Практика 04 · Три конверти: train / validation / test¶

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

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

Що зробимо:

  1. Переконаємось, що навчальна точність не варта нічого
  2. Розібʼємо дані на три частини самим numpy, а потім через train_test_split
  3. Зробимо стратифіковане розбиття вручну і звіримо з stratify=y
  4. Побачимо числом, як мала тестова вибірка перетворює оцінку на лотерею, і порівняємо розкид із формулою SE = √(p(1−p)/n)
  5. Відтворимо два витоки даних і виміряємо, на скільки кожен завищує точність
  6. Складемо чесний Pipeline із крос-валідацією і торкнемось тесту рівно один раз

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

In [1]:
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 на всіх даних і поміряємо точність на тих самих даних. Модель шукає найближчого сусіда — і знаходить сама себе. Результат відомий заздалегідь, але побачити його корисно.

In [2]:
# модель бачить усі дані й міряється на них же — так робити не можна,
# ми робимо це навмисно, щоб побачити, наскільки безглузде вийде число
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. Розбиття руками: три конверти¶

Уся процедура — це один рядок перемішування і два розрізи. Перемішуємо індекси об'єктів, а не самі дані: так простіше перевірити, що жоден об'єкт не потрапив у дві вибірки одночасно.

In [3]:
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%)

Тепер найважливіша перевірка, яку в реальних проєктах пропускають найчастіше: чи справді частини не перетинаються. Одна спільна точка — і тест уже нечесний.

In [4]:
# перетин будь-якої пари вибірок має бути порожнім
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% від того, що лишилось після відрізання тесту. Тут помиляються постійно.

In [5]:
# крок 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. Це перша з обов'язкових перевірок «всередині бібліотеки немає магії».

In [6]:
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.

In [7]:
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%)
In [8]:
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 розбиттів нашої власної вибірки й подивимось, що буває насправді.

In [9]:
# точна арифметика прикладу з лекції: 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 узагалі
не визначений. Це не рідкісна аварія, а буденність.
In [10]:
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 — розмір тесту. Перевіримо її емпірично: розкид по багатьох розбиттях має збігтися з тим, що дає формула.

In [11]:
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 з попр.»,
і він лягає на емпіричне σ майже точно.

Для реального проєкту користуйся звичайною формулою: там тестова вибірка
справді має вдавати нові дані, і поправки не буде.
In [12]:
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 обʼєктів їй дістались.")
No description has been provided for this image
на тесті з 20 обʼєктів результат гуляв від 0.70 до 1.00
це та сама модель. Різниця лише в тому, які 20 обʼєктів їй дістались.

Наслідок для порівняння моделей¶

Формула має корінь у знаменнику, тому щоб зменшити похибку вдвічі, вибірку треба збільшити вчетверо. Порахуємо, скільки об'єктів потрібно для інтервалу ±1 пункт.

In [13]:
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 · нормалізація до розбиття¶

Класична помилка: спершу нормалізуємо всі дані, потім ділимо. Здається невинним — жодних міток, самі числа. Але подивимось, звідки беруться параметри нормалізації.

In [14]:
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 повторів і дивимось на середню різницю та її похибку.

In [15]:
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 (чесно).

In [16]:
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 відсоткових пунктів на даних, де немає ЖОДНОГО сигналу
In [17]:
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("бо шукати нічого. Рожева обіцяє робочу модель там, де її немає.")
No description has been provided for this image
Бірюзова гістограма чесно стоїть навколо 0.50 — модель нічого не знайшла,
бо шукати нічого. Рожева обіцяє робочу модель там, де її немає.

9. Як робити правильно: Pipeline і крос-валідація¶

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

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

  1. відкласти тест першим ділом;
  2. на решті даних крос-валідацією обрати гіперпараметр;
  3. навчити переможця на всіх не-тестових даних;
  4. один раз поміряти на тесті.
In [18]:
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 обʼєктів — не відкриваємо до кінця
In [19]:
# КРОК 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)
In [20]:
# КРОК 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 — разом¶

  1. Постав у split_three частки 0.85 / 0.05. Скільки об'єктів лишилось на тест? Порахуй SE для такої вибірки і скажи, чи можна на ній щось порівнювати.
  2. Запусти accuracy_spread(10). Який розмах точності? Чи бачив ти колись у чужому звіті число, поміряне на десяти об'єктах?

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

  1. Додай у selection_leak третій варіант: відбір ознак усередині Pipeline через SelectKBest. Переконайся, що він дає ті самі чесні 0,50.
  2. Змоделюй витік через дублікати: продублюй кожен об'єкт двічі перед розбиттям і поміряй точність. На скільки вона підскочила і чому.

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

  1. Реалізуй GroupShuffleSplit вручну: розбиття, у якому всі рядки однієї групи (наприклад, одного пацієнта) їдуть в одну вибірку цілком.
  2. Відтвори «прокляття переможця» з другого інтерактиву лекції: згенеруй 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. Крос-валідація замінює валідаційну вибірку чи тестову?

відповідь Валідаційну. Тест відкладають першим, до нього крос-валідація не має доступу. Інакше вибір моделі знову почне підганятись під те саме число, яким ми звітуємо.