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

Ćwiczenie 6: Walidacja i dobór modelu

Po co to ćwiczenie?

W ćwiczeniu 01, w zadaniu 4, zrobiliśmy coś, co wyglądało całkowicie rozsądnie: sprawdziliśmy max_depth od 1 do 15, dla każdej wartości zmierzyliśmy skuteczność na zbiorze testowym i wybraliśmy tę, która wypadła najlepiej.

To był błąd. I to nie drobiazg - to jest jeden z dwóch, trzech najczęstszych powodów, dla których model świetny w notatniku okazuje się przeciętny w produkcji.

Zasada ze ćwiczenia 01 brzmiała: modelu nie wolno oceniać na danych, na których się uczył. Teraz musimy ją zaostrzyć:

Zbiór testowy wolno wykorzystać raz - na samym końcu. Każda decyzja podjęta na jego podstawie zużywa go.

Bo skoro spośród 15 głębokości wybraliśmy tę o najlepszym wyniku testowym, to informacja ze zbioru testowego przeciekła do naszego modelu - nie przez kod, tylko przez naszą własną decyzję. Wynik, który potem raportujemy, jest już optymistycznie obciążony. Oszukujemy samych siebie, i to w sposób, którego nie widać w kodzie.

To ćwiczenie naprawia ten błąd i daje narzędzia, dzięki którym nie trzeba go już nigdy popełniać.

Czego się nauczysz

  1. Dlaczego dobieranie hiperparametrów (ang. hyperparameters) na zbiorze testowym jest oszukiwaniem samego siebie.
  2. Jak podzielić dane na trzy części: uczącą, walidacyjną i testową - i kiedy to wystarcza.
  3. Czym jest walidacja krzyżowa (ang. cross-validation) i dlaczego daje stabilniejszą ocenę niż jeden podział.
  4. Jak przeszukać przestrzeń hiperparametrów za pomocą GridSearchCV i RandomizedSearchCV.
  5. Jak z krzywej uczenia (ang. learning curve) odczytać, czy pomoże więcej danych, czy raczej trzeba zmienić model.
  6. Dlaczego skalowanie wykonane przed walidacją krzyżową to przeciek danych (ang. data leakage) - i dlaczego Pipeline jest jedynym poprawnym rozwiązaniem.

Zanim zaczniesz: uruchamiaj komórki po kolei (Shift+Enter). Kilka komórek w tym notatniku liczy się dłużej niż poprzednio - przy każdej takiej napisane jest, ile mniej więcej to potrwa.

1. Dane i przypomnienie błędu

Pracujemy na znanym już zbiorze dane/diabetes.csv - 10 000 kart pacjentów, etykieta Diabetic (0/1). Przygotowanie jest identyczne jak w ćwiczeniu 01: usuwamy PatientID, dzielimy na X (cechy, ang. features) i y (etykietę, ang. label).

In [ ]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
import time
from sklearn.dummy import DummyClassifier
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.model_selection import cross_val_score

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

X = dane.drop(columns=['PatientID', 'Diabetic'])
y = dane['Diabetic']

X_ucz_pelny, X_test, y_ucz_pelny, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)

print(f"Zbiór uczący (pełny): {len(X_ucz_pelny):5d}")
print(f"Zbiór testowy:        {len(X_test):5d}")
print(f"Liczba cech:          {X.shape[1]}")

Odtwórzmy błąd z ćwiczenia 01

Poniższy kod robi dokładnie to, co robiliśmy poprzednio: dla każdej głębokości mierzy wynik na zbiorze testowym i wybiera najlepszą. Uruchom go i zwróć uwagę na ostatnią linię wydruku.

In [ ]:
from sklearn.tree import DecisionTreeClassifier

wyniki_testowe = {}
for glebokosc in range(1, 16):
    d = DecisionTreeClassifier(max_depth=glebokosc, random_state=42)
    d.fit(X_ucz_pelny, y_ucz_pelny)
    wyniki_testowe[glebokosc] = d.score(X_test, y_test)   # <-- tu jest błąd

najlepsza = max(wyniki_testowe, key=wyniki_testowe.get)

print("Skuteczność na zbiorze TESTOWYM dla kolejnych głębokości:")
for g, w in wyniki_testowe.items():
    print(f"  max_depth={g:2d}: {w:.4f}")
print()
print(f"Wybrana głębokość: {najlepsza}")
print(f"Raportowany wynik: {wyniki_testowe[najlepsza]:.4f}  <-- ta liczba jest zawyżona")

