본문 바로가기
프로그래밍/딥러닝 (완)

다중 레이블 분류 성능 평가 방법 (97)

by 서가_ 2025. 6. 28.
반응형

다중 레이블 분류 성능 평가 방법

들어가며

모델을 훈련했다면 이제 그 성능을 정확히 평가해야 합니다. 하지만 다중 레이블 분류는 일반적인 단일 분류와 달리 여러 개의 레이블이 동시에 예측되기 때문에, 평가 방법도 더욱 복잡하고 다양한 관점에서 접근해야 합니다.
이번 글에서는 편향성 감지 모델의 성능을 종합적으로 평가할 수 있는 다양한 지표들과 그 해석 방법을 자세히 알아보겠습니다.

다중 레이블 분류의 복잡성

단일 분류 vs 다중 레이블 분류

단일 분류의 경우:

  • 하나의 정답 클래스만 존재
  • 정확도(Accuracy) 하나로 성능 요약 가능
  • 혼동 행렬(Confusion Matrix)이 직관적

다중 레이블 분류의 경우:

  • 여러 레이블이 동시에 참일 수 있음
  • 레이블 간 상관관계 고려 필요
  • 부분적으로 맞는 예측의 처리 문제

예시로 이해하는 복잡성

실제 레이블: [1, 0, 1, 0, 0, 0, 0, 0, 0]  # 성별, 인종 편향
예측 레이블: [1, 0, 0, 0, 0, 1, 0, 0, 0]  # 성별, 세대 편향

이 경우 성별 편향은 맞췄지만 인종 편향은 놓치고, 세대 편향은 잘못 예측했습니다. 이를 어떻게 평가할 것인가가 핵심입니다.

기본 성능 지표들

1. 정확도 (Accuracy)

Exact Match Accuracy

모든 레이블을 정확히 맞춘 샘플의 비율:

def exact_match_accuracy(y_true, y_pred):
    """모든 레이블이 정확히 일치하는 비율"""
    return np.mean(np.all(y_true == y_pred, axis=1))

# 예시
y_true = np.array([[1, 0, 1], [0, 1, 0], [1, 1, 0]])
y_pred = np.array([[1, 0, 1], [0, 1, 1], [1, 1, 0]])
print(f"Exact Match Accuracy: {exact_match_accuracy(y_true, y_pred):.3f}")
# 결과: 0.667 (3개 중 2개만 완전히 일치)

Hamming Loss

레이블별 오분류율의 평균:

from sklearn.metrics import hamming_loss

def hamming_accuracy(y_true, y_pred):
    """햄밍 거리 기반 정확도"""
    return 1 - hamming_loss(y_true, y_pred)

print(f"Hamming Accuracy: {hamming_accuracy(y_true, y_pred):.3f}")
# 결과: 0.889 (9개 레이블 중 8개가 맞음)

2. 정밀도, 재현율, F1 스코어

다중 레이블에서는 평균화 방법에 따라 다양한 계산이 가능합니다.

Macro Average (매크로 평균)

각 레이블별로 계산한 후 단순 평균:

from sklearn.metrics import precision_score, recall_score, f1_score

def calculate_macro_metrics(y_true, y_pred):
    """레이블별 지표의 단순 평균"""
    precision = precision_score(y_true, y_pred, average='macro', zero_division=0)
    recall = recall_score(y_true, y_pred, average='macro', zero_division=0)
    f1 = f1_score(y_true, y_pred, average='macro', zero_division=0)

    return {
        'macro_precision': precision,
        'macro_recall': recall,
        'macro_f1': f1
    }

매크로 평균의 특징:

  • 모든 레이블을 동등하게 취급
  • 희귀한 편향 유형도 중요하게 평가
  • 클래스 불균형에 민감

Micro Average (마이크로 평균)

전체 예측을 하나로 합쳐서 계산:

def calculate_micro_metrics(y_true, y_pred):
    """전체 예측의 종합 지표"""
    precision = precision_score(y_true, y_pred, average='micro')
    recall = recall_score(y_true, y_pred, average='micro')
    f1 = f1_score(y_true, y_pred, average='micro')

    return {
        'micro_precision': precision,
        'micro_recall': recall,
        'micro_f1': f1
    }

마이크로 평균의 특징:

  • 빈도가 높은 레이블의 영향이 큼
  • 전체적인 성능을 잘 반영
  • 클래스 불균형에 덜 민감

Weighted Average (가중 평균)

레이블의 빈도에 따라 가중치를 부여:

