How to Use Transfer Learning to Train Custom Models for Resource-Constrained MCUs

Adapting pretrained neural networks to run on tiny microcontrollers with limited memory

You’ve got 400 vibration samples from a pump that’s been running fine, and 60 from when it started failing. That’s it. Every TinyML tutorial assumes 10,000+ examples and a GPU you don’t have access to. Your Cortex-M4 has 256KB of flash free and 64KB of RAM to spare. The training-from-scratch path is closed before you start.

Transfer learning is how you get out of this corner. You’ll take a model that’s already learned what edges, textures, and shapes look like, then teach it the narrow thing you actually care about: this pump, in this rack, sounds wrong. The whole workflow fits in a weekend, and the result drops onto an STM32F4 or nRF52840 with room to spare.

This walkthrough covers the full path: collecting field samples, adapting MobileNetV2, fine-tuning the head, INT8 quantization, and deployment to the MCU with TensorFlow Lite Micro.

Why Transfer Learning Belongs on MCUs

Field deployments give you small datasets. You can’t run a motor to failure 10,000 times to balance your labels. Even in a forgiving setting (HVAC, conveyor belts), you’ll be lucky to get a few hundred labeled windows per class before the project deadline.

Training a CNN from scratch on that data overfits in 3 epochs and generalizes to nothing. A pre-trained backbone already knows how to extract useful features from 2D inputs. You just retrain the last couple of layers to map those features onto your labels. That’s the reason a sub-200KB anomaly detector on a Cortex-M4 is realistic instead of aspirational.

The use case for the rest of this article: a vibration anomaly detector running on a Cortex-M4 at 80MHz, reading from an accelerometer, deciding “normal” vs. “abnormal” every second.

The Five-Stage Workflow

 ┌──────────┐   ┌───────────┐   ┌──────────┐   ┌──────────┐   ┌─────────┐
 │ Collect  │──▶│ Adapt     │──▶│ Fine-    │──▶│ Quantize │──▶│ Deploy  │
 │ Field    │   │ Base Model│   │ Tune Head│   │ to INT8  │   │ to MCU  │
 │ Samples  │   │ (MNetV2)  │   │ (few ep) │   │          │   │         │
 └──────────┘   └───────────┘   └──────────┘   └──────────┘   └─────────┘

Raw sensor data becomes spectrograms. A small classification head trains on those spectrograms. The trained model gets compressed 10x and flashed alongside your firmware. You can stop at any stage to validate before committing more effort.

Stage 1 — Collect & Prepare Field Samples

Sample your accelerometer at 1–4 kHz. Below 1 kHz you miss the high-frequency content where bearing faults live. Above 4 kHz you’re burning storage with no extra signal for most rotating machinery.

Window the stream into 1-second frames. Convert each frame to a log-mel spectrogram (64 mel bins, 64 time steps gives you a clean 64×64 input). That’s the trick that lets you use an image-trained backbone on time-series data: a spectrogram is an image.

Labeling is the easy part. Two classes: normal, abnormal. Aim for 200–500 windows per class. If your abnormal class is thin (it usually is), oversample with augmentation rather than collecting fake data.

Split discipline matters more than usual with small datasets:

  • 70/15/15 train/val/test
  • Split by recording session, not by window. Windows from the same 5-minute recording aren’t independent samples.
  • Keep at least one abnormal session entirely out of training.
Accuracy
  100% │
   95% │              ╭──────────  scratch-trained (10k+ samples)
   90% │       ╭──────
   85% │   ╭───      ╭────────────  transfer learning (200–500)
   80% │ ╭─    ╭─────
   75% │╱   ╭──
       └────┴────────────────────▶ Samples
        50  200    1k    5k   10k

Stage 2 — Adapt the Base Model

MobileNetV2 is the right starting point. Depthwise separable convolutions keep the parameter count low, and the inverted residual blocks quantize cleanly to INT8 (some architectures don’t, and you find out the painful way at deployment).

Load it with a low width multiplier and small input:

base = tf.keras.applications.MobileNetV2(
    alpha=0.35,
    input_shape=(64, 64, 3),
    include_top=False,
    weights='imagenet')
base.trainable = False

A few notes on those choices. alpha=0.35 cuts channel counts to 35% of the default, which is the difference between a 1.7MB model and a 6MB model. 64×64 input shrinks activations quadratically, which matters more for RAM than flash. And yes, you’ll need to stack your single-channel spectrogram into 3 channels (just np.repeat it) to match the pretrained input expectation.

Bolt on a tiny classification head:

[Input 64x64x3 spectrogram]
        │
        ▼
[MobileNetV2 backbone — FROZEN]  ◀── pre-trained weights
        │
        ▼
[GlobalAvgPool → Dense(16) → Dense(2)]  ◀── trainable head

The frozen backbone is doing all the feature extraction. The trainable head is roughly 1,300 parameters. With 500 training samples, that ratio works. With a fully trainable 2.3M-parameter MobileNetV2 and 500 samples, it doesn’t.

Stage 3 — Fine-Tune the Head

Train in two passes.

Pass 1: Train only the head. 10–20 epochs, Adam at 1e-3, batch size 32. This converges fast. You’re optimizing ~1.3K parameters. Watch validation loss. With a small dataset, training loss will keep dropping long after the model has stopped learning anything useful.

Pass 2 (optional): Unfreeze the last inverted residual block of MobileNetV2 and train for 5 more epochs at a much lower learning rate (1e-5). This shifts the late-stage features toward your specific spectrogram distribution. If accuracy doesn’t move, skip it.

Spectrogram-specific augmentation helps. Three to use:

  • Time masking: zero out a random 8-step time slice
  • Frequency masking: zero out a random 8-bin frequency slice
  • Mixup between same-class samples (gentler than across classes)

Don’t use horizontal flips or rotations. Spectrograms aren’t symmetric, and you’ll just confuse the model.

Guardrails against overfitting:

  • Dropout of 0.3 between the dense layers
  • Early stopping on validation loss with patience=5
  • L2 regularization (1e-4) on the dense layers

If your validation accuracy plateaus at 75% and training hits 99%, you’ve overfit. Collect more data or pull more aggressive augmentation. There’s no clever optimizer trick that fixes a 200-sample dataset.

Stage 4 — Quantize for the MCU

Float32 weights are 4 bytes each. INT8 weights are 1 byte each. That’s most of your size reduction right there, and post-training quantization in TFLite does it without retraining.

converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = rep_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

The representative_dataset is the part people skip and then wonder why accuracy collapses. It needs 100+ samples from your actual training distribution. TFLite uses it to calibrate the activation ranges for quantization. Use random noise and you’ll get a model that runs fine and predicts garbage.

Expected budget:

┌──────────────────────┬──────────┬──────────┐
│ Asset                │  Float32 │   INT8   │
├──────────────────────┼──────────┼──────────┤
│ Model weights        │  1.7 MB  │  180 KB  │
│ Tensor arena (RAM)   │  220 KB  │   68 KB  │
│ Inference (Cortex-M4)│  ~340ms  │   ~85ms  │
└──────────────────────┴──────────┴──────────┘

Accuracy drop budget: target less than 2%. If you see 5%+, your representative dataset is too small or not representative. Re-sample it and try again before redesigning anything.

Stage 5 — Deploy with TFLite Micro

Convert the .tflite file to a C array:

xxd -i model.tflite > model_data.cc

In firmware, wire it into the TFLM interpreter. The tensor arena is the scratch memory TFLM uses for intermediate activations. Start with 80KB, run inference, then call interpreter.arena_used_bytes() and shrink to the actual high-water mark plus a small margin.

A minimal inference loop:

TfLiteTensor* input  = interpreter.input(0);
TfLiteTensor* output = interpreter.output(0);

// quantize float spectrogram to int8 using input->params
for (int i = 0; i < kInputSize; i++) {
    input->data.int8[i] = (int8_t)(spec[i] / input->params.scale
                                   + input->params.zero_point);
}

if (interpreter.Invoke() != kTfLiteOk) { /* handle */ }

int8_t score_abnormal = output->data.int8[1];

Enable CMSIS-NN kernels and a Cortex-M4 at 80MHz runs inference in 40–120ms, depending on your backbone configuration. Without CMSIS-NN, expect 3–5x slower. Turn it on.

If you’re sending those inference results back over a low-power network rather than a gateway, the Hubble terrestrial device SDK handles the radio side, so you can keep your MCU budget focused on the ML.

Validation & Troubleshooting

Desktop accuracy lies. The model you tested in Python isn’t the model running on the chip. Always re-test on-device with held-out recordings before claiming a number.

Common failures, in order of frequency:

  • Input normalization mismatch. Your Python pipeline divided spectrograms by 80dB; your firmware didn’t. Outputs are garbage. Print one input tensor from each side and compare.
  • Wrong INT8 scale/zero-point. You hard-coded the quantization params instead of reading them from the model. Use input->params.scale and input->params.zero_point every time.
  • Tensor arena too small. TFLM may fail silently or return wrong results depending on version. If accuracy on-device is wildly off, double the arena and see if it recovers.
  • CMSIS-NN not actually linked. Inference takes 400ms instead of 85ms. Check your build flags.
  • Stale representative dataset. You changed the spectrogram preprocessing and forgot to regenerate calibration data.

Sanity check: feed the same 10 spectrograms through Python TFLite (tf.lite.Interpreter) and the on-device model. Outputs should match within ±1 INT8 unit. If they don’t, the problem is in preprocessing, not the model.

Three Directions to Push the Baseline

EfficientNet-Lite0 on a Cortex-M7 or M33 gives you 3–5% more accuracy for roughly 2x the compute, which is fine if you have the MIPS. On-device learning (TFLM has experimental support) lets you adapt to sensor drift without re-flashing the whole model. And if you’re shipping at scale, the provisioning-at-scale guide covers pushing model updates alongside device credentials, so fleet OTA and ML versioning don’t fight each other.


Hubble Network makes it possible to push model updates to fleets in the field without per-device cellular costs or WiFi provisioning headaches. See how it works →