Dzisiaj napiszemy własny (a raczej poznamy mechanikę działania) oraz nauczymy się szukać najlepszych hyper parametrów dla modelu (właściwie: całego pipeline) w zwarty sposób.
W pierszej części zobaczyliśmy jak zbudować pipeline dla danych i modeli. Dzięki temu dostaliśmy możliwość zmiany sposobu transformacji danych i zmiany modeli w ramach prostego, zwartego kodu. Ale co jeśli potrzebujemy jakiś transformator którego nie ma w pakietach?
Tak jak poprzednio – nasze działania oprzemy na Pythonie i pakiecie scikit learn. Podobne rozwiązania można znaleźć w R (jeśli tego szukasz – zainteresuj się Tidymodels).
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# bez tego nie ma data science! ;) import pandas as pd import numpy as np from random import random # modele from sklearn.dummy import DummyClassifier # preprocessing - bazowe klasy from sklearn.base import BaseEstimator, TransformerMixin # Pipeline from sklearn.pipeline import Pipeline |
Aby zobaczyć co się dzieje wewnątrz kolejnych budowanych przez nas metod zbudujemy sobie prosty zestaw danych. W dzisiejszym ćwiczeniu nie chodzi o znalezienie konkretnego modelu czy też najlepszego wyniku – dane mogą być więc dowolne, ważne żebyśmy widzieli na nich efekty działań naszego kodu.
1 2 3 4 5 6 |
# cechy X = pd.DataFrame({"Title": ["T1","T2","T3","T4","T5","T6"], "Body": ["B1","B2","B3","B4","B5","B6"], "Code": ["C1","C2","C3","C4","C5","C6"]}) # odpowiedź y = np.array([1.,0.,1.,0.,1.,1.]) |
Własny transformer/estymator
Budujemy pierwszy transformer. Tutaj przyda się podstawowa wiedza na temat programowania obiektowego – co to jest klasa, co to są metody tej klasy, co to jest dziedziczenie i jak to wygląda w Pythonie. Zakładam, że znasz te podstawy.
Nasz transformer potrzebuje dwóch metod: fit()
oraz transform()
. Wiemy jak działają transformery i modele w scikit-learn, prawda? Uczymy je na danych treningowych poprzez wywołanie metody fit()
a potem stosujemy przekształcenie do danych treningowych poprzez transform()
(często stosuje się też od razu uczenie i przekształcenie wywołując fit_transform()
), zaś dane testowe (czy też nowe dane) traktujemy jedynie przez transfor()
.
Często modele/transformery mają jakieś parametry. Podaje się je podczas budowania klasy – czyli wywołuje się konstruktora klasy __init__()
z odpowiednimi parametrami. Konstruktor "zapamiętuje" w ramach obiektu te parametry (w Pythonie jest to po prostu ustawienie wartości zmiennych self.cośtam
dostępnych w ramach całego obiektu). Tak wygląda teoria, konkrety tłumaczy kod poniżej:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
class MyTransformer(BaseEstimator, TransformerMixin): def __init__(self, param_a = [], param_b = 1): """ Konstruktor naszego obiektu MyTransformer. Przyjmuje dwa parametry: * param_a - lista * param_b - wartość liczbowa, domyślnie równa 1 """ # zapamiętujemy hyper paramtery w ramach obiektu self.param_a = param_a self.param_b = param_b # tą wartość "wyprodukuje" metoda fit() self.fitted = None # wypisujemy co się dzieje print("\n= LOG BEG =========================") print("MyTransformer.__init__():") print(f"param_a:\n{self.param_a}\n") print(f"param_b:\n{self.param_b}\n") print(f"fitted:\n{self.fitted}") print("= LOG END =========================\n") def fit(self, x, y=None): """ Metoda ta powinna "nauczyć" parametry w ramach obiektu. Tutaj parametry te są losowo nadane. Pobiera parametry: * x - cechy * y - odpowiedź (ważne dla modelu, przy transformacji bez znaczenia) """ # ustalamy parametry transformatora - jedna losowa liczba 0 lub 1 self.fitted = np.round(np.random.random(1)) # wypisujemy co się dzieje i przy jakich parametrach print("\n= LOG BEG =========================") print("MyTransformer.fit():") print(f"x:\n{x}\n") print(f"y:\n{y}\n") print(f"param_a:\n{self.param_a}\n") print(f"param_b:\n{self.param_b}\n") print(f"fitted:\n{self.fitted}") print("= LOG END =========================\n") return self def transform(self, x): """ Metoda zmienia dane, korzystając z wyuczonych w fit() parametrów. Tutaj zwróci tyle razy wartość wyuczoną w ramach fit() ile jest wierszy danych wejściowych """ # szykujemy odpowiedź ret_val = np.repeat(self.fitted, x.shape[0]) # wypisujemy co się dzieje print("\n= LOG BEG =========================") print("MyTransformer.transform():") print(f"x:\n{x}\n") print(f"y:\n{y}\n") print(f"param_a:\n{self.param_a}\n") print(f"param_b:\n{self.param_b}\n") print(f"fitted:\n{self.fitted}\n") print(f"ret_val:\n{ret_val}") print("= LOG END =========================\n") return ret_val def show_fitted(self): """ Ta metoda nie jest potrzebna, ale tutaj przyda się nam aby pokazać jaką wartość mają wyuczone parametry. """ print(self.fitted) |
Mając zbudowaną klasę możemy sprawdzić jak ona się zachowuje. Najpierw "samodzielnie", a później upakujemy ją w pipeline.
Co się stanie jak utworzymy obiekt naszej klasy? Powinna wykonać się metoda __init__()
– spróbujmy od razu podać parametry:
1 |
obj = MyTransformer(param_a=['a', 'b', 'c'], param_b=123) |
1 2 3 4 5 6 7 8 9 10 11 |
= LOG BEG ========================= MyTransformer.__init__(): param_a: ['a', 'b', 'c'] param_b: 123 fitted: None = LOG END ========================= |
I zadziałało zgodnie z planem – wypisały się podane przez nas parametry, a fitted jest jeszcze nie zdefiniowany. Ale czy na pewno?
1 |
obj.show_fitted() |
1 |
None |
Zgadza się. Zatem wyuczmy (dofitujmy) nasz obiekt na danych przygotowanych wyżej:
1 |
obj.fit(X) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
= LOG BEG ========================= MyTransformer.fit(): x: Title Body Code 0 T1 B1 C1 1 T2 B2 C2 2 T3 B3 C3 3 T4 B4 C4 4 T5 B5 C5 5 T6 B6 C6 y: None param_a: ['a', 'b', 'c'] param_b: 123 fitted: [1.] = LOG END ========================= MyTransformer(param_a=['a', 'b', 'c'], param_b=123) |
1 |
obj.show_fitted() |
1 |
[1.] |
Wszystko się zgadza. A co się stanie jak zrobimy transformację?
1 |
obj.transform(X) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
= LOG BEG ========================= MyTransformer.transform(): x: Title Body Code 0 T1 B1 C1 1 T2 B2 C2 2 T3 B3 C3 3 T4 B4 C4 4 T5 B5 C5 5 T6 B6 C6 y: [1. 0. 1. 0. 1. 1.] param_a: ['a', 'b', 'c'] param_b: 123 fitted: [1.] ret_val: [1. 1. 1. 1. 1. 1.] = LOG END ========================= array([1., 1., 1., 1., 1., 1.]) |
Poza wypisaniem jakichś parametrów obiektu dostaliśmy tablicę pięciu wartości równych temu co jest w fitted. I właśnie o to chodziło. Tutaj jest tablica długa na tyle na ile mamy rekordów w danych – musimy dostać listę, którą da się porównać z wartościami Y naszych danych.
Ale miało być o pipeline’ach – zatem użymy naszego transformera, a za klasyfikator weźmiemy najprostszy DummyClassifier() w dodatku skonfigurowany tak, aby zawsze odpowiadał wartością 0, nie ważne jakie są cechy konkretnej próbki (to ułatwi nam porównanie wyników).
1 2 3 4 |
pipe = Pipeline(steps = [ ('preprocessor', MyTransformer(param_a=[1,2,3], param_b=10)), ('classifier', DummyClassifier(strategy='constant', constant=0)) ]) |
1 2 3 4 5 6 7 8 9 10 11 |
= LOG BEG ========================= MyTransformer.__init__(): param_a: [1, 2, 3] param_b: 10 fitted: None = LOG END ========================= |
Co się wydarzyło? A no tylko tyle, że wywołaliśmy konstruktora.
Co zrobi fitowanie całego pipeline’u?
1 |
pipe.fit(X, y) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
= LOG BEG ========================= MyTransformer.fit(): x: Title Body Code 0 T1 B1 C1 1 T2 B2 C2 2 T3 B3 C3 3 T4 B4 C4 4 T5 B5 C5 5 T6 B6 C6 y: [1. 0. 1. 0. 1. 1.] param_a: [1, 2, 3] param_b: 10 fitted: [0.] = LOG END ========================= = LOG BEG ========================= MyTransformer.transform(): x: Title Body Code 0 T1 B1 C1 1 T2 B2 C2 2 T3 B3 C3 3 T4 B4 C4 4 T5 B5 C5 5 T6 B6 C6 y: [1. 0. 1. 0. 1. 1.] param_a: [1, 2, 3] param_b: 10 fitted: [0.] ret_val: [0. 0. 0. 0. 0. 0.] = LOG END ========================= Pipeline(memory=None, steps=[('preprocessor', MyTransformer(param_a=[1, 2, 3], param_b=10)), ('classifier', DummyClassifier(constant=0, random_state=None, strategy='constant'))], verbose=False) |
Wykonało się najpierw fit() a potem transform().
Po fitowaniu możemy ocenić nasz model:
1 |
pipe.score(X, y) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
= LOG BEG ========================= MyTransformer.transform(): x: Title Body Code 0 T1 B1 C1 1 T2 B2 C2 2 T3 B3 C3 3 T4 B4 C4 4 T5 B5 C5 5 T6 B6 C6 y: [1. 0. 1. 0. 1. 1.] param_a: [1, 2, 3] param_b: 10 fitted: [0.] ret_val: [0. 0. 0. 0. 0. 0.] = LOG END ========================= 0.3333333333333333 |
Widzimy że wykonał się transformer z już wyuczonymi wartościami fitted. Odpowiedzią w tym przypadku jest accuracy – mamy do czynienia z modelem klasyfikującym.
Oczywiście otrzymana wartość predykcji zależy od tego jak wylosował nam się w ramach fit parametr fitted, ale w tym konkretnym przypadku zawsze będziemy mieć 1/3 skuteczności (bo mamy 2 zera i 4 jedynki w danych, a DummyClassifier skonfigurowaliśmy tak, aby zawsze zwracał zero).
Do czego to wszystko może być potrzebne? Do kazdej sytuacji, w której nie mamy gotowego rozwiązania. Nie mamy (a przynajmniej nie kojarzę) transformatora który na przykład zamieni nam zapis liczb dziesiętnych z 12.345,67 na 12345.67 i zmieni otrzymaną wartość na float. Oczywiście przykłady można mnożyć – to jeden z najprostszych.
Szukanie najlepszych hyper parametrów
No dobrze – wiemy jak przygotować swoje transformatory danych (spróbuj analogicznie przygotować estymatory!), a nawet dać im możliwość kręcenia śrubkami w postaci hyper parametrów. Ale jak znaleźć najlepszą kombinację hyper parametrów?
Użyjemy przeszukiwania po siatce wszystkich parametrów. SciKit Learn ma to na dzień dobry:
1 |
from sklearn.model_selection import GridSearchCV |
Do tego ćwiczenia przygotujemy inną wersję naszego transformatora – takiego, który podczas fittowania niczego nie robi, ale podczas transformacji już coś się dzieje (i jest to zależne od hyper parametrów):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class MyTransformerTwo(BaseEstimator, TransformerMixin): def __init__(self, param_a = [], param_b = 1): self.param_a = param_a self.param_b = param_b def fit(self, x, y=None): return self def transform(self, x): titles = ",".join(x.Title) if np.sum(self.param_a)/(1+self.param_b) >= 10: ret_val = np.zeros(x.shape[0]) else: ret_val = np.ones(x.shape[0]) print(f"x.shape: {x.shape} ({titles}) \t\t\tparam_a: {self.param_a} \t\tparam_b: {self.param_b}\t\tret_val: {ret_val}") return ret_val |
Podobnie jak już wiele razy wcześniej – budujemy pipeline i siatkę parametrów do przeszukania. Dla naszego przykładu nie będziemy zmieniać parametrów klasyfikatora (i tak jak poprzednio zawsze dostaniemy – tym razem – jedynkę) – dzięki temu zobaczymy jakie są kolejne przebiegi po siatce.
1 2 3 4 5 6 7 8 9 |
pipe_two = Pipeline(steps=[ ('preprocessor', MyTransformerTwo()), ('classifier', DummyClassifier(strategy='constant', constant=1)) ]) param_grid = { 'preprocessor__param_a': [[1, 2, 3], [4, 5, 6]], 'preprocessor__param_b': [0, 1] } |
No to szukamy. Przy okazji (parametr cv) włączamy walidację krzyżową (cross validation) – dla każdej kombinacji parametrów podzielimy zbiór na dwa foldy.
1 2 |
grid = GridSearchCV(pipe_two, param_grid, cv=2) grid.fit(X, y) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
x.shape: (3, 3) (T4,T5,T6) param_a: [1, 2, 3] param_b: 0 ret_val: [1. 1. 1.] x.shape: (3, 3) (T1,T2,T3) param_a: [1, 2, 3] param_b: 0 ret_val: [1. 1. 1.] x.shape: (3, 3) (T1,T2,T3) param_a: [1, 2, 3] param_b: 0 ret_val: [1. 1. 1.] x.shape: (3, 3) (T4,T5,T6) param_a: [1, 2, 3] param_b: 0 ret_val: [1. 1. 1.] x.shape: (3, 3) (T4,T5,T6) param_a: [1, 2, 3] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T1,T2,T3) param_a: [1, 2, 3] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T1,T2,T3) param_a: [1, 2, 3] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T4,T5,T6) param_a: [1, 2, 3] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T4,T5,T6) param_a: [4, 5, 6] param_b: 0 ret_val: [0. 0. 0.] x.shape: (3, 3) (T1,T2,T3) param_a: [4, 5, 6] param_b: 0 ret_val: [0. 0. 0.] x.shape: (3, 3) (T1,T2,T3) param_a: [4, 5, 6] param_b: 0 ret_val: [0. 0. 0.] x.shape: (3, 3) (T4,T5,T6) param_a: [4, 5, 6] param_b: 0 ret_val: [0. 0. 0.] x.shape: (3, 3) (T4,T5,T6) param_a: [4, 5, 6] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T1,T2,T3) param_a: [4, 5, 6] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T1,T2,T3) param_a: [4, 5, 6] param_b: 1 ret_val: [1. 1. 1.] x.shape: (3, 3) (T4,T5,T6) param_a: [4, 5, 6] param_b: 1 ret_val: [1. 1. 1.] x.shape: (6, 3) (T1,T2,T3,T4,T5,T6) param_a: [1, 2, 3] param_b: 0 ret_val: [1. 1. 1. 1. 1. 1.] GridSearchCV(cv=2, error_score=nan, estimator=Pipeline(memory=None, steps=[('preprocessor', MyTransformerTwo(param_a=[], param_b=1)), ('classifier', DummyClassifier(constant=1, random_state=None, strategy='constant'))], verbose=False), iid='deprecated', n_jobs=None, param_grid={'preprocessor__param_a': [[1, 2, 3], [4, 5, 6]], 'preprocessor__param_b': [0, 1]}, pre_dispatch='2*n_jobs', refit=True, return_train_score=False, scoring=None, verbose=0) |
Każdy przebieg to jeden wiersz w powyższym listingu. I w wierszu tym widzimy które z elementów służyły jako dane treningowe oraz jakie hyper parametry zostały podane na "rurociąg". Nie widzimy wyniku modelu dla takich parametrów (za moment zobaczymy), ale możemy szybko znaleźć najlepszy wynik:
1 |
grid.best_score_ |
1 |
0.6666666666666666 |
Wszytkie wyniki oczywiście też. Ale uwaga – są to wartości dla danej kombinacji parametrów (odpowiednio uśrednione) a nie konkretnego przebiegu w ramach puli parametrów. To nawet lepiej – mamy wynik bardziej stabilny (uwzględniający cross validation):
1 |
grid.cv_results_ |
1 2 3 4 5 6 7 8 |
{'mean_fit_time': array([0.00187075, 0.00159085, 0.00153482, 0.00143743]), 'std_fit_time': array([2.78115273e-04, 7.37905502e-05, 1.80363655e-04, 2.71797180e-05]), 'mean_score_time': array([0.00124204, 0.00121975, 0.00105858, 0.00102854]), 'std_score_time': array([3.93390656e-06, 6.15119934e-05, 1.28984451e-04, 8.44001770e-05]), 'param_preprocessor__param_a': masked_array(data=[list([1, 2, 3]), list([1, 2, 3]), list([4, 5, 6]), list([4, 5, 6])], mask=[False, False, False, False], fill_value='?', dtype=object), 'param_preprocessor__param_b': masked_array(data=[0, 1, 0, 1], mask=[False, False, False, False], fill_value='?', dtype=object), 'params': [{'preprocessor__param_a': [1, 2, 3], 'preprocessor__param_b': 0}, {'preprocessor__param_a': [1, 2, 3], 'preprocessor__param_b': 1}, {'preprocessor__param_a': [4, 5, 6], 'preprocessor__param_b': 0}, {'preprocessor__param_a': [4, 5, 6], 'preprocessor__param_b': 1}], 'split0_test_score': array([0.66666667, 0.66666667, 0.66666667, 0.66666667]), 'split1_test_score': array([0.66666667, 0.66666667, 0.66666667, 0.66666667]), 'mean_test_score': array([0.66666667, 0.66666667, 0.66666667, 0.66666667]), 'std_test_score': array([0., 0., 0., 0.]), 'rank_test_score': array([1, 1, 1, 1], dtype=int32)} |
Dość długa i nieczytelna ta lista, najprościej uzyskać zestaw najlepszych hyper parametrów przez
1 |
grid.best_params_ |
1 |
{'preprocessor__param_a': [1, 2, 3], 'preprocessor__param_b': 0} |
Wytrenowany obiekt typu GridSearch() jest tym samym co wytrenowany pipeline czy też gotowy estymator, tak więc już na nim możemy użyć metod do predykcji (oczywiście skorzysta wtedy z najlepszych hyper parametrów):
1 |
grid.predict(X) |
1 2 |
x.shape: (6, 3) (T1,T2,T3,T4,T5,T6) param_a: [1, 2, 3] param_b: 0 ret_val: [1. 1. 1. 1. 1. 1.] array([1, 1, 1, 1, 1, 1]) |
Wydawać się może dziwne, że wszystkie kombinacje parametrów dały 66% skuteczności. Ale spójrz na dobór próbek do foldów – mamy zestaw T1, T2 i T3 i drugi zestaw to T3, T4, T5. Patrząc na tabelę z danymi:
1 2 |
X['y'] = y X |
1 2 3 4 5 6 7 |
Title Body Code y 0 T1 B1 C1 1.0 1 T2 B2 C2 0.0 2 T3 B3 C3 1.0 3 T4 B4 C4 0.0 4 T5 B5 C5 1.0 5 T6 B6 C6 1.0 |
widać, że w tych kombinacjach zawsze mamy dwie jedynki i jedno zero (a DummyClassifier zawsze zwraca 1) więc zawsze w 2/3 "pasuje".
Czy przeszukiwanie całej siatki hyper parametrów to najlepszy sposób? Na to pytanie odpowiemy sobie w części kolejnej.