들어가며: 다중 분류 코드에서 네 군데만 바꾸면 됩니다
AICE Professional 시험의 2번 Text 문제는 다중 분류가 나올 수도 있지만, **이진 분류(Binary Classification)**가 출제될 가능성도 있습니다. 이전 글에서 Text 다중 분류를 다뤘으니, 이번에는 이진 분류 유형을 정리합니다.
저는 2번 문제에서 만점을 받았습니다. 이진 분류든 다중 분류든 전처리와 토크나이저 과정은 완전히 동일하고, 출력층, 손실 함수, 예측 후처리, 임계값 설정 네 가지만 달라집니다. 다중 분류 코드를 확실히 익혀 둔 분이라면, 이진 분류 전환은 5분이면 충분합니다.
AICE Professional 시험 구조 (복습)
| 문제 | 유형 | 배점 |
|---|---|---|
| 1번 | Tabular (정형 데이터) | 30점 |
| 2번 | Text (텍스트 데이터) | 35점 |
| 3번 | Image (이미지 데이터) | 35점 |
합격 기준은 80점 이상입니다. 2번은 35점 배점으로 비중이 큰 만큼, 확실히 가져가야 할 점수입니다.
2번 문제: Text 이진 분류 — 출제 유형 분석
예상 문제 형태
텍스트 컬럼(문의 메시지, 리뷰, 문장 등)과 이진 라벨(긍정/부정, 스팸/정상 등)이 주어지고, 테스트 텍스트가 두 클래스 중 어디에 속하는지 예측하는 문제입니다.
핵심 포인트는 다음과 같습니다.
- 전처리와 토크나이저 과정은 다중 분류와 완전히 동일합니다
- 출력층이
Dense(1, sigmoid)로 변경됩니다 - 손실 함수가
binary_crossentropy로 변경됩니다 - 예측 시
argmax대신 0.5 임계값으로 0/1을 판정합니다
이진 분류 vs 다중 분류 — 차이점 한눈에 보기
Text 문제에서 이진/다중 전환 시 바꿔야 할 부분은 정확히 네 곳입니다.
| 항목 | 이진 분류 | 다중 분류 |
|---|---|---|
| 출력층 | Dense(1, sigmoid) | Dense(num_classes, softmax) |
| 손실 함수 | binary_crossentropy | sparse_categorical_crossentropy |
| 예측 변환 | proba >= 0.5 → 0/1 | argmax → 클래스 인덱스 |
| 확률 출력 형태 | (N, 1) → .ravel() 필요 | (N, K) → 그대로 사용 |
나머지는 전부 동일합니다. 전처리, 토크나이저, 패딩, Embedding + BiLSTM 구조, 콜백 설정, LabelEncoder, inverse_transform 모두 그대로 사용합니다.
풀이 전략: 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, 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
# ── 모델 하이퍼파라미터 ──
EMBED_DIM = 128
LSTM_UNITS = 64
DROPOUT_R = 0.3
BATCH_SIZE = 64
EPOCHS = 15
PATIENCE = 3
SEED = 42
THRESHOLD = 0.5 # 이진 분류 판정 임계값
다중 분류 코드와 거의 동일하지만, THRESHOLD = 0.5가 추가되었습니다. sigmoid 출력이 이 값 이상이면 양성(1), 미만이면 음성(0)으로 판정합니다.
Step 1. 데이터 로드
python
train_df = pd.read_csv(TRAIN_CSV)
test_df = pd.read_csv(TEST_CSV)
# 테스트 ID 컬럼 찾기
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))
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)
전처리 함수는 다중 분류와 완전히 동일합니다. 영문/숫자/한글/공백만 남기고 나머지를 제거합니다. 문제에서 “한글만 남기세요”라고 지시하면 정규식을 [^가-힣 ]으로 변경하면 됩니다.
Step 3. 라벨 인코딩 및 학습/검증 분리
python
# 라벨 인코딩
le = LabelEncoder()
y = le.fit_transform(train_df[TARGET_COL].values)
classes = list(le.classes_)
print('Classes:', classes, '(binary)')
# 학습/검증 분리
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
)
이진 분류에서도 LabelEncoder를 사용합니다. 라벨이 이미 0/1이더라도 적용해 두면, 나중에 inverse_transform()으로 안전하게 원래 라벨명을 복원할 수 있습니다.
Step 4. 토크나이저 & 시퀀스 패딩 — 다중 분류와 동일
python
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)
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))
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')
토크나이저와 패딩 과정은 이진/다중 구분 없이 완전히 동일합니다. 다중 분류 글에서 설명한 내용을 그대로 적용하면 됩니다.
Step 5. 모델 구성 — 여기서 차이가 생깁니다
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(1, activation='sigmoid')) # ← 이진 분류
model.compile(
optimizer='adam',
loss='binary_crossentropy', # ← 이진 분류
metrics=['accuracy']
)
model.summary()
다중 분류 코드에서 딱 두 줄만 변경했습니다.
- 출력층:
Dense(num_classes, softmax)→Dense(1, sigmoid) - 손실 함수:
sparse_categorical_crossentropy→binary_crossentropy
나머지 Embedding, SpatialDropout1D, BiLSTM, Dense(64), Dropout 구조는 전부 동일합니다.
모델 구조를 한눈에 정리하면 이렇습니다.
Input(정수 시퀀스) → Embedding → SpatialDropout → BiLSTM → Dense(64, relu) → Dropout → Dense(1, sigmoid)
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
)
]
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}')
콜백 설정도 다중 분류와 동일합니다. val_accuracy를 모니터링하면서 mode='max'로 설정하는 것, 잊지 마세요.
Step 7. 테스트 예측 및 제출 — 여기서도 차이가 있습니다
python
best_model = tf.keras.models.load_model(OUT_KERAS)
# sigmoid 출력: (N, 1) → 1차원으로 변환
proba = best_model.predict(X_test, batch_size=BATCH_SIZE, verbose=1).ravel()
# 임계값 0.5 기준으로 0/1 판정
pred_idx = (proba >= THRESHOLD).astype(int)
# 원래 라벨명으로 복원
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)
이진 분류 예측의 핵심 차이점을 정리합니다.
| 단계 | 다중 분류 | 이진 분류 |
|---|---|---|
| 모델 출력 형태 | (N, K) 확률 행렬 | (N, 1) 확률값 |
| 평탄화 | 불필요 | .ravel()로 1차원 변환 필요 |
| 클래스 결정 | argmax(axis=1) | proba >= 0.5 |
| 결과 | 클래스 인덱스 (0~K-1) | 0 또는 1 |
.ravel()을 잊으면 안 됩니다. sigmoid 출력은 shape이 (N, 1)이라서, 그대로 비교하면 차원 불일치가 발생할 수 있습니다. .ravel()로 (N,) 형태로 평탄화한 뒤 임계값 비교를 해야 합니다.
성능이 안 나올 때 — 간단 튜닝 가이드
| 우선순위 | 조정 항목 | 시도 값 | 기대 효과 |
|---|---|---|---|
| 1 | EPOCHS | 15 → 20 → 30 | 학습 부족 해소 |
| 2 | LSTM_UNITS | 64 → 96 → 128 | 모델 용량 증가 |
| 3 | EMBED_DIM | 128 → 192 → 256 | 임베딩 표현력 강화 |
| 4 | THRESHOLD | 0.5 → 0.4 → 0.6 | 판정 기준 조정 |
| 5 | DROPOUT_R | 0.3 → 0.2 → 0.4 | 과적합/과소적합 균형 |
| 6 | max_len 범위 | 256 상한 → 384 | 긴 문장 정보 보존 |
이진 분류에서 다중 분류와 다른 튜닝 포인트가 하나 있습니다. 임계값(THRESHOLD) 조정입니다. 기본값 0.5가 항상 최적은 아닙니다. 클래스 불균형이 있으면 0.4나 0.6으로 바꿔 보는 것만으로 Accuracy가 개선될 수 있습니다.
실전 체크리스트
시험장에서 제출 전, 이 순서대로 확인하세요.
TEXT_COL,TARGET_COL컬럼명이 문제와 일치하는가?- 텍스트 전처리를 train과 test 모두에 적용했는가?
Tokenizer.fit_on_texts를 학습 데이터로만 실행했는가?- 출력층이
Dense(1, sigmoid)인가? (다중 분류와 혼동하지 않았는가?) - 손실 함수가
binary_crossentropy인가? monitor='val_accuracy'와mode='max'가 일치하는가?- val_accuracy가 목표 기준을 넘겼는가?
- 예측 시
.ravel()로 평탄화한 뒤 임계값 비교를 했는가? inverse_transform()으로 원래 라벨명을 복원했는가?- 제출 CSV 컬럼명과
.keras파일이 정상 저장되었는가?
마무리
Text 이진 분류는 다중 분류 코드를 이미 익힌 분이라면 가장 빠르게 전환할 수 있는 유형입니다. 전처리부터 BiLSTM까지 전부 동일하고, 출력층과 손실 함수, 예측 후처리만 바꾸면 됩니다.
핵심을 한 문장으로 요약합니다. 출력은 sigmoid, 손실은 binary_crossentropy, 예측은 ravel() 후 0.5 기준. 이 세 가지만 기억하면 다중 분류 코드에서 2분 안에 이진 분류로 전환할 수 있습니다.