안녕, 세상!

(2)-2. 자연어와 단어의 분산 표현 본문

It공부/Deep learning

(2)-2. 자연어와 단어의 분산 표현

dev_Lumin 2020. 6. 30. 20:58

(1) 자연어 처리 (Natural Language Processing)

자연어 : 우리가 평소에 쓰는 말

일반적인 프로그래밍 언어는 기계적이고 고정되어 있습니다.

반면, 자연어는 똑같은 의미의 문장도 여러 형태로 표현할 수 있거나, 문장의 뜻이 애매할 수도 있고, 의미나 형태가 유연하게 바뀌는 부드러운 언어입니다.

자연어 처리는 우리의 말을 컴퓨터에게 이해시키기 위한 기술입니다.

 

우리의 말은 문자로 구성되며 말의 의미는 단어로 구성됩니다.

컴퓨터한테 단어의 의미를 잘 파악하는 표현 방법은 시소러스, 통계 기반 기법, 추론 기반 기법 등 있습니다.

 

 

 

(2) 시소러스

사전이 단어의 각각 의미를 설명하는 것과 같이 사람이 직접 단어의 의미를 정의하는 방식을 사용하는 방법입니다.

이를 유의어로 통해 컴퓨터에게 의미를 파악하도록 합니다.

시소러스는 유의 사전으로 동의어나 유의어가 한 그룹으로 분류되어 있습니다.

예를 들어 car 이란 단어에 auto, automobile, machine, motorcar 등 그룹으로 묶어서 관계를 정의합니다.

또한 단어 사이의 '상위와 하위' 혹은 '전체와 부분' 등 세세한 관계까지 그룹화를 시킵니다.

이처럼 모든 단어에 대한 유의어 집합을 만든 다음, 단어들의 관계를 그래프로 표현하여 단어 사이의 연결을 정의할 수 있습니다.

 

 

WordNet

자연어 처리 분야에서 가장 유명한 시소러스로 사용하면 유의어를 얻거나 단어 네트워크를 이용할 수 있습니다.

 

 

시소러스의 문제점

시소러스를 사용하면 단어의 의미를 간접적으로 컴퓨터에게 전달할 수 있겠지만, 사람이 수작업으로 레이블링 하는 방식에 큰 결점이 존재합니다.

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

 - 자연어는 살아있는 말이므로 시간이 흐를수록 변형, 생성, 제거 등 변화가 지속적으로 일어납니다.

2. 사람을 쓰는 비용이 큽니다.

 - 시소러스를 만드는데 엄청난 양의 단어들의 관계를 일일이 등록하면 시간과 자본이 많이 듭니다

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

 - 의미가 비슷하지만 차이가 있는 단어를 구분할 수 없습니다.

 

 

 

 

(3) 통계 기반 기법

대량의 텍스트 데이터인 말뭉치를 사용해서 핵심을 추출하는 방법입니다.

말뭉치는 자연어 처리 연구나 애플리케이션을 염두에 두고 수집된 텍스트 데이터입니다.

말뭉치는 사람의 지식, 문장을 쓰는 방법, 단어를 선택하는 방법, 단어의 의미 등 사람이 알고 있는 자연어에 대한 지식이 포함되어 있습니다.

파이썬으로 말뭉치에 전처리(prepocessing), 즉 텍스트를 단어로 분할하고 그 분할된 단어들을 단어 ID목록을 변환하는 일을 예시로 코드로 표현하면 다음과 같습니다.

 

.lower() - 영어 대문자를 소문자로 바꾸는 매소드

 

.replace() - 문자를 대체하는 매소드

 

.split() - 특정 기준으로 분할하는 매소드

           여기서 .split(' ')는 공백을 기준으로 단어를 분리시켜서 저장합니다.

 

corpus 변수는 단어의 ID로 순서를 표기해서 ID로 문장의 구성 순서를 파악할 수 있습니다.

 

 

 

① 분포 가설

색을 RGB로 표현할 때 벡터로 표현할 수 있는데 이 경우 보통 3차원 벡터로 세 가지 성분이 어떤 비율로 섞여 있는지 표현할 수 있습니다.

