[NLP] queen - women = king - men

O(logn)·2023년 10월 30일
0
post-thumbnail
post-custom-banner

사진: UnsplashIshan @seefromthesky

목차

  • 완전판 CBOW
  • king - men = queen - women

지금까지 기존 word2vec을 개선해왔다. word2vec의 두 가지 병목은 입력 측 가중치 행렬 계산과 출력측 가중치 행렬, softmax층에서 발생했다. 이를 해결하기 위해 Embedding층, Embedding dot층에 이어 negative sampling 기법을 적용했다. 이러한 개선을 신경망 구현에 적용하고 PTB 데이터셋으로 테스트 해볼 것이다.

CBOW 모델 구현

이전 포스팅에서 살펴본 SimpleCBOW에서 Embedding 계층과 Negative Smpling Loss 계층을 적용해 개선한 코드이다. 추가로 맥락의 윈도우 크기를 임의로 조절할 수 있도록 확장한다.

# coding: utf-8
import sys
sys.path.append('..')
from common.np import *  # import numpy as np
from common.layers import Embedding
from ch04.negative_sampling_layer import NegativeSamplingLoss


class CBOW:
    def __init__(self, vocab_size, hidden_size, window_size, corpus):
        V, H = vocab_size, hidden_size

        # 가중치 초기화
        W_in = 0.01 * np.random.randn(V, H).astype('f')
        W_out = 0.01 * np.random.randn(V, H).astype('f')

        # 계층 생성
        self.in_layers = []
        for i in range(2 * window_size):
            layer = Embedding(W_in)  # Embedding 계층 사용
            self.in_layers.append(layer)
        self.ns_loss = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=5)

        # 모든 가중치와 기울기를 배열에 모은다.
        layers = self.in_layers + [self.ns_loss]
        self.params, self.grads = [], []
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads

        # 인스턴스 변수에 단어의 분산 표현을 저장한다.
        self.word_vecs = W_in

    def forward(self, contexts, target):
        h = 0
        for i, layer in enumerate(self.in_layers):
            h += layer.forward(contexts[:, i])
        h *= 1 / len(self.in_layers)
        loss = self.ns_loss.forward(h, target)
        return loss

    def backward(self, dout=1):
        dout = self.ns_loss.backward(dout)
        dout *= 1 / len(self.in_layers)
        for layer in self.in_layers:
            layer.backward(dout)
        return None
class CBOW:
    def __init__(self, vocab_size, hidden_size, window_size, corpus):
        V, H = vocab_size, hidden_size
        # 가중치 초기화
        W_in = 0.01 * np.random.randn(V, H).astype('f')
        W_out = 0.01 * np.random.randn(V, H).astype('f')
        # 계층 생성
        self.in_layers = []
        for i in range(2 * window_size):
            layer = Embedding(W_in)  # Embedding 계층 사용
            self.in_layers.append(layer)
        self.ns_loss = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=5)
        # 모든 가중치와 기울기를 배열에 모은다.
        layers = self.in_layers + [self.ns_loss]
        self.params, self.grads = [], []
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads
        # 인스턴스 변수에 단어의 분산 표현을 저장한다.
        self.word_vecs = W_in

