← Все главы

14 · Версия материала 6

Накопление градиентов в скалярном графе

Постройте на Rust скалярное автоматическое дифференцирование в обратном режиме, сложите градиенты повторных вхождений операндов и проверьте результат для обучения LLM.

Предскажите градиент повторно используемого скаляра

Начните с одной отслеживаемой скалярной переменной. Умножение дважды использует x, а сложение дважды использует square:

x = 2
square = x * x
loss = square + square

В прямом проходе получатся square=4\mathrm{square}=4 и loss=8\mathrm{loss}=8. Теперь задайте для скалярного выхода начальную сопряжённую величину lossˉ=1\bar{\mathrm{loss}}=1. Локальная производная сложения по каждому операнду равна 11. Оба операнда указывают на один узел square, но остаются двумя отдельными вхождениями. Поэтому обратные вклады равны 11 и 11, а squareˉ=2\bar{\mathrm{square}}=2.

Умножение тоже содержит два вхождения одного узла xx. Локальная производная по каждому операнду равна значению другого операнда, то есть 22. Умножение на входящую сопряжённую величину 22 даёт вклады 44 и 44, поэтому xˉ=8\bar{x}=8.

Постройте граф из трёх узлов, сохранив четыре повторных ребра операндов rust/demos/ch14-scalar-autodiff/src/lib.rs#shared-scalar-fixture
/// Builds one shared DAG, runs it twice, clears it, and runs one fresh pass.
pub fn reused_square_example() -> Result<ReusedSquareExample, ScalarAutodiffError> {
    let x = Scalar::variable(REUSED_INPUT)?;
    let square = x.mul(&x)?;
    let loss = square.add(&square)?;

    let first_pass = loss.backward()?;
    let first = snapshot(&x, &square, &loss);
    loss.backward()?;
    let repeated = snapshot(&x, &square, &loss);
    loss.zero_grad();
    let zeroed = snapshot(&x, &square, &loss);
    loss.backward()?;
    let after_zero = snapshot(&x, &square, &loss);

    Ok(ReusedSquareExample {
        x_value: x.value(),
        square_value: square.value(),
        loss_value: loss.value(),
        first_pass,
        first,
        repeated,
        zeroed,
        after_zero,
    })
}

Сложите все пути обратного прохода

Для одного нового обратного прохода точное правило объединяет граничное условие в выбранном выходе с суммированием по рёбрам:

vˉ=s𝟏[v=o]+eEo(v)c(e)ˉde\bar v=s\,\mathbf{1}[v=o]+\sum_{e\in E_o(v)}\bar{c(e)}\,d_e

Здесь oo — выбранный отслеживаемый скалярный выход, а ss — конечная начальная сопряжённая величина, которую явно задаёт вызывающий код. Метод backward_with_seed устанавливает ss в узле oo, а backward() использует s=1s=1. Отслеживаемый узел vv принадлежит подграфу зависимостей, который обходится от oo к его операндам. Индикатор равен 11, когда vv и oo — один и тот же узел графа, и 00 в остальных случаях; совпадения значений прямого прохода для этого недостаточно. Поэтому индикатор задаёт oˉ=s\bar o=s, хотя в этом подграфе у выбранного выхода нет узла-потребителя. В Eo(v)E_o(v) каждое вхождение отслеживаемого vv как операнда представлено отдельным ребром; потребитель, не связанный с oo, исключён. Неотслеживаемые константы и отсоединённые значения могут присутствовать в структурном обходе, но не входят в область рекуррентного вычисления сопряжённых величин: реализация не сохраняет для них сопряжённую величину текущего прохода и не распространяет её в них. Результат c(e)c(e) использует данное вхождение, а ded_e — локальная производная по этому слоту, вычисленная по сохранённым значениям прямого прохода. Так правило цепочки явно задаёт и граничное условие, и ветвление. В xxx\cdot x два слота операндов создают два ребра, хотя оба указывают на один узел xx. Каждое ребро даёт произведение входящей сопряжённой величины на свою локальную производную; эти вклады нужно складывать. Присваивание незаметно отбросило бы все вклады, кроме одного.

Сначала граф вычисляется в прямом направлении. Затем топологический список, где результаты идут после операндов, обходится в обратном порядке. Поэтому результат- потребитель успевает получить все вклады, прежде чем передаст завершённую сопряжённую величину текущего прохода своим операндам. Накопление между разными вызовами обратного прохода — отдельная операция: gv+=vˉg_v\mathrel{+}=\bar v выполняется лишь после проверки всего нового прохода.

