NLP

NLP - 단어 수준 임베딩

단어 수준 임베딩

  • 예측 기반 모델

    • NPLM
    • Word2Vec
    • FastText
  • 행렬 분해 기반 모델

    • LSA
    • GloVe
    • Swivel
  • 단어 임베딩을 문장 수준 임베딩으로 확장하는 방법

    • 가중 임베딩(Weighted Embedding)

NPLM(Neural Probabilistic Language Model)

  • NLP 분야에서 임베딩 개념을 널리 퍼뜨리는 데 일조한 선구자적 모델로서 임베딩 역사에서 차지하는 역할이 작지 않다.

  • ‘단어가 어떤 순서로 쓰였는가’라는 가정을 통해 만들어진 통계 기반의 전통적인 언어 모델의 한계를 극복하는 과정에서 만들어졌다는데 의의가 있으며 NPLM 자체가 단어 임베딩 역할을 수행할 수 있다. 다음과 같은 한계점들이 기존에는 존재했다.

    • 1) 학습 데이터에 존재하지 않는 데이터에 대해 나타날 확률을 0으로 부여 한다. 물론, 백오프(back-off)나 스무딩(smoothing)으로 완화시켜 줄 순 있지만 근본적인 해결방안은 아니였다.

    • 2) 문장의 장기 의존성(long-term dependency)을 포착해내기 어렵다. 다시 말해서 n-gram모델의 n을 5 이상으로 길게 설정할 수 없다. n이 커질수록 확률이 0이될 가능성이 높기 때문이다.

    • 3) 단어/문장 간 유사도를 계산할 수 없다.

NLPM의 학습

  • NLPM은 직전까지 등장한 n-1개(n: gram으로 묶을 수) 단어들로 다음 단어를 맞추는 n-gram 언어 모델이다.

NPLM의 학습 원리

  • NLPM 구조의 말단 출력

    • $|V|$(corpus의 token vector, 어휘집합의 크기) 차원의 score 벡터 $y_{w_{t}}$에 softmax 함수를 적용한 $|V|$차원의 확률 벡터이다. NPLM은 확률 벡터에서 가장 높은 요소의 인덱스에 해당하는 단어가 실제 정답 단어와 일치하도록 학습 한다.
  • NLPM 구조의 입력

    • 문장 내 t번째 단어($w_{t}$)에 대응하는 단어 벡터 $x_{t}$를 만드는 과정은 다음과 같다. 먼저 $|V| \times m (m: x_{t}의 차원 수)$크기를 갖는 행렬 C에서 $w_{t}$에 해당하는 벡터를 참조하는 형태이다. C 행렬의 원소값은 초기에 랜덤 설정한다. 참조한다는 의미는 예를 들어 아래 그림에서 처럼 어휘 집합에 속한 단어가 5개이고 $w_{t}$가 4번째 인덱스를 의미한다고 하면 행렬 $C$와 $w_{t}$에 해당하는 one-hot 벡터를 내적한 것과 같다. 즉 $C$라는 행렬에서 $w_{t}$에 해당하는 행만 참조하는 것과 동일한 결과를 얻을 수 있다. 따라서 이 과정을 Look-up table이라는 용어로 많이 사용한다

    • 문장 내 모든 단어들을 한 단어씩 훑으면서 Corpus 전체를 학습하게 된다면 NPLM 모델의 C 행렬에 각 단어의 문맥 정보를 내재할 수 있게 된다.

NPLM 입력 벡터

모델 구조 및 의미정보

이 때, walking을 맞추는 과정에서 발생한 손실(train loss)를 최소화하는 그래디언트(gradient)를 받아 각 단어에 해당하는 행들이 동일하게 업데이트 된다. 따라서 이 단어들의 벡터는 벡터 공간에서 같은 방향으로 조금씩 움직인다고 볼 수 있다. 결과적으로 임베딩 벡터 공간에서 해당 단어들 사이의 거리가 가깝다는 것은 의미가 유사하다라는 의미로 해석할 수 있다.

NPLM의 input 벡터

NPLM의 구조

NPLM의 특징

  • NPLM은 그 자체로 언어 모델 역할을 수행할 수 있다. 기존의 통계 기반 n-gram 모델은 학습 데이터에 한 번도 등장하지 않은 패턴에 대해서는 그 등장 확률을 0으로 부여하는 문제점을 NPLM은 문장이 Corpus에 없어도 문맥이 비슷한 다른 문장을 참고해 확률을 부여하는 방식으로 극복하는데 큰 의의가 있다. 하지만, 학습 파라미터가 너무 많아 계산이 복잡해지고, 자연스럽게 모델 과적합(overfitting)이 발생할 가능성이 높다는 한계를 가진다.

이 한계를 극복하기 위해 다음 임베딩 방법론인 Word2Vec이 등장하였다.

Word2Vec

Word2vec은 가장 널리 쓰이고 있는 단어 임베딩 모델이다. Skip-gramCBOW라는 모델이 제안되었고, 이 두 모델을 근간으로 하되 negative sampling등 학습 최적화 기법을 제안한 내용이 핵심이다.

  • CBOW

    • 주변에 있는 context word들을 가지고 target word 하나를 맟추는 과정에서 학습된다.

    • 입,출력 데이터 쌍

      • {context words, target word}

CBOW 모델 01

CBOW 모델 02

CBOW 모델의 Projection Layer

CBOW 모델의 Output Layer

  • Skip-gram

    • 처음 제안된 방식은 target word 하나를 가지고 context word들이 무엇일지 예측하는 과정에서 학습된다. 하지만, 이 방식은 정답 문맥 단어가 나타날 확률은 높이고 나머지 단어들 확률은 그에 맞게 낮춰야 한다. 그런데 어휘 집합에 속한 단어 수는 보통 수십만 개나되므로 이를 모두 계산하려면 비효율 적이다. 이런 점을 극복하기 위해 negative sampling이라는 target word와 context word 쌍이 주어졌을 때 해당 쌍이 positive sample인지 negative sample인지 이진 분류하는 과정에서 학습하는 방식을 제안했다. 이런다면 학습 step마다 1개의 positive sample과 나머지 k개(임의의 k:target 단어의 negative sampling 개수)만 계산하면 되므로 차원수가 2인 시그모이드를 k+1회만 계산하면된다. 이전의 매 step마다 어휘 집합 크기만큼의 차원을 갖는 softmax를 1회 계산하는 방법보다 계산량이 훨씬 적다. 또한 Corpus에서 자주 등장하지 않는 희귀한 단어가 negative sample로 조금 더 잘 뽑힐 수 있도록 하고 자주 등장하는 단어는 학습에서 제외하는 subsampling이라는 기법을 적용하였다. Skip-gram은 Corpus로 부터 엄청나게 많은 학습 데이터 쌍을 만들어 낼 수 있기 때문에 고빈도 단어의 경우 등장 횟수만큼 모두 학습시키는 것이 비효울적이라고 보았다. 이 또한, 학습량을 효과적으로 줄여 계산량을 감소시키는 전략이다.

    • 작은 Corpus는 k=5~20, 큰 Corpus는 k=2~5로 하는 것이 성능이 좋다고 알려져 있다.

    • skip-gram이 같은 말뭉치로도 더 많은 학습 데이터를 확보할 수 있어 임베딩 품질이 CBOW보다 좋은 경향이 있다.

    • 입,출력 데이터 쌍

      • {target word, target word 전전 단어}, {target word, target word 직전 단어}, {target word, target word 다음 단어}, {target word, target word 다다음 단어}

Skip-gram 모델

  • negative sample Prob

  • subsampling Prob

  • t, c가 positive sample(=target word 주변에 context word가 존재)일 확률

    • target word와 context가 실제 positive sample이라면 아래의 조건부 확률을 최대화해야 한다. 모델의 학습 parameter는 U와 V 행렬 두개 인데, 둘의 크기는 어휘 집합 크기$(|V|) \times 임베딩 차원 수(d)$로 동일하다. U와 V는 각각 target word와 context word에 대응한다.

Skip-gram 모델의 파라미터

  • 위의 식을 최대화 하려면 분모를 줄여야한다. 분모를 줄이려면 $exp(-u_{t}v_{c})$를 줄여야 한다. 그러려면 두 벡터의 내적값이 커지게 해야한다. 이는 코사인유사도와 비례함을 알 수 있다. 결론적으로 두 벡터간의 유사도를 높인다는 의미이다.

Exponential 함수

  • 잘 이해가 가지 않는다면 아래과 그림을 보자. A 가 B에 정확히 포개어져 있을 때(θ=0도) cos(θ)는 1이다. 녹색선의 길이가 단위원 반지름과 일치하기 때문이다. B는 고정한 채 A가 y축 상단으로 옮겨간다(θ가 0도에서 90도로 증가)고 할때 cos(θ)는 점점 감소하여 0이 되게 됩니다. 아래 그림의 경우 빨간색 직선이 x축과 만나는 점이 바로 cos(θ)를 의미한다.

cosine함수와 벡터간의 내적과의 관계

  • t, c가 negative sample(target word와 context word가 무관할때)일 확률
    • 만약 학습데이터가 negative sample에 해당한다면 아래의 조건부 확률을 최대화하여야 한다. 이 때는 분자를 최대화 해주어야 하므로, 두 벡터의 내적값을 줄여야 한다.
  • 모델의 손실함수를 이제 알았으니 최대화를 하는 파라미터를 찾으려면 MLE를 구해야 할 것이다. 그렇다면 log likelihood function은 아래와 같을 것이다. 임의의 모수인 모델 파라미터인 $\theta$라고 가정 했을때, $\theta$를 한 번 업데이트할 때 1개 쌍의 positive sample과 k개의 negative sample이 학습된다는 의미이다. Word2vec은 결국 두 단어벡터의 유사도 뿐만아니라 전체 Corpus 분포 정보를 단어 Embedding에 함축시키게 된다. 분포가 유사한 단어 쌍은 그 속성 또한 공유할 가능성이 높다. 유사도 검사를 통해 비슷한 단어들을 출력 했을때, 그 단어들이 반드시 유의 관계를 보여준다기 보다는 동일한 속성을 갖는 관련성이 높은 단어를 출력한다는 의미로 이해해야한다.

  • 모델 학습이 완료되면 U(target_word에 관한 행렬)만 d차원의 단어 임베딩으로 사용할 수도 있고, U+V.t 행렬을 임베딩으로 쓸 수도 있다. 혹은 concatenate([U, V.t])를 사용할 수도 있다.

참고

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# from gensim.models import word2vec
from gensim.models import Word2Vec
corpus = '원하는 텍스트를 단어를 기준으로 tokenize한 상태의 파일(가장 쉽게는 공백을 기준으로)'

# model = word2vec.Word2Vec()
model = Word2Vec(corpus,
size=임베딩 특징 벡터 차원수,
# 모델에 의미 있는 단어를 가지고 학습하기 위해 적은 빈도 수의 단어들은 학습하지 않는다.
min_count=단어에 대한 최소 빈도 수,
# default negative=5, 보통 5~20을 많이 사용
negative=negative sample을 뽑는 k,
workers=학습시 사용하는 프로세스 개수,
window=context window 크기,
# 학습을 수행할 때 빠른 학습을 위해 정답 단어 라벨에 대한 다운샘플링 비율을 지정한다.
# 유용한 범위는 (0, 1e-5)이며, 보통 0.001이 좋은 성능을 낸다고 한다.
sample=다운 샘플링 비율,
# default sg=0 => CBOW, if sg=1 => skip-gram
sg=1
)

model.save("모델을 저장할 directory path")

# 저장했던 모델을 불러와서 추가적으로 훈련시킬 수 있다.
model = Word2Vec.load("이미 존재하는 모델의 directory path")
model.train([["hello", "world"]], total_examples=1, epochs=1)

# 훈련된 벡터를 KeyedVector로 분리하는 이유는 전체 모델 상태가 더 이상 필요하지 않을 경우(훈련을 계속할 필요가 없음)
# 모델이 폐기될 수 있기 때문에 프로세스 간에 RAM의 벡터를 빠르게 로드하고 공유할 수 있는 훨씬 작고 빠른 상태로 만드는 것이다.
vector = model.wv['computer']

from gensim.models import KeyedVectors

path = get_tmpfile("wordvectors 파일명")

model.wv.save(path)
wv = KeyedVectors.load("model.wv", mmap='r')
vector = wv['computer']

  • 학습이 완료된 임베딩 결과물을 활요하여 코사인 유사도가 가장 높은 단어들을 뽑아 임베딩을 평가해 볼 수도 있다. 이는 추후에 한번에 소개할 것이다.

FastText

  • Facebook에서 개발해 공개한 단어 임베딩 기법이다. 각 단어를 문자단위 n-gram으로 표현한다. 이외의 점은 모두 Word2Vec과 같다. 동일하게 negative sampling을 사용하며, 조금 다른 점은 Fasttext는 target word(t), context word(c) 쌍을 학습할 때 target word(t)에 속한 문자 단위 n-gram 벡터(z)들을 모두 업데이트 한다는 점이다.

  • 설치 방법은 gensim에서 FastText를 제공하고 있기에 pip를 통해 설치해주거나 이 방법이 안된다면, 참조페이지를 클릭해서 직접 C++방식으로 받아도 상관없다.

모델 기본 구조

  • 예를 들어 시나브로라는 단어의 문자 단위 3-gram은 다음과 같이 n-gram 벡터의 합으로 표현한다. 아래 식에서 $G_{t}$는 target word t에 속한 문자 단위 n-gram집합을 의미한다.

  • Fasttext의 단어 벡터 표현(<,>는 단어의 경계를 나타내 주기 위해 모델이 사용하는 기호)

FastText를 통한 임베딩

  • n-gram 참조 및 NLP에 도움이 되는 사이트

    • n을 작게 선택하면 훈련 코퍼스에서 카운트는 잘 되겠지만 근사의 정확도는 현실의 확률분포와 멀어진다. 그렇기 때문에 적절한 n을 선택해야 한다. trade-off 문제로 인해 정확도를 높이려면 n은 최대 5를 넘게 잡아서는 안 된다고 권장되고 있다.
  • 손실함수 자체는 위의 식을 word2vec 손실함수 $u_{t}$에 대입해 주기만 하면된다.

  • FastText 모델의 강점은 조사나 어미가 발달한 한국어에 좋은 성능을 낼 수 있다는 점이다. 용언(동사, 형용사)의 활용이나 그와 관계된 어미들이 벡터 공간상 가깝게 임베딩 되기 때문이다.(예를들면, ‘하였다’가 t이고, ‘공부’가 c라면 ‘공부’와 ‘했(다), 하(다), 하(였으며)’등에 해당하는 벡터도 비슷한 공간상에 있다는 의미이다.) 한글은 자소 단위(초성, 중성, 종성)로 분해할 수 있고, 이 자소 각각을 하나의 문자로 보고 FastText를 실행할 수 있다는 점도 강점이다.

  • 또한, 각 단어의 임베딩을 문자 단위 n-gram 벡터의 합으로 표현하기 때문에 오타나 미등록단어(unknown word)에도 robust하다. 그래서 미등록된 단어도 벡터를 뽑아낼수 있다. 동일한 음절이나 단어를 가진 공간상의 벡터를 추출할 수 있기 때문이다. 다른 단어 임베딩 기법이 미등록 단어 벡터를 아예 추출할 수 없다는 사실을 감안하면 FastText는 경쟁력이 있다.

Fasttext 참조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from gensim.models import FastText

corpus = '원하는 텍스트를 단어를 기준으로 tokenize한 상태의 파일(가장 쉽게는 공백을 기준으로)'

model = Word2Vec(corpus,
size=임베딩 특징 벡터 차원수,
# 모델에 의미 있는 단어를 가지고 학습하기 위해 적은 빈도 수의 단어들은 학습하지 않는다.
min_count=단어에 대한 최소 빈도 수,
# default negative=5, 보통 5~20을 많이 사용
negative=negative sample을 뽑는 k,
workers=학습시 사용하는 프로세스 개수,
window=context window 크기,
# 학습을 수행할 때 빠른 학습을 위해 정답 단어 라벨에 대한 다운샘플링 비율을 지정한다.
# 유용한 범위는 (0, 1e-5)이며, 보통 0.001이 좋은 성능을 낸다고 한다.
sample=다운 샘플링 비율,
# default sg=0 => CBOW, if sg=1 => skip-gram
sg=1,
# default word_ngrams=1 => n-gram 사용, 0 => 미사용(word2vec과 동일)
word_ngrams=1,
# n-gram 최소 단위
min_n=3,
# n-gram 최대 단위 (최소단위보단 커야한다.)
max_n=6,
)

