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

Ć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

  1. Jak drzewo dzieli przestrzeń cech i według jakiego kryterium wybiera podziały (wskaźnik Giniego).
  2. Jak obejrzeć wytrenowane drzewo - funkcją plot_tree i przez rysunek granicy decyzyjnej.
  3. Jak przycinać drzewo: max_depth, min_samples_leaf oraz przycinanie kosztowo-złożonościowe (ccp_alpha).
  4. Dlaczego las losowy - setki słabszych drzew - bije pojedyncze dobre drzewo.
  5. Czym boosting różni się od lasu i kiedy który wybrać.
  6. 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.

In [ ]:
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.

In [ ]:
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ń.

In [ ]:
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).

In [ ]:
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ą.

In [ ]:
# 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.

In [ ]:
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.

In [ ]:
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:

  1. Zbuduj bardzo proste drzewo (kilka poziomów). Będzie słabe.
  2. Policz, gdzie i jak bardzo model się myli.
  3. Zbuduj kolejne drzewo, które przewiduje te właśnie błędy.
  4. Dodaj je do modelu z małą wagą (learning_rate).
  5. 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.

In [ ]:
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: GradientBoostingClassifier to klasyczna, wolna implementacja; HistGradientBoostingClassifier to 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:

  1. 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.
  2. 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)
In [ ]:
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

  1. Wytrenuj DecisionTreeClassifier(max_depth=1, random_state=42) na X_ucz, y_ucz.
  2. Odczytaj z modelu cechę i próg wybrane w korzeniu: drzewo.tree_.feature[0] oraz drzewo.tree_.threshold[0] (indeks cechy odnosi się do kolejności kolumn w X.columns).
  3. Korzystając z funkcji gini z sekcji 1, policz ręcznie: Gini korzenia, Gini obu liści oraz ważony zysk z tego podziału.
  4. Porównaj swoje liczby z wartościami widocznymi na rysunku plot_tree tego samego drzewa.

Jeśli liczby się zgadzają, rozumiesz, co drzewo faktycznie robi.

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 2: Czytelność drzewa a jego głębokość

  1. Narysuj plot_tree dla drzew o max_depth = 2, 4 i 6 (trzy osobne rysunki, figsize co najmniej (18, 9)).
  2. Dla każdego wypisz: liczbę liści, głębokość oraz wynik walidacji krzyżowej.
  3. 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ść?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 3: Przycinanie przez ccp_alpha

Wybierz ccp_alpha uczciwie - walidacją krzyżową.

  1. Weź kandydatów wyliczonych w sekcji 3 (zmienna kandydaci).
  2. Dla każdego policz cross_val_score dla DecisionTreeClassifier(random_state=42, ccp_alpha=a).
  3. Zbierz wyniki w DataFrame: ccp_alpha, średnia CV, odchylenie, liczba liści drzewa wytrenowanego na całym X_ucz.
  4. Narysuj wykres: średnia CV w funkcji ccp_alpha (oś X w skali logarytmicznej: ax.set_xscale('log')).
  5. Wskaż najlepszą alpha i porównaj wynik z drzewem max_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ć?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 4: Ile drzew potrzebuje las?

Sprawdź, jak wynik lasu zależy od n_estimators.

  1. Dla n_estimators ∈ {1, 5, 10, 25, 50, 100, 200} policz cross_val_score dla RandomForestClassifier(random_state=42, n_jobs=-1) i zmierz czas.
  2. Narysuj wykres: średnia CV w funkcji liczby drzew (z zaznaczonym odchyleniem, np. ax.errorbar).
  3. 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"?

In [ ]:
# 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?

In [ ]:
# TWÓJ KOD TUTAJ

Zadanie 6: Boosting - learning_rate i liczba drzew

  1. Dla HistGradientBoostingClassifier(random_state=42) sprawdź kombinacje: learning_rate ∈ {0,01, 0,1, 0,5} × max_iter ∈ {50, 200}. To 6 kombinacji, każda przez cross_val_score - około minuty.
  2. Zbierz wyniki w tabeli i wypisz posortowane malejąco.
  3. 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?

In [ ]:
# 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.

  1. Zbuduj X_plus = X_ucz z 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.

  2. Wytrenuj na X_plus las (n_estimators=100, random_state=42).

  3. Wypisz feature_importances_ posortowane malejąco. Na którym miejscu wylądował szum_ciagly, a na którym szum_binarny?

  4. Policz permutation_importance na odpowiednio rozszerzonym zbiorze testowym i porównaj obie miary dla obu kolumn szumu.

  5. 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))
In [ ]:
# TWÓJ KOD TUTAJ

Pytania do przemyślenia

Odpowiadasz słowami, nie kodem.

  1. 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?
  2. Dlaczego zwiększanie n_estimators w lesie praktycznie nie grozi przeuczeniem, a zwiększanie n_estimators w boostingu - tak? Skąd bierze się ta różnica?
  3. 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"?
  4. Cecha A i cecha B są ze sobą silnie skorelowane i obie dobrze przewidują chorobę. Co pokaże feature_importances_ lasu, a co permutation_importance? Która z tych odpowiedzi jest bardziej myląca dla odbiorcy raportu?
  5. 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?
  6. W ćwiczeniu 01 PatientID wylą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

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.