Перейти к содержимому

Маски

Маска в attention указывает, на какие позиции токен может смотреть. Это маленькая деталь, от которой зависит корректность всей модели: неверная маска либо показывает модели ответ при обучении, либо молча портит результат при батчевой обработке, либо превращает выход в NaN.

  • Как маска входит в формулу внимания и почему это −∞-\infty, прибавленная до softmax.
  • Как выглядят causal-маска и маска скользящего окна и какой кусок маски берётся при работе с KV-кэшем.
  • Что такое attention_mask, чем правый паддинг отличается от левого и зачем при левом паддинге сдвигать позиции.
  • Что из этого поддерживает репозиторий и как паддинг исключается из функции потерь (метки -100).
  • Какие ловушки ждут во float16.

Перед softmax запрещённым парам (строка — запрос ii, столбец — ключ jj) в матрицу оценок scores записывается −∞-\infty, и их веса становятся нулевыми. В репозитории три вида масок.

МаскаОткудаГдеЧто запрещает
Causalстроится внутри attention (_tril_mask)все моделисмотреть в будущее: j>ij > i
Скользящее окностроится внутри GroupedQueryAttention, если задан window_sizeMistral, Mixtralто же и слишком далёкое прошлое: i−j>Wi - j > W
attention_maskпередаётся снаружи, [batch, seq_len], 1 — токен, 0 — паддингпараметр forward и generate всех моделейсмотреть на pad-токены

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

Напомним формулу внимания (см. Механизм внимания); маскирование «установкой в −∞-\infty всех недопустимых входов softmax» описано ещё в исходной статье трансформера (Vaswani et al., 2017, разд. 3.2.3):

