Seznam příznaků:

  • survived - zda pasažér přežil, 0 = Ne, 1 = Ano, vysvětlovaná proměnná, kterou chcete predikovat
  • pclass - Třída lodního lístku, 1 = první, 2 = druhá, 3 = třetí
  • name - jméno
  • sex - pohlaví
  • age - věk v letech
  • sibsp - počet sourozenců / manželů, manželek na palubě
  • parch - počet rodičů / dětí na palubě
  • ticket - číslo lodního lístku
  • fare - cena lodního lístku
  • cabin - číslo kajuty
  • embarked - místo nalodění, C = Cherbourg, Q = Queenstown, S = Southampton
  • home.dest - Bydliště/Cíl

Import knihoven

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import f1_score, auc, roc_curve, RocCurveDisplay, confusion_matrix, ConfusionMatrixDisplay
from sklearn.model_selection import train_test_split, GridSearchCV, PredefinedSplit
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import OrdinalEncoder, OneHotEncoder, FunctionTransformer, MinMaxScaler
  • Random seed zafixuji pro konzistentní výsledky buněk pro každé volání celého jupyter notebooku.
    • Nežádoucí efekt zvoleného seedu 42 je okomentován v závěru.
  • Float hodnoty v pandas tabulkách zobrazuji na max 3 desetinná místa.
np.random.seed(42)
pd.set_option('display.precision', 3)

Předzpracování dat

  • V prvním kroku nahrávám data a pomocí vhodných metod data zkoumám:
    • Vypisuji velikosti dat a informace o sloupcích (datové typy a chybějící hodnoty).
    • Zobrazuji počet unikátních hodnot v jednotlivých příznacích a zkoumám, kterých hodnot vysvětlovaná proměnná nabývá.
data = pd.read_csv('data.csv')
display(data.shape)
display(data.head())
display(data.info())
display(data.nunique())
display(data['survived'].value_counts())
(1000, 13)
ID survived pclass name sex age sibsp parch ticket fare cabin embarked home.dest
0 0 0 3 Goodwin, Master. William Frederick male 11.0 5 2 CA 2144 46.900 NaN S Wiltshire, England Niagara Falls, NY
1 1 0 3 Jardin, Mr. Jose Neto male NaN 0 0 SOTON/O.Q. 3101305 7.050 NaN S NaN
2 2 0 3 Skoog, Master. Harald male 4.0 3 2 347088 27.900 NaN S NaN
3 3 1 3 O'Brien, Mrs. Thomas (Johanna "Hannah" Godfrey) female NaN 1 0 370365 15.500 NaN Q NaN
4 4 1 3 Abrahim, Mrs. Joseph (Sophie Halaut Easu) female 18.0 0 0 2657 7.229 NaN C Greensburg, PA
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000 entries, 0 to 999
Data columns (total 13 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   ID         1000 non-null   int64  
 1   survived   1000 non-null   int64  
 2   pclass     1000 non-null   int64  
 3   name       1000 non-null   object 
 4   sex        1000 non-null   object 
 5   age        802 non-null    float64
 6   sibsp      1000 non-null   int64  
 7   parch      1000 non-null   int64  
 8   ticket     1000 non-null   object 
 9   fare       999 non-null    float64
 10  cabin      233 non-null    object 
 11  embarked   998 non-null    object 
 12  home.dest  566 non-null    object 
dtypes: float64(2), int64(5), object(6)
memory usage: 101.7+ KB
None
ID           1000
survived        2
pclass          3
name          999
sex             2
age            95
sibsp           7
parch           8
ticket        746
fare          254
cabin         157
embarked        3
home.dest     313
dtype: int64
survived
0    602
1    398
Name: count, dtype: int64

Rozdělení dat

  • V datasetu je celkem 1000 záznamů, u žádného nechybí hodnota vysvětlované proměnné.
  • V tomto kroku dělím datasety na trénovací, validační a testovací.
X_data = data.drop('survived', axis = 1)
y_data = data['survived']
X_train_val, X_test, y_train_val, y_test  = train_test_split(X_data, y_data, test_size=0.2)
X_train, X_val, y_train, y_val = train_test_split(X_train_val, y_train_val, test_size=0.25)
print(X_train.shape[0], 'train,',
      X_val.shape[0], 'val,',
      X_test.shape[0], 'test')
600 train, 200 val, 200 test

Transformace

  • V následujících buňkách si předpřipravuji seznamy sloupců, kterým přiřadím příznaky podle typu předzpracování:
    • drop_cols - tyto příznaky vypouštím
    • ordinal_cols - kategorická data s přirozeným uspořádáním
    • nominal_cols - kategorická data bez uspořádání
    • dense_num_cols - hustá kvantitativní data (hustá ve smyslu možných hodnot)
    • sparse_num_cols - kvantitativní data s malým počtem nabývajících hodnot
    • list_count_cols - speciální kategorie pro příznak cabin, u kterého sleduji četnost prvků v seznamu

Příznak ID, name, ticket a home.dest

print('ID:\t', X_train['ID'].dropna().tolist()[:10], '...')
print('name:\t', X_train['name'].dropna().tolist()[:2], '...')
print('ticket:\t', X_train['ticket'].dropna().tolist()[:5], '...')
print('ticket:\t', X_train['home.dest'].dropna().tolist()[:3], '...')
ID:	 [155, 253, 586, 776, 661, 540, 435, 388, 77, 516] ...
name:	 ['Ivanoff, Mr. Kanio', 'Gracie, Col. Archibald IV'] ...
ticket:	 ['349201', '113780', 'PC 17585', '367229', '2661'] ...
ticket:	 ['Washington, DC', 'New York, NY', 'London'] ...
X_train[['ID', 'name', 'ticket', 'home.dest']].nunique()
ID           600
name         600
ticket       493
home.dest    214
dtype: int64
  • U příznaků ID, name, ticket a home.dest nepozoruji žádné nulové hodnoty. Příznaky ID a name jsou v celých datech unikátní. Hodnoty v ticket a home.dest se opakují velmi zřídka.
  • Ze jmen by bylo možné vyčíst různé přibuznosti, či vztahy, nicméně tuto informaci mám do jisté míry naměřené v příznacích sibsp a parch.
  • Všechny čtyři příznaky zahazuji - jejich rozlišování by data roztáhla na "mnoho dimenzí" a zároveň odhaduji, že nejsou pro klasifikaci tak zásadní jako ty zbylé.
drop_cols = ['ID', 'name', 'ticket', 'home.dest']

Příznak pclass

X_train['pclass'].value_counts().sort_index()
pclass
1    153
2    132
3    315
Name: count, dtype: int64
  • Sloupec pclass je bez chybějících záznamů a nabývá pouze hodnot: 1, 2, 3.
  • Jedná se o třídy lodního lístku, které má přirozené uspořádání, tedy jedná se o ordinální kategorický příznak.
  • V datech, které mám nahrané nejsou chybějící hodnoty, nicméně obecně při predikci může hodnota příznaku chybět.
    • Bude-li chybět pclass, přiřadím nejfrekvetovanější hodnotu v trénovacích datech.
ordinal_cols = ['pclass']
ordinal_encoder = Pipeline([('ordinal_encoder', OrdinalEncoder(categories=[[1, 2, 3]], handle_unknown='use_encoded_value', unknown_value=np.nan, dtype=np.float64)),
                            ('imputer', SimpleImputer(missing_values=np.nan, strategy='most_frequent'))])

Příznak sex a embark

display(X_train['sex'].value_counts())
display(X_train['embarked'].value_counts())
sex
male      375
female    225
Name: count, dtype: int64
embarked
S    412
C    124
Q     62
Name: count, dtype: int64
  • Příznak sex je bez chybějích hodnot, u příznaku embarked v datasetu chybí dva záznamy.
  • Sex nabývá dvou hodnot: muž a žena. Embarded má až tři kvality: S, C, Q.
  • Jedná se tedy o nominální příznaky bez přirozeného uspořádání.
    • Hodnoty transformuji metodou one-hot-encoding.
    • Chybějícím záznamům přiřadím samé nuly ve všech kategoriích.
nominal_cols = ['sex', 'embarked']
nominal_encoder = Pipeline([('nominal_encoder', OneHotEncoder(handle_unknown='ignore'))])

Příznak cabin

display(X_train['cabin'].value_counts().head())
display(X_train['cabin'].notna().sum())
cabin
C78                3
D                  3
B57 B59 B63 B66    3
G6                 3
B96 B98            3
Name: count, dtype: int64
142
  • Příznak cabin obsahující seznam čísel kajut má mnoho chybějících dat.
  • Rozlišování jednotlivých hodnot by mohlo navýšit rozměry dat až o 142 dimenzí.
  • Nicméně fakt, že pasažér měl kajutu, může mít velký vliv na přežití, proto tento příznak transformuji tak, aby reprezentoval počet přiřazených kajut (chybějící hodnotě přirozeně přiřadím 0 kajut).
def split_series_col(X, sep=' '):
    return X.apply(lambda x: x.split(sep) if type(x) == str else [])

def map_cabin_to_len(X):
    return pd.DataFrame(split_series_col(X['cabin']).str.len())

class ListCountTransformer(FunctionTransformer):
    def get_feature_names_out(self, input_features=None):
        return ['cabin_count']

list_count_encoder = ListCountTransformer(map_cabin_to_len, validate=False)
list_count_cols = ['cabin']

Příznak age a fare

print('age:\t', X_train['age'].dropna().tolist()[:3], '...')
print('fare:\t', X_train['fare'].dropna().tolist()[:3], '...')
age:	 [53.0, 14.0, 22.0] ...
fare:	 [7.8958, 28.5, 79.2] ...
  • Příznaky age a fare jsou kvantitativní hodnoty, které nabývají mnoha různých hodnot.
  • V tomto případě je hodnota v pořádku a není třeba ji nijak transformovat.
  • V případě chybějící hodnoty jen doplním průměrem z trénovacích dat.
dense_num_cols = ['age', 'fare']
dense_num_encoder = Pipeline([('imputer', SimpleImputer(missing_values=np.nan, strategy='mean'))])

Příznak sibsp a parch

print('sibsp:\t', X_train['sibsp'].unique())
print('parch:\t', X_train['parch'].unique())
sibsp:	 [0 1 2 4 8 3 5]
parch:	 [0 2 1 3 5 6 4 9]
  • Příznaky sibsp a parch jsou podobně jako age nebo fare numerické kategorie.
  • Neznámé hodnoty u těchto příznaků nahrazuji mediánem (trénovacích dat)
    • Narozdíl od průměru nezvyšuje počet možných nabývajících hodnot (může pomoct u stromů).
sparse_num_cols = ['sibsp', 'parch']
sparse_num_encoder = Pipeline([('imputer', SimpleImputer(missing_values=np.nan, strategy='median'))])

Aplikování transformací

  • Nyní vytvářím transformaci, kterou naučím na trénovacích datech (počítám pro imputery průměry, mediány, nejčetnější hodnoty, a pro OHE identifikuji možné kategorie).
  • U rozhodovacího stromu je jedno jak jsou hodnoty naškálované, ale u metody kNN je třeba všechy příznaky standardizovat.
    • Standardizaci provádím metodou min-max do rozmezí [0, 1] -- (opět zamýšleno ve scope trénovacích dat).
    • Alternativně by šlo použít standard scaler pomocí průměru a směrodatné odchylky.
cols_preprocessor = ColumnTransformer(
    transformers=[
        ('column_dropper', 'drop', drop_cols),
        ('ordinal_encoder', ordinal_encoder, ordinal_cols),
        ('nominal_encoder', nominal_encoder, nominal_cols),
        ('dense_numerical_encoder', dense_num_encoder, dense_num_cols),
        ('sparse_numerical_encoder', sparse_num_encoder, sparse_num_cols),
        ('list_count_encoder', list_count_encoder, list_count_cols),
    ],
)

preprocessorTree = Pipeline([('num_preprocessor', cols_preprocessor)])
preprocessorKNN = Pipeline([('num_preprocessor', cols_preprocessor),
                            ('scaler', MinMaxScaler())])
preprocessorTree.fit(X_train)
preprocessorKNN.fit(X_train)

class PreprocessorFunctor:
    def __init__(self, preprocessor, columns):
        self.prep = preprocessor
        self.cols = columns
        
    def __call__(self, raw_data):
        return pd.DataFrame(self.prep.transform(raw_data), columns=self.cols)

cols = [s.split('__')[1] for s in preprocessorTree.get_feature_names_out()]
prepTree = PreprocessorFunctor(preprocessorTree, cols)
prepKNN = PreprocessorFunctor(preprocessorKNN, cols)
print('Tree Preprocessor Pipeline')
display(preprocessorTree)
print('\nKNN Preprocessor Pipeline')
display(preprocessorKNN)
Tree Preprocessor Pipeline
Pipeline(steps=[('num_preprocessor',
                 ColumnTransformer(transformers=[('column_dropper', 'drop',
                                                  ['ID', 'name', 'ticket',
                                                   'home.dest']),
                                                 ('ordinal_encoder',
                                                  Pipeline(steps=[('ordinal_encoder',
                                                                   OrdinalEncoder(categories=[[1,
                                                                                               2,
                                                                                               3]],
                                                                                  handle_unknown='use_encoded_value',
                                                                                  unknown_value=nan)),
                                                                  ('imputer',
                                                                   SimpleImputer(strategy='most_frequent'))]),
                                                  ['pclass']),
                                                 ('nominal_...
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['sex', 'embarked']),
                                                 ('dense_numerical_encoder',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer())]),
                                                  ['age', 'fare']),
                                                 ('sparse_numerical_encoder',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer(strategy='median'))]),
                                                  ['sibsp', 'parch']),
                                                 ('list_count_encoder',
                                                  ListCountTransformer(func=<function map_cabin_to_len at 0x7f1c8b0bff40>),
                                                  ['cabin'])]))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
KNN Preprocessor Pipeline
Pipeline(steps=[('num_preprocessor',
                 ColumnTransformer(transformers=[('column_dropper', 'drop',
                                                  ['ID', 'name', 'ticket',
                                                   'home.dest']),
                                                 ('ordinal_encoder',
                                                  Pipeline(steps=[('ordinal_encoder',
                                                                   OrdinalEncoder(categories=[[1,
                                                                                               2,
                                                                                               3]],
                                                                                  handle_unknown='use_encoded_value',
                                                                                  unknown_value=nan)),
                                                                  ('imputer',
                                                                   SimpleImputer(strategy='most_frequent'))]),
                                                  ['pclass']),
                                                 ('nominal_...
                                                  ['sex', 'embarked']),
                                                 ('dense_numerical_encoder',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer())]),
                                                  ['age', 'fare']),
                                                 ('sparse_numerical_encoder',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer(strategy='median'))]),
                                                  ['sibsp', 'parch']),
                                                 ('list_count_encoder',
                                                  ListCountTransformer(func=<function map_cabin_to_len at 0x7f1c8b0bff40>),
                                                  ['cabin'])])),
                ('scaler', MinMaxScaler())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
  • Nyní mám transformaci naučenou (a zafixovanou) na trénovacích datech.
  • Před použitím dat na modelech vždy aplikuji toto předzpracování, která zaručí, že data budou správně předzpracovaná.
print('Preprocessed data (for a decision tree)')
prepTree(X_train)
Preprocessed data (for a decision tree)
pclass sex_female sex_male embarked_C embarked_Q embarked_S embarked_nan age fare sibsp parch cabin_count
0 2.0 0.0 1.0 0.0 0.0 1.0 0.0 29.915 7.896 0.0 0.0 0.0
1 0.0 0.0 1.0 1.0 0.0 0.0 0.0 53.000 28.500 0.0 0.0 1.0
2 0.0 1.0 0.0 1.0 0.0 0.0 0.0 29.915 79.200 0.0 0.0 0.0
3 2.0 0.0 1.0 0.0 1.0 0.0 0.0 29.915 7.750 1.0 0.0 0.0
4 2.0 1.0 0.0 1.0 0.0 0.0 0.0 29.915 15.246 0.0 2.0 0.0
... ... ... ... ... ... ... ... ... ... ... ... ...
595 2.0 0.0 1.0 0.0 0.0 1.0 0.0 32.000 7.925 0.0 0.0 0.0
596 2.0 1.0 0.0 0.0 0.0 1.0 0.0 29.915 25.467 0.0 4.0 0.0
597 0.0 0.0 1.0 1.0 0.0 0.0 0.0 60.000 79.200 1.0 1.0 1.0
598 0.0 1.0 0.0 1.0 0.0 0.0 0.0 26.000 136.779 1.0 0.0 1.0
599 0.0 0.0 1.0 0.0 0.0 1.0 0.0 29.915 26.550 0.0 0.0 0.0

600 rows × 12 columns

print('Preprocessed data (for a kNN)')
prepKNN(X_train)
Preprocessed data (for a kNN)
pclass sex_female sex_male embarked_C embarked_Q embarked_S embarked_nan age fare sibsp parch cabin_count
0 1.0 0.0 1.0 0.0 0.0 1.0 0.0 0.371 0.015 0.000 0.000 0.00
1 0.0 0.0 1.0 1.0 0.0 0.0 0.0 0.661 0.056 0.000 0.000 0.25
2 0.0 1.0 0.0 1.0 0.0 0.0 0.0 0.371 0.155 0.000 0.000 0.00
3 1.0 0.0 1.0 0.0 1.0 0.0 0.0 0.371 0.015 0.125 0.000 0.00
4 1.0 1.0 0.0 1.0 0.0 0.0 0.0 0.371 0.030 0.000 0.222 0.00
... ... ... ... ... ... ... ... ... ... ... ... ...
595 1.0 0.0 1.0 0.0 0.0 1.0 0.0 0.397 0.015 0.000 0.000 0.00
596 1.0 1.0 0.0 0.0 0.0 1.0 0.0 0.371 0.050 0.000 0.444 0.00
597 0.0 0.0 1.0 1.0 0.0 0.0 0.0 0.749 0.155 0.125 0.111 0.25
598 0.0 1.0 0.0 1.0 0.0 0.0 0.0 0.322 0.267 0.125 0.000 0.25
599 0.0 0.0 1.0 0.0 0.0 1.0 0.0 0.371 0.052 0.000 0.000 0.00

600 rows × 12 columns

Učení

  • V následujících dvou sekcích modeluji rozhodovací strom a metodu nejbližších sousedů.
  • Pro danou třídu modelů postupně provádím:
    • rozbor vhodnosti modelu,
    • vyběr hyperparametrů k ladění,
    • selekci nejlepšího modelu, který vyhodnocuji pomocí vybraných evaluačních metod.

Rozhodovací strom

Vhodnost modelu

  • Rozhodovací strom má v této úloze pár výhod: Model se trénuje rychle a nenáročně. Odhad pro to, zda jedinec přežije, je výpočetně jednoduchý (konstrukce rozhodovacích stromů probíhá pouze při učení). Výsledný model je při nízké hloubce dobře interpretovatelný.
  • Na druhou stranu je trénování nekonzistentní a při nezafixovaném random seedu dostávám pokaždé jiný strom (stačí drobná změna v rozdělení dat).
  • U těchto dat je použití tohoto modelu poměrně rozumný, dobře pracuje s různými typy příznaků (kombinace kategorických a numerických) a jsem schopen vybraný model interpretovat.

Hyperparametry k ladění

tree = DecisionTreeClassifier(max_depth=50, min_samples_split=2, min_samples_leaf=1)
tree.fit(prepTree(X_train), y_train)
tree.get_depth()
20
  • Z výsledku předchozí buňky usuzuji, že v tomto případě stačí projít stromy do hloubky 20.
  • Ladím maximální hloubku a míru neuspořádanosti. Hyperparametry min_samples_split nebo min_samples_leaf neoptimalizuji.
    • Jak půjde vidět později, není to ani nutné vzhledem k tomu, že nejoptimálnější modely jsou hloubky méně něž 5.
tree_params = {
    'max_depth': range(1, 20),
    'criterion': ['gini', 'entropy', 'log_loss'],
}

Výběr nejlepšího stromu

  • Pro výběr nejlepšího stromu projíždím všechny možné kombinace hyperparametrů a porovnám různé sady hyperparametrů podle přesnosti na validačních datech.
  • Používám GridSearchCV z sklearn, který ve výchozím nastavení používá metodu cross-validation, proto si vytvářím vlastní train_val_split, kterému natvrdo nastavím trénovací a validační data.
train_val_split = PredefinedSplit([-1 if x in X_train.index else 0 for x in X_train_val.index])
gs = GridSearchCV(DecisionTreeClassifier(), param_grid=[tree_params], scoring='accuracy', cv=train_val_split)
gs.fit(prepTree(X_train_val), y_train_val)
GridSearchCV(cv=PredefinedSplit(test_fold=array([-1,  0, ..., -1, -1])),
             estimator=DecisionTreeClassifier(),
             param_grid=[{'criterion': ['gini', 'entropy', 'log_loss'],
                          'max_depth': range(1, 20)}],
             scoring='accuracy')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
tree_param_list = ['param_criterion', 'param_max_depth', 'mean_test_score']
tree_results = pd.DataFrame(gs.cv_results_).loc[:, tree_param_list]
tree_results.sort_values('mean_test_score', ascending=False).head()
param_criterion param_max_depth mean_test_score
40 log_loss 3 0.80
21 entropy 3 0.80
0 gini 1 0.79
19 entropy 1 0.79
38 log_loss 1 0.79
  • Z tabulky výše je mean_test_score přesnost na validačních datech.
  • Nejlepší skóré dosahují dvě sady parametrů. Jeden ze stromů má následující parametry:
best_tree = gs.best_estimator_
best_tree.get_params()
{'ccp_alpha': 0.0,
 'class_weight': None,
 'criterion': 'entropy',
 'max_depth': 3,
 'max_features': None,
 'max_leaf_nodes': None,
 'min_impurity_decrease': 0.0,
 'min_samples_leaf': 1,
 'min_samples_split': 2,
 'min_weight_fraction_leaf': 0.0,
 'random_state': None,
 'splitter': 'best'}

Interpretace nejlepšího stromu

  • Z následující vizualizace je krásně vidět, že
    • dámy z první třídy ve většině případů přežijí,
    • pánové bez zarezervovaných kajut mají zas naopak šanci na přežití velmi nízkou,
    • u pánů jsou výjimkou pasažéři věků méně než 17.5 (děti, mládež), přičemž počet kajut opět zvyšuje šanci na přežití.
fig, ax = plt.subplots(figsize=(12, 8))
plot_tree(best_tree, max_depth=4, feature_names=cols, class_names=['died', 'survived'],
          label='root', filled=True, rounded=False,
          fontsize=10, impurity=False, precision=2,
          ax=ax, )
plt.show()

Evaluace nejlepšího stromu

Confusion matrix

  • Z matice záměň je vidět, že model pracuje relativně dobře, i přestože je negativních výsledků zhruba dvakrát víc.
  • V druhé části porovnávám s kNN.
disp = ConfusionMatrixDisplay.from_estimator(
    best_tree, prepTree(X_val), y_val, cmap=plt.cm.Blues,
    display_labels=['Died', 'Survived']
)
disp.ax_.set_title('Confusion Matrix ');

F1 score

  • Na těchto datech F1 score moc nevyniká, data nejsou moc nevybalancovaná.
y_val_predict = best_tree.predict(prepTree(X_val))
print('F1 score:', f1_score(y_val, y_val_predict))
F1 score: 0.7402597402597403

ROC curve a AUC

  • Model docela dobře pracuje s poměrem FPR a TPR.
  • Momentálně nemám hodnoty s čím porovnat. Spočítám stejné evaluační míry pro metodu kNN (pak budu moct okomentovat/provést srovnání).
y_val_predict = best_tree.predict_proba(prepTree(X_val))
fpr, tpr, thresholds = roc_curve(y_val, y_val_predict[:, 1])
roc_auc = auc(fpr, tpr)
disp = RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=roc_auc, estimator_name='Decision Tree')
disp.plot();

Metoda nejbližších sousedů

Vhodnost modelu

  • kNN není třeba nijak učit, pro model stačí uložit trénovací data, na kterých budu hledat nejbližší sousedy.
  • Predikce je výpočetně náročnější než u stromu (velikost trénovacích dat není naštěstí nijak velká).
  • Pro tyto data může být kNN vhodnou metodou. Data mám normalizované, OHE je jen na několika příznacích a dimenze dat je malá (nenarážím na problém nedostatku dat v okolí).

Hyperparametry k ladění

  • Ladím počet sousedů, váhy a metriku - minkowského pro různá p.
  • Hyperparametry jsou v pořadí od nejlépe generalizujících (metoda best_estimator v případě stejně dobrých modelů vybírá podle pořadí).
knn_params = {
    'n_neighbors': range(20, 0, -1),
    'weights': ['uniform', 'distance'],
    'metric': ['minkowski'],
    'p': [1, 2, 3, 4],
}

Výběr nejlepších hyperparametrů

  • Pro výběr nejlepších hyperparametrů opět používám GridSearchCV s evaluační mírou accuracy.
train_val_split = PredefinedSplit([-1 if x in X_train.index else 0 for x in X_train_val.index])
gs = GridSearchCV(KNeighborsClassifier(), param_grid=[knn_params], scoring='accuracy', cv=train_val_split)
gs.fit(prepKNN(X_train_val), y_train_val)
GridSearchCV(cv=PredefinedSplit(test_fold=array([-1,  0, ..., -1, -1])),
             estimator=KNeighborsClassifier(),
             param_grid=[{'metric': ['minkowski'],
                          'n_neighbors': range(20, 0, -1), 'p': [1, 2, 3, 4],
                          'weights': ['uniform', 'distance']}],
             scoring='accuracy')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
knn_param_list = ['param_n_neighbors', 'param_weights', 'param_p', 'mean_test_score']
knn_results = pd.DataFrame(gs.cv_results_).loc[:, knn_param_list]
knn_results.sort_values('mean_test_score', ascending=False).head()
param_n_neighbors param_weights param_p mean_test_score
0 20 uniform 1 0.790
34 16 uniform 2 0.790
128 4 uniform 1 0.790
82 10 uniform 2 0.790
70 12 uniform 4 0.785
  • Nejlepší model má následující parametry:
best_knn = gs.best_estimator_
best_knn.get_params()
{'algorithm': 'auto',
 'leaf_size': 30,
 'metric': 'minkowski',
 'metric_params': None,
 'n_jobs': None,
 'n_neighbors': 20,
 'p': 1,
 'weights': 'uniform'}

Evaluace nejlepšího kNN

Confusion matrix

  • Z matice záměň je vidět, že model pracuje relativně dobře.
  • Oproti nejlepšímu stromu má vyšší FN, ale za to nižší FP.
disp = ConfusionMatrixDisplay.from_estimator(
    best_knn, prepKNN(X_val), y_val, cmap=plt.cm.Blues,
    display_labels=['Did not Survive', 'Survived']
)
disp.ax_.set_title('Confusion Matrix ');

F1 score

  • Oproti nejlepšímu stromu je nejlepší model kNN v metrice F1 score výrazně horší.
y_val_predict = best_knn.predict(prepKNN(X_val))
f1_score(y_val, y_val_predict)
0.6962962962962962

ROC curve a AUC

  • Model pracuje s poměrem FPR a TPR lépe než strom. Plocha pod křivkou (AUC) je u modelu kNN vyšší než u nejlepšího stromu.
y_val_predict = best_knn.predict_proba(prepKNN(X_val))
fpr, tpr, thresholds = roc_curve(y_val, y_val_predict[:, 1])
roc_auc = auc(fpr, tpr)
disp = RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=roc_auc, estimator_name='Decision Tree')
disp.plot();

Finální model

  • Nyní srovnávám oba modely a vybírám ten lepší.
print('tree:', best_tree.score(prepTree(X_val), y_val))
print('kNN: ', best_knn.score(prepKNN(X_val), y_val))
tree: 0.8
kNN:  0.795
  • Výchozí metrikou pro score je přesnost, která je pro strom vyšší. Nejlepší strom je tedy nejlepší model, který držím v ruce (co se týče přesnosti).
  • Strom znovu natrénuji na celých trénovacích datech (trénovací + validační), abych obdržel nejlepší možný model pro predikce.
  • Pro tento nejlepší model spočítám očekávanou přesnost na nových datech.
best_model = DecisionTreeClassifier(**best_tree.get_params())
best_model.fit(prepTree(X_train_val), y_train_val)
print('Test accuracy:', best_model.score(prepTree(X_test), y_test))
Test accuracy: 0.8
  • Na nových datech očekávám přesnost 0.8

Disclaimer: Zkoušel jsem spustit celý jupyter notebook pro nezafixované random seedy a ukázalo se, že pro seed 42 (inspirován cvičeními) jsou data podle finálních validačních přesností optimisticky rozdělená už ve fázi train/val/test split. Vzhledem k tomu, že byl seed nastaven na hodnotu 42 od počátku a všechny komentáře se pojí ke konkrétním výsledkům buněk pro tento zvolený seed, nenahrazuji tento optimistický seed jiným normálním seedem.

Evaluace evaluation.csv

eval_data = pd.read_csv('evaluation.csv')
eval_data['survived'] = best_model.predict(prepTree(eval_data))
eval_data.to_csv('results.csv', columns=['ID', 'survived'],
                 header=True, index=False)