Модель не обязана отвечать всегда. На мультиклассовой табличке (letter) я сравнил 3 способа отказа для CatBoost: maxprob, entropy/margin и conformal APS. Результаты — в risk–coverage кривых и таблице «coverage → ошибка», чтобы быстро понять, какой метод лучше “отбрасывает” ошибки отказами.
В табличной классификации ошибка часто стоит дороже, чем “не знаю”. Поэтому вместо “модель всегда отвечает” полезнее режим 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.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"])
tablemethod | target_cov | actual_cov | reject_rate | risk | acc_on_accepted | |
|---|---|---|---|---|---|---|
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) (а отказ – это частный случай, когда набор не сузился до одного класса). Главная практическая ценность такого сравнения – не “лучшая метрика вообще”, а понимание: сколько отказов нужно, чтобы получить заданный уровень ошибки на автоматическом контуре.
Если найдёте ошибки, спорные места или уместные улучшения — буду рад обратной связи.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Ваша модель показывает 95% accuracy и при этом бесполезна: метрики для несбалансированных классов | 0 | 9.19 | 24-05-2026 |
| 2 | Трансформер в on-premise AppSec: как мы встроили ML-модель для классификации секретов в продукт без GPU | 0 | 17.09 | 02-06-2026 |
| 3 | predict_proba выдаёт 0.9 — но это не вероятность 90% | 0 | 12.82 | 02-06-2026 |
| 4 | Почему «чем проще, тем лучше» не работает на ИИ-классификаторе | 0 | 13.64 | 19-07-2026 |
| 5 | Аугментация ограничивающих боксов в детекции: форматы, `BboxParams` и типичные ошибки | -1 | 6.34 | 11-04-2026 |
| 6 | Credit Scoring: Одинокое дерево, целый лес и разочарование в нейросетях | 0 | 8.69 | 28-07-2026 |
| 7 | Масштабируемость ML-алгоритмов при увеличении вычислительных ресурсов | 0 | 17.69 | 04-06-2026 |
| 8 | Откуда в обучении берётся nan: численная нестабильность в ML и почему всё считают в логарифмах | 0 | 6.4 | 11-06-2026 |
| 9 | olmOCR-2 vs PaddleOCR-VL: Which Extracts PDF Tables Better? | 0 | 15.21 | 11-06-2026 |