NLP 기초

[NLP-2] 텍스트 전처리하기 - 토크나이저

빈이름 2024. 2. 28. 23:16

텍스트를 숫자로 바꾸는 과정을 토큰화라고 합니다. 이 때 텍스트를 숫자로 바꾸기 위해서 텍스트를 일정 단위로 끊어서 숫자에 매핑하게 되는데, 텍스트를 어떤 기준으로 끊을 것인가도 NLP에 있어서 중요한 부분 중 하나입니다. 여러가지 토크나이저를 살펴본 뒤 어떤 토크나이저가 본인의 데이터셋에 적합할지 생각해 보도록 합시다.

1. 토큰화의 단위
    1-1. character
    1-2. word
    1-3. subword
2. Subword 토크나이저
    2-1. BPE (Byte-Pair-Encoding)
    2-2. Wordpiece
    2-3. Unigram
3. 토크나이저 실험 실습
    3-1. Sentencepiece 토크나이저
    3-2. character 토크나이저
    3-3. mecab 토크나이저
    3-4. 토크나이저 별 학습 결과 비교

1. 토큰화의 단위

텍스트를 자르는 기준은 크게 charactor, word, subword 단위로 볼 수 있습니다.

1-1. character

첫 예제에서 했던 것과 같이 글자 단위로 토큰화 하는 것을 말합니다.

"오늘은 날씨가 좋지 않다" >>> ["오", "늘", "은", "날", "씨", "가", "좋", "지", "않", "다"]

characer 토큰화의 장점은 토큰화가 간단하단 것입니다. 그냥 for문을 돌리며 중복만 확인하고 앞 글자부터 하나씩 숫자에 매핑하면 되기 때문입니다. 또 알파벳과 같이 문자 수가 적은 언어의 경우 모든 글자를 vocab에 추가할 수 있어 out of vocabulary 문제(vocab에 등록되지 않아 <unk> 토큰이 발생하는 문제)가 잘 발생하지 않습니다.

단점은 단어가 글자로 쪼개지기 때문에 모델이 토큰의 의미를 파악하기 어려울 수 있다는 문제가 있습니다.

1-2. word

word는 단어 단위로 토큰화를 수행하는 것입니다. 문장에서 단어를 파악하는 방법은 띄어쓰기를 기준으로 잘라서 토큰화하는 것입니다.

"오늘은 날씨가 좋지 않다" >>> ["오늘은", "날씨가", "좋지", "않다"]

영어의 경우 띄어쓰기를 기준으로 문장을 나누면 단어 별로 잘 나눠지기 때문에 적용이 가능합니다.

"good morning" >>> ["good", "morning"]

하지만 한글은 띄어쓰기를 기준으로 단어를 나눠도 확실히 단어 단위로 분리되지 않죠. 거의 모든 단어가 다른 조사와 결합되어 사용되기 때문입니다.

"오늘은" >>> "오늘 + 은" , "날씨가" >>> "날씨 + 가"

word 토크나이저를 사용하는 이유는 단어를 토큰으로 치환하기 때문에 모델이 단어의 의미를 파악하는데 유용하다는 장점을 갖기 때문입니다. 하지만 단순히 띄어쓰기를 기준으로 하기에는 단어가 제대로 분리되지도 않고, 같은 단어라도 조사에 따라 형태가 수십가지가 될 수 있기 때문에 vocab도 너무 커지게 됩니다.

["오늘은", "오늘도", "오늘만", "오늘이" ...] 등 '오늘'이라는 단어도 너무 많은 형태를 가질 수 있습니다.

1-3. subword

앞선 word 토크나이저의 단점을 보완할 수 있는 것이 subword 토크나이저입니다. character보단 크지만 word 보단 작은 단위로 토큰화를 수행합니다.

"오늘은 날씨가 좋지 않다." >>> ["오늘", "은", "날씨", "가", "좋", "지", "않", "다"]

