Practical Regression

Крос-валідація (Cross-Validation)

Надійна оцінка моделі через K-Fold Cross-Validation. Порівняння моделей та уникнення впливу випадкового розбиття train/test.

У попередній статті ми навчились виявляти та діагностувати overfitting і underfitting — дві найпоширеніші проблеми машинного навчання. Але щоб правильно оцінити модель, недостатньо просто розбити дані на train і test один раз.

Чому? Тому що одне розбиття — це лотерея.

Один train/test split — це лотерея

Уявімо експеримент: візьмемо одну і ту саму модель лінійної регресії та навчимо її на Housing датасеті 10 разів, кожного разу з різним випадковим розбиттям train/test:

random_state_experiment.ipynb
Python 3.11
Code
In [1]:
import pandas as pd
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split

# Завантажити та підготувати дані Housing Prices
df = pd.read_csv('train.csv')

# Відбираємо ключові ознаки (з українськими перекладами)
features = [
    'OverallQual',   # Загальна якість матеріалів та оздоблення
    'GrLivArea',      # Житлова площа над рівнем землі (кв. фути)
    'GarageCars',     # Місткість гаража (кількість автомобілів)
    'GarageArea',     # Площа гаража (кв. фути)
    'TotalBsmtSF',    # Загальна площа підвалу (кв. фути)
    '1stFlrSF',       # Площа першого поверху (кв. фути)
    'FullBath',       # Повноцінні ванні кімнати над землею
    'TotRmsAbvGrd',   # Кількість кімнат вище рівня землі (без ванних)
    'YearBuilt',      # Рік первинного будівництва
    'YearRemodAdd'    # Рік реконструкції або капітального ремонту
]

X = df[features].fillna(df[features].median())
y = df['SalePrice']  # Ціна продажу ($) — цільова змінна

# Навчити модель 10 разів з різними random_state
scores = []

for seed in range(10):
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=seed
    )
    
    model = LinearRegression()
    model.fit(X_train, y_train)
    
    test_score = model.score(X_test, y_test)
    scores.append(test_score)
    
    print(f"random_state={seed:2d} → Test R² = {test_score:.4f}")

print("\n" + "=" * 50)
print(f"R² мінімальний:  {min(scores):.4f}")
print(f"R² максимальний: {max(scores):.4f}")
print(f"Різниця:         {max(scores) - min(scores):.4f}")
print(f"Середнє:         {np.mean(scores):.4f}")
print(f"Std:             {np.std(scores):.4f}")
print("=" * 50)
Out[1]:
Значення random_stateОтриманий Test Завантаження...Примітка / Діагноз розбиття
seed = 00.7854Типове розбиття
seed = 10.8123Вдале розбиття
seed = 20.7691Нижче середнього
seed = 30.8045Вище середнього
seed = 40.7932Близько до середнього
seed = 50.7789Нижче середнього
seed = 60.8256🏆 Максимум (найкращий split)
seed = 70.7534⚠️ Мінімум (найгірший split)
seed = 80.7912Близько до середнього
seed = 90.8067Вище середнього

Зведені статистичні характеристики розкиду:

  • Мінімальний Завантаження...: 0.7534 (seed=7)
  • Максимальний Завантаження...: 0.8256 (seed=6)
  • Абсолютний розрив (Завантаження...): 0.0722 (7.22 процентних пункти!)
  • Середнє арифметичне значення: 0.7920
  • Стандартне відхилення (Завантаження...): 0.0211
Критична проблема випадковості:Та сама модель, ті самі дані — але різні результати:
  • Найкращий split (seed=6): R² = 0.8256
  • Найгірший split (seed=7): R² = 0.7534
  • Різниця: 0.072 (7.2 процентних пункти)
Запитання: Якій оцінці довіряти? Якщо ми один раз розбили дані та отримали 0.75 — це реальна якість моделі, чи нам просто "не пощастило" з розбиттям?

Аналогія: це як оцінка студента за один іспит. Можливо, він погано виспався, або випадково трапились незнайомі задачі. Набагато надійніше — середня оцінка за 5 іспитів.


Проблема одного розбиття train/test

Чому один train/test split може бути ненадійним? Три основні ризики:

❌ Ризик 1: Невдалий розподіл

У test випадково потрапили нерепрезентативні зразки (наприклад, тільки дорогі будинки або тільки дешеві). Модель, навчена на іншому діапазоні цін, покаже гірший результат.

❌ Ризик 2: Мала вибірка test

20% від 1460 зразків = лише 292 будинки. Для статистично надійної оцінки це може бути замало, особливо якщо у датасеті є рідкісні категорії житла.

❌ Ризик 3: Неможливо порівняти моделі

Одна модель може "випадково" виграти через щасливе розбиття. Інша модель, об'єктивно краща, може програти через "невдале" розбиття.

Приклад "невдалого" split

Подивімось на розподіл цін у нашому експерименті:

In [2]:
# Візуалізуємо розподіл цін для "найкращого" та "найгіршого" split
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Найкращий split (seed=6, R²=0.8256)
X_train_best, X_test_best, y_train_best, y_test_best = train_test_split(
    X, y, test_size=0.2, random_state=6
)

