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

119а. Оптимизация модели для микроконтроллера: прунинг, кластеризация весов, дистилляция, выбор разрядности — размер, скорость и качество в реальной среде

В разделе 119 сеть сжата квантованием: те же веса, но в int8. Это одна ступень из нескольких. Модель можно уменьшить и по-другому: обнулить слабые веса (прунинг), убрать целые нейроны, заменить веса номерами из короткой таблицы (кластеризация), выучить маленькую сеть на ответах большой (дистилляция) или просто взять сеть уже. Здесь все способы проведены на одной задаче и измерены одинаково: верные ответы на отложенных записях, байты во флеш-памяти, команды на ответ в QEMU и энергия на ответ по даташиту F407. В конце — что бывает с моделью в поле: другой датчик, шум, порог уверенности и за чем следить на приборе.

Опоры: numpy 2.3.3 и scikit-learn 1.7.2 (только KMeans) в образе stm-ml:1, скрипт train119b.py. Пакета tensorflow_model_optimization в образах нет — маски прунинга, общие веса кластеров и мягкие ответы учителя написаны в самом скрипте; повторный прогон даёт те же файлы. Вывод на C (infer119b.c) собран gcc 12.2.0 (stm-sys:1) для сверки, armclang 6.24 — размеры, arm-none-eabi-gcc 12.2.1 и QEMU 7.2 (stm-arm:1, платы netduinoplus2 со STM32F405 и mps2-an500 с Cortex-M7) — счёт команд. Ток — даташит DS8626 ред. 9 из пака Keil. Квантование подробно — раздел 119, обучение — 118, признаки — 117.

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

Оригинал Перевод
pruning прунинг: обнуление части весов
magnitude pruning прунинг по величине: обнулить самые малые по модулю
unstructured / structured pruning неструктурный — отдельные веса; структурный — целые нейроны или каналы
sparsity разреженность: доля нулей
gradual pruning, pruning schedule постепенный прунинг по расписанию
fine-tuning дообучение
sparse format, CSR (compressed sparse row) разреженный формат: ненулевые значения и номера их входов
weight clustering, weight sharing кластеризация весов: много весов — одно общее значение
codebook, centroid таблица центров, центр кластера
knowledge distillation дистилляция знаний
teacher, student учитель, ученик
soft targets, temperature мягкие ответы, температура
half precision (float16) половинная точность: 2 байта на число
latency, energy per inference задержка, энергия на ответ
distribution shift, sensor drift сдвиг данных, дрейф датчика
confidence threshold порог уверенности: ниже него ответ «не уверен»

119а.1 Задача, мера и разброс

Аналоговый акселерометр на корпусе мотора, АЦП STM32 — 12 бит на ±4 g, 1024 Гц, окно 256 отсчётов (250 мс). Признаки — 64 бина спектра с окном Ханна, от 4 до 256 Гц, в дБ относительно 1 g:

    q = np.clip(np.round(x / 8.0 * 4096 + 2048), 0, 4095)
    x = (q - 2048) * 8.0 / 4096
    x = x - x.mean(axis=1, keepdims=True)
    m = np.abs(np.fft.rfft(x * hann, axis=1))[:, 1:NB + 1] * (2.0 / N)
    return (20 * np.log10(m + 1e-4)).astype(np.float32)

Четыре класса: норма, дисбаланс (растёт гармоника частоты вращения около 25 Гц), перекос валов (растут вторая и третья гармоники) и подшипник (несущая 120…200 Гц, модулированная вращением). Шум датчика — 0,01 g, у каждой записи своё крепление ±10 %. Всего 160 записей по 10 окон; записи делятся целиком (раздел 118): обучение — 96 записей и 960 окон, подбор — 32 и 320, проверка — 32 и 320.

Рабочая сеть — 64 → 32 → 4 во float32, 2212 параметров: верно 278 из 320 окон проверки. Учитель — 64 → 128 → 64 → 4, 16 836 параметров: 283.

Учитель 64 → 128 → 64 → 4 даёт 283 верных из 320 при 67 344 байтах весов и 103 025 командах на ответ; рабочая сеть 64 → 32 → 4 — 278, 8848 байт и 14 125 команд; из неё получены прунинг 90 % в формате CSR — 280, 1885 байт и 3863 команды, тот же прунинг в плотном формате — 280, 8848 байт и те же 14 125 команд, структурный прунинг до 8 нейронов — 278, 2224 байта и 4021 команда, 16 кластеров — 277, 1360 байт и 31 607 команд, int8 — 277, 2464 байта и 15 240 команд, float16 — 278, 4496 байт и 18 545 команд; ученик 64 → 4 → 4 на мягких ответах учителя — 278 вместо 264 без учителя, 1120 байт и 2337 команд

Прежде чем сравнивать, надо знать шум измерения. Та же сеть, обученная с пятью другими начальными весами, дала на проверке от 271 до 282. Вторая проверка — ещё 32 новые записи из того же генератора — у рабочей сети 301 из 320. Значит, разница в несколько окон — не выигрыш, а случай; выбирать вариант надо по подборочной выборке, а проверку трогать один раз.


119а.2 Прунинг по величине: нули без скорости

Неструктурный прунинг обнуляет долю самых малых по модулю весов слоя. Прорежен скрытый слой — 2048 весов; выходной слой (128 весов) оставлен целым:

    k = int(round(sparsity * W.size))
    thr = np.sort(np.abs(W).ravel())[k - 1]
    return (np.abs(W) > thr).astype(W.dtype)

Три способа. Разовый — обнулить и сразу считать. Разовый с дообучением — 40 эпох, градиент нулевых весов маской гасится. Постепенный — те же 40 эпох, но доля нулей растёт по кубическому расписанию за первые 30:

    sched = lambda ep, sp=sp: sp * (1 - (1 - min(ep, 30) / 30.0) ** 3)

Верно из 320, подбор / проверка:

Нулей в скрытом слое разовый разовый + дообучение постепенный
50 % (1024) 268 / 275 269 / 281 269 / 278
75 % (1536) 263 / 278 265 / 279 272 / 280
90 % (1843) 241 / 250 267 / 277 271 / 280
95 % (1946) 227 / 233 258 / 276 273 / 286

Без дообучения 90 % нулей стоят 28 окон проверки, 95 % — 45. Дообучение возвращает потерю, постепенный прунинг на подборе лучше разового при 75, 90 и 95 %: сеть успевает перераспределить вес на оставшиеся связи.

Почему нули не ускоряют. Плотное ядро перемножает все веса подряд, нули тоже. В QEMU сеть с 90 % нулей в плотном формате — те же 14 125 команд на ответ, что и без нулей. Чтобы пропускать нули, нужен разреженный формат: ненулевые значения строки и байт с номером входа у каждого.

        for (int k = L->rowp[j]; k < L->rowp[j + 1]; k++)
            s += v[k] * x[L->col[k]];
Формат, нулей Флеш, байт Умножений Команд на ответ, M4
плотный, 0 % 8848 2176 14 125
плотный, 90 % 8848 2176 14 125
CSR, 50 % 5980 1152 10 419
CSR, 75 % 3420 640 6323
CSR, 90 % 1885 333 3863

Номер входа — лишняя загрузка на каждое умножение: в CSR на один ненулевой вес уходит 11,6 команды против 6,5 в плотном цикле. Здесь формат окупается уже с половины нулей, но не вдвое: 50 % нулей сократили команды на 26 %, а не на 50.


