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

판다·7일 전

1. 프로젝트 개요

이번 실습에서는 네이버 영화 리뷰 데이터(NSMC, Naver Sentiment Movie Corpus)를 활용해 리뷰가 긍정인지 부정인지 분류하는 감성 분석 모델을 구현했다.

감성 분석(Sentiment Analysis)은 문장이나 문서에 담긴 감정·평가를 분류하는 자연어 처리 작업이다. 영화 리뷰에서는 일반적으로 다음과 같이 이진 분류 문제로 설정한다.

레이블의미
0부정 리뷰
1긍정 리뷰

데이터는 리뷰 내용이 담긴 document, 감성 레이블인 label, 데이터 식별자인 id로 구성된다. 모델 학습에는 document와 label을 사용하고, id는 분류에 직접적인 의미가 없으므로 제외한다. wikidocs

2. 데이터 구성

NSMC 데이터는 총 200,000개의 네이버 영화 리뷰로 구성된다.

데이터 구분리뷰 수
훈련 데이터150,000개
테스트 데이터50,000개
전체 데이터200,000개

훈련 데이터의 예시는 다음과 같은 구조를 가진다.

id          document                         label
9976970     아 더빙.. 진짜 짜증나네요 목소리      0
3819312     흠... 포스터보고 초딩영화줄...        1
10265843    너무재밓었다그래서보는것을추천한다     1

한국어 리뷰 데이터에는 띄어쓰기 오류, 이모티콘, 반복 문자, 숫자, 영어, 특수문자, 신조어 등이 포함될 수 있다. 따라서 모델 학습 전 데이터 정제와 형태소 기반 토큰화가 중요하다. wikidocs

3. 데이터 전처리

중복 데이터 제거

먼저 같은 리뷰 문장이 여러 번 존재할 수 있으므로 document 열을 기준으로 중복 데이터를 제거한다.

train_data.drop_duplicates(subset=['document'], inplace=True)

원본 훈련 데이터는 150,000개였지만, 중복 제거 후에는 146,183개가 남는다. document의 고유값 수는 146,182개이며, 여기에는 결측값 1개가 포함되어 있다. wikidocs

결측값 제거

리뷰 내용이 없는 행은 감성 분석에 사용할 수 없으므로 제거한다.

train_data = train_data.dropna(how='any')

결측값을 제거한 뒤 텍스트 전처리를 수행한다.

한글 이외 문자 처리

실습에서는 한글 자음·모음·완성형 한글과 공백을 제외한 문자를 제거한다.

train_data['document'] = train_data['document'].str.replace(
    "[^ㄱ-ㅎㅏ-ㅣ가-힣 ]",
    "",
    regex=True
)

정규표현식의 의미는 다음과 같다.

표현의미
ㄱ-ㅎ한글 자음
ㅏ-ㅣ한글 모음
가-힣완성형 한글 음절
공백띄어쓰기 유지
[^ ... ]대괄호 안에 포함되지 않는 문자

특수문자 제거 이후에는 영어, 숫자, 이모티콘만으로 작성된 리뷰가 빈 문자열이 될 수 있다. 이런 데이터는 NaN으로 바꾼 뒤 다시 제거한다.

train_data['document'] = train_data['document'].str.replace(
    '^ +',
    '',
    regex=True
)

train_data['document'].replace('', np.nan, inplace=True)
train_data = train_data.dropna(how='any')

전처리 후 훈련 데이터는 145,393개, 테스트 데이터는 48,852개가 남는다. wikidocs

4. 형태소 토큰화와 불용어 제거

한국어는 조사와 어미가 단어에 결합하는 교착어이므로, 단순히 공백 기준으로 나누는 방식보다 형태소 분석 기반 토큰화가 유용하다.

실습에서는 KoNLPy의 Mecab 형태소 분석기를 사용한다.

from konlpy.tag import Mecab

mecab = Mecab()

sentence = "와 이런 것도 영화라고 차라리 뮤직비디오를 만드는 게 나을 뻔"
print(mecab.morphs(sentence))
[
    '와', '이런', '것', '도', '영화', '라고', '차라리',
    '뮤직', '비디오', '를', '만드', '는', '게', '나을', '뻔'
]

이후 감성 분류에 상대적으로 도움이 적을 수 있는 조사, 어미, 접속 표현 등을 불용어로 정의해 제거한다.

