작년 말에 GluonNLP 0.6버전 개발에 활발하게 참여하였는데, 그중에서 사용자들이 편리하게 사용할만한 부분에 대해 소개하기 위해 글을 써봤다. 다들 버트, 버트 하는데, 어떻게 사용할지 모를 분들에게 도움이 될 것이라 예상해 본다.
이 글은 MXNet-Gluon 기반으로 설명이 된다. 최근 훌륭한 한글 자료가 인터넷에 나왔으니 관심 있으신 분들은 먼저 참고하시길 바란다.
버트(BERT)
인간은 직접 혹은 간접 경험을 통해 특정 상황이나 문제에 대한 가장 효과적인 해결책을 찾을 수 있는 지혜를 얻을 수 있다. 그러한 이유 때문에 경험을 중요시 여기며 모든 경험을 할수는 간접 경험의 하나인 독서를 강조하곤 한다. NLP 영역에서 인기있는 기술인 언어모델 전이학습은 바로 이러한 컨셉을 적용한 학습전략이다. 언어모델이 어떠한 방식으로 사용될지 확실치 않더라도 미리 양질의 방대한 양의 학습데이터로 미리 학습을 시켜두고 이렇게 학습된 모델을 간단하게 튜닝해 다른 목적으로 활용하는 것이다.
버트(BERT)는 현존하는 가장 강력한 NLP 언어모델로 다양한 NLP테스크에서 가장 좋은 성능을 보여주고 있다.
이 글에서는 버트를 기반으로 대표적인 한글 코퍼스인 네이버 무비리뷰 분류기 성능을 높여보도록 하겠다. 이글에서는 예측 성능에 대한 이야기 보다는 얼마나 간단하게 버트를 한글 코퍼스에 활용할 수 있는지에 대해서 중점적으로 이야기해보겠다. 기존의 버트관련 좋은 글이 많이 나왔기 때문에 버트에 대한 자세한 설명은 해당글을 참고하길 바란다.
데이터 전처리
버트가 기존의 방법론 대비 활용하기 어려운 주요한 이유중에 하나는 아래와 같이 다양한 입력을 받아야되기 때문이다. 대부분 구현체들은 이들에 대한 일반화를 하지 않아 별도의 모형을 구축할때 구현이 까다롭다.
버트는 입력 문장에 대해서 아래와 같은 작업을 요구한다.
- 각 토큰의 Vocabulary 인덱스를 추출해 이를 정해진 길이의 벡터로 생성.
- 두 문장 혹은 하나의 문장이 들어올 수 있기 때문에 이들을 구분하기 위한 토큰 타입 벡터 생성
- 유효 길이 벡터
토큰 인덱스 : ‘[CLS] is this jack ##son ##ville ? [SEP] no it is not .[SEP]’
토큰 타입: 0 0 0 0 0 0 0 0 1 1 1 1 1 1
유효길이: 14
토큰인덱스는 Token Embedding을 생성하는데 필요하고, 토큰 타입은 Sentence Embedding, 유효길이는 내부적으로 여러 연산을 하는데 필요하다. Positional Embedding은 입력의 길이 정보만 알수 있다면 학습/추론시 자동 생성 가능한 벡터이다.
GluonNLP는 위 작업을 매우 편하게 진행해주는 함수(BERTSentenceTransform)를 제공하고 있다.
먼저 적절한 버트 모델을 로딩한다(구글에서 공개한 multilingual 모델을 사용해야 한글 문제에 적용할 수 있다).
bert_base, vocabulary = nlp.model.get_model('bert_12_768_12',
dataset_name='wiki_multilingual_cased',
pretrained=True, ctx=ctx, use_pooler=True,
use_decoder=False, use_classifier=False)
ds = gluon.data.SimpleDataset([['나 보기가 역겨워', '김소월']])
tok = nlp.data.BERTTokenizer(vocab=vocabulary, lower=False)
trans = nlp.data.BERTSentenceTransform(tok, max_seq_length=10)
list(ds.transform(trans)
[(array([ 2, 8982, 9356, 47869, 9566, 3, 8935, 22333, 38851,
3], dtype=int32),
array(10, dtype=int32),
array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1], dtype=int32))]
입력에 대한 전처리 부분을 제외하고 우리가 진행해야 될 부분은 무비리뷰에 대한 긍/부정 레이블을 처리하는 것이다. 모든 학습 데이터는 배치단위로 입/출력이 정의되기 때문에 입력 데이터 처리와 레이블 처리를 동시에 배치 출력하기 위해 Dataset 클래스를 정의하면 아래와 같다. Dataset 클래스는 PyTorch의 Dataset과 동일한 형태를 띈다.
class BERTDataset(Dataset):
def __init__(self, dataset, sent_idx, label_idx, bert_tokenizer, max_len,
pad, pair):
transform = nlp.data.BERTSentenceTransform(
bert_tokenizer, max_seq_length=max_len, pad=pad, pair=pair)
sent_dataset = gluon.data.SimpleDataset([[
i[sent_idx],
] for i in dataset])
self.sentences = sent_dataset.transform(transform)
self.labels = gluon.data.SimpleDataset(
[np.array(np.int32(i[label_idx])) for i in dataset])
def __getitem__(self, i):
return (self.sentences[i] + (self.labels[i], ))
def __len__(self):
return (len(self.labels))
위에서 로딩한 버트 모델은 인코더 부분이다. 인코더 위에 분류기를 붙여야 긍/부정을 학습할 수 있을 것이다. 버트 논문에서는 하나의 문장이 입력될때 분류기는 아래와 같이 구성하는걸 제안하고 있다.
모든 버트 모델은 첫 클래스 레이블을 pooler 라고 네이밍 하고 있고 이 pooler 결과를 받아 FC(fully connected) 레이어 하나를 추가해 간단하게 구성한다.
class BERTClassifier(nn.Block):
def __init__(self,
bert,
num_classes=2,
dropout=None,
prefix=None,
params=None):
super(BERTClassifier, self).__init__(prefix=prefix, params=params)
self.bert = bert
with self.name_scope():
self.classifier = nn.HybridSequential(prefix=prefix)
if dropout:
self.classifier.add(nn.Dropout(rate=dropout))
self.classifier.add(nn.Dense(units=num_classes))
def forward(self, inputs, token_types, valid_length=None):
_, pooler_out = self.bert(inputs, token_types, valid_length)
return self.classifier(pooler_out)
나머지 부분은 일반적인 학습 모듈과 크게 다르지 않다. 아래는 학습 로그를 찍어본 것이다.
[Epoch 1 Batch 50/2344] loss=8.4847, lr=0.0000026681, acc=0.556
[Epoch 1 Batch 100/2344] loss=7.6343, lr=0.0000053362, acc=0.612
….
[Epoch 1 Batch 2250/2344] loss=4.5963, lr=0.0000422197, acc=0.805
[Epoch 1 Batch 2300/2344] loss=4.2460, lr=0.0000419234, acc=0.806
Test Acc : 0.84662
[Epoch 2 Batch 50/2344] loss=4.4179, lr=0.0000413664, acc=0.846
[Epoch 2 Batch 100/2344] loss=4.4742, lr=0.0000410702, acc=0.843
….
[Epoch 2 Batch 2200/2344] loss=3.4934, lr=0.0000286265, acc=0.869
[Epoch 2 Batch 2250/2344] loss=3.5244, lr=0.0000283302, acc=0.869
[Epoch 2 Batch 2300/2344] loss=3.1572, lr=0.0000280339, acc=0.869
Test Acc : 0.86312
[Epoch 3 Batch 50/2344] loss=3.4649, lr=0.0000274769, acc=0.885
[Epoch 3 Batch 100/2344] loss=3.4550, lr=0.0000271806, acc=0.885
….
[Epoch 3 Batch 2250/2344] loss=2.8383, lr=0.0000144406, acc=0.901
[Epoch 3 Batch 2300/2344] loss=2.4659, lr=0.0000141443, acc=0.901
Test Acc : 0.86686
[Epoch 4 Batch 50/2344] loss=2.6485, lr=0.0000135873, acc=0.919
[Epoch 4 Batch 100/2344] loss=2.6904, lr=0.0000132911, acc=0.916
….
[Epoch 4 Batch 2250/2344] loss=2.3454, lr=0.0000005511, acc=0.924
[Epoch 4 Batch 2300/2344] loss=2.0258, lr=0.0000002548, acc=0.925
Test Acc : 0.87136
성능
일반적으로 네이버 무비 리뷰 문제로 모델을 생성시 83~85% 정도의 정확도를 보인다고 알려져 있다. 간단하게 4 에폭 파인튜닝 후 최종 성능은 0.871이다. 물론 더 학습할 경우 성능이 오를 가능성이 있으니 좀더 학습해 보는 것도 괜찮을 것이다.
테스크(한국어) 특화 pre-training의 필요성
위 성능은 테스크 특화의 아무런 튜닝을 하지 않은 상황에서 좋은 성능이나, 버트를 쓰지 않아도 달성 가능한 성능(fasttext + LSTM + Attention)으로 고무적인 성능은 아니다. 이런 성능의 주된 이유는 한국어 특화된 버트 모형을 사용하지 않아서이다. 필자의 경험으로 한국어만 학습한 버트 모형을 해당 문제에 적용했을때 이 문제에서 0.90 이상의 정확도를 보임을 경험했고, 학습(pre-training)을 지속할수록 성능이 올라갈 수 있음을 확인했다. 이는 자신의 테스크에 잘 동작하는 버트 모형의 학습(pre-training)이 필요할 수 있음을 시사하고 있고, 아직 공개적인 한국어 버트모델이 나오지 않은 상황에서 더 필요한 작업이라 볼 수 있다.
마치며
이 글에서는 GluonNLP에서 제공하는 버트 모형을 이용해 간단하게 한국어 관련 모델을 학습해 봤다. GluonNLP는 버트를 간단하게 로딩하는 인터페이스를 제공하고 있고, 이들이 요구하는 형태로 데이터를 전처리 하는 API를 제공하고 있어 활용하기 복잡한 버트 모형을 다양한 문제에 간단하게 활용 가능하게 한다. 이곳에서 활용한 전체 코드는 여기에 공개해 두었다.
버트(BERT) 파인튜닝 간단하게 해보자. by from __future__ import dream is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.