Grid vs Random search w scikit-learn – co powinieneś wiedzieć o doborze parametrów?

Co ma decydujący wpływ na efektywność w procesie klasyfikacji? Od razu nasuwa się odpowiedź algorytm, ale pamiętajmy nawet najlepszy/najmodniejszy algorytm nie zadziała bez skrupulatnie dobranych parametrów. Jak je dobierać, na co zwrócić uwagę oraz czy losowe przeszukiwanie przestrzeni jest lepsze od podejścia uczesanego wyczerpującego?

Ucząc model predykcyjny, jakiego algorytmu byśmy nie wybrali to zawsze ma on jakieś parametry (tutaj był żart o tym, że cebula ma warstwy a algorytm ma parametry, ale był słaby więc go pominąłem) :

  • w Support Vector Machines (SVM) – musimy podać  typ jądra (RBF, linear, poly itp), parametry jądra (RBF – gamma, poly – wykładnik) oraz wybrać wartość parametru „C”
  • w Randomforest – musimy podać liczbę drzew w lesie (jakkolwiek mało naukowo by to nie brzmiało 🙂 )
  • w Sieciach neuronowych – uuuu, w tym przypadku to lista jest długa. W zależności od typu modelu musimy określić: liczbę warstw ukrytych, liczbę neuronów w warstwie, typu funkcji aktywacji, rozmiar okna konwolucji, stride, długość wczytywanej sekwencji, typ komórki (LSTM, GRU), wartość dropout itp.

Powyższa lista nie jest wyczerpująca i jak pewnie zauważyłeś mój bystry czytelniku wybór samego algorytmu to dopiero połowa sukcesu i to ta mniejsza połowa.

Domyślne parametry rzadko są optymalne, bo skąd autor danej biblioteki ma wiedzieć, z jakimi danymi przyszło Ci pracować. Więc co nam zwykłym zjadaczom danych pozostaje? Eksperymentować i poszukiwać!
Jak to zrobić mądrze?

Jak wybrać najlepsze parametry modelu?

Zakładam, że już wybrałeś swój ulubiony/modny algorytm. Znasz go, a co ważniejsze rozumiesz jaki wpływ na jego skuteczność mają jego parametry. Dobierając parametry, często się zdarza, że musimy pójść na jakiś kompromis. Zazwyczaj nie maj jednego złotego zestawu, na jednych działa dobrze, ale liczy się długo, na innych może słabiej chwytać jakąś wybraną klasę itp.

Wybierz miarę oceny klasyfikatora.

Wiec, od czego mam zacząć mistrzu Yoda?
– Od końca zacznij, miarę oceny modelu określ.

Idąc za radą Yody, powinieneś wybrać miarę oceny modelu. Jest ich wiele w zależności od problemu, zachęcam do poczytania o:  precision+recall, miara F1, ROC curve. My przyjmiemy najprostszą, czyli dokładność klasyfikacji (accuracy), mierzoną jako procent poprawnie rozpoznanych przykładów.

Nie zafałszuj sobie wyniku.

Pewnie nie muszę przypominać, że podczas poszukiwania parametrów, oceny klasyfikatora powinniśmy dokonywać na danych, które nie były wykorzystywane do treningu. W przeciwnym wypadku padamy ofiarą przeuczenia (ang. overfittingu), czyli dostosowania się algorytmu do naszego zbioru. Obliczony model będzie działał rewelacyjnie na danych treningowych, ale już na rzeczywistych będziemy osiągali słabe wyniki. Najłatwiej można poradzić sobie na dwa sposoby:

  1. Podzielić zbiór treningowy na dwie części, służącą do treningu (train dataset) oraz walidacyjną (validation dataset). Poszukiwanie parametrów uruchamiać na części treningowej a ocenę przeprowadzić na części walidacyjnej.
  2. Jeżeli danych mamy niewiele i wydzielanie oddzielnego zbioru walidacyjnego bardzo uszczupla nam zbiór treningowy, to możemy wykorzystać technikę walidacji krzyżowej (cross-validation) – dzielimy nasz zbiór treningowy na K części (zazwyczaj 3,5,7,10). Następnie kolejno odrzucamy jedną część, uczymy nasz klasyfikator na pozostałych, a oceny dokonujemy na tej odrzuconej. Postępujemy w ten sposób K-razy, za każdym razem odrzucając inną część. Na koniec uśredniamy wyniki dla otrzymanych w ten sposób K ocen.

