자연어 처리가 다루는 분야는 다양하지만, 본질적 문제는 컴퓨터가 우리의 말을 알아듣게 만드는 것이다.
이번 장에서는 고전적인 기법을 알아보고, 파이썬으로 텍스트를 다루는 연습도 겸한다.
텍스트를 단어로 분할하는 처리나 단어를 단어 ID로 변환하는 처리 등을 구현한다.
자연어 처리란
우리가 평소에 쓰는 말을 자연어라고 한다.
자연어 처리(Natural Language Processing,NLP)를 문자 그대로 해석하면 자연어를 처리하는 분야이고, 우리 말을 컴퓨터에게 이해시키기 위한 기술이다.
일반적인 프로그래밍 언어는 기계적이고 고정되어 있다.
반면 자연어는 부드러운 언어이다. 똑같은 의미의 문장도 여러 형태로 표현할 수 있거나, 뜻이 애매할 수 있거나, 그 의미나 형태가 유연하게 바뀔 수 있다.
단어의 의미
우리의 말은 '문자'로 구성되며, 말의 의미는 '단어'로 구성된다.
단어가 최소 단위인 셈이다. 그래서 자연어를 컴퓨터에게 이해시키는 데는 무엇보다 단어의 의미를 이해시키는 것이 중요하다.
컴퓨터에게 단어의 의미를 잘 파악하는 기법 세가지를 알아본다.
- 시소러스를 활용한 기법
- 통계 기반 기법
- 추론 기반 기법(word2vec) - 다음장에서 학습
먼저 사람의 손으로 만든 시소러스(thesaurus)를 살펴보자.
시소러스
단어의 의미를 나타내는 방법으로는 먼저 사람이 직접 단어의 의미를 정의하는 방식을 생각할 수 있다.
표준국어대사전을 참고하여, 자동차라는 단어를 찾으면 "원동기를 장치하여 그 동력으로 바퀴를 굴려서 철길이나 가설된 선에 의하지 아니하고 땅 위를 움직이도록 만든 차"라는 설명이 나온다.
이런 식으로 단어들을 정의해두면 컴퓨터도 단어의 의미를 이해할 수 있을지 모른다.
자연어 처리의 역사를 되돌아보면 단어의 의미를 인력을 동원해 정의하려는 시도는 수없이 있었다.
단, 사람이 이용하는 일반적인 사전이 아닌, 시소러스형태의 사전을 애용했다.
시소러스란 유의어 사전으로, 뜻이 같은 단어(동의어)나 뜻이 비슷한 단어(유의어)가 한 그룹으로 분류되어 있다.

또한 시소러스에는 단어 사이의 상위와 하위 혹은 전체와 부분 등, 더 세세한 관계까지 정의해둔 경우가 있다.
아래 그림의 예처럼 각 단어의 관계를 그래프 구조로 정의한다.

