Практика 03 · Крива компромісів: ROC та AUC¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md· 🧪 Тест:quiz.html
Наскрізний приклад той самий, що в лекції: антифрод. Модель оцінює кожен платіж числом від 0 до 1 — наскільки він схожий на шахрайський. Рішення «блокувати чи ні» ухвалює вже поріг, і ROC — це чесний перелік усіх варіантів цього рішення одразу.
Що зробимо:
- Побудуємо ROC вручну на 24 платежах — сортуванням, без жодних формул
- Накладемо нашу криву на
roc_curveзіscikit-learnі переконаємось, що вони збігаються - Порахуємо AUC двома способами: площею трапецій і через частку правильно
впорядкованих пар — і звіримо обидва з
roc_auc_score - Подивимось на чотири моделі одразу: сильну, слабку, випадкову й перевернуту
- Побачимо, як при дисбалансі ROC не ворухнеться, а PR обвалиться
- Підберемо поріг під обмеження на precision
- Перевіримо сліпу зону AUC: піднесемо всі ймовірності до квадрата
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 не змінюється, коли міняється співвідношення класів.
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 вручну: сортування і два види кроків¶
Алгоритм із четвертого розділу лекції, дослівно:
- Відсортувати всі об'єкти за скором, від найбільшого до найменшого.
- Почати з порога вище за максимальний скор — ми в точці (0, 0).
- Опускати поріг так, щоб щоразу «захоплювати» рівно один наступний об'єкт.
- Перерахувати TPR і FPR, поставити точку.
Кожен крок дає рівно один із двох рухів. Захопили справжній позитив — крок угору. Захопили негатив — крок праворуч. Ніякої третьої можливості немає, тому ROC на скінченній вибірці завжди сходинки.
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 → праворуч
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("але залишалася б такими самими сходинками.")
Сходинки великі саме тому, що обʼєктів мало: висота кроку 1/8 = 0.125, ширина 1/16 = 0.0625. На тисячі платежів крива виглядала б гладкою, але залишалася б такими самими сходинками.
Звірка з roc_curve¶
scikit-learn за замовчуванням викидає проміжні точки, які лежать на одній
прямій (drop_intermediate=True) — щоб крива займала менше памʼяті. Нам потрібні
всі точки, тому вимикаємо це й порівнюємо масиви поелементно.
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 дорівнює ймовірності того, що випадково взятий шахрайський платіж отримає вищий скор, ніж випадково взятий чесний. Це означення, а не аналогія: беремо всі пари «шахрай × чесний» і рахуємо частку правильно впорядкованих. Нічия рахується за половину.
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 — це ймовірність, її можна просто виміряти. Кидаємо навмання одну пару «шахрай × чесний», питаємо, чий скор вищий, і рахуємо частку перемог. Подивимось, як оцінка збігається до точного значення.
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
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}")
похибка після 3000 кидків: 0.0026
4. Чотири моделі на одній площині¶
Форма кривої одразу каже, з чим ми маємо справу. Візьмемо 1000 платежів і чотири моделі: сильну, слабку, випадкову й перевернуту (у якої переплутані знаки).
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 сильної моделі
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("Випадкова модель лягла на діагональ: жоден поріг не дає їй переваги.")
Крива перевернутої моделі — дзеркальне відображення сильної відносно діагоналі. Це видно й на графіку, і в числах: 0.035 і 0.965 у сумі дають 1. Випадкова модель лягла на діагональ: жоден поріг не дає їй переваги.
5. Дисбаланс: ROC не ворухнеться, PR обвалиться¶
Тепер найпрактичніший розділ. Модель лишається буквально тією самою — змінюється лише частка шахрайства в потоці платежів.
ROC не помітить нічого: у TPR і FPR різні знаменники, і жоден із них не знає про пропорцію класів. А precision = TP / (TP + FP) змішує обидва класи в одному знаменнику — і тому бачить дисбаланс.
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 падає разом із часткою позитивів, бо разом із нею падає і базовий
рівень. Останній стовпчик — у скільки разів модель краща за випадковість —
тримається набагато стабільніше. Саме його і треба дивитись.
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.")
Пунктири праворуч — базові рівні PR. Вони дорівнюють частці позитивних і повзуть униз разом із нею. Базовий рівень ROC — завжди 0.5.
Скільки сміття доведеться розгрібати¶
Ось конкретне число, якого ROC не показує ніколи. Візьмемо FPR = 0.05 — виглядає скромно. Порахуємо, скільки це хибних тривог у штуках при різній частці шахрайства.
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.
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
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 — метрика, яка міряє не порядок, а те, наскільки ймовірність схожа на правду.
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 — разом¶
- Зміни
FRAUD_COUNTна 4 і перебудуй ROC на 24 платежах. Якою стала висота одного кроку вгору? Скільки точок тепер у кривій? - Постав
separation = 0уmake_paymentsі побудуй ROC. Наскільки крива відхиляється від діагоналі й чому вона не лежить на ній рівно?
🟡 Рівень 2 — самостійно¶
- Додай у
roc_manualпідтримку нічиїх: зроби так, щоб кілька платежів з однаковим скором давали одну діагональну сходинку, а не дві окремі. Зроблено, якщо твоя крива збігається зroc_curveна даних, де скори округлені до одного знака після коми. - Порівняй дві моделі з AUC 0.88 і 0.90, у яких переваги лежать у різних зонах FPR. Зроблено, якщо ти показав діапазон порогів, у якому «гірша» за AUC модель насправді краща, і пояснив, для якої задачі обрав би саме її.
🔴 Рівень 3 — виклик¶
- Порахуй довірчий інтервал для AUC методом bootstrap: 1000 разів візьми вибірку з поверненням і подивись на розкид. Зроблено, якщо ти назвав 95% інтервал і відповів, чи значуща різниця 0.88 проти 0.90 на 200 об'єктах.
- Побудуй опуклу оболонку 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 не змінилась. Чи означає це, що модель не постраждала?