简介:这份资源面向具备一定C++与Qt基础的开发者,聚焦如何用Qt框架落地人工神经网络,并打通Excel数据读写环节,解决空气质量预测类项目中模型集成与数据处理的衔接问题。压缩包共22个文件、约5.11MB,包含cpp与h源码、ui界面文件、pro工程配置、exe可执行程序及dll运行库,另有xls数据表与docx说明文档,覆盖从代码到可运行程序的完整链路。资源已有1227人学习下载,具备一定参考基础。读者可从中获得BP神经网络预测PM2.5的建模思路、网络结构设计与训练评估要点,了解Qt通过QAxContainer调用Excel读取输入、回写预测结果的做法,并借助说明文档与可执行程序对照理解前向传播、反向传播的实现与界面交互流程,适合作为课程设计或小型预测系统的整合范例。
1. 从一份“能跑起来的神经网络课设”说起
很多做 C++ 桌面开发的人,第一次接触人工神经网络,不是从 Python 的框架开始,而是被一个 Qt 项目逼出来的:界面要能画、数据要从 Excel 读、训练过程要实时显示,最后还得把预测结果写回表格。这个项目就是干这件事的——用 Qt 搭一个完整的 BP 神经网络演示程序,输入层、隐藏层、输出层可配置,训练数据从 Excel 导入,训练完成后对 PM2.5 这类时序或回归数据做预测。它适合两类人:一是想搞懂神经网络底层矩阵运算到底怎么写的 C++ 开发者,二是需要把算法嵌进桌面工具、又不想引入重型依赖的工程人员。下面按“能干什么、怎么跑、怎么改”的顺序拆开讲。
2. 项目骨架与构建:Qt 工程怎么组织、依赖怎么装
2.1 目录结构与模块划分
这个项目的代码量不大,但分层是清楚的。常见做法是把工程拆成三层:界面层、算法层、数据层。界面层负责主窗口、参数配置面板和训练曲线绘制;算法层是纯 C++ 的神经网络类,不依赖 Qt 的 GUI 模块,只用到QVector或标准容器;数据层封装 Excel 的读写,把表格里的数值转成训练用的样本矩阵。
一个典型的目录长这样:
ann-qt-demo/ ├── ann-qt-demo.pro # qmake 工程文件 ├── main.cpp # 程序入口 ├── ui/ │ ├── mainwindow.h/.cpp # 主窗口 │ └── mainwindow.ui # Qt Designer 界面 ├── core/ │ ├── neuralnetwork.h/.cpp # BP 网络核心 │ └── matrixutils.h/.cpp # 矩阵运算辅助 ├── io/ │ └── excelhandler.h/.cpp # Excel 读写封装 └── data/ └── pm25_sample.xlsx # 示例训练数据算法层和界面层解耦是关键。神经网络类只暴露train()、predict()、setTopology()这类接口,界面通过信号槽把训练进度回传,这样以后换界面或者把算法抽成静态库都不用动核心代码。.pro文件里需要链接的模块通常包括QT += core gui widgets,如果用到图表还要加charts。
2.2 依赖安装与工程配置
Excel 读写是这个项目最容易翻车的地方。Qt 本身不带 xlsx 解析能力,常见做法有两种:一是用QAxObject调 Windows 上的 Office COM 接口,二是引入第三方库如 QXlsx。前者不需要额外依赖,但只能在装了 Office 的 Windows 上跑;后者跨平台,但要把源码编进工程。
用 QXlsx 的话,.pro里这样配:
# ann-qt-demo.pro 关键片段 QT += core gui widgets # 引入 QXlsx 源码目录 include(QXlsx/QXlsx.pri) CONFIG += c++11 TARGET = ann-qt-demo SOURCES += main.cpp \ ui/mainwindow.cpp \ core/neuralnetwork.cpp \ io/excelhandler.cpp HEADERS += ui/mainwindow.h \ core/neuralnetwork.h \ io/excelhandler.hinclude(QXlsx/QXlsx.pri)这行把第三方库的编译规则拉进来,省得手动列一堆源文件。CONFIG += c++11是底线,神经网络里用到std::vector和 lambda 的话建议直接上 c++14。编译前确认 QXlsx 目录放在工程根下,路径别写绝对路径,否则换台机器就找不到。
提示:如果目标机器没有 Office,别用 QAxObject 方案,运行时会直接抛 COM 异常,而且报错信息很含糊,排查起来费时间。
2.3 第一次编译与运行验证
工程配好后,用 Qt Creator 打开.pro,选好 Kit 直接构建。第一次跑建议先用示例数据走一遍完整流程:点“加载数据”、设好网络结构、点“开始训练”,看损失曲线是否下降。如果界面能起来但训练没反应,先看控制台有没有样本数为 0 的警告,多半是 Excel 路径或表头解析的问题。
运行成功的标志是:训练日志里每个 epoch 打印一次均方误差,误差整体呈下降趋势,训练结束后在预测面板输入一组特征能返回一个合理范围内的数值。这一步跑通,后面改代码才有基准。
3. BP 网络核心实现:前向传播、反向传播与参数更新
3.1 网络结构定义与权重初始化
网络用层来描述,每层存一个权重矩阵和一个偏置向量。输入层节点数等于特征维度,输出层对回归任务就是 1 个节点,隐藏层数和每层节点数做成可配置。权重初始化不能全置零,否则同一层的神经元梯度完全一样,训练等于没训。常见做法是均匀随机初始化到[-1/sqrt(n), 1/sqrt(n)],n 是上一层节点数。
// core/neuralnetwork.h 结构定义片段 struct Layer { int inputSize; int outputSize; QVector<QVector<double>> weights; // [outputSize][inputSize] QVector<double> biases; // [outputSize] QVector<double> activations; // 本层输出缓存 }; class NeuralNetwork { public: void setTopology(const QVector<int>& sizes); // 如 {4, 8, 1} void train(const QVector<QVector<double>>& inputs, const QVector<double>& targets, int epochs, double lr); double predict(const QVector<double>& input); private: QVector<Layer> layers; double sigmoid(double x); double sigmoidDerivative(double y); // 传入已激活值 };setTopology接收一个整数数组,相邻两个数就是一层的前后维度。weights用二维QVector存,虽然不如裸数组快,但调试时能直接看每个值,对教学和二次开发友好。activations缓存每层输出,反向传播时不用重算。
3.2 前向传播与激活函数选择
前向传播就是逐层做矩阵乘加再套激活函数。隐藏层用 sigmoid 或 tanh,输出层对回归任务一般不加激活,或者用线性输出。sigmoid 的导数可以用输出值直接算:f'(x) = f(x)(1 - f(x)),所以缓存激活值能省一次指数运算。
// 前向传播:返回最终输出,同时填充各层 activations double NeuralNetwork::forward(const QVector<double>& input) { QVector<double> current = input; for (int l = 0; l < layers.size(); ++l) { Layer& layer = layers[l]; QVector<double> out(layer.outputSize); for (int i = 0; i < layer.outputSize; ++i) { double sum = layer.biases[i]; for (int j = 0; j < layer.inputSize; ++j) sum += layer.weights[i][j] * current[j]; // 隐藏层用 sigmoid,最后一层线性输出 out[i] = (l == layers.size() - 1) ? sum : sigmoid(sum); } layer.activations = out; current = out; } return current[0]; }内层循环是weights[i][j] * current[j],注意下标顺序别写反,权重矩阵是“输出维度 × 输入维度”。最后一层不加 sigmoid 是因为 PM2.5 浓度是连续值,加了会把输出压到 0 到 1 之间,预测值永远偏小,这是新手常踩的坑。
3.3 反向传播的梯度计算与权重更新
反向传播从输出层往回推,每层算一个误差项 delta,再用 delta 更新权重和偏置。输出层如果是线性,delta 就是预测减真实;隐藏层的 delta 是后一层 delta 乘权重再乘本层激活导数。
// 单样本反向传播与更新 void NeuralNetwork::backward(const QVector<double>& input, double target, double lr) { forward(input); // 先跑一遍拿到各层激活值 int L = layers.size(); QVector<QVector<double>> deltas(L); // 输出层误差(线性输出) deltas[L - 1].resize(layers[L - 1].outputSize); for (int i = 0; i < layers[L - 1].outputSize; ++i) deltas[L - 1][i] = layers[L - 1].activations[i] - target; // 隐藏层误差回传 for (int l = L - 2; l >= 0; --l) { deltas[l].resize(layers[l].outputSize); for (int i = 0; i < layers[l].outputSize; ++i) { double sum = 0.0; for (int k = 0; k < layers[l + 1].outputSize; ++k) sum += deltas[l + 1][k] * layers[l + 1].weights[k][i]; double a = layers[l].activations[i]; deltas[l][i] = sum * a * (1.0 - a); // sigmoid 导数 } } // 更新权重和偏置 QVector<double> prev = input; for (int l = 0; l < L; ++l) { for (int i = 0; i < layers[l].outputSize; ++i) { for (int j = 0; j < layers[l].inputSize; ++j) layers[l].weights[i][j] -= lr * deltas[l][i] * prev[j]; layers[l].biases[i] -= lr * deltas[l][i]; } prev = layers[l].activations; } }lr是学习率,太大损失会震荡甚至发散,太小收敛慢。这个项目里建议从 0.1 起步,观察损失曲线再调。prev在每层更新后切换成该层激活值,因为下一层的输入就是上一层的输出,这个变量名容易写错,血泪经验是更新前先确认prev的维度等于layers[l].inputSize。
3.4 训练循环与损失监控
训练循环把样本逐个喂进去,每个 epoch 算一次平均损失并通过信号发给界面。批量更新(一次算完所有样本再更新)比逐样本更新稳定,但代码稍复杂,这个项目用逐样本更新足够演示。
void NeuralNetwork::train(const QVector<QVector<double>>& inputs, const QVector<double>& targets, int epochs, double lr) { for (int e = 0; e < epochs; ++e) { double loss = 0.0; for (int s = 0; s < inputs.size(); ++s) { backward(inputs[s], targets[s], lr); double pred = forward(inputs[s]); loss += 0.5 * (pred - targets[s]) * (pred - targets[s]); } loss /= inputs.size(); emit epochFinished(e, loss); // 界面据此画曲线 } }emit epochFinished要求NeuralNetwork继承QObject,这样界面才能实时刷新。损失用 0.5 乘平方误差,求导后正好是pred - target,和上面backward里的 delta 对得上。如果损失一直不降,先检查数据有没有归一化,PM2.5 原始值动辄上百,不归一化梯度会爆。
4. Excel 数据链路:导入、清洗与结果回写
4.1 用 QXlsx 读取训练样本
Excel 里的数据一般是一行一个样本,前几列是特征,最后一列是目标值。读取时先定位表头行,再逐行取值。QXlsx 的接口是read(row, col),行列都从 1 开始,这点和 C++ 数组习惯不同,容易差一位。
// io/excelhandler.cpp 读取片段 QVector<QVector<double>> ExcelHandler::readFeatures(const QString& path, int featureCount) { QXlsx::Document doc(path); QVector<QVector<double>> samples; int row = 2; // 跳过表头 while (!doc.read(row, 1).isNull()) { QVector<double> feat; for (int c = 1; c <= featureCount; ++c) feat.append(doc.read(row, c).toDouble()); samples.append(feat); ++row; } return samples; }doc.read(row, 1).isNull()用来判断行尾,比固定行数灵活。toDouble()对空单元格返回 0,所以读之前最好确认数据列没有空行,否则会混进一堆零样本,训练出来的模型偏差很大。
4.2 数据归一化与样本划分
PM2.5 预测的特征可能包括温度、湿度、风速、前一时刻浓度,量纲差异大,必须归一化到 0 到 1 或标准化到均值 0 方差 1。常见做法是 min-max 归一化,训练完预测时再用同一组 min/max 反变换回去。
// 归一化:返回缩放参数供反变换使用 struct ScaleParam { double minVal; double maxVal; }; QVector<double> normalize(const QVector<double>& col, ScaleParam& p) { p.minVal = *std::min_element(col.begin(), col.end()); p.maxVal = *std::max_element(col.begin(), col.end()); QVector<double> out; double range = p.maxVal - p.minVal; for (double v : col) out.append(range > 1e-8 ? (v - p.minVal) / range : 0.0); return out; }range > 1e-8是防除零,某列如果全是同一个值,归一化后全变 0,这列特征等于废了,训练前最好先扫一遍方差。样本划分按时间顺序切,前 80% 训练、后 20% 验证,别随机打乱,时序数据随机切会引入未来信息,验证指标虚高。
4.3 预测结果写回 Excel
训练完把预测值写回表格,方便和真实值对比。QXlsx 写单元格用write(row, col, value),最后save()落盘。写之前先复制一份原文件,别直接覆盖原始数据,这是后悔药。
void ExcelHandler::writePredictions(const QString& src, const QString& dst, const QVector<double>& preds) { QXlsx::Document doc(src); int col = doc.dimension().columnCount() + 1; doc.write(1, col, "Predicted"); for (int i = 0; i < preds.size(); ++i) doc.write(i + 2, col, preds[i]); doc.saveAs(dst); }dimension().columnCount()拿到现有列数,预测列追加在最后,不破坏原表结构。saveAs写到新文件,原文件保持只读,出问题还能回退。
5. 避坑与排查:这类 Qt + 神经网络项目最容易翻车的地方
5.1 训练损失不下降甚至变成 NaN
现象是日志里损失一路涨或者直接打印 nan。原因通常是学习率过大、数据没归一化,或者权重初始化范围太大导致 sigmoid 饱和。解决顺序:先把学习率降到 0.01 试一轮,再检查输入是否落在合理区间,最后把初始化范围收紧到±0.5。如果还是 nan,在backward里打印每层 delta 的最大绝对值,定位是哪一层先爆的。
5.2 Excel 读取返回全零或样本数为 0
现象是界面显示加载成功但训练没数据。原因多半是表头行判断错、列号从 0 开始写,或者文件路径含中文导致 QXlsx 打开失败。解决:先用doc.dimension()打印实际行列范围,确认数据从第几行第几列开始;路径统一用QDir::toNativeSeparators转换;读单元格时加isNull判断并跳过空行。
5.3 界面训练时卡死无响应
现象是点了训练按钮窗口变灰、进度条不动。原因是训练循环跑在主线程,阻塞了事件循环。解决:把NeuralNetwork移到QThread里,用信号槽跨线程传进度;或者至少在训练循环里周期性调用QCoreApplication::processEvents(),但后者只是权宜之计,样本一多还是会卡。
5.4 换台机器编译报找不到 QXlsx 头文件
现象是本地能编,拷到另一台机器提示QXlsx/xlsxdocument.h: No such file。原因是.pro里用了绝对路径或者 QXlsx 目录没一起拷过去。解决:把 QXlsx 作为子目录放进工程,.pro里用相对路径include(QXlsx/QXlsx.pri),并在版本控制里把整个目录提交上去,别只提交.pri。
5.5 预测值全部挤在同一个数附近
现象是不同输入预测出来几乎一样。原因是输出层加了 sigmoid 但目标值没归一化到 0 到 1,或者隐藏层节点太少导致网络表达能力不足。解决:回归任务输出层改线性,目标值单独做归一化并在预测后反变换;隐藏层节点数至少设为输入维度的 1.5 倍再试。
6. 二次开发:把演示程序改成能用的预测工具
拿到这个项目后,最值得先动的是数据链路,而不是网络结构。我的习惯是先把ExcelHandler抽成一个接口类,定义load()、save()两个纯虚函数,然后分别实现 QXlsx 版和 CSV 版。这样换数据源不用碰算法层,测试时也能用内存里的假数据跑单元测试。下面是一个最小改造示例:
// io/dataSource.h 抽象接口 class DataSource { public: virtual ~DataSource() = default; virtual QVector<QVector<double>> load(const QString& path) = 0; virtual void save(const QString& path, const QVector<double>& preds) = 0; }; // 使用时通过工厂切换实现 std::unique_ptr<DataSource> src = createSource("xlsx"); auto samples = src->load("data/pm25_sample.xlsx");接口定好后,网络结构参数建议从硬编码改成配置文件读取,用QSettings存隐藏层节点数、学习率、训练轮数,界面上做成输入框,改参数不用重编译。再进一步,把训练和预测拆成两个按钮、两个线程,训练时界面还能响应停止操作,用QAtomicInt做停止标志,训练循环每轮检查一次。
验证改造是否成功,别只看界面能不能点。我的做法是固定一份小样本(比如 20 条),记录改造前后的损失曲线和预测值,数值差异在 1e-6 以内才算没改坏逻辑。另外每次改完backward里的下标,都拿一个 2-2-1 的极小网络手算一遍梯度对一遍,这个习惯帮我省过好几次通宵排查。从那以后我每次动矩阵下标相关的代码,都强制先跑这个小网络对拍,再上真实数据。希望帮到你。
本文还有配套的精品资源,点击获取