본문 바로가기
NLP 기초

[NLP-3] 언어 임베딩 : word2vec과 glove

by 빈이름 2024. 3. 19.
1. 언어 임베딩이란?
2. Glove
3. Word2Vec
4. 실습

1. 언어 임베딩이란?

언어 임베딩이란 컴퓨터가 글자 단어들을 이해하는 방식이라고 볼 수 있습니다. 이전까지 예제들에서는 모델을 학습할 때 랜덤한 값으로 초기화된 임베딩 레이어를 통해 이를 학습해 왔습니다.

 

예를 들어 앞서 본 nsmc 데이터셋을 학습한다면 모델이 영화 리뷰들을 긍정, 부정으로 분류하는 과정에서 어떤 단어가 들어 갔을 때 긍정에 가까워지는지, 어떤 단어가 들어 갔을 때 부정에 가까워지는지를 학습하게 될 것입니다. 하지만 이것이 모델이 그 단어들의 의미를 정확히 이해할 수 있는가? 하면 살짝 의문이 생길 수 있습니다. nsmc 데이터로 학습한 모델로 아예 종류가 다른 task를 수행하게 됐을 때도 좋은 성능을 낼 수 있는지는 의문인 것이죠.

 

하지만 NLP 분야에서도 비전 분야의 ImageNet과 같이 대규모 데이터를 이용해 사전학습을 하고 다양한 task에 적용하고 싶을 겁니다. 그렇지만 텍스트는 이미지와 같이 라벨링을 하는 것이 애매한 부분이 있습니다. 이미지의 경우 각 이미지에 나타난 물체의 종류로 비교적 라벨링의 기준을 내리기 쉽지만, 텍스트는 애매한 부분이 없잖아 있습니다.

 

오늘 소개할 Glove와 Word2Vec은 이런 고민을 해결하기 위해 등장한 텍스트의 사전학습 방식입니다. 라벨이 없는 텍스트들을 비지도 학습을 이용해 단어 임베딩을 사전 학습한 뒤 이를 이용해 다양한 NLP task에 활용할 수 있도록 하는 것입니다. 그렇다면 Glove와 Word2Vec이 어떻게 텍스트들을 이용해 단어의 의미를 학습하는지에 대해서 알아 보도록 하겠습니다.

2. Glove

Glove의 모티브는 "함께 사용된 적이 있는 단어끼리는 서로 연관성이 높다." 입니다. 예를 들면 "빙산" 이라는 단어는 "사막" 보다는 "남극" 이라는 단어와 더 자주 함께 사용되겠죠? Glove는 이걸 "동시 확률 빈도 표"를 이용해서 파악합니다.

아래 예시를 보겠습니다.

"I like this movie"
"I love the actor in this movie"
"I don't like this movie"

위와 같은 문장 3개가 있다고 해봅시다. 이 때 각 문장마다 함께 사용된 단어가 존재하게 됩니다. 함께 사용된 단어는 target 단어를 기준으로 좌우 n개의 단어(window 크기가 n이라고 합니다.)로 정의합니다. 예를 들어 두번째 문장에서 "actor"라는 단어는 주변 단어로 ["love", "the", "in", "this"] 4개의 단어를 갖게 됩니다. 이 때 "actor"라는 단어의 동시 등장 빈도는 아래와 같이 기록할 수 있습니다.

  I like this movie love the actor in don't
actor 0 0 1 0 1 1 0 1 0

이런 식으로 문장마다 단어마다 주변 단어들의 등장 횟수를 세서 최종적으로 아래와 같은 표를 만들게 됩니다.

  I like this movie love the actor in don't
I 0 2 1 0 0 1 0 0 1
like 2 0 2 2 0 0 0 0 1
this 1 2 0 3 0 0 1 1 1
movie 0 2 3 0 0 0 0 1 0
love 0 0 0 0 0 1 1 0 0
the 1 0 0 0 1 0 1 1 0
actor 0 0 1 0 1 1 0 1 0
in 0 0 1 1 0 1 1 0 0
don't 1 1 1 0 0 0 0 0 0

각 표의 값들은 전체 문장에서 각 단어가 서로 같은 window 내에서 동시에 등장한 빈도 수를 나타냅니다.

 

Glove의 학습은 아래의 loss function을 최소화하는 방식으로 이뤄집니다.

 

