1. 자연어 처리

  • 자연어(Natural Language): 한국어와 영어 등 우리가 평소에 쓰는 말
  • 자연어 처리(Natural Language Processing, NLP): 언어를 컴퓨터에게 이해시키기 위한 기술
  • 단어의 의미를 잘 파악하는 표현 방법
    • 시소러스를 활용한 기법
    • 통계 기반 기법
    • 추론 기반 기법(word2vec)

 

  - 시소러스(Thesaurus)

  • 시소러스: 유의어 사전
    • 동의어나 유의어가 한 그룹으로 분류
    • 상·하위, 전체와 부분 등의 관계까지 정의
  • 모든 단어가 레이블링 되어 있다면, '단어 네트워크'를 이용하여 컴퓨터에게 단어 사이의 관계를 가르칠 수 있음
    • ex) WordNet, K-Thesaurus 등
  • 시소러스의 문제점
    • 시대 변화에 대응하기 어려움
    • 사람이 쓰는 비용이 큼
    • 단어의 미묘한 차리를 표현할 수 없음

 

  - 통계 기반 기법

  • 어떤 단어에 주목했을 때, 그 주변에 어떤 단어가 몇 번이나 등장하는지를 세어 집계하는 방법
  • ex) 검색 기반 방법혼이 발전 되어 나온 방법
    - 번역 데이터를 모으는 일은 쉬운 일이 아니며 보통은 1천만 문장쌍이 있어야 쓸만한 성능이 나옴
    - 도메인(분야: 뉴스, 특허, 웹채팅, SNS 등)에 굉장히 의존적이며 뉴스 도메인 데이터로 학습한 모델을 SMS 번역에 적용하면 심각한 성능저하 발생
import numpy as np

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

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

