데이터 분석/프로젝트

NSMC 영화리뷰 데이터 감성분석(Sentiment Analysis) - Word2Vec + LSTM

Aytekin 2023. 1. 17. 23:52
728x90

 

데이터 EDA

  • 데이터 셋에는 id, document, label이렇게 세개의 칼럼이 있다.
    학습에 필요한 부분은 document(리뷰텍스트)와 label(부정:0, 긍정:1) 이 두 칼럼데이터 이다.
# 네이버 영화리뷰 데이터 불러오기
train = pd.read_table(r"data\nsmc\ratings_train.txt")
test = pd.read_table(r"data\nsmc\ratings_test.txt")
train.head()

 

  • 학습데이터 150000, 테스트데이터 50000개로 총 200000개의 영화리뷰가 있다.
    라벨은 50:50으로 균형
# 데이터 개수
print(train.shape) # (150000, 3)
print(test.shape) # (50000, 3)
# 라벨의 비율 확인
# 0 : 부정, 1 : 긍정
plt.figure(figsize=(3,3))
sns.countplot(x='label',data=train)
plt.title('라벨 분포')
plt.show()

 

  • 리뷰 평균길이 및 히스토그램
print('리뷰의 최대 길이 :', max([len(str(review)) for review in train['document']])) # 146
print('리뷰의 평균 길이 :', np.mean([len(str(review)) for review in train['document']])) # 35.20
plt.hist([len(str(review)) for review in train['document']])
plt.xlabel('문장의 길이')
plt.ylabel('샘플 개수')
plt.show()

전처리 & 데이터클리닝

전처리 순서

1. 정규표현식을 이용하여 한국어를 제외한 글자 제거
2. 중복데이터 제거
    1번 정제과정에서 데이터가 정제되면서 중복데이터가 생길 수 있기 때문에 정규표현식 정제를 먼저 해주었습니다.
3. 결측데이터 제거
    2번 중복데이터 제거 후 공백 " " 데이터를 Nan데이터로 변환한 후 결측데이터를 제거 하였습니다.
4. 불용어 정의 및 제거(토큰화 한 후 불용어 제거)

 

  • 정규표현식 이용하여 한국어를 제외한 글자 제거
train['document'] = train['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")
test['document'] = test['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")

 

  • 결측값 및 중복데이터 확인
# 결측값 확인
print('train data 결측값 수:', train['document'].isnull().sum()) # 5
print('test data 결측값 수:', test['document'].isnull().sum()) # 3
# 중복데이터 확인-train
print('중복데이터 개수(train) :',train[train['document'].duplicated()].shape[0]) # 6317
train[train['document'].duplicated()].head()

# 중복데이터 확인 - test
print('중복데이터 개수(test) :',test[test['document'].duplicated()].shape[0]) # 1581
test[test['document'].duplicated()].head()

 

  • 중복데이터 제거

중복데이터 제거 후 데이터수가 약간 줄어든것을 확인 할 수 있다.

train.drop_duplicates(subset=['document'], inplace=True)
test.drop_duplicates(subset=['document'], inplace=True)

print('중복데이터 제거 후 데이터 수(train) :',train.shape[0]) # 143683
print('중복데이터 제거 후 데이터 수(test) :',test.shape[0]) #48416

 

  • 결측값 제거

정규표현식으로 빈칸이 된 데이터들을 Nan데이터로 변경하여 결측값으로 처리되도록 하였다.

결측값 제거 후 데이터수가 약간 더 줄어들었다.

train['document'] = train['document'].str.replace('^ +', "") # white space 데이터를 empty value로 변경
train['document'].replace('', np.nan, inplace=True)
test['document'] = test['document'].str.replace('^ +', "") # white space 데이터를 empty value로 변경
test['document'].replace('', np.nan, inplace=True)

print('결측값 수(train) :', train['document'].isnull().sum()) # 23
print('결측값 수(test) :',test['document'].isnull().sum()) # 16
# 총 데이터 수
print('train :', train.shape[0]) # 143660
print('test :', test.shape[0]) # 48403

 

  • 불용어 정의 및 제거

https://github.com/stopwords-iso/stopwords-ko
여기에 한국어 불용어에 대해서 정리를 해놓은 파일이 있다.

 

처음엔 여기에서 정의해 놓은 불용어를 사용하려 했으나 리뷰분석 시 의미있는 단어들이 다수 포함되어 있어서 사용하지 않기로 하였습니다.

 

리뷰데이터는 데이터 하나하나의 길이가 길지 않고 중요한 단어 하나 혹은 접속사나 조사에 의미를 담고 평가를 내리는 경우가 많습니다. 그러나 이 불용어 리스트에는 '메스껍다','결론을 낼 수 있다' 와 같이 영화에 대한 평가를 내리는데 중요한 단어, 표현들이 있기 때문에 사용하지 않기로 하였습니다.

 

대신에 영화리뷰 데이터이므로 직접 보면서 불용어 리스트들을 정리해보았다. 이 부분은 개인적인 생각이기 때문에 보는 사람의 관점에 따라 의견이 다를수 있을 것 같습니다.

 

또한 실제 불용어 제거는 토큰화 작업 후에 할 계획이다. 지금은 띄어쓰기도 잘 되어있지 않은 데이터가 많고 오타도 많아 한국어 형태소분석기의 힘을 빌려 데이터를 더 정제한 뒤에 불용어를 제거하는 방식으로 진행해보겠습니다.

# 불용어 정의
stopwords = ['의','가','이','은','들','는','좀','잘','걍','과','도','를','으로','자','에','와','한','하다','아니','및','아','혹시','흠']

 

토큰화

  • 한국어 형태소분석기 비교

오픈된 한국어 형태소 분석기 중에서 5가지를 선택하였고 이 중에서 형태소 분석기를 비교해서 선택하겠습니.

  1. OKT
  2. mecab
  3. komoran
  4. kkma
  5. hannanum

모두 konlpy 라이브러리에서 사용 가능하며 특별히 mecab은 윈도우에서 사용하려면 별도의 설치작업이 필요하다.

 

분석하려는 텍스트 데이터가 영화 리뷰이므로 이 데이터에 가장 잘 맞는 형태소 분석기를 선택하는 것도 중요하다. 띄어쓰기, 맞춤법, 은어들이 포함되어 있는 텍스트 이므로 이를 잘 분석해주는 형태소 분석기를 찾아보도록 하겠습니다.

 

# 토큰화 함수 정의

okt = konlpy.tag.Okt()
mecab = konlpy.tag.Mecab('C:\mecab\mecab-ko-dic')
komoran = konlpy.tag.Komoran()
kkma = konlpy.tag.Kkma()
hannanum = konlpy.tag.Hannanum()

tokenizer_list = [okt, mecab, komoran, kkma, hannanum]
# 난 정말 무서웠는데 스토리도 나름 괜찮았어요.
for tokenizer in tokenizer_list:
    print('형태소분석기 : {}\n'.format(str(tokenizer.__class__)[20:-2]),tokenizer.morphs(test1),'\n')

# 아진짜계속빵터졌음ㅋㅋ아너무재밌어
for tokenizer in tokenizer_list:
    print('형태소분석기 : {}\n'.format(str(tokenizer.__class__)[20:-2]),tokenizer.morphs(test2),'\n')

# 너무재밓었다그래서보는것을추천한다
for tokenizer in tokenizer_list:
    print('형태소분석기 : {}\n'.format(str(tokenizer.__class__)[20:-2]),tokenizer.morphs(test3),'\n')

# 알바넘티난다완전말투다비슷함짱웃경진짜이런게있구나 잼써도알바땜에기분나빠서안봄
for tokenizer in tokenizer_list:
    print('형태소분석기 : {}\n'.format(str(tokenizer.__class__)[20:-2]),tokenizer.morphs(test4),'\n')

<비교결과 및 형태소분석기 선정>

여러가지 케이스로 형태소분석기들을 시험해 본 결과 okt kkma가 영화리뷰 데이터에는 적합한 것 같다.


띄어쓰기나 오타 및 맞춤법 오류를 다른 형태소 분석기보다 잘 잡아내는 것을 확인할 수 있다.

이 중에서 나는 okt를 사용하려 한다.

 

그 이유는 속도에 있다. kkma가 너무 느리다.
konlpy 공식 홈페이지에 가면 형태소분석기 간 성능비교를 한 표가 있는데 이때 kkma는 속도가 다른 모델에 비해 너무 느린 것을 확인할 수 있었다.

https://konlpy.org/ko/v0.4.3/_images/time.png

또 okt는 과거 트위터 형태소 분석기에서 발전한 모델이라고 한다.
트위터도 소셜텍스트이기 때문에 영화리뷰같은 오타, 띄어쓰기, 맞춤법이 잘 정제되지 않은 텍스트를 잘 분석할 수 있는 것 같다.

 

 

  • 토큰화(Tokenizing) - okt

한국어 형태소 분석기 okt.morphs를 이용한 토큰화

ex -  [ "더빙", "진짜", "짜증나네요", "목소리"]

 

토큰화 한 하면서 동시에 불용어 제거

 

나중에 다시 파일을 열었을 때 다시 토크나이징 하지 않고 바로 불러다가 쓸수 있도록 하기 위해서한번 해놓고 저장하는 코드를 만들어보았다. 

# okt - morphs
if os.path.exists('tokenized_train_text_okt_wopos.json'):
    print("load")
    with open('tokenized_train_text_okt_wopos.json', encoding='utf-8') as f:
        X_train = json.load(f)
else:
    X_train = [[token for token in okt.morphs(doc, stem=True) if not token in stopwords] for doc in train['document']]
    with open('tokenized_train_text_okt_wopos.json', 'w', encoding='utf-8') as f:
        json.dump(X_train,  f, ensure_ascii=False, indent='\t')
print('tokenizing train data finished')

if os.path.exists('tokenized_test_text_okt_wopos.json'):
    print("load")
    with open('tokenized_test_text_okt_wopos.json', encoding='utf-8') as f:
        X_test = json.load(f)
else:
    X_test = [[token for token in okt.morphs(doc, stem=True) if not token in stopwords] for doc in test['document']]
    with open('tokenized_test_text_okt_wopos.json', 'w', encoding='utf-8') as f:
        json.dump(X_test, f, ensure_ascii=False, indent='\t')
print('tokenizing test data finished')

 

Text Embedding -> Word2Vec

  • 희귀단어 정의

Word2Vec로 단어들을 임베딩 하기전에 자주 쓰이지 않았던 단어들은 큰 의미가 없을것이라 생각하여 제외하는 과정을 추가하였습니다.

 

전체 문서에서 5번 미만 나온 단어를 희귀단어라고 정의했을 때

전체 단어 집합에서 희귀단어의 비율은 67% 전체 문서에서 희귀단어가 등장하는 비율은 2.98% 이다.

 

단어의 개수는 많이 줄어지만 전체 등장 빈도는 3% 이하로 높지 않으며
영화 리뷰라는 한정적인 도메인에서 쓰이는 단어만 쓰인다는 부분과 학습의 효율성을 고려하여 제외하도록 하였습니다.

tokenizer = Tokenizer()
tokenizer.fit_on_texts(X_train)

threshold = 5
total_cnt = len(tokenizer.word_index) # 단어의 수
rare_cnt = 0 # 등장 빈도수가 threshold보다 작은 단어의 개수를 카운트
total_freq = 0 # 훈련 데이터의 전체 단어 빈도수 총 합
rare_freq = 0 # 등장 빈도수가 threshold보다 작은 단어의 등장 빈도수의 총 합

# 단어와 빈도수의 쌍(pair)을 key와 value로 받는다.
for key, value in tokenizer.word_counts.items():
    total_freq = total_freq + value

    # 단어의 등장 빈도수가 threshold보다 작으면
    if(value < threshold):
        rare_cnt = rare_cnt + 1
        rare_freq = rare_freq + value

print('단어 집합(vocabulary)의 크기 :',total_cnt) # 43748
print('등장 빈도가 %s번 이하인 희귀 단어의 수: %s'%(threshold - 1, rare_cnt)) # 29516
print("단어 집합에서 희귀 단어의 비율:", (rare_cnt / total_cnt)*100) # 67.4682
print("전체 등장 빈도에서 희귀 단어 등장 빈도 비율:", (rare_freq / total_freq)*100) # 2.9837

 

  • Word2Vec 임베딩 벡터 학습 및 정의
# word2vec 임베딩 벡터 만들기
w2vmodel_wopos = Word2Vec(sentences=X_train, size=300, window=5, min_count=5, iter=10)
print(w2vmodel_wopos.wv.vectors.shape,'\n') # (14232,300)
# 14232가 word2vec의 vocab_size가 된다.

'최민식', '재미없다', '히어로' 와 비슷한 단어들을 Word2Vec에서 뽑아보았습니다.

print(w2vmodel_wopos.wv.most_similar('최민식'),'\n')
print(w2vmodel_wopos.wv.most_similar('재미없다'),'\n')
print(w2vmodel_wopos.wv.most_similar('히어로'),'\n')

# 임베딩 벡터값과 정수 인덱싱을 맵핑하는 함수이다.

embedding_matrix = np.zeros((vocab_size, 300))
print(np.shape(embedding_matrix)) # (14233, 300)

def get_vectors(w2v_model, word):
    if word in w2v_model:
        return w2v_model[word]
    else:
        None

# # 단어 집합 크기의 행과 300개의 열을 가지는 행렬 생성. 값은 전부 0으로 채워진다.
for word, i in tokenizer.word_index.items():
    temp = get_vectors(w2vmodel_wopos, word)
    if temp is not None:
        embedding_matrix[i] = temp

 

임베딩 벡터의 전체 단어 개수는 14233개가 나왔습니다.

 

이제 훈련데이터를 임베딩벡터의 단어 개수만큼 정수 인덱싱을 할 수 있도록 토크나이저 객체를 다시 선언합니다.

 

tokenizer = Tokenizer(vocab_size)
tokenizer.fit_on_texts(X_train)
# 텍스트 데이터 인코딩작업
X_train = tokenizer.texts_to_sequences(X_train)
X_test = tokenizer.texts_to_sequences(X_test)
print(X_train[0])
print(X_test[0])

라벨값도 모델에 맞춰줍니다.

y_train = np.array(train['label'])
y_test = np.array(test['label'])

 

  • 빈 셈플 제거

전체 데이터에서 빈도수가 낮은 단어가 삭제되었다는 것은 희귀단어로만 이루어진 데이터가 삭제되었을 수도 있습니다.

삭제되었다면 아무런 텍스트정보가 없는 빈 데이터가 될 것이고

라벨이 붙어있다 하더라도 의미가 없으므로 제거합니다.

 

# 단어개수(vocab size)를 줄이면서 생긴 빈 샘플의 수
drop_train = [index for index, sentence in enumerate(X_train) if len(sentence) < 1]
print(len(drop_train)) # 327
# 빈 셈플 제거
print(len(X_train)) # 143660
X_train = np.delete(X_train, drop_train, axis=0)
y_train = np.delete(y_train, drop_train, axis=0)
print(len(X_train)) # 143333
print(len(y_train)) # 143333

 

  • 패딩(Padding)

리뷰마다 단어의 개수가 다르다는 것은 당연한 사실.
그러나 lstm의 모델에 input으로 넣기 위해서는 모든 데이터들의 차원을 통일시킬 필요가 있다.
이를 위해서 사용하는 기법이 패딩이고
얼만큼의 리뷰길이가 적절한지 알아보자

 

print('리뷰의 최대 길이 :',max(len(review) for review in X_train)) # 69
print('리뷰의 평균 길이 :',sum(map(len, X_train))/len(X_train)) # 10.77
plt.hist([len(review) for review in X_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()

길이가 5~15정도인 리뷰가 가장 많다

def below_threshold_len(max_len, nested_list):
    count = 0
    for sentence in nested_list:
        if(len(sentence) <= max_len):
            count = count + 1
    print('전체 샘플 중 길이가 %s 이하인 샘플의 비율: %s'%(max_len, (count / len(nested_list))*100))

max_len = 30
below_threshold_len(max_len, X_train) # 94.435

길이 30 이하인 리뷰가 전체 리뷰중에서 94%이다. 

 

리뷰 최대값을 30으로 설정!

X_train = pad_sequences(X_train, maxlen=max_len)
X_test = pad_sequences(X_test, maxlen=max_len)

LSTM 모델 학습

  • LSTM모델 with Word2Vec
# 하이퍼 파라미터 설정
batch_size = 128
EPOCHS = 15

checkpoint_path = 'results/lstm_w2v.ckpt'
checkpoint_dir = os.path.dirname(checkpoint_path)# Create a callback that saves the model's weights
cp_callback = tf.keras.callbacks.ModelCheckpoint(filepath=checkpoint_path,
                                save_weights_only=True,
                                verbose=1)# Train the model with the new callback

earlystopper = tf.keras.callbacks.EarlyStopping(monitor="val_loss", patience=3, verbose=1)

Embedding layer에서 weights를 미리 학습해둔 embedding_matrix로 설정하고 trainable옵션은 False로 하여 학습된 벡터를 사용하도록 한다.

# Modeling - lstm with word2vec
model = Sequential()
model.add(Embedding(vocab_size, 300, weights=[embedding_matrix], input_length=max_len, trainable=False)) # weights를 미리 학습해둔 embedding_matrix로 설정하고 trainable옵션은 False로 하여 학습된 벡터를 사용하도록 한다.
model.add(LSTM(128, dropout=0.2))
model.add(Dense(64, activation = 'tanh'))
model.add(Dense(1, activation = 'sigmoid'))
print(model.summary())

# 모델 컴파일
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])

# 학습
history = model.fit(X_train, y_train, epochs=EPOCHS, callbacks=[earlystopper,cp_callback], batch_size=batch_size, validation_split=0.2)

# Model Test
score, acc = model.evaluate(X_test, y_test, batch_size=batch_size)

# Model Save
model.save('results/lstm_w2v.h5')

# score
print("Test Score: ", score) # 0.4616
print("Test Accuracy: ", acc) # 0.8427

 

  • LSTM without Word2Vec

이건 keras에서 제공하는 embedding layer를 사용한 모델이다. 

# Modeling - lstm without word2vec
model_wo_w2v = Sequential()
model_wo_w2v.add(Embedding(vocab_size, 300, input_length=max_len)) # keras에서 제공하는 embedding vector를 사용
model_wo_w2v.add(LSTM(128, dropout=0.2))
model_wo_w2v.add(Dense(64, activation = 'tanh'))
model_wo_w2v.add(Dense(1, activation = 'sigmoid'))
print(model_wo_w2v.summary())

# 모델 컴파일
model_wo_w2v.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])