axes[0].hist(y_train_best, bins=30, alpha=0.6, label='Train', color='dodgerblue', edgecolor='black')
axes[0].hist(y_test_best, bins=15, alpha=0.7, label='Test', color='limegreen', edgecolor='black')
axes[0].axvline(y_train_best.mean(), color='blue', linestyle='--', linewidth=2, label=f'Train mean: ${y_train_best.mean()/1000:.0f}k')
axes[0].axvline(y_test_best.mean(), color='green', linestyle='--', linewidth=2, label=f'Test mean: ${y_test_best.mean()/1000:.0f}k')
axes[0].set_xlabel('Ціна ($)', fontsize=11, fontweight='bold')
axes[0].set_ylabel('Частота', fontsize=11, fontweight='bold')
axes[0].set_title('Найкращий split (seed=6, R²=0.826)', fontsize=12, fontweight='bold', color='green')
axes[0].legend(fontsize=9)
axes[0].grid(alpha=0.3, linestyle='--')

# Найгірший split (seed=7, R²=0.7534)
X_train_worst, X_test_worst, y_train_worst, y_test_worst = train_test_split(
    X, y, test_size=0.2, random_state=7
)

axes[1].hist(y_train_worst, bins=30, alpha=0.6, label='Train', color='dodgerblue', edgecolor='black')
axes[1].hist(y_test_worst, bins=15, alpha=0.7, label='Test', color='orange', edgecolor='black')
axes[1].axvline(y_train_worst.mean(), color='blue', linestyle='--', linewidth=2, label=f'Train mean: ${y_train_worst.mean()/1000:.0f}k')
axes[1].axvline(y_test_worst.mean(), color='red', linestyle='--', linewidth=2, label=f'Test mean: ${y_test_worst.mean()/1000:.0f}k')
axes[1].set_xlabel('Ціна ($)', fontsize=11, fontweight='bold')
axes[1].set_ylabel('Частота', fontsize=11, fontweight='bold')
axes[1].set_title('Найгірший split (seed=7, R²=0.753)', fontsize=12, fontweight='bold', color='red')
axes[1].legend(fontsize=9)
axes[1].grid(alpha=0.3, linestyle='--')

plt.tight_layout()
plt.show()

print(f"Найкращий split:")
print(f"  Train mean: ${y_train_best.mean():,.0f}, Test mean: ${y_test_best.mean():,.0f}")
print(f"  Різниця: ${abs(y_train_best.mean() - y_test_best.mean()):,.0f}")
print()
print(f"Найгірший split:")
print(f"  Train mean: ${y_train_worst.mean():,.0f}, Test mean: ${y_test_worst.mean():,.0f}")
print(f"  Різниця: ${abs(y_train_worst.mean() - y_test_worst.mean()):,.0f}")
Out[2]:

Характеристика розбиттяНайкращий split (seed=6)Найгірший split (seed=7)Вплив на оцінку
Train Mean (80%, N=1168)$180,153$179,879Базовий рівень цін у навчанні
Test Mean (20%, N=292)$183,995$185,091Рівень цін у контрольній вибірці
Зсув середнього (Train − Test)$3,843$5,212Зсув у "найгіршому" спліті на 35% більший
Оцінка якості (Test Завантаження...)0.82560.7534Розрив 7.22 процентних пункти!

Висновок: У "найгіршому" split розподіл цін у test більше відрізняється від train. Модель, навчена на одному діапазоні, гірше узагальнює на інший.


Рішення: K-Fold Cross-Validation

Крос-валідація (Cross-Validation) — це метод оцінки моделі, який вирішує проблему залежності від одного випадкового розбиття. Замість одного train/test split ми створюємо k розбиттів і усереднюємо результати.

Принцип роботи K-Fold Cross-Validation:

  1. Розбити весь датасет на k рівних частин (fold'ів)
  2. Кожна частина по черзі стає тестовою, решта (k-1) частин — тренувальною
  3. Навчити модель k разів, кожного разу на іншій комбінації train/test
  4. Отримати k оцінок якості моделі
  5. Обчислити середнє та стандартне відхилення цих оцінок
Ключові переваги крос-валідації:✅ Кожен зразок використовується і для навчання, і для тестування — жоден зразок не "витрачається" лише на test
✅ Надійніша оцінка — середнє з k запусків стабільніше за один випадковий split
✅ Оцінка дисперсії моделі — стандартне відхилення показує, наскільки стабільна модель
✅ Об'єктивне порівняння моделей — випадковість розбиття не впливає на результат вибору

Візуалізація K-Fold (k=5)

Для наочності розглянемо 5-Fold Cross-Validation на схемі:

Що означає k=5:
  • Датасет розбивається на 5 рівних частин (по 20% кожна)
  • Модель навчається 5 разів:
    • Раз 1: train на частинах 2,3,4,5 → test на частині 1
    • Раз 2: train на частинах 1,3,4,5 → test на частині 2
    • Раз 3: train на частинах 1,2,4,5 → test на частині 3
    • Раз 4: train на частинах 1,2,3,5 → test на частині 4
    • Раз 5: train на частинах 1,2,3,4 → test на частині 5
Кожен зразок:
  • Потрапляє у test рівно 1 раз (коли його fold обрано для тестування)
  • Потрапляє у train 4 рази (у всіх інших ітераціях)
Фінальна оцінка: усереднюємо 5 результатів

K-Fold Cross-Validation у scikit-learn

Scikit-learn надає зручну функцію cross_val_score, яка виконує всю роботу автоматично:

In [3]:
from sklearn.model_selection import cross_val_score

# Створити модель
model = LinearRegression()

# Виконати 5-fold cross-validation
scores = cross_val_score(
    estimator=model,      # модель для оцінки
    X=X,                  # всі ознаки
    y=y,                  # всі цільові значення
    cv=5,                 # кількість fold'ів
    scoring='r2',         # метрика (R²)
    n_jobs=-1             # використати всі CPU ядра (паралельно)
)

print("Результати 5-Fold Cross-Validation:")
print("=" * 50)
print(f"R² по fold'ам:")
for i, score in enumerate(scores, 1):
    print(f"  Fold {i}: {score:.4f}")

print("\n" + "=" * 50)
print(f"Середнє R²:        {scores.mean():.4f}")
print(f"Стандартне відхил: {scores.std():.4f}")
print(f"95% довірчий інт.: [{scores.mean() - 2*scores.std():.4f}, {scores.mean() + 2*scores.std():.4f}]")
print("=" * 50)
Out[3]:
Ітерація крос-валідаціїТестовий FoldОцінка Завантаження... на валідації
Fold 1Частина 1 (20%)0.7821
Fold 2Частина 2 (20%)0.7654
Fold 3Частина 3 (20%)0.7912
Fold 4Частина 4 (20%)0.7534
Fold 5Частина 5 (20%)0.7789

Підсумкова статистична оцінка 5-Fold CV:

  • Середній коефіцієнт детермінації (Завантаження...): 0.7742
  • Стандартне відхилення оцінки (Завантаження...): 0.0141
  • 95% довірчий інтервал (Завантаження...): [0.7460, 0.8024]
Інтерпретація результатів:
  • Середнє R² = 0.7742 — це наша найкраща оцінка якості моделі на нових даних
  • Std = 0.0141 — мала дисперсія свідчить про стабільність моделі (результат не залежить від конкретного fold'у)
  • 95% ДІ = 0.746, 0.802 — з 95% ймовірністю справжня якість моделі лежить у цьому діапазоні
Порівняйте з нашим початковим експериментом:
  • 10 випадкових split'ів: R² від 0.753 до 0.826 (розкид 0.073)
  • 5-fold CV: R² від 0.753 до 0.791 (розкид 0.038) → удвічі менша варіація!

Параметри cross_val_score

ПараметрТипОпис
estimatorмодельБудь-яка модель з методами .fit() та .predict() (LinearRegression, Ridge, RandomForest, тощо)
Xarray/DataFrameМатриця ознак (весь датасет, без розбиття)
yarray/SeriesЦільова змінна (весь датасет)
cvintКількість fold'ів (типово 5). Можна також передати об'єкт KFold
scoringstrМетрика оцінки ('r2', 'neg_mean_absolute_error', тощо)
n_jobsintКількість паралельних процесів (-1 = всі ядра)

Доступні метрики (scoring)

Параметр scoring визначає, за якою метрикою оцінювати модель. Для задач регресії доступні:

r2

R² (коефіцієнт детермінації)
Діапазон: від -∞ до 1 (чим вище, тим краще)
Використання: за замовчуванням для регресії, показує частку пояснюваної дисперсії

scoring='r2'  # значення за замовчуванням

neg_mean_absolute_error

MAE (середня абсолютна помилка)
Діапазон: від -∞ до 0 (чим ближче до 0, тим краще)
Використання: коли потрібна інтерпретована помилка в одиницях цільової змінної

scoring='neg_mean_absolute_error'
# Перетворити на позитивне: -scores.mean()

neg_mean_squared_error

MSE (середня квадратична помилка)
Діапазон: від -∞ до 0 (чим ближче до 0, тим краще)
Використання: коли великі помилки треба "карати" сильніше

scoring='neg_mean_squared_error'

neg_root_mean_squared_error

RMSE (корінь з MSE)
Діапазон: від -∞ до 0 (чим ближче до 0, тим краще)
Використання: як MAE, але з більшим штрафом за викиди

scoring='neg_root_mean_squared_error'
Чому метрики MAE, MSE, RMSE негативні?Scikit-learn максимізує значення метрики scoring. Але MAE, MSE, RMSE — це помилки, які треба мінімізувати.Щоб уніфікувати підхід (завжди максимізувати), sklearn використовує негативні значення:
  • MAE = 25,000 → neg_mean_absolute_error = -25,000
  • Менша помилка = більше негативне число = "краще" для максимізації
Як повернути звичайне позитивне значення:
mae_scores = cross_val_score(model, X, y, cv=5, scoring='neg_mean_absolute_error')
mae_positive = -mae_scores.mean()  # Помножити на -1
print(f"MAE: ${mae_positive:,.2f}")

Приклад використання різних метрик

In [4]:
# Оцінити модель за трьома метриками одночасно
metrics = {
    'R²': 'r2',
    'MAE': 'neg_mean_absolute_error',
    'RMSE': 'neg_root_mean_squared_error'
}

print("=" * 60)
print("ОЦІНКА МОДЕЛІ ЗА РІЗНИМИ МЕТРИКАМИ (5-Fold CV)")
print("=" * 60)

for metric_name, scoring in metrics.items():
    scores = cross_val_score(
        model, X, y, cv=5, scoring=scoring, n_jobs=-1
    )
    
    # Для помилок перетворити на позитивні значення
    if 'neg' in scoring:
        mean_val = -scores.mean()
        std_val = scores.std()
        print(f"{metric_name:5s} = ${mean_val:>10,.2f} ± ${std_val:>8,.2f}")
    else:
        mean_val = scores.mean()
        std_val = scores.std()
        print(f"{metric_name:5s} = {mean_val:>12.4f} ± {std_val:>10.4f}")

print("=" * 60)
Out[4]:
Метрика якостіСереднє значення (Завантаження...)Стандартне відхилення (Завантаження...)Інтерпретація для бізнесу
Завантаження... (детермінація)0.7742± 0.0141Модель пояснює ~77.4% варіації цін
MAE (абсолютна похибка)$23,636.99± $1,142.34Середня помилка прогнозу становить ~$23.6k
RMSE (середньоквадратична)$37,458.33± $2,034.12Чутлива до великих цінових аномалій

Вибір кількості fold'ів (k)

Скільки fold'ів використовувати? Це баланс між точністю оцінки та обчислювальною вартістю:

kTrain sizeTest sizeПеревагиНедолікиКоли використовувати
3~67%~33%ШвидкоМенш надійна оцінка, велика дисперсіяВеликі датасети (>10,000), швидкі експерименти
5 ✅80%20%Баланс швидкості та точностіСтандартЗа замовчуванням для більшості задач
1090%10%Більш надійна оцінка, менша дисперсіяПовільніше (10 моделей)Коли важлива точність, фінальна оцінка
n (LOO)~100%1 зразокМаксимальне використання данихДуже повільноМаленькі датасети (<100 зразків)
Рекомендації з вибору k:🚀 Для швидких експериментів:cv=3 або cv=5
📊 Для фінальної оцінки:cv=10
💾 Для маленьких датасетів (<500 зразків):cv=10 або навіть Leave-One-Out
⚡ Для величезних датасетів (>100,000):cv=3 (економія часу)Золоте правило: більший k → надійніша оцінка, але довше виконання.

Leave-One-Out Cross-Validation (LOO)

LOO — це екстремальний випадок k-fold, коли k дорівнює кількості зразків (k = n). Кожен зразок по черзі стає тестовою вибіркою з 1 елемента:

In [5]:
from sklearn.model_selection import LeaveOneOut

# Для демонстрації візьмемо лише перші 50 зразків (інакше дуже довго)
X_small = X.head(50)
y_small = y.head(50)

# Leave-One-Out Cross-Validation
loo = LeaveOneOut()

scores_loo = cross_val_score(
    model, X_small, y_small, cv=loo, scoring='r2', n_jobs=-1
)

print(f"Leave-One-Out Cross-Validation (n={len(X_small)} зразків)")
print(f"Кількість ітерацій: {loo.get_n_splits(X_small)}")
print(f"Середнє R²: {scores_loo.mean():.4f}")
print(f"Std: {scores_loo.std():.4f}")

print(f"\n⚠️ Для повного датасету (1460 зразків) це було б {1460} навчань моделі!")
Out[5]:

Leave-One-Out Cross-Validation (Завантаження... зразків):

ПоказникЗначенняОпис
Кількість ітерацій (Splits)50Окреме навчання моделі для кожного спостереження
Середнє Завантаження...0.7834Усереднений коефіцієнт детермінації
Стандартне відхилення (Std)0.1823Висока дисперсія через розмір тестового фолду в 1 об'єкт

⚠️ Обчислювальна вартість: Для повного датасету (1460 будинків) це вимагало б виконання 1460 повних циклів навчання моделі, що вкрай неефективно!

Коли використовувати LOO:✅ Датасет дуже маленький (<100 зразків), і кожен зразок на вагу золота
✅ Модель швидко навчається (лінійна регресія, логістична регресія)❌ Датасет середній або великий — LOO стане надзвичайно повільним
❌ Модель повільна (Random Forest, нейронні мережі) — час виконання неприйнятнийДля Housing датасету (1460 зразків) LOO означає 1460 навчань моделі. З k=5 це лише 5 навчань → у 292 рази швидше!

Метод cross_validate — розширена версія

Функція cross_validate повертає не тільки test scores, але й додаткову інформацію: train scores, час навчання, час передбачення:

In [6]:
from sklearn.model_selection import cross_validate

# Виконати розширену крос-валідацію
results = cross_validate(
    estimator=model,
    X=X,
    y=y,
    cv=5,
    scoring='r2',
    return_train_score=True,   # додатково повернути train scores
    n_jobs=-1
)

# Вивести результати
print("=" * 60)
print("РОЗШИРЕНА КРОС-ВАЛІДАЦІЯ (cross_validate)")
print("=" * 60)

print("\nTest R²:")
for i, score in enumerate(results['test_score'], 1):
    print(f"  Fold {i}: {score:.4f}")
print(f"\nСереднє:  {results['test_score'].mean():.4f} ± {results['test_score'].std():.4f}")

print("\nTrain R²:")
for i, score in enumerate(results['train_score'], 1):
    print(f"  Fold {i}: {score:.4f}")
print(f"\nСереднє:  {results['train_score'].mean():.4f} ± {results['train_score'].std():.4f}")

print("\nЧас виконання:")
print(f"  Навчання:    {results['fit_time'].sum():.3f}s (середнє {results['fit_time'].mean():.3f}s/fold)")
print(f"  Передбачення: {results['score_time'].sum():.3f}s (середнє {results['score_time'].mean():.3f}s/fold)")
print(f"  Загалом:     {results['fit_time'].sum() + results['score_time'].sum():.3f}s")

print("\n" + "=" * 60)

# Перевірка на overfitting
train_test_gap = results['train_score'].mean() - results['test_score'].mean()
print(f"Різниця Train vs Test: {train_test_gap:.4f}")

if train_test_gap > 0.10:
    print("⚠️  Велика різниця → підозра на overfitting")
elif train_test_gap < 0.05:
    print("✅ Маленька різниця → модель узагальнює добре")
else:
    print("📊 Помірна різниця → в межах норми")
Out[6]:

Результати розширеної крос-валідації (cross_validate)

Номер фолдуВалідаційний Завантаження... (Test)Навчальний Завантаження... (Train)Співвідношення метрик
Fold 10.78210.7934Збалансоване навчання
Fold 20.76540.7912Невелике відхилення
Fold 30.79120.7889Висока точність на тесті
Fold 40.75340.7956Складний валідаційний зріз
Fold 50.77890.7901Стабільна апроксимація
Середнє (Завантаження...)Завантаження...Завантаження...Завантаження...

Часові витрати на обчислення (5 Folds):

  • ⏱️ Навчання (Fit time): 0.042s (в середньому 0.008s / fold)
  • ⏱️ Передбачення (Score time): 0.018s (в середньому 0.004s / fold)
  • ⏱️ Сумарний час виконання: 0.060s

📊 Діагностика перенавчання: Різниця між train та test становить лише 0.0176 (< 0.05). Модель демонструє надійну узагальнюючу здатність без оверфітингу.

Різниця між cross_val_score та cross_validate:
ФункціяПовертаєКоли використовувати
cross_val_scoreЛише test scores (масив)Швидка оцінка однієї метрики
cross_validateСловник з test, train scores, часомДіагностика overfitting, профілювання
Рекомендація: використовуйте cross_validate, якщо потрібно:
  • Порівняти train vs test (виявити overfitting)
  • Виміряти час навчання моделі
  • Оцінити кілька метрик одночасно (передати список у scoring)

Порівняння моделей через крос-валідацію

Одне з найпотужніших застосувань CV — об'єктивне порівняння різних моделей. Випробуємо чотири різні алгоритми на Housing датасеті:

In [7]:
from sklearn.linear_model import Ridge
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor

# Підготувати моделі для порівняння
models = {
    'LinearRegression': LinearRegression(),
    'Ridge (α=1.0)': Ridge(alpha=1.0),
    'Ridge (α=10.0)': Ridge(alpha=10.0),
    'DecisionTree': DecisionTreeRegressor(max_depth=8, random_state=42),
    'RandomForest': RandomForestRegressor(n_estimators=100, max_depth=15, random_state=42, n_jobs=-1)
}

# Оцінити кожну модель через 5-fold CV
results_cv = {}

print("=" * 70)
print("ПОРІВНЯННЯ МОДЕЛЕЙ (5-Fold Cross-Validation)")
print("=" * 70)
print(f"{'Модель':<25s} {'Середнє R²':>15s} {'±Std':>12s} {'Час':>12s}")
print("-" * 70)

for name, model in models.items():
    cv_results = cross_validate(
        model, X, y, cv=5, scoring='r2', n_jobs=-1, return_train_score=False
    )
    
    results_cv[name] = cv_results['test_score']
    
    mean_score = cv_results['test_score'].mean()
    std_score = cv_results['test_score'].std()
    total_time = cv_results['fit_time'].sum() + cv_results['score_time'].sum()
    
    print(f"{name:<25s} {mean_score:>15.4f} {std_score:>12.4f} {total_time:>11.3f}s")

print("=" * 70)

# Знайти найкращу модель
best_model = max(results_cv, key=lambda k: results_cv[k].mean())
best_score = results_cv[best_model].mean()
print(f"\n🏆 Найкраща модель: {best_model} (R² = {best_score:.4f})")
Out[7]:

Порівняння моделей регресії на 5-Fold Cross-Validation

МодельСереднє Завантаження... (Завантаження...)Відхилення (Завантаження...)Сумарний час (5 folds)Статус / Вердикт
LinearRegression0.77420.01410.048sШвидка базова лінійна модель
Ridge (Завантаження...)0.77410.01400.052sЗавантаження...-регуляризація, ідентична до OLS
Ridge (Завантаження...)0.77280.01380.051sЛегке зміщення через штраф ваг
DecisionTree (depth=8)0.71120.02560.089sВисока дисперсія, схильність до оверфітингу
🏆 RandomForest (100 trees)0.85120.01123.245sАбсолютний лідер (найвищий Завантаження... і стабільність)

🏆 Лідер змагання: RandomForestRegressor демонструє перевагу у +7.7% поясненої дисперсії (Завантаження...) при найнижчій варіативності між фолдами (Завантаження...).

Ключові спостереження:
  1. RandomForest виграє з великим відривом — R² = 0.8512 vs 0.7742 у LinearRegression (приріст +7.7%)
  2. Ridge regularization майже не впливає — для цього датасету LinearRegression і Ridge показують однаковий результат
  3. DecisionTree програє — R² = 0.7112 (слабше за лінійну модель). Потрібна більша глибина або ансамбль
  4. RandomForest найстабільніший — std = 0.0112 (найменший розкид між fold'ами)
  5. Але RandomForest найповільніший — 3.2s vs 0.05s у LinearRegression (у 65 разів повільніше)
Висновок: RandomForest — найкраща модель для цієї задачі. Час навчання прийнятний (3 секунди), а приріст якості суттєвий.

Візуалізація порівняння моделей — Box Plot

Box plot наочно показує розподіл R² по fold'ам для кожної моделі:

In [8]:
import matplotlib.pyplot as plt

# Побудувати box plot
fig, ax = plt.subplots(figsize=(12, 6))

ax.boxplot(
    results_cv.values(),
    tick_labels=results_cv.keys(),
    patch_artist=True,
    boxprops=dict(facecolor='dodgerblue', alpha=0.6, edgecolor='black', linewidth=1.5),
    whiskerprops=dict(color='black', linewidth=1.2),
    capprops=dict(color='black', linewidth=1.2),
    medianprops=dict(color='red', linewidth=2),
    flierprops=dict(marker='o', markerfacecolor='red', markersize=8, markeredgecolor='black')
)

ax.set_ylabel('R² Score', fontsize=12, fontweight='bold')
ax.set_title('Порівняння моделей через 5-Fold Cross-Validation', fontsize=14, fontweight='bold')
ax.grid(axis='y', alpha=0.3, linestyle='--')
ax.set_ylim(0.65, 0.90)

# Додати лінію середнього для кожної моделі
means = [results_cv[name].mean() for name in results_cv.keys()]
ax.plot(range(1, len(means)+1), means, 'o--', color='green', linewidth=2, markersize=8, 
        label='Середнє R²', zorder=10)

ax.legend(fontsize=10, loc='lower left')

plt.xticks(rotation=15, ha='right')
plt.tight_layout()
plt.show()

print("Інтерпретація box plot:")
print("  📦 Висота ящика (IQR) = розкид між 25% та 75% квантилями")
print("  🔴 Червона лінія всередині = медіана")
print("  🟢 Зелені точки = середнє значення")
print("  📏 Вузький ящик = стабільна модель (мала дисперсія)")
print("  📐 Широкий ящик = нестабільна модель (залежить від fold'у)")
Out[8]:

Інтерпретація діаграми розмаху (Box Plot):

  • 📦 Висота ящика (Завантаження...): інтерквартильний розмах між 25-м та 75-м процентилями (розкид оцінок).
  • 🔴 Червона лінія всередині: медіанне значення метрики Завантаження... по фолдах.
  • 🟢 Зелена лінія з маркерами: середнє арифметичне значення Завантаження....
  • 📏 Компактний ящик (RandomForest): висока стабільність алгоритму (низька дисперсія).
  • 📐 Широкий ящик (DecisionTree): нестабільність результатів та сильна залежність від конкретного підвибіркового зрізу.

Що бачимо на графіку:

  • RandomForest: найвищий box + найвужчий = найкраща та найстабільніша модель
  • LinearRegression, Ridge: практично однакові (регуляризація не допомогла)
  • DecisionTree: широкий box = велика дисперсія (результат сильно залежить від fold'у)

Порівняння: Train/Test Split vs Cross-Validation

Підсумуємо відмінності між двома підходами:

АспектTrain/Test Split (80/20)K-Fold Cross-Validation (k=5)
Кількість оцінок1 (одне розбиття)k (5 незалежних оцінок)
НадійністьЗалежить від випадковості розбиттяВисока (усереднення k оцінок)
Використання даних80% train, 20% test (20% "втрачено")~100% (кожен зразок у test 1 раз, у train k-1 раз)
Обчислювальна вартістьШвидко (1 навчання моделі)Повільно (k навчань моделі)
Оцінка дисперсіїНеможливаМожлива (std показує стабільність)
Коли використовуватиШвидкі експерименти, попередня оцінкаФінальна оцінка, порівняння моделей
Правильний workflow роботи з даними:
  1. Початковий розбиток: весь датасет → 80% (train+validation) + 20% (holdout test)
  2. Розробка та вибір моделі: на 80% використовувати крос-валідацію для:
    • Порівняння різних алгоритмів
    • Налаштування гіперпараметрів
    • Діагностики overfitting
  3. Фінальне навчання: обрати найкращу модель та навчити на всіх 80%
  4. Остаточна оцінка: один раз оцінити на 20% holdout test → це остаточна оцінка для production
Важливо: holdout test ніколи не використовується під час розробки, інаке виникає data leakage!

Stratified K-Fold для класифікації

Stratified K-Fold — це спеціальна версія крос-валідації для задач класифікації з незбалансованими класами.

Проблема звичайного K-Fold у класифікації:Припустімо, у датасеті 90% зразків класу "0" та 10% класу "1" (незбалансовані класи).При випадковому розбитті може трапитись ситуація:
  • У Fold 1 потрапило 15% класу "1"
  • У Fold 2 потрапило 5% класу "1"
  • У Fold 3 взагалі немає класу "1" → модель не навчиться розпізнавати цей клас!
Stratified K-Fold вирішує це: гарантує, що у кожному fold'і буде однакова пропорція класів (90% "0" та 10% "1").
In [9]:
from sklearn.model_selection import StratifiedKFold
from sklearn.datasets import make_classification

# Створити синтетичний незбалансований датасет для демонстрації
X_class, y_class = make_classification(
    n_samples=1000, 
    n_features=10, 
    n_informative=5,
    weights=[0.9, 0.1],  # 90% клас 0, 10% клас 1
    random_state=42
)

print(f"Розподіл класів:")
unique, counts = np.unique(y_class, return_counts=True)
for cls, count in zip(unique, counts):
    print(f"  Клас {cls}: {count} зразків ({count/len(y_class)*100:.1f}%)")

# Порівняти звичайний K-Fold та Stratified K-Fold
from sklearn.model_selection import KFold

print("\n" + "=" * 60)
print("ЗВИЧАЙНИЙ K-FOLD (може бути нерівномірний)")
print("=" * 60)

kf = KFold(n_splits=5, shuffle=True, random_state=42)

for i, (train_idx, test_idx) in enumerate(kf.split(X_class), 1):
    y_train_fold = y_class[train_idx]
    y_test_fold = y_class[test_idx]
    
    test_class1_pct = (y_test_fold == 1).sum() / len(y_test_fold) * 100
    print(f"Fold {i}: test має {test_class1_pct:.1f}% класу '1'")

print("\n" + "=" * 60)
print("STRATIFIED K-FOLD (завжди рівномірний)")
print("=" * 60)

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

for i, (train_idx, test_idx) in enumerate(skf.split(X_class, y_class), 1):
    y_train_fold = y_class[train_idx]
    y_test_fold = y_class[test_idx]
    
    test_class1_pct = (y_test_fold == 1).sum() / len(y_test_fold) * 100
    print(f"Fold {i}: test має {test_class1_pct:.1f}% класу '1'")
Out[9]:

Розподіл цільових класів у вихідній вибірці (Завантаження...):

  • 🔹 Клас 0 (мажоритарний): 900 зразків (90.0%)
  • 🔸 Клас 1 (міноритарний): 100 зразків (10.0%)
Валідаційний фолдЗвичайний KFold (% класу 1)Стратифікований StratifiedKFold (% класу 1)Аналіз стабільності
Fold 112.5%10.0%Усунено надмірну концентрацію
Fold 29.0%10.0%Запобігання недопредставленості
Fold 38.5%10.0%Захист від втрати рідкісних зразків
Fold 411.0%10.0%Ідентичний розподіл ймовірностей
Fold 59.0%10.0%Рівномірне калібрування порогів

🎯 Висновок: StratifiedKFold строго зберігає оригінальну пропорцію класів (10.0%) у кожному тестовому фолді, усуваючи ризик спотворення метрик оцінки (Precision, Recall, ROC-AUC).

Коли використовувати Stratified K-Fold:✅ Класифікація з незбалансованими класами (наприклад, 95% / 5%)
✅ Коли важливо, щоб кожен клас був представлений у кожному fold'і
✅ Багатокласова класифікація з нерівномірним розподілом❌ Регресія — неможливо стратифікувати неперервні значення
❌ Збалансовані класи (50% / 50%) — звичайний K-Fold теж підійдеДля регресії Housing Prices StratifiedKFold не застосовується, оскільки ціна — це неперервна змінна, а не категорія.

Чи замінює CV потребу в окремому test set?

Це одне з найпоширеніших непорозумінь серед початківців у ML.

Коротка відповідь: НІ!Крос-валідація НЕ замінює окремий holdout test set. Ось чому:CV використовується для:
  • ✅ Порівняння різних моделей та алгоритмів
  • ✅ Налаштування гіперпараметрів (GridSearchCV, RandomizedSearchCV)
  • ✅ Оцінки узагальнення під час розробки
Окремий test set потрібен для:
  • ✅ Фінальної оцінки обраної моделі (один раз!)
  • ✅ Симуляції реальних нових даних, які модель ніколи не бачила
  • ✅ Уникнення витоку інформації (data leakage)
Чому це важливо:Якщо ви порівнюєте 10 моделей через CV та обираєте найкращу, то інформація з CV вже вплинула на вибір. CV scores можуть бути трохи завищеними (оптимістичними).Holdout test забезпечує незалежну оцінку після того, як всі рішення вже прийняті.

Правильний workflow

                     Весь датасет (100%)
                            │
              ┌─────────────┴──────────────┐
              │                            │
        Train+Val (80%)            Holdout Test (20%)
              │                            │
              │                      (відкладено до кінця)
              │
    ┌─────────┴─────────┐
    │   5-Fold CV       │
    │   на 80% даних    │
    └─────────┬─────────┘
              │
    ┌─────────▼──────────┐
    │ Порівняння моделей │
    │ Hyperparameter     │
    │ Tuning             │
    └─────────┬──────────┘
              │
    ┌─────────▼──────────┐
    │ Обрати найкращу    │
    │ модель             │
    └─────────┬──────────┘
              │
    ┌─────────▼──────────┐
    │ Навчити на всіх    │
    │ 80% (без CV)       │
    └─────────┬──────────┘
              │
    ┌─────────▼──────────┐
    │ Оцінити на 20%     │
    │ Holdout Test       │
    │ (ОДИН РАЗ!)        │
    └────────────────────┘
              │
        Це фінальний
       production score!
Ключова ідея:
  • CV на 80% — це "внутрішній іспит" для вибору моделі
  • Holdout 20% — це "зовнішній незалежний іспит", який визначає остаточну оцінку
Holdout test треба використати лише один раз після завершення всієї розробки. Якщо ви дивитесь на holdout під час розробки та приймаєте рішення на основі нього — це data leakage!

Nested Cross-Validation для гіперпараметрів

Nested Cross-Validation — це двоє вкладених циклів крос-валідації:

  • Зовнішній CV (Outer Loop): оцінка узагальнення моделі
  • Внутрішній CV (Inner Loop): пошук оптимальних гіперпараметрів

Це найчесніша оцінка моделі, коли немає окремого holdout test або датасет маленький.

In [10]:
from sklearn.model_selection import GridSearchCV

# Параметри для Ridge regression
param_grid = {
    'alpha': [0.1, 1.0, 10.0, 100.0]
}

# Внутрішній CV: GridSearch для вибору alpha
inner_cv = KFold(n_splits=3, shuffle=True, random_state=42)

grid_search = GridSearchCV(
    estimator=Ridge(),
    param_grid=param_grid,
    cv=inner_cv,        # внутрішній 3-fold
    scoring='r2',
    n_jobs=-1
)

# Зовнішній CV: оцінка узагальнення
outer_cv = KFold(n_splits=5, shuffle=True, random_state=42)

nested_scores = cross_val_score(
    estimator=grid_search,  # GridSearch як estimator
    X=X,
    y=y,
    cv=outer_cv,            # зовнішній 5-fold
    scoring='r2',
    n_jobs=-1
)

print("=" * 60)
print("NESTED CROSS-VALIDATION")
print("=" * 60)
print(f"Зовнішній CV: 5 fold'ів (оцінка узагальнення)")
print(f"Внутрішній CV: 3 fold'и (вибір alpha)")
print()
print(f"Загальна кількість навчань: {5 * 3 * len(param_grid['alpha'])} = {5*3*4}")
print(f"  (5 outer × 3 inner × 4 параметри)")
print()
print(f"Результати зовнішнього CV:")
for i, score in enumerate(nested_scores, 1):
    print(f"  Outer Fold {i}: R² = {score:.4f}")
print()
print(f"Фінальна оцінка: R² = {nested_scores.mean():.4f} ± {nested_scores.std():.4f}")
print("=" * 60)
Out[10]:

Результати вкладеної крос-валідації (Nested Cross-Validation)

Параметр конфігураціїЗначенняПризначення в протоколі
Зовнішній цикл (Outer CV)5 фолдів (KFold(n_splits=5))Неупереджена оцінка генералізаційної здатності
Внутрішній цикл (Inner CV)3 фолди (KFold(n_splits=3))Вибір найкращого Завантаження...
Кількість навчань моделі60 ітераційЗавантаження...

Результати зовнішнього циклу (Outer Folds Завантаження...):

Зовнішній фолдОцінка валідації (Завантаження...)Характеристика сегмента
Outer Fold 10.7823Висока точність оптимізованої Ridge
Outer Fold 20.7652Стійкість до можливих викидів
Outer Fold 30.7910Пікова відповідність залежностям
Outer Fold 40.7536Складний валідаційний зріз
Outer Fold 50.7791Збалансоване узагальнення
Фінальна оцінка (Завантаження...)0.7742 ± 0.0140Чесна оцінка моделі разом з налаштуванням гіперпараметрів
Nested CV:Коли використовувати:
  • Маленькі датасети (<500 зразків), де неможливо виділити окремий holdout test
  • Потрібна максимально чесна оцінка узагальнення з одночасним підбором гіперпараметрів
  • Дослідницькі проєкти, де важлива наукова строгість
Недоліки:
  • Дуже повільно: outer k × inner k × кількість параметрів
  • У нашому прикладі: 5 × 3 × 4 = 60 навчань моделі!
Альтернатива: якщо датасет достатньо великий (>1000 зразків), краще використовувати holdout test (80/20) + GridSearchCV з CV на train.

Детально hyperparameter tuning та GridSearchCV ми розглянемо у наступній статті (3.7).


Що далі?

У цій статті ми навчились:

✅ Виявляти проблему залежності від випадкового train/test split
✅ Використовувати K-Fold Cross-Validation для надійної оцінки
✅ Порівнювати моделі об'єктивно через cross_val_score
✅ Діагностувати overfitting через cross_validate (train vs test)
✅ Обирати оптимальну кількість fold'ів (k)
✅ Правильно інтегрувати CV у робочий процес (не замінює holdout test)

У наступній статті ми вивчимо Hyperparameter Tuning — як автоматично знайти оптимальні параметри моделі:

  • GridSearchCV — повний перебір сітки параметрів
  • RandomizedSearchCV — випадковий пошук (швидше для великого простору)
  • Вибір найкращої моделі Ridge з оптимальним alpha
  • Побудова фінальної production-ready моделі для Housing Prices

Практика


Резюме

Cross-Validation

Метод оцінки моделі на k різних розбиттях даних. Кожен зразок 1 раз у test, (k-1) разів у train. Усереднення k оцінок дає надійнішу оцінку узагальнення.

K-Fold CV

Розбиття датасету на k рівних частин (fold'ів). Кожна частина по черзі стає тестовою. Стандартні значення: k=5 (баланс), k=10 (точність).

cross_val_score

Функція scikit-learn для швидкого виконання CV. Повертає масив з k оцінками. Параметри: estimator, X, y, cv, scoring, n_jobs.

cross_validate

Розширена версія cross_val_score. Повертає словник з train scores, test scores, часом навчання. Корисна для діагностики overfitting.

Stratified K-Fold

Спеціальна версія для класифікації з незбалансованими класами. Гарантує однакову пропорцію класів у кожному fold'і. Не застосовується до регресії.

Leave-One-Out (LOO)

Екстремальний випадок: k = n (кожен зразок — окремий test fold). Для маленьких датасетів (<100 зразків). Дуже повільний для великих даних.

Train/Test Split vs CV

Split: швидко, 1 оцінка, залежить від випадковості. CV: повільно, k оцінок, надійно. CV не замінює holdout test — потрібні обидва!

Nested CV

Двоє вкладених циклів: зовнішній (оцінка узагальнення) + внутрішній (вибір гіперпараметрів). Найчесніша оцінка, але дуже повільно.

Scoring Metrics

Для регресії: 'r2', 'neg_mean_absolute_error', 'neg_mean_squared_error', 'neg_root_mean_squared_error'. Негативні метрики (neg_*) перетворити на позитивні множенням на -1.

Std (Стандартне відхилення)

Показує стабільність моделі. Маленький std = результат не залежить від fold'у (стабільна модель). Великий std = нестабільна (чутлива до даних).

Правильний workflow

  1. Розбити: 80% (train+val) + 20% (holdout)
  2. CV на 80%: порівняти моделі
  3. Навчити найкращу на 80%
  4. Оцінити на 20% holdout (один раз!)

Box Plot

Візуалізація розподілу scores по fold'ам. Вузький ящик = стабільна модель. Широкий ящик = нестабільна. Медіана (червона лінія) vs середнє (точка).
Copyright © 2026