Практика 02 · Ціна помилки: precision і recall¶

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

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

Що зробимо:

  1. Складемо матрицю плутанини з нуля — самими логічними масками, без бібліотек
  2. Порахуємо precision, recall і F1 руками й звіримо зі sklearn.metrics
  3. Відтворимо історію з лекції: accuracy 99,9% при recall рівно 0
  4. Подивимось на всі метрики як на функції порогу
  5. Підберемо поріг під вимогу «recall не нижче 0,98» і побачимо, чим за це платимо
  6. Побудуємо PR-криву та порахуємо average precision
  7. Знайдемо поріг за економічним критерієм CFN / CFP
In [1]:
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

N_PATIENTS = 240
PREVALENCE = 0.25          # чверть обстежених справді хворі

# справжній діагноз: 1 — хворий, 0 — здоровий
is_sick = (rng.random(N_PATIENTS) < PREVALENCE).astype(int)

# оцінка моделі. Розподіли двох груп перекриваються — саме тому й існує
# компроміс між precision і recall: жоден поріг не розділить їх ідеально
model_score = np.empty(N_PATIENTS)
model_score[is_sick == 1] = rng.beta(5.0, 2.0, size=int(np.sum(is_sick == 1)))
model_score[is_sick == 0] = rng.beta(2.0, 5.0, size=int(np.sum(is_sick == 0)))

print(f"обстежено пацієнтів : {N_PATIENTS}")
print(f"насправді хворих    : {is_sick.sum()} ({is_sick.mean():.1%})")
print(f"середня оцінка хворих   : {model_score[is_sick == 1].mean():.3f}")
print(f"середня оцінка здорових : {model_score[is_sick == 0].mean():.3f}")
обстежено пацієнтів : 240
насправді хворих    : 63 (26.2%)
середня оцінка хворих   : 0.708
середня оцінка здорових : 0.276
In [2]:
fig, ax = plt.subplots(figsize=(9, 4))

bins = np.linspace(0, 1, 31)
ax.hist(model_score[is_sick == 0], bins=bins, alpha=0.65, color="teal", label="здорові")
ax.hist(model_score[is_sick == 1], bins=bins, alpha=0.65, color="crimson", label="хворі")
ax.axvline(0.5, color="gray", ls="--", lw=2, label="поріг t = 0.5")

ax.set_xlabel("оцінка моделі")
ax.set_ylabel("скільки пацієнтів")
ax.set_title("Дві групи перекриваються — і в цьому вся суть компромісу")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

overlap = np.sum((model_score > 0.35) & (model_score < 0.65))
print(f"у зоні перекриття (0.35–0.65) сидить {overlap} пацієнтів обох груп —")
print("будь-яка вертикальна межа розріже їх навпіл, хоч би де ми її поставили")
No description has been provided for this image
у зоні перекриття (0.35–0.65) сидить 67 пацієнтів обох груп —
будь-яка вертикальна межа розріже їх навпіл, хоч би де ми її поставили

1. Поріг перетворює оцінку на рішення¶

Модель не каже «хворий». Вона каже 0.83. Рішення робимо ми:

$$\hat{y} = 1 \text{, якщо } \hat{p} \ge t \qquad \hat{y} = 0 \text{, якщо } \hat{p} < t$$

Значення t = 0.5 стоїть у бібліотеках за замовчуванням, і від цього виникає ілюзія, що половина — щось природне. Це просто зручне число.

In [3]:
THRESHOLD = 0.5

# один рядок, який перетворює оцінку на діагноз
prediction = (model_score >= THRESHOLD).astype(int)

print(f"поріг t = {THRESHOLD}")
print(f"модель оголосила хворими : {prediction.sum()} пацієнтів")
print(f"насправді хворих         : {is_sick.sum()} пацієнтів")
print(f"\nчисла різні — і саме з цієї різниці народжуються всі метрики нижче")
поріг t = 0.5
модель оголосила хворими : 82 пацієнтів
насправді хворих         : 63 пацієнтів

числа різні — і саме з цієї різниці народжуються всі метрики нижче

2. Матриця плутанини з нуля¶

Чотири числа. Кожен пацієнт потрапляє рівно в одне з них — на перетині двох питань: яка правда і що сказала модель.

модель каже «хворий» модель каже «здоровий»
насправді хворий TP — влучання FN — пропуск
насправді здоровий FP — хибна тривога TN — правильна тиша

Ніяких бібліотек тут не потрібно: достатньо логічних масок.

In [4]:
def confusion_counts(y_true, y_pred):
    """Рахує TP, FP, TN, FN прямо за означенням.

    Кожен рядок — це одна з чотирьох клітинок таблиці. Множення масок не
    потрібне: логічне «і» через & робить те саме й читається зрозуміліше.
    """
    true_positive = int(np.sum((y_true == 1) & (y_pred == 1)))
    false_positive = int(np.sum((y_true == 0) & (y_pred == 1)))
    true_negative = int(np.sum((y_true == 0) & (y_pred == 0)))
    false_negative = int(np.sum((y_true == 1) & (y_pred == 0)))
    return true_positive, false_positive, true_negative, false_negative


tp, fp, tn, fn = confusion_counts(is_sick, prediction)

print(f"                     модель: хворий   модель: здоровий")
print(f"насправді хворий  {tp:>14}   {fn:>17}")
print(f"насправді здоровий{fp:>14}   {tn:>17}")
print(f"\nсума всіх чотирьох = {tp + fp + tn + fn}, пацієнтів = {N_PATIENTS}")

assert tp + fp + tn + fn == N_PATIENTS, "хтось із пацієнтів загубився!"
print("✅ жоден пацієнт не порахований двічі й жоден не втрачений")
                     модель: хворий   модель: здоровий
насправді хворий              56                   7
насправді здоровий            26                 151

сума всіх чотирьох = 240, пацієнтів = 240
✅ жоден пацієнт не порахований двічі й жоден не втрачений

Звірка з бібліотекою¶

confusion_matrix зі scikit-learn повертає ту саму таблицю, але у своєму порядку: рядки — правда, стовпці — прогноз, класи за зростанням. Тобто

[[TN, FP],
 [FN, TP]]

Складемо нашу матрицю в тому самому порядку й порівняємо поелементно.

In [5]:
from sklearn.metrics import confusion_matrix

our_matrix = np.array([[tn, fp],
                       [fn, tp]])
library_matrix = confusion_matrix(is_sick, prediction)

print("наша матриця:")
print(our_matrix)
print("\nsklearn confusion_matrix:")
print(library_matrix)

assert np.allclose(our_matrix, library_matrix), "матриці розійшлися!"
print("\n✅ збігається — усередині бібліотеки рівно ті самі чотири підрахунки")
наша матриця:
[[151  26]
 [  7  56]]

sklearn confusion_matrix:
[[151  26]
 [  7  56]]

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

3. Precision, recall і F1 руками¶

Три формули, кожна — дріб із тих самих чотирьох чисел:

$$\text{precision} = \frac{TP}{TP + FP} \qquad \text{recall} = \frac{TP}{TP + FN} \qquad F_1 = \frac{2 \cdot P \cdot R}{P + R}$$

Precision дивиться на стовпець матриці (все, що модель назвала позитивним), recall — на рядок (усі, хто справді позитивний).

In [6]:
def precision_recall_f1(y_true, y_pred):
    """Три метрики за означенням. Нуль у знаменнику обробляємо явно.

    Precision не визначена, коли модель не зробила жодного позитивного прогнозу:
    це не «поганий precision», а відсутність відповіді. Повертаємо 0.0, щоб
    поводитись так само, як бібліотека, але памʼятаємо про різницю.
    """
    tp, fp, tn, fn = confusion_counts(y_true, y_pred)

    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0

    if precision + recall == 0:
        f1 = 0.0
    else:
        f1 = 2 * precision * recall / (precision + recall)
    return precision, recall, f1


our_precision, our_recall, our_f1 = precision_recall_f1(is_sick, prediction)

print(f"TP = {tp}, FP = {fp}, TN = {tn}, FN = {fn}\n")
print(f"precision = {tp} / ({tp} + {fp}) = {our_precision:.4f}")
print(f"recall    = {tp} / ({tp} + {fn}) = {our_recall:.4f}")
print(f"F1        = {our_f1:.4f}")
print(f"accuracy  = ({tp} + {tn}) / {N_PATIENTS} = {(tp + tn) / N_PATIENTS:.4f}")
TP = 56, FP = 26, TN = 151, FN = 7

precision = 56 / (56 + 26) = 0.6829
recall    = 56 / (56 + 7) = 0.8889
F1        = 0.7724
accuracy  = (56 + 151) / 240 = 0.8625
In [7]:
from sklearn.metrics import precision_score, recall_score, f1_score, classification_report

library_values = [
    precision_score(is_sick, prediction),
    recall_score(is_sick, prediction),
    f1_score(is_sick, prediction),
]
our_values = [our_precision, our_recall, our_f1]

print(f"{'метрика':<12}{'наша':>12}{'sklearn':>12}")
print("-" * 36)
for name, ours, theirs in zip(["precision", "recall", "F1"], our_values, library_values):
    print(f"{name:<12}{ours:>12.8f}{theirs:>12.8f}")

assert np.allclose(our_values, library_values), "розрахунок розійшовся!"
print("\n✅ збігається\n")

print(classification_report(is_sick, prediction,
                            target_names=["здоровий", "хворий"], digits=3))
метрика             наша     sklearn
------------------------------------
precision     0.68292683  0.68292683
recall        0.88888889  0.88888889
F1            0.77241379  0.77241379

✅ збігається

              precision    recall  f1-score   support

    здоровий      0.956     0.853     0.901       177
      хворий      0.683     0.889     0.772        63

    accuracy                          0.863       240
   macro avg      0.819     0.871     0.837       240
weighted avg      0.884     0.863     0.868       240

4. Історія, з якої починалася лекція¶

Лікарня, рідкісна хвороба, «точність 99,4%». Відтворимо арифметику точно так, як вона зроблена в третьому розділі лекції: 10 000 обстежених, 10 хворих, і модель-порожнеча, яка всім підряд пише «здоровий».

In [8]:
POPULATION = 10_000
SICK_COUNT = 10

# модель-порожнеча: не дивиться на дані взагалі
empty_true = np.zeros(POPULATION, dtype=int)
empty_true[:SICK_COUNT] = 1
empty_pred = np.zeros(POPULATION, dtype=int)   # завжди «здоровий»

e_tp, e_fp, e_tn, e_fn = confusion_counts(empty_true, empty_pred)
e_precision, e_recall, e_f1 = precision_recall_f1(empty_true, empty_pred)

print(f"TP = {e_tp}, FP = {e_fp}, TN = {e_tn}, FN = {e_fn}\n")
print(f"accuracy  = ({e_tp} + {e_tn}) / {POPULATION} = {(e_tp + e_tn) / POPULATION:.4f}")
print(f"recall    = {e_tp} / ({e_tp} + {e_fn}) = {e_recall:.4f}")
print(f"precision = не визначена (жодного позитивного прогнозу), "
      f"бібліотека віддає {e_precision:.1f}")

print("\nДва числа описують ту саму модель: 99,9% і 0%. Обидва правильні.")
print("Перше рахує всі відповіді разом, друге дивиться лише на клас,")
print("заради якого систему й будували.")
TP = 0, FP = 0, TN = 9990, FN = 10

accuracy  = (0 + 9990) / 10000 = 0.9990
recall    = 0 / (0 + 10) = 0.0000
precision = не визначена (жодного позитивного прогнозу), бібліотека віддає 0.0

Два числа описують ту саму модель: 99,9% і 0%. Обидва правильні.
Перше рахує всі відповіді разом, друге дивиться лише на клас,
заради якого систему й будували.

Та сама модель у популяціях із різною поширеністю¶

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

In [9]:
def resample_population(target_prevalence, size=4000, seed=0):
    """Збирає популяцію із заданою часткою хворих, беручи оцінки з тих самих
    двох розподілів. Модель при цьому лишається буквально тією самою."""
    generator = np.random.default_rng(seed)
    n_sick = int(size * target_prevalence)
    truth = np.zeros(size, dtype=int)
    truth[:n_sick] = 1
    scores = np.empty(size)
    scores[:n_sick] = generator.beta(5.0, 2.0, size=n_sick)
    scores[n_sick:] = generator.beta(2.0, 5.0, size=size - n_sick)
    return truth, scores


print(f"{'частка хворих':>14}{'accuracy':>10}{'«усі здорові»':>15}"
      f"{'precision':>11}{'recall':>9}")
print("-" * 59)

for share in [0.50, 0.30, 0.10, 0.05, 0.01]:
    truth, scores = resample_population(share)
    guess = (scores >= 0.5).astype(int)
    p, r, _ = precision_recall_f1(truth, guess)
    accuracy = np.mean(guess == truth)
    trivial = 1 - share                      # accuracy моделі, яка мовчить завжди
    print(f"{share:>13.0%}{accuracy:>10.3f}{trivial:>15.3f}{p:>11.3f}{r:>9.3f}")

