NSMC 감성 분류 — Bidirectional LSTM 단계별 실험

이원준·2026년 3월 20일

NLP

목록 보기
4/9

주제

NSMC 데이터셋으로 감성 분류 모델을 구현하고, Bidirectional LSTM + 전처리 최적화로 Test Accuracy 85% 이상을 달성

내용

아키텍처 설계

1. 베이스라인 (단방향 LSTM + morphs 전처리)
↓ 전처리 변경
2. 실험1: 형태소 품사 필터링 (POS)
↓ 모델 변경
3. 실험2: Bidirectional LSTM
↓ 시퀀스 길이 최적화
4. 실험3: MAX_LEN 최적화
↓ 학습 전략
5. 실험4: 학습률 스케줄링
↓
6. 최종 결과 비교표 출력

1. 공통 설정 및 데이터 로드

import numpy as np
import pandas as pd
import re
import time
import matplotlib.pyplot as plt

from konlpy.tag import Okt
import tensorflow as tf
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Embedding, Bidirectional, Dense, LSTM, Dropout
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau

SEED = 42
np.random.seed(SEED)
tf.random.set_seed(SEED)

okt = Okt()
results = {}

from google.colab import drive
drive.mount('/content/drive')

TRAIN_PATH = '/content/drive/MyDrive/ratings_train.txt'
TEST_PATH  = '/content/drive/MyDrive/ratings_test.txt'

train_data = pd.read_csv(TRAIN_PATH, delimiter='\t')
test_data  = pd.read_csv(TEST_PATH,  delimiter='\t')

print("전체 train shape:", train_data.shape)
print("전체 test  shape:", test_data.shape)
print("\n레이블 분포 (train):")
print(train_data['label'].value_counts())
train_data.head()
train_data = train_data.sample(n=50000, random_state=SEED)
test_data  = test_data.sample(n=5000,  random_state=SEED)

train_data.dropna(inplace=True)
test_data.dropna(inplace=True)

print(f"샘플링 후 → train: {train_data.shape}, test: {test_data.shape}")

구글 드라이브에서 직접 파일을 로드, train 50,000개 / test 5,000개로 샘플링하고, dropna()로 null값을 제거


2. 전처리 함수

2-1) 베이스라인용

def preprocess_base(sentence):
    sentence = re.sub(r'\\n', ' ', str(sentence))
    sentence = re.sub('[^가-힣ㄱ-ㅎㅏ-ㅣ ]', '', sentence)
    sentence = sentence.strip()
    if not sentence:
        return []
    tokens = okt.morphs(sentence, stem=True)
    tokens = [t for t in tokens if len(t) > 1]
    return tokens

okt.morphs(stem=True)로 모든 형태소를 분리하고, 1글자 토큰만 제거함. 조사·부사 등 기능어가 모두 포함됨.

2-2) 실험용

KEEP_POS = {'Noun', 'Adjective', 'Verb'}

def preprocess_pos(sentence):
    sentence = re.sub(r'\\n', ' ', str(sentence))
    sentence = re.sub('[^가-힣ㄱ-ㅎㅏ-ㅣ ]', '', sentence)
    sentence = sentence.strip()
    if not sentence:
        return []
    pos_tags = okt.pos(sentence, stem=True)
    tokens   = [word for word, pos in pos_tags
                if pos in KEEP_POS and len(word) > 1]
    return tokens

okt.pos(stem=True)로 (단어, 품사) 쌍을 받아 Noun/Adjective/Verb만 추출함. 부사, 관형사, 조사, 어미 등은 감성 판단에 기여가 적다는 가설하에 제거.

2-3) 텍스트 전처리

import time