Назовите элементы графа и сопряжённые величины

СимволПрактический смысл
vvОдин отслеживаемый скалярный узел в подграфе зависимостей, который обходится от oo к его операндам.
vˉ\bar vСопряжённая величина текущего прохода при начальном значении ss: произведение ss на производную выбранного выхода oo по vv.
ooВыбранный отслеживаемый скалярный выход, для которого выполняется обратный проход.
ssКонечная скалярная начальная сопряжённая величина, которую задаёт вызывающий код для oo; метод backward() использует s=1s=1.
𝟏[v=o]\mathbf{1}[v=o]Индикатор идентичности узлов графа: 11, когда vv — выходной узел oo, и 00 в остальных случаях, даже если значения двух узлов совпадают.
eeОдно отдельное исходящее ребро для одного вхождения vv как операнда.
Eo(v)E_o(v)Отдельные рёбра вхождений отслеживаемого vv как операнда в подграфе зависимостей обратного прохода от oo.
c(e)c(e)Узел-результат, который использует вхождение операнда, представленное ребром ee.
c(e)ˉ\bar{c(e)}Сопряжённая величина текущего прохода, уже накопленная в результате-потребителе.
ded_eЛокальная производная результата по этому вхождению операнда, вычисленная по сохранённым значениям прямого прохода.

В топологическом списке x, square и loss встречаются по одному разу. Но square=xx\mathrm{square}=x\cdot x хранит два ребра к x, а loss=square+square\mathrm{loss}=\mathrm{square}+\mathrm{square} — два ребра к square. Устранять повторные посещения узлов правильно; объединять эти четыре вхождения операндов нельзя.

От градиентов следующего слова к масштабным авторегрессионным Transformer

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

Ранний пример нейронной языковой модели — работа Bengio et al., A Neural Probabilistic Language Model. Bengio и соавторы обучают вероятности следующего слова и параметры представлений слов и описывают прямой этап, а также этап обратного распространения и обновления, который обнуляет и складывает градиенты для выходных и скрытых нейронов и входных представлений слов.

Baydin и соавторы описывают обратный режим: зависимости записываются во время прямого вычисления, после чего сопряжённые величины распространяются от одного скалярного выхода назад по графу, а вклады всех путей складываются. Такое направление вычислений подходит для скалярной цели обучения с множеством параметров. Затем Vaswani и соавторы обучают повторяющиеся слои внимания и полносвязные блоки Transformer, а Radford и соавторы масштабируют авторегрессионные языковые модели Transformer от 12 до 48 слоёв и от 117 миллионов до 1,542 миллиарда параметров.

Обратный режим систематизирован в обзоре Baydin et al., Automatic Differentiation in Machine Learning: a Survey. Baydin и соавторы показывают, как символьное дифференцирование дублирует общие подвыражения, объясняют, почему прямому режиму нужен отдельный проход для каждой независимой входной переменной или направления, и описывают запись зависимостей с накоплением сопряжённых величин за один обратный проход.

Пример обучения Transformer приведён в работе Vaswani et al., Attention Is All You Need. Vaswani и соавторы строят Transformer из повторяющихся подслоёв внимания и позиционно-независимых полносвязных сетей и обучают базовые модели 100 000 шагов, а большие — 300 000 шагов с оптимизатором Adam.

Масштабирование авторегрессионной языковой модели показано в работе Radford et al., Language Models are Unsupervised Multitask Learners. Radford и соавторы используют авторегрессионные языковые модели на основе Transformer и описывают четыре размера: от 12 до 48 слоёв и от 117 миллионов до 1,542 миллиарда параметров.

В этой главе обратное накопление выделено в маленький скалярный граф, после чего выбранные производные сопоставляются с выборочной численной сверкой из главы 13, реализованной отдельным путём. Оба пути всё ещё используют одну и ту же математическую функцию, входное значение и арифметику IEEE f64, поэтому совпадение в выбранной гладкой точке служит свидетельством, но не доказывает правильность всей системы обратного режима. Этот результат подготавливает ленту тензорных операций для обучения LLM в главах 15 и 16. При обычном инференсе декодера обратный граф не выполняется: обратный режим нужен для вычисления градиентов во время обучения, причём сопряжённые величины текущего прохода следует отличать от градиентов, накопленных за завершённые вызовы обратного прохода.

Компактный тип Scalar — это учебный пример общего механизма, а не утверждение о внутреннем представлении какого-либо из цитируемых Transformer.

Выполните новый обратный проход

Конструкторы Scalar различают отслеживаемые переменные и неотслеживаемые константы. Их значения должны быть конечными. Закрытые узлы хранят только неизменяемые ссылки на операнды, поэтому операции не могут создать цикл. Методы add, mul, neg, sub, exp и tanh вычисляют конечный результат прямого прохода и записывают упорядоченные рёбра к операндам вместе с локальными производными, которые понадобятся позже.

Создавайте конечные скалярные узлы и записывайте проверенные результаты с упорядоченными рёбрами локальных производных rust/crates/llm-from-scratch/src/autograd/scalar.rs#scalar-dag-operations
impl Scalar {
    /// Creates a finite leaf whose gradient is tracked.
    pub fn variable(value: f64) -> Result<Self, ScalarAutodiffError> {
        Self::leaf(value, ScalarOperation::Variable, true)
    }

    /// Creates a finite leaf treated as a constant by backpropagation.
    pub fn constant(value: f64) -> Result<Self, ScalarAutodiffError> {
        Self::leaf(value, ScalarOperation::Constant, false)
    }

    fn leaf(
        value: f64,
        operation: ScalarOperation,
        tracked: bool,
    ) -> Result<Self, ScalarAutodiffError> {
        if !value.is_finite() {
            return Err(ScalarAutodiffError::NonFiniteLeaf { operation, value });
        }
        Ok(Self::new_node(value, operation, Vec::new(), tracked))
    }

    fn new_node(
        value: f64,
        operation: ScalarOperation,
        parents: Vec<ParentEdge>,
        tracked: bool,
    ) -> Self {
        Self {
            node: Rc::new(RefCell::new(Node {
                value,
                operation,
                parents,
                gradient: tracked.then_some(0.0),
            })),
        }
    }

    fn operation_node(
        value: f64,
        operation: ScalarOperation,
        parents: Vec<ParentEdge>,
    ) -> Result<Self, ScalarAutodiffError> {
        if !value.is_finite() {
            return Err(ScalarAutodiffError::NonFiniteResult { operation, value });
        }
        debug_assert!(parents.iter().all(|edge| edge.local_derivative.is_finite()));
        let tracked = parents.iter().any(|edge| edge.parent.tracks_gradient());
        Ok(Self::new_node(value, operation, parents, tracked))
    }

    pub fn value(&self) -> f64 {
        self.node.borrow().value
    }

    pub fn operation(&self) -> ScalarOperation {
        self.node.borrow().operation
    }

    pub fn tracks_gradient(&self) -> bool {
        self.node.borrow().gradient.is_some()
    }

    pub fn gradient(&self) -> Option<f64> {
        self.node.borrow().gradient
    }

    /// Returns whether two handles refer to the same graph node.
    pub fn is_same_node(&self, other: &Self) -> bool {
        Rc::ptr_eq(&self.node, &other.node)
    }

    /// Adds two finite scalars and records both ordered operand edges.
    pub fn add(&self, other: &Self) -> Result<Self, ScalarAutodiffError> {
        Self::operation_node(
            self.value() + other.value(),
            ScalarOperation::Add,
            vec![
                ParentEdge {
                    parent: self.clone(),
                    local_derivative: 1.0,
                },
                ParentEdge {
                    parent: other.clone(),
                    local_derivative: 1.0,
                },
            ],
        )
    }

    /// Multiplies two finite scalars and records one edge per operand use.
    pub fn mul(&self, other: &Self) -> Result<Self, ScalarAutodiffError> {
        let left = self.value();
        let right = other.value();
        Self::operation_node(
            left * right,
            ScalarOperation::Multiply,
            vec![
                ParentEdge {
                    parent: self.clone(),
                    local_derivative: right,
                },
                ParentEdge {
                    parent: other.clone(),
                    local_derivative: left,
                },
            ],
        )
    }

    pub fn neg(&self) -> Result<Self, ScalarAutodiffError> {
        Self::operation_node(
            -self.value(),
            ScalarOperation::Negate,
            vec![ParentEdge {
                parent: self.clone(),
                local_derivative: -1.0,
            }],
        )
    }

    pub fn sub(&self, other: &Self) -> Result<Self, ScalarAutodiffError> {
        Self::operation_node(
            self.value() - other.value(),
            ScalarOperation::Subtract,
            vec![
                ParentEdge {
                    parent: self.clone(),
                    local_derivative: 1.0,
                },
                ParentEdge {
                    parent: other.clone(),
                    local_derivative: -1.0,
                },
            ],
        )
    }

    pub fn exp(&self) -> Result<Self, ScalarAutodiffError> {
        let value = self.value().exp();
        Self::operation_node(
            value,
            ScalarOperation::Exp,
            vec![ParentEdge {
                parent: self.clone(),
                local_derivative: value,
            }],
        )
    }

    pub fn tanh(&self) -> Result<Self, ScalarAutodiffError> {
        let value = self.value().tanh();
        Self::operation_node(
            value,
            ScalarOperation::Tanh,
            vec![ParentEdge {
                parent: self.clone(),
                local_derivative: 1.0 - value * value,
            }],
        )
    }

    /// Copies the primal into a new untracked constant with no parent edge.
    pub fn detach(&self) -> Self {
        Self::new_node(self.value(), ScalarOperation::Detached, Vec::new(), false)
    }
}

Обратный проход никогда не начинает со старых промежуточных градиентов. Он строит топологический список уникальных узлов, задаёт в новой карте сопряжённых величин единицу для loss и при обратном обходе складывает вклад каждого ребра операнда. Накопленные градиенты изменяются только после того, как все значения прохода и все будущие сохранённые суммы признаны конечными.

Это разделение определяет поведение повторных вызовов. Первый вызов сохраняет lossˉ=1\bar{\mathrm{loss}}=1, squareˉ=2\bar{\mathrm{square}}=2 и xˉ=8\bar{x}=8. Второй заново вычисляет те же сопряжённые величины текущего прохода, а затем прибавляет их к сохранённым градиентам: получаются 22, 44 и 1616. Сохранённая после первого вызова величина squareˉ=2\bar{\mathrm{square}}=2 повторно назад не распространяется.

Сложите вклады всех повторных рёбер в новом проходе и только затем зафиксируйте накопленные градиенты rust/crates/llm-from-scratch/src/autograd/scalar.rs#scalar-reverse-pass
    /// Accumulates one fresh reverse pass seeded by one.
    pub fn backward(&self) -> Result<BackwardPass, ScalarAutodiffError> {
        self.backward_with_seed(1.0)
    }

    /// Accumulates one fresh reverse pass without reading stale intermediate grads.
    ///
    /// No stored gradient changes unless every contribution, pass adjoint, and
    /// prospective accumulated gradient is finite.
    pub fn backward_with_seed(&self, seed: f64) -> Result<BackwardPass, ScalarAutodiffError> {
        if !self.tracks_gradient() {
            return Err(ScalarAutodiffError::UntrackedOutput {
                operation: self.operation(),
            });
        }
        if !seed.is_finite() {
            return Err(ScalarAutodiffError::NonFiniteSeed { seed });
        }

        let topology = self.topology();
        let indices = topology
            .iter()
            .enumerate()
            .map(|(index, scalar)| (scalar.key(), index))
            .collect::<HashMap<_, _>>();
        let mut pass_adjoints = vec![0.0; topology.len()];
        pass_adjoints[topology.len() - 1] = seed;
        let mut edges = Vec::new();

        for child in (0..topology.len()).rev() {
            let upstream = pass_adjoints[child];
            let parents = topology[child].node.borrow().parents.clone();
            for (operand, edge) in parents.iter().enumerate() {
                let parent = indices[&edge.parent.key()];
                let contribution = upstream * edge.local_derivative;
                if !contribution.is_finite() {
                    return Err(ScalarAutodiffError::NonFiniteContribution {
                        child,
                        parent,
                        operand,
                        upstream,
                        local_derivative: edge.local_derivative,
                    });
                }

                let parent_tracked = edge.parent.tracks_gradient();
                let (before, after) = if parent_tracked {
                    let previous = pass_adjoints[parent];
                    let next = previous + contribution;
                    if !next.is_finite() {
                        return Err(ScalarAutodiffError::NonFinitePassAdjoint {
                            node: parent,
                            previous,
                            contribution,
                        });
                    }
                    pass_adjoints[parent] = next;
                    (Some(previous), Some(next))
                } else {
                    (None, None)
                };

                edges.push(BackwardEdge {
                    reverse_index: edges.len(),
                    child,
                    parent,
                    operand,
                    local_derivative: edge.local_derivative,
                    upstream,
                    contribution,
                    parent_tracked,
                    parent_adjoint_before: before,
                    parent_adjoint_after: after,
                });
            }
        }

        let prospective = topology
            .iter()
            .enumerate()
            .map(|(index, scalar)| {
                scalar.gradient().map(|stored| {
                    let pass_adjoint = pass_adjoints[index];
                    let accumulated = stored + pass_adjoint;
                    if !accumulated.is_finite() {
                        Err(ScalarAutodiffError::NonFiniteAccumulatedGradient {
                            node: index,
                            stored,
                            pass_adjoint,
                        })
                    } else {
                        Ok(accumulated)
                    }
                })
            })
            .map(|candidate| candidate.transpose())
            .collect::<Result<Vec<_>, _>>()?;

        for (scalar, &gradient) in topology.iter().zip(&prospective) {
            if let Some(gradient) = gradient {
                scalar.node.borrow_mut().gradient = Some(gradient);
            }
        }

        let nodes = topology
            .iter()
            .enumerate()
            .map(|(topology_index, scalar)| BackwardNode {
                topology_index,
                operation: scalar.operation(),
                value: scalar.value(),
                tracked: scalar.tracks_gradient(),
                pass_adjoint: scalar
                    .tracks_gradient()
                    .then_some(pass_adjoints[topology_index]),
                accumulated_gradient: prospective[topology_index],
            })
            .collect();

        Ok(BackwardPass { seed, nodes, edges })
    }

    /// Clears every reachable tracked node without changing the graph or values.
    pub fn zero_grad(&self) {
        for scalar in self.topology() {
            let mut node = scalar.node.borrow_mut();
            if node.gradient.is_some() {
                node.gradient = Some(0.0);
            }
        }
    }

