Embedded HandbookApps
All sections › XIV. Machine learning on a microcontroller
STM32F407 · level intermediate · 39 min read
Open in the interactive handbook

118а. Keras for TinyML: architectures for a microcontroller — dense, 1D convolution, depthwise-separable, small CNN — training, metrics and export to .tflite

In section 118 the model was trained in scikit-learn on four features computed by a human. Here the network gets the sensor window itself and finds the features on its own, and it is trained in Keras — the part of TensorFlow used to train most models for TensorFlow Lite. The section walks the path from an array of samples to a .tflite file: how to build a model from layers, how to train it and when to stop, how to measure quality, how much flash and RAM it will take and how many instructions a Cortex-M4 spends on one answer.

There is one task for all models — five gestures from a three-axis accelerometer — and four architectures solve it differently: a dense network, a single 1D convolution, a depthwise-separable network and a small convolutional network. Training runs in the stm-tf:1 image — TensorFlow 2.21.0, Keras 3.15.1, numpy 2.3.3 — and repeats byte for byte. Inference of the same models is written in C and checked against Keras on all held-out windows.

Foundations: what TinyML and the tensor arena are — section 85; approaches from a threshold to a perceptron — section 116; how a neural network learns (gradient, step, epoch) — section 116б; windows and features — section 117; splitting by recordings — 118.3; int8 quantization, TensorFlow Lite Micro and CMSIS-NN — section 119.

Terms used in the documentation

Term Meaning
layer one step of the model
Sequential / functional API a model as a list of layers / a model built from layer calls
Dense (fully connected) every output depends on every input
Conv1D, Conv2D convolution along one axis (time) and along two
depthwise separable convolution a per-channel convolution, then 1 × 1
pointwise convolution 1 × 1 convolution: mixes channels
pooling, global average pooling maximum or mean over a window, over the whole length
BatchNormalization per-channel shift and scale fitted over batches
Dropout switching off part of the neurons during training
epoch, batch one pass over the data; examples per step
loss, optimizer the function being minimized, the rule for the step
logits outputs before softmax
callback code that fit calls after an epoch
early stopping, checkpoint stopping training, saving the best weights
learning curve loss and accuracy by epoch
F1 score F1: harmonic mean of recall and precision
MAC (multiply-accumulate) one multiplication with accumulation
tensor arena RAM for intermediate tensors
operator (op) an operation in a .tflite file
SavedModel the TensorFlow format for a saved model
seed the seed of the random number generator

118а.1 Task and data

A device in the hand, a three-axis accelerometer, 50 samples per second. A window is 64 samples, 1.28 s; a sample is in mg, an integer, as the sensor delivers it. Five classes: rest, shake, circle, double tap and lift. Rest is the hard class: in two cases out of three it is not stillness but a single tap or a light swing in the hand, that is "almost a gesture".

The data are synthetic. A recording is one participant: their own tempo (from 0.8 to 1.25 of normal), their own strength, their own tilt of the device in the hand, their own axis zero offset up to 40 mg and their own noise. 40 participants repeated each gesture 8 times — 1600 windows. The split is by participant, as in 118.3: 22 participants for training (880 windows), 6 for tuning (240 windows) and 12 for the test (480 windows). The test contains people the model has never seen.

As input Keras takes numpy arrays shaped "windows × time × axes":

X = (Xi.astype(np.float32) / np.float32(1000.0))  # вход модели — g
parts = rng.permutation(NPART)
p_tr, p_va, p_te = parts[:22], parts[22:28], parts[28:]
mtr, mva, mte = np.isin(g, p_tr), np.isin(g, p_va), np.isin(g, p_te)

All 1600 windows in float are 1.2 MB, and arrays are enough. When the data do not fit into memory, the same fit accepts a tf.data.Dataset that reads them from disk in batches; the model and the training do not change.


118а.2 Four architectures

Four models on an accelerometer window of 64 × 3: dense — Flatten and Dense, 6341 parameters and 6304 multiplications, 454 of 480 correct; a single Conv1D with a global maximum — 401 parameters, 9132 multiplications, 453; depthwise-separable — 1317 parameters, 23408 multiplications, 475; a small CNN of three Conv1D — 6357 parameters, 114912 multiplications, 473

A. Dense. The 64 × 3 window is stretched into a row of 192 numbers, then come two fully connected layers. The model is built sequentially — Sequential, layer by layer:

def model_A():
    return tf.keras.Sequential([
        tf.keras.Input((N, 3)),
        L.Flatten(),
        L.Dense(32, activation="relu"),
        L.Dropout(0.2),
        L.Dense(NCLS),
    ], name="A_dense")

The weakness follows from the design: each of the 192 inputs has its own weight, and a tap at the start of the window is a different pattern for it than the same tap in the middle.

B. A single 1D convolution. Conv1D(12, 9, strides=2) — 12 filters of 9 samples over all three axes, stride 2. A filter looks for its pattern anywhere in the window, and GlobalMaxPooling1D keeps the largest response of each filter: where the pattern was no longer matters.

C. Depthwise-separable. An ordinary 5 × 16 → 16 channel convolution has 5 · 16 · 16 = 1280 weights. The separable one does the same in two steps: DepthwiseConv2D — each channel has its own kernel of 5 weights, the channels are not mixed (80 weights), then a 1 × 1 convolution mixes 16 channels into 16 (256 weights). Together 336 weights — 3.8 times fewer. The model is built with the functional API: a layer is called on the output of the previous one, and this way one can build branches that Sequential cannot:

def model_C():
    inp = tf.keras.Input((N, 3))
    x = L.Reshape((N, 1, 3))(inp)                 # время × 1 × оси: свёртки 2D с ядром (k, 1)
    x = L.Conv2D(16, (5, 1), strides=(2, 1), use_bias=False)(x)
    x = L.BatchNormalization()(x)
    x = L.ReLU()(x)
    for _ in range(2):
        x = L.DepthwiseConv2D((5, 1), use_bias=False)(x)
        x = L.BatchNormalization()(x)
        x = L.ReLU()(x)
        x = L.Conv2D(16, (1, 1), use_bias=False)(x)
        x = L.BatchNormalization()(x)
        x = L.ReLU()(x)
    x = L.GlobalAveragePooling2D()(x)
    out = L.Dense(NCLS)(x)
    return tf.keras.Model(inp, out, name="C_dscnn")

The convolutions have no bias (use_bias=False): right after them comes BatchNormalization, which has its own shift.

The window here is a picture 64 high and 1 wide, the axes are channels. The (5, 1) kernel runs along time only, and a 2D convolution computes exactly the same as a 1D one. Why do it this way is shown in 118а.6: such layers turn into TensorFlow Lite operators one to one.

