Module 3 — Post-training quantization
Module 2 gave us a 9.20 MB float32 baseline. Post-training quantization is the cheapest tool to shrink it: no retraining, no data touch (or almost none), one flag on the converter. Three modes exist, they behave differently, and picking the wrong one is the difference between a mobile app that ships and one that regresses in silence.
What quantization actually does
A neural network multiplies inputs by weights and sums the results, several billion times per inference. In float32, each weight and each activation takes 32 bits. Most of those bits carry no useful information for inference: MobileNetV2's weights sit in a range of roughly , and the last dozen bits of precision are pure noise as far as the classification decision is concerned.
Quantization replaces float32 with a lower-precision representation, most often int8. That is a 4x compression at rest and, on hardware that has integer SIMD (essentially every phone made after 2018), a 2x to 4x speed-up. The cost is an approximation error at every layer — small on average, occasionally catastrophic — and the whole game is to keep it small enough not to notice.
Mode 1: dynamic range quantization
The cheapest option, one line:
converter = tf.lite.TFLiteConverter.from_saved_model("models/leaf_classifier/1")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_model = converter.convert()
Weights are stored as int8 on disk, then dequantized to float32 on the fly during inference; activations stay in float32. Size drops by roughly 4x — our 9.20 MB becomes about 2.40 MB — and the accuracy drop is essentially zero because the arithmetic itself is still float.
The catch is latency. The runtime spends time converting weights back to float at every layer, so on CPU the gain is modest (10 to 30 percent), and integer accelerators cannot help because the ops themselves are still float. Dynamic range is the safe first move — never worse than the baseline on accuracy — but rarely the endpoint.
Mode 2: full integer quantization
This is the mode that unlocks the real speed-up. Weights and activations become int8, the arithmetic is integer, and integer accelerators (the phone's NNAPI, XNNPACK, coral edge TPU) can run the model natively.
The activation ranges cannot be read off the model file: they depend on the data the model sees. So the converter needs samples to estimate them, and this is where the representative dataset comes in:
def representative_data_gen():
for image_batch in val_dataset.take(200):
# Yield one image at a time, in the exact format the model expects.
yield [tf.image.resize(image_batch[0], [224, 224])[tf.newaxis, ...]]
converter = tf.lite.TFLiteConverter.from_saved_model("models/leaf_classifier/1")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_data_gen
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()
Two hundred samples is usually enough; the converter runs each one through the model to record the min and max activations at every layer, then chooses an int8 range that captures the bulk of the distribution.
Use a slice of your validation data — real images, not augmented ones, not from a special split. If you feed synthetic noise, or images preprocessed differently from what the mobile app will produce, the recorded activation ranges do not match reality and the quantized model will regress hard on real inputs. The single most common cause of a big accuracy drop after full-int quantization is a representative dataset that does not represent what the model will actually see.
The two inference_input_type = tf.int8 lines make the model accept and return integers directly, so the mobile app can skip the float conversion at the boundary and pass the raw uint8 pixels straight from the camera.
Mode 3: float16 quantization
An option for GPU delegates specifically:
converter = tf.lite.TFLiteConverter.from_saved_model("models/leaf_classifier/1")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.target_spec.supported_types = [tf.float16]
tflite_model = converter.convert()
Weights and activations are float16. Size halves (9.20 MB becomes 4.60 MB), accuracy loss is negligible, and mobile GPUs (which are natively float16) run it at nearly the same speed as they run float32. On CPU there is no benefit — the runtime has to promote back to float32 for the arithmetic.
The trade-off matrix, updated
| Variant | Size | Latency (Pixel 4a, CPU) | Top-1 accuracy |
|---|---|---|---|
| Float32 baseline | 9.20 MB | 88 ms | 0.941 |
| Dynamic range | 2.40 MB | 65 ms | 0.940 |
| Full integer int8 | 2.35 MB | 22 ms | 0.928 |
| Float16 (GPU) | 4.60 MB | 24 ms (GPU) | 0.941 |
Full integer wins on our four metrics from module 1: 2.35 MB fits well under the 8 MB budget, 22 ms fits well under the 60 ms budget, and the 1.3-point accuracy drop is acceptable for a leaf classifier where a suggested diagnosis is confirmed by the farmer before treatment. If the drop had been three or four points, module 4 (quantization-aware training) would take over.
Layer-level fallback
Sometimes one layer refuses to quantize cleanly — activations with a very wide dynamic range, custom operators, exotic normalisations. The converter lets that layer stay in float while the rest of the model becomes int8:
converter.target_spec.supported_ops = [
tf.lite.OpsSet.TFLITE_BUILTINS_INT8,
tf.lite.OpsSet.TFLITE_BUILTINS, # float fallback for hard layers
]
The .tflite file is then a mix of int and float layers. The interpreter inserts the conversion tensors at the boundaries; latency suffers slightly on those boundaries but the model still fits and still runs. Prefer this to giving up on quantization entirely.
How to measure the accuracy drop honestly
The classification metric on the validation set is the first check, but it hides two things.
Per-class accuracy matters more than global accuracy when classes are unbalanced. In our leaf dataset, rare diseases are the ones a farmer most needs the classifier to catch. A 1.3-point global drop that comes entirely from the three rare classes is a much worse outcome than a 1.3-point uniform drop.
Prediction stability on the same image is a check the float model does not need. Run the quantized model on the same image ten times and confirm the top prediction never wavers. A model that "flickers" between two classes on borderline inputs is a poor mobile experience and often the first symptom of aggressive quantization.
Key takeaways
- Dynamic range shrinks weights to int8 with zero accuracy risk but a modest latency gain — the safe first move.
- Full integer quantizes weights and activations, unlocks integer accelerators, and needs a representative dataset drawn from real validation data, not synthetic or augmented data.
- Float16 halves the size and matches float32 accuracy but only speeds things up on the mobile GPU.
- Always update the trade-off matrix and check per-class accuracy and prediction stability, not just global accuracy — the 1.3-point drop that comes entirely from your rarest class is not the same as a uniform drop.
Next module: quantization-aware training, for when the drop is too large to accept and post-training quantization is not enough.