Nieco więcej o pozbywaniu się niepotrzebnych danych

Jakiś czas temu pisałem o rozpoznawaniu najważniejszych kolumn w ramce danych (artykuł Które kolumny są dla nas najważniejsze?). Rozpisałem się tam między innymi o niepotrzebnych kosztach wynikających ze składowania i używania wszystkich możliwych danych. Nie podałem tam jednak żadnych konkretnych przykładów. Zabrakło liczb i przykładów. W tym artykule nadrabiam zaległości.

Zbiór danych

Zbiór danych, który wybrałem do tego artykułu to New York City Taxi Fare Prediction. Zbiór ten składa się z 8 kolumn i 50 tysięcy wierszy. Dane, które są w nim zawarte to: miejsce startu przejazdu taksówki, data i godzina startu, ilość pasażerów, miejsce zakończenia przejazdu i zapłacona kwota. Wybrałem ten zbiór danych, gdyż jest dość liczny (50k obserwacji), przedstawia zrozumiałe zagadnienie i jest w miarę dobrze przygotowany. Poprzez w miarę dobrze mam na myśli, że nie musimy zajmować się za bardzo konwersjami kolumn. Fakt, zauważyłem w nim kilka dziwnych wartości, ale nie ma ich za dużo i nie przeszkadzają w omówieniu sprytnego usuwania danych.

Przygotowanie danych

To, że wybrałem taki prosty zbiór danych, nie oznacza, że nie możemy sobie go trochę rozbudować. Pierwszym pomysłem, od którego prawie zawsze zaczynam to rozbicie kolumny pickup_datetime na poszczególne składowe. Bardzo często możemy z takiej kolumny wyciągnąć rok, miesiąc, dzień, godzinę, minutę, sekundę i dzień tygodnia. Fakt, minuty i sekundy prawie nigdy nie mają sensu. No bo ilość zjawisk, które dzieją się inaczej w 50-tej  sekundzie zegara niż w 10-tej, jest raczej znikoma. Nie przychodzi mi nawet aktualnie żaden pomysł na takie zjawisko. Jeśli masz jakiś, to podziel się z nami koniecznie w komentarzu ;). W każdym razie, w ten sposób dokładamy sobie trochę dodatkowych kolumn do naszych danych. Celem artykułu jest pokazanie jak się ich pozbywać, musimy mieć więc jakieś pole do popisu ;). Rozbijanie tej kolumny będziemy robić w następujący sposób:

taxi_data["pickup_datetime"] = pd.to_datetime(taxi_data["pickup_datetime"])

taxi_data["pickup_year"] = taxi_data["pickup_datetime"].dt.year
taxi_data["pickup_month"] = taxi_data["pickup_datetime"].dt.month
taxi_data["pickup_day"] = taxi_data["pickup_datetime"].dt.day
taxi_data["pickup_hour"] = taxi_data["pickup_datetime"].dt.hour
taxi_data["pickup_minute"] = taxi_data["pickup_datetime"].dt.minute
taxi_data["pickup_second"] = taxi_data["pickup_datetime"].dt.second
taxi_data["pickup_dayofweek"] = taxi_data["pickup_datetime"].dt.dayofweek

taxi_data.drop(["key", "pickup_datetime"], axis = 1, inplace = True)

Więcej kolumn geograficznych

Kolejnym krokiem będzie dołożenie kolumn związanych z cechami geograficznymi. Pierwszym pomysłem (nie koniecznie dobrym) będzie dodanie dwóch kolumn odpowiadających odpowiednio za różnicę w długości i szerokości geograficznej. Domyślamy się, że pewnie nie będzie to miało sensu. No bo raczej taksówkarze nie rozliczają się inaczej jeśli jedziesz z południa na północ niż ze wschodu na zachód. Ale mamy dodatkowe kolumny, których zapewne szybko się pozbędziemy.

taxi_data["longitude_diff"] = abs(taxi_data["pickup_longitude"] - taxi_data["dropoff_longitude"])
taxi_data["latitude_diff"] = abs(taxi_data["pickup_latitude"] - taxi_data["dropoff_latitude"])

Ostatnim pomysłem na dołożenie kolumn będzie wyznaczenie odległości. Fakt, najbardziej interesowałby nas dystans pokonany między punktami, jednak tej informacji z tych danych nie wyciągniemy. Możemy za to wyznaczyć dwa rodzaje odległości: euklidesową i taksówkową.

Bardziej nam znaną miarą odległości jest miara euklidesowa:

odl_e(\mathbf {start}, \mathbf {stop}) = \sqrt{(stop_{dlu} - start_{dlu})^2 + (stop_{sze} - start_{sze})^2}

O co chodzi w tym równaniu? Mamy dwa punkty – punkt startowy start i punkt końcowy stop. Obydwa te punkty mają swoje współrzędne (długość i szerokość). Odejmujemy wartości szerokości i podnosimy do kwadratu. To samo robimy z wartościami długość. Następnie sumujemy te dwie liczby i wyciągamy z nich pierwiastek. Wprawne oko od razu zauważy, że jest to nic innego jak twierdzenie Pitagorasa. I o to właśnie chodzi. Po prostu zaznaczamy na mapie dwa punkty i rysujemy między nimi linię.

