회원 상태가 '일반/휴면/탈퇴' 중 하나이거나 고객 문의 유형이 여러 가지로 나뉜다면 다중 분류(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)를 확률 형태로 변환한다.
각 확률은 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()를 사용할 때 축을 빠뜨리면 전체 배열에서 하나의 값만 뽑는 실수를 할 수 있다.
| 정답 표현 | 예시(네 클래스 중 세 번째) | 손실 함수 |
|---|---|---|
| 정수 레이블 | 2 | sparse_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에는 입력 특성 스케일러를 적용하지 않는다. 정수 레이블은 클래스 식별자이지 연속된 수치가 아니기 때문이다.
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)은 샘플별 예측 클래스를 반환한다.