1) Зачем нужен “отказ от ответа”?
В табличной классификации ошибка часто стоит дороже, чем “не знаю”. Поэтому вместо “модель всегда отвечает” полезнее режим selective classification (abstention): модель отвечает только когда уверена, а сомнительные случаи отправляет в ручную проверку / второй контур.
Например:
-
Антифрод (транзакции) :
Ошибка → пропустили мошенника (прямой убыток).
Отказ → транзакция уходит на дополнительную проверку (потеря UX/времени, но контролируемо). -
Кредитный скоринг (одобрить/отклонить):
Ошибка → одобрили “плохого” клиента (риск дефолта).
Отказ → запросили дополнительные документы / ручной андеррайтинг. -
Медицина (диагностика):
Ошибка → неправильное лечение, риски.
Отказ → Направили к специалисту, доп. обследования
Во всех трёх примерах система выигрывает, если “сложные” объекты можно отсеять и не принимать автоматическое решение вслепую.
Что именно мы оптимизируем?
На практике важен компромисс:
-
coverage – на какой доле случаев модель вообще отвечает
-
risk – какая ошибка на тех случаях, где она отвечает
То есть вопрос не “какая accuracy в среднем?”, а:
если мы согласны на X% отказов, какой станет ошибка на оставшихся (100−X)%?
Это удобно показывать risk–coverage кривой и таблицей “coverage → ошибка”.
2) Формализация: что считаем и как сравниваем
2.1. “Модель отвечает не всегда”
Есть классификатор, который выдаёт вероятности классов p(x) и метку
Добавляем правило допуска (селектор)
-
g(x)=1 – модель отвечает
-
g(x)=0 – отказ
Практически g(x) строится по “скорy уверенности” s(x) и порогу:
2.2. Coverage
Доля объектов, на которых модель ответила:
Интерпретация: “сколько кейсов обработали автоматически”.
2.3. Selective risk (ошибка на отвеченных)
Считаем ошибку только на тех объектах, где g(x)=1:
Интерпретация: “насколько мы ошибаемся там, где решаем автоматически”.
2.4. Risk–Coverage (RC) кривая
Меняем порог → получаем разные пары (coverage,risk).
График “risk от coverage” показывает, как быстро падает ошибка при увеличении отказов.
-
точка справа: coverage≈1 (почти без отказов)
-
чем левее: больше отказов → обычно меньше risk
3) Эксперимент: данные, сплиты, модель
3.1. Данные: letter
На простых датасетах многие “уверенности” ранжируют объекты почти одинаково, и RC-кривые слипаются. Поэтому дальше используем letter
Код: загрузка + подготовка
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_openml
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from catboost import CatBoostClassifier
SEED = 1744
np.random.seed(SEED)
ds = fetch_openml(name="letter", as_frame=True)
X = ds.data
y_raw = ds.target
le = LabelEncoder()
y = le.fit_transform(y_raw.astype(str))
n_classes = len(np.unique(y))
cat_cols = [c for c in X.columns if str(X[c].dtype) in ("object", "category", "bool")]
if len(cat_cols) > 0:
X[cat_cols] = X[cat_cols].astype("object").fillna("__MISSING__").replace("?", "__MISSING__").astype(str)
print("X:", X.shape, "classes:", n_classes, "cat_cols:", len(cat_cols))
3.2. Честные сплиты: train / calibration / test (+ valid внутри train)
-
train– учим модель -
valid– только для early stopping -
calibration– только для порогов/квантили (и conformal) -
test– финальная оценка
# 60 / 20 / 20
X_train, X_tmp, y_train, y_tmp = train_test_split(
X, y, test_size=0.4, random_state=SEED, stratify=y
)
X_cal, X_test, y_cal, y_test = train_test_split(
X_tmp, y_tmp, test_size=0.5, random_state=SEED, stratify=y_tmp
)
# valid внутри train (например 15%)
X_fit, X_valid, y_fit, y_valid = train_test_split(
X_train, y_train, test_size=0.15, random_state=SEED, stratify=y_train
)
print("fit:", X_fit.shape, "valid:", X_valid.shape, "cal:", X_cal.shape, "test:", X_test.shape)
3.3. Одна модель CatBoost — для всех правил отказа
loss = "MultiClass"
model = CatBoostClassifier(
loss_function=loss,
iterations=2000,
learning_rate=0.05,
depth=8,
random_seed=SEED,
verbose=200,
od_type="Iter",
od_wait=200
)
model.fit(
X_fit, y_fit,
cat_features=cat_cols,
eval_set=(X_valid, y_valid),
use_best_model=True
)
3.4. Вероятности — “сырьё” для всех методов
P_cal = model.predict_proba(X_cal)
P_test = model.predict_proba(X_test)
yhat_test = P_test.argmax(axis=1)
baseline_acc = (yhat_test == y_test).mean()
print("Baseline accuracy (coverage=1.0):", baseline_acc)
4) Три подхода отказа (единый протокол сравнения)
Все методы ниже строят правило допуска g(x) (отвечаем/отказываемся) через score уверенности и порог, откалиброванный на calibration
4.1. Порог по max probability
Идея: отвечать, когда top-1 вероятность достаточно большая.
4.2. Порог по entropy
Энтропия — мера “размазанности” распределения. Чем меньше энтропия, тем модель увереннее.
eps = 1e-12
H_cal = -(P_cal * np.log(P_cal + eps)).sum(axis=1)
H_test = -(P_test * np.log(P_test + eps)).sum(axis=1)
4.3. Порог по margin (top-1 − top-2)
Margin ловит ситуацию “модель сомневается между двумя классами”.
part_cal = np.partition(-P_cal, 1, axis=1)
smar_cal = (-part_cal[:, 0]) - (-part_cal[:, 1])
part_test = np.partition(-P_test, 1, axis=1)
smar_test = (-part_test[:, 0]) - (-part_test[:, 1])
4.4. Conformal APS sets (singleton-only)
Conformal APS вместо одной метки строит набор допустимых классов C(x). Для отказа используется жёсткое правило: отвечать только если набор сузился до одного класса.
APS-score на calibration. Для каждого объекта берём классы по убыванию вероятности и смотрим, сколько суммарной массы нужно набрать, чтобы “дойти” до истинного класса.
где r(x,y) – ранг истинного класса y в сортировке вероятностей по убыванию.
Дальше берётся split-conformal квантиль :
И строится размер набора на test:
order = np.argsort(-P_cal, axis=1)
P_sorted = np.take_along_axis(P_cal, order, axis=1)
cumsum = np.cumsum(P_sorted, axis=1)
pos = np.array([np.where(order[i] == y_cal[i])[0][0] for i in range(len(y_cal))])
aps_scores = cumsum[np.arange(len(y_cal)), pos]
aps_sorted = np.sort(aps_scores)
order_t = np.argsort(-P_test, axis=1)
P_sorted_t = np.take_along_axis(P_test, order_t, axis=1)
cumsum_t = np.cumsum(P_sorted_t, axis=1)
y_pred_top1 = order_t[:, 0]
5) Оценка: таблица “coverage → risk” и RC-кривые
Здесь используются одни и те же метрики: coverage и selective risk (ошибка на принятых). Пороги/уровни строгости выбираются по calibration, качество измеряется по test.
5.1. Таблица “coverage → risk” для maxprob / entropy / margin
Для maxprob и margin принимаются верхние по score объекты; порог берётся как квантиль на calibration:
Для энтропии принимаются объекты с малой энтропией, поэтому порог другой:
import pandas as pd
coverages = [1.0, 0.995, 0.99, 0.98, 0.97, 0.95, 0.90]
rows = []
def add(method, target_c, accept):
cov = accept.mean()
risk = (yhat_test[accept] != y_test[accept]).mean()
rows.append([method, target_c, float(cov), float(1-cov), float(risk), float(1-risk)])
# maxprob
for c in coverages:
tau = np.quantile(smax_cal, 1.0 - c)
add("maxprob", c, smax_test >= tau)
for c in coverages:
tau = np.quantile(H_cal, c)
add("entropy", c, H_test <= tau)
# margin
for c in coverages:
tau = np.quantile(smar_cal, 1.0 - c)
add("margin", c, smar_test >= tau)
table = pd.DataFrame(rows, columns=["method","target_cov","actual_cov","reject_rate","risk","acc_on_accepted"])
table
|
|
method |
target_cov |
actual_cov |
reject_rate |
risk |
acc_on_accepted |
|---|---|---|---|---|---|---|
|
0 |
maxprob |
1.000 |
0.99975 |
0.00025 |
0.042261 |
0.957739 |
|
1 |
maxprob |
0.995 |
0.99475 |
0.00525 |
0.038452 |
0.961548 |
|
2 |
maxprob |
0.990 |
0.99050 |
0.00950 |
0.036093 |
0.963907 |
|
3 |
maxprob |
0.980 |
0.97775 |
0.02225 |
0.030427 |
0.969573 |
|
4 |
maxprob |
0.970 |
0.96200 |
0.03800 |
0.024428 |
0.975572 |
|
5 |
maxprob |
0.950 |
0.94225 |
0.05775 |
0.017246 |
0.982754 |
|
6 |
maxprob |
0.900 |
0.88375 |
0.11625 |
0.008204 |
0.991796 |
|
7 |
entropy |
1.000 |
0.99975 |
0.00025 |
0.042511 |
0.957489 |
|
8 |
entropy |
0.995 |
0.99475 |
0.00525 |
0.039206 |
0.960794 |
|
9 |
entropy |
0.990 |
0.99075 |
0.00925 |
0.037598 |
0.962402 |
|
10 |
entropy |
0.980 |
0.98075 |
0.01925 |
0.032118 |
0.967882 |
|
11 |
entropy |
0.970 |
0.96325 |
0.03675 |
0.026473 |
0.973527 |
|
12 |
entropy |
0.950 |
0.93825 |
0.06175 |
0.019185 |
0.980815 |
|
13 |
entropy |
0.900 |
0.88350 |
0.11650 |
0.008772 |
0.991228 |
|
14 |
margin |
1.000 |
1.00000 |
0.00000 |
0.042500 |
0.957500 |
|
15 |
margin |
0.995 |
0.99475 |
0.00525 |
0.039457 |
0.960543 |
|
16 |
margin |
0.990 |
0.99075 |
0.00925 |
0.037850 |
0.962150 |
|
17 |
margin |
0.980 |
0.97725 |
0.02275 |
0.031977 |
0.968023 |
|
18 |
margin |
0.970 |
0.96575 |
0.03425 |
0.026145 |
0.973855 |
|
19 |
margin |
0.950 |
0.94600 |
0.05400 |
0.018235 |
0.981765 |
|
20 |
margin |
0.900 |
0.88400 |
0.11600 |
0.007919 |
0.992081 |
5.2. RC-кривые (maxprob / entropy / margin)
RC-кривая — это зависимость selective risk от coverage при переборе уровней строгости.
import matplotlib.pyplot as plt
grid = np.linspace(0.90, 1.00, 80)
def rc_ge(score_cal, score_test):
covs, risks = [], []
for c in grid:
tau = np.quantile(score_cal, 1.0 - c)
accept = score_test >= tau
if accept.sum() == 0:
continue
covs.append(accept.mean())
risks.append((yhat_test[accept] != y_test[accept]).mean())
return np.array(covs), np.array(risks)
def rc_le(score_cal, score_test):
covs, risks = [], []
for c in grid:
tau = np.quantile(score_cal, c)
accept = score_test <= tau
if accept.sum() == 0:
continue
covs.append(accept.mean())
risks.append((yhat_test[accept] != y_test[accept]).mean())
return np.array(covs), np.array(risks)
cov_max, risk_max = rc_ge(smax_cal, smax_test)
cov_ent, risk_ent = rc_le(H_cal, H_test)
cov_mar, risk_mar = rc_ge(smar_cal, smar_test)
plt.figure()
plt.plot(cov_max, risk_max, label="maxprob")
plt.plot(cov_ent, risk_ent, label="entropy")
plt.plot(cov_mar, risk_mar, label="margin")
plt.xlabel("coverage")
plt.ylabel("selective risk")
plt.title("Risk–Coverage (letter)")
plt.grid(True)
plt.legend()
plt.show()