stopwords = [
    '도', '는', '다', '의', '가', '이', '은', '한',
    '에', '하', '고', '을', '를', '인', '듯', '과',
    '와', '네', '들', '지', '임', '게'
]
X_train = []

for sentence in train_data['document']:
    tokens = mecab.morphs(sentence)
    tokens = [word for word in tokens if word not in stopwords]
    X_train.append(tokens)

예를 들어 다음 리뷰는 형태소 토큰화와 불용어 제거 후 여러 토큰으로 분리된다.

원문: 아 더빙.. 진짜 짜증나네요 목소리

토큰화 결과:
['아', '더', '빙', '진짜', '짜증', '나', '네요', '목소리']

다만 불용어는 고정된 정답 목록이 아니다. 분석 목적, 도메인, 모델 구조에 따라 중요한 단어가 달라질 수 있으므로 실제 프로젝트에서는 데이터 탐색과 성능 평가를 바탕으로 조정해야 한다. wikidocs

5. 학습·검증·테스트 데이터 분리

모델은 훈련 데이터로 학습하고, 검증 데이터로 하이퍼파라미터와 과적합 여부를 점검하며, 마지막으로 테스트 데이터에서 최종 성능을 측정한다.

훈련 데이터의 20%를 검증 데이터로 분리한다.

from sklearn.model_selection import train_test_split

X_train, X_valid, y_train, y_valid = train_test_split(
    X_train,
    y_train,
    test_size=0.2,
    random_state=0,
    stratify=y_train
)

여기서 stratify=y_train은 분리 이후에도 긍정·부정 레이블의 비율이 최대한 유지되도록 한다.

데이터부정 비율긍정 비율
학습 데이터약 50.238%약 49.762%
검증 데이터약 50.239%약 49.761%
테스트 데이터약 49.808%약 50.192%

레이블이 거의 균형을 이루므로 정확도(Accuracy)를 기본 평가 지표로 사용할 수 있다. 다만 실제 서비스에서 한쪽 감성이 압도적으로 많다면 정확도만으로 성능을 판단하지 말고 Precision, Recall, F1-score, ROC-AUC 등을 함께 확인해야 한다. wikidocs

6. 단어 집합과 정수 인코딩

딥러닝 모델은 문자열이 아니라 숫자를 입력으로 받는다. 따라서 토큰화된 단어에 고유한 정수 인덱스를 부여하는 정수 인코딩(Integer Encoding) 을 수행한다.

먼저 학습 데이터에서 단어의 등장 빈도를 계산한다.

from collections import Counter

word_list = []

for sentence in X_train:
    for word in sentence:
        word_list.append(word)

word_counts = Counter(word_list)

학습 데이터에는 총 45,296개의 서로 다른 단어가 등장한다. 이 중 등장 횟수가 2회 이하인 희귀 단어는 26,105개로, 전체 단어 종류의 약 57.63%를 차지한다. 하지만 전체 등장 빈도에서 차지하는 비중은 약 2.28%에 불과하다. wikidocs

따라서 등장 빈도 3회 미만의 희귀 단어를 단어 사전에서 제외한다.

threshold = 3

vocab_size = total_cnt - rare_cnt
vocab = vocab[:vocab_size]

희귀 단어 제거 후 실제 단어 사전 크기는 19,191개가 된다.

특수 토큰

시퀀스 길이를 맞추기 위한 패딩과 단어 사전에 없는 단어 처리를 위해 특수 토큰을 추가한다.

word_to_index = {}

word_to_index['<PAD>'] = 0
word_to_index['<UNK>'] = 1
토큰인덱스역할
<PAD>0길이가 짧은 문장을 채우는 패딩 토큰
<UNK>1단어 사전에 없는 미지의 단어

최종 단어 사전 크기는 19,193개가 된다.

for index, word in enumerate(vocab):
    word_to_index[word] = index + 2

토큰을 정수 시퀀스로 바꾸는 함수는 다음과 같다.

def texts_to_sequences(tokenized_X_data, word_to_index):
    encoded_X_data = []

    for sent in tokenized_X_data:
        index_sequences = []

        for word in sent:
            index_sequences.append(
                word_to_index.get(word, word_to_index['<UNK>'])
            )

        encoded_X_data.append(index_sequences)

    return encoded_X_data

예를 들어 토큰 시퀀스가 다음과 같다고 가정해보자.

['이야', '어쩜', '이렇게', '나', '지루', '할', '수']

정수 인코딩 후에는 다음처럼 변환된다.

[924, 1866, 128, 7, 80, 48, 34]

