Как строить надёжные оценки качества моделей и никогда не смешивать train и test

Кросс-валидация — это процедура для оценки качества работы модели, которая широко применяется в машинном обучении. Она помогает сравнить между собой различные модели и выбрать наилучшую для конкретной задачи.

В этом разделе мы рассмотрим наиболее распространённые методы кросс-валидации, а также обсудим возможные проблемы, которые могут возникнуть в процессе их применения.

Hold-out

Метод hold-out представляет из себя простое разделение на train и test:

Источник

Такое разделение очень легко реализовать с помощью библиотеки sklearn:

import numpy as np
from sklearn.model_selection import train_test_split
 
X, y = np.arange(1000).reshape((500, 2)), np.arange(500)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, 
    test_size=0.2, 
    random_state=42
)

Чтобы оценить модель, вы обучаете её на тренировочном множестве, а результаты измеряете на тестовом. У sklearn по дефолту выставлен параметр shuffle=True, то есть перед разделением на тренировочное и тестовое множества происходит перемешивание семплов (и для воспроизводимости такого разбиения нужно фиксировать random_state).

А что будет, если не перемешать данные?

Если обучение модели не зависит от порядка подачи в неё примеров (что верно, например, для k-NN или решающего дерева), то перемешивание данных влияет только на то, кто в итоге окажется в train и test. Если данные шли какими-то группами, например сначала 800 картинок с кошками, а за ними 200 картинок с собаками, а train_test_split был совершён в пропорции 0.8, то модель просто не увидит собак в трейне.

А в случае когда модель обучается с помощью градиентного спуска или его вариации (про различные модификации SGD подробно рассказывается в параграфе о нейросетях), отсутствие перемешивания данных может влиять более интересным образом.

Вот пример из практики Yandex.Research — как вы думаете, что не так с графиком обучения данной модели?

Источник: курс Лены Войты по NLP
Ответ (не открывайте сразу; сначала подумайте сами!)

На графике видна периодичность по числу итераций! По большим пикам можно вычислить места, где проход по данным начался заново. Кроме того, график в конце ползёт вниз, что означает, что модель уже начала переобучаться, выучив последовательность данных на трейне и используя эту информацию больше, чем сами данные.

Если данные перемешать, то график обучения станет таким:

Источник: курс Лены Войты по NLP

Можно привести даже более простой пример, когда отсутствие перемешивания данных может вас сильно подвести. Допустим, у вас большой датасет из миллиона кошек и собак и вам нужно научить модель их различать.

Пусть изначальный порядок тренировочных данных такой: сначала подряд идёт полмиллиона картинок с кошками, а затем так же подряд идут картинки с собаками. Тогда модель на первой половине обучения выучит, что на картинке всегда кошка, а за вторую забудет, что учила на первой, и будет всегда предсказывать собак. При этом на сами данные при предсказании она опираться не будет вообще.

Продолжим. Если у вас достаточно данных, лучше всегда предусматривать также валидационное множество:

import numpy as np
from sklearn.model_selection import train_test_split
 
X, y = np.arange(1000).reshape((500, 2)), np.arange(500)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, 
    test_size=0.2, 
    random_state=42
)
X_train, X_val, y_train, y_val = train_test_split(
    X_train, y_train, 
    test_size=0.1, 
    random_state=42
)

Если вы перебираете какие-то модели для вашей задачи, то оптимизировать их качества стоит на валидационном множестве, а окончательное сравнение моделей проводить на тестовом множестве.

Оптимизация качеств модели может включать в себя подбор гиперпараметров, подбор архитектуры (в случае нейросетей) или подбор оптимального трешолда для максимизации значений целевой метрики (например, вы делаете двуклассовую классификацию, а модель выдаёт непрерывные значения от 0 до 1, которые нужно бинаризовать так, чтобы получить максимальный скор по F1) и так далее.

Если же оптимизировать качества моделей и проводить их сравнение на одном и том же множестве, то можно неявно заложить в модели информацию о тестовом множестве и получить результаты хуже ожидаемых на новых данных.

Немного прервёмся на пример — к чему может привести неявное использование моделью тестового множества

Представьте, что вы хотите обучить модель одномерной линейной регрессии для предсказания ваших данных:

где и — искомые параметры вашей модели.

Однако представьте, что параметр вам кто-то запретил обучать на тренировочном множестве и для вас у этой модели всего один параметр. Пусть на первой итерации у вас задано какое-то фиксированное , вы с ним подобрали на трейне лучшее при данном и замерили качество получившейся модели на тестовом множестве.

На следующей итерации вы взяли новое значение , повторили с ним предыдущий шаг и так далее. Теперь пришло время выбирать модель, и из всех них вы выбрали ту, которая показала лучший результат на тестовом множестве. Вам может показаться, что ваша модель с одним параметром обучена на трейне и всё хорошо, но на самом деле вы использовали оба множества, чтобы обучить модель с двумя параметрами, и теперь ваша тестовая оценка качества модели завышена.

Может показаться, что этот пример довольно искусственный, но он на самом деле легко переносится на модели любой сложности. Просто представьте себе, что часть обучаемых весов вашей сложной модели вам запретили обучать на трейне и вы начинаете так же, как и выше, оценивать их на тесте, то есть по факту учить на тесте.

А чем такая ситуация отличается от подбора гиперпараметров модели (которые вы уже действительно не можете обучить на трейне) сразу на тестовом множестве? Вообще говоря, ничем.

Продолжим. Для окончательного применения найденную лучшую модель можно обучить на всех имеющихся данных. Правда, вы не сможете оценить качество получившейся модели, так как у вас уже не будет тестового множества. Чтобы примерно оценить, как будет вести себя модель при добавлении новых данных, вы можете построить кривые обучения: графики качества модели на трейне и на тесте в зависимости от числа поданных семплов на вход.

Кривые обучения могут выглядеть следующим образом (код для отрисовки таких кривых можно найти в документации библиотеки sklearn):

Источник

Если графики подсказывают, что качество модели по валидационным метрикам продолжает расти, имеет смысл добавить новые данные.

На картинке выше приведены кривые обучения двух моделей на одном и том же датасете. Модель слева показала итоговые результаты явно хуже модели справа — плюс график качества на валидации у неё близок к плато, хотя и продолжает расти, — а качество модели справа могло бы ещё вырасти при добавлении дополнительных семплов (качество на трейне константно высокое, а на валидации возрастает).

Стратификация (stratification)

При простом случайном разделении на тренировочное и тестовое множества (как в примерах выше) может случиться так, что их распределения окажутся не такими, как у всего исходного множества. Проиллюстрируем такую ситуацию на примере случайного разбиения датасета Iris на трейн и тест. Распределение классов в данном датасете равномерное:

  • Setosa
  • Versicolor
  • Virginica

Случайное разбиение, в котором две трети цветов (100) отправились в трейн, а оставшаяся треть (50) отправилась в тест, может выглядеть, например, так:

  • трейн: 38 Setosa, 28 Versicolor, 34 Virginica (распределение )
  • тест: 12 Setosa, 22 Versicolor, 16 Virginica (распределение )

Если распределение цветов в исходном датасете отражает то, что в природе они встречаются одинаково часто, то мы только что получили два новых датасета, не соответствующих распределению цветов в природе. Распределения обоих датасетов вышли не только несбалансированными, но ещё и разными: самый частый класс в трейне соответствует наименее частому классу в тесте.

На помощь в такой ситуации может прийти стратификация: разбиение на трейн и тест, сохраняющее соотношение классов, представленное в исходном датасете. В библиотеке sklearn такое разбиение можно получить с помощью параметра stratify:

import numpy as np
from sklearn.model_selection import train_test_split
 
X, y = np.arange(1000).reshape((500, 2)), np.random.choice(4, size=500, p=[0.1, 0.2, 0.3, 0.4])
X_train, X_test, y_train, y_test = train_test_split(
    X, y, 
    test_size=0.2, 
    random_state=42,
    stratify=y
)

В целом на достаточно больших датасетах (порядка хотя бы 10 тысяч семплов) со сбалансированными классами можно не очень сильно беспокоиться об описанной выше проблеме и использовать обычный random split.

Но если у вас очень несбалансированные данные, в которых один класс встречается сильно чаще другого (как, например, в задачах фильтрации спама или сегментации осадков на спутниковых снимках), стратификация может довольно сильно помочь.

k-Fold

Метод k-Fold чаще всего имеют в виду, когда говорят о кросс-валидации. Он является обобщением метода hold-out и представляет из себя следующий алгоритм:

  1. Фиксируется некоторое целое число (обычно от 5 до 10), меньшее числа семплов в датасете.
  2. Датасет разбивается на одинаковых частей (в последней части может быть меньше семплов, чем в остальных). Эти части называются фолдами.
  3. Далее происходит итераций, во время каждой из которых один фолд выступает в роли тестового множества, а объединение остальных — в роли тренировочного. Модель учится на фолде и тестируется на оставшемся.
  4. Финальный скор модели получается либо усреднением получившихся тестовых результатов, либо измеряется на отложенном тестовом множестве, не участвовавшем в кросс-валидации.
Источник

Этот метод есть в sklearn:

import numpy as np
from sklearn.model_selection import KFold
 
X = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]])
y = np.array([1, 2, 3, 4])
kf = KFold(n_splits=2)
 
for train_index, test_index in kf.split(X):
    print("TRAIN:", train_index, "TEST:", test_index)
    X_train, X_test = X[train_index], X[test_index]
    y_train, y_test = y[train_index], y[test_index]
'''
result:
TRAIN: [2 3] TEST: [0 1]
TRAIN: [0 1] TEST: [2 3]
'''

В коде выше получилось два фолда: в первый вошли объекты с индексами 2 и 3, во второй — объекты с индексами 0 и 1. На первой итерации алгоритма фолд с индексами 2 и 3 будет тренировочным, а на второй — фолд с индексами 0 и 1. В sklearn есть также метод cross_val_score, принимающий на вход классификатор, данные и способ разбиения данных (либо число фолдов) и возвращающий результаты кросс-валидации:

from sklearn.model_selection import cross_val_score
 
clf = svm.SVC(kernel='linear', C=1, random_state=42)
scores = cross_val_score(clf, X, y, cv=5)
print(scores)
'''
result:
array([0.96..., 1. , 0.96..., 0.96..., 1. ])
'''

Интересный вопрос состоит в том, какую модель брать для сравнения с остальными на отложенном тестовом множестве (если оно у вас есть) либо для окончательного применения в задаче. После применения k-Fold для одной модели у вас на руках останется экземпляров (инстансов) этой модели, обученных на разных подмножествах трейна. Возможные варианты:

  • делать предсказание с помощью усреднения предсказаний этих инстансов;
  • из этих инстансов выбрать тот, который набрал лучший скор на своём тестовом фолде, и применять дальше его;
  • заново обучить модель уже на всех фолдах и делать предсказания уже этой моделью.

Выбирать, какой способ лучше, нужно в зависимости от конкретной задачи и имеющихся вычислительных возможностей.

Метод k-Fold даёт более надёжную оценку качества модели, чем hold-out, так как обучение и тест модели происходят на разных подмножествах исходного датасета. Однако проведение итераций обучения и теста может быть вычислительно затратным, и поэтому метод обычно применяют либо когда данных достаточно мало, либо при наличии большого количества вычислительных ресурсов, позволяющих проводить все итераций параллельно.

В реальных задачах данных зачастую достаточно много для того, чтобы hold-out давал хорошую оценку качества модели, поэтому k-Fold в больших задачах применяется не очень часто.

Leave-one-out

Метод leave-one-out (LOO) — частный случай метода k-Fold: в нём каждый фолд состоит ровно из одного семпла. LOO тоже есть в библиотеке sklearn:

import numpy as np
from sklearn.model_selection import LeaveOneOut
 
X = np.array([[1, 2], [3, 4], [5, 6]])
y = np.array([1, 2, 3])
loo = LeaveOneOut()
 
for train_index, test_index in loo.split(X):
    print("TRAIN:", train_index, "TEST:", test_index)
    X_train, X_test = X[train_index], X[test_index]
    y_train, y_test = y[train_index], y[test_index]
'''
result:
TRAIN: [1 2] TEST: [0]
TRAIN: [0 2] TEST: [1]
TRAIN: [0 1] TEST: [2]
'''

Этот метод может понадобиться в случае, если у вас очень мало данных, — например, в задаче сегментации клеток на изображениях с оптического микроскопа, — и вы хотите использовать максимальное их количество для обучения модели.

Для валидации на каждой итерации методу требуется всего один семпл, однако и итераций будет столько, сколько семплов в данных, поэтому метод неприменим для средних и больших задач.

Stratified k-Fold

Метод stratified k-Fold — это метод k-Fold, использующий стратификацию при разбиении на фолды: каждый фолд содержит примерно такое же соотношение классов, как и всё исходное множество. Такой подход может потребоваться в случае, например, очень несбалансированного соотношения классов, когда при обычном random split некоторые фолды могут либо вообще не содержать семплов каких-то классов, либо содержать их слишком мало. Этот метод также представлен в sklearn:

import numpy as np
from sklearn.model_selection import StratifiedKFold
 
X = np.array([[1, 2], [3, 4], [1, 2], [3, 4]])
y = np.array([0, 0, 1, 1])
skf = StratifiedKFold(n_splits=2)
 
for train_index, test_index in skf.split(X, y):
    print("TRAIN:", train_index, "TEST:", test_index)
    X_train, X_test = X[train_index], X[test_index]
    y_train, y_test = y[train_index], y[test_index]
'''
result:
TRAIN: [1 3] TEST: [0 2]
TRAIN: [0 2] TEST: [1 3]
'''

Кросс-валидация на временных рядах

Существует такая задача, как прогнозирование временных рядов. На практике она часто возникает в форме «Что будет с показателями нашего продукта в ближайший день / месяц / год?». При этом имеются какие-то исторические данные этих показателей за предыдущее время, которые можно визуализировать в виде некоторого графика по времени:

Источник

Этот график — пример графика временного ряда, и наша задача — спрогнозировать, как будет выглядеть данный график в будущие моменты времени. Кросс-валидация моделей для такой задачи осложняется тем, что данные не должны пересекаться по времени: тренировочные данные должны идти до валидационных, а валидационные — до тестовых. С учётом этих особенностей фолды в кросс-валидации для временных рядов располагаются вдоль временной оси так, как показано на следующей картинке:

Источник

В sklearn реализована такая схема кросс-валидации:

import numpy as np
from sklearn.model_selection import TimeSeriesSplit
X = np.array([[1, 2], [3, 4], [1, 2], [3, 4], [1, 2], [3, 4]])
y = np.array([1, 2, 3, 4, 5, 6])
tscv = TimeSeriesSplit()
print(tscv)
 
for train_index, test_index in tscv.split(X):
    print("TRAIN:", train_index, "TEST:", test_index)
    X_train, X_test = X[train_index], X[test_index]
    y_train, y_test = y[train_index], y[test_index]
 
'''
result:
TRAIN: [0] TEST: [1]
TRAIN: [0 1] TEST: [2]
TRAIN: [0 1 2] TEST: [3]
TRAIN: [0 1 2 3] TEST: [4]
TRAIN: [0 1 2 3 4] TEST: [5]
'''

Когда стоит заподозрить, что оценка качества модели завышена?

Ваша модель показала очень высокое качество на тестовых данных, вы радостно откидываетесь на спинку кресла и достаёте шампанское... Или пока рано? Перед тем как информировать коллег о своих высоких результатах, проверьте, что вы не допустили какую-то из следующих ошибок:

  • ваши данные не были перемешаны (вспоминаем пример выше с тензорбордом курильщика);
  • вы подбирали гиперпараметры на тестовом множестве и на нём же оценивали качество модели;
  • у вас в данных есть фича, которая в некотором смысле является «прокси» к таргету (proxy for the target). Это такая фича, которая почти равна таргету, хотя формально им не является и так же, как и таргет, не будет доступна на момент реального применения модели;
Пример

Пусть вы хотите предсказывать, сколько будут зарабатывать выпускники разных вузов с разных факультетов через 10 лет после выпуска. Допустим, что у вас есть разнообразные исторические данные о прошлых выпускниках (какие вуз / школу оканчивали, какие факультеты, в каком городе и так далее), где много колонок, и есть искушение особенно не вглядываться в каждую отдельную колонку, а просто разбить данные на трейн и тест и отправить в модель. Но потом вдруг обнаруживается, что у вас всё это время имелась колонка «Доход через пять лет после выпуска», которая явно скоррелирована с таргетом и является важной для вашей модели, но на момент реального применения модели этой информации у вас не будет. Соответственно, наличием этой колонки во многом и объяснялся высокий скор вашей модели. Мораль: всегда внимательно изучайте свои данные перед обучением моделей.

  • вы проводили feature engineering на всём датасете, а не только на трейне. Например, вы строили tf-idf фичи или bag-of-words на всех данных, а не только на трейне, тем самым заложив в свои тренировочные данные информацию о тестовых данных;
  • вы применяли стандартизацию данных на всём датасете, а не только на трейне. Например, в случае StandardScaler тестовое множество повлияет на используемые этим методом оценки среднего и стандартного отклонения;
  • вы смешали трейн с тестом.

Последний пункт может звучать очень банально, но на практике часто оказывается, что правильно разделить данные на тренировочные и тестовые не так просто даже с учётом всех описанных выше техник. Об этом в следующих разделах.

Примеры подмешивания тестовых данных в тренировочные

  • Ваши данные зависят от времени, а вы при разбиении на трейн и тест это не учли. Например, вы применили обычный random split при работе с временными рядами, передав тем самым вашей модели информацию из будущего. Или вы предсказываете погоду на несколько часов вперёд, а у вас данные из одного и того же дня находятся и в трейне, и в тесте.
  • У вас есть датасет с картинками, и вы решили увеличить количество семплов в нём с помощью аугментаций (примерами аугментаций могут служить симметричные отражения, повороты, растяжения). При этом вы взяли весь датасет, применили к нему аугментации и только после этого разделили на трейн и тест. В таком случае преобразования какой-то одной картинки могут попасть в оба множества, и вы получите пересечение трейна и теста.
  • Вы решаете задачу рекомендации статей или постов пользователям на основании их комментариев и прочтений, при этом в трейне и тесте у вас одни и те же пользователи.
  • Вы решаете какую-то задачу, где происходит работа с видеоданными. Например, распознаёте движение по видео или предсказываете фамилию актёра, попавшего в кадр. При этом в трейн и тест у вас попадают различные кадры из одного и того же видео.
  • У вас есть спутниковые снимки, и вы хотите по ним предсказывать рельеф местности. При этом у вас в трейне и тесте есть кропы снимков над одними и теми же географическими координатами (хоть и в разное время).
  • Вы обучаете голосового ассистента в звуковом потоке распознавать момент, когда к нему обращаются (например, «Слушай, Алиса», «Ok, Google»). При этом у вас в трейне и тесте одни и те же люди. Это, на первый взгляд, не очень страшная проблема, но на самом деле достаточно большая нейронка может запомнить интонации и манеру речи конкретного человека и будет использовать эти сведения для тестовых записей с этим человеком. При этом на новых людях распознавание будет работать сильно хуже.
  • Вы хотите расширить тренировочный датасет какими-то дополнительными данными из другого датасета, но при этом оказывается, что другой датасет содержит в себе часть тестового множества вашего исходного датасета. Например, есть два публичных датасета: ImageNet LSVRC 2015, в котором 1000 классов и чуть больше миллиона изображений, и ImageNet, в котором 21 тысяча классов и чуть больше 14 миллионов изображений. При этом первый полностью содержится во втором, поэтому использование ImageNet для расширения обучающей выборки из ImageNet LSVRC 2015 может закончиться тем, что в трейне окажутся примеры из тестового множества, сформированного из ImageNet LSVRC 2015.

Ещё один интересный пример, когда что-то пошло не так

Пример заимствован отсюда. Допустим, что вы должны обучить модель, предсказывающую тему новостной статьи по её тексту. Если отсортировать статьи по дате их публикации, то ваши данные могут выглядеть, например, так:

Источник

Здесь форма и цвет фигуры соответствуют новости, которой посвящена статья. Почему случайное разбиение данных на трейн и тест может привести к проблемам в этой задаче?

На самом деле новостные статьи с одной и той же тематикой появляются кластерами во времени, так как статьи о новом событии выходят, как правило, порциями в то же время, когда произошло событие. Если разбить данные случайно, то тренировочное и тестовое множества с большой вероятностью будут содержать статьи на одни и те же наборы тем:

Источник

Такое разбиение не соответствует тому, как потом модель будет применяться в реальной задаче: при нём модель будет ожидать равномерного распределения тем, предложенных ей в трейне, тогда как в реальности ей на вход будут приходить всё те же кластеры, и они, вообще говоря, не обязаны были быть в её тренировочном множестве. Простым решением будет при разбиении на трейн и тест учитывать время, когда была опубликована статья:

Источник

Тут нужно, однако, учитывать, что в реальности кластеры историй по времени выражены не столь чётко и могут пересекаться. Поэтому если трейн и тест расположены слишком близко друг от друга по времени, то они могут пересечься. В принципе, это не так плохо с учётом того, что новости о каких-то событиях могут продолжать выходить в течение некоторого растянутого промежутка времени. Но если хочется избежать такой ситуации, то можно оставить между трейном и тестом некоторый временной зазор: тренироваться, например, на апрельских публикациях, а тестироваться на второй неделе мая, оставив, таким образом, недельный промежуток между двумя множествами.

Почитать по теме

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.1. Метрики классификации и регрессии

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

Следующий параграф3.3. Подбор гиперпараметров

Как эффективно подбирать значения гиперпараметров модели и не переобучиться при этом