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

Feature Engineering - jak z notesu zrobić dane, które model zrozumie

Notes masz gotowy, @name. Przez pół sezonu wpisywałeś do niego wyniki liczeń przy wodopojach, potem łatałeś braki, rysowałeś wykresy i testami sprawdzałeś, które różnice są prawdziwe. Wczoraj przyszedł radiogram z centrali: zbuduj model, który przewidzi, czy gatunek jest zagrożony. Podajesz notes taki, jaki jest, wciskasz enter i dostajesz jedno zdanie:

could not convert string to float: 'Lion'

Model nie umie przeczytać słowa "Lion". Nie umie przeczytać "Savanna", nie umie przeczytać daty ani zdania z opisem tropu. Model widzi wyłącznie liczby ustawione w tabelę i nic poza tym. A kiedy już podasz mu same liczby, zaczyna się drugi problem: kolumna z liczebnością ma wartości od 85 do 5000, więc jeden wodopój z migrującym stadem przygniata wszystkie pozostałe pomiary, dokładnie tak jak przygniatał średnią w poprzedniej lekcji.

Te dwa problemy rozwiązuje ta sama robota i to ona jest tematem dzisiejszego dnia.

Cecha, czyli to, co model dostaje na wejściu

Cecha (po angielsku feature) to jedna kolumna wartości, którą podajesz modelowi. Liczebność stada to cecha. Powierzchnia terenu to cecha. Gatunek - jeszcze nie, bo to tekst, ale za chwilę zrobimy z niego trzy cechy liczbowe.

Feature engineering to tworzenie nowych cech z surowych danych na potrzeby modeli uczenia maszynowego. Bierzesz to, co przywiozłeś z terenu, i przerabiasz na kolumny, które model potrafi wczytać i z których da się czegoś nauczyć. Czasem przeliczasz starą kolumnę na inną skalę, czasem rozbijasz jedną kolumnę na kilka, a czasem - i to jest najciekawsza część - wymyślasz kolumnę, której w notesie nigdy nie było.

Zapamiętaj tę definicję dokładnie w tym brzmieniu, bo łatwo ją pomylić z trzema sąsiednimi rzeczami. Feature engineering nie polega na usuwaniu wszystkich cech z danych - to byłby absurd, bo po usunięciu wszystkich kolumn nie zostaje nic, z czego model mógłby się uczyć. Istnieje pokrewna dziedzina zwana selekcją cech, która odrzuca kolumny bezużyteczne, ale odrzuca niektóre, nigdy wszystkie, i jest osobnym etapem. Feature engineering nie jest też trenowaniem modelu - trenowanie to dopiero następny krok, ten z wywołaniem

fit
, i zaczyna się wtedy, gdy cechy są już gotowe. I wreszcie feature engineering nie jest metodą wizualizacji danych - od rysowania masz Matplotlib i Seaborn z lekcji o wykresach, a tu nie powstanie ani jeden obrazek, tylko nowe kolumny liczb.

Notes, na którym pracujemy

Zaczynamy od surowych zapisków. Potrzebne będą dwie znajome biblioteki: pandas pod skrótem

pd
, która trzyma tabele, oraz NumPy pod skrótem
np
, która liczy na tablicach. Ramkę danych buduję funkcją
pd.DataFrame
ze słownika, gdzie klucz to nazwa kolumny, a wartość to lista pomiarów. Nazywam ją krótko
df
, bo taką nazwę zobaczysz w zadaniach praktycznych. W notesie jest pięć obserwacji i sześć kolumn: gatunek, siedlisko, liczebność, powierzchnia rewiru w kilometrach kwadratowych, poziom zagrożenia w terenie oraz status ochronny zapisany słowami "Yes" i "No".

1import pandas as pd
2import numpy as np
3
4df = pd.DataFrame({
5    'species': ['Lion', 'Elephant', 'Cheetah', 'Lion', 'Elephant'],
6    'habitat': ['Savanna', 'Forest', 'Savanna', 'Forest', 'Savanna'],
7    'population': [120, 450, 85, 200, 5000],
8    'area_km2': [100, 500, 50, 200, 10000],
9    'danger': ['High', 'Low', 'High', 'Medium', 'Low'],
10    'endangered': ['Yes', 'No', 'Yes', 'No', 'No']
11})
12
13print(df)
14#     species  habitat  population  area_km2  danger endangered
15# 0      Lion  Savanna         120       100    High        Yes
16# 1  Elephant   Forest         450       500     Low         No
17# 2   Cheetah  Savanna          85        50    High        Yes
18# 3      Lion   Forest         200       200  Medium         No
19# 4  Elephant  Savanna        5000     10000     Low         No

Pięć wierszy, sześć kolumn i ani jednej cechy więcej. Cztery z tych kolumn to czysty tekst, więc dla modelu nie istnieją. Dwie pozostałe są liczbami, ale w skali, która robi więcej szkody niż pożytku. Cała reszta lekcji polega na tym, żeby z tych sześciu kolumn zrobić kilkanaście takich, które model naprawdę przyjmie.

Oryginał zostaje nietknięty

Zanim cokolwiek dopiszemy, jedna zasada obozowa: surowego notesu się nie skreśla. Jeśli nowa cecha okaże się bzdurą, chcesz móc wrócić do zapisków z terenu bez ponownego wczytywania pliku. Do zrobienia niezależnej kopii ramki służy metoda

copy
, wołana na ramce jako
df.copy()
. Zwraca ona nową tabelę z tymi samymi danymi, ale mieszkającą pod innym adresem w pamięci - dopisanie kolumny do kopii nie rusza oryginału. Kopię nazywam
features
, bo to w niej będą lądować wszystkie wyprodukowane dzisiaj cechy.

1features = df.copy()
2
3print(features.shape)
4# (5, 6)

Na razie kopia jest bliźniaczo podobna do oryginału: te same pięć wierszy i sześć kolumn, co potwierdza atrybut

shape
. Nic się nie policzyło i nic nie zmieniło - to tylko drugi egzemplarz notesu, na którym wolno bazgrać. Zapamiętaj tę nazwę
df.copy()
, bo za kilka akapitów wrócę do niej przy pewnej funkcji, którą początkujący często biorą właśnie za kopiowanie ramki.

Gdy jedna liczba przygniata wszystkie pozostałe

Spójrz jeszcze raz na kolumnę

population
: 120, 450, 85, 200 i nagle 5000. Największa wartość jest prawie pięćdziesiąt dziewięć razy większa od najmniejszej. Dla większości modeli oznacza to, że cztery pierwsze wodopoje zlewają się w jeden nierozróżnialny punkt tuż przy zerze, a cała uwaga modelu idzie na piąty. Rozkład o takim kształcie nazywamy skośnym - poznałeś go w lekcji o statystyce jako dodatnią skośność, czyli długi ogon po prawej stronie.

Lekarstwem jest logarytm. Logarytm rośnie coraz wolniej, więc ściska duże liczby mocniej niż małe i spłaszcza ten ogon. W NumPy jest gotowa funkcja

log1p
, której nazwa czyta się jako "logarytm z jeden plus x": dostaje liczbę, dodaje do niej jedynkę i dopiero z tego liczy logarytm naturalny. Ta dodana jedynka nie jest kaprysem. Zwykły
np.log
dla zera daje minus nieskończoność, a wodopój, przy którym danego dnia nie było ani jednego zwierzęcia, to najzwyklejszy wpis w notesie.
np.log1p(0)
daje spokojne zero i nic się nie psuje.

Wywołanie składa się z sześciu części ułożonych w sztywnej kolejności: najpierw skrót biblioteki

np
, potem kropka, potem nazwa funkcji
log1p
, potem nawias otwierający, potem to, co ma zostać przeliczone, czyli
df['population']
, a na końcu nawias zamykający. Kropka mówi Pythonowi "sięgnij do wnętrza NumPy", a nawiasy mówią "wykonaj to teraz". Sama nazwa
np.log1p
bez nawiasów nie policzyłaby niczego - byłaby tylko wskazaniem na funkcję. Wynik logarytmu ma kilkanaście cyfr po przecinku, więc do wydruku doklejam metodę
round
, która przycina liczby do podanej liczby miejsc po przecinku i niczego w danych nie zmienia.

1features['log_population'] = np.log1p(df['population'])
2
3print(features[['population', 'log_population']].round(2))
4#    population  log_population
5# 0         120            4.80
6# 1         450            6.11
7# 2          85            4.45
8# 3         200            5.30
9# 4        5000            8.52

Stosunek największej wartości do najmniejszej spadł z 58.8 do 1.9 - migrujące stado przestało zagłuszać resztę rezerwatu. Co ważniejsze, nie zmieniła się kolejność wierszy: najmniejszy wodopój dalej ma najmniejszą wartość, największy dalej największą, bo logarytm jest funkcją rosnącą i nigdy nie przestawia danych. Nie zmieniła się też kolumna

population
ani nic w ramce
df
- w
features
po prostu przybyła siódma kolumna obok starych sześciu.

Ten sam metr dla każdej kolumny: standaryzacja

Logarytm poprawił kształt jednej kolumny, ale nie rozwiązał drugiego kłopotu: liczebność mierzy się w setkach, powierzchnia w tysiącach, a gęstość wyjdzie w ułamkach. Model, który liczy odległości między obserwacjami, potraktuje wtedy kolumnę o większych liczbach jako ważniejszą, choć nikt tego nie zadeklarował. Trzeba sprowadzić wszystkie cechy do wspólnego metra.

Najczęstszy sposób to standaryzacja, zwana też Z-score. Od każdej wartości odejmujesz średnią kolumny, a wynik dzielisz przez odchylenie standardowe tej kolumny. Oba te pojęcia znasz z poprzedniej lekcji: średnią liczy metoda

mean
, odchylenie standardowe metoda
std
, obie wołane bezpośrednio na kolumnie. Wynik czyta się bardzo prosto: zero oznacza dokładnie średnią, jedynka oznacza "jedno odchylenie powyżej średniej", minus dwa - "dwa odchylenia poniżej".

1mean = df['population'].mean()
2std = df['population'].std()
3
4features['pop_standardized'] = (df['population'] - mean) / std
5
6print(features[['population', 'pop_standardized']].round(2))
7#    population  pop_standardized
8# 0         120             -0.49
9# 1         450             -0.34
10# 2          85             -0.51
11# 3         200             -0.45
12# 4        5000              1.78

Cztery zwyczajne wodopoje wylądowały tuż poniżej zera, a stado gnu 1.78 odchylenia powyżej średniej. Zwróć uwagę, że odejmowanie i dzielenie zadziałało na całej kolumnie naraz, bez żadnej pętli - to znane Ci z NumPy zachowanie, w którym operacja arytmetyczna rozlewa się na wszystkie elementy. Nie zmieniła się przy tym ani kolejność wierszy, ani ich liczba: standaryzacja przesuwa i skaluje oś, nie rusza danych.

Że oś naprawdę przesunęła się pod średnią, można sprawdzić na piechotę. Nowa kolumna z definicji ma średnią zero i odchylenie standardowe jeden, niezależnie od tego, w czym mierzone były oryginalne liczby.