def run_preprocessing(preprocess_fn, label=""):
    print(f"\n[전처리 시작] {label}")
    train_sents, train_labels = [], []
    test_sents,  test_labels  = [], []
    start = time.time()

    for i, (sent, lbl) in enumerate(
            zip(train_data['document'], train_data['label'])):
        if i % 10000 == 0:
            print(f"  train {i}/{len(train_data)}")
        tokens = preprocess_fn(sent)
        if tokens:
            train_sents.append(tokens)
            train_labels.append(lbl)

    for i, (sent, lbl) in enumerate(
            zip(test_data['document'], test_data['label'])):
        if i % 1000 == 0:
            print(f"  test  {i}/{len(test_data)}")
        tokens = preprocess_fn(sent)
        if tokens:
            test_sents.append(tokens)
            test_labels.append(lbl)

    print(f"  완료: {time.time()-start:.1f}s | "
          f"train {len(train_sents)}개, test {len(test_sents)}개")

    return (train_sents, np.array(train_labels),
            test_sents,  np.array(test_labels))

2-4) 모델 정의

# 베이스라인, 실험1
def build_baseline(vocab_size, max_len):
    model = Sequential([
        Embedding(vocab_size + 1, 64),
        LSTM(64),
        Dense(32, activation='relu'),
        Dense(1,  activation='sigmoid')
    ])
    model.compile(
        loss='binary_crossentropy',
        optimizer='adam',
        metrics=['accuracy']
    )
    return model

Embedding(64) → 단방향 LSTM(64) → Dense(32, relu) → Dense(1, sigmoid) 구조. Dropout이 없어 과적합에 취약함.

# 실험2~4
def build_bilstm(vocab_size, max_len):
    model = Sequential([
        Embedding(vocab_size + 1, 128, input_length=max_len),
        Bidirectional(LSTM(128, dropout=0.2, recurrent_dropout=0.2)),
        Dense(64, activation='relu'),
        Dropout(0.3),
        Dense(1,  activation='sigmoid')
    ])
    model.compile(
        loss='binary_crossentropy',
        optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),
        metrics=['accuracy']
    )
    return model

Embedding(128) → Bidirectional LSTM(128) → Dense(64, relu) → Dropout(0.3) → Dense(1, sigmoid) 구조

  • Bidirectional: 순방향+역방향 LSTM을 동시에 학습해 앞뒤 문맥을 모두 반영
  • dropout=0.2, recurrent_dropout=0.2: LSTM 내부 과적합 방지
  • Dropout(0.3): Dense 레이어 후 30% 뉴런 랜덤 비활성화

2-5) run_experiment() 함수

def run_experiment(name, train_sents, test_sents,
                   train_labels, test_labels,
                   vocab_size, max_len, build_fn,
                   epochs=20, batch_size=128, callbacks=None):

    print(f"\n{'='*50}")
    print(f"  실험: {name}")
    print(f"  vocab_size={vocab_size}, max_len={max_len}")
    print(f"{'='*50}")

    tokenizer = Tokenizer(num_words=vocab_size, oov_token='<OOV>')
    tokenizer.fit_on_texts(train_sents)

    train_seq = tokenizer.texts_to_sequences(train_sents)
    test_seq  = tokenizer.texts_to_sequences(test_sents)

    train_pad = pad_sequences(train_seq, maxlen=max_len,
                               padding='post', truncating='post')
    test_pad  = pad_sequences(test_seq,  maxlen=max_len,
                               padding='post', truncating='post')

    print(f"  train_pad: {train_pad.shape}, test_pad: {test_pad.shape}")

    reverse_word_index = {v: k for k, v in tokenizer.word_index.items()}
    decoded = ' '.join([reverse_word_index.get(i, '<pad>')
                        for i in train_pad[0] if i != 0])
    print(f"  [검증] 첫 샘플 역변환: {decoded[:80]}")

    model = build_fn(vocab_size, max_len)
    model.summary()

    history = model.fit(
        train_pad, train_labels,
        epochs=epochs,
        batch_size=batch_size,
        validation_data=(test_pad, test_labels),
        callbacks=callbacks,
        verbose=1
    )

    best_acc = max(history.history['val_accuracy'])
    min_acc  = min(history.history['val_accuracy'])
    avg_acc  = np.mean(history.history['val_accuracy'])
    best_epoch = np.argmax(history.history['val_accuracy']) + 1

    train_acc = history.history['accuracy']
    val_acc   = history.history['val_accuracy']
    gap = max(train_acc) - max(val_acc)

    val_loss     = history.history['val_loss']
    min_val_loss = min(val_loss)
    best_loss_epoch = np.argmin(val_loss) + 1

    last_train_acc = train_acc[-1]
    last_val_acc   = val_acc[-1]
    drop_from_best = best_acc - last_val_acc

    results[name] = round(best_acc * 100, 2)

    print(f"\n  [{name}] 최고 val_accuracy: {best_acc*100:.2f}%")
    print(f"  [{name}] 최저 val_accuracy: {min_acc*100:.2f}%")
    print(f"  [{name}] 평균 val_accuracy: {avg_acc*100:.2f}%")
    print(f"  [{name}] 최고 epoch: {best_epoch}")
    print(f"  [{name}] 과적합 gap: {gap*100:.2f}%")
    print(f"  [{name}] 최소 val_loss: {min_val_loss:.4f}")
    print(f"  [{name}] 최소 val_loss epoch: {best_loss_epoch}")
    print(f"  [{name}] 마지막 train_accuracy: {last_train_acc*100:.2f}%")
    print(f"  [{name}] 마지막 val_accuracy: {last_val_acc*100:.2f}%")
    print(f"  [{name}] 최고 대비 마지막 val_accuracy 하락폭: {drop_from_best*100:.2f}%")

    return history, model, tokenizer

파이프라인 동작 순서:

  1. Tokenizer 생성 (train 데이터로만 사전 구축)
  2. texts_to_sequences로 숫자 시퀀스 변환
  3. pad_sequences로 길이 통일 (post padding/truncating)
  4. reverse_word_index로 첫 샘플 역변환 (전처리 검증)
  5. 모델 학습 후 val_accuracy 통계 출력 및 results 저장

3. 실험

3-1) 베이스라인

(train_sents_base, train_labels_base,
 test_sents_base,  test_labels_base) = run_preprocessing(
    preprocess_base, "Baseline (morphs)")

hist_base, model_base, tok_base = run_experiment(
    name         = 'Baseline',
    train_sents  = train_sents_base,
    test_sents   = test_sents_base,
    train_labels = train_labels_base,
    test_labels  = test_labels_base,
    vocab_size   = 10000,
    max_len      = 15,
    build_fn     = build_baseline,
    epochs       = 20
)

모든 형태소를 품사 구분 없이 분리

3-2) 형태소 품사 필터링

(train_sents_pos, train_labels_pos,
 test_sents_pos,  test_labels_pos) = run_preprocessing(
    preprocess_pos, "Exp1 (POS filtering)")

hist_exp1, model_exp1, tok_exp1 = run_experiment(
    name         = 'Exp1_POS',
    train_sents  = train_sents_pos,
    test_sents   = test_sents_pos,
    train_labels = train_labels_pos,
    test_labels  = test_labels_pos,
    vocab_size   = 10000,
    max_len      = 15,
    build_fn     = build_baseline,
    epochs       = 20
)

okt.morphs()okt.pos()로 교체해서 감성 분석에 유의미한 Noun, Adjective, Verb만 남기고 조사·어미·접속사 등 기능어를 제거

3-3) Bidirectional LSTM

hist_exp2, model_exp2, tok_exp2 = run_experiment(
    name         = 'Exp2_BiLSTM',
    train_sents  = train_sents_pos,
    test_sents   = test_sents_pos,
    train_labels = train_labels_pos,
    test_labels  = test_labels_pos,
    vocab_size   = 10000,
    max_len      = 15,
    build_fn     = build_bilstm,
    epochs       = 20
)

