[NLP] 텍스트 전처리(CBOW, Skip-gram)

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

썸네일 출처: 사진: UnsplashRiccardo Chiarini


목차

  • Review(one-hot vector, embedding)
  • 추론 기반 기법(word2vec)
  • CBOW의 작동 방식
  • Skip-gram의 작동 방식

이전 포스팅에서는 1세대와 2세대 텍스트 전처리 방법에 대해 알아보았다. 이번에는 3세대인 예측 기반 텍스트 전처리 모델 두 가지를 살펴보려고 한다. 본격적으로 소개하기 앞서 1세대와 2세대에 대한 간략한 요약과 3세대로 이어지는 흐름에 대해 정리할 것이다.

Review

1세대는 아주 정직하고 단순한 방법으로 단어를 벡터화하였다. 이런 식이다.

I am a boy, you are a girl.

단어ID벡터
I0[1,0,0,0,0,0,0,0,0]
am1[0,1,0,0,0,0,0,0,0]
a2[0,0,1,0,0,0,0,0,0]
boy3[0,0,0,1,0,0,0,0,0]
,4[0,0,0,0,1,0,0,0,0]
you5[0,0,0,0,0,1,0,0,0]
are6[0,0,0,0,0,0,1,0,0]
girl7[0,0,0,0,0,0,0,1,0]
.8[0,0,0,0,0,0,0,0,1]

아래 문장을 행렬로 바꿔보면 다음과 같다.

I am a boy, you are a girl.
[[1,0,0,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0,0],
[0,0,1,0,0,0,0,0,0],
[0,0,0,1,0,0,0,0,0],
[0,0,0,0,1,0,0,0,0],
[0,0,0,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0,0],
[0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0],
[0,0,0,0,0,0,0,0,1]]

이 방식은 낭비되는 공간이 너무 많고, 심지어 벡터가 그렇게 많은 정보를 담고 있지도 않다. 이를 보완하는 것이 2세대 통계 기반(count-based) 기법이다.

2세대는 1세대와 달리 벡터가 의미를 담고 있다. 하나의 단어의 바로 앞, 뒤 이웃 단어를 통해 문맥 정보를 벡터에 담을 수 있다.

I am a boy, you are a girl.

Iamaboy,youaregirl.
I010000000
am101000000
a010100110
boy001010000
,000101000
you000010100
are001010000
girl001000001
.000000010

이렇게 만들어진 벡터는 코사인 유사도 계산을 할 수 있다. 즉, 어떤 단어들이 서로 더 유사한 지를 객관적으로 판단할 수 있게된 것이다! 또, PPMI행렬로 변환하고 다시 SVD로 차원을 축소시킴으로써 '희소행렬'에서 '밀집행렬'로 변환할 수 있다.

하지만 동시발생행렬도 만능은 아니다. nnn*n 크기(nn: 단어 수)의 행렬을 차원축소하는 데 드는 시간 복잡도는 O(n3n^3)에 달하며, 통상 O(n2n^2)만 되어도 쓰레기 취급을 받는다. 참조

단어 수가 100만 개가 넘어가면 어떻게 벡터로 변환을 할 수 있을까? 답은 3세대 추론 기반(prediction-based) 기법이다.

추론 기반 기법

1) 2세대 통계 기반 기법 vs 3세대 추론 기반 기법

  • 두 기법의 공통점은 '단어의 의미는 주변 단어에 의해 형성된다'는 분포 가설에 기초한다는 것이다.
  • 즉 '단어의 동시발생 가능성'을 얼마나 잘 모델링하는지가 쟁점이다.
  • 가장 큰 차이는 한꺼번에 (배치)학습하느냐, 데이터를 쪼개어 순차적으로 (미니배치)학습하느냐
  • 3세대 추론 기반 기법은 말뭉치가 어휘 수가 많아도 미니배치 학습을 통해 처리할 수 있다.
  • 또, GPU를 이용한 병렬 계산도 가능해져 학습 속도를 높일 수 있다.

