Materiały kursów
Materiały / Machine Learning
Pobierz notatnik (.ipynb)

Ćwiczenie 5: Klasyfikacja i metryki

Po co to ćwiczenie?

W ćwiczeniu 01 pojawił się model odniesienia (ang. baseline), który zawsze odpowiadał „ten pacjent jest zdrowy". Nie patrzył na wyniki badań, nie miał żadnej wiedzy o medycynie - a mimo to jego skuteczność (ang. accuracy) wyniosła około 66,6%, bo dokładnie tyle osób w zbiorze jest zdrowych.

Ten model nie wykrył ani jednego chorego pacjenta. Zostało wtedy obiecane, że metrykami zajmiemy się w ćwiczeniu 05. To jest właśnie to ćwiczenie.

Pytanie, na które odpowiadamy, brzmi: jak zmierzyć jakość klasyfikatora tak, żeby zobaczyć to, czego skuteczność nie pokazuje? A przy okazji drugie, w praktyce ważniejsze:

W diagnostyce medycznej przeoczenie chorego kosztuje więcej niż fałszywy alarm. Skoro tak, to dlaczego model ma stawiać diagnozę przy progu 0,5 - liczbie, która nie wzięła się z żadnej analizy kosztów, tylko z domyślnego ustawienia biblioteki?

Domyślny próg 0,5 prawie nigdy nie jest właściwym wyborem. To ćwiczenie pokazuje, skąd to wiadomo i co z tym zrobić.

Czego się nauczysz

  1. Dlaczego sama skuteczność kłamie, gdy klasy są niezrównoważone (ang. imbalanced classes).
  2. Jak czytać macierz pomyłek (ang. confusion matrix) i co każde z jej czterech pól oznacza dla pacjenta.
  3. Czym różni się precyzja (ang. precision) od czułości (ang. recall) i dlaczego rosną kosztem siebie nawzajem.
  4. Kiedy sięgać po F1, a kiedy ta metryka jest złym pomysłem.
  5. Jak czytać classification_report.
  6. Jak przesunąć próg decyzyjny (ang. decision threshold) przy użyciu predict_proba - i dlaczego to najtańszy sposób na poprawę modelu.
  7. Jak korzystać z krzywej ROC i pola pod nią (AUC) oraz z krzywej precyzja-czułość - i która z nich jest uczciwsza przy niezrównoważonych klasach.

Zanim zaczniesz: uruchamiaj komórki po kolei (Shift+Enter). Późniejsze korzystają ze zmiennych zdefiniowanych wcześniej.

1. Punkt wyjścia: te same dane, te same dwa modele

Zaczynamy dokładnie tam, gdzie skończyło się ćwiczenie 01: zbiór dane/diabetes.csv, PatientID wyrzucony z cech (to identyfikator, nie wynik badania), podział ze stratyfikacją i ustalonym ziarnem losowości.

Budujemy dwa modele, bo cały sens tego ćwiczenia polega na ich porównywaniu:

  • model_odniesienia - zawsze odpowiada klasą większościową (ang. majority class), czyli „zdrowy",
  • model - regresja logistyczna (ang. logistic regression) w potoku ze skalowaniem.
In [ ]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier

dane = pd.read_csv('dane/diabetes.csv')

X = dane.drop(columns=['PatientID', 'Diabetic'])   # PatientID to identyfikator, nie cecha
y = dane['Diabetic']

X_ucz, X_test, y_ucz, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)

model_odniesienia = DummyClassifier(strategy="most_frequent")
model_odniesienia.fit(X_ucz, y_ucz)

model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000, random_state=42))
model.fit(X_ucz, y_ucz)

print(f"Udział chorych w zbiorze testowym: {y_test.mean():.1%}")
print(f"Skuteczność modelu odniesienia:    {model_odniesienia.score(X_test, y_test):.1%}")
print(f"Skuteczność regresji logistycznej: {model.score(X_test, y_test):.1%}")

2. Dlaczego jedna liczba to za mało

Skuteczność to po prostu odsetek trafnych odpowiedzi:

$$\text{skuteczność} = \frac{\text{liczba trafnych odpowiedzi}}{\text{liczba wszystkich pacjentów}}$$

Wada tego wzoru jest w tym, czego w nim nie ma: nie ma informacji, kogo model pomylił. Traktuje on pomyłkę „zdrowy uznany za chorego" i „chory uznany za zdrowego" jako jeden i ten sam błąd, wart tyle samo.

