☰
Qt纯C++手搓BP神经网络:不依赖Python实现Excel数据训练与预测
2026/10/14 13:08:58 网站建设 项目流程

简介:这份资源面向希望将人工神经网络落地到桌面应用的开发者与学习者,围绕Qt框架实现BP神经网络并完成对PM2.5浓度的预测建模,同时演示Qt通过QAxContainer调用Excel进行数据读写。包内共22个文件,以cpp与h源码、obj中间文件、docx说明文档为主,另含pro工程文件、ui界面文件、xls数据表、dll动态库与exe可执行程序,压缩包约5.11MB,可直接运行体验或基于源码二次开发。三份docx分别讲解建模软件使用、BP网络在PM2.5中的实现流程以及该方法在城市空气质量预测上的优势,涵盖数据预处理、网络结构设计、训练与结果评估等环节。已有1227人学习下载,适合具备一定C++与Qt基础、想打通界面、算法与数据处理链路的读者参考,可据此理解前向与反向传播的代码组织方式及Excel交互思路。

1. 从一份 Qt 神经网络工程说起:为什么有人用 C++ 手搓 ANN 而不是调 Python

前阵子有个做环境监测设备上位机的朋友找我,说他们想在一台工控机上跑 PM2.5 浓度预测,数据来自传感器串口,界面用 Qt 写,但不想在目标机器上装 Python 环境,问我有没有办法把人工神经网络直接嵌进 Qt 工程里。这个需求其实挺典型的:工业现场、嵌入式触屏、离线部署,Python 那套依赖链在客户现场就是玄学,装个 numpy 都能因为 glibc 版本翻车。于是就有了这份「Qt 实现人工神经网络 + Qt 调用 Excel」的资源——它用纯 C++ 在 Qt 框架下实现了一个可训练、可推理的 BP 神经网络,并且通过 QAxObject 读写 Excel 文件,把训练数据和预测结果落地成表格。整套东西不依赖任何第三方数学库,编译出来就是一个 exe,拷到机器上双击就能跑。适合谁?做上位机、工控 HMI、嵌入式数据采集的 C++ 开发者,尤其是那些被「Python 部署」折磨过、想找一个能塞进现有 Qt 工程里的轻量 ANN 方案的人。下面我按「这东西怎么跑起来 → 网络怎么搭 → Excel 怎么读写 → 哪里容易翻车 → 怎么验证效果」的顺序,把这份资源拆开讲一遍。

2. 把工程跑起来:Qt 版本选择、pro 文件配置与第一个训练窗口

2.1 环境准备与 Qt 版本的血泪经验

这份资源的核心是 C++ 和 Qt Widgets,不涉及 QML,所以 Qt 5.9 到 Qt 6.x 理论上都能编。但我实际试下来,Qt 6 对 QAxObject 的支持有变化,如果你要用 Excel 读写那部分,建议锁在 Qt 5.15.x 或者 Qt 5.12 LTS。安装的时候记得勾选 MSVC 或 MinGW 对应的套件,另外 QAxObject 属于 Qt ActiveQt 模块,Windows 下默认是带的,但如果你装的是开源版,需要在安装器里手动勾上「Qt ActiveQt」这个组件,否则编译时会报QAxObject: No such file or directory。

我一般会先建一个空的 Qt Widgets Application,然后把资源里的源文件按目录结构拷进去。典型的目录长这样:

QtANN/ ├── QtANN.pro ├── main.cpp ├── mainwindow.cpp ├── mainwindow.h ├── mainwindow.ui ├── ann/ │ ├── neuralnetwork.cpp │ ├── neuralnetwork.h │ ├── layer.cpp │ └── layer.h └── excel/ ├── excelengine.cpp └── excelengine.h

.pro文件里需要加的东西不多,但少一行都编不过:

QT += core gui widgets QT += axcontainer CONFIG += c++11 TARGET = QtANN TEMPLATE = app SOURCES += main.cpp \ mainwindow.cpp \ ann/neuralnetwork.cpp \ ann/layer.cpp \ excel/excelengine.cpp HEADERS += mainwindow.h \ ann/neuralnetwork.h \ ann/layer.h \ excel/excelengine.h FORMS += mainwindow.ui