119а.3 Структурный прунинг и кластеризация весов

Структурный прунинг убирает нейрон целиком — строку весов скрытого слоя и столбец выходного. Важность нейрона — произведение норм входящих и исходящих весов:

imp = np.linalg.norm(BASE[0][0], axis=1) * np.linalg.norm(BASE[1][0], axis=0)
Оставлено нейронов сразу после 40 эпох дообучения сеть такой ширины с нуля
16 из 32 237 / 259 266 / 280 268 / 278
8 из 32 189 / 209 272 / 278 271 / 277
4 из 32 137 / 144 262 / 273 256 / 264

Подбор / проверка, верно из 320. Срезанная до 8 нейронов сеть сразу теряет 69 окон, после дообучения — ни одного; это просто сеть 64 → 8 → 4: 544 умножения вместо 2176, 4021 команда вместо 14 125, 2224 байта. Обычное ядро, никакого особого формата. При 4 нейронах дообученная срезанная сеть оказалась лучше обученной с нуля — 273 против 264: большая сеть дала ей хорошее начало.

Кластеризация заменяет веса слоя k общими значениями: k-средних по самим весам, в памяти — номер кластера у каждого веса и таблица центров.

        km = KMeans(n_clusters=k - (1 if keep_zero else 0), n_init=10, random_state=SEED).fit(v[nz].reshape(-1, 1))
Центров на слой Бит на вес сразу после дообучения центров
4 2 279 / 281 267 / 278
8 3 270 / 280 267 / 277
16 4 272 / 278 269 / 277

Дообучение центров (градиент центра — сумма градиентов его весов) здесь ничего не добавило: сеть почти не заметила, что у неё осталось 4 разных веса на слой. На приборе вес достаётся из таблицы:

            s += L->tab[(p[k >> 1] >> ((k & 1) * 4)) & 15] * x[i];

16 центров: 1360 байт против 8848 — в 6,5 раза меньше. Но 31 607 команд на ответ — в 2,2 раза медленнее плотного float: распаковка номера и чтение таблицы на каждом весе. Кластеры — ради флеш-памяти и передачи модели по радио, а не ради скорости. Вместе с постепенным прунингом 90 % (ноль — свой центр, он не дообучается) — 277 на проверке; в этом формате номер хранится у каждого веса, и нули места не экономят.


119а.4 Дистилляция знаний

Учитель — 283 из 320, ученик 64 → 4 → 4, обученный с нуля, — 264. Дистилляция учит ученика не только меткам, но и мягким ответам учителя: логиты обоих делят на температуру T, и ученик подтягивает свои вероятности к учительским. Потери — 0,3 обычной перекрёстной энтропии и 0,7 · T² · KL; градиент по логитам ученика:

                pt = softmax(teacher[bi], T)
                    d += alpha * T * (softmax(acts[-1], T) - pt)
Температура Подбор Проверка
без учителя 256 264
T = 1 256 264
T = 2 265 264
T = 4 271 276
T = 8 280 278

При T = 1 толку нет: учитель на своих обучающих окнах прав в 960 из 960, и средняя наибольшая вероятность — 0,9998; мягкий ответ совпал с меткой. При T = 8 она 0,7614 — учитель сообщает, какие классы похожи. По подбору выбрана T = 8. С пятью другими начальными весами ученик без учителя дал от 263 до 279 (в среднем 268,0), с учителем — от 274 до 279 (в среднем 275,4): выигрыш около 7 окон и меньше разброс.

Ученик — 2337 команд и 1120 байт, учитель — 103 025 команд и 67 344 байта: в 44 раза быстрее и в 60 раз меньше при 5 окнах разницы.


119а.5 Ширина и разрядность: сводная таблица

Сети 64 → H → 4 с нуля, верно из 320 (int8 — свой: масштаб на тензор для входа и скрытого слоя, на выход — для весов; подробно — раздел 119):

Скрытый слой Подбор, float32 Проверка: float32 float16 int8
4 256 264 264 266
8 271 277 277 277
16 268 278 278 279
32 269 278 278 277
64 274 275 275 276

Цена ответа. Флеш — веса и смещения из объектного файла armclang (нормировка, 512 байт, у всех общая); команды — QEMU, на Cortex-M4F (netduinoplus2) и Cortex-M7 (mps2-an500); время и энергия — по командам на 168 МГц:

Вариант Флеш, байт Умножений Команд, M4 Команд, M7 мкс, не меньше мкДж, не меньше
H = 8, float32 2224 544 4021 4021 23,9 3,2
H = 8, float16 1136 544 5129 5131 30,5 4,0
H = 8, int8 640 544 6030 4268 35,9 4,7
H = 32, float32 8848 2176 14 125 14 125 84,1 11,1
H = 32, float16 4496 2176 18 545 18 547 110,4 14,6
H = 32, int8 2464 2176 15 240 12 897 90,7 12,0
H = 64, float32 17 680 4352 27 597 27 597 164,3 21,7
H = 64, float16 8976 4352 36 433 36 435 216,9 28,6
H = 64, int8 4896 4352 27 518 24 403 163,8 21,6

ОЗУ — стек: model_run берёт 1088 байт на любой вариант float (два буфера по 128 чисел, по самой широкой сети — учителю), i8_run — 184 байта на M4 и 176 на M7 (gcc, -fstack-usage).

Что видно. С 8 нейронов качество больше не растёт, а цена растёт линейно. float16 дал те же ответы, что float32, во всех пяти ширинах и вдвое меньше флеш-памяти, но на 31 % больше команд: каждый вес переводится во float командой vcvtb. int8 — ±2 окна и в 3,6 раза меньше флеш-памяти у H = 32, но на M4 он не быстрее float: округление входа и скрытого слоя floorf на M4 — вызов библиотеки, на M7 — команда vrintm (по дизассемблеру gcc), и int8 там на 9 % быстрее float. Простой цикл умножает по одному числу за команду; SMLAD, два умножения за команду, — в ядрах CMSIS-NN из раздела 119. Для команд float QEMU не различает M4 и M7: это один и тот же код. Такты M7 с двойной выдачей команд и кэшем QEMU не моделирует, они не измерены.


119а.6 В реальной среде: задержка, энергия, датчик, уверенность

Задержка. Время в таблице — команды, делённые на 168 МГц: тактов у M4 не меньше, чем команд, а при 168 МГц у флеш-памяти 5 тактов ожидания (DS8626, раздел 5.3.6). Окно приходит раз в 250 мс; даже учителю нужно не меньше 613,2 мкс.

Энергия. DS8626 ред. 9, таблица 20: 168 МГц, ART включён, вся периферия выключена — 40 мА типично при 25 °C и 3,3 В (с включённой периферией — 87 мА). Энергия на ответ — 3,3 В · 40 мА · время: рабочая сеть — не меньше 11,1 мкДж, CSR с 90 % нулей — 3,0, ученик — 1,8, учитель — 80,9. Это ядро в счёте; АЦП, датчик и сон между окнами — отдельная статья.

Другой датчик, дрейф, шум. Те же 32 проверочные записи, но датчик другой: чувствительность −20 % и +25 % (дрейф калибровки или другой экземпляр), шум втрое больше (хуже крепление, наводка) и смещение нуля 0,1 g. Верно из 320:

