드디어 Going-Deeper 과정이 시작됐다.
필자는 CV와 NLP 중 NLP 과정을 선택했다. (어차피 나중에 CV도 해야 할테지만 일단 NLP 부터 하고싶어서)
GD 첫 번째 노드는 여러 가지 형태소 분석기에 관한 내용이었다.
이번 프로젝트의 목표는 SentencePiece와 다른 형태소 분석기에 따른 LSTM 모델의 성능 차이를 탐색하는 것이다.
데이터는 Ex05에서 다루었던 "네이버 영화리뷰 감정분석 데이터"를 사용했다.
목차
- Load Data & Library
- Preprocessing
- Tokenization
- DataLoader
- Model training
- Mcab & Okt
- 결론
1. Load Data & Library
# KoNLPy 설치
!pip install konlpy
# SentencePiece 설치
!pip install sentencepiece
import sentencepiece as spm
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.nn.utils.rnn import pad_sequence
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import konlpy
import time
# 네이버 영화리뷰 감정분석 데이터
!wget https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt
!wget https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt
train = pd.read_table("/content/ratings_train.txt")
test = pd.read_table("/content/ratings_test.txt")
print(train.info())
train.head()
결과:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 150000 entries, 0 to 149999
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 id 150000 non-null int64
1 document 149995 non-null object
2 label 150000 non-null int64
dtypes: int64(2), object(1)
memory usage: 3.4+ MB
None
id document label
9976970 아 더빙.. 진짜 짜증나네요 목소리 0
3819312 흠...포스터보고 초딩영화줄....오버연기조차 가볍지 않구나 1
10265843 너무재밓었다그래서보는것을추천한다 0
9045019 교도소 이야기구먼 ..솔직히 재미는 없다..평점 조정 0
6483659 사이몬페그의 익살스런 연기가 돋보였던 영화!스파이더맨에서 늙어보이기만 했던 커스틴 ... 1
데이터는 고유 ID, 리뷰 텍스트, 레이블(감정분석 용도) 세 개의 컬럼으로 이루어져 있다.
2. Preprocessing
진행할 전처리는 아래와 같다.
- 반복되는 문장부호 (e.g. ‘…’, ‘;;;’) → 하나만 남기고 제거
- 반복 문자 축소 (3번 이상 반복 → 2번으로 축소)
- 이모티콘, 특수문자 제거
- 맞춤법 변형 통일
- 궅, 굳, 굿 ⇒ 굳
- e.g. "굳이" → "굿이" 로 바뀌어버리는 경우를 고려해 "굿"이 아닌 "굳"으로 통일
- ㅁㅊ, 미쳣, 미쳣다 → 미쳤
- 괜찮, 괜춘, 괜찬, ㄱㅊ, 갠찬, 갠찮, 괸찬, 괸찮 → 괜찮
- 봣 → 봤
- 겟 → 겠
- 궅, 굳, 굿 ⇒ 굳
- 문장부호 앞뒤로 공백 추가
- ‘ㅋ’, ‘ㅠ’ 등 자음/모음 단독으로 존재 → 제거
- 영어 → 전부 소문자 처리
- 불용어 제거
- 단어 끝에 불용어가 붙어있으면 제거
- 단, 남은 부분이 두 글자 이상일 때만 제거
- e.g. "굳이" → "굳" (X). "영화는" → "영화" (O)
- 연속 공백을 하나의 공백으로 교체
- 앞뒤 공백 제거
import re
# 불용어 리스트
STOPWORDS = ['의','가','이','은','들','는','좀','잘','걍','과','도','를','으로','자','에','와','한','하다']
# 맞춤법 변형 사전
SPELLING_DICT = {
'굳': ['궅', '굳', '굿'],
'미쳤': ['미첫', '미쳣', '미첬', '미쳤', 'ㅁㅊ'],
'괜찮': ['괜찮', '괜춘', '괜찬', 'ㄱㅊ', '갠찬', '갠찮', '괸찬', '괸찮'],
'봤': ['봣'],
'겠': ['겟']
}
# 텍스트 컬럼명
TEXT_COL = "document"
def preprocess_text(text):
"""
텍스트 전처리 함수
"""
if pd.isna(text):
return ""
text = str(text)
# 1. 반복되는 문장 부호 제거 (2개 이상 → 1개)
text = re.sub(r'([.!?…;])\1+', r'\1', text)
# 2. 맞춤법 변형 통일
for correct, variations in SPELLING_DICT.items():
for variant in variations:
text = text.replace(variant, correct)
# 3. 반복 문자 제거 (3번 이상 반복 → 2번)
text = re.sub(r'(.)\1{2,}', r'\1\1', text)
# 4. 자음/모음 단독 제거 (완성형 한글 필터링 전에 먼저 제거)
# 한글 자음: ㄱ-ㅎ, 한글 모음: ㅏ-ㅣ
text = re.sub(r'[ㄱ-ㅎㅏ-ㅣ]+', ' ', text)
# 5. 영어/숫자/한글/문장부호만 남기고 모두 삭제
# 이모지, 이모티콘, 특수문자 자동 제거
text = re.sub(r'[^가-힣a-zA-Z0-9\s.!?,]', ' ', text)
# 6. 문장부호 앞뒤로 공백 추가
text = re.sub(r'([.!?,])', r' \1 ', text)
# 7. 영어 소문자 변환
text = text.lower()
# 9. 불용어 제거 (조사가 붙은 경우도 처리)
words = text.split()
# 단어 끝에 불용어가 붙어있으면 제거
filtered_words = []
for word in words:
# 단어 전체가 불용어인 경우
if word in STOPWORDS:
continue
# 단어 끝에서 불용어 제거 (가장 긴 것부터 체크)
# 예: "학교에서" -> "학교", "영화는" -> "영화"
found = False
for stopword in sorted(STOPWORDS, key=len, reverse=True):
if len(word) > len(stopword) and word.endswith(stopword):
cleaned = word[:-len(stopword)]
# 남은 부분이 2글자 이상일 때만 제거
# 굳이 -> 굳, 같이 -> 같 이렇게 바뀌어서 길이 제한 추가했습니다.
# "굳이" -> "굳" (X), "영화는" -> "영화" (O)
if len(cleaned) >= 2:
filtered_words.append(cleaned)
found = True
break
if not found:
filtered_words.append(word)
text = ' '.join(filtered_words)
# 10. 연속 공백을 하나의 공백으로 교체
text = re.sub(r'\s+', ' ', text)
# 11. 앞뒤 공백 제거
text = text.strip()
return text
def preprocess_dataframe(df, text_col=TEXT_COL):
"""
데이터프레임 전처리 함수
"""
print(f"전처리 전 데이터 크기: {len(df)}")
# 1. 결측치 제거
df = df.dropna(subset=[text_col])
print(f"결측치 제거 후: {len(df)}")
# 2. 텍스트 전처리 적용
df[text_col] = df[text_col].apply(preprocess_text)
# 3. 전처리 후 빈 문자열 제거
df = df[df[text_col].str.strip() != '']
print(f"빈 문자열 제거 후: {len(df)}")
# 4. 중복 행 제거
df = df.drop_duplicates(subset=[text_col])
print(f"중복 제거 후: {len(df)}")
# 인덱스 재설정
df = df.reset_index(drop=True)
return df
# 샘플 텍스트로 테스트
sample_texts = [
"어제 본 영화 진짜 재밌었음!!! 또 보고 싶어 😂",
"나는 오늘 아침에 학교에 갔다. 근데 너무 졸렸음ㅋㅋㅋㅋ",
"밥은 먹었니?? 아직이야... 점심에 같이 먹자!!!",
"메캅 형태소 분석은 한국어 처리에서 많이 사용돼 👍",
"파이썬으로 토큰 빈도와 품사 분포를 시각화해 보자!!!",
"요즘 코사인 유사도 기반 벡터 검색으로 RG 구축을 많이 해!!",
"에이전트는 외부 도구를 호출해 작업을 자동화할 수 있어. 굳!",
"이 영화 진짜 미쳣다!!! 너무 재밌음ㅋㅋㅋㅋ",
"배우 연기 굳이 훌륭했음, 스토리는 봣지만...",
"이건 ㄱㅊ 영화네, 굿굿!"
]
print("=" * 80)
print("샘플 텍스트 전처리 결과")
print("=" * 80)
for i, text in enumerate(sample_texts, 1):
processed = preprocess_text(text)
print(f"\n[{i}] 원본: {text}")
print(f" 결과: {processed}")
print("\n" + "=" * 80)
print("실제 데이터 적용 예시")
print("=" * 80)
train_processed = preprocess_dataframe(train.copy())
test_processed = preprocess_dataframe(test.copy())
print("\n전처리 완료!")
print(f"Train 데이터: {len(train_processed)}개")
print(f"Test 데이터: {len(test_processed)}개")
print("\n전처리 결과 샘플:")
print(train_processed.head(10))
================================================================================
샘플 텍스트 전처리 결과
================================================================================
[1] 원본: 어제 본 영화 진짜 재밌었음!!! 또 보고 싶어 😂
결과: 어제 본 영화 진짜 재밌었음 ! 또 보고 싶어
[2] 원본: 나는 오늘 아침에 학교에 갔다. 근데 너무 졸렸음ㅋㅋㅋㅋ
결과: 나는 오늘 아침 학교 갔다 . 근데 너무 졸렸음
[3] 원본: 밥은 먹었니?? 아직이야... 점심에 같이 먹자!!!
결과: 밥은 먹었니 ? 아직이야 . 점심 같이 먹자 !
[4] 원본: 메캅 형태소 분석은 한국어 처리에서 많이 사용돼 👍
결과: 메캅 형태소 분석 한국어 처리에서 많이 사용돼
[5] 원본: 파이썬으로 토큰 빈도와 품사 분포를 시각화해 보자!!!
결과: 파이썬 토큰 빈도 품사 분포 시각화해 보자 !
[6] 원본: 요즘 코사인 유사도 기반 벡터 검색으로 RG 구축을 많이 해!!
결과: 요즘 코사인 유사 기반 벡터 검색 rg 구축을 많이 해 !
[7] 원본: 에이전트는 외부 도구를 호출해 작업을 자동화할 수 있어. 굳!
결과: 에이전트 외부 도구 호출해 작업을 자동화할 수 있어 . 굳 !
[8] 원본: 이 영화 진짜 미쳣다!!! 너무 재밌음ㅋㅋㅋㅋ
결과: 영화 진짜 미쳤다 ! 너무 재밌음
[9] 원본: 배우 연기 굳이 훌륭했음, 스토리는 봣지만...
결과: 배우 연기 굳이 훌륭했음 , 스토리 봤지만 .
[10] 원본: 이건 ㄱㅊ 영화네, 굿굿!
결과: 이건 괜찮 영화네 , 굳굳 !
================================================================================
실제 데이터 적용 예시
================================================================================
전처리 전 데이터 크기: 150000
결측치 제거 후: 149995
빈 문자열 제거 후: 149607
중복 제거 후: 144478
전처리 전 데이터 크기: 50000
결측치 제거 후: 49997
빈 문자열 제거 후: 49843
중복 제거 후: 48700
전처리 완료!
Train 데이터: 144478개
Test 데이터: 48700개
전처리 결과 샘플:
id document label
0 9976970 아 더빙 . 진짜 짜증나네요 목소리 0
1 3819312 흠 . 포스터보고 초딩영화줄 . 오버연기조차 가볍지 않구나 1
2 10265843 너무재밓었다그래서보는것을추천한다 0
3 9045019 교도소 이야기구먼 . 솔직히 재미 없다 . 평점 조정 0
4 6483659 사이몬페그 익살스런 연기 돋보였던 영화 ! 스파이더맨에서 늙어보이기만 했던 커스틴 ... 1
5 5403919 막 걸음마 뗀 3세부터 초등학교 1학년생인 8살용영화 . . 별반개 아까움 . 0
6 7797314 원작 긴장감을 제대로 살려내지못했다 . 0
7 9443947 별 반개 아깝다 욕나온다 이응경 길용우 연기생활이몇년인지 . 정말 발로해 그것보단 ... 0
8 7156791 액션 없는데 재미 있는 몇안되 영화 1
9 5912145 왜케 평점 낮은건데 ? 꽤 볼만한데 . 헐리우드식 화려함에만 너무 길들여져 있나 ? 1
전처리 결과 샘플을 보니 의도대로 전처리가 진행된 것을 확인할 수 있다.
3. Tokenization
SentencePiece를 이용한 토큰화를 진행한다.
3.1. SentencePiece 모델 학습
# 학습용 corpus 파일 생성
corpus_file = 'naver_reviews.train.temp'
with open(corpus_file, 'w', encoding='utf-8') as f:
for text in train_processed['document']:
f.write(text + '\n')
# SentencePiece 학습
vocab_size = 8000
model_prefix = 'naver_spm'
spm.SentencePieceTrainer.Train(
f'--input={corpus_file} '
f'--model_prefix={model_prefix} '
f'--vocab_size={vocab_size} '
f'--model_type=bpe '
f'--pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3 '
f'--pad_piece= --unk_piece= --bos_piece= --eos_piece='
)
!ls -l naver_spm*
-rw-r--r-- 1 root root 374858 Nov 13 14:20 naver_spm.model
-rw-r--r-- 1 root root 117611 Nov 13 14:20 naver_spm.vocab
# 학습된 SentencePiece 모델 활용 예시
sp = spm.SentencePieceProcessor(model_file=f'{model_prefix}.model')
sample_text = "이 영화 진짜 미쳣다!!! 너무 재밌음ㅋㅋㅋㅋ"
encoded_ids = sp.encode(preprocess_text(sample_text), # 샘플 text 전처리
out_type=int, add_bos=True, add_eos=True)
decoded_text = sp.decode(encoded_ids)
print("Encoded IDs:", encoded_ids)
print("Decoded Text:", decoded_text)
결과:
Encoded IDs: [2, 6, 52, 2756, 6458, 13, 26, 1137, 3]
Decoded Text: 영화 진짜 미쳤다 ! 너무 재밌음
3.2. 텐서로 변환
SentencePiece로 토큰화된 데이터를 LSTM 등 모델 입력용 tensor로 만드는 함수이다.
먼저, 패딩의 효율을 위해 max_length를 지정한다.
# max length 지정
import matplotlib.pyplot as plt
lens = [len(sp.EncodeAsIds(sen)) for sen in train_processed['document']]
max_len = int(np.percentile(lens, 95))
print(max_len)
plt.hist(lens, bins=50)
plt.show()
결과:
46

생성된 vocab 파일을 읽어와
- { <word> : <idx> } 형태를 가지는 word_index 사전과
- { <idx> : <word>} 형태를 가지는 index_word 사전을
생성해 함께 반환하고, 최대 길이(46)에 맞춰 패딩까지 진행한다.
def sp_tokenize(s, corpus, max_len=46):
tensor = []
# 1. 문장별 토큰화 + 최대 길이 제한
for sen in corpus:
ids = sp.EncodeAsIds(sen) # 토큰화
ids = ids[:max_len] # 최대 길이 자르기
tensor.append(torch.tensor(ids, dtype=torch.long)) # 텐서 변환
# 2. vocab 파일 불러오기
with open("./naver_spm.vocab", 'r') as f:
vocab = f.readlines()
# 3. 단어 인덱스 매핑
word_index = {}
index_word = {}
for idx, line in enumerate(vocab):
word = line.split("\t")[0]
word_index[word] = idx
index_word[idx] = word
# 4. PAD 토큰(0)으로 패딩
tensor = pad_sequence(tensor, batch_first=True, padding_value=0)
return tensor, word_index, index_word
4. Data Loader
모델에 넣을 데이터로더를 생성한다.
from torch.utils.data import TensorDataset, DataLoader, random_split
def create_dataloaders(train_tensor, train_labels, test_tensor, test_labels,
batch_size=32, val_ratio=0.2):
dataset = TensorDataset(train_tensor, train_labels)
val_size = int(len(dataset) * val_ratio)
train_size = len(dataset) - val_size
train_dataset, val_dataset = random_split(dataset, [train_size, val_size])
test_dataset = TensorDataset(test_tensor, test_labels)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
return train_loader, val_loader, test_loader
# 1. SentencePiece 모델 로드
sp = spm.SentencePieceProcessor(model_file='naver_spm.model')
# 2. 데이터 준비
# 훈련 데이터
corpus_train = train_processed['document'].tolist()
labels_train = torch.tensor(train_processed['label'].values, dtype=torch.long)
# 테스트 데이터
corpus_test = test_processed['document'].tolist()
labels_test = torch.tensor(test_processed['label'].values, dtype=torch.long)
# 4. SentencePiece 토큰화 + 최대 길이 자르기 + 패딩
tensor_train, word_index, index_word = sp_tokenize(sp, corpus_train, max_len=46)
tensor_test, word_index_test, index_word_test = sp_tokenize(sp, corpus_test, max_len=46)
# 5. DataLoader 생성
train_loader, val_loader, test_loader = create_dataloaders(
train_tensor=tensor_train,
train_labels=labels_train,
test_tensor=tensor_test,
test_labels=labels_test,
batch_size=32,
val_ratio=0.2
)
# 6. 데이터 로더 상태 확인
print(f"Train batches: {len(train_loader)}, Validation batches: {len(val_loader)}, Test batches: {len(test_loader)}")
# 학습 데이터 확인
for batch in train_loader:
x, y = batch
print("[Train Loader]")
print("입력 텐서 shape:", x.shape)
print("레이블 shape:", y.shape)
break
# 검증 데이터 확인
for batch in val_loader:
x, y = batch
print("\n[Validation Loader]")
print("입력 텐서 shape:", x.shape)
print("레이블 shape:", y.shape)
break
# 테스트 데이터 확인
for batch in test_loader:
if len(batch) == 2: # test에 label이 있는 경우
x, y = batch
print("\n[Test Loader]")
print("입력 텐서 shape:", x.shape)
print("레이블 shape:", y.shape)
else: # test에 label이 없는 경우
x = batch[0]
print("\n[Test Loader]")
print("입력 텐서 shape:", x.shape)
break
결과:
Train batches: 3612, Validation batches: 903, Test batches: 1522
[Train Loader]
입력 텐서 shape: torch.Size([32, 46])
레이블 shape: torch.Size([32])
[Validation Loader]
입력 텐서 shape: torch.Size([32, 46])
레이블 shape: torch.Size([32])
[Test Loader]
입력 텐서 shape: torch.Size([32, 46])
레이블 shape: torch.Size([32])
5. 모델 정의 및 학습
5.1. 하이퍼파라미터 정의
# 공통 하이퍼파라미터 정의
VOCAB_SIZE = 8000 # 단어 사전의 크기
EMBEDDING_DIM = 100 # 임베딩 벡터의 차원
HIDDEN_DIM = 128 # LSTM의 은닉 상태 차원
OUTPUT_DIM = 1 # 출력 차원 (긍정=1, 부정=0 -> 1개)
N_LAYERS = 2 # LSTM 레이어 개수
BIDIRECTIONAL = True # 양방향 RNN/LSTM 여부
DROPOUT_RATE = 0.2 # 드롭아웃 비율
PAD_IDX = 0 # 패딩 인덱스 (0)
5.2. LSTM 모델 설계
class LSTMModel(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers,
bidirectional, dropout, pad_idx):
super().__init__()
# 1. 임베딩 레이어
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_idx)
# 2. LSTM 레이어
self.lstm = nn.LSTM(input_size=embedding_dim,
hidden_size=hidden_dim,
num_layers=n_layers,
bidirectional=bidirectional,
batch_first=True,
dropout=dropout)
# 3. FC 레이어
fc_input_dim = hidden_dim * 2
self.fc = nn.Linear(fc_input_dim, output_dim)
# 4. 배치정규화 (LSTM 출력 차원에 맞게)
fc_input_dim = hidden_dim * 2 if bidirectional else hidden_dim
self.batchnorm = nn.BatchNorm1d(fc_input_dim)
# 5. 드롭아웃
self.dropout = nn.Dropout(dropout)
def forward(self, text):
embedded = self.embedding(text)
# 2. LSTM
_output, (hidden, cell) = self.lstm(embedded)
# 3. 양방향인 경우 마지막 forward/backward hidden 연결
if self.lstm.bidirectional:
hidden = torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim=1)
else:
hidden = hidden[-1,:,:]
# 4. 드롭아웃
hidden = self.dropout(hidden)
# 5. 배치 정규화 (배치 차원 B, feature 차원 F → BatchNorm1d(F) 적용)
hidden = self.batchnorm(hidden)
# 6. FC 통과
output = self.fc(hidden)
return output.squeeze(1)
5.3. 학습, 평가에 필요한 함수들 정의
# 0. GPU 장치 설정
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using device: {device}")
# 1. 헬퍼 함수 정의
def count_parameters(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
def binary_accuracy(preds, y):
rounded_preds = torch.round(torch.sigmoid(preds))
correct = (rounded_preds == y).float()
acc = correct.sum() / len(correct)
return acc
def epoch_time(start_time, end_time):
elapsed_time = end_time - start_time
elapsed_mins = int(elapsed_time / 60)
elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
return elapsed_mins, elapsed_secs
# 2. 훈련 함수 정의
def train(model, iterator, optimizer, criterion):
epoch_loss = 0
epoch_acc = 0
model.train()
for texts, labels in iterator:
texts = texts.to(device)
labels = labels.to(device)
optimizer.zero_grad()
predictions = model(texts)
loss = criterion(predictions, labels.float())
acc = binary_accuracy(predictions, labels)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 클리핑
optimizer.step()
epoch_loss += loss.item()
epoch_acc += acc.item()
return epoch_loss / len(iterator), epoch_acc / len(iterator)
# 3. 평가 함수 정의
def evaluate(model, iterator, criterion):
epoch_loss = 0
epoch_acc = 0
model.eval()
with torch.no_grad():
for texts, labels in iterator:
texts = texts.to(device)
labels = labels.to(device)
predictions = model(texts)
loss = criterion(predictions, labels.float())
acc = binary_accuracy(predictions, labels)
epoch_loss += loss.item()
epoch_acc += acc.item()
return epoch_loss / len(iterator), epoch_acc / len(iterator)
5.4. 모델 학습 및 평가
# 모델 설정
lstm_model = LSTMModel(
VOCAB_SIZE,
EMBEDDING_DIM,
HIDDEN_DIM,
OUTPUT_DIM,
N_LAYERS,
BIDIRECTIONAL,
DROPOUT_RATE,
PAD_IDX
).to(device)
save_path = 'best_model_lstm.pt'
N_EPOCHS = 20
patience = 5
criterion = nn.BCEWithLogitsLoss().to(device)
optimizer = optim.Adam(filter(lambda p: p.requires_grad, lstm_model.parameters()), lr=0.0001)
# Early stopping 변수
best_valid_loss = float('inf')
patience_counter = 0
train_losses, train_accs, valid_losses, valid_accs = [], [], [], []
print(f"\n{'='*60}")
print(f"--- LSTM Model Training starts ---")
print(f"{'='*60}\n")
# 학습 루프
for epoch in range(N_EPOCHS):
start_time = time.time()
train_loss, train_acc = train(lstm_model, train_loader, optimizer, criterion)
valid_loss, valid_acc = evaluate(lstm_model, val_loader, criterion)
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time)
print(f'Epoch: {epoch+1:02} | Time: {epoch_mins}m {epoch_secs}s')
print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}%')
print(f'\t Val. Loss: {valid_loss:.3f} | Val. Acc: {valid_acc*100:.2f}%')
# 기록
train_losses.append(train_loss)
train_accs.append(train_acc)
valid_losses.append(valid_loss)
valid_accs.append(valid_acc)
# Early Stopping
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(lstm_model.state_dict(), save_path)
patience_counter = 0
print(f'\t>> Validation loss improved ({best_valid_loss:.3f}). Model saved.')
else:
patience_counter += 1
print(f'\t>> Validation loss did not improve. Counter: {patience_counter}/{patience}')
if patience_counter >= patience:
print(f'--- Early stopping triggered after {epoch+1} epochs ---')
break
# 테스트 평가
print(f"\n--- Loading best LSTM model for test evaluation ---")
lstm_model.load_state_dict(torch.load(save_path))
test_loss, test_acc = evaluate(lstm_model, test_loader, criterion)
print(f"\n--- LSTM Model Test Results (Best Model) ---")
print(f'\tTest Loss: {test_loss:.3f}')
print(f'\tTest Acc: {test_acc*100:.2f}%')
결과:
============================================================
--- LSTM Model Training starts ---
============================================================
Epoch: 01 | Time: 0m 21s
Train Loss: 0.593 | Train Acc: 67.16%
Val. Loss: 0.488 | Val. Acc: 76.16%
>> Validation loss improved (0.488). Model saved.
Epoch: 02 | Time: 0m 21s
Train Loss: 0.453 | Train Acc: 78.77%
Val. Loss: 0.444 | Val. Acc: 79.26%
>> Validation loss improved (0.444). Model saved.
Epoch: 03 | Time: 0m 21s
Train Loss: 0.400 | Train Acc: 82.03%
Val. Loss: 0.415 | Val. Acc: 81.02%
>> Validation loss improved (0.415). Model saved.
Epoch: 04 | Time: 0m 20s
Train Loss: 0.365 | Train Acc: 84.06%
Val. Loss: 0.416 | Val. Acc: 81.50%
>> Validation loss did not improve. Counter: 1/5
Epoch: 05 | Time: 0m 21s
Train Loss: 0.342 | Train Acc: 85.34%
Val. Loss: 0.388 | Val. Acc: 82.47%
>> Validation loss improved (0.388). Model saved.
Epoch: 06 | Time: 0m 21s
Train Loss: 0.322 | Train Acc: 86.49%
Val. Loss: 0.385 | Val. Acc: 83.06%
>> Validation loss improved (0.385). Model saved.
Epoch: 07 | Time: 0m 20s
Train Loss: 0.305 | Train Acc: 87.39%
Val. Loss: 0.408 | Val. Acc: 82.58%
>> Validation loss did not improve. Counter: 1/5
Epoch: 08 | Time: 0m 21s
Train Loss: 0.289 | Train Acc: 88.22%
Val. Loss: 0.399 | Val. Acc: 82.86%
>> Validation loss did not improve. Counter: 2/5
Epoch: 09 | Time: 0m 21s
Train Loss: 0.275 | Train Acc: 88.95%
Val. Loss: 0.410 | Val. Acc: 83.12%
>> Validation loss did not improve. Counter: 3/5
Epoch: 10 | Time: 0m 20s
Train Loss: 0.260 | Train Acc: 89.70%
Val. Loss: 0.417 | Val. Acc: 82.69%
>> Validation loss did not improve. Counter: 4/5
Epoch: 11 | Time: 0m 21s
Train Loss: 0.246 | Train Acc: 90.39%
Val. Loss: 0.423 | Val. Acc: 83.20%
>> Validation loss did not improve. Counter: 5/5
--- Early stopping triggered after 11 epochs ---
--- Loading best LSTM model for test evaluation ---
--- LSTM Model Test Results (Best Model) ---
Test Loss: 0.389
Test Acc: 82.80%
Loss와 Acc 추세 시각화도 해보면
# 그래프 스타일 설정
plt.style.use('seaborn-v0_8')
# Loss 곡선
plt.figure(figsize=(8, 5))
plt.plot(train_losses, label='Train Loss', marker='o')
plt.plot(valid_losses, label='Validation Loss', marker='o')
plt.title('LSTM Model Loss Curve')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)
plt.show()
# Accuracy 곡선
plt.figure(figsize=(8, 5))
plt.plot([acc * 100 for acc in train_accs], label='Train Accuracy', marker='o')
plt.plot([acc * 100 for acc in valid_accs], label='Validation Accuracy', marker='o')
plt.title('LSTM Model Accuracy Curve')
plt.xlabel('Epoch')
plt.ylabel('Accuracy (%)')
plt.legend()
plt.grid(True)
plt.show()