D. Small CNN. Three Conv1D (16, 32 and 32 filters) with MaxPooling1D(2) between them — each pooling halves the time length — then GlobalAveragePooling1D and Dense(5). The "heaviest" model of the section in terms of computation.

After each convolution in C and D there is a BatchNormalization: during training it brings the channel output to zero mean and unit spread over the batch, and the network learns faster and more smoothly. At inference it is just a shift and scale of the channel — they are folded into the convolution weights (118а.6). Dropout randomly switches off part of the neurons during training so that the network does not rely on any one of them; at inference it does nothing.


118а.3 Training: compile, fit and when to stop

All four models are trained by one function:

model.compile(optimizer=tf.keras.optimizers.Adam(0.003),
              loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
              metrics=["accuracy"])
cb = [tf.keras.callbacks.EarlyStopping(monitor="val_loss", patience=20, restore_best_weights=True),
      tf.keras.callbacks.ModelCheckpoint(TMP + "/best_%s.keras" % tag, monitor="val_loss", save_best_only=True)]
kw = dict(validation_data=val) if isinstance(val, tuple) else dict(validation_split=val)
h = model.fit(Xtr, ytr, epochs=300, batch_size=32, verbose=0, callbacks=cb, shuffle=True, **kw)

The optimizer is Adam with a step of 0.003; the loss is cross-entropy for integer labels. from_logits=True means that the last layer outputs numbers before softmax: softmax is needed only for training, it does not change which output is the largest, and the firmware does not compute it. A batch is 32 windows, 28 steps per epoch.

Callbacks decide when to stop. EarlyStopping watches the tuning loss and stops training if it has not improved for 20 epochs, and restore_best_weights brings back the weights of the best epoch. ModelCheckpoint writes the best weights to a file — if training crashes or is interrupted, they are not lost. Model C stopped at epoch 181, the best being 161; D at epoch 45, the best being 25.

The learning curve of model C — loss and accuracy by epoch:

Epoch Loss, training Loss, tuning Accuracy, training Accuracy, tuning
1 1.304 1.608 0.545 0.200
5 0.308 1.990 0.959 0.200
10 0.093 2.055 0.986 0.287
20 0.025 0.120 0.998 0.962
40 0.004 0.029 1.000 0.992
160 0.000 0.014 1.000 0.992

In the first epochs tuning gives 0.200 — exactly one class out of five, although training is already at 0.959. This is how BatchNormalization behaves: on tuning it works with accumulated means, and during the first hundreds of steps they have not yet caught up with the real ones. From epoch 20 the curves converge. After epoch 40 training knows the data by heart (loss near zero), while tuning barely changes — there is no point in training further.

Tuning set by participant. Keras has a shortcut — validation_split=0.2: it sets aside the last 20 % of the array for tuning. The last — that is, before shuffling. If the windows are stacked by class, as if from five files in a row, only the lift windows went to tuning — 224 out of 224 — and the model never saw them: 96 of 480 correct, early stopping fired at epoch 21, the best was the first. If the windows are shuffled, tuning contains the same people as training: the dense model got 203 of 224 on tuning, and 426 of 480 on new participants; model C showed 224 of 224 on tuning. Here the gap is small — repetitions of one gesture differ more than the windows of one recording in 118.3 — but the tuning number no longer says anything about new people. The tuning set is separated the same way as the test set: by participant, and passed in validation_data.

Seed. Initial weights, the order of examples and Dropout are random. Without a seed every run gives a different model:

tf.keras.utils.set_random_seed(seed)
tf.config.experimental.enable_op_determinism()

The first line sets the seed for Python, numpy and TensorFlow, the second forbids TensorFlow fast but non-repeatable computation paths. With them repeated training gave the same weights down to the bit. How large the spread is can be seen on other seeds: seeds 1, 2 and 3 gave 438, 458, 448 correct for A and 477, 478, 479 for C. The 20-window spread of the dense model is larger than its lead over model B on the main seed. When comparing architectures, look at several seeds, not at one run.


118а.4 Metrics: accuracy, confusion matrix, F1

The test is 480 windows of 12 participants, once at the end:

Model Parameters MAC Correct of 480 % F1 averaged over classes
A. Dense 6341 6304 454 94.6 0.946
B. A single Conv1D 401 9132 453 94.4 0.944
C. Depthwise-separable 1317 23408 475 99.0 0.990
D. Small CNN 6357 114912 473 98.5 0.985

The model's answer is the index of the largest of the five outputs:

logit = m.predict(X[mte], verbose=0).astype(np.float32)
p = logit.argmax(1)

Accuracy hides where the errors are. The confusion matrix of model C (rows — the true class, columns — the answer):

True / answer rest shake circle double tap lift
Rest 96 0 0 0 0
Shake 0 96 0 0 0
Circle 5 0 91 0 0
Double tap 0 0 0 96 0
Lift 0 0 0 0 96

F1 by class: 0.975; 1.000; 0.973; 1.000; 1.000. F1 is the harmonic mean of recall and precision of a class (118.8): it falls both from misses and from false answers. Here five circles were taken for rest — recall of the circle fell, precision of rest fell — and F1 dropped for both.

Models A and B err elsewhere. The dense one confuses rest and double tap: rest as double tap in 9 windows, double tap as rest in 13. B does the same, more strongly: 17 rest windows went to double tap. The reason for B is the global maximum: only the largest response of a filter remains, and one tap and two taps make it the same. Such a model cannot count taps. In C and D several convolutions stand before the global mean, and both recognized the double tap in 96 windows out of 96.

If one class matters more than the others — a missed gesture is worse than a false one or vice versa — look at the F1 and the recall of that class, not at the average.


118а.5 Parameters and MAC: into flash, RAM and instructions

model.summary() prints the layers, output shapes and parameters. For model C (the beginning and the end):

Model: "C_dscnn"
│ conv2d (Conv2D)                   │ (None, 30, 1, 16)        │           240 │
│ batch_normalization               │ (None, 30, 1, 16)        │            64 │
│ depthwise_conv2d                  │ (None, 26, 1, 16)        │            80 │
│ dense_3 (Dense)                   │ (None, 5)                │            85 │
Total params: 3,633 (14.20 KB)
Trainable params: 1,157 (4.52 KB)
Non-trainable params: 160 (640.00 B)
Optimizer params: 2,316 (9.05 KB)