Jak przeprowadzić proces przeszukiwania przestrzeni parametrów

Mając wybrany algorytm, miarę oceny jego sprawności oraz to, na jakich danych będziemy go uczyć. Powinniśmy zastanowić się nad parametrami, które będziemy chcieli optymalizować. Jak wspomniałem na wstępie, w zależności od algorytmu, parametrów może być dużo. Nie zawsze będziemy chcieli wszystkie tuningować, nie zawsze się to opłaca. Każdy dodatkowy brany pod uwagę meta-parametr poszerza naszą przestrzeń poszukiwania o dodatkowy wymiar. Przykładowo w algorytmie SVM z jądrem liniowym mamy tylko jeden parametr C, natomiast gdy już wybierzemy SVM z jądrem RBF to dochodzi nam drugi parametr \(\gamma\). Skutkuje to tym, że podczas naszych poszukiwań musimy uwzględnić wszystkie kombinacje tych dwóch parametrów. Możemy skomplikować sobie życie jeszcze bardziej i dodać jeszcze trzeci: typ jądra w SVM (liniowe, RBF, Poly). Zalecałbym zacząć spokojnie od jak najmniejszej liczby optymalizowanych parametrów.

Na piechotę.

Pierwsza ewentualność, można to robić ręcznie. Kolejno uruchamiać program/skrypt i w kodzie zmieniać istotne parametry wedle naszego uznania lub doświadczenia. Oczywiście my jako leniwi informatycy brzydzimy się takim podejściem i stosujemy rozwiązania automatyczne (czytaj zazwyczaj dłuższe, pełne błędów).  Jednak to podejście ma swoją jedną ogromną zaletę, jest proste i szybkie do implementacji. Przy niedużych modelach podczas prototypowania może wystarczyć. Oczywiście, gdy chcemy zoptymalizować model produkcyjny, to takie podejście w nie zda egzaminu.

Zacznijmy od wygenerowania tablicy logarytmicznie rozłożonych elementów. Wykorzystamy w tym celu  funkcję np.logspace,

import numpy as np
print np.logspace(-1, 1, 3)
# [ 0.1 1. 10.]

Dla przekazanych parametrów wygeneruje tablicę  \([10^{-1},10^0, 10^1]\). Następnie, aby wygenerować różne wartości w ramach danego rzędu wielkości wykorzystamy „iloczyn zewnętrzny” (np.outer)

C_range = np.outer(np.logspace(-1, 1, 3),np.array([1, 3, 5, 7]))
# [[ 0.1 0.3 0.5 0.7]
# [ 1. 3. 5. 7. ]
# [ 10. 30. 50. 70. ]]
C_range = C_range.flatten()

W ten sposób otrzymaliśmy dwuwymiarową tablicę z możliwymi parametrami z  różnych „rzędów wielkości”. Na koniec spłaszczmy tę tablicę używając funkcji flatten().

Teraz sprawa wydaje się prosta. Jeżeli rozważamy algorytm SVM (z jądrem liniowym) to musimy sprawdzić tylko parametr C. To do dzieła! Z wykorzystaniem brutalnej siły sprawdzamy N możliwości. W pętli  kolejno podstawiamy do algorytmu i przeprowadzam trening.

No ale poczekaj, to znaczy, że muszę przetrenować N klasyfikatorów! Niestety tak, nie ma nic za darmo. Łatwo policzyć, że skoro trening jednego zajmuje np. 2h – to całość zajmie nam 2*N godzin. Tutaj często trzeba uzbroić się w cierpliwość. W swojej pracy miałem sytuację, że uruchomione obliczenia trwały nawet 1 miesiąc. Wyobraźcie sobie, jakaż była moja frustracja, gdy z powodu awarii prądu w serwerowni wydziałowej, a raz z powodu ciekawości admina (co ten serwer tak hałasuje i się grzeje) maszyna została zrestartowana.
Niestety, dopiero po tych sytuacjach wpadłem na pomysł zapisywania wyników cząstkowych.

Grid search w scikit-learn na przykładzie SVM

Gdy chcesz przejść od razu do pracy z kodem, to ściągnij przykład z mojego githuba –  SVM grid search.

