Kaggle, Optuna i bardzo szybki las losowy

W ramach konkursu: testuję las losowy od Intel-a, optymalizuję go z użyciem Optuny i zamieniam CV na OOB. 😉


  1. Wstęp
  2. Założenia dotyczące projektu
  3. Cele projektu
  4. Wczytanie danych i niezbędnych bibliotek
  5. Przygotowanie danych do analizy
  6. Wybranie strategii walidacyjnej
  7. Podejście numer 1
  8. Podejście numer 2
  9. Podejście numer 3
  10. Weryfikacja wyniku na Kaggle
  11. Podsumowanie

Wstęp

Kontekst tej analizy jest następujący (za źródłem):

Zostajesz zatrudniony przez firmę Gem Stones co ltd, która jest producentem cyrkonu. Otrzymasz zestaw danych zawierający ceny i inne atrybuty cyrkonii sześciennych. [...] Musisz pomóc firmie w przewidywaniu ceny kamienia na podstawie szczegółów podanych w zbiorze danych, aby mogła rozróżnić kamienie o różnej wartości, co wpłynie na zyskach firmy.

Co istotne, nie będę korzystać z oryginalnego zbioru. Posłużę się jego kopią, która została wygenerowana przez głęboką sieć neuronową na potrzeby tego konkursu na Kaggle.

Założenia dotyczące projektu

Przed przystąpieniem do prac nam zbiorem muszę wymienić kilka założeń, które przyjąłem:

  • Nie będę "żyłować" wyniku w konkursie. Mam skończony czas poza pracą zawodową i nie chcę nikogo ani niczego zaniedbać. Model ma być zatem "good enough". Bez szaleństw. 😉 Powiedzmy, że chciałbym być maksymalnie o 5% RMSE gorszy niż najlepszy uczestnik konkursu (na zbiorze prywatnym Kaggle).
  • Z uwagi na poprzedni punkt ważna dla mnie jest szybkość. Wszystkie "ciężkie" przetwarzania (uczenie + walidacja) powinny się zamknąć w 30 minutach.
  • Model nie musi być interpretowalny. Decyduję się więc na las losowy. Wierzę, że będzie to dobry kompromis pomiędzy szybkością, a jakością.
  • Przeprowadzę optymalizację parametrów modelu z użyciem biblioteki Optuna, która od kilku miesięcy jest narzędziem state-of-the-art w konkursach Kaggle.
  • Na potrzeby tego projektu pominę eksploracyjną analizę danych. Zbiór jest mi znany, a nie chcę pisać niepotrzebnego kodu. 😉

Podsumowując: chcę wykręcić niezły wynik (mierzyny w RMSE, nie rankingu konkursu Kaggle ;-)), bez poświęcania dużej ilości czasu.

Cele projektu

  1. Cel jakościowy: budowa modelu nie gorszego niż 5% od modelu zwycięskiego w konkursie.

  2. Cel optymalizacyjny: całość przetwarzań nie powinna trwać dłużej niż 30 minut. Im krócej trwa przetwarzanie (przy zachowaniu odpowiedniej jakości), tym lepiej.

  3. Cel edukacyjny (absolutnie najważniejszy!): zaprezentowanie Tobie drogi czytelniku kilku sztuczek, których być może nie znasz, a które to mogą Ci się przydać w Twojej pracy:

    • szybszy RandomForest z Sklearn bez utraty na jakości i funkcjonalności - Intel® Extension for Scikit-learn,
    • dobór parametrów w sposób nieco sprytniejszy niż RandomizedSearchCV/GridSearchCV - Optuna,
    • przyspieszenie procesu walidacji modelu bez utraty na jakości - RF OOB.

Wczytanie danych i niezbędnych bibliotek

In [1]:
import numpy as np
import pandas as pd
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import KFold, cross_val_score
from category_encoders import OrdinalEncoder # zmiana kodowania zmiennych kategorycznych
from dsplus.utils import read_data, split_data # moja prywatna biblioteka do uzytku domowego
import optuna # jeden z dzisiejszych bohaterów
import timeit # mierzenie czasu przetwarzań
import warnings
warnings.filterwarnings('ignore')

Ustawiam formatowanie zmiennych ciągłych w Pandas z precyzją do trzech miejsc po przecinku.

In [2]:
pd.set_option('float_format', '{:.3f}'.format)

Korzystam z wcześniej zdefiniowanych funkcji. Nie robią one nic nadzwyczajnego.

Funkcja read_data wczytuje w odpowiedni sposób zbiór uczący i testowy z plików csv.