W diagnostyce medycznej te błędy nie są równie kosztowne i nawet nie są tego samego rzędu:

Rodzaj błędu Co się dzieje z pacjentem Koszt
zdrowy uznany za chorego dodatkowe badanie, stres, koszt wizyty kilkadziesiąt złotych i kilka nieprzyjemnych dni
chory uznany za zdrowego brak diagnozy, brak leczenia, choroba postępuje powikłania, w skrajnym przypadku życie

Skoro koszty są niesymetryczne, to miara, która je uśrednia, jest miarą złą. Potrzebujemy czegoś, co pokazuje oba rodzaje błędów osobno.

Zacznijmy od sprawdzenia, jak sobie radzi model odniesienia w kategoriach innych niż skuteczność.

In [ ]:
y_pred_odniesienia = model_odniesienia.predict(X_test)

print("Model odniesienia - jakie odpowiedzi w ogóle padły?")
print(pd.Series(y_pred_odniesienia).value_counts().to_string())
print()
print("Ilu chorych jest w zbiorze testowym:      ", int(y_test.sum()))
print("Ilu chorych model odniesienia wskazał:    ", int(y_pred_odniesienia.sum()))

Model odniesienia nie wskazał ani jednego chorego. Wszyscy chorzy pacjenci ze zbioru testowego zostali odesłani do domu z informacją „wszystko w porządku".

I ten model ma 66,6% skuteczności. Ta liczba brzmi przyzwoicie i jest kompletnie bezwartościowa. Gdyby chorych był 1% zamiast 33%, ten sam bezmyślny model miałby 99% skuteczności - i nadal przeoczyłby każdy przypadek choroby.

Reguła do zapamiętania: im rzadsza jest klasa, na której nam zależy, tym bardziej skuteczność wprowadza w błąd.

3. Macierz pomyłek - cztery pola zamiast jednej liczby

Macierz pomyłek rozbija wynik na cztery liczby. W zadaniu binarnym jedną z klas nazywamy klasą pozytywną (ang. positive class) - to ta, którą chcemy wykrywać. Tutaj jest nią Diabetic = 1, czyli chory pacjent.

Uwaga językowa: „pozytywny" nie znaczy „dobry". Pozytywny wynik testu na cukrzycę to zła wiadomość dla pacjenta. Klasa pozytywna to po prostu ta, której szukamy.

Cztery pola macierzy:

Skrót Nazwa angielska Nazwa polska Co to znaczy w naszym zadaniu
TN true negative prawdziwie ujemny zdrowy pacjent rozpoznany jako zdrowy
FP false positive fałszywie dodatni zdrowy pacjent wysłany na niepotrzebne badania (fałszywy alarm)
FN false negative fałszywie ujemny chory pacjent odesłany do domu - błąd, którego się boimy
TP true positive prawdziwie dodatni chory pacjent wykryty i skierowany do leczenia

W scikit-learn macierz ma układ: wiersze to rzeczywistość, kolumny to przewidywanie, a klasy idą rosnąco (najpierw 0, potem 1):

                      przewidziano 0   przewidziano 1
rzeczywiście 0             TN                FP
rzeczywiście 1             FN                TP
In [ ]:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay

y_pred = model.predict(X_test)
macierz = confusion_matrix(y_test, y_pred)

tn, fp, fn, tp = macierz.ravel()   # rozpakowanie czterech pól do osobnych zmiennych

print("Regresja logistyczna, próg domyślny 0,5:")
print(f"  TN = {tn:4d}  zdrowi rozpoznani poprawnie")
print(f"  FP = {fp:4d}  zdrowi wysłani na niepotrzebne badania")
print(f"  FN = {fn:4d}  CHORZY ODESŁANI DO DOMU")
print(f"  TP = {tp:4d}  chorzy wykryci")
In [ ]:
fig, osie = plt.subplots(1, 2, figsize=(11, 4.5))

for os_, m, tytul in [
    (osie[0], model_odniesienia, "Model odniesienia"),
    (osie[1], model, "Regresja logistyczna"),
]:
    ConfusionMatrixDisplay.from_estimator(
        m, X_test, y_test,
        display_labels=['zdrowy', 'chory'],
        cmap="Blues", values_format="d", colorbar=False, ax=os_,
    )
    os_.set_title(tytul)
    os_.set_xlabel("Przewidziano")
    os_.set_ylabel("Rzeczywistość")

plt.tight_layout()
plt.show()

