Практика 36 · Друга спадна гілка (подвійний спуск)¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md· 🧪 Тест:quiz.html
У лекції ти рухав повзунки й бачив криву подвійного спуску. Тут ми зберемо той самий стенд руками, на чистому NumPy, і перевіримо кожне твердження числом.
Що зробимо:
- Зберемо модель із випадкових нелінійних ознак — таку саму, як в інтерактивах лекції
- Навчимо її методом найменших квадратів і розвʼязком мінімальної норми (
np.linalg.pinv) - Звіримо нашу підгонку зі
scikit-learn— має збігтися до 10⁻⁸ - Побудуємо криву тестової помилки з піком рівно на p = N
- Побудуємо норму коефіцієнтів і побачимо ту саму форму
- Покажемо, що явний L2-штраф (Ridge) пік прибирає
- Перевіримо, чи винен у піку шум
1. Стенд: дані¶
Беремо рівно те, що в лекції: гладка істинна функція, 24 навчальні точки з шумом і 300 тестових точок. Тестова вибірка велика навмисно — на маленькій крива помилки шумить так, що піку не видно.
import numpy as np
import matplotlib.pyplot as plt
КІЛЬКІСТЬ_НАВЧАЛЬНИХ = 24 # це і є N — воно ж поріг інтерполяції
КІЛЬКІСТЬ_ТЕСТОВИХ = 300 # велика вибірка: інакше оцінка помилки скаче
РІВЕНЬ_ШУМУ = 0.12
def істинна_функція(x):
"""Те, що модель має відновити. У даних її ніхто не бачить — лише зашумлені значення."""
return np.sin(3.4 * x) + 0.4 * x
rng = np.random.default_rng(42)
# навчальні точки майже рівномірні, з дрібним зсувом — щоб сітка не була ідеальною
x_train = np.linspace(-1, 1, КІЛЬКІСТЬ_НАВЧАЛЬНИХ, endpoint=False)
x_train = x_train + 1 / КІЛЬКІСТЬ_НАВЧАЛЬНИХ + rng.uniform(-0.025, 0.025, КІЛЬКІСТЬ_НАВЧАЛЬНИХ)
x_test = np.sort(rng.uniform(-1, 1, КІЛЬКІСТЬ_ТЕСТОВИХ))
# шум зберігаємо окремо від сигналу: далі ми будемо крутити його рівень
шум_train = rng.normal(0, 1, КІЛЬКІСТЬ_НАВЧАЛЬНИХ)
шум_test = rng.normal(0, 1, КІЛЬКІСТЬ_ТЕСТОВИХ)
y_train = істинна_функція(x_train) + РІВЕНЬ_ШУМУ * шум_train
y_test = істинна_функція(x_test) + РІВЕНЬ_ШУМУ * шум_test
print(f"навчальних точок N = {КІЛЬКІСТЬ_НАВЧАЛЬНИХ}, тестових = {КІЛЬКІСТЬ_ТЕСТОВИХ}")
print(f"рівень шуму σ = {РІВЕНЬ_ШУМУ}")
print(f"x від {x_train.min():.2f} до {x_train.max():.2f}, y від {y_train.min():.2f} до {y_train.max():.2f}")
навчальних точок N = 24, тестових = 300 рівень шуму σ = 0.12 x від -0.94 до 0.98, y від -1.27 до 1.35
2. Стенд: випадкові нелінійні ознаки¶
Модель із лекції влаштована так: беремо p випадкових гладких хвилястих функцій
φ₁(x) … φ_p(x) і вчимо поверх них звичайну лінійну модель
f(x) = γ₁·φ₁(x) + γ₂·φ₂(x) + … + γ_p·φ_p(x)
Самі ознаки не навчаються — вони кинуті раз і назавжди. Навчаються лише ваги γ. Це рівно те, чим є нейромережа з одним випадковим прихованим шаром і навченим виходом.
Кожна ознака — випадкова суміш 50 косинусів, причому високі частоти беруться з меншою амплітудою. Тому ознаки в середньому гладкі, але всі різні.
КІЛЬКІСТЬ_КОСИНУСІВ = 50
СПАД_АМПЛІТУДИ = 1.2 # чим більший, тим гладші ознаки
МАКС_ПАРАМЕТРІВ = 200
КІЛЬКІСТЬ_ЖЕРЕБІВ = 15 # скільки разів перекидаємо набір ознак
# амплітуда j-го косинуса: високі частоти пригнічені
амплітуди = (1.0 + np.arange(КІЛЬКІСТЬ_КОСИНУСІВ)) ** (-СПАД_АМПЛІТУДИ)
def косинусний_базис(x):
"""Матриця значень cos(j·π·(x+1)/2) для всіх точок x і всіх j. Розмір: len(x) × 50."""
номери = np.arange(КІЛЬКІСТЬ_КОСИНУСІВ)
return np.cos(np.outer(np.asarray(x, dtype=float) + 1.0, номери) * np.pi / 2.0)
def випадкові_ознаки(номер_жереба):
"""Матриця 50 × 200: кожен стовпець — коефіцієнти однієї випадкової ознаки."""
r = np.random.default_rng(1000 + номер_жереба)
суміш = r.normal(size=(КІЛЬКІСТЬ_КОСИНУСІВ, МАКС_ПАРАМЕТРІВ))
return суміш * амплітуди[:, None] # гасимо високі частоти
базис_train = косинусний_базис(x_train)
базис_test = косинусний_базис(x_test)
ЖЕРЕБИ = [випадкові_ознаки(s) for s in range(КІЛЬКІСТЬ_ЖЕРЕБІВ)]
print(f"базис на навчальних точках: {базис_train.shape}")
print(f"один жереб ознак: {ЖЕРЕБИ[0].shape} (50 косинусів × 200 ознак)")
print(f"усього жеребів: {len(ЖЕРЕБИ)} — по них далі беремо медіану")
базис на навчальних точках: (24, 50) один жереб ознак: (50, 200) (50 косинусів × 200 ознак) усього жеребів: 15 — по них далі беремо медіану
Як виглядають самі ознаки¶
Подивимось на перші три ознаки одного жеребу. Це просто хвилясті криві — жодної структури, повʼязаної з нашою задачею, у них немає.
сітка = np.linspace(-1, 1, 400)
базис_сітки = косинусний_базис(сітка)
fig, ax = plt.subplots(figsize=(9, 3.6))
for номер in range(3):
значення_ознаки = базис_сітки @ ЖЕРЕБИ[0][:, номер]
ax.plot(сітка, значення_ознаки, lw=2, label=f"φ_{номер + 1}(x)")
ax.axhline(0, color="gray", lw=1, ls="--")
ax.set_xlabel("x"); ax.set_ylabel("значення ознаки")
ax.set_title("Три випадкові ознаки з одного жеребу")
ax.legend(); ax.grid(alpha=.25)
plt.tight_layout(); plt.show()
print("Ознаки гладкі, але між собою некорельовані за задумом — і саме з них")
print("модель мусить скласти істинну криву.")
Ознаки гладкі, але між собою некорельовані за задумом — і саме з них модель мусить скласти істинну криву.
3. Правило підгонки¶
Тут ховається вся суть теми, тому проговоримо повільно.
- Поки p ≤ N (параметрів не більше, ніж точок), рівнянь не менше, ніж невідомих. Точно пройти крізь усі точки зазвичай неможливо, тому шукаємо компроміс — звичайний МНК.
- Коли p > N, невідомих більше, ніж рівнянь. Розвʼязків, які проходять крізь усі навчальні точки, стає нескінченно багато. Дані вже нічого не вибирають — вибирає алгоритм. Ми беремо той інтерполянт, у якого найменша норма ‖γ‖.
Приємна новина: обидва випадки закриває одна функція — псевдообернена матриця
np.linalg.pinv. При p ≤ N вона дає розвʼязок МНК, при p > N — розвʼязок мінімальної норми.
Це не збіг, а означення псевдооберненої.
def навчити(матриця_плану, y, штраф=0.0):
"""Ваги моделі. При штраф=0 — МНК або мінімальна норма (залежно від форми матриці).
Явно рахувати норму й порівнювати p з N не треба: pinv робить рівно те,
що нам потрібно, в обох режимах.
"""
if штраф > 0:
# гребенева регресія (Ridge): додаємо λ·I до нормального рівняння
кількість_параметрів = матриця_плану.shape[1]
ліва = матриця_плану.T @ матриця_плану + штраф * np.eye(кількість_параметрів)
return np.linalg.solve(ліва, матриця_плану.T @ y)
return np.linalg.pinv(матриця_плану) @ y
def матриця_плану(номер_жереба, p, базис):
"""Значення перших p ознак жеребу в усіх точках. Розмір: точок × p."""
return базис @ ЖЕРЕБИ[номер_жереба][:, :p]
# швидка перевірка обох режимів
for p in [10, 24, 60]:
план = матриця_плану(0, p, базис_train)
ваги = навчити(план, y_train)
залишок = np.max(np.abs(y_train - план @ ваги))
режим = "класичний" if p < 24 else ("поріг" if p == 24 else "надпараметризований")
print(f"p={p:3d} {режим:20s} макс. залишок на навчанні = {залишок:.2e} ‖γ‖={np.linalg.norm(ваги):8.2f}")
p= 10 класичний макс. залишок на навчанні = 4.47e-01 ‖γ‖= 2.18 p= 24 поріг макс. залишок на навчанні = 6.00e-15 ‖γ‖= 3.20 p= 60 надпараметризований макс. залишок на навчанні = 8.22e-15 ‖γ‖= 0.83
Чому саме мінімальна норма¶
Перевіримо, що pinv при p > N справді дає найкоротший з усіх інтерполянтів.
Візьмемо розвʼязок pinv, зсунемо його вздовж напрямку, який не змінює жодного
значення в навчальних точках (тобто вздовж ядра матриці плану), — і подивимось на норму.
p_багато = 60
план = матриця_плану(0, p_багато, базис_train)
ваги_мін = навчити(план, y_train)
# ядро матриці плану: напрямки, уздовж яких прогнози на навчальних точках не змінюються
_, сингулярні, права_матриця = np.linalg.svd(план)
ядро = права_матриця[len(сингулярні):] # рядки, що відповідають нульовим сингулярним числам
r = np.random.default_rng(5)
print(f"розмірність множини інтерполянтів: {ядро.shape[0]} (= p − N = {p_багато} − 24)")
print(f"\n{'варіант':>22} {'‖γ‖':>10} {'макс. залишок':>16}")
print(f"{'мінімальна норма':>22} {np.linalg.norm(ваги_мін):10.3f} {np.max(np.abs(y_train - план @ ваги_мін)):16.2e}")
for спроба in range(4):
зсув = r.normal(size=ядро.shape[0]) @ ядро # рух усередині множини інтерполянтів
інший = ваги_мін + зсув
print(f"{'інший інтерполянт':>22} {np.linalg.norm(інший):10.3f} "
f"{np.max(np.abs(y_train - план @ інший)):16.2e}")
assert np.linalg.norm(ваги_мін) < np.linalg.norm(ваги_мін + r.normal(size=ядро.shape[0]) @ ядро)
print("\n✅ розвʼязок pinv справді найкоротший, а залишки в усіх варіантів нульові")
розмірність множини інтерполянтів: 36 (= p − N = 60 − 24)
варіант ‖γ‖ макс. залишок
мінімальна норма 0.829 8.22e-15
інший інтерполянт 5.261 8.33e-15
інший інтерполянт 5.854 1.28e-14
інший інтерполянт 5.508 1.09e-14
інший інтерполянт 5.193 7.99e-15
✅ розвʼязок pinv справді найкоротший, а залишки в усіх варіантів нульові
4. Звірка зі scikit-learn¶
Обовʼязковий крок: переконатись, що всередині бібліотеки немає магії. У класичному
режимі (p < N) наша функція має дати рівно те саме, що LinearRegression.
from sklearn.linear_model import LinearRegression
p_класичний = 12
план = матриця_плану(0, p_класичний, базис_train)
наші_ваги = навчити(план, y_train)
# fit_intercept=False, бо вільний член у нас не передбачений — модель це чиста сума ознак
модель_sklearn = LinearRegression(fit_intercept=False).fit(план, y_train)
print(f"наші ваги (перші 4): {np.round(наші_ваги[:4], 6)}")
print(f"sklearn (перші 4): {np.round(модель_sklearn.coef_[:4], 6)}")
print(f"макс. різниця: {np.max(np.abs(наші_ваги - модель_sklearn.coef_)):.2e}")
assert np.allclose(наші_ваги, модель_sklearn.coef_), "розрахунок розійшовся!"
print("\n✅ збігається")
наші ваги (перші 4): [ 1.188524 -0.432319 -0.135792 -0.952564] sklearn (перші 4): [ 1.188524 -0.432319 -0.135792 -0.952564] макс. різниця: 4.44e-15 ✅ збігається
5. Головна крива¶
Тепер будуємо те, заради чого все затівалось: тестову помилку як функцію кількості параметрів. Помилку міряємо через MAE (середнє абсолютне відхилення) і усереднюємо медіаною по 15 жеребах ознак — інакше крива стрибала б від одного невдалого розкладу.
Сітка по p згущена біля порогу p = N = 24: саме там уся драма.
СІТКА_P = list(range(1, 9)) + list(range(10, 33, 2)) + [36, 44, 54, 66, 82, 100, 124, 155, 200]
def побудувати_криву(сітка_p, рівень_шуму=РІВЕНЬ_ШУМУ, штраф=0.0):
"""Для кожного p: медіанна тестова MAE, навчальна MAE і норма ваг по всіх жеребах."""
y_навч = істинна_функція(x_train) + рівень_шуму * шум_train
y_тест = істинна_функція(x_test) + рівень_шуму * шум_test
тестова, навчальна, норми = [], [], []
for p in сітка_p:
по_жеребах_тест, по_жеребах_навч, по_жеребах_норма = [], [], []
for жереб in range(КІЛЬКІСТЬ_ЖЕРЕБІВ):
план_навч = матриця_плану(жереб, p, базис_train)
ваги = навчити(план_навч, y_навч, штраф)
прогноз_тест = матриця_плану(жереб, p, базис_test) @ ваги
по_жеребах_тест.append(np.mean(np.abs(y_тест - прогноз_тест)))
по_жеребах_навч.append(np.mean(np.abs(y_навч - план_навч @ ваги)))
по_жеребах_норма.append(np.linalg.norm(ваги))
тестова.append(np.median(по_жеребах_тест))
навчальна.append(np.median(по_жеребах_навч))
норми.append(np.median(по_жеребах_норма))
return np.array(тестова), np.array(навчальна), np.array(норми)
тест_mae, навч_mae, норма_ваг = побудувати_криву(СІТКА_P)
print(f"{'p':>5} {'test MAE':>11} {'train MAE':>11} {'‖γ‖':>10} режим")
for k, p in enumerate(СІТКА_P):
if p in (1, 8, 16, 22, 24, 26, 32, 54, 100, 200):
режим = "класичний" if p < 24 else ("← ПОРІГ p = N" if p == 24 else "надпараметризований")
print(f"{p:>5} {тест_mae[k]:11.4f} {навч_mae[k]:11.4f} {норма_ваг[k]:10.2f} {режим}")
p test MAE train MAE ‖γ‖ режим
1 0.7611 0.7398 0.19 класичний
8 0.2342 0.2092 1.68 класичний
16 0.1703 0.0993 1.93 класичний
22 0.2265 0.0463 3.89 класичний
24 0.8311 0.0000 18.01 ← ПОРІГ p = N
26 0.2534 0.0000 3.90 надпараметризований
32 0.1591 0.0000 1.98 надпараметризований
54 0.1346 0.0000 1.01 надпараметризований
100 0.1294 0.0000 0.66 надпараметризований
200 0.1273 0.0000 0.43 надпараметризований
Перевірка головного твердження¶
Пік тестової помилки має стояти рівно на p = N, а не «десь поруч». Це не фігура
мовлення — це те, що можна перевірити assert-ом.
індекс_порогу = СІТКА_P.index(КІЛЬКІСТЬ_НАВЧАЛЬНИХ)
індекс_піку = int(np.argmax(тест_mae))
# найкраща класична модель: беремо з безпечної відстані від порогу
маска_класична = np.array(СІТКА_P) <= КІЛЬКІСТЬ_НАВЧАЛЬНИХ - 4
маска_далеко = np.array(СІТКА_P) >= 2 * КІЛЬКІСТЬ_НАВЧАЛЬНИХ
класичний_мінімум = тест_mae[маска_класична].min()
дно_другого_спуску = тест_mae[маска_далеко].min()
print(f"пік тестової помилки : p = {СІТКА_P[індекс_піку]}, MAE = {тест_mae[індекс_піку]:.4f}")
print(f"кращий класичний : MAE = {класичний_мінімум:.4f}")
print(f"дно другого спуску : MAE = {дно_другого_спуску:.4f}")
print(f"\nпік гірший за класику у {тест_mae[індекс_піку] / класичний_мінімум:.1f} раза")
print(f"друга гілка краща за класику у {класичний_мінімум / дно_другого_спуску:.2f} раза")
assert СІТКА_P[індекс_піку] == КІЛЬКІСТЬ_НАВЧАЛЬНИХ, "пік стоїть не на порозі інтерполяції!"
assert дно_другого_спуску < класичний_мінімум, "друга гілка не опустилась нижче класичної!"
print("\n✅ пік рівно на p = N, і друга гілка опустилась нижче класичного мінімуму")
пік тестової помилки : p = 24, MAE = 0.8311 кращий класичний : MAE = 0.1703 дно другого спуску : MAE = 0.1273 пік гірший за класику у 4.9 раза друга гілка краща за класику у 1.34 раза ✅ пік рівно на p = N, і друга гілка опустилась нижче класичного мінімуму
Малюємо криву¶
Обидві осі логарифмічні — інакше пік розчавить усе решту.
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(СІТКА_P, тест_mae, color="teal", lw=2.4, marker="o", ms=4, label="тестова MAE")
# нульову навчальну помилку на лог-шкалі не намалюєш — підмінюємо крихітним числом
навчальна_для_графіка = np.maximum(навч_mae, 1e-4)
ax.plot(СІТКА_P, навчальна_для_графіка, color="crimson", lw=2, marker="o", ms=3,
label="навчальна MAE")
ax.axvline(КІЛЬКІСТЬ_НАВЧАЛЬНИХ, color="gray", ls="--", lw=1.6)
ax.axhline(класичний_мінімум, color="darkorange", ls=":", lw=1.6,
label="кращий класичний результат")
ax.text(КІЛЬКІСТЬ_НАВЧАЛЬНИХ * 1.06, тест_mae.max() * 0.8, "поріг p = N = 24",
color="gray", fontsize=10)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("кількість параметрів p (лог. шкала)")
ax.set_ylabel("MAE (лог. шкала)")
ax.set_title("Подвійний спуск: помилка спадає, вибухає на порозі й спадає вдруге")
ax.legend(); ax.grid(alpha=.25, which="both")
plt.tight_layout(); plt.show()
print("Ліворуч від пунктиру — знайома U-крива з підручника.")
print("На пунктирі — вузький пік. Праворуч — навчальна помилка вже нульова,")
print("а тестова спокійно опускається нижче за все, чого досягала класична модель.")
Ліворуч від пунктиру — знайома U-крива з підручника. На пунктирі — вузький пік. Праворуч — навчальна помилка вже нульова, а тестова спокійно опускається нижче за все, чого досягала класична модель.
6. Як виглядає підгонка в кожному режимі¶
Числа — це добре, але корисніше побачити самі криві. Візьмемо чотири моделі: недонавчену (p=6), майже оптимальну класичну (p=16), рівно на порозі (p=24) і глибоко надпараметризовану (p=200).
режими = [(6, "класичний: замало"), (16, "класичний: оптимум"),
(24, "ПОРІГ p = N"), (200, "надпараметризований")]
# беремо не перший-ліпший жереб ознак, а типовий: той, у якого норма ваг
# на порозі дорівнює медіанній по всіх жеребах
норми_на_порозі = [np.linalg.norm(навчити(матриця_плану(ж, КІЛЬКІСТЬ_НАВЧАЛЬНИХ, базис_train), y_train))
for ж in range(КІЛЬКІСТЬ_ЖЕРЕБІВ)]
ТИПОВИЙ_ЖЕРЕБ = int(np.argsort(норми_на_порозі)[КІЛЬКІСТЬ_ЖЕРЕБІВ // 2])
print(f"типовий жереб ознак: №{ТИПОВИЙ_ЖЕРЕБ} (‖γ‖ на порозі = {норми_на_порозі[ТИПОВИЙ_ЖЕРЕБ]:.1f})\n")
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for вісь, (p, підпис) in zip(axes, режими):
ваги = навчити(матриця_плану(ТИПОВИЙ_ЖЕРЕБ, p, базис_train), y_train)
крива = базис_сітки @ (ЖЕРЕБИ[ТИПОВИЙ_ЖЕРЕБ][:, :p] @ ваги)
розмах = крива.max() - крива.min()
вісь.plot(сітка, істинна_функція(сітка), color="gray", ls="--", lw=1.8, label="істина")
вісь.plot(сітка, крива, color="teal", lw=2.2, label="модель")
вісь.scatter(x_train, y_train, s=20, color="crimson", zorder=3, label="дані")
# межі фіксовані під істину — щоб було видно, наскільки модель з них вилітає
вісь.set_ylim(-2.6, 2.6)
вісь.set_title(f"p = {p} · {підпис}\nрозмах |f| = {розмах:.1f}", fontsize=10)
вісь.grid(alpha=.2)
axes[0].legend(fontsize=9)
plt.tight_layout(); plt.show()
for p, підпис in режими:
ваги = навчити(матриця_плану(ТИПОВИЙ_ЖЕРЕБ, p, базис_train), y_train)
крива = базис_сітки @ (ЖЕРЕБИ[ТИПОВИЙ_ЖЕРЕБ][:, :p] @ ваги)
print(f"p={p:3d} розмах кривої = {крива.max() - крива.min():9.1f} ‖γ‖ = {np.linalg.norm(ваги):9.2f}")
print("\nПри p = 24 крива теж проходить крізь усі точки — але між ними вилітає")
print("далеко за межі малюнка: її розмах утричі більший за розмах істини.")
print("При p = 200 вона проходить крізь ті самі точки, а між ними тримається біля істини.")
print("Обидві «перенавчені» за класичним означенням. Поводяться протилежно.")
типовий жереб ознак: №6 (‖γ‖ на порозі = 18.0)
p= 6 розмах кривої = 2.7 ‖γ‖ = 1.50 p= 16 розмах кривої = 2.8 ‖γ‖ = 1.86 p= 24 розмах кривої = 9.1 ‖γ‖ = 18.01 p=200 розмах кривої = 2.6 ‖γ‖ = 0.42 При p = 24 крива теж проходить крізь усі точки — але між ними вилітає далеко за межі малюнка: її розмах утричі більший за розмах істини. При p = 200 вона проходить крізь ті самі точки, а між ними тримається біля істини. Обидві «перенавчені» за класичним означенням. Поводяться протилежно.
7. Норма коефіцієнтів — прихована пружина¶
Розмову про «величезну норму» ми вже перевірили одним числом. Тепер побудуємо ‖γ‖ уздовж усієї осі складності й порівняємо форму з кривою помилки.
fig, ax = plt.subplots(figsize=(10, 4.6))
ax.plot(СІТКА_P, норма_ваг, color="crimson", lw=2.4, marker="o", ms=4, label="норма ваг ‖γ‖")
ax.plot(СІТКА_P, тест_mae * норма_ваг.max() / тест_mae.max(), color="teal", lw=2,
ls="--", label="тестова MAE (масштабована)")
ax.axvline(КІЛЬКІСТЬ_НАВЧАЛЬНИХ, color="gray", ls="--", lw=1.6)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("кількість параметрів p (лог. шкала)")
ax.set_ylabel("‖γ‖ (лог. шкала)")
ax.set_title("Норма ваг має максимум рівно там, де й тестова помилка")
ax.legend(); ax.grid(alpha=.25, which="both")
plt.tight_layout(); plt.show()
print(f"максимум норми стоїть на p = {СІТКА_P[int(np.argmax(норма_ваг))]}")
print(f"‖γ‖ на порозі = {норма_ваг[індекс_порогу]:.1f}, при p = 200 = {норма_ваг[-1]:.2f}")
assert СІТКА_P[int(np.argmax(норма_ваг))] == КІЛЬКІСТЬ_НАВЧАЛЬНИХ
print("\n✅ норма ваг вибухає рівно на порозі — дві криві мають один і той самий рельєф")
максимум норми стоїть на p = 24 ‖γ‖ на порозі = 18.0, при p = 200 = 0.43 ✅ норма ваг вибухає рівно на порозі — дві криві мають один і той самий рельєф
8. Явний штраф прибирає пік¶
Якщо пік спричинений гігантською нормою, то штраф за норму має його зрізати. Додаємо у функцію втрат звичайний L2-доданок (гребенева регресія):
L(γ) = ‖y − Φγ‖² + λ·‖γ‖²
Тепер ми не вимагаємо точної інтерполяції — торгуємось між точністю на навчальних даних і величиною ваг.
СІТКА_P2 = [1, 2, 4, 6, 8, 12, 16, 20, 22, 24, 26, 28, 32, 40, 54, 72, 100, 140, 200]
індекс_порогу2 = СІТКА_P2.index(КІЛЬКІСТЬ_НАВЧАЛЬНИХ)
fig, ax = plt.subplots(figsize=(10, 5))
кольори = ["crimson", "darkorange", "seagreen", "teal"]
результати = {}
for колір, штраф in zip(кольори, [0.0, 0.001, 0.01, 0.1]):
крива, _, _ = побудувати_криву(СІТКА_P2, штраф=штраф)
результати[штраф] = крива
стиль = "--" if штраф == 0 else "-"
ax.plot(СІТКА_P2, крива, color=колір, lw=2.2, ls=стиль, label=f"λ = {штраф}")
ax.axvline(КІЛЬКІСТЬ_НАВЧАЛЬНИХ, color="gray", ls="--", lw=1.5)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("кількість параметрів p (лог. шкала)"); ax.set_ylabel("тестова MAE (лог. шкала)")
ax.set_title("Ridge зрізає пік: чим більший λ, тим монотонніша крива")
ax.legend(); ax.grid(alpha=.25, which="both")
plt.tight_layout(); plt.show()
print(f"{'λ':>8} {'помилка на порозі':>20} {'найкраще на кривій':>21}")
for штраф, крива in результати.items():
print(f"{штраф:>8} {крива[індекс_порогу2]:20.4f} {крива.min():21.4f}")
пік_без_штрафу = результати[0.0][індекс_порогу2]
пік_зі_штрафом = результати[0.1][індекс_порогу2]
assert пік_зі_штрафом < пік_без_штрафу / 3, "штраф мав зрізати пік щонайменше втричі!"
print(f"\n✅ штраф λ = 0.1 зменшив пік з {пік_без_штрафу:.3f} до {пік_зі_штрафом:.3f}")
print("Найкраще значення регуляризованої моделі майже дорівнює тому, чого")
print("надпараметризована модель досягала взагалі без штрафу. Мінімальна норма —")
print("це і є прихована («неявна») регуляризація.")
λ помилка на порозі найкраще на кривій
0.0 0.8311 0.1273
0.001 0.2719 0.1273
0.01 0.1650 0.1272
0.1 0.1428 0.1261
✅ штраф λ = 0.1 зменшив пік з 0.831 до 0.143
Найкраще значення регуляризованої моделі майже дорівнює тому, чого
надпараметризована модель досягала взагалі без штрафу. Мінімальна норма —
це і є прихована («неявна») регуляризація.
9. Чи винен у піку шум¶
Природна гіпотеза: пік виникає тому, що на порозі модель змушена точно відтворити шум. Перевіримо її прямо — покрутимо рівень шуму від нуля до великого.
print(f"{'σ':>6} {'пік (p=N)':>12} {'класичний мін.':>16} {'дно 2-го спуску':>17} {'виграш 2-ї гілки':>18}")
рядки = []
for σ in [0.0, 0.06, 0.12, 0.30]:
крива, _, _ = побудувати_криву(СІТКА_P2, рівень_шуму=σ)
пік = крива[індекс_порогу2]
класика = крива[np.array(СІТКА_P2) <= КІЛЬКІСТЬ_НАВЧАЛЬНИХ - 4].min()
дно = крива[np.array(СІТКА_P2) >= 2 * КІЛЬКІСТЬ_НАВЧАЛЬНИХ].min()
рядки.append((σ, пік, класика, дно))
print(f"{σ:>6} {пік:12.4f} {класика:16.4f} {дно:17.4f} {класика / дно:17.2f}×")
σ_нуль, пік_без_шуму, класика_без_шуму, _ = рядки[0]
assert пік_без_шуму > 2 * класика_без_шуму, "без шуму пік мав би лишитись!"
print("\n✅ при σ = 0 пік нікуди не зник — жорсткість на порозі шуму не потребує")
print("Зате виграш другої гілки шум зʼїдає: без шуму вона у 8 разів краща за класику,")
print("а при σ = 0.30 перевага майже зникає. Інтерполювати брудні мітки сенсу мало.")
σ пік (p=N) класичний мін. дно 2-го спуску виграш 2-ї гілки
0.0 0.3729 0.1223 0.0148 8.25×
0.06 0.6310 0.1307 0.0651 2.01×
0.12 0.8311 0.1703 0.1273 1.34×
0.3 1.4189 0.3431 0.3171 1.08× ✅ при σ = 0 пік нікуди не зник — жорсткість на порозі шуму не потребує Зате виграш другої гілки шум зʼїдає: без шуму вона у 8 разів краща за класику, а при σ = 0.30 перевага майже зникає. Інтерполювати брудні мітки сенсу мало.
💻 Завдання¶
🟢 Рівень 1 — разом¶
- Зменш
КІЛЬКІСТЬ_ТЕСТОВИХдо 20 і перебудуй головну криву. Куди поїхав пік? Це і є причина, чому тестова вибірка має бути великою. - Зміни
КІЛЬКІСТЬ_НАВЧАЛЬНИХна 40 і перевір, що пік переїхав на p = 40.
🟡 Рівень 2 — самостійно¶
- Зміни
СПАД_АМПЛІТУДИз 1.2 на 0.3 — ознаки стануть різкішими. Що сталося з висотою піку й з дном другого спуску? Поясни через гладкість. - Побудуй криву не для медіани, а для кожного жеребу окремо (15 тонких ліній). Де розкид між жеребами найбільший і чому саме там?
🔴 Рівень 3 — виклик¶
- Побудуй ту саму картинку для повноцінної нейромережі:
MLPRegressorзі зростаючою шириною прихованого шару. Чи видно пік? Чому його важче спіймати? - Відтвори epoch-wise double descent: зафіксуй p = 24 і будуй тестову помилку як функцію кількості кроків градієнтного спуску, а не кількості параметрів.
🧪 Самоперевірка¶
1. Чому np.linalg.pinv працює і при p < N, і при p > N?
відповідь
Псевдообернена матриця за означенням дає розвʼязок з найменшою нормою серед усіх, що мінімізують ‖Φγ − y‖. При p < N мінімум єдиний — це МНК. При p > N мінімум досягається на цілій множині (усі інтерполянти), і pinv обирає з неї найкоротший вектор.2. Модель має 24 параметри й 24 навчальні точки. Чи можна врятувати ситуацію, не міняючи розмір моделі?
відповідь
Так — додати регуляризацію. Розділ 8 показує це числом: λ = 0.1 зрізає пік у шість разів. Пік виникає не від кількості параметрів, а від того, що єдиний інтерполюючий розвʼязок вимагає гігантської норми. Штраф просто забороняє таку норму.3. Чому пік не зникає при нульовому шумі?