"오늘은"과 같은 단어도 {"오늘" + "은"}으로 분리할 수 있기 때문에 vocab이 지나치게 커질 수 있는 word 토크나이저의 단점을 보완할 수 있으며, 띄어쓰기가 없더라도 단어 분리가 가능하기까지 합니다. 그러면서 자주 쓰이는 글자끼리 묶일 수 있기 때문에 토큰에 의미를 보유하기도 character 토크나이저보다 수월합니다. 그렇기 때문에 가장 자주 사용되는 토크나이저이기도 합니다.

 

문제는 단어를 어떻게 분리할 것인가 하는 규칙을 다시 정해야 한다는 겁니다. 그래서 지금부터는 subword 토크나이저를 만드는 방법들에 대해서 몇가지 알아 보도록 하겠습니다.

2. Subword 토크나이저

2-1. BPE (Byte Pair Encoding)

BPE는 character 토큰에서 시작해서 점점 글자를 하나씩 결합하며 단어를 하나씩 추가하는 방식입니다. 예를 들어 어떤 텍스트 문서에서 단어들이 아래와 같은 빈도 수로 나타났다고 가정하고 BPE를 수행해 보겠습니다.

단어 low lower newst widest
빈도 수 5 2 6 3

 

1. 우선 단어에 사용된 모든 character들을 vocab에 등록합니다.

vocab = ["l", "o", "w", "e", "r", "s", "t", "i", "d"]

 

2. vocab의 글자들을 2개씩 묶은 뒤, 각 글자쌍들의 빈도 수를 다시 파악합니다.

단어 빈도수 단어 빈도수 단어 빈도수
lo 7 ow 7 we 8
er 2 ne 6 ew 6
es 9 st 9 wi 3
id 3        

 

3. "es"와 "st"가 9회로 가장 많이 등장했기 때문에 먼저 등장한 "es"를 vocab에 추가합니다.

vocab = ["l", "o", "w", "e", "r", "s", "t", "i", "d", "es"]

 

4. 새로운 단어 "es"를 추가한 뒤 다시 2개씩 글자 쌍을 묶어 빈도 수를 파악합니다.

단어 빈도수 단어 빈도수 단어 빈도수
lo 7 ow 7 we 2
er 2 ne 6 ew 6
wi 3 id 3 de 3
wes 6 est 9 des 3

 

5. "est"가 9회로 가장 많이 등장했기 때문에 vocab에 "est"를 추가합니다.

vocab = ["l", "o", "w", "e", "r", "s", "t", "i", "d", "es", "est"]

6. 이런 식으로 지정한 횟수 만큼 반복해서 vocab을 완성합니다.

2-2. WordPiece

Wordpiece는 BPE에서 몇가지가 더 추가된 방식입니다. 우선 글자를 첫번째 글자와 그 뒤에 이어지는 글자로 구분합니다. (이어지는 글자는 앞에 '##'을 붙여서 구분) 그렇기 때문에 "w"와 "##w"는 서로 다른 글자입니다.

단어 low lower newst widest
빈도 수 5 2 6 3
vocab = ["l", "##o", "##w", "##e", "##r", "n", "##s", "##t", "w", "##d", "##i"]

character 단위에서 시작해 단어를 하나씩 추가하는 방식은 bpe와 동일하나 빈도 수가 아니라 wordpiece만의 score를 계산해서 가장 score가 높은 단어를 추가합니다.

$$score={{(\text{freq-of-pair})}\over{(\text{freq-of-first-element})\text{x}(\text{freq-of-second-element})}}$$

단어쌍의 빈도 수를 해당 단어 쌍을 이루는 단어들의 빈도수의 곱으로 나눈 것이 점수가 됩니다. 예를 들면 "lo"의 경우,

$$score={{7(\text{"lo"})}\over{7(\text{"l"}) \text{x} 7(\text{"##o"})}}={1\over7}$$

"lo" 단어 쌍의 등장 빈도는 7회이고, "l"의 등장빈도도 7, "##o"의 등장빈도도 7회이므로 최종 점수는 7/(7*7)=1/7이 됩니다. bpe에서 가장 높은 빈도 수를 보였던 "es"의 경우,

