자연어 처리란
사람들이 평소에 사용하는 말을 자연어라고 한다. 자연어 처리(NLP)는 사람의 말을 컴퓨터에게 이해시키기 위한 기술이다. 자연어 처리가 추구하는 목표는 사람의 말을 컴퓨터가 이해하도록 만들어서, 컴퓨터가 우리에게 도움이 되는 일을 수행하게 하는 것이다.
단어의 의미
우리의 말은 ‘문자’로 구성되며, 말의 의미는 ‘단어’로 구성된다. 즉 단어는 의미의 최소단위이다. 컴퓨터에게 단어의 의미를 전달하기 위해서는 먼저 단어를 표현하는 방식이 있어야 한다. 여러가지 방식 중 3가지 정도 알아보자.
- 시소러스(유의어 사전)를 활용한 기법
- 통계 기반 기법
- 추론 기반 기법(word2vec)
시소러스
시소러스는 유의어 사전으로 뜻이 비슷한 단어가 한 그룹으로 분류되어 있다. ex) car, auto, automobile, machine, motorcar 또한 자연어 처리에 이용되는 시소러스에서는 단어 사이의 ‘상위와 하위’ 혹은 ‘전체와 부분’등, 더 세세한 관계까지 정의해둔 경우도 있다.

이처럼 모든 단어에 대한 유의어 집합을 만든 다음, 단어들의 관계를 그래프로 표현하여 단어사이의 연결을 정의할 수 있다. 그러면 이 ‘단어 네트워크’를 이용하여 컴퓨터에게 단어 사이의 관계를 가르칠 수 있다.
WordNet
가장 유명한 시소러스는 WordNet이다. wordNet은 프린스턴 대학교에서 1985년부터 구축하기 시작한 전통 있는 시소러스로, 지금까지 많은 연구와 다양한 자연어 처리 애플리케이션에서 활용되고 있다.
시소러스의 문제점
시소러스는 사람이 수작업으로 레이블링하는 방식으로 진행된다. 그렇기 때문에 아래와 같은 문제점이 발생한다.
- 시대 변화에 대응하기 어렵다.
- 비용이 많이 든다.
- 단어의 미묘한 차이를 표현할 수 없다.
통계 기반 기법
통계기반기법에서는 말뭉치(corpus)를 이용한다. 말뭉치는 단순한 텍스트 데이터이지만, 사람이 작성한 문장들로 구성되어 있어 사람이 말을 사용하는 방식이 충분히 담겨있다.
파이썬으로 말뭉치 전처리하기
말뭉치 중 하나의 예시로 위키백과와 구글 뉴스 등의 텍스트 데이터를 들 수 있다. 이들을 잘 활용하기 위해서는 전처리가 필수적이다. 단계적으로 진행해보자.
먼저 모든 문자를 소문자로 변경한 이후 공백을 기준으로 분리하여 문장에 사용된 단어 목록을 만든다.
text = 'You say goodbye and I say hello.'
text = text.lower() # 모두 소문자로 변경
text = text.replace('.', ' .')
print(text) # you say goodbye and i say hello .
words = text.split(' ')
print(words) # ['you', 'say', 'goodbye', 'and', 'i', 'say', 'hello', '.']
단어들에 ID를 부여하고, 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
print(word_to_id) # {'you' 0, 'say' 1, 'goodbye' 2, 'and' 3, 'i' 4, 'hello' 5, '.' 6}
print(id_to_word) # {0 'you', 1 'say', 2 'goodbye', 3 'and', 4 'i', 5 'hello', 6 '.'}
위에서 사용한 문장을 ID를 이용해 나타내보자.
corpus = [word_to_id[w] for w in words]
corpus = np.array(corpus)
print(corpus) # [0 1 2 3 4 1 5 6]
위의 모든 기능을 모아 문장을 전처리하는 함수를 만들어보자.
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[word] for word 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)
전처리를 통해 단어 ID 목록(corpus), word_to_id 사전, id_to_word 사전을 얻을 수 있다.
단어의 분산 표현
색은 R, G, B로 구성할 수 있다. 이렇게 벡터표현으로 변경을 하면 적은 숫자로 많은 조합을 만들 수 있다. 단어도 벡터표현으로 변경할 수 있을까 벡터표현을 자연어처리 분야에서는 단어의 분산 표현(distributional representation)이라고 한다.
분포 가설
단어를 벡터로 표현할 때 ‘단어의 의미는 주변 단어에 의해 형성된다’는 아이디어로부터 시작한다. 이 아이디어를 분포 가설(distributional hypothesis)이라고 한다. 분포 가설이 말하는 것은 단어 자체에는 의미가 없고, 그 단어가 사용된 맥락이 의미를 형성한다는 것이다. 예를 들어 drink라는 단어 근처에는 음료가 등장하기 쉽다. guzzle 이라는 단어 근처에도 음료가 잘 등장한다. 여기서 guzzle과 drink는 가까운 의미의 단어라는 것도 알 수 있다.
여기서는 맥락이라는 말을 사용하는데, 특정 단어 근처의 여러 단어를 맥락이라고 한다.