Funkcja split_data dzieli zbiór uczący (ten, do którego posiadam zmienną celu) w proporcjach 70:30. Zbiór test to zbiór, do którego nie posiadam zmiennej celu. Muszę ją wyznaczyć i zweryfikować dokładność predykcji za pomocą mechanizmu na Kaggle.

In [3]:
train, test = read_data()
X_tr, X_va, y_tr, y_va = split_data(train, target='price')

Mając wczytany zbiór, sprawdzam jego rozmiar i podglądam, jak wyglądają poszczególne obserwacje, by sprawdzić, czy wszystko wczytało się prawidłowo.

In [4]:
print('Zbiór uczący:')
print(str(X_tr.shape[0]) + ' wierszy.')
print(str(X_tr.shape[1]) + ' kolumn.')

print('\nZbiór walidacyjny:')
print(str(X_va.shape[0]) + ' wierszy.')
print(str(X_va.shape[1]) + ' kolumn.')
Zbiór uczący:
135501 wierszy.
9 kolumn.

Zbiór walidacyjny:
58072 wierszy.
9 kolumn.
In [5]:
X_tr.head()
Out[5]:
carat cut color clarity depth table x y z
id
11504 0.410 Ideal E VVS2 60.600 56.000 4.850 4.800 2.930
95284 1.230 Very Good H VS1 59.900 59.000 6.910 7.010 4.190
184777 1.700 Premium H VS2 62.000 58.000 7.610 7.660 4.740
5419 0.330 Ideal F VVS1 61.200 56.000 4.470 4.440 2.730
45466 0.330 Very Good I SI1 62.100 58.000 4.410 4.450 2.750

Przygotowanie danych do analizy

Buduję prosty data frame z podstawowymi informacjami o zbiorze.

In [7]:
summary = pd.DataFrame(X_tr.dtypes, columns=['Dtype'])
summary['Nulls'] = pd.DataFrame(X_tr.isnull().any())
summary['Sum_of_nulls'] = pd.DataFrame(X_tr.isnull().sum())
summary['Per_of_nulls'] = round((X_tr.apply(pd.isnull).mean()*100),2)
summary.Dtype = summary.Dtype.astype(str)
print(summary)
           Dtype  Nulls  Sum_of_nulls  Per_of_nulls
carat    float64  False             0         0.000
cut       object  False             0         0.000
color     object  False             0         0.000
clarity   object  False             0         0.000
depth    float64  False             0         0.000
table    float64  False             0         0.000
x        float64  False             0         0.000
y        float64  False             0         0.000
z        float64  False             0         0.000

Nie ma braków. Są za to zmienne kategoryczne. Nalezy zmienić ich kodowanie.

In [8]:
categorical_features = X_tr.select_dtypes('object').columns
encoder = OrdinalEncoder(cols=categorical_features)
X_tr = encoder.fit_transform(X_tr, y_tr)
X_va = encoder.transform(X_va)
test = encoder.transform(test)

Wybranie strategii walidacyjnej

Krótko opisze przyjętą przeze mnie strategię walidacyjną na ten konkurs. Chcę uniknąć przeuczenia na zbiorze publicznym Kaggle. Mam odłożoną część danych na potrzeby walidacji X_va + y_va.

Chciałbym, by wyniki RMSE na zbiorze walidacyjnym i zbiorze publicznym Kaggle się zbiegły. Mógłbym zastosować walidację krzyżową z powtórzeniami, ale zajmuje ona zbyt dużo czasu.

Posłużę się wynikami OOB w lesie losowym. Czym są wyniki OOB? OOB (ang. out-of-bag), to wynik, który można użyć, korzystając z algorytmu lasu losowego. Las losowy bazuje na metodzie Bagging, która do każdego drzewa losuje obserwacje, by je nauczyć. Wynik OOB to wynik dla obserwacji, które nie były użyte w procesie uczenia danego drzewa. Można powiedzieć, że mamy coś w rodzaju out-of-sample powtórzone wielokrotnie, uzyskane relatywnie niewielkim kosztem.

Poniżej porównam czasy przetwarzania procesu poszukiwania parametrów modelu z użyciem walidacji krzyżowej i OOB.

Podejście numer 1

Opis:

  • CV - KFold.
  • RandomForestRegressor - sklearn.
In [9]:
from sklearn.ensemble import RandomForestRegressor
kf = KFold(n_splits=10)
In [10]:
def objective(trial):
    n_estimators = trial.suggest_int("n_estimators", 10, 150, log=True)
    max_depth = trial.suggest_int("max_depth", 5, 20, log=True)
    max_features = trial.suggest_float('max_features', 0.15, 1.0)
    min_samples_split = trial.suggest_int('min_samples_split', 2, 28)
    min_samples_leaf = trial.suggest_int('min_samples_leaf', 1, 14)
    max_samples = trial.suggest_float('max_samples', 0.3, 0.9)

    model = RandomForestRegressor(max_depth=max_depth,
                                  n_estimators=n_estimators,
                                  max_features=max_features,
                                  min_samples_split=min_samples_split,
                                  min_samples_leaf=min_samples_leaf,
                                  max_samples=max_samples,
                                  bootstrap=True,
                                  n_jobs=2,
                                  verbose=0,
                                  random_state=42)
    
    cvs = cross_val_score(model,
                          cv=kf,
                          X=X_tr,
                          y=y_tr,
                          scoring='neg_root_mean_squared_error')
    score = cvs.mean()
    return score
In [11]:
sampler=optuna.samplers.TPESampler(seed=42) # musimy zadbać o powstarzalność wyników pomiędzy eksperymentami
study = optuna.create_study(direction="maximize", sampler=sampler)
optuna.logging.set_verbosity(optuna.logging.WARNING)
[I 2023-02-27 10:20:44,651] A new study created in memory with name: no-name-41bb4bd1-3503-4233-863f-fadca5f409f7
In [12]:
start = timeit.default_timer()
study.optimize(objective, n_trials=30, show_progress_bar=True)
stop = timeit.default_timer()
print('Czas przetwarzania: ', stop - start)
  0%|          | 0/30 [00:00<?, ?it/s]
Czas przetwarzania:  2402.4953246000223

Nieco ponad 40 min. Znacznie dłużej niż zakładałem. Sprawdźmy jakie parametry zostały wybrane i jakość modelu.

In [13]:
study.best_params
Out[13]:
{'n_estimators': 93,
 'max_depth': 20,
 'max_features': 0.9303011129739734,
 'min_samples_split': 4,
 'min_samples_leaf': 4,
 'max_samples': 0.5605854199491204}
In [ ]:
model = RandomForestRegressor(**study.best_params)
kf = KFold(n_splits=10)
cvs = cross_val_score(model,
                      cv=kf,
                      X=X_tr,
                      y=y_tr,
                      scoring='neg_root_mean_squared_error')
model.fit(X_tr, y_tr)
pred_tr = model.predict(X_tr)
pred_va = model.predict(X_va)
In [16]:
score_tr = mean_squared_error(y_tr, pred_tr, squared=False)
score_va = mean_squared_error(y_va, pred_va, squared=False)
In [17]:
print(np.round([np.mean(-cvs), score_va], 1))
[599.2 597.8]

Wyniki:

  • 2 402 sekundy - czas poszukiwania parametrów
  • RMSE = 599.8 - podczas szukania parametrów
  • RMSE = 598.2 - podczas CV
  • RMSE = 597.8 - podczas walidacji za pomocą X_va

Jest dosyć stabilnie, ale bardzo długo - ponad 40 min i to stosując przetwarzanie równoległe. Sprawdźmy, co otrzymamy (i w jakim czasie) korzystając z metody OOB.

Podejście numer 2

Opis:

  • CV - OOB.
  • RandomForestRegressor - sklearn.
In [18]:
def objective(trial):
    n_estimators = trial.suggest_int("n_estimators", 10, 150, log=True)
    max_depth = trial.suggest_int("max_depth", 5, 20, log=True)
    max_features = trial.suggest_float('max_features', 0.15, 1.0)
    min_samples_split = trial.suggest_int('min_samples_split', 2, 28)
    min_samples_leaf = trial.suggest_int('min_samples_leaf', 1, 14)
    max_samples = trial.suggest_float('max_samples', 0.3, 0.9)

    model = RandomForestRegressor(max_depth=max_depth,
                                  n_estimators=n_estimators,
                                  max_features=max_features,
                                  min_samples_split=min_samples_split,
                                  min_samples_leaf=min_samples_leaf,
                                  max_samples=max_samples,
                                  bootstrap=True,
                                  n_jobs=2,
                                  verbose=0,
                                  oob_score=True,
                                  random_state=42)
    model.fit(X_tr, y_tr)
    pred_train = model.oob_prediction_
    score = mean_squared_error(y_tr, pred_train, squared=False)
    return score
In [19]:
sampler=optuna.samplers.TPESampler(seed=42) # musimy zadbać o powstarzalność wyników pomiędzy eksperymentami
study = optuna.create_study(direction="minimize", sampler=sampler)
optuna.logging.set_verbosity(optuna.logging.WARNING)
In [20]:
start = timeit.default_timer()
study.optimize(objective, n_trials=30, show_progress_bar=True)
stop = timeit.default_timer()
print('Czas przetwarzania: ', stop - start)
  0%|          | 0/30 [00:00<?, ?it/s]
Czas przetwarzania:  215.9985469999956
In [21]:
study.best_params
Out[21]:
{'n_estimators': 59,
 'max_depth': 15,
 'max_features': 0.8917347254258926,
 'min_samples_split': 24,
 'min_samples_leaf': 4,
 'max_samples': 0.5259673133128604}
In [22]:
model = RandomForestRegressor(**study.best_params)
kf = KFold(n_splits=10)
cvs = cross_val_score(model,
                      cv=kf,
                      X=X_tr,
                      y=y_tr,
                      scoring='neg_root_mean_squared_error')
model.fit(X_tr, y_tr)
pred_tr = model.predict(X_tr)
pred_va = model.predict(X_va)

score_tr = mean_squared_error(y_tr, pred_tr, squared=False)
score_va = mean_squared_error(y_va, pred_va, squared=False)
In [23]:
print(np.round([np.mean(-cvs), score_va], 1))
[599.9 597.1]

Wyniki:

  • 216 sekund - czas poszukiwania parametrów
  • RMSE = 597.9 - podczas szukania parametrów
  • RMSE = 599.9 - podczas CV
  • RMSE = 597.1 - podczas walidacji za pomocą X_va

Jest stabilnie, szybko (o ok. 91% krótszy czas poszukiwania parametrów) i dokładniej (na zbiorze walidacyjnym) niż z użyciem walidacji krzyżowej. 🙂

Dodajmy jeszcze jedno usprawnienie.

Podejście numer 3

Opis:

  • CV - OOB.
  • RandomForestRegressor - zoptymalizowana implementacja od Intel-a.
In [27]:
del(RandomForestRegressor)
In [28]:
from sklearnex.ensemble import RandomForestRegressor
In [30]:
def objective(trial):
    n_estimators = trial.suggest_int("n_estimators", 10, 150, log=True)
    max_depth = trial.suggest_int("max_depth", 5, 20, log=True)
    max_features = trial.suggest_float('max_features', 0.15, 1.0)
    min_samples_split = trial.suggest_int('min_samples_split', 2, 28)
    min_samples_leaf = trial.suggest_int('min_samples_leaf', 1, 14)
    max_samples = trial.suggest_float('max_samples', 0.3, 0.9)

    model = RandomForestRegressor(max_depth=max_depth,
                                  n_estimators=n_estimators,
                                  max_features=max_features,
                                  min_samples_split=min_samples_split,
                                  min_samples_leaf=min_samples_leaf,
                                  max_samples=max_samples,
                                  bootstrap=True,
                                  n_jobs=2,
                                  verbose=0,
                                  oob_score=True,
                                  random_state=42)
    model.fit(X_tr, y_tr)
    pred_train = model.oob_prediction_
    score = mean_squared_error(y_tr, pred_train, squared=False)
    return score
In [31]:
sampler=optuna.samplers.TPESampler(seed=42) # musimy zadbać o powstarzalność wyników pomiędzy eksperymentami
study = optuna.create_study(direction="minimize", sampler=sampler)
optuna.logging.set_verbosity(optuna.logging.WARNING)
In [32]:
start = timeit.default_timer()
study.optimize(objective, n_trials=30, show_progress_bar=True)
stop = timeit.default_timer()
print('Czas przetwarzania: ', stop - start)
  0%|          | 0/30 [00:00<?, ?it/s]
Czas przetwarzania:  94.29380659997696
In [33]:
study.best_params
Out[33]:
{'n_estimators': 59,
 'max_depth': 15,
 'max_features': 0.8917347254258926,
 'min_samples_split': 24,
 'min_samples_leaf': 4,
 'max_samples': 0.5259673133128604}
In [34]:
model = RandomForestRegressor(**study.best_params)
kf = KFold(n_splits=10)
cvs = cross_val_score(model,
                      cv=kf,
                      X=X_tr,
                      y=y_tr,
                      scoring='neg_root_mean_squared_error')
model.fit(X_tr, y_tr)
pred_tr = model.predict(X_tr)
pred_va = model.predict(X_va)

score_tr = mean_squared_error(y_tr, pred_tr, squared=False)
score_va = mean_squared_error(y_va, pred_va, squared=False)
In [35]:
print(np.round([np.mean(-cvs), score_va], 1))
[596.4 594.5]

Wyniki:

  • 94 sekundy - czas poszukiwania parametrów
  • RMSE = 593.6 - podczas szukania parametrów
  • RMSE = 596.4 - podczas CV
  • RMSE = 594.5 - podczas walidacji za pomocą X_va

Jest jeszcze szybciej (o ok. 56% krótszy czas poszukiwania parametrów niż w podejściu #2 i 96% krótszy niż w podejściu #1), jeszcze stabilniej i (co mnie dziwi) dokładniej niż w poprzednich podejściach. 🙂

Weryfikacja wyniku na Kaggle

In [41]:
pred_te = model.predict(test)
In [42]:
pd.DataFrame(
    {
     'id': test.index,
     'price': pred_te
    }).to_csv('data/out/submission.csv.zip',
              index=False,
              compression='zip')

Poniżej wynik uzyskany na zbiorze testowym (część "publiczna" - ok. 20% całego zbioru testowego).

Wynik uzyskany na zbiorze testowym (publicznym)

Może się wydawać, że model jest nieco przeuczony, ale finalna weryfikacja nastąpi 7 marca. Wtedy to zakończy się konkurs i zostaną opublikowane wyniki obliczone na pozostałej części zbioru testowego - tzw. części "prywatnej" (80% zbioru). Gdy to nastąpi, dodam pod wpisem stosowny komentarz.

A jak ma się uzyskany wynik do czołówki rankingu? Zobaczmy.

Top 3 uczestników w rankingu (publicznym)

Jestem ok. 31 punktów RMSE za pierwszym miejscem, co jest wynikiem o ok. 5% gorszym. Biorąc pod uwagę, że mój kod przeliczał się poniżej 2 minut, to wspomniane 5% oceniam jako bardzo dobry wynik. 🙂

Ponownie, zwracam uwagę, że jest to wynik wyznaczony na 20% zbioru testowego. Przy finalnym ogłoszeniu wyników dużo może się zmienić. 😉

Podsumowanie

Jestem zadowolony z wykonanej pracy (bez względu na finalny wynik na Kaggle Leaderboard).

  • Znacząco zoptymalizowałem kod. Podejście nr 3 było 25 razy (!!!) szybsze niż podejście nr 1 w którym nie popełniłem żadnego rażącego błędu, który mógłby skutkować znacznym spowolnieniem.
  • Optuna działa znakomicie. Jej api podoba mi się nieco bardziej niż np. scikit-opt.
  • sklearnex od Intela również działa świetnie. Szacuję, że zgodnie z benchmarkami Intela las losowy przyspiesza ok. dwukrotnie w stosunku do czystego sklearn.

Uważam, że w przypadkach, gdzie nie ma potrzeby śrubowania wyniku, podejście typu "RandomForest + OOB score" jest wystarczające i ma masę korzyści, m.in.:

  • W większości przypadków da znaczny uzysk jakościowy w stosunku do metod liniowych (oczywiście dużo zależy od zbioru),
  • Jest bardzo szybkie w porównaniu do XGB, LGBM i Catboost,
  • Szybkość przekłada się na oszczędność czasu, który możemy przeznaczyć na inne czynności, np.: weryfikacja dodatkowych hipotez, budowa zmiennych, selekcja zmiennych, etc. i dodatkowo wpłynąć na jakość modelu.
  • Prosty model = łatwiejsze i tańsze utrzymanie.

Dziękuję Ci za dobrnięcie do końca. Mam nadzieję, że ten projekt przypadł Ci do gustu. Jeśli masz jakiekolwiek uwagi, pytania, lub spostrzeżenia, to zapraszam do dyskusji w komentarzach pod wpisem. 🙂

Źródła:

photo: pixabay.com

Aktualizacja (08.03.2023)

Konkurs się zakończył. Finalny wynik powyższego modelu (na części prywatnej zbioru), to 596.51.

Jak się okazało model nie był przeuczony. Walidacja z użyciem metody OOB okazała się wiarygodna i bardzo dobrze przybliżała finalny wynik (dla przypomnienia, w CV model osiągnął 596.4, a na zbiorze walidacyjnym 594.5). 🙂

Podobał Ci się ten artykuł?

Jeśli tak, to zarejestruj się, by otrzymywać informacje o nowych wpisach. Dodatkowo w prezencie wyślę Ci bezpłatny poradnik 🙂

Bądź pierwszy, który skomentuje ten wpis!

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*