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
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:
- There is no BatchNormalization. The converter folded it into the
convolution: w′ = w · γ / √(σ² + ε), b′ = (b − μ) · γ / √(σ² + ε) + β, where
γ and β are the scale and shift of the layer, μ and σ² the accumulated mean
and variance. It is folded the same way for the C inference — the
foldfunction intrain118b.py— and C matched Keras in all windows: the logit difference is at most 1.5e-05. - Conv1D became CONV_2D. TensorFlow Lite has no one-dimensional convolution: Conv1D, MaxPooling1D and GlobalAveragePooling are CONV_2D, MAX_POOL_2D and MEAN over a tensor of height 1.
- The batch must be fixed. By default the SavedModel signature accepts a
batch of any size. Then the converter cannot fold the shapes in advance: A
and C get SHAPE, STRIDED_SLICE and PACK — to compute the shape for Flatten
and Reshape at run time — and in D every Conv1D and MaxPooling1D gets a
pair of EXPAND_DIMS and RESHAPE, and there are 17 operators instead of 9.
The files also grow: A is 27868 bytes instead of 27068, D is 32440 instead
of 29932. In the line above the batch is set:
batch= 1. - File size is weights plus description. B has 1604 bytes of weights, and the file is 4212: the rest describes tensors, operators and shapes. On small models this share is large.
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
- One task — 1600 windows of 40 participants, the test on 480 windows of 12 new participants; splitting and tuning are by participant.
- Depthwise-separable C: 1317 parameters, 23408 MAC, 475 of 480 correct, F1 0.990. Small CNN D: 6357 parameters, 114912 MAC, 473. Dense A and a single convolution B — 454 and 453.
- Callbacks: EarlyStopping with
restore_best_weightsand ModelCheckpoint; the learning curve shows when further training is useless. validation_spliton windows stacked by class — a tuning set of one class and 96 correct of 480.- Seed and determinism: repeated training gave the same weights down to the bit; without them A spreads over 438–458.
Total paramsafterfitincludes the optimizer: 3633 instead of 1317.- Flash is 4 bytes per parameter after folding BN, RAM is the largest tensor, time is MAC: C has 4308 bytes of weights, 3840 bytes of buffers, 164700 instructions per answer.
- Export: SavedModel → TFLiteConverter, batch 1; BN is folded, Conv1D becomes CONV_2D; all operators are in TFLM 1.23.2, MEAN and REDUCE_MAX have no CMSIS-NN kernels.
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.