Tuning hiperparametrów na przykładzie drzewa decyzyjnego

W jednym z poprzednich artykułów pokazałem, na czym polega proces budowy drzewa decyzyjnego. Pokazałem również, nad jakimi hiperparametrami warto się pochylić przy budowie takiego drzewa. W tym artykule chciałem pokazać jak ugryźć proces wyboru wartości hiperparametrów w sposób praktyczny.

Zbiór danych

Aby móc przeprowadzić to ćwiczenie przydałby nam się jakiś zbiór danych. Do tego celu wybrałem zbiór breast cancer który jest wbudowany w moduł Scikit-Learn. Składa się on z 569 obserwacji, 30 cech i dwóch klas. Jest on dość duży, żeby ćwiczenie miało sens, a jednocześnie na tyle mały, żebyśmy byli w stanie uzyskiwać wyniki bez konieczności zostawiania komputera „na noc”.

Tak jak wcześniej, będę korzystał z modułu Scikit-Learn i funkcji DecisionTreeClassifier. Użyjemy dodatkowo funkcji load_breast_cancer, train_test_split i GridSearchCV. Będę je omawiał, gdy się pojawią w kodzie.

Zacznijmy więc od wczytania danych i wpisania ich do zmiennej X (to, co możemy zbadać) i zmiennej y (to, co chcemy przewidzieć). Zrobimy to w taki sposób:

import pandas as pd
from sklearn.datasets import load_breast_cancer

breast_cancer = load_breast_cancer()
X = pd.DataFrame(breast_cancer["data"], 
                 columns = breast_cancer["feature_names"])
y = pd.Series(breast_cancer["target"])

Funkcja load_breast_cancer zwraca dane, o których pisałem powyżej. Jako jej wynik dostaniemy obiekt, który składa się z 5 elementów (żeby zobaczyć ich nazwy: breast_cancer.keys()). Nas najbardziej interesują wartości pomiarów (breast_cancer[„data”]) i ich ocena (breast_cancer[„target”]). Przydatne także będą nazwy poszczególnych kolumn (breast_cancer[„feature_names”]). I to w zasadzie wszystko – mamy już zmienne X i y wypełnione odpowiednimi danymi.

Ocena modelu

Zastanówmy się teraz, w jaki sposób będziemy oceniać, która kombinacja wartości hiperparametrów jest lepsza. Zawsze możemy policzyć, w ilu procentach nasza predykcja się zgadzała. Jest to jedna z najprostszych metod oceny modelu klasyfikującego. I czasem zupełnie wystarczająca.

Zacznijmy od wyznaczenia jednego hiperparametru. Parametr ten to max_depth i jest najważniejszy przy konstruowaniu drzewa decyzyjnego. Mówi nam o tym, jak dużo poziomów może mieć nasze drzewo. Domyślnie ustawiony jest na None, co oznacza, że nie ma ograniczenia ilości poziomów. Sprawdźmy więc, czy wprowadzenie ograniczenia ma tutaj sens:

from sklearn.tree import DecisionTreeClassifier

wyniki = pd.DataFrame()
for max_depth in range(1,11):
    klasyfikator = DecisionTreeClassifier(criterion = 'entropy', 
                                          max_depth = max_depth, 
                                          random_state = 42)
    klasyfikator.fit(X = X, y = y)
    wyniki = wyniki.append({"max_depth": max_depth, 
                            "dokładność": klasyfikator.score(X = X, y = y)}, 
                           ignore_index=True)

Najważniejszym elementem powyższego kawałka kodu jest pętla, która podstawia liczby od 1 do 10 do parametru max_depth. W każdym przebiegu pętli trenujemy model z kolejnymi wartościami i sprawdzamy, jak taki model się spisuje (klasyfikator.score()). Wyniki zapisujemy do osobnej ramki danych. Wykres uzyskanych wyników wygląda tak:

Wykres dokładności
Wykres dokładności

