Практика 16 · Підбір гіперпараметрів¶

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

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

Що зробимо:

  1. Зберемо ту саму дошку оголошень, що в темах 10 і 11, і відкладемо тестову частину
  2. Підберемо k руками через крос-валідацію й побудуємо три криві
  3. Звіримо свій підбір із GridSearchCV — числа мають зійтися до останнього знака
  4. Проженемо RandomizedSearchCV із учетверо меншим бюджетом і порівняємо
  5. Побачимо оптимістичне зміщення у власних числах — двома різними способами
  6. Закінчимо вкладеною крос-валідацією й чесним числом

1. Дані: та сама дошка оголошень¶

Генератор той самий, що в темі kNN, із тим самим np.random.default_rng(42) — тому всі числа порівнянні між темами.

Ознак дві: відносна_ціна (запитувана ціна, поділена на типову ринкову) і вік_акаунта в днях. Мітка шахрайство — 1, якщо оголошення виявилось приманкою.

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

from sklearn.model_selection import (train_test_split, StratifiedKFold, cross_val_score,
                                     cross_validate, GridSearchCV, RandomizedSearchCV)
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score

генератор = np.random.default_rng(42)
КІЛЬКІСТЬ = 800

# скільки коштує модель у базовій комплектації
базова_ціна_моделі = {"iPhone 12": 12000, "Samsung S21": 9000, "Xiaomi Note 11": 5000}
надбавка_за_памʼять = {64: 0.90, 128: 1.00, 256: 1.15}
надбавка_за_стан = {3: 0.85, 4: 1.00, 5: 1.10}

модель = генератор.choice(list(базова_ціна_моделі), КІЛЬКІСТЬ)
рік = генератор.integers(2019, 2024, КІЛЬКІСТЬ)
памʼять_гб = генератор.choice([64, 128, 256], КІЛЬКІСТЬ)
стан = генератор.choice([3, 4, 5], КІЛЬКІСТЬ)
ємність_батареї = генератор.uniform(70, 100, КІЛЬКІСТЬ).round(1)

# типова ринкова ціна: модель × памʼять × стан × свіжість року × стан батареї
типова_ціна = np.array([базова_ціна_моделі[m] for m in модель], dtype=float)
типова_ціна *= np.array([надбавка_за_памʼять[p] for p in памʼять_гб])
типова_ціна *= np.array([надбавка_за_стан[s] for s in стан])
типова_ціна *= 1 + 0.08 * (рік - 2019)
типова_ціна *= 0.85 + 0.01 * (ємність_батареї - 70)

# дві ознаки, за якими ловитимемо шахрайство
відносна_ціна = генератор.uniform(0.30, 1.45, КІЛЬКІСТЬ)
вік_акаунта = генератор.integers(0, 366, КІЛЬКІСТЬ)

дешева_приманка = (відносна_ціна < 0.85) & (вік_акаунта < 150)
преміум_приманка = (відносна_ціна >= 0.95) & (відносна_ціна <= 1.28) & (вік_акаунта < 48)
шахрайство = (дешева_приманка | преміум_приманка).astype(int)

# 6% міток перевертаємо: без шуму вибір k узагалі не мав би значення
помилкова_розмітка = генератор.random(КІЛЬКІСТЬ) < 0.06
шахрайство[помилкова_розмітка] = 1 - шахрайство[помилкова_розмітка]

таблиця = pd.DataFrame({
    "відносна_ціна": відносна_ціна,
    "вік_акаунта": вік_акаунта,
    "шахрайство": шахрайство,
})

print(таблиця.head(5).round(3).to_string(index=False))
print(f"\nусього оголошень: {len(таблиця)}")
print(f"шахрайських: {таблиця['шахрайство'].sum()} ({таблиця['шахрайство'].mean():.1%})")
 відносна_ціна  вік_акаунта  шахрайство
         0.986          196           0
         1.211          278           0
         1.166          113           0
         1.326          337           0
         1.227          297           0

усього оголошень: 800
шахрайських: 218 (27.3%)

2. Три ролі даних¶

Розподіл ролей із теми Train / Validation / Test:

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

stratify тримає однакову частку шахрайських оголошень в обох частинах.

In [2]:
X = таблиця[["відносна_ціна", "вік_акаунта"]].to_numpy(dtype=float)
y = таблиця["шахрайство"].to_numpy()

X_навч, X_тест, y_навч, y_тест = train_test_split(
    X, y, test_size=0.35, random_state=42, stratify=y)

базова_точність = 1 - y_тест.mean()

print(f"навчальна частина: {len(X_навч)} оголошень, шахрайських {y_навч.mean():.1%}")
print(f"тестова частина:   {len(X_тест)} оголошень, шахрайських {y_тест.mean():.1%}")
print(f"\nбазова лінія «усі оголошення чесні»: точність {базова_точність:.4f}")
навчальна частина: 520 оголошень, шахрайських 27.3%
тестова частина:   280 оголошень, шахрайських 27.1%

базова лінія «усі оголошення чесні»: точність 0.7286

Параметр і гіперпараметр — на живій моделі¶

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

In [3]:
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

# та сама підвибірка, що в лекції: 520 оголошень зі збереженням частки шахрайських
X_нав, _, y_нав, _ = train_test_split(X, y, train_size=520, random_state=42, stratify=y)

for C in (1.0, 0.01):
    модель = make_pipeline(StandardScaler(), LogisticRegression(C=C))
    модель.fit(X_нав, y_нав)
    ваги = модель.named_steps["logisticregression"]
    оцінка = cross_val_score(модель, X_нав, y_нав, cv=StratifiedKFold(
        n_splits=5, shuffle=True, random_state=42)).mean()
    print(f"C = {C}")
    print(f"  w0 = {ваги.intercept_[0]:+.3f}   "
          f"w_ціна = {ваги.coef_[0][0]:+.3f}   w_вік = {ваги.coef_[0][1]:+.3f}")
    print(f"  оцінка на крос-валідації: {оцінка:.3f}\n")

# числа лекції не мають розходитися з цими — інакше падаємо голосно
перевірка = make_pipeline(StandardScaler(), LogisticRegression(C=1.0)).fit(X_нав, y_нав)
w = перевірка.named_steps["logisticregression"]
assert np.allclose([w.intercept_[0], w.coef_[0][0], w.coef_[0][1]],
                   [-1.824, -1.231, -1.917], atol=5e-4), "ваги розійшлися з лекцією"
print("ваги збігаються з тими, що наведені в лекції")
C = 1.0
  w0 = -1.824   w_ціна = -1.231   w_вік = -1.917
  оцінка на крос-валідації: 0.892

C = 0.01
  w0 = -1.098   w_ціна = -0.388   w_вік = -0.634
  оцінка на крос-валідації: 0.767

ваги збігаються з тими, що наведені в лекції

3. Що саме ми підбираємо¶

Модель — конвеєр із двох кроків: StandardScaler зрівнює масштаби ознак (без цього вік акаунта в днях розчавить відносну ціну — див. тему kNN), а далі голосують сусіди.

Конвеєр тут не косметика, а вимога чесності: усередині крос-валідації fit викликається окремо на кожній навчальній частині фолда, тож середнє й відхилення рахуються без валідаційних рядків. Порахувати їх один раз на всій таблиці означало б витік даних.

n_neighbors — гіперпараметр: його треба задати до навчання.

In [4]:
def конвеєр(k):
    # уся модель цілком: спершу зрівняти масштаби ознак, потім голосування сусідів
    return make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=k))


# одне й те саме розбиття на фолди для всіх кандидатів — інакше порівняння нечесне
розбиття = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

оцінки_по_фолдах = cross_val_score(конвеєр(9), X_навч, y_навч, cv=розбиття, scoring="accuracy")

print("k = 9, точність на кожному з п'яти фолдів:")
print("  " + "  ".join(f"{о:.4f}" for о in оцінки_по_фолдах))
print(f"\nсереднє: {оцінки_по_фолдах.mean():.4f}   розкид: {оцінки_по_фолдах.std():.4f}")
k = 9, точність на кожному з п'яти фолдів:
  0.9423  0.9519  0.9231  0.8942  0.9135

середнє: 0.9250   розкид: 0.0205

4. Підбір k руками¶

Тепер найголовніша клітинка теми. Проженемо всі непарні k від 1 до 51 і порахуємо для кожного три числа:

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

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

In [5]:
значення_k = list(range(1, 52, 2))
рядки = []

for k in значення_k:
    оцінки = cross_val_score(конвеєр(k), X_навч, y_навч, cv=розбиття, scoring="accuracy")
    модель_k = конвеєр(k)
    модель_k.fit(X_навч, y_навч)
    рядки.append({
        "k": k,
        "навчання": accuracy_score(y_навч, модель_k.predict(X_навч)),
        "CV": оцінки.mean(),
        "розкид_CV": оцінки.std(),
        "тест": accuracy_score(y_тест, модель_k.predict(X_тест)),
    })

крива = pd.DataFrame(рядки)
print(крива.head(8).round(4).to_string(index=False))
print("...")
print(крива.tail(3).round(4).to_string(index=False))
 k  навчання     CV  розкид_CV   тест
 1    1.0000 0.8885     0.0331 0.8750
 3    0.9538 0.9404     0.0128 0.9071
 5    0.9519 0.9327     0.0122 0.9143
 7    0.9404 0.9385     0.0156 0.9143
 9    0.9404 0.9250     0.0205 0.9214
11    0.9404 0.9269     0.0144 0.9179
13    0.9327 0.9250     0.0205 0.9143
15    0.9308 0.9212     0.0165 0.9214
...
 k  навчання     CV  розкид_CV   тест
47    0.8981 0.8885     0.0077 0.9000
49    0.8942 0.8865     0.0112 0.9000
51    0.8942 0.8827     0.0094 0.8929

Три різні відповіді на питання «яке k найкраще»¶

Подивись, куди вказує кожна колонка окремо. Це і є зміст розділу 2 лекції.

In [6]:
за_навчанням = крива.loc[крива["навчання"].idxmax()]
за_cv = крива.loc[крива["CV"].idxmax()]
за_тестом = крива.loc[крива["тест"].idxmax()]

print(f"якби обирали за помилкою на НАВЧАННІ: k = {int(за_навчанням['k']):2d}, "
      f"точність на навчанні {за_навчанням['навчання']:.4f}")
print(f"   ...а на відкладених даних воно дає {за_навчанням['тест']:.4f} — "
      f"найгірше з усього стовпця")
print()
print(f"якби обирали за КРОС-ВАЛІДАЦІЄЮ:      k = {int(за_cv['k']):2d}, "
      f"CV {за_cv['CV']:.4f} ± {за_cv['розкид_CV']:.4f}")
print(f"   ...а на відкладених даних {за_cv['тест']:.4f}")
print()
print(f"заднім числом найкращим на ТЕСТІ був: k = {int(за_тестом['k']):2d} "
      f"з точністю {за_тестом['тест']:.4f}")
print("   але це число нам недоступне: підглядати в тест під час підбору не можна")
якби обирали за помилкою на НАВЧАННІ: k =  1, точність на навчанні 1.0000
   ...а на відкладених даних воно дає 0.8750 — найгірше з усього стовпця

якби обирали за КРОС-ВАЛІДАЦІЄЮ:      k =  3, CV 0.9404 ± 0.0128
   ...а на відкладених даних 0.9071

заднім числом найкращим на ТЕСТІ був: k = 17 з точністю 0.9286
   але це число нам недоступне: підглядати в тест під час підбору не можна

Точність на навчанні при k = 1 дорівнює одиниці, бо найближчий сусід навчального оголошення — воно саме. Помилка на навчанні монотонно спадає зі складністю моделі, а 1/k і є складність, — тому вибір за нею завжди впирається в k = 1.

In [7]:
plt.figure(figsize=(8.5, 4.4))
plt.plot(крива["k"], крива["навчання"], label="навчальна вибірка")
plt.plot(крива["k"], крива["CV"], label="крос-валідація", linewidth=2.2)
plt.plot(крива["k"], крива["тест"], label="відкладені 280")
plt.axhline(базова_точність, linestyle=":", color="gray", label="«усі чесні»")
plt.axvline(int(за_cv["k"]), linestyle="--", color="gray",
            label=f"переможець CV: k = {int(за_cv['k'])}")
