ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt手写人工神经网络与Excel数据对接:从模型训练到工业落地

Qt手写人工神经网络与Excel数据对接:从模型训练到工业落地

简介:内容涵盖Qt框架下人工神经网络(BP算法)的完整实现,以及通过QAxContainer调用Excel进行数据读写的工程示例,面向希望在C++/Qt环境中落地机器学习预测功能的开发者与学习者。资源共22个文件,压缩包5.11MB;源码部分以cpp/h/pro/ui为主,含mainwindow、excelengine等核心模块,另有3份docx文档详解BP网络建模思路、PM2.5预测案例与模型建模软件使用说明,配套exe和Qt4动态库,可快速运行与对照学习。核心场景围绕城市PM2.5浓度预测:从网络结构设计、训练参数调整到结果评估,均有文档与源码相互印证;xls电子表格作为神经网络的输入输出载体,配合excelengine模块实现数据导入、预测结果写回,串联起完整应用闭环。已有1227人学习本资源。通过源码阅读、工程编译与文档解析,可掌握Qt框架内构建人工神经网络、调用Excel读写数据以及可视化预测结果的完整路径。

1. Qt + 人工神经网络 + Excel:这套组合解决什么实际问题

做上位机的人迟早会撞上一个需求:客户丢来一份Excel,里面躺着几万行传感器历史数据,要你在桌面上跑一个预测模型,还明确要求不能上云、不给装Python环境。这时候用Qt手写一个人工神经网络,再让Qt直接读Excel,就成了最不绕路的落地方案。这套组合在工业质检、设备预测性维护、实验室曲线拟合里出现频率很高,核心价值是把「数据导入→模型训练→结果回写」全部收在同一个客户端进程里。下面把网络实现、Excel对接、联调踩坑三个部分逐一拆开。

2. 在Qt里手写人工神经网络:矩阵、激活函数与反向传播

2.1 为什么手写MLP而不调框架

TensorFlow、PyTorch在自己电脑上跑demo很顺手,一旦要交付给客户就变了味。目标机器上大概率没有Python,用pyinstaller打包出来的依赖动辄几百MB;工控机CPU可能是赛扬甚至ARM,GPU框架根本装不上去。Qt本身是C++生态,手写一个多层感知机(MLP)编译后就是几十KB代码,无第三方依赖,部署的时候扔一个exe加几个dll就完事。另一个实际原因是,很多上位机项目离线运行,不允许运行时去拉模型库或者检查更新。

手写网络的代价是,反向传播的坑要自己一个人趟。但这类项目里网络规模通常很小,几层全连接、几百个参数,不像CV模型那样动辄几十万参数,手写完全可控。而且手写之后所有权重数据都在自己的结构体里,方便后续序列化到文件或者存回Excel,不会被框架的黑匣子挡住。

2.2 网络结构定义与初始化

先定义一个层结构体,把权重、偏置、输出缓存、误差缓存全部放一起。这里用QVector而不是std::vector,主要考虑要和Qt的容器迭代器、隐式共享机制配合,排错时在Qt Creator里查看数据也更直观。

struct Layer { QVector<QVector<double>> weights; // weights[i][j]: 第i个输入到第j个输出 QVector<double> biases; // 每个输出节点一个偏置 QVector<double> outputs; // 前向传播后缓存本层输出 QVector<double> deltas; // 反向传播时缓存误差项 int actType = 0; // 0=sigmoid 1=tanh 2=relu };

一个MLP就是一组这样的层,构造时传入各层神经元数。注意第一层的输入个数是特征维数,最后一层的输出个数是标签维数。隐藏层数量和神经元个数没有绝对标准,先按经验值搭,训练完对比验证集误差再调。

class MLP { public: MLP() = default; explicit MLP(const QVector<int>& sizes); QVector<double> forward(const QVector<double>& input); void trainSample(const QVector<double>& input, const QVector<double>& target, double lr); double mse(const QVector<double>& output, const QVector<double>& target) const; bool save(const QString& path) const; bool load(const QString& path); private: double activate(double x, int type) const; double activateDerivative(double y, int type) const; QVector<Layer> layers_; };

构造函数里用均匀分布初始化权重。这里不需要复杂的He初始化,因为项目数据量小,对初始化方式不敏感;但注意如果用了ReLU激活,建议权重方差取2/输入节点数,防止深层梯度消失。

MLP::MLP(const QVector<int>& sizes) { Q_ASSERT(sizes.size() >= 2); for (int l = 1; l < sizes.size(); ++l) { Layer layer; int inCount = sizes[l - 1]; int outCount = sizes[l]; layer.weights.resize(inCount); for (int i = 0; i < inCount; ++i) { layer.weights[i].resize(outCount); for (int j = 0; j < outCount; ++j) { layer.weights[i][j] = 0.01 * (rand() / (double)RAND_MAX - 0.5); } } layer.biases.fill(0.0, outCount); layer.actType = (l == sizes.size() - 1) ? 1 : 2; // 输出层tanh,隐藏层relu layers_.append(layer); } }

2.3 前向传播:从输入到输出的计算路径

前向传播的本质是逐层做两个动作:线性加权求和,然后过激活函数。代码里把每一层的输出缓存下来,因为反向传播要复用这些中间结果,这是空间换时间的常见做法。

QVector<double> MLP::forward(const QVector<double>& input) { QVector<double> current = input; for (int l = 0; l < layers_.size(); ++l) { Layer& layer = layers_[l]; QVector<double> next(layer.biases.size(), 0.0); for (int j = 0; j < layer.biases.size(); ++j) { double sum = layer.biases[j]; for (int i = 0; i < current.size(); ++i) { sum += current[i] * layer.weights[i][j]; } next[j] = activate(sum, layer.actType); } layer.outputs = next; current = next; } return current; }

注意weights[i][j]的下标顺序,写成「输入i到输出j」之后,反向传播更新时不用转置,直接拿对应元素累加梯度。如果写反了,训练时梯度方向会乱,表现就是loss反复横跳不收敛,这是手写网络最典型的翻车姿势,排查时先检查这里。

激活函数用三个最常用的。sigmoid适合输出层做二分类概率,tanh适合回归任务的输出层,ReLU给隐藏层防止梯度饱和。不要全用sigmoid,否则三层以上网络训练速度会明显变慢。

double MLP::activate(double x, int type) const { switch (type) { case 0: return 1.0 / (1.0 + qExp(-x)); // sigmoid case 1: return qTanh(x); // tanh case 2: return x > 0.0 ? x : 0.0; // relu } return x; }

2.4 反向传播与权重更新:误差如何逐层回传

反向传播是整个手写网络里最容易出错的部分。思路是求损失函数对每个权重的偏导,然后沿负梯度方向调整。输出层的误差是「预测值减真实值再乘激活函数的导数」,从输出层往输入层方向逐层回传。

void MLP::trainSample(const QVector<double>& input, const QVector<double>& target, double lr) { forward(input); // 顺带把每层的outputs缓存好 int L = layers_.size(); // 输出层误差项: // delta_j = (target_j - output_j) * d_activation(output_j) Layer& outLayer = layers_[L - 1]; outLayer.deltas.resize(outLayer.outputs.size()); for (int j = 0; j < outLayer.outputs.size(); ++j) { double diff = target[j] - outLayer.outputs[j]; double deriv = activateDerivative(outLayer.outputs[j], outLayer.actType); outLayer.deltas[j] = diff * deriv; } // 隐藏层误差项: // delta_i = sum_j(delta_j * weights_ij) * d_activation(output_i) for (int l = L - 2; l >= 0; --l) { Layer& layer = layers_[l]; Layer& next = layers_[l + 1]; layer.deltas.resize(layer.outputs.size()); for (int i = 0; i < layer.outputs.size(); ++i) { double sum = 0.0; for (int j = 0; j < next.deltas.size(); ++j) { sum += next.deltas[j] * next.weights[i][j]; } double deriv = activateDerivative(layer.outputs[i], layer.actType); layer.deltas[i] = sum * deriv; } } // 权重更新:W_ij += lr * delta_j * input_i QVector<double> layerInput = input; for (int l = 0; l < L; ++l) { Layer& layer = layers_[l]; for (int j = 0; j < layer.deltas.size(); ++j) { for (int i = 0; i < layerInput.size(); ++i) { layer.weights[i][j] += lr * layer.deltas[j] * layerInput[i]; } layer.biases[j] += lr * layer.deltas[j]; } layerInput = layer.outputs; // 下一层的输入就是本层输出 } }

激活函数导数是反向传播里的关键参数:sigmoid的导数是y*(1-y),tanh的导数是1-y*y,ReLU的导数是y>0?1:0。注意传进去的是激活后的输出y而不是加权和x,用y算导数写法更简洁;只有ReLU需要判断输出是否大于0,其它函数直接用y算即可。

double MLP::activateDerivative(double y, int type) const { switch (type) { case 0: return y * (1.0 - y); case 1: return 1.0 - y * y; case 2: return y > 0.0 ? 1.0 : 0.0; } return 1.0; }

训练时把样本循环丢进trainSample就行。学习率lr从0.01起步,loss震荡就调小到0.001,下降太慢就调大到0.05。这里有一个血泪经验:手写网络不收敛,先别急着改结构,把学习率降一个数量级试试,多半是lr太大导致梯度在极小值附近来回跳。

3. Qt调用Excel的三种路线:QAxObject、CSV与SQLite的选型落地

3.1 选型先搞清楚:什么场景走哪条路

Qt读Excel有三条主流路线,选错路后面全是坑。第一种是用QAxObject走COM接口直接操作Excel,适合客户端机器装了Office、数据量在几万行以内、需要读写原始格式的场景。第二种是把Excel另存为CSV,用QTextStream直接解析,适合数据量大、格式简单、不要求保留样式的场景。第三种是用SQLite当中间层,先用工具把Excel导成db文件,Qt里走SQL查询,适合数据要反复筛选聚合、多次跨表关联的场景。

路线适用场景优点缺点
QAxObject客户有Office,需读写格式原样读写格式依赖COM,进程易残留
CSV桥接数据量大、格式固定快,无外部依赖丢样式,需先转CSV
SQLite中介反复查询、跨表关联查询能力强多一道导入工序

日常项目里CSV桥接用得最多,很多客户本身就有把Excel转CSV的习惯。但如果你要做成产品,不期望用户手动操作,QAxObject仍然是绕不开的技能,因为自动转换还得靠它。下面把两条主路线代码都过一遍。

3.2 QAxObject操作Excel:打开、读取、写入、关闭

COM这条路的关键是记住一条原则:凡是用户能看到的Excel对象,都要拿到一个QAxObject再操作。最顶层是Excel.Application,往下依次是Workbooks、Workbook、Worksheets、Sheet、Range,一层层querySubObject下去。每一层名字拼错都会在运行时报奇怪的COM错误,排查时从下往上打印每个对象的name()属性最有效率。

#include <QAxObject> QAxObject* excel = new QAxObject("Excel.Application", this); excel->setProperty("Visible", false); // 后台运行,不弹窗口 excel->setProperty("DisplayAlerts", false); // 关闭保存确认弹窗 QAxObject* workbooks = excel->querySubObject("Workbooks"); QAxObject* workbook = workbooks->querySubObject( "Open(const QString&)", QString("D:/data/train.xlsx")); QAxObject* sheets = workbook->querySubObject("Worksheets"); QAxObject* sheet = sheets->querySubObject("Item(int)", 1); // 读取A1:C100区域,Value返回QVariant,实际是二维数组 QAxObject* range = sheet->querySubObject("Range(const QString&)", "A1:C100"); QVariant value = range->property("Value");

读取出来的QVariant要小心拆。Excel的COM接口返回的二维区域在Qt里是QVariantList套QVariantList,每一行是子列表。拆数据时先判断类型,再逐行转double,不要直接toDouble,否则带文本格式的单元格会静默变成0。

写入Excel同样走Range。注意构造二维QVariantList的行列顺序,行是外层列表,列是内层列表,和Excel的排列方向要完全对齐。

// 把训练结过写回E1:F10 QAxObject* outRange = sheet->querySubObject("Range(const QString&)", "E1:F10"); QVariantList rows; for (int r = 0; r < 10; ++r) { QVariantList row; row << QVariant(theta[r]) << QVariant(mseList[r]); rows << QVariant(row); } outRange->setProperty("Value", rows); // 关闭保存:Close的第一个参数表示是否保存修改 workbook->dynamicCall("Close(bool)", true); excel->dynamicCall("Quit()"); delete excel;

Close之后一定要调用Quit,并且delete掉QAxObject。这一点和「vb关闭excel文件」踩的坑完全一样,COM对象不释放,Excel进程就一直挂在后台。不要以为setProperty("Visible", false)就彻底隐藏了,进程照样跑,任务管理器一打开能看到好几条EXCEL.EXE。

3.3 数据量大时别硬读:CSV + QTableView自定义Model

Excel文件超过五万行时,逐单元格用COM读取会慢到让人怀疑人生,因为每一次Range取值都是一次跨进程调用。常见做法是让用户先把Excel另存为CSV,Qt这边用QTextStream按行读,速度提升一个数量级。如果不想让用户手动转,可以在程序里用QAxObject打开Excel后执行一次SaveAs,把.xlsx转成.csv再关掉,两全其美。

QFile file("D:/data/train.csv"); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() << "open csv failed"; return; } QTextStream in(&file); in.setCodec("UTF-8"); // 跳过表头 QString header = in.readLine(); QVector<QVector<double>> data; while (!in.atEnd()) { QString line = in.readLine(); const QStringList fields = line.split(','); QVector<double> row; bool ok = false; for (const QString& f : fields) { double v = f.trimmed().toDouble(&ok); if (!ok) break; row.append(v); } if (row.size() == fields.size()) { data.append(row); } } file.close();

CSV方案没有COM进程问题,也不会弹Excel界面,缺点是用户必须接受「数据已导入」这个交互。要注意Windows版Excel另存的CSV默认可能是ANSI编码,用QTextStream读时要么显式setCodec("GBK"),要么检测是否有UTF-8的Byte Order Mark再决定用哪种编码。

数据到了Qt这边,展示层还有一个经典优化点:用QTableView配一个只读的自定义QAbstractTableModel,替代默认的QTableWidget。QTableWidget每个单元格都是一个Item对象,几万行下来光创建Item就卡到界面发白;QTableView加自定义Model只在视图可见范围内按需取数,这就是表格大数据卡顿优化里反复被提到的「从tableWidget到QTableView + 自定义model」套路。

class DataFrameModel : public QAbstractTableModel { Q_OBJECT public: void setData(const QVector<QVector<double>>& rows) { beginResetModel(); data_ = rows; endResetModel(); } int rowCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : data_.size(); } int columnCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : (data_.isEmpty() ? 0 : data_[0].size()); } QVariant data(const QModelIndex& index, int role) const override { if (!index.isValid() || role != Qt::DisplayRole) return QVariant(); return QVariant(data_[index.row()][index.column()]); } private: QVector<QVector<double>> data_; };

这个Model只维护一份二维数组,视口滚动时Qt会自动按需调用data()取可见区域的数据,内存占用和渲染开销都小很多。配上setModel之后,几万行数据也能流畅滚动。

4. Qt + Excel + 神经网络联调:五个高频踩坑记录

网络和Excel读取各自能跑通之后,拼到一起才是真正的考验。下面五条是这条技术路线上反复出现的坑,每一条都按「现象→原因→解决」写清楚,很多是典型到值得直接写进团队新人手册的问题。

4.1 Excel进程残留:任务管理器里一堆EXCEL.EXE

现象:程序跑几次后,任务管理器里的EXCEL.EXE越积越多,后面再打开Excel文件时提示被占用,读取速度也越来越慢。

原因:QAxObject操作完Excel后,Close、Quit、delete的顺序不对,或者中间某一步抛异常直接跳过了释放逻辑。COM对象没被正确释放,Excel进程不会自己退出。

解决:把Excel操作封装成一个RAII类,析构函数里统一做释放。不管中间代码是否异常,析构一定执行。释放顺序是:先Close工作簿,再Quit应用,最后delete QAxObject。

class ExcelGuard { public: ExcelGuard() : excel_(nullptr) { excel_ = new QAxObject("Excel.Application"); excel_->setProperty("Visible", false); } ~ExcelGuard() { if (excel_) { excel_->dynamicCall("Quit()"); delete excel_; } } QAxObject* get() { return excel_; } private: QAxObject* excel_; };

进程清理之后还有一个连带好处:程序退出速度变快了。之前退出时要等COM超时,现在秒退,用户感知明显。

4.2 Range.Value返回的类型混乱:数字、字符串、时间混在一起

现象:从Excel读出来的数据用toDouble()转出来全是0,或者某些行解析失败,训练出来的模型精度莫名其妙地差。

原因:Excel单元格格式不统一。有些列是文本格式,有些列混入空单元格,有些列存的是日期序列号,COM接口返回的QVariant类型五花八门。用一个toDouble()通吃,遇到非数值类型就返回0,数据被静默篡改,模型当然训不好。

解决:写一个专用转换函数,先看QVariant的type再决定怎么转。空单元格跳过不处理,文本类型先toString再toDouble,注意带千分位逗号的情况要先去逗号,日期类型统一转成时间戳数值。

bool toDoubleVariant(const QVariant& v, double& out) { switch (v.type()) { case QVariant::Double: case QVariant::Int: case QVariant::LongLong: out = v.toDouble(); return true; case QVariant::String: { QString s = v.toString().trimmed(); if (s.isEmpty()) return false; s.remove(QLatin1Char(',')); bool ok = false; out = s.toDouble(&ok); return ok; } default: return false; } }

这个函数看着简单,却是训练数据正确性的最后一道防线。数据清洗阶段多花十分钟写它,后面调模型能省下几天时间。数据错了模型再玄学也救不回来。

4.3 界面假死:训练循环堵死了GUI线程

现象:点击「开始训练」按钮后,窗口立刻无响应,鼠标拖动窗口没有动画,标题栏出现「未响应」。训练小数据集还能接受,数据量一大整个程序看起来像崩了。

原因:trainSample的循环直接跑在GUI线程里,Qt事件循环被占用,界面更新消息全在排队等训练结束。程序没崩,但对用户来说这就是崩溃体验。

解决:把训练循环放到QThread里,用moveToThread把训练对象迁移到工作线程,训练过程中通过信号把当前epoch的loss发回GUI线程刷新曲线。训练结束后再发finished信号通知UI恢复按钮。

class TrainerWorker : public QObject { Q_OBJECT public: explicit TrainerWorker(MLP* net) : net_(net) {} public slots: void start(const QVector<QVector<double>>& inputs, const QVector<QVector<double>>& labels) { for (int epoch = 0; epoch < 200; ++epoch) { double totalLoss = 0.0; for (int s = 0; s < inputs.size(); ++s) { net_->trainSample(inputs[s], labels[s], 0.01); totalLoss += net_->mse(net_->forward(inputs[s]), labels[s]); } emit epochFinished(epoch, totalLoss / inputs.size()); } emit trainingFinished(); } signals: void epochFinished(int epoch, double loss); void trainingFinished(); private: MLP* net_; };

主线程里创建worker后调用moveToThread,用信号触发start槽而不是直接调用方法。关键点是worker的parent设为nullptr,训练结束时用deleteLater清理,避免线程退出后对象悬空。

4.4 中文路径和编码:COM接口传路径的坑

现象:Excel文件放在D:/数据/训练集/目录下,QAxObject的Open(path)打不开,提示找不到文件;换成英文路径就一切正常。

原因:COM接口接收的字符串是BSTR,Qt的QString经querySubObject传参时编码转换不完整,中文路径在传递过程中出现乱码。

解决:先把QString转成QDir::toNativeSeparators处理过的路径,再传给Open。CSV方案读取时同样注意编码,Windows版Excel另存的CSV默认是ANSI,QTextStream要显式setCodec。

QString path = QDir::toNativeSeparators("D:/数据/训练集/train.xlsx"); // path -> "D:\数据\训练集\train.xlsx" workbook = workbooks->querySubObject("Open(const QString&)", path);

Windows上这个坑基本必然出现一遍,写代码时直接统一先把路径过一遍toNativeSeparators,不要等客户报bug再修。客户端路径里的空格、括号也可能带来类似问题,最好都过一遍处理。

4.5 换机器运行闪退:找不到Qt平台插件

现象:程序在自己电脑上跑得好好的,用windeployqt打包后拷到客户机器,双击闪退,日志里报qt.qpa.plugin: could not find the Qt platform plugin "windows" in ""。

原因:Qt程序依赖platforms目录下的qwindows.dll等插件,部署时只拷贝exe和Qt核心dll,漏掉了整个platforms目录。加上训练和Excel操作都涉及内存分配,插件缺失的报错往往被埋在后面。

解决:用windeployqt完整部署,并把platforms目录一起放进安装包。打好的包在干净虚拟机里跑一遍确认启动无报错再发版。

// 在main里显式指定插件目录,方便排查 QCoreApplication::setLibraryPaths( QStringList() << QDir::current().absoluteFilePath("platforms"));

这个坑其实和神经网络、Excel都没关系,但它专坑Qt项目,而且往往在交付验收那几天爆发。打包脚本里把「拷贝platforms目录」写成固定步骤,不要手动拖拽。

5. 完整闭环:从Excel数据到训练结果回写

前面四章把三个部件都讲明白了,最后把它们串成一个能用的闭环。数据流是这样走的:CSV或COM读取原始Excel数据,然后做数据清洗与归一化,切分训练集验证集,TrainerWorker线程训练,保存模型参数,最后把预测结果和新数据回写Excel。

最容易被忽略的是归一化参数的保存。训练时用min-max归一化把特征压到0到1区间,预测时还要用同一组min和max还原,否则模型输出永远是归一化尺度的结果。常见做法是把min、max和网络权重一起写进同一个文件,之后加载模型时一并读取。如果这几个参数只在内存里留着,软件重启之后再预测新数据,结果就全错了。

bool MLP::save(const QString& path) const { QFile f(path); if (!f.open(QIODevice::WriteOnly)) return false; QDataStream out(&f); out << layers_.size(); for (const Layer& layer : layers_) { out << layer.weights << layer.biases << layer.actType; } return true; } bool MLP::load(const QString& path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return false; QDataStream in(&f); int count = 0; in >> count; layers_.clear(); for (int i = 0; i < count; ++i) { Layer layer; in >> layer.weights >> layer.biases >> layer.actType; layers_.append(layer); } return true; }

验证模型有没有训好,我的习惯是留10%到20%的样本不参与训练,训练完用这部分数据算一下MSE,和训练集MSE对比。验证集MSE远高于训练集MSE就是过拟合,降低隐藏层神经元数、加一点L2正则或者提前停止都是手段。正常的误差曲线应该是平滑下降后趋于稳定,呈锯齿状震荡就先检查学习率。

最后补一句经验之谈:把训练好的模型参数和归一化参数放到用户数据目录而不是exe目录,因为exe目录在安装后会变成只读,升级程序时数据也会被覆盖。之前把模型文件和exe放在一起,客户一升级全没了的教训,不想再来第二次。这套方案我做了三个项目,现在已经能很从容地迁移到新的Excel数据源上。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进