Практика 14 · Крос-валідація¶

📖 Теорія: відкрий lecture.html у цій же теці. 📝 Домашнє завдання: homework.md. 🧠 Тест: quiz.html.

Головна думка лекції: кожна оцінка якості — випадкова величина. Одне число без уявлення про його розкид не варте нічого. Тут ми поміряємо цей розкид числом і подивимось, скільки саме шуму знімає усереднення по фолдах.

Що зробимо:

  1. Реалізуємо K-Fold вручну, без sklearn, і звіримо розбиття з KFold
  2. Порахуємо CV-оцінку своїм циклом і звіримо з cross_val_score
  3. Поміряємо, у скільки разів усереднення звужує розкид проти одного розбиття
  4. Подивимось, що робить дисбаланс класів і як його лікує стратифікація
  5. Спіймаємо витік, коли відбір ознак стоїть поза пайплайном
  6. Побачимо, що робить залежність рядків, і полікуємо її GroupKFold
  7. Підберемо гіперпараметр GridSearchCV і перевіримо його число власноруч

0. Дані та модель¶

Синтетична задача бінарної класифікації: 200 обʼєктів, шість ознак, з яких насправді корисні лише дві. Класи розділені не ідеально — саме так і має бути, інакше міряти буде нічого.

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

In [1]:
import numpy as np
import matplotlib.pyplot as plt

from sklearn.datasets import make_classification
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.base import clone
from sklearn.metrics import accuracy_score

features, labels = make_classification(
    n_samples=200, n_features=6, n_informative=2, n_redundant=1,
    class_sep=0.9, random_state=42)

model = Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression(max_iter=1000)),
])

print(f"обʼєктів: {len(labels)}, ознак: {features.shape[1]}")
print(f"класи: {np.bincount(labels)} — приблизно порівну")
обʼєктів: 200, ознак: 6
класи: [101  99] — приблизно порівну

1. K-Fold вручну¶

Алгоритм із розділу 02 лекції, крок за кроком:

  1. Ділимо індекси на $K$ приблизно рівних частин. Якщо $n$ не ділиться на $K$ націло, перші кілька фолдів беруть на одну точку більше — саме так робить і scikit-learn.
  2. По черзі беремо кожен фолд як валідаційний, решту склеюємо в навчальну частину.

Візьмемо $K = 7$ навмисно: 200 на 7 не ділиться, і буде видно, як розподіляється залишок.