Widzimy na nim, że maksymalną efektywność (1.00) uzyskujemy przy głębokości 7. Oznaczałoby to, że przy danych, którymi dysponujemy, nie ma sensu budować większego drzewa. Ale czy to prawidłowy wniosek?

Otóż nie. Popełniliśmy tutaj jeden bardzo duży błąd. Użyliśmy tego samego zbioru danych do treningu i testu. Doprowadziliśmy więc do tego, że nasz model „zapamiętał” wszystkie obserwacje i w czasie testu przywołał je z pamięci. Jeżeli w taki sposób będziemy wybierać hiperparametry, to każda funkcja dojdzie do momentu, w którym będzie miała dokładność 100%.

Podział na zbiory treningowe i testowe

Żeby uniknąć takiej sytuacji, zastosujemy prostą, ale bardzo skuteczną technikę. Podzielimy nasz zbiór podstawowy na dwa zbiory – treningowy i testowy. Zbiór treningowy będzie większy i będzie używany do trenowania modelu. Zbiór testowy będzie mniejszy i będzie użyty tylko do oceny jakości modelu. Ważne jest, żeby każda obserwacja występowała albo w jednym, albo w drugim zbiorze. Jak zabrać się za coś takiego? W Scikit-Learn mamy funkcję train_test_split, która robi dokładnie coś takiego:

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, 
                                                    test_size = 0.25, 
                                                    random_state = 42)

Do funkcji train_test_split możemy podać dowolną ilość ramek danych, które mają taką samą długość. Funkcja ta będzie je dzielić i zwracać kolejne wyniki w kolejności: ramka1_train, ramka1_test, ramka2_train, ramka2_test i tak dalej. Musimy w jakiś sposób określić proporcje zbiorów. Ja użyłem parametru test_size, którego wartość podajemy od 0 do 1. Dzięki tej funkcji nie musimy się martwić wymieszaniem indeksów – dokładnie takie same obserwacje trafią w dokładnie takiej samej kolejności do zbiorów X i y.

Skoro mamy już odpowiednie zbiory testowe i treningowe to możemy powtórzyć pętlę – tym razem z wykorzystaniem odpowiednich zbiorów w odpowiednich miejscach:

wyniki = pd.DataFrame()
for max_depth in range(1,11):
    klasyfikator = DecisionTreeClassifier(criterion = 'entropy', 
                                          max_depth = max_depth, 
                                          random_state = 42)
    klasyfikator.fit(X = X_train, y = y_train)
    wyniki = wyniki.append({"max_depth": max_depth, 
                            "dokładność_trening": klasyfikator.score(X = X_train, y = y_train), 
                            "dokładność_test": klasyfikator.score(X = X_test, y = y_test)}, 
                           ignore_index=True)

Dla zachowania spójności wyliczam również dokładność na zbiorze treningowym. Dzięki temu przekonamy się w praktyce, czym jest nadmierne dopasowanie. Po przejściu przez tę pętlę uzyskaliśmy następujące wyniki:

Wykres dokładności dla zbioru train i test
Wykres dokładności dla zbioru train i test

Czy widzisz już gdzie „leży pies pogrzebany”? Jeśli nie, to zobacz uzyskane wartości dla głębokości 3. Wynik uzyskany na bazie danych treningowych (dokładność_trening) jest taki sobie w tym miejscu – przy większych głębokościach będzie rósł do 1.00. Natomiast dla danych testowych mamy tutaj maksimum, zwiększanie głębokości drzewa będzie nam psuło predykcje. Tak, dobrze przeczytałeś – większe drzewo, które lepiej pamięta dane treningowe, będzie nam gorzej przewidywało klasę, dla danych których nie widziało. Zjawisko to nazywa się nadmiernym dopasowaniem (ang. overfitting) i właśnie poznałeś bardzo prostą, ale dość skuteczną metodę radzenia sobie z nim.

