Практика · Бустинг¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Працюємо на тій самій дошці оголошень про вживані телефони, що й у лекції, тільки тепер ознак три: рік випуску, обсяг памʼяті й стан за пʼятибальною шкалою. Задача — передбачити ціну.
Що зробимо:
- Напишемо градієнтний бустинг з нуля — циклом на десять рядків.
- Звіримо його з
GradientBoostingRegressor— числа мають зійтися до останнього знаку. - Прожене́мо три швидкості навчання й побачимо компроміс «крок проти кількості дерев».
- Побудуємо криву перенавчання й знайдемо точку ранньої зупинки.
- Порівняємо ланцюжок із випадковим лісом із теми 26 на тих самих даних.
import os
# На маленьких даних розпаралелювання лише заважає: sklearn витрачає більше часу
# на синхронізацію потоків, ніж на саме навчання. Один потік — і зошит летить.
os.environ["OMP_NUM_THREADS"] = "1"
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import (GradientBoostingRegressor, RandomForestRegressor,
HistGradientBoostingRegressor)
генератор = np.random.default_rng(42)
def згенерувати_оголошення(скільки):
'''Дошка оголошень: три ознаки й ціна, у якій сидить випадковий торг продавця.'''
рік_випуску = генератор.integers(2014, 2025, скільки)
памʼять_гб = генератор.choice([64, 128, 256, 512], скільки)
стан = генератор.integers(1, 6, скільки)
# ціна падає з віком телефона по експоненті, а памʼять і стан додають лінійно
ціна = (1900
+ 18500 * 0.8 ** (2025 - рік_випуску)
+ 18 * памʼять_гб
+ 520 * стан
+ генератор.normal(0, 1500, скільки)) # шум: у двох однакових телефонів різні ціни
ознаки = np.column_stack([рік_випуску, памʼять_гб, стан]).astype(float)
return ознаки, ціна
X_навч, y_навч = згенерувати_оголошення(140)
X_тест, y_тест = згенерувати_оголошення(400)
таблиця = pd.DataFrame(X_навч[:5], columns=["рік", "памʼять_гб", "стан"])
таблиця["ціна"] = y_навч[:5].round(0)
print(f"навчальних оголошень: {len(y_навч)}, нових для перевірки: {len(y_тест)}")
print(f"середня ціна на навчальних: {y_навч.mean():.0f} грн")
print(таблиця.to_string(index=False))
навчальних оголошень: 140, нових для перевірки: 400 середня ціна на навчальних: 13630 грн рік памʼять_гб стан ціна 2014.0 512.0 2.0 12480.0 2022.0 64.0 3.0 12256.0 2021.0 128.0 4.0 12544.0 2018.0 128.0 3.0 9143.0 2018.0 512.0 3.0 17930.0
1. З чого починає бустинг¶
Нульовий крок ансамблю — константа: середня ціна навчальної вибірки. Це найгірший осмислений прогноз, і саме від нього ми будемо відштовхуватись. Заміряймо, скільки він коштує в гривнях помилки.
def rmse(факт, прогноз):
'''Корінь із середнього квадрата помилки — та сама метрика, що в лекції.'''
return float(np.sqrt(np.mean((факт - прогноз) ** 2)))
константа = np.full(len(y_тест), y_навч.mean())
помилка_константи = rmse(y_тест, константа)
# для порівняння: одне глибоке дерево, навчене одразу на ціні
глибоке_дерево = DecisionTreeRegressor(max_depth=10, random_state=0).fit(X_навч, y_навч)
помилка_дерева = rmse(y_тест, глибоке_дерево.predict(X_тест))
print(f"середня ціна як прогноз : {помилка_константи:8.0f} грн помилки")
print(f"одне дерево глибини 10 : {помилка_дерева:8.0f} грн помилки")
print(f"рівень шуму в даних : {1500:8.0f} грн — нижче цього не опуститься ніхто")
середня ціна як прогноз : 5300 грн помилки одне дерево глибини 10 : 2257 грн помилки рівень шуму в даних : 1500 грн — нижче цього не опуститься ніхто
2. Бустинг з нуля¶
Увесь алгоритм — це цикл. На кожному кроці ми:
- рахуємо залишки — різницю між фактом і поточним прогнозом ансамблю;
- вчимо мілке дерево передбачати саме ці залишки;
- додаємо його прогноз до ансамблю, помноживши на швидкість навчання.
Більше в градієнтному бустингу для квадратичної втрати немає нічого.
def бустинг_з_нуля(X_навч, y_навч, X_тест, y_тест, кількість_дерев, крок, глибина=3):
'''Градієнтний бустинг для регресії.
y_тест потрібен лише для того, щоб дорогою записати помилку на нових даних —
сама модель його не бачить і нічого з нього не вчить.
'''
прогноз_навч = np.full(len(y_навч), y_навч.mean()) # F0 — константа
прогноз_тест = np.full(len(y_тест), y_навч.mean())
помилка_навч, помилка_тест = [], []
for номер_дерева in range(кількість_дерев):
залишки = y_навч - прогноз_навч # те, що ансамбль ще не пояснив
дерево = DecisionTreeRegressor(max_depth=глибина,
criterion="friedman_mse", # так само рахує sklearn
random_state=0)
дерево.fit(X_навч, залишки) # цільова змінна — залишок, а не ціна
прогноз_навч = прогноз_навч + крок * дерево.predict(X_навч)
прогноз_тест = прогноз_тест + крок * дерево.predict(X_тест)
помилка_навч.append(rmse(y_навч, прогноз_навч))
помилка_тест.append(rmse(y_тест, прогноз_тест))
return прогноз_тест, помилка_навч, помилка_тест
наш_прогноз, наша_навч, наша_тест = бустинг_з_нуля(
X_навч, y_навч, X_тест, y_тест, кількість_дерев=100, крок=0.1, глибина=3)
print("як тане помилка з довжиною ланцюжка:")
print(f"{'дерев':>7} {'на навчальних':>15} {'на нових':>12}")
print(f"{0:>7} {rmse(y_навч, np.full(len(y_навч), y_навч.mean())):>15.0f} {помилка_константи:>12.0f}")
for скільки in [1, 5, 10, 20, 40, 60, 100]:
print(f"{скільки:>7} {наша_навч[скільки - 1]:>15.0f} {наша_тест[скільки - 1]:>12.0f}")
як тане помилка з довжиною ланцюжка:
дерев на навчальних на нових
0 4928 5300
1 4540 4886
5 3327 3578
10 2446 2687
20 1655 1966
40 1290 1749
60 1195 1781
100 1115 1880
3. Перевірка: наша реалізація проти бібліотечної¶
Це найважливіша клітинка зошита. GradientBoostingRegressor зі scikit-learn робить рівно
те саме, що наш цикл вище: починає з середнього, вчить дерева на залишках і додає їх із
множником. Якщо ми нічого не наплутали, прогнози мають збігтися з точністю до
похибки округлення.
бібліотечний = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1,
max_depth=3, random_state=0)
бібліотечний.fit(X_навч, y_навч)
прогноз_бібліотеки = бібліотечний.predict(X_тест)
найбільша_розбіжність = np.abs(наш_прогноз - прогноз_бібліотеки).max()
print(f"найбільша розбіжність на 400 оголошеннях: {найбільша_розбіжність:.2e} грн")
print(f"наш ланцюжок : {rmse(y_тест, наш_прогноз):.4f} грн помилки")
print(f"GradientBoosting : {rmse(y_тест, прогноз_бібліотеки):.4f} грн помилки")
assert np.allclose(наш_прогноз, прогноз_бібліотеки), "розрахунок розійшовся!"
print("✅ збігається — усередині бібліотеки той самий цикл на залишках")
найбільша розбіжність на 400 оголошеннях: 7.28e-12 грн наш ланцюжок : 1880.3336 грн помилки GradientBoosting : 1880.3336 грн помилки ✅ збігається — усередині бібліотеки той самий цикл на залишках
4. Три швидкості навчання¶
Тепер перевіримо головний компроміс лекції: малий крок доходить до нижчого дна, але
вимагає більше дерев. Прожене́мо ланцюжок довжиною 400 при трьох різних learning_rate
і подивимось, де кожен досягає найкращої помилки на нових оголошеннях.
криві_по_кроку = {}
print(f"{'крок':>6} {'найкраща помилка':>18} {'на дереві №':>13} {'при 400 деревах':>17}")
for крок in [0.5, 0.1, 0.03]:
модель = GradientBoostingRegressor(n_estimators=400, learning_rate=крок,
max_depth=3, random_state=0).fit(X_навч, y_навч)
# staged_predict віддає прогноз після кожного дерева — не треба навчати 400 разів
крива = np.array([rmse(y_тест, p) for p in модель.staged_predict(X_тест)])
криві_по_кроку[крок] = крива
найкраще = int(крива.argmin())
print(f"{крок:>6} {крива[найкраще]:>18.0f} {найкраще + 1:>13} {крива[-1]:>17.0f}")
print()
print("менший крок опускає дно нижче, але платить кількістю дерев —")
print("рівно те, що показує другий інтерактив лекції")
крок найкраща помилка на дереві № при 400 деревах
0.5 1819 10 2303
0.1 1744 37 2071
0.03 1732 140 1850 менший крок опускає дно нижче, але платить кількістю дерев — рівно те, що показує другий інтерактив лекції
5. Крива перенавчання¶
У лісі більше дерев ніколи не шкодить. У бустингу — шкодить. Побудуймо обидві помилки як функції довжини ланцюжка й знайдімо точку, після якої додавати дерева стає збитково.
довгий_ланцюжок = GradientBoostingRegressor(n_estimators=400, learning_rate=0.1,
max_depth=3, random_state=0).fit(X_навч, y_навч)
крива_навч = np.array([rmse(y_навч, p) for p in довгий_ланцюжок.staged_predict(X_навч)])
крива_тест = np.array([rmse(y_тест, p) for p in довгий_ланцюжок.staged_predict(X_тест)])
дно = int(крива_тест.argmin())
plt.figure(figsize=(8, 4.2))
plt.plot(np.arange(1, 401), крива_навч, label="на навчальних 140")
plt.plot(np.arange(1, 401), крива_тест, label="на 400 нових")
plt.axvline(дно + 1, linestyle="--", color="gray", label=f"дно: {дно + 1} дерев")
plt.xscale("log")
plt.xlabel("дерев у ланцюжку (логарифмічна вісь)")
plt.ylabel("помилка, грн")
plt.title("Більше дерев — не завжди краще")
plt.legend()
plt.tight_layout()
plt.show()
print(f"найкраща помилка на нових : {крива_тест[дно]:.0f} грн на {дно + 1} деревах")
print(f"помилка на 400 деревах : {крива_тест[-1]:.0f} грн "
f"(на {крива_тест[-1] - крива_тест[дно]:.0f} грн гірше)")
print(f"а на навчальних вона впала з {крива_навч[0]:.0f} до {крива_навч[-1]:.0f} грн — "
f"і жодного разу не розвернулась")
найкраща помилка на нових : 1744 грн на 37 деревах помилка на 400 деревах : 2071 грн (на 326 грн гірше) а на навчальних вона впала з 4540 до 919 грн — і жодного разу не розвернулась
6. Рання зупинка так, як її роблять насправді¶
Вище ми знайшли дно по тестовій вибірці — так робити не можна: підглянувши в тест,
ми втратили чесну оцінку якості. Правильний спосіб — відкласти від навчальних даних окрему
валідаційну частину, стежити за помилкою на ній і зупинятись, коли вона перестала
покращуватись. GradientBoostingRegressor уміє це сам: validation_fraction каже, яку частку
навчальних даних відкласти, а n_iter_no_change — скільки дерев поспіль терпіти без покращення.
модель_із_зупинкою = GradientBoostingRegressor(
n_estimators=400, # верхня межа: далі неї ланцюжок не піде в будь-якому разі
learning_rate=0.1,
max_depth=3,
validation_fraction=0.25, # чверть навчальних оголошень відкладається під контроль
n_iter_no_change=10, # десять дерев поспіль без покращення — і зупиняємось
tol=1e-4,
random_state=0).fit(X_навч, y_навч)
помилка_із_зупинкою = rmse(y_тест, модель_із_зупинкою.predict(X_тест))
print(f"дозволено було 400 дерев, ланцюжок зупинився на {модель_із_зупинкою.n_estimators_}")
print()
print(f"{'варіант':<40}{'помилка на нових, грн':>22}")
print(f"{'рання зупинка по валідації':<40}{помилка_із_зупинкою:>22.0f}")
print(f"{'без зупинки, усі 400 дерев':<40}{крива_тест[-1]:>22.0f}")
print(f"{'ідеал, підглянувши в тест':<40}{крива_тест[дно]:>22.0f}")
print()
print(f"рання зупинка недобрала до ідеалу {помилка_із_зупинкою - крива_тест[дно]:.0f} грн,")
print(f"але виграла {крива_тест[-1] - помилка_із_зупинкою:.0f} грн у ланцюжка без гальм —")
print("і жодного разу не зазирнула в тестову вибірку")
дозволено було 400 дерев, ланцюжок зупинився на 46 варіант помилка на нових, грн рання зупинка по валідації 1794 без зупинки, усі 400 дерев 2071 ідеал, підглянувши в тест 1744 рання зупинка недобрала до ідеалу 50 грн, але виграла 277 грн у ланцюжка без гальм — і жодного разу не зазирнула в тестову вибірку
ліс = RandomForestRegressor(n_estimators=300, random_state=42).fit(X_навч, y_навч)
гістограмний = HistGradientBoostingRegressor(max_iter=200, random_state=42).fit(X_навч, y_навч)
результати = [
("середня ціна (константа)", помилка_константи),
("одне дерево, глибина 10", помилка_дерева),
("випадковий ліс, 300 дерев", rmse(y_тест, ліс.predict(X_тест))),
(f"бустинг із зупинкою, {модель_із_зупинкою.n_estimators_} дерев", помилка_із_зупинкою),
("бустинг, 400 дерев без гальм", крива_тест[-1]),
("HistGradientBoosting, 200 дерев", rmse(y_тест, гістограмний.predict(X_тест))),
]
print(f"{'модель':<36}{'помилка на нових, грн':>22}")
print("-" * 58)
for назва, значення in sorted(результати, key=lambda пара: пара[1]):
print(f"{назва:<36}{значення:>22.0f}")
print("-" * 58)
print("бустинг із чесною ранньою зупинкою попереду лісу.")
print("Той самий бустинг без гальм — позаду лісу. Ліс такої пастки не має взагалі,")
print("і саме тому він лишається надійною базовою лінією.")
модель помилка на нових, грн ---------------------------------------------------------- бустинг із зупинкою, 46 дерев 1794 випадковий ліс, 300 дерев 1883 HistGradientBoosting, 200 дерев 1923 бустинг, 400 дерев без гальм 2071 одне дерево, глибина 10 2257 середня ціна (константа) 5300 ---------------------------------------------------------- бустинг із чесною ранньою зупинкою попереду лісу. Той самий бустинг без гальм — позаду лісу. Ліс такої пастки не має взагалі, і саме тому він лишається надійною базовою лінією.
Завдання¶
🟢 Рівень 1 — База¶
У функції бустинг_з_нуля уже є аргумент глибина. Прожени ланцюжок зі 100 дерев і кроком
0.1 при глибина = 1 (пеньки), 2, 3 і 6. Для кожного варіанта візьми найменшу помилку
на нових оголошеннях за весь ланцюжок і виведи таблицю «глибина → найменша помилка → на якому дереві».
Зроблено, якщо: таблиця з чотирьох рядків надрукована й ти можеш сказати словами, яка глибина виявилась найкращою на цих даних і чому пеньки програють глибині 3.
🟡 Рівень 2 — Плюс¶
Знайди пару (learning_rate, n_estimators), яка дає найменшу помилку на валідаційній
вибірці з розділу 6. Перебери learning_rate зі списку [0.3, 0.1, 0.05, 0.02], для кожного
візьми найкращу кількість дерев за валідацією — і лише для переможця подивись у тест.
Зроблено, якщо: надруковано таблицю з чотирьох рядків (крок, кількість дерев, помилка на валідації) і одне число — помилка переможця на тесті.
🔴 Рівень 3 — Виклик¶
Заміни квадратичну втрату на абсолютну. Для неї антиградієнт — це не залишок, а його
знак: np.sign(y - прогноз). Навчи дерева на знаках, а значення в листках підбирай як
медіану залишків у листку (номер листка дає дерево.apply(X)). Потім зіпсуй пʼять
навчальних цін, помноживши їх на 10, і порівняй обидві версії на тесті.
Зроблено, якщо: обидві версії навчаються, і показано числами, що версія з абсолютною втратою після псування даних тримається краще за квадратичну.