Laboratorium Statystycznej Eksploracji Danych

LAB 5

Drzewa klasyfikujące (decyzyjne) 2

Grid search: metoda najbliższych sąsiadów - jeden parametr

W naszych poprzednich zadaniach istotną kwestią okazywało się przeszukiwanie przestrzeni parametrów określających dany klasyfikator - np. w przypadku metody najbliższych sąsiadów jedyną zmienną, którą sterowaliśmy była liczba najbliższych sąsiadów. Tę analizę możną wykonywac “na piechotę”, ale często łatwiej jest się odwołać do funkcji GridSearchCV(). Jak sama nazwa wskazuje, jej celem jest wykonanie CV na pewnej sitace, czyli zestawie parametrów.

Zaczynamy od nieśmiertelnych danych (trochę zmienionych):

import numpy as np
import pandas as pd

def draw_data(n1, n2, m1, m2, S1, S2):

    data1 = np.random.multivariate_normal(m1, S1, n1)
    data2 = np.random.multivariate_normal(m2, S2, n2)

    data = np.concatenate((data1, data2), axis = 0)
    data = pd.DataFrame(data, columns=["x", "y"])

    data["klasa"] = np.repeat([0, 1], [n1, n2])
    
    return data

m1, m2 = [-1, -1], [1, 1]
S1 = [[4, 0], [0, 4]]
S2 = [[4, 0], [0, 4]]
n1, n2 = 60, 40

np.random.seed(129)
data = draw_data(n1, n2, m1, m2, S1, S2)

Następnie uruchamiamy pakiety oraz określamy siatkę parametrów - na początku bedzie to po prostu liczba najbliższych sąsiadów:

from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier

param_grid = {'n_neighbors': range(2, 22)}

Kolejnym krokiem jest wskazanie klasyfikatora oraz uruchomienie docelowej funkcji

clf = KNeighborsClassifier()
clf_grid = GridSearchCV(clf, param_grid)
clf_grid.fit(data[["x", "y"]], data.klasa)
GridSearchCV(estimator=KNeighborsClassifier(),
             param_grid={'n_neighbors': range(2, 22)})
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.

Teraz przyjrzyjmy się wynikom, które kryja się pod clf.grid.cv_results_:

print(clf_grid.cv_results_)
## {'mean_fit_time': array([0.00103087, 0.00086465, 0.00088787, 0.00104766, 0.00090022,
##        0.00097637, 0.00086799, 0.00093455, 0.00082698, 0.00091963,
##        0.00082917, 0.00086117, 0.00082784, 0.00081258, 0.000811  ,
##        0.00125141, 0.00097685, 0.00085316, 0.00079618, 0.00088997]), 'std_fit_time': array([1.80731247e-04, 7.13216099e-05, 5.15489419e-05, 3.22793589e-04,
##        1.59487695e-04, 1.63889798e-04, 5.10796101e-05, 8.93312642e-05,
##        1.39922256e-05, 1.90570248e-04, 3.73005543e-05, 6.37436940e-05,
##        2.84801546e-05, 7.93099323e-06, 9.75432152e-06, 7.41339366e-04,
##        2.01895713e-04, 1.10031515e-04, 2.46206868e-06, 1.70189147e-04]), 'mean_score_time': array([0.00160389, 0.00129361, 0.0015862 , 0.00127063, 0.00130925,
##        0.00146461, 0.00137863, 0.00145402, 0.00131855, 0.00137696,
##        0.00128679, 0.0013298 , 0.00129609, 0.0012548 , 0.00124326,
##        0.00160637, 0.00145893, 0.0014195 , 0.00132179, 0.00135555]), 'std_score_time': array([2.70354197e-04, 1.01480694e-04, 3.94514357e-04, 6.81018956e-05,
##        4.90082366e-05, 1.06534535e-04, 1.54411667e-04, 2.62227863e-04,
##        1.55602245e-04, 1.55494440e-04, 9.28267850e-05, 1.09807802e-04,
##        3.72319143e-05, 3.14712524e-05, 3.88447014e-05, 5.05244300e-04,
##        2.25398327e-04, 3.63612374e-04, 1.73930234e-04, 1.15898957e-04]), 'param_n_neighbors': masked_array(data=[2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
##                    18, 19, 20, 21],
##              mask=[False, False, False, False, False, False, False, False,
##                    False, False, False, False, False, False, False, False,
##                    False, False, False, False],
##        fill_value=999999), 'params': [{'n_neighbors': 2}, {'n_neighbors': 3}, {'n_neighbors': 4}, {'n_neighbors': 5}, {'n_neighbors': 6}, {'n_neighbors': 7}, {'n_neighbors': 8}, {'n_neighbors': 9}, {'n_neighbors': 10}, {'n_neighbors': 11}, {'n_neighbors': 12}, {'n_neighbors': 13}, {'n_neighbors': 14}, {'n_neighbors': 15}, {'n_neighbors': 16}, {'n_neighbors': 17}, {'n_neighbors': 18}, {'n_neighbors': 19}, {'n_neighbors': 20}, {'n_neighbors': 21}], 'split0_test_score': array([0.8 , 0.8 , 0.75, 0.75, 0.85, 0.8 , 0.9 , 0.85, 0.85, 0.85, 0.9 ,
##        0.9 , 0.9 , 0.9 , 0.85, 0.85, 0.85, 0.85, 0.85, 0.85]), 'split1_test_score': array([0.9 , 0.8 , 0.9 , 0.9 , 0.9 , 0.9 , 0.9 , 0.95, 0.9 , 0.9 , 0.9 ,
##        0.95, 0.9 , 0.9 , 0.9 , 0.9 , 0.85, 0.9 , 0.9 , 0.9 ]), 'split2_test_score': array([0.7 , 0.65, 0.7 , 0.8 , 0.8 , 0.75, 0.8 , 0.8 , 0.8 , 0.8 , 0.8 ,
##        0.8 , 0.85, 0.85, 0.85, 0.85, 0.9 , 0.85, 0.85, 0.85]), 'split3_test_score': array([0.7 , 0.75, 0.75, 0.75, 0.75, 0.75, 0.8 , 0.8 , 0.75, 0.75, 0.75,
##        0.8 , 0.75, 0.85, 0.8 , 0.85, 0.85, 0.85, 0.85, 0.85]), 'split4_test_score': array([0.8 , 0.75, 0.8 , 0.8 , 0.8 , 0.85, 0.85, 0.85, 0.85, 0.9 , 0.85,
##        0.9 , 0.9 , 0.9 , 0.85, 0.85, 0.75, 0.8 , 0.7 , 0.75]), 'mean_test_score': array([0.78, 0.75, 0.78, 0.8 , 0.82, 0.81, 0.85, 0.85, 0.83, 0.84, 0.84,
##        0.87, 0.86, 0.88, 0.85, 0.86, 0.84, 0.85, 0.83, 0.84]), 'std_test_score': array([0.07483315, 0.05477226, 0.0678233 , 0.05477226, 0.0509902 ,
##        0.05830952, 0.04472136, 0.05477226, 0.0509902 , 0.05830952,
##        0.05830952, 0.06      , 0.05830952, 0.0244949 , 0.03162278,
##        0.02      , 0.04898979, 0.03162278, 0.0678233 , 0.04898979]), 'rank_test_score': array([18, 20, 19, 17, 15, 16,  5,  8, 14,  9,  9,  2,  3,  1,  5,  3,  9,
##         5, 13,  9], dtype=int32)}

Jest to dość sporo danych. Dla nas najważniejsza jest kwestia jakości (skuteczności) klasyfikatora mierzonego za pomocą wyniku kroswalidacji. Możemy to przedstawić za pomocą prostego wykresu:

import matplotlib.pyplot as plt

plt.errorbar(param_grid["n_neighbors"], clf_grid.cv_results_["mean_test_score"], clf_grid.cv_results_["std_test_score"], fmt="o", capsize=4)
plt.ylim(0.5, 1)
## (0.5, 1.0)
plt.xlabel("liczba najbliższych sąsiadów")
plt.ylabel("wynik CV")
plt.xticks(range(2, 22));

plt.show()

Aby uzyskać informację dla jakiego parametru przeszukiwanie dostrarczyło najlepszą wartość CV, odwołujemy się do:

print(clf_grid.best_params_, clf_grid.best_score_)
## {'n_neighbors': 15} 0.8800000000000001

Oczywiście jest to dość dyskusyjna sprawa, czy powinniśmy wybrać akurat taką wartość parametry, jeśli weźmiemy pod uwagę słupki niepewności.

Grid search: metoda najbliższych sąsiadów - dwa parametry

Nie jest do końca prawdą, że w przypadku metody knn mamy tylko jeden parametr, którym możemy sterować. Mamy takż wpływ na tzw. odległość Mahalanobisa, czyli innymi słowy metrykę, za pomocą określamy które punkty są najbliższym sąsiadami. Odległość Mahalanobisa ma postać macierzy kowariancji

param_grid = {'n_neighbors': range(2, 32), 'metric_params': [{'V': [[2, 0], [0, 2]]}, {'V': [[2, 1], [1, 2]]}, {'V': [[2, 1.5], [1.5, 2]]}]}
clf = KNeighborsClassifier(metric="mahalanobis")
clf_grid = GridSearchCV(clf, param_grid)
clf_grid.fit(data[["x", "y"]], data.klasa)
GridSearchCV(estimator=KNeighborsClassifier(metric='mahalanobis'),
             param_grid={'metric_params': [{'V': [[2, 0], [0, 2]]},
                                           {'V': [[2, 1], [1, 2]]},
                                           {'V': [[2, 1.5], [1.5, 2]]}],
                         'n_neighbors': range(2, 32)})
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.

Dla wygody tworzymy ramkę danych z wyników

out = pd.DataFrame.from_dict(clf_grid.cv_results_)
out["mahalanobis"]=out["param_metric_params"].apply(pd.Series).astype(str)
out
##     mean_fit_time  std_fit_time  ...  rank_test_score           mahalanobis
## 0        0.000908      0.000101  ...               64      [[2, 0], [0, 2]]
## 1        0.000906      0.000110  ...               74      [[2, 0], [0, 2]]
## 2        0.001008      0.000363  ...               68      [[2, 0], [0, 2]]
## 3        0.000997      0.000227  ...               52      [[2, 0], [0, 2]]
## 4        0.000828      0.000013  ...               39      [[2, 0], [0, 2]]
## ..            ...           ...  ...              ...                   ...
## 85       0.000847      0.000014  ...               85  [[2, 1.5], [1.5, 2]]
## 86       0.001174      0.000572  ...               90  [[2, 1.5], [1.5, 2]]
## 87       0.001069      0.000393  ...               84  [[2, 1.5], [1.5, 2]]
## 88       0.001614      0.000570  ...               89  [[2, 1.5], [1.5, 2]]
## 89       0.001232      0.000253  ...               85  [[2, 1.5], [1.5, 2]]
## 
## [90 rows x 16 columns]

i rysujemy efekty, które nie są specjalnie niespodziwane

import seaborn as sns

sns.relplot(data = out, x = "param_n_neighbors", y = "mean_test_score", hue = "mahalanobis", kind="line", style="mahalanobis", markers=True);

Grid search: drzewa decyzyjne

Zastanówmy się teraz jak możemy wykorzystać zdobytą wiedzę i narzędzie w przypadku drzew decyzyjnych. Na poprzednich zajęciach mieliśmy do czynienia z raczej prostą strukturą. Dla nowych danych sytuacja wygląda trochę odmiennie:

from sklearn import tree

m1, m2 = [-1, -1], [2, 2]
S1 = [[4, 2], [2, 4]]
S2 = [[4, 2], [2, 4]]
n1, n2 = 60, 40

np.random.seed(129)
data = draw_data(n1, n2, m1, m2, S1, S2)

clf = tree.DecisionTreeClassifier()
clf.fit(data[["x", "y"]], data.klasa)
DecisionTreeClassifier()
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.
print(clf.tree_.n_leaves)
## 24
plt.figure(figsize = (8,8))
tree.plot_tree(clf, feature_names=["x", "y"], class_names = ["0", "1"], filled = True);
plt.show()

Jak widac, drzewo jest dośc spore - składa się aż z 32 liści. Właśnie tę własność (liczbę liści) potraktujemy jako parametr naszej siatki.

from sklearn.model_selection import GridSearchCV

param_grid = {'max_leaf_nodes': range(2, clf.tree_.n_leaves + 1)}

clf_grid = GridSearchCV(clf, param_grid)
clf_grid.fit(data[["x", "y"]], data.klasa)
GridSearchCV(estimator=DecisionTreeClassifier(),
             param_grid={'max_leaf_nodes': range(2, 25)})
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.

Jako porówanie wykonamy powtórne podstawienie. Żeby skorzystać z gotowych już funkcji, zapiszemy w dość dziwny sposób parametr korswalidacyjny:

n = len(data)
cv_split = [(list(range(n)), list(range(n)))]

clf_grid_res = GridSearchCV(clf, param_grid, cv=cv_split)
clf_grid_res.fit(data[["x", "y"]], data.klasa)
GridSearchCV(cv=[([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
                   18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, ...],
                  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
                   18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, ...])],
             estimator=DecisionTreeClassifier(),
             param_grid={'max_leaf_nodes': range(2, 25)})
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.

Na koniec tworzymy wykres, na którym porównujemy wyniki:

plt.errorbar(param_grid["max_leaf_nodes"], clf_grid.cv_results_["mean_test_score"], clf_grid.cv_results_["std_test_score"], fmt="o", capsize=4, label = "CV")
plt.scatter(param_grid["max_leaf_nodes"], clf_grid_res.cv_results_["mean_test_score"], c = "orange", label = "Pow. pod.")
plt.ylim(0.4, 1.05)
## (0.4, 1.05)
plt.xlabel("liczba liści")
plt.ylabel("dokładnośc klasyfikatora")
plt.legend()

Algorytm kosztu-złożoności

Przedstawione powyżej wyniki stoją za ideą tzw. algortmu kosztu-złożoności, opisanego na wykładu 6: sprowadza się on do wybrania takiego paramteru \(\alpha\), który minimalizuje funkcję \(R_{\alpha}(T)=R(T)+\alpha|T|\), gdzie \(R(T)\) to ułamek blędów klasyfikacji na drzewie \(T\), a \(|T|\) to liczba liści w tym drzewie.

Wartości \(\alpha\) możemy otrzymać z funkcji cost_complexity_pruning_path():

path = clf.cost_complexity_pruning_path(data[["x","y"]], data.klasa)
path
## {'ccp_alphas': array([0.        , 0.00833333, 0.00894737, 0.009     , 0.00964286,
##        0.01190476, 0.01602716, 0.01792986, 0.030375  , 0.0427063 ,
##        0.18533088]), 'impurities': array([0.        , 0.01666667, 0.05245614, 0.10645614, 0.12574185,
##        0.13764662, 0.18572811, 0.22158782, 0.25196282, 0.29466912,
##        0.48      ])}

Wstawiamy je następnie jako parametry do wyszukiwania po siatce: oznacza to tyle, że dla konkretnego parametru tworzone jest określone drzewo, które statystki (dokładność) następnie wyznaczamy

param_grid = {'ccp_alpha': path['ccp_alphas']}

clf_grid = GridSearchCV(clf, param_grid)
clf_grid.fit(data[["x", "y"]], data.klasa)
GridSearchCV(estimator=DecisionTreeClassifier(),
             param_grid={'ccp_alpha': array([0.        , 0.00833333, 0.00894737, 0.009     , 0.00964286,
       0.01190476, 0.01602716, 0.01792986, 0.030375  , 0.0427063 ,
       0.18533088])})
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.

oraz tworzymy wykres

plt.plot(param_grid["ccp_alpha"], clf_grid.cv_results_["mean_test_score"], marker="o", drawstyle="steps-post", c = "#0000ff55")
plt.errorbar(param_grid["ccp_alpha"], clf_grid.cv_results_["mean_test_score"], clf_grid.cv_results_["std_test_score"], fmt="o", capsize=4, c = "#0000ff")
plt.xlabel(r"$\alpha$ - współczynnik złożoności")
plt.ylabel("dokładnośc klasyfikatora")

Pole clf_grid.best_estimator_ przechowuje najlepszy klasyfikator, wyglądający w następujący sposób:

tree.plot_tree(clf_grid.best_estimator_, feature_names=["x", "y"], class_names = ["0", "1"], filled = True);
plt.show()

De facto, powinniśmy się posłużyć tzw. regułą 1-SE - wziąc ten klasyfikator, który znjaduje się w granicach jednego odchylenia standardowego od najlepszego, ale cechuje się wyższą wartością \(\alpha\). Jego wynik jest porównywalny z tym najlepszym, natomiast liczba liści jest na pewno mniejsza.