AI Отказ от ответа в табличной классификации: max-prob, entropy и conformal sets на CatBoost

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
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) и метку

\hat y(x)=\arg\max_{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_i\ne 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. Данные: [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 уверенности и порог, откалиброванный на 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)

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])

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-\alpha\big).

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

m(x)=\min\left\{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]

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


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()
09becf203cd33be8a40cabc163429d90.png


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())
1b1cc3abacfd6071424f2774166e963b.png

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

Если найдёте ошибки, спорные места или уместные улучшения — буду рад обратной связи.
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru