본문 바로가기
Deep Learning/밑바닥부터 시작하는 딥러닝

딥러닝 등장 이전의 자연어와 단어의 분산 표현

by 대소기 2022. 2. 1.

 

 

단어의 의미

* 자연어처리는 컴퓨터에게 인간의 언어를 이해할 수 있게 만드는 기술이다. 컴퓨터가 인간의 언어를 이해하기 위해서는 먼저 문장의 최소 단위인 단어를 이해하는 법을 알아야 할 것이다. 우리가 다루게 될 컴퓨터가 단어를 이해할 수 있도록 인간의 단어를 표현하는 방법은 아래와 같이 3가지이다.

1) 시소러스(thesaurus)를 활용한 기법

- 인간이 사전에 만든 유의어 사전인 시소러스를 활용

2) 통계 기반 기법

3) 추론 기반 기법(word2vec)

- 신경망 이용(다음 포스팅에서 다룰 것임)

* 먼저 시소러스부터 살펴보자.

시소러스(thesaurus) 기법

* 시소러스는 우리가 사용하는 일반적인 국어사전과 같이 단어와 단어의 뜻이 기술되어있는 것이 아니라 유의어 관계를 정의해 놓은 사전을 뜻한다. 예) car는 auto, automobile, machine, motorcar와 동의어

* 시소러스는 이렇게 단순히 유의성 뿐만 아니라 '상위와 하위', '전체와 부분' 등의 관계를 정의하기도 한다.

* 이와 같이 모든 단어에 대한 시소러스 집합을 생성하고, 단어들의 관계를 그래프로 표현하여 단어 사이의 연결을 정의한다.

WordNet

* 자연어 처리 분야에서 가장 유명한 시소러스로 프린스턴 대학교에서 1985년부터 구축하기 시작했다.

시소러스의 문제점

* 시소러스는 사람이 직접 레이블링 하는 방식을 사용하기 때문에 다음과 같은 문제점이 발생한다.

1) 시대 변화에 대응하기 어렵다.

- 시대에 따라 달라지는 단어의 의미, 신조어 등을 반영하는 것을 직접 해줘야 함.

2) 인적 비용

- 수작업에 따른 인적 비용이 만만치 않다.

3) 단어의 미묘한 차이를 표현할 수 없다.

- 유의어 관계에서도 각 단어들 간에 미묘한 뉘앙스 차이가 존재하는데, 이를 반영하지 못한다.

통계 기반 기법

* 통계 기반 기법을 이용하기 위해서는 말뭉치(corpus)를 사용하게 된다. 말뭉치는 자연어처리를 위해 선별적으로 수집된 텍스트 데이터이다.

* 말뭉치 안의 텍스트에는 문법, 단어 선택법, 단어의 의미 등 복합적인 정보가 들어있고, 이를 활용하여 말뭉치에서 자동으로, 효율적으로 핵심을 추출하는 것이 통계 기반 기법의 중요한 포인트이다.

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

* 말뭉치에는 여러 개의 문장이 들어있는 것이 일반적이지만, 간단한 구현을 위해 하나의 문장을 사용한다.

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', '.']

* 문장을 단어 단위로 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

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_to_word, word_to_id dictionary를 생성하였다.

corpus = [word_to_id[w] for w in words]
corpus = np.array(corpus)
corpus 

# array([0, 1, 2, 3, 4, 1, 5, 6])

* corpus에 단어 id array를 할당하였다.

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

* 위에서 생성한 코드들을 묶어 함수로 만들면 위와 같다.

단어의 분산 표현

* 단어의 의미를 정확하게 표현할 수 있는 벡터 표현을 단어의 분산 표현(distributional representation)이라고 한다.

* 우리는 이 벡터 표현법인 분산 표현을 통해 단어간의 유사성 등을 확인할 것이다.

분포 가설

* 분포 가설이란 단어의 의미는 주변 단어에 의해 형성된다는 가설이다.

* 예를 들어 drink 주변에는 음료가 올 가능성이 높다. I drink wine, I drink water 등과 같은 문장에서 drink 주변에 있는 wine, water는 모두 음료에 해당한다.

* 앞으로 맥락이란 말을 사용하게 될텐데 맥락은 단어 주변에 위치한 단어들을 가리킨다. 주변의 정확한 범위는 window size를 통해 정의한다.

* 위 예시는 goodbye에 주목할 때 window size가 2인 맥락을 나타낸 자료이다.

* 지금 우리는 맥락에 해당하는 단어는 window size만큼의 좌측 단어, 우측 단어를 예로 들었지만, 실제로는 왼쪽 단어만 보거나, 오른쪽 단어만 보는 것들이 가능하다.

