← Все главы

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

Проверяйте градиенты, прежде чем доверять обратному распространению

Сверяйте выбранные производные для обучения LLM по фактически представимым точкам: учитывайте требование локальной гладкости и погрешность с учётом масштаба, а координаты тензора выбирайте детерминированно в Rust.

Предскажите одну производную квадратичной функции

Начнём с небольшой скалярной проверки градиента:

q(θ)=θ2,θ=3,h=0.1q(\theta)=\theta^2,\qquad \theta=3,\qquad h=0.1

Запрошенный шаг задаёт две точки, которые в ручном расчёте записаны как 2.92.9 и 3.13.1. Однако в f64 хранятся соседние представимые значения θ=fl(30.1)\theta_- = \operatorname{fl}(3-0.1) и θ+=fl(3+0.1)\theta_+ = \operatorname{fl}(3+0.1). Поэтому реализация вычисляет фактические расстояния h=3θh_-=3-\theta_- и h+=θ+3h_+=\theta_+-3, а не считает каждое из них равным запрошенному 0.10.1. При обычном округлении для ручного расчёта получаем q(2.9)=8.41q(2.9)=8.41 и q(3.1)=9.61q(3.1)=9.61, а трёхточечная формула даёт численную оценку производной 66.

Этот результат проверяет аналитическое значение, а не создаёт его. Производная θ2\theta^2, выведенная вручную, даёт значение 66, которое должно уложиться в допуск. Значение 5.55.5 тоже является вполне конечным числом, но сравнение с ним должно завершиться с pass=false. Математическое несовпадение отличается от недопустимого шага или неконечного значения целевой функции.

Задать квадратичный пример и оба аналитических значения rust/demos/ch13-gradient-checking/src/lib.rs#quadratic-gradient-prediction
pub fn quadratic(point: f64) -> f64 {
    point * point
}

/// Checks either the expected derivative `6` or a deliberately wrong candidate.
pub fn quadratic_gradient_check(analytic: f64) -> Result<ScalarGradientCheck, GradCheckError> {
    scalar_gradient_check(3.0, analytic, 0.1, STEP_TOLERANCE, quadratic)
}

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

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

θ=fl(θh),θ+=fl(θ+h).\theta_- = \operatorname{fl}(\theta-h),\qquad \theta_+ = \operatorname{fl}(\theta+h).

Обозначим положительные фактические расстояния от проверяемой точки через

h=θθ,h+=θ+θ.h_- = \theta-\theta_-,\qquad h_+ = \theta_+-\theta.

Трёхточечная формула для неравных расстояний объединяет два односторонних наклона:

f(θ)h+h+h+f(θ)f(θ)h+hh+h+f(θ+)f(θ)h+f'(\theta)\approx\frac{h_+}{h_-+h_+}\frac{f(\theta)-f(\theta_-)}{h_-}+\frac{h_-}{h_-+h_+}\frac{f(\theta_+)-f(\theta)}{h_+}

Только при равных фактических расстояниях h=h+=h^h_-=h_+=\widehat h вклад значения в центре сокращается, и формула принимает вид

f(θ)f(θ+)f(θ)2h^.f'(\theta)\approx\frac{f(\theta_+)-f(\theta_-)}{2\widehat h}.

В знаменатель входит фактическое расстояние h^\widehat h, а не просто запрошенное hh.

Реализация вычисляет веса, не складывая сначала два потенциально огромных расстояния. При m=max(h,h+)m=\max(h_-,h_+) она находит u=h/mu_-=h_-/m и u+=h+/mu_+=h_+/m, а затем вес левого наклона u+/(u+u+)u_+/(u_-+u_+) и правого — u/(u+u+)u_-/(u_-+u_+). Эти отношения сохраняют коэффициенты формулы для неравных расстояний и не допускают переполнения суммы h+h+h_-+h_+.

Если функция достаточно гладкая в окрестности точки — например, её третья производная непрерывна на всём отрезке от θ\theta_- до θ+\theta_+, — погрешность усечения трёхточечной интерполяции имеет порядок O(max(h,h+)2)O(\max(h_-,h_+)^2). Уменьшение запрошенного шага помогает лишь до тех пор, пока главной причиной ошибки не станут округление точек вычисления и потеря точности при вычитании почти равных значений функции.

Линейная функция f(x)=xf(x)=x позволяет обнаружить ошибку в работе с округлением. Выберите конечную точку и шаг, для которых две операции с плавающей запятой дают разные ненулевые расстояния. Оба односторонних наклона в формуле по-прежнему равны 11, поэтому итоговая оценка должна совпасть с 11 в пределах допуска. Деление на запрошенное 2h2h может не пройти такую проверку: этот знаменатель не описывает фактические точки.

Локальная гладкость — предварительное условие, которое нельзя доказать одним совпадением. Для f(x)=xf(x)=|x| при x=0x=0 симметричные точки дают численную оценку 00, и аналитическое значение 00 может пройти проверку, хотя в точке излома производная не существует. Это ложное успешное сравнение за пределами области применимости метода; процедура не умеет безошибочно находить все изломы и другие негладкие точки.

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

Реализация требует конечного θ\theta, положительного конечного hh, конечного диапазона запрошенного шага, двух конечных точек, строго расположенных по разные стороны от θ\theta, положительных конечных фактических расстояний и конечных значений функции во всех трёх точках. Если после округления одна из точек совпала с исходной, запрос отклоняется, а не выдаётся нулевая производная.