Модель Как есть ×0,8 ×1,25 Шум ×3 Смещение 0,1 g
Учитель 283 272 267 181 281
Рабочая 64 → 32 → 4 278 271 274 161 279
Прунинг 90 %, постепенный 280 282 271 156 282
Прунинг 90 % + 16 центров 277 277 272 158 280
Структурный, 8 нейронов 278 273 272 130 272
Ученик 64 → 4 → 4 278 278 265 142 278
64 → 4 → 4 без учителя 264 265 256 105 268
Рабочая, обучена с разбросом датчика 256 256 253 232 255

Чувствительность в дБ — сдвиг на 1,9 дБ, и сеть его почти не заметила. Смещение нуля убирает вычитание среднего. Шум ×3 — обвал у всех: пол спектра поднялся, слабые признаки перекоса и подшипника утонули. Прорежённые и кластеризованные варианты держатся как исходная сеть; маленькие сети падают сильнее. Обучение с разбросом датчика — 6 копий обучающих записей, чувствительность 0,7…1,4 и шум в 1…3 раза больше — подняло шум ×3 до 232, но стоило 22 окон на чистых данных.

Порог уверенности. Ответ «не уверен», если наибольшая вероятность softmax меньше порога. Порог выбран на подборе — наибольший из 0,5…0,95, при котором «не уверен» не больше 5 %: 0,7 (14 окон из 320).

        sum += expf(s[c] - mx);
    return 1.0f / sum >= TAU;

У рабочей сети на проверке «не уверен» — 11 окон, из 309 уверенных верны 275. При шуме ×3 «не уверен» всего 19, а из 301 уверенного ответа верны лишь 159. Сеть ошибается уверенно: доля «не уверен» сдвиг данных почти не видит.

Мониторинг в поле. Поэтому следят и за входом. Пол спектра — медиана 64 бинов в дБ; на обучении он не выше −57,7 дБ (медиана −61,6). При шуме ×3 выше этого порога все 320 окон, на чистых данных, при другой чувствительности и смещении — ни одного. На приборе обе доли — «не уверен» и «вход вне обучения» — копят за сутки и отправляют наверх: их рост — повод проверить крепление и откалибровать датчик, а не верить классу.


119а.7 Грабли

После прунинга точность упала на 28 окон. Обнулили без дообучения: 90 % нулей — 250 вместо 278. Дообучение с маской — 277, постепенный прунинг — 280.

Модель на 90 % из нулей считает так же долго. Плотное ядро умножает нули: 14 125 команд и с ними, и без них. Нужен разреженный формат: CSR — 3863 команды и 1885 байт.

Кластеризованная модель медленнее исходной. Распаковка 4-битного номера и таблица на каждом весе: 31 607 команд против 14 125. Кластеры экономят флеш-память, не время.

Дистилляция ничего не дала. T = 1: учитель на своих обучающих окнах уверен на 0,9998, мягкие ответы равны меткам. Температуру — 4…8, выбирать по подбору.

Ученик стал неуверенным. Температура только у учителя, логиты ученика не поделены: ученик учится отвечать размазанно. Верных столько же (278), но средняя наибольшая вероятность 0,896 вместо 0,969 и «не уверен» — 55 окон из 320 вместо 11. Делить на T оба логита и умножать градиент на T.

На проверке прунинг 95 % «лучше» исходной сети. 286 против 278 — но на второй проверке из новых записей 300 против 301. Разброс от начальных весов — 271…282, а две проверки различаются на 23 окна. Подбирать по подбору, проверку трогать один раз, спорное — на новых записях.

int8 на F407 не быстрее float. Своё целочисленное ядро без SIMD и floorf из библиотеки: 15 240 команд против 14 125. Нужны ядра CMSIS-NN с SMLAD (раздел 119).

В поле модель уверенно ошибается. Шум ×3: верно 159 из 301 уверенного ответа, «не уверен» — 19. Следить за входом: пол спектра выше обучающего во всех 320 окнах.


119а.8 Итоги

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

train119b.py — данные, обучение на numpy, прунинг, кластеризация, дистилляция, проверка в поле и выгрузка всех вариантов в model119b.h.

# -*- coding: utf-8 -*-
"""Раздел 119а: оптимизация модели для микроконтроллера — прунинг, кластеризация весов,
дистилляция, ширина сети и разрядность.

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

Всё на numpy (и KMeans из scikit-learn): пакета tensorflow_model_optimization нет, маски
весов, общие веса кластеров и мягкие ответы учителя написаны здесь же. Данные — вибрация
мотора с аналогового акселерометра на АЦП STM32 (12 бит, ±4 g, 1024 Гц, окно 256 отсчётов),
признаки — 64 бина спектра в дБ (4…256 Гц). Четыре класса: норма, дисбаланс, перекос валов,
подшипник. Записи делятся на обучение, подбор и проверку целиком.

Выход: model119b.h (веса всех вариантов для infer119b.c, проверочные окна, контрольные суммы
логитов, которые обязан повторить C) и summary119b.txt (все числа раздела)."""
import os
import sys

os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["OMP_NUM_THREADS"] = "1"
import numpy as np
import sklearn
from sklearn.cluster import KMeans

OUT = sys.argv[1] if len(sys.argv) > 1 else "."
SEED = 1192
N, FS, NB, WPR = 256, 1024.0, 64, 10           # отсчётов в окне, Гц, бинов, окон на запись
REC_TR, REC_VA, REC_TE = 24, 8, 8              # записей на класс: обучение, подбор, проверка
t = np.arange(N) / FS
hann = 0.5 * (1 - np.cos(2 * np.pi * np.arange(N) / N))
lines = ["versions=%s,%s" % (np.__version__, sklearn.__version__)]
S = {}                                          # сводка: ключ → строка


def put(k, *v):
    S[k] = ",".join(str(x) for x in v)
    lines.append("%s=%s" % (k, S[k]))


# ══ 1. Данные ═════════════════════════════════════════════════════════════
drng = np.random.RandomState(SEED)


def recording(c):
    """Одна запись: чистая вибрация в g (без шума датчика) и шум отдельно — датчик применяется потом."""
    u = lambda lo, hi: drng.uniform(lo, hi)
    f1 = 25.0 + u(-1.5, 1.5)                    # частота вращения
    mount = u(0.9, 1.1)                         # крепление этого датчика на этом моторе
    a1 = 0.06 * u(0.5, 1.5)
    a2, a3, ab, fb = 0.01 * u(0.5, 1.5), 0.0, 0.0, 0.0
    if c == 1:                                  # дисбаланс: сильная 1×
        a1 = 0.15 * u(0.6, 1.4)
    elif c == 2:                                # перекос: растут 2× и 3×
        a2, a3 = 0.05 * u(0.4, 1.6), 0.02 * u(0.5, 1.5)
    elif c == 3:                                # подшипник: несущая 120…200 Гц, модулированная вращением
        ab, fb = 0.02 * u(0.3, 1.5), u(120.0, 200.0)
    sig_n = 0.01 * u(0.8, 1.2)
    X, Nz = [], []
    for _ in range(WPR):
        p = drng.uniform(0, 2 * np.pi, 4)
        k = drng.uniform(0.95, 1.05, 4)
        x = (k[0] * a1 * np.sin(2 * np.pi * f1 * t + p[0]) + k[1] * a2 * np.sin(4 * np.pi * f1 * t + p[1])
             + k[2] * a3 * np.sin(6 * np.pi * f1 * t + p[2])
             + k[3] * ab * (1 + 0.6 * np.cos(2 * np.pi * f1 * t)) * np.sin(2 * np.pi * fb * t + p[3]))
        X.append(mount * x)
        Nz.append(sig_n * drng.randn(N))
    return X, Nz


