밑바닥부터 시작하는 딥러닝 - 자연어(2)

벡터 간 유사도

이전 글에서 동시발생 행렬을 활용해 단어를 벡터로 표현하는 방법을 알아봤다.

이번 글에서는 벡터 간 유사도에 대해 알아볼 것이다.

벡터 사이의 유사도를 측정하는 방법은 다양하다. 대표적으로 벡터의 내적이나 유클리드 거리가 있다. 그 외에도 단어 벡터의 유사도를 나타낼 때는 코사인 유사도를 자주 사용한다.

코사인 유사도

위의 식에서 분자는 벡터의 내적, 분모에는 각 벡터의 노름(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)

이 코드에서 인수 x와 y는 넘파이 배열이라고 가정한다. 먼저 벡터 x와 y를 정규화한 후 두 벡터의 내적을 구한다.

이렇게만 해도 코사인 유사도를 구할 수 있지만, 문제가 하나 있다.

인수로 제로 벡터(원소가 모두 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)
    return np.dot(nx, ny)

여기에선 eps를 인수로 받고, 기본값으로 1e-8이 설정되도록 했다.

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

'you'와 'i'의 코사인 유사도는 약 0.71이 나왔다. 코사인 유사도 값은 -1 ~ 1 사이이므로, 이 값은 비교적 높다(유사성이 크다)라고 말할 수 있을 것이다.


유사 단어의 랭킹 표시

코사인 유사도를 구했으니 어떤 단어가 검색어로 주어지면, 그 검색어와 비슷한 단어를 유사도 순으로 출력하는 함수를 작성해볼 것이다.

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('\n[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
  1. 검색어의 단어 벡터를 꺼낸다.
  2. 검색어의 단어 벡터와 다른 모든 단어 벡터와의 코사인 유사도를 각각 구한다.
  3. 계산한 코사인 유사도 결과를 기준으로 값이 높은 순서대로 출력한다.

3의 코드에서 similarity 배열에 담긴 원소의 인덱스를 내림차순으로 정렬한 후 상위 원소들을 출력한다. 이때 배열 인덱스의 정렬을 바꾸는 데 사용한 argsort() 메서드는 넘파이 배열의 원소를 오름차순으로 정렬한다.

 

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)

'you'와 유사한 단어를 상위 5개만 출력한 것이다. 결과를 보면 'goodbye', 'i', 'hello'가 'you'와 가장 가까운 단어로 나왔다.

 

지금까지 동시발생 행렬을 이용하면 단어를 벡터로 표현할 수 있는 것을 알아봤다.

 


통계 기반 기법 개선하기

상호 정보량

앞 절에서 본 동시발생 행렬의 원소는 두 단어가 동시에 발생한 횟수를 나타낸다.

그러나 이 '발생' 횟수라는 것은 좋은 특징이 아니다. 고빈도 단어(많이 출현하는 단어)로 살펴보면 이유를 알 수 있다.

'the'와 'car'의 동시발생의 경우로 생각해보자.

"... the car..."라는 문구가 있을 것이다. 따라서 두 단어의 동시발생 횟수는 아주 많을 것이다.

한편 'car'와 'drive'는 확실히 관련이 깊다. 하지만 단순히 등장 횟수만을 본다면 'car'는 'drive'보다 'the'와의 관련성이 훨씬 강하다고 나올 것이다. 'the'가 고빈도 단어라서 'car'와 강한 관련성을 갖는다고 평가되기 때문이다.

이 문제를 해결하기 위해 점별 상호 정보량(PMI)이라는 척도를 사용한다.

PMI

 

위의 결과에서 보면 PMI를 사용하면 'car'는 'the'보다 'drive'와의 관련성이 강해진다. 이러한 결과가 나온 이유는 단어가 단독으로 출현하는 횟수가 고려되었기 때문이다. 'the'가 자주 출현했으므로 PMI 점수가 낮아진 것이다.

그럼 코드로 구현해보자.

# PMI 함수로 구현

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 + 1) == 0:
                    print('%.1f%% 완료' % (100 * cnt / total))
    return M