Więcej hiperparametrów

Okej, mamy jeden hiperparametr, a co z resztą? Nie możemy sobie za bardzo ich dobierać osobno – konkretna ich kombinacja daje konkretne wyniki. Musimy więc stworzyć sobie pętlę, która przejdzie nam przez wszystkie możliwe ich kombinacje. Pętla, czy też raczej pętle, będą wyglądać mniej więcej tak:

wyniki = pd.DataFrame()
for max_depth in range(1,11):
    for min_samples_split in range(2, 21):
        for min_samples_leaf in range(1, 21):
            for criterion in ["gini", "entropy"]:
                klasyfikator = DecisionTreeClassifier(random_state = 42,
                                                      criterion = criterion, 
                                                      max_depth = max_depth, 
                                                      min_samples_split = min_samples_split, 
                                                      min_samples_leaf = min_samples_leaf)
                klasyfikator.fit(X = X_train, y = y_train)
                wyniki = wyniki.append({"max_depth": max_depth,
                                        "criterion": criterion,
                                        "min_samples_split": min_samples_split,
                                        "min_samples_leaf": min_samples_leaf,
                                        "dokładność_trening": klasyfikator.score(X = X_train, y = y_train), 
                                        "dokładność_test": klasyfikator.score(X = X_test, y = y_test)}, 
                                       ignore_index=True)

Tutaj już wykres nie będzie miał sensu – mamy dwie wyznaczone wartości zależne od 4 wymiarów. To, co nas będzie najbardziej interesować to ramka danych „wyniki” i wartość maksymalna w kolumnie „dokładność_test”. Żeby zobaczyć, dla jakich hiperparametrów mamy najwyższą wartość w tej kolumnie możemy dokonać następującej selekcji:

wyniki[wyniki["dokładność_test"] == wyniki["dokładność_test"].max()].head()

Na samym końcu dodałem funkcję head(), gdyż cała ta tabelka była całkiem spora. Funkcja head() wyświetli nam pierwsze 5 wierszy ramki danych. Uzyskaliśmy więc całkiem sporo różnych kombinacji hiperparametrów, które dają nam dokładnie takie same wyniki w czasie testów. Przy większych zbiorach danych to się raczej nie zdarza, w naszym ćwiczeniu jednak pojawiła się taka sytuacja. Jak z tego wybrnąć? Możemy dołożyć sobie jeszcze jedną kolumnę. Kolumna ta będzie nazywać się „czas” i będzie odpowiadać czasowi poświęconemu na trenowanie modelu. Będziemy mogli dzięki temu znaleźć „najszybszą” kombinację hiperparametrów. Może to być przydatne, gdy po procesie tuningu hiperparametrów chcemy przekazać nasz model na produkcję, wiemy jednak, że co jakiś czas będziemy go trenować na nowych danych historycznych. W ten sposób będziemy mieć całkiem dobre przesłanki, że trenujemy model dla najlepszych, a następnie najszybszych hiperparametrów. Pętle z pomiarem czasu będą wyglądać tak:

import timeit

wyniki = pd.DataFrame()
for max_depth in range(1,11):
    for min_samples_split in range(2, 21):
        for min_samples_leaf in range(1, 21):
            for criterion in ["gini", "entropy"]:
                klasyfikator = DecisionTreeClassifier(random_state = 42, 
                                                      criterion = criterion, 
                                                      max_depth = max_depth, 
                                                      min_samples_split = min_samples_split, 
                                                      min_samples_leaf = min_samples_leaf)
                start_time = timeit.default_timer()
                klasyfikator.fit(X = X_train, y = y_train)
                elapsed = timeit.default_timer() - start_time
                wyniki = wyniki.append({"max_depth": max_depth,
                                        "criterion": criterion,
                                        "min_samples_split": min_samples_split,
                                        "min_samples_leaf": min_samples_leaf,
                                        "czas": elapsed,
                                        "dokładność_trening": klasyfikator.score(X = X_train, y = y_train), 
                                        "dokładność_test": klasyfikator.score(X = X_test, y = y_test)}, 
                                       ignore_index=True)

Dodałem tutaj tylko 4 linijki. Odpowiedni import (linia 1), zapisanie czasu do zmiennej przed trenowaniem (linia 13), zapisanie czasu, który upłynął na trenowaniu (linia 15) i zapisanie tego czasu do ramki danych (linia 20). Żeby wyświetlić uporządkowaną ramkę danych, możemy zrobić coś takiego:

wyniki.sort_values(by = ["dokładność_test","czas"], ascending = [False, True]).head()

Jeśli zapiszemy sobie wynik powyższego działania do zmiennej, to możemy się dobrać do hiperparametrów w następujący sposób:

top_wyniki = wyniki.sort_values(by = ["dokładność_test","czas"], 
                                ascending = [False, True]).head()
criterion = top_wyniki.iloc[0]["criterion"]
max_depth = top_wyniki.iloc[0]["max_depth"]
min_samples_leaf = top_wyniki.iloc[0]["min_samples_leaf"]
min_samples_split = top_wyniki.iloc[0]["min_samples_split"]

No i to by było w zasadzie tyle, jeśli chodzi o najprostszy tuning hiperparametrów. Pozostaje jeszcze pytanie, czy za każdym razem jak będziemy chcieli sprawdzić kombinacje różnych hiperparametrów, to będziemy musieli pisać kaskadę pętli? Otóż nie. Każda poważana biblioteka do uczenia maszynowego ma jakąś funkcję, która zrobi to za nas. Zadba o stworzenie kombinacji hiperparametrów, sprawdzi każdą z nich i zapisze wynik. Czasem nawet doda do tego sprawdzian krzyżowy, żeby uzyskać wynik bliższy rzeczywistości. Jak wygląda to w przypadku Scikit-Learn?

Funkcja sprawdzająca hiperparametry

from sklearn.model_selection import GridSearchCV

estymator = DecisionTreeClassifier(random_state = 42)
hiperparametry = {"max_depth": range(1,11), 
                  "min_samples_split": range(2, 21), 
                  "min_samples_leaf": range(1, 21), 
                  "criterion": ["gini", "entropy"]}

klasyfikator = GridSearchCV(estimator = estymator, 
                            param_grid = hiperparametry)


klasyfikator.fit(X = X_train, y = y_train)

print(klasyfikator.best_score_)
print(klasyfikator.score(X = X_test, y = y_test))
print(klasyfikator.best_params_)

Proces doboru hiperparametrów wygląda nieco inaczej w tym wypadku. Używamy tutaj funkcji GridSearchCV. Jako argumenty tej funkcji podajemy funkcję, którą chcemy użyć oraz słownik parametrów z wartościami, które nas interesują. Naszą funkcją jest użyta wcześniej funkcja DecisionTreeClassifier(random_state = 42). Widzimy tutaj, że podałem jeden parametr. W ten sposób możemy podawać stałe wartości parametrów, które mają być uwzględnione w czasie procesu. Funkcja GridSearchCV przygotuje wszystkie kombinacje wartości hiperparametrów i przeprowadzi dla nich trening z uwzględnieniem sprawdzianu krzyżowego.

Sprawdzian krzyżowy

Na czym polega taki sprawdzian (ang. cross validation)? Przyjmijmy, że parametrem naszego sprawdzianu jest domyślna wartość n_splits = 3. Oznacza to, że przed treningiem, nasz zbiór zostanie podzielony na trzy rozłączne podzbiory. Do faktycznego treningu zostaną wzięte zbiory numer 0 i 1, a zbiór numer 2 zostanie użyty do testu. W drugim przebiegu do nowego treningu zostaną wzięte zbiory 0 i 2, a do testu 1. W ostatnim przebiegu trening będzie odbywał się na zbiorach 1 i 2, a test na zbiorze 0. Następnie wyniki z tych testów są uśredniane.

Funkcja GridSearchCV może wybrać więc nam nieco inne wartości hiperparametrów. Jest tak, chociażby z tego powodu, że kryterium wyboru jest tutaj sprawdzian krzyżowy, a w podejściu z pętlami jest to wynik uzyskany na zbiorze testowym. Zawsze możemy używać podejścia ręcznego, jednakże dobrą praktyką jest używanie funkcji takich jak GridSearchCV właśnie. Oczywiście, funkcję tą używamy na zbiorze treningowym, a na samym końcu testujemy ją na zbiorze testowym. W naszym przypadku rozwiązanie to dało nieco gorsze wyniki, jednakże dobrane hiperparametry powinny sprawdzić się dobrze w większej liczbie sytuacji.

Kursy Online

Jeśli jesteś zainteresowany zakupem wideo kursów online które przygotowałem, sprawdź tę stronę – może akurat opublikowałem tam kupony zniżkowe 🙂

Podobne artykuły

2 myśli na temat “Tuning hiperparametrów na przykładzie drzewa decyzyjnego

  1. Hej. Dzięki za wartościowy wpis 🙂
    Próbuję powtórzyć to co tu zaprezentowałeś i mam dwa pytania:
    1) Wychodzi mi, że pojedyncze drzewo, z domyślnymi parametrami ma accuracy na poziomie około 95% (przy różnych podziałach test/train wynik się utrzymuje). GridSearchCV znajduje parametry, które dają około 90% accuracy. Z czego to wynika?
    Czy danych jest na tyle mało, że odrzucenie części przy cross walidacji wpływa tak mocno na drzewo? Bo chyba drzewo stworzone na podstawowych parametrach nie jest przetrenowane skoro ma tak wysokie accuracy dla danych testowych.
    2) Uwzględniając to co powyżej, jak udowodnić że model z GridSearchCV jest lepszy?

    1. Hej Adam!
      Nie znam Twojego kodu ale spróbuję Ci coś doradzić.
      Ad 1) Czy w parametrach sprawdzanych w GridSearchCV są na pewno te same wartości które są ustawione domyślnie w drzewie decyzyjnym? Jeśli nie, to to jest odpowiedź. A jeśli są, drążymy dalej. Może tak być, że odrzucenie danych przy CV faktycznie słabo trenuje drzewo – wtedy można by zwiększyć ilość „zbiorów” 3 na 5 albo i więcej. Wtedy proces będzie trwał odpowiednio dłużej, ale efekt ten powinien się zniwelować. A jeśli nie, to może warto też się upewnić, że faktycznie sprawdzamy wyniki na tym samym zbiorze test. Jeżeli i to zawiedzie, to możesz przejrzeć listę wyników i namierzyć czy kombinacja z drzewa faktycznie tak bardzo odstaje od innych kombinacji. Przyda się do tego atrybut cv_results_ który znajdziesz w dokumentacji https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.GridSearchCV.html
      2) Udowodnić – to brzmi dość jednoznacznie. Nie jestem pewien czy możemy przedstawić taki dowód. Możemy za to przedstawić informację jakie parametry zostały sprawdzone i w jakich proporcjach użyliśmy proces CV. Problemem w powyższym przykładzie jest to, że zbiór danych faktycznie jest mały. Przy większych zbiorach taki negatywny efekt powinien zaniknąć. Ale jeśli faktycznie proces GridSearchCV da Ci gorsze wyniki, to absolutnie nie powinieneś go forsować jako rozwiązanie które powinno być a nie jest lepsze.

      Pokaż kod (np. tutaj: https://gist.github.com/) to w wolnej chwili rzucę na niego okiem i może przyjdzie mi do głowy coś bardziej przydatnego 😉

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *