Pipeline w SciKit Learn - własny estymator
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).
# 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.
# 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:
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:
obj = MyTransformer(param_a=['a', 'b', 'c'], param_b=123)
= 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?
obj.show_fitted()
None
Zgadza się. Zatem wyuczmy (dofitujmy) nasz obiekt na danych przygotowanych wyżej:
obj.fit(X)
= 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)
obj.show_fitted()
[1.]
Wszystko się zgadza. A co się stanie jak zrobimy transformację?
obj.transform(X)
= 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).
pipe = Pipeline(steps = [
('preprocessor', MyTransformer(param_a=[1,2,3], param_b=10)),
('classifier', DummyClassifier(strategy='constant', constant=0))
])
= 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?
pipe.fit(X, y)
= 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:
pipe.score(X, y)
= 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:
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):
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.
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.
grid = GridSearchCV(pipe_two, param_grid, cv=2)
grid.fit(X, y)
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:
grid.best_score_
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):
grid.cv_results_
{'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
grid.best_params_
{'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):
grid.predict(X)
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:
X['y'] = y
X
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.