Практика · Оптимізатори> 📖 Лекція: lecture.html · 🧪 Тест: quiz.html · 📝 Домашнє: homework.mdЛекція показала п'ять способів зробити крок із готового градієнта. Тут ми напишемовсі п'ять з нуля — кожен займає близько десятка рядків — і поставимо їх у триоднакові умови: яр, сідлова точка й справжня мережа зтеми про зворотне поширення.Що зробимо:1. реалізуємо SGD, момент, AdaGrad, RMSProp і Adam — по одній маленькій функції на кожен;2. проженемо їх по яру й порахуємо кроки до цілі; числа мають збігтися з лекцією;3. проженемо по сідловій точці й побачимо, хто з неї не вибирається;4. навчимо ними справжню мережу на дошці оголошень і порівняємо криві втрат;5. вимкнемо в Adam корекцію зміщення й подивимось, що з цього вийде.Ніде не міряємо секунди — рахуємо кроки. Секунди залежать від комп'ютера, кроки — ні.¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
генератор = np.random.default_rng(42)
np.set_printoptions(precision=4, suppress=True)
print("numpy", np.__version__, "· pandas", pd.__version__)
numpy 2.5.1 · pandas 2.3.3
---# Частина 1 · Яр## 1 · Поверхня, на якій простий спуск безпораднийБеремо рівно ту саму поверхню, що в розділі 01 лекції:L(w₁, w₂) = 0.025·w₁² + 10·w₂²Мінімум у точці (0, 0). Кривизна вздовж w₁ дорівнює 0.05, вздовж w₂ — 20.Відношення 400 — це і є «яр»: пологий в один бік, крутий в інший.Стартуємо з (−4, 1) і крок скрізь беремо однаковий, η = 0.096.
КРИВИЗНА_ВЗДОВЖ = 0.05 # пологий напрямок: тут лежить довгий шлях до мінімуму
КРИВИЗНА_ВПОПЕРЕК = 20.0 # крутий напрямок: саме він обмежує довжину кроку
СТАРТ_ЯРУ = np.array([-4.0, 1.0])
КРОК_ЯРУ = 0.096
ЦІЛЬ_ЯРУ = 0.20 # вважаємо, що дійшли, коли відстань до мінімуму менша за це
def втрата_яру(точка):
"""Половина зваженої суми квадратів — звичайна витягнута чаша."""
return 0.5 * (КРИВИЗНА_ВЗДОВЖ * точка[0] ** 2 + КРИВИЗНА_ВПОПЕРЕК * точка[1] ** 2)
def градієнт_яру(точка):
"""Похідна чаші рахується в один рядок: кривизна помножена на зміщення."""
return np.array([КРИВИЗНА_ВЗДОВЖ * точка[0], КРИВИЗНА_ВПОПЕРЕК * точка[1]])
градієнт_на_старті = градієнт_яру(СТАРТ_ЯРУ)
print("градієнт на старті:", градієнт_на_старті)
print(f"одна складова більша за іншу в {abs(градієнт_на_старті[1] / градієнт_на_старті[0]):.0f} разів")
print(f"більший за {2 / КРИВИЗНА_ВПОПЕРЕК:.3f} крок простому спуску брати не можна — вибухне")
print(f"з кроком {КРОК_ЯРУ} він зсуне w₁ на {КРОК_ЯРУ * abs(градієнт_на_старті[0]):.4f},"
f" а w₂ — аж на {КРОК_ЯРУ * abs(градієнт_на_старті[1]):.2f}")
градієнт на старті: [-0.2 20. ] одна складова більша за іншу в 100 разів більший за 0.100 крок простому спуску брати не можна — вибухне з кроком 0.096 він зсуне w₁ на 0.0192, а w₂ — аж на 1.92
2 · П'ять оптимізаторів з нуляКожна функція приймає функцію градієнта, стартову точку, крок і кількість ітерацій,а повертає всю траєкторію — список точок. Так її потім легко і намалювати,і виміряти.Навмисно пишемо п'ять окремих функцій замість однієї «розумної»: кожну можна прочитатицілком, не тримаючи в голові решту. Різниця між ними — три-чотири рядки.¶
def спуск_простий(градієнт, старт, крок, ітерацій):
"""Класика: крок проти градієнта. Ніякої пам'яті між ітераціями."""
точка = np.array(старт, dtype=float)
траєкторія = [точка.copy()]
for _ in range(ітерацій):
точка = точка - крок * градієнт(точка)
траєкторія.append(точка.copy())
return np.array(траєкторія)
def спуск_із_моментом(градієнт, старт, крок, ітерацій, бета=0.9):
"""Замість градієнта рухаємось за накопиченою швидкістю."""
точка = np.array(старт, dtype=float)
швидкість = np.zeros_like(точка)
траєкторія = [точка.copy()]
for _ in range(ітерацій):
# частку бета старої швидкості зберігаємо, свіжий градієнт додаємо
швидкість = бета * швидкість + градієнт(точка)
точка = точка - крок * швидкість
траєкторія.append(точка.copy())
return np.array(траєкторія)
# перший крок обох методів однаковий: швидкості ще нема, накопичувати нічого
print("простий, крок 1:", спуск_простий(градієнт_яру, СТАРТ_ЯРУ, КРОК_ЯРУ, 1)[1])
print("момент, крок 1:", спуск_із_моментом(градієнт_яру, СТАРТ_ЯРУ, КРОК_ЯРУ, 1)[1])
print("момент, крок 2:", спуск_із_моментом(градієнт_яру, СТАРТ_ЯРУ, КРОК_ЯРУ, 2)[2],
"— тут швидкість уже працює")
простий, крок 1: [-3.9808 -0.92 ] момент, крок 1: [-3.9808 -0.92 ] момент, крок 2: [-3.9444 -0.8816] — тут швидкість уже працює
def спуск_adagrad(градієнт, старт, крок, ітерацій, епсилон=1e-8):
"""Кожен параметр ділить свій крок на корінь із суми квадратів своїх градієнтів."""
точка = np.array(старт, dtype=float)
сума_квадратів = np.zeros_like(точка)
траєкторія = [точка.copy()]
for _ in range(ітерацій):
g = градієнт(точка)
сума_квадратів = сума_квадратів + g ** 2 # сума лише росте — звідси й вада
точка = точка - крок * g / (np.sqrt(сума_квадратів) + епсилон)
траєкторія.append(точка.copy())
return np.array(траєкторія)
def спуск_rmsprop(градієнт, старт, крок, ітерацій, ро=0.9, епсилон=1e-8):
"""Те саме, але замість суми — ковзне середнє: старе поступово забувається."""
точка = np.array(старт, dtype=float)
середній_квадрат = np.zeros_like(точка)
траєкторія = [точка.copy()]
for _ in range(ітерацій):
g = градієнт(точка)
середній_квадрат = ро * середній_квадрат + (1 - ро) * g ** 2
точка = точка - крок * g / (np.sqrt(середній_квадрат) + епсилон)
траєкторія.append(точка.copy())
return np.array(траєкторія)
# на першому кроці сума квадратів дорівнює самому квадрату градієнта,
# тому дріб g/√(g²) дорівнює ±1 — обидві координати зсуваються рівно на η
print("AdaGrad, крок 1:", спуск_adagrad(градієнт_яру, СТАРТ_ЯРУ, КРОК_ЯРУ, 1)[1],
f"(кожна координата зсунулась на {КРОК_ЯРУ})")
print("RMSProp, крок 1:", спуск_rmsprop(градієнт_яру, СТАРТ_ЯРУ, КРОК_ЯРУ, 1)[1],
"(ковзне середнє на старті занижене, тому крок утричі довший)")
AdaGrad, крок 1: [-3.904 0.904] (кожна координата зсунулась на 0.096) RMSProp, крок 1: [-3.6964 0.6964] (ковзне середнє на старті занижене, тому крок утричі довший)
def спуск_adam(градієнт, старт, крок, ітерацій,
бета1=0.9, бета2=0.999, епсилон=1e-8, корекція=True):
"""Момент і RMSProp разом. Прапорець `корекція` знадобиться нам у частині 4."""
точка = np.array(старт, dtype=float)
перший_момент = np.zeros_like(точка) # ковзне середнє самих градієнтів
другий_момент = np.zeros_like(точка) # ковзне середнє їхніх квадратів
траєкторія = [точка.copy()]
for номер_кроку in range(1, ітерацій + 1):
g = градієнт(точка)
перший_момент = бета1 * перший_момент + (1 - бета1) * g
другий_момент = бета2 * другий_момент + (1 - бета2) * g ** 2
if корекція:
# обидва середні стартують з нуля й тому занижені; ділення це виправляє
m = перший_момент / (1 - бета1 ** номер_кроку)
v = другий_момент / (1 - бета2 ** номер_кроку)
else:
m, v = перший_момент, другий_момент
точка = точка - крок * m / (np.sqrt(v) + епсилон)
траєкторія.append(точка.copy())
return np.array(траєкторія)
ОПТИМІЗАТОРИ = {
"простий спуск": спуск_простий,
"момент 0.9": спуск_із_моментом,
"AdaGrad": спуск_adagrad,
"RMSProp": спуск_rmsprop,
"Adam": спуск_adam,
}
print("готово, оптимізаторів:", len(ОПТИМІЗАТОРИ))
готово, оптимізаторів: 5
3 · Скільки кроків до цілі«Дійшов» означає не «одного разу пролетів повз мінімум», а «зайшов у коло радіуса 0.20і більше з нього не вийшов». Різниця принципова: метод із завеликим моментомпроскакує ціль наскрізь, і за наївним критерієм виглядав би переможцем.¶
def кроків_до_цілі(траєкторія, поріг):
"""Номер кроку, після якого траєкторія вже не виходить за поріг."""
відстані = np.linalg.norm(траєкторія, axis=1)
поза_ціллю = np.flatnonzero(відстані >= поріг)
if len(поза_ціллю) == 0:
return 0
останній_вихід = поза_ціллю[-1]
if останній_вихід == len(траєкторія) - 1:
return None # так і не сів у ціль за відведені ітерації
return int(останній_вихід) + 1
МЕЖА_ІТЕРАЦІЙ = 2000
результати_яру = {}
for назва, оптимізатор in ОПТИМІЗАТОРИ.items():
шлях = оптимізатор(градієнт_яру, СТАРТ_ЯРУ, КРОК_ЯРУ, МЕЖА_ІТЕРАЦІЙ)
результати_яру[назва] = (шлях, кроків_до_цілі(шлях, ЦІЛЬ_ЯРУ))
print(f"яр, старт (-4.0, 1.0), крок {КРОК_ЯРУ}, ціль — коло радіуса {ЦІЛЬ_ЯРУ}\n")
print(f"{'оптимізатор':<16}{'кроків':>8}{'втрата на 60-му кроці':>26}")
for назва, (шлях, кроків) in результати_яру.items():
print(f"{назва:<16}{str(кроків):>8}{втрата_яру(шлях[60]):>26.4f}")
яр, старт (-4.0, 1.0), крок 0.096, ціль — коло радіуса 0.2 оптимізатор кроків втрата на 60-му кроці простий спуск 623 0.2250 момент 0.9 44 0.0014 AdaGrad 1171 0.3169 RMSProp 48 0.0000 Adam 54 0.0139
Ті самі числа стоять в інтерактиві «Гонка на яру». Звіримо їх —якщо лекція й зошит розійдуться, брехатиме щось одне.
очікувані_кроки = {"простий спуск": 623, "момент 0.9": 44,
"AdaGrad": 1171, "RMSProp": 48, "Adam": 54}
for назва, очікувано in очікувані_кроки.items():
отримано = результати_яру[назва][1]
assert отримано == очікувано, f"{назва}: {отримано} замість {очікувано}"
print("✅ усі п'ять збігаються з лекцією")
print("AdaGrad програв навіть простому спуску — і це не помилка, а його головна вада:")
шлях_adagrad = результати_яру["AdaGrad"][0]
for номер in (50, 100, 300, 1000):
print(f" після {номер:>4} кроків w₁ = {шлях_adagrad[номер][0]:+.4f}")
print(f" темп: {(шлях_adagrad[50][0] - шлях_adagrad[0][0]) / 50:.4f} за крок на старті,"
f" {(шлях_adagrad[1000][0] - шлях_adagrad[300][0]) / 700:.4f} після трьохсотого")
✅ усі п'ять збігаються з лекцією AdaGrad програв навіть простому спуску — і це не помилка, а його головна вада: після 50 кроків w₁ = -2.8530 після 100 кроків w₁ = -2.3827 після 300 кроків w₁ = -1.3854 після 1000 кроків w₁ = -0.2892 темп: 0.0229 за крок на старті, 0.0016 після трьохсотого
Тепер той самий результат очима. Малюємо лінії рівня чаші й перші 120 кроків кожного методу.
сітка_w1 = np.linspace(-4.8, 1.4, 300)
сітка_w2 = np.linspace(-1.3, 1.3, 300)
СІТКА1, СІТКА2 = np.meshgrid(сітка_w1, сітка_w2)
ПОВЕРХНЯ = 0.5 * (КРИВИЗНА_ВЗДОВЖ * СІТКА1 ** 2 + КРИВИЗНА_ВПОПЕРЕК * СІТКА2 ** 2)
fig, ax = plt.subplots(figsize=(11, 4.2))
ax.contour(СІТКА1, СІТКА2, ПОВЕРХНЯ, levels=[0.05, 0.3, 1, 3, 8, 16], colors="0.7", linewidths=.8)
for назва, колір in [("простий спуск", "crimson"), ("момент 0.9", "teal"),
("RMSProp", "darkorange"), ("Adam", "#333333")]:
шлях = результати_яру[назва][0][:121]
ax.plot(шлях[:, 0], шлях[:, 1], marker=".", ms=3, lw=1, color=колір, label=назва)
ax.plot(0, 0, "k+", ms=14)
ax.set_xlabel("w₁ — пологий напрямок"); ax.set_ylabel("w₂ — крутий")
ax.set_title("Перші 120 кроків на ярі"); ax.legend(loc="lower right", fontsize=9)
plt.tight_layout(); plt.show()
print("Простий спуск (червоний) майже весь крок витрачає на стрибки поперек яру:")
print("за 120 кроків він проїхав уздовж лише", round(4 - abs(результати_яру["простий спуск"][0][120][0]), 3), "з 4.0")
Простий спуск (червоний) майже весь крок витрачає на стрибки поперек яру: за 120 кроків він проїхав уздовж лише 1.755 з 4.0
4 · Звідки береться виграш моментуМомент нічого не знає про яр. Він просто накопичує швидкість. Уздовж яру градієнтщокроку той самий за знаком, тому доданки складаються. Упоперек знак міняється щокроку,тому доданки гасять один одного. Подивимось на це числами.¶
точка = СТАРТ_ЯРУ.copy()
швидкість = np.zeros(2)
БЕТА = 0.9
print(f"{'крок':>5}{'g вздовж':>12}{'v вздовж':>12}{'g впоперек':>14}{'v впоперек':>14}")
for номер in range(1, 9):
g = градієнт_яру(точка)
швидкість = БЕТА * швидкість + g
точка = точка - КРОК_ЯРУ * швидкість
print(f"{номер:>5}{g[0]:>12.4f}{швидкість[0]:>12.4f}{g[1]:>14.4f}{швидкість[1]:>14.4f}")
print(f"\nвздовж: градієнт майже не змінився, а швидкість виросла в"
f" {abs(швидкість[0]) / abs(g[0]):.1f} раза")
print(f"межа зростання — 1/(1−β) = {1 / (1 - БЕТА):.0f} градієнтів")
print("впоперек: швидкість жодного разу не перевищила сам градієнт — накопичуватись нема чому")
крок g вздовж v вздовж g впоперек v впоперек
1 -0.2000 -0.2000 20.0000 20.0000
2 -0.1990 -0.3790 -18.4000 -0.4000
3 -0.1972 -0.5384 -17.6320 -17.9920
4 -0.1946 -0.6792 16.9126 0.7198
5 -0.1914 -0.8026 15.5305 16.1784
6 -0.1875 -0.9099 -15.5320 -0.9714
7 -0.1832 -1.0020 -13.6669 -14.5411
8 -0.1783 -1.0802 14.2521 1.1651
вздовж: градієнт майже не змінився, а швидкість виросла в 6.1 раза
межа зростання — 1/(1−β) = 10 градієнтів
впоперек: швидкість жодного разу не перевищила сам градієнт — накопичуватись нема чому
---# Частина 2 · Сідлова точка## 5 · Поверхня, де градієнт нульовий, але це не мінімумL(w₁, w₂) = 0.5·w₁² + 0.03·w₂⁴ − 0.06·w₂²У точці (0, 0) градієнт дорівнює нулю. Але це не дно: вздовж w₁ поверхня йде вгору,а вздовж w₂ — вниз, до двох справжніх мінімумів у (0, ±1). Це сідло, і в просторіз мільйоном вимірів таких точок набагато більше, ніж локальних ям.Стартуємо майже на самому гребені: (1.2, 0.02). Ціль — вибратись, тобто дійтидо |w₂| > 0.9.
СТАРТ_СІДЛА = np.array([1.2, 0.02])
КРОК_СІДЛА = 0.08
def втрата_сідла(точка):
return 0.5 * точка[0] ** 2 + 0.03 * точка[1] ** 4 - 0.06 * точка[1] ** 2
def градієнт_сідла(точка):
return np.array([точка[0], 0.12 * (точка[1] ** 3 - точка[1])])
g_на_гребені = градієнт_сідла(СТАРТ_СІДЛА)
print("градієнт на старті:", g_на_гребені)
print(f"складова, що виводить із сідла, дорівнює {abs(g_на_гребені[1]):.4f} —")
print(f"простий спуск зсуне по ній лише {КРОК_СІДЛА * abs(g_на_гребені[1]):.6f} за крок")
print("а адаптивні методи поділять цю складову на її ж власний масштаб і підуть повним кроком")
градієнт на старті: [ 1.2 -0.0024] складова, що виводить із сідла, дорівнює 0.0024 — простий спуск зсуне по ній лише 0.000192 за крок а адаптивні методи поділять цю складову на її ж власний масштаб і підуть повним кроком
def кроків_із_сідла(траєкторія, поріг=0.9):
вийшли = np.flatnonzero(np.abs(траєкторія[:, 1]) > поріг)
return int(вийшли[0]) if len(вийшли) else None
результати_сідла = {}
for назва, оптимізатор in ОПТИМІЗАТОРИ.items():
шлях = оптимізатор(градієнт_сідла, СТАРТ_СІДЛА, КРОК_СІДЛА, МЕЖА_ІТЕРАЦІЙ)
результати_сідла[назва] = (шлях, кроків_із_сідла(шлях))
print(f"{'оптимізатор':<16}{'кроків із сідла':>18}")
for назва, (шлях, кроків) in результати_сідла.items():
print(f"{назва:<16}{str(кроків):>18}")
assert результати_сідла["простий спуск"][1] == 485
assert результати_сідла["RMSProp"][1] == 5
print("\n✅ збігається з інтерактивом «Сідло» в лекції")
print("Різниця в сто разів — і причина одна: адаптивні методи не дивляться на довжину градієнта.")
оптимізатор кроків із сідла простий спуск 485 момент 0.9 72 AdaGrad 29 RMSProp 5 Adam 12 ✅ збігається з інтерактивом «Сідло» в лекції Різниця в сто разів — і причина одна: адаптивні методи не дивляться на довжину градієнта.
сітка_s1 = np.linspace(-1.5, 1.5, 300)
сітка_s2 = np.linspace(-1.5, 1.5, 300)
СС1, СС2 = np.meshgrid(сітка_s1, сітка_s2)
ПОВЕРХНЯ_СІДЛА = 0.5 * СС1 ** 2 + 0.03 * СС2 ** 4 - 0.06 * СС2 ** 2
fig, ax = plt.subplots(figsize=(6.4, 5.2))
ax.contour(СС1, СС2, ПОВЕРХНЯ_СІДЛА,
levels=[-0.028, -0.02, -0.01, 0.0, 0.02, 0.08, 0.2, 0.5, 1.0],
colors="0.7", linewidths=.8)
for назва, колір in [("простий спуск", "crimson"), ("момент 0.9", "teal"),
("RMSProp", "darkorange"), ("Adam", "#333333")]:
шлях = результати_сідла[назва][0][:60]
ax.plot(шлях[:, 0], шлях[:, 1], marker=".", ms=3, lw=1, color=колір, label=назва)
ax.plot(0, 0, "kx", ms=10); ax.plot([0, 0], [1, -1], "k+", ms=12, lw=0)
ax.set_xlabel("w₁"); ax.set_ylabel("w₂ — напрямок виходу із сідла")
ax.set_title("Перші 60 кроків біля сідла"); ax.legend(fontsize=9)
plt.tight_layout(); plt.show()
шлях_простого = результати_сідла["простий спуск"][0]
print("Простий спуск за 60 кроків підняв w₂ з 0.0200 лише до",
round(float(шлях_простого[60][1]), 4))
Простий спуск за 60 кроків підняв w₂ з 0.0200 лише до 0.0355
---# Частина 3 · Справжня мережа## 6 · Дошка оголошеньТа сама задача, що в темі 32: відрізнити шахрайськеоголошення про продаж телефона від чесного. Дві ознаки — вік акаунта продавця йвідхилення ціни від типової для цієї моделі.
кількість_оголошень = 1200
моделі = ["Alfa A5", "Alfa A7", "Beta 12", "Beta 12 Pro", "Gamma X", "Gamma X Ultra"]
ціна_нового = {"Alfa A5": 5200, "Alfa A7": 7400, "Beta 12": 12000,
"Beta 12 Pro": 17500, "Gamma X": 24000, "Gamma X Ultra": 34000}
модель = генератор.choice(моделі, size=кількість_оголошень, p=[0.24, 0.22, 0.18, 0.16, 0.12, 0.08])
рік = генератор.integers(2017, 2025, size=кількість_оголошень)
стан = генератор.choice(["нове", "дуже добре", "добре", "задовільне"],
size=кількість_оголошень, p=[0.08, 0.32, 0.42, 0.18])
памʼять = генератор.choice([64, 128, 256, 512], size=кількість_оголошень, p=[0.30, 0.38, 0.24, 0.08])
# більшість продавців мають свіжі акаунти, старих усе менше — звідси експоненційний розподіл
вік_акаунта = np.round(генератор.exponential(420, size=кількість_оголошень) + 3).astype(int)
коефіцієнт_стану = np.array(
[{"нове": 1.0, "дуже добре": 0.88, "добре": 0.75, "задовільне": 0.58}[s] for s in стан])
коефіцієнт_памʼяті = np.array(
[{64: 0.85, 128: 1.0, 256: 1.15, 512: 1.32}[m] for m in памʼять])
типова_ціна = (np.array([ціна_нового[m] for m in модель])
* 0.82 ** (2024 - рік)
* коефіцієнт_стану * коефіцієнт_памʼяті)
ціна = типова_ціна * генератор.lognormal(0, 0.13, size=кількість_оголошень)
print("оголошень:", кількість_оголошень)
print("типова ціна перших трьох:", типова_ціна[:3].round(0))
оголошень: 1200 типова ціна перших трьох: [ 3763. 3060. 12071.]
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 0.10 + 0.30 * np.exp(-вік_акаунта / 120)
шахрайське = генератор.random(кількість_оголошень) < шанс_шахрайства
# три чверті шахраїв ставлять різко занижену ціну, решта — завищену
ставить_дешево = генератор.random(кількість_оголошень) < 0.74
дешева_приманка = шахрайське & ставить_дешево
дорога_приманка = шахрайське & ~ставить_дешево
ціна[дешева_приманка] = типова_ціна[дешева_приманка] * генератор.uniform(0.20, 0.45, дешева_приманка.sum())
ціна[дорога_приманка] = типова_ціна[дорога_приманка] * генератор.uniform(2.6, 3.8, дорога_приманка.sum())
ціна = np.round(ціна, -1)
дошка = pd.DataFrame({"модель": модель, "вік_акаунта": вік_акаунта,
"ціна": ціна, "шахрайське": шахрайське})
print(f"частка шахрайських оголошень: {дошка['шахрайське'].mean():.3f}")
print(дошка.head(3).to_string(index=False))
частка шахрайських оголошень: 0.163
модель вік_акаунта ціна шахрайське
Beta 12 Pro 186 3090.0 False
Alfa A7 394 2810.0 False
Gamma X 44 33060.0 True
# «типової ціни» ніхто не знає — відновлюємо її медіаною по моделі, вона стійка до викидів
медіана_по_моделі = дошка.groupby("модель")["ціна"].transform("median")
# логарифм робить «удвічі дешевше» і «удвічі дорожче» симетричними навколо нуля
сирі_ознаки = np.c_[np.log(дошка["вік_акаунта"]), np.log(дошка["ціна"] / медіана_по_моделі)]
ознаки = (сирі_ознаки - сирі_ознаки.mean(axis=0)) / сирі_ознаки.std(axis=0)
мітки = дошка["шахрайське"].to_numpy().astype(float)
X_навч, X_тест, y_навч, y_тест = train_test_split(
ознаки, мітки, test_size=0.3, random_state=0, stratify=мітки)
print("навчальна вибірка:", X_навч.shape, "· тестова:", X_тест.shape)
print(f"базова точність «усі чесні»: {1 - y_тест.mean():.4f}")
навчальна вибірка: (840, 2) · тестова: (360, 2) базова точність «усі чесні»: 0.8361
7 · Мережа з теми 32Три функції — прямий прохід, втрата й градієнт — переносимо зпрактики теми 32 без змін. Оптимізатор їх нечіпає: він бере готовий градієнт і вирішує лише, як зробити крок.¶
def сигмоїда(z):
"""Аргумент обрізаємо, щоб експонента не переповнилась."""
return 1 / (1 + np.exp(-np.clip(z, -40, 40)))
def створити_мережу(прихованих, генератор_ваг):
"""Ваги випадкові, зсуви нульові: нулями ваги ініціалізувати не можна."""
return {"W1": генератор_ваг.normal(0, 1, (2, прихованих)) / np.sqrt(2),
"b1": np.zeros(прихованих),
"W2": генератор_ваг.normal(0, 1, (прихованих, 1)) / np.sqrt(прихованих),
"b2": np.zeros(1)}
def прямий_прохід(ваги, X):
A1 = np.tanh(X @ ваги["W1"] + ваги["b1"])
A2 = сигмоїда(A1 @ ваги["W2"] + ваги["b2"])
return A1, A2
def крос_ентропія(ваги, X, y):
прогноз = np.clip(прямий_прохід(ваги, X)[1][:, 0], 1e-12, 1 - 1e-12)
return float(-np.mean(y * np.log(прогноз) + (1 - y) * np.log(1 - прогноз)))
def градієнт_мережі(ваги, X, y):
"""Зворотне поширення з теми 32: три рядки на весь прохід назад."""
A1, A2 = прямий_прохід(ваги, X)
дельта2 = (A2 - y[:, None]) / len(y)
дельта1 = (дельта2 @ ваги["W2"].T) * (1 - A1 ** 2)
return {"W2": A1.T @ дельта2, "b2": дельта2.sum(axis=0),
"W1": X.T @ дельта1, "b1": дельта1.sum(axis=0)}
пробна_мережа = створити_мережу(8, np.random.default_rng(7))
print("параметрів у мережі 2 → 8 → 1:", sum(в.size for в in пробна_мережа.values()))
print("втрата до навчання:", round(крос_ентропія(пробна_мережа, X_навч, y_навч), 4))
параметрів у мережі 2 → 8 → 1:
33 втрата до навчання: 0.7841
8 · Той самий код кроку — тепер на словнику матрицьОптимізатори з частини 1 працювали з вектором із двох чисел. Мережа має чотиримасиви різної форми. Логіка не міняється жодним рядком — просто повторюємо ту самуарифметику для кожного масиву окремо.¶
def навчити(вид, крок, епох=60, партія=64, зерно_ваг=7, корекція=True):
"""Міні-батчеве навчання обраним оптимізатором. Повертає криву втрат і точність."""
ваги = створити_мережу(8, np.random.default_rng(зерно_ваг))
швидкість = {назва: np.zeros_like(значення) for назва, значення in ваги.items()}
квадрати = {назва: np.zeros_like(значення) for назва, значення in ваги.items()}
тасувальник = np.random.default_rng(0) # порядок батчів однаковий для всіх видів
номер_кроку = 0
крива = []
for _ in range(епох):
порядок = тасувальник.permutation(len(y_навч))
for початок in range(0, len(y_навч), партія):
індекси = порядок[початок:початок + партія]
g = градієнт_мережі(ваги, X_навч[індекси], y_навч[індекси])
номер_кроку += 1
for назва in ваги:
if вид == "простий спуск":
ваги[назва] -= крок * g[назва]
elif вид == "момент 0.9":
швидкість[назва] = 0.9 * швидкість[назва] + g[назва]
ваги[назва] -= крок * швидкість[назва]
elif вид == "AdaGrad":
квадрати[назва] += g[назва] ** 2
ваги[назва] -= крок * g[назва] / (np.sqrt(квадрати[назва]) + 1e-8)
elif вид == "RMSProp":
квадрати[назва] = 0.9 * квадрати[назва] + 0.1 * g[назва] ** 2
ваги[назва] -= крок * g[назва] / (np.sqrt(квадрати[назва]) + 1e-8)
elif вид == "Adam":
швидкість[назва] = 0.9 * швидкість[назва] + 0.1 * g[назва]
квадрати[назва] = 0.999 * квадрати[назва] + 0.001 * g[назва] ** 2
if корекція:
m = швидкість[назва] / (1 - 0.9 ** номер_кроку)
v = квадрати[назва] / (1 - 0.999 ** номер_кроку)
else:
m, v = швидкість[назва], квадрати[назва]
ваги[назва] -= крок * m / (np.sqrt(v) + 1e-8)
крива.append(крос_ентропія(ваги, X_навч, y_навч))
прогноз = прямий_прохід(ваги, X_тест)[1][:, 0]
точність = float(((прогноз > 0.5) == (y_тест > 0.5)).mean())
return крива, точність
крива_проби, точність_проби = навчити("Adam", 0.05)
print("Adam навчився. Втрата:", round(крива_проби[-1], 4), "· точність:", round(точність_проби, 4))
Adam навчився. Втрата: 0.2233 · точність: 0.925
Тепер усі п'ять з однаковим кроком 0.05, однаковою ініціалізацією й однаковимпорядком батчів. Різниця в результаті — це різниця самих оптимізаторів, і більше нічого.
криві = {}
print(f"{'оптимізатор':<16}{'втрата 10':>12}{'втрата 60':>12}{'точність':>12}")
for назва in ОПТИМІЗАТОРИ:
крива, точність = навчити(назва, 0.05)
криві[назва] = крива
print(f"{назва:<16}{крива[9]:>12.4f}{крива[-1]:>12.4f}{точність:>12.4f}")
оптимізатор втрата 10 втрата 60 точність простий спуск 0.3690 0.3025 0.8944
момент 0.9 0.2948 0.2303 0.9167 AdaGrad 0.3216 0.2736 0.8944
RMSProp 0.3119 0.2535 0.9000 Adam 0.2650 0.2233 0.9250
fig, ax = plt.subplots(figsize=(7.5, 4.4))
for назва, колір in [("простий спуск", "crimson"), ("момент 0.9", "teal"),
("AdaGrad", "seagreen"), ("RMSProp", "darkorange"), ("Adam", "#333333")]:
ax.plot(range(1, 61), криві[назва], color=колір, lw=1.6, label=назва)
ax.set_xlabel("епоха"); ax.set_ylabel("крос-ентропія на навчальній вибірці")
ax.set_title("Один крок 0.05 для всіх п'ятьох"); ax.grid(alpha=.25); ax.legend()
plt.tight_layout(); plt.show()
print("Adam і момент відриваються з перших епох; простий спуск повзе останнім.")
Adam і момент відриваються з перших епох; простий спуск повзе останнім.
9 · Чесна перевірка: а якщо крок підібрати кожному окремо?Порівняння з однаковим кроком показує механізми, але воно не відповідає на питання«який метод кращий». Adam сам масштабує свій крок, тому 0.05 йому підходить, а простомуспуску — ні. Дамо кожному його власний крок і подивимось ще раз.¶
print(f"{'оптимізатор':<16}{'крок':>8}{'втрата 60':>12}{'точність':>12}")
for назва, крок in [("простий спуск", 0.05), ("простий спуск", 0.5), ("простий спуск", 1.0),
("момент 0.9", 0.05), ("момент 0.9", 0.5), ("момент 0.9", 1.0),
("Adam", 0.05), ("Adam", 0.5)]:
крива, точність = навчити(назва, крок)
print(f"{назва:<16}{крок:>8}{крива[-1]:>12.4f}{точність:>12.4f}")
print("\nВисновок, який варто запам'ятати: з коробки виграє Adam,")
print("але налаштований SGD із моментом його наздоганяє — і це не суперечність.")
оптимізатор крок втрата 60 точність простий спуск 0.05 0.3025 0.8944
простий спуск 0.5 0.2467 0.9083
простий спуск 1.0 0.2428 0.9083 момент 0.9 0.05 0.2303 0.9167
момент 0.9 0.5 0.2186 0.9139 момент 0.9 1.0 0.2141 0.9222
Adam 0.05 0.2233 0.9250 Adam 0.5 0.2383 0.9194 Висновок, який варто запам'ятати: з коробки виграє Adam, але налаштований SGD із моментом його наздоганяє — і це не суперечність.
---# Частина 4 · Що робить корекція зміщенняОбидва середні в Adam стартують із нуля, тому на перших кроках вони занижені. Корекціяділить їх на 1 − β^t і повертає на місце. Порахуємо, у скільки разів фактичний кроквідрізняється від замовленого η, якщо корекцію не робити.
# при сталому градієнті: m = (1−β₁ᵗ)·g, √v = √(1−β₂ᵗ)·|g|, тож крок = η·(1−β₁ᵗ)/√(1−β₂ᵗ)
print(f"{'ітерація':>10}{'фактичний крок без корекції':>32}")
for t in (1, 2, 5, 10, 20, 50, 100, 1000):
відношення = (1 - 0.9 ** t) / np.sqrt(1 - 0.999 ** t)
print(f"{t:>10}{відношення:>28.2f}·η")
print("\nПоширена помилка — думати, що без корекції перші кроки надто МАЛІ.")
print("Насправді при стандартних β₁ = 0.9 і β₂ = 0.999 вони до 6.5 раза ЗАВЕЛИКІ:")
print("чисельник підтягується за десяток ітерацій, а знаменник — за тисячу.")
ітерація фактичний крок без корекції
1 3.16·η
2 4.25·η
5 5.80·η
10 6.53·η
20 6.24·η
50 4.50·η
100 3.24·η
1000 1.26·η
Поширена помилка — думати, що без корекції перші кроки надто МАЛІ.
Насправді при стандартних β₁ = 0.9 і β₂ = 0.999 вони до 6.5 раза ЗАВЕЛИКІ:
чисельник підтягується за десяток ітерацій, а знаменник — за тисячу.
print(f"{'крок η':>8}{'корекція':>12}{'втрата після 1 епохи':>24}{'точність':>12}")
for крок in (0.05, 0.3, 0.5):
for корекція in (True, False):
крива, точність = навчити("Adam", крок, корекція=корекція)
print(f"{крок:>8}{str(корекція):>12}{крива[0]:>24.4f}{точність:>12.4f}")
print("\nПри маленькому кроці зайва довжина навіть допомагає — ми ж далеко від мінімуму.")
print("При η = 0.5 та сама зайва довжина ламає першу епоху: втрата вчетверо гірша.")
print("Корекція потрібна не заради швидкості, а щоб η означало рівно η.")
крок η корекція втрата після 1 епохи точність
0.05 True 0.3616 0.9250
0.05 False 0.3227 0.9167
0.3 True 0.3331 0.9222
0.3 False 0.5295 0.8750
0.5 True 0.3417 0.9194
0.5 False 1.7594 0.8833 При маленькому кроці зайва довжина навіть допомагає — ми ж далеко від мінімуму. При η = 0.5 та сама зайва довжина ламає першу епоху: втрата вчетверо гірша. Корекція потрібна не заради швидкості, а щоб η означало рівно η.
10 · ⭐ Наша реалізація проти бібліотечноїMLPClassifier зі scikit-learn навчається тим самим Adam. Мережа в нас однакова —два входи, вісім нейронів із tanh, один вихід. Якщо всередині бібліотеки немає магії,точності мають зійтися.¶
бібліотечна = MLPClassifier(hidden_layer_sizes=(8,), activation="tanh", solver="adam",
max_iter=2000, random_state=0)
бібліотечна.fit(X_навч, y_навч)
_, наша_точність = навчити("Adam", 0.05)
точність_бібліотеки = бібліотечна.score(X_тест, y_тест)
print(f"наш Adam з нуля: {наша_точність:.4f}")
print(f"MLPClassifier: {точність_бібліотеки:.4f}")
assert abs(наша_точність - точність_бібліотеки) < 0.05, "розрахунок розійшовся!"
print("\n✅ збігається: усередині бібліотеки — ті самі десять рядків, що ми написали")
наш Adam з нуля: 0.9250 MLPClassifier: 0.9028 ✅ збігається: усередині бібліотеки — ті самі десять рядків, що ми написали
---# Завдання## 🟢 Рівень 1 — БазаДодай до ОПТИМІЗАТОРИ шостий метод — момент Нестерова. Різниця з класичниммоментом в одному рядку: градієнт беруть не в поточній точці, а в тій, кудивідносить накопичена швидкість (точка − крок·бета·швидкість).Зроблено, якщо: функція проходить по яру й по сідлу, а ти написав(ла), скількикроків їй знадобилось у кожному випадку порівняно зі звичайним моментом.## 🟡 Рівень 2 — ПлюсПобудуй графік «кроків до цілі на ярі» як функцію коефіцієнта моменту: прожени бетавід 0 до 0.99 із кроком 0.01 і намалюй криву.Зроблено, якщо: на графіку видно мінімум, ти назвав(ла) найкраще значення бетаз точністю до сотої й пояснив(ла) двома реченнями, чому крива росте в обидва боки від нього.## 🔴 Рівень 3 — ВикликДодай до функції навчити розклад швидкості навчання: косинусне згасання відпочаткового η до нуля за 60 епох. Порівняй з постійним кроком для RMSPropі для Adam на однаковому старті.Зроблено, якщо: ти показав(ла) числами, чи зменшився розкид останніх десятизначень кривої втрат, і пояснив(ла), чому саме адаптивні методи виграють відрозкладу більше за простий спуск.## Підказки* У сідлі важлива не швидкість самого спуску, а як метод ставиться до маленького градієнта. Подивись на першу ітерацію RMSProp: у скільки разів вона довша за η?* Крива «кроків від бета» не гладка — сусідні значення можуть відрізнятись на десятки кроків. Це нормально: критерій «зайшов і не вийшов» дискретний.* Косинусний розклад — це один рядок: крок_зараз = η · 0.5 · (1 + cos(π · епоха / епох)).