Skip to content

Padding, Masking and Variable-Length Sequences

Real sequences have different lengths. Tensors do not. Every sequence model therefore starts with a compromise — pad everything to one length — and that compromise introduces a bug you cannot see in the loss curve, only in the score.

Here is the headline, measured on 5,000 IMDB reviews with an identical SimpleRNN(32), five epochs, four setups:

Paddingmask_zeroBest validation accuracy
preFalse0.5856
preTrue0.5868
postFalse0.5036
postTrue0.5272

Three of those runs learn something. One of them scores chance on a balanced binary task, and nothing in the code looks wrong.

  • What pad_sequences does, and why maxlen is a two-sided trade: at 50 tokens you keep 0.2045 of the text; at 800 tokens 0.6987 of your batch is padding.
  • Why post-padding without a mask destroys a recurrent model but pre-padding survives.
  • What mask_zero=True actually produces — a boolean tensor travelling alongside the data, not zeroed values.
  • Which layers respect a mask and which silently ignore it.
  • Why an unmasked GlobalAveragePooling1D divides every review by the wrong number: the mean weight on real tokens was 0.8098, and the shortest review got 0.0800.
  • Bucketing and ragged tensors, and when the padding is cheap enough not to care.

IMDB reviews, 5,000 of them: min 16 tokens, median 182, mean 243.8, p95 633, max 1,851. One tensor has to hold all of them.

The only two decisions that matter
x = keras.preprocessing.sequence.pad_sequences(
    sequences,
    maxlen=200,        # every row becomes exactly this long
    padding="pre",     # where the zeros go:      "pre" = front, "post" = back
    truncating="pre",  # which end is cut off if the row is too long
)
maxlenShare of the batch that is paddingShare of real tokens keptRows truncated
500.00300.20450.9802
1000.03320.39650.8944
2000.19020.66420.4386
4000.46420.87900.1506
8000.69870.98870.0206
figure 5,000 IMDB reviews: the length distribution and what each maxlen costs matplotlib
Two panels. Left: a histogram of IMDB review lengths with a long right tail, a green line at the median of 182 tokens and a red dashed line at maxlen 200. Right: two curves against maxlen on a log axis — the share of the batch that is padding rising from 0.00 to 0.70, and the share of real tokens kept rising from 0.20 to 0.99. Two panels. Left: a histogram of IMDB review lengths with a long right tail, a green line at the median of 182 tokens and a red dashed line at maxlen 200. Right: two curves against maxlen on a log axis — the share of the batch that is padding rising from 0.00 to 0.70, and the share of real tokens kept rising from 0.20 to 0.99.
The two curves cross somewhere around 200 tokens, which is why that value appears in every IMDB tutorial. At 800 the model sees 98.9% of the text and spends 69.9% of its compute on zeros; at 50 it wastes nothing and reads a fifth of each review. There is no setting that is cheap and complete — the distribution has a 1,851-token tail and a 16-token floor.

Padding is not free even when it is harmless: at maxlen=800 a recurrent layer runs 800 timesteps sequentially, of which 559 carry no information.

A recurrent layer’s output is its last hidden state. That is the whole explanation.

diagram Diagram mermaid

With pre-padding the padding comes first, and whatever the state accumulated over those zeros is then overwritten by the actual review. The last step is a real word, so the output is about the review.

With post-padding the last step is padding. For a review 68 tokens long inside a 200-step tensor, the network’s answer is the state after 132 consecutive padding steps — and every one of those steps multiplies the state by the recurrent weights again. Whatever the review contributed decays away, and the measured result is 0.5036: chance.

figure The same SimpleRNN(32), four ways of handling the same data matplotlib
Two panels. Left: a bar chart of four setups — pre-padding unmasked 0.5856, pre-padding masked 0.5868, post-padding unmasked 0.5036 in red, post-padding masked 0.5272. Right: validation accuracy per epoch for the same four, where the two pre-padding curves climb from 0.50 to 0.59 and the two post-padding curves stay between 0.48 and 0.53. Two panels. Left: a bar chart of four setups — pre-padding unmasked 0.5856, pre-padding masked 0.5868, post-padding unmasked 0.5036 in red, post-padding masked 0.5272. Right: validation accuracy per epoch for the same four, where the two pre-padding curves climb from 0.50 to 0.59 and the two post-padding curves stay between 0.48 and 0.53.
The post-padded unmasked run does not diverge, crash or warn — it converges smoothly to chance. Masking recovers about half the damage (0.5036 to 0.5272) but not all of it: the two post-padded runs also see less of each review, because truncating='pre' keeps a different part of the text than the pre-padded runs do. The lesson is not 'masking fixes post-padding', it is 'pre-padding makes the question moot for recurrent layers'.

