Loading [MathJax]/jax/output/CommonHTML/jax.js

상세 컨텐츠

본문 제목

[Kiwi] 문장 같은 고유명사 잘 추출해내기

프로그래밍/NLP

by ∫2tdt=t²+c 2022. 3. 20. 21:58

본문

고유명사 처리의 어려움

형태소 분석을 진행할 때 어려운 부분 중 하나는 고유명사(NNP) 처리입니다. 나머지 품사의 경우는 말뭉치를 잘 구축해두면 그 안에서 어지간한 패턴은 다 등장합니다만, 고유명사의 경우 그 특성상 끊임없이 새로 생성되기 때문에 아무리 말뭉치를 잘 구축해둬도 시간이 조금만 흐르면 새로 등장한 고유명사는 다 놓치게 됩니다. 그래서 대부분의 형태소 분석기는 사용자가 직접 사전 내에 새로운 단어를 삽입하여 이런 문제를 완화하고자 하지요.

새로 추가된 고유명사는 해당 문자열이 오분석되는 것을 막기 위해 대개 일반 분석 결과보다 더 높은 우선순위를 가지게 됩니다. 즉, 기존의 분석 결과를 새로 추가된 고유명사가 덮어쓴다고 할까요.

입력 분석결과
도전무한지식 도전/NNG 무한/NNG 지식/NNG
도전무한지식을 봤다 도전/NNG 무한/NNG 지식/NNG 을/JKO 보/VV 았/EP 다/EF
사전에 `도전무한지식/NNP`을 삽입 후
도전무한지식 도전무한지식/NNP
도전무한지식 도전무한지식/NNP 을/JKO 보/VV 았/EP 다/EF

위의 예시에서 `도전무한지식`이라는 형태는 항상 `도전무한지식/NNP`으로 분석되게 되는데, 대부분의 경우 이렇게 기존 분석 결과를 새 고유명사가 덮어써도 큰 문제가 없습니다. 문제는 일부 고유명사는 고유명사인데 평범한 문장처럼 생기기도 해서 기계적으로 기존 분석 결과를 덮어쓰면 오분석이 크게 생긴다는 거죠. 예를 들어 MBC에서 방영된 드라마 <누구세요?>가 있겠습니다. 드라마 이름을 가리키는 '누구세요?'는 고유명사로 처리되어야 하지만, 일반적인 문장 내에세너느 고유 명사로 처리가 되면 안되는데요, 사전에 고유명사를 등록하면 부작용이 발생하게 됩니다.

입력 분석결과
당신 누구세요? 당신/NP 누구/NP 이/VCP 시/EP 어요/EF ?/SF
누구세요?는 2008년에 방영된 드라마다 누구/NP 이/VCP 시/EP 어요/EF ?/SF 이/VCP 는/ETM 2008/SN 년/NNB 에/JKB 방영/NNG 되/XSV ᆫ/ETM 드라마/NNG 하/XSA 다/EF
사전에 `누구세요?/NNP`를 삽입 후
당신 누구세요? 당신/NP 누구세요?/NNP
누구세요?는 2008년에 방영된 드라마다 누구세요?/NNP 이/VCP 는/ETM 2008/SN 년/NNB 에/JKB 방영/NNG 되/XSV ᆫ/ETM 드라마/NNG 하/XSA 다/EF

'당신 누구세요?'와 같은 문장에서도 `누구세요?`가 고유명사로 처리되는 문제가 발생했습니다. 고유명사를 사전에 등록하다 보면, 이와 같이 하나를 간신히 더 맞히지만, 다른 하나는 더 틀리게 되어 전체 분석 정확도는 결국 개선하지 못하게 되는 경우가 많습니다. (운이 나쁘면 오히려 분석 정확도가 더 떨어질 수도 있죠.) 그래서 사전에 고유명사를 등록하기에 앞서 이 단어를 등록하는게 어떤 결과를 가져올지 득과 실을 따져보는게 중요합니다. 매우 복잡한 문제지요...

언어모델 점수를 활용해보자

다행히도 이를 약간 우회할 수 있는 방법이 있습니다. 품사에 대한 정보를 학습한 언어모델의 경우 특정 위치에 어떤 품사가 등장할 확률이 높은지를 계산해낼 수 있습니다. 그래서 주변 문맥을 살펴보고 이 위치가 명사가 등장할만한 위치인지 아닌지 판단해서 더 적절한 선택을 할 수 있는거죠. 이를 위해서 다음 3가지 템플릿을 고안해봤습니다.

템플릿
__NNP__
바로 __NNP__가 그것이다.
그래서 __NNP__를 했다.

Kiwi에 위의 템플릿 문장을 입력하여 분석 점수를 구해보았습니다. 여기서 __NNP__는 빈자리를 채우기 위해서 넣은 녀석이구요, 알려진게 없는 새로운 단어로 보시면 되겠습니다. 이제 __NNP__에 위의 고유명사 단어들을 넣어서 분석 결과를 살펴보겠습니다.

템플릿 Kiwi 언어모델 점수 템플릿 Kiwi 언어모델 점수
도전무한지식 -43.45 누구세요? -14.09
바로 도전무한지식이 그것이다. -53.75 바로 누구세요?가 그것이다. -55.26
그래서 도전무한지식을 했다. -46.76 그래서 누구세요?를 했다. -47.99

`도전무한지식`과 `누구세요?`를 빈 자리에 넣어보면, "바로 __NNP__가 그것이다."와 "그래서 __NNP__를 했다." 템플릿에서는 두 고유명사 사이에 점수 차이가 거의 없습니다. 반면 "__NNP__"만 있는 템플릿에 넣은 경우에는 `누구세요?`의 점수가 압도적으로 큰 것을 볼 수 있습니다. 언어모델의 점수는 쉽게 말하자면 해당 단어열(혹은 문장)이 등장할 가능성인데요, 위의 결과에서 볼 수 있듯이 "도전무한지식"이라는 단어열이 단독으로 등장할 가능성은 굉장히 낮지만, "누구세요?"라는 단어열이 단독으로 등장할 가능성은 꽤 높다는 거죠. 따라서 `누구세요?`를 사전에 함부로 고유명사로 등재하면 오분석이 날 가능성이 크다는걸 미리 알 수 있습니다.

고유명사에 적절한 점수를 부여해 문제 해결하기

이를 응용해 Kiwi에서는 각 고유명사에 적절한 점수를 부여함으로써 이 문제를 어느 정도 해결하는 방법을 제공합니다. Kiwi에서는 사전에 사용자 단어를 등재할 때 그 단어의 점수를 부여할 수 있습니다. 단어점수는 해당 단어가 등장할때마다 언어모델 점수에 더해지는 값이라고 보면 되는데, 기본값은 0으로 그 단어가 아무리 등장해도 패널티를 제공하지 않습니다.

템플릿 Kiwi 언어모델 점수
__NNP__ -12.46
바로 __NNP__가 그것이다. -27.36
그래서 __NNP__를 했다. -25.67

위는 __NNP__에 0점을 부여한 경우 Kiwi 분석 결과의 언어모델 점수입니다. 만약 __NNP__에 -5점을 부여한다면 최종 결과값은 아래처럼 -5씩 더해져서 더 작은 값이 되지요.

템플릿 Kiwi 언어모델 점수
__NNP__ -12.46 - 5 = -17.67
바로 __NNP__가 그것이다. -27.36 - 5 = -32.36
그래서 __NNP__를 했다. -25.67 - 5 = -30.67

Kiwi는 최종 형태소 분석 결과가 여럿일 경우 그 중에서 언어모델 점수가 가장 높은 후보를 선택합니다. 따라서 모호성이 발생하는 고유명사라 해도 단어점수를 잘 조절해주면 맥락에 따라 적절한 결과가 나오도록 유도할 수 있습니다. 위의 `누구세요?` 사례를 다시 가져와서, `누구세요?`를 고유명사(NNP)로 사전에 등재할 때 그 단어점수를 x라고 설정했다고 해봅시다. 그럼 분석 결과는 아래와 같이 될겁니다.

입력 후보 Kiwi 언어모델 점수 후보 Kiwi 언어모델 점수
누구세요? 누구세요?/NNP -12.46 + x 누구/NP 이/VCP 시/EP 어요/EF ?/SF -14.09
바로 누구세요?가 그것이다. 바로/MAG 누구세요?/NNP 가/JKS 그것/NP 이/JKS 다/EF ./SF -27.36 + x 바로/MAG 누구/NP 이/VCP 시/EP 어요/EF ?/SF 가/VV 어/EC 그것/NP 이/JKS 다/EF ./SF -55.26
그래서 누구세요?를 했다. 그래서/MAJ 누구세요?/NNP 를/JKO 하/VV 었/EP 다/EF ./SF -25.67 + x 그래서/MAJ 누구/NP 이/VCP 시/EP 어요/EF ?/SF 를/JKO 하/VV 었/EP 다/EF ./SF -47.99

`누구세요?`가 고유명사로 분석되는 경우는 왼쪽, 그렇지 않은 경우는 오른쪽에 표시해봤습니다. 이 중 최종 분석 결과로 선택되길 원하는 쪽을 굵게 표시해두었구요. `누구세요?`의 단어 점수 x의 값에 따라 왼쪽과 오른쪽 결과 중 하나가 선택됩니다. 원하는 쪽이 선택되려면 그 쪽의 언어모델 점수가 더 높아야하므로 다음과 같이 간단한 부등식을 세워볼 수 있겠죠?

12.46+x<14.09x<1.63

27.36+x>55.26x>27.9

25.67+x>47.99x>22.32

최종적으로 x22.32<x<1.63 범위 내에 있으면 우리가 원하는 결과를 얻을 수 있다는 것을 알 수 있습니다. 지금은 긍정사례(고유명사로 분석되어야하는 예시) 2개와 부정사례(고유명사로 분석되지 말아야하는 예시) 1개만 가지고 범위를 산정을 했지만 더 많은 템플릿을 이용하면 좀 더 정확한 범위를 구해낼 수 있다는 것은 두말할 필요도 없습니다.

Kiwi 0.11.0에 내장된 사전에서는 위와 같은 방법을 이용해 고유명사의 점수를 새로 산정했습니다. 실제 점수가 어떤식으로 부여됐는지 보고싶으시다면 여기에서 확인할 수 있어요.

Python 코드로 시험해보기

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
from kiwipiepy import Kiwi
kiwi = Kiwi(load_default_dict=False)
# 실험을 위해 기본 사전을 제외하고 로드함
kiwi.add_user_word('__NNP__', 'NNP')
 
# 템플릿의 기본 점수를 확인한다.
print(kiwi.analyze('__NNP__')[0])
# ([Token(form='__NNP__', tag='NNP', start=0, len=7)],
# -12.466018676757812)
 
print(kiwi.analyze('바로 __NNP__가 그것이다.')[0])
# ([Token(form='바로', tag='MAG', start=0, len=2),
# Token(form='__NNP__', tag='NNP', start=3, len=7),
# Token(form='가', tag='JKS', start=10, len=1),
# Token(form='그것', tag='NP', start=12, len=2),
# Token(form='이', tag='VCP', start=14, len=1),
# Token(form='다', tag='EF', start=15, len=1),
# Token(form='.', tag='SF', start=16, len=1)],
# -27.364591598510742)
 
print(kiwi.analyze('그래서 __NNP__를 했다.')[0])
# ([Token(form='그래서', tag='MAJ', start=0, len=3),
# Token(form='__NNP__', tag='NNP', start=4, len=7),
# Token(form='를', tag='JKO', start=11, len=1),
# Token(form='하', tag='VV', start=13, len=1),
# Token(form='었', tag='EP', start=13, len=1),
# Token(form='다', tag='EF', start=14, len=1),
# Token(form='.', tag='SF', start=15, len=1)],
# -25.668298721313477)
 
 
# kiwi가 자동으로 추측하여 미등재어를 출력하지 못하도록
# 미등재 형태소의 패널티를 아주 높게 설정
kiwi._unk_score_scale = 10000
 
# 고유명사가 사전에 미등재된 경우의 분석 점수를 확인한다.
print(kiwi.analyze('누구세요?')[0])
# ([Token(form='누구', tag='NP', start=0, len=2),
# Token(form='이', tag='VCP', start=2, len=0),
# Token(form='시', tag='EP', start=2, len=1),
# Token(form='어요', tag='EF', start=2, len=2),
# Token(form='?', tag='SF', start=4, len=1)],
# -14.093750953674316)
 
print(kiwi.analyze('바로 누구세요?가 그것이다.')[0])
# ([Token(form='바로', tag='MAG', start=0, len=2),
# Token(form='누구', tag='NP', start=3, len=2),
# Token(form='이', tag='VCP', start=5, len=0),
# Token(form='시', tag='EP', start=5, len=1),
# Token(form='어요', tag='EF', start=5, len=2),
# Token(form='?', tag='SF', start=7, len=1),
# Token(form='가', tag='VV', start=8, len=1),
# Token(form='어', tag='EC', start=8, len=1),
# Token(form='그', tag='MM', start=10, len=1),
# Token(form='것', tag='NNB', start=11, len=1),
# Token(form='이', tag='VCP', start=12, len=1),
# Token(form='다', tag='EF', start=13, len=1),
# Token(form='.', tag='SF', start=14, len=1)],
# -55.25975036621094)
 
print(kiwi.analyze('그래서 누구세요?를 했다.')[0])
# ([Token(form='그래서', tag='MAJ', start=0, len=3),
# Token(form='누구', tag='NP', start=4, len=2),
# Token(form='이', tag='VCP', start=6, len=0),
# Token(form='시', tag='EP', start=6, len=1),
# Token(form='어요', tag='EF', start=6, len=2),
# Token(form='?', tag='SF', start=8, len=1),
# Token(form='를', tag='JKO', start=9, len=1),
# Token(form='하', tag='VV', start=11, len=1),
# Token(form='었', tag='EP', start=11, len=1),
# Token(form='다', tag='EF', start=12, len=1),
# Token(form='.', tag='SF', start=13, len=1)],
# -47.99250030517578)
 
# -22.32 < x < -1.63 범위 안의 값으로 `누구세요?`를 등재시키기
kiwi.add_user_word('누구세요?', 'NNP', -20)
 
print(kiwi.analyze('누구세요?')[0])
# ([Token(form='누구', tag='NP', start=0, len=2),
#   Token(form='이', tag='VCP', start=2, len=0),
#   Token(form='시', tag='EP', start=2, len=1),
#   Token(form='어요', tag='EF', start=2, len=2),
#   Token(form='?', tag='SF', start=4, len=1)],
#   -14.093750953674316)
 
print(kiwi.analyze('바로 누구세요?가 그것이다.')[0])
# ([Token(form='바로', tag='MAG', start=0, len=2),
#   Token(form='누구세요?', tag='NNP', start=3, len=5),
#   Token(form='가', tag='JKS', start=8, len=1),
#   Token(form='그것', tag='NP', start=10, len=2),
#   Token(form='이', tag='VCP', start=12, len=1),
#   Token(form='다', tag='EF', start=13, len=1),
#   Token(form='.', tag='SF', start=14, len=1)],
#   -47.36458969116211)
 
print(kiwi.analyze('그래서 누구세요?를 했다.')[0])
# ([Token(form='그래서', tag='MAJ', start=0, len=3),
#   Token(form='누구세요?', tag='NNP', start=4, len=5),
#   Token(form='를', tag='JKO', start=9, len=1),
#   Token(form='하', tag='VV', start=11, len=1),
#   Token(form='었', tag='EP', start=11, len=1),
#   Token(form='다', tag='EF', start=12, len=1),
#   Token(form='.', tag='SF', start=13, len=1)],
#  -45.668296813964844)
 
# 다른 사례에 대해서도 잘 작동하는지 확인해본다
print(kiwi.analyze('당신 누구세요?')[0])
# ([Token(form='당신', tag='NP', start=0, len=2),
#   Token(form='누구', tag='NP', start=3, len=2),
#   Token(form='이', tag='VCP', start=5, len=0),
#   Token(form='시', tag='EP', start=5, len=1),
#   Token(form='어요', tag='EF', start=5, len=2),
#   Token(form='?', tag='SF', start=7, len=1)],
#  -24.068819046020508)
 
print(kiwi.analyze('누구세요?는 2008년에 방영된 드라마이다')[0])
# ([Token(form='누구세요?', tag='NNP', start=0, len=5),
#   Token(form='는', tag='JX', start=5, len=1),
#   Token(form='2008', tag='SN', start=7, len=4),
#   Token(form='년', tag='NNB', start=11, len=1),
#   Token(form='에', tag='JKB', start=12, len=1),
#   Token(form='방영', tag='NNG', start=14, len=2),
#   Token(form='되', tag='XSV', start=16, len=1),
#   Token(form='ᆫ', tag='ETM', start=16, len=1),
#   Token(form='드라마', tag='NNG', start=18, len=3),
#   Token(form='이', tag='VCP', start=21, len=1),
#   Token(form='다', tag='EF', start=22, len=1)],
#   -68.0195083618164)

위와 같이 "누구세요?"를 사전에 등록한 경우 "당신 누구세요?"와 "누구세요?는 2008년 방영된 드라마이다"에서도 잘 작동하는걸 볼 수 있습니다. 

자동화가 가능할까?

이 방법을 사용하려면 긍정사례와 부정사례를 사람이 직접 선정해주는 게 필요합니다. 위에서는 등록할 단어가 고유명사라는 점을 감안해 명사가 들어갈 법한 템플릿 문장을 사용했지요. 사용자가 이 템플릿을 생성하는게 가장 정확한 방법이겠지만 이게 번거롭다면 등록하려는 형태소의 품사를 이용해 해당 품사가 주로 등장하는 문장 패턴을 말뭉치에서 찾아주는것도 한 가지 방법이 될 수 있겠네요.

가능하면 자동화하여 Kiwi의 차기 업데이트에 최적의 단어 점수를 추정해주는 기능을 추가해보려고 현재 노력 중입니다. 많은 기대 부탁드려요~

관련글 더보기

댓글 영역