In [2]:
def make_folds(n_objects, n_folds):
    """Ділить індекси 0…n-1 на n_folds частин. Повертає список пар (навчання, валідація)."""
    # базовий розмір фолда, а залишок роздаємо першим фолдам по одному обʼєкту
    fold_sizes = np.full(n_folds, n_objects // n_folds)
    fold_sizes[:n_objects % n_folds] += 1

    all_indices = np.arange(n_objects)
    splits = []
    start = 0
    for size in fold_sizes:
        validation = all_indices[start:start + size]
        # навчальна частина — усе, що ліворуч, плюс усе, що праворуч від фолда
        training = np.concatenate([all_indices[:start], all_indices[start + size:]])
        splits.append((training, validation))
        start += size
    return splits


our_splits = make_folds(len(labels), 7)

print(f"{'фолд':>5} {'розмір валідації':>18} {'розмір навчання':>17}")
for i, (training, validation) in enumerate(our_splits):
    print(f"{i + 1:>5} {len(validation):>18} {len(training):>17}")
print(f"\n200 = 7 × 28 + 4, тому перші чотири фолди мають по 29 обʼєктів")
 фолд   розмір валідації   розмір навчання
    1                 29               171
    2                 29               171
    3                 29               171
    4                 29               171
    5                 28               172
    6                 28               172
    7                 28               172

200 = 7 × 28 + 4, тому перші чотири фолди мають по 29 обʼєктів

Звірка розбиття з KFold¶

Порівнюємо не метрику, а самі індекси: до останнього номера.

In [3]:
from sklearn.model_selection import KFold

library_splits = list(KFold(n_splits=7).split(features))

for i in range(7):
    our_training, our_validation = our_splits[i]
    library_training, library_validation = library_splits[i]
    assert np.array_equal(our_validation, library_validation), f"фолд {i + 1} розійшовся!"
    assert np.array_equal(our_training, library_training), f"навчальна частина {i + 1} розійшлася!"

print("перевірено 7 фолдів, індекс за індексом")
print("\n✅ наше розбиття збігається з KFold повністю")
перевірено 7 фолдів, індекс за індексом

✅ наше розбиття збігається з KFold повністю

Тепер сама оцінка¶

Крок 2 і 3 алгоритму: у кожному фолді навчаємо модель з нуля (не донавчаємо, не переносимо ваги) і міряємо метрику на відкладеній частині. Потім усереднюємо.

Разом із середнім завжди рахуємо стандартне відхилення — воно каже, наскільки метрика залежить від того, які саме обʼєкти потрапили у валідацію.

In [4]:
def cross_validate_by_hand(estimator, X, y, splits):
    """Проганяє модель по всіх фолдах. Повертає масив оцінок — по одній на фолд."""
    scores = []
    for training, validation in splits:
        # clone дає свіжу необучену копію: жодних слідів попереднього фолда
        fitted = clone(estimator).fit(X[training], y[training])
        scores.append(accuracy_score(y[validation], fitted.predict(X[validation])))
    return np.array(scores)


five_folds = make_folds(len(labels), 5)
our_scores = cross_validate_by_hand(model, features, labels, five_folds)

print(f"оцінки по фолдах: {np.round(our_scores, 4)}")
print(f"середнє: {our_scores.mean():.4f}   розкид: ±{our_scores.std():.4f}")
print(f"\nнайгірший фолд {our_scores.min():.2f}, найкращий {our_scores.max():.2f} — "
      f"різниця {our_scores.max() - our_scores.min():.2f}")
print("Одне число з такої вибірки вибирати немає сенсу: це просто лотерея.")
оцінки по фолдах: [0.85 0.9  0.9  0.75 0.75]
середнє: 0.8300   розкид: ±0.0678

найгірший фолд 0.75, найкращий 0.90 — різниця 0.15
Одне число з такої вибірки вибирати немає сенсу: це просто лотерея.
In [5]:
from sklearn.model_selection import cross_val_score

library_scores = cross_val_score(model, features, labels, cv=KFold(n_splits=5))

print(f"наші    : {np.round(our_scores, 6)}")
print(f"sklearn : {np.round(library_scores, 6)}")

assert np.allclose(our_scores, library_scores), "оцінки розійшлися!"
print("\n✅ збігається — cross_val_score робить рівно те, що ми щойно написали руками")
наші    : [0.85 0.9  0.9  0.75 0.75]
sklearn : [0.85 0.9  0.9  0.75 0.75]

✅ збігається — cross_val_score робить рівно те, що ми щойно написали руками

2. Скільки шуму знімає усереднення¶

Це найважливіше число теми, і його варто побачити, а не прийняти на віру.

Експеримент чесний: багато разів перемішуємо ту саму вибірку по-новому. Для кожного перемішування рахуємо дві оцінки:

  • hold-out — беремо лише перший фолд як валідаційний і зупиняємось. Це рівно те, що робить train_test_split з одним фіксованим random_state;
  • повний K-Fold — проганяємо всі $K$ фолдів того самого розбиття й усереднюємо.

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

In [6]:
N_SHUFFLES = 120
K = 5

shuffle_rng = np.random.default_rng(0)
holdout_estimates = np.zeros(N_SHUFFLES)
kfold_estimates = np.zeros(N_SHUFFLES)

for i in range(N_SHUFFLES):
    order = shuffle_rng.permutation(len(labels))            # нове перемішування
    shuffled_features, shuffled_labels = features[order], labels[order]
    scores = cross_validate_by_hand(model, shuffled_features, shuffled_labels,
                                    make_folds(len(labels), K))
    holdout_estimates[i] = scores[0]      # зупинились на першому фолді
    kfold_estimates[i] = scores.mean()    # прогнали всі й усереднили

print(f"перемішувань: {N_SHUFFLES}, K = {K}\n")
print(f"hold-out : середнє {holdout_estimates.mean():.4f}, σ = {holdout_estimates.std():.4f}")
print(f"K-Fold   : середнє {kfold_estimates.mean():.4f}, σ = {kfold_estimates.std():.4f}")
print(f"\nшум знижено в {holdout_estimates.std() / kfold_estimates.std():.2f} раза "
      f"(для довідки: √K = {np.sqrt(K):.2f})")
перемішувань: 120, K = 5

hold-out : середнє 0.8169, σ = 0.0513
K-Fold   : середнє 0.8169, σ = 0.0116

шум знижено в 4.40 раза (для довідки: √K = 2.24)

Чому виграш вийшов більший за $\sqrt{K}$, хоча незалежні оцінки дали б рівно $\sqrt{K}$? Тут діють два ефекти в різні боки. Фолди перекриваються навчальними частинами, сусідні моделі бачать майже ті самі точки — це виграш зменшує. Зате K-Fold перевіряє модель на всіх 200 обʼєктах, а одне hold-out розбиття — лише на 40 з них. Другий ефект тут сильніший.

In [7]:
fig, ax = plt.subplots(figsize=(9, 4.4))

bins = np.linspace(min(holdout_estimates.min(), kfold_estimates.min()) - 0.01,
                   max(holdout_estimates.max(), kfold_estimates.max()) + 0.01, 30)
ax.hist(holdout_estimates, bins=bins, color="crimson", alpha=.55,
        label=f"одне розбиття (σ = {holdout_estimates.std():.3f})")
ax.hist(kfold_estimates, bins=bins, color="teal", alpha=.65,
        label=f"{K}-Fold (σ = {kfold_estimates.std():.3f})")
ax.axvline(kfold_estimates.mean(), color="black", ls="--", lw=1.5, label="середнє")

ax.set_xlabel("оцінка accuracy"); ax.set_ylabel("скільки перемішувань")
ax.set_title("Та сама модель, ті самі дані — різні способи поміряти")
ax.legend(); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()

print("Практичний висновок: різниця між двома моделями має сенс лише тоді,")
print(f"коли вона більша за розкид оцінки. Для K-Fold це ±{kfold_estimates.std():.3f},")
print(f"а якщо довіритись одному розбиттю — ±{holdout_estimates.std():.3f}, тобто "
      f"у {holdout_estimates.std() / kfold_estimates.std():.1f} раза більше.")
No description has been provided for this image
Практичний висновок: різниця між двома моделями має сенс лише тоді,
коли вона більша за розкид оцінки. Для K-Fold це ±0.012,
а якщо довіритись одному розбиттю — ±0.051, тобто у 4.4 раза більше.

3. Дисбаланс і стратифікація¶

Тепер задача, де позитивний клас рідкісний — приблизно 5%. Випадкове перемішування тут перестає бути безпечним: з відчутною ймовірністю в якийсь фолд не потрапить жодного позитивного обʼєкта. І тоді recall на цьому фолді просто не визначений — ділити нема на що.

In [8]:
rare_features, rare_labels = make_classification(
    n_samples=200, n_features=6, n_informative=2, n_redundant=1,
    weights=[0.96, 0.04], class_sep=1.2, random_state=7)

print(f"позитивів: {rare_labels.sum()} з {len(rare_labels)} "
      f"({rare_labels.mean() * 100:.1f}%)")
print(f"у середньому на фолд при K=5: {rare_labels.sum() / 5:.1f} обʼєкта")
позитивів: 11 з 200 (5.5%)
у середньому на фолд при K=5: 2.2 обʼєкта
In [9]:
from sklearn.model_selection import StratifiedKFold

plain_splitter = KFold(n_splits=5, shuffle=True, random_state=8)
stratified_splitter = StratifiedKFold(n_splits=5, shuffle=True, random_state=8)

plain_counts = [int(rare_labels[validation].sum())
                for _, validation in plain_splitter.split(rare_features, rare_labels)]
stratified_counts = [int(rare_labels[validation].sum())
                     for _, validation in stratified_splitter.split(rare_features, rare_labels)]

print(f"позитивів у кожному валідаційному фолді:")
print(f"  звичайний KFold  : {plain_counts}")
print(f"  StratifiedKFold  : {stratified_counts}")
print(f"\nУ звичайного розбиття є фолд, де позитивів {min(plain_counts)}.")
print("Recall на такому фолді не визначений: у знаменнику TP + FN = 0.")
позитивів у кожному валідаційному фолді:
  звичайний KFold  : [3, 1, 4, 0, 3]
  StratifiedKFold  : [2, 2, 2, 2, 3]

У звичайного розбиття є фолд, де позитивів 0.
Recall на такому фолді не визначений: у знаменнику TP + FN = 0.

Один невдалий приклад — ще не доказ. Проженемо триста різних перемішувань і порахуємо, як часто розбиття виявляється зіпсованим.

In [10]:
seed_rng = np.random.default_rng(0)
n_experiments = 300
broken_plain = 0
broken_stratified = 0

for _ in range(n_experiments):
    seed = int(seed_rng.integers(1_000_000))

    plain = KFold(n_splits=5, shuffle=True, random_state=seed)
    counts = [rare_labels[validation].sum() for _, validation in plain.split(rare_features, rare_labels)]
    if min(counts) == 0:
        broken_plain += 1

    stratified = StratifiedKFold(n_splits=5, shuffle=True, random_state=seed)
    counts = [rare_labels[validation].sum()
              for _, validation in stratified.split(rare_features, rare_labels)]
    if min(counts) == 0:
        broken_stratified += 1

print(f"з {n_experiments} випадкових розбиттів мають фолд без жодного позитива:")
print(f"  звичайний KFold : {broken_plain:>3}  ({broken_plain / n_experiments * 100:.0f}%)")
print(f"  StratifiedKFold : {broken_stratified:>3}  ({broken_stratified / n_experiments * 100:.0f}%)")
print("\nКожне друге-третє розбиття дає сміття в середньому — і мовчки.")
з 300 випадкових розбиттів мають фолд без жодного позитива:
  звичайний KFold : 119  (40%)
  StratifiedKFold :   0  (0%)

Кожне друге-третє розбиття дає сміття в середньому — і мовчки.

Стратифікація вручну¶

Ідея тривіальна: різати не всю вибірку разом, а кожен клас окремо, і потім склеювати шматки. Функція make_folds у нас уже є — застосуємо її до індексів кожного класу.

In [11]:
def make_stratified_folds(y, n_folds):
    """Ріже кожен клас окремо й склеює шматки — так частки класів зберігаються."""
    validation_parts = [[] for _ in range(n_folds)]

    for label_value in np.unique(y):
        indices_of_class = np.where(y == label_value)[0]
        # ділимо індекси саме цього класу тим самим правилом, що й раніше
        for fold_number, (_, part) in enumerate(make_folds(len(indices_of_class), n_folds)):
            validation_parts[fold_number].append(indices_of_class[part])

    splits = []
    for parts in validation_parts:
        validation = np.sort(np.concatenate(parts))
        training = np.setdiff1d(np.arange(len(y)), validation)
        splits.append((training, validation))
    return splits


our_stratified = make_stratified_folds(rare_labels, 5)
our_counts = [int(rare_labels[validation].sum()) for _, validation in our_stratified]

library_stratified = list(StratifiedKFold(n_splits=5).split(rare_features, rare_labels))
library_counts = [int(rare_labels[validation].sum()) for _, validation in library_stratified]

print(f"позитивів у фолді, наша стратифікація : {our_counts}")
print(f"позитивів у фолді, StratifiedKFold    : {library_counts}")

# частка рідкісного класу в кожному фолді має збігатися із загальною з точністю до обʼєкта
overall_share = rare_labels.mean()
for _, validation in our_stratified:
    share_in_fold = rare_labels[validation].mean()
    assert abs(share_in_fold - overall_share) < 1 / len(validation), "фолд перекошений!"

print(f"\nзагальна частка позитивів: {overall_share:.3f}")
print("✅ у кожному нашому фолді частка збігається із загальною з точністю до одного обʼєкта")
print("   (номери обʼєктів у sklearn інші — залишок він роздає з іншого кінця, —")
print("    але склад фолдів по класах той самий)")
позитивів у фолді, наша стратифікація : [3, 2, 2, 2, 2]
позитивів у фолді, StratifiedKFold    : [2, 2, 2, 2, 3]

загальна частка позитивів: 0.055
✅ у кожному нашому фолді частка збігається із загальною з точністю до одного обʼєкта
   (номери обʼєктів у sklearn інші — залишок він роздає з іншого кінця, —
    але склад фолдів по класах той самий)

4. Витік: коли підготовка даних стоїть поза фолдом¶

Найдорожча помилка крос-валідації робиться в один рядок і виглядає невинно: підготувати дані до CV, а не всередині фолда.

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

In [12]:
from sklearn.feature_selection import SelectKBest, f_classif

noise_rng = np.random.default_rng(1)
noise_features = noise_rng.normal(size=(60, 2000))     # чистий шум
coin_labels = noise_rng.integers(0, 2, 60)             # мітки кинуті монеткою

# ❌ ТАК РОБИТИ НЕ МОЖНА: відбір бачить усі мітки, включно з майбутньою валідацією
selector = SelectKBest(f_classif, k=5).fit(noise_features, coin_labels)
leaked_score = cross_val_score(model, selector.transform(noise_features), coin_labels, cv=5)

# ✅ правильно: відбір — крок пайплайну, тобто робиться всередині кожного фолда
honest_pipeline = Pipeline([
    ("selector", SelectKBest(f_classif, k=5)),
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression(max_iter=1000)),
])
honest_score = cross_val_score(honest_pipeline, noise_features, coin_labels, cv=5)

print(f"відбір ознак ДО крос-валідації : {leaked_score.mean():.3f}")
print(f"відбір усередині пайплайна     : {honest_score.mean():.3f}")
print(f"\nПравильна відповідь тут — 0.5, бо закономірності в даних немає взагалі.")
print(f"Перше число — не якість моделі, а виміряний рівень самообману.")
відбір ознак ДО крос-валідації : 0.817
відбір усередині пайплайна     : 0.500

Правильна відповідь тут — 0.5, бо закономірності в даних немає взагалі.
Перше число — не якість моделі, а виміряний рівень самообману.

Механізм простий: відбір ознак подивився на всі мітки й знайшов ті 5 стовпців із 2000, які випадково найкраще корелюють із ціллю. Валідаційний фолд уже брав участь у цьому виборі — отже, він більше не «нові дані». Те саме стосується масштабування, балансування (SMOTE) і будь-якого кроку, що дивиться на дані.

Правило: усе, що навчається на даних, має жити всередині Pipeline.

5. Групи: коли рядки не незалежні¶

Уся математика крос-валідації тримається на одному припущенні: рядки вибірки незалежні. На практиці воно ламається постійно — і ламається тихо.

Класичний випадок: кілька записів на одного пацієнта. Зробимо саме такі дані: 40 пацієнтів, у кожного свій «профіль» і по 5 майже однакових записів. Діагноз залежить від профілю, тобто від пацієнта, а не від конкретного запису.

І поставимо питання чесно: що модель побачить уперше в реальному використанні? Нового пацієнта. Отже, і міряти треба на нових пацієнтах.

In [13]:
from sklearn.ensemble import RandomForestClassifier


def make_patient_records(rng, n_patients, records_per_patient=5):
    """Дані з групами: у кожного пацієнта свій профіль і кілька схожих записів."""
    profile = rng.normal(size=(n_patients, 4))          # що робить пацієнта пацієнтом
    influence = np.array([1.2, -0.9, 0.6, 0.3])
    diagnosis = (profile @ influence + rng.normal(0, 0.9, n_patients) > 0).astype(int)

    # кожен запис — профіль пацієнта плюс маленький шум вимірювання
    records = np.repeat(profile, records_per_patient, axis=0)
    records = records + rng.normal(0, 0.12, records.shape)
    record_labels = np.repeat(diagnosis, records_per_patient)
    patient_id = np.repeat(np.arange(n_patients), records_per_patient)
    return records, record_labels, patient_id


patient_rng = np.random.default_rng(3)
records, record_labels, patient_id = make_patient_records(patient_rng, n_patients=40)
new_records, new_labels, _ = make_patient_records(patient_rng, n_patients=200)

print(f"записів: {len(record_labels)} від {len(np.unique(patient_id))} пацієнтів")
print(f"перші десять записів належать пацієнтам: {patient_id[:10]}")
print(f"незалежна перевірка: {len(new_labels)} записів від 200 інших пацієнтів")
записів: 200 від 40 пацієнтів
перші десять записів належать пацієнтам: [0 0 0 0 0 1 1 1 1 1]
незалежна перевірка: 1000 записів від 200 інших пацієнтів
In [14]:
from sklearn.model_selection import GroupKFold

