Кафедра ШІзики

Train, Validate, Test - Правильне розділення даних

Кафедра ШІзики

Автор

20 хвилин

Час читання

28.10.2025

Дата публікації

Рівень:
Початківець
Теги: #train-test-split #cross-validation #overfitting #data-leakage #машинне-навчання #валідація

📌 Новачок у Python? Ця стаття використовує приклади коду Python. Якщо ви раніше не працювали з Python, почніть з нашого посібника Налаштування Python для ML!

Train, Validate, Test - Правильне розділення даних 🎯

Уявіть, що ви готуєтесь до важливого іспиту. Ви старанно вчитесь, розв’язуєте задачі і відчуваєте впевненість. У день іспиту ви сідаєте і… зачекайте, це ті САМІ запитання, які ви практикували! Ви отримуєте 100%. Чудово, правда?

Неправильно. Ви насправді не навчились - ви просто запам’ятали відповіді.

Саме це відбувається, коли ML моделі “вчаться” і “тестуються” на одних і тих самих даних.

Ось шокуюча істина: Більшість новачків роблять свої моделі чудовими, випадково шахраюючи на тесті. Вони тренують на повному датасеті, оцінюють на тому ж датасеті і отримують неймовірні результати… які повністю розвалюються в реальному світі.

У цій статті ви навчитеся:

  • Чому ми повинні розділяти дані перед тренуванням
  • Як робити прості train/test розділення (80/20, 70/30)
  • Коли використовувати train/validation/test (3-стороннє розділення)
  • Що таке крос-валідація і коли її використовувати
  • Особливе розділення для незбалансованих даних і часових рядів
  • Поширені помилки, що викликають витік даних

Давайте переконаємось, що ваші моделі справді працюють у продакшені! 🚀


Проблема перенавчання - Чому ми розділяємо дані 🤔

Пастка запам’ятовування

Давайте використаємо наш приклад піцерії. У вас є дані з січня з цими фічами:

  • Температура, день тижня, година, тощо
  • Ціль: Кількість проданих піц

Сценарій 1: Тренування та тестування на тих самих даних

# Тренуємо модель на січневих даних
model.fit(january_data)

# Тестуємо модель на... січневих даних знову!
score = model.score(january_data)
# Score: 99% - Чудово! 🎉

# Розгортаємо у продакшен...
# Лютий настає...
# Продуктивність моделі: 45% - Катастрофа! 😱

Що сталося? Модель запам’ятала специфічні патерни січня замість того, щоб навчитись загальним правилам:

  • “15 січня о 19:00 продати 23 піци” (запам’ятала)
  • проти
  • “П’ятничні вечори зазвичай продають 20-25 піц” (навчилась)

Перше марне для лютого. Друге - цінне знання.

Реальний тест

Сценарій 2: Тренування та тестування на окремих даних

# Тренуємо на січневих даних
model.fit(january_data)

# Тестуємо на лютневих даних (модель НІКОЛИ це не бачила!)
score = model.score(february_data)
# Score: 78% - Більш реалістично!

# Розгортаємо у продакшен...
# Березень настає...
# Продуктивність моделі: 76% - Послідовно! ✅

Тепер модель навчилась справжнім патернам, які узагальнюються на нові дані.

Типи узагальнення

НЕДОНАВЧАННЯ            ГАРНА МОДЕЛЬ           ПЕРЕНАВЧАННЯ
(Занадто проста)        (Саме те)             (Занадто складна)

Тренування: 60%         Тренування: 85%        Тренування: 99%
Тест: 58%               Тест: 82%              Тест: 45%

Модель занадто проста   Модель навчилась       Модель запам'ятала
Не може захопити        узагальнюваним         конкретні приклади
патерни                 правилам

Наша мета: Знайти золоту середину, де модель працює добре і на тренувальних, і на тестових даних.


Просте розділення Train/Test - Основа 📊

Найбазовіший підхід: розділити дані на дві частини.

Правило 80/20

from sklearn.model_selection import train_test_split
import pandas as pd

# Наші дані піцерії
data = pd.read_csv('pizza_orders_clean.csv')