Na czym dokładnie polega oszustwo

Zbiór testowy ma odpowiadać na jedno pytanie: jak ten model poradzi sobie na danych, których nigdy nie widział. Odpowiedź jest wiarygodna tylko wtedy, gdy testu użyliśmy raz.

My użyliśmy go piętnaście razy i wybraliśmy maksimum. A maksimum z piętnastu pomiarów, z których każdy obarczony jest losowym błędem, jest systematycznie wyższe od prawdziwej wartości - wybieramy przecież nie tylko najlepszy model, ale i najszczęśliwszy zbieg okoliczności w tym konkretnym podziale danych.

Analogia: student, który zna zestaw pytań egzaminacyjnych i wybiera ten wariant odpowiedzi, który daje najwyższą ocenę. Ocena przestaje mierzyć wiedzę, zaczyna mierzyć dopasowanie do konkretnego arkusza.

Co robimy Czy wolno Dlaczego
Uczymy model na zbiorze uczącym tak po to jest
Porównujemy 15 wariantów na zbiorze testowym nie wybór jest podjęty na podstawie testu, więc test przestaje być bezstronny
Porównujemy 15 wariantów na zbiorze walidacyjnym tak walidacyjny jest właśnie „na zużycie"
Mierzymy raz, na końcu, na zbiorze testowym tak to jest jedyne poprawne użycie testu

Rozwiązania są dwa i zajmiemy się oboma: trzeci zbiór (walidacyjny) oraz walidacja krzyżowa.

2. Trzy zbiory: uczący, walidacyjny, testowy

Najprostsza naprawa: zamiast dwóch części, robimy trzy.

Zbiór Nazwa angielska Do czego służy Ile razy wolno go użyć
uczący training set model dopasowuje na nim swoje parametry dowolnie
walidacyjny validation set my wybieramy na nim hiperparametry i model dowolnie
testowy test set końcowa, bezstronna ocena wybranego modelu raz

Warto zobaczyć różnicę między parametrem a hiperparametrem:

  • parametr (ang. parameter) - to, czego model uczy się sam z danych: progi w węzłach drzewa, wagi regresji logistycznej;
  • hiperparametr (ang. hyperparameter) - to, co ustawiamy my przed uczeniem: max_depth, min_samples_leaf, C, liczba drzew.

Zbiór walidacyjny wycinamy ze zbioru uczącego, nie z testowego - testowy ma pozostać nietknięty.

In [ ]:
X_ucz, X_wal, y_ucz, y_wal = train_test_split(
    X_ucz_pelny, y_ucz_pelny,
    test_size=0.25,        # 25% z 80% całości = 20% całości
    stratify=y_ucz_pelny,
    random_state=42,
)

print(f"uczący:      {len(X_ucz):5d}  ({len(X_ucz) / len(X):.0%} całości)")
print(f"walidacyjny: {len(X_wal):5d}  ({len(X_wal) / len(X):.0%} całości)")
print(f"testowy:     {len(X_test):5d}  ({len(X_test) / len(X):.0%} całości)")

Teraz to samo poszukiwanie głębokości, ale uczciwie: wybieramy na walidacyjnym, a zbioru testowego dotykamy dopiero na samym końcu, jeden raz.

In [ ]:
wyniki = []
for glebokosc in range(1, 16):
    d = DecisionTreeClassifier(max_depth=glebokosc, random_state=42)
    d.fit(X_ucz, y_ucz)
    wyniki.append({
        'max_depth': glebokosc,
        'uczacy': d.score(X_ucz, y_ucz),
        'walidacyjny': d.score(X_wal, y_wal),
    })

tabela = pd.DataFrame(wyniki)
najlepsza_wal = int(tabela.loc[tabela['walidacyjny'].idxmax(), 'max_depth'])
print(tabela.to_string(index=False, float_format=lambda v: f"{v:.4f}"))
print()
print(f"Najlepsza głębokość WEDŁUG ZBIORU WALIDACYJNEGO: {najlepsza_wal}")
In [ ]:
# Dopiero teraz - raz - sięgamy po zbiór testowy.
# Model uczymy ponownie na CAŁYM zbiorze uczącym (uczący + walidacyjny),
# bo skoro hiperparametr jest już wybrany, szkoda marnować dane.
model_koncowy = DecisionTreeClassifier(max_depth=najlepsza_wal, random_state=42)
model_koncowy.fit(X_ucz_pelny, y_ucz_pelny)

