안녕, 세상!

(2)-3-1. word2vec 본문

It공부/Deep learning

(2)-3-1. word2vec

dev_Lumin 2021. 1. 17. 17:33

'추론 기반 기법'으로 단어를 분석하는데, 대표적인 신경망 모델인 word2vec을 사용합니다.

이번에는 처리 효율을 희생하지만 이해하기 쉽게 단순하게 word2vec을 구현해보겠습니다.

 

(1) 추론 기반 기법과 신경망

단어를 벡터로 표현하는 방법 중 가장 성공적인 기법들을 크게 두 분류로 나눌 수 있습니다.

'통계 기반'과 '추론 기반' 기법입니다.

단어의 의미를 얻는 방식은 서로 크게 다르지만, 그 배경에는 모두 분포 가설이 있습니다.

분포 가설이란,

'단어의 의미는 주변 단어에 의해 형성된다'라는 가설입니다.

추론 기반 기법에서는 이를 추측 문제로 귀결시킵니다.

두 기법 모두 분포 가설에 근거하여 '단어의 동시발생 가능성'을 얼마나 잘 모델링하는가를 초점으로 맞추고 있습니다.

① 통계 기반 기법의 문제점

통계 기반 기법은 주변 단어의 빈도를 기초로 단어를 표현했습니다.

그러나 이 방식은 대규모 말뭉치를 다룰 때 문제가 발생합니다.

현업에서는 대규모의 말뭉치를 다루는데 어휘 수가 엄청 많게 되며, 100만은 훌쩍 넘을 것입니다.

통계 기반 기법에서는 100만x100만 이라는 거대한 행렬을 만들어 SVD를 적용하게 되는데 현실적이지 못합니다.

 

SVD를 nxn 행렬에 적용하는 비용은 O(n^3), 즉 계산 시간이 n의 3 제곱에 비례합니다.

상당한 컴퓨팅 자원을 들여서 장시간 계산을 해야 합니다.

 

 

통계 기반 기법은 말뭉치 전체의 통계를 이용해 단 1회의 처리(SVD)만에 단어의 분산 표현을 얻습니다.

한편, 추론 기반 기법에서 신경망을 이용하는 경우는 신경망이 한 번에 소량의 학습 샘플씩 반복해서 학습하며, 가중치를 갱신해가는 미니 배치로 학습하는 것이 일반적입니다.

 

정리하자면,

통계 기반 기법은 학습 데이터를 한꺼번에 처리,

추론 기반 기법은 학습 데이터의 일부를 사용하여 순차적으로 처리합니다.

 

 

 

② 추론 기반 기법 

추론이란 말 그대로 주변의 근거들을 바탕으로 추측하는 작업입니다.

이를 단어의 추론을 예를 들어 보자면,

You [ ? ] goodbye and I say hello.

라는 문장에서 맥락을 살피고 '?'에 들어갈 단어를 추측하는 것입니다.

모델은 이러한 추론 문제를 반복해서 풀면서 단어의 출현 패턴을 학습하는 것입니다.

 

모델에게 input으로 맥락을 주면, 모델은 해당 영역에 각 단어의 출현 확률을 출력하는 형식이 되는 것입니다.

 

 

신경망에서의 단어 처리는 'you', 'say'와 같은 단어를 있는 그대로 처리할 수 없으므로,

'고정 길이의 벡터'로 변환해야 합니다.

대표적으로 One-hot-encoding 방식이 있습니다.

'You say goodbye and i say hello .' 라는 문장에서 

say는 [0,1,0,0,0,0,0]와 같이 표현됩니다.

 

 

그럼 간단하게 위의 예시를 이용하여 입력층과 퍼셉트론 3개만 있는 은닉층 1계만 구성돼있는 코드를 짜면 다음과 같습니다.

여기서 주목할 곳은 c와 W의 행렬 곱입니다.

위 코드의 C와 W의 행렬곱은 결국 가중치의 행 벡터 하나를 뽑아낸 것과 같습니다.

그저 가중치로부터 행 벡터를 뽑아낼 뿐인데 행렬 곱을 계산하는 것이 비효율적이라고 생각할 수 있습니다.