2) 추론 기반은 무엇을 추론하는 것일까?

  1. 주변 단어(문맥)가 주어졌을 때 '?'에 들어갈 단어를 추측한다. (CBOW)

    you ❓ goodbye and I say hello.

  2. 중앙의 단어(타깃)로부터 주변의 여러 단어(문맥)을 추측한다.(Skip-gram)

    ❓ say ❓ and I say hello.

위와 같이 추론 문제를 풀고 학습하는 것이 '추론 기반 기법'이 다루는 문제이다. 모델은 추론 문제를 반복해서 풀며 단어의 확률분포를 학습한다.

여기서 모델은 신경망(Neural Network) 모델이다. 모델이 맥락(context)을 학습하여 맥락 상 ❓에 들어올 확률을 단어 별로 출력한다. 처음엔 가중치에 아무 의미없는 랜덤한 숫자들을 넣었기 때문에 정답과 거리가 먼 확률 분포를 나타낸다. 정답과의 차이인 loss값을 줄이는 방향으로 점차 학습을 진행하다보면 우리가 의도한 대로 정답('say') 단어가 가장 높은 확률을 가진 확률 분포가 완성된다. 이렇게 학습시킨 뒤 나온 가중치를 가지고 이제는 정답을 가르쳐주지 않는 test를 진행한다. 전체적인 과정을 살펴보았으니 세부적인 실행 코드와 함께 작동 원리를 파헤쳐보자.

CBOW의 작동 방식

개요

  • 입력층(input_layer): 맥락으로부터 타겟을 추측하는 용도의 신경망
  • 타깃은 중앙 단어, 맥락은 주변 단어
  • window = 1이면 입력층의 개수는 2개, 즉, 입력층의 개수는 window 크기 * 2
  • 입력층, 출력층 각각 뉴런의 개수 = 말뭉치 안의 모든 단어의 개수(V)

  • 입력 가중치 행렬(input weight matrix)상동
  • 입력 가중치 행렬(W_in)의 shape은 V x H이다.(V는 단어 개수에 해당하는 7,H는 은닉층의 뉴런의 수인 3)
  • 입력층과 입력 가중치 행렬(W_in)의 행렬곱을 한 결과(h0,h1)가 2개 나온다.(입력층이 2개이기 때문)
  • 결과 두 개를 평균 내면 은닉층 뉴런의 값이 된다.(h = 0,5*(h0+h1))
  • 행렬곱은 곱하려는 두 행렬의 shape이 맞아야 하기 때문에 W_in의 shape이 입력층의 뉴런 수와 은닉층의 뉴런 수에 따라 결정된다.
  • 은닉층(hidden layer) 뉴런의 개수는 인간의 주관으로 결정되는 하이퍼파라미터에 해당한다. 벡터 표현의 차원이라고도 한다.(H)
  • 은닉층에 활성화 함수(activation function)가 없다.
  • 일반적인 affine층과 달리 더해지는 편향(bias)도 없다.

  • 출력 가중치 행렬(output weight matrix)는 입력 가중치 행렬과 다르게 한 개이다.
  • 타깃이 하나이기 때문.
  • 출력 가중치 행렬의 shape은 은닉층의 뉴런 수와 출력층의 뉴런 수에 따라 결정된다.
  • 은닉층에 출력 가중치 행렬(W_out)을 행렬곱하면 출력층의 score가 계산된다.
  • 출력층의 score은 아직 확률이 아닌 상태이지만, 통상 가장 절댓값이 큰 요소에 해당하는 단어가 label이 된다.
  • 이후 loss값을 계산하기 위해 softmax로 확률 변환해준 뒤, cross entropy error값을 구하게 된다.
  • 이 loss값이 작아지는 방향으로 가중치가 조절되고 이런 학습이 반복되면 예측값 label이 target(정답)과 유사해지는 것을 확인할 수 있다.

