Практика · Регуляризація нейромереж¶
📖 Лекція: lecture.html · 🧪 Тест: quiz.html · 📝 Домашнє: homework.md
Лекція показала пʼять способів стримати мережу. Тут ми зробимо всі, які можна зробити на числових ознаках, — і зробимо їх з нуля, без жодної бібліотечної магії.
План:
- беремо мережу з теми 32 і навмисно перенавчаємо її на 120 прикладах — дивимось на розрив;
- перевіряємо, чи вона взагалі здатна вчити напамʼять: даємо їй перемішані мітки;
- додаємо спад ваг і дивимось, як розрив стискається;
- пишемо dropout з нуля — маска на навчанні, масштабування на передбаченні — і окремо дивимось, що буде, якщо масштабування забути;
- ставимо ранню зупинку з терпінням і рахуємо зекономлені епохи;
- зводимо всі способи в одну таблицю на тих самих даних.
Нічого з цього не займе більш ніж пів хвилини: мережа маленька, вибірка крихітна. Саме така пара — велика мережа на малих даних — і потрібна, щоб перенавчання було видно неозброєним оком.
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
кількість_оголошень = 1200
моделі = ["Alfa A5", "Alfa A7", "Beta 12", "Beta 12 Pro", "Gamma X", "Gamma X Ultra"]
ціна_нового = {"Alfa A5": 5200, "Alfa A7": 7400, "Beta 12": 12000,
"Beta 12 Pro": 17500, "Gamma X": 24000, "Gamma X Ultra": 34000}
модель = генератор.choice(моделі, size=кількість_оголошень, p=[0.24, 0.22, 0.18, 0.16, 0.12, 0.08])
рік = генератор.integers(2017, 2025, size=кількість_оголошень)
стан = генератор.choice(["нове", "дуже добре", "добре", "задовільне"],
size=кількість_оголошень, p=[0.08, 0.32, 0.42, 0.18])
памʼять = генератор.choice([64, 128, 256, 512], size=кількість_оголошень, p=[0.30, 0.38, 0.24, 0.08])
# більшість продавців мають свіжі акаунти, старих усе менше — звідси експоненційний розподіл
вік_акаунта = np.round(генератор.exponential(420, size=кількість_оголошень) + 3).astype(int)
коефіцієнт_стану = np.array(
[{"нове": 1.0, "дуже добре": 0.88, "добре": 0.75, "задовільне": 0.58}[s] for s in стан])
коефіцієнт_памʼяті = np.array(
[{64: 0.85, 128: 1.0, 256: 1.15, 512: 1.32}[m] for m in памʼять])
типова_ціна = (np.array([ціна_нового[m] for m in модель])
* 0.82 ** (2024 - рік)
* коефіцієнт_стану * коефіцієнт_памʼяті)
ціна = типова_ціна * генератор.lognormal(0, 0.13, size=кількість_оголошень)
print("оголошень:", кількість_оголошень)
print("типова ціна перших трьох:", типова_ціна[:3].round(0))
оголошень: 1200 типова ціна перших трьох: [ 3763. 3060. 12071.]
# шахрай частіше працює зі свіжого акаунта, тому ймовірність залежить від його віку
шанс_шахрайства = 0.10 + 0.30 * np.exp(-вік_акаунта / 120)
шахрайське = генератор.random(кількість_оголошень) < шанс_шахрайства
# три чверті шахраїв ставлять різко занижену ціну, решта — завищену
ставить_дешево = генератор.random(кількість_оголошень) < 0.74
дешева_приманка = шахрайське & ставить_дешево
дорога_приманка = шахрайське & ~ставить_дешево
ціна[дешева_приманка] = типова_ціна[дешева_приманка] * генератор.uniform(0.20, 0.45, дешева_приманка.sum())
ціна[дорога_приманка] = типова_ціна[дорога_приманка] * генератор.uniform(2.6, 3.8, дорога_приманка.sum())
ціна = np.round(ціна, -1)
дошка = pd.DataFrame({"модель": модель, "вік_акаунта": вік_акаунта,
"ціна": ціна, "шахрайське": шахрайське})
print(f"частка шахрайських оголошень: {дошка['шахрайське'].mean():.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 прикладах та сама мережа перенавчилась би значно слабше, і демонстрація вийшла б млявою.
# «типової ціни» ніхто не знає — відновлюємо її медіаною по моделі, вона стійка до викидів
медіана_по_моделі = дошка.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
ПРИХОВАНИХ = 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 разів.
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 нічого не змінюється¶
Найдешевша перевірка правильності: якщо частка вимкнених дорівнює нулю, наш прохід із маскою мусить дати точно ті самі числа, що звичайний прохід.
маска_нульова = маска_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.
Зверни увагу: окремий прогін від повного значення відрізняється сильно. Вирівнюється не кожен крок, а математичне сподівання.
ваги_проби = створити_мережу(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: так одна λ означає
однакову силу для всіх параметрів. Зсуви не штрафуємо.
Функція повертає дві криві втрат (навчальну й валідаційну) і найкращі ваги за валідаційною втратою — вони знадобляться для ранньої зупинки.
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
ваги_база, навч_база, вал_база, кращі_база, епоха_база = навчити()
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
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 оголошень.")
Криві розходяться після ~50-ї епохи: далі мережа вчить уже не закономірність, а особисті риси конкретних 120 оголошень.
7 · Чи здатна вона вчити напамʼять? Перемішані мітки¶
Найпряміша перевірка того, що мережа не «шукає просте пояснення», а просто запамʼятовує. Перемішуємо мітки між прикладами: частки класів лишаються ті самі, а звʼязок між ознаками й відповіддю зникає повністю. Вивчити тут нема чого.
Даємо мережі більше часу — 2000 епох замість 300, бо запамʼятовувати важче, ніж вчитись.
перестановка = 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 Мережа вивчила напамʼять таблицю, у якій немає жодної закономірності, і на нових даних стала гіршою за найтупішу сталу відповідь.
результати = {} # сюди складаємо все, що знадобиться для підсумкової таблиці
результати["без регуляризації"] = (ваги_база, навч_база, вал_база, кращі_база, епоха_база)
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
сили = [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("Але точність на тесті монотонною НЕ буде — у неї є дно, і воно посередині.")
Обидві криві спадають монотонно — це очікувано: штраф прямо стягує ваги до нуля, а разом із вагами зникає й здатність обхопити окрему точку. Але точність на тесті монотонною НЕ буде — у неї є дно, і воно посередині.
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 і вище мережа
почала недонавчатись: точність упала до базової частки «усі чесні», а навчальна
втрата зупинилась на значенні сталої відповіді.
Порахуймо це значення руками, щоб переконатись, що мережа справді здалася й видає константу.
частка = 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 під час
передбачення. Порівняймо два передбачення однієї й тієї самої мережі.
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 · Терпіння — і скільки епох воно економить¶
Ранню зупинку не треба вбудовувати в навчання: криві вже пораховані, тож достатньо пройти по валідаційній кривій і застосувати правило. Так навіть зручніше — можна порівняти різні значення терпіння на одному й тому самому прогоні.
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. І це при тому, що рання зупинка не додає до мережі жодного параметра й жодного рядка в градієнті.
результати["спад 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
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-штраф, тобто той самий спад
ваг (у класичному варіанті, через функцію втрат).
Якщо всередині бібліотеки немає магії, точності мають зійтися.
бібліотечна = 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в обидва боки й порівняй різницю втрат зі своєю формулою.