수달이네 기술 블로그

7. LSTM(Long Short Term Memory) 본문

AI공부/자연어처리

7. LSTM(Long Short Term Memory)

슬픈 수달이 2026. 2. 27. 18:59

LSTM(Long-Short-Term Memory)

RNN에는 장기 의존성 문제가 존재한다.

  • 시퀀스가 길어지면 이전의 기억이 사라지거나 폭주하는 현상
  • 이를 해결하기 위해 LSTM이 고안되었다.

LSTM 구조

  • 셀 상태(Cell state)
  • 입력 게이트: 새로운 정보 저장
  • 출력 게이트: 최종 출력을 결정
  • 망각 게이트: 이전 셀 상태에서 필요없는 정보 삭제

LSTM 특징

  • 중요한 정보를 오래동안 저장, 불필요한 정보 제거
    • 그러나 너무 길어지면 장기 의존성 존재
  • 장기 시퀀스를 다루는 자연어 처리, 음성 인식, 시계열 예측 등의 분야에서 사용
  • 그러나, 구조의 복잡성으로 계산량이 많아 학습이 오래걸린다.

RNN vs LSTM

비교 RNN LSTM
구조 단순한 은닉상태(ht) 유지 셀 상태(Ct) 추가로 장기 기억 유지
정보 흐름 조절 단순한 상태 전달 망각, 입력, 출력 게이트 사용
장기 의존성 기울기 소실로 인한 학습의 어려움 장기 기억을 효과적으로 유지
계산 비용 상대적으로 적음 게이트 구조로 인해 연산량 증가
학습 속도 빠름 상대적으로 느림
적용 분야 단기 패턴 인식, 간단한 시계열 예측 장기 의존성이 필요한 자연어 처리, 음성 인식 등

LSTM 구현

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'])
  • 네이버 쇼핑 리뷰 테이블(평점, 리뷰내용)
data = data[data['rating'] != 3]
data['label'] = np.where(data['rating'] > 3, 1, 0)
  • 3점짜리 리뷰(보통) 제거하고, 긍정, 부정 을 라벨링해둠
def preprocess_text(text):
    text = re.sub(r'[^가-힣\\s]', '', text)
    return text

data['review'] = data['review'].apply(preprocess_text)
  • 한글 텍스트만 분류해서 남겨두기
mecab = Mecab(dicpath='C:/mecab/mecab-ko-dic')
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)
  • mecab을 이용하여 형태소 분석 + 불용어 제거
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))
  • 문장의 길이가 너무 길면 최대 길이(100)로 맞춰주고, 적으면 패딩 적용
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)
  • 데이터를 train, test로 나눠준 후
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)
  • 데이터 로더 적용(배치 크기 64)
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 적용됨
  • LSTM 모델 정의
    • 파이토치의 LSTM 모델을 받아 만든 것.
    • 배치 정규화(과적합 방지)
    • Linear(맞는지 틀린지 확인용)
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)

# SentimentLSTM(
#   (embedding): Embedding(41130, 128)
#   (lstm): LSTM(128, 512, num_layers=2, batch_first=True, dropout=0.2)
#   (batch_norm): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
#   (fc): Linear(in_features=512, out_features=1, bias=True)
# )
  • 모델 초기화
    • 은닉차원 512차원
    • 출력차원 1(0 or 1중 하나로 출력됨)
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.AdamW(model.parameters(), lr=0.0005)
  • 손실함수: 이진 Cross Entropy 로 설정
  • optimizer: AdamW
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)
  • 학습
    • 결과 나온 것을 기준으로 0.5 기준으로 나눠줌

Epoch 1/5, Loss: 0.5165, Accuracy: 0.6847
Epoch 2/5, Loss: 0.2426, Accuracy: 0.9116
Epoch 3/5, Loss: 0.2033, Accuracy: 0.9280
Epoch 4/5, Loss: 0.1709, Accuracy: 0.9412
Epoch 5/5, Loss: 0.1378, Accuracy: 0.9549

3분 47초의 시간이 걸렸다.

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)
    
        #Test Accuracy: 0.9118

Accuracy는 0.9118이 나왔고.

  • 검증
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)
  • 예시 텍스트로 테스트 해보기
    • 네이버 쇼핑 리뷰 데이터셋 형식의 내용으로는 잘 맞추겠지만 아닐 경우 맞추지 못함

입력 문장: 이 제품 정말 좋아요! 추천합니다.
예측 확률: 0.9658 (긍정)
입력 문장: 완전 별로예요. 사지 마세요.
예측 확률: 0.0019 (부정)
입력 문장: 기대 이하입니다. 실망했어요.
예측 확률: 0.0029 (부정)

  • 검증 결과 또한 전체적으로 좋은 결과를 보임을 확인할 수 있었다.

'AI공부 > 자연어처리' 카테고리의 다른 글

9. Seq2Seq(Sequence-to-Sequence)  (0) 2026.03.02
8. GRU(Gated Recurrent Unit)  (0) 2026.02.28
6. RNN활용 KOSPI예측(시퀀스데이터)  (0) 2026.02.23
5. RNN(Recurrent Neural Network)기초  (1) 2026.02.22
4. FastText  (0) 2026.02.21