Po lewej stronie widać w liczbach to, o czym była mowa: cała prawa kolumna modelu odniesienia jest pusta (zero fałszywych alarmów, ale i zero wykrytych chorych), a wszyscy chorzy siedzą w polu FN.

Po prawej model faktycznie kogoś wykrywa. Ale pole FN nadal nie jest puste - i to właśnie ono nas interesuje najbardziej.

4. Precyzja, czułość, F1

Z czterech pól macierzy da się policzyć metryki, które odpowiadają na różne pytania. To jest sedno: nie ma jednej „najlepszej metryki", jest metryka pasująca do pytania, które zadajemy.

Precyzja (ang. precision)

$$\text{precyzja} = \frac{TP}{TP + FP}$$

Odpowiada na pytanie: spośród pacjentów, których model uznał za chorych, ilu faktycznie choruje? Czyli: na ile można ufać alarmowi. Niska precyzja oznacza dużo fałszywych alarmów.

Czułość (ang. recall, w medycynie też sensitivity)

$$\text{czułość} = \frac{TP}{TP + FN}$$

Odpowiada na pytanie: spośród faktycznie chorych, ilu model wykrył? Czyli: ilu chorych nie przeoczyliśmy. Niska czułość oznacza pacjentów odesłanych do domu z chorobą.

Dlaczego te dwie metryki walczą ze sobą

Bo można je „kupić" jedna za drugą. Model, który uznaje wszystkich za chorych, ma czułość 100% - nie przeoczy nikogo - i fatalną precyzję, bo większość jego alarmów jest fałszywa. Model, który alarmuje tylko przy pacjentach absolutnie oczywistych, ma bardzo wysoką precyzję i niską czułość.

Chcemy wysoką… Kiedy Przykład
czułość gdy przeoczenie jest groźne badanie przesiewowe w kierunku nowotworu, wykrywanie awarii, filtrowanie zagrożeń
precyzję gdy fałszywy alarm jest kosztowny lub uciążliwy filtr antyspamowy (mail od szefa w spamie boli bardziej niż spam w skrzynce), automatyczne blokowanie konta

W naszym zadaniu medycznym priorytetem jest czułość.

F1

$$F_1 = 2 \cdot \frac{\text{precyzja} \cdot \text{czułość}}{\text{precyzja} + \text{czułość}}$$

To średnia harmoniczna obu metryk. Używa się jej, gdy potrzebna jest jedna liczba podsumowująca oba aspekty, na przykład do automatycznego porównania wielu modeli.

Średnia harmoniczna, a nie zwykła, dlatego że mocno karze skrajności: model o precyzji 1,0 i czułości 0,0 ma zwykłą średnią 0,5, a F1 równe 0. I bardzo dobrze - taki model jest bezużyteczny.

Ostrzeżenie: F1 traktuje precyzję i czułość jako równie ważne. W diagnostyce medycznej nie są równie ważne. Optymalizowanie F1 w zadaniu, w którym przeoczenie chorego kosztuje dziesięć razy więcej niż fałszywy alarm, to wybór wygodnej liczby zamiast właściwej.

In [ ]:
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score

# Liczymy najpierw "recznie" ze wzorow, zeby bylo widac, skad sie biora liczby
precyzja_recznie = tp / (tp + fp)
czulosc_recznie = tp / (tp + fn)

print("Ze wzorów:")
print(f"  precyzja = TP / (TP + FP) = {tp} / ({tp} + {fp}) = {precyzja_recznie:.3f}")
print(f"  czułość  = TP / (TP + FN) = {tp} / ({tp} + {fn}) = {czulosc_recznie:.3f}")
print()
print("Z funkcji scikit-learn (te same liczby):")
print(f"  skuteczność: {accuracy_score(y_test, y_pred):.3f}")
print(f"  precyzja:    {precision_score(y_test, y_pred):.3f}")
print(f"  czułość:     {recall_score(y_test, y_pred):.3f}")
print(f"  F1:          {f1_score(y_test, y_pred):.3f}")

5. classification_report - wszystko naraz

Ręczne wypisywanie metryk jest dobre do nauki, ale w praktyce używa się jednej funkcji, która liczy je dla każdej klasy osobno.

To „osobno" jest ważne. Do tej pory patrzyliśmy na metryki klasy chorych. Klasa zdrowych ma własną precyzję i własną czułość - i przy niezrównoważonych danych wyglądają one znacznie lepiej, co potrafi uśpić czujność.

W raporcie pojawiają się trzy wiersze podsumowania:

Wiersz Co liczy Kiedy jest zdradliwy
accuracy odsetek trafnych odpowiedzi zawsze, gdy klasy są niezrównoważone
macro avg zwykła średnia z obu klas, każda liczy się tak samo dobry sygnał, gdy zależy nam na klasie rzadkiej
weighted avg średnia ważona liczebnością klas klasa większościowa przeważa i zagłusza problem

Kolumna support to po prostu liczba pacjentów danej klasy w zbiorze testowym.

In [ ]:
from sklearn.metrics import classification_report

print("=== MODEL ODNIESIENIA ===")
print(classification_report(
    y_test, y_pred_odniesienia,
    target_names=['zdrowy', 'chory'],
    zero_division=0,      # model nie wskazal ani jednego chorego -> dzielenie przez zero
))

print("=== REGRESJA LOGISTYCZNA ===")
print(classification_report(y_test, y_pred, target_names=['zdrowy', 'chory']))

W raporcie modelu odniesienia wiersz chory ma same zera - precyzja, czułość i F1 równe 0. Dokładnie to chcieliśmy zobaczyć: metryka, która od razu pokazuje, że model jest bezwartościowy, mimo przyzwoicie wyglądającej skuteczności.

Zwróć też uwagę na argument zero_division=0. Bez niego scikit-learn zgłasza ostrzeżenie, bo precyzja tego modelu to dzielenie przez zero (żadnego alarmu nie było, więc nie da się policzyć, jaki odsetek alarmów był trafny). To nie jest usterka biblioteki - to sygnał, że model w ogóle nie korzysta z jednej z klas.

6. Model nie mówi „tak" ani „nie". Model podaje prawdopodobieństwo

Tu dochodzimy do najważniejszej części ćwiczenia.

Metoda predict zwraca gotowe zera i jedynki - i przez to wygląda, jakby model podejmował decyzję. Nie podejmuje. Model wylicza prawdopodobieństwo (ang. probability), a dopiero potem ktoś je zamienia na decyzję, porównując z progiem. Ten ktoś to domyślne ustawienie biblioteki, które brzmi: jeśli prawdopodobieństwo choroby przekracza 0,5, odpowiedz „chory".

Skąd wzięło się 0,5? Z niczego. To liczba wygodna, a nie liczba przemyślana. Nikt nie policzył, że w tym szpitalu, przy tych kosztach badań i tych konsekwencjach przeoczenia, optymalny punkt odcięcia wypada akurat w połowie.

Do prawdopodobieństw sięgamy metodą predict_proba. Zwraca ona tablicę o dwóch kolumnach: prawdopodobieństwo klasy 0 i klasy 1. Interesuje nas druga kolumna.

In [ ]:
prawdopodobienstwa = model.predict_proba(X_test)[:, 1]   # kolumna [:, 1] = P(pacjent jest chory)

podglad = pd.DataFrame({
    'P(chory)': prawdopodobienstwa,
    'decyzja przy progu 0,5': (prawdopodobienstwa >= 0.5).astype(int),
    'prawda': y_test.values,
})

print(podglad.head(10).to_string(index=False, float_format=lambda v: f"{v:.3f}"))
print()
print("Sprawdzenie: decyzja przy progu 0,5 jest identyczna z wynikiem predict():",
      np.array_equal((prawdopodobienstwa >= 0.5).astype(int), y_pred))

Zwróć uwagę na ostatni wiersz wydruku - ten z napisem Sprawdzenie: ... True. Właśnie potwierdził rzecz, która brzmi banalnie, a ma spore konsekwencje: predict() to dosłownie predict_proba() >= 0,5. Nic więcej się tam nie dzieje.

Innymi słowy: model nie podejmuje decyzji - model zwraca prawdopodobieństwo. Decyzję podejmuje próg, a próg 0,5 jest tylko wartością domyślną, którą ktoś kiedyś wybrał za Ciebie.

Skoro tak, to próg jest parametrem, który wolno zmienić bez ponownego trenowania modelu. To najtańsza zmiana, jaką da się w modelu wprowadzić - nie kosztuje ani jednej sekundy obliczeń.

Zobaczmy, jak rozkładają się prawdopodobieństwa w obu grupach pacjentów.

In [ ]:
fig, ax = plt.subplots(figsize=(9, 4.5))

ax.hist(prawdopodobienstwa[y_test == 0], bins=40, alpha=0.6, label='pacjenci zdrowi')
ax.hist(prawdopodobienstwa[y_test == 1], bins=40, alpha=0.6, label='pacjenci chorzy')
ax.axvline(0.5, color='black', linestyle='--', linewidth=1.5, label='domyślny próg 0,5')
ax.set_xlabel('P(pacjent jest chory) według modelu')
ax.set_ylabel('liczba pacjentów')
ax.set_title('Gdzie postawić kreskę?')
ax.legend()
plt.tight_layout()
plt.show()

Gdyby obie grupy były całkowicie rozdzielone, wybór progu nie miałby znaczenia - wystarczyłoby postawić kreskę w pustym miejscu. W rzeczywistości rozkłady się nakładają, i to w tym obszarze nakładania rodzą się wszystkie błędy.

Przesunięcie kreski w lewo (niższy próg) oznacza: łapiemy więcej chorych, ale zgarniamy przy okazji więcej zdrowych. Przesunięcie w prawo - odwrotnie.

7. Przesuwanie progu decyzyjnego

Sprawdźmy to liczbowo: dla kilku progów policzmy komplet metryk i pola macierzy pomyłek.

In [ ]:
progi = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
wiersze = []

for prog in progi:
    decyzja = (prawdopodobienstwa >= prog).astype(int)
    tn_p, fp_p, fn_p, tp_p = confusion_matrix(y_test, decyzja).ravel()
    wiersze.append({
        'próg': prog,
        'FN (przeoczeni chorzy)': fn_p,
        'FP (fałszywe alarmy)': fp_p,
        'precyzja': precision_score(y_test, decyzja, zero_division=0),
        'czułość': recall_score(y_test, decyzja),
        'F1': f1_score(y_test, decyzja),
        'skuteczność': accuracy_score(y_test, decyzja),
    })

tabela_progow = pd.DataFrame(wiersze)
print(tabela_progow.to_string(index=False, float_format=lambda v: f"{v:.3f}"))

Przeczytaj tę tabelę w dwóch kierunkach:

  • z dołu do góry (próg maleje): czułość rośnie, FN spada - coraz mniej chorych wymyka się modelowi,
  • przy okazji: precyzja spada, FP rośnie - coraz więcej zdrowych trafia na niepotrzebne badania.

Nie da się mieć obu naraz. To jest kompromis (ang. trade-off), którego nie usuwa żaden algorytm - można go jedynie świadomie ustawić tam, gdzie pasuje do kosztów zadania.

Prześledź osobno kolumnę skuteczności i sprawdź, przy którym progu wypada ona najwyżej. Skuteczność premiuje klasę większościową, więc jej maksimum wypada tam, gdzie model rzadko alarmuje - a to znaczy, że próg maksymalizujący skuteczność nie jest progiem, który chcemy w szpitalu. To ta sama pułapka co w ćwiczeniu 01, tylko widziana od innej strony.

Te same dane wyglądają wyraźniej na wykresie, gdy próg zmienia się płynnie.

In [ ]:
from sklearn.metrics import precision_recall_curve

precyzje, czulosci, progi_pr = precision_recall_curve(y_test, prawdopodobienstwa)

# precision_recall_curve zwraca o jeden element wiecej dla precyzji i czulosci
# niz dla progow - ostatni punkt (precyzja 1, czulosc 0) nie ma odpowiadajacego progu
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(progi_pr, precyzje[:-1], label='precyzja')
ax.plot(progi_pr, czulosci[:-1], label='czułość')
ax.axvline(0.5, color='black', linestyle='--', linewidth=1, label='domyślny próg 0,5')
ax.set_xlabel('próg decyzyjny')
ax.set_ylabel('wartość metryki')
ax.set_title('Precyzja i czułość jako funkcje progu - jedna rośnie kosztem drugiej')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

Dwie krzywe biegnące w przeciwnych kierunkach - to jest obraz, który warto zapamiętać z tego ćwiczenia.

Jak wybrać próg w prawdziwym projekcie: nie z wykresu i nie ze wzoru, tylko z rozmowy z osobą, która zna koszty. Pytanie do lekarza brzmi: „ilu dodatkowych zdrowych pacjentów wolno nam wysłać na badanie kontrolne, żeby wykryć jednego chorego więcej?". Odpowiedź na to pytanie wyznacza próg. Zadaniem osoby budującej model jest pokazać kompromis, a nie wybrać go samodzielnie.

8. Krzywa ROC i pole pod nią (AUC)

Tabela progów ma wadę: pokazuje kilka wybranych punktów. Krzywa ROC (ang. receiver operating characteristic) pokazuje wszystkie progi naraz, jako jedną linię.

Na osiach są dwie wielkości:

  • oś pionowa: czułość (TPR, ang. true positive rate) - odsetek chorych, których wykryliśmy,
  • oś pozioma: odsetek fałszywych alarmów (FPR, ang. false positive rate) - $FP / (FP + TN)$, czyli odsetek zdrowych, których niepotrzebnie zaalarmowaliśmy.

Każdy punkt krzywej to jeden próg. Im bardziej krzywa wygina się ku lewemu górnemu rogowi, tym lepiej - tam jest dużo wykrytych chorych przy mało fałszywych alarmach.

AUC (ang. area under the curve) to pole pod tą krzywą, liczba od 0 do 1. Ma ładną interpretację: to prawdopodobieństwo, że losowo wybrany chory pacjent dostanie od modelu wyższą ocenę niż losowo wybrany zdrowy. AUC = 0,5 oznacza model losowy (przekątna na wykresie), AUC = 1,0 - model idealny.

Najważniejsza zaleta AUC: nie zależy od progu. Mierzy jakość samego uszeregowania pacjentów, a nie konkretnej decyzji.

In [ ]:
from sklearn.metrics import RocCurveDisplay, roc_auc_score

fig, ax = plt.subplots(figsize=(6.5, 6))

RocCurveDisplay.from_predictions(y_test, prawdopodobienstwa, name='regresja logistyczna', ax=ax)
ax.plot([0, 1], [0, 1], linestyle='--', color='gray', label='model losowy (AUC = 0,5)')
ax.set_xlabel('odsetek fałszywych alarmów (FPR)')
ax.set_ylabel('czułość (TPR)')
ax.set_title('Krzywa ROC')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

print(f"ROC AUC: {roc_auc_score(y_test, prawdopodobienstwa):.3f}")

9. Krzywa precyzja-czułość - uczciwsza przy rzadkiej klasie

ROC ma jedną niewygodną właściwość: jej oś pozioma, czyli FPR, ma w mianowniku $FP + TN$ - a więc wszystkich zdrowych pacjentów. Gdy zdrowych jest bardzo dużo, nawet spora liczba fałszywych alarmów daje mały ułamek i krzywa wygląda dobrze.

Przykład: przy 9 900 zdrowych pacjentach 99 fałszywych alarmów to FPR równe zaledwie 0,01 - krzywa ROC ledwie drgnie. Ale jeśli chorych jest 100 i model wykrył 50, to na 149 alarmów tylko 50 jest trafnych: precyzja wynosi około 34%. Lekarz odbierający takie alarmy uzna model za niewiarygodny, a ROC zaraportuje, że jest świetnie.

Krzywa precyzja-czułość (ang. precision-recall curve) tego problemu nie ma, bo w żadnej z jej metryk nie występuje TN. Patrzy wyłącznie na klasę, na której nam zależy.

Cecha Krzywa ROC Krzywa precyzja-czułość
osie FPR kontra czułość czułość kontra precyzja
czy uwzględnia TN tak nie
zachowanie przy rzadkiej klasie pozytywnej bywa zbyt optymistyczna pokazuje problem wprost
poziom odniesienia dla modelu losowego zawsze 0,5 udział klasy pozytywnej w danych
podsumowanie jedną liczbą ROC AUC średnia precyzja (ang. average precision)

Zwróć uwagę na wiersz o poziomie odniesienia. W ROC model losowy zawsze daje 0,5, niezależnie od danych. W krzywej precyzja-czułość poziom odniesienia to udział chorych w zbiorze - u nas około 0,334. Dlatego wartości średniej precyzji nie porównuje się między różnymi zbiorami danych, tylko z tym poziomem odniesienia.

In [ ]:
from sklearn.metrics import PrecisionRecallDisplay, average_precision_score

fig, ax = plt.subplots(figsize=(6.5, 6))

PrecisionRecallDisplay.from_predictions(y_test, prawdopodobienstwa, name='regresja logistyczna', ax=ax)
ax.axhline(y_test.mean(), color='gray', linestyle='--',
           label=f'model losowy (udział chorych = {y_test.mean():.3f})')
ax.set_xlabel('czułość')
ax.set_ylabel('precyzja')
ax.set_title('Krzywa precyzja-czułość')
ax.legend(loc='lower left')
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

print(f"Średnia precyzja (average precision): {average_precision_score(y_test, prawdopodobienstwa):.3f}")
print(f"Poziom odniesienia (udział chorych):  {y_test.mean():.3f}")

Zadania

Wszystko, czego potrzeba, pojawiło się w przykładzie powyżej. Zadania są ustawione od najprostszego do najtrudniejszego.

Zadanie 1: Metryki modelu odniesienia w liczbach

Dla model_odniesienia policz i wypisz cztery pola macierzy pomyłek (TN, FP, FN, TP) oraz precyzję, czułość i F1 dla klasy chorych.

Przy liczeniu precyzji przyda się argument zero_division=0 - zastanów się, dlaczego akurat ta metryka wymaga tu specjalnego traktowania.

Na koniec odpowiedz jednym zdaniem: która z policzonych liczb najkrócej uzasadnia, że tego modelu nie wolno wdrożyć w szpitalu?

In [ ]:
# TWÓJ KOD TUTAJ
# Podpowiedź: confusion_matrix(y_test, y_pred_odniesienia).ravel()

Zadanie 2: Metryki ze wzoru, bez gotowych funkcji

Napisz funkcję metryki_recznie(y_prawda, y_przewidziane), która na podstawie macierzy pomyłek policzy ze wzorów precyzję, czułość i F1, i zwróci je jako słownik.

Nie korzystaj wewnątrz z precision_score, recall_score ani f1_score - o to właśnie chodzi w tym zadaniu. Z confusion_matrix korzystać wolno.

Sprawdź wynik na przewidywaniach regresji logistycznej, porównując z funkcjami scikit-learn. Zadbaj o to, żeby funkcja nie wywracała się przy dzieleniu przez zero.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 3: Własna tabela progów

Zbuduj tabelę metryk dla progów od 0,05 do 0,95 co 0,05 (np.arange(0.05, 1.0, 0.05)).

Dla każdego progu podaj: liczbę przeoczonych chorych (FN), liczbę fałszywych alarmów (FP), precyzję, czułość i F1.

Następnie odczytaj z tabeli trzy rzeczy:

  1. przy jakim progu F1 jest największe,
  2. przy jakim progu skuteczność jest największa,
  3. czy to ten sam próg - a jeśli nie, to dlaczego.
In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 4: Próg dla czułości co najmniej 0,9

To zadanie jest w tym ćwiczeniu najważniejsze, bo odwraca zwykły sposób myślenia: zamiast pytać „jakie metryki ma mój model", stawiamy wymaganie i pytamy, ile ono kosztuje.

Szpital stawia warunek: model ma wykrywać co najmniej 90% chorych. Czułość poniżej 0,9 dyskwalifikuje rozwiązanie.

  1. Znajdź najwyższy próg, przy którym czułość wynosi co najmniej 0,9. Dlaczego akurat najwyższy - zastanów się, zanim zajrzysz do podpowiedzi.
  2. Podaj, jaka jest przy tym progu precyzja.
  3. Wypisz macierz pomyłek dla tego progu i porównaj ją z macierzą dla progu 0,5: ilu chorych więcej udało się wykryć i ilu zdrowych pacjentów za to zapłaciło niepotrzebnym badaniem.
  4. Sformułuj wniosek zdaniem, które dałoby się powiedzieć lekarzowi - bez żargonu, za to z liczbami.
In [ ]:
# TWÓJ KOD TUTAJ
# Podpowiedź: precision_recall_curve zwraca gotowe tablice precyzji, czułości i progów.
# Szukamy najwyższego progu, bo im wyższy próg, tym mniej fałszywych alarmów -
# chcemy spełnić wymaganie czułości jak najmniejszym kosztem precyzji.

Zadanie 5: Które porównanie modeli jest uczciwe?

Wytrenuj DecisionTreeClassifier(max_depth=5, random_state=42) i porównaj go z regresją logistyczną na cztery sposoby:

  1. skuteczność,
  2. F1 przy domyślnym progu,
  3. ROC AUC,
  4. średnia precyzja.

Wyniki zestaw w tabeli (pd.DataFrame). Narysuj też obie krzywe ROC na jednym wykresie i obie krzywe precyzja-czułość na drugim.

Pytanie, na które odpowiadasz: czy wszystkie cztery metryki wskazują ten sam model jako lepszy? Jeśli nie - która z nich najlepiej pasuje do zadania medycznego i dlaczego?

In [ ]:
# TWÓJ KOD TUTAJ
# Podpowiedź: obie krzywe na jednym wykresie rysuje się, przekazując ten sam `ax`
# do kolejnych wywołań RocCurveDisplay.from_predictions(...).