CLEAN, NOISE, y, g = [], [], [], []
for c in range(4):
    for r in range(REC_TR + REC_VA + REC_TE):
        X_, N_ = recording(c)
        CLEAN += X_
        NOISE += N_
        y += [c] * WPR
        g += [r] * WPR
for c in range(4):                              # вторая проверка: ещё по 8 новых записей на класс
    for r in range(REC_TE):
        X_, N_ = recording(c)
        CLEAN += X_
        NOISE += N_
        y += [c] * WPR
        g += [100 + r] * WPR
CLEAN, NOISE, y, g = np.array(CLEAN), np.array(NOISE), np.array(y), np.array(g)
mtr, mva = g < REC_TR, (g >= REC_TR) & (g < REC_TR + REC_VA)
mte, mte2 = (g >= REC_TR + REC_VA) & (g < 100), g >= 100


def sensor(gain=1.0, noise=1.0, offset=0.0):
    """Акселерометр + АЦП 12 бит на ±4 g → 64 бина спектра в дБ относительно 1 g."""
    x = gain * CLEAN + noise * NOISE + offset
    q = np.clip(np.round(x / 8.0 * 4096 + 2048), 0, 4095)
    x = (q - 2048) * 8.0 / 4096
    x = x - x.mean(axis=1, keepdims=True)
    m = np.abs(np.fft.rfft(x * hann, axis=1))[:, 1:NB + 1] * (2.0 / N)
    return (20 * np.log10(m + 1e-4)).astype(np.float32)


X = sensor()
put("windows", int((g < 100).sum()), int(mtr.sum()), int(mva.sum()), int(mte.sum()), int(mte2.sum()))
put("recordings", 4 * (REC_TR + REC_VA + REC_TE), 4 * REC_TR, 4 * REC_VA, 4 * REC_TE, 4 * REC_TE)
MU = X[mtr].mean(axis=0).astype(np.float32)
SD = X[mtr].std(axis=0).astype(np.float32)
norm = lambda A: ((A - MU) / SD).astype(np.float32)
Z = norm(X)
Y1 = np.eye(4)[y]

# ══ 2. Обучение: numpy, Adam, маски и общие веса ═══════════════════════════


def init(sizes, rng):
    return [[rng.randn(o, i) * np.sqrt(2.0 / i), np.zeros(o)] for i, o in zip(sizes[:-1], sizes[1:])]


def forward(P, A):
    acts = [A]
    for k, (W, b) in enumerate(P):
        A = A @ W.T + b
        if k < len(P) - 1:
            A = np.maximum(A, 0.0)
        acts.append(A)
    return acts


def softmax(Zl, T=1.0):
    e = np.exp((Zl - Zl.max(axis=1, keepdims=True)) / T)
    return e / e.sum(axis=1, keepdims=True)


def magnitude_mask(W, sparsity):
    """Маска прунинга по величине: обнулить долю sparsity самых малых по модулю весов слоя."""
    k = int(round(sparsity * W.size))
    if k == 0:
        return np.ones_like(W)
    thr = np.sort(np.abs(W).ravel())[k - 1]
    return (np.abs(W) > thr).astype(W.dtype)


def prune_masks(P, sparsity):
    """Прунинг скрытых слоёв; выходной слой (32 × 4 = 128 весов) не трогаем — в нём каждый вес на счету."""
    return [magnitude_mask(W, sparsity) if k < len(P) - 1 else np.ones_like(W) for k, (W, _) in enumerate(P)]


def train(P, idx, epochs, lr, seed, masks=None, schedule=None, teacher=None, T=1.0, alpha=0.0, bug=None,
          clusters=None, l2=1e-4):
    """Обучение (или дообучение) сети P на окнах idx. masks — нули прунинга; schedule(эпоха) → доля нулей
    для постепенного прунинга; teacher — логиты учителя на тех же окнах (дистилляция); clusters — для
    каждого слоя (номера кластеров, центры): учатся только центры."""
    rng = np.random.RandomState(seed)
    P = [[W.copy(), b.copy()] for W, b in P]
    m1 = [[np.zeros_like(W), np.zeros_like(b)] for W, b in P]
    m2 = [[np.zeros_like(W), np.zeros_like(b)] for W, b in P]
    if clusters is not None:
        C = [c.copy() for _, c, _ in clusters]
        cm1, cm2 = [np.zeros_like(c) for c in C], [np.zeros_like(c) for c in C]
    step = 0
    for ep in range(epochs):
        if schedule is not None:
            masks = prune_masks(P, schedule(ep))
            for k in range(len(P)):
                P[k][0] *= masks[k]
        order = rng.permutation(idx)
        for s0 in range(0, len(order), 32):
            bi = order[s0:s0 + 32]
            acts = forward(P, Z[bi])
            p = softmax(acts[-1])
            d = (p - Y1[bi]) * (1.0 - alpha)
            if teacher is not None:
                pt = softmax(teacher[bi], T)
                if bug == "student_T1":                       # ученик без температуры
                    d += alpha * (softmax(acts[-1]) - pt)
                elif bug == "no_T2":                          # забыт множитель T²
                    d += alpha * (softmax(acts[-1], T) - pt) / T
                else:                                         # T² · KL → градиент T · (ps − pt)
                    d += alpha * T * (softmax(acts[-1], T) - pt)
            d /= len(bi)
            step += 1
            grads = []
            for k in range(len(P) - 1, -1, -1):
                gW = d.T @ acts[k] + l2 * P[k][0]
                gb = d.sum(axis=0)
                grads.append((k, gW, gb))
                if k:
                    d = (d @ P[k][0]) * (acts[k] > 0)
            for k, gW, gb in grads:
                if masks is not None:
                    gW = gW * masks[k]
                if clusters is not None:                      # общий вес: градиент центра — сумма по кластеру
                    a = clusters[k][0]
                    gC = np.bincount(a.ravel(), weights=gW.ravel(), minlength=len(C[k]))
                    if clusters[k][2]:
                        gC[0] = 0.0                           # кластер «ноль» остаётся нулём
                    cm1[k] = 0.9 * cm1[k] + 0.1 * gC
                    cm2[k] = 0.999 * cm2[k] + 0.001 * gC * gC
                    C[k] -= lr * (cm1[k] / (1 - 0.9 ** step)) / (np.sqrt(cm2[k] / (1 - 0.999 ** step)) + 1e-8)
                    P[k][0] = C[k][a]
                    gW = None
                for j, gr in ((0, gW), (1, gb)):
                    if gr is None:
                        continue
                    m1[k][j] = 0.9 * m1[k][j] + 0.1 * gr
                    m2[k][j] = 0.999 * m2[k][j] + 0.001 * gr * gr
                    P[k][j] -= lr * (m1[k][j] / (1 - 0.9 ** step)) / (np.sqrt(m2[k][j] / (1 - 0.999 ** step)) + 1e-8)
                if masks is not None:
                    P[k][0] *= masks[k]
    return P


def acc(P, mask, Zs=None):
    Zs = Z if Zs is None else Zs
    return int((forward(P, Zs[mask])[-1].argmax(axis=1) == y[mask]).sum())


TR = np.where(mtr)[0]
EPOCHS, LR = 150, 0.003

# ══ 3. Ширина сети и учитель ═══════════════════════════════════════════════
WIDTHS = (4, 8, 16, 32, 64)
NETS = {}
for h in WIDTHS:
    NETS["w%d" % h] = train(init((NB, h, 4), np.random.RandomState(SEED + h)), TR, EPOCHS, LR, SEED + h)
    put("width_%d" % h, acc(NETS["w%d" % h], mva), acc(NETS["w%d" % h], mte))
REP = [acc(train(init((NB, 32, 4), np.random.RandomState(SEED + 500 + r)), TR, EPOCHS, LR, SEED + 500 + r), mte)
       for r in range(5)]
put("w32_seeds", *REP)
TEACH = train(init((NB, 128, 64, 4), np.random.RandomState(SEED + 128)), TR, EPOCHS, LR, SEED + 128)
put("teacher", acc(TEACH, mva), acc(TEACH, mte), sum(W.size + b.size for W, b in TEACH))
BASE = NETS["w32"]

# ══ 4. Прунинг по величине: разовый, разовый + дообучение, постепенный ═══════
FT = 40
SPARS = (0.5, 0.75, 0.9, 0.95)
PRUNED = {}
for sp in SPARS:
    mk = prune_masks(BASE, sp)
    once = [[W * m, b.copy()] for (W, b), m in zip(BASE, mk)]
    ft = train(once, TR, FT, 0.001, SEED + 7, masks=mk)
    sched = lambda ep, sp=sp: sp * (1 - (1 - min(ep, 30) / 30.0) ** 3)
    grad = train(BASE, TR, FT, 0.001, SEED + 7, schedule=sched)
    grad_mask = prune_masks(grad, sp)
    grad = [[W * m, b] for (W, b), m in zip(grad, grad_mask)]
    PRUNED[sp] = grad
    zeros = sum(int((W == 0).sum()) for W, _ in grad)
    put("prune_%d" % round(sp * 100), acc(once, mva), acc(once, mte), acc(ft, mva), acc(ft, mte),
        acc(grad, mva), acc(grad, mte), zeros)

# выбор по проверке: лучшая из 12 настроек прунинга по mte — и она же на новых записях mte2
cand = []
for sp in SPARS:
    v = [int(x) for x in S["prune_%d" % round(sp * 100)].split(",")]
    cand += [(v[1], v[0], sp, "once"), (v[3], v[2], sp, "ft"), (v[5], v[4], sp, "grad")]
bt = max(cand, key=lambda c: (c[0], c[1]))
bv = max(cand, key=lambda c: (c[1], c[0]))
put("pick_by_test", bt[3], round(bt[2] * 100), bt[0], acc(PRUNED[bt[2]], mte2) if bt[3] == "grad" else -1)
put("pick_by_val", bv[3], round(bv[2] * 100), bv[1], bv[0], acc(PRUNED[bv[2]], mte2) if bv[3] == "grad" else -1)
put("base_test2", acc(BASE, mte2))

# ══ 5. Структурный прунинг: целые нейроны скрытого слоя ════════════════════
imp = np.linalg.norm(BASE[0][0], axis=1) * np.linalg.norm(BASE[1][0], axis=0)
STRUCT = {}
for keep in (16, 8, 4):
    kk = np.sort(np.argsort(-imp)[:keep])
    cut = [[BASE[0][0][kk], BASE[0][1][kk]], [BASE[1][0][:, kk], BASE[1][1].copy()]]
    ft = train(cut, TR, FT, 0.001, SEED + 9)
    STRUCT[keep] = ft
    put("struct_%d" % keep, acc(cut, mva), acc(cut, mte), acc(ft, mva), acc(ft, mte))

# ══ 6. Кластеризация весов: k-средних, номера + таблица центров ═════════════


def cluster(P, k, keep_zero=False):
    out = []
    for n, (W, _) in enumerate(P):
        v = W.ravel()
        nz = v != 0 if keep_zero and n < len(P) - 1 else np.ones(v.size, bool)
        km = KMeans(n_clusters=k - (1 if keep_zero else 0), n_init=10, random_state=SEED).fit(v[nz].reshape(-1, 1))
        cen = np.sort(km.cluster_centers_.ravel())
        lab = np.zeros(v.size, np.int64)
        idx = np.abs(v[nz][:, None] - cen[None, :]).argmin(axis=1)
        if keep_zero:
            cen = np.concatenate([[0.0], cen])
            lab[nz] = idx + 1
        else:
            lab[:] = idx
        out.append((lab.reshape(W.shape), cen, keep_zero))
    return out


CLUST = {}
for k in (4, 8, 16):
    cl = cluster(BASE, k)
    raw = [[c[a], b] for (a, c, _), (_, b) in zip(cl, BASE)]
    ft = train(raw, TR, 20, 0.001, SEED + 11, clusters=cl)
    CLUST[k] = (ft, cl)
    put("cluster_%d" % k, acc(raw, mva), acc(raw, mte), acc(ft, mva), acc(ft, mte),
        sum(len(np.unique(W)) for W, _ in ft))
cl = cluster(PRUNED[0.9], 16, keep_zero=True)
raw = [[c[a], b] for (a, c, _), (_, b) in zip(cl, PRUNED[0.9])]
DEEP = train(raw, TR, 20, 0.001, SEED + 11, clusters=cl)
DEEP_CL = cl
put("deep", acc(raw, mva), acc(raw, mte), acc(DEEP, mva), acc(DEEP, mte), sum(int((W == 0).sum()) for W, _ in DEEP))

# ══ 7. Дистилляция: ученик 64 → 4 → 4 на мягких ответах учителя ═══════════
TL = forward(TEACH, Z)[-1]
SW = 4
st0 = init((NB, SW, 4), np.random.RandomState(SEED + SW))
put("teacher_train", acc(TEACH, mtr), int(mtr.sum()),
    *["%.4f" % float(softmax(TL[mtr], T_).max(axis=1).mean()) for T_ in (1.0, 2.0, 4.0, 8.0)])
DIST = {}
for name, T, bug in (("T1", 1.0, None), ("T2", 2.0, None), ("T4", 4.0, None), ("T8", 8.0, None),
                     ("T4_noT2", 4.0, "no_T2"), ("T4_studentT1", 4.0, "student_T1")):
    st = train(st0, TR, EPOCHS, LR, SEED + SW, teacher=TL, T=T, alpha=0.7, bug=bug)
    DIST[name] = st
    pm = softmax(forward(st, Z[mte])[-1]).max(axis=1)
    put("dist_%s" % name, acc(st, mva), acc(st, mte), "%.3f" % float(pm.mean()))
pm = softmax(forward(NETS["w%d" % SW], Z[mte])[-1]).max(axis=1)
put("dist_scratch", acc(NETS["w%d" % SW], mva), acc(NETS["w%d" % SW], mte), "%.3f" % float(pm.mean()))
names = ("T1", "T2", "T4", "T8")
best_val = max(names, key=lambda n: (int(S["dist_" + n].split(",")[0]), -names.index(n)))
best_test = max(names, key=lambda n: (int(S["dist_" + n].split(",")[1]), -names.index(n)))
put("dist_pick", best_val, best_test)
STUDENT = DIST[best_val]
Tb = float(best_val[1:])
sc, di = [], []
for r in range(5):                              # то же с пятью другими начальными весами
    s0 = init((NB, SW, 4), np.random.RandomState(SEED + 700 + r))
    sc.append(acc(train(s0, TR, EPOCHS, LR, SEED + 700 + r), mte))
    di.append(acc(train(s0, TR, EPOCHS, LR, SEED + 700 + r, teacher=TL, T=Tb, alpha=0.7), mte))
put("dist_seeds_scratch", *sc)
put("dist_seeds_teacher", *di)

# ══ 8. Устойчивость: другой датчик, дрейф, шум ═════════════════════════════
DRIFTS = (("clean", 1.0, 1.0, 0.0), ("gain08", 0.8, 1.0, 0.0), ("gain125", 1.25, 1.0, 0.0),
          ("noise3", 1.0, 3.0, 0.0), ("offset", 1.0, 1.0, 0.1))
ZD = {n: norm(sensor(gn, nz, of)) for n, gn, nz, of in DRIFTS}
# обучение с разбросом датчика: те же записи, усиление 0,7…1,4 и шум в 1…3 раза больше
arng = np.random.RandomState(SEED + 3)
gains = np.concatenate([[1.0], arng.uniform(0.7, 1.4, 5)])          # первая копия — сам датчик
noises = np.concatenate([[1.0], arng.uniform(1.0, 3.0, 5)])
ZA = np.concatenate([norm(sensor(gn, nz))[mtr] for gn, nz in zip(gains, noises)])
YA = np.concatenate([y[mtr]] * len(gains))
_Z, _Y1 = Z, Y1
Z, Y1 = ZA, np.eye(4)[YA]
AUG = train(init((NB, 32, 4), np.random.RandomState(SEED + 32)), np.arange(len(YA)), EPOCHS, LR, SEED + 32)
Z, Y1 = _Z, _Y1
ROBUST = (("teacher", TEACH), ("w32", BASE), ("prune90", PRUNED[0.9]), ("deep", DEEP), ("struct8", STRUCT[8]),
          ("student", STUDENT), ("w4", NETS["w4"]), ("aug", AUG))
for n, P in ROBUST:
    put("robust_%s" % n, *[acc(P, mte, ZD[d]) for d, *_ in DRIFTS])

# ══ 9. Порог уверенности и доля «не уверен» ═══════════════════════════════
pv = softmax(forward(BASE, Z[mva])[-1]).max(axis=1)
TAUS = (0.5, 0.6, 0.7, 0.8, 0.9, 0.95)
tau = max(tt for tt in TAUS if (pv < tt).mean() <= 0.05)
put("tau", tau, int((pv < tau).sum()), int(mva.sum()))
for n, P in (("w32", BASE), ("aug", AUG), ("student", STUDENT), ("studentT4", DIST["T4"]), ("studentT1", DIST["T4_studentT1"])):
    row = []
    for d, *_ in DRIFTS:
        lo = forward(P, ZD[d][mte])[-1]
        pm = softmax(lo).max(axis=1)
        sure = pm >= tau
        row += [int((~sure).sum()), int(((lo.argmax(axis=1) == y[mte]) & sure).sum()), int(sure.sum())]
    put("conf_%s" % n, *row)

# монитор входа: «пол» спектра — медиана 64 бинов, дБ; порог — наибольший на обучении
floor = lambda A: np.median(A, axis=1)
fl_thr = float(floor(X[mtr]).max())
put("floor_thr", "%.1f" % fl_thr, "%.1f" % float(np.median(floor(X[mtr]))))
put("floor_flag", *[int((floor(sensor(gn, nz, of)[mte]) > fl_thr).sum()) for _, gn, nz, of in DRIFTS])

# ══ 10. Варианты для C: float32, float16, int8, CSR, кластеры ══════════════
ZT = Z[mte]
f32 = np.float32


def seq_dense(Wd, b, A, relu):
    """Как на C: s = b; s += w·x по входам по порядку, каждое действие во float32."""
    s = np.tile(b.astype(f32), (A.shape[0], 1))
    for i in range(Wd.shape[1]):
        s = (s + (Wd[:, i][None, :] * A[:, i][:, None]).astype(f32)).astype(f32)
    return np.where(s > f32(0), s, f32(0)) if relu else s


def run_float(layers, A):
    for k, (Wd, b) in enumerate(layers):
        A = seq_dense(Wd.astype(f32), b.astype(f32), A, k < len(layers) - 1)
    return A


def quant_i8(P):
    """int8: вход и скрытый слой — свой масштаб на тензор (по обучению), веса — на каждый выход."""
    (W1, b1), (W2, b2) = P
    sx = f32(np.abs(Z[mtr]).max() / 127.0)
    h = np.maximum(Z[mtr] @ W1.T + b1, 0)
    sh = f32(h.max() / 127.0)
    sw1 = (np.abs(W1).max(axis=1) / 127.0).astype(f32)
    sw2 = (np.abs(W2).max(axis=1) / 127.0).astype(f32)
    q1 = np.round(W1 / sw1[:, None]).astype(np.int8)
    q2 = np.round(W2 / sw2[:, None]).astype(np.int8)
    bq1 = np.round(b1 / (sx * sw1)).astype(np.int32)
    bq2 = np.round(b2 / (sh * sw2)).astype(np.int32)
    m1 = (sx * sw1 / sh).astype(f32)
    m2 = (sh * sw2).astype(f32)
    return dict(sx=sx, q1=q1, bq1=bq1, m1=m1, q2=q2, bq2=bq2, m2=m2)


def run_i8(Q, A):
    xq = np.clip(np.floor((A / Q["sx"]).astype(f32) + f32(0.5)), -128, 127).astype(np.int64)
    a1 = xq @ Q["q1"].T.astype(np.int64) + Q["bq1"]
    hq = np.clip(np.floor((a1.astype(f32) * Q["m1"]).astype(f32) + f32(0.5)), 0, 127).astype(np.int64)
    a2 = hq @ Q["q2"].T.astype(np.int64) + Q["bq2"]
    return (a2.astype(f32) * Q["m2"]).astype(f32)


def fnv(L):
    h = 2166136261
    for v in np.ascontiguousarray(L, f32).view(np.uint32).ravel():
        h = ((h ^ int(v)) * 16777619) & 0xFFFFFFFF
    return h


VARIANTS = []                                   # (имя, вид, данные для C, логиты на проверке)
for h in WIDTHS:
    P = NETS["w%d" % h]
    VARIANTS.append(("f32_w%d" % h, "f32", P, run_float(P, ZT)))
    P16 = [[W.astype(np.float16), b] for W, b in P]
    VARIANTS.append(("f16_w%d" % h, "f16", P16, run_float([[W.astype(f32), b] for W, b in P16], ZT)))
    Q = quant_i8(P)
    VARIANTS.append(("i8_w%d" % h, "i8", Q, run_i8(Q, ZT)))
VARIANTS.append(("f32_teacher", "f32", TEACH, run_float(TEACH, ZT)))
VARIANTS.append(("f32_struct8", "f32", STRUCT[8], run_float(STRUCT[8], ZT)))
VARIANTS.append(("f32_student", "f32", STUDENT, run_float(STUDENT, ZT)))
VARIANTS.append(("f32_prune90", "f32", PRUNED[0.9], run_float(PRUNED[0.9], ZT)))
for sp in (0.5, 0.75, 0.9):
    VARIANTS.append(("csr_p%d" % round(sp * 100), "csr", PRUNED[sp], run_float(PRUNED[sp], ZT)))
VARIANTS.append(("cl4_k16", "cl4", (CLUST[16][0], CLUST[16][1]), run_float(CLUST[16][0], ZT)))
VARIANTS.append(("cl4_deep", "cl4", (DEEP, DEEP_CL), run_float(DEEP, ZT)))
for name, kind, P, L in VARIANTS:
    unsure = int((softmax(L.astype(np.float64)).max(axis=1) < tau).sum())
    put("var_%s" % name, int((L.argmax(axis=1) == y[mte]).sum()), "%08x" % fnv(L), unsure)


# ══ 11. Заголовок для 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(name, a, typ):
    flat = np.asarray(a).ravel()
    if typ in ("float", "half"):                # сначала во float32 (half — уже float16): тогда текст точен
        body = ", ".join(c_float(float(v)) for v in flat.astype(np.float32))
    else:
        body = ", ".join(str(int(v)) for v in flat)
    return "static const %s %s[%d] = { %s };\n" % (typ, name, len(flat), body)


def cl4_pack(a):
    v = a.ravel()
    return (v[0::2] | (v[1::2] << 4)).astype(np.uint8)


h_ = ["/* Раздел 119а: все варианты модели — сгенерировано train119b.py, не править */\n",
      "#define N_IN %d\n#define N_OUT 4\n#define N_TEST %d\n#define TAU %s\n" % (NB, int(mte.sum()), c_float(tau)),
      c_arr("MU", MU, "float"), c_arr("SD", SD, "float")]
descr, i8d = [], []
for name, kind, P, L in VARIANTS:
    u = name.upper()
    if kind == "i8":
        Q = P
        h_ += [c_arr(u + "_Q1", Q["q1"], "int8_t"), c_arr(u + "_BQ1", Q["bq1"], "int32_t"), c_arr(u + "_M1", Q["m1"], "float"),
               c_arr(u + "_Q2", Q["q2"], "int8_t"), c_arr(u + "_BQ2", Q["bq2"], "int32_t"), c_arr(u + "_M2", Q["m2"], "float")]
        i8d.append("    { \"%s\", %d, %s, %s_Q1, %s_BQ1, %s_M1, %s_Q2, %s_BQ2, %s_M2, 0x%08xu },\n"
                   % (name, Q["q1"].shape[0], c_float(float(Q["sx"])), u, u, u, u, u, u, fnv(L)))
        continue
    lay = []
    if kind == "cl4":
        P, cl = P
    for k, (W, b) in enumerate(P):
        n_out, n_in = W.shape
        relu = int(k < len(P) - 1)
        tag = "%s_L%d" % (u, k + 1)
        h_.append(c_arr(tag + "_B", b, "float"))
        if kind == "f32":
            h_.append(c_arr(tag + "_W", W, "float"))
            lay.append("{ L_F32, %d, %d, %d, %s_W, %s_B, 0, 0, 0 }" % (relu, n_in, n_out, tag, tag))
        elif kind == "f16":
            h_.append(c_arr(tag + "_W", W, "half"))
            lay.append("{ L_F16, %d, %d, %d, %s_W, %s_B, 0, 0, 0 }" % (relu, n_in, n_out, tag, tag))
        elif kind == "csr":
            nz = W != 0
            rowp = np.concatenate([[0], np.cumsum(nz.sum(axis=1))])
            h_ += [c_arr(tag + "_V", W[nz], "float"), c_arr(tag + "_COL", np.where(nz)[1], "uint8_t"),
                   c_arr(tag + "_ROWP", rowp, "uint16_t")]
            lay.append("{ L_CSR, %d, %d, %d, %s_V, %s_B, %s_ROWP, %s_COL, 0 }" % (relu, n_in, n_out, tag, tag, tag, tag))
        else:
            a, cen, _ = cl[k]
            cen = np.array([W[a == c][0] if (a == c).any() else cen[c] for c in range(len(cen))])  # центры после дообучения
            h_ += [c_arr(tag + "_IDX", cl4_pack(a), "uint8_t"), c_arr(tag + "_TAB", cen, "float")]
            lay.append("{ L_CL4, %d, %d, %d, %s_IDX, %s_B, 0, 0, %s_TAB }" % (relu, n_in, n_out, tag, tag, tag))
    h_.append("static const layer_t %s_LAYERS[%d] = { %s };\n" % (u, len(lay), ", ".join(lay)))
    descr.append("    { \"%s\", %d, %s_LAYERS, 0x%08xu },\n" % (name, len(lay), u, fnv(L)))
h_.append("static const model_t MODELS[%d] = {\n%s};\n" % (len(descr), "".join(descr)))
h_.append("static const i8model_t I8MODELS[%d] = {\n%s};\n" % (len(i8d), "".join(i8d)))
h_.append("#ifdef ML_DATA\n" + c_arr("TEST_X", X[mte], "float") + c_arr("TEST_Y", y[mte], "uint8_t") + "#endif\n")
open(OUT + "/model119b.h", "w", encoding="utf-8", newline="\n").write("".join(h_))
open(OUT + "/summary119b.txt", "w", encoding="utf-8", newline="\n").write("\n".join(lines) + "\n")
print("\n".join(lines))

infer119b.c — вывод всех вариантов на C: float32, float16, CSR, кластеры и int8.

/* Раздел 119а: вывод всех вариантов сети на C — плотный float32, веса float16, прореженный CSR,
 * кластеры (4-битные номера + таблица центров) и int8. Веса — из model119b.h (выгрузил train119b.py).
 * Вход — 64 бина спектра вибрации в дБ, ответ — 4 логита: норма, дисбаланс, перекос, подшипник.
 * На компьютере (-DML_HOST) main считает каждый вариант на проверочных окнах и сверяет логиты
 * с Python по контрольной сумме бит в бит. Собирать с -ffp-contract=off: без слитного
 * умножения-сложения порядок округлений тот же, что в numpy. Проверка — _verify/ml119b_check.py. */
#include <math.h>
#include <stdint.h>
#include <string.h>

#if defined(__arm__)
typedef __fp16 half;                    /* хранение: 2 байта, счёт — во float */
#else
typedef _Float16 half;
#endif

enum { L_F32, L_F16, L_CSR, L_CL4 };

typedef struct {
    uint8_t kind, relu;
    uint16_t n_in, n_out;
    const void *w;                      /* веса: float, half, значения CSR или упакованные номера */
    const float *b;                     /* смещения */
    const uint16_t *rowp;               /* CSR: начало строки каждого выхода */
    const uint8_t *col;                 /* CSR: номер входа каждого ненулевого веса */
    const float *tab;                   /* кластеры: 16 центров слоя */
} layer_t;

typedef struct {
    const char *name;
    uint8_t n_layers;
    const layer_t *layers;
    uint32_t hash;                      /* контрольная сумма логитов Python на проверке */
} model_t;

