Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Gradient Boosting - XGBoost, LightGBM, CatBoost

W lekcji o scikit-learn zbudowałeś las losowy: sto drzew, sto tropicieli, każdy dostaje własną próbkę okazów i własny zestaw pomiarów, a na koniec wszyscy głosują. Pomysł działa świetnie, dopóki tropiciele mylą się w różnych miejscach - wtedy pomyłki się znoszą i większość ma rację.

Ale jest taki kwadrat sawanny, na którym cały las się wykłada. Młode hieny bywają dokładnie tak ciężkie jak wychudzona lwica, mają podobny obwód łapy i chodzą tym samym korytarzem. Sto niezależnych tropicieli, z których żaden nigdy nie rozmawiał z pozostałymi, popełni tam sto razy ten sam błąd. Głosowanie nie pomoże, bo większość myli się razem. I nie ma w lesie nikogo, kto by po naradzie powiedział: "spójrzcie, dwadzieścia okazów wciąż wpisujemy do złej rubryki, zajmijmy się nimi".

Brakuje Ci mechanizmu, który po każdym tropicielu bierze listę jego pomyłek i wysyła następnego specjalnie do tych pomyłek. Nie kolejnego niezależnego głosu, tylko poprawki tego, czego poprzednik nie umiał. Ten mechanizm nazywa się gradient boosting i to on stoi za większością zwycięskich rozwiązań w konkursach na danych tabelarycznych - takich jak Twój notes terenowy, gdzie każdy wiersz to jedno zwierzę, a każda kolumna jeden pomiar.

Kolejka zamiast narady

Zapamiętaj jedno zdanie, bo jest definicją całej techniki: gradient boosting łączy słabe modele sekwencyjnie, a każdy kolejny poprawia błędy poprzedniego.

Rozłóżmy je na trzy słowa. Słaby model to model ledwie lepszy od zgadywania - w praktyce bardzo płytkie drzewo decyzyjne, czasem dosłownie jedno pytanie i dwie odpowiedzi. Taki kikut sam z siebie jest bezużyteczny, ale za to szybki i trudno mu się przeuczyć. Sekwencyjnie znaczy jeden po drugim, w kolejce, a nie równolegle: drugi model nie może powstać, zanim pierwszy nie skończy pracy, bo dopiero wtedy wiadomo, co poszło źle. I wreszcie poprawia błędy poprzedniego - kolejny model nie uczy się na oryginalnych odpowiedziach, tylko na tym, o ile poprzednik się pomylił.

Ta pomyłka ma swoją nazwę: reszta (po angielsku residual), czyli prawdziwa wartość minus to, co model przewidział. Nazwa "gradient" bierze się stąd, że reszta jest matematycznie kierunkiem, w którym błąd spada najszybciej - kolejne drzewo idzie więc dokładnie tam, gdzie najbardziej się opłaca. Na szczęście do korzystania z boostingu ta matematyka nie jest potrzebna. Potrzebna jest intuicja kolejki, a tę najlepiej zbudować własnymi rękami.

Boosting na palcach: dwa kikuty i lista pomyłek

Zrobimy boosting ręcznie, na sześciu lwach o znanym wieku i znanej wadze, żebyś zobaczył mechanizm bez żadnej biblioteki poza tą, którą już znasz. Użyjemy

DecisionTreeRegressor
ze scikit-learn - to drzewo decyzyjne w wersji przewidującej liczby, czyli krewniak
DecisionTreeClassifier
z lekcji o scikit-learn. Parametr
max_depth=1
ogranicza je do jednego pytania, więc dostaniemy dokładnie taki kikut, o jakim była mowa. Metody są znajome:
fit
uczy,
predict
przewiduje.

Pierwszy tropiciel dostaje surowe dane: wiek w latach i wagę w kilogramach. Zaraz zobaczysz, co przewidział i jak bardzo się rozminął z prawdą.

1import numpy as np
2from sklearn.tree import DecisionTreeRegressor
3
4wiek = np.array([[1], [2], [3], [4], [5], [6]])
5waga_kg = np.array([40.0, 70.0, 100.0, 150.0, 170.0, 190.0])
6
7kikut_1 = DecisionTreeRegressor(max_depth=1)
8kikut_1.fit(wiek, waga_kg)
9
10przewidziane = kikut_1.predict(wiek)
11reszty = waga_kg - przewidziane
12
13print(przewidziane)                    # [ 70.  70.  70. 170. 170. 170.]
14print(reszty)                          # [-30.   0.  30. -20.   0.  20.]
15print(np.abs(reszty).sum())            # 100.0

Jedno pytanie, które zadał kikut, brzmiało "czy lew ma mniej niż trzy i pół roku". Wszystkim młodym przypisał 70 kg, wszystkim starszym 170 kg - i to naprawdę wszystko, co potrafi model o głębokości jeden. Ciekawa jest dopiero druga linia. Rocznemu lwu dołożył 30 kg za dużo, trzylatka zaniżył o 30 kg, a czterolatka o 20 kg. Suma bezwzględnych pomyłek wynosi 100 kg i to jest nasz punkt odniesienia. Zwróć uwagę, że tablica