forest = Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", RandomForestClassifier(n_estimators=100, random_state=0)),
])

# ❌ звичайний KFold: записи одного пацієнта розлітаються по різних фолдах
plain_estimate = cross_val_score(
    forest, records, record_labels, cv=KFold(5, shuffle=True, random_state=0)).mean()

# ✅ GroupKFold: усі записи пацієнта цілком ідуть або в навчання, або у валідацію
group_estimate = cross_val_score(
    forest, records, record_labels, cv=GroupKFold(5), groups=patient_id).mean()

# арбітр: навчаємось на всіх наявних пацієнтах і міряємо на 200 нових
trained = clone(forest).fit(records, record_labels)
honest_estimate = accuracy_score(new_labels, trained.predict(new_records))

print(f"звичайний KFold      : {plain_estimate:.3f}")
print(f"GroupKFold           : {group_estimate:.3f}")
print(f"справді нові пацієнти: {honest_estimate:.3f}   ← єдине чесне число")
print(f"\nЗвичайна CV завищила якість на {plain_estimate - honest_estimate:.2f} —")
print("це не помилка моделі, це помилка вимірювального приладу.")
звичайний KFold      : 0.970
GroupKFold           : 0.740
справді нові пацієнти: 0.793   ← єдине чесне число

Звичайна CV завищила якість на 0.18 —
це не помилка моделі, це помилка вимірювального приладу.