'색'을 벡터로 표현하듯이 '단어'도 벡터로 표현할 수 있는데 자연어 처리 분야에서는 단어의 분산 표현(distributional representation) 이라고 합니다.

벡터로 표현하기 위해서 '단어의 의미는 주변 단어에 의해 형성된다'라는 아이디어인 분포 가설(distributional hypothesis)을 이용합니다.

분포 가설이 말하는 바는 단어 자체에는 의미가 없고, 그 단어가 사용하는 '맥락'이 의미를 형성한다는 것입니다.

예시로, "I drink soju", "I drink beer" 처럼 "drink" 주변에는 음료가 등장하기 쉽다는 맥락을 알 수 있고 "i guzzle sozu", "i guzzle beer" 이라는 문장이 있으면 "drink"와 "guzzle"이 같은 맥락에서 사용됨을 알 수 있습니다.

 

분포 가설에서 자주사용할 '맥락'이란 단어는 특정 단어를 중심에 둔 그 주변 단어를 말합니다.

맥락의 크기(주변 단어를 몇 개나 포함할지)를 '윈도우 크기"라고 합니다.

위에서 예시로 든 말뭉치인 "you say goodbye and i say hello."의 "you"는 윈도우 크기가 1이라고 할 때 맥락은 "say"라는 단어 하나뿐입니다.

"you"라는 단어의 맥락에 포함 되는 단어의 빈도를 표로 정리하면 다음과 같습니다.

  you say  goodbye and i hello .
you 0 1 0 0 0 0 0

"you"란 단어를 [0, 1, 0, 0, 0, 0, 0]이라는 벡터로 표현할 수 있습니다.

이는 말뭉치의 모든 단어들을 벡터로 표현할 수 있습니다.

말뭉치의 단어들을 벡터로 자동화시켜서 표현하는 동시발생 행렬 코드는 다음과 같습니다.

 

enumerate() - 반복문에서 쓰이는 매소드로 몇 번째 반복문인지 0부터 숫자로, 특정 변수에 해당 값과 함께 반환합니다.

         

 

② 벡터 간 유사도

코사인 유사도(cosine similarity)

벡터 사이의 유사도를 나타낼 때는 코사인 유사도를 자주 이용합니다.

코사인 유사도는 코사인 내적식과 (거의) 같습니다.

코사인 내적에서 두 벡터의 각도로 표현되는데 두 벡터의 각도가 0이면 cos 0 = 1 두 벡터의 각도가 180도 이면 cos 180= -1 이므로 이를 통해 유사도를 표현할 수 있습니다.

따라서 코사인 유사도 값은 1에서 -1 사이로 나타내 지며, 1에 가까워질수록 유사도가 높다고 할 수 있습니다.

수식은 다음과 같습니다.

이를 코드로 표현하면 다음과 같습니다.

단어 "you"와 "i"의 유사도를 나타낸 코드로 전에 설명한 preprocess, create_co_matrix 함수는 나타내지 않았습니다.

코사인 유사도는 -1에서 1 사이의 값이므로 0.70.. 은 유사도가 비교적 높다고 말할 수 있습니다.

 

말뭉치에 있는 단어들의 유사도를 정리한 코드는 다음과 같습니다.

'You say goodbye and i say hello' 말뭉치에서 "you"라는 단어가 다른 단어와의 유사도를 표현하는 코드입니다.

결과에 의하면 "you"에 가까운 단어가 "I"라는 건 둘 다 인칭대명사라 비슷하다는 것이 납득이 가지만 "goodbye"나 "hello"와 유사도가 높다는 것은 납득이 가지 않습니다.

다음과 같은 결과가 나온 이유는 말뭉치가 너무 적어서이기 때문입니다.

나중에 큰 말뭉치를 사용해서 실험을 해보겠습니다.

 

 

 

③ 점별 상호정보량(Pointwise Mutual Information)

동시발생 행렬의 원소는 두 단어가 동시에 발생한 횟수를 나타내는데 '발생 횟수'로 특징을 잡으면 결점이 있습니다.

