Model Conversion¶
Convert a trained Keras model to a quantized TFLite model using post-training quantization (PTQ) with INT8 internals and float32 I/O.
Basic usage¶
python -m birdnet_stm32 convert \
--checkpoint_path checkpoints/my_model.keras \
--model_config checkpoints/my_model_model_config.json \
--data_path_train data/train
This produces:
my_model_quantized.tflite— quantized TFLite modelmy_model_quantized_labels.txt— ordered output labels-
my_model_quantized_validation_data.npz— validation inputs for on-device comparison Passing--split_headadds the backbone/classifier split artifacts: -
my_model_quantized_backbone.tflite(+.gz) — audio → embeddings my_model_quantized_classifier.tflite(+.gz) — embeddings → scoresmy_model_quantized_classifier_labels.txt— the head's own label list
How it works¶
flowchart TD
A[".keras model"] --> B["Load model\n+ model_config.json"]
B --> C["Build representative\ndataset (1024 samples)"]
C --> D["TFLite PTQ\nfloat32 I/O, INT8 internals"]
D --> E["Validate: Keras vs. TFLite\ncosine sim, MSE, Pearson r"]
E --> F{"Mean + p05\nparity pass?"}
F -->|Yes| G["Atomic promote\n.tflite + .npz"]
F -->|No| H["Diagnostic report only"]
G -->|"--split_head"| I["Split at the embedding layer\nbackbone + classifier head"]
I --> J{"Chained\nparity pass?"}
J -->|Yes| K["Atomic promote\nboth halves + .gz"]
J -->|No| H
Backbone and classifier split¶
With --split_head, conversion also emits the model as two artifacts: a
backbone that maps audio to an embedding vector, and a classifier head
that maps that embedding to per-class probabilities.
The reason is the update path. The backbone is generic feature extraction and is flashed once. The head is the only part that changes when the species list changes — and on a remote deployment it has to arrive over a narrowband satellite link, where the whole model is far too large to send. Shipping the head alone turns a ~400 kB update into a few kB.
flowchart LR
A["audio"] --> B["backbone.tflite
flashed once"]
B --> C["embeddings
(256 floats)"]
C --> D["classifier.tflite
sent over the air"]
D --> E["per-class scores"]
Where the split happens¶
At the global-average-pooling layer (gap) that produces the embedding vector.
Everything after it — dropout, then the
classifier Dense — is rebuilt onto a clean embeddings input as a standalone
model with its own copy of the weights. The backbone keeps the audio frontend
and the whole convolutional body.
How each half is quantized¶
The backbone is calibrated on the same representative audio as the whole model. The head is then calibrated on the embeddings the quantized backbone produces for that audio — not on float embeddings, which is not what the board will feed it.
The two extra conversions roughly double the wall-clock cost of convert:
the representative dataset is decoded again for the backbone, and once more to
produce the head's calibration embeddings. That cost is why the split is
opt-in rather than automatic.
Gates¶
Three parity measurements are reported, and the third is a hard gate:
| Measurement | Compared against | Gated |
|---|---|---|
| Whole model | Keras vs. TFLite | Yes — --min_cosine_sim, --min_cosine_p05 |
| Backbone | Keras embeddings vs. TFLite embeddings | Reported |
| Chained | Keras whole model vs. TFLite backbone → TFLite head | Yes — same thresholds |
Both halves are staged in temporary files and promoted together only after the chained gate passes, so a failed split never leaves a half-valid pair behind.
Updating the head against a flashed backbone¶
Once a backbone is on a device it must not move. --backbone_path converts only
a new head, calibrating it on embeddings that exact .tflite emits:
python -m birdnet_stm32 convert \
--checkpoint_path checkpoints/my_model_probe.keras \
--model_config checkpoints/my_model_model_config.json \
--data_path_train data/new_classes/train \
--output_path checkpoints/new_head.tflite \
--backbone_path release/MyModel_INT8_backbone.tflite
The backbone is not reconverted and not re-emitted. Reconverting it would recalibrate its activation ranges against whatever data the new classes arrived with, producing a backbone that no deployed device has.
Two artifacts come out. new_head_classifier.tflite (plus .gz and its own
labels) is the over-the-air payload. new_head.tflite is the monolithic
backbone-plus-head graph, converted alongside so the board can be tested without
changing firmware that runs a single network.
Backbone identity¶
Whenever --split_head writes a backbone it also writes
<backbone>.tflite.fingerprint.json, a SHA-256 over the backbone's float
weights in graph order. A head-only conversion recomputes that fingerprint from
its own checkpoint and refuses to proceed on a mismatch:
RuntimeError: Backbone mismatch: probe.keras carries backbone a3e37e27... but
release/MyModel_INT8_backbone.tflite was built from 121c0428.... A head
calibrated against a different backbone will not work on a device flashed with
this one. Was the backbone left frozen during fine-tuning?
That is the check for the mistake this workflow invites: fine-tuning that
quietly unfreezes the backbone produces a head which scores well in testing and
is useless on the device. --allow_backbone_mismatch overrides it for
diagnostics and marks the conversion report accordingly.
The chained gate still applies, measured against the flashed backbone rather than a freshly built one.
Running a split model¶
Evaluation takes the head as a second argument and chains the two:
python -m birdnet_stm32 evaluate \
--model_path checkpoints/my_model_quantized_backbone.tflite \
--classifier_path checkpoints/my_model_quantized_classifier.tflite \
--model_config checkpoints/my_model_model_config.json \
--data_path_test data/test
Making the head small¶
The head is a single Dense of embedding_dim × num_classes, so its weight
count is exactly that product — a 256-d embedding driving 100 outputs is 25,600
INT8 weights. On top sits roughly 2 kB of TFLite flatbuffer overhead that does
not shrink with the model, which dominates for small class counts.
The head's gzipped size is dominated by how compressible its INT8 weights are: zeroed weights collapse, random ones do not. Gradual magnitude pruning was removed in 1.2.0 — no release ever used it — so the shipped head is dense and its transmitted size is the honest one measured below.
Measured on a 10-species, 256-d model:
| Classifier head | INT8 sparsity | .tflite |
gzipped |
|---|---|---|---|
| Dense | 0.4% | 4,704 B | 3,631 B |
Note the floor: 2,560 INT8 weights in a 4,704 B file means roughly 2 kB is flatbuffer scaffolding that no amount of sparsity removes. The wider the embedding and the more species, the smaller that fixed cost is as a fraction.
The conversion report records size_bytes, gzip_bytes, and int8_sparsity
for both halves under split, so an update budget can be checked directly.
Firmware runs one network today
The firmware and stedgeai deployment path compile a single network. Using
the split pair on-device requires running two networks back to back and
passing the embedding buffer between them, which is not implemented yet.
The split artifacts are produced, validated, and measured; wiring them into
the firmware is separate work.
Validation metrics¶
After conversion, the script reports:
| Metric | Target | Description |
|---|---|---|
| Mean cosine similarity | ≥ 0.95 | Average directional agreement of output vectors |
| Fifth-percentile cosine | ≥ 0.90 | Tail agreement; prevents the mean hiding severe failures |
| MSE | Low | Mean squared error |
| MAE | Low | Mean absolute error |
| Pearson r | > 0.95 | Linear correlation |
Cosine similarity < 0.95
The command fails if either cosine gate is missed. Conversion occurs in a
temporary file, and the final .tflite is promoted only after validation.
Common causes include:
- Overly diverse representative dataset widens INT8 ranges.
- Activation ranges that are too wide for stable INT8 quantization.
- Very wide channel counts without proper alignment.
First inspect calibration coverage and repeat parity on held-out examples. If representative calibration cannot pass reliably, use QAT and rerun the complete conversion, evaluation, STM32N6 analysis, and board checks.
Arguments¶
| Argument | Default | Description |
|---|---|---|
--checkpoint_path |
(required) | Path to trained .keras model |
--model_config |
(inferred) | Path to _model_config.json |
--output_path |
(inferred) | Output .tflite path |
--data_path_train |
None | Training data for representative dataset |
--num_samples |
1024 | Number of representative samples |
--validate_samples |
256 | Samples for Keras vs. TFLite validation |
--min_cosine_sim |
0.95 | Fail conversion if cosine similarity is below this |
--min_cosine_p05 |
0.90 | Fail conversion if fifth-percentile cosine is below this |
--per_tensor |
off | Use per-tensor quantization instead of per-channel |
--batch_validate |
0 | Run validation N times with different seeds, report worst-case |
--export_onnx |
off | Export and validate ONNX (requires tf2onnx, onnx, and onnxruntime) |
--split_head |
off | Also emit the backbone/classifier pair (see Backbone and classifier split) |
--backbone_path |
None | Convert only the head against this already-quantized backbone (see Updating the head against a flashed backbone); mutually exclusive with --split_head |
--allow_backbone_mismatch |
off | Proceed when the checkpoint's backbone does not match --backbone_path; diagnostics only |
--output_activation |
logit |
logit leaves the final sigmoid to the caller, so the output stays off the INT8 1/256 grid; sigmoid keeps it (see below) |
--report_json |
None | Save structured JSON conversion report |
Probability or logit output¶
A model that ends in a sigmoid emits probabilities — and those probabilities are quantized onto the INT8 grid, whose step is 1/256. Everything below about 0.002 is floored to zero and the rest is tied to that grid, which costs ranking accuracy across the long tail of classes.
So the conversion removes the sigmoid by default (--output_activation logit,
every release from 1.5; sigmoid keeps it). The model then emits logits, whose grid (about 0.1 per step over roughly ±12) keeps far
more resolution where scores are small. Measured on the 1.5 candidates over the
full catalog:
| Model | Probability output | Logit output | Δ cMAP |
|---|---|---|---|
| raw, 100 epochs | 0.6897 | 0.7111 | +0.021 |
| hybrid, 50 epochs | 0.7093 | 0.7533 | +0.044 |
Field metrics (event recall, window cMAP on soundscapes) do not change: those positives sit well above the grid floor. The gain is in ranking near-zero scores, which is what catalog cMAP measures over thousands of recordings.
The conversion writes a _model_config.json next to the model recording
"output_activation", and everything in this package that loads a model with
its config — evaluate, board-test, measure-operational — applies the
sigmoid automatically. A caller outside this package applies
1 / (1 + exp(-x)), or skips it and compares logits against
log(t / (1 - t)), since thresholding is monotonic. See
Reference Implementation.
Quantization details¶
- Scheme: full integer quantization (INT8 weights + INT8 activations)
- I/O: float32 — audio inputs are continuous-valued and lose meaningful precision at INT8
- Calibration: deterministic, exactly sized, class-stratified training sample including quiet and nuisance recordings; validation paths are stratified and disjoint
- Provenance: the report records counts, per-class coverage, and SHA-256 identities for both path manifests
- Target hardware: STM32N6 NPU (requires channel counts in multiples of 8)
- Activation ranges: the converter calibrates every activation's min/max, then each range is cut to a percentile of that activation's values on the same calibration data — the raw frontend's at 99.99, the rest at 99.999 (input and output keep min/max) — and the model is quantized with those ranges. See Activation ranges.
- Two-exposure PWL: a raw frontend with a PWL compressor is converted as its float-exact two-exposure rewrite. See Two-exposure PWL.
- Per-channel (default): quantizes each output channel separately — better accuracy
- Per-tensor: single scale per tensor — use only if per-channel causes N6 issues
When --export_onnx is requested, export uses the native Keras 3 ONNX path and
a temporary sibling file. The ONNX checker runs with full validation, then 16
held-out samples must pass ONNX Runtime parity (cosine_min >= 0.9999 and
max_abs_error <= 1e-4) before the final .onnx is promoted. Install the
required tools with pip install 'birdnet-stm32[release]'.
Per-tensor
Use --per_tensor only if stedgeai rejects a per-channel model.
Activation ranges¶
An INT8 activation has 256 steps spread over its range. The TFLite converter sets that range to the minimum and maximum the activation reached on the calibration data, so one rare loud input stretches it and coarsens the grid for every other value — and the scores of a 1,000-class model rank on small values. The conversion therefore cuts each range to the 99.999th percentile of the activation's values on the calibration data (both ends where the range is negative). It changes only the stored ranges: the graph, size and on-device speed stay the same.
Measured on 2.0 Raw over the 61 WABAD 2.0 sites (59,418 annotated calls):
| INT8 ranges | Window AUPRC | Window cMAP | Calls found @0.5 |
|---|---|---|---|
| min/max | 0.258 | 0.179 | 0.141 |
| 99.999th percentile | 0.286 | 0.190 | 0.169 |
| float model (reference) | 0.302 |
The gain is all in the audio frontend's tensors; clipping only the backbone's
changes nothing. 99.9 clips too far (below min/max) and 99.9999 too little. On
the STM32N6 the clipped model runs at the same 11.2 ms and tracks the host more
closely (stedgeai validate cos 0.99994 vs 0.99984).
From 2.2 the raw frontend's tensors are cut harder, at the 99.99th percentile: their ranges are set by loud focal calls while soundscape bins sit a few INT8 steps above zero. The backbone keeps 99.999 (99.99 there costs). On 2.2's N6-B0 over the 61 WABAD sites, INT8 window AUPRC 0.429 → 0.437 (float 0.465) and calls found @0.5 0.357 → 0.368; catalog cMAP 0.7115 → 0.7150 (float 0.7341).
Two-exposure PWL¶
The raw frontend's band magnitude x and its PWL compressor
y = k₀x + Σᵢ kᵢ·relu(sᵢx + bᵢ) are the tensors that lose most in INT8: on
WABAD windows N6-B0's PWL output sits at level 3 of 255 in the median bin, and
the hinge chain compounds the rounding. With every slope sᵢ > 0 and a split
point x_T per band where its PWL reaches 2.0,
PWL(x) = PWL(min(x, x_T)) + [PWL(x) − PWL(min(x, x_T))] exactly. The
conversion builds both terms as their own paths (conversion/pwl_split.py):
- low: a copy of the band mix with ReLU6 at gain 6 / x_T, i.e. min(x, x_T) on a [0, 6] grid, then the PWL rescaled to it. Every tensor of this path spans the quiet range only.
- high: a copy of the band mix minus x_T with ReLU, then the PWL re-anchored at x_T. It is exactly 0 on quiet bins.
Their sum feeds the stem as before. The float model, its checkpoint and the ONNX export are unchanged; the converted model is validated against the float model as always. On 2.2's N6-B0 (61 WABAD sites):
| INT8 conversion | Window AUPRC | Loss vs float | On board |
|---|---|---|---|
| ranges at 99.999 | 0.4286 | 7.9% | 33.7 ms |
| frontend at 99.99 | 0.4366 | 6.2% | 33.7 ms |
| + two-exposure PWL | 0.4409 | 5.2% | 34.6 ms |
| float model (reference) | 0.4653 |
Every op stays on the NPU: 82 epochs (74 without), 2 in software either way;
stedgeai validate on target: cos 0.99991, SNR 37.4 dB (35.0 without).
No INT8 I/O
Audio spectrograms are continuous-valued signals. Quantizing model inputs to INT8 would destroy meaningful precision. The pipeline enforces float32 I/O with INT8 internals only.
Release names¶
Experiment outputs may keep descriptive internal names. Public model families
use BirdNET_Tiny_N6_<REGION>_<SPECIES_COUNT>_V<MAJOR.MINOR>, then append an
uppercase _FP32, _FP16, or _INT8 precision token to model artifacts. The
species count excludes nuisance/background outputs. See the
release process for required sidecars and validation
gates.