신경망 학습을 위한 단어 전처리

  • 신경망은 숫자로된 데이터만 처리할 수 있다.
    -> 단어를 숫자로 변환해야 한다.
  • 신경망은 입력값의 크기가 균일해야 한다.
    -> 단어를 고정 길이의 벡터로 변환해야 한다.
    -> 원핫 표현(one-hot vector)이 대표적인 방법이다.
    -> 원핫표현이란 벡터의 원소 중 하나만 1이고 나머지는 0인 벡터를 말한다.

코드로 자세히 살펴보기

1) 행렬곱(matrix multiplication)층을 구현한 코드이다.

class MatMul:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.x = None

    def forward(self, x):
        W, = self.params
        out = np.dot(x, W)
        self.x = x
        return out

    def backward(self, dout):
        W, = self.params
        dx = np.dot(dout, W.T)
        dW = np.dot(self.x.T, dout)
        self.grads[0][...] = dW
        return dx
        

코드 출처

  • 파라미터 W는 Weight matrix를 의미한다.
  • mm = MatMul()클래스를 호출하는 순간 self.params,self.grads,self.x 가 초기화된다.
  • mm.forward()메소드는 순전파 계산을 수행한다.
    - x를 파라미터로 받고 있다.
    - xW와 행렬곱을 수행할 입력층 또는 은닉층 벡터이다.
    - xW의 행렬곱 결과 out을 리턴한다.
  • mm.backward()메소드는 역전파 계산을 수행한다.
    - 입력 파라미터 dout는 이전 레이어로부터 전파된 미분값을 의미한다.
    - W, = self.params에서 self.params는 레이어 내의 파라미터값을 저장한 리스트 또는 튜플이다. 여기서는 튜플 언패킹을 통해 W에 첫 번째 파라미터(가중치 행렬)을 저장한다.
    -dx = np.dot(dout, W.T)에서 doutW의 전치행렬을 내적하는 것은 doutW에 대해 미분한 것과 같다.
    -dW = np.dot(self.x.T, dout)에서 x의 전치행렬과 dout을 내적하는 것은 doutx에 대해 미분한 것과 같다.
    -self.grads[0][...] = dW는 입력층의 가중치에 대한 그래디언트를 self.grads 리스트의 첫 번째 요소에 저장한다. self.grads[0]은 가중치에 대한 그래디언트를 나타내며, [...]를 사용하여 해당 리스트 내용을 dW로 덮어쓴다.(shap유지, 내용만 덮어씀)
    -return dx 입력값에 대한 미분값 dx를 반환한다. 이 미분값은 이전 레이어로 전파될 것이며, 전체 신경망의 역전파 과정 중에 사용된다.

2) MatMul층을 활용해 초기 가중치 값으로 정답을 예측하는 과정이다.

# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul


# 샘플 맥락 데이터
c0 = np.array([[1, 0, 0, 0, 0, 0, 0]])
c1 = np.array([[0, 0, 1, 0, 0, 0, 0]])

# 가중치 초기화
W_in = np.random.randn(7, 3)
W_out = np.random.randn(3, 7)

# 계층 생성
in_layer0 = MatMul(W_in)
in_layer1 = MatMul(W_in)
out_layer = MatMul(W_out)

# 순전파
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1)
s = out_layer.forward(h)
print(s)

코드 출처

  • c0,c1은 주변 단어의 원핫벡터이다.
  • W_inVxH사이즈의 랜덤한 값으로 채워진 초기 입력층 가중치 값이다.
  • V는 전체 단어 수, H는 은닉층의 뉴런 수(벡터 차원 수)이다.
  • W_outHxV사이즈의 랜덤한 값으로 채워진 초기 입력층 가중치 값이다.
  • in_layer0W_in을 곱하는 합성곱층을 의미한다.
  • in_layer1in_layer0과 같다. 두번째 맥락(context)데이터에 적용할 합성곱층이다.
  • out_layerW_out을 곱하는 합성곱층을 의미한다.
  • h0은 은닉 벡터를 구하기 위한 중간단계로, c0데이터에 in_layer0층을 forward()메소드로 통과시킨 값이다.
  • h1도 은닉 벡터를 구하기 위한 중간단계로, c1데이터에 in_layer1층을 forward()메소드로 통과시킨 값이다.
  • h는 은닉벡터이며, 앞서 구한 h0h1의 평균값이다.
  • s는 앞서 구한 은닉벡터 hout_layerforward()메소드로 통과시킨 값으로, 중심 단어 예측 결과 중 score의 형태이다. (확률 구하기 위한 전 단계)

