Word2Vec

7 minute read

분포 가정 (distributional hypothesis)

“You shall know a word by the company it keeps.”
영국의 언어학자 존 루퍼트 퍼스가 했던 말이다.

“같이 다니는 것들로 단어를 알 수 있다.”
분포 가정을 아주 잘 반영하는 말이다.

처음으로 다시 돌아가서,
단어를 임베딩하는 것에는 세 가지 가정, 철학이 있다고 했다.
첫번째는 백오브워즈 가정, 많이 쓰이는 단어로 글의 주제를 알 수 있다는 것이었고
두번째는 언어 모델, 단어가 쓰이는 순서로 다음 단어를 알 수 있다는 것,
그리고 이번 세번째, 분포 가정은 같이 쓰인 단어로 핵심 단어를 알 수 있다는 가정이다.

Word2Vec

백오브워즈 가정 하에서 자연어를 처리하는 방법에는
단어-문서의 관계를 알아내는 TF-IDF 행렬,
그리고 그 행렬에서 핵심 주제 몇 가지를 추려내는 LSA,
그리고 단어가 주제를 반영할 확률과
각 문서에 그런 단어들이 분포될 확률로 주제를 예측하는 LDA 등이 있었다.
이 모델들은 분명 주제를 찾는데 중요한 일을 하고 실제 현업에서도 쓰이고 있지만
우리의 궁극적인 목표인 ‘컴퓨터에게 언어 이해시키기’ 에는 거리가 있다.

그리고 지금 알아볼 Word2Vec은 지금까지 했던 모델들 중 가장 이에 근접한 모델이라고 할 수 있다.
물론 Word2Vec이 가장 좋다는 뜻은 아니다. 어디까지나 장단점이 있고,
하려는 일에 따라서 각기 다른 모델을 적재적소에 사용하는 것이 바람직하다.

Word2Vec은 2013년 구글이 개발한 분포 가정 단어 임베딩 모델이다.

Word2Vec은 크게 두 가지 모델로 나뉜다.
주변 단어들로 중심단어 하나를 예측하는 CBOW(Continuous Bag of words),
이와는 반대로, 중심단어 하나로 주변 단어들을 예측하는 Skip-Gram이 그것이다.

중심단어, 주변단어, window

다음과 같은 빈칸 맞추기 문제가 있다고 한다.
“The Beatles is the most famous ___ band in history.”

사람은 태어날 때부터 딥 러닝을 시작했으므로 이런 건 후처리 없이 바로 ‘rock’이라고 맞출 수 있다.
여기서 우리는 CBOW 모델을 적용하여 이 빈칸을 맞추려고 한다.

이때 우리가 맞추려는 단어, 중심단어는 ‘rock’이 된다.
그렇다면 주변단어는 왼쪽으로는 famous, most, the, is… 가 있고
오른쪽으로는 band, in, history 등이 있다.

주변단어의 개수를 정하는 것을 window 라고 한다.
예를 들어 window 가 3이라면 주변단어는 rock 주변 최대 3개의 단어인
the, most, famous, band, in, history 가 된다.
2라면 most, famous, band, in 등이 된다.

CBOW, Skip-gram

두 모델의 개요도는 다음과 같다.

cbow and skip-gram

Word2Vec은 이름에서도 알 수 있다시피 단어를 벡터로 변환하는 임베딩 기법이다.
사용자가 정한 차원에 따라서, ‘cat’ 이라는 단어는 그 차원의 벡터로 표현이 된다.
단어가 벡터화되는 과정에 대해서 알아보자.

먼저 CBOW 에 대해서 알아본다.

cbow

위 사진은 cbow 모델에서 일어나는 일을 간략하게 나타낸 것이다.
좌측부터 입력층, 은닉층, 출력층이다.
입력층에 입력되는 값은 단어의 수만큼의 차원을 가지는 원핫인코딩 방식의 벡터이다.
원-핫 인코딩이란 n차원 벡터에서 1개의 1과 n-1개의 0을 가지는 방식을 말한다.
문서에 단어가 n개, 그 중 cat이라는 단어가 3번째에 등장한다면
cat을 가리키는 벡터는 (0, 0, 1, 0, … 0) 이 된다.