: 초기화 메소드는 4개의 인수를 받는다. vocab_size는 어휘 수, hidden_size는 은닉층의 뉴런 수, corpus는 단어 ID 목록이다. 그리고 맥락의 크기(주변 단어 중 몇 개나 맥락으로 포함시킬지)를 window_size로 지정한다. 예컨대 window_size가 2이면 타깃 단어의 좌우 2개씩, 총 4개 단어가 맥락이 된다. SimpleCBOW 클래스는 입력 측의 가중치와 출력 측의 가중치의 형상이 달라서 출력 측의 가중치에서는 단어 벡터가 열 방향으로 배치되었다. 하지만 CBOW클래스의 출력 측 가중치는 입력 측 가중치와 같은 형상으로 단어 벡터가 행 방향에 배치된다. 그이유는 NegativeSmaplingLoss 클래스에서 Embedding 계층을 사용하기 떄문이다.
가중치 초기화가 끝나면 이어서 계층을 생성한다. 여기에서는 Embedding 계층을 2* window_size개 작성하여 인스턴스 변수인 in_layers에 배열로 보관한다. 그 다음 Negative Smpling Loss 계층을 생성한다. 계층을 다 생성했으면 이 신경망에서 사용하는 모든 매개변수와 기울기를 인스턴스 변수인paramsgards에 모은다. 또한 나중에 단어의 분산 표현에 접근할 수 있도록 인스턴스 변수인 word_vecs에 가중치 W_in을 할당한다.

    def forward(self, contexts, target):
        h = 0
        for i, layer in enumerate(self.in_layers):
            h += layer.forward(contexts[:, i])
        h *= 1 / len(self.in_layers)
        loss = self.ns_loss.forward(h, target)
        return loss
    def backward(self, dout=1):
        dout = self.ns_loss.backward(dout)
        dout *= 1 / len(self.in_layers)
        for layer in self.in_layers:
            layer.backward(dout)
        return None

이 구현은 각 계층의 순전파/역전파를 적절한 순서로 호출할 뿐이다. 앞장의 SimpleCBOW클래스를 자연스럽게 확장한 것이다. 단, forward(contexts,target)메서드가 인수로 받는 맥락과 타깃이 단어 ID라는 점이 다르다.(앞 장에서는 단어 ID를 원핫 벡터로 변환해서 사용했다. ) 구체적인 예시는 [그림 4-19]와 같다.

[그림 4-19]의 오른쪽에 보이는 단어 ID의 배열이 contextstarget의 예이다. contexts는 2차원 배열이고 target은 1차원 배열이다. 이러한 데이터가 forward(contexts, target)에 입력되는 것이다.

CBOW 모델 학습 코드

# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
from common import config
# GPU에서 실행하려면 아래 주석을 해제하세요(CuPy 필요).
# ===============================================
# config.GPU = True
# ===============================================
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from cbow import CBOW
from skip_gram import SkipGram
from common.util import create_contexts_target, to_cpu, to_gpu
from dataset import ptb


# 하이퍼파라미터 설정
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10

# 데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)

contexts, target = create_contexts_target(corpus, window_size)
if config.GPU:
    contexts, target = to_gpu(contexts), to_gpu(target)

# 모델 등 생성
model = CBOW(vocab_size, hidden_size, window_size, corpus)
# model = SkipGram(vocab_size, hidden_size, window_size, corpus)
optimizer = Adam()
trainer = Trainer(model, optimizer)

# 학습 시작
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

# 나중에 사용할 수 있도록 필요한 데이터 저장
word_vecs = model.word_vecs
if config.GPU:
    word_vecs = to_cpu(word_vecs)
params = {}
params['word_vecs'] = word_vecs.astype(np.float16)
params['word_to_id'] = word_to_id
params['id_to_word'] = id_to_word
pkl_file = 'cbow_params.pkl'  # or 'skipgram_params.pkl'
with open(pkl_file, 'wb') as f:
    pickle.dump(params, f, -1)

윈도우 크기를 5로, 은닉층의 뉴런 수를 100개로 설정했다. 사용하는 말뭉치에 따라 다르지만 윈도우 크기는 2~10개, 은닉층의 뉴런 수(단어의 분산 표현의 차원 수)는 50~500개 정도면 통상 좋은 결과를 얻을 수 윘다.

이번에 학습시킬 PTB 데이터는 지금까지의 말뭉치보다 월등히 커서 학습 시간이 상당히 오래 걸린다. 그래서 GPU를 사용할 수 있는 모드가 추가되었다. GPU로 실행하려면 파일 앞부분에 있는 # config.GPU = True 주석을 해제해야 한다. 이를 시행하기에 앞서 엔비디아 GPU를 장착한 컴퓨터여야하고 쿠파이도 미리 설치해야 한다.

