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.