Практика 12 · Перенавчання й недонавчання¶

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

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

Що зробимо:

  1. Породимо ціни телефонів із відомої нам залежності плюс шум
  2. Навчимо поліноми різних степенів і перевіримо свою реалізацію бібліотечною
  3. Побудуємо таблицю «степінь → помилка на навчанні → помилка на тесті»
  4. Знайдемо точку перелому — момент, з якого починається перенавчання
  5. Повторимо все на вдесятеро більшій вибірці й побачимо, що перелом зсунувся
  6. Наостанок подивимось на недонавчання в чистому вигляді

Числа тут не збігатимуться з лекцією до гривні: там дані породжував генератор браузера, тут — NumPy. Явище й усі висновки ті самі.

0. Світ, у якому ми знаємо істину¶

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

До цієї закономірності додається шум — усе, що не пояснюється роком: подряпина на корпусі, продавець поспішає, продавець поставив із запасом.

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

In [1]:
import numpy as np
import matplotlib.pyplot as plt
from numpy.polynomial import legendre

ПЕРШИЙ_РІК = 2010          # найстаріший телефон на дошці
ОСТАННІЙ_РІК = 2025        # найновіший
РОЗКИД_ЦІН = 750           # шум: стандартне відхилення в гривнях


def справжня_ціна(рік):
    """Закономірність, якої модель не знає: здешевлення з віком плюс горб на 2019."""
    здешевлення = 1900 + 18500 * 0.80 ** (ОСТАННІЙ_РІК - рік)
    вдала_серія = 1500 * np.exp(-((рік - 2019) / 1.3) ** 2)
    return здешевлення + вдала_серія


def згенерувати_оголошення(rng, скільки):
    """Оголошення = справжня ціна свого року плюс випадкове відхилення."""
    роки = rng.uniform(ПЕРШИЙ_РІК, ОСТАННІЙ_РІК, скільки)
    ціни = справжня_ціна(роки) + rng.normal(0, РОЗКИД_ЦІН, скільки)
    return np.sort(роки), ціни[np.argsort(роки)]


rng = np.random.default_rng(42)
роки_навчання, ціни_навчання = згенерувати_оголошення(rng, 16)
роки_тесту, ціни_тесту = згенерувати_оголошення(rng, 300)

print(f"навчальних оголошень: {len(роки_навчання)}")
print(f"тестових оголошень:   {len(роки_тесту)}")
print(f"\nперші пʼять навчальних оголошень:")
for рік, ціна in zip(роки_навчання[:5], ціни_навчання[:5]):
    print(f"  {рік:.2f} року — {ціна:8.0f} ₴   (типова ціна цього року: {справжня_ціна(рік):.0f} ₴)")
навчальних оголошень: 16
тестових оголошень:   300

перші пʼять навчальних оголошень:
  2011.41 року —     2653 ₴   (типова ціна цього року: 2792 ₴)
  2011.92 року —     2578 ₴   (типова ціна цього року: 2899 ₴)
  2013.41 року —     2988 ₴   (типова ціна цього року: 3293 ₴)
  2015.56 року —     4552 ₴   (типова ціна цього року: 4153 ₴)
  2016.58 року —     4056 ₴   (типова ціна цього року: 4775 ₴)

1. Модель: поліном, але обережно¶

Складність моделі регулює степінь полінома. Тут є технічна пастка, про яку варто знати заздалегідь.

Якщо будувати ознаки як сирі степені року — [1, рік, рік², …, рік¹⁵] — то при роках близько 2020 значення рік¹⁵ має порядок 10⁴⁹. Стовпці матриці стають майже однаковими, система погано обумовлена, і на високих степенях ми отримаємо не перенавчання, а чисельне сміття.

Рятує це дві дії разом: спершу стиснути роки у відрізок [-1, 1], потім узяти не сирі степені, а поліноми Лежандра — вони задають той самий простір функцій, але їхні стовпці майже ортогональні.

In [2]:
def у_відрізок(роки):
    """Стискаємо роки в [-1, 1]: без цього високі степені розвалюються чисельно."""
    return 2 * (роки - ПЕРШИЙ_РІК) / (ОСТАННІЙ_РІК - ПЕРШИЙ_РІК) - 1


def навчити_поліном(роки, ціни, степінь):
    """МНК-поліном заданого степеня. Повертає коефіцієнти в базисі Лежандра."""
    матриця_ознак = legendre.legvander(у_відрізок(роки), степінь)
    коефіцієнти, *_ = np.linalg.lstsq(матриця_ознак, ціни, rcond=None)
    return коефіцієнти


def передбачити(коефіцієнти, роки):
    """Ціна, яку модель називає для кожного року."""
    степінь = len(коефіцієнти) - 1
    return legendre.legvander(у_відрізок(роки), степінь) @ коефіцієнти


коефіцієнти_3 = навчити_поліном(роки_навчання, ціни_навчання, 3)
print("коефіцієнти полінома 3-го степеня:", np.round(коефіцієнти_3, 1))
print("прогноз для телефона 2020 року:", round(float(передбачити(коефіцієнти_3, np.array([2020.0]))[0])), "₴")
коефіцієнти полінома 3-го степеня: [7408.7 7946.1 3271.4  959.5]
прогноз для телефона 2020 року: 8576 ₴

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

In [3]:
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression

бібліотечна_модель = make_pipeline(PolynomialFeatures(3), LinearRegression())
бібліотечна_модель.fit(у_відрізок(роки_навчання).reshape(-1, 1), ціни_навчання)

наш_прогноз = передбачити(коефіцієнти_3, роки_тесту)
бібліотечний_прогноз = бібліотечна_модель.predict(у_відрізок(роки_тесту).reshape(-1, 1))

print(f"найбільше розходження: {np.abs(наш_прогноз - бібліотечний_прогноз).max():.2e} ₴")

assert np.allclose(наш_прогноз, бібліотечний_прогноз), "розрахунок розійшовся!"
print("\n✅ збігається — усередині бібліотеки той самий МНК")
найбільше розходження: 2.18e-11 ₴

✅ збігається — усередині бібліотеки той самий МНК

2. Помилка: одна формула, дві вибірки¶

Міряти будемо RMSE — корінь із середнього квадрата помилки. Зручність у тому, що результат виходить у гривнях: «модель у середньому промахується на стільки-то».

Головне тут не формула, а те, що ми рахуємо її двічі: на тих оголошеннях, за якими вчились, і на тих, яких модель не бачила.

In [4]:
def rmse(справжні_ціни, прогнози):
    """Середня квадратична помилка в гривнях."""
    return float(np.sqrt(np.mean((справжні_ціни - прогнози) ** 2)))


def дві_помилки(степінь, роки_тр, ціни_тр):
    """Помилка тієї самої моделі на навчальних і на тестових оголошеннях."""
    коефіцієнти = навчити_поліном(роки_тр, ціни_тр, степінь)
    на_навчанні = rmse(ціни_тр, передбачити(коефіцієнти, роки_тр))
    на_тесті = rmse(ціни_тесту, передбачити(коефіцієнти, роки_тесту))
    return на_навчанні, на_тесті


for степінь in (1, 3, 9, 15):
    навч, тест = дві_помилки(степінь, роки_навчання, ціни_навчання)
    print(f"степінь {степінь:>2}:  навчання {навч:9.0f} ₴   тест {тест:12.0f} ₴")
степінь  1:  навчання      1521 ₴   тест         1780 ₴
степінь  3:  навчання       556 ₴   тест          843 ₴
степінь  9:  навчання       490 ₴   тест         2553 ₴
степінь 15:  навчання         0 ₴   тест   1281804556 ₴

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

3. Уся таблиця степенів¶

Тепер порахуємо це для всіх степенів підряд і складемо в таблицю.

In [5]:
import pandas as pd

рядки = []
for степінь in range(1, 16):
    навч, тест = дві_помилки(степінь, роки_навчання, ціни_навчання)
    рядки.append({"степінь": степінь,
                  "помилка на навчанні": round(навч),
                  "помилка на тесті": round(тест),
                  "розрив": round(тест - навч)})

таблиця = pd.DataFrame(рядки).set_index("степінь")
print(таблиця.to_string())
         помилка на навчанні  помилка на тесті      розрив
степінь                                                   
1                       1521              1780         259
2                        635               874         239
3                        556               843         288
4                        540               818         277
5                        540               829         289
6                        530               905         376
7                        510              1131         621
8                        510              1066         556
9                        490              2553        2063
10                       487              9450        8963
11                       476            132413      131937
12                       470            188294      187824
13                       458           8011051     8010593
14                        86         494460505   494460418
15                         0        1281804556  1281804556

4. Точка перелому¶

Читати таблицю очима незручно, тому знайдемо перелом програмно. Нас цікавлять дві речі:

  • найкращий степінь — той, де тестова помилка найменша;
  • перший степінь, після якого тестова помилка пішла вгору, а навчальна далі падає. Це і є момент переходу в перенавчання.
In [6]:
помилки_навчання = таблиця["помилка на навчанні"].to_numpy()
помилки_тесту = таблиця["помилка на тесті"].to_numpy()
степені = таблиця.index.to_numpy()

