Логистическая регрессия: сигмоида, отношение шансов и пример на Python
Логистическая регрессия простыми словами: сигмоида и логит, шансы и отношение шансов, интерпретация коэффициентов, лог-лосс. Модель оплаты на Python с ROC AUC и калибровкой.
Содержание статьи
Логистическая регрессия — модель, которая оценивает вероятность события с двумя исходами: заплатит пользователь или нет, уйдёт или останется. Рабочая ситуация: менеджер продукта спрашивает, можно ли по первым трём дням после регистрации понять, кто заплатит в первые три недели, и какие действия с оплатой связаны. Ниже — сигмоида, логит, шансы и лог-лосс с формулами, а затем модель на учебной базе «Маяк»: 3 207 пользователей, обучение через scipy, коэффициенты и отношения шансов, ROC AUC и калибровка. Итог неожиданный: канал привлечения говорит об оплате больше, чем все четыре действия вместе, а созданное рабочее пространство, которое в сырой таблице повышает шансы в 1,36 раза, после учёта канала почти ничего не добавляет.
Коротко
Логистическая регрессия считает вероятность события по нескольким признакам: p = 1 / (1 + e^(−z)), где z = β₀ + β₁x₁ + … + βₖxₖ.
- Линейная часть та же, что в линейной регрессии, но её значение z проходит через сигмоиду и становится вероятностью от 0 до 1.
- Коэффициент β — изменение логарифма шансов при росте признака на единицу. exp(β) — отношение шансов: во сколько раз меняются шансы при прочих равных.
- Модель обучают методом максимального правдоподобия, то есть минимизируют лог-лосс. Готовой формулы, как у МНК, нет: минимум ищут численно.
- Качество проверяют на отложенной части данных: ROC AUC показывает, как модель ранжирует людей, калибровка — сходятся ли обещанные вероятности с фактом.
- Коэффициенты описывают связь, а не причину. Порог 0,5 — договорённость, а не свойство модели.
Логистическая регрессия — это что и когда она нужна
Её берут, когда исход бинарный: оплатил или нет, продлил подписку или нет, вернул товар, кликнул по письму, не вернул кредит. Единица наблюдения — тот, про кого спрашивают: пользователь, заказ, заявка. Ответ модели — вероятность для каждой единицы.
С моделью работают двумя способами. Прогноз: отсортировать новых пользователей по вероятности оплаты и отдать верх списка менеджерам — здесь важны ранжирование и калибровка. Описание: какие признаки сопровождают оплату при прочих равных — здесь смотрят на коэффициенты и интервалы.
Метод относят к классификации, хотя выдаёт он число. Класс появляется только после порога: «вероятность выше 0,3 — звоним». Если размеченного исхода нет и нужно разбить пользователей на похожие группы, это другая задача — кластеризация k-means. Как выбрать метод под вопрос, разобрано в навигаторе по статистическим методам.
Почему линейная регрессия не годится для вероятности
Можно закодировать исход как 0 и 1 и провести через точки обычную прямую методом наименьших квадратов. Такую модель называют линейной вероятностной, и у неё три изъяна.
Первый — выход за границы. Допустим, база — 20%, а каждое из шести действий прибавляет по 15 пунктов: у человека, сделавшего всё, выйдет 110%. На «Маяке» с бинарными признаками прогнозы прямой остались в диапазоне 7,6–36,8%, но числовой признак вроде числа сессий выталкивает её за край.
Второй — сложение эффектов на шкале вероятности. Прямая прибавляет одни и те же пункты и при базе 2%, и при базе 90%, хотя с 95% нельзя вырасти на 15 пунктов.
Третий — разброс. Дисперсия бинарного исхода равна p(1 − p) и зависит от прогноза, а стандартные ошибки линейной регрессии предполагают одинаковый разброс. Логистическая регрессия обходит все три проблемы: линейной она делает не вероятность, а логарифм шансов.
Сигмоида и логит: формулы
Сигмоида, она же логистическая функция, превращает любое число z в вероятность. Кривая симметрична и круче всего в середине: одинаковый сдвиг z сильно меняет вероятность около 50% и почти не меняет её у краёв — там мало места.
Логит — обратная функция: логарифм отношения вероятности события к вероятности его отсутствия. Модель записывают так: логит вероятности равен линейной комбинации признаков. Слово logit ввёл Джозеф Берксон в статье 1944 года о применении логистической функции в биологических испытаниях, по аналогии с уже существовавшим probit.
p = σ(z) = 1 / (1 + e^(−z)); logit(p) = ln(p / (1 − p)) = z = β₀ + β₁x₁ + … + βₖxₖσ(0) = 0,5; σ(2) = 0,881; σ(−2) = 0,119. Логит переводит интервал (0; 1) во всю числовую прямую, поэтому справа можно складывать признаки без ограничений.
Шаг z на единицу меняет p на 23 пункта в середине (с 0,5 до 0,731) и на 3 пункта у края (с 0,953 до 0,982).
Шансы и отношение шансов
Шансы — вероятность события, делённая на вероятность его отсутствия. В выборке «Маяка» оплатили 756 человек из 3 207, это 23,6%. Шансы — 756 / 2 451 = 0,31: на одного плательщика приходится около трёх неплательщиков. Вероятность 50% — это шансы 1, вероятность 90% — шансы 9.
Отношение шансов, OR, сравнивает шансы двух групп: шансы тех, у кого признак есть, делят на шансы тех, у кого его нет. OR = 1 — разницы нет, больше единицы — шансы выше, меньше — ниже. Подробно о том, чем OR отличается от относительного риска и почему их нельзя подменять, — в статье об относительном риске и отношении шансов.
Модель живёт в шансах потому, что их логарифм принимает любые значения: к нему можно прибавлять вклады признаков, не боясь выйти за границы.
| Вероятность p | Шансы p / (1 − p) | Логит ln(шансов) |
|---|---|---|
| 0,10 | 0,111 | −2,197 |
| 0,236 (оплата в «Маяке») | 0,309 | −1,175 |
| 0,50 | 1 | 0 |
| 0,75 | 3 | 1,099 |
| 0,90 | 9 | 2,197 |
Как обучается модель: максимум правдоподобия и лог-лосс
Правдоподобие — вероятность увидеть ровно те исходы, что есть в данных, если верить модели с данными коэффициентами: для плательщика это pᵢ, для неплательщика — 1 − pᵢ, для выборки — произведение. Ищут коэффициенты, при которых оно максимально. Произведение тысяч малых чисел неудобно, поэтому берут логарифм со знаком минус и усредняют. Получается лог-лосс.
Лог-лосс дорого наказывает уверенные промахи. Плательщику модель дала 0,3 — штраф −ln 0,3 = 1,20, неплательщику 0,3 — штраф 0,36, плательщику 0,01 — штраф 4,61. Поэтому такая модель стремится выдавать честные вероятности, а не только правильный порядок.
Формулы в одну строку, как у МНК, здесь нет: минимум ищут численно. Функция выпуклая, минимум у неё один. Градиент — Xᵀ(p − y) / n, признаки, взвешенные на ошибку прогноза. Классический способ — метод Ньютона (в пакетах его называют IRLS), в коде ниже — BFGS из scipy.
Ломается обучение на полном разделении: если признак без исключений отделяет плательщиков от остальных, коэффициент уходит в бесконечность, и оптимизатор останавливается на огромном числе. Коэффициент в десятки единиц с огромной стандартной ошибкой — повод искать ячейку, где платят все или никто.
L(β) = −(1/n) · Σ [yᵢ · ln pᵢ + (1 − yᵢ) · ln(1 − pᵢ)], pᵢ = σ(β₀ + β₁xᵢ₁ + … + βₖxᵢₖ)yᵢ — 1, если пользователь оплатил, иначе 0. Модель, которая всем даёт среднюю долю 23,6%, получает около 0,55; это нижняя планка, которую нужно обойти.
Как собрать данные для модели в SQL
Строка будущей таблицы — пользователь. Признаки — только из первых трёх дней, то есть события раньше signup_date + 3: создал ли рабочее пространство, отчёт, отправил ли приглашение, сделал ли экспорт. В учебной базе эти события приходятся на дни 0–2, так что в окно попадают все. Плюс канал привлечения и устройство.
Исход — хотя бы одна оплата не позже 20-го дня после регистрации, то есть в первые 21 день. Первые оплаты в базе приходят на 3–20-й день, и признаки всегда собраны до исхода. В реальных данных это проверяют отдельно.
Оплаты записаны по 30 августа, поэтому берём регистрации по 9 августа включительно: у этих людей 21 день уже прошёл. Зарегистрированный 25 августа ещё не успел заплатить и стал бы в модели ложным нулём. Остаётся 3 207 человек, оплатили 756.
В ноутбуке таблицу на пользователя забирают целиком через pd.read_sql. Песочница отдаёт не больше 200 строк, поэтому запрос сворачивает её в счётчики: восемь строк «канал × устройство», в каждой десять ячеек «пользователей/оплатили» по комбинациям действий. Код 1101 — «пространство, отчёт, приглашения нет, экспорт». Комбинаций десять, а не шестнадцать: отчёт и экспорт бывают только у создавших пространство. Это пригодится в разговоре о мультиколлинеарности.
WITH features AS (
SELECT
u.user_id, u.signup_date, u.channel, u.device,
max(CASE WHEN e.event_name = 'workspace_created' THEN 1 ELSE 0 END) AS workspace,
max(CASE WHEN e.event_name = 'report_created' THEN 1 ELSE 0 END) AS report,
max(CASE WHEN e.event_name = 'invite_sent' THEN 1 ELSE 0 END) AS invite,
max(CASE WHEN e.event_name = 'export_completed' THEN 1 ELSE 0 END) AS exported
FROM users AS u
LEFT JOIN events AS e
ON e.user_id = u.user_id
AND e.event_time < CAST(u.signup_date + 3 AS timestamp)
WHERE u.signup_date <= DATE '2026-08-09'
GROUP BY u.user_id, u.signup_date, u.channel, u.device
),
labeled AS (
SELECT
f.*,
concat(workspace, report, invite, exported) AS pattern,
CASE WHEN EXISTS (
SELECT 1 FROM payments AS p
WHERE p.user_id = f.user_id AND p.paid_at <= f.signup_date + 20
) THEN 1 ELSE 0 END AS paid
FROM features AS f
),
grid AS (
SELECT c.channel, c.device, p.pattern
FROM (SELECT DISTINCT channel, device FROM labeled) AS c
CROSS JOIN (SELECT DISTINCT pattern FROM labeled) AS p
),
cells AS (
SELECT g.channel, g.device, g.pattern,
count(l.user_id) AS users, COALESCE(sum(l.paid), 0) AS payers
FROM grid AS g
LEFT JOIN labeled AS l
ON l.channel = g.channel AND l.device = g.device AND l.pattern = g.pattern
GROUP BY g.channel, g.device, g.pattern
)
SELECT
channel, device,
string_agg(CAST(users AS varchar) || '/' || CAST(payers AS varchar), ' ' ORDER BY pattern) AS cells
FROM cells
GROUP BY channel, device
ORDER BY channel, device;Логистическая регрессия в Python: обучение через scipy
Блок разворачивает счётчики обратно в 3 207 строк и строит матрицу: константа, четыре действия, три канала и мобильное устройство. Одна категория каждого признака уходит в базу — organic и desktop, иначе столбцы канала в сумме повторят константу. Лог-лосс минимизирует scipy.optimize.minimize с явным градиентом.
Стандартные ошибки — из обратной матрицы Гессе XᵀWX, где на диагонали W стоят pᵢ(1 − pᵢ). Это приближение для больших выборок: оно предполагает независимых пользователей и верную форму модели. Для проверки мы пересчитали модель на 500 бутстреп-выборках: ошибки разошлись с гессианом не больше чем на 0,01.
В работе модель обычно строят библиотеками. sklearn.linear_model.LogisticRegression по умолчанию добавляет регуляризацию (C = 1,0): коэффициенты сжимаются к нулю, стандартных ошибок нет. Для прогноза это нормально, для чтения коэффициентов — нет. Logit из statsmodels печатает ошибки и p-value, но константу сам не добавляет — нужен add_constant. Числа ниже получены только scipy.
| Признак | β | SE | OR = exp(β) | 95% для OR | p |
|---|---|---|---|---|---|
| константа | −1,062 | 0,101 | 0,35 | 0,28–0,42 | <0,001 |
| рабочее пространство | 0,181 | 0,121 | 1,20 | 0,95–1,52 | 0,134 |
| отчёт | −0,056 | 0,109 | 0,95 | 0,76–1,17 | 0,612 |
| приглашение | 0,189 | 0,096 | 1,21 | 1,00–1,46 | 0,049 |
| экспорт | −0,187 | 0,110 | 0,83 | 0,67–1,03 | 0,091 |
| канал paid_search | −1,061 | 0,148 | 0,35 | 0,26–0,46 | <0,001 |
| канал partner | −0,343 | 0,121 | 0,71 | 0,56–0,90 | 0,005 |
| канал referral | 0,210 | 0,102 | 1,23 | 1,01–1,51 | 0,040 |
| мобильное устройство | −0,105 | 0,086 | 0,90 | 0,76–1,07 | 0,222 |
import numpy as np
from scipy import optimize, stats
# Результат SQL-запроса выше: канал, устройство и десять ячеек «пользователей/оплатили»
# по комбинациям «пространство, отчёт, приглашение, экспорт» в порядке PATTERNS
PATTERNS = ['0000', '0010', '1000', '1001', '1010', '1011', '1100', '1101', '1110', '1111']
CELLS = """
organic desktop 170/48 55/14 75/30 34/5 23/7 15/4 139/40 97/30 54/15 29/6
organic mobile 141/29 49/18 72/12 30/8 20/6 15/6 106/31 67/11 47/10 20/7
paid_search desktop 105/8 35/5 13/2 10/2 2/1 4/0 21/5 7/1 9/1 8/1
paid_search mobile 198/11 56/10 29/6 11/0 11/0 3/0 40/7 34/6 16/1 9/0
partner desktop 126/20 39/11 29/6 20/2 10/3 6/2 62/14 42/9 13/2 13/0
partner mobile 81/13 23/6 21/7 15/1 7/2 0/0 36/7 20/7 16/4 8/4
referral desktop 73/26 22/6 65/22 36/11 23/7 10/5 102/31 48/9 35/14 23/8
referral mobile 58/20 26/10 43/15 23/6 12/4 4/1 68/21 46/9 12/0 12/7
"""
NAMES = ['const', 'workspace', 'report', 'invite', 'export',
'paid_search', 'partner', 'referral', 'mobile'] # база: organic, desktop
# разворачиваем счётчики обратно в строку на пользователя
rows, y = [], []
for line in CELLS.strip().splitlines():
channel, device, *cells = line.split()
for pattern, cell in zip(PATTERNS, cells):
users, payers = map(int, cell.split('/'))
x = [1, *map(int, pattern), channel == 'paid_search', channel == 'partner',
channel == 'referral', device == 'mobile']
rows += [x] * users
y += [1] * payers + [0] * (users - payers)
X, y = np.array(rows, dtype=float), np.array(y)
def log_loss(beta): # среднее −log правдоподобия
z = X @ beta
return np.mean(np.logaddexp(0, z) - y * z)
def gradient(beta):
p = 1 / (1 + np.exp(-(X @ beta)))
return X.T @ (p - y) / len(y)
fit = optimize.minimize(log_loss, np.zeros(X.shape[1]), jac=gradient, method='BFGS')
beta = fit.x
p = 1 / (1 + np.exp(-(X @ beta)))
hessian = X.T @ (X * (p * (1 - p))[:, None]) # Гессе для −log L по всем строкам
se = np.sqrt(np.diag(np.linalg.inv(hessian)))
z = stats.norm.ppf(0.975)
print(f'пользователей {len(y)}, оплатили {y.sum()} ({y.mean():.1%}), лог-лосс {fit.fun:.4f}')
print(f'{"признак":<12}{"β":>7}{"SE":>7}{"OR":>6} 95% для OR p')
for name, b, s in zip(NAMES, beta, se):
p_value = 2 * stats.norm.sf(abs(b / s))
print(f'{name:<12}{b:>7.3f}{s:>7.3f}{np.exp(b):>6.2f} '
f'{np.exp(b - z * s):.2f}–{np.exp(b + z * s):.2f} {p_value:.3f}')Интерпретация коэффициентов логистической регрессии
Коэффициент читают через экспоненту. У канала referral β = 0,210, exp(0,210) = 1,23: при тех же действиях и устройстве шансы оплаты у пришедших по рекомендации в 1,23 раза выше, чем у органики. У paid_search β = −1,061, OR = 0,35: шансы втрое ниже. В процентах: (OR − 1) · 100% — шансы выше на 23% и ниже на 65%.
Чтобы перейти к вероятности, нужна конкретная точка. Пользователь из органики с десктопа, не сделавший ни одного действия: σ(−1,062) = 25,7%. Тот же профиль из платного поиска — σ(−1,062 − 1,061) = 10,7%, из рекомендаций — 29,9%. Приглашение с OR 1,21 поднимает органического пользователя с 25,7% до 29,5%, на 3,8 пункта. При базе 10,7% тот же коэффициент дал бы меньше пунктов: одинаковое OR — разные приросты вероятности.
Частая ошибка — прочитать OR 1,21 как «платят на 21% чаще». Доли оплаты у пригласивших и остальных — 26,2% и 22,7%, их отношение 1,15. OR больше относительного риска, когда исход не редкий, а 23,6% — не редкий.
Теперь интервалы. У приглашения нижняя граница — ровно 1,00, p = 0,049: связь на грани различимости. У пространства, отчёта и экспорта интервалы накрывают единицу; экспорт с OR 0,83 выглядит как минус, но интервал 0,67–1,03 не позволяет так сказать. Уверенно модель говорит только о каналах.
OR = шансы(x + 1) / шансы(x) = e^β; изменение шансов = (e^β − 1) · 100%Верно для любого значения x и при фиксированных остальных признаках. Изменение вероятности от того же β зависит от исходной точки.
Таблица сопряжённости против модели: сырое и скорректированное отношение шансов
Проверим модель на самом простом расчёте. Таблица 2×2 по рабочему пространству: из 1 950 создавших оплатили 501 (25,69%, шансы 0,346), из 1 257 остальных — 255 (20,29%, шансы 0,254). Сырое отношение шансов 0,346 / 0,254 = 1,36, интервал 1,15–1,61. По этой таблице пространство выглядит заметным сигналом.
Логистическая регрессия с единственным бинарным признаком воспроизводит таблицу точно: exp(β) = 1,359. Добавляем канал и устройство — и OR падает до 1,08 с интервалом 0,90–1,29. Связь почти исчезла.
Дело в канале. Пришедшие по рекомендации создают пространство в 75,8% случаев и платят в 31,3%, из платного поиска — в 36,6% и 10,8%. Канал двигает обе величины сразу, и сырая таблица приписывает пространству то, что на деле принёс канал. С приглашением иначе: 1,21 в таблице и 1,21 в модели — с каналом оно почти не связано. Если сырое и скорректированное OR сильно расходятся, рядом стоит третья переменная; этот же эффект в крайней форме описывает парадокс Симпсона.
| Признак | Таблица 2×2 | + канал и устройство | Полная модель |
|---|---|---|---|
| рабочее пространство | 1,36 (1,15–1,61) | 1,08 (0,90–1,29) | 1,20 (0,95–1,52) |
| приглашение | 1,21 (1,00–1,45) | 1,21 (1,00–1,46) | 1,21 (1,00–1,46) |
WITH features AS (
SELECT
u.user_id, u.signup_date,
max(CASE WHEN e.event_name = 'workspace_created' THEN 1 ELSE 0 END) AS workspace
FROM users AS u
LEFT JOIN events AS e
ON e.user_id = u.user_id
AND e.event_time < CAST(u.signup_date + 3 AS timestamp)
WHERE u.signup_date <= DATE '2026-08-09'
GROUP BY u.user_id, u.signup_date
),
labeled AS (
SELECT
f.workspace,
CASE WHEN EXISTS (
SELECT 1 FROM payments AS p
WHERE p.user_id = f.user_id AND p.paid_at <= f.signup_date + 20
) THEN 1 ELSE 0 END AS paid
FROM features AS f
)
SELECT
workspace,
count(*) AS users,
sum(paid) AS payers,
round(CAST(100.0 * sum(paid) / count(*) AS numeric(18, 6)), 2) AS paid_pct,
round(CAST(1.0 * sum(paid) / (count(*) - sum(paid)) AS numeric(18, 6)), 3) AS odds
FROM labeled
GROUP BY workspace
ORDER BY workspace;Как проверить качество: ROC AUC через ранги
Коэффициенты читают на всех данных, качество прогноза — на отложенных. Делим пользователей случайно 70 на 30 с фиксированным seed: 2 244 для обучения, 963 для проверки. Модель учится на первой части, оценивается на второй.
ROC AUC — вероятность того, что случайно взятый плательщик получил прогноз выше случайно взятого неплательщика. Значит, его можно посчитать через ранги, без scikit-learn: ранжируем все прогнозы, складываем ранги плательщиков и вычитаем минимально возможную сумму. Это тот же расчёт, что в критерии Манна — Уитни. У модели всего около 80 разных прогнозов — по числу комбинаций признаков, — поэтому ничьи неизбежны; scipy.stats.rankdata даёт им средний ранг, и это корректно. Что ещё показывает AUC и где он обманывает, разобрано в статье о метриках классификации.
Результат: AUC 0,607 на обучении и 0,631 на проверке. Модель только из канала даёт на проверке 0,627, только из действий и устройства — 0,560. Лог-лосс на проверке — 0,5267 против 0,5473 у константы. Проверка выше обучения — случайность разбиения: с seed 7 и 2026 на проверке выходит 0,649 и 0,624. На 963 людях AUC гуляет на пару сотых.
Вывод для менеджера: в «Маяке» действия первых трёх дней почти не предсказывают оплату, канал предсказывает заметно лучше. Это тоже результат: для скоринга по поведению нужны другие признаки — глубина использования, повторные визиты, размер команды.
AUC = (R₁ − n₁(n₁ + 1) / 2) / (n₁ · n₀)R₁ — сумма рангов прогнозов у плательщиков, n₁ и n₀ — число плательщиков и неплательщиков. 0,5 — случайный порядок, 1 — все плательщики выше всех остальных.
import numpy as np
from scipy import optimize, stats
# Те же данные, что в предыдущем блоке: канал, устройство и десять ячеек «пользователей/оплатили»
# по комбинациям «пространство, отчёт, приглашение, экспорт» в порядке PATTERNS
PATTERNS = ['0000', '0010', '1000', '1001', '1010', '1011', '1100', '1101', '1110', '1111']
CELLS = """
organic desktop 170/48 55/14 75/30 34/5 23/7 15/4 139/40 97/30 54/15 29/6
organic mobile 141/29 49/18 72/12 30/8 20/6 15/6 106/31 67/11 47/10 20/7
paid_search desktop 105/8 35/5 13/2 10/2 2/1 4/0 21/5 7/1 9/1 8/1
paid_search mobile 198/11 56/10 29/6 11/0 11/0 3/0 40/7 34/6 16/1 9/0
partner desktop 126/20 39/11 29/6 20/2 10/3 6/2 62/14 42/9 13/2 13/0
partner mobile 81/13 23/6 21/7 15/1 7/2 0/0 36/7 20/7 16/4 8/4
referral desktop 73/26 22/6 65/22 36/11 23/7 10/5 102/31 48/9 35/14 23/8
referral mobile 58/20 26/10 43/15 23/6 12/4 4/1 68/21 46/9 12/0 12/7
"""
NAMES = ['const', 'workspace', 'report', 'invite', 'export',
'paid_search', 'partner', 'referral', 'mobile'] # база: organic, desktop
# разворачиваем счётчики обратно в строку на пользователя
rows, y = [], []
for line in CELLS.strip().splitlines():
channel, device, *cells = line.split()
for pattern, cell in zip(PATTERNS, cells):
users, payers = map(int, cell.split('/'))
x = [1, *map(int, pattern), channel == 'paid_search', channel == 'partner',
channel == 'referral', device == 'mobile']
rows += [x] * users
y += [1] * payers + [0] * (users - payers)
X, y = np.array(rows, dtype=float), np.array(y)
def fit(X, y): # та же минимизация лог-лосса
loss = lambda b: np.mean(np.logaddexp(0, X @ b) - y * (X @ b))
grad = lambda b: X.T @ (1 / (1 + np.exp(-(X @ b))) - y) / len(y)
return optimize.minimize(loss, np.zeros(X.shape[1]), jac=grad, method='BFGS').x
def auc(score, target): # ROC AUC через ранги, ничьи получают средний ранг
ranks = stats.rankdata(score)
n1, n0 = target.sum(), len(target) - target.sum()
return (ranks[target == 1].sum() - n1 * (n1 + 1) / 2) / (n1 * n0)
def log_loss(p, target):
return -np.mean(target * np.log(p) + (1 - target) * np.log(1 - p))
rng = np.random.default_rng(42)
order = rng.permutation(len(y))
train, test = order[:int(0.7 * len(y))], order[int(0.7 * len(y)):]
print(f'обучение {len(train)} ({y[train].mean():.1%} оплат), проверка {len(test)} ({y[test].mean():.1%})')
sets = {'все признаки': list(range(9)), 'только канал': [0, 5, 6, 7],
'только действия и устройство': [0, 1, 2, 3, 4, 8]}
for label, cols in sets.items():
beta = fit(X[train][:, cols], y[train])
p_train = 1 / (1 + np.exp(-(X[train][:, cols] @ beta)))
p_test = 1 / (1 + np.exp(-(X[test][:, cols] @ beta)))
print(f'{label:<30} AUC обучение {auc(p_train, y[train]):.3f}, проверка {auc(p_test, y[test]):.3f}')
if label == 'все признаки':
p_full, y_test = p_test, y[test]
base = np.full(len(test), y[train].mean()) # модель «всем одна средняя доля»
print(f'лог-лосс на проверке: модель {log_loss(p_full, y_test):.4f}, константа {log_loss(base, y_test):.4f}')
print(f'прогнозы от {p_full.min():.3f} до {p_full.max():.3f}, не ниже 0,5: {(p_full >= 0.5).sum()}')
# калибровка: десять равных групп по возрастанию прогноза
decile = (stats.rankdata(p_full, method='ordinal') - 1) * 10 // len(p_full)
print('дециль людей прогноз факт')
for d in range(10):
m = decile == d
print(f'{d + 1:>6}{m.sum():>7}{p_full[m].mean():>9.3f}{y_test[m].mean():>6.3f}')
# порог превращает вероятность в решение «звонить / не звонить»
for threshold in (0.5, 0.3, 0.25, 0.2):
flagged = p_full >= threshold
hits = (flagged & (y_test == 1)).sum()
print(f'порог {threshold}: в списке {flagged.sum()}, из них оплатили {hits}, '
f'полнота {hits / y_test.sum():.1%}')Калибровка: совпадают ли вероятности с фактом
AUC проверяет порядок, но не сами числа. Калибровка проверяет числа: проверочную часть делят на десять равных групп по возрастанию прогноза и в каждой сравнивают средний прогноз с долей оплативших. У хорошо откалиброванной модели две линии идут рядом.
Здесь они идут рядом с поправкой на шум. В группе около 96 человек, стандартная ошибка доли около 23% — примерно 4,3 пункта. Самое большое расхождение — в третьем дециле: обещано 20,2%, оплатили 13,5%; прежде чем чинить, его проверяют на другом разбиении.
Главное другое: прогнозы лежат между 10% и 33%. Модель отличает плохую группу от хорошей, но не умеет назвать человека, который заплатит почти наверняка.
| Дециль | Людей | Прогноз | Факт |
|---|---|---|---|
| 1 | 97 | 10,2 | 8,2 |
| 2 | 96 | 12,7 | 13,5 |
| 3 | 96 | 20,2 | 13,5 |
| 4 | 97 | 23,0 | 19,6 |
| 5 | 96 | 24,9 | 28,1 |
| 6 | 96 | 26,4 | 29,2 |
| 7 | 97 | 27,6 | 25,8 |
| 8 | 96 | 29,1 | 35,4 |
| 9 | 96 | 30,5 | 29,2 |
| 10 | 96 | 33,3 | 34,4 |
Проверочная часть, seed 42. Линии расходятся на 1–7 пунктов, ошибка доли в группе из 96 человек — около 4 пунктов.
Порог 0,5 и дисбаланс классов
Самый высокий прогноз на проверке — 0,364. При стандартном пороге 0,5 модель не назовёт плательщиком никого, и её доля верных ответов совпадёт с долей неплательщиков — 76,3%. Модель «никто не заплатит» даёт ровно столько же, ничего не считая.
Порог выбирают от задачи. Если у отдела продаж есть время на 170 звонков, берут порог 0,3: в списке 173 человека, 55 из них оплатят — 31,8% против 23,7% в среднем, но это лишь четверть всех будущих плательщиков. Порог 0,25 даёт 538 человек, 166 оплат и 72,8% полноты. Порог задают ёмкость команды и цена ошибки, а не красота числа.
Не спешите «выравнивать» классы перевзвешиванием: вероятности сдвинутся вверх, и калибровка сломается. Нужен другой баланс точности и полноты — двигайте порог.
| Порог | В списке | Из них оплатили | Точность | Полнота |
|---|---|---|---|---|
| 0,50 | 0 | 0 | — | 0% |
| 0,30 | 173 | 55 | 31,8% | 24,1% |
| 0,25 | 538 | 166 | 30,9% | 72,8% |
| 0,20 | 736 | 199 | 27,0% | 87,3% |
Ошибки и ограничения: где модель легко прочитать неверно
Связь — не причина. Пригласившие платят чаще, OR 1,21. Это не значит, что подсказка «пригласите коллегу» поднимет оплату: приглашают те, кто пришёл с командой и задачей, и платят они по той же причине. Ответ на вопрос «что будет, если подтолкнуть» даёт только A/B-тест; подробнее — в разборе корреляции и причинности.
Мультиколлинеарность. Отчёт и экспорт бывают только у создавших пространство: корреляция пространства с отчётом — 0,645, с экспортом — 0,43. Когда они в модели вместе, коэффициент пространства означает «создал, но не сделал ни отчёта, ни экспорта» и вырастает с 1,08 до 1,20. Стандартная ошибка растёт с 0,091 до 0,121. Прогноз от этого не страдает, а отдельные коэффициенты читать трудно. Признаки, вложенные друг в друга, лучше объединить в одну ступенчатую переменную.
Утечка и окно. Признак должен быть известен к моменту прогноза. В «Маяке» оплаты начинаются с третьего дня, и окно 0–2 дня безопасно. В реальных данных так бывает не всегда: событие «ввёл карту» за день до оплаты сделает AUC прекрасным на истории и бесполезным в работе. Как это ловить и что происходит с моделью после запуска — в статье об утечке и дрейфе.
Незрелые когорты. 1 406 человек, зарегистрированных после 9 августа, мы не взяли: их 21 день ещё не прошёл. Включите их нулями — и доля оплат, и коэффициенты сдвинутся просто из-за календаря.
Линейность в логитах. Для числового признака модель считает, что каждая единица умножает шансы на одно и то же число. Число сессий или чек проверяют разбивкой на интервалы. Сами признаки удобно выбирать от первой ценности продукта — см. статью про activation rate.
Частые вопросы
Логистическая регрессия — это классификация или регрессия?
По устройству — регрессия: она оценивает вероятность как функцию признаков. По применению — чаще классификация: вероятность сравнивают с порогом и получают класс.
Чем логистическая регрессия отличается от линейной?
Линейная предсказывает само число и связывает его с признаками прямой. Логистическая предсказывает вероятность бинарного исхода, прямой у неё связан логарифм шансов, а обучают её по правдоподобию, а не наименьшими квадратами.
Как перевести коэффициент в проценты?
exp(β) — во сколько раз меняются шансы; (exp(β) − 1) · 100% — на сколько процентов. В изменение вероятности коэффициент переводят только для конкретного профиля: посчитайте σ(z) без признака и с ним.
Как сделать логистическую регрессию в Python?
Для прогноза — LogisticRegression из scikit-learn, помня о регуляризации по умолчанию. Для таблицы коэффициентов с ошибками — Logit из statsmodels с add_constant. Без этих библиотек — минимизация лог-лосса через scipy.optimize.minimize, как в примере выше.
Запросы из статьи выполняются в песочнице Симулятора SQL-аналитика на той же базе «Маяк»: можно поменять окно признаков, исход или канал и посмотреть, как сдвинутся счётчики.
Итог
Логистическая регрессия переводит линейную комбинацию признаков в вероятность через сигмоиду, коэффициенты отдаёт в логарифмах шансов: exp(β) — отношение шансов при прочих равных. Обучают её минимизацией лог-лосса, проверяют на отложенной части по ROC AUC и калибровке, порог выбирают под задачу.
На «Маяке» ответ скромный, но полезный: канал объясняет оплату лучше действий первых трёх дней, сырое OR пространства 1,36 после учёта канала сжимается до 1,08, а приглашение на грани различимости описывает связь, а не причину. Собрать такие признаки на продуктовых данных можно в Симуляторе SQL-аналитика.
Материалы по теме

Кластеризация k-means: метод k-средних на данных пользователей
Кластерный анализ методом k-средних на данных продукта: признаки, масштабирование, выбор k по локтю и силуэту, проверка устойчивости и когда хватит правил.

Относительный риск и отношение шансов: разница на примерах
Чем относительный риск отличается от отношения шансов: таблица 2×2, абсолютный эффект, доверительные интервалы, случай — контроль и A/B-тест в Python.

Гистограмма: как построить, выбрать интервалы и прочитать форму
Что показывает гистограмма распределения и чем она отличается от столбчатой диаграммы, как выбрать число интервалов, построить её в SQL, Python и Excel и не ошибиться.