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 (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.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 NoPięć 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.
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.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.52Stosunek 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.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.78Cztery 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ć.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.000Wodopó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ć.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 hugePięć 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.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 Q4Granice 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.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.5Przeczytaj 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ć.
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 50000000Pierwiastek ś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.
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 FalseJedna 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 1Dwa 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.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.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 1Jedna 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.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 0Kolejność 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.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 0Nawiasy 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.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 365Z 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 1Dwie 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.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.000Poró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.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 0Trzy 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.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.fit) i nie jest to wizualizacja (od tego są Matplotlib i Seaborn).np, ., log1p, (, df['population'], ). Wariant log1p zamiast log przeżywa zera.(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.pd.merge i pd.concat.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.dt na rok, miesiąc, kwartał, dzień tygodnia i dzień roku, a cykliczność ratujesz parą np.sin i np.cos.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ć.