Text and image preprocessing layers

Keras preprocessing layers, TextVectorization and StringLookup, and building resizing and augmentation into the model so training and serving agree.

Why preprocessing belongs in the model

If tokenisation and normalisation live in Python before the model, then every serving path must reimplement them exactly. Putting them inside the model as layers makes the transformation part of the saved artefact, so training and inference cannot drift apart.

import tensorflow as tf

vectoriser = tf.keras.layers.TextVectorization(
    max_tokens=20_000,        # vocabulary size, OOV index 0 is prepended
    output_mode="int",
    output_sequence_length=64,
    standardize="lower_and_strip_punctuation",
    ngrams=None,
)
vectoriser.adapt(train_texts)      # learns the vocabulary from training data only
print(vectoriser.get_vocabulary()[:10])
print(vectoriser(["The quick brown fox"]).shape)     # (1, 64)
  • adapt must run on training data only. Adapting on all data leaks the vocabulary, which is mild here but is exactly the same mistake as fitting a scaler on the test set.
  • output_sequence_length truncates and pads to a fixed length. Without it, a batch of variable-length sequences fails to stack.
  • max_tokens caps the vocabulary; rarer words map to the OOV index. Choose it from the token frequency curve rather than a round number.
  • standardize accepts a callable or a named mode. If you use a custom normaliser during experimentation, put it here, not in a separate function, or serving will differ.

An end-to-end text classifier

text_input = tf.keras.Input(shape=(1,), dtype=tf.string, name="text")
x = vectoriser(text_input)                       # (None, 1) -> (None, 64)
x = tf.keras.layers.Embedding(
    input_dim=len(vectoriser.get_vocabulary()),
    output_dim=32,
    mask_zero=True,                              # ignore the padding positions
)(x)
x = tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(32))(x)
x = tf.keras.layers.Dropout(0.3)(x)
out = tf.keras.layers.Dense(1)(x)

model = tf.keras.Model(text_input, out)
model.compile(optimizer="adam",
              loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
              metrics=["accuracy"])

model.fit(tf.constant(train_texts), train_labels, validation_split=0.2, epochs=5)

# the same raw string input works after saving
model.save("text_clf.keras")
restored = tf.keras.models.load_model("text_clf.keras")
restored(tf.constant(["this movie was excellent"]))
  • mask_zero=True makes the embedding produce a mask so recurrent and attention layers skip padding; without it the padding positions are treated as real tokens.
  • Take a string tensor input so the saved model accepts raw text. That is what makes the artefact deployable without an accompanying preprocessing service.
  • TextVectorization is not trainable: its vocabulary is fixed after adapt, and loading the saved model restores it exactly.
  • For short texts, a global pooling over a dense or CNN stack is often faster than an LSTM and just as accurate.

Image preprocessing in the model

IMG = (160, 160, 3)

preprocess = tf.keras.Sequential([
    tf.keras.layers.Resizing(IMG[0], IMG[1]),      # deterministic resize
    tf.keras.layers.Rescaling(1.0 / 255),          # deterministic scaling
], name="resize_scale")

augment = tf.keras.Sequential([
    tf.keras.layers.RandomFlip("horizontal"),
    tf.keras.layers.RandomRotation(0.08),
    tf.keras.layers.RandomContrast(0.1),
], name="augment")

image_input = tf.keras.Input(shape=IMG, dtype=tf.uint8, name="image")
x = preprocess(image_input)
x = augment(x)                                     # active only while training
x = tf.keras.layers.Conv2D(16, 3, padding="same", activation="relu")(x)
x = tf.keras.layers.GlobalAveragePooling2D()(x)
out = tf.keras.layers.Dense(num_classes)(x)
model = tf.keras.Model(image_input, out)

# a uint8 input keeps the pipeline small and the model self-contained
print(model.input_dtype, model.output_shape)
LayerDeterministic?Runs at inference?Purpose
ResizingYesYesFixed input size for the backbone
RescalingYesYesMatch the backbone's expected range
NormalizationYes (adapted)YesPer-feature mean/std from training data
RandomFlip / RandomRotationNoNoAugmentation, training only
StringLookupYes (adapted)YesCategorical ids, OOV index 0
TextVectorizationYes (adapted)YesToken ids for text models
⚠️
Preprocessing layers that require adapt (TextVectorization, Normalization, StringLookup) throw at load time if the adapted state was never saved. Always adapt before the first fit or save, never after.

FAQ

Should I use <code>tf.data</code> augmentation or preprocessing layers?
Layers are the safer default because they travel with the model and cannot drift from serving. Use tf.data when you need transformations that are expensive enough to want on the input workers rather than the accelerator.
How do I keep a tokenizer in sync between training and serving?
Do not maintain two. Put the vectoriser in the model, save the model, and let every consumer load the same artefact. A vocabulary file copied by hand will eventually diverge.

The tf.data pipeline in depth Transfer learning and fine-tuning

Last refreshed 2026-09-18.