AdaGram : 동음이의어를 구분하는 Word2Vec

Posted by 적분 ∫2tdt=t²+c
2018.09.23 03:38 프로그래밍/NLP

2013년 등장한 Word2Vec은 강력한 단어 임베딩 기법입니다. 의미적으로 유사한 단어를 벡터 공간 상에 가까운 위치에 배치시키도록 학습하는 신경망을 이용해서 성공적으로 단어를 임베딩시켰습니다. 유사한 단어가 유사한 위치에 배치된다는 특징 덕분에 여러 자연언어처리 기법에서 이미 널리 쓰이고 있는데요, 사실 이 기법에는 한가지 약점이 있습니다. 오직 단어의 형태로만 단어를 구분하기 때문에 형태가 같아도 의미가 전혀 다른 단어를 구분해내지 못한다는 것입니다. 


예를 들어 한국어 단어 '밤'을 생각해봅시다. 해 진 이후의 시간대를 뜻하는 '밤'과 견과류의 일종인 '밤'이 같은 형태로 적히기 때문에 Word2Vec은 학습과정에서 두 단어를 동일한 단어로 간주하고 처리합니다. 실제로 두 단어는 다른 단어인데 말이죠. 이 때문에 동음이의어(사실 텍스트를 처리하는 입장에서는 동철이의어(철자가 같지만 다른 의미인 단어)라고 말하는게 맞겠네요)를 전혀 구분하지 못하고 둘을 같은 단어로 벡터 공간 상에 매핑합니다.


다행히도 이런 단어가 (영어에는) 매우 많지 않기 때문에 Word2Vec이 성공적으로 쓰일 수 있었지만, 좀더 발전된 자연언어처리를 위해서는 넘어야할 벽이겠죠. 다행히 2016년에 디리클레 프로세스(Dirichlet Process)를 이용해서 모호성을 해소하는 Word2Vec 기법이 제시되었습니다. 


Bartunov, S., Kondrashkin, D., Osokin, A., & Vetrov, D. (2016, May). Breaking sticks and ambiguities with adaptive skip-gram. In Artificial Intelligence and Statistics (pp. 130-138).


위 논문인데요, 개인적으로 제목을 아주 잘 지었다고 생각합니다. Breaking sticks는 이 논문에서 사용된 디리클레 프로세스를 설명하는데에 자주 쓰이는 예시인 막대 부러뜨리기(Stick Breaking)를 나타내는 것이고, Breaking ambiguities는 이 기법이 모호성을 해소하는데 쓰일 수 있다는 걸 잘 드러내고 있죠. 아주 재밌게 지은 제목인것 같아요.