3-4) MAX_LEN 최적화 + vocab 확장

lengths = [len(s) for s in train_sents_pos]
p80 = int(np.percentile(lengths, 80))
p95 = int(np.percentile(lengths, 95))
print(f"\n[MAX_LEN 분석]")
print(f"  평균: {np.mean(lengths):.1f} | 중간값: {np.median(lengths):.1f}")
print(f"  80th percentile: {p80} | 95th percentile: {p95}")

plt.figure(figsize=(8, 3))
plt.hist(lengths, bins=40, edgecolor='white')
plt.axvline(p95, color='red',    linestyle='--', label=f'95th = {p95}')
plt.axvline(p80, color='orange', linestyle='--', label=f'80th = {p80}')
plt.xlabel('Token count per sentence')
plt.ylabel('Frequency')
plt.title('문장 길이 분포')
plt.legend()
plt.tight_layout()
plt.show()

⇒ 토큰 1~3개가 압도적으로 많음. 거의 다 짧은 리뷰.

OPT_MAX_LEN = 11
print(f"  → 선택된 MAX_LEN: {OPT_MAX_LEN}")

hist_exp3, model_exp3, tok_exp3 = run_experiment(
    name         = 'Exp3_MaxLen',
    train_sents  = train_sents_pos,
    test_sents   = test_sents_pos,
    train_labels = train_labels_pos,
    test_labels  = test_labels_pos,
    vocab_size   = 20000,
    max_len      = OPT_MAX_LEN,
    build_fn     = build_bilstm,
    epochs       = 20
)

max_len=11로 바꾸고, vocab 또한 10,000개에서 20,000개로 늘려 더 많은 단어를 <OOV> 없이 처리

3-5) 학습률 스케줄링

callbacks_final = [
    EarlyStopping(
        monitor='val_accuracy',
        patience=3,
        restore_best_weights=True,
        verbose=1
    ),
    ReduceLROnPlateau(
        monitor='val_loss',
        factor=0.5,
        patience=2,
        min_lr=1e-6,
        verbose=1
    )
]

hist_exp4, model_exp4, tok_exp4 = run_experiment(
    name         = 'Exp4_LRSchedule',
    train_sents  = train_sents_pos,
    test_sents   = test_sents_pos,
    train_labels = train_labels_pos,
    test_labels  = test_labels_pos,
    vocab_size   = 20000,
    max_len      = OPT_MAX_LEN,
    build_fn     = build_bilstm,
    epochs       = 30,
    callbacks    = callbacks_final
)

EarlyStopping(restore_best_weights=True)으로 val_accuracy가 최고인 시점의 가중치를 자동 복원하고, ReduceLROnPlateau로 학습이 정체될 때 학습률을 절반으로 줄여 더 세밀하게 수렴시킴.


4. 초기 실험 결과

fig, ax = plt.subplots(figsize=(10, 4))
names  = list(results.keys())
values = list(results.values())
colors = ['#888780', '#534AB7', '#0F6E56', '#993C1D', '#3B6D11']

bars = ax.barh(names, values, color=colors[:len(names)], height=0.5)
ax.axvline(x=85, color='red', linestyle='--', linewidth=1.5, label='목표 85%')
ax.set_xlabel('val_accuracy (%)')
ax.set_title('실험별 성능 비교')
ax.set_xlim(70, 92)

for bar, val in zip(bars, values):
    ax.text(bar.get_width() + 0.2,
            bar.get_y() + bar.get_height() / 2,
            f'{val:.2f}%', va='center', fontsize=11)

ax.legend()
plt.tight_layout()
plt.show()

histories = {
    'Baseline':        hist_base,
    'Exp1_POS':        hist_exp1,
    'Exp2_BiLSTM':     hist_exp2,
    'Exp3_MaxLen':     hist_exp3,
    'Exp4_LRSchedule': hist_exp4,
}