Вычислить фактические расстояния и применить формулу для неравных расстояний rust/crates/llm-from-scratch/src/autograd/gradcheck.rs#central-difference
/// Approximates one derivative from the actual representable probe spacings.
///
/// The callback is evaluated exactly three times, in minus, center, plus order.
/// A meaningful gradient check requires a deterministic, side-effect-free
/// objective that is differentiable and sufficiently smooth throughout the
/// closed probe interval. Known or possible kinks must be excluded or examined
/// with [`compare_one_sided_slopes`]; a passing comparison is evidence for the
/// sampled smooth point, not a universal proof of a derivative.
pub fn central_difference(
    point: f64,
    step: f64,
    mut evaluate: impl FnMut(f64) -> f64,
) -> Result<CentralDifference, GradCheckError> {
    let geometry = perturbations(point, step)?;

    let minus_value = evaluate(geometry.minus_point);
    if !minus_value.is_finite() {
        return Err(GradCheckError::NonFiniteEvaluation {
            side: DifferenceSide::Minus,
            value: minus_value,
        });
    }

    let center_value = evaluate(point);
    if !center_value.is_finite() {
        return Err(GradCheckError::NonFiniteEvaluation {
            side: DifferenceSide::Center,
            value: center_value,
        });
    }

    let plus_value = evaluate(geometry.plus_point);
    if !plus_value.is_finite() {
        return Err(GradCheckError::NonFiniteEvaluation {
            side: DifferenceSide::Plus,
            value: plus_value,
        });
    }

    let left_slope = (center_value - minus_value) / geometry.minus_spacing;
    if !left_slope.is_finite() {
        return Err(GradCheckError::NonFiniteOneSidedSlope {
            side: DifferenceSide::Minus,
            value: left_slope,
        });
    }
    let right_slope = (plus_value - center_value) / geometry.plus_spacing;
    if !right_slope.is_finite() {
        return Err(GradCheckError::NonFiniteOneSidedSlope {
            side: DifferenceSide::Plus,
            value: right_slope,
        });
    }

    let derivative = geometry.left_weight * left_slope + geometry.right_weight * right_slope;
    if !derivative.is_finite() {
        return Err(GradCheckError::NonFiniteNumericalGradient { value: derivative });
    }

    Ok(CentralDifference {
        point,
        requested_step: step,
        minus_point: geometry.minus_point,
        plus_point: geometry.plus_point,
        minus_spacing: geometry.minus_spacing,
        plus_spacing: geometry.plus_spacing,
        minus_value,
        center_value,
        plus_value,
        left_slope,
        right_slope,
        left_weight: geometry.left_weight,
        right_weight: geometry.right_weight,
        stencil: geometry.stencil,
        derivative,
    })
}

Назовите величины, используемые при проверке производной

СимволПрактический смысл
ffДетерминированная скалярная функция потерь, производная которой проверяется.
θ\thetaКонечный скалярный параметр или выбранная координата тензора.
hhПоложительная конечная запрошенная величина шага, по которой строятся обе точки вычисления в формате с плавающей запятой.
θ,θ+\theta_-,\theta_+Фактически представимые точки вычисления fl(θh)\operatorname{fl}(\theta-h) и fl(θ+h)\operatorname{fl}(\theta+h).
h,h+h_-,h_+Фактические положительные расстояния θθ\theta-\theta_- и θ+θ\theta_+-\theta слева и справа.
f(θ)f'(\theta)Производная в локально гладкой точке, приближённая трёхточечной формулой для неравных расстояний.

Для аналитического значения aa и численной оценки nn процедура выбирает s=max(1,a,n)s=\max(1,|a|,|n|) и записывает e=a/sn/se=|a/s-n/s|. При малых градиентах нижняя граница масштаба 11 делает погрешность абсолютной; при больших она становится относительной. Аналитическое значение укладывается в допуск тогда и только тогда, когда нормированная погрешность не превышает заданный конечный неотрицательный допуск.

Нормировать расхождение конечных производных по большей величине или единице rust/crates/llm-from-scratch/src/autograd/gradcheck.rs#scale-aware-comparison
/// Compares gradients after scaling both by the larger magnitude or one.
pub fn compare_gradients(
    analytic: f64,
    numerical: f64,
    tolerance: f64,
) -> Result<GradientComparison, GradCheckError> {
    validate_tolerance(tolerance)?;
    if !analytic.is_finite() {
        return Err(GradCheckError::NonFiniteAnalyticGradient { value: analytic });
    }
    if !numerical.is_finite() {
        return Err(GradCheckError::NonFiniteNumericalGradient { value: numerical });
    }

    let scale = 1.0_f64.max(analytic.abs()).max(numerical.abs());
    let absolute_error = (analytic - numerical).abs();
    let scaled_error = (analytic / scale - numerical / scale).abs();

    Ok(GradientComparison {
        analytic,
        numerical,
        absolute_error,
        scale,
        scaled_error,
        tolerance,
        passed: scaled_error <= tolerance,
    })
}

/// Compares the recorded left and right secant slopes without claiming proof.
///
/// A failed diagnostic can indicate a nondifferentiable kink, a step that is
/// too large for the local curvature, or floating-point rounding. A passing
/// diagnostic cannot establish differentiability on its own.
pub fn compare_one_sided_slopes(
    difference: &CentralDifference,
    tolerance: f64,
) -> Result<OneSidedSlopeComparison, GradCheckError> {
    validate_tolerance(tolerance)?;
    let left = difference.left_slope;
    if !left.is_finite() {
        return Err(GradCheckError::NonFiniteOneSidedSlope {
            side: DifferenceSide::Minus,
            value: left,
        });
    }
    let right = difference.right_slope;
    if !right.is_finite() {
        return Err(GradCheckError::NonFiniteOneSidedSlope {
            side: DifferenceSide::Plus,
            value: right,
        });
    }
    let scale = 1.0_f64.max(left.abs()).max(right.abs());
    let absolute_gap = (left - right).abs();
    let scaled_gap = (left / scale - right / scale).abs();
    Ok(OneSidedSlopeComparison {
        left,
        right,
        absolute_gap,
        scale,
        scaled_gap,
        tolerance,
        consistent: scaled_gap <= tolerance,
    })
}

/// Runs a central difference and compares it with one analytic candidate.
///
/// The objective has the same smoothness, determinism, and side-effect
/// requirements as [`central_difference`].
pub fn scalar_gradient_check(
    point: f64,
    analytic: f64,
    step: f64,
    tolerance: f64,
    evaluate: impl FnMut(f64) -> f64,
) -> Result<ScalarGradientCheck, GradCheckError> {
    validate_tolerance(tolerance)?;
    let difference = central_difference(point, step, evaluate)?;
    let comparison = compare_gradients(analytic, difference.derivative, tolerance)?;
    Ok(ScalarGradientCheck {
        difference,
        comparison,
    })
}

От обратного распространения для следующего слова к проверке производных при обучении Transformer

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

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

Transformer с повторяющимися слоями внимания и полносвязными блоками обучают градиентным методом, выполняя 100 000 шагов Adam для базовой модели и 300 000 для большой. Байдин и соавторы различают численное дифференцирование и автоматическое дифференцирование в обратном режиме: конечные разности оценивают одну локальную производную по нескольким вычислениям функции, а обратный режим эффективно получает градиент скалярной цели по множеству параметров. Здесь отдельный путь численного вычисления помогает обнаружить локальную ошибку в проверяемой производной.

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

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

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

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

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

Реализуйте выборочную численную сверку

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

Различать ошибки шагов, точек, значений, форм, координат и представлений rust/crates/llm-from-scratch/src/autograd/gradcheck.rs#gradcheck-errors
/// A rejected numerical derivative, comparison, sample request, or tensor read.
#[derive(Clone, Debug, PartialEq)]
pub enum GradCheckError {
    InvalidStep {
        step: f64,
    },
    NonFinitePoint {
        point: f64,
    },
    PerturbationNotFinite {
        side: DifferenceSide,
        point: f64,
        step: f64,
    },
    PerturbationUnchanged {
        side: DifferenceSide,
        point: f64,
        step: f64,
    },
    InvalidActualSpacing {
        side: DifferenceSide,
        point: f64,
        probe: f64,
        spacing: f64,
    },
    NonFiniteStencilWeight {
        side: DifferenceSide,
        value: f64,
    },
    NonFiniteEvaluation {
        side: DifferenceSide,
        value: f64,
    },
    NonFiniteOneSidedSlope {
        side: DifferenceSide,
        value: f64,
    },
    NonFiniteNumericalGradient {
        value: f64,
    },
    InvalidTolerance {
        tolerance: f64,
    },
    NonFiniteAnalyticGradient {
        value: f64,
    },
    GradientShapeMismatch {
        parameters: Vec<usize>,
        analytic: Vec<usize>,
    },
    ShapeOverflow,
    EmptyTensor,
    ZeroSamples,
    SampleAllocationFailed {
        samples: usize,
        rank: usize,
    },
    View(TensorViewError),
    AtCoordinate {
        coordinate: Vec<usize>,
        source: Box<GradCheckError>,
    },
}

impl fmt::Display for GradCheckError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidStep { step } => write!(
                formatter,
                "central-difference requested step {step:?} must be positive and finite"
            ),
            Self::NonFinitePoint { point } => {
                write!(formatter, "gradient-check point {point:?} must be finite")
            }
            Self::PerturbationNotFinite { side, point, step } => write!(
                formatter,
                "{side} perturbation from point {point:?} by step {step:?} is not finite"
            ),
            Self::PerturbationUnchanged { side, point, step } => write!(
                formatter,
                "{side} perturbation from point {point:?} by step {step:?} rounds back to the point"
            ),
            Self::InvalidActualSpacing {
                side,
                point,
                probe,
                spacing,
            } => write!(
                formatter,
                "{side} actual spacing from point {point:?} to probe {probe:?} must be positive and finite, got {spacing:?}"
            ),
            Self::NonFiniteStencilWeight { side, value } => {
                write!(formatter, "{side} stencil weight is not finite: {value:?}")
            }
            Self::NonFiniteEvaluation { side, value } => {
                write!(formatter, "{side} function evaluation returned {value:?}")
            }
            Self::NonFiniteOneSidedSlope { side, value } => {
                write!(formatter, "{side} one-sided slope is not finite: {value:?}")
            }
            Self::NonFiniteNumericalGradient { value } => {
                write!(
                    formatter,
                    "central difference produced non-finite gradient {value:?}"
                )
            }
            Self::InvalidTolerance { tolerance } => write!(
                formatter,
                "gradient-check tolerance {tolerance:?} must be finite and nonnegative"
            ),
            Self::NonFiniteAnalyticGradient { value } => {
                write!(formatter, "analytic gradient {value:?} must be finite")
            }
            Self::GradientShapeMismatch {
                parameters,
                analytic,
            } => write!(
                formatter,
                "parameter shape {parameters:?} does not match analytic-gradient shape {analytic:?}"
            ),
            Self::ShapeOverflow => formatter.write_str("sampled tensor shape overflows usize"),
            Self::EmptyTensor => {
                formatter.write_str("sampled tensor gradient check needs at least one value")
            }
            Self::ZeroSamples => {
                formatter.write_str("sampled tensor gradient check needs at least one sample")
            }
            Self::SampleAllocationFailed { samples, rank } => write!(
                formatter,
                "cannot allocate {samples} sampled coordinates of rank {rank}"
            ),
            Self::View(error) => error.fmt(formatter),
            Self::AtCoordinate { coordinate, source } => {
                write!(
                    formatter,
                    "gradient check at coordinate {coordinate:?} failed: {source}"
                )
            }
        }
    }
}