print("\nRecall не змінюється взагалі: він дивиться лише на хворих.")
print("Accuracy моделі теж майже стоїть на місці, а от accuracy тривіальної")
print("моделі росте разом із дисбалансом і врешті обганяє справжню.")
print("Precision тим часом обвалюється: хибні тривоги набираються з дедалі")
print("більшої маси здорових.")
 частка хворих  accuracy  «усі здорові»  precision   recall
-----------------------------------------------------------
          50%     0.893          0.500      0.895    0.891
          30%     0.890          0.700      0.772    0.901
          10%     0.891          0.900      0.475    0.895
           5%     0.892          0.950      0.304    0.895
           1%     0.890          0.990      0.075    0.875

Recall не змінюється взагалі: він дивиться лише на хворих.
Accuracy моделі теж майже стоїть на місці, а от accuracy тривіальної
моделі росте разом із дисбалансом і врешті обганяє справжню.
Precision тим часом обвалюється: хибні тривоги набираються з дедалі
більшої маси здорових.

5. Усі метрики як функції порогу¶

Повернемось до наших 240 пацієнтів. Модель зафіксована, змінюємо лише поріг — і дивимось, як поводяться чотири метрики одночасно.

In [10]:
thresholds = np.linspace(0.0, 1.0, 101)

accuracy_curve = np.zeros_like(thresholds)
precision_curve = np.zeros_like(thresholds)
recall_curve = np.zeros_like(thresholds)
f1_curve = np.zeros_like(thresholds)

for i, t in enumerate(thresholds):
    guess = (model_score >= t).astype(int)
    p, r, f = precision_recall_f1(is_sick, guess)
    accuracy_curve[i] = np.mean(guess == is_sick)
    precision_curve[i] = p
    recall_curve[i] = r
    f1_curve[i] = f

best_f1_at = thresholds[np.argmax(f1_curve)]
majority_share = max(is_sick.mean(), 1 - is_sick.mean())

# «плато accuracy» — усі пороги, на яких вона відрізняється від найкращої
# менш ніж на 3 пункти. З погляду accuracy це все однаково хороші рішення
ACCURACY_TOLERANCE = 0.03
plateau = accuracy_curve >= accuracy_curve.max() - ACCURACY_TOLERANCE

print(f"максимум F1 = {f1_curve.max():.3f} досягається на порозі t = {best_f1_at:.2f}")
print(f"найкраща accuracy                : {accuracy_curve.max():.3f}")
print(f"частка більшого класу (здорових) : {majority_share:.3f}")

print(f"\nплато accuracy (не гірше за максимум на {ACCURACY_TOLERANCE}):")
print(f"  пороги   : від {thresholds[plateau].min():.2f} до {thresholds[plateau].max():.2f}")
print(f"  accuracy : від {accuracy_curve[plateau].min():.3f} "
      f"до {accuracy_curve[plateau].max():.3f}  (розмах {np.ptp(accuracy_curve[plateau]):.3f})")
print(f"  recall   : від {recall_curve[plateau].min():.3f} "
      f"до {recall_curve[plateau].max():.3f}  (розмах {np.ptp(recall_curve[plateau]):.3f})")

missed_at_best_recall = 1 - recall_curve[plateau].max()
missed_at_worst_recall = 1 - recall_curve[plateau].min()

print("\nОсь і вся претензія до accuracy. Усередині цього плато вона вважає всі")
print(f"пороги однаково хорошими: різниця між ними менша за {ACCURACY_TOLERANCE}.")
print(f"А recall тим часом гуляє від {recall_curve[plateau].min():.2f} "
      f"до {recall_curve[plateau].max():.2f} —")
print(f"це різниця між «пропустили {missed_at_best_recall:.0%} хворих» і "
      f"«пропустили {missed_at_worst_recall:.0%}».")
print("Для accuracy ці два рішення однакові. Для лікарні — ні.")
максимум F1 = 0.850 досягається на порозі t = 0.61
найкраща accuracy                : 0.929
частка більшого класу (здорових) : 0.738

плато accuracy (не гірше за максимум на 0.03):
  пороги   : від 0.55 до 0.67
  accuracy : від 0.900 до 0.929  (розмах 0.029)
  recall   : від 0.619 до 0.857  (розмах 0.238)

Ось і вся претензія до accuracy. Усередині цього плато вона вважає всі
пороги однаково хорошими: різниця між ними менша за 0.03.
А recall тим часом гуляє від 0.62 до 0.86 —
це різниця між «пропустили 14% хворих» і «пропустили 38%».
Для accuracy ці два рішення однакові. Для лікарні — ні.
In [11]:
fig, ax = plt.subplots(figsize=(9.5, 5))