5.3. Conformal APS на том же участке coverage
alphas = np.linspace(0.01, 0.999, 300)
cov_aps, risk_aps = [], []
n = len(aps_sorted)
for alpha in alphas:
k = int(np.ceil((n + 1) * (1.0 - alpha)))
k = min(max(k, 1), n)
qhat = aps_sorted[k - 1]
m = (cumsum_t < qhat).sum(axis=1) + 1
accept = (m == 1)
if accept.sum() == 0:
continue
cov_aps.append(accept.mean())
risk_aps.append((y_pred_top1[accept] != y_test[accept]).mean())
cov_aps = np.array(cov_aps)
risk_aps = np.array(risk_aps)
idx = np.argsort(cov_aps)
cov_aps, risk_aps = cov_aps[idx], risk_aps[idx]
x_min, x_max = 0.90, 1.00
mask = (cov_aps >= x_min) & (cov_aps <= x_max)
plt.figure()
plt.plot(cov_max, risk_max, label="maxprob")
plt.plot(cov_ent, risk_ent, label="entropy")
plt.plot(cov_mar, risk_mar, label="margin")
plt.plot(cov_aps[mask], risk_aps[mask], label="conformal APS (singleton)", linestyle="--", marker="o")
plt.xlim(x_min, x_max)
plt.xlabel("coverage")
plt.ylabel("selective risk")
plt.title("Risk–Coverage zoom (letter)")
plt.grid(True)
plt.legend()
plt.show()
print("APS points in [0.90, 1.00]:", mask.sum())

