Перейти к содержимому
Ступень 22.2 Линейные модели с нуля

Линейная регрессия

Первая модель, которую вы обучите целиком своими руками, — от формулы y = Xw + b и MSE до аналитического решения, градиентного спуска и нормализации признаков.

УРОКstage-2/2-2-linear-models/01-linear-regression.mdx1 ч 40 минУрок 1 из 3
После урока вы сможете
  • Записать линейную регрессию в матричной форме y^=Xw+b\hat y = Xw + b, посчитать MSE на числах и объяснить, почему квадрат ошибки — удобная функция потерь
  • Найти веса двумя способами — нормальным уравнением и градиентным спуском — и понимать, когда какой выбирать
  • Объяснить, почему без нормализации признаков градиентный спуск сходится в тысячи раз медленнее, и стандартизировать данные без утечки
  • Реализовать линейную регрессию на NumPy и повторить её на PyTorch через nn.Linear

Линейная регрессиялинейная регрессияEN linear regression — первая модель, которую вы обучите целиком: сформулируете модель, выберете функцию потерь, найдёте веса и подготовите данные. Ровно тот же цикл потом повторится для LLMбольшая языковая модель (БЯМ, LLM)EN large language model (LLM)В китайском «большая модель» («да мосин») — разговорное общее название для LLM и вообще foundation models. на миллиардах параметров. Разница в масштабе, а не в идее.

Вторая причина: формула Xw+bXw + b — это атом нейросети. Когда выходов несколько, вектор весов ww становится матрицей, и получается линейный слой из урока 1.1.2: Y=XW⊤+bY = XW^\top + b (транспонирование — потому что nn.Linear хранит веса в форме «выходы × входы»). Внутри LLM таких слоёв сотни: проекции Q, K, V во внимании, слои FFN, выходной слой, который выдаёт логиты по всему словарю. Эти названия — детали трансформера со ступени 4, запоминать их сейчас не нужно. Важно другое: большая часть вычислений в LLM — матричные умножения, и в основном именно в таких слоях. Разберёте одну формулу досконально — поймёте, что происходит в каждом из них.

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

Сквозной пример: сколько ждать ответа модели

Заголовок раздела «Сквозной пример: сколько ждать ответа модели»

Вы подняли локальную модель и замерили время ответа на пяти запросах с разной длиной ответа (числа синтетические, но правдоподобные):

Токенов в ответе xx100200300400500
Время, с yy2.44.56.38.610.2

Гипотеза: время растёт линейно, то есть точки лежат почти на прямой y^=w⋅x+b\hat y = w \cdot x + b (линейная функция и наклон — урок 1.0.1). У обоих параметров есть смысл.

  • ww — наклон, секунды на один токен. Обратная величина 1/w1/w — скорость генерации в токенах в секунду (её ещё называют скоростью декодирования).
  • bb — сдвиг, накладные расходы: передача по сети, обработка промпта (текста запроса), время до первого токена.

Задача — найти ww и bb по данным. Это и есть обучение.

Обычно признаковпризнакEN featureОдно измеримое свойство объекта, вход модели. «Feature engineering» — конструирование признаков. больше одного. Время ответа зависит и от длины ответа, и от длины промпта, и от размера батча. Для dd признаков модель — это взвешенная сумма:

y^=w1x1+w2x2+⋯+wdxd+b=x⊤w+b\hat y = w_1 x_1 + w_2 x_2 + \dots + w_d x_d + b = x^\top w + b

Каждый признак умножаем на свой вес, всё складываем и прибавляем сдвиг. Запись x⊤wx^\top w — это то же скалярное произведение x⋅wx \cdot w из урока 1.1.1: перемножить попарно и сложить (значок ⊤^\top — транспонирование, урок 1.1.2). Пример с двумя признаками: в ответе x1=300x_1 = 300 токенов, в промпте x2=1000x_2 = 1000 токенов, веса w=(0.02, 0.0002)w = (0.02,\ 0.0002), b=0.5b = 0.5. Тогда y^=0.02⋅300+0.0002⋅1000+0.5=6+0.2+0.5=6.7\hat y = 0.02 \cdot 300 + 0.0002 \cdot 1000 + 0.5 = 6 + 0.2 + 0.5 = 6.7 с.

Для nn примеров сразу складываем признаки в матрицу XX, по строке на пример:

y^=Xw+b,X∈Rn×d,  w∈Rd,  b∈R,  y^∈Rn\hat y = Xw + b, \qquad X \in \mathbb{R}^{n \times d},\; w \in \mathbb{R}^{d},\; b \in \mathbb{R},\; \hat y \in \mathbb{R}^{n}

Справа — формы (урок 1.0.3): XX — таблица из nn строк (примеров) и dd столбцов (признаков), ww — dd весов, bb — одно число, y^\hat y — nn предсказаний, по одному на пример. Умножение матрицы на вектор считает x⊤wx^\top w сразу для всех строк.

Сдвиг bb прибавляется к каждому элементу — это broadcastingbroadcasting (растягивание форм)EN broadcastingПравило, по которому NumPy и PyTorch выполняют операции над массивами разных форм без копирования данных. из урока 0.3.2. Полезный трюк: допишите к XX столбец из единиц, а bb — последним элементом в ww. Тогда модель становится чистым y^=Xw\hat y = Xw, и формулы короче. Например, строка (300)(300) превращается в (300, 1)(300,\ 1), веса — в (w, b)(w,\ b), и 300⋅w+1⋅b300 \cdot w + 1 \cdot b — ровно прежнее предсказание.

