LSTM(Long Short-Term Memory)은 RNN의 장기 의존성 문제를 해결하기 위해 고안된 모델입니다. LSTM은 셀 상태(cell state)와 3개의 게이트(입력 게이트, 출력 게이트, 망각 게이트)를 사용하여 중요한 정보를 오랫동안 저장하고 불필요한 정보를 제거하는 구조를 갖추고 있습니다. 망각 게이트는 이전 셀 상태에서 필요 없는 정보를 삭제하고, 입력 게이트는 새로운 정보를 저장하며, 출력 게이트는 최종 출력을 결정합니다. 이러한 구조 덕분에 LSTM은 장기 시퀀스를 다루는 자연어 처리, 음성 인식, 시계열 예측 등의 다양한 분야에서 효과적으로 사용됩니다. 하지만 구조가 복잡하여 계산량이 많고, 학습 시간이 오래 걸린다는 단점이 있습니다.
!pip install konlpy
!pip install mecab-python
!bash <(curl -s https://raw.githubusercontent.com/konlpy/konlpy/master/scripts/mecab.sh)
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import numpy as np
import re
from konlpy.tag import Mecab
from collections import Counter
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
# 데이터 로드
url = 'https://raw.githubusercontent.com/bab2min/corpus/master/sentiment/naver_shopping.txt'
data = pd.read_table(url, names=['rating', 'review'])
# 3점 리뷰 제거 후 긍정(1), 부정(0) 라벨링
data = data[data['rating'] != 3]
data['label'] = np.where(data['rating'] > 3, 1, 0)
# 한글 텍스트만 남기기
def preprocess_text(text):
text = re.sub(r'[^가-힣\s]', '', text)
return text
data['review'] = data['review'].apply(preprocess_text)
# 형태소 분석기 적용
mecab = Mecab()
stopwords = ['도', '는', '다', '의', '가', '이', '은', '한', '에', '하', '고', '을', '를']
def tokenize(text):
tokens = mecab.morphs(text)
return [token for token in tokens if token not in stopwords]
data['tokenized'] = data['review'].apply(tokenize)
# 단어 사전 생성
all_tokens = [token for tokens in data['tokenized'] for token in tokens]
vocab = Counter(all_tokens)
vocab_size = len(vocab) + 2 # 패딩(0), OOV(1) 고려
word_to_index = {word: idx + 2 for idx, (word, _) in enumerate(vocab.most_common())}
word_to_index['<PAD>'] = 0
word_to_index['<OOV>'] = 1
# 정수 인코딩
def encode_tokens(tokens):
return [word_to_index.get(token, 1) for token in tokens]
data['encoded'] = data['tokenized'].apply(encode_tokens)
# 패딩 적용
max_len = 100
def pad_sequence(seq, max_len):
return seq[:max_len] + [0] * (max_len - len(seq))
data['padded'] = data['encoded'].apply(lambda x: pad_sequence(x, max_len))
class ReviewDataset(Dataset):
def __init__(self, reviews, labels):
self.reviews = torch.tensor(reviews, dtype=torch.long)
self.labels = torch.tensor(labels, dtype=torch.float)
def __len__(self):
return len(self.reviews)
def __getitem__(self, idx):
return self.reviews[idx], self.labels[idx]
# 데이터 분할
X_train, X_test, y_train, y_test = train_test_split(data['padded'].tolist(), data['label'].tolist(), test_size=0.2, random_state=42)
# DataLoader
batch_size = 64
train_dataset = ReviewDataset(X_train, y_train)
test_dataset = ReviewDataset(X_test, y_test)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
class SentimentLSTM(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, dropout):
super(SentimentLSTM, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=n_layers, batch_first=True, dropout=dropout)
self.batch_norm = nn.BatchNorm1d(hidden_dim) # 배치 정규화 추가
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
embedded = self.embedding(x)
lstm_out, _ = self.lstm(embedded)
out = self.batch_norm(lstm_out[:, -1, :]) # 배치 정규화 적용
out = self.fc(out)
return out # BCEWithLogitsLoss 내부에서 sigmoid 적용됨
# 모델 초기화
embedding_dim = 128
hidden_dim = 512 # 은닉 차원 증가
output_dim = 1
n_layers = 2
dropout = 0.2
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SentimentLSTM(vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, dropout)
model.to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.AdamW(model.parameters(), lr=0.0005)
def train_model(model, train_loader, criterion, optimizer, n_epochs):
model.train()
for epoch in range(n_epochs):
epoch_loss = 0
correct = 0
total = 0
for reviews, labels in train_loader:
reviews, labels = reviews.to(device), labels.to(device)
optimizer.zero_grad()
predictions = model(reviews).squeeze()
loss = criterion(predictions, labels)
loss.backward()
optimizer.step()
epoch_loss += loss.item()
preds = (torch.sigmoid(predictions) >= 0.5).float()
correct += (preds == labels).sum().item()
total += labels.size(0)
epoch_acc = correct / total
print(f'Epoch {epoch+1}/{n_epochs}, Loss: {epoch_loss/len(train_loader):.4f}, Accuracy: {epoch_acc:.4f}')
# 학습 실행
train_model(model, train_loader, criterion, optimizer, 5)
def evaluate_model(model, test_loader):
model.eval()
correct = 0
total = 0
predictions_list = []
labels_list = []
with torch.no_grad():
for reviews, labels in test_loader:
reviews, labels = reviews.to(device), labels.to(device)
predictions = model(reviews).squeeze()
preds = (predictions >= 0.5).float()
correct += (preds == labels).sum().item()
total += labels.size(0)
predictions_list.extend(preds.cpu().numpy())
labels_list.extend(labels.cpu().numpy())
accuracy = accuracy_score(labels_list, predictions_list)
print(f'Test Accuracy: {accuracy:.4f}')
# 평가 실행
evaluate_model(model, test_loader)
import torch
def predict_sentiment(model, sentence):
model.eval()
tokens = tokenize(sentence)
encoded = encode_tokens(tokens)
padded = pad_sequence(encoded, max_len)
input_tensor = torch.tensor([padded], dtype=torch.long).to(device)
with torch.no_grad():
prediction = model(input_tensor).item()
probability = torch.sigmoid(torch.tensor(prediction)).item() # 확률로 변환
sentiment = "긍정" if probability >= 0.5 else "부정"
print(f"입력 문장: {sentence}")
print(f"예측 확률: {probability:.4f} ({sentiment})")
# 테스트
test_sentences = [
"이 제품 정말 좋아요! 추천합니다.",
"완전 별로예요. 사지 마세요.",
"기대 이하입니다. 실망했어요."
]
for sentence in test_sentences:
predict_sentiment(model, sentence)
GRU(Gated Recurrent Unit)는 2014년 뉴욕대학교(NYU) 조경현(Kyunghyun Cho) 교수 연구팀이 제안한 RNN의 장기 의존성 문제를 해결하기 위해 개발한 신경망 구조입니다. LSTM과 유사한 성능을 가지면서도 더 간단한 구조를 갖고 있어 연산량이 적고 학습 속도가 빠릅니다. GRU는 업데이트 게이트(Update Gate)와 리셋 게이트(Reset Gate)라는 두 개의 게이트만을 사용하여 정보를 조절하며, LSTM보다 파라미터 수가 적어 적은 데이터셋에서도 효과적으로 학습할 수 있습니다. 업데이트 게이트는 이전 정보를 얼마나 유지할지 결정하고, 리셋 게이트는 새로운 정보를 반영하기 위해 기존 정보를 얼마나 잊을지 조정합니다. 이러한 특성 덕분에 GRU는 텍스트 처리, 음성 인식, 시계열 예측 등에서 LSTM보다 더 빠르고 효율적으로 사용할 수 있지만, 장기 의존성이 중요한 경우 LSTM이 더 나은 성능을 보일 수도 있습니다.
class SentimentGRU(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, dropout):
super(SentimentGRU, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
# LSTM -> GRU 변경
self.gru = nn.GRU(embedding_dim, hidden_dim, num_layers=n_layers, batch_first=True, dropout=dropout, bidirectional=True)
self.batch_norm = nn.BatchNorm1d(hidden_dim * 2) # 배치 정규화 추가
self.fc = nn.Linear(hidden_dim * 2, output_dim)
def forward(self, x):
embedded = self.embedding(x)
gru_out, _ = self.gru(embedded) # GRU 실행
out = self.batch_norm(gru_out[:, -1, :]) # 마지막 타임스텝의 출력 사용
out = self.fc(out)
return out # BCEWithLogitsLoss 내부에서 sigmoid 적용됨
# 모델 초기화
embedding_dim = 128
hidden_dim = 512
output_dim = 1
n_layers = 2
dropout = 0.2
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SentimentGRU(vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, dropout)
model.to(device)
※ Bidirectional
Bidirectional(양방향) RNN은 순방향(forward)과 역방향(backward)으로 정보를 처리하여 입력 시퀀스의 과거와 미래 정보를 모두 활용하는 방식으로, 일반적인 단방향 RNN이 과거에서 현재로만 정보를 전달하는 것과 달리, Bidirectional RNN은 역방향으로도 학습하여 보다 풍부한 문맥 정보를 학습할 수 있어 자연어 처리(NLP)와 시계열 분석에서 유용하게 사용됩니다. (bidirectional=True 옵션만 추가하면 Bidirectional RNN, LSTM/GRU를 사용할 수 있습니다.)
교사 강요(Teacher Forcing)는 RNN이나 LSTM과 같은 순환 신경망을 훈련할 때 사용되는 기법으로, 이전 타임스텝에서 예측한 출력을 다음 입력으로 사용하는 대신, 정답 레이블(실제 값, Ground Truth)을 다음 입력으로 강제로 제공하여 모델이 빠르게 학습하도록 돕는 방법입니다. 이 방식은 수렴 속도를 높이고 학습을 안정적으로 만들지만, 실제 추론 시에는 정답을 제공할 수 없기 때문에 모델이 학습 시점과 다르게 동작하는 노출 편향(Exposure Bias) 문제가 발생할 수 있습니다.