1print(round(features['pop_standardized'].mean(), 10))
2# -0.0
3print(round(features['pop_standardized'].std(), 10))
4# 1.0

Średnia wyszła minus zero, co jest zwykłym śmieciem zmiennoprzecinkowym: prawdziwy wynik to liczba rzędu dziesięć do minus siedemnastej, a zaokrąglenie do dziesiątego miejsca zostawia po niej tylko znak minus. Odchylenie wyszło równo 1.0. Od teraz kolumna

pop_standardized
mówi tym samym językiem, co każda inna standaryzowana kolumna w tabeli, i model może je uczciwie porównywać.

Skala od zera do jedynki: normalizacja Min-Max

Standaryzacja nie daje żadnej gwarancji co do zakresu - wartości mogą wyjść i minus trzy, i plus dziesięć. Bywają sytuacje, w których potrzebujesz twardych widełek, na przykład gdy karmisz sieć neuronową albo gdy chcesz pokazać przewodnikom pasek postępu od zera do stu procent. Wtedy sięgasz po normalizację Min-Max.

Wzór ma trzy składniki i wszystkie pochodzą z tej samej kolumny: od wartości odejmujesz minimum kolumny, a wynik dzielisz przez rozstęp, czyli różnicę między maksimum a minimum. Zapisujemy to jako

(x - min) / (max - min)
. Minimum podaje metoda
min
, maksimum metoda
max
, obie wołane na kolumnie tak samo jak
mean
; odkładam je do zmiennych
lo
i
hi
, żeby wzór dało się przeczytać jednym spojrzeniem. Efekt jest zawsze ten sam i wynika wprost z arytmetyki: najmniejszy pomiar daje w liczniku zero, więc wychodzi dokładnie 0.0; największy daje licznik równy mianownikowi, więc wychodzi dokładnie 1.0; wszystko inne wypada gdzieś pomiędzy.

1lo = df['population'].min()
2hi = df['population'].max()
3
4features['pop_normalized'] = (df['population'] - lo) / (hi - lo)
5
6print(features[['population', 'pop_normalized']].round(3))
7#    population  pop_normalized
8# 0         120           0.007
9# 1         450           0.074
10# 2          85           0.000
11# 3         200           0.023
12# 4        5000           1.000

Wodopój z 85 zwierzętami dostał zero, ten z 5000 dostał jedynkę, a pozostałe trzy zmieściły się w dolnych ośmiu setnych przedziału. I to jest jednocześnie największa wada tej metody: jedna wartość odstająca zjada całą skalę, upychając resztę danych przy zerze. Standaryzacja z poprzedniej sekcji rozłożyła te same liczby wyraźniej. Dlatego polecam standaryzację jako domyślny wybór, a normalizację Min-Max wtedy, gdy zakres od zera do jedynki jest twardym wymogiem odbiorcy - i najlepiej po wcześniejszym zalogarytmowaniu skośnej kolumny. Co nie zmieniło się i tutaj: kolejność wierszy oraz oryginalna kolumna

population
, do której wciąż możesz wrócić.

Liczby, które wolą być kategoriami

Czasem dokładna liczebność w ogóle nie jest tym, co chcesz podać modelowi. Dyrekcja rezerwatu nie pyta, czy przy wodopoju było 120 czy 130 zwierząt - pyta, czy to stado małe, średnie, duże czy ogromne. Zamiana liczby na przedział nazywa się binningiem (od angielskiego bin, czyli kubełek), a w pandas robi to funkcja

pd.cut
.

pd.cut
przyjmuje trzy rzeczy. Pierwsza to kolumna do pocięcia. Druga to
bins
, czyli lista granic kubełków - podając cztery granice, dostajesz trzy przedziały, podając pięć granic, dostajesz cztery. Trzecia to
labels
, czyli lista nazw dla tych przedziałów, i musi być dokładnie o jeden krótsza od listy granic. Ostatnią granicę ustawiam na
np.inf
, czyli nieskończoność z NumPy, żeby najwyższy kubełek przyjął dowolnie wielkie stado. Przedziały są domyślnie otwarte od lewej i domknięte od prawej, więc
(0, 100]
obejmuje setkę, ale już nie zero.

1features['pop_category'] = pd.cut(
2    df['population'],
3    bins=[0, 100, 300, 1000, np.inf],
4    labels=['small', 'medium', 'large', 'huge']
5)
6
7print(features[['population', 'pop_category']])
8#    population pop_category
9# 0         120       medium
10# 1         450        large
11# 2          85        small
12# 3         200       medium
13# 4        5000         huge

Pięć liczb zamieniło się w pięć etykiet: 85 wpadło do

small
, 120 i 200 do
medium
, 450 do
large
, a stado gnu do
huge
. Teraz najważniejsze zdanie tej sekcji, czyli to, co się nie stało. Nie zniknął ani jeden wiersz - dalej jest ich pięć. Nie zniknęła ani jedna kolumna. Nie powstała żadna kopia ramki.
pd.cut
dzieli dane numeryczne na kategorie, czyli przedziały, i dokłada z nich nową kolumnę, a wszystko inne zostawia w spokoju.

Warto to podkreślić, bo angielskie słowo cut podpowiada trzy nieprawdziwe rzeczy naraz. Kopiowaniem ramki zajmuje się poznana wcześniej

df.copy()
i to jej wynik nazwaliśmy
features
. Usuwanie kolumn to
df.drop(columns=['area_km2'])
z lekcji o pandas, i po nim kolumn faktycznie ubywa. Wycinanie wierszy spełniających warunek to maska logiczna, czyli zapis w rodzaju
df[df['population'] > 100]
, który zwraca krótszą tabelę.
pd.cut
nie robi żadnej z tych trzech rzeczy - ona wyłącznie nakleja etykiety przedziałów na liczby.

Kubełki równoliczne zamiast równych

pd.cut
kroi oś liczbową w miejscach, które sam wskazujesz, więc kubełki bywają skrajnie nierówne pod względem liczby obserwacji - może się zdarzyć, że w
huge
wyląduje jeden wodopój, a w
medium
czterdzieści. Gdy zależy Ci na odwrotnym efekcie, czyli na kubełkach o zbliżonej liczności, sięgasz po
pd.qcut
. Litera "q" oznacza kwantyle, znane Ci z lekcji o statystyce. Zamiast granic podajesz parametr
q
z liczbą grup:
q=4
daje kwartyle,
q=10
decyle. Nazwy grup podajesz tak samo jak w
pd.cut
, przez
labels
.

1features['pop_quartile'] = pd.qcut(
2    df['population'],
3    q=4,
4    labels=['Q1', 'Q2', 'Q3', 'Q4']
5)
6
7print(features[['population', 'pop_quartile']])
8#    population pop_quartile
9# 0         120           Q1
10# 1         450           Q3
11# 2          85           Q1
12# 3         200           Q2
13# 4        5000           Q4

Granice policzyły się same, z samych danych, i nikt nie musiał zgadywać, gdzie kończy się "małe" stado. Pięciu obserwacji nie da się rozdzielić po równo na cztery grupy, więc

Q1
dostało dwa wodopoje, a pozostałe grupy po jednym - przy większych zbiorach ta nierówność znika. Zwróć uwagę, że 5000 wylądowało w
Q4
tak samo jak 450 w
Q3
: dla
pd.qcut
liczy się wyłącznie pozycja w kolejce, a nie odległość, więc jest to metoda odporna na wartości odstające. Kolumna
population
znów pozostała nietknięta.

Cecha, której w notesie nigdy nie było

Wszystko, co zrobiliśmy do tej pory, przerabiało istniejące kolumny. Teraz przychodzi najlepsza część feature engineeringu: wymyślanie kolumn, których nikt nie zmierzył. Popatrz na dwie liczby z notesu - liczebność i powierzchnię rewiru. Każda z osobna mówi mało: 5000 zwierząt brzmi imponująco, dopóki nie zapytasz, na jak wielkim terenie. Interesujące jest dopiero zagęszczenie, czyli liczba zwierząt na kilometr kwadratowy. Powstaje przez zwykłe dzielenie jednej kolumny przez drugą i nazywa się cechą ilorazową.

1features['density'] = df['population'] / df['area_km2']
2
3print(features[['species', 'population', 'area_km2', 'density']])
4#     species  population  area_km2  density
5# 0      Lion         120       100      1.2
6# 1  Elephant         450       500      0.9
7# 2   Cheetah          85        50      1.7
8# 3      Lion         200       200      1.0
9# 4  Elephant        5000     10000      0.5

Przeczytaj tę tabelę uważnie, bo wywraca ona wnioski do góry nogami. Największa populacja, te przygniatające wszystko 5000 zwierząt, ma najniższe zagęszczenie w całym rezerwacie: 0.5 zwierzęcia na kilometr kwadratowy. Najmniejsza populacja, 85 gepardów, ma zagęszczenie najwyższe: 1.7. Żadna z kolumn źródłowych sama z siebie tego nie mówiła - informacja siedziała w relacji między nimi i wyszła na jaw dopiero po podzieleniu. Przy okazji nowa kolumna nie ma już problemu ze skalą, bo wszystkie wartości mieszczą się między 0.5 a 1.7. Obie kolumny źródłowe zostały w ramce bez zmian i możesz ich dalej używać.

Kwadraty, pierwiastki i iloczyny

Iloraz to nie jedyny sposób lepienia nowych cech. Gdy podejrzewasz, że związek nie jest prostoliniowy - bo rewir dwa razy większy wcale nie żywi dwa razy więcej zwierząt - dokładasz cechy wielomianowe: kolumnę podniesioną do kwadratu operatorem

**
albo jej pierwiastek funkcją
np.sqrt
. Gdy z kolei podejrzewasz, że dopiero dwie rzeczy naraz mają znaczenie, tworzysz cechę interakcyjną, czyli iloczyn dwóch kolumn.

1features['area_sqrt'] = np.sqrt(df['area_km2'])
2features['pop_x_area'] = df['population'] * df['area_km2']
3
4print(features[['area_km2', 'area_sqrt', 'pop_x_area']].round(2))
5#    area_km2  area_sqrt  pop_x_area
6# 0       100      10.00       12000
7# 1       500      22.36      225000
8# 2        50       7.07        4250
9# 3       200      14.14       40000
10# 4     10000     100.00    50000000

Pierwiastek ścisnął dziesięć tysięcy kilometrów kwadratowych do setki i wyrównał skalę podobnie jak logarytm. Iloczyn poszedł w przeciwną stronę - dobił do pięćdziesięciu milionów, więc taką cechę wypada standaryzować, zanim trafi do modelu. I jedno ostrzeżenie z obozu: skoro każdą parę kolumn można przez siebie pomnożyć, podzielić i podnieść do kwadratu, z sześciu kolumn łatwo zrobić sto. Nie rób tego. Twórz te cechy, które umiesz uzasadnić słowami, jak zagęszczenie z poprzedniej sekcji - reszta to szum, w którym model gubi trop. Oryginalne kolumny, rzecz jasna, dalej leżą w ramce nienaruszone.

Model nie przeczyta słowa "Lion"