7. 패딩

리뷰마다 길이가 다르기 때문에, 배치 단위 연산을 하려면 모든 입력 시퀀스의 길이를 동일하게 맞춰야 한다. 이 과정을 패딩(Padding) 이라고 한다.

훈련 데이터에서 리뷰의 최대 길이는 74, 평균 길이는 약 12.3이다. 실습에서는 max_len = 30으로 설정하며, 이 길이는 훈련 샘플의 약 92.5%를 포함한다. wikidocs

max_len = 30
def pad_sequences(sentences, max_len):
    features = np.zeros((len(sentences), max_len), dtype=int)

    for index, sentence in enumerate(sentences):
        features[index, :len(sentence)] = np.array(sentence)[:max_len]

    return features

패딩 후 첫 번째 리뷰는 다음과 같은 형태가 된다.

[
    924, 1866, 128, 7, 80, 48, 34,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]

문장 뒤쪽에 붙은 0은 <PAD> 토큰이다.

데이터패딩 후 크기
훈련 데이터(116,314, 30)
검증 데이터(29,079, 30)
테스트 데이터(48,852, 30)

각 샘플은 길이 30의 정수 시퀀스로 변환된다. wikidocs

8. LSTM 감성 분류 모델

모델은 다음 순서로 구성된다.

정수 인코딩된 리뷰
→ Embedding
→ LSTM
→ 마지막 은닉 상태
→ Fully Connected Layer
→ 긍정/부정 분류

PyTorch 구현은 다음과 같다.

import torch
import torch.nn as nn

class TextClassifier(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim):
        super(TextClassifier, self).__init__()

        self.embedding = nn.Embedding(vocab_size, embedding_dim)

        self.lstm = nn.LSTM(
            input_size=embedding_dim,
            hidden_size=hidden_dim,
            batch_first=True
        )

        self.fc = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        # x: (batch_size, sequence_length)
        embedded = self.embedding(x)

        # embedded: (batch_size, sequence_length, embedding_dim)
        lstm_out, (hidden, cell) = self.lstm(embedded)

        # hidden: (num_layers, batch_size, hidden_dim)
        last_hidden = hidden.squeeze(0)

        # logits: (batch_size, output_dim)
        logits = self.fc(last_hidden)

        return logits

입력과 출력의 텐서 형태는 다음과 같이 변한다.

단계텐서 형태설명
입력(batch_size, sequence_length)정수 인코딩된 단어 인덱스
임베딩(batch_size, sequence_length, embedding_dim)각 단어가 벡터로 변환됨
LSTM 마지막 은닉 상태(batch_size, hidden_dim)문장 전체를 요약한 벡터
출력층(batch_size, 2)부정·긍정 클래스별 점수

예를 들어 배치 크기 32, 문장 길이 30, 임베딩 차원 100, 은닉 상태 차원 128이라면 텐서 흐름은 다음과 같다.

(32, 30)
→ (32, 30, 100)
→ (32, 128)
→ (32, 2)

9. 학습 설정

실습에서 사용한 주요 하이퍼파라미터는 다음과 같다.

항목값
단어 사전 크기19,193
최대 문장 길이30
임베딩 차원100
LSTM 은닉 상태 차원128
출력 클래스 수2
배치 크기32
옵티마이저Adam
학습률0.001
손실 함수Cross Entropy Loss
에포크5
embedding_dim = 100
hidden_dim = 128
output_dim = 2

model = TextClassifier(
    vocab_size=vocab_size,
    embedding_dim=embedding_dim,
    hidden_dim=hidden_dim,
    output_dim=output_dim
)

criterion = nn.CrossEntropyLoss()

optimizer = torch.optim.Adam(
    model.parameters(),
    lr=0.001
)

이진 분류 문제이지만 출력 노드를 2개로 두고 nn.CrossEntropyLoss()를 사용한다. 모델은 각 클래스의 점수(logits)를 출력하며, 가장 높은 점수를 가진 클래스를 최종 예측으로 선택한다. wikidocs

10. 학습과 검증

학습 과정에서는 다음 순서가 반복된다.

  1. 배치 데이터를 모델에 입력한다.
  2. 예측 결과인 logits를 계산한다.
  3. 실제 레이블과 예측값의 손실을 계산한다.
  4. loss.backward()로 역전파를 수행한다.
  5. optimizer.step()으로 모델 파라미터를 업데이트한다.
  6. 각 에포크가 끝날 때 검증 데이터 성능을 확인한다.
  7. 검증 손실이 가장 낮은 모델을 저장한다.
