Валидиране на модели

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import mglearn
import warnings

warnings.filterwarnings(action='ignore', module='scipy', message='^internal gelsd')

%matplotlib inline

Днес

  1. Оценяване на модел
  2. Увереност на модел
  3. Cross-validation

Оценяване на модел

Когато искаме да определим колко добре се справя даден модел може да ползваме различни методи за оценяването му. Няма универсална формула – всичко зависи от конкретните данни и целта ни с тях.

Ще разгледаме няколко различни подхода.

Подходите за оценка на регресия и класификация са различни.

Ще започнем с регресията и ще минем към класификацията.

Нотации

Ще ползваме следните нотации (примери в контекста на определяне на цени на апартаменти).

  • $n$ – брой данни (колко апартамента имаме)
  • $y \in \mathbb{R}^n$ – целеви стойности / target values (истинската цена на апартамент)
  • $\hat{y} \in \mathbb{R}^n$ – предвидени стойности / predicted values (цени, предвидени от нашия модел)
  • $\hat{y}_i \in \mathbb{R}$ – предвидена стойност на $i$-тия елемент (цена на $i$-тия апартамент според модела)

Средна квадратна грешка

Още Mean Squared Error (MSE). Това е средно аритметичното на грешките в предвижданията:

$$ \operatorname {MSE} = \frac{1}{n} \sum_{i=1}^{n} \big( \hat{y}_i - y_i \big) ^ 2 $$

Обърнете внимание, че $\hat{y}_i - y_i$ е грешката за $i$-тия елемент (разликата между търсената стойност и какво казва модела).

Когато ползваме линейна регресия (без регуляризация), алгоритъма намира коефициенти $a_0, a_1, \ldots, a_n$, които минимизират тази функция.

Нека да видим пример. Първо, малко данни:

In [2]:
frame = pd.DataFrame({
    'y': [1, 3, 5, 10, 24, 55, 77],
    'ŷ': [1, 2, 5, 10, 30, 50, 100],
})

frame['correct'] = frame['y'] == frame['ŷ']
frame['difference'] = frame['ŷ'] - frame['y']
frame['squared_error'] = frame.difference**2
In [3]:
frame
Out[3]:
y ŷ correct difference squared_error
0 1 1 True 0 0
1 3 2 False -1 1
2 5 5 True 0 0
3 10 10 True 0 0
4 24 30 False 6 36
5 55 50 False -5 25
6 77 100 False 23 529
In [4]:
frame.squared_error.mean()
Out[4]:
84.428571428571431

Въпрос:

Кой е най-добрият константен предиктор $c$ за горния сет $y$?

"Константен предиктор" ще рече $c = \hat{y}_1 = \hat{y}_2 = \ldots = \hat{y}_n$.

Нека пробваме да го намерим по "инженерен метод" (без математика). Просто ще пробваме числата между -100 и 100.

In [5]:
xs = pd.np.linspace(-100, 100)
ys = [((frame.y - constant) ** 2).mean() for constant in xs]
plt.plot(xs, ys)
plt.grid();

Тази функция изглежда изпъкнала с минимум в constant = 25.

Интересно, но това е и средното-аритметично на $y$:

In [6]:
frame.y.mean()
Out[6]:
25.0

Нека да наречем това Constant Model Mean Square Error (CMMSE). Т.е.:

$$ \operatorname{CMMSE} = \frac{1}{n} \sum_{i=1}^{n} \big( \bar{y} - y_i \big)^2 $$

Където $\bar{y}$ е средното-аритметично, т.е.:

$$ \bar{y} = \frac{1}{n} \sum_{i=1}^{n} y_i $$

$R^2$ – коефициент на детерминация

Най-популярната оценка при регресия е $R^2$. Тя се измерва с:

$$ \operatorname{R^2} = 1 - \frac{\operatorname{MSE}}{\operatorname{CMMSE}} $$

Или по-подробно:

$$ \operatorname{R^2} = 1 - \frac{\frac{1}{n} \sum_{i=1}^n \big( \hat{y}_i - y_i \big)^2}{\frac{1}{n} \sum_{i=1}^n \big( \bar{y} - y_i \big)^2} $$

Обърнете внимание, че:

  • Когато $\operatorname{MSE}$ е 0 имаме $R^2 = 1$ (максимална оценка)
  • Когато $\operatorname{MSE}$ съвпада с този на константния модел имаме $R^2 = 0$
  • $R^2$ може да е отрицателен (например при по-лош модел от оптималния константен модел)
  • Оптимизиране на $\operatorname{MSE}$ оптимизира и $R^2$ (знаменателя е константен)

Накратко, baseline-а на $R^2$ е средното аритметично и оценява на нула. Всичко по-добре е положително число, като максималния резултат е 0. Всичко по-лошо е отрицателно число.

score на линейната регресия връща $R^2$ score. Това най-полезния score за регресия в повечето практически ситуации.

Да илюстрираме това с малък есперимент. Ще вземем следните данни:

In [7]:
from sklearn.datasets import make_regression

X, y = make_regression(n_features=1, n_samples=100, noise=15.0, random_state=0)

plt.scatter(X, y);

Да натренираме линейна регресия:

In [8]:
from sklearn.linear_model import LinearRegression

model = LinearRegression().fit(X, y)
model.score(X, y)
Out[8]:
0.87837943574915134

Видяхме какво връща score. Нека пробваме горната формула:

In [9]:
1 - ((model.predict(X) - y) ** 2).mean() / ((y.mean() - y) ** 2).mean()
Out[9]:
0.87837943574915134

Получаваме същия резултат.

Може да видим и отрицателна стойност с много грешен модел (например винаги отговор 100):

In [10]:
from sklearn.dummy import DummyRegressor