fig, axes = plt.subplots(len(histories), 2,
                          figsize=(12, 4 * len(histories)))
for i, (name, hist) in enumerate(histories.items()):
    axes[i, 0].plot(hist.history['accuracy'],     label='train')
    axes[i, 0].plot(hist.history['val_accuracy'], label='val')
    axes[i, 0].axhline(0.85, color='red', linestyle='--',
                        linewidth=0.8, label='목표 85%')
    axes[i, 0].set_title(f'{name} — accuracy')
    axes[i, 0].set_xlabel('Epochs')
    axes[i, 0].legend()

    axes[i, 1].plot(hist.history['loss'],     label='train')
    axes[i, 1].plot(hist.history['val_loss'], label='val')
    axes[i, 1].set_title(f'{name} — loss')
    axes[i, 1].set_xlabel('Epochs')
    axes[i, 1].legend()

plt.tight_layout()
plt.show()

  • 참고 train accuracy : 모델이 훈련 데이터를 얼마나 맞추는지( epoch마다 가중치를 업데이트하면서 학습하기 때문에 일반적으로 계속 올라감.) val accuracy : 모델이 한 번도 본 적 없는 테스트 데이터를 얼마나 맞추는지(실제 모델 성능) train loss : 훈련 데이터에서 예측값과 정답의 차이(학습이 잘되면 계속 내려감.) val loss : 테스트 데이터에서 예측값과 정답의 차이(올라가면 과적합 신호)
print("\n" + "=" * 52)
print(f"  {'실험명':<22} {'val_accuracy':>12}")
print("=" * 52)
for name, acc in results.items():
    bar    = '█' * int((acc - 70) / 1.5)
    target = '  ← 목표 달성!' if acc >= 85.0 else ''
    print(f"  {name:<22} {acc:>8.2f}%  {bar}{target}")
print("=" * 52)

# 단계별 개선폭
print("\n[단계별 개선폭]")
prev_name = None
for name, acc in results.items():
    if prev_name:
        diff = acc - results[prev_name]
        sign = '+' if diff >= 0 else ''
        print(f"  {prev_name}{name}: {sign}{diff:.2f}%")
    prev_name = name

def predict_sentiment(text, model, tokenizer, max_len):
    tokens = preprocess_pos(text)
    seq    = tokenizer.texts_to_sequences([tokens])
    padded = pad_sequences(seq, maxlen=max_len,
                            padding='post', truncating='post')
    prob   = model.predict(padded, verbose=0)[0][0]
    label  = 'positive' if prob >= 0.5 else 'negative'
    print(f"  입력: {text}")
    print(f"  토큰: {tokens}")
    print(f"  결과: {label}  (확률: {prob:.4f})\n")
    return label

print("\n[최종 모델 예측 테스트]")
samples = [
    "이 영화 정말 재미있었어요. 배우들 연기가 너무 좋았어요",
    "시간 낭비였어요. 완전 실망했습니다",
    "그냥 그저 그랬어요. 기대보다는 별로",
    "역대급 명작입니다. 꼭 보세요",
    "스토리가 너무 허술해서 몰입이 안 됨"
]

for text in samples:
    predict_sentiment(text, model_exp4, tok_exp4, OPT_MAX_LEN)

실험명val_accuracy최고 epoch과적합 gap진단
Baseline~80%2~315%+심한 과적합
Exp1_POS~80%2~315%+심한 과적합
Exp2_BiLSTM~80%2~315%+심한 과적합
Exp3_MaxLen~80%2~315%+심한 과적합
Exp4_LRSchedule79.58%111.85%epoch 1이 최고

목표 85%에 미달하였으며, 전 실험에서 78~80% 수준에 정체됨.

실험별 분석