Zadanie 6: Wagi klas kontra przesunięcie progu

Przesuwanie progu to nie jedyny sposób na zwiększenie czułości. Drugi to powiedzenie modelowi już na etapie uczenia, że pomyłka na chorym pacjencie boli bardziej. Służy do tego argument class_weight='balanced' w LogisticRegression.

  1. Wytrenuj regresję logistyczną z class_weight='balanced' (reszta bez zmian: max_iter=1000, random_state=42, potok ze skalowaniem).
  2. Porównaj jej macierz pomyłek i metryki przy progu 0,5 z modelem bez wag.
  3. Porównaj jej ROC AUC z modelem bez wag. Czy ta metryka się zmieniła? Zastanów się, dlaczego wynik wygląda właśnie tak.
  4. Znajdź taki próg dla modelu bez wag, przy którym czułość jest w przybliżeniu równa czułości modelu z wagami. Porównaj wtedy precyzję obu modeli.

Wniosek, do którego zmierzamy: czy class_weight daje coś, czego nie da się osiągnąć samym przesunięciem progu?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 7 (trudniejsze): Próg wyznaczony z kosztów

Do tej pory próg wybieraliśmy „na oko" albo pod zadane wymaganie. Teraz wyznaczymy go rachunkiem.

Przyjmijmy koszty (umowne jednostki, ustalone przez szpital):

Zdarzenie Koszt
FN - chory odesłany do domu 10
FP - zdrowy wysłany na dodatkowe badanie 1
TP, TN - trafna decyzja 0
  1. Napisz funkcję, która dla zadanego progu policzy łączny koszt: 10 * FN + 1 * FP.
  2. Policz koszt dla progów od 0,01 do 0,99 co 0,01 i narysuj wykres koszt-próg.
  3. Znajdź próg minimalizujący koszt. Porównaj go z 0,5 oraz z progiem z zadania 4.
  4. Sprawdź, jak przesuwa się optymalny próg, gdy stosunek kosztów zmieni się na 3:1 oraz na 30:1. Opisz zależność jednym zdaniem.
  5. Na koniec pytanie bez kodu: skąd w prawdziwym projekcie wziąć te koszty i co zrobić, gdy nikt nie potrafi ich podać?
In [ ]:
# TWÓJ KOD TUTAJ

Pytania do przemyślenia

Na te pytania odpowiada się słowami, nie kodem.

  1. Model wykrywania rzadkiej choroby (1 przypadek na 1000 badanych) osiąga 99,9% skuteczności. Ile w najgorszym razie wykrył chorych? Jakie dwie metryki trzeba zobaczyć, zanim będzie można cokolwiek o tym modelu powiedzieć?
  2. Filtr antyspamowy i badanie przesiewowe w kierunku nowotworu to oba zadania klasyfikacji binarnej. Dlaczego w jednym priorytetem jest precyzja, a w drugim czułość? Co jest klasą pozytywną w każdym z nich?
  3. Dwa modele mają identyczne ROC AUC, ale różne F1 przy progu 0,5. Czy to w ogóle możliwe - a jeśli tak, to co się między nimi różni?
  4. Dlaczego F1 jest średnią harmoniczną, a nie zwykłą? Co by się stało z oceną modelu, który uznaje wszystkich pacjentów za chorych, gdyby F1 liczyło się jako zwykła średnia?
  5. Obniżenie progu z 0,5 do 0,3 podniosło czułość. Czy model stał się przez to lepszy? Która z metryk omawianych w tym ćwiczeniu w ogóle nie zareaguje na zmianę progu i dlaczego właśnie ona jest dobrym miernikiem samego modelu?
  6. Próg dobrany w tym ćwiczeniu został wyznaczony na zbiorze testowym. To wygodne, ale metodycznie podejrzane. Na czym dokładnie polega problem i jak należałoby to zrobić poprawnie?

Chcesz wiedzieć więcej

Ostatnie pytanie do przemyślenia zostawia otwarty problem: próg dobrany na zbiorze testowym przestaje być bezstronny, bo zbiór testowy został użyty do podjęcia decyzji. Dokładnie tym zajmuje się ćwiczenie 06 - Walidacja i dobór modelu: walidacją krzyżową (ang. cross-validation), doborem hiperparametrów i przeciekiem danych. Metryki poznane tutaj będą tam narzędziem pomiaru - zobaczysz między innymi, że GridSearchCV z argumentem scoring='recall' szuka czegoś zupełnie innego niż scoring='accuracy'.