여기에서 인수 C는 동시발생 행렬, verbose는 진행상황 출력 여부를 결정하는 플래그이다. True로 설정하면 중간중간 진행 상황을 알려준다.

그럼, 동시발생 행렬을 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)

이렇게 동시발생 행렬을 PPMI 행렬로 변환하는 방법을 알아봤다.

하지만 PPMI 행렬에도 문제가 있다.

말뭉치의 어휘 수가 증가함에 따라 각 단어 벡터의 차원 수도 증가한다는 문제이다. 예를 들어 말뭉치의 어휘 수가 10만 개라면 그 벡터의 차원 수도 똑같이 10만이 된다.

이 문제에 대처하고자 자주 수행하는 기법이 벡터의 차원 감소이다.

 


차원 감소

차원 감소란 문자 그대로 벡터의 차원을 줄이는 방법이다. 단순히 줄이기만 하는 게 아니라, '중요한 정보'는 최대한 유지하면서 줄이는 것이 핵심이다.

차원을 감소시키는 방법은 주로 특이값 분해(SVD)를 사용한다. SVD는 임의의 행렬을 세 행렬의 곱으로 분해한다.

SVD는 임의의 행렬 X를 U, S, V라는 세 행렬의 곱으로 분해한다. 여기서 U와 V는 직교 행렬이고, 그 열 벡터는 서로 직교한다. 또한 S는 대각 행렬이다. 이 수식을 시각적으로 표현한 것이 아래 그림과 같다.

행렬 S에서 특이값이 작다면 중요도가 낮다는 뜻이므로, 행렬 U에서 여분의 열 벡터를 깎아내어 원래의 행렬을 근사할 수 있다.

 


PTB 데이터셋

지금까지 아주 작은 텍스트 데이터를 말뭉치로 사용했다. 지금부턴 펜트리 뱅크(PTB) 데이터셋을 사용할 것이다.

PTB 말뭉치는 word2vec의 발명자인 토마스 미콜로프의 웹 페이지에서 받을 수 있다.

위의 그림에서 볼 수 있듯이 PTB 말뭉치에서는 한 문장이 하나의 줄로 저장되어 있다.

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 목록이 저장된다. id_to_word는 단어 ID에서 단어로 변환하는 딕셔너리이고, word_to_id는 단어에서 단어 ID로 변환하는 딕셔너리이다.

 

PTB 데이터셋 평가

통계 기반 기법을 적용해보자. 큰 행렬에 SVD를 적용해야 하므로 sklearn 모듈에 있는 SVD를 사용하자.

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)

이 코드는 SVD를 수행하는 데 sklearn의 randomized_svd() 메서드를 이용했다. 이 메서드는 무작위 수를 사용한 Truncated SVD로, 특이값이 큰 것들만 계산하여 기본적인 SVD보다 훨씬 빠르다.

결과를 보면 우선 'you'라는 검색어에서는 인칭대명사인 'i'와 'we'가 상위를 차지했음을 알 수 있다.

영어 문장에서 관용적으로 자주 같이 나오는 단어들이다.

또 'year'의 연관어로는 'month'와 'next'가 나왔고, 'car'의 연관어로는 'auto', 'luxury' 등이 뽑혔다.

'toyota'의 연관어로는 'honda', 'nissan', 'lexus'와 같은 자동차 제조 업체나 브랜드가 뽑힌 것도 알 수 있다.

 


정리

저번 글과 이번 글을 통해 기본적인 자연어에 대한 주제로 공부했다.

시소러스 기반 기법에서는 단어들의 관련성을 사람이 수작업으로 하나씩 정의한다. 이 작업은 매우 힘들고 표현력에도 한계가 있다.

한편, 통계 기반 기법은 말뭉치로부터 단어의 의미를 자동으로 추출하고, 그 의미를 벡터로 표현한다. 구체적으로는 단어의 동시발생 행렬을 만들고, PPMI 행렬로 변환한 다음, 안정성을 높이기 위해 SVD를 이용해 차원을 감소시켜 각 단어의 분산 표현을 만들어낸다.

 

또한 단어의 벡터 공간에서 의미가 가까운 단어는 그 거리도 가깝다는 것도 알게 되었다.

TAGS.

Comments