Exp1_POS: 품사 필터링으로 노이즈를 줄여 Baseline보다 좋아질 것을 기대했지만, 실제로는 Baseline(80.35%)보다 낮은 79.96%. 부사 등 감성에 중요한 품사를 제거해서 오히려 정보가 손실된 것.

Exp2_BiLSTM: 양방향 LSTM + Dropout으로 성능 향상과 과적합 완화를 기대했지만, 80.33%로 Baseline과 거의 차이가 없음. 그래프를 보면 과적합 패턴도 Baseline과 동일. 데이터가 부족한 상황에서 모델 용량만 키우면 과적합만 더 심해지는 걸 보여줌.

Exp3_MaxLen: max_len과 vocab 최적화로 개선을 기대했지만 79.68%로 오히려 떨어짐. POS 필터링으로 이미 토큰이 1~3개 수준으로 줄어든 상태에서 max_len을 15→11로 줄이고 vocab을 2만으로 늘린 건 실질적 효과가 없었음.

Exp4_LRSchedule: EarlyStopping + LR 스케줄링으로 가장 좋은 결과를 기대했지만, 79.58%로 최하위. 그래프를 보면 epoch 3~4에서 EarlyStopping이 걸려서 일찍 멈춘 것이 보이는데, 이는 epoch 1부터 이미 과적합이 시작되어 개선 여지가 없었다는 뜻. 학습률 스케줄링은 "더 잘 수렴하게" 해주는 기법이지, 데이터 부족 문제를 해결하는 기법이 아님.


5. 예상 결과와 다른 이유 — 가설

가설 1. POS 필터링이 오히려 정보를 과도하게 제거

현재 KEEP_POS = {Noun, Adjective, Verb}로 설정되어 있어, 감성 강조에 핵심적인 부사가 전부 제거됨.

  • "너무 별로다" → "별로다"만 남음 ("너무" 제거)
  • "정말 재미있다" → "재미있다"만 남음 ("정말" 제거)
  • "진짜 실망했어" → "실망하다"만 남음 ("진짜" 제거)

POS 필터링 후 평균 토큰 수가 7.9개로 매우 적어, 모델이 학습할 정보 자체가 부족한 상태.

가설 2. MAX_LEN 너무 짧게 설정

POS 필터링 후 토큰 수가 이미 적은 상태에서 max_len을 15→11로 더 줄인 것은 정보 부족을 악화시킴.

가설 3. EarlyStopping이 너무 일찍 종료

실험 4에서 epoch 1이 최고점이었다는 것은, 모델이 첫 epoch부터 과적합으로 진입했다는 의미. patience=3이 너무 짧아 학습률이 줄어든 후 다시 개선될 여지를 주지 못함.

가설 4. 데이터 양 부족 (가장 핵심적인 원인)

train 50,000개는 NSMC 전체(150,000개)의 33% 수준. 감성 분류처럼 다양한 표현 패턴을 학습해야 하는 태스크에서 데이터 양은 성능에 직결됨. 5만 개로는 어휘 다양성과 문맥 패턴을 충분히 커버하지 못하고, 모델이 소수 패턴에 과적합하게 됨.

가설 5. 코드 자체의 문제점

실험 외적으로 코드에도 개선이 필요한 부분들이 있음:

  • 변수 통제 실패: Exp3에서 max_len과 vocab_size를 동시에 변경하여 개별 효과 측정 불가
  • validation으로 test 직접 사용: validation_data=(test_pad, test_labels)로 test 정보가 학습에 간접 반영
  • build_baseline에 input_length 누락: build_bilstm과 불일치
  • recurrent_dropout으로 인한 속도 저하: recurrent_dropout > 0이면 cuDNN 가속 비활성화

6. 가설 검증 — 개선 실험

개선 실험 1. Adverb(감성 강조 부사) 추가

KEEP_POS = {'Noun', 'Adjective', 'Verb', 'Adverb'}

결과: Exp1이 오히려 0.33% 줄음 (별 차이 없음)