def calculate_weighted_metrics(y_true, y_pred):
    """빈도 가중 평균 지표"""
    precision = precision_score(y_true, y_pred, average='weighted', zero_division=0)
    recall = recall_score(y_true, y_pred, average='weighted', zero_division=0)
    f1 = f1_score(y_true, y_pred, average='weighted', zero_division=0)

    return {
        'weighted_precision': precision,
        'weighted_recall': recall,
        'weighted_f1': f1
    }

3. ROC AUC와 PR AUC

ROC AUC (Receiver Operating Characteristic)

from sklearn.metrics import roc_auc_score
import matplotlib.pyplot as plt

def calculate_roc_auc(y_true, y_pred_probs):
    """ROC AUC 계산 (확률 점수 필요)"""
    try:
        # 매크로 평균 ROC AUC
        macro_auc = roc_auc_score(y_true, y_pred_probs, average='macro')

        # 레이블별 ROC AUC
        label_aucs = []
        for i in range(y_true.shape[1]):
            if len(np.unique(y_true[:, i])) > 1:  # 양/음성 모두 존재하는 경우만
                auc = roc_auc_score(y_true[:, i], y_pred_probs[:, i])
                label_aucs.append(auc)
            else:
                label_aucs.append(np.nan)

        return macro_auc, label_aucs
    except ValueError as e:
        print(f"ROC AUC 계산 오류: {e}")
        return None, None

PR AUC (Precision-Recall AUC)

클래스 불균형 상황에서 더 적합한 지표:

from sklearn.metrics import average_precision_score

def calculate_pr_auc(y_true, y_pred_probs):
    """PR AUC 계산"""
    macro_pr_auc = average_precision_score(y_true, y_pred_probs, average='macro')

    label_pr_aucs = []
    for i in range(y_true.shape[1]):
        pr_auc = average_precision_score(y_true[:, i], y_pred_probs[:, i])
        label_pr_aucs.append(pr_auc)

    return macro_pr_auc, label_pr_aucs

종합 평가 함수 구현

완전한 평가 메트릭 계산

import torch
import numpy as np
from sklearn.metrics import classification_report

def compute_metrics(pred):
    """Trainer에서 사용할 종합 평가 함수"""
    labels = pred.label_ids
    predictions = pred.predictions

    # 로짓을 확률로 변환
    probs = torch.sigmoid(torch.tensor(predictions))

    # 이진 예측으로 변환 (임계값 0.5)
    pred_labels = (probs > 0.5).int().numpy()

    # 기본 지표들
    exact_match = np.mean(np.all(labels == pred_labels, axis=1))
    hamming_acc = 1 - hamming_loss(labels, pred_labels)

    # 정밀도, 재현율, F1
    macro_precision = precision_score(labels, pred_labels, average='macro', zero_division=0)
    macro_recall = recall_score(labels, pred_labels, average='macro', zero_division=0)
    macro_f1 = f1_score(labels, pred_labels, average='macro', zero_division=0)

    micro_precision = precision_score(labels, pred_labels, average='micro')
    micro_recall = recall_score(labels, pred_labels, average='micro')
    micro_f1 = f1_score(labels, pred_labels, average='micro')

    # ROC AUC
    try:
        macro_auc = roc_auc_score(labels, probs.numpy(), average='macro')
    except ValueError:
        macro_auc = 0.0

    # PR AUC
    macro_pr_auc = average_precision_score(labels, probs.numpy(), average='macro')

    return {
        'exact_match_accuracy': exact_match,
        'hamming_accuracy': hamming_acc,
        'macro_precision': macro_precision,
        'macro_recall': macro_recall,
        'macro_f1': macro_f1,
        'micro_precision': micro_precision,
        'micro_recall': micro_recall,
        'micro_f1': micro_f1,
        'macro_roc_auc': macro_auc,
        'macro_pr_auc': macro_pr_auc
    }

레이블별 상세 분석

혼동 행렬과 분류 리포트

