Info


준비

패키지 및 데이터 다운로드

  • sklearn, torch, keras, transformers, numpy, pandas가 필요합니다.
    1. transformers: BERT 이용 메인 API
    2. torch: 파인튜닝 실제 학습용 딥러닝 프레임워크
    3. numpy 및 pandas: 전처리 목적
    4. sklearn: train_test_split 사용 목적
    5. keras: pad_sequences 사용 목적
  • 위 항목 중 bold한 항목을 제외하고는 쓰임새에 맞게 다른 라이브러리로 구현해도 상관없습니다.
  • 이 중 자신의 컴퓨터에 없는 패키지를 설치합니다.
  • google colab으로 실습하고 있다면 나머지는 이미 설치되어 있으므로 transformers만 설치합니다.
!pip install transformers

주요 패키지 불러오기

필요한 패키지를 import해줍니다.
제일 중요한 건 파이토치와 트랜스포머입니다.

import torch

from transformers import BertTokenizer
from transformers import BertForSequenceClassification, AdamW, BertConfig
from transformers import get_linear_schedule_with_warmup
from torch.utils.data import TensorDataset, DataLoader, RandomSampler, SequentialSampler
from keras.preprocessing.sequence import pad_sequences
from sklearn.model_selection import train_test_split

import pandas as pd
import numpy as np
import random
import time
import datetime

GPU 확인

Torch에서 GPU가 사용되는지 확인하는 코드입니다.
Colab 사용시에도 어떤 GPU를 쓰는지 알 수 있습니다.

import os

n_devices = torch.cuda.device_count()
print(n_devices)

for i in range(n_devices):
    print(torch.cuda.get_device_name(i))

네이버 영화 리뷰데이터 다운로드

!git clone https://github.com/e9t/nsmc.git 

git clone을 하면 현재 파이썬 파일 혹은 Jupyter notebook이 있는 자리에 nsmc라는 이름의 디렉토리가 생성되었을 것입니다.


train, test 각각 로드

해당 디렉토리 내의 train, test 텍스트 파일을 불러옵니다.

train = pd.read_csv("nsmc/ratings_train.txt", sep='\t')
test = pd.read_csv("nsmc/ratings_test.txt", sep='\t')

print(train.shape)
print(test.shape)

출력하면 output으로 각각 (150000, 3), (50000, 3)이 출력됩니다.
전체의 25%가 테스트셋으로 배분되어있는 데이터입니다.


전처리

문장별 전처리

  • BERT 분류모델은 각 문장의 앞마다 [CLS]를 붙여 인식합니다.
  • 문장 종료는 [SEP]으로 알립니다.
  • 저는 list comprehension을 선호하여 그렇게 처리했지만, for loop을 명시적으로 사용해도 상관없습니다.
document_bert = ["[CLS] " + str(s) + " [SEP]" for s in train.document]
document_bert[:5]
  • output
['[CLS] 아 더빙.. 진짜 짜증나네요 목소리 [SEP]',
 '[CLS] 흠...포스터보고 초딩영화줄....오버연기조차 가볍지 않구나 [SEP]',
 '[CLS] 너무재밓었다그래서보는것을추천한다 [SEP]',
 '[CLS] 교도소 이야기구먼 ..솔직히 재미는 없다..평점 조정 [SEP]',
 '[CLS] 사이몬페그의 익살스런 연기가 돋보였던 영화!스파이더맨에서 늙어보이기만 했던 커스틴 던스트가 너무나도 이뻐보였다 [SEP]']

토크나이징

  • 사전학습된 BERT multilingual 모델 내 포함되어있는 토크나이저를 활용하여 토크나이징합니다.
tokenizer = BertTokenizer.from_pretrained('bert-base-multilingual-cased', do_lower_case=False)
tokenized_texts = [tokenizer.tokenize(s) for s in document_bert]
print(tokenized_texts[0])
  • output
['[CLS]', '아', '더', '##빙', '.', '.', '진', '##짜', '짜', '##증', '##나', '##네', '##요', '목', '##소', '##리', '[SEP]']

패딩

  • token들의 max length보다 크게 MAX_LEN을 설정합니다.
  • 설정한 MAX_LEN 만큼 빈 공간을 0이 채웁니다.
MAX_LEN = 128
input_ids = [tokenizer.convert_tokens_to_ids(x) for x in tokenized_texts]
input_ids = pad_sequences(input_ids, maxlen=MAX_LEN, dtype='long', truncating='post', padding='post')
input_ids[0]
  • output
array([   101,   9519,   9074, 119005,    119,    119,   9708, 119235,
         9715, 119230,  16439,  77884,  48549,   9284,  22333,  12692,
          102,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0,
            0,      0,      0,      0,      0,      0,      0,      0])