Całą procedurę poszukiwania parametrów, zrealizujemy na przykładzie zbioru MNIST (odręcznie pisanych cyfr) oraz algorytmu SVM. W podlinkowanym wpisie opisałem sam zbiór oraz idea algorytmu SVM. W opisywanym przykładzie wykorzystamy SVM z jądrem RBF, wyrażone wzorem:

\({\displaystyle K(\mathbf {x} ,\mathbf {x’} )=\exp(-\gamma \|\mathbf {x} -\mathbf {x’} \|^{2})} \)

Jądro w SVM można rozumieć jako miarę podobieństwa pomiędzy dwoma obiektami (wektorami). W ujęciu bardziej formalnym powinien to być iloczyn skalarny. Nie wchodząc w dalsze szczegóły, chciałbym abyście zwrócili uwagę na metaparametr \(\gamma\) (gamma). Właśnie od niego w dużej mierze zależy jakość naszego modelu i jego sprawność w przewidywaniu przyszłych etykiet.

Do ściągnięcia danych, uczenia algorytmu oraz wyboru parametrów użyjemy biblioteki scikit-learn.

W pierwszej kolejności importujemy niezbędne biblioteki. Wykorzystując funkcję fetch_mldata() ściągamy do folderu głównego zbiór MNIST (pierwsze uruchomienie może chwilę zająć, następne używają już plików z dysku). Mając obiekt mnist w polach data i target mamy odpowiednio obrazy oraz docelowe etykiety.

# Standard scientific Python imports
import matplotlib.pyplot as plt
import numpy as np
import time
import datetime as dt

# Import datasets, classifiers and performance metrics
from sklearn import datasets, svm, metrics
#fetch original mnist dataset
from sklearn.datasets import fetch_mldata

# import custom module
from mnist_helpers import *


mnist = fetch_mldata('MNIST original', data_home='./')
#data field is 70k x 784 array, each row represents pixels from 28x28=784 image
images = mnist.data
targets = mnist.target

#rescale data to [0,1]
X_data = images/255.0
Y = targets

Następnie podzielmy wczytane dane na dwa zbiory: testowy i treningowy. Wielkość zbioru testowego ustaliliśmy na 15% całości danych.

Importujemy klasę GridSearchCV (grid search cross validation). Generujemy zakres parametru gamma, będą to wielkości od \( [10^-2, 10^-1] \) dodatkowo przemnożone przez 1 i 5. Zakres dla parametru C ustalimy jako \( [10^-1, 10^-0] \) przemnożone także przez 1 i 5. Na końcu pozostaje nam wszystko zapakować w słownik, w którym ustalamy ostatni metaparametr, czyli sam typ jądra na 'rbf’.

Rozpoczynamy uczenie. Tworzymy obiekt SVC (support vector classification) przekazujemy go do klasy GridSearchCV.  Wywołując funkcję grid_clsf.fit(X_train, y_train) rozpoczynamy całą procedurę poszukiwania parametrów. Klasa ta po kolei będzie uczyła klasyfikatory SVC z wykorzystaniem techniki cross-validation. Przypominam, że dla każdej pary (gamma, C) zostanie uruchomiona klasyfikacja. W naszym przypadku mamy 4 parametry gamma i 4 parametry C. Proces klasyfikacji zostanie przeprowadzony 4*4=48 razy! Uwaga w oryginalnym kodzie na github zakres parametrów jest poszerzony.

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X_data, Y, test_size=0.15, random_state=42)

############### Classification with grid search ##############

# Warning! It takes really long time to compute this about 2 days

# Create parameters grid for RBF kernel, we have to set C and gamma
from sklearn.model_selection import GridSearchCV

# generate matrix with all gammas
gamma_range = np.outer(np.logspace(-2, -1, 2),np.array([1,5]))
gamma_range = gamma_range.flatten()

# generate matrix with all C
C_range = np.outer(np.logspace(-1, 0, 2),np.array([1,5]))
# flatten matrix, change to 1D numpy array
C_range = C_range.flatten()

parameters = {'kernel':['rbf'], 'C':C_range, 'gamma': gamma_range}

svm_clsf = svm.SVC()
grid_clsf = GridSearchCV(estimator=svm_clsf,param_grid=parameters,n_jobs=1, verbose=2)


start_time = dt.datetime.now()
print('Start param searching at {}'.format(str(start_time)))

grid_clsf.fit(X_train, y_train)

elapsed_time= dt.datetime.now() - start_time
print('Elapsed time, param searching {}'.format(str(elapsed_time)))
sorted(grid_clsf.cv_results_.keys())

classifier = grid_clsf.best_estimator_
params = grid_clsf.best_params_

scores = grid_clsf.cv_results_['mean_test_score'].reshape(len(C_range),
                                                     len(gamma_range))

plot_param_space_scores(scores, C_range, gamma_range)

Wynikiem uruchomienia tego kodu będzie wykres przedstawiający heatmap’ę pokazującą, dla jakich par parametrów otrzymaliśmy najlepsze wyniki.

svm grid search hetamap scikit learn
Dokładność klasyfikacji cyfr z MNIST z wykorzystaniem Grid Search w scikit-learn

Najlepsze wyniki otrzymamy, gdy wybierzemy parametry C=5.0 a gamma=0.05.

Przeszukiwanie losowe – czy ma to sens?

Wadą poprzedniego podejścia może być słabe „przeczesanie przestrzeni” szczególnie w przypadku gdy poszukujemy więcej niż jednego parametru na raz. Jeżeli przestrzeń parametrów jest mocno nieliniowa oraz jeden z parametrów ma niewielki wpływ na wyniki to poszukiwanie parametrów na siatce nie jest optymalnym rozwiązaniem. Regularność siatki powoduje, że możemy nie natrafiać na istotne maksima. Tego problemu pozbawione jest przeszukiwanie losowe. Branie do  każdego eksperymentu losowych wartości metaparametrów zwiększa szanse na to aby trafić w charakter przestrzeni.

Które z dwóch podejść jest lepsze? Polecam przeczytać publikację, która porównuje „Random Search vs Grid Search” pt.  „Random Search for Hyper-Parameter Optimization James Bergstra, Yoshua Bengio”

Porównanie przeszukania przestrzenia z wykorzystaniem Grid i Random search.
Obraz od James Bergstra, Yoshua Bengio

Choć w publikacji wykazano wyższość podejścia losowego, to z mojej praktyki oba mają swoje zalety. Pierwsze Grid Search przydaje się, wtedy gdy mamy jakąś intuicję lub wiedzę, z jakiego zakresu chcemy poszukiwać parametrów. U mnie sprawdza się także przy niedużej liczbie parametrów do optymalizacji maksymalnie 3. Drugie Random Search pozwala szybciej przeszukać większy zakres parametrów oraz ich wartości. Możemy w ten sposób szybko nabrać intuicji o zakresach i wpływie parametrów na model.

RandomSearch w scikit-learn na przykładzie SVM

W poprzednim przykładzie poszukiwaliśmy parametrów dla alg. SVM z jądrem RBF, ustaliliśmy, że oba parametry będziemy brali z zakresu skali logarytmicznej \(C= [10^-1, 10^-1] \) a \(\gamma = [10^-2, 10^-1] \).

W podejściu losowym parametr gamma będziemy  losować z przedziału \(\gamma = [0, 1] \) (przyjęto rozkład jednostajny). Natomiast parametr C z zakresu \(C = [0, 10] \).

Klasa RandomizedSearchCV z scikit-learn podobnie jak GridSearchCV przyjmuje argument w postaci słownika z parametrami. W GridSearchCV był to param_grid a w RandomizedSearchCV  jest to param_distributions. Jak sama nazwa wskazuje, przekazujemy mu rozkłady prawdopodobieństwa parametrów.

Najłatwiej to zrobić z wykorzystaniem modułu stats z biblioteki SciPy. Na nasze potrzeby wykorzystamy funkcję scipy.stats.uniform. Można ją wykorzystać do wylosowania tablicy wartości lub podając parametry rozkładu ustalić i zamrozić rozkład prawdopodobieństwa. Następnie posługiwać się funkcją, która będzie zwracać liczby pseudolosowe z tego rozkładu. 

from scipy.stats import uniform as sp_uniform

# frozen distribution, C_range now is a function
C_range = sp_uniform(scale=10)
# frozen distribution, C_range now is a function
gamma_range = sp_uniform(scale=1)
parameters = {'kernel':['rbf'],
              'C':C_range, 
              'gamma': gamma_range
 }

Struktura samego skryptu jest bardzo podobna do procedury GridSearch. W pliku svm_mnist_random_search.py w repozytorium znajdziecie pełen skrypt wraz z wczytaniem danych oraz ich przeskalowaniem. Poniżej tylko główna część skryptu

