Практика · Регуляризація нейромереж¶

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

Лекція показала пʼять способів стримати мережу. Тут ми зробимо всі, які можна зробити на числових ознаках, — і зробимо їх з нуля, без жодної бібліотечної магії.

План:

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

Нічого з цього не займе більш ніж пів хвилини: мережа маленька, вибірка крихітна. Саме така пара — велика мережа на малих даних — і потрібна, щоб перенавчання було видно неозброєним оком.

In [1]:
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 · Дошка оголошень¶

Та сама задача, що в темах 32 і 34: відрізнити шахрайське оголошення про продаж телефона від чесного. Дві ознаки — вік акаунта продавця й відхилення ціни від типової для цієї моделі.

In [2]:
кількість_оголошень = 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.]
In [3]:
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 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():.4f}")
print(дошка.head(3).to_string(index=False))
частка шахрайських оголошень: 0.1633
     модель  вік_акаунта    ціна  шахрайське
Beta 12 Pro          186  3090.0       False
    Alfa A7          394  2810.0       False
    Gamma X           44 33060.0        True

2 · Розбиття, у якому навчальна вибірка навмисне мала¶

Тут головна відмінність цієї практики від попередніх. Ми віддаємо на навчання лише 120 оголошень із 1200. Решта йде на валідацію (300) і на чесний тест (780).

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

In [4]:
# «типової ціни» ніхто не знає — відновлюємо її медіаною по моделі, вона стійка до викидів
медіана_по_моделі = дошка.groupby("модель")["ціна"].transform("median")

# логарифм робить «удвічі дешевше» і «удвічі дорожче» симетричними навколо нуля
сирі_ознаки = np.c_[np.log(дошка["вік_акаунта"]), np.log(дошка["ціна"] / медіана_по_моделі)]
ознаки = (сирі_ознаки - сирі_ознаки.mean(axis=0)) / сирі_ознаки.std(axis=0)
мітки = дошка["шахрайське"].to_numpy().astype(float)

# спершу відрізаємо рівно 120 навчальних, потім решту ділимо на валідацію й тест
решта_ознак, X_навч, решта_міток, y_навч = train_test_split(
    ознаки, мітки, test_size=120, random_state=0, stratify=мітки)
X_вал, X_тест, y_вал, y_тест = train_test_split(
    решта_ознак, решта_міток, test_size=780, random_state=0, stratify=решта_міток)

print(f"навчальна {X_навч.shape} · валідаційна {X_вал.shape} · тестова {X_тест.shape}")
print(f"частка шахрайських у навчальній: {y_навч.mean():.4f} · у тестовій: {y_тест.mean():.4f}")
print(f"базова точність «усі оголошення чесні» на тесті: {1 - y_тест.mean():.4f}")
навчальна (120, 2) · валідаційна (300, 2) · тестова (780, 2)
частка шахрайських у навчальній: 0.1667 · у тестовій: 0.1628
базова точність «усі оголошення чесні» на тесті: 0.8372

3 · Мережа: два приховані шари по сорок нейронів¶

Прямий прохід і градієнт — те саме зворотне поширення з теми 32, просто шарів тепер три, а не два. Одразу закладаємо в них два вимикачі, які знадобляться далі: dropout (частка вимкнених нейронів) і спад ваг (він живе не тут, а в кроці оптимізатора).

In [5]:
ПРИХОВАНИХ = 40


def сигмоїда(z):
    """Аргумент обрізаємо, щоб експонента не переповнилась."""
    return 1 / (1 + np.exp(-np.clip(z, -40, 40)))


def створити_мережу(зерно_ваг):
    """Ваги випадкові за He-масштабом із теми 33; зсуви нульові."""
    r = np.random.default_rng(зерно_ваг)
    return {"W1": r.normal(0, 1, (2, ПРИХОВАНИХ)) / np.sqrt(2),
            "b1": np.zeros(ПРИХОВАНИХ),
            "W2": r.normal(0, 1, (ПРИХОВАНИХ, ПРИХОВАНИХ)) / np.sqrt(ПРИХОВАНИХ),
            "b2": np.zeros(ПРИХОВАНИХ),
            "W3": r.normal(0, 1, (ПРИХОВАНИХ, 1)) / np.sqrt(ПРИХОВАНИХ),
            "b3": np.zeros(1)}


def прямий_прохід(ваги, X):
    """Режим передбачення: жодних масок, жодного масштабування."""
    A1 = np.tanh(X @ ваги["W1"] + ваги["b1"])
    A2 = np.tanh(A1 @ ваги["W2"] + ваги["b2"])
    A3 = сигмоїда(A2 @ ваги["W3"] + ваги["b3"])
    return A3[:, 0]


def крос_ентропія(ваги, X, y):
    прогноз = np.clip(прямий_прохід(ваги, X), 1e-12, 1 - 1e-12)
    return float(-np.mean(y * np.log(прогноз) + (1 - y) * np.log(1 - прогноз)))


def точність(ваги, X, y):
    return float(((прямий_прохід(ваги, X) > 0.5) == (y > 0.5)).mean())


пробна = створити_мережу(7)
кількість_параметрів = sum(в.size for в in пробна.values())
print("параметрів у мережі 2 → 40 → 40 → 1:", кількість_параметрів)
print("навчальних прикладів:", len(y_навч))
print(f"параметрів на один приклад: {кількість_параметрів / len(y_навч):.1f}")
параметрів у мережі 2 → 40 → 40 → 1: 1801
навчальних прикладів: 120
параметрів на один приклад: 15.0

4 · Dropout з нуля¶

Одна функція на весь метод. Вона повертає маску — масив із нулів та одиниць того самого розміру, що й шар активацій.

Ключовий рядок — ділення на 1 - p. Це той самий inverted dropout із лекції: масштабуємо на навчанні, щоб на передбаченні не робити нічого. Без цього ділення сума, яку отримує наступний шар, у середньому менша за повну рівно в 1 - p разів.

In [6]:
def маска_dropout(форма, p, генератор_масок, інвертувати=True):
    """Нулі там, де нейрон вимкнено; одиниці (або 1/(1-p)) там, де працює."""
    if p <= 0:
        return np.ones(форма)
    залишився = (генератор_масок.random(форма) > p).astype(float)
    if інвертувати:
        # ділимо тут, на навчанні, — тоді код передбачення взагалі не знає про dropout
        return залишився / (1 - p)
    return залишився


def градієнт_мережі(ваги, X, y, p_dropout=0.0, генератор_масок=None, інвертувати=True):
    """Зворотне поширення. Маска застосовується і вперед, і назад — це важливо:
    вимкнений нейрон не має отримати градієнт."""
    A1 = np.tanh(X @ ваги["W1"] + ваги["b1"])
    M1 = маска_dropout(A1.shape, p_dropout, генератор_масок, інвертувати)
    A1 = A1 * M1

    A2 = np.tanh(A1 @ ваги["W2"] + ваги["b2"])
    M2 = маска_dropout(A2.shape, p_dropout, генератор_масок, інвертувати)
    A2 = A2 * M2

    A3 = сигмоїда(A2 @ ваги["W3"] + ваги["b3"])

    дельта3 = (A3 - y[:, None]) / len(y)
    дельта2 = (дельта3 @ ваги["W3"].T) * (1 - A2 ** 2) * M2
    дельта1 = (дельта2 @ ваги["W2"].T) * (1 - A1 ** 2) * M1
    return {"W3": A2.T @ дельта3, "b3": дельта3.sum(axis=0),
            "W2": A1.T @ дельта2, "b2": дельта2.sum(axis=0),
            "W1": X.T @ дельта1, "b1": дельта1.sum(axis=0)}


print("маска при p = 0.5, перші десять чисел одного шару:")
print(маска_dropout((1, 10), 0.5, np.random.default_rng(0))[0])
print("нулі — вимкнені; 2.0 — це 1 / (1 − 0.5), тобто те саме масштабування")
маска при p = 0.5, перші десять чисел одного шару:
[2. 0. 0. 0. 2. 2. 2. 2. 2. 2.]
нулі — вимкнені; 2.0 — це 1 / (1 − 0.5), тобто те саме масштабування

⭐ Перевірка перша: при p = 0 нічого не змінюється¶

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

In [7]:
маска_нульова = маска_dropout((5, ПРИХОВАНИХ), 0.0, np.random.default_rng(1))

assert np.allclose(маска_нульова, 1.0), "при p = 0 маска мусить бути з самих одиниць!"
print("✅ при p = 0 маска не чіпає жодного нейрона")
✅ при p = 0 маска не чіпає жодного нейрона

⭐ Перевірка друга: масштабування вирівнює саме середнє¶

Це найважливіше твердження всього розділу про dropout, і його варто побачити числом. Прогонимо шар 2000 разів із випадковими масками й усереднимо результат. Якщо ділення на 1 - p зроблене правильно, середнє збіжиться до значення без dropout.

Зверни увагу: окремий прогін від повного значення відрізняється сильно. Вирівнюється не кожен крок, а математичне сподівання.

In [8]:
ваги_проби = створити_мережу(7)
активації = np.tanh(X_навч[:1] @ ваги_проби["W1"] + ваги_проби["b1"])
повна_сума = float(активації.sum())

генератор_масок = np.random.default_rng(0)
p_проби = 0.4
прогонів = 5000
суми = np.empty(прогонів)
for i in range(прогонів):
    суми[i] = float((активації * маска_dropout(активації.shape, p_проби, генератор_масок)).sum())

print(f"сума активацій без dropout:            {повна_сума:.4f}")
print(f"середнє за {прогонів} прогонів із dropout:  {суми.mean():.4f}")
print(f"відхилення середнього:                 {abs(суми.mean() / повна_сума - 1) * 100:.2f} %")
print(f"розкид ОКРЕМОГО прогону:               {суми.std():.4f}")
print(f"найменший і найбільший прогін:         {суми.min():.4f} … {суми.max():.4f}")

assert abs(суми.mean() - повна_сума) < 0.05 * abs(повна_сума), "масштабування зміщує середнє!"
print("\n✅ середнє збігається з повною сумою з точністю до похибки вибірки —")
print("   а от окремий прогін гуляє на кілька одиниць. Dropout вирівнює сподівання,")
print("   і саме на нього розрахований наступний шар.")
сума активацій без dropout:            -7.1433
середнє за 5000 прогонів із dropout:  -7.1168
відхилення середнього:                 0.37 %
розкид ОКРЕМОГО прогону:               3.1789
найменший і найбільший прогін:         -17.5214 … 4.6084

✅ середнє збігається з повною сумою з точністю до похибки вибірки —
   а от окремий прогін гуляє на кілька одиниць. Dropout вирівнює сподівання,
   і саме на нього розрахований наступний шар.

5 · Навчання: Adam зі спадом ваг і dropout¶

Оптимізатор — Adam із теми 34, без змін. Спад ваг дописуємо окремим рядком після кроку, тобто у варіанті AdamW: так одна λ означає однакову силу для всіх параметрів. Зсуви не штрафуємо.

Функція повертає дві криві втрат (навчальну й валідаційну) і найкращі ваги за валідаційною втратою — вони знадобляться для ранньої зупинки.

In [9]:
def навчити(епох=300, спад_ваг=0.0, p_dropout=0.0, крок=0.01, партія=32,
            зерно_ваг=7, мітки_навч=None, інвертувати=True):
    """Повертає (ваги в кінці, крива навчальної втрати, крива валідаційної,
    найкращі ваги за валідацією, номер найкращої епохи)."""
    y_ц = y_навч if мітки_навч is None else мітки_навч
    ваги = створити_мережу(зерно_ваг)
    швидкість = {назва: np.zeros_like(значення) for назва, значення in ваги.items()}
    квадрати = {назва: np.zeros_like(значення) for назва, значення in ваги.items()}

    тасувальник = np.random.default_rng(0)      # порядок партій однаковий для всіх прогонів
    генератор_масок = np.random.default_rng(123)
    номер_кроку = 0
    крива_навч, крива_вал = [], []
    найкраща_втрата, найкращі_ваги, найкраща_епоха = np.inf, None, 0

    for епоха in range(1, епох + 1):
        порядок = тасувальник.permutation(len(y_ц))
        for початок in range(0, len(y_ц), партія):
            індекси = порядок[початок:початок + партія]
            g = градієнт_мережі(ваги, X_навч[індекси], y_ц[індекси],
                                p_dropout, генератор_масок, інвертувати)
            номер_кроку += 1
            for назва in ваги:
                швидкість[назва] = 0.9 * швидкість[назва] + 0.1 * g[назва]
                квадрати[назва] = 0.999 * квадрати[назва] + 0.001 * g[назва] ** 2
                m = швидкість[назва] / (1 - 0.9 ** номер_кроку)
                v = квадрати[назва] / (1 - 0.999 ** номер_кроку)
                ваги[назва] -= крок * m / (np.sqrt(v) + 1e-8)
                # спад ваг окремим доданком, повз накопичувачі Adam — це і є AdamW
                if спад_ваг > 0 and not назва.startswith("b"):
                    ваги[назва] -= крок * спад_ваг * ваги[назва]

        крива_навч.append(крос_ентропія(ваги, X_навч, y_ц))
        крива_вал.append(крос_ентропія(ваги, X_вал, y_вал))
        if крива_вал[-1] < найкраща_втрата:
            найкраща_втрата = крива_вал[-1]
            найкращі_ваги = {назва: значення.copy() for назва, значення in ваги.items()}
            найкраща_епоха = епоха

    return ваги, крива_навч, крива_вал, найкращі_ваги, найкраща_епоха


def норма_ваг(ваги):
    """Корінь із суми квадратів усіх ваг — одне число, що міряє «розгін» мережі."""
    return float(np.sqrt(sum((з ** 2).sum() for назва, з in ваги.items()
                             if not назва.startswith("b"))))


ваги_проби, _, _, _, _ = навчити(епох=10)
print("десять епох пройшло. Втрата на навчанні:", round(крос_ентропія(ваги_проби, X_навч, y_навч), 4))
десять епох пройшло. Втрата на навчанні: 0.2692

Частина 2 · Перенавчаємо навмисно¶

6 · Триста епох без жодної регуляризації¶

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

In [10]:
ваги_база, навч_база, вал_база, кращі_база, епоха_база = навчити()

print(f"{'':<26}{'навчальна':>12}{'валідаційна':>14}")
print(f"{'втрата після 10 епох':<26}{навч_база[9]:>12.4f}{вал_база[9]:>14.4f}")
print(f"{'втрата після 300 епох':<26}{навч_база[-1]:>12.4f}{вал_база[-1]:>14.4f}")
print(f"\nрозрив у кінці:            {вал_база[-1] - навч_база[-1]:.4f}")
print(f"найкраща валідаційна:      {min(вал_база):.4f} (епоха {епоха_база})")
print(f"\nточність на навчальній:    {точність(ваги_база, X_навч, y_навч):.4f}")
print(f"точність на тестовій:      {точність(ваги_база, X_тест, y_тест):.4f}")
print(f"норма ваг:                 {норма_ваг(ваги_база):.2f}")
                             навчальна   валідаційна
втрата після 10 епох            0.2692        0.3524
втрата після 300 епох           0.0562        0.4565

розрив у кінці:            0.4003
найкраща валідаційна:      0.2637 (епоха 51)

точність на навчальній:    0.9833
точність на тестовій:      0.8795
норма ваг:                 22.67
In [11]:
fig, ax = plt.subplots(figsize=(7.5, 4.2))
ax.plot(range(1, 301), навч_база, color="teal", lw=1.6, label="навчальна втрата")
ax.plot(range(1, 301), вал_база, color="crimson", lw=1.6, label="валідаційна втрата")
ax.axvline(епоха_база, color="darkorange", ls="--", lw=1.3,
           label=f"дно валідаційної: епоха {епоха_база}")
ax.set_xlabel("епоха"); ax.set_ylabel("крос-ентропія")
ax.set_title("120 навчальних прикладів на 1801 параметр")
ax.grid(alpha=.25); ax.legend()
plt.tight_layout(); plt.show()

print("Криві розходяться після ~50-ї епохи: далі мережа вчить уже не закономірність,")
print("а особисті риси конкретних 120 оголошень.")
No description has been provided for this image
Криві розходяться після ~50-ї епохи: далі мережа вчить уже не закономірність,
а особисті риси конкретних 120 оголошень.

7 · Чи здатна вона вчити напамʼять? Перемішані мітки¶

Найпряміша перевірка того, що мережа не «шукає просте пояснення», а просто запамʼятовує. Перемішуємо мітки між прикладами: частки класів лишаються ті самі, а звʼязок між ознаками й відповіддю зникає повністю. Вивчити тут нема чого.

Даємо мережі більше часу — 2000 епох замість 300, бо запамʼятовувати важче, ніж вчитись.

In [12]:
перестановка = np.random.default_rng(2024).permutation(len(y_навч))
мітки_випадкові = y_навч[перестановка]

print(f"частка шахрайських лишилась та сама: {мітки_випадкові.mean():.4f}")
print(f"збіглося з правдою випадково:        {(мітки_випадкові == y_навч).mean():.4f}")

ваги_шум, навч_шум, _, _, _ = навчити(епох=2000, мітки_навч=мітки_випадкові)

прогноз_шум = прямий_прохід(ваги_шум, X_навч) > 0.5
print(f"\nвтрата на перемішаних мітках:  {навч_шум[-1]:.4f}")
print(f"точність на цих же мітках:     {(прогноз_шум == (мітки_випадкові > 0.5)).mean():.4f}")
print(f"точність на тестовій вибірці:  {точність(ваги_шум, X_тест, y_тест):.4f}")
print(f"для порівняння, «усі чесні»:   {1 - y_тест.mean():.4f}")
print("\nМережа вивчила напамʼять таблицю, у якій немає жодної закономірності,")
print("і на нових даних стала гіршою за найтупішу сталу відповідь.")
частка шахрайських лишилась та сама: 0.1667
збіглося з правдою випадково:        0.7167
втрата на перемішаних мітках:  0.0128
точність на цих же мітках:     1.0000
точність на тестовій вибірці:  0.7141
для порівняння, «усі чесні»:   0.8372

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

Частина 3 · Спад ваг¶

8 · Що робить λ з розривом і з нормою ваг¶

Той самий штраф, що в темі 23, тільки застосований до мережі й реалізований як AdamW. Дивимось одразу на три числа: розрив між кривими, норму ваг і точність на тесті.

In [13]:
результати = {}          # сюди складаємо все, що знадобиться для підсумкової таблиці
результати["без регуляризації"] = (ваги_база, навч_база, вал_база, кращі_база, епоха_база)

print(f"{'спад ваг':>10}{'навч.':>10}{'валід.':>10}{'розрив':>10}{'норма ваг':>12}{'точн. тест':>12}")
print(f"{0.0:>10}{навч_база[-1]:>10.4f}{вал_база[-1]:>10.4f}"
      f"{вал_база[-1] - навч_база[-1]:>10.4f}{норма_ваг(ваги_база):>12.2f}"
      f"{точність(ваги_база, X_тест, y_тест):>12.4f}")

for сила in (0.03, 0.1, 0.3, 1.0, 3.0):
    прогін = навчити(спад_ваг=сила)
    результати[f"спад ваг {сила}"] = прогін
    в, н, вал = прогін[0], прогін[1], прогін[2]
    print(f"{сила:>10}{н[-1]:>10.4f}{вал[-1]:>10.4f}{вал[-1] - н[-1]:>10.4f}"
          f"{норма_ваг(в):>12.2f}{точність(в, X_тест, y_тест):>12.4f}")
  спад ваг     навч.    валід.    розрив   норма ваг  точн. тест
       0.0    0.0562    0.4565    0.4003       22.67      0.8795
      0.03    0.0707    0.4181    0.3474       19.45      0.8808
       0.1    0.0937    0.3388    0.2452       16.27      0.8808
       0.3    0.1675    0.2484    0.0810       11.81      0.9103
       1.0    0.2144    0.2518    0.0374        6.59      0.8962
       3.0    0.2713    0.3435    0.0722        5.33      0.8987
In [14]:
сили = [0.0, 0.03, 0.1, 0.3, 1.0, 3.0]
розриви = [вал_база[-1] - навч_база[-1]]
норми = [норма_ваг(ваги_база)]
for сила in сили[1:]:
    в, н, вал = результати[f"спад ваг {сила}"][0], результати[f"спад ваг {сила}"][1], результати[f"спад ваг {сила}"][2]
    розриви.append(вал[-1] - н[-1])
    норми.append(норма_ваг(в))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10.5, 3.8))
ax1.plot(range(len(сили)), розриви, "o-", color="crimson")
ax1.set_xticks(range(len(сили))); ax1.set_xticklabels([str(s) for s in сили])
ax1.set_xlabel("спад ваг λ"); ax1.set_ylabel("валідаційна − навчальна")
ax1.set_title("розрив між кривими"); ax1.grid(alpha=.25)
ax2.plot(range(len(сили)), норми, "o-", color="teal")
ax2.set_xticks(range(len(сили))); ax2.set_xticklabels([str(s) for s in сили])
ax2.set_xlabel("спад ваг λ"); ax2.set_ylabel("√(сума квадратів ваг)")
ax2.set_title("норма ваг"); ax2.grid(alpha=.25)
plt.tight_layout(); plt.show()

print("Обидві криві спадають монотонно — це очікувано: штраф прямо стягує ваги до нуля,")
print("а разом із вагами зникає й здатність обхопити окрему точку.")
print("Але точність на тесті монотонною НЕ буде — у неї є дно, і воно посередині.")
No description has been provided for this image
Обидві криві спадають монотонно — це очікувано: штраф прямо стягує ваги до нуля,
а разом із вагами зникає й здатність обхопити окрему точку.
Але точність на тесті монотонною НЕ буде — у неї є дно, і воно посередині.

Частина 4 · Dropout¶

9 · Чотири значення p на тих самих даних¶

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

In [15]:
print(f"{'p':>6}{'навч.':>10}{'валід.':>10}{'розрив':>10}{'краща вал.':>12}{'епоха':>8}{'точн. тест':>12}")
print(f"{0.0:>6}{навч_база[-1]:>10.4f}{вал_база[-1]:>10.4f}{вал_база[-1] - навч_база[-1]:>10.4f}"
      f"{min(вал_база):>12.4f}{епоха_база:>8}{точність(ваги_база, X_тест, y_тест):>12.4f}")

for p in (0.1, 0.2, 0.3, 0.5):
    прогін = навчити(p_dropout=p)
    результати[f"dropout {p}"] = прогін
    в, н, вал, _, еп = прогін
    print(f"{p:>6}{н[-1]:>10.4f}{вал[-1]:>10.4f}{вал[-1] - н[-1]:>10.4f}"
          f"{min(вал):>12.4f}{еп:>8}{точність(в, X_тест, y_тест):>12.4f}")
     p     навч.    валід.    розрив  краща вал.   епоха  точн. тест
   0.0    0.0562    0.4565    0.4003      0.2637      51      0.8795
   0.1    0.1121    0.3271    0.2150      0.2551      56      0.8846
   0.2    0.4463    0.5712    0.1249      0.2498     182      0.8205
   0.3    0.4466    0.4373   -0.0093      0.2721      50      0.8372
   0.5    0.4475    0.4366   -0.0109      0.3298      36      0.8372

Читаємо результат чесно. На наших даних dropout спрацював лише при p = 0.1: розрив упав удвічі, точність на тесті трохи виросла. Уже при p = 0.2 і вище мережа почала недонавчатись: точність упала до базової частки «усі чесні», а навчальна втрата зупинилась на значенні сталої відповіді.

Порахуймо це значення руками, щоб переконатись, що мережа справді здалася й видає константу.

In [16]:
частка = y_навч.mean()
втрата_сталої_відповіді = -(частка * np.log(частка) + (1 - частка) * np.log(1 - частка))

прогнози_p05 = прямий_прохід(результати["dropout 0.5"][0], X_тест)

print(f"втрата, якщо завжди відповідати «{частка:.4f}»: {втрата_сталої_відповіді:.4f}")
print(f"навчальна втрата при dropout 0.5:            {результати['dropout 0.5'][1][-1]:.4f}")
print(f"\nувесь розкид прогнозів мережі при dropout 0.5: "
      f"від {прогнози_p05.min():.4f} до {прогнози_p05.max():.4f}")
print("Мережа не зовсім константа, але майже: усі 780 прогнозів лежать у вузькій")
print("смузі навколо базової частки, і жодного разу не перетинають поріг 0.5.")
print("\nВисновок як є: дві числові ознаки й 120 прикладів — надто тонкий сигнал,")
print("щоб пережити викидання половини нейронів. Dropout любить широкі шари")
print("й багато даних, і на нашому стенді він програє спаду ваг.")
втрата, якщо завжди відповідати «0.1667»: 0.4506
навчальна втрата при dropout 0.5:            0.4475

увесь розкид прогнозів мережі при dropout 0.5: від 0.1394 до 0.1912
Мережа не зовсім константа, але майже: усі 780 прогнозів лежать у вузькій
смузі навколо базової частки, і жодного разу не перетинають поріг 0.5.

Висновок як є: дві числові ознаки й 120 прикладів — надто тонкий сигнал,
щоб пережити викидання половини нейронів. Dropout любить широкі шари
й багато даних, і на нашому стенді він програє спаду ваг.

10 · А якщо забути масштабування?¶

Це найчастіша помилка в реалізаціях dropout, і зараз ми її зробимо навмисно.

Навчимо мережу простим dropout — маска без ділення (інвертувати=False). Тоді на навчанні шар віддає в середньому лише 1 - p від повної суми, і мережа підлаштовує ваги саме під цей рівень. На передбаченні маски немає, сигнал раптом стає повним — і мережа працює не в тому масштабі, у якому вчилась.

Правильна відповідь у цьому варіанті — помножити активації на 1 - p під час передбачення. Порівняймо два передбачення однієї й тієї самої мережі.

In [17]:
def прохід_із_множником(ваги, X, множник):
    """Передбачення, у якому активації кожного прихованого шару множаться на число."""
    A1 = np.tanh(X @ ваги["W1"] + ваги["b1"]) * множник
    A2 = np.tanh(A1 @ ваги["W2"] + ваги["b2"]) * множник
    return сигмоїда(A2 @ ваги["W3"] + ваги["b3"])[:, 0]


def втрата_за_прогнозом(прогноз, y):
    прогноз = np.clip(прогноз, 1e-12, 1 - 1e-12)
    return float(-np.mean(y * np.log(прогноз) + (1 - y) * np.log(1 - прогноз)))


p_забутий = 0.5
ваги_простий, _, _, _, _ = навчити(p_dropout=p_забутий, інвертувати=False)

без_масштабу = прохід_із_множником(ваги_простий, X_тест, 1.0)
з_масштабом = прохід_із_множником(ваги_простий, X_тест, 1 - p_забутий)

print(f"{'варіант передбачення':<32}{'втрата':>10}{'точність':>11}{'серед. прогноз':>17}")
print(f"{'забули масштабування':<32}{втрата_за_прогнозом(без_масштабу, y_тест):>10.4f}"
      f"{((без_масштабу > 0.5) == (y_тест > 0.5)).mean():>11.4f}{без_масштабу.mean():>17.4f}")
print(f"{'помножили на 1 − p':<32}{втрата_за_прогнозом(з_масштабом, y_тест):>10.4f}"
      f"{((з_масштабом > 0.5) == (y_тест > 0.5)).mean():>11.4f}{з_масштабом.mean():>17.4f}")
print(f"{'справжня частка шахрайських':<32}{'':>10}{'':>11}{y_тест.mean():>17.4f}")
варіант передбачення                втрата   точність   серед. прогноз
забули масштабування                0.5132     0.8897           0.1033
помножили на 1 − p                  0.2981     0.8910           0.1582
справжня частка шахрайських                                     0.1628

Ось чому цю помилку важко зловити. Точність майже не змінилась — межа рішень зсунулась мало, і відповіді «так / ні» лишились ті самі. А крос-ентропія зіпсувалась майже вдвічі, і середній прогноз поїхав від справжньої частки шахрайських оголошень.

Мережа лишилась приблизно правою у відповідях і систематично неправою в упевненості. Якщо в проєкті ти дивишся тільки на точність, така помилка проживе роками — і виявиться рівно тоді, коли комусь знадобляться ймовірності, а не мітки.


Частина 5 · Рання зупинка¶

11 · Терпіння — і скільки епох воно економить¶

Ранню зупинку не треба вбудовувати в навчання: криві вже пораховані, тож достатньо пройти по валідаційній кривій і застосувати правило. Так навіть зручніше — можна порівняти різні значення терпіння на одному й тому самому прогоні.

In [18]:
def рання_зупинка(крива_вал, терпіння):
    """Повертає (епоха зупинки, епоха найкращих ваг).

    Правило: рахуємо, скільки епох поспіль не оновлювався рекорд. Щойно їх стало
    рівно «терпіння» — зупиняємось і повертаємо ваги найкращої епохи, а не останньої.
    """
    найкраща_втрата, найкраща_епоха = np.inf, 0
    for епоха, втрата in enumerate(крива_вал, start=1):
        if втрата < найкраща_втрата:
            найкраща_втрата, найкраща_епоха = втрата, епоха
        elif епоха - найкраща_епоха >= терпіння:
            return епоха, найкраща_епоха
    return len(крива_вал), найкраща_епоха


print(f"{'терпіння':>10}{'зупинка':>10}{'ваги епохи':>13}{'валід. втрата':>16}{'зекономлено':>14}")
for терпіння in (5, 10, 20, 50):
    зупинка, повертаємо = рання_зупинка(вал_база, терпіння)
    print(f"{терпіння:>10}{зупинка:>10}{повертаємо:>13}"
          f"{вал_база[повертаємо - 1]:>16.4f}{300 - зупинка:>14}")

print(f"\nбез зупинки взагалі, 300 епох:{вал_база[-1]:>29.4f}")
print(f"точність на тесті з вагами епохи {епоха_база}: {точність(кращі_база, X_тест, y_тест):.4f}")
print(f"точність на тесті з вагами епохи 300: {точність(ваги_база, X_тест, y_тест):.4f}")
  терпіння   зупинка   ваги епохи   валід. втрата   зекономлено
         5         9            4          0.3629           291
        10        61           51          0.2637           239
        20        71           51          0.2637           229
        50       101           51          0.2637           199

без зупинки взагалі, 300 епох:                       0.4565
точність на тесті з вагами епохи 51: 0.9038
точність на тесті з вагами епохи 300: 0.8795

Терпіння 5 зупиняється на девʼятій епосі: валідаційна крива на початку шумна, і пʼяти епох поспіль без рекорду там набирається легко. Терпіння від 10 і вище цей шум перечікує й доходить до справжнього дна.

Ціна помилки в обидва боки видна числами: зупинка занадто рання дає втрату 0.3629 замість 0.2637, а відсутність зупинки — 0.4565. І це при тому, що рання зупинка не додає до мережі жодного параметра й жодного рядка в градієнті.


Частина 6 · Усе разом¶

12 · Підсумкова таблиця¶

Один прогін лишився — спад ваг разом із dropout. Далі зводимо все на тих самих даних. Для кожного способу дивимось дві речі: результат у кінці навчання й результат із ранньою зупинкою (терпіння 20), бо в реальному проєкті рання зупинка стоїть завжди.

In [19]:
результати["спад 0.3 + dropout 0.1"] = навчити(спад_ваг=0.3, p_dropout=0.1)

рядки = []
for назва in ["без регуляризації", "спад ваг 0.3", "спад ваг 1.0",
              "dropout 0.1", "спад 0.3 + dropout 0.1"]:
    ваги_к, навч_к, вал_к, кращі_к, епоха_к = результати[назва]
    зупинка, повертаємо = рання_зупинка(вал_к, 20)
    рядки.append({
        "спосіб": назва,
        "навч.": round(навч_к[-1], 4),
        "валід.": round(вал_к[-1], 4),
        "розрив": round(вал_к[-1] - навч_к[-1], 4),
        "точн. тест": round(точність(ваги_к, X_тест, y_тест), 4),
        "ES: епоха": повертаємо,
        "ES: валід.": round(вал_к[повертаємо - 1], 4),
        "ES: точн.": round(точність(кращі_к, X_тест, y_тест), 4),
    })

підсумок = pd.DataFrame(рядки)
print(підсумок.to_string(index=False))
                спосіб  навч.  валід.  розрив  точн. тест  ES: епоха  ES: валід.  ES: точн.
     без регуляризації 0.0562  0.4565  0.4003      0.8795         51      0.2637     0.9038
          спад ваг 0.3 0.1675  0.2484  0.0810      0.9103         91      0.2445     0.9115
          спад ваг 1.0 0.2144  0.2518  0.0374      0.8962        241      0.2520     0.8962
           dropout 0.1 0.1121  0.3271  0.2150      0.8846         56      0.2551     0.8949
спад 0.3 + dropout 0.1 0.1730  0.2537  0.0806      0.8962         75      0.2479     0.9038
In [20]:
print("Що читається з таблиці:")
print()
print("1. Найдешевший виграш дає рання зупинка: сама по собі, без жодного додаткового")
print("   параметра, вона піднімає точність з 0.8795 до 0.9038.")
print("2. Найкращий одиничний спосіб — спад ваг 0.3: 0.9103 у кінці навчання.")
print("   Разом із ранньою зупинкою він дає 0.9115 — найкраще в таблиці.")
print("3. Dropout на цих даних слабший за спад ваг, а в парі з ним нічого не додає:")
print("   0.8962 проти 0.9103. Два способи стримують одне й те саме, і разом")
print("   виходить перерегуляризація.")
print("4. Спад ваг 1.0 має менший розрив, ніж 0.3 (0.0374 проти 0.0810), але гіршу")
print("   точність (0.8962 проти 0.9103). Розрив сам по собі не мета —")
print("   мета це помилка на нових даних.")
Що читається з таблиці:

1. Найдешевший виграш дає рання зупинка: сама по собі, без жодного додаткового
   параметра, вона піднімає точність з 0.8795 до 0.9038.
2. Найкращий одиничний спосіб — спад ваг 0.3: 0.9103 у кінці навчання.
   Разом із ранньою зупинкою він дає 0.9115 — найкраще в таблиці.
3. Dropout на цих даних слабший за спад ваг, а в парі з ним нічого не додає:
   0.8962 проти 0.9103. Два способи стримують одне й те саме, і разом
   виходить перерегуляризація.
4. Спад ваг 1.0 має менший розрив, ніж 0.3 (0.0374 проти 0.0810), але гіршу
   точність (0.8962 проти 0.9103). Розрив сам по собі не мета —
   мета це помилка на нових даних.

13 · ⭐ Наша реалізація проти бібліотечної¶

MLPClassifier зі scikit-learn — та сама мережа: два приховані шари по сорок нейронів, tanh, оптимізатор Adam. Параметр alpha в ньому — це L2-штраф, тобто той самий спад ваг (у класичному варіанті, через функцію втрат).

Якщо всередині бібліотеки немає магії, точності мають зійтися.

In [21]:
бібліотечна = MLPClassifier(hidden_layer_sizes=(ПРИХОВАНИХ, ПРИХОВАНИХ),
                            activation="tanh", solver="adam", alpha=0.1,
                            max_iter=3000, random_state=0)
бібліотечна.fit(X_навч, y_навч)

наша_точність = точність(результати["спад ваг 0.3"][0], X_тест, y_тест)
точність_бібліотеки = бібліотечна.score(X_тест, y_тест)

print(f"наша мережа зі спадом ваг 0.3: {наша_точність:.4f}")
print(f"MLPClassifier з alpha = 0.1:   {точність_бібліотеки:.4f}")
print(f"різниця:                       {abs(наша_точність - точність_бібліотеки):.4f}")

assert abs(наша_точність - точність_бібліотеки) < 0.05, "розрахунок розійшовся!"
print("\n✅ збігається: усередині бібліотеки — той самий Adam і той самий штраф на ваги")
наша мережа зі спадом ваг 0.3: 0.9103
MLPClassifier з alpha = 0.1:   0.9115
різниця:                       0.0013

✅ збігається: усередині бібліотеки — той самий Adam і той самий штраф на ваги

Завдання¶

🟢 Рівень 1 — База¶

Повтори експеримент зі спадом ваг, але зміни розмір навчальної вибірки. Візьми test_size рівним 120 (як у нас), 300 і 600 навчальних прикладів і для кожного розміру прожени навчити() без регуляризації та зі спадом ваг 0.3.

Побудуй таблицю з трьох рядків: скільки прикладів, розрив без штрафу, розрив зі штрафом.

Зроблено, якщо: таблиця побудована, і ти написав(ла) двома реченнями, як змінюється користь від регуляризації з ростом вибірки — росте, падає чи стоїть на місці, і чому саме так.


🟡 Рівень 2 — Плюс¶

У розділі 9 dropout при p ≥ 0.2 задушив мережу. Перевір гіпотезу, що причина — вузький шар, а не сам метод: збільш ПРИХОВАНИХ до 200 і прожени ту саму сітку p = [0, 0.1, 0.2, 0.3, 0.5].

Зроблено, якщо: ти показав(ла) таблицею дві точності на тесті для кожного p — при 40 нейронах і при 200 — і відповів(ла) числом: чи зсунулось найкраще значення p вправо, і на скільки виросла найкраща точність.


🔴 Рівень 3 — Виклик¶

Реалізуй пакетну нормалізацію для першого прихованого шару: у прямому проході відніми середнє по партії, поділи на корінь із дисперсії плюс 1e-5, а потім помнож на навчальний параметр гамма й додай бета (обидва починаються з 1 і 0). Не забудь про градієнт: похідна нормалізації по входу має три доданки, бо середнє й дисперсія самі залежать від усіх прикладів партії.

Окремо накопичуй ковзні середнє й дисперсію, щоб на передбаченні брати їх, а не статистики партії.

Зроблено, якщо: мережа навчається, і ти показав(ла) числами дві речі: (1) на скільки епох раніше вона досягає валідаційної втрати 0.30 порівняно зі звичайною мережею, і (2) наскільки відрізняється точність на тесті, якщо на передбаченні помилково взяти статистики самої тестової вибірки замість накопичених.


Підказки¶

  • На рівні 1 не забудь перерахувати X_вал і X_тест після зміни розбиття — інакше частина навчальних прикладів опиниться в тесті, і всі числа стануть надто гарними.
  • На рівні 2 мережа на 200 нейронів у два шари має вже 48 тисяч параметрів, і 300 епох займуть помітно довше. Візьми 150 епох: дно валідаційної кривої в нас і так було близько пʼятдесятої.
  • На рівні 3 найпростіший спосіб не помилитися в градієнті — перевірити його чисельно, як у практиці теми 32: зсунь один параметр на 1e-6 в обидва боки й порівняй різницю втрат зі своєю формулою.