Czym jest SMOTE?
Motywacja
Muszę przyznać, że do SMOTE i innych tego typu technik zawsze podchodziłem niechętne. Ciężko mi było uwierzyć, że takie np. kopiowanie obserwacji (zmodyfikowanych lub nie) może naprawdę działać. Głównie dlatego — i to może się wydawać dość głupi powód — że agitatorzy tych technik prezentowali ich zalety w zupełnie nieodpowiedni sposób. Najczęściej chwalili się, jak to wzrósł np. recall, który przecież tylko częściowo zależy od modelu, a w dużej mierze od progu i kalibracji. Mogę mieć takie recall, jakie chcę (prawie), ustalając odpowiedni próg, dla którego traktuję daną obserwację jako należącą do pewnej kategorii.
Dalej, te tłumaczenia, że jak klasa jest rzadka, to model będzie ją kiepsko przewidywał, zwracał bardzo niskie prawdopodobieństwa itd. Przecież tak powinno to działać! Skoro jakieś zdarzenie jest rzadkie, powinno być to odzwierciedlone w prawdopodobieństwach. Jeśli a priori jest bardzo mało prawdopodobne, że zdarzenie zajdzie, to nawet jeśli wiele cech na to wskazuje, wciąż to prawdopodobieństwo powinno być niewielkie. Jeśli proporcja klas w moich danych treningowych odpowiada rzeczywistości (tzn. w przyszłych danych również jedna z klas będzie bardzo rzadka), to wcale nie chcę mieć zbalansowanego klasyfikatora. Oczywiście rozumiem, że bardziej może mi zależeć na poprawnym przewidywaniu rzadszej kategorii, ale to jest kwestia dobrania odpowiedniego progu.
A patrząc technicznie, czy las losowy lub gradient boosting nie mają zdolności adaptacji do takich niezbalansowanych danych? Czy nie wystarczy np. odpowiednio obniżyć takie hiperparametry jak min_n? (minimalna liczba obserwacji w węźle).
Czy SMOTE działa?
Oczywiście te wszystkie argumenty nie dyskredytują SMOTE, co najwyżej sposób argumentowania za jego dobrodziejstwem. Natomiast w 2022 roku wyszła praca, w której autorzy pokazują, że SMOTE nie działa, a przynajmniej w większości praktycznych zastosowań. Tzn. jeśli używamy słabych modeli, to owszem — ale rozwiązaniem jest po prostu użycie lepszego modelu. Muszę powiedzieć, że odetchnąłem, jak ją przeczytałem, bo usprawiedliwiła moją awersję.
W moim artykule pokażę, dlaczego najpopularniejszy sposób prezentacji zalet SMOTE, czyli skupianie się na zmianach recall, swoistości, precyzji itp. to nieodpowiednie podejście do sprawy. W ten sposób nie da się nic wykazać. Oczywiście zobaczymy też, jak zrobić to lepiej i spróbujemy odpowiedzieć na pytanie, czy warto używać SMOTE, czy nie.
Dodam, że to nie jest publikacja naukowa, raczej studium przypadku, dlatego z wnioskami trzeba być ostrożnym.
Dane
Analizowałem dwa przykładowe zbiory danych, których użyto w tym artykule. Celem badania było porównanie różnych metod radzenia sobie z problemem niezbalansowanych klas. Nie będę polemizował z tym artykułem, natomiast je to dla mnie przykład pracy z nieodpowiednim podejściem do ewaluacji modelu. Mimo że widzę, że jednym z autorów jest Leo Breiman,
Dane dotyczą problemu wykrywania raka piersi na podstawie zdjęć radiologicznych. Informacje ze zdjęć zostały przedstawione jako cechy ilościowe, np. powierzchnia podejrzanego obiektu (w pikselach) czy średni poziom szarości. Poniżej fragment danych.
Rows: 11,183
Columns: 7
$ X1 <dbl> 0.23001961, 0.15549112, -0.78441482, 0.54608818, -0.10298725, -0.18…
$ X2 <dbl> 5.0725783, -0.1693904, -0.4436537, 0.1314146, -0.3949941, -0.381723…
$ X3 <dbl> -0.27606055, 0.67065219, 5.67470530, -0.45638679, -0.14081588, 0.26…
$ X4 <dbl> 0.8324441, -0.8595525, -0.8595525, -0.8595525, 0.9797027, 0.7729500…
$ X5 <dbl> -0.3778657, -0.3778657, -0.3778657, -0.3778657, -0.3778657, 1.46898…
$ X6 <dbl> 0.4803223, -0.9457232, -0.9457232, -0.9457232, 1.0135658, 0.8520692…
$ Y <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
W kolumnie Y
jest informacja o raku piersi. Jest to zmienna jakościowa o dwóch kategoriach: rzadsza (oznaczona jako “1”) występuje 260 razy, co stanowi 2,3%. Mamy 6 ciągłych predyktorów.
Model
Do budowy modelu wykorzystałem las losowy (pakiet ranger
). Ustaliłem liczbę drzew na 1000, wybieram optymalne mtry
i min_n
przy pomocy optymalizacji bayesowskiej (tune_bayes()
) i 10-fold CV (powtarzane 3-krotnie). Te hiperparametry wybieram osobno dla oryginalnych danych i SMOTE, bo można się spodziewać, że optymalna wartość min_n
może być różna (dla SMOTE większa — i rzeczywiście tak jest). Jak już znajdę najlepsze hiperaparametry, liczę metryki na podstawie 10-fold CV z 20 powtórzeniami. Czyli hiperparametry wybieram przy pomocy walidacji krzyżowej z jedynie 3 powtórzeniami, ale jak już je wybiorę, do podsumowania modelu używam 20 powtórzeń — żeby wyniki były dokładne.
Ponieważ związek Y
z predyktorami jest w tych danych bardzo silny, dodatkowo rozważam sytuację, w której usuwam 3 najważniejsze zmienne. Pierwszą wersję nazywam strong, drugą weak.
Aby podsumować model, liczę pole pod krzywą ROC (roc_auc) i precision-recall (pr_auc). Dodatkowo, dla progu 0,5: czułość (sens), swoistość (spec), recall i precyzję. Oczywiście recall to ta sama miara, co czułość, ale podaję ją pod obiema nazwami, bo niektórzy są przyzwyczajeni tylko do jednej z nich. Procedurę optymalizacji hiperparametrów wykonuję dwukrotnie: maksymalizując pole pod krzywą ROC oraz precision-recall. Bo trzeba pamiętać, że najlepsze hiperparametry dla jednej z tych miar nie muszą maksymalizować drugiej.
AUC ROC, czułość i swoistość
Na poniższym wykresie podaję pole pod krzywą ROC wraz z czułością i swoistością.
Skupmy się na razie na modelu ze wszystkimi zmiennymi (strong, lewa strona). Jeśli patrzeć na AUC, nie ma znaczenia, czy zastosujemy SMOTE, czy nie. No dobrze, ale ktoś zaraz powie, poniekąd słusznie, że co mnie interesuje jakieś pole pod krzywą? (choć oczywiście można lepiej zinterpretować AUC). W praktyce muszę wybrać konkretny próg i podejmować decyzje! I chcę znajdywać przypadki, dla których zaszło zdarzenie, bo one są bardziej interesujące. I dzięki SMOTE udaje się to zrobić lepiej: czułość (sens) jest znacznie wyższa. Co prawda jest jakiś koszt tego, bo spadła swoistość (spec), ale pewnie możemy z tym żyć, bo wciąż jest bardzo wysoka.
W przypadku słabszego modelu (bez najważniejszy zmiennych) sytuacja jest w pewnym sensie taka sama, choć jeśli ktoś chciałby sprzedawać SMOTE, marketingowo wygląda to jeszcze lepiej: czułość na oryginalnych danych na dramatycznie niskim poziomie i ogromna poprawa po zastosowaniu SMOTE (trochę większym kosztem, niż poprzednio, ale wciąż niewielkim).
AUC PR, precyzja i recall
Przeanalizujmy jeszcze wyniki z perspektywy recall i precyzji.
Tym razem historię można opowiedzieć inaczej. Dla obu modeli pole pod krzywą precision-recall jest trochę mniejsze — choć znów ktoś nieuprzejmy powie, że nic go to nie obchodzi. Natomiast co do konkretnych wartości recall i precyzji, wyraźnie widać, że “coś za coś”. Dzięki SMOTE wyłapujemy więcej pozytywnych przypadków, ale stajemy się w tym coraz mniej dokładni. Dla słabego modelu ponad 80% rzekomym odkryć okazuje się być wynikiem fałszywie pozytywnym.
Czy taka prezentacja ma sens?
Szczerze mówiąc, nigdy nie rozumiałem, jak można w ten sposób prezentować wyniki i twierdzić, że jakaś metoda jest lepsza lub gorsza. Jak już pisałem we wstępie, mogę przecież otrzymać dowolne wartości czułości czy swoistości, zmieniając próg. To nie jest cecha modelu, że mamy taką a nie inną czułość! To cecha procesu decyzyjnego, którego dokonuję NA PODSTAWIE modelu. Przecież las losowy, którego użyłem, nie zwraca kategorii, ale wartości z przedziału 0-1 (które chcielibyśmy interpretować jako prawdopodobieństwo).
Żeby pokazać, jak bardzo myląca jest taka prezentacja, zmienię teraz próg (przypominam, że do tej pory stosowałem 0,5). Robię to w następujący sposób. Dla wersji strong, dzięki zastosowaniu SMOTE mamy czułość 0,8, a dla oryginalnych danych tylko 0,5. W takim razie znajdźmy taki próg, by również na oryginalnych danych otrzymać czułość 0,8. Analogicznie dla modelu weak. Poniżej wykresy dla tak zmodyfikowanych progów.
Jak MOŻNA było się spodziewać (po tym samym wyniku AUC), nie ma żadnego znaczenia, czy stosujemy SMOTE, czy nie: czułość i swoistość są takie same. A jak z precyzją?
Pole pod precision-recall dla SMOTE jest mniejsze, więc precyzja (dla tej samej wartości recall) też.
Inne dane?
Może to kwestia danych, weźmy inne:
Rows: 6,916
Columns: 22
$ Age <dbl> 0.45, 0.61, 0.16, 0.85, 0.75, 0.85, 0.85, 0.…
$ Sex <fct> 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0,…
$ on_thyroxine <fct> 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,…
$ query_on_thyroxine <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ on_antithyroid_medication <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ sick <fct> 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ pregnant <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ thyroid_surgery <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ I131_treatment <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ query_hypothyroid <fct> 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0,…
$ query_hyperthyroid <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ lithium <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ goitre <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ tumor <fct> 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ hypopituitary <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ psych <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ TSH <dbl> 61.0000, 29.0000, 29.0000, 114.0000, 49.0000…
$ T3_measured <dbl> 6.0000, 15.0000, 19.0000, 3.0000, 3.0000, 0.…
$ TT4_measured <dbl> 23.00000, 61.00000, 58.00000, 24.00000, 5.00…
$ T4U_measured <dbl> 87.00, 96.00, 103.00, 61.00, 116.00, 102.00,…
$ FTI_measured <dbl> 26.00000, 64.00000, 56.00000, 39.00000, 4.00…
$ Y <fct> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,…
Tym razem chcemy przewidzieć, czy dany pacjent ma chorobę tarczycy (zmienna Y
). Mamy 250 zdarzeń, które stanowią 3,6% wszystkich obserwacji. Predyktorów jest więcej (21), jakościowe i ilościowe — dlatego tym razem stosuję wersję SMOTE dla kategorii (odległość Gowera, funkcja step_smotenc()
). Ponowne rozpatruję dwie wersje, strong i weak, w tej drugiej zrezygnowałem z bardzo ważnej zmiennej TSH
.
W wersji strong czułość już jest wysoka, za to w weak wynosi tylko 0,19. Co trzeba zrobić, żeby ją podnieść? Zastosować SMOTE? Nie, po prostu zmienić próg:
Zmiana hiperparametrów SMOTE
Póki co chciałem jedynie pokazać, że aby sprawdzić, czy dzięki SMOTE uzyskamy lepszy model, należy to zrobić “sprawiedliwie”. I na przykład AUC jest jednym z takich sprawiedliwych podejść (nie twierdzę, że optymalnym), bo można je traktować jako podsumowanie modelu, a nie klasyfikatora, który na tym modelu bazuje. A czy można sprawiedliwie porównać modele, skupiając się na czułości? Można, ale gdy komplementarną miarę (np. swoistość) ustalimy na konkretną wartość.
I patrząc w ten sposób, w rozpatrywanych przykładach SMOTE albo nie pomógł, albo pogorszył sprawę.
Ale przecież SMOTE na swoje parametry. Jak do tej pory używałem domyślnych: oversampling do poziomu 1:1 (idealny balans) oraz 5 sąsiadów. Głównie dlatego, że — jak z większością domyślnych parametrów — właśnie takie są najczęściej używane. Ale spróbujmy znaleźć lepsze (optymalne), szczególnie że doprowadzenie do idealnego balansu dla danych, które rozważaliśmy, oznaczało stworzenie ogromnej liczby sztucznych obserwacji, a to nie brzmi dobrze.
Na poniższych wykresach widać, jak zmienia się AUC wraz ze wzrostem over_ratio (dla over_ratio = 0 mamy oryginalne dane, bez SMOTE). Dla każdego przypadku wybrano optymalną liczbę sąsiadów (testowałem 5, 10, 20, 50 i 100). Parametrów szukałem niezależnie dla obu miar.
Analizując wyniki, zwróćcie uwagę na wartości na osi Y — różnice nie są duże. Natomiast SMOTE pogarsza sprawę, jeśli interesuje nas P-R, a poprawia, jeśli wolimy patrzeć na ROC. Różnica wynosi 0,01 (0,913 vs. 0,903) dla najlepszego zestawu hiperparametrów. Dodam, że im większe over_ratio, tym średnio większa liczba sąsiadów jest optymalna. I dlatego dla over_ratio równego 1 uzyskałem tu lepszy wynik, niż na oryginalnych danych (bez SMOTE), mimo że na poprzednich wykresach tak nie było (czyli domyślna liczba sąsiadów była zbyt niska).
Zagnieżdżona walidacja krzyżowa (nested CV)
Tutaj trzeba uważać z wnioskami, bo mimo że używałem kroswalidacji, to na tych samych danych wybierałem hiperparametry i mierzyłem, na ile model jest dobry. Z tego względu SMOTE ma “łatwiej”, o czym pisałem tutaj.
Zgodnie ze sztuką, należałoby wykonać zagnieżdżoną walidację. Wydaje mi się jednak, że w naszym przypadku, ponieważ liczba hiperparametrów SMOTE nie jest duża (tzn. sprawdzałem tylko pięć różnych wartości dla liczby sąsiadów i cztery dla over_ratio), nie należy oczekiwać dużych różnic. A dochodzi pewien problem, bo wykonując dodatkową kroswalidację wewnętrzną, jeszcze bardziej redukuję liczbę obserwacji z rzadszej klasy…
Mimo wszystko wykonałem zagnieżdżona walidację i otrzymałem poniższe wyniki.
SMOTE wciąż lepszy, choć różnica jest mniejsza, niż poprzednio (0,006). Natomiast błąd standardowy jest porównywalny z różnicą między tymi metodami walidacji. Na dodatek jest problem z jego wyznaczeniem, bo zakładam tu niezależność poszczególnych AUC, a przecież wykonuję powtarzaną kroswalidację (czyli błędy standardowe powinny być trochę większe).
Dodam, że wyżej podaję tylko jeden wynik dla SMOTE, bo testuję całą strategię (tzn. SMOTE + wybór hiperparametrów) w porównaniu do nierobienia nic. Nie mogę napisać, jakie hiperparametry zostały wybrane, bo w różnych zbiorach walidacyjnych mogły być (i były) inne.
Czyli SMOTE działa?
Wniosek z artykułu, o którym wspomniałem na początku, był taki, że SMOTE nie działa, co niekoniecznie jest zbieżne z moim wynikami. Ale po pierwsze, był to ogólny wniosek na podstawie wielu danych, tzn. SMOTE średnio nie działa. Po drugie, analizowano tam niewiele danych o tak dużej dysproporcji klas, jak u mnie (tylko dla 13 zbiorów z 73 proporcja rzadzszej klasy była poniżej 3%). Po trzecie, SMOTE nie działał, ale jeśli użyjemy odpowiednio silnego modelu. I w artykule tym “odpowiednim” modelem był boosting, a nie bagging, jak u mnie. Dlatego sprawdziłem jeszcze LightGBM i przy pomocy zagnieżdżonej kroswalidacji uzyskałem poniższe wyniki.
Czyli bez SMOTE wynik jest taki sam, jak dla lasu losowego + SMOTE. Wciąż użycie SMOTE może być opłacalne, choć różnica jest już mniejsza od błędu standardowego.
I pamiętajmy, że cały czas mówimy tu o krzywej ROC. Istnieją poważne argumenty, że przy tak dużej dysproporcji klas powinno się patrzeć na pole pod krzywą P-R. A jak widzieliśmy, wtedy każde użycie SMOTE pogarszało sprawę.
To SMOTE or not to SMOTE?
Oczywiście te moje wyniki niczego nie rozstrzygają (chyba że ktoś wierzył, że takie proste porównywanie recall przed i po użyciu SMOTE jest rozsądne). Nawet jeśli SMOTE nie ma sensu, to ciężko byłoby to udowodnić, bo jego użycie lub nie to w pewnym sensie dodatkowy hiperparametr. I dla pewnych konkretnych danych może się okazać, że odpowiednio kalibrując SMOTE, uzyskamy lepszy model. Natomiast dla mnie nie jest to tak ważny hiperparametr, jak się powszechnie sądzi. Szczególnie że on kosztuje: mamy większe dane (sztucznie), więc proces budowy modelu (optymalizacja hiperparametrów) się wydłuży. Oprócz tego, jeśli chcemy zrozumieć, jak działa model, to taki oversampling wpłynie chociażby na partial dependence plots.
Wyobrażam sobie jednak, że nie zawsze rozwiązaniem będzie po prostu użycie lepszego modelu. Bo może z jakiegoś powodu nie chcę tego robić? Ten lepszy model pewnie będzie bardziej skomplikowany? Może jego budowa zajmie więcej czasu?
Oprócz tego są sytuacje, w których tego typu techniki jak SMOTE jak najbardziej znajdują zastosowanie. Dla mnie takie najbardziej naturalne jest wtedy, gdy wiemy, że proporcje klas na rzeczywistych danych będą inne, niż na treningowych. Powiedzmy, że spodziewamy się proporcji pół na pół, ale z racji dużego kosztu pozyskania jednej kategorii, mamy jej znacznie mniej. Wtedy oversampling może być nie tylko dobrą techniką na etapie dopasowania parametrów, ale też do ostatecznej walidacji.
Trzeba jednak pamiętać, że wystarczające może być prostsze podejście: wprowadzenie macierzy kosztów lub wag (case weights). Oczywiście prostsze tylko wtedy, jeśli da się zastosować.
Cały kod dostępny tutaj. Jest on dość obszerny (sprawdzałem znacznie więcej rzeczy, niż tu opisuję).