############### Classification with random search ##############

from scipy.stats import uniform as sp_uniform

# Create parameters grid for RBF kernel, we have to set C and gamma
C_range = sp_uniform(scale=10)
gamma_range = sp_uniform(scale=1)
parameters = {'kernel':['rbf'],
              'C':C_range, 
              'gamma': gamma_range
 }
from sklearn.model_selection import RandomizedSearchCV
n_iter_search = 1
svm_clsf = svm.SVC()
rnd_clsf = RandomizedSearchCV(estimator=svm_clsf,
                              param_distributions=parameters,
                              n_iter=n_iter_search, 
                              cv=3,
                              n_jobs=1,
                              verbose=2)

# Warning! It takes really long time to compute this about 2 days
start_time = dt.datetime.now()
print('Start param searching at {}'.format(str(start_time)))

rnd_clsf.fit(X_train, y_train)

elapsed_time= dt.datetime.now() - start_time
print('Elapsed time, param searching {}'.format(str(elapsed_time)))
sorted(rnd_clsf.cv_results_.keys())

classifier = rnd_clsf.best_estimator_
params = rnd_clsf.best_params_

scores = rnd_clsf.cv_results_['mean_test_score'].reshape(len(C_range),
                                                     len(gamma_range))

plot_param_space_scores(scores, C_range, gamma_range)


######################### end random search section #############

# Now predict the value of the test
expected = y_test
predicted = classifier.predict(X_test)

show_some_digits(X_test,predicted,title_text="Predicted {}")

print("Classification report for classifier %s:\n%s\n"
      % (classifier, metrics.classification_report(expected, predicted)))
      
cm = metrics.confusion_matrix(expected, predicted)
print("Confusion matrix:\n%s" % cm)

plot_confusion_matrix(cm)

print("Accuracy={}".format(metrics.accuracy_score(expected, predicted)))

W powyższym ustaliliśmy poziom cross-validacji na 3 parametr cv=3 oraz liczbę poszukiwań na 1 parametr n_iter=1. Zrobiłem to tylko z powodów wydajnościowych gdyż 1 przetrenowanie na moim komputerze zajmuje ok 120 minut. Można by parametr cv zwiększyć do 5 lub 7, a to będzie już wymagało 5 lub 7 przetrenowań algorytmu. Ilość prób musicie dostosować do własnych potrzeb, im więcej tym dokładniej przeszukamy przestrzeń. Zależy to od ilości optymalizowanych hyper-parametrów. 

Przykładowy wydruk na konsoli podczas z uruchomienia 

Start param searching at 2018-12-16 08:54:53.533465
Fitting 3 folds for each of 1 candidates, totalling 3 fits
[Parallel(n_jobs=1)]: Using backend SequentialBackend with 1 concurrent workers.
[CV] C=3.9457151521739853, gamma=0.8659696656106756, kernel=rbf ......
[CV]  C=3.9457151521739853, gamma=0.8659696656106756, kernel=rbf, total=129.5min
[Parallel(n_jobs=1)]: Done   1 out of   1 | elapsed: 154.6min remaining:    0.0s
[CV] C=3.9457151521739853, gamma=0.8659696656106756, kernel=rbf ......
[CV]  C=3.9457151521739853, gamma=0.8659696656106756, kernel=rbf, total=131.1min
[CV] C=3.9457151521739853, gamma=0.8659696656106756, kernel=rbf ......
[CV]  C=3.9457151521739853, gamma=0.8659696656106756, kernel=rbf, total=136.9min
[Parallel(n_jobs=1)]: Done   3 out of   3 | elapsed: 473.7min finished

Polecane materiały

Jeżeli uważasz ten wpis za wartościowy to Zasubskrybuj bloga. Dostaniesz informacje o nowych artykułach.

Join 99 other subscribers

Photo by mari lezhava on Unsplash

4 Comments Grid vs Random search w scikit-learn – co powinieneś wiedzieć o doborze parametrów?

  1. Kamil

    Czy poprawnym będzie połączenie obydwu metod, tzn. najpierw zastosowanie Random Search w celu określenia przybliżonego zakresu, a następnie Grid Search w oparciu o uzyskane wyniki?

    Reply

Ciekawe, wartościowe, podziel się proszę opinią!

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.