尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32嵌入式PLS回归:从模型训练到C语言预测部署

STM32嵌入式PLS回归:从模型训练到C语言预测部署 简介本资源是一套轻量级C语言实现的偏最小二乘回归PLS算法代码专为嵌入式场景优化可直接部署于STM32等资源受限的单片机平台面向嵌入式AI开发者、工业传感器数据分析工程师及低功耗边缘计算学习者解决高维多变量共线性数据在端侧实时建模与预测的难题。压缩包共4个文件2个C源码 2个头文件总大小仅8KB其中pls.c封装PLS核心迭代逻辑matrix.c提供精简矩阵运算支持配套头文件定义接口与数据结构整体设计兼顾数值稳定性与内存效率。已有1599人学习下载适合希望深入理解PLS数学原理、掌握嵌入式机器学习算法移植方法、并获取可直接编译运行的轻量化参考实现的中高级开发者。 最近我在做一个便携式近红外光谱检测项目硬件部分用的STM32F407需要在设备端实时估算样品成分含量。理论上这活儿应该在PC上跑Python但真要交付到现场设备上就得把回归模型塞进单片机里。我选的是偏最小二乘回归PLS这是光谱分析领域最常用的线性建模方法之一优点是能处理特征多、样本少、变量之间强相关性的数据。把PLS移植到STM32上核心思路其实很清晰训练在PC端完成STM32只负责做预测推理把模型参数固化成C数组用纯C实现矩阵运算和预测函数。这篇内容适合做嵌入式传感器方案、光谱/气体检测设备的人也适合想熟悉嵌入式端机器学习的人。里面涉及的代码和思路都能直接抄作业不是那种只能看的原理文。我会从模型选型、算法化简、C代码实现、STM32适配到问题排查把整个流程完整走一遍。1. 嵌入式场景下的回归建模与整体设计1.1 回归模型在MCU上能解决什么问题先聊一下应用场景。你在MCU上做回归预测多半是遇到这几类需求第一是传感器标定比如MQ135这类气体传感器读数受温湿度影响很大直接拿ADC值换算浓度误差能到30%以上这时候用多元回归拟合补偿模型就很划算第二是光谱/近红外检测特征波段有几十上百个必须用偏最小二乘或主成分回归这类能降维的方法第三是设备健康预测比如根据电机电流、振动特征估计剩余寿命这类问题特征不多但对实时性有要求。MCU和PC做回归的本质区别不是“能不能算”的问题而是“内存够不够、时间来得及来不及”的问题。STM32F4主频168MHz带FPU单精度浮点乘加大概几个周期能完成跑一次几十个特征的线性预测根本不在话下。真正的挑战是算法实现是否简单可靠、模型参数怎么组织、怎么烧进Flash、怎么和现有的采集逻辑对接。这些才是本文要解决的重点。1.2 为什么选PLS而不是神经网络或其他算法做嵌入式算法的选型我一直坚持一个原则能用线性模型解决的坚决不引入非线性模型。PLS回归本质上是一个带降维步骤的线性模型计算量小、参数少、行为可解释比神经网络更适合资源受限的MCU。神经网络动辄几千上万个权重光存储就够呛而且推理时的激活函数、矩阵运算复杂度也高在单片机上跑只能说“能跑”谈不上“好用”。MLR多元线性回归倒是更简单但特征多的时候要满足无多重共线性实际操作里光谱数据相邻波长的相关性极高MLR求逆矩阵时经常数值不稳定。PCA回归做一个折中方案是可以的先降维再做回归不过它只看X的方差不管和Y的相关性降维后的主成分可能和预测目标没什么关系。PLS同时考虑X和Y的方差与协方差提取的潜变量和Y的相关性最强所以同样的维度下预测精度通常更好。这也是为什么光谱分析行业里PLS是事实标准。1.3 工程结构训练在PC预测在MCU初次接触嵌入式机器学习的人容易犯一个错误——想把训练过程一起搬到单片机上。训练需要迭代、需要求逆、需要大量样本一套十几个特征的PLS训练在PC上毫秒级完成在STM32上就可能要几秒甚至更久还会占用大量RAM。除非你的设备有特殊需求比如板载在线学习否则不推荐在MCU上做训练。我的工程结构是两段式的PC端用Python的scikit-learn或自定义的PLS算法训练模型导出回归系数、均值、标准差和截距MCU端用纯C实现预测函数把模型参数以const数组或struct的方式放在Flash里上电加载之后直接调用预测。这样PC端可以灵活调整模型、评估精度MCU端保持代码简单、逻辑可控两边的优势都发挥出来了。2. PLS回归原理与C语言实现前的核心认知2.1 数学本质抓主要矛盾PLS的核心思想用一句话概括在X矩阵里找出一组潜变量也叫得分向量这组潜变量不仅尽量保留X的方差信息同时还要求与Y的协方差最大化。你可以把它理解成“既要代表X又要解释Y”的折中策略。和PCA只关心X的方差不同PLS在迭代过程中每一步都想办法让提取出来的成分与Y对齐所以预测Y的时候效率更高。PLS有多种实现算法最常用的有NIPALS非线性迭代偏最小二乘法和SIMPLS。NIPALS历史最悠久、容易理解适合教学和验证SIMPLS在单输出回归上计算更稳定、速度更快主流统计软件里基本都用它。无论哪种算法最终产出的核心东西是一样的一组可以用于预测的回归系数B和截距项b0预测公式就是 y_pred X · B b0。2.2 预测公式的推导与标准化处理如果训练时用了标准化z-score即减去均值除以标准差那么预测时就要对输入X做同样的变换。标准化的价值在于消除不同特征的量纲差异否则量级大的特征会在模型里占主导。假设训练时X的均值为mean_i、标准差为std_iY的均值为y_mean、标准差为y_stdPLS内部得到的标准化系数为c_i那么预测步骤是先对x_i做标准化x_std_i (x_i - mean_i) / std_i 再算标准化Y的预测值y_std_pred sum(c_i * x_std_i) b 最后还原为真实Yy_pred y_std_pred * y_std y_mean这里有个工程上的小技巧MCU上每次预测都要做减法、除法很啰嗦而且除法在嵌入式上很贵。你完全可以把标准化和系数合并成一个总的线性系数a_i c_i / std_i b_total b - sum(mean_i * c_i / std_i)最终预测就变成 y sum(x_i * a_i) * y_std y_mean。如果你不想在MCU里保存y_std和y_mean还能再进一步直接把a_i乘上y_std把b_total也乘上y_std再加y_mean最终合并成 y_pred sum(x_i * w_i) bias。这就在MCU端完全消灭了除法和平方根运算只剩下乘加累加预测耗时可以压到微秒级。2.3 回归系数的离线求取那这套系数怎么来我建议在PC端用Python完成训练然后导出参数。我自己测试过两种方式一是直接用sklearn的PLSRegression训练完读取model.coef_和model.intercept_再配合自己算的mean/std手动合并二是手写一个简化的SIMPLS算法完全控制每一处输出。前者省事、适合快速迭代后者适合你想深入理解算法、或者需要特殊定制的场景。无论哪种方式训练完成后你手里会有一套数组mean[features]、std[features]、coef[features]已经合并标准化之后的系数、bias合并后的总截距。接下来就是把这些数组转成C语言的static const数组放进头文件或单独的.c文件里。3. C语言实现矩阵运算与PLS预测引擎3.1 嵌入式友好的矩阵数据结构在MCU上写矩阵运算我强烈建议不要用动态分配malloc/free原因是ST芯片的堆默认不大长期运行容易产生碎片而且MCU上出内存泄漏问题非常难排查。我推荐用固定大小的结构体加静态数组结构体里只保存行列数和数据指针数据本身放在静态区或全局数组里。下面是我在项目里使用的矩阵头文件定义// matrix.h #ifndef MATRIX_H #define MATRIX_H #include stdint.h #define MATRIX_MAX_ROWS 16 #define MATRIX_MAX_COLS 64 typedef struct { uint16_t rows; uint16_t cols; float data[MATRIX_MAX_ROWS * MATRIX_MAX_COLS]; } Matrix; void mat_init(Matrix *m, uint16_t rows, uint16_t cols); void mat_set(Matrix *m, uint16_t r, uint16_t c, float val); float mat_get(const Matrix *m, uint16_t r, uint16_t c); void mat_mul(const Matrix *a, const Matrix *b, Matrix *out); void mat_print(const Matrix *m); #endif这里把最大行列数写死是为了让编译器分配静态内存时算得准不会出现越界或分配失败的问题。如果你要用的特征维度超过了64把MATRIX_MAX_COLS改大就行代价是多占一些RAM自己评估一下。3.2 矩阵乘法的C语言实现矩阵乘法是PLS预测里最核心的运算之一。虽然预测阶段最终可以化简成乘加但训练阶段、联合测试阶段或者你要在MCU上做交叉验证的时候还是离不开通用矩阵乘法。实现时注意三重循环的内层顺序尽量让内存访问连续Cortex-M4有FPU但缓存很小访存模式不好速度差异明显。// matrix.c #include matrix.h void mat_init(Matrix *m, uint16_t rows, uint16_t cols) { m-rows rows; m-cols cols; for (uint16_t i 0; i rows * cols; i) { m-data[i] 0.0f; } } void mat_set(Matrix *m, uint16_t r, uint16_t c, float val) { m-data[r * m-cols c] val; } float mat_get(const Matrix *m, uint16_t r, uint16_t c) { return m-data[r * m-cols c]; } void mat_mul(const Matrix *a, const Matrix *b, Matrix *out) { // 确保out与a、b不是同一个矩阵否则会有覆盖问题 if (a-cols ! b-rows) return; uint16_t M a-rows; uint16_t N b-cols; uint16_t K a-cols; mat_init(out, M, N); for (uint16_t i 0; i M; i) { for (uint16_t j 0; j N; j) { float sum 0.0f; for (uint16_t k 0; k K; k) { sum a-data[i * K k] * b-data[k * N j]; } out-data[i * N j] sum; } } }注意Matrix结构体里自带data数组所以矩阵变量本身比较大。如果你在函数里临时定义Matrix局部变量会占不少栈空间建议矩阵都定义为全局或static变量函数只操作指针。3.3 预测函数与模型结构体预测函数的核心是一个乘加循环。我把模型结构体定义成下面这样直接映射训练后的参数// pls_model.h #ifndef PLS_MODEL_H #define PLS_MODEL_H #include stdint.h #define PLS_MAX_FEATURES 32 typedef struct { uint16_t n_features; float coef[PLS_MAX_FEATURES]; // 已合并标准化的最终系数 float bias; // 合并后的最终偏置 } PLS_Model; float pls_predict(const PLS_Model *model, const float *x); #endif// pls_model.c #include pls_model.h float pls_predict(const PLS_Model *model, const float *x) { float y model-bias; for (uint16_t i 0; i model-n_features; i) { y x[i] * model-coef[i]; } return y; }这个函数简单到让人怀疑是不是少写了什么但实际上PLS预测阶段就是这么简单线性加权和。整个算法的复杂度集中在训练阶段而推理阶段不过是一条乘加指令流。如果特征数是32在168MHz的F407上跑一次预测大概在几百纳秒到一两微秒之间完全不用担心性能。3.4 模型参数如何导入与固化模型参数的固化方式有两种视你的开发习惯而定。第一种是直接写在C源码里用static const数组初始化适合模型不常变化的场景。第二种是存成二进制文件放外部Flash或SD卡设备启动时读入RAM适合希望后期在线升级模型的场景。直接写在源码里的方式最简单// pls_model_data.c #include pls_model.h const PLS_Model g_model { .n_features 32, .coef { 0.1524f, -0.0872f, 0.2103f, 0.0731f, 0.0964f, -0.0318f, 0.1802f, -0.0647f, // 剩余系数... }, .bias 2.3471f };如果你的单片机Flash空间够建议把模型数组放到单独的.c文件里这样修改模型参数时只需要重新编译这个文件不影响其他代码。如果模型较大还可以用__attribute__((section(.model)))把数组放到特定Flash扇区方便做OTA更新。实际上我在做批量标定的时候经常通过串口下发一组新的系数直接在RAM里更新g_model结构体内容然后校验CRC这样现场调参就不用反复烧录固件了。4. STM32平台适配与性能优化4.1 资源评估与编译环境准备我用的开发环境是STM32CubeIDE加HAL库编译器是arm-none-eabi-gcc开启-O2优化。目标芯片是STM32F407VET6Flash 512KBRAM 128KB。一个32特征的PLS模型模型数组占约4×324132字节运行时的栈开销几十字节对这样一颗芯片来说简直可以忽略不计。如果你用的是STM32F103这种Cortex-M3没有FPU浮点运算全靠软件模拟速度慢一些但跑几十个特征的预测同样没问题只是要注意编译选项不要开过度优化。CubeIDE新建工程时确保在Project Settings里启用了浮点打印支持如果你要printf调试的话在C/C Build的Settings里把优化级别设成-O2。F4系列默认启用硬件FPUCubeIDE会自动加-mfloat-abihard -mfpufpv4-sp-d16。如果你是在旧工程或裸机工程里折腾记得检查这组编译选项。另外如果你在VS Code或命令行里自己搭环境链接器脚本里也要确保堆栈大小足够虽然预测函数用不到多少栈但其他外设和协议栈可能吃栈很凶。4.2 内存组织Flash存模型RAM存临时数据STM32的内存结构里Flash和RAM是分开的。模型参数是只读的放Flash最合适不占RAM加载也快。但如果你的模型参数需要在线更新就不能放const区要把参数放到RAM或者放到外部Flash启动时拷贝到RAM。这个设计决策要在项目初期就定下来否则后面改起来很痛苦。我的设计习惯是模型参数全部放Flash const区预测函数只读不写。涉及临时矩阵运算时在函数内部定义static的临时数组避免每次调用都重新初始化顺便也避开了栈溢出风险。比如预测函数本身不申请任何临时空间直接遍历模型结构体里的coef数组所以它天然是零内存分配的这在嵌入式里是一个很好的特性。有个细节值得注意如果你用scanf、printf这类标准库函数HAL库和newlib的默认堆设置可能被改变导致malloc行为异常。为了避免踩坑建议在代码里禁用malloc或者排查链接脚本里的堆大小设置确认不会在运行时出现内存不足。对于只需要printf输出的场景我一般直接把堆大小设到0反正不用动态分配。4.3 浮点运算优化与编译器选项Cortex-M4F的FPU是单精度的这意味着double运算会被拆成多个指令速度明显变慢。所以我在整个工程里统一用float类型包括模型参数、中间变量和返回值。有一个常见的坑是在C语言里写小数常量时3.14会被默认成double运算时触发类型转换和精度损失警告解决办法是使用f后缀写成3.14f。编译器优化选项上我建议用-O2而不是-Os。-Os为了减小代码体积可能会牺牲部分性能而-O2在平衡代码体积和性能上更合适。如果你的Flash空间特别紧张再考虑-Os同时配合-fomit-frame-pointer这类选项。如果要在MCU里临时算方差或标准差直接调用sqrtf等CMSIS-DSP函数。CMSIS-DSP库里还有矩阵运算函数arm_mat_mult_f32、arm_mean_f32等实测比手写循环要快一些不过会引入额外的库依赖适合你对性能和代码体积都有要求的情况。对于简单的乘加预测我建议不要用CMSIS-DSP的向量乘加函数因为编译器在-O2下会把简单循环优化得很好没必要增加调用开销。4.4 与ADC采集、外设的集成流程真正让PLS跑起来的场景一定是模型输入来自传感器采集。以近红外光谱为例一般是光谱仪通过ADC输出一组光强值你要把这组值换算成浓度。集成的时候我建议把采集、预处理、PLS预测、结果输出拆成四个独立模块每个模块用函数接口隔离方便单独测试。下面是一个简化的主程序框架// main.c 片段 #include pls_model.h #include adc.h extern const PLS_Model g_model; // 假设有16个波段的原始ADC值 float raw_features[16]; float final_value; // ADC采集并换算为特征 float read_feature(uint8_t channel); int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); while (1) { for (uint8_t i 0; i g_model.n_features; i) { raw_features[i] read_feature(i); } final_value pls_predict(g_model, raw_features); // 输出到串口、OLED或控制外部设备 printf(predict: %.3f\r\n, final_value); HAL_Delay(100); } }整个流程清晰简单每一层都能独立验证。我一般会先写一个test函数用一组已知的输入和期望输出直接比数值确认预测引擎没问题后再接入真实的传感器数据流。这样定位问题时能清楚知道是采集的锅、预处理的锅还是模型的锅。5. 模型验证与常见问题排查5.1 离线与在线预测一致性验证移植完代码第一件事不是接传感器而是做一致性验证。具体做法是在PC上用测试集数据跑一遍Python预测得到一组结果再把同样的数据数组喂给STM32的pls_predict函数打印结果两者逐项对比。如果浮点精度控制在1e-3以内说明C移植没问题。我踩到过一个典型的坑PC端sklearn默认输入是二维数组Y是一维的如果你用自定义的训练脚本容易把mean和std的方向搞反导致导出的系数对不上。建议导出参数后先在PC端手动按公式算一遍合并系数再对比C代码里的预期输出这个步骤能省掉大量后面联调的时间。还有一次遇到过很隐蔽的问题sklearn的PLSRegression返回的coef_形状是(1, n_features)用model.coef_[0]和model.coef_在数组维度上不同导致我当时对两者求均值时数据错位。这个问题在Python里不报错但结果全错非常坑。所以实际导出时最好显式保存coef_[0]并且写一段脚本打印前几个系数肉眼核对后再嵌到C文件里。5.2 常见问题速查表问题现象可能原因解决方案预测结果全为常数模型系数或bias为0导入时数据错位检查数组元素个数用单元测试逐值比对输出值和PC端差异大特征输入顺序不一致或没做标准化确认特征顺序检查PC端标准化是否合并在系数里系统卡死或HardFault数组越界、栈溢出或用了未初始化的指针静态分配矩阵检查内存大小开启MPU保护测试浮点输出精度异常double和float混用或编译选项未开软浮点统一float类型检查-mfloat-abi设置模型更新后不生效编译器把const数组优化掉了或Flash没更新重新编译下载确认链接脚本里的Flash段地址printf输出全是0浮点printf未启用CubeIDE里勾选Use float with printf或用整数拆分打印5.3 实测效果与优化心得以我这个项目的实测数据为例16个特征对应16个波长点训练集样本60个PLS潜变量数取5预测一个样本在STM32F407上耗时不到2微秒RAM占用增加不超过200字节Flash占用增加不超过1KB。同样的流程放在STM32F103上因为没有FPU耗时大约在10微秒左右但依然可以轻松满足100Hz以上的采集刷新率。如果觉得预测结果不够准优先检查的不是代码而是模型本身。潜变量个数选多少合适可以通过交叉验证来确定特征输入是否包含了环境扰动变量温度、湿度等也会显著影响精度。模型侧的调整比优化代码带来的收益大得多这是我在多个项目里验证过的经验。另外还有一些小细节如果用printf打印浮点在CubeIDE里要勾选“使用浮点printf”否则输出全是0ADC采集要注意多通道切换后的稳定时间转换数据要等通道稳定后再读取如果MCU还有其他中断任务预测函数要关中断或使用临界区保护避免数据被采样中断篡改。最后再分享一个小技巧把预测函数写成inline形式或者放到头文件里定义为static inline能减少一次函数调用开销。虽然这对单次预测影响不大但如果你的设备一秒钟要预测几千次累积起来还是比较可观的。我实际测试过在-O2下inline后预测耗时能再缩个20%左右。这个项目最后跑下来的整体感受是PLS这类线性模型天生就是为单片机准备的关键是处理好训练和预测的边界把模型参数合理固化到Flash剩下的代码量其实很小排查起来也很有底气。本文还有配套的精品资源点击获取
返回列表