# 학습
history = model_wo_w2v.fit(X_train, y_train, epochs=EPOCHS, callbacks=[earlystopper,cp_callback], batch_size=batch_size, validation_split=0.2)

# Model Test
score, acc = model_wo_w2v.evaluate(X_test, y_test, batch_size=batch_size)

# Model Save
model_wo_w2v.save('results/lstm.h5')

# score
print("Test Score: ", score) # 0.4616
print("Test Accuracy: ", acc) # 0.8427

 

  • 리뷰 예측해보기
def sentiment_predict(new_sentence,loaded_model):
    new_sentence = re.sub(r'[^ㄱ-ㅎㅏ-ㅣ가-힣 ]','', new_sentence)
    new_sentence = okt.morphs(new_sentence, stem=True) # 토큰화
    new_sentence = [word for word in new_sentence if not word in stopwords] # 불용어 제거
    print(new_sentence)
    encoded = tokenizer.texts_to_sequences([new_sentence]) # 정수 인코딩
    pad_new = pad_sequences(encoded, maxlen = max_len) # 패딩
    score = float(loaded_model.predict(pad_new)) # 예측
    if(score > 0.5):
        print("{:.2f}% 확률로 긍정 리뷰입니다.\n".format(score * 100))
    else:
        print("{:.2f}% 확률로 부정 리뷰입니다.\n".format((1 - score) * 100))

