NSMC 데이터셋으로 감성 분류 모델을 구현하고, Bidirectional LSTM + 전처리 최적화로 Test Accuracy 85% 이상을 달성
1. 베이스라인 (단방향 LSTM + morphs 전처리)
↓ 전처리 변경
2. 실험1: 형태소 품사 필터링 (POS)
↓ 모델 변경
3. 실험2: Bidirectional LSTM
↓ 시퀀스 길이 최적화
4. 실험3: MAX_LEN 최적화
↓ 학습 전략
5. 실험4: 학습률 스케줄링
↓
6. 최종 결과 비교표 출력
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값을 제거
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글자 토큰만 제거함. 조사·부사 등 기능어가 모두 포함됨.
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만 추출함. 부사, 관형사, 조사, 어미 등은 감성 판단에 기여가 적다는 가설하에 제거.
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))
# 베이스라인, 실험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) 구조
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
파이프라인 동작 순서:
texts_to_sequences로 숫자 시퀀스 변환pad_sequences로 길이 통일 (post padding/truncating)reverse_word_index로 첫 샘플 역변환 (전처리 검증)(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
)
모든 형태소를 품사 구분 없이 분리
(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만 남기고 조사·어미·접속사 등 기능어를 제거
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
)
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> 없이 처리
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로 학습이 정체될 때 학습률을 절반으로 줄여 더 세밀하게 수렴시킴.
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()




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~3 | 15%+ | 심한 과적합 |
| Exp1_POS | ~80% | 2~3 | 15%+ | 심한 과적합 |
| Exp2_BiLSTM | ~80% | 2~3 | 15%+ | 심한 과적합 |
| Exp3_MaxLen | ~80% | 2~3 | 15%+ | 심한 과적합 |
| Exp4_LRSchedule | 79.58% | 1 | 11.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부터 이미 과적합이 시작되어 개선 여지가 없었다는 뜻. 학습률 스케줄링은 "더 잘 수렴하게" 해주는 기법이지, 데이터 부족 문제를 해결하는 기법이 아님.
현재 KEEP_POS = {Noun, Adjective, Verb}로 설정되어 있어, 감성 강조에 핵심적인 부사가 전부 제거됨.
POS 필터링 후 평균 토큰 수가 7.9개로 매우 적어, 모델이 학습할 정보 자체가 부족한 상태.
POS 필터링 후 토큰 수가 이미 적은 상태에서 max_len을 15→11로 더 줄인 것은 정보 부족을 악화시킴.
실험 4에서 epoch 1이 최고점이었다는 것은, 모델이 첫 epoch부터 과적합으로 진입했다는 의미. patience=3이 너무 짧아 학습률이 줄어든 후 다시 개선될 여지를 주지 못함.
train 50,000개는 NSMC 전체(150,000개)의 33% 수준. 감성 분류처럼 다양한 표현 패턴을 학습해야 하는 태스크에서 데이터 양은 성능에 직결됨. 5만 개로는 어휘 다양성과 문맥 패턴을 충분히 커버하지 못하고, 모델이 소수 패턴에 과적합하게 됨.
실험 외적으로 코드에도 개선이 필요한 부분들이 있음:
validation_data=(test_pad, test_labels)로 test 정보가 학습에 간접 반영build_bilstm과 불일치recurrent_dropout > 0이면 cuDNN 가속 비활성화KEEP_POS = {'Noun', 'Adjective', 'Verb', 'Adverb'}
결과: Exp1이 오히려 0.33% 줄음 (별 차이 없음)
분석: 부사 추가 자체는 맞는 방향이지만, 5만 개 데이터 제한이라는 더 큰 병목이 있어서 효과가 가려진 것.

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

optimizer=tf.keras.optimizers.Adam(learning_rate=0.0003)
결과: 미세하지만 성능 감소
분석: 학습률을 낮추면 수렴은 안정적이지만, 데이터가 부족한 상황에서는 더 오래 학습해도 일반화 성능이 오르지 않음. 학습률은 "더 잘 수렴하게" 해주는 기법이지, 데이터 부족을 해결하는 기법이 아님.

