Градиентный спуск на m примерах: как усреднённый градиент двигает веса

На практике ни одна модель не учится на одном-единственном примере: реальные наборы данных — это тысячи или миллионы примеров, и первый вопрос, который встаёт при переходе от игрушечных формул к настоящему обучению, — как один шаг спуска учитывает их все одновременно, а не по одному. Ответ окажется до смешного простым: усреднить.

В «Производных с вычислительным графом» мы разобрали обратный ход по графу на одном игрушечном примере с числами a,b,c, а перед этим — в «Бинарной классификации» — увидели один шаг спуска для одного обучающего примера (x,y). В этой статье соединяем оба куска: как градиентный спуск работает не на одном примере, а сразу на всём наборе из m примеров, и почему это буквально то же самое, что усреднение (среднее по всем L, J). Три крошечных примера с числами разобраны вручную и перепроверены в Python.

Почему J — это среднее, а не сумма

Напомним разницу терминов из «Бинарной классификации»: loss L(yˆ,y) — штраф за один пример, cost J(w,b) — штраф на всём наборе, и связь между ними была задана формулой J=1m∑iL(i) — среднее арифметическое всех m штрафов.

Раз J — среднее loss'ов (L), то и производная J по любому весу — среднее производных loss'ов по этому весу. Это не отдельное правило, а прямое следствие того, как работает производная суммы: производная суммы равна сумме производных (проверить легко — если каждое слагаемое чуть сдвинулось на свой кусочек, сумма сдвинулась на сумму этих кусочков), а деление на константу m просто выносится за знак производной, потому что m не зависит от весов. Отсюда:

∂J∂w=1m∑i=1m∂L(i)∂w

Представь, что несколько друзей на велосипедах едут вместе и каждый показывает рукой, в какую сторону, по его мнению, нужно повернуть. Один говорит «немного левее», другой — «чуть правее», третий — «прямо». Группа не слушает только одного — она усредняет все указанные направления и поворачивает туда, куда показывает это среднее. Градиент от каждого примера — это ровно такое «указание рукой»: каждый пример считает, куда лучше сдвинуть веса, а итоговый шаг — усреднённое мнение всех примеров сразу.

В обозначениях предыдущих статей («Бинарной классификации» и «Логистической регрессии») — то же самое короче: dw=1m∑idw(i), и аналогично db=1m∑idb(i), где dw(i) — вклад одного примера, посчитанный ровно так же, как в «Бинарной классификации» (dw(i)=x(i)⋅dz(i), db(i)=dz(i)).

Практический смысл: чтобы обучить модель на всём наборе, не нужно ничего нового изобретать — берём формулу градиента для одного примера (она уже выведена), считаем её m раз, для каждого примера отдельно, и усредняем результаты. Усреднение — это защита от одного шумного или нетипичного примера: если один пример «кричит» в одну сторону, а остальные девять — в другую, среднее склонится к большинству, а не дёрнется за одним — совсем как группа велосипедистов не свернёт резко из-за одного человека, махнувшего рукой в сторону.

Если можешь пересказать: J — среднее по loss'ам, значит и его градиент dw, db — среднее по градиентам отдельных примеров, — листай дальше.

Три примера, посчитанные вручную

Возьмём крошечный учебный набор: m=3 примера, у каждого n=2 признака. Веса (w) и сдвиг (b) возьмём стартовыми, ещё не обученными: w=[1.0,−0.5], b=0.2, скорость обучения (α) равна α=0.1.

Представь, что три человека пробуют суп и говорят, чего не хватает: один говорит «слишком солёно», другой — «в самый раз», третий — «недосолено». Перед тем как читать таблицу ниже — угадай: если 2 дегустатора говорят «недосолено», а 1 — «слишком солёно», в какую сторону в среднем сдвинется мнение — «пересолить», «недосолить» или «никуда, всё поровну»? Проверь свою догадку в Шаге 1 ниже, глядя на знаки dz: отрицательный dz означает «недосолено» (модель дала меньше, чем нужно), положительный — «пересолено». Повар не бросается угождать одному — он выслушивает все три мнения и решает, куда двигать рецепт, взвесив их вместе. Каждый из трёх примеров ниже — такой «дегустатор»: у него свои признаки x1,x2 и свой правильный ответ y, и по нему будет свой сигнал ошибки dz, который дальше усреднится с двумя другими.

примерx1x2y
10.50.31
21.00.20
30.20.81

Шаг 1 — прямой ход по каждому примеру. Считаем z=w1x1+w2x2+b, затем a=σ(z) (сигмоида (σ) — та же таблица значений, что в «Бинарной классификации»), затем сигнал ошибки dz=a−y (формула выведена в «Логистической регрессии»).

Для каждого примера — своя тройка чисел z,a,dz, и считаются они независимо от двух других:

Loss (L) каждого примера — кросс-энтропия: L=−log⁡a при y=1 или L=−log⁡(1−a) при y=0.

Считаем все три: L1=−log⁡(0.6341)=0.4555, L2=−log⁡(1−0.7503)=−log⁡(0.2497)=1.3873, L3=−log⁡(0.5000)=0.6931.

Средний штраф (J): J=(0.4555+1.3873+0.6931)/3=2.5359/3=0.8453.

Три значения dz по примерам (−0.366, +0.750, −0.500) и их среднее ≈ −0.039, отмеченное пунктиром

Шаг 2 — накопить вклад в dw и db. Для каждого примера считаем его вклад x(i)⋅dz(i) (по каждому из двух признаков отдельно) и dz(i).

Складываем эти вклады по всем трём примерам:

Суммы: ∑x1dz=−0.1829+0.7503−0.1000=0.4673, ∑x2dz=−0.1098+0.1501−0.4000=−0.3597, ∑dz=−0.3659+0.7503−0.5000=−0.1156.

Мини-квиз: три дегустатора дали dz=−0.3659,+0.7503,−0.5. Представь, что появился четвёртый со значением dz=+0.9 — каким станет среднее dz по всем четырём?

ОтветСумма: −0.3659+0.7503−0.5+0.9=0.7844. Делим на 4 дегустаторов: 0.7844/4≈0.1961. Знак поменялся с отрицательного (в среднем «недосолено») на положительный («в среднем пересолено») — один сильный голос четвёртого дегустатора перетянул среднее в свою сторону.

Шаг 3 — разделить на m. Усреднённые градиенты: dw=[0.4673,−0.3597]/3=[0.1558,−0.1199], db=−0.1156/3=−0.0385.

Шаг 4 — шаг спуска. По той же формуле, что в предыдущих статьях («Градиентном спуске» и «Логистической регрессии»), w:=w−αdw, b:=b−αdb:

w:=[1.0,−0.5]−0.1⋅[0.1558,−0.1199]=[0.9844,−0.4880]

b:=0.2−0.1⋅(−0.0385)=0.2039

Проверь на второй компоненте веса: −0.5−0.1⋅(−0.1199)=−0.5+0.01199=−0.4880 — совпадает. Один шаг спуска на трёх примерах сделан: веса чуть подросли по первому признаку (в основном под влиянием примера 2, где ошибка была самой крупной по модулю), а по второму — чуть упали.

Хочешь закрепить механику руками — посчитай сам четвёртый пример: x1=0.7, x2=0.5, y=0 (те же веса w=[1.0,−0.5], b=0.2, что и в Шаге 1) — найди z, a, dz и его вклад в dw, db. Или пропусти этот шаг и иди сразу к следующему разделу про наивный цикл — оба пути ведут туда же.

Мини-квиз: а если бы примеров было не 3, а 5, и сумма x1⋅dz по всем пяти оказалась 1.0 — чему равен усреднённый dw1?

Ответ1.0/5=0.2. Правило то же самое при любом m: сложить вклад каждого примера и поделить на их число.

Наивный цикл: как это выглядит в коде

Механика трёх шагов выше — это ровно два вложенных цикла: внешний по примерам (от 1 до m), внутренний по признакам (от 1 до n). Псевдокод для логистической регрессии с n признаками, инициализация нулями:

J=0, dw1=0,…,dwn=0, db=0.

Для каждого примера i от 1 до m: посчитать z(i)=wTx(i)+b, a(i)=σ(z(i)), прибавить L(a(i),y(i)) к J, посчитать dz(i)=a(i)−y(i). Затем для каждого признака j от 1 до n: прибавить xj(i)⋅dz(i) к dwj. И прибавить dz(i) к db.

После внешнего цикла — разделить всё на m: J/=m; каждый dwj/=m; db/=m.

Ровно это и произошло в разделе выше, только руками и на трёх примерах вместо цикла: внешний проход по примерам 1→2→3 — это внешний цикл, а два числа x1⋅dz и x2⋅dz внутри каждого примера — внутренний цикл по признакам.

Проблема этого кода — не в правильности (он абсолютно верно считает то, что нужно), а в скорости. При настоящих размерах — m в тысячах примеров, n в тысячах признаков (вспомни nx=12288 для картинки 64×64×3 из «Бинарной классификации») — внутренний цикл по признакам придётся прогонять m×n раз на каждый шаг спуска, а шагов спуска — сотни или тысячи. Два вложенных цикла на числах такого масштаба — это медленно даже для современного процессора.

Представь конкретные цифры: пусть n=1000 признаков и m=1000 примеров — размеры не запредельные, обычная небольшая задача. Прежде чем читать дальше — угадай: сколько отдельных «подкруток» (умножений xj⋅dz во внутреннем цикле) происходит внутри одного-единственного шага градиентного спуска? Ответ: внешний цикл по m=1000 примерам, и внутри каждого — внутренний цикл по n=1000 признакам, то есть 1000×1000=1000000 операций — миллион подкруток на один шаг, а шагов спуска нужны сотни или тысячи. Как душ со старым краном без термостата: крутишь ручку, ждёшь, проверяешь рукой — и если это нужно повторить миллион раз только чтобы сделать один шаг к нужной температуре, время уходит заметное.

Если можешь пересказать: наивный код — два вложенных цикла, внешний по примерам, внутренний по признакам, с накоплением dw, db и делением на m в конце, — листай дальше.

Что дальше

Мы закрыли последний пробел перед полноценным обучением: J и его градиенты dw, db — усреднение по m примерам, и это усреднение можно честно запрограммировать двумя вложенными циклами, что мы и сделали руками на трёх примерах.

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

Если можешь пересказать: усреднение градиента по m примерам — то же среднее, что и в J; наивная реализация — вложенные циклы по примерам и признакам, которые векторизация впоследствии убирает, — значит, тема усвоена.