27 · Версия материала 2
Вычислите одну голову самовнимания без маски
Разберите, как одна голова самовнимания Transformer без маски сопоставляет запросы с ключами, нормирует каждую строку и смешивает значения на основе проверяемого примера на Rust.
Предскажите, какое значение учтёт каждый запрос
Продолжим непосредственно с результата главы 26. Три проекции дали
В пакете две позиции токенов. Ширина признаков запросов и ключей, как и ширина значений, равна двум, поэтому .
До нормировки softmax предскажите, строка какого значения получит больший вес для каждого запроса. Для первого запроса два скалярных произведения равны
поэтому больший вес должно получить . Для второго запроса получаем
поэтому намного больший вес должно получить . Важны знаки и разности оценок: после нормировки каждая строка станет набором весов внимания.
Вычислите оценки, масштабируйте, нормируйте и смешайте значения
Одна голова внимания без маски на основе масштабированного скалярного произведения вычисляется по формуле
В разобранном примере матрицы оценок до и после масштабирования равны
Для каждого запроса softmax независимо нормирует строку по позициям ключей:
Первая взвешенная смесь значений равна
Вторая равна
Итак,
Не смешивайте строки запросов со столбцами ключей
- В каждой позиции токена соответствует строка запроса: какое содержимое ей следует учесть?
- В находятся строки ключей-кандидатов: по каким признакам позицию можно сопоставить с запросом?
- — общая ширина запросов и ключей.
- В каждому запросу соответствует одна нормированная строка весов внимания.
- В находятся строки значений с содержимым для смешивания.
- В каждому запросу соответствует итоговая смесь.
- — размер пакета, — число токенов, а — ширина значения.
Полное правило для форм имеет вид
При фиксированных элементе пакета и позиции запроса позиция ключа меняется внутри одной строки вероятностей:
Из-за такой построчной нормировки служит весом внимания. Это не калиброванная вероятность того, что токен или утверждение верны.
Васвани и соавторы обосновывают знаменатель при конкретных предположениях: если компоненты запросов и ключей независимы, имеют нулевое среднее и единичную дисперсию, то дисперсия их скалярного произведения равна . При этих предположениях деление на не позволяет дисперсии входа softmax расти вместе с . Это не универсальная теорема, не гарантия от переполнения и не доказательство того, что масштаб задаёт оптимальную температуру.
Наглядный эффект виден в примере с двумя координатами. Для оценок вес первой координаты без масштабирования равен
При после масштабирования на квадратный корень тот же вес равен
В этом примере распределение стало менее резким. Приведённые выше предположения объясняют пользу знаменателя, но не задают резкость любой строки внимания.
От рекуррентного контекста к учёту всех доступных позиций
Это история развития нейронного внимания на пути к современным LLM, а не история языков программирования.
Базовая рекуррентная модель энкодера–декодера может пропускать всё исходное предложение через один вектор фиксированного размера; при аддитивном внимании декодер всё ещё обрабатывает выходную последовательность рекуррентно и на каждом шаге заново вычисляет выравнивание с исходной последовательностью.
Бахданау, Чо и Бенжио, Neural Machine Translation by Jointly Learning to Align and Translate предложили важный промежуточный шаг. Бахданау, Чо и Бенжио описывают возможное узкое место базовой модели энкодера–декодера в виде вектора фиксированной длины и вычисляют каждый новый контекст как взвешенную по softmax сумму аннотаций энкодера, оценки для которых зависят от предыдущего состояния декодера.
Название аддитивное внимание для этой обучаемой функции оценивания — более поздняя ретроспективная классификация, которую используют Васвани и соавторы. В ранней работе нет обозначений запросов, ключей и значений из этой главы, масштабированных скалярных произведений или матрицы оценок внутри одной последовательности.
Transformer объединяет множество запросов в матрицы и с помощью самовнимания на основе масштабированного скалярного произведения сопоставляет позиции одной доступной последовательности, поэтому слой может сформировать всю матрицу оценок пакетными матричными операциями.
Васвани и соавторы, Attention Is All You Need сделали следующий шаг. Васвани и соавторы определяют внимание на основе масштабированного скалярного произведения как нормированную по softmax матрицу масштабированных скалярных произведений запросов и ключей, применяемую к значениям, объединяют одновременно обрабатываемые запросы в матрицу и определяют самовнимание как установление связей между позициями одной последовательности.
Каждая голова самовнимания декодера превращает обученные запросы и ключи в построчно нормированные веса внимания, а затем смешивает обученные значения; каузальный декодер перед нормировкой дополнительно скрывает будущие позиции ключей.
Одновременное формирование матрицы оценок не делает общий объём работы постоянным и не распараллеливает авторегрессионную генерацию токенов. Важное историческое изменение относится к структуре модели: рекуррентное выравнивание вычисляет новый контекст по мере продвижения декодера, а самовнимание связывает все доступные позиции внутри одного слоя.
Следующий исполняемый пример выделяет различие в том, откуда берутся позиции для запросов и для ключей со значениями:
rust/demos/ch27-self-attention/src/lib.rs#historical-attention-contrast fn attention_pairs<'a>(queries: &'a [&'a str], keys: &'a [&'a str]) -> Vec<(&'a str, &'a str)> {
queries
.iter()
.flat_map(|query| keys.iter().map(move |key| (*query, *key)))
.collect()
}
fn historical_attention_contrast() -> HistoryEvidence {
let decoder_states = ["decoder-state-0", "decoder-state-1"];
let encoder_annotations = ["encoder-annotation-0", "encoder-annotation-1"];
let hidden_sequence = ["hidden-position-0", "hidden-position-1"];
let encoder_decoder_alignment = attention_pairs(&decoder_states, &encoder_annotations);
let self_attention = attention_pairs(&hidden_sequence, &hidden_sequence);
assert!(
encoder_decoder_alignment
.iter()
.all(|(query, key)| query.starts_with("decoder") && key.starts_with("encoder"))
);
assert!(
self_attention
.iter()
.all(|(query, key)| query.starts_with("hidden") && key.starts_with("hidden"))
);
HistoryEvidence {
earlier: "recurrent-fixed-context",
bridge: "additive-encoder-decoder-alignment",
transformer: "scaled-dot-product-self-attention",
comparison: "all-sequence-positions",
}
} Соберите голову из дифференцируемых операций над тензорами
scaled_dot_product_self_attention принимает три объекта TensorValue третьего
ранга. Функция последовательно применяет уже реализованные транспонирование,
пакетный matmul, умножение на скаляр, устойчивый log_softmax, exp и второй
matmul. Результат позволяет проверить оценки до масштабирования,
масштабированные оценки, вероятности, выход, коэффициент масштабирования, ширину
ключей и ширину значений:
rust/crates/llm-from-scratch/src/attention/self_attention.rs#self-attention-forward /// Shared, validated score preparation for unmasked and causally masked heads.
#[derive(Clone, Debug)]
pub(crate) struct ScaledSelfAttentionScores {
pub(crate) raw_scores: TensorValue,
pub(crate) scaled_scores: TensorValue,
pub(crate) scale: f64,
pub(crate) key_width: usize,
pub(crate) value_width: usize,
}
/// Inspectable evidence from one unmasked attention head.
#[derive(Clone, Debug)]
pub struct SelfAttentionForward {
raw_scores: TensorValue,
scaled_scores: TensorValue,
weights: TensorValue,
output: TensorValue,
scale: f64,
key_width: usize,
value_width: usize,
}
impl SelfAttentionForward {
/// The unnormalized matrix `Q K^T` with shape `[batch, tokens, tokens]`.
pub fn raw_scores(&self) -> &TensorValue {
&self.raw_scores
}
/// Alias that emphasizes that each raw cell is one query-key dot product.
pub fn dot_products(&self) -> &TensorValue {
&self.raw_scores
}
/// The raw scores divided by the square root of the query/key width.
pub fn scaled_scores(&self) -> &TensorValue {
&self.scaled_scores
}
/// Row-normalized probabilities over key positions.
pub fn weights(&self) -> &TensorValue {
&self.weights
}
/// Alias for the row-normalized attention weights.
pub fn probabilities(&self) -> &TensorValue {
&self.weights
}
/// The weighted value rows with shape `[batch, tokens, value_width]`.
pub fn output(&self) -> &TensorValue {
&self.output
}
/// The fixed score multiplier `1 / sqrt(key_width)` used by this pass.
pub const fn scale(&self) -> f64 {
self.scale
}
pub const fn key_width(&self) -> usize {
self.key_width
}
pub const fn value_width(&self) -> usize {
self.value_width
}
pub fn into_output(self) -> TensorValue {
self.output
}
pub fn into_parts(self) -> (TensorValue, TensorValue, TensorValue, TensorValue) {
(
self.raw_scores,
self.scaled_scores,
self.weights,
self.output,
)
}
}
/// Computes one unmasked scaled dot-product self-attention head.
///
/// Q, K, and V must describe the same batch and token positions. Q and K share
/// one nonzero feature width; V may use a different nonzero output width.
pub fn scaled_dot_product_self_attention(
query: &TensorValue,
key: &TensorValue,
value: &TensorValue,
) -> Result<SelfAttentionForward, SelfAttentionError> {
let prepared = scaled_self_attention_scores(query, key, value)?;
let log_weights = prepared
.scaled_scores
.log_softmax(2)
.map_err(autodiff_error(SelfAttentionStage::LogSoftmax))?;
let weights = log_weights
.exp()
.map_err(autodiff_error(SelfAttentionStage::Probabilities))?;
let output = weights
.matmul(value)
.map_err(autodiff_error(SelfAttentionStage::ValueMixture))?;
Ok(SelfAttentionForward {
raw_scores: prepared.raw_scores,
scaled_scores: prepared.scaled_scores,
weights,
output,
scale: prepared.scale,
key_width: prepared.key_width,
value_width: prepared.value_width,
})
}
pub(crate) fn scaled_self_attention_scores(
query: &TensorValue,
key: &TensorValue,
value: &TensorValue,
) -> Result<ScaledSelfAttentionScores, SelfAttentionError> {
let query_shape = query.shape();
let key_shape = key.shape();
let value_shape = value.shape();
for (input, shape) in [
(SelfAttentionInput::Query, query_shape.as_slice()),
(SelfAttentionInput::Key, key_shape.as_slice()),
(SelfAttentionInput::Value, value_shape.as_slice()),
] {
if shape.len() != 3 {
return Err(SelfAttentionError::InputRank {
input,
rank: shape.len(),
});
}
}
if query_shape[0] != key_shape[0] || query_shape[0] != value_shape[0] {
return Err(SelfAttentionError::BatchMismatch {
query: query_shape[0],
key: key_shape[0],
value: value_shape[0],
});
}
if query_shape[1] != key_shape[1] || query_shape[1] != value_shape[1] {
return Err(SelfAttentionError::TokenMismatch {
query: query_shape[1],
key: key_shape[1],
value: value_shape[1],
});
}
if query_shape[1] == 0 {
return Err(SelfAttentionError::EmptyTokens);
}
if query_shape[2] == 0 {
return Err(SelfAttentionError::EmptyFeatureWidth {
input: SelfAttentionInput::Query,
});
}
if key_shape[2] == 0 {
return Err(SelfAttentionError::EmptyFeatureWidth {
input: SelfAttentionInput::Key,
});
}
if query_shape[2] != key_shape[2] {
return Err(SelfAttentionError::QueryKeyWidthMismatch {
query: query_shape[2],
key: key_shape[2],
});
}
if value_shape[2] == 0 {
return Err(SelfAttentionError::EmptyFeatureWidth {
input: SelfAttentionInput::Value,
});
}
let key_transposed = key
.transpose(1, 2)
.map_err(autodiff_error(SelfAttentionStage::KeyTranspose))?;
let raw_scores = query
.matmul(&key_transposed)
.map_err(autodiff_error(SelfAttentionStage::RawScores))?;
let scale = 1.0 / (query_shape[2] as f64).sqrt();
let scale_tensor =
Tensor::from_vec(Vec::new(), vec![scale]).map_err(|source| SelfAttentionError::Tensor {
stage: SelfAttentionStage::ScaleTensor,
source,
})?;
let scale_value = TensorValue::constant(scale_tensor)
.map_err(autodiff_error(SelfAttentionStage::ScaleTensor))?;
let scaled_scores = raw_scores
.mul(&scale_value)
.map_err(autodiff_error(SelfAttentionStage::ScaledScores))?;
Ok(ScaledSelfAttentionScores {
raw_scores,
scaled_scores,
scale,
key_width: query_shape[2],
value_width: value_shape[2],
})
} Тензоры запросов, ключей и значений должны иметь третий ранг, а размеры пакета и оси токенов должны совпадать. Ось токенов и все оси признаков должны быть непустыми. Ширины запросов и ключей должны совпадать, но может отличаться от . Пустой пакет допустим. Типизированные ошибки сохраняют сведения о ранге входа, размерах пакета и оси токенов, пустой оси токенов, ширине признаков и этапе прямого прохода:
rust/crates/llm-from-scratch/src/attention/self_attention.rs#self-attention-errors /// One of the three inputs to a self-attention head.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelfAttentionInput {
Query,
Key,
Value,
}
impl fmt::Display for SelfAttentionInput {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Query => "query",
Self::Key => "key",
Self::Value => "value",
})
}
}
/// The forward stage at which a cumulative tensor operation failed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelfAttentionStage {
KeyTranspose,
RawScores,
ScaleTensor,
ScaledScores,
LogSoftmax,
Probabilities,
ValueMixture,
}
impl fmt::Display for SelfAttentionStage {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::KeyTranspose => "key transpose",
Self::RawScores => "raw query-key scores",
Self::ScaleTensor => "score scale",
Self::ScaledScores => "scaled query-key scores",
Self::LogSoftmax => "row log-softmax",
Self::Probabilities => "attention probabilities",
Self::ValueMixture => "weighted value mixture",
})
}
}
/// A rejected Q/K/V shape or cumulative tensor operation.
#[derive(Clone, Debug, PartialEq)]
pub enum SelfAttentionError {
InputRank {
input: SelfAttentionInput,
rank: usize,
},
BatchMismatch {
query: usize,
key: usize,
value: usize,
},
TokenMismatch {
query: usize,
key: usize,
value: usize,
},
QueryKeyWidthMismatch {
query: usize,
key: usize,
},
EmptyTokens,
EmptyFeatureWidth {
input: SelfAttentionInput,
},
Tensor {
stage: SelfAttentionStage,
source: TensorError,
},
Autodiff {
stage: SelfAttentionStage,
source: TensorAutodiffError,
},
}
impl fmt::Display for SelfAttentionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InputRank { input, rank } => write!(
formatter,
"self-attention {input} must have rank three [batch, tokens, features], got rank {rank}"
),
Self::BatchMismatch { query, key, value } => write!(
formatter,
"self-attention batch sizes must match, got query {query}, key {key}, value {value}"
),
Self::TokenMismatch { query, key, value } => write!(
formatter,
"unmasked self-attention token counts must match, got query {query}, key {key}, value {value}"
),
Self::QueryKeyWidthMismatch { query, key } => write!(
formatter,
"self-attention query and key widths must match, got query {query}, key {key}"
),
Self::EmptyTokens => formatter.write_str(
"unmasked self-attention needs at least one token so every probability row has a key",
),
Self::EmptyFeatureWidth { input } => write!(
formatter,
"self-attention {input} needs a nonzero feature width"
),
Self::Tensor { stage, source } => {
write!(formatter, "self-attention {stage}: {source}")
}
Self::Autodiff { stage, source } => {
write!(formatter, "self-attention {stage}: {source}")
}
}
}
}
impl Error for SelfAttentionError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Tensor { source, .. } => Some(source),
Self::Autodiff { source, .. } => Some(source),
_ => None,
}
}
}
fn autodiff_error(
stage: SelfAttentionStage,
) -> impl FnOnce(TensorAutodiffError) -> SelfAttentionError {
move |source| SelfAttentionError::Autodiff { stage, source }
} В примере обратного прохода черта сверху обозначает градиент в обратном режиме:
Зададим входящий градиент по выходу
который задаёт скалярную целевую функцию
Сначала обратный проход через матричное умножение, вычисляющее выход, даёт
Для каждой строки запроса правило обратного прохода через softmax имеет вид
Затем масштабированные скалярные произведения передают градиенты запросам и ключам:
Здесь означает транспонирование последних двух осей отдельно внутри каждого элемента пакета.
Для разобранных входов эти уравнения дают
Все четыре координаты каждого входа совпадают с центральными разностями при шаге и допуске . Одинаковые ключи дают одинаковые веса; у единственного токена вес единственного значения равен единице; одинаковая перестановка , и так же переставляет выход; элементы пакета остаются независимыми. Для пустого пакета тензоры имеют форму , а — форму ; результатом служат формы и формы . Ширина значений может отличаться от ширины запросов и ключей: значения формы дают выход формы .
Полный пример объединяет данные прямого и обратного проходов, масштабирования, форм, границ и воспроизводимости:
rust/demos/ch27-self-attention/src/lib.rs#self-attention-fixture pub fn learner_evidence() -> Result<LearnerEvidence, FixtureError> {
let primary = primary_once()?;
let replay = primary_once()?;
let (query_checks, key_checks, value_checks, gradcheck_passed) = gradient_evidence(&primary)?;
Ok(LearnerEvidence {
replay_bitwise: primary == replay,
scale: scale_evidence()?,
single_token: single_token_evidence()?,
shapes: shape_evidence()?,
errors: error_evidence()?,
history: historical_attention_contrast(),
primary,
query_checks,
key_checks,
value_checks,
gradcheck_passed,
})
} rust/demos/ch27-self-attention/src/main.rs fn main() -> Result<(), Box<dyn std::error::Error>> {
let evidence = ch27_self_attention::learner_evidence()?;
print!("{}", ch27_self_attention::render_report(&evidence));
Ok(())
} Команда cargo run --quiet --locked -p ch27-self-attention выводит те же оценки,
вероятности, смеси, градиенты, формы и результаты проверки границ.
Проследите путь каждой строки внимания от оценок до выхода
Диаграмма проводит точные входы через оценки до и после масштабирования, строки вероятностей, взвешенные слагаемые значений, выходы, градиенты, формы, отклонённые входы и исторический переход к самовниманию:
Проследите путь каждой оценки до взвешенной смеси значений
Проследите точные запросы, ключи, значения, оценки, вероятности, смеси, градиенты, формы, исторические этапы и отклонённые недопустимые входы одной головы внимания без маски.
- Сплошная рамка запроса
- Штриховая рамка ключа
- Двойная рамка значения
- Пунктирная рамка оценки
- Двойная рамка вероятности
Проследите полный расчёт внимания без маски
Строки и столбцы точно сохраняют расчёт для двух токенов; рамки и подписи передают все различия без опоры на цвет.
Начните со строк запросов, ключей и значений
-
Строки запросов: какое содержимое следует учесть для каждой позиции? Форма:
-
Строки ключей: по каким признакам сопоставлять позиции? Форма:
-
Строки значений: какое содержимое может внести каждая позиция? Форма:
Сопоставьте каждый запрос с каждым ключом
Масштабируйте матрицу оценок
- Масштаб внимания
- Форма
Нормируйте каждую строку запроса по ключам
-
- Проверка суммы строки вероятностей
- Ось нормировки
key
-
- Проверка суммы строки вероятностей
- Ось нормировки
key
Смешайте строки значений в выходные строки
-
- Нормируйте каждую строку запроса по ключам
- Слагаемые после умножения на веса
- Выходная строка
-
- Нормируйте каждую строку запроса по ключам
- Слагаемые после умножения на веса
- Выходная строка
Проверьте градиенты, формы и отклонение недопустимых входов
Градиенты, формы и граничные случаи показывают, что сохраняет и что отклоняет одна и та же операция внимания.
Данные обратного прохода
Независимость элементов пакета и формы
- Независимость элементов пакета
- Проверено
У одного токена нет другого ключа для сравнения
Градиент запроса:
Градиент ключа:
Граница видимости
Видны все позиции ключей
Отклонённые недопустимые входы
- Отклонено Вид ошибки:
input-rankВо входе запросов должны быть явно заданы оси пакета, токенов и признаков. Недопустимая форма входа:operand=query|rank=2 - Отклонено Вид ошибки:
batch-mismatchУ тензоров запросов, ключей и значений должен совпадать размер пакета. Недопустимая форма входа:query=1|key=2|value=1 - Отклонено Вид ошибки:
token-mismatchУ тензоров запросов, ключей и значений должно совпадать число токенов. Недопустимая форма входа:query=2|key=3|value=2 - Отклонено Вид ошибки:
empty-token-axisДля нормировки каждой строки вниманию нужна хотя бы одна позиция ключа. Недопустимая форма входа:tokens=0 - Отклонено Вид ошибки:
query-key-width-mismatchДля скалярного произведения ширины признаков запросов и ключей должны совпадать. Недопустимая форма входа:query=2|key=3
Численные проверки
- Проверка суммы строки вероятностей
- Проверка градиентов
gradcheck=true- Проверенные координаты градиентов
- Допуск проверки градиентов
- Точная воспроизводимость
replay=bitwise
Проследите развитие нейронного внимания на пути к современным LLM
Сравнивается устройство моделей, а не история языков программирования или производительность оборудования.
-
Фиксированный рекуррентный контекст
Один вектор исходной последовательности фиксированного размера повторно используется на всех рекуррентных шагах декодера.
-
Аддитивное выравнивание энкодера и декодера
На каждом шаге декодер получает новый взвешенный контекст из аннотаций энкодера.
-
Самовнимание на основе масштабированного скалярного произведения
Один слой формирует оценку для каждой пары позиций запроса и ключа в доступной последовательности.
В этой голове без маски каждому запросу разрешены все доступные позиции ключей.
Сначала прочитайте строку вероятностей вдоль позиций ключей, затем проследите, как она превращается в соответствующие взвешенные слагаемые значений. Сплошные, штриховые, двойные и пунктирные рамки различают запросы, ключи, значения, оценки и вероятности без опоры на цвет. Пока видны все позиции; глава 28 добавит каузальную границу.
Сначала сделайте предсказания
- Вычислите все четыре элемента для разобранного примера.
- До применения softmax предскажите, какой ключ получит больший вес для каждого запроса.
- Объясните, почему сумма каждой строки , а не каждого столбца, равна единице.
- Предскажите форму выхода для и .
- Предскажите вероятность внимания и выход для одного токена со значением .
- Определите, что произойдёт, если одинаково переставить две позиции токенов в , и .
- Предскажите вероятности для двух одинаковых строк ключей.
- Укажите, какой доступ к будущему привёл бы к утечке целевой информации при каузальном обучении декодера.
- Сопоставьте рекуррентное выравнивание со структурой матрицы оценок самовнимания.
Проверить предсказания
- Строки оценок равны и .
- Запрос с номером ноль отдаёт больший вес ключу один, а запрос один — ключу ноль.
- Каждый запрос выбирает среди позиций ключей, поэтому softmax нормирует его ось ключей.
- Форма выхода равна .
- Единственная вероятность равна , а выход — .
- Совместная перестановка строк , и так же переставляет строки выхода. Входы, зависящие от позиции, и маску нужно рассматривать отдельно.
- Одинаковые оценки дают равномерную строку .
- Запрос может использовать более поздние целевые позиции, потому что у этой головы нет маски; при авторегрессионном обучении такой доступ привёл бы к утечке целевой информации.
- Рекуррентное выравнивание вычисляет новый контекст по мере продвижения декодера, а самовнимание устанавливает связи между всеми доступными позициями внутри одного слоя. Это не делает общий объём работы постоянным и не распараллеливает авторегрессионную генерацию.
Далее скройте будущие ключи
Накопительный декодер теперь преобразует одну тройку спроецированных тензоров запросов, ключей и значений в выход головы внимания без маски. В главе 28 будущие позиции ключей будут исключены до нормировки каждой строки оценок.
Голова уже сохраняет оси пакета и позиций запросов и возвращает как доступную для проверки матрицу вероятностей, так и смешанные значения. Для авторегрессионного декодирования она пока небезопасна: каждый запрос может использовать любой ключ. Следующая граница — каузальная маска; позиционная информация, несколько голов, выходная проекция, остаточная структура и кэширование появятся в следующих главах.