자연어와 단어의 분산표현
2.1 자연어 처리란
자연어(Natural language) : 한국어나 영어 등 우리가 평소에 쓰는 말
자연어처리(Natural Language Processing, NLP): 우리의 말을 컴퓨터에게 이해시키기 위한 기술
자연어처리 생활 속 활용 : 검색엔진, 기계 번역, 질의응답 시스템, IME(입력기 전환), 문장 자동요약솨 감정분석 등
2.1.1 단어의 의미
우리의 말은 '문자'로 구성되며 말의 의미는 '단어'로 구성됩니다.
※ '단어의 의미'를 잘 파악하는 표현 방법(기법)
- 시소러스를 활용한 기법
- 통계 기반 기법
- 추론 기반 기법(word2vec)
2.2 시소러스
유의어 사전으로, '뜻이 같은 단어'나 '뜻이 비슷한 단어'가 한 그룹으로 분류되어 있습니다.
'단어의 의미'를 나타내는 방법으로는 먼저 사람이 직접 단어의 의미를 정의하는 방식이 있습니다.
'car'의 상위 개념으로 'motor vehicle'이라는 단어가 존재하며 하위 개념으로 'SUV', 'compact', 'hatch-back' 등 구체적인 차종이 있습니다. 이처럼 '단어 네트워크'를 이용하여 컴퓨터에게 단어의 관계를 가르쳐줄 수 있습니다.
2.2.1 WordNet
자연어 처리 분야에서 가장 유명한 시소러스는 WordNet입니다.
지금까지 많은 연구와 다양한 자연어 처리 애프리케이션에서 활용되고 있습니다.
WordNet을 사용하면 유의어를 얻거나 '단어 네트워크'를 이용할 수 있습니다.
또한, 단어 네트워크를 사용해 단어 사이의 유사도를 구할 수도 있습니다.
2.2.2 시소러스의 문제점
WordNet과 같은 시소러스에는 수많은 단어에 대한 동의어와 계층 구조 등의 관계가 정의되어 있습니다.
그리고 이 지식을 이용하면 '단어의 의미'를 컴퓨터에 전달할 수 있습니다.
하지만 사람이 수작업으로 레이블링하는 방식에는 문제들이 존재합니다.
- 시대 변화에 대응하기 어렵습니다. 신조어 혹은 의미 변화된 단어들을 바로 적용 시키기 어렵습니다.(우리가 사용하는 말은 살아 있습니다.)
- 사람을 쓰는 비용이 든다. 현존하는 영어 단어의 수는 1,000만 개가 넘으며 WordNet에 등록된 단어는 20만 개 이상이다.
- 단어의 미묘한 차이를 표현할 수 없다. 가령, 빈티지와 레트로의 의미는 같으나 용법의 차이가 존재한다.
위 문제점들을 피하기 위해 '통계 기반 기법'과 신경망을 사용한 '추론 기반 기법'을 알아볼 것이다.
이 두 기법에서는 '단어의 의미'를 자동으로 추출합니다.
2.3 통계 기반 기법
목표 : 사람의 지식으로 가득한 말뭉치에서 자동으로, 효율적으로 그 핵심을 추출하는 것입니다.
말뭉치: 자연어 처리연구나 애플리케이션을 염두에 두고 수집된 텍스트 데이터
2.3.1 파이썬으로 말뭉치 전처리하기
전처리: 텍스트 데이터를 단어로 분할하고 그 분할된 단어들을 단어 ID목록으로 변환하는 일
text = 'You say goodbye and I say hello.'
# 전처리 과정
text = text.lower()
text = text.replace('.', ' .')
words = text.split(' ')
words
→ ['you', 'say', 'goodbye', 'and', 'i', 'say', 'hello', '.']
.lower( ) : 모든 문자를 소문자로 변환
.split(' ') : 공백을 기준으로 분할
단어를 분할하여 다루기가 쉬워졌고, 이후 단어에 ID를 부여하고, ID의 리스트로 이용할 수 있도록 해줍니다.
이를 위해 파이썬의 딕셔너리를 이용하여 단어 ID와 단어를 짝지어주는 대응표를 작성합니다.
word_to_id = {}
id_to_word = {}
단어 ID에서 단어로의 변환은 id_to_word가 담당합니다.(키 = 단어 ID, 값이 단어)
단어에서 단어 ID로의 변환은 word_to_id가 담당합니다.
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
단어가 word_to_id에 들어 있지 않으면 word_to_id, id_to_word 각각에 새로운 ID와 단어를 추가함
추가 시점의 딕셔너리 길이가 새로운 단어의 ID로 설정되어 추가됨
※ 위 과정을 모아서 전처리 하기
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
이후 '통계 기반 기법' 을 사용해 우리는 단어를 벡터로 표현할 수 있게 됩니다
2.3.2 단어의 분산표현
색에는 고유한 이름이 붙여진 다채로운 색들도 있고, RGB(Red/Green/Blue)라는 세가지 성분이 어떤 비율로 섞여 있느냐로 표현하는 방법이 있습니다.
전자는 색의 가짓수만큼 의 이름을부여하는 반면에 후자는 색을 3차원의 벡터로 표현합니다.
여기서 주목할 점은 RGB같은 벡터 표현이 단 3개의 성분으로 간결하게 표현할 수 있고, 색을 더 정확하게 명시할 수 있다는 점입니다.
'색'을 벡터로 표현하듯 '단어'도 벡터로 표현할 수 있습니다. 이를 단어의 '분산 표현'이라고 합니다.
2.3.3 분포가설
분포 가설이란 단어의 의미는 주변 단어에 의해 형성된다는 것입니다.
분포 가설이 말하고자 하는 것은 단어 자체에는 의미가 없고, 그 단어가 사용된 '맥락'이 의미를 형성한다는 것입니다.
예를 들어, I drink beer를 I guzzle beer라고 해도 guzzle을 drink로 이해할 수 있다는 것입니다.
위 그림에서 goodbye를 기준으로 좌우의 두 단어씩이 '맥락'에 해당합니다.
맥락의 크기를 '윈도우 크기'라고 합니다.
여기서는 '위도우 크기'가 2이기 때문에 좌우로 두 단어씩이 맥락에 포함됩니다.
2.3.4 동시발생 행렬
분포 가설에 기초해 단어를 벡터로 나타내는 방법을 생각해보면 주변 단어를 세어보는 방법이 떠오를 것이며 이를 '통계 기반'기법이라고 합니다.
위 그림에서 알 수 있듯, 단어 "you"의 맥락은 "say"라는 단어 하나뿐입니다.
이를 표로 정리하면,
'you'의 맥락으로써 동시에 발생하는 단어의 빈도를 나타낸 것이며 벡터로 표현하면 [0, 1, 0, 0, 0, 0, 0]입니다.
'say'라는 단어는 벡터 [1, 0, 1, 0, 1, 1, 0]으로 표현할 수 있습니다.
위의 표는 모든 단어에 대해 동시발생하는 단어를 표에 정리한 것입니다.
위 표의 각 행은 벡터이며 행렬의 형태를 띄어 동시발생 행렬이라 합니다.
다음은 동시발생 행렬 생성(자동화) 코드 입니다.
# 동시발생 행렬 생성(자동화)
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[wprd_id, right_word_id] += 1
return co_matrix
2.3.5 벡터간 유사도
단어 벡터의 유사도를 나타낼 때는 코사인 유사도를 자주 이용합니다.
위의 식처럼 분자에는 벡터의 내적이, 분모에는 각 벡터의 노름(norm)이 등장합니다.
노름은 벡터의 크기를 나타낸 것으로 여기서는 L2노름을 계산합니다.
위 식의 핵심은 벡터를 정규화 하고 내적을 구하는 것입니다.
def cos_similarity(x,y):
nx = x / np.sqrt(np.sum(x**2)) # x의 정규화
ny = y / np.sqrt(np.sum(y**2)) # y의 정규화
return np.dot(nx, ny)
위 코드는 코사인 유사도를 파이썬 함수로 구현한 것입니다.
이 코드는 문제가 하나 발생합니다. 만약 인수로 제로 벡터(원소가 모두 0인 벡터)가 들어오면
'0으로 나누기(divide by zero)' 오류가 발생합니다.
이 문제를 해결하는 전통적인 방법은 분모에 작은 값을 더해 나누는 것입니다.
작은 값을 뜻하는 eps를 인수로 받도록 하고, 이 인수의 값을 지정하지 않으면 기본 값으로 13-8(=0.00000001)이
설정 되도록 수정하였습니다.(eps는 엡실론epsilon의 약어입니다.)
※ 개선된 코드
def cos_similarity(x, t, eps=1e-8):
nx = x / (np.sqrt(np.sum(x**2))+eps)
ny = y / (np.sqrt(np.sum(y**2))+eps)
return np.dot(nx, ny)
※ 유사도를 구하는 코드
# 유사도를 구하는 코드입니다.
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))
2.3.6 유사 단어의 랭킹 표시
most_similar() : 코사인 유사도를 이용하여 단어가 주어지면, 그 검색어와 비슷한 단어를 유사도 순으로 출력하는 함수
※ most_similar( )함수의 구현
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 (-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
이 코드는 다음 순서로 동작합니다.
- 검색어의 단어 벡터를 꺼낸다.
- 검색어의 단어 벡터와 다른 모든 단어 벡터와의 코사인 유사도를 각각 구한다.
- 계산한 코사인 유사도 결과를 기준으로 값이 높은 순서대로 출력한다.
※ "you"를 검색어로 지정해 유사 단어 출력 함수의 구현
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)
2.4 통계 기반 기법 개선하기
2.4.1 상호정보량
동시발생 행렬의 원소는 두 단어가 동시에 발생한 횟수를 나타내지만 '발생' 횟수라는 것은 사실 좋은 특징이 아닙니다.
예를 들어 'the' 와 'car'의 동시발생을 생각해봅니다.
'...the car...'라는 문구가 자주 보일 것이며 'car'와 'drive'는 관련이 깊다고 할 수 있습니다.
하지만 'the'가 고빈도 단어이기 때문에 'car'와 더 관련이 있어 보이게 결과가 나올 수 있습니다.
이를 해결하기 위해 점별 상호정보량(PMI)이라는 척도를 사용합니다.
PMI는 확률 변수 x와 y에 대해 다음 식으로 정의 됩니다.
P(x)는 x가 일어날 확률, P(y)는 y가 일어날 확률, P(x,y)는 x,y가 동시에 일어날 확률입니다.
PMI값이 높을수록 관련성이 높다는 의미입니다.
위 식을 다시 정리하면 밑에 식처럼 표현됩니다.
여기서 C는 동시발생 행렬, C(x,y)는 단어 x와 y가 동시발생하는 횟수, C(x)와 C(y)는 각각 단어 x와 y의 등장 횟수이며 N은 말뭉치에 포함된 단어 수입니다.
이 식을 토대로 1,000번 등장한 'the', 20번 등장한 'car'와 10번 등장한 'drive'를 계산해봅니다.
우선 'the'와 'car'의 동시발생 수가 10회라면 PMI 결과는 다음과 같습니다.
그 다음으로 'car'와 'drive'의 동시발생 수가 5라면 PMI 결과는 다음과 같습니다.
두 PMI의 결과를 살펴보면 'car'와 'drvie'의 관계성이 강하다는 것을 볼 수 있습니다.
이러한 결과가 나온 이유는 단어가 단독으로 출현하는 횟수가 고려되었기 때문입니다.
이 예에서는 'the'가 자주 출현하였기 때문에 PMI값이 낮아진 것입니다.
하지만 PMI에도 문제가 하나 있습니다.
이는 두 단어의 동시발생 횟수가 0이면 log(0,2) = -infinite가 됩니다.
이 문제를 피하기 위해 실제 구현할 때는 양의 상호정보량(PPMI)를 사용합니다.
이 식에 따라 PMI가 음수인 때는 0으로 취급하며 단어 사이의 관련성을 0 이상의 실수로 나타낼 수 있습니다.
하지만 PPMI 행렬에도 문제가 있는데 말뭉치의 어휘 수가 증가함에 따라 각 단어 벡터의 차원 수도 증가한다는 문제입니다.
이 문제를 대처하고자 자주 수행하는 기법이 '벡터의 차원 감소'입니다.
def ppmi(C, verbose=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 verbose:
cnt += 1
if cnt % (total//100) == 0:
print('%.1f%% 완료' % (100*cnt/total))
return M
여기에서 인수 C는 동시발생 행렬, verbose는 진행상황 출력 여부를 결정하는 플래그 입니다.
큰 말뭉치를 다룰 때 verbose=True로 설정하면 중간중간 진행상황을 알려줍니다.
한 가지 더, 이코드에서 np.log2(0)이 음의 무한대(-inf)가 되는 사태를 피하기위해 eps라는 작은 값을 사용했습니다.
동시발생 행렬을 PPMI 행렬로 변환해봅니다. 다음처럼 구현이 가능합니다.
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('-'*50)
print('PPMI')
print(W)
2.4.2 차원 감소
차원 감소는 벡터의 차원을 '중요한 정보'는 최대한 유지하면서 줄이는 방법을 말합니다.
위의 그림 예시처럼 데이터의 분포를 고려해 중요한 '축'을 찾는 일을 수행합니다.
왼쪽 그림은 데이터점들을 2차원 좌표에 표시한 모습이고 오른쪽 그림은 새로운 축을 도입하여 똑같은 데이터를 촤표축 하나만으로 표시했습니다.
여기서 중요한 것은 가장 적합한 축을 찾아내는 일로, 1차원 값만으로 데이터의 본직적인 차이를 구별할 수 있어야 하며.그리고 다차원 데이터에 대해서도 수행 가능합니다.
차원을 감소시키는 방법 중 하나인 특잇값분해(SVD)는 임의의 행렬을 세 행렬의 곱으로 분해하며, 수식으로는 다음과 같습니다.
SVD는 임의의 행렬 X를 U,S,V라는 세 행렬의 곱으로 분해한다.
U와 V는 직교행렬이고 열벡터는 서로 직교한다. S는 대각행렬이다.
행렬 S에서 특잆값이 작다면 중요도가 낮다는 뜻이므로 행렬 U에서 여분의 열벡터를 깎아내려 원래의 행렬을 근사할 수 있습니다.
이를 '단어의 PPMI 행렬'에 적용하면 행렬 X의 각 행에는 해당 당너 ID의 단어 벡터가 저장되어 있으며, 그 단어 벡터가 행렬 U'라는 차원 감소된 벡터로 표현됩니다.
2.4.3 SVD에 의한 차원 감소
SVD는 넘파이의 linalg 모듈이 제공하는 svd 메서드로 실행 가능합니다.
import numpy as np
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)
이제 SVD를 수행할 수 있습니다. 이 코드에서 SVD에의 해 변환된 밀집벡터 표현은 변수 U에 저장됩니다.
np.set_printoptions(precision=3) # 유효 자릿수를 세 자리로 표시
print(C[0]) # 동시발생 행렬
print(W[0]) # PPMI행렬
print(U[0]) # SVD
for word, word_id in word_to_id.items():
plt.annotate(word, (U[word_id, 0], U[word_id, 1]))
plt.scatter(U[:,0], U[:,1], alpha=0.5)
plt.show()
plt.annotata(word, x, y) 메서드는 2차원 그래프상에서 좌표(x, y)지점에 word에 담긴 텍스트를 그립니다.
코드를 돌린 결과 위의 그림이 나옵니다.
'goodbye'와 'hello', 'you'와 'i'가 제법 가까이 있음을 알 수 있습니다.
하지만 지금 사용한 말뭉치가 작기 때문에 PTB 데이터셋이라는 더 큰 말뭉치를 사용하여 똑같은 실험을 수행해봅니다.
2.4.4 PTB 데이터셋
지금까지는 아주 작은 텍스터 데이터를 사용했습니다. 이번 절에는 '본격적인 말뭉치' 펜 트리뱅크(PTB)를 이용합니다.
우리가 사용할 PTB(펜 트리뱅크) 말뭉치는 word2vec의 발명자인 토마스 미콜로프의 웹 페이지에서 받을 수 있습니다.
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'])
결과적으로 말뭉치를 사용해 맥락에 속한 단어의 등장 횟수를 센 후 PPMI 행렬로 변환하고 다시 SVD를 이용해 차원을 감소시킴으로서 더 좋은 단어 벡터를 얻었습니다.
이것이 단어의 분산 표현이고, 각 단어는 고정 길이의 밀집벡터로 표현되었습니다.
2.4.5 PTB 데이터셋 평가
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('calculating 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)
이 코드는 SVD를 수행하는 데 sklearn의 randomized_svd( ) 메서드를 이용했습니다.
이 메서드는 무작위 수를 사용한 Truncated SVD로, 특잇값이 큰 것들만 계산하여 기본적인 SVD보다 빠릅니다.
* cos_similarity( ) : 벡터간 유사도 측정
* most_similar( ) : 유사 단어의 랭킹 표시
'Deep learning > Computer vision(영상처리)' 카테고리의 다른 글
Numpy (0) | 2020.02.18 |
---|---|
Pandas (0) | 2020.02.18 |
BoW(이미지관련) (0) | 2020.02.12 |
GAN(이론) (0) | 2020.02.12 |
책(밑바닥부터 시작하는 딥러닝 2) 3 (0) | 2020.02.11 |