Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Supervised Learning - uczenie nadzorowane

W poprzedniej lekcji wytrenowałeś swój pierwszy model, @name, i oceniłeś go jedną linijką:

model.score(X_test, y_test)
. Wyobraź sobie, że wypisała 95%. Brzmi jak wynik wart wpisania do dziennika ekspedycji - dopóki nie policzysz, ile klatek z fotopułapki naprawdę pokazuje kłusownika. Jeśli kłusownik pojawia się na pięciu procentach nagrań, to model, który nie wykrywa nikogo i na każdą klatkę odpowiada "spokój", też ma 95% trafień. I jest zupełnie bezużyteczny, bo nie złapał ani jednego kłusownika.

To jest prawdziwy problem uczenia nadzorowanego: nie "jak wytrenować model", tylko "skąd mam wiedzieć, czy on cokolwiek umie". Ta lekcja odpowiada na dwa pytania. Pierwsze: jakiego rodzaju odpowiedzi wolno od modelu żądać. Drugie: jak zmierzyć, czy te odpowiedzi są dobre, i z czym w ogóle je porównać.

Para: pomiar i odpowiedź

Supervised Learning, czyli uczenie nadzorowane, poznałeś już z lotu ptaka. Model dostaje pary: zestaw pomiarów oraz prawidłową odpowiedź do tych pomiarów. Słowo "nadzorowane" nie odnosi się do żadnego nadzoru nad algorytmem - oznacza, że ktoś, jakiś przyrodnik z notesem, wcześniej podpisał każdy okaz. Bez tych podpisów nie ma uczenia nadzorowanego, bo model nie ma się do czego przyrównać.

Przyjęły się dwie nazwy, których będziemy używać do końca modułu.

X
to cechy (po angielsku features), czyli tabela pomiarów: jeden wiersz na jedno zwierzę, jedna kolumna na jeden mierzony parametr.
y
to etykieta (label), czyli kolumna prawidłowych odpowiedzi - dokładnie tyle wartości, ile wierszy w
X
. Duże
X
i małe
y
to konwencja z matematyki, którą scikit-learn przejął, i spotkasz ją w każdym przykładzie w sieci.

Cała różnica, wokół której kręci się ta lekcja, siedzi w kolumnie

y
. Poniżej te same cztery zwierzęta opisane tymi samymi dwoma pomiarami, ale z dwiema różnymi odpowiedziami: raz nazwą gatunku, raz przewidywaną wagą za rok.

1# Te same pomiary, dwie rozne odpowiedzi
2X = [[190, 58], [55, 110], [175, 60], [48, 115]]
3
4y_species = ["lion", "cheetah", "lion", "cheetah"]   # odpowiedz: nazwa
5y_weight_next_year = [195, 57, 178, 50]              # odpowiedz: liczba

Zwróć uwagę, że

X
się nie zmieniło - to ten sam notes terenowy w obu przypadkach. Nie zmieniła się też liczba wierszy ani sposób ich zapisu: lista list, gdzie każda wewnętrzna lista to jedno zwierzę. Zmienił się wyłącznie typ zawartości
y
. W pierwszym przypadku odpowiedzią jest jedna z kilku z góry znanych nazw, w drugim - dowolna liczba. Ten jeden szczegół decyduje o wszystkim, co dalej: o nazwie zadania, o modelu, który wybierzesz, i o metrykach, którymi go ocenisz.

Klasyfikacja - gdy odpowiedzią jest nazwa

Gdy

y
zawiera nazwy z zamkniętej listy, zadanie nazywa się klasyfikacją, a możliwe odpowiedzi to klasy. "Lew albo gepard" to klasyfikacja. "Spokój albo kłusownik" to klasyfikacja. "Zagrożony, narażony albo niezagrożony" też, tyle że z trzema klasami. Modele nie rozumieją napisów, więc klasy zapisujemy liczbami: 0, 1, 2. To tylko identyfikatory, a nie wielkości - klasa numer 2 nie jest "dwa razy większa" od klasy numer 1.

Pierwszym klasyfikatorem, którego użyjemy, jest regresja logistyczna, w kodzie

LogisticRegression
. Nazwa jest myląca i warto o tym wiedzieć od razu: mimo słowa "regresja" jest to klasyfikator, bo zwraca klasę, a nie dowolną liczbę. Model korzysta z dwóch metod.
fit(X, y)
uczy go na parach pomiar-odpowiedź, a
predict(X)
zwraca przewidywane klasy dla nowych pomiarów. W kolejnej lekcji zobaczysz, że wszystkie modele scikit-learn mówią tymi samymi czasownikami - dlatego warto poznać je teraz, na najprostszym możliwym zbiorze.

Zbierzmy więc osiem zwierząt: cztery lwy i cztery gepardy, każde opisane wagą w kilogramach i prędkością w kilometrach na godzinę.

1from sklearn.linear_model import LogisticRegression
2
3# waga w kg, predkosc w km/h
4X_train = [
5    [190, 58], [175, 60], [205, 55], [160, 62],
6    [55, 110], [48, 115], [62, 105], [50, 112],
7]
8y_train = [0, 0, 0, 0, 1, 1, 1, 1]   # 0 = lew, 1 = gepard
9
10model = LogisticRegression()
11model.fit(X_train, y_train)
12
13print(model.predict([[52, 108]]))   # [1]

Model odpowiedział

[1]
, czyli "gepard" - i słusznie, bo 52 kilogramy przy 108 km/h to sylwetka geparda. Zwróć uwagę na dwie rzeczy, które się nie zmieniły. Po pierwsze,
fit
nie zwrócił nowego modelu do podstawienia pod zmienną: zmienił obiekt
model
w miejscu, dlatego wywołujemy go jako osobną instrukcję. Po drugie,
X_train
i
y_train
pozostały nietknięte - model zapamiętał wyliczone współczynniki u siebie, a Twoje dane leżą tam, gdzie leżały. Zauważ też, że
predict
dostaje listę list, nawet dla jednego zwierzęcia, i zwraca tablicę odpowiedzi, a nie pojedynczą wartość. Zawsze przewiduje dla wielu okazów naraz, choćby ten "wiele" wynosił jeden.

Sama nazwa klasy to często za mało. Tropiciel wolałby wiedzieć, czy model jest pewny, czy zgaduje. Do tego służy

predict_proba
, które zamiast klasy zwraca prawdopodobieństwa - po jednej liczbie na każdą możliwą klasę.

1proba = model.predict_proba([[52, 108]])
2
3print(proba.shape)                    # (1, 2)
4print(round(float(proba.sum()), 2))   # 1.0

Wynik ma kształt

(1, 2)
: jeden wiersz, bo pytaliśmy o jedno zwierzę, i dwie kolumny, bo klasy są dwie. Kolumny idą w kolejności rosnących numerów klas, więc pierwsza to pewność co do lwa, a druga co do geparda. Obie sumują się do jedynki i właśnie to sprawdziliśmy drugim wydrukiem. Dla naszego geparda model jest niemal całkowicie pewny, ale nie deklaruję tu dokładnej liczby, bo zależy ona od wersji biblioteki - uruchom kod i zobacz sam. Praktyczne zastosowanie jest takie: gdy najwyższe prawdopodobieństwo nie przekracza na przykład 0,7, lepiej oznaczyć klatkę do ręcznego obejrzenia, niż udawać, że model wie. Pamiętaj przy tym, że to pewność modelu, a nie prawda o świecie - model potrafi być pewny i mylić się jednocześnie.

Regresja - gdy odpowiedzią jest liczba

Gdy

y
zawiera liczby, których nie da się wypisać na zamkniętej liście, zadanie nazywa się regresją. Liczebność stada, waga zwierzęcia, roczne opady w rezerwacie, czas do następnej migracji - to wszystko regresja. Kluczowe słowo to ciągłe: odpowiedź może wypaść w dowolnym miejscu skali, a nie tylko w kilku z góry wypisanych punktach. Waga 187,4 kg jest równie sensowną odpowiedzią jak 187,5 kg, a między nimi mieści się jeszcze nieskończenie wiele innych. W klasyfikacji nic pośredniego nie istnieje - nie ma zwierzęcia w połowie lwa, w połowie geparda.

Najprostszym modelem regresji jest

LinearRegression
, czyli regresja liniowa. Szuka ona prostej zależności "im więcej tego, tym więcej tamtego" i zapisuje ją jako linię. Obsługujesz ją tymi samymi czasownikami co klasyfikator:
fit
uczy,
predict
przewiduje. Sprawdźmy ją na pytaniu, które w Safari pada często: ile zwierząt utrzyma rezerwat o danej powierzchni?

1from sklearn.linear_model import LinearRegression
2
3X = [[100], [200], [300], [400]]   # powierzchnia rezerwatu w km2
4y = [150, 280, 420, 550]           # policzone stado
5
6model = LinearRegression()
7model.fit(X, y)
8
9prediction = model.predict([[250]])
10print(f"Przewidywana liczebnosc: {prediction[0]:.0f}")   # 350

Model odpowiedział 350, choć takiej wartości nie było w danych - policzył ją z linii dopasowanej do czterech obserwacji. To jest cała istota regresji: odpowiedź nie musi pochodzić ze zbioru treningowego. Zwróć uwagę na dwa szczegóły zapisu. Powierzchnia to pojedynczy pomiar, a mimo to każdy rezerwat jest osobną listą -

[[100], [200]]
, a nie
[100, 200]
- bo
X
zawsze jest tabelą z wierszami, nawet przy jednej kolumnie. I znów
predict
zwrócił tablicę, więc sięgamy po
prediction[0]
. Zapis
:.0f
zaokrągla wynik do pełnych osobników, bo pół zwierzęcia nie chodzi po sawannie.

To nie jest kwestia szybkości ani ilości danych

Zanim pójdziemy dalej, obalmy trzy pomysły, które krążą wokół pary klasyfikacja-regresja i brzmią sensownie, a są nieprawdziwe.

Pierwszy: "klasyfikacja jest szybsza od regresji". Nie jest - i odwrotnie też nie. Czas obliczeń zależy od wybranego modelu i rozmiaru danych, a nie od tego, czy odpowiedzią jest nazwa, czy liczba. Regresja liniowa na czterech rezerwatach, którą właśnie uruchomiłeś, policzyła się natychmiast, a klasyfikator na milionie zdjęć potrafi trenować godzinami. Podział na klasyfikację i regresję dotyczy rodzaju odpowiedzi, nie wydajności.

Drugi: "regresja wymaga więcej danych". Też nie. Zapotrzebowanie na dane wynika z liczby cech i złożoności zjawiska, a nie z typu zadania. Nasza regresja poradziła sobie z czterema wierszami, bo zależność była prosta i liniowa, a rozpoznanie gatunku na zdjęciach wymaga tysięcy przykładów, choć to klasyfikacja.

Trzeci, najgroźniejszy: "nie ma żadnej różnicy". Różnica jest fundamentalna, bo przenika wszystko dalej. Inne są modele, inne metryki, a nawet pytanie "czy odpowiedź jest poprawna" znaczy co innego. W klasyfikacji odpowiedź jest trafna albo nie, bez odcieni. W regresji nie trafisz idealnie prawie nigdy - liczy się, o ile się pomyliłeś. Zapamiętaj to zdanie, bo za chwilę zobaczysz je w metrykach: klasyfikacja przewiduje kategorie, regresja przewiduje wartości ciągłe.

Accuracy - odsetek trafień

Wróćmy do fotopułapki z początku lekcji. Do oceny modelu potrzebujesz dwóch ciągów tej samej długości:

y_true
, czyli co naprawdę było na klatkach, i
y_pred
, czyli co powiedział model. Te nazwy są w scikit-learn niepisanym standardem i będziemy się ich trzymać. Wszystkie metryki działają tak samo: dostają te dwa ciągi i zwracają jedną liczbę.

Najprostsza z nich to accuracy, czyli dokładność. Mierzy dokładnie jedno: odsetek poprawnych przewidywań, liczbę trafień podzieloną przez liczbę wszystkich prób. Liczy ją funkcja

accuracy_score
, którą sprowadzasz z modułu
sklearn.metrics
- to tam mieszkają wszystkie metryki tej biblioteki.

1from sklearn.metrics import accuracy_score
2
3# 12 klatek z fotopulapki: 0 = spokoj, 1 = klusownik
4y_true = [0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0]
5y_pred = [0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0]
6
7print(f"Accuracy: {accuracy_score(y_true, y_pred):.2%}")   # 75.00%

Dziewięć z dwunastu klatek model opisał tak samo jak przyrodnik, więc accuracy wynosi 75%. Zapis

:.2%
mnoży wynik przez sto i dokleja znak procentu, bo sama funkcja zwraca ułamek 0,75 - to formatowanie, nie inna metryka. I od razu ważne zastrzeżenie: accuracy nie mierzy niczego innego. Nie mówi, jak długo trwał trening, bo czas mierzy się stoperem, a nie metryką. Nie mówi, ile pamięci zajął model. Nie jest też błędem średniokwadratowym - ten, jak zobaczysz za chwilę, należy do rodziny metryk regresji i liczy odległość od prawdy, a nie liczbę trafień. Accuracy to jedna, konkretna rzecz: procent poprawnych przewidywań.

Precision i Recall - dwa różne pytania o ten sam model

Te 75% wyglądają przyzwoicie, ale przypomnij sobie hipotetyczny model z początku lekcji, który zawsze mówił "spokój". Na naszych dwunastu klatkach kłusownik był cztery razy, więc taki leniwy model trafiłby osiem razy na dwanaście, czyli 66,67%. Nasz model jest lepszy o niecałe dziewięć punktów - a przecież to on ma pilnować rezerwatu. Accuracy tego nie pokaże, bo miesza dwa zupełnie różne rodzaje pomyłek. Nazwijmy je po imieniu.

Model podniósł alarm, choć było spokojnie: to fałszywy alarm, po angielsku false positive. Ktoś pojedzie sprawdzić pusty kwadrat sawanny. Model milczał, choć kłusownik był: to przeoczenie, false negative, i kosztuje dużo więcej. Trafiony alarm to true positive, a poprawne milczenie to true negative.

Dwie metryki zadają o te pomyłki dwa różne pytania. Precision, czyli precyzja, pyta: ile z alarmów podniesionych przez model było prawdziwych? To ocena wiarygodności alarmu. Recall, czyli czułość, pyta: ile z prawdziwych wtargnięć model w ogóle wykrył? To ocena szczelności systemu. Liczą je

precision_score
i
recall_score
z tego samego modułu
sklearn.metrics
.

1from sklearn.metrics import precision_score, recall_score
2
3print(f"Precision: {precision_score(y_true, y_pred):.2%}")   # 66.67%
4print(f"Recall:    {recall_score(y_true, y_pred):.2%}")      # 50.00%

Policzmy to ręcznie, bo tu warto zobaczyć skąd biorą się liczby. Model podniósł alarm trzy razy, z czego dwa razy słusznie: 2 podzielone przez 3 to 66,67% precyzji. Prawdziwych wtargnięć było cztery, a model wykrył dwa: 2 przez 4, czyli 50% czułości. Połowa kłusowników przeszła niezauważona. Najważniejsze jest to, co się nie zmieniło: przewidywania są dokładnie te same co przy accuracy, dane też. Zmieniło się wyłącznie pytanie, które zadajemy - i dlatego z jednego zestawu wyników wyszły trzy różne liczby. Moja rada, @name: przy każdym klasyfikatorze licz precision i recall razem z accuracy. Sama accuracy przy rzadkim zjawisku potrafi wyglądać świetnie i nie znaczyć nic.

Precyzja i czułość ciągną w przeciwne strony. Model, który alarmuje przy każdym poruszeniu trawy, będzie miał wysoką czułość i fatalną precyzję. Model, który alarmuje tylko przy stuprocentowej pewności - odwrotnie. Żeby porównywać modele jedną liczbą, łączy się je w metrykę F1, czyli średnią harmoniczną obu.

1from sklearn.metrics import f1_score
2
3print(f"F1: {f1_score(y_true, y_pred):.2%}")   # 57.14%

Dlaczego harmoniczna, a nie zwykła? Zwykła średnia z 66,67% i 50% to 58,33%, a F1 wyszło 57,14% - odrobinę niżej, bliżej gorszej ze składowych. To celowe. Średnia harmoniczna karze nierównowagę: model z precyzją 100% i czułością 1% dostanie F1 bliskie zeru, choć zwykła średnia dałaby mu przyzwoite 50%. Dzięki temu wysokie F1 można osiągnąć tylko wtedy, gdy obie składowe są przyzwoite naraz.

Confusion matrix - cztery liczby zamiast jednej

Każda z dotychczasowych metryk streszczała cały wynik do jednej liczby, a przy okazji gubiła informację, których pomyłek było więcej. Macierz pomyłek (confusion matrix) nie streszcza niczego: pokazuje wszystkie cztery przypadki naraz. Wierszami idzie prawda, kolumnami przewidywanie modelu, a klasy ustawiane są w kolejności rosnącej, więc przy etykietach 0 i 1 najpierw "spokój", potem "kłusownik". Funkcję

confusion_matrix
sprowadzasz - jak poprzednie - z modułu
sklearn.metrics
.

1from sklearn.metrics import confusion_matrix
2
3print(confusion_matrix(y_true, y_pred))
4# [[7 1]
5#  [2 2]]

Czytaj tę tabelkę wiersz po wierszu. Górny wiersz to klatki, na których naprawdę był spokój: 7 razy model też powiedział "spokój" (poprawne milczenie), 1 raz podniósł fałszywy alarm. Dolny wiersz to klatki z kłusownikiem: 2 razy model go przeoczył, 2 razy wykrył. Przekątna z lewego górnego rogu do prawego dolnego to trafienia, wszystko poza nią to pomyłki. Z tych czterech liczb odtworzysz każdą wcześniejszą metrykę: accuracy to (7 plus 2) przez 12, precision to 2 przez (2 plus 1), recall to 2 przez (2 plus 2). Dlatego macierz pomyłek warto oglądać jako pierwszą - metryki wyliczysz z niej, ale z metryk nie odtworzysz macierzy.

Gdy klas jest więcej niż dwie, precision i recall trzeba policzyć osobno dla każdej z nich, a to robi się żmudne. Scikit-learn ma na to gotowy skrót:

classification_report
drukuje komplet metryk dla wszystkich klas w jednej tabeli. Parametr
target_names
pozwala podmienić numery klas na czytelne nazwy.

1from sklearn.metrics import classification_report
2
3print(classification_report(y_true, y_pred, target_names=["spokoj", "klusownik"]))
4# tabela: precision, recall, f1-score i support dla kazdej klasy

W wydruku zobaczysz po jednym wierszu na klasę, a w kolumnach znajome precision, recall i f1-score. Dochodzi jedna nowa kolumna: support, czyli liczba prawdziwych wystąpień danej klasy w

y_true
. To ona zdradza nierównowagę - u nas osiem klatek spokoju i tylko cztery z kłusownikiem. Nie podaję tu dokładnego wyglądu tabeli, bo szerokości kolumn zmieniają się między wersjami biblioteki. Ważniejsze jest, co z niej wyczytasz: klasa rzadka niemal zawsze ma gorsze wyniki niż częsta, a to właśnie ona zwykle jest tą, na której Ci zależy.

Metryki regresji - o ile się pomyliliśmy

W klasyfikacji odpowiedź była trafna albo nie. W regresji tak się nie da: model, który przewidział 349 osobników zamiast 350, nie jest "niepoprawny", tylko bliski. Dlatego wszystkie metryki regresji mierzą odległość przewidywań od prawdy, a nie liczbę trafień. Zostajemy przy nazwach

y_true
i
y_pred
, ale teraz kryją się pod nimi zupełnie inne dane: rzeczywiste i przewidziane liczebności stad w czterech rezerwatach.

Pierwsza metryka to MSE, czyli mean squared error, błąd średniokwadratowy. Liczy różnicę dla każdego rezerwatu, podnosi ją do kwadratu i uśrednia. Kwadrat robi dwie rzeczy: usuwa znak, żeby pomyłka w dół nie kasowała pomyłki w górę, i wyolbrzymia duże błędy. Kłopot w tym, że wynik jest w kwadratach osobników, czego nie da się sensownie zinterpretować. Dlatego zwykle liczy się jeszcze RMSE, root mean squared error - pierwiastek z MSE, który wraca do jednostki oryginalnej. Pierwiastek weźmiemy funkcją

np.sqrt
z NumPy, którą znasz z modułu ósmego.

1from sklearn.metrics import mean_squared_error
2import numpy as np
3
4y_true = [150, 280, 420, 550]   # rzeczywista liczebnosc stada
5y_pred = [140, 300, 400, 560]   # przewidywania modelu
6
7mse = mean_squared_error(y_true, y_pred)
8print(f"MSE:  {mse:.2f}")             # 250.00
9print(f"RMSE: {np.sqrt(mse):.2f}")    # 15.81

MSE wyszło 250, ale ta liczba nic Ci nie mówi, bo to "250 kwadratowych osobników". Dopiero RMSE, czyli 15,81, jest zdaniem po polsku: typowa pomyłka modelu to około szesnastu zwierząt. Zwróć uwagę na kolejność argumentów - najpierw prawda, potem przewidywania. Przy MSE akurat zamiana miejscami nie zmieni wyniku, bo różnica jest podnoszona do kwadratu, ale w innych metrykach zmieni, więc lepiej od razu wyrobić sobie nawyk:

y_true
zawsze pierwsze.

Skoro kwadrat wyolbrzymia duże błędy, czasem chcesz metryki, która tego nie robi. To MAE, mean absolute error, czyli średni błąd bezwzględny: bierze wartość bezwzględną każdej różnicy i uśrednia, bez podnoszenia do kwadratu.

1from sklearn.metrics import mean_absolute_error
2
3print(f"MAE: {mean_absolute_error(y_true, y_pred):.2f}")   # 15.00

MAE wynosi 15, czyli mniej niż RMSE równe 15,81. Ta różnica nie jest przypadkiem i warto rozumieć, skąd się bierze. Nasze cztery pomyłki to 10, 20, 20 i 10 zwierząt - MAE po prostu je uśrednia i dostaje 15. RMSE, dzięki kwadratom, przyznaje większą wagę tym dwudziestkom, więc wychodzi wyżej. Reguła jest prosta: RMSE zawsze jest większe lub równe MAE, a im bardziej się rozjeżdżają, tym bardziej nierówne są Twoje błędy. Gdy różnica jest duża, poszukaj kilku rezerwatów, na których model kompletnie się gubi.

Obie te metryki mają jedną wadę: są w osobnikach, więc nie porównasz nimi modelu liczącego stada z modelem przewidującym opady. Potrzebna jest miara bez jednostki, i taką jest , współczynnik determinacji. Liczy ją funkcja

r2_score
, a odpowiada ona na pytanie: o ile lepszy jest mój model od najprostszej możliwej strategii, czyli od wypisywania zawsze średniej z prawdziwych wartości?

1from sklearn.metrics import r2_score
2
3print(f"R2: {r2_score(y_true, y_pred):.4f}")   # 0.9889

Wynik 0,9889 czyta się tak: model wyjaśnia niecałe 99% zmienności liczebności stad, czyli jest bardzo blisko idealnego dopasowania. Jedynka oznaczałaby trafienie co do sztuki w każdym rezerwacie. I tu muszę sprostować rozpowszechnioną nieprawdę, którą znajdziesz w wielu ściągach: R² wcale nie mieści się w przedziale od zera do jedynki. Górną granicą jest 1, ale dolnej nie ma żadnej. Zero oznacza model dokładnie tak dobry jak wypisywanie średniej, a wszystko poniżej zera - model gorszy od tej średniej. Zobaczmy oba te przypadki na naszych czterech rezerwatach, bo dopiero wtedy widać, o co chodzi.

1# model, ktory zawsze mowi "srednia" (350)
2print(r2_score([150, 280, 420, 550], [350, 350, 350, 350]))   # 0.0
3
4# model, ktory odwrocil kolejnosc rezerwatow
5print(r2_score([150, 280, 420, 550], [550, 420, 280, 150]))   # -3.0

Pierwszy model nie patrzy na dane w ogóle i dostaje dokładnie 0,0 - to nie zbieg okoliczności, tylko definicja tej metryki. Drugi pomylił rezerwaty i wypada na minus trzy, bo myli się grubo bardziej niż zwykłe zgadywanie średniej. Jeżeli kiedykolwiek zobaczysz ujemne R², nie szukaj błędu w funkcji: Twój model naprawdę jest gorszy od jednej stałej liczby.

Baseline - z czym w ogóle się ścigasz

Zauważ, co właśnie zrobiło R²: samo z siebie porównało model z najgłupszą sensowną strategią. Taki punkt odniesienia nazywa się baseline. Regresja ma go wbudowanego w metrykę, ale klasyfikacja nie ma - i dlatego można spędzić tydzień na modelu z accuracy 95%, nie wiedząc, że zgadywanie daje 94%.

Baseline dla klasyfikacji trzeba policzyć samodzielnie, ale scikit-learn ma do tego gotowe narzędzie:

DummyClassifier
z modułu
sklearn.dummy
. To celowo bezmyślny model - ze strategią
most_frequent
zawsze odpowiada najczęstszą klasą ze zbioru treningowego i w ogóle nie patrzy na cechy. Właśnie dlatego musimy podać mu jakieś
X
o poprawnej liczbie wierszy, choć ich zawartość nie ma tu znaczenia.

1from sklearn.dummy import DummyClassifier
2import numpy as np
3
4y_frames = [0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0]
5X_ignored = np.zeros((len(y_frames), 1))
6
7baseline = DummyClassifier(strategy="most_frequent")
8baseline.fit(X_ignored, y_frames)
9
10print(f"Baseline: {baseline.score(X_ignored, y_frames):.2%}")   # 66.67%

Baseline trafia 66,67%, bo osiem z dwunastu klatek to spokój, a on zawsze mówi "spokój". Nasz model miał 75%. Cała przewaga tygodnia pracy to nieco ponad osiem punktów procentowych - i to jest informacja, której accuracy sama w sobie nigdy by Ci nie dała. Zwróć uwagę, że

DummyClassifier
obsługujesz identycznie jak prawdziwy klasyfikator:
fit
, potem
score
. Nie zmienił się żaden z Twoich modeli ani danych - to osobny, jednorazowy pomiar odniesienia. Moja rada: policz baseline zanim wytrenujesz cokolwiek poważnego. Jeśli baseline daje 94%, wiesz od razu, że musisz patrzeć na recall, a nie na accuracy.

Rytuał ewaluacji w czterech krokach

Wszystko, co dotąd zebraliśmy, układa się w stałą kolejność. Zapamiętaj ją, bo powtórzysz ją przy każdym modelu w tym module i w każdym projekcie później.

  1. Wytrenuj model na zbiorze treningowym. Model widzi pomiary razem z odpowiedziami i dopasowuje do nich swoje wewnętrzne liczby.
  2. Wykonaj predykcje na zbiorze testowym. Dopiero teraz pokazujesz mu dane, których nigdy nie widział. Przewidywania z danych treningowych nie mówią nic o jakości modelu - to sprawdzanie, czy uczeń pamięta odpowiedzi, które sam wcześniej dostał.
  3. Oblicz metryki. Zestawiasz
    y_true
    z
    y_pred
    i liczysz accuracy, precision i recall, a przy regresji MSE, MAE i R².
  4. Porównaj wyniki z baseline. Dopiero to zamienia gołą liczbę w ocenę.

Kolejność nie jest przypadkowa i żadnego kroku nie da się przestawić. Nie policzysz metryk przed wykonaniem predykcji, bo nie miałbyś czego porównywać z prawdą. Baseline jest na końcu, bo to on nadaje sens liczbom z kroku trzeciego - dokładnie tak, jak 75% naszego modelu zaczęło coś znaczyć dopiero obok 66,67% od

DummyClassifier
.

Cross-validation - jeden podział to loteria

W tym rytuale została jeszcze jedna słabość, i to poważna. Zbiór testowy powstaje z losowego podziału danych. Jeśli traf chciał, że wylądowały w nim same łatwe przypadki, dostaniesz zawyżoną ocenę. Jeśli same trudne - zaniżoną. Przy stu zwierzętach i podziale 80 na 20 cały werdykt opiera się na dwudziestu okazach, a wystarczy inny los i wynik potrafi skoczyć o kilkanaście punktów procentowych.

Lekarstwem jest cross-validation, czyli walidacja krzyżowa. To technika oceny modelu, która dzieli dane na wiele części treningowych i walidacyjnych. Te części nazywają się foldami. Przy pięciu foldach dane dzielone są na pięć równych kawałków, a potem procedura powtarza się pięć razy: za każdym razem inny fold pełni rolę zbioru walidacyjnego, a pozostałe cztery służą do treningu. Na końcu masz pięć ocen zamiast jednej, a każdy okaz był dokładnie raz sprawdzany i cztery razy używany do nauki.

Wyjaśnijmy od razu, czym cross-validation nie jest, bo nazwa bywa myląca. Nie jest rodzajem sieci neuronowej - sieci poznasz w lekcji o PyTorch i to zupełnie inna rzecz. Nie jest metodą czyszczenia danych - braki i duplikaty usuwałeś Pandasem w poprzednim module, i to dzieje się długo przed jakąkolwiek walidacją. Nie jest też algorytmem sortowania - walidacja krzyżowa niczego nie porządkuje, tylko wielokrotnie dzieli i wielokrotnie ocenia.

Do przećwiczenia jej potrzeba więcej niż ośmiu zwierząt, więc wygenerujemy sztuczny zbiór funkcją

make_classification
z modułu
sklearn.datasets
. Uwaga na jej parametry, bo łatwo tu o błąd:
n_features
to liczba wszystkich kolumn,
n_informative
mówi, ile z nich naprawdę niesie sygnał, a
n_redundant
ile jest kopiami przeliczonymi z innych. Te trzy muszą się zgadzać arytmetycznie, a dodatkowo liczba klas razy liczba skupień na klasę (domyślnie dwa) nie może przekroczyć dwójki podniesionej do potęgi
n_informative
. Przy trzech gatunkach i domyślnym
n_informative
równym 2 wywołanie skończy się wyjątkiem
ValueError
, dlatego podajemy wszystkie te liczby jawnie.

1from sklearn.datasets import make_classification
2
3X, y = make_classification(
4    n_samples=300,
5    n_features=4,
6    n_informative=4,
7    n_redundant=0,
8    n_classes=3,      # 0 = lew, 1 = slon, 2 = gepard
9    random_state=42,
10)
11
12print(X.shape, y.shape)   # (300, 4) (300,)

Dostaliśmy 300 zwierząt opisanych czterema pomiarami i 300 etykiet - kształty potwierdza

shape
, który znasz z NumPy. Parametr
random_state=42
ustala ziarno losowości, dzięki czemu przy każdym uruchomieniu wygenerują się te same dane. To nie kosmetyka, tylko warunek powtarzalności: bez niego każde uruchomienie skryptu dawałoby inne wyniki i nie odróżniłbyś poprawki w modelu od zwykłego szczęścia.

Teraz najważniejsza funkcja tej sekcji.

cross_val_score
bierze niewytrenowany model, dane i liczbę foldów w parametrze
cv
, po czym sama wykonuje cały cykl: dzieli, trenuje, ocenia i zwraca tablicę wyników. Sprowadzasz ją z modułu
sklearn.model_selection
- tego samego, z którego brałeś
train_test_split
w poprzedniej lekcji.

1from sklearn.model_selection import cross_val_score
2from sklearn.linear_model import LogisticRegression
3
4model = LogisticRegression(max_iter=1000)
5scores = cross_val_score(model, X, y, cv=5)
6
7print(len(scores))   # 5
8print(f"Srednia: {scores.mean():.2%} (+/- {scores.std() * 2:.2%})")

Wynikiem jest pięć liczb, po jednej na fold - i one naprawdę będą się od siebie różnić, często o kilkanaście punktów procentowych. Nie podaję ich dokładnych wartości, bo zależą od wersji biblioteki, ale sam rozrzut jest tu ważniejszy niż konkretne cyfry: to on pokazuje, jak bardzo pojedynczy podział mógł Cię oszukać. Średnia z pięciu ocen jest znacznie uczciwszym podsumowaniem niż którakolwiek z nich osobno, a odchylenie standardowe pomnożone przez dwa daje wygodny margines niepewności. Zwróć uwagę na jeszcze jedną rzecz: przekazujemy model przed treningiem i nie wywołujemy

fit
samodzielnie.
cross_val_score
trenuje pięć niezależnych kopii, a Twój obiekt
model
zostaje po wszystkim niewytrenowany - dlatego po walidacji krzyżowej i tak musisz go wytrenować na pełnych danych.

Sam sposób dzielenia też można kontrolować, ale najpierw warto wiedzieć, co dzieje się domyślnie. Gdy przy klasyfikatorze podajesz samą liczbę, jak nasze

cv=5
, scikit-learn wybiera podział warstwowy: pilnuje, żeby w każdym foldzie proporcje gatunków były takie same jak w całym zbiorze. To dobra decyzja i sama z siebie nic nie wymaga. Nie tasuje jednak wierszy przed podziałem, więc jeśli notes jest uporządkowany chronologicznie albo według kolejnych wypraw, sąsiadujące okazy trafią do tego samego foldu. Żeby to zmienić, w miejsce liczby przekazujesz obiekt dzielący. Najprostszym z nich jest
KFold
, który przyjmuje liczbę foldów, przełącznik
shuffle
oraz ziarno losowości.

1from sklearn.model_selection import KFold
2
3kfold = KFold(n_splits=5, shuffle=True, random_state=42)
4scores = cross_val_score(model, X, y, cv=kfold)
5
6print(len(scores))   # 5

Wyników nadal jest pięć, bo

n_splits=5
znaczy to samo co
cv=5
- zmienił się wyłącznie sposób wybierania foldów, teraz z losowym potasowaniem. Jest tu jednak pułapka, o której musisz wiedzieć:
KFold
dzieli na ślepo i nie pilnuje proporcji klas, więc przy klasyfikacji tracisz warstwowanie, które dostawałeś za darmo. Dlatego moja rekomendacja jest taka, @name: przy regresji sięgaj po
KFold
, a przy klasyfikacji po
StratifiedKFold
z tego samego modułu - ma dokładnie te same parametry, a przy okazji zachowuje proporcje gatunków w każdym foldzie. Widziałeś pewnie w sieci przykłady z ręczną pętlą po
kfold.split(X)
, gdzie samodzielnie wycina się indeksy treningowe i walidacyjne. Odradzam ten zapis na start, @name: robi dokładnie to samo, co jedna linijka wyżej, a daje cztery dodatkowe okazje do pomyłki. Przekaż
KFold
jako
cv
i pozwól bibliotece pilnować księgowości - ręczną pętlę pisz dopiero wtedy, gdy naprawdę potrzebujesz zrobić coś nietypowego wewnątrz każdego foldu.

Zapamiętaj z tej lekcji jedno zdanie, @name: liczba, którą model wypisuje po treningu, nic nie znaczy, dopóki nie wiesz, jakiej odpowiedzi w ogóle szukałeś i ile trafiłoby zwykłe zgadywanie - dobry tropiciel nie ufa tropom, których nie ma z czym porównać.

Przejdź do CodeWorlds