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

116. Типовые подходы и алгоритмы: от порога до маленькой нейросети

На микроконтроллере «машинное обучение» почти всегда значит одно: по нескольким числам с датчика решить, в каком состоянии объект. Для этого есть пять типовых подходов — порог, логистическая регрессия, дерево решений, k ближайших соседей и маленькая нейросеть. Раздел решает ими одну задачу — состояние мотора по вибрации — и сравнивает их по точности, памяти, числу умножений и размеру кода на F407. Обучение — scikit-learn в образе stm-ml:1, вывод — свой код на C, и его ответы совпадают с scikit-learn на всей проверочной выборке.

TinyML в целом — раздел 85, признаки из сигнала — раздел 117, обучение — раздел 118.

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

Оригинал Перевод
feature признак: число, по которому решают
class, label класс, метка
training set, test set обучающая и проверочная выборки
accuracy доля верных ответов
inference вывод: ответ обученной модели
threshold rule порог, правило
logistic regression логистическая регрессия
decision tree дерево решений
k-nearest neighbours (k-NN) k ближайших соседей
multilayer perceptron (MLP) многослойный перцептрон
ReLU «выпрямитель»: max(0, x)
standardization приведение признака к среднему 0 и разбросу 1
multiply-accumulate (MAC) умножение со сложением

116.1 Задача и данные

Данные задачи: три состояния мотора по двум признакам и порог

Два признака: СКЗ вибрации, g, и частота пика спектра, Гц. Три класса: норма, дисбаланс — пик на частоте вращения 25 Гц при вибрации сильнее нормы, — и износ подшипника с пиком около 160 Гц. Данные синтетические, с фиксированным зерном: 600 примеров для обучения и 300 для проверки, по трети на класс. Норма и дисбаланс перекрываются по СКЗ — ошибаться будут все пять подходов, вопрос в том, насколько.


116.2 Порог: два сравнения

int rule_predict(const float x[2])
{
    if (x[1] > F_CUT)
        return 2;
    return x[0] > R_CUT ? 1 : 0;
}

Частота пика выше 92,5 Гц — подшипник; иначе СКЗ выше 0,4209 g — дисбаланс. Порог по СКЗ — середина между средними нормы и дисбаланса на обучающей выборке. Правило верно в 282 случаях из 300 — лучше всех остальных подходов, — хранит 2 числа и не умножает ни разу.


116.3 Логистическая регрессия

int logreg_predict(const float x[2])
{
    float z[2], s[3];
    standardize(x, z);
    for (int c = 0; c < 3; c++)
        s[c] = LR_W[c * 2] * z[0] + LR_W[c * 2 + 1] * z[1] + LR_B[c];
    return argmax3(s);
}

Каждому классу — взвешенная сумма признаков, ответ — наибольшая. Признаки сначала приводят к одному масштабу: иначе частота в сотни герц задавила бы СКЗ в доли g. 13 чисел, 6 умножений, 281 верный ответ из 300.


116.4 Дерево решений

int tree_predict(const float x[2])
{
    int n = 0;
    while (TREE_LEFT[n] >= 0)
        n = x[TREE_FEAT[n]] <= TREE_THR[n] ? TREE_LEFT[n] : TREE_RIGHT[n];
    return TREE_CLASS[n];
}

Дерево глубиной 3 — 9 узлов: не больше трёх сравнений, ни одного умножения. Порог из 116.2 — то же дерево, только построенное головой. Обученное дерево верно в 279 случаях из 300: оно подогнало пороги под обучающую выборку чуть точнее, чем нужно.


116.5 k ближайших соседей