잠재 의미 분석(LSA, Latent Semantic Analysis)

  • word-document 행렬이나 TF-IDF 행렬, word-context 행렬 같은 커다란 행렬에 차원 축소 방법의 일종인 특이값 분해를 수행해 데이터의 차원 수를 줄여 계산 효율성을 키우는 한편 행간에 숨어있는 잠재 의미를 추출해내는 방법론이다.

  • 예를 들면, word-documents 행렬이나 word-context 행렬 등에 SVD를 한 다음 그 결과로 도출되는 행벡터들을 단어 임베딩으로 사용할 수 있다. 잠재 의미 분석은 GloVe나 Swivel과 더불어 Matrix Factorization 기반의 기법으로 분류된다.

PPMI(점별 상호 정보량) 행렬

  • word-document 행렬, TF-IDF 행렬, word-context 행렬, PMI 행렬에 모두 LSA를 수행할 수 있다. 이 중 PMI 행렬을 보완하는 PPMI 행렬에 대해 소개하고자한다. PMI 행렬과 위의 행렬들을 모른다면 클릭!

  • PPMI란 간단히 말해 우리가 가진 말뭉치의 크기가 충분히 크지 않다면, PMI식의 로그 안 분자가 분모보다 작을 때 음수가 되거나, 극단적으로 단어 A,B가 단 한번도 같이 등장하지 않는다면 $-inf$값을 갖게 된다. 이러한 이유로 NLP 분야에서는 PMI 대신 PPMI(Positive Pointwise Mutual Information)를 지표로 사용한다. PMI가 양수가 아닌 경우 그 값을 신뢰하기 힘들어 0으로 치환해 무시한다는 뜻이다.

  • Shifted PMI(SPMI)는 Word2Vec과 깊은 연관이 있다는 논문이 발표되기도 했다.

행렬 분해로 이해하는 잠재 의미 분석

  • Eigenvalue Decomposition(고유값 분해)를 우선 알고 있다는 전제조건으로 SVD를 모르실수도 있는 분들을 위해 간략히 설명하자면, 고유값 분해는 행렬 A가 정방행렬일 경우만 가능한데, 만약 정방행렬이 아닌 행렬은 고유값 분해를 어떻게 해야 하는지에 대한 개념이라고 말할 수 있겠다. 혹시 고유값 분해도 잘 모르시겠다면 이곳을 클릭해서 필자가 추천하는 강의들을 꼭 공부해 보시길 추천한다. 필자는 개인적으로 선형대수는 Computer Science(or 데이터 분석)를 하는데 기본적으로 어느 정도 알고 있어야 한다고 생각한다.

참조

특이값 분해 - SVD

  • 예를 들어, 행렬 A의 m개의 word, n개 documents로 이루어져 shape이 $ m \times n $인 word-documents 행렬에 truncated SVD를 하여 LSA를 수행한다고 가정해본다. 그렇다면 U는 단어 임베딩, V.t는 문서 임베딩에 대응한다. 마찬가지로 m개 단어, m개 단어로 이루어진 PMI 행렬에 LSA를 하면 d차원 크기의 단어 임베딩을 얻을 수 있다.

  • 각종 연구들에 따르면 LSA를 적용하면 단어와 문맥 간의 내재적인 의미를 효과적으로 보존할 수 있게 돼 결과적으로 문서 간 유사도 측정 등 모델의 성능 향상에 도움을 줄 수 있다고 한다. 또한 입력 데이터의 노이즈, sparsity(희소)를 줄일 수 있다.

Truncated SVD

행렬 분해로 이해하는 Word2Vec

  • negative sampling 기법으로 학습된 Word2Vec의 Skip-gram 모델(SGNS, Skip-Gram with Negative Sampling)은 Shifted PMI 행렬을 분해한 것과 같다는 것을 볼 수 있다.

행렬 분해 관점에서 이해하는 word2vec

  • $A_{ij}$는 SPMI행렬의 i,j번째 원소이다. k는 Skip-gram 모델의 negative sample 수를 의미한다. 그러므로 k=1인 negative sample 수가 1개인 Skip-gram 모델은 PMI 행렬을 분해하는 것과 같다.
  • soynlp에서 제공하는 sent_to_word_contexts_matrix 함수를 활용하면 word-context 행렬을 구축할 수 있다. dynamic_weight=True는 target word에서 멀어질수록 카운트하는 동시 등장 점수(co-occurrence score)를 조금씩 깎는다는 의미이다. dynamic_weight=False라면 window 내에 포함된 context word들의 동시 등장 점수는 target word와의 거리와 관계 없이 모두 1로 계산한다. 예를 들어서 window=3이고 ‘도대체 언제쯤이면 데이터 사이언스 분야를 조금은 공부했다고 말할 수 있을까…’라는 문장의 target word가 ‘분야’라면, ‘를’과 ‘사이언스’의 동시 등장 점수는 1, ‘데이터’, ‘조금’은 0.66, ‘은’, ‘이면’은 0.33이 된다.
  • word-context 행렬을 활용한 LSA

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from sklearn.decomposition import TruncatedSVD
    from soynlp.vectorizer import sent_to_word_contexts_matrix

    corpus_file

    corpus = [sent.replace('\n', '').strip() for sent in open(corpus_file, 'r').readlines()]

    input_matrix, idx2vocab = sent_to_word_contexts_matrix(corpus,
    window=3,
    # 최소 단어 빈도 수
    min_tf=10,
    dynamic_weight=True,
    verbose=True)

    cooc_svd = TruncatedSVD(n_components=100)
    cooc_vecs = cooc_svd.fit_transform(input_matrix)
  • 구축한 word-context 행렬에 soynlp에서 제공하는 pmi 함수를 적용한다. min_pmi 보다 낮은 PMI 값은 0으로 치환한다. 따라서 min_pmi=0으로 설정하면 정확히 PPMI와 같다. 또한, pmi matrix의 차원수는 어휘 수 x 어휘 수의 정방 행렬이다.