这里QT += axcontainer是 Excel 读写的关键,没有它 QAxObject 用不了。CONFIG += c++11是因为网络里用了std::vector和std::function,虽然 Qt 5.15 默认支持 C++17,但显式写上避免某些老编译器出问题。

2.2 主窗口的初始化与训练入口

资源里的主界面不复杂,几个按钮加一个文本输出区。核心逻辑在mainwindow.cpp的构造函数里做初始化,我一般会加一个日志输出方便排查:

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); // 初始化神经网络:输入层 3 个节点(温度、湿度、前一小时 PM2.5) // 隐藏层 8 个节点,输出层 1 个节点(当前 PM2.5) m_net = new NeuralNetwork({3, 8, 1}, 0.05); // 初始化 Excel 引擎 m_excel = new ExcelEngine(); connect(ui->btnLoadData, &QPushButton::clicked, this, &MainWindow::onLoadData); connect(ui->btnTrain, &QPushButton::clicked, this, &MainWindow::onTrain); connect(ui->btnPredict, &QPushButton::clicked, this, &MainWindow::onPredict); ui->textLog->append("初始化完成,网络结构 3-8-1,学习率 0.05"); }

NeuralNetwork的构造函数接收一个std::vector<int>表示每层节点数,第二个参数是学习率。这里 3-8-1 的结构是我根据 PM2.5 预测场景调的,输入用温度、湿度、前一小时浓度,输出当前小时浓度。隐藏层节点数没有严格公式,一般取输入输出的 1.5 到 2 倍,8 个是折中值,太少欠拟合,太多训练慢还容易过拟合。

提示:如果你换数据集,输入层节点数一定要跟着改,否则读 Excel 的时候维度对不上,程序不会崩但结果全是错的,这种坑最难查。

3. 纯 C++ 手搓 BP 网络:前向传播、反向传播与权重更新

3.1 网络结构定义与激活函数选型

这份资源的网络实现走的是经典 BP,没有用卷积或 LSTM,因为目标场景是结构化数值预测,BP 足够。核心类NeuralNetwork里维护三个东西:每层的权重矩阵、每层的偏置向量、每层的激活值缓存。权重初始化用的是 Xavier 的简化版,即rand() / RAND_MAX * sqrt(2.0 / fan_in),比全零初始化好,全零会导致所有神经元对称,反向传播时梯度一样,网络永远学不出东西。

激活函数方面,隐藏层用 Sigmoid,输出层用线性。为什么输出层不用 Sigmoid?因为 PM2.5 浓度范围可能到几百,Sigmoid 输出被压在 0 到 1 之间,反归一化后误差会被放大。线性输出配合均方误差损失,梯度更直接。

// neuralnetwork.h 关键成员 class NeuralNetwork { public: NeuralNetwork(const std::vector<int>& layers, double lr); std::vector<double> forward(const std::vector<double>& input); void backward(const std::vector<double>& target); void train(const std::vector<std::vector<double>>& inputs, const std::vector<std::vector<double>>& targets, int epochs); private: std::vector<int> m_layerSizes; std::vector<std::vector<std::vector<double>>> m_weights; // [层][节点][前一层节点] std::vector<std::vector<double>> m_biases; std::vector<std::vector<double>> m_activations; double m_lr; double sigmoid(double x); double sigmoidDerivative(double x); };

m_weights是个三维向量,第一维是层索引,第二维是当前层节点索引,第三维是前一层节点索引。这种结构在 C++ 里内存不连续,但胜在直观,调试的时候能直接m_weights[1][2][3]看到具体值。如果追求性能可以拍平成一维数组,但这份资源定位是教学和轻量应用,可读性优先。

3.2 前向传播与反向传播的代码落地

前向传播就是把输入逐层加权求和再过激活函数:

