Практика · Бустинг¶

Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md

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

Що зробимо:

  1. Напишемо градієнтний бустинг з нуля — циклом на десять рядків.
  2. Звіримо його з GradientBoostingRegressor — числа мають зійтися до останнього знаку.
  3. Прожене́мо три швидкості навчання й побачимо компроміс «крок проти кількості дерев».
  4. Побудуємо криву перенавчання й знайдемо точку ранньої зупинки.
  5. Порівняємо ланцюжок із випадковим лісом із теми 26 на тих самих даних.
In [1]:
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. З чого починає бустинг¶

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

In [2]:
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. Бустинг з нуля¶

Увесь алгоритм — це цикл. На кожному кроці ми:

  1. рахуємо залишки — різницю між фактом і поточним прогнозом ансамблю;
  2. вчимо мілке дерево передбачати саме ці залишки;
  3. додаємо його прогноз до ансамблю, помноживши на швидкість навчання.

Більше в градієнтному бустингу для квадратичної втрати немає нічого.

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

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

In [5]:
криві_по_кроку = {}
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. Крива перенавчання¶

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

In [6]:
довгий_ланцюжок = 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"і жодного разу не розвернулась")
No description has been provided for this image
найкраща помилка на нових : 1744 грн на 37 деревах
помилка на 400 деревах    : 2071 грн (на 326 грн гірше)
а на навчальних вона впала з 4540 до 919 грн — і жодного разу не розвернулась

6. Рання зупинка так, як її роблять насправді¶

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

In [7]:
модель_із_зупинкою = 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 грн у ланцюжка без гальм —
і жодного разу не зазирнула в тестову вибірку

7. Ланцюжок проти лісу¶

І нарешті — очна ставка з випадковим лісом із теми 26 на тих самих даних. Плюс HistGradientBoostingRegressor: це вбудована в scikit-learn гістограмна реалізація бустингу — той самий алгоритм, тільки значно швидший на великих таблицях.

In [8]:
ліс = 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, і порівняй обидві версії на тесті.

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