waga_kg
nie została w żaden sposób ruszona - reszty policzyliśmy obok, zwykłym odejmowaniem tablic z NumPy, dokładnie tak, jak odejmowałeś je w module o obliczeniach numerycznych.

Teraz najważniejszy ruch w całej lekcji. Drugi kikut dostaje te same wieki, ale w miejsce wag dostaje listę pomyłek pierwszego. Nie uczy się, ile lew waży. Uczy się, gdzie poprzednik chybił i w którą stronę.

1kikut_2 = DecisionTreeRegressor(max_depth=1)
2kikut_2.fit(wiek, reszty)
3
4poprawka = kikut_2.predict(wiek)
5razem = przewidziane + poprawka
6
7print(poprawka)                        # [-30.   6.   6.   6.   6.   6.]
8print(razem)                           # [ 40.  76.  76. 176. 176. 176.]
9print(np.abs(waga_kg - razem).sum())      # 76.0

Drugi kikut zauważył, że jeden okaz odstaje od reszty bardziej niż wszystkie inne, i zadał pytanie "czy to roczniak". Roczniakowi odejmuje 30 kg, każdemu innemu dokłada 6 kg. Po zsumowaniu obu przewidywań łączny błąd spadł ze 100 kg do 76 kg - a przecież dołożyliśmy tylko jedno dodatkowe pytanie.

Teraz najcenniejsze zdanie tej sekcji: pierwszy kikut nie został zmieniony ani przetrenowany. Nadal przewiduje 70 i 170, tak samo jak przed chwilą. Nie zmieniły się też

wiek
ani
waga_kg
- dane wejściowe są nietknięte. Zmieniło się wyłącznie to, że obok pierwszego modelu stanął drugi i że wynikiem jest teraz suma ich przewidywań. Drugi kikut nigdy nie zobaczył prawdziwych wag lwów. Widział wyłącznie cudze pomyłki. Dokładnie to robi XGBoost, tyle że zamiast dwóch drzew ustawia w kolejce sto albo tysiąc i przed dodaniem każdej poprawki mnoży ją przez małą liczbę -
learning_rate
. Gdyby nasza poprawka weszła z wagą 0,1, byłaby dziesięciokrotnie ostrożniejsza i trzeba by wielu takich drzew, żeby dojść do celu. Właśnie dlatego domyślne ustawienia tych bibliotek to setki płytkich drzew, a nie trzy głębokie: małymi krokami trudniej przestrzelić.

Czym gradient boosting nie jest

Nazwa brzmi tajemniczo, więc krąży wokół niej kilka nieporozumień. Rozprawmy się z nimi teraz, zanim wejdziemy w kod.

To nie jest rodzaj sieci neuronowej. W boostingu nie ma ani jednego neuronu, warstwy ani wagi połączenia, nie ma też propagacji wstecznej. Są drzewa decyzyjne, czyli łańcuchy pytań tak-nie, ustawione w szereg. Sieci neuronowe poznasz w następnej lekcji, przy PyTorch, i zobaczysz, że to zupełnie inna konstrukcja - a dla danych tabelarycznych zwykle słabsza od boostingu.

To nie jest metoda losowego wybierania cech. Losowanie cech i próbek to sztuczka z lasu losowego: tam każde drzewo dostaje inny wycinek danych właśnie po to, żeby drzewa były od siebie niezależne. Boosting też ma parametry

subsample
i
colsample_bytree
, którymi można losowo ograniczyć próbki i kolumny dla każdego drzewa, ale to tylko dodatek przeciw przeuczeniu, a nie definicja techniki. Losowanie z niczyich błędów nie korzysta, a boosting cały stoi na tym, że kolejne drzewo widzi pomyłki poprzedniego.

To wreszcie nie jest algorytm klasteryzacji. Klasteryzacja to uczenie nienadzorowane: nie ma żadnego

y
, a algorytm sam grupuje podobne okazy i nikt mu nie mówi, jak się nazywają. Boosting jest uczeniem nadzorowanym w czystej postaci - bez kolumny prawidłowych odpowiedzi nie da się policzyć żadnej reszty, więc nie ma czego poprawiać. Pamiętasz z lekcji o uczeniu nadzorowanym, że wszystko zaczyna się od par pomiar-odpowiedź? Tutaj ta zasada obowiązuje podwójnie.

Trzy biblioteki, jeden pomysł

Ten sam pomysł zrealizowały niezależnie trzy zespoły i tak powstały trzy biblioteki, o których mówi ta lekcja. XGBoost to najstarsza i najbardziej rozpoznawalna, domyślny wybór na start. LightGBM od Microsoftu została napisana pod kątem szybkości na dużych zbiorach. CatBoost od Yandexu specjalizuje się w kolumnach tekstowych, czyli kategorycznych. Wszystkie trzy mówią czasownikami scikit-learn, które znasz -

fit
,
predict
,
score
- więc gdy opanujesz jedną, dwie pozostałe rozpoznasz w locie.

Żadna nie wchodzi w skład scikit-learn, więc trzeba je doinstalować. Jedno polecenie w terminalu załatwia wszystkie trzy naraz.