$$score={{9(\text{"es"})}\over{17(\text{"##e"}) \text{x} 9(\text{"##s"})}}={1\over17}$$

1/17점으로 점수가 낮아진 것을 확인할 수 있습니다.

 

wordpiece의 점수 계산식을 보면 알 수 있듯, 단어 쌍의 빈도 수가 많을수록, 단어 쌍을 이루는 단어들이 적게 등장할수록 score가 높아지게 됩니다. 즉 자주 사용되지 않는 단어들이 모일수록 vocab에 등록될 확률이 높아집니다. 자주 사용되는 단어 조합일수록 해당 단어 조합이 어떤 의미를 가질 확률이 높기 때문에 최대한 다른 단어와 결합되지 않고 사용되도록 하는 것입니다. 반대로 자주 사용되지 않는 단어들일수록 빨리 더 긴 단어로 결합되어 의미를 갖는 단어가 되도록 하는 것입니다.

<"unpredictible">
"un"과 "predictible"의 경우 둘 다 자주 사용되는 단어이기 때문에 "unpredictible"이라는 하나의 단어로 묶일 확률이 낮다.

<"hope">
"ho"와 "pe"는 따로 사용되지 않는 단어이기 때문에 빈도 수가 적을 것이고 따라서 "hope"라는 하나의 단어로 묶일 확률이 높다.

2-3. Unigram

Subword를 만드는 또 다른 방법으로는 Unigram 방식이 있습니다. Unigram은 BPE와 반대로 vocabulary를 최대한 채운 뒤 필요 없는 단어들을 제거해 나가는 방식으로 진행됩니다. 이 때 단어를 제거하기 전의 초기 vocabulary는 BPE나 mecab 형태소 분석기 등을 이용해 구성합니다.

 

우선은 unigram 방식의 토큰화 방법을 먼저 살펴보겠습니다. 토크나이저를 학습하려는 text corpus에서 아래 단어들이 다음과 같은 빈도 수로 등장했다고 가정해 봅시다.

단어 "low" "lower"
빈도 수 5 2

그리고 vocabulary가 아래와 같이 완성되었다고 하겠습니다.

vocab = ["l", "o", "w", "e", "r", "lo", "ow", "low"]

Unigram은 각 vocab의 단어들이 사용된 비율을 계산합니다.

단어 빈도수 단어 빈도수 단어 빈도수
l 7 / 46 o 7 / 46 w 7 / 46
e 2 / 46 r 2 / 46 lo 7 / 46
ow 7 / 46 low 7 / 46    

전체 단어들이 사용된 횟수는 총 46회이며, "ow"의 경우 7회 사용되었기 때문에 "ow"의 확률값은 7/46이 되는 식입니다.

"low"라는 단어를 토큰화할 땐 이 단어를 vocab의 단어들을 조합했을 때 그 확률값이 가장 큰 지에 따라서 토큰 단어를 결정합니다. 예를 들면 아래와 같습니다.

1) "l" + "o" + "w" : 7/46 * 7/46 * 7/46
2) "lo" + "w" : 7/46 * 7/46
3) "l" + "ow" : 7/46 * 7/46
4) "low" : 7/46

"low"라는 단어를 토큰으로 구성할 수 있는 경우 가짓수는 위와 같습니다. 각 조합의 확률값은 조합을 이루는 단어들의 확률값들을 모두 곱한 값이 됩니다. 위의 경우 "low"라는 토큰 하나로 토큰화할 때 확률값이 가장 높기 때문에 "low"는 그대로 "low로 토큰화되게 됩니다.

'단어를 이루는 토큰들의 곱이 가장 큰 조합을 사용하겠다' 라는 어려운 말은, 쉽게 생각하면 단어를 '최대한 적은 토큰을 사용해 토큰화하겠다'와 같은 말입니다. ("토큰 수가 같은 경우엔 더 자주 사용된 토큰을 사용하겠다.")

 

바로 이어서 unigram이 vocab을 완성해 가는 과정을 알아보겠습니다. 우선 corpus의 단어들을 현재 vocab에서 구성할 수 있는 최선의 토큰 조합으로 구성한 뒤, 각 단어의 확률값을 계산합니다.

