Перейти к содержимому
Ступень 33.1 От нейрона к многослойной сети

Нейрон, слой, MLP

Из чего собрана нейросеть — нейрон, линейный слой, нелинейность и многослойный перцептрон; какие активации стоят в LLM и почему MLP хранит большую часть параметров любой языковой модели.

УРОКstage-3/3-1-neural-nets/01-neuron-mlp.mdx1 ч 30 минУрок 1 из 2
После урока вы сможете
  • Посчитать выход нейрона и слоя вручную и в NumPy, следя за формами матриц
  • Показать, почему без нелинейности многослойная сеть схлопывается в один слой, и решить XOR двухслойной сетью
  • Сравнить ReLU, GELU и SiLU и объяснить, где каждая из них стоит в языковых моделях
  • Объяснить теорему об универсальной аппроксимации своими словами — что она обещает и чего не обещает

В уроке 2.2 вы собрали softmax-регрессию — один линейный слой, за которым стоит softmax. Это уже нейросетьнейронная сетьEN neural network, и именно таким слоем заканчивается любая LLM. Но у неё есть потолок: граница решения всегда прямая, и даже XOR ей не по силам.

Чтобы преодолеть этот потолок, между входом и выходом вставляют скрытые слои с нелинейностью. Получается многослойный перцептрон (MLP)многослойный перцептрон (MLP)EN multilayer perceptron (MLP)Чередование линейных слоёв и нелинейных активаций. FFN в блоке трансформера — это MLP с одним скрытым слоем. — главный строительный блок глубокого обучения. В каждом блоке трансформера, кроме внимания, стоит MLP, его там называют FFNполносвязный блок (FFN)EN feed-forward network (FFN) (feed-forward network). И на него приходится большая часть параметров:

  • в GPT-2 — две трети весов каждого блока;
  • в Qwen3-0.6B — около 60% весов блока;
  • в DeepSeek-V3 — по нашей прикидке около 98% всех 671 млрд параметров (это MLP-«эксперты», подробнее в китайском углу ниже).

Кроме того, интерпретационные исследования связывают с MLP-слоями хранение фактических «знаний» модели вроде «столица Китая — Пекин» (подробнее — в китайском углу). Так что, разобравшись с MLP, вы поймёте, где живут миллиарды параметров LLM.

Нейроннейрон (искусственный нейрон)EN neuron (artificial neuron, unit)Взвешенная сумма входов плюс сдвиг, пропущенная через нелинейность: a = f(w·x + b). — это крошечная функция. Она берёт dd чисел x1,…,xdx_1, \dots, x_d, умножает каждое на свой вес, складывает, прибавляет сдвиг (bias) и пропускает результат через нелинейную функцию ff:

z=w1x1+w2x2+⋯+wdxd+b=w⊤x+b,a=f(z)z = w_1 x_1 + w_2 x_2 + \dots + w_d x_d + b = w^\top x + b, \qquad a = f(z)

Словами: zz — «насколько вход похож на то, что ищет нейрон», а ff решает, что с этой похожестью делать. Веса ww и сдвиг bb — параметрыпараметрEN parameter, их подбирает обучение. Функция ff называется функцией активациифункция активацииEN activation function. Запись w⊤xw^\top x («w транспонированное на x») — просто короткое имя для суммы w1x1+⋯+wdxdw_1 x_1 + \dots + w_d x_d, в NumPy это w @ x (урок 1.0.3).

Пример. Вход x=(2, −1, 0.5)x = (2,\ -1,\ 0.5), веса w=(0.6, 0.3, −1)w = (0.6,\ 0.3,\ -1), сдвиг b=0.4b = 0.4:

z=0.6⋅2+0.3⋅(−1)+(−1)⋅0.5+0.4=1.2−0.3−0.5+0.4=0.8z = 0.6 \cdot 2 + 0.3 \cdot (-1) + (-1) \cdot 0.5 + 0.4 = 1.2 - 0.3 - 0.5 + 0.4 = 0.8

С активацией ReLU f(z)=max⁡(0,z)f(z) = \max(0, z) выход равен 0.80.8. Сдвиньте bb до −1-1: тогда z=−0.6z = -0.6, и ReLU выдаст 0 — нейрон «молчит». Сдвиг работает как порог срабатывания.

То же самое в NumPy — три строки:

import numpy as np
x = np.array([2.0, -1.0, 0.5]) # вход: 3 числа
w = np.array([0.6, 0.3, -1.0]) # веса нейрона: по одному на каждый вход
b = 0.4 # сдвиг
z = w @ x + b # взвешенная сумма: 1.2 − 0.3 − 0.5 + 0.4
a = max(0.0, z) # ReLU: отрицательное → 0, положительное — как есть
print(round(z, 4), round(a, 4))
0.8 0.8

Вы уже встречали два нейрона. ПерцептронперцептронEN perceptronМодель Фрэнка Розенблатта (1958) — линейный классификатор с пороговой активацией, предок нейросетей. Розенблатта (1958) — нейрон со ступенькой вместо ff. Логистическая регрессия из урока 2.2 — нейрон с сигмоидой. Геометрически нейрон делит пространство входов границей w⊤x+b=0w^\top x + b = 0 и реагирует на то, по какую сторону и как далеко лежит точка. При двух входах эта граница — прямая на плоскости (как в логистической регрессии), при трёх — плоскость, а при большем числе входов её называют гиперплоскостью: это то же «ровное разделение пространства пополам», только нарисовать его уже нельзя.

Слой: много нейронов — одно умножение матриц

Заголовок раздела «Слой: много нейронов — одно умножение матриц»

Линейный (полносвязный) слойлинейный (полносвязный) слойEN linear layer (fully connected layer)y = xWᵀ + b. В PyTorch nn.Linear(in, out) хранит weight формы (out, in) — частый источник путаницы. — это mm нейронов, которые смотрят на один и тот же вход. У каждого свой столбец весов. Сложим столбцы в матрицу WW размера d×md \times m, сдвиги — в вектор bb длины mm, и все mm нейронов посчитаются одной операцией — матричным умножением из урока 1.1.2:

y=xW+b,x∈R1×d, W∈Rd×m, y∈R1×my = x W + b, \qquad x \in \mathbb{R}^{1 \times d},\ W \in \mathbb{R}^{d \times m},\ y \in \mathbb{R}^{1 \times m}

Читается так: вход xx — строка из dd чисел, WW — таблица d×md \times m, на выходе строка из mm чисел — по одному на нейрон. Пример: два входа, три нейрона.

x=(1, 2),W=(10−10.512),b=(0, −1, 0.5)x = (1,\ 2), \qquad W = \begin{pmatrix} 1 & 0 & -1 \\ 0.5 & 1 & 2 \end{pmatrix}, \qquad b = (0,\ -1,\ 0.5)

Первый нейрон — первый столбец WW: 1⋅1+2⋅0.5+0=21 \cdot 1 + 2 \cdot 0.5 + 0 = 2. Второй: 1⋅0+2⋅1−1=11 \cdot 0 + 2 \cdot 1 - 1 = 1. Третий: 1⋅(−1)+2⋅2+0.5=3.51 \cdot (-1) + 2 \cdot 2 + 0.5 = 3.5. Итого y=(2, 1, 3.5)y = (2,\ 1,\ 3.5) — три нейрона посчитаны одним умножением x @ W + b.

На практике обрабатывают сразу батч из nn примеров (пачку, которую сеть обрабатывает за один раз). Строки матрицы XX — примеры:

Y=XW+b,X:(n×d)  →  Y:(n×m)Y = X W + b, \qquad X: (n \times d) \;\to\; Y: (n \times m)

Сдвиг bb прибавляется к каждой строке через broadcasting (урок 0.3.2). Пример: батч из 32 картинок MNIST по 28⋅28=78428 \cdot 28 = 784 пикселя проходит через слой 784 → 128. Получаем XX формы (32,784)(32, 784), WW формы (784,128)(784, 128) и YY формы (32,128)(32, 128). Параметров 784⋅128+128=100 480784 \cdot 128 + 128 = 100\,480.

import numpy as np
x = np.array([[1.0, 2.0]]) # один пример: форма (1, 2)
W = np.array([[1.0, 0.0, -1.0],
[0.5, 1.0, 2.0]]) # 2 входа → 3 нейрона: форма (2, 3)
b = np.array([0.0, -1.0, 0.5]) # по сдвигу на нейрон: форма (3,)
print(x @ W + b) # пример из текста выше
X = np.random.default_rng(0).normal(size=(32, 784)) # «батч» из 32 картинок
W1, b1 = np.zeros((784, 128)), np.zeros(128) # слой 784 → 128 (числа сейчас неважны)
print((X @ W1 + b1).shape, W1.size + b1.size) # форма выхода и число параметров
[[2. 1. 3.5]]
(32, 128) 100480

Поставим два линейных слоя подряд без активации:

(XW1+b1)W2+b2=X(W1W2)⏟W+(b1W2+b2)⏟b(X W_1 + b_1) W_2 + b_2 = X \underbrace{(W_1 W_2)}_{W} + \underbrace{(b_1 W_2 + b_2)}_{b}

Получился снова один линейный слой. Хоть сто таких слоёв ставьте — они схлопнутся в один, ведь произведение матриц — это тоже матрица. Глубина без нелинейности ничего не даёт. На одном числе это видно сразу: «умножить на 2, потом на 3» — то же самое, что «умножить на 6». Вы встречали этот факт в уроках 1.0.1 и 1.1.2, теперь проверим его в NumPy (np.allclose(a, b) отвечает, совпадают ли два массива с точностью до ошибок округления):

import numpy as np
rng = np.random.default_rng(0)
X = rng.normal(size=(5, 3)) # 5 примеров по 3 признака
W1, b1 = rng.normal(size=(3, 4)), rng.normal(size=4)
W2, b2 = rng.normal(size=(4, 2)), rng.normal(size=2)
two_layers = (X @ W1 + b1) @ W2 + b2 # два слоя БЕЗ активации
W, b = W1 @ W2, b1 @ W2 + b2 # один эквивалентный слой
print(np.allclose(two_layers, X @ W + b)) # True: «глубина» пропала
with_relu = np.maximum(X @ W1 + b1, 0) @ W2 + b2 # с ReLU между слоями
print(np.allclose(with_relu, X @ W + b)) # False: это уже другая функция
True
False

XOR: задача, которую не решит ни один линейный слой

Заголовок раздела «XOR: задача, которую не решит ни один линейный слой»

XOR — «ровно один из двух входов равен 1». Четыре точки: (0,0)→0(0,0) \to 0, (0,1)→1(0,1) \to 1, (1,0)→1(1,0) \to 1, (1,1)→0(1,1) \to 0. Единицы стоят на одной диагонали квадрата, нули — на другой. Никакая прямая их не разделит, поэтому логистическая регрессия бессильна (урок 2.2).

Добавим скрытый слой из двух нейронов с ReLU и подберём веса руками:

h1=ReLU(x1+x2),h2=ReLU(x1+x2−1),y=h1−2h2\begin{gathered} h_1 = \mathrm{ReLU}(x_1 + x_2), \\ h_2 = \mathrm{ReLU}(x_1 + x_2 - 1), \\ y = h_1 - 2 h_2 \end{gathered}
x1,x2x_1, x_2h1h_1h2h_2y=h1−2h2y = h_1 - 2h_2
0, 0000
0, 1101
1, 0101
1, 1210
flowchart LR
X1(("x₁")) -->|"1"| H1(("h₁ = ReLU(·)<br/>b = 0"))
X2(("x₂")) -->|"1"| H1
X1 -->|"1"| H2(("h₂ = ReLU(·)<br/>b = −1"))
X2 -->|"1"| H2
H1 -->|"1"| Y(("y"))
H2 -->|"−2"| Y
Сеть 2 → 2 → 1, которая вычисляет XOR. Числа на стрелках — веса

Что произошло? Скрытый слой перенёс точки в новое пространство (h1,h2)(h_1, h_2). Там точки (0,1)(0,1) и (1,0)(1,0) слиплись в одну точку (1,0)(1, 0), а (1,1)(1,1) уехала в (2,1)(2, 1). В новых координатах классы разделяет обычная прямая h1−2h2=0.5h_1 - 2h_2 = 0.5. Это главная идея глубокого обучения: скрытые слои учатся строить такие признаки, в которых задача становится простой. В LLM то же самое: трансформер превращает текст в векторы, где следующий токен угадывается линейным слоем.

MLP чередует линейные слои и активации:

H1=f(XW1+b1),H2=f(H1W2+b2),…,логиты=HL−1WL+bL\begin{gathered} H_1 = f(X W_1 + b_1), \quad H_2 = f(H_1 W_2 + b_2), \quad \dots, \\ \text{логиты} = H_{L-1} W_L + b_L \end{gathered}
flowchart LR
X["Вход X<br/>(n × 784)"] --> L1["Linear 784→128"] --> A1["ReLU"] --> L2["Linear 128→64"] --> A2["ReLU"] --> L3["Linear 64→10"] --> Z["Логиты<br/>(n × 10)"]
Z --> S["softmax + CE<br/>(урок 1.3)"]
MLP для распознавания цифр: два скрытых слоя, на выходе — логиты