print(f"Wynik na walidacyjnym (na jego podstawie wybraliśmy): "
      f"{tabela['walidacyjny'].max():.4f}")
print(f"Wynik na TESTOWYM (uczciwa ocena końcowa):            "
      f"{model_koncowy.score(X_test, y_test):.4f}")
print()
print("Porównaj z liczbą z sekcji 1 - tam wynik był wybrany jako maksimum z testu.")

Zwróć uwagę na kolejność: najpierw wybór, potem pomiar. Gdy raz zmierzysz wynik testowy i on Ci się nie spodoba, nie wolno wrócić i poprawić hiperparametru - w tym momencie zbiór testowy jest już zużyty i przestaje być bezstronny.

Podział na trzy części ma jednak dwie wady:

  1. Marnuje dane - model uczy się tylko na 60% zbioru zamiast na 80%.
  2. Jeden podział to jeden pomiar - trafi się „łatwy" zbiór walidacyjny i wybierzemy zły hiperparametr, nie wiedząc o tym.

Obie wady usuwa walidacja krzyżowa.

3. Walidacja krzyżowa

Pomysł jest prosty: zamiast wycinać jeden zbiór walidacyjny, dzielimy zbiór uczący na k równych części (ang. folds, „foldy"). Potem k razy uczymy model: za każdym razem jedna część pełni rolę walidacyjnej, a pozostałe k-1 rolę uczącej. Na koniec uśredniamy k wyników.

Dla k = 5 wygląda to tak:

przebieg 1:  [WAL][ucz][ucz][ucz][ucz]
przebieg 2:  [ucz][WAL][ucz][ucz][ucz]
przebieg 3:  [ucz][ucz][WAL][ucz][ucz]
przebieg 4:  [ucz][ucz][ucz][WAL][ucz]
przebieg 5:  [ucz][ucz][ucz][ucz][WAL]

Co na tym zyskujemy:

  • każdy wiersz jest raz sprawdzany i k-1 razy używany do uczenia - nic się nie marnuje,
  • dostajemy k wyników zamiast jednego, więc widzimy nie tylko średnią, ale i rozrzut,
  • rozrzut jest cenną informacją sam w sobie: jeśli wyniki foldów rozjeżdżają się o kilka punktów procentowych, to różnica 0,3 punktu między dwoma modelami nic nie znaczy.

Cena: model uczymy k razy zamiast raz, więc trwa to k razy dłużej.

Używamy StratifiedKFold - odpowiednika stratify=y z train_test_split. Zapewnia, że w każdym foldzie proporcja chorych do zdrowych jest taka jak w całości. Przy klasyfikacji to powinien być domyślny wybór.

In [ ]:
from sklearn.model_selection import cross_val_score, StratifiedKFold

kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

drzewo = DecisionTreeClassifier(max_depth=5, random_state=42)

wyniki_cv = cross_val_score(drzewo, X_ucz_pelny, y_ucz_pelny, cv=kfold, scoring='accuracy')

print("Wyniki poszczególnych foldów:")
for i, w in enumerate(wyniki_cv, start=1):
    print(f"  fold {i}: {w:.4f}")
print()
print(f"Średnia:             {wyniki_cv.mean():.4f}")
print(f"Odchylenie std:      {wyniki_cv.std():.4f}")
print(f"Przedział ± 1 odch.: {wyniki_cv.mean() - wyniki_cv.std():.4f} - "
      f"{wyniki_cv.mean() + wyniki_cv.std():.4f}")

Trzy argumenty StratifiedKFold, które warto rozumieć:

Argument Co robi Dlaczego tak
n_splits=5 liczba foldów 5 lub 10 to standard; więcej foldów = dokładniejsza ocena, ale dłużej
shuffle=True miesza wiersze przed podziałem bez tego foldy są wycinane po kolei - katastrofa, jeśli dane są posortowane np. po etykiecie
random_state=42 ustala losowość mieszania powtarzalność; działa tylko przy shuffle=True

Uwaga: random_state bez shuffle=True scikit-learn odrzuci błędem - i słusznie, bo bez mieszania nie ma czego losować.

4. GridSearchCV - systematyczne przeszukiwanie siatki

Skoro potrafimy uczciwie ocenić jeden zestaw hiperparametrów, możemy zautomatyzować sprawdzanie wielu. GridSearchCV robi dokładnie to: przyjmuje siatkę (ang. grid) wartości, sprawdza każdą kombinację walidacją krzyżową i zapamiętuje najlepszą.

Uwaga na liczbę dopasowań - rośnie ona mnożnikowo:

liczba dopasowań = (liczba kombinacji) × (liczba foldów)

Nasza siatka poniżej ma 5 × 3 × 2 = 30 kombinacji, a przy 5 foldach daje 150 dopasowań drzewa na 6400 wierszach. Z n_jobs=-1 (wszystkie rdzenie) powinno to zająć kilkanaście sekund. Gdyby dołożyć do siatki jeszcze jeden parametr o 5 wartościach, byłoby to już 750 dopasowań - i kilka minut czekania. Siatki trzeba projektować z głową.

In [ ]:
from sklearn.model_selection import GridSearchCV

siatka = {
    'max_depth': [3, 5, 7, 10, None],
    'min_samples_leaf': [1, 10, 50],
    'criterion': ['gini', 'entropy'],
}

szukanie = GridSearchCV(
    estimator=DecisionTreeClassifier(random_state=42),
    param_grid=siatka,
    cv=kfold,
    scoring='accuracy',
    n_jobs=-1,          # wykorzystaj wszystkie rdzenie procesora
    return_train_score=True,
)

szukanie.fit(X_ucz_pelny, y_ucz_pelny)   # kilkanaście sekund

print("Najlepsze hiperparametry:", szukanie.best_params_)
print(f"Najlepszy wynik walidacji krzyżowej: {szukanie.best_score_:.4f}")

GridSearchCV zapisuje pełną historię w cv_results_. To słownik, który najwygodniej obejrzeć jako DataFrame - poniżej pięć najlepszych kombinacji.

Kolumna std_test_score jest tu ważniejsza, niż się wydaje: jeśli różnica średnich między pierwszym a piątym miejscem jest mniejsza niż odchylenie standardowe, to ranking jest w dużej mierze dziełem przypadku. W takiej sytuacji rozsądnie jest wybrać nie najlepszy wynik, lecz najprostszy model o wyniku porównywalnym - prostszy model rzadziej zaskakuje na produkcji.

In [ ]:
wyniki_siatki = pd.DataFrame(szukanie.cv_results_)

kolumny = ['param_max_depth', 'param_min_samples_leaf', 'param_criterion',
           'mean_test_score', 'std_test_score', 'mean_train_score']

print("Pięć najlepszych kombinacji:")
print(wyniki_siatki.sort_values('mean_test_score', ascending=False)
      [kolumny].head(5).to_string(index=False))
print()
print("Pięć najgorszych kombinacji:")
print(wyniki_siatki.sort_values('mean_test_score')
      [kolumny].head(5).to_string(index=False))

Porównaj kolumny mean_train_score i mean_test_score. Tam, gdzie uczący jest bliski 1,0, a walidacyjny wyraźnie niższy, patrzysz na przeuczenie (ang. overfitting). Tam, gdzie oba są niskie i podobne - na niedouczenie (ang. underfitting).

Po zakończeniu GridSearchCV domyślnie (refit=True) uczy najlepszy model jeszcze raz na całych przekazanych danych. Dlatego obiekt szukanie można od razu używać jak model: ma predict i score.

In [ ]:
# Teraz - i dopiero teraz - jednorazowa ocena na zbiorze testowym.
print(f"Wynik walidacji krzyżowej (na nim wybieraliśmy): {szukanie.best_score_:.4f}")
print(f"Wynik na zbiorze TESTOWYM (ocena końcowa):       "
      f"{szukanie.score(X_test, y_test):.4f}")

5. RandomizedSearchCV - losowanie zamiast siatki

Siatka ma wadę: koszt rośnie wykładniczo z liczbą hiperparametrów. Cztery parametry po pięć wartości to już 625 kombinacji.

RandomizedSearchCV zamiast sprawdzać wszystko, losuje ustaloną liczbę kombinacji (n_iter) z podanych rozkładów lub list. Brzmi jak gorsze rozwiązanie, a zwykle jest lepsze, i to z konkretnego powodu:

W typowym problemie tylko jeden lub dwa hiperparametry naprawdę wpływają na wynik. Siatka marnuje większość dopasowań na staranne przeszukiwanie parametrów, które nic nie zmieniają. Losowanie sprawdza więcej różnych wartości tego jednego ważnego parametru przy tym samym budżecie.

Dodatkowa zaleta: budżet ustalasz Ty (n_iter), więc z góry wiesz, ile to potrwa. Poniżej 30 losowań × 5 foldów = 150 dopasowań, czyli podobnie jak siatka wyżej - ale przeszukiwana przestrzeń jest znacznie większa.

In [ ]:
from sklearn.model_selection import RandomizedSearchCV

przestrzen = {
    'max_depth': [3, 4, 5, 6, 7, 8, 10, 12, 15, None],
    'min_samples_leaf': [1, 2, 5, 10, 20, 50, 100],
    'min_samples_split': [2, 5, 10, 20, 50],
    'criterion': ['gini', 'entropy'],
    'max_features': [None, 'sqrt', 0.5],
}

liczba_kombinacji = 10 * 7 * 5 * 2 * 3
print(f"Pełna siatka miałaby {liczba_kombinacji} kombinacji "
      f"= {liczba_kombinacji * 5} dopasowań (za długo).")
print("Losujemy 30 kombinacji = 150 dopasowań.")

losowanie = RandomizedSearchCV(
    estimator=DecisionTreeClassifier(random_state=42),
    param_distributions=przestrzen,
    n_iter=30,           # budżet: tyle kombinacji sprawdzimy
    cv=kfold,
    scoring='accuracy',
    n_jobs=-1,
    random_state=42,     # powtarzalne losowanie kombinacji
)

losowanie.fit(X_ucz_pelny, y_ucz_pelny)   # kilkanaście sekund

print()
print("Najlepsze hiperparametry:", losowanie.best_params_)
print(f"Najlepszy wynik CV: {losowanie.best_score_:.4f}")
print(f"Dla porównania GridSearchCV: {szukanie.best_score_:.4f}")
GridSearchCV RandomizedSearchCV
Co sprawdza każdą kombinację z siatki losowo wybrane n_iter kombinacji
Koszt rośnie mnożnikowo z liczbą parametrów ustalasz go sam przez n_iter
Wartości ciągłe musisz je wyliczyć ręcznie w listę przyjmuje rozkłady, np. scipy.stats.uniform
Kiedy używać 1-2 parametry, mała siatka, chcesz pełny obraz 3 i więcej parametrów, ograniczony czas

W praktyce częsty schemat to: najpierw RandomizedSearchCV po szerokiej przestrzeni, żeby znaleźć obiecujący rejon, potem drobna GridSearchCV wokół znalezionych wartości.

6. Krzywa uczenia - czy pomoże więcej danych?

Załóżmy, że model nie działa wystarczająco dobrze. Masz do wyboru kilka kosztownych ruchów: zebrać więcej danych, dołożyć cechy, zmienić algorytm. Który z nich ma sens?

Krzywa uczenia (ang. learning curve) odpowiada na to pytanie. Uczy ten sam model na coraz większych podzbiorach danych i dla każdego rozmiaru rysuje dwa wyniki: na danych uczących i na walidacyjnych (przez walidację krzyżową).

Czyta się ją tak:

Obraz na wykresie Diagnoza Co robić
Obie krzywe nisko, blisko siebie, płaskie niedouczenie - model jest za prosty zwiększyć złożoność modelu, dodać cechy; więcej danych nie pomoże
Ucząca wysoko, walidacyjna wyraźnie niżej, przerwa się nie zamyka przeuczenie - model za bardzo dopasowuje się do danych uprościć model, mocniej regularyzować, więcej danych pomoże
Krzywe zbiegają się i obie wypłaszczają wysoko model dobrze dobrany więcej danych da już niewiele - szukaj lepszych cech lub innego algorytmu

Poniższa komórka liczy się około pół minuty do minuty: 5 rozmiarów × 5 foldów × 2 modele = 50 dopasowań, każde na innym wycinku danych.

In [ ]:
from sklearn.model_selection import learning_curve

def rysuj_krzywa_uczenia(model, tytul, ax):
    rozmiary, wyniki_ucz, wyniki_wal = learning_curve(
        model, X_ucz_pelny, y_ucz_pelny,
        train_sizes=np.linspace(0.1, 1.0, 5),
        cv=kfold,
        scoring='accuracy',
        n_jobs=-1,
    )
    ax.plot(rozmiary, wyniki_ucz.mean(axis=1), marker='o', label='zbiór uczący')
    ax.plot(rozmiary, wyniki_wal.mean(axis=1), marker='s', label='walidacja krzyżowa')
    ax.fill_between(rozmiary,
                    wyniki_wal.mean(axis=1) - wyniki_wal.std(axis=1),
                    wyniki_wal.mean(axis=1) + wyniki_wal.std(axis=1),
                    alpha=0.15)
    ax.set_title(tytul)
    ax.set_xlabel('liczba przykładów uczących')
    ax.set_ylabel('skuteczność')
    ax.set_ylim(0.6, 1.02)
    ax.grid(alpha=0.3)
    ax.legend(loc='lower right')

fig, osie = plt.subplots(1, 2, figsize=(13, 5))
rysuj_krzywa_uczenia(DecisionTreeClassifier(max_depth=2, random_state=42),
                     'Drzewo max_depth=2 (model prosty)', osie[0])
rysuj_krzywa_uczenia(DecisionTreeClassifier(random_state=42),
                     'Drzewo bez ograniczeń (model złożony)', osie[1])
plt.tight_layout()
plt.show()

Porównaj oba wykresy. Po lewej obie krzywe leżą blisko siebie i nisko - dołożenie danych nie podniesie ich, bo ogranicza je sam model. Po prawej krzywa ucząca siedzi przy 1,0, a walidacyjna znacznie niżej - to przerwa, którą można zmniejszać większą liczbą danych (albo przycięciem drzewa).

To jest praktyczna wartość tego wykresu: mówi Ci, na co wydać pieniądze. Zbieranie danych jest drogie; jeśli krzywe są płaskie i zbieżne, wydasz je bez efektu.

7. Przeciek danych przy skalowaniu

Została najbardziej podstępna rzecz w całym ćwiczeniu. Spójrz na ten kod i spróbuj znaleźć w nim błąd:

skaler = StandardScaler()
X_przeskalowane = skaler.fit_transform(X_ucz_pelny)     # skalujemy CAŁOŚĆ
wyniki = cross_val_score(LogisticRegression(), X_przeskalowane, y_ucz_pelny, cv=5)

Wygląda niewinnie. A jednak jest błędny, i to z tego samego powodu co wybór max_depth na teście.

StandardScaler w metodzie fit liczy średnią i odchylenie standardowe każdej cechy. Jeśli policzymy je na całym zbiorze uczącym, to średnia zawiera wkład także tych wierszy, które w danym foldzie pełnią rolę walidacyjnych. Czyli model - pośrednio, przez parametry skalowania - dostaje informację o danych, których „nie powinien znać". To jest przeciek danych (ang. data leakage).

Przy StandardScaler i 8000 wierszach efekt jest drobny - średnia policzona z 8000 wierszy i z 6400 wierszy niewiele się różni. Ale dokładnie ten sam mechanizm przy innych przekształceniach jest druzgocący:

Przekształcenie Co „wycieka" Skala problemu
StandardScaler średnia i odchylenie mała przy dużych danych, rosnąca przy małych
Uzupełnianie braków medianą mediana z całości średnia
SelectKBest - wybór cech które cechy korelują z etykietą ogromna
Nadpróbkowanie klasy mniejszościowej kopie wierszy trafiają do obu części ogromna

Rozwiązanie jest jedno i zawsze to samo: wszystkie kroki przetwarzania muszą znaleźć się wewnątrz Pipeline, a walidacja krzyżowa ma dostać ten potok, a nie gotowe przeskalowane dane. Wtedy fit skalera wykonuje się osobno w każdym foldzie, wyłącznie na jego części uczącej.

In [ ]:
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

# ŹLE: skalowanie przed walidacją krzyżową
skaler = StandardScaler()
X_przeskalowane = skaler.fit_transform(X_ucz_pelny)
zle = cross_val_score(LogisticRegression(max_iter=1000, random_state=42),
                      X_przeskalowane, y_ucz_pelny, cv=kfold)

# DOBRZE: skalowanie wewnątrz potoku, potok wewnątrz walidacji krzyżowej
potok = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=1000, random_state=42),
)
dobrze = cross_val_score(potok, X_ucz_pelny, y_ucz_pelny, cv=kfold)