1pip install xgboost lightgbm catboost

Instalacja pobiera skompilowane paczki, więc trwa chwilę i nie wymaga kompilatora. Zwróć uwagę, że nazwy pakietów do instalacji nie zawsze pokrywają się z nazwami do importu: pakiet

lightgbm
importujemy jako
lgb
, a z pakietu
catboost
sprowadzamy klasę
CatBoostClassifier
. To częste źródło pomyłek przy pierwszym uruchomieniu. Jeżeli na macOS zobaczysz komunikat o brakującej bibliotece
libomp
, doinstaluj ją poleceniem
brew install libomp
- XGBoost korzysta z niej do liczenia na wielu rdzeniach.

Notes terenowy, na którym będziemy pracować

Potrzebujemy danych. Zbudujemy je sami, korzystając z generatora liczb losowych NumPy, który znasz z modułu o obliczeniach numerycznych:

np.random.default_rng(42)
tworzy generator z ustalonym ziarnem, dzięki czemu za każdym uruchomieniem wypadną te same liczby. Metoda
normal
losuje pomiary wokół zadanej średniej z zadanym rozrzutem - tak jak w naturze, gdzie żadne dwa lwy nie ważą identycznie. Wyniki wkładamy do ramki Pandas, bo dzięki nazwanym kolumnom wykresy ważności cech będą czytelne. Funkcja
pd.concat
skleja ramki jedna pod drugą, a
np.repeat
produkuje kolumnę etykiet: dwieście zer, dwieście jedynek i dwieście dwójek.

Jedna kolumna jest tu wstawiona przewrotnie.

godzina_obserwacji
to godzina, o której tropiciel zrobił zdjęcie - losowa dla każdego gatunku, więc nie niesie żadnej informacji o tym, kto jest na zdjęciu. Zostawiam ją celowo, bo za chwilę sprawdzimy, czy model to zauważy.

1import numpy as np
2import pandas as pd
3
4rng = np.random.default_rng(42)
5
6def stado(n, waga, predkosc, ogon, ugryzienie):
7    return pd.DataFrame({
8        "waga": rng.normal(waga, waga * 0.30, n),
9        "predkosc": rng.normal(predkosc, predkosc * 0.25, n),
10        "dlugosc_ogona": rng.normal(ogon, ogon * 0.30, n),
11        "sila_ugryzienia": rng.normal(ugryzienie, ugryzienie * 0.35, n),
12        "godzina_obserwacji": rng.uniform(5, 19, n),
13    })
14
15X = pd.concat([
16    stado(200, 190, 58, 85, 650),     # lew
17    stado(200, 55, 110, 75, 475),     # gepard
18    stado(200, 60, 65, 30, 1100),     # hiena
19], ignore_index=True)
20
21y = np.repeat([0, 1, 2], 200)         # 0 = lew, 1 = gepard, 2 = hiena
22
23print(X.shape, y.shape)               # (600, 5) (600,)

Mamy sześćset okazów opisanych pięcioma liczbami i sześćset etykiet - kształty potwierdza

shape
. Gatunki celowo się na siebie nakładają: hiena waży niewiele więcej od geparda, a jej siła ugryzienia bywa lwia. Żaden pojedynczy pomiar ich nie rozdziela i właśnie dlatego to dobry materiał na boosting. Zauważ, że
X
jest ramką Pandas, a
y
zwykłą tablicą NumPy - wszystkie trzy biblioteki przyjmują takie połączenie bez konwersji, bo pod spodem i tak czytają wartości liczbowe.

XGBoost - cztery kroki, zawsze w tej samej kolejności

Praca z XGBoost ma cztery kroki i ich kolejność nie jest kwestią gustu.

Krok pierwszy: import. Nie ma z czego zbudować modelu, dopóki biblioteka nie jest wczytana. Krok drugi: hiperparametry, czyli liczby opisujące, jak model ma wyglądać - wpisujesz je do konstruktora w chwili tworzenia obiektu, więc musisz je znać wcześniej. Krok trzeci: trening metodą

fit
, bo dopiero teraz model ogląda dane. Krok czwarty: ważność cech, którą można odczytać dopiero z wytrenowanego modelu - przed
fit
nie ma o co pytać, bo żadne drzewo jeszcze nie powstało.

Zacznijmy od kroku pierwszego. Import ma cztery kawałki: słowo kluczowe

import
, nazwę pakietu
xgboost
, słowo
as
i skrót
xgb
, którym będziemy się dalej posługiwać. Ten skrót to konwencja przyjęta w całej dokumentacji, więc trzymaj się jej. Przy okazji dzielimy dane na część treningową i testową funkcją
train_test_split
, którą poznałeś przy uczeniu nadzorowanym:
test_size=0.2
odkłada co piątego okaza na egzamin,
random_state=42
zapewnia powtarzalność podziału, a
stratify=y
pilnuje, żeby w obu częściach proporcje gatunków były takie same.

1import xgboost as xgb
2from sklearn.model_selection import train_test_split
3
4X_train, X_test, y_train, y_test = train_test_split(
5    X, y, test_size=0.2, random_state=42, stratify=y
6)
7
8print(X_train.shape, X_test.shape)    # (480, 5) (120, 5)