найкращий_степінь = int(степені[помилки_тесту.argmin()])
найменша_помилка = int(помилки_тесту.min())

print(f"найкращий степінь: {найкращий_степінь}")
print(f"його помилка на тесті: {найменша_помилка} ₴")
print(f"його помилка на навчанні: {помилки_навчання[помилки_тесту.argmin()]} ₴")
print()

# перевіряємо, що навчальна помилка справді ніколи не росте
чи_падає_завжди = np.all(np.diff(помилки_навчання) <= 0)
print(f"навчальна помилка ніколи не росте: {чи_падає_завжди}")
print(f"а тестова після степеня {найкращий_степінь} — росте у "
      f"{int(np.sum(np.diff(помилки_тесту[найкращий_степінь - 1:]) > 0))} випадках із "
      f"{len(помилки_тесту) - найкращий_степінь}")
найкращий степінь: 4
його помилка на тесті: 818 ₴
його помилка на навчанні: 540 ₴

навчальна помилка ніколи не росте: True
а тестова після степеня 4 — росте у 10 випадках із 11

Тепер намалюємо те, заради чого все й затівалось: дві криві помилки. Шкала помилки логарифмічна — інакше степінь 15 розчавив би всю решту в лінію.

In [7]:
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(степені, помилки_навчання, "o-", color="#0f766e", label="помилка на навчанні")
ax.plot(степені, помилки_тесту, "o-", color="#c2185b", label="помилка на тесті")
ax.axvline(найкращий_степінь, color="#555", linestyle="--", linewidth=1)
ax.set_yscale("log")
ax.set_xlabel("степінь полінома")
ax.set_ylabel("RMSE, ₴  (логарифмічна шкала)")
ax.set_title("Навчальна помилка падає завжди, тестова має форму літери U")
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

print(f"пунктир — найкращий степінь ({найкращий_степінь})")
No description has been provided for this image
пунктир — найкращий степінь (4)

І три моделі поруч, щоб побачити ті самі числа очима: занадто проста, вдала й перенавчена.

In [8]:
сітка_років = np.linspace(ПЕРШИЙ_РІК, ОСТАННІЙ_РІК, 400)
fig, осі = plt.subplots(1, 3, figsize=(13, 4), sharey=True)

for вісь, степінь in zip(осі, (1, найкращий_степінь, 15)):
    коефіцієнти = навчити_поліном(роки_навчання, ціни_навчання, степінь)
    вісь.plot(сітка_років, справжня_ціна(сітка_років), "--", color="#888", label="справжня залежність")
    вісь.plot(сітка_років, передбачити(коефіцієнти, сітка_років), color="#c2185b", label="модель")
    вісь.scatter(роки_навчання, ціни_навчання, color="#17212b", zorder=3, s=25, label="оголошення")
    навч, тест = дві_помилки(степінь, роки_навчання, ціни_навчання)
    вісь.set_title(f"степінь {степінь}\nнавчання {навч:.0f} ₴ · тест {тест:.0f} ₴", fontsize=10)
    вісь.set_ylim(0, 24000)          # обрізаємо: поліном 15-го степеня вилітає на мільйони
    вісь.set_xlabel("рік випуску")

осі[0].set_ylabel("ціна, ₴")
осі[0].legend(fontsize=8)
plt.tight_layout()
plt.show()

print("зверни увагу: права крива проходить точно через кожну точку — і саме тому вона найгірша")
No description has been provided for this image
зверни увагу: права крива проходить точно через кожну точку — і саме тому вона найгірша

5. Та сама модель, але оголошень удесятеро більше¶

Складність моделі завжди відносна до обсягу даних. Поліном 9-го степеня на 16 оголошеннях — це складна модель. На 160 оголошеннях — цілком помірна.

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

In [9]:
роки_навчання_багато, ціни_навчання_багато = згенерувати_оголошення(rng, 160)

рядки_багато = []
for степінь in range(1, 16):
    навч, тест = дві_помилки(степінь, роки_навчання_багато, ціни_навчання_багато)
    рядки_багато.append({"степінь": степінь,
                         "помилка на навчанні": round(навч),
                         "помилка на тесті": round(тест),
                         "розрив": round(тест - навч)})

таблиця_багато = pd.DataFrame(рядки_багато).set_index("степінь")
print(таблиця_багато.to_string())
         помилка на навчанні  помилка на тесті  розрив
степінь                                               
1                       1834              1684    -150
2                        860               908      48
3                        789               851      62
4                        763               832      68
5                        738               793      55
6                        733               803      70
7                        730               794      64
8                        730               795      64
9                        725               790      65
10                       716               788      72
11                       699               801     102
12                       679               811     132
13                       678               809     131
14                       674               812     138
15                       673               815     142
In [10]:
найкращий_на_16 = найкращий_степінь
найкращий_на_160 = int(таблиця_багато["помилка на тесті"].idxmin())

print(f"на  16 оголошеннях найкращий степінь: {найкращий_на_16}")
print(f"на 160 оголошеннях найкращий степінь: {найкращий_на_160}")
print()

for степінь in (9, 15):
    розрив_16 = таблиця.loc[степінь, "розрив"]
    розрив_160 = таблиця_багато.loc[степінь, "розрив"]
    print(f"степінь {степінь:>2}: розрив на 16 оголошеннях {розрив_16:>9} ₴, "
          f"на 160 — {розрив_160:>6} ₴")

print("\nмодель не змінилась ані на коефіцієнт — змінилось те, скільки їй довелось вигадувати")
на  16 оголошеннях найкращий степінь: 4
на 160 оголошеннях найкращий степінь: 10

степінь  9: розрив на 16 оголошеннях      2063 ₴, на 160 —     65 ₴
степінь 15: розрив на 16 оголошеннях 1281804556 ₴, на 160 —    142 ₴

модель не змінилась ані на коефіцієнт — змінилось те, скільки їй довелось вигадувати

6. Недонавчання в чистому вигляді¶

І контрольний дослід. Якщо перенавчання лікується даними, то, може, дані вилікують і недонавчання? Візьмемо пряму (степінь 1) і будемо давати їй усе більше й більше оголошень.

In [11]:
обсяги = [16, 40, 100, 400, 2000]
print(f"{'оголошень':>10} | {'навчання':>10} | {'тест':>10}")
print("-" * 36)
for обсяг in обсяги:
    роки_x, ціни_x = згенерувати_оголошення(rng, обсяг)
    навч, тест = дві_помилки(1, роки_x, ціни_x)
    print(f"{обсяг:>10} | {навч:>9.0f} ₴ | {тест:>9.0f} ₴")

print("\nдві тисячі оголошень — і жодного покращення: пряма лишається прямою")
 оголошень |   навчання |       тест
------------------------------------
        16 |      1770 ₴ |      1702 ₴
        40 |      1902 ₴ |      1781 ₴
       100 |      1919 ₴ |      1722 ₴
       400 |      1788 ₴ |      1699 ₴
      2000 |      1780 ₴ |      1680 ₴

дві тисячі оголошень — і жодного покращення: пряма лишається прямою

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

Ось і вся діагностика в одному рядку: дивись не на одну помилку, а на дві — і на відстань між ними.

In [12]:
print("що бачу                                    | діагноз       | що робити")
print("-" * 92)
print("обидві високі, розриву майже немає         | недонавчання  | ускладнити модель, додати ознак")
print("обидві низькі, розрив невеликий            | усе гаразд    | не чіпати")
print("навчальна дуже низька, тестова помітно вища| перенавчання  | спростити, додати даних, регуляризація")
що бачу                                    | діагноз       | що робити
--------------------------------------------------------------------------------------------
обидві високі, розриву майже немає         | недонавчання  | ускладнити модель, додати ознак
обидві низькі, розрив невеликий            | усе гаразд    | не чіпати
навчальна дуже низька, тестова помітно вища| перенавчання  | спростити, додати даних, регуляризація

Завдання¶

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

Заміни розкид цін РОЗКИД_ЦІН із 750 на 200 і перебудуй таблицю з розділу 3.

Зроблено, якщо: ти назвав новий найкращий степінь і пояснив одним реченням, чому при меншому шумі вигідно брати складнішу модель.

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

Побудуй криву навчання: зафіксуй степінь 9 і намалюй обидві помилки як функцію обсягу навчальної вибірки (від 12 до 400 оголошень, штук вісім значень).

Зроблено, якщо: на графіку видно, як дві криві сходяться, і ти назвав обсяг, з якого розрив стає меншим за 150 ₴.

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

Вибір степеня по тестовій вибірці — це підглядання у відповідь. Зроби чесно: розділи 16 навчальних оголошень на дві частини (наприклад, 11 і 5), обирай степінь за меншою частиною, а тестову вибірку чіпай один раз наприкінці.

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

Підказки¶

  • Для рівня 1 достатньо перезапустити нотатник згори з іншою константою; але памʼятай, що rng треба створити наново, інакше числа поїдуть.
  • Для рівня 2 зручно скласти список пар (обсяг, розрив) і надрукувати його перед тим, як малювати.
  • Для рівня 3 не забудь, що валідаційна частина має бути випадковою, а не «останні пʼять років»: інакше модель ніколи не побачить нових телефонів.