print(f"Skalowanie PRZED CV (przeciek): {zle.mean():.4f} ± {zle.std():.4f}")
print(f"Skalowanie W POTOKU (poprawne): {dobrze.mean():.4f} ± {dobrze.std():.4f}")
print()
print("Różnica jest tu niewielka - i to jest część lekcji: przeciek nie zawsze")
print("widać w liczbach. Dlatego nie wykrywa się go patrząc na wynik, tylko")
print("stosując zasadę: wszystko, co uczy się z danych, wchodzi do Pipeline.")

Pipeline ma jeszcze jedną zaletę: jego hiperparametry można stroić razem z hiperparametrami modelu. Nazwy tworzy się jako nazwa_kroku__nazwa_parametru (dwa podkreślniki).

In [ ]:
potok_nazwany = Pipeline([
    ('skalowanie', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42)),
])

siatka_potoku = {
    'model__C': [0.01, 0.1, 1, 10, 100],      # siła regularyzacji
    'skalowanie__with_mean': [True, False],   # nawet krok przetwarzania da się stroić
}

szukanie_potok = GridSearchCV(potok_nazwany, siatka_potoku, cv=kfold, n_jobs=-1)
szukanie_potok.fit(X_ucz_pelny, y_ucz_pelny)   # kilka sekund

print("Najlepsze:", szukanie_potok.best_params_)
print(f"Wynik CV: {szukanie_potok.best_score_:.4f}")

Zadania

Wszystko, czego potrzebujesz, pojawiło się w przykładzie powyżej. Pracuj na X_ucz_pelny / y_ucz_pelny; X_test zostaw w spokoju aż do zadania, które wyraźnie o nim mówi.

Zadanie 1: Pierwsza walidacja krzyżowa

Oceń potok StandardScaler + LogisticRegression walidacją krzyżową na 5 foldach (kfold).

Wypisz: wynik każdego foldu, średnią oraz odchylenie standardowe. Zanim uruchomisz - oszacuj, jak duży będzie rozrzut między foldami przy 8000 wierszy: bliżej 0,001 czy bliżej 0,05?

In [ ]:
# TWÓJ KOD TUTAJ
# Podpowiedź: cross_val_score(model, X_ucz_pelny, y_ucz_pelny, cv=kfold)

Zadanie 2: Uczciwe porównanie trzech modeli

Porównaj walidacją krzyżową trzy modele:

  1. DummyClassifier(strategy="most_frequent") - model odniesienia z ćwiczenia 01,
  2. potok StandardScaler + LogisticRegression,
  3. DecisionTreeClassifier(max_depth=5, random_state=42).