"low" >>> "low" : 7/46
"lower" >>> "low" + "e" + "r" : 7/46 * 2/46 * 2/46

가장 쓸모 없는 토큰을 골라내기 위해 loss를 계산하는데, loss는 negative loglikelihood를 이용해 계산합니다. 위의 확률값에 log를 씌운뒤 -1을 곱하고 해당 단어의 빈도 수를 다시 곱해주는 겁니다.

loss = 5 * (-log(7/46)) + 2 * (-log(7/46 * 2/46 * 2/46)) = 4.08829 + 7.08223 = 11.17052

이 때 vocab의 토큰들 중 어떤 토큰을 제거 했을 때 loss에 미치는 영향이 가장 작은지를 계산하여 해당 토큰을 제거하는 것입니다. 예를 들어 "lo"와 "low" 토큰을 제거해보겠습니다.

"lo" 토큰의 경우 corpus에서 한번도 사용되지 않았기 때문에 삭제하더라도 loss에 영향을 전혀 미치지 않습니다.

반대로 "low" 토큰의 경우, 제거된다면 단어들이 아래와 같이 다시 토큰화되게 됩니다.

"lo" + "w" : 7/46 * 7/46
"lo" + "w" + "e" + "r" : 7/46 * 7/46 * 2/46 * 2/46

이 때 loss는 아래와 같이 계산됩니다.

loss = 5 * (-log(7/46 * 7/46)) + 2 * (-log(7/46 * 7/46 * 2/46 * 2/46)) = 8.17659 + 8.71755 = 16.89414

"low" 토큰을 제거하기 이전보다 loss가 5가량 커졌습니다. 즉, "low"를 제거하는 것보다 "lo" 토큰을 제거하는 것이 더 좋다고 볼 수 있는 것입니다. (단, "l", "o", "w"와 같은 character 토큰들은 unk 토큰 방지를 위해 제거하지 않는다고 합니다.)

이런 과정을 원하는 vocab의 길이에 도달할 때까지 반복하는 것이 unigram 방식입니다.

3. 토크나이저 실험 실습

이제 토크나이저에 대해 대강 알아봤으니 네이버 영화 리뷰 데이터셋(nsmc)에 대해 다양한 토크나이저들을 적용해보고 성능을 비교해 보도록 하겠습니다. 아래 링크에서 실험에 사용한 colab 노트북을 확인할 수 있습니다.

https://colab.research.google.com/drive/1D6OC3Sbg50HJp-O0Lk8xSPrNPwy5XTTl?usp=sharing

 

nsmc_Moview_Review_2.ipynb

Colaboratory notebook

colab.research.google.com

우선 데이터를 간단하게 전처리하고 시작하도록 하겠습니다. 길이가 너무 짧은 리뷰 문장들은 제외하고 총 149497개의 훈련셋과 50000개의 테스트셋을 사용합니다.

import re

with open("nsmc/ratings_train.txt", "r", encoding="utf-8") as f:
    ratings_train = f.readlines()

with open("nsmc/ratings_test.txt", "r", encoding="utf-8") as f:
    ratings_test = f.readlines()

# 제목 행은 제거
ratings_train = ratings_train[1:]
ratings_test = ratings_test[1:]

code = "[^가-힣]"
filtered_ratings_train = []

for rating in ratings_train:
    sentence = rating.split("\t")[1]
    if len(sentence) < 3 and re.search(re.compile(code), sentence) != None:
        continue
    filtered_ratings_train.append(rating)

print(len(filtered_ratings_train), len(ratings_test))

 

3-1. Sentencepiece 토크나이저

Subword 토크나이저 실험을 위해 sentencepiece를 사용하도록 하겠습니다. 이름 때문에 wordpiece의 다른 버전인가 싶을수 있지만 앞서 설명한 bpe, unigram 방식들을 쉽고 효율적으로 사용할 수 있도록 해주는 tool의 개념에 가깝습니다. Sentencepiece는 라이브러리로 사용이 가능합니다. 우선 sentencepiece 학습을 위해 text만 따로 모아서 txt 파일로 저장해야 합니다.

