- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
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) кривая
Меняем порог
График “risk от coverage” показывает, как быстро падает ошибка при увеличении отказов.
точка справа: coverage≈1 (почти без отказов)
чем левее: больше отказов → обычно меньше risk
3) Эксперимент: данные, сплиты, модель
3.1. Данные:
[B]letter[/B]На простых датасетах многие “уверенности” ранжируют объекты почти одинаково, и RC-кривые слипаются. Поэтому дальше используем
[B]letter[/B]Код: загрузка + подготовка
Код:
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 уверенности и порог, откалиброванный на
calibration4.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) (а отказ – это частный случай, когда набор не сузился до одного класса). Главная практическая ценность такого сравнения – не “лучшая метрика вообще”, а понимание: сколько отказов нужно, чтобы получить заданный уровень ошибки на автоматическом контуре.Если найдёте ошибки, спорные места или уместные улучшения — буду рад обратной связи.