Total params: 3,633 is not the size of the model. After fit Keras 3 adds the optimizer state to it: Adam keeps two numbers for every trainable weight and a step counter, 2 · 1157 + 2 = 2316. The model itself is 1317 numbers. Of them, 320 belong to five BatchNormalization layers of 64 each: 160 trainable (scale and shift) and 160 accumulated means and variances. After folding BN the model has 1077 numbers: 997 weights and 80 biases — 16 for each of the five convolutions.

By hand, layer by layer:

Layer Output Parameters MAC
Conv2D 16 × (5, 1), stride 2 30 × 16 5 · 3 · 16 = 240 30 · 16 · 5 · 3 = 7200
DepthwiseConv2D (5, 1) 26 × 16 5 · 16 = 80 26 · 16 · 5 = 2080
Conv2D 1 × 1 26 × 16 16 · 16 = 256 26 · 16 · 16 = 6656
DepthwiseConv2D (5, 1) 22 × 16 80 22 · 16 · 5 = 1760
Conv2D 1 × 1 22 × 16 256 22 · 16 · 16 = 5632
GlobalAveragePooling2D 16 0 —
Dense 5 5 16 · 5 + 5 = 85 80
BatchNormalization × 5 — 5 · 64 = 320 0 after folding
Total 1317 23408

Without edge padding (padding="valid", the default) a convolution with kernel k shortens the time by k − 1, and stride 2 also halves it: 64 → 30, then 30 → 26 → 22. The MAC count of a convolution is output length × output channels × kernel × input channels; in a depthwise one the input channels are not multiplied. Parameters determine flash, MAC — time, and the size of tensors between layers — RAM, and these are three different numbers: D has as many parameters as A, but 18 times more multiplications.

What this turns into on a microcontroller was measured on the C inference (infer118b.c: one loop for all layers, float weights, BN folded) and on the .tflite file:

A B C D
Weights after folding BN, bytes 25364 1604 4308 24468
.tflite, bytes 27068 4212 9700 29932
Layer buffers in C, bytes 1536 2688 3840 7680
Peak of live .tflite tensors, bytes 1536 2688 3584 5760
Instructions per inference, M4 33500 56000 164700 626300
Instructions per MAC 5.3 6.1 7.0 5.5

Flash. Weights take 4 bytes per parameter. The code that computes them is shared by all models: the run_model function is 1074 bytes of code with -Os and 4634 with -O2 (armclang, Cortex-M4); on Cortex-M7 — 1040 and 2270.

RAM. The C inference keeps two buffers: the input of a layer and its output, in turn. Their size is set by the largest tensor of the model. In D it is the output of the first convolution, 60 × 16 = 960 numbers: the convolution has no stride, pooling comes only after it. In C the first convolution has stride 2, and the largest tensor is 30 × 16. Another 768 bytes are the float window on the stack. For .tflite the sum of tensors alive at the same time was computed — from the operation that writes a tensor to the last one that reads it. This is only an estimate of the arena: TensorFlow Lite Micro lays tensors out by its own plan and adds service data, and the exact number on the board is reported by the interpreter's arena_used_bytes().

Instructions. The same C was built with arm-none-eabi-gcc -O2 and run in QEMU: netduinoplus2 is a Cortex-M4F, like the STM32F405; mps2-an500 is a Cortex-M7. QEMU with -icount keeps time by the number of instructions. On the Cortex-M7 the instruction count is the same — 164740 versus 164736 for C — the code is the same. The inner loop of the convolution and Dense is five instructions per multiplication: two vldmia loads, vfma, a compare and a branch; in the depthwise one it is eight, the input and weight addresses advance by the number of channels with separate additions. With the loop overhead this comes to 5.3–7.0 instructions per MAC. At 168 MHz, 164700 instructions are about 0.98 ms if every instruction took one cycle; for D it is 3.73 ms. A window arrives once every 1.28 s, so any of the models fits with a large margin. These are instructions, not cycles: on silicon flash wait states and branches add up, and the Cortex-M7 can execute pairs of instructions simultaneously (dual issue, DUI0646B), so it may need fewer cycles than instructions.


118а.6 Export: SavedModel → .tflite and the list of operators

The model is saved as a SavedModel, and the TensorFlow Lite converter makes a file from it. Here it is in float — int8 quantization is covered in 119:

model.export(sm, verbose=False, input_signature=[tf.TensorSpec((batch, N, 3), tf.float32)])
blob = tf.lite.TFLiteConverter.from_saved_model(sm).convert()

The TensorFlow Lite interpreter on these files answered with the same class as Keras in all 480 windows for all four models. These operators ended up in the .tflite:

Model Operators
A RESHAPE, FULLY_CONNECTED × 2
B RESHAPE, CONV_2D, RESHAPE, REDUCE_MAX, FULLY_CONNECTED
C RESHAPE, CONV_2D, (DEPTHWISE_CONV_2D, CONV_2D) × 2, MEAN, FULLY_CONNECTED
D RESHAPE, (CONV_2D, MAX_POOL_2D) × 2, CONV_2D, RESHAPE, MEAN, FULLY_CONNECTED

What this list shows:

What matters most is who will execute these operators. In the TensorFlow Lite Micro 1.23.2 pack (Keil) all operators of the table are in the MicroMutableOpResolver list. The accelerated CMSIS-NN kernels in the same pack exist only for add, conv, depthwise_conv, fully_connected, mul, pooling, softmax and svdf. MEAN of models C and D and REDUCE_MAX of model B will go to the plain TFLM kernels — for global pooling this is little: one pass over 22 × 16 numbers. But the rule is general: before choosing a layer, check what it will turn into and whether there is a kernel for it. How the model is quantized and run in TFLM is section 119.


118а.7 Pitfalls

Tuning looks fine, but new people do worse — or the model never saw one class at all. validation_split takes the tail of the array before shuffling: windows stacked by class — tuning holds one class; shuffled windows — tuning holds the same people. Separate tuning by participant and pass it in validation_data.

model.summary() shows three times more parameters. After fit in Keras 3 Total params includes the optimizer state: 3633 for a model of 1317 numbers. The model size is the sum of trainable and non-trainable parameters, and after folding BN it is smaller still.

In the first epochs tuning is stuck on one class. The accumulated BatchNormalization means have not caught up with the data yet. Do not stop training before they do: a patience of tens of epochs.

Every run gives different weights and different quality. No seed set or determinism not enabled. set_random_seed and enable_op_determinism; compare architectures over several seeds — A spreads by 20 windows out of 480.

Flatten before Dense inflates the weights. In A, 6176 of 6341 parameters are the first Dense after Flatten, and the model is tied to where the gesture sits in the window. In D, replacing global pooling with Flatten gave 7957 parameters instead of 6357, and 454 correct instead of 473. Global pooling is both smaller and independent of the shift.