이는 뒤에서 설명하겠습니다.

일단 의미적인 부분을 이해하면 됩니다.

 

 

(2) 단순한 word2vec

  word2vec이라는 용어는 원래 프로그램이나 도구를 가리키는데 사용됐습니다.

그런데 이용어가 유명해지면서, 문맥에 따라서는 신경망 모델을 가리키는 경우도 많이 볼 수 있습니다.

CBOW 모델, skip-gram 모델은 word2vec에서 사용되는 신경망입니다.

이번에는 CBOW (Continuous bag-of-words) 모델에 대해서 보겠습니다.

 

① CBOW 모델

CBOW 모델은 맥락으로부터 중앙 단어를 추측하는 용도의 신경망입니다.

그러므로 CBOW 모델의 input은 맥락입니다.

맥락은 전에 예시로 말했듯이 'you', 'goodbye' 같은 단어들이라고 할 수 있습니다.

 

앞서 말한 예시 

You [ ? ] goodbye and I say hello.

에서 맥락으로 고려할 단어는 앞뒤 합쳐서 2개라고 하고 계층을 그림으로 표현하면 다음과 같습니다.

 

위의 그림에서 은닉층에 주목하겠습니다.

은닉층의 뉴런은 입력층의 완전 연결 계층에 의해 변환된 값이 되는데,

입력층이 여러 개이면 전체를 '평균'하면 됩니다.

완전 연결 계층에 의해 첫 번째 입력층의 변환 결과를 h1, 두 번째 입력층의 변환 결과를 h2라고 하면, 은닉층은 1/2*(h1+h2)가 되는 것입니다.

 

출력층은 Softmax를 달아서 확률 형태로 출력시키고 학습시키면 될 것입니다.

 

입력층과 은닉층 사이의 가중치들이 있을 텐데, 가중치들이 의미하는 바는 단어의 분산 표현입니다.

즉, 단어와 단어들 간의 의미와 관계를 나타낸다고 할 수 있습니다.

 

은닉층의 뉴런 수를 입력층의 뉴런 수보다 적게 하는 것이 중요한 핵심입니다.

이렇게 해야 은닉층에는 단어 예측에 필요한 정보를 '간결하게' 담게 되며, 결과적으로 밀집 벡터 표현을 얻을 수 있습니다.

 

위를 코드로 표현하면 다음과 같습니다. (아직 출력층에는 softmax 적용 안 함)

MatMul 클래스는 이전에 따로 만든 클래스이며 다음과 같습니다.

 

입력층에 크게 두 개의 계층이 있고 입력층 두 개의 계층은 각 입력층 계층의 MatMul은 W_in을 공유한다는 점에 주의합니다.

 

 

② CBOW 모델의 학습

CBOW 모델은 단어 출현 패턴을 학습 시 사용한 말뭉치로부터 배웁니다.

따라서 말뭉치가 다르면 학습 후 얻게 되는 단어의 분산 표현도 달라집니다.

예컨대 말뭉치로 '음식' 관련 내용을 사용하는 경우와 '음악'관련 내용을 사용하는 경우는 얻게 되는 단어의 분산 표현이 크게 다를 것입니다.

 

우선 지금 다르고 있는 모델은 Classification 분류 모델입니다.

따라서 신경망을 학습시키려면 Softmax와 Cross entropy 손실 함수를 사용하면 됩니다.

이를 그림으로 나타내면 다음과 같습니다.

Softmax 부분과 Cross Entropy 부분은 합쳐서 표현했습니다.

 

지금까지 말한 word2vec에서 사용되는 신경망에는 두 가지 가중치가 있습니다.

입력 측 완전 연결 계층의 가중치 W(in)과

출력 측 완전 연결 계층의 가중치 W(out)입니다.

W(in) 가중치의 각 행이 각 단어의 분산 표현에 해당하고,

W(out) 가중치에도 역시 단어의 의미 즉 분산 표현이 담겨 있다고 생각할 수 있습니다.

 

다만 둘의 차이점은 분산 표현의 저장 방향이 행이냐 열(수직방향)이냐 차이입니다.

 

그러면 최종적으로 이용하는 단어의 분산 표현으로 가중치를 선택하는 방법이 3가지가 됩니다.

 

A. 입력 측의 가중치만 분산 표현으로 이용한다.

B. 출력 측의 가중치만 분산 표현으로 이용한다.

C. 양쪽 가중치 모두 분산표현으로 이용한다.

 

word2vec(특히 skip-gram 모델)에서는 A 안인 '입력 측의 가중치만 이용한다'가 가장 대중적인 선택입니다.

많은 연구에서 출력 측 가중치는 버리고 입력 측 가중치만을 최종 단어의 분산 표현으로서 이용합니다.

 

 

 

(3) 학습 데이터 준비

 

 

 

 

이를 사용할 수 있도록 다음과 같이 만듭니다.

이를 위한 코드는 다음과 같습니다.

def create_contexts_target(corpus, window_size=1):
    target = corpus[window_size:-window_size]
    contexts = []

    for idx in range(window_size, len(corpus)-window_size):
        cs = []
        for t in range(-window_size, window_size + 1):
            if t == 0:   # 0이 의미하는 것은 target을 의미하므로 continue로 넘김
                continue
            cs.append(corpus[idx + t]) # corpus[idx]중심으로 양쪽의 context들을 cs[]에 저장
        contexts.append(cs)     # 그러한 cs[] 들을 context[]안에 저장

    return np.array(contexts), np.array(target)

이를 다음과 같이 실행시키면 문맥들(Contexts)과 Target이 잘 나오는 것을 확인할 수 있습니다.

 

 

이제 ID로 되어 있는 원소들을 모델에 넣을 수 있도록  One-hot-encoding을 시켜줍니다.

 

이를 위해 다음과 같이 convert_one_hot() 함수를 사용합니다.

def convert_one_hot(corpus, vocab_size):
    '''one-hot으로 표현으로 변환
    :param corpus: 단어 ID 목록(1차원 또는 2차원 넘파이 배열)
    :param vocab_size: 어휘 수
    :return: 원핫 표현(2차원 또는 3차원 넘파이 배열)
    '''
    N = corpus.shape[0]

    if corpus.ndim == 1:
        one_hot = np.zeros((N, vocab_size), dtype=np.int32)
        for idx, word_id in enumerate(corpus):
            one_hot[idx, word_id] = 1

    elif corpus.ndim == 2:
        C = corpus.shape[1]
        one_hot = np.zeros((N, C, vocab_size), dtype=np.int32)
        for idx_0, word_ids in enumerate(corpus):
            for idx_1, word_id in enumerate(word_ids):
                one_hot[idx_0, idx_1, word_id] = 1

    return one_hot

 

이를 사용하여 다음과 같이 변환을 해주면 됩니다.

모델 학습을 위한 데이터가 준비되었습니다.

 

 

(4) CBOW 모델 구현

이제 모델을 대략적으로 구현해보겠습니다.

CBOW에 대한 클래스를 간략적으로 표현하면 다음과 같습니다.

class SimpleCBOW:
    def __init__(self, vocab_size, hidden_size):
        V, H = vocab_size, hidden_size

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

        # 계층 생성
        self.in_layer0 = MatMul(W_in)
        self.in_layer1 = MatMul(W_in)
        self.out_layer = MatMul(W_out)
        self.loss_layer = SoftmaxWithLoss()

        # 모든 가중치와 기울기를 리스트에 모은다.
        layers = [self.in_layer0, self.in_layer1, self.out_layer]
        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):
        h0 = self.in_layer0.forward(contexts[:, 0])  # Matmul class의 forward()
        h1 = self.in_layer1.forward(contexts[:, 1])
        h = (h0 + h1) * 0.5
        score = self.out_layer.forward(h)
        loss = self.loss_layer.forward(score, target)
        return loss

    def backward(self, dout=1):
        ds = self.loss_layer.backward(dout)    # Matmul class의 backward()
        da = self.out_layer.backward(ds)
        da *= 0.5
        self.in_layer1.backward(da)
        self.in_layer0.backward(da)
        return None

앞서 말했듯이 단어의 의미 관계 즉, 분산을 나타내는 가중치는 입력 쪽 가중치만 사용하겠다고 했으므로, 

