LSTM을 사용하여 영화 리뷰 데이터의 감성 분석을 하려고 한다.
데이터셋 다운로드
https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt
https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt
0. 필요 라이브러리 선언
import pickle
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import re
import urllib.request
from konlpy.tag import Okt
from tqdm import tqdm
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
import matplotlib.font_manager as fm
import seaborn as sns
font_path = "C:/Windows/Fonts/malgun.ttf"
# 폰트 설정
font_name = fm.FontProperties(fname=font_path).get_name()
plt.rcParams['font.family'] = font_name
plt.rcParams['axes.unicode_minus'] = False
1. 데이터셋 로드
train_data = pd.read_table('./data/ratings_train.txt')
test_data = pd.read_table('./data/ratings_test.txt')
훈련 데이터 수 확인

훈련 데이터 샘플 확인
train_data[:5]

테스트 데이터 수 확인

테스트 데이터 샘플 확인

2. 데이터 정제
- train_data의 데이터 중복 유무 확인
# document 열과 label 열의 중복을 제외한 값의 개수
train_data['document'].nunique(), train_data['label'].nunique()
(146182, 2)
150,000개의 샘플 중 중복을 제거한 샘플의 수가 146,182개 라는것은 약 4,000개의 중복 샘플이 존재한다는것을 뜻함
- 중복 데이터 제거
# document 열의 중복 제거
train_data.drop_duplicates(subset=['document'], inplace=True)
- 샘플 수 확인
print(f'총 샘플의 수 : {len(train_data):,}')
총 샘플의 수 : 146,183
- train_data에서 label 분포 확인
- 73,342, 72,841로 약 반반의 비율을 보여주고 있음
labels, frequencies = np.unique(train_data.label.values, return_counts=True)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
ax = sns.countplot(ax=axes[0], x="label", hue="label", data=train_data, palette="pastel", legend=False)
axes[0].set_title("Label Count")
for p in ax.patches:
height = p.get_height()
ax.annotate(f'{int(height):,}',
(p.get_x() + p.get_width() / 2., height),
ha='center', va='bottom', fontsize=10)
axes[1].pie(frequencies, labels=labels, autopct='%1.1f%%', colors=sns.color_palette("pastel"))
axes[1].set_title("Label 비율")
plt.tight_layout()
plt.show()

- 결측치 확인
- document 열에서 1개의 샘플이 Null 값을 갖는 결측치를 가지고 있음
print(train_data.isnull().sum())

- 결측치 제거
# 결측치 제거
train_data = train_data.dropna(how = 'any')
# 결측치 확인
print(train_data.isnull().values.any())
False
- 데이터 전처리
- train_data, test_data에서 온점(.)이나 ?와 같은 특수 문자가 사용되었고, train_data로 부터 한글만 남기고 나머지를 제거하기 위해 정규 표현식을 사용하여 처리
# 한글과 공백을 제외하고 모두 제거
train_data['document'] = train_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","", regex=True)
train_data[:5]

- 네이버 영화 리뷰는 영어, 숫자, 특수문자로도 리뷰를 업로드 할수 있음.
- 한글이 없는 리뷰라면 빈(empty)값이 됨. train_data에 공백(whitespace)만 있거나 빈값을 가진 행이 있다면 Null값으로 변경하도록 하고 Null값이 존재하는지 확인.
- 789개의 Null값이 생성됨
train_data['document'] = train_data['document'].str.replace('^ +', "", regex=True) # white space 데이터를 empty value로 변경
train_data['document'].replace('', np.nan, inplace=True)
print(train_data.isnull().sum())

- Null 샘플 제거
train_data = train_data.dropna(how='any')
print(f'데이터 수: {len(train_data):,}')
데이터 수: 145,393
- 테스트 데이터도 동일한 처리
# document 열에서 중복인 내용이 있다면 중복 제거
test_data.drop_duplicates(subset = ['document'], inplace=True)
# 정규 표현식 수행
test_data['document'] = test_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","", regex=True)
# 공백은 empty 값으로 변경
test_data['document'] = test_data['document'].str.replace('^ +', "", regex=True)
# 공백은 Null 값으로 변경
test_data['document'].replace('', np.nan, inplace=True)
# Null 값 제거
test_data = test_data.dropna(how='any')
print(f'전처리 후 테스트용 샘플의 개수: {len(test_data):,}')
전처리 후 테스트용 샘플의 개수: 48,852
3. 토큰화
토큰화를 진행하며, 토큰화 과정에서 불용어를 제거. 불용어는 정의하기 나름인데 조사, 접속사 등의 보편적인 불용어를 사용할수도 있으나, 풀고자 하는 문제의 데이터를 지속적으로 검토하여 추가하는 경우도 많음
현재의 데이터는 아래와 같은 불용어로 정의
stopwords = ['의','가','이','은','들','는','좀','잘','걍','과','도','를','으로','자','에','와','한','하다']
Okt는 KoNLPy에서 제공하는 형태소 분석기. 한국어를 토큰화 할때는 영어처럼 띄어쓰기 기준으로 토큰화를 하는것이 아니라, 주로 형태소 분석기를 사용한다고함.
stem = True를 사용하면 일정 수준의 정규화를 수행해주는데, 예를 들어 '이런' -> '이렇다'로 변환되었고 '만드는' -> '만들다' 로 변환됨
train_data에 형태소 분석기를 사용하여 토큰화를 하면서 불용어를 제거하여 X_train에 저장
okt = Okt()
X_train = []
for sentence in tqdm(train_data['document']):
tokenized_sentence = okt.morphs(sentence, stem=True) # 토큰화
stopwords_removed_sentence = [word for word in tokenized_sentence if not word in stopwords] # 불용어 제거
X_train.append(stopwords_removed_sentence)
print(X_train[:3])

테스트 데이터도 동일한 처리를 함
X_test = []
for sentence in tqdm(test_data['document']):
tokenized_sentence = okt.morphs(sentence, stem=True) # 토큰화
stopwords_removed_sentence = [word for word in tokenized_sentence if not word in stopwords] # 불용어 제거
X_test.append(stopwords_removed_sentence)
4. 정수 인코딩
기계가 텍스트를 숫자로 처리할 수 있도록 훈련 데이터와 테스트 데이터에 정수 인코딩을 수행해야함
tokenizer = Tokenizer()
tokenizer.fit_on_texts(X_train)
print(tokenizer.word_index)

단어가 43,000개가 넘게 존재하며, 각 정수는 전체 훈련 데이터에서 등장 빈도수가 높은 순서대로 부여되었기 때문에, 높은 정수가 부여된 단어들은 등장 빈도수가 매우 낮다는것을 의미.
빈도수가 낮은 단어들은 자연어 처리에서 배제해야함. 등장 빈도수가 3회 미만인 단어들이 이 데이터에서 얼만큼의 비중을 차지하는지 확인
threshold = 3
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)
print('등장 빈도가 %s번 이하인 희귀 단어의 수: %s'%(threshold - 1, rare_cnt))
print("단어 집합에서 희귀 단어의 비율:", (rare_cnt / total_cnt)*100)
print("전체 등장 빈도에서 희귀 단어 등장 빈도 비율:", (rare_freq / total_freq)*100)

등장 빈도가 threshold 값인 3회 미만. 즉, 2회 이하인 단어들은 단어 집합에서 무려 절반 이상을 차지함. 하지만 실제로 훈련 데이터에서 등장 빈도로 차지하는 비중은 상대적으로 매우 적은 수치인 1.87% 밖에 되지 않음.
등장 빈도가 2회 이하인 단어들은 자연어 처리에서 별로 중요하지 않을듯 함. 이 단어들은 인코딩 과정에서 배제
등장 빈도수가 2이하인 단어들의 수를 제외한 단어의 개수를 단어 집합의 최대 크기로 제한
# 전체 단어 개수 중 빈도수 2이하인 단어는 제거.
# 0번 패딩 토큰을 고려하여 + 1
vocab_size = total_cnt - rare_cnt + 1
print('단어 집합의 크기 :',vocab_size)
단어 집합의 크기 : 19416
단어 집합의 크기는 19,416개 입니다. 이를 keras 토크나이저의 인자로 넘겨주고 텍스트 시퀀스로 변환합니다.
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[:3])
[[50, 454, 16, 260, 659], [933, 457, 41, 602, 1, 214, 1449, 24, 961, 675, 19], [386, 2444, 2315, 5671, 2, 222, 9]]
y데이터 저장
y_train = np.array(train_data['label'])
y_test = np.array(test_data['label'])
5. 빈 샘플 제거
전체 데이터에서 빈도수가 낮은 단어가 삭제 되었다는 것은 빈도수가 낮은 단어로만 구성된 샘플은 빈 샘플이 되었음.
빈 샘플은 어떤 레이블이 붙어있던 의미가 없으므로 빈 샘플들을 제거해주는 작업이 필요함
각 샘플들의 길이를 확인해서 길이가 0인 샘플들의 인덱스를 가져옴
drop_train = [index for index, sentence in enumerate(X_train) if len(sentence) < 1]
인덱스를 통해 리스트컴프리헨션으로 샘플 제거
X_train = [x for i, x in enumerate(X_train) if i not in drop_train]
y_train = [y for i, y in enumerate(y_train) if i not in drop_train]
print(len(X_train))
print(len(y_train))
145162
145162
6. 패딩
서로 다른 길이의 샘플들이 길이를 동일하게 맞춰주는 패딩 작업
전체 데이터에서 가장 길이가 긴 리뷰와 전체 데이터의 길이 분포 확인
print('리뷰의 최대 길이 :',max(len(review) for review in X_train))
print('리뷰의 평균 길이 :',sum(map(len, X_train))/len(X_train))
plt.hist([len(review) for review in X_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()
리뷰의 최대 길이 : 69
리뷰의 평균 길이 : 10.812485361182679

가장 긴 리뷰의 길이는 69이며, 그래프를 확인했을때 전체 데이터의 길이 분포는 대체적으로 약 11내외의 길이를 가지는것이 확인됨.
모델이 처리할수 있도록 X_train과 X_test의 모든 샘플의 길이를 특정 길이로 동일하게 맞춰줄 필요가 있음.
특정 길이 변수를 max_len으로 정의.
대부분 리뷰가 내용이 잘리지 않도록 할 수 있는 최적의 max_len을 구하는 함수 작성
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)
전체 샘플 중 길이가 30 이하인 샘플의 비율: 94.31944999380003
max_len이 30일때 샘플 중 약 94%가 포함됨
max_len 30으로 패딩 진행
X_train = np.array(X_train, dtype=object)
y_train = np.array(y_train)
X_train = pad_sequences(X_train, maxlen=max_len)
X_test = pad_sequences(X_test, maxlen=max_len)
7. LSTM 모델링
이진 분류 문제를 수행해야하므로 활성함수는 시그모이드, 손실함수는 크로스 엔트로피 함수를 사용.
EarlyStopping을 사용하여 검증 데이터 손실이 4회 증가하면 종료 하도록 설정하며, ModelCheckpoint를 사용하여 검증 데이터의 정확도가 이전보다 좋을때만 모델을 저장하도록 설정
from tensorflow.keras.layers import Embedding, Dense, LSTM
from tensorflow.keras.models import Sequential
from tensorflow.keras.models import load_model
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
embedding_dim = 100
hidden_units = 128
model = Sequential()
model.add(Embedding(vocab_size, embedding_dim))
model.add(LSTM(hidden_units))
model.add(Dense(1, activation='sigmoid'))
es = EarlyStopping(monitor='val_loss', mode='min', verbose=1, patience=4)
mc = ModelCheckpoint('best_model.h5', monitor='val_acc', mode='max', verbose=1, save_best_only=True)
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
history = model.fit(X_train, y_train, epochs=15, callbacks=[es, mc], batch_size=64, validation_split=0.2)
테스트셋 검증
loaded_model = load_model('best_model.h5')
print("\n 테스트 정확도: %.4f" % (loaded_model.evaluate(X_test, y_test)[1]))
테스트 정확도: 0.8513
테스트 데이터에서 약 85.13%의 정확도를 보임
tokenizer저장
with open('tokenizer.pickle', 'wb') as handle:
pickle.dump(tokenizer, handle)
8. 리뷰 데이터 예측
리뷰를 예측 하는 전처리 및 예측 함수 작성
def sentiment_predict(new_sentence):
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] # 불용어 제거
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))

'Data Science&AI' 카테고리의 다른 글
| [데이터 수집] selenium 사용하지 않고 데이터 수집하기 (9) | 2025.07.15 |
|---|