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ł.

Skomentuj Michał D Anuluj pisanie odpowiedzi

Twój adres email nie zostanie opublikowany.


*