4.5 LSTM으로 네이버 영화 리뷰 분류하기

유명곤·2026년 10월 1일

리뷰를 분류하려면 문장을 숫자로 바꾸는 규칙과 그 숫자를 읽는 모델이 맞물려야 한다. 토큰화는 입력 단위를 정하고, 임베딩은 단위를 벡터로 바꾸며, LSTM은 순서를 따라 문장의 정보를 모은다. 이 연결에서는 텐서의 크기뿐 아니라 각 문장이 실제로 끝나는 위치도 중요하다.

훈련 자료에서 입력 규칙을 정한다

감성 분류(Sentiment classification)는 글에 드러난 감정을 정해진 범주로 구분하는 작업이다. 여기서는 네이버 영화 리뷰를 부정과 긍정으로 나눈다. 입력은 리뷰 문장이고, 정답인 레이블(Label)은 부정이면 0, 긍정이면 1이다.

NSMC는 훈련용 리뷰 15만 개와 테스트용 리뷰 5만 개를 제공한다. 파일의 document가 문장, label이 정답이며 id는 리뷰 식별자다. 정제 과정에서 행을 제거하면 실제 사용량은 줄어든다. 훈련용 자료 중 일부를 검증용으로 분리해 모델을 선택하고, 테스트 자료는 선택한 모델의 최종 평가에 사용한다.

교재는 중복·결측 리뷰를 제거하고 한글·한글 자모·공백을 남긴 뒤, Mecab으로 형태소를 나누고 정해 둔 불용어(Stopword)를 제거한다. 불용어는 분석에서 제외하기로 정한 단어다. 영문이나 문장 부호에도 감정 정보가 있을 수 있으므로 이 정제 방식이 모든 감성 분류에 필요한 규칙은 아니다.

토큰마다 번호를 붙이는 정수 인코딩(Integer encoding)에는 고정된 사전이 필요하다. 사전은 검증 자료까지 분리한 뒤 훈련 데이터의 토큰만으로 만든다. 검증·테스트 자료의 단어 분포를 사전 구성에 쓰면 평가 자료의 정보가 모델을 준비하는 과정에 들어간다.

교재는 드문 토큰을 사전에서 제외하고, 미등록 토큰은 UNK로 바꾼다. 이 분류 예시의 번호는 PAD=0, UNK=1이다. 임베딩 글의 작은 예시와 배정이 다르지만 역할은 같다. UNK는 실제로 등장한 미등록 토큰이고, PAD는 길이를 맞추려고 추가한 자리다. 전처리와 모델 전체에서 같은 번호를 써야 한다.

패딩 길이와 실제 문장 길이를 구분한다

길이가 다른 문장을 배치(Batch)로 묶어 한 번에 계산하려면 직사각형 텐서로 만들어야 한다. 짧은 문장을 채우는 작업이 패딩(Padding)이고, 정한 길이를 넘는 부분을 자르는 작업이 절단(Truncation)이다.

교재는 최대 길이를 30으로 두고 뒷부분을 자르거나 뒤에 PAD를 붙인다. 최대 길이는 훈련 자료의 길이 분포와 계산량을 보고 정할 값이다. 잘린 부분의 정보는 모델이 읽을 수 없으므로 30을 모든 문제의 기준으로 삼을 수는 없다.

분류에 마지막 실제 토큰의 상태를 쓰려면 절단 후, 패딩 전의 길이를 보관해야 한다. 토큰 수가 6이고 최대 길이가 4라면 사용할 실제 길이는 4이다. 정제·토큰화 후 토큰이 남지 않는 문장은 정답과 함께 학습 자료에서 제외하거나, 새 입력이라면 분류할 내용이 없다고 처리한다.

LSTM에서 어느 상태를 분류할 것인가

임베딩(Embedding)은 번호를 학습 가능한 벡터로 바꾸고, 장단기 메모리(Long Short-Term Memory, LSTM)는 벡터를 차례로 읽어 은닉 상태(Hidden state)를 갱신한다. 단방향 LSTM에서 마지막 실제 토큰의 상태는 해당 문장을 끝까지 읽은 표현이다. 이 표현을 선형 분류층에 넣어 부정·긍정 점수를 만든다.