Inną, być może bardziej przydatną dla nas miarą odległości może być odległość taksówkowa. Zacznijmy od równania dla naszego przypadku:

odl_t(\mathbf {start}, \mathbf {stop}) = |stop_{dlu} - start_{dlu}| + |stop_{sze} - start_{sze}|

Odległości
Odległości taksówkowe (czerwona, niebieska i żółta) – 12 jednostek. Odległość euklidesowa (zielona) ~ 8.4853 jednostek. By User:Psychonaut – Created by User:Psychonaut with XFig, Public Domain, Link

Tutaj sprawa jest już mniej intuicyjna. Ale tylko pozornie. Weźmy sobie czynnik długość z naszego położenia. Wyznaczamy wartość bezwzględną ze zmiany tej wartości. Robiliśmy to już kilka akapitów wcześniej, ale z innego powodu. To samo zróbmy z szerokością. Zsumujmy te wartości. Uzyskaliśmy odległość zmierzoną pod warunkiem, że mogliśmy się poruszać tylko wzdłuż południków i równoleżników. Czyli praktycznie rzecz biorąc, mogliśmy się poruszać, jak chcieliśmy, pod warunkiem, że zawsze skręcaliśmy pod kątem 90 stopni. I właśnie z tego powodu, metryka ta, oprócz bycia nazywaną taksówkową jest również nazywana manhattańską. Manhattańską, bo część ulic na wyspie Manhattan krzyżuje się właśnie pod kątem prostym. Ciekawy zbieg okoliczności, zważywszy na to, że wybrany przeze mnie zbiór danych to przejazdy taksówek w Nowym Jorku.

Dołożenie tych dwóch kolumn będzie wyglądać następująco:

taxi_data["euclidean_dist"] = np.sqrt((taxi_data["pickup_longitude"] - taxi_data["dropoff_longitude"])**2 + 
                                      (taxi_data["pickup_latitude"] - taxi_data["dropoff_latitude"])**2)
taxi_data["taxicab_dist"] = taxi_data["longitude_diff"] + taxi_data["latitude_diff"]

Po wydzieleniu kolumny fare_amount jako wektora y (nasz cel do wyznaczenia) zostało nam 16 kolumn, które powinny mieć jakiś związek z naszym wspomnianym celem. Powinno nam więc starczyć danych do eksperymentu.

Eksperyment

Nasz eksperyment będzie składał się z jednego działania oraz pomiaru dwóch wartości. Całość będzie się odbywać w pętli od 0 do liczba kolumn – 1, a w każdym przebiegu będziemy trenować funkcję modelującą na zbiorze danych pozbawionym coraz większej liczby kolumn.

Zacznijmy od pomiarów. W czasie przebiegu eksperymentu będziemy mierzyć czas trenowania funkcji modelującej, w zależności od ilości danych. Na funkcję modelującą wybrałem RandomForestRegressor. Przekazuję jej dwa parametry: random_state = 42 (odpowiedzialny za powtarzalność) i  n_estimators = 100 (żeby za każdym razem było trenowanych 100 drzew). Za pomiar czasu trenowania będzie odpowiedzialna funkcja magiczna %timeit.

Drugim pomiarem będzie efektywność naszej funkcji modelującej. W skrypcie wydzielam zbiór testowy, więc w czasie przebiegu pętli, zaraz po trenowaniu mogę wyliczyć sobie dla niego score. Do wyliczenia score wybrałem funkcję mean_squared_error, czyli błąd średniokwadratowy. Im ta wartość jest wyższa, tym nasz model dokonuje mniej zgodnych z rzeczywistością predykcji.

Po wyznaczeniu tych wartości znajduję jeszcze kolumnę, która miała najmniejszy wkład przy trenowaniu modelu. Wtedy usuwam ją ze zbioru treningowego i testowego, które będą użyte w kolejnym przebiegu eksperymentu.

Kod z esencją tego eksperymentu wygląda tak:

deleted_columns = []
results = pd.DataFrame()
for number_of_removed in range(len(X_train.columns)):
    print("Ilość usuniętych kolumn: {}".format(number_of_removed))
    
    execution_time = %timeit -o forest.fit(X = X_train, y = y_train)
    mse = mean_squared_error(y_true = y_test, y_pred = forest.predict(X_test))
    results = results.append({"average_time":execution_time.average, "mse":mse}, ignore_index = True)
    print("mse: {}".format(mse))

    print("Usunięte kolumny: {}".format(deleted_columns))
    print()
    
    worst_feature_id = np.where(forest.feature_importances_ == min(forest.feature_importances_))[0].item()
    worst_feature = X_train.columns[worst_feature_id]

    deleted_columns.append(worst_feature)
    
    X_train = X_train.drop([worst_feature], axis=1)
    X_test = X_test.drop([worst_feature], axis=1)

Na końcu uzyskujemy ramkę danych results, w której indeks odpowiada liczbie usuniętych kolumn a kolumny average_time i mse to wartości średniego czasu trenowania modelu i błąd średniokwadratowy jego predykcji.

