Visualization¶
Two views of the same decode: what the model looked at, and what the search considered and discarded.
| Question | Functions |
|---|---|
| What did the decoder attend to? | plot_attention, format_attention |
| What did the search consider and prune? | plot_beam_search, format_beam_search |
The second is the one students find least intuitive, because pruning is invisible in the output. A translation tells you what won and never what lost — yet the entire argument for beam search is about paths greedy never explores. See Beam search below.
Attention¶
Overview¶
Each row of an attention matrix is a probability distribution saying "while producing this target token, here is how much I looked at each source token." Rendering that turns an abstraction into something you can check against your own intuition about the sentence pair.
Two renderers are provided:
| Function | Output | Needs |
|---|---|---|
plot_attention |
matplotlib heatmap | matplotlib |
format_attention |
shaded text grid | nothing extra |
format_attention exists because a figure window is not always available — over
SSH, in a log file, in CI, in a doctest.
Quick Start¶
from torchlingo.visualization import format_attention, plot_attention
logits, weights = model(src, tgt, return_attention=True)
src_tokens = src_vocab.indices_to_tokens(src_ids)
tgt_tokens = tgt_vocab.indices_to_tokens(tgt_ids)
print(format_attention(weights, src_tokens, tgt_tokens))
plot_attention(weights, src_tokens, tgt_tokens, title="Luong dot attention")
Both take a single sentence: either [tgt_len, src_len], or
[1, tgt_len, src_len], which is unwrapped for you. A real batch raises, rather
than silently showing you sentence zero — index it yourself with weights[i].
Works on both architectures¶
return_attention=True means the same thing on SimpleSeq2SeqLSTM and
SimpleTransformer, and returns the same (batch, tgt_len, src_len) shape, so
the code above does not care which model you hand it.
On the Transformer the weights come from the last decoder layer with heads
averaged. That is the conventional choice for alignment plots. For every layer,
or per-head maps, use
capture_cross_attention
directly.
Call eval() first
In training mode, attention dropout randomly zeroes weights and rescales the survivors, so rows will not sum to 1 and the picture is not the one inference uses. This is easy to miss because the plot still looks plausible. Check it:
Why the Transformer needs extra machinery and the LSTM does not
The LSTM computes attention in TorchLingo's own code, so returning the weights is a matter of not throwing them away.
The Transformer uses PyTorch's nn.Transformer, and
TransformerDecoderLayer._mha_block calls attention with
need_weights=False hardcoded. That is a deliberate performance choice: it
allows a fused kernel that never materializes the attention matrix at all.
The weights are not hidden behind an option, they are genuinely never
computed, which is why a forward hook on multihead_attn comes back with
None.
capture_cross_attention temporarily replaces each layer's
multihead_attn.forward to force need_weights=True, records what comes
back, and restores the original afterwards — including if the forward pass
raises. The speed cost is why this is opt-in rather than always on.
This is worth knowing beyond TorchLingo. Fast paths that discard intermediate values are common in deep learning libraries, and "the framework will not give me X" often means "X is never computed on the path you are taking."
Attention for the translation the model actually produced¶
model(src, tgt, return_attention=True) tells you where attention went while
processing a translation you supplied. Hand it the reference translation and
you learn where the model would have looked had it produced the right answer —
which is a different question from what it did.
The decoders answer the second one:
from torchlingo.inference import beam_search_decode, greedy_decode
tokens, weights = greedy_decode(model, src, return_attention=True)
tokens, weights = beam_search_decode(model, src, beam_size=5, return_attention=True)
Greedy decodes a batch, so it returns a list of (tgt_len, src_len) tensors,
one per sentence — a list rather than a stacked tensor because decoded sequences
differ in length, and padding them would invent attention rows that were never
computed. Beam search takes one sentence and returns one tensor, for the winning
hypothesis.
Why re-running is exact, and why beam search does not keep weights
Both decoders discard attention as they go. Rather than thread weights
through the search, attention_for_sequence re-runs the finished sequence
in one teacher-forced pass.
That is exact, not an approximation. The decoder is causally masked, so
the state at target position t depends only on tokens up to t. Feeding
the whole sequence at once reproduces each row exactly as the incremental
decode computed it — asserted to floating-point noise in
tests/test_attention_from_decoding.py::ExactnessTests.
For beam search there is a second reason. Weights belong to a hypothesis,
and hypotheses get pruned, so most of what a beam search computes belongs to
candidates that lost. Keeping all of it to discard all but one costs memory
proportional to beam_size for no benefit. This mirrors what the search
already does with scores: it re-scores prefixes rather than caching every
partial result.
A model that got it right¶
EN I want to help you.
BEAM-5 Quiero ayudar.
<sos> ▁I ▁want ▁to ▁help ▁you . <eos>
▁Quiero ··· ░░░ ▒▒▒ ░░░ ··· ··· ··· ···
▁ayud ··· ··· ··· ··· ▒▒▒ ░░░ ··· ···
ar ··· ··· ··· ░░░ ··· ░░░ ░░░ ···
. ··· ··· ··· ··· ░░░ ░░░ ░░░ ···
<eos> ▒▒▒ ··· ··· ··· ··· ··· ··· ▒▒▒
Read the two dark cells. ▁Quiero attends most to ▁want, and ▁ayud attends
most to ▁help. The model has learned that Spanish fuses "I want" into a single
inflected verb and that the alignment is not monotonic — Quiero covers source
positions 1 and 2 at once.
Note also what it dropped. The translation omits "you" entirely, and the ▁you
column is correspondingly faint everywhere. The map is not just showing you a
correct alignment; it is showing you which source word the model never really
used.
A model that got it wrong¶
Same checkpoint, a sentence it handles badly. This is the more common case, and the more instructive one:
EN The cat sleeps on the mat.
ES El código de la catura.
<sos> ▁The ▁c at ▁s le ep s ▁on ▁the ▁mat . <eos>
▁El ··· ▓▓▓ ··· ··· ··· ··· ··· ··· ··· ··· ··· ··· ···
▁c ··· ··· ░░░ ··· ░░░ ··· ··· ··· ··· ··· ··· ··· ···
ó ··· ··· ░░░ ░░░ ░░░ ··· ··· ··· ··· ··· ░░░ ··· ···
d ··· ··· ··· ··· ··· ··· ░░░ ░░░ ··· ··· ░░░ ··· ···
igo ··· ··· ··· ··· ··· ░░░ ░░░ ░░░ ··· ··· ░░░ ··· ···
▁de ··· ··· ··· ··· ··· ··· ··· ··· ░░░ ··· ··· ░░░ ···
▁la ··· ··· ··· ··· ··· ··· ··· ··· ··· ░░░ ··· ░░░ ···
▁c ··· ··· ░░░ ░░░ ░░░ ··· ··· ··· ··· ··· ··· ··· ···
at ··· ··· ░░░ ░░░ ░░░ ··· ··· ··· ··· ··· ░░░ ··· ···
ura ··· ··· ··· ··· ··· ░░░ ░░░ ░░░ ··· ··· ░░░ ··· ···
The translation is wrong, and the map shows how it is wrong, which a correct translation would not.
▁El attends sharply to ▁The — the one word it got right, and the one place
the grid is dark. ▁c and at attend to the ▁c/at pieces of "cat", so the
model half-recognized the word and produced catura. Everywhere else the row is
a flat wash of ░░░: attention spread thinly across the whole source, which is
what "has not learned what to look at" looks like.
Compare that to Tutorial 4's model, trained on a synthetic task with a known correct alignment, where the map is a clean diagonal. The contrast is the lesson: a sharp attention map is evidence the model learned something, and a diffuse one is evidence it did not.
Reading the text grid¶
Rows are target tokens, columns are source tokens, and shading runs
· ░ ▒ ▓ █ from no attention to full attention. Here is a model trained
on the reversal task from examples/attention_alignment.py, where the target is
the source translated word-for-word and reversed:
<sos> bird wants a cat old dog <eos>
<sos> ··· ··· ··· ··· ··· ··· ███ ···
perro ··· ··· ··· ··· ··· ▓▓▓ ░░░ ···
viejo ··· ··· ··· ··· ███ ··· ··· ···
gato ··· ··· ··· ███ ··· ··· ··· ···
un ··· ··· ▓▓▓ ··· ··· ··· ··· ···
quiere ··· ███ ··· ··· ··· ··· ··· ···
pajaro ▓▓▓ ░░░ ··· ··· ··· ··· ··· ···
The anti-diagonal is exactly right: to emit the first target word the decoder
looks at the last source word. Each row is offset by one from the token it
names, because row n is the state that predicts token n + 1.
Pass show_values=True for rounded percentages instead of shading when you need
the actual numbers.
Beam search¶
Beam search keeps several hypotheses alive and discards the rest at every step. The output tells you which one won; it never tells you what was thrown away, and that is exactly where the interesting behaviour is.
Pass a list as trace and the search records every candidate it scored:
from torchlingo.inference import beam_search_decode
from torchlingo.visualization import format_beam_search
trace = []
tokens = beam_search_decode(model, src, beam_size=3, trace=trace)
print(format_beam_search(trace, itos=tgt_vocab.idx2token, winner=tokens))
Tracing is observation only. It never changes the result, and costs nothing when omitted.
Reading the output¶
step 0
+ -1.953 <s> w23
> -2.007 <s> w19
+ -2.214 <s> w4
step 1
+ -3.245 <s> w19 w16
> -3.383 <s> w19 w21
+ -3.501 <s> w4 w16
. -3.543 <s> w23 w19
... 5 more considered
| Marker | Meaning |
|---|---|
> |
kept, and on the path that eventually won |
+ |
kept into the next step |
. |
pruned here |
Look for a > sitting below a +. That is the whole lesson. At step 0 above, the
eventual winner ranked second: greedy decoding would have committed to w23 and never
recovered. Beam search found the better translation only because the beam was wide enough
to carry a hypothesis that did not look best at the time.
If the > is always at the top, beam search did nothing greedy would not have done — and
on an easy sentence that is the common case, which is worth seeing too.
plot_beam_search shows the same thing as scores over steps: kept candidates filled,
pruned ones hollow, and the winning path drawn as a line. The visual question is whether
that line ever dips below other filled points.
API Reference¶
visualization
¶
Visualizing what a model attends to, and what a search considered.
Two things here, answering two different questions about the same decode:
- Attention shows what the decoder looked at while producing each token.
- Beam search shows what it considered and discarded on the way there.
The second is the one students find least intuitive, because pruning is invisible in the output: a translation tells you what won, never what lost, and the whole argument for beam search is about the paths greedy never explores.
Attention weights are the most directly inspectable quantity in an NMT model: each row is a probability distribution saying "while producing this target token, here is how much I looked at each source token." Plotting that matrix turns an abstraction into something a student can check against their own intuition about the sentence pair.
Two renderers are provided:
- :func:
plot_attentiondraws the familiar alignment heatmap with matplotlib. - :func:
format_attentionreturns a shaded text grid instead. It needs nothing beyond the standard library, so it works over SSH, in a plain terminal, in log files, and in doctests -- anywhere a figure window is not available.
Typical usage
import torch weights = torch.tensor([[0.8, 0.1, 0.1], [0.1, 0.2, 0.7]]) print(format_attention(weights, ["el", "gato", "duerme"], ["the", "sleeps"])) el gato duerme the ▓▓▓ ··· ··· sleeps ··· ░░░ ▓▓▓
format_attention
¶
format_attention(weights: Tensor, src_tokens: list[str], tgt_tokens: list[str], show_values: bool = False) -> str
Render an attention matrix as a shaded text grid.
Rows are target tokens, columns are source tokens, and each row sums to 1. Darker cells mean the decoder looked harder at that source token while producing that target token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
weights
|
Tensor
|
Attention weights of shape (tgt_len, src_len), or (1, tgt_len, src_len). |
required |
src_tokens
|
list[str]
|
Source token strings, one per source position. |
required |
tgt_tokens
|
list[str]
|
Target token strings, one per target position. |
required |
show_values
|
bool
|
Print two-digit percentages instead of shading. Useful when you need the exact numbers. Defaults to False. |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
A multi-line string suitable for printing. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the weights are not a single 2-D matrix, or the label counts do not match its shape. |
Examples:
>>> import torch
>>> w = torch.tensor([[0.9, 0.1]])
>>> print(format_attention(w, ["gato", "duerme"], ["cat"], show_values=True))
gato duerme
cat 90 10
Source code in src/torchlingo/visualization.py
plot_attention
¶
plot_attention(weights: Tensor, src_tokens: list[str], tgt_tokens: list[str], title: str | None = None, cmap: str = 'viridis', ax: Axes | None = None) -> Axes
Draw an attention alignment heatmap with matplotlib.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
weights
|
Tensor
|
Attention weights of shape (tgt_len, src_len), or (1, tgt_len, src_len). |
required |
src_tokens
|
list[str]
|
Source token strings, one per source position. |
required |
tgt_tokens
|
list[str]
|
Target token strings, one per target position. |
required |
title
|
str
|
Title for the plot. |
None
|
cmap
|
str
|
Matplotlib colormap name. Defaults to "viridis". |
'viridis'
|
ax
|
Axes
|
Existing axes to draw into. A new figure and axes are created when omitted. |
None
|
Returns:
| Type | Description |
|---|---|
Axes
|
matplotlib.axes.Axes: The axes the heatmap was drawn into. |
Raises:
| Type | Description |
|---|---|
ImportError
|
If matplotlib is unavailable. It ships as a TorchLingo
dependency, so this normally cannot happen; the guard exists for
stripped-down installs, and points at :func: |
ValueError
|
If the weights are not a single 2-D matrix, or the label counts do not match its shape. |
Examples:
>>> import torch
>>> ax = plot_attention(torch.rand(3, 4).softmax(-1),
... ["a", "b", "c", "d"], ["x", "y", "z"])
...
Source code in src/torchlingo/visualization.py
format_beam_search
¶
format_beam_search(trace: list[BeamStep], itos: list[str] | None = None, winner: list[int] | None = None, top: int = 6, max_steps: int | None = None) -> str
Render a beam search as text: what was considered, kept, and discarded.
Produced from the trace argument of
:func:torchlingo.inference.beam_search_decode.
Each step lists candidates in the order the search ranked them. The marker in the first column is the point of the whole display:
========== ==========================================================
> kept, and a prefix of the hypothesis that eventually won
+ kept into the next step
. pruned here
========== ==========================================================
The steps worth looking at are the ones where a > sits below a +:
the eventual winner ranked below another hypothesis at that moment and
survived only because the beam was wide enough to carry it. That is exactly
the situation greedy decoding cannot recover from, and it is otherwise
invisible in the output.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trace
|
list[BeamStep]
|
Steps recorded during decoding. |
required |
itos
|
list[str]
|
Index-to-token mapping. Raw ids are shown when omitted. |
None
|
winner
|
list[int]
|
The returned token sequence, used to mark which candidates were on the winning path. |
None
|
top
|
int
|
Candidates to show per step. Defaults to 6. |
6
|
max_steps
|
int
|
Steps to show. All of them when omitted. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
A multi-line string suitable for printing. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the trace is empty. |
Examples:
>>> from torchlingo.inference import BeamCandidate, BeamStep
>>> step = BeamStep(0, [BeamCandidate([2, 7], -0.2, -0.2, True)])
>>> print(format_beam_search([step], itos=["<pad>", "<unk>", "<s>", "</s>", "", "", "", "hola"]))
step 0
+ -0.200 <s> hola
Source code in src/torchlingo/visualization.py
plot_beam_search
¶
plot_beam_search(trace: list[BeamStep], winner: list[int] | None = None, top: int = 6, title: str | None = None, ax: Axes | None = None) -> Axes
Plot candidate scores per step, separating survivors from pruned paths.
Kept candidates are drawn filled, pruned ones hollow, and the winning path is connected by a line. The visual question the plot answers is whether the winning line ever dips below other kept points — which is precisely when beam search earns its cost over greedy.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trace
|
list[BeamStep]
|
Steps recorded during decoding. |
required |
winner
|
list[int]
|
The returned token sequence. |
None
|
top
|
int
|
Candidates to plot per step. |
6
|
title
|
str
|
Title for the plot. |
None
|
ax
|
Axes
|
Existing axes to draw into. |
None
|
Returns:
| Type | Description |
|---|---|
Axes
|
matplotlib.axes.Axes: The axes drawn into. |
Raises:
| Type | Description |
|---|---|
ImportError
|
If matplotlib is unavailable. |
ValueError
|
If the trace is empty. |