typedef struct {
    const char *name;
    uint16_t n_hid;
    float sx;                           /* масштаб входа int8 */
    const int8_t *q1;
    const int32_t *bq1;
    const float *m1;                    /* пересчёт суммы в масштаб скрытого слоя */
    const int8_t *q2;
    const int32_t *bq2;
    const float *m2;                    /* пересчёт суммы в логиты */
    uint32_t hash;
} i8model_t;

#if defined(ML_HOST) && !defined(ML_DATA)
#define ML_DATA
#endif
#include "model119b.h"

#define MAX_W 128

/* Плотный слой: каждый вес умножается, нули тоже */
static void dense_f32(const layer_t *L, const float *x, float *y)
{
    const float *w = (const float *)L->w;
    for (int j = 0; j < L->n_out; j++) {
        float s = L->b[j];
        for (int i = 0; i < L->n_in; i++)
            s += w[j * L->n_in + i] * x[i];
        y[j] = L->relu && !(s > 0.0f) ? 0.0f : s;
    }
}

/* Веса float16: вдвое меньше флеш-памяти, перед умножением — VCVTB в float */
static void dense_f16(const layer_t *L, const float *x, float *y)
{
    const half *w = (const half *)L->w;
    for (int j = 0; j < L->n_out; j++) {
        float s = L->b[j];
        for (int i = 0; i < L->n_in; i++)
            s += (float)w[j * L->n_in + i] * x[i];
        y[j] = L->relu && !(s > 0.0f) ? 0.0f : s;
    }
}

/* Разреженный слой CSR: только ненулевые веса, к каждому — номер входа */
static void dense_csr(const layer_t *L, const float *x, float *y)
{
    const float *v = (const float *)L->w;
    for (int j = 0; j < L->n_out; j++) {
        float s = L->b[j];
        for (int k = L->rowp[j]; k < L->rowp[j + 1]; k++)
            s += v[k] * x[L->col[k]];
        y[j] = L->relu && !(s > 0.0f) ? 0.0f : s;
    }
}

/* Кластеры: вес = таблица[номер], номер — 4 бита, два номера в байте */
static void dense_cl4(const layer_t *L, const float *x, float *y)
{
    const uint8_t *p = (const uint8_t *)L->w;
    for (int j = 0; j < L->n_out; j++) {
        float s = L->b[j];
        for (int i = 0; i < L->n_in; i++) {
            int k = j * L->n_in + i;
            s += L->tab[(p[k >> 1] >> ((k & 1) * 4)) & 15] * x[i];
        }
        y[j] = L->relu && !(s > 0.0f) ? 0.0f : s;
    }
}

/* Модель с весами float, float16, CSR или кластерами: нормировка и слои по очереди */
void model_run(const model_t *m, const float *x, float *out)
{
    float a[MAX_W], b[MAX_W], *in = a, *o = b, *t;
    for (int i = 0; i < N_IN; i++)
        a[i] = (x[i] - MU[i]) / SD[i];
    for (int l = 0; l < m->n_layers; l++) {
        const layer_t *L = &m->layers[l];
        float *dst = l == m->n_layers - 1 ? out : o;
        switch (L->kind) {
        case L_F32: dense_f32(L, in, dst); break;
        case L_F16: dense_f16(L, in, dst); break;
        case L_CSR: dense_csr(L, in, dst); break;
        default: dense_cl4(L, in, dst); break;
        }
        t = in;
        in = o;
        o = t;
    }
}

/* int8: вход и скрытый слой — байты, суммы — int32, пересчёт масштаба — одно умножение во float */
void i8_run(const i8model_t *m, const float *x, float *out)
{
    int8_t q[N_IN], h[64];
    for (int i = 0; i < N_IN; i++) {
        float z = (x[i] - MU[i]) / SD[i];
        int v = (int)floorf(z / m->sx + 0.5f);
        q[i] = (int8_t)(v < -128 ? -128 : v > 127 ? 127 : v);
    }
    for (int j = 0; j < m->n_hid; j++) {
        int32_t acc = m->bq1[j];
        for (int i = 0; i < N_IN; i++)
            acc += m->q1[j * N_IN + i] * q[i];
        int v = (int)floorf((float)acc * m->m1[j] + 0.5f);
        h[j] = (int8_t)(v < 0 ? 0 : v > 127 ? 127 : v);  /* ReLU — нижняя граница 0 */
    }
    for (int c = 0; c < N_OUT; c++) {
        int32_t acc = m->bq2[c];
        for (int j = 0; j < m->n_hid; j++)
            acc += m->q2[c * m->n_hid + j] * h[j];
        out[c] = (float)acc * m->m2[c];
    }
}

/* Уверенность: наибольшая вероятность softmax не меньше TAU, иначе ответ «не уверен» */
int confident(const float s[N_OUT])
{
    float mx = s[0], sum = 0.0f;
    for (int c = 1; c < N_OUT; c++)
        mx = s[c] > mx ? s[c] : mx;
    for (int c = 0; c < N_OUT; c++)
        sum += expf(s[c] - mx);
    return 1.0f / sum >= TAU;
}

int argmax4(const float s[N_OUT])
{
    int k = 0;
    for (int c = 1; c < N_OUT; c++)
        if (s[c] > s[k])
            k = c;
    return k;
}

/* Контрольная сумма FNV-1a по битам логитов: совпала — значит, совпало каждое число */
uint32_t fnv_add(uint32_t h, const float s[N_OUT])
{
    for (int c = 0; c < N_OUT; c++) {
        uint32_t u;
        memcpy(&u, &s[c], 4);
        h = (h ^ u) * 16777619u;
    }
    return h;
}

/* Все варианты под одним номером — чтобы ни одна таблица весов не выпала при сборке */
int n_models(void)
{
    return (int)(sizeof MODELS / sizeof MODELS[0] + sizeof I8MODELS / sizeof I8MODELS[0]);
}

void run_any(int k, const float *x, float *out)
{
    int nf = (int)(sizeof MODELS / sizeof MODELS[0]);
    if (k < nf)
        model_run(&MODELS[k], x, out);
    else
        i8_run(&I8MODELS[k - nf], x, out);
}

#ifdef ML_HOST
#include <stdio.h>

int main(void)
{
    int nf = (int)(sizeof MODELS / sizeof MODELS[0]), same = 0;
    for (int k = 0; k < n_models(); k++) {
        uint32_t h = 2166136261u, want = k < nf ? MODELS[k].hash : I8MODELS[k - nf].hash;
        int right = 0, unsure = 0;
        for (int n = 0; n < N_TEST; n++) {
            float s[N_OUT];
            run_any(k, &TEST_X[N_IN * n], s);
            h = fnv_add(h, s);
            right += argmax4(s) == TEST_Y[n];
            unsure += !confident(s);
        }
        same += h == want;
        printf("%s=%d,%08x,%d\n", k < nf ? MODELS[k].name : I8MODELS[k - nf].name, right, h, unsure);
    }
    printf("host_hash_ok=%d,%d\n", same, n_models());
    return 0;
}
#endif

Дальше: раздел 119б «Модель на плате: TensorFlow Lite Micro изнутри — MicroInterpreter, резолвер операторов, тензорная арена, входной буфер и поток отсчётов» — модель уезжает в прошивку: массив во флеш-памяти, набор ядер, арена, вход int8, поток от DMA до окна, время по операторам и сборка TFLM без make.

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