예컨대 "the", "chicken" 의 동시발생을 생각해보면 "chicken"은 "eat"라는 단어와 관련이 깊지만 "the chicken"이라는 동시발생이 더 자주 발생하면 "chicken"은 "eat"보다 "the"와의 관련성이 깊다고 나옵니다.

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

수식으로 나타내면 다음과 같습니다.

P(x)는 x가 일어날 확률, P(y)는 y가 일어날 확률, N은 말뭉치 전체 단어별 동시발생 횟수, C(x)와 C(y)는 각각 단어 x, y의 등장 횟수, C(x, y)는 x와 y가 동시에 일어난 횟수입니다.

PMI를 이용하면 위의 예시에서 "the chicken"이 "chicken eat" 보다 훨씬 많이 나왔다 하더라도 연관성이 납득가게 나옵니다.

예시로 "the"와"chicken"과 "eat"이 각각 1000번, 20번, 10번 나왔고 "the"와 "chicken"의 동시발생 횟수는 10회, "chicken"과 "eat"의 동시발생 횟수가 5회라고 가정하면 PMI("the","chicken")=2.32, PMI("chicken","eat")=7.97 결과가 나옵니다.

하지만 PMI는 두 단어의 동시발생 횟수가 0이면 log2() 값이 마이너스 무한대가 되므로 양의 상호정보량(Positive PMI)(PPMI)를 사용합니다.

PPMI의 코드는 다음과 같습니다.

 

동시발생 행렬을 PPMI행렬로 변환해서 유사성의 좋은 척도를 얻었지만 PPMI에 문제점이 있습니다.

말뭉치의 어휘 수가 증가함에 따라 각 단어 벡터의 차원 수도 증가한다는 문제입니다.

또한, 행렬의 내용을 보면 원소 대부분이 0으로 중요하지 않은 즉, 중요도가 낮은 원소로 대부분 이루어져 있습니다.

더구나 이런 벡터는 노이즈에 약하고 견고하지 못합니다.

이를 대처하고자 벡터의 차원 감소를 이용합니다.

 

 

④ 차원 감소(dimensionality reduction)

벡터의 차원을 줄이는 방법인데 '중요한 정보'는 최대한 유지하면서 줄입니다.

예시로 2차원 데이터를 1차원 데이터로 차원 감소를 설명하겠습니다.

2차원 데이터를 1차원으로 표현하기 위해 오른쪽 그림은 새로운 축을 도입하여 똑같은 데이터를 좌표축 하나만으로 표시했습니다.

새로운 축을 찾을 때는 데이터가 넓게 분포되도록 고려해야 합니다.

여기서 중요한 것은 가장 적합한 축을 찾아내는 일로, 1차원 값만으로도 데이터의 본질적인 차이를 구별할 수 있어야 합니다.

이와 같은 방법을 다차원에 대해서도 수행할 수 있습니다.

 

차원을 감소시키는 방법은 여러 가지지만, 특잇값분해(SVD)를 이용하겠습니다.

SDV는 임의의 행렬을 세 행렬의 곱으로 분해하며, 수식으로는 다음과 같습니다.

U와 V는 직교 행렬이고 S는 대각 행렬입니다.

( SVD를 이해하려면 선형대수학의 고유값 분해부터 공부해야 되기 때문에 그 부분은 나중에 따로 블로그에 정리하겠습니다. 여기선 대략적인 느낌으로 설명하겠습니다.)

이 수식을 시각적으로 표현한 것은 다음과 같습니다.

여기서 흰색 부분은 원소가 '0'입니다.

직교 행렬의 U는 앞에 예시에 설명에 대조를 하면 어떠한 공간의 축을 형성하는 역할을 합니다.

지금 맥락에서는 이 U행렬을 단어 공간으로 취급할 수 있습니다.

S는 대각 행렬로 대각 성분의 '특잇값(singular value)'이 큰 순서로 나열되어 있습니다.

특잇값은 '해당 축'의 중요도라고 간주할 수 있습니다.