# Фічі (X) та ціль (y)
X = data.drop('num_pizzas', axis=1)
y = data['num_pizzas']

# Розділяємо: 80% train, 20% test
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,      # 20% для тестування
    random_state=42     # Відтворюваність
)

print(f"Всього зразків: {len(X)}")
print(f"Тренувальних зразків: {len(X_train)} ({len(X_train)/len(X)*100:.1f}%)")
print(f"Тестових зразків: {len(X_test)} ({len(X_test)/len(X)*100:.1f}%)")

Вихід:

Всього зразків: 10000
Тренувальних зразків: 8000 (80.0%)
Тестових зразків: 2000 (20.0%)

Чому це працює

НабірПризначенняЩо модель знає
Тренування (80%)Вивчити патерниБачить всі фічі та мітки
Тест (20%)Оцінити продуктивністьНІКОЛИ не бачила під час тренування

Тестовий набір імітує нові, невидимі дані з продакшену.

Вибір співвідношення розділення

РозділенняКоли використовуватиПеревагиНедоліки
80/20Стандартний вибірХороший балансЗа замовчуванням для більшості випадків
70/30Менші датасети (<1000 зразків)Більше тестових даних для надійної оцінкиМенше тренувальних даних
90/10Великі датасети (>100,000 зразків)Максимізація тренувальних данихМалий тестовий набір може бути менш надійним
60/20/20Коли потрібен валідаційний набірНайкраща практика для налаштування гіперпараметрівБільш складно (див. наступний розділ)

Правило великого пальця: Починайте з 80/20, якщо немає конкретної причини змінити.

Параметр random_state

# Без random_state - різні результати кожного разу!
split1 = train_test_split(X, y, test_size=0.2)
split2 = train_test_split(X, y, test_size=0.2)
# Різні розділення!

# З random_state - відтворювані результати
split1 = train_test_split(X, y, test_size=0.2, random_state=42)
split2 = train_test_split(X, y, test_size=0.2, random_state=42)
# Точно ті самі розділення!

Завжди встановлюйте random_state під час експериментів, щоб можна було справедливо порівнювати результати!


Train/Validation/Test - 3-стороннє розділення 🎯

Проблема налаштування гіперпараметрів

Проблема: Ви тренуєте модель, налаштовуєте гіперпараметри тестуючи різні значення, і обираєте найкращий на основі тестової продуктивності.

Зачекайте… ви щойно перенавчилися на тестовому наборі?

Так! Неодноразово тестуючи на тестовому наборі для вибору гіперпараметрів, ви “пролили” інформацію з тестового набору в процес вибору моделі.

Рішення: Використовуйте три набори замість двох!

Три набори пояснені

from sklearn.model_selection import train_test_split

# Перше розділення: відокремлюємо тестовий набір
X_temp, X_test, y_temp, y_test = train_test_split(
    X, y,
    test_size=0.2,     # 20% для фінального тестування
    random_state=42
)

# Друге розділення: розділяємо залишок на train та validation
X_train, X_val, y_train, y_val = train_test_split(
    X_temp, y_temp,
    test_size=0.25,    # 0.25 з 80% = 20% оригіналу
    random_state=42
)

print(f"Тренувальний набір: {len(X_train)} зразків ({len(X_train)/len(X)*100:.0f}%)")
print(f"Валідаційний набір: {len(X_val)} зразків ({len(X_val)/len(X)*100:.0f}%)")
print(f"Тестовий набір: {len(X_test)} зразків ({len(X_test)/len(X)*100:.0f}%)")

Вихід:

Тренувальний набір: 6000 зразків (60%)
Валідаційний набір: 2000 зразків (20%)
Тестовий набір: 2000 зразків (20%)

Призначення кожного набору

НабірПризначенняВикористовується дляМодель бачить?
ТренуванняВивчити патерниФітування параметрів моделі (ваги, коефіцієнти)✅ Так - повністю видимі
ВалідаціяНалаштувати гіперпараметриВибір найкращих гіперпараметрів, раннє зупинення❌ Ні - але впливає на рішення
ТестФінальна оцінкаОдноразова перевірка продуктивності перед розгортанням❌ Ні - повністю недоторканий

Приклад повного робочого процесу

