Практика 38 · Рівні шанси на помилку (Equality of Odds)¶

📖 Теорія: відкрий lecture.html у цій же теці. 📝 Домашнє завдання: homework.md · 🧪 Тест: quiz.html

Уся тема тримається на одному вмінні: рахувати TPR і FPR окремо по групах. Далі — самі лише наслідки. Тут ми порахуємо все руками й переконаємось, що жодне з тверджень лекції не потребує віри на слово.

Що зробимо:

  1. Зберемо дві групи з різними розподілами скорів і різними базовими ставками
  2. Реалізуємо TPR/FPR і звіримо зі scikit-learn
  3. Побачимо, що спільний поріг ≠ однакове ставлення
  4. Підберемо два пороги, які вирівнюють шанси, і виміряємо ціну в точності
  5. Наткнемось на ліниві розвʼязки й зрозуміємо, чому не можна оптимізувати лише розрив
  6. Побачимо на числах теорему про несумісність із каліброваністю
  7. Покажемо, що прибрати ознаку групи не допомагає: проксі відновлює її

1. Дані: дві групи, одна модель¶

Синтетичні дані, зібрані рівно так, як в інтерактивах лекції. Для кожної людини є:

  • справжня мітка Y — 1 означає «результат мав би бути позитивним»;
  • скор s — число від 0 до 1, яке видала модель.

Групи відрізняються двома речами. По-перше, базовою ставкою — часткою тих, у кого Y = 1 (в A вона 0.50, у B — 0.35). По-друге, розподілом скорів: для групи B модель систематично видає нижчі числа.

In [1]:
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) — яку частку тих, хто не мав, вона пропустила все одно.

Обидві величини рахуються всередині свого справжнього класу, тому не залежать від того, скільки в групі тих і тих. Саме тому їх можна порівнювати між групами різного розміру.

In [2]:
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 легко дістати з матриці плутанини.

In [3]:
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.

In [4]:
СПІЛЬНИЙ_ПОРІГ = 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("однакового ставлення.")
No description has been provided for this image
                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 і подивимось, чи знайдеться такий, де обидві різниці одночасно нульові.

In [5]:
СІТКА_ПОРОГІВ = 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
In [6]:
різниці_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("біля нуля там, де друга. Одним порогом обидві рівності не задовольнити.")
No description has been provided for this image
|ΔTPR| мінімальна при t = 0.355: 0.000
|ΔFPR| мінімальна при t = 0.820: 0.000

✅ найменший EO-розрив на спільному порозі = 0.092 — не нуль
Дві криві мають мінімуми в різних місцях, і жодна з них не тримається
біля нуля там, де друга. Одним порогом обидві рівності не задовольнити.

4. Два пороги: вирівнюємо шанси¶

Якщо спільний поріг дає різні шанси, найпростіше втручання — дозволити порогам відрізнятись. Це post-processing: саму модель ми не чіпаємо, працюємо лише з її виходом.

Переберемо всі пари порогів. Дві важливі деталі:

  • Відкидаємо крайні пороги. Якщо група приймається майже вся або майже ніхто, це вироджений режим, до якого ми повернемось у наступному розділі.
  • Мінімізувати розрив не можна. Треба максимізувати точність за умови, що розрив не перевищує допуску ε. Інакше алгоритм знайде «ідеально справедливий» порожній розвʼязок.
In [7]:
ДОПУСК = 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

Скільки коштує кожен рівень строгості¶

Найчесніший спосіб побачити ціну — побудувати максимальну досяжну точність як функцію допуску ε. Крива має бути неспадною: що більший допуск, то більше пар порогів дозволено, то краще найкраще.

In [8]:
розриви_пар = 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("порівняно з повною відсутністю обмеження.")
No description has been provided for this image
       ε   макс. точність
   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 скрізь).

Формально критерій виконано ідеально. Практично це порожні розвʼязки: модель не робить різниці ні між ким. Саме через них не можна оптимізувати лише розрив справедливості.

In [9]:
ліниві = [("приймаємо всіх (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 збігаються. Можливо це лише там, де криві перетинаються.

In [10]:
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) — це ті самі ліниві розвʼязки: там криві зустрічаються завжди.")
No description has been provided for this image
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 неминуче різний.

In [11]:
база_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 (вони від базової ставки не залежать, бо рахуються всередині класів).

In [12]:
ставки_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("причин, з яких лекція починалась.")
No description has been provided for this image
розрив PPV мінімальний при базовій ставці B = 0.506
а базова ставка групи A дорівнює 0.500

✅ конфлікт зникає рівно тоді, коли базові ставки однакові — і ніколи інакше
Різні базові ставки — норма, і часто вони самі є наслідком тих історичних
причин, з яких лекція починалась.

8. Пастка «модель не бачить групи»¶

Найперша ідея, яка спадає на думку: не давати моделі ознаку групи. Немає стовпця — немає й дискримінації. Цей підхід називають fairness through unawareness, і він майже ніколи не працює: інші ознаки корелюють із групою.

Зберемо навчальний приклад. Три ознаки:

  • досвід — чесна ознака, від якої справді залежить результат;
  • район — «нейтральне» число, яке насправді сильно корелює з групою (це і є проксі);
  • група — сама ознака групи.

Навчимо три моделі на різних наборах ознак і подивимось, що станеться з розривом.

In [13]:
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
In [14]:
набори = [("досвід + район + група", [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 — разом¶

  1. Зміни базову ставку групи B з 0.35 на 0.50 і перебудуй розділи 3-4. Що сталося з EO-розривом при спільному порозі, а що — з розривом PPV у розділі 7?
  2. Побудуй equal opportunity замість повного EO: шукай пари порогів, які вирівнюють лише TPR. Наскільки дешевше це обходиться?

🟡 Рівень 2 — самостійно¶

  1. Візьми sklearn.datasets.fetch_openml або будь-який власний датасет із бінарною міткою, розбий вибірку за якоюсь ознакою (регіон, вік, тип пристрою) і порахуй EO-розрив для навченої моделі. Групою не обовʼязково має бути демографічна категорія.
  2. Реалізуй демографічний паритет (однакова частка прийнятих у групах) і покажи на своїх даних, що його нуль лежить не там, де нуль EO.

🔴 Рівень 3 — виклик¶

  1. Зроби вирівнювання під час навчання, а не після: додай до втрат логістичної регресії гладкий штраф за розрив TPR і FPR (замість індикатора «s ≥ t» візьми сигмоїду) і порівняй результат із post-processing за ціною в точності.
  2. Побудуй рандомізований 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. Ми прибрали з датасету стовпець «група». Чи можемо ми тепер не рахувати групові метрики?

відповідь Навпаки — саме тепер їх рахувати найважливіше. Проксі-ознаки відновлюють групу (у розділі 8 — з точністю 0.87), тож зміщення нікуди не поділось. А от виміряти його без ознаки групи неможливо. Тому ознаку групи треба мати хоча б на етапі перевірки.