Практика 04 · Три конверти: train / validation / test¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md· 🧪 Тест:quiz.html
У лекції ми домовились про головне: щоб дізнатись, чи навчилась модель, її треба перевірити на задачах, яких вона не бачила. Тут ми зробимо це руками й побачимо числа, які стоять за кожним твердженням лекції.
Що зробимо:
- Переконаємось, що навчальна точність не варта нічого
- Розібʼємо дані на три частини самим
numpy, а потім черезtrain_test_split - Зробимо стратифіковане розбиття вручну і звіримо з
stratify=y - Побачимо числом, як мала тестова вибірка перетворює оцінку на лотерею, і порівняємо розкид із формулою SE = √(p(1−p)/n)
- Відтворимо два витоки даних і виміряємо, на скільки кожен завищує точність
- Складемо чесний
Pipelineіз крос-валідацією і торкнемось тесту рівно один раз
Наскрізний приклад той самий, що в лекції: два класи, які частково перекриваються, і модель k найближчих сусідів — у неї немає жодної формули, тому ніщо не відволікає від самої процедури розбиття.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import train_test_split
# два серпи, які частково налазять один на одного:
# ідеального розділення не існує, тому будь-яка модель мусить помилятись
features, labels = make_moons(n_samples=400, noise=0.30, random_state=42)
print(f"обʼєктів : {len(labels)}")
print(f"ознак : {features.shape[1]}")
print(f"клас 0 / клас 1: {np.sum(labels == 0)} / {np.sum(labels == 1)}")
обʼєктів : 400 ознак : 2 клас 0 / клас 1: 200 / 200
1. Навчальна точність завжди бреше¶
Почнемо з демонстрації з першого розділу лекції. Навчимо k = 1 на всіх даних
і поміряємо точність на тих самих даних. Модель шукає найближчого сусіда — і
знаходить сама себе. Результат відомий заздалегідь, але побачити його корисно.
# модель бачить усі дані й міряється на них же — так робити не можна,
# ми робимо це навмисно, щоб побачити, наскільки безглузде вийде число
memorizer = KNeighborsClassifier(n_neighbors=1).fit(features, labels)
train_accuracy_on_itself = memorizer.score(features, labels)
print(f"точність k=1 на тих самих даних, на яких навчали: {train_accuracy_on_itself:.4f}")
print("це не якість моделі — це визначення методу найближчого сусіда")
точність k=1 на тих самих даних, на яких навчали: 1.0000 це не якість моделі — це визначення методу найближчого сусіда
2. Розбиття руками: три конверти¶
Уся процедура — це один рядок перемішування і два розрізи. Перемішуємо індекси об'єктів, а не самі дані: так простіше перевірити, що жоден об'єкт не потрапив у дві вибірки одночасно.
def split_three(n_objects, train_share, val_share, seed):
"""Ділить індекси 0..n-1 на train / validation / test.
Перемішування робимо рівно один раз: якби ми тасували двічі, об'єкт міг би
потрапити у дві вибірки, а це вже не розбиття.
"""
shuffled = np.random.default_rng(seed).permutation(n_objects)
n_train = int(n_objects * train_share)
n_val = int(n_objects * val_share)
train_idx = shuffled[:n_train]
val_idx = shuffled[n_train:n_train + n_val]
test_idx = shuffled[n_train + n_val:]
return train_idx, val_idx, test_idx
train_idx, val_idx, test_idx = split_three(len(labels), 0.6, 0.2, seed=42)
print(f"train : {len(train_idx):3d} обʼєктів ({len(train_idx)/len(labels):.0%})")
print(f"validation : {len(val_idx):3d} обʼєктів ({len(val_idx)/len(labels):.0%})")
print(f"test : {len(test_idx):3d} обʼєктів ({len(test_idx)/len(labels):.0%})")
train : 240 обʼєктів (60%) validation : 80 обʼєктів (20%) test : 80 обʼєктів (20%)
Тепер найважливіша перевірка, яку в реальних проєктах пропускають найчастіше: чи справді частини не перетинаються. Одна спільна точка — і тест уже нечесний.
# перетин будь-якої пари вибірок має бути порожнім
overlap_train_val = np.intersect1d(train_idx, val_idx)
overlap_train_test = np.intersect1d(train_idx, test_idx)
overlap_val_test = np.intersect1d(val_idx, test_idx)
print(f"спільних обʼєктів train∩val : {len(overlap_train_val)}")
print(f"спільних обʼєктів train∩test: {len(overlap_train_test)}")
print(f"спільних обʼєктів val∩test : {len(overlap_val_test)}")
# і жоден обʼєкт не має загубитись по дорозі
all_used = np.concatenate([train_idx, val_idx, test_idx])
print(f"\nвикористано унікальних індексів: {len(np.unique(all_used))} з {len(labels)}")
assert len(np.unique(all_used)) == len(labels), "частина обʼєктів загубилась!"
print("✅ розбиття коректне: без перетинів і без втрат")
спільних обʼєктів train∩val : 0 спільних обʼєктів train∩test: 0 спільних обʼєктів val∩test : 0 використано унікальних індексів: 400 з 400 ✅ розбиття коректне: без перетинів і без втрат
3. Те саме через train_test_split¶
У бібліотеці немає функції, яка ділить одразу на три. Стандартний прийом —
викликати train_test_split двічі: спершу відрізаємо тест, потім від решти
відрізаємо валідацію.
Уважно з другою часткою: 20% від початкових даних — це вже 25% від того, що лишилось після відрізання тесту. Тут помиляються постійно.
# крок 1: відрізаємо тест від усього
rest_x, test_x, rest_y, test_y = train_test_split(
features, labels, test_size=0.2, random_state=42)
# крок 2: відрізаємо валідацію від решти.
# 0.2 від усіх даних = 0.25 від решти, бо решта — це лише 80% початкового обсягу
train_x, val_x, train_y, val_y = train_test_split(
rest_x, rest_y, test_size=0.25, random_state=42)
print(f"train : {len(train_y):3d}")
print(f"validation : {len(val_y):3d}")
print(f"test : {len(test_y):3d}")
assert len(train_y) == len(train_idx), "розміри розійшлися з нашим ручним розбиттям"
print("\n✅ розміри збіглися з ручним розбиттям 60/20/20")
train : 240 validation : 80 test : 80 ✅ розміри збіглися з ручним розбиттям 60/20/20
4. Наша точність проти бібліотечної¶
Точність — це просто частка правильних відповідей. Порахуємо її самі одним рядком
і звіримо з accuracy_score. Це перша з обов'язкових перевірок «всередині
бібліотеки немає магії».
from sklearn.metrics import accuracy_score
model = KNeighborsClassifier(n_neighbors=15).fit(train_x, train_y)
predictions = model.predict(test_x)
# частка збігів між прогнозом і правдою — це і є accuracy, без жодних хитрощів
our_accuracy = np.mean(predictions == test_y)
library_accuracy = accuracy_score(test_y, predictions)
print(f"наша : {our_accuracy:.10f}")
print(f"sklearn : {library_accuracy:.10f}")
assert np.allclose(our_accuracy, library_accuracy), "розрахунок розійшовся!"
print("\n✅ збігається")
print(f"\nдля порівняння: на навчальних даних та сама модель дає "
f"{model.score(train_x, train_y):.3f}, на тесті — {our_accuracy:.3f}")
наша : 0.9000000000 sklearn : 0.9000000000 ✅ збігається для порівняння: на навчальних даних та сама модель дає 0.917, на тесті — 0.900
5. Стратифікація: чому випадковість шкодить при дисбалансі¶
Зробимо задачу схожою на реальну: нехай позитивний клас складає лише 7% — як шахрайство, рідкісна хвороба чи відмова обладнання.
Стратифіковане розбиття робиться в одну думку: тасуємо кожен клас окремо і
беремо з кожного рівно ту частку, яка потрібна. Напишемо це самі й звіримо
з аргументом stratify=y.
rng = np.random.default_rng(0)
# та сама геометрія, але позитивний клас штучно зроблено рідкісним
positive_pool = np.flatnonzero(labels == 1)
negative_pool = np.flatnonzero(labels == 0)
keep_positive = rng.choice(positive_pool, size=16, replace=False)
rare_idx = np.concatenate([negative_pool, keep_positive])
rare_x = features[rare_idx]
rare_y = labels[rare_idx]
print(f"обʼєктів: {len(rare_y)}")
print(f"позитивних: {rare_y.sum()} ({rare_y.mean():.1%})")
обʼєктів: 216 позитивних: 16 (7.4%)
def stratified_split(y, test_share, seed):
"""Стратифіковане розбиття вручну: кожен клас ділимо окремо у тій самій частці."""
generator = np.random.default_rng(seed)
train_parts, test_parts = [], []
for class_value in np.unique(y):
class_idx = np.flatnonzero(y == class_value)
shuffled = generator.permutation(class_idx)
# скільки обʼєктів цього класу віддати в тест. Округлення тут —
# єдине місце, де наша реалізація може розійтися з бібліотечною:
# при «незручних» розмірах класів вони поділять залишок по-різному
n_test = int(round(len(class_idx) * test_share))
test_parts.append(shuffled[:n_test])
train_parts.append(shuffled[n_test:])
return np.concatenate(train_parts), np.concatenate(test_parts)
our_train, our_test = stratified_split(rare_y, test_share=0.25, seed=7)
our_share = rare_y[our_test].mean()
_, _, _, sk_test_y = train_test_split(
rare_x, rare_y, test_size=0.25, random_state=7, stratify=rare_y)
sk_share = sk_test_y.mean()
print(f"частка позитивних у всій вибірці : {rare_y.mean():.6f}")
print(f"наше стратифіковане розбиття : {our_share:.6f}")
print(f"sklearn stratify=y : {sk_share:.6f}")
assert np.allclose(our_share, sk_share), "стратифікація розійшлася з бібліотечною!"
print("\n✅ збігається")
частка позитивних у всій вибірці : 0.074074 наше стратифіковане розбиття : 0.074074 sklearn stratify=y : 0.074074 ✅ збігається
А що робить звичайне випадкове розбиття¶
Лекція рахувала олівцем: при 100 об'єктах у тесті і 2% мінорного класу стандартне відхилення кількості позитивних дорівнює √(100·0,02·0,98) ≈ 1,4, тому нуль позитивних у тесті — цілком буденний випадок.
Спершу доведемо олівцевий розрахунок до кінця (ймовірність «нуль позитивних» рахується в один рядок), а потім переберемо 2000 розбиттів нашої власної вибірки й подивимось, що буває насправді.
# точна арифметика прикладу з лекції: 100 обʼєктів у тесті, мінорний клас 2%
n_test_lecture = 100
minority_share = 0.02
expected_positives = n_test_lecture * minority_share
std_positives = np.sqrt(n_test_lecture * minority_share * (1 - minority_share))
# ймовірність нуля позитивних: усі 100 обʼєктів мають виявитись негативними
probability_of_zero = (1 - minority_share) ** n_test_lecture
print(f"очікувано позитивних у тесті : {expected_positives:.1f}")
print(f"стандартне відхилення : {std_positives:.2f}")
print(f"ймовірність НУЛЯ позитивних : {probability_of_zero:.1%}")
print(f"\nТобто {probability_of_zero:.0%} розбиттів дають тест, на якому recall узагалі")
print("не визначений. Це не рідкісна аварія, а буденність.")
очікувано позитивних у тесті : 2.0 стандартне відхилення : 1.40 ймовірність НУЛЯ позитивних : 13.3% Тобто 13% розбиттів дають тест, на якому recall узагалі не визначений. Це не рідкісна аварія, а буденність.
REPEATS = 2000
random_positive_counts = np.zeros(REPEATS, dtype=int)
strat_positive_counts = np.zeros(REPEATS, dtype=int)
for repeat in range(REPEATS):
# звичайне розбиття: тасуємо все разом
shuffled = np.random.default_rng(repeat).permutation(len(rare_y))
n_test = int(len(rare_y) * 0.25)
random_positive_counts[repeat] = rare_y[shuffled[:n_test]].sum()
# стратифіковане: тасуємо кожен клас окремо
_, strat_test = stratified_split(rare_y, test_share=0.25, seed=repeat)
strat_positive_counts[repeat] = rare_y[strat_test].sum()
print(f"позитивних у тесті, випадкове розбиття : "
f"середнє {random_positive_counts.mean():.2f}, σ = {random_positive_counts.std():.2f}, "
f"діапазон {random_positive_counts.min()}–{random_positive_counts.max()}")
print(f"позитивних у тесті, стратифіковане : "
f"середнє {strat_positive_counts.mean():.2f}, σ = {strat_positive_counts.std():.2f}, "
f"діапазон {strat_positive_counts.min()}–{strat_positive_counts.max()}")
empty_tests = np.mean(random_positive_counts == 0)
print(f"\nрозбиттів, де у тесті НЕ ЛИШИЛОСЬ жодного позитивного: {empty_tests:.2%}")
print("у таких розбиттях recall узагалі не визначений — міряти нема чого")
позитивних у тесті, випадкове розбиття : середнє 3.99, σ = 1.63, діапазон 0–9 позитивних у тесті, стратифіковане : середнє 4.00, σ = 0.00, діапазон 4–4 розбиттів, де у тесті НЕ ЛИШИЛОСЬ жодного позитивного: 0.70% у таких розбиттях recall узагалі не визначений — міряти нема чого
6. Скільки коштує мала тестова вибірка¶
Тепер головний числовий експеримент. Візьмемо ту саму модель і ті самі дані, але міняти будемо лише розмір тестової вибірки. Модель не змінюється — змінюється тільки якість вимірювання.
Лекція вивела формулу стандартної похибки:
$$SE = \sqrt{\frac{p(1-p)}{n}}$$
де p — справжня точність, n — розмір тесту. Перевіримо її емпірично: розкид по багатьох розбиттях має збігтися з тим, що дає формула.
def accuracy_spread(test_size, repeats=300, n_neighbors=15):
"""Багато разів ділимо ті самі дані й щоразу міряємо точність на тесті.
Повертає масив точностей: його розкид — це і є похибка вимірювання.
"""
scores = np.zeros(repeats)
for repeat in range(repeats):
tr_x, te_x, tr_y, te_y = train_test_split(
features, labels, test_size=test_size, random_state=repeat, stratify=labels)
scores[repeat] = KNeighborsClassifier(n_neighbors).fit(tr_x, tr_y).score(te_x, te_y)
return scores
spreads = {}
print(f"{'n тесту':>8} {'середнє':>9} {'σ емпір.':>10} {'SE формула':>11} "
f"{'SE з попр.':>11} {'95% ДІ ±':>9}")
print("-" * 64)
for test_size in [20, 50, 200]:
scores = accuracy_spread(test_size)
spreads[test_size] = scores
p = scores.mean()
formula_se = np.sqrt(p * (1 - p) / test_size)
# поправка на скінченну популяцію: ми не беремо нові дані, а перетасовуємо
# ті самі 400 обʼєктів, тому вибірки не є повністю незалежними
finite_correction = np.sqrt(1 - test_size / len(labels))
print(f"{test_size:>8} {p:>9.3f} {scores.std():>10.3f} {formula_se:>11.3f} "
f"{formula_se * finite_correction:>11.3f} {1.96 * formula_se:>9.3f}")
print("\nЧистий біноміальний SE трохи завищує розкид, і причина цікава.")
print("Формула припускає, що для кожного вимірювання ми беремо НОВІ дані.")
print("Ми ж перетасовуємо ті самі 400 обʼєктів — тому вибірки перетинаються,")
print("а розкид зменшується у √(1 − n/N) разів. Це і є стовпчик «SE з попр.»,")
print("і він лягає на емпіричне σ майже точно.")
print("\nДля реального проєкту користуйся звичайною формулою: там тестова вибірка")
print("справді має вдавати нові дані, і поправки не буде.")
n тесту середнє σ емпір. SE формула SE з попр. 95% ДІ ± ----------------------------------------------------------------
20 0.894 0.064 0.069 0.067 0.135
50 0.895 0.042 0.043 0.041 0.085
200 0.893 0.015 0.022 0.015 0.043 Чистий біноміальний SE трохи завищує розкид, і причина цікава. Формула припускає, що для кожного вимірювання ми беремо НОВІ дані. Ми ж перетасовуємо ті самі 400 обʼєктів — тому вибірки перетинаються, а розкид зменшується у √(1 − n/N) разів. Це і є стовпчик «SE з попр.», і він лягає на емпіричне σ майже точно. Для реального проєкту користуйся звичайною формулою: там тестова вибірка справді має вдавати нові дані, і поправки не буде.
fig, ax = plt.subplots(figsize=(9, 4.5))
for test_size, color in zip([20, 50, 200], ["crimson", "darkorange", "teal"]):
ax.hist(spreads[test_size], bins=np.arange(0.5, 1.02, 0.02), alpha=0.55,
color=color, label=f"n тесту = {test_size} (σ = {spreads[test_size].std():.3f})")
ax.set_xlabel("виміряна точність на тесті")
ax.set_ylabel("скільки разів трапилось")
ax.set_title("Одна модель, одні дані — 300 різних розбиттів")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()
worst = spreads[20]
print(f"на тесті з 20 обʼєктів результат гуляв від {worst.min():.2f} до {worst.max():.2f}")
print("це та сама модель. Різниця лише в тому, які 20 обʼєктів їй дістались.")
на тесті з 20 обʼєктів результат гуляв від 0.70 до 1.00 це та сама модель. Різниця лише в тому, які 20 обʼєктів їй дістались.
Наслідок для порівняння моделей¶
Формула має корінь у знаменнику, тому щоб зменшити похибку вдвічі, вибірку треба збільшити вчетверо. Порахуємо, скільки об'єктів потрібно для інтервалу ±1 пункт.
true_accuracy = 0.85
for target_half_width in [0.05, 0.02, 0.01]:
# з рівняння 1.96 * sqrt(p(1-p)/n) = target виражаємо n
needed = (1.96 ** 2) * true_accuracy * (1 - true_accuracy) / target_half_width ** 2
print(f"щоб 95% інтервал був ±{target_half_width:.0%}, потрібно {needed:>8.0f} обʼєктів у тесті")
n_test = 200
se = np.sqrt(true_accuracy * (1 - true_accuracy) / n_test)
print(f"\nа тепер зворотний бік: тест на {n_test} обʼєктів дає SE = {se:.4f} "
f"({100 * se:.1f} пп)")
print(f"різниця «модель A дала 87%, модель B — 86%» менша за похибку "
f"{1.96 * 100 * se:.1f} пп і тому не існує")
щоб 95% інтервал був ±5%, потрібно 196 обʼєктів у тесті щоб 95% інтервал був ±2%, потрібно 1225 обʼєктів у тесті щоб 95% інтервал був ±1%, потрібно 4898 обʼєктів у тесті а тепер зворотний бік: тест на 200 обʼєктів дає SE = 0.0252 (2.5 пп) різниця «модель A дала 87%, модель B — 86%» менша за похибку 4.9 пп і тому не існує
7. Витік даних №1 · нормалізація до розбиття¶
Класична помилка: спершу нормалізуємо всі дані, потім ділимо. Здається невинним — жодних міток, самі числа. Але подивимось, звідки беруться параметри нормалізації.
from sklearn.preprocessing import MinMaxScaler
# нормалізуємо ВСЕ, а потім ділимо — саме так робити не можна
scaler_on_everything = MinMaxScaler().fit(features)
# а тепер чесно: спершу ділимо, вчимо нормалізатор лише на train
honest_train_x, honest_test_x = features[train_idx], features[test_idx]
scaler_on_train = MinMaxScaler().fit(honest_train_x)
in_test = set(test_idx.tolist())
print(f"{'параметр':<24}{'по всіх даних':>15}{'лише по train':>15} кому належить")
print("-" * 74)
for feature_number in range(features.shape[1]):
for name, pick in [("мінімум", np.argmin), ("максимум", np.argmax)]:
owner = pick(features[:, feature_number])
whole = (scaler_on_everything.data_min_ if name == "мінімум"
else scaler_on_everything.data_max_)[feature_number]
train_only = (scaler_on_train.data_min_ if name == "мінімум"
else scaler_on_train.data_max_)[feature_number]
where = "ТЕСТ ⚠️" if owner in in_test else "train"
print(f"{name} ознаки {feature_number:<10}{whole:>15.4f}{train_only:>15.4f} "
f"обʼєкт #{owner} — {where}")
print("\nТам, де крайній обʼєкт випадково опинився у train, два числа збіглися.")
print("Там, де він у тесті, — розійшлися: нормалізатор узяв параметр із тесту.")
print("Але покладатись тут ні на що: чи пощастить, вирішує генератор випадкових")
print("чисел. Витік — це не «розійшлись числа», а сама залежність перетворення")
print("від даних, яких воно бачити не мало.")
параметр по всіх даних лише по train кому належить -------------------------------------------------------------------------- мінімум ознаки 0 -1.4896 -1.4558 обʼєкт #287 — ТЕСТ ⚠️ максимум ознаки 0 2.5557 2.5557 обʼєкт #19 — train мінімум ознаки 1 -1.1262 -1.0705 обʼєкт #139 — ТЕСТ ⚠️ максимум ознаки 1 1.4182 1.4182 обʼєкт #107 — train Там, де крайній обʼєкт випадково опинився у train, два числа збіглися. Там, де він у тесті, — розійшлися: нормалізатор узяв параметр із тесту. Але покладатись тут ні на що: чи пощастить, вирішує генератор випадкових чисел. Витік — це не «розійшлись числа», а сама залежність перетворення від даних, яких воно бачити не мало.
А тепер поміряємо, скільки цей витік коштує¶
Різницю в одному розбитті побачити неможливо: вона тоне у випадковості. Тому робимо 300 повторів і дивимось на середню різницю та її похибку.
def normalization_leak(n_objects=40, repeats=300, n_neighbors=3):
"""Порівнює два порядки дій на однакових розбиттях: нормалізація до і після.
Різницю рахуємо попарно (на тому самому розбитті), бо так із результату
зникає випадковість самого розбиття і лишається чистий ефект витоку.
"""
differences = np.zeros(repeats)
for repeat in range(repeats):
small_x, small_y = make_moons(n_samples=n_objects, noise=0.35,
random_state=repeat)
# робимо ознаки різного масштабу, щоб нормалізація взагалі мала значення
small_x = small_x * np.array([1.0, 25.0])
tr_x, te_x, tr_y, te_y = train_test_split(
small_x, small_y, test_size=0.4, random_state=repeat, stratify=small_y)
# з витоком: нормалізатор бачив тестові обʼєкти
leaked = MinMaxScaler().fit(small_x)
leaked_score = KNeighborsClassifier(n_neighbors).fit(
leaked.transform(tr_x), tr_y).score(leaked.transform(te_x), te_y)
# чесно: нормалізатор бачив лише train
clean = MinMaxScaler().fit(tr_x)
clean_score = KNeighborsClassifier(n_neighbors).fit(
clean.transform(tr_x), tr_y).score(clean.transform(te_x), te_y)
differences[repeat] = leaked_score - clean_score
return differences
leak_diff = normalization_leak()
mean_gap = leak_diff.mean() * 100
half_width = 1.96 * leak_diff.std() / np.sqrt(len(leak_diff)) * 100
print(f"середнє завищення точності: {mean_gap:+.2f} пп")
print(f"95% довірчий інтервал : ±{half_width:.2f} пп")
if abs(mean_gap) < half_width:
print("\nВердикт: витік реальний за механікою, але його ціна тоне у похибці")
print("вимірювання. Саме це й казала лекція: нормалізація дає невелике")
print("зміщення, яке тане зі зростанням вибірки.")
else:
print(f"\nВердикт: зміщення видно навіть крізь шум — {mean_gap:+.2f} пп.")
print("\nНебезпечна тут не величина, а звичка: наступний крок тим самим порядком")
print("дій коштує вже десятки пунктів. Дивись нижче.")
середнє завищення точності: +0.17 пп 95% довірчий інтервал : ±0.36 пп Вердикт: витік реальний за механікою, але його ціна тоне у похибці вимірювання. Саме це й казала лекція: нормалізація дає невелике зміщення, яке тане зі зростанням вибірки. Небезпечна тут не величина, а звичка: наступний крок тим самим порядком дій коштує вже десятки пунктів. Дивись нижче.
8. Витік даних №2 · відбір ознак до розбиття¶
Той самий порядок дій, але перетворення тепер дивиться на мітки. Візьмемо найчесніший можливий тест: 300 ознак чистого шуму і мітки, підкинуті монеткою. Зв'язку між ознаками й відповіддю не існує взагалі, тому єдина правильна точність тут — 0,50.
Відберемо 10 ознак, найбільш корельованих із міткою, — один раз по всіх даних (з витоком) і один раз лише по train (чесно).
def selection_leak(n_objects=60, n_features=300, top_k=10, repeats=200, n_neighbors=3):
"""Відбір ознак до і після розбиття на даних, у яких немає сигналу."""
leaked_scores = np.zeros(repeats)
clean_scores = np.zeros(repeats)
for repeat in range(repeats):
generator = np.random.default_rng(repeat)
noise_x = generator.normal(size=(n_objects, n_features))
coin_y = generator.integers(0, 2, n_objects) # мітки не пов'язані з ознаками
# З ВИТОКОМ: корисність ознаки міряємо по всіх даних, включно з тестовими мітками
usefulness = np.abs(noise_x.T @ (coin_y - coin_y.mean()))
chosen = np.argsort(usefulness)[-top_k:]
tr_x, te_x, tr_y, te_y = train_test_split(
noise_x[:, chosen], coin_y, test_size=0.3,
random_state=repeat, stratify=coin_y)
leaked_scores[repeat] = KNeighborsClassifier(n_neighbors).fit(tr_x, tr_y).score(te_x, te_y)
# ЧЕСНО: спершу розбиття, корисність ознак — лише по навчальній частині
tr_x, te_x, tr_y, te_y = train_test_split(
noise_x, coin_y, test_size=0.3, random_state=repeat, stratify=coin_y)
usefulness = np.abs(tr_x.T @ (tr_y - tr_y.mean()))
chosen = np.argsort(usefulness)[-top_k:]
clean_scores[repeat] = KNeighborsClassifier(n_neighbors).fit(
tr_x[:, chosen], tr_y).score(te_x[:, chosen], te_y)
return leaked_scores, clean_scores
leaked_scores, clean_scores = selection_leak()
print(f"справжня якість (сигналу в даних немає) : 0.500")
print(f"чесний порядок дій : {clean_scores.mean():.3f}")
print(f"відбір ознак до розбиття : {leaked_scores.mean():.3f}")
print(f"\nзавищення: {100 * (leaked_scores.mean() - clean_scores.mean()):+.1f} "
f"відсоткових пунктів на даних, де немає ЖОДНОГО сигналу")
справжня якість (сигналу в даних немає) : 0.500 чесний порядок дій : 0.512 відбір ознак до розбиття : 0.730 завищення: +21.8 відсоткових пунктів на даних, де немає ЖОДНОГО сигналу
fig, ax = plt.subplots(figsize=(9, 4.5))
bins = np.arange(0.2, 1.02, 0.04)
ax.hist(leaked_scores, bins=bins, alpha=0.6, color="crimson", label="відбір ознак до розбиття")
ax.hist(clean_scores, bins=bins, alpha=0.6, color="teal", label="чесний порядок дій")
ax.axvline(0.5, color="gray", ls="--", lw=2, label="справжня якість = 0.50")
ax.set_xlabel("точність на тесті")
ax.set_ylabel("скільки разів трапилось")
ax.set_title("Дані — чистий шум. Одна з двох гістограм бреше")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()
print("Бірюзова гістограма чесно стоїть навколо 0.50 — модель нічого не знайшла,")
print("бо шукати нічого. Рожева обіцяє робочу модель там, де її немає.")
Бірюзова гістограма чесно стоїть навколо 0.50 — модель нічого не знайшла, бо шукати нічого. Рожева обіцяє робочу модель там, де її немає.
9. Як робити правильно: Pipeline і крос-валідація¶
Pipeline — це не «зручність», а гарантія. Він змушує кожне перетворення
навчатись на тій самій частині даних, на якій навчається модель, і застосовуватись
до решти. Усередині крос-валідації це відбувається окремо в кожному фолді.
Правильна послідовність із лекції:
- відкласти тест першим ділом;
- на решті даних крос-валідацією обрати гіперпараметр;
- навчити переможця на всіх не-тестових даних;
- один раз поміряти на тесті.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score, StratifiedKFold
# КРОК 1. Тест відкладаємо першим і більше до нього не торкаємось
work_x, holdout_x, work_y, holdout_y = train_test_split(
features, labels, test_size=0.25, random_state=42, stratify=labels)
print(f"робоча частина: {len(work_y)} обʼєктів")
print(f"тест у конверті: {len(holdout_y)} обʼєктів — не відкриваємо до кінця")
робоча частина: 300 обʼєктів тест у конверті: 100 обʼєктів — не відкриваємо до кінця
# КРОК 2. Обираємо k крос-валідацією ТІЛЬКИ на робочій частині.
# Нормалізатор стоїть усередині Pipeline, тому в кожному фолді він навчається
# заново лише на навчальних чотирьох пʼятих — витоку зі сценарію 1 не буде.
folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
print(f"{'k':>4} {'середнє CV':>12} {'σ по фолдах':>13}")
print("-" * 31)
cv_means = {}
for k in [1, 3, 5, 9, 15, 25, 45]:
pipeline = Pipeline([
("scaler", StandardScaler()),
("knn", KNeighborsClassifier(n_neighbors=k)),
])
fold_scores = cross_val_score(pipeline, work_x, work_y, cv=folds)
cv_means[k] = fold_scores.mean()
print(f"{k:>4} {fold_scores.mean():>12.3f} {fold_scores.std():>13.3f}")
best_k = max(cv_means, key=cv_means.get)
print(f"\nпереможець за крос-валідацією: k = {best_k} (CV {cv_means[best_k]:.3f})")
k середнє CV σ по фолдах ------------------------------- 1 0.823 0.050
3 0.863 0.059 5 0.860 0.048 9 0.853 0.051 15 0.870 0.059 25 0.877 0.050 45 0.873 0.023 переможець за крос-валідацією: k = 25 (CV 0.877)
# КРОК 3 і 4. Навчаємо переможця на всій робочій частині — і торкаємось тесту
# рівно один раз. Після цього рядка жодних змін у моделі бути не може.
final_model = Pipeline([
("scaler", StandardScaler()),
("knn", KNeighborsClassifier(n_neighbors=best_k)),
]).fit(work_x, work_y)
test_score = final_model.score(holdout_x, holdout_y)
se = np.sqrt(test_score * (1 - test_score) / len(holdout_y))
print(f"оцінка на крос-валідації : {cv_means[best_k]:.3f}")
print(f"оцінка на тесті : {test_score:.3f}")
print(f"95% довірчий інтервал : {test_score:.3f} ± {1.96 * se:.3f}")
print(f"\nЧесний звіт виглядає так: точність {test_score:.1%} "
f"± {1.96 * se:.1%} на {len(holdout_y)} обʼєктах.")
print("Одне число без похибки — це не звіт, а реклама.")
оцінка на крос-валідації : 0.877 оцінка на тесті : 0.920 95% довірчий інтервал : 0.920 ± 0.053 Чесний звіт виглядає так: точність 92.0% ± 5.3% на 100 обʼєктах. Одне число без похибки — це не звіт, а реклама.
💻 Завдання¶
🟢 Рівень 1 — разом¶
- Постав у
split_threeчастки 0.85 / 0.05. Скільки об'єктів лишилось на тест? Порахуй SE для такої вибірки і скажи, чи можна на ній щось порівнювати. - Запусти
accuracy_spread(10). Який розмах точності? Чи бачив ти колись у чужому звіті число, поміряне на десяти об'єктах?
🟡 Рівень 2 — самостійно¶
- Додай у
selection_leakтретій варіант: відбір ознак усерединіPipelineчерезSelectKBest. Переконайся, що він дає ті самі чесні 0,50. - Змоделюй витік через дублікати: продублюй кожен об'єкт двічі перед розбиттям і поміряй точність. На скільки вона підскочила і чому.
🔴 Рівень 3 — виклик¶
- Реалізуй
GroupShuffleSplitвручну: розбиття, у якому всі рядки однієї групи (наприклад, одного пацієнта) їдуть в одну вибірку цілком. - Відтвори «прокляття переможця» з другого інтерактиву лекції: згенеруй 30 моделей з однаковою справжньою якістю, обери найкращу за валідацією й покажи на графіку, наскільки її оцінка на валідації завищена порівняно з тестом.
🧪 Самоперевірка¶
1. Чому 20% від усіх даних — це 25% у другому виклику train_test_split?
відповідь
Бо другий виклик працює вже не з усіма даними, а з рештою після відрізання тесту. Якщо тест забрав 20%, лишилось 80%, і 0.2 / 0.8 = 0.25.2. Нормалізація до розбиття дала завищення менше за похибку вимірювання. Чи означає це, що так можна робити?
відповідь
Ні. По-перше, розмір витоку залежить від перетворення: для нормалізації він малий, для відбору ознак — десятки пунктів, і порядок дій у коді той самий. По-друге, на маленькому датасеті чи при сильних викидах зміщення росте. Правило «спершу розбиття» коштує один рядок і знімає весь клас проблем одразу.3. Модель A дала на тесті 87%, модель B — 86%. Тест на 200 об'єктів. Яка краща?
відповідь
Невідомо. SE ≈ 2,4 пп, довірчий інтервал ±4,7 пп — різниця в один пункт цілком уміщається в похибку. Щоб відповісти, потрібен більший тест або порівняння по фолдах крос-валідації.4. Крос-валідація замінює валідаційну вибірку чи тестову?