이 논문에서 소개된 AdaGram(Adaptive Skip-gram)의 예시 코드(https://github.com/sbos/AdaGram.jl)는 Julia라는 언어로 작성되어 있는데요, 제가 이 언어에 익숙하지 않은 관계로 실험을 돌려보는데에 지장이 있어서 연휴를 이용해서 c++로 포팅했습니다. (성능 향상은 덤이구요. 이에 대해서는 뒷부분에 소개하겠습니다.)


디리클레 프로세스 복습!

디리클레 프로세스에 대해서는 예전에 비모수 토픽 모델링 기법 중 하나인 HDP를 소개할 때 같이 설명한 적이 있습니다. 막대 부러뜨리기 과정에 대해서는 이 블로그에 잘 설명되어 있구요. 디리클레 프로세스의 주요 특징 중 하나는 모분포를 따르는 이산 분포를 잘 묘사할 수 있다는 것이고, 또 다른 특징은 비모수 분포(쉽게 말해 그 갯수가 사전에 정해져 있지 않다고 생각하시면 되겠습니다)라는 것입니다.


예를 들어 길이가 1인 빼빼로를 여러 사람이 적당히 나눠가져야 한다고 생각해봅시다. 사람이 3명으로 정해져 있다면야 세 조각으로 (공평하든 공평하지않든) 적당히 자를 수 있겠지만, 나눠 먹을 사람의 수가 정해져 있지 않은 경우 어떻게 나누면 좋을까요?


  1. [0, 1] 범위에서 임의의 실수 r1를 뽑는다. 이 값은 남은 빼빼로 중 얼만큼을 잘라먹을지 결정한다.
  2. 남은 길이 1에서 r1만큼을 잘라낸다. 첫번째 사람은 r1만큼 먹고, 이제 남은 빼빼로는 1 - r1
  3. 다음 사람으로 넘어가서 다시, [0, 1] 범위에서 임의의 실수 r2를 뽑는다.
  4. 남은 길이 1 - r1에서 r2비율 만큼을 잘라낸다. 두번째 사람은 (1-r1)r2 만큼 먹고, 이제 남은 빼빼로는 (1 - r1)(1 - r2)
  5. 다음 사람으로 넘어가서 다시, [0, 1] 범위에서 임의의 실수 r3를 뽑는다.
  6. 남은 길이 (1-r1)(1-r2)에서 r3비율 만큼을 잘라낸다. 세번째 사람은 (1-r1)(1-r2)r3 만큼 먹고, 이제 남은 빼빼로는 (1 - r1)(1 - r2)(1 - r3)
  7. 계속 반복...

우리는 이론적으로 빼빼로가 0.00001이라도 남아있는 한 이 과정을 계속 반복할 수 있습니다. 물론 뒷사람들이 코딱지만큼 남은 빼빼로를 보고 기분나빠할순 있겠지만 그건 수학자가 신경쓸 바가 아닙니다.


[0, 1] 범위에서 임의의 수 r을 어떻게 뽑느냐에 따라서 사람들이 나눠먹는 빼빼로의 비중이 달라질 수 있습니다. 임의의 수 r로 1에 가까운 값이 더 많이 뽑힌다면, 앞쪽의 사람이 대부분의 빼빼로를 먹어치울테지요. 반면, r이 작은 값 위주로 뽑힌다면, 앞사람들이 조금만 먹고 뒷쪽으로 넘겨줄테니 뒷 사람들도 많은 빼빼로를 먹을 수 있을 겁니다. 이때 [0, 1]에서 임의의 실수를 뽑는 분포로 베타 분포 B(1, α)를 사용하는데요, 여기에 사용되는 파라미터 α가 바로 이를 조절합니다. α가 작을수록 전반적인 r값이 커지구요, 반대로 α가 클수록 전반적인 r값이 작아집니다.


이렇게 장황하게 디리클레 프로세스를 설명한 이유는, 우리가 데이터를 보기 전까지 한 단어 형태에 얼마나 다양한 의미가 포함되어 있을지 모르기 때문에, 이를 해결하기 위해 각 단어의 의미가 디리클레 프로세스를 따르도록 논문에서 설정하고 있기 때문입니다.


즉 각 단어 A, B, ... 에 대해


A: 의미A1, 의미A2, 의미A3...

B: 의미B1, 의미B2, 의미B3...

처럼 두고, 각각의 의미 단위에 대해서 Word2Vec 학습을 진행하는게 이 논문의 아이디어입니다.


Word2Vec 복습!

https://ronxin.github.io/wevi/ 에서 Word2Vec 학습 과정의 시각화를 살펴볼 수 있습니다.


복잡해보이지만 의외로 간단한 구조입니다. 입력, 출력이 각각 V개가 있고, 가운데 은닉층 N개가 이를 이어주는 구조입니다. 위의 예시 그림에서는 V = 8, N = 5겠죠. "eat apple drink"라는 문장이 입력되었고, Window 크기를 1이라고 한다면(좌우 1개의 단어를 보겠다는 뜻입니다.) apple이라는 단어의 좌우 문맥은 eat와 drink가 됩니다. 따라서 apple이라는 단어를 입력했을때 eat이 나오도록 학습을 하고, 또 apple이 입력되었을때 drink가 나오도록 학습을 하죠. (skip-gram 기법) 혹은 반대로 문맥이 주어졌을 때 가운데의 단어를 예측하도록 학습을 할 수도 있구요. (cbow 기법)

학습시 모든 edge의 weight을 계산하려면, 그 연산량이 굉장히 많기 때문에 이를 줄이기 위해 Hierarchical Softmax나 Negative Sampling 기법을 사용하여 그 연산량을 줄입니다. Hierarchical Softmax는 간단히 설명하자면 단어의 출현빈도를 바탕으로 Huffman 트리를 구축하고, 그 트리의 leaf로 단어를 배치하여, 한 단어의 출현확률을 계산하는데 모든 노드를 추적할 필요 없이, 트리구조를 따르며 경로 상의 노드만 계산할 수 있도록 구조를 개량한 것입니다.

Negative Sampling의 경우는 모든 노드를 계산하는 대신, 문맥에 등장하는 단어 전부(positive sample) + 출현확률에 비례해 문맥에 출현하지 않는 단어 중 일부를 추출하여(negative sample) 이를 학습에 쓰는 것입니다. 이에 대해서는 https://shuuki4.wordpress.com/2016/01/27/word2vec-%EA%B4%80%EB%A0%A8-%EC%9D%B4%EB%A1%A0-%EC%A0%95%EB%A6%AC/ 블로그에 잘 설명되어 있으니 참고하시면 되겠습니다.



둘을 합쳐 AdaGram

AdaGram에서는 위의 네트워크 구조에서 입력 쪽을 수정하여 다양한 의미를 반영할 수 있게 했습니다.

각 단어가 최대 2가지의 의미를 가질수 있다고 세팅하면 왼쪽과 같이 입력층의 노드를 2배로 설정해줍니다. 출력층은 그대로 두고요. 여기서 "eat apple drink"라는 문장을 학습할 경우에는 먼저 이 맥락에 속한 apple이 apple1에 가까울지 apple2에 가까울지 그 확률을 계산한 다음 그 확률에 비례해 (apple1, eat), (apple1, drink), (apple2, eat), (apple2, drink)를 학습합니다. 그리고 다시 apple의 apple1, apple2 분포 확률을 업데이트하구요. 복잡한 수식은 설명하지 않도록 하겠습니다. 해당 논문과 코드에 잘 드러나므로 굳이 여기서 다시 설명할 필요도 없을것 같구요!


원래 디리클레 프로세스 상에서는 무한한 개수의 빼빼로 조각을 다룰 수 있습니다만, 실제 계산 상에서는 메모리와 속도의 한계로 그런 무한한 빼빼로 조각을 허용해줄 수 없습니다. 따라서 보통 최대 T개의 조각을 허용하겠다고 maximum을 정해두고, 또한 각 조각의 크기가 threshold보다 작으면 이 조각은 무시하는 것으로 문턱값도 설정합니다.


AdaGram의 학습 과정은 다음과 같습니다. 


  1. 학습을 진행할 단어 w와 그 문맥 c_1, c_2, ... c_n을 코퍼스에서 가져온다.
  2. 문맥 c_1 ~ c_n을 바탕으로 현재 단어 w가 의미1 ~ 의미T에 속할 확률을 계산한다. (단어 w가 의미t에 속할 확률을 s_t라고 정의)
  3. s_t가 threshold보단 큰 경우에만 대하여(그보다 작은 경우는 무시)
    단어 w의 t번째 의미인 w_t에 대해 (w_t, c_1), (w_t, c_2) ... (w_t, c_n)을 학습하여 weight을 업데이트
  4. s_1 ~ s_t를 바탕으로 단어 w의 의미별 분포를 업데이트
  5. 1로 돌아가 학습 반복

2번과 4번이 AdaGram에서 추가된 부분입니다. 처음에는 모든 단어가 각자 하나씩의 의미만을 가지고 시작하지만, 학습을 반복하면서 어떤 단어 w가 기존의 맥락과 맞지 않는 새로운 맥락에서 등장하게 될 경우 이를 새로운 의미에 할당하게 됩니다. 이를 계속 반복하면서 결국 가중치 값들은 수렴하고 되구요.


나도 실험을 해보자

도입부에 설명했듯이 원 코드가 Julia로 써져 있어서 돌려보기가 어려운 관계로 빠르게 Julia를 독학하고 Documentation을 펼쳐놓고 코드를 C++로 옮겼습니다. (다행히도 Julia가 직관적으로 쉽게 만들어진 언어라서 어려운 작업을 아니었습니다.) https://github.com/bab2min/AdaGram-Cpp 에 C++로 포팅된 코드를 공유해드리니 필요하신 분들은 사용하시면 되겠습니다.



처음에 C++로 옮겼을때는 그 성능이 Julia로 짜여진것과 별 차이가 없었는데요 (초당 7,060단어 처리) C++의 Eigen 라이브러리로 최적화를 실시하고, SSE2 옵션을 켜니 약 3배 (초당 30,110단어 처리) 향상되었고, AVX까지 켤 경우 4~5배 (초당 45,000단어 처리) 빨라지는 것을 확인했습니다! 오예! (Eigen 라이브러리의 최적화가 아주 좋네요.) 아직 멀티스레딩 부분을 제대로 개발하지 않아서, 병목이 걸리는 관계로 멀티코어에서의 성능 향상은 거의 없습니다만, 이 멀티스레딩도 구현한다면 10배 이상의 속도를 낼 수 있을 것으로 예상됩니다.


먼저 영어 텍스트를 가지고 실험을 돌려본 결과입니다. 위키백과 영문 판의 임의 문서 3000개를 가지고 M = 100 (단어의 벡터 차원수), T = 5 (한 단어가 가질수 있는 의미 최대 개수), alpha = 0.1 (디리클레 프로세스의 하이퍼 파라미터)로 돌렸습니다. 다음의 표들은 단어의 각 의미에 대해서 코사인 유사도가 가장 높은 상위 15개 단어를 추려서 보여주고 있습니다.

appl
의미 0 0.630 의미 1 0.370
macintosh 0 0.873 cooki 0 0.601
wozniak 0 0.814 pomegran 0 0.587
iig 0 0.807 peach 0 0.570
iie 0 0.804 cider 0 0.551
iic 0 0.797 pepsi 0 0.545
beo 0 0.787 candi 0 0.541
messagepad 0 0.776 woodi 0 0.539
amiga 0 0.769 grape 0 0.531
iphon 0 0.758 sweet 0 0.530
desktop 0 0.758 sneak 0 0.528
visicalc 0 0.756 fruit 0 0.527
microarchitectur 0 0.756 bundi 0 0.522
amstrad 0 0.755 cocktail 0 0.520
powerpc 0 0.755 stout 0 0.520
chipset 0 0.754 pear 0 0.519


stemming 후 돌린거라 단어 어미가 조금 잘리긴 했지만 단어 알아보는데에는 어려움이 없으시죠? 먼저 Apple이라는 단어를 보니 AdaGram이 총 2가지의 의미를 발견했습니다. 의미 0은 약 0.630의 비중을 가지고 있고, 의미 1은 약 0.370의 비중을 가지고 있었대요. 의미 0의 경우 MacIntosh, Wozniak, iPhone등의 단어가 나오는걸로 봐서 기업 Apple을 가리킨다는 것을 알 수 있죠. 의미 1은 cookie, peach, cider 등의 단어로 보건대 먹는 사과를 가리킨다는 것을 알수가 있구요.


tree
의미 0 0.489 의미 1 0.469
b-tree 0 0.850 shrub 0 0.762
avl 0 0.846 grass 0 0.741
node 0 0.832 evergreen 0 0.739
delet 0 0.810 conif 0 0.714
search 0 0.802 pine 0 0.713
rebalanc 0 0.762 mimosa 0 0.701
subtre 0 0.755 woodland 0 0.697
binari 0 0.748 vine 0 0.691
insert 0 0.739 pinu 0 0.689
leftmost 0 0.720 acacia 0 0.689
root 0 0.718 oak 0 0.689
rb 0 0.717 foliag 0 0.687
unbalanc 0 0.691 fern 0 0.678
lookup 0 0.654 quercu 0 0.668
recurs 0 0.654 edibl 0 0.664


tree도 2가지 의미가 있다고 하네요. 의미 0의 경우 컴퓨터 과학에서 말하는 트리, 의미 1은 식물 나무를 가리키고 있습니다.


base
의미 0 0.290 의미 1 0.320 의미 2 0.391
batter 0 0.779 step-by-step 0 0.773 oper 0 0.616
bat 0 0.776 gerald 1 0.743 classifi 1 0.615
hit 0 0.765 modifi 0 0.737 aviat 0 0.607
at-bat 0 0.748 higher-ord 0 0.730 depend 1 0.599
infield 0 0.742 non-standard 0 0.729 system 1 0.595
rbi 0 0.741 combin 0 0.729 air 0 0.593
catcher 0 0.740 simpl 0 0.727 brest 0 0.590
stolen 0 0.728 essenti 0 0.725 reorganis 0 0.589
score 0 0.724 known 1 0.720 catalyst 1 0.584
hitter 0 0.718 approach 0 0.714 ron 1 0.581
fielder 0 0.714 franz 1 0.712 constabulari 0 0.578
scorer 0 0.713 exampl 0 0.712 cr 1 0.577
pitcher 0 0.701 relat 0 0.712 third-largest 0 0.573
roquet 0 0.692 term 1 0.711 unit 0 0.569
single-season 0 0.688 simplest 0 0.710 addit 1 0.568


항상 사람이 생각하는것처럼 깔끔하게 결과가 나오지는 않더라구요. base의 경우 의미 0은 야구에 관한 단어들이라는 걸 확실히 알겠는데, 의미 1과 2는 정확하게 어떤 걸 가리키는지 쉽게 감이 잡히지는 않습니다.


한국어에 대해서도 돌려봐야겠죠? 세종 말뭉치 중 문어체 텍스트만 가지고, 어미, 조사, 특수 기호 등은 제거한 뒤 AdaGram을 돌려보았습니다. (M = 300, T = 5, alpha = 0.1)


열/VV
의미 0 0.466 의미 1 0.534
개최/NNG 0 0.601 닫/VV 0 0.758
열리/VV 0 0.565 열어젖히/VV 0 0.647
창립/NNG 0 0.562 닫히/VV 0 0.637
심포지엄/NNG 0 0.548 잠그/VV 0 0.614
문학자/NNG 0 0.503 쾅/MAG 0 0.576
심포지움/NNG 0 0.491 드르륵/MAG 0 0.573
주역/NNG 0 0.484 열리/VV 1 0.567
기념/NNG 0 0.483 덧문/NNG 0 0.561
토론회/NNG 0 0.479 여닫/VV 0 0.554
제천/NNG 0 0.477 벌컥/MAG 0 0.548
학술회의/NNG 0 0.476 노크/NNG 0 0.546
세미나/NNG 0 0.474 찰칵/MAG 0 0.533
전당/NNG 0 0.472 문틈/NNG 0 0.532
학술/NNG 0 0.469 유리문/NNG 0 0.530
융성/NNG 0 0.465 문/NNG 0 0.522
공간/NNG
의미 0 0.648 의미 1 0.352
리이만/NNP 0 0.680 후면/NNG 0 0.693
존현/NNG 0 0.636 존현/NNG 0 0.670
강학/NNG 0 0.595 강학/NNG 0 0.655
유클리드/NNP 0 0.529 누정/NNG 0 0.642
주체자/NNG 0 0.479 안채/NNG 0 0.641
여백/NNG 0 0.478 남향/NNG 0 0.636
무한히/MAG 0 0.471 행랑채/NNG 0 0.631
위계/NNG 0 0.465 내삼문/NNG 0 0.623
선험/NNG 0 0.461 벽체/NNG 0 0.616
공간/NNG 1 0.455 회랑/NNG 0 0.611
유한/NNG 0 0.452 안마당/NNG 0 0.607
생활공간/NNG 0 0.452 우물마루/NNG 0 0.607
개조/NNG 0 0.450 아담/XR 0 0.606
다차원/NNG 0 0.448 달아내/VV 0 0.605
협소/XR 0 0.446 사랑채/NNG 0 0.604
이상/NNG
의미 0 0.562 의미 1 0.438
퍼센트/NNG 0 0.679 일층/MAG 0 0.581
밀리그램/NNB 0 0.651 상대주의/NNG 0 0.578
약/MM 0 0.615 유토피아/NNG 0 0.577
그램/NNB 0 0.607 선험/NNG 0 0.574
이하/NNG 0 0.605 상통/NNG 0 0.566
미만/NNG 0 0.590 기대/VV 1 0.558
비만/NNG 0 0.589 구극/NNG 0 0.556
발병/NNG 0 0.578 한갓/MAG 0 0.553
평균/NNG 0 0.566 순전/XR 0 0.551
폐경/NNG 0 0.564 콩트/NNP 0 0.549
절반/NNG 0 0.555 소산/NNG 0 0.540
당뇨병/NNG 0 0.551 심미/NNG 0 0.540
이환/NNG 0 0.545 원초/NNG 0 0.539
섭취량/NNG 0 0.545 과학주의/NNG 0 0.532
뇌종양/NNG 0 0.542 즉물/NNG 0 0.531
전기/NNG
의미 0 0.363 의미 1 0.636
회로/NNG 0 0.638 후기/NNG 0 0.714
기기/NNG 0 0.591 통일신라/NNP 0 0.650
콘덴서/NNG 0 0.585 하타다/NNP 0 0.646
연소/NNG 0 0.582 이행기/NNG 0 0.630
금속/NNG 0 0.580 중세/NNG 0 0.625
연접/NNG 0 0.576 구석기/NNG 0 0.619
전극/NNG 0 0.573 고려/NNP 0 0.618
방전/NNG 0 0.564 사대부/NNG 0 0.611
수소/NNG 0 0.561 통일신라기/NNG 0 0.607
연료/NNG 0 0.560 고려사상/NNG 0 0.603
변압기/NNG 0 0.559 중기/NNG 0 0.602
직류/NNG 0 0.559 이규보/NNP 0 0.600
증기/NNG 0 0.557 고려사/NNG 0 0.598
이온/NNG 0 0.555 근세/NNG 0 0.598
스위치/NNG 0 0.549 실학파/NNG 0 0.597


전기(eletronic)와 전기(first-half)처럼 동음이의어를 구분해낸 경우도 있고, 열다(축제를 개최하다)와 열다(문을 열다) 처럼 같은 단어지만 맥락에 따라 다르게 쓰이는 갈래뜻을 구분한 경우도 있네요. 물론 코퍼스의 한계인지, 구분해내지 못한 동음이의어도 많았습니다. 눈, 배, 밤과 같은 단어는 자주 쓰이는 단어 쪽만 학습이 되어 신체 부위 눈, 신체 부위 배, 해가 진 이후의 시간대 밤만 잡아냈고, 다른 동음이의어는 잡지 못했습니다. 이 경우 alpha값을 높임으로써 더 세부적으로 의미 분별이 가능하도록 학습할 수는 있습니다만, 그 경우 또 같은 의미의 단어가 여러 의미로 잘못 갈라지는 경우가 생길 수도 있겠죠... 말뭉치에 따라 최적의 파라미터를 설정하는게 중요할듯 합니다.


AdaGram의 활용처

위의 결과에서 확인하셨다시피 AdaGram은 비지도학습 기반으로 텍스트 데이터에서 단어의 세부의미를 분별해내는 데 강점을 보이고 있습니다. 각 단어의 의미별 벡터값과 그 비중이 주어지기 때문에, 거꾸로 새로운 텍스트를 주면 거기에 포함된 각 단어들이 어떤 의미에 속하는지도 계산이 가능하구요. 논문에 따르면 Word Sense Induction 작업에 있어서 기존까지 제시된 기법 중 가장 높은 성능을 보이고 있다고 합니다. 말 그대로 Word2vec의 막강함에 단어 의미 변별 기능이 추가되어 더욱 막강한 기법이 된것이라고 할 수 있겠습니다.


다만 한계 역시 존재합니다. 단어 분포만을 가지고 의미를 학습해내는 것이기 때문에 입력되는 데이터가 편향되어 있을 경우 잘못된 의미 분포를 학습하게 된다는 (어찌보면 당연한) 문제가 있구요, 또한 사람이 인지하는 (사전적) 의미와 컴퓨터가 학습해낸 의미 간의 괴리가 존재할 수 있다는 점이 있습니다. 때에 따라서는 사전적인 의미에 따라 의미를 나누고 싶을 수 있는데(Word Sense Disambiguation), 이런 경우에는 아직 적용이 불가하다는 한계가 있습니다.


하지만 역으로 생각해보면, 인간이 사전을 편찬해 낼때 그 단어의 용례를 직접 찾아보며 의미를 정의하는데, 이 과정에 AdaGram이 큰 도움이 될 수도 있습니다. 방대한 텍스트 자료를 자동으로 학습하여 해당 단어가 가지고 있는 의미를 보여줌으로써 사람이 놓칠 가능성이 있는 의미를 잡아주거나, 그 용례를 제시하는데 쓰일 수도 있을듯합니다.



이 댓글을 비밀 댓글로