어텐션 마스크

  • 학습속도를 높이기 위해 실 데이터가 있는 곳과 padding이 있는 곳을 attention에게 알려줍니다.
attention_masks = []

for seq in input_ids:
    seq_mask = [float(i>0) for i in seq]
    attention_masks.append(seq_mask)
    
print(attention_masks[0])
  • output
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

train - validation set 분리

  • input과 mask가 뒤섞이지 않도록 random_state를 일정하게 고정합니다.
  • test set은 위에서 이미 분리되었기에 train과 validation set만을 분리합니다.
  • random state는 42가 국룰입니다.
train_inputs, validation_inputs, train_labels, validation_labels = \
train_test_split(input_ids, train['label'].values, random_state=42, test_size=0.1)

train_masks, validation_masks, _, _ = train_test_split(attention_masks, 
                                                       input_ids,
                                                       random_state=42, 
                                                       test_size=0.1)

파이토치 텐서로 변환

  • numpy ndarray로 되어있는 input, label, mask들을 torch tensor로 변환합니다.
train_inputs = torch.tensor(train_inputs)
train_labels = torch.tensor(train_labels)
train_masks = torch.tensor(train_masks)
validation_inputs = torch.tensor(validation_inputs)
validation_labels = torch.tensor(validation_labels)
validation_masks = torch.tensor(validation_masks)

배치 및 데이터로더 설정

  • 현재 쓰고 있는 GPU의 VRAM에 맞도록 배치사이즈를 설정합니다.
  • 우선 배치사이즈를 크게 넣어보고 VRAM 부족 메시지가 나오면 8의 배수 중 더 작은 것으로 줄여나가는 것이 일반적인 방법입니다.
BATCH_SIZE = 32

train_data = TensorDataset(train_inputs, train_masks, train_labels)
train_sampler = RandomSampler(train_data)
train_dataloader = DataLoader(train_data, sampler=train_sampler, batch_size=BATCH_SIZE)

validation_data = TensorDataset(validation_inputs, validation_masks, validation_labels)
validation_sampler = SequentialSampler(validation_data)
validation_dataloader = DataLoader(validation_data, sampler=validation_sampler, batch_size=BATCH_SIZE)

테스트셋 전처리

  • 위의 train-val 셋 전처리와 동일합니다.
  • train 전처리시 함께 처리해도 무방합니다.
sentences = test['document']
sentences = ["[CLS] " + str(sentence) + " [SEP]" for sentence in sentences]
labels = test['label'].values

tokenized_texts = [tokenizer.tokenize(sent) for sent in sentences]

input_ids = [tokenizer.convert_tokens_to_ids(x) for x in tokenized_texts]
input_ids = pad_sequences(input_ids, maxlen=MAX_LEN, dtype="long", truncating="post", padding="post")

attention_masks = []
for seq in input_ids:
    seq_mask = [float(i>0) for i in seq]
    attention_masks.append(seq_mask)

test_inputs = torch.tensor(input_ids)
test_labels = torch.tensor(labels)
test_masks = torch.tensor(attention_masks)

test_data = TensorDataset(test_inputs, test_masks, test_labels)
test_sampler = RandomSampler(test_data)
test_dataloader = DataLoader(test_data, sampler=test_sampler, batch_size=BATCH_SIZE)

모델 학습

GPU 체크 및 할당

  • GPU 8개가 있고 이 중 0번째 GPU TITAN X를 사용합니다.
  • CPU를 사용해도 학습 가능합니다. (거짓말)
if torch.cuda.is_available():    
    device = torch.device("cuda")
    print('There are %d GPU(s) available.' % torch.cuda.device_count())
    print('We will use the GPU:', torch.cuda.get_device_name(0))
else:
    device = torch.device("cpu")
    print('No GPU available, using the CPU instead.')
  • output
Found GPU at: /device:GPU:0
There are 8 GPU(s) available.
We will use the GPU: TITAN X (Pascal)

분류를 위한 BERT 모델 생성

  • transformers의 BertForSequenceClassification 모듈을 이용합니다.
  • 이진분류이기 때문에 num_labels는 2로 둡니다.
model = BertForSequenceClassification.from_pretrained("bert-base-multilingual-cased", num_labels=2)
model.cuda()
  • output