동시발생 행렬(Co-Occurence Matrix)

* 특정 단어를 기준으로 했을 때 맥락으로써 동시에 발생(등장)하는 단어의 빈도를 벡터로 나타내고, 각 단어별 벡터들을 모은 것을 동시발생 행렬이라고 한다. 말로 설명하면 어려울 수 있지만, 아래 그림을 보면 이해하기 쉬워질 것이다.

* 'you say goodbye i say hello.' 라는 문장에서 say를 기준으로 보면 you, goodbye, i, hello가 동시에 맥락으로서 등장한다. 이를 벡터로 정리하면 위와 같다.

* 또한 이런 벡터들을 모아서 행렬로 정리하면 아래와 같아질 것이다.

* 말뭉치로부터 동시발생 행렬을 생성하는 python code는 아래와 같이 구현 가능하다.

def create_co_matrix(corpus, vocab_size, window_size=1):
    '''동시발생 행렬 생성
    :param corpus: 말뭉치(단어 ID 목록)
    :param vocab_size: 어휘 수
    :param window_size: 윈도우 크기(윈도우 크기가 1이면 타깃 단어 좌우 한 단어씩이 맥락에 포함)
    :return: 동시발생 행렬
    '''
    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

벡터 간 유사도

코사인 유사도

* 벡터 간 유사도를 측정하는 방법으로는 내적, 유클리드 거리 등 여러 방법을 사용할 수 있지만, 단어 벡터의 유사도를 나타낼 때는 코사인 유사도를 주로 사용한다.

* 코사인 유사도값은 내적을 하되(분자), 벡터를 정규화 하는 것(분모로 나눠줌으로서) 을 뜻한다.

* 두 벡터간의 코사인 유사도를 구하는 함수를 python code로 구현하면 아래와 같다.

def cos_similarity(x, y, eps=1e-8):
    '''코사인 유사도 산출
    :param x: 벡터
    :param y: 벡터
    :param eps: '0으로 나누기'를 방지하기 위한 작은 값
    :return:
    '''
    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.707106...

유사 단어의 랭킹 표시

def most_similar(query, word_to_id, id_to_word, word_matrix, top=5):
    '''유사 단어 검색
    :param query: 쿼리(텍스트)
    :param word_to_id: 단어에서 단어 ID로 변환하는 딕셔너리
    :param id_to_word: 단어 ID에서 단어로 변환하는 딕셔너리
    :param word_matrix: 단어 벡터를 정리한 행렬. 각 행에 해당 단어 벡터가 저장되어 있다고 가정한다.
    :param top: 상위 몇 개까지 출력할 지 지정
    '''
    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
    # 내림차순 정렬을 위해 -1을 곱해줌
    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'와 유사한 단어들을 출력해보자.

import sys
sys.path.append('..')
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)


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

* 결과를 보면 i는 인칭대명사이기 때문에 유사도가 높은 것이 납득이 가지만, goodbye와 hello가 유사도가 높은 것은 조금 이해가 가지 않는다. 이는 현재 말뭉치의 크기가 작기 때문이다. 더 큰 말뭉치를 사용해 실험을 해보자.

 

통계 기반 기법 개선하기

 

상호정보량(Mutual Information)

 

* 동시발생 행렬을 만들 때 사용하는 '동시발생'이라는 개념은 사실 문제점이 있다. 바로 빈도에만 집중한다는 문제이다.예를 들어 car라는 단어를 볼 때 the car 두 단어가 동시발생하는 빈도가 drive 와 car가 동시발생하는 빈도가 훨씬 많을 것이다. 이에 따라 the가 car와 유사도가 높다고 판단할 수 있는데, 정말 그러한가? drive와 car가 훨씬 유사도가 높아 보인다. 이처럼 빈도에만 집중하는 것은 유사도를 잘 못 판단할 가능성이 있다.

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

 

$PMI(x, y) = log_2 {P(x, y) \over P(x) P(y)}$

 

* 여기서 P(x, y)는 x와 y가 동시에 발생할 확률을 나타낸다.

* PMI는 각 단어의 발생 확률을 나눠주기 때문에 (the, car)와 (drive, car)의 예에서 발생했던 빈도만 고려하는 문제가 해결 가능해진다. the의 경우 발생빈도가 높기 때문에 발생확률 P(the)가 클 것이고, 이에 따라 PMI가 작아질 것이다.

* 위 PMI 식을 단어의 발생빈도 C로 표현하면 아래와 같이 바꿀 수 있다.

 

$PMI(x, y) = log_2 {{C(x, y) \over N} \over {{C(x) \over N}, {C(y) \over N}}} = log_2 {C(x, y) \bullet N \over C(x) C(y)} $ 

 