Extra operators in .tflite. A batch of any size: SHAPE, STRIDED_SLICE, PACK, EXPAND_DIMS. Fix the batch at 1 in the export signature.

An operator TFLM or CMSIS-NN does not have. A Keras layer is not an operator: check the .tflite operator list against MicroMutableOpResolver and the CMSIS-NN kernels before the model is trained. The global maximum, on top of that, cannot count events — B confused a double tap with a single one.

A large arena. RAM is decided by the largest tensor between layers, not by the weights: in D it is twice as large as in C because of the first convolution without a stride. A stride or pooling in the first layer cuts memory at once.


118а.8 Summary

Full sources

train118b.py — data, four models, training, metrics, export to .tflite and the header for C.

# -*- coding: utf-8 -*-
"""Раздел 118а: Keras для TinyML — четыре архитектуры на одной задаче с акселерометра.

Запуск — в образе stm-tf:1:  python train118b.py <каталог_вывода>

Данные — синтетические жесты с трёхосевого акселерометра: 50 Гц, окно 64 отсчёта
(1,28 с), единицы — g, отсчёт округлён до 1 mg, как у датчика с 16-битным выходом.
Пять классов: покой (с дрожанием руки, одиночным касанием и лёгким покачиванием —
это трудные «не жесты»), встряхивание, круг, двойное касание, подъём. Запись — один
участник: свой темп, своя сила, свой наклон прибора в руке, своё смещение нуля осей.
40 участников по 8 повторов каждого жеста; разбиение — по участникам (раздел 118.3):
22 на обучение, 6 на подбор (валидацию), 12 на проверку.

Модели: A — плотная (Flatten → Dense), B — одна 1D-свёртка, C — depthwise-separable
(слои Conv2D/DepthwiseConv2D, функциональный API), D — маленькая CNN из трёх Conv1D
с BatchNormalization. Каждая учится с EarlyStopping и ModelCheckpoint, затем
SavedModel → TFLiteConverter (float) → .tflite: размер и список операторов.
BatchNormalization сворачивается в свёртку, веса уезжают в model118b.h для infer118b.c;
отложенные окна — в test118b.bin (int16, mg). Сводка — summary118b.txt."""
import os
import sys

os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
os.environ["TF_ENABLE_ONEDNN_OPTS"] = "0"
import numpy as np
import tensorflow as tf
from sklearn.metrics import confusion_matrix, f1_score

OUT = sys.argv[1] if len(sys.argv) > 1 else "."
TMP = "/tmp/m118b"
os.makedirs(TMP, exist_ok=True)
SEED = 118
FS, N = 50.0, 64                                  # Гц, отсчётов в окне
NPART, REPS, NCLS = 40, 8, 5
NAMES = ["покой", "встряхивание", "круг", "двойное касание", "подъём"]
rng = np.random.RandomState(SEED)
t = np.arange(N) / FS


def rot(roll, pitch, yaw):
    cr, sr, cp, sp, cy, sy = np.cos(roll), np.sin(roll), np.cos(pitch), np.sin(pitch), np.cos(yaw), np.sin(yaw)
    rx = np.array([[1, 0, 0], [0, cr, -sr], [0, sr, cr]])
    ry = np.array([[cp, 0, sp], [0, 1, 0], [-sp, 0, cp]])
    rz = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]])
    return rz @ ry @ rx


def envelope(c, width):
    """Огибающая жеста: половина периода синуса шириной width секунд с центром c."""
    u = (t - c) / width + 0.5
    return np.where((u > 0) & (u < 1), np.sin(np.pi * np.clip(u, 0, 1)), 0.0)


def pulse(c, w):
    return np.exp(-0.5 * ((t - c) / w) ** 2)


def gesture(c, P):
    """Ускорение в системе «мира» (g) без тяжести, 3 × N, и наклон прибора по времени."""
    k, a = P["tempo"], P["amp"]
    d = np.zeros((3, N))
    tilt = np.zeros(N)
    t0 = rng.uniform(0.45, 0.83)                  # где в окне центр жеста, с
    if c == 0:                                    # покой: дрожание руки, медленный наклон
        for _ in range(2):
            f = rng.uniform(7, 12)
            d += rng.uniform(0.005, 0.02) * np.sin(2 * np.pi * f * t + rng.uniform(0, 6.3))[None, :] \
                 * rng.uniform(-1, 1, (3, 1))
        tilt = rng.uniform(-0.15, 0.15) * (t - t.mean())
        kind = rng.randint(3)
        if kind == 1:                             # одиночное касание по столу или корпусу
            d[2] += rng.uniform(1.0, 2.5) * a * (pulse(t0, 0.012) - 0.3 * pulse(t0 + 0.03, 0.02))
        elif kind == 2:                           # лёгкое покачивание в руке
            f = rng.uniform(1.5, 3.0)
            d[rng.randint(2)] += rng.uniform(0.1, 0.3) * a * np.sin(2 * np.pi * f * t) * envelope(t0, 0.8)
    elif c == 1:                                  # встряхивание вдоль оси x мира
        f = rng.uniform(3.2, 4.5) * k
        d[0] = rng.uniform(0.8, 1.6) * a * np.sin(2 * np.pi * f * (t - t0)) * envelope(t0, rng.uniform(0.6, 0.9) / k)
        d[1] = 0.15 * d[0] * rng.uniform(-1, 1)
    elif c == 2:                                  # круг в плоскости xy, по или против часовой
        f = rng.uniform(1.0, 1.5) * k
        s = rng.choice([-1, 1])
        e = envelope(t0, rng.uniform(0.9, 1.1) / k)
        A = rng.uniform(0.35, 0.7) * a
        ph = rng.uniform(0, 6.3)
        d[0] = A * np.cos(2 * np.pi * f * t + ph) * e
        d[1] = s * A * np.sin(2 * np.pi * f * t + ph) * e
    elif c == 3:                                  # двойное касание: два удара по z
        gap = rng.uniform(0.18, 0.32) / k
        for c0 in (t0 - gap / 2, t0 + gap / 2):
            d[2] += rng.uniform(1.0, 2.5) * a * (pulse(c0, 0.012) - 0.3 * pulse(c0 + 0.03, 0.02))
    else:                                         # подъём: разгон вверх, торможение, наклон к себе
        w = rng.uniform(0.12, 0.2) / k
        A = rng.uniform(0.25, 0.5) * a
        d[2] = A * (pulse(t0 - w, w) - pulse(t0 + w, w))
        tilt = rng.uniform(0.25, 0.6) / (1 + np.exp(-(t - t0) / (0.08 / k)))
    for ax in range(3):
        d[ax] += rng.normal(0, 0.004, N)          # мелкая дрожь руки
    return d, tilt