model = DummyRegressor(strategy='constant', constant=100).fit(X, y)
model.score(X, y)
Out[10]:
-4.6088828493778671

При оптималния константен модел имаме $R^2 = 0$:

In [11]:
model = DummyRegressor(strategy='constant', constant=y.mean()).fit(X, y)
model.score(X, y)
Out[11]:
0.0

Mean Absolute Error (MAE)

$$ \operatorname{MAE} = \frac{1}{N} \sum_{i=1}^N \big| \hat y_i - y_i \big| $$

  • Използва се често във финансите
  • €10 грешка е 2 пъти повече от €5 грешка
  • При MSE €10 грешка е 4 по-лошо от €5 грешка
  • По-устойчив на outliers

Забележете, че константия модел, оптимизиращ MSE не е същия като този, който оптимизира MAE.

In [12]:
xs = np.linspace(-10, 30, 400)
ys = [(constant - frame.y).abs().mean() for constant in xs]
plt.plot(xs, ys)
plt.grid();

scikit-learn ви дава функции за тези метрики:

In [13]:
from sklearn.metrics import mean_absolute_error, r2_score, mean_squared_error

X, y = make_regression(n_features=1, n_samples=100, noise=15.0, random_state=0)
model = LinearRegression().fit(X, y)
y_pred = model.predict(X)

print("mean square error:   {}".format(mean_squared_error(y, y_pred)))
print("mean absolute error: {}".format(mean_absolute_error(y, y_pred)))
print("R² score:            {}".format(r2_score(y, y_pred)))
mean square error:   256.8858438784384
mean absolute error: 13.161296203224675
R² score:            0.8783794357491513

Mean Squared Logarithmic Error (MSLE)

Може и да изчислим MSE на логаритмите на $y$ и $\hat{y}$:

$$ \operatorname{MSLE} = \frac{1}{n} \sum_{i=1}^{n} \big( \ln(1 + y_i) - \ln(1 + \hat{y}_i) \big) $$

Тази функция е по-подходяща при експоненциално нарастващи dataset-и.

scikit-learn ви я дава на готово.

In [14]:
from sklearn.metrics import mean_squared_log_error

Класификация

Класификацията ползва малко по-различни методи за оценка. Нека ги разгледаме.

Accuracy

Най-простата метрика е за колко точки предвиждаме правилно:

$$ \operatorname{Accuracy} = \frac{1}{n} \sum_{i=1}^n \big[ \hat{y}_i = y_i \big] $$

Където $\big[ \hat{y}_i = y_i \big]$ е 1 при правилно предвиждане и 0 при грешно.

Показва колко често моделът предсказва верен клас.

Това прави score при класификациите в scikit-learn:

In [15]:
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

cancer = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    cancer.data, cancer.target, stratify=cancer.target, random_state=42)

model = LogisticRegression(C=1.0).fit(X_train, y_train)
model.score(X_test, y_test)
Out[15]:
0.95804195804195802

Небалансирани данни

Тази метрика е много неподходяща за данни, в които единия клас има много повече представители от другия. Нека да разгледаме какво се случва.

Ще ползваме друг интересен dataset – digits.

In [16]:
from sklearn.datasets import load_digits

digits = load_digits()

fig, axes = plt.subplots(5, 20, figsize=(10, 5), subplot_kw={'xticks': (), 'yticks': ()})
for ax, img in zip(axes.ravel(), digits.images):
    ax.imshow(img)   
plt.subplots_adjust(wspace=1, hspace=0, top=.5, bottom=0)

Всяка "данна" е просто 8x8 картинка, представена като масив от пиксели:

In [17]:
digits.data[0]
Out[17]:
array([  0.,   0.,   5.,  13.,   9.,   1.,   0.,   0.,   0.,   0.,  13.,
        15.,  10.,  15.,   5.,   0.,   0.,   3.,  15.,   2.,   0.,  11.,
         8.,   0.,   0.,   4.,  12.,   0.,   0.,   8.,   8.,   0.,   0.,
         5.,   8.,   0.,   0.,   9.,   8.,   0.,   0.,   4.,  11.,   0.,
         1.,  12.,   7.,   0.,   0.,   2.,  14.,   5.,  10.,  12.,   0.,
         0.,   0.,   0.,   6.,  13.,  10.,   0.,   0.,   0.])

Ако се загледате внимателно, може да видите нулата по-долу:

In [18]:
digits.data[0].reshape(8, 8)
Out[18]:
array([[  0.,   0.,   5.,  13.,   9.,   1.,   0.,   0.],
       [  0.,   0.,  13.,  15.,  10.,  15.,   5.,   0.],
       [  0.,   3.,  15.,   2.,   0.,  11.,   8.,   0.],
       [  0.,   4.,  12.,   0.,   0.,   8.,   8.,   0.],
       [  0.,   5.,   8.,   0.,   0.,   9.,   8.,   0.],
       [  0.,   4.,  11.,   0.,   1.,  12.,   7.,   0.],
       [  0.,   2.,  14.,   5.,  10.,  12.,   0.,   0.],
       [  0.,   0.,   6.,  13.,  10.,   0.,   0.,   0.]])

Нека да създадем dataset в който ще се опитваме да отгатваме само 9ките.

In [19]:
y = digits.target == 9

X_train, X_test, y_train, y_test = train_test_split(digits.data, y, random_state=0, stratify=y)

Какво ли прави логистичната регресия:

In [20]:
logistic = LogisticRegression().fit(X_train, y_train)
logistic.score(X_test, y_test)
Out[20]:
0.96666666666666667

96% не е никак зле!

Или?

Да видим какво ще направи един константен модел:

In [21]:
from sklearn.dummy import DummyClassifier

dummy = DummyClassifier(strategy='constant', constant=0).fit(X_train, y_train)
dummy.score(X_test, y_test)
Out[21]:
0.90000000000000002

Явно може и по-лесно да постигнем 90% точност.

Представете си, че имаме рядко заболяване – имат го само 1% от хората. Може да постигнем 99% точност просто като винаги отговаряме "пациента е здрав". Това не е много добър модел.

За да се справим с това ползваме малко по-различни метрики.

Confusion matrix

Първата интересна графика е confusion matrix. За да я начертаем ни трябват 4 числа:

  • True Positives (TP) – $\hat{y} = 1$ за които сме предвидили правилно
  • False Positives (FP) – $\hat{y} = 1$ за които сме предвидили грешно
  • True Negatives (TN) – $\hat{y} = 0$ за които сме предвидили правилно
  • False Negatives (FN) – $\hat{y} = 0$ за които сме предвидили грешно

Обърнете внимание, че при бинарната класификация често наричаме единия клас "позитивен" ($y = 1$). Това може да е "спам" в мейлите, "злокачествен" при туморите и "девятка" при числата.

Confusion matrix е просто следната таблица:

In [22]:
mglearn.plots.plot_binary_confusion_matrix()

scikit-learn има функция, която дава метриките на confusion matrix:

In [23]:
from sklearn.metrics import confusion_matrix

confusion_matrix(y_test, dummy.predict(X_test))
Out[23]:
array([[405,   0],
       [ 45,   0]])

Така по-лесно може да се ориентираме в резултата и да видим, че има проблем.

Логистичната регресия се справя по-добре:

In [24]:
confusion_matrix(y_test, logistic.predict(X_test))
Out[24]:
array([[395,  10],
       [  5,  40]])

Хората се радват на шарено. Нека си направим функция за чертане на confusion matrix:

In [25]:
import itertools

def plot_confusion_matrix(y_true, y_pred, classes,
                          normalize=False,
                          title='Confusion matrix',
                          cmap=plt.cm.Blues,
                          figsize=(9, 7)):
    matrix = confusion_matrix(y_true, y_pred)

    if normalize:
        matrix = matrix.astype('float') / matrix.sum(axis=1)[:, np.newaxis]

    plt.figure(figsize=figsize)
    plt.imshow(matrix, interpolation='nearest', cmap=cmap)
    plt.title(title)
    plt.colorbar()

    tick_marks = np.arange(len(classes))
    plt.xticks(tick_marks, classes, rotation=45)
    plt.yticks(tick_marks, classes)

    fmt = '.2f' if normalize else 'd'
    thresh = matrix.max() / 2.
    for i, j in itertools.product(range(matrix.shape[0]), range(matrix.shape[1])):
        plt.text(j, i, format(matrix[i, j], fmt),
                 horizontalalignment="center",
                 size=int((figsize[0] / 10) * 38),
                 color="white" if matrix[i, j] > thresh else "black")

    plt.tight_layout()
    plt.ylabel('True label')
    plt.xlabel('Predicted label')
In [26]:
plot_confusion_matrix(y_test, logistic.predict(X_test), ['not nine', 'nine'])

Така числата са малко трудни за разбиране. Затова имаме normalize=True:

In [27]:
plot_confusion_matrix(y_test, logistic.predict(X_test), ['not nine', 'nine'], normalize=True)

Виждаме, че познаваме not-nine в 98% от случаите и nine в 89%.

Как ли се справя глупавия ни модел?

In [28]:
plot_confusion_matrix(y_test, dummy.predict(X_test), ['not nine', 'nine'])

Тук ясно се вижда проблема.

Като цяло, оптималната матрица е с положителни числа по основния диагонал и нули извън него.

Precision

Precision е метрика, която се опитва да намали False Positives:

$$ \operatorname{Precision} = \frac{\operatorname{TP}}{\operatorname{TP} + \operatorname{FP}} $$

Тя е, важна когато искаме да сме сигурни в позитивното си предвиждане.

Примери за важен precision

Представете си, че правим система за автоматично бомбардиране на терористи. Алгоритъма преценява дали даден човек е терорист, и ако е такъв, го бомбардираме. Искаме да минимизираме (или отстраним) цивилните жертви – за нас е много важно да сме сигурни в оценката на алгоритъма, когато предвиди "терорист". Съответно, търсим алгоритъм с добър precision.

Аналогичен случай е спама – ако алгоритъма е решил, че определ имейл е спам, искаме да сме много сигурни в това, преди да го изтрием. Предпотичаме имейлите на кантар да не са спам, пред това да изтрием нещо важно.

Recall

Recall е аналогична метрика:

$$ \operatorname{Recall} = \frac{\operatorname{TP}}{\operatorname{TP} + \operatorname{FN}} $$

Тя е важна, когато искаме да сме много сигурни, че ще хванем всички позитивни данни.

Пример за важен recall

Добър пример за това са туморите. Цената на false positive е различна от тази на false negative. При false positive ще кажем на здрав пациент, че може да е болен. Това ще струва допълнителни изследвания и малко емоционални мъки. При false negative може да пропуснем да направим лечение и да застрашим живота на пациента. В този случай предпочитаме алгоритъма ни да има висок recall.

Precision и recall илюстрирани

Лесен начин да помним двете метрики е TP разделено на сбора на реда или колоната:

In [29]:
plot_confusion_matrix(y_test, logistic.predict(X_test), ['not nine', 'nine'], figsize=(5, 4))

Precision е 395 разделено на сбора на първата колона, докато recall е 395 разделено на сбора на първия ред.

$f_1$ score

Ако метрика която взема и двете числа предвид, може да ползваме $f_1$ score. Това е хармоничната средна стойност на двете:

$$ f_1 = 2 \cdot \frac{\operatorname{precision} \cdot \operatorname{recall}}{\operatorname{precision} + \operatorname{recall}} $$

In [30]:
from sklearn.metrics import f1_score

f1_score(y_test, logistic.predict(X_test))
Out[30]:
0.8421052631578948

scikit-learn може да ни даде всички метрики.

In [31]:
from sklearn.metrics import classification_report
print(classification_report(y_test, logistic.predict(X_test), target_names=['not nine', 'nine']))
             precision    recall  f1-score   support

   not nine       0.99      0.98      0.98       405
       nine       0.80      0.89      0.84        45

avg / total       0.97      0.97      0.97       450

Нека да пробваме с още един dummy класификатор (с магически random state):

In [32]:
dummy = DummyClassifier(random_state=7260, strategy='stratified').fit(X_train, y_train)

Ако гледаме само score, няма да видим твърде драматична разлика:

In [33]:
print("Dummy score:    {}".format(dummy.score(X_test, y_test)))
print("Logistic score: {}".format(logistic.score(X_test, y_test)))
Dummy score:    0.8688888888888889
Logistic score: 0.9666666666666667

Разликите си личат в пълния report:

In [34]:
print("Dummy Classifier:\n\n", classification_report(y_test, dummy.predict(X_test), target_names=['not nine', 'nine']))
print("Logistic Regression:\n\n", classification_report(y_test, logistic.predict(X_test), target_names=['not nine', 'nine']))
Dummy Classifier:

              precision    recall  f1-score   support

   not nine       0.91      0.94      0.93       405
       nine       0.28      0.20      0.23        45

avg / total       0.85      0.87      0.86       450

Logistic Regression:

              precision    recall  f1-score   support

   not nine       0.99      0.98      0.98       405
       nine       0.80      0.89      0.84        45

avg / total       0.97      0.97      0.97       450

Увереност на модела

Повечето класификационни модели имат още една хватка – могат да дават увереност колко са убедени в модела си. Нека разгледаме един синтетичен пирмер.

In [35]:
from sklearn.datasets import make_circles

X, y = make_circles(noise=0.25, factor=0.5, random_state=1)

X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)

Да видим как изглежда този dataset:

In [36]:
mglearn.discrete_scatter(X_test[:, 0], X_test[:, 1], y_test, markers='^')
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, markers='o');

Да го пробваме с GradientBoostingClasifier (от семейство decision trees):

In [37]:
from sklearn.ensemble import GradientBoostingClassifier

gbrt = GradientBoostingClassifier(random_state=0)
gbrt.fit(X_train, y_train);

Нека да начертаем границата:

In [38]:
mglearn.tools.plot_2d_separator(gbrt, X, alpha=.4, fill=True, cm=mglearn.cm2)
mglearn.discrete_scatter(X_test[:, 0], X_test[:, 1], y_test, markers='^')
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, markers='o');

Вероятности

Повечето класификационни модели имат функция predict_proba, която показва вероятността на всеки клас (според класификатора):

In [39]:
gbrt.predict_proba(X_test[:6])
Out[39]:
array([[ 0.01573626,  0.98426374],
       [ 0.84575649,  0.15424351],
       [ 0.98112869,  0.01887131],
       [ 0.97406775,  0.02593225],
       [ 0.01352142,  0.98647858],
       [ 0.02504637,  0.97495363]])

Първото число е вероятността за първия клас, второто – за втория. Може да видим, че модела е по-убеден за едни точки, отколкото за други.

Също така, сборът на двете е 1 (до floating point точност).

Decision function

Моделите имат и decision_function, който е числото зад тези неща.

Стойността на decision_function зависи от конкретния модел и обикновено е нещо абстрактно. Вероятността е функция на нейния резултат.

In [40]:
gbrt.decision_function(X_test[:6])
Out[40]:
array([ 4.13592629, -1.7016989 , -3.95106099, -3.62599351,  4.28986668,
        3.66166106])

Нека си направим таблица:

In [41]:
frame = pd.DataFrame(
    np.hstack((
        gbrt.predict_proba(X_test).round(4),
        gbrt.decision_function(X_test).reshape(-1, 1).round(4),
        gbrt.predict(X_test).reshape(-1, 1),
    )),
    columns=['blue %', 'red %', 'decision function', 'answer']
)
frame['color'] = np.array(['blue', 'red'])[frame['answer'].astype(int)]
frame
Out[41]:
blue % red % decision function answer color
0 0.0157 0.9843 4.1359 1.0 red
1 0.8458 0.1542 -1.7017 0.0 blue
2 0.9811 0.0189 -3.9511 0.0 blue
3 0.9741 0.0259 -3.6260 0.0 blue
4 0.0135 0.9865 4.2899 1.0 red
5 0.0250 0.9750 3.6617 1.0 red
6 0.9995 0.0005 -7.6910 0.0 blue
7 0.0161 0.9839 4.1100 1.0 red
8 0.2483 0.7517 1.1075 1.0 red
9 0.0321 0.9679 3.4078 1.0 red
10 0.9984 0.0016 -6.4626 0.0 blue
11 0.0135 0.9865 4.2899 1.0 red
12 0.0198 0.9802 3.9016 1.0 red
13 0.7686 0.2314 -1.2003 0.0 blue
14 0.0250 0.9750 3.6617 1.0 red
15 0.9848 0.0152 -4.1723 0.0 blue
16 0.7738 0.2262 -1.2301 0.0 blue
17 0.9805 0.0195 -3.9158 0.0 blue
18 0.0174 0.9826 4.0360 1.0 red
19 0.0161 0.9839 4.1100 1.0 red
20 0.0161 0.9839 4.1100 1.0 red
21 0.3414 0.6586 0.6571 1.0 red
22 0.0631 0.9369 2.6983 1.0 red
23 0.9344 0.0656 -2.6567 0.0 blue
24 0.8662 0.1338 -1.8678 0.0 blue

