Привет, Хабр!

Сегодня рассмотрим тему неопределённости в моделях. Классические ML-модели детерминированы: на вход получили – на выход выдали одно число или метку. Но жизнь полна неопределённости, и игнорировать её плохая идея. Представьте, у вас мало данных, модель предсказывает конверсию 15%. Но насколько она уверена? Может, разброс от 5% до 30%. Обычная модель этого не скажет, а вот вероятностная модель скажет.

В этой статье в коротком формате разберём, как с помощью байесовского подхода и фреймворка Pyro моделировать такую неопределённость на примере A/B-теста конверсии и заставить модель честно признавать свою неуверенность.

Вероятностное программирование: модели, которые сомневаются

Начнем с понятия. Вероятностное программирование — это подход, при котором мы прямо закладываем в программу (или модель) случайность и неопределённость. Вместо конкретных значений параметров мы задаём распределения. Вместо одного ответа получаем распределение ответов.

Классический Bayesian пример: у нас есть некоторое априорное представление о параметре (например, конверсия на сайте скорее всего ~10%, но это не точно). Собрав данные (скажем, 100 посетителей, из них 12 сконвертились), мы обновляем это представление и получаем апостериорное распределение параметра (конверсия теперь может быть ~12%, с некоторым доверительным интервалом). Главное отличие от частотного подхода в том, что мы не говорим “оценка конверсии = 12% ± доверительный интервал 95%”. А прямо получаем распределение вероятности для каждой возможной конверсии. То есть можем ответить на любой интересный вопрос, например: а какова вероятность, что реальная конверсия > 15%?. Bayesian модель скажет: “~10%” (к примеру), а классическая начнет ытаскивать p-value и все равно ничего не поймёт.

Pyro – один из фреймворков для вероятностного программирования. По сути, это библиотека над PyTorch. В Pyro мы можно задавать вероятностные модели прямо как обычный код на Python, используя примитив pyro.sample для случайных величин.

Bayesian модель конверсии

Возьмём понятный кейс: есть стартап, и мы пробуем два способа вовлечь пользователей — email-рассылка и звонок. Запустили A/B-тест: 700 человек получили email, из них 73 совершили целевое действие (конверсия ~10.4%). 200 другим позвонили менеджеры, и 35 из них сконвертились (~17.5%). На первый взгляд звонки эффективнее, хоть выборка и меньше. Но мы хотим оценить неопределённость этой ситуации: насколько уверенно можно заявить, что звонки правда лучше? Может, сэмпл маленький и это случайность?

Построим байесовскую модель для конверсий. Пусть вероятность конверсии для email = p_email, для звонка = p_call. До эксперимента мы не знали их, поэтому зададим априорное распределение. Естественный выбор для вероятностей – Beta-распределение. Возьмём неинформативный Beta(2,2) для обеих (это почти равномерное на [0,1], с легким акцентом на 50%, но довольно широкое). Далее, данные (число конверсий из попыток) моделируем как биномиальное распределение: obs_email ~ Binomial(n_email, p_email), obs_call ~ Binomial(n_call, p_call).

В Pyro модель задаётся как функция:

import pyro
import pyro.distributions as dist

def conversion_model(conv_call, conv_email, n_call, n_email):
    # априорные вероятности конверсии
    p_call = pyro.sample("p_call", dist.Beta(2., 2.))
    p_email = pyro.sample("p_email", dist.Beta(2., 2.))
    # наблюдаемые данные (likelihood)
    pyro.sample("obs_call", dist.Binomial(total_count=n_call, probs=p_call), obs=conv_call)
    pyro.sample("obs_email", dist.Binomial(total_count=n_email, probs=p_email), obs=conv_email)

Ничего особо страшного, обычная функция. Используем pyro.sample(name, dist.X) чтобы задать случайные параметры. Первый pyro.sample("p_call", Beta(2,2)) означает: выбери значение p_call из Beta(2,2). При этом Pyro помечает его как скрытый параметр модели, который надо будет оценивать по данным. Затем pyro.sample("obs_call", Binomial(...), obs=conv_call), это указываем, что наблюдение conv_call подчиняется биномиальному распределению с параметром p_call. Эта строчка связывает нашу случайную переменную p_call с конкретными данными.

Аналогично для email.

На этом описание модели всё. Дальше начинается инференс, вычисления апостериорного распределения p_call и p_email с учётом наблюдений. Можно задействовать вариационный метод, но я предпочитаю старый добрый MCMC, благо Pyro поддерживает его сам по себе. Воспользуемся алгоритмом NUTS:

from pyro.infer import MCMC, NUTS

kernel = NUTS(conversion_model)
mcmc = MCMC(kernel, num_samples=3000, warmup_steps=500)
mcmc.run( conv_call=35, conv_email=73, n_call=200, n_email=700 )

Запустили 3000 итераций выборки (и 500 шагов прогрева). Конечно еще надо смотреть на сходимость цепей, эффективный sample size и прочие MCMC штучки, но опустим для краткости. Предположим, метод отработал и выдал нам выборки из апостериорных распределений p_call и p_email. Их можно получить так:

posterior_samples = mcmc.get_samples()  # словарь выборок
p_call_samples = posterior_samples["p_call"].numpy()
p_email_samples = posterior_samples["p_email"].numpy()
print(p_call_samples.mean(), p_email_samples.mean())

Если вывести средние, вероятно будет что-то около p_call ~0.175, p_email ~0.105, то есть оценки близки к наблюденным частотам (17.5% и 10.5%). Интереснее другое: распределения этих параметров. Можно посчитать, например, 95%-й интервал доверия:

import numpy as np
low_c, high_c = np.quantile(p_call_samples, [0.025, 0.975])
low_e, high_e = np.quantile(p_email_samples, [0.025, 0.975])
print(f"95%-интервал для p_call: [{low_c:.3f}, {high_c:.3f}]")
print(f"95%-интервал для p_email: [{low_e:.3f}, {high_e:.3f}]")

Получится p_call в интервале [0.130, 0.225], p_email ~ [0.082, 0.130]. Видно, что интервал для email не пересекается с верхом интервала для call. Т.е. дажес учётом неопределённости, конверсия от звонков практически наверняка выше. Вычислим прямо вероятность того, что p_call > p_email:

prob_call_better = np.mean(p_call_samples > p_email_samples)
print(f"Prob(p_call > p_email) = {prob_call_better:.3f}")

Выдаётся ~0.997, то есть ~99.7%. С вероятностью ~99% звонки эффективнее, чем email, при данных которые у нас есть.

Вероятностные модели могут быть сколь угодно сложными: от простых A/B тестов до целых нейросетей. Например, можно делать Bayesian нейронные сети, где веса имеют априорное распределение, и в результате обучения мы получаем не точные веса, а их распределения. Это довольно затратные штуки.

Применение

Байесовские подходы дают крепкую математическую основу, чтобы учитывать неопределённость, объединять знания из разных источников и принимать решения более осмотрительно.

Когда пригодится вероятностный подход:

  • Маленький датасет или уникальная задача. Когда данных мало, стандартные модели неустойчивы. Байесовский метод впитывает априорные знания и отражает неизвестность, вместо переобучения в ноль или выдачи детерминированной ерунды.

  • Нужна доверенная вероятность. В медицинских диагнозах, финансовых предсказаниях важно знать доверительный интервал прогноза. PPL-модель сразу даёт распределение результатов.

  • Комплексные выводы. Когда вы делаете выводы на основе нескольких источников данных или слоёв неопределённости (например, прогнозируя что-то на основе других прогнозов), вероятностный подход будет естественнее.

Наш пример с двумя Beta — это лишь верхушка айсберга. Pyro поддерживает сложные иерархические модели, интегрируется с глубоким обучением, есть модуль numpyro для более быстрой MCMC.

Вообще, если тема зашла, первое естественное направление — углубиться в байесовскую статистику и классические модели. Это Beta-Binomial, Normal–Normal, и вообще conjugate priors, многие простые задачи там решаются аналитически, без MCMC.

Второе направление — сами фреймворки вероятностного программирования. Помимо Pyro есть NumPyro, PyMC, Stan, Turing и др. У каждого свои плюсы: где-то проще декларативный синтаксис, где-то лучше готовые диагностические инструменты, где-то удобнее интеграция.

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

Расскажите, применяете ли вы такие методы у себя и как боретесь с неопределённостью в проектах.


Если вам близка идея моделей, которые честно признаются в своей неопределённости, следующий логичный шаг — научиться доводить их до продакшена. На курсе по MLOps вы разберёте полный цикл: от хранения данных и кода до CI/CD, контейнеризации, k8s и мониторинга ML-систем в бою, сохраняя их устойчивость под реальной нагрузкой. Пройдите бесплатное тестирование по курсу, чтобы оценить свои знания и навыки.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 8 декабря: «MLFlow — полный контроль над ML-экспериментами!». Записаться

  • 16 декабря: «API — учим модель общаться с внешним миром». Записаться

Комментарии (0)