impl Error for GradCheckError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::View(error) => Some(error),
            Self::AtCoordinate { source, .. } => Some(source.as_ref()),
            _ => None,
        }
    }
}

impl From<TensorViewError> for GradCheckError {
    fn from(error: TensorViewError) -> Self {
        Self::View(error)
    }
}

Пусть NN — число элементов непустого тензора, RR — максимальное запрошенное число координат, а S=min(R,N)S=\min(R,N) — фактическое число выбранных координат. Аргумент Rust max_samples задаёт RR. Нулевой запрос и пустой тензор отклоняются, поэтому S1S\geq1.

При S>1S>1 алгоритм вычисляет плоское смещение k(N1)/(S1)\left\lfloor k(N-1)/(S-1)\right\rfloor для каждого целого k{0,1,,S1}k\in\{0,1,\ldots,S-1\}. Для формы [2,3] имеем N=6N=6; при R=4R=4 получаем S=4S=4, а k=0,1,2,3k=0,1,2,3 даёт смещения [0,1,3,5]. Им соответствуют координаты [[0,0],[0,1],[1,0],[1,2]]. Генератор случайных чисел и скрытое состояние не нужны. При S=1S=1 знаменатель формулы был бы равен нулю, поэтому отдельная ветвь выбирает N/2\lfloor N/2\rfloor; для чётного NN это большее из двух центральных плоских смещений. Общий алгоритм использует промежуточное значение без риска переполнения и те же произведения последующих размерностей, что и накопительное тензорное хранилище.

Выбрать уникальные упорядоченные координаты тензора без случайности rust/crates/llm-from-scratch/src/autograd/gradcheck.rs#sample-tensor-coordinates
/// Selects unique, ordered coordinates without randomness or hidden state.
pub fn sample_tensor_coordinates(
    shape: &[usize],
    max_samples: usize,
) -> Result<Vec<SampledCoordinate>, GradCheckError> {
    if max_samples == 0 {
        return Err(GradCheckError::ZeroSamples);
    }
    let elements = element_count(shape)?;
    if elements == 0 {
        return Err(GradCheckError::EmptyTensor);
    }
    let samples = max_samples.min(elements);
    let mut coordinates = Vec::new();
    coordinates
        .try_reserve_exact(samples)
        .map_err(|_| GradCheckError::SampleAllocationFailed {
            samples,
            rank: shape.len(),
        })?;

    for sample in 0..samples {
        let flat_index = if samples == 1 {
            elements / 2
        } else {
            let numerator = (sample as u128) * ((elements - 1) as u128);
            (numerator / ((samples - 1) as u128)) as usize
        };
        coordinates.push(SampledCoordinate {
            flat_index,
            coordinate: coordinate_from_offset(shape, flat_index)?,
        });
    }
    Ok(coordinates)
}

Для каждой выбранной координаты процедура вычисляет одну и ту же целевую функцию сначала в фактической точке слева θ\theta_-, затем при исходном значении θ\theta и после этого в фактической точке справа θ+\theta_+. После каждого временного изменения она сразу восстанавливает точные исходные биты — до анализа результата и до любого обычного возврата с ошибкой. Если переданная целевая функция паникует, ограниченная гарантия восстановления из этого учебного примера не действует.

Проверить выбранные координаты тензора и восстановить каждое временное изменение rust/crates/llm-from-scratch/src/autograd/gradcheck.rs#sampled-tensor-gradient-check
/// Checks deterministic tensor coordinates while restoring every probed value.
///
/// The parameter tensor is restored on every ordinary `Ok` or `Err` path. A
/// panic inside `objective` is deliberately outside this reference guarantee.
/// The objective must be deterministic, side-effect-free, and differentiable
/// with sufficient smoothness across every sampled probe interval; known or
/// possible kinks are outside this comparison's contract.
pub fn sampled_tensor_gradient_check(
    parameters: &mut Tensor,
    analytic: &TensorView<'_>,
    step: f64,
    tolerance: f64,
    max_samples: usize,
    mut objective: impl FnMut(&Tensor) -> f64,
) -> Result<TensorGradientCheck, GradCheckError> {
    validate_step(step)?;
    validate_tolerance(tolerance)?;
    if parameters.shape() != analytic.shape() {
        return Err(GradCheckError::GradientShapeMismatch {
            parameters: parameters.shape().to_vec(),
            analytic: analytic.shape().to_vec(),
        });
    }

    let samples = sample_tensor_coordinates(parameters.shape(), max_samples)?;
    let mut candidates = Vec::new();
    candidates.try_reserve_exact(samples.len()).map_err(|_| {
        GradCheckError::SampleAllocationFailed {
            samples: samples.len(),
            rank: parameters.rank(),
        }
    })?;

    // Validate every selected coordinate before the first objective call.
    for sample in &samples {
        let point = parameters.as_slice()[sample.flat_index];
        perturbations(point, step).map_err(|error| at_coordinate(&sample.coordinate, error))?;
        let analytic_value = *analytic
            .get(&sample.coordinate)
            .map_err(GradCheckError::View)?;
        if !analytic_value.is_finite() {
            return Err(at_coordinate(
                &sample.coordinate,
                GradCheckError::NonFiniteAnalyticGradient {
                    value: analytic_value,
                },
            ));
        }
        candidates.push((sample.clone(), point, analytic_value));
    }

    let mut checks = Vec::new();
    checks.try_reserve_exact(candidates.len()).map_err(|_| {
        GradCheckError::SampleAllocationFailed {
            samples: candidates.len(),
            rank: parameters.rank(),
        }
    })?;

    for (sample, point, analytic_value) in candidates {
        let difference = central_difference(point, step, |probe| {
            parameters.as_mut_slice()[sample.flat_index] = probe;
            let value = objective(parameters);
            parameters.as_mut_slice()[sample.flat_index] = point;
            value
        })
        .map_err(|error| at_coordinate(&sample.coordinate, error))?;
        let comparison = compare_gradients(analytic_value, difference.derivative, tolerance)
            .map_err(|error| at_coordinate(&sample.coordinate, error))?;
        checks.push(CoordinateGradientCheck {
            flat_index: sample.flat_index,
            coordinate: sample.coordinate,
            difference,
            comparison,
        });
    }

    Ok(TensorGradientCheck {
        shape: parameters.shape().to_vec(),
        requested_samples: max_samples,
        passed: checks.iter().all(|check| check.comparison.passed),
        checks,
    })
}

В численной целевой функции используется среднее NLL по индексам из главы 12. Логиты формы [2,3] равны [0,1,-1,2,0,-2], цели — [0,2], а прямое вычисление средней функции потерь даёт 2.775268796472. Отдельная аналитическая реализация начинает с исходных логитов каждой строки. Для строки rr она вычисляет

mr=maxjr,j,pr,c=exp(r,cmr)jexp(r,jmr).m_r=\max_j \ell_{r,j},\qquad p_{r,c}=\frac{\exp(\ell_{r,c}-m_r)}{\sum_j\exp(\ell_{r,j}-m_r)}.

Для класса словаря cc и цели trt_r вручную выведенная производная имеет вид

r,c=pr,c𝟏[c=tr]2.\frac{\partial\mathcal L}{\partial \ell_{r,c}} =\frac{p_{r,c}-\mathbf 1[c=t_r]}{2}.

В позиции целевого логита из вычисленной здесь вероятности вычитается единица, после чего градиент каждой строки делится на число строк с целями, равное двум. Этот аналитический путь не вызывает основные функции softmax или indexed_mean_nll, а численная целевая функция вызывает indexed_mean_nll. Такое разделение существенных вычислений помогает обнаружить ошибку, которая не повторена в обоих путях, но не делает их полностью независимыми: у них общие исходные логиты и цели, элементарная арифметика f64, хранилище Tensor и соглашение о построчных индексах.

Вычислить аналитическое значение NLL непосредственно по исходным логитам rust/demos/ch13-gradient-checking/src/lib.rs#hand-derived-nll-gradient
/// Applies `(normalized probabilities - one_hot(target)) / batch_size` through a separate local path.
///
/// This frozen two-row analytic path deliberately implements its own row traversal,
/// maximum shift, exponential sum, normalization, target subtraction, and
/// batch scaling without calling the production probability or indexed-NLL
/// helpers. Both paths still share the input values and targets, IEEE `f64`
/// arithmetic and its primitive exponential, `Tensor` storage, and row-major
/// index conventions.
pub fn hand_derived_nll_gradient(logits: &Tensor) -> Result<Tensor, Box<dyn Error>> {
    if logits.shape() != LOGIT_SHAPE {
        return Err(format!(
            "the local Chapter 13 analytic path requires shape {LOGIT_SHAPE:?}, got {:?}",
            logits.shape()
        )
        .into());
    }

    let columns = LOGIT_SHAPE[1];
    let mut values = vec![0.0; LOGIT_VALUES.len()];
    for (row, &target) in TARGETS.iter().enumerate() {
        let start = row * columns;
        let row_logits = &logits.as_slice()[start..start + columns];
        let maximum = row_logits.iter().copied().fold(f64::NEG_INFINITY, f64::max);
        if !maximum.is_finite() {
            return Err(format!("oracle row {row} has no finite maximum").into());
        }

        let mut normalizer = 0.0;
        for (column, &logit) in row_logits.iter().enumerate() {
            if !logit.is_finite() {
                return Err(format!("oracle logit at [{row}, {column}] is not finite").into());
            }
            let weight = (logit - maximum).exp();
            values[start + column] = weight;
            normalizer += weight;
        }
        if !normalizer.is_finite() || normalizer <= 0.0 {
            return Err(
                format!("oracle row {row} has invalid normalization {normalizer:?}").into(),
            );
        }

        for column in 0..columns {
            values[start + column] /= normalizer;
        }
        values[start + target] -= 1.0;
    }
    for gradient in &mut values {
        *gradient /= TARGETS.len() as f64;
    }
    Ok(Tensor::from_vec(LOGIT_SHAPE.to_vec(), values)?)
}
Сопоставить четыре аналитических значения с изменёнными входами NLL по индексам rust/demos/ch13-gradient-checking/src/lib.rs#sampled-nll-gradient-check
/// Checks four deterministic vocabulary-logit coordinates against indexed NLL.
pub fn tiny_nll_gradient_example() -> Result<TinyNllGradientExample, Box<dyn Error>> {
    let mut logits = logits()?;
    let analytic = hand_derived_nll_gradient(&logits)?;
    let loss = indexed_mean_nll(&logits.view(), 1, &TARGETS)?;
    let original_bits = logits
        .as_slice()
        .iter()
        .map(|value| value.to_bits())
        .collect::<Vec<_>>();
    let check = sampled_tensor_gradient_check(
        &mut logits,
        &analytic.view(),
        TENSOR_STEP,
        TENSOR_TOLERANCE,
        TENSOR_SAMPLES,
        |candidate| {
            indexed_mean_nll(&candidate.view(), 1, &TARGETS)
                .expect("the frozen finite logits and targets remain valid")
        },
    )?;
    let restored_exactly = logits
        .as_slice()
        .iter()
        .map(|value| value.to_bits())
        .eq(original_bits);

    Ok(TinyNllGradientExample {
        logits,
        analytic,
        loss,
        check,
        restored_exactly,
    })
}

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

Подготовить полный детерминированный результат перед печатью rust/demos/ch13-gradient-checking/src/main.rs#learner-gradient-check-output
    let correct = quadratic_gradient_check(6.0)?;
    let wrong = quadratic_gradient_check(5.5)?;
    let rounded = rounded_identity_gradient_check()?;
    let kink = absolute_kink_diagnostic()?;
    let scan = cubic_step_scan()?;
    let nll = tiny_nll_gradient_example()?;
    let collapsed = central_difference(1.0, 1.0e-20, |point| point * point).unwrap_err();
./course run cargo run --quiet --locked -p ch13-gradient-checking
quadratic: theta=3.000000000000 requested_h=1.00000000000000006e-1 actual_h_minus=1.00000000000000089e-1 actual_h_plus=1.00000000000000089e-1 f_minus=8.410000000000 f_center=9.000000000000 f_plus=9.610000000000 left_slope=5.900000000000 right_slope=6.100000000000 left_weight=5.00000000000000000e-1 right_weight=5.00000000000000000e-1 numerical=6.000000000000
wrong candidate: analytic=5.500000000000 scaled_error=8.333333333333e-2 tolerance=1.000000000000e-6 pass=false
nll logits: shape=[2, 3] values=[0.0, 1.0, -1.0, 2.0, 0.0, -2.0] targets=[0, 2] loss=2.775268796472
sampled coordinates: [[0, 0], [0, 1], [1, 0], [1, 2]]
tensor restored exactly: true

Проследите, как уменьшение шага сначала помогает, а затем мешает

Для g(θ)=θ32θg(\theta)=\theta^3-2\theta при θ=1.5\theta=1.5 аналитическая производная равна 4.754.75. Перебор в Rust начинается с запрошенного h=1h=1, улучшается при 10110^{-1} и 10310^{-3} и достигает наименьшей показанной нормированной погрешности при запрошенном h=105h=10^{-5}: численная оценка равна 4.7500000001004.750000000100, нормированная погрешность — 2.103583973678×10112.103583973678\times10^{-11}, и результат укладывается в допуск. Затем из-за округления при запрошенном h=1013h=10^{-13} получается 4.7511111111114.751111111111 с нормированной погрешностью 2.338634237605×1042.338634237605\times10^{-4}, поэтому результат не проходит проверку. При запрошенном h=1015h=10^{-15} численная оценка равна 4.8000000000004.800000000000, а нормированная погрешность — 1.041666666667×1021.041666666667\times10^{-2}; этот результат также не укладывается в допуск. Глава не объявляет 10510^{-5} универсально лучшим шагом: это надёжная область только для данного примера.

Схема сопоставляет запрошенную величину шага с фактическими расстояниями до точек вычисления и показывает проверку линейной функции после округления, известную недифференцируемую точку x|x| при x=0x=0, отдельные аналитический и численный пути NLL с их общими предпосылками, все режимы погрешности, результаты сравнения, выбранные координаты и отклонённые запросы. Оценки и нормированные погрешности в схеме округлены только для удобства чтения; полный вывод исполняемого примера сохраняет их точные значения вместе с точками вычисления и значениями функции. Каждое показанное hh — запрошенный шаг, а производная вычисляется по двум фактическим расстояниям. Перебор показывает полезную область для гладкой функции между погрешностями усечения и округления, а координаты функции потерь — ограниченность выводов по успешной выборочной сверке.

Проверить производную кубической функции при всех шести величинах шага rust/demos/ch13-gradient-checking/src/lib.rs#step-size-scan
/// Runs the same central difference from truncation-dominated to rounded probes.
pub fn cubic_step_scan() -> Result<Vec<StepScanRecord>, GradCheckError> {
    STEP_SCAN
        .iter()
        .zip(STEP_PHASES)
        .map(|(&step, phase)| {
            scalar_gradient_check(CUBIC_POINT, CUBIC_ANALYTIC, step, STEP_TOLERANCE, cubic)
                .map(|check| StepScanRecord { phase, check })
        })
        .collect()
}

Сопоставьте фактические расстояния, прежде чем доверять выборочной сверке

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

Численная производная
6.000000000000
Точка для кубической функции
θ=1.500000000000\theta=1.500000000000
Среднее NLL по индексам
2.775268796472

Проверьте фактические расстояния при значении параметра три