Тук виждаме няколко неща:

  • Положителен резултат от decision function се оценява като 1.0, а отрицателен като 0.0.
  • Колкото по-голяма е стойността на функцията, толкова по уверен е алгоритъма – имаме 98% при 4.13 и 65% при 0.65.
  • Най-вероятно decision функцията минава през сигмоид.

Нека наречтаем това на графика:

In [42]:
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
mglearn.tools.plot_2d_separator(gbrt, X, ax=axes[0], alpha=.4, fill=True, cm=mglearn.cm2)
scores_image = mglearn.tools.plot_2d_scores(gbrt, X, ax=axes[1], alpha=.4, cm=mglearn.ReBl)

for ax in axes:
    mglearn.discrete_scatter(X_test[:, 0], X_test[:, 1], y_test, markers='^', ax=ax)
    mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, markers='o', ax=ax)
    ax.set_xlabel("Feature 0")
    ax.set_ylabel("Feature 1")

cbar = plt.colorbar(scores_image, ax=axes.tolist())
axes[0].legend(["Test class 0", "Test class 1", "Train class 0", "Train class 1"], ncol=4, loc=(.1, 1.1));

Фонът във втората диаграма показва стойността на decision функцията. Може да съобразим, че алгоритъма сравнява с някакъв treshold и така определя класа. В този конретен случай изглежда, че сравнява с 0.

Бихме могли да го тестваме:

In [43]:
np.all(gbrt.predict(X_test) == (gbrt.decision_function(X_test) > 0))
Out[43]:
True

Ако ползваме decision_function вместо predict може да наклоним класификатора в определена посока:

In [44]:
print("df > 0:  {}".format(gbrt.predict(X_test).sum()))
print("df > 4:  {}".format((gbrt.decision_function(X_test) > 4).sum()))
print("df > -4: {}".format((gbrt.decision_function(X_test) > -4).sum()))
df > 0:  14
df > 4:  7
df > -4: 22

Може да начертаем decision boundary при различните варианти:

In [45]:
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for (ax, threshold) in zip(axes, [0, 4, -4]):
    mglearn.tools.plot_2d_separator(gbrt, X, ax=ax, alpha=.4, threshold=threshold, fill=True, cm=mglearn.cm2)
    mglearn.discrete_scatter(X_test[:, 0], X_test[:, 1], y_test, markers='^', ax=ax)
    mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, markers='o', ax=ax)
    ax.set_title("df > {}".format(threshold))

Виждаме, че при промяна на стойноста, с която сравняваме, започваме да предпочитаме единия клас пред другия.

Гледайки горната таблица, при > 4 имаме увереност над 98%. Така бихме могли да правим trade-off между decision и recall.

In [46]:
print("Report at df > 0:\n")
print(classification_report(y_test, gbrt.predict(X_test)))
print("Report at df > -4:\n")
print(classification_report(y_test, gbrt.decision_function(X_test) > -4))
Report at df > 0:

             precision    recall  f1-score   support

          0       0.73      0.89      0.80         9
          1       0.93      0.81      0.87        16

avg / total       0.86      0.84      0.84        25

Report at df > -4:

             precision    recall  f1-score   support

          0       0.67      0.22      0.33         9
          1       0.68      0.94      0.79        16

avg / total       0.68      0.68      0.63        25

Може да видим, че за позитивня клас (1) сме увеличили recall-а от 0.81 до 0.94 за сметка на всичко останало. Понякога това може да желано.

Всичко това ни е нуждо да обясним финалните метрики за класификация.

Precision-recall curve

За видим precision-recall curve, нека да започнем с друг dataset:

In [47]:
from mglearn.datasets import make_blobs

X, y = make_blobs(n_samples=(4000, 500), centers=2, cluster_std=[7.0, 2], random_state=22)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)

Нека да видим как изглежда:

In [48]:
plt.figure(figsize=(10, 10))
mglearn.discrete_scatter(X_test[:, 0], X_test[:, 1], y_test, markers='^')
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, markers='o');

Ще тренираме друг класификатор (SVC), който няма да обясняваме подробно. Просто може да знаем, че е класификатор:

In [49]:
from sklearn.svm import SVC

svc = SVC(gamma=.05).fit(X_train, y_train)

Може да видим report с default-ния treshold:

In [50]:
print(classification_report(y_test, svc.predict(X_test)))
             precision    recall  f1-score   support

          0       0.96      0.96      0.96      1004
          1       0.64      0.67      0.66       121

avg / total       0.93      0.92      0.93      1125

Интересно е да видим как ще променят recall и precision, ако променяме treshold-а. Може да начертаем графика с precision_recall_curve:

In [51]:
from sklearn.metrics import precision_recall_curve

precision, recall, thresholds = precision_recall_curve(y_test, svc.decision_function(X_test))

close_zero = np.argmin(np.abs(thresholds))
plt.plot(precision[close_zero], recall[close_zero], 'o', markersize=10, label='threshold zero', fillstyle='none', c='k', mew=2)
plt.plot(precision, recall, label='precision recall curve')
plt.xlabel('Precision')
plt.ylabel('Recall');

На тази графика виждаме:

  • Точката избрана от модела (при $y$ = 1).
  • Връзката между presicion и recall при промяна на treshold-а.
  • В този модел виждаме, че може да получим precision = 0.5 при висок recall. Ако искаме повече precision трябва да жертваме recall.
  • Колкото по-близо е кривата до горния десен ъгъл, толкова по-добре – т.е. може да имаме едновременно висок precision и recall.

Различните модели ще имат различна крива. Да пробваме с друг:

In [52]:
from sklearn.ensemble import RandomForestClassifier

forest = RandomForestClassifier(n_estimators=100, random_state=0, max_features=2)
forest.fit(X_train, y_train)

precision_rf, recall_rf, tresholds_rf = precision_recall_curve(y_test, forest.predict_proba(X_test)[:, 1])

close_default_rf = np.argmin(np.abs(tresholds_rf - 0.5))

plt.plot(precision_rf, recall_rf, label='rf')
plt.plot(precision_rf[close_default_rf], recall_rf[close_default_rf], '^', c='k', markersize=10, label='treshold 0.5 rf', fillstyle='none', mew=2)
plt.xlabel('Precision')
plt.xlabel('Recall');

Може да видим и двете заедно на една графика:

In [53]:
plt.plot(precision, recall, label='svc')
plt.plot(precision_rf, recall_rf, label='rf')
plt.plot(precision[close_zero], recall[close_zero], 'o', markersize=10, label='treshold zero svc', fillstyle='none', c='k', mew=2)
plt.plot(precision_rf[close_default_rf], recall_rf[close_default_rf], '^', c='k', markersize=10, label='treshold 0.5 rf', fillstyle='none', mew=2)
plt.xlabel('Precision')
plt.xlabel('Recall')
plt.legend(loc='best');

От графиката се вижда, че support vector машината се оправя по-добре в средната точка, но random forest-а се справя по-добре с крайностите. Това не се вижда в $f_1$ score-а:

In [54]:
print("f₁ for SVC:           {}".format(f1_score(y_test, svc.predict(X_test))))
print("f₁ for Random Forest: {}".format(f1_score(y_test, forest.predict(X_test))))
f₁ for SVC:           0.6558704453441295
f₁ for Random Forest: 0.6097560975609757

Друга метрика е площта под кривата. scikit-learn има функция и за това:

In [55]:
from sklearn.metrics import average_precision_score

print("AUC for SVC:           {}".format(average_precision_score(y_test, svc.decision_function(X_test))))
print("AUC for Random Forest: {}".format(average_precision_score(y_test, forest.predict_proba(X_test)[:, 1])))
AUC for SVC:           0.6663140319391998
AUC for Random Forest: 0.659559370099291

Reciever operating characteristics

ROC е друга метрика, която може да погледнем. Вместо recall и precision съпоставя две други метрики – false positive rate и true positive rate:

$$ \operatorname{TPR} = \frac{\operatorname{TP}}{\operatorname{TP}+\operatorname{FN}} $$

$$ \operatorname{FPR} = \frac{\operatorname{FP}}{\operatorname{FP}+\operatorname{TN}} $$

Интуитивно:

  • TPR е отношението на броя имейли, за които сме казали "спам" към всички спам мейли.
  • FPR е отномението на броя имейли, за които сме казали "не спам" към всички не спам мейли.
  • TPR е просто recall.

Може да си начертаем графика:

In [56]:
from sklearn.metrics import roc_curve
fpr, tpr, tresholds = roc_curve(y_test, svc.decision_function(X_test))

plt.plot(fpr, tpr, label="ROC Curve")
plt.xlabel("False Positive Rate")
plt.ylabel("True Positive Rate")
close_zero = np.argmin(np.abs(tresholds))
plt.plot(fpr[close_zero], tpr[close_zero], 'o', fillstyle='none', c='k', markersize=10);

Идеалната крива е максимално близка до горния ляв ъгъл. От графиката може да видим, че след един момент нататък (около 0.9), може да увеличим recall само на цената на голямо увеличение на FPR.

Може да направим същото за Random Forest класификатора:

In [57]:
fpr_rf, tpr_rf, tresholds_rf = roc_curve(y_test, forest.predict_proba(X_test)[:, 1])

plt.plot(fpr_rf, tpr_rf, label="ROC Curve")
plt.xlabel("False Positive Rate")
plt.ylabel("True Positive Rate")
close_default_rf = np.argmin(np.abs(tresholds_rf - 0.5))
plt.plot(fpr_rf[close_default_rf], tpr_rf[close_default_rf], 'o', fillstyle='none', c='k', markersize=10);

Или и двете заедно:

In [58]:
plt.plot(fpr, tpr, label="svm")
plt.plot(fpr[close_zero], tpr[close_zero], 'o', fillstyle='none', c='k', markersize=10)

plt.plot(fpr_rf, tpr_rf, label="random forest")
plt.plot(fpr_rf[close_default_rf], tpr_rf[close_default_rf], '^', fillstyle='none', c='k', markersize=10);

plt.xlabel("False Positive Rate")
plt.ylabel("True Positive Rate")
plt.legend(loc='best');

Отново, имаме метрика събираща лицето под кривата:

In [59]:
from sklearn.metrics import roc_auc_score

print("AUC for SVC:           {}".format(roc_auc_score(y_test, svc.decision_function(X_test))))
print("AUC for Random Forest: {}".format(roc_auc_score(y_test, forest.predict_proba(X_test)[:, 1])))
AUC for SVC:           0.9162935036712655
AUC for Random Forest: 0.9366953672911658

Logarithmic loss (LogLoss)

При двоична класификация:

$$ \operatorname{LogLoss} = - \frac {1}{n} \sum_{i=1}^n \Big( y_i \ln(p_i) + (1 - y_i) \ln(1 - p_i) \Big) $$

Където:

  • $y_i \in \{0, 1\}$ – класът на $i$-тия елемент
  • $p_i$ – вероятността предвиждането на $i$-тия елемент да е 1

Пример:

Предсказваме клас 1 с вероятност 0.9 и истинския клас е 1:

$$ \operatorname{LogLoss} = - \big ( 1 * log(0.9) + (1 - 1) * log(1 - 0.9) \big ) = $$

$$ = - \big (-0.105 + 0 \big ) = 0.105 $$

Предсказваме клас 1 с вероятност 0.9 и истинския клас е 0:

$$ \operatorname{LogLoss} = - \big ( 0 * log(0.9) + (1 - 0) * log(1 - 0.9) \big ) = $$

$$ = - \big ( 0 - 2.3 \big ) = 2.3 $$

При много класове:

$$ \operatorname{LogLoss} = - \frac {1}{n} \sum_{i=1}^n \sum_{j=1}^l \sigma_{ij}\ln(p_{ij} ) $$

Където:

  • $l \in \mathbb{N}$ – броя класове
  • $\sigma_{ij} \in \{0, 1\}$ – 1 ако $i$-тия елемент е от клас $j$, иначе 0
  • $p_{ij} \in [0, 1]$ – вероятността (според модела) $i$-тия елемент да е от клас $j$
In [60]:
from sklearn.metrics import log_loss, accuracy_score

def plot_log_loss():
    plt.figure(figsize=(16, 4))
    plt.subplot(1,2,1)
    plt.title('Клас 1')
    x = pd.np.linspace(0, 1)
    loss_log = [log_loss([1], [x], labels=[0, 1]) for x in x]

    plt.plot(x, loss_log); plt.grid();
    plt.plot(x, 1 -x);
    plt.ylim(-0.1, 8)
    plt.ylabel('Натрупана грешка за 1 семпъл')
    plt.xlabel('Разлика във вероятността от истинския клас')

    plt.subplot(1,2,2)
    plt.title('Клас 0')
    
    x = pd.np.linspace(0, 1)
    loss_log = [log_loss([0], [x], labels=[0, 1]) for x in x]

    plt.ylim(-0.1, 8)
    plt.plot(x, loss_log); plt.grid();
    plt.plot(x, x);
    plt.xlabel('Разлика във вероятността от истинския клас')
    plt.legend(['LogLoss', 'Абсолютна разлика'])
In [61]:
plot_log_loss()

Вижда се, че тази функция наказва модел, който е много уверен в грешните неща. Оптимизирането й води до модел, е по-консервативен във вероятностите си.

Това е функцията, която се оптимизира при логистична регресия.

Най-добрия константен модел:

Настройваме вероятностите за всеки клас да са равни на съответните честоти на класа.

Dataset:

  • 10 кучета
  • 90 котки

Вероятностите са [0.1, 0.9] за двата класа.

Калибрирани модели

predict_proba връща вероятности, които модела е определил. Това може да не са реалните вероятности, обаче.

Наричаме един модел "калибриран" когато двете съвпадат. Калибрирането на модели е сложна тема, която може и да разгледаме по-натам. Засега може да се пробвате със следния paper:

http://www.machinelearning.org/proceedings/icml2005/papers/079_GoodProbabilities_NiculescuMizilCaruana.pdf

Sensitivity and specificity

Recall и precision са само част от голям набор от метрики, базирани на TP, FP, TN и FN. В machine learning те се ползват най-често, но има и други. Ако ви е любопитно, wikipedia има много добра статия по въпроса:

https://en.wikipedia.org/wiki/Sensitivity_and_specificity

Cross Validation

Да се тренира и тества модел върху едни и същи данни се счита за грешка. Достатъчно сложен модел може да научи перфектно резултата за всеки вход за данните с които се тренира. Това не означава, че ще генерализира добре.

В sklearn можем да използваме функцията train_test_split за бързо разделяне на данните на две части:

  1. За трениране на модел: X_train и y_train
  2. За оценяване на модел: X_test и y_test

train_test_split прави и случайно разбъркване на данните.

In [62]:
from sklearn.datasets import load_iris

iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.4, random_state=0)

print("Train shape:", X_train.shape, y_train.shape)
print("Test shape: ", X_test.shape, y_test.shape)
Train shape: (90, 4) (90,)
Test shape:  (60, 4) (60,)

Валидационно множество

Имаме следната ситуация:

  • Когато настройваме различни хипер-параметри (alpha, max_depth, n_features, и т.н.) се появява шанс за овърфит.
  • При значителен брой итерации върху тест сета, информация за тест сета може да изтече към модела.
  • В този момент модела става оптимален за конкретния тест сет, но не генерализира толкова добре на нови данни.

За да се реши този проблем се прави още едно разделяне на данните, което се нарича "Валидационнен сет".

На практика данните се разделят на 3 части:

  1. Тренировачно множество
    • Служи за многократно трениране на модела с различни настройки (хипер параметри).
  2. Валидационно множество
    • Служи като тестово множество за всяко претрениране на модела с различни настройки.
    • След изседване на различни комбинации на параметри се избира моделът, показващ най-добър резултат върху валидационното множество.
  3. Тестово множество
    • Използва се само веднъж, след като сме приключили с експериментите и сме избрали най-добрия модел.
    • Целта е да видим какви са показателите на модела върху нови данни.

Или на картинка:

img

Какво става ако данните не са достатъчно?

Може да ползваме cross (кръстосана) валидация.

  • Крос валидацията разделя тренировачното множество на $k$ части.
  • Прави $k$ на брой тренирания с $k - 1$ части и винаги оставя една част за валидация.
  • Оцененката от крос валидацията е средната оценка от всички $k$ тренирания и оценявания.

Или на картинка:

cv

Може да ползваме cross_val_score за да направи тази операция:

In [63]:
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression

logistic = LogisticRegression()

scores = cross_val_score(logistic, iris.data, iris.target, cv=10, scoring='accuracy')
print(scores)
print("Mean: {}".format(scores.mean()))
print("Std:  {}".format(scores.std()))
[ 1.          1.          1.          0.93333333  0.93333333  0.93333333
  0.8         0.93333333  1.          1.        ]
Mean: 0.9533333333333334
Std:  0.059999999999999984

Обърнете внимание, че:

  • Крос валидацията показва дали има вариация в модела.
  • Облекчава проблема с неприятен разрез на данните от train_test_split.
  • По подразбиране cross_val_score запазва пропорциите на класовете в двете множествата.
  • Може да се наложи предварително да размесим данните - не става автоматично.
  • За всяко оценяване трябва да бъдат направени k тренирания и оценявания на различни части от тренировачното множество.

Параметри

Параметърът cv може да приема следните стойности:

  • По подразбиране None използва три-кратно крос валидиране.
  • int - k-кратно крос валидиране, където k е подаденото число.
  • Обект, използван за генериране на валидационни множества, например StratifiedKFold.
  • Python iterable връщаш двойка (train, validation) множества

Допълнителна информация

Има друга функция, cross_validate която дава повече информация:

In [64]:
from sklearn.model_selection import cross_validate

scoring = ['accuracy', 'precision_macro', 'recall_macro', 'f1_macro']

logistic = LogisticRegression()
scores = cross_validate(logistic, iris.data, iris.target, scoring=scoring, cv=5, return_train_score=False)
pd.DataFrame(scores)
Out[64]:
fit_time score_time test_accuracy test_f1_macro test_precision_macro test_recall_macro
0 0.000647 0.001616 1.000000 1.000000 1.000000 1.000000
1 0.000541 0.001138 0.966667 0.966583 0.969697 0.966667
2 0.000465 0.001313 0.933333 0.933333 0.933333 0.933333
3 0.000494 0.001144 0.900000 0.897698 0.923077 0.900000
4 0.000633 0.001308 1.000000 1.000000 1.000000 1.000000

Ако търсим средни стойности, може да се пробваме с describe:

In [65]:
pd.DataFrame(scores).describe()
Out[65]:
fit_time score_time test_accuracy test_f1_macro test_precision_macro test_recall_macro
count 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000
mean 0.000556 0.001304 0.960000 0.959523 0.965221 0.960000
std 0.000082 0.000194 0.043461 0.044257 0.036167 0.043461
min 0.000465 0.001138 0.900000 0.897698 0.923077 0.900000
25% 0.000494 0.001144 0.933333 0.933333 0.933333 0.933333
50% 0.000541 0.001308 0.966667 0.966583 0.969697 0.966667
75% 0.000633 0.001313 1.000000 1.000000 1.000000 1.000000
max 0.000647 0.001616 1.000000 1.000000 1.000000 1.000000

Leave One Out

Частния случай $k = n$ се нарича leave one out. В него правим по едно трениране за всеки елемент:

In [66]:
from sklearn.model_selection import LeaveOneOut

X = np.array([1, 2, 3, 4])
y = np.array([0.1, 0.2, 0.3, 0.4])

leave_one_out = LeaveOneOut()
for train_idx, test_idx in leave_one_out.split(X):
    print("* train: {} {}   test: {} {}".format(X[train_idx], y[train_idx], X[test_idx], y[test_idx]))
* train: [2 3 4] [ 0.2  0.3  0.4]   test: [1] [ 0.1]
* train: [1 3 4] [ 0.1  0.3  0.4]   test: [2] [ 0.2]
* train: [1 2 4] [ 0.1  0.2  0.4]   test: [3] [ 0.3]
* train: [1 2 3] [ 0.1  0.2  0.3]   test: [4] [ 0.4]

Може да ползваме LeaveOneOut за оценка по следния начин:

In [67]:
logistic = LogisticRegression()
scores = cross_val_score(logistic, iris.data, iris.target, cv=LeaveOneOut())

print(scores.mean())
print(scores)
0.953333333333
[ 1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.
  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.
  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.
  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  0.  1.  1.  1.  0.  1.
  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  0.  0.  0.  1.  1.  1.  1.
  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.
  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.
  1.  1.  1.  0.  1.  1.  1.  0.  1.  1.  1.  1.  1.  1.  1.  1.  1.  1.
  1.  1.  1.  1.  1.  1.]

Има и cross_val_predict, който прави предвиждания:

In [68]:
from sklearn.model_selection import cross_val_predict

logistic = LogisticRegression()
y_pred = cross_val_predict(logistic, iris.data, iris.target, cv=LeaveOneOut())

print(y_pred)
print(iris.target)
print((y_pred == iris.target).mean())
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 2 1 1 1
 1 1 1 1 1 1 1 1 1 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 2 2 2 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2
 2 2]
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
 2 2]
0.953333333333

Stratified k-fold

Да отбележим още веднъж, че прип класификация, fold-овете генерирани от крос валидация трябва да запазвата пропорциите на класовете. Важно е всеки клас да бъде представен равномерно във всеки fold:

In [69]:
mglearn.plots.plot_stratified_cross_validation();

cross_val_score сам се сеща да го направи при класификация, но поняга може да се наложи да ползвате StratifiedKFold.

Крос валидация на времеви редове от данни

  • Данните с времева серия се характеризират с корелацията между наблюденията, които са близки във времето (автокорелация).
  • Техники за кръстосано валидиране, като KFold и ShuffleSplit, предполагат, че пробите са независими и идентично разпределени.
  • Това би довело до неразумна връзка между тренировачните и валидационни множества (водещи до лоши оценки на грешката за генерализация).
  • Поради това е много важно да оценим модела с данни от динамични редове за "бъдещите" наблюдения.
  • За да се постигне това, се използва класа TimeSeriesSplit.
In [70]:
from sklearn.model_selection import TimeSeriesSplit

X = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
y = np.array([0, 1, 0, 1, 0, 1, 0, 1, 0, 1])

series_split = TimeSeriesSplit(n_splits=3, max_train_size=5)

for train_idx, test_idx in series_split.split(X):
    print('* train: {} test: {}'.format(X[train_idx], X[test_idx]))
* train: [0 1 2 3] test: [4 5]
* train: [1 2 3 4 5] test: [6 7]
* train: [3 4 5 6 7] test: [8 9]

Въпроси