Zbierz wyniki w DataFrame z kolumnami: nazwa modelu, średnia, odchylenie standardowe.

Na koniec odpowiedz sobie: czy różnica między modelem 2 a 3 jest większa od odchyleń standardowych obu? Jeśli nie - czy wolno powiedzieć, że jeden jest lepszy?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 3: Ile foldów?

Sprawdź, jak liczba foldów wpływa na ocenę. Dla n_splits równego 3, 5 i 10 (StratifiedKFold(shuffle=True, random_state=42)) oceń drzewo max_depth=5 i wypisz średnią, odchylenie oraz czas wykonania.

Czas zmierzysz tak:

import time
start = time.perf_counter()
...
print(f"czas: {time.perf_counter() - start:.1f} s")

Pytanie, na które odpowiadasz: czy 10 foldów dało istotnie inną średnią niż 5? A o ile dłużej trwało?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 4: Własna siatka dla regresji logistycznej

Zbuduj Pipeline ze StandardScaler i LogisticRegression(max_iter=1000, random_state=42), a następnie przeszukaj GridSearchCV siatkę:

  • C: 0,001, 0,01, 0,1, 1, 10, 100,
  • class_weight: None oraz 'balanced'.

To 12 kombinacji × 5 foldów = 60 dopasowań, czyli kilka sekund.

Wypisz najlepsze parametry, najlepszy wynik CV oraz pełną tabelkę cv_results_ posortowaną malejąco po mean_test_score. Zastanów się, co robi class_weight='balanced' przy klasach 66,6% / 33,4% - i dlaczego może obniżyć skuteczność, mimo że brzmi jak ulepszenie.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 5: Siatka kontra losowanie przy tym samym budżecie

Porównaj dwa podejścia do strojenia drzewa przy identycznym budżecie 20 kombinacji:

  1. GridSearchCV po siatce max_depth ∈ {2,...,11} × min_samples_leaf ∈ {1, 20} - to dokładnie 20 kombinacji,
  2. RandomizedSearchCV z n_iter=20 po szerszej przestrzeni (np. max_depth od 2 do 20, min_samples_leaf z listy [1, 2, 5, 10, 20, 50, 100], criterion z ['gini', 'entropy']), random_state=42.

Dla obu wypisz najlepsze parametry, najlepszy wynik CV i czas wykonania. Który wypadł lepiej? Czy różnica przekracza odchylenie standardowe najlepszego wyniku?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 6: Krzywa uczenia dla dobrze dobranego modelu

Narysuj krzywą uczenia dla modelu z najlepszymi parametrami znalezionymi w sekcji 4 (szukanie.best_estimator_). Użyj train_sizes=np.linspace(0.1, 1.0, 6).

Odpowiedz na podstawie wykresu (słowami, w komórce markdown albo w komentarzu):

  1. Czy krzywe się zbiegają?
  2. Czy zebranie kolejnych 8000 pacjentów podniosłoby wynik w sposób odczuwalny?
  3. Gdyby budżet wystarczył na jedną rzecz - więcej danych czy więcej cech (np. wynik dodatkowego badania) - co wybrać i dlaczego?

Komórka liczy się około pół minuty.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 7 (trudniejsze): Zobacz przeciek na własne oczy

W sekcji 7 różnica między poprawnym a błędnym skalowaniem była znikoma. Teraz zobaczysz przeciek, który niszczy wynik całkowicie - i to przy użyciu kodu, który u wielu osób przechodzi przez recenzję bez mrugnięcia okiem.

Plan:

  1. Weź małą próbkę: pierwsze 300 wierszy z X_ucz_pelny i y_ucz_pelny.
  2. Dołóż do nich 500 kolumn czystego szumu: rng = np.random.default_rng(42), szum = rng.normal(size=(300, 500)). Te kolumny z definicji nic nie znaczą.
  3. Wariant błędny: na całej próbce (ze wszystkimi 508 kolumnami) uruchom SelectKBest(f_classif, k=10) i wybierz 10 „najlepszych" cech. Dopiero na tak wybranych cechach policz cross_val_score dla LogisticRegression.
  4. Wariant poprawny: włóż SelectKBest(f_classif, k=10) i LogisticRegression do jednego Pipeline i przekaż ten potok do cross_val_score z pełnym zestawem kolumn.
  5. Wypisz oba wyniki obok siebie razem z wynikiem modelu odniesienia (udział klasy większościowej).

Importy, których potrzebujesz:

from sklearn.feature_selection import SelectKBest, f_classif

Zanim uruchomisz: który wariant da wyższy wynik? Który z nich mówi prawdę o tym, jak model poradzi sobie na nowym pacjencie?

In [ ]:
# TWÓJ KOD TUTAJ

Pytania do przemyślenia

Odpowiadasz słowami, nie kodem.

  1. Zbiór testowy „wolno użyć raz". Co konkretnie się psuje, gdy po zobaczeniu słabego wyniku testowego wracasz i zmieniasz hiperparametr? Czy pomogłoby, gdyby zrobić to tylko dwa razy?
  2. Walidacja krzyżowa na 5 foldach daje pięć wyników. Dlaczego odchylenie standardowe między nimi bywa ważniejsze od średniej, gdy porównujesz dwa modele?
  3. GridSearchCV po dużej siatce sprawdza setki kombinacji i zwraca najlepszą. Czy tu także grozi „oszukiwanie samego siebie" - tym razem na zbiorze walidacyjnym? Kiedy to zaczyna być realnym problemem?
  4. Krzywa uczenia pokazuje dwie krzywe blisko siebie, obie płaskie i niskie. Klient pyta, czy warto kupić dane od drugiego szpitala. Co odpowiadasz i jak to uzasadniasz?
  5. Dlaczego uzupełnianie braków medianą policzoną z całego zbioru to przeciek, mimo że mediana to „tylko jedna liczba" i nie zawiera etykiet?
  6. Na jednym z foldów model wypadł zauważalnie gorzej niż na pozostałych czterech. Wymień dwa różne wyjaśnienia takiej sytuacji i powiedz, jak sprawdzić, które jest prawdziwe.

Chcesz wiedzieć więcej

W kolejnym ćwiczeniu (07 - Drzewa i lasy) wrócimy do drzew decyzyjnych i zobaczymy, co się stanie, gdy zamiast jednego drzewa wytrenujemy ich kilkaset naraz. Narzędzia z tego ćwiczenia - walidacja krzyżowa i GridSearchCV - będą tam podstawowym sposobem porównywania modeli.