* 여기서 N은 말뭉치에 포함된 단어의 수를 뜻한다.

* 하지만, PMI도 문제점이 하나 있는데, 바로 동시발생횟수 C(x, y)가 0이면 $log_2 0 = - \inf$가 된다는 점이다. 이를 개선하기 위해 다음과 같은 방법을 사용한다.

* 양의 상호정보량 (Positive PMI, PPMI) 은 PMI가 음수일 때는 0으로 취급하는 방법이다.

 

$PPMI(x, y) = max(0, PMI(x, y))$

 

* PPMI를 통해 $-inf$가 되는 상황을 모두 0으로 처리할 수 있다.

 

def ppmi(C, verbose=False, eps = 1e-8):
    '''PPMI(점별 상호정보량) 생성
    :param C: 동시발생 행렬
    :param verbose: 진행 상황을 출력할지 여부
    :return:
    '''
    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
    

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)

# 동시발생 행렬
# [[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.807 0.    0.    0.    0.    0.   ]
#  [1.807 0.    0.807 0.    0.807 0.807 0.   ]
#  [0.    0.807 0.    1.807 0.    0.    0.   ]
#  [0.    0.    1.807 0.    1.807 0.    0.   ]
#  [0.    0.807 0.    1.807 0.    0.    0.   ]
#  [0.    0.807 0.    0.    0.    0.    2.807]
#  [0.    0.    0.    0.    0.    2.807 0.   ]]

* 이렇게 동시발생 행렬을 PPMI행렬로 변환하는 법까지 알아보았다. 하지만, PPMI행렬도 문제점이 있다. 말뭉치의 단어 수가 증가함에 따라 행렬의 크기가 과도하게 커진다는 점이다. 만약 10만개의 단어가 있다면, 10만차원의 벡터로 이뤄진 행렬을 다뤄야 한다.

* 행렬의 크기가 큰 것도 문제이지만, 대부분의 값이 0인 sparse matrix라는 점도 문제점이다. 이를 해결하기 위해서 차원축소 기법을 사용한다.

 

 

 

차원 감소(Dimension Reduction)

* 차원 감소는 데이터의 차원을 줄이되, 중요한 정보를 최대로 유지하면서 줄이는 기법을 뜻한다. 

* 직관적으로 2차원 그래프를 통해 살펴보자면 위와 같이 2차원에서 1차원으로 차원을 축소할 때 관측값들을 설명할 수 있는 축을 찾는 것이 주요하다고 할 수 있겠다.

* 이렇게 차원을 축소하는 과정에서 대부분의 값이 0인 sparse vector는 dense vector로 변환된다.

* 여러가지 차원 감소 기법들이 있지만, 우리는 SVD(Singular Value Decomposition)를 이용한다. 

 

SVD(Singular Value Decomposition)

 

$X=USV^T$

 

* SVD는 임의의 행렬 X를 U, S, V 세 행렬의 곱으로 분해하는 방법으로, U와 V는 orthogonal matrix이고, 열벡터는 서로 직교한다. S는 diagonal matrix이다. 이를 시각적으로 표현하면 아래와 같다.

* U orthogonal matrix는 어떠한 공간의 축을 형성한다. 우리가 공부하고 있는 내용에 적용해보면 단어공간으로 취급할 수 있다.

* S diagonal matrix에는 diagonal에 singular value가 큰 순서로 나열되어 있다. sigular value는 해당 축의 중요도라고 간주할 수 있다.

* 특이값이 작다는 것은 중요도가 낮다는 것을 뜻하기 때문에 S에서 특이값이 작은 부분을 깎아내고, U에서 해당 부분 만큼의 열벡터를 깎아내고, $V^T$에서 행 벡터를 깎아내면 벡터 X를 근사할 수 있다(곱했을 때 shape은 동일하기 때문). 

* 만약 단어의 PPMI 행렬에 적용한다면, 행렬 X의 각 행에는 해당 단어 ID의 단어 벡터가 저장되어 있고, 그 단어 벡터가 행렬 U'라는 차원이 감소된 벡터로 표현된다고 할 수 있다.

 

 

 

 

SVD에 의한 차원 감소 구현

import numpy as np
import matplotlib.pyplot as plt

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)

np.set_printoptions(precision=3)  # 유효 자릿수를 세 자리로 표시
print(C[0])
print(W[0])
print(U[0])

# [0 1 0 0 0 0 0]
# [0.    1.807 0.    0.    0.    0.    0.   ]
# [-1.110e-16  3.409e-01 -4.163e-16 -1.205e-01 -1.110e-16 -9.323e-01 -1.086e-16]

* python numpy에서 제공하는 svd() 메소드를 통해 sparse vector 를 dense vector로 변환하였다.

* 만약 이 dense vector의 차원을 감소시키려면 인덱싱을 사용해 원하는 차원 n만큼의 원소를 꺼내 사용하면 된다.

print(U[0, :2])
# [-1.110e-16  3.409e-01]

 

* 각 단어를 2차원 vector로 표현한 후 그래프로 그려보자.

 

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

 

* hello, goodbye, you 등이 비교적 가까이 있지만, 말뭉치가 작은 상태에서 시행한 차원축소이기 때문에 신뢰성이 좀 떨어진다. 더 큰 말뭉치 데이터를 사용해보자.

 

 

 

PTB 데이터셋

* PTB(Penn Treebank)는 word2vec의 발명자인 Thomas Mikolov의 웹 페이지에서 다운받을 수 있다. 해당 데이터는 원본 PTB에 몇 가지 전처리를 해 놓은 데이터이다. 희소한 단어를 <unk>로 치환하거나, 구체적인 숫자를 'N'으로 대체하는 등의 전처리를 해 두었다.

* PTB 말뭉치에서는 한 문장이 하나의 줄로 저장되어 있고, 각 문장 끝에는 <eos> 토큰이 삽입되어 있다. 말뭉치는 각 문장을 연결한 '하나의 큰 시계열 데이터'로 취급한다.

 

import sys
sys.path.append('..')
import numpy as np
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)
    
# 동시발생 수 계산 ...
# PPMI 계산 ...
# 1.0% 완료
# 2.0% 완료
# 3.0% 완료
# 4.0% 완료
# 5.0% 완료
# 6.0% 완료
# 7.0% 완료
# 8.0% 완료
# 9.0% 완료
# 10.0% 완료

# ... 중략

# 90.0% 완료
# 91.0% 완료
# 92.0% 완료
# 93.0% 완료
# 94.0% 완료
# 95.0% 완료
# 96.0% 완료
# 97.0% 완료
# 98.0% 완료
# 99.0% 완료
# calculating SVD ...

# [query] you
#  i: 0.6771411299705505
#  we: 0.623031735420227
#  do: 0.6072342395782471
#  anybody: 0.5368391275405884
#  something: 0.4829496145248413

# [query] year
#  month: 0.6991046071052551
#  quarter: 0.6471530795097351
#  earlier: 0.620699405670166
#  months: 0.613271951675415
#  last: 0.5946714878082275

# [query] car
#  auto: 0.6562137603759766
#  luxury: 0.5974475145339966
#  corsica: 0.5970813035964966
#  cars: 0.5780744552612305
#  domestic: 0.5703691244125366

# [query] toyota
#  motor: 0.7260446548461914
#  nissan: 0.6947320103645325
#  motors: 0.6893180012702942
#  lexus: 0.6194877624511719
#  honda: 0.6071298122406006

* svd 메소드를 사용할 때 np.linalg.svd() 보다는 sklearn.utils.extmath.randomized_svd()를 사용하는 것이 훨씬 빠르다. 무작위수를 사용한 truncated SVD로 큰 것들만 계산하기 때문이다. 우리는 대용량 데이터를 사용하고 있기 때문에 sklearn의 randomized_svd() 메소드를 사용할 것이다.

 

 

* 출력된 단어별 유사도를 보면 corpus의 크기가 작을 때보다 훨씬 더 납득이 가능한 결과가 나왔다는 것을 알 수 있다.

* 이렇게 단어의 의미를 벡터로 인코딩 하는 것을 완료하였다. 우리가 배웠던 내용들을 다시 한 번 살펴보자.

 

1.  단어를 벡터로 표현하기 위해 동시발생 행렬을 생성하였다. 하지만, 동시발생 행렬은 빈도에만 집중한다는 문제가 있었다.

2. 그래서 PMI, PPMI를 사용하였다. 이는 동시발생 빈도 뿐만 아니라 각 단어의 발생빈도까지 고려한 지표였다. 하지만 또한 sparse matrix라는 문제가 있었다.

3. sparse matrix를 dense matrix로 변환하기 위해 SVD를 사용하여 dimension reduction을 시행하였다. 이를 통해 dense matrix를 구할 수 있었고 이를 통해 유사도를 측정하는 실험을 해 보았다.

'Deep Learning > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글

word2vec 속도개선  (0) 2022.02.10
Word2Vec  (0) 2022.02.03
합성곱 신경망(CNN)  (0) 2022.01.30
Optimizer  (0) 2022.01.27
Backpropagation  (0) 2022.01.27