근본적인 병목을 해결하기 위해 코드를 전면 수정:
| 수정 사항 | 기존 | 수정 | 이유 |
|---|---|---|---|
| 데이터 | train 50,000개 | train 150,000 전체 | 80% 벽의 근본 원인 해결 |
| POS 필터링 | Noun/Adj/Verb | + Adverb + Exclamation | 감성 핵심 부사/감탄사 보존 |
| validation | test를 직접 사용 | validation_split=0.2 | test 정보 유출 방지 |
| 변수 통제 | 동시에 여러 변수 변경 | 한 번에 하나만 변경 | 개별 기법 기여도 측정 |
| input_length | baseline에 누락 | 추가 | 모델 간 일관성 확보 |
| EarlyStopping | mode 미지정 | mode='max' 명시 | 안전한 동작 보장 |
| recurrent_dropout | 0.2 | 0.0 | cuDNN 가속 활성화 (3~5배 속도 향상) |
결과: 전 실험에서 단계별 상승 패턴 확인, 최종 83.06% 달성
분석: 데이터를 15만 전체로 늘리자 80% 벽을 넘어 83%대에 진입. 과적합은 줄어들었지만 완전히 해소되지는 않은 상태.

POS 필터링이 크게 의미있어 보이지 않아, Exp2_BiLSTM부터 morphs 전처리로 변경.
이유: Adverb를 추가해도 품사 태깅 과정에서 오분류되는 토큰이 있고, 짧은 리뷰에서 토큰이 1~3개로 줄어드는 문제가 있음. morphs 기반으로 돌아가면 토큰 수가 더 많아져서 LSTM이 학습할 시퀀스 패턴이 풍부해짐.
결과: 개선되긴 했지만 극적이지는 않음

과적합 gap이 10%+ 이상이므로 모델 용량을 줄이고 정규화를 강화:
| 파라미터 | 기존 | 수정 |
|---|---|---|
| Embedding 차원 | 128 | 64 |
| LSTM 유닛 | 128 | 64 |
| Dense 유닛 | 64 | 32 |
| Dropout (Embedding 뒤) | 0.2 | 0.3 |
| Dropout (Dense 뒤) | 0.3 | 0.5 |
왜 유닛을 줄이는가?
LSTM 128 유닛이면 문장을 읽으면서 128개의 메모를 동시에 적을 수 있음. 메모 칸이 많으면 "이 영화 봤는데"라는 특정 표현이 훈련 데이터에서 우연히 긍정 리뷰에 많이 나왔을 때, 이걸 "긍정 신호"로 외워버림 (실제로는 중립적인 표현임에도).
LSTM 64 유닛이면 메모 칸이 절반이라 모든 세부 사항을 적을 수 없음. 개별 표현을 외우는 대신 "부정 부사 + 형용사 = 부정" 같은 일반적인 패턴만 학습하게 됨. 이게 새로운 데이터에도 통하는 패턴.
왜 Dropout을 강화하는가?
Dropout 0.5면 Dense 레이어의 뉴런 절반을 매번 랜덤으로 꺼버림. 모델이 특정 뉴런 조합에 의존해서 답을 외우는 게 불가능해지고, 여러 뉴런이 고르게 유용한 정보를 학습하게 됨.
결과적으로 train accuracy가 95%에서 88% 정도로 내려가는 대신, val accuracy가 83%에서 85%로 올라가는 것을 기대하는 전략.
결과: 과적합 gap이 줄어들며 소폭 성능 개선

이후 단계적으로 추가 최적화를 진행:
| 실험 | 변경 사항 | Test Accuracy |
|---|---|---|
| Exp6_Vocab | vocab 10,000→25,000, max_len→150 | 83.48% |
| Exp7_BatchSize | batch_size 128→64 | 83.15% |
| Exp8_DeepLSTM | 2층 BiLSTM (128→64), Embedding 256 | 83.66% |
| Exp8_ValSplit | val_split 0.2→0.1 | 84.11% |

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