def detailed_evaluation(y_true, y_pred, y_pred_probs, label_names):
    """레이블별 상세 분석"""

    print("=== 전체 성능 요약 ===")
    print(f"Exact Match Accuracy: {np.mean(np.all(y_true == y_pred, axis=1)):.3f}")
    print(f"Hamming Accuracy: {1 - hamming_loss(y_true, y_pred):.3f}")
    print(f"Macro F1: {f1_score(y_true, y_pred, average='macro', zero_division=0):.3f}")
    print(f"Micro F1: {f1_score(y_true, y_pred, average='micro'):.3f}")

    print("\n=== 레이블별 상세 분석 ===")
    report = classification_report(
        y_true, y_pred, 
        target_names=label_names,
        output_dict=True,
        zero_division=0
    )

    for i, label_name in enumerate(label_names):
        if label_name in report:
            metrics = report[label_name]
            print(f"\n{label_name}:")
            print(f"  정밀도: {metrics['precision']:.3f}")
            print(f"  재현율: {metrics['recall']:.3f}")
            print(f"  F1 스코어: {metrics['f1-score']:.3f}")
            print(f"  지원 샘플: {metrics['support']}")

            # ROC AUC (이진 분류가 가능한 경우)
            if len(np.unique(y_true[:, i])) > 1:
                auc = roc_auc_score(y_true[:, i], y_pred_probs[:, i])
                print(f"  ROC AUC: {auc:.3f}")

임계값 최적화

from sklearn.metrics import precision_recall_curve

def optimize_thresholds(y_true, y_pred_probs, label_names):
    """레이블별 최적 임계값 찾기"""
    optimal_thresholds = []

    for i, label_name in enumerate(label_names):
        if len(np.unique(y_true[:, i])) > 1:
            # Precision-Recall 곡선 계산
            precision, recall, thresholds = precision_recall_curve(
                y_true[:, i], y_pred_probs[:, i]
            )

            # F1 스코어가 최대인 임계값 찾기
            f1_scores = 2 * (precision * recall) / (precision + recall + 1e-8)
            optimal_idx = np.argmax(f1_scores)
            optimal_threshold = thresholds[optimal_idx] if optimal_idx < len(thresholds) else 0.5

            optimal_thresholds.append(optimal_threshold)
            print(f"{label_name}: 최적 임계값 = {optimal_threshold:.3f}, F1 = {f1_scores[optimal_idx]:.3f}")
        else:
            optimal_thresholds.append(0.5)
            print(f"{label_name}: 데이터 부족으로 기본값 0.5 사용")

    return optimal_thresholds

시각화를 통한 성능 분석

성능 지표 히트맵

import matplotlib.pyplot as plt
import seaborn as sns

def plot_performance_heatmap(y_true, y_pred_probs, label_names, thresholds=None):
    """레이블별 성능 지표 히트맵"""
    if thresholds is None:
        thresholds = [0.5] * len(label_names)

    metrics_data = []

    for i, (label_name, threshold) in enumerate(zip(label_names, thresholds)):
        pred_binary = (y_pred_probs[:, i] > threshold).astype(int)

        if len(np.unique(y_true[:, i])) > 1:
            precision = precision_score(y_true[:, i], pred_binary, zero_division=0)
            recall = recall_score(y_true[:, i], pred_binary, zero_division=0)
            f1 = f1_score(y_true[:, i], pred_binary, zero_division=0)
            auc = roc_auc_score(y_true[:, i], y_pred_probs[:, i])
        else:
            precision = recall = f1 = auc = 0.0

        metrics_data.append([precision, recall, f1, auc])

    # 히트맵 생성
    metrics_df = pd.DataFrame(
        metrics_data,
        index=label_names,
        columns=['Precision', 'Recall', 'F1', 'ROC AUC']
    )

    plt.figure(figsize=(8, 6))
    sns.heatmap(metrics_df, annot=True, cmap='Blues', fmt='.3f')
    plt.title('레이블별 성능 지표')
    plt.tight_layout()
    plt.show()

    return metrics_df

ROC 곡선과 PR 곡선 시각화

from sklearn.metrics import roc_curve

def plot_roc_pr_curves(y_true, y_pred_probs, label_names):
    """ROC와 PR 곡선 시각화"""
    fig, axes = plt.subplots(2, 2, figsize=(12, 10))

    # ROC 곡선
    ax1 = axes[0, 0]
    for i, label_name in enumerate(label_names[:4]):  # 처음 4개만 표시
        if len(np.unique(y_true[:, i])) > 1:
            fpr, tpr, _ = roc_curve(y_true[:, i], y_pred_probs[:, i])
            auc = roc_auc_score(y_true[:, i], y_pred_probs[:, i])
            ax1.plot(fpr, tpr, label=f'{label_name} (AUC={auc:.3f})')

    ax1.plot([0, 1], [0, 1], 'k--', alpha=0.5)
    ax1.set_xlabel('False Positive Rate')
    ax1.set_ylabel('True Positive Rate')
    ax1.set_title('ROC Curves')
    ax1.legend()

    # PR 곡선
    ax2 = axes[0, 1]
    for i, label_name in enumerate(label_names[:4]):
        if len(np.unique(y_true[:, i])) > 1:
            precision, recall, _ = precision_recall_curve(y_true[:, i], y_pred_probs[:, i])
            pr_auc = average_precision_score(y_true[:, i], y_pred_probs[:, i])
            ax2.plot(recall, precision, label=f'{label_name} (AP={pr_auc:.3f})')

    ax2.set_xlabel('Recall')
    ax2.set_ylabel('Precision')
    ax2.set_title('Precision-Recall Curves')
    ax2.legend()

    plt.tight_layout()
    plt.show()