Wyniki

Intuicja podpowiada nam, że skoro usuwamy kolumny, to czas trenowania naszego modelu powinien systematycznie maleć. Mniej danych to w lesie losowym po prostu mniej potencjalnych punktów podziału do sprawdzenia. Z drugiej strony, spodziewamy się, że błąd średniokwadratowy predykcji naszego modelu będzie rosnąć. Bo im mniej danych mamy, tym trudniej jest na ich podstawie dokonywać wniosków. Obejrzyjmy więc nasze wyniki:

Rezultat
Rezultat

Widzimy, że nasza intuicja mniej więcej pokrywa się z wynikami, które uzyskaliśmy. Jak możemy wykorzystać uzyskane wyniki do ulepszenia naszego procesu? Widzimy, że czas trenowania maleje nam mniej więcej liniowo. Możemy więc użyć tej wiedzy do oszacowania czasu potrzebnego na trening dla zbiorów, w których będziemy mieć więcej obserwacji.

Nieco inaczej sytuacja wygląda z błędem średniokwadratowym. Tutaj nie mamy takiej oczywistej zależności. Czasem nieco maleje, czasem nieco rośnie, aż do momentu, w którym zdecydowanie zaczyna rosnąć. Oznacza to tyle, że kolumny, które odrzucaliśmy, nie miały prawie żadnego znaczenia dla efektywności predykcji naszego modelu. Jeśli po zmianach, które zaproponowałem w sekcji poniżej, dalej uzyskujemy taką informację, to mamy bardzo silny argument, żeby się ich pozbyć.

Dodatkowo jeszcze, w skrypcie eksperymentu wyświetlam kolumny, które kolejno zostawały odrzucane. Pierwsza kolumna na tej liście będzie więc najmniej przydatna, a kolejne kolumny będą teoretycznie coraz bardziej przydatne. Pozwoli nam to zapoznać się „naocznie” z danymi, na których bazie funkcja modelujące podejmuje decyzje. A raczej których danych nie używa. Widzimy więc, że pierwszą odrzuconą kolumną była passenger_count. Wartość w tej kolumnie mówi nam o ilości osób w trakcie przejazdu taksówką. No i zgodnie z tym, co np. znamy z przejazdów taksówką w Polsce, cena przejazdu nie zależy od ilości jadących osób. Jeżeli mamy wiedzę ekspercką z zakresu zjawiska, które badamy, możemy ją również użyć jako czynnik przydany w ocenie ilości kolumn, które chcemy zachować.

Patrząc więc na powyższy wykres, podjąłbym pewnie decyzję o zatrzymaniu się na usunięciu 9 kolumn. Skróciłbym wtedy czas o około 1/3 a błąd wcale by nie wzrósł.

Potencjalna rozbudowa eksperymentu

To, co przedstawiłem powyżej, dalekie jest od wyczerpania tematu. Przedstawię tutaj kilka pomysłów na potencjalną rozbudowę tego eksperymentu.

Pierwszym pomysłem jest zbudowanie większej ilości zbiorów train i test. W tej chwili używam tylko jednej takiej pary. Być może jest ona wyjątkowa pod jakimś względem, który wpływa na wyliczenie błędu lub na czas trenowania. By pozbyć się tego potencjalnego efektu, można by zbudować np. 100 par i przeprowadzić ten sam eksperyment dla nich wszystkich. Uzyskamy wtedy stabilniejsze wyniki, ale czas trwania całości wzrośnie 100-krotnie.

Drugi pomysł to użycie innej funkcji modelującej. Ja wybrałem tutaj las losowy, bo wiem, jak łatwo mogę wydłużyć jego trenowanie na czas eksperymentu, a jak skrócić na czas jego przygotowania (parametr n_estimators). Natomiast inne funkcje mogą skalować się w nieco inny sposób. Też będziemy w nich obserwować malejący czas i rosnący błąd, ale kluczowe dla nas punkty odcinające mogą być inne.

Kolejny pomysł to krytyczne spojrzenie na funkcję score i zastanowienie się, czy nie możemy znaleźć (lub stworzyć) takiej, która jest bardziej adekwatna dla naszego problemu. Tutaj użyłem błędu średniokwadratowego. Być może błąd, który ma dla nas więcej sensu, będzie wskazywał na zupełnie inną liczbę kolumn odrzuconych?

Konkluzja

Jak widzimy na przykładzie prostego eksperymentu, nie zawsze warto ładować wszystkie możliwe kolumny ramki danych do funkcji modelującej. Warto zastanowić się nad potencjalnymi ustępstwami w kontekście dokładności naszego modelu, na rzecz szybszego jego trenowania. W ten sposób możemy znacznie poprawić np. czas trwania cyklu testowania różnych hiperparametrów. Jak pokazałem powyżej, taki eksperyment nie jest trudny, natomiast da nam więcej wglądu w nasz proces.

Pełny kod źródłowy z dzisiejszego postu znajduje się tutaj.

Kursy Online

Jeśli jesteś zainteresowany zakupem wideo kursów online które przygotowałem, sprawdź tę stronę – może akurat opublikowałem tam kupony zniżkowe. 🙂

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *