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

Команда VK Cloud перевела статью о том, какими могут быть подобные утечки и как с ними бороться. 

В чём суть проблемы


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

Представьте, что вы готовитесь к экзамену по математике. Вы решаете много задач, чтобы получше натренироваться. И тут выясняете, что вопросы к экзамену случайно выложили в интернет. У вас есть доступ к этой критически важной информации и возможность всё решить. То есть вы начинаете учиться на датасете, который, по идее, должен был попасть к вам только на экзамене, и таким образом вы «запоминаете» паттерны. Результат? Вы вызубрили задачи «тестового датасета» и получили нереалистично высокую оценку за эту часть экзамена, но когда речь заходит о выполнении реальных задач… лучше даже не говорить, что будет.

Утечка информации о целевой переменной


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

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

Контаминация обучающих и тестовых данных и утечка данных во время предварительной обработки 


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

scaler = StandardScaler()
scaler.fit(X_train)
scaler.transform(X_train)
scaler.transform(X_test)

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

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

Последствия утечки данных


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

В конечном счёте из-за неточной оценки модели вы получаете ненадёжные прогнозы. Какая бессмысленная трата ресурсов!

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


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

Например, в случае очень высокой корреляции признака с целевой переменной нужно проявить здоровый скепсис и тщательно исследовать эти отношения. Иногда корреляцию можно выявить с помощью разведочного анализа данных (Exploratory Data Analysis, EDA)). Более того, разносторонние предметные и экспертные знания помогают понять, следует ли включать признак в модель. Если сомневаетесь, всегда задавайте себе наводящий вопрос: «Содержит ли этот признак информацию, которая не будет доступна на момент прогнозирования?». Если ответ на утвердительный, то использование этого признака может привести к утечке данных.

Предотвращение утечки данных: пайплайн рулит!


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

Впервые я столкнулся с идеей использования пайплайна в книге "Data Cleaning and Exploration with Machine Learning". Из неё я извлёк несколько важнейших уроков: писать код, встраивая каждый этап предварительной обработки в качестве аргументов переменной в метод  make_pipeline, и разделять этапы для числовых, категориальных и бинарных переменных.

Пайплайн — это линейная последовательность выполняемых друг за другом действий по предварительной обработке данных. Пайплайны позволяют создавать чёткую, упорядоченную цепочку действий для автоматизации рабочего процесса в рамках проекта машинного обучения. Мы можем использовать в библиотеке scikit-learn класс Pipeline, который в качестве вводных данных содержит список кортежей, каждый из которых представляет собой один этап пайплайна. Первый элемент каждого кортежа — это строка с именем этапа. Второй элемент — экземпляр объекта transformer или estimator в scikit-learn. Конечно, можно воспользоваться и укороченным альтернативным вариантом make_pipeline, для которого не нужно давать имена алгоритмам оценки (ведь все мы ленивые создания). Не забудьте, для алгоритмов оценки нужны оба метода: и fit, и transform.

Зачем вообще делать пайплайны


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

Вот пример: в датасете нужно предварительно обработать числовые, категориальные и бинарные признаки, и для каждого из них нужны разные этапы. Можно использовать make_pipeline, чтобы упорядочить процесс, а пайплайн позаботится обо всех закулисных задачах. В результате получаем объект Pipeline, у которого есть несколько вызываемых атрибутов и методов. Например, можно вызвать fit(X_train, y_train) и score(X_test, y_test) для тренировки и оценки модели соответственно.

from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import make_pipeline
from feature_engine.encoding import OneHotEncoder
from preprocfunc import OutlierTrans #self-created Python class

standardtrans = make_pipeline(OutlierTrans(2), 
                              StandardScaler()
                             )

categoricaltrans = make_pipeline(SimpleImputer(strategy="most_frequent"), 
                                 OneHotEncoder(drop_last=True)
                                )

binarytrans = make_pipeline(SimpleImputer(strategy="most_frequent")
                           )

columntrans = ColumnTransformer(transformers=[
    ("standard", standardtrans, numerical_cols),
    ("categorical", categoricaltrans, ['gender']),
    ("binary", binarytrans, ['completedba'])
])

