Практика · Активації та ініціалізація¶
📖 Лекція: lecture.html · 🧪 Тест: quiz.html · 📝 Домашнє: homework.md
Лекція стверджувала три речі числами: похідна сигмоїди ніде не перевищує 0.25, завеликий крок навчання вбиває половину шару ReLU непомітно для метрик, а мережа з нульовим стартом не зрушить жодною вагою. Тут ми перевіримо кожну з них руками на тій самій дошці оголошень про вживані телефони, що в темах 22 і 32.
Що зробимо:
- намалюємо шість активацій разом із їхніми похідними й знайдемо максимум похідної чисельно;
- зберемо дошку оголошень і витягнемо дві ознаки;
- зміряємо норму градієнта на кожному з десяти шарів для сигмоїди, tanh і ReLU — затухання буде видно таблицею;
- порахуємо, скільки нейронів помирає при семи різних швидкостях навчання;
- порівняємо пʼять ініціалізацій за дисперсією активацій по шарах;
- навчимо ту саму мережу двічі — з нулів і з He. Перша не зрушить із місця.
Про позначення. У лекції формули записані для одного обʼєкта-стовпчика (
z = Wa + b). У коді обʼєкти зручніше тримати рядками таблиці, тому всі матриці транспоновані:Z = AW + b. Це та сама математика, записана навпаки.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.utils.extmath import softmax as softmax_бібліотечний
# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
генератор = 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 · Функції й похідні в одному місці¶
Кожна активація — це пара: сама функція для прямого проходу й її похідна для зворотного.
Записуємо їх поруч, бо в коді вони завжди ходять парою: прямий прохід рахує a, зворотний
одразу питає f'(z).
Зверни увагу на сигмоїду й tanh: їхню похідну зручніше рахувати не з z, а з уже готового
a — прямий прохід його вже порахував, і другий раз експоненту брати не треба.
def сигмоїда(z):
"""σ(z) = 1/(1+e^-z). Аргумент обрізаємо, щоб експонента не переповнилась:
на результат це не впливає, бо σ(-40) уже нуль із точністю float."""
return 1 / (1 + np.exp(-np.clip(z, -40, 40)))
def похідна_сигмоїди(z):
a = сигмоїда(z)
return a * (1 - a)
def похідна_tanh(z):
a = np.tanh(z)
return 1 - a * a
def relu(z):
return np.maximum(0.0, z)
def похідна_relu(z):
# у нулі похідна не визначена; беремо 0 — так роблять усі бібліотеки
return (z > 0).astype(float)
def leaky_relu(z, нахил=0.01):
return np.where(z > 0, z, нахил * z)
def похідна_leaky_relu(z, нахил=0.01):
return np.where(z > 0, 1.0, нахил)
def elu(z):
return np.where(z > 0, z, np.exp(np.clip(z, -40, 0)) - 1)
def похідна_elu(z):
return np.where(z > 0, 1.0, np.exp(np.clip(z, -40, 0)))
def нормальна_щільність(z):
return np.exp(-z * z / 2) / np.sqrt(2 * np.pi)
print("оголошено функцій:", 12)
оголошено функцій: 12
2 · Φ(z) без сторонніх бібліотек¶
GELU потребує функції розподілу стандартної нормальної величини Φ(z). У чистому numpy
її немає, тому беремо класичне наближення Abramowitz-Stegun: похибка не більша за 1.5e-7,
чого для графіка й для розрахунку похідної більш ніж досить.
Оголосимо Φ тут і одразу звіримо її з точним значенням у трьох точках, відомих із таблиць:
без такої звірки наближення легко переплутати з помилкою.
def erf_наближено(x):
"""Abramowitz-Stegun 7.1.26. Формула довга, але це лише многочлен від t."""
знак = np.sign(x)
x = np.abs(x)
t = 1 / (1 + 0.3275911 * x)
многочлен = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t
- 0.284496736) * t + 0.254829592) * t
return знак * (1 - многочлен * np.exp(-x * x))
def нормальна_функція_розподілу(z):
return 0.5 * (1 + erf_наближено(z / np.sqrt(2)))
def gelu(z):
return z * нормальна_функція_розподілу(z)
def похідна_gelu(z):
# добуток: (z·Φ)' = Φ + z·φ
return нормальна_функція_розподілу(z) + z * нормальна_щільність(z)
# точні значення Φ у трьох точках, відомі з таблиць: Φ(-1), Φ(0), Φ(1)
точні = np.array([0.158655253931, 0.5, 0.841344746069])
наші = нормальна_функція_розподілу(np.array([-1.0, 0.0, 1.0]))
print("наші значення Φ:", наші)
print("максимальна похибка:", np.abs(наші - точні).max())
assert np.allclose(наші, точні, atol=1e-6), "наближення Φ розійшлося з таблицею!"
print("✅ наближення Φ придатне")
наші значення Φ: [0.1587 0.5 0.8413] максимальна похибка: 9.901363728914703e-09 ✅ наближення Φ придатне
3 · Максимум похідної — чисельно¶
Тепер найголовніше число теми. Пройдемо густою сіткою по z і знайдемо, де похідна кожної
функції найбільша. Ніяких формул — просто дивимось на максимум масиву.
Очікуємо побачити рівно те, що казала лекція: 0.25 у сигмоїди, 1.0 у tanh, ReLU, Leaky й ELU і 1.129 у GELU.
сітка = np.linspace(-8, 8, 400001) # крок 4e-5: максимум знайдемо з чотирма знаками
активації = {
"sigmoid": (сигмоїда, похідна_сигмоїди),
"tanh": (np.tanh, похідна_tanh),
"ReLU": (relu, похідна_relu),
"Leaky ReLU": (leaky_relu, похідна_leaky_relu),
"ELU": (elu, похідна_elu),
"GELU": (gelu, похідна_gelu),
}
рядки = []
for назва, (функція, похідна) in активації.items():
значення_похідної = похідна(сітка)
індекс_максимуму = значення_похідної.argmax()
рядки.append({
"активація": назва,
"максимум похідної": round(float(значення_похідної[індекс_максимуму]), 4),
"у точці z": round(float(сітка[індекс_максимуму]), 2),
"мінімум похідної": round(float(значення_похідної.min()), 4),
})
таблиця_похідних = pd.DataFrame(рядки)
print(таблиця_похідних.to_string(index=False))
активація максимум похідної у точці z мінімум похідної
sigmoid 0.2500 0.00 0.0003
tanh 1.0000 0.00 0.0000
ReLU 1.0000 0.00 0.0000
Leaky ReLU 1.0000 0.00 0.0100
ELU 1.0000 0.00 0.0003
GELU 1.1289 1.41 -0.1289
Перший рядок — це та сама чверть, з якої виростає вся перша половина лекції. Сигмоїда фізично не здатна пропустити назад більше ніж чверть градієнта, і не «в поганому випадку», а ніколи.
Порахуймо одразу, що це означає на десяти шарах.
глибина = 10
for назва in ["sigmoid", "tanh", "ReLU", "GELU"]:
стеля = таблиця_похідних.loc[таблиця_похідних["активація"] == назва,
"максимум похідної"].iloc[0]
print(f"{назва:<11} стеля {стеля:6.4f} → крізь {глибина} шарів пройде "
f"щонайбільше {стеля ** глибина:.3e}")
sigmoid стеля 0.2500 → крізь 10 шарів пройде щонайбільше 9.537e-07 tanh стеля 1.0000 → крізь 10 шарів пройде щонайбільше 1.000e+00 ReLU стеля 1.0000 → крізь 10 шарів пройде щонайбільше 1.000e+00 GELU стеля 1.1289 → крізь 10 шарів пройде щонайбільше 3.362e+00
4 · Малюємо: функція вгорі, похідна внизу¶
Той самий графік, що в інтерактиві 1 лекції, тільки всі шість функцій одразу. Верхній ряд — самі функції, нижній — похідні. Пунктир на нижньому — рівень 0.25.
вісь = np.linspace(-6, 6, 601)
фігура, осі = plt.subplots(2, 1, figsize=(9, 7), sharex=True)
for назва, (функція, похідна) in активації.items():
осі[0].plot(вісь, функція(вісь), label=назва, linewidth=1.8)
осі[1].plot(вісь, похідна(вісь), label=назва, linewidth=1.8)
осі[0].set_ylim(-1.6, 3.2)
осі[0].set_ylabel("f(z)")
осі[0].set_title("Шість активацій та їхні похідні")
осі[0].grid(alpha=.25)
осі[0].legend(ncol=3, fontsize=9)
# рівень 0.25 — стеля похідної сигмоїди, головне число теми
осі[1].axhline(0.25, color="crimson", linestyle="--", linewidth=1.2)
осі[1].text(-5.9, 0.29, "0.25 — стеля сигмоїди", color="crimson", fontsize=9)
осі[1].axhline(1.0, color="gray", linestyle=":", linewidth=1.0)
осі[1].set_ylim(-0.35, 1.35)
осі[1].set_ylabel("f'(z)")
осі[1].set_xlabel("вхід нейрона z")
осі[1].grid(alpha=.25)
plt.tight_layout()
plt.show()
print("Верхній ряд визначає, що мережа рахує. Нижній — чи вона взагалі навчиться.")
Верхній ряд визначає, що мережа рахує. Нижній — чи вона взагалі навчиться.
5 · Перевірка «наша реалізація = бібліотечна»¶
Softmax ми в лекції обговорювали, але не писали. Напишімо — і звіримо зі scikit-learn.
Ключова деталь: перед експонентою віднімаємо максимум рядка. Математично це нічого не змінює
(множник скорочується в чисельнику й знаменнику), а від переповнення рятує.
def наш_softmax(логіти):
"""Перетворює K чисел на розподіл: усі додатні, сума по рядку дорівнює 1."""
зсунуті = логіти - логіти.max(axis=1, keepdims=True) # захист від переповнення exp
експоненти = np.exp(зсунуті)
return експоненти / експоненти.sum(axis=1, keepdims=True)
# числа задаємо руками, а не генератором: так приклад легко перечитати очима,
# і головний генератор теми лишається незайманим до побудови дошки оголошень
приклад_логітів = np.array([
[2.0, 1.0, 0.1, -1.5], # є явний лідер
[0.0, 0.0, 0.0, 0.0], # усі однакові — має вийти рівномірний розподіл
[8.0, 7.5, 7.0, 6.0], # великі числа: без зсуву exp тут переповнилась би
])
наш_результат = наш_softmax(приклад_логітів)
бібліотечний_результат = softmax_бібліотечний(приклад_логітів.copy())
print("наш softmax:\n", наш_результат.round(4))
print("сума по кожному рядку:", наш_результат.sum(axis=1))
assert np.allclose(наш_результат, бібліотечний_результат), "розрахунок розійшовся!"
print("✅ збігається зі scikit-learn")
наш softmax: [[0.6461 0.2377 0.0966 0.0195] [0.25 0.25 0.25 0.25 ] [0.474 0.2875 0.1744 0.0641]] сума по кожному рядку: [1. 1. 1.] ✅ збігається зі scikit-learn
Друга перевірка — самої похідної. Візьмімо визначення похідної в лоб (центральна різниця, як у темі 32) і звіримо з нашими формулами. Якщо десь у похідній помилка, вона вилізе саме тут.
крок = 1e-6
точки = np.linspace(-4, 4, 17)
print(f"{'активація':<12}{'найбільша відносна різниця':>28}")
for назва, (функція, похідна) in активації.items():
# ReLU і Leaky мають злам у нулі — там чисельна похідна брехатиме, тому нуль обходимо
точки_без_зламу = точки[np.abs(точки) > 1e-3]
чисельно = (функція(точки_без_зламу + крок) - функція(точки_без_зламу - крок)) / (2 * крок)
аналітично = похідна(точки_без_зламу)
різниця = np.abs(чисельно - аналітично).max() / (np.abs(аналітично).max() + 1e-12)
print(f"{назва:<12}{різниця:>28.2e}")
assert різниця < 1e-5, f"похідна {назва} не сходиться з чисельною!"
print("✅ усі шість похідних збігаються з чисельними")
активація найбільша відносна різниця sigmoid 2.67e-10 tanh 2.88e-11 ReLU 1.40e-10 Leaky ReLU 1.40e-10 ELU 1.40e-10 GELU 4.57e-07 ✅ усі шість похідних збігаються з чисельними
Частина 2 · Дошка оголошень¶
Далі потрібні справжні дані. Збираємо ту саму дошку оголошень про вживані телефони, що й у темі про розвідку даних та в темі про backprop — і ті самі дві ознаки.
6 · Будуємо дані¶
кількість_оголошень = 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)
print("оголошень:", кількість_оголошень)
print("вік акаунта перших пʼятьох:", вік_акаунта[:5])
оголошень: 1200 вік акаунта перших пʼятьох: [186 394 44 777 351]
# «типова» ціна — скільки телефон коштує за паспортом
коефіцієнт_стану = 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 - рік) # телефон дешевшає приблизно на 18 % за рік
* коефіцієнт_стану * коефіцієнт_памʼяті)
ціна = типова_ціна * генератор.lognormal(0, 0.13, size=кількість_оголошень)
print("типова ціна перших пʼятьох:", типова_ціна[:5].round(0))
print("ціна в оголошенні :", ціна[:5].round(0))
типова ціна перших пʼятьох: [ 3763. 3060. 12071. 8322. 1499.] ціна в оголошенні : [ 3085. 2810. 10822. 8346. 1852.]
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 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({"модель": модель, "вік_акаунта": вік_акаунта,
"ціна": ціна, "шахрайське": шахрайське.astype(int)})
print("шахрайських оголошень:", int(дошка["шахрайське"].sum()), "з", len(дошка))
дошка.head()
шахрайських оголошень: 196 з 1200
| модель | вік_акаунта | ціна | шахрайське | |
|---|---|---|---|---|
| 0 | Beta 12 Pro | 186 | 3090.0 | 0 |
| 1 | Alfa A7 | 394 | 2810.0 | 0 |
| 2 | Gamma X | 44 | 33060.0 | 1 |
| 3 | Beta 12 Pro | 777 | 8350.0 | 0 |
| 4 | Alfa A5 | 351 | 1850.0 | 0 |
7 · Дві ознаки¶
Ті самі, що в темі 32: вік акаунта й відхилення ціни від медіани по моделі. Обидві — в логарифмі (щоб «удвічі дешевше» і «удвічі дорожче» були однаково далеко від нуля) і стандартизовані.
медіана_по_моделі = дошка.groupby("модель")["ціна"].transform("median")
сирі_ознаки = np.column_stack([
np.log(дошка["вік_акаунта"].to_numpy()),
np.log(дошка["ціна"].to_numpy() / медіана_по_моделі.to_numpy()),
])
# без стандартизації одна ознака перекрила б другу просто через масштаб
ознаки = (сирі_ознаки - сирі_ознаки.mean(axis=0)) / сирі_ознаки.std(axis=0)
мітки = дошка["шахрайське"].to_numpy().astype(float)
print("форма таблиці ознак:", ознаки.shape)
print("середні після стандартизації:", ознаки.mean(axis=0).round(6))
print(f"частка шахрайських: {мітки.mean():.3f}")
print(f"точність тупої відповіді «усі чесні»: {1 - мітки.mean():.4f}")
форма таблиці ознак: (1200, 2) середні після стандартизації: [-0. 0.] частка шахрайських: 0.163 точність тупої відповіді «усі чесні»: 0.8367
Частина 3 · Затухання градієнта по шарах¶
8 · Глибока мережа й один зворотний прохід¶
Будуємо мережу з десяти прихованих шарів по 32 нейрони і робимо один зворотний прохід.
Нас цікавить не навчання, а одне число на кожному шарі: норма провини ‖δ‖.
Ваги беремо правильного масштабу — Xavier для сигмоїди й tanh, He для ReLU. Це важливо: якби ваги були абияк, ми міряли б не активацію, а свою помилку в ініціалізації.
def побудувати_глибоку(активація, шарів=10, ширина=32, зерно=7):
"""Ваги масштабуємо під активацію: He для ReLU, Xavier для решти."""
свій_генератор = np.random.default_rng(зерно)
ваги, зсуви = [], []
входів = ознаки.shape[1]
for _ in range(шарів):
множник = 2 if активація == "relu" else 1
ваги.append(свій_генератор.normal(0, np.sqrt(множник / входів), (входів, ширина)))
зсуви.append(np.zeros(ширина))
входів = ширина
ваги_виходу = свій_генератор.normal(0, np.sqrt(1 / ширина), (ширина, 1))
return ваги, зсуви, ваги_виходу
def норми_провини(активація):
"""Повертає ‖δ‖ на кожному шарі — від першого до останнього."""
ваги, зсуви, ваги_виходу = побудувати_глибоку(активація)
активація_шару = ознаки
суми, активації_шарів = [], [ознаки]
for W, b in zip(ваги, зсуви):
z = активація_шару @ W + b
суми.append(z)
активація_шару = relu(z) if активація == "relu" else (
сигмоїда(z) if активація == "sigmoid" else np.tanh(z))
активації_шарів.append(активація_шару)
прогноз = сигмоїда(активація_шару @ ваги_виходу)
# градієнт крос-ентропії по виходу — та сама різниця «прогноз мінус правда» з теми 32
провина = (прогноз - мітки.reshape(-1, 1)) / len(ознаки)
норми = [None] * len(ваги)
for шар in range(len(ваги) - 1, -1, -1):
наступні_ваги = ваги[шар + 1] if шар + 1 < len(ваги) else ваги_виходу
провина = провина @ наступні_ваги.T
if активація == "relu":
провина = провина * (суми[шар] > 0)
elif активація == "sigmoid":
a = активації_шарів[шар + 1]
провина = провина * a * (1 - a)
else:
a = активації_шарів[шар + 1]
провина = провина * (1 - a * a)
норми[шар] = float(np.linalg.norm(провина))
return np.array(норми)
норми = {назва: норми_провини(назва) for назва in ["sigmoid", "tanh", "relu"]}
таблиця_затухання = pd.DataFrame(норми, index=[f"шар {i}" for i in range(1, 11)])
print(таблиця_затухання.to_string(float_format=lambda v: f"{v:.3e}"))
sigmoid tanh relu шар 1 1.139e-08 7.576e-03 8.254e-03 шар 2 4.844e-08 8.581e-03 1.014e-02 шар 3 1.805e-07 8.935e-03 1.072e-02 шар 4 6.943e-07 9.095e-03 1.014e-02 шар 5 3.243e-06 1.018e-02 9.705e-03 шар 6 1.303e-05 1.102e-02 9.571e-03 шар 7 5.001e-05 1.112e-02 1.203e-02 шар 8 2.595e-04 1.421e-02 1.270e-02 шар 9 1.073e-03 1.454e-02 1.323e-02 шар 10 4.515e-03 1.639e-02 1.449e-02
Читай знизу вгору: останній рядок — те, що вийшло з функції втрат, перший — те, що дійшло до першого шару. Тепер переведімо це у відношення, як у лекції.
підсумок = []
for назва, значення in норми.items():
відношення = значення[0] / значення[-1]
підсумок.append({
"активація": назва,
"дійшло до 1-го шару": f"{відношення:.2e}",
"множник на шар": round(відношення ** (1 / 9), 3),
"кроків замість 1000": f"{1000 / відношення:.2e}",
})
print(pd.DataFrame(підсумок).to_string(index=False))
print()
print("Сигмоїда: множник на шар близький до теоретичних 0.25 з лекції.")
print("ReLU: перший шар отримує майже те саме, що останній.")
активація дійшло до 1-го шару множник на шар кроків замість 1000
sigmoid 2.52e-06 0.239 3.97e+08
tanh 4.62e-01 0.918 2.16e+03
relu 5.69e-01 0.939 1.76e+03
Сигмоїда: множник на шар близький до теоретичних 0.25 з лекції.
ReLU: перший шар отримує майже те саме, що останній.
фігура, вісь_графіка = plt.subplots(figsize=(8, 4.5))
номери_шарів = np.arange(1, 11)
for назва, значення in норми.items():
вісь_графіка.semilogy(номери_шарів, значення, marker="o", label=назва)
вісь_графіка.set_xlabel("номер шару (1 — вхід, 10 — вихід)")
вісь_графіка.set_ylabel("норма провини ‖δ‖, логарифмічна шкала")
вісь_графіка.set_title("Що доходить до першого шару")
вісь_графіка.grid(alpha=.3, which="both")
вісь_графіка.legend()
plt.tight_layout()
plt.show()
print("Сигмоїда падає прямою — це і є множення на сталий множник, менший за одиницю.")
Сигмоїда падає прямою — це і є множення на сталий множник, менший за одиницю.
Частина 4 · Скільки нейронів помирає¶
9 · Мережа 2 → 64 → 1 і сім швидкостей навчання¶
Тепер повний цикл навчання. Мережа проста: два входи, шістдесят чотири нейрони ReLU, один вихід із сигмоїдою. Ініціалізація He, повний градієнтний спуск, 300 епох. Міняємо тільки швидкість навчання.
Мертвим вважаємо нейрон, у якого z ≤ 0 на всіх обʼєктах навчальної вибірки: такий уже
ніколи не отримає градієнта.
def навчити_relu_мережу(крок_навчання, епох=300, нейронів=64, зерно=42, старт="he"):
"""Повертає історію втрат, кількість мертвих нейронів і точність."""
свій_генератор = np.random.default_rng(зерно)
if старт == "нулі":
W1 = np.zeros((2, нейронів))
W2 = np.zeros((нейронів, 1))
else:
W1 = свій_генератор.normal(0, np.sqrt(2 / 2), (2, нейронів))
W2 = свій_генератор.normal(0, np.sqrt(2 / нейронів), (нейронів, 1))
b1 = np.zeros(нейронів)
b2 = np.zeros(1)
ціль = мітки.reshape(-1, 1)
n = len(ознаки)
історія = []
for _ in range(епох):
сума1 = ознаки @ W1 + b1
актив1 = relu(сума1)
прогноз = сигмоїда(актив1 @ W2 + b2)
безпечний = np.clip(прогноз, 1e-12, 1 - 1e-12)
історія.append(float(-np.mean(ціль * np.log(безпечний)
+ (1 - ціль) * np.log(1 - безпечний))))
# ділення на n одразу тут: далі всі градієнти вже усереднені по вибірці
провина2 = (прогноз - ціль) / n
градієнт_W2 = актив1.T @ провина2
градієнт_b2 = провина2.sum(axis=0)
провина1 = (провина2 @ W2.T) * (сума1 > 0)
градієнт_W1 = ознаки.T @ провина1
градієнт_b1 = провина1.sum(axis=0)
W1 -= крок_навчання * градієнт_W1
b1 -= крок_навчання * градієнт_b1
W2 -= крок_навчання * градієнт_W2
b2 -= крок_навчання * градієнт_b2
сума1 = ознаки @ W1 + b1
прогноз = сигмоїда(relu(сума1) @ W2 + b2)[:, 0]
безпечний = np.clip(прогноз, 1e-12, 1 - 1e-12)
# останній замір робимо вже ПІСЛЯ фінального кроку, інакше історія відстає на епоху
історія.append(float(-np.mean(мітки * np.log(безпечний)
+ (1 - мітки) * np.log(1 - безпечний))))
мертвих = int((сума1.max(axis=0) <= 0).sum()) # жодного обʼєкта в додатній зоні
точність = float(((прогноз > 0.5) == мітки).mean())
return історія, мертвих, точність, (W1, W2)
рядки_смерті = []
for крок in [0.1, 1, 5, 10, 20, 50, 100]:
історія, мертвих, точність, _ = навчити_relu_мережу(крок)
рядки_смерті.append({"крок η": крок, "мертвих із 64": мертвих,
"частка шару": f"{мертвих / 64:.0%}",
"втрата": round(історія[-1], 3),
"точність": round(точність, 3)})
таблиця_смерті = pd.DataFrame(рядки_смерті)
print(таблиця_смерті.to_string(index=False))
крок η мертвих із 64 частка шару втрата точність
0.1 0 0% 0.263 0.915
1.0 0 0% 0.223 0.929
5.0 17 27% 0.221 0.924
10.0 35 55% 0.238 0.924
20.0 38 59% 0.423 0.870
50.0 61 95% 0.541 0.865
100.0 64 100% 4.513 0.837
Це та сама таблиця, що в лекції, і в ній та сама пастка. При η = 5 уже мертва чверть шару,
а точність найкраща за всю таблицю. При η = 10 мертва більшість — і точність не змінилась.
Мертві нейрони не дають ні помилки, ні провалу метрики.
Побачити катастрофу можна лише в останньому рядку — і от що там насправді відбувається.
історія, мертвих, точність, _ = навчити_relu_мережу(100)
print(f"крок 100: мертвих {мертвих} з 64, точність {точність:.4f}")
print(f"точність відповіді «усі оголошення чесні»: {1 - мітки.mean():.4f}")
print()
print("Мережа з повністю мертвим шаром видає константу — і її точність")
print("дорівнює частці чесних оголошень. Вона не навчилась нічому.")
крок 100: мертвих 64 з 64, точність 0.8367 точність відповіді «усі оголошення чесні»: 0.8367 Мережа з повністю мертвим шаром видає константу — і її точність дорівнює частці чесних оголошень. Вона не навчилась нічому.
Частина 5 · Ініціалізація¶
10 · Чому завеликі ваги псують справу¶
Перш ніж рахувати дисперсії, перевірмо окреме твердження лекції: великі ваги заганяють сигмоїду в насичення ще до першого кроку навчання. Нейрон зі ста входами й вагами зі стандартним відхиленням 1 отримує зважену суму зі стандартним відхиленням 10 — і на такій відстані від нуля похідна сигмоїди майже нульова.
входів_у_нейрон = 100
окремий_генератор = np.random.default_rng(0) # дослід не має зачіпати головний генератор
for відхилення_ваг in [0.1, 1.0]:
# дисперсія суми = кількість входів × дисперсія ваги × дисперсія входу
сума = окремий_генератор.normal(0, відхилення_ваг * np.sqrt(входів_у_нейрон), 200_000)
похідна = похідна_сигмоїди(сума)
print(f"відхилення ваг {відхилення_ваг:>4}: std(z) = {сума.std():5.2f}, "
f"середня похідна = {похідна.mean():.4f}, "
f"частка |z| > 4: {(np.abs(сума) > 4).mean():.3f}")
print()
print("Праворуч від межі |z| > 4 сигмоїда практично пласка: градієнт туди не проходить.")
відхилення ваг 0.1: std(z) = 1.00, середня похідна = 0.2066, частка |z| > 4: 0.000 відхилення ваг 1.0: std(z) = 10.02, середня похідна = 0.0390, частка |z| > 4: 0.691 Праворуч від межі |z| > 4 сигмоїда практично пласка: градієнт туди не проходить.
11 · Дисперсія активацій по десяти шарах¶
Той самий дослід, що в інтерактиві 3 лекції. Прямий прохід крізь десять шарів по 64 нейрони з ReLU, чотири способи почати. Дивимось на дисперсію активацій кожного шару.
Вимога проста: дисперсія має лишатись приблизно тією самою. Якщо вона множиться на 0.003 — сигнал згасне; якщо на 30 — вибухне.
def дисперсії_по_шарах(спосіб, шарів=10, ширина=64, зерно=3):
"""Прямий прохід крізь глибокий стос ReLU. Повертає дисперсію кожного шару."""
свій_генератор = np.random.default_rng(зерно)
активація_шару = ознаки
входів = ознаки.shape[1]
результат = []
for _ in range(шарів):
if спосіб == "нулі":
W = np.zeros((входів, ширина))
elif спосіб == "малі":
W = свій_генератор.normal(0, 0.01, (входів, ширина))
elif спосіб == "великі":
W = свій_генератор.normal(0, 1.0, (входів, ширина))
elif спосіб == "Xavier":
W = свій_генератор.normal(0, np.sqrt(1 / входів), (входів, ширина))
else:
W = свій_генератор.normal(0, np.sqrt(2 / входів), (входів, ширина))
активація_шару = relu(активація_шару @ W)
входів = ширина
результат.append(float(активація_шару.var()))
return np.array(результат)
способи = ["нулі", "малі", "великі", "Xavier", "He"]
дисперсії = {спосіб: дисперсії_по_шарах(спосіб) for спосіб in способи}
таблиця_дисперсій = pd.DataFrame(дисперсії, index=[f"шар {i}" for i in range(1, 11)])
print(таблиця_дисперсій.to_string(float_format=lambda v: f"{v:.3e}"))
нулі малі великі Xavier He шар 1 0.000e+00 8.900e-05 8.900e-01 4.450e-01 8.900e-01 шар 2 0.000e+00 2.942e-07 2.942e+01 2.299e-01 9.195e-01 шар 3 0.000e+00 8.768e-10 8.768e+02 1.070e-01 8.563e-01 шар 4 0.000e+00 3.212e-12 3.212e+04 6.126e-02 9.802e-01 шар 5 0.000e+00 1.123e-14 1.123e+06 3.348e-02 1.071e+00 шар 6 0.000e+00 4.710e-17 4.710e+07 2.193e-02 1.404e+00 шар 7 0.000e+00 1.360e-19 1.360e+09 9.892e-03 1.266e+00 шар 8 0.000e+00 3.904e-22 3.904e+10 4.438e-03 1.136e+00 шар 9 0.000e+00 1.350e-24 1.350e+12 2.399e-03 1.228e+00 шар 10 0.000e+00 2.276e-27 2.276e+13 6.319e-04 6.470e-01
рядки_ініціалізації = []
for спосіб, значення in дисперсії.items():
if значення[0] > 0 and значення[-1] > 0:
множник = (значення[-1] / значення[0]) ** (1 / 9)
множник_текст = f"{множник:.4g}"
else:
множник_текст = "0"
рядки_ініціалізації.append({
"спосіб": спосіб,
"Var 1-го шару": f"{значення[0]:.2e}",
"Var 10-го шару": f"{значення[-1]:.2e}",
"множник на шар": множник_текст,
})
print(pd.DataFrame(рядки_ініціалізації).to_string(index=False))
print()
print("Теорія з лекції: множник дорівнює n·Var(w)/2, бо ReLU обнуляє половину сигналу.")
print(f"Для ширини 64: Xavier дає {64 * (1 / 64) / 2:.2f}, He дає {64 * (2 / 64) / 2:.2f}.")
спосіб Var 1-го шару Var 10-го шару множник на шар
нулі 0.00e+00 0.00e+00 0
малі 8.90e-05 2.28e-27 0.003089
великі 8.90e-01 2.28e+13 30.89
Xavier 4.45e-01 6.32e-04 0.4826
He 8.90e-01 6.47e-01 0.9652
Теорія з лекції: множник дорівнює n·Var(w)/2, бо ReLU обнуляє половину сигналу.
Для ширини 64: Xavier дає 0.50, He дає 1.00.
фігура, вісь_графіка = plt.subplots(figsize=(8, 4.5))
for спосіб, значення in дисперсії.items():
видимі = np.where(значення > 0, значення, np.nan) # нулі на лог-шкалу не лягають
вісь_графіка.semilogy(np.arange(1, 11), видимі, marker="o", label=спосіб)
вісь_графіка.axhline(1.0, color="gray", linestyle="--", linewidth=1)
вісь_графіка.set_xlabel("номер шару")
вісь_графіка.set_ylabel("дисперсія активацій, логарифмічна шкала")
вісь_графіка.set_title("Три різні долі однієї мережі")
вісь_графіка.grid(alpha=.3, which="both")
вісь_графіка.legend()
plt.tight_layout()
plt.show()
print("Криву «нулі» не видно: її дисперсія рівно нуль, а нуль на логарифмічну шкалу не лягає.")
Криву «нулі» не видно: її дисперсія рівно нуль, а нуль на логарифмічну шкалу не лягає.
12 · Нулі не вчаться взагалі¶
Останній дослід — найпростіший і найпереконливіший. Беремо ту саму мережу 2 → 64 → 1 і навчаємо її двічі: з нульового старту й з He. Крок і кількість епох однакові.
Дивимось не лише на втрату, а й на найбільшу за модулем вагу після навчання. Якщо міркування лекції правильне, у нульової мережі вона так і лишиться нулем.
історія_нулі, _, точність_нулі, ваги_нулі = навчити_relu_мережу(1.0, епох=400, старт="нулі")
історія_he, _, точність_he, ваги_he = навчити_relu_мережу(1.0, епох=400, старт="he")
print(f"{'старт':<8}{'втрата':>10}{'точність':>12}{'max|W1|':>12}{'max|W2|':>12}")
for назва, історія, точність, ваги in [("нулі", історія_нулі, точність_нулі, ваги_нулі),
("He", історія_he, точність_he, ваги_he)]:
print(f"{назва:<8}{історія[-1]:>10.4f}{точність:>12.4f}"
f"{np.abs(ваги[0]).max():>12.4g}{np.abs(ваги[1]).max():>12.4g}")
старт втрата точність max|W1| max|W2| нулі 0.4452 0.8367 0 0 He 0.2211 0.9292 2.207 1.64
Обидві ваги нульової мережі після чотирьохсот епох дорівнюють рівно нулю. Не «майже» — рівно. Рухався тільки зсув вихідного нейрона, і мережа скотилась до найкращої константи.
Що це за константа, легко перевірити: найкраща стала відповідь — це частка шахрайських оголошень, а її втрата дорівнює ентропії розподілу міток.
частка = мітки.mean()
ентропія_міток = -(частка * np.log(частка) + (1 - частка) * np.log(1 - частка))
print(f"втрата мережі з нулів : {історія_нулі[-1]:.6f}")
print(f"ентропія розподілу міток: {ентропія_міток:.6f}")
assert np.isclose(історія_нулі[-1], ентропія_міток, atol=1e-4), "щось таки зрушило!"
print("✅ мережа з нульовим стартом дійшла рівно до найкращої константи — і ні на крок далі")
втрата мережі з нулів : 0.445156 ентропія розподілу міток: 0.445156 ✅ мережа з нульовим стартом дійшла рівно до найкращої константи — і ні на крок далі
фігура, вісь_графіка = plt.subplots(figsize=(8, 4.5))
вісь_графіка.plot(історія_нулі, label="старт із нулів", linewidth=1.8)
вісь_графіка.plot(історія_he, label="старт He", linewidth=1.8)
вісь_графіка.axhline(ентропія_міток, color="gray", linestyle="--", linewidth=1)
вісь_графіка.text(160, ентропія_міток + 0.006, "втрата найкращої константи",
color="gray", fontsize=9)
вісь_графіка.set_xlabel("епоха")
вісь_графіка.set_ylabel("крос-ентропія на навчальній вибірці")
вісь_графіка.set_title("Той самий код, та сама задача, різний старт")
вісь_графіка.grid(alpha=.3)
вісь_графіка.legend()
plt.tight_layout()
plt.show()
print("Крива нульового старту лягає на пунктир і зупиняється: це не повільне навчання,")
print("а його відсутність. Крива He проходить крізь той рівень за перші десять епох.")
Крива нульового старту лягає на пунктир і зупиняється: це не повільне навчання, а його відсутність. Крива He проходить крізь той рівень за перші десять епох.
Завдання¶
🟢 Рівень 1 — База¶
Додай до словника активації Swish (він же SiLU): f(z) = z·σ(z), похідна
σ(z) + z·σ(z)(1 − σ(z)). Прогони на ньому клітинки 3, 4 і другу перевірку з клітинки 5.
Зроблено, якщо: Swish зʼявився на обох графіках, чисельна перевірка похідної пройдена, а максимум похідної надрукований числом. Порівняй його з GELU одним реченням.
🟡 Рівень 2 — Плюс¶
Знайди межу виживання ReLU. Пройди швидкістю навчання від 1 до 30 із дрібним кроком і побудуй
графік «кількість мертвих нейронів від η». Наклади на нього другу криву — точність.
Зроблено, якщо: графік побудовано, названо перше значення η, при якому вмирає хоч один
нейрон, і перше, при якому точність падає нижче за базову. Поясни двома реченнями, чому ці
два значення такі різні.
🔴 Рівень 3 — Виклик¶
Заміни в мережі з частини 4 ReLU на Leaky ReLU з нахилом 0.01 і повтори всю таблицю мертвих нейронів. Потім відповідай на питання: чи буває мертвий Leaky ReLU взагалі?
Щоб відповісти чесно, потрібне нове визначення «мертвого»: нейрон, у якого норма градієнта
по його вагах менша за 1e-8 протягом останніх ста епох.
Зроблено, якщо: таблиця побудована для обох активацій поруч, нове визначення реалізоване кодом, і сформульовано висновок: за яких умов Leaky ReLU все-таки перестає вчитись, попри ненульову похідну.
Підказки¶
- Рівень 1. Похідну Swish зручно рахувати через уже готове
a = σ(z): не бери експоненту двічі. - Рівень 2. Не навчай мережу заново для кожного
ηз нуля коду — функціянавчити_relu_мережууже приймає крок параметром. - Рівень 3. Градієнт по вагах одного нейрона — це стовпчик матриці
градієнт_W1. Щоб його зберігати, доведеться повертати з функції ще й історію градієнтів; збирай не всі, а норму по стовпчику — одне число на нейрон на епоху.