Jak zająłem 4 miejsce w konkursie Kaggle – case study

Dziś chcę się z Tobą podzielić studium przypadku z ostatniego konkursu Kaggle, w którym brałem udział.

Uważam, że przypadek ten jest ciekaw z kilku powodów:

  1. Udało mi się odpowiednio dobrać strategię walidacyjną do rozmiaru i specyfiki zbioru.
  2. Obrazuje jak ważny jest dobór odpowiedniego algorytmu do złożoności badanego zagadnienia.
  3. Potwierdza skuteczność wiedzy, jaką dzielę się z Wami na blogu. Najlepszy wynik zapewniło mi podejście analogiczne do tego, które opisywałem przed 2 miesiącami, w artykule "Kaggle, Optuna i bardzo szybki las losowy".

Krótkie zastrzeżenie: zanim przejdę dalej, chcę mieć pewność, że zostanę dobrze zrozumiany. Nie chcę zostać odebrany, jako "chwalipięta". Uważam, że dobry rezultat na Kaggle jest składową następujących czynników: szczęścia, umiejętności i doświadczenia. Tym razem nie zabrakło mi żadnego z nich i wszystko "zagrało". Stąd tak wysokie miejsce. 😉

Krótkie ogłoszenie: pomiędzy pracą, rodziną, a startem w konkursach na Kaggle, pracuję nad jeszcze jednym projektem, który jest dla mnie szczególnie ważny. Jest nim kurs "Wprowadzenie do Data Science z Python". Intensywne prace trwają od kilku miesięcy, a premierę planuję jeszcze przed wakacjami. Więcej szczegółów na jego temat znajdziesz pod tym linkiem. 🙂

Informacje o konkursie

Konkurs rozpoczął się 18 kwietnia 2023 i trwał 2 tygodnie. Łącznie wzięło w nim udział ponad 900 zespołów.

Celem konkursu była predykcja choroby na podstawie objawów. Lista chorób liczyła 11 pozycji. Mieliśmy zatem do czynienia z klasyfikacją wieloklasową.

Smaczku dodawała tu metryka, na którą zdecydowali się organizatorzy: MAP@3. Należało więc dla każdego pacjenta podać 3 najbardziej prawdopodobne choroby (kolejność ma tu znaczenie), na podstawie dostępnych objawów.

Więcej informacji o samym konkursie możesz znaleźć bezpośrednio na podstronie Kaggle.

Informacje o zbiorze danych + pierwsze wnioski

Jak już wspomniałem, zmienna celu liczyła 11 unikalnych klas chorób. Nie byłoby w tym nic dziwnego, gdyby nie fakt, że zbiór uczący liczył zaledwie 707 obserwacji. Już na tej podstawie odrzuciłem znaczną część algorytmów i podejść do walidacji.

Jeśli spojrzymy na ten problem, jak na 11 niezależnych problemów klasyfikacji dwuklasowej, to zbiór ten średnio zawierał ok. 64 obserwacje z informacjami o pacjencie chorym (jedynki) i ok. 643 obserwacje z informacjami o pacjentach "zdrowych" (zera).

Jeśli chodzi o zmienne objaśniające, to wszystkie 64 zmienne były zmiennymi binarnymi. Nie wymagały więc znacznej obróbki.

Pierwsze spostrzeżenia, jakie miałem na starcie konkursu były mniej więcej takie:

  1. Zbiór jest bardzo mały jak na tę kategorię problemu.
  2. Warto zachować dużą ostrożność podczas testowania hipotez (dotyczących m.in. użytego algorytmu predykcyjnego, wybranych zmiennych, czy zestawu hiperparametrów). Wielopoziomowa zmienna celu i niewielka liczba obserwacji to duża wariancja w nieumiejętnie przeprowadzonej walidacji. Łatwo zatem zostać zwiedzonym przez przypadek wynikający z "niefortunnego" podziału zbioru.
  3. Niewielki rozmiar zbioru może przełożyć się na tempo konkursu. Mały zbiór to szybkie uczenie, co z kolei umożliwia uczestnikom konkursu testowanie znacznej liczby hipotez i budowę dużej liczby modeli.
  4. Duża liczba prognoz i spoglądanie na listę publicznych wyników może wywołać coś na wzór owczego pędu znanego z rynków finansowych. Ludzie zaczną masowo przeuczać swoje modele i gonić wirtualnego zająca.
  5. Lista publicznych wyników nie będzie miarodajna. Nie należy kierować się miejscem zajmowanym w konkursie, aż do ogłoszenia finalnym rezultatów. Jednocześnie warto badać korelację wyników pomiędzy wynikiem swojego modelu w CV, a jego publicznym wynikiem.

