Практика 14 · Крос-валідація¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md. 🧠 Тест:quiz.html.
Головна думка лекції: кожна оцінка якості — випадкова величина. Одне число без уявлення про його розкид не варте нічого. Тут ми поміряємо цей розкид числом і подивимось, скільки саме шуму знімає усереднення по фолдах.
Що зробимо:
- Реалізуємо K-Fold вручну, без sklearn, і звіримо розбиття з
KFold - Порахуємо CV-оцінку своїм циклом і звіримо з
cross_val_score - Поміряємо, у скільки разів усереднення звужує розкид проти одного розбиття
- Подивимось, що робить дисбаланс класів і як його лікує стратифікація
- Спіймаємо витік, коли відбір ознак стоїть поза пайплайном
- Побачимо, що робить залежність рядків, і полікуємо її
GroupKFold - Підберемо гіперпараметр
GridSearchCVі перевіримо його число власноруч
0. Дані та модель¶
Синтетична задача бінарної класифікації: 200 обʼєктів, шість ознак, з яких насправді корисні лише дві. Класи розділені не ідеально — саме так і має бути, інакше міряти буде нічого.
Модель — логістична регресія у пайплайні зі стандартизацією. Пайплайн тут не для краси: далі ми побачимо, що станеться, якщо винести підготовку даних назовні.
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 лекції, крок за кроком:
- Ділимо індекси на $K$ приблизно рівних частин. Якщо $n$ не ділиться на $K$
націло, перші кілька фолдів беруть на одну точку більше — саме так робить
і
scikit-learn. - По черзі беремо кожен фолд як валідаційний, решту склеюємо в навчальну частину.
Візьмемо $K = 7$ навмисно: 200 на 7 не ділиться, і буде видно, як розподіляється залишок.
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¶
Порівнюємо не метрику, а самі індекси: до останнього номера.
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 алгоритму: у кожному фолді навчаємо модель з нуля (не донавчаємо, не переносимо ваги) і міряємо метрику на відкладеній частині. Потім усереднюємо.
Разом із середнім завжди рахуємо стандартне відхилення — воно каже, наскільки метрика залежить від того, які саме обʼєкти потрапили у валідацію.
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 Одне число з такої вибірки вибирати немає сенсу: це просто лотерея.
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$ фолдів того самого розбиття й усереднюємо.
Навчальні частини однакові за розміром, тож порівняння чесне.
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 з них. Другий ефект тут сильніший.
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} раза більше.")
Практичний висновок: різниця між двома моделями має сенс лише тоді, коли вона більша за розкид оцінки. Для K-Fold це ±0.012, а якщо довіритись одному розбиттю — ±0.051, тобто у 4.4 раза більше.
3. Дисбаланс і стратифікація¶
Тепер задача, де позитивний клас рідкісний — приблизно 5%. Випадкове перемішування тут перестає бути безпечним: з відчутною ймовірністю в якийсь фолд не потрапить жодного позитивного обʼєкта. І тоді recall на цьому фолді просто не визначений — ділити нема на що.
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 обʼєкта
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.
Один невдалий приклад — ще не доказ. Проженемо триста різних перемішувань і порахуємо, як часто розбиття виявляється зіпсованим.
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 у нас уже є — застосуємо її до індексів
кожного класу.
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. Подивимось, що покаже відбір ознак, зроблений до розбиття.
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 майже однакових записів. Діагноз залежить від профілю, тобто від пацієнта, а не від конкретного запису.
І поставимо питання чесно: що модель побачить уперше в реальному використанні? Нового пацієнта. Отже, і міряти треба на нових пацієнтах.
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 інших пацієнтів
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 означає сильну регуляризацію.
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_ — не магія: це просто середнє по фолдах для
переможця. Порахуємо його своїм циклом.
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_ — це мінімум із семи зашумлених чисел, а мінімум
випадкових величин у середньому менший за їхнє справжнє значення. Тобто ми
обрали не лише найкращу модель, а й найудачливіший шум. Це число не можна
писати у звіт як оцінку якості — для цього потрібна вкладена крос-валідація або
окрема тестова вибірка, якої не бачив ніхто.
# наскільки «переможець» відірвався від решти — і чи це взагалі відрив
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 — разом¶
- Проженемо експеримент з розділу 2 для $K = 2$, $5$ і $10$. Як змінюється виграш у σ? Чи росте він як $\sqrt{K}$?
- Зміни
class_sepуmake_classificationз0.9на2.0(класи розділяються легше). Що станеться з розкидом оцінки й чому?
🟡 Рівень 2 — самостійно¶
- Реалізуй повторену крос-валідацію вручну: прожени 5-Fold пʼять разів із
різними перемішуваннями й усередни 25 чисел. Звір із
RepeatedKFold. - Порівняй два алгоритми — логістичну регресію й
RandomForestClassifier— за CV-оцінкою. Обовʼязково подивись на.std(), а не лише на.mean().
Зроблено, якщо: ти написав(ла), чи є різниця між алгоритмами значущою на тлі розкиду, і навів(ла) обидва числа: різницю середніх і типове σ.
🔴 Рівень 3 — виклик¶
Відтвори інтерактив 6 лекції: покажи, як росте оптимізм від кількості кандидатів.
- Візьми базову модель і зроби $m$ кандидатів, кожен з яких додає до неї рівно одну випадкову ознаку-шум, ніяк не пов'язану з ціллю. Усі кандидати насправді однаково хороші.
- Для $m$ від 1 до 30 порахуй три числа: (а) найкращу просту CV-оцінку серед
кандидатів, (б) справжню якість обраного переможця на незалежній великій
вибірці, (в) оцінку вкладеної крос-валідації (
cross_val_scoreнадGridSearchCV). - Усередни все по 20 незалежних наборах даних, щоб криві не тремтіли.
Зроблено, якщо: на графіку видно, що крива (а) невпинно повзе вгору зі зростанням $m$, крива (б) стоїть на місці (кандидати ж однакові!), а крива (в) тримається біля (б) і нікуди не дрейфує. І ти пояснив(ла) одним реченням, чому саме сталість, а не точність, робить вкладену CV чесною.
Підказки¶
- У розділі 2 ми перемішували дані самі. Те саме дає
KFold(shuffle=True, random_state=...)— але писати руками корисніше: видно, що саме перемішується. - Якщо
cross_val_scoreраптом видаєnan— майже напевно в якомусь фолді немає одного з класів. ЛікуєтьсяStratifiedKFold. - У рівні 3 не забудь: вкладена CV — це
cross_val_score(GridSearchCV(...), X, y, cv=зовнішній). Внутрішній цикл обирає, зовнішній міряє, і вони ніколи не бачать одних і тих самих даних.