학습이 끝나면 가중치를 꺼내 나중에 이용할 수 있도록 파일에 보관한다. (단어와 단어 ID 변환을 위해 사전도 함꼐 보관한다. ) 파일로 저장할 때는 파이썬의 'pickle' 기능을 이용한다. 피클은 파이썬 코드의 객체를 파일로 저장 ( 또는 파일에서 읽기)하는 데 이용할 수 있따.

CBOW 모델 평가

# coding: utf-8
import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle


pkl_file = 'cbow_params.pkl'
# pkl_file = 'skipgram_params.pkl'

with open(pkl_file, 'rb') as f:
    params = pickle.load(f)
    word_vecs = params['word_vecs']
    word_to_id = params['word_to_id']
    id_to_word = params['id_to_word']

# 가장 비슷한(most similar) 단어 뽑기
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
    most_similar(query, word_to_id, id_to_word, word_vecs, top=5)

# 유추(analogy) 작업
print('-'*50)
analogy('king', 'man', 'queen',  word_to_id, id_to_word, word_vecs)
analogy('take', 'took', 'go',  word_to_id, id_to_word, word_vecs)
analogy('car', 'cars', 'child',  word_to_id, id_to_word, word_vecs)
analogy('good', 'better', 'bad',  word_to_id, id_to_word, word_vecs)

이 코드를 실행시키면 다음과 같은 결과를 얻을 수 있다.

'you'를 입력하니 비슷한 단어로 인칭대명사 'i'와 'we' 등이 나왔다. 'year'에 대해서는 'month'와 'week'같은 기간을 뜻하는 같은 성격의 단어들이 나왔다. 'toyota'에 대해서는 'ford', 'mazda','nissan' 같은 자동차 메이커가 나왔다. 이 결과를 보면 CBOW모델로 획득한 단어의 분산 표현은 제법 괜찮은 특성을 지닌다고 말 할 수 있을 것이다.

word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 가까이 모을 뿐 아니라, 더 복잡한 패턴을 파악하는 것으로 알려져있다. 대표적인 예가 'king - man + women = queen'으로 유명한 유추문제이다. 더 정확하게 말하면, word2vec의 단어의 분산 표현을 사용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있따는 뜻이다.

실제로 유추 문제를 풀려면 [그림 4-20]처럼 단어 벡터 공간에서 'man -> woman' 벡터와 'king -> ?' 벡터가 가능한 가까워지는 단어를 찾는다.

단어 'man'의 분산 표현(딴어 벡터)을 vec('man')이라고 표현해보자. 그러면 그림 [4-20]에서 얻고 싶은 관계를 수식으로 나타내면 vec('women') - vec('men') = vec('woman') - vec('man') = vec(?)라는 벡터에 가장 가까운 단어 벡터를 구하는 일이 됩니다. 이 로직을 구현한 함수는 analogy()이다. 이 함수를 사용하면 지금과 같은 유추 문제를 analogy('man','king','woman',word_to_id, id_to_Word, word_Vecs, top = 5)라는 한 줄로 처리할 수 있습니다. 이 함수를 실행하면 결과가 다음과 같은 형태로 출력된다.


이와 같이 첫 번째 줄에 문제 문장이 출력되고, 다음 줄부터는 점수가 높은 순으로 5개의 단어가 출력된다. 각 단어 옆에는 점수가 표시된다. 다음 4개의 유추 문제를 풀어보자.


사람과 비슷하게 유추하는 것을 확인할 수 있다. 의미 뿐 아니라 went, took 등과 같은 비교형도 구분할 수 있으며 more, better과 같은 비교형도 구분하고 있다. 비록 PTB 데이터셋의 크기적 한계 때문에 대부분의 답은 정확하지 않지만, 더 정확하고 견고한 단어의 분산 표현을 얻을 수 있다면 유추 문제의 정확도도 크게 향상될 것이다.

profile
는 내 성장의 시간 복잡도!!
post-custom-banner

0개의 댓글