결국 X의 값은 U와 S와 V의 일차 결합으로 이뤄지는데 방금 말했듯이 특잇값이 큰 순서대로 나열되어 있으므로 씨그마는 r개 있겠지만 그중 가장 중요한 값, 가장 큰 값들은 씨그마1, 씨그마2,.... 순으로 배치되어 있습니다.

따라서 다음과 같이 U의 여분의 열벡터를 깎아내어 차원을 차감시키고 S행렬의 중요도가 낮은 값들을 줄여 차원을 줄여도 중요도가 높은 값들이 앞에 배치되어 있기 때문에 원래의 X행렬에 근사할 수 있습니다.

즉, 중요한 특징은 그대로 유지가 된다고 볼 수 있습니다.

 

SVD를 코드로 표현하면 다음과 같습니다.

np.linalg - 선형대수 관련 매소드

 

plt.annotate() - 그래프에 해당 값에 텍스트를 삽입하는 매소드

 

plt.scatter() - 점으로 된 그래프를 만드는 매소드

                  alpha옵션은 투명도를 설정하며 0.1~1 사이 값을 설정하고, 1에 가까울수록 불투명합니다.

 

 

결과에서 "i"와 "goodbye"가 겹쳐져 있는데 말뭉치의 단어가 너무 적어서 유사성이 실제보다 크게 나왔습니다.

그래프에서 납득이 되는 특징은 "goodbye"와 "hello", "you"와 "I"가 제법 가까이에 있다는 것입니다.

 

 

 

 

⑤ PTB 데이터셋

앞전까지는 아주 작은 말뭉치를 사용해서 유사성을 판단하는데 정보가 부족했습니다.

그러므로 이번엔 적당한 길이의 말뭉치인 펜 트리뱅크(Penn Treebank)(PTB)데이터셋을 사용해보겠습니다.

PTB말뭉치는 텍스트 파일로 제공되며, 원래의 PTB문장에 몇 가지 전처리를 해두었습니다.

예컨대 희소한 단어를 <unk>라는 특수문자로 치환한다거나, 구체적인 숫자를 N으로 대체하는 작업이 적용되었습니다.

PTB 말뭉치에서는 한 문장이 하나의 줄로 저장되어 있고 각 문장의 끝에 <eos>라는 특수 문자를 삽입합니다.

하지만 이번 실습에서는 문장 단위로 처리하지 않고 각 문장을 연결한 하나의 큰 시계열 데이터로 취급하겠습니다.

 

우선 PTB데이터셋을 대략적으로 보는 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
sys.path.append('..')
from dataset2 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'])
cs

결과

 

ptb.load_data() 괄호에 들어갈 수 있는 인수로는 'train', 'test', 'valid' 중 하나를 지정할 수 있는데, 차례대로 '훈련용', '실험용', '검증용' 데이터를 가리킵니다.

결과에 Downloading.... 이 부분은 ptb.load_data()가 실행되면서 처음에 텍스트 파일을 다운로드하는 과정을 표시하는 부분 입다. (ptb.py에 그렇게 설정했습니다)

 

이제 PTB데이터셋에 통계 기반 기법을 적용해보겠습니다.

통계 기반 기법을 기존엔 SVD를 사용했는데 시간이 오래 걸리고 메모리도 훨씬 많이 사용되므로 고속 SVD를 적용시키겠습니다. 

고속 SVD를 사용하려면 sklearn 모듈을 설치해야 합니다.

코드는 다음과 같습니다.

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
30
31
import sys
sys.path.append('..')
import numpy as np
from common2.util import most_similar, create_co_matrix, ppmi
from dataset2 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('Create Co-occurrence Matrix...')
= create_co_matrix(corpus, vocab_size, window_size)
 
print('PPMI 계산...')
= ppmi(C, verbose=True)
 
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:
    # 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)
cs

일부 결과만 나타냈습니다.

ppmi인수에 verbose를 true로 설정해서 단어 하나를 파악할 때마다 완료되었다고 출력이 나옵니다.

SVD를 수행하는데 sklearn의 randomized_svd()메서드를 이용했습니다.

 

이 메서드는 무작위 수를 사용한 Truncated SVD로, 특잇값이 큰 것들만 계산하여 기본적인 SVD보다 훨씬 빠릅니다.

Truncated SVD는 무작위 수를 사용하므로 결과가 매번 다릅니다.

 

 

결과에서 검색단어로 선택한 단어들로 보면 단어들이 의미 혹은 문법적인 관점에서 비슷한 단어들이 가까운 벡터로 나타났음을 알 수 있습니다.

 

 

 

 

위의 코드에서 사용한 ptb.py의 코드는 다음과 같습니다.

전에 손글씨 분류 데이터셋인 MNIST를 부를 때 사용하는 코드와 비슷합니다.

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import sys
import os
sys.path.append('..')
try:
    import urllib.request
except ImportError:
    raise ImportError('Use Python3!')
import pickle
import numpy as np
 
 
url_base = 'https://raw.githubusercontent.com/tomsercu/lstm/master/data/'
key_file = {
    'train':'ptb.train.txt',
    'test':'ptb.test.txt',
    'valid':'ptb.valid.txt'
}
save_file = {
    'train':'ptb.train.npy',
    'test':'ptb.test.npy',
    'valid':'ptb.valid.npy'
}
vocab_file = 'ptb.vocab.pkl'
 
dataset_dir = os.path.dirname(os.path.abspath(__file__))
 
 
def _download(file_name):
    file_path = dataset_dir + '/' + file_name
    if os.path.exists(file_path):
        return
 
    print('Downloading ' + file_name + ' ... ')
 
    try:
        urllib.request.urlretrieve(url_base + file_name, file_path)
    except urllib.error.URLError:
        import ssl
        ssl._create_default_https_context = ssl._create_unverified_context
        urllib.request.urlretrieve(url_base + file_name, file_path)
 
    print('Done')
 
 
def load_vocab():
    vocab_path = dataset_dir + '/' + vocab_file
 
    if os.path.exists(vocab_path):
        with open(vocab_path, 'rb') as f:
            word_to_id, id_to_word = pickle.load(f)
        return word_to_id, id_to_word
 
    word_to_id = {}
    id_to_word = {}
    data_type = 'train'
    file_name = key_file[data_type]
    file_path = dataset_dir + '/' + file_name
 
    _download(file_name)
 
    words = open(file_path).read().replace('\n''<eos>').strip().split()
 
    for i, word in enumerate(words):
        if word not in word_to_id:
            tmp_id = len(word_to_id)
            word_to_id[word] = tmp_id
            id_to_word[tmp_id] = word
 
    with open(vocab_path, 'wb') as f:
        pickle.dump((word_to_id, id_to_word), f)
 
    return word_to_id, id_to_word
 
 
def load_data(data_type='train'):
    '''
        :param data_type: 데이터 유형: 'train' or 'test' or 'valid (val)'
        :return:
    '''
    if data_type == 'val': data_type = 'valid'
    save_path = dataset_dir + '/' + save_file[data_type]
 
    word_to_id, id_to_word = load_vocab()
 
    if os.path.exists(save_path):
        corpus = np.load(save_path)
        return corpus, word_to_id, id_to_word
 
    file_name = key_file[data_type]
    file_path = dataset_dir + '/' + file_name
    _download(file_name)
 
    words = open(file_path).read().replace('\n''<eos>').strip().split()
    corpus = np.array([word_to_id[w] for w in words])
 
    np.save(save_path, corpus)
    return corpus, word_to_id, id_to_word
 
 
if __name__ == '__main__':
    for data_type in ('train''val''test'):
        load_data(data_type)
cs

 

'It공부 > Deep learning' 카테고리의 다른 글

(2)-3-1. word2vec  (0) 2021.01.17
SVD를 이용한 차원 축소가 의미하는 바  (0) 2021.01.16
(2)-1 자연어 처리 하기 전에  (0) 2020.06.29
7. CNN  (0) 2020.06.26
6. 학습 관련 기술들  (0) 2020.06.21
Comments