Czterysta osiemdziesiąt zwierząt idzie na trening, sto dwadzieścia zostaje w zamknięciu do egzaminu. Zwróć uwagę, że

train_test_split
zwraca cztery obiekty w ustalonej kolejności: najpierw dwie części cech, potem dwie części etykiet. Pomylenie tej kolejności to klasyczny błąd, po którym model uczy się na etykietach zamiast na pomiarach. Sama ramka
X
nie została naruszona - split zwrócił nowe obiekty, a oryginał leży dalej nietknięty.

Krok drugi to hiperparametry. Klasa

XGBClassifier
przyjmuje ich kilkadziesiąt, ale trzy decydują o wszystkim.
n_estimators
to długość kolejki, czyli ile drzew po sobie stanie.
max_depth
to głębokość pojedynczego drzewa, czyli ile pytań z rzędu wolno mu zadać; im większa, tym silniejsze pojedyncze drzewo, ale i tym łatwiej o przeuczenie.
learning_rate
to waga, z jaką każda kolejna poprawka wchodzi do sumy - ta sama liczba, o której mówiliśmy przy kikutach. Do tego dwa parametry ostrożnościowe:
subsample=0.8
daje każdemu drzewu losowe 80 procent okazów, a
colsample_bytree=0.8
losowe 80 procent kolumn. Na koniec
random_state=42
, bo bez ustalonego ziarna to losowanie sprawiłoby, że każde uruchomienie dałoby inny wynik.

1model = xgb.XGBClassifier(
2    n_estimators=100,        # dlugosc kolejki drzew
3    max_depth=6,             # ile pytan moze zadac jedno drzewo
4    learning_rate=0.1,       # jak mocno wchodzi kazda poprawka
5    subsample=0.8,           # procent okazow na drzewo
6    colsample_bytree=0.8,    # procent kolumn na drzewo
7    random_state=42,
8)
9
10print(model.n_estimators, model.max_depth)    # 100 6

Model istnieje, ale nie widział jeszcze ani jednego zwierzęcia - konstruktor zapisał wyłącznie ustawienia. Możesz je odczytać jako zwykłe atrybuty obiektu, co właśnie zrobiliśmy, i to jest cała zawartość modelu na tym etapie. Gdybyś teraz wywołał

predict
, dostałbyś wyjątek, bo nie ma jeszcze żadnego drzewa.

Krok trzeci i najkrótszy: trening.

fit
buduje całą kolejkę drzew, jedno po drugim, każde na resztach poprzednich. Potem
score
liczy odsetek trafień na zbiorze testowym - to ta sama metryka accuracy, którą poznałeś przy uczeniu nadzorowanym.
predict
zwraca numery gatunków dla nowych okazów.

1model.fit(X_train, y_train)
2
3print(f"Trafnosc: {model.score(X_test, y_test):.2%}")
4print(model.predict(X_test)[:8])

Trafność wypadnie w okolicach 97 procent - nie podaję dokładnej liczby, bo zależy ona od wersji biblioteki, więc uruchom kod i sprawdź u siebie. Ważniejsze jest to, czego

fit
nie zrobił. Nie zwrócił nowego modelu do podstawienia pod zmienną, tylko zmienił obiekt
model
w miejscu, dokładnie jak w scikit-learn. Nie ruszył też
X_train
ani
y_train
- sto drzew zamieszkało wewnątrz modelu, a Twoje dane leżą tam, gdzie leżały. I jeszcze jedno: kolejka jest zbudowana raz na zawsze. Ponowne wywołanie
fit
nie dokłada drzew do istniejących, tylko buduje wszystko od nowa.

Kiedy przestać dokładać drzewa

Sto drzew wpisaliśmy z palca i nikt nie zagwarantował, że akurat tyle jest potrzebne. Przy zbyt krótkiej kolejce model nie zdąży się nauczyć, przy zbyt długiej zacznie się uczyć szumu z danych treningowych. Rozwiązaniem jest early stopping: ustawiasz kolejkę z dużym zapasem, a XGBoost po każdym drzewie sprawdza wynik na zbiorze walidacyjnym i przerywa, gdy przez zadaną liczbę rund nic się nie poprawia. Tę liczbę podaje parametr

early_stopping_rounds
- od XGBoost w wersji 2.0 wpisuje się go do konstruktora, a nie do
fit
, co jest częstym źródłem nieaktualnych przykładów w sieci.

Potrzebny jest zbiór, na którym model będzie się kontrolował, i tu uwaga na pułapkę: nie może to być zbiór testowy. Gdyby XGBoost wybierał liczbę drzew, patrząc na egzamin, ten egzamin przestałby być uczciwy - model dostroiłby się do odpowiedzi, które ma potem odgadnąć. Dlatego odcinamy kawałek danych treningowych na osobny zbiór walidacyjny. Zbiór walidacyjny przekazujemy do

fit
parametrem
eval_set
, jako listę par. Parametr
verbose=False
wycisza raport z każdego drzewa, a
best_iteration
mówi po treningu, na którym drzewie model się zatrzymał.

