AlaBoussoffara's picture
added infer docker & /config option & various bug fixes & new tests
b22e8ba
Raw
History Blame Contribute Delete
39.6 kB
from __future__ import annotations
import math
import random
from collections.abc import Callable, Iterable, Sequence
from pathlib import Path
from typing import Any
import numpy as np
import torch
import torch.nn.functional as F
from torch import Tensor
def _require_matplotlib() -> Any:
try:
import matplotlib.pyplot as plt # type: ignore
except ImportError as exc: # pragma: no cover - optional dependency
raise ImportError("Install mini-transformer[viz] to enable plotting utilities.") from exc
return plt
# -----------------------------------------------------------------------------
# Global seeding utilities
# -----------------------------------------------------------------------------
def set_global_seed(seed: int, *, deterministic: bool = True) -> None:
"""Seed Python, NumPy, and PyTorch (CPU and CUDA) for reproducible runs."""
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
if deterministic:
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
def make_worker_init_fn(seed: int) -> Callable[[int], None]:
"""Return a DataLoader worker init function that derives unique seeds."""
def _init_fn(worker_id: int) -> None:
worker_seed = seed + worker_id
random.seed(worker_seed)
np.random.seed(worker_seed)
torch.manual_seed(worker_seed)
return _init_fn
# -----------------------------------------------------------------------------
# Attention building blocks
# -----------------------------------------------------------------------------
def split_heads(x: torch.Tensor, num_heads: int) -> torch.Tensor:
"""Split the last dimension into ``(num_heads, d_head)`` and permute to (B, H, S, d_head)."""
if not isinstance(x, torch.Tensor):
raise TypeError(f"x must be a torch.Tensor, got {type(x)}")
if not isinstance(num_heads, int):
raise TypeError(f"num_heads must be an int, got {type(num_heads)}")
if x.ndim != 3:
raise ValueError(
f"x must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(x.shape)}"
)
if num_heads <= 0:
raise ValueError(f"num_heads must be > 0; got {num_heads}")
batch_size, seq_length, d_model = x.shape
if d_model % num_heads != 0:
raise ValueError(
f"d_model ({d_model}) must be divisible by num_heads ({num_heads});"
f" got remainder {d_model % num_heads}"
)
d_head = d_model // num_heads
return x.reshape(batch_size, seq_length, num_heads, d_head).permute(0, 2, 1, 3)
def join_heads(x: torch.Tensor) -> torch.Tensor:
"""Merge ``(num_heads, d_head)`` back into the model dimension."""
if not isinstance(x, torch.Tensor):
raise TypeError(f"Expected torch.Tensor, got {type(x)}")
if x.ndim != 4:
raise ValueError(
f"Expected 4D torch.Tensor (batch, num_heads, seq_len, d_head), got {tuple(x.shape)}"
)
batch_size, num_heads, seq_length, d_head = x.shape
return x.permute(0, 2, 1, 3).contiguous().view(batch_size, seq_length, num_heads * d_head)
def calculate_attention(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
mask: torch.Tensor | None,
*,
dropout_p: float = 0.0,
return_probs: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
"""Scaled dot-product attention with optional dropout and probability return.
Args:
query, key, value: ``[B, H, S, Dh]`` tensors on the same device/dtype.
mask: Optional boolean tensor broadcastable to ``[B, H, Sq, Sk]`` where
``True`` entries are masked. ``None`` disables masking.
dropout_p: Dropout probability applied to the attention weights. Callers
should pass ``0.0`` when not training.
return_probs: When ``True`` returns ``(context, probs)``; otherwise only
the context tensor is returned.
"""
if not (query.dim() == key.dim() == value.dim() == 4):
raise ValueError(
"query, key, value must be 4D tensors shaped (B, H, S, Dh);"
f" got q={tuple(query.shape)}, k={tuple(key.shape)}, v={tuple(value.shape)}"
)
if query.device != key.device or query.device != value.device:
raise RuntimeError("query, key, value must be on the same device")
if mask is not None:
if mask.dtype != torch.bool:
raise TypeError("mask must be boolean when provided")
if mask.device != query.device:
mask = mask.to(query.device)
target_shape = (query.size(0), query.size(1), query.size(2), key.size(2))
if mask.shape != target_shape:
try:
mask = mask.expand(target_shape)
except RuntimeError as exc:
raise ValueError(
f"mask with shape {tuple(mask.shape)} not broadcastable to {target_shape}"
) from exc
p = float(dropout_p)
if p < 0 or p >= 1:
raise ValueError(f"dropout_p must be in [0, 1), got {dropout_p}")
return _attention_with_probs(query, key, value, mask, p, return_probs)
def _attention_with_probs(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
mask: torch.Tensor | None,
dropout_p: float,
return_probs: bool,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
"""Manual attention path supporting optional probability return."""
head_dim = query.size(-1)
work_dtype = torch.float32 if query.dtype in (torch.float16, torch.bfloat16) else query.dtype
q = query.to(work_dtype)
k = key.to(work_dtype)
scores = torch.matmul(q, k.transpose(-2, -1))
scores.mul_(1.0 / math.sqrt(head_dim))
full_mask_rows = None
if mask is not None:
mask = mask.to(scores.device)
fill_value = torch.finfo(scores.dtype).min
scores = scores.masked_fill(mask, fill_value)
full_mask_rows = mask.all(dim=-1, keepdim=True)
if full_mask_rows.any():
scores = scores.masked_fill(full_mask_rows, 0.0)
row_max = scores.max(dim=-1, keepdim=True).values
row_max = torch.where(torch.isfinite(row_max), row_max, torch.zeros_like(row_max))
logits = scores - row_max
probs = torch.softmax(logits, dim=-1)
if full_mask_rows is not None:
probs = torch.where(full_mask_rows, torch.zeros_like(probs), probs)
if dropout_p > 0.0:
probs = F.dropout(probs, p=dropout_p, training=True)
context = torch.matmul(probs.to(value.dtype), value)
if return_probs:
probs_out = probs.to(work_dtype)
return context, probs_out
return context
# -----------------------------------------------------------------------------
# Positional encodings
# -----------------------------------------------------------------------------
def sinusoidal_positional_encoding(S: int, D: int) -> torch.Tensor:
"""Return the classic sinusoidal positional encoding table (shape ``[S, D]``)."""
if S <= 0 or D <= 0:
raise ValueError(f"S and D must be > 0, got S={S}, D={D}")
positions = torch.arange(S, dtype=torch.float32).unsqueeze(1)
div_terms = torch.exp(torch.arange(0, D, 2, dtype=torch.float32) * -(math.log(10000.0) / D))
pe = torch.zeros(S, D, dtype=torch.float32)
pe[:, 0::2] = torch.sin(positions * div_terms)
pe[:, 1::2] = torch.cos(positions * div_terms[: D // 2])
return pe
# -----------------------------------------------------------------------------
# Sampling utilities
# -----------------------------------------------------------------------------
def _ensure_2d_logits(logits: torch.Tensor) -> torch.Tensor:
"""Reshape logits so that the last dimension is vocabulary sized and the rest flatten."""
if logits.dim() == 2:
return logits
if logits.dim() >= 3:
V = logits.size(-1)
return logits.reshape(-1, V)
raise ValueError(f"sample_from_logits: logits must be at least 2D, got {tuple(logits.shape)}")
def _apply_allow_deny_mask(
logits: torch.Tensor,
*,
allowed_tokens: Iterable[int] | None,
disallowed_tokens: Iterable[int] | None,
filter_value: float,
) -> torch.Tensor:
if allowed_tokens is not None:
mask = torch.zeros_like(logits, dtype=torch.bool)
idx = torch.tensor(list(allowed_tokens), device=logits.device)
idx = idx[(idx >= 0) & (idx < logits.size(-1))]
if idx.numel() > 0:
mask.index_fill_(-1, idx, True)
logits = torch.where(mask, logits, torch.full_like(logits, filter_value))
if disallowed_tokens is not None:
idx = torch.tensor(list(disallowed_tokens), device=logits.device)
idx = idx[(idx >= 0) & (idx < logits.size(-1))]
if idx.numel() > 0:
logits.index_fill_(-1, idx, filter_value)
return logits
def _top_k_filtering(
logits: torch.Tensor,
top_k: int | None,
*,
min_tokens_to_keep: int,
filter_value: float,
) -> torch.Tensor:
if top_k is None or top_k <= 0:
return logits
k = min(max(top_k, min_tokens_to_keep), logits.size(-1))
values, _ = torch.topk(logits, k, dim=-1)
threshold = values[..., -1, None]
return torch.where(logits < threshold, torch.full_like(logits, filter_value), logits)
def _top_p_filtering(
logits_scaled: torch.Tensor,
probs: torch.Tensor,
top_p: float,
*,
min_tokens_to_keep: int,
filter_value: float,
) -> torch.Tensor:
if top_p is None or not (0.0 < top_p < 1.0):
return logits_scaled
sorted_probs, sorted_idx = torch.sort(probs, dim=-1, descending=True)
cumulative = torch.cumsum(sorted_probs, dim=-1)
to_remove = cumulative > top_p
to_remove[..., :min_tokens_to_keep] = False
scatter_mask = torch.zeros_like(to_remove, dtype=torch.bool).scatter(-1, sorted_idx, to_remove)
return torch.where(scatter_mask, torch.full_like(logits_scaled, filter_value), logits_scaled)
def sample_from_logits(
logits: torch.Tensor,
*,
do_sample: bool = False,
temperature: float = 1.0,
top_k: int | None = None,
top_p: float | None = None,
min_tokens_to_keep: int = 1,
allowed_tokens: Iterable[int] | None = None,
disallowed_tokens: Iterable[int] | None = None,
repetition_ctx: torch.Tensor | None = None,
presence_penalty: float = 0.0,
frequency_penalty: float = 0.0,
no_repeat_ngram_size: int | None = None,
filter_value: float = -float("inf"),
rng: torch.Generator | None = None,
return_probs: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
"""Warp logits (temperature, top-k, nucleus, penalties) and produce next-token ids."""
if temperature <= 0:
raise ValueError("temperature must be > 0")
logits2d = _ensure_2d_logits(logits).to(dtype=torch.float32)
batch, vocab = logits2d.shape
logits2d = _apply_allow_deny_mask(
logits2d,
allowed_tokens=allowed_tokens,
disallowed_tokens=disallowed_tokens,
filter_value=filter_value,
)
if repetition_ctx is not None and (presence_penalty > 0.0 or frequency_penalty > 0.0):
if repetition_ctx.dim() != 2 or repetition_ctx.size(0) != batch:
raise ValueError(
f"repetition_ctx must be [B, T]; got {tuple(repetition_ctx.shape)} with B={batch}"
)
counts = torch.zeros((batch, vocab), device=logits2d.device, dtype=torch.float32)
ctx = repetition_ctx
valid = (ctx >= 0) & (ctx < vocab)
if valid.any():
ids = ctx.masked_select(valid)
bidx = (
torch.arange(batch, device=logits2d.device)
.unsqueeze(1)
.expand_as(ctx)
.masked_select(valid)
)
counts.index_put_(
(bidx, ids), torch.ones_like(ids, dtype=torch.float32), accumulate=True
)
if presence_penalty > 0.0:
logits2d = logits2d - presence_penalty * (counts > 0).to(logits2d.dtype)
if frequency_penalty > 0.0:
logits2d = logits2d - frequency_penalty * counts
if (
no_repeat_ngram_size is not None
and no_repeat_ngram_size >= 2
and repetition_ctx is not None
):
ctx_cpu = repetition_ctx.detach().to("cpu")
for b, seq in enumerate(ctx_cpu.tolist()):
tokens = [t for t in seq if 0 <= t < vocab]
if len(tokens) < no_repeat_ngram_size:
continue
history: dict[tuple[int, ...], set[int]] = {}
n = int(no_repeat_ngram_size)
for i in range(len(tokens) - (n - 1)):
key = tuple(tokens[i : i + n - 1])
nxt = tokens[i + n - 1]
history.setdefault(key, set()).add(nxt)
key = tuple(tokens[-(n - 1) :])
banned = history.get(key)
if banned:
idx = torch.tensor(list(banned), device=logits2d.device, dtype=torch.long)
logits2d[b].index_fill_(0, idx, filter_value)
logits_scaled = logits2d / float(temperature)
logits_scaled = _top_k_filtering(
logits_scaled, top_k=top_k, min_tokens_to_keep=min_tokens_to_keep, filter_value=filter_value
)
probs = F.softmax(logits_scaled, dim=-1)
if top_p is not None and 0.0 < top_p < 1.0:
logits_scaled = _top_p_filtering(
logits_scaled,
probs,
top_p=top_p,
min_tokens_to_keep=min_tokens_to_keep,
filter_value=filter_value,
)
probs = F.softmax(logits_scaled, dim=-1)
if do_sample:
next_ids = torch.multinomial(probs, num_samples=1, replacement=True, generator=rng).squeeze(
-1
)
else:
next_ids = torch.argmax(probs, dim=-1)
next_ids = next_ids.to(dtype=torch.long, device=logits.device)
if return_probs:
return next_ids, probs.to(device=logits.device, dtype=probs.dtype)
return next_ids
# -----------------------------------------------------------------------------
# Attention mask helpers
# -----------------------------------------------------------------------------
def create_causal_mask(x: torch.Tensor, num_heads: int) -> torch.Tensor:
"""Return a boolean causal mask ``[B, num_heads, S, S]`` for decoder self-attention."""
if x.ndim != 2:
raise ValueError(f"Expected input of shape (B, S), got {x.shape}")
if num_heads <= 0:
raise ValueError(f"num_heads must be > 0, got {num_heads}")
batch, seq_len = x.shape
if seq_len <= 0:
raise ValueError(f"Sequence length must be > 0, got {seq_len}")
base = torch.triu(torch.ones(seq_len, seq_len, dtype=torch.bool, device=x.device), diagonal=1)
return base.view(1, 1, seq_len, seq_len).expand(batch, num_heads, seq_len, seq_len).contiguous()
def create_qk_padding_mask(
query_attention_mask: torch.Tensor, key_attention_mask: torch.Tensor
) -> torch.Tensor:
"""Combine query and key padding masks (boolean) into a ``[B, H, Sq, Sk]`` mask."""
Bq, Hq, _, Sq = query_attention_mask.shape
Bk, Hk, _, Sk = key_attention_mask.shape
if Bq != Bk or Hq != Hk:
raise ValueError(
f"Padding mask batch/head mismatch: query {query_attention_mask.shape} vs key {key_attention_mask.shape}"
)
q_mask = query_attention_mask.to(torch.bool).view(Bq, Hq, Sq, 1)
k_mask = key_attention_mask.to(torch.bool).view(Bk, Hk, 1, Sk)
return (q_mask | k_mask).expand(Bq, Hq, Sq, Sk)
def broadcast_padding_mask(mask: torch.Tensor, num_heads: int) -> torch.Tensor:
"""Expand a ``[B, S]`` padding mask to ``[B, H, 1, S]``."""
if not isinstance(mask, torch.Tensor):
raise TypeError(f"mask must be a torch.Tensor, got {type(mask)}")
if mask.dim() != 2:
raise ValueError(f"mask must be [B, S], got shape {tuple(mask.shape)}")
if not isinstance(num_heads, int) or num_heads <= 0:
raise ValueError(f"num_heads must be a positive int, got {num_heads}")
batch, seq_len = mask.shape
return mask.unsqueeze(1).unsqueeze(2).expand(batch, num_heads, 1, seq_len)
def combine_masks(
causal_mask: torch.Tensor | None, padding_mask: torch.Tensor | None
) -> torch.Tensor | None:
"""Combine causal and padding masks (boolean OR) while handling ``None`` values."""
if padding_mask is None:
return causal_mask.to(torch.bool) if causal_mask is not None else None
if causal_mask is None:
return padding_mask.to(torch.bool)
m1 = causal_mask.to(torch.bool)
m2 = padding_mask.to(torch.bool).to(device=m1.device)
try:
return m1 | m2
except RuntimeError as exc:
raise ValueError(
f"Masks not broadcastable: causal {tuple(m1.shape)} vs padding {tuple(m2.shape)}"
) from exc
# -----------------------------------------------------------------------------
# Attention introspection / plotting
# -----------------------------------------------------------------------------
def extract_all_attention_maps(
model,
src_ids: torch.Tensor,
tgt_ids: torch.Tensor,
src_padding_2d: torch.Tensor,
tgt_padding_2d: torch.Tensor,
):
"""Collect attention probability tensors for encoder only/decoder self/cross attention."""
was_training = model.training
model.eval()
device = src_ids.device
num_heads = model.cfg.num_heads
src_pad_b = broadcast_padding_mask(src_padding_2d.to(device), num_heads)
tgt_pad_b = broadcast_padding_mask(tgt_padding_2d.to(device), num_heads)
tgt_causal = create_causal_mask(tgt_ids.to(device), num_heads)
x = model.embed(src_ids.to(device))
y = model.embed(tgt_ids.to(device))
enc_layers = model.encoder.layers
dec_layers = model.decoder.layers
enc_self_maps: list[Tensor] = []
dec_self_maps: list[Tensor] = []
dec_cross_maps: list[Tensor] = []
cur = x
for layer in enc_layers:
mha = layer.attention_layer
attn_input = layer.norm1(cur) if getattr(layer, "pre_norm", False) else cur
q = split_heads(mha.query_linear(attn_input), mha.num_heads)
k = split_heads(mha.key_linear(attn_input), mha.num_heads)
v = split_heads(mha.value_linear(attn_input), mha.num_heads)
mask = create_qk_padding_mask(src_pad_b, src_pad_b)
_, probs = calculate_attention(q, k, v, mask, dropout_p=0.0, return_probs=True)
enc_self_maps.append(probs)
cur = layer(cur, src_pad_b)
mem_full = cur
y_in = y
for layer in dec_layers:
pre_norm = getattr(layer, "pre_norm", False)
self_mha = layer.self_attention_layer
self_attn_input = layer.norm1(y_in) if pre_norm else y_in
q = split_heads(self_mha.query_linear(self_attn_input), self_mha.num_heads)
k = split_heads(self_mha.key_linear(self_attn_input), self_mha.num_heads)
v = split_heads(self_mha.value_linear(self_attn_input), self_mha.num_heads)
pad = create_qk_padding_mask(tgt_pad_b, tgt_pad_b)
combined_mask = combine_masks(tgt_causal, pad)
self_ctx, self_probs = calculate_attention(
q, k, v, combined_mask, dropout_p=0.0, return_probs=True
)
dec_self_maps.append(self_probs)
self_ctx_merged = join_heads(self_ctx)
self_projected = self_mha.output_linear(self_ctx_merged)
self_residual = y_in + layer.dropout1(self_projected)
if pre_norm:
cross_query_input = layer.norm2(self_residual)
else:
cross_query_input = layer.norm1(self_residual)
cross_mha = layer.cross_attention_layer
cq = split_heads(cross_mha.query_linear(cross_query_input), cross_mha.num_heads)
ck = split_heads(cross_mha.key_linear(mem_full), cross_mha.num_heads)
cv = split_heads(cross_mha.value_linear(mem_full), cross_mha.num_heads)
cmask = create_qk_padding_mask(tgt_pad_b, src_pad_b)
_, cross_probs = calculate_attention(cq, ck, cv, cmask, dropout_p=0.0, return_probs=True)
dec_cross_maps.append(cross_probs)
y_in = layer(mem_full, y_in, src_pad_b, tgt_pad_b, tgt_causal)
maps = {"enc_self": enc_self_maps, "dec_self": dec_self_maps, "dec_cross": dec_cross_maps}
model.train(was_training)
return maps
def attach_tokens(maps_dict, tokenizer, src_ids, tgt_ids):
"""Attach decoded token strings to a maps dictionary produced by ``extract_all_attention_maps``."""
src_tok = [
tokenizer.convert_ids_to_tokens(row, skip_special_tokens=False) for row in src_ids.tolist()
]
tgt_tok = [
tokenizer.convert_ids_to_tokens(row, skip_special_tokens=False) for row in tgt_ids.tolist()
]
maps_dict["src_tokens"] = src_tok
maps_dict["tgt_tokens"] = tgt_tok
return maps_dict
def _imshow(ax, array2d, title="", vmin=0.0, vmax=1.0):
image = ax.imshow(array2d, aspect="auto", vmin=vmin, vmax=vmax)
ax.set_title(title, fontsize=9)
ax.set_xticks([])
ax.set_yticks([])
return image
def plot_layer_heads_grid(
maps_dict: dict,
layer_idx: int,
batch: int = 0,
heads: list[int] | None = None,
figsize_per_cell: tuple[float, float] = (2.2, 2.2),
show_colorbar: bool = True,
vmin: float = 0.0,
vmax: float = 1.0,
*,
show: bool = True,
save_path: str | Path | None = None,
):
"""Plot a single layer as a 3-row (enc self / dec self / dec cross) grid."""
plt = _require_matplotlib()
enc_layers = maps_dict["enc_self"]
dec_self_layers = maps_dict["dec_self"]
dec_cross_layers = maps_dict["dec_cross"]
enc_idx = min(layer_idx, len(enc_layers) - 1)
dec_idx = min(layer_idx, len(dec_self_layers) - 1)
enc = enc_layers[enc_idx][batch]
dec_self = dec_self_layers[dec_idx][batch]
dec_cross = dec_cross_layers[dec_idx][batch]
num_heads = enc.size(0)
heads = list(range(num_heads)) if heads is None else heads
rows = 3
cols = len(heads)
fig_width = max(1, int(round(figsize_per_cell[0] * cols)))
fig_height = max(1, int(round(figsize_per_cell[1] * rows)))
fig, axes = plt.subplots(
rows, cols, figsize=(fig_width, fig_height), squeeze=False, constrained_layout=True
)
images = []
for column, head in enumerate(heads):
images.append(
_imshow(axes[0, column], enc[head].cpu().float().numpy(), f"Enc h{head}", vmin, vmax)
)
_imshow(
axes[1, column], dec_self[head].cpu().float().numpy(), f"Dec self h{head}", vmin, vmax
)
_imshow(
axes[2, column], dec_cross[head].cpu().float().numpy(), f"Dec cross h{head}", vmin, vmax
)
if show_colorbar and images:
fig.colorbar(images[0], ax=axes, fraction=0.02, pad=0.01)
fig.suptitle(f"Layer {layer_idx}", fontsize=12)
if save_path is not None:
save_path = Path(save_path)
save_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(save_path, dpi=150, bbox_inches="tight")
if show:
plt.show()
else:
plt.close(fig)
return fig
def plot_all_layers_all_heads(
maps_dict: dict,
batch: int = 0,
max_layers: int | None = None,
heads: list[int] | None = None,
figsize_per_cell: tuple[float, float] = (2.0, 2.0),
vmin: float = 0.0,
vmax: float = 1.0,
save_pdf_path: str | None = None,
*,
show: bool = True,
):
"""Render attention grids for every layer (optionally saving a multi-page PDF)."""
plt = _require_matplotlib()
from matplotlib.backends.backend_pdf import PdfPages # type: ignore
enc_layers = len(maps_dict["enc_self"])
dec_layers = len(maps_dict["dec_self"])
total_layers = max(enc_layers, dec_layers)
if max_layers is not None:
total_layers = min(total_layers, max_layers)
def _render_layers(record_page):
for layer in range(total_layers):
fig = plot_layer_heads_grid(
maps_dict,
layer_idx=layer,
batch=batch,
heads=heads,
figsize_per_cell=figsize_per_cell,
show_colorbar=True,
vmin=vmin,
vmax=vmax,
show=show,
)
if record_page is not None:
record_page(fig)
plt.close(fig)
if save_pdf_path:
with PdfPages(save_pdf_path) as pdf:
_render_layers(pdf.savefig)
print(f"[saved] multi-page attention viewer -> {save_pdf_path}")
else:
_render_layers(None)
def _format_token_sequence(tokens: list[str]) -> str:
return " | ".join(tokens)
def _resolve_attention_layers(available: int, requested: Sequence[int] | None) -> list[int]:
if requested is None:
return list(range(available))
return sorted({int(idx) for idx in requested if 0 <= int(idx) < available})
def _resolve_attention_heads(available: int, requested: Sequence[int] | None) -> list[int]:
if requested is None:
return list(range(available))
return sorted({int(idx) for idx in requested if 0 <= int(idx) < available})
# -----------------------------------------------------------------------------
# Debug helper
# -----------------------------------------------------------------------------
def debug_transformer_forward(
model,
tokenizer,
src_ids: torch.Tensor,
tgt_ids: torch.Tensor,
*,
logits: torch.Tensor | None = None,
pad_id: int,
device: str | torch.device = "cpu",
batch_index: int = 0,
sample_index: int = 0,
show_attention: bool = False,
save_attention: bool = False,
average_heads: bool = False,
attention_types: Sequence[str] = ("enc_self", "dec_self", "dec_cross"),
attention_layers: Sequence[int] | None = None,
attention_heads: Sequence[int] | None = None,
attention_figsize: tuple[float, float] = (4.0, 4.0),
skip_special_tokens: bool = False,
log_fn: Callable[[str], None] | None = print,
return_maps: bool = False,
save_dir: str | Path | None = None,
run_dir: str | Path | None = None,
) -> dict[str, Any]:
"""Run a forward pass, compute diagnostics, and optionally render attention heatmaps."""
if log_fn is None:
def log_fn(_: str) -> None: # type: ignore[redefinition]
return
device = torch.device(device)
if src_ids.dim() != 2 or tgt_ids.dim() != 2:
raise ValueError("src_ids and tgt_ids must be rank-2 tensors")
batch_size = src_ids.size(0)
if not (0 <= sample_index < batch_size):
raise IndexError(f"sample_index {sample_index} out of range for batch size {batch_size}")
src_ids = src_ids.to(device)
tgt_ids = tgt_ids.to(device)
if tgt_ids.size(1) < 2:
raise ValueError("tgt_ids must contain at least BOS and one target token")
decoder_in = tgt_ids[:, :-1]
labels = tgt_ids[:, 1:]
src_pad_mask = src_ids.eq(pad_id)
tgt_pad_mask = decoder_in.eq(pad_id)
tgt_pad_full = tgt_ids.eq(pad_id)
was_training = model.training
attention_requested = show_attention or save_attention or return_maps
if logits is None:
if not callable(model):
raise TypeError("model must be callable")
model.eval()
with torch.no_grad():
logits = model(src_ids, decoder_in, src_pad_mask, tgt_pad_mask)
model.train(was_training)
else:
logits = logits.to(device)
if logits.dim() != 3 or logits.size(0) != batch_size:
raise ValueError("logits must be of shape [batch, seq_len, vocab]")
vocab_size = logits.size(-1)
mask = labels.ne(pad_id)
with torch.no_grad():
log_probs = F.log_softmax(logits, dim=-1)
nll = F.nll_loss(
log_probs.reshape(-1, vocab_size),
labels.reshape(-1),
reduction="sum",
ignore_index=pad_id,
)
tokens_total = int(mask.sum().item())
avg_nll = (nll / max(tokens_total, 1)).item()
perplexity = math.exp(avg_nll) if tokens_total > 0 else float("nan")
pred_ids = logits.argmax(dim=-1)
correct_mask = pred_ids.eq(labels) & mask
correct_tokens = int(correct_mask.sum().item())
token_accuracy = correct_tokens / max(tokens_total, 1)
sample_labels = labels[sample_index]
sample_preds = pred_ids[sample_index]
sample_mask = mask[sample_index]
sample_tokens_total = int(sample_mask.sum().item())
sample_correct_tokens = int((sample_preds.eq(sample_labels) & sample_mask).sum().item())
sample_token_accuracy = sample_correct_tokens / max(sample_tokens_total, 1)
sample_exact_match = bool(torch.equal(sample_preds[sample_mask], sample_labels[sample_mask]))
sample_decoder_in = decoder_in[sample_index]
sample_pred_sequence = torch.cat([sample_decoder_in[:1], sample_preds], dim=0)
with torch.no_grad():
sample_log_probs = F.log_softmax(logits[sample_index], dim=-1)
sample_nll = F.nll_loss(
sample_log_probs,
sample_labels,
reduction="sum",
ignore_index=pad_id,
)
sample_avg_nll = (sample_nll / max(sample_tokens_total, 1)).item()
sample_perplexity = math.exp(sample_avg_nll) if sample_tokens_total > 0 else float("nan")
sample_src_ids = src_ids[sample_index]
sample_tgt_ids = tgt_ids[sample_index]
sample_src_tokens = tokenizer.convert_ids_to_tokens(
sample_src_ids.tolist(),
skip_special_tokens=skip_special_tokens,
)
sample_tgt_tokens = tokenizer.convert_ids_to_tokens(
sample_tgt_ids.tolist(),
skip_special_tokens=skip_special_tokens,
)
sample_pred_tokens = tokenizer.convert_ids_to_tokens(
sample_pred_sequence.tolist(),
skip_special_tokens=skip_special_tokens,
)
sample_src_text = tokenizer.decode(sample_src_ids.tolist(), skip_special_tokens=True)
sample_tgt_text = tokenizer.decode(sample_tgt_ids.tolist(), skip_special_tokens=True)
sample_pred_text = tokenizer.decode(sample_pred_sequence.tolist(), skip_special_tokens=True)
log_fn("\n--- DEBUG TRANSFORMER FORWARD ---")
log_fn(f"Batch index : {batch_index}")
log_fn(f"Sample index: {sample_index}")
log_fn(f"Batch token accuracy: {token_accuracy * 100:.2f}% ({correct_tokens}/{tokens_total})")
log_fn(
f"Batch NLL / ppl : {avg_nll:.4f} / {perplexity:.4f}"
if tokens_total > 0
else "Batch NLL / ppl : n/a"
)
log_fn(
f"Sample token accuracy: {sample_token_accuracy * 100:.2f}% ({sample_correct_tokens}/{sample_tokens_total})"
)
log_fn(
f"Sample NLL / ppl : {sample_avg_nll:.4f} / {sample_perplexity:.4f}"
if sample_tokens_total > 0
else "Sample NLL / ppl : n/a"
)
log_fn(f"Sample exact match : {sample_exact_match}")
log_fn("-- Source sequence --")
log_fn(f"IDs : {sample_src_ids.tolist()}")
log_fn(f"Tokens: {_format_token_sequence(sample_src_tokens)}")
log_fn(f"Text : {sample_src_text}")
log_fn("-- Target sequence --")
log_fn(f"IDs : {sample_tgt_ids.tolist()}")
log_fn(f"Tokens: {_format_token_sequence(sample_tgt_tokens)}")
log_fn(f"Text : {sample_tgt_text}")
log_fn("-- Predicted sequence --")
log_fn(f"IDs : {sample_pred_sequence.tolist()}")
log_fn(f"Tokens: {_format_token_sequence(sample_pred_tokens)}")
log_fn(f"Text : {sample_pred_text}")
result: dict[str, Any] = {
"batch_index": batch_index,
"token_accuracy": token_accuracy,
"avg_negative_log_likelihood": avg_nll,
"perplexity": perplexity,
"tokens_total": tokens_total,
"correct_tokens": correct_tokens,
"sample": {
"index": sample_index,
"token_accuracy": sample_token_accuracy,
"exact_match": sample_exact_match,
"avg_negative_log_likelihood": sample_avg_nll,
"perplexity": sample_perplexity,
"source_ids": sample_src_ids.tolist(),
"target_ids": sample_tgt_ids.tolist(),
"predicted_ids": sample_pred_sequence.tolist(),
"source_tokens": sample_src_tokens,
"target_tokens": sample_tgt_tokens,
"predicted_tokens": sample_pred_tokens,
"source_text": sample_src_text,
"target_text": sample_tgt_text,
"predicted_text": sample_pred_text,
},
}
if attention_requested:
plt = _require_matplotlib()
if not hasattr(model, "embed"):
raise AttributeError("model must expose an embed() method for debugging")
with torch.no_grad():
attention_maps = extract_all_attention_maps(
model,
src_ids,
tgt_ids,
src_pad_mask,
tgt_pad_full,
)
target_types = [t for t in attention_types if t in attention_maps]
if not target_types:
target_types = [k for k in ("enc_self", "dec_self", "dec_cross") if k in attention_maps]
target_dir: Path | None = None
if save_attention:
if save_dir is not None:
target_dir = Path(save_dir)
elif run_dir is not None:
target_dir = Path(run_dir) / "debug" / "attention"
else:
log_fn(
"[warn] save_attention requested but no save_dir/run_dir provided; skipping save."
)
if target_dir is not None:
target_dir.mkdir(parents=True, exist_ok=True)
sample_label = f"batch{batch_index}_sample{sample_index}"
figure_records: list[dict[str, Any]] = []
for att_type in target_types:
layer_maps = attention_maps.get(att_type, [])
if not layer_maps:
continue
layers_to_plot = _resolve_attention_layers(len(layer_maps), attention_layers)
for layer_idx in layers_to_plot:
layer_tensor = layer_maps[layer_idx][sample_index]
head_candidates = _resolve_attention_heads(layer_tensor.size(0), attention_heads)
if not head_candidates:
continue
if average_heads:
averaged = layer_tensor[head_candidates].mean(dim=0).cpu().float().numpy()
fig, ax = plt.subplots(figsize=attention_figsize)
im = ax.imshow(averaged, aspect="auto", vmin=0.0, vmax=1.0)
ax.set_title(f"{att_type} L{layer_idx} (avg heads)")
ax.set_xlabel("Key index")
ax.set_ylabel("Query index")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
save_path = None
if target_dir is not None:
save_path = target_dir / f"{sample_label}_{att_type}_L{layer_idx}_avg.png"
fig.savefig(save_path, dpi=150, bbox_inches="tight")
if show_attention:
plt.show()
else:
plt.close(fig)
figure_records.append(
{
"type": att_type,
"layer": layer_idx,
"heads": "average",
"path": str(save_path) if save_path else None,
}
)
else:
cols = len(head_candidates)
fig, axes = plt.subplots(
1,
cols,
figsize=(attention_figsize[0] * cols, attention_figsize[1]),
squeeze=False,
constrained_layout=True,
)
for col, head_idx in enumerate(head_candidates):
ax = axes[0, col]
head_map = layer_tensor[head_idx].cpu().float().numpy()
im = ax.imshow(head_map, aspect="auto", vmin=0.0, vmax=1.0)
ax.set_title(f"{att_type} L{layer_idx} H{head_idx}")
ax.set_xlabel("Key index")
if col == 0:
ax.set_ylabel("Query index")
fig.colorbar(im, ax=axes.ravel().tolist(), fraction=0.046, pad=0.04)
save_path = None
if target_dir is not None:
head_tag = "-".join(str(h) for h in head_candidates)
save_path = (
target_dir / f"{sample_label}_{att_type}_L{layer_idx}_H{head_tag}.png"
)
fig.savefig(save_path, dpi=150, bbox_inches="tight")
if show_attention:
plt.show()
else:
plt.close(fig)
figure_records.append(
{
"type": att_type,
"layer": layer_idx,
"heads": head_candidates,
"path": str(save_path) if save_path else None,
}
)
if target_dir is not None:
raw_path = target_dir / f"{sample_label}_attention.pt"
torch.save(
{k: [v.cpu() for v in tensors] for k, tensors in attention_maps.items()}, raw_path
)
result.setdefault("attention", {})["raw_path"] = str(raw_path)
summary_path = target_dir / f"{sample_label}_attention_all_layers.pdf"
plot_all_layers_all_heads(
attention_maps,
batch=sample_index,
figsize_per_cell=attention_figsize,
save_pdf_path=str(summary_path),
show=False,
)
result.setdefault("attention", {})["summary_path"] = str(summary_path)
if figure_records:
result.setdefault("attention", {})["figures"] = figure_records
if return_maps:
result["attention_maps"] = attention_maps
log_fn("--- END DEBUG ---\n")
return result
# -----------------------------------------------------------------------------
# Config compatibility checker
# -----------------------------------------------------------------------------
def check_tokenizer_model_compatibility(model_cfg, tokenizer_cfg):
"""Ensure tokenizer and model configuration agree on core vocabulary settings."""
if model_cfg.vocab_size != tokenizer_cfg.vocab_size:
raise ValueError(
f"Vocab size mismatch: model={model_cfg.vocab_size}, tokenizer={tokenizer_cfg.vocab_size}"
)
if model_cfg.max_seq_len != tokenizer_cfg.max_seq_len:
raise ValueError(
f"Max sequence length mismatch: model={model_cfg.max_seq_len}, tokenizer={tokenizer_cfg.max_seq_len}"
)
if model_cfg.pad_id != tokenizer_cfg.pad_id:
raise ValueError(
f"pad_id mismatch: model={model_cfg.pad_id}, tokenizer={tokenizer_cfg.pad_id}"
)
if model_cfg.bos_id != tokenizer_cfg.bos_id:
raise ValueError(
f"bos_id mismatch: model={model_cfg.bos_id}, tokenizer={tokenizer_cfg.bos_id}"
)
if model_cfg.eos_id != tokenizer_cfg.eos_id:
raise ValueError(
f"eos_id mismatch: model={model_cfg.eos_id}, tokenizer={tokenizer_cfg.eos_id}"
)