Практика 03 · Крива компромісів: ROC та AUC¶

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

Наскрізний приклад той самий, що в лекції: антифрод. Модель оцінює кожен платіж числом від 0 до 1 — наскільки він схожий на шахрайський. Рішення «блокувати чи ні» ухвалює вже поріг, і ROC — це чесний перелік усіх варіантів цього рішення одразу.

Що зробимо:

  1. Побудуємо ROC вручну на 24 платежах — сортуванням, без жодних формул
  2. Накладемо нашу криву на roc_curve зі scikit-learn і переконаємось, що вони збігаються
  3. Порахуємо AUC двома способами: площею трапецій і через частку правильно впорядкованих пар — і звіримо обидва з roc_auc_score
  4. Подивимось на чотири моделі одразу: сильну, слабку, випадкову й перевернуту
  5. Побачимо, як при дисбалансі ROC не ворухнеться, а PR обвалиться
  6. Підберемо поріг під обмеження на precision
  7. Перевіримо сліпу зону AUC: піднесемо всі ймовірності до квадрата
In [1]:
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(7)

N_SMALL = 24
FRAUD_COUNT = 8

# 24 платежі: вісім шахрайських, шістнадцять чесних
is_fraud_small = np.zeros(N_SMALL, dtype=int)
is_fraud_small[:FRAUD_COUNT] = 1

# оцінка ризику. Шахрайським модель загалом дає більше, але не завжди —
# інакше крива була б ідеальною і дивитись не було б на що
score_small = np.empty(N_SMALL)
score_small[is_fraud_small == 1] = rng.beta(5.0, 2.0, size=FRAUD_COUNT)
score_small[is_fraud_small == 0] = rng.beta(2.0, 4.0, size=N_SMALL - FRAUD_COUNT)

print(f"платежів      : {N_SMALL}")
print(f"шахрайських   : {is_fraud_small.sum()}")
print(f"чесних        : {N_SMALL - is_fraud_small.sum()}")
print(f"\nвисота одного кроку вгору   = 1/P = {1 / FRAUD_COUNT:.4f}")
print(f"ширина одного кроку праворуч = 1/N = {1 / (N_SMALL - FRAUD_COUNT):.4f}")
платежів      : 24
шахрайських   : 8
чесних        : 16

висота одного кроку вгору   = 1/P = 0.1250
ширина одного кроку праворуч = 1/N = 0.0625

1. Дві осі: TPR і FPR¶

$$TPR = \frac{TP}{TP + FN} \qquad FPR = \frac{FP}{FP + TN}$$

Головне в цих формулах — знаменники. TPR рахується тільки по справжніх позитивах, FPR — тільки по справжніх негативах. Жодна з них не знає, скільки в даних тих і тих. Саме тому ROC не змінюється, коли міняється співвідношення класів.

In [2]:
def tpr_and_fpr(y_true, y_pred):
    """TPR (він же recall) і FPR за означенням.

    Обидві частки рахуються всередині свого класу, тому пропорція класів
    у вибірці на них не впливає взагалі.
    """
    true_positive = int(np.sum((y_true == 1) & (y_pred == 1)))
    false_negative = int(np.sum((y_true == 1) & (y_pred == 0)))
    false_positive = int(np.sum((y_true == 0) & (y_pred == 1)))
    true_negative = int(np.sum((y_true == 0) & (y_pred == 0)))

    tpr = true_positive / (true_positive + false_negative)
    fpr = false_positive / (false_positive + true_negative)
    return tpr, fpr


for threshold in [0.0, 0.3, 0.5, 0.7, 1.01]:
    guess = (score_small >= threshold).astype(int)
    tpr, fpr = tpr_and_fpr(is_fraud_small, guess)
    print(f"поріг {threshold:>4}:  TPR = {tpr:.3f}   FPR = {fpr:.3f}   "
          f"заблоковано платежів: {guess.sum():>2}")

print("\nПри порозі 0 крива сидить у правому верхньому куті (1, 1):")
print("спіймали всіх шахраїв, але заблокували геть усе.")
print("При порозі вище за максимальний скор — у лівому нижньому (0, 0).")
поріг  0.0:  TPR = 1.000   FPR = 1.000   заблоковано платежів: 24
поріг  0.3:  TPR = 1.000   FPR = 0.500   заблоковано платежів: 16
поріг  0.5:  TPR = 1.000   FPR = 0.188   заблоковано платежів: 11
поріг  0.7:  TPR = 0.625   FPR = 0.000   заблоковано платежів:  5
поріг 1.01:  TPR = 0.000   FPR = 0.000   заблоковано платежів:  0