from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error

# Спробуйте різні гіперпараметри на валідаційному наборі
best_mae = float('inf')
best_params = None

for n_trees in [50, 100, 200]:
    for max_depth in [10, 20, None]:
        # Тренуємо на тренувальному наборі
        model = RandomForestRegressor(
            n_estimators=n_trees,
            max_depth=max_depth,
            random_state=42
        )
        model.fit(X_train, y_train)

        # Оцінюємо на валідаційному наборі
        val_pred = model.predict(X_val)
        mae = mean_absolute_error(y_val, val_pred)

        # Відстежуємо найкращі гіперпараметри
        if mae < best_mae:
            best_mae = mae
            best_params = {'n_estimators': n_trees, 'max_depth': max_depth}

print(f"Найкращі гіперпараметри: {best_params}")
print(f"Валідаційний MAE: {best_mae:.2f}")

# Тренуємо фінальну модель з найкращими гіперпараметрами на тренувальних даних
final_model = RandomForestRegressor(**best_params, random_state=42)
final_model.fit(X_train, y_train)

# ОДНОРАЗОВА оцінка на тестовому наборі
test_pred = final_model.predict(X_test)
test_mae = mean_absolute_error(y_test, test_pred)

print(f"\n🎯 Фінальний тестовий MAE: {test_mae:.2f} піц")
print("Це продуктивність, яку ми очікуємо в продакшені!")

Ключове правило: Торкайтесь тестового набору ЛИШЕ ОДИН РАЗ, в самому кінці!


K-Fold крос-валідація - Отримання більш надійних оцінок 🔄

Проблема одиночних розділень

З одиночним train/test розділенням, ваша оцінка продуктивності залежить від того, які зразки потрапили в тестовий набір. Ви можете мати удачу (або невдачу)!

# Різні випадкові розділення дають різні показники!
for i in range(5):
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=i
    )
    model = RandomForestRegressor(random_state=42)
    model.fit(X_train, y_train)
    score = model.score(X_test, y_test)
    print(f"Розділення {i+1}: R² = {score:.3f}")

Вихід:

Розділення 1: R² = 0.843
Розділення 2: R² = 0.871
Розділення 3: R² = 0.826
Розділення 4: R² = 0.889
Розділення 5: R² = 0.854

Яка “справжня” продуктивність? 🤔

Як працює K-Fold крос-валідація

Ідея: Розділити дані на K частин (фолдів), тренувати K разів, кожного разу використовуючи різний фолд як тестовий набір.

Приклад: 5-Fold крос-валідація

Fold 1: [TEST] [train] [train] [train] [train]
Fold 2: [train] [TEST] [train] [train] [train]
Fold 3: [train] [train] [TEST] [train] [train]
Fold 4: [train] [train] [train] [TEST] [train]
Fold 5: [train] [train] [train] [train] [TEST]

Фінальний показник = середнє всіх 5 тестових показників

Кожен зразок потрапляє в тестовий набір рівно один раз!

Реалізація

from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestRegressor
import numpy as np

# Створюємо модель
model = RandomForestRegressor(n_estimators=100, random_state=42)

# Виконуємо 5-fold крос-валідацію
scores = cross_val_score(
    model, X, y,
    cv=5,                    # 5 фолдів
    scoring='r2'             # Метрика R²
)

print("Показники для кожного фолду:", scores)
print(f"\nСереднє R²: {scores.mean():.3f}")
print(f"Стандартне відхилення: {scores.std():.3f}")
print(f"95% довірчий інтервал: {scores.mean():.3f} ± {1.96*scores.std():.3f}")

Вихід:

Показники для кожного фолду: [0.843 0.871 0.826 0.889 0.854]

Середнє R²: 0.857
Стандартне відхилення: 0.023
95% довірчий інтервал: 0.857 ± 0.045

Інтерпретація: R² моделі приблизно 0.857, з продуктивністю в діапазоні від ~0.81 до 0.90.

Коли використовувати K-Fold крос-валідацію

✅ Використовуйте K-Fold коли:

  • Малі до середніх датасети (<10,000 зразків)
  • Вам потрібні надійні оцінки продуктивності
  • Порівняння кількох моделей або гіперпараметрів
  • Вам потрібні довірчі інтервали

❌ Не використовуйте K-Fold коли:

  • Дуже великі датасети (занадто дорого обчислювально)
  • Дані часових рядів (використовуйте розділення на основі часу)
  • Сильна незбалансованість класів (використовуйте stratified K-fold)

Вибір K

Значення KПеревагиНедолікиКоли використовувати
K=5Швидко, хороший балансМенш точноЗа замовчуванням, великі датасети
K=10Більш надійна оцінкаПовільнішеСтандартний вибір, середні датасети
K=n (LOO)Максимум даних для тренуванняДуже повільно, висока варіаціяКрихітні датасети (<100 зразків)

Правило великого пальця: Використовуйте K=5 для швидких експериментів, K=10 для фінальної оцінки.


Стратифіковане розділення - Обробка незбалансованих даних ⚖️

Проблема незбалансованості

Уявіть, що ваша піцерія хоче прогнозувати, чи буде замовлення “великим” (5+ піц) чи “звичайним” (1-4 піци).

# Перевіряємо розподіл класів
print(data['is_large_order'].value_counts())

Вихід:

False    9500  (95%)
True      500  (5%)

Лише 5% замовлень великі!

Проблема з випадковим розділенням:

# Випадкове розділення
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

print("Розподіл тренувального набору:")
print(y_train.value_counts(normalize=True))
print("\nРозподіл тестового набору:")
print(y_test.value_counts(normalize=True))

Вихід:

Розподіл тренувального набору:
False    0.951  (95.1%)
True     0.049  (4.9%)

Розподіл тестового набору:
False    0.940  (94.0%)
True     0.060  (6.0%)

Тестовий набір має 6% великих замовлень замість 5% - не репрезентативно!

Стратифіковане розділення на допомогу

# Стратифіковане розділення - зберігає розподіл класів
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    stratify=y,        # Зберігає розподіл y
    random_state=42
)

print("Розподіл тренувального набору:")
print(y_train.value_counts(normalize=True))
print("\nРозподіл тестового набору:")
print(y_test.value_counts(normalize=True))

Вихід:

Розподіл тренувального набору:
False    0.950  (95.0%)
True     0.050  (5.0%)

Розподіл тестового набору:
False    0.950  (95.0%)
True     0.050  (5.0%)

Ідеально! Обидва набори мають рівно 5% великих замовлень. ✅

Стратифікований K-Fold

from sklearn.model_selection import StratifiedKFold, cross_val_score

# Стратифікований K-Fold забезпечує однаковий розподіл класів у кожному фолді
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

scores = cross_val_score(
    model, X, y,
    cv=skf,
    scoring='f1'
)

print(f"Показники стратифікованого K-Fold: {scores}")
print(f"Середнє F1: {scores.mean():.3f} ± {scores.std():.3f}")

Коли використовувати стратифікацію:

  • Бінарна класифікація з незбалансованістю
  • Мультикласова класифікація з нерівними класами
  • Регресія зі скошеним розподілом цілі

Розділення часових рядів - Ніякого підглядання в майбутнє! 📅

Проблема подорожі в часі

НЕ РОБІТЬ ТАК з даними часових рядів:

# НЕПРАВИЛЬНО: Випадкове розділення для часових рядів
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Проблема: Тренувальний набір містить МАЙБУТНІ дані!
# Тренування: [5 січ, 20 лют, 15 бер, ...]
# Тест: [3 січ, 8 лют, 1 бер, ...]

# Ви прогнозуєте 3 січня використовуючи дані з лютого та березня!
# Це подорож у часі - шахрайство!

У реальному продакшені: Ви можете використовувати лише минулі дані для прогнозування майбутнього.

Послідовне розділення

# Спочатку сортуємо дані по даті!
data = data.sort_values('date')

# Розділяємо послідовно - тренуємо на минулому, тестуємо на майбутньому
split_point = int(len(data) * 0.8)

train_data = data[:split_point]
test_data = data[split_point:]

print(f"Період тренування: {train_data['date'].min()} до {train_data['date'].max()}")
print(f"Період тестування: {test_data['date'].min()} до {test_data['date'].max()}")

Вихід:

Період тренування: 2024-01-01 до 2024-10-12
Період тестування: 2024-10-13 до 2024-12-31

Ключовий принцип: Тренуємо на минулому, тестуємо на майбутньому. Без перекриття!

Крос-валідація часових рядів

from sklearn.model_selection import TimeSeriesSplit
import matplotlib.pyplot as plt

# Створюємо розділення часових рядів
tscv = TimeSeriesSplit(n_splits=5)

# Візуалізуємо розділення
fig, ax = plt.subplots(figsize=(12, 6))

for i, (train_idx, test_idx) in enumerate(tscv.split(X)):
    # Малюємо train та test для кожного фолду
    ax.scatter(train_idx, [i]*len(train_idx), c='blue', marker='s', s=10, label='Train' if i==0 else '')
    ax.scatter(test_idx, [i]*len(test_idx), c='red', marker='s', s=10, label='Test' if i==0 else '')

ax.set_xlabel('Індекс зразку (Час →)')
ax.set_ylabel('Fold')
ax.set_title('Крос-валідація часових рядів')
ax.legend()
plt.tight_layout()
plt.show()

Як це працює:

Fold 1: [train              ] [test]
Fold 2: [train                    ] [test]
Fold 3: [train                          ] [test]
Fold 4: [train                                ] [test]
Fold 5: [train                                      ] [test]

Тренувальний набір зростає по мірі просування вперед у часі - імітує реальний сценарій!

Реалізація

from sklearn.model_selection import TimeSeriesSplit, cross_val_score

tscv = TimeSeriesSplit(n_splits=5)

scores = cross_val_score(
    model, X, y,
    cv=tscv,
    scoring='neg_mean_absolute_error'
)

# Примітка: негативне, бо sklearn хоче максимізувати показники
mae_scores = -scores

print(f"MAE для кожного фолду: {mae_scores}")
print(f"Середній MAE: {mae_scores.mean():.2f} піц")

Поширені помилки та як їх уникнути 🚨

Помилка 1: Витік даних - Фітування скейлера на повному датасеті

❌ НЕПРАВИЛЬНО:

from sklearn.preprocessing import StandardScaler

# Фітуємо скейлер на ВСІХ даних
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # Використовує статистику тестових даних!

# Потім розділяємо
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y)

# Проблема: Модель бачила статистику тестових даних (середнє, std)

✅ ПРАВИЛЬНО:

# Спочатку розділяємо
X_train, X_test, y_train, y_test = train_test_split(X, y)

# Фітуємо скейлер ЛИШЕ на тренувальних даних
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)  # Вчимося з train
X_test_scaled = scaler.transform(X_test)        # Застосовуємо до test

# Тестові дані справді невидимі!

Ключовий принцип: Завжди розділяйте перед будь-якою трансформацією даних!

Помилка 2: Використання тестового набору кілька разів

❌ НЕПРАВИЛЬНО:

# Спробуйте модель 1
model1 = RandomForest()
model1.fit(X_train, y_train)
score1 = model1.score(X_test, y_test)  # 0.85

# Недостатньо добре, спробуйте модель 2
model2 = XGBoost()
model2.fit(X_train, y_train)
score2 = model2.score(X_test, y_test)  # 0.87

# Все ще пробуємо... модель 3
model3 = NeuralNet()
model3.fit(X_train, y_train)
score3 = model3.score(X_test, y_test)  # 0.89

# Обираємо модель 3, бо вона показала найкраще на тестовому наборі
# Проблема: Ви перенавчилися на тестовому наборі!

✅ ПРАВИЛЬНО:

# Використовуємо валідаційний набір або крос-валідацію для порівняння моделей
for model in [RandomForest(), XGBoost(), NeuralNet()]:
    scores = cross_val_score(model, X_train, y_train, cv=5)
    print(f"{model.__class__.__name__}: {scores.mean():.3f}")

# Обираємо найкращу модель на основі крос-валідації

# Тестуємо ОДИН РАЗ на тестовому наборі в самому кінці
final_model.fit(X_train, y_train)
final_score = final_model.score(X_test, y_test)
print(f"Фінальний тестовий показник: {final_score:.3f}")

Помилка 3: Не перемішування даних

❌ НЕПРАВИЛЬНО:

# Дані відсортовані по даті або категорії
# Без перемішування, перші 80% можуть бути лише січневими даними
X_train, X_test = X[:8000], X[8000:]

✅ ПРАВИЛЬНО:

# Перемішуємо для випадкового розділення (окрім часових рядів!)
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    shuffle=True,     # За замовчуванням True
    random_state=42
)

Помилка 4: Забування встановити random_state

❌ НЕПРАВИЛЬНО:

# Різні результати кожного запуску!
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# Неможливо відтворити результати або порівняти справедливо

✅ ПРАВИЛЬНО:

# Відтворювані результати
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42  # Будь-яке число, але тримайте його послідовним
)

Помилка 5: Тестування на тренувальних даних

❌ НЕПРАВИЛЬНО:

model.fit(X_train, y_train)
score = model.score(X_train, y_train)  # Тестування на тренувальних даних!
print(f"Точність моделі: {score:.2f}")  # Завжди виглядає чудово!

✅ ПРАВИЛЬНО:

model.fit(X_train, y_train)
score = model.score(X_test, y_test)   # Тестування на невидимих даних
print(f"Точність моделі: {score:.2f}") # Реалістична оцінка

Зібрати все разом - Повний робочий процес 🏗️

Давайте створимо повний, готовий до продакшену робочий процес, використовуючи все, що ми навчились!

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, r2_score

# Крок 1: Завантаження та підготовка даних
print("🍕 Піцерія Мама ML - Повний робочий процес ML\n")
data = pd.read_csv('pizza_orders_clean.csv')

# Фічі та ціль
X = data.drop(['num_pizzas', 'date', 'order_id'], axis=1)
y = data['num_pizzas']

print(f"Всього зразків: {len(X)}\n")

# Крок 2: Початкове train/test розділення (80/20)
# КРИТИЧНО: Розділяємо ПЕРЕД будь-якою попередньою обробкою!
X_train_full, X_test, y_train_full, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42,
    shuffle=True
)

print(f"Тренувальний набір: {len(X_train_full)} зразків")
print(f"Тестовий набір: {len(X_test)} зразків (ЗАМКНУТИЙ 🔒)\n")

# Крок 3: Створюємо валідаційний набір з тренувальних даних
X_train, X_val, y_train, y_val = train_test_split(
    X_train_full, y_train_full,
    test_size=0.25,  # 25% з 80% = 20% загалом
    random_state=42
)

print(f"Фінальний тренувальний набір: {len(X_train)} зразків (60%)")
print(f"Валідаційний набір: {len(X_val)} зразків (20%)\n")

# Крок 4: Попередня обробка - фітуємо лише на тренувальних даних!
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_val_scaled = scaler.transform(X_val)
X_test_scaled = scaler.transform(X_test)

print("✅ Дані масштабовано (без витоку!)\n")

# Крок 5: Налаштування гіперпараметрів використовуючи валідаційний набір
print("🔧 Налаштування гіперпараметрів на валідаційному наборі...\n")

best_score = float('inf')
best_params = None

for n_estimators in [50, 100, 150]:
    for max_depth in [10, 20, 30]:
        model = RandomForestRegressor(
            n_estimators=n_estimators,
            max_depth=max_depth,
            random_state=42,
            n_jobs=-1
        )
        model.fit(X_train_scaled, y_train)
        val_pred = model.predict(X_val_scaled)
        mae = mean_absolute_error(y_val, val_pred)

        if mae < best_score:
            best_score = mae
            best_params = {
                'n_estimators': n_estimators,
                'max_depth': max_depth
            }

print(f"Найкращі гіперпараметри: {best_params}")
print(f"Валідаційний MAE: {best_score:.2f} піц\n")

# Крок 6: Крос-валідація для більш надійної оцінки
print("📊 Крос-валідація на повному тренувальному наборі...\n")

final_model = RandomForestRegressor(**best_params, random_state=42, n_jobs=-1)

cv_scores = cross_val_score(
    final_model,
    scaler.fit_transform(X_train_full),
    y_train_full,
    cv=5,
    scoring='neg_mean_absolute_error'
)

cv_mae = -cv_scores.mean()
cv_std = cv_scores.std()

print(f"Крос-валідаційний MAE: {cv_mae:.2f} ± {cv_std:.2f} піц\n")

# Крок 7: Тренуємо фінальну модель на всіх тренувальних даних
print("🎓 Тренування фінальної моделі на повному тренувальному наборі...\n")

final_model.fit(
    scaler.fit_transform(X_train_full),
    y_train_full
)

# Крок 8: ОДНОРАЗОВА оцінка на тестовому наборі
print("🎯 ФІНАЛЬНА ТЕСТОВА ОЦІНКА (лише один раз!):\n")
print("="*50)

test_pred = final_model.predict(X_test_scaled)
test_mae = mean_absolute_error(y_test, test_pred)
test_r2 = r2_score(y_test, test_pred)

print(f"Тестовий MAE: {test_mae:.2f} піц")
print(f"Тестовий R²: {test_r2:.3f}")
print("="*50)

print("\n💡 Це продуктивність, яку ми очікуємо в продакшені!")

# Крок 9: Аналіз важливості фіч
feature_importance = pd.DataFrame({
    'feature': X.columns,
    'importance': final_model.feature_importances_
}).sort_values('importance', ascending=False)

print("\n📊 Топ 5 найважливіших фіч:")
print(feature_importance.head().to_string(index=False))

# Крок 10: Зберігаємо модель та скейлер для продакшену
import joblib

joblib.dump(final_model, 'pizza_model.pkl')
joblib.dump(scaler, 'pizza_scaler.pkl')

print("\n✅ Модель та скейлер збережено для продакшену!")
print("\nРобочий процес завершено! 🎉")

Очікуваний вихід:

🍕 Піцерія Мама ML - Повний робочий процес ML

Всього зразків: 10000

Тренувальний набір: 8000 зразків
Тестовий набір: 2000 зразків (ЗАМКНУТИЙ 🔒)

Фінальний тренувальний набір: 6000 зразків (60%)
Валідаційний набір: 2000 зразків (20%)

✅ Дані масштабовано (без витоку!)

🔧 Налаштування гіперпараметрів на валідаційному наборі...

Найкращі гіперпараметри: {'n_estimators': 100, 'max_depth': 20}
Валідаційний MAE: 0.42 піц

📊 Крос-валідація на повному тренувальному наборі...

Крос-валідаційний MAE: 0.45 ± 0.03 піц

🎓 Тренування фінальної моделі на повному тренувальному наборі...

🎯 ФІНАЛЬНА ТЕСТОВА ОЦІНКА (лише один раз!):
==================================================
Тестовий MAE: 0.47 піц
Тестовий R²: 0.876
==================================================

💡 Це продуктивність, яку ми очікуємо в продакшені!

📊 Топ 5 найважливіших фіч:
           feature  importance
 is_friday_evening      0.245
          hour_sin      0.187
       temperature      0.156
      is_peak_hour      0.128
  rolling_avg_3h         0.092

✅ Модель та скейлер збережено для продакшену!

Робочий процес завершено! 🎉

Швидкий довідник 📋

Шпаргалка з розділення даних

# 1. Просте розділення 80/20
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# 2. Трьохстороннє розділення (60/20/20)
X_temp, X_test, y_temp, 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_temp, y_temp, test_size=0.25, random_state=42)

# 3. Стратифіковане розділення (для незбалансованих даних)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)

# 4. K-Fold крос-валідація
from sklearn.model_selection import cross_val_score
scores = cross_val_score(model, X, y, cv=5)

# 5. Стратифікований K-Fold
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=skf)

# 6. Розділення часових рядів
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(model, X, y, cv=tscv)

Дерево рішень: Яке розділення мені використовувати?