Фактическая точка слева
θ=2.9\theta_-=2.9
q(θ)=8.41q(\theta_-)=8.41
Фактическое расстояние слева
1.00000000000000089e-1
Односторонний наклон слева
5.900000000000
Центр
θ=3,  q(θ)=9\theta=3,\;q(\theta)=9
Запрошенная величина шага
1.00000000000000006e-1
Схема расположения точек
symmetric
ww_- 5.00000000000000000e-1 w+w_+ 5.00000000000000000e-1
Численный градиент
6.000000000000
Фактическая точка справа
θ+=3.1\theta_+=3.1
q(θ+)=9.61q(\theta_+)=9.61
Фактическое расстояние справа
1.00000000000000089e-1
Односторонний наклон справа
6.100000000000

Переберите шесть величин шага при значении параметра полтора

  1. h=1h=1 выше допуска
    Численный градиент
    5.755.75
    Нормированная погрешность
    1.74×1011.74\times10^{-1}
    Фактическое расстояние слева / Фактическое расстояние справа
    1.00000000000000000e0 1.00000000000000000e0
    Режим погрешности
    усечение
  2. h=101h=10^{-1} выше допуска
    Численный градиент
    4.764.76
    Нормированная погрешность
    2.10×1032.10\times10^{-3}
    Фактическое расстояние слева / Фактическое расстояние справа
    1.00000000000000089e-1 1.00000000000000089e-1
    Режим погрешности
    усечение
  3. h=103h=10^{-3} в допуске
    Численный градиент
    4.7500014.750001
    Нормированная погрешность
    2.11×1072.11\times10^{-7}
    Фактическое расстояние слева / Фактическое расстояние справа
    9.99999999999889866e-4 9.99999999999889866e-4
    Режим погрешности
    сходимость
  4. h=105h=10^{-5} в допуске
    Численный градиент
    4.75000000014.7500000001
    Нормированная погрешность
    2.10×10112.10\times10^{-11}
    Фактическое расстояние слева / Фактическое расстояние справа
    1.00000000000655120e-5 1.00000000000655120e-5
    Режим погрешности
    надёжная область
  5. h=1013h=10^{-13} выше допуска
    Численный градиент
    4.7511114.751111
    Нормированная погрешность
    2.34×1042.34\times10^{-4}
    Фактическое расстояние слева / Фактическое расстояние справа
    9.99200722162640886e-14 9.99200722162640886e-14
    Режим погрешности
    округление
  6. h=1015h=10^{-15} выше допуска
    Численный градиент
    4.84.8
    Нормированная погрешность
    1.04×1021.04\times10^{-2}
    Фактическое расстояние слева / Фактическое расстояние справа
    1.11022302462515654e-15 1.11022302462515654e-15
    Режим погрешности
    округление

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

Численный градиент 66 Допуск 10610^{-6}

  1. Аналитическое значение 66 в допуске
    Нормированная погрешность
    00
  2. Аналитическое значение 5.55.5 выше допуска
    Нормированная погрешность
    8.33×1028.33\times10^{-2}

Сверка четырёх выбранных координат NLL

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

Биты восстановлены да, точно

  1. 0[0,0]0\to[0,0]
    Аналитическое значение
    -0.377635764473
    Численный градиент
    -0.377635764481
    Нормированная погрешность
    8.75×10128.75\times10^{-12}
  2. 1[0,1]1\to[0,1]
    Аналитическое значение
    0.332620477887
    Численный градиент
    0.332620477894
    Нормированная погрешность
    6.43×10126.43\times10^{-12}
  3. 3[1,0]3\to[1,0]
    Аналитическое значение
    0.433406666099
    Численный градиент
    0.433406666087
    Нормированная погрешность
    1.21×10111.21\times10^{-11}
  4. 5[1,2]5\to[1,2]
    Аналитическое значение
    -0.492061880012
    Численный градиент
    -0.492061879994
    Нормированная погрешность
    1.75×10111.75\times10^{-11}

Что не доказывает успешная сверка

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

f(x)=x,  x=1f(x)=x,\;x=1 в допуске
Запрошенная величина шага
1.33226762955018780e-16
Фактическое расстояние слева
1.11022302462515654e-16
Фактическое расстояние справа
2.22044604925031308e-16
Схема расположения точек
unequal
Численный градиент
1.000000000000
f(x)=x,  x=0f(x)=|x|,\;x=0
Односторонний наклон слева
-1.000000000000
Односторонний наклон справа
1.000000000000
Численный градиент
0.000000000000
Допуск
1.000000000000e-12

Аналитический путь NLL реализован локально и не вызывает основные функции вероятностей или NLL по индексам; оба пути по-прежнему используют общие входные значения и цели, арифметику IEEE f64 и её базовую экспоненту, хранилище Tensor и соглашения о построчной индексации.

Аналитическое значение
local-row-max-exp-sum-normalize-target-gradient
objective-path
indexed-mean-nll
shared-primitives
f64-exp,frozen-inputs-and-targets

Недопустимые запросы

Недопустимый запрос отклоняется до принятия результата за производную.

  1. нулевой шаг

    h=0h=0

  2. точка не изменилась

    слева θ=1\theta=1 h=1020h=10^{-20}

  3. неконечное значение

    слева f=NaNf=\mathrm{NaN}

  4. формы не совпадают

    [2][1,2][2]\ne[1,2]

Сделайте предсказания до запуска Rust

  1. Вычислите q(2.9)q(2.9) и q(3.1)q(3.1), а затем объясните, почему реализация должна получить hh_- и h+h_+, а не делить на запрошенное 2h2h.
  2. Для f(x)=xf(x)=x и разных ненулевых фактических расстояний предскажите оба односторонних наклона и результат взвешенной трёхточечной формулы.
  3. Предскажите результаты сравнения для аналитических значений 66 и 5.55.5 при допуске 10610^{-6}.
  4. Выберите результат с наименьшей погрешностью в этом переборе шести шагов, не объявляя соответствующий запрошенный шаг универсально лучшим.
  5. Объясните, почему запрошенные шаги h=1013h=10^{-13} и h=1015h=10^{-15} не проходят проверку после успешного результата с более крупным шагом из надёжной области.
  6. До ответа вычислите масштаб и нормированную погрешность для ошибочного аналитического значения.
  7. В тензоре N=6N=6 элементов, а значение max_samples равно R=4R=4. Вычислите S=min(R,N)S=\min(R,N), затем для каждого k{0,1,2,3}k\in\{0,1,2,3\} найдите плоское смещение k(N1)/(S1)\left\lfloor k(N-1)/(S-1)\right\rfloor и сопоставьте четыре смещения с координатами формы [2,3].
  8. Предскажите поведение при нулевом шаге, точке, которая после округления не изменилась, и обычной ошибке после временного изменения тензора.
  9. Для f(x)=xf(x)=|x| при x=0x=0 объясните, почему численная оценка 00 может совпасть с аналитическим значением 00, но не установить существование производной.
  10. Проверьте понимание: что именно подтверждают четыре успешные сверки NLL, а какие общие предпосылки и вычисления остаются непроверенными?
Проверьте предсказания
  1. При округлении для ручного расчёта q(2.9)=8.41q(2.9)=8.41 и q(3.1)=9.61q(3.1)=9.61. Операции с плавающей запятой могут расположить точки на разных расстояниях, поэтому в формулу должны входить эти расстояния, а не запрошенный hh.
  2. Оба отношения равны 11: (f(x)f(x))/h=1(f(x)-f(x_-))/h_-=1 и (f(x+)f(x))/h+=1(f(x_+)-f(x))/h_+=1. Сумма весов равна единице, поэтому даже при hh+h_-\ne h_+ результат совпадает с 11 в пределах погрешности вычислений.
  3. Аналитическое значение 66 укладывается в допуск. Для значения 5.55.5 возвращается конечный результат сравнения с pass=false.
  4. В этом фиксированном переборе запрошенный шаг h=105h=10^{-5} даёт наименьшую показанную нормированную погрешность: 2.103583973678×10112.103583973678\times10^{-11} при численной оценке 4.7500000001004.750000000100. Для другой функции, точки, числового типа или пары фактических точек надёжная область может отличаться.
  5. При запрошенном h=1013h=10^{-13} численная оценка 4.7511111111114.751111111111 имеет нормированную погрешность 2.338634237605×1042.338634237605\times10^{-4} и не проходит проверку. При h=1015h=10^{-15} фактическое расстояние с каждой стороны равно лишь 1.11022302462515654×10151.11022302462515654\times10^{-15}; односторонние наклоны становятся равны 4.44.4 и 5.25.2, поэтому их взвешенное значение 4.8000000000004.800000000000 имеет нормированную погрешность 1.041666666667×1021.041666666667\times10^{-2} и также не проходит проверку. После округления соседние значения уже не сохраняют достаточно младших разрядов для надёжной оценки наклона.
  6. В точной арифметике max(1,5.5,6)=6\max(1,5.5,6)=6 и 5.5/66/6=1/12|5.5/6-6/6|=1/12; напечатанный результат f64 равен 8.333333333333e-2 и всё равно намного превышает 10610^{-6}.
  7. Здесь S=min(4,6)=4S=\min(4,6)=4. Для k=0,1,2,3k=0,1,2,3 выражение k(61)/(41)=5k/3\lfloor k(6-1)/(4-1)\rfloor=\lfloor 5k/3\rfloor даёт плоские смещения [0,1,3,5], то есть координаты [[0,0],[0,1],[1,0],[1,2]].
  8. Нулевой hh недопустим; при h=1020h=10^{-20} и исходной точке 11 смещённая влево точка после округления совпадает с исходной. При любой обычной ошибке временное значение тензора восстанавливается до возврата.
  9. Наклон симметричной секущей равен 00, но наклон слева равен 1-1, а справа — 11. В точке излома производная не существует, поэтому совпадение с нулём — ложное успешное сравнение за пределами требования локальной гладкости.
  10. Результаты свидетельствуют только о четырёх выбранных координатах для этой целевой функции, входных данных, фактических точек, запрошенного шага и допуска. Они не доказывают весь градиент, не обучают модель и не относятся к генерации. Кроме того, оба пути используют общие входы, f64, Tensor и соглашения об индексах, поэтому эти предпосылки не проверяются сравнением.

После предсказаний запустите пример:

./course run cargo run --quiet --locked -p ch13-gradient-checking

Подготовьте автоматическое дифференцирование в обратном режиме

Теперь проект может сопоставлять выбранные производные по логитам словаря, вычисленные отдельным локальным аналитическим путём, с результатами основной реализации среднего NLL по индексам при изменённых входах. Аналитический путь не вызывает основные функции softmax или indexed_mean_nll, но оба пути всё ещё используют одни и те же исходные данные, арифметику f64, хранилище Tensor и соглашения об индексах. Поэтому совпадение служит полезным выборочным свидетельством, а не доказательством полной правильности обеих реализаций. В главе 14 та же граница применится к скалярным производным в обратном режиме.

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