그래서 위 사진에서 보면 입력층, 출력층 쪽에 V-dim 이라는 표시를 볼 수 있다.
단어 개수만큼의 차원을 가지는 벡터가 입력되고, 출력된다는 뜻이다.

그리고 그 사이의 은닉층에서는 은닉하게 어떤 계산이 실행된다.
잘 보면 은닉층의 벡터는 N차원임을 알 수가 있다.
V차원의 벡터는 VxN의 가중치 행렬과 곱해져 N차원의 벡터가 된다.
그리고 모두 다 더해져서 은닉층에 쌓이게 되고,
은닉층은 이 출력에 다시 NxV의 가중치 행렬과 곱해서 다시 V차원의 벡터로 출력을 하게 되는 것이다.

위의 rock 이란 단어를 맞추는 예로 다시 돌아가자.
window 를 2라고 가정했을 때 입력층에는 most, famous, band, in 이라는 단어가 들어간다.
이 문장은 전체 10개의 단어로 이루어졌으므로 단어에 해당하는 벡터는 각각
(0, 0, 0, 0, 1, 0, 0, 0, 0), (0, 0, 0, 0, 0, 1, 0, 0, 0),
(0, 0, 0, 0, 0, 0, 1, 0, 0), (0, 0, 0, 0, 0, 0, 0, 1, 0) 가 된다.

그리고 이 각 벡터에 VxN 행렬 W1, W2, W3, W4 를 곱하면
그 결과는 W1의 5번째 행, W2의 6번째 행, W3의 8번째 행, W4의 9번째 행이 나온다.
(W에 대한 행 곱셈에서 입력 벡터가 하나 빼고 다 0이므로)

그 후 이를 다 더해서 평균을 내고, 다시 NxV의 가중치 행렬을 곱한다.
그렇게 되면 이는 다시 V차원의 벡터가 되는데,
이를 softmax 함수를 취하여 출력하게 된다.
그리고 그 결과를 우리의 ‘rock’ 을 가리키는 벡터
(0, 0, 0, 0, 0, 0, 1, 0, 0, 0) 와 손실함수를 계산하고
이 손실함수의 값에 따라서 우리가 썼던 W 행렬들의 값을 조정한다.
이때 손실함수로는 cross entropy error 를 사용한다.

이를 반복하게 되면 입력층 - 은닉층, 은닉층 - 출력층 사이의 가중치 행렬들의 값이 수정되고
마침내 출력층에서 우리가 설정한 차원의 벡터를 얻을 수가 있게 된다.

Skip-gram 은 이와 반대로 작동하는 것이다.
입력 층에서 rock 을 입력하고, 출력층에서 most, famous 등의 단어들과 손실함수로 값을 수정하는 것이다.
또한 은닉층에서 평균값을 구할 필요가 없어진다.

CBOW 와 Skip-gram 은 적용되는 문서에 따라서 다른 결과를 보이기도 한다.
뉴스같이 정형화된 문서에서는 CBOW가 높은 성적을 보이고,
반대로 그렇지 않은 트위터 같은 문서에서는 Skip-gram 이 높은 성적을 보인다.

구현

이전과 같이 CNN 단어 자료를 이용한다.
라이브러리로는 젠심의 Word2Vec 을 사용한다.
이번에는 문서가 아닌 문장의 리스트로 넘겨주어야 하므로
토크나이즈 된 것을 다시 합칠 필요는 없다.

아래는 토크나이즈 과정이다.

    tokenizedSaveFile = open("../data/English/tokenized/cnn.txt", "w", encoding="UTF8")

    cnnPreprocessed = []
    lemmatizer = WordNetLemmatizer()
    stopWords = stopwords.words("english")
    while True:
        line = cnnFile.readline()
        if len(line) == 0:
            print("File read finished")
            break
        cnnSentTokenized = sent_tokenize(line)
        # 문장별로 끊음

        for sent in cnnSentTokenized:
            sent = re.sub("[^a-zA-Z]", " ", sent)
            cnnWordTokenized = word_tokenize(sent)
            i = 0
            while i < len(cnnWordTokenized):
                if cnnWordTokenized[i] in ["CNN", "highlight"] or \
                        len(cnnWordTokenized[i]) <= 2 or \
                        cnnWordTokenized[i] in stopWords:
                    cnnWordTokenized.remove(cnnWordTokenized[i])
                else:
                    cnnWordTokenized[i] = cnnWordTokenized[i].lower()
                    cnnWordTokenized[i] = lemmatizer.lemmatize(cnnWordTokenized[i])
                    tokenizedSaveFile.write(cnnWordTokenized[i])
                    if i < len(cnnWordTokenized) - 1:
                        tokenizedSaveFile.write(" ")
                    i += 1

            tokenizedSaveFile.write("\n")
            cnnPreprocessed.append(cnnWordTokenized)

    tokenizedSaveFile.close()