lr = LinearRegression()
pipe = make_pipeline(columntrans, KNNImputer(n_neighbors=5), lr)

Предотвращение утечки данных: перекрёстный контроль


На этоу главу меня вдохновила книга «Data Cleaning and Exploration with Machine Learning». Ещё я извлек из неё идею объединения концепций пайплайна и перекрёстного контроля — оказалось, это не взаимоисключающие вещи! Очень важно правильно подобрать обучающие и тестовые датасеты, ведь в противном случае это может привести к утечке данных. Если не выполнять перекрёстный контроль для оценки модели, можно столкнуться с риском чрезмерного обучения, а также с получением слабых результатов с новыми данными, которые модель не видела. Например, иногда в результате однократного разделения данных на обучающие и тестовые модель случайно тренируется на конкретном признаке, характерном исключительно для этой разбивки и не поддающемся обобщению.

Для чего нужен перекрёстный контроль


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

Для этого можно воспользоваться K-кратным перекрёстным контролем (CV) в библиотеке scikit-learn.

Как работает K-кратный CV? Сначала данные делят на k равных групп, после чего модели обучают на k-1 группах, а потом тестируют на последней группе. Эта операция повторяется k раз, и при этом каждая группа по одному разу выступает в качестве тестового датасета. В конце итерации генерируется оценка эффективности модели путем вычисления средних результатов по k итераций. Если k равно 1 — мы возвращаемся к обычной разбивке данных на обучающие и тестовые. Мы учим модель на всём датасете и тестируем её на отдельном датасете.

Хорошая новость — можно продолжить с того места в пайплане, где мы остановились.

from sklearn.model_selection import cross_validate, KFold

ttr = TransformedTargetRegressor(regressor=pipe, transformer=StandardScaler())

kf = KFold(n_splits=5, shuffle=True, random_state=0)
scores = cross_validate(ttr, 
                        X=X_train, 
                        y=y_train, 
                        cv=kf, 
                        scoring=('r2', 'neg_mean_absolute_error'), 
                        n_jobs=1)

Примеры реальных датасетов: «Титаник»


Датасет «Титаник» — это классическая задача по машинному обучению. В ней задан набор характеристик каждого пассажира: возраст, пол, класс билета, место посадки, наличие родственников на борту. На этих признаках модель учится предсказывать, выживет ли пассажир. Это быстрый вариант прогнозирования, без углублённой настройки гиперпараметров и отбора признаков.

Используем следующий код для очистки необработанного датасета.

import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer

df = pd.read_csv("../dataset/titanic/csv_result-phpMYEkMl.csv")

#Change column names, replace "?" to "NaN", change data types
def tweak_df(df):
    features = ["PassengerId", "Survived", "Pclass", "Name", "Sex", "Age", "SibSp", "Parch", "Ticket", "Fare", "Cabin", "Embarked"]
    return (df
            .rename(columns={"id": "PassengerId", "'pclass'": "Pclass", "'survived'": "Survived", "'name'": "Name", "'sex'": "Sex", "'age'": "Age", "'sibsp'": "SibSp", "'parch'": "Parch", "'ticket'": "Ticket", "'fare'": "Fare", "'cabin'": "Cabin", "'embarked'": "Embarked"})
            [features]
            .replace('?', np.nan)
            .astype({'Age': 'float', 'Fare': 'float16'})

          )

#Splitting dataset into train, validation, test, and unseen
X_train_val_test, X_unseen, y_train_val_test, y_unseen = train_test_split(tweak_df(df).drop(columns=['Survived']), tweak_df(df).Survived, test_size=0.33, random_state=42)
X_train, X_val_test, y_train, y_val_test = train_test_split(X_train_val_test, y_train_val_test, test_size=0.4, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_val_test, y_val_test, test_size=0.5, random_state=42)

