70%를 믿어도 될까?
모델이 “70% 확신”이라고 말한다. 그런 예측을 많이 모아 보면 _열에 일곱_이 정말 맞을까? 가끔은. 대개는 아니다. 화면에 찍힌 수와 장기 빈도는 _서로 다른 두 양_이고, 둘을 맞추는 일이
신뢰도는 수다. 진실은 빈도다. 캘리브레이션은 그 사이의 격차다. 보정은 예측한
확률이 약속하는 것
모델이 어떤 클래스에 대해 이라는 수를 내놓을 때, 그 수는 무엇을 뜻해야 할까? 정직한 계약은 장기 빈도다. 모델이 “70%“라고 찍은 예제들을 잔뜩 모았을 때 그중 약 가 실제로 맞아야 한다는 것. 이 계약은
자신 있게 틀리기는 softmax가 왜 확률처럼 보이는 것을 만드는지 보여주었다. 이 페이지의 질문은 그다음이다: 보이는 대로의 의미를 갖고 있는가?
신뢰도 다이어그램
캘리브레이션을 눈으로 보는 표준 방법은
막대가 대각선 _아래_로 처져 있으면 — 모델은 라 말하지만 실제로는 만 맞는다 — 그게 _과신_의 표시다. 반대로 막대가 _위_로 솟아 있으면 모델이 너무 겸손한 것이다. 이라 말해놓고 실제로는 이 맞는다. 각 구간의 세로 간격을 그 구간의 예측 개수로 가중평균하면 하나의 수로 묶인다 — 기대 캘리브레이션 오차, ECE다.
import numpy as np
# Bin predictions; for each bin compute mean predicted prob and observed
# accuracy. The vertical gaps are the calibration error, by bin.
def reliability(probs, labels, n_bins=10):
edges = np.linspace(0, 1, n_bins + 1)
out = []
for lo, hi in zip(edges[:-1], edges[1:]):
mask = (probs >= lo) & (probs < hi if hi < 1 else probs <= hi)
if mask.sum() == 0:
out.append((float((lo + hi) / 2), None, 0))
continue
mean_p = float(probs[mask].mean())
accuracy = float(labels[mask].mean()) # labels ∈ {0, 1}
out.append((mean_p, accuracy, int(mask.sum())))
return out
# Toy: 1000 examples drawn from a known truth(p), with labels sampled
# Bernoulli(truth(p)). The model SAYS p; reality returns truth(p).
rng = np.random.default_rng(0)
probs = rng.uniform(0, 1, size=1000)
truth = lambda p, T=0.55: 1 / (1 + np.exp(-np.log(p / (1 - p)) / T))
labels = rng.binomial(1, truth(probs))
reliability(probs, labels, n_bins=10)
# → [(0.05, 0.18, ...), ..., (0.95, 0.78, ...)]
# At "95% confident", reality returns ~78% — the model is overconfident.실제 모델은 — 예측 가능하게 — 과신한다
이미지 분류기, 언어 모델, 표 데이터 신경망 어디서나 같은 경험적 패턴이 보고된다. 막대는 대각선 _아래_에 깔려 있고, 격차는 신뢰도가 높은 쪽일수록 벌어진다. “아주 확신한다”는 모델은 자기 수가 약속한 것보다 자주 틀리고, “잘 모르겠다”는 모델은 대체로 정직하다. 대각선 위에 겹쳐 그려보면 그 모양은 0.5 쪽으로 살짝 눌린 시그모이드처럼 생겼다. 우연이 아니다. 진짜 사후확률이 모델의 출력 확률을 에 (T < 1로) 통과시킨 결과라면 정확히 이 모양이 나온다.
위젯에서 를 (“과신” 프리셋) 로 옮겨보자. 오른쪽 꼬리의 막대들이 뚝 처진다 — 구간이 근처까지 내려앉는다. ECE는 치솟는다. 로 옮기면 곡선이 대각선 위로 뒤집힌다. _과소확신_이다. 이라고 찍은 구간이 근처에 떨어진다.
# Expected calibration error (ECE): weighted average of bin gaps.
def ece(probs, labels, n_bins=10):
bins = reliability(probs, labels, n_bins)
n = sum(c for _, _, c in bins)
return sum(c * abs(p - a) for p, a, c in bins if a is not None) / n
ece(probs, labels, 10) # ≈ 0.13 (13% calibration gap on average)
# 0 means perfect — every bar lies on the diagonal. ~0.05 is "lab-grade
# calibrated"; modern deep nets often start at 0.10–0.30 out of the box.한 구간에서 선형화 — 국소적 처방
위젯의 막대를 클릭해보자. 그 자리에 뜨는 갈색 선이 구간 중심에서의 캘리브레이션 곡선
이게 왜 중요할까. 어떤 종류의 보정이 필요한지를 곧장 알려주기 때문이다. 기울기가 이면 “여기서 곡선은 대각선과 나란하고 단지 평행이동했을 뿐” — 상수 하나만 더해주면 끝이다. 기울기가 이면 격차 자체가 신뢰도에 따라 바뀐다. 제대로 된 보정은 곡선을 옆으로 미는 게 아니라 대각선 쪽으로 돌려놓아야 한다. 그 회전을 매개변수 하나 — 온도 — 로 전역에서 한 번에 해주는 게 다음 절의 이야기다.
# Local linearization at one bin: y ≈ accuracy(c) + slope·(p - c).
# If slope ≈ 1, the curve is parallel to truth — a constant shift, easy to
# fix. If slope ≠ 1, the gap CHANGES with confidence, which is exactly
# what one scalar (temperature) can rotate away.
def local_slope(p_centers, accuracies, i):
# central difference; falls back to one-sided at the edges.
if i == 0:
return (accuracies[1] - accuracies[0]) / (p_centers[1] - p_centers[0])
if i == len(p_centers) - 1:
return (accuracies[-1] - accuracies[-2]) / (p_centers[-1] - p_centers[-2])
return (accuracies[i+1] - accuracies[i-1]) / (p_centers[i+1] - p_centers[i-1])
# At the bin centered at 0.85, the slope tells you the "local fix":
# slope == 1 means subtract a constant; slope < 1 means stretch toward 0.5.온도 스케일링 — 사후, 단 하나의 스칼라
레시피는 이렇다. 훈련된 모델을 그대로 둔다. 재훈련 없음. 아키텍처 변경 없음. 따로 빼둔 검증 셋의 원시
작동 원리는 단순하다. 로짓을 T > 1로 나누면 softmax가 평평해진다 — 모든 출력 확률이 균등분포 쪽으로 끌려간다. 예제를 잔뜩 모아 보면, 이 효과는 오른쪽 꼬리 (과신 영역) 의 막대를 가운데보다 훨씬 더 많이 끌어내려, 막대를 눌러놓던 시그모이드 휨을 정확히 되돌린다. 흔한 실패 양식에 대해 놀랍도록 값싼 처방이고, 신뢰도 다이어그램이 대각선 아래로 휘어 있다면 가장 먼저 꺼내볼 카드다.
# Temperature scaling: divide every logit by T before softmax.
# argmax is preserved (accuracy unchanged); only confidence is rescaled.
def softmax(z, T=1.0):
s = z / T
s = s - s.max(axis=-1, keepdims=True)
e = np.exp(s)
return e / e.sum(axis=-1, keepdims=True)
# Fit T on a held-out validation set by minimizing log-loss in T.
from scipy.optimize import minimize_scalar
def fit_temperature(logits, y):
def nll(T):
p = softmax(logits, T=T)
# negative log-likelihood of the true class
return -np.log(p[np.arange(len(y)), y] + 1e-12).mean()
res = minimize_scalar(nll, bounds=(0.05, 10.0), method="bounded")
return float(res.x)
# T > 1 → softer; T < 1 → sharper. Modern LLMs ship with T ≈ 1.5–3 to
# tame overconfidence in the high-probability tail.온도 스케일링은 미스캘리브레이션이 입력 공간 어디서나 같은 모양이라고 — 그래서 전역 회전 한 번으로 고칠 수 있다고 — 가정한다. 그런데 실제 모델은 입력의 갈래마다 다르게 어긋난다. 쉬운 예제는 자신만만하게 맞고, 어려운 예제는 자신만만하게 틀리고, 분포 바깥 입력에서는 터무니없이 자신만만해진다. 하나로는 그 격차들을 평균내는 데 그치고, 자칫하면 양쪽 영역 모두 손대지 않은 쪽보다 못하게 만들 수도 있다. 정직한 신호는 이렇다. 검증 셋의 ECE는 떨어지는데, 따로 빼둔 신뢰도 다이어그램의 오른쪽 꼬리는 여전히 휘어 있다. 이때 필요한 건 더 큰 T가 아니다. “이 입력이 내가 본 것들 근처인가?”를 따로 묻는 모델 — 선택적 예측, 컨포멀 집합, 밀도 추정기 — 이고, 이건 softmax 바깥에 사는 별개의 장치다.
신뢰도는 수다. 진실은 빈도다. 캘리브레이션은 그 사이의 격차다. 신뢰도 다이어그램이 격차를 눈에 띄게 만들고, 한 구간의 접선이 국소적 처방을 알려주며, 스칼라 하나 — 온도 — 가 곡선 전체를 대각선 쪽으로 돌려놓는다.
위젯에서 (과신) 로 두자. 중심 구간을 클릭하고 막대 높이를 읽어보자. 모델이 “85% 확신”이라고 찍은 예측 100개 중 실제로 맞은 건 대략 몇 개일까? 같은 작업을 구간에서도 해보자. 모델은 어떤 종류의 입력 앞에서는 여전히 정직한가?
친구가 신뢰도 다이어그램을 보고는 오른쪽 절반의 막대가 모두 대각선 _아래_에 있는 걸 가리키며 “좋다 — 모델이 겸손하네”라고 말한다. _겸손한_과 _과신하는_을 한 문장으로 구분해 답해보자. “대각선 아래”는 어느 쪽에 해당하는가?
과신 상태 () 에서 구간을 클릭하면 위젯이 국소 기울기를 알려준다. 그 기울기가 , 막대 높이가 () 라 하자. 국소 선형 근사를 꼴로 써보자 — 만 계산하면 된다. 그 식으로 에서의 캘리브레이션 오차를 예측해보자.
자신 있게 틀리기에서 다뤘듯, 교차 엔트로피는 이다. 이 손실을 최소화하도록 훈련된 모델은 왜 훈련 셋에서 _과신_할 유인이 생기는지, 그 유인이 왜 따로 빼둔 데이터의 좋은 캘리브레이션으로는 이어지지 않는지를 — 수식 없이 — 말로 스케치해보자.
ML 강의는 softmax, 교차 엔트로피, 정확도까지는 가르친다. 캘리브레이션은 거의 가르치지 않는다. 한쪽에는 문화의 문제 — 리더보드가 추적하는 건 정확도다 — 가 있고, 다른 한쪽에는 역사의 문제가 있다. 고전 학습 이론은 결정 경계 (argmax가 정한다) 에는 공을 들였지만, 모델이 그 과정에서 내놓는 확률에는 관심이 적었다. 그러나 “70% 확신”이라고 말하는 모든 출시 모델은 암묵적으로 캘리브레이션 주장을 하고 있는 셈이다. Lemma가 softmax 옆에 신뢰도 다이어그램을 나란히 두는 이유가 그것이다. 독자는 확률처럼 보인다와 빈도와 맞아떨어진다 사이의 격차를 — 따로 빼둔 정답에 비춘 별개의 측정으로, 교차 엔트로피 훈련을 아무리 더 해도 대신할 수 없는 무언가로 — 눈으로 볼 수 있어야 한다.