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.
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.
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.0Jedno 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.0Drugi 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ć.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.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 catboostInstalacja 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.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.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 6Model 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.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.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.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.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.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
z szerokimi zakresami, a RandomizedSearchCV
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.GridSearchCV
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.