При порозі 0 крива сидить у правому верхньому куті (1, 1):
спіймали всіх шахраїв, але заблокували геть усе.
При порозі вище за максимальний скор — у лівому нижньому (0, 0).

2. ROC вручну: сортування і два види кроків¶

Алгоритм із четвертого розділу лекції, дослівно:

  1. Відсортувати всі об'єкти за скором, від найбільшого до найменшого.
  2. Почати з порога вище за максимальний скор — ми в точці (0, 0).
  3. Опускати поріг так, щоб щоразу «захоплювати» рівно один наступний об'єкт.
  4. Перерахувати TPR і FPR, поставити точку.

Кожен крок дає рівно один із двох рухів. Захопили справжній позитив — крок угору. Захопили негатив — крок праворуч. Ніякої третьої можливості немає, тому ROC на скінченній вибірці завжди сходинки.

In [3]:
def roc_manual(y_true, scores):
    """Будує ROC перебором порогів. Жодних бібліотек — саме сортування.

    Повертає (fpr, tpr, thresholds). Перша точка — (0, 0): поріг вище за
    будь-який скор, тому нічого не оголошено позитивним.
    """
    # від найбільшого скора до найменшого: саме в такому порядку ми
    # «захоплюємо» обʼєкти, опускаючи поріг
    order = np.argsort(-scores, kind="stable")
    sorted_truth = y_true[order]
    sorted_scores = scores[order]

    positives_total = int(np.sum(y_true == 1))
    negatives_total = int(np.sum(y_true == 0))

    # скільки позитивів і негативів захоплено після кожного кроку
    captured_positives = np.cumsum(sorted_truth == 1)
    captured_negatives = np.cumsum(sorted_truth == 0)

    # обʼєкти з однаковим скором мусять захоплюватись разом: поріг не вміє
    # відрізнити їх один від одного. Лишаємо тільки останній індекс кожної групи
    last_of_group = np.flatnonzero(np.diff(sorted_scores) != 0)
    keep = np.append(last_of_group, len(sorted_scores) - 1)

    tpr = np.concatenate([[0.0], captured_positives[keep] / positives_total])
    fpr = np.concatenate([[0.0], captured_negatives[keep] / negatives_total])
    thresholds = np.concatenate([[np.inf], sorted_scores[keep]])
    return fpr, tpr, thresholds


our_fpr, our_tpr, our_thresholds = roc_manual(is_fraud_small, score_small)

print(f"{'крок':>5}{'скор':>10}{'клас':>8}{'TPR':>8}{'FPR':>8}   рух")
print("-" * 48)

order = np.argsort(-score_small, kind="stable")
for step in range(len(our_fpr)):
    if step == 0:
        print(f"{0:>5}{'—':>10}{'—':>8}{0.0:>8.3f}{0.0:>8.3f}   старт (0, 0)")
        continue
    obj = order[step - 1]
    kind = "шахрай" if is_fraud_small[obj] == 1 else "чесний"
    move = "↑ угору" if is_fraud_small[obj] == 1 else "→ праворуч"
    print(f"{step:>5}{score_small[obj]:>10.4f}{kind:>8}"
          f"{our_tpr[step]:>8.3f}{our_fpr[step]:>8.3f}   {move}")
 крок      скор    клас     TPR     FPR   рух
------------------------------------------------
    0         —       —   0.000   0.000   старт (0, 0)
    1    0.9252  шахрай   0.125   0.000   ↑ угору
    2    0.9062  шахрай   0.250   0.000   ↑ угору
    3    0.8581  шахрай   0.375   0.000   ↑ угору
    4    0.7774  шахрай   0.500   0.000   ↑ угору
    5    0.7504  шахрай   0.625   0.000   ↑ угору
    6    0.6825  шахрай   0.750   0.000   ↑ угору
    7    0.6072  шахрай   0.875   0.000   ↑ угору
    8    0.6071  чесний   0.875   0.062   → праворуч
    9    0.5914  чесний   0.875   0.125   → праворуч
   10    0.5641  шахрай   1.000   0.125   ↑ угору
   11    0.5526  чесний   1.000   0.188   → праворуч
   12    0.4755  чесний   1.000   0.250   → праворуч
   13    0.4411  чесний   1.000   0.312   → праворуч
   14    0.3674  чесний   1.000   0.375   → праворуч
   15    0.3473  чесний   1.000   0.438   → праворуч
   16    0.3067  чесний   1.000   0.500   → праворуч
   17    0.2871  чесний   1.000   0.562   → праворуч
   18    0.2476  чесний   1.000   0.625   → праворуч
   19    0.1860  чесний   1.000   0.688   → праворуч
   20    0.1689  чесний   1.000   0.750   → праворуч
   21    0.1440  чесний   1.000   0.812   → праворуч
   22    0.1279  чесний   1.000   0.875   → праворуч
   23    0.1009  чесний   1.000   0.938   → праворуч
   24    0.0519  чесний   1.000   1.000   → праворуч
