Deep Learning
[밑바닥부터 시작하는 딥러닝2] Chapter 2. 자연어와 단어의 분산 표현
sara2601
2024. 6. 24. 22:06
다음은 [밑바닥부터 시작하는 딥러닝2]을 학습하고 정리한 내용입니다.
2.1 자연어 처리란
- NLP(자연어처리) : 인간의 말을 컴퓨터에게 이해시키기 위한 기술(분야)
- 대표적인 예 : Q&A 시스템, 문장 자동요약, 감정분석 등.
2.2 시소러스
- 시소러스(Thesaurus) : 유의어 사전. 동의어와 유의어가 한 그룹으로 분류되어 있다.
- 단어 사이의 '상위와 하위', '전체와 부분' 등 더 세세한 관계까지 정의해 둔 경우가 많음.
- 그림처럼 모든 단어에 대한 유의어 집합을 만든 다음, 단어들의 관계를 그래프로 표현하여 단어 사이의 연결을 정의할 수 있다. 그러면 이 '단어 네트워크'를 이용해 컴퓨터에게 단어 사이의 관계를 가르칠 수 있다.
- 더 자세히 알고싶다면 Stanford의 CS224N : Deep Learning for Natural Language Processing 수업을 참고하자.
2.2.1 WordNet
- 자연어 처리 분야에서 가장 유명한 시소러스. 이를 사용하면 유의어를 얻거나 '단어 네트워크'를 이용할 수 있으며, 단어 사이의 유사도도 구할 수 있다.
2.2.2 시소러스의 문제점
- 사람이 수작업으로 레이블링 해야 함. (cost가 높음)
- 시대의 변화에 대응하기 어렵다. (시대에 따라 언어의 의미가 변화하기도 함.)
- 단어의 미묘한 차이를 표현할 수 없음.
2.3 통계 기반 기법
- corpus(말뭉치) : 자연어 처리 연구를 염두에 두고 수집된 텍스트 데이터.
- 사람의 지식이 내포되어있는 말뭉치에서 효율적으로 자동으로 핵심을 추출하자.
2.3.1 파이썬으로 말뭉치 전처리하기
- 대표적 말뭉치 : Wikipedia, Google News ... etc
- 아래 preprocess() 함수 코드는 텍스트를 단어 목록 형태로 이용하기 위해 각 단어에 id를 부여하고, id의 리스트로 이용할 수 있도록 id_to_word : 단어id에서 단어로의 변환, word_to_id : 단어에서 단어 id로의 변환 2개의 딕셔너리를 생성하는 코드이다. 이후 자연어 단어 목록을 '단어 ID' 목록으로 변경한다.
import numpy as np
text = "You say goodbye and I say hello."
def preprocess(text):
text = text.lower()
text = text.replace('.', ' .') # 'you say goodbye and i say hello .'
words = text.split(" ") # words = ['you', 'say', 'goodbye', 'and', 'i', 'say', 'hello', '.']
# or just use re.split('(\W+)?'.text)
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
corpus, word_to_id, id_to_word = preprocess(text) # array([0, 1, 2, 3, 4, 1, 5, 6]), {'you': 0, 'say': 1, 'goodbye': 2, 'and': 3, 'i': 4, 'hello': 5, '.': 6}, {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}
2.3.2 단어의 분산 표현
- 단어의 분산 표현 : '단어의 의미'를 정확하게 파악할 수 있는 벡터 표현.
- 보통 고정 길이의 밀집 벡터(dense vector)로 표현한다.
2.3.3 분포 가설
- 분포 가설(distributional hypothesis) : 단어의 의미는 주변 단어에 의해 형성된다. 즉, 단어 자체에는 의미가 없고 그 단어가 사용된 맥락(context)이 단어의 의미를 형성한다는 것.
- ex) I guzzle beer / We guzzle wine 문장이 있다면 'guzzle'은 'drink'와 같은 맥락에서 사용되며 가까운 의미의 단어임을 알 수 있다.
- 맥락(context) : 특정 단어를 중심에 둔 주변 단어. 윈도우(Window)는 맥락의 크기를 의미한다. 즉 window=2면 좌우의 2개 단어, 총 4개가 맥락에 포함됨.
2.3.4 동시발생 행렬 (Co-occurrence Matrix)
- 통계 기반 기법 (Statistical based) : 어떤 단어를 주목했을 때, 주변에 어떤 단어가 몇번이나 등장했는지를 세어 집계
- 위의 예시를 그대로 활용해보면, 총 단어의 개수는 7개가 존재한다. Window=1로 뒀을 때 동시발생 행렬을 구해보자.
- 'say'가 중심단어일 경우 주변 단어는 'you', 'goodbye', 'i', 'hello'가 되며 이를 벡터 [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 - 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
C = create_co_matrix(corpus, vocab_size=7)
2.3.5 벡터 간 유사도
- 코사인 유사도(Cosine Similarity) : $\mathbf{x} = (x_1, x_2, \cdots, x_n)$과 $\mathbf{y} = (y_1, y_2, \cdots, y_n)$이 있을 때, $$ similarity(\mathbf{x}, \mathbf{y}) = \frac{ \mathbf{x} \cdot \mathbf{y} }{ ||\mathbf{x}|| \cdot ||\mathbf{y}|| } = \frac{x_1 y_1 + \cdots x_n y_n}{\sqrt{x_1^2 + x_2^2 + \cdots + x_n^2} \sqrt{y_1^2 + y_2^2 + \cdots + y_n^2} }$$
- 직관적 이해 : 두 벡터가 가리키는 방향이 얼마나 비슷한가. 두 벡터의 방향이 완전히 일치한다면 cosine similarity = 1이 됨.
- x와 y가 넘파이 배열이라고 할 때, 아래와 같이 코드를 작성할 수 있다. 제로 벡터가 들어올 경우 divide by zero 오류가 발생할 수 있기 때문에 분모에 아주 작은 값 eps = 1e-8을 더해준다.
- 이후 위에서 작성한 preprocess(), create_co_matrix(), cos_similarity() 함수로 'You'와 'i'의 코사인 유사도를 계산한 것이 다음과 같다. 현재 0.7071이기 때문에 두 단어는 유사성이 크다고 볼 수 있다.
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)
return np.dot(nx, ny)
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']]
c1 = C[word_to_id['i']]
print(cos_similarity(c0, c1)) # 0.7071067691154799
2.3.6 유사 단어의 랭킹 표시
- 검색어로 주어진 단어와 비슷한 단어를 유사도 순으로 출력해주는 함수를 구현해보자.
def most_similar(query, word_to_id, id_to_word, word_matrix, top=5):
"""
params
query : 검색어(단어)
word_to_id : 단어에서 단어 ID로의 딕셔너리
id_to_word : 단어 ID에서 단어로의 딕셔너리
word_matrix : 단어 벡터들을 한데 모은 행렬, 각 행에는 대응하는 단어의 벡터가 저장되어있다고 가정한다.
top : 상위 몇 개 까지 출력할지 설정
"""
# 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
most_similar('you', word_to_id, id_to_word, C, top=5)
# [query] you
# goodbye: 0.7071067691154799
# i: 0.7071067691154799
# hello: 0.7071067691154799
# say: 0.0
# and: 0.0
2.4 통계 기반 기법 개선하기
2.4.1 상호정보량
- 동시발생 행렬 : "발생" 횟수라는 것은 사실 그리 좋은 특징이 아니다. 예를 들면, "the"와 "car"라는 단어의 동시발생을 생각해 보자. "car"와 "drive"가 매우 관련이 깊음에도 불구하고 단순히 등장 횟수만 본다면 "the"가 고빈도 단어이기 때문에 "car"는 "the"와의 관련성이 더 강하다고 나올 것이다.
- 이 문제 해결을 위해 점별 상호정보량(PMI)이라는 척도를 사용한다. 이 값은 높을수록 관련성이 높음을 의미한다. $P(x)$ : 단어 $x$가 말뭉치에 등장할 확률, $P(x, y)$ : 단어 $x, y$가 동시 발생할 확률일 때,
$$PMI(x, y) = \log_2 \frac{P(x, y)}{P(x) P(y)} = \log_2 \frac{ \frac{C(x, y)}{N}}{ \frac{C(x)}{N} \frac{C(y)}{N} } = \log_2 \frac{C(x, y) \cdot N}{ C(x) C(y) } $$ - 그렇다면 위의 "the", "car", "drive" 예제의 PMI를 계산해보자. N = 10000, C("the") = 1000, C("car") = 20, C("drive") = 10, C("the", "car") = 10, C("car", "drive") = 4라 가정하자.
$PMI(the, car) = \log_2 \frac{10 \times 10000}{1000 \times 20} \approx 2.32$, $PMI(car, drive) = \log_2 \frac{5 \times 10000}{20 \times 10} \approx 7.97$ 가 되어 "car"는 "the"보다 "drive"와의 관련성이 강해진다. - 양의 상호정보량(PPMI) : PMI의 경우 두 단어의 동시발생 횟수가 0이면 $\log_2 0 = -\infty$가 되는 문제를 피하기 위해 실제 구현에서 사용하는 방법. $PPMI(x, y) = max(0, PMI(x, y))$
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
W = ppmi(C)
- 하지만, 어휘 수가 증가하면 단어 벡터의 차원 수가 증가한다. 또한, 원소 대부분이 0인 sparse matrix이기 때문에 벡터의 원소 대부분이 중요하지 않다는 의미이다. 이런 벡터는 노이즈에 약하고 견고하지 못한 약점이 있다. 이를 해결하고자 자주 수행하는 기법이 바로 벡터의 차원 감소이다.
2.4.2 차원 감소(Dimensionality Reduction)
- '중요한 정보'는 최대한 유지하면서 벡터의 차원을 줄이는 방법. 직관적으로, 다음 그림처럼 데이터의 분포를 고려해, 데이터의 설명력을 가장 잘 보존하는 "축"을 찾는 일을 수행한다.
- 새로운 축을 도입하여 동일한 데이터를 하나의 좌표축으로 표시했다. 이때 각 데이터점의 값은 새로운 축으로 사영(projection)된 값으로 변한다. 가장 중요한 것은 적합한 축을 찾아내는 것으로, 1차원 값만으로도 데이터가 구분될 수 있어야 한다. Sparse vector에서 중요한 축을 찾아내 더 적은 차원으로 재표현 할 경우 이제 dense vector로 재표현된다.
- 특이값 분해(Singular Value Decomposition, SVD) : 대표적인 차원 축소 방법론.
$\mathbf{X} = \mathbf{USV}^T$ where $\mathbf{U}, \mathbf{V}$ : 직교행렬(Orthgonal matrix)이며, 그 열벡터는 서로 직교한다. $\mathbf{S}$ : 대각행렬(Diagonal matrix) - 직교행렬 $\mathbf{U}$는 단어 공간의 축(기저)을 형성한다. $\mathbf{S}$ 대각행렬의 대각성분에는 특잇값(singular value)이 큰 순서대로 나열되어있다. 따라서 중요도가 높은 원소들을 남김으로써 차원 축소가 가능하다.
- 더 자세한 수식적 증명은 기회가 된다면 블로그 포스팅으로 업로드 예정.
2.4.3 SVD에 의한 차원 감소(축소)
- numpy의 linalg 모듈이 제공하는 svd 메서드로 실행 가능. 결과에서 보듯 sparse vector W[0]이 SVD에 의해 dense matrix U[0]로 변했다. 이 밀집벡터의 차원을 감소시키려면, 단순히 처음의 두 원소를 꺼내면 된다.
C = create_co_matrix(corpus, vocab_size, window_size=1)
W = ppmi(C)
U, S, V = np.linalg.svd(W)
print(C[0]) # [0 1 0 0 0 0 0]
print(W[0]) # [0. 1.807 0. 0. 0. 0. 0.]
print(U[0]) # [ 3.4094876e-01 -1.1102230e-16 -1.2051624e-01 -4.1633363e-16
# -9.3232495e-01 -1.1102230e-16 -2.4257469e-17]
print(U[0, :2]) # [ 3.4094876e-01 -1.1102230e-16]
2.4.4 PTB 데이터셋
- 펜 트리뱅크(Penn Treebank, PTB) : 주어진 기법의 품질을 측정하는 벤치마크로 자주 이용되는 말뭉치 텍스트 파일. 한 문장이 하나의 줄에 저장되어있고, 각 문장 끝에 <eos>라는 특수문자 토큰이 추가되어있다.
corpus, word_to_id, id_to_word = 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'])
2.4.5 PTB 데이터셋 평가
- 큰 행렬에 SVD를 적용해야 하기 때문에 sklearn의 고속 SVD를 이용하자.
- sklearn의 randomized_svd는 무작위 수를 사용한 Truncated SVD로 특잇값이 큰 것들만 계산하여 기본 SVD보다 훨씬 빠르다. (random number 사용하기 때문에 결과가 매번 다름)
import numpy as np
window_size = 2
wordvec_size = 100
corpus, word_to_id, id_to_word = 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)
- 결과 상 "you" 검색어에 대해 인칭대명사 "we", "i"가 상위를 차지한다. 또 "year"의 연관어로는 "week"과 "month"가, "car"의 연관어로는 "auto"와 "truck" 등이 뽑힌다. 이처럼 단어의 의미, 문법적인 관점에서 비슷한 단어들이 가까운 벡터로 나타났다.
- 대규모 말뭉치를 사용하면 단어의 분산표현의 품질이 더 좋아질 것.
2.5 정리
- WordNet 등의 시소러스를 이용하면 유의어를 얻거나 단어 사이의 유사도를 측정하는 등 유용한 작업을 할 수 있다.
- 시소러스 기반 기법은 시소러스를 작성하는 데 엄청난 인적 자원이 든다거나 새로운 단어에 대응하기 어렵다.
- 현재는 말뭉치를 이용해 단어를 벡터화하는 방식이 주로 쓰인다.
- 최근의 단어 벡터화 기법들은 대부분 '단어의 의미는 주변 단어에 의해 형성된다'는 분포 가설에 기초한다.
- 통계 기반 기법은 말뭉치 안의 각 단어에 대해서 그 단어의 주변 단어의 빈도를 집계한다(동시 발생 행렬)
- 동시 발생 행렬을 PPMI 행렬로 변환하고 다시 차원을 감소시킴으로써, 거대한 '희소벡터'를 작은 '밀집벡터'로 변환할 수 있다.
- 단어의 벡터 공간에서는 의미가 가까운 단어는 그 거리도 가까울 것으로 기대된다.