혜온의 이것저것

[Chapter 2 자연어와 단어의 분산 표현] 3 통계 기반 기법 본문

Deep Learning/밑바닥부터 시작하는 딥러닝2

[Chapter 2 자연어와 단어의 분산 표현] 3 통계 기반 기법

혜온 :) 2022. 3. 4. 14:59

통계 기반 기법을 살펴보면서 말뭉차(corpus)를 이용할 것이다. 간단히 말하면 대량의 텍스터 데이터인데, 맹목적으로 수집된 텍스트 데이터가 아닌 자연어 처리 연구나 애플리케이션을 염두에 두고 수집된 텍스트 데이터를 일반적으로 말뭉치라고 한다.

말뭉치에는 자연어에 대한 사람의 지식이 충분히 담겨 있다고 볼 수 있다. 문장을 쓰는 방법, 단어를 선택하는 방법, 단어의 의미 등 사람이 알고 있는 자연어에 대한 지식이 포함되어 있다.

통계 기반 깁버의 목표는 이처럼 사람의 지식으로 가득한 말뭉치에서 자동으로, 그리고 효울적으로 그 핵심을 추출하는 것이다.

 

2.3.1 파이썬으로 말뭉치 전처리하기

자연어 처리에는 다양한 말뭉치가 사용되다. 위키백과와 구글 뉴스 등의 텍스트 데이터, 셰익스피어나 나쓰메 소세키 같은 작품들도 말뭉치로 이용된다. 이번장에서는 우선 문장 하나로 이뤄진 단순한 텍스트를 사용한 후 더 실용적인 말뭉치를 다뤄보겠다.

 

말뭉치로 이용할 예시 문장이다.

text='You say goodbye and I say hello'

실전이라면 이 text에 수천, 수만 개가 넘는 문장이 연이어 담겨 있을 것이다.

이 text를 단어 단위로 분할하자

text=text.lower()
text=text.replace('.',' .')
text
## 'you say goodbye and i say hello .'
words=text.split(' ')
words
## ['you', 'say', 'goodbye', 'and', 'i', 'say', 'hello', '.']

lower() 매세드를 사용해 모든 문자를 소문자로 변환한다. 그리고 split(' ') 메서드를 호출해 공백을 기준으로 분할한다.

 

원래의 문장을 단어 목록 형태로 이용할 수 있게 되었다. 단어 단위로 분할되어 다루기 쉬워진 것은 맞지만, 단어를 텍스트 그대로 조작하기란 여러 면에서 불편하다. 그래서 단어어 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_to_word
## {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}
word_to_id
## {'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}

딕셔러니를 이용하면 단어를 가지고 단어 ID를 검색하거나 반ㄷ로 단어ID를 가지고 단어를 검색할 수 있다.

id_to_word[1]
## 'say'
word_to_id['hello']
## 5

단어 목록을 단어ID목록으로 변경해보자.

import numpy as np
corpus=[wrod_to_id[w] for w in words]
corpus=np.array(corpus)
corpus
## array([0, 1, 2, 3, 4, 5, 6])

 

말뭉치를 이용하기 위한 사전 준비를 마쳤다. 이상의 처리를 한 데 모아 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

이 함수를 사용하면 말뭉치 전처리를 다음과 같이 수행할 수 있다.

text='You say goodbye and I say hello.'
corpus, word_to_id, id_to_word=preprocess(text)

 

2.3.2 단어의 분산 표현

색을 표현하는 방법에는 RGB라는 세가지 성분이 어떤 비율로 섞여 있느냐로 표현하는 방법이 있다. 색을 3차원의 벡터로 표현하는 방식이다. RGB같은 벡터표현이 색을 더 정확하게 명시할 수 있다. 또한 색끼리의 관련성도 벡터 표현 쪽이 더 쉽게 판단할 수 있고, 정량화하기도 쉽다.

 

색을 벡터로 표현하듯이 단어도 벡터로 표현할 수 있을까 ? 이제부터 우리가 원하는 것은 단어의 의미를 정확하게 파악할 수 있는 벡터 표현이다. 이를 자연어 처리 분야에서는 단어의 분산표현(distributional representation)이라고 한다.

 

2.3.3 분포 가설

자연어 처리의 역사에 단어를 벡터로 표현하는 연구는 수없이 이뤄져 왔다. 그 연구들으 살펴보면 중요한 기법 모두 하나의 간단한 아이디어에 뿌리를 두고 있음을 알 수 있다. '단어의 의미는 주변 단어에 의해 형성된다.'라는 것이다. 이를 분포 가설(distributional htpothesis)이라 하며, 벡터로 표현하는 최근 연구도 대부분 이 가설에 기초한다.

 

분포 가설이 말하고자 하는 바는 매우 간단하다. 단어 자체에는 의미가 없고, 그 단어가 사용된 맥락이 의미를 형성한다는 것이다. 의미가 같은 단어들은 같은 맥락에서 다 많이 등장한다. 

 

앞으로 맥락이라는 말을 자주 사용할 것이다. 맥락이라 하면 주변에 놓인 단어를 가리킨다.

맥락의 크기를 윈도우 크기(window size)라고 한다. 윈도우 크기가 1이면 좌우 한 단어식이, 윈도우 크기가 2이면 좌우 두 단어씩이 맥락에 포함된다.

 

2.3.4 동시발생 행렬

분포 가설에 기초해 단어를 벡터로 나타내는 방법을 생각해보자. 어떤 단어에 주목했을 때, 그 주변에 어떤 단어가 몇 번이나 등장하는지를 세어 집계하는 방법이 있다. 이를 이 책에서는 통계 기반 기법이라고 한다.

import sys
sys.path.append('..')
import numpy as np
from common.util import precprocess

text='You say goodbye I say hello.'
corpus, word_to_id, id_to_word=preprocess(text)

print(corpus)
## [0 1 2 3 4 1 5 6]
print(id_to_word)
# {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6" '.}

 

결과를 보면 단어 수가 총 7개임을 알 수 있다. 다음으로는 각 단어의 맥락에 해당하는 단어의 빈도를 세오보자. 윈도우 크기를 1로 하고, 단어ID가 0인 'you'부터 시작해보자.

단어 'you'의 맥락은 'say'라는 단어 하나뿐이다. 이를 표로 정리하면 다음과 같다.

'you'의 맥락으로써 동시에 발생하는 단어의 빈도를 나타낸 것이다. 이를 바탕으로 'you'라는 단어를 [0,1,0,0,0,0,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)

이 동시발생 행렬을 사용하면 다음과 같은 방식을 각 단어의 벡터를 얻을 수 있다.

print(C[0]) #ID가 0인 단어의 벡터 표현
# [0 1 0 0 0 0 0]

print(C[4]) #ID가 4인 단어의 벡터 표현
# [0 1 0 1 0 0 0]

print(C[word_to_id['goodbye']]) #"goodbye"의 벡터 표현
# [0 1 0 1 0 0 0]

 

동시발생 행렬을 수동으로 만들었지만, 당연히 자동화할 수 있다.

말뭉치로부터 동시발생 행렬을 만들어주는 함수를 구현해보자. 함수 이름은 create_to_matrix(corpus, vocab_size,window_size=1)로 하자. 인수들은 차례로 단어ID의 리스트, 어휘 수, 윈도우 크기를 나타낸다.

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-i
            right_idx=idx+i
            
            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

0으로 채워진 2차원 배열로 초기화한 후 말뭉치의 모든 단어 각각에 대하여 윈도우에 포함된 주변 단어르 세어나간다. 이 때 말뭉치의 왼쪽 끝과 오른쪽 끝 경계를 벗어나지 않는지도 확인한다.

이 함수는 말뭉치가 아무리 커지더라고 자동으로 동시발행 행렬을 만들어준다. 앞으로 말뭉치의 동시발행 행렬을 만들 때 면 언제나 이 함수를 사용할 것이다.

 

2.3.5 벡터 간 유사도

벡터 사이의 유사도를 측정하는 방법은 다양하다. 대표적으로는 벡터의 내적이나 유클리드 거리 등을 꼽을 수 있다. 그 외에도 다양하지만, 단어 벡터의 유사도를 나타낼 때는 코사인 유사도를 자주 이용한다. 두 벡터에 대한 코사인 유사도는 다음 식으로 정의된다.

이 식의 핵심은 벡터를 정규화하고 내적을 구하는 것이다.

코사인 유사도를 파이썬 함수로 구현하면 다음과 같다.

def cos_similarity(x,y):
    nx=x/np.sqrt(np.sum(x**2))
    ny=y/np.sqrt(np.sum(y**2))
    return np.dot(nx,ny)

이 구현에는 문제가 하나 있다. 인수로 제로 벡터가 들어오면 0으로 나누기 오류가 발생해버린다.

이 문제를 해결하는 전통적인 방법은 나눌 때 분모에 작은 값을 더해주는 것이다. 작은 값을 뜻하는 eps를 인수로 받도록하고, 이 인수의 값을 지정하지 않으면 기본값으로 0.00000001이 설정되도록 수정한다.

def cos_similarity(x,y):
    nx=x/np.sqrt(np.sum(x**2)+eps)
    ny=y/np.sqrt(np.sum(y**2)+eps)
    return np.dot(nx,ny)

이 함수를 사용하면 단어 벡터의 유사도를 구할 수 있다.

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))
# 0.7071067691154799

실행 결과 'you'와 'i'의 코사인 유사도는 0.70...으로 나왔다. 코사인 유사도 값은 -1에서 1 사이의 값이므로 이 값은 비교적 높다 즉, 유사성이 크다고 말할 수 있다.

 

2.3.6 유사 단어의 랭킹 표시

어떤 단어가 검색어로 주어지면, 그 검색어와 비슷한 단어를 유사도 순으로 출력하는 함수 most_similar()를 구현해보자.

def most_similar(quert, word_to_id, id_to_word, word_matrix, top=5):
	# 1) 검색어를 꺼낸다.
	if query not in word_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 (-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

#3에서 사용된 argsort() 메서드는 넘파이 배열의 원소를 오름차순을 정렬한다.(단, 반환값은 배열의 인덱스) 하지만 우리가 원하는 것은 유사도가 큰 순서이므로 배열의 각 원소에 마이너스를 곱한 후 argsort() 메서드를 호출하면 원하는 결과를 얻을 수 있다.

 

'you'를 검색어로 지정해 유사한 단어들을 출력해보겠다.

import sys
sys.path.append('..')
from common.util import preprocess, create_co_matrix, most_similar

text='You say goodbye ans 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)

# [query] you
# goodbye: 0.7071067691154799
# i: 0.7071067691154799
# hello: 0.7071097691154799
# say: 0.0
# and: 0.0

'you'에 가장 가까운 단어는 총 3개가 나왔다. 'i'와 'you'는 모두 인칭대명사이므로 둘이 비슷하다는 것은 납득이 된다. 하지만 'goodbye'와 'hello'의 코사인 유사도가 높다는 것은 우리의 직관과는 거리가 멀다. 물론 지금은 말뭉치의 크기가 너무 작다는 것이 원인이다. 나중에 더 큰 말뭉치를 사용하면 다른 결과가 나올 수 있다.

 

Comments