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 stoppingcalibration– только для порогов/квантили (и 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) (а отказ – это частный случай, когда набор не сузился до одного класса). Главная практическая ценность такого сравнения – не “лучшая метрика вообще”, а понимание: сколько отказов нужно, чтобы получить заданный уровень ошибки на автоматическом контуре.
Если найдёте ошибки, спорные места или уместные улучшения — буду рад обратной связи.