import numpy as np
X = np.array([[100.0], [200.0], [300.0], [400.0], [500.0]]) # (5, 1): 5 примеров, 1 признак
y = np.array([2.4, 4.5, 6.3, 8.6, 10.2]) # (5,): правильные ответы
w, b = np.array([0.02]), 0.0 # веса «на глаз»: 0.02 с на токен
y_hat = X @ w + b # (5,) — все предсказания разом
print(y_hat)
[ 2. 4. 6. 8. 10.]

Ошибка на примере — остаток ri=y^i−yir_i = \hat y_i - y_i. Чтобы свести остатки в одно число, возводим их в квадрат и усредняем. Получаем среднеквадратичную ошибку (MSE)среднеквадратичная ошибка (MSE)EN mean squared error (MSE)Корень из MSE — RMSE (root mean squared error), измеряется в тех же единицах, что и целевая переменная.:

L(w,b)=1n∑i=1n(y^i−yi)2=1n∥Xw+b−y∥2L(w, b) = \frac{1}{n} \sum_{i=1}^{n} (\hat y_i - y_i)^2 = \frac{1}{n} \lVert Xw + b - y \rVert^2

Почему именно квадрат:

  • он гладкий, у него везде есть производная (у модуля ∣r∣\lvert r \rvert в нуле её нет);
  • большие ошибки штрафуются сильнее: ошибка 2 с стоит вчетверо дороже ошибки 1 с;
  • для линейной модели MSE — выпуклаявыпуклая функцияEN convex function«Чаша» без ложных ям — любой локальный минимум глобальный. Отрезок между двумя точками графика лежит не ниже графика. квадратичная чаша (урок 1.4.1): ложных минимумов нет, а если признаки не дублируют друг друга, минимум единственный.

Проверим на числах. Возьмём «на глаз» w=0.02w = 0.02, b=0b = 0. Предсказания: 2, 4, 6, 8, 10 секунд. Остатки: −0.4,−0.5,−0.3,−0.6,−0.2-0.4, -0.5, -0.3, -0.6, -0.2. Тогда MSE=0.18\text{MSE} = 0.18. Лучшая прямая, которую мы сейчас найдём, даёт MSE=0.0182\text{MSE} = 0.0182 — в 10 раз меньше. Корень из MSE (RMSE) измеряется в секундах: 0.0182≈0.13\sqrt{0.0182} \approx 0.13 с. Это типичная ошибка прогноза.

Для любознательных: откуда берётся квадрат

Заголовок раздела «Для любознательных: откуда берётся квадрат»

Этот раздел отвечает на вопрос «почему именно квадрат, а не что-то другое?». При первом чтении достаточно вывода в конце раздела (он выделен жирным), а сам вывод можно пробежать глазами.

В уроке 1.3.2 вы видели, что cross-entropy — это отрицательное лог-правдоподобие. С MSE та же история. Предположим, что данные порождены так: y=x⊤w+b+εy = x^\top w + b + \varepsilon, где шум ε\varepsilon имеет нормальное распределениенормальное (гауссовское) распределениеEN normal (Gaussian) distribution«Колокол» N(μ, σ²). Правило 68–95–99.7 — доля значений в пределах 1, 2 и 3 σ от среднего. N(0,σ2)\mathcal{N}(0, \sigma^2) — «колокол» с центром 0 и шириной σ\sigma из урока 1.3.1. Тогда yy при известном xx — это колокол с центром в предсказании модели. Запись p(y∣x)p(y \mid x) читается «плотность вероятности yy при условии xx»:

p(y∣x)=12πσ2exp⁡(−(y−x⊤w−b)22σ2)p(y \mid x) = \frac{1}{\sqrt{2\pi\sigma^2}} \exp\left( -\frac{(y - x^\top w - b)^2}{2\sigma^2} \right)

Берём минус логарифм. Экспонента и логарифм взаимно уничтожаются:

−log⁡p(y∣x)=(y−x⊤w−b)22σ2+12log⁡(2πσ2)⏟не зависит от w, b-\log p(y \mid x) = \frac{(y - x^\top w - b)^2}{2\sigma^2} + \underbrace{\tfrac{1}{2} \log (2\pi\sigma^2)}_{\text{не зависит от } w,\, b}

Примеры независимы, поэтому правдоподобие всей выборки — произведение вероятностей, а его логарифм — сумма: −log⁡∏ip(yi∣xi)=12σ2∑i(yi−y^i)2+const-\log \prod_i p(y_i \mid x_i) = \frac{1}{2\sigma^2} \sum_i (y_i - \hat y_i)^2 + \text{const}. Положительный множитель 12σ2\frac{1}{2\sigma^2} и константа не меняют того, где находится минимум. Значит, максимизировать правдоподобие данных — то же самое, что минимизировать сумму квадратов ошибок. MSE — это MLE при гауссовском шуме. Если шум другой, например с редкими огромными выбросами, лучше подойдут другие потери (MAE — в уроке 2.2.3).

Идея: минимум гладкой чаши — там, где она плоская, то есть где все производные равны нулю (как у параболы в уроке 1.2.1). Спрячем bb в ww трюком со столбцом единиц. Градиент MSE — вектор частных производных по каждому весу (урок 1.2.3, там же выводилась формула ∂L/∂W=X⊤⋅∂L/∂Y\partial L / \partial W = X^\top \cdot \partial L / \partial Y):

∇wL=2nX⊤(Xw−y)\nabla_w L = \frac{2}{n} X^\top (Xw - y)

В минимуме градиент равен нулю. Получаем нормальное уравнениенормальное уравнениеEN normal equationXᵀXw = Xᵀy — условие минимума MSE. Его решение называют аналитическим решением (closed-form solution).:

X⊤X w=X⊤y⟹w∗=(X⊤X)−1X⊤yX^\top X \, w = X^\top y \quad\Longrightarrow\quad w^* = (X^\top X)^{-1} X^\top y

Здесь (X⊤X)−1(X^\top X)^{-1} — обратная матрица из урока 1.1.3: она «отменяет» умножение на X⊤XX^\top X, как деление отменяет умножение на число.

У равенства X⊤(Xw−y)=0X^\top (Xw - y) = 0 есть красивый смысл. Вектор остатков ортогонален каждому признаку (ортогональныортогональные (перпендикулярные) векторыEN orthogonal vectorsВекторы, скалярное произведение которых равно нулю. Ортогональная матрица — та, у которой столбцы единичные и попарно ортогональны. — значит, скалярное произведение равно нулю, урок 1.1.1). В остатках не осталось ничего, что признаки могли бы объяснить линейно: мы «выжали» из данных всё, что доступно прямой.

Для одного признака нормальное уравнение — это система из двух уравнений с двумя неизвестными. Её решение записывается совсем просто: w=cov(x,y)/var(x)w = \mathrm{cov}(x, y) / \mathrm{var}(x) и b=yˉ−wxˉb = \bar y - w \bar x (пошаговый вывод — в «Тыквенной книге», глава 3). Что здесь написано:

  • xˉ\bar x и yˉ\bar y («икс с чертой», «игрек с чертой») — средние значения;
  • var(x)\mathrm{var}(x) — дисперсия xx: среднее от (x−xˉ)2(x - \bar x)^2, разброс признака;
  • cov(x,y)\mathrm{cov}(x, y) — ковариация: среднее от (x−xˉ)(y−yˉ)(x - \bar x)(y - \bar y). Она положительна, если xx и yy растут вместе, и отрицательна, если один растёт, когда другой падает.

В отношении cov/var\mathrm{cov} / \mathrm{var} общий множитель 1n\frac{1}{n} сокращается, поэтому дальше считаем просто суммы. Считаем по таблице.

  • Средние: xˉ=300\bar x = 300, yˉ=6.4\bar y = 6.4.
  • Отклонения x−xˉx - \bar x: −200,−100,0,100,200-200, -100, 0, 100, 200. Отклонения y−yˉy - \bar y: −4.0,−1.9,−0.1,2.2,3.8-4.0, -1.9, -0.1, 2.2, 3.8.
  • ∑(x−xˉ)(y−yˉ)=800+190+0+220+760=1970\sum (x - \bar x)(y - \bar y) = 800 + 190 + 0 + 220 + 760 = 1970 и ∑(x−xˉ)2=100 000\sum (x - \bar x)^2 = 100\,000.
  • w=1970/100 000=0.0197w = 1970 / 100\,000 = 0.0197 с/токен, b=6.4−0.0197⋅300=0.49b = 6.4 - 0.0197 \cdot 300 = 0.49 с.

Итог: модель генерирует примерно 1/0.0197≈511 / 0.0197 \approx 51 токен в секунду и тратит около полсекунды на старт. Ответ на 1000 токенов займёт 0.0197⋅1000+0.49≈20.20.0197 \cdot 1000 + 0.49 \approx 20.2 с.

Проверим ортогональность на этих числах. Остатки лучшей прямой r=y^−yr = \hat y - y равны 0.06,−0.07,0.10,−0.23,0.140.06, -0.07, 0.10, -0.23, 0.14. Их сумма — ноль: остатки ортогональны столбцу единиц, то есть прямая в среднем не завышает и не занижает. Сумма ∑xiri=6−14+30−92+70=0\sum x_i r_i = 6 - 14 + 30 - 92 + 70 = 0 — они ортогональны и признаку xx.

0100200300400500600024681012токенов в ответе, xвремя ответа, сb = 0.49 с — накладные расходысплошная — лучшая прямая ŷ = 0.0197·x + 0.49 (MSE = 0.0182)пунктир — «на глаз» ŷ = 0.02·x (MSE = 0.18)жёлтые отрезки — остатки лучшей прямой
Сквозной пример: пять замеров (точки), лучшая прямая по МНК и её остатки. Наклон прямой w — секунды на токен, высота её левого конца (при x = 0) — сдвиг b.

В NumPy формулу не переписывают буква в букву, а зовут готовую функцию np.linalg.lstsq (least squares — «наименьшие квадраты», МНК). Продолжение кода выше:

A = np.c_[X, np.ones(len(X))] # np.c_ склеивает столбцы: справа приписали столбец единиц для b
w_b, *_ = np.linalg.lstsq(A, y, rcond=None) # МНК: устойчиво и точно
# lstsq возвращает 4 значения; нам нужно первое (веса), а «*_» собирает остальные в ненужную переменную
print(w_b) # [w, b]
[0.0197 0.49 ]

Тот же ответ, что мы получили вручную: w=0.0197w = 0.0197, b=0.49b = 0.49.

np.linalg.lstsq решает ту же задачу, но не обращает матрицу явно. Явное inv(X.T @ X) теряет точность, когда признаки почти линейно зависимы (скажем, длина ответа в токенах и она же в символах). А если один признак точно выражается через другие (длина в токенах и в тысячах токенов) или признаков больше, чем примеров (d>nd > n), матрица X⊤XX^\top X вырождена: обратной у неё нет, а минимумов MSE бесконечно много. lstsq и тогда вернёт ответ — решение с наименьшей нормой. Другой выход — L2-регуляризациярегуляризацияEN regularization, то есть гребневая регрессия (ridge)гребневая регрессия (ridge), L2-регуляризацияEN ridge regressionШтраф λ‖w‖² к функции потерь. В нейросетях та же идея называется weight decay. из урока 2.1.2: (X⊤X+λI)−1X⊤y(X^\top X + \lambda I)^{-1} X^\top y. Добавка λI\lambda I делает матрицу обратимой. (Для любознательных: собственные числа X⊤XX^\top X из урока 1.1.3 неотрицательны, а λI\lambda I сдвигает каждое из них на λ>0\lambda > 0, так что нулевых не остаётся.)

Главное ограничение — цена. Её записывают через «О большое» (сложность алгоритмасложность алгоритма (O-нотация)EN time complexity, big-O notationКак растёт время работы с размером данных n. O(n) — пропорционально n (поиск в list), O(1) — не зависит от n (поиск в dict и set).): O(d3)O(d^3) значит «число операций растёт как d3d^3» — при вдвое большем dd работы в 23=82^3 = 8 раз больше. Построить X⊤XX^\top X стоит O(nd2)O(nd^2), решить систему — O(d3)O(d^3). Для d=1000d = 1000 это доли секунды. Для нейросети с d=109d = 10^9 параметров d3=1027d^3 = 10^{27} операций — невозможно. И у нейросети вообще нет готовой формулы для лучших весов, как нормальное уравнение: модель нелинейна. Поэтому нужен второй способ.

Градиентный спускградиентный спускEN gradient descent из урока 1.4.1 работает для любой дифференцируемой модели. Именно им учат все LLM. Для линейной регрессии один шаг выглядит так:

  1. Посчитать предсказания y^=Xw+b\hat y = Xw + b и остатки r=y^−yr = \hat y - y.
  2. Посчитать градиенты: ∇wL=2nX⊤r\nabla_w L = \frac{2}{n} X^\top r,   ∂L/∂b=2n∑iri\;\partial L / \partial b = \frac{2}{n} \sum_i r_i.
  3. Шагнуть против градиента: w←w−η∇wLw \leftarrow w - \eta \nabla_w L,   b←b−η ∂L/∂b\;b \leftarrow b - \eta \, \partial L / \partial b.

Здесь η\eta («эта») — скорость обученияскорость обученияEN learning rate, размер шага из урока 1.4.1, а стрелка ←\leftarrow значит «заменить старое значение новым», как w = w - lr * dw в Python. Посмотрите на градиент по ww: каждый признак взвешен остатком. Где модель сильно ошиблась, там вклад примера в обновление больше.

Один шаг на наших данных. Старт: w=0w = 0, b=0b = 0, η=10−6\eta = 10^{-6}. Все предсказания равны 0, остатки r=−yr = -y: −2.4,−4.5,−6.3,−8.6,−10.2-2.4, -4.5, -6.3, -8.6, -10.2. Тогда ∂L/∂b=25(−32)=−12.8\partial L / \partial b = \frac{2}{5}(-32) = -12.8, и ∂L/∂w=25∑ixiri=25⋅(−11 570)=−4628\partial L / \partial w = \frac{2}{5} \sum_i x_i r_i = \frac{2}{5} \cdot (-11\,570) = -4628. Шаг против градиента: w=0+10−6⋅4628≈0.0046w = 0 + 10^{-6} \cdot 4628 \approx 0.0046, b=0+10−6⋅12.8≈0.000013b = 0 + 10^{-6} \cdot 12.8 \approx 0.000013. Оба параметра двинулись в нужную сторону — вверх. Но заметьте: градиент по bb в 360 раз меньше, чем по ww, и bb сдвинулся в 360 раз меньше. Это не случайность, а главная беда этого раздела — о ней ниже.

Запустим спуск на наших данных «как есть», с xx в токенах, из точки w=0w = 0, b=0b = 0. И сразу упрёмся в проблему.

Learning rate η\etaШаговЧто получилось
10−510^{-5}100Расходится: MSE ≈ 3⋅10173 \cdot 10^{17} и растёт дальше
8⋅10−68 \cdot 10^{-6}1000w=0.0210w = 0.0210, b=0.0015b = 0.0015, MSE = 0.062 — в 3.4 раза хуже оптимума
8⋅10−68 \cdot 10^{-6}10610^6w=0.0198w = 0.0198, b=0.46b = 0.46, MSE = 0.0183 — почти дошли

Learning rate 10−510^{-5} уже слишком велик, а при 8⋅10−68 \cdot 10^{-6} сдвиг bb еле ползёт: за тысячу шагов он сдвинулся на полторы тысячных из нужных 0.49. Почему так?

Градиент по ww содержит множитель x≈300x \approx 300, а градиент по bb — множитель 1. Поверхность потерь круто обрывается вдоль ww и почти плоская вдоль bb. Это узкий длинный овраг — такой же, как в уроке 1.4.1, только намного уже.

Чем это плохо, видно без всяких формул. Шаг η\eta один на оба параметра. Если сделать его большим, спуск «перелетит» крутой склон по ww и разойдётся — первая строка таблицы. Поэтому шаг приходится брать крошечным, под самое крутое направление. Но с таким шагом по пологому направлению bb модель ползёт еле-еле — вторая строка. Вывод: признаки в разных масштабах (сотни и единицы) превращают чашу в овраг, и спуск становится очень медленным.

Для любознательных: точный расчёт. Следующие абзацы показывают, откуда берутся числа «605 000» и «миллион шагов». При первом чтении их можно пропустить и перейти к «Лечению». Кривизна — это крутизна стенок чаши: насколько быстро растёт loss, когда отходим от минимума. В уроке 1.4.1 было правило: для чаши с кривизной aa спуск устойчив только при η<2/a\eta < 2/a. Когда параметров несколько, кривизну описывает матрица вторых производных — гессиан HH. У MSE он не зависит от весов: H=2nX⊤XH = \frac{2}{n} X^\top X (здесь XX уже со столбцом единиц). Для наших данных ∑xi2=550 000\sum x_i^2 = 550\,000 и ∑xi=1500\sum x_i = 1500, поэтому

H=25(∑xi2∑xi∑xi5)=(220 0006006002)H = \frac{2}{5} \begin{pmatrix} \sum x_i^2 & \sum x_i \\ \sum x_i & 5 \end{pmatrix} = \begin{pmatrix} 220\,000 & 600 \\ 600 & 2 \end{pmatrix}

Собственные числасобственное числоEN eigenvalueЧисло λ, для которого Av = λv при некотором ненулевом v. Показывает, во сколько раз матрица растягивает «свои» направления. гессиана — это кривизны вдоль главных осей оврага (урок 1.1.3). У нас λmax⁡≈220 000\lambda_{\max} \approx 220\,000 и λmin⁡≈0.36\lambda_{\min} \approx 0.36. Их отношение — число обусловленностичисло обусловленностиEN condition numberОтношение самой большой кривизны к самой маленькой. Большое число обусловленности — вытянутый «овраг», где градиентный спуск зигзагует. — около 605 000. Для сравнения: у оврага из урока 1.4.1 оно было 20.

Шаг ограничен самым крутым направлением: η<2/λmax⁡≈9⋅10−6\eta < 2 / \lambda_{\max} \approx 9 \cdot 10^{-6}. При допустимом шаге η=8⋅10−6\eta = 8 \cdot 10^{-6} отклонение от минимума вдоль пологого направления за шаг умножается всего на 1−ηλmin⁡≈1−3⋅10−61 - \eta \lambda_{\min} \approx 1 - 3 \cdot 10^{-6}. Чтобы это отклонение уменьшилось хотя бы в e≈2.7e \approx 2.7 раза, нужно около 1/(ηλmin⁡)≈340 0001 / (\eta \lambda_{\min}) \approx 340\,000 шагов. Отсюда и миллион.

Кстати, это же число объясняет, почему inv(X.T @ X) проигрывает lstsq. Число обусловленности X⊤XX^\top X равно квадрату числа обусловленности самой XX: у нас 7782≈605 000778^2 \approx 605\,000. Грубо говоря, при решении системы теряется столько десятичных знаков, каков порядок этого числа. Через X⊤XX^\top X это почти 6 знаков из примерно 16, которые хранит float64, а lstsq работает с самой XX и теряет около 3.

Стандартизация признаковстандартизация признаковEN feature standardization (z-score)(x − μ)/σ. Не путайте с нормализацией (normalization) — обычно это min-max масштабирование в [0, 1]; в китайских текстах эти термины иногда смешивают.: из каждого признака вычитаем среднее и делим на стандартное отклонение (оба понятия — из урока 0.3.2, там вы уже стандартизовали столбцы матрицы):

x′=x−μσx' = \frac{x - \mu}{\sigma}

Здесь μ\mu («мю») — среднее признака, σ\sigma («сигма») — его стандартное отклонение. После такого преобразования у каждого признака среднее 0 и разброс 1, в каких бы единицах он ни был раньше. У нас μ=300\mu = 300, σ≈141.4\sigma \approx 141.4, и признак превращается в −1.41,−0.71,0,0.71,1.41-1.41, -0.71, 0, 0.71, 1.41: например, (100−300)/141.4≈−1.41(100 - 300) / 141.4 \approx -1.41. Теперь оба признака — x′x' и столбец единиц для bb — одного масштаба, и овраг исчезает.

(Для любознательных: ∑xi′=0\sum x'_i = 0 и ∑xi′2=n\sum x'^2_i = n, поэтому в формуле гессиана выше 2n(n00n)=2⋅I\frac{2}{n} \begin{pmatrix} n & 0 \\ 0 & n \end{pmatrix} = 2 \cdot I. Оба собственных числа равны 2, число обусловленности — 1.) Овраг превратился в круглую чашу: градиент указывает прямо в минимум.

Сырые признакиСтандартизированные
Число обусловленности≈ 605 0001
Граница устойчивости 2/λmax⁡2/\lambda_{\max}≈ 9⋅10−69 \cdot 10^{-6}1.0
Шагов до MSE = 0.0182 (4 знака)≈ 1.2⋅1061.2 \cdot 10^6 при η=8⋅10−6\eta = 8 \cdot 10^{-6}31 при η=0.1\eta = 0.1

Проверьте таблицу сами — хватит десятка строк на NumPy:

import numpy as np
x = np.array([100.0, 200.0, 300.0, 400.0, 500.0])
y = np.array([2.4, 4.5, 6.3, 8.6, 10.2])
def gd(x, y, lr, steps):
"""Полный градиентный спуск для ŷ = w·x + b из точки w = b = 0."""
w, b = 0.0, 0.0
for _ in range(steps): # «_» — номер шага нам не нужен
r = w * x + b - y # остатки для всех 5 точек сразу
w -= lr * 2 * np.mean(r * x) # dL/dw = (2/n)·Σ rᵢ·xᵢ; «w -= a» значит «w = w - a»
b -= lr * 2 * np.mean(r) # dL/db = (2/n)·Σ rᵢ
return w, b, np.mean((w * x + b - y) ** 2) # итоговые веса и MSE
# Для любознательных: гессиан для сырых признаков и его собственные числа
A = np.c_[x, np.ones(5)] # столбец единиц для b
H = 2 / 5 * A.T @ A
lam = np.linalg.eigvalsh(H) # собственные числа по возрастанию (урок 1.1.3)
print(f"λmin = {lam[0]:.2f}, λmax = {lam[1]:.0f}, число обусловленности ≈ {lam[1] / lam[0]:.0f}")
# Строка % (a, b, c) — старый способ форматирования: каждое %.4f заменяется числом с 4 знаками
print("сырые, lr=8e-6, 1000 шагов: w = %.4f, b = %.4f, MSE = %.4f" % gd(x, y, 8e-6, 1000))
xs = (x - x.mean()) / x.std() # стандартизация: среднее 0, std 1
print("станд., lr=0.1, 31 шаг: w' = %.4f, b' = %.4f, MSE = %.4f" % gd(xs, y, 0.1, 31))
λmin = 0.36, λmax = 220002, число обусловленности ≈ 605009
сырые, lr=8e-6, 1000 шагов: w = 0.0210, b = 0.0015, MSE = 0.0616
станд., lr=0.1, 31 шаг: w' = 2.7832, b' = 6.3937, MSE = 0.0182

На сырых данных за тысячу шагов bb почти не сдвинулся. На стандартизированных за 31 шаг w′w' и b′b' подошли к оптимуму 2.7862.786 и 6.46.4 (их смысл — чуть ниже). Замените 1000 на 1 000 000 — и примерно через 10 секунд получите третью строку первой таблицы.

При η=0.5\eta = 0.5 спуск вообще приходит в минимум за один шаг: это идеальный шаг η=1/a\eta = 1/a из урока 1.4.1. После стандартизации кривизна чаши a=2a = 2 по всем направлениям (отсюда и граница 2/a=1.02/a = 1.0 в таблице), так что 1/a=0.51/a = 0.5, и в круглой чаше этот шаг один на все направления. При η=1.0\eta = 1.0 модель прыгает между двумя стенками чаши, при η=1.1\eta = 1.1 — расходится.

Посмотрите на это своими глазами в песочнице из урока 1.4.1. Ниже уже выбрана функция «Овраг». Поставьте скорость обучения около 0.45 (чуть ниже границы 2/4=0.52/4 = 0.5) и 10 шагов. Красная траектория SGD скачет поперёк оврага и остаётся далеко от звёздочки. Теперь переключитесь на «Чашу». При смене функции песочница сбрасывает скорость обучения, поэтому снова поставьте 0.45: путь идёт почти прямо в минимум. Синюю и жёлтую траектории (Momentum и Adam) можно выключить галочками. Овраг в песочнице — уменьшенная копия наших сырых признаков: отношение кривизн там 20, у нас 605 000. Чаша — то, что даёт стандартизация.

Песочница оптимизаторовинтерактив
Кликните по карте, чтобы выбрать стартовую точку. Звёздочка — минимум функции.

Обученные на x′x' веса легко вернуть в исходный масштаб. Модель на стандартизированных данных — это y^=w′⋅x−μσ+b′\hat y = w' \cdot \frac{x - \mu}{\sigma} + b'. Раскроем скобки и соберём слагаемые с xx и без него:

y^=w′σ⏟w⋅x+b′−w′μσ⏟b⟹w=w′σ,b=b′−w′μσ\hat y = \underbrace{\frac{w'}{\sigma}}_{w} \cdot x + \underbrace{b' - \frac{w' \mu}{\sigma}}_{b} \qquad\Longrightarrow\qquad w = \frac{w'}{\sigma}, \qquad b = b' - \frac{w' \mu}{\sigma}

У нас w′=2.786w' = 2.786, b′=6.4b' = 6.4, откуда w=2.786/141.4=0.0197w = 2.786 / 141.4 = 0.0197 и b=6.4−2.786⋅300/141.4=0.49b = 6.4 - 2.786 \cdot 300 / 141.4 = 0.49. Ответ совпал с аналитическим.

flowchart LR
A["Данные"] --> B["train / val / test"]
B --> C["μ, σ — только по train"]
C --> D["Стандартизация train, val и test<br/>одними и теми же μ, σ"]
D --> E["Градиентный спуск<br/>на train"]
E --> G["Оценка на val / test"]
E -.-> F["Веса → исходный масштаб<br/>(чтобы читать их смысл)"]
Порядок работы с признаками: статистики считаются только по обучающей выборке

Та же идея живёт внутри каждой LLM. LayerNormнормализация слояEN layer normalization и RMSNorm нормируют уже не входные признаки, а активации на каждом слое. Иначе глубокая сеть превращается в каскад оврагов. Подробно — в уроках 3.4.1 и 4.5.3.

Соберите линейную регрессию целиком: предсказание, MSE, градиенты, стандартизацию, спуск и возврат весов в исходный масштаб. В заготовке шесть функций — делайте их сверху вниз, каждая опирается на предыдущие. Все формулы уже есть в докстрингах и в разделах урока выше, задача — перевести их в NumPy. Последний тест — пример из урока: должны получиться w≈0.0197w \approx 0.0197 и b≈0.49b \approx 0.49.

Линейная регрессия градиентным спускомnumpy · нужен интернет7 тестов
import numpy as np


def predict(X, w, b):
    """Предсказания линейной модели: ŷ = X·w + b.

    X — матрица признаков формы (n, d), w — вектор весов формы (d,), b — число.
    Возвращает вектор формы (n,).
    """
    # TODO: одна строка
    ...


def mse(y_pred, y):
    """Среднеквадратичная ошибка: mean((ŷ − y)²). Возвращает число (float)."""
    # TODO
    ...


def gradients(X, y, w, b):
    """Градиенты MSE по w и по b.

    dL/dw = (2/n) · Xᵀ(ŷ − y)
    dL/db = (2/n) · Σ(ŷ − y)
    Возвращает кортеж (dw, db): dw — вектор формы (d,), db — число.
    """
    # TODO
    ...


def standardize(X, mean=None, std=None):
    """Стандартизация признаков по столбцам: (X − mean) / std.

    Если mean и std не переданы — посчитайте их по X (это обучающая выборка).
    Если переданы — используйте их (так преобразуют валидацию и тест).
    Столбец-константу (std == 0) делите не на 0, а на 1.
    Возвращает кортеж (X_std, mean, std).
    """
    # TODO
    ...


def fit_gd(X, y, lr=0.1, steps=500):
    """Обучает линейную регрессию полным градиентным спуском.

    Старт: w — нули формы (d,), b = 0.0.
    На каждом шаге: градиенты → шаг против градиента → записать MSE в history.
    Возвращает (w, b, history), где history — список из steps чисел.
    """
    # TODO
    ...


def to_original_scale(w_std, b_std, mean, std):
    """Пересчитывает веса, обученные на стандартизированных признаках, в исходный масштаб.

    На стандартизированных данных: ŷ = Σ_j w_std[j] · (x[j] − mean[j]) / std[j] + b_std.
    Раскройте скобки и верните (w, b), для которых ŷ = X·w + b на ИСХОДНЫХ X.
    """
    # TODO
    ...


# Проверьте себя кнопкой «Запустить»:
X_demo = np.array([[100.0], [200.0], [300.0]])
print(predict(X_demo, np.array([0.02]), 0.5))             # ожидается [2.5 4.5 6.5]
print(mse(np.array([2.5, 4.5]), np.array([2.4, 4.5])))    # ожидается ≈ 0.005

Теперь два признака: длина промпта и длина ответа. Обработка промпта (prefill) идёт параллельно и стоит дёшево на токен, генерация (decode) — последовательно и дорого. Решим задачу обоими способами. Запустите у себя.

PyTorch вы видели в уроках 1.1.2 (nn.Linear) и 1.4.1 (цикл zero_grad → backward → step). Тензор PyTorch устроен как массив NumPy, и многие функции называются похоже; незнакомые объяснены в комментариях.

import torch
torch.manual_seed(0)
n = 256
n_in = torch.randint(100, 8000, (n,)).float() # токены промпта: 256 случайных целых от 100 до 7999
n_out = torch.randint(10, 500, (n,)).float() # токены ответа; .float() — перевести в дробные числа
# «Истина» (синтетика): 0.2 мс на токен промпта, 20 мс на токен ответа, 0.3 с накладных
t = 0.0002 * n_in + 0.02 * n_out + 0.3 + 0.05 * torch.randn(n) # + шум со std 0.05 с
X = torch.stack([n_in, n_out], dim=1) # два вектора → таблица (256, 2): по столбцу на признак
y = t.unsqueeze(1) # (256,) → (256, 1): так ждёт nn.Linear
# Способ 1. Аналитическое решение: МНК через lstsq (столбец единиц отвечает за b)
A = torch.cat([X, torch.ones(n, 1)], dim=1) # cat — склеить по столбцам, как np.c_
w_in, w_out, b = torch.linalg.lstsq(A, y).solution.squeeze().tolist() # три числа в обычный список Python
print(f"lstsq: w_in = {w_in:.6f}, w_out = {w_out:.5f}, b = {b:.3f}")
# Способ 2. Градиентный спуск: стандартизация + nn.Linear + SGD
mean, std = X.mean(0), X.std(0) # torch.std делит на n − 1, NumPy — на n; для стандартизации это не важно
Xs = (X - mean) / std
model = torch.nn.Linear(2, 1) # ŷ = Xw + b с двумя весами и одним сдвигом
opt = torch.optim.SGD(model.parameters(), lr=0.1) # SGD здесь на полном батче: все 256 примеров на каждом шаге
for step in range(200):
loss = torch.nn.functional.mse_loss(model(Xs), y) # MSE предсказаний
opt.zero_grad() # стереть старые градиенты
loss.backward() # посчитать градиенты (PyTorch делает это сам)
opt.step() # шаг: w ← w − η·∇L
if step in (0, 9, 199):
print(f"шаг {step + 1:3d}: MSE = {loss.item():.4f}")
# Веса обратно в исходный масштаб: w = w'/σ, b = b' − Σ w'·μ/σ
# .detach() — «просто числа» без истории для градиентов; .squeeze() убирает лишние оси размера 1
w_std, b_std = model.weight.detach().squeeze(), model.bias.detach()
w = w_std / std
b = b_std - (w_std * mean / std).sum()
print(f"SGD: w_in = {w[0]:.6f}, w_out = {w[1]:.5f}, b = {b.item():.3f}")

Ожидаемый вывод (в другой версии PyTorch последние знаки могут отличаться):

lstsq: w_in = 0.000200, w_out = 0.01999, b = 0.304
шаг 1: MSE = 45.7267
шаг 10: MSE = 0.8553
шаг 200: MSE = 0.0028
SGD: w_in = 0.000200, w_out = 0.01999, b = 0.304

Оба способа нашли «истину»: 0.2 мс на токен промпта и 20 мс на токен ответа. Финальная MSE ≈ 0.0028 почти равна дисперсии шума 0.052=0.00250.05^2 = 0.0025 — лучше не бывает. nn.Linear(2, 1) — та самая Xw+bXw + b. В трансформере таких слоёв сотни, только вместо (2→1)(2 \to 1) там (4096→4096)(4096 \to 4096) и больше.

Бонус: закон масштабирования — тоже линейная регрессия

Заголовок раздела «Бонус: закон масштабирования — тоже линейная регрессия»

Многие зависимости в LLM степенные: y=a⋅Cky = a \cdot C^k. Логарифмируем и пользуемся свойствами логарифма из урока 1.0.2 (логарифм произведения — сумма логарифмов, логарифм степени — показатель, умноженный на логарифм): log⁡y=log⁡a+klog⁡C\log y = \log a + k \log C. Если обозначить u=log⁡Cu = \log C и v=log⁡yv = \log y, получится v=k⋅u+log⁡av = k \cdot u + \log a — обычная прямая с наклоном kk и сдвигом log⁡a\log a. Её коэффициенты находит линейная регрессия.

Здесь CC — вычислительный бюджет прогона в FLOPs: сколько арифметических операций с дробными числами потрачено на обучение. Числа вида 1e17 — научная запись из урока 1.0.2: 101710^{17}.

import numpy as np
# Синтетика: «лучший» learning rate в 6 маленьких прогонах с разным бюджетом C (FLOPs)
C = np.array([1e17, 3e17, 1e18, 3e18, 1e19, 3e19])
rng = np.random.default_rng(0)
lr_best = 2.0 * C ** -0.15 * np.exp(rng.normal(0, 0.05, C.size)) # закон + шум
# Степенной закон lr = a·C^k ⇔ log lr = log a + k·log C — это прямая!
k, log_a = np.polyfit(np.log(C), np.log(lr_best), deg=1) # линейная регрессия
print(f"k = {k:.3f}, a = {np.exp(log_a):.2f}")
print(f"прогноз для C = 1e22: lr ≈ {np.exp(log_a) * 1e22 ** k:.2e}")
k = -0.151, a = 2.07
прогноз для C = 1e22: lr ≈ 1.00e-03

Числа здесь выдуманы, но приём настоящий: так по дешёвым маленьким прогонам предсказывают гиперпараметры для дорогого большого. Подробно законы масштабированиязаконы масштабированияEN scaling laws разберём на ступени 5.

Проверь себя6 вопросов
1 Батч из 64 примеров, у каждого 10 признаков. Какой формы вектор весов ww и предсказание y^=Xw+b\hat y = Xw + b?
2 Один признак: xˉ=4\bar x = 4, yˉ=10\bar y = 10, ∑(x−xˉ)(y−yˉ)=50\sum (x - \bar x)(y - \bar y) = 50, ∑(x−xˉ)2=25\sum (x - \bar x)^2 = 25. Чему равен сдвиг bb?
3 Какие утверждения верны?Можно выбрать несколько ответов
4 Признаки: «длина промпта» (сотни–тысячи токенов) и «температура» (0–1). Градиентный спуск без нормализации сходится очень медленно. Почему?
5 По каким данным считать μ\mu и σ\sigma для стандартизации?
6 После стандартизации поверхность потерь в примере урока — круглая чаша с кривизной a=2a = 2 по всем направлениям. При каком шаге η\eta градиентный спуск попадает в минимум ровно за один шаг?
  • Линейная регрессия: y^=Xw+b\hat y = Xw + b, потери — MSE. MSE — это MLE при гауссовском шуме.
  • Аналитическое решение — нормальное уравнение X⊤Xw=X⊤yX^\top X w = X^\top y. Решайте через lstsq. Оно точное, но стоит O(d3)O(d^3) и существует только для линейной модели.
  • Градиентный спуск: ∇wL=2nX⊤(y^−y)\nabla_w L = \frac{2}{n} X^\top (\hat y - y). Работает для любой модели, включая LLM.
  • Стандартизация превращает овраг в круглую чашу: в примере урока больше миллиона шагов превратились в 31. Статистики — только по train.
  • nn.Linear — это Xw+bXw + b, атом любой нейросети.

Дальше: логистическая и softmax-регрессия — та же линейная модель, но для классификации. Добавим сигмоиду и cross-entropy и получим однослойную нейросеть — в точности выходной слой LLM.