2.1 자연어처리란
한국어와 영어 등 평소에 쓰는 말을 자연어(natural language)라고 한다. 자연어 처리(natural language processing)를 문자 그대로 해석하면 '자연어를 처리하는 분야'이고, 쉽게 말해 '우리의 말을 컴퓨터에게 이해시키는 기술이자 분야'이다.
우리의 말은 일상생활에서도 느끼지만, 의미나 형태가 유연하게 바뀔수도 있고 때론 같은 의미의 문장도 여러 형태로 표현되며 세월이 흐르면서 새로운 말이나 새로운 의미가 생겨나거나 사라질 수 있다. 이렇게 언어라는 것이 모호하고 중의적이기 때문에 컴퓨터에게 자연어를 이해시킨다는 것은 매우 어려운 일이기 때문에, 이러한 일들을 할 수 있다면 수많은 사람에게 도움을 줄 수 있다.
2.1.1 단어의 의미
우리의 말은 '문자'로 구성되며, 말의 의미는 '단어'로 구성된다. 즉 단어는 의미의 최소 단위이기 대문에, 이 단어의 의미를 컴퓨터에게 이해시키는 것이 중요하다. 이러한 방법은 시소러스(유의어 사전)를 활용한 기법, 통계 정보로부터 단어를 표현하는 통계 기반 기법, 신경망을 활용한 추론 기반 기법(word2vec) 총 3가지가 있다.
2.2 시소러스(유의어 사전)
'단어의 의미'를 나타내는 방법으로는 먼저 사람이 직접 단어의 의미를 정의하는 방식을 생각할 수 있다. 국어사전처럼 '자동차'라는 단어를 찾으면 '원동기를 장치하여 그 동력으로 바퀴를 굴려서 철길이나 가설된 서넹 의하지 아니하고 땅 위를 움직이도록 만든 차'라는 설명이 나온다. 이런 식으로 단어들을 정의해두면 컴퓨터도 단어의 의미를 이해할 수 있기 때문에 시소러스(유의사전)라는 뜻이 같은 단어나 뜻이 비슷한 단어를 한 그룹으로 분류한다.
또한 자연어 처리에 이용되는 시소러스는 단어 사이의 '상위와 하위' 혹은 '전체와 부분' 등 더 세세한 관계까지 정의해둔 경우가 있다.
이처럼 모든 단어에 대한 유의어 집합을 만들어, 단어들의 관계를 그래프로 표현하여 단어 사이의 연결을 정의할 수 있다. 이러한 '단어 네트워크'를 이용하여 컴퓨터에게 단어 사이의 관계를 가르칠 수 있는 것이다.
2.2.1 WordNet
자연어 처리 분야에서 가장 유명한 시소러스는 Wordnet으로, 프린스턴 대학교에서 1985년부터 구축하기 시작한 전통적인 시소러스이다. WordNet을 사용하면 유의어를 얻거나 '단어 네트워크'를 이용, 또는 단어 사이의 유사도를 구할 수 있다.
2.2.2 시소러스의 문제점
하지만 WordNet같은 시소러스에는 문제점이 있는데, 사람이 수작업으로 레이블링하기 때문에 새로운 단어가 생겨나고 의미가 변할 때 이를 반영할 수 없다. 또한 사람을 쓰는 비용이 매우 커 비효율적이며, 단어의 미묘한 차이들을 표현할 수 없다는 단점이 있다. 이를 해결하기 위해 '통계 기반 기법'과 '추론 기반 기법'이 나왔고, 이 기법들은 대량의 텍스트 데이터로부터 '단어의 의미'를 자동으로 추출한다.
2.3 통계 기반 기법
통계 기반 기법을 살펴보기 전에 말뭉치(corpus)에 대해 알아보자. 말뭉치는 간단히 말하자면 대량의 텍스트 데이터를 뜻하며, 맹목적으로 수집된 텍스트 데이터가 아닌 연구나 애플리케이션을 염두에 두고 수집된 데이터를 '말뭉치'라고 부른다. 문장을 쓰는 방법, 단어를 선택하는 방법, 단어의 의미 등 사람이 알고 있는 자연어에 대한 지식이 포함되어 있으며, 예컨대 '품사'가 레이블링 되어있을 수도 있다.
2.3.1 파이썬으로 말뭉치 전처리하기
자연어처리에는 위키백과나 구글 뉴스등의 텍스트 데이터들이 다양한 말뭉치로 사용된다. 또한 셰익스피어 같은 작품들도 말뭉치로 이용될 수 있다. 여기서는 문장 하나로 이루어진 단순한 텍스트를 사용하며, 텍스트 데이터를 단어로 분할하고 분할된 단어들을 단어 ID목록으로 변환하는 전처리 방법을 코드로 설명한다.
가장 먼저 lower를 통해 소문자로 변환하고, split(' ') 메서드를 호출해 공백을 기준으로 분할한다. 여기선 문장 끝의 마침표(.)도 고려해 마침표 앞에 공백을 삽입하기 위해 분할을 수행했다. 하지만 단어를 텍스트 그대로 조작하기란 여러 면에서 불편하기 때문에, 단어에 ID를 부여하고 ID의 리스트로 이용할 수 있도록 Python의 dictionary 구조를 이용하여 단어 ID와 단어를 짝지어준다.
단어 ID에서 단어로의 변환은 id_to_word가 담당하고, 단어에서 단어 ID로의 변환은 word_to_id가 담당한다. 이렇게 dictionary 형태의 대응표를 가지게 된다면, 말뭉치를 다룰 준비를 마쳤고 말뭉치를 사용해 '단어의 의미'를 벡터로 표현할 수 있게 된다.
2.3.2 단어의 분산 표현
단어의 분산 표현을 소개하기 앞서 색으로 비유를 들어보자. 색들에는 '코발트블루'나 '싱크레드'같은 고유한 이름을 붙일 수 있고, RGB라는 세가지 성분의 비율로 표현할 수 있다. 전자는 색에 이름(id)을 부여하고, 후자는 색에 3차원 벡터로 표현하는 것이다. 이처럼 색을 벡터로 표현하듯 단어도 벡터로 표현할 수 있다. 이를 자연어 처리 분야에서 단어의 분산 표현이라고 하며, 고정 길의 밀집벡터 ex - [0.21, -0.45, 0.83과 같은 모습을 가지게 된다.
2.3.3 분포 가설
자연어 처리에서 단어를 벡터로 표현하는 연구가 수없이 이뤄져 왔는데, 핵심적인 아이디어는 바로 '단어의 의미는 주변 단어에 의해 형성된다'라는 것이다. 이를 분포 가설(distributed hypothesis)라고 하며, 단어 자체에는 의미가 없고 그 단어가 사용된 맥락(context)이 의미를 형성한다는 것이다. 물론 의미가 같은 단어들은 같은 맥락에서 더 많이 등장할 것이고, i drink beer, i guzzle beer와 같이 같은 맥락에서 사용되면 가까운 의미의 단어라는 것을 알 수 있다. 즉 '맥락'이라는 것은 주변에 놓인 단어를 가리키고, 윈도우 크기가 2라고 했을 대 좌우 각 두 단어씩 '맥락'에 해당된다.
2.3.4 동시발생 행렬
분포 가설에 기초해 단어를 벡터로 나타내는 방법을 생각해보자면, 주변 단어를 '세어보는'방법이 자연스럽게 떠오른다. 주변에 어떤 단어가 몇 번이나 등장하는지 세어 집계하는 방법으로, 이를 책에서는 '통계 기반 기법'이라고 말한다.
위에서 만든 말뭉치를 통해 단어 수가 총 7개임을 알 수 있다. 다음 각 단어의 맥락에 해당하는 단어 빈도를 윈도우 크기 1로 지정하고 세어 보자면 you의 맥락은 say 하나 뿐이다.
이를 그림으로 나타내자면 아래와 같이 되고
이는 you의 맥락으로써 동시에 발생(등장)하는 단어의 빈도를 나타낸 것이다. 즉 you라는 단어를 [0,1,0,0,0,0,0]이라는 벡터로 표현할 수 있다. 다른 단어에 대해서도 계속 수행하자면
위와 같이 모든 단어의 각각의 맥락에 해당하는 단어 빈도를 표를 통해 나타낼 수 있고, 이를 행렬의 형태를 띈다는 뜻에서 동시발생 행렬(co-occurrence matrix)라고 한다. 이를 말뭉치로부터 동시발생 행렬을 만들어주는 함수로 구현하자면
위와 같이 나타낼 수 있다. 파라미터는 차례대로 corpus, vocab_size, window_size를 지정하고 먼저 co_matrix를 0으로 채워진 2차원 배열로 초기화한다. 그 후 말뭉치의 모든 단어 각각에 대하여 윈도우에 포함된 주변 단어를 세어나간다. 말뭉치가 아무리 커지더라도 자동으로 동시발생 행렬을 만들어주는 장점이 있다.
2.3.5 벡터 간 유사도
앞에서 동시발생 행렬을 활용해 단어를 벡터로 표현하는 방법에 대해 알아봤다. 그럼 계속해서 벡터 사이의 유사도를 측정하는 방법을 알아보자면, 대표적으로 벡터의 내적이나 유클리드 거리 등을 꼽을 수 있다. 그 외에도 다양하지만, 단어 벡터의 유사도를 나타낼 때는 코사인 유사도(cosine similarity)를 자주 이용한다. 코사인 유사도는 다음 식으로 정의된다.
두 벡터 x = (x1, x2, x3, ...), y = (y1, y2, y3, ...)가 있다고 가정하자면 분자에는 벡터의 내적이, 분모에는 각 벡터의 노름(norm)이 등장한다. 노름은 벡터의 크기를 나타낸 것으로, 여기서는 L2 노름(벡터의 각 원소를 제곱해 더한 후 다시 제곱급을 구하는 방식)을 계산한다. 이 식의 핵심은 벡터를 정규화하고 내적을 구하는 것으로, 직관적으로 설명하자면 '두 벡터가 가리키는 방향이 얼마나 비슷한가'를 뜻한다. 두 벡터의 방향이 완전히 같다면 코사인 유사도가 1이 되며, 완전히 반대라면 -1이 된다. 이 식을 파이썬으로 구현하자면
위와 같이 나타낼 수 있고, 식 그대로 코드를 구현하면 분모가 0이 되어 0으로 나누는 오류가 발생한다. 이를 해결하기 위해 전통적으로 eps이라는 매우 작은 수를 더해 문제점을 개선한다.
위에서 구한 식을 바탕으로 you의 단어 벡터와 i의 단어 벡터를 구해 코사인 유사도를 구하자면 0.7이 나오고 이 값은 비교적 높다(유사성이 높다)라고 말할 수 있다.
2.3.6 유사 단어의 랭킹 표시
코사인 유사도까지 구현했으니, 다른 유용한 기능을 구해보자. 어떤 단어가 검색어로 주어지면, 그 검색어와 비슷한 단어를 유사도 순으로 출력하는 함수를 python으로 구현해보자면
위와 같은 코드로 구현할 수 있다. 먼저 (1)검색어의 단어 벡터를 꺼내고, (2)검색어의 단어 벡터와 다른 모든 단어 벡터와의 코사인 유사도를 각각 구한다. (3)그 후 계산한 코사인 유사도 결과를 기준으로, 값이 높은 순서대로 출력한다. 3에서 쓰인 argsort()는 배열의 원소를 오름차순으로 정렬하고 반환값은 배열의 인덱스이다.
직접 you라는 단어의 유사 단어를 5개 출력해보았다. 차례로 goodbye, i, hello로 goobye가 코사인 유사도가 높다는 것에 의문을 가질 수 있다. 이는 말뭉치 크기가 너무 작다는 것이 원인으로, 후에 더 큰 말뭉치를 사용하여 실험해보자.
2.4 통계 기반 기법 개선하기
2.4.1 상호정보량
앞 절에서 본 동시발생 행렬의 원소는 두 단어가 동시에 발생한 횟수를 나타낸다. 그러나 이 '발생' 횟수라는 것은 사실 그리 좋은 특징이 아니다. the car... 처럼 car와 drive가 연관이 높으나 '발생 횟수'로 따지면 the가 연관성이 높다고 평가되기 때문이다. 이 문제를 해결하기 위해 점별 상호정보량(Pointwise Mutual Information - PMI)이라는 척도를 사용한다. PMI는 확률 변수 x와 y에 대해 다음 식으로 정의된다.
P(x)는 x가 일어날 확률, P(y)는 y가 일어날 확률, P(x, y)는 x와 y가 동시에 일어날 확률을 뜻한다. 즉 PMI값이 높을수록 관련성이 높다는 의미다. 이 식을 앞의 자연어 예에 적용하면 P(x)는 단어 x가 말뭉치에 등장할 확률, 예컨대 10,000개 단어로 이뤄진 말뭉치에서 "the"가 100번 등장한다면 P("the")=100/10000=0.01이 되고, "the"와 "car"가 10번 동시발생했다면 0.001이 된다.
식을 다시 정리해보자. C는 동시발생 행렬, C(x,y)는 단어 x와 y가 동시발생하는 횟수, C(x)와 C(y)는 각각 단어 x와 y의 등장 횟수, 말뭉치에 포함딘 단어 수를 N이라고 하면 위와 같은 식으로 변환할 수 있다. 위 식에 대입하여 PMI("the", "car"0는 2.32, PMI("car", "drive")는 7.97이 나온다면 결과에서 알 수 있듯이 "car"는 "the"보다 "drive"와의 관련성이 강해진다. 이 이유는 단어가 단독으로 출현하는 횟수가 고려되어 "the"가 자주 출현했으므로 PMI점수가 낮아졌기 때문이다. 하지만 PMI에는 문제점이 있는데, 동시발생 횟수가 0이면 log2 0이 되어 -무한대가 딘다. 이 문제를 피하기 위해 실제로 구현할 때는 양의 상호정보량(Positive PMI - PPMI)을 사용한다.
이 식에 따라 PMI가 음수일 때는 0으로 취급한다. 이 PPMI를 python으로 구현하자면 다음와 같이 구현할 수 있다.
파라미터 C는 동시발생 행렬, verbose는 진행상황 출력 여부를 결정한다. 큰 말뭉치를 다룰 때 verbose=True로 설정하면 중간중간 진행 상황을 볼 수 있다. 그럼 이제 동시발생 행렬을 PPMI 행렬로 변환해보자.
PPMI 행렬의 각 원소는 0이상의 실수이고, 더 좋은 척도로 이뤄진 행렬을 얻어 냈다. 하지만 PPMI 행렬에도 여전히 큰 문제가 있는데, 말뭉치의 어휘 수가 증가함에 따라 각 단어 벡터의 차원 수도 증가하는 것이다. 또한 행렬의 원소 대부분이 0인 것을 알 수 있어, 각 원소의 '중요도'가 낮아 노이즈에 약한 단점이 있다. 이 문제에 대처하고자 자주 수행하는 기법이 바로 벡터의 차원 감소이다.
2.4.2 차원 감소
차원 감소(dimensionality reduction)는 문자 그대로 벡터의 차원을 줄이는 방법을 말한다. 그러나 단순히 줄이기만 하는 게 아니라, '중요한 정보'는 최대한 유지하면서 줄이는 게 핵심이다.
왼쪽 그림은 2차원 좌표에 데이터를 표시한 것이고, 오른쪽 그림은 새로운 축을 도입하여 좌표축 하나만으로 표시했다. 이처럼 데이터의 점들은 새로운 축으로 사영되고, 여기서 가장 중요한 것은 가장 적합한 축을 찾아내는 일이다. 1차원 값만으로도 데이터의 본질적인 차이를 구별할 수 있어야 한다. 추가적으로 원소 대부분이 0인 행렬을 희소행렬(sparse matrix)라고 말하는데, 여기서 차원을 감소하면 밀집벡터(dense matrix)로 되고, 이 벡터가 우리가 원하는 단어의 분산 표현이다.
차원을 감소시키는 방법은 여러가지지만, 우리는 특잇값분해(Singular Value Decomposition - SVD)를 이용해본다.
SVD는 위 식과 같이 임의의 행렬 X를 U, S, V라는 세 행렬의 곱으로 분해한다. 여기서 U와 V는 직교행렬(orthogonal matrix)이고, 그 열벡터는 서로 직교한다. 또한 S는 대각행렬(diagonal matrix - 대각성분 오에는 모두 0인 행렬)이다.
U는 직교행렬로 어떠한 공간의 축(기저)을 형성한다. 다시 말해 '단어 공간'으로 취급할 수 있다. S의 대각성분에는 특잇값(singular value)이 큰 순서로 나열되어 있고, 특잇값이란 쉽게 말해 '해당 축'의 중요도라고 간주할 수 있다. 그래서 아래 그림과 같이 중요도가 낮은 원소를 깎아내는 방법을 생각할 수 있다.
행렬 S에서 특잇값이 작다면 중요도가 낮다는 뜻이므로, 행렬 U에서 여분의 열벡터를 깎아내어 원래의 행렬을 근사할 수 있다. 이를 '단어의 PPMI'행렬에 적용해보자면 행렬 X의 각 행에는 해당 단어 ID의 단어 벡터가 저장되어 있으며, 그 단어 벡터가 행렬 U'라는 차원 감소된 벡터로 표현되는 것이다.
2.4.3 SVD에 의한 차원 감소
SVD를 파이썬 코드로 살펴보자. SVD는 넘파이의 linalg 모듈이 제공하는 svd 메서드로 실행할 수 있다.
이 코드에서 SVD에 의해 변환된 밀집벡터 표현은 변수 U에 저장된다. C는 동시발생 행렬, W는 PPMI행렬로 나타낸다. 결과를 보면 원래 희소벡터 W[0]가 SVD에 의해 밀집벡터 U[0]로 변했고, 이 벡터를 차원시키려면 단순히 처음 두 원소를 꺼내면 된다. 그럼 각 단어를 2차원 벡터로 표현해보자.
plt.annotate(word, x, y)메서드는 2차원 그래프상에서 좌표(x, y) 지점에 word에 담긴 텍스트를 그린다. 결과를 보면 "goodbye"와 "hello", "you"와 "i"가 제법 가까이 있음을 알 수 있다. 하지만 현재 사용한 말뭉치가 아주 작아 결과를 그대로 받아들일 수는 없기에, PTB라는 큰 말뭉치를 사용해 실험을 해보고자 한다.
2.4.4 PTB 데이터셋
PTB 데이터셋은 Peen Treebank 데이터셋으로, 주어진 기법의 품질을 측정하는 벤치마크로 자주 이용된다. PTB 말뭉치는 텍스트 파일로 제공되며, 원래의 PTB문장에 몇 가지 전처리를 해두었다. 예컨대 희소한 단어를 <unk>라는 특수 문자로 치환한다거나, 구체적인 숫자를 "N"으로 대체하는 등의 작업이 적용되었다. 이 책에서는 각 문장을 연결한 '하나의 큰 시계열 데이터'로 취급하며, 한 문장이 하나의 줄로 저장되어 각 문장 끝에 <eos>라는 특수 문자를 삽입한다.
# chap02/count_method_big.py
import sys
sys.path.append('..')
import numpy as np
from common.util import most_similar, create_co_matrix, ppmi
from dataset import ptb
window_size = 2
wordvec_size = 100
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)
print('Create Co-occurrence Matrix...')
C = create_co_matrix(corpus, vocab_size, window_size)
print('PPMI 계산...')
W = ppmi(C, verbose=True)
print('SVD 계산...')
try:
# truncated SVD
from sklearn.utils.extmath import randomized_svd
U, S, V = randomized_svd(W, n_components=wordvec_size, n_iter=5,
random_state=None)
except:
# SVD
U, S, V = np.linalg.svd(W)
word_vecs = U[:, :wordvec_size]
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
most_similar(query, word_to_id, id_to_word, word_vecs, top=5)
위 코드는 SVD를 수행하기 위해 sklearn의 randomized_svd() 메서드를 이용했다. 이 메서드는 무작위 수를 사용한 Truncated SVD로, 특잇값이 큰 것들만 계산하여 기본적인 SVD보다 훨씬 빠르다.
결과를 보면, 우선 "you"라는 검색어에서는 인칭대명사인 "i"와 "we"가 상위를 차지했음을 알 수 있다. 영어 문장에서 관용적으로 자주 나오는 단어들이기 때문이다. 이외에도 각 단어마다 단어의 의미 혹은 문법적인 관점에서 비슷한 단어들이 가까운 벡터로 나타났다. 말뭉치를 사용해 맥락에 속한 단어의 등장 횟수를 센 후 PPMI 행렬로 변환, 다시 SVD를 이용해 차원을 감소시킴으로써 더 좋은 단어 벡터를 얻어냈다. 이것이 단어의 분산 표현이고, 각 단어는 고정 길이의 밀집벡터로 표현됐다.
2.5 정리
Chapter 2는 컴퓨터에게 '단어의 의미' 이해시키기를 주제로 이야기를 진행했다. 먼저 시소러스 기반에서는 단어들의 관련성을 사람이 수작업으로 하나씩 정의했지만 비효율적이며 표현력에 한계가 있다. 통계 기반 기법은 말뭉치로부터 단어의 의미를 자동 추출하고, 그 의미를 벡터로 표현한다. 구체적으로는 단어의 동시발생 행렬을 만들고, PPMI 행렬로 변환, 안전성을 높이기 위해 SVD를 이용해 차원을 감소시켜 각 단어의 분산 표현을 만들어냈다.
'Data Science > NLP' 카테고리의 다른 글
[밑바닥부터 시작하는 딥러닝 2] chapter4 - word2vec 속도 개선 (0) | 2022.02.10 |
---|---|
밑바닥부터시작하는딥러닝3 - word2vec (0) | 2022.02.04 |
NLP(자연어처리) - 자언어처리란? intro (0) | 2021.02.14 |
NLP(자연어처리) - 정규표현식 with python (2) (0) | 2021.02.14 |
NLP(자연어처리) - 정규표현식 with python (1) (0) | 2021.02.14 |
최근댓글