이처럼 모든 단어에 대한 유의어 집합을 만든 다음, 단어들의 관계를 그래프로 표현하여 단어 사이의 연결을 정의할 수 있다.
이 단어 네트워크를 이용하여 컴퓨터에게 단어 사이의 관계를 가르칠 수 있다.
WordNet
자연어 처리 분야에서 가장 유명한 시소러스는 WordNet이다.
WordNet은 프린스턴 대학교에서 1985년부터 구축하기 시작한 전통 있는 시소러스로, 지금까지 많은 연구와 다양한 자연어 처리 애플리케이션에서 활용되고 있다.
WordNet을 사용하면 유의어를 얻거나 단어 네트워크를 이용할 수 있다.
또한 단어 네트워크를 사용해 단어 사이의 유사도를 구할 수 있다.
pip install nltk
시소러스의 문제점
WordNet과 같은 시소러스에는 수많은 단에 대한 동의어와 계층 구조 등의 관계가 정의돼 있다.
이 지식을 이용하면 단어의 의미를 컴퓨터에 전달할 수 있다.
하지만 이처럼 사람이 수작업으로 레이블링하는 방식에는 크나큰 결점이 존재한다.
- 시대 변화에 대응하기 어렵다 - 우리가 사용하는 말은 살아있다. 신조어가 생겨나기 때문이다. 또한 시대에 따라 언어의 의미가 변하기도 한다. 이런 변화에 대응하려면 시소러스를 사람이 수작업으로 끊임없이 갱신해야 한다.
- 사람을 쓰는 비용은 크다 - 시소러스를 만드는 데는 엄청난 인적 비용이 발생한다. 영어를 예로 들면, 현존하는 영어 단어 수는 1000만개가 넘는다. 이 방대한 단어들 모두에 대해 단어 사이의 관계를 정의해줘야 한다. 참고로 WordNet에 등록된 단어는 20만개 이상이다.
- 단어의 미묘한 차이를 표현할 수 없다 - 시소러스에서는 뜻이 비슷한 단어들을 묶는다. 하지만 실제로 비슷한 단어들이라도 미묘한 차이가 있는 법이다. 시소러스에서는 미묘한 차이를 표현할 수 없다.
통계 기반 기법
통계 기반 기법을 살펴보면서 말뭉치(corpus)를 이용한다.
말뭉치란 대량의 텍스트 데이터이다.
다만 맹목적으로 수집된 텍스트 데이터가 아닌, 자연어 처리 연구나 애플리케이션을 염두에 두고 수집된 텍스트 데이터를 일반적으로 말뭉치라 부른다.
결국 말뭉치란 텍스트 데이터에 지나지 않지만, 이 안에 담긴 문장들은 사람이 쓴 글이다.
다른 시각에서 생각해보면 말뭉치에는 자연어에 대한 사람의 지식이 담겨 있다고 볼 수 있다.
문장을 쓰는 방법, 단어를 선택하는 방법, 단어의 의미 등 사람이 알고 있는 자연어에 대한 지식이 포함되어 있는 것이다.
통계 기반 기법의 목표는 이처럼 사람의 지식으로 가득한 말뭉치에서 자동으로, 그리고 효율적으로 그 핵심을 추출하는 것이다.
파이썬으로 말뭉치 전처리하기
자연어 처리에는 다양한 말뭉치가 사용된다.
유명한 것으로는 위키백과(Wikipedia)와 구글 뉴스(Google News) 등의 텍스트 데이터를 들 수 있다.
파이썬 대화모드를 이용하여 매우 작은 텍스트 데이터(말뭉치)에 전처리(preprocessing)을 해보자.
여기서 말하는 전처리란 텍스트 데이터를 단어로 분할하고 그 분할된 단어들을 단어 ID 목록으로 변환하는 일이다.
말뭉치 예시 문장을 보자.
text = 'You say goodbye and I say Hello.'
이 텍스트를 단어 단위로 분할하자.
text = text.lower()
text = text.replace('.', ' .')
print(text)
words = text.split(' ')
print(words)

단어 단위로 분할되어 다루기 쉬워진 것은 사실이지만, 단어를 텍스트 그대로 조작하기란 여러 면에서 불편하다.
그래서 단어에 ID를 부여하고, ID의 리스트로 이용할 수 있도록 한번 더 손질한다.
이를 위한 사전 준비로, 파이썬의 딕셔너리를 이용하여 단어 ID와 짝지어주는 대응표를 작성한다.
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

마지막으로 단어 목록을 단어 ID 목록으로 변경해보자.
파이썬의 내포(cxomprehension) 표기를 사용하여 단어 목록에서 단어 ID 목록으로 변환한 다음, 다시 넘파이 배열로 변환했다.
import numpy as np
corpus = [word_to_id[w] for w in words]
corpus = np.array(corpus)

말뭉치를 이용하기 위한 사전 준비를 마쳤다.
처리를 한데 모아 preprocess()라는 함수로 구현한다.
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
말뭉치를 다룰 준비를 마쳤다.
다음 목표는 말뭉치를 사용해 단어의 의미를 추출하는 것이다.
이중 한가지 방법으로, 통계 기반 기법을 살펴보자. 이 기법을 사용해 우리는 단어를 벡터로 표현할 수 있게 될 것이다.
단어의 분산 표현
세상은 다채로운 색으로 가득하다.
예를들어 코발트블루나 싱크레드같은 고유한 이름을 붙일 수 있다.
또는, RGB로 표현할 수도 있다.
주목할 점은 RGB같은 벡터 표현이 색을 더 정확하게 명시할 수 있다는 사실이다.
단어에서도 단어의 의미를 정확하게 파악할 수 있는 벡터 표현이 필요하다.
이를 자연어 처리 분야에서는 단어의 분산 표현(distributional representation)이라고 한다.
단어의 분산 표현은 단어를 고정 길이의 밀집벡터로 표현한다. 밀집벡터는 대부분의 원소가 0이 아닌 실수인 벡터를 말한다. 3차원의 분산 표현은 [0.21, -0.45, 0.38]과 같은 모습이 된다.
분포 가설
단어의 의미는 주변 단어에 의해 형성된다. 이를 분포 가설(distributional hypothesis)이라고 하며, 단어를 벡터로 표현하는 연구도 대부분 이 가설에 기초한다.
분포 가설이 말하고자 하는 바는 매우 간단하다. 단어 자체에는 의미가 없고, 그 단어가 사용된 맥락(context)이 의미를 형성한다는 것이다.
아래 그림에서는 좌우의 각 두 단어씩이 맥락에 해당한다.

그림처럼 맥락이란 특정 단어를 중심에 둔 그 주변 단어를 말한다.
그리고 맥락의크기(주변 단어를 몇개나 포함할지)를 윈도우 크기(window size)라고 한다.
윈도우 크기가 1이면 좌우 한단어씩, 2면 좌우 두단어씩 맥락에 포함된다.
동시발생 행렬
분포가설에 기초해 단어를 벡터로 나타내는 방법을 생각해보자.
어떤 단어에 주목했을 때, 그 주변에 어떤 단어가 몇번이나 등장하는지 세어 집계하는 방법이다. 이 책에서는 이를 통계기반 기법이라고 설명한다.

단어 you의 맥락은 say 하나 뿐이다. 이를 표로 정리하면 아래와 같다.

단어 you의 맥락으로써 동시에 발생(등장)하는 단어의 빈도를 나타낸 것이다.
이를 바탕으로 you라는 단어를 [0, 1, 0, 0, 0, 0, 0]이라는 벡터로 표현할 수 있다.
계속해서 say에 대해서도 같은 작업을 수행한다.

이 결과로부터 say라는 단어는 벡터 [1, 0, 1, 0, 1, 1, 0]으로 표현할 수 있다.
이러한 식으로 모든 단어에 대해 수행한 결과는 다음과 같다.

이 표가 행렬의 형태를 띤다는 뜻에서 동시발생 행렬(co-occurrence matrix)이라고 한다.
그럼 동시발생 행렬을 파이썬으로 구현해보자. 그림 그대로를 손으로 입력한다.
C = np.array([
[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],
],dtype = np.int32)

동시발생행렬을 자동화할 수도 있다.
그러면 말뭉치로부터 동시발생행렬을 만들어주는 함수를 구현해보자.
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-1
right_idx = idx+1
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
이 함수는 말뭉치가 아무리 커지더라도 자동으로 동시발생 행렬을 만들어준다.
벡터간 유사도
벡터 사이의 유사도를 측정하는 방법은 다양하다.
대표적으로는 벡터의 내적이나 유클리드 거리 등을 꼽을 수 있다. 그 외에도 다양하지만, 단어 사이의 유사도를 나타낼 때는 코사인 유사도(cosine similarity)를 자주 이용한다. 두 벡터 x,y가 있다면 코사인 유사도는 다음 식으로 정의된다.

분자에는 벡터의 내적이, 분모에는 각 벡터의 norm이 등장한다.
norm은 벡터의 크기를 나타낸 것으로, L2 norm을 계산한다.(L2 norm은 벡터의 각 원소를 제곱해 더한 후 다시 제곱근을 구해 계산한다.)
이 식의 핵심은 벡터를 정규화하고 내적을 구하는 것이다.
코사인 유사도를 직관적으로 풀어보자면 두 벡터가 가리키는 방향이 얼마나 비슷한가 이다.
두 벡터의 방향이 완전히 같다면 1, 완전히 반대라면 -1이 된다.
코드로 구현한 코사인 유사도는 다음과 같다.
def cos_similarity(x,y):
nx = x / np.sqrt(np.sum(x**2))
ny = y / np.sqrt(np.sum(y**2)) # x,y의 정규화
return np.dot(nx,ny)
이 코드에는 오류가 ㅇ있다.
인수로 제로 벡터(원소가 모드 0인)가 들어오면 0으로 나누기 오류가 발생한다.
이 역시 전통적인 방법으로 아주 작은 값을 더해준다.
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) # x,y의 정규화
return np.dot(nx,ny)
이 함수를 사용하면 단어 벡터의 유사도를 구할 수 있다.
you와 i의 유사도를 구하는 코드를 보자.
import sys
sys.path.append('..')
from common.util import preprocess, create_co_matrix, cos_similarity
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)
c0 = C[word_to_id['you']] #you의 단어 벡터
c1 = C[word_to_id['i']] #i의 단어 벡터
print(cos_similarity(c0, c1))

