다중 분류 모델 구현하기: Softmax와 정답 인코딩의 차이

Alchemist·3일 전

KT AIVLE

목록 보기
126/130

회원 상태가 '일반/휴면/탈퇴' 중 하나이거나 고객 문의 유형이 여러 가지로 나뉜다면 다중 분류(Multiclass Classification) 가 필요하다. 이진 분류와 가장 다른 부분은 출력층의 노드 수, 활성화 함수, 정답 인코딩 방식에 따른 손실 함수다.

다중 분류의 예측값은 어떻게 생길까?

서로 배타적인 네 가지 문의 유형을 예측하는 경우 출력층에는 네 개의 노드를 둔다.

from tensorflow import keras

model = keras.Sequential([
    keras.Input(shape=(5,)),
    keras.layers.Dense(16, activation="relu"),
    keras.layers.Dense(4, activation="softmax"),
])

softmax는 각 클래스의 점수(logit)를 확률 형태로 변환한다.

pi=ezi∑jezjp_i=\frac{e^{z_i}}{\sum_j e^{z_j}}

각 확률은 0~1 사이에 있고 한 샘플에 대한 클래스 확률 합이 1이다. 예를 들어 출력값 [0.10, 0.65, 0.20, 0.05]는 두 번째 클래스가 가장 유력하다는 뜻이다.

import numpy as np

p = np.array([[0.10, 0.65, 0.20, 0.05]])
print(np.argmax(p, axis=1))  # [1]

axis=1은 샘플마다 가장 높은 확률을 가진 클래스의 열 인덱스를 선택한다. argmax()를 사용할 때 축을 빠뜨리면 전체 배열에서 하나의 값만 뽑는 실수를 할 수 있다.

정답을 어떻게 표시하느냐에 따라 손실이 달라진다

정답 표현예시(네 클래스 중 세 번째)손실 함수
정수 레이블2sparse_categorical_crossentropy
원-핫 벡터[0, 0, 1, 0]categorical_crossentropy

두 손실은 같은 종류의 다중 클래스 크로스엔트로피를 계산하되 정답의 자료형과 형태가 다르다. 정수 레이블과 원-핫 표현을 뒤섞으면 모양 오류나 잘못된 학습이 발생할 수 있다.

참고로 한 샘플이 여러 클래스를 동시에 가질 수 있는 다중 레이블 분류(Multilabel) 는 다른 문제다. 그 경우에는 클래스별 시그모이드 출력과 binary_crossentropy를 사용하는 방법이 일반적이다.

예제: 고객 문의를 네 가지 유형으로 자동 분류

이번에는 가상의 문의 분석 시스템을 생각해 보자. 최근 로그인 실패, 결제 실패, 반품 요청, 서비스 장애 기록, 대기 시간이라는 다섯 특성으로 문의를 네 그룹에 할당한다.

import numpy as np

rng = np.random.default_rng(42)
n = 2_400

login_failures = rng.poisson(2.3, n)
payment_failures = rng.poisson(1.8, n)
return_requests = rng.poisson(1.5, n)
service_errors = rng.poisson(1.9, n)
wait_hours = rng.uniform(0, 48, n)

X = np.column_stack([
    login_failures,
    payment_failures,
    return_requests,
    service_errors,
    wait_hours,
]).astype("float32")

# 실제 레이블이 없는 환경 대신, 학습 연습을 위해 가상 규칙으로 정답 생성
scores = np.column_stack([
    1.2 * login_failures + 0.02 * wait_hours,
    1.3 * payment_failures,
    1.4 * return_requests,
    1.2 * service_errors + 0.02 * wait_hours,
]) + rng.normal(0, 1.2, size=(n, 4))

y = scores.argmax(axis=1).astype("int32")
classes = ["로그인", "결제", "반품", "서비스 장애"]

print("클래스별 개수:", np.bincount(y, minlength=4))

이는 실제 고객 문의 데이터가 아니라 입력과 정답 사이의 패턴을 학습하기 위한 합성 데이터다. 실제 프로젝트라면 사람이 확인한 문의 유형 레이블이나 신뢰할 수 있는 분류 기준이 필요하다.

학습·검증·테스트 분리와 스케일링

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

X_train_all, X_test, y_train_all, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)
X_train, X_val, y_train, y_val = train_test_split(
    X_train_all, y_train_all,
    test_size=0.2, random_state=42, stratify=y_train_all
)

scaler = StandardScaler()
X_train = scaler.fit_transform(X_train).astype("float32")
X_val = scaler.transform(X_val).astype("float32")
X_test = scaler.transform(X_test).astype("float32")