ax.plot(thresholds, accuracy_curve, lw=2, color="gray", label="accuracy")
ax.plot(thresholds, precision_curve, lw=2, color="teal", label="precision")
ax.plot(thresholds, recall_curve, lw=2, color="crimson", label="recall")
ax.plot(thresholds, f1_curve, lw=2.5, color="darkorange", label="F1")
ax.axvline(best_f1_at, color="darkorange", ls=":", lw=1.6,
           label=f"максимум F1 (t = {best_f1_at:.2f})")

ax.set_xlabel("поріг t")
ax.set_ylabel("значення метрики")
ax.set_title("Одна модель, 101 поріг")
ax.set_ylim(0, 1.02)
ax.legend(loc="lower center", ncol=2)
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

print("Recall спадає монотонно: піднімаючи поріг, ми лише втрачаємо хворих.")
print("Precision загалом росте: кожен вирок стає надійнішим.")
print("F1 має чіткий максимум між ними — це і є компроміс у чистому вигляді.")
No description has been provided for this image
Recall спадає монотонно: піднімаючи поріг, ми лише втрачаємо хворих.
Precision загалом росте: кожен вирок стає надійнішим.
F1 має чіткий максимум між ними — це і є компроміс у чистому вигляді.

6. Поріг під вимогу «recall не нижче 0,98»¶

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

Логіка проста: recall монотонно спадає з порогом, тож беремо найвищий поріг, який ще задовольняє вимогу. Вище — вимога ламається, нижче — ми даремно платимо хибними тривогами.

In [12]:
REQUIRED_RECALL = 0.98

# усі пороги, на яких вимога ще виконується
allowed = thresholds[recall_curve >= REQUIRED_RECALL]
chosen_threshold = allowed.max()

guess_at_default = (model_score >= 0.5).astype(int)
guess_at_chosen = (model_score >= chosen_threshold).astype(int)

print(f"найвищий поріг, на якому recall ще ≥ {REQUIRED_RECALL}: "
      f"t = {chosen_threshold:.2f}\n")

for label, guess in [("поріг 0.50", guess_at_default),
                     (f"поріг {chosen_threshold:.2f}", guess_at_chosen)]:
    tp_, fp_, tn_, fn_ = confusion_counts(is_sick, guess)
    p_, r_, f_ = precision_recall_f1(is_sick, guess)
    print(f"{label:<22} recall={r_:.3f}  precision={p_:.3f}  F1={f_:.3f}")
    print(f"{'':<22} TP={tp_:<4} FP={fp_:<4} FN={fn_:<4} TN={tn_}")
найвищий поріг, на якому recall ще ≥ 0.98: t = 0.29

поріг 0.50             recall=0.889  precision=0.683  F1=0.772
                       TP=56   FP=26   FN=7    TN=151
поріг 0.29             recall=0.984  precision=0.473  F1=0.639
                       TP=62   FP=69   FN=1    TN=108
In [13]:
tp_default, fp_default, _, fn_default = confusion_counts(is_sick, guess_at_default)
tp_chosen, fp_chosen, _, fn_chosen = confusion_counts(is_sick, guess_at_chosen)

print(f"пропущених хворих: було {fn_default}, стало {fn_chosen} "
      f"(врятували {fn_default - fn_chosen})")
print(f"хибних тривог    : було {fp_default}, стало {fp_chosen} "
      f"(доплатили {fp_chosen - fp_default})")

cost_per_saved = (fp_chosen - fp_default) / max(fn_default - fn_chosen, 1)
print(f"\nЦіна вимоги: {cost_per_saved:.1f} зайвих обстежень за кожного")
print("додатково знайденого хворого. Це число і треба нести замовнику —")
print(f"саме воно, а не абстрактне «recall {REQUIRED_RECALL}», дозволяє ухвалити рішення.")
пропущених хворих: було 7, стало 1 (врятували 6)
хибних тривог    : було 26, стало 69 (доплатили 43)

Ціна вимоги: 7.2 зайвих обстежень за кожного
додатково знайденого хворого. Це число і треба нести замовнику —
саме воно, а не абстрактне «recall 0.98», дозволяє ухвалити рішення.

7. F-beta: коли одна помилка дорожча за іншу¶

$$F_\beta = (1 + \beta^2) \cdot \frac{P \cdot R}{\beta^2 P + R}$$

Читається так: recall важить у β² разів більше за precision. При β = 2 recall учетверо важливіший (медичний скринінг), при β = 0.5 — учетверо важливіший precision (спам-фільтр).