In [4]:
fig, ax = plt.subplots(figsize=(6.5, 6.5))

ax.step(our_fpr, our_tpr, where="post", lw=2.5, color="crimson", label="наша ROC")
ax.scatter(our_fpr, our_tpr, s=45, color="crimson", zorder=5)
ax.plot([0, 1], [0, 1], ls="--", lw=1.8, color="gray", label="випадкове вгадування")

ax.set_xlabel("FPR — частка даремно заблокованих чесних платежів")
ax.set_ylabel("TPR — частка спійманого шахрайства")
ax.set_title(f"ROC на {N_SMALL} платежах: видно кожну сходинку")
ax.set_xlim(-0.02, 1.02)
ax.set_ylim(-0.02, 1.02)
ax.legend(loc="lower right")
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

print("Сходинки великі саме тому, що обʼєктів мало: висота кроку 1/8 = 0.125,")
print("ширина 1/16 = 0.0625. На тисячі платежів крива виглядала б гладкою,")
print("але залишалася б такими самими сходинками.")
No description has been provided for this image
Сходинки великі саме тому, що обʼєктів мало: висота кроку 1/8 = 0.125,
ширина 1/16 = 0.0625. На тисячі платежів крива виглядала б гладкою,
але залишалася б такими самими сходинками.

Звірка з roc_curve¶

scikit-learn за замовчуванням викидає проміжні точки, які лежать на одній прямій (drop_intermediate=True) — щоб крива займала менше памʼяті. Нам потрібні всі точки, тому вимикаємо це й порівнюємо масиви поелементно.

In [5]:
from sklearn.metrics import roc_curve

sk_fpr, sk_tpr, sk_thresholds = roc_curve(
    is_fraud_small, score_small, drop_intermediate=False)

print(f"точок у нашій кривій   : {len(our_fpr)}")
print(f"точок у кривій sklearn : {len(sk_fpr)}")
print(f"\nнаші FPR   : {np.round(our_fpr[:6], 4)} ...")
print(f"sklearn FPR: {np.round(sk_fpr[:6], 4)} ...")

assert np.allclose(our_fpr, sk_fpr), "FPR розійшовся!"
assert np.allclose(our_tpr, sk_tpr), "TPR розійшовся!"
print("\n✅ крива збіглася точка в точку — усередині бібліотеки те саме сортування")
точок у нашій кривій   : 25
точок у кривій sklearn : 25

наші FPR   : [0. 0. 0. 0. 0. 0.] ...
sklearn FPR: [0. 0. 0. 0. 0. 0.] ...

✅ крива збіглася точка в точку — усередині бібліотеки те саме сортування

3. AUC двома способами¶

Спосіб перший — геометричний. Площа під ламаною = сума площ трапецій. Площа однієї трапеції — це ширина, помножена на півсуму двох висот:

$$S_i = (FPR_i - FPR_{i-1}) \cdot \frac{TPR_i + TPR_{i-1}}{2}$$

Спосіб другий — ймовірнісний. AUC дорівнює ймовірності того, що випадково взятий шахрайський платіж отримає вищий скор, ніж випадково взятий чесний. Це означення, а не аналогія: беремо всі пари «шахрай × чесний» і рахуємо частку правильно впорядкованих. Нічия рахується за половину.

In [6]:
def auc_by_area(fpr, tpr):
    """Площа під ламаною через трапеції."""
    widths = np.diff(fpr)
    average_heights = (tpr[1:] + tpr[:-1]) / 2
    return float(np.sum(widths * average_heights))


def auc_by_pairs(y_true, scores):
    """Частка правильно впорядкованих пар «позитив × негатив».

    Беремо кожен позитив проти кожного негативу. Якщо скор позитиву вищий —
    один бал, якщо однаковий — пів бала, якщо нижчий — нуль.
    """
    positive_scores = scores[y_true == 1]
    negative_scores = scores[y_true == 0]

    # таблиця порівнянь: рядки — позитиви, стовпці — негативи
    higher = positive_scores[:, None] > negative_scores[None, :]
    equal = positive_scores[:, None] == negative_scores[None, :]
    return float(np.mean(higher + 0.5 * equal))