def window(c, P):
    d, tilt = gesture(c, P)
    x = np.zeros((N, 3))
    for i in range(N):
        g = np.array([0.0, np.sin(tilt[i]), np.cos(tilt[i])])     # тяжесть: наклон вокруг оси x
        x[i] = P["R"] @ (g + d[:, i])
    x += P["bias"] + rng.normal(0, P["noise"], (N, 3))
    return np.clip(np.round(x * 1000.0), -32768, 32767).astype(np.int16)   # mg, как отдаёт датчик


Xi, y, g = [], [], []
for p in range(NPART):
    P = dict(tempo=rng.uniform(0.8, 1.25), amp=rng.uniform(0.7, 1.3),
             R=rot(*rng.uniform(-0.45, 0.45, 2), rng.uniform(-0.6, 0.6)),
             bias=rng.uniform(-0.04, 0.04, 3), noise=rng.uniform(0.004, 0.012))
    for c in range(NCLS):
        for _ in range(REPS):
            Xi.append(window(c, P))
            y.append(c)
            g.append(p)
Xi, y, g = np.array(Xi), np.array(y), np.array(g)
X = (Xi.astype(np.float32) / np.float32(1000.0))  # вход модели — g
parts = rng.permutation(NPART)
p_tr, p_va, p_te = parts[:22], parts[22:28], parts[28:]
mtr, mva, mte = np.isin(g, p_tr), np.isin(g, p_va), np.isin(g, p_te)
lines = ["versions=%s,%s,%s" % (tf.__version__, tf.keras.__version__, np.__version__),
         "windows=%d,%d,%d,%d" % (len(y), mtr.sum(), mva.sum(), mte.sum())]
L = tf.keras.layers


def model_A():
    return tf.keras.Sequential([
        tf.keras.Input((N, 3)),
        L.Flatten(),
        L.Dense(32, activation="relu"),
        L.Dropout(0.2),
        L.Dense(NCLS),
    ], name="A_dense")


def model_B():
    return tf.keras.Sequential([
        tf.keras.Input((N, 3)),
        L.Conv1D(12, 9, strides=2, activation="relu"),
        L.GlobalMaxPooling1D(),
        L.Dense(NCLS),
    ], name="B_conv1d")


def model_C():
    inp = tf.keras.Input((N, 3))
    x = L.Reshape((N, 1, 3))(inp)                 # время × 1 × оси: свёртки 2D с ядром (k, 1)
    x = L.Conv2D(16, (5, 1), strides=(2, 1), use_bias=False)(x)
    x = L.BatchNormalization()(x)
    x = L.ReLU()(x)
    for _ in range(2):
        x = L.DepthwiseConv2D((5, 1), use_bias=False)(x)
        x = L.BatchNormalization()(x)
        x = L.ReLU()(x)
        x = L.Conv2D(16, (1, 1), use_bias=False)(x)
        x = L.BatchNormalization()(x)
        x = L.ReLU()(x)
    x = L.GlobalAveragePooling2D()(x)
    out = L.Dense(NCLS)(x)
    return tf.keras.Model(inp, out, name="C_dscnn")


def model_D(flatten=False):
    return tf.keras.Sequential([
        tf.keras.Input((N, 3)),
        L.Conv1D(16, 5, use_bias=False), L.BatchNormalization(), L.ReLU(),
        L.MaxPooling1D(2),
        L.Conv1D(32, 5, use_bias=False), L.BatchNormalization(), L.ReLU(),
        L.MaxPooling1D(2),
        L.Conv1D(32, 3, use_bias=False), L.BatchNormalization(), L.ReLU(),
        L.Flatten() if flatten else L.GlobalAveragePooling1D(),
        L.Dropout(0.2),
        L.Dense(NCLS),
    ], name="D_cnn_flat" if flatten else "D_cnn")


def fit(make, seed, Xtr, ytr, val, tag):
    tf.keras.utils.set_random_seed(seed)
    tf.config.experimental.enable_op_determinism()
    model = make()
    model.compile(optimizer=tf.keras.optimizers.Adam(0.003),
                  loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
                  metrics=["accuracy"])
    cb = [tf.keras.callbacks.EarlyStopping(monitor="val_loss", patience=20, restore_best_weights=True),
          tf.keras.callbacks.ModelCheckpoint(TMP + "/best_%s.keras" % tag, monitor="val_loss", save_best_only=True)]
    kw = dict(validation_data=val) if isinstance(val, tuple) else dict(validation_split=val)
    h = model.fit(Xtr, ytr, epochs=300, batch_size=32, verbose=0, callbacks=cb, shuffle=True, **kw)
    return model, h.history