Overfitting, czyli nadmierne dopasowanie do zbioru uczącego

Spośród wymienionych w poprzednim akapicie punktów, kluczowy był numer 2. Można go rozbić na dwa podpunkty:

  1. Nieodpowiednia strategia walidacyjna (np. zbyt mała liczba podziałów w CV, pominięcie stratyfikacji, źle wykonane podziały) zwiększą wariancję wyników i tym samym wpływ losowości na finalny rezultat.
  2. Nieodpowiedni algorytm uczenia maszynowego (np. złożony Boosting, wielopoziomowy stacking), który będzie niewspółmiernie mocny do złożoności badanego zagadnienia i prócz sygnału zawartego w danych, uwzględni również szum.

Oczywiście możliwa była interakcja pomiędzy powyższymi, prowadząca do małej katastrofy. 😉 Sprowadziłoby się to do nadmiernego dopasowania algorytmu do zbioru uczącego. Algorytm zacząłby rozpoznawać wzorce, które nie opisują badanego zjawiska. Innymi słowy: algorytm nauczyłby się szumu lub błędu, jakim obarczony jest dany zbiór, bądź jego część. Fajnie opisuje to poniższa grafika. 🙂

Przebieg konkursu

Przez większość konkursu znajdowałem się w okolicy 10-20 percentyla w tabeli wyników (na tablicy wyników publicznych ok. 10-20% zespołów było wyżej ode mnie).

Po ok. 3-4 dniach od startu konkursu, przestałem brać w nim aktywny udział. Jedynie obserwowałem, jak inny śrubują swoje modele i podglądałem spostrzeżenia, jakimi się dzielą. Chciałem mieć pewność, że nic istotnego mi nie umknęło.

Wynikało to z pierwszych spostrzeżeń (poprzednia sekcja) i obranej strategii, którą można streścić w sposób następujący:

  1. Ufaj swojej strategii walidacyjnej.
  2. Nie patrz na miejsce w publicznej tabeli wyników.
  3. Nie ufaj algorytmom niewspółmiernie mocnym do wielkości i złożoności zagadnienia.

Nie chcę wchodzić w szczegóły, ale zwłaszcza 3 punkt w mojej ocenie miał duże znaczenie. Podczas konkursu widziałem rozwiązania bazujące na głębokich sieciach neuronowych, które na tak małym zbiorze uczyły się kilkadziesiąt minut!!! Dla przypomnienia dodam, że zbiór składał się z 707 obserwacji i 64 zmiennych binarnych. 😉

Mój końcowy rezultat na publicznej części zbioru był następujący:

  • Mój wynik: MAP@3 = 0.38079
  • Najlepszy wynik: MAP@3 = 0.45474
  • Miejsce w rankingu: 411

Mój finalny rezultat na prywatnej części zbioru:

  • Mój wynik: MAP@3 = 0.51864
  • Najlepszy wynik: MAP@3 = 0.53179
  • Miejsce w rankingu: 4

Zbiór publiczny vs zbiór prywatny - o co chodzi? Jeśli nie miałeś drogi czytelniku wcześniej styczności z Kaggle, to spieszę z wyjaśnieniem:

  • Zbiór publiczny - jest częścią zbioru testowego (do którego zmiennej celu nie ma wglądu żaden z uczestników), dla którego predykcje można zweryfikować z użyciem mechanizmu Kaggle, przez cały czas trwania konkursu. Służy m.in. do pośredniej weryfikacji budowanego modelu.
  • Zbiór prywatny - jest częścią zbioru testowego, dla którego weryfikacja predykcji poszczególnych modeli, następuje dopiero po zakończeniu konkursu. To na nim wyznaczana jest finalna wartość statystyki podsumowującej jakość predykcji (np. AUC lub MAP@3). Podsumowując: od predykcji na tym zbiorze zależy, kto konkurs wygrał, a kto przegrał.