from sklearn.metrics import roc_auc_score

area_auc = auc_by_area(our_fpr, our_tpr)
pairs_auc = auc_by_pairs(is_fraud_small, score_small)
library_auc = roc_auc_score(is_fraud_small, score_small)

total_pairs = int(np.sum(is_fraud_small == 1)) * int(np.sum(is_fraud_small == 0))

print(f"площею трапецій        : {area_auc:.10f}")
print(f"часткою правильних пар : {pairs_auc:.10f}   (усього пар: {total_pairs})")
print(f"sklearn roc_auc_score  : {library_auc:.10f}")

assert np.allclose(area_auc, library_auc), "площа розійшлася!"
assert np.allclose(pairs_auc, library_auc), "підрахунок пар розійшовся!"
print("\n✅ усі три числа збігаються")
print("\nЦе не збіг: площа під ROC і частка правильно впорядкованих пар —")
print("одна й та сама статистика (у математичній статистиці — критерій Манна–Вітні).")
площею трапецій        : 0.9843750000
часткою правильних пар : 0.9843750000   (усього пар: 128)
sklearn roc_auc_score  : 0.9843750000

✅ усі три числа збігаються

Це не збіг: площа під ROC і частка правильно впорядкованих пар —
одна й та сама статистика (у математичній статистиці — критерій Манна–Вітні).

Кидання пар: те саме означення на практиці¶

Якщо AUC — це ймовірність, її можна просто виміряти. Кидаємо навмання одну пару «шахрай × чесний», питаємо, чий скор вищий, і рахуємо частку перемог. Подивимось, як оцінка збігається до точного значення.

In [7]:
THROWS = 3000

fraud_scores = score_small[is_fraud_small == 1]
honest_scores = score_small[is_fraud_small == 0]

thrower = np.random.default_rng(1)
picked_fraud = thrower.choice(fraud_scores, size=THROWS)
picked_honest = thrower.choice(honest_scores, size=THROWS)

# 1 — порядок правильний, 0.5 — нічия, 0 — модель переплутала
outcome = (picked_fraud > picked_honest) + 0.5 * (picked_fraud == picked_honest)
running_estimate = np.cumsum(outcome) / np.arange(1, THROWS + 1)

for n in [10, 100, 1000, THROWS]:
    print(f"після {n:>5} кидків оцінка = {running_estimate[n - 1]:.4f}")
print(f"точний AUC                 = {library_auc:.4f}")
після    10 кидків оцінка = 1.0000
після   100 кидків оцінка = 0.9900
після  1000 кидків оцінка = 0.9880
після  3000 кидків оцінка = 0.9870
точний AUC                 = 0.9844
In [8]:
fig, ax = plt.subplots(figsize=(9, 4.5))

ax.plot(np.arange(1, THROWS + 1), running_estimate, lw=1.5, color="crimson",
        label="оцінка киданням пар")
ax.axhline(library_auc, color="teal", ls="--", lw=2, label=f"точний AUC = {library_auc:.3f}")

ax.set_xscale("log")
ax.set_xlabel("кинуто пар (лог. шкала)")
ax.set_ylabel("частка правильно впорядкованих")
ax.set_title("Означення AUC, перевірене киданням монетки")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

print(f"похибка після {THROWS} кидків: "
      f"{abs(running_estimate[-1] - library_auc):.4f}")
No description has been provided for this image
похибка після 3000 кидків: 0.0026

4. Чотири моделі на одній площині¶

Форма кривої одразу каже, з чим ми маємо справу. Візьмемо 1000 платежів і чотири моделі: сильну, слабку, випадкову й перевернуту (у якої переплутані знаки).

In [9]:
N_BIG = 1000
FRAUD_SHARE = 0.20


def make_payments(separation, size=N_BIG, fraud_share=FRAUD_SHARE, seed=3):
    """Генерує платежі й скори моделі заданої сили.

    separation — наскільки далеко рознесені розподіли скорів двох класів.
    Нуль означає, що модель не несе жодної інформації про клас.
    """
    generator = np.random.default_rng(seed)
    n_fraud = int(size * fraud_share)
    truth = np.zeros(size, dtype=int)
    truth[:n_fraud] = 1

    # прихована «схильність до шахрайства», яку модель бачить із шумом
    latent = generator.normal(0, 1, size) + truth * separation
    scores = 1 / (1 + np.exp(-latent))          # стискаємо в діапазон (0, 1)
    return truth, scores


