Модель регрессии выдаёт число, модель классификации — вектор softmax-вероятностей, и оба молчат о том, насколько им можно верить на конкретном объекте. softmax-вероятность 0.9 не означает, что в 90% таких случаев ответ верен, а точечный прогноз цены ничего не говорит о том, в каком диапазоне реально лежит истина. Обычные способы добавить неопределённость опираются на предположения: доверительные интервалы линейной регрессии — на нормальность остатков, байесовские — на корректность априорных, bootstrap — на репрезентативность выборки.
Конформные предсказания дают ответ другой формы — множество классов или числовой интервал с гарантией: при заданном уровне α доля промахов в среднем не превысит α, без предположений о распределении данных и почти поверх любой обученной модели.
Идея и точная гарантия
Метод в базовой split-форме откладывает калибровочную выборку, которую модель не видела при обучении. Для каждого её объекта считается оценка нонконформности — насколько предсказание «удивлено» истинным ответом; для регрессии это модуль остатка. Берётся эмпирический квантиль этих оценок, и он становится порогом: в предсказание нового объекта попадает всё, что в порог укладывается.
Гарантия — следствие чистой комбинаторики, а не свойств модели. Если новый объект и калибровочные обмениваемы, то ранг нонконформности нового объекта среди n калибровочных распределён равномерно по позициям от 1 до n+1. Поэтому, взяв в качестве порога значение под номером среди отсортированных калибровочных оценок, получаем точную двустороннюю границу покрытия:
Нижняя граница держится всегда, верхняя — когда оценки почти наверное различны. Отсюда же требование к размеру калибровки: если больше n, порога не существует и множество становится тривиальным, так что для нетривимой гарантии нужно примерно 1/α точек минимум — около ста при α = 0.01. Поправка (n+1)/n и квантиль «сверху» — это и есть перевод равномерности ранга в строгое неравенство, а не косметика.
Регрессия: рабочий пример с проверкой покрытия
Возьмём задачу с гетероскедастичным шумом — он растёт вместе с X, что станет важно дальше. Данные делятся на три части: обучение модели, калибровка, тест.
import numpy as np from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import train_test_split rng = np.random.default_rng(0) X = rng.uniform(0, 10, size=(8000, 1)) y = np.sin(X[:, 0]) + rng.normal(0, 0.2 + 0.4 * X[:, 0], size=8000) # шум растёт с X X_train, X_rest, y_train, y_rest = train_test_split(X, y, test_size=0.5, random_state=0) X_cal, X_test, y_cal, y_test = train_test_split(X_rest, y_rest, test_size=0.5, random_state=0) model = GradientBoostingRegressor(random_state=0).fit(X_train, y_train) alpha = 0.1 n = len(y_cal) resid = np.abs(y_cal - model.predict(X_cal)) # нонконформность q = np.quantile(resid, np.ceil((n + 1) * (1 - alpha)) / n, method="higher") pred = model.predict(X_test) lower_base, upper_base = pred - q, pred + q print(np.mean((y_test >= lower_base) & (y_test <= upper_base))) # ~0.90
Последняя строка печатает долю теста, попавшую в интервал, и она садится около 0.90. Мы посчитали квантиль остатков на калибровке, отложили его в обе стороны от прогноза — и интервал покрывает истину с заявленной частотой, никакой настройки сверх α тут нет.
Конечная выборка в коде: при малом n покрытие плывёт вверх
Верхняя граница не абстракция — на маленькой калибровке метод заметно перекрывает и сильно колеблется. Прогоним базовую схему при разных размерах калибровки, по 200 случайных разбиений каждый:
def split_coverage(cal_size, seed): Xc, Xt, yc, yt = train_test_split(X_rest, y_rest, train_size=cal_size, random_state=seed) m = len(yc) r = np.abs(yc - model.predict(Xc)) qq = np.quantile(r, min(np.ceil((m + 1) * (1 - alpha)) / m, 1.0), method="higher") p = model.predict(Xt) return np.mean((yt >= p - qq) & (yt <= p + qq)) for cs in (20, 100, 1000): cov = np.array([split_coverage(cs, s) for s in range(200)]) print(cs, round(cov.mean(), 3), round(cov.std(), 3)) # 20 0.93 0.05 — перекрытие и большой разброс # 100 0.905 0.02 # 1000 0.901 0.006 — у 0.90, разброс мал
При n = 20 верхняя граница допускает до , и среднее покрытие действительно уходит выше 0.90, а разброс отдельных прогонов огромен. С ростом калибровки и среднее садится на 0.90, и разброс сжимается — это единственный параметр, которым тут стоит управлять.
При этом важно, что гарантия покрытия выполняется при любой оценке нонконформности, потому что аргумент про ранг не зависит от того, осмысленна оценка или нет. Можно взять случайные числа вместо остатков — покрытие всё равно будет 0.90, но интервал окажется бессмысленно широким, а множество классов — почти полным.
Валидность даётся даром, а вся инженерия конформных методов — про эффективность, то есть про то, как при той же гарантии получить узкий интервал и маленькое множество. Все варианты ниже отличаются только выбором оценки нонконформности и борются именно за размер ответа, а не за покрытие.
Где базовый интервал проседает
Маргинальные 0.90 — это среднее по всем объектам, и оно прячет неоднородность. Наш шум растёт с X, а интервал фиксированной ширины этого не знает:
low = X_test[:, 0] < 2 high = X_test[:, 0] > 8 cov_low = np.mean((y_test[low] >= lower_base[low]) & (y_test[low] <= upper_base[low])) cov_high = np.mean((y_test[high] >= lower_base[high]) & (y_test[high] <= upper_base[high])) print(round(cov_low, 2), round(cov_high, 2)) # например, 0.99 и 0.80 при среднем 0.90
В спокойной области интервал избыточно широк и покрывает почти всё, в шумной слишком узок и промахивается чаще обещанного, а в среднем выходит ровно 0.90. Маргинальная гарантия соблюдена, условная — нет, и это фундаментальное свойство фиксированной ширины. Дальше — два способа сделать ширину переменной.
Адаптивность через квантильную регрессию (CQR)
CQR обучает две квантильные модели на уровни α/2 и 1 − α/2, которые сами дают переменный коридор, а нонконформность измеряет, насколько истина вылезает за него. Калибровочный квантиль этой величины поправляет границы ровно настолько, чтобы вернуть покрытие.
lo = GradientBoostingRegressor(loss="quantile", alpha=alpha / 2, random_state=0).fit(X_train, y_train) hi = GradientBoostingRegressor(loss="quantile", alpha=1 - alpha / 2, random_state=0).fit(X_train, y_train) score = np.maximum(lo.predict(X_cal) - y_cal, y_cal - hi.predict(X_cal)) # выход за коридор q = np.quantile(score, np.ceil((n + 1) * (1 - alpha)) / n, method="higher") lower, upper = lo.predict(X_test) - q, hi.predict(X_test) + q print(round(np.mean((y_test >= lower) & (y_test <= upper)), 3)) # ~0.90 print(round((upper - lower)[low].mean(), 2), round((upper - lower)[high].mean(), 2)) # узко / широко
Покрытие держится около 0.90, но ширина переменная: в спокойной зоне интервал заметно уже, в шумной шире. Проверить, что условное покрытие выровнялось, помогает покрытие по бинам ширины — size-stratified coverage:
width = upper - lower edges = np.quantile(width, [0, .25, .5, .75, 1.0]) for i in range(4): b = (width >= edges[i]) & (width <= edges[i + 1]) cov = np.mean((y_test[b] >= lower[b]) & (y_test[b] <= upper[b])) print(f"ширина {edges[i]:.2f}-{edges[i+1]:.2f}: покрытие {cov:.3f}")
Если покрытие держится около 0.90 во всех бинах ширины, интервал честно адаптивен; провал в каком-то бине означает, что в этой области уверенность модели не соответствует реальной ошибке. Это и есть рабочая диагностика адаптивности вместо одного маргинального числа.
Адаптивность через нормализацию остатков
Второй путь к переменной ширине не требует квантильных моделей. Обучается отдельная модель масштаба ошибки , и нонконформность нормируется на неё:
. Интервал получает ширину, пропорциональную ожидаемой ошибке в этой точке.
mu = GradientBoostingRegressor(random_state=0).fit(X_train, y_train) res = np.abs(y_train - mu.predict(X_train)) sigma = GradientBoostingRegressor(random_state=1).fit(X_train, res) # модель масштаба ошибки sig_cal = np.clip(sigma.predict(X_cal), 1e-3, None) s = np.abs(y_cal - mu.predict(X_cal)) / sig_cal q = np.quantile(s, np.ceil((n + 1) * (1 - alpha)) / n, method="higher") sig_test = np.clip(sigma.predict(X_test), 1e-3, None) half = q * sig_test lower, upper = mu.predict(X_test) - half, mu.predict(X_test) + half print(round(np.mean((y_test >= lower) & (y_test <= upper)), 3)) # ~0.90, ширина переменная
Модель обучена на тех же остатках, что и μ, поэтому масштаб лучше оценивать на отдельной части данных, иначе он оптимистично занижен. CQR обычно даёт более точную форму интервала, нормализация дешевле и не требует квантильной модели — выбор между ними определяется тем, есть ли у вас удобный квантильный регрессор.
Классификация: LAC и APS с пококлассовым покрытием
Для классификации простейшая нонконформность — единица минус вероятность истинного класса (вариант LAC): в множество попадают все классы с вероятностью не ниже порога.
from sklearn.datasets import make_classification from sklearn.ensemble import RandomForestClassifier Xc, yc = make_classification(n_samples=8000, n_classes=5, n_informative=8, n_features=20, random_state=0) Xtr, Xr, ytr, yr = train_test_split(Xc, yc, test_size=0.5, random_state=0) Xcal, Xte, ycal, yte = train_test_split(Xr, yr, test_size=0.5, random_state=0) clf = RandomForestClassifier(random_state=0).fit(Xtr, ytr) m = len(ycal) # LAC s_lac = 1 - clf.predict_proba(Xcal)[np.arange(m), ycal] q_lac = np.quantile(s_lac, np.ceil((m + 1) * (1 - alpha)) / m, method="higher") lac_sets = clf.predict_proba(Xte) >= (1 - q_lac) print(round(lac_sets[np.arange(len(yte)), yte].mean(), 3), round(lac_sets.sum(1).mean(), 2))
Покрытие около 0.90, а средний размер множества — честный сигнал неопределённости: на простых объектах множество сжимается до одного класса, на спорных разрастается. У LAC покрытие ровное в среднем, но неравномерное по классам. APS это выравнивает: его оценка — накопленная масса отсортированных по убыванию вероятностей до истинного класса включительно.
def aps_scores(proba, y): order = np.argsort(-proba, axis=1) sorted_p = np.take_along_axis(proba, order, axis=1) cum = np.cumsum(sorted_p, axis=1) rank = (order == y[:, None]).argmax(axis=1) return cum[np.arange(len(y)), rank] q_aps = np.quantile(aps_scores(clf.predict_proba(Xcal), ycal), np.ceil((m + 1) * (1 - alpha)) / m, method="higher") proba_t = clf.predict_proba(Xte) order_t = np.argsort(-proba_t, axis=1) sorted_t = np.take_along_axis(proba_t, order_t, axis=1) cum_before = np.cumsum(sorted_t, axis=1) - sorted_t # масса до добавления класса take = cum_before < q_aps # включаем до пересечения порога aps_sets = np.zeros_like(proba_t, dtype=bool) np.put_along_axis(aps_sets, order_t, take, axis=1) print(round(aps_sets[np.arange(len(yte)), yte].mean(), 3), round(aps_sets.sum(1).mean(), 2))
Теперь сравним покрытие по каждому классу — здесь разница LAC и APS видна напрямую:
for c in range(5): mask = yte == c print(c, round(lac_sets[mask, c].mean(), 2), round(aps_sets[mask, c].mean(), 2))
У LAC покрытие гуляет от класса к классу, у APS держится ровнее ценой чуть большего среднего размера множества. Выбор между ними — это выбор между компактностью и равномерностью покрытия по объектам.
Без отдельной калибровки: jackknife+ и CV+
Split-схема отдаёт калибровочную выборку и не использует её для обучения, что дорого на малых данных. Jackknife+ обходит это: для каждого объекта обучается модель без него, считается leave-one-out остаток, и интервал нового объекта строится из предсказаний этих моделей, сдвинутых на соответствующие остатки.
Гарантия чуть слабее — не ниже 1 − 2α, — но данные используются полностью, а на практике покрытие обычно близко к 1 − α. CV+ — это K-фолдовая, вычислительно посильная версия того же.
from sklearn.model_selection import KFold X_all = np.vstack([X_train, X_cal]); y_all = np.r_[y_train, y_cal] kf = KFold(n_splits=10, shuffle=True, random_state=0) fold_models, loo_res = [], np.empty(len(y_all)) for tr, va in kf.split(X_all): mdl = GradientBoostingRegressor(random_state=0).fit(X_all[tr], y_all[tr]) fold_models.append(mdl) loo_res[va] = np.abs(y_all[va] - mdl.predict(X_all[va])) # остатки на отложенном фолде # для нового x: нижняя/верхняя границы — квантили объединённых {pred_k(x) ∓ loo_res_i} preds = np.array([mdl.predict(X_test) for mdl in fold_models]) # (folds, n_test)
Дальше границы CV+ берутся как нижний α- и верхний (1−α)-квантили по объединённым предсказаниям фолд-моделей, сдвинутым на LOO-остатки. Это компромисс между расходом данных и стоимостью обучения, и на средних выборках он выгоднее одиночного split.
Под сдвигом распределения: взвешенный конформный и ACI
Обмениваемость ломается в реальных системах первой, и тогда покрытие плывёт. Если меняется только распределение признаков (сдвиг ковариат), а зависимость ответа прежняя, работает взвешенный конформный метод: калибровочные оценки перевзвешиваются на отношение плотностей прод-к-калибровке, которое оценивается классификатором «калибровка против прода» — тем же приёмом, что и в adversarial validation.
from sklearn.linear_model import LogisticRegression d = LogisticRegression(max_iter=1000).fit( np.vstack([X_cal, X_prod]), np.r_[np.zeros(len(X_cal)), np.ones(len(X_prod))]) p = d.predict_proba(X_cal)[:, 1] w = p / (1 - p) # вес калибровочной точки, дальше — взвешенный квантиль остатков
Под произвольным дрейфом этого мало, и тогда работает адаптивная конформная инференция (ACI): уровень подстраивается онлайн по тому, попадали мы в интервал или нет. После каждого шага α корректируется на ошибку покрытия, и долгосрочная доля промахов сходится к α при любом, даже состязательном, сдвиге.
gamma, alpha_t = 0.02, alpha misses = [] for t in range(len(y_stream)): lvl = np.clip(1 - alpha_t, 0.0, 1.0) q_t = np.quantile(recent_scores(t), lvl, method="higher") # остатки в скользящем окне lo_t, hi_t = pred_stream[t] - q_t, pred_stream[t] + q_t miss = not (lo_t <= y_stream[t] <= hi_t) misses.append(miss) alpha_t = alpha_t + gamma * (alpha - miss) # промах сужает уровень, попадание расширяет # np.mean(misses) -> около alpha независимо от характера дрейфа
ACI не требует обмениваемости и платит за это тем, что гарантия становится долгосрочной средней, а не пообъектной, а ширина интервала может скакать в моменты резких сдвигов. Это разумный дефолт для потоков и временных рядов.
Конформные предсказания превращают выход любой модели в множество или интервал с гарантией покрытия, и эта гарантия проверяется одной строкой — доля попаданий садится на 1 − α, что мы видели в коде на каждом шаге, вместе с её точной двусторонней границей и зависимостью от размера калибровки.
Валидность даётся даром при любой оценке нонконформности, поэтому вся работа уходит в эффективность: CQR и нормализация выравнивают условное покрытие переменной шириной, APS делает покрытие ровным по классам, size-stratified coverage служит честной диагностикой адаптивности, jackknife+ и CV+ возвращают отданные под калибровку данные, а взвешенная схема и ACI удерживают покрытие, когда обмениваемость ломается. Цена — отдельная калибровочная выборка и более широкий ответ там, где модель не уверена.
Разобраться с конформными интервалами проще, когда уверенно чувствуешь себя в базовых задачах регрессии и понимаешь, как работает модель под капотом. На бесплатных открытых уроках OTUS можно пройти этот путь от градиентного бустинга до практического решения задачи на Python, познакомиться с преподавателями‑практиками и задать вопросы по формату обучения.
1 июля в 18:00 — «Градиентный бустинг — мощный алгоритм ансамблирования в ML».
Разберём принцип работы градиентного бустинга и его применение в задачах машинного обучения.15 июля в 18:00 — «Решаем задачу регрессии методами ML на Python».
На практике пройдём основные этапы решения задачи регрессии: от подготовки данных до обучения и оценки модели.
Больше бесплатных уроков смотрите в дайджесте.
Комментарии (2)

Atomic_cosmos
16.06.2026 06:03Подскажите пожалуйста, какие методы разложения позволяют с погрешностью не более 5% подсказать выходной параметр с наименьшей базой обучения?
Для примера: 10 входных параметров, задача нелинейная, но без резих изломов.
Проблема в том, что расчет одного случая занимает около 30 минут и база для обучения соответственно ограничена, условно не более 100 случаев.
П.с. проблема актуальная в связи с внедрением технологий vvuq в обоснование точности компьютерной модели (цифровые двойники).
П.с. с. Мировая практика рекомендует метод полиномиального хаоса, но он очень прожорлив при указанных исходных данных (10 входных параметров, полином как минимум 2 порядка).
realbtr
Жаль, что все это не работает в перемежающихся метриках. Если несколько последовательностей запутаны проекцией в линейное пространство, получается нерегулярный хаос, в котором нельзя строить доверительные интервалы. А вот мои задачи именно такого рода...