Qt+C++实现神经网络训练与Excel数据交互实战 📅 发布时间:2026/9/7 8:42:05 👁 浏览次数: 简介这是一份基于Qt与C开发的人工神经网络项目资源聚焦BP神经网络在PM2.5浓度预测中的应用并实现了Qt对Excel数据的读写调用适合需要将神经网络算法与桌面GUI及表格数据打通的开发者参考。压缩包共22个文件以.cpp/.h/.ui源文件、.pro工程文件、编译好的.exe可执行程序及Qt运行所需的.dll库为主另含三个Word说明文档和Excel样例数据整体体积仅5.11MB结构清晰。目前已有1225人学习下载。通过阅读源码与文档可以掌握Qt界面设计、神经网络前向与反向传播的编码实现、利用QAxObject操作Excel读取输入数据或写回预测结果的方法文档还从模型实现、算法优势等角度讲解了BP网络在城市空气质量预测中的具体应用配合可直接运行的release程序便于边学边练快速上手类似预测项目。 最近做工业数据采集分析工具的时候我发现了一个挺反直觉的现象Python 写神经网络原型确实爽几行代码就能搭出模型但真要把训练、推理、数据导入导出、界面展示做成一套完整闭环交付给现场工程师用反而是 Qt C 这套“老派桌面技术栈”更占优势。原因很简单——客户那边不会给你装 Python 环境和一堆依赖库他们要的是双击就能跑的 exe。这篇文章就把我这次从零用 Qt 实现人工神经网络、并通过 Qt 调用 Excel 完成数据读写闭环的完整经验梳理一遍。内容会覆盖多层感知机的 C 实现、训练过程的实时可视化用 QCustomPlot 画损失曲线再用 KissFFT 把时域信号转成频域做震荡分析、以及通过 COM 组件操作 Excel 的完整代码。适合有 Qt 基础但没碰过神经网络的读者也适合想在 C 里做数据可视化、Excel 联动的开发者。1. 整体设计为什么用纯 C/Qt 而不是 Python1.1 技术选型背后的三个现实理由先说结论用 Qt 写神经网络不是为了和 PyTorch、TensorFlow 比训练效率而是解决“交付”和“集成”两个核心问题。第一是部署成本。我在实际项目里遇到的情况是客户现场是 Windows 工控机可能还装了杀毒软件、各种监控程序给这种环境装 Python 环境、配 pip 源、装 GPU 驱动想想都头疼。Qt 程序用 windeployqt 打一下包带上必要的 DLL 就能跑省心太多。第二是性能优势。虽然神经网络训练本身不差那一点半点的速度但 C 实现没有 Python 解释器的开销在数据预处理和推理阶段差距还是比较明显的。尤其当网络需要部署到嵌入式 Linux 板子或者 ARM 平台时C 几乎是唯一选择。第三是生态整合。Qt 的信号槽机制、界面库、跨平台能力让神经网络不再是孤立的训练脚本而是能和业务系统无缝结合。比如我这个工具里数据源是串口读取的传感器数值训练结果又需要写回 Excel 报表这一整条链路用 Python 做的话还得多绕几层在 Qt 里直接一把梭。1.2 模块划分与工程目录规划做这个项目前我先把代码结构理了一遍避免神经网络的逻辑和界面代码混成一团。我的工程目录大致是这样NeuralNetworkApp/ ├── NeuralNetworkApp.pro ├── src/ │ ├── main.cpp │ ├── MainWindow.h / MainWindow.cpp │ ├── network/ │ │ ├── Matrix.h / Matrix.cpp // 简单矩阵运算封装 │ │ ├── Layer.h / Layer.cpp // 全连接层 │ │ ├── Activation.h // 激活函数 │ │ └── NeuralNetwork.h / NeuralNetwork.cpp // 网络结构训练 │ ├── visualization/ │ │ ├── LossChart.h / LossChart.cpp // QCustomPlot 封装 │ │ └── SpectrumAnalyzer.h / SpectrumAnalyzer.cpp // KissFFT 封装 │ └── excel/ │ └── ExcelHelper.h / ExcelHelper.cpp // QAxObject 封装 └── third_party/ ├── qcustomplot/ └── kissfft/这样分层的核心思路是网络模块不依赖任何 Qt 组件纯粹用标准库实现方便做单元测试或者以后移植Excel 操作和可视化各自独立封装界面层只负责把数据串起来。这种模块化设计还有一个好处——如果哪天你想把网络核心替换成 ONNX Runtime 做推理直接改 network 模块的内部实现就行界面和数据处理逻辑完全不用动。环境方面我用的 Qt 5.15.2编译器选的 MSVC 2019。如果下载速度慢记得用国内镜像源这个后面在常见问题里细说。2. 神经网络核心实现从零写一个多层感知机2.1 网络结构与矩阵运算的基础神经网络听起来高大上剥开外壳后核心就两个操作矩阵乘法和激活函数。我实现的这个多层感知机MLP包含输入层、两个隐藏层和一个输出层结构大概是输入层神经元数由特征维度决定隐藏层分别设 16 和 8 个神经元输出层神经元数等于分类类别数。先看矩阵部分的封装。为了提高代码可读性我没用太复杂的三方库就自己封装了极简的矩阵操作// Matrix.h class Matrix { public: Matrix(int rows, int cols, double initVal 0.0); double at(int row, int col); const double at(int row, int col) const; // 返回一个初始化后的随机权重矩阵 static Matrix random(int rows, int cols, double minVal, double maxVal); int rows() const { return rows_; } int cols() const { return cols_; } private: int rows_; int cols_; std::vectordouble data_; // 行优先存储避免 vectorvector 的内存碎片 };权重初始化我用的是 Xavier 初始化也就是根据输入输出维度来设置均匀分布的取值范围limit sqrt(6 / (fan_in fan_out))。这个细节很重要如果用固定小随机数初始化深层网络训练时容易梯度消失如果初始值太大又容易导致激活函数饱和。2.2 前向传播与反向传播的实现细节前向传播很简单一层一层算过去Matrix NeuralNetwork::forward(const Matrix input) { Matrix current input; for (Layer layer : layers_) { // 1. 线性变换: z W * x b Matrix z layer.weights * current; for (int row 0; row z.rows(); row) { z.at(row, 0) layer.bias.at(row, 0); } // 2. 激活函数 current activate(z); } return current; }隐藏层激活函数我用的 ReLUf(x) max(0, x)。ReLU 计算简单、梯度在正区间恒为 1能有效缓解梯度消失问题。输出层在分类任务下用 Softmax把输出映射成概率分布配合交叉熵损失函数使用。反向传播是整个实现的难点核心还是链式法则。我之前踩过不少坑有两个细节特别想提醒第一反向传播时梯度要从输出层往输入层反向传递但中间要缓存每一层的输入值也就是前向传播喂给激活函数之前的值 z后面算梯度要用。第二权重的更新方式是W - learning_rate * dW其中 dW 上一层的残差 * 当前层输入转置。偏置的梯度是残差按列求和。这些细节在代码里容易漏掉一漏整个训练就崩了。训练时我用的是带动量momentum的 SGD 优化器权重更新规则改为velocity momentum * velocity - learning_rate * gradient; W velocity;动量项能让参数更新方向更平滑尤其在损失曲面比较崎岖的时候能显著减少震荡。我在实际运行中对比过不加动量时损失曲线容易在高位波动加了动量之后收敛明显更稳。2.3 训练流程与数据准备训练流程就是一个循环打乱数据集、按 batch 喂入网络、前向计算损失、反向传播更新权重。核心代码如下void NeuralNetwork::train(const std::vectorSample samples, int epochs, int batchSize, double learningRate) { for (int epoch 0; epoch epochs; epoch) { std::vectorSample shuffled samples; std::random_shuffle(shuffled.begin(), shuffled.end()); double totalLoss 0.0; int batchCount 0; for (int i 0; i shuffled.size(); i batchSize) { // 构造一个 batch int end std::min(i batchSize, (int)shuffled.size()); // 累计梯度并在 batch 结束时更新 // ... totalLoss computeLoss(currentBatch); batchCount; if (batchCount % 10 0) { emit trainingProgress(epoch, totalLoss / batchCount); } } } }这里我特意在训练循环里发射了一个信号trainingProgress方便界面实时刷新损失曲线。这是 Qt 的拿手好戏——C 后端通过信号槽把训练进度推给 QCustomPlot完全不需要多线程处理界面刷新。数据准备上我的训练样本是存在 Excel 里的前面几列是特征最后一列是标签。在读取后需要做两步预处理一是把标签转成 one-hot 编码比如三类就映射成 [1,0,0]、[0,1,0]、[0,0,1]二是对特征做归一化把数值压到 0~1 之间。归一化这步特别重要如果不做那些数值范围大的特征会在梯度中占据主导地位其他特征根本学不到东西。3. 训练过程实时可视化QCustomPlot KissFFT 的组合拳3.1 为什么用 QCustomPlotKissFFT 又是干什么的训练神经网络最怕什么不是不收敛而是你不知道它到底在“不收敛”还是“正在往好的方向慢慢走”。所以训练过程的实时可视化非常关键。QCustomPlot 是我用过最好上手的 Qt 绘图库没有之一。它不需要额外安装什么框架把源码丢进工程里就能用绘图的效率在数据点不多的情况下完全够用。你在 pro 文件里加一句QT printsupport然后把qcustomplot.h和qcustomplot.cpp加进工程就可以开始画图了。那 KissFFT 呢这就得说说我自己折腾出来的一个小技巧了。训练过程中损失曲线如果出现周期性的上下震荡可能意味着学习率过大或者 batch size 太小但光看时域曲线很难量化这种震荡。我就在想能不能把损失序列当成一个时域信号做一次快速傅里叶变换FFT在频域里看看有没有明显的周期成分KissFFT 是一个轻量级的 C 语言 FFT 库简单到什么程度呢你把kiss_fft.c、kiss_fft.h、_kiss_fft_guts.h三个文件拷到工程里就能用不需要配置额外的依赖这对 Qt 项目来说太友好了。3.2 损失曲线实时刷新与频谱分析的实现先用 QCustomPlot 建立坐标轴和曲线// LossChart 初始化 void LossChart::setupPlot() { ui-plot-addGraph(); ui-plot-graph(0)-setPen(QPen(Qt::blue)); ui-plot-graph(0)-setName(Training Loss); ui-plot-xAxis-setLabel(Epoch); ui-plot-yAxis-setLabel(Loss); ui-plot-legend-setVisible(true); }训练过程中收到trainingProgress信号时就追加一个数据点并刷新void LossChart::appendPoint(double epoch, double loss) { ui-plot-graph(0)-addData(epoch, loss); ui-plot-xAxis-setRange(0, epoch 1); ui-plot-yAxis-setRange(0, *std::max_element(lossVec.begin(), lossVec.end()) * 1.1); ui-plot-replot(); }训练结束后把缓存的损失值数组交给频谱分析模块。这是 KissFFT 的核心用法#include kiss_fft.h std::vectordouble SpectrumAnalyzer::computeSpectrum(const std::vectordouble timeSeries) { int nfft 1; while (nfft timeSeries.size()) nfft 1; // 补零到 2 的幂 kiss_fft_cfg cfg kiss_fft_alloc(nfft, 0, nullptr, nullptr); kiss_fft_cpx* input new kiss_fft_cpx[nfft]; kiss_fft_cpx* output new kiss_fft_cpx[nfft]; for (int i 0; i timeSeries.size(); i) { input[i].r timeSeries[i]; input[i].i 0.0; } for (int i timeSeries.size(); i nfft; i) { input[i].r 0.0; input[i].i 0.0; } kiss_fft(cfg, input, output, 0); std::vectordouble magnitudes(nfft / 2); for (int i 0; i nfft / 2; i) { magnitudes[i] sqrt(output[i].r * output[i].r output[i].i * output[i].i); } delete[] input; delete[] output; free(cfg); return magnitudes; }频域结果怎么看如果你在频谱的低频区域看到一个明显的主峰说明损失曲线的变化以缓慢趋势为主训练正常但如果频谱在中高频某处出现尖峰就意味着损失在周期性波动你需要调小学习率或者增大 batch size。这个思路算是我这个项目的独家干货查了下网上很少有人这么干但实测下来真的很能说明问题。3.3 频谱分析的注意事项有几个细节得提醒一下。第一做 FFT 之前最好对损失序列做去趋势处理——把数据的平均值减掉。如果不减直流分量频谱图的第 0 个点会是一个特别大的直流峰值其他频率分量都会被它盖住。第二补零到 2 的 N 次幂这个操作会引入一点频谱泄漏但对我们观察周期成分已经足够了不必像做专业信号分析那样加窗函数除非你发现频谱泄漏严重影响了判断。第三损失序列通常只有几百个点做 512 点的 FFT 就能满足分辨率要求不需要取更多点数否则高频区全是补零引入的假信号。4. Qt 调用 Excel 的完整方案4.1 三种方案对比CSV、QAxObject、第三方库聊完网络和可视化终于到标题里另一个重头戏Qt 调用 Excel。我实测过的方法有三种先列个表对比下方案原理优点缺点适用场景CSV 文件读写把 Excel 另存为 CSV用文件流读写简单可靠无需依赖 Office格式丢失不支持多 Sheet数据交换简单、格式不敏感QAxObject COM 操作通过 ActiveX 控件调用 Excel COM 接口功能最完整支持格式设置、图表机器必须装 Office速度偏慢需要完全操控 xlsx 格式第三方库libxlsxwriter、QXlsx 等跨平台无需 Office格式支持有限调试麻烦Linux 下读写 xlsx如果你只是把 Excel 当作数据存取工具CSV 是最省事的选择。但涉及到客户要求的“按模板输出报表”“带格式的汇总表”还是得用 QAxObject 走 COM 这套方案。4.2 用 QAxObject 读写 Excel 的完整代码QAxObject 属于 Qt 的 ActiveX 模块在 pro 文件里加一行QT axcontainerWindows 下 MSVC 和 MinGW 都可以。看代码// ExcelHelper.h #include QAxObject class ExcelHelper { public: bool open(const QString filePath); bool writeCell(int sheetIndex, int row, int col, const QVariant value); QVariant readCell(int sheetIndex, int row, int col) const; bool saveAndClose(); private: QAxObject* excel_ nullptr; QAxObject* workbook_ nullptr; QString filePath_; };对应的实现文件// ExcelHelper.cpp bool ExcelHelper::open(const QString filePath) { // 创建 Excel 应用对象 excel_ new QAxObject(Excel.Application); if (excel_-isNull()) { qWarning() Excel.Application 启动失败请检查是否安装了 Office; return false; } excel_-setProperty(Visible, false); excel_-setProperty(DisplayAlerts, false); QAxObject* workbooks excel_-querySubObject(Workbooks); workbook_ workbooks-querySubObject(Open(const QString), filePath); if (workbook_ nullptr || workbook_-isNull()) { qWarning() 打开 Excel 文件失败: filePath; excel_-dynamicCall(Quit()); delete excel_; return false; } filePath_ filePath; return true; } bool ExcelHelper::writeCell(int sheetIndex, int row, int col, const QVariant value) { QAxObject* sheets workbook_-querySubObject(Worksheets); QAxObject* sheet sheets-querySubObject(Item(int), sheetIndex); QAxObject* cellA sheet-querySubObject(Cells(int,int), row, col); // 方式一用 setProperty cellA-setProperty(Value, value); // 或者方式二用 dynamicCall // cellA-dynamicCall(SetValue(const QVariant), value); delete cellA; delete sheet; delete sheets; return true; } QVariant ExcelHelper::readCell(int sheetIndex, int row, int col) const { QAxObject* sheets workbook_-querySubObject(Worksheets); QAxObject* sheet sheets-querySubObject(Item(int), sheetIndex); QAxObject* cellA sheet-querySubObject(Cells(int,int), row, col); QVariant result cellA-property(Value); delete cellA; delete sheet; delete sheets; return result; } bool ExcelHelper::saveAndClose() { workbook_-dynamicCall(Save()); workbook_-dynamicCall(Close()); excel_-dynamicCall(Quit()); delete workbook_; delete excel_; workbook_ nullptr; excel_ nullptr; return true; }这段代码踩过的坑不少我一条条说。第一每个querySubObject返回的对象都要手动 delete否则 Excel 进程不会被完全释放。即使调用了Quit()如果子对象有遗留任务管理器里还是能看到 EXCEL.EXE 在跑。这一点在长时间运行的软件里非常致命——内存和句柄会越积越多。第二DisplayAlerts属性要设为 false否则你保存文件的时候如果遇到同名文件覆盖这类情况Excel 会弹出模态对话框把你的程序卡死在那里。第三写大量数据的时候千万别逐单元格调用性能太差。正确做法是把数据塞进 QVariantList然后一次性赋值给一个 Range 对象// 批量写入的优化写法 QVariantList dataList; dataList QVariant(姓名) QVariant(分数) QVariant(92); // ... QAxObject* range sheet-querySubObject(Range(const QString), A1:C3); range-setProperty(Value, QVariant(dataList)); delete range;4.3 打通完整链路Excel 读取 - 训练 - 回写结果现在我把自己本地的实际链路捋一遍你就知道这些模块怎么串起来了。训练前用ExcelHelper::readCell把训练样本读入 QVector转成神经网络的输入矩阵和标签向量。因为 Excel 文件名和路径可能是中文连接字符串时要注意编码文件路径尽量用QString::fromLocal8Bit转换一下避免中文路径打不开文件。数据量不大的时候逐单元格读取没问题。但如果要在 Excel 和数据库同步数据建议用 Excel 内置的 SQL 查询或者先导出 CSV 再导入数据库这样能少掉一堆 COM 调用速度能快一个量级。训练完成后把每个 epoch 的损失写回一个新的 Sheet再把模型对测试集的预测结果逐行填入。这样客户打开这个 Excel既能看训练过程曲线又能对比预测值和真实值体验真的很不错。再配合前面说的频谱分析结论我甚至会把“是否存在周期震荡”这一判断直接当作一列写进 Excel 的报表总结里这样客户不用看图表也能知道模型训练得稳不稳。5. 常见问题与排查技巧实录5.1 环境和编译问题速查做这类项目最容易卡人的往往不是神经网络本身而是一开始的环境搭建。我把碰到的几个典型问题整理成了表格方便你对照排查报错/现象可能原因解决方法Qt 下载速度极慢官方源国外太慢用国内镜像比如将安装器替换为清华、阿里的镜像地址QCustomPlot 编译报 undefined reference缺少对 Qt 打印模块的依赖pro 文件加QT printsupport找不到QAxObject头文件axcontainer 模块未启用pro 文件加QT axcontainerKissFFT 编译正常但结果全为 0忘记初始化输入数组或者忘记 free 配置对象检查 kiss_fft_alloc 返回值检查输入数组是否有有效数据5.2 打包部署的老大难platform plugin 和 windeployqt程序开发调试没问题一发给同事就跑不起来了打开时报一个 This application failed to start because no Qt platform plugin could be initialized ——这个错我估计每个 Qt 开发者都见过。原因非常简单Qt 程序启动时需要加载平台插件在 Windows 下就是qwindows.dll这个 DLL 位于 Qt 安装目录的plugins/platforms/里。你用 windeployqt 打包时它虽然会自动把 plugins 目录拷贝到 exe 同级目录但如果你部署时把某些文件手动删了或者照网上教程只拷贝了那几个主要的 DLL就会缺这个插件。正确的打包步骤是1. 用 Release 模式编译工程 2. 将生成的 exe 放到一个单独目录例如 D:\release_app\ 3. 打开 Qt 自带的命令提示符对应编译器的那个 4. cd 到 exe 所在目录 5. 执行 windeployqt.exe 你的程序名.exe 6. 检查 platforms/qwindows.dll 是否存在windeployqt 不能识别每个第三方库比如 QCustomPlot 是纯源码集成不需要额外处理但如果你用了 OpenSSL 之内的就得手工拷贝 DLL。这里分享我的一个习惯部署完以后先把 exe 剪切到 C 盘根目录再运行一次能跑通再压缩发出去不然换个目录就崩的事故我遇到过不止一次。5.3 Excel 操作相关的典型坑Excel 这块的坑也很容易让人抓狂。第一个就是 Excel 进程残留问题我前面提到过比较隐蔽的是当你Quit()之后紧接着又创建新的Excel.Application偶尔会失败这是因为前一个进程还没完全释放。解决办法是退出的代码里加上线程休眠或者用 QProcess 去 kill 掉残留进程但不太优雅。更稳妥的做法是全局只维护一个 ExcelHelper 单例用完不退出下次继续复用反正DisplayAlerts(false)之后也不会弹窗打断流程。第二个坑是中文乱码。用 QAxObject 读 Excel 里的中文内容理论上 COM 内部用的 Unicode 不会乱但如果你走 CSV 方案读写就很容易在编码上翻车——Excel 另存的默认 CSV 是 ANSI 编码而 Qt 的QTextStream默认按 UTF-8 解码就会出现中文全变问号。处理办法是读 CSV 时手动指定编码检测 BOM如果是 UTF-8 就直接读否则按 GBK 用QTextCodec转码。第三个坑比较冷门但真实存在如果 Excel 文件里的数字带千分符比如1,234.56读出来会是文本而不是数字传给神经网络做归一化时就出问题。我遇到这情况后用了一个小工具函数把这个字符串里的逗号全去掉再转 double。5.4 训练不收敛的排查思路最后说个神经网络相关的排查技巧。如果训练时损失不下降先别急着怀疑代码写错了按这个顺序排查一是看输入数据的归一化。归一化不是“做不做”的问题而是“每列都要做”的问题。我一开始只对第一列做了归一化第二列数值范围超大结果训练出来模型基本只有第二列在起作用这种错误很难一眼看出来。二是确认反向传播时用的损失函数对输出层的梯度是否正确。可以直接用数值梯度验证把某个权重改动一个极小量看损失变化是否和反向传播算出来的一致。我写过一个小测试在训练前自动跑一遍确保梯度计算正确这个测试救了我很多次。三是看学习率和 batch size 是否匹配。学习率太大容易震荡太小收敛慢。我的建议是先用1e-2起步观察前几十个 epoch 的损失曲线如果震荡明显调到1e-3如果下降太慢调回1e-2。是不是有点调参炼丹的感觉确实是。至于串口数据采集、Qt 调用 Halcon 做图像识别这类扩展方向我这个工程的代码结构里已经预留了接口。比如把输入层从QVectordouble换成QImage预处理后的特征向量就能无缝接上图像分类场景。Qt 的生态有个好处就是它不像某些框架限制你只能在某个领域里打转串口、摄像头、数据库、Excel、网络请求统统都能在一个进程里搞定这也是我最终选择在这套技术栈上实现神经网络的原因。这套东西做完之后我最大的体会是不要把“用 C 写神经网络”想得多么高不可攀。你需要的只是矩阵乘法、链式法则和一点耐心Qt 负责把剩下所有东西变成用户能看得到、用得上的产品。最后再分享一个小技巧如果你打算长期维护这类 Qt 数值计算的项目建议把矩阵运算单独抽成一个静态库以后做滤波、信号处理、数据拟合的时候都能直接复用不用再重新造一遍轮子。本文还有配套的精品资源点击获取