models = {}
truth_big, strong_scores = make_payments(separation=2.6)
models["сильна"] = strong_scores
models["слабка"] = make_payments(separation=0.9)[1]
models["випадкова"] = make_payments(separation=0.0)[1]
models["перевернута"] = 1 - strong_scores     # ті самі знання, переплутані знаки

print(f"{'модель':>14}{'AUC':>9}")
print("-" * 23)
for name, scores in models.items():
    print(f"{name:>14}{roc_auc_score(truth_big, scores):>9.3f}")

print("\nПеревернута модель дає AUC нижче 0.5 — це не безнадійна модель,")
print("а переплутані знаки. Інвертуй прогноз, і крива віддзеркалиться")
print("над діагональ: 1 − AUC.")
print(f"перевірка: 1 − {roc_auc_score(truth_big, models['перевернута']):.3f} = "
      f"{1 - roc_auc_score(truth_big, models['перевернута']):.3f} = AUC сильної моделі")
        модель      AUC
-----------------------
        сильна    0.965
        слабка    0.740
     випадкова    0.502
   перевернута    0.035

Перевернута модель дає AUC нижче 0.5 — це не безнадійна модель,
а переплутані знаки. Інвертуй прогноз, і крива віддзеркалиться
над діагональ: 1 − AUC.
перевірка: 1 − 0.035 = 0.965 = AUC сильної моделі
In [10]:
fig, ax = plt.subplots(figsize=(6.5, 6.5))

colors = {"сильна": "crimson", "слабка": "darkorange",
          "випадкова": "gray", "перевернута": "teal"}

for name, scores in models.items():
    fpr, tpr, _ = roc_manual(truth_big, scores)
    ax.plot(fpr, tpr, lw=2.2, color=colors[name],
            label=f"{name} (AUC = {roc_auc_score(truth_big, scores):.3f})")

ax.plot([0, 1], [0, 1], ls="--", lw=1.5, color="black", alpha=0.4)

ax.set_xlabel("FPR")
ax.set_ylabel("TPR")
ax.set_title("Що сильніше перетинаються розподіли скорів, то ближче крива до діагоналі")
ax.legend(loc="lower right")
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

print("Крива перевернутої моделі — дзеркальне відображення сильної відносно")
print("діагоналі. Це видно й на графіку, і в числах: 0.035 і 0.965 у сумі дають 1.")
print("Випадкова модель лягла на діагональ: жоден поріг не дає їй переваги.")
No description has been provided for this image
Крива перевернутої моделі — дзеркальне відображення сильної відносно
діагоналі. Це видно й на графіку, і в числах: 0.035 і 0.965 у сумі дають 1.
Випадкова модель лягла на діагональ: жоден поріг не дає їй переваги.

5. Дисбаланс: ROC не ворухнеться, PR обвалиться¶

Тепер найпрактичніший розділ. Модель лишається буквально тією самою — змінюється лише частка шахрайства в потоці платежів.

ROC не помітить нічого: у TPR і FPR різні знаменники, і жоден із них не знає про пропорцію класів. А precision = TP / (TP + FP) змішує обидва класи в одному знаменнику — і тому бачить дисбаланс.

In [11]:
from sklearn.metrics import average_precision_score, precision_recall_curve

print(f"{'частка шахрайства':>18}{'AUC (ROC)':>12}{'AP (PR)':>10}"
      f"{'база PR':>10}{'AP / база':>11}")
print("-" * 61)

imbalance_results = {}
for share in [0.50, 0.20, 0.05, 0.01]:
    truth, scores = make_payments(separation=2.6, size=4000, fraud_share=share, seed=11)
    auc_value = roc_auc_score(truth, scores)
    ap_value = average_precision_score(truth, scores)
    imbalance_results[share] = (truth, scores)
    print(f"{share:>17.0%}{auc_value:>12.3f}{ap_value:>10.3f}"
          f"{share:>10.3f}{ap_value / share:>11.1f}")

print("\nAUC стоїть як укопаний — модель та сама, і ROC це чесно показує.")
print("AP падає разом із часткою позитивів, бо разом із нею падає і базовий")
print("рівень. Останній стовпчик — у скільки разів модель краща за випадковість —")
print("тримається набагато стабільніше. Саме його і треба дивитись.")
 частка шахрайства   AUC (ROC)   AP (PR)   база PR  AP / база
-------------------------------------------------------------
              50%       0.966     0.967     0.500        1.9
              20%       0.968     0.902     0.200        4.5
               5%       0.973     0.757     0.050       15.1
               1%       0.971     0.477     0.010       47.7

AUC стоїть як укопаний — модель та сама, і ROC це чесно показує.
AP падає разом із часткою позитивів, бо разом із нею падає і базовий
рівень. Останній стовпчик — у скільки разів модель краща за випадковість —
тримається набагато стабільніше. Саме його і треба дивитись.
In [12]:
fig, (left, right) = plt.subplots(1, 2, figsize=(13, 5.5))

for share, color in zip([0.50, 0.20, 0.05, 0.01],
                        ["crimson", "darkorange", "teal", "purple"]):
    truth, scores = imbalance_results[share]

    fpr, tpr, _ = roc_manual(truth, scores)
    left.plot(fpr, tpr, lw=2.2, color=color,
              label=f"{share:.0%} (AUC {roc_auc_score(truth, scores):.3f})")

    precision, recall, _ = precision_recall_curve(truth, scores)
    right.plot(recall, precision, lw=2.2, color=color,
               label=f"{share:.0%} (AP {average_precision_score(truth, scores):.3f})")
    right.axhline(share, color=color, ls=":", lw=1.2)

left.plot([0, 1], [0, 1], ls="--", lw=1.5, color="black", alpha=0.4)
left.set_xlabel("FPR"); left.set_ylabel("TPR")
left.set_title("ROC: чотири криві лежать одна на одній")
left.legend(loc="lower right"); left.grid(alpha=0.25)

right.set_xlabel("recall"); right.set_ylabel("precision")
right.set_title("PR: та сама модель, крива обвалюється")
right.set_ylim(0, 1.02)
right.legend(loc="upper right"); right.grid(alpha=0.25)

plt.tight_layout()
plt.show()

print("Пунктири праворуч — базові рівні PR. Вони дорівнюють частці позитивних")
print("і повзуть униз разом із нею. Базовий рівень ROC — завжди 0.5.")
No description has been provided for this image
Пунктири праворуч — базові рівні PR. Вони дорівнюють частці позитивних
і повзуть униз разом із нею. Базовий рівень ROC — завжди 0.5.

Скільки сміття доведеться розгрібати¶

Ось конкретне число, якого ROC не показує ніколи. Візьмемо FPR = 0.05 — виглядає скромно. Порахуємо, скільки це хибних тривог у штуках при різній частці шахрайства.

In [13]:
TARGET_FPR = 0.05

print(f"{'частка шахрайства':>18}{'шахрайств':>11}{'хибних тривог':>15}"
      f"{'на 1 спійманого':>17}")
print("-" * 61)

for share in [0.50, 0.20, 0.05, 0.01]:
    truth, scores = imbalance_results[share]
    fpr, tpr, thresholds = roc_manual(truth, scores)

    # найближча точка кривої з FPR не вище за цільовий
    usable = np.flatnonzero(fpr <= TARGET_FPR)
    point = usable[-1]

    guess = (scores >= thresholds[point]).astype(int)
    caught = int(np.sum((truth == 1) & (guess == 1)))
    false_alarms = int(np.sum((truth == 0) & (guess == 1)))

    print(f"{share:>17.0%}{int(truth.sum()):>11}{false_alarms:>15}"
          f"{false_alarms / max(caught, 1):>17.2f}")

print(f"\nFPR = {TARGET_FPR} усюди однаковий, а робота аналітика — ні.")
print("При 1% шахрайства на кожен спійманий платіж припадає кілька хибних")
print("тривог. ROC цього не покаже, бо у її знаменниках немає співвідношення")
print("класів. PR-крива покаже одразу.")
 частка шахрайства  шахрайств  хибних тривог  на 1 спійманого
-------------------------------------------------------------
              50%       2000            100             0.06
              20%        800            160             0.24
               5%        200            190             1.10
               1%         40            198             5.66

FPR = 0.05 усюди однаковий, а робота аналітика — ні.
При 1% шахрайства на кожен спійманий платіж припадає кілька хибних
тривог. ROC цього не покаже, бо у її знаменниках немає співвідношення
класів. PR-крива покаже одразу.

6. Поріг під обмеження на precision¶

AUC відповідає на питання «яка модель краща». Питання «який поріг узяти» вона не розвʼязує взагалі — для нього потрібна бізнес-вимога.

Типова вимога антифроду звучить так: не більше однієї помилки на п'ять блокувань, тобто precision ≥ 0,80. Серед усіх порогів, які її задовольняють, беремо той, що дає найбільший recall.

In [14]:
REQUIRED_PRECISION = 0.80

truth_work, scores_work = imbalance_results[0.20]
precision_curve, recall_curve, pr_thresholds = precision_recall_curve(
    truth_work, scores_work)

# останній елемент precision_recall_curve — службова точка (1, 0) без порогу
precision_at_thresholds = precision_curve[:-1]
recall_at_thresholds = recall_curve[:-1]

good_enough = precision_at_thresholds >= REQUIRED_PRECISION
best_index = int(np.argmax(np.where(good_enough, recall_at_thresholds, -1)))
chosen_threshold = pr_thresholds[best_index]

print(f"порогів, що задовольняють вимогу: {int(np.sum(good_enough))} "
      f"з {len(pr_thresholds)}")
print(f"обраний поріг : {chosen_threshold:.4f}")
print(f"precision     : {precision_at_thresholds[best_index]:.3f}")
print(f"recall        : {recall_at_thresholds[best_index]:.3f}")
порогів, що задовольняють вимогу: 835 з 4000
обраний поріг : 0.8334
precision     : 0.800
recall        : 0.835
In [15]:
for label, threshold in [("поріг 0.50 (за замовчуванням)", 0.5),
                         (f"поріг {chosen_threshold:.3f} (під вимогу)", chosen_threshold)]:
    guess = (scores_work >= threshold).astype(int)
    tp = int(np.sum((truth_work == 1) & (guess == 1)))
    fp = int(np.sum((truth_work == 0) & (guess == 1)))
    fn = int(np.sum((truth_work == 1) & (guess == 0)))
    tn = int(np.sum((truth_work == 0) & (guess == 0)))
    tpr, fpr = tpr_and_fpr(truth_work, guess)

    print(f"{label}")
    print(f"   TP={tp:<5} FP={fp:<5} FN={fn:<5} TN={tn}")
    print(f"   precision={tp / max(tp + fp, 1):.3f}   TPR={tpr:.3f}   FPR={fpr:.3f}\n")

print("Два кроки, дві різні метрики: модель обираємо за AUC, поріг — за")
print("бізнес-вимогою. Плутати їх не можна.")
поріг 0.50 (за замовчуванням)
   TP=797   FP=1639  FN=3     TN=1561
   precision=0.327   TPR=0.996   FPR=0.512

поріг 0.833 (під вимогу)
   TP=668   FP=167   FN=132   TN=3033
   precision=0.800   TPR=0.835   FPR=0.052

Два кроки, дві різні метрики: модель обираємо за AUC, поріг — за
бізнес-вимогою. Плутати їх не можна.

7. Сліпа зона AUC: порядок є, калібрування немає¶

AUC залежить лише від порядку скорів. Піднесемо всі ймовірності до квадрата: порядок збережеться (зведення в квадрат монотонне на додатних числах), а самі числа зміняться помітно. AUC цього не побачить узагалі.

А тепер найцікавіше: подивись, у який бік при цьому поїде Brier score — метрика, яка міряє не порядок, а те, наскільки ймовірність схожа на правду.

In [16]:
from sklearn.metrics import brier_score_loss

honest_probabilities = models["сильна"]
squared_probabilities = honest_probabilities ** 2

auc_before = roc_auc_score(truth_big, honest_probabilities)
auc_after = roc_auc_score(truth_big, squared_probabilities)

print(f"AUC до зведення в квадрат  : {auc_before:.10f}")
print(f"AUC після                  : {auc_after:.10f}")

assert np.allclose(auc_before, auc_after), "AUC не мала змінитись!"
print("✅ AUC не змінилась ані на десятимільйонну — вона бачить лише порядок\n")

print(f"частка шахрайства у даних  : {truth_big.mean():.3f}")
print(f"середня ймовірність до     : {honest_probabilities.mean():.3f}")
print(f"середня ймовірність після  : {squared_probabilities.mean():.3f}")
print(f"\nBrier score до   : {brier_score_loss(truth_big, honest_probabilities):.4f}")
print(f"Brier score після: {brier_score_loss(truth_big, squared_probabilities):.4f}")
print("(менше — краще: це середній квадрат відхилення ймовірності від правди)")

brier_before = brier_score_loss(truth_big, honest_probabilities)
brier_after = brier_score_loss(truth_big, squared_probabilities)

if brier_after < brier_before:
    print("\nЗверни увагу на напрямок: зведення в квадрат зробило ймовірності")
    print("БЛИЖЧИМИ до правди. Початкова модель завищувала ризик (середня оцінка")
    print(f"{honest_probabilities.mean():.3f} при реальній частці {truth_big.mean():.3f}), і квадрат")
    print("випадково підтягнув її до реальності.")