동시발생 행렬
간단하게 맥락을 이용하는 방법은 그 주변에 어떤 단어가 몇 번이나 등장하는지를 세어 집계하는 방법이다. 이를 이 책에서는 ‘통계 기반’ 기법이라고 한다. 예시로 든 문장에서는 윈도우 크기가 1인 맥락에서 아래표처럼 주변 단어의 빈도를 얻을 수 있다. 이표를 동시발생행렬(co-occurrence matrix)라고 한다.

말뭉치로부터 동시발생 행렬을 만들어주는 함수를 구현해보자.
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
벡터 간 유사도
앞에서 동시발생 행렬을 통해 단어를 벡터로 표현하는 방법을 알아보았다. 계속해서 벡터 사이의 유사도를 측정하는 방법을 살펴본다. 단어 벡터의 유사도를 나타낼 때는 코사인 유사도를 자주 이용한다. 두 벡터 x, y 사이의 코사인 유사도는 다음 식으로 정의된다. 코사인 유사도는 -1에서 1사이의 값을 가진다.

# 코사인 유사도
def cos_similarity(x, y, eps=1e-8)
nx = x np.sqrt(np.sum(x2) + eps)
ny = y np.sqrt(np.sum(y2) + eps)
return np.dot(nx, ny)
예시 문장에서 you와 i의 유사도를 구해보자.
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.7071067758832467
유사 단어의 랭킹 표시
어떤 단어가 검색어로 주어지면, 그 검색어와 비슷한 단어를 유사도 순으로 출력하는 함수도 구현해보자.
def most_similar(query, word_to_id, id_to_word, word_matrix, top=5)
# 검색어를 꺼낸다.
if query not in word_to_id
print(%s(을)를 찾을 수 없습니다. % query)
return
print('[query] ' + query)
query_id = word_to_id[query]
query_vec = word_matrix[query_id]
# 코사인 유사도 계산
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)
# 코사인 유사도를 기준으로 내림차순으로 출력
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와 유사한 단어 상위 5개를 확인해보면 goodbye, i, hello 등이 있다. i와 you 모두 인칭대명사이므로 둘이 비슷하다는 것은 납득이 된다. 하지만 goodbye 와 hello의 코사인 유사도가 높다는 것은 이해가 잘 되지 않는다. 말뭉치의 크기를 더 늘리면 잘 작동하지 않을까
# you와 유사한 단어 상위 5개
most_similar('you', word_to_id, id_to_word, C, top=5)
#---------------------출력---------------------#
# [query] you
# goodbye 0.7071067758832467
# i 0.7071067758832467
# hello 0.7071067758832467
# say 0.0
# and 0.0
통계 기반 기법 개선하기
상호 정보량
앞 절에서의 동시발생 행렬은 두 단어가 동시에 발생한 횟수를 나타낸다. 그러나 발생 횟수라는것은 그리 좋은 특징이 아니다. 예를 들어 the와 car의 동시 발생 횟수는 아주 많다. 이는 우리가 원하는 결과가 아니다. 단순히 the가 고빈도 단어라서 car와 강한 관련성을 갖는다고 평가되기 때문이다. 이 문제를 해결하기 위해 점별 상호정보량(PMI)라는 척도를 사용한다. P(x)는 x가 일어날 확률, P(y)는 y가 일어날 확률, P(x, y)는 x와 y가 동시에 일어날 확률이다.

동시발생 행렬을 사용하여 PMI를 다시 써보자. C(x, y)는 단어 x와 y가 동시발생하는 횟수, C(x)와 C(y)는 각각 단어 x와 y의 등장횟수이다.

PMI에도 한 가지 문제가 있는데 두 단어의 동시발생횟수가 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 % (total100) == 0
print(f'{100cnttotal.0f}% 완료')
return M
W = ppmi(C)
print('동시발생 행렬')
print(C)
print('-'50)
print('PPMI')
print(W)
#---------------------출력---------------------#
# 동시발생 행렬
# [[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]]
# --------------------------------------------------
# PPMI
# [[0. 1.8073549 0. 0. 0. 0. 0. ]
# [1.8073549 0. 0.8073549 0. 0.8073549 0.8073549 0. ]
# [0. 0.8073549 0. 1.8073549 0. 0. 0. ]
# [0. 0. 1.8073549 0. 1.8073549 0. 0. ]
# [0. 0.8073549 0. 1.8073549 0. 0. 0. ]
# [0. 0.8073549 0. 0. 0. 0. 2.807355 ]
# [0. 0. 0. 0. 0. 2.807355 0. ]]
차원 감소
차원 감소(dimensionality reduction)는 정보는 최대한 유지하면서 벡터의 차원을 줄이는 방법이다. 차원을 감소시키는 방법은 여러가지지만 여기서는 특잇값분해(SVD)를 이용한다. SVD는 임의의 행렬을 세 행렬의 곱으로 분해하며, 수식으로는 아래와 같다.