Метод zero_grad очищает каждый достижимый отслеживаемый узел. После этого один новый обратный проход восстанавливает исходные градиенты первого прохода. Метод detach, напротив, создаёт неотслеживаемый постоянный лист с тем же значением прямого прохода и без рёбер к операндам. Для x2+3detach(x)x^2+3\operatorname{detach}(x) при x=2x=2 значение равно 1010, но к исходной переменной ведёт только ветвь x2x^2, поэтому её градиент равен 44.

Типизированные ошибки различают постоянный выход, недопустимое начальное значение, неконечный результат прямого прохода, локальный вклад, сопряжённую величину прохода и будущую сохранённую сумму. Поскольку перед фиксацией обратный проход проверяется целиком, неудачный вызов не меняет ни одного бита сохранённых градиентов.

Отклоняйте небезопасные значения графа и вклады обратного прохода без частичного изменения градиентов rust/crates/llm-from-scratch/src/autograd/scalar.rs#scalar-autodiff-errors
/// The operation that produced a scalar graph node.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScalarOperation {
    Variable,
    Constant,
    Detached,
    Add,
    Multiply,
    Negate,
    Subtract,
    Exp,
    Tanh,
}

impl ScalarOperation {
    /// A stable, locale-neutral name suitable for deterministic evidence.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Variable => "variable",
            Self::Constant => "constant",
            Self::Detached => "detached",
            Self::Add => "add",
            Self::Multiply => "mul",
            Self::Negate => "neg",
            Self::Subtract => "sub",
            Self::Exp => "exp",
            Self::Tanh => "tanh",
        }
    }
}

impl fmt::Display for ScalarOperation {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// A deterministic rejection from scalar graph construction or backpropagation.
#[derive(Clone, Debug, PartialEq)]
pub enum ScalarAutodiffError {
    NonFiniteLeaf {
        operation: ScalarOperation,
        value: f64,
    },
    NonFiniteResult {
        operation: ScalarOperation,
        value: f64,
    },
    UntrackedOutput {
        operation: ScalarOperation,
    },
    NonFiniteSeed {
        seed: f64,
    },
    NonFiniteContribution {
        child: usize,
        parent: usize,
        operand: usize,
        upstream: f64,
        local_derivative: f64,
    },
    NonFinitePassAdjoint {
        node: usize,
        previous: f64,
        contribution: f64,
    },
    NonFiniteAccumulatedGradient {
        node: usize,
        stored: f64,
        pass_adjoint: f64,
    },
}

impl fmt::Display for ScalarAutodiffError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NonFiniteLeaf { operation, value } => {
                write!(
                    formatter,
                    "{operation} scalar value {value:?} must be finite"
                )
            }
            Self::NonFiniteResult { operation, value } => {
                write!(
                    formatter,
                    "{operation} produced non-finite scalar value {value:?}"
                )
            }
            Self::UntrackedOutput { operation } => write!(
                formatter,
                "cannot backpropagate from untracked {operation} output"
            ),
            Self::NonFiniteSeed { seed } => {
                write!(formatter, "backward seed {seed:?} must be finite")
            }
            Self::NonFiniteContribution {
                child,
                parent,
                operand,
                upstream,
                local_derivative,
            } => write!(
                formatter,
                "edge {operand} from topology node {child} to {parent} produced a non-finite contribution from upstream {upstream:?} and local derivative {local_derivative:?}"
            ),
            Self::NonFinitePassAdjoint {
                node,
                previous,
                contribution,
            } => write!(
                formatter,
                "topology node {node} cannot accumulate pass adjoint {previous:?} plus contribution {contribution:?}"
            ),
            Self::NonFiniteAccumulatedGradient {
                node,
                stored,
                pass_adjoint,
            } => write!(
                formatter,
                "topology node {node} cannot accumulate stored gradient {stored:?} plus pass adjoint {pass_adjoint:?}"
            ),
        }
    }
}