else:
    print("\nЗведення в квадрат погіршило калібрування: ймовірності поїхали")
    print("далі від реальної частки позитивних.")

print("\nАле головне не в напрямку, а в тому, що AUC не ворухнулась ані на")
print("десятимільйонну. Два набори ймовірностей, помітно різні за якістю")
print("калібрування, для неї абсолютно однакові — вона бачить виключно порядок.")
print("Якщо тобі потрібне число, яке справді означає «20% ризику», AUC тут не")
print("помічник: потрібні калібрувальні криві й Brier score.")
AUC до зведення в квадрат  : 0.9653437500
AUC після                  : 0.9653437500
✅ AUC не змінилась ані на десятимільйонну — вона бачить лише порядок

частка шахрайства у даних  : 0.200
середня ймовірність до     : 0.588
середня ймовірність після  : 0.407

Brier score до   : 0.2449
Brier score після: 0.1199
(менше — краще: це середній квадрат відхилення ймовірності від правди)

Зверни увагу на напрямок: зведення в квадрат зробило ймовірності
БЛИЖЧИМИ до правди. Початкова модель завищувала ризик (середня оцінка
0.588 при реальній частці 0.200), і квадрат
випадково підтягнув її до реальності.

Але головне не в напрямку, а в тому, що AUC не ворухнулась ані на
десятимільйонну. Два набори ймовірностей, помітно різні за якістю
калібрування, для неї абсолютно однакові — вона бачить виключно порядок.
Якщо тобі потрібне число, яке справді означає «20% ризику», AUC тут не
помічник: потрібні калібрувальні криві й Brier score.

💻 Завдання¶

🟢 Рівень 1 — разом¶

  1. Зміни FRAUD_COUNT на 4 і перебудуй ROC на 24 платежах. Якою стала висота одного кроку вгору? Скільки точок тепер у кривій?
  2. Постав separation = 0 у make_payments і побудуй ROC. Наскільки крива відхиляється від діагоналі й чому вона не лежить на ній рівно?

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

  1. Додай у roc_manual підтримку нічиїх: зроби так, щоб кілька платежів з однаковим скором давали одну діагональну сходинку, а не дві окремі. Зроблено, якщо твоя крива збігається з roc_curve на даних, де скори округлені до одного знака після коми.
  2. Порівняй дві моделі з AUC 0.88 і 0.90, у яких переваги лежать у різних зонах FPR. Зроблено, якщо ти показав діапазон порогів, у якому «гірша» за AUC модель насправді краща, і пояснив, для якої задачі обрав би саме її.

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

  1. Порахуй довірчий інтервал для AUC методом bootstrap: 1000 разів візьми вибірку з поверненням і подивись на розкид. Зроблено, якщо ти назвав 95% інтервал і відповів, чи значуща різниця 0.88 проти 0.90 на 200 об'єктах.
  2. Побудуй опуклу оболонку ROC (ROC convex hull) і поясни, що означають точки, які лежать під нею. Підказка: будь-яку точку під оболонкою можна побити випадковою сумішшю двох сусідніх порогів.

🧪 Самоперевірка¶

1. AUC моделі дорівнює 0.23. Що робити?

відповідь Інвертувати прогноз: `1 - scores` дасть AUC 0.77. Значення нижче 0.5 означає, що модель систематично дає негативам вищий скор — тобто знання в неї є, просто знаки переплутані. Найчастіша причина — переплутані мітки класів у коді.

2. Чому ROC не змінюється при зміні частки класів, а PR — змінюється?

відповідь У TPR знаменник — усі справжні позитиви, у FPR — усі справжні негативи. Кожна частка живе всередині свого класу й не знає про інший. А в precision знаменник TP + FP змішує обидва класи: чим більше негативів, тим більше FP при тому самому порозі.

3. Модель A має AUC 0.90, модель B — 0.88. Яку брати для антифроду, де припустимо блокувати не більше 1% чесних платежів?

відповідь Треба дивитись на криві в зоні FPR ≤ 0.01, а не на площу. AUC підсумовує всі пороги з однаковою вагою, включно з тими, які ти ніколи не використаєш. Модель B цілком може бути кращою саме там, де ти працюєш.

4. Ми піднесли всі ймовірності до квадрата, і AUC не змінилась. Чи означає це, що модель не постраждала?

відповідь Ні. Не постраждало впорядкування — а разом із ним і здатність обирати поріг. Але самі ймовірності перестали означати ризик: середня оцінка тепер набагато нижча за реальну частку позитивних. Якщо число моделі йде далі в розрахунок очікуваних збитків, така модель уже непридатна.