Практика 38 · Рівні шанси на помилку (Equality of Odds)¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md· 🧪 Тест:quiz.html
Уся тема тримається на одному вмінні: рахувати TPR і FPR окремо по групах. Далі — самі лише наслідки. Тут ми порахуємо все руками й переконаємось, що жодне з тверджень лекції не потребує віри на слово.
Що зробимо:
- Зберемо дві групи з різними розподілами скорів і різними базовими ставками
- Реалізуємо TPR/FPR і звіримо зі
scikit-learn - Побачимо, що спільний поріг ≠ однакове ставлення
- Підберемо два пороги, які вирівнюють шанси, і виміряємо ціну в точності
- Наткнемось на ліниві розвʼязки й зрозуміємо, чому не можна оптимізувати лише розрив
- Побачимо на числах теорему про несумісність із каліброваністю
- Покажемо, що прибрати ознаку групи не допомагає: проксі відновлює її
1. Дані: дві групи, одна модель¶
Синтетичні дані, зібрані рівно так, як в інтерактивах лекції. Для кожної людини є:
- справжня мітка Y — 1 означає «результат мав би бути позитивним»;
- скор s — число від 0 до 1, яке видала модель.
Групи відрізняються двома речами. По-перше, базовою ставкою — часткою тих, у кого Y = 1 (в A вона 0.50, у B — 0.35). По-друге, розподілом скорів: для групи B модель систематично видає нижчі числа.
import numpy as np
import matplotlib.pyplot as plt
РОЗМІР_ГРУПИ = 400
def стиснути(значення):
"""Монотонно заганяє число в (0, 1). ROC від монотонного перетворення не змінюється,
зате скор не доводиться обрізати — а обрізання зліпило б купу точок на краю шкали."""
return 1.0 / (1.0 + np.exp(-5.5 * (значення - 0.5)))
def зібрати_групу(генератор, базова_ставка, середнє_при_1, розкид_при_1,
середнє_при_0, розкид_при_0):
"""Повертає (справжні мітки, скори моделі) для однієї групи."""
мітки = (генератор.random(РОЗМІР_ГРУПИ) < базова_ставка).astype(int)
сире = np.where(мітки == 1,
середнє_при_1 + розкид_при_1 * генератор.normal(size=РОЗМІР_ГРУПИ),
середнє_при_0 + розкид_при_0 * генератор.normal(size=РОЗМІР_ГРУПИ))
скори = np.clip(стиснути(сире), 0.005, 0.995)
return мітки, скори
rng = np.random.default_rng(2024)
мітки_A, скори_A = зібрати_групу(rng, 0.50, 0.66, 0.20, 0.36, 0.13)
мітки_B, скори_B = зібрати_групу(rng, 0.35, 0.52, 0.11, 0.30, 0.17)
print(f"група A: {РОЗМІР_ГРУПИ} людей, базова ставка P(Y=1) = {мітки_A.mean():.3f}")
print(f"група B: {РОЗМІР_ГРУПИ} людей, базова ставка P(Y=1) = {мітки_B.mean():.3f}")
print(f"\nсередній скор у тих, хто має Y=1: A = {скори_A[мітки_A == 1].mean():.3f}, "
f"B = {скори_B[мітки_B == 1].mean():.3f}")
print(f"середній скор у тих, хто має Y=0: A = {скори_A[мітки_A == 0].mean():.3f}, "
f"B = {скори_B[мітки_B == 0].mean():.3f}")
print("\nСкори групи B зміщені нижче — і саме звідси візьмуться всі проблеми далі.")
група A: 400 людей, базова ставка P(Y=1) = 0.535 група B: 400 людей, базова ставка P(Y=1) = 0.297 середній скор у тих, хто має Y=1: A = 0.680, B = 0.529 середній скор у тих, хто має Y=0: A = 0.341, B = 0.270 Скори групи B зміщені нижче — і саме звідси візьмуться всі проблеми далі.
2. Інструмент: матриця плутанини по групах¶
Єдиний інструмент, який нам знадобиться. Є справжня мітка Y і рішення моделі Ŷ, яке зʼявляється лише після вибору порогу t: приймаємо, якщо s ≥ t.
- TPR = TP / (TP + FN) — яку частку тих, хто справді мав отримати позитивне рішення, модель пропустила;
- FPR = FP / (FP + TN) — яку частку тих, хто не мав, вона пропустила все одно.
Обидві величини рахуються всередині свого справжнього класу, тому не залежать від того, скільки в групі тих і тих. Саме тому їх можна порівнювати між групами різного розміру.
def метрики(мітки, скори, поріг):
"""Усі числа, які знадобляться далі, для однієї групи при одному порозі."""
рішення = (скори >= поріг).astype(int)
TP = int(np.sum((мітки == 1) & (рішення == 1))) # правильно прийняли
FN = int(np.sum((мітки == 1) & (рішення == 0))) # помилково відхилили
FP = int(np.sum((мітки == 0) & (рішення == 1))) # помилково прийняли
TN = int(np.sum((мітки == 0) & (рішення == 0))) # правильно відхилили
return {
"TP": TP, "FN": FN, "FP": FP, "TN": TN,
"TPR": TP / (TP + FN) if TP + FN else 0.0,
"FPR": FP / (FP + TN) if FP + TN else 0.0,
"PPV": TP / (TP + FP) if TP + FP else float("nan"), # точність позитивних рішень
"точність": (TP + TN) / len(мітки),
"прийнято": (TP + FP) / len(мітки),
}
наші = метрики(мітки_A, скори_A, 0.5)
print("група A при порозі 0.5:")
for ключ in ["TP", "FN", "FP", "TN", "TPR", "FPR", "PPV", "точність"]:
значення = наші[ключ]
print(f" {ключ:>9} = {значення:.4f}" if isinstance(значення, float) else f" {ключ:>9} = {значення}")
група A при порозі 0.5:
TP = 179
FN = 35
FP = 30
TN = 156
TPR = 0.8364
FPR = 0.1613
PPV = 0.8565
точність = 0.8375
Звірка зі scikit-learn¶
Обовʼязковий крок: переконатись, що всередині бібліотеки немає магії.
recall_score — це і є TPR, а FPR легко дістати з матриці плутанини.
from sklearn.metrics import confusion_matrix, recall_score, precision_score, accuracy_score
рішення_A = (скори_A >= 0.5).astype(int)
TN_sk, FP_sk, FN_sk, TP_sk = confusion_matrix(мітки_A, рішення_A).ravel()
print(f"{'величина':>12} {'наша':>10} {'sklearn':>10}")
print(f"{'TPR':>12} {наші['TPR']:10.6f} {recall_score(мітки_A, рішення_A):10.6f}")
print(f"{'FPR':>12} {наші['FPR']:10.6f} {FP_sk / (FP_sk + TN_sk):10.6f}")
print(f"{'PPV':>12} {наші['PPV']:10.6f} {precision_score(мітки_A, рішення_A):10.6f}")
print(f"{'точність':>12} {наші['точність']:10.6f} {accuracy_score(мітки_A, рішення_A):10.6f}")
assert np.allclose(наші["TPR"], recall_score(мітки_A, рішення_A))
assert np.allclose(наші["FPR"], FP_sk / (FP_sk + TN_sk))
assert np.allclose(наші["PPV"], precision_score(мітки_A, рішення_A))
assert np.allclose(наші["точність"], accuracy_score(мітки_A, рішення_A))
print("\n✅ збігається")
величина наша sklearn
TPR 0.836449 0.836449
FPR 0.161290 0.161290
PPV 0.856459 0.856459
точність 0.837500 0.837500
✅ збігається
3. Один поріг на всіх¶
Подивимось на самі дані. Чотири «мішки» точок: у кожній групі окремо ті, хто має Y = 1, і ті, хто має Y = 0. Частка прийнятих у ряді «Y = 1» — це TPR групи, у ряді «Y = 0» — FPR.
СПІЛЬНИЙ_ПОРІГ = 0.5
fig, ax = plt.subplots(figsize=(11, 4))
ряди = [("A · Y=1", скори_A[мітки_A == 1]), ("A · Y=0", скори_A[мітки_A == 0]),
("B · Y=1", скори_B[мітки_B == 1]), ("B · Y=0", скори_B[мітки_B == 0])]
for номер, (підпис, значення) in enumerate(ряди):
висота = np.full_like(значення, 3 - номер, dtype=float)
висота = висота + np.random.default_rng(номер).uniform(-0.18, 0.18, len(значення))
прийняті = значення >= СПІЛЬНИЙ_ПОРІГ
ax.scatter(значення[прийняті], висота[прийняті], s=14, color="teal", alpha=.75)
ax.scatter(значення[~прийняті], висота[~прийняті], s=14, color="lightgray", alpha=.9)
ax.axvline(СПІЛЬНИЙ_ПОРІГ, color="crimson", lw=2, label=f"спільний поріг t = {СПІЛЬНИЙ_ПОРІГ}")
ax.set_yticks([3, 2, 1, 0]); ax.set_yticklabels([п for п, _ in ряди])
ax.set_xlabel("скор моделі s"); ax.set_title("Бірюзові — прийняті, сірі — відхилені")
ax.legend(); ax.grid(alpha=.2, axis="x")
plt.tight_layout(); plt.show()
A_поріг = метрики(мітки_A, скори_A, СПІЛЬНИЙ_ПОРІГ)
B_поріг = метрики(мітки_B, скори_B, СПІЛЬНИЙ_ПОРІГ)
print(f"{'':>10} {'TPR':>8} {'FPR':>8} {'точність':>10}")
print(f"{'група A':>10} {A_поріг['TPR']:8.3f} {A_поріг['FPR']:8.3f} {A_поріг['точність']:10.3f}")
print(f"{'група B':>10} {B_поріг['TPR']:8.3f} {B_поріг['FPR']:8.3f} {B_поріг['точність']:10.3f}")
print(f"\nΔTPR = {abs(A_поріг['TPR'] - B_поріг['TPR']):.3f} "
f"ΔFPR = {abs(A_поріг['FPR'] - B_поріг['FPR']):.3f}")
print("\nЯкість моделі в обох групах близька. Але скори групи B зміщені нижче,")
print("тому той самий поріг відтинає різні частки. Спільний поріг — не синонім")
print("однакового ставлення.")
TPR FPR точність група A 0.836 0.161 0.838 група B 0.580 0.117 0.792 ΔTPR = 0.257 ΔFPR = 0.044 Якість моделі в обох групах близька. Але скори групи B зміщені нижче, тому той самий поріг відтинає різні частки. Спільний поріг — не синонім однакового ставлення.
Порахуємо все на сітці порогів¶
Розрив Equality of Odds — це максимум із двох різниць:
EO-розрив = max(|TPR_A − TPR_B|, |FPR_A − FPR_B|)
Пройдемо всі пороги від 0 до 1 і подивимось, чи знайдеться такий, де обидві різниці одночасно нульові.
СІТКА_ПОРОГІВ = np.arange(0, 201) / 200
МЕТРИКИ_A = [метрики(мітки_A, скори_A, t) for t in СІТКА_ПОРОГІВ]
МЕТРИКИ_B = [метрики(мітки_B, скори_B, t) for t in СІТКА_ПОРОГІВ]
def спільна_точність(індекс_A, індекс_B):
"""Точність на обʼєднаній вибірці при двох (можливо, різних) порогах."""
правильних = (МЕТРИКИ_A[індекс_A]["точність"] * РОЗМІР_ГРУПИ
+ МЕТРИКИ_B[індекс_B]["точність"] * РОЗМІР_ГРУПИ)
return правильних / (2 * РОЗМІР_ГРУПИ)
def EO_розрив(індекс_A, індекс_B):
різниця_TPR = abs(МЕТРИКИ_A[індекс_A]["TPR"] - МЕТРИКИ_B[індекс_B]["TPR"])
різниця_FPR = abs(МЕТРИКИ_A[індекс_A]["FPR"] - МЕТРИКИ_B[індекс_B]["FPR"])
return max(різниця_TPR, різниця_FPR)
найточніший_спільний = max(range(len(СІТКА_ПОРОГІВ)), key=lambda k: спільна_точність(k, k))
ТОЧНІСТЬ_СПІЛЬНОГО = спільна_точність(найточніший_спільний, найточніший_спільний)
print(f"найточніший спільний поріг: t = {СІТКА_ПОРОГІВ[найточніший_спільний]:.3f}")
print(f" точність = {ТОЧНІСТЬ_СПІЛЬНОГО:.4f}")
print(f" EO-розрив = {EO_розрив(найточніший_спільний, найточніший_спільний):.4f}")
print(f" TPR: A = {МЕТРИКИ_A[найточніший_спільний]['TPR']:.3f}, "
f"B = {МЕТРИКИ_B[найточніший_спільний]['TPR']:.3f}")
print(f" FPR: A = {МЕТРИКИ_A[найточніший_спільний]['FPR']:.3f}, "
f"B = {МЕТРИКИ_B[найточніший_спільний]['FPR']:.3f}")
найточніший спільний поріг: t = 0.455 точність = 0.8213 EO-розрив = 0.1325 TPR: A = 0.855, B = 0.723 FPR: A = 0.199, B = 0.149
різниці_TPR = np.array([МЕТРИКИ_A[k]["TPR"] - МЕТРИКИ_B[k]["TPR"] for k in range(len(СІТКА_ПОРОГІВ))])
різниці_FPR = np.array([МЕТРИКИ_A[k]["FPR"] - МЕТРИКИ_B[k]["FPR"] for k in range(len(СІТКА_ПОРОГІВ))])
точності = np.array([спільна_точність(k, k) for k in range(len(СІТКА_ПОРОГІВ))])
fig, (ліва, права) = plt.subplots(1, 2, figsize=(13, 4.2))
ліва.plot(СІТКА_ПОРОГІВ, різниці_TPR, lw=2.2, color="teal", label="TPR_A − TPR_B")
ліва.plot(СІТКА_ПОРОГІВ, різниці_FPR, lw=2.2, color="crimson", label="FPR_A − FPR_B")
ліва.axhline(0, color="gray", ls="--", lw=1.4)
ліва.set_xlabel("спільний поріг t"); ліва.set_ylabel("різниця між групами")
ліва.set_title("Обидві різниці не обнуляються разом"); ліва.legend(); ліва.grid(alpha=.25)
права.plot(СІТКА_ПОРОГІВ, точності, lw=2.2, color="darkorange")
права.axvline(СІТКА_ПОРОГІВ[найточніший_спільний], color="gray", ls="--", lw=1.5)
права.set_xlabel("спільний поріг t"); права.set_ylabel("точність")
права.set_title("Точність як функція спільного порогу"); права.grid(alpha=.25)
plt.tight_layout(); plt.show()
# дивимось лише на робочу частину шкали: краї — це вироджені режими
РОБОЧІ = range(20, 181)
найкраще_TPR = min(РОБОЧІ, key=lambda k: abs(різниці_TPR[k]))
найкраще_FPR = min(РОБОЧІ, key=lambda k: abs(різниці_FPR[k]))
print(f"|ΔTPR| мінімальна при t = {СІТКА_ПОРОГІВ[найкраще_TPR]:.3f}: "
f"{abs(різниці_TPR[найкраще_TPR]):.3f}")
print(f"|ΔFPR| мінімальна при t = {СІТКА_ПОРОГІВ[найкраще_FPR]:.3f}: "
f"{abs(різниці_FPR[найкраще_FPR]):.3f}")
найменший_розрив = min(EO_розрив(k, k) for k in РОБОЧІ)
assert найменший_розрив > 0.01, "спільний поріг несподівано виконав EO!"
print(f"\n✅ найменший EO-розрив на спільному порозі = {найменший_розрив:.3f} — не нуль")
print("Дві криві мають мінімуми в різних місцях, і жодна з них не тримається")
print("біля нуля там, де друга. Одним порогом обидві рівності не задовольнити.")
|ΔTPR| мінімальна при t = 0.355: 0.000 |ΔFPR| мінімальна при t = 0.820: 0.000 ✅ найменший EO-розрив на спільному порозі = 0.092 — не нуль Дві криві мають мінімуми в різних місцях, і жодна з них не тримається біля нуля там, де друга. Одним порогом обидві рівності не задовольнити.
4. Два пороги: вирівнюємо шанси¶
Якщо спільний поріг дає різні шанси, найпростіше втручання — дозволити порогам відрізнятись. Це post-processing: саму модель ми не чіпаємо, працюємо лише з її виходом.
Переберемо всі пари порогів. Дві важливі деталі:
- Відкидаємо крайні пороги. Якщо група приймається майже вся або майже ніхто, це вироджений режим, до якого ми повернемось у наступному розділі.
- Мінімізувати розрив не можна. Треба максимізувати точність за умови, що розрив не перевищує допуску ε. Інакше алгоритм знайде «ідеально справедливий» порожній розвʼязок.
ДОПУСК = 0.02 # ε: розрив менший за це вважаємо прийнятним
# розумні пари порогів: у кожній групі приймається від 10% до 90%
розумні_пари = []
for a in range(len(СІТКА_ПОРОГІВ)):
if not 0.10 <= МЕТРИКИ_A[a]["прийнято"] <= 0.90:
continue
for b in range(len(СІТКА_ПОРОГІВ)):
if 0.10 <= МЕТРИКИ_B[b]["прийнято"] <= 0.90:
розумні_пари.append((a, b))
найточніша_пара = max(розумні_пари, key=lambda пара: спільна_точність(*пара))
справедливі_пари = [пара for пара in розумні_пари if EO_розрив(*пара) <= ДОПУСК]
найточніша_справедлива = max(справедливі_пари, key=lambda пара: спільна_точність(*пара))
print(f"переглянуто пар порогів: {len(розумні_пари)}, з них справедливих: {len(справедливі_пари)}\n")
print(f"{'варіант':>34} {'t_A':>7} {'t_B':>7} {'точність':>10} {'EO-розрив':>11}")
print(f"{'один спільний поріг':>34} {СІТКА_ПОРОГІВ[найточніший_спільний]:7.3f} "
f"{СІТКА_ПОРОГІВ[найточніший_спільний]:7.3f} {ТОЧНІСТЬ_СПІЛЬНОГО:10.4f} "
f"{EO_розрив(найточніший_спільний, найточніший_спільний):11.4f}")
print(f"{'два пороги, без обмеження':>34} {СІТКА_ПОРОГІВ[найточніша_пара[0]]:7.3f} "
f"{СІТКА_ПОРОГІВ[найточніша_пара[1]]:7.3f} {спільна_точність(*найточніша_пара):10.4f} "
f"{EO_розрив(*найточніша_пара):11.4f}")
print(f"{'два пороги, EO ≤ 0.02':>34} {СІТКА_ПОРОГІВ[найточніша_справедлива[0]]:7.3f} "
f"{СІТКА_ПОРОГІВ[найточніша_справедлива[1]]:7.3f} "
f"{спільна_точність(*найточніша_справедлива):10.4f} "
f"{EO_розрив(*найточніша_справедлива):11.4f}")
ЦІНА = спільна_точність(*найточніша_пара) - спільна_точність(*найточніша_справедлива)
print(f"\nціна справедливості = {ЦІНА:.4f} ({100 * ЦІНА:.2f} відсоткових пункти)")
print("Це різниця між найкращим взагалі і найкращим серед справедливих.")
print("Вона невідʼємна завжди: обмеження звужує множину, з якої ми обираємо.")
assert ЦІНА >= 0, "ціна справедливості не може бути відʼємною!"
assert EO_розрив(*найточніша_справедлива) < EO_розрив(найточніший_спільний, найточніший_спільний)
print(f"\n✅ два пороги зменшили розрив із "
f"{EO_розрив(найточніший_спільний, найточніший_спільний):.3f} до "
f"{EO_розрив(*найточніша_справедлива):.3f}")
переглянуто пар порогів: 14008, з них справедливих: 59
варіант t_A t_B точність EO-розрив
один спільний поріг 0.455 0.455 0.8213 0.1325
два пороги, без обмеження 0.495 0.405 0.8300 0.0202
два пороги, EO ≤ 0.02 0.490 0.405 0.8263 0.0176
ціна справедливості = 0.0037 (0.37 відсоткових пункти)
Це різниця між найкращим взагалі і найкращим серед справедливих.
Вона невідʼємна завжди: обмеження звужує множину, з якої ми обираємо.
✅ два пороги зменшили розрив із 0.132 до 0.018
Скільки коштує кожен рівень строгості¶
Найчесніший спосіб побачити ціну — побудувати максимальну досяжну точність як функцію допуску ε. Крива має бути неспадною: що більший допуск, то більше пар порогів дозволено, то краще найкраще.
розриви_пар = np.array([EO_розрив(*пара) for пара in розумні_пари])
точності_пар = np.array([спільна_точність(*пара) for пара in розумні_пари])
сітка_допусків = np.array([0.005, 0.01, 0.02, 0.05, 0.10, 0.20, 0.50])
найкращі_точності = []
for ε in сітка_допусків:
дозволені = розриви_пар <= ε # пари порогів, що вкладаються в допуск
найкращі_точності.append(точності_пар[дозволені].max())
найкращі_точності = np.array(найкращі_точності)
fig, ax = plt.subplots(figsize=(9, 4.2))
ax.plot(сітка_допусків, найкращі_точності, marker="o", lw=2.4, color="teal")
ax.axhline(ТОЧНІСТЬ_СПІЛЬНОГО, color="crimson", ls="--", lw=1.8,
label="найкращий спільний поріг")
ax.set_xscale("log")
ax.set_xlabel("допуск ε на EO-розрив (лог. шкала)")
ax.set_ylabel("максимальна досяжна точність")
ax.set_title("Що строгіша вимога справедливості, то дорожче вона коштує")
ax.legend(); ax.grid(alpha=.25, which="both")
plt.tight_layout(); plt.show()
print(f"{'ε':>8} {'макс. точність':>16}")
for ε, точність in zip(сітка_допусків, найкращі_точності):
print(f"{ε:>8} {точність:16.4f}")
assert np.all(np.diff(найкращі_точності) >= -1e-12), "крива має бути неспадною!"
print("\n✅ крива неспадна — інакше ми б десь помилились в оптимізації")
print(f"Найстрогіша вимога (ε = {сітка_допусків[0]}) коштує "
f"{100 * (найкращі_точності[-1] - найкращі_точності[0]):.2f} п.п. точності")
print("порівняно з повною відсутністю обмеження.")
ε макс. точність
0.005 0.8237
0.01 0.8237
0.02 0.8263
0.05 0.8300
0.1 0.8300
0.2 0.8300
0.5 0.8300
✅ крива неспадна — інакше ми б десь помилились в оптимізації
Найстрогіша вимога (ε = 0.005) коштує 0.62 п.п. точності
порівняно з повною відсутністю обмеження.
5. Ліниві розвʼязки¶
Рухаючи пороги, легко натрапити на дві позиції, де обидва розриви рівно нульові: приймаємо всіх (TPR = FPR = 1 скрізь) або не приймаємо нікого (TPR = FPR = 0 скрізь).
Формально критерій виконано ідеально. Практично це порожні розвʼязки: модель не робить різниці ні між ким. Саме через них не можна оптимізувати лише розрив справедливості.
ліниві = [("приймаємо всіх (t = 0)", 0, 0),
("не приймаємо нікого (t = 1)", len(СІТКА_ПОРОГІВ) - 1, len(СІТКА_ПОРОГІВ) - 1)]
print(f"{'розвʼязок':>30} {'EO-розрив':>11} {'точність':>10}")
for підпис, a, b in ліниві:
print(f"{підпис:>30} {EO_розрив(a, b):11.4f} {спільна_точність(a, b):10.4f}")
print(f"{'найточніший справедливий':>30} {EO_розрив(*найточніша_справедлива):11.4f} "
f"{спільна_точність(*найточніша_справедлива):10.4f}")
for підпис, a, b in ліниві:
assert EO_розрив(a, b) < 1e-9, "лінивий розвʼязок мав дати нульовий розрив!"
assert спільна_точність(a, b) < спільна_точність(*найточніша_справедлива)
print("\n✅ обидва ліниві розвʼязки дають ідеальний нуль — і нікому не потрібні")
print("Правило: метрику справедливості завжди читають у парі з метрикою якості.")
print("Розрив 0.00 при точності 0.5 гірший за розрив 0.02 при точності 0.83.")
розвʼязок EO-розрив точність
приймаємо всіх (t = 0) 0.0000 0.4163
не приймаємо нікого (t = 1) 0.0000 0.5837
найточніший справедливий 0.0176 0.8263
✅ обидва ліниві розвʼязки дають ідеальний нуль — і нікому не потрібні
Правило: метрику справедливості завжди читають у парі з метрикою якості.
Розрив 0.00 при точності 0.5 гірший за розрив 0.02 при точності 0.83.
6. Погляд через ROC¶
ROC-крива групи — це траєкторія точки (FPR, TPR), коли поріг рухається від 1 до 0. Умова Equality of Odds стає геометричною: потрібна пара порогів, за якої точка групи A і точка групи B збігаються. Можливо це лише там, де криві перетинаються.
from sklearn.metrics import roc_auc_score
ROC_A = np.array([[м["FPR"], м["TPR"]] for м in МЕТРИКИ_A])[::-1]
ROC_B = np.array([[м["FPR"], м["TPR"]] for м in МЕТРИКИ_B])[::-1]
fig, ax = plt.subplots(figsize=(6.4, 6))
ax.plot(ROC_A[:, 0], ROC_A[:, 1], lw=2.4, color="teal", label="ROC групи A")
ax.plot(ROC_B[:, 0], ROC_B[:, 1], lw=2.4, color="crimson", label="ROC групи B")
ax.plot([0, 1], [0, 1], color="gray", ls="--", lw=1.4)
точка_A = МЕТРИКИ_A[найточніша_справедлива[0]]
точка_B = МЕТРИКИ_B[найточніша_справедлива[1]]
ax.scatter([точка_A["FPR"]], [точка_A["TPR"]], s=140, color="teal", zorder=5,
edgecolor="black", label="обрані пороги")
ax.scatter([точка_B["FPR"]], [точка_B["TPR"]], s=140, color="crimson", zorder=5,
edgecolor="black")
ax.set_xlabel("FPR"); ax.set_ylabel("TPR")
ax.set_title("EO виконано там, де точки груп зійшлися")
ax.legend(loc="lower right"); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()
print(f"AUC групи A = {roc_auc_score(мітки_A, скори_A):.3f}")
print(f"AUC групи B = {roc_auc_score(мітки_B, скори_B):.3f}")
print("\nЯкість моделі в групах майже однакова — а шанси при спільному порозі різні.")
print("Кути (0,0) і (1,1) — це ті самі ліниві розвʼязки: там криві зустрічаються завжди.")
AUC групи A = 0.897 AUC групи B = 0.888 Якість моделі в групах майже однакова — а шанси при спільному порозі різні. Кути (0,0) і (1,1) — це ті самі ліниві розвʼязки: там криві зустрічаються завжди.
7. Теорема про несумісність¶
Найважливіший результат теми — негативний. Якщо базові ставки груп різні і класифікатор не ідеальний, то виконати одночасно рівність TPR, рівність FPR і рівність PPV (точності позитивних рішень) математично неможливо.
Побачити чому найпростіше через тотожність. Позначимо базову ставку p = P(Y = 1):
PPV = p·TPR / ( p·TPR + (1 − p)·FPR )
У правій частині стоять рівно три величини. Якщо EO виконано, TPR і FPR однакові в обох групах — отже PPV лишається функцією тільки базової ставки. І якщо p різні, PPV неминуче різний.
база_A = мітки_A.mean()
база_B = мітки_B.mean()
def PPV_за_формулою(базова_ставка, TPR, FPR):
"""Та сама тотожність із лекції — записана буквально."""
чисельник = базова_ставка * TPR
знаменник = базова_ставка * TPR + (1 - базова_ставка) * FPR
return чисельник / знаменник
print(f"{'':>10} {'база p':>9} {'TPR':>8} {'FPR':>8} {'PPV з даних':>13} {'PPV з формули':>15}")
for підпис, база, точка in [("група A", база_A, точка_A), ("група B", база_B, точка_B)]:
за_формулою = PPV_за_формулою(база, точка["TPR"], точка["FPR"])
print(f"{підпис:>10} {база:9.3f} {точка['TPR']:8.3f} {точка['FPR']:8.3f} "
f"{точка['PPV']:13.3f} {за_формулою:15.3f}")
assert np.allclose(точка_A["PPV"], PPV_за_формулою(база_A, точка_A["TPR"], точка_A["FPR"]))
assert np.allclose(точка_B["PPV"], PPV_за_формулою(база_B, точка_B["TPR"], точка_B["FPR"]))
print("\n✅ тотожність PPV = p·TPR / (p·TPR + (1−p)·FPR) виконується точно")
розрив_PPV = abs(точка_A["PPV"] - точка_B["PPV"])
print(f"\nПри цих порогах EO-розрив = {EO_розрив(*найточніша_справедлива):.3f} (майже нуль),")
print(f"а розрив PPV = {розрив_PPV:.3f} — далеко не нуль.")
assert розрив_PPV > 5 * EO_розрив(*найточніша_справедлива)
print("\n✅ EO виконано, каліброваність — ні. Це не недогляд реалізації, а теорема.")
база p TPR FPR PPV з даних PPV з формули група A 0.535 0.841 0.177 0.845 0.845 група B 0.297 0.824 0.181 0.658 0.658 ✅ тотожність PPV = p·TPR / (p·TPR + (1−p)·FPR) виконується точно При цих порогах EO-розрив = 0.018 (майже нуль), а розрив PPV = 0.187 — далеко не нуль. ✅ EO виконано, каліброваність — ні. Це не недогляд реалізації, а теорема.
А якби базові ставки збіглися¶
Та сама формула підказує, коли конфлікту немає: коли p в обох групах однакове. Перевіримо це прямо — підставимо в формулу різні базові ставки при тих самих TPR і FPR (вони від базової ставки не залежать, бо рахуються всередині класів).
ставки_B = np.linspace(0.15, 0.85, 60)
PPV_A_стала = PPV_за_формулою(0.50, точка_A["TPR"], точка_A["FPR"])
PPV_B_крива = np.array([PPV_за_формулою(p, точка_A["TPR"], точка_A["FPR"]) for p in ставки_B])
fig, ax = plt.subplots(figsize=(9, 4.2))
ax.axhline(PPV_A_стала, color="teal", lw=2.4, label="PPV групи A (база 0.50)")
ax.plot(ставки_B, PPV_B_крива, lw=2.4, color="crimson", label="PPV групи B")
ax.axvline(0.50, color="gray", ls="--", lw=1.6, label="однакові базові ставки")
ax.set_xlabel("базова ставка групи B"); ax.set_ylabel("PPV")
ax.set_title("EO виконано скрізь, а PPV збігається лише в одній точці")
ax.legend(); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()
найближча = int(np.argmin(np.abs(PPV_B_крива - PPV_A_стала)))
print(f"розрив PPV мінімальний при базовій ставці B = {ставки_B[найближча]:.3f}")
print(f"а базова ставка групи A дорівнює 0.500")
assert abs(ставки_B[найближча] - 0.50) < 0.03
print("\n✅ конфлікт зникає рівно тоді, коли базові ставки однакові — і ніколи інакше")
print("Різні базові ставки — норма, і часто вони самі є наслідком тих історичних")
print("причин, з яких лекція починалась.")
розрив PPV мінімальний при базовій ставці B = 0.506 а базова ставка групи A дорівнює 0.500 ✅ конфлікт зникає рівно тоді, коли базові ставки однакові — і ніколи інакше Різні базові ставки — норма, і часто вони самі є наслідком тих історичних причин, з яких лекція починалась.
8. Пастка «модель не бачить групи»¶
Найперша ідея, яка спадає на думку: не давати моделі ознаку групи. Немає стовпця — немає й дискримінації. Цей підхід називають fairness through unawareness, і він майже ніколи не працює: інші ознаки корелюють із групою.
Зберемо навчальний приклад. Три ознаки:
- досвід — чесна ознака, від якої справді залежить результат;
- район — «нейтральне» число, яке насправді сильно корелює з групою (це і є проксі);
- група — сама ознака групи.
Навчимо три моделі на різних наборах ознак і подивимось, що станеться з розривом.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
r = np.random.default_rng(7)
КІЛЬКІСТЬ_ЛЮДЕЙ = 4000
група = r.integers(0, 2, КІЛЬКІСТЬ_ЛЮДЕЙ) # 0 = A, 1 = B
район = 2.2 * група + r.normal(0, 1, КІЛЬКІСТЬ_ЛЮДЕЙ) # проксі: тягне за собою групу
досвід = r.normal(0, 1, КІЛЬКІСТЬ_ЛЮДЕЙ) # чесна ознака
# історичні мітки: залежать від досвіду, але й від групи теж — саме це модель успадкує
логіт = 1.2 * досвід - 1.8 * група + 0.6
мітка = (r.random(КІЛЬКІСТЬ_ЛЮДЕЙ) < 1 / (1 + np.exp(-логіт))).astype(int)
ВСІ_ОЗНАКИ = np.column_stack([досвід, район, група])
навчальні, тестові = train_test_split(np.arange(КІЛЬКІСТЬ_ЛЮДЕЙ), test_size=0.4, random_state=0)
print(f"кореляція «район» із групою: {np.corrcoef(район, група)[0, 1]:.3f}")
print(f"базова ставка: група A = {мітка[група == 0].mean():.3f}, "
f"група B = {мітка[група == 1].mean():.3f}")
кореляція «район» із групою: 0.738 базова ставка: група A = 0.617, група B = 0.268
набори = [("досвід + район + група", [0, 1, 2]),
("досвід + район (групу прибрали)", [0, 1]),
("лише досвід (проксі теж прибрали)", [0])]
print(f"{'набір ознак':>36} {'точність':>10} {'ΔTPR':>8} {'ΔFPR':>8} {'групу вгадано':>15}")
результати = {}
for назва, стовпці in набори:
X = ВСІ_ОЗНАКИ[:, стовпці]
модель = LogisticRegression().fit(X[навчальні], мітка[навчальні])
рішення = модель.predict(X[тестові])
по_групах = []
for значення_групи in (0, 1):
своя = група[тестові] == значення_групи
свої_мітки, свої_рішення = мітка[тестові][своя], рішення[своя]
TPR = np.sum((свої_мітки == 1) & (свої_рішення == 1)) / np.sum(свої_мітки == 1)
FPR = np.sum((свої_мітки == 0) & (свої_рішення == 1)) / np.sum(свої_мітки == 0)
по_групах.append((TPR, FPR))
# окрема модель, яка намагається відновити групу з тих самих ознак
детектор = LogisticRegression().fit(X[навчальні], група[навчальні])
вгадано = детектор.score(X[тестові], група[тестові])
точність = np.mean(рішення == мітка[тестові])
ΔTPR = abs(по_групах[0][0] - по_групах[1][0])
ΔFPR = abs(по_групах[0][1] - по_групах[1][1])
результати[назва] = (точність, ΔTPR, ΔFPR, вгадано)
print(f"{назва:>36} {точність:10.3f} {ΔTPR:8.3f} {ΔFPR:8.3f} {вгадано:15.3f}")
з_проксі = результати["досвід + район (групу прибрали)"]
без_проксі = результати["лише досвід (проксі теж прибрали)"]
assert з_проксі[3] > 0.75, "проксі мав відновлювати групу набагато краще за монетку!"
assert без_проксі[3] < 0.60, "без проксі група мала стати невгадуваною!"
assert з_проксі[1] > 0.05, "розрив мав лишитись після видалення ознаки групи!"
print(f"\n✅ прибрали стовпець групи — а модель усе одно вгадує її "
f"з точністю {з_проксі[3]:.2f} (монетка дала б 0.50)")
print(f" і розрив нікуди не подівся: ΔTPR = {з_проксі[1]:.3f}, ΔFPR = {з_проксі[2]:.3f}")
print("\nПрибравши явний стовпець, ми не прибрали інформацію — лише позбавили себе")
print("можливості її виміряти. Щоб контролювати нерівномірність, ознаку групи")
print("треба знати: не щоб подавати в модель, а щоб рахувати метрики окремо по групах.")
print("\nЗверни увагу й на третій рядок: коли прибрати ще й проксі, група перестає")
print("вгадуватись, а розрив усе одно лишається. Він приходить не лише від проксі,")
print("а й із самих історичних міток — і жодним викиданням стовпців не лікується.")
набір ознак точність ΔTPR ΔFPR групу вгадано
досвід + район + група 0.731 0.482 0.388 1.000
досвід + район (групу прибрали) 0.706 0.188 0.197 0.865 лише досвід (проксі теж прибрали) 0.671 0.184 0.117 0.507 ✅ прибрали стовпець групи — а модель усе одно вгадує її з точністю 0.86 (монетка дала б 0.50) і розрив нікуди не подівся: ΔTPR = 0.188, ΔFPR = 0.197 Прибравши явний стовпець, ми не прибрали інформацію — лише позбавили себе можливості її виміряти. Щоб контролювати нерівномірність, ознаку групи треба знати: не щоб подавати в модель, а щоб рахувати метрики окремо по групах. Зверни увагу й на третій рядок: коли прибрати ще й проксі, група перестає вгадуватись, а розрив усе одно лишається. Він приходить не лише від проксі, а й із самих історичних міток — і жодним викиданням стовпців не лікується.
💻 Завдання¶
🟢 Рівень 1 — разом¶
- Зміни базову ставку групи B з 0.35 на 0.50 і перебудуй розділи 3-4. Що сталося з EO-розривом при спільному порозі, а що — з розривом PPV у розділі 7?
- Побудуй equal opportunity замість повного EO: шукай пари порогів, які вирівнюють лише TPR. Наскільки дешевше це обходиться?
🟡 Рівень 2 — самостійно¶
- Візьми
sklearn.datasets.fetch_openmlабо будь-який власний датасет із бінарною міткою, розбий вибірку за якоюсь ознакою (регіон, вік, тип пристрою) і порахуй EO-розрив для навченої моделі. Групою не обовʼязково має бути демографічна категорія. - Реалізуй демографічний паритет (однакова частка прийнятих у групах) і покажи на своїх даних, що його нуль лежить не там, де нуль EO.
🔴 Рівень 3 — виклик¶
- Зроби вирівнювання під час навчання, а не після: додай до втрат логістичної регресії гладкий штраф за розрив TPR і FPR (замість індикатора «s ≥ t» візьми сигмоїду) і порівняй результат із post-processing за ціною в точності.
- Побудуй рандомізований post-processing: дозволь приймати рішення з імовірністю, а не детерміновано. Покажи, що так можна досягти точнішого нуля EO-розриву, ніж будь-якою парою детермінованих порогів.
🧪 Самоперевірка¶
1. Чому TPR і FPR можна порівнювати між групами різного розміру, а кількість помилок — ні?
відповідь
TPR і FPR — це частки всередині свого справжнього класу: TPR ділиться на кількість тих, хто має Y = 1, FPR — на кількість тих, хто має Y = 0. Розмір групи скорочується. Абсолютна кількість помилок у більшої групи буде більшою просто тому, що вона більша.2. Модель дає EO-розрив 0.00 при точності 0.63. Інша — 0.04 при точності 0.82. Яка краща?
відповідь
Друга. Перша майже напевно потрапила в лінивий розвʼязок: приймає всіх або нікого, формально виконуючи критерій і не роблячи різниці ні між ким. Метрику справедливості завжди читають у парі з метрикою якості.3. Ми прибрали з датасету стовпець «група». Чи можемо ми тепер не рахувати групові метрики?