10. Общие подводные камни и рекомендуемые практики

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

10.1. Непоследовательная предварительная обработка

scikit-learn предоставляет библиотеку Преобразования наборов данных, которые могут очищать (см. Предварительная обработка данных), уменьшать (см. Неконтролируемое снижение размерности), расширять (см. Аппроксимация ядра (Kernel Approximation)) или генерировать (см. Извлечение признаков) представления признаков. Если эти преобразования данных используются при обучении модели, они должны применяться и для последующих наборов данных, будь то тестовые данные или данные в производственной системе. В противном случае пространство признаков изменится, и модель не сможет работать эффективно.

Для следующего примера создадим синтетический набор данных с одним признаком:

>>> from sklearn.datasets import make_regression
>>> from sklearn.model_selection import train_test_split

>>> random_state = 42
>>> X, y = make_regression(random_state=random_state, n_features=1, noise=1)
>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, y, test_size=0.4, random_state=random_state)

Неправильно

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

>>> from sklearn.metrics import mean_squared_error
>>> from sklearn.linear_model import LinearRegression
>>> from sklearn.preprocessing import StandardScaler

>>> scaler = StandardScaler()
>>> X_train_transformed = scaler.fit_transform(X_train)
>>> model = LinearRegression().fit(X_train_transformed, y_train)
>>> mean_squared_error(y_test, model.predict(X_test))
62.80...

Правильно

Вместо того чтобы передавать в predict нетрансформированный X_test, мы должны преобразовать тестовые данные так же, как мы преобразовали обучающие данные:

>>> X_test_transformed = scaler.transform(X_test)
>>> mean_squared_error(y_test, model.predict(X_test_transformed))
0.90...

В качестве альтернативы мы рекомендуем использовать Pipeline, который упрощает цепочку преобразований с оценками и уменьшает вероятность забыть преобразование:

>>> from sklearn.pipeline import make_pipeline

>>> model = make_pipeline(StandardScaler(), LinearRegression())
>>> model.fit(X_train, y_train)
Pipeline(steps=[('standardscaler', StandardScaler()),
                ('linearregression', LinearRegression())])
>>> mean_squared_error(y_test, model.predict(X_test))
0.90...

Конвейеры также помогают избежать еще одного распространенного подводного камня: утечки тестовых данных в обучающие данные.

10.2. Утечка данных

Утечка данных происходит, когда при построении модели используется информация, которая не была бы доступна во время прогнозирования. Это приводит к чрезмерно оптимистичным оценкам производительности, например, по результатам cross-validation, и, следовательно, к ухудшению производительности, когда модель используется на действительно новых данных, например, в процессе производства.

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

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

10.2.1. Как избежать утечки данных

Ниже приведены некоторые советы по предотвращению утечки данных:

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

  • Никогда не включайте тестовые данные при использовании методов fit и fit_transform. Использование всех данных, например, fit(X), может привести к слишком оптимистичным оценкам.

    Напротив, метод transform следует использовать как для обучающих, так и для тестовых подмножеств, поскольку ко всем данным должна быть применена одна и та же предварительная обработка. Этого можно достичь, используя fit_transform на обучающем подмножестве и transform на тестовом подмножестве.

  • Конвейер scikit-learn pipeline - это отличный способ предотвратить утечку данных, поскольку он гарантирует, что соответствующий метод будет выполнен на правильном подмножестве данных. Конвейер идеально подходит для использования в функциях кросс-валидации и настройки гиперпараметров.

Ниже приведен пример утечки данных во время предварительной обработки.

10.2.2. Утечка данных во время предварительной обработки

Примечание

В данном случае мы решили проиллюстрировать утечку данных на этапе выбора признаков. Однако этот риск утечки данных актуален практически для всех преобразований в scikit-learn, включая (но не ограничиваясь) StandardScaler, SimpleImputer, и PCA.

В scikit-learn доступен ряд функций Отбор признаков (Feature selection). Они могут помочь удалить нерелевантные, избыточные и шумные признаки, а также улучшить время построения модели и производительность. Как и при любом другом типе предварительной обработки, при отборе признаков следует только использовать обучающие данные. Включение тестовых данных в отбор признаков приведет к оптимистическому смещению модели.

Для демонстрации создадим задачу бинарной классификации с 10 000 случайно сгенерированных признаков:

>>> import numpy as np
>>> n_samples, n_features, n_classes = 200, 10000, 2
>>> rng = np.random.RandomState(42)
>>> X = rng.standard_normal((n_samples, n_features))
>>> y = rng.choice(n_classes, n_samples)

Неправильно

Использование всех данных для отбора признаков дает результат, значительно превышающий точность, несмотря на то, что наши цели совершенно случайны. Эта случайность означает, что наши X и y независимы, и поэтому мы ожидаем, что точность будет около 0.5. Однако, поскольку этап выбора признаков «видит» тестовые данные, модель получает несправедливое преимущество. В приведенном ниже некорректном примере мы сначала используем все данные для отбора признаков, а затем разбиваем их на обучающие и тестовые подмножества для подгонки модели. В результате мы получаем гораздо более высокую, чем ожидалось, точность:

>>> from sklearn.model_selection import train_test_split
>>> from sklearn.feature_selection import SelectKBest
>>> from sklearn.ensemble import GradientBoostingClassifier
>>> from sklearn.metrics import accuracy_score

>>> # Incorrect preprocessing: the entire data is transformed
>>> X_selected = SelectKBest(k=25).fit_transform(X, y)

>>> X_train, X_test, y_train, y_test = train_test_split(
...     X_selected, y, random_state=42)
>>> gbc = GradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train, y_train)
GradientBoostingClassifier(random_state=1)

>>> y_pred = gbc.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.76

Правильно

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

>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, y, random_state=42)
>>> select = SelectKBest(k=25)
>>> X_train_selected = select.fit_transform(X_train, y_train)
>>> gbc = GradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train_selected, y_train)
GradientBoostingClassifier(random_state=1)
>>> X_test_selected = select.transform(X_test)
>>> y_pred = gbc.predict(X_test_selected)
>>> accuracy_score(y_test, y_pred)
0.46

Здесь мы снова рекомендуем использовать Pipeline для объединения в цепочку функций выбора признаков и оценки модели. Конвейер гарантирует, что при выполнении fit будут использоваться только обучающие данные, а тестовые данные будут использоваться только для вычисления оценки точности:

>>> from sklearn.pipeline import make_pipeline
>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, y, random_state=42)
>>> pipeline = make_pipeline(SelectKBest(k=25),
...                          GradientBoostingClassifier(random_state=1))
>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('selectkbest', SelectKBest(k=25)),
                ('gradientboostingclassifier',
                GradientBoostingClassifier(random_state=1))])

>>> y_pred = pipeline.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.46

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

>>> from sklearn.model_selection import cross_val_score
>>> scores = cross_val_score(pipeline, X, y)
>>> print(f"Mean accuracy: {scores.mean():.2f}+/-{scores.std():.2f}")
Mean accuracy: 0.46+/-0.07

10.3. Управление случайностью

Некоторые объекты scikit-learn являются случайными по своей природе. Обычно это модели (например, RandomForestClassifier) и кросс-валидационные сплиттеры (например, KFold). Случайность этих объектов контролируется с помощью параметра random_state, как описано в Glossary. Этот раздел расширяет содержание глоссария и описывает хорошие практики и распространенные подводные камни в отношении этого тонкого параметра.

Примечание

Краткое описание рекомендации

Для оптимальной устойчивости результатов перекрестной проверки (CV) передавайте экземпляры RandomState при создании модели, или оставьте random_state в None. Передача целых чисел в CV-сплиттеры обычно является наиболее безопасным и предпочтительным вариантом; передача экземпляров RandomState в сплиттеры иногда может быть полезна для достижения очень специфических случаев использования. Как для модели, так и для расщепителей передача целого числа по сравнению с передачей экземпляра (или None) приводит к тонким, но существенным различиям, особенно для процедур CV. Эти различия важно понимать при представлении результатов.

Для получения воспроизводимых результатов в разных исполнениях удалите любое использование random_state=None.

10.3.1. Использование экземпляров None или RandomState, а также повторные вызовы fit и split

Параметр random_state определяет, приведет ли многократный вызов fit (для моделей) или split (для CV-сплиттеров) к одинаковым результатам, в соответствии с этими правилами:

  • Если передано целое число, то многократный вызов fit или split всегда приводит к одинаковым результатам.

  • Если передано None или экземпляр RandomState: fit и split будут давать разные результаты каждый раз, когда их вызывают, и последовательность вызовов использует все источники энтропии. По умолчанию для всех параметров random_state используется значение None.

Здесь мы проиллюстрируем эти правила как для оценочников, так и для CV-разделителей.

Примечание

Поскольку передача random_state=None эквивалентна передаче глобального экземпляра RandomState из numpy (random_state=np.random.mtrand._rand), мы не будем явно упоминать None здесь. Все, что относится к экземплярам, относится и к использованию None.

10.3.1.1. Модели

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

>>> from sklearn.linear_model import SGDClassifier
>>> from sklearn.datasets import make_classification
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(n_features=5, random_state=rng)
>>> sgd = SGDClassifier(random_state=rng)
>>> sgd.fit(X, y).coef_
array([[ 8.85418642,  4.79084103, -3.13077794,  8.11915045, -0.56479934]])
>>> sgd.fit(X, y).coef_
array([[ 6.70814003,  5.25291366, -7.55212743,  5.18197458,  1.37845099]])

Из приведенного выше фрагмента видно, что многократный вызов gd.fit привел к получению разных моделей, даже если данные были одинаковыми. Это происходит потому, что генератор случайных чисел (Random Number Generator - RNG) модели потребляется (т.е. мутирует) при вызове fit, и этот мутировавший RNG будет использоваться в последующих вызовах fit. Кроме того, объект rng является общим для всех объектов, которые его используют, и, как следствие, эти объекты становятся в некоторой степени взаимозависимыми. Например, две модели, использующие один и тот же экземпляр RandomState, будут влиять друг на друга, как мы увидим позже, когда будем обсуждать клонирование. Этот момент важно иметь в виду при отладке.

Если бы мы передали целое число параметру random_state в SGDClassifier, то каждый раз получали бы одни и те же модели, а значит, и одни и те же оценки. Если мы передаем целое число, то при всех вызовах fit используется один и тот же RNG. Внутренне происходит так: хотя RNG расходуется при вызове fit, он всегда возвращается в исходное состояние в начале fit.

10.3.1.2. Сплиттеры CV

Случайные CV-сплиттеры ведут себя аналогично, когда передается экземпляр RandomState; вызов split несколько раз приводит к разным разбиениям данных:

>>> from sklearn.model_selection import KFold
>>> import numpy as np

>>> X = y = np.arange(10)
>>> rng = np.random.RandomState(0)
>>> cv = KFold(n_splits=2, shuffle=True, random_state=rng)

>>> for train, test in cv.split(X, y):
...     print(train, test)
[0 3 5 6 7] [1 2 4 8 9]
[1 2 4 8 9] [0 3 5 6 7]

>>> for train, test in cv.split(X, y):
...     print(train, test)
[0 4 6 7 8] [1 2 3 5 9]
[1 2 3 5 9] [0 4 6 7 8]

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

10.3.2. Общие подводные камни и тонкости

Хотя правила, управляющие параметром random_state, кажутся простыми, они, тем не менее, имеют некоторые тонкие последствия. В некоторых случаях это может даже привести к неверным выводам.

10.3.2.1. Модели

Различные типы `random_state` приводят к различным процедурам кросс-валидации.

В зависимости от типа параметра random_state, модели будут вести себя по-разному, особенно в процедурах кросс-валидации. Рассмотрим следующий фрагмент:

>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import cross_val_score
>>> import numpy as np

>>> X, y = make_classification(random_state=0)

>>> rf_123 = RandomForestClassifier(random_state=123)
>>> cross_val_score(rf_123, X, y)
array([0.85, 0.95, 0.95, 0.9 , 0.9 ])

>>> rf_inst = RandomForestClassifier(random_state=np.random.RandomState(0))
>>> cross_val_score(rf_inst, X, y)
array([0.9 , 0.95, 0.95, 0.9 , 0.9 ])

Мы видим, что кросс-валидированные оценки rf_123 и rf_inst отличаются, как и следовало ожидать, поскольку мы не передали один и тот же параметр random_state. Однако разница между этими оценками более тонкая, чем кажется, и процедуры кросс-валидации, которые были выполнены cross_val_score значительно отличаются в каждом случае:

  • Поскольку rf_123 было передано целое число, каждый вызов fit использует один и тот же RNG: это означает, что все случайные характеристики оценки случайного леса будут одинаковыми для каждой из 5 фолдов процедуры CV. В частности, (случайно выбранное) подмножество признаков модели будет одинаковым во всех фолдах.

  • Поскольку rf_inst был передан экземпляр RandomState, каждый вызов fit начинается с другого RNG. В результате случайное подмножество признаков будет разным для каждого фолда.

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

Примечание

Здесь cross_val_score будет использовать нерандомизированный CV-сплиттер (как и по умолчанию), поэтому обе оценки будут оцениваться на одних и тех же сплитах. В этом разделе не рассматривается вариабельность разбиение. Кроме того, передаем ли мы целое число или экземпляр в make_classification, не имеет значения для нашей иллюстрации: важно то, что мы передаем в оценщик RandomForestClassifier.

Клонирование Click for more details

Еще один тонкий побочный эффект передачи экземпляров RandomState заключается в том, как будет работать clone:

>>> from sklearn import clone
>>> from sklearn.ensemble import RandomForestClassifier
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> a = RandomForestClassifier(random_state=rng)
>>> b = clone(a)

Поскольку экземпляр RandomState был передан в a, a и b не являются клонами в строгом смысле, а скорее клонами в статистическом смысле: a и b все равно будут разными моделями, даже при вызове fit(X, y) на одних и тех же данных. Более того, a и b будут влиять друг на друга, поскольку у них один и тот же внутренний RNG: вызов a.fit будет потреблять RNG b, а вызов b.fit будет потреблять RNG a, поскольку они одинаковы. Это справедливо для всех оценочных устройств, имеющих общий параметр random_state; это не относится к клонам.

Если бы было передано целое число, то a и b были бы точными клонами и не влияли бы друг на друга.

Предупреждение

Несмотря на то, что clone редко используется в пользовательском коде, она повсеместно вызывается в кодовой базе scikit-learn: в частности, большинство метамоделей, которые принимают не обученные оценки, вызывают clone (GridSearchCV, StackingClassifier, CalibratedClassifierCV, и т.д.).

10.3.2.2. Сплиттеры CV

При передаче экземпляра RandomState, CV-сплиттеры выдают разные сплиты каждый раз, когда вызывается split. При сравнении различных оценок это может привести к переоценке дисперсии разницы в производительности между оценками:

>>> from sklearn.naive_bayes import GaussianNB
>>> from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import KFold
>>> from sklearn.model_selection import cross_val_score
>>> import numpy as np

>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(random_state=rng)
>>> cv = KFold(shuffle=True, random_state=rng)
>>> lda = LinearDiscriminantAnalysis()
>>> nb = GaussianNB()

>>> for est in (lda, nb):
...     print(cross_val_score(est, X, y, cv=cv))
[0.8  0.75 0.75 0.7  0.85]
[0.85 0.95 0.95 0.85 0.95]

Прямое сравнение производительности модели LinearDiscriminantAnalysis с оценщиком GaussianNB на каждом фолде было бы ошибкой: сплиты, на которых оцениваются эти оценки, различны. Действительно, cross_val_score будет внутренне вызывать cv.split на одном и том же экземпляре KFold, но разбиение каждый раз будут разными. Это также справедливо для любого инструмента, выполняющего выбор модели через перекрестную проверку, например, GridSearchCV и RandomizedSearchCV: оценки не сравнимы между собой при разных вызовах search.fit, поскольку cv.split вызывался несколько раз. Однако в рамках одного вызова search.fit сравнение fold-to-fold возможно, поскольку поисковый оценщик вызывает cv.split только один раз.

Для получения сопоставимых результатов fold-to-fold во всех сценариях следует передавать целое число в разделитель CV: cv = KFold(shuffle=True, random_state=0).

Примечание

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

Примечание

В этом примере важно то, что было передано в KFold. Передаем ли мы экземпляр RandomState или целое число в make_classification, не имеет значения для нашей иллюстрации. Кроме того, ни LinearDiscriminantAnalysis, ни GaussianNB не являются рандомизированными моделими.

10.3.3. Общие рекомендации

10.3.3.1. Получение воспроизводимых результатов при многократном выполнении

Чтобы получить воспроизводимые (т.е. неизменные) результаты при нескольких выполнениях программы, нам нужно удалить все использования random_state=None, которое используется по умолчанию. Рекомендуемый способ - объявить переменную rng в верхней части программы и передавать ее вниз любому объекту, принимающему параметр random_state:

>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import train_test_split
>>> import numpy as np

>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(random_state=rng)
>>> rf = RandomForestClassifier(random_state=rng)
>>> X_train, X_test, y_train, y_test = train_test_split(X, y,
...                                                     random_state=rng)
>>> rf.fit(X_train, y_train).score(X_test, y_test)
0.84

Теперь мы гарантируем, что результат работы этого скрипта всегда будет равен 0.84, сколько бы раз мы его ни запускали. Изменение глобальной переменной rng на другое значение должно повлиять на результаты, как и ожидалось.

Можно также объявить переменную rng как целое число. Однако это может привести к менее надежным результатам кросс-валидации, как мы увидим в следующем разделе.

Примечание

Мы не рекомендуем задавать глобальный сид numpy вызовом np.random.seed(0). Обсуждение см. здесь <https://stackoverflow.com/questions/5836335/consistently-create-same-random-numpy-array/5837352#comment6712034_5837352>`_.

10.3.3.2. Надежность результатов кросс-валидации

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

По этим причинам предпочтительнее оценивать эффективность кросс-валидации, позволяя модели использовать разные RNG на каждом фолде. Это делается путем передачи экземпляра RandomState (или None) при инициализации модели.

Когда мы передаем целое число, модель будет использовать один и тот же RNG на каждом фолде: если модель работает хорошо (или плохо), как оценивает CV, это может быть просто потому, что нам повезло (или не повезло) с этим конкретным сидом. Передача экземпляров приводит к более надежным результатам CV и делает сравнение между различными алгоритмами более справедливым. Это также помогает ограничить соблазн рассматривать RNG модели как гиперпараметр, который можно настраивать.

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