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)adaptmust 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_lengthtruncates and pads to a fixed length. Without it, a batch of variable-length sequences fails to stack.max_tokenscaps the vocabulary; rarer words map to the OOV index. Choose it from the token frequency curve rather than a round number.standardizeaccepts 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=Truemakes 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.
TextVectorizationis not trainable: its vocabulary is fixed afteradapt, 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)| Layer | Deterministic? | Runs at inference? | Purpose |
|---|---|---|---|
Resizing | Yes | Yes | Fixed input size for the backbone |
Rescaling | Yes | Yes | Match the backbone's expected range |
Normalization | Yes (adapted) | Yes | Per-feature mean/std from training data |
RandomFlip / RandomRotation | No | No | Augmentation, training only |
StringLookup | Yes (adapted) | Yes | Categorical ids, OOV index 0 |
TextVectorization | Yes (adapted) | Yes | Token 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.
Related
The tf.data pipeline in depth Transfer learning and fine-tuning
Last refreshed 2026-09-18.