본문 바로가기
  • 데이터에 가치를 더하다, 서영석입니다.
공부하는 습관을 들이자/Python_ML

멀티클래스 vs 멀티레이블 분류

by 꿀먹은데이터 2025. 8. 25.

분류 문제에 있어 Multi-Class와 Multi-Label의 헷갈리는 점이 종종 발생해 정리하려고 한다. 

 

요약

  • 멀티클래스: 샘플당 정답 1개 (예: 고양이/개/새).
  • 멀티레이블: 샘플당 정답 여러 개 가능 (예: 감정 joy+trust).
  • 핵심 차이는 출력층, 활성화 함수, 손실함수, 평가 지표, 임계치(Threshold) 운용에 있다.
  • 실무에서는 데이터 라벨 표현, 클래스 불균형, 임계치 튜닝, 캘리브레이션, 리콜/정밀 균형이 성패를 가른다.

1) 문제 정의와 차이 한눈에 보기

항목 멀티클래스 (Multi-Class) 멀티레이블 (Multi-Label)

라벨 수 1개 고정 0~N개 가변
출력층 크기 = 클래스 수 크기 = 라벨 수
활성화 softmax sigmoid
손실함수 CrossEntropyLoss BCEWithLogitsLoss
예측 argmax 1개 각 라벨별 점수>임계치
지표 Acc, Macro/Micro F1 등 Micro/Macro/Weighted F1, mAP, Subset Acc 등

2) 데이터 표현 (y의 형태)

멀티클래스

# 예: 3클래스 (0,1,2)
y = [2, 0, 1, 1, 2]  # (N, ) 정수 인덱스

멀티레이블

# 예: 5라벨 (A,B,C,D,E)
# 각 샘플은 라벨 벡터로 표현 (원-핫 다중)
Y = [
  [1,0,1,0,0],  # A+C
  [0,1,0,0,1],  # B+E
  [0,0,0,1,0],  # D
]
# shape: (N, L)

팁: 텍스트 분류라면 sklearn.preprocessing.MultiLabelBinarizer 혹은 직접 사전(mapping)으로 변환.


3) 모델링 차이 (출력/활성/손실)

멀티클래스: Softmax + CrossEntropy

import torch
import torch.nn as nn

class HeadMultiClass(nn.Module):
    def __init__(self, hidden_size: int, num_classes: int):
        super().__init__()
        self.fc = nn.Linear(hidden_size, num_classes)
    def forward(self, h):  # h: (batch, hidden)
        logits = self.fc(h)            # (batch, C)
        # train: CrossEntropyLoss(logits, target)
        # infer: probs = softmax(logits, dim=-1); pred = argmax
        return logits

멀티레이블: Sigmoid + BCEWithLogits

class HeadMultiLabel(nn.Module):
    def __init__(self, hidden_size: int, num_labels: int):
        super().__init__()
        self.fc = nn.Linear(hidden_size, num_labels)
    def forward(self, h):
        logits = self.fc(h)            # (batch, L)
        # train: BCEWithLogitsLoss(logits, targets)
        # infer: probs = sigmoid(logits); pred = probs > thresholds
        return logits

BCEWithLogitsLoss는 내부에 시그모이드를 포함하므로, 학습 시 시그모이드를 따로 적용하지 않는다.

 


4) 평가 지표: 언제 무엇을 쓸까

멀티클래스

  • Accuracy: 직관적이지만 클래스 불균형에 취약.
  • Macro F1: 클래스별 F1 평균(동일 가중). 소수 클래스까지 균형 있게.
  • Weighted F1: 샘플 수로 가중.

멀티레이블

  • Micro F1: 전체 TP/FP/FN 집계 후 계산 → 불균형에 상대적으로 강함.
  • Macro F1: 라벨별 F1의 평균 → 소수 라벨 민감.
  • Subset Accuracy: 모든 라벨을 정확히 맞춘 비율 → 지나치게 보수적(희귀).
  • mAP (mean Average Precision), PR-AUC: 랭킹 기반, 임계치 독립.
from sklearn.metrics import f1_score, accuracy_score, average_precision_score
import numpy as np

# y_true, y_pred in {0,1}; y_score in [0,1]
macro_f1 = f1_score(y_true, y_pred, average='macro')
micro_f1 = f1_score(y_true, y_pred, average='micro')
subset_acc = (y_true == y_pred).all(axis=1).mean()
map_score = average_precision_score(y_true, y_score, average='macro')

5) 임계치(Threshold)

멀티레이블은 라벨별 임계치가 핵심. 기본 0.5로 두면 재현율이 과소/과대가 되기 쉽다.

간단한 라벨별 임계치 튜닝 (DEV 기준)

import numpy as np
from sklearn.metrics import f1_score

def search_best_thresholds(y_true, y_score, grid=None):
    L = y_true.shape[1]
    grid = grid or np.linspace(0.1, 0.9, 17)  # 0.1~0.9 by 0.05
    best = np.zeros(L)
    for l in range(L):
        f1_best, th_best = -1, 0.5
        for th in grid:
            pred_l = (y_score[:, l] >= th).astype(int)
            f1_l = f1_score(y_true[:, l], pred_l, zero_division=0)
            if f1_l > f1_best:
                f1_best, th_best = f1_l, th
        best[l] = th_best
    return best

