Przejdź do treści

Czym są hiperparametry i jak je dobrać?

– Tatusiu, czemu dzisiaj tak późno po mnie przyjechałeś?

– Przepraszam Księżniczko. Wyszedłem dzisiaj troszkę później z pracy, ponieważ chciałem jeszcze dobrać najlepsze hiperparametry dla modelu.

– Hiper co?

– Hiperparametry. Zamyśliłem się przez chwilkę. Hmm… Jakby Ci to wytłumaczyć… Pamiętasz zestaw do karaoke, który dostałaś od Mikołaja w ostatnie święta? Jagoda kiwnęła twierdząco głową. Na początku jak włączyłaś urządzenie grała tylko muzyka, ale nie było słychać Twojego śpiewu do mikrofonu. Zaczęłaś więc kręcić różnymi pokrętłami, ale wówczas słychać było tylko Ciebie bez muzyki. Kręciłaś kolejnymi pokrętłami i zmieniałaś ustawienia. Tłumaczyłem Ci, że ustawiając odpowiednio pokrętła będzie słychać jednocześnie i Ciebie i muzykę.

– Pamiętam. Zajęło mi to troszkę czasu, ale z Twoją pomocą udało się.

– Prawda. Wynika to z tego, że przekręcaliśmy każde pokrętło po troszeczku. Ale potem nabrałaś już wprawy i przestawiałaś kilka pokręteł jednocześnie. W ten sposób idealnie dobrałaś parametry i mogłaś śpiewać jednocześnie słuchając muzyki. Ja dzisiaj w pracy robiłem dokładnie to samo. Jest kilka różnych metod, ale o tym opowiem Ci innym razem. Teraz jedźmy do domku pobawić się kucykami i jednorożcami z Otylką.

Czym różni się parametr od hiperparametru?

Przyznam, że troszkę czasu zajęło mi znalezienie różnicy w nazewnictwie między parametrami i hiperparametrami. Okazało się, że jest ona bardzo prosta.

Każdy model w uczeniu maszynowym ma parametry. Jedne mają ich więcej, drugie mniej. Jeśli parametr wyliczany jest samodzielnie przez algorytm podczas uczenia to nazywamy go po prostu parametrem. Przykładem mogą być wagi w sieciach neuronowych.

Natomiast jeśli parametr podawany jest przez użytkownika, który używa algorytmu, wówczas nazywamy go hiperparametrem (np. liczba drzew i iteracji w lesie losowym).

Czy warto zmieniać i dobierać hiperparametry w modelu?

Jeśli mamy już wybrane dane oraz przeliczony wstępny model, to moim zdaniem zawsze warto poświęcić odrobinę czasu na dobór hiperparametrów. Dlaczego? Ponieważ w każdym przypadku optymalizacji dostaniecie lepszy wynik niż na wartościach domyślnych.

Natomiast to o ile będzie wyższy zależy od wielu elementów:

  • jakie macie dane i ile można z nich wyciągnąć,
  • jakiej metody użyliście,
  • ile czasu chcecie przeznaczyć na wyliczenia.

Zdarzają się problemy, gdzie dzięki optymalizacji można uzyskać bardzo niewielki uzysk. Natomiast często dzięki niej mogłem poprawić moc modelu na wartościach domyślnych o 10%-20%.

Warto również pamiętać o tym, że wszystkie modele mają hiperparametry – również te najprostsze.

Od czego zacząć?

Z doświadczenia powiem, że testuję najpierw kilka różnych modeli (XGBoost, Random Forest, Regresje) na domyślnych parametrach.

Następnie dla dwóch najlepszych metod dokonuję kilku przeliczeń dla najistotniejszych hiperparametrów. W przypadku algorytmów drzewiastych przede wszystkim potestujcie:

  • liczbę iteracji,
  • głębokość drzewa,
  • szybkość uczenia.

A w przypadku, gdyby model był przetrenowany, możecie zmniejszyć liczbę branych obserwacji:

  • wierszy,
  • kolumn.

Ostateczne hiperparametry dobieram dopiero wówczas, gdy mam już końcową listę charakterystyk. W przypadku XGBoosta optymalizacja zawsze dawała mi lepszy wynik – pytanie tylko o ile 🙂