배치 크기를 BB, 패딩한 길이를 TT, 임베딩 차원을 EE, 은닉 크기를 HH라 하자. 투영을 사용하지 않는 단방향 1층 LSTM에 batch_first=True를 설정하면 다음과 같이 연결된다.

단계크기담는 정보
정수 입력(B, T)토큰 번호
임베딩(B, T, E)토큰별 벡터
LSTM 출력(B, T, H)시점별 은닉 상태
실제 끝의 상태 선택(B, H)문장별 표현
선형 분류층(B, 2)부정·긍정 점수

교재의 모델은 LSTM이 반환한 hidden에서 층 차원을 없앤 뒤 분류한다. 일반적인 패딩 텐서를 넣었을 때 이 상태는 패딩까지 모두 읽은 뒤의 상태다. 짧은 문장에서는 실제 마지막 토큰 직후의 상태와 다를 수 있다. 임베딩에 padding_idx=0을 설정해도 LSTM이 PAD 시점을 건너뛰지는 않는다.

뒤쪽 패딩과 단방향 LSTM을 사용한다면, 문장별 길이에서 1을 뺀 위치의 outputs를 골라 실제 마지막 상태를 얻을 수 있다. 해당 시점의 상태는 뒤에 붙은 PAD의 영향을 받지 않기 때문이다. 이는 패딩 계산을 생략하는 방법은 아니다. 계산 자체를 제외하려면 실제 길이를 함께 전달하는 패킹된 시퀀스(Packed sequence)를 사용할 수 있다. 양방향 LSTM에는 역방향 계산도 있으므로 같은 위치 선택법을 그대로 적용할 수 없다.

두 점수를 정답과 비교한다

분류층의 출력은 로짓(Logit), 즉 확률로 바꾸기 전의 실수 점수다. 두 값은 음수일 수도 있고 합이 1일 필요도 없다. 정답 클래스의 점수가 상대적으로 높아지도록 학습할 때 교차 엔트로피 손실(Cross-entropy loss)을 사용한다.

한 문장의 로짓이 z0,z1z_0,z_1이고 정답이 y∈{0,1}y\in\{0,1\}이면 손실은 다음과 같다.

L=−log⁡(exp⁡(zy)exp⁡(z0)+exp⁡(z1))L=-\log\left(\frac{\exp(z_y)}{\exp(z_0)+\exp(z_1)}\right)

괄호 안은 소프트맥스(Softmax)로 계산한 정답 클래스의 확률이다. 정답에 높은 확률을 줄수록 손실이 작아진다. PyTorch의 CrossEntropyLoss는 (B, 2)의 로짓과 (B,)의 정수 정답을 받아 이 계산을 처리한다. 따라서 손실 함수에 넣기 전에 소프트맥스를 적용하지 않는다.

학습에서는 이전 기울기를 지우고, loss.backward()로 기울기를 계산한 뒤 옵티마이저의 step()으로 가중치를 바꾼다. 임베딩·LSTM·분류층이 같은 분류 손실을 줄이도록 함께 학습된다. 검증에서는 model.eval()로 평가 모드를 정하고 torch.no_grad()로 기울기 계산을 끈다. eval()만 호출한다고 기울기 계산이 꺼지는 것은 아니다.

교재는 검증 손실이 가장 작은 시점의 가중치를 저장하고 그 모델을 다시 불러와 테스트한다. 테스트 결과를 보며 설정을 계속 바꾸면 새로운 데이터에 대한 평가라는 의미가 약해진다. 이 글에서는 학습을 실행하지 않았으므로 정확도나 손실 수치를 보고하지 않는다.

두 문장의 입력부터 로짓까지 연결하기

다음은 설명을 위해 정한 사전과 토큰 목록이다. 실제 리뷰나 Mecab 실행 결과가 아니다. 사전은 PAD:0, UNK:1, 영화:2, 좋다:3, 나쁘다:4로 두고 길이를 4로 맞춘다.