Word2Vec를 사용한 LSTM 모델

print('LSTM 모델 with Word2Vec')
sentiment_predict('겁나 재밌었음. 다음에도 이 감독이 만든 영화는 무조건 챙겨본다 진짜',model)
sentiment_predict('이딴 영화를 왜 만드는거임 정말?',model)

 

Word2Vec를 사용하지 않은 LSTM 모델

print('LSTM 모델 without Word2Vec')
sentiment_predict('겁나 재밌었음. 다음에도 이 감독이 만든 영화는 무조건 챙겨본다 진짜',model_wo_w2v)
sentiment_predict('이딴 영화를 왜 만드는거임 정말?',model_wo_w2v)

 

결론

주변 단어까지도 같이 학습하고 관계를 내포할 수 있는 Word2Vec로 LSTM을 학습시키면 더 나은 성능을 보이지 않을까 예상하고 시작한 프로젝트였다.

word2vec 임베딩벡터를 사용한 것과 keras 임베딩 벡터를 사용한 두 모델을 비교해보았고 결과는 크게 다르지 않았다.

 

개선할 점

  • 왜 Word2Vec을 사용한 모델과 사용하지 않은 모델의 차이는 별로 없는지?
  • Glove, Fasttext 임베딩 모델을 사용하면 더 나아질 수 있는지?
  • KoBert모델을 사용하면 90점까지 나온다고 하는데 시도해보기

 


이번 프로젝트를 시작하면서 다양한 사람들의 코드를 째려보았습니다...하핳

열심히 째려보면서 배우고 느낄 수 있는 귀한 시간이었던 것 같습니다.

 

많은 도움이 되었던 링크입니다.

사실 이 프로젝트는 대부분은 이 분들의 코드를 짜깁기하고 제 아이디어(Word2Vec)를 접목시킨 것 입니다.


 

풀 코드는 아래 제 깃헙에 올려두었습니다.

부족한 글이지만 여기까지 모두 읽어주셨다면 정말로 감사하다는 말씀을 드리며

이 글 혹은 어떤 부분이던 피드백, 지적은 언제나 환영합니다!

꼭 부탁드립니다!

감사합니다.

 

모두 좋은 하루 되세요!

 

https://github.com/aytekin827/nsmc_sentment_classifier/blob/main/nsmc_with_W2VandLSTM.ipynb

 

GitHub - aytekin827/nsmc_sentment_classifier: Sentiment classifier with NSMC data

Sentiment classifier with NSMC data. Contribute to aytekin827/nsmc_sentment_classifier development by creating an account on GitHub.

github.com

 

728x90