Ćwiczenie 7: Drzewa i lasy¶
Po co to ćwiczenie?¶
W ćwiczeniu 01 narysowaliśmy drzewo decyzyjne i przeczytaliśmy jego reguły jak zdania po polsku: „jeśli glukoza ≤ 122,5 oraz BMI ≤ 29,4, to pacjent jest zdrowy". To rzadka i bardzo cenna właściwość - większość modeli uczenia maszynowego jest dla człowieka nieprzejrzysta, a drzewo można pokazać lekarzowi i zapytać, czy to ma sens medyczny.
Zostawiliśmy wtedy otwarte zdanie: „las losowy bywa wyraźnie skuteczniejszy od pojedynczego drzewa, ale nie da się go już narysować na jednej kartce". To ćwiczenie jest o tym zdaniu.
Zobaczysz, jak z czegoś, co da się zrozumieć wzrokiem, powstaje coś, co da się ocenić wyłącznie liczbami - i dlaczego ta zamiana się opłaca. A potem zobaczysz, ile za nią płacisz, gdy ktoś zażąda uzasadnienia decyzji.
Wątek przewodni całego notatnika:
Dokładność i wyjaśnialność (ang. explainability) ciągną w przeciwne strony. Zawsze trzeba wybrać, i ten wybór nie jest techniczny - jest biznesowy albo prawny.
Czego się nauczysz¶
- Jak drzewo dzieli przestrzeń cech i według jakiego kryterium wybiera podziały (wskaźnik Giniego).
- Jak obejrzeć wytrenowane drzewo - funkcją
plot_treei przez rysunek granicy decyzyjnej. - Jak przycinać drzewo:
max_depth,min_samples_leaforaz przycinanie kosztowo-złożonościowe (ccp_alpha). - Dlaczego las losowy - setki słabszych drzew - bije pojedyncze dobre drzewo.
- Czym boosting różni się od lasu i kiedy który wybrać.
- Jak czytać ważność cech (ang. feature importance) - i dlaczego ta domyślna potrafi wprowadzać w błąd.
Zanim zaczniesz: to ćwiczenie zakłada, że masz za sobą ćwiczenie 06. Modele będziemy porównywać walidacją krzyżową (ang. cross-validation), a nie wynikiem na zbiorze testowym.
1. Jak drzewo podejmuje decyzje¶
Wczytujemy dane dokładnie tak jak poprzednio.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
dane = pd.read_csv('dane/diabetes.csv')
X = dane.drop(columns=['PatientID', 'Diabetic'])
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
)
kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
print(f"uczący: {len(X_ucz)}, testowy: {len(X_test)}, cech: {X.shape[1]}")
Wskaźnik Giniego - jak drzewo wybiera pytanie¶
Drzewo buduje się zachłannie: w każdym węźle algorytm sprawdza każdą cechę i każdy sensowny próg i wybiera ten podział, który najbardziej „porządkuje" dane. Potrzebna jest więc miara nieporządku.
Domyślnie jest nią wskaźnik Giniego (ang. Gini impurity). Dla węzła, w którym udział klasy i wynosi pi:
Gini = 1 - Σ p_i²
Przy dwóch klasach:
| Skład węzła | Gini | Interpretacja |
|---|---|---|
| 100% zdrowych | 0,0 | węzeł czysty - nie ma czego dzielić |
| 90% / 10% | 0,18 | prawie czysty |
| 66,6% / 33,4% | 0,445 | tyle ma korzeń naszego drzewa |
| 50% / 50% | 0,5 | maksymalny nieporządek |
Algorytm wybiera podział, który maksymalizuje spadek nieczystości:
zysk = Gini(rodzic) - [ udział_lewego × Gini(lewy) + udział_prawego × Gini(prawy) ]
Kluczowe jest to ważenie udziałami: podział odcinający trzech pacjentów do idealnie czystego liścia daje minimalny zysk, bo dotyczy znikomej części danych.
Policzmy to ręcznie dla korzenia - żeby liczba z rysunku plot_tree przestała być magiczna.
def gini(etykiety):
etykiety = np.asarray(etykiety)
if len(etykiety) == 0:
return 0.0
udzialy = np.bincount(etykiety, minlength=2) / len(etykiety)
return 1.0 - np.sum(udzialy ** 2)
print(f"Gini całego zbioru uczącego: {gini(y_ucz):.4f}")
print()
# Recznie sprawdzamy kilka progow dla jednej cechy
cecha = 'PlasmaGlucose'
wartosci = X_ucz[cecha].to_numpy()
etykiety = y_ucz.to_numpy()
gini_rodzica = gini(etykiety)
print(f"Zysk z podziału po cesze {cecha}:")
for prog in [80, 100, 110, 120, 130, 150]:
lewo = etykiety[wartosci <= prog]
prawo = etykiety[wartosci > prog]
wazone = (len(lewo) * gini(lewo) + len(prawo) * gini(prawo)) / len(etykiety)
print(f" próg {prog:3d}: Gini po podziale {wazone:.4f} | zysk {gini_rodzica - wazone:.4f}")
Zwróć uwagę na kształt tej listy: zysk rośnie, osiąga szczyt przy pewnym progu i znów maleje. Drzewo robi dokładnie to samo, tylko dla wszystkich cech naraz i wszystkich progów występujących w danych, po czym wybiera najlepszą parę (cecha, próg). Potem powtarza to w każdym z powstałych węzłów.
To wyjaśnia dwie rzeczy, o które studenci zwykle pytają:
- Dlaczego drzewo nie potrzebuje skalowania? Bo porównuje wartości cechy z progiem wewnątrz tej samej cechy. Zamiana jednostek zmienia tylko liczbę w warunku, nie kolejność wierszy - a podział zależy wyłącznie od kolejności.
- Dlaczego drzewo nie znajduje rozwiązania optymalnego? Bo jest zachłanne: wybiera najlepszy podział tu i teraz, nie sprawdzając, czy gorszy podział na górze nie otworzyłby lepszych możliwości niżej. Znalezienie optymalnego drzewa jest problemem obliczeniowo nieprzystępnym.
criterion='entropy'to alternatywna miara nieporządku (entropia). Prowadzi do bardzo podobnych drzew - różnica jest zwykle pomijalna i nie warto się nad nią zatrzymywać.
2. Jak drzewo widzi przestrzeń cech¶
Drzewo zadaje pytania postaci „cecha ≤ próg", więc każda granica, którą rysuje, jest prostopadła do osi. Efekt: przestrzeń cech zostaje pocięta na prostokąty, a wewnątrz każdego prostokąta model odpowiada zawsze tak samo.
Żeby to zobaczyć, ograniczymy się do dwóch cech (PlasmaGlucose i BMI) - tylko wtedy da się narysować płaszczyznę. Porównamy dwa drzewa: płytkie i bez ograniczeń.
from sklearn.tree import DecisionTreeClassifier, plot_tree
dwie_cechy = ['PlasmaGlucose', 'BMI']
X2 = X_ucz[dwie_cechy].to_numpy()
y2 = y_ucz.to_numpy()
# Siatka punktow pokrywajaca plaszczyzne
os_x = np.linspace(X2[:, 0].min(), X2[:, 0].max(), 300)
os_y = np.linspace(X2[:, 1].min(), X2[:, 1].max(), 300)
siatka_x, siatka_y = np.meshgrid(os_x, os_y)
punkty = np.c_[siatka_x.ravel(), siatka_y.ravel()]
fig, osie = plt.subplots(1, 2, figsize=(13, 5.5))
for ax, glebokosc, tytul in [(osie[0], 3, 'max_depth=3'),
(osie[1], None, 'bez ograniczeń')]:
d = DecisionTreeClassifier(max_depth=glebokosc, random_state=42).fit(X2, y2)
Z = d.predict(punkty).reshape(siatka_x.shape)
ax.contourf(siatka_x, siatka_y, Z, alpha=0.25, cmap='coolwarm', levels=1)
probka = np.random.default_rng(42).choice(len(X2), 800, replace=False)
ax.scatter(X2[probka, 0], X2[probka, 1], c=y2[probka],
cmap='coolwarm', s=8, edgecolors='none', alpha=0.7)
ax.set_xlabel(dwie_cechy[0])
ax.set_ylabel(dwie_cechy[1])
ax.set_title(f"{tytul} (liczba liści: {d.get_n_leaves()})")
plt.tight_layout()
plt.show()
Po lewej widać kilka dużych prostokątów - prostą, czytelną regułę. Po prawej ta sama przestrzeń jest poszatkowana na setki drobnych plamek, z których wiele obejmuje po kilku pacjentów. Te plamki to przeuczenie (ang. overfitting) narysowane wprost: model nie opisuje zjawiska, tylko obrysowuje pojedyncze punkty ze zbioru uczącego.
Zwróć też uwagę na schodkowy kształt granic. Gdyby prawdziwa granica biegła ukośnie (np. „glukoza + 3 × BMI > 200"), drzewo musiałoby przybliżać ją schodkami - i potrzebowałoby na to wielu podziałów. Regresja logistyczna narysowałaby ją jedną prostą. To jest realna słabość drzew i warto ją pamiętać.
Ten sam model na rysunku drzewa¶
plot_tree pokazuje tę samą informację w drugiej postaci. Rysujemy tylko płytkie drzewo - głębsze byłoby nieczytelne (przekonasz się w zadaniu 2).
drzewo = DecisionTreeClassifier(max_depth=3, random_state=42)
drzewo.fit(X_ucz, y_ucz)
fig, ax = plt.subplots(figsize=(18, 9))
plot_tree(
drzewo,
feature_names=X.columns,
class_names=['brak cukrzycy', 'cukrzyca'],
filled=True, rounded=True, fontsize=9, ax=ax,
)
plt.tight_layout()
plt.show()
print("Liczba liści:", drzewo.get_n_leaves())
print("Głębokość: ", drzewo.get_depth())
3. Przycinanie drzewa¶
Drzewo bez ograniczeń rośnie, aż każdy liść będzie czysty - w skrajnym przypadku po jednym pacjencie na liść. Trzeba mu w tym przeszkodzić. Scikit-learn daje trzy główne sposoby:
| Parametr | Co ogranicza | Kiedy szczególnie przydatny |
|---|---|---|
max_depth |
liczbę poziomów | najprostszy do zrozumienia i do wytłumaczenia komuś |
min_samples_leaf |
minimalną liczbę pacjentów w liściu | chroni przed liśćmi opartymi na 2-3 przypadkach; zwykle skuteczniejszy niż max_depth |
ccp_alpha |
„opłacalność" każdego liścia | przycinanie po zbudowaniu drzewa; najbardziej zasadne teoretycznie |
Pierwsze dwa to przycinanie wstępne (ang. pre-pruning) - zatrzymujemy wzrost. ccp_alpha to przycinanie następcze (ang. post-pruning): najpierw pozwalamy drzewu urosnąć, potem obcinamy gałęzie, które nie zarabiają na siebie.
Idea ccp_alpha (przycinanie kosztowo-złożonościowe, ang. cost-complexity pruning): każdemu drzewu przypisujemy koszt
koszt = błąd na danych uczących + alpha × liczba liści
Przy alpha = 0 wygrywa drzewo pełne. Im większa alpha, tym droższy każdy liść - i tym mocniej drzewo się kurczy. Przy dostatecznie dużej alpha zostaje sam korzeń.
Różnica wobec max_depth jest istotna: max_depth tnie równo na całej szerokości, nawet tam, gdzie gałąź była wartościowa. ccp_alpha tnie wybiórczo - zostawia głębokie gałęzie tam, gdzie się opłacają.
# Sciezka przycinania: sklearn wylicza wszystkie wartosci alpha, przy ktorych
# drzewo zmienia ksztalt. Liczy sie kilka sekund.
pelne = DecisionTreeClassifier(random_state=42)
sciezka = pelne.cost_complexity_pruning_path(X_ucz, y_ucz)
alfy = sciezka.ccp_alphas
print(f"Liczba różnych wartości alpha: {len(alfy)}")
print(f"Zakres: od {alfy.min():.6f} do {alfy.max():.6f}")
# Wybieramy kilkanascie wartosci rozlozonych logarytmicznie - sprawdzenie
# wszystkich trwaloby zdecydowanie za dlugo.
kandydaci = np.unique(np.quantile(alfy[alfy > 0], np.linspace(0.5, 1.0, 12)))
wiersze = []
for a in kandydaci:
d = DecisionTreeClassifier(random_state=42, ccp_alpha=a).fit(X_ucz, y_ucz)
wiersze.append({'ccp_alpha': a, 'liczba_lisci': d.get_n_leaves(),
'glebokosc': d.get_depth(),
'uczacy': d.score(X_ucz, y_ucz)})
tabela = pd.DataFrame(wiersze)
print()
print(tabela.to_string(index=False, float_format=lambda v: f"{v:.6f}"))
Widać tu wprost, o co chodzi w przycinaniu: przy rosnącej alpha liczba liści spada gwałtownie - z tysięcy do kilkunastu - a skuteczność na danych uczących obniża się nieporównanie wolniej. Tysiące liści prawie nic nie wnosiły; istniały tylko po to, żeby obsłużyć pojedyncze przypadki.
Którą alpha wybrać? Tę, która daje najlepszy wynik walidacji krzyżowej - i dokładnie to zrobisz w zadaniu 3.
4. Las losowy - dlaczego wiele słabszych drzew bije jedno dobre¶
Pojedyncze drzewo ma jedną fundamentalną wadę: jest niestabilne. Zmień kilkadziesiąt wierszy w danych uczących, a drzewo potrafi wybrać w korzeniu inną cechę i wyjść zupełnie inne. Mówimy, że ma dużą wariancję (ang. variance).
Las losowy (ang. random forest) atakuje dokładnie ten problem. Pomysł opiera się na spostrzeżeniu, które łatwo sprawdzić: średnia z wielu zaszumionych pomiarów jest mniej zaszumiona niż pojedynczy pomiar. Jeśli zbudujemy setki drzew, z których każde myli się trochę inaczej, i pozwolimy im głosować, to ich przypadkowe błędy częściowo się zniosą.
Warunek jest jednak twardy: drzewa muszą się od siebie różnić. Sto identycznych drzew to wciąż jedno drzewo. Dlatego las wprowadza losowość dwa razy:
1. Losowanie przykładów (ang. bagging, od bootstrap aggregating) Każde drzewo uczy się na własnej próbce losowanej ze zwracaniem z danych uczących, tej samej wielkości co oryginał. Skutek: niektóre wiersze trafiają do niej wielokrotnie, inne wcale (średnio ok. 37% zbioru nie trafia do danego drzewa). Każde drzewo widzi więc trochę inny świat.
2. Losowanie cech w każdym węźle (max_features)
To jest różnica między lasem losowym a zwykłym baggingiem i jest ona kluczowa. Przy każdym podziale drzewo dostaje do wyboru tylko losowy podzbiór cech (domyślnie √p, u nas √8 ≈ 3 z 8).
Po co? Bez tego wszystkie drzewa wybierałyby w korzeniu tę samą, najsilniejszą cechę (u nas prawie na pewno PlasmaGlucose) i byłyby do siebie bardzo podobne - a wtedy uśrednianie niewiele daje, bo popełniałyby te same błędy. Losowanie cech zmusza część drzew do korzystania ze słabszych cech i rozprasza korelację między drzewami. Las płaci za to tym, że pojedyncze drzewo jest gorsze - i zarabia na tym, że całość jest lepsza.
| Pojedyncze drzewo | Las losowy | |
|---|---|---|
| Obciążenie (ang. bias) | małe | podobne, lekko większe |
| Wariancja | duża | znacznie mniejsza |
| Przeuczenie przy wzroście złożoności | rośnie | prawie nie rośnie z liczbą drzew |
| Da się obejrzeć | tak | nie |
Ostatni wiersz to cena, którą płacimy - i wątek przewodni tego ćwiczenia.
from sklearn.ensemble import RandomForestClassifier
# Porownanie przez walidacje krzyzowa. Las z 200 drzewami na 8000 wierszy:
# okolo 20-40 sekund przy n_jobs=-1.
drzewo_dobre = DecisionTreeClassifier(max_depth=5, min_samples_leaf=10, random_state=42)
las = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)
w_drzewo = cross_val_score(drzewo_dobre, X_ucz, y_ucz, cv=kfold)
w_las = cross_val_score(las, X_ucz, y_ucz, cv=kfold)
print(f"Pojedyncze drzewo: {w_drzewo.mean():.4f} ± {w_drzewo.std():.4f}")
print(f"Las losowy (200): {w_las.mean():.4f} ± {w_las.std():.4f}")
print(f"Różnica: {w_las.mean() - w_drzewo.mean():+.4f}")
Zobaczmy „rozpraszanie błędów" na własne oczy¶
Poniższy kod trenuje 50 pojedynczych drzew, każde na innej losowej próbce danych, i sprawdza dwie rzeczy: jak dobre jest przeciętne drzewo i jak dobre jest ich wspólne głosowanie.
rng = np.random.default_rng(42)
X_ucz_np, y_ucz_np = X_ucz.to_numpy(), y_ucz.to_numpy()
# Wydzielamy maly zbior sprawdzajacy (to nie jest zbior testowy!)
X_a, X_b, y_a, y_b = train_test_split(X_ucz_np, y_ucz_np, test_size=0.25,
stratify=y_ucz_np, random_state=42)
glosy = []
wyniki_pojedyncze = []
for i in range(50):
indeksy = rng.integers(0, len(X_a), len(X_a)) # losowanie ZE ZWRACANIEM
d = DecisionTreeClassifier(random_state=42).fit(X_a[indeksy], y_a[indeksy])
glosy.append(d.predict(X_b))
wyniki_pojedyncze.append(d.score(X_b, y_b))
glosy = np.array(glosy)
wieksz = (glosy.mean(axis=0) >= 0.5).astype(int) # glosowanie wiekszosciowe
print(f"Najgorsze z 50 drzew: {min(wyniki_pojedyncze):.4f}")
print(f"Przeciętne drzewo: {np.mean(wyniki_pojedyncze):.4f}")
print(f"Najlepsze z 50 drzew: {max(wyniki_pojedyncze):.4f}")
print(f"GŁOSOWANIE wszystkich 50: {(wieksz == y_b).mean():.4f}")
print()
print("Zwróć uwagę: głosowanie bije nawet najlepsze pojedyncze drzewo -")
print("a przecież każde z osobna jest przeuczone i niczym nieograniczone.")
To jest sedno metod zespołowych (ang. ensemble methods). Nie potrzebujemy dobrych składników - potrzebujemy składników, które mylą się niezależnie od siebie.
Uwaga na często spotykane nieporozumienie: liczba drzew (n_estimators) nie jest hiperparametrem, który się stroi w poszukiwaniu optimum. Więcej drzew nigdy nie pogarsza wyniku, tylko przestaje go poprawiać (i kosztuje czas). Zwykle 100-500 wystarcza; sprawdzisz to w zadaniu 4.
5. Boosting - drzewa, które uczą się na cudzych błędach¶
Las buduje wszystkie drzewa równolegle i niezależnie, a potem je uśrednia. Boosting robi coś przeciwnego: buduje drzewa po kolei, a każde kolejne zajmuje się tym, czego poprzednie nie potrafiły.
W gradient boostingu wygląda to tak:
- Zbuduj bardzo proste drzewo (kilka poziomów). Będzie słabe.
- Policz, gdzie i jak bardzo model się myli.
- Zbuduj kolejne drzewo, które przewiduje te właśnie błędy.
- Dodaj je do modelu z małą wagą (
learning_rate). - Wróć do punktu 2 - setki razy.
| Las losowy | Gradient boosting | |
|---|---|---|
| Jak powstają drzewa | równolegle, niezależnie | sekwencyjnie, każde poprawia poprzednie |
| Rola pojedynczego drzewa | pełnoprawny model (głęboki) | drobna poprawka (płytkie, np. 3 poziomy) |
| Co redukuje | wariancję | głównie obciążenie |
| Więcej drzew | nie szkodzi | może przeuczyć |
| Wrażliwość na hiperparametry | mała - działa „z pudełka" | duża, zwłaszcza learning_rate |
| Zwykle wygrywa na danych tabelarycznych | rzadziej | częściej |
Kluczowy jest learning_rate (ang. tempo uczenia): mówi, jaką część poprawki dopisujemy w każdym kroku. Mała wartość plus dużo drzew zwykle daje lepszy wynik niż duża wartość plus mało drzew - ale liczy się dłużej. learning_rate i n_estimators zawsze strojymy razem, bo działają przeciwstawnie.
Użyjemy HistGradientBoostingClassifier - wariantu, który grupuje wartości cech w kubełki (histogramy) i przez to jest wielokrotnie szybszy od klasycznego GradientBoostingClassifier. Przy 10 000 wierszy różnica czasu jest już wyraźna.
from sklearn.ensemble import HistGradientBoostingClassifier
import time
boosting = HistGradientBoostingClassifier(random_state=42)
start = time.perf_counter()
w_boost = cross_val_score(boosting, X_ucz, y_ucz, cv=kfold)
czas = time.perf_counter() - start
print(f"Pojedyncze drzewo: {w_drzewo.mean():.4f} ± {w_drzewo.std():.4f}")
print(f"Las losowy (200 drzew): {w_las.mean():.4f} ± {w_las.std():.4f}")
print(f"HistGradientBoosting: {w_boost.mean():.4f} ± {w_boost.std():.4f} ({czas:.1f} s)")
Skąd tyle nazw:
GradientBoostingClassifierto klasyczna, wolna implementacja;HistGradientBoostingClassifierto szybki wariant wbudowany w scikit-learn. Poza scikit-learn królują XGBoost, LightGBM i CatBoost - wszystkie realizują ten sam pomysł, różnią się szczegółami i szybkością. To one wygrywają większość konkursów na danych tabelarycznych.
6. Ważność cech - i dlaczego domyślna potrafi kłamać¶
Skoro lasu nie da się narysować, chcemy chociaż wiedzieć, na czym opiera decyzje. Służy do tego ważność cech (ang. feature importance).
feature_importances_ w drzewach i lasach to ważność liczona z czystości węzłów (ang. impurity-based albo Gini importance): dla każdej cechy sumuje się spadek nieczystości we wszystkich węzłach, w których ta cecha została użyta, ważony liczbą pacjentów w węźle.
Liczy się błyskawicznie (to uboczny produkt trenowania) - i ma dwie poważne wady:
- Faworyzuje cechy o wielu różnych wartościach. Cecha ciągła albo identyfikator dają algorytmowi tysiące progów do wyboru, więc statystycznie któryś z nich zawsze coś „poprawi". Cecha binarna ma jeden próg. W ćwiczeniu 01, w zadaniu 6,
PatientID- kolumna bez żadnego sensu - potrafiła wylądować wysoko w tym rankingu. To nie był przypadek, tylko właśnie ten mechanizm. - Liczona jest na danych uczących. Cecha, dzięki której model świetnie zapamiętał zbiór uczący, dostaje wysoką ważność, nawet jeśli na nowych pacjentach nie wnosi nic.
Uczciwsza alternatywa to ważność permutacyjna (ang. permutation importance): bierzemy wytrenowany model i dane, których nie widział, po czym losowo mieszamy wartości w jednej kolumnie i sprawdzamy, o ile spadł wynik. Jeśli spadł mocno - cecha jest ważna. Jeśli wcale - model jej nie potrzebuje.
feature_importances_ |
permutation_importance |
|
|---|---|---|
| Na jakich danych | uczących | dowolnych, najlepiej nieużywanych do uczenia |
| Koszt | zerowy | trzeba wielokrotnie przeliczyć predykcje |
| Faworyzuje cechy o wielu wartościach | tak | nie |
| Mierzy | ile cecha pomogła zbudować drzewo | ile cecha wnosi do jakości predykcji |
| Problem ze skorelowanymi cechami | tak (dzieli ważność) | tak (obie wyjdą nieważne) |
from sklearn.inspection import permutation_importance
las_ucz = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)
las_ucz.fit(X_ucz, y_ucz)
waznosc_gini = pd.Series(las_ucz.feature_importances_, index=X.columns)
# Permutacyjna - liczona na danych, ktorych model nie widzial.
# Uwaga: uzywamy tu zbioru testowego wylacznie do INTERPRETACJI modelu,
# a nie do jego wyboru. Kilkanascie sekund.
wynik_perm = permutation_importance(
las_ucz, X_test, y_test, n_repeats=10, random_state=42, n_jobs=-1
)
waznosc_perm = pd.Series(wynik_perm.importances_mean, index=X.columns)
porownanie = pd.DataFrame({
'gini (uczące)': waznosc_gini,
'permutacyjna (testowe)': waznosc_perm,
}).sort_values('permutacyjna (testowe)', ascending=False)
print(porownanie.to_string(float_format=lambda v: f"{v:.4f}"))
fig, ax = plt.subplots(figsize=(9, 5))
porownanie.plot.barh(ax=ax)
ax.set_xlabel('ważność')
ax.set_title('Dwie miary ważności cech - ta sama kolejność?')
ax.invert_yaxis()
plt.tight_layout()
plt.show()
Porównaj obie kolumny. Jeśli kolejność się zgadza - dobrze, wnioski są wiarygodne. Jeśli któraś cecha jest wysoko według Giniego, a nisko według permutacji - to sygnał ostrzegawczy: model jej używa przy budowie drzew, ale nie przekłada się to na jakość predykcji.
Najważniejsze zastrzeżenie do obu miar: ważność cechy nie jest miarą przyczynowości. Że model mocno opiera się na glukozie, nie znaczy, że obniżenie glukozy wyleczy pacjenta. Model widzi wyłącznie współwystępowanie. Mylenie tych dwóch rzeczy to najczęstszy błąd w prezentacjach wyników - i najkosztowniejszy, bo decyzje podejmuje się właśnie na podstawie takich slajdów.
7. Dokładność kontra wyjaśnialność¶
Zbierzmy wątek przewodni w jedną tabelę.
| Model | Skuteczność | Czy da się obejrzeć | Co można powiedzieć lekarzowi |
|---|---|---|---|
Pień decyzyjny (max_depth=1) |
najniższa | jedno zdanie | „decyduje poziom glukozy powyżej X" |
Drzewo max_depth=3-5 |
dobra | rysunek na jednej kartce | pełna ścieżka decyzji dla konkretnego pacjenta |
| Las losowy (200 drzew) | wysoka | nie | ranking ważności cech - i nic więcej |
| Gradient boosting | najwyższa | nie | jw. |
To nie jest tabela, z której wybiera się ostatni wiersz. Wybór zależy od tego, do czego model służy:
- System podpowiadający lekarzowi diagnozę musi umieć uzasadnić każdą rekomendację - lekarz bierze na siebie odpowiedzialność i nie podpisze się pod „bo model tak powiedział". Drzewo albo regresja logistyczna.
- Decyzja kredytowa podlega w Unii Europejskiej prawu do wyjaśnienia decyzji zautomatyzowanej (RODO). Model niewytłumaczalny bywa nie do wdrożenia z przyczyn prawnych, niezależnie od dokładności.
- Ranking pacjentów do zaproszenia na badanie przesiewowe nikogo nie krzywdzi i nie wymaga uzasadnienia wobec pojedynczej osoby. Tu bierzemy najdokładniejszy model, jaki mamy.
Istnieją metody wyjaśniania modeli nieprzejrzystych po fakcie (SHAP, LIME) - ale dają przybliżenie działania modelu, a nie jego rzeczywistą regułę. To osobny, obszerny temat.
Zadania¶
Modele porównuj walidacją krzyżową na X_ucz / y_ucz (obiekt kfold), a nie wynikiem na zbiorze testowym - tak jak w ćwiczeniu 06.
Zadanie 1: Policz Gini sam¶
- Wytrenuj
DecisionTreeClassifier(max_depth=1, random_state=42)naX_ucz,y_ucz. - Odczytaj z modelu cechę i próg wybrane w korzeniu:
drzewo.tree_.feature[0]orazdrzewo.tree_.threshold[0](indeks cechy odnosi się do kolejności kolumn wX.columns). - Korzystając z funkcji
giniz sekcji 1, policz ręcznie: Gini korzenia, Gini obu liści oraz ważony zysk z tego podziału. - Porównaj swoje liczby z wartościami widocznymi na rysunku
plot_treetego samego drzewa.
Jeśli liczby się zgadzają, rozumiesz, co drzewo faktycznie robi.
# TWÓJ KOD TUTAJ
Zadanie 2: Czytelność drzewa a jego głębokość¶
- Narysuj
plot_treedla drzew omax_depth= 2, 4 i 6 (trzy osobne rysunki,figsizeco najmniej (18, 9)). - Dla każdego wypisz: liczbę liści, głębokość oraz wynik walidacji krzyżowej.
- Spróbuj narysować drzewo bez ograniczenia głębokości.
Odpowiedz sobie: przy której głębokości rysunek przestaje być czytelny? Porównaj tę wartość z głębokością, przy której wynik walidacji krzyżowej przestaje rosnąć. Czy to ta sama wartość?
# TWÓJ KOD TUTAJ
Zadanie 3: Przycinanie przez ccp_alpha¶
Wybierz ccp_alpha uczciwie - walidacją krzyżową.
- Weź kandydatów wyliczonych w sekcji 3 (zmienna
kandydaci). - Dla każdego policz
cross_val_scoredlaDecisionTreeClassifier(random_state=42, ccp_alpha=a). - Zbierz wyniki w
DataFrame:ccp_alpha, średnia CV, odchylenie, liczba liści drzewa wytrenowanego na całymX_ucz. - Narysuj wykres: średnia CV w funkcji
ccp_alpha(oś X w skali logarytmicznej:ax.set_xscale('log')). - Wskaż najlepszą
alphai porównaj wynik z drzewemmax_depth=5, min_samples_leaf=10.
Powinno to zająć około minuty. Pytanie dodatkowe: czy najlepsza alpha daje drzewo, które da się jeszcze narysować?
# TWÓJ KOD TUTAJ
Zadanie 4: Ile drzew potrzebuje las?¶
Sprawdź, jak wynik lasu zależy od n_estimators.
- Dla
n_estimators∈ {1, 5, 10, 25, 50, 100, 200} policzcross_val_scoredlaRandomForestClassifier(random_state=42, n_jobs=-1)i zmierz czas. - Narysuj wykres: średnia CV w funkcji liczby drzew (z zaznaczonym odchyleniem, np.
ax.errorbar). - Wskaż, od której wartości krzywa przestaje rosnąć w sposób odczuwalny.
Uwaga na czas: całość to około 400 dopasowań drzew, czyli mniej więcej minuta. Nie zwiększaj listy powyżej 200.
Zastanów się: dlaczego przy n_estimators=1 las wypada gorzej od przyciętego drzewa z sekcji 4, mimo że to „ten sam algorytm"?
# TWÓJ KOD TUTAJ
Zadanie 5: Czy losowanie cech naprawdę pomaga?¶
To zadanie sprawdza twierdzenie z sekcji 4 - że losowanie cech w węzłach jest tym, co odróżnia las losowy od zwykłego uśredniania drzew.
Dla max_features ∈ {1, 2, 'sqrt', 4, 6, None} policz cross_val_score dla RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1) i wypisz wyniki w tabeli.
Przypomnienie: max_features=None oznacza „rozważaj wszystkie cechy w każdym węźle" - czyli las bez drugiego źródła losowości, sam bagging.
Pytanie: czy None wypadło najlepiej? Jeśli nie - dlaczego ograniczanie modelowi wyboru poprawia wynik?
# TWÓJ KOD TUTAJ
Zadanie 6: Boosting - learning_rate i liczba drzew¶
- Dla
HistGradientBoostingClassifier(random_state=42)sprawdź kombinacje:learning_rate∈ {0,01, 0,1, 0,5} ×max_iter∈ {50, 200}. To 6 kombinacji, każda przezcross_val_score- około minuty. - Zbierz wyniki w tabeli i wypisz posortowane malejąco.
- Porównaj najlepszy wynik z lasem z sekcji 4.
Odpowiedz: przy którym learning_rate zwiększenie max_iter z 50 do 200 pomogło najbardziej, a przy którym nie zmieniło nic (albo zaszkodziło)? Jak to się ma do opisu boostingu z sekcji 5?
# TWÓJ KOD TUTAJ
Zadanie 7 (trudniejsze): Zdemaskuj ważność cech¶
Sprawdzisz teraz empirycznie zarzut postawiony w sekcji 6: że feature_importances_ faworyzuje cechy o wielu różnych wartościach.
Zbuduj
X_plus=X_uczz dwiema dodatkowymi kolumnami czystego szumu (rng = np.random.default_rng(42)):szum_ciagly- wartości z rozkładu normalnego (rng.normal(size=len(X_ucz))), praktycznie same unikalne,szum_binarny- wartości 0/1 (rng.integers(0, 2, size=len(X_ucz))).
Obie są całkowicie niezależne od etykiety (ang. label) - żadna nie niesie informacji.
Wytrenuj na
X_pluslas (n_estimators=100, random_state=42).Wypisz
feature_importances_posortowane malejąco. Na którym miejscu wylądowałszum_ciagly, a na którymszum_binarny?Policz
permutation_importancena odpowiednio rozszerzonym zbiorze testowym i porównaj obie miary dla obu kolumn szumu.Sprawdź, czy dodanie kolumn szumu zmieniło wynik walidacji krzyżowej lasu.
Punkt 3 jest sednem zadania. Zanim uruchomisz kod - czy spodziewasz się, że obie kolumny szumu dostaną podobną ważność Giniego?
Podpowiedź do punktu 1:
X_plus = X_ucz.copy()
X_plus['szum_ciagly'] = rng.normal(size=len(X_ucz))
X_plus['szum_binarny'] = rng.integers(0, 2, size=len(X_ucz))
# TWÓJ KOD TUTAJ
Pytania do przemyślenia¶
Odpowiadasz słowami, nie kodem.
- Las losowy składa się z drzew, z których każde jest przeuczone (rosną bez ograniczeń). Dlaczego cały las nie jest przeuczony w tym samym stopniu?
- Dlaczego zwiększanie
n_estimatorsw lesie praktycznie nie grozi przeuczeniem, a zwiększanien_estimatorsw boostingu - tak? Skąd bierze się ta różnica? - Granica decyzyjna drzewa składa się z odcinków prostopadłych do osi. Jak wyglądałaby granica narysowana przez las stu drzew? Czy nadal byłaby „schodkowa"?
- Cecha A i cecha B są ze sobą silnie skorelowane i obie dobrze przewidują chorobę. Co pokaże
feature_importances_lasu, a copermutation_importance? Która z tych odpowiedzi jest bardziej myląca dla odbiorcy raportu? - Szpital chce wdrożyć model przesiewowy. Wersja A: drzewo o głębokości 4, skuteczność 76%. Wersja B: gradient boosting, skuteczność 79%. Jakie trzy pytania musisz zadać zamawiającemu, zanim doradzisz wybór?
- W ćwiczeniu 01
PatientIDwylądował wysoko w rankingu ważności cech pojedynczego drzewa. Czy w lesie losowym byłby równie wysoko, niżej, czy wyżej? Uzasadnij, odwołując się do losowania cech w węzłach.
Chcesz wiedzieć więcej¶
- Drzewa decyzyjne w scikit-learn - w tym sekcja o przycinaniu kosztowo-złożonościowym i o złożoności obliczeniowej.
- Metody zespołowe - lasy, bagging, boosting i głosowanie w jednym miejscu.
plot_tree- zobacz teżexport_text, które wypisuje drzewo jako tekst (przydatne, gdy rysunek jest za duży).- Ważność permutacyjna - z przykładem pokazującym problem cech o wielu wartościach.
- Pułapki interpretacji ważności cech - gotowy przykład ze skorelowanymi cechami.
W kolejnym ćwiczeniu (08 - Uczenie nienadzorowane) zniknie etykieta. Zamiast pytać „czy ten pacjent choruje", zapytamy „czy w tych danych da się w ogóle znaleźć jakieś grupy pacjentów" - i okaże się, że bez etykiety zmienia się nie tylko algorytm, ale i sposób oceniania wyników.