impl Error for ScalarAutodiffError {}

Глава 13 предоставляет выборочную численную сверку, реализованную отдельным путём. Она вычисляет ту же математическую функцию и поэтому всё ещё использует общее описание функции, входное значение и арифметику IEEE f64. Совпадение в выбранной гладкой точке служит свидетельством, но не доказывает правильность всего обратного графа. Прямое выражение этого графа равно 2x22x^2, поэтому аналитическая производная равна 4x4x, то есть 88 при x=2x=2. Проверка центральной разностью возмущает только обычную скалярную функцию; она не изучает и не использует обратный граф.

Сопоставьте обратный режим с выборочной сверкой центральными разностями и проверьте отсоединение rust/demos/ch14-scalar-autodiff/src/lib.rs#nonlinear-detach-gradcheck
/// Keeps the live `x*x` path but stops `detach(x)*3` from reaching `x`.
pub fn detach_example() -> Result<DetachExample, ScalarAutodiffError> {
    let x = Scalar::variable(REUSED_INPUT)?;
    let square = x.mul(&x)?;
    let detached = x.detach();
    let three = Scalar::constant(3.0)?;
    let stopped = detached.mul(&three)?;
    let loss = square.add(&stopped)?;
    loss.backward()?;

    Ok(DetachExample {
        input: x.value(),
        value: loss.value(),
        x_gradient: x.gradient().expect("x is tracked"),
        detached_gradient: detached.gradient(),
    })
}

/// Differentiates a two-operation elementary-function chain.
pub fn nonlinear_example() -> Result<NonlinearExample, ScalarAutodiffError> {
    let x = Scalar::variable(0.5)?;
    let output = x.tanh()?.exp()?;
    output.backward()?;
    Ok(NonlinearExample {
        input: x.value(),
        value: output.value(),
        gradient: x.gradient().expect("x is tracked"),
    })
}

/// Checks the reverse derivative of `2x^2` with Chapter 13 central differences.
pub fn gradcheck_example() -> Result<ScalarGradientCheck, Box<dyn Error>> {
    let x = Scalar::variable(REUSED_INPUT)?;
    let square = x.mul(&x)?;
    let loss = square.add(&square)?;
    loss.backward()?;
    Ok(scalar_gradient_check(
        REUSED_INPUT,
        x.gradient().expect("x is tracked"),
        GRADCHECK_STEP,
        GRADCHECK_TOLERANCE,
        |value| 2.0 * value * value,
    )?)
}

Пример выводит общий граф, два успешных прохода, обнуление, одно новое восстановление, отсоединение и численное совпадение:

Подготовьте воспроизводимые результаты скалярного автоматического дифференцирования перед выводом rust/demos/ch14-scalar-autodiff/src/main.rs#learner-scalar-autodiff-output
    let reused = reused_square_example()?;
    let detached = detach_example()?;
    let nonlinear = nonlinear_example()?;
    let gradcheck = gradcheck_example()?;
    let errors = typed_error_example()?;