약간의 과적합이 일어났지만, 오늘의 목표는 다른 형태소 분석기와 비교해보는 것이기 때문에 넘어가겠다.
6. 다른 형태소 분석기와 비교 (Mecab, Okt)
먼저 Mecab과 자바 설치를 해준다.
!git clone https://github.com/SOMJANG/Mecab-ko-for-Google-Colab.git
%cd Mecab-ko-for-Google-Colab/
!bash install_mecab-ko_on_colab_light_220429.sh
!sudo apt update
!sudo apt install default-jre
위에서 진행한 전처리 완료 데이터를 다시 봐보자.
print(f"전처리 완료한 Train 데이터: {len(train_processed)}개")
print(f"전처리 완료한 Test 데이터: {len(test_processed)}개")
print("\n전처리 결과 샘플:")
print(train_processed.head(10))
전처리 완료한 Train 데이터: 144478개
전처리 완료한 Test 데이터: 48700개
전처리 결과 샘플:
id document label
0 9976970 아 더빙 . 진짜 짜증나네요 목소리 0
1 3819312 흠 . 포스터보고 초딩영화줄 . 오버연기조차 가볍지 않구나 1
2 10265843 너무재밓었다그래서보는것을추천한다 0
3 9045019 교도소 이야기구먼 . 솔직히 재미 없다 . 평점 조정 0
4 6483659 사이몬페그 익살스런 연기 돋보였던 영화 ! 스파이더맨에서 늙어보이기만 했던 커스틴 ... 1
5 5403919 막 걸음마 뗀 3세부터 초등학교 1학년생인 8살용영화 . . 별반개 아까움 . 0
6 7797314 원작 긴장감을 제대로 살려내지못했다 . 0
7 9443947 별 반개 아깝다 욕나온다 이응경 길용우 연기생활이몇년인지 . 정말 발로해 그것보단 ... 0
8 7156791 액션 없는데 재미 있는 몇안되 영화 1
9 5912145 왜케 평점 낮은건데 ? 꽤 볼만한데 . 헐리우드식 화려함에만 너무 길들여져 있나 ? 1
6.1. KoNLPy용 학습 함수 정의
재사용성을 위해 위의 과정을 함수화한 코드이다.
# KoNLPy용 토큰화 및 텐서 변환 함수
def konlpy_build_vocab(corpus, tokenizer, vocab_size=8000):
"""KoNLPy 토크나이저로 말뭉치에서 사전을 구축합니다."""
print("사전 구축 시작...")
counter = Counter()
for text in corpus:
tokens = tokenizer.morphs(text)
counter.update(tokens)
# SentencePiece와 동일하게 8000개로 제한 (특수 토큰 4개 포함)
common_words = counter.most_common(vocab_size - 4)
word_index = {'': 0, '': 1, '': 2, '': 3}
for i, (word, _) in enumerate(common_words):
word_index[word] = i + 4
index_word = {i: w for w, i in word_index.items()}
print(f"사전 구축 완료. 총 단어 수: {len(word_index)}")
return word_index, index_word
def konlpy_texts_to_tensors(corpus, tokenizer, word_index, max_len=46):
"""KoNLPy로 토큰화된 텍스트 리스트를 텐서로 변환합니다."""
tensor = []
for text in corpus:
tokens = tokenizer.morphs(text)
ids = [word_index.get(t, word_index['']) for t in tokens]
ids = ids[:max_len] # 최대 길이 제한
tensor.append(torch.tensor(ids, dtype=torch.long))
# 패딩
tensor = pad_sequence(tensor, batch_first=True, padding_value=word_index[''])
return tensor
# 학습 및 평가 전체 파이프라인
def run_experiment(tokenizer_name, tokenizer, train_corpus, test_corpus,
train_labels, test_labels, common_hparams):
print(f"\n{'='*60}")
print(f"--- {tokenizer_name} 모델 실험 시작 ---")
print(f"{'='*60}")
# 1. 사전 구축
word_index, index_word = konlpy_build_vocab(train_corpus, tokenizer, vocab_size=common_hparams['VOCAB_SIZE'])
# 2. 텐서 변환
print("텐서 변환 시작...")
tensor_train = konlpy_texts_to_tensors(train_corpus, tokenizer, word_index, max_len=common_hparams['MAX_LEN'])
tensor_test = konlpy_texts_to_tensors(test_corpus, tokenizer, word_index, max_len=common_hparams['MAX_LEN'])
print("텐서 변환 완료.")
# 3. 데이터 로더 생성
train_loader, val_loader, test_loader = create_dataloaders(
tensor_train, train_labels,
tensor_test, test_labels,
batch_size=common_hparams['BATCH_SIZE'],
val_ratio=0.2
)
print(f"데이터 로더 생성 완료. Train: {len(train_loader)}, Val: {len(val_loader)}, Test: {len(test_loader)}")
# 4. 모델 초기화
# *** 중요: vocab_size를 SentencePiece의 8000개가 아닌, 실제 구축된 사전 크기로 설정 ***
actual_vocab_size = len(word_index)
model = LSTMModel(
vocab_size=actual_vocab_size,
embedding_dim=common_hparams['EMBEDDING_DIM'],
hidden_dim=common_hparams['HIDDEN_DIM'],
output_dim=common_hparams['OUTPUT_DIM'],
n_layers=common_hparams['N_LAYERS'],
bidirectional=common_hparams['BIDIRECTIONAL'],
dropout=common_hparams['DROPOUT_RATE'],
pad_idx=common_hparams['PAD_IDX']
).to(device)
save_path = f'best_model_{tokenizer_name.lower()}.pt'
criterion = nn.BCEWithLogitsLoss().to(device)
optimizer = optim.Adam(model.parameters(), lr=0.0001) # SP와 동일한 LR
# 5. 학습 루프
best_valid_loss = float('inf')
patience_counter = 0
history = {'train_loss': [], 'train_acc': [], 'valid_loss': [], 'valid_acc': []}
print("\n--- 모델 학습 시작 ---")
for epoch in range(common_hparams['N_EPOCHS']):
start_time = time.time()
train_loss, train_acc = train(model, train_loader, optimizer, criterion)
valid_loss, valid_acc = evaluate(model, val_loader, criterion)
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time)
print(f'Epoch: {epoch+1:02} | Time: {epoch_mins}m {epoch_secs}s')
print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}%')
print(f'\t Val. Loss: {valid_loss:.3f} | Val. Acc: {valid_acc*100:.2f}%')
history['train_loss'].append(train_loss)
history['train_acc'].append(train_acc)
history['valid_loss'].append(valid_loss)
history['valid_acc'].append(valid_acc)
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(model.state_dict(), save_path)
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= common_hparams['PATIENCE']:
print(f'--- Early stopping triggered after {epoch+1} epochs ---')
break
# 6. 테스트 평가
print(f"\n--- {tokenizer_name} 모델 테스트 평가 ---")
model.load_state_dict(torch.load(save_path))
test_loss, test_acc = evaluate(model, test_loader, criterion)
print(f'Test Loss: {test_loss:.3f} | Test Acc: {test_acc*100:.2f}%')
return test_loss, test_acc, history
# 그래프 그리기 함수
def plot_history(history, title):
plt.style.use('seaborn-v0_8')
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 5))
# Loss
ax1.plot(history['train_loss'], label='Train Loss', marker='o')
ax1.plot(history['valid_loss'], label='Validation Loss', marker='o')
ax1.set_title(f'{title} - Loss Curve')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.legend()
ax1.grid(True)
# Accuracy
ax2.plot([acc * 100 for acc in history['train_acc']], label='Train Accuracy', marker='o')
ax2.plot([acc * 100 for acc in history['valid_acc']], label='Validation Accuracy', marker='o')
ax2.set_title(f'{title} - Accuracy Curve')
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Accuracy (%)')
ax2.legend()
ax2.grid(True)
plt.show()
6.2. 공통 하이퍼파라미터 및 데이터 준비
# SentencePiece 모델과 동일한 하이퍼파라미터 사용
common_hparams = {
'VOCAB_SIZE': 8000, # KoNLPy에서는 이 크기를 "최대" 크기로 사용
'EMBEDDING_DIM': 100,
'HIDDEN_DIM': 128,
'OUTPUT_DIM': 1,
'N_LAYERS': 2,
'BIDIRECTIONAL': True,
'DROPOUT_RATE': 0.2,
'PAD_IDX': 0,
'MAX_LEN': 46, # SentencePiece 모델과 동일한 max_len
'BATCH_SIZE': 32,
'N_EPOCHS': 20, # 최대 Epoch
'PATIENCE': 5 # Early stopping patience
}
# 전처리된 데이터
corpus_train = train_processed['document'].tolist()
labels_train = torch.tensor(train_processed['label'].values, dtype=torch.long)
corpus_test = test_processed['document'].tolist()
labels_test = torch.tensor(test_processed['label'].values, dtype=torch.long)
# 결과 저장을 위한 딕셔너리
results = {}
# SentencePiece (BPE) 결과 저장
results['SentencePiece (BPE)'] = {'loss': 0.389, 'acc': 82.80}
6.3. Mecab
from konlpy.tag import Mecab
from collections import Counter
# 1. Mecab 토크나이저 초기화
mecab = Mecab()
# 2. Mecab 실험 실행
mecab_loss, mecab_acc, mecab_history = run_experiment(
tokenizer_name="Mecab",
tokenizer=mecab,
train_corpus=corpus_train,
test_corpus=corpus_test,
train_labels=labels_train,
test_labels=labels_test,
common_hparams=common_hparams
)
# 3. 결과 저장
results['Mecab'] = {'loss': mecab_loss, 'acc': mecab_acc * 100}
# 4. 그래프 그리기
plot_history(mecab_history, "Mecab LSTM Model")
결과:
============================================================
--- Mecab 모델 실험 시작 ---
============================================================
사전 구축 시작...
사전 구축 완료. 총 단어 수: 8000
텐서 변환 시작...
텐서 변환 완료.
데이터 로더 생성 완료. Train: 3612, Val: 903, Test: 1522
--- 모델 학습 시작 ---
Epoch: 01 | Time: 0m 19s
Train Loss: 0.527 | Train Acc: 72.68%
Val. Loss: 0.456 | Val. Acc: 78.29%
Epoch: 02 | Time: 0m 20s
Train Loss: 0.412 | Train Acc: 81.01%
Val. Loss: 0.396 | Val. Acc: 81.75%
Epoch: 03 | Time: 0m 19s
Train Loss: 0.374 | Train Acc: 83.38%
Val. Loss: 0.374 | Val. Acc: 83.15%
Epoch: 04 | Time: 0m 20s
Train Loss: 0.350 | Train Acc: 84.67%
Val. Loss: 0.392 | Val. Acc: 83.44%
Epoch: 05 | Time: 0m 19s
Train Loss: 0.331 | Train Acc: 85.77%
Val. Loss: 0.361 | Val. Acc: 83.98%
Epoch: 06 | Time: 0m 20s
Train Loss: 0.316 | Train Acc: 86.58%
Val. Loss: 0.387 | Val. Acc: 82.82%
Epoch: 07 | Time: 0m 19s
Train Loss: 0.301 | Train Acc: 87.41%
Val. Loss: 0.355 | Val. Acc: 84.62%
Epoch: 08 | Time: 0m 19s
Train Loss: 0.287 | Train Acc: 88.09%
Val. Loss: 0.365 | Val. Acc: 84.51%
Epoch: 09 | Time: 0m 20s
Train Loss: 0.275 | Train Acc: 88.71%
Val. Loss: 0.363 | Val. Acc: 84.39%
Epoch: 10 | Time: 0m 19s
Train Loss: 0.263 | Train Acc: 89.37%
Val. Loss: 0.379 | Val. Acc: 84.68%
Epoch: 11 | Time: 0m 20s
Train Loss: 0.249 | Train Acc: 90.02%
Val. Loss: 0.378 | Val. Acc: 84.94%
Epoch: 12 | Time: 0m 19s
Train Loss: 0.238 | Train Acc: 90.58%
Val. Loss: 0.393 | Val. Acc: 84.60%
--- Early stopping triggered after 12 epochs ---
--- Mecab 모델 테스트 평가 ---
Test Loss: 0.353 | Test Acc: 84.70%

6.4. Okt
from konlpy.tag import Okt
# 1. Okt 토크나이저 초기화
# stem=True: 어간 추출 (예: '하다', '하니', '하는' -> '하다')
okt = Okt()
# Okt의 morphs 메서드를 사용하기 위해 래퍼 함수를 만듭니다.
# (run_experiment 함수가 tokenizer.morphs(text)를 호출하기 때문)
class OktWrapper:
def __init__(self):
self.okt = Okt()
def morphs(self, text):
return self.okt.morphs(text, stem=True)
okt_tokenizer = OktWrapper()
# 2. Okt 실험 실행
okt_loss, okt_acc, okt_history = run_experiment(
tokenizer_name="Okt",
tokenizer=okt_tokenizer,
train_corpus=corpus_train,
test_corpus=corpus_test,
train_labels=labels_train,
test_labels=labels_test,
common_hparams=common_hparams
)
# 3. 결과 저장
results['Okt'] = {'loss': okt_loss, 'acc': okt_acc * 100}
# 4. 그래프 그리기
plot_history(okt_history, "Okt LSTM Model")
결과:
============================================================
--- Okt 모델 실험 시작 ---
============================================================
사전 구축 시작...
사전 구축 완료. 총 단어 수: 8000
텐서 변환 시작...
텐서 변환 완료.
데이터 로더 생성 완료. Train: 3612, Val: 903, Test: 1522
--- 모델 학습 시작 ---
Epoch: 01 | Time: 0m 21s
Train Loss: 0.525 | Train Acc: 72.72%
Val. Loss: 0.423 | Val. Acc: 80.32%
Epoch: 02 | Time: 0m 21s
Train Loss: 0.415 | Train Acc: 80.78%
Val. Loss: 0.397 | Val. Acc: 81.65%
Epoch: 03 | Time: 0m 21s
Train Loss: 0.376 | Train Acc: 83.04%
Val. Loss: 0.376 | Val. Acc: 82.93%
Epoch: 04 | Time: 0m 21s
Train Loss: 0.354 | Train Acc: 84.33%
Val. Loss: 0.372 | Val. Acc: 83.09%
Epoch: 05 | Time: 0m 21s
Train Loss: 0.335 | Train Acc: 85.50%
Val. Loss: 0.371 | Val. Acc: 83.51%
Epoch: 06 | Time: 0m 22s
Train Loss: 0.320 | Train Acc: 86.29%
Val. Loss: 0.375 | Val. Acc: 83.55%
Epoch: 07 | Time: 0m 21s
Train Loss: 0.306 | Train Acc: 87.06%
Val. Loss: 0.367 | Val. Acc: 83.97%
Epoch: 08 | Time: 0m 21s
Train Loss: 0.293 | Train Acc: 87.74%
Val. Loss: 0.366 | Val. Acc: 84.00%
Epoch: 09 | Time: 0m 22s
Train Loss: 0.279 | Train Acc: 88.44%
Val. Loss: 0.381 | Val. Acc: 83.75%
Epoch: 10 | Time: 0m 21s
Train Loss: 0.268 | Train Acc: 89.04%
Val. Loss: 0.398 | Val. Acc: 84.45%
Epoch: 11 | Time: 0m 21s
Train Loss: 0.257 | Train Acc: 89.63%
Val. Loss: 0.378 | Val. Acc: 84.38%
Epoch: 12 | Time: 0m 22s
Train Loss: 0.246 | Train Acc: 90.12%
Val. Loss: 0.386 | Val. Acc: 84.57%
Epoch: 13 | Time: 0m 21s
Train Loss: 0.233 | Train Acc: 90.75%
Val. Loss: 0.399 | Val. Acc: 84.47%
--- Early stopping triggered after 13 epochs ---
--- Okt 모델 테스트 평가 ---
Test Loss: 0.366 | Test Acc: 84.13%

