Граф вычислений
Вычисление как граф
Любую формулу можно разложить на элементарные операции — сложение, умножение, экспоненту — и нарисовать как граф: узлы — значения, стрелки — откуда значения берутся. Например, f = (x + y) \cdot z:
import vis
vis.graph(
{"x": "x = −2", "y": "y = 5", "z": "z = −4", "+": "+", "q": "q = x + y\n= 3", "*": "×", "f": "f = q · z\n= −12"},
[("x", "+"), ("y", "+"), ("+", "q"), ("q", "*"), ("z", "*"), ("*", "f")],
title="Граф вычислений f = (x + y) · z",
)
Нейросеть — тоже такой граф, только огромный: миллионы узлов умножения и сложения, активации, функция потерь в конце. Граф — удобный способ думать о градиентах, потому что он сводит сложное правило цепочки к локальным действиям в каждом узле.
Прямой и обратный проходы
Прямой проход — идём от входов к выходу и считаем значения в узлах: q = 3, f = -12.
Обратный проход — идём от выхода к входам и считаем, как выход зависит от каждого узла. Начинаем с \frac{\partial f}{\partial f} = 1. Каждый узел получает сверху градиент \frac{\partial f}{\partial \text{выход узла}} и передаёт своим входам
— пришедший градиент, умноженный на локальную производную. Это правило цепочки, применённое по одному звену за раз. Узлу не нужно знать ничего о графе вокруг: только свои входы и пришедший градиент.
Для f = q \cdot z, q = x + y:
| Узел | Локальные производные | Градиент |
|---|---|---|
| f | — | \frac{\partial f}{\partial f} = 1 |
| f = q z | \frac{\partial f}{\partial q} = z, \frac{\partial f}{\partial z} = q | \frac{\partial f}{\partial q} = -4, \frac{\partial f}{\partial z} = 3 |
| q = x + y | \frac{\partial q}{\partial x} = 1, \frac{\partial q}{\partial y} = 1 | \frac{\partial f}{\partial x} = -4 \cdot 1 = -4, \frac{\partial f}{\partial y} = -4 |
x, y, z = -2.0, 5.0, -4.0
# прямой проход
q = x + y
f = q * z
# обратный проход
df = 1.0
dq = df * z # узел умножения: градиент для q — это другой множитель
dz = df * q
dx = dq * 1.0 # узел сложения: градиент проходит без изменений
dy = dq * 1.0
print(f"f = {f}; ∂f/∂x = {dx}, ∂f/∂y = {dy}, ∂f/∂z = {dz}")
Как ведут себя узлы
Полезно запомнить «характеры» основных операций:
- Сложение a + b — распределитель: передаёт пришедший градиент всем входам без изменений (локальные производные равны 1).
- Умножение a \cdot b — переключатель: входу a достаётся градиент, умноженный на b, а входу b — умноженный на a. Большой вход делает градиент другого большим.
- Максимум \max(a, b) — маршрутизатор: весь градиент уходит победителю, проигравший получает 0. ReLU — это \max(0, x).
- Функции e^x, \ln x, \sigma(x) — умножают градиент на свою производную: e^x, 1/x, \sigma(1 - \sigma).
Ветвление: градиенты складываются
Если значение используется в нескольких местах, оно влияет на результат несколькими путями, и вклады путей складываются. Например, f = x \cdot y + x:
import vis
vis.graph(
{"x": "x = 3", "y": "y = 2", "*": "×", "m": "m = x·y", "+": "+", "f": "f = m + x"},
[("x", "*"), ("y", "*"), ("*", "m"), ("m", "+"), ("x", "+"), ("+", "f")],
title="x используется дважды",
)
x, y = 3.0, 2.0
m = x * y
f = m + x
df = 1.0
dm = df # от сложения
dx = df # первая ветка: x напрямую в сложение
dx += dm * y # вторая ветка: через умножение — прибавляем!
dy = dm * x
print(f"∂f/∂x = {dx} (= y + 1), ∂f/∂y = {dy}")
Поэтому в библиотеках автодифференцирования градиенты накапливаются (+=), а перед каждым шагом обучения их нужно обнулять — иначе к градиенту этого шага прибавится градиент прошлого. Отсюда optimizer.zero_grad() в каждом цикле обучения PyTorch.
Разбор: нейрон как граф
Нейрон с двумя входами, ReLU и квадратичной потерей:
import vis
vis.graph(
{"x1": "x₁ = 2", "w1": "w₁ = 0.5", "x2": "x₂ = −1", "w2": "w₂ = 0.3", "c": "c = 0.1",
"a": "a = w₁x₁\n= 1.0", "b": "b = w₂x₂\n= −0.3", "s": "s = a + b + c\n= 0.8",
"r": "r = ReLU(s)\n= 0.8", "L": "L = (r − 1)²\n= 0.04"},
[("x1", "a"), ("w1", "a"), ("x2", "b"), ("w2", "b"), ("a", "s"), ("b", "s"), ("c", "s"), ("s", "r"), ("r", "L")],
title="Нейрон: прямой проход",
)
x1, w1, x2, w2, c, y = 2.0, 0.5, -1.0, 0.3, 0.1, 1.0
a, b = w1 * x1, w2 * x2
s = a + b + c
r = max(0.0, s)
L = (r - y) ** 2
dr = 2 * (r - y) # −0.4
ds = dr * (1.0 if s > 0 else 0) # ReLU пропускает: −0.4
da = db = dc = ds # сложение раздаёт
dw1, dw2 = da * x1, db * x2 # умножение: на другой множитель
print(f"L = {L:.4f}; ∂L/∂w₁ = {dw1:.2f}, ∂L/∂w₂ = {dw2:.2f}, ∂L/∂c = {dc:.2f}")
Обратите внимание: градиент по весу w_1 равен «градиенту на выходе нейрона» × «вход x_1». Вес, на который пришёл большой вход, меняется сильнее. Если вход равен нулю — вес вообще не меняется: он не повлиял на ошибку.
Почему обратный режим
Производные по графу можно считать и вперёд: взять один вход, протолкнуть его производную до выхода. Это прямой режим (forward mode). Но он даёт производную выхода по одному входу за проход. Для сети с миллионом параметров — миллион проходов.
Обратный режим (reverse mode) начинает с одного выхода — функции потерь — и за один проход назад даёт производные по всем узлам. Стоимость примерно равна стоимости прямого прохода (обычно в 2–3 раза больше). Это и называется backpropagation, и именно поэтому обучение больших нейросетей вообще возможно: градиент по миллиардам параметров стоит как пара вычислений сети.
| Прямой режим | Обратный режим | |
|---|---|---|
| Направление | от входов к выходам | от выходов к входам |
| За один проход | производные всех выходов по одному входу | производные одного выхода по всем входам |
| Выгоден, когда | входов мало, выходов много | входов много (параметры), выход один (потеря) |
| Цена | — | нужно хранить промежуточные значения прямого прохода |
Последняя строка — причина, почему обучение требует намного больше памяти, чем применение модели: все промежуточные результаты прямого прохода хранятся до обратного.
Итог
- Вычисление — граф простых операций; прямой проход считает значения, обратный — градиенты.
- Каждый узел умножает пришедший градиент на свою локальную производную — это правило цепочки по шагам.
- Сложение раздаёт градиент, умножение меняет множители местами, максимум направляет градиент победителю.
- При ветвлении градиенты складываются, поэтому их накапливают и обнуляют перед шагом.
- Обратный режим даёт градиент по всем параметрам за один проход — это backpropagation.
Практика решено 0 из 6
Граф: q = x + y, f = q \cdot z. При x = -2, y = 5, z = -4 найдите \frac{\partial f}{\partial x}.
В том же графе (q = x + y, f = q \cdot z, x = -2, y = 5, z = -4) найдите \frac{\partial f}{\partial z}.
Узел вычисляет c = \max(a, b) при a = 3, b = 7, и сверху пришёл градиент \frac{\partial L}{\partial c} = 2. Какие градиенты получат a и b?
Переменная x используется в графе дважды: f = x \cdot y + x. Как получить \frac{\partial f}{\partial x}?
У сети 1 000 000 параметров и одна функция потерь. Сколько обратных проходов нужно, чтобы получить градиент по всем параметрам?
Обратный проход вручную. Для графа
напишите forward_backward(x1, x2, w1, w2, c, y), которая выполняет прямой проход, затем обратный
и возвращает словарь {"L": ..., "w1": ..., "w2": ..., "c": ...} — значение потерь и производные
L по w_1, w_2, c. Производная ReLU в нуле — 0.
Какие тесты проверяют код (3)
out = forward_backward(2.0, -1.0, 0.5, 0.3, 0.1, 1.0)
assert abs(out["L"] - 0.04) < 1e-12, f"L = {out['L']}, ожидалось 0.04"
assert abs(out["w1"] - (-0.8)) < 1e-12 and abs(out["w2"] - 0.4) < 1e-12 and abs(out["c"] - (-0.4)) < 1e-12
out = forward_backward(1.0, 1.0, -1.0, -1.0, 0.5, 2.0)
assert out["w1"] == 0 and out["w2"] == 0 and out["c"] == 0, "Если s ≤ 0, ReLU не пропускает градиент"
def L(w1, w2, c):
return (max(0.0, w1 * 1.5 + w2 * 2.0 + c) - 0.3) ** 2
out = forward_backward(1.5, 2.0, 0.2, 0.4, -0.1, 0.3)
h = 1e-6
for k, f in [("w1", lambda d: L(0.2 + d, 0.4, -0.1)), ("w2", lambda d: L(0.2, 0.4 + d, -0.1)), ("c", lambda d: L(0.2, 0.4, -0.1 + d))]:
assert abs(out[k] - (f(h) - f(-h)) / (2 * h)) < 1e-6, f"Производная по {k} не совпадает с численной"
def forward_backward(x1, x2, w1, w2, c, y):
a = w1 * x1
b = w2 * x2
s = a + b + c
r = max(0.0, s)
L = (r - y) ** 2
dr = 2 * (r - y)
ds = dr * (1.0 if s > 0 else 0.0)
return {"L": L, "w1": ds * x1, "w2": ds * x2, "c": ds}
Войдите, чтобы отмечать пройденные уроки.