./course run cargo run --quiet --locked -p ch14-scalar-autodiff
reused square: x=2.000000000000 square=4.000000000000 loss=8.000000000000
one backward: x_grad=8.000000000000 square_grad=2.000000000000 loss_grad=1.000000000000
repeated backward: x_grad=16.000000000000 square_grad=4.000000000000 loss_grad=2.000000000000
zero_grad: x_grad=0.000000000000 square_grad=0.000000000000 loss_grad=0.000000000000
after zero: x_grad=8.000000000000 square_grad=2.000000000000 loss_grad=1.000000000000
detach: expression=x*x+detach(x)*3 value=10.000000000000 x_grad=4.000000000000 detached_grad=none
nonlinear: expression=exp(tanh(x)) input=0.500000000000 value=1.587431271430 gradient=1.248431724655
gradcheck: expression=2*x*x analytic=8.000000000000 numerical=8.000000000000 scaled_error=0.000000000000e0 pass=true
typed errors: constant-output | non-finite-seed | non-finite-accumulated-gradient; gradients unchanged=true
chapter 15 handoff: replace scalar edges with tensor vector-Jacobian products

Проследите назад каждое ребро операнда

На первой схеме одновременно видны правила прямого и обратного проходов. Значения не копируются в семь карточек: каждый из трёх узлов показан один раз. При этом в таблице обратного прохода остаются четыре отдельно подписанных ребра операндов. В каждой строке указаны порядок обхода, вхождение операнда, входящая сопряжённая величина, локальная производная и вклад. Состояния узлов и проходов показывают суммы явно, поэтому два пути нельзя случайно слить в один.

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

Проследите каждое повторное ребро операнда до исходного скаляра

Сопоставьте три уникальных узла прямого прохода, четыре повторных ребра операндов и каждый упорядоченный вклад одного нового обратного прохода.

Скалярная функция потерь
8.000000000000
Уникальных узлов графа
3
Рёбер операндов
4

Постройте один граф с общими узлами

Идентичность узла устраняет повторные посещения в топологическом порядке, но не рёбра операндов. И x · x, и square + square сохраняют два упорядоченных вхождения.

  1. x

    Порядок прямого прохода
    0
    Операция
    отслеживаемая переменная
    Значение прямого прохода
    2.000000000000
    Сопряжённая величина этого прохода
    8.000000000000
  2. square

    Порядок прямого прохода
    1
    Операция
    умножение
    Значение прямого прохода
    4.000000000000
    Сопряжённая величина этого прохода
    2.000000000000
    • Вхождение операнда 0 x
    • Вхождение операнда 1 x
  3. loss

    Порядок прямого прохода
    2
    Операция
    сложение
    Значение прямого прохода
    8.000000000000
    Сопряжённая величина этого прохода
    1.000000000000
    • Вхождение операнда 0 square
    • Вхождение операнда 1 square

Накопите один новый обратный проход

Начальная единица от выхода дважды достигает square. Узел square ждёт оба вклада, а затем передаёт накопленную сопряжённую величину через оба операнда умножения.

Порядок обратного прохода Результат-потребитель Вхождение операнда Значение операнда Локальная производная Входящая сопряжённая величина Вклад ребра
0 loss 0 square 1.000000000000 1.000000000000 1.000000000000
1 loss 1 square 1.000000000000 1.000000000000 1.000000000000
2 square 0 x 2.000000000000 2.000000000000 4.000000000000
3 square 1 x 2.000000000000 2.000000000000 4.000000000000

Отделите новый обратный проход от состояния накопленных градиентов

Сопоставьте повторные проходы, обнуление, отсоединение, численное совпадение и отклонённые запросы, не смешивая сопряжённые величины текущего прохода с накопленными градиентами.

Зафиксируйте, повторите, обнулите и восстановите

Каждый вызов сначала вычисляет новые сопряжённые величины текущего прохода, а затем целиком добавляет их в хранилище. Промежуточные значения предыдущего вызова повторно не распространяются.

первый проход зафиксирован

x
8.000000000000
square
2.000000000000
loss
1.000000000000

второй проход накоплен

x
16.000000000000
square
4.000000000000
loss
2.000000000000

накопленные градиенты обнулены

x
0.000000000000
square
0.000000000000
loss
0.000000000000

один новый проход восстановлен

x
8.000000000000
square
2.000000000000
loss
1.000000000000

Проверьте отсоединение и выборочную численную сверку

Отсоединение сохраняет значение прямого прохода, но удаляет ребро к исходному узлу. Глава 13 вычисляет ту же математическую функцию отдельным выборочным путём центральных разностей. Поскольку оба пути используют одно и то же описание функции, входное значение и арифметику f64, совпадение служит свидетельством, а не доказательством.

отсоединённая ветвь остановлена

Выражение
x*x+detach(x)*3
Значение прямого прохода
10.000000000000
Накопленный градиент
4.000000000000

нелинейная цепочка продифференцирована

Выражение
exp(tanh(x))
Значение прямого прохода
1.587431271430
Накопленный градиент
1.248431724655

численная проверка пройдена

Аналитический градиент
8.000000000000
Численный градиент
8.000000000000
Масштабированная ошибка
0.000000000000e0
Допуск
1.000000000000e-9

Отклоните небезопасные градиенты до изменения графа

Перед фиксацией проверяются каждый вклад и каждая будущая накопленная сумма, поэтому неудачный обратный проход не меняет ни одного бита сохранённых градиентов.

у постоянного выхода нет отслеживаемого пути градиента

Операция
constant

начальное значение обратного прохода не является конечным

Начальная сопряжённая величина
inf

будущий накопленный градиент не является конечным

Узел графа
0

Сначала предскажите, затем запустите Rust

  1. Предскажите square и loss, прежде чем смотреть на результат работы графа.
  2. Отдельно посчитайте уникальные узлы и рёбра операндов. Почему числа различаются?
  3. Вычислите оба вклада в squareˉ\bar{\mathrm{square}} и оба вклада в xˉ\bar{x}.
  4. Предскажите градиенты, если каждый результат будет присваивать, а не складывать вклады своих операндов.
  5. Предскажите накопленные градиенты после двух обратных проходов, после обнуления и после ещё одного прохода.
  6. Предскажите значение и градиент исходного xx для x2+3detach(x)x^2+3\operatorname{detach}(x) при x=2x=2.
  7. Объясните, почему ошибка при проверке последней накопленной суммы не должна фиксировать уже проверенные узлы.
  8. Проверка заблуждения: обратный режим приближает производные, выбирает одну ветвь или выполняется при обычном инференсе декодера?
Проверить восемь предсказаний о скалярном автоматическом дифференцировании
  1. square=22=4\mathrm{square}=2\cdot2=4; loss=4+4=8\mathrm{loss}=4+4=8.
  2. Есть три уникальных узла и четыре ребра к операндам. Результат каждой операции посещается один раз, но каждое повторное вхождение операнда остаётся отдельным путём производной.
  3. Сложение даёт 1+11+1, поэтому squareˉ=2\bar{\mathrm{square}}=2. Умножение даёт 22+222\cdot2+2\cdot2, поэтому xˉ=8\bar{x}=8.
  4. Присваивание сохранит один из двух одинаковых вкладов сложения, поэтому squareˉ=1\bar{\mathrm{square}}=1, а затем один из двух одинаковых вкладов умножения, поэтому xˉ=2\bar{x}=2. В этом симметричном графе порядок рёбер всегда даёт одинаковые неверные значения. В несимметричном графе от порядка зависело бы ещё и то, какой вклад сохранится.
  5. Накопленные (lossˉ,squareˉ,xˉ)(\bar{\mathrm{loss}},\bar{\mathrm{square}},\bar{x}) последовательно равны (1,2,8)(1,2,8), (2,4,16)(2,4,16), (0,0,0)(0,0,0) и (1,2,8)(1,2,8). Каждый вызов сначала вычисляет новый проход, а затем добавляет его к хранилищу.
  6. Значение равно 4+6=104+6=10. У отсоединённой ветви нет ребра к исходному xx, поэтому градиент равен только 2x=42x=4.
  7. При частичном изменении результат неудачного прохода зависел бы от места ошибки в обходе. Предварительная проверка всех значений прохода и будущих накопленных сумм сохраняет прежние биты при ошибке.
  8. Ни одно из утверждений. Обратный режим применяет точные правила локальных производных к сохранённым значениям прямого прохода и складывает все пути графа. Этот граф нужен для обучения; при обычной генерации работает только прямой проход декодера.

После собственных расчётов запустите пример:

./course run cargo run --quiet --locked -p ch14-scalar-autodiff

Подготовьте обратный режим для тензоров

Теперь проект умеет записывать зависимости между скалярами, по одному разу обходить каждый достижимый узел в обратном топологическом порядке, складывать вклад каждого ребра операнда, безопасно накапливать завершённые новые проходы, обнулять градиенты, отсоединять значение от графа и численно проверять аналитические результаты. В главе 15 скалярные узлы будут заменены VJP тензорных операций изменения формы, транспонирования, согласования форм и редукций с сохранением этих правил обратного накопления.

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