Порахуємо руками й звіримо з fbeta_score, а заодно подивимось, куди їде оптимальний поріг зі зміною β.

In [14]:
from sklearn.metrics import fbeta_score


def f_beta(precision, recall, beta):
    """F-beta за означенням. При beta=1 має збігтися зі звичайним F1."""
    if precision + recall == 0:
        return 0.0
    numerator = (1 + beta ** 2) * precision * recall
    denominator = beta ** 2 * precision + recall
    return numerator / denominator


our_fbeta = [f_beta(our_precision, our_recall, b) for b in [0.5, 1.0, 2.0]]
library_fbeta = [fbeta_score(is_sick, prediction, beta=b) for b in [0.5, 1.0, 2.0]]

print(f"{'beta':>6}{'наша':>14}{'sklearn':>14}")
print("-" * 34)
for beta, ours, theirs in zip([0.5, 1.0, 2.0], our_fbeta, library_fbeta):
    print(f"{beta:>6}{ours:>14.8f}{theirs:>14.8f}")

assert np.allclose(our_fbeta, library_fbeta), "розрахунок розійшовся!"
print("\n✅ збігається")
  beta          наша       sklearn
----------------------------------
   0.5    0.71611253    0.71611253
   1.0    0.77241379    0.77241379
   2.0    0.83832335    0.83832335

✅ збігається
In [15]:
print(f"{'beta':>6}   {'сенс':<32}{'найкращий поріг':>17}{'recall':>9}{'precision':>11}")
print("-" * 75)

for beta, meaning in [(0.25, "precision важить майже все"),
                      (0.5, "precision учетверо важливіший"),
                      (1.0, "рівновага"),
                      (2.0, "recall учетверо важливіший"),
                      (4.0, "recall важить майже все")]:
    curve = np.array([f_beta(p, r, beta)
                      for p, r in zip(precision_curve, recall_curve)])
    best = int(np.argmax(curve))
    print(f"{beta:>6}   {meaning:<32}{thresholds[best]:>17.2f}"
          f"{recall_curve[best]:>9.3f}{precision_curve[best]:>11.3f}")

print("\nβ — це буквально ручка, якою ти плавно переїжджаєш від одної метрики")
print("до іншої, а разом із нею їде й оптимальний поріг.")
  beta   сенс                              найкращий поріг   recall  precision
---------------------------------------------------------------------------
  0.25   precision важить майже все                   0.63    0.683      1.000
   0.5   precision учетверо важливіший                0.63    0.683      1.000
   1.0   рівновага                                    0.61    0.762      0.960
   2.0   recall учетверо важливіший                   0.48    0.937      0.694
   4.0   recall важить майже все                      0.29    0.984      0.473

β — це буквально ручка, якою ти плавно переїжджаєш від одної метрики
до іншої, а разом із нею їде й оптимальний поріг.

8. PR-крива і average precision¶

Викинемо поріг із координат і побудуємо залежність precision від recall. Кожна точка кривої — це один поріг.

Площа під нею називається average precision (AP). Важлива деталь: базовий рівень PR-кривої дорівнює частці позитивних у вибірці. При 25% хворих випадковий класифікатор дасть AP ≈ 0,25 — і саме з цим числом треба порівнювати.

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

sk_precision, sk_recall, sk_thresholds = precision_recall_curve(is_sick, model_score)

# рахуємо ті самі метрики самі, рівно на тих порогах, які повернула бібліотека
our_precision_curve = np.zeros(len(sk_thresholds))
our_recall_curve = np.zeros(len(sk_thresholds))

for i, t in enumerate(sk_thresholds):
    guess = (model_score >= t).astype(int)
    p, r, _ = precision_recall_f1(is_sick, guess)
    our_precision_curve[i] = p
    our_recall_curve[i] = r

print(f"порогів у кривій: {len(sk_thresholds)}")
print(f"перші три наші precision   : {np.round(our_precision_curve[:3], 6)}")
print(f"перші три sklearn precision: {np.round(sk_precision[:3], 6)}")

assert np.allclose(our_precision_curve, sk_precision[:-1]), "precision розійшовся!"
assert np.allclose(our_recall_curve, sk_recall[:-1]), "recall розійшовся!"
print("\n✅ уся крива збіглася точка в точку")
порогів у кривій: 240
перші три наші precision   : [0.2625   0.263598 0.264706]
перші три sklearn precision: [0.2625   0.263598 0.264706]