다운 받았던 파일을 열고 한 줄을 읽은 다음 (파일에서 한 줄은 기사 하나이다.)
그 한 줄을 문장 단위로 자른 뒤에 각 문장에 대해서 정규화와 불용어 제거, 단어 단위 토크나이즈를 실행한다.
그리고 모든 단어를 다 대문자로 바꾸어 준다.
정제된 문장들을 tokenizedSaveFile 에 써준다. 나중에 다시 또 쓰기 위함이다.

    print("Word2Vec Train initiating")
    model = Word2Vec(sentences=cnnPreprocessed, size=100, window=5, min_count=5, workers=4, sg=0)
    # size = 워드 벡터의 차원, N에 해당.
    # window = 문맥 윈도우 크기 (주변 몇 단어?)
    # min_count = 단어 최소 빈도 수 제한 (빈도 적은 단어는 학습 x)
    # workers = 학습을 위한 프로세스 수
    # sg 0은 cbow, 1은 skip-gram
    print("Word2Vec Train completed")
    model.save('../data/English/word2vec/cnn_w2v')
    print("Word2Vec file saved")

그리고 Word2Vec CBOW 모델로 학습을 진행한다. 역시 나중에 또 쓰기 위해서 Word2Vec 파일을 저장한다.

전체 코드는 아래와 같다.

 import re
from nltk.tokenize import word_tokenize, sent_tokenize
from gensim.models import Word2Vec
from nltk.corpus import stopwords
from nltk.stem import WordNetLemmatizer

model = None
try:
    model = Word2Vec.load('../data/English/word2vec/cnn_w2v')
    print("the word2vec file exists!")

    tokenizedLoadFile = open("../data/English/tokenized/cnn.txt", "r", encoding="UTF8")
    print("tokenized text exists.")
    cnnPreprocessed = tokenizedLoadFile
except IOError or FileNotFoundError:
    print("the word2vec file does not exist! T.T")

    print("tokenized text doesn't exist.")
    cnnFile = open("../data/English/raw/cnn/all.txt", "r", encoding='UTF8')
    tokenizedSaveFile = open("../data/English/tokenized/cnn.txt", "w", encoding="UTF8")

    cnnPreprocessed = []
    lemmatizer = WordNetLemmatizer()
    stopWords = stopwords.words("english")
    while True:
        line = cnnFile.readline()
        if len(line) == 0:
            print("File read finished")
            break
        cnnSentTokenized = sent_tokenize(line)
        # 문장별로 끊음

        for sent in cnnSentTokenized:
            sent = re.sub("[^a-zA-Z]", " ", sent)
            cnnWordTokenized = word_tokenize(sent)
            i = 0
            while i < len(cnnWordTokenized):
                if cnnWordTokenized[i] in ["CNN", "highlight"] or \
                        len(cnnWordTokenized[i]) <= 2 or \
                        cnnWordTokenized[i] in stopWords:
                    cnnWordTokenized.remove(cnnWordTokenized[i])
                else:
                    cnnWordTokenized[i] = cnnWordTokenized[i].lower()
                    cnnWordTokenized[i] = lemmatizer.lemmatize(cnnWordTokenized[i])
                    tokenizedSaveFile.write(cnnWordTokenized[i])
                    if i < len(cnnWordTokenized) - 1:
                        tokenizedSaveFile.write(" ")
                    i += 1

            tokenizedSaveFile.write("\n")
            cnnPreprocessed.append(cnnWordTokenized)

    tokenizedSaveFile.close()

    print("Word2Vec Train initiating")
    model = Word2Vec(sentences=cnnPreprocessed, size=100, window=5, min_count=5, workers=4, sg=0)
    # size = 워드 벡터의 차원, N에 해당.
    # window = 문맥 윈도우 크기 (주변 몇 단어?)
    # min_count = 단어 최소 빈도 수 제한 (빈도 적은 단어는 학습 x)
    # workers = 학습을 위한 프로세스 수
    # sg 0은 cbow, 1은 skip-gram
    print("Word2Vec Train completed")
    model.save('../data/English/word2vec/cnn_w2v')
    print("Word2Vec file saved")


def vectorSubtract(a, b, c):
    result = model.wv.most_similar(positive=[c, b], negative=[a])
    return result[0][0]

mostSimilar = model.wv.most_similar("america")
for i in range(0, 6):
    print(mostSimilar[i])

아래의 vectorSubtract 라는 함수는 사실 내가 자연어 처리의 흥미를 가열시킨 기능인데
“man”과 “king”, “woman”을 입력하면 “queen”이라는 단어가 나오게 된다. (잘 학습됐다면)
이는 man과 king 벡터의 차를 woman 에 더하면 queen 이라는 벡터에 근접하게 된다는 말이다.
단어의 벡터가 그냥 마구잡이로 흩어져있는 게 아니라 두 벡터의 차에도 의미가 있다는 뜻이다.
잘 학습된 모델에서는 이런 결과를 확인할 수 있다.
(단 이 CNN 문서는 370메가로 작아서 확인할 수 없다.)

비슷하게 “japan”, “japanese”, “korea”를 입력하면 “korean” 이 나온다.
이건 본 코드에서도 작동한다.

image

이 Word2Vec은 데이터가 너무 작아서 좀 특별한 단어에서는 제대로 된 출력을 기대할 수 없는 것 같다.
예컨대 ‘king’이라는 단어는 사실 뉴스에선 보기 힘든 단어라서 man king woman 을 입력하면 좋은 출력을 보지 못한다.

most_similar 함수는 말 그대로 해당 단어와 비슷한 의미를 가진 단어들을 출력한다.

image

king은 데이터셋이 작아서 위와 같이 이상한 출력을 내보내지만
미 CNN 뉴스에서 주구장창 등장하는 Obama 나
(출처가 2015년에 모여진 데이터라서 Trump를 넣으면 이상한 단어가 등장한다.)

image

terror 같은 단어는 괜찮은 결과를 확인할 수 있다.

image

물론 일반적인 단어도 좋은 결과를 보인다.

image

그리고 이제 와서 만든 거지만

from nltk.stem import WordNetLemmatizer
import re
from nltk.tokenize import word_tokenize, sent_tokenize

def myTokenizer(readFileDir, saveFileDir, stopwords, lim):
    readFile = open(readFileDir, "r", encoding="UTF8")
    saveFile = open(saveFileDir, "w", encoding="UTF8")
    preprocessed = []
    lemmatizer = WordNetLemmatizer()
    while True:
        line = readFile.readline()
        if len(line) == 0:
            print("File read finished")
            readFile.close()
            break
        sentTokenized = sent_tokenize(line)

        for sent in sentTokenized:
            sent = re.sub("[^a-zA-Z]", " ", sent)
            wordTokenized = word_tokenize(sent)
            i = 0
            while i < len(wordTokenized):
                if len(wordTokenized[i]) <= lim or \
                        wordTokenized[i] in stopwords:
                    wordTokenized.remove(wordTokenized[i])
                else:
                    wordTokenized[i] = wordTokenized[i].lower()
                    wordTokenized[i] = lemmatizer.lemmatize(wordTokenized[i])
                    saveFile.write(wordTokenized[i])
                    if i < len(wordTokenized) - 1:
                        saveFile.write(" ")
                    i += 1

            saveFile.write("\n")
            preprocessed.append(wordTokenized)

    saveFile.close()

    return preprocessed

이런 함수를 만들어서 갖고 다니는 것도 좋은 방법이라고 생각한다.
위 함수는 읽을 파일 경로와 저장할 경로, 불용어, 최소 길이를 전달해주면
그에 맞게 토크나이즈를 진행해서 토큰화된 문장들의 리스트를 반환해주고
그와 동시에 토크나이즈된 글을 파일로 저장까지 해준다.