다중 분류 보고서에서 정확도는 0.770인데 macro F1은 0.473이라면 계산이 틀린 것일까? 같은 예측 결과라도 사례를 한꺼번에 세는지, 클래스마다 같은 비중을 주는지, 실제 클래스 건수로 가중하는지에 따라 값이 달라진다. micro·macro·weighted F1을 비교할 때는 먼저 각 지표가 무엇에 같은 비중을 주는지 확인해야 한다.
아래 문서 100건과 분류 결과는 모두 설명용 합성 자료다. 실제 모델이나 사이트의 성능을 측정하지 않았다. 문서마다 정답과 예측이 하나씩 있고, 둘 다 A·B·C 중 하나다. 보류·누락 없이 전체 클래스를 포함한 단일 라벨 다중 분류를 가정한다.
행은 정답, 열은 예측으로 고정한다
A는 일반 안내, B는 계약 문서, C는 긴급 검토 문서라고 가정하자. 클래스 이름은 설명을 위한 것이며 실제 업무의 중요도나 오류 비용을 수치화한 것은 아니다.
| 정답 | 예측 A | 예측 B | 예측 C | 정답 건수 support |
|---|---|---|---|---|
| A | 72 | 7 | 1 | 80 |
| B | 10 | 4 | 1 | 15 |
| C | 3 | 1 | 1 | 5 |
| 예측 합계 | 85 | 12 | 3 | 100 |
정확도는 대각선의 합을 전체 건수로 나눈 (72 + 4 + 1) / 100 = 0.770000이다. 클래스별 지표는 해당 클래스 하나를 양성, 나머지를 음성으로 두고 계산한다. 예를 들어 C는 TP=1, FP=2, FN=4다. 예측 C 세 건 중 한 건이 맞고, 실제 C 다섯 건 중 한 건을 찾았다. TP·FP·FN의 기본 분모는 혼동행렬과 precision·recall 계산에서 확인할 수 있다.
클래스별 F1을 먼저 계산한다
정밀도 P=TP/(TP+FP), 재현율 R=TP/(TP+FN)이다. F1은 두 값의 조화평균이며, 건수로 쓰면 F1=2TP/(2TP+FP+FN)이다. 정밀도나 재현율 하나만 높아서는 F1도 높아지지 않는다. 아래 값은 반올림 전 건수에서 계산한 뒤 소수 여섯 자리로 표시했다.
| 클래스 | TP | FP | FN | precision | recall | F1 |
|---|---|---|---|---|---|---|
| A | 72 | 13 | 8 | 0.847059 | 0.900000 | 0.872727 |
| B | 4 | 8 | 11 | 0.333333 | 0.266667 | 0.296296 |
| C | 1 | 2 | 4 | 0.333333 | 0.200000 | 0.250000 |
평균 방식의 정의는 scikit-learn의 f1_score 공식 문서와 맞췄다. 아래 검산은 패키지를 호출한 결과가 아니라 같은 정의를 작은 표에 직접 적용한 것이다.
micro·macro·weighted는 집계 순서가 다르다
| 지표 | 계산 | 결과 |
|---|---|---|
| micro F1 | 전체 TP=77, FP=23, FN=23을 합쳐 154/(154+23+23) | 0.770000 |
| macro F1 | 클래스별 F1 세 값의 단순 산술평균 | 0.473008 |
| weighted F1 | (80×F1 A + 15×F1 B + 5×F1 C)/100 | 0.755126 |
| accuracy | 정답과 일치한 77건/전체 100건 | 0.770000 |
micro는 클래스별 TP·FP·FN을 먼저 합친다. 이 예의 오분류 한 건은 정답 클래스의 FN 한 건과 예측 클래스의 FP 한 건을 동시에 만든다. 전체 클래스를 포함한 단일 라벨 조건에서는 FP 합과 FN 합이 모두 오분류 건수라 micro F1이 정확도와 같아진다. 다중 라벨이나 일부 클래스만 고른 평가에 그대로 적용할 수 있는 등식은 아니다.
macro는 클래스 하나에 같은 비중을 준다. A가 80건이어도 C의 F1을 A와 같은 한 항으로 평균내므로, 드문 클래스에서 낮은 성능이 전체 건수에 묻히는 것을 드러낼 수 있다. 다만 C의 실제 정답은 다섯 건뿐이다. macro가 낮다는 사실만으로 장래 성능을 정밀하게 추정하거나 오류 비용까지 판단할 수는 없다.
weighted는 정답 클래스의 support로 클래스별 F1을 가중한다. 표에서 0.755126으로 정확도 0.770000과 다르다. support 가중 재현율과 F1을 혼동하면 안 된다. 또 macro F1은 평균 precision과 평균 recall로 다시 만든 F1이 아니다. 이 표에서 후자의 계산은 0.478814로 macro F1 0.473008과 다르다. 이 집계 차이와 micro의 조건은 공식 평가 가이드에도 명시돼 있다.
본문 코드로 값과 빈 클래스 정책을 검산한다
다음 코드는 Python 표준 라이브러리만 쓴다. Windows의 Python 3.14.3에서 본문 HTML의 코드 텍스트를 추출해 실행했다. scikit-learn 실행·실제 모델 평가·통계적 불확실성 추정은 하지 않았다. 여기서는 분모가 0인 클래스 지표를 0으로 정하고, 전체 평가 건수가 0이면 집계 지표를 None으로 둔다.
from fractions import Fraction
import json
def score(matrix, labels):
size = len(labels)
if not size or len(set(labels)) != size:
raise ValueError("unique nonempty labels required")
if len(matrix) != size or any(len(row) != size for row in matrix):
raise ValueError("square matrix required")
if any(type(v) is not int or v < 0 for row in matrix for v in row):
raise ValueError("nonnegative integer counts required")
def divide(a, b):
return Fraction(a, b) if b else Fraction(0)
total = sum(map(sum, matrix))
rows = []
for i, label in enumerate(labels):
support = sum(matrix[i])
predicted = sum(row[i] for row in matrix)
tp = matrix[i][i]
rows.append(dict(label=label, support=support, tp=tp,
fp=predicted-tp, fn=support-tp,
precision=divide(tp, predicted),
recall=divide(tp, support),
f1=divide(2*tp, support+predicted)))
tp = sum(row["tp"] for row in rows)
fp = sum(row["fp"] for row in rows)
fn = sum(row["fn"] for row in rows)
summary = dict(micro=None, macro=None, weighted=None, accuracy=None)
if total:
summary = dict(micro=divide(2*tp, 2*tp+fp+fn),
macro=sum(row["f1"] for row in rows)/size,
weighted=sum(row["support"]*row["f1"] for row in rows)/total,
accuracy=divide(tp, total))
return rows, summary
def shown(values):
return {key: None if value is None else round(float(value), 6)
for key, value in values.items()}
matrix = [[72, 7, 1], [10, 4, 1], [3, 1, 1]]
rows, summary = score(matrix, ["A", "B", "C"])
for row in rows:
print(row["label"], shown({key: row[key] for key in
["support", "tp", "fp", "fn", "precision", "recall", "f1"]}))
print("summary", json.dumps(shown(summary)))
mean_p = sum(row["precision"] for row in rows)/3
mean_r = sum(row["recall"] for row in rows)/3
print("F1_of_mean_P_R", round(float(2*mean_p*mean_r/(mean_p+mean_r)), 6))
extended = [row+[0] for row in matrix] + [[0, 0, 0, 0]]
print("with_absent_D", json.dumps(shown(score(extended, ["A", "B", "C", "D"])[1])))
print("empty", json.dumps(shown(score([[0]], ["A"])[1])))
주요 출력은 다음과 같다. 위 클래스별 출력도 앞의 표와 일치한다.
summary {"micro": 0.77, "macro": 0.473008, "weighted": 0.755126, "accuracy": 0.77}
F1_of_mean_P_R 0.478814
with_absent_D {"micro": 0.77, "macro": 0.354756, "weighted": 0.755126, "accuracy": 0.77}
empty {"micro": null, "macro": null, "weighted": null, "accuracy": null}
D는 정답에도 예측에도 없는 클래스다. 이를 평가 label 집합에 넣고 F1=0으로 처리하면 macro의 분모가 3에서 4로 바뀌어 0.354756이 된다. 반면 앞 표의 C는 정답 5건·예측 3건·TP 1건으로, recall은 0.2이고 F1은 0.25다. 이 C와 정답·예측 모두 없는 D를 구별해야 한다. 별도 가정으로 정답이 한 건 이상 있지만 해당 클래스로 예측한 건수가 0이라면, 그 클래스의 recall과 F1은 0이다. D의 F1은 원래 정의되지 않는 0/0이며 여기서는 공개한 정책으로 0을 넣었다. 공식 함수는 zero_division 설정에 따라 0·1·NaN 등의 처리가 가능하므로, 서로 다른 설정의 평균을 같은 점수처럼 비교하지 않는다.
지표 하나를 고르기 전에 고정할 것
비교할 클래스 목록과 순서, 정답 판정 기준, 누락·보류 처리, 0분모 정책을 평가 전에 고정한다. 그런 다음 클래스별 TP·FP·FN과 support를 함께 보고, 업무 질문에 맞는 집계를 선택한다. 드문 중요 클래스의 실패는 weighted 점수만으로 숨기지 않는다. 오류 비용이 다르면 그 비용을 별도 기준으로 평가해야 한다. 프롬프트 판본 비교의 입력·판정 기준을 고정하는 과정은 프롬프트 회귀 평가와 연결된다.
자주 묻는 질문
macro F1이 더 낮으면 모델이 나빠진 건가요?
같은 결과의 micro와 macro를 비교한 것만으로 성능 변화라고 할 수 없다. 두 지표의 가중 방식이 다르다. 판본의 변화를 보려면 같은 클래스 집합·입력·정책에서 같은 지표끼리 비교하고 클래스별 변화도 확인한다.
희소 클래스가 중요하면 weighted F1이면 충분한가요?
희소 클래스는 support가 작아서 weighted 평균에 작게 반영된다. 그 클래스의 recall·F1·실패 건수와 업무상 피해를 별도로 보고, 필요한 최소 기준을 정하는 편이 명확하다.
micro F1과 정확도는 언제나 같은가요?
이 글처럼 전체 클래스를 포함하고 각 사례의 정답과 예측이 하나인 다중 분류에서는 같다. 다중 라벨, 클래스 일부만 집계한 경우, 평가 대상 제외 정책이 다른 경우에는 그 등식을 그대로 쓰지 않는다.