분석: 부사 추가 자체는 맞는 방향이지만, 5만 개 데이터 제한이라는 더 큰 병목이 있어서 효과가 가려진 것.

개선 실험 2. MAX_LEN을 20으로 설정

결과: 약간의 성능 상승

분석: 시퀀스 길이를 늘려 정보 손실을 줄인 효과가 있지만, 근본적인 데이터 부족 문제는 해결하지 못함.

개선 실험 3. 학습률 낮춤 (0.001 → 0.0003)

optimizer=tf.keras.optimizers.Adam(learning_rate=0.0003)

결과: 미세하지만 성능 감소

분석: 학습률을 낮추면 수렴은 안정적이지만, 데이터가 부족한 상황에서는 더 오래 학습해도 일반화 성능이 오르지 않음. 학습률은 "더 잘 수렴하게" 해주는 기법이지, 데이터 부족을 해결하는 기법이 아님.

개선 실험 4. 전면 코드 개선 + 데이터 전체 사용

근본적인 병목을 해결하기 위해 코드를 전면 수정:

수정 사항기존수정이유
데이터train 50,000개train 150,000 전체80% 벽의 근본 원인 해결
POS 필터링Noun/Adj/Verb+ Adverb + Exclamation감성 핵심 부사/감탄사 보존
validationtest를 직접 사용validation_split=0.2test 정보 유출 방지
변수 통제동시에 여러 변수 변경한 번에 하나만 변경개별 기법 기여도 측정
input_lengthbaseline에 누락추가모델 간 일관성 확보
EarlyStoppingmode 미지정mode='max' 명시안전한 동작 보장
recurrent_dropout0.20.0cuDNN 가속 활성화 (3~5배 속도 향상)

결과: 전 실험에서 단계별 상승 패턴 확인, 최종 83.06% 달성

분석: 데이터를 15만 전체로 늘리자 80% 벽을 넘어 83%대에 진입. 과적합은 줄어들었지만 완전히 해소되지는 않은 상태.

개선 5. morphs 전처리로 복귀

POS 필터링이 크게 의미있어 보이지 않아, Exp2_BiLSTM부터 morphs 전처리로 변경.

이유: Adverb를 추가해도 품사 태깅 과정에서 오분류되는 토큰이 있고, 짧은 리뷰에서 토큰이 1~3개로 줄어드는 문제가 있음. morphs 기반으로 돌아가면 토큰 수가 더 많아져서 LSTM이 학습할 시퀀스 패턴이 풍부해짐.

결과: 개선되긴 했지만 극적이지는 않음

개선 6. Dropout 강화 + LSTM 유닛 축소

과적합 gap이 10%+ 이상이므로 모델 용량을 줄이고 정규화를 강화:

파라미터기존수정
Embedding 차원12864
LSTM 유닛12864
Dense 유닛6432
Dropout (Embedding 뒤)0.20.3
Dropout (Dense 뒤)0.30.5

왜 유닛을 줄이는가?

LSTM 128 유닛이면 문장을 읽으면서 128개의 메모를 동시에 적을 수 있음. 메모 칸이 많으면 "이 영화 봤는데"라는 특정 표현이 훈련 데이터에서 우연히 긍정 리뷰에 많이 나왔을 때, 이걸 "긍정 신호"로 외워버림 (실제로는 중립적인 표현임에도).

LSTM 64 유닛이면 메모 칸이 절반이라 모든 세부 사항을 적을 수 없음. 개별 표현을 외우는 대신 "부정 부사 + 형용사 = 부정" 같은 일반적인 패턴만 학습하게 됨. 이게 새로운 데이터에도 통하는 패턴.

왜 Dropout을 강화하는가?

Dropout 0.5면 Dense 레이어의 뉴런 절반을 매번 랜덤으로 꺼버림. 모델이 특정 뉴런 조합에 의존해서 답을 외우는 게 불가능해지고, 여러 뉴런이 고르게 유용한 정보를 학습하게 됨.