Це дані часових рядів?
├─ ТАК → Використайте TimeSeriesSplit або послідовне розділення
└─ НІ
    └─ Датасет незбалансований?
        ├─ ТАК → Використайте стратифіковане розділення / StratifiedKFold
        └─ НІ
            └─ Потрібне налаштування гіперпараметрів?
                ├─ ТАК → Використайте train/val/test (60/20/20)
                └─ НІ
                    └─ Малий датасет (<1000 зразків)?
                        ├─ ТАК → Використайте K-Fold крос-валідацію (K=5 або 10)
                        └─ НІ → Використайте просте train/test розділення (80/20)

Золоті правила

  1. Завжди розділяйте ПЕРЕД попередньою обробкою (масштабування, кодування, тощо)
  2. Встановлюйте random_state для відтворюваності
  3. Використовуйте стратифікацію для незбалансованих даних
  4. Ніколи не використовуйте тестовий набір для вибору моделі
  5. Торкайтесь тестового набору ЛИШЕ ОДИН РАЗ, в самому кінці
  6. Для часових рядів: тренуйте на минулому, тестуйте на майбутньому
  7. Використовуйте крос-валідацію для надійних оцінок

Підсумок та наступні кроки 🎯

Що ви навчились

1. Чому розділення даних критично важливе

  • Запобігти перенавчанню тестуючи на невидимих даних
  • Імітувати умови реального розгортання
  • Отримати чесні оцінки продуктивності

2. Просте розділення Train/Test

  • Правило 80/20 за замовчуванням
  • Завжди встановлюйте random_state
  • Тестовий набір імітує продакшн дані

3. Розділення Train/Validation/Test

  • Використовуйте валідаційний набір для налаштування гіперпараметрів
  • Тримайте тестовий набір повністю недоторканим
  • Торкайтесь тестового набору лише один раз

4. K-Fold крос-валідація

  • Більш надійні оцінки продуктивності
  • Кожен зразок використовується для тренування та тестування
  • Стандартний вибір: K=5 або K=10

5. Спеціальні випадки

  • Стратифіковане розділення для незбалансованих даних
  • Розділення часових рядів (ніякого підглядання в майбутнє!)
  • Послідовна валідація для темпоральних даних

6. Поширені пастки

  • Витік даних від попередньої обробки перед розділенням
  • Використання тестового набору кілька разів
  • Випадкове розділення для часових рядів

Подорож даними досі

Сирі дані

[Стаття 1: Якість даних] ✅ Очистити дані

[Стаття 2: EDA] ✅ Зрозуміти патерни

[Стаття 3: Фіча-інженірінг] ✅ Створити ML-готові фічі

[Стаття 4: Train/Test розділення] ✅ Правильно розділити дані (Ви тут!)

Готові тренувати моделі!

[Стаття 5: Лінійна регресія] → Далі!

Що далі?

У Статті 5: Ваша перша ML модель - Лінійна регресія, ми нарешті натренуємо модель:

  • Розуміння лінійних зв’язків
  • Тренування вашого першого алгоритму
  • Інтерпретація параметрів моделі
  • Прогнозування на нових даних
  • Оцінка продуктивності моделі

Тепер ви знаєте, як правильно розділяти дані. Далі ми використаємо ці знання для тренування та оцінки вашої першої моделі машинного навчання!

Практичні вправи

Вправа 1: Базове розділення

# Маючи датасет з 1000 зразками, створіть:
# 1. Розділення 80/20 train/test з random_state=42
# 2. Перевірте, що розміри правильні
# 3. Перевірте, чи random_state дає відтворювані результати

Вправа 2: Трьохстороннє розділення

# Створіть розділення 60/20/20 train/validation/test
# Виведіть розмір кожного набору
# Перевірте, що вони складаються до 100% оригінальних даних

Вправа 3: Знайдіть помилку

# Що не так з цим кодом?
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test = train_test_split(X_scaled, test_size=0.2)

Вправа 4: Часові ряди

# У вас є замовлення піци з січня-грудня 2024
# Створіть правильне розділення часових рядів:
# - Train: Січень-Вересень
# - Test: Жовтень-Грудень
# Переконайтесь, що немає витоку даних!

Готові тренувати вашу першу модель? З правильно розділеними даними, ваші моделі справді працюватимуть в реальному світі! 🚀

Пам’ятайте: Спочатку розділяємо, потім тренуємо, тестуємо останнім! 🎯

Щасливого розділення! 💪✨