Worked examples — Handling variable-length sequences
This page is the worked-example gauntlet for Handling variable-length sequences. The parent note gave you the machinery: masking, packing, dynamic unrolling, and attention. Here we drive that machinery through every case it can hit — every length pattern, every degenerate input, the limiting cases, a real word problem, and an exam twist.
Before we compute anything, one promise: every symbol is earned before it is used. If a term shows up, it was defined in the parent note or is defined here in plain words first.
The scenario matrix
Think of "variable-length handling" as a machine with dials. Each cell below is a distinct way the world sets those dials. If we work one example per cell, the reader never meets an unshown situation.
| # | Case class | What is unusual about it | Covered by |
|---|---|---|---|
| C1 | Ordinary batch — several sequences, all different lengths | The baseline: pad → mask → normalise | Example 1 |
| C2 | Masked loss normalisation | Prove shorter ≠ artificially cheaper loss | Example 2 |
| C3 | Degenerate: length-1 sequence | Almost all of the row is padding | Example 3 |
| C4 | Degenerate: all sequences same length | Padding does nothing — must still be correct | Example 4 |
| C5 | Packing bookkeeping | Reconstruct batch_sizes from lengths and back |
Example 5 |
| C6 | Attention + -∞ mask |
Softmax must send padding weight to exactly 0 | Example 6 |
| C7 | Limiting case: score scale | What happens as dimension grows | Example 7 |
| C8 | Real-world word problem — chatbot batch cost | Turn padding waste into a number | Example 8 |
| C9 | Exam twist — unsorted lengths break packing | Spot the bug, sort, recover | Example 9 |
Notation we will reuse (all defined here)
Everything below is arithmetic on these seven.
Example 1 — C1: the ordinary batch
Forecast: guess the total number of cells and the fraction that is padding before reading on.
- Find . . Why this step? The batch tensor must be rectangular, so every row is stretched to the longest row.
- Pad each row to width 4.
Why this step? Appending (the
<PAD>id) fills the slots without inventing meaning — as long as we later mask them. - Build the mask (1 = real, 0 = pad), positions : Why this step? The mask is the ground truth of which cells count; see the picture below.
- Count. Total cells . Real cells . Waste .
Figure below (s01): the padded grid drawn in blueprint style — real token cells are cyan (labelled with their token id), padding cells are outlined in amber and labelled "PAD". An amber arrow points to the padding block (top-right), a cyan arrow to the real tokens, and the true length is annotated at the end of each row. It is the picture of "8 real, 4 wasted".

