Практика 16 · Підбір гіперпараметрів¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє:homework.md· 🧪 Тест:quiz.html
У лекції ми розібрали, чому гіперпараметр не можна вивчити разом із вагами й чому найкраща оцінка при підборі завищена. Тут ми зробимо це руками — так, щоб кожне число з лекції з'явилось у тебе на екрані.
Що зробимо:
- Зберемо ту саму дошку оголошень, що в темах 10 і 11, і відкладемо тестову частину
- Підберемо
kруками через крос-валідацію й побудуємо три криві - Звіримо свій підбір із
GridSearchCV— числа мають зійтися до останнього знака - Проженемо
RandomizedSearchCVіз учетверо меншим бюджетом і порівняємо - Побачимо оптимістичне зміщення у власних числах — двома різними способами
- Закінчимо вкладеною крос-валідацією й чесним числом
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 тримає однакову частку шахрайських оголошень в обох частинах.
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 у сто разів і подивимось, що станеться
з вагами й з якістю.
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 — гіперпараметр: його треба задати до навчання.
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 оголошеннях — правда, у яку ми поки що не підглядаємо.
Третю колонку рахуємо лише для того, щоб потім показати, наскільки перша й друга розходяться з нею. У справжньому проєкті її на цьому етапі не було б.
значення_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 лекції.
за_навчанням = крива.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.
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("крива навчання має максимум на самому краю, обидві чесні — ні")
крива навчання має максимум на самому краю, обидві чесні — ні
5. Звірка з GridSearchCV¶
Найцінніша клітинка практики: переконатись, що всередині бібліотеки немає магії.
GridSearchCV робить рівно те, що ми щойно зробили циклом, — і має дати те саме
число до останнього знака.
Зверни увагу на назву параметра: kneighborsclassifier__n_neighbors. Два підкреслення
означають «крок конвеєра, а всередині нього — аргумент».
сітка_по_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.
велика_сітка = {
"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. Оптимістичне зміщення: погляд перший¶
Відкриваємо тестову частину — і порівнюємо два числа для однієї й тієї самої моделі: оцінку, за якою ми її обрали, і точність на даних, що не брали участі в підборі.
переможець = конвеєр(наше_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.
симуляція = 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, зафіксованого заздалегідь — його ніхто не обирав.
Різниця між цими двома розривами і є чистою ціною вибору.
сітка_дешевша = 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.
зовнішні_фолди = 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. Підсумок числами¶
Зберемо все, що ми виміряли, в одну таблицю — і подивимось, які з цих чисел можна писати у звіт, а які ні.
підсумок = 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 робить із конвеєром перед
кожним зовнішнім фолдом).