3) softmax와 crossentropy를 추가하여 완성된 cbow 구현해보기

import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul, SoftmaxWithLoss


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])
        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)
        da = self.out_layer.backward(ds)
        da *= 0.5
        self.in_layer1.backward(da)
        self.in_layer0.backward(da)
        return None

코드 출처

from common.layers import MatMul, SoftmaxWithLoss

: MatMul모듈은 앞에서 설명한 합성곱층이고, SoftmaxWithLoss는 앞서 얻은 스코어를 확률로 만들고 loss를 구하는 단계이다. SoftmaxWithLoss 참조

class SimpleCBOW:

: 클래스 명은SimpleCBOW로, 후에 완전한 CBOW를 구현할 것이기 때문에 간략화된 버전이라고 볼 수있다.

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

: 클래스가 호출되는 동시에 실행되는 초기화 메소드 __init__()의 입력 파라미터는 단어의 수인 vocab_size와 은닉층의 뉴런 수(벡터 차원 수)인 hidden_size이다. model = SimpleCBOW(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')

: 입력 가중치 행렬(W_in)과 출력 가중치 행렬(W_out)을 VH의 shape에 따라 랜덤한 값을 넣어 초기화한다. 이 때 데이터 타입은 32bit 부동소수점 실수이다. 64bit가 아닌 32bit를 사용하는 이유는 공간 효율성 때문이다. np.randmo.randn()은 평균이 0이고 표준편차가 1인 표준정규분포에 따라 난수를 생성하는 메소드이고, 0.01을 곱한 것은 최대 자릿수가 소수점 아래 둘째자리에 오도록 설정한 것이다.

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

: 입력 측의 MatMul레이어를 2개, 출력 층의 MatMul레이어를 1개, 그리고 SoftmaxWithLoss레이어를 1개 생성한다. 여기서 입력 측의 맥락(context)을 처리하는 MatMul레이어의 개수는 윈도우 크기의 2배이다. 또, 입력측 MatMul레이어 두 개는 모두 같은 가중치를 이용하도록 초기화한다.

        # 모든 가중치와 기울기를 리스트에 모은다.
        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

: MatMul층을 거치며 저장된 매개변수와 기울기를 인스턴스 변수인 paramsgrads리스트에 각각 모아둔다. 이렇게 하는 이유는 옵티마이저 동작 시 중복되는 가중치가 에러를 유발하기 때문에 모아서 한번에 중복을 제거하기 위함이다.

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

:

    def forward(self, contexts, target):
        h0 = self.in_layer0.forward(contexts[:, 0])
        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

: 신경망의 순전파인 forward() 메소드를 구현한다. 이 메소드는 파라미터로 맥락(contexts)과 타깃(target)을 받아 손실(loss)을 반환한다.h0에서 사용하는 forward()메소드는 MatMul층에서 정의된 순전파 메소드이다. 여기서 인수 contexts는 [그림 3-18]의 경우 shape(6,2,7)이다. 6은 맥락이 6쌍 있다는 뜻이고, 2는 window의 크기가 1이므로 두 배인 2개라는 뜻이고, 7은 원핫벡터로 변환할 때 말뭉치 내의 총 단어 수가 7개라는 뜻이다 .

따라서 contexts[:,0]이 의미하는 바는 타깃(중앙 단어)의 왼쪽에 있는 맥락(주변 단어)의 원핫 벡터만 추출한 6개의 미니배치이다. shape은 (6,1,7)이다.

contexts[:,1]이 의미하는 바는 타깃(중앙 단어)의 오른쪽에 있는 맥락(주변 단어)의 원핫 벡터만 추출한 6개의 미니배치이다. shape은 (6,1,7)이다.

이렇게 한번에 배치 처리로 h0h1을 구한 뒤 평균을 내면 은닉 벡터인 h를 빠르게 구할 수 있다. 은닉벡터hout_layer에 통과 시켜 score를 구하고, scoreloss_layer에 통과시켜 score를 확률로 바꾼 뒤(softmax) target과의 차이를 cross_entropy_error로 계산하여 최종적으로 loss값을 리턴하게 된다.

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

: 마지막으로 역전파인 backward()를 구성한다. [그림 3-10]은 역전파의 계산 그래프이다.

신경망의 역전파는 기울기를 순전파와 반대 방향으로 전파한다. 1에서 시작해 SoftmaxWithLoss층을 통과하여 ds가 출력된다. 그 다음 ds를 출력층(MatMul)에 입력하여 da가 나오고, dadp 0.5를 곱해서 입력 측 MatMul층으로 보낸다. None을 리턴하는 이유는 순전파와 역전파를 실행하는 것 만으로도 이미 grads리스트의 기울기가 갱신되기 때문이다.

다음으로 위에서 사용된 SoftmaxWithLoss층에 대한 분석이다.

  • common.layers.SoftmaxWithLoss
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

코드 출처

class SoftmaxWithLoss:

: softmaxcross_entropy_error를 합쳐 한번에 loss를 구하는 층이다.

    def __init__(self):
        self.params, self.grads = [], []
        self.y = None  # softmax의 출력
        self.t = None  # 정답 레이블

: SoftmaxWithLoss클래스를 호출하면 동시에 실행되는 __init__()메소드이다. 입력 파라미터는 없고, 신경망의 파라미터들과 기울기를 저장하는 self.params, self.grads를 빈 리스트로 초기화하며 softmax의 출력을 저장하는 self.y와 정답 레이블을 저장하는 self.tNone으로 초기화된다.

    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

: 순전파를 진행하는 forward()메소드이다. softmax결과값을 저장하는 x, 타깃값을 저장하는 t를 입력 파라미터로 가진다. t가 원핫 벡터라면 0~6 사이의 label로 변환해준다. argmax()함수는 python 내장 함수로, 벡터에서 가장 큰 값을 가진 요소의 인덱스를 반환한다. (axis = 1)은 열방향으로 탐색하는 것을 의미한다. 그 다음 softmax()결과 값이 저장된 y와 정답 label인 t를 입력하여 cross_entropy_error()를 구해준다. loss값은 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

: 역전파 함수의 입력 파라미터는 dout값으로, 출력층에서 전달된 그래디언트(기울기)를 의미한다. 기본값은 1이다. batch_size는 한번에 처리할 입력값의 양이다. self.t.shape[0]은 [그림 3-18]에서의 타깃의 shape(6,7)에서 6에 해당한다. dx는 score를 확률로 바꾼 y를 얕게 복사하여(copy()) 저장했다. 그 이유는 그래디언트를 계산하는 과정에서 중간 결과를 저장하고 사용하기 위해서이다.
🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟

  • softmax

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

softmax 함수 식을 구현한 코드이다.

1. if x.ndim == 2:: 입력 x의 차원이 2차원인 경우에는 다음 단계를 수행한다. 이 경우는 주로 배치(batch)로 처리되는 다중 클래스 분류 문제에 적용된다.

  • x = x - x.max(axis=1, keepdims=True): 각 행(row)의 최댓값을 해당 행의 모든 원소에서 빼주어 값의 크기를 조절한다. 이렇게 표준화함으로써 너무 큰 값으로 인한 수치적인 불안정성을 방지할 수 있다.
  • x = np.exp(x): 모든 원소에 지수 함수를 적용하여 양수로 만든다. 이는 확률 분포를 만들기 위한 과정 중 하나이다.
  • x /= x.sum(axis=1, keepdims=True): 각 행의 원소를 해당 행의 모든 원소의 합으로 나누어 확률 분포를 만든다. 이렇게 함으로써 각 클래스에 대한 확률 값을 얻을 수 있다.
  1. elif x.ndim == 1:: 입력 x의 차원이 1차원인 경우에는 다음 단계를 수행한다. 이 경우는 주로 단일 데이터 포인트에 대한 다중 클래스 분류 문제에 적용된다.
    • x = x - np.max(x): 입력 배열의 최댓값을 해당 배열의 모든 원소에서 빼주어 값의 크기를 조절한다.
    • x = np.exp(x) / np.sum(np.exp(x)): 각 원소에 지수 함수를 적용하여 양수로 만든 후, 모든 원소의 합으로 나누어 확률 분포를 만든다. 이 과정은 입력 데이터가 단일 데이터 포인트인 경우에 적용된다.
  2. return x: 계산된 softmax 확률 분포를 반환한다. 반환된 값은 입력 x의 각 클래스에 대한 확률 값으로 해석될 수 있다.
    이렇게 구현된 softmax 함수는 다중 클래스 분류 문제에서 모델의 출력을 확률 분포로 변환하여 예측을 수행하는 데 사용된다.
  • cross_entropy_error
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

코드 출처

cross entropy error 함수 식을 구현한 코드이다.

1. if y.ndim == 1:: 출력 y의 차원이 1차원인 경우를 처리한다. 이 경우는 주로 단일 데이터 포인트에 대한 처리를 위한 것이다.

  • t = t.reshape(1, t.size): 정답 레이블 t를 2차원 배열로 변환한다. 이렇게 함으로써 단일 데이터 포인트에 대한 처리를 일반화할 수 있습니다.
  • y = y.reshape(1, y.size): 출력 y도 2차원 배열로 변환한다.
  1. if t.size == y.size:: 정답 데이터 t가 원핫 벡터(one-hot vector)인 경우를 처리한다. 원핫 벡터는 하나의 정답 레이블만 1로 표시하고 나머지는 0으로 표시하는 방식으로 표현된다. 이 경우, 원핫 벡터에서 정답 레이블의 인덱스로 변환된다.
    • t = t.argmax(axis=1): argmax 함수를 사용하여 가장 큰 값의 인덱스를 찾아서 정답 레이블을 해당 인덱스로 변환한다.
  2. batch_size = y.shape[0]: 배치 크기(batch size)를 계산한다. y의 첫 번째 차원의 크기로 배치 크기를 구한다. 이 값은 한 번에 처리되는 데이터 포인트의 수를 나타낸다.
  3. -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size: 교차 엔트로피 오차를 계산한다.
    • np.arange(batch_size): 0부터 batch_size-1까지의 정수 배열을 생성한다. 이 배열은 배치 내의 데이터 포인트를 나타낸다.
    • y[np.arange(batch_size), t]: y 배열에서 정답 레이블 t에 해당하는 값을 선택한다. 이는 각 데이터 포인트에서의 신경망 출력의 확률값을 나타낸다.
    • np.log(y[np.arange(batch_size), t] + 1e-7): 선택된 확률값에 로그를 취한다. 이는 교차 엔트로피 오차의 핵심 부분이다.
    • -np.sum(...): 로그를 취한 값들을 모두 더한 후, 부호를 반대로 바꾸어 오차를 계산한다.
    • / batch_size: 배치 크기로 나누어 각 데이터 포인트에 대한 평균 오차를 계산한다.

4) 학습 코드 구현