1X_fit, X_val, y_fit, y_val = train_test_split(
2    X_train, y_train, test_size=0.2, random_state=42, stratify=y_train
3)
4
5model_es = xgb.XGBClassifier(
6    n_estimators=500,
7    max_depth=6,
8    learning_rate=0.1,
9    early_stopping_rounds=10,
10    random_state=42,
11)
12model_es.fit(X_fit, y_fit, eval_set=[(X_val, y_val)], verbose=False)
13
14print("Najlepsze drzewo:", model_es.best_iteration)
15print(f"Trafnosc: {model_es.score(X_test, y_test):.2%}")

Zamówiliśmy pięćset drzew, a model zatrzymał się dużo wcześniej i to bez straty na trafności - dokładny numer zależy od wersji, więc znowu odsyłam Cię do własnego uruchomienia. Zwróć uwagę na jedną rzecz, która się nie zmieniła: zbiór testowy nie brał udziału w niczym. Nie widział go ani

fit
, ani mechanizm zatrzymywania, więc wynik z
score
nadal jest uczciwym egzaminem. Zauważ też, że
X_fit
powstało z podziału
X_train
, a nie z podziału całych danych - egzamin był odłożony wcześniej i pozostaje nienaruszony.

Które pomiary naprawdę pracują

Krok czwarty rytuału. Model wytrenowany, więc można wreszcie zapytać, na czym oparł decyzje. Atrybut

feature_importances_
- z podkreślnikiem na końcu, jak wszystko, co scikit-learn wylicza podczas treningu - zwraca po jednej liczbie na kolumnę, a wszystkie sumują się do jedynki. Im wyższa, tym częściej i skuteczniej dany pomiar rozdzielał gatunki w drzewach. Kolejność liczb odpowiada kolejności kolumn w
X_train
, więc sparujemy je funkcją
zip
, którą znasz z podstaw Pythona.

1for nazwa, waznosc in zip(X_train.columns, model.feature_importances_):
2    print(f"{nazwa:>20}: {waznosc:.3f}")

Na samym dole listy wyląduje

godzina_obserwacji
z wartością bliską zeru - dokładnie ta kolumna, którą wstawiliśmy przewrotnie. Model sam odkrył, że pora zdjęcia nic nie mówi o gatunku, i praktycznie z niej nie korzystał. To jest praktyczna wartość tego kroku: dostajesz podpowiedź, których pomiarów w ogóle nie warto zbierać w terenie. Uważaj jednak na interpretację - wysoka ważność mówi, że model korzystał z kolumny, a nie że kolumna jest przyczyną czegokolwiek. Gdyby do notesu wkradł się numer klatki nadawany rosnąco według gatunku, wylądowałby na szczycie tej listy i nie znaczyłoby to nic poza tym, że tropiciel katalogował po kolei.

Listę liczb łatwiej porównać wzrokiem niż w druku, dlatego XGBoost ma własną funkcję rysującą. Wywołanie ma sześć kawałków: skrót biblioteki

xgb
, kropka, nazwa funkcji
plot_importance
, nawias otwierający, wytrenowany
model
i nawias zamykający. Nic więcej nie jest potrzebne - funkcja sama wyciągnie z modelu nazwy kolumn i posortuje słupki. Wykres pokazujemy poznanym w module o wizualizacji poleceniem
plt.show
.

1import matplotlib.pyplot as plt
2
3xgb.plot_importance(model)
4plt.show()

Dostaniesz poziomy wykres słupkowy z najważniejszą cechą na górze. Jeżeli cech masz kilkadziesiąt, dorzuć argument

max_num_features
, żeby obciąć wykres do kilkunastu najlepszych - ale przy pięciu kolumnach nie ma po co. Drobna niespodzianka na koniec: domyślnie
plot_importance
liczy ważność inaczej niż
feature_importances_
, bo pokazuje, ile razy dana kolumna w ogóle pojawiła się w pytaniach drzew. Kolejność bywa więc lekko inna niż na wydruku wyżej i to nie jest błąd, tylko inna miara tego samego zjawiska.

LightGBM - ten sam pomysł, szybsza maszyna

XGBoost radzi sobie świetnie, dopóki notes mieści się w pamięci. Gdy ekspedycja urośnie do milionów wierszy - a tak wygląda praca z odczytami czujników albo klatkami z setek fotopułapek - zaczyna doskwierać czas treningu, zwłaszcza że przy strojeniu trenujesz model dziesiątki razy. Wtedy sięga się po LightGBM, która na bardzo dużych zbiorach jest zazwyczaj szybsza od XGBoost.

Skąd ta przewaga? Z dwóch decyzji projektowych. Po pierwsze, LightGBM zamienia ciągłe pomiary na kubełki histogramu, więc zamiast rozważać każdą możliwą wartość progu, rozważa kilkaset kubełków - i robi to raz, a nie przy każdym drzewie. Po drugie, rozrasta drzewo liściowo: zamiast równo dobudowywać cały poziom, wybiera ten liść, który najbardziej zmniejszy błąd, i rozwija tylko jego. Dzięki temu przy tym samym budżecie obliczeń zbija błąd mocniej.

