Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost. abstention.. abstention. aps.. abstention. aps. CatBoost.. abstention. aps. CatBoost. conformal prediction.. abstention. aps. CatBoost. conformal prediction. conformal sets.. abstention. aps. CatBoost. conformal prediction. conformal sets. risk-coverage.. abstention. aps. CatBoost. conformal prediction. conformal sets. risk-coverage. selective classification.. abstention. aps. CatBoost. conformal prediction. conformal sets. risk-coverage. selective classification. uncertainty.. abstention. aps. CatBoost. conformal prediction. conformal sets. risk-coverage. selective classification. uncertainty. отказ от ответа.. abstention. aps. CatBoost. conformal prediction. conformal sets. risk-coverage. selective classification. uncertainty. отказ от ответа. табличные данные.

1) Зачем нужен “отказ от ответа”?

В табличной классификации ошибка часто стоит дороже, чем “не знаю”. Поэтому вместо “модель всегда отвечает” полезнее режим selective classification (abstention): модель отвечает только когда уверена, а сомнительные случаи отправляет в ручную проверку / второй контур.

Например:

  • Антифрод (транзакции) :
    Ошибка → пропустили мошенника (прямой убыток).
    Отказ → транзакция уходит на дополнительную проверку (потеря UX/времени, но контролируемо).

  • Кредитный скоринг (одобрить/отклонить):
    Ошибка → одобрили “плохого” клиента (риск дефолта).
    Отказ → запросили дополнительные документы / ручной андеррайтинг.

  • Медицина (диагностика):
    Ошибка → неправильное лечение, риски.
    Отказ → Направили к специалисту, доп. обследования

Во всех трёх примерах система выигрывает, если “сложные” объекты можно отсеять и не принимать автоматическое решение вслепую.

Что именно мы оптимизируем?

На практике важен компромисс:

  • coverage – на какой доле случаев модель вообще отвечает

  • risk – какая ошибка на тех случаях, где она отвечает

То есть вопрос не “какая accuracy в среднем?”, а:

если мы согласны на X% отказов, какой станет ошибка на оставшихся (100−X)%?

Это удобно показывать risk–coverage кривой и таблицей “coverage → ошибка”.

2) Формализация: что считаем и как сравниваем

2.1. “Модель отвечает не всегда”

Есть классификатор, который выдаёт вероятности классов p(x) и метку

hat y(x)=argmax_{k} p_k(x)

Добавляем правило допуска (селектор) g(x)in{0,1}

  • g(x)=1 – модель отвечает

  • g(x)=0 – отказ

Практически g(x) строится по “скорy уверенности” s(x) и порогуtau:

g(x)=mathbb{1}[s(x)ge tau]

2.2. Coverage

Доля объектов, на которых модель ответила:

text{coverage}=frac{1}{n}sum_{i=1}^{n} g(x_i)

Интерпретация: “сколько кейсов обработали автоматически”.

2.3. Selective risk (ошибка на отвеченных)

Считаем ошибку только на тех объектах, где g(x)=1:

text{risk}=frac{sum_{i=1}^{n}mathbb{1}[hat y_ine y_i]cdot g(x_i)}{sum_{i=1}^{n} g(x_i)}

Интерпретация: “насколько мы ошибаемся там, где решаем автоматически”.

2.4. Risk–Coverage (RC) кривая

Меняем порог tau → получаем разные пары (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))
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 8

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)
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 9

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
)
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 10

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)
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 11

4) Три подхода отказа (единый протокол сравнения)

Все методы ниже строят правило допуска g(x) (отвечаем/отказываемся) через score уверенности и порог, откалиброванный на calibration

4.1. Порог по max probability

Идея: отвечать, когда top-1 вероятность достаточно большая.

s_{max}(x)=max_{k} p_k(x), qquad g_{max}(x)=mathbb{1}[s_{max}(x)ge tau].

4.2. Порог по entropy

Энтропия — мера “размазанности” распределения. Чем меньше энтропия, тем модель увереннее.

H(p(x))=-sum_{k=1}^{K} p_k(x)log p_k(x), qquad g_{text{ent}}(x)=mathbb{1}[H(p(x))le tau].

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)
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 14

4.3. Порог по margin (top-1 − top-2)

Margin ловит ситуацию “модель сомневается между двумя классами”.

s_{text{mar}}(x)=p_{(1)}(x)-p_{(2)}(x), qquad g_{text{mar}}(x)=mathbb{1}[s_{text{mar}}(x)ge tau].

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])
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 16

4.4. Conformal APS sets (singleton-only)

Conformal APS вместо одной метки строит набор допустимых классов C(x). Для отказа используется жёсткое правило: отвечать только если набор сузился до одного класса.

g_{text{APS}}(x)=mathbb{1}[|C(x)|=1].

APS-score на calibration. Для каждого объекта берём классы по убыванию вероятности и смотрим, сколько суммарной массы нужно набрать, чтобы “дойти” до истинного класса.

text{APS}(x,y)=sum_{j=1}^{r(x,y)} p_{(j)}(x),

где r(x,y) – ранг истинного класса y в сортировке вероятностей по убыванию.

Дальше берётся split-conformal квантиль q_{alpha}​:

q_{alpha}=text{Quantile}big({text{APS}(x_i,y_i)}_{(x_i,y_i)in D_{text{cal}}}, 1-alphabig).

И строится размер набора на test:

m(x)=minleft{m:sum_{j=1}^{m} p_{(j)}(x)ge q_{alpha}right}, qquad |C(x)|=m(x).

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]
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 22

5) Оценка: таблица “coverage → risk” и RC-кривые

Здесь используются одни и те же метрики: coverage и selective risk (ошибка на принятых). Пороги/уровни строгости выбираются по calibration, качество измеряется по test.

5.1. Таблица “coverage → risk” для maxprob / entropy / margin

Для maxprob и margin принимаются верхние по score объекты; порог берётся как квантиль на calibration:

tau(c)=operatorname{Quantile}(s_{text{cal}},,1-c),qquad g(x)=mathbb{1}[s(x)ge tau(c)].

Для энтропии принимаются объекты с малой энтропией, поэтому порог другой:

tau(c)=operatorname{Quantile}(H_{text{cal}},,c),qquad g(x)=mathbb{1}[H(x)le tau(c)].

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
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 25

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 при переборе уровней строгости.

text{RC}={(text{coverage}(tau),,text{risk}(tau))}_{tau}.

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()
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 27
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 28

5.3. Conformal APS на том же участке coverage

alpha mapsto q_{alpha} mapsto C(x) mapsto (text{coverage},text{risk}), qquad g(x)=mathbb{1}[|C(x)|=1].

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())
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 30
Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost - 31

6) Обсуждение

  • maxprob реагирует только на величину top-1.

  • margin различает “уверенно” и “сомневаюсь между двумя” даже при одинаковом top-1.

  • entropy штрафует распределения, где масса размазана по нескольким классам.

В мультиклассе эти критерии дают разное ранжирование примеров, поэтому RC-кривые расходятся.

7) Практические выводы

  • Если нужен самый дешёвый в реализации отказ – maxprob (один порог).

  • Если ошибки похожи на “путаю два близких класса” – margin часто выигрывает.

  • Если неопределённость распределена по нескольким классам – entropy полезна, но важно принимать низкую энтропию.

  • Если важен не только отказ, но и “набор допустимых ответов” – APS даёт другой интерфейс: C(x) вместо hat y​, а отказ — это большой ∣C(x)∣.

Заключение

Это моя первая статья на Хабре, и я хотел начать с темы, которая одновременно практична и легко воспроизводима: отказ от ответа для табличной классификации. Идея простая: если разрешить модели “молчать” на сомнительных примерах, можно управлять компромиссом coverage ↔ risk и заметно снижать ошибку на тех случаях, где модель всё же принимает решение.

В эксперименте на мультиклассовом letter сравнивались три простых эвристики отказа – maxprob, entropy, margin – и более “структурный” подход Conformal APS, который возвращает не одну метку, а набор допустимых классов C(x) (а отказ – это частный случай, когда набор не сузился до одного класса). Главная практическая ценность такого сравнения – не “лучшая метрика вообще”, а понимание: сколько отказов нужно, чтобы получить заданный уровень ошибки на автоматическом контуре.

Если найдёте ошибки, спорные места или уместные улучшения — буду рад обратной связи.

Автор: bratik1744

Источник

Rambler's Top100