# 출력 결과
# say는 첫 say에서 id가 1번으로 부여되어 뒤에 나온 say에 새로운 id 부여없이 1로 사용
corpus:[0 1 2 3 4 1 5 6]
word_to_id:{'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}
id_to_word:{0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}
  • 파이썬으로 말뭉치 전처리하기
  • 단어의 분산 표현
    • '단어의 의미'를 정확하게 파악할 수 있는 벡터 표현
    • 단어를 고정 길이의 '밀집벡터'로 표현
    • 밀집벡터: 대부분의 원소가 0이 아닌 실수인 벡터 (ex/ (R, G, B) = (170, 33, 22))
  • 분포 가설
    • 단어의 의미는 주변 단어에 의해 형성
    • 단어 자체에는 의미가 없고, 그 단어가 사용된 '맥락'이 의미를 형성
    • 맥락: 주목하는 단어 주변에 놓인 단어
    • 맥락의 크기(주변 단어를 몇 개나 포함할 지) = 윈도우 크기
  • 동시발생 행렬

def create_co_matrix(corpus, vocab_size, window_size = 1):
    corpus_size = len(corpus)
    co_matrix = np.zeros((vocab_size, vocab_size), dtype = np.int32)

    for idx, word_id in enumerate(corpus):
        for i in range(1, window_size + 1):
            left_idx = idx - i
            right_idx = idx + i
            
            if left_idx >= 0:
                left_word_id = corpus[left_idx]
                co_matrix[word_id, left_word_id] += 1
            
            if right_idx < corpus_size:
                right_word_id = corpus[right_idx]
                co_matrix[word_id, right_word_id] += 1
    
    return co_matrix
C = create_co_matrix(corpus, len(word_to_id))
C

# 출력 결과
array([[0, 1, 0, 0, 0, 0, 0, 0],
           [1, 0, 1, 0, 1, 1, 0, 0],
           [0, 1, 0, 1, 0, 0, 0, 0],
           [0, 0, 1, 0, 1, 0, 0, 0],
           [0, 1, 0, 1, 0, 0, 0, 0],
           [0, 1, 0, 0, 0, 0, 1, 0],
           [0, 0, 0, 0, 0, 1, 0, 0],
           [0, 0, 0, 0, 0, 0, 0, 0]])

 

  • 벡터 간 유사도
    • 벡터의 내적, 유클리드 거리등
    • 코사인 유사도(cosine similarity)를 자주 이용

$$ similarity(x, y) = \frac{x \cdot y}{\left || x \right || \left || y \right ||} = \frac{x_{1}y_{1} + \cdots + x_{n}y_{n}}{\sqrt{x_{1}^{2} + \cdots + x_{n}^{2}} \sqrt{y_{1}^{2} + \cdots + y_{n}^{2}}} $$

def cos_similarity(x, y, eps = 1e-8):
    nx = x / np.sqrt(np.sum(x**2) + eps)
    ny = y / np.sqrt(np.sum(y**2) + eps)
    return np.dot(nx, ny)

c0 = C[word_to_id['you']]  # 'you'의 단어 벡터: [0 1 0 0 0 0 0]
c1 = C[word_to_id['i']]  # 'i'의 단어 벡터: [0 1 0 1 0 0 0]
print(cos_similarity(c0, c1))

# 출력 결과
0.7071067758832467

 

  - 통계 기반 기법 개선하기

  • 유사 단어 랭킹 표시
def most_similar(query, word_to_id, id_to_word, word_matrix, top = 5):
    if query not in word_to_id:
        print("'%s(을)를 찾을 수 없습니다."%query)
        return
    print('\n[query]'+query)

    query_id = word_to_id[query]
    query_vec = word_matrix[query_id]

    vocab_size = len(id_to_word)
    similarity = np.zeros(vocab_size)
    for i in range(vocab_size):
        similarity[i] = cos_similarity(word_matrix[i], query_vec)

    count = 0
    for i in (-1*similarity).argsort():
        if id_to_word[i] == query:
            continue
        print('%s: %s'%(id_to_word[i], similarity[i]))

        count += 1
        if count >= top:
            return
most_similar('he', word_to_id, id_to_word, C, top = 5)

# 출력 결과
'he(을)를 찾을 수 없습니다.
most_similar('you', word_to_id, id_to_word, C, top = 5)

# 출력 결과
[query]you
goodbye: 0.7071067758832467
i: 0.7071067758832467
hello: 0.7071067758832467
say: 0.0
and: 0.0
  • 직관적으로 'you' & 'hello', 'you' & 'goodbye'는 관련이 없어 보일 수 있음
  • 이는 말뭉치의 크기가 너무 작기 때문

 

  • 상호정보량(점별 상호정보량(Pointwise Mutual Information, PMI)
    \( PMI(x, y) = log_{2} \frac{P(x, y)}{P(x)P(y)} = log_{2} \frac{\frac{C(x, y)}{N}}{\frac{C(x)}{N}\frac{C(y)}{N}}=log_{2}\frac{C(x, y) \cdot N}{C(x)C(y)} \)
    • x, y가 일어날 확률 / x가 일어날 확률, y가 일어날 확률
    • 양의 상호정보량(Positive PMI, PPMI)
      \( PPMI(x, y)=max(0, PMI(x, y)) \)
def ppmi(C, verbose = False, eps = 1e-8):
    M = np.zeros_like(C, dtype = np.float32)
    N = np.sum(C)
    S = np.sum(C, axis = 0)
    total = C.shape[0] * C.shape[1]
    cnt = 0

    for i in range(C.shape[0]):
        for j in range(C.shape[1]):
            pmi = np.log2(C[i, j] * N / (S[i] * S[j]) + eps)
            M[i, j] = max(0, pmi)

            if verbose:
                cnt += 1
                if cnt % (total //100) == 0:
                    print('%.1f%% 완료'%(100 * cnt / total))
    return M

C = create_co_matrix(corpus, len(word_to_id), window_size = 1)
W = ppmi(C)

np.set_printoptions(precision = 3)
print("동시발생 행렬")
print(C)
print('-'*50)
print('PPMI')
print(W)

# 출력 결과
동시발생 행렬
[[0 1 0 0 0 0 0]
 [1 0 1 0 1 1 0]
 [0 1 0 1 0 0 0]
 [0 0 1 0 1 0 0]
 [0 1 0 1 0 0 0]
 [0 1 0 0 0 0 1]
 [0 0 0 0 0 1 0]]
--------------------------------------------------
PPMI
[[0.    1.807 0.    0.    0.    0.    0.   ]
 [1.807 0.    0.807 0.    0.807 0.807 0.   ]
 [0.    0.807 0.    1.807 0.    0.    0.   ]
 [0.    0.    1.807 0.    1.807 0.    0.   ]
 [0.    0.807 0.    1.807 0.    0.    0.   ]
 [0.    0.807 0.    0.    0.    0.    2.807]
 [0.    0.    0.    0.    0.    2.807 0.   ]]
  • PPMI 행렬의 문제점
    • 말뭉치의 어휘수가 증가함에 따라 단어 벡터의 차원 수도 증가
    • 원소 대부분이 0(각 원소의 중요도가 낮음
    • 노이즈에 약하고 견고하지 못함
    → 중요한 정보는 최대한 유지하면서 차원을 줄여야 함

 

  • 차원 감소 - 특이값 분해(Singular Value Decomopsition, SVD)

full SVD
thin SVD
compact SVD
truncated SVD

import sys
sys.path.append('..')
import numpy as np
import matplotlib.pyplot as plt

text = "You say goodbye and I say hello."
corpus, word_to_id, id_to_word = preprocess(text)
vocab_size= len(word_to_id)
C = create_co_matrix(corpus, vocab_size)
W = ppmi(C)
U, S, V = np.linalg.svd(W)

print('차원 감소된 U')
print(U[:, :2])

for word, word_id in word_to_id.items():
    plt.annotate(word, (U[word_id, 0], U[word_id, 1]))
plt.scatter(U[:, 0], U[:, 1], alpha = 0.5)
plt.show()

# 출력 결과
차원 감소된 U
[[-1.110e-16  3.409e-01]
 [-5.976e-01  0.000e+00]
 [-5.551e-17  4.363e-01]
 [-4.978e-01  1.665e-16]
 [-3.124e-17  4.363e-01]
 [-3.124e-17  7.092e-01]
 [-6.285e-01 -1.943e-16]]

 

 

2. Word2Vec

  • 단어를 벡터로 표현하는 방법
    • 통계 기반 기법: 배치학습(많은 데이터를 한번에 처리), 학습 데이터에 대해 SVD을 통해 저차원으로 변환
    • 추론 기반 기법: 미니 배치 학습 가능(전체를 다 하지 않고 필요한 부분만 분리시켜 학습, 나눠서 처리하므로 나눠서 할 수 있고 GPU 병렬 계산 등이 가능)

 

  - 신경망에서의 단어 처리

  • 추론 기반 기법의 주된 작업은 추론
  • 모델로 사용하는 것은 신경망
  • '?'를 추론하기 위해 맥락을 모델에 넣어 '?' 자리에 올 수 있는 단어의 확률분포 값을 추론해서 알려줌 

 

  • 신경망은 단어를 그대로 처리할 수 없음
  • 원-핫 표현으로 변환(벡터의 원소 중 하나만 1이고 나머지는 모두 0인 벡터
  • 신경망의 입력층은 뉴런의 수 고정

 

  • 단어 하나를 완전연결계층을 통해 변환
    • 완전연결계층: 인접하는 계층의 모든 뉴런과 연결되어 있는 신경망

 

  • C와 W의 곱은 가중치의 행벡터 한 줄과 같음
  • matmul로도 수행가능
import numpy as np

c = np.array([1, 0, 0, 0, 0, 0, 0]) # 입력
W = np.random.randn(7, 3)  # 가중치
h = np.matmul(c,W)  # 중간 노드
print(h)

# 출력 결과
[-0.473 -1.132 -1.333]

 

  - CBOW(continuous bag-of-word) 모델

  • 주변 단어들로부터 중앙 단어를 추측하는 용도의 신경망
  • CBOW의 입력은 맥락
  • 맥락에 포함시킬 단어가 N개면 입력층도 N개
  • 입력층이 여러 개일 경우 은닉층은 전체의 평균을 하면 됨
  • 출력층에 소프트맥스 함수를 적용하여 확률을 얻을 수 있음

  • 계층  관점에서 CBOW 모델의 신경망 구성
    • you와 goodbye 사이에 무엇이 올지 추론
    • 가중치 계산할 때 행렬곱한 뒤, 0.5를 곱함(두 개의 평균을 취하기 위해)
    • 그 후 가중치 출력을 통해 점수를 계산

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)

# 출력 결과
[[ 0.216  0.096  0.06   0.536  0.389 -0.392 -0.217]]
  • CBOW 모델의 학습에서 하는 일: 올바른 학습을 할 수 있게 가중치 조절
  • 다루고 있는 모델은 다중 클래스 분류를 수행하는 신경망(softmax와 cross entropy error)만 이용하면 됨

 

  - word2vec의 가중치와 분산 표현

  • Word2vec에서 사용되는 가중치는 2개가 존재
    • W(in) 입력층 완전 연결계층의 가중치
    • W(out) 출력층 완전 연결계층의 가중치
  • 최종적으로 이용하는 단어의 분산표현으로는 어느 가중치를 선택해야 할까?
    → 가장 대중적인 선택: W(in) 입력층 완전 연결계층의 가중치만 이용하는 것

 

  - 맥락과 타겟

  • 타겟: 맥락에 둘러 싸인 중앙의 단어(정답 레이블)
  • 말뭉치 → 단어 ID 변환

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:
                continue
            cs.append(corpus[idx + t])
        contexts.append(cs)
    return np.array(contexts), np.array(target)

contexts, target = create_contexts_target(corpus, window_size = 1)
print(contexts)
print(target)
# 출력 결과
[[0 2]
 [1 3]
 [2 4]
 [3 1]
 [4 5]
 [1 6]]
[1 2 3 4 1 5]

 

  • 맥락과 타겟 → 원-핫 표현으로 변환

def convert_one_hot(corpus, vocab_size):
    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] = i
    
    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

 

  - CBOW 모델 구현

  • 생성자레서 어휘 수와 뉴런 수를 입력 받음
  • 가중치 두 개 생성
    • astype(f): float형으로 타입 변환
  • MatMul 계층 2개, 출력층 1개, Softmax with Loss 계층 1개 생성
  • 매개변수와 기울기를 인스턴스 변수에 모음
  • 이때 사용한 common.layers import SoftmaxWithLoss 파일은 '밑바닥부터 시작하는 딥러닝' github에서 다운받음
    https://github.com/WegraLee/deep-learning-from-scratch-2 )
from common.layers import MatMul
from common.layers import 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.trainer import Trainer
from common.optimizer import Adam

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

 

  - word2vec 보충

  • 동시확률: A와 B가 동시에 일어날 확률
  • 사후확률: 사건이 일어난 후의 확률

  • 타깃이 \( W_{t} \)가 될 확률은 수식으로
    \( P(w_{t}|w_{t-1}, w_{t+1}) \)
  • 손실함수도 간결하게 표현 가능
    \( L=-logP(w_{t}|w_{t-1}, w_{t+1}) \)
  • 말 뭉치 전체로 확장
    \( L=-\frac{1}{T}\sum_{t=1}^{T}logP(w_{t}|w_{t-1}, w_{t+1}) \)

 

 

  - skip-gram 모델

  • Word2vec은 CBOW 모델, skip-gram 모델 두 개 제안
  • skip-gram모델은 CBOW에서 다루는 맥락과 타겟을 역전시킨 모델

  • 입력층은 하나, 출력의 수는 맥락의 수만큼 존재
  • 개별 손실들을 모두 더한 값이 최종 손실이 됨

  • skip-gram 모델의 확률 표기
    • \( W_{t} \)가 주어졌을 때, \( W_{t-1} \)과 \( W_{t+1} \)이 동시에 일어날 확률
      \( P(w_{t-1}, w_{t+1}|w_{t}) \)
    • 조건부 독립
      \( P(w_{t-1}, w_{t+1}|w_{t})=P(w_{t-1}|w_{t})P(w_{t+1}|w_{t}) \)
    • skip-gram 모델의 손실 함수
      \( L=-logP(w_{t-1}, w_{t+1}|w_{t} \\ \quad =-logP(w_{t-1}|w_{t})P(w_{t+1}|w_{t}) \\ \quad =-(logP(w_{t-1}|w_{t})+logP(w_{t+1}|w_{t})) \)
    • 말뭉치 전체의 손실함수
      \( L=-\frac{1}{T} \sum_{t=1}^{T}(logP(w_{t-1}|w_{t})+logP(w_{t+1}|w_{t})) \)
  • CBOW모델과 SKip-gram모델을 비교했을 때,
    단어 분산 표현의 정밀도 면에서 skip-gram모델이 유리
    • 말뭉치가 커질수록 성능이 뛰어남
    • 학습속도는 CBOW모델보다 느림

 

 

3. Word2Vec 속도 개선

  • word2vec의 문제점: 말뭉치에 포함된 어휘 수가 많아지면 계산량도 커짐
  • word2vec의 개선방향
    • Embedding이라는 새로운 개층 도입
    • 네거티브 샘플링이라는 새로운 손실함수 도입

 

  - word2vec 개선 1

  • 입력측 가중치(W_in)와의 행렬곱으로 은닉층 계산
  • 출력측 가중치(W_out)와의 행렬곱으로 각 단어의 점수 구함
  • softmax 함수를 적용해 단어의 확률을 얻어 손실 구함

  • CBOW 모델의 문제점
    • 입력층의 원-핫 표현과 가중치 행렬(W_in)의 곱 계산
      → Embedding 계층을 도입해서 해결
    • 은닉층과 가중치 행렬(W_out)의 곱 및 softmax 계층의 계산
      → 네거티브 샘플링을 도입해서 해결

 

  - Embedding 계층

  • 맥락 c와 가중치 W의 곱으로 해당 위치의 행 벡터 추출 → 결과적으로 행렬의 특정 행을 추출하는 것

import numpy as np
W = np.arange(21).reshape(7, 3)
W

# 출력 결과
array([[ 0,  1,  2],
          [ 3,  4,  5],
          [ 6,  7,  8],
          [ 9, 10, 11],
          [12, 13, 14],
          [15, 16, 17],
          [18, 19, 20]])
W[0]

# 출력 결과
array([0, 1, 2])


W[2]

# 출력 결과
array([6, 7, 8])


idx = np.array([1, 0, 3, 0])
W[idx]

# 출력 결과
array([[ 3,  4,  5],
          [ 0,  1,  2],
          [ 9, 10, 11],
          [ 0,  1,  2]])

 

  • Embedding의 순전파, 역전파 구현

  • Embedding 계층 backward의 문제점
    • 아래 그림에서 첫번째와 세번째가 중복으로 0번 인덱스에 들어가야 하는데 어떻게 처리해야 하나
      → 중복문제 해결을 위해, 할당이 아닌 '더하기'를 해주어야 함

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

    def forward(self, idx):
        W = self.params
        self.idx = idx
        out = W[idx]
        return out
    
    def backward(self, dout):
        dW = self.grads
        dW[...] = 0
        
        # backward의 중복 문제 해결을 위해 더하기를 해줌
        np.add.at(dW, self.idx, dout)
        # 혹은 다음과 같은 for문 사용
        # for i, word_id in enumerate(self.idx):
        # dW[word_id] += dout[i]
        
        return None

 

  - word2vec 개선 2

  • 은닉층 이후에 계산이 오래 걸리는 곳(은닉측의 뉴런과 가중치 행렬(W_out)의 곱 / softmax 계층의 계산)
    (\( y_{k}=\frac{exp(s_{k}}{\sum_{i=1}^{1000000}exp(s_{i})} \))

 

  • 다중 분류에서 이진 분류로(네거티브 샘플링)

  • 출력층의 가중치 W_out에서는 각 단어 ID의 단어 벡터가 열로 저장되어 있음
  • 아래의 예에서 'say'에 해당하는 단어 벡터를 추출
    → 그 벡터와 은닉층 뉴런과의 내적을 구함

  다중 분류 이진 분류
출력층 softmax sigmoid
손실 함수 cross entropy error cross entropy error

 

  • 시그모이드 함수와 교차 엔트로피 오차
    • 시그모이드 함수
      \( y=\frac{1}{1+exp(-x)} \)
    • 교차 엔트로피 오차
      \( L=-(tlogy+(1-t)log(1-y)) \)
      t가 1이면 정답은 'Yes' → -log y 출력
      t가 1이면 정답은 'No' → -log (1-y) 출력

  • 정답 레이블이 1이라면 y가 1(100%)에 가까워질수록 오차가 줄어듦
    반대로, y가 1로부터 멀어지면 오차가 커짐
  • 오차가 크면 '크게' 학습하고, 오차가 작으면 '작게' 학습하게 됨

 

  • 다중 분류 구현

다중분류

  • 이진 분류 구현
    • 은닉층 뉴런 h와 출력층의 가중치 W_out에서 'say'에 해당하는 단어 벡터와의 내적을 계산
    • 출력을 Sigmoid with Loss 계층에 입력해 최종 손실을 얻음

이진분류

  • Embedding Dot 계층을 도입

class EmbeddingDot:
    def __init__(self, W):
        self.embed = Embedding(W)
        self.params = self.embed.params # 매개변수 저장
        self.grads = self.embed.grads  # 기울기 저장
        self.cache = None
    
    def forward(self, h, idx):
        target_W = self.embed.forward(idx)
        out = np.sum(target_W * h, axis = 1)  # 내적 계산

        self.cache = (h, target_W)
        return out
    
    def backward(self, dout):
        h, target_W = self.cache
        dout = dout.reshape(dout.shape[0], 1)
        
        dtarget_W = dout * h
        self.embed.backward(dtarget_W)
        dh = dout * target_W
        return dh
idx = [0, 3, 1]
embed = Embedding(W)
target_W = embed.forward(idx)
out = np.sum(target_W * h, axis = 1)

target_W

# 출력 결과
array([[ 0,  1,  2],
          [ 9, 10, 11],
          [ 3,  4,  5]])


out

# 출력 결과
array([0.383, 7.621, 2.796])

  • 정답은 1에 가깝게, 오답은 0에 가깝게 만들어야 함
  • 부정적인 예에서는 오답이 무엇인지 알 수 없음
  • 네거티브 샘플링을 통해 부정적인 예를 전부 학습하지 않고 일부만 샘플링하여 학습
    • 긍정적인 예(say): Sigmoid with Loss 계층에 정답 레이블로 '1'을 입력함
    • 부정적인 예(hello와 I): Sigmoid with Loss 계층에 정답 레이블로 '0'을 입력함

 

  • 네거티브 샘플링의 샘플링 기법
    • 말뭉치에서 각 단어의 출현 횟수를 구해 '확률분포'로 샘플링

import numpy as np

# 무작위 샘플링
words = ['you', 'say', 'goodbye', 'I', 'hello', '']
np.random.choice(words)

# 5개 무작위 샘플링(죽복 o)
np.random.choice(words, size = 5)

# 5개 무작위 샘플링(죽복 x)
np.random.choice(words, size = 5, replace = False)

# 확률분포에 따라 샘플링
# 아래는 각 단어가 선택될 확률
p = [0.5, 0.1, 0.05, 0.2, 0.05, 0.1]
np.random.choice(words, p = p)
  • \( P'(w_{i}-\frac{P(w_{i})^0.75}{\sum_{j}^{n}P(w_{j})^0.75} \)
  • 기본 확률분포에 0.75를 제곱하는 방법을 추천
    0.75를 제곱함으로써, 원래 확률이 낮은 단어의 확률을 살짝 높일 수 있음
p = [0.7, 0.29, 0.01]
new_p = np.power(p, 0.75)
new_p /= np.sum(new_p)
print(new_p)

# 출력 결과
[0.642 0.332 0.027]

 

  • UnigramSampler: 네거티브 샘플링 말뭉치에서 단어의 확률분포를 만들고, 0.75 제곱한 후 np.random.choice()를 사용해 부정적 예를 샘플링하는 클래스
# UnigramSampler 클래스
import collections
GPU=False
class UnigramSampler:
    def __init__(self, corpus, power, sample_size):
        self.sample_size = sample_size
        self.vocab_size = None
        self.word_p = None

        counts = collections.Counter()
        for word_id in corpus:
            counts[word_id] += 1

        vocab_size = len(counts)
        self.vocab_size = vocab_size

        self.word_p = np.zeros(vocab_size)
        for i in range(vocab_size):
            self.word_p[i] = counts[i]

        self.word_p = np.power(self.word_p, power)
        self.word_p /= np.sum(self.word_p)  # 전체 합이 1이 되도록 확률 설정

    def get_negative_sample(self, target):
        batch_size = target.shape[0]

        if not GPU:
            negative_sample = np.zeros((batch_size, self.sample_size), dtype=np.int32)

            for i in range(batch_size):
                p = self.word_p.copy()
                target_idx = target[i]
                p[target_idx] = 0
                p /= p.sum() # 정답 부분의 확률을 0으로 만들어 버렸으므로 남은 확률들의 합이 1이되도록 하기 위함
                negative_sample[i, :] = np.random.choice(self.vocab_size, size=self.sample_size, replace=False, p=p)
        else:
            # GPU(cupy)로 계산할 때는 속도를 우선
            # 부정적인 예에 타겟이 포함될 수 있음
            negative_sample = np.random.choice(self.vocab_size, size=(batch_size, self.sample_size),
                                               replace=True, p=self.word_p)

        return negative_sample
corpus = np.array([0, 1, 2, 3, 4, 1, 2, 3])
power = 0.75
sample_size = 2

sampler = UnigramSampler(corpus, power, sample_size)
target = np.array([1, 3, 0])
negative_sample = sampler.get_negative_sample(target)
print(negative_sample)

# 출력 결과
[[2 3]
 [0 2]
 [1 3]]

 

  • 네거티브 샘플링 손실함수 클래스 구현
from common.layers import SigmoidWithLoss
class NegativeSamplingLoss:
    def __init__(self, W, corpus, power = 0.75, sample_size = 5):
        self.sample_size = sample_size
        self.sampler = UnigramSampler(corpus, power, sample_size)
        self.loss_layers = [SigmoidWithLoss for _ in range(sample_size + 1)]
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

        self.params, self.grads = [], []
        for layer in self.embed_dot_layers:
            self.params += layer.params
            self.grads += layer.grads

    def forward(self, h, target):
        batch_size = target.shape[0]
        negative_sample = self.sampler.get_negative_sample(target)

        # 긍정적인 예 순전파
        score = self.embed_dot_layers[0].forward(h, target)
        correct_label = np.ones(batch_size, dtype = np.int32)
        loss = self.loss_layers[0].forward(score, correct_label)

        # 부정적인 예 순전파
        negative_label = np.zeros(batch_size, dtype = np.int32)
        for i in range(self.sample_size):
            negative_target = negative_sample[:, i]
            score = self.embed_dot_layers[1 + i].forward(h, negative_target)
            loss += self.loss_layers[1 + i].forward(score, negative_label)

        return loss
    
    def backward(self, dout = 1):
        dh = 0
        for I0, I1 in zip(self.loss_layers, self.embed_dot_layers):
            dscore = I0.backward(dout)
            dh += I1.backward(dscore)

        return dh

 

  • 개선된 word2vec 학습
import numpy as np

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)
            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
# CBOW 모델 학습
from common import config
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from common.util import 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)
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'
with open(pkl_file, 'wb') as f:
    pickle.dump(params, f, -1)

 

  • word2vec의 단어 분산 표현을 사용하면 유추문제를 덧셈과 뺄셈으로 풀 수 있음
    • king → ?에서 ?를 man → woman의 관계로부터 유추
    • analogy 함수 사용

# 유추 문제
from common.util import analogy

pkl_file = 'cbow_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']

# 가장 비슷한 단어 뽑기
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)

 

  - 자연어 처리 분야에서 단어의 분산 표현이 중요한 이유

  • 전이 학습 가능
  • 단어를 고정 길이 벡터로 변환해줌(bag of words)

 

  - word2vec을 사용한 애플리케이션의 예

  • 메일 자동 분류 시스템 만들기
    1. 데이터(메일) 수집
    2. 수동으로 레이블링 작업(3단계의 감정을 나타내는 레이블)
    3. 학습된 word2vec을 이용해 메일을 벡터로 변환
    4. 감정분석을 수행하는 어떤 분류 시스템(SVM이나 신경망 등)에 벡터화된 메일과 감정 레이블을 입력하여 학습 수행

 

  - 단어 벡터 평가 방법

  • 모델에 따라 정확도가 다름(말뭉치에 따라 적합한 모델 선택
  • 일반적으로 말뭉치가 클수록 결과가 좋음(항상 데이터가 많은 게 좋음)
  • 단어 벡터 차원 수는 적당한 크기가 좋음(너무 커도 정확도가 나빠짐)

 

  - 정리

  • Embedding 계층은 단어의 분산 표현을 담고 있으며, 순전파 시 지정한 단어 ID의 벡터를 추출
  • Word2vec은 어휘 수 증가에 비례항 계산량도 증가하므로, 근사치로 계산하는 빠른 기법을 사용하면 좋음
  • 네거티브 샘플링은 부정적 예를 몇 개 샘플링한느 기법으로, 이를 이요하면 다중 분류를 이진 분류처럼 취급
  • word2vec으로 얻은 단어의 분산 표현에는 단어의 의미가 녹아들어 있으며, 비슷한 맥락에서 사용되는 단어는 벡터 공간에서 가까이 위치함
  • word2vec은 전이 학습 측면에서 특히 중요하며, 그 단어의 분산 표현은 다양한 자연어 처리 작업에 이용할 수 있음

+ Recent posts