결과적으로 train accuracy가 95%에서 88% 정도로 내려가는 대신, val accuracy가 83%에서 85%로 올라가는 것을 기대하는 전략.

결과: 과적합 gap이 줄어들며 소폭 성능 개선

개선 7. 추가 최적화 (Vocab 확장, LSTM 구조확장, Batch Size, Val Split, MAX_LEN 확대)

이후 단계적으로 추가 최적화를 진행:

실험변경 사항Test Accuracy
Exp6_Vocabvocab 10,000→25,000, max_len→15083.48%
Exp7_BatchSizebatch_size 128→6483.15%
Exp8_DeepLSTM2층 BiLSTM (128→64), Embedding 25683.66%
Exp8_ValSplitval_split 0.2→0.184.11%


7. 최종 결과

실험별 성능 비교

실험변경 사항핵심 효과
Baseline기준점~80%
Exp1_POS품사 필터링효과 미미 (오히려 정보 손실)
Exp2_BiLSTM양방향 LSTM문맥 학습 능력 향상
Exp3_BiLSTM_RegDropout 강화 + 유닛 축소과적합 gap 감소
Exp4_MaxLen시퀀스 길이 최적화정보 손실 최소화
Exp5_LRSchedule학습률 스케줄링세밀한 수렴
Exp6_Vocab어휘 확장 + MAX_LEN 확대OOV 감소
Exp7_BatchSize배치 크기 축소gradient 노이즈 정규화
Exp8_DeepLSTM2층 BiLSTM + Embedding 256과적합 증가, 단일층보다 오히려 낮음
Exp8_ValSplit학습 데이터 확대패턴 다양성 증가

주요 인사이트

  1. 데이터 양이 가장 중요: 5만 개 샘플링 시 80% 벽에 막혔으나, 15만 전체 사용 시 83%+ 달성. 어떤 기법을 적용해도 데이터가 부족하면 성능 향상에 한계가 있음.
  2. POS 필터링은 역효과: 짧은 구어체 리뷰에서는 부사("너무", "정말", "안") 등 기능어도 감성 판단에 핵심적. 무조건 제거하면 오히려 정보 손실.
  3. 과적합 억제가 핵심: 모델 용량을 키우는 것보다 Dropout 강화 + 유닛 축소가 더 효과적. train accuracy가 다소 떨어지더라도 val과의 gap을 줄이면 실제 성능이 향상됨.
  4. 변수 통제의 중요성: 한 번에 하나의 변수만 바꿔야 각 기법의 기여도를 정확히 측정 가능. 여러 변수를 동시에 바꾸면 "왜 이 결과가 나왔는지" 설명할 수 없음.
  5. validation 분리 필수: test를 validation으로 사용하면 EarlyStopping이 test 성능 기준으로 최적 모델을 선택하게 되어 보고된 정확도가 과대 추정될 위험.

한계 및 개선 방향

  • LSTM의 구조적 한계: LSTM 기반 아키텍처로는 83~84%가 실질적 한계. 단어를 순차적으로 처리하는 RNN 계열의 근본적 제약.
  • 사전학습 모델 필요: 85% 이상 달성을 위해서는 KoBERT, KoELECTRA 등 대규모 사전학습 모델 활용 필요. 이들은 이미 수십억 개의 텍스트로 사전 학습되어 있어 소량 데이터에서도 높은 성능 가능.
  • 2층 LSTM의 가능성: 2층 LSTM 구조는 MAX_LEN이 충분히 길 때(150+) 효과적. 짧은 시퀀스에서는 첫 번째 층에서 이미 정보를 다 처리하여 두 번째 층의 이점이 없음.

깃허브 링크

NLP_STUDTY/week1/이원준 at main · Tave-Tokens/NLP_STUDTY

0개의 댓글