실제 평가 실행

테스트 데이터 평가

# 훈련된 모델로 테스트 데이터 예측
test_results = trainer.predict(test)
test_predictions = test_results.predictions
test_labels = test_results.label_ids

# 확률로 변환
test_probs = torch.sigmoid(torch.tensor(test_predictions)).numpy()

# 레이블 이름 정의
label_names = ['성별', '정치', '인종', '국가', '지역', '세대', '사회계층', '외모', '기타']

# 기본 임계값(0.5)으로 이진 예측
test_pred_binary = (test_probs > 0.5).astype(int)

# 종합 평가 실행
print("=== 최종 테스트 결과 ===")
detailed_evaluation(test_labels, test_pred_binary, test_probs, label_names)

# 최적 임계값 찾기
print("\n=== 임계값 최적화 ===")
optimal_thresholds = optimize_thresholds(test_labels, test_probs, label_names)

# 최적화된 임계값으로 재평가
optimized_predictions = np.zeros_like(test_pred_binary)
for i, threshold in enumerate(optimal_thresholds):
    optimized_predictions[:, i] = (test_probs[:, i] > threshold).astype(int)

print("\n=== 최적화된 임계값 결과 ===")
detailed_evaluation(test_labels, optimized_predictions, test_probs, label_names)

결과 해석과 개선 방향

성능 지표 해석 가이드

높은 정밀도, 낮은 재현율:

  • 모델이 보수적으로 예측 (확실한 경우만 양성 예측)
  • 거짓 긍정(False Positive)을 줄이고 싶은 경우에 적합
  • 임계값을 낮춰서 재현율 개선 가능

낮은 정밀도, 높은 재현율:

  • 모델이 적극적으로 예측 (의심스러운 경우도 양성 예측)
  • 거짓 부정(False Negative)을 줄이고 싶은 경우에 적합
  • 임계값을 높여서 정밀도 개선 가능

편향 유형별 특성 고려

def analyze_label_characteristics(y_true, label_names):
    """레이블별 특성 분석"""
    print("=== 레이블별 데이터 특성 ===")

    for i, label_name in enumerate(label_names):
        positive_count = np.sum(y_true[:, i])
        total_count = len(y_true)
        positive_ratio = positive_count / total_count

        print(f"{label_name}:")
        print(f"  양성 샘플: {positive_count} / {total_count} ({positive_ratio:.3f})")

        if positive_ratio < 0.01:
            print("  → 매우 희귀한 편향 유형, 데이터 증강 고려")
        elif positive_ratio < 0.05:
            print("  → 희귀한 편향 유형, 가중치 조정 고려")
        elif positive_ratio > 0.3:
            print("  → 빈번한 편향 유형, 임계값 조정 고려")

다음 단계 미리보기

성능 평가가 완료되면 다음과 같은 인사이트를 얻을 수 있습니다:

  • 전체적인 모델 성능: 편향 감지 시스템의 실용성 판단
  • 레이블별 강약점: 어떤 편향 유형을 잘/못 감지하는지 파악
  • 최적 임계값: 실제 서비스에 적용할 예측 기준점
  • 개선 방향: 데이터 보강, 모델 조정 등의 다음 단계

다음 글에서는 이렇게 평가된 모델을 실제 서비스에 배포하고 활용하는 방법을 다뤄보겠습니다.

마무리

다중 레이블 분류의 성능 평가는 단일 지표로는 완전히 설명할 수 없는 복잡한 과정입니다. 다양한 관점에서의 종합적인 평가를 통해 모델의 실제 성능과 한계를 정확히 파악할 수 있으며, 이는 실무 적용에서 매우 중요한 정보가 됩니다.
특히 편향 감지와 같은 민감한 주제에서는 거짓 긍정과 거짓 부정의 비용을 신중히 고려하여 최적의 임계값을 설정하는 것이 핵심입니다.

반응형