Inżynieria cech

Definicja

Feature Engineering polega na przekształcaniu surowych danych w cechy użyteczne w procesie modelowania.

Info

Tworzenie zmiennych predykcyjnych zawsze powinno opierać się przede wszystkim na zrozumieniu danych i wiedzy biznesowej związanej z danym problemem. Istnieje jednak kilka standardowych technik, które można stosować do tworzenia cech w większości problemów, można tu wyróżnić:

  • przekształcanie zmiennych kategorycznych

  • zmienne na podstawie cech z daty

  • zmienne powstałe przez przesunięcie w czasie

  • transformacje zmiennych numerycznych

  • zmienne interakcji

Uwaga!

Feature Engineering stanowi jeden z ważniejszych etapów w procesie budowy uczenia maszynowego, dlatego należy mu poświęcić dużo uwagi. Często można uzyskać znacznie lepsze wyniki mając prosty model oparty na cechach predykcyjnych dobrze oddających naturę badanego zjawiska niż budując wyrafinowany model w oparciu o zbyt wąski zbiór zmiennych.

Tworzenie przykładowych zbiorów danych

Zaprezentujemy tutaj 2 przykładowe zbiory danych o różnym charakterze oraz przykłady zmiennych predykcyjnych, które można wygenerować na ich podstawie

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
def generateIceCreamSalesData(month_coef=10000, week_coef=1000, day_coef=100, random_coef =10000):
    dates = pd.date_range(start="2018-01-01", end="2020-12-31", freq ="D")
    df = pd.DataFrame(dates, columns=["SalesDate"])
    df["Month"] = df.SalesDate.dt.month
    df["Week"] = df.SalesDate.dt.isocalendar().week
    df["WeekDay"] = df.SalesDate.dt.dayofweek+1
    df["IceCreamSales"] = (-1*np.power(df.Month-6,2)+np.power(6,2))*month_coef + (-1*np.power(df.Week-27,2)+np.power(27,2))*week_coef +df.WeekDay*day_coef +random_coef * np.random.randint(low =1,high =10, size=len(df))
    return df.loc[:,["SalesDate","IceCreamSales"]]
IceCream_df = generateIceCreamSalesData()
IceCream_df
SalesDate IceCreamSales
0 2018-01-01 183100
1 2018-01-02 233200
2 2018-01-03 233300
3 2018-01-04 203400
4 2018-01-05 193500
... ... ...
1091 2020-12-27 164700
1092 2020-12-28 123100
1093 2020-12-29 93200
1094 2020-12-30 123300
1095 2020-12-31 143400

1096 rows × 2 columns

def generate_used_cars_data(len_df =1000):
    conditions ={"very_bad":1,"bad":2,"medium":3,"good":4,"very_good":5}
    brands =["Fiat","Renault","VW", "Seat", "Skoda","Toyota", "Audi","BMW","Mercedes", "Bugatti"]
    standard_brands = ["Fiat","Renault","VW", "Seat", "Skoda","Toyota"]
    premium_brands = ["Audi","BMW","Mercedes"]
    luxury_brands = ["Bugatti"]
    dict_data ={"condition":np.random.choice(list(conditions.keys()),size=len_df, p = [0.05, 0.15, 0.3, 0.3,0.2 ]),
                "brand":np.random.choice(brands,size=len_df, p=[0.1]*10 ),
                "year_manufactured":np.random.randint(1950,2020,size =len_df)
               }
    df = pd.DataFrame(dict_data)
    df["age"] =2021- df.year_manufactured
    df["mileage"] = df.age *np.random.randint(100,10000, len_df)+np.random.randint(100,10000, len_df)
    
    df["selling_price"] = 100000*df["brand"].isin(standard_brands)+300000*df["brand"].isin(premium_brands)+600000*df["brand"].isin(luxury_brands)
    df["selling_price"] /= np.log1p(df.age)+np.log1p(df.mileage)
    df["condition_num"] = df.condition.map(lambda x:conditions[x])
    df["selling_price"] *=  np.log1p(df["condition_num"])
    df.loc[(df["brand"].isin(luxury_brands))&(df.year_manufactured<=1970)&(df["condition_num"]>3),"selling_price"] *=\
    np.log1p(df.loc[(df["brand"].isin(luxury_brands))&(df.year_manufactured<=1970)&(df["condition_num"]>3),"age"])
    df["selling_price"] = np.round(df["selling_price"])
    return df.loc[:,["selling_price","condition","mileage","brand","year_manufactured"]]
UsedCars_df =generate_used_cars_data()
UsedCars_df
selling_price condition mileage brand year_manufactured
0 9724.0 good 237110 VW 1957
1 31357.0 good 135188 Audi 1986
2 4068.0 very_bad 432500 Seat 1964
3 12422.0 very_good 63353 Skoda 1993
4 8091.0 medium 424824 Seat 1957
... ... ... ... ... ...
995 4531.0 very_bad 64802 Fiat 1954
996 11131.0 very_good 257754 Seat 1984
997 10018.0 good 256309 Fiat 1985
998 11703.0 very_good 139378 Skoda 1990
999 7992.0 medium 578293 Skoda 1963

1000 rows × 5 columns

Cechy tworzone na podstawie danych czasowych

Pierwszy zbiór danych nazwany tutaj IceCream_df de facto ma charakter szeregu czasowego i patrząc na poniższy wykres mógłby być z powodzeniem przewidywany dedykowanymi metodami do predykcji szeregów czasowych, jednak dla celów pokazania tworzenia zmiennych na podstawie danych o charakterze czasowym potraktujemy to zagadnienie jako problem regresyjny, w którym naszą zmienną celu będzie IceCreamSales.

IceCream_df.set_index("SalesDate").plot(figsize=(15,9), title="IceCream Sales")
<AxesSubplot:title={'center':'IceCream Sales'}, xlabel='SalesDate'>
../_images/6_Inzynieria cech_13_1.png

Wyraźnie widać tutaj, że sprzedaż lodów ma silnie sezonowy charakter, stąd cechy wyciągnięte z daty powinny znacznie ułatwić dobrą predykcję. Funkcję generującą takie cechy zaprezentowano poniżej:

def extract_date_features(df, date_column):
    df_temp = df.copy()
    df_temp[date_column + 'Quarter'] = df_temp[date_column].dt.quarter
    df_temp[date_column + 'Month'] = df_temp[date_column].dt.month
    df_temp[date_column + 'Week'] = df_temp[date_column].dt.isocalendar().week
    # poniżej zwracany rozkład to 0-6, dodajemy 1 aby przejsc na bardziej intuicyjne wrtości 1-7
    df_temp[date_column + 'WeekDay'] = df_temp[date_column].dt.dayofweek + 1 
    df_temp[date_column + 'YearDay'] = df_temp[date_column].dt.dayofyear
    df_temp[date_column + 'isWeekend'] = np.where(df_temp[date_column + 'WeekDay']>5,1,0)
    return df_temp
IceCream_df_extended = extract_date_features(IceCream_df,"SalesDate")
IceCream_df_extended
SalesDate IceCreamSales SalesDateQuarter SalesDateMonth SalesDateWeek SalesDateWeekDay SalesDateYearDay SalesDateisWeekend
0 2018-01-01 183100 1 1 1 1 1 0
1 2018-01-02 233200 1 1 1 2 2 0
2 2018-01-03 233300 1 1 1 3 3 0
3 2018-01-04 203400 1 1 1 4 4 0
4 2018-01-05 193500 1 1 1 5 5 0
... ... ... ... ... ... ... ... ...
1091 2020-12-27 164700 4 12 52 7 362 1
1092 2020-12-28 123100 4 12 53 1 363 0
1093 2020-12-29 93200 4 12 53 2 364 0
1094 2020-12-30 123300 4 12 53 3 365 0
1095 2020-12-31 143400 4 12 53 4 366 0

1096 rows × 8 columns

Cechy powstałe przez przesunięcie w czasie

Analizując wykres można zaobserwować, że sprzedaż lodów nie jest liniowo zależna od zmiennych takich jak numer kwartału, numer miesiąca czy numer tygodnia, aby uzyskać lepsze wyniki warto zbudować cechy w oparciu o przesunięte w czasie wartości zmiennej celu.

Uwaga!

Przy korzystaniu ze zmiennych przesuniętych w czasie, w szczególności opartych o wcześniejsze wartości zmiennej celu nalezy zawsze wziąc pod uwagę jakie dane będziemy mieli dostępne na moment predykcji, inaczej możemy popełnić jeden z najczęstszych błędów czyli data leakage. Przy założeniu, że mamy przewidywać sprzedaż lodów np na rok do przodu wykorzystanie sprzedaży lodów z dnia poprzedniego stanowi data leakage, ponieważ ta informacja nie będzie dostępna na moment predykcji w tej samej formie. Moglibyśmy natomiast uwzględnić sprzedaż lodów sprzed roku, jako, że ta informacja będzie dostępna w tej samej formie.

Tutaj dla uproszczenia zakładamy chwilowo, że horyzont predykcji to tylko 1 dzień do przodu, co pozwoli zaprezentować większy zakres zmiennych przesuniętych w czasie.

def get_shifted_target_values(df, lag_values, date_column, target_column):
    df_temp = df.copy()
    df_temp =df_temp.sort_values(by=date_column)
    for lag in lag_values:
        df_temp[target_column +"_lagged_" + str(lag)] = df_temp[target_column].shift(lag)
    return df_temp

Uwzględniając, że przewidujemy z horyzontem czasowym tylko na dzień w przód, pierwszym kandydatem na wartość przesunięcia jest 1 dzień, jako, że w danych o charakterze szeregu czasowego podobieństwo kolejnych obserwacji będzie siłą rzeczy relatywnie wysokie. Z analizy wykresu można łatwo wywnioskować, że wartośc przesunięcia 365 dni również będzie miała dużą siłę predykcyjną. Ponadto można tutaj wypróbować 7 dni co powinno być przydatne w przypadku tygodniowej sezonowości danych

IceCream_df_extended = get_shifted_target_values(IceCream_df_extended, [1,7,365], "SalesDate","IceCreamSales")
IceCream_df_extended
SalesDate IceCreamSales SalesDateQuarter SalesDateMonth SalesDateWeek SalesDateWeekDay SalesDateYearDay SalesDateisWeekend IceCreamSales_lagged_1 IceCreamSales_lagged_7 IceCreamSales_lagged_365
0 2018-01-01 183100 1 1 1 1 1 0 <NA> <NA> <NA>
1 2018-01-02 233200 1 1 1 2 2 0 183100 <NA> <NA>
2 2018-01-03 233300 1 1 1 3 3 0 233200 <NA> <NA>
3 2018-01-04 203400 1 1 1 4 4 0 233300 <NA> <NA>
4 2018-01-05 193500 1 1 1 5 5 0 203400 <NA> <NA>
... ... ... ... ... ... ... ... ... ... ... ...
1091 2020-12-27 164700 4 12 52 7 362 1 134600 243700 144600
1092 2020-12-28 123100 4 12 53 1 363 0 164700 144100 184700
1093 2020-12-29 93200 4 12 53 2 364 0 123100 194200 93100
1094 2020-12-30 123300 4 12 53 3 365 0 93200 144300 103200
1095 2020-12-31 143400 4 12 53 4 366 0 123300 144400 183300

1096 rows × 11 columns

Widzimy, że początek zbioru danych zawiera teraz wartości puste, ze względu na fakt, że dla wartości z roku 2018 nie istniały obserwacje cofnięte o rok. Do pokazywania liczby niepustych wartości w ramce danych przydatna jest funkcja info z biblioteki pandas

IceCream_df_extended.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 1096 entries, 0 to 1095
Data columns (total 11 columns):
 #   Column                    Non-Null Count  Dtype         
---  ------                    --------------  -----         
 0   SalesDate                 1096 non-null   datetime64[ns]
 1   IceCreamSales             1096 non-null   Int64         
 2   SalesDateQuarter          1096 non-null   int64         
 3   SalesDateMonth            1096 non-null   int64         
 4   SalesDateWeek             1096 non-null   UInt32        
 5   SalesDateWeekDay          1096 non-null   int64         
 6   SalesDateYearDay          1096 non-null   int64         
 7   SalesDateisWeekend        1096 non-null   int32         
 8   IceCreamSales_lagged_1    1095 non-null   Int64         
 9   IceCreamSales_lagged_7    1089 non-null   Int64         
 10  IceCreamSales_lagged_365  731 non-null    Int64         
dtypes: Int64(4), UInt32(1), datetime64[ns](1), int32(1), int64(4)
memory usage: 99.5 KB

oczywiście wartości puste należy potem w jakiś sposób obsłużyć, poprzez ich usunięcie lub imputację

Info

Tworząc zmienne oparte o cechy przesunięte w czasie warto mieć na uwadze, że dane historyczne mogą zawierać wartości odstające. Dlatego zamiast brać wprost wartość sprzed np. 365 dni można rozważyć wygładzenie wartości stosując medianę z 5 dniowego okna, którego środek stanowi wartość sprzed 365 dni.

Cechy kategoryczne i ich transformacje

Dla zilustrowania transformacji na zmiennych kategorycznych posłużymy się drugim z przygotowanych zbiorów danych, gdzie chcemy przewidzieć cenę sprzedaży używanego samochodu.

UsedCars_df.head()
selling_price condition mileage brand year_manufactured
0 9724.0 good 237110 VW 1957
1 31357.0 good 135188 Audi 1986
2 4068.0 very_bad 432500 Seat 1964
3 12422.0 very_good 63353 Skoda 1993
4 8091.0 medium 424824 Seat 1957

jak widzimy występują tutaj 2 cechy kategoryczne condition oraz brand, na podstawie których zaprezentujemy które transformacje danych najlepiej zastosować w którym przypadku. Aby umożliwić wykorzystanie tych cech w predykcji niezbędne jest odpowiednie ich przekształcenie w wartości numeryczne

UsedCars_df.condition.unique()
array(['good', 'very_bad', 'very_good', 'medium', 'bad'], dtype=object)

patrząc na atrybuty zmiennej condition widzimy, że ma ona charakter porządkowy - jesteśmy w stanie łatwo ustalić naturalną kolejność jej atrybutów. Stanowi ona zatem dobrego kandydata do zastosowania kodowania porządkowego

Definicja

kodowania porządkowe (ang. Ordinal encoding) - reprezentacja każdego atrybutu kodowanej zmiennej jako kolejnej liczby naturalnej

poniżej zaprezentujemy kodowanie porządkowe z użyciem transformera OrdinalEncoder, najpierw jednak podzielimy nasze dane na zbiór treningowy i testowy, aby lepiej odwzorować to, że dane treningowe są oddzielone od danych produkcyjnych, na których model będzie potem stosowany.

from sklearn.preprocessing import OrdinalEncoder
from sklearn.model_selection import train_test_split

tutaj dla uproszczenia stosujemy tylko prosty podział na zbiór treningowy i testowy, generalnie najlepszą praktyką jest wydzielenie osobnego zbioru testowego reprezentującego zdolność modelu do generalizacji na nowych, niewidzianych wcześniej danych i dobór najlepszego zestawu parametrów i transformacji stosując crosswalidację na zbiorze treningowym.

X_train, X_test = train_test_split(UsedCars_df, test_size =0.25, random_state=42)

następnie tworzymy obiekt klasy OrdinalEncoder, określamy tutaj porządek cechy atrybutów cechy, którą chcemy przetransformować, inaczej mogą one zostać po prostu posortowane alfabetycznie co najczęściej nie będzie odpowiadało ich znaczeniu biznesowemu

oe= OrdinalEncoder(categories =[['very_bad', 'bad', 'medium', 'good', 'very_good']],
                   handle_unknown ='use_encoded_value', unknown_value=np.NaN)
Uwaga!

Kodując atrybuty należy mieć świadomość, że na nowych danych mogą pojawić się niewidziane wcześniej wartości, które należy w jakiś sposób obsłużyć. Domyślnym zachowaniem OrdinalEncoder w takiej sytuacji jest zwrócenie wyjątku, tutaj skorzystaliśmy z przypisania mu ustalonej wartości, gdzie wybraliśmy przypisanie wartości pustej. Następnie taką wartość można zastąpić np. dominantą ze zbioru treningowego, lub przypisać jej wartość neutralną, którą tutaj byłoby 2 odpowiadające kategorii medium.

Zdecydowanie najlepszym sposobem użycia wszystkich transformerów jest skorzystanie z pipeline, co zostanie zaprezentowane potem. Tutaj zaprezentujemy najprostsze wykorzystanie polegające na skorzystaniu z metod fit i transform. OrdinalEncoder stosujemy tylko do przekształcenia jednej cechy, gdyby było inaczej moglibyśmy wykonać fit na całym zbiorze treningowym a następnie przetransformować zbiory treningowy i testowy.

oe.fit(X_train.condition.values.reshape(-1, 1))
X_train["condition_transformed"] = oe.transform(X_train.condition.values.reshape(-1, 1))
X_test["condition_transformed"] = oe.transform(X_test.condition.values.reshape(-1, 1))
C:\Users\KNAJMA~1\AppData\Local\Temp/ipykernel_11688/1043533196.py:2: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  X_train["condition_transformed"] = oe.transform(X_train.condition.values.reshape(-1, 1))
C:\Users\KNAJMA~1\AppData\Local\Temp/ipykernel_11688/1043533196.py:3: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  X_test["condition_transformed"] = oe.transform(X_test.condition.values.reshape(-1, 1))
Uwaga!

Jeśli nie korzystamy z pipeline należy zwrócić szczególną uwagę żeby metody fit używać tylko na zbiorze treningowym a następnie mając już “nauczony” transformer stosować metodę transform na pozostałych zbiorach. Inaczej może dojść do przecieku informacji ze zbioru testowego.

możemy jeszcze sprawdzić czy przypisanie atrybutów na zbiorach treningowym i testowym jest prawidłowe, a następnie pozbyć się pierwotnej kolumny, wartości pierwotne mogą być łatwo odzyskane z przetransformowanych danych stosując metodę inverse_transform

X_train[["condition","condition_transformed"]].drop_duplicates().sort_values(by="condition_transformed")
condition condition_transformed
250 very_bad 0.0
519 bad 1.0
789 medium 2.0
82 good 3.0
398 very_good 4.0
X_test[["condition","condition_transformed"]].drop_duplicates().sort_values(by="condition_transformed")
condition condition_transformed
521 very_bad 0.0
513 bad 1.0
411 medium 2.0
740 good 3.0
737 very_good 4.0
X_train.drop(["condition"],axis=1, inplace=True)
X_test.drop(["condition"],axis=1, inplace=True)
C:\Users\knajmajer\.conda\envs\politechnika\lib\site-packages\pandas\core\frame.py:4163: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  return super().drop(
X_train
selling_price mileage brand year_manufactured condition_transformed
82 9614.0 372672 Renault 1972 3.0
991 10467.0 91527 Seat 1970 3.0
789 26411.0 246350 BMW 1994 2.0
894 27586.0 596233 Audi 1955 3.0
398 37872.0 112234 BMW 2009 4.0
... ... ... ... ... ...
106 78410.0 31878 Bugatti 2015 3.0
270 8451.0 187503 Fiat 1951 2.0
860 6770.0 318814 Skoda 1987 1.0
435 33319.0 103451 Mercedes 2003 3.0
102 8265.0 53915 Renault 2011 1.0

750 rows × 5 columns

widzimy, że do zakodowania pozostała nam jeszcze cecha - brand, w odróżnieniu od poprzednio rozważanej cechy tutaj nie ma oczywistego naturalnego porządku, dlatego należy tutaj zastosować inne podejście

Definicja

kodowania 1 z n (ang. One-hot encoding) - reprezentacja każdego atrybutu kodowanej zmiennej jako osobnej zmiennej binarnej, gdzie występowanie rozważanego atrybutu dla danej obserwacji oznaczane jest jako 1 a wszystkie pozostałe atrybuty oznaczane są jako 0

kodowanie 1 z n stanowi jeden z najczęściej używanych i najbardziej intuicyjnych sposobów kodowania, jednak jego główną wadą jest zwiększanie wymiarowości danych, co zaraz zaprezentujemy

najpierw pokażemy jak posługiwać się transformerem OneHotEncoder

from sklearn.preprocessing import OneHotEncoder

tworzymy obiekt klasy OneHotEncoder, podobnie jak w przypadku poprzedniego transformera, tu także domyślnym sposobem obsługi nieznanych atrybutów jest zwracanie wyjątku, my wybieramy handle_unknown=”ignore” co sprawi, że nieznane atrybuty reprezentowane będą po prostu jako 0 we wszystkich zakodowanych kolumnach.

Natomiast ustawienie sparse=False sprawia, że zwracane dane będą typu np.array zamiast domyślnego sparse matrix

ohe = OneHotEncoder(handle_unknown="ignore",sparse=False)

należy zwrócić uwagę, że we wszystkich transformerach z biblioteki sklearn stosowane jest to samo API, w związku z tym tutaj analogicznie jak w poprzednim przypadku możemy skorzystać z metod fit i transform

ohe.fit(X_train.brand.values.reshape(-1, 1))
brand_transformed_train = ohe.transform(X_train.brand.values.reshape(-1, 1))
brand_transformed_test = ohe.transform(X_test.brand.values.reshape(-1, 1))
print(f"rozmiar zakodowanej kolumny brand na danych treningowych to: {brand_transformed_train.shape}")
print(f"rozmiar zakodowanej kolumny brand na danych testowych to: {brand_transformed_test.shape}")
print(brand_transformed_train)
rozmiar zakodowanej kolumny brand na danych treningowych to: (750, 10)
rozmiar zakodowanej kolumny brand na danych testowych to: (250, 10)
[[0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 1. 0. ... 0. 0. 0.]
 ...
 [0. 0. 0. ... 1. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]]

jak widzimy kolumna brand reprezentowana jest teraz jako array gdzie każdy atrybut ze zbioru treningowego zaprezentowany jest w osobnej kolumnie binarnej. Można to sprawdzić zliczając liczbę unikalnych atrybutów tej kolumny na zbiorze treningowym

X_train.brand.nunique()
10

kolejność atrybutów odpowiadających kolumnom wynikowego arraya można zobaczyć korzystając z atrybutu categories_

ohe.categories_
[array(['Audi', 'BMW', 'Bugatti', 'Fiat', 'Mercedes', 'Renault', 'Seat',
        'Skoda', 'Toyota', 'VW'], dtype=object)]

całą transformację wraz z dodaniem przetransformowanej zmiennej do zbioru treningowego można zrealizować za pomocą prostej funkcji

def OneHotEncode(X_train, X_test, encoded_column_name, **encoder_kwargs):
    
    ohe = OneHotEncoder(**encoder_kwargs)
    ohe.fit(X_train[encoded_column_name].values.reshape(-1, 1))
    transformed_train = ohe.transform(X_train[encoded_column_name].values.reshape(-1, 1))
    transformed_test = ohe.transform(X_test[encoded_column_name].values.reshape(-1, 1))
    
    column_names = [encoded_column_name +"_"+category for category in list(ohe.categories_[0]) ]
    df_transformed_train = pd.DataFrame(transformed_train, columns = column_names)
    df_transformed_test = pd.DataFrame(transformed_test, columns = column_names)
    
    df_out_train = X_train.reset_index(drop=True).drop([encoded_column_name], axis=1)
    df_out_test = X_test.reset_index(drop=True).drop([encoded_column_name], axis=1)
    df_out_train = pd.concat([df_out_train, df_transformed_train],axis=1)
    df_out_test = pd.concat([df_out_test, df_transformed_test],axis=1)
    
    return df_out_train, df_out_test

powyższa funkcja zwraca zbiór treningowy i testowy po dodaniu odpowiednio nazwanych kolumn powstałych po transformacji obiektem OneHotEncoder i usunięciu pierwotnej zmiennej. Argumenty do OneHotEncoder przekazywane są z pomocą **encoder_kwargs

X_train_ohe, X_test_ohe = OneHotEncode(X_train, X_test, "brand", handle_unknown="ignore",sparse=False)
X_train_ohe.head()
selling_price mileage year_manufactured condition_transformed brand_Audi brand_BMW brand_Bugatti brand_Fiat brand_Mercedes brand_Renault brand_Seat brand_Skoda brand_Toyota brand_VW
0 9614.0 372672 1972 3.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0
1 10467.0 91527 1970 3.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0
2 26411.0 246350 1994 2.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
3 27586.0 596233 1955 3.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
4 37872.0 112234 2009 4.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Info

Alternatywnie zamiast OneHotEncoder można wykorzystać funkcję get_dummies z biblioteki pandas, która jest nieco prostsza w użyciu, jednak OneHotEncoder jest lepiej dostosowany do obsługi niewidzianych wartości a ponadto znacznie łatwiej go zastosować w ramach pipeline

jak widzimy nawet przy tak niskiej liczbie atrybutów wymiarowość naszych danych znacznie wzrosła. W prawdziwych zastosowaniach możemy się spotkać ze zbiorami danych mającymi wiele zmiennych kategorycznych o dziesiątkach lub setkach unikalnych atrybutów, więc metodę OneHotEncoder zaleca się stosować tylko tam, gdzie atrybuty są niezbyt liczne, w pozostałych sytuacjach lepiej skorzystać z kodowania zmienną celu

Definicja

kodowanie zmienną celu (ang. Target encoding) - reprezentacja każdego atrybutu kodowanej zmiennej jako estymata średniej wartości zmiennej celu na danych treningowych

Kodowanie zmienną celu stanowi jedną z najbardziej uniwersalnych metod kodowania zmiennych kategorycznych, nie wymaga naturalnego porządku w danych jak kodowanie porządkowe i nie zwiększa wymiarowości danych jak kodowanie 1 z n.

Istnieje wiele różnych sposobów kodowania zmiennej celu, my tutaj posłużymy się metodą James-Stein Encoder dostępną w bibliotece category_encoders

Definicja

Formuła na zakodowaną wartość k-tego atrybutu kodowanej zmiennej: $\( X_{k} = (1-B) *Avg(Y_{k}) +B*Avg(Y) \)$

gdzie:

\(X_{k}\) - wartość przypisana k-temu atrybutowi kodowanej zmiennej kategorycznej

\(Avg(Y_{k})\) - średnia wartości zmiennej celu dla k-tego atrybutu zmiennej kategorycznej

\(Avg(Y)\) - globalna średnia zmiennej celu na zbiorze treningowym

\(B\) -waga globalnej średniej, wyliczona według formuły: $\( B = \frac{Var(Y_{k})}{Var(Y) + Var(Y_{k})} \)$ gdzie:

\(Var(Y_{k})\) - wariancja zmiennej celu dla k-tego atrybutu zmiennej kategorycznej

\(Var(Y)\) - wariancja zmiennej celu dla całego zbioru treningowego

importujemy potrzebną klasę

from category_encoders.james_stein import JamesSteinEncoder

tworzymy obiekt klasy JamesSteinEncoder, wybierając kolumnę brand do przekształcenia, domyślnie przekształcone zostaną wszystkie kolumny kategoryczne

jse = JamesSteinEncoder(cols=["brand"])

tak jak w transformerach z biblioteki sklearn także tutaj posługujemy się metodami fit i transform, jednak jako, że jest to kodowanie zmienną celu niezbędne jest jej podanie do metody fit, dlatego najpierw wydzielimy zmienną celu

y_train = X_train.selling_price
y_test = X_test.selling_price
X_train = X_train.drop("selling_price",axis=1)
X_test = X_test.drop("selling_price",axis=1)
jse.fit(X_train,y_train)
X_train_jse = jse.transform(X_train)
X_test_jse = jse.transform(X_test)
X_train_jse.head()
mileage brand year_manufactured condition_transformed
82 372672 9616.303599 1972 3.0
991 91527 10130.350200 1970 3.0
789 246350 29876.349281 1994 2.0
894 596233 28614.354954 1955 3.0
398 112234 29876.349281 2009 4.0

jak widzimy zmienna brand została teraz zastąpiona estymatami średniej wartości zmiennej celu w zależności od brandu

Łączenie atrybutów cech kategorycznych

Oprócz standardowych metod transformacji zmiennych kategorycznych opisanych w poprzedniej sekcji można też stosować różne przekształcenia polegające na łączeniu pierwotnych atrybutów w podgrupy. Takie łączenie może być oparte na podobieństwie atrybutów pod katęm statystycznym np. zbliżony poziom średniej wartości zmiennej celu lub, co bardziej zalecane - na wiedzy domenowej.

Tutaj zaprezentujemy prosty przykład łączenia atrybutów oparty na wiedzy domenowej - załóżmy, że znamy podział wszystkich marek samochodów ze zmiennej brand na 3 relatywnie jednolite podgrupy - marki podstawowe, marki premium i marki luksusowe. Na tej podstawie stworzymy nową zmienną korzystając z prostej funkcji:

def brand_binning(brand):
    result ="standard"
    standard_brands = ["Fiat","Renault","VW", "Seat", "Skoda","Toyota"]
    premium_brands = ["Audi","BMW","Mercedes"]
    luxury_brands = ["Bugatti"]
    if brand in luxury_brands:
        result = "luxury"
    elif brand in premium_brands:
        result = "premium"
    return result
X_train["brand_binned"] = X_train["brand"].map(lambda x:brand_binning(x))
X_test["brand_binned"] = X_test["brand"].map(lambda x:brand_binning(x))
X_train.head()
mileage brand year_manufactured condition_transformed brand_binned
82 372672 Renault 1972 3.0 standard
991 91527 Seat 1970 3.0 standard
789 246350 BMW 1994 2.0 premium
894 596233 Audi 1955 3.0 premium
398 112234 BMW 2009 4.0 premium

możemy też sprawdzić jak wygląda unikalne przypisanie pomiędzy pierwotną a zgrupowaną zmienną:

X_train[["brand","brand_binned"]].drop_duplicates().sort_values(by="brand_binned")
brand brand_binned
519 Bugatti luxury
789 BMW premium
894 Audi premium
5 Mercedes premium
82 Renault standard
991 Seat standard
323 VW standard
922 Fiat standard
97 Toyota standard
7 Skoda standard

jak widzimy przypisanie zostało przeprowadzone poprawnie, nowa zmienna niesie mniej informacji niż zmienna pierwotna, dlatego zastąpienie nią pierwotnej zmiennej może być pomocne jeśli mamy problem z przeuczeniem modelu. Oczywiście tak powstałą zmienną należy potem jeszcze przetransformować zgodnie z wytycznymi z poprzedniej sekcji.

Cechy oparte na wiedzy domenowej oraz cechy interakcji

Zaprezentowane powyżej grupowanie marek polegało na przekształceniu zmiennej kategorycznej w oparciu o wiedzę domenową. Tutaj pokażemy przykłady zastosowania wiedzy domenowej na zmiennych numerycznych i interakcji cech numerycznych i katgorycznych.

Przykładem prostej cechy opartej na wiedzy domenowej może być wiek samochodu w momencie sprzedaży. Nie mamy tutaj informacji o dacie transakcji, dlatego można dla ułatwienia założyć, że wszystkie transakcje odbyły się w bieżącym roku.

X_train["age"] = 2021 - X_train["year_manufactured"]
X_test["age"] = 2021 - X_test["year_manufactured"]
X_train
mileage brand year_manufactured condition_transformed brand_binned age
82 372672 Renault 1972 3.0 standard 49
991 91527 Seat 1970 3.0 standard 51
789 246350 BMW 1994 2.0 premium 27
894 596233 Audi 1955 3.0 premium 66
398 112234 BMW 2009 4.0 premium 12
... ... ... ... ... ... ...
106 31878 Bugatti 2015 3.0 luxury 6
270 187503 Fiat 1951 2.0 standard 70
860 318814 Skoda 1987 1.0 standard 34
435 103451 Mercedes 2003 3.0 premium 18
102 53915 Renault 2011 1.0 standard 10

750 rows × 6 columns

Zmienne interakcji można wygenerować stosując np. iloczyny poszczególnych zmiennych numerycznych, tutaj wydaje się to nie być najlepszy pomysł, ponieważ intuicyjnie czujemy, że np mnożenie przebiegu * rok produkcji nie będzie zbyt dobrą cechą predykcyjną. Można natomiast wyliczyć np średni roczny przebieg dzieląc przebieg przez dodany powyżej wiek samochodu.

X_train["avg_yearly_mileage"] = np.round(X_train["mileage"]/X_train["age"])
X_test["avg_yearly_mileage"] = np.round(X_test["mileage"]/X_test["age"])
X_train
mileage brand year_manufactured condition_transformed brand_binned age avg_yearly_mileage
82 372672 Renault 1972 3.0 standard 49 7606.0
991 91527 Seat 1970 3.0 standard 51 1795.0
789 246350 BMW 1994 2.0 premium 27 9124.0
894 596233 Audi 1955 3.0 premium 66 9034.0
398 112234 BMW 2009 4.0 premium 12 9353.0
... ... ... ... ... ... ... ...
106 31878 Bugatti 2015 3.0 luxury 6 5313.0
270 187503 Fiat 1951 2.0 standard 70 2679.0
860 318814 Skoda 1987 1.0 standard 34 9377.0
435 103451 Mercedes 2003 3.0 premium 18 5747.0
102 53915 Renault 2011 1.0 standard 10 5392.0

750 rows × 7 columns

Innym przykładem zmiennej opartej stricte na wiedzy domenowej łączecej ze sobą informacje ze zmiennych numerycznych i kategorycznych mogła by być informacja o klasycznych modelach. Załóżmy, że modele luksusowych marek wyprodukowane przed 1970 rokiem są modelami klasycznymi i jeśli ich stan jest co najmniej dobry to ich cena jest znacznie wyższa niż by to wynikało z wieku auta, gdzie normalnie spodziewamy się ujemnej relacji z ceną sprzedaży.

X_train.loc[(X_train.brand_binned=="luxury")&(X_train.year_manufactured<=1970)&(X_train.condition_transformed>2.0),"is_classic"]=1
X_train["is_classic"] = X_train["is_classic"].fillna(0)
X_test.loc[(X_test.brand_binned=="luxury")&(X_test.year_manufactured<=1970)&(X_test.condition_transformed>2.0),"is_classic"]=1
X_test["is_classic"] = X_test["is_classic"].fillna(0)
X_train["is_classic"].value_counts()
0.0    742
1.0      8
Name: is_classic, dtype: int64

Takie przypadki są stosunkowo rzadkie, ale tego typu zmienna może pomóc w dokładniejszym przewidzeniu tych obserwacji.

Cechy oparte na transformacji zmiennych numerycznych

Dość powszechnie spotykaną praktyką jest stosowanie nieliniowych transformacji zmiennych numerycznych, co zazwyczaj sprzyja uzyskaniu lepszej jakości predykcji ze względu na fakt, że modele najlepiej radzą sobie gdy rozkłady zmiennych są zbliżone do rozkładu normalnego.

Często spotykaną jest po prostu logarytmowanie zmiennych numerycznych, tutaj natomiast posłużymy się gotowym transformerem z biblioteki sklearn, a mianowicie PowerTransformer

from sklearn.preprocessing import PowerTransformer

zanim dokonamy transformacji sprawdźmy jak wyglądają rozkłady zmiennych numerycznych, ograniczymy się tutaj do przebiegu, wieku i średniego przebiegu

num_columns = ["mileage", "age","avg_yearly_mileage"]
X_train[num_columns].hist(figsize=(15,9), bins=30)
array([[<AxesSubplot:title={'center':'mileage'}>,
        <AxesSubplot:title={'center':'age'}>],
       [<AxesSubplot:title={'center':'avg_yearly_mileage'}>,
        <AxesSubplot:>]], dtype=object)
../_images/6_Inzynieria cech_102_1.png

można tu zaobserwować, że rozkład przebiegu jest silnie skośny, pozostałe 2 zmienne mają rozkład zbliżony do jednostajnego

pt=PowerTransformer(standardize=False)

tworzymy obiekt klasy PowerTransformer, korzystamy tutaj z domyślnej transformacji Yeo-Johnson, zmieniamy natomiast ustawienie o sprowadzeniu do rozkładu o średniej równej 0 i odchyleniu standardowym równym 1, które domyślnie jest ustawione na True

Info

Domyślna transformacja czyli metoda Yeo-Johnsona może być stosowana niezależnie od znaku transformowanych zmiennych, natomiast alternatywna transformacja Box-Cox wymaga ściśle dodatnich zmiennych na wejściu.

pt.fit(X_train[num_columns])
X_train_num_transformed = pd.DataFrame(pt.transform(X_train[num_columns]), columns =num_columns)
X_test_num_transformed =  pd.DataFrame(pt.transform(X_test[num_columns]), columns =num_columns)

analogicznie jak przy poprzednio stosowanych transformerach korzystamy tutaj z metod fit i transform, a następnie konwertujemy wynik na ramkę danych.

Przeanalizujmy jak mocno zmieniły się rozkłady poszczególnych zmiennych:

X_train_num_transformed.hist(figsize=(15,9), bins=30)
array([[<AxesSubplot:title={'center':'mileage'}>,
        <AxesSubplot:title={'center':'age'}>],
       [<AxesSubplot:title={'center':'avg_yearly_mileage'}>,
        <AxesSubplot:>]], dtype=object)
../_images/6_Inzynieria cech_110_1.png
X_test_num_transformed.hist(figsize=(15,9), bins=30)
array([[<AxesSubplot:title={'center':'mileage'}>,
        <AxesSubplot:title={'center':'age'}>],
       [<AxesSubplot:title={'center':'avg_yearly_mileage'}>,
        <AxesSubplot:>]], dtype=object)
../_images/6_Inzynieria cech_111_1.png

Zmienna mileage na zbiorze treningowym z silnie skośnej nabrała cech rozkładu normalnego, pozostałe przypadki nie wyglądają jednak na rozkład normalny.

Wypróbujmy inny sposób modyfikacji rozkładu - QuantileTransformer

from sklearn.preprocessing import QuantileTransformer

tworzymy obiekt klasy QuantileTransformer, zamiast bazowego rozkładu jednostajnego wybierając rozkład normalny

qt = QuantileTransformer(output_distribution="normal")
qt.fit(X_train[num_columns])
X_train_num_transformed = pd.DataFrame(qt.transform(X_train[num_columns]), columns =num_columns)
X_test_num_transformed =  pd.DataFrame(qt.transform(X_test[num_columns]), columns =num_columns)
C:\Users\knajmajer\.conda\envs\politechnika\lib\site-packages\sklearn\preprocessing\_data.py:2612: UserWarning: n_quantiles (1000) is greater than the total number of samples (750). n_quantiles is set to n_samples.
  warnings.warn("n_quantiles (%s) is greater than the total number "
X_train_num_transformed.hist(figsize=(15,9), bins=30)
array([[<AxesSubplot:title={'center':'mileage'}>,
        <AxesSubplot:title={'center':'age'}>],
       [<AxesSubplot:title={'center':'avg_yearly_mileage'}>,
        <AxesSubplot:>]], dtype=object)
../_images/6_Inzynieria cech_117_1.png
X_test_num_transformed.hist(figsize=(15,9), bins=30)
array([[<AxesSubplot:title={'center':'mileage'}>,
        <AxesSubplot:title={'center':'age'}>],
       [<AxesSubplot:title={'center':'avg_yearly_mileage'}>,
        <AxesSubplot:>]], dtype=object)
../_images/6_Inzynieria cech_118_1.png

Jak widzimy rozkłady są teraz zupełnie inne, na zbiorze treningowym bardzo zbliżone do rozkładu normalnego, jednak na zbiorze testowym juz dość znacznie się różnią, co może wynikać po części z niewielkiego rozmiaru próby.

Info

To jaki dokładnie zestaw transformacji ostatecznie wybrać powinno wynikać przede wszystkim z obserwacji ustalonej wcześniej metryki jakości modelu.

Łączenie zmiennych numerycznych w przedziały

podobnie jak w przypadku zmiennych kategorycznych, zmienne numeryczne również można przekształcać grupując razem pewne ich wartości. Proces taki nazywamy kubełkowaniem.

Definicja

Kubełkowanie (ang. binning) polega na łączeniu wartości zmiennych numerycznych w przedziały, ma to na celu zmniejszenie możliwości przeuczenia modelu.

Wybór takich przedziałów może występować zarówno na podstawie cech rozkładu zmiennych (np wartości kwartyli) jak i wiedzy domenowej, tutaj zaprezentujemy obie możliwości.

do podziału wg statystyk pozycyjnych z rozkładu wykorzystamy funkcję qcut z biblioteki pandas

X_train["mileage_binned_4"] = pd.qcut(X_train["mileage"], q=4)
X_train["mileage_binned_10"] = pd.qcut(X_train["mileage"], q=10)
X_train[["mileage","mileage_binned_4","mileage_binned_10"]]
mileage mileage_binned_4 mileage_binned_10
82 372672 (282680.75, 681205.0] (322830.8, 414821.3]
991 91527 (64578.5, 151917.5] (83551.2, 111968.8]
789 246350 (151917.5, 282680.75] (195449.8, 246451.1]
894 596233 (282680.75, 681205.0] (414821.3, 681205.0]
398 112234 (64578.5, 151917.5] (111968.8, 151917.5]
... ... ... ...
106 31878 (5120.999, 64578.5] (25335.4, 52790.0]
270 187503 (151917.5, 282680.75] (151917.5, 195449.8]
860 318814 (282680.75, 681205.0] (246451.1, 322830.8]
435 103451 (64578.5, 151917.5] (83551.2, 111968.8]
102 53915 (5120.999, 64578.5] (52790.0, 83551.2]

750 rows × 3 columns

Stworzyliśmy tutaj 2 nowe cechy na zbiorze treningowym w oparciu o kwartyle i decyle rozkładu zmiennej mileage. Jak widzimy poszczególnym wartościom przypisane zostały wartości przedziałów do których one wpadają w danym podziale na kubełki.

jeśli chcemy samodzielnie określić etykiety przedziałów można posłużyć się parametrem labels

X_train["mileage_binned_4"] = pd.qcut(X_train["mileage"], q=4, labels=np.arange(1,5))
X_train["mileage_binned_10"] = pd.qcut(X_train["mileage"], q=10, labels=np.arange(1,11))
X_train[["mileage","mileage_binned_4","mileage_binned_10"]]
mileage mileage_binned_4 mileage_binned_10
82 372672 4 9
991 91527 2 4
789 246350 3 7
894 596233 4 10
398 112234 2 5
... ... ... ...
106 31878 1 2
270 187503 3 6
860 318814 4 8
435 103451 2 4
102 53915 1 3

750 rows × 3 columns

Uwaga!

Wszystkie przekształcenia wykorzystujące informacje o rozkładach cech należy implementować najpierw na zbiorze treningowym, a następnie w oparciu o rozkład ze zbioru treningowego - na zbiorze testowym. Inaczej wykorzystujemy informacje ze zbioru testowego i przestaje on być niezależny.

tutaj możemy skorzystać z parametru retbins i dostać granice kubełków a następnie w oparciu o te granice dokonać identycznego podziału na zbiorze testowym

_, bins = pd.qcut(X_train["mileage"], q=4, retbins=True)
bins
array([  5121.  ,  64578.5 , 151917.5 , 282680.75, 681205.  ])

alternatywnym podejściem jest użycie funkcji cut z biblioteki pandas, która pozwala podzielić wartości zmiennej na zdefiniowaną liczbę przedziałów o identycznej szerokości lub przypisać je do zdefiniowanych samodzielnie przedziałów

załóżmy, że jeśli chodzi o wiek samochodu znaczenie mają przedziały do 3 lat, od 3 do 7, od 7 do 12, 12-25 oraz ponad 25. Możemy łatwo dokonać takiego przypisania przydzielając poszczególnym kubełkom odpowiednie nazwy:

X_train["age_binned"] = pd.cut(X_train["age"],[0,3,7,12,25,100], labels =["new","middle_age","old","very_old","extremely_old"] )
X_train[["age","age_binned"]]
age age_binned
82 49 extremely_old
991 51 extremely_old
789 27 extremely_old
894 66 extremely_old
398 12 old
... ... ...
106 6 middle_age
270 70 extremely_old
860 34 extremely_old
435 18 very_old
102 10 old

750 rows × 2 columns

Normalizacja i Standaryzacja

Załóżmy, że finalnie mamy zbiór danych złożony stricte ze zmiennych numerycznych:

X_train = X_train.loc[:,["mileage","condition_transformed", "age","avg_yearly_mileage","is_classic"]]
X_test = X_test.loc[:,["mileage","condition_transformed", "age","avg_yearly_mileage","is_classic"]]
X_train
mileage condition_transformed age avg_yearly_mileage is_classic
82 372672 3.0 49 7606.0 0.0
991 91527 3.0 51 1795.0 0.0
789 246350 2.0 27 9124.0 0.0
894 596233 3.0 66 9034.0 0.0
398 112234 4.0 12 9353.0 0.0
... ... ... ... ... ...
106 31878 3.0 6 5313.0 0.0
270 187503 2.0 70 2679.0 0.0
860 318814 1.0 34 9377.0 0.0
435 103451 3.0 18 5747.0 0.0
102 53915 1.0 10 5392.0 0.0

750 rows × 5 columns

możemy łatwo zaobserwować, że zmienne znacznie różnią się pod względem średniej czy wariancji:

np.round(X_train.describe(),2)
mileage condition_transformed age avg_yearly_mileage is_classic
count 750.00 750.00 750.00 750.00 750.00
mean 190401.90 2.47 37.03 5340.15 0.01
std 151902.42 1.10 20.12 2822.90 0.10
min 5121.00 0.00 2.00 176.00 0.00
25% 64578.50 2.00 20.00 2986.50 0.00
50% 151917.50 3.00 36.00 5391.00 0.00
75% 282680.75 3.00 54.75 7639.50 0.00
max 681205.00 4.00 71.00 12628.00 1.00

Niektóre metody modelowania są zależne od odległości bądź wariancji zmiennych, więc chcąc aby wpływ danej zmiennej na predykcje wynikał przede wszystkim z jej związku ze zmienną celu, a nie ze skali, powinniśmy zastosować normalizację lub standaryzację

zacznijmy od normalizacji, zrealizujemy ją za pomocą kolejnego transformera z biblioteki sklearn: MinMaxScaler

Definicja

Normalizacja polega na przekształcaniu zmiennej do zakresu wartości <0,1> poprzez odjęcie minumum i podzielenie przez różnicę pomiędzy maksimum a minimum z rozkładu.

from sklearn.preprocessing import MinMaxScaler

tworzymy obiekt klasy MinMaxScaler korzystając z bazowych ustawień, możliwa jest zmiana docelowego zakresu wartości z bazowego <0,1> na dowolnie wybrany poprzez użycie parametru feature_range

mm = MinMaxScaler()

Metody są oczywiście analogiczne jak w poprzednich przypadkach, konwertujemy wyniki z powrotem na ramki danych aby łatwiej było je oglądać

mm.fit(X_train)
X_train_mm_scaled = pd.DataFrame(mm.transform(X_train), columns=X_train.columns)
X_test_mm_scaled = pd.DataFrame(mm.transform(X_test), columns=X_test.columns)
np.round(X_train_mm_scaled.describe(),2)
mileage condition_transformed age avg_yearly_mileage is_classic
count 750.00 750.00 750.00 750.00 750.00
mean 0.27 0.62 0.51 0.41 0.01
std 0.22 0.27 0.29 0.23 0.10
min 0.00 0.00 0.00 0.00 0.00
25% 0.09 0.50 0.26 0.23 0.00
50% 0.22 0.75 0.49 0.42 0.00
75% 0.41 0.75 0.76 0.60 0.00
max 1.00 1.00 1.00 1.00 1.00

jak widzimy wszystkie cechy zostały teraz przeniesione do tego samego zakresu wartości <0,1>, ale ich średnia i odchylenie standardowe nie są identyczne. Oczywiście nie mamy gwarancji, że na nowych danych nie pojawią się wartości spoza wykresu widzianego na zbiorze treningowym. Jeśli chcemy być pewni, że znormalizowane zmienne zachowają swój zakres wartości należy tworząc obiekt klasy MinMaxScaler ustawić parametr clip na True.

Uwaga!

Nie zaleca się korzystania z MinMaxScaler dla zmiennych posiadających wartości odstające. Jeśli takie wartości nie zostaną wcześniej prawidłowo obsłużone, to większość “normalnych” wartości zmiennej będzie “upchana” w małym zakresie wartości co nie będzie sprzyjać jej wartości dla predykcji.

zaprezentujemy teraz przykład standaryzacji z wykorzystaniem transformera StandardScaler z biblioteki sklearn

Definicja

Standaryzacja polega na przekształcaniu zmiennej do rozkładu o wartości oczekiwanej 0 i odchyleniu standardowym 1 poprzez odjęcie średniej i podzielenie przez odchylenie standardowe

from sklearn.preprocessing import StandardScaler

tworzymy obiekt klasy StandardScaler korzystając z bazowych ustawień

ss =StandardScaler()

Metody rownież są analogiczne jak w poprzednich przypadkach, konwertujemy wyniki z powrotem na ramki danych aby łatwiej było je oglądać

ss.fit(X_train)
X_train_ss_scaled = pd.DataFrame(ss.transform(X_train), columns=X_train.columns)
X_test_ss_scaled = pd.DataFrame(ss.transform(X_test), columns=X_test.columns)
np.round(X_train_ss_scaled.describe(),2)
mileage condition_transformed age avg_yearly_mileage is_classic
count 750.00 750.00 750.00 750.00 750.00
mean -0.00 -0.00 0.00 -0.00 -0.00
std 1.00 1.00 1.00 1.00 1.00
min -1.22 -2.25 -1.74 -1.83 -0.10
25% -0.83 -0.43 -0.85 -0.83 -0.10
50% -0.25 0.49 -0.05 0.02 -0.10
75% 0.61 0.49 0.88 0.82 -0.10
max 3.23 1.40 1.69 2.58 9.63

W przeciwieństwie do poprzedniego przekształcenia zmienne mają identyczną średnią i odchylenie standardowe, za to różnią sie zakresami wartości.

Info

Jako że StandardScaler opiera się na użyciu średniej i odchylenia standardowego z transformowanej zmiennej, również nie jest on wolny od wpływu wartości odstających. Jeśli chcemy być pewni, że wartości odstające nie będą miały wpływu na skalę przekształconej zmiennej można skorzystać z klasy RobustScaler

Łączenie różnych transformacji w ramach pipeline

cofnijmy się teraz na chwilę do etapu generowania danych i zobaczmy jak można w łatwy i niezawodny sposób łączyć ze sobą różne transformacje z wykorzystaniem obiektów Pipeline oraz ColumnTransformer

Definicja

Pipeline jest to przepływ danych przez ułożone w kolejności moduły wykonujące ustalone transformacje, zazwyczaj ostatnim elementem jest model predykcyjny

UsedCars_df =generate_used_cars_data()
X = UsedCars_df.drop("selling_price",axis=1)
y = UsedCars_df["selling_price"]
X["age"] = 2021 - X["year_manufactured"]
X.drop("year_manufactured", axis=1, inplace=True)
X_train, X_test, y_train, y_test = train_test_split(X,y, test_size=0.25, random_state=42)
X_train
condition mileage brand age
82 bad 115046 BMW 63
991 good 131589 Renault 21
789 good 307303 Renault 35
894 very_good 23757 Skoda 3
398 good 358893 Skoda 50
... ... ... ... ...
106 very_bad 184449 Toyota 32
270 medium 406184 Fiat 50
860 very_good 317414 Toyota 56
435 very_good 406974 Toyota 54
102 medium 138846 Mercedes 25

750 rows × 4 columns

zdefiniujmy teraz grupy zmiennych, które będziemy poddawać poszczególnym transformacjom:

columns_for_ordinal_encoding =["condition"]
columns_for_target_encoding =["brand"]
numerical_columns =["age","mileage"]

następnie importujemy niezbędne klasy

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

definiujemy pipeline do przetwarzania poszczególnych grup kolumn, pipeline mogą zawierać wiele kroków, tutaj dla uproszczenia wykorzystamy jednoelementowe

pipeline_oe = Pipeline(steps =[("OrdinalEncoder",
                            OrdinalEncoder(
                               categories =[['very_bad', 'bad', 'medium', 'good', 'very_good']],
                               handle_unknown ='use_encoded_value', unknown_value=np.NaN))])

pipeline_jse = Pipeline(steps=[("JamesSteinEncoder",JamesSteinEncoder())])

pipeline_num = Pipeline(steps=[("PowerTransformer",PowerTransformer(standardize=False))])

następnie przypisujemy zmienne do poszczególnych transformacji, korzystając z obiektu ColumnTransformer

column_transformer = ColumnTransformer(
                        transformers=[
                         ('categorical_oe', pipeline_oe, columns_for_ordinal_encoding),
                         ('categorical_jse', pipeline_jse, columns_for_target_encoding),
                         ('numerical', pipeline_num, numerical_columns)
                         ])

sam ColumnTransformer również może być częścią pipeline, przykładowo możemy na koniec zastosować standaryzacje

preprocessing_pipeline = Pipeline(steps = [
                        ("column_transformer", column_transformer),
                        ("scaler",StandardScaler())
                    ])

jeśli używamy pipeline bez modelu predykcyjnego na końcu to stosujemy te same metody co przy zwykłych transformerach

preprocessing_pipeline.fit(X_train,y_train)
Pipeline(steps=[('column_transformer',
                 ColumnTransformer(transformers=[('categorical_oe',
                                                  Pipeline(steps=[('OrdinalEncoder',
                                                                   OrdinalEncoder(categories=[['very_bad',
                                                                                               'bad',
                                                                                               'medium',
                                                                                               'good',
                                                                                               'very_good']],
                                                                                  handle_unknown='use_encoded_value',
                                                                                  unknown_value=nan))]),
                                                  ['condition']),
                                                 ('categorical_jse',
                                                  Pipeline(steps=[('JamesSteinEncoder',
                                                                   JamesSteinEncoder())]),
                                                  ['brand']),
                                                 ('numerical',
                                                  Pipeline(steps=[('PowerTransformer',
                                                                   PowerTransformer(standardize=False))]),
                                                  ['age', 'mileage'])])),
                ('scaler', StandardScaler())])

po uruchomieniu metody fit widzimy wszystkie kroki całego pipeline

X_train_transformed = preprocessing_pipeline.transform(X_train)
X_test_transformed = preprocessing_pipeline.transform(X_test)
print(f"rozmiar zbioru treningowego po transformacji: {X_train_transformed.shape}")
print(f"rozmiar zbioru testowego po transformacji: {X_test_transformed.shape}")
print(X_train_transformed)
rozmiar zbioru treningowego po transformacji: (750, 4)
rozmiar zbioru testowego po transformacji: (250, 4)
[[-1.40548077  0.76716719  1.25110117 -0.25251526]
 [ 0.44383603 -0.64800124 -0.7479358  -0.11984719]
 [ 0.44383603 -0.64800124 -0.01971012  0.8442581 ]
 ...
 [ 1.36849443 -0.70496567  0.949548    0.88581833]
 [ 1.36849443 -0.70496567  0.86170703  1.21784742]
 [-0.48082237  0.74069968 -0.5302968  -0.06541736]]

jak widzimy zwrócony zbiór treningowy ma typ danych array a jego wymiary odpowiadają tym przed transformacją, zgodność wymiarów zależy jednak od zastosowanych transformacji

jeśli pipeline jest zakończony modelem predykcyjnym jego metody są identyczne jak metody modelu, czyli korzystamy z fit i predict, tak jak w poniższym przykładzie

from sklearn.linear_model import LinearRegression

final_pipeline = Pipeline(steps = [
                ("preprocessing", preprocessing_pipeline),
                ("model", LinearRegression())
                ])

poniżej pokazujemy jak wytrenować pipeline w oparciu o zbiór treningowy a następnie dokonać predykcji na zbiorze testowym

final_pipeline.fit(X_train, y_train)
X_test_predicted = pd.Series(final_pipeline.predict(X_test),name="prediction")
X_test_predicted.head()
0    13564.916529
1    26946.786722
2    44621.855493
3    76340.676086
4    87779.341552
Name: prediction, dtype: float64

Podsumowanie

Powyżej zaprezentowaliśmy kilka różnych przykładów przekształcania surowych danych w cechy predykcyjne przydatne przy modelowaniu. Niektóre transformacje można z powodzeniem stosować przy pracy nad innymi problemami, inne należy raczej potraktować jako inspirację. Ważne aby prace nad Feature Engineering poprzedzone były dobrym zrozumieniem danych. Niezwykle ważne jest tutaj aby już na tym etapie stosować odpowiednią strategię walidacyjną, aby zapobiec korzystaniu z informacji ze zbioru testowego, inaczej nasze wyniki nie będą miarodajne.