분산표현(Distributed Representation), Word2Vec, 분포가설(Distribution hypothesis), 임베딩(embedding)
지난 노트에서는 벡터화를 하는 2가지 방법 중 Counter-based Representation에 대해 배웠었는데, 오늘은 나머지 방법인 Distributed Representation에 대해 배우고 실습까지 해보았다.
분포가설(Distribution hypothesis)
이란 쉽게 말해 비슷한 위치에서 등장한 단어는 비슷한 의미를 가진다는 것이다. 유유상종이라고 할 수 있겠다. I found good stores.
, I found beautiful stores.
이 두 문장에서 good
,beautiful
은 주변에 분포한 단어가 유사하기 때문에 비슷한 의미를 지닐 것이다~ 라고 하는 것이다.분산 표현(Distributed representation)
은 이 분포가설에 기반하여 주변 단어 분포를 기준으로 단어의 벡터 표현을 결정하는 것을 말한다.본격적으로 이 분산 표현을 배우기 전에 이전에 배웠던 원핫인코딩을 다시 짚고 넘어갔다.
I am a student
라는 문장이 있다면, 아래와 같이 원핫인코딩을 하는 것이다.I
: [1 0 0 0]am
: [0 1 0 0]a
: [0 0 1 0]student
: [0 0 0 1]sent = "I am a student"
word_lst = sent.split()
word_dict = {}
for idx, word in enumerate(word_lst):
vec = [0 for _ in range(len(word_lst))] # 단어 길이만큼 [0, 0, 0, 0] 을 만들고
vec[idx] = 1 # for loop에 의해 단어 순서대로 1을 매기겠단 뜻.
word_dict[word] = vec
print(word_dict)
코사인 유사도(cosine similarity)
를 쓰는데, 이렇게 원핫인코딩을 통해 벡터화를 하면 두 단어의 내적은 항상 0이 되기 때문에 유사도 자체를 측정할 수 없게 된다. 코사인 유사도 구하는 수식은 참고. 그래서 이 원핫인코딩의 단점을 해결하기 위해 나온게 바로 임베딩(Embedding)이다!!!
(왜 나왔는지 항상 흐름을 생각하자구~)
[0.04227, -0.0033, 0.1607, -0.0236, ...]
와 같이 벡터 내의 각 요소가 연속적인 값을 가지게 된다. Word2Vec
은 이 임베딩의 가장 널리 알려진 방법이다.Word2Vec
은 특정 단어 양 옆에 있는 두 단어(window size = 2)의 관계를 활용하기 때문에 분포 가설을 잘 반영하고 있다고 한다. Word2Vec
에는 CBoW
와 Skip-gram
의 2가지 방법이 있다. 각각 무엇인지 우선 아래 그림을 보자.CBoW
: 주변 단어에 대한 정보를 기반으로 중심 단어의 정보를 예측하는 모델.Skip-gram
: 중심 단어의 정보를 기반으로 주변 단어의 정보를 예측하는 모델.어머님 나 는 별 하나 에 아름다운 말 한마디 씩 불러 봅니다
__
를 예측한다!__
들을 예측한다. [0.04227, -0.0033, 0.1607, -0.0236, ...]
식으로 되어있다고 했는데, 안의 요소가 300개라는 것이다. 아까 원핫인코딩 얘기를 했는데, 원핫인코딩은 단어의 수만큼 차원이 늘어날 수밖에 없는데 임베딩은 조절할 수 있는 장점이 있는 것이다. 이 말 참고하자. 차원 역시 사용자의 지정입니다. 일반적으로 목적에 맞게 입력 단어의 수보다 적게 지정을 해줍니다(256, 512, 1024 등)
Sub-sampling
, Negative-sampling
등이 있다고 하는데, 이것에 대해서는 나중에 한 번 알아보자.King+Woman-man
을 하면 queen
을 뽑아낼 수 있는데 왜 그 결과를 낼 수 있는지는 위 그림을 보면서 생각해보니 더 잘 이해가 되었다. print(wv.most_similar(positive=['king', 'women'], negative=['men'], topn=1)) # [('queen', 0.6525818109512329)]
print(wv.most_similar(positive=['walking', 'swam'], negative=['walked'], topn=1)) # [('swimming', 0.7448815703392029)]
.doesnt_match
메소드를 이용하면 가장 관련이 없는 단어도 찾을 수 있다. 짱 신기함.print(wv.doesnt_match(['fire', 'water', 'land', 'sea', 'air', 'car'])) # car
gensim
은 Word2Vec 으로 사전학습된 임베딩 벡터를 쉽게 사용해볼 수 있는 패키지이다. 오늘은 각 단어별로 임베딩 벡터를 다 구하는 과정까지는 하지 않았고 이미 구글에서 만들어놓은 것을 가져다가 썼다.Word2Vec
은 단어 집합에 지정하지 않은 단어는 벡터화 할 수 없다는 단점이 있다. 이 문제를 OOV(Out-Of-Vocabulary)
문제라고 한다. 캐글의 SMS Spam dataset 에 사전 학습된 Word2Vec 임베딩 벡터를 적용하여 분류해봅시다. 세션 노트에 있었던 단어 임베딩 벡터를 평균내어 분류하는 방법을 적용해봅시다.
참고로 위 문제에서 말한 단어 임베딩 벡터를 평균내어 분류하는 방법
은 다음과 같다. 노트에 있던 내용을 인용한다.
예를 들어, "I am a student"라는 문장을 구성하는 단어의 임베딩 벡터가 아래와 같다고 해보겠습니다.
이 때, "I am a student"라는 문장을 분류하기 위해서 최종적으로 아래 벡터를 사용합니다.
각 요소별로 벡터간 단순평균을 낸 것이다. 생각보다 성능이 좋아 기준모델로 많이 사용한다고 한다. 왜 이렇게가 가능한지는 아까 한국-서울+도쿄 = 일본
이 나올 수 있는 공간 상의 시각화를 잘 생각해보면 이해가 갈 것이다.
[준비]
!pip install gensim --upgrade # 코랩같은 경우 최신 버전이 아니어서 업그레이드 진행하고 해줬음.
--
import gensim
gensim.__version__ # 4.2.0
--
# 단어집 가져오기
import gensim.downloader as api
wv = api.load('word2vec-google-news-300')
--
# index 0-5에 어떤 단어가 있는지 살펴보기
for idx, word in enumerate(wv.index_to_key): #=> enumerate() index도 함께 반환해줘서 활용 가능한 메서드.
if idx == 5:
break
print(f"word #{idx}/{len(wv.index_to_key)} is '{word}'") #총 3,000,000개의 단어가 있구나!
# 차원 수 확인해보기
vec_king = wv['data']
print('\n벡터 차원수 = ', vec_king.shape)
[word2vec을 이용해 구한 'data'와 'science' 임베딩 값의 코사인 유사도 구하기]
from sklearn.metrics.pairwise import cosine_similarity
'''
vec_data = wv['data']
vec_science = wv['science']
print("'data'와 'science'의 코사인 유사도 = ",cosine_similarity(vec_data, vec_science))
# 위와 같이 돌렸을 때 아래와 같은 오류가 리턴됨.
"ValueError: Expected 2D array, got 1D array instead: Reshape your data either using array.reshape(-1, 1) if your data has a single feature or array.reshape(1, -1) if it contains a single sample."
# 이런 오류가 발생하는 이유는, skit-learn에서는 모든 인풋 'x'가 2d array로 들어올 것으로 기대하기 때문이라고 한다. (출처 - https://datamasters.co.kr/55)
이 경우 벡터값을 reshape 하고 코사인 유사도를 계산하면 된다. (# reshape() 레퍼런스: https://jimmy-ai.tistory.com/99)
vec_data = wv['data'].reshape(1,-1)
vec_science = wv['science'].reshape(1,-1)
# 혹은 더 간단하게는 아래와 같이 해도 작동한다.
vec_data = wv[['data']]
vec_science = wv[['science']]
# 넘파이 array의 차원에 대해서 헷갈린다면 https://datascienceschool.net/01%20python/03.01%20%EB%84%98%ED%8C%8C%EC%9D%B4%20%EB%B0%B0%EC%97%B4.html 를 참고하자.
'''
vec_data = wv[['data']]
vec_science = wv[['science']]
print("'data'와 'science'의 코사인 유사도 = ", cosine_similarity(vec_data, vec_science)) # 'data'와 'science'의 코사인 유사도 = [[0.1575913]]
[필요한 패키지 업로드]
import numpy as np
import pandas as pd
import tensorflow as tf
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from keras.preprocessing import sequence
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Embedding, GlobalAveragePooling1D
from tensorflow.keras.preprocessing.text import Tokenizer
텍스트 분류문제
[데이터 전처리]
- 데이터셋을 데이터프레임으로 읽어옵니다
encoding = 'latin-1'
을 사용합니다.- 필요없는 열(column)을 삭제합니다.
- LabelEncoder를 사용하여 label 전처리를 해줍니다.
from google.colab import files
# 데이터셋 출처 - https://www.kaggle.com/datasets/uciml/sms-spam-collection-dataset
file = files.upload()
df = pd.read_csv('spam.csv', encoding='latin-1')
# v1, v2 칼럼만 남기고 나머지 drop
df.drop(columns=['Unnamed: 2','Unnamed: 3','Unnamed: 4'], inplace= True)
df.head()
# checking label counts
print(df['v1'].value_counts()) # => ham 4825, spam 747
# label encoding
encoder = LabelEncoder()
df.encoded = encoder.fit_transform(df['v1'])
# df 변경
df['v1'] = df.encoded # ham=0, spam=1
df.head()
[텍스트 분류 실행]
- 데이터셋 split시 test_size의 비율은 15%로,
random_state = 42
로 설정합니다.- Tokenizer의
num_words = 1000
으로 설정합니다.- pad_sequence의
maxlen=150
으로 설정합니다.- 학습 시, 파라미터는
batch_size=64, epochs=10, validation_split=0.2
로 설정합니다.- evaluate 했을 때의 loss와 accuarcy를 [loss, acc] 형태로 입력해주세요. Ex) [0.4321, 0.8765]
np.random.seed(42)
tf.random.set_seed(42)
## 참고 - 여기서 텍스트 분류를 진행한다고 함은 v2를 보고 spam인지를 알 수 있는 분류기를 만들고자 하는 것이다. ##
# train, test data split
train, test = train_test_split(df, test_size=0.15, random_state = 42)
'''
# 타겟 분포 불균형해서 'stratify=df['v1']' 넣어봤더니 아래 word_index 갯수가 달라짐. 왜 그렇지?
아, 문제와 해설에서는 안한 걸로 가정하고 random_state 걸었으니까 당연한거긴 하네.
'''
print('df shape = ', df.shape) # (5572, 2)
print('\ntrain shape = ', train.shape, '\ntest shape = ', test.shape) # train shape= (4736, 2), test shape= (836, 2)
# X,y dataset
feature = 'v2'
target = 'v1'
X_train = train[feature]
X_test = test[feature]
y_train = train[target]
y_test = test[target]
# 피쳐 문장
sentences = [v for v in X_train]
# 토큰화 (레퍼런스 https://wikidocs.net/31766 / https://codetorial.net/tensorflow/natural_language_processing_in_tensorflow_01.html)
tokenizer = Tokenizer(num_words = 1000) # 빈도가 많은 순으로 999개의 단어만 사용하겠다는 뜻. (num_words는 0부터 카운트되기 때문)
tokenizer.fit_on_texts(sentences) # 입력한 텍스트로부터 단어 빈도수가 높은 순으로 낮은 정수 인덱스를 부여함.
word_index = tokenizer.word_index
#print(word_index) # fit_on_texts 인덱스 부여 확인
print(len(word_index)) # => 상위 1000개의 단어만 나오는게 아니라 왜 그렇지? 싶어서 찾아봄. 실제 적용은 texts_to_sequences를 사용할 때 된다고 함.
# text_to_sequences
sentences_encoded = tokenizer.texts_to_sequences(sentences)
#print('\n', tokenizer.texts_to_sequences(sentences)) # => 여기 출력된 걸 보면 빈도 순 999개 초과된 것은 제거되어 출력되는 것을 확인할 수 있다. 이건 위의 'num_words='을 조절하면서 결과를 봐보면 더 잘 알 수 있음.
# pad_sequence (=> 입력데이터 문장길이가 다르기 때문에 이를 임의로 맞춰주는 작업을 말함. 자세한 의미는 레퍼런스 참고.)
'''
레퍼런스 : http://www.nextobe.com/2020/05/14/%EA%B0%80%EB%B3%80-%EA%B8%B8%EC%9D%B4-%EC%9E%85%EB%A0%A5-%EC%8B%9C%ED%80%80%EC%8A%A4%EC%97%90-%EB%8C%80%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A4%80%EB%B9%84/)
https://wikidocs.net/83544
'''
maxlen = 150
padded = pad_sequences(sentences_encoded, maxlen = maxlen)
print(padded)
print('\n\n maxlen 잘 적용되었는지 확인 => len(padded[0]) ==', len(padded[0])) # 150, 제대로 적용되었다. 디폴트로 앞에 0이 추가되며 뒤에 추가하고 싶다면 padding='post' 를 넣어주면 된다.
print('\n참고 - padded 데이터 수 = ', len(padded),'개') # 4736개, X_train 데이터 수와 동일한 것 확인.
# 임베딩 벡터 가중치 행렬 만들기
'''
아 앞에 wv쓰는게 Load 해왔던게 지금 각 단어별로 임베딩 벡터값 불러와서 쓰려고 했던거구나~ 이렇게 생각하면 전체적인 그림차이가 지난 번이랑 다른게 느껴지네.
아 오늘은 단어별로 임베딩 벡터 직접하지 않고 이미 학습된 거 가져온다는 의미가 이거였어!! 오케이!
'''
vocab_size = len(tokenizer.word_index) + 1
'''
# 근데 이번에는 노트와 다르게 num_words를 1000개로 제한했으니 1000으로 맞춰도 되지 않나? 일단 주석처리 해놓고 넘어가기.
=> 질문해서 답변 받음. 가중치행렬에서는 뭐가 상위 1000개일지 모르는 상태니까 일단 전체 토큰 수를 기준으로 임베딩 벡터 가중치 행렬을 만들어둔다는 말이라고 함. 쉽게 생각하면 직관적으로 이해될 거야!
# +1 한 이유는 패딩 때문인데 위에서 패딩할 때 길이 맞추기 위해서 임의로 '0'을 추가했으니까 그것까지 포함하려는 것으로 이해하면 됨.
'''
#기본 매트릭스 생성
embedding_matrix = np.zeros((vocab_size, 300)) # 300차원.
print(np.shape(embedding_matrix)) # => (8186, 300)
# 각 단어별 벡터를 찾기 위한 함수 작성
def get_vector(word):
"""
입력 단어가 vocab 에 있는 단어일 경우 임베딩 벡터를 반환
Args:
word: 입력 단어 -> str
"""
if word in wv:
return wv[word]
else:
return None
# 가중치 행렬에 적용
for word, i in tokenizer.word_index.items():
temp = get_vector(word)
if temp is not None:
embedding_matrix[i] = temp
#print('\n\n가중치 행렬\n',embedding_matrix)
# 모델링
X_train = padded
y_train = np.array(y_train)
model = Sequential()
model.add(Embedding(vocab_size, 300, weights=[embedding_matrix], input_length=maxlen, trainable=False)) #embedding 레퍼런스 https://runebook.dev/ko/docs/tensorflow/keras/layers/embedding
model.add(GlobalAveragePooling1D()) # 입력되는 단어 벡터의 평균을 구하는 층이라고 생각하면 된다. (각 단어 벡터의 평균을 내서 특징을 잡는거라고 생각하면 됨)
model.add(Dense(1, activation='sigmoid')) #이진분류 문제이므로
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc'])
model.fit(X_train, y_train, batch_size=64, epochs=10, validation_split=0.2)
# test data evaluate
test_sentences = [v for v in X_test]
X_test_encoded = tokenizer.texts_to_sequences(test_sentences)
X_test=pad_sequences(X_test_encoded, maxlen=maxlen)
y_test=np.array(y_test)
model.evaluate(X_test, y_test) # loss: 0.4714 - acc: 0.8660
[Word2Vec에서의 OOV 문제]
def get_vector(word): """ 해당 word가 word2vec에 있는 단어일 경우 임베딩 벡터를 반환 """ if word in wv: return wv[word] else: return None for word, i in tokenizer.word_index.items(): temp = get_vector(word) if temp is not None: embedding_matrix[i] = temp
Lecture Note에 있는 위의 코드를 변형하여, OOV 개수를 확인해주세요.
- tokenizer는 위에서 활용한 tokenizer를 그대로 사용하겠습니다.
- Tip : dictionary를 활용하거나, Counter를 활용해보세요.
# 위 함수를 통해 wv에 있는 단어인지 검색하고 없으면 return none을 했었는데 이걸 어딘가에 적절히 저장하도록 하면 oov 개수 파악은 쉽게 가능할 듯.
def get_vector2(word):
"""
해당 word가 word2vec에 있는 단어일 경우 임베딩 벡터를 반환
"""
if word in wv:
return wv[word]
else:
return word # word를 리턴해서 가지고 있도록 하자. 나중에 어떤 단어가 oov인지도 확인 가능할테니 유용할 듯.
# oov 갯수 저장할 곳
oov = []
for word, i in tokenizer.word_index.items():
temp = get_vector2(word)
if type(temp) == str:
oov.append(temp)
print(len(oov)) # 2419
#print(oov) 리스트 확인도 가능