LSTM과 GRU
1. LSTM
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)
2. GRU
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를 사용할 수 있습니다.)
3. LSTM + CNN
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, LSTM, Embedding
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.preprocessing import sequence
from tensorflow.keras.datasets import reuters # 로이터 뉴스 데이터셋 불러오기
from tensorflow.keras.callbacks import EarlyStopping
import numpy as np
import matplotlib.pyplot as plt
# 데이터를 불러와 학습셋, 테스트셋으로 나눕니다.
(X_train, y_train), (X_test, y_test) = reuters.load_data(num_words=1000, test_split=0.2)
category = np.max(y_train) + 1
print(category, '카테고리')
print(len(X_train), '학습용 뉴스 기사')
print(len(X_test), '테스트용 뉴스 기사')
print(X_train[0])
# 단어의 수를 맞추어 줍니다.
X_train = sequence.pad_sequences(X_train, maxlen=100)
X_test = sequence.pad_sequences(X_test, maxlen=100)
# 원-핫 인코딩 처리를 합니다.
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
# 모델의 구조를 설정합니다.
model = Sequential()
model.add(Embedding(1000, 100))
model.add(LSTM(100, activation='tanh'))
model.add(Dense(46, activation='softmax'))
# 모델의 실행 옵션을 정합니다.
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# 학습의 조기 중단을 설정합니다.
early_stopping_callback = EarlyStopping(monitor='val_loss', patience=5)
# 모델을 실행합니다.
history = model.fit(X_train, y_train, batch_size=20, epochs=200, validation_data=(X_test, y_test), callbacks=[early_stopping_callback])
# 테스트 정확도를 출력합니다.
print("\n Test Accuracy: %.4f" % (model.evaluate(X_test, y_test)[1]))
y_vloss = history.history['val_loss']
y_loss = history.history['loss']
x_len = np.arange(len(y_loss))
plt.plot(x_len, y_vloss, marker='.', c="red", label='Testset_loss')
plt.plot(x_len, y_loss, marker='.', c="blue", label='Trainset_loss')
plt.legend(loc='upper right')
plt.grid()
plt.xlabel('epoch')
plt.ylabel('loss')
plt.show()
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Input, Dense, Dropout, Activation, Embedding, LSTM, Conv1D, MaxPooling1D
from tensorflow.keras.datasets import imdb
from tensorflow.keras.preprocessing import sequence
from tensorflow.keras.callbacks import EarlyStopping
import numpy as np
import matplotlib.pyplot as plt
# 데이터를 불러와 학습셋, 테스트셋으로 나눕니다.
(X_train, y_train), (X_test, y_test) = imdb.load_data(num_words=5000)
# 단어의 수를 맞추어 줍니다.
X_train = sequence.pad_sequences(X_train, maxlen=500)
X_test = sequence.pad_sequences(X_test, maxlen=500)
# 모델의 구조를 설정합니다.
model = Sequential([
Input(shape=(None,)),
Embedding(5000, 100),
Dropout(0.5),
Conv1D(64, 5, padding='valid', activation='relu', strides=1),
MaxPooling1D(pool_size=4),
LSTM(55),
Dense(1),
Activation('sigmoid')
])
model.summary()
CNN 뒤에 LSTM을 둔 이유
Conv1D를 먼저 두는 이유는 문장 속 국소 패턴(n-그램)을 빠르게 추출하고(예: 부정 표현, 강한 형용사 등), MaxPooling으로 시간 축을 압축해 노이즈를 줄이고 연산량을 크게 낮춘 뒤 그 순차적 특징열을 LSTM에 넘겨 장기 의존성과 순서 정보를 학습하게 하려는 설계이기 때문입니다. 이렇게 하면 LSTM이 처음부터 긴 시퀀스 전체를 처리하지 않아도 되어 학습이 더 안정적이고 빠르며, CNN이 잡은 국소 의미와 LSTM이 학습한 전역 문맥을 결합해 성능과 효율을 동시에 얻을 수 있습니다.
# 모델의 실행 옵션을 정합니다.
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
# 학습의 조기 중단을 설정합니다.
early_stopping_callback = EarlyStopping(monitor='val_loss', patience=3)
# 모델을 실행합니다.
history = model.fit(X_train, y_train, batch_size=40, epochs=100, validation_split=0.25, callbacks=[early_stopping_callback])
# 테스트 정확도를 출력합니다.
print("\n Test Accuracy: %.4f" % (model.evaluate(X_test, y_test)[1]))
# 학습셋과 테스트셋의 오차를 저장합니다.
y_vloss = history.history['val_loss']
y_loss = history.history['loss']
# 그래프로 표현해 보겠습니다.
x_len = np.arange(len(y_loss))
plt.plot(x_len, y_vloss, marker='.', c="red", label='Testset_loss')
plt.plot(x_len, y_loss, marker='.', c="blue", label='Trainset_loss')
# 그래프에 그리드를 주고 레이블을 표시하겠습니다.
plt.legend(loc='upper right')
plt.grid()
plt.xlabel('epoch')
plt.ylabel('loss')
plt.show()