#Extensive cleanup
def tweak_titanic_cleaned(train_df):
    
    impute_table = (train_df
                     .assign(SibSp=lambda df_: np.where(df_.SibSp==0, 0, 1),
                             Parch=lambda df_: np.where(df_.Parch==0, 0, 1))
                     .groupby(['SibSp', 'Parch'])

     ['Age']
                     .agg('mean')
                   )

    train_df_intermediary = (train_df
                             .assign(SibSp=lambda df_: np.where(df_.SibSp==0, 0, 1),
                                     Parch=lambda df_: np.where(df_.Parch==0, 0, 1),)
                            )

    condlist = [((train_df_intermediary.Age.isna()) & (train_df_intermediary.SibSp == 0) & (train_df_intermediary.Parch == 0)),
                ((train_df_intermediary.Age.isna()) & (train_df_intermediary.SibSp == 0) & (train_df_intermediary.Parch == 1)),
                ((train_df_intermediary.Age.isna()) & (train_df_intermediary.SibSp == 1) & (train_df_intermediary.Parch == 0)),

  ((train_df_intermediary.Age.isna()) & (train_df_intermediary.SibSp == 1) & (train_df_intermediary.Parch == 1)),]

    choicelist = [impute_table.iloc[0],
                  impute_table.iloc[1],
                  impute_table.iloc[2],
                  impute_table.iloc[3],]

    bins = [0, 12, 18, 30, 50, 100]
    labels = ['Child', 'Teenager', 'Young Adult', 'Adult', 'Senior']
    features = ["Survived", "Pclass","Sex","Fare","Embarked","AgeGroup","SibSp","Parch","IsAlone","Title"]
    
    return (train_df
             .assign(Embarked=lambda df_: SimpleImputer(strategy="most_frequent").fit_transform(df_.Embarked.values.reshape(-1,1)),
                     Age=lambda df_: np.select(condlist, choicelist, df_.Age),
                     IsAlone=lambda df_: np.where(df_.SibSp + df_.Parch > 0, 0, 1),
                     Title=lambda df_: df_.Name.str.extract(',(.*?)\.'))
             .assign(AgeGroup=lambda df_: pd.cut(df_.Age, bins=bins, labels=labels),
                     Title=lambda df_: df_.Title.replace(['Dr', 'Rev', 'Major', 'Col', 'Capt', 'Sir', 'Lady', 'Don', 'Jonkheer', 'Countess', 'Mme', 'Ms', 'Mlle','the Countess'], 
                                                         'Other'))
             .set_index("PassengerId")
             [features]
            )

Позвольте дать пояснения по выполненным этапам предварительной обработки:

  1. Берём недостающие данные из столбца Embarked с наиболее распространёнными записями, используя класс SimpleImputer из scikit-learn.
  2. Берём недостающие данные из столбца Age, используя список средних значений, основанный на условии, были ли на борту родственники пассажира. Если не было, мы вписываем среднее значение, которое рассчитывается исходя из данных по другим пассажирам, у которых также не было родственников на борту.
  3. Создаём признак IsAlone, чтобы обозначить, путешествовал ли пассажир с родственниками.
  4. Создаём признак Title, чтобы обозначить форму обращения к пассажиру.
  5. Создаём признак AgeGroup, чтобы отнести пассажира к одной из пяти возрастных групп.

Утечка данных, пример первый: в качестве признака используется целевая переменная survived


#Intentionally add target variable to list of features
X_train = tweak_titanic_cleaned(pd
 .concat([X_train, pd.DataFrame(y_train)], axis=1))

X_val = tweak_titanic_cleaned(pd
 .concat([X_val, pd.DataFrame(y_val)], axis=1))

X_test = tweak_titanic_cleaned(pd
 .concat([X_test, pd.DataFrame(y_test)], axis=1))

# Prepare the training data
X_train = pd.get_dummies(X_train, columns=["Survived", "Pclass", "Sex", "Embarked", "AgeGroup", "IsAlone", "Title"], drop_first=True)
X_val = pd.get_dummies(X_val, columns=["Survived", "Pclass", "Sex", "Embarked", "AgeGroup", "IsAlone", "Title"], drop_first=True)

# Scale numerical columns
scaler = MinMaxScaler()
num_cols = ["Fare","SibSp","Parch"]
X_train[num_cols] = scaler.fit_transform(X_train[num_cols])
X_val[num_cols] = scaler.transform(X_val[num_cols])

# Fit and evaluate Logistic Regression model
lr_model = LogisticRegression(random_state=0)
lr_model.fit(X_train, y_train)

# Make predictions on validation data
y_pred_val = lr_model.predict(X_val)

# Evaluate model on validation data
acc_val = round(accuracy_score(y_val, y_pred_val) * 100, 2)
print("Logistic Regression Model accuracy on validation data:", acc_val)

Как вы, вероятно, и ожидали, если использовать в качестве признака целевую переменную survived, наша модель становится бесполезной, ведь теперь её точность для проверочных данных достигает 100,0%. Нет никакого смысла выполнять прогнозирование. Эту ошибку легко выявить, так что она встречается нечасто.

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


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

X_train = (pd
           .concat([tweak_titanic_cleaned(X_train), 
                    tweak_titanic_cleaned(X_val).iloc[:150, :]])
          )
y_train = (pd
           .concat([y_train, 
                    y_val.iloc[:150]])
          )

X_val = tweak_titanic_cleaned(X_val)

# Prepare the training data
X_train = pd.get_dummies(X_train, columns=["Pclass", "Sex", "Embarked", "AgeGroup", "IsAlone", "Title"], drop_first=True)
X_val = pd.get_dummies(X_val, columns=["Pclass", "Sex", "Embarked", "AgeGroup", "IsAlone", "Title"], drop_first=True)

# Scale numerical columns
scaler = MinMaxScaler()
num_cols = ["Fare","SibSp","Parch"]
X_train[num_cols] = scaler.fit_transform(X_train[num_cols])
X_val[num_cols] = scaler.transform(X_val[num_cols])

# Fit and evaluate Logistic Regression model
lr_model = LogisticRegression(random_state=0)
lr_model.fit(X_train, y_train)

# Make predictions on validation data
y_pred_val = lr_model.predict(X_val)

# Evaluate model on validation data
acc_val = round(accuracy_score(y_val, y_pred_val) * 100, 2)
print("Logistic Regression Model accuracy on validation data:", acc_val)

Утечка данных, пример третий: неправильные этапы предварительной обработки данных


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

# Prepare the training data
df_leaked = pd.get_dummies(tweak_titanic_cleaned(X_train_val_test), columns=["Pclass", "Sex", "Embarked", "AgeGroup", "IsAlone", "Title"], drop_first=True)

# Scale numerical columns
scaler = MinMaxScaler()
num_cols = ["Fare","SibSp","Parch"]
df_leaked[num_cols] = scaler.fit_transform(df_leaked[num_cols])

# Split the data into train, validation, and test sets
X_train, X_val_test, y_train, y_val_test = train_test_split(df_leaked, y_train_val_test, test_size=0.4, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_val_test, y_val_test, test_size=0.5, random_state=42)

Правильный этап это fit_transform для обучающего датасета и transform — для тестового.

Утечка данных, пример четвёртый: включили признак «каюта» в состав признаков модели


Эту проблему с утечкой данных не так просто выявить; возможно, для её понимания потребуются некоторые предметные знания. Главный вопрос здесь: «Содержит ли этот признак информацию, которая не будет доступна на момент выполнения предсказания?» Если ответ на этот вопрос утвердительный, высока вероятность, что здесь мы столкнулись с утечкой данных.

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

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

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

Послесловие


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

Построить систему машинного обучения поможет Cloud ML Platform от VK Cloud — система на основе Open-Source-инструментов. Это готовая платформа полного цикла ML-разработки и совместной работы Data-команд. Новым пользователям для тестирования мы начисляем 3000 бонусных рублей.

Stay tuned

Присоединяйтесь к Telegram-каналу «Данные на стероидах». В нём вы найдёте всё об инструментах и подходах для извлечения максимальной пользы из работы с данными: регулярные дайджесты, полезные статьи, а также анонсы конференций и вебинаров.

Комментарии (1)


  1. IamSVP
    13.07.2023 05:53

    термин "перекрёстный контроль" я бы все таки не переводил и оставил бы как кросс-валидация. Термин устоявшийся, понятный и использующийся в комьюнити