1
2
3
4
from soynlp import pmi
ppmi_matrix, _, _ = pmi(input_matrix, min_pmi=0)
ppmi_svd = TruncatedSVD(n_components=100)
ppmi_vecs = ppmi_svd.fit_transform(input_matrix)

GloVe(Global Word Vectors)

  • 미국 스탠포트대학교연구팀에서 개발한 단어 임베딩 기법이다. 임베딩된 단어 벡터 간 유사도 측정을 수월하게 하면서도 Corpus 전체의 통계 정보를 좀 더 잘 반영하는 것을 지향하여 Vanilla Word2Vec과 LSA 두 기법의 단점을 극복하고자 했다. LSA(잠재 의미 분석)은 Corpus 전체의 통계량을 모두 활용할 수 있지만, 그 결과물로 단어 간 유사도를 측정하기는 어렵다. 반대로 Vanilla Word2Vec은 단어 벡터 사이의 유사도를 측정하는 데는 LSA보다 유리하지만 사용자가 지정한 window 내의 local context만 학습하기 때문에 Corpus 전체의 통계 정보는 반영되기 어렵다는 단점을 지닌다. 물론 GloVe 이후 발표된 Skip-gram 모델이 Corpus 전체의 Global한 통계량인 SPMI 행렬을 분해하는 것과 동치라는 점을 증명하기는 했다.

그림으로 이해하는 GloVe

  • 손실 함수

    • 임베딩된 두 단어 벡터의 내적이 말뭉치 전체에서의 동시 증장 빈도의 로그 값이 되도록 정의했다.

    • 단어 i,j 각각에 해당하는 벡터 $U_{i}$, $V_{j}$ 사이의 내적값과 두 단어 동시 등장 빈도 $A_{ij}$의 로그값 사이의 차이가 최소화될수록 학습 손실이 작아진다. bias항 2개와 f(A_{ij})는 임베딩 품질을 높이기 위해 고안된 장치이다.

    • Glove는 word-context 행렬 A를 만든 후에 학습이 끝나면 U를 단어 임베딩으로 사용하거나 U+V.t, concatenate([U, V.t])를 임베딩으로 사용할 수 있다.

Swivel

  • Google 연구팀이 발표한 행렬 분해 기반의 단어 임베딩 기법이다. PMI 행렬을 U와 V로 분해하고, 학습이 종료되면 U를 단어 임베딩으로 쓸 수 있으며 U+V.t, concatenate([U, V.t])도 임베딩으로 사용할 수 있다.

  • PMI 행렬을 분해한다는 점에서 word-context 행렬을 분해하는 GloVe와 다르며, Swivel은 목적함수를 PMI의 단점을 보완할 수 있도록 설계했다. 두 단어가 한번도 동시에 등장하지 않았을 경우 PMI가 -inf로 가능 현상을 보완하기 위해 이런경우의 손실함수를 따로 정의했다. 그 결과, i,j가 각각 고빈도 단어인데 두 단어의 동시 등장빈도가 0이라면 두 단어는 정말로 등장하지 않는 의미상 무관계한 단어라고 가정하고, 단어 i,j가 저빈도 단어인데 두 단어의 동시 등장빈도가 0인 경우에는 두 단어는 의미상 관계가 일부 있을 수 있다고 가정한다.

그림으로 이해하는 Swivel

단어 임베딩 평가 방법

  • 참고로 카카오브레인 박규병 님께서는 한국어, 일본어, 중국어 등 30개 언어의 단어 임베딩을 학습해 공개했다. 모델은 주로 해당 언어의 위키백과 등으로 학습됐으며 벡터 차원 수는 100, 3000차원 두 종류가 있다.

단어 유사도 평가(word similarity test)

  • 일련의 단어 쌍을 미리 구성한 후에 사람이 평가한 점수와 단어 벡터 간 코사인 유사도 사이의 상관관계를 계산해 단어 임베딩의 품질을 평가하는 방법이다.

  • Word2Vec과 FastText 같은 예측 기반 임베딩 기법들이 GloVe, Swivel 등 행렬 분해 방법들에 비해 상관관계가 상대적으로 강한 것을 알 수 있다. 물론 무조건 예측기반이 좋다는 의미는 아니다. 데이터에 다르겠지만 보통은 저런 결과를 얻을 것이다.

단어 유추 평가(word analogy test)

  • 의미론적 유추에서 단어 벡터 간 계산을 통해 갑 - 을 + 병 = 정을 통해 평가하는 방법이다. 갑 - 을 + 병에 해당하는 벡터에 대해 코사인 유사도가 가장 높은 벡터에 해당하는 단어가 실제 인지를 확인한다.

단어 임베딩 시각화

  • 시각화 또한 단어 임베딩을 평가하는 방법이다. 다만 단어 임베딩은 보통 고차원 벡터이기 때문에 사람이 인식하는 2, 3차원으로 축소해 시각화를 하게 된다. t-SNE(t-Stochastic Neighbor Embedding)은 고차원의 원공간에 존재하는 벡터 x의 이웃 간의 거리를 최대한 보존하는 저차원 벡터 y를 학습하는 방법론이다. 원 공간의 데이터 확률 분포와 축소된 공간의 분포 사이의 차이를 최소화하는 방향으로 벡터 공간을 업데이트한다.