The default is the safe one, and that is why this bug hides

Section titled “The default is the safe one, and that is why this bug hides”

pad_sequences defaults to padding="pre". Most tutorials therefore work without ever mentioning masking — and the first time you write padding="post" because a transformer tutorial told you to, your accuracy quietly halves.

It does not zero the padded vectors. It attaches a boolean tensor to the output that downstream layers may consult:

python
embedding = keras.layers.Embedding(10000, 4, mask_zero=True)
sample = np.array([[0, 0, 7, 9],
                   [4, 5, 6, 8]])
embedded = embedding(sample)                   # (2, 4, 4) — real numbers everywhere
mask = embedding.compute_mask(sample).numpy()
# [[False False  True  True]
#  [ True  True  True  True]]

With mask_zero=False, compute_mask returns None — there is no mask to ignore, which is why the failure is silent rather than an error.

Measured on a freshly seeded layer: the padded position’s embedding vector has v=0.0939\sum |v| = 0.0939, not zero. Token id 0 is an ordinary row of the embedding matrix and it gets ordinary gradients like any other. The mask is metadata; a layer that ignores it sees those numbers as data.

LayerRespects a mask?
SimpleRNN, LSTM, GRUyes — stops updating the state at masked steps and repeats the last real state
Bidirectionalyes, in both directions
GlobalAveragePooling1Dyes — since TF 2.x it divides by the unmasked count
GlobalMaxPooling1Dno in older versions; check your version
Dense, Conv1Dno — they process every position identically
Flattenno, and it destroys the mask
MultiHeadAttentiononly if you pass attention_mask yourself

The two dangerous rows are Conv1D and Dense: they consume the padded vectors as though they were words, and they never complain.

GlobalAveragePooling1D over an unmasked padded batch computes

1Tt=1Tvt=LT1Ltrealvtwhat you wanted+TLTvpad\frac{1}{T}\sum_{t=1}^{T} v_t = \frac{L}{T}\cdot\underbrace{\frac{1}{L}\sum_{t \in \text{real}} v_t}_{\text{what you wanted}} + \frac{T - L}{T}\cdot v_{\text{pad}}

where LL is the true length and TT is maxlen. Every review is scaled by L/TL/T — a factor that varies per row and encodes nothing but length.

figure How much of an averaged review is actually the review matplotlib
Two panels. Left: a histogram of the weight an unmasked average gives to real tokens, with a large spike at 1.0 for truncated reviews, a spread between 0.2 and 1.0, and a dashed line at the mean of 0.8098. Right: four bars — pre-padding unmasked 0.8004 and masked 0.8092, post-padding unmasked 0.7916 and masked 0.7992. Two panels. Left: a histogram of the weight an unmasked average gives to real tokens, with a large spike at 1.0 for truncated reviews, a spread between 0.2 and 1.0, and a dashed line at the mean of 0.8098. Right: four bars — pre-padding unmasked 0.8004 and masked 0.8092, post-padding unmasked 0.7916 and masked 0.7992.
The spike at 1.0 is the 43.9% of reviews long enough to be truncated — they have no padding at all. The tail reaching 0.2 is short reviews, and the shortest in this sample gets weight 0.0800: 92% of its pooled vector is the embedding of the padding token. Masking recovered +0.0088 (pre) and +0.0076 (post). Small, because the network can partly learn to compensate — and free, because the fix is one keyword.

Two honest observations about that measurement:

  1. The gain is small. +0.0088 is not the difference between working and broken. A trained embedding can push the padding token towards zero and partly cancel the dilution itself, which is exactly what it does here.
  2. It is still worth taking. One keyword, no cost, and it removes a length signal the model was free to abuse. On tasks where sequence length correlates with the label — support tickets, medical notes — an unmasked average lets the model cheat by reading the length instead of the content.

Also worth noting: the averaging model scored 0.8004, while the best SimpleRNN(32) on this page managed 0.5868. Averaging the embeddings and throwing away all order beat the recurrent layer by 0.21. That is not a masking result, it is a warning about SimpleRNN over 200 timesteps — the subject of the next page.

ApproachHowWhen
Pad to a global maxlenpad_sequencesdefault; simple, wasteful on skewed data
Bucketing by lengthtf.data + bucket_by_sequence_lengthlong-tailed lengths; each batch pads to its own longest row
Ragged tensorstf.ragged.constant, keras.layers.Input(ragged=True)genuinely variable length, layers that support it
Truncate hardsmall maxlenwhen the signal is front-loaded — sentiment often is

Bucketing is the practical win, and Exercise 6 measures it: padding all 5,000 reviews to the longest one (1,851 tokens) makes 0.8683 of the batch padding; five length buckets bring that to 0.3207 — 0.5476 of the compute recovered with no change to the model and no text discarded.

Bucketing, in the shape you will actually use it
dataset = tf.data.Dataset.from_generator(...)          # variable-length rows
dataset = dataset.bucket_by_sequence_length(
    element_length_func=lambda x, y: tf.shape(x)[0],
    bucket_boundaries=[100, 200, 400, 800],
    bucket_batch_sizes=[64, 64, 32, 16, 8],            # one more than boundaries
)
sketch Padding, masking and the last hidden state p5.js
Toggle pre/post padding and the mask, and watch which step produces the output the classifier sees.
sketch The measured table, ranked p5.js
Click a column to rank every row by it. The bars are that column's values and the highest and lowest are computed from the numbers, not written in.
  • padding="post" with a recurrent layer and no mask. Measured 0.5036 — chance — with no error, no warning and a smooth loss curve.
  • Assuming mask_zero=True zeroes the padded vectors. It does not: the measured padded embedding had v=0.1477\sum|v| = 0.1477. It attaches a boolean mask that layers may ignore.
  • Putting Flatten or Dense after a masked embedding. The mask is dropped and every padded position becomes a feature.
  • Reserving id 0 for a real token. mask_zero=True means id 0 is padding. If your vocabulary starts at 0, every occurrence of that word is masked away.
  • Choosing maxlen from the maximum length. At 800 here, 69.9% of the batch is padding to accommodate a 1,851-token tail. Use a percentile — p95 was 633 — or bucket.
  • Truncating the wrong end. truncating="pre" keeps the end of the text. For sentiment that is often right; for a news headline task it is exactly wrong.
  • Comparing a padded model against an unpadded baseline. The padded model sees less text; that is a data difference, not an architecture result.
  • pad_sequences(maxlen=..., padding=..., truncating=...) is the only place these decisions are made, and its defaults (pre, pre) are the safe ones for RNNs.
  • On 5,000 IMDB reviews: maxlen=200 keeps 0.6642 of the tokens and spends 0.1902 of the batch on padding; maxlen=800 keeps 0.9887 and spends 0.6987.
  • A recurrent layer outputs its last hidden state, so post-padding hands the classifier the state after the zeros: 0.5036 against 0.5856 for pre-padding.
  • mask_zero=True attaches a boolean mask; RNNs, Bidirectional and GlobalAveragePooling1D honour it, while Dense, Conv1D and Flatten do not.
  • An unmasked average weights the real tokens by L/TL/T — mean 0.8098, minimum 0.0800 in this sample — and masking bought +0.0088.
  • Bucketing by length is the fix that scales: padding everything to 1,851 tokens wastes 0.8683 of the batch, five buckets waste 0.3207.

SimpleRNN managed 0.5868 here while averaging the embeddings scored 0.8004. The gates that fix it — and the exact reason a plain recurrent state cannot hold information for 200 steps — are next: LSTM & GRU Networks.

pch.quizTag pch.quizDefaultTitle
  1. An identical SimpleRNN scored 0.5856 with pre-padding and 0.5036 with post-padding. Why?

    pch.quizShowAnswer

    B — A recurrent layer's output is its last hidden state — with post-padding that state is the one after all the zeros, so the review's contribution has decayed away — 0.5036 on a balanced binary task is chance, and nothing in the code or the loss curve indicates a problem.

  2. What does mask_zero=True actually do to the padded positions?

    pch.quizShowAnswer

    B — Nothing to the values — the measured padded vector still had sum|v| = 0.1477. It attaches a boolean mask tensor that downstream layers may consult — Which is why a Dense or Conv1D layer after the embedding happily consumes padding as data.

  3. On this corpus, maxlen=800 keeps 0.9887 of the real tokens. What does it cost?

    pch.quizShowAnswer

    B — 0.6987 of every batch becomes padding, and a recurrent layer runs all 800 timesteps sequentially, so most of the compute processes zeros — The 1,851-token maximum dictates that setting for the sake of a handful of rows; the p95 was 633 and bucketing avoids the choice entirely.

  4. An unmasked GlobalAveragePooling1D over pre-padded reviews gave the real tokens a mean weight of 0.8098, and 0.0800 for the shortest review. What is the risk?

    pch.quizShowAnswer

    B — Every review is scaled by its own length/maxlen ratio, which injects a length signal the model can use instead of the content — and short reviews are mostly the padding embedding — Masking bought only +0.0088 here because the trained embedding partly cancels the dilution itself, but the fix is one keyword and it removes the shortcut.

  5. Which of these layers will silently treat padded positions as real data?

    pch.quizShowAnswer

    B — Conv1D and Dense — neither consults the mask, and Flatten destroys it — Recurrent layers, Bidirectional and modern GlobalAveragePooling1D honour the mask; convolutional and dense layers process every position identically.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading