24 · Версия материала 3
Для каждого обучаемого обновления сохраняйте тождественный путь
Проследите остаточное сложение при точном совпадении форм, тождественный путь градиента и путь через обучаемую ветвь, обучение ветви с нулевыми весами и повторные преобразования с остаточными связями и без них.
Предскажите, что сохраняет тождественный путь
Возьмите и квадратную линейную ветвь без смещения, выход которой равен . Перед запуском примера предскажите . Поэлементное сложение даёт и сохраняет форму .
Теперь обнулите веса ветви. После этого ветвь тождественно равна нулю, а не только обращается в ноль на данном входе. Для входящего градиента предскажите три величины: выход, градиент по входу и градиент весов ветви. Первые две в точности следуют тождественному пути. Градиент весов при этом может оставаться ненулевым, поскольку изменение нулевого веса изменило бы выход ветви.
Добавьте обучаемое обновление к неизменённому потоку
Вся остаточная связь задаётся формулой:
Полные формы обоих слагаемых должны совпадать:
Это условие строже обычного согласования форм (broadcasting). Ветвь формы можно согласовать с входом формы при обычном сложении, но она не принадлежит тому же остаточному потоку, поэтому в этой главе такое сложение отклоняется.
В обратном режиме вычисление проходит по обоим родительским рёбрам и складывает их вклады:
Тождественный путь напрямую вносит . Ветвь вносит своё векторно-якобианное произведение . Эти вклады могут усиливать друг друга, взаимно сокращаться или увеличивать итог, поэтому тождественный член не гарантирует, что каждый градиент останется большим или будет вести себя устойчиво.
Для понимания полезна форма с коэффициентом:
При в прямом проходе остаётся только тождественный член; при получается обычная остаточная связь, реализованная в этой главе. В этой главе отдельная политика масштабирования остаточной ветви не вводится.
Не смешивайте роли потока и обновления
- — входной тензор, который тождественный путь передаёт без изменений.
- — обучаемое отображение ветви; позднее им может стать внимание или сеть прямого распространения.
- — обновление ветви, форма которого в точности совпадает с формой .
- — их поэлементная сумма той же формы.
- — входящая сопряжённая величина выхода, а — накопленная сопряжённая величина входа.
- — вклад ветви при обратном проходе.
- — необязательный поясняющий коэффициент, а не обучаемое значение в этой реализации.
Сложение — это не конкатенация: размер оси признаков не увеличивается. Это также не нормализация: среднее, дисперсия и среднеквадратическое значение не вычисляются.
От глубоких преобразований без остаточных связей к остаточному потоку Transformer
Хэ и соавторы обнаружили эффект деградации: более глубокие сети без остаточных связей могли иметь большую ошибку на обучающей выборке, хотя добавленные слои в принципе должны были уметь реализовать тождественное отображение.
Этот результат описан в первоисточнике — Хэ и соавторы, Deep Residual Learning for Image Recognition. Хэ и соавторы описывают деградацию глубоких сетей без остаточных связей и представляют блок неизменной размерности как сумму обучаемой остаточной функции и тождественной обходной связи без параметров. Остаточное обучение явно выделило тождественный путь, а позднее в Transformer остаточное сложение стало охватывать каждый подслой внимания и сети прямого распространения при общей ширине модели.
Следующий первоисточник — Васвани и соавторы, Attention Is All You Need. Васвани и соавторы помещают остаточную связь вокруг каждого подслоя энкодера и декодера и сохраняют общую ширину выходов подслоёв, необходимую для сложения. В исходной архитектуре Transformer за каждой суммой следует LayerNorm. В этом курсе позднее будет собран декодер с предварительной RMSNorm, поэтому статья не служит подтверждением такого порядка операций.
Transformer только с декодером сохраняет остаточный поток через всю цепочку блоков, а обучаемые ветви внимания и сети прямого распространения вносят обновления той же формы; в следующих главах добавятся нормализация и сборка этих ветвей.
Остаточное сложение даёт каждому подслою Transformer, сохраняющему ширину модели, явный тождественный путь наряду с обучаемым обновлением. Этот путь сохраняет непосредственный вклад в прямой проход и добавляет непосредственный вклад в градиент при обратном проходе, но не гарантирует успешного обучения любой глубокой модели.
Остаточное обучение сделало тождественный обходной путь явным в глубоких сетях для распознавания изображений. Затем архитектуры Transformer применили тот же механизм вокруг подслоёв внимания и сетей прямого распространения. Сравнение четырёх слоёв выделяет одно архитектурное следствие: каждый обычный слой даёт только результат обучаемого преобразования, а каждый остаточный слой добавляет этот результат к явному тождественному пути:
rust/demos/ch24-residual-connections/src/lib.rs#residual-stack fn stack_fixture(use_residual: bool) -> Result<StackFixture, Box<dyn Error>> {
let prefix = if use_residual { "residual" } else { "plain" };
let input = TensorValue::parameter(tensor(&INPUT_SHAPE, &INPUT_VALUES))?;
let mut current = input.clone();
let mut values = vec![current.value_snapshot()];
let mut layers = Vec::new();
for depth in 0..4 {
let layer = named_linear(
format!("{prefix}.stack.{depth}.branch.weight"),
&STACK_WEIGHT_VALUES,
)?;
let branch = layer.forward(¤t)?;
current = if use_residual {
residual_add(¤t, &branch)?
} else {
branch
};
values.push(current.value_snapshot());
layers.push(layer);
}
current.backward_with_seed(
&tensor(&INPUT_SHAPE, &UPSTREAM_VALUES).view(),
GraphRetention::Retain,
)?;
let parameter_names = layers
.iter()
.map(|layer| layer.weight().name().to_owned())
.collect();
let parameter_gradients_finite_nonzero = layers.iter().all(|layer| {
layer.weight().tensor().gradient().is_some_and(|gradient| {
gradient.as_slice().iter().all(|value| value.is_finite())
&& gradient.as_slice().iter().any(|value| *value != 0.0)
})
});
Ok(StackFixture {
values,
input_gradient: input.gradient_snapshot().expect("stack input gradient"),
parameter_names,
parameter_gradients_finite_nonzero,
})
} Перед сложением отклоните несовпадающие формы
Переиспользуемая функция не владеет параметрами. Сначала она сравнивает полные формы, а затем выполняет допустимое слияние с помощью уже реализованной дифференцируемой операции сложения. Благодаря этому в ленте операций сохраняются оба ребра от операндов и, следовательно, оба вклада при обратном проходе:
rust/crates/llm-from-scratch/src/nn/residual.rs#residual-add /// Adds an identity path to one same-shaped branch output.
///
/// The lower-level tensor addition supports broadcasting. A residual connection
/// does not: the branch must return exactly the residual stream's complete shape.
/// This utility owns no parameters and preserves both operands' tape edges.
pub fn residual_add(
identity: &TensorValue,
branch_output: &TensorValue,
) -> Result<TensorValue, ResidualError> {
let identity_shape = identity.shape();
let branch_shape = branch_output.shape();
if identity_shape != branch_shape {
return Err(ResidualError::ShapeMismatch {
identity: identity_shape,
branch: branch_shape,
});
}
Ok(identity.add(branch_output)?)
} Пример с квадратным слоем Linear и заданными значениями владеет параметром
residual.branch.weight. В прямом проходе он выдаёт . При
тождественный вклад равен , вклад ветви —
, а их сумма — . Градиент матрицы весов ветви в
построчном порядке равен :
rust/demos/ch24-residual-connections/src/lib.rs#residual-fixture fn primary_fixture() -> Result<PrimaryFixture, Box<dyn Error>> {
let layer = named_linear("residual.branch.weight", &WEIGHT_VALUES)?;
let input = TensorValue::parameter(tensor(&INPUT_SHAPE, &INPUT_VALUES))?;
let branch_output = layer.forward(&input)?;
let output = residual_add(&input, &branch_output)?;
let upstream = tensor(&INPUT_SHAPE, &UPSTREAM_VALUES);
output.backward_with_seed(&upstream.view(), GraphRetention::Retain)?;
let branch_probe_layer = named_linear("residual.branch_probe.weight", &WEIGHT_VALUES)?;
let branch_probe_input = TensorValue::parameter(tensor(&INPUT_SHAPE, &INPUT_VALUES))?;
branch_probe_layer
.forward(&branch_probe_input)?
.backward_with_seed(&upstream.view(), GraphRetention::Retain)?;
Ok(PrimaryFixture {
input: input.value_snapshot(),
branch_parameter_name: layer.weight().name().to_owned(),
branch_weight: layer.weight().tensor().value_snapshot(),
branch_output: branch_output.value_snapshot(),
residual_output: output.value_snapshot(),
upstream: upstream.clone(),
identity_gradient: upstream,
branch_input_gradient: branch_probe_input
.gradient_snapshot()
.expect("branch probe input gradient"),
input_gradient: input.gradient_snapshot().expect("residual input gradient"),
weight_gradient: layer
.weight()
.tensor()
.gradient_snapshot()
.expect("residual branch weight gradient"),
})
}
fn zero_branch_fixture() -> Result<(Tensor, Tensor, Tensor, bool), Box<dyn Error>> {
let layer = named_linear("residual.zero.weight", &ZERO_WEIGHT_VALUES)?;
let input = TensorValue::parameter(tensor(&INPUT_SHAPE, &INPUT_VALUES))?;
let branch_output = layer.forward(&input)?;
let output = residual_add(&input, &branch_output)?;
output.backward_with_seed(
&tensor(&INPUT_SHAPE, &UPSTREAM_VALUES).view(),
GraphRetention::Retain,
)?;
let weight_gradient = layer
.weight()
.tensor()
.gradient_snapshot()
.expect("zero branch weight gradient");
let nonzero = weight_gradient
.as_slice()
.iter()
.all(|value| value.is_finite())
&& weight_gradient.as_slice().iter().any(|value| *value != 0.0);
Ok((
output.value_snapshot(),
input
.gradient_snapshot()
.expect("zero branch input gradient"),
weight_gradient,
nonzero,
))
} Ветвь с нулевыми весами выдаёт для любого входа, поэтому её вклад в градиент по входу здесь равен нулю. Градиент её весов остаётся . Нельзя переносить этот вывод на ветвь, выход которой случайно обращается в ноль только в одной точке: из само по себе не следует .
Численная проверка вычисляет независимую реализацию скалярной целевой функции на обычных тензорах в каждой возмущённой координате, а затем проверяет обе координаты входа и все четыре координаты весов:
rust/demos/ch24-residual-connections/src/lib.rs#residual-gradcheck fn numeric_gradient_fixture() -> Result<(usize, usize, bool), Box<dyn Error>> {
let layer = named_linear("residual.gradcheck.weight", &WEIGHT_VALUES)?;
let input = TensorValue::parameter(tensor(&INPUT_SHAPE, &INPUT_VALUES))?;
let branch = layer.forward(&input)?;
let output = residual_add(&input, &branch)?;
output.mul(&output)?.sum_axis(0, false)?.backward()?;
let analytic_input = input.gradient().expect("analytic input gradient");
let analytic_weight = layer
.weight()
.tensor()
.gradient()
.expect("analytic weight gradient");
let frozen_weight = tensor(&WEIGHT_SHAPE, &WEIGHT_VALUES);
let mut input_probe = tensor(&INPUT_SHAPE, &INPUT_VALUES);
let input_check = sampled_tensor_gradient_check(
&mut input_probe,
&analytic_input.view(),
GRADCHECK_STEP,
GRADCHECK_TOLERANCE,
2,
|probe| squared_residual_objective(probe, &frozen_weight),
)?;
let frozen_input = tensor(&INPUT_SHAPE, &INPUT_VALUES);
let mut weight_probe = tensor(&WEIGHT_SHAPE, &WEIGHT_VALUES);
let weight_check = sampled_tensor_gradient_check(
&mut weight_probe,
&analytic_weight.view(),
GRADCHECK_STEP,
GRADCHECK_TOLERANCE,
4,
|probe| squared_residual_objective(&frozen_input, probe),
)?;
Ok((
input_check.checks.len(),
weight_check.checks.len(),
input_check.passed && weight_check.passed,
))
} Функция формирования отчёта принимает проверенный пример и печатает детерминированный текст:
rust/demos/ch24-residual-connections/src/lib.rs#learner-residual-report pub fn render_learner_report() -> Result<String, Box<dyn Error>> {
let report = learner_report()?;
let mut output = String::new();
writeln!(output, "chapter=24-residual-connections")?;
writeln!(
output,
"prediction=zero branch preserves output and input gradient but its weight gradient can be nonzero"
)?;
writeln!(
output,
"input=shape:{} values:{}",
bracketed_shape(report.input.shape()),
bracketed_values(&report.input)
)?;
writeln!(
output,
"branch_parameter=name:{} shape:{} values:{}",
report.branch_parameter_name,
bracketed_shape(report.branch_weight.shape()),
bracketed_values(&report.branch_weight)
)?;
writeln!(
output,
"branch_output=shape:{} values:{}",
bracketed_shape(report.branch_output.shape()),
bracketed_values(&report.branch_output)
)?;
writeln!(
output,
"residual_output=shape:{} values:{}",
bracketed_shape(report.residual_output.shape()),
bracketed_values(&report.residual_output)
)?;
writeln!(
output,
"upstream=shape:{} values:{}",
bracketed_shape(report.upstream.shape()),
bracketed_values(&report.upstream)
)?;
writeln!(
output,
"identity_gradient={}",
bracketed_values(&report.identity_gradient)
)?;
writeln!(
output,
"branch_input_gradient={}",
bracketed_values(&report.branch_input_gradient)
)?;
writeln!(
output,
"input_gradient={}",
bracketed_values(&report.input_gradient)
)?;
writeln!(
output,
"weight_gradient=shape:{} values:{}",
bracketed_shape(report.weight_gradient.shape()),
bracketed_values(&report.weight_gradient)
)?;
writeln!(
output,
"zero_branch=output:{} input_gradient:{} weight_gradient_nonzero:{}",
bracketed_values(&report.zero_output),
bracketed_values(&report.zero_input_gradient),
report.zero_weight_gradient_nonzero
)?;
writeln!(
output,
"shape_error=identity:{} branch:{} broadcastable:{} rejected:{}",
bracketed_shape(&report.mismatch_identity_shape),
bracketed_shape(&report.mismatch_branch_shape),
report.generic_add_broadcasts,
report.residual_mismatch_rejected
)?;
for row in &report.stack {
writeln!(
output,
"stack[{}]=plain:{} residual:{}",
row.depth,
bracketed_values(&row.plain),
bracketed_values(&row.residual)
)?;
}
writeln!(
output,
"stack_input_gradients=plain:{} residual:{}",
bracketed_values(&report.plain_stack_input_gradient),
bracketed_values(&report.residual_stack_input_gradient)
)?;
writeln!(
output,
"stack_parameters={}",
report.stack_parameter_names.join(",")
)?;
writeln!(
output,
"numeric_gradient=input_checks:{} weight_checks:{} tolerance:{:.6} passed:{}",
report.input_gradient_checks,
report.weight_gradient_checks,
GRADCHECK_TOLERANCE,
report.numeric_gradient_passed
)?;
writeln!(
output,
"historical=plain_depth4_retention:{} residual_depth4_retention:{}",
fixed(report.plain_stack_input_gradient.as_slice()[0]),
fixed(report.residual_stack_input_gradient.as_slice()[0])
)?;
writeln!(
output,
"same_fixture_replays_bitwise={}",
report.same_fixture_replays_bitwise
)?;
writeln!(
output,
"next=normalize each residual branch input with RMSNorm"
)?;
Ok(output)
} rust/demos/ch24-residual-connections/src/main.rs use std::error::Error;
use ch24_residual_connections::render_learner_report;
fn main() -> Result<(), Box<dyn Error>> {
print!("{}", render_learner_report()?);
Ok(())
} Выполните cargo run --quiet --locked -p ch24-residual-connections. Стандартный
вывод зафиксирован в rust/demos/ch24-residual-connections/expected.txt.
Проследите оба пути и не потеряйте место сложения
Трассировка содержит каждый показанный ниже вектор, решение о форме, строку цепочки, проверку градиента и проверенное свойство остаточной связи. Две строки схемы обозначают одновременно действующие пути: значение по тождественному пути проходит без изменений, обучаемая ветвь создаёт обновление, а в месте слияния они складываются покоординатно:
rust/demos/ch24-residual-connections/src/diagram_trace.rs#residual-connections-trace /// Renders the exact Rust-owned evidence consumed by the static chapter diagram.
pub fn render_trace() -> Result<String, Box<dyn Error>> {
let report = learner_report()?;
if report.stack.len() != 5 {
return Err("residual trace requires depths zero through four".into());
}
let mut trace = String::new();
writeln!(trace, "TRACE residual-connections-v1 BEGIN")?;
writeln!(
trace,
"CONFIG name=known-residual-linear shape={} branch-parameter={}",
x_shape(report.input.shape()),
report.branch_parameter_name
)?;
writeln!(
trace,
"FORWARD input={} branch={} output={}",
bracketed_values(&report.input),
bracketed_values(&report.branch_output),
bracketed_values(&report.residual_output)
)?;
writeln!(
trace,
"BACKWARD upstream={} identity={} branch={} input={}",
bracketed_values(&report.upstream),
bracketed_values(&report.identity_gradient),
bracketed_values(&report.branch_input_gradient),
bracketed_values(&report.input_gradient)
)?;
writeln!(
trace,
"PARAMETER name={} shape={} gradient={}",
report.branch_parameter_name,
x_shape(report.weight_gradient.shape()),
bracketed_values(&report.weight_gradient)
)?;
writeln!(
trace,
"ZERO-BRANCH output={} input-gradient={} weight-gradient={} weight-gradient-nonzero={}",
bracketed_values(&report.zero_output),
bracketed_values(&report.zero_input_gradient),
bracketed_values(&report.zero_weight_gradient),
report.zero_weight_gradient_nonzero
)?;
writeln!(
trace,
"SHAPE-ERROR identity={} branch={} broadcastable={} rejected={}",
bracketed_shape(&report.mismatch_identity_shape),
bracketed_shape(&report.mismatch_branch_shape),
report.generic_add_broadcasts,
report.residual_mismatch_rejected
)?;
for row in &report.stack {
writeln!(
trace,
"STACK depth={} plain={} residual={}",
row.depth,
bracketed_values(&row.plain),
bracketed_values(&row.residual)
)?;
}
writeln!(
trace,
"STACK-GRADIENT plain={} residual={} parameters={}",
bracketed_values(&report.plain_stack_input_gradient),
bracketed_values(&report.residual_stack_input_gradient),
report.stack_parameter_names.join(",")
)?;
writeln!(
trace,
"GRADCHECK input-checks={} weight-checks={} tolerance={:.6} passed={}",
report.input_gradient_checks,
report.weight_gradient_checks,
GRADCHECK_TOLERANCE,
report.numeric_gradient_passed
)?;
writeln!(
trace,
"PROOF identity=exact gradient=added parameters=branch-owned broadcast=forbidden"
)?;
writeln!(trace, "TRACE residual-connections-v1 END")?;
Ok(trace)
} Проследите тождественный путь и обучаемое обновление
Проследите точные значения примера через прямое слияние и оба вклада обратного прохода, затем сопоставьте ветвь с нулевыми весами, ошибку формы и две четырёхслойные цепочки.
Разведите и снова объедините пути в прямом проходе
- Сплошная линия — тождественный путь
- Пунктирная линия — обучаемая ветвь
- Двойная рамка — сложение
Точные значения одинаковой формы в прямом проходе
Сложите два вклада обратного прохода
- Сплошная линия — тождественный путь
- Пунктирная линия — обучаемая ветвь
- Двойная рамка — сложение
Тождественный вклад и векторно-якобианное произведение ветви складываются
Проверьте параметры, тождественный путь и формы
Параметры ветви
residual.branch.weight
Градиент весов
Ветвь с нулевыми весами
В прямом проходе выход совпадает со входом, полный градиент по входу равен входящему градиенту, а градиент весов ветви остаётся ненулевым.
Согласование форм здесь недопустимо
Формы остаточных путей должны точно совпадать
- Обычное сложение
- Допустимо
broadcastable=true - Остаточное сложение
- Отклонено
rejected=true
Выборочная проверка градиента
Допустимо passed=true
Проверенные свойства остаточной связи
Пример с заданными значениями known-residual-linear
Параметры ветви residual.branch.weight
identity=exact
gradient=added
parameters=branch-owned
broadcast=forbidden
Сравните повторяющиеся обычные и остаточные преобразования
| Глубина | Без остаточных связей | С остаточными связями |
|---|---|---|
| Градиент по входу |
residual.stack.0.branch.weightresidual.stack.1.branch.weightresidual.stack.2.branch.weightresidual.stack.3.branch.weight
Верхний путь со сплошной рамкой переносит тождественное значение, нижний путь с пунктирной рамкой — обучаемое обновление, а сложение в месте их встречи отмечено двойной рамкой. В обратном проходе такое же разветвление показывает, почему два вклада в градиент складываются.
Каждая из четырёх диагональных матриц ветвей умножает свой вход на . После четырёх обычных слоёв градиент по входу сохраняет множитель , а после четырёх остаточных — . Эти точные значения показывают влияние явных тождественных членов в данном примере. Они не задают универсальной границы для оптимизации.
Сначала сделайте предсказания
- Вычислите для заданных и .
- Разделите на тождественный вклад и вклад ветви.
- Предскажите выход ветви с нулевыми весами, градиент по входу и градиент весов.
- Определите, образуют ли формы и допустимую остаточную связь.
- Предскажите градиент, если обоими операндами сложения служит один и тот же отслеживаемый тензор.
- Вычислите множители обычной и остаточной цепочек глубины четыре при коэффициенте ветви .
- Объясните, почему остаточное сложение не конкатенирует и не нормализует признаки.
- Определите, гарантирует ли тождественный путь успешное обучение глубокой модели.
Проверить предсказания
- .
- .
- Выход равен , градиент по входу — , а градиент весов — ; последний ненулевой.
- Нет. Обычное сложение может согласовать эти формы, но остаточное сложение требует их точного совпадения.
- Два разных ребра операндов приходят к одному листовому тензору, поэтому он получает удвоенный входящий градиент.
- Для обычной цепочки множитель равен , а для остаточной — .
- Сложение сохраняет ширину признаков и не вычисляет статистик нормализации.
- Нет. Тождественный член создаёт прямой путь, но якобиан ветви по-прежнему может сократить, усилить или дестабилизировать итоговый градиент.
Далее нормализуйте вход ветви
Теперь в собираемом декодере есть операция слияния, требующая точного совпадения форм; благодаря ей единый остаточный поток пройдёт в обход обучаемых подслоёв. В главе 25 значения на входе каждой ветви будут нормализованы, а тождественный путь минует эту нормализацию.
Сейчас ветвь представляет собой небольшое квадратное линейное отображение, выбранное, чтобы вычисления было легко проверить. Более поздние ветви внимания и SwiGLU будут соблюдать тот же инвариант слияния: вернуть тензор ширины модели, а затем сложить его с неизменённым остаточным потоком.