가중 임베딩

  • 단어 임베딩을 문장 수준 임베딩으로 확장하는 방법을 설명하겠다. 아주 간단한 방법이지만 성능 효과가 좋아서 사용해볼만한 방법이다. 미국 프린스턴 대학교 연구팀이 ICLR에 발표한 방법론이다.

모델 개요

  • Arora et al.(2016)은 문서 내 단어의 등장은 저자가 생각한 주제에 의존한다고 가정했다. 이를 위해 주제 벡터(discourse vector)라는 개념을 도입했다. 주제 벡터 $c_{s}$가 주어졌을 때 어떤 단어 w가 나타날 확률을 아래와 같이 정의했다. $\tilde{c_{s}}$는 $c_{s}$로 부터 도출되는데 그 과정은 생략하고, 간단히 말하면 주제 벡터 c_{s}와 거의 비슷한 역할을 하는 임의의 어떤 벡터라고 보겠다. Z는 우변 두번째 항이 확률 값이 되도록 해주는 Normalize Factor이다.

  • 우변의 첫째항은 단어 w가 주제와 상관없이 등장할 확률이며, 한국어에서는 조사(은,는,이,가 등)가 P(w)가 높은 축에 속한다. 두 번째 항은 단어 w가 주제와 관련을 가질 확률을 의미한다. 주제 벡터 $\tilde{c_{s}}$와 w에 해당하는 단어 벡터 $v_{w}$가 내적값이 클수록 그 값이 커진다. $\alpha$는 사용자가 지정하는 hyper parameter이다.

  • 단어 등장 확률

  • 단어 sequence는 문장이다. 문장 등장 확률(단어들이 동시에 등장할 확률)은 문장에 속한 모든 단어들이 등장할 확률의 누적 곱으로 나타낼 수 있다. 그런데 확률을 누적해서 곱하면 너무 작아지는 underflow 문제가 발생하므로 로그를 취해 덧셈을 하는 것으로 대체한다.

  • 문장 등장확률

  • 단어 등장 확률의 Taylor Series approximation

  • 우리가 관찰하고 있는 단어 w가 등장할 확률을 최대화하는 주제벡터 $ c_{s} / \tilde{c_{s}} $를 찾는 것이 목표이다. w가 등장할 확률을 최대화하는 $ c_{s} / \tilde{c_{s}} $를 찾게 된다면 이 $ c_{s} / \tilde{c_{s}} $ 는 해당 단어의 사용을 제일 잘 설명하는 주제 벡터가 될 것이다.

  • 직관적으로 말하자면, 우리가 관찰하고 있는 문장이 등장할 확률을 최대화하는 주제 벡터 $ c_{s} / \tilde{c_{s}} $는 문장에 속한 단어들에 해당하는 단어 벡터에 가중치를 곱해 만든 새로운 벡터들의 합에 비례한다. 희귀한 단어라면 높은 가중치를 곱해 해당 단어 벡터의 크기를 키우고, 고빈도 단어라면 해당 벡터의 크기를 줄니다. 이는 정보성이 높은, 희귀한 단어에 가중치를 높게 주는 TF-IDF의 철학과도 맞닿아 있는 부분이다. 또한 문장 내 단어의 등장 순서를 고려하지 않는다는 점에서 Bag of Words 가정과도 연결된다.

모델 구현

  • 문장을 Token으로 나눈 뒤 해당 Token들에 대응하는 벡터들의 합으로 문장의 임베딩을 구한다. 예측은 테스트 문장이 들어오면 Token 벡터의 합으로 만들고, 이 벡터와 코사인 유사도가 가장 높은 학습 데이터 문장의 임베딩을 찾는다. 이후 해당 학습 데이터 문장에 달려 있는 레이블을 리턴하는 방식이다.

  • 예를들어, ‘영화 정말 재밌다.’가 테스트 문장이고, 이 문장과 유사한 학습 데이터 임베딩이 ‘영화가 진짜 재미지네요.+긍정’이라면, 테스트 문장을 긍정이라고 예측한다는 것이다.

  • 또한, 과연 어느정도의 효과가 있는지 비교하기위해 대조군으로 일반적인 합을 통한 임베딩 방식도 수행해볼것이다.

Weighted Sum을 이용한 Documents Classification Model

  • 참고로 해당 모델을 수행하려면 먼저 형태소 분석이 완료된 Corpus file과 Corpus를 통해 만들어진 Embedding File이 존재해야한다.

  • 먼저 tokenizer를 선택해서 사용할 수 있도록 각 Tokenizer에 따른 객체를 생성해주는 함수를 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from khaiii import KhaiiiApi
from konlpy.tag import Okt, Komoran, Mecab, Hannanum, Kkma


def get_tokenizer(tokenizer_name):
if tokenizer_name == "komoran":
tokenizer = Komoran()
elif tokenizer_name == "okt":
tokenizer = Okt()
elif tokenizer_name == "mecab":
tokenizer = Mecab()
elif tokenizer_name == "hannanum":
tokenizer = Hannanum()
elif tokenizer_name == "kkma":
tokenizer = Kkma()
elif tokenizer_name == "khaiii":
tokenizer = KhaiiiApi()
else:
tokenizer = Mecab()
return tokenizer
  • 모델을 저장할 path가 존재하지 않는다면 directory를 만들어주는 함수를 만들어준다.
1
2
3
4
5
6
7
8
import os

