이번에는 LSTM(Long Short-Term Memory) 을 이용해 네이버 영화 리뷰가 긍정인지 부정인지 분류해보았다.
사용할 데이터 : 네이버 영화 리뷰 데이터(NSMC)
전체 데이터 : 200,000개
Train : 150,000개
Test : 50,000개
Label : 긍정1, 부정0
전체 과정은 다음과 같다.
영화 리뷰
↓
데이터 정제
↓
형태소 분석
↓
정수 인코딩
↓
Padding
↓
Embedding
↓
LSTM
↓
긍정 / 부정 분류
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import urllib.request
from konlpy.tag import Mecab
from tqdm import tqdm
from sklearn.model_selection import train_test_split
from collections import Counter
이번 실습에서는 특히
Mecab : 한국어 형태소 분석Counter : 단어 등장 빈도 계산train_test_split : Train / Validation 데이터 분리를 사용한다.
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt",
filename="ratings_train.txt"
)
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt",
filename="ratings_test.txt"
)
train_data = pd.read_table('ratings_train.txt')
test_data = pd.read_table('ratings_test.txt')
데이터 개수를 확인하면
Train : 150,000
Test : 50,000
이고 데이터는
id / document / label
세 개의 컬럼으로 구성되어 있다.
여기서
document → 모델의 입력
label → 모델이 맞혀야 할 정답
이라고 이해하면 된다.
먼저 중복된 리뷰를 확인한다.
train_data['document'].nunique()
146182
150,000개의 데이터 중 중복된 리뷰가 존재하기 때문에 제거한다.
train_data.drop_duplicates(
subset=['document'],
inplace=True
)
Null 데이터도 제거한다.
train_data = train_data.dropna(how='any')
그리고 한글과 공백을 제외한 문자를 제거한다.
train_data['document'] = train_data['document'].str.replace(
"[^ㄱ-ㅎㅏ-ㅣ가-힣 ]",
"",
regex=True
)
특수문자를 제거하면서 아무 내용도 남지 않은 리뷰가 새롭게 생길 수 있으므로 빈 문자열도 제거한다.
train_data['document'].replace('', np.nan, inplace=True)
train_data = train_data.dropna(how='any')
145,393개
처음에는 150,000개였지만 중복, Null, 의미 없는 데이터를 제거하면서 데이터가 줄어들었다.
한국어는 영어처럼 단순히 띄어쓰기만으로 단어를 나누기 어렵다.
예를 들어
나는 영화를 봤다
에서 나는, 영화를, 봤다에는 조사와 어미가 붙어 있다.
그래서 Mecab 형태소 분석기를 이용한다.
mecab = Mecab()
mecab.morphs(
'와 이런 것도 영화라고 차라리 뮤직비디오를 만드는 게 나을 뻔'
)
['와', '이런', '것', '도', '영화', '라고',
'차라리', '뮤직', '비디오', '를',
'만드', '는', '게', '나을', '뻔']
문장이 형태소 단위로 분리되는 것을 확인할 수 있다.
분류에 큰 의미가 없다고 판단되는 조사 등의 단어는 제거한다.
stopwords = [
'도', '는', '다', '의', '가', '이',
'은', '한', '에', '하', '고',
'을', '를', '인', '듯', '과',
'와', '네', '들', '지', '임', '게'
]
전체 리뷰에 형태소 분석과 불용어 제거를 적용한다.
X_train = []
for sentence in tqdm(train_data['document']):
tokenized_sentence = mecab.morphs(sentence)
stopwords_removed_sentence = [
word for word in tokenized_sentence
if word not in stopwords
]
X_train.append(stopwords_removed_sentence)
결과적으로
문장
↓
형태소들의 리스트
형태로 변환된다.
컴퓨터는 영화, 재밌다 같은 문자열을 그대로 계산할 수 없다.
따라서 먼저 학습 데이터에 어떤 단어들이 존재하는지 확인한다.
word_list = []
for sent in X_train:
for word in sent:
word_list.append(word)
word_counts = Counter(word_list)
print(len(word_counts))
45,296
약 45,000개의 서로 다른 형태소가 존재했다.
하지만 이 중에는 한두 번밖에 등장하지 않는 단어도 많았다.
2회 이하 등장한 희귀 단어를 확인한 결과
희귀 단어 비율 : 약 57.63%
전체 등장 빈도에서 차지하는 비율 : 약 2.28%
였다.
즉 단어 종류에서는 절반 이상이지만 실제 문장에서는 거의 등장하지 않는다.
그래서 희귀 단어를 제외하고 단어 집합을 만들었다.
Vocabulary : 19,191개
여기에
<PAD> = 0
<UNK> = 1
을 추가한다.
19,193개
<UNK>는 학습할 때 보지 못한 단어, <PAD>는 문장 길이를 맞추는 데 사용한다.
이제 단어를 숫자로 바꾼다.
예를 들어
영화 → 2
좋 → 6
너무 → 11
와 같은 방식이다.
def texts_to_sequences(tokenized_data, word_to_index):
encoded_data = []
for sent in tokenized_data:
encoded_sent = []
for word in sent:
encoded_sent.append(
word_to_index.get(
word,
word_to_index['<UNK>']
)
)
encoded_data.append(encoded_sent)
return encoded_data
이를 통해
['영화', '정말', '재밌']
같은 문장은
[2, 53, 142]
와 같은 숫자의 나열로 바뀐다.
중요한 것은 숫자의 크기 자체에 의미가 있는 것이 아니라 단어를 구별하기 위한 번호라는 것이다.
문제는 리뷰마다 길이가 다르다는 것이다.
리뷰 A → 7개 단어
리뷰 B → 15개 단어
리뷰 C → 30개 단어
신경망에서 Batch 단위로 처리하기 위해서는 길이를 동일하게 맞출 필요가 있다.
Train 데이터의 길이를 확인해보면
최대 길이 : 74
평균 길이 : 약 12.3
이었다.
길이를 30으로 설정하면 약 92.5%의 리뷰를 포함할 수 있기 때문에
max_len = 30
으로 설정하였다.
Padding 이후 데이터 크기는
Train : (116314, 30)
Validation : (29079, 30)
Test : (48852, 30)
이 된다.
예를 들어
[924, 1866, 128, 7, 80, 48, 34]
라는 리뷰는
[924, 1866, 128, 7, 80, 48, 34,
0, 0, 0, 0, ...]
처럼 길이 30까지 PAD = 0으로 채워진다.
이제 실제 감성 분류 모델을 만든다.
모델 구조는 생각보다 간단하다.
Integer Encoding
↓
Embedding
↓
LSTM
↓
Linear
↓
긍정 / 부정
코드는 다음과 같다.
class TextClassifier(nn.Module):
def __init__(
self,
vocab_size,
embedding_dim,
hidden_dim,
output_dim
):
super().__init__()
self.embedding = nn.Embedding(
vocab_size,
embedding_dim
)
self.lstm = nn.LSTM(
embedding_dim,
hidden_dim,
batch_first=True
)
self.fc = nn.Linear(
hidden_dim,
output_dim
)
def forward(self, x):
embedded = self.embedding(x)
lstm_out, (hidden, cell) = self.lstm(
embedded
)
last_hidden = hidden.squeeze(0)
return self.fc(last_hidden)
사용한 주요 설정은
Vocabulary Size : 19,193
Embedding Dimension : 100
Hidden Dimension : 128
Output Dimension : 2
Batch Size : 32
Epoch : 5
Learning Rate : 0.001
이다.
이 부분이 개인적으로 가장 중요했다.
Batch Size가 32라면
Input
(32, 30)
↓
Embedding
(32, 30, 100)
↓
LSTM
(32, 128)
↓
Linear
(32, 2)
가 된다.
즉 Embedding Layer에서는 각각의 단어를 100차원의 벡터로 표현한다.
LSTM은 문장의 순서를 따라 정보를 읽고 마지막에 128차원의 Hidden State로 문장의 정보를 요약한다.
마지막 Linear Layer는
부정 점수 / 긍정 점수
두 값을 출력한다.
Loss Function은
criterion = nn.CrossEntropyLoss()
Optimizer는
optimizer = torch.optim.Adam(
model.parameters(),
lr=0.001
)
을 사용한다.
하나의 Batch가 학습되는 흐름은
Forward
↓
Loss 계산
↓
Gradient 초기화
↓
Backward
↓
Parameter Update
이다.
코드로 보면
logits = model(batch_X)
loss = criterion(
logits,
batch_y
)
optimizer.zero_grad()
loss.backward()
optimizer.step()
이 과정이 반복되면서 LSTM의 Parameter가 업데이트된다.
학습 과정에서는 Train 데이터만 보는 것이 아니라 Validation 데이터의 Loss를 확인해서 가장 좋은 모델을 저장한다.
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(
model.state_dict(),
'best_model_checkpoint.pth'
)
최종적으로 가장 좋은 모델을 불러와 평가한 결과
| 데이터 | Loss | Accuracy |
|---|---|---|
| Validation | 0.3392 | 84.90% |
| Test | 0.3435 | 84.92% |
Test Accuracy는 약
84.92%
가 나왔다.
Validation과 Test Accuracy가 거의 비슷하게 나왔다는 것도 확인할 수 있었다.
학습한 모델에 직접 문장을 입력해보았다.
test_input = "이 영화 개꿀잼 ㅋㅋㅋ"
긍정
반대로
test_input = "이딴게 영화냐 ㅉㅉ"
부정
실제 인터넷에서 사용할 법한 표현도 어느 정도 긍정과 부정을 구분하는 것을 확인할 수 있었다.
LSTM 모델 자체만 보는 것이 아니라 자연어가 모델에 입력되기까지의 과정
문장
↓
형태소 분석
↓
불용어 제거
↓
Vocabulary 생성
↓
정수 인코딩
↓
Padding
↓
Embedding
↓
LSTM
↓
Linear
↓
긍정 / 부정