Термины:

  • входной слой — сами данные (весов у него нет);
  • скрытые слоискрытый слойEN hidden layerСлой между входом и выходом. Его значения никто не размечал — сеть сама решает, какие признаки в нём строить. — всё, что между входом и выходом; их выходы HiH_i никто не размечал, сеть сама решает, что в них положить;
  • выходной слой — последний линейный слой. Активации на нём нет: он выдаёт логиты, а softmax и cross-entropy добавляет функция потерь (уроки 1.3 и 2.2);
  • глубина — число слоёв с весами, ширина — число нейронов в скрытом слое.

Посчитаем параметры MLP 784 → 128 → 64 → 10:

СлойВесаСдвигиВсего
784 → 128100 352128100 480
128 → 648 192648 256
64 → 1064010650
Итого109 386

Почти все параметры сидят в первом слое — там, где вход широкий. В LLM картина похожая: больше всего весов в самых широких матрицах. Это матрицы словаря, которые переводят токены в векторы и векторы обратно в токены (эмбеддинги и LM head, ступень 4), и расширяющие матрицы FFN.

Активация должна быть нелинейной, дешёвой и иметь удобную производную: по производной сеть будет учиться (следующий урок). Вот основные кандидаты:

zz−2-2−1-1012
сигмоида σ(z)\sigma(z)0.1190.2690.50.7310.881
tanh⁡z\tanh z−0.964-0.964−0.762-0.76200.7620.964
ReLU00012
GELU−0.046-0.046−0.159-0.15900.8411.954
SiLU−0.238-0.238−0.269-0.26900.7311.762

Исторически первыми были сигмоидасигмоида (логистическая функция)EN sigmoid (logistic function)σ(z) = 1 / (1 + e^(−z)) — превращает любое число в вероятность от 0 до 1. и tanh⁡\tanh. Обе насыщаются: при ∣z∣>4\lvert z \rvert > 4 кривая почти плоская, и производная почти нулевая. У сигмоиды производная не больше 0.25, у tanh⁡\tanh при z=3z = 3 она равна 0.01 (эти производные вы считали в уроке 1.2.1). Градиент проходит через каждый слой и каждый раз умножается на эту производную. В глубокой сети он затухает экспоненциально, и первые слои перестают учиться. Подробно эту проблему разберём в уроке 3.4. Сигмоида при этом никуда не делась: она работает как «гейт» со значениями от 0 до 1 — в LSTM, в роутерах MoE, в SiLU.

ReLU(z)=max⁡(0,z),ReLU′(z)={1,z>00,z<0\mathrm{ReLU}(z) = \max(0, z), \qquad \mathrm{ReLU}'(z) = \begin{cases} 1, & z > 0 \\ 0, & z < 0 \end{cases}

ReLUReLU (выпрямитель)EN rectified linear unit (ReLU)ReLU(z) = max(0, z). Дешёвая, не насыщается при z > 0; минус — «мёртвые» нейроны, у которых z < 0 на всех примерах. стоит почти ничего: одно сравнение. При z>0z > 0 производная равна ровно 1, и градиент проходит без затухания. В 2012 году AlexNet (сеть для распознавания картинок, о ней — в уроке 3.4.3) показала, что сеть с ReLU учится в разы быстрее, чем с tanh⁡\tanh. С тех пор ReLU — активация по умолчанию. Оригинальный трансформер 2017 года тоже использовал ReLU в FFN.

Минус ReLU — «мёртвые» нейроны. Если сдвиг ушёл в большой минус, то z<0z < 0 на всех примерах. Выход и градиент нейрона тогда всегда нулевые, и он больше не оживёт. Вы увидите такой нейрон в задании следующего урока.

GELUGELUEN Gaussian error linear unit (GELU)GELU(z) = z·Φ(z), где Φ — функция распределения N(0, 1). Гладкая активация BERT и GPT-2 (в GPT-2 — tanh-приближение). (Gaussian Error Linear Unit) умножает вход на вероятность Φ(z)\Phi(z), что стандартная нормальная величина окажется меньше zz:

GELU(z)=z⋅Φ(z)≈0.5 z(1+tanh⁡[2/π (z+0.044715 z3)])\mathrm{GELU}(z) = z \cdot \Phi(z) \approx 0.5\, z \left(1 + \tanh\left[\sqrt{2/\pi}\,(z + 0.044715\, z^3)\right]\right)

Здесь Φ(z)\Phi(z) — функция распределенияфункция распределения (CDF)EN cumulative distribution function (CDF)F(x) = P(X ≤ x); для дискретной величины — накопленные суммы вероятностей. Через неё сэмплируют методом обратной функции. стандартного нормального «колокола» N(0,1)N(0, 1) из урока 0.3.3: доля площади под колоколом левее точки zz. Например, Φ(0)=0.5\Phi(0) = 0.5 (ровно половина колокола левее нуля), Φ(1)≈0.841\Phi(1) \approx 0.841, Φ(−1)≈0.159\Phi(-1) \approx 0.159. Отсюда значения из таблицы: GELU(1)=1⋅0.841=0.841\mathrm{GELU}(1) = 1 \cdot 0.841 = 0.841, GELU(−1)=−1⋅0.159=−0.159\mathrm{GELU}(-1) = -1 \cdot 0.159 = -0.159. Длинная формула справа — приближение через tanh⁡\tanh: его считают, когда нужно быстро, а вычислять его руками не придётся.

Интуиция: GELU — это «мягкий ReLU». Большие положительные zz проходят почти целиком (Φ≈1\Phi \approx 1), большие отрицательные гасятся (Φ≈0\Phi \approx 0). Около нуля переход плавный, без излома. GELU стоит в BERT и GPT-2, причём GPT-2 использует tanh-приближение.

SiLUSiLU (Swish)EN sigmoid linear unit (SiLU), SwishSiLU(z) = z·σ(z). Внутри SwiGLU — в FFN Llama, Qwen, DeepSeek. Минимум ≈ −0.278 при z ≈ −1.28. (она же Swish) устроена так же, только вместо Φ\Phi — сигмоида:

SiLU(z)=z⋅σ(z)=z1+e−z\mathrm{SiLU}(z) = z \cdot \sigma(z) = \frac{z}{1 + e^{-z}}

Пример: σ(1)=1/(1+e−1)≈0.731\sigma(1) = 1/(1 + e^{-1}) \approx 0.731, поэтому SiLU(1)≈1⋅0.731=0.731\mathrm{SiLU}(1) \approx 1 \cdot 0.731 = 0.731, а SiLU(−1)≈−1⋅0.269=−0.269\mathrm{SiLU}(-1) \approx -1 \cdot 0.269 = -0.269 (сигмоиду вы считали в уроке 1.0.1). У SiLU неглубокий «провал» ниже нуля: минимум ≈−0.278\approx -0.278 при z≈−1.28z \approx -1.28. Для отрицательных zz градиент не нулевой, поэтому нейроны не умирают. Почему гладкие активации работают лучше ReLU на трансформерах, до конца не объяснено: их выбрали эмпирически, по метрикам. Ноам Шазир в статье о SwiGLU прямо написал, что не берётся объяснить успех этих вариантов, и в шутку приписал его «божественной благосклонности».

В Llama, Qwen и DeepSeek SiLU стоит внутри SwiGLUSwiGLUEN SwiGLU (Swish-gated linear unit)FFN современных LLM: down(SiLU(gate(x)) · up(x)), три матрицы без смещений; скрытый размер обычно ≈ 8d/3 и больше (Shazeer, 2020). — FFN с тремя матрицами вместо двух:

FFN(x)=(SiLU(xWgate)⊙xWup) Wdown\mathrm{FFN}(x) = \big(\mathrm{SiLU}(x W_{\text{gate}}) \odot x W_{\text{up}}\big)\, W_{\text{down}}

Здесь ⊙\odot — поэлементное умножение: одна ветка через SiLU решает, какую часть второй ветки пропустить. Словами: вход умножается на две разные матрицы, WgateW_{\text{gate}} («ворота») и WupW_{\text{up}} («расширение»); первый результат пропускается через SiLU и поэлементно умножается на второй, а WdownW_{\text{down}} сжимает итог обратно до исходной ширины. Сейчас достаточно запомнить, что внутри стоит SiLU. Подробно SwiGLU разберём в уроке 4.5.3, когда будем собирать современный блок LLM.

Теорема об универсальной аппроксимациитеорема об универсальной аппроксимацииEN universal approximation theoremСеть с одним скрытым слоем и неполиномиальной активацией приближает любую непрерывную функцию на ограниченной области. Ничего не говорит о числе нейронов, обучении и обобщении. (Cybenko, 1989 — для сигмоиды; Hornik, 1991; для любой неполиномиальной активации — Leshno и соавторы, 1993) утверждает:

MLP с одним скрытым слоем и нелинейной (не полиномиальной) активацией может приблизить любую непрерывную функцию на ограниченной области с любой точностью — если нейронов достаточно много.

Звучит как магия, но с ReLU это видно буквально — вы уже собирали кривые из изломов в уроке 1.0.1. Каждый нейрон ReLU(x−t)\mathrm{ReLU}(x - t) — это «излом» в точке tt: левее tt он молчит, правее растёт с наклоном 1. Сумма изломов с разными весами даёт ломаную, а ломаной можно сколь угодно точно обвести любую гладкую кривую.

Как подобрать веса? Отметим на графике k+1k + 1 точку и соединим их отрезками. В первой точке ставим нейрон с наклоном первого отрезка. В каждой следующей точке наклон ломаной меняется — ставим там ещё один нейрон, вес которого равен изменению наклона. Пример: если первый отрезок идёт вверх с наклоном 0.9, а второй — с наклоном 0.3, то второму нейрону нужен вес 0.3−0.9=−0.60.3 - 0.9 = -0.6: он «гнёт» ломаную вниз. Построим такую сеть 1 → kk → 1 для sin⁡x\sin x на отрезке [0,π][0, \pi]. Код короткий, но плотный — читайте комментарии построчно:

import numpy as np
def relu(z):
return np.maximum(z, 0)
def relu_net_interpolation(f, a, b, k):
"""Сеть 1 → k → 1 с ReLU, которая проводит ломаную через k + 1 точек графика f."""
t = np.linspace(a, b, k + 1) # k + 1 равноотстоящих узлов ломаной на [a, b]
slopes = np.diff(f(t)) / np.diff(t) # наклон каждого отрезка: (разность высот) / (разность x)
# np.diff([a, b, c]) = [b − a, c − b]
w2 = np.diff(slopes, prepend=0.0) # вес нейрона i = на сколько меняется наклон в узле t_i
def net(x):
# x[:, None] — столбец (N, 1), t[None, :-1] — строка (1, k); broadcasting даёт таблицу (N, k):
# в клетке (j, i) стоит x_j − t_i, то есть вход нейрона i для точки x_j
h = relu(x[:, None] - t[None, :-1]) # скрытый слой: k нейронов ReLU(x − t_i)
return f(t[0]) + h @ w2 # выходной слой: старт в f(a) плюс взвешенная сумма изломов
return net
x = np.linspace(0, np.pi, 10_001)
for k in [2, 4, 8, 16, 32]:
net = relu_net_interpolation(np.sin, 0, np.pi, k)
err = np.abs(net(x) - np.sin(x)).max()
print(f"нейронов: {k:2d} макс. ошибка: {err:.4f}")
нейронов: 2 макс. ошибка: 0.2105
нейронов: 4 макс. ошибка: 0.0704
нейронов: 8 макс. ошибка: 0.0188
нейронов: 16 макс. ошибка: 0.0048
нейронов: 32 макс. ошибка: 0.0012

Удваиваем нейроны — ошибка падает почти в 4 раза. Это и есть теорема в действии.

Чего теорема НЕ говорит — и это важнее самой теоремы:

  1. Сколько нужно нейронов. Для сложных функций многих переменных — астрономически много.
  2. Как найти веса. Мы построили их вручную, зная ff. В реальности ff неизвестна: есть только примеры, а веса ищет градиентный спуск (следующий урок).
  3. Будет ли сеть обобщать. Можно идеально запомнить обучающие точки и ошибаться на новых (урок 2.1.2).

Глубина даёт композицию: каждый слой работает с признаками, которые построил предыдущий. Простой пример — «палатка» из двух нейронов ReLU, t(x)=2 ReLU(x)−4 ReLU(x−0.5)t(x) = 2\,\mathrm{ReLU}(x) - 4\,\mathrm{ReLU}(x - 0.5). Посчитаем несколько точек: t(0)=0t(0) = 0, t(0.25)=0.5t(0.25) = 0.5, t(0.5)=1t(0.5) = 1, t(0.75)=1.5−1=0.5t(0.75) = 1.5 - 1 = 0.5, t(1)=2−2=0t(1) = 2 - 2 = 0. График поднимается до вершины и спускается обратно — отсюда название. Палатка переводит [0,1][0, 1] в [0,1][0, 1] и как бы складывает отрезок пополам. Применим её несколько раз подряд — по слою из 2 нейронов за раз — и посчитаем, сколько прямых кусков у получившегося графика:

import numpy as np
def tent(x): # «палатка» из двух нейронов ReLU
return 2 * np.maximum(x, 0) - 4 * np.maximum(x - 0.5, 0)
x = np.linspace(0, 1, 2**14 + 1) # густая сетка точек на [0, 1]
y = x
for depth in range(1, 6):
y = tent(y) # ещё один слой из 2 нейронов
slopes = np.round(np.diff(y) / np.diff(x), 6) # наклон графика между соседними точками
pieces = 1 + np.count_nonzero(np.diff(slopes)) # каждое изменение наклона — новый линейный кусок
print(f"слоёв: {depth}, нейронов: {2 * depth:2d}, линейных кусков: {pieces}")
слоёв: 1, нейронов: 2, линейных кусков: 2
слоёв: 2, нейронов: 4, линейных кусков: 4
слоёв: 3, нейронов: 6, линейных кусков: 8
слоёв: 4, нейронов: 8, линейных кусков: 16
слоёв: 5, нейронов: 10, линейных кусков: 32

Глубокая сеть из 2L2L нейронов даёт 2L2^L линейных кусков. Одному широкому слою для той же «пилы» нужно не меньше 2L−12^L - 1 нейронов: каждый ReLU-нейрон добавляет не больше одного излома. При L=10L = 10 это 20 нейронов против 1023. Поэтому на практике сети делают и широкими, и глубокими: у GPT-2 small 12 блоков, у DeepSeek-V3 — 61.

Реализуйте прямой проход MLP на NumPy: три активации, линейный слой, сеть произвольной глубины и подсчёт параметров. Тесты проверят значения активаций, формы, XOR с весами из урока и «схлопывание» сети без нелинейности.

Порядок работы: сначала три активации (каждая — одна строка), потом linear, потом mlp_forward, в конце count_params. После каждой функции нажимайте «Запустить» и смотрите, что печатается. Если застряли — открывайте подсказки по одной: первые наводят на мысль, последняя почти готовый ответ.

Прямой проход MLP рукамиnumpy · нужен интернет7 тестов
import numpy as np


def relu(z):
    """ReLU: max(0, z) поэлементно. Подсказка: np.maximum."""
    # TODO: ваш код
    ...


def gelu(z):
    """GELU в tanh-приближении (как в GPT-2):

        0.5 * z * (1 + tanh( sqrt(2/π) * (z + 0.044715 * z**3) ))
    """
    # TODO: ваш код
    ...


def silu(z):
    """SiLU (она же Swish): z * sigmoid(z), где sigmoid(z) = 1 / (1 + e^(-z))."""
    # TODO: ваш код
    ...


def linear(X, W, b):
    """Линейный слой.

    X: (n, d_in) — n примеров по d_in признаков
    W: (d_in, d_out), b: (d_out,)
    Возвращает массив формы (n, d_out).
    """
    # TODO: ваш код
    ...


def mlp_forward(X, params, act=relu):
    """Прямой проход MLP.

    params — список пар (W, b), по одной на слой.
    После каждого слоя, КРОМЕ последнего, применяется активация act.
    Возвращает выход последнего слоя (логиты), форма (n, d_out).
    """
    # TODO: ваш код
    ...


def count_params(sizes):
    """Число параметров (весов и сдвигов) MLP с размерами слоёв sizes = [d_in, h1, ..., d_out].

    Пример: [2, 3, 1] → (2*3 + 3) + (3*1 + 1) = 13.
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»: XOR двухслойной сетью с весами «вручную»
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
W1 = np.array([[1.0, 1.0], [1.0, 1.0]])
b1 = np.array([0.0, -1.0])
W2 = np.array([[1.0], [-2.0]])
b2 = np.array([0.0])
print(mlp_forward(X, [(W1, b1), (W2, b2)]))   # ожидается столбец 0, 1, 1, 0
print(count_params([784, 128, 10]))            # ожидается 101770

PyTorch — библиотека, на которой пишут почти все нейросети; подробно её разберём в модуле 3.3. Сейчас просто сравните: всё, что вы написали руками, там уже есть готовым. Если PyTorch ещё не установлен (урок 0.1.2), блок можно пропустить и вернуться к нему после модуля 3.3.

import torch
import torch.nn as nn
import torch.nn.functional as F
# MLP для MNIST: 784 → 128 → 64 → 10
mlp = nn.Sequential(
nn.Linear(784, 128), nn.ReLU(),
nn.Linear(128, 64), nn.ReLU(),
nn.Linear(64, 10), # на выходе — логиты, без активации
)
print(sum(p.numel() for p in mlp.parameters())) # 109386
print(mlp[0].weight.shape) # PyTorch хранит W как (out, in)
x = torch.randn(32, 784) # батч из 32 «картинок»
print(mlp(x).shape)
# XOR с весами из урока. В PyTorch y = x @ W.T + b, поэтому кладём транспонированные матрицы
xor = nn.Sequential(nn.Linear(2, 2), nn.ReLU(), nn.Linear(2, 1))
with torch.no_grad():
xor[0].weight.copy_(torch.tensor([[1.0, 1.0], [1.0, 1.0]]).T)
xor[0].bias.copy_(torch.tensor([0.0, -1.0]))
xor[2].weight.copy_(torch.tensor([[1.0], [-2.0]]).T)
xor[2].bias.zero_()
X = torch.tensor([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
print(xor(X).squeeze().tolist())
z = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])
print(F.relu(z).tolist())
print([round(v, 4) for v in F.gelu(z).tolist()]) # точная GELU (через erf)
print([round(v, 4) for v in F.gelu(z, approximate="tanh").tolist()]) # tanh-приближение, как в GPT-2
print([round(v, 4) for v in F.silu(z).tolist()])

Ожидаемый вывод:

109386
torch.Size([128, 784])
torch.Size([32, 10])
[0.0, 1.0, 1.0, 0.0]
[0.0, 0.0, 0.0, 1.0, 2.0]
[-0.0455, -0.1587, 0.0, 0.8413, 1.9545]
[-0.0454, -0.1588, 0.0, 0.8412, 1.9546]
[-0.2384, -0.2689, 0.0, 0.7311, 1.7616]

Точная GELU и её tanh-приближение расходятся лишь в четвёртом знаке. Но если загружаете чужие веса, берите тот вариант, с которым модель обучалась. В конфигах Hugging Face это поле activation_function у GPT-2 (gelu_new) и hidden_act у Qwen (silu).

Проверь себя6 вопросов
1 Сеть из 10 линейных слоёв подряд без функций активации эквивалентна…
2 Нейрон с ReLU: x=(3,1)x = (3, 1), w=(0.5,−2)w = (0.5, -2), b=1b = 1. Чему равен его выход?
3 Сколько параметров (веса + сдвиги) у MLP 100 → 50 → 10? Введите число без пробелов.
4 Какие утверждения верны?Можно выбрать несколько ответов
5 Что гарантирует теорема об универсальной аппроксимации?
6 В блоке GPT-2 у внимания 4d24d^2 весов, у MLP (d→4d→dd \to 4d \to d) — 8d28d^2. Какая доля весов блока приходится на MLP? (до двух знаков)
  • Нейрон: a=f(w⊤x+b)a = f(w^\top x + b). Слой из mm нейронов — одно умножение XW+bXW + b, параметров dindout+doutd_{in} d_{out} + d_{out}.
  • Без нелинейности слои схлопываются в один. Скрытый слой с ReLU решает XOR: он перестраивает пространство так, что классы делятся прямой.
  • ReLU — простая активация по умолчанию. GELU (GPT-2, BERT) и SiLU (внутри SwiGLU у Llama, Qwen, DeepSeek) — гладкие активации современных трансформеров.
  • Одного широкого скрытого слоя теоретически хватает для любой функции. Но глубина экономит нейроны за счёт композиции, а теорема ничего не говорит об обучении.
  • MLP хранит большую часть параметров LLM: 2/3 блока GPT-2 и около 98% DeepSeek-V3.

Дальше: как такую сеть обучать. Выведем обратное распространение ошибки для Linear + ReLU + softmax-CE в матричной форме и научимся проверять градиенты численно.