Изтичане на данни (data leakage)
Накратко: Изтичането на данни (data leakage) е, когато при обучението моделът вижда информация, която няма да има при истинската прогноза, и затова оценката на теста излиза по-добра, отколкото ще е в живота.
Проблем
99,7% точност
Колега е направил модела от урока за класификацията: кои клиенти няма да поръчат след 1 юли. Само че взима таблицата на клиентите направо от системата, каквато е днес, 1 ноември: брой поръчки, дни от последната поръчка и средна поръчка. И ти показва:
Клиентите са 1 279. Правилото „всички се връщат“ познава 72,5% от тестовите. Моделът познава 99,7%: от 320 клиенти в теста бърка един. В урока за класификацията със същите три признака моделът беше 72,2%, под правилото. Какво се е променило? Таблицата.
Идеята
Отговорът е в признаците
Виж колко дни от последната поръчка имат върналите се (lost е 0) и загубените (lost е 1):
Всеки върнал се има от 0 до 122 дни, а всеки загубен от 123 до 363. Едно число ги разделя без грешка. Защо? days_since е сметнато до днес. Който е поръчал след 1 юли, има последна поръчка след 1 юли, значи е на по-малко от 123 дни от 1 ноември. Признакът съдържа етикета. Същото е с orders: брои и поръчките след 1 юли.
Това се казва изтичане на информация (data leakage): в обучението влиза информация, която моделът няма да има, когато прогнозира. На 1 юли не знаеш кой ще поръча през август. Правилото е едно: всеки признак се смята само от това, което е известно в момента на прогнозата.
По-точно, за любопитните
Изтичането се крие в колони, които системата обновява след събитието: „общо поръчки“, „последна покупка“, „статус“, „върнат продукт“. За всяка колона питай кога се записва стойността ѝ. Ако е след деня на прогнозата, колоната не става за признак, колкото и полезна да изглежда. Най-силните признаци в един модел са и първите, които проверяваш.
Идеята
Честното число
Същите признаци два пъти. Веднъж от всички поръчки до днес, веднъж само от поръчките преди 1 юли и до 1 юли:
С бъдещето в признаците точността е 99,7%. Честно е 72,2%, а базовата линия е 72,5%. С тези три признака моделът по точност не печели нищо, точно като в урока за класификацията. Неприятно, но вярно. По-добре да го знаеш сега, отколкото след като раздадеш купоните.
features(rows, now) е една функция за двете таблици: разликата е само кои поръчки ѝ даваш и до кой ден брои. .loc[honest.index] взима същите 1 279 клиенти в същия ред, затова двете разделяния са еднакви: 959 клиенти за обучение и 320 за тест.
Виж го
Как бъдещето влиза в признака
Същият код бавно. Точките са поръчките на четирима клиенти по месеци, а стената е 1 юли. Етикетът е от точките вдясно от нея. Признакът на колегата брои и тях.
import pandas as pdfrom sklearn.model_selection import train_test_splitfrom sklearn.linear_model import LogisticRegression
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])items = pd.read_csv('year_items.csv')items['value'] = items['quantity'] * items['unit_price']orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')before = orders[orders['ordered_at'] < cutoff]after = orders[orders['ordered_at'] >= cutoff]
def features(rows, now): people = rows.groupby('customer_id').agg( orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean')) people['days_since'] = (now - people['last']).dt.days return people[['orders', 'days_since', 'basket']]
honest = features(before, cutoff)leaky = features(orders, pd.Timestamp('2026-11-01')).loc[honest.index]y = (~honest.index.isin(after['customer_id'])).astype(int)
for name, X in [('с бъдещето', leaky), ('честно', honest)]: X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y) model = LogisticRegression(max_iter=1000).fit(X_train, y_train) print(name, round(model.score(X_test, y_test), 3))print('клиенти:', len(honest), 'обучение:', len(X_train), 'тест:', len(X_test))print('базова линия:', round(1 - y_test.mean(), 3))Стъпка по стъпка, с думи
- orders са всички поръчки за годината, до 31.10.2026. Точките са поръчките на четирима клиенти по месеци.
- cutoff е 1 юли. Вляво от стената е before: това, което знаеш. Вдясно е after: това, което предсказваш.
- lost е 1, ако клиентът няма нито една поръчка вдясно. Етикетът е направен от точките след 1 юли.
- Таблицата на колегата брои до днес. orders брои и точките вдясно, а days_since се мери от днес: същите точки, от които е етикетът.
- Разделяне: 959 клиенти за обучение и 320 за тест, от 1 279.
- С бъдещето в признаците точността е 99,7%. Базовата линия „всички се връщат“ е 72,5%.
- Честно: признаците спират на 1 юли. Точките вдясно не се броят, а days_since се мери от стената.
- Честната точност е 72,2%, под базовата линия. 99,7% беше изтичане, не модел.
Клиентите в сцената са схема: истински са числата в надписите, от броя на клиентите до двете точности.
Пробвай
Още един признак
Колегата иска още един признак: recent, броят поръчки на клиента в последните 30 дни. Сметни го честно, към 1 юли: в month поръчките от 30-те дни преди cutoff, в people['recent'] броя им за всеки клиент (0 за тези без поръчка), а в score точността на модела с четирите признака.
Решение
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
month = before[before['ordered_at'] >= cutoff - pd.Timedelta(days=30)]
people['recent'] = month.groupby('customer_id').size()
people['recent'] = people['recent'].fillna(0)
X = people[['orders', 'days_since', 'basket', 'recent']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
score = model.score(X_test, y_test)
print(round(people['recent'].mean(), 3), round(score, 3))Средно клиент има 0,699 поръчки през юни, а точността остава 72,2%: recent не помага. Ако броиш последните 30 дни до днес, тоест октомври, точността става 75,6% и изглежда като подобрение над базовата линия. Не е: октомврийските поръчки са бъдещето, което предсказваш. Изтичането не винаги дава 99%. Понякога дава точно толкова, колкото да му повярваш.
Идеята
Подготовка преди разделянето
Логистичната регресия работи по-добре, когато признаците са в сходен мащаб: days_since стига до 240, а orders рядко минава 10. StandardScaler изважда от всеки признак средната му и дели на стандартното отклонение. Така всеки признак става около 0, с разсейване 1. Средните скалерът научава с fit, от данните, които му даваш. Колегата му дава всички:
Скалерът е учен от всичките 1 279 клиенти: средните са 5,88 поръчки, 67,42 дни и 10,69 €. Само обучението има 5,96, 67,84 и 10,68. Разликата идва от 320-те тестови клиенти: те са повлияли на подготовката, преди моделът да ги види. Тестът вече не е съвсем непознат.
Тук точността не се мени: пак е 72,2%, защото средните са близки. Не винаги е така. Изтичането расте при малко данни и при подготовка, която гледа етикета, например когато избираш признаците по връзката им с lost във всички редове. Попълването на липсващи стойности със средната (fillna) е същият случай: средната се смята само от обучението.
Идеята
Pipeline: подготовката учи само от обучението
Pipeline свързва подготовката и модела в един обект. make_pipeline(StandardScaler(), LogisticRegression()) прави верига от две стъпки:
pipe.fit(X_train, y_train)учи скалера и после модела, само от обучението;pipe.score(X_test, y_test)иpipe.predictмащабират теста с научените средни и питат модела.
Средните на скалера в pipe са 5,96, 67,84 и 10,68: точно тези на обучението. Точността е 72,2%. Сега редът не може да се сбърка: подготовката е част от модела. Pipeline помага и в следващия урок: при кръстосаната проверка всяко разделяне учи скалера наново.
По-точно, за любопитните
pipe[0] е първата стъпка, а pipe[-1] моделът. В една верига може да има повече стъпки: SimpleImputer попълва липсващите, OneHotEncoder прави колони 0/1 от категории, а ColumnTransformer прилага различна подготовка на различни колони. Всички се учат с един fit, само от обучението.
Идеята
Един клиент в обучението и в теста
Таблицата е слепена от два експорта и всеки клиент е в нея два пъти. В кода това прави pd.concat: слага двете таблици една под друга. Изглежда безобидно: същите данни, само повече. Пробваш дърво на решенията (DecisionTreeClassifier), което расте без ограничение на дълбочината:
Редовете са 2 558, а 1 279 от тях са повторения. С дубликатите дървото познава 89,4% от теста, над базовата линия. Без тях познава 58,4%. При случайно разделяне повечето тестови редове имат близнак в обучението, а дървото без ограничение запомня всеки ред. То не прогнозира, а си спомня. Нов клиент няма близнак: 58,4% е истинското число, под базовата линия, защото дървото е преобучено. За това е следващият урок.
Правилото: махаш дубликатите (drop_duplicates) преди разделянето. Тук reset_index() прави customer_id колона, за да се махат повторенията на един и същ клиент, а не двама различни клиенти с еднакви числа.
По-точно, за любопитните
Понякога един клиент има няколко истински реда, например по един за всяка поръчка. Дубликати няма, но ако редовете му са и в обучението, и в теста, моделът пак учи клиента, а не правилото. Тогава разделяш по клиенти: GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42).split(X, y, groups=rows['customer_id']) слага всички редове на един клиент от едната страна.
Идеята
Случайни дни от времеви ред
В урока за регресията разделяше по време. Ето защо случайното разделяне лъже. Прогнозата е за поръчките на ден, по номера на деня и деня от седмицата. Моделът е случайна гора (RandomForestRegressor): много дървета, всяко учено на малко различни редове, а прогнозата е средната им.
При случайни дни гората греши средно с 4,88 поръчки на ден, а средната (DummyRegressor) с 5,76: гората изглежда по-добра. Учена до 31 юли и пробвана на август–октомври, гората греши със 7,96, а средната с 5,44. Гората е по-лоша от средната.
При случайно разделяне, за да познае 15 септември, гората е видяла 14 и 16 септември. В живота на 31 юли нямаш нито един ден от септември. Освен това дърветата не продължават тенденция: ден след последния познат е за тях като последните познати дни. А през август–октомври има и изненада: септември е по-слаб. Защо, ще разберем в проекта. Модел, учен до юли, не може да го знае, и ти на 31 юли също. Затова 7,96 е честното число.
Упражнение
Поправи модела на колегата
Поправи кода на колегата. Признаците са само от поръчките преди cutoff, а days_since се брои до cutoff. Скалерът и логистичната регресия са в pipe, учен само с обучението. Запиши в score точността на pipe върху теста.
Решение
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
X = people[['orders', 'days_since', 'basket']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
pipe = make_pipeline(StandardScaler(), LogisticRegression())
pipe.fit(X_train, y_train)
score = pipe.score(X_test, y_test)
print(round(score, 3))Честната точност е 72,2% срещу 99,7% с бъдещето. Поправките са две. Първо, признаците спират на 1 юли: before вместо orders и cutoff вместо today. Второ, скалерът учи от 959-те клиенти в обучението, а не от всички 1 279: pipe.fit(X_train, y_train), не pipe.fit(X, y).
Колегата има и модел за оборота на ден, без отказаните поръчки. Пробвай го честно. Учи случайната гора (n_estimators=50, random_state=42) и DummyRegressor с дните до 31 юли (late е маската на дните от 1 август). После в mae запиши грешката (MAE) на гората на август–октомври, а в base_mae грешката на средната.
Решение
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
orders = orders[orders['status'] != 'cancelled']
daily = orders.set_index('ordered_at').resample('D')['basket'].sum().rename('revenue').to_frame()
daily['day'] = range(len(daily))
daily['weekday'] = daily.index.dayofweek
X = daily[['day', 'weekday']]
y = daily['revenue']
late = daily.index >= '2026-08-01'
forest = RandomForestRegressor(n_estimators=50, random_state=42).fit(X[~late], y[~late])
base = DummyRegressor().fit(X[~late], y[~late])
mae = mean_absolute_error(y[late], forest.predict(X[late]))
base_mae = mean_absolute_error(y[late], base.predict(X[late]))
print(round(mae, 2), round(base_mae, 2))Гората греши средно със 128,81 € на ден, а средната на дните до 31 юли с 65,24 €: гората греши около два пъти повече от средната. Със случайно разделяне същата гора дава 65,12 € и изглежда добра. Ако я учиш с всички дни, грешката пада на 23,96 €, защото гората е видяла теста. Честният извод: с номера на деня и деня от седмицата гората не става за прогноза напред.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Описание на данните · Модел: учене от примери · Линейна регресия · Класификация
- Продължава в: Кръстосана проверка (cross-validation)
- Задачи по темата: Модели със scikit-learn