with open("nsmc/filtered_ratings_train.txt", "w") as f:
    for rating in filtered_ratings_train:
        sentence = rating.split("\t")[1]
        f.write(sentence + "\n")

이 파일을 이용해서 아래와 같이 sentencepiece 학습이 가능합니다.

import sentencepiece as spm

spm.SentencePieceTrainer.Train(
    '--input=nsmc/filtered_ratings_train.txt --model_prefix=nsmc --vocab_size=5000 --model_type=bpe\
    --unk_id=1 --pad_id=0 --bos_id=-1 --eos_id=-1'
)

명령어의 인자들을 설명하자면,

--model_prefix : 토크나이저를 저장할 이름입니다. 'nsmc'로 하면 'nsmc.vocab', 'nsmc.model'과 같이 결과가 저장됩니다.
--vocab_size : subword 토큰의 vocab 수를 제한합니다.
--model_type : unigram, bpe, char, word 중 하나를 선택해서 토크나이저를 만들 수 있습니다.
--unk_id, pad_id, bos_id, eos_id : 토크나이저에 필요한 특수 토큰들의 id를 지정합니다. (bos와 eos는 문장의 시작과 끝을 알리는 토큰인데 여기선 사용하지 않기 때문에 -1로 지정했습니다.)

nsmc.model과 nsmc.vocab이 추가된다.

토크나이저 학습이 굉장히 빨리 되는 것을 확인할 수 있습니다. 학습이 완료되면 위 그림과 같이 'nsmc.model', 'nsmc.vocab' 파일이 만들어진걸 확인할 수 있습니다.

'nsmc.model' 파일을 불러와서 아래와 같이 토크나이저로 사용할 수 있습니다.

sp = spm.SentencePieceProcessor()
sp.load("nsmc.model")

example = filtered_ratings_train[15].split("\t")[1]

print(sp.encode_as_pieces(example))
print(sp.encode_as_ids(example))
['▁', 'ᄀ', '냥', '▁매', '번', '▁긴장', '되고', '▁재밋', '음', 'ᅲᅲ']
[3289, 3995, 3483, 178, 3495, 563, 1396, 522, 3344, 100]

Sentencepiece를 이용해서 dataset을 만들어 보겠습니다.

import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import Dataset, DataLoader


class ReviewDataset(Dataset):
    def __init__(self, ratings: list, sp: spm.SentencePieceProcessor):
        super().__init__()
        self.reviews, self.labels = self.preprocess(ratings)
        self.tokenizer = sp

    def preprocess(self, ratings):
        reviews, labels = [], []
        for rating in ratings:
            _, review, label = rating.split("\t")
            label = int(label[0])
            reviews.append(review)
            labels.append(label)

        return reviews, labels

    def __len__(self):
        return len(self.reviews)

    def __getitem__(self, idx):
        review = self.reviews[idx]
        label = self.labels[idx]
        ids = self.tokenizer.encode_as_ids(review)

        return {"review": review, "ids": torch.tensor(ids), "label": label}

class Collate_fn:
    def __init__(self, pad_id):
        self.pad_id = pad_id

    def __call__(self, batch):
        reviews, ids, labels = [], [], []
        for b in batch:
            reviews.append(b["review"])
            ids.append(b["ids"])
            labels.append(b["label"])
        # pad_sequence를 사용하면 문장 padding을 쉽게 구현할 수 있습니다.
        ids = pad_sequence(ids, batch_first=True, padding_value=self.pad_id)
        return reviews, ids, torch.tensor(labels).float()

sp_train_dataset = ReviewDataset(filtered_ratings_train, sp)
sp_test_dataset = ReviewDataset(ratings_test, sp)

collate_fn = Collate_fn(sp.pad_id())

sp_train_loader = DataLoader(sp_train_dataset, batch_size=64, shuffle=True, collate_fn=collate_fn)
sp_test_loader = DataLoader(sp_test_dataset, batch_size=64, shuffle=False, collate_fn=collate_fn)

모델은 이전 포스트와 똑같이 구성하겠습니다.

모델 구조

import torch.nn as nn

class ReviewSentimentClassifier(nn.Module):
    def __init__(self, vocab_size: int):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, 256)
        self.rnn = nn.LSTM(256, 256, num_layers=2, batch_first=True, dropout=0.1)
        self.linear = nn.Linear(256, 1)
        self.sigmoid = nn.Sigmoid()

    def forward(self, inputs):
        x = self.embedding(inputs)
        x, (h, c) = self.rnn(x)
        x = self.linear(h[-1])
        x = self.sigmoid(x)

        return x.squeeze()

model = ReviewSentimentClassifier(vocab_size = len(sp))

학습까지 해보겠습니다.

from tqdm import tqdm
from sklearn.metrics import accuracy_score


epochs = 5
lr = 1e-3
device = 'cuda' if torch.cuda.is_available() else 'cpu'

criterion = nn.BCELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)

def train(model, criterion, optimizer, train_loader, test_loader, epochs):
    model.to(device)

    train_losses, eval_losses, eval_accuracies = [], [], []

    for epoch in range(epochs):
        print(f"epoch {epoch+1}/{epochs}")
        model.train()
        train_loss = []
        for data in tqdm(train_loader):
            _, ids, labels = data
            ids = ids.to(device).int()
            labels = labels.to(device).float()

            preds = model(ids)
            loss = criterion(preds, labels)
            train_loss.append(loss.detach().cpu().item())

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        train_losses.append(sum(train_loss)/len(train_loss))

        model.eval()
        eval_loss = []
        eval_preds = []
        eval_labels = []
        for data in tqdm(test_loader):
            _, ids, labels = data
            ids = ids.to(device).int()
            eval_labels += list(labels.int())
            labels = labels.to(device).float()

            with torch.no_grad():
                preds = model(ids)
            loss = criterion(preds, labels)
            eval_loss.append(loss.cpu().item())
            eval_preds.append(preds.cpu())

        eval_preds = torch.cat(eval_preds, dim=0)
        eval_preds = [1 if pred > 0.5 else 0 for pred in eval_preds]
        acc = accuracy_score(eval_labels, eval_preds)

        eval_losses.append(sum(eval_loss)/len(eval_loss))
        eval_accuracies.append(acc)

    return train_losses, eval_losses, eval_accuracies

sp_train_losses, sp_eval_losses, sp_eval_accuracies = train(
    model, criterion, optimizer, sp_train_loader, sp_test_loader, epochs
)

학습 결과를 보면 아래와 같이 학습이 잘되는 것을 확인할 수 있습니다.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2)

ax[0].plot(sp_train_losses, label="train loss")
ax[0].plot(sp_eval_losses, label="eval loss")
ax[0].legend()
ax[0].set_title("Loss graph")

ax[1].plot(sp_eval_accuracies)
ax[1].set_ylim(0, 1)
ax[1].set_title("Accuracy graph")

sentencepiece 학습 결과 그래프

다른 토크나이저들과 성능을 비교하기 위해 다른 토크나이저도 실험해 보겠습니다.

3-2. Character 토크나이저

character 토크나이저의 자세한 설명은 이전 포스트 참고 바랍니다. colab 노트북에도 설명이 있습니다.

학습 결과만 비교해 보겠습니다.

character 토크나이저와 sentencepiece 토크나이저의 학습 그래프 비교

학습을 돌릴 때마다 약간씩 차이는 있지만, character 토크나이저가 sentencepiece 토크나이저보다 조금 더 좋은 성능을 보이는 것을 확인할 수 있습니다.

nsmc 데이터셋에는 맞춤법이나 띄어쓰기가 잘 지켜지지 않은 문장이 많아 의미 있는 subword 추출에 어려움이 있었을 것으로 생각됩니다. 일단 mecab 토크나이저까지 실험해본 뒤에 토크나이저 결과를 한번 봐보도록 하겠습니다.

3-3. mecab 토크나이저

mecab-ko는 한국어 형태소 분석기입니다. 형태소는 의미를 갖는 가장 작은 글자 단위입니다. 그렇기 때문에 이를 이용하면 모델에 좀 더 의미 있는 토큰들을 학습 시킬 수 있을 것으로 기대됩니다.

from mecab import MeCab

mecab = MeCab()

example = filtered_ratings_train[100].split("\t")[1]

print(mecab.morphs(example))
['신카이', '마코토', '의', '작화', '와', ',', '미유', '와', '하나', '카', '나', '가', '연기', '를', '잘', '해', '줘서', '더', '대박', '이', '였', '다', '.']

mecab을 이용해서 vocab을 구성해 보겠습니다.

train_sentences = []
word_counter = Counter()

for rating in ratings_train:
    sentence = rating.split("\t")[1]
    words = mecab.morphs(sentence)
    word_counter.update(words)
    train_sentences.append(sentence)

word_counter = sorted(dict(word_counter).items(), key=lambda x: x[1], reverse=True)

words, counts = [], []

for word, count in word_counter:
    words.append(word)
    counts.append(count)

freqs = Counter(counts)

for k, v in freqs.items():
    print(f"{k}회 사용된 단어 토큰의 수 : {v}")

mecab을 이용해서 nsmc 데이터셋을 토큰화하면 총 5만 여개의 토큰이 생성됩니다. 하지만 이 토큰들이 전부 자주 사용되는 것은 아닙니다. 특히 15만 개의 문장에서 단 한번 사용된 토큰의 수가 23842개입니다.

이 토큰들을 모두 학습하는 것보단 자주 사용되는 토큰 n개만 사용하는 것이 더 효율이 좋습니다. 이는 자주 사용되는 토큰일수록 더 많은 정보를 담고 있을 거라는 가정 하에서 학습의 효율을 위해 하는 것입니다. 그리고 너무 자주 사용되지 않는 단어들은 표본이 적어 학습이 잘 안되어 오히려 학습에 방해가 될 수도 있습니다.

 

같은 subword 토크나이저인 sentencepiece와의 비교를 위해 가장 많이 사용된 5000개의 토큰만 사용해 vocab을 구성하고 모델을 학습해 보겠습니다.

class Tokenizer:
    def __init__(self, vocab):
        self.vocab = vocab
        self.pad_id = self.vocab.index("<pad>")
        self.unk_id = self.vocab.index("<unk>")

    def encode(self, sentence):
        ids = []
        words = mecab.morphs(sentence)
        for word in words:
            if word not in self.vocab:
                id_ = self.vocab.index("<unk>")
            else:
                id_ = self.vocab.index(word)
            ids.append(id_)
        return ids

    def decode(self, ids):
        sentence = ""
        for id_ in ids:
            sentence += self.vocab[id_]
        return sentence

class ReviewDataset(Dataset):
    def __init__(self, ratings: list, tokenizer: object):
        super().__init__()
        self.reviews, self.labels = self.preprocess(ratings)
        self.tokenizer = tokenizer

    def preprocess(self, ratings):
        reviews, labels = [], []
        for rating in ratings:
            _, review, label = rating.split("\t")
            label = int(label[0])
            reviews.append(review)
            labels.append(label)

        return reviews, labels

    def __len__(self):
        return len(self.reviews)

    def __getitem__(self, idx):
        review = self.reviews[idx]
        label = self.labels[idx]
        ids = self.tokenizer.encode(review)

        return {"review": review, "ids": torch.tensor(ids), "label": label}

# 5000개의 토큰만 사용
vocab = ["<pad>", "<unk>"] + words[:5000]
tokenizer = Tokenizer(vocab)

mecab_train_dataset = ReviewDataset(filtered_ratings_train, tokenizer)
mecab_test_dataset = ReviewDataset(ratings_test, tokenizer)

mecab_train_loader = DataLoader(mecab_train_dataset, batch_size=64, shuffle=True, collate_fn=collate_fn)
mecab_test_loader = DataLoader(mecab_test_dataset, batch_size=64, shuffle=False, collate_fn=collate_fn)

model3 = ReviewSentimentClassifier(vocab_size = len(vocab))

epochs = 5
lr = 1e-3
device = 'cuda' if torch.cuda.is_available() else 'cpu'

criterion = nn.BCELoss()
optimizer = torch.optim.Adam(model3.parameters(), lr=lr)

mecab_train_losses, mecab_test_losses, mecab_test_accuracies = train(
    model3, criterion, optimizer, mecab_train_loader, mecab_test_loader, epochs
)

3-4. 토크나이저 별 학습 결과 비교

Sentencepiece, character, mecab 토크나이저의 학습 그래프를 비교해 보겠습니다.

fig, ax = plt.subplots(1, 2)

ax[0].plot(sp_eval_losses, label="sentencepiece")
ax[0].plot(ch_eval_losses, label="character")
ax[0].plot(mecab_test_losses, label="mecab")
ax[0].legend()
ax[0].set_title("Loss graph")

ax[1].plot(sp_eval_accuracies, label="sentencepiece")
ax[1].plot(ch_eval_accuracies, label="character")
ax[1].plot(mecab_test_accuracies, label="mecab")
ax[1].legend()
ax[1].set_title("Accuracy graph")

sentencepiece, character, mecab 토크나이저 학습 결과 비교 그래프

성능은 mecab > character > sentencepiece 순으로 나타났습니다. mecab과 sentencepiece는 같은 subword 단위지만 mecab의 성능이 확실히 더 좋은 것을 확인할 수 있습니다. 이는 의미를 갖는 subword 위주로 토큰을 구성하는 것이 모델의 성능에 영향을 미친다는 것을 확실히 보여줍니다.

그렇다면 mecab과 sentencepiece는 토큰화에서 어떤 차이가 있을까요? 몇가지 예시를 살펴보면 sentencepiece는 명사들을 분리해서 토큰화하는 경향을 보입니다.

example = filtered_ratings_train[200].split("\t")[1]

print("sentencepiece :", sp.encode_as_pieces(example))
print("mecab :", mecab.morphs(example))

example = filtered_ratings_train[500].split("\t")[1]

print("sentencepiece :", sp.encode_as_pieces(example))
print("mecab :", mecab.morphs(example))

example = filtered_ratings_train[30].split("\t")[1]

print("sentencepiece :", sp.encode_as_pieces(example))
print("mecab :", mecab.morphs(example))

sentencepiece : ['▁TV', '용', '▁건', '담', '▁시리즈', '▁중', '에서', '▁아직까지', '도', '▁최고', '봉']
mecab : ['TV', '용', '건담', '시리즈', '중', '에서', '아직', '까지', '도', '최고봉']
sentencepiece : ['▁나', '▁이거', '▁보고', '▁인', '형', '▁절대', '안', '삼']
mecab : ['나', '이거', '보', '고', '인형', '절대', '안', '삼']
sentencepiece : ['▁엄', '포', '스의', '▁위', '력', '을', '▁다시', '▁한번', '▁깨닫', '게', '▁해준', '▁적', '.', '남', '▁꽃', '검', '사', '님', '도', '▁연기', '▁정말', '▁좋았어요', '!', '▁완전', '▁명', '품', '드라마', '!']
mecab : ['엄', '포스', '의', '위력', '을', '다시', '한', '번', '깨닫', '게', '해', '준', '적', '.', '남', '꽃', '검사', '님', '도', '연기', '정말', '좋', '았', '어요', '!', '완전', '명품', '드라마', '!']

그렇다고 무조건 mecab을 사용해야 하냐면 꼭 그렇지는 않습니다. mecab은 문장을 형태소 단위로 분리하는 데 시간이 오래 걸리기 때문입니다. 속도를 위해서 어느정도 타협을 볼 수 있는 부분이라고 생각합니다. 그리고 nsmc가 아닌 다른 잘 정제된 텍스트로 학습할 경우 mecab과 sentencepiece의 차이가 크지 않을 수도 있습니다. 항상 내가 가진 데이터셋의 특징을 잘 생각해서 적합한 토크나이저를 선택할 수 있도록 합시다!