코사인 유사도는 -1~1이므로, 이 값은 비교적 높다(유사성이 크다)고 말할 수 있다.
유사 단어의 랭킹 표시
코사인 유사도까지 구현했으니 이 함수를 활용해 또 다른 유용한 기능을 구현해보자.
어떤 단어가 검색어로 주어지면, 그 검색어와 비슷한 단어를 유사도 순으로 출력하는 함수를 구현해보자.
인수명 | 설명 |
query | 검색어(단어) |
word_to_id | 단어에서 단어 ID로의 딕셔너리 |
id_to_word | 단어 ID에서 단어로의 딕셔너리 |
word_matrix | 단어 벡터들을 한데 모은 행렬. 각 행에는 대응하는 단어의 벡터가 저장되어 있다고 가정한다. |
top | 상위 몇개까지 출력할지 결정 |
코드 구현은 다음과 같다.
def most_similar(query, word_to_id, id_to_word, word_matrix, top=5):
#1. 검색어를 꺼낸다
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]
#2. 코사인 유사도 계산
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)
#3. 코사인 유사도를 기준으로 내림차순 출력
count = 0
for i in range(-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
argsort()메서드는 넘파이 배열의 원소를 오름차순으로 정렬한다. 단, return값은 배열의 인덱스이다. 예를 보자.
x = np.array([100,-20,2])
x. argsort() # array([1, 2, 0])
(-x).argsort() # array([0, 2, 1])
이제 you를 검색어로 지정해 유사한 단어들을 출력해보자.
import sys
sys.path.append('..')
from common.util import preprocess, create_co_matrix, most_similar
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)
most_similar('you', word_to_id, id_to_word,C,top=5)

통계 기반 기법 개선하기
단어의 동시발생 행렬에는 아직 개선할 점이 있다.
개선을 진행하고, 더 실용적인 말뭉치를 사용하여 진짜 단어의 분산 표현을 해보자.
상호정보량
앞절에서 본 동시발생 행렬의 원소는 두 단어가 동시에 발생한 횟수를 나타낸다.
이 발생횟수라는 것은 사실 그리 좋은 특징이 아니다.
말뭉치에서 the와 car의 동시발생을 생각해본다. the car 라는 문구가 자주 보일 것이다. 따라서 두 단어의 동시발생 횟수는 아주 많을 것이다.
한편, car와 drive는 확실히 관련이 깊다. 하지만 단순히 등장 횟수만을 본다면 car는 drive보다는 the와의 관련성이 훨씬 강하다고 나올 것이다. the가 고빈도 단어이기 때문이다.
이 문제를 해결하기 위해 점별 상호정보량(Pointwise Mutual Information,PMI)라는 척도를 사용한다.
PMI의 수식은 다음과 같다.

P(x),P(y)는 각각 x,y가 일어날 확률, P(x,y)는 x,y가 동시에 일어날 확률을 뜻한다.
이 PMI 값이 높을수록 관련성이 높다는 의미이다.
예를 들어 10000개의 단어로 이뤄진 말뭉치에서 the가 100번 등장한다면 P("the") = 100 / 10000 = 0.01이 된다.
the와 car가 10번 동시발생했다면 P("the","car") = 10/10000 = 0.001이 된다.
그럼 동시발생 행렬을 사용하여 위 식을 다시 써보자.
C는 동시발생 행렬을, N은 말뭉치에 포함된 단어 수를 뜻한다.

the와 car의 동시발생 수는 10회, car와 drive의 동시발생 횟수는 5회라고 가정해보자.
이 조건에서는 동시발생 횟수 관점에서는 car는 the와 관련이 더 깊다고 나온다.
PMI 관점에서는 어떨까?


위 결과에서 알수 있듯이 PMI를 사용하면 car는 drive와의 관련성이 강해진다. 우리가 원했던 결과이다.
이러한 결과가 나온 이유는 단어가 단독으로 출현하는 횟수가 고려되었기 때문이다.
이 PMI에도 한가지 문제가 있다.
바로 두 단어의 동시발생 횟수가 0이면 log0 = -inf가 되기 때문이다.
이 문제를 피하기 위해 실제로 구현할 때는 양의 상호정보량(Positive PMI,PPMI)를 사용한다.

이 식에 따라 PMI가 음수일때는 0으로 취급한다. 이제 단어 사이의 관련성을 0 이상의 실수로 표현할 수 있다.
동시발생 행렬을 PPMI행렬로 변환하는 함수를 구현해보자.
def ppmi(C, verbase = 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[j]*S[i]) + eps)
M[i,j] = max(0,pmi)
if verbase:
cnt +=1
if cnt % (total//100 + 1) == 0:
print('%.1f%% 완료' % (100*cnt/total))
return M
C는 동시발생 행렬, verbase는진행상황 출력 여부를 결정하는 플래그이다.
큰 말뭉치는 다룰때 verbase + True로 설정하면 중간 진행 상황을 알려준다.
이 코드는 동시발생 행렬에 대해서만 PPMI 행렬을 구할수 있도록 하고자 단순화해 구현한 것이다.
구체적으로 말하면 단어 x,y가 동시에 발생하는 횟수인 C(x,y)에 대해 근삿값을 구하도록 구현했다.
그러면 동시발생 행렬을 PPMI행렬로 변환해보자.
import sys
sys.path.append('..')
import numpy as np
from common.util import preprocess, create_co_matrix, cos_similarity, ppmi
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)
np.set_printoptions(precision=3) #유효 자릿수를 세자리로 표시
print('동시발생행렬')
print(C)
print('-'*30)
print('PPMI')
print(W)

PPMI에도 여전히 큰 문제가 있다. 말뭉치의 어휘 수가 증가함에 따라 각 단어 벡터의 차원 수도 증가한다는 문제이다.
말뭉치 어휘 수가 10만개라면 벡터의 차원수도 똑같이 10만이 된다.
이 행렬의 내용을 들여다보면 원소 대부분이 0인 것을 알 수 있다.
벡터의 원소 대부분이 중요하지 않다는 뜻이다. 더구나 이런 벡터는 노이즈에 약하고 견고하지 못하다는 약점도 있다.
이러한 문제에 대처하고자 자주 수행하는 기법이 벡터의 차원 감소이다.
차원 감소
차원 감소(dimensionality reduction)는 말 그대로 벡터의 차원을 줄이는 방법이다.
단순히 줄이기만 하는 것이 아니라, 중요한 정보는 최대한 유지하면서 줄이는게 핵심이다.
직관적인 예로, 아래 그림처럼 데이터의 분포를 고려해 중요한 축을 찾는 일을 수행한다.

왼쪽은 데이터점들을 2차원 좌표에 표시한 모습이다. 오른쪽은 새로운 축을 도입하여 똑같은 데이터를 좌표축 하나만으로 표시했다.(새로운 축을 찾을 때는 데이터가 넓게 분포되도록 고려해야 한다.) 이때 각 데이터점의 값은 새로운 축으로 사영된 값으로 변한다.
이때 각 데이터점의 값은 새로운 축으로 사영된 값으로 변한다.
여기서 가장 중요한 것은 가장 적합한 축을 찾아내는 일로, 1차원 값만으로도 본질적인 차이를 구별할 수 있어야 한다.
이와 같은 작업은 다차원 데이터에 대해서도 수행할 수 있다.
원소 대부분이 0인 행렬 또는 벡터를 희소행렬,희소벡터라 한다. 차원 감소의 핵심은 희소벡터에서 중요한 축을 찾아내어 더 적은 차원으로 다시 표현하는 것인데, 차원 감소의 결과로 원래의 희소벡터는 원소 대부분이 0이 아닌 값으로 구성된 밀집벡터로 변환된다. 이 조밀한 벡터야말로 우리가 원하는 단어의 분산표현이다.
차원을 감소시키는 방법은 여러가지인데, 그중 특이값분해(Singular Value Decomposition,SVD)를 이용해보자.
SVD는 임의의 행렬을 세 행렬의 곱으로 분해하며, 수식은 다음과 같다.

U와 V는 직교행렬(orthogonal matrix)이고, 열벡터는 서로 직교한다. S는 대각행렬(diagonal matrix, 대각성분 외에는 모두 0인 행렬)이다. 이 수식을 시각화하면 다음과 같다.

U는 직교행렬이라 했다. 그리고 이 직교행렬은 어떠한 공간의 축(기저)를 형성한다.
지금 맥락에서는 U행렬을 단어공간으로 취급할 수 있다. S는 대각행렬로, 대각성분에는 특이값이 큰 순서로 나열되어있다. 특이값이란, 해당 축의 중요도 정도로 말할 수 있다.
그래서 아래 그림과 같이 중요도가 낮은 원소(특이값이 작은 원소)를 깎아내는 방법을 생각해낼 수 있다.

위의 방법을 단어의 PPMI 행렬에 적용해보면, X의 각 행에는 해당 단어 ID의 단어 벡터가 저장되어 있으며, 그 단어 벡터가 행렬 U`라는 차원 감소된 벡터로 표현되는 것이다.
단어의 동시발생 행렬은 정방행렬이다.
SVD에 의한 차원 감소
SVD는 numpy에서 지원하는 linalg(linear algebra,선형대수)모듈의 svd메서드로 실행할 수 있다.
import numpy as np
import sys
sys.path.append('..')
import matplotlib.pyplot as plt
from common.util import preprocess,create_co_matrix,ppmi
text = 'You say goodbye and I say hello.'
corpus, word_to_id, id_to_word = preprocess(text)
vocab_size = len(id_to_word)
C = create_co_matrix(corpus, vocab_size,window_size = 1)
W = ppmi(C)
#SVD
U,S,V = np.linalg.svd(W)
plt.annotate(word,(U[word_id,0], U[word_id,1]))
plt.scatter(U[:,0], U[:,1],alpha = 0.5)
plt.show()
이 코드에서 SVD에 의해 변환된 밀집벡터 표현은 U에 저장된다.
희소벡터인 W가 SVD에 의해 밀집벡터 U로 변했다. 이 밀집벡터의 차원을 감소시키려면, 2차원 벡터로 줄이려면 단순히 처음의 두 원소를 꺼내면 된다.
print(U[0,:2]) # [4.409e-01 -1.110e-16]

그림을 보면 goodbye와 hello, you와 i가 가까이 있음을 알 수 있다.
우리의 직관과 비교적 비슷하다. 하지만 지금 사용한 말뭉치가 아주 작아서 이 결과를 그대로 받아들이기는 좀 그렇다.
계속해서 PTB 데이터셋이라는 좀 더 큰 말뭉치를 사용하여 같은 실험을 계속해보자.
행렬의 크기가 N이면 SVD 계산은 O(N^3)이 걸린다. 이는 현실적으로 감당하기 어려운 수준이므로 Truncated SVD같은 더 빠른 기법을 이용한다. Truncated SVD는 특이값이 작은 것은 버리는(truncated) 방식으로 성능 향상을 꾀한다.
다음 절에서도 옵션으로 사이킷런(scikit-learn) 라이브러리의 truncated SVD를 이용한다.
PTB 데이터셋
펜 트리뱅크(Penn Treebank,PTB) 말뭉치는 word2vec 발명자인 토마스 미콜로프의 웹페이지에서 받을 수 있다.
PTB 말뭉치는 텍스트 파일로 제공되고, 원래의 PTB 문장에 몇가지 전처리를 해두었다.
희소한 단어를 <unk>라는 특수 문자로 치환하거나, 구체적인 숫자를 "N"으로 대체하는 등의 작업이 적용되었다.
아래의 그림을 참고하자.

그림에서 보듯 PTB 말뭉치에는 한 문장이 하나의 줄로 저장되어 있다.
이책에서는 각 문장을 연결한 하나의 큰 시계열 데이터로 취급한다.
각 문장에 <eos>(end of sentence) 특수문자를 삽입한다.
이책에서는 PTB 데이터셋을 쉽게 이용할 수 있도록 파일을 준비했다. ptb.py를 사용하는 예를 보자.
# -*- coding: utf-8 -*-
"""
You can do it
"""
import sys
sys.path.append('..')
from dataset import ptb
corpus, word_to_id, id_to_word = ptb.load_data('train')
print('말뭉치 크기:', len(corpus))
print('corpus[:30]:',corpus[:30])
print('')
print('id_to_word[0]:',id_to_word[0])
print('id_to_word[1]:',id_to_word[1])
print('id_to_word[2]:',id_to_word[2])
print('')
print('word_to_id[car]:',word_to_id['car'])
print('word_to_id[happy]:',word_to_id['happy'])
print('word_to_id[lexus]:',word_to_id['lexus'])

corpus에는 단어 ID 목록이 저장된다.
ptb.load_data()는 데이터를 읽어 들이는데 이때 인수로는 train, test, valid를 지정할 수 있다. 각각 훈련용, 테스트용, 검증용 데이터를 가리킨다.
PTB 데이터셋 평가
PTB데이터셋에 통계 기반 기법을 적용해보자.
큰 행렬에 SVD를 적용해야 하므로 고속 SVD를 이용할 것을 추천한다.
sklearn 모듈을 설치해야 한다.
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('동시발생 수 계산 ...')
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 ImportError:
#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)

참고로 Truncated SVD는 무작위 수를 사용하므로 결과가 매번 다르다.
결과를 보면 you라는 검색어에서는 인칭대명사가 상위를 차지했다. 영어 문장에서 관용적으로 자주 같이 나오는 단어들이기 때문이다.
이처럼 단어의 의미 혹은 문법적인 관점에서 비슷한 단어들이 가까운 벡터로 나타났다.
우리의 직관과 비슷한 결과라고 할 수 있다.
정리
이번 장에서는 자연어를 대상으로 컴퓨터에게 '단어의 의미'를 이해시키기에 대해 다뤘다.
시소러스 기반 기법에서는 단어들의 관련성을 사람이 수작업으로 하나씩 정의한다. 이 작업은 매우 힘들고 표현력에도 한계가 있다.
한편 통계 기반 기법은 말뭉치로부터 단어의 의미를 자동으로 추출하고 그 의미를 벡터로 표현한다.
구체적으로는 단어의 동시발생 행렬을 만들고, PPMI 행렬로 변환한 다음, 안전성을 높이기 위해 SVD를 이용해 차원을 감소시켜 각 단어의 분산 표현을 만들어낸다.
그리고 그 분산 표현에 따르면 의미가(그리고 문법적인 용법면에서) 비슷한 단어들이 벡터 공간에서도 서로 가까이 모여 있음을 확인했다.
- WordNet 등의 시소러스를 이용하면 유의어를 얻거나 단어 사이의 유사도를 측정하는 등 유용한 작업을 할 수 있다.
- 시소러스 기반 기법은 시소러스를 작성하는데 엄청난 인적 자원이 들거나 새로운 단어에 대응하기 어려운 문제가 있다.
- 현재는 말뭉치를 사용해 단어를 벡터화하는 것이 주로 쓰인다.
- 최근의 단어 벡터화 기법들은 대부분 단어의 의미는 주변 단어에 의에 형성된다는 분포 가설에 기초한다.
- 통계 기반 기법은 말뭉치 안의 각 단어에 대해서 그 단어의 주변 단어의 빈도를 집계한다(동시발생 행렬).
- 동시발생 행렬을 PPMI 행렬로 변환하고 다시 차원을 감소시킴으로써, 거대한 희소벡터를 작은 밀집벡터로 변환시킬 수 있다.
- 단어의 벡터 공간에서는 의미가 가까운 단어는 그 거리도 가까울 것으로 기대된다.
※dataset 및 코드 제공 - 밑바닥부터 시작하는 딥러닝 GitHub:https://github.com/WegraLee/deep-learning-from-scratch-2scratch/tree/master
GitHub - kchcoo/WegraLee-deep-learning-from-scratch
Contribute to kchcoo/WegraLee-deep-learning-from-scratch development by creating an account on GitHub.
github.com
'공부 > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글
밑바닥부터 시작하는 딥러닝2 - Chap4. word2vec 속도 개선 (2) | 2024.12.26 |
---|---|
밑바닥부터 시작하는 딥러닝2 - Chap3. word2vec (6) | 2024.12.23 |
밑바닥부터 시작하는 딥러닝2 - Chap1. 신경망 복습 (5) | 2024.12.14 |
밑바닥부터 시작하는 딥러닝1 - Chap8. 딥러닝 (2) | 2024.12.10 |
밑바닥부터 시작하는 딥러닝1 - Chap7. 합성곱 신경망(CNN) (7) | 2024.12.06 |