토큰 목록패딩한 정수 입력실제 길이
영화, 정말, 좋다[2, 1, 3, 0]3
영화, 나쁘다[2, 4, 0, 0]2

정말은 사전에 없어 UNK가 된다. 첫 문장은 인덱스 2, 둘째 문장은 인덱스 1의 상태를 선택해야 한다. 아래 실행 미확인 코드는 교재의 기본 구조에 padding_idx=0과 실제 길이에 따른 상태 선택을 반영한 예시다. 완성된 학습 파이프라인은 아니며, 정수 입력부터 손실 계산까지 연결한다.

import torch
from torch import nn


class ReviewClassifier(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(
            vocab_size, embedding_dim, padding_idx=0
        )
        self.lstm = nn.LSTM(
            embedding_dim, hidden_dim, batch_first=True
        )
        self.classifier = nn.Linear(hidden_dim, 2)

    def forward(self, ids, lengths):
        embedded = self.embedding(ids)               # (B, T, E)
        outputs, _ = self.lstm(embedded)             # (B, T, H)
        rows = torch.arange(ids.size(0), device=ids.device)
        positions = lengths.to(ids.device) - 1
        last_states = outputs[rows, positions]      # (B, H)
        return self.classifier(last_states)         # (B, 2)


ids = torch.tensor([[2, 1, 3, 0], [2, 4, 0, 0]], dtype=torch.long)
lengths = torch.tensor([3, 2], dtype=torch.long)
labels = torch.tensor([1, 0], dtype=torch.long)  # 설명용 정답

model = ReviewClassifier(vocab_size=5, embedding_dim=3, hidden_dim=4)
logits = model(ids, lengths)
loss = nn.CrossEntropyLoss()(logits, labels)
predicted_labels = logits.argmax(dim=1)

입력 (2, 4)는 임베딩 (2, 4, 3), LSTM 출력 (2, 4, 4), 문장 표현 (2, 4), 로짓 (2, 2)로 이어진다. 이는 설정에서 도출한 크기이며 실행 로그가 아니다. lengths는 문장마다 하나인 정수이고, 값은 1 이상 TT 이하이어야 한다. 실제 토큰이 앞에 연속해서 놓이고 뒤쪽에만 PAD가 붙는다는 조건도 필요하다.

argmax(dim=1)은 문장마다 점수가 큰 클래스의 번호를 고른다. 클래스만 필요하면 소프트맥스를 거칠 필요가 없다. 확률 형태의 값이 필요할 때는 torch.softmax(logits, dim=1)을 계산한다. 위 모델은 학습하지 않았으므로 예측이 정확하다고 볼 근거는 없다.

새 리뷰에도 같은 전처리와 사전을 사용한다

새 문장에도 훈련 때의 정제·토큰화·불용어·절단 규칙과 저장된 사전을 적용한다. 새 리뷰마다 사전을 만들면 같은 번호가 다른 토큰을 가리켜 학습된 임베딩과 맞지 않는다. 모델 가중치와 함께 사전, PAD·UNK 번호, 토큰화 설정, 최대 길이를 저장해야 한다.

교재의 마지막 predict 함수는 토큰화·불용어 제거·정수 변환 후 가변 길이 입력을 넣는다. 앞에서 사용한 정규식 정제와 길이 30의 패딩·절단은 포함하지 않는다. 교재처럼 패딩 뒤의 최종 상태를 분류하는 모델을 그대로 재현할 때는 이 입력 조건 차이를 함께 봐야 한다.

이 글의 길이 기반 모델은 실제 마지막 토큰의 상태를 고르므로, 단방향이라는 조건 아래 뒤에 붙는 PAD 수가 그 상태를 바꾸지 않는다. 그래도 토큰 자체가 달라지는 정제·사전·절단 규칙은 같아야 한다. 입력 규칙과 문장의 끝을 일관되게 처리해야 학습한 분류기를 새 리뷰에도 적용할 수 있다.

참고자료

profile
Werde, der du bist!

2개의 댓글

comment-user-thumbnail
2026년 10월 1일

잘 보구 가요

1개의 답글