카테고리 자체를 정수로 인코딩한 y에는 입력 특성 스케일러를 적용하지 않는다. 정수 레이블은 클래스 식별자이지 연속된 수치가 아니기 때문이다.

Softmax 모델 만들기

from tensorflow import keras

keras.utils.set_random_seed(42)

model = keras.Sequential([
    keras.Input(shape=(X_train.shape[1],)),
    keras.layers.Dense(24, activation="relu"),
    keras.layers.Dense(12, activation="relu"),
    keras.layers.Dense(len(classes), activation="softmax"),
])

model.compile(
    optimizer=keras.optimizers.Adam(learning_rate=0.001),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

history = model.fit(
    X_train, y_train,
    validation_data=(X_val, y_val),
    batch_size=32,
    epochs=70,
    verbose=0,
)

prob = model.predict(X_test, verbose=0)
y_pred = prob.argmax(axis=1)

print("첫 샘플의 클래스별 확률:", prob[0])
print("예측 유형:", classes[y_pred[0]])

정답은 정수 0, 1, 2, 3으로 만들었기 때문에 sparse_categorical_crossentropy를 사용했다. 마지막 층의 4는 클래스가 네 종류라는 뜻이지 입력 특성 네 개를 뜻하지 않는다.

분류 결과를 이해하기

from sklearn.metrics import confusion_matrix, classification_report

print(confusion_matrix(y_test, y_pred))
print(classification_report(
    y_test,
    y_pred,
    labels=np.arange(len(classes)),
    target_names=classes,
    zero_division=0,
))

혼동행렬의 행은 실제 클래스, 열은 예측 클래스를 뜻한다. 대각선 원소는 맞힌 건수이고, 그 밖의 원소는 어떤 문의 유형을 서로 혼동했는지 보여 준다. 전체 정확도가 비슷해도 특정 문의 유형에서 재현율이 낮을 수 있다.

원-핫 인코딩으로도 학습할 수 있을까?

정수 레이블을 0과 1로 이루어진 벡터로 바꾸면 손실 함수를 변경해야 한다.

from tensorflow import keras

y_train_onehot = keras.utils.to_categorical(y_train, num_classes=4)
y_val_onehot = keras.utils.to_categorical(y_val, num_classes=4)

# 출력층의 구조는 그대로 두고 손실 함수만 원-핫 방식에 맞게 설정
model_onehot = keras.Sequential([
    keras.Input(shape=(X_train.shape[1],)),
    keras.layers.Dense(24, activation="relu"),
    keras.layers.Dense(4, activation="softmax"),
])

model_onehot.compile(
    optimizer="adam",
    loss="categorical_crossentropy",
    metrics=["accuracy"],
)
model_onehot.fit(
    X_train, y_train_onehot,
    validation_data=(X_val, y_val_onehot),
    epochs=30,
    verbose=0,
)

정수와 원-핫 중 하나가 무조건 더 좋은 것은 아니다. 레이블이 하나의 클래스인 문제에서는 정수 방식이 간편하고 메모리도 절약하기 쉽다.

자주 발생하는 오류

Arguments target and output must have the same shape 라는 오류는 정답이 원-핫인지 정수인지, 출력층의 클래스 수가 맞는지, 손실 함수가 적절한지부터 확인하면 된다.

출력 확률을 그대로 classification_report에 입력하면 연속값과 이산 레이블이 섞였다는 오류가 발생할 수 있다. 먼저 argmax(axis=1)로 클래스를 선택한다.

레이블 인코딩 순서를 혼동하는 경우에는 LabelEncoder().classes_ 또는 직접 만든 매핑을 저장해 둔다. 운영 단계에서 학습 때와 다른 순서로 클래스를 해석하면 결과가 엉뚱한 유형으로 표시된다.

핵심 정리

  • 다중 분류에서는 보통 클래스 수만큼 출력 노드를 만든다.
  • softmax는 클래스 점수를 합이 1인 확률 분포로 변환한다.
  • 정수 레이블에는 sparse_categorical_crossentropy를 사용한다.
  • 원-핫 레이블에는 categorical_crossentropy를 사용한다.
  • np.argmax(prob, axis=1)은 샘플별 예측 클래스를 반환한다.
  • 다중 레이블 문제는 단일 정답 다중 분류와 구별해야 한다.
  • 모델 평가에서는 전체 정확도뿐 아니라 클래스별 정밀도와 재현율을 확인해야 한다.
profile
html_programming_language

0개의 댓글