6) Обсуждение
-
maxprobреагирует только на величину top-1. -
marginразличает “уверенно” и “сомневаюсь между двумя” даже при одинаковом top-1. -
entropyштрафует распределения, где масса размазана по нескольким классам.
В мультиклассе эти критерии дают разное ранжирование примеров, поэтому RC-кривые расходятся.
7) Практические выводы
-
Если нужен самый дешёвый в реализации отказ –
maxprob(один порог). -
Если ошибки похожи на “путаю два близких класса” –
marginчасто выигрывает. -
Если неопределённость распределена по нескольким классам –
entropyполезна, но важно принимать низкую энтропию. -
Если важен не только отказ, но и “набор допустимых ответов” – APS даёт другой интерфейс: C(x) вместо
, а отказ — это большой ∣C(x)∣.
Заключение
Это моя первая статья на Хабре, и я хотел начать с темы, которая одновременно практична и легко воспроизводима: отказ от ответа для табличной классификации. Идея простая: если разрешить модели “молчать” на сомнительных примерах, можно управлять компромиссом coverage ↔ risk и заметно снижать ошибку на тех случаях, где модель всё же принимает решение.
В эксперименте на мультиклассовом letter сравнивались три простых эвристики отказа – maxprob, entropy, margin – и более “структурный” подход Conformal APS, который возвращает не одну метку, а набор допустимых классов C(x) (а отказ – это частный случай, когда набор не сузился до одного класса). Главная практическая ценность такого сравнения – не “лучшая метрика вообще”, а понимание: сколько отказов нужно, чтобы получить заданный уровень ошибки на автоматическом контуре.
Если найдёте ошибки, спорные места или уместные улучшения — буду рад обратной связи.
Автор: bratik1744


