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:
- 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.
- 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.
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”
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
- https://scikit-learn.org/stable/modules/cross_validation.html – zawiera szczegółowy opis technik wykorzystywanych przy Cross Validacji. Własny zbiór walidacyjny, różne podziały i techniki optymalizacji.
- https://github.com/cgnorthcutt/hypopt – podczas pisania znalazłem także bibliotekę hypopt do optymalizacji parametrów. Nie stosotwałem ale wygląda solidnie.
Jeżeli uważasz ten wpis za wartościowy to Zasubskrybuj bloga. Dostaniesz informacje o nowych artykułach.
Photo by mari lezhava on Unsplash
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?
Jak najbardziej, ale często sam random search jest wystarczający.