best_val_loss = float('inf')

for epoch in range(num_epochs):
    model.train()

    for batch_X, batch_y in train_dataloader:
        batch_X = batch_X.to(device)
        batch_y = batch_y.to(device)

        logits = model(batch_X)
        loss = criterion(logits, batch_y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    val_loss, val_accuracy = evaluate(
        model,
        valid_dataloader,
        criterion,
        device
    )

    if val_loss < best_val_loss:
        best_val_loss = val_loss
        torch.save(model.state_dict(), 'best_model_checkpoint.pth')

모델 평가 시에는 model.eval()과 torch.no_grad()를 사용한다.

model.eval()

with torch.no_grad():
    logits = model(batch_X)
코드역할
model.train()학습 모드 활성화
model.eval()평가 모드 활성화
torch.no_grad()평가 중 기울기 계산 비활성화
optimizer.zero_grad()이전 배치의 기울기 초기화
loss.backward()손실 기준 역전파 수행
optimizer.step()모델 파라미터 업데이트

특히 model.eval()은 드롭아웃, 배치 정규화처럼 학습·평가 동작이 다른 레이어가 포함된 모델에서 중요하다. torch.no_grad()는 평가 시 기울기 계산을 생략해 메모리 사용량과 연산 시간을 줄여준다. wikidocs

11. 모델 성능

검증 손실이 가장 낮았던 모델을 불러와 최종 성능을 평가한다.

평가 데이터LossAccuracy
검증 데이터0.339284.90%
테스트 데이터0.343584.92%

훈련에 참여하지 않은 테스트 데이터에서도 약 84.92%의 정확도를 보였다. 검증 성능과 테스트 성능 차이가 크지 않기 때문에, 예시 결과에서는 극단적인 과적합 징후가 크지 않다고 해석할 수 있다. wikidocs

12. 새로운 리뷰 예측

학습한 모델은 새로운 영화 리뷰에 대해 긍정 또는 부정을 예측할 수 있다.

index_to_tag = {
    0: '부정',
    1: '긍정'
}
def predict(text, model, word_to_index, index_to_tag):
    model.eval()

    tokens = mecab.morphs(text)
    tokens = [word for word in tokens if word not in stopwords]

    token_indices = [
        word_to_index.get(token, word_to_index['<UNK>'])
        for token in tokens
    ]

    input_tensor = torch.tensor(
        [token_indices],
        dtype=torch.long
    ).to(device)

    with torch.no_grad():
        logits = model(input_tensor)

    predicted_index = torch.argmax(logits, dim=1)

    return index_to_tag[predicted_index.item()]

예측 결과는 다음과 같다.

입력 리뷰예측 결과
이 영화 개꿀잼 ㅋㅋㅋ긍정
이딴게 영화냐 ㅉㅉ부정
감독 뭐하는 놈이냐?부정
와 개쩐다 정말 세계관 최강자들의 영화다긍정

모델은 토큰화, 불용어 제거, 정수 인코딩, 임베딩, LSTM 연산을 거쳐 입력 문장에 포함된 감성 정보를 바탕으로 최종 클래스를 예측한다. 다만 신조어, 비꼬는 표현, 복합 감정, 문맥 의존적 표현에서는 오분류가 발생할 수 있다. wikidocs

13. 정리

  • NSMC는 긍정·부정 레이블을 가진 총 20만 건의 네이버 영화 리뷰 데이터셋이다.
  • 중복값과 결측값을 제거하고, 정규표현식으로 텍스트를 정제했다.
  • 한국어는 교착어이므로 Mecab 형태소 분석기를 이용해 토큰화했다.
  • 희귀 단어를 제거하고 <PAD>, <UNK> 특수 토큰을 추가해 단어 사전을 구성했다.
  • 토큰을 정수로 인코딩하고, 모든 리뷰 길이를 30으로 맞추기 위해 패딩을 적용했다.
  • Embedding → LSTM → Fully Connected Layer 구조의 감성 분류 모델을 구현했다.
  • 예시 모델은 검증 정확도 84.90%, 테스트 정확도 84.92%를 기록했다.
  • 실제 성능 개선을 위해서는 양방향 LSTM, GRU, 사전학습 임베딩, Transformer 기반 언어 모델, 하이퍼파라미터 튜닝 등을 추가로 실험할 수 있다.
profile
welcome to deeplearning

0개의 댓글