✅ уся крива збіглася точка в точку
In [17]:
# AP за означенням: сума приростів recall, помножених на precision у цій точці
recall_steps = -np.diff(sk_recall)          # recall у sklearn спадає, тому мінус
our_average_precision = float(np.sum(recall_steps * sk_precision[:-1]))
library_average_precision = average_precision_score(is_sick, model_score)

print(f"наш AP     : {our_average_precision:.8f}")
print(f"sklearn AP : {library_average_precision:.8f}")

assert np.allclose(our_average_precision, library_average_precision), "AP розійшовся!"
print("✅ збігається")

print(f"\nбазовий рівень (частка хворих) : {is_sick.mean():.3f}")
print(f"наша модель                    : {our_average_precision:.3f}")
print(f"тобто в {our_average_precision / is_sick.mean():.1f} раза краще за випадковість")
наш AP     : 0.93057288
sklearn AP : 0.93057288
✅ збігається

базовий рівень (частка хворих) : 0.263
наша модель                    : 0.931
тобто в 3.5 раза краще за випадковість
In [18]:
fig, ax = plt.subplots(figsize=(7.5, 5.5))

ax.plot(sk_recall, sk_precision, lw=2.5, color="crimson", label="PR-крива моделі")
ax.axhline(is_sick.mean(), color="gray", ls="--", lw=1.8,
           label=f"випадковий класифікатор ({is_sick.mean():.2f})")

# позначимо на кривій дві робочі точки, про які йшлося вище
for t, color, name in [(0.5, "teal", "t = 0.50"),
                       (chosen_threshold, "darkorange", f"t = {chosen_threshold:.2f}")]:
    guess = (model_score >= t).astype(int)
    p, r, _ = precision_recall_f1(is_sick, guess)
    ax.scatter([r], [p], s=90, color=color, zorder=5, label=f"{name}: P={p:.2f}, R={r:.2f}")

ax.set_xlabel("recall")
ax.set_ylabel("precision")
ax.set_title(f"PR-крива, AP = {library_average_precision:.3f}")
ax.set_xlim(0, 1.02)
ax.set_ylim(0, 1.05)
ax.legend(loc="lower left")
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

print("Дві точки на кривій — це два рішення, які ми ухвалили вище.")
print("Крива від цього не змінилась: вона описує модель, а не наш вибір.")
print("Обрати точку на ній — окрема робота, і робить її людина.")
No description has been provided for this image
Дві точки на кривій — це два рішення, які ми ухвалили вище.
Крива від цього не змінилась: вона описує модель, а не наш вибір.
Обрати точку на ній — окрема робота, і робить її людина.

9. Економіка: поріг за вартістю помилок¶

Найчесніший спосіб обрати поріг — приписати кожному виду помилки вартість і мінімізувати сумарні втрати:

$$\text{втрати}(t) = C_{FP} \cdot FP(t) + C_{FN} \cdot FN(t)$$

Абсолютні величини не потрібні — досить відношення CFN / CFP. Візьмемо умови шостого інтерактиву лекції: популяція 1000 випадків, поширеність позитивного класу 8%.

In [19]:
cost_truth, cost_scores = resample_population(0.08, size=1000, seed=5)

print(f"популяція : {len(cost_truth)}")
print(f"позитивних: {cost_truth.sum()} ({cost_truth.mean():.1%})")


def total_loss(y_true, scores, threshold, cost_ratio):
    """Сумарні втрати при заданому порозі. C_FP приймаємо за одиницю,
    тоді C_FN дорівнює просто відношенню вартостей."""
    guess = (scores >= threshold).astype(int)
    _, false_positive, _, false_negative = confusion_counts(y_true, guess)
    return cost_ratio * false_negative + 1.0 * false_positive


print(f"\n{'C_FN / C_FP':>12}{'оптимальний поріг':>20}{'FP':>7}{'FN':>7}{'втрати':>10}")
print("-" * 56)

loss_curves = {}
for ratio in [1, 3, 10, 30]:
    losses = np.array([total_loss(cost_truth, cost_scores, t, ratio) for t in thresholds])
    loss_curves[ratio] = losses
    best = int(np.argmin(losses))
    guess = (cost_scores >= thresholds[best]).astype(int)
    _, fp_, _, fn_ = confusion_counts(cost_truth, guess)
    print(f"{ratio:>12}{thresholds[best]:>20.2f}{fp_:>7}{fn_:>7}{losses[best]:>10.0f}")

print("\nЗі зростанням C_FN оптимальний поріг їде вліво — система стає")
print("підозріливішою. При відношенні 1:1 оптимум навпаки заходить далеко")
print("за 0,5: позитивних лише 8%, тож хибні тривоги накопичуються швидше.")
популяція : 1000
позитивних: 80 (8.0%)

 C_FN / C_FP   оптимальний поріг     FP     FN    втрати
--------------------------------------------------------
           1                0.66     13     33        46
           3                0.56     47     16        95
          10                0.45    126      6       186
          30                0.41    178      3       268

Зі зростанням C_FN оптимальний поріг їде вліво — система стає
підозріливішою. При відношенні 1:1 оптимум навпаки заходить далеко
за 0,5: позитивних лише 8%, тож хибні тривоги накопичуються швидше.
In [20]:
fig, ax = plt.subplots(figsize=(9.5, 5))

for ratio, color in zip([1, 3, 10, 30], ["gray", "teal", "darkorange", "crimson"]):
    losses = loss_curves[ratio]
    ax.plot(thresholds, losses, lw=2, color=color, label=f"C_FN/C_FP = {ratio}")
    best = int(np.argmin(losses))
    ax.scatter([thresholds[best]], [losses[best]], s=70, color=color, zorder=5)

ax.set_yscale("log")
ax.set_xlabel("поріг t")
ax.set_ylabel("сумарні втрати (лог. шкала)")
ax.set_title("Оптимальний поріг — це економічне рішення, а не 0.5")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()

print("Точки — мінімуми. Жоден із них не припадає рівно на 0.5.")
No description has been provided for this image
Точки — мінімуми. Жоден із них не припадає рівно на 0.5.

💻 Завдання¶

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

  1. Постав THRESHOLD = 0.0 і THRESHOLD = 1.0. Порахуй матрицю плутанини для кожного випадку. Чому precision в одному з них не визначена?
  2. Зміни PREVALENCE на 0.02. На скільки впаде precision при тому самому порозі? Recall при цьому зміниться?

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

  1. Додай до розділу 6 вимогу «precision не нижче 0,90» і знайди поріг під неї. Скільки хворих доведеться пропустити? Зроблено, якщо ти назвав обидва числа й пояснив, який із двох порогів узяв би для онкоскринінгу, а який — для спам-фільтра.
  2. Побудуй PR-криву для двох моделей різної якості (зміни параметри rng.beta, щоб розподіли розійшлися далі) і порівняй їхні AP.

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

  1. Реалізуй макро-, мікро- і зважене усереднення F1 для трьох класів із нуля. Звір із f1_score(..., average='macro' / 'micro' / 'weighted'). Зроблено, якщо всі три збіглися з бібліотечними до 10⁻⁸ і ти показав випадок, у якому мікро-F1 виглядає пристойно, а макро провалюється.
  2. Візьми будь-який реальний незбалансований датасет зі sklearn.datasets, навчи логістичну регресію й підбери поріг за критерієм мінімуму втрат при CFN/CFP = 20. Порівняй із порогом 0.5.

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

1. Поріг зсунули з 0.5 на 0.8. Що станеться з recall і precision?

відповідь Recall може лише впасти (частина хворих із оцінками між 0.5 і 0.8 тепер проходить як здорові). Precision зазвичай зросте, бо в позитивних лишаються тільки найвпевненіші випадки. «Зазвичай», а не «завжди»: на маленькій вибірці кілька невдалих об'єктів можуть його й опустити.

2. Модель дає precision 1.00 і recall 0.02. Арифметичне середнє — 0.51, F1 — 0.039. Чому правильне саме друге?

відповідь Гармонійне середнє притягується до меншого з двох чисел. Модель, яка знайшла 2% хворих, безкорисна для скринінгу, і давати їй половину балів немає підстав. Арифметичне середнє дозволяє «купити» бали однією ідеальною метрикою.

3. У формулі F1 немає TN. Це вада чи властивість?

відповідь Залежить від задачі. Для рідкісного позитивного класу це перевага: TN там мільйони, і вони затопили б будь-який показник. Але якщо класи рівноправні й тебе цікавить симетрична якість, F1 не та метрика — потрібні specificity або збалансована accuracy.

4. Чому оптимальний поріг при C_FN/C_FP = 1 виявився вищим за 0.5?

відповідь Позитивних лише 8%. Кожен крок порогу вниз чіпляє хибні тривоги з великої маси здорових і рятує мало пропусків. Коли обидві помилки коштують однаково, вигідно мовчати частіше — тому мінімум втрат їде вправо.