k-NN не обучается вовсе: он помнит всю обучающую выборку и отвечает голосованием пяти ближайших примеров. Отсюда цена — 1204 числа в памяти и 1200 умножений на каждый ответ, а верных ответов — 278 из 300.

    for (int i = 0; i < N_TRAIN; i++) {
        float dx = z[0] - KNN_X[2 * i], dy = z[1] - KNN_X[2 * i + 1];
        float d = dx * dx + dy * dy;

116.6 Маленькая нейросеть

Перцептрон 2 → 8 → 3: скрытый слой из 8 нейронов с ReLU и выходной слой.

    for (int j = 0; j < N_HIDDEN; j++) {
        float a = MLP_B1[j] + z[0] * MLP_W1[j] + z[1] * MLP_W1[N_HIDDEN + j];
        h[j] = a > 0 ? a : 0;
    }

55 чисел, 40 умножений, 281 верный ответ из 300 — столько же, сколько у логистической регрессии. Нейросеть выигрывает, когда граница между классами кривая и признаков много; здесь граница — две прямые, и сети нечего добавить.


116.7 Где ошибаются: матрица ошибок

Одно число «верно из 300» прячет главное — какие классы модель путает. Матрица ошибок раскладывает ответы по парам «было — сказала модель». Здесь у всех пяти подходов картина одна: подшипник все пять узнают в 100 случаях из 100 — его выдаёт частота пика, — а все ошибки приходятся на пару «норма — дисбаланс», которые перекрываются по СКЗ.

Подход Норму приняли за дисбаланс Дисбаланс приняли за норму
Порог 8 10
Логистическая регрессия 11 8
Дерево глубиной 3 2 19
k ближайших 10 12
Перцептрон 11 8

Дерево ошибается несимметрично: почти не поднимает ложную тревогу, зато пропускает 19 дисбалансов из 100. Для прибора это разные ошибки: ложная тревога стоит вызова мастера, пропущенный дисбаланс — подшипника через месяц. Какую ошибку терпеть, решает задача, а не точность; порог сдвигают в нужную сторону.


116.8 Кросс-проверка: разница в пределах случайности

Проверочная выборка — одна из многих возможных, и 282 против 278 может оказаться везением. Кросс-проверка делит обучающую выборку на 5 частей по 120 примеров, по очереди проверяет на каждой, обучаясь на остальных. Сумма по пяти частям: регрессия — 547 из 600, k ближайших и перцептрон — по 546, дерево и порог — по 541. А внутри одной модели точность гуляет от части к части: у регрессии от 105 до 113 из 120. Разница между подходами на проверочной выборке — от 1 до 4 примеров из 300, то есть меньше этого разброса. Вывод честный: на этой задаче все пять подходов равны, и выбирают по цене, а не по точности.


116.9 Сколько нужно данных

Те же модели, обученные на первых 10, 25, 50, 100 и 200 примерах каждого класса, и та же проверочная выборка из 300:

Примеров на класс Регрессия Дерево Перцептрон
10 275 235 273
25 277 284 277
50 282 281 281
100 282 281 282
200 281 279 281

На 10 примерах дерево ставит пороги по случайным точкам и ошибается в 65 случаях, а регрессия и сеть, у которых граница гладкая, держатся. С 50 примеров на класс точность больше не растёт: границу между нормой и дисбалансом определяет перекрытие классов, а не число примеров. Больше данных помогает, пока модель не упёрлась в сами данные; дальше помогают только лучшие признаки — раздел 117.

Число верных ответов из 300 против числа примеров на класс от 10 до 200 для регрессии, дерева и перцептрона: дерево на 10 примерах даёт 235, остальные держатся около 275, с 50 примеров на класс все три выходят на плато около 281


116.10 Сводка

Подход Верно из 300 Параметров Умножений Код на F407
Порог 282 2 0 48 байт
Логистическая регрессия 281 13 6 176 байт
Дерево глубиной 3 279 9 узлов 0 88 байт
k ближайших, k = 5 278 1204 1200 808 байт
Перцептрон 2 → 8 → 3 281 55 40 760 байт

Ответы функций на C совпали с ответами scikit-learn во всех 300 случаях у каждой из пяти моделей. Выбирать начинают с порога или дерева: они понятны, их можно проверить глазами, и они почти ничего не стоят. К нейросети переходят, когда признаков много и простая граница не находится. k-NN на микроконтроллере почти всегда плох: память и время растут вместе с обучающей выборкой.


116.11 Грабли

Точность на обучающей выборке 100 %, на проверочной — хуже порога. Модель запомнила обучающие примеры. Точность считают только на данных, которых модель не видела.

Модель на плате отвечает не так, как на компьютере. Признаки не приведены к масштабу обучения: среднее и разброс надо увезти в прошивку вместе с весами.

k-NN не помещается в ОЗУ. Он хранит всю обучающую выборку.

Нейросеть «лучше», а в поле ошибается. Синтетика и лабораторные данные не похожи на настоящий мотор; сначала собирают данные с объекта (раздел 117).


116.12 Итоги

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

infer116.c — вывод пяти моделей на C во float с параметрами из model116.h.

/* Раздел 116: пять моделей — одна задача, вывод на Cortex-M4F во float.
 * Параметры — из model116.h (выгрузил train116.py). На плате каждая функция
 * берёт два признака — СКЗ, g, и частоту пика, Гц — и отвечает классом:
 * 0 — норма, 1 — дисбаланс, 2 — подшипник. На компьютере main сверяет
 * ответы с scikit-learn на проверочной выборке (-DML_HOST).
 * Проверка — _verify/ml116_check.py. */
#include <stdint.h>

#include "model116.h"

/* 1. Порог: два сравнения, ни одного умножения */
int rule_predict(const float x[2])
{
    if (x[1] > F_CUT)
        return 2;
    return x[0] > R_CUT ? 1 : 0;
}

static void standardize(const float x[2], float z[2])
{
    z[0] = (x[0] - MU[0]) / SD[0];
    z[1] = (x[1] - MU[1]) / SD[1];
}

static int argmax3(const float s[3])
{
    int k = 0;
    for (int i = 1; i < 3; i++)
        if (s[i] > s[k])
            k = i;
    return k;
}

/* 2. Логистическая регрессия: три взвешенные суммы, класс — наибольшая */
int logreg_predict(const float x[2])
{
    float z[2], s[3];
    standardize(x, z);
    for (int c = 0; c < 3; c++)
        s[c] = LR_W[c * 2] * z[0] + LR_W[c * 2 + 1] * z[1] + LR_B[c];
    return argmax3(s);
}

/* 3. Дерево решений: спуск по узлам, одно сравнение на уровень */
int tree_predict(const float x[2])
{
    int n = 0;
    while (TREE_LEFT[n] >= 0)
        n = x[TREE_FEAT[n]] <= TREE_THR[n] ? TREE_LEFT[n] : TREE_RIGHT[n];
    return TREE_CLASS[n];
}

/* 4. k ближайших соседей, k = 5: расстояние до каждого обучающего примера */
int knn_predict(const float x[2])
{
    float z[2], best_d[5];
    int best_y[5];
    standardize(x, z);
    for (int i = 0; i < 5; i++) {
        best_d[i] = 3.4e38f;
        best_y[i] = 0;
    }
    for (int i = 0; i < N_TRAIN; i++) {
        float dx = z[0] - KNN_X[2 * i], dy = z[1] - KNN_X[2 * i + 1];
        float d = dx * dx + dy * dy;
        if (d >= best_d[4])
            continue;
        int j = 4;                      /* вставка в отсортированные пять лучших */
        while (j > 0 && best_d[j - 1] > d) {
            best_d[j] = best_d[j - 1];
            best_y[j] = best_y[j - 1];
            j--;
        }
        best_d[j] = d;
        best_y[j] = KNN_Y[i];
    }
    float votes[3] = { 0, 0, 0 };
    for (int i = 0; i < 5; i++)
        votes[best_y[i]] += 1.0f;
    return argmax3(votes);
}

/* 5. Перцептрон 2 → 8 → 3: скрытый слой с ReLU и выходной слой */
int mlp_predict(const float x[2])
{
    float z[2], h[N_HIDDEN], s[3];
    standardize(x, z);
    for (int j = 0; j < N_HIDDEN; j++) {
        float a = MLP_B1[j] + z[0] * MLP_W1[j] + z[1] * MLP_W1[N_HIDDEN + j];
        h[j] = a > 0 ? a : 0;
    }
    for (int c = 0; c < 3; c++) {
        float a = MLP_B2[c];
        for (int j = 0; j < N_HIDDEN; j++)
            a += h[j] * MLP_W2[j * 3 + c];
        s[c] = a;
    }
    return argmax3(s);
}

#ifdef ML_HOST
#include <stdio.h>

int main(void)
{
    static const struct {
        const char *name;
        int (*fn)(const float *);
        const unsigned char *sk;
    } m[5] = {
        { "rule", rule_predict, SK_RULE }, { "logreg", logreg_predict, SK_LOGREG },
        { "tree", tree_predict, SK_TREE }, { "knn", knn_predict, SK_KNN }, { "mlp", mlp_predict, SK_MLP },
    };
    for (int k = 0; k < 5; k++) {
        int same = 0, right = 0;
        for (int i = 0; i < N_TEST; i++) {
            int p = m[k].fn(&TEST_X[2 * i]);
            same += p == m[k].sk[i];
            right += p == TEST_Y[i];
        }
        printf("c_%s=%d,%d\n", m[k].name, same, right);
    }
    printf("done=1\n");
    return 0;
}
#endif

Дальше: раздел 117 «Данные с датчика: окна, признаки, CMSIS-DSP» — откуда берутся СКЗ и частота пика.

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