# dev에서 thresholds 찾고, test에 적용
# th = search_best_thresholds(Y_dev, S_dev)
# Y_pred = (S_test >= th).astype(int)

6) 클래스 불균형 다루기

  • 입력단: 샘플링 (언더/오버/SMOTE)
  • 손실단: 가중치(class_weight, pos_weight) / Focal Loss
  • 출력단: 라벨별 임계치 조절로 리콜/정밀 균형
import torch

def compute_pos_weight(Y):
    # Y: (N, L) in {0,1}
    N, L = Y.shape
    pos = Y.sum(0)
    neg = N - pos
    # pos_weight = neg/pos, 0 division 방지
    pw = (neg / (pos + 1e-6)).clip(max=100)
    return torch.tensor(pw, dtype=torch.float32)

# loss = BCEWithLogitsLoss(pos_weight=pos_weight)

멀티라벨에서 라벨 간 상관 구조가 크면, 샘플링 전략을 라벨 공존(co-occurrence)을 깨지 않도록 신중히 설계.


7) 베이스라인

멀티클래스: 로지스틱 회귀

from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer

clf = Pipeline([
    ("tfidf", TfidfVectorizer(max_features=50_000, ngram_range=(1,2))),
    ("lr", LogisticRegression(max_iter=200, n_jobs=-1))
])
clf.fit(texts_train, y_train)  # y_train: (N,)

멀티레이블: One-vs-Rest

from sklearn.multiclass import OneVsRestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import MultiLabelBinarizer

mlb = MultiLabelBinarizer()
y_train_bin = mlb.fit_transform(y_train_multi)  # list[set/labels]

ovr = OneVsRestClassifier(LogisticRegression(max_iter=200, n_jobs=-1))
ovr.fit(texts_train_vec, y_train_bin)
# 예측 점수: ovr.decision_function(X)

8) PyTorch 미니멀 레퍼런스 (텍스트 임베딩 이후 헤드만 예시)

import torch, torch.nn as nn
from torch.utils.data import Dataset, DataLoader

class SimpleTextDataset(Dataset):
    def __init__(self, X_embed, Y):
        self.X = X_embed  # (N, H)
        self.Y = Y        # (N, ) or (N, L)
    def __len__(self): return len(self.X)
    def __getitem__(self, i): return self.X[i], self.Y[i]

class MultiLabelNet(nn.Module):
    def __init__(self, H, L):
        super().__init__()
        self.fc = nn.Linear(H, L)
    def forward(self, x):
        return self.fc(x)

H, L = 768, 8
model = MultiLabelNet(H, L)
opt = torch.optim.AdamW(model.parameters(), lr=2e-4)
crit = nn.BCEWithLogitsLoss()

for X, Y in DataLoader(SimpleTextDataset(X_train, Y_train), batch_size=64, shuffle=True):
    logits = model(X)
    loss = crit(logits, Y)
    loss.backward(); opt.step(); opt.zero_grad()

9) 실전 스니펫: 한국어 감정 8라벨(멀티레이블)

라벨: joy, anticipation, trust, surprise, disgust, fear, anger, sadness

(1) 임베딩 모델 + 헤드

import torch
import torch.nn as nn
from transformers import AutoModel, AutoTokenizer

class Emotion8(nn.Module):
    def __init__(self, plm="klue/roberta-base", num_labels=8):
        super().__init__()
        self.backbone = AutoModel.from_pretrained(plm)
        hidden = self.backbone.config.hidden_size
        self.head = nn.Linear(hidden, num_labels)

    def forward(self, input_ids, attention_mask=None):
        out = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
        h = out.last_hidden_state[:, 0]  # [CLS] 또는 pooler 적절히 선택
        logits = self.head(h)
        return logits

(2) 학습 루프 + 라벨별 임계치 저장

from sklearn.metrics import f1_score
import numpy as np

crit = nn.BCEWithLogitsLoss()

@torch.no_grad()
def eval_dev(model, dev_loader):
    model.eval()
    scores, truths = [], []
    for batch in dev_loader:
        logits = model(batch["input_ids"].to(device), batch["attention_mask"].to(device))
        probs = torch.sigmoid(logits).cpu().numpy()
        scores.append(probs)
        truths.append(batch["labels"].numpy())
    S = np.vstack(scores); Y = np.vstack(truths)
    th = search_best_thresholds(Y, S)
    P = (S >= th).astype(int)
    return {
        "micro_f1": f1_score(Y, P, average="micro"),
        "macro_f1": f1_score(Y, P, average="macro"),
        "thresholds": th,
    }

마무리

멀티클래스와 멀티레이블은 닮았지만, 임계치/지표/불균형에서 실전 난이도가 크게 갈린다. 본문 스니펫을 뼈대로 잡고, Dev 기반으로 라벨별 튜닝을 체계화하면 운영 품질이 안정되는 것같다.

 

다만,, 현재진행형인 AI 과제에서 Macro F1 score가 잘 나오지 않는 것이 딜레마다.

반응형

'공부하는 습관을 들이자 > Python_ML' 카테고리의 다른 글

[2-4]타이타닉 생존자 예측  (0) 2022.01.05
[#3]데이터 전처리  (0) 2022.01.01
[2. (2) 교차 검증]  (0) 2021.12.30
[2. (1) 붓꽃 품종 예측하기]  (0) 2021.12.29
부스팅 알고리즘  (0) 2021.12.29