Практика · Активації та ініціалізація¶

📖 Лекція: lecture.html · 🧪 Тест: quiz.html · 📝 Домашнє: homework.md

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

Що зробимо:

  1. намалюємо шість активацій разом із їхніми похідними й знайдемо максимум похідної чисельно;
  2. зберемо дошку оголошень і витягнемо дві ознаки;
  3. зміряємо норму градієнта на кожному з десяти шарів для сигмоїди, tanh і ReLU — затухання буде видно таблицею;
  4. порахуємо, скільки нейронів помирає при семи різних швидкостях навчання;
  5. порівняємо пʼять ініціалізацій за дисперсією активацій по шарах;
  6. навчимо ту саму мережу двічі — з нулів і з He. Перша не зрушить із місця.

Про позначення. У лекції формули записані для одного обʼєкта-стовпчика (z = Wa + b). У коді обʼєкти зручніше тримати рядками таблиці, тому всі матриці транспоновані: Z = AW + b. Це та сама математика, записана навпаки.

In [1]:
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 — прямий прохід його вже порахував, і другий раз експоненту брати не треба.

In [2]:
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, чого для графіка й для розрахунку похідної більш ніж досить.

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

In [3]:
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.

In [4]:
сітка = 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

Перший рядок — це та сама чверть, з якої виростає вся перша половина лекції. Сигмоїда фізично не здатна пропустити назад більше ніж чверть градієнта, і не «в поганому випадку», а ніколи.

Порахуймо одразу, що це означає на десяти шарах.

In [5]:
глибина = 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.

In [6]:
вісь = 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("Верхній ряд визначає, що мережа рахує. Нижній — чи вона взагалі навчиться.")
No description has been provided for this image
Верхній ряд визначає, що мережа рахує. Нижній — чи вона взагалі навчиться.

5 · Перевірка «наша реалізація = бібліотечна»¶

Softmax ми в лекції обговорювали, але не писали. Напишімо — і звіримо зі scikit-learn. Ключова деталь: перед експонентою віднімаємо максимум рядка. Математично це нічого не змінює (множник скорочується в чисельнику й знаменнику), а від переповнення рятує.

In [7]:
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) і звіримо з нашими формулами. Якщо десь у похідній помилка, вона вилізе саме тут.

In [8]:
крок = 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 · Будуємо дані¶

In [9]:
кількість_оголошень = 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]
In [10]:
# «типова» ціна — скільки телефон коштує за паспортом
коефіцієнт_стану = 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.]
In [11]:
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 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
Out[11]:
модель вік_акаунта ціна шахрайське
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: вік акаунта й відхилення ціни від медіани по моделі. Обидві — в логарифмі (щоб «удвічі дешевше» і «удвічі дорожче» були однаково далеко від нуля) і стандартизовані.

In [12]:
медіана_по_моделі = дошка.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. Це важливо: якби ваги були абияк, ми міряли б не активацію, а свою помилку в ініціалізації.

In [13]:
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

Читай знизу вгору: останній рядок — те, що вийшло з функції втрат, перший — те, що дійшло до першого шару. Тепер переведімо це у відношення, як у лекції.

In [14]:
підсумок = []
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: перший шар отримує майже те саме, що останній.
In [15]:
фігура, вісь_графіка = 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("Сигмоїда падає прямою — це і є множення на сталий множник, менший за одиницю.")
No description has been provided for this image
Сигмоїда падає прямою — це і є множення на сталий множник, менший за одиницю.

Частина 4 · Скільки нейронів помирає¶

9 · Мережа 2 → 64 → 1 і сім швидкостей навчання¶

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

Мертвим вважаємо нейрон, у якого z ≤ 0 на всіх обʼєктах навчальної вибірки: такий уже ніколи не отримає градієнта.

In [16]:
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 мертва більшість — і точність не змінилась. Мертві нейрони не дають ні помилки, ні провалу метрики.

Побачити катастрофу можна лише в останньому рядку — і от що там насправді відбувається.

In [17]:
історія, мертвих, точність, _ = навчити_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 — і на такій відстані від нуля похідна сигмоїди майже нульова.

In [18]:
входів_у_нейрон = 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 — вибухне.

In [19]:
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
In [20]:
рядки_ініціалізації = []
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.
In [21]:
фігура, вісь_графіка = 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("Криву «нулі» не видно: її дисперсія рівно нуль, а нуль на логарифмічну шкалу не лягає.")
No description has been provided for this image
Криву «нулі» не видно: її дисперсія рівно нуль, а нуль на логарифмічну шкалу не лягає.

12 · Нулі не вчаться взагалі¶

Останній дослід — найпростіший і найпереконливіший. Беремо ту саму мережу 2 → 64 → 1 і навчаємо її двічі: з нульового старту й з He. Крок і кількість епох однакові.

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

In [22]:
історія_нулі, _, точність_нулі, ваги_нулі = навчити_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

Обидві ваги нульової мережі після чотирьохсот епох дорівнюють рівно нулю. Не «майже» — рівно. Рухався тільки зсув вихідного нейрона, і мережа скотилась до найкращої константи.

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

In [23]:
частка = мітки.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
✅ мережа з нульовим стартом дійшла рівно до найкращої константи — і ні на крок далі
In [24]:
фігура, вісь_графіка = 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 проходить крізь той рівень за перші десять епох.")
No description has been provided for this image
Крива нульового старту лягає на пунктир і зупиняється: це не повільне навчання,
а його відсутність. Крива 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. Щоб його зберігати, доведеться повертати з функції ще й історію градієнтів; збирай не всі, а норму по стовпчику — одне число на нейрон на епоху.