def make_save_path(full_path):
if full_path[:4] == "data":
full_path = os.path.join(os.path.abspath("."), full_path)
model_path = '/'.join(full_path.split("/")[:-1])
if not os.path.exists(model_path):
os.makedirs(model_path)
  • 아래 embedding_method의 default값은 fasttext이지만 실제로 필자가 실행시에는 word2vec을 사용할 것이다.
  • defaultdict은 말 그대로 처음에 값을 지정해주지 않으면 default값을 넣어준다는 의미이다.

  • 비교 할 사항

    • embedding method : fasttext vs word2vec
    • sum method : weighted sum vs sum
    • average or nor : average vs not average
  • 이 글에서는 2번째 항목의 비교한 결과만을 보여 줄 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
from collections import defaultdict
from gensim.models import Word2Vec

class CBoWModel(object):

def __init__(self, train_fname, embedding_fname, model_fname, embedding_corpus_fname,
embedding_method="fasttext", is_weighted=None, average=False, dim=100, tokenizer_name="mecab"):
# configurations
make_save_path(model_fname)
self.dim = dim
# 평균을 내줄것인지 아니면 합만을 사용할 것인지에 대한 옵션이다.
self.average = average
if is_weighted:
model_full_fname = model_fname + "-weighted"
else:
model_full_fname = model_fname + "-original"
self.tokenizer = get_tokenizer(tokenizer_name)
if is_weighted:
# ready for weighted embeddings
# dictionary 형태로 이루어져있다. (embedding["word"]=embedding_vaector)
self.embeddings = self.load_or_construct_weighted_embedding(embedding_fname, embedding_method, embedding_corpus_fname)
print("loading weighted embeddings, complete!")
else:
# ready for original embeddings
words, vectors = self.load_word_embeddings(embedding_fname, embedding_method)
self.embeddings = defaultdict(list)
for word, vector in zip(words, vectors):
self.embeddings[word] = vector
print("loading original embeddings, complete!")

# 모델이 존재하지 않는다면 새롭게 훈련시키고 존재한다면 load해 온다.
if not os.path.exists(model_full_fname):
print("train Continuous Bag of Words model")
self.model = self.train_model(train_fname, model_full_fname)
else:
print("load Continuous Bag of Words model")
self.model = self.load_model(model_full_fname)

def evaluate(self, test_data_fname, batch_size=3000, verbose=False):
print("evaluation start!")
test_data = self.load_or_tokenize_corpus(test_data_fname)
data_size = len(test_data)
num_batches = int((data_size - 1) / batch_size) + 1
eval_score = 0
for batch_num in range(num_batches):
batch_sentences = []
batch_tokenized_sentences = []
batch_labels = []
start_index = batch_num * batch_size
end_index = min((batch_num + 1) * batch_size, data_size)
features = test_data[start_index:end_index]
for feature in features:
sentence, tokens, label = feature
batch_sentences.append(sentence)
batch_tokenized_sentences.append(tokens)
batch_labels.append(label)
preds, curr_eval_score = self.predict_by_batch(batch_tokenized_sentences, batch_labels)
eval_score += curr_eval_score
if verbose:
for sentence, pred, label in zip(batch_sentences, preds, batch_labels):
print(sentence, ", pred:", pred, ", label:", label)
print("number of correct:", str(eval_score), ", total:", str(len(test_data)), ", score:", str(eval_score / len(test_data)))

def predict(self, sentence):
# 문장을 예측을 하기 위해서는 우선 형태소를 분석을 해야한다.
tokens = self.tokenizer.morphs(sentence)
# 문장의 형태소들을 임베딩 벡터와 같은 크기의 영벡터를 만든후 계속해서 더해주는 방식으로 문장 임베딩 벡터를 생성한다.
# 만약 average=True했다면,
sentence_vector = self.get_sentence_vector(tokens)
# 모델의 문장 임베딩 벡터와 sentence 문장 벡터와의 내적으로 유사도를 측정한다.
scores = np.dot(self.model["vectors"], sentence_vector)
# 제일높은 유사도를 지닌 라벨을 출력해준다.
pred = self.model["labels"][np.argmax(scores)]
return pred

def predict_by_batch(self, tokenized_sentences, labels):
sentence_vectors, eval_score = [], 0
for tokens in tokenized_sentences:
sentence_vectors.append(self.get_sentence_vector(tokens))
scores = np.dot(self.model["vectors"], np.array(sentence_vectors).T)
preds = np.argmax(scores, axis=0)
for pred, label in zip(preds, labels):
if self.model["labels"][pred] == label:
eval_score += 1
return preds, eval_score

def get_sentence_vector(self, tokens):
vector = np.zeros(self.dim)
for token in tokens:
if token in self.embeddings.keys():
vector += self.embeddings[token]
if self.average:
vector /= len(tokens)
vector_norm = np.linalg.norm(vector)
if vector_norm != 0:
unit_vector = vector / vector_norm
else:
unit_vector = np.zeros(self.dim)
return unit_vector

def load_or_tokenize_corpus(self, fname):
data = []
if os.path.exists(fname + "-tokenized"):
with open(fname + "-tokenized", "r") as f1:
for line in f1:
sentence, tokens, label = line.strip().split("\u241E")
data.append([sentence, tokens.split(), label])
else:
with open(fname, "r") as f2, open(fname + "-tokenized", "w") as f3:
for line in f2:
sentence, label = line.strip().split("\u241E")
tokens = self.tokenizer.morphs(sentence)
data.append([sentence, tokens, label])
f3.writelines(sentence + "\u241E" + ' '.join(tokens) + "\u241E" + label + "\n")
return data

def compute_word_frequency(self, embedding_corpus_fname):
total_count = 0
# {단어 : 해당 단어 개수}로 표현해주기 위해 다음과 같이 defaultdict을 사용했다.
# defaultdict 을 사용한 이유는 값을 따로 지정해 주지 않는다면 default 값을 사용하기 위해서이다.
words_count = defaultdict(int)
with open(embedding_corpus_fname, "r") as f:
for line in f:
tokens = line.strip().split()
for token in tokens:
words_count[token] += 1
total_count += 1
return words_count, total_count

def load_word_embeddings(self, vecs_fname, method):
if method == "word2vec":
model = Word2Vec.load(vecs_fname)
words = model.wv.index2word
vecs = model.wv.vectors
else:
words, vecs = [], []
with open(vecs_fname, 'r', encoding='utf-8') as f1:
if "fasttext" in method:
next(f1) # skip head line
for line in f1:
if method == "swivel":
splited_line = line.replace("\n", "").strip().split("\t")
else:
splited_line = line.replace("\n", "").strip().split(" ")
words.append(splited_line[0])
vec = [float(el) for el in splited_line[1:]]
vecs.append(vec)
return words, vecs

def load_or_construct_weighted_embedding(self, embedding_fname, embedding_method, embedding_corpus_fname, a=0.0001):
dictionary = {}
# 이미 만들어진 가중합 embedding이 존재할 경우
if os.path.exists(embedding_fname + "-weighted"):
with open(embedding_fname + "-weighted", "r") as f2:
for line in f2:
# \u241E : Symbol for Record Seperator
word, weighted_vector = line.strip().split("\u241E")
weighted_vector = [float(el) for el in weighted_vector.split()]
dictionary[word] = weighted_vector
else:
# 위에서 embedding-weighted 파일이 존재하지 않는다면 훈련을 해야하므로
# 우선 이미 embedding된 파일의 단어와 해당단어의 임베딩 벡터를 불러온다.
# load pretrained word embeddings
# 해당 임베딩 파일에 있는 단어와 그에 해당하는 임베딩벡터를 순서대로 불러온다.
words, vecs = self.load_word_embeddings(embedding_fname, embedding_method)
# compute word frequency
words_count, total_word_count = self.compute_word_frequency(embedding_corpus_fname)
# construct weighted word embeddings
# embedding_fname - weighted로 가중합을 계산한 임베딩벡터 파일을 생성한다.
with open(embedding_fname + "-weighted", "w") as f3:
for word, vec in zip(words, vecs):
if word in words_count.keys():
word_prob = words_count[word] / total_word_count
else:
word_prob = 0.0
weighted_vector = (a / (word_prob + a)) * np.asarray(vec)
dictionary[word] = weighted_vector
f3.writelines(word + "\u241E" + " ".join([str(el) for el in weighted_vector]) + "\n")
return dictionary

def train_model(self, train_data_fname, model_fname):
model = {"vectors": [], "labels": [], "sentences": []}
# [sentence, tokens, label]형태로 출력
train_data = self.load_or_tokenize_corpus(train_data_fname)
with open(model_fname, "w") as f:
for sentence, tokens, label in train_data:
tokens = self.tokenizer.morphs(sentence)
sentence_vector = self.get_sentence_vector(tokens)
model["sentences"].append(sentence)
model["vectors"].append(sentence_vector)
model["labels"].append(label)
str_vector = " ".join([str(el) for el in sentence_vector])
f.writelines(sentence + "\u241E" + " ".join(tokens) + "\u241E" + str_vector + "\u241E" + label + "\n")
return model

def load_model(self, model_fname):
model = {"vectors": [], "labels": [], "sentences": []}
with open(model_fname, "r") as f:
for line in f:
sentence, _, vector, label = line.strip().split("\u241E")
vector = np.array([float(el) for el in vector.split()])
model["sentences"].append(sentence)
model["vectors"].append(vector)
model["labels"].append(label)
return model

모델의 파라미터 값 설정

1
2
3
4
5
6
train_fname = "./data/processed/processed_ratings_train.txt"
embedding_fname = "./data/word-embeddings/word2vec/word2vec"
model_fname = "./data/word-embeddings/cbow/word2vec"
embedding_corpus_fname = "./data/tokenized/ratings_mecab.txt"
embedding_method = "word2vec"
test_data_fname = "./data/processed/processed_ratings_test.txt"

모델 학습 및 평가

해당 문장에 대한 단어벡터들의 합만을 가지고 예측

1
2
3
4
original_Model=CBoWModel(train_fname=train_fname, embedding_fname=embedding_fname, model_fname=model_fname, embedding_corpus_fname=None,
embedding_method=embedding_method, is_weighted=False, average=False, dim=100, tokenizer_name="mecab")

original_Model.evaluate(test_data_fname)

결과

1
2
3
4
5
loading original embeddings, complete!
train Continuous Bag of Words model

evaluation start!
number of correct: 36498 , total: 49997 , score: 0.7300038002280137

해당 문장에 대한 단어벡터들의 가중합을 가중합을 가지고 예측

1
2
3
4
5
weighted_Model=CBoWModel(train_fname=train_fname, embedding_fname=embedding_fname, model_fname=model_fname,
embedding_corpus_fname=embedding_corpus_fname,embedding_method=embedding_method,
is_weighted=True, average=False, dim=100, tokenizer_name="mecab")

weighted_Model.evaluate(test_data_fname)

결과

1
2
3
4
5
loading weighted embeddings, complete!
train Continuous Bag of Words model

evaluation start!
number of correct: 34208 , total: 49997 , score: 0.6842010520631238