Практика 12 · Перенавчання й недонавчання¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md. 🧠 Тест:quiz.html.
Лекція показала дві протилежні невдачі моделі: занадто проста не бачить закономірності, занадто гнучка повторює шум. Тут ми відтворимо обидві своїми руками й побачимо їх у числах.
Що зробимо:
- Породимо ціни телефонів із відомої нам залежності плюс шум
- Навчимо поліноми різних степенів і перевіримо свою реалізацію бібліотечною
- Побудуємо таблицю «степінь → помилка на навчанні → помилка на тесті»
- Знайдемо точку перелому — момент, з якого починається перенавчання
- Повторимо все на вдесятеро більшій вибірці й побачимо, що перелом зсунувся
- Наостанок подивимось на недонавчання в чистому вигляді
Числа тут не збігатимуться з лекцією до гривні: там дані породжував генератор браузера, тут — NumPy. Явище й усі висновки ті самі.
0. Світ, у якому ми знаємо істину¶
Уявімо дошку оголошень про вживані телефони рідкісної марки. Ціна залежить від року випуску: телефон дешевшає з віком, але не по прямій — свіжий флагман втрачає тисячі гривень за рік, а десятирічний апарат уже майже не дешевшає. Плюс невеликий горб на 2019-му: тодішня серія вийшла вдалою й тримає ціну краще за сусідні роки.
До цієї закономірності додається шум — усе, що не пояснюється роком: подряпина на корпусі, продавець поспішає, продавець поставив із запасом.
У житті істину ніхто не знає. Тут знаємо ми, бо самі її задали, — і саме тому зможемо показати пальцем, що модель вивчила закономірність, а що шум.
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], потім узяти не
сирі степені, а поліноми Лежандра — вони задають той самий простір функцій, але
їхні стовпці майже ортогональні.
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, тільки
на звичайних степенях. Простір функцій той самий — отже, прогноз має збігтися.
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 — корінь із середнього квадрата помилки. Зручність у тому, що результат виходить у гривнях: «модель у середньому промахується на стільки-то».
Головне тут не формула, а те, що ми рахуємо її двічі: на тих оголошеннях, за якими вчились, і на тих, яких модель не бачила.
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. Уся таблиця степенів¶
Тепер порахуємо це для всіх степенів підряд і складемо в таблицю.
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. Точка перелому¶
Читати таблицю очима незручно, тому знайдемо перелом програмно. Нас цікавлять дві речі:
- найкращий степінь — той, де тестова помилка найменша;
- перший степінь, після якого тестова помилка пішла вгору, а навчальна далі падає. Це і є момент переходу в перенавчання.
помилки_навчання = таблиця["помилка на навчанні"].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 розчавив би всю решту в лінію.
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"пунктир — найкращий степінь ({найкращий_степінь})")
пунктир — найкращий степінь (4)
І три моделі поруч, щоб побачити ті самі числа очима: занадто проста, вдала й перенавчена.
сітка_років = 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("зверни увагу: права крива проходить точно через кожну точку — і саме тому вона найгірша")
зверни увагу: права крива проходить точно через кожну точку — і саме тому вона найгірша
5. Та сама модель, але оголошень удесятеро більше¶
Складність моделі завжди відносна до обсягу даних. Поліном 9-го степеня на 16 оголошеннях — це складна модель. На 160 оголошеннях — цілком помірна.
Перевіримо: згенеруємо вдесятеро більшу навчальну вибірку з того самого джерела й побудуємо ту саму таблицю.
роки_навчання_багато, ціни_навчання_багато = згенерувати_оголошення(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
найкращий_на_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) і будемо давати їй усе більше й більше оголошень.
обсяги = [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 ₴
дві тисячі оголошень — і жодного покращення: пряма лишається прямою
Порівняй це з попереднім розділом. Там дані різко зменшили розрив між помилками. Тут вони не змінили нічого, бо проблема не в розриві: обидві помилки високі й однакові з самого початку.
Ось і вся діагностика в одному рядку: дивись не на одну помилку, а на дві — і на відстань між ними.
print("що бачу | діагноз | що робити")
print("-" * 92)
print("обидві високі, розриву майже немає | недонавчання | ускладнити модель, додати ознак")
print("обидві низькі, розрив невеликий | усе гаразд | не чіпати")
print("навчальна дуже низька, тестова помітно вища| перенавчання | спростити, додати даних, регуляризація")
що бачу | діагноз | що робити -------------------------------------------------------------------------------------------- обидві високі, розриву майже немає | недонавчання | ускладнити модель, додати ознак обидві низькі, розрив невеликий | усе гаразд | не чіпати навчальна дуже низька, тестова помітно вища| перенавчання | спростити, додати даних, регуляризація
Завдання¶
🟢 Рівень 1 — База¶
Заміни розкид цін РОЗКИД_ЦІН із 750 на 200 і перебудуй таблицю з розділу 3.
Зроблено, якщо: ти назвав новий найкращий степінь і пояснив одним реченням, чому при меншому шумі вигідно брати складнішу модель.
🟡 Рівень 2 — Плюс¶
Побудуй криву навчання: зафіксуй степінь 9 і намалюй обидві помилки як функцію обсягу навчальної вибірки (від 12 до 400 оголошень, штук вісім значень).
Зроблено, якщо: на графіку видно, як дві криві сходяться, і ти назвав обсяг, з якого розрив стає меншим за 150 ₴.
🔴 Рівень 3 — Виклик¶
Вибір степеня по тестовій вибірці — це підглядання у відповідь. Зроби чесно: розділи 16 навчальних оголошень на дві частини (наприклад, 11 і 5), обирай степінь за меншою частиною, а тестову вибірку чіпай один раз наприкінці.
Зроблено, якщо: ти отримав два числа — помилку обраної моделі на валідації й на тесті — і пояснив, чому перша виявилась оптимістичнішою за другу.
Підказки¶
- Для рівня 1 достатньо перезапустити нотатник згори з іншою константою; але
памʼятай, що
rngтреба створити наново, інакше числа поїдуть. - Для рівня 2 зручно скласти список пар
(обсяг, розрив)і надрукувати його перед тим, як малювати. - Для рівня 3 не забудь, що валідаційна частина має бути випадковою, а не «останні пʼять років»: інакше модель ніколи не побачить нових телефонів.