Wracamy do błędu z początku lekcji. Cztery kolumny notesu to tekst, a model przyjmuje wyłącznie liczby. Trzeba je zakodować, i najważniejszą metodą kodowania jest One-Hot Encoding.

Pomysł jest taki: każda możliwa wartość kategorii dostaje swoją własną, osobną kolumnę, a w tej kolumnie wpisujemy jedynkę, gdy dany wiersz do tej kategorii należy, i zero, gdy nie należy. Nazwa bierze się z elektroniki - w każdym wierszu dokładnie jedna kolumna jest "gorąca", czyli zapalona, a wszystkie pozostałe zgaszone. W pandas robi to funkcja

pd.get_dummies
, gdzie dummy oznacza kolumnę zastępczą. Zacznijmy od jednej kolumny, żeby zobaczyć sam mechanizm.

1print(pd.get_dummies(df['species']))
2#    Cheetah  Elephant   Lion
3# 0    False     False   True
4# 1    False      True  False
5# 2     True     False  False
6# 3    False     False   True
7# 4    False      True  False

Jedna kolumna tekstowa zamieniła się w trzy kolumny logiczne, po jednej na każdy gatunek występujący w danych. W każdym wierszu dokładnie jedna wartość to

True
- wiersz zerowy to lew, więc zapaliła się kolumna
Lion
. Pandas domyślnie zwraca tu wartości logiczne
True
i
False
, a nie jedynki i zera, ale dla modelu to ta sama informacja, bo
True
traktowane jest jak 1, a
False
jak 0. Jeśli wolisz oglądać liczby, dodajesz argument
dtype=int
.

1print(pd.get_dummies(df['habitat'], dtype=int))
2#    Forest  Savanna
3# 0       0        1
4# 1       1        0
5# 2       0        1
6# 3       1        0
7# 4       0        1

Dwa siedliska, dwie kolumny binarne, w każdym wierszu jedna jedynka. Warto tu zauważyć, że przy dwóch kategoriach druga kolumna nie wnosi nic nowego - skoro

Forest
ma zero, to
Savanna
musi mieć jeden. Dla części modeli ta nadmiarowość bywa szkodliwa, więc
pd.get_dummies
przyjmuje argument
drop_first=True
, który usuwa pierwszą kolumnę z każdego zestawu.

Cała ramka naraz

W praktyce rzadko kodujesz kolumny po jednej.

pd.get_dummies
przyjmuje całą ramkę i sama znajduje w niej wszystko, co jest tekstem. Wywołanie składa się z sześciu części w stałej kolejności: skrót
pd
, kropka, nazwa
get_dummies
, nawias otwierający, nazwa ramki
df
, nawias zamykający.

1encoded = pd.get_dummies(df)
2
3print(encoded.shape)
4# (5, 12)

Sześć kolumn zamieniło się w dwanaście, a liczba wierszy została ta sama - i to jest sedno tej operacji. Cztery kolumny tekstowe rozwinęły się na dziesięć kolumn binarnych: trzy gatunki, dwa siedliska, trzy poziomy zagrożenia i dwa statusy ochronne. Dwie kolumny liczbowe,

population
i
area_km2
, przeszły przez
pd.get_dummies
bez żadnej zmiany
- funkcja rusza wyłącznie kategorie, liczb nie dotyka.

To ostatnie zdanie warto zapamiętać, bo wokół One-Hot Encodingu krąży kilka trwałych nieporozumień. Nie służy on do normalizacji danych numerycznych - normalizacja to poznany wcześniej wzór

(x - min) / (max - min)
albo standaryzacja, obie działające na liczbach; to, że kodowanie produkuje zera i jedynki, jest przypadkowym podobieństwem, bo tych zer i jedynek nikt nie skalował, tylko zapalał. Nie służy też do usuwania wartości odstających - odstające pomiary wykrywasz regułą rozstępu międzykwartylowego z lekcji o eksploracji danych, a One-Hot nie usuwa ani jednego wiersza, tylko dokłada kolumny. I wreszcie nie służy do łączenia ramek DataFrame - od sklejania tabel są
pd.merge
i
pd.concat
z lekcji o pandas, a
pd.get_dummies
pracuje na jednej ramce i żadnej drugiej nawet nie widzi. One-Hot Encoding robi jedną rzecz: zamienia zmienne kategoryczne na kolumny binarne.

Pokusa numerowania kategorii

Istnieje krótsza droga do zakodowania gatunków: ponumerować je po kolei. Robi to

LabelEncoder
z biblioteki scikit-learn, czyli z pakietu importowanego jako
sklearn
, a dokładniej z jego podmodułu
preprocessing
, w którym mieszkają wszystkie narzędzia do przygotowania danych. Tworzysz obiekt
LabelEncoder()
, a potem wołasz na nim metodę
fit_transform
, która jednym ruchem uczy się listy kategorii i podmienia je na liczby. Zobaczmy, co z tego wychodzi, bo wynik jest pouczający.

1from sklearn.preprocessing import LabelEncoder
2
3le = LabelEncoder()
4features['species_encoded'] = le.fit_transform(df['species'])
5
6print(features[['species', 'species_encoded']])
7#     species  species_encoded
8# 0      Lion                2
9# 1  Elephant                1
10# 2   Cheetah                0
11# 3      Lion                2
12# 4  Elephant                1

Jedna kolumna zamiast trzech, więc wygląda oszczędnie. Problem w tym, że numery przydzieliły się alfabetycznie: Cheetah dostał 0, Elephant 1, Lion 2. Model, który dostanie taką kolumnę, odczyta z niej rzeczy, których nikt nie napisał - że lew jest większy od słonia, że słoń leży dokładnie w połowie drogi między gepardem a lwem i że średnia z geparda i lwa to słoń. Gatunki nie mają porządku, więc każda taka liczba jest kłamstwem wprowadzonym do danych. Dlatego dla kolumn wejściowych bez naturalnej kolejności polecam wyłącznie

pd.get_dummies
, a
LabelEncoder
zostawiam do kodowania kolumny przewidywanej, gdzie model traktuje wartości jak nazwy klas, a nie jak wielkości.

Kiedy kolejność naprawdę istnieje

Bywa jednak, że kategorie mają porządek wpisany w samą definicję. Kolumna

danger
przyjmuje wartości "Low", "Medium" i "High" - i tu wysokie zagrożenie naprawdę jest większe od niskiego. Takie kategorie nazywamy porządkowymi (ordinal), a przypisanie im rosnących liczb jest wtedy uczciwe. Robi się to słownikiem i metodą
map
, która przechodzi po kolumnie i podmienia każdą wartość na to, co znajdzie pod tym kluczem w słowniku.

1danger_map = {'Low': 0, 'Medium': 1, 'High': 2}
2features['danger_level'] = df['danger'].map(danger_map)
3
4print(features[['danger', 'danger_level']])
5#    danger  danger_level
6# 0    High             2
7# 1     Low             0
8# 2    High             2
9# 3  Medium             1
10# 4     Low             0

Kolejność liczb odzwierciedla teraz kolejność, która istnieje w rzeczywistości, więc model może uczciwie policzyć, że wzrost zagrożenia o jeden stopień coś zmienia. Uwaga na jedną pułapkę:

map
nie ostrzega przed nieznanymi wartościami. Gdyby ktoś wpisał do notesu "Extreme", którego nie ma w słowniku, w wynikowej kolumnie pojawiłby się cichy brak danych
NaN
- dlatego po każdym mapowaniu warto sprawdzić, czy nie przybyło dziur. Kolumna
danger
w postaci tekstowej pozostała w ramce nietknięta.

Tak i nie w jednej kolumnie

Zostaje ostatnia kolumna tekstowa:

endangered
ze słowami "Yes" i "No". Przy dwóch możliwych wartościach nie trzeba ani kodowania jeden z wielu, ani słownika - wystarczy porównanie. Zapis
df['endangered'] == 'Yes'
sprawdza warunek dla całej kolumny naraz i zwraca kolumnę wartości logicznych, dokładnie tę samą maskę, której używałeś do filtrowania w lekcji o pandas. Metoda
astype
zmienia typ kolumny, a
astype(int)
zamienia
True
na 1, a
False
na 0.

1features['endangered_binary'] = (df['endangered'] == 'Yes').astype(int)
2
3print(features[['endangered', 'endangered_binary']])
4#   endangered  endangered_binary
5# 0        Yes                  1
6# 1         No                  0
7# 2        Yes                  1
8# 3         No                  0
9# 4         No                  0

Nawiasy wokół porównania są konieczne, bo bez nich Python najpierw spróbowałby wywołać

astype
na napisie
'Yes'
. Wynik jest dokładnie tym, co dałoby One-Hot Encoding z
drop_first=True
na kolumnie o dwóch kategoriach - jedna kolumna zamiast dwóch, bez straty informacji. Zwróć uwagę, że oryginalna kolumna dalej stoi obok, więc w raporcie dla dyrekcji możesz pokazać czytelne "Yes", a modelowi podać 1.

Data to nie jeden fakt, tylko sześć

Kolumna z datą obserwacji jest dla modelu jeszcze gorsza niż tekst: nawet jeśli przemielisz ją na liczbę sekund, wyjdzie z tego licznik rosnący w nieskończoność, z którego nie da się wyczytać sezonu ani rytmu tygodnia. Datę trzeba rozłożyć na części. Pandas trzyma je pod przystawką

dt
, dostępną na każdej kolumnie typu data. Sięgasz po nie zapisem
kolumna.dt.nazwa
:
dt.year
to rok,
dt.month
to numer miesiąca,
dt.quarter
to kwartał,
dt.dayofweek
to dzień tygodnia liczony od zera dla poniedziałku do szóstki dla niedzieli, a
dt.dayofyear
to numer dnia w roku. Same daty wczytuję funkcją
pd.to_datetime
, która zamienia napisy na prawdziwe daty.

1dates = pd.DataFrame({
2    'observation_date': pd.to_datetime([
3        '2023-01-14', '2023-03-15', '2023-07-04', '2023-12-31'
4    ])
5})
6
7dates['year'] = dates['observation_date'].dt.year
8dates['month'] = dates['observation_date'].dt.month
9dates['day_of_week'] = dates['observation_date'].dt.dayofweek
10dates['quarter'] = dates['observation_date'].dt.quarter
11dates['day_of_year'] = dates['observation_date'].dt.dayofyear
12
13print(dates)
14#   observation_date  year  month  day_of_week  quarter  day_of_year
15# 0       2023-01-14  2023      1            5        1           14
16# 1       2023-03-15  2023      3            2        1           74
17# 2       2023-07-04  2023      7            1        3          185
18# 3       2023-12-31  2023     12            6        4          365

Z jednej kolumny zrobiło się sześć, a każda niesie inny rodzaj sygnału: rok łapie trend wieloletni, miesiąc i kwartał łapią porę suchą i deszczową, numer dnia tygodnia łapie rytm pracy obozu. Sprawdź na drugim wierszu: 15 marca 2023 to środa, więc

day_of_week
wynosi 2, licząc poniedziałek jako zero. Ostatni wiersz to 31 grudnia, czyli 365 dzień roku i czwarty kwartał. Sama kolumna
observation_date
została w tabeli - rozkładanie daty na części niczego z niej nie zabiera.

Z tych części można od razu zlepić cechę bardziej użyteczną niż każda z osobna. Metoda

isin
sprawdza, czy wartość należy do podanej listy, i zwraca maskę logiczną - a piątka i szóstka to właśnie sobota i niedziela. Doklejone
astype(int)
zamienia maskę na zera i jedynki.

1dates['is_weekend'] = dates['day_of_week'].isin([5, 6]).astype(int)
2
3print(dates[['observation_date', 'day_of_week', 'is_weekend']])
4#   observation_date  day_of_week  is_weekend
5# 0       2023-01-14            5           1
6# 1       2023-03-15            2           0
7# 2       2023-07-04            1           0
8# 3       2023-12-31            6           1

Dwie obserwacje wypadły w weekend i dostały jedynkę, dwie w tygodniu i dostały zero. Modelowi łatwiej wyłapać jedną wyraźną kolumnę zero-jedynkową niż odgadnąć, że akurat wartości 5 i 6 w kolumnie

day_of_week
znaczą coś wspólnego. Ta sama sztuczka działa dla pory suchej, sezonu migracji czy dni po ulewie.

Grudzień i styczeń są sąsiadami

Z miesiącem zapisanym jako liczba od 1 do 12 jest jeden kłopot, którego nie widać na pierwszy rzut oka. Dla modelu grudzień, czyli 12, leży jedenaście jednostek od stycznia, czyli 1 - dalej niż od czerwca. W przyrodzie jest odwrotnie: między grudniową a styczniową obserwacją mija jeden miesiąc i pogoda jest niemal ta sama. Rok jest kołem, a liczba od 1 do 12 jest odcinkiem.

Rozwiązaniem jest kodowanie cykliczne: zamiast jednej liczby podajesz modelowi dwie, będące położeniem miesiąca na okręgu. Liczą je funkcje

np.sin
i
np.cos
, a wzór w obu przypadkach jest ten sam - numer miesiąca dzielisz przez 12, żeby dostać ułamek roku, i mnożysz przez pełny kąt, czyli dwa razy
np.pi
, gdzie
pi
to stała liczba pi zapisana w NumPy. Numery miesięcy pakuję do znanej Ci z lekcji o pandas struktury
pd.Series
, czyli pojedynczej kolumny, bo cztery przykładowe miesiące nie potrzebują całej tabeli.

1months = pd.Series([1, 2, 6, 12])
2
3month_sin = np.sin(2 * np.pi * months / 12)
4month_cos = np.cos(2 * np.pi * months / 12)
5
6print(pd.DataFrame({
7    'month': months,
8    'month_sin': month_sin.round(3),
9    'month_cos': month_cos.round(3)
10}))
11#    month  month_sin  month_cos
12# 0      1      0.500      0.866
13# 1      2      0.866      0.500
14# 2      6      0.000     -1.000
15# 3     12     -0.000      1.000

Porównaj teraz pary punktów na tym okręgu. Styczeń siedzi w

(0.5, 0.866)
, grudzień w
(0.0, 1.0)
, luty w
(0.866, 0.5)
. Odległość między styczniem a grudniem wynosi 0.518 i jest dokładnie taka sama jak odległość między styczniem a lutym - czyli para grudzień-styczeń jest wreszcie tak blisko siebie, jak blisko jest w rzeczywistości. Czerwiec, oddalony o pół roku, leży 1.932 od stycznia, po przeciwnej stronie koła. Minus przy zerze w ostatnim wierszu to znowu tylko śmieć zmiennoprzecinkowy: prawdziwa wartość sinusa dla pełnego kąta to liczba rzędu dziesięć do minus szesnastej. Ta sama sztuczka działa na godzinę doby i na dzień roku.

Opis z terenu też jest daną

Została ostatnia rzecz z notesu, ta najmniej podobna do tabeli: zdania, które przewodnicy dopisują ołówkiem przy obserwacji. Nie da się ich podać modelowi wprost, ale da się z nich wycisnąć liczby. Pandas udostępnia operacje tekstowe pod przystawką

str
, działającą na całej kolumnie naraz:
str.len
podaje długość napisu w znakach,
str.split
rozbija zdanie na listę słów, a
str.contains
sprawdza, czy w tekście występuje podany fragment, i zwraca znaną Ci już maskę logiczną. Doklejając do
str.split()
jeszcze jedno
.str.len()
, dostajesz liczbę słów w każdym zdaniu.

1notes = pd.DataFrame({
2    'description': [
3        'Large male lion spotted near river',
4        'Small elephant calf with mother',
5        'Fast cheetah hunting gazelle'
6    ]
7})
8
9notes['text_length'] = notes['description'].str.len()
10notes['word_count'] = notes['description'].str.split().str.len()
11notes['has_river'] = notes['description'].str.contains('river').astype(int)
12
13print(notes[['text_length', 'word_count', 'has_river']])
14#    text_length  word_count  has_river
15# 0           34           6          1
16# 1           31           5          0
17# 2           28           4          0

Trzy zdania zamieniły się w trzy kolumny liczb, a każda niesie inny sygnał: długość opisu bywa miarą tego, jak niecodzienna była obserwacja, a

has_river
to gotowa cecha zero-jedynkowa mówiąca, że zwierzę widziano przy wodzie. Kolumna
description
została nietknięta i dalej nadaje się do czytania przez ludzi. Istnieją oczywiście metody, które biorą cały słownik tekstu i ważą znaczenie każdego słowa - najbardziej znana nazywa się TF-IDF - ale wchodzą one w przetwarzanie języka naturalnego i mają zbyt wiele własnych pojęć, żeby zmieścić je w tej lekcji. Na razie w zupełności wystarczą trzy ręcznie wymyślone kolumny.

Ostatni obóz: policz statystyki tam, gdzie wolno

Zostało jedno ostrzeżenie, bez którego cała powyższa robota potrafi cicho oszukać. Kiedy liczyliśmy standaryzację, średnia i odchylenie policzyły się ze wszystkich pięciu wierszy. Gdyby część z nich była danymi testowymi, na których model ma dopiero zostać sprawdzony, to informacja z testu przeciekłaby do przygotowania danych i wyniki wyszłyby zbyt piękne. Dlatego w prawdziwych projektach przekształcenia opisuje się z góry, a dopiero potem uruchamia na właściwym kawałku danych.

Służy do tego

ColumnTransformer
z podmodułu
compose
biblioteki scikit-learn: dostaje listę zadań, w której każde zadanie to trójka - nazwa własna, narzędzie przekształcające i lista kolumn, na których ma pracować.
StandardScaler
to gotowa standaryzacja, którą liczyłeś wcześniej ręcznie, a
OneHotEncoder
to gotowe kodowanie jeden z wielu, z argumentem
drop='first'
odpowiadającym znanemu
drop_first
. Metoda
fit_transform
robi dwie rzeczy naraz: uczy się potrzebnych statystyk z podanych danych i od razu je przekształca.

1from sklearn.compose import ColumnTransformer
2from sklearn.preprocessing import StandardScaler, OneHotEncoder
3
4preprocessor = ColumnTransformer([
5    ('num', StandardScaler(), ['population', 'area_km2']),
6    ('cat', OneHotEncoder(drop='first'), ['habitat'])
7])
8
9matrix = preprocessor.fit_transform(df)
10
11print(matrix.shape)
12# (5, 3)

Z ramki wyszła zwykła tablica NumPy o pięciu wierszach i trzech kolumnach: dwie kolumny liczbowe po standaryzacji plus jedna kolumna binarna dla siedliska, bo przy dwóch kategoriach i

drop='first'
zostaje tylko jedna. Kolumny, których nie wymieniłeś w żadnym zadaniu, po prostu nie trafiły do wyniku -
ColumnTransformer
przepuszcza wyłącznie to, co mu wskazano. Wartości standaryzowane różnią się tu o kilka setnych od tych, które liczyliśmy ręcznie, bo scikit-learn dzieli przez odchylenie liczone dla całej populacji, a pandas domyślnie przez odchylenie dla próby. Ten sam
preprocessor
można potem wstawić jako pierwszy krok do obiektu
Pipeline
, który skleja przygotowanie danych i model w jedną całość - wtedy statystyki policzą się wyłącznie na danych treningowych i przeciek jest niemożliwy.

Co zabierasz z obozu

  • Feature engineering to tworzenie nowych cech z surowych danych na potrzeby modeli ML. Nie jest to usuwanie wszystkich cech, nie jest to trenowanie modelu (to dopiero
    fit
    ) i nie jest to wizualizacja (od tego są Matplotlib i Seaborn).
  • Skośną kolumnę spłaszczasz logarytmem w kolejności
    np
    ,
    .
    ,
    log1p
    ,
    (
    ,
    df['population']
    ,
    )
    . Wariant
    log1p
    zamiast
    log
    przeżywa zera.
  • Standaryzacja to
    (x - mean) / std
    i daje średnią 0 oraz odchylenie 1. Normalizacja Min-Max to
    (x - min) / (max - min)
    i wtłacza dane dokładnie w przedział od 0.0 do 1.0.
    Domyślnie polecam standaryzację, bo pojedyncza wartość odstająca nie zjada jej skali.
  • pd.cut
    dzieli dane numeryczne na kategorie, czyli przedziały.
    Nie kopiuje ramki (to
    df.copy()
    ), nie usuwa kolumn (to
    df.drop
    ) i nie wycina wierszy spełniających warunek (to maska logiczna).
    pd.qcut
    robi to samo, ale kubełkami o równej liczności.
  • Najcenniejsze cechy powstają z relacji między kolumnami - zagęszczenie pokazało, że największa populacja ma najniższe zagęszczenie w rezerwacie.
  • One-Hot Encoding zamienia zmienne kategoryczne na kolumny binarne. Nie usuwa wartości odstających, nie normalizuje liczb i nie łączy ramek - od łączenia są
    pd.merge
    i
    pd.concat
    .
  • Wywołanie kodowania to sześć części:
    pd
    ,
    .
    ,
    get_dummies
    ,
    (
    ,
    df
    ,
    )
    . Kolumny liczbowe przechodzą przez nie bez zmian.
  • LabelEncoder
    numeruje kategorie alfabetycznie i wmawia modelowi porządek, którego nie ma. Numeruj tylko kategorie naprawdę porządkowe, słownikiem i metodą
    map
    .
  • Datę rozkładasz przystawką
    dt
    na rok, miesiąc, kwartał, dzień tygodnia i dzień roku, a cykliczność ratujesz parą
    np.sin
    i
    np.cos
    .
  • Tekst zamieniasz w liczby przez
    str.len
    ,
    str.split
    i
    str.contains
    , a statystyki do skalowania licz na końcu przez
    ColumnTransformer
    , żeby nie przeciekły z danych testowych.

Model nigdy nie zobaczy tego, co Ty widziałeś przy wodopoju, @name - zobaczy tylko te kolumny, które mu ulepisz, więc od dzisiaj to Ty decydujesz, ile z rezerwatu da się w ogóle wyczytać.

Przejdź do CodeWorlds