std::vector<double> NeuralNetwork::forward(const std::vector<double>& input) { m_activations.clear(); m_activations.push_back(input); // 第 0 层是输入 std::vector<double> current = input; for (size_t layer = 0; layer < m_weights.size(); ++layer) { std::vector<double> next(m_layerSizes[layer + 1], 0.0); for (size_t node = 0; node < next.size(); ++node) { double sum = m_biases[layer][node]; for (size_t prev = 0; prev < current.size(); ++prev) { sum += m_weights[layer][node][prev] * current[prev]; } // 隐藏层用 Sigmoid,输出层线性 next[node] = (layer == m_weights.size() - 1) ? sum : sigmoid(sum); } m_activations.push_back(next); current = next; } return current; }

反向传播是这份资源里最容易写错的部分。核心是链式法则:输出层误差对权重的梯度等于误差项乘以输入激活值。误差项从输出层往前逐层传递,每经过一个 Sigmoid 就要乘上它的导数。

void NeuralNetwork::backward(const std::vector<double>& target) { size_t numLayers = m_weights.size(); std::vector<std::vector<double>> deltas(numLayers); // 输出层误差(线性输出,导数就是 1) size_t outLayer = numLayers - 1; deltas[outLayer].resize(m_layerSizes[outLayer + 1]); for (size_t i = 0; i < deltas[outLayer].size(); ++i) { double output = m_activations[outLayer + 1][i]; deltas[outLayer][i] = (output - target[i]); } // 从后往前传误差 for (int layer = numLayers - 2; layer >= 0; --layer) { deltas[layer].resize(m_layerSizes[layer + 1]); for (size_t i = 0; i < deltas[layer].size(); ++i) { double sum = 0.0; for (size_t j = 0; j < deltas[layer + 1].size(); ++j) { sum += deltas[layer + 1][j] * m_weights[layer + 1][j][i]; } double act = m_activations[layer + 1][i]; deltas[layer][i] = sum * sigmoidDerivative(act); } } // 更新权重和偏置 for (size_t layer = 0; layer < numLayers; ++layer) { for (size_t node = 0; node < m_weights[layer].size(); ++node) { for (size_t prev = 0; prev < m_weights[layer][node].size(); ++prev) { m_weights[layer][node][prev] -= m_lr * deltas[layer][node] * m_activations[layer][prev]; } m_biases[layer][node] -= m_lr * deltas[layer][node]; } } }

sigmoidDerivative这里传的是激活后的值而不是原始加权和,因为 Sigmoid 的导数可以用f(x) * (1 - f(x))表示,这样省一次计算。注意输出层用的是线性激活,所以误差项直接是output - target,没有乘导数。如果哪天你把输出层改成 Sigmoid,这里必须补上导数,否则训练不收敛,这是血泪经验。

3.3 训练循环与学习率参数怎么调

训练循环就是反复前向、反向、更新,每轮打印一次损失:

void NeuralNetwork::train(const std::vector<std::vector<double>>& inputs, const std::vector<std::vector<double>>& targets, int epochs) { for (int epoch = 0; epoch < epochs; ++epoch) { double totalLoss = 0.0; for (size_t i = 0; i < inputs.size(); ++i) { std::vector<double> output = forward(inputs[i]); for (size_t j = 0; j < output.size(); ++j) { double diff = output[j] - targets[i][j]; totalLoss += diff * diff; } backward(targets[i]); } totalLoss /= inputs.size(); if (epoch % 100 == 0) { qDebug() << "Epoch" << epoch << "Loss" << totalLoss; } } }

学习率m_lr设 0.05 是保守值。如果损失下降太慢,可以试 0.1 到 0.3;如果损失震荡甚至变大,说明学习率过高,降到 0.01。另外这份资源没有加动量项和自适应学习率,所以对数据归一化很敏感。输入数据一定要先归一化到 0 到 1 之间,否则梯度爆炸,损失直接变 nan。归一化我一般用 min-max,公式是(x - min) / (max - min),预测完再反归一化回去。

4. Qt 调用 Excel:QAxObject 读写表格与数据预处理

4.1 QAxObject 连接 Excel 的完整流程

Qt 调 Excel 走的是 COM 接口,Windows 下用 QAxObject 封装。核心步骤是:创建 Excel Application 对象、获取 Workbooks 集合、打开指定文件、获取 Worksheets、选中某个 Sheet、读取 UsedRange 的数据。这套流程每一步都可能返回空指针,所以错误检查不能省。

bool ExcelEngine::openFile(const QString& path) { m_excel = new QAxObject("Excel.Application"); if (!m_excel) { qDebug() << "无法创建 Excel 对象,检查是否安装 Office"; return false; } m_excel->setProperty("Visible", false); // 后台运行,不弹窗 m_excel->setProperty("DisplayAlerts", false); // 不弹保存提示 QAxObject* workbooks = m_excel->querySubObject("Workbooks"); if (!workbooks) return false; m_workbook = workbooks->querySubObject("Open(const QString&)", path); if (!m_workbook) { qDebug() << "打开文件失败:" << path; return false; } QAxObject* sheets = m_workbook->querySubObject("Worksheets"); m_sheet = sheets->querySubObject("Item(int)", 1); // 第一个 Sheet return m_sheet != nullptr; }

Visible设 false 是为了不弹 Excel 窗口,DisplayAlerts设 false 是防止保存时弹确认框卡住程序。Open的参数是文件绝对路径,路径里如果有中文,Qt 的 QString 能处理,但 Excel 那边偶尔会出问题,建议路径全用英文。

4.2 读取单元格数据与批量导入

读单个单元格用Range的Value属性,批量读用UsedRange的Value拿二维数组。批量读比循环读单格快一个数量级,几千行数据差距很明显。

std::vector<std::vector<double>> ExcelEngine::readRange(int rows, int cols) { std::vector<std::vector<double>> data; QAxObject* range = m_sheet->querySubObject("Range(const QString&)", "A1:C" + QString::number(rows)); if (!range) return data; QVariant var = range->property("Value"); QVariantList rowList = var.toList(); for (const QVariant& row : rowList) { QVariantList cellList = row.toList(); std::vector<double> rowData; for (const QVariant& cell : cellList) { rowData.push_back(cell.toDouble()); } data.push_back(rowData); } delete range; return data; }

这里Value返回的是 QVariant,嵌套两层 QVariantList 对应二维表格。注意 Excel 的索引从 1 开始,不是 0。如果某一行有空单元格,toDouble()会返回 0,所以数据预处理阶段要检查缺失值,我一般用前一行填充或者直接剔除。

4.3 写入预测结果与格式控制

写数据和读类似,构造一个 QVariantList 然后赋给 Range 的 Value:

void ExcelEngine::writeColumn(int startRow, const std::vector<double>& values) { QVariantList col; for (double v : values) { col.append(v); } QString rangeStr = "D" + QString::number(startRow) + ":D" + QString::number(startRow + values.size() - 1); QAxObject* range = m_sheet->querySubObject("Range(const QString&)", rangeStr); if (range) { range->setProperty("Value", col); delete range; } }

写入的时候如果目标单元格有格式,比如百分比或者小数位数,Excel 会按原格式显示。想强制设成数值格式,可以再调NumberFormat属性。另外记得在析构函数里关闭 workbook 和 quit Excel,否则后台会残留 EXCEL.EXE 进程,任务管理器里一堆,这是最常见的翻车点。

注意:QAxObject 只在 Windows 下可用,Linux 和 macOS 编不过。如果你的 Qt 工程要跨平台,Excel 这部分得用条件编译隔离,或者换成 libxlsxwriter 之类的库。

5. 避坑与排查:数据归一化、Excel 进程残留与训练不收敛

5.1 现象:损失变成 nan 或一直不下降

原因:输入数据没归一化,或者归一化范围不对。PM2.5 原始值可能到 500,直接喂给网络,加权和巨大,Sigmoid 饱和,梯度接近零,权重几乎不更新。解决:在onLoadData里读完 Excel 后立刻做 min-max 归一化,把归一化用的 min 和 max 存成成员变量,预测时用同一组参数反归一化。别用训练集的 min/max 去归一化测试集,要用全体数据的统计量,否则测试集超出范围会外推。

5.2 现象:程序退出后 Excel 进程还在

原因:QAxObject 析构时没有正确释放 COM 对象,或者m_excel->dynamicCall("Quit()")没调用。解决:在 ExcelEngine 的析构函数里按顺序释放 sheet、workbook、excel,最后调 Quit。顺序反了会崩。另外delete之后指针置 nullptr,防止重复释放。

ExcelEngine::~ExcelEngine() { if (m_sheet) { delete m_sheet; m_sheet = nullptr; } if (m_workbook) { m_workbook->dynamicCall("Close(Boolean)", false); delete m_workbook; m_workbook = nullptr; } if (m_excel) { m_excel->dynamicCall("Quit()"); delete m_excel; m_excel = nullptr; } }

5.3 现象:训练集损失很低但预测新数据偏差大

原因:过拟合。隐藏层节点太多或者训练轮数太多,网络把训练样本背下来了。解决:减少隐藏层节点,3-8-1 可以降到 3-5-1;或者加早停,每 50 轮在验证集上测一次,验证损失连续上升就停。这份资源没内置验证集划分,我一般手动切 80% 训练 20% 验证,在train外面自己控制。

5.4 现象:Excel 读取返回空列表

原因:Sheet 名字不对或者 UsedRange 为空。Item(int)是按索引取,如果 Excel 文件第一个 Sheet 不是数据页,就读错了。解决:改成按名字取Item(const QString&),传实际 Sheet 名。另外文件路径含空格或特殊字符时,Open可能失败,用双引号包一下路径。

5.5 现象:Qt 6 下编译报 axcontainer 找不到

原因:Qt 6 把 ActiveQt 拆成了独立模块,默认不装。解决:要么换回 Qt 5.15,要么在 Qt 6 安装器里勾选 ActiveQt 并手动在 pro 里加QT += axcontainer。如果还不行,检查编译器是不是 MSVC,MinGW 对 ActiveQt 支持不完整。

6. 进阶技巧:用训练好的权重做离线推理与精度验证

网络训练完之后,权重和偏置就是一堆浮点数,没必要每次预测都重新训练。我一般会在训练结束后把m_weights和m_biases序列化成一个二进制文件或者 JSON,下次启动直接加载。这样上位机开机就能预测,不用等训练。序列化用 QDataStream 最简单:

void NeuralNetwork::saveWeights(const QString& path) { QFile file(path); if (!file.open(QIODevice::WriteOnly)) return; QDataStream out(&file); out << (quint32)m_layerSizes.size(); for (int s : m_layerSizes) out << (quint32)s; for (const auto& layer : m_weights) for (const auto& node : layer) for (double w : node) out << w; for (const auto& layer : m_biases) for (double b : layer) out << b; file.close(); }

加载就是反过来读,读之前先校验层结构是否匹配,不匹配直接报错,别硬读,否则权重错位,预测结果全是乱的。

验证精度我习惯用三个指标:MAE、RMSE 和 R²。MAE 看平均偏差,RMSE 对大误差敏感,R² 看拟合优度。在 Excel 里写公式算就行,不用在 C++ 里实现。把预测值和真实值两列并排,MAE 用AVERAGE(ABS(A2:A100-B2:B100))数组公式,RMSE 用SQRT(SUMPRODUCT((A2:A100-B2:B100)^2)/COUNT(A2:A100)),R² 用1-SUMPRODUCT((A2:A100-B2:B100)^2)/SUMPRODUCT((A2:A100-AVERAGE(A2:A100))^2)。PM2.5 这种时序数据,R² 能到 0.85 以上就算不错了,低于 0.7 说明特征不够,得加输入维度,比如把风速、气压也拉进来。

从那以后我每次交付带 Excel 读写的 Qt 工程,都会在目标机器上先跑一遍「打开-读-写-关闭」的完整流程,然后打开任务管理器确认没有残留进程,再检查一遍归一化参数是否和训练时一致。这三个动作花不了五分钟,但能省掉现场调试的后悔药。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询