Verify: waste fraction . Sanity: only row 3 (the short "AI") should waste much — it contributes of the wasted cells, and row 1 contributes . . ✓
Example 2 — C2: why we divide by the mask sum
Forecast: which version makes the short sentence look "better" than it deserves?
- Normalised (correct) loss. Why this step? Dividing by averages over real tokens only.
- Wrong version — divide by . Why this step? The padding term still occupies a slot in the denominator, diluting the average.
- Interpret. The wrong number pretends the model did better on the short sentence purely because it is short. Why this step? Optimisers chase lower loss — a length-dependent bias would push the model to favour short sequences.
Verify: with true per-token loss exactly , the correct average must equal regardless of length, while the un-normalised form gives . Since , the un-normalised form is provably biased. ✓ (This is the algebra behind the parent's "shorter sequences would have artificially lower losses".)
Example 3 — C3: the length-1 degenerate row
Forecast: the short row is almost entirely padding — what does that do to its per-token weight?
- Mask row for sequence 2, padded to , positions : Why this step? Only position is real; positions are pad.
- Per-token weight. Its loss is , where is the true token and the model's prediction at the single real position (both defined in the notation box). Why this step? The single real token carries all the weight ; the normaliser is , so no dilution.
- Waste fraction of that row. Real , padded width , waste . Why this step? This is exactly why packing exists — dynamic unrolling / packing would skip those five dead cells entirely.
Verify: the normaliser can never be here because for any real sequence, so division is always safe. Row waste ✓
Example 4 — C4: all lengths equal (padding does nothing)
Forecast: if nothing needs padding, does the masked-loss formula still give the plain average?
- ; no row is stretched. Why this step? Padding is defined relative to the longest row — here every row is the longest.
- Mask is all ones (positions ): Why this step? Every position is real, so no zeros.
- Masked loss collapses to the plain mean. For each row . Why this step? With the mask factors out — proving our formula degrades gracefully to the ordinary average.
Verify: waste . ✓ The general formula must reduce to the un-masked one in this limit, and it does.
Example 5 — C5: packing bookkeeping both directions
Forecast: batch_sizes counts sequences still "alive" at each step — sketch that count in your head.
Recall our fixed 1-based convention: columns are , and a row of true length is alive exactly at .
- Draw the padded grid (rows = sequences a,b,c; columns = time ):
Figure below (s02): the same blueprint grid, but now each real cell is labelled with its token name and 1-based time index ( for the length-4 row; ; just ). Dead cells are amber dashes. Along the bottom, amber numbers give the alive count for each column — that row of numbers is the batch_sizes array .

- Count living sequences per column. At each time , how many rows still have a real token (i.e. )? Why this step? (length 1) is alive only at ; (length 3) is alive at ; (length 4) is alive at . So has all three, and have , and has only . (PyTorch stores this array 0-indexed internally, but the meaning of each entry is "alive count at the -th step" — we keep our labels.)
- Check the sum. , and . Why this step? Every real token is stored exactly once; the two counts must agree — that is the invariant packing relies on.
Verify: batch_sizes . The packed data therefore has 8 rows, holding zero padding. ✓
Recall Why descending order?
Question: why must sequences be sorted longest-first before packing? ::: Because batch_sizes is non-increasing — at each step the still-alive sequences form a prefix of the rows, so the RNN processes rows with no gaps.
Example 6 — C6: attention mask sends padding to exactly zero
Forecast: the padding score was the smallest anyway — does masking change the other two weights?
- Softmax without masking (for contrast). With , , , sum : Why this step? Shows padding would wrongly steal of the attention.
- Mask: set , so . New sum . Why this step? before softmax is the standard trick to make a term vanish and renormalise the rest.
- Masked weights: Why this step? The real positions redistribute the padding's share among themselves — not just drop it.
Figure below (s03): a grouped blueprint bar chart, three key positions on the x-axis. Cyan bars = attention weights without the mask (padding still grabs ≈0.14); amber bars = weights after the mask (padding is exactly 0, the two real bars grow taller). An amber arrow marks the padding bar collapsing to zero. It shows visually that the reclaimed mass moves to the real keys.

Verify: (probability distribution) and exactly. ✓ Note the real weights grew (0.6285→0.7311) because the padding mass was reclaimed.
Example 7 — C7: limiting case, the scale
Forecast: does the raw score's spread grow slowly or fast as we increase dimension?
- Unscaled standard deviation : Why this step? Large spread pushes softmax into saturation (one weight , rest ), killing gradients.
- Scaled variance. . Why this step? Dividing a random variable by constant divides its variance by ; choosing exactly cancels the growth.
- Limiting behaviour. As the unscaled spread (worse and worse), while the scaled spread stays pinned at — length- and dimension-agnostic.
Verify: and . ✓ across all three dimensions.
Example 8 — C8: real-world word problem (chatbot batch cost)
Here is the average true length defined in the notation box, so .
Forecast: one giant outlier message — guess whether the waste is "a little" or "brutal".
- Total slots . Why this step? Batch processing forces a rectangle at the longest row, so the model allocates work for every one of the cells.
- Real slots . Why this step? Real work is proportional to actual tokens, not padded width; and follows straight from the definition of the average.
- Waste fraction . Why this step? One outlier drags far above the average, so 90%+ of the grid is dead padding — this is the number that justifies bothering with packing.
- Packing speedup fewer RNN steps. Why this step? An RNN's cost scales with the number of tokens processed; packing processes only the real ones, so the compute drops by the ratio of padded to real work.
Verify: , so waste ; speedup . Units: (slots)/(slots) is a dimensionless ratio, as a "fraction" and a "speedup factor" both must be. ✓ This is exactly why production systems either bucket by length (group similar-length messages so ) or pack (skip the padding entirely).
Example 9 — C9: exam twist — the packing bug
Forecast: the numbers are fine — what property of the order is violated?
- Diagnose. Classic
pack_padded_sequencerequires lengths sorted descending; is not. Why this step? Non-descending order breaks the "alive sequences are a prefix" invariant from Example 5, corruptingbatch_sizes. - Sort descending and record where each row came from. Original rows are indexed with lengths . Ordering by length, largest first: Why this step? We must remember the source index of each moved row so we can undo the shuffle later; call this permutation ("sorted position came from original row ").
- Compute
batch_sizeson the sorted lengths , using our 1-based columns (here ). Alive count number of rows with : Why this step? All 3 rows are alive through ; the length-2 row dies after (→ 2 at ); the length-3 row dies after (→ 1 at ). This is now non-increasing, so the invariant holds and packing succeeds. - Restore original order after the RNN. The RNN's outputs come back in sorted order; we need the inverse permutation so that original row is fetched from its sorted slot. Since , its inverse is (original row sits at sorted slot , original row at slot , original row at slot ). Why this step? Applying then returns the identity — the outputs land back in the caller's original ordering, unshuffled.
Verify: batch_sizes . ✓ And applying permutation followed by its inverse returns the identity . ✓
Recall
Recall Test yourself
Why divide the masked loss by and not by ? ::: So each sequence is averaged over its real tokens; dividing by dilutes short sequences and biases the model toward them (Example 2: 2.0 vs wrong 1.5).
In Example 8, what single feature of the batch caused 90% waste? ::: One outlier length-128 message pulling far above the average length 12.
What does each entry of batch_sizes count? ::: The number of sequences still alive (real token present) at that time step; it must be non-increasing, which requires descending-length sorting.
After masking with , why did the real attention weights increase? ::: Softmax renormalises; the padding position's probability mass (≈14%) is redistributed to the real positions (Example 6: 0.6285→0.7311).
Related deep material: Transformers, Sequence-to-Sequence Models, LSTM and GRU.