Dwie rzeczy trzeba tu powiedzieć wprost, bo łatwo o fałszywy wniosek. Nie jest prawdą, że obie biblioteki są równie szybkie - na dużych zbiorach różnica bywa kilkukrotna i jest łatwo mierzalna. Nie jest też prawdą, że któraś z nich nie radzi sobie z dużymi danymi: obie powstały właśnie do dużych danych i obie potrafią pracować na milionach wierszy. Rzecz w tym, że na bardzo dużych zbiorach LightGBM zwykle dochodzi do celu wcześniej. Nie próbuj tego zmierzyć na naszych sześciuset zwierzętach - przy takim rozmiarze dominują koszty startowe i stoper pokaże Ci przypadkowego zwycięzcę. Przewaga LightGBM ujawnia się dopiero przy setkach tysięcy i milionach wierszy.

Klasa nazywa się

LGBMClassifier
i przyjmuje te same trzy podstawowe hiperparametry co XGBoost. Dochodzą dwa własne, wynikające z liściowego rozrastania.
num_leaves
ogranicza liczbę liści w drzewie i to on, a nie
max_depth
, jest tu głównym pokrętłem złożoności.
min_child_samples
mówi, ilu okazów minimalnie musi trafić do liścia, żeby wolno go było utworzyć - zabezpieczenie przed liśćmi zbudowanymi na dwóch przypadkowych zwierzętach. Dorzucam jeszcze
verbose=-1
, żeby biblioteka nie zasypała konsoli komunikatami z treningu.

1import lightgbm as lgb
2
3model_lgb = lgb.LGBMClassifier(
4    n_estimators=100,
5    max_depth=6,
6    learning_rate=0.1,
7    num_leaves=31,          # maksymalna liczba lisci w drzewie
8    min_child_samples=20,   # min. okazow potrzebnych na lisc
9    random_state=42,
10    verbose=-1,
11)
12model_lgb.fit(X_train, y_train)
13
14print(f"LightGBM: {model_lgb.score(X_test, y_test):.2%}")

Wynik będzie bardzo zbliżony do XGBoost i tak ma być - to ten sam algorytm, inaczej zaimplementowany. Popatrz na kod jeszcze raz i zobacz, co się nie zmieniło:

fit
przyjął dokładnie te same
X_train
i
y_train
,
score
policzył dokładnie tę samą metrykę, a nazwy
n_estimators
,
max_depth
i
learning_rate
brzmią identycznie. Zmienił się jeden import i jedna nazwa klasy. To jest właśnie ta wspólna mowa, o której była mowa przy scikit-learn: przesiadka między bibliotekami boostingu kosztuje dwie linijki. LightGBM ma też własne
plot_importance
w module
lgb
, działające tak samo jak wersja z XGBoost.

CatBoost - gdy w notesie są słowa

Zarówno XGBoost, jak i LightGBM czytają wyłącznie liczby. Tymczasem prawdziwy notes terenowy jest pełen słów: siedlisko to "sawanna", "busz" albo "rzeka", dieta to "mieso" albo "padlina". Dotąd radziłeś sobie z tym kodowaniem -

OneHotEncoder
zamieniał każdą wartość na osobną kolumnę zer i jedynek. Przy trzech siedliskach to nic strasznego, ale przy kolumnie z pięciuset gatunkami roślin dostajesz pięćset nowych kolumn i model zaczyna się w nich gubić.

CatBoost rozwiązuje ten problem inaczej: przyjmuje kolumny tekstowe wprost i sam zamienia je na liczby, wyliczając dla każdej kategorii statystykę z etykiet - i robi to w sposób odporny na podglądanie odpowiedzi. Dlatego jest domyślnym wyborem, gdy w danych dominują kategorie.

Dołóżmy więc do naszego notesu dwie kolumny tekstowe. Siedlisko w siedemdziesięciu procentach przypadków będzie zgadzać się z gatunkiem, a w pozostałych wypadnie losowo - taki notes jest bliższy prawdy niż idealny. Dieta rozdzieli hieny, bo to one żywią się padliną. Do wyboru między dwiema tablicami służy

np.where
, które znasz z modułu o NumPy: bierze warunek i dwie wartości, i dla każdego elementu wybiera jedną z nich.

1rng_kat = np.random.default_rng(7)
2teren = np.array(["sawanna", "busz", "rzeka"])
3
4zgodne = rng_kat.random(len(y)) < 0.7
5losowe = rng_kat.integers(0, 3, len(y))
6
7X_kat = X.copy()
8X_kat["habitat"] = teren[np.where(zgodne, y, losowe)]
9X_kat["diet"] = np.where(y == 2, "padlina", "mieso")
10
11print(X_kat[["habitat", "diet"]].head(3))

Ramka ma teraz siedem kolumn: pięć liczbowych i dwie tekstowe. Ważne, co się nie zmieniło -

X.copy()
zrobiło kopię, więc oryginalne
X
nadal ma pięć kolumn i wcześniejsze modele nic o tej zmianie nie wiedzą. Gdybyśmy pominęli
copy
, dopisalibyśmy kolumny do ramki, na której trenowaliśmy XGBoost, i przy ponownym uruchomieniu tamten kod by się wywrócił.

Teraz sam model. Klasa

CatBoostClassifier
używa własnych nazw dla dwóch znanych Ci pojęć:
iterations
to długość kolejki drzew, czyli odpowiednik
n_estimators
, a
depth
to głębokość drzewa, czyli odpowiednik
max_depth
. Kluczowy jest
cat_features
: lista nazw kolumn, które mają być traktowane jako kategoryczne. I tu najczęstsza pomyłka początkujących - podanie nazw kolumn działa tylko wtedy, gdy trenujesz na ramce Pandas zawierającej te kolumny. Jeżeli podasz nazwy, a przekażesz tablicę NumPy, CatBoost przerwie z błędem, że danych liczbowych nie da się uznać za kategoryczne. Dlatego dzielimy
X_kat
, a nie
X
. Parametr
verbose=False
wycisza raport z każdej iteracji, a
random_seed
pełni rolę
random_state
z pozostałych bibliotek.

1from catboost import CatBoostClassifier
2
3Xk_train, Xk_test, yk_train, yk_test = train_test_split(
4    X_kat, y, test_size=0.2, random_state=42, stratify=y
5)
6
7model_cat = CatBoostClassifier(
8    iterations=100,
9    depth=6,
10    learning_rate=0.1,
11    cat_features=["habitat", "diet"],
12    verbose=False,
13    random_seed=42,
14)
15model_cat.fit(Xk_train, yk_train)
16
17print(f"CatBoost: {model_cat.score(Xk_test, yk_test):.2%}")

Model wytrenował się na kolumnach tekstowych bez jednej linijki kodowania z naszej strony - i to jest cała obietnica CatBoost. Zauważ, że podział zrobiliśmy z tym samym

random_state=42
i tym samym
stratify
, więc do zbioru testowego trafiły dokładnie te same zwierzęta co poprzednio. Dzięki temu porównanie wyników z XGBoost ma sens: różnica bierze się z modelu i z dodanych kolumn, a nie z innego losowania.

Ważność cech CatBoost udostępnia metodą, a nie atrybutem - nazywa się

get_feature_importance
i zwraca tablicę liczb w kolejności kolumn. Skala jest inna niż w XGBoost, bo wartości sumują się do stu, więc czytasz je wprost jako procenty.

1for nazwa, waznosc in zip(Xk_train.columns, model_cat.get_feature_importance()):
2    print(f"{nazwa:>20}: {waznosc:.2f}")

Na liście zobaczysz obok siebie kolumny liczbowe i tekstowe, wycenione w tej samej walucie -

habitat
i
diet
dostają swoje miejsce w rankingu jak każdy inny pomiar. Dieta ląduje na samej górze, bo bezbłędnie wskazuje hieny, a siedlisko nisko, bo myli się w co trzecim przypadku. Wyciągnij z tego wniosek ogólniejszy niż sam CatBoost: jedna dobrze dobrana kolumna kategoryczna potrafi być warta więcej niż cztery pomiary z taśmy mierniczej. Zwróć jeszcze uwagę na nawiasy przy
get_feature_importance()
: to metoda, czyli trzeba ją wywołać, w odróżnieniu od
feature_importances_
z XGBoost, które jest zwykłym atrybutem i nawiasów nie przyjmuje.

Strojenie hiperparametrów - kto to ma wszystko przeklikać

Wróćmy do liczb, które wpisywaliśmy z palca. Dlaczego

max_depth
ma być sześć, a nie trzy? Dlaczego
learning_rate
to 0,1, a nie 0,3? Uczciwa odpowiedź brzmi: nie wiadomo. Te wartości zależą od danych i jedyny wiarygodny sposób ich wyboru to sprawdzenie. Ręcznie oznacza to dziesiątki uruchomień, zapisywanie wyników na kartce i pomylenie się w połowie.

Od tego jest GridSearchCV z modułu

sklearn.model_selection
. Jej jedyne zadanie to automatyczne strojenie hiperparametrów modelu: podajesz siatkę wartości do sprawdzenia, a ona trenuje model na każdej kombinacji i zwraca najlepszą. Powiedzmy od razu, czym GridSearchCV nie jest, bo nazwa nikomu tego nie podpowiada. Nie służy do wizualizacji danych - od wykresów jest matplotlib i seaborn z modułu o wizualizacji, a GridSearchCV nie rysuje niczego. Nie służy do czyszczenia danych - braki i duplikaty usuwałeś Pandasem na długo przed treningiem, a tutaj dane muszą być już czyste. I na pewno nie tworzy sieci neuronowych - sieci to zupełnie inna rodzina modeli, którą poznasz przy PyTorch; GridSearchCV co najwyżej dobierze im hiperparametry, ale sama żadnej sieci nie zbuduje.

Działa tak:

param_grid
to słownik, w którym kluczem jest nazwa hiperparametru, a wartością lista do sprawdzenia.
cv=3
to liczba foldów walidacji krzyżowej, którą poznałeś przy uczeniu nadzorowanym - każda kombinacja jest oceniana trzykrotnie, na różnych podziałach, żeby wynik nie był loterią.
scoring="accuracy"
mówi, którą metryką mierzymy jakość, a
n_jobs=-1
pozwala korzystać ze wszystkich rdzeni procesora. Po treningu
best_params_
zwraca zwycięski zestaw,
best_score_
jego średnią ocenę z foldów, a
best_estimator_
gotowy, wytrenowany już model.

1from sklearn.model_selection import GridSearchCV
2
3param_grid = {
4    "max_depth": [3, 6],
5    "learning_rate": [0.05, 0.1, 0.3],
6    "n_estimators": [50, 100],
7}
8
9grid = GridSearchCV(
10    xgb.XGBClassifier(random_state=42),
11    param_grid,
12    cv=3,
13    scoring="accuracy",
14    n_jobs=-1,
15)
16grid.fit(X_train, y_train)
17
18print(len(grid.cv_results_["params"]))    # 12
19print(grid.best_params_)
20print(f"Najlepszy wynik: {grid.best_score_:.2%}")
21
22best_model = grid.best_estimator_

Dwanaście kombinacji, bo dwa razy trzy razy dwa - i to jest liczba, którą warto policzyć w głowie przed uruchomieniem. Przy trzech foldach oznacza ona trzydzieści sześć treningów. Gdybyś dołożył czwarty parametr z czterema wartościami i podniósł

cv
do pięciu, byłoby ich już dwieście czterdzieści, a siatka rośnie iloczynowo, nie sumą. Zwróć uwagę na jeszcze jedną rzecz: do
GridSearchCV
przekazujemy model przed treningiem i nigdzie nie wywołujemy na nim
fit
samodzielnie. Obiekt
grid
sam trenuje wszystkie kopie, a gotowy zwycięzca czeka w
best_estimator_
i nie wymaga ponownego treningu.

Skoro siatka rośnie tak szybko, jest alternatywa: zamiast sprawdzać wszystkie kombinacje, wylosuj ich ustaloną liczbę. Robi to

RandomizedSearchCV
z tego samego modułu, a zamiast list wartości przyjmuje rozkłady losowe z
scipy.stats
.
randint(3, 10)
losuje liczbę całkowitą od 3 do 9 włącznie, natomiast
uniform
ma zaskakujące parametry: pierwszy to początek zakresu, ale drugi to szerokość, a nie koniec.
uniform(0.01, 0.29)
losuje więc z przedziału od 0,01 do 0,30 - to jedna z najczęstszych pomyłek przy strojeniu. Parametr
n_iter
mówi, ile kombinacji wylosować, i to on, a nie rozmiar siatki, decyduje o czasie.

1from sklearn.model_selection import RandomizedSearchCV
2from scipy.stats import randint, uniform
3
4param_dist = {
5    "max_depth": randint(3, 10),
6    "learning_rate": uniform(0.01, 0.29),
7    "n_estimators": randint(50, 300),
8}
9
10random_search = RandomizedSearchCV(
11    xgb.XGBClassifier(random_state=42),
12    param_dist,
13    n_iter=20,
14    cv=3,
15    random_state=42,
16    n_jobs=-1,
17)
18random_search.fit(X_train, y_train)
19
20print(random_search.best_params_)

Dwadzieścia losowań zamiast pełnego przeglądu, a wynik zwykle nie odbiega od najlepszego z siatki. Nie podaję znalezionych wartości, bo zależą od losowania i wersji biblioteki - i to jest tu ważniejsza lekcja niż konkretne liczby: przy losowym przeszukiwaniu

random_state
przestaje być kosmetyką i staje się warunkiem powtarzalności eksperymentu.

Moja rekomendacja, @name, jest jednoznaczna: zaczynaj od

RandomizedSearchCV
z szerokimi zakresami, a
GridSearchCV
używaj dopiero na końcu
, do dopieszczenia wąskiego przedziału wokół zwycięzcy. Powód jest prosty - większość hiperparametrów niewiele zmienia, więc pełny przegląd marnuje ogromną część czasu na kombinacje, które i tak przegrają. Losowe przeszukiwanie w dwudziestu próbach zwykle trafia bliżej optimum niż siatka o tym samym koszcie, bo sprawdza więcej różnych wartości każdego parametru.

Gdy i to przestanie wystarczać, sięgniesz po bibliotekę Optuna. Różni się tym, że nie losuje na ślepo: pamięta wyniki poprzednich prób i kolejne kombinacje dobiera tam, gdzie spodziewa się poprawy. Definiujesz w niej funkcję celu, która dla zadanego zestawu parametrów zwraca ocenę modelu, a Optuna wywołuje ją setki razy, sama zawężając poszukiwania. To narzędzie na później - najpierw opanuj dwa powyższe, bo w dziewięciu przypadkach na dziesięć wystarczą.

Zapamiętaj z tej lekcji jedno, @name: las losowy to narada tropicieli, którzy się nawzajem nie słuchają, a boosting to karawana, w której każdy kolejny idzie tropem pomyłek poprzednika - i dlatego dochodzi dalej, choć każdy z osobna widzi mniej.

Przejdź do CodeWorlds