들어가며: 2번은 패턴만 잡으면 만점입니다
AICE Professional 시험의 2번 문제는 텍스트 데이터를 분류하는 NLP 문제입니다. 영문, 한글, 특수문자가 섞인 텍스트가 주어지고, 이것이 어떤 카테고리에 속하는지 예측해야 합니다.
저는 이 2번 문제에서 만점을 받았습니다. 1번 Tabular도 만점이었지만, 3번 Image에서 불합격한 경험이 있습니다. 2번은 텍스트 전처리와 LSTM 모델 구성이라는 정해진 패턴이 있어서, 이 패턴을 확실히 익혀 두면 안정적으로 만점을 받을 수 있습니다.
AICE Professional 시험 구조 (복습)
| 문제 | 유형 | 배점 |
|---|---|---|
| 1번 | Tabular (정형 데이터) | 30점 |
| 2번 | Text (텍스트 데이터) | 35점 |
| 3번 | Image (이미지 데이터) | 35점 |
합격 기준은 80점 이상입니다. 2번은 35점 배점으로, 1번(30점)보다 비중이 큽니다. 확실히 가져가야 할 점수입니다.
2번 문제: Text 다중 분류 — 출제 유형 분석
예상 문제 형태
텍스트 컬럼(예: 문의 메시지, 리뷰, 문장 등)과 카테고리 라벨이 주어지고, 테스트 텍스트의 카테고리를 예측하는 다중 분류 문제가 출제됩니다.
핵심 포인트는 다음과 같습니다.
- 텍스트에 영문, 숫자, 한글, 특수문자가 섞여 있습니다
- 전처리로 특수문자를 제거하고 영문/숫자/한글/공백만 남깁니다
- 모델은 Embedding + BiLSTM + Dense 구조를 사용합니다
- 평가 기준은 val_accuracy 56% 이상 (문제에 따라 다를 수 있음)
- 제출 파일: 예측 CSV +
.keras모델 파일
1번 Tabular과 달리 sklearn이 아닌 TensorFlow/Keras를 사용해야 하고, 토크나이저와 시퀀스 패딩이라는 NLP 특유의 전처리가 필요합니다. 처음 접하면 복잡하게 느껴지지만, 각 단계의 역할을 이해하면 기계적으로 적용할 수 있습니다.
풀이 전략: 8단계 접근법
Step 0. 환경 설정 — 하이퍼파라미터를 한 곳에 모으세요
python
import re
import numpy as np
import pandas as pd
from pathlib import Path
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
import tensorflow as tf
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras import layers, models, callbacks
from tensorflow.keras.models import Sequential
# ── 경로 설정 ──
DATA_DIR = Path('.')
TRAIN_CSV = DATA_DIR / '02_train.csv'
TEST_CSV = DATA_DIR / '02_test_x.csv'
PHONE = '01012345678' # 본인 전화번호로 변경
OUT_CSV = DATA_DIR / f'{PHONE}_2.csv'
OUT_KERAS = DATA_DIR / f'{PHONE}_2.keras'
TEXT_COL = 'reqmsg' # 텍스트 컬럼명 (문제에 따라 변경)
TARGET_COL = 'category' # 라벨 컬럼명 (문제에 따라 변경)
# ── 토크나이저/시퀀스 설정 ──
NUM_WORDS = 30000 # 단어 사전 크기
OOV_TOKEN = '<OOV>' # 사전에 없는 단어 대체 토큰
PCT_LEN = 0.95 # 문장 길이 95% 분위수 사용
# ── 모델 하이퍼파라미터 ──
EMBED_DIM = 128
LSTM_UNITS = 64
DROPOUT_R = 0.3
BATCH_SIZE = 64
EPOCHS = 15
PATIENCE = 3
SEED = 42
하이퍼파라미터 각각의 의미를 정리해 두겠습니다.
| 파라미터 | 값 | 역할 |
|---|---|---|
NUM_WORDS | 30000 | 빈도 상위 30,000개 단어만 사전에 포함 |
OOV_TOKEN | ‘<OOV>’ | 사전에 없는 단어를 이 토큰으로 대체 |
PCT_LEN | 0.95 | 상위 5% 긴 문장은 잘라내고, 95%까지만 커버 |
EMBED_DIM | 128 | 단어 임베딩 벡터 차원 |
LSTM_UNITS | 64 | LSTM 은닉 상태 크기 |
DROPOUT_R | 0.3 | 과적합 방지를 위한 드롭아웃 비율 |
Step 1. 데이터 로드
python
train_df = pd.read_csv(TRAIN_CSV)
test_df = pd.read_csv(TEST_CSV)
# 테스트 ID 컬럼 찾기 (없으면 index 사용)
ID_COL_CANDIDATES = ['id', 'ID', 'index', 'sample_id', 'reqid']
test_id_col = None
for c in ID_COL_CANDIDATES:
if c in test_df.columns:
test_id_col = c
break
if test_id_col is None:
test_id_col = 'id'
test_df = test_df.copy()
test_df[test_id_col] = np.arange(len(test_df))
팁: 테스트 CSV의 ID 컬럼명은 문제마다 다를 수 있습니다. 여러 후보를 미리 리스트에 넣어 두고 자동으로 찾는 방식이 시험장에서 안전합니다.
Step 2. 텍스트 전처리 — 특수문자 제거
python
def clean_text(s: str) -> str:
if not isinstance(s, str):
s = '' if pd.isna(s) else str(s)
s = s.lower()
s = re.sub(r'[^0-9a-z가-힣\s]', ' ', s) # 영문/숫자/한글/공백만 남김
s = re.sub(r'\s+', ' ', s).strip()
return s
train_df[TEXT_COL] = train_df[TEXT_COL].apply(clean_text)
test_df[TEXT_COL] = test_df[TEXT_COL].apply(clean_text)
전처리는 이 한 함수로 충분합니다. 핵심은 영문, 숫자, 한글, 공백 외의 모든 문자를 제거하는 것입니다. 정규식 [^0-9a-z가-힣\s]가 이 역할을 합니다.
만약 문제에서 “한글만 남기세요”라고 지시하면, 정규식을 [^가-힣 ]으로 변경하면 됩니다.
python
# 한글만 남기는 경우
s = re.sub(r'[^가-힣 ]', '', s)
Step 3. 라벨 인코딩 및 학습/검증 분리
python
# 라벨 인코딩
le = LabelEncoder()
y = le.fit_transform(train_df[TARGET_COL].values)
num_classes = len(le.classes_)
print('Classes:', list(le.classes_), ' -> ', num_classes)
# 학습/검증 분리 (stratify 필수)
X_train_text, X_val_text, y_train, y_val = train_test_split(
train_df[TEXT_COL].values, y,
test_size=0.2, random_state=SEED, stratify=y
)
1번 Tabular 다중 분류와 마찬가지로, LabelEncoder로 문자열 라벨을 정수로 변환하고 stratify=y로 클래스 비율을 유지합니다.
Step 4. 토크나이저 & 시퀀스 패딩
python
# 토크나이저: 학습 텍스트로만 fit
tk = Tokenizer(num_words=NUM_WORDS, oov_token=OOV_TOKEN)
tk.fit_on_texts(X_train_text)
# 텍스트 → 정수 시퀀스 변환
seq_train = tk.texts_to_sequences(X_train_text)
seq_val = tk.texts_to_sequences(X_val_text)
seq_test = tk.texts_to_sequences(test_df[TEXT_COL].values)
# 시퀀스 최대 길이 결정: 95% 분위수 기준
lengths = np.array([len(s) for s in seq_train if len(s) > 0] + [1])
max_len = int(np.percentile(lengths, PCT_LEN * 100))
max_len = max(16, min(max_len, 256)) # 16~256 범위로 제한
print('Max sequence length:', max_len)
# 패딩: 길이를 맞춤
X_train = pad_sequences(seq_train, maxlen=max_len, padding='post', truncating='post')
X_val = pad_sequences(seq_val, maxlen=max_len, padding='post', truncating='post')
X_test = pad_sequences(seq_test, maxlen=max_len, padding='post', truncating='post')
이 단계가 Text 문제의 핵심입니다. 각 과정의 역할을 정리하면 이렇습니다.
| 과정 | 입력 | 출력 | 역할 |
|---|---|---|---|
Tokenizer.fit_on_texts | 원문 텍스트 | (내부 사전 구축) | 단어별 정수 인덱스 생성 |
texts_to_sequences | 원문 텍스트 | 정수 리스트 | “안녕 세계” → [5, 12] |
pad_sequences | 가변 길이 리스트 | 고정 길이 배열 | 짧으면 0으로 채움, 길면 자름 |
padding='post'를 사용하는 이유: 문장 앞부분(의미가 있는 부분)을 그대로 두고, 뒤쪽에 0을 채웁니다. LSTM이 문장을 앞에서부터 읽으므로, 의미 있는 토큰이 앞에 있는 것이 학습에 유리합니다.
fit_on_texts는 학습 데이터로만: 검증/테스트 데이터의 단어까지 사전에 포함하면 데이터 누출(leakage)이 발생합니다. 토크나이저는 반드시 학습 텍스트로만 fit하세요.
Step 5. 모델 구성 — Embedding + BiLSTM
python
vocab_size = min(NUM_WORDS, len(tk.word_index) + 1)
model = Sequential()
model.add(layers.Embedding(
input_dim=vocab_size, output_dim=EMBED_DIM, input_length=max_len
))
model.add(layers.SpatialDropout1D(0.2))
model.add(layers.Bidirectional(layers.LSTM(LSTM_UNITS, return_sequences=False)))
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dropout(DROPOUT_R))
model.add(layers.Dense(num_classes, activation='softmax'))
model.compile(
optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
model.summary()
모델 구조를 한눈에 정리하면 이렇습니다.
Input(정수 시퀀스) → Embedding → SpatialDropout → BiLSTM → Dense(64, relu) → Dropout → Dense(softmax)
각 레이어의 역할도 알아 두면 좋습니다.
| 레이어 | 역할 |
|---|---|
Embedding | 정수 인덱스를 고정 차원 벡터로 변환 (단어의 의미를 학습) |
SpatialDropout1D | 임베딩 차원 단위로 드롭아웃 (단어 수준 정규화) |
Bidirectional(LSTM) | 문장을 앞→뒤, 뒤→앞 양방향으로 읽어 문맥 파악 |
Dense(64, relu) | 비선형 변환으로 표현력 확보 |
Dropout | 과적합 방지 |
Dense(softmax) | 클래스별 확률 출력 |
sparse_categorical_crossentropy를 쓰는 이유: 라벨이 정수(0, 1, 2, …)이므로, 원-핫 인코딩 없이 바로 사용할 수 있는 sparse 버전을 씁니다. 원-핫으로 변환했다면 categorical_crossentropy를 사용해야 합니다.
Step 6. 콜백 설정 및 학습
python
cb = [
callbacks.EarlyStopping(
monitor='val_accuracy', patience=PATIENCE,
mode='max', restore_best_weights=True
),
callbacks.ModelCheckpoint(
filepath=str(OUT_KERAS),
monitor='val_accuracy', mode='max',
save_best_only=True, save_format='keras'
)
]
hist = model.fit(
X_train, y_train,
validation_data=(X_val, y_val),
epochs=EPOCHS,
batch_size=BATCH_SIZE,
callbacks=cb,
verbose=1
)
# 검증 정확도 확인
val_loss, val_acc = model.evaluate(X_val, y_val, verbose=0)
print(f'[VAL] accuracy = {val_acc:.4f} (target >= 0.56)')
콜백에서 주의할 점이 있습니다.
| monitor 값 | mode | 의미 |
|---|---|---|
val_accuracy | max | 정확도가 높을수록 좋음 |
val_loss | min | 손실이 낮을수록 좋음 |
monitor와 mode를 잘못 맞추면 EarlyStopping이 엉뚱하게 동작합니다. val_accuracy를 모니터링하면서 mode=’min’으로 설정하면, 정확도가 떨어질 때 “좋아지고 있다”고 판단하는 실수가 생깁니다.
Step 7. 테스트 예측 및 제출 파일 저장
python
# 저장된 베스트 모델 로드 (안전)
best_model = tf.keras.models.load_model(OUT_KERAS)
# 예측: softmax 확률 → argmax → 원래 라벨명 복원
proba = best_model.predict(X_test, batch_size=BATCH_SIZE, verbose=1)
pred_idx = proba.argmax(axis=1)
pred_lbl = le.inverse_transform(pred_idx)
# 제출 CSV 저장
submit = pd.DataFrame({
test_id_col: test_df[test_id_col],
TARGET_COL: pred_lbl
})
submit.to_csv(OUT_CSV, index=False, encoding='utf-8')
print('Saved:', OUT_CSV)
print('Saved:', OUT_KERAS)
1번 Tabular 다중 분류와 마찬가지로, inverse_transform()으로 정수 인덱스를 원래 카테고리명으로 복원해야 합니다. 이 변환을 빠뜨리고 정수를 그대로 제출하면 채점에서 틀립니다.
ModelCheckpoint로 저장한 베스트 모델을 다시 로드하는 이유: 학습 중 val_accuracy가 가장 높았던 시점의 가중치를 사용하기 위해서입니다. EarlyStopping의 restore_best_weights=True가 있어 사실상 같은 결과이지만, 파일에서 로드하는 것이 더 확실합니다.
성능이 56%에 못 미칠 때 — 간단 튜닝 가이드
| 우선순위 | 조정 항목 | 시도 값 | 기대 효과 |
|---|---|---|---|
| 1 | EPOCHS | 15 → 20 → 30 | 학습 부족 해소 |
| 2 | LSTM_UNITS | 64 → 96 → 128 | 모델 용량 증가 |
| 3 | EMBED_DIM | 128 → 192 → 256 | 임베딩 표현력 강화 |
| 4 | NUM_WORDS | 30000 → 40000 → 60000 | 더 많은 단어 커버 |
| 5 | max_len 범위 | 256 상한 → 384 | 긴 문장 정보 보존 |
| 6 | DROPOUT_R | 0.3 → 0.2 → 0.4 | 과적합/과소적합 균형 |
| 7 | 모델 구조 변경 | LSTM → GRU | GRU가 더 빠르고 비슷한 성능 |
시간 여유가 있다면 LSTM을 2층으로 쌓는 것도 효과적입니다.
python
# 2층 Stacked LSTM
model.add(layers.Bidirectional(
layers.LSTM(LSTM_UNITS, return_sequences=True) # 첫 번째: return_sequences=True
))
model.add(layers.Bidirectional(
layers.LSTM(LSTM_UNITS, return_sequences=False) # 두 번째: return_sequences=False
))
return_sequences=True는 모든 시점의 출력을 다음 레이어로 전달합니다. 마지막 LSTM만 False로 설정하면 최종 시점의 출력만 Dense 층으로 넘깁니다.
실전 체크리스트
시험장에서 제출 전, 이 순서대로 확인하세요.
TEXT_COL,TARGET_COL컬럼명이 문제와 일치하는가?- 텍스트 전처리(특수문자 제거)를 train과 test 모두에 적용했는가?
Tokenizer.fit_on_texts를 학습 데이터로만 실행했는가?LabelEncoder로 라벨을 인코딩했는가?train_test_split에stratify=y를 넣었는가?monitor와mode가 일치하는가? (val_accuracy → max, val_loss → min)- val_accuracy가 목표 기준(예: 56%)을 넘겼는가?
inverse_transform()으로 예측값을 원래 카테고리명으로 복원했는가?- 제출 CSV 컬럼명과 포맷이 문제 요구사항과 일치하는가?
.keras모델 파일이 정상 저장되었는가?
마무리
2번 Text 문제는 NLP라는 이름 때문에 어렵게 느껴질 수 있지만, 풀이 패턴은 명확하게 정해져 있습니다. 텍스트 정제 → 토크나이저 → 패딩 → Embedding + BiLSTM → 예측 → inverse_transform. 이 흐름을 한 번 확실히 익혀 두면, 시험장에서는 컬럼명과 하이퍼파라미터만 바꿔 넣으면 됩니다.
핵심을 세 가지로 요약합니다. 전처리는 정규식 한 줄, 토크나이저는 학습 데이터로만, 예측은 반드시 inverse_transform. 이 세 가지만 놓치지 않으면 35점은 확보할 수 있습니다.