Кръстосана проверка (cross-validation)
Накратко: Кръстосаната проверка (cross-validation) дели данните на няколко части и обучава и проверява модела много пъти, за да даде средна оценка с разсейване вместо едно случайно число.
Проблем
„Дървото или правата?“
Магазинът иска да знае колко поръчки ще направи всеки клиент през следващите 4 месеца. С това решава на кого да прати карта за отстъпка и колко стока да поръча. Признаците са от поръчките преди 1 юли 2026, както в урока за класификация: брой поръчки, дни от последната, откога купува и средна поръчка. Целта е броят поръчки от 1 юли до 31 октомври.
Ти казваш „линейна регресия“, а колегата казва „дърво с 3 нива“. Пускате ги на 6 различни разделяния на обучение и тест (random_state от 0 до 5) и мерите MAE в поръчки на клиент:
Клиентите са 1 279 и правят средно 2,70 поръчки за 4-те месеца. При разделяния 0, 1, 2 и 4 дървото греши по-малко, а при 3 и 5 правата. Ако бяхте пуснали само разделяне 2, колегата щеше да спечели с 1,25 срещу 1,31. С разделяне 5 печелиш ти. Кой е прав?
Идеята
Една проверка е една извадка
Тестовата част е 256 клиенти, избрани случайно. Като средната от 30 поръчки в урока за извадките, и грешката на 256 клиенти е шумна: друга извадка дава друго число. Грешката на правата скача от 1,31 до 1,43 само заради това кои клиенти са попаднали в теста.
Разликата между двата модела е няколко стотни. Шумът от разделянето е десетина стотни. С една проверка не можеш да кажеш кой е по-добър, както не можеш да кажеш колко е средната поръчка от 30 поръчки.
Идеята
Кръстосана проверка
Решението е да провериш много пъти и всеки клиент да е в теста точно веднъж. Делиш клиентите на 5 части. Обучаваш на 4 и мериш на петата, после сменяш коя е тестовата. Получаваш 5 грешки за всеки модел. Това се казва кръстосана проверка (cross-validation).
KFold дели на части, а cross_val_score обучава и мери модела на всяка. С shuffle=True клиентите се разбъркват, преди да се разделят:
Четири пъти учиш на 1 023 клиенти и проверяваш на 256, веднъж на 1 024 и 255. scikit-learn винаги търси най-голямото число, затова грешката идва с минус (neg_mean_absolute_error), а минусът отпред я обръща.
Базовата линия (средната, DummyRegressor) греши с 2,70 ± 0,09. Правата греши с 1,34 ± 0,06, а дървото с 1,38 ± 0,09. Числото след ± е разсейването на петте грешки (std). И двата модела бият базовата линия ясно, а разликата между тях (0,04) е по-малка от разсейването. Честният извод е „правата е малко по-добра, но не ясно“. Когато два модела са толкова близо, взимаш по-простия.
По-точно, за любопитните
Двата модела се сравняват на едни и същи части: затова kf е един и с random_state. cv=5 също дели на 5, но без да разбърква: първата част са първите 256 клиенти по ред. Ако таблицата е подредена (по номер, по дата на първата поръчка), частите са различни една от друга и оценката се изкривява.
Пробвай
Гората по-добра ли е?
Колегата предлага случайна гора (RandomForestRegressor с 50 дървета, max_depth=5). Провери я с кръстосана проверка на същите части kf. Запиши в forest_mae и line_mae петте грешки (MAE, положителни) на гората и на правата, в gap разликата в средната грешка (гора минус права), а в spread разсейването на грешките на гората.
Решение
from sklearn.ensemble import RandomForestRegressor
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
kf = KFold(n_splits=5, shuffle=True, random_state=42)
forest = RandomForestRegressor(n_estimators=50, max_depth=5, random_state=0)
forest_mae = -cross_val_score(forest, X, y, cv=kf, scoring='neg_mean_absolute_error')
line_mae = -cross_val_score(LinearRegression(), X, y, cv=kf, scoring='neg_mean_absolute_error')
gap = forest_mae.mean() - line_mae.mean()
spread = forest_mae.std()
print(forest_mae.round(2), line_mae.round(2))
print(round(gap, 2), round(spread, 2))Гората греши средно с 1,36 поръчки, правата с 1,34: gap е 0,02 в полза на правата. Разсейването на гората е 0,10, много повече от разликата. Гората не е по-добра, а е по-бавна и по-трудна за обяснение. Без минуса пред cross_val_score грешките излизат отрицателни, а с cv=5 вместо kf двата модела се мерят на други части от тези в примера.
Идеята
Преобучение
Защо дървото е с 3 нива, а не с 20? Колкото по-дълбоко е, толкова по-фино дели клиентите. Ето грешката на обучаващите и на тестовите клиенти за дълбочина от 1 до 12:
На обучаващите клиенти грешката пада всеки път: от 1,87 до 0,41. На тестовите пада до дълбочина 3 (1,50) и после расте до 1,91. Дърво без ограничение стига 23 нива и греши с 0,001 на обучаващите, почти нула, а на тестовите с 2,00.
Дълбокото дърво не е научило повече за клиентите. То е запомнило всеки обучаващ клиент заедно с шума му: един клиент случайно е поръчал повече, друг по-малко. Това се казва преобучение (overfitting). Признакът е голяма разлика между грешката при обучение и при тест. Затова грешка върху данните, на които моделът е учил, нищо не казва за нови клиенти.
Пробвай
Избери дълбочината
Избери дълбочината на дървото с кръстосана проверка. Запиши в cv_mae (Series с индекс дълбочината от 1 до 10) средната MAE от cross_val_score с частите kf, а в best_depth дълбочината с най-малка грешка.
Решение
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
kf = KFold(n_splits=5, shuffle=True, random_state=42)
cv_mae = pd.Series(dtype=float)
for depth in range(1, 11):
tree = DecisionTreeRegressor(max_depth=depth, random_state=0)
cv_mae[depth] = -cross_val_score(tree, X, y, cv=kf, scoring='neg_mean_absolute_error').mean()
best_depth = cv_mae.idxmin()
print(cv_mae.round(2))
print(best_depth)Кръстосаната проверка дава 1,89 при дълбочина 1, най-малко 1,38 при 3 и после грешката расте до 1,70 при 10. best_depth е 3. Ако мериш грешката на същите клиенти, на които дървото е учило, тя само пада и „най-добро“ излиза най-дълбокото дърво: точно преобученото.
Идеята
Когато редът е времето
Клиентите могат да се разбъркат: един клиент не знае нищо за друг. Дните не могат. Ако прогнозираш поръчките за утре, моделът ще има само миналото. Разбъркани части биха го учили и на дни след тестовите, а това е изтичането от предишния урок.
TimeSeriesSplit дели по времето: учи на началото и проверява на следващите дни, после учи на повече и проверява на още по-късни. Пример: поръчките за деня от деня от седмицата, поръчките преди 7 дни и средната за предишните 7 дни:
Първите 7 дни нямат „преди 7 дни“ и отпадат, остават 358. Всяка тестова част е 59 дни след обучаващата, а обучаващата расте: първо 2 месеца, накрая 10. Правата греши средно с 4,51 ± 0,18 поръчки на ден.
Пробвай
Бие ли базовата линия?
Сравни правата с базовата линия (DummyRegressor, средната на обучаващите дни) с TimeSeriesSplit на 5 части. Запиши в line_mae и base_mae петте грешки (MAE, положителни) на всеки.
Решение
import numpy as np
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from sklearn.linear_model import LinearRegression
from sklearn.dummy import DummyRegressor
orders = pd.read_csv('year_orders.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
daily = orders.set_index('ordered_at').resample('D').size().rename('orders').to_frame()
daily['weekday'] = daily.index.dayofweek
daily['last_week'] = daily['orders'].shift(7)
daily['week_mean'] = daily['orders'].shift(1).rolling(7).mean()
daily = daily.dropna()
X = pd.get_dummies(daily['weekday'], prefix='day', dtype=int).join(daily[['last_week', 'week_mean']])
y = daily['orders']
ts = TimeSeriesSplit(n_splits=5)
line_mae = -cross_val_score(LinearRegression(), X, y, cv=ts, scoring='neg_mean_absolute_error')
base_mae = -cross_val_score(DummyRegressor(), X, y, cv=ts, scoring='neg_mean_absolute_error')
print(line_mae.round(2), base_mae.round(2))
print(f'{line_mae.mean():.2f} ± {line_mae.std():.2f}', f'{base_mae.mean():.2f} ± {base_mae.std():.2f}')Правата греши с 4,51 ± 0,18 поръчки на ден, а базовата линия с 5,69 ± 1,92. Първата част на базовата линия е 9,29: тя учи на ноември и декември, върха на годината, и предсказва януари и февруари, най-слабите месеци. Разсейването показва, че средната от миналото е лоша прогноза, когато сезонът се сменя. С разбъркани части (KFold) числата са други: моделът е учил на дни след тестовите.
Идеята
Тестът се пипа веднъж
Ако пробваш 10 модела на тестовата част и вземеш най-добрия, тестът е участвал в избора. Един от десетте ще има късмет точно с тези клиенти и грешката му ще изглежда по-добра, отколкото е.
Затова редът е:
- Отделяш тестова част в началото и не я гледаш.
- Избираш модела и настройките му (дълбочина, признаци) с кръстосана проверка само на обучаващата част.
- Обучаваш избрания модел на цялата обучаваща част и го мериш на теста веднъж. Това число казваш на управителя.
На 1 023-те обучаващи клиенти базовата линия греши с 2,74 ± 0,17, правата с 1,32 ± 0,06, а дървото с 1,36 ± 0,07. Правата е с по-малка грешка и е по-проста: избираш нея. 256-те тестови клиенти още не са пипани.
По-точно, за любопитните
Ако след теста смениш модела и пак мериш на теста, тестът става част от избора. Тогава ти трябва нова тестова част: нови данни, например следващия месец. Затова при модел за поръчки най-честната проверка е времето след обучението, както в TimeSeriesSplit.
Идеята
Кога модел не трябва
Модел струва време: данни, обучение, следене дали не се разваля. Понякога не си струва:
- Малко данни. С няколко десетки клиенти грешката на кръстосаната проверка скача толкова, че не можеш да кажеш дали моделът бие средната.
- Правило без модел върши същото. „Клиентът ще купува със същото темпо“ е едно деление. Ако моделът не го бие ясно, правилото е по-добро: всеки го разбира и няма какво да се развали.
- Цената на грешката. Ако грешката е скъпа (откажеш кредит, спреш карта), моделът трябва да е много сигурен или човек да проверява.
- Никой не действа по прогнозата. Ако от числото не следва решение (на кого купон, колко стока), моделът е само упражнение.
Правата има коефициент 0,469 пред броя поръчки досега, а другите признаци почти не участват. Значи тя е почти „половината от досегашните поръчки“: 8 месеца преди, 4 месеца след. Провери го в упражнението.
Упражнение
Присъдата
Кръстосаната проверка на обучаващата част избра правата. Обучи я на X_train и я провери веднъж на теста: запиши в model обучения модел, в test_mae грешката му (MAE) на тестовите клиенти, а в base_mae грешката на базовата линия на теста.
Решение
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = LinearRegression().fit(X_train, y_train)
test_mae = mean_absolute_error(y_test, model.predict(X_test))
base = DummyRegressor().fit(X_train, y_train)
base_mae = mean_absolute_error(y_test, base.predict(X_test))
print(round(test_mae, 2), round(base_mae, 2))Правата греши на 256-те тестови клиенти с 1,38 поръчки, а базовата линия с 2,58. Това е числото за управителя: „прогнозата за 4 месеца е средно с 1,4 поръчки встрани“. На кръстосаната проверка правата даде 1,32 ± 0,06: тестът е малко по-лош, но в рамките на шума. Ако обучиш на всички клиенти, моделът е видял и тестовите и 1,38 вече не е честно. Дървото на колегата на теста греши повече, но не го избираш заради теста: изборът е направен преди.
Бие ли правата едно правило без модел? Правилото е „клиентът ще купува със същото темпо“: за 4 месеца половината от поръчките му за 8-те месеца преди. Запиши в rule прогнозата на правилото за всеки тестов клиент, а в rule_mae грешката му на теста.
Решение
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = LinearRegression().fit(X_train, y_train)
model_mae = mean_absolute_error(y_test, model.predict(X_test))
rule = X_test['orders'] / 2
rule_mae = mean_absolute_error(y_test, rule)
print(round(model_mae, 2), round(rule_mae, 2))
print(dict(zip(X.columns, model.coef_.round(3).tolist())))Правилото греши с 1,41, правата с 1,38: 0,03 поръчки разлика, под шума на една проверка. Коефициентът на правата пред броя поръчки е 0,469, почти половината, затова двете дават почти едно и също. Ако вземеш всички досегашни поръчки, без да делиш на 2, прогнозираш 8 месеца вместо 4. Тук правилото е разумният избор: едно деление, което всеки разбира, срещу модел, който трябва да се обучава и следи.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Модел: учене от примери · Линейна регресия · Класификация · Изтичане на данни (data leakage)
- Задачи по темата: Модели със scikit-learn