# coding: utf-8
import sys
sys.path.append('..')  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
from common.trainer import Trainer
from common.optimizer import Adam
from simple_cbow import SimpleCBOW
from common.util import preprocess, create_contexts_target, convert_one_hot


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])
    

코드 출처

학습 데이터를 전처리(preprocess(text))해 신경망에 입력한 다음, 기울기를 구하고(SimpleCBOW()) 가중치 매개변수를 순서대로 갱신(Trainer(), .fit())한다.

반복 수가 늘어날 수록 loss값이 점차 감소하는 것을 확인할 수 있다.
다음은 학습이 끝난 후의 가중치 매개변수이다.


이 가중치 매개변수는 단어의 의미가 잘 담겨있고, 공간 효율성이 높게 밀집되어있는 분산 표현이라 볼 수 있다.

5) import된 모듈들 살펴보기

  • util.py
    - preprocess
# coding: utf-8
import sys
sys.path.append('..')
import os
from common.np import *


def preprocess(text):
    text = text.lower()
    text = text.replace('.', ' .')
    words = text.split(' ')

    word_to_id = {}
    id_to_word = {}
    for word in words:
        if word not in word_to_id:
            new_id = len(word_to_id)
            word_to_id[word] = new_id
            id_to_word[new_id] = word

    corpus = np.array([word_to_id[w] for w in words])

    return corpus, word_to_id, id_to_word

코드 출처

preprocess는 입력으로 받은 텍스트를 소문자로 변환하고 문장 내의 마침표를 분리한 후, 각 단어를 고유한 정수로 매핑하고 이를 코퍼스로 반환하는 역할을 한다.

  • create_contexts_target
def create_contexts_target(corpus, window_size=1):
    '''맥락과 타깃 생성

    :param corpus: 말뭉치(단어 ID 목록)
    :param window_size: 윈도우 크기(윈도우 크기가 1이면 타깃 단어 좌우 한 단어씩이 맥락에 포함)
    :return:
    '''
    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:
                continue
            cs.append(corpus[idx + t])
        contexts.append(cs)

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

코드 출처

create_context_target() 함수는 주어진 코퍼스에서 주어진 윈도우 크기 내에서의 맥락과 그에 해당하는 타깃을 생성한다. 윈도우 크기(window_size)는 맥락의 범위를 결정하며, 타깃은 해당 맥락 내에서 중심에 위치하는 단어이다. 주어진 코퍼스를 기반으로 맥락과 타깃을 생성하고 반환한다.

  • convert_one_hot

def convert_one_hot(corpus, vocab_size):
    '''원핫 표현으로 변환

    :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

코드 출처

convert_one_hot() 함수는 주어진 코퍼스(corpus)를 원핫 표현으로 변환한다. 이 때, 코퍼스의 차원에 따라 2차원 또는 3차원 넘파이 배열로 원핫 표현을 생성한다. 원핫 표현은 어휘 크기(vocab_size)에 해당하는 차원을 가지며, 각 단어를 해당 단어의 인덱스에만 1로 표시하고 나머지는 0으로 표시하는 방식으로 구현된다.

  • trainer.py
# coding: utf-8
import sys
sys.path.append('..')
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

코드 출처

이 코드는 신경망 모델을 훈련시키는 Trainer 클래스를 정의한 것이다. 이 클래스는 주어진 모델과 최적화기(optimizer)를 사용하여 데이터를 훈련하고, 훈련 중에 발생하는 손실(loss)을 기록한다.
1. __init__ 메서드: Trainer 클래스의 생성자로, 모델과 optimizer를 초기화하고 손실(loss)을 저장할 리스트(loss_list) 및 평가 간격(eval_interval)을 초기화한다.
2. fit 메서드: 데이터를 훈련하는 메서드로, 다음과 같은 인자를 받는다.

  • x: 입력 데이터
  • t: 정답 레이블
  • max_epoch: 최대 에폭(epoch) 수
  • batch_size: 미니배치 크기
  • max_grad: 그래디언트 클리핑을 위한 임계값
  • eval_interval: 평가 간격
  • 메소드는 다음과 같이 동작한다.
    • 데이터를 섞는다.
    • 미니배치를 만들어 가중치를 업데이트하고 손실을 계산한다.
    • 정해진 평가 간격(eval_interval)마다 손실과 경과 시간을 출력한다.
    • 손실 값을 loss_list에 추가한다.

Skip-gram의 작동 방식

CBOW와 다르게 중앙 단어로부터 주변 단어를 예측하는 것이 Skip-gram의 작동 방식이다. 말뭉치의 크기가 더 커지면 Skip-gram이 더 유리하다.

코드

# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul, SoftmaxWithLoss


class SimpleSkipGram:
    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_layer = MatMul(W_in)
        self.out_layer = MatMul(W_out)
        self.loss_layer1 = SoftmaxWithLoss()
        self.loss_layer2 = SoftmaxWithLoss()

        # 모든 가중치와 기울기를 리스트에 모은다.
        layers = [self.in_layer, 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):
        h = self.in_layer.forward(target)
        s = self.out_layer.forward(h)
        l1 = self.loss_layer1.forward(s, contexts[:, 0])
        l2 = self.loss_layer2.forward(s, contexts[:, 1])
        loss = l1 + l2
        return loss

    def backward(self, dout=1):
        dl1 = self.loss_layer1.backward(dout)
        dl2 = self.loss_layer2.backward(dout)
        ds = dl1 + dl2
        dh = self.out_layer.backward(ds)
        self.in_layer.backward(dh)
        return None

코드 출처

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

: Skip_gram을 간단하게 구현한 클래스이다. 초기화 메소드에는 vocab_sizehidden_size가 입력 인스턴스로 필요하고, 클래스 내에서 각각 V, H에 저장된다.

        # 가중치 초기화
        W_in = 0.01 * np.random.randn(V, H).astype('f')
        W_out = 0.01 * np.random.randn(H, V).astype('f')
        # 계층 생성
        self.in_layer = MatMul(W_in)
        self.out_layer = MatMul(W_out)
        self.loss_layer1 = SoftmaxWithLoss()
        self.loss_layer2 = SoftmaxWithLoss()

: 가중치 초기화는 SimpleCBOW와 동일하고, 다른 점은 입력 측 합성곱층이 한 개이고, loss층이 두 개라는 것이다. 그림과는 다르게 W_out 합성곱층은 한 개이고, 하나의 스코어만을 구한 뒤 loss에서 2개로 나누어지는 것이다.

        # 모든 가중치와 기울기를 리스트에 모은다.
        layers = [self.in_layer, self.out_layer]
        self.params, self.grads = [], []
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads
        # 인스턴스 변수에 단어의 분산 표현을 저장한다.
        self.word_vecs = W_in

: 가중치와 기울기가 포함된 in_layerout_layer에 대해 순회하며 paramsgrads를 따로 리스트에 저장한다. 입력 가중치 행렬(W_in)은 말뭉치 내 단어들의 의 분산 표현과 같다. 원핫 벡터로 변환한 뒤 W_in과 행렬곱을 수행하면 'you'는 [1,0,0,0,0,0,0]⋅W_in이므로 W_in의 첫 번째 행이 'you'의 벡터 표현이 되고 다른 단어들도 마찬가지로 W_inidx번째 행을 벡터 표현으로 가져가기 때문에 W_in 자체가 말뭉치(corpus)의 분산 표현 행렬이 되는 것이다.

    def forward(self, contexts, target):
        h = self.in_layer.forward(target)
        s = self.out_layer.forward(h)
        l1 = self.loss_layer1.forward(s, contexts[:, 0])
        l2 = self.loss_layer2.forward(s, contexts[:, 1])
        loss = l1 + l2
        return loss

: 순전파 메소드에서 입력 파라미터는 주변 단어(contexts)와 중심 단어(target)이다. skip-gram은 중심 단어로부터 주변 단어를 예측하는 것임을 기억하자. 따라서 targetin_layer에 통과시킨 것을 h은닉 벡터에 저장하고, hout_layer로 통과시킨 s스코어를 구한다. 그림과 달리 스코어를 구하는 것까지는 하나인데, loss층을 거치며 두 개(window_size * 2)로 나누어진다. 미니 배치 학습으로 이루어지기 때문에 contexts행렬에서 첫 번째 맥락에 해당하는 단어를 첫 번째 l1층에 s와 함께 통과시키고 두 번째 맥락은 sl2층을 통과시킨다. 이렇게 나온 두 l1, l2를 더하여 loss값을 얻고, 리턴한다.

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

0개의 댓글