Poniżej przykładowy wykres obrazujący moce wszystkich modeli, które uzyskałem podczas optymalizacji hiperparametrów dla jednego z problemów w pracy. Wówczas przez weekend przeliczyło mi się kilkanaście tysięcy iteracji. Poniżej histogram przedstawiający rozkład po mocy. Jak widzicie, gdybym przyjął parametry domyślne, to moc liczona miarą AUC byłaby o około 10 punktów procentowych mniejsza.

Osobiście uważam, że w większości problemów nie ma co zabijać się o każdy procent (oczywiście tutaj też jest zasada „to zależy”, ponieważ są problemy, gdzie warto walczyć o procenty), ale mimo wszystko warto być bliżej tych wyższych wartości na wykresie (ostatnia góra na prawo).

Jeśli nie mamy możliwości puszczenia dużej liczby optymalizacji (>1.000) to moim zdaniem spokojnie wystarczy puścić losowo 50-100 optymalizacji hiperparametrów, aby dobrać już fajny wynik. Następnie mając te wyniki przyjrzyjcie się im i wybierzcie najrozsądniejsze. Czasami, jeśli nie ma znaczącej różnicy na mocy, warto dać prostszy model (np. mniejszą głębokość).

Jakie są metody doboru hiperparametrów?

Najbardziej rozpowszechnione i najczęściej używane są trzy metody.

a) Metoda siatki (Grid Search)

Jest to pierwsza metoda jaka powstała i jest bardzo intuicyjna. Polega na tym, że rysujemy siatkę parametrów, które chcemy przetestować. Załóżmy, że chcemy przetestować dwa hiperparametry: głębokość (od 1 do 9) oraz szybkość uczenia (10 wartości).

Wówczas możemy wyliczyć 9 x 10 = 90 modeli, aby sprawdzić wszystkie możliwości. Wykorzystując dodatkowo walidację krzyżową (link) przy cv = 5 otrzymalibyśmy 90 x 5 = 450 modeli.

A tak naprawdę zamiast dwóch parametrów najlepiej byłoby przetestować ich z 10. Gdyby każdy z nich przyjmował maksymalnie 10 wartości wówczas mielibyśmy 10^10 możliwości. Przy założeniu, że każdy model liczyłby się tylko 1 sekundę na wynik czekalibyśmy…317 lat 🙂

Zatem pewnie już się domyślasz jaka jest główna wada tego rozwiązania: czas wyliczenia. 

b) Metoda losowania (Random Search)

Metoda ta działa podobnie jak wcześniejsza, ale z drobną modyfikacją: zamiast brać wszystkie punkty z siatki to hiperparametry sobie losujemy.

Główną zaletą tego rozwiązania jest to, że możemy zdefiniować dokładną liczbę iteracji, którą chcemy przebadać. Jeśli dodatkowo zdefiniujemy prawidłowo przestrzeń, z której należy losować parametry, to ta metoda jest bardzo prosta w implementacji i da nam fajne wyniki. Wyszukiwanie losowe jest w rzeczywistości bardziej wydajne niż wyszukiwanie metodą siatki.

Natomiast wadą tej metody jest to, że jest jednolita. Mam tutaj na myśli, że nie wykorzystujemy informacji z wcześniejszych wyników do wybierania następnych wartości wejściowych do wypróbowania.

Np. jeśli w naszym przypadku określimy przestrzeń, aby głębokość drzew losować z przedziału od 1 do 9, a dla drzew do głębokości 6 wyniki są mizerne, to jest to bez znaczenia. Ewentualnie możemy zmodyfikować sami ekspercko przestrzeń, z której losować hiperparametry i ją zawęzić w kolejnych iteracjach. Ten problem rozwiązuje kolejna najbardziej zaawansowana metoda.

c) Metoda optymalizacji baysowskiej (Baysian optimalization)

Optymalizacja bayesowska jest podejściem opartym na modelu probabilistycznym w celu znalezienia minimum funkcji, która zwraca metrykę wartości rzeczywistej. Może to być prosta funkcja lub może być tak złożona jak np. metryka, której używamy w naszym modelu 🙂

Moim zdaniem jest to aktualnie najlepsza metoda, której można użyć do szukania hiperparametów.

Rozumowanie bayesowskie polega na aktualizacji modelu opartego na nowych „dowodach” (czyli nowych przeliczeniach modelu dla nowych parametrów). A przy każdych nowo pozyskanych danych jest ponownie obliczany w celu uwzględnienia najnowszych informacji. Im dłużej algorytm działa, tym bliżej funkcja zastępcza przypomina rzeczywistą funkcję celu. W tle wykorzystane są matematyczne estymatory drzewa Parzen’a (TPE – Tree of Parzen Estimators).

Ale jak to napisać w Python?

To teraz spróbujmy w praktyce dobrać hiperparametry. Na samym początku wczytajmy biblioteki i dane. Dodajmy funkcje do przygotowania podziału na zbiory do nauki i testowe tak jak w poprzednim poście.

1) Wczytanie bibliotek

import numpy as np
import pandas as pd
import seaborn as sns
import xgboost as xgb 

import warnings
import sklearn

from sklearn.metrics import roc_auc_score, accuracy_score # wczytanie metryk sukcesu
from sklearn.model_selection import train_test_split

pd.options.display.max_columns = 250
seed = 2019
warnings.simplefilter("ignore")

print('numpy ' + str(np.__version__))
print('pandas ' + str(pd.__version__))
print('seaborn ' + str(pd.__version__))
print('xgboost ' + str(pd.__version__))
print('sklearn ' + str(sklearn.__version__))

Na wszelki wypadek dodałem informacje o wersji bibliotek, które czasami z czasem się zmieniają.

2) Wczytanie danych

Dane wykorzystamy z wcześniejszego postu. Zatem jeśli ich nie masz to możesz pobrać z Kaggle z konkursu Santander (link do przykładowych danych). Są one wystarczające bez żadnej dodatkowej obróbki do naszego eksperymentu pokazującego jak można na różne sposoby dobrać hiperparametry.

df = pd.read_csv('../data_raw/train.csv')
print(df.shape)
df.head()

3) Funkcje pomocnicze

Funkcje pomocnicze, które pomogą przygotować wczytanie danych do odpowiednich zborów:

def get_feats(df): 
    feats = [f for f in df.columns if f not in ['ID_code','target']]
    return feats

def get_X(df): 
    return df[ get_feats(df) ].values

def get_y(df, target_var='target'): 
    return df[target_var].values

oraz do zwracania informacji o metrykach AUC oraz GINI:

def create_measures(y,y_pred): 
    score_test = roc_auc_score(y, y_pred)
    Gini_index = 2*score_test - 1
    
    d = {'AUC': [round(score_test,4)], 'GINI': [round(Gini_index,4)]}
    d = pd.DataFrame.from_dict(d)
    return d

def calculating_metrics(X_train, X_val, X_oot, y_train, y_val, y_oot):
    test = create_measures(y_train,model.predict_proba(X_train)[:, 1])
    val = create_measures(y_val,model.predict_proba(X_val)[:, 1])
    oot = create_measures(y_oot,model.predict_proba(X_oot)[:, 1]) 

    measures =  pd.concat([test,val,oot]).set_index([pd.Index(['TRAIN', 'VAL', 'OOT'])]) 
    
    return measures

4) Podział zbioru na treningowy i walidacyjne

Teraz dokonujemy podziału na zbiory treningowe oraz walidacyjne.

X, y = get_X(df), get_y(df) 
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.4, random_state=2019)
X_test, X_val, y_test, y_val = train_test_split(X_test, y_test, test_size=0.5, random_state=2019)

print('TRAIN:',X_train.shape, y_train.shape)
print('TEST:',X_test.shape, y_test.shape)
print('VALIDATION:',X_val.shape, y_val.shape)

5) Model na wartościach domyślnych

Sprawdźmy, jaka będzie moc metryk modelu na wartościach domyślnych.

model = xgb.XGBClassifier(tree_method='gpu_hist')
model.fit(X_train, y_train)  
measures = calculating_metrics(X_train, X_test, X_val, y_train, y_test, y_val)
measures

6) Przeszukiwanie hiperparametrów

Załóżmy dla naszego eksperymentu, że chcemy dokonać dokładnie 128 iteracji szukania najlepszych hiperparametrów. Wykorzystajmy wszystkie trzy metody, które omówiliśmy.

a) Metoda siatki (Grid Search)

Dla ułatwienia dodam, że liczbę 128 można inaczej zapisać jako 4*4*4*2 :). W pierwszym kroku musimy wczytać odpowiednią procedurę.

from sklearn.model_selection import GridSearchCV

Następnie należy zdefiniować zakres, który będziemy chcieli przeszukać dla naszego modelu. Robi się to poprzez stworzenie słownika wszystkich parametrów, które Ciebie interesują i odpowiadającego im zestawu wartości, które chcesz przetestować w celu uzyskania najlepszej wydajności.

grid_param = {  
    'n_estimators': [50, 100, 200, 500],
    'max_depth': [4, 5, 6, 7],
    'learning_rate': [0.05, 0.1, 0.25, 0.5],
    'subsample': [0.75, 1.00], 
    'tree_method': ['gpu_hist']    
}

Jak widzisz dla tego testu wzięliśmy 4 parametry po 4 wartości oraz jeden z dwoma wartościami. W sumie to będzie 128 przeliczeń. Dodatkowo na końcu zdefiniowaliśmy, aby wyliczeń dokonać na kartach graficznych w celu przyśpieszenia obliczeń.

grid_search = GridSearchCV(estimator=model,  
                     param_grid=grid_param,
                     scoring='roc_auc')

Zdefiniowaliśmy model, zakres parametrów (param_grid) i teraz szukamy wszystkich kombinacji. Wykorzystujemy funkcję, która korzysta z CV, czyli walidacji krzyżowej (Cross Validation). Tutaj możesz poczytać jak to działa. W przypadku tej metody dla tej wersji sklearn domyślnie CV wynosi 5, zatem dla każdej iteracji w sumie zostanie zbudowanych aż 5 modeli a następnie jako wynik zobaczymy średnią wartość.

grid_search.fit(X_train, y_train) 

best_parameters = grid_search.best_params_  
print(best_parameters) 

A teraz przeliczmy jeszcze model z najlepszymi parametrami:

model = xgb.XGBClassifier(**best_parameters)
model.fit(X_train, y_train)  
measures = calculating_metrics(X_train, X_test, X_val, y_train, y_test, y_val)
measures

Jak widać, moc modelu znacznie się zwiększyła. Zobaczmy jeszcze dla lepszego zrozumienia jak wyglądała moc przy każdym kolejnym przeliczeniu (na czerwono moc > 0.88 AUC):

A tutaj jeszcze histogram:

B) Metoda losowania (Random Search)

W pierwszym kroku musimy wczytać odpowiednią procedurę. Tutaj też skorzystajmy z biblioteki sklearn.

from sklearn.model_selection import RandomizedSearchCV

Wcześniej podawaliśmy dokładny zakres jaki należy przeszukać. Teraz podamy przestrzeń z jakiego będą losowane parametry. Pamiętajcie, aby dobrze dobrać zakres. Np. dla głębokości drzew (max_depth) powinny być tylko liczby całkowite.

random_param = {
    'max_depth': range(2,9),
    'learning_rate': np.logspace(np.log10(0.005), np.log10(0.5), base = 10, num = 1000),
    'n_estimators': range(100, 1000, 50),
    'gamma': np.linspace(0, 0.4, 6),
    'min_child_weight': range(1,100,5),
    'subsample': np.linspace(0.5, 1, 101),
    'colsample_bytree': np.linspace(0.6, 1, 11),
    'colsample_bylevel': np.linspace(0.6, 1, 11),
    'reg_alpha': np.linspace(0, 1),
    'reg_lambda': np.linspace(0, 1),
    'tree_method': ['gpu_hist']
}

Zatem analogicznie jak wcześniej jesteśmy gotowi do zdefiniowania przeliczenia. W tym eksperymencie sprawdźmy jaki otrzymamy wynik dla takiej samej liczby iteracji, czyli n_iter = 128. Pamiętajcie, że tutaj też wykorzystujemy CV (jak wskasuje sama nazwa RandomizedSearchCV).

model = xgb.XGBClassifier()

random_search = RandomizedSearchCV(estimator=model
                                 , param_distributions=random_param
                                 , n_iter=4*4*4*2
                                 , scoring='roc_auc' )

i szukamy parametrów:

random_search.fit(X=X_train, y=y_train)

best_parameters = random_search.best_params_  
print(best_parameters) 

Przeliczmy jeszcze model dla znalezionych najlepszych parametrów:

model = xgb.XGBClassifier(**best_parameters)
model.fit(X_train, y_train)  
measures = calculating_metrics(X_train, X_test, X_val, y_train, y_test, y_val)
measures

Sprawdźmy jeszcze jak wcześniej wyglądała moc przy każdym kolejnym przeliczeniu:

oraz histogram:

Jak widać odrobinę więcej razy trafiliśmy w wyniki z mocą > 0.88 AUC.

C) Metoda optymalizacji baysowskiej (Baysian optimalization)

Tą metodę masz zaimplementowaną w kilku różnych bibliotekach. Ja stosuję bibliotekę hyperopt, którą pokazał mi Vladimir.

from hyperopt import hp, fmin, tpe, STATUS_OK, Trials, partial

Uwaga. Jednym z najczęstszych błędów, z którymi możesz się borykać podczas pierwszego korzystania z hyperopt jest niepoprawna wersja networkx. Wystarczy obniżyć wersję do 1.11 i będzie już wszystko śmigać 🙂

pip install networkx==1.11

Tak jak wcześniej opisałem metoda polega na tym, że najpierw losujemy iteracje. Następnie na podstawie ich wyników przy wykorzystaniu estymatora Parzen’a wybierany jest parametr do kolejnego wyliczenia.

Zatem w pierwszym kroku podajemy ile chcemy mieć losowań początkowych parametrów oraz ile w sumie przeliczeń. W naszym eksperymencie mamy w sumie 128 przeliczeń. Przyjmijmy zatem, że pierwsze 48 będą losowe.

# parametr mówiący ile iteracji najpierw losujemy zanim zaczniemy optymalizować parametry
n_startup_jobs = 48 # liczba całkowita > 0

# parametr ile łącznie robimy iteracji 
max_evals = 4*4*4*2 # liczba całkowita > n_startup_jobs

BS_results = []

Definiowanie przestrzeni

Teraz analogicznie jak przy Random Search określamy przestrzeń do przeszukiwania parametrów. Dobierzcie ją wg własnych potrzeb. Jak nie wiesz jakie, to możesz zostawić takie, jakie poniżej zaproponowałem.

# Określenie zakresu do przeszukiwania dla hiperparametrów. 
# Można modyfikować dowolnie według uznania
space ={
    'learning_rate': hp.loguniform ('x_learning_rate', 0.01, 0.5),
    'max_depth': hp.quniform ('x_max_depth', 1, 9, 1),
    'n_estimators': hp.quniform ('x_n_estimators', 100, 1000, 50),
    'min_child_weight': hp.quniform ('x_min_child_weight', 0, 100, 1),
    'gamma': hp.loguniform ('x_gamma', 0.0, 2.0),
    'subsample': hp.uniform ('x_subsample', 0.5, 1.0),    
    'colsample_bytree': hp.uniform ('x_colsample_bytree', 0.5, 1.0),
    'colsample_bylevel': hp.uniform ('x_colsample_bylevel', 0.5, 1.0),
    'reg_alpha': hp.loguniform ('x_reg_alpha', 0.0, 2.0),
    'reg_lambda': hp.loguniform ('x_reg_lambda', 0.0, 2.0),
}

Powyżej widzicie specyficzny sposób definiowania parametów. Poprzez rozkład uniform możesz rozumieć równomiernie rozłożone parametry. Analogicznie rozkład norm oznacza, że wartości będą mieć rozkład symetryczny od wartości średniej, a na końcach będą najrzadsze wystąpienia. Prefiks log oznacza rozkład logarytmiczny (czyli głównie będziemy przeszukiwać mniejsze wartości). Prefiks q oznacza, że dane są dyskretne.

Poniżej kilka przykładowych opisów z dokumentacji, które najczęściej stosuję (więcej możesz poszukać bezpośrednio u źródła TUTAJ):

  • hp.uniform(label, low, high): label będzie zawierała ciąg odnoszący się do hiperparametru i zwraca wartość równomiernie między low i high,
  • hp.quniform(label, low, high, q): label będzie zawierała ciąg odnoszący się do hiperparametru i zwraca wartość równomiernie między low i high. Dla przykładu low=1, high=5, q = 2 da nam do przeszukiwania zbiór [1, 3, 5],
  • hp.loguniform (label, low, high ):  label będzie zawierała ciąg odnoszący się do hiperparametru i zwraca wartość równomiernie między low i high. Natomiast zwracana wartość będzie zgodna z exp(uniform(low, high)), dzięki czemu logarytm wartości zwracanej jest równomiernie rozłożony.

Definiowanie funkcji

Teraz zdefiniujmy metryki i funkcje, które posłużą do przeszukiwania najlepszych hiperparametów:

def objective(space):
    xgb_params = {
        # ogólne
        'learning_rate': space['learning_rate'],
        'max_depth': int(space['max_depth']),
        'n_estimators': int(space['n_estimators']),
        'min_child_weight': int(space['min_child_weight']),
        'gamma': space['gamma'],
        'seed': 2019, 
        # do walki z overfiting
        'subsample': space['subsample'],
        'colsample_bytree': space['colsample_bytree'],
        'colsample_bylevel': space['colsample_bylevel'],
        'max_delta_step': 0,
        # regularyzacja
        'reg_alpha': space['reg_alpha'],
        'reg_lambda': space['reg_lambda'],
        # pozostałe
        'metric': 'auc',  
        'eval_metric': 'auc', 
        'tree_method': 'gpu_hist'
    }

    model = xgb.XGBClassifier(**xgb_params)
    model.fit(X_train, y_train)
    y_pred = model.predict_proba(X_test)[:, 1]
    
    score = - roc_auc_score(y_test, y_pred)
    BS_results.append([roc_auc_score(y_test, y_pred), xgb_params])
    print("SCORE: {0}".format(roc_auc_score(y_test, y_pred)))
    return{'loss':score, 'status': STATUS_OK }

Tutaj chciałem byście zwrócili i zapamiętali jedną najważniejsza rzecz. Będziemy wykorzystywać fmin z biblioteki hyperopt – czyli będziemy szukać minimum funkcji. Zatem jeśli używamy metryki, która im większa tym lepiej dla naszego modelu (np. AUC / GINI) wówczas należy przemnożyć ją przez -1 jak w powyższym przykładzie. Skoro fmin szuka minimum to dzięki temu zabiegowi prawidłowo będziemy szukać najlepszych hiperparametrów dla naszej metryki.

Przeliczenie za pomocą fmin

Funkcja fmin wykona iterację dla różnych zestawów algorytmów i ich hiperparametrów i zwróci zestaw, w którym strata jest minimalna.

trials = Trials()
best_params = fmin(fn=objective,
                   space=space,
                   algo=partial(tpe.suggest, n_startup_jobs=n_startup_jobs),
                   max_evals=max_evals,
                   trials=trials)

print("The best params: ", best_params)

Przeliczmy teraz model dla najlepszego wyniku:

model = xgb.XGBClassifier(**best_parameters)
model.fit(X_train, y_train)  
measures = calculating_metrics(X_train, X_test, X_val, y_train, y_test, y_val)
measures

Niewielkia różnica między szukaniem losowym… ale większa.

Sprawdźmy jeszcze tak jak wcześniej jak wyglądała moc przy każdym kolejnym przeliczeniu:

oraz histogram:

Jak widać na wykresie na początku wygląda analogicznie jak Random Search (bo przez 64 pierwszych iteracji działał tak samo). Następnie widać, że ta metoda celuje znacznie dokładniej niż wcześniejsze (na czerwono wyniki z mocą > 0.88 AUC).

Moc przy ostatniej metodzie nie musi być znacznie lepsza. Widać przede wszystkim, że częściej trafia w wyższe wartości przy szukaniu metryki.

Kod dostępny na GitHubie

https://github.com/MamczurMiroslaw/gradient_boosting_example/blob/master/Hyperparameters_optimalization_example.ipynb

Podsumowanie

Mam nadzieję, że teraz nie będziecie mieć żadnych wątpliwości odnośnie opisanych tutaj trzech metod. Już wiecie na czym polegają i czym się różnią oraz będziecie umieli je wykorzystać w praktyce.

Powodzenia i udanych poszukiwań hiperparametrów!

6 komentarzy do “Czym są hiperparametry i jak je dobrać?”

  1. Pingback: Explainer Dashboard, czyli narzędzie do odpowiedzi jak działa model uczenia maszynowego! - Mirosław Mamczur

  2. Pingback: Od analityka danych do data scientist! (moja historia) - Mirosław Mamczur

  3. Pingback: #014 Mapa ciepła (Heatmap) - Mirosław Mamczur

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Spodobało Ci się? Udostępnij ten post w social mediach! ❤️❤️❤️