self.word_vecs = W_in 

 

위의 코드에서 필요한 몇몇 함수는 다음과 같습니다. ( 이미 위에서 나온 함수들은 안 씀)

def softmax(x):
    if x.ndim == 2:
        x = x - x.max(axis=1, keepdims=True)
        x = np.exp(x)
        x /= x.sum(axis=1, keepdims=True)
    elif x.ndim == 1:
        x = x - np.max(x)
        x = np.exp(x) / np.sum(np.exp(x))

    return x


def cross_entropy_error(y, t):
    if y.ndim == 1:
        t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)
        
    # 정답 데이터가 원핫 벡터일 경우 정답 레이블 인덱스로 변환
    if t.size == y.size:
        t = t.argmax(axis=1)
             
    batch_size = y.shape[0]

    return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size
    
    
class SoftmaxWithLoss:
    def __init__(self):
        self.params, self.grads = [], []
        self.y = None  # softmax의 출력
        self.t = None  # 정답 레이블

    def forward(self, x, t):
        self.t = t
        self.y = softmax(x)

        # 정답 레이블이 원핫 벡터일 경우 정답의 인덱스로 변환
        if self.t.size == self.y.size:
            self.t = self.t.argmax(axis=1)

        loss = cross_entropy_error(self.y, self.t)
        return loss

    def backward(self, dout=1):
        batch_size = self.t.shape[0]

        dx = self.y.copy()
        dx[np.arange(batch_size), self.t] -= 1
        dx *= dout
        dx = dx / batch_size

        return dx

 

SimpleCBOW의 클래스 중 backward 과정은 그림으로 표현하면 다음과 같습니다.

 

 

필요한 CBOW 클래스를 선언하였으므로 이제 학습하는 과정이 필요합니다.

학습을 하기 위해서 다음 Trainer class를 사용합니다.

import numpy
import time
import matplotlib.pyplot as plt
# from common.np import *  # import numpy as np
# from common.util import clip_grads


class Trainer:
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.loss_list = []
        self.eval_interval = None
        self.current_epoch = 0

    def fit(self, x, t, max_epoch=10, batch_size=32, max_grad=None, eval_interval=20):
        data_size = len(x)
        max_iters = data_size // batch_size
        self.eval_interval = eval_interval
        model, optimizer = self.model, self.optimizer
        total_loss = 0
        loss_count = 0

        start_time = time.time()
        for epoch in range(max_epoch):
            # 뒤섞기
            idx = numpy.random.permutation(numpy.arange(data_size))
            x = x[idx]
            t = t[idx]

            for iters in range(max_iters):
                batch_x = x[iters*batch_size:(iters+1)*batch_size]
                batch_t = t[iters*batch_size:(iters+1)*batch_size]

                # 기울기 구해 매개변수 갱신
                loss = model.forward(batch_x, batch_t)
                model.backward()
                params, grads = remove_duplicate(model.params, model.grads)  # 공유된 가중치를 하나로 모음
                if max_grad is not None:
                    clip_grads(grads, max_grad)
                optimizer.update(params, grads)
                total_loss += loss
                loss_count += 1

                # 평가
                if (eval_interval is not None) and (iters % eval_interval) == 0:
                    avg_loss = total_loss / loss_count
                    elapsed_time = time.time() - start_time
                    print('| 에폭 %d |  반복 %d / %d | 시간 %d[s] | 손실 %.2f'
                          % (self.current_epoch + 1, iters + 1, max_iters, elapsed_time, avg_loss))
                    self.loss_list.append(float(avg_loss))
                    total_loss, loss_count = 0, 0

            self.current_epoch += 1

    def plot(self, ylim=None):
        x = numpy.arange(len(self.loss_list))
        if ylim is not None:
            plt.ylim(*ylim)
        plt.plot(x, self.loss_list, label='train')
        plt.xlabel('반복 (x' + str(self.eval_interval) + ')')
        plt.ylabel('손실')
        plt.show()

위의 코드에서 remove_duplicate() 함수가 있는데 이 함수는 CBOW 모델의 계층에서 입력 층 부분에서 두 개의 입력층이 들어 있고 두 개의 입력층이 같은 가중치를 공유하고 있는 상태라서,

SimpleCBOW class 코드에서 self.params와 self.grads에 가중치 값과 기울기를 리스트 형식으로 보관하는 과정에서 똑같은 값들이 두 번 반복되어서 저장될 것입니다.

(self.in_layer0과 self.in_layer1 의 가중치와 기울기 값이 같기 때문)

그렇기 때문에 이를 그대로 사용하면 안 되기 때문에 이를 고려해서,

중복되는 가중치를 하나로 모으고 그 가중치에 해당되는 기울기를 서로 더합니다.

 

코드는 다음과 같습니다.

def remove_duplicate(params, grads):
    '''
    매개변수 배열 중 중복되는 가중치를 하나로 모아
    그 가중치에 대응하는 기울기를 더한다.
    '''
    params, grads = params[:], grads[:]  # copy list

    while True:
        find_flg = False
        L = len(params)

        for i in range(0, L - 1):
            for j in range(i + 1, L):
                # 가중치 공유 시
                if params[i] is params[j]:
                    grads[i] += grads[j]  # 경사를 더함
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)
                # 가중치를 전치행렬로 공유하는 경우(weight tying)
                elif params[i].ndim == 2 and params[j].ndim == 2 and \
                     params[i].T.shape == params[j].shape and np.all(params[i].T == params[j]):
                    grads[i] += grads[j].T
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)

                if find_flg: break
            if find_flg: break

        if not find_flg: break

    return params, grads

 

이것은 아직 사용하지는 않을 것이지만 clip_grads() 함수 코드는 다음과 같습니다.

def clip_grads(grads, max_norm):
    total_norm = 0
    for grad in grads:
        total_norm += np.sum(grad ** 2)
    total_norm = np.sqrt(total_norm)

    rate = max_norm / (total_norm + 1e-6)
    if rate < 1:
        for grad in grads:
            grad *= rate

 

 

이제 Trainer 클래스를 사용하여 학습 코드를 구현하면 다음과 같이 구현할 수 있습니다.

window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000

text = 'You say goodbye and I say hello.'
corpus, word_to_id, id_to_word = preprocess(text)

vocab_size = len(word_to_id)
contexts, target = create_contexts_target(corpus, window_size)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)

model = SimpleCBOW(vocab_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)

trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
    print(word, word_vecs[word_id])

학습을 거듭할수록 손실이 줄어드는 것을 확인할 수 있습니다.

word_vecs는 분산 표현이 저장되어 있고 위와 같이 밀집 벡터로 각 단어들의 의미를 벡터 형태로 나타낼 수 있습니다.

 

하지만 여기서 다룬 작은 말뭉치는 많이 작기 때문에 좋은 결과를 얻기 힘듭니다.

큰 말뭉치를 사용한다면 결과도 그만큼 좋아지겠지만, 처리 속도면에서 문제가 생깁니다.

위의 처리 효율 면에서 개선을 한 진짜 CBOW 모델을 다음에 구현해 보겠습니다.

 

 

 

참고

위의 코드에서 사용한 optimizer Adam 코드는 다음과 같습니다.

class Adam:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.iter = 0
        self.m = None
        self.v = None
        
    def update(self, params, grads):
        if self.m is None:
            self.m, self.v = [], []
            for param in params:
                self.m.append(np.zeros_like(param))
                self.v.append(np.zeros_like(param))
        
        self.iter += 1
        lr_t = self.lr * np.sqrt(1.0 - self.beta2**self.iter) / (1.0 - self.beta1**self.iter)

        for i in range(len(params)):
            self.m[i] += (1 - self.beta1) * (grads[i] - self.m[i])
            self.v[i] += (1 - self.beta2) * (grads[i]**2 - self.v[i])
            
            params[i] -= lr_t * self.m[i] / (np.sqrt(self.v[i]) + 1e-7)

 

 

 

(위의 내용은 밑바닥부터 시작하는 딥러닝 2 (한빛미디어)를 바탕으로 글을 작성하였습니다)

 

 

 

 

Comments