P=softmax⁡ ⁣(S+M),S=QK⊤dh,Mij={0,пара (i,j) разрешена−∞,запрещенаP = \operatorname{softmax}\!\left(S + M\right), \qquad S = \frac{QK^\top}{\sqrt{d_h}}, \qquad M_{ij} = \begin{cases} 0, & \text{пара } (i, j) \text{ разрешена} \\ -\infty, & \text{запрещена} \end{cases}

где:

  • S∈RT×TkvS \in \mathbb{R}^{T \times T_{kv}} — оценки сходства запросов и ключей;
  • M∈{0,−∞}T×TkvM \in \{0, -\infty\}^{T \times T_{kv}} — аддитивная маска;
  • P∈RT×TkvP \in \mathbb{R}^{T \times T_{kv}} — веса внимания, softmax берётся по каждой строке.

Для разрешённого jj вес равен

Pij=exp⁡(Sij+Mij)∑kexp⁡(Sik+Mik)=exp⁡(Sij)∑k∈Aiexp⁡(Sik)P_{ij} = \frac{\exp(S_{ij} + M_{ij})}{\sum_{k} \exp(S_{ik} + M_{ik})} = \frac{\exp(S_{ij})}{\sum_{k \in \mathcal{A}_i} \exp(S_{ik})}

где Ai\mathcal{A}_i — множество разрешённых для строки ii ключей. Второе равенство верно потому, что exp⁡(−∞)=0\exp(-\infty) = 0: запрещённые слагаемые пропадают и из числителя, и из знаменателя. Получается softmax только по разрешённым ключам: запрещённые имеют вес ровно 0, разрешённые в сумме дают 1, а значения оценок SikS_{ik} для запрещённых kk на результат никак не влияют.

В коде булева маска mask (True — можно) применяется через scores.masked_fill(~mask, float("-inf")): это то же самое, что прибавить MM.

Записать 0 вместо −∞-\infty — ошибка: оценка 0 означает не «нельзя», а «среднее сходство», и её вес e0/Σe^0 / \Sigma вполне заметен. Запрещённая позиция продолжит влиять на выход.

Умножить веса на маску после softmax (Pij⋅mijP_{ij} \cdot m_{ij}, mij∈{0,1}m_{ij} \in \{0, 1\}) — тоже ошибка, и более тонкая. Возьмём строку 1 из численного примера главы о внимании: оценки (0, 0,7071, 0,7071)(0,\ 0{,}7071,\ 0{,}7071), ключ 2 — будущий.

softmax без маски: (0.1978, 0.4011, 0.4011)
× маска (1, 1, 0): (0.1978, 0.4011, 0 ) сумма 0.5989
правильно (−∞ до softmax): (0.3302, 0.6698, 0 ) сумма 1

Во-первых, веса больше не дают в сумме 1. Во-вторых — и это хуже — они зависят от будущего: знаменатель softmax содержит exp⁡(S12)\exp(S_{12}), оценку будущего ключа. Если бы S12S_{12} было 3 вместо 0,7071, после умножения на маску получилось бы (0,0433, 0,0877, 0)(0{,}0433,\ 0{,}0877,\ 0) — другой выход для токена 1 при изменении только будущего токена 2. Модель могла бы извлекать информацию о будущем из общего масштаба выхода — это утечка (leakage).

Если после умножения ещё и перенормировать строку, результат совпадёт с −∞-\infty-вариантом:

Pij mij∑kPik mik=eSijmij/Σi∑keSikmik/Σi=eSijmij∑k∈AieSik\frac{P_{ij}\, m_{ij}}{\sum_k P_{ik}\, m_{ik}} = \frac{e^{S_{ij}} m_{ij} / \Sigma_i}{\sum_k e^{S_{ik}} m_{ik} / \Sigma_i} = \frac{e^{S_{ij}} m_{ij}}{\sum_{k \in \mathcal{A}_i} e^{S_{ik}}}

где Σi=∑keSik\Sigma_i = \sum_k e^{S_{ik}} сокращается. Но это лишние операции, и считать exp⁡\exp от запрещённых оценок впустую (а они могут быть огромными и переполниться) нет смысла. Аддитивная −∞-\infty — самый простой и численно надёжный способ.

Маска во float16 и строка из одних минус бесконечностей

Заголовок раздела «Маска во float16 и строка из одних минус бесконечностей»

В арифметике с плавающей точкой (IEEE 754) −∞-\infty представима в любом формате, включая float16, и exp⁡(−∞)=0\exp(-\infty) = 0 вычисляется точно. Проблемы начинаются в двух случаях.

1. Строка, где запрещено всё. Если для запроса ii запрещены все ключи, softmax получает (−∞,…,−∞)(-\infty, \dots, -\infty). Реализации softmax вычитают максимум строки для устойчивости, а −∞−(−∞)-\infty - (-\infty) не определено; в итоге получается 0/00/0:

import torch
print(torch.softmax(torch.tensor([float("-inf")] * 3), dim=-1)) # tensor([nan, nan, nan])

NaN в одной строке распространяется дальше: через WOW_O он попадает в выход этой позиции, при обучении — в градиенты и, после шага оптимизатора, во все веса. Такая строка возникает, например, при левом паддинге: pad-токен на позиции 0 по causal-маске может видеть только себя, а маска паддинга запрещает и это.

2. «Большое отрицательное число» вместо −∞-\infty. Чтобы избежать NaN, многие реализации (в том числе HuggingFace Transformers при построении маски) пишут в запрещённые позиции не −∞-\infty, а минимальное конечное число типа, torch.finfo(dtype).min. Тогда полностью запрещённая строка даёт не NaN, а равномерное распределение — выход такой позиции бессмысленен, но конечен, и его отбрасывают. Здесь свои ловушки:

  • Константа должна помещаться в тип. Максимальное по модулю конечное число float16 — 65504; «универсальное» −109-10^9 при переводе во float16 становится −∞-\infty, и защита от NaN пропадает. −104-10^4 помещается, и exp⁡(−104)=0\exp(-10^4) = 0 уже в float32.
  • Две маски, сложенные вместе, переполняются: во float16 −65504+(−65504)=−∞-65504 + (-65504) = -\infty. Маски комбинируют логическим «и» или через masked_fill, а не сложением двух аддитивных масок.
  • Конечная константа должна быть намного меньше реальных оценок, иначе запрещённая позиция получит ненулевой вес.

В репозитории используется float("-inf"), и это безопасно: и causal-маска, и маска окна всегда разрешают диагональ (j=ij = i), поэтому строк из одних −∞-\infty не бывает. Маску паддинга модули attention не применяют (см. ниже), так что проблемы пункта 1 не возникает.

Causal-маска нужна для обучения на предсказании следующего токена: без неё, предсказывая токен i+1i + 1, модель видела бы его во входе. Она накладывается всегда, в том числе с KV-кэшем. Условие:

разрешено(i,j)  ⟺  j≤i\text{разрешено}(i, j) \iff j \le i

Для T=5T = 5 (строки — запросы ii, столбцы — ключи jj; 1 — можно, · — нельзя, то есть −∞-\infty):

causal j=0 j=1 j=2 j=3 j=4
i=0 1 · · · ·
i=1 1 1 · · ·
i=2 1 1 1 · ·
i=3 1 1 1 1 ·
i=4 1 1 1 1 1

Это нижнетреугольная матрица, torch.tril(torch.ones(T, T)). Именно так она строится в конструкторе MultiHeadAttention и MultiQueryAttention и хранится в буфере _tril_mask.

Скользящее окно (Longformer; Mistral 7B, разд. 2) у Mistral/Mixtral дополнительно отсекает далёкое прошлое:

разрешено(i,j)  ⟺  0≤i−j≤W\text{разрешено}(i, j) \iff 0 \le i - j \le W

где WW = window_size. Это W+1W + 1 позиций вместе с самим токеном (почему +1+1 — в mistral.md). Для T=5T = 5, W=2W = 2:

окно W=2 j=0 j=1 j=2 j=3 j=4
(репозиторий)
i=0 1 · · · ·
i=1 1 1 · · ·
i=2 1 1 1 · ·
i=3 · 1 1 1 ·
i=4 · · 1 1 1

Разрешена полоса шириной W+1W + 1 вдоль диагонали. Её строит GroupedQueryAttention._create_sliding_window_mask: col <= row (causal) и row - col <= window_size (окно). Без window_size вместо окна подставляется max_seq_len, и маска превращается в обычную causal. Для сравнения, в HuggingFace sliding_window = 2 означает i−j<2i - j < 2 — полосу шириной 2:

окно W=2 j=0 j=1 j=2 j=3 j=4
(HuggingFace)
i=0 1 · · · ·
i=1 1 1 · · ·
i=2 · 1 1 · ·
i=3 · · 1 1 ·
i=4 · · · 1 1

Поэтому при загрузке весов HF задают window_size = sliding_window − 1.

Маска хранится для абсолютных позиций 0…Tmax⁡−10 \dots T_{\max} - 1 (буфер _tril_mask формы [max_seq_len, max_seq_len]), а в конкретном вызове берётся её прямоугольный кусок. Пусть ss = start_pos — абсолютная позиция первого нового токена, TT — число новых токенов.

Строки — новые токены, позиции s,…,s+T−1s, \dots, s + T - 1. Столбцы — все ключи, которые есть в этом вызове: из кэша и новые.

Для MultiHeadAttention и MultiQueryAttention кэш содержит все прошлые позиции 0…s−10 \dots s - 1, поэтому

causal_mask = self._tril_mask[start_pos:start_pos + seq_len, :start_pos + seq_len]

то есть строки [s, s+T)[s,\ s + T), столбцы [0, s+T)[0,\ s + T); форма [T, s + T].

Для GroupedQueryAttention со скользящим окном кэш содержит только последние cc позиций перед ss (c≤Wc \le W), то есть позиции s−c,…,s−1s - c, \dots, s - 1. Столбцы среза начинаются с позиции самого старого ключа:

cache_len = k.size(2) - seq_len # c — сколько ключей пришло из кэша
window_mask = self._tril_mask[
start_pos:start_pos + seq_len, start_pos - cache_len:start_pos + seq_len
]

то есть строки [s, s+T)[s,\ s + T), столбцы [s−c, s+T)[s - c,\ s + T); форма [T, c + T]. Без окна c=sc = s, и срез совпадает со срезом MHA.

Пример 1: MHA, кэш из 3 токенов, 2 новых (s=3s = 3, T=2T = 2): строки 3–4, столбцы 0–4 полной causal-матрицы.

j=0 j=1 j=2 j=3 j=4
i=3 1 1 1 1 ·
i=4 1 1 1 1 1

Токен 3 не видит токен 4, хотя они пришли в одном вызове, — ради этого маска нужна и с кэшем.

Пример 2: окно W=2W = 2, промпт из 3 токенов, затем 2 новых. После prefill кэш обрезан до последних W=2W = 2 позиций — 1 и 2, c=2c = 2. Срез: строки 3–4, столбцы с s−c=3−2=1s - c = 3 - 2 = 1 по 4 матрицы окна:

j=1 j=2 j=3 j=4
i=3 1 1 1 ·
i=4 · 1 1 1

Пример 3: генерация по одному токену с окном (T=1T = 1). В кэше последние WW позиций, и строка среза состоит из одних единиц: вся работа окна сделана обрезкой кэша, а маска лишь подтверждает это. Именно поэтому при генерации по одному токену строка одна и видит всё, что есть в кэше; а при нескольких новых токенах (prefill промпта кусками) срез не даёт им видеть друг друга «вперёд» и даёт каждой строке своё окно.

Проверка: заполнение кэша кусками разной длины со скользящим окном даёт тот же выход, что и прогон всей последовательности сразу (см. пример кода в Механизм внимания).

Последовательности в батче должны быть одной длины, и короткие дополняют pad-токенами. attention_mask отмечает, где настоящие токены, а где паддинг:

input_ids attention_mask
Привет мир ! 1 1 1
Да <pad> <pad> 1 0 0 ← правый паддинг
<pad> <pad> Да 0 0 1 ← левый паддинг

Форма attention_mask — [B, T]: одно число на токен. Чтобы превратить её в маску для attention, нужна маска ключей (key padding mask): запрос ii строки батча bb может смотреть на ключ jj, только если jj — настоящий токен. Вместе с causal-маской:

разрешено(b,i,j)  ⟺  j≤i  и  mb,j=1\text{разрешено}(b, i, j) \iff j \le i \ \text{ и } \ m_{b, j} = 1

где mb,jm_{b,j} — элемент attention_mask. В тензорной форме маска ключей имеет форму [B, 1, 1, T_kv] и по правилам трансляции объединяется с causal-маской [T, T_kv] в [B, 1, T, T_kv] — общую для всех голов.

Правый паддинг стоит после настоящих токенов, и causal-маска и так не даёт им на него смотреть: для настоящего токена ii все pad-позиции jj лежат правее, то есть j>ij > i. Выход для настоящих токенов не зависит от паддинга, позиции настоящих токенов те же, что без паддинга. Маска ключей не нужна.

Pad-позиции сами смотрят на настоящие токены и получают какой-то выход, но он никому не нужен: при обучении loss на pad-позициях отключают метками -100 (см. ниже). Так дополняет батчи коллатор hf-proxy: input_ids — pad-токеном справа, attention_mask — нулями, labels — значением -100.

Проверка на модели GPT:

import torch
from llm.models.gpt import GPT
torch.manual_seed(0)
model = GPT({"vocab_size": 50, "embed_dim": 32, "num_heads": 4, "num_layers": 2,
"max_position_embeddings": 32, "dropout": 0.0}).eval()
ids = torch.tensor([[5, 6, 7, 0, 0], [5, 6, 7, 8, 9]])
mask = torch.tensor([[1, 1, 1, 0, 0], [1, 1, 1, 1, 1]])
with torch.no_grad():
padded, _ = model(ids, attention_mask=mask)
alone, _ = model(ids[:1, :3])
print(torch.allclose(padded[0, :3], alone[0], atol=1e-6)) # True

Левый паддинг нужен для генерации батчем: все строки должны кончаться в одной позиции, чтобы новый токен дописывался сразу после текста. При правом паддинге новый токен короткой строки встал бы после pad-токенов.

Здесь одной causal-маски мало, и нужны две вещи.

1. Маска ключей. Настоящие токены видят паддинг слева: для Да на позиции 2 ключи 0 и 1 — это <pad>, и causal-маска их разрешает. Нужна маска ключей из формулы выше.

2. Сдвиг позиций. Без него Да получает позицию 2 вместо 0, а от позиции зависят позиционные эмбеддинги (GPT) и RoPE (остальные модели): модель увидела бы тот же текст «в другом месте» и выдала бы другой результат. В HuggingFace для этого из маски вычисляются position_ids:

posb,t=∑k=0tmb,k−1\mathrm{pos}_{b, t} = \sum_{k=0}^{t} m_{b, k} - 1

где posb,t\mathrm{pos}_{b,t} — позиция токена tt строки bb, mb,km_{b,k} — элементы attention_mask. В коде это position_ids = attention_mask.cumsum(-1) - 1 — номер токена среди настоящих токенов своей строки. Пример:

attention_mask 0 0 1 1 1
cumsum 0 0 1 2 3
cumsum − 1 −1 −1 0 1 2 ← настоящие токены получили позиции 0, 1, 2

Pad-позиции получают −1-1, что недопустимо как индекс таблицы позиций; HuggingFace заменяет их произвольным допустимым значением (их выход всё равно не используется). Для правого паддинга формула тоже работает: 1 1 1 0 0 → 0 1 2 2 2, и позиции настоящих токенов совпадают с обычными 0,1,20, 1, 2.

И ловушка NaN: pad-токен на позиции 0 при левом паддинге не может смотреть ни на кого — causal-маска разрешает только j=0j = 0, а маска ключей запрещает j=0j = 0. Его строка состоит из одних −∞-\infty (см. выше).

Паддинг допускается в любом месте строки: слева, справа и в середине. Обе нужные вещи — маску ключей и позиции — строит padding_from_attention_mask(attention_mask, x, start_pos) из core/padding.py. Её вызывают forward всех шести моделей; результат — Padding(key_mask, positions) или None:

  1. attention_mask is None — None.
  2. Форма должна быть [B, T] без кэша и [B, cache_len + T] с кэшем — как в HF. Иначе — ValueError. С кэшем маска всегда полная, даже из одних единиц: по одним новым токенам нельзя узнать, был ли паддинг в закэшированных. Если бы короткая маска из единиц принималась, pad-токены кэша остались бы незамаскированными, а позиции новых токенов считались бы от start_pos — логиты молча оказались бы неверными.
  3. Все элементы ненулевые — None: модели идут прежним путём, побитово с тем же результатом, что и до поддержки паддинга.
  4. Иначе key_mask = attention_mask != 0 по всем слотам (кэш и новые токены) и positions = (cumsum(mask) − 1).clamp(min=0) для новых токенов: pad-позициям вместо −1-1 достаётся 0.

Модель передаёт Padding через декодеры в attention (параметр padding). Позиции идут в PositionalEmbeddings (GPT, GPT-2) и RoPE вместо start_pos, start_pos + 1, …, а Padding.apply добавляет маску ключей к causal-маске и окну — получается маска формы [B, 1, T, T_kv], общая для всех голов.

Ловушку NaN Padding.apply обходит так: pad-токен как запрос всегда видит себя. Тогда в каждой строке маски есть хотя бы один разрешённый ключ, и softmax определён. Без этого NaN pad-строки попал бы в выход настоящих токенов через нулевой вес: 0⋅NaN=NaN0 \cdot \mathrm{NaN} = \mathrm{NaN}. Выход pad-позиций смысла не имеет и дальше не используется.

Causal-маска и скользящее окно по-прежнему считаются по столбцам (слотам) последовательности, а не по позициям. Поэтому у моделей с окном (Mistral, Mixtral) нули в середине строки меняют состав окна: pad-слоты занимают место в окне, хотя и замаскированы.

Маскаforwardgenerate
None или из одних единиц✅✅
правый паддинг✅❌ ValueError: генерация продолжилась бы с pad-токена
левый паддинг✅✅ — батч промптов разной длины; каждая строка даёт то же, что её промпт отдельно
нули в середине строки✅✅, если последний токен каждой строки настоящий
маска вместе с кэшем✅ только полная, [B, cache_len + T]; короткая [B, T] — ValueError— (generate продлевает маску сам)

Во всех случаях выход настоящих токенов совпадает с прогоном строки без паддинга; это проверяют тесты для всех шести моделей (llm/tests/models/test_attention_mask.py) и сверка с HF (llm/tests/models/test_padding_hf_parity.py: GPT-2, LLaMA, Mistral с окном, Gemma с MQA).

generate проверяет маску промпта функцией check_generation_mask из core/generation.py: форма — как у промпта, последний токен каждой строки — настоящий (иначе ValueError). Дальше маска растёт на единицу с каждым новым токеном и передаётся в forward целиком, вместе с частью кэша; когда последовательность становится длиннее max_seq_len, маска обрезается вместе с ней.

import torch
from llm.models.gpt import GPT
torch.manual_seed(0)
model = GPT({"vocab_size": 50, "embed_dim": 32, "num_heads": 4, "num_layers": 2,
"max_position_embeddings": 32, "dropout": 0.0}).eval()
ids = torch.tensor([[0, 0, 5, 6, 7], [5, 6, 7, 8, 9]]) # левый паддинг в первой строке
mask = torch.tensor([[0, 0, 1, 1, 1], [1, 1, 1, 1, 1]])
batch = model.generate(ids, max_new_tokens=4, do_sample=False, attention_mask=mask)
alone = model.generate(ids[:1, 2:], max_new_tokens=4, do_sample=False)
print(torch.equal(batch[0, 2:], alone[0])) # True

Маска внимания решает, на что смотрят токены. Отдельный вопрос — за что штрафуют модель: loss на pad-позициях считать нельзя, иначе модель учится предсказывать паддинг, а средний loss зависит от того, сколько паддинга в батче.

Для этого метки (labels) на pad-позициях заменяют на −100-100, а в cross-entropy передают ignore_index=-100. Такие позиции исключаются и из суммы, и из числа слагаемых при усреднении:

L=−1∣I∣∑t∈Ilog⁡pθ(yt∣x≤t),I={ t:yt≠−100 }\mathcal{L} = -\frac{1}{|\mathcal{I}|} \sum_{t \in \mathcal{I}} \log p_\theta(y_t \mid x_{\le t}), \qquad \mathcal{I} = \{\, t : y_t \ne -100 \,\}

где:

  • yty_t — целевой токен (метка) для позиции tt: после сдвига это следующий токен xt+1x_{t+1} или −100-100;
  • pθ(yt∣x≤t)p_\theta(y_t \mid x_{\le t}) — вероятность этого токена по мнению модели: softmax логитов позиции tt, которые вычислены по токенам x0,…,xtx_0, \dots, x_t;
  • I\mathcal{I} — позиции с настоящими метками, ∣I∣|\mathcal{I}| — их число.

Число −100-100 выбрано по соглашению: это значение ignore_index по умолчанию в torch.nn.CrossEntropyLoss и F.cross_entropy, и его же использует HuggingFace; настоящим индексом токена оно быть не может.

Пример: три позиции, словарь из двух токенов, логиты (2,0)(2, 0), (0,2)(0, 2), (1,1)(1, 1), метки 0,−100,10, -100, 1. Средняя позиция игнорируется, и

L=12(−log⁡e2e2+e0−log⁡e1e1+e1)=12 (0,1269+0,6931)=0,4100\mathcal{L} = \frac{1}{2}\left( -\log\frac{e^2}{e^2 + e^0} - \log\frac{e^1}{e^1 + e^1} \right) = \frac{1}{2}\,(0{,}1269 + 0{,}6931) = 0{,}4100

В репозитории это Trainer.compute_lm_loss в training/trainer.py: логиты и метки сдвигаются на одну позицию (логит позиции tt предсказывает метку t+1t + 1), затем F.cross_entropy(..., ignore_index=-100). Метки -100 при паддинге проставляют датасеты llm/datasets (lm_example в datasets/lm_example.py) и HFTokenizerAdapter.pad из hf-proxy. Подробнее о функции потерь — в Обучение.

Итого при правом паддинге работают два независимых механизма: causal-маска гарантирует, что настоящие токены не видят паддинг, а -100 — что паддинг не участвует в loss.

  • Маска после softmax без перенормировки — утечка информации о будущем через знаменатель softmax.
  • 0 вместо −∞-\infty — запрещённая позиция продолжает получать вес.
  • Строка из одних −∞-\infty — NaN. Возникает при левом паддинге (pad-токен в начале), при пустом окне, при ошибке в срезе маски.
  • Константа −109-10^9 во float16 — переполнение до −∞-\infty; сложение двух масок из finfo.min — тоже.
  • Срез маски с кэшем. Строки — абсолютные позиции новых токенов, а не 0…T−10 \dots T - 1; со скользящим окном столбцы начинаются с s−cs - c.
  • Левый паддинг без сдвига позиций — модель видит текст «не на своих местах».
  • Паддинг в loss — забытые метки -100 делают loss зависящим от длины паддинга.
  • Правый паддинг при генерации — новый токен дописывается после <pad>; поэтому generate такие маски отклоняет.
  • Маска — слагаемое M∈{0,−∞}M \in \{0, -\infty\} к оценкам перед softmax. Это даёт softmax только по разрешённым ключам; умножение после softmax без перенормировки оставляет утечку из будущего.
  • Causal-маска: j≤ij \le i. Скользящее окно в репозитории: 0≤i−j≤W0 \le i - j \le W (W+1W + 1 позиций), в HuggingFace — WW позиций.
  • При работе с кэшем берётся срез маски: строки [s,s+T)[s, s + T), столбцы [0,s+T)[0, s + T) или [s−c,s+T)[s - c, s + T) при окне.
  • Правый паддинг безопасен для настоящих токенов без дополнительной маски; левый требует маски ключей и position_ids = cumsum(mask) − 1.
  • Репозиторий поддерживает паддинг в любом месте строки (core/padding.py): маска ключей, позиции cumsum(mask) − 1, pad-запрос видит себя, чтобы не было NaN. generate принимает левый паддинг и отклоняет правый.
  • Паддинг исключается из loss метками -100 и ignore_index=-100.
  • Строка из одних −∞-\infty даёт NaN; конечные «большие отрицательные» константы должны помещаться в тип данных.
  1. Нарисуйте маску скользящего окна репозитория для T=6T = 6, W=1W = 1. Сколько позиций видит каждый токен?

    Ответ
    i=0 1 · · · · ·
    i=1 1 1 · · · ·
    i=2 · 1 1 · · ·
    i=3 · · 1 1 · ·
    i=4 · · · 1 1 ·
    i=5 · · · · 1 1

    Каждый токен, кроме первого, видит W+1=2W + 1 = 2 позиции: себя и предыдущий.

  2. Докажите, что при Mij=−∞M_{ij} = -\infty веса PijP_{ij} не зависят от оценок SikS_{ik} запрещённых ключей kk, а при умножении на маску после softmax (без перенормировки) — зависят.

    Ответ

    С аддитивной маской Pij=eSij/∑k∈AieSikP_{ij} = e^{S_{ij}} / \sum_{k \in \mathcal{A}_i} e^{S_{ik}} — в формуле только разрешённые kk. С умножением Pijmij=eSijmij/∑keSikP_{ij} m_{ij} = e^{S_{ij}} m_{ij} / \sum_{k} e^{S_{ik}} — знаменатель содержит все kk, включая запрещённые, и ∂(Pijmij)/∂Sik=−PijPikmij≠0\partial (P_{ij} m_{ij}) / \partial S_{ik} = -P_{ij} P_{ik} m_{ij} \ne 0 для запрещённого kk при разрешённом jj.

  3. GroupedQueryAttention с window_size = 3 обработал промпт из 6 токенов, затем получает 2 новых токена. Чему равны start_pos, cache_len и какой срез _tril_mask будет взят? Выпишите его.

    Ответ

    start_pos = 6, в кэше последние 3 позиции (3, 4, 5), cache_len = 3. Срез: строки 6–7, столбцы [6−3, 8)[6 - 3,\ 8) = 3–7.

    j=3 j=4 j=5 j=6 j=7
    i=6 1 1 1 1 ·
    i=7 · 1 1 1 1
  4. Вычислите position_ids = attention_mask.cumsum(-1) − 1 для масок 0 0 0 1 1 и 1 1 1 1 0. Какие значения имеют смысл?

    Ответ

    0 0 0 1 1 → cumsum 0 0 0 1 2 → −1 −1 −1 0 1: настоящие токены получили позиции 0 и 1, значения на pad-позициях не используются. 1 1 1 1 0 → cumsum 1 2 3 4 4 → 0 1 2 3 3: настоящие токены — обычные позиции 0–3, pad-позиция получила 3, но её выход не нужен.

  5. Логиты трёх позиций (сдвиг на одну позицию уже сделан) для словаря из двух токенов: (0,0)(0, 0), (3,0)(3, 0), (0,1)(0, 1); метки 1,−100,−1001, -100, -100. Чему равен loss с ignore_index=-100? А если бы метки паддинга были не −100-100, а 0?

    Ответ

    Учитывается одна позиция: −log⁡(e0/(e0+e0))=log⁡2≈0,6931-\log(e^0 / (e^0 + e^0)) = \log 2 \approx 0{,}6931. С метками 0: слагаемые log⁡2=0,6931\log 2 = 0{,}6931, −log⁡(e3/(e3+1))=0,0486-\log(e^3 / (e^3 + 1)) = 0{,}0486 и −log⁡(1/(1+e))=1,3133-\log(1/(1 + e)) = 1{,}3133, среднее ≈0,6850\approx 0{,}6850 — loss смешан с «предсказанием паддинга» и зависит от его количества.

  6. Почему generate отклоняет правый паддинг, хотя forward его принимает?

    Ответ

    generate дописывает новый токен в конец каждой строки. При правом паддинге в короткой строке он встанет после pad-токенов, будет смотреть на них (causal-маска это разрешает) и получит позицию, сдвинутую на длину паддинга. Корректная генерация батчем требует левого паддинга со сдвигом позиций — его generate и принимает.

  7. Во float16 в запрещённые позиции записали torch.finfo(torch.float16).min, а затем к оценкам прибавили вторую маску с тем же значением. Что получится в позициях, запрещённых обеими масками, и в строке, где всё запрещено?

    Ответ

    −65504+(−65504)-65504 + (-65504) переполняется до −∞-\infty. Если в строке всё запрещено обеими масками, строка состоит из −∞-\infty, и softmax даёт NaN — ровно то, от чего константа должна была защищать. Маски нужно объединять логически (mask1 & mask2) и применять одним masked_fill.

  • Vaswani et al. Attention Is All You Need. 2017. arXiv:1706.03762 — маскирование в декодере (разд. 3.2.3)
  • Beltagy, Peters, Cohan. Longformer: The Long-Document Transformer. 2020. arXiv:2004.05150 — sliding window attention
  • Jiang et al. Mistral 7B. 2023. arXiv:2310.06825 — скользящее окно и кэш фиксированного размера