$F(\text{Coocur})=min(({\text{Coocur}\over x_{max}})^{\alpha}, 1)$

$loss=F(\text{Coocur})\cdot(w_m^T\cdot w_n+b_m+b_n-log(\text{Coocur}))^2$

 

학습은 동시 등장 빈도가 1 이상인 단어 m과 단어 n을 입력받아 시작됩니다. $w_m$과 $w_n$은 각각 단어 m과 단어 n의 임베딩 벡터고, $b_m$과 $b_n$은 각 단어의 bias를 나타냅니다. Coocur는 두 단어 m과 n이 동시에 등장한 횟수를 나타냅니다.

loss function을 크게 보면 $w_m$과 $w_n$의 내적값이 Coocur의 log값에 유사해질수록 loss가 작아지도록 설계되어 있습니다. 즉, Coocur가 커질수록 $w_m$과 $w_n$의 내적값이 커져야 하고, 내적값이 커진다는 것은 $w_m$과 $w_n$이 유사한 벡터 값을 가지게 된다는 것입니다. 그 말은 즉, 서로 함께 등장한 횟수가 많을수록 두 단어의 임베딩 벡터는 서로 가까운 값을 갖게 된다는 것입니다.

거기에 추가로 $F(\text{Coocur})$라는 weight factor를 loss에 추가로 곱하게 됩니다. 이 weight factor는 coocur가 커질수록 값이 커지게 됩니다. 즉, 동시 등장 빈도가 큰 단어쌍일수록 더 큰 loss 값을 갖게 될 것이고, 이는 모델이 동시 등장 빈도가 큰 단어 쌍에 대해 더 예민하게 학습되도록 합니다.

추가로 $x_{max}$와 $\alpha$는 너무 큰 coocur를 규제하기 위한 상수입니다.

 

복잡하지만 결과적으로 Glove는 서로 가까운 거리에서 함께 등장한 단어 쌍은 서로 유사한 임베딩 값을 갖도록 학습되는 것이 Glove의 기초 골자입니다.

3. Word2Vec

Word2Vec도 Glove와 같이 서로 더 자주 함께 사용되는 단어들이 서로 연관이 있다고 생각합니다. 다만 학습 방법이 조금 다릅니다. 각 문장에서 target 단어를 선정하고, window 범위 내의 단어들을 주변 단어로 정의하는 것은 Glove와 같습니다.

"I love the actor in this movie"

Glove와의 차이점은 ' "actor"와 "this" 라는 단어가 전체 데이터셋에서 서로 동시에 등장한 횟수가 총 몇 회인가'를 측정하는 것이 아니라 이 문장에서 "this"라는 단어가 "actor" 라는 단어의 주변에 존재하는가를 학습한다는 것입니다.

 

Word2Vec이 이를 학습하는 방법은 2가지로 나눠집니다.

Word2Vec의 2가지 학습방법

window의 중심에 있는 단어를 target word(대상 단어), 그 외의 단어들을 context words(주변 단어들)이라고 하겠습니다.

a) Skip-gram

Skip-gram은 target word를 이용해 context word를 예측합니다. target word의 임베딩 벡터 $v0$가 window 밖의 단어의 임베딩 벡터 $v1$보다 context word의 임베딩 벡터 $v3$와 더 가까운 값을 갖도록 하는 것입니다. 이는 cross entropy loss를 이용해 학습할 수 있습니다. $v0$와 $v1$을 내적한 값은 0에 가까운 값을, $v0$와 $v3$를 내적한 값은 1에 가까운 값을 나타내는 확률 분포를 출력하도록 하는 것입니다.

b) CBOW

CBOW는 반대로 context word를 이용해서 target word를 예측하는 것입니다. context word들의 임베딩 벡터들을 각각 계산한 뒤, 그 임베딩 벡터들의 평균값이 target word의 임베딩 벡터의 값이 되도록 학습합니다.

 

Word2Vec과 Glove의 학습방식은 다르지만, 둘 다 "서로 함께 사용된 적이 많을수록 서로 유사한 의미를 갖는다" 라는 가설에 기반하여 학습합니다. 텍스트를 분류하는 것보다는 단어의 좀 더 인공지능이 단어의 의미를 파악하기 용이하겠죠? 그리고 이렇게 하면 텍스트에 따로 라벨이 필요 없기 때문에 데이터 수집이 용이할 것입니다. 그렇기 때문에 대규모 데이터를 수집하기도 쉽겠죠.

