Граф вычислений

⏱ 35 мин · 🧩 заданий: 6

Вычисление как граф

Любую формулу можно разложить на элементарные операции — сложение, умножение, экспоненту — и нарисовать как граф: узлы — значения, стрелки — откуда значения берутся. Например, 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{выход узла}} и передаёт своим входам

\frac{\partial f}{\partial \text{вход}} = \frac{\partial f}{\partial \text{выход}} \cdot \frac{\partial\, \text{выход}}{\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 и квадратичной потерей:

s = w_1 x_1 + w_2 x_2 + c, \quad r = \text{ReLU}(s), \quad L = (r - y)^2
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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Ввод ответа

Граф: q = x + y, f = q \cdot z. При x = -2, y = 5, z = -4 найдите \frac{\partial f}{\partial x}.

Задание 2 Ввод ответа

В том же графе (q = x + y, f = q \cdot z, x = -2, y = 5, z = -4) найдите \frac{\partial f}{\partial z}.

Задание 3 Один вариант ответа

Узел вычисляет c = \max(a, b) при a = 3, b = 7, и сверху пришёл градиент \frac{\partial L}{\partial c} = 2. Какие градиенты получат a и b?

Задание 4 Один вариант ответа

Переменная x используется в графе дважды: f = x \cdot y + x. Как получить \frac{\partial f}{\partial x}?

Задание 5 Один вариант ответа

У сети 1 000 000 параметров и одна функция потерь. Сколько обратных проходов нужно, чтобы получить градиент по всем параметрам?

Задание 6 Программа на Python

Обратный проход вручную. Для графа

a = w_1 x_1, \quad b = w_2 x_2, \quad s = a + b + c, \quad r = \max(0, s), \quad L = (r - y)^2

напишите 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} не совпадает с численной"

Войдите, чтобы отмечать пройденные уроки.