Механізм той самий, що й у витоку з попереднього розділу, тільки джерело інше. Модель побачила пацієнта в навчанні й упізнала його у валідації — записи ж майже ідентичні. Метрика відповіла на питання «наскільки добре модель упізнає вже бачених людей», тоді як реальна задача — «як вона працює на нових».

Симптом завжди однаковий: чудові числа на крос-валідації, провал у продакшені.

Питання, яке треба ставити щоразу: що саме модель побачить уперше в момент реального використання? Нового користувача — розбивай за користувачами. Новий день — за датами. Нову лікарню — за лікарнями. Для часових рядів у цієї ж ідеї є своя назва — TimeSeriesSplit: навчальна частина завжди строго передує валідаційній, інакше модель тренується, знаючи майбутнє.

6. Підбір гіперпараметра: GridSearchCV¶

Найчастіше крос-валідацію застосовують не для звіту, а для вибору. Переберемо силу регуляризації C у логістичній регресії.

Нагадування з теми 17: у scikit-learn задають не $\lambda$, а обернену величину $C = 1/\lambda$. Маленьке C означає сильну регуляризацію.

In [15]:
from sklearn.model_selection import GridSearchCV

parameter_grid = {"classifier__C": np.logspace(-3, 3, 7)}
splitter = KFold(n_splits=5, shuffle=True, random_state=0)

search = GridSearchCV(model, parameter_grid, cv=splitter, scoring="accuracy")
search.fit(features, labels)

print(f"{'C':>10} {'CV-оцінка':>12} {'розкид':>10}")
for value, mean_score, spread in zip(parameter_grid["classifier__C"],
                                     search.cv_results_["mean_test_score"],
                                     search.cv_results_["std_test_score"]):
    mark = "  ← переможець" if mean_score == search.best_score_ else ""
    print(f"{value:>10g} {mean_score:>12.4f} {spread:>9.4f}{mark}")

print(f"\nнайкраще: C = {search.best_params_['classifier__C']:g}, "
      f"оцінка {search.best_score_:.4f}")
         C    CV-оцінка     розкид
     0.001       0.7150    0.1375
      0.01       0.7850    0.0339
       0.1       0.8300    0.0245  ← переможець
         1       0.8200    0.0332
        10       0.8250    0.0316
       100       0.8250    0.0316
      1000       0.8250    0.0316

найкраще: C = 0.1, оцінка 0.8300

Перевіримо, що best_score_ — не магія: це просто середнє по фолдах для переможця. Порахуємо його своїм циклом.

In [16]:
best_model = clone(model).set_params(**search.best_params_)
manual_scores = cross_val_score(best_model, features, labels, cv=splitter)

print(f"наш перерахунок : {manual_scores.mean():.10f}")
print(f"search.best_score_: {search.best_score_:.10f}")

assert np.allclose(manual_scores.mean(), search.best_score_), "best_score_ не сходиться!"
print("\n✅ збігається")
наш перерахунок : 0.8300000000
search.best_score_: 0.8300000000

✅ збігається

І одразу — головне застереження¶

Подивись на таблицю вище ще раз. Переможець виграв у сусідів на кілька тисячних, а розкид по фолдах — кілька сотих. Різниця всередині шуму. Дно долини пласке, і ганятися за третім знаком після коми тут безглуздо.

Гірше того: best_score_ — це мінімум із семи зашумлених чисел, а мінімум випадкових величин у середньому менший за їхнє справжнє значення. Тобто ми обрали не лише найкращу модель, а й найудачливіший шум. Це число не можна писати у звіт як оцінку якості — для цього потрібна вкладена крос-валідація або окрема тестова вибірка, якої не бачив ніхто.

In [17]:
# наскільки «переможець» відірвався від решти — і чи це взагалі відрив
all_means = search.cv_results_["mean_test_score"]
runner_up = np.sort(all_means)[-2]
winner_spread = search.cv_results_["std_test_score"][search.best_index_]

print(f"переможець       : {search.best_score_:.4f}")
print(f"другий результат : {runner_up:.4f}")
print(f"відрив           : {search.best_score_ - runner_up:.4f}")
print(f"розкид переможця по фолдах: ±{winner_spread:.4f}")
print(f"\nВідрив у {winner_spread / (search.best_score_ - runner_up):.0f} разів менший "
      f"за власний розкид оцінки.")
print("Це не означає «переможець гірший». Це означає, що вибір між ними —")
print("монетка, і сама CV-оцінка переможця систематично оптимістична.")
переможець       : 0.8300
другий результат : 0.8250
відрив           : 0.0050
розкид переможця по фолдах: ±0.0245

Відрив у 5 разів менший за власний розкид оцінки.
Це не означає «переможець гірший». Це означає, що вибір між ними —
монетка, і сама CV-оцінка переможця систематично оптимістична.

💻 Завдання¶

🟢 Рівень 1 — разом¶

  1. Проженемо експеримент з розділу 2 для $K = 2$, $5$ і $10$. Як змінюється виграш у σ? Чи росте він як $\sqrt{K}$?
  2. Зміни class_sep у make_classification з 0.9 на 2.0 (класи розділяються легше). Що станеться з розкидом оцінки й чому?

🟡 Рівень 2 — самостійно¶

  1. Реалізуй повторену крос-валідацію вручну: прожени 5-Fold пʼять разів із різними перемішуваннями й усередни 25 чисел. Звір із RepeatedKFold.
  2. Порівняй два алгоритми — логістичну регресію й RandomForestClassifier — за CV-оцінкою. Обовʼязково подивись на .std(), а не лише на .mean().

Зроблено, якщо: ти написав(ла), чи є різниця між алгоритмами значущою на тлі розкиду, і навів(ла) обидва числа: різницю середніх і типове σ.

🔴 Рівень 3 — виклик¶

Відтвори інтерактив 6 лекції: покажи, як росте оптимізм від кількості кандидатів.

  1. Візьми базову модель і зроби $m$ кандидатів, кожен з яких додає до неї рівно одну випадкову ознаку-шум, ніяк не пов'язану з ціллю. Усі кандидати насправді однаково хороші.
  2. Для $m$ від 1 до 30 порахуй три числа: (а) найкращу просту CV-оцінку серед кандидатів, (б) справжню якість обраного переможця на незалежній великій вибірці, (в) оцінку вкладеної крос-валідації (cross_val_score над GridSearchCV).
  3. Усередни все по 20 незалежних наборах даних, щоб криві не тремтіли.

Зроблено, якщо: на графіку видно, що крива (а) невпинно повзе вгору зі зростанням $m$, крива (б) стоїть на місці (кандидати ж однакові!), а крива (в) тримається біля (б) і нікуди не дрейфує. І ти пояснив(ла) одним реченням, чому саме сталість, а не точність, робить вкладену CV чесною.

Підказки¶

  • У розділі 2 ми перемішували дані самі. Те саме дає KFold(shuffle=True, random_state=...) — але писати руками корисніше: видно, що саме перемішується.
  • Якщо cross_val_score раптом видає nan — майже напевно в якомусь фолді немає одного з класів. Лікується StratifiedKFold.
  • У рівні 3 не забудь: вкладена CV — це cross_val_score(GridSearchCV(...), X, y, cv=зовнішній). Внутрішній цикл обирає, зовнішній міряє, і вони ніколи не бачать одних і тих самих даних.