Embedded HandbookПриложения
Все разделы › XIV. Машинное обучение на микроконтроллере
STM32F407 · уровень средний · 39 мин чтения
Открыть в интерактивном справочнике

118а. Keras для TinyML: архитектуры под микроконтроллер — плотная, 1D-свёртка, depthwise-separable, маленькая CNN — обучение, метрики и экспорт в .tflite

В разделе 118 модель училась в scikit-learn на четырёх признаках, которые посчитал человек. Здесь сеть получает само окно с датчика и признаки ищет сама, а учится она в Keras — части TensorFlow, на которой обучают большинство моделей для TensorFlow Lite. Раздел проходит путь от массива отсчётов до файла .tflite: как собрать модель из слоёв, как её учить и когда остановиться, чем мерить качество, сколько она займёт во флеш-памяти и ОЗУ и сколько команд потратит Cortex-M4 на один ответ.

Задача одна на все модели — пять жестов с трёхосевого акселерометра, — и четыре архитектуры решают её по-разному: плотная сеть, одна 1D-свёртка, depthwise-separable и маленькая свёрточная сеть. Обучение идёт в образе stm-tf:1 — TensorFlow 2.21.0, Keras 3.15.1, numpy 2.3.3 — и повторяется байт в байт. Вывод тех же моделей написан на C и сверен с Keras на всех отложенных окнах.

Опоры: что такое TinyML и тензорная арена — раздел 85; подходы от порога до перцептрона — раздел 116; как нейросеть учится (градиент, шаг, эпоха) — раздел 116б; окна и признаки — раздел 117; разбиение по записям — 118.3; квантование в int8, TensorFlow Lite Micro и CMSIS-NN — раздел 119.

Как это называется в документации

Оригинал Перевод
layer слой
Sequential / functional API последовательная модель / модель из вызовов слоёв
Dense (fully connected) полносвязный слой
Conv1D, Conv2D свёртка по одной оси (времени) и по двум
depthwise separable convolution свёртка по каналам отдельно, затем 1 × 1
pointwise convolution свёртка 1 × 1: смешивает каналы
pooling, global average pooling прореживание: максимум или среднее по окну, по всей длине
BatchNormalization нормировка по пакету: сдвиг и масштаб каналов
Dropout выключение части нейронов при обучении
epoch, batch эпоха: один проход по данным; пакет: примеров за шаг
loss, optimizer функция потерь, оптимизатор
logits выходы до softmax
callback обратный вызов: код, который fit зовёт после эпохи
early stopping, checkpoint ранняя остановка, сохранение лучших весов
learning curve кривая обучения
F1 score F1: среднее гармоническое полноты и точности
MAC (multiply-accumulate) умножение с накоплением
tensor arena тензорная арена: ОЗУ под промежуточные тензоры
operator (op) оператор: операция в файле .tflite
SavedModel формат сохранения модели TensorFlow
seed зерно генератора случайных чисел

118а.1 Задача и данные

Прибор в руке, акселерометр на три оси, 50 отсчётов в секунду. Окно — 64 отсчёта, 1,28 с; отсчёт — в mg, целое, как отдаёт датчик. Пять классов: покой, встряхивание, круг, двойное касание и подъём. Покой — трудный класс: в двух случаях из трёх это не неподвижность, а одиночное касание или лёгкое покачивание в руке, то есть «почти жест».

Данные синтетические. Запись — один участник: свой темп (от 0,8 до 1,25 обычного), своя сила, свой наклон прибора в руке, своё смещение нуля осей до 40 mg и свой шум. 40 участников повторили каждый жест 8 раз — 1600 окон. Разбиение — по участникам, как в 118.3: 22 участника на обучение (880 окон), 6 на подбор (240 окон) и 12 на проверку (480 окон). На проверке — люди, которых модель не видела.

На вход Keras принимает массивы numpy формы «окна × время × оси»:

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)

Все 1600 окон во float — 1,2 МБ, и массивов хватает. Когда данные не помещаются в память, тот же fit принимает tf.data.Dataset, который читает их с диска пакетами; модель и обучение от этого не меняются.


118а.2 Четыре архитектуры

Четыре модели на окне акселерометра 64 × 3: плотная — Flatten и Dense, 6341 параметр и 6304 умножения, верно 454 из 480; одна Conv1D с глобальным максимумом — 401 параметр, 9132 умножения, 453; depthwise-separable — 1317 параметров, 23 408 умножений, 475; маленькая CNN из трёх Conv1D — 6357 параметров, 114 912 умножений, 473

A. Плотная. Окно 64 × 3 вытягивается в строку из 192 чисел, дальше два полносвязных слоя. Модель собрана последовательно — Sequential, слой за слоем:

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")

Слабость видна из устройства: у каждого из 192 входов свой вес, и касание в начале окна для неё — другой рисунок, чем то же касание в середине.

B. Одна 1D-свёртка. Conv1D(12, 9, strides=2) — 12 фильтров по 9 отсчётов на все три оси, шаг 2. Фильтр ищет свой рисунок в любом месте окна, а GlobalMaxPooling1D оставляет от каждого фильтра наибольший отклик: где был рисунок, уже неважно.

C. Depthwise-separable. Обычная свёртка 5 × 16 → 16 каналов — 5 · 16 · 16 = 1280 весов. Разделённая делает то же в два шага: DepthwiseConv2D — у каждого канала своё ядро из 5 весов, каналы не смешиваются (80 весов), затем свёртка 1 × 1 смешивает 16 каналов в 16 (256 весов). Вместе 336 весов — в 3,8 раза меньше. Модель собрана функциональным API: слой вызывается на выходе предыдущего, и так можно строить ветвления, которых Sequential не умеет:

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")

Свёртки идут без сдвига (use_bias=False): сразу за ними BatchNormalization, у которой сдвиг свой.

Окно здесь — картинка высотой 64 и шириной 1, оси — каналы. Ядро (5, 1) идёт только по времени, и свёртка 2D считает ровно то же, что 1D. Зачем так — видно в 118а.6: такие слои превращаются в операторы TensorFlow Lite один к одному.

D. Маленькая CNN. Три Conv1D (16, 32 и 32 фильтра) с MaxPooling1D(2) между ними — каждый пулинг вдвое укорачивает время, — затем GlobalAveragePooling1D и Dense(5). Самая «тяжёлая» по счёту модель раздела.

После каждой свёртки в C и D стоит BatchNormalization: при обучении она приводит выход канала к нулевому среднему и единичному разбросу по пакету, и сеть учится быстрее и ровнее. При выводе это просто сдвиг и масштаб канала — их сворачивают в веса свёртки (118а.6). Dropout при обучении случайно выключает часть нейронов, чтобы сеть не полагалась на один из них; при выводе он не делает ничего.


118а.3 Обучение: compile, fit и когда остановиться

Все четыре модели учатся одной функцией:

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)

Оптимизатор Adam с шагом 0,003; функция потерь — перекрёстная энтропия для меток-номеров. from_logits=True значит, что последний слой выдаёт числа до softmax: softmax нужен только для обучения, номер наибольшего выхода от него не меняется, и в прошивке его не считают. Пакет — 32 окна, 28 шагов на эпоху.

Обратные вызовы решают, когда остановиться. EarlyStopping следит за потерями на подборе и останавливает обучение, если они 20 эпох не улучшались, а restore_best_weights возвращает веса лучшей эпохи. ModelCheckpoint пишет лучшие веса в файл — если обучение упадёт или его прервут, они не пропадут. Модель C остановилась на эпохе 181, лучшая — 161; D — на 45-й, лучшая 25-я.

Кривая обучения модели C — потери и доля верных по эпохам:

Эпоха Потери, обучение Потери, подбор Верно, обучение Верно, подбор
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

Первые эпохи на подборе 0,200 — ровно один класс из пяти, хотя на обучении уже 0,959. Так ведёт себя BatchNormalization: на подборе она работает по накопленным средним, а они в первые сотни шагов ещё не догнали настоящие. С 20-й эпохи кривые сходятся. После 40-й обучение знает данные наизусть (потери около нуля), а подбор почти не меняется — дальше учить незачем.

Подбор — по участникам. У Keras есть короткий путь — validation_split=0.2: он откладывает на подбор последние 20 % массива. Последние — значит, до перемешивания. Если окна сложены по классам, как из пяти файлов подряд, в подбор ушли только окна подъёма — 224 из 224, — и модель их ни разу не видела: верно 96 из 480, ранняя остановка сработала на 21-й эпохе, лучшей была первая. Если окна перемешать, в подборе окажутся те же люди, что в обучении: у плотной модели в подборе верно 203 из 224, а на новых участниках — 426 из 480; у модели C подбор показал 224 из 224. Здесь разрыв невелик — повторы одного жеста различаются больше, чем окна одной записи в 118.3, — но число на подборе уже не говорит о новых людях. Подбор отделяют так же, как проверку: по участникам, и передают в validation_data.

Зерно. Начальные веса, порядок примеров и Dropout случайны. Без зерна каждый запуск даёт другую модель:

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

Первая строка задаёт зерно Python, numpy и TensorFlow, вторая запрещает TensorFlow быстрые, но неповторяемые пути счёта. С ними повторное обучение дало те же веса до бита. Насколько велик разброс, видно на других зёрнах: зёрна 1, 2 и 3 дали 438, 458, 448 верных у A и 477, 478, 479 у C. Разница плотной модели в 20 окон — больше, чем её отрыв от модели B на основном зерне. Сравнивая архитектуры, смотрят на несколько зёрен, а не на один запуск.


118а.4 Метрики: доля верных, матрица ошибок, F1

Проверка — 480 окон 12 участников, один раз в конце:

Модель Параметров MAC Верно из 480 % F1 среднее по классам
A. Плотная 6341 6304 454 94,6 0,946
B. Одна Conv1D 401 9132 453 94,4 0,944
C. Depthwise-separable 1317 23 408 475 99,0 0,990
D. Маленькая CNN 6357 114 912 473 98,5 0,985

Ответ модели — номер наибольшего из пяти выходов:

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

Доля верных скрывает, где ошибки. Матрица ошибок модели C (строки — правильный класс, столбцы — ответ):

Верно / ответ покой встряхивание круг двойное касание подъём
Покой 96 0 0 0 0
Встряхивание 0 96 0 0 0
Круг 5 0 91 0 0
Двойное касание 0 0 0 96 0
Подъём 0 0 0 0 96

F1 по классам: 0,975; 1,000; 0,973; 1,000; 1,000. F1 — среднее гармоническое полноты и точности класса (118.8): оно падает и от пропусков, и от ложных ответов. Здесь пять кругов приняты за покой — у круга упала полнота, у покоя точность, — и F1 просел у обоих.

Модели A и B ошибаются в другом месте. Плотная путает покой с двойным касанием: покой за двойное касание в 9 окнах, двойное касание за покой — в 13. У B то же, сильнее: 17 окон покоя ушли в двойное касание. Причина у B — в глобальном максимуме: от фильтра остаётся наибольший отклик, а одно касание и два дают его одинаковым. Сосчитать касания такая модель не может. У C и D до глобального среднего стоят несколько свёрток, и двойное касание обе узнали в 96 окнах из 96.

Если один класс важнее остальных — пропущенный жест хуже ложного или наоборот, — смотрят F1 и полноту этого класса, а не среднее.


118а.5 Параметры и MAC: во флеш, ОЗУ и команды

model.summary() печатает слои, формы выходов и параметры. Для модели C (начало и конец):

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 — не размер модели. Keras 3 после fit добавляет к нему состояние оптимизатора: Adam хранит два числа на каждый обучаемый вес и счётчик шагов, 2 · 1157 + 2 = 2316. Самой модели — 1317 чисел. Из них 320 — пять слоёв BatchNormalization по 64: 160 обучаемых (масштаб и сдвиг) и 160 накопленных средних и разбросов. После свёртки BN в модели 1077 чисел: 997 весов и 80 сдвигов — по 16 у каждой из пяти свёрток.

Вручную, слой за слоем (L — длина по времени, C — каналы):

Слой Выход Параметры MAC
Conv2D 16 × (5, 1), шаг 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 после свёртки
Всего 1317 23 408

Без дополнения краёв (padding="valid", так по умолчанию) свёртка с ядром k укорачивает время на k − 1, а шаг 2 ещё и делит его пополам: 64 → 30, дальше 30 → 26 → 22. MAC свёртки — длина выхода × каналы выхода × ядро × каналы входа; у depthwise каналы входа не перемножаются. Параметры определяют флеш-память, MAC — время, а размер тензоров между слоями — ОЗУ, и это три разных числа: у D параметров столько же, сколько у A, а умножений в 18 раз больше.

Во что это превращается на микроконтроллере, измерено на выводе на C (infer118b.c: один цикл на все слои, веса во float, BN свёрнута) и на файле .tflite:

A B C D
Веса после свёртки BN, байт 25 364 1604 4308 24 468
.tflite, байт 27 068 4212 9700 29 932
Буферы слоёв на C, байт 1536 2688 3840 7680
Пик живых тензоров .tflite, байт 1536 2688 3584 5760
Команд на вывод, M4 33 500 56 000 164 700 626 300
Команд на один MAC 5,3 6,1 7,0 5,5

Флеш-память. Веса — 4 байта на параметр. Код, который их считает, общий для всех моделей: функция run_model — 1074 байт кода с -Os и 4634 с -O2 (armclang, Cortex-M4); на Cortex-M7 — 1040 и 2270.

ОЗУ. Вывод на C держит два буфера: вход слоя и выход, по очереди. Их размер задаёт самый большой тензор модели. У D это выход первой свёртки 60 × 16 = 960 чисел: свёртка без шага, пулинг только после неё. У C первая свёртка идёт с шагом 2, и самый большой тензор — 30 × 16. Ещё 768 байт — окно во float на стеке. Для .tflite посчитана сумма тензоров, живых одновременно, — от операции, которая тензор пишет, до последней, которая его читает. Это только оценка арены: TensorFlow Lite Micro раскладывает тензоры по своему плану и добавляет служебные данные, а точное число на плате сообщает arena_used_bytes() интерпретатора.

Команды. Тот же C собран arm-none-eabi-gcc -O2 и запущен в QEMU: netduinoplus2 — Cortex-M4F, как у STM32F405; mps2-an500 — Cortex-M7. QEMU с -icount ведёт время по числу команд. На Cortex-M7 команд столько же — для C 164 740 против 164 736, — код тот же. Внутренний цикл свёртки и Dense — пять команд на умножение: две загрузки vldmia, vfma, сравнение и переход; у depthwise — восемь, адреса входа и веса сдвигаются на число каналов отдельными сложениями. С обвязкой циклов выходит 5,3–7,0 команды на MAC. При 168 МГц 164 700 команд — около 0,98 мс, если бы каждая команда занимала один такт; у D — 3,73 мс. Окно приходит раз в 1,28 с, так что любая из моделей укладывается с запасом. Это команды, а не такты: на кристалле добавятся ожидания флеш-памяти и переходы, а Cortex-M7 умеет выполнять пары команд одновременно (dual issue, DUI0646B), и у него тактов может быть меньше, чем команд.


118а.6 Экспорт: SavedModel → .tflite и список операторов

Модель сохраняется как SavedModel, и из него конвертер TensorFlow Lite делает файл. Здесь он во float — квантование в int8 разобрано в 119:

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

Интерпретатор TensorFlow Lite на этих файлах ответил тем же классом, что Keras, во всех 480 окнах у всех четырёх моделей. В .tflite попали операторы:

Модель Операторы
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

Что видно в этом списке:

Важнее всего — кто эти операторы выполнит. В паке TensorFlow Lite Micro 1.23.2 (Keil) все операторы из таблицы есть в списке MicroMutableOpResolver. Ускоренные ядра CMSIS-NN в том же паке — только для add, conv, depthwise_conv, fully_connected, mul, pooling, softmax и svdf. MEAN модели C и D и REDUCE_MAX модели B пойдут простыми ядрами TFLM — для глобального пулинга это немного: один проход по 22 × 16 числам. Но правило общее: прежде чем выбирать слой, проверить, во что он превратится и есть ли для этого ядро. Как модель квантуется и запускается в TFLM, — раздел 119.


118а.7 Грабли

На подборе всё хорошо, а на новых людях хуже — или один класс модель не видела вовсе. validation_split берёт хвост массива до перемешивания: окна по классам — в подборе один класс, окна вперемешку — в подборе те же люди. Подбор отделять по участникам и передавать в validation_data.

model.summary() показывает в три раза больше параметров. После fit в Keras 3 Total params включает состояние оптимизатора: 3633 у модели из 1317 чисел. Размер модели — сумма обучаемых и необучаемых параметров, а после свёртки BN — ещё меньше.

Первые эпохи подбор застыл на одном классе. Накопленные средние BatchNormalization ещё не догнали данные. Не останавливать обучение раньше, чем они догонят: patience в десятки эпох.

Каждый запуск — другие веса и другое качество. Не задано зерно или не включён детерминизм. set_random_seed и enable_op_determinism; сравнивать архитектуры по нескольким зёрнам — у A разброс 20 окон из 480.

Flatten перед Dense раздувает веса. У A 6176 из 6341 параметра — первый Dense после Flatten, и модель привязана к месту жеста в окне. У D замена глобального пулинга на Flatten дала 7957 параметров вместо 6357, верно 454 вместо 473. Глобальный пулинг — и меньше, и не зависит от сдвига.

Лишние операторы в .tflite. Пакет любого размера: SHAPE, STRIDED_SLICE, PACK, EXPAND_DIMS. Задать пакет 1 в сигнатуре экспорта.

Оператор, которого нет в TFLM или в CMSIS-NN. Слой Keras не равен оператору: проверять список операторов .tflite по MicroMutableOpResolver и по ядрам CMSIS-NN до того, как модель обучена. Глобальный максимум к тому же не умеет считать события — двойное касание B путал с одиночным.

Большая арена. ОЗУ решает самый большой тензор между слоями, а не веса: у D он вдвое больше, чем у C, из-за первой свёртки без шага. Шаг или пулинг в первом слое режет память сразу.


118а.8 Итоги

Полные исходники

train118b.py — данные, четыре модели, обучение, метрики, экспорт в .tflite и заголовок для 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 — описание слоя для вывода на C.

/* Раздел 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: один цикл на все слои, сверка с Keras на компьютере.

/* Раздел 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

Дальше: раздел 119 «Квантование int8» — те же веса в целых числах, CMSIS-NN и TensorFlow Lite Micro.

Открыть в интерактивном справочнике