여기서 U, V는 직교행렬이고, S는 대각행렬이다. U에는 단어 각각에 대한 정보가 들어있다. S 행렬의 대각성분에는 특잇값(singular value)이 큰 순서로 나열되어있다. 특잇값이란, 쉽게 ‘해당 축’의 중요도라고 생각할 수 있다. 따라서 차원감소를 할 때 중요도가 낮은 원소를 깎아내는 방법을 생각할 수 있다.

# SVD
U, S, V = np.linalg.svd(W)
# 동시발생 행렬
print(C[0]) # [0 1 0 0 0 0 0]
# PPMI 행렬
print(W[0]) # [0. 1.8073549 0. 0. 0. 0. 0. ]
print(U[0]) # [-3.4094876e-01 -1.1102230e-16 -3.8857806e-16 -1.2051624e-01 0.0000000e+00 9.3232495e-01 2.2259700e-16]
2차원 벡터로 줄이려면 단순히 처음의 두 원소를 꺼내면 된다.
print(U[0, 2]) # [-3.4094876e-01 -1.1102230e-16]
각 단어를 2차원 벡터로 표현한 후 그래프로 그려보자.그림을 보면 goodbye와 hello, you와 i가 가까이 있음을 알 수 있다. 우리의 직관과 비슷함을 알 수 있다. 더 큰 말뭉치에서 다시 진행해보자.
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()

PTB 데이터셋
PTB 말뭉치는 적절히 큰 말뭉치이다. PTB 데이터셋에는 몇 가지 전처리가 되어 있는데, 희소한 단어를 라는 특수문자로 치환하거나 구체적인 숫자를 N으로 대체하는 등의 작업이 적용되었다. (여기 코드에서는 저자가 제공하는 dataset.py 파일을 이용하였다.)
from dataset import ptb
corpus, word_to_id, id_to_word = ptb.load_data('train')
print('말뭉치 크기', len(corpus)) # 929589
print('corpus[30]', corpus[30]) # [ 0 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]
print()
print('id_to_word[0]', id_to_word[0]) # aer
print('id_to_word[1]', id_to_word[1]) # banknote
print('id_to_word[2]', id_to_word[2]) # berlitz
print()
print('word_to_id[car]', word_to_id[car]) # 3856
print('word_to_id[happy]', word_to_id[happy]) # 4428
print('word_to_id[lexus]', word_to_id[lexus]) # 7426
PTB 데이터셋 평가
결과를 보면 ‘you’라는 검색어에서는 인칭대명사인 ‘i’와 ‘we’가 상위를 차지했다. ‘year’의 연관어로는 ‘month’와 ‘quarter’가, ‘car’의 연관어로는 ‘auto’와 ‘vehicle’ 등이 봅혔다. 이처럼 단어의 의미 혹은 문법적인 과넘에서 비슷한 단어들이 가까운 벡터로 나타났다. 꽤나 잘 나오는 것을 알 수 있다.
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)
#---------------------출력---------------------#
# [query] you
# i 0.6710301637649536
# we 0.6254411935806274
# do 0.5747286081314087
# anybody 0.5510210990905762
# 've 0.508428156375885
# [query] year
# month 0.6649443507194519
# quarter 0.6307580471038818
# february 0.6111197471618652
# earlier 0.599685788154602
# last 0.5955864787101746
# [query] car
# auto 0.6284395456314087
# luxury 0.5839594602584839
# cars 0.5388651490211487
# vehicle 0.5209798216819763
# corsica 0.5109282732009888
# [query] toyota
# motor 0.7405445575714111
# nissan 0.680721640586853
# mazda 0.6518720388412476
# honda 0.6441175937652588
# motors 0.6432375311851501
'독서 > 밑바닥부터 시작하는 딥러닝 2' 카테고리의 다른 글
[밑바닥부터 시작하는 딥러닝 2]Chapter 6. 게이트가 추가된 RNN (0) | 2024.07.30 |
---|---|
[밑바닥부터 시작하는 딥러닝 2] Chapter 5. 순환 신경망(RNN) (6) | 2024.07.23 |
[밑바닥부터 시작하는 딥러닝 2] Chapter 4. word2vec 속도개선 (0) | 2024.07.14 |
[밑바닥부터 시작하는 딥러닝 2] Chapter 3. word2vec (0) | 2024.07.07 |
[밑바닥부터 시작하는 딥러닝 2] Chapter 1. 신경망 복습 (0) | 2024.06.23 |