7. 결론

가설:
- SP를 사용한 LSTM의 성능이 Mecab, Okt를 사용한 LSTM의 성능보다 좋을 것이다.
통제 변인:
- 전처리
- vocab_size
- 모델 & 하이퍼파라미터
실험 결과 예상과 다르게 SP를 사용한 LSTM의 성능이 다른 두 개보다 낮았다.
그 이유를 찾아보니 학습 전에 수행한 데이터 전처리가 문제였을 가능성이 높아 보이는데,
SP의 강점 "원본 텍스트에서 의미 있는 서브워드를 스스로 학습하는 것"이 미리 수행한 전처리 때문에 훼손되어 제대로 된 성능을 발휘하지 못 한 것 같다.
추후 실험 계획:
- 아주 기초적인 전처리(중복, 결측치 제거 등)만 수행한 후 다시 비교
- SentecnePiece의 model_type, vocab_size를 변경해가며 성능 체크해보기
'AI > NLP' 카테고리의 다른 글
| [11/19] 아이펠 리서치 15기 TIL | WEAT score (1) | 2025.11.27 |
|---|---|
| [11/17] 아이펠 리서치 15기 TIL | vocab_size에 따른 ML 모델의 성능 비교 (뉴스 카테고리 다중 분류) (0) | 2025.11.27 |
| [11/6~11/11] 아이펠 리서치 15기 TIL | DLthon: 감정 분류 해커톤 (2) (0) | 2025.11.15 |
| [11/6~11/11] 아이펠 리서치 15기 TIL | DLthon: 감정 분류 해커톤 (1) (0) | 2025.11.15 |
| [11/05] 아이펠 리서치 15기 TIL | Transformer 구현 (0) | 2025.11.15 |