plt.xlabel("k — скільки сусідів голосує")
plt.ylabel("точність")
plt.title("Одна нечесна крива і дві чесні")
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print("крива навчання має максимум на самому краю, обидві чесні — ні")
No description has been provided for this image
крива навчання має максимум на самому краю, обидві чесні — ні

5. Звірка з GridSearchCV¶

Найцінніша клітинка практики: переконатись, що всередині бібліотеки немає магії. GridSearchCV робить рівно те, що ми щойно зробили циклом, — і має дати те саме число до останнього знака.

Зверни увагу на назву параметра: kneighborsclassifier__n_neighbors. Два підкреслення означають «крок конвеєра, а всередині нього — аргумент».

In [8]:
сітка_по_k = {"kneighborsclassifier__n_neighbors": значення_k}

пошук = GridSearchCV(конвеєр(1), сітка_по_k, cv=розбиття, scoring="accuracy")
початок = time.time()
пошук.fit(X_навч, y_навч)
секунд = time.time() - початок

наше_k = int(за_cv["k"])
бібліотечне_k = пошук.best_params_["kneighborsclassifier__n_neighbors"]

assert бібліотечне_k == наше_k, "переможці розійшлися!"
assert np.isclose(пошук.best_score_, за_cv["CV"]), "оцінки переможця розійшлися!"
print("✅ збігається: наш ручний підбір і GridSearchCV дали той самий результат")
print(f"   k = {бібліотечне_k}, оцінка {пошук.best_score_:.6f}")
print(f"   моделей навчено: {len(значення_k)} × 5 фолдів = {len(значення_k) * 5}, "
      f"плюс одна фінальна на всіх даних")
print(f"   час: {секунд:.1f} с")
✅ збігається: наш ручний підбір і GridSearchCV дали той самий результат
   k = 3, оцінка 0.940385
   моделей навчено: 26 × 5 фолдів = 130, плюс одна фінальна на всіх даних
   час: 0.6 с

6. Сітка проти випадкового пошуку¶

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

Повна сітка перебирає всі 26 × 2 × 2 = 104 комбінації. Випадковий пошук робить 26 спроб — рівно стільки, скільки коштувала одновимірна сітка з розділу 5.

In [9]:
велика_сітка = {
    "kneighborsclassifier__n_neighbors": значення_k,
    "kneighborsclassifier__weights": ["uniform", "distance"],
    "kneighborsclassifier__p": [1, 2],          # 1 — манхеттенська, 2 — евклідова
}
скільки_комбінацій = len(значення_k) * 2 * 2

повний_перебір = GridSearchCV(конвеєр(1), велика_сітка, cv=розбиття, scoring="accuracy")
початок = time.time()
повний_перебір.fit(X_навч, y_навч)
час_сітки = time.time() - початок

випадковий = RandomizedSearchCV(конвеєр(1), велика_сітка, n_iter=26, cv=розбиття,
                                scoring="accuracy", random_state=42)
початок = time.time()
випадковий.fit(X_навч, y_навч)
час_випадкового = time.time() - початок

# і одразу подивимось, що обидва переможці дають на відкладених оголошеннях
тест_сітки = accuracy_score(y_тест, повний_перебір.predict(X_тест))
тест_випадкового = accuracy_score(y_тест, випадковий.predict(X_тест))

print(f"ПОВНА СІТКА · комбінацій {скільки_комбінацій} × 5 фолдів = "
      f"{скільки_комбінацій * 5} навчань, {час_сітки:.1f} с")
print(f"   переможець: {повний_перебір.best_params_}")
print(f"   оцінка при підборі: {повний_перебір.best_score_:.4f}, "
      f"на відкладених: {тест_сітки:.4f}")
print()
print(f"ВИПАДКОВИЙ ПОШУК · спроб 26 × 5 фолдів = {26 * 5} навчань, "
      f"{час_випадкового:.1f} с")
print(f"   переможець: {випадковий.best_params_}")
print(f"   оцінка при підборі: {випадковий.best_score_:.4f}, "
      f"на відкладених: {тест_випадкового:.4f}")
print()
print(f"навчань менше у {скільки_комбінацій / 26:.0f} рази, "
      f"а знайдене число — {'те саме' if np.isclose(повний_перебір.best_score_, випадковий.best_score_) else 'інше'}")
ПОВНА СІТКА · комбінацій 104 × 5 фолдів = 520 навчань, 2.4 с
   переможець: {'kneighborsclassifier__n_neighbors': 17, 'kneighborsclassifier__p': 1, 'kneighborsclassifier__weights': 'distance'}
   оцінка при підборі: 0.9404, на відкладених: 0.9321

ВИПАДКОВИЙ ПОШУК · спроб 26 × 5 фолдів = 130 навчань, 0.7 с
   переможець: {'kneighborsclassifier__weights': 'distance', 'kneighborsclassifier__p': 1, 'kneighborsclassifier__n_neighbors': 17}
   оцінка при підборі: 0.9404, на відкладених: 0.9321

навчань менше у 4 рази, а знайдене число — те саме

Причина не у везінні. Із трьох наших ручок вирішує переважно одна — n_neighbors. Сітка ділить бюджет порівну між осями й перевіряє кожне значення k по чотири рази (з різними weights і p), а випадковий пошук на тих самих 26 спробах чіпляє 26 різних комбінацій одразу. Коли ручок п'ять, а важать дві, ця різниця стає десятикратною.

7. Оптимістичне зміщення: погляд перший¶

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

In [10]:
переможець = конвеєр(наше_k)
переможець.fit(X_навч, y_навч)
точність_переможця = accuracy_score(y_тест, переможець.predict(X_тест))

print(f"k = {наше_k}")
print(f"  оцінка, за якою обирали (CV):      {за_cv['CV']:.4f}")
print(f"  точність на відкладених 280:       {точність_переможця:.4f}")
print(f"  розрив:                            {за_cv['CV'] - точність_переможця:+.4f}")

# а тепер те саме для всіх кандидатів разом — щоб побачити, що переможець особливий
крива["розрив"] = крива["CV"] - крива["тест"]
print(f"\nсередній розрив «CV мінус тест» по всіх {len(крива)} кандидатах: "
      f"{крива['розрив'].mean():+.4f}")
print("тобто типовий кандидат оцінений навіть трохи песимістично,")
print("а переможець — навпаки, помітно оптимістично")
k = 3
  оцінка, за якою обирали (CV):      0.9404
  точність на відкладених 280:       0.9071
  розрив:                            +0.0332

середній розрив «CV мінус тест» по всіх 26 кандидатах: -0.0062
тобто типовий кандидат оцінений навіть трохи песимістично,
а переможець — навпаки, помітно оптимістично

8. Звідки береться завищення: чиста арифметика¶

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

Це та сама модель, що й в інтерактиві 1 лекції, тільки в чотирьох рядках NumPy.

In [11]:
симуляція = np.random.default_rng(42)
СПРАВЖНЯ_ЯКІСТЬ = 0.900
ШУМ = 0.020            # реалістичний розкид оцінки 5-фолдової CV на кількох сотнях обʼєктів
ПОВТОРІВ = 20000

print("кандидатів   найкраща оцінка   завищення")
for m in [1, 2, 5, 10, 20, 50, 100]:
    # рядок = один незалежний повтор пошуку, колонка = один кандидат
    оцінки = СПРАВЖНЯ_ЯКІСТЬ + ШУМ * симуляція.standard_normal((ПОВТОРІВ, m))
    найкращі = оцінки.max(axis=1)
    print(f"{m:>10}   {найкращі.mean():>15.4f}   {найкращі.mean() - СПРАВЖНЯ_ЯКІСТЬ:>+9.4f}")

print("\nжоден кандидат не кращий за інших — і все одно «переможець» виглядає кращим")
print("завищення росте з кількістю кандидатів і прямо пропорційне шуму оцінки")
кандидатів   найкраща оцінка   завищення
         1            0.9001     +0.0001
         2            0.9112     +0.0112
         5            0.9233     +0.0233
        10            0.9307     +0.0307
        20            0.9374     +0.0374
        50            0.9450     +0.0450
       100            0.9501     +0.0501

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

9. Оптимістичне зміщення: чесний вимір на своїх даних¶

Один розрив у розділі 7 ще нічого не доводить: у ньому змішані дві причини — завищення від вибору й звичайна різниця між двома вибірками. Розділимо їх.

Візьмемо дванадцять різних розбиттів дошки й для кожного порахуємо розрив «оцінка мінус правда» двічі:

  • для k, обраного крос-валідацією;
  • для k = 9, зафіксованого заздалегідь — його ніхто не обирав.

Різниця між цими двома розривами і є чистою ціною вибору.

In [12]:
сітка_дешевша = list(range(1, 52, 4))       # 13 значень замість 26 — щоб рахувалось швидко
ФІКСОВАНЕ_K = 9

розриви_обраного = []
розриви_фіксованого = []
обрані_k = []

for сід in range(12):
    X_a, X_b, y_a, y_b = train_test_split(X, y, test_size=0.35, random_state=сід, stratify=y)
    фолди = StratifiedKFold(n_splits=5, shuffle=True, random_state=сід)

    оцінки_cv = []
    оцінки_відкладені = []
    for k in сітка_дешевша:
        оцінки_cv.append(cross_val_score(конвеєр(k), X_a, y_a, cv=фолди).mean())
        м = конвеєр(k)
        м.fit(X_a, y_a)
        оцінки_відкладені.append(accuracy_score(y_b, м.predict(X_b)))

    номер_переможця = int(np.argmax(оцінки_cv))
    обрані_k.append(сітка_дешевша[номер_переможця])
    розриви_обраного.append(оцінки_cv[номер_переможця] - оцінки_відкладені[номер_переможця])

    номер_фіксованого = сітка_дешевша.index(ФІКСОВАНЕ_K)
    розриви_фіксованого.append(оцінки_cv[номер_фіксованого] - оцінки_відкладені[номер_фіксованого])

середній_обраного = float(np.mean(розриви_обраного))
середній_фіксованого = float(np.mean(розриви_фіксованого))

print(f"розрив для фіксованого k = {ФІКСОВАНЕ_K}: {середній_фіксованого:+.4f}")
print(f"розрив для обраного за CV:    {середній_обраного:+.4f}")
print(f"\nчиста ціна вибору:            {середній_обраного - середній_фіксованого:+.4f}")
print(f"\nобрані k по дванадцяти розбиттях: {обрані_k}")
розрив для фіксованого k = 9: -0.0071
розрив для обраного за CV:    -0.0030

чиста ціна вибору:            +0.0041

обрані k по дванадцяти розбиттях: [9, 13, 9, 5, 5, 5, 9, 5, 13, 9, 9, 13]

Чотири тисячних — і це важливо не лише як число, а як пояснення.

Тринадцять значень k — це не тринадцять незалежних кандидатів: модель із k = 21 і модель із k = 25 майже однакові, тому й помилки в них спільні. Розмір завищення визначається кількістю незалежних кандидатів і шумністю оцінки, а не довжиною списку. Небезпечна ситуація — багато незв'язаних ручок, маленька валідаційна вибірка й шумна метрика; тоді тисячні перетворюються на відсотки.

Зверни увагу й на список обраних k: він різний від розбиття до розбиття. «Найкраще k» не є властивістю задачі.

10. Вкладена крос-валідація¶

І нарешті чесне число. Два цикли: зовнішній ділить усі 800 оголошень на 5 фолдів і тільки міряє; внутрішній живе всередині кожного зовнішнього фолда й тільки обирає k. Вибір і оцінка ніколи не бачать одних і тих самих даних.

У scikit-learn це пишеться в один рядок: GridSearchCV сам стає моделлю, яку передають у cross_validate.

In [13]:
зовнішні_фолди = StratifiedKFold(n_splits=5, shuffle=True, random_state=7)
внутрішні_фолди = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

підбір_усередині = GridSearchCV(конвеєр(1), сітка_по_k,
                                cv=внутрішні_фолди, scoring="accuracy")

початок = time.time()
результат = cross_validate(підбір_усередині, X, y, cv=зовнішні_фолди,
                           scoring="accuracy", return_estimator=True)
секунд = time.time() - початок

внутрішні_оцінки = [оцінювач.best_score_ for оцінювач in результат["estimator"]]
обрані = [оцінювач.best_params_["kneighborsclassifier__n_neighbors"]
          for оцінювач in результат["estimator"]]

print("оцінки по зовнішніх фолдах:", np.round(результат["test_score"], 4))
print(f"\nчесна вкладена оцінка:        {результат['test_score'].mean():.4f} "
      f"± {результат['test_score'].std():.4f}")
print(f"середня внутрішня best_score_: {np.mean(внутрішні_оцінки):.4f} "
      f"(те, що показав би звичайний GridSearchCV)")
print(f"різниця:                       "
      f"{np.mean(внутрішні_оцінки) - результат['test_score'].mean():+.4f}")
print(f"\nобрані k по зовнішніх фолдах: {обрані}")
print(f"навчань: 5 × {len(значення_k)} × 5 = {5 * len(значення_k) * 5}, час {секунд:.1f} с")
оцінки по зовнішніх фолдах: [0.9312 0.9312 0.9125 0.9375 0.925 ]

чесна вкладена оцінка:        0.9275 ± 0.0085
середня внутрішня best_score_: 0.9344 (те, що показав би звичайний GridSearchCV)
різниця:                       +0.0069

обрані k по зовнішніх фолдах: [7, 5, 9, 19, 9]
навчань: 5 × 26 × 5 = 650, час 4.0 с

11. Підсумок числами¶

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

In [14]:
підсумок = pd.DataFrame([
    {"число": "точність на навчанні при k = 1",
     "значення": крива.loc[0, "навчання"],
     "чи можна вірити": "ні — модель міряє себе на власних даних"},
    {"число": "найкраща оцінка CV при підборі",
     "значення": за_cv["CV"],
     "чи можна вірити": "ні — це максимум зашумлених оцінок"},
    {"число": "переможець на відкладених 280",
     "значення": точність_переможця,
     "чи можна вірити": "так — тест не брав участі у виборі"},
    {"число": "вкладена крос-валідація",
     "значення": результат["test_score"].mean(),
     "чи можна вірити": "так — вибір і оцінка на різних даних"},
    {"число": "базова лінія «усі чесні»",
     "значення": базова_точність,
     "чи можна вірити": "так — з нею й порівнюємо"},
])

print(підсумок.round(4).to_string(index=False))
print("\nдва верхні числа — службові: вони потрібні всередині процедури,")
print("але у звіт про якість моделі не йдуть ніколи")
                         число  значення                         чи можна вірити
точність на навчанні при k = 1    1.0000 ні — модель міряє себе на власних даних
найкраща оцінка CV при підборі    0.9404      ні — це максимум зашумлених оцінок
 переможець на відкладених 280    0.9071      так — тест не брав участі у виборі
       вкладена крос-валідація    0.9275    так — вибір і оцінка на різних даних
      базова лінія «усі чесні»    0.7286                так — з нею й порівнюємо

два верхні числа — службові: вони потрібні всередині процедури,
але у звіт про якість моделі не йдуть ніколи

Завдання¶

🟢 Рівень 1 — База¶

Додай у сітку розділу 6 четверту ручку — metric зі значеннями ["euclidean", "manhattan", "chebyshev"] — і порахуй, у скільки разів зросла кількість навчань. Перевір це число двічі: формулою «комбінації × фолди» і довжиною повний_перебір.cv_results_["params"].

Зроблено, якщо: обидва способи дали однакове число, а в тексті пояснено, чому додавання однієї ручки множить роботу, а не додає до неї.

🟡 Рівень 2 — Плюс¶

Прожени розділ 8 із трьома різними значеннями ШУМ — 0.005, 0.020, 0.050 — і побудуй графік «завищення від кількості кандидатів» для всіх трьох.

Зроблено, якщо: на графіку три криві, і в підписі сказано, у скільки разів завищення при ШУМ = 0.050 більше, ніж при ШУМ = 0.005, при однаковій кількості кандидатів.

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

Напиши вкладену крос-валідацію вручну, без cross_validate і без GridSearchCV: двома вкладеними циклами по StratifiedKFold. Зовнішній цикл відкладає фолд, внутрішній підбирає k на решті, потім модель із обраним k навчається на всій внутрішній частині й міряється на відкладеному фолді.

Зроблено, якщо: твоє середнє відрізняється від числа з розділу 10 не більш ніж на 0.005 при тих самих random_state, і ти можеш назвати причину залишкової різниці (підказка: подивись, що саме cross_validate робить із конвеєром перед кожним зовнішнім фолдом).