4. 실습

지금까지 조금 어려웠을 수도 있는데 직접 코드를 돌려 보면서 Glove와 Word2Vec이 어떤 식으로 작동하는지 알아 보도록 하겠습니다. 아래 colab 노트북에서 코드를 직접 돌려볼 수 있습니다.

https://colab.research.google.com/drive/1LiRq_xLe--qCD7yzS0HQCIqI2vbIOdkM?usp=sharing

 

nsmc_with_Glove, Word2Vec.ipynb

Colaboratory notebook

colab.research.google.com

이번에도 nsmc 데이터셋을 사용할 겁니다. 하지만 이번엔 데이터셋이 부족한 상황을 가정하여 15만 개의 영화 리뷰 중 10%인 1만 5천 개의 문장만 이용해 분류 학습을 할겁니다. 그리고 전체 15만 개의 문장을 이용해 Glove, Word2Vec 사전학습을 통해 단어 임베딩을 학습한 뒤, 이 임베딩 레이어가 모델 성능에 어떤 영향을 미치는지 알아볼 겁니다.

 

1. 데이터 준비

우선 데이터셋을 준비하겠습니다.

!git clone https://github.com/e9t/nsmc
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))

빠른 전처리를 위해 sentencepiece를 이용하겠습니다.

import sentencepiece as spm

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")
        
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'
    )

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

 

데이터를 1만 5천개만 사용하기 위해 데이터를 나누도록 하겠습니다. 그 전에 결과 재현을 위해 랜덤 시드를 먼저 설정하겠습니다.

import torch
import numpy as np
import random

def set_seed(random_seed):
    torch.manual_seed(random_seed)
    torch.cuda.manual_seed(random_seed)
    torch.cuda.manual_seed_all(random_seed)  # if use multi-GPU
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False
    np.random.seed(random_seed)
    random.seed(random_seed)

set_seed(314)

라벨 비율을 유지하기 위해 sklearn의 train_test_split을 이용해서 데이터셋을 나누도록 하겠습니다.

from sklearn.model_selection import train_test_split

def preprocess(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

train_reviews, train_labels = preprocess(filtered_ratings_train)
test_reviews, test_labels = preprocess(ratings_test)

small_train_reviews, _, small_train_labels, _ = train_test_split(train_reviews, train_labels, train_size=0.1)
len(small_train_reviews), len(small_train_labels)

2. 사전 학습 임베딩 없이 학습

결과 비교를 위해 사전 학습 임베딩을 사용하지 않고 먼저 학습을 수행합니다. 이 부분에 대한 설명은 이전 포스트를 참고해 주기 바랍니다. 학습 결과는 아래와 같이 나타났습니다.

사전 학습 임베딩 없이 학습한 학습 그래프

3-1. Glove 사전학습하기

Glove는 앞서 설명했듯이 "동시 등장 빈도 표"를 이용합니다. 따라서 우선 "동시 등장 빈도 표"를 먼저 만들 필요가 있습니다. Sentencepiece를 이용해 단어의 수를 5천 개로 제한했기 때문에 이 표(matrix)는 5000x5000의 크기를 갖게 됩니다. 그리고 각 셀의 값은 m번째 단어와 n번째 단어가 서로 함께 등장한 빈도를 기록한 값이 들어가게 됩니다.

 

다만 여기서 만약 서로 함께 등장한 적이 전혀 없는 단어 쌍이 있을 경우 학습을 방해할 수 있기 때문에 이 단어 쌍들의 인덱스는 따로 기록해 둔 뒤에 나중에 학습에 사용되지 않도록 후처리를 수행할 것입니다.

vocab = sp

def build_matrix(corpus: list[str], window_size: int):
    matrix = np.zeros(shape=(len(vocab), len(vocab)), dtype='int')
    for sentence in tqdm(corpus):
        ids = sp.encode_as_ids(sentence)
        #ids = tokenizer.encode(sentence)
        for i in range(len(ids)):
            min_idx = max(0, i-window_size)
            max_idx = min(i+window_size, len(ids))
            target = ids[i]
            window = ids[min_idx:i] + ids[i+1:max_idx]
            for id_ in window:
                if id_ == 1: # skip <unk> id
                    continue
                matrix[target][id_] += 1
    # 만약 어떤 단어와도 함께 등장한 적이 없는 단어가 존재할 경우, 학습을 방해하기 때문에
    # 체크해놓고 학습 데이터에 포함되지 않도록 추가 처리를 해 줄 필요가 있습니다.
    no_coocur_idx = []
    for i, mat in enumerate(matrix):
        if sum(mat) == 0:
            no_coocur_idx.append(i)

    return matrix, no_coocur_idx


mat, no_coocur_idx = build_matrix(train_reviews, window_size=3)
print(no_coocur_idx)

 

 

이제 이를 이용해서 서로 함께 등장한 적이 있는 단어 m과 단어 n, 그리고 두 단어의 동시 등장 빈도 coocur를 return하는 데이터셋을 만들 겁니다.

from torch.utils.data import Dataset, DataLoader


class GloveDataset(Dataset):
    def __init__(self, matrix, no_coocur_idx, max_ocur):
        super().__init__()
        self.matrix = matrix
        self.no_coocur_idx = no_coocur_idx
        self.max_ocur = max_ocur
        self.length = self.matrix.shape[0]

    def __len__(self):
        return self.length

    def __getitem__(self, idx):
        # 동시 발생 빈도가 0인 경우 다른 데이터로 교체한다.
        while idx in self.no_coocur_idx:
            idx = np.random.choice(self.length)

        around_ids = np.where(mat[idx] > 0)

        around_id = np.random.choice(around_ids[0])
        coocur = mat[idx][around_id]

        return idx, around_id, coocur

glove_dataset = GloveDataset(mat, no_coocur_idx, 1000)
glove_loader = DataLoader(glove_dataset, batch_size=512, shuffle=True)

# m과 n 단어가 3토큰 안에 함께 등장할 확률은 coocur이다.
m, n, coocur = next(iter(glove_loader))
print(m[0], n[0], coocur[0])
print(m.shape, n.shape, coocur.shape)

유의할 점은 동시 등장 빈도가 0인 단어 쌍이 등장하지 않도록 후처리를 해줘야 한다는 것입니다. Glove의 loss 함수는 coocur가 0일 경우 제대로 작동하지 않기 때문입니다.

 

이제 Glove 임베딩 벡터를 학습하기 위한 모델을 구현할 것입니다. Loss 함수를 직접 구현해야 하는데 저는 이를 step() 함수를 추가로 만들어서 구현했습니다. 학습하는 동안엔 step() 함수를 이용해 loss를 계산하고, 학습이 완료된 뒤에는 forward() 함수를 이용해서 단어 임베딩 벡터를 출력할 것입니다.

'''
https://github.com/noaRricky/pytorch-glove/blob/master/glove.py
https://github.com/maciejkula/glove-python/blob/master/glove/glove.py
'''

import torch
import torch.nn as nn
import torch.nn.init as init


class Glove(nn.Module):
    def __init__(self, vocab_size, n_dim):
        super().__init__()
        self.vector = nn.Embedding(vocab_size, n_dim)
        self.bias = nn.Embedding(vocab_size, 1)
        self.x_max = 1000
        self.alpha = 3 / 4
        for params in self.parameters():
            init.uniform_(params, a=-1, b=1)

    def forward(self, x):
        return self.vector(x)

    def step(self, m, n, coocur):
        w_m = self.vector(m)
        w_n = self.vector(n)
        b_m = self.bias(m)
        b_n = self.bias(n)

        weight_factor = torch.pow(coocur/self.x_max, self.alpha)
        weight_factor[weight_factor > 1] = 1

        x = torch.sum(w_m * w_n, dim=1)
        x = x + b_m + b_n - torch.log(coocur)
        x = x**2
        x *= weight_factor

        return torch.mean(x)

gloveModel = Glove(len(vocab), 300)

 

Glove 사전학습을 통해 얻고자 하는 것은 임베딩 레이어 self.vector입니다. step() 함수는 Glove의 loss 함수를 구현한 것입니다.

 

이제 데이터와 모델을 이용해 Glove 사전학습을 수행해 보겠습니다. 총 100 에포크를 학습했으며, 배치 크기가 512이기 때문에 모델은 학습하는 동안 단어 쌍을 총 51,200번 가량 보게 됩니다.

from torch.optim import Adam

optim = Adam(gloveModel.parameters(), lr=0.001)
epochs = 100
device = 'cuda' if torch.cuda.is_available() else 'cpu'

gloveModel.to(device)

train_losses =[]

for epoch in tqdm(range(epochs)):
    gloveModel.train()
    train_loss = 0
    for m, n, coocur in glove_loader:
        m = m.to(device)
        n = n.to(device)
        coocur = coocur.to(device)
        loss = gloveModel.step(m, n, coocur)

        optim.zero_grad()
        loss.backward()
        optim.step()

        train_loss += loss.cpu().detach().numpy()
    loss = train_loss/len(glove_loader)
    train_losses.append(loss)

plt.plot(train_losses)

3-2. Glove 벡터를 이용해 nsmc 학습하기

이제 사전학습된 Glove 임베딩 벡터를 이용해 nsmc 리뷰 분류를 다시 수행한 뒤, 사전학습 없이 학습했던 분류 모델과 성능을 비교해 보도록 하겠습니다.

 

Glove 임베딩 벡터를 적용하기 위해선 사전학습한 gloveModel의 forward() 함수를 이용해 임베딩 벡터를 얻을 수 있습니다. 따라서 기존 모델의 임베딩 레이어를 Glove 모델로 대체하면 됩니다. 그 외의 레이어들은 건드리지 않았습니다.

class ReviewSentimentClassifier(nn.Module):
    def __init__(self, vocab_size: int):
        super().__init__()
        # 임베딩 레이어를 Glove 벡터로 대체한다.
        self.embedding = gloveModel
        self.rnn = nn.LSTM(300, 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_with_glove = ReviewSentimentClassifier(vocab_size = len(sp))

학습 파라미터 역시 건들지 않았습니다.

epochs = 10
lr = 1e-3

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

glove_train_losses, glove_eval_losses, glove_eval_accuracies = train(
    model_with_glove, criterion, optimizer, sp_train_loader, sp_test_loader, epochs
)
fig, ax = plt.subplots(1, 2, figsize=(15, 8))

ax[0].plot(sp_eval_losses, label="pure embedding")
ax[0].plot(glove_eval_losses, label="glove embedding")
ax[0].legend(loc="lower right")
ax[0].set_title("eval loss graph")

ax[1].plot(sp_eval_accuracies, label="pure embedding")
ax[1].plot(glove_eval_accuracies, label="glove embedding")
ax[1].legend(loc="lower right")
ax[1].set_title("eval acc graph")

Glove 임베딩을 사용한 모델의 학습 결과(주황색)와 사용하지 않은 학습 결과(파란색)의 비교 그래프

위 그래프를 보면 Glove 벡터를 사용한 모델이 더 높은 정확도를 보이는 것을 확인할 수 있습니다. 파라미터 설정에 따라서 결과가 달라질 수도 있습니다.

4-1. Word2Vec 사전학습하기

Word2Vec은 문장마다 target 단어를 선정한 뒤, window 범위 내의 context 단어들에 대해 CBOW, Skip-gram 2가지 방식으로 임베딩 벡터를 학습할 수 있습니다. 여기서는 Skip-gram을 이용해 학습해 볼 것입니다.

 

Skip-gram은 target word를 이용해 context word를 예측하는 방식입니다. 앞서 cross entropy loss를 이용해 학습할 수 있다고 했는데 이번에 코드를 보면서 어떤 식으로 학습할 수 있는지 확인해 보도록 하겠습니다.

 

우선 데이터셋을 만들어야 합니다. Glove와 같이 동시 등장 빈도 표를 만드는 등의 사전 작업은 필요 없습니다. Word2vecDataset()의 역할은 문장에서 target word와 window 범위 내에 사용된 단어(positive)와 범위 내에 사용되지 않은 단어(negative)를 'n_negative'개 선정해 반환하는 것입니다.

 

예를 들면 아래와 같습니다.

sentence : "저번 시리즈까지는 좋았는데 점점 뇌절하는 듯... 이제 그만해라"
window_size : 3
n_negative : 3

<output>
target : "시리즈까지는"
positive : "저번"
negatives : ["듯...", "이제", "그만해라"]

위 문장에서 window_size가 3이라고 할 때, "시리즈까지는" 이라는 단어의 positive 단어 후보들은 ["저번", "좋았는데", "점점", "뇌절하는"] 4개입니다. 이 중에 하나를 선정해 positive로 출력을 합니다.

그리고 negative 단어 후보들은 ["듯...", "이제", "그만해라"] 3개 입니다. n_negative가 3이므로 이 중에 3개의 단어를 선택해서 negatives로 출력해야 합니다. 따라서 ["듯...", "이제", "그만해라"] 가 출력됩니다.

# https://www.tensorflow.org/tutorials/text/word2vec?hl=ko

class word2vecDataset(Dataset):
    def __init__(self, reviews: list, window_size: int, n_negative: int):
        super().__init__()
        self.reviews = reviews
        self.window_size = window_size
        self.n_negative = n_negative

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

    def __getitem__(self, idx):
        sentence = self.reviews[idx]
        ids = sp.encode_as_ids(sentence)
        target_idx = np.random.choice(len(ids))

        min_idx = max(0, target_idx-self.window_size)
        max_idx = min(len(ids), target_idx+self.window_size)
        positive_ids = ids[min_idx:max_idx+1]
        negative_ids = ids[:min_idx] + ids[max_idx+1:]

        positive = np.random.choice(positive_ids)
        if len(negative_ids) == 0:
            negatives = np.random.choice(len(sp), self.n_negative)
        else:
            negatives = np.random.choice(negative_ids, self.n_negative)

        return ids[target_idx], positive, negatives

문장을 이루는 단어 토큰의 수가 너무 적으면 positive와 negative 단어들을 추출하는데 애로 사항이 발생할 수 있으므로 토큰 수가 너무 적은 문장들 (여기선 토큰이 5개보다 적게 쓰인 문장들)을 제외한 뒤에 데이터셋으로 사용했습니다.

# 주변 단어를 원활히 추출하기 위해 문장을 이루는 토큰 수가 너무 적은 문장은 제거.
reviews_for_word2vec = []

for review in train_reviews:
    ids = sp.encode_as_ids(review)
    if len(ids) > 5:
        reviews_for_word2vec.append(review)

word2vec_dataset = word2vecDataset(reviews_for_word2vec, window_size=3, n_negative=4)
word2vec_loader = DataLoader(word2vec_dataset, batch_size=512, shuffle=True)

target, positive, negatives = word2vec_dataset.__getitem__(0)
print("sentence :", sp.encode_as_ids(word2vec_dataset.reviews[0]))
print("target :", target)
print("positive :", positive)
print("negatives :", negatives)

이제 Word2Vec 모델을 구현하겠습니다. Word2Vec 모델 역시 목표는 단어들의 임베딩 벡터를 학습하는 것입니다. 즉, self.embedding 레이어를 학습하는 것이 목표입니다. Loss는 Glove와 다르게 자주 사용되는 Cross Entropy Loss를 사용하기 때문에 복잡하게 구현할 필요가 없습니다.

 

target 단어와 positive, negatives 단어들을 임베딩 레이어를 이용해 임베딩 벡터를 얻은 뒤, target 벡터와 [positive, negative1, negative2, ... negative_n] 행렬을 곱한 뒤에, [1, 0, 0, ...,0] 과 같은 확률 분포를 출력하도록 합니다.

Glove 벡터와의 비교를 위해 모델의 학습횟수를 맞출 겁니다. Glove 벡터가 대략 1000 step 정도 학습을 수행했기 때문에 Word2Vec도 1000 step 학습하도록 4에포크 정도 학습을 수행할 겁니다.

optim = Adam(word2vec.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
# Glove와 학습 횟수를 맞추기 위해 word2vec_loader의 길이가 256이므로 총 4에포크를 학습하면 Glove의 학습 step 수(1000)와 대략 맞는다.
epochs = 4
total_train_step = epochs * len(word2vec_loader)

word2vec.to(device)

train_losses = []

train_loss = 0
train_step = 0

for epoch in range(epochs):
    for target, positive, negatives in word2vec_loader:
        target = target.to(device)
        positive = positive.to(device)
        negatives = negatives.to(device)
        outputs = word2vec(target, positive, negatives)

        labels = torch.LongTensor([0 for _ in range(outputs.size(0))]).to(device)
        loss = criterion(outputs, labels)

        optim.zero_grad()
        loss.backward()
        optim.step()

        train_loss += loss.cpu().detach().numpy()
        train_step += 1

        # 10 step마다 loss 초기화
        if train_step%10 == 0:
            loss = train_loss/len(word2vec_loader)
            train_losses.append(loss)
            train_loss = 0
        if train_step%100 == 0:
            print(f"trained {train_step}/{total_train_step} steps.")

4-2. Word2Vec 벡터를 이용해 nsmc 학습하기

Word2Vec 벡터를 이용하는 방법은 기존 모델의 임베딩 레이어를 Word2Vec의 임베딩 레이어로 교체하면 됩니다. 역시 그 외의 것들은 거들지 않았습니다.

class ReviewSentimentClassifier(nn.Module):
    def __init__(self, vocab_size: int):
        super().__init__()
        # 임베딩 레이어를 Word2vec 벡터로 대체한다.
        self.embedding = word2vec.embedding
        self.rnn = nn.LSTM(300, 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_with_word2vec = ReviewSentimentClassifier(vocab_size = len(sp))

그리고 학습을 하면,

epochs = 10
lr = 1e-3

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

word2vec_train_losses, word2vec_eval_losses, word2vec_eval_accuracies = train(
    model_with_word2vec, criterion, optimizer, sp_train_loader, sp_test_loader, epochs
)

 

사전 학습을 하지 않은 모델, Glove, Word2Vec 3개의 모델의 성능을 비교해 보면...

fig, ax = plt.subplots(1, 2, figsize=(20, 8))

ax[0].plot(sp_eval_losses, label="pure embedding")
ax[0].text(9, sp_eval_losses[-1]+0.0025, round(sp_eval_losses[-1], 2))
ax[0].plot(glove_eval_losses, label="glove embedding")
ax[0].text(9, glove_eval_losses[-1]+0.0025, round(glove_eval_losses[-1], 2))
ax[0].plot(word2vec_eval_losses, label="word2vec embedding")
ax[0].text(9, word2vec_eval_losses[-1]+0.0025, round(word2vec_eval_losses[-1], 2))
ax[0].legend(loc="lower right")
ax[0].set_title("eval loss graph")

ax[1].plot(sp_eval_accuracies, label="pure embedding")
ax[1].text(9, sp_eval_accuracies[-1]+0.0025, round(sp_eval_accuracies[-1], 2))
ax[1].plot(glove_eval_accuracies, label="glove embedding")
ax[1].text(9, glove_eval_accuracies[-1]+0.0025, round(glove_eval_accuracies[-1], 2))
ax[1].plot(word2vec_eval_accuracies, label="word2vec embedding")
ax[1].text(9, word2vec_eval_accuracies[-1]+0.0025, round(word2vec_eval_accuracies[-1], 2))
ax[1].legend(loc="lower right")
ax[1].set_title("eval acc graph")

사전 학습하지 않은 모델(pure), Glove 모델, Word2Vec 모델 3개의 학습 그래프 비교

성능은 Glove > Word2Vec >= pure 순으로 나타났습니다. Glove가 Word2Vec보다 더 좋다는 것을 보여주려고 한 것은 아닙니다. 파라미터 조정에 따라 결과는 달라질 수도 있으니까요. 중요한 것은 Glove, Word2Vec 사전 학습 방식이 모델의 성능에 영향을 준다는 것입니다.

 

Glove와 Word2Vec은 영화 리뷰를 분류하는 학습을 하지 않고 비지도 학습을 수행했음에도 nsmc 리뷰 분류에서 긍정적인 효과를 보여줬습니다. 이것이 인공지능에게 단어의 의미를 알려주는 방법이라고 볼 수 있겠죠. 적절한 단어 임베딩을 형성하는 것이 모델에게 긍정적인 영향을 줄 수 있다는 것입니다.

 

이는 최신 NLP에 사용되는 transformer 기반의 BERT, GPT와 같은 사전학습 모델들에서도 사용되는 개념입니다. 대규모의 라벨 없는 텍스트를 비지도 사전 학습을 통해 학습한 뒤, 이를 이용해 다양한 task들에 활용하는 것이죠. Transformer 기반의 모델들이 SOTA를 줄줄이 갈아치우면서 Glove나 Word2Vec을 활용한 LSTM, CNN 기반의 모델들은 잘 사용되지 않게 되었지만, 그래도 transformer 기반의 모델들이 꽤 무겁다는 것을 생각하면 Glove나 Word2Vec 기반의 모델들도 활용할 부분이 충분히 많다고 생각해서 자세히 다뤄봤습니다.

 

Glove와 Word2Vec을 이해하는데 이 글이 도움이 됐으면 좋겠습니다. 감사합니다.