Omówienie procesu modelowania + kod Python

Zanim przejdziemy do kodu, chciałbym zaznaczyć jedną bardzo ważną rzecz: poniższy kod nie oddaje całości wysiłku włożonego w konkurs. Zanim przeszedłem do modelowania, wykonałem, m.in.:

  • *adversarial check*, by wybrać odpowiednią strategię walidacyjną i przygotować zbiór,
  • eksploracyjną analizę danych, by poznać zależności pomiędzy zmiennymi i zobaczyć strukturę danych,
  • analizę jednowymiarową i wielowymiarową zbioru, by ocenić moc predykcyjną zmiennych i zrozumieć zależności,
  • szereg testów dotyczących różnych hipotez, które przyszły mi do głowy na etapie prototypowania rozwiązania.

Tak więc, o ile poniższy kod jest minimalistyczny i relatywnie prosty, to dojście do takiej postaci proste nie było i kosztowało sporo czasu. 🙂

1. Import bibliotek.
In [1]:
import numpy as np
import pandas as pd
import optuna
import warnings

from sklearn.preprocessing import OrdinalEncoder
from sklearnex.ensemble import RandomForestClassifier
from utils import mapk

warnings.filterwarnings('ignore')
2. Wczytanie i przygotowanie zbioru danych.
In [2]:
tr = pd.read_csv('data/train.csv', index_col=0)
te = pd.read_csv('data/test.csv', index_col=0)

y = tr.prognosis
X = tr.drop(columns='prognosis')

ohe = OrdinalEncoder()
y = ohe.fit_transform(tr[['prognosis']])

OrdinalEncoder pozwala sprowadzić zmienną dyskretną do postaci zmiennej numerycznej.

3. Tuning hiperparametrów.

Kilka zdań o wybranym algorytmie i strategii walidacyjnej.

Zdecydowałem się na algorytm lasu losowego z uwagi na jego szybkość i prostotę. Uważałem, że do tego problemu (klasyfikacji wieloklasowej) i niewielkiego zbioru (z dosyć dużą liczbą zmiennych binarnych) będzie idealny.

Dodatkowo niewielka liczba obserwacji sprawiała, że należało zachować dużą ostrożność podczas budowania strategii walidacyjnej. Las losowy daje możliwość skorzystania z wyników OOB (ang. out-of-bag). Wynik walidacyjny jest liczony na obserwacjach, które nie brały udziału w procesie uczenia modelu.

To podejście daje pewne korzyści w stosunku do standardowej walidacji krzyżowej (z np. 10 podziałami), zwłaszcza w przypadku małych zbiorów:

  • Obniża wariancję wynikającą z podziałów.
  • Nie wyłącza znacznej części obserwacji z procesu uczenia.
  • Jest bardzo szybkie.

Poniżej znajduje się definicja funkcji do optymalizacji parametrów lasu losowego.

In [3]:
def objective(trial):    
    n_estimators = trial.suggest_int("n_estimators", 200, 1000, log=True)
    max_depth = trial.suggest_int("max_depth", 5, 20, log=True)
    max_features = trial.suggest_float('max_features', 0.1, 1.0)
    min_samples_split = trial.suggest_int('min_samples_split', 2, 50)
    min_samples_leaf = trial.suggest_int('min_samples_leaf', 1, 25)
    max_samples = trial.suggest_float('max_samples', 0.2, 0.99)
    model = RandomForestClassifier(
        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,
        verbose=0,
        oob_score=True,
        random_state=42,
        n_jobs=3
    )
    model.fit(X, y)
    pred_tr = model.oob_decision_function_
    sorted_prediction_ids = np.argsort(-pred_tr, axis=1)
    top_3_prediction_ids = sorted_prediction_ids[:,:3]
    score = mapk(y.reshape(-1, 1), top_3_prediction_ids, k=3)    
    return score

W tym miejscu niestety nie ustawiłem seed-a, dlatego nie jestem w stanie w 100% odwzorować uzyskanego rezultatu. 🙁 Nie mniej jednak powinien być on zbliżony, biorąc pod uwagę liczbę prób (n_trials=100). 🙂

In [4]:
optuna.logging.set_verbosity(optuna.logging.WARNING)
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=100)

By zapewnić odtwarzalność wyników, wystarczyłoby zamienić powyższe 3 linie z następującymi:

optuna.logging.set_verbosity(optuna.logging.WARNING)
sampler = optuna.samplers.TPESampler(seed=42)
study = optuna.create_study(direction="maximize", sampler=sampler)
study.optimize(objective, n_trials=100)
4. Budowa modelu.
In [5]:
model = RandomForestClassifier(**study.best_params)
model.fit(X, y)
pred_te = model.predict_proba(te)
5. Przygotowanie i eksport pliku z predykcjami.
In [6]:
te_sorted_prediction_ids = np.argsort(-pred_te, axis=1)
te_top_3_prediction_ids = te_sorted_prediction_ids[:,:3]

original_shape = te_top_3_prediction_ids.shape
te_top_3_prediction = ohe.inverse_transform(te_top_3_prediction_ids.reshape(-1, 1))
te_top_3_prediction = te_top_3_prediction.reshape(original_shape)

te['prognosis'] = np.apply_along_axis(lambda x: np.array(' '.join(x), dtype="object"), 1, te_top_3_prediction)
te['prognosis'].reset_index().to_csv('data/te_pred.csv', index=False)

Podsumowanie i kluczowe wnioski z konkursu

Wszystkie konkursy, w których biorę udział, staram się w krótki sposób podsumować. Zazwyczaj w notesie spisuję co "zagrało", a co zrobiłbym inaczej. Dodaję do tego ciekawe pomysły innych uczestników konkursu, które okazały się trafne. Wierzę, że takie podejście pomaga w rozwoju.

Ten konkurs podsumowałbym następującymi punktami:

  1. Nie zawsze sieć neuronowa i XGBOOST dadzą lepsze rezultaty niż prostsze algorytmy (jak w np. w tym wypadku las losowy).
  2. Czas poświęcony na EDA, zrozumienie problemu i ocenę jego złożoności nie jest czasem straconym. Pominięcie eksploracyjnej analizy danych i przejście prosto do modelowania zazwyczaj jest błędnym podejściem.
  3. Strategia walidacyjna powinna być starannie zaplanowana i wynikać z twardych przesłanek odkrytych na etapie EDA.

Dzięki za dobrnięcie do końca. Mam nadzieję, że ten wpis przypadł Ci do gustu. Jeśli masz jakieś spostrzeżenia lub pytania, to podziel się nimi w komentarzu poniżej. 🙂

Źródła:

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 🙂

6 Komentarze

  1. Gratulacje wyniku i podziękowania, że zechciał się Pan podzielić wnioskami! Tak z ciekawości – czy model trenowany był na surowych danych czy jednak konieczny był jakiś feature engineering czy też selekcja zmiennych? Jakie inne algorytmy brał Pan poważnie pod uwagę?

    • Panie Pawle, dziękuję bardzo za miłe słowo i za ciekawe pytania! 😉

      Czy model trenowany był na surowych danych czy jednak konieczny był jakiś feature engineering czy też selekcja zmiennych?

      W finalnym modelu lasu losowego dane były w postaci surowej. Nie robiłem żadnej selekcji. Zrzuciłem wszystko na algorytm RF.

      Jakie inne algorytmy brał Pan poważnie pod uwagę?

      Sporo czasu zainwestowałem w regresję logistyczną i podejście „one vs rest”. Dla 4, czy 5 przykładowych kategorii zmiennej celu sprowadziłem problem klasyfikacji wieloklasowej do postaci klasyfikacji dwuklasowej i zastosowałem:

      1. poszukiwanie interakcji (z użyciem biblioteki RuleFit),
      2. konwersja do WoE,
      3. selekcja zmiennych (korelacyjna i krokowa z użyciem 90% przedziałów ufności i powtarzaną CV ze stratyfikacją).

      Powyższe podejście dla jednej kategorii wypadło lepiej niż las losowy, ale dla pozostałych znacznie gorzej, więc porzuciłem ten pomysł.

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*