# ── слои Keras → описание для infer118b.c, BatchNormalization свёрнута в свёртку ──
def fold(model):
    """Цепочка слоёв для C. BN после свёртки уходит в её веса и сдвиг:
    w' = w·γ/√(σ²+ε), b' = (b − μ)·γ/√(σ²+ε) + β."""
    out, lin, cin = [], N, 3
    for ly in model.layers:
        if isinstance(ly, L.BatchNormalization):
            gam, bet, mu, var = [v.numpy() for v in ly.weights]
            sc = gam / np.sqrt(var + ly.epsilon)
            last = out[-1]
            last["w"] = last["w"] * (sc[:, None, None] if last["op"] == "conv" else sc[None, :])
            last["b"] = (last["b"] - mu) * sc + bet
        elif isinstance(ly, L.ReLU):
            out[-1]["relu"] = 1
        elif isinstance(ly, L.DepthwiseConv2D):
            kk, s = ly.kernel_size[0], ly.strides[0]
            w = ly.kernel.numpy()[:, 0, :, 0]          # (k, 1, c, 1) → [k][c]
            b = ly.bias.numpy() if ly.use_bias else np.zeros(cin, np.float32)
            lout = (lin - kk) // s + 1
            out.append(dict(op="dw", k=kk, s=s, cin=cin, cout=cin, lout=lout, w=w, b=b, relu=0))
            lin = lout
        elif isinstance(ly, (L.Conv1D, L.Conv2D)):
            k = ly.kernel.numpy()
            if k.ndim == 4:
                k = k[:, 0]                            # (k, 1, cin, cout) → (k, cin, cout)
            kk, s, co = k.shape[0], ly.strides[0], k.shape[2]
            w = np.transpose(k, (2, 0, 1))             # [cout][k][cin]: веса выхода лежат подряд, как окно входа
            b = ly.bias.numpy() if ly.use_bias else np.zeros(co, np.float32)
            lout = (lin - kk) // s + 1
            relu = 1 if ly.get_config().get("activation") == "relu" else 0
            out.append(dict(op="conv", k=kk, s=s, cin=cin, cout=co, lout=lout, w=w, b=b, relu=relu))
            lin, cin = lout, co
        elif isinstance(ly, L.Dense):
            w = ly.kernel.numpy().T                    # [выход][вход]
            relu = 1 if ly.get_config().get("activation") == "relu" else 0
            out.append(dict(op="dense", k=1, s=1, cin=w.shape[1], cout=w.shape[0], lout=1, w=w,
                            b=ly.bias.numpy(), relu=relu))
            lin, cin = 1, w.shape[0]
        elif isinstance(ly, L.MaxPooling1D):
            p = ly.pool_size[0]
            out.append(dict(op="maxpool", k=p, s=p, cin=cin, cout=cin, lout=lin // p, w=None, b=None, relu=0))
            lin //= p
        elif isinstance(ly, (L.GlobalAveragePooling1D, L.GlobalAveragePooling2D, L.GlobalMaxPooling1D)):
            op = "gmp" if isinstance(ly, L.GlobalMaxPooling1D) else "gap"
            out.append(dict(op=op, k=lin, s=1, cin=cin, cout=cin, lout=1, w=None, b=None, relu=0))
            lin = 1
        elif isinstance(ly, L.Flatten):
            cin, lin = cin * lin, 1                    # раскладка [время][канал] уже плоская
        elif isinstance(ly, (L.Dropout, L.Reshape, L.InputLayer)):
            pass                                       # при выводе Dropout ничего не делает, Reshape — та же память
        else:
            raise ValueError(ly.name)
    return out


def macs(ly):
    if ly["op"] == "conv":
        return ly["lout"] * ly["cout"] * ly["k"] * ly["cin"]
    if ly["op"] == "dw":
        return ly["lout"] * ly["cout"] * ly["k"]
    if ly["op"] == "dense":
        return ly["cin"] * ly["cout"]
    return 0


def act_floats(chain):
    """Самый большой тензор цепочки: под него два буфера C, вход и выход слоя по очереди."""
    return max([N * 3] + [ly["lout"] * ly["cout"] for ly in chain])


def tflite_export(model, tag, batch=1):
    """SavedModel → TFLiteConverter (float) → .tflite: размер, операторы, пик живых тензоров.
    batch=1 — размер пакета закреплён в сигнатуре; None — как по умолчанию, пакет любой."""
    sm = TMP + "/sm_%s_%s" % (tag, batch)
    model.export(sm, verbose=False, input_signature=[tf.TensorSpec((batch, N, 3), tf.float32)])
    blob = tf.lite.TFLiteConverter.from_saved_model(sm).convert()
    open(TMP + "/%s.tflite" % tag, "wb").write(blob)
    it = tf.lite.Interpreter(model_content=blob,
                             experimental_op_resolver_type=tf.lite.experimental.OpResolverType.BUILTIN_REF)
    it.allocate_tensors()
    ops = it._get_ops_details()
    td = {d["index"]: d for d in it.get_tensor_details()}
    din, dout = it.get_input_details()[0], it.get_output_details()[0]
    made = {i for op in ops for i in op["outputs"]} | {din["index"]}
    const = {i for op in ops for i in op["inputs"] if i >= 0 and i not in made}
    nbytes = lambda i: int(np.prod(td[i]["shape"])) * 4
    first, last = {din["index"]: 0}, {dout["index"]: len(ops) - 1}
    for n, op in enumerate(ops):
        for i in op["outputs"]:
            first.setdefault(i, n)
        for i in op["inputs"]:
            if i in made:
                last[i] = n
    peak = max(sum(nbytes(i) for i in first if first[i] <= n <= last.get(i, first[i])) for n in range(len(ops)))
    res = []
    for row in X[mte]:
        it.set_tensor(din["index"], row[None])
        it.invoke()
        res.append(it.get_tensor(dout["index"])[0].copy())
    return dict(size=len(blob), ops=[op["op_name"] for op in ops], peak=peak,
                wconst=sum(nbytes(i) for i in const), out=np.array(res, np.float32))


ARCH = (("A", model_A), ("B", model_B), ("C", model_C), ("D", model_D))
R = {}
for name, make in ARCH:
    m, hist = fit(make, SEED, X[mtr], y[mtr], (X[mva], y[mva]), name)
    logit = m.predict(X[mte], verbose=0).astype(np.float32)
    p = logit.argmax(1)
    vl = np.array(hist["val_loss"])
    ch = fold(m)
    tfl = tflite_export(m, name)
    dyn = tflite_export(m, name, batch=None)
    R[name] = dict(model=m, hist=hist, logit=logit, chain=ch)
    nw = sum(ly["w"].size + ly["b"].size for ly in ch if ly["w"] is not None)
    be = int(vl.argmin())
    lines += ["%s_params=%d" % (name, m.count_params()),
              "%s_trainable=%d" % (name, sum(int(np.prod(v.shape)) for v in m.trainable_weights)),
              "%s_folded=%d" % (name, nw),
              "%s_layer_macs=%s" % (name, ",".join("%s:%d" % (ly["op"], macs(ly)) for ly in ch)),
              "%s_mac=%d" % (name, sum(macs(ly) for ly in ch)),
              "%s_act=%d" % (name, act_floats(ch)),
              "%s_epochs=%d,%d" % (name, len(vl), be + 1),
              "%s_val=%d,%d" % (name, int(round(hist["val_accuracy"][be] * mva.sum())), mva.sum()),
              "%s_test=%d,%d" % (name, int((p == y[mte]).sum()), mte.sum()),
              "%s_f1=%s" % (name, ",".join("%.3f" % v for v in f1_score(y[mte], p, average=None))),
              "%s_f1macro=%.3f" % (name, f1_score(y[mte], p, average="macro")),
              "%s_cm=%s" % (name, ",".join(str(v) for v in confusion_matrix(y[mte], p).ravel())),
              "%s_tflite=%d" % (name, tfl["size"]),
              "%s_tflite_w=%d" % (name, tfl["wconst"]),
              "%s_tflite_peak=%d" % (name, tfl["peak"]),
              "%s_ops=%s" % (name, ",".join(tfl["ops"])),
              "%s_ops_anybatch=%s;%d" % (name, ",".join(dyn["ops"]), dyn["size"]),
              "%s_tflite_same=%d,%.1e" % (name, int((tfl["out"].argmax(1) == p).sum()),
                                          float(np.abs(tfl["out"] - logit).max()))]

# кривая обучения модели C: потери и доля верных на обучении и подборе
hC = R["C"]["hist"]
for e in (1, 5, 10, 20, 40, 80, 120, 160, len(hC["loss"])):
    if e <= len(hC["loss"]):
        lines.append("C_curve%d=%.3f,%.3f,%.3f,%.3f" % (e, hC["loss"][e - 1], hC["val_loss"][e - 1],
                                                       hC["accuracy"][e - 1], hC["val_accuracy"][e - 1]))

# ── зерно: та же архитектура, другие начальные веса и порядок примеров ──
for name, make in ARCH:
    acc = []
    for sd in (1, 2, 3):
        m, _ = fit(make, sd, X[mtr], y[mtr], (X[mva], y[mva]), name + "s")
        acc.append(int((m.predict(X[mte], verbose=0).argmax(1) == y[mte]).sum()))
    lines.append("%s_seeds=%s" % (name, ",".join(str(a) for a in acc)))

# ── validation_split: Keras берёт последние 20 % массива, и берёт их ДО перемешивания ──
pool = mtr | mva
order = np.argsort(y[pool], kind="stable")       # окна сложены по классам, как из пяти файлов подряд
Xs, ys = X[pool][order], y[pool][order]
nval = int(len(ys) * 0.2)
m, hist = fit(model_C, SEED, Xs, ys, 0.2, "vs_sorted")
lines.append("vsplit_sorted=%s;%d;%d;%d,%d" % ("+".join(str(c) for c in sorted(set(ys[-nval:]))), nval,
                                               int((m.predict(X[mte], verbose=0).argmax(1) == y[mte]).sum()),
                                               len(hist["loss"]), int(np.argmin(hist["val_loss"])) + 1))
sh = np.random.RandomState(SEED).permutation(len(ys))   # окна перемешаны: в подборе — те же участники
for name, make in (("A", model_A), ("C", model_C)):
    Xs, ys = X[pool][sh], y[pool][sh]
    m, hist = fit(make, SEED, Xs, ys, 0.2, "vs_shuffled")
    vl = np.array(hist["val_loss"])
    lines.append("%s_vsplit_shuffled=%d,%d;%d" % (name, int(round(hist["val_accuracy"][int(vl.argmin())] * nval)), nval,
                                                  int((m.predict(X[mte], verbose=0).argmax(1) == y[mte]).sum())))

# ── Flatten вместо глобального пулинга в модели D ──
m, _ = fit(lambda: model_D(flatten=True), SEED, X[mtr], y[mtr], (X[mva], y[mva]), "Dflat")
lines.append("Dflat=%d,%d" % (m.count_params(), int((m.predict(X[mte], verbose=0).argmax(1) == y[mte]).sum())))

# ── model.summary() модели C ──
txt = []
R["C"]["model"].summary(print_fn=lambda s, **k: txt.append(s), line_length=80)
open(OUT + "/summaryC118b.txt", "w", encoding="utf-8", newline="\n").write("\n".join(txt) + "\n")


# ── заголовок для C и отложенные окна ──
def c_float(v):
    s_ = "%.9g" % v
    return (s_ if any(ch in s_ for ch in ".en") else s_ + ".0") + "f"


def c_arr(nm, a, typ):
    flat = np.asarray(a).ravel()
    body = ", ".join(c_float(v) if typ == "float" else str(int(v)) for v in flat)
    return "static const %s %s[%d] = { %s };\n" % (typ, nm, len(flat), body)


h = ["/* Раздел 118а: четыре модели Keras, BatchNormalization свёрнута — сгенерировано train118b.py, не править */\n",
     "#include \"layer118b.h\"\n",
     "#define N_IN %d\n#define N_CH 3\n#define N_CLS %d\n#define N_TEST %d\n" % (N, NCLS, int(mte.sum()))]
for name, _ in ARCH:
    ch = R[name]["chain"]
    for i, ly in enumerate(ch):
        if ly["w"] is not None:
            h.append(c_arr("%s_W%d" % (name, i), ly["w"].astype(np.float32), "float"))
            h.append(c_arr("%s_B%d" % (name, i), ly["b"].astype(np.float32), "float"))
    h.append("static const layer_t %s_L[%d] = {\n" % (name, len(ch)))
    for i, ly in enumerate(ch):
        wb = ("%s_W%d, %s_B%d" % (name, i, name, i)) if ly["w"] is not None else "0, 0"
        h.append("    { OP_%s, %d, %d, %d, %d, %d, %s },\n" % (ly["op"].upper(), ly["k"], ly["s"], ly["cin"],
                                                              ly["cout"], ly["relu"], wb))
    h.append("};\n#define %s_NL %d\n#define %s_ACT %d\n" % (name, len(ch), name, act_floats(ch)))
xte = Xi[mte]
h.append("#ifdef ML_BENCH\n" + c_arr("BENCH_X", xte[0], "int16_t") + "#endif\n")
open(OUT + "/model118b.h", "w", encoding="utf-8", newline="\n").write("".join(h))
with open(OUT + "/test118b.bin", "wb") as fb:
    fb.write(xte.astype("<i2").tobytes())
    fb.write(y[mte].astype(np.uint8).tobytes())
    for name, _ in ARCH:
        fb.write(R[name]["logit"].astype("<f4").tobytes())
open(OUT + "/summary118b.txt", "w", encoding="utf-8", newline="\n").write("\n".join(lines) + "\n")
print("\n".join(lines))

layer118b.h — the layer description for the C inference.

/* Раздел 118а: слой модели для infer118b.c — что делать, размеры и веса. */
#ifndef LAYER118B_H
#define LAYER118B_H
#include <stdint.h>

enum { OP_CONV, OP_DW, OP_DENSE, OP_MAXPOOL, OP_GAP, OP_GMP };

typedef struct {
    uint8_t op;         /* OP_... */
    uint8_t k, s;       /* ядро (или окно пулинга) и шаг по времени */
    uint16_t cin, cout; /* каналов на входе и выходе; у Dense — длина входа и выхода */
    uint8_t relu;       /* 1 — ReLU после слоя */
    const float *w, *b; /* веса и сдвиги, BatchNormalization уже внутри */
} layer_t;

#endif

infer118b.c — float inference of the four models: one loop for all layers, a cross-check against Keras on the computer.

/* Раздел 118а: вывод четырёх моделей Keras во float на C — один цикл на все слои.
 * Вход — окно акселерометра [64 отсчёта][3 оси] в mg, как отдаёт датчик.
 * Раскладка тензоров — [время][канал], как в Keras (channels_last): Flatten ничего не двигает.
 * Сборка на компьютере с -DML_HOST сверяет ответы с Keras на всех отложенных окнах. */
#include "model118b.h"

#define MAX2(a, b) ((a) > (b) ? (a) : (b))
#define ACT_MAX MAX2(MAX2(A_ACT, B_ACT), MAX2(C_ACT, D_ACT))

static float buf[2][ACT_MAX]; /* вход и выход слоя по очереди */

static const struct {
    const layer_t *l;
    int n;
} MODELS[4] = { { A_L, A_NL }, { B_L, B_NL }, { C_L, C_NL }, { D_L, D_NL } };

/* Один проход модели m: x — [N_IN][N_CH] во float (g), ответ — N_CLS чисел до softmax */
void run_model(int m, const float *x, float *logit)
{
    const layer_t *ly = MODELS[m].l;
    const float *in = x;
    int nl = MODELS[m].n, len = N_IN, ch = N_CH, cur = 0;

    for (int n = 0; n < nl; n++, ly++) {
        float *out = (n == nl - 1) ? logit : buf[cur];
        int lout = 1;
        switch (ly->op) {
        case OP_CONV: {                       /* свёртка: окно k отсчётов × cin каналов подряд в памяти */
            int span = ly->k * ly->cin;
            lout = (len - ly->k) / ly->s + 1;
            for (int t = 0; t < lout; t++) {
                const float *win = in + t * ly->s * ly->cin;
                for (int o = 0; o < ly->cout; o++) {
                    const float *w = ly->w + o * span;
                    float acc = ly->b[o];
                    for (int j = 0; j < span; j++)
                        acc += win[j] * w[j];
                    out[t * ly->cout + o] = (ly->relu && acc < 0.0f) ? 0.0f : acc;
                }
            }
            ch = ly->cout;
            break;
        }
        case OP_DW:                           /* depthwise: у каждого канала своё ядро, каналы не смешиваются */
            lout = (len - ly->k) / ly->s + 1;
            for (int t = 0; t < lout; t++)
                for (int c = 0; c < ch; c++) {
                    float acc = ly->b[c];
                    for (int j = 0; j < ly->k; j++)
                        acc += in[(t * ly->s + j) * ch + c] * ly->w[j * ch + c];
                    out[t * ch + c] = (ly->relu && acc < 0.0f) ? 0.0f : acc;
                }
            break;
        case OP_DENSE:                        /* полносвязный: вход — весь предыдущий тензор подряд */
            for (int o = 0; o < ly->cout; o++) {
                const float *w = ly->w + o * ly->cin;
                float acc = ly->b[o];
                for (int i = 0; i < ly->cin; i++)
                    acc += in[i] * w[i];
                out[o] = (ly->relu && acc < 0.0f) ? 0.0f : acc;
            }
            ch = ly->cout;
            break;
        case OP_MAXPOOL:
            lout = len / ly->k;
            for (int t = 0; t < lout; t++)
                for (int c = 0; c < ch; c++) {
                    float v = in[t * ly->k * ch + c];
                    for (int j = 1; j < ly->k; j++)
                        v = MAX2(v, in[(t * ly->k + j) * ch + c]);
                    out[t * ch + c] = v;
                }
            break;
        case OP_GAP:                          /* среднее по времени: длина входа больше не важна */
        case OP_GMP:                          /* максимум по времени */
            for (int c = 0; c < ch; c++) {
                float v = in[c];
                for (int t = 1; t < len; t++)
                    v = ly->op == OP_GAP ? v + in[t * ch + c] : MAX2(v, in[t * ch + c]);
                out[c] = ly->op == OP_GAP ? v / (float)len : v;
            }
            break;
        }
        in = out;
        len = lout;
        cur ^= 1;
    }
}

/* Окно в mg → вход модели в g → номер класса */
int classify118b(int m, const int16_t *raw)
{
    float x[N_IN * N_CH], logit[N_CLS];
    int best = 0;
    for (int i = 0; i < N_IN * N_CH; i++)
        x[i] = (float)raw[i] / 1000.0f;
    run_model(m, x, logit);
    for (int c = 1; c < N_CLS; c++)
        if (logit[c] > logit[best])
            best = c;
    return best;
}

#if defined(ML_HOST)
#include <math.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
    static int16_t xr[N_TEST][N_IN * N_CH];
    static uint8_t yl[N_TEST];
    static float ref[4][N_TEST][N_CLS];
    FILE *f = fopen(argc > 1 ? argv[1] : "test118b.bin", "rb");
    if (!f || fread(xr, sizeof xr, 1, f) != 1 || fread(yl, sizeof yl, 1, f) != 1 || fread(ref, sizeof ref, 1, f) != 1) {
        printf("нет test118b.bin\n");
        return 1;
    }
    fclose(f);
    for (int m = 0; m < 4; m++) {
        int agree = 0, right = 0;
        double dmax = 0.0;
        for (int n = 0; n < N_TEST; n++) {
            float x[N_IN * N_CH], lg[N_CLS];
            int kc = 0, kp = 0;
            for (int i = 0; i < N_IN * N_CH; i++)
                x[i] = (float)xr[n][i] / 1000.0f;
            run_model(m, x, lg);
            for (int c = 0; c < N_CLS; c++) {
                dmax = fmax(dmax, fabs((double)lg[c] - ref[m][n][c]));
                if (lg[c] > lg[kc])
                    kc = c;
                if (ref[m][n][c] > ref[m][n][kp])
                    kp = c;
            }
            agree += kc == kp;
            right += kc == yl[n];
            if (classify118b(m, xr[n]) != kc)
                return 2;
        }
        printf("%c_agree=%d,%d\n%c_right=%d\n%c_maxdiff=%.1e\n", 'A' + m, agree, N_TEST, 'A' + m, right, 'A' + m, dmax);
    }
    printf("act_max=%d\n", ACT_MAX);
    return 0;
}
#elif !defined(ML_BENCH)
volatile int16_t g_raw[N_IN * N_CH];
volatile int g_model, g_class;
void SystemInit(void) {}
int main(void)
{
    for (;;) {
        int16_t raw[N_IN * N_CH];
        for (int i = 0; i < N_IN * N_CH; i++)
            raw[i] = g_raw[i];
        g_class = classify118b(g_model & 3, raw);
    }
}
#endif

Next: section 119 "int8 quantization" — the same weights in integers, CMSIS-NN and TensorFlow Lite Micro.

Open in the interactive handbook