BertForSequenceClassification(
  (bert): BertModel(
    (embeddings): BertEmbeddings(
      (word_embeddings): Embedding(119547, 768, padding_idx=0)
      (position_embeddings): Embedding(512, 768)
      (token_type_embeddings): Embedding(2, 768)
      (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
      (dropout): Dropout(p=0.1, inplace=False)
    )
# 이하 출력은 생략합니다.

학습스케쥴링

  • transformers에서 제공하는 옵티마이저 중 AdamW를 사용합니다.
  • 총 훈련 스텝은 이터레이션 * 에폭수로 설정해둡니다.
  • 러닝 레잇 스케쥴러는 역시 transformers에서 제공하는 것을 사용합니다.
# 옵티마이저 설정
optimizer = AdamW(model.parameters(),
                  lr = 2e-5, # 학습률
                  eps = 1e-8 # 0으로 나누는 것을 방지하기 위한 epsilon 값
                )

# 에폭수
epochs = 4

# 총 훈련 스텝
total_steps = len(train_dataloader) * epochs

# lr 조금씩 감소시키는 스케줄러
scheduler = get_linear_schedule_with_warmup(optimizer, 
                                            num_warmup_steps = 0,
                                            num_training_steps = total_steps)

학습

accuracy와 시간 표시함수 정의

# 정확도 계산 함수
def flat_accuracy(preds, labels):
    pred_flat = np.argmax(preds, axis=1).flatten()
    labels_flat = labels.flatten()
    return np.sum(pred_flat == labels_flat) / len(labels_flat)

# 시간 표시 함수
def format_time(elapsed):
    # 반올림
    elapsed_rounded = int(round((elapsed)))
    # hh:mm:ss으로 형태 변경
    return str(datetime.timedelta(seconds=elapsed_rounded))

학습 실행부분

  • 데이터로더에서 배치만큼 가져온 후 forward, backward pass를 수행합니다.
  • gradient update는 명시적으로 하지 않고 위에서 로드한 optimizer를 활용합니다.
# 재현을 위해 랜덤시드 고정
seed_val = 42
random.seed(seed_val)
np.random.seed(seed_val)
torch.manual_seed(seed_val)
torch.cuda.manual_seed_all(seed_val)

# 그래디언트 초기화
model.zero_grad()

# 에폭만큼 반복
for epoch_i in range(0, epochs):
    
    # ========================================
    #               Training
    # ========================================
    
    print("")
    print('======== Epoch {:} / {:} ========'.format(epoch_i + 1, epochs))
    print('Training...')

    # 시작 시간 설정
    t0 = time.time()

    # 로스 초기화
    total_loss = 0

    # 훈련모드로 변경
    model.train()
        
    # 데이터로더에서 배치만큼 반복하여 가져옴
    for step, batch in enumerate(train_dataloader):
        # 경과 정보 표시
        if step % 500 == 0 and not step == 0:
            elapsed = format_time(time.time() - t0)
            print('  Batch {:>5,}  of  {:>5,}.    Elapsed: {:}.'.format(step, len(train_dataloader), elapsed))

        # 배치를 GPU에 넣음
        batch = tuple(t.to(device) for t in batch)
        
        # 배치에서 데이터 추출
        b_input_ids, b_input_mask, b_labels = batch

        # Forward 수행                
        outputs = model(b_input_ids, 
                        token_type_ids=None, 
                        attention_mask=b_input_mask, 
                        labels=b_labels)
        
        # 로스 구함
        loss = outputs[0]

        # 총 로스 계산
        total_loss += loss.item()

        # Backward 수행으로 그래디언트 계산
        loss.backward()

        # 그래디언트 클리핑
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

        # 그래디언트를 통해 가중치 파라미터 업데이트
        optimizer.step()

        # 스케줄러로 학습률 감소
        scheduler.step()

        # 그래디언트 초기화
        model.zero_grad()

    # 평균 로스 계산
    avg_train_loss = total_loss / len(train_dataloader)            

    print("")
    print("  Average training loss: {0:.2f}".format(avg_train_loss))
    print("  Training epcoh took: {:}".format(format_time(time.time() - t0)))
        
    # ========================================
    #               Validation
    # ========================================

    print("")
    print("Running Validation...")

    #시작 시간 설정
    t0 = time.time()

    # 평가모드로 변경
    model.eval()

    # 변수 초기화
    eval_loss, eval_accuracy = 0, 0
    nb_eval_steps, nb_eval_examples = 0, 0

    # 데이터로더에서 배치만큼 반복하여 가져옴
    for batch in validation_dataloader:
        # 배치를 GPU에 넣음
        batch = tuple(t.to(device) for t in batch)
        
        # 배치에서 데이터 추출
        b_input_ids, b_input_mask, b_labels = batch
        
        # 그래디언트 계산 안함
        with torch.no_grad():     
            # Forward 수행
            outputs = model(b_input_ids, 
                            token_type_ids=None, 
                            attention_mask=b_input_mask)
        
        # 로스 구함
        logits = outputs[0]

        # CPU로 데이터 이동
        logits = logits.detach().cpu().numpy()
        label_ids = b_labels.to('cpu').numpy()
        
        # 출력 로짓과 라벨을 비교하여 정확도 계산
        tmp_eval_accuracy = flat_accuracy(logits, label_ids)
        eval_accuracy += tmp_eval_accuracy
        nb_eval_steps += 1

    print("  Accuracy: {0:.2f}".format(eval_accuracy/nb_eval_steps))
    print("  Validation took: {:}".format(format_time(time.time() - t0)))

print("")
print("Training complete!")

출력부분

  • 모든 Epoch를 학습하면 학습이 종료됩니다.
======== Epoch 1 / 4 ========
Training...
/pytorch/torch/csrc/utils/python_arg_parser.cpp:749: UserWarning: This overload of add_ is deprecated:
	add_(Number alpha, Tensor other)
Consider using one of the following signatures instead:
	add_(Tensor other, Number alpha)
  Batch   500  of  4,219.    Elapsed: 0:03:01.
  Batch 1,000  of  4,219.    Elapsed: 0:06:03.
  Batch 1,500  of  4,219.    Elapsed: 0:09:04.
  Batch 2,000  of  4,219.    Elapsed: 0:12:07.
  Batch 2,500  of  4,219.    Elapsed: 0:15:09.
  Batch 3,000  of  4,219.    Elapsed: 0:18:12.
  Batch 3,500  of  4,219.    Elapsed: 0:21:15.
  Batch 4,000  of  4,219.    Elapsed: 0:24:18.

  Average training loss: 0.38
  Training epcoh took: 0:25:37
.
.
.
Training complete!

테스트

테스트셋 평가

#시작 시간 설정
t0 = time.time()

# 평가모드로 변경
model.eval()

# 변수 초기화
eval_loss, eval_accuracy = 0, 0
nb_eval_steps, nb_eval_examples = 0, 0

# 데이터로더에서 배치만큼 반복하여 가져옴
for step, batch in enumerate(test_dataloader):
    # 경과 정보 표시
    if step % 100 == 0 and not step == 0:
        elapsed = format_time(time.time() - t0)
        print('  Batch {:>5,}  of  {:>5,}.    Elapsed: {:}.'.format(step, len(test_dataloader), elapsed))

    # 배치를 GPU에 넣음
    batch = tuple(t.to(device) for t in batch)
    
    # 배치에서 데이터 추출
    b_input_ids, b_input_mask, b_labels = batch
    
    # 그래디언트 계산 안함
    with torch.no_grad():     
        # Forward 수행
        outputs = model(b_input_ids, 
                        token_type_ids=None, 
                        attention_mask=b_input_mask)
    
    # 로스 구함
    logits = outputs[0]

    # CPU로 데이터 이동
    logits = logits.detach().cpu().numpy()
    label_ids = b_labels.to('cpu').numpy()
    
    # 출력 로짓과 라벨을 비교하여 정확도 계산
    tmp_eval_accuracy = flat_accuracy(logits, label_ids)
    eval_accuracy += tmp_eval_accuracy
    nb_eval_steps += 1

print("")
print("Accuracy: {0:.2f}".format(eval_accuracy/nb_eval_steps))
print("Test took: {:}".format(format_time(time.time() - t0)))

테스트 출력

  Batch   100  of  1,563.    Elapsed: 0:00:11.
  Batch   200  of  1,563.    Elapsed: 0:00:22.
  Batch   300  of  1,563.    Elapsed: 0:00:34.
  Batch   400  of  1,563.    Elapsed: 0:00:45.
  Batch   500  of  1,563.    Elapsed: 0:00:56.
  Batch   600  of  1,563.    Elapsed: 0:01:07.
  Batch   700  of  1,563.    Elapsed: 0:01:18.
  Batch   800  of  1,563.    Elapsed: 0:01:29.
  Batch   900  of  1,563.    Elapsed: 0:01:40.
  Batch 1,000  of  1,563.    Elapsed: 0:01:52.
  Batch 1,100  of  1,563.    Elapsed: 0:02:03.
  Batch 1,200  of  1,563.    Elapsed: 0:02:14.
  Batch 1,300  of  1,563.    Elapsed: 0:02:25.
  Batch 1,400  of  1,563.    Elapsed: 0:02:36.
  Batch 1,500  of  1,563.    Elapsed: 0:02:47.

Accuracy: 0.87
Test took: 0:02:54

문장 앞뒤에 구분자 토큰을 넣는 것 외에 별다른 전처리를 하지 않고도 epoch 4회만에 테스트셋 acc 0.87이 나왔습니다.
BERT 임베딩의 강력함을 알 수 있습니다.
multilingual model이 아닌 kobert 등 한국어 코퍼스를 중점으로 학습한 모델을 사용하면 95% 이상의 결과도 낼 수 있다고 합니다.

이 다음으로는 유사한 태스크인 IMDB 데이터도 실습해보겠습니다.