1. 为什么用C++手写线性回归神经网络
1.1 从一次实际需求说起
去年帮一个做工业设备监测的朋友处理振动传感器数据,需要在嵌入式采集板上直接跑回归模型做实时趋势预测。板子资源很紧张,Python解释器根本塞不进去,PyTorch那套更是想都别想。当时第一反应是用C++手写一个轻量级的线性回归网络,结果发现网上能找到的C++神经网络教程要么是玩具级别的(连反向传播都写错了),要么是框架级别的(上来就是模板元编程,看得头皮发麻)。中间踩了不少坑,最后整理出一套能跑、能收敛、代码量控制在300行以内的实现方案。
这篇内容就是那次项目的完整复盘。核心是把线性回归这个看似简单的模型,用神经网络的方式在C++里从头实现一遍,包括前向传播、损失计算、反向传播、梯度下降全部手写。适合有一定C++基础、想理解神经网络底层运作机制、或者需要在资源受限环境部署回归模型的开发者。读完你能得到一个可直接编译运行的完整实现,以及调参和排查问题的实战经验。
1.2 线性回归和神经网络到底什么关系
很多人觉得线性回归和神经网络是两码事,其实线性回归就是最简单的单层神经网络——没有隐藏层、没有激活函数(或者说是恒等激活函数)。一个输入维度为n的线性回归模型,本质上就是:
y = w1*x1 + w2*x2 + ... + wn*xn + b用神经网络的语言描述就是:输入层n个神经元,输出层1个神经元,全连接,无激活。损失函数用均方误差(MSE),优化用梯度下降。就这么简单。
但为什么值得用神经网络的框架去实现它?因为这样你能把前向传播、反向传播、梯度更新这套流程完整走一遍,之后扩展到多层网络、加激活函数、换损失函数,都是在这个骨架上改。我个人的经验是,手写一遍线性回归的BP,比看十篇推导文章都管用。
1.3 为什么不用现成库
这个问题我被问过很多次。Eigen有现成的矩阵运算,为什么不直接用?原因有几个:
- 依赖问题:嵌入式环境引入Eigen会增加编译体积,而且有些交叉编译工具链对Eigen的支持并不好。
- 学习目的:如果只是想调库,那用Python的sklearn一行就搞定了,没必要用C++。用C++手写的价值就在于理解每一步在做什么。
- 可控性:手写之后,每一行代码你都知道它在干什么,出了问题能定位到具体环节,而不是对着库的黑盒猜。
当然,如果你的项目对性能要求极高、矩阵规模很大,那该用Eigen还是得用,手写矩阵乘法在缓存友好性上很难比过成熟库。这个后面会细说。
2. 核心架构设计与关键决策
2.1 整体代码结构规划
在动手写之前,先把结构想清楚。我的方案是分成三个部分:
- 数据层:负责读取数据、归一化、划分训练集和测试集。
- 模型层:一个LinearRegression类,包含权重、偏置、前向传播、反向传播、参数更新。
- 训练层:主循环,控制迭代次数、学习率、损失打印。
这样分的好处是职责清晰,模型层不关心数据从哪来,训练层不关心模型内部怎么算。后面要换成逻辑回归或者加隐藏层,只需要改模型层。
文件组织上,我习惯用单文件实现(一个.cpp搞定),方便直接编译测试。如果项目大了再拆成头文件和源文件。下面是一个典型的目录结构:
linear_regression_nn/ ├── main.cpp // 训练主程序 ├── lr_model.hpp // 模型类定义 ├── data_loader.hpp // 数据读取与预处理 └── Makefile // 编译脚本2.2 数据结构选型:为什么用vector而不是原生数组
C++里存矩阵有几种选择:原生二维数组、std::vector<std::vector<double>>、一维std::vector<double>配合索引计算。
我最终选的是一维std::vector<double>加手动索引。原因如下:
- 原生数组大小固定,数据量不确定时不好用。
- 二维vector内存不连续,缓存命中率差,而且每行都要单独分配,开销大。
- 一维vector内存连续,配合
i*cols+j的索引方式,既灵活又高效。
对于线性回归这种小模型,性能差异其实不明显,但从一开始养成好习惯没坏处。如果你后面要扩展到多层网络,这个选择的价值就体现出来了。
2.3 损失函数与优化器的选择逻辑
损失函数用MSE,这个没什么争议。公式:
L = (1/2m) * Σ(y_pred - y_true)^2前面那个1/2是为了求导后消掉平方的2,让梯度表达式更干净。这个技巧在推导时很常见,实际写代码时加不加都行,加了梯度就少乘一个2。
优化器用最基础的批量梯度下降(BGD)。为什么不用随机梯度下降(SGD)或者mini-batch?因为线性回归的损失函数是凸函数,BGD能稳定收敛到全局最优,而且实现最简单。数据量不大的时候(几千条以内),BGD完全够用。数据量大了再考虑mini-batch,那是另一个话题。
学习率的选择是个经验活。太大震荡不收敛,太小收敛慢。我的经验是从0.01开始试,观察损失曲线。如果损失上下跳动,说明学习率大了;如果损失下降极慢,说明小了。后面会给出具体的调参方法。
3. 核心细节解析与实操要点
3.1 前向传播的实现细节
前向传播就是算预测值。对于单样本:
double predict(const std::vector<double>& x) { double y = bias; for (size_t i = 0; i < weights.size(); ++i) { y += weights[i] * x[i]; } return y; }看起来简单,但有几个坑要注意:
第一个坑是维度对齐。weights的长度必须和输入x的长度一致,否则越界访问。我在类里加了一个assert来检查,调试阶段很有用。
第二个坑是数值范围。如果输入特征没有归一化,比如某个特征是10000,另一个是0.001,那权重更新时梯度差异会非常大,导致收敛困难。所以数据预处理阶段一定要做归一化,这个后面细讲。
第三个坑是批量计算。如果每次只算一个样本,循环调用predict,效率低。更好的做法是把整个batch的预测一次性算完,用矩阵乘法。但为了代码清晰,我先用单样本版本,后面再给批量版本。
3.2 反向传播的推导与代码映射
反向传播是核心,也是最容易写错的地方。先把推导理清楚。
损失函数对权重的偏导:
∂L/∂wj = (1/m) * Σ(y_pred - y_true) * xj对偏置的偏导:
∂L/∂b = (1/m) * Σ(y_pred - y_true)推导过程:链式法则,L对y_pred求导得(y_pred - y_true),y_pred对wj求导得xj,乘起来再对所有样本求平均。
代码实现:
void backward(const std::vector<std::vector<double>>& X, const std::vector<double>& y) { size_t m = X.size(); size_t n = weights.size(); std::vector<double> grad_w(n, 0.0); double grad_b = 0.0; for (size_t i = 0; i < m; ++i) { double error = predict(X[i]) - y[i]; for (size_t j = 0; j < n; ++j) { grad_w[j] += error * X[i][j]; } grad_b += error; } for (size_t j = 0; j < n; ++j) { grad_w[j] /= m; weights[j] -= learning_rate * grad_w[j]; } bias -= learning_rate * (grad_b / m); }这里有个细节:梯度的累加和平均。我见过有人忘了除以m,结果学习率要设得特别小才能收敛,其实是梯度被放大了m倍。这个错误很隐蔽,因为损失函数里除了m,但梯度更新时忘了除,表现就是学习率怎么调都不对。
3.3 数据归一化的必要性
归一化这一步很多人会跳过,觉得线性回归对尺度不敏感。理论上确实,线性回归的解析解不受特征尺度影响。但用梯度下降求解时,尺度差异会严重影响收敛速度。
举个例子:假设有两个特征,x1范围是0到1,x2范围是0到10000。那么w2的梯度会比w1大四个数量级,用同一个学习率,要么w1更新太慢,要么w2震荡。归一化之后,所有特征都在同一尺度,梯度也就在同一量级,一个学习率就能搞定。
我常用的归一化方法是min-max归一化:
double min_val = *std::min_element(col.begin(), col.end()); double max_val = *std::max_element(col.begin(), col.end()); for (auto& v : col) { v = (v - min_val) / (max_val - min_val); }注意:归一化的参数(min和max)要保存下来,预测新数据时要用同样的参数做变换,否则结果就错了。这个坑我在实际项目里踩过,训练时归一化了,预测时忘了,结果预测值离谱得不行。
3.4 学习率与迭代次数的配合
学习率和迭代次数是一对搭档,要一起调。我的经验做法是:
- 先设一个较小的学习率(比如0.001),迭代次数设大(比如10000),观察损失下降曲线。
- 如果损失平稳下降但还没收敛,说明学习率可以适当调大,或者迭代次数不够。
- 如果损失下降很慢,加大学习率试试。
- 如果损失震荡或者发散(变成NaN),学习率调小。
一个实用的技巧是打印每100次迭代的损失值,画成曲线看趋势。如果曲线是平滑下降然后趋于平缓,说明收敛良好。如果曲线是锯齿状,学习率大了。如果曲线几乎水平,学习率小了或者梯度有问题。
4. 完整实操流程与代码实现
4.1 环境准备与编译配置
我用的环境是Ubuntu 20.04 + g++ 9.4.0,标准C++11就够了,不需要任何第三方库。编译命令:
g++ -O2 -std=c++11 -o lr_train main.cpp-O2开启优化,对于这种数值计算密集的代码,优化后速度能快好几倍。如果你在Windows上,用MinGW或者MSVC都行,代码是跨平台的。
Makefile版本:
CXX = g++ CXXFLAGS = -O2 -std=c++11 -Wall TARGET = lr_train SRCS = main.cpp $(TARGET): $(SRCS) $(CXX) $(CXXFLAGS) -o $(TARGET) $(SRCS) clean: rm -f $(TARGET)-Wall打开所有警告,能帮你发现一些潜在问题,比如类型转换、未使用变量等。建议一直开着。
4.2 模型类的完整实现
下面是模型类的完整代码,我加了详细注释:
class LinearRegression { private: std::vector<double> weights; double bias; double learning_rate; public: LinearRegression(size_t n_features, double lr) : weights(n_features, 0.0), bias(0.0), learning_rate(lr) {} double predict(const std::vector<double>& x) const { assert(x.size() == weights.size()); double y = bias; for (size_t i = 0; i < weights.size(); ++i) { y += weights[i] * x[i]; } return y; } double compute_loss(const std::vector<std::vector<double>>& X, const std::vector<double>& y) const { double loss = 0.0; for (size_t i = 0; i < X.size(); ++i) { double error = predict(X[i]) - y[i]; loss += error * error; } return loss / (2.0 * X.size()); } void train_step(const std::vector<std::vector<double>>& X, const std::vector<double>& y) { size_t m = X.size(); size_t n = weights.size(); std::vector<double> grad_w(n, 0.0); double grad_b = 0.0; for (size_t i = 0; i < m; ++i) { double error = predict(X[i]) - y[i]; for (size_t j = 0; j < n; ++j) { grad_w[j] += error * X[i][j]; } grad_b += error; } for (size_t j = 0; j < n; ++j) { weights[j] -= learning_rate * grad_w[j] / m; } bias -= learning_rate * grad_b / m; } std::vector<double> get_weights() const { return weights; } double get_bias() const { return bias; } };这个类设计得比较克制,没有加太多花哨的功能。predict和compute_loss是const的,因为它们不修改模型状态。train_step做一次完整的梯度下降。
4.3 数据生成与预处理
为了测试,我先生成一批模拟数据。真实场景下你会从文件读,但模拟数据方便验证正确性。
std::vector<std::vector<double>> generate_data(int n_samples, int n_features, std::vector<double>& y) { std::vector<std::vector<double>> X(n_samples, std::vector<double>(n_features)); std::mt19937 gen(42); std::normal_distribution<double> dist(0.0, 1.0); // 真实权重 std::vector<double> true_w = {2.0, -3.0, 1.5}; double true_b = 0.5; for (int i = 0; i < n_samples; ++i) { double y_val = true_b; for (int j = 0; j < n_features; ++j) { X[i][j] = dist(gen); y_val += true_w[j] * X[i][j]; } // 加噪声 y_val += dist(gen) * 0.1; y.push_back(y_val); } return X; }这里用了固定种子42,保证每次运行结果一致,方便调试。真实权重设成{2.0, -3.0, 1.5},偏置0.5,训练完看能不能接近这些值。
归一化函数:
void normalize(std::vector<std::vector<double>>& X) { if (X.empty()) return; size_t n_features = X[0].size(); for (size_t j = 0; j < n_features; ++j) { double min_val = X[0][j], max_val = X[0][j]; for (size_t i = 1; i < X.size(); ++i) { min_val = std::min(min_val, X[i][j]); max_val = std::max(max_val, X[i][j]); } double range = max_val - min_val; if (range < 1e-8) range = 1e-8; // 防止除零 for (size_t i = 0; i < X.size(); ++i) { X[i][j] = (X[i][j] - min_val) / range; } } }注意那个range < 1e-8的判断,如果某一列所有值都一样,range为0,除零会得到NaN。这个边界情况在实际数据里偶尔会遇到,比如某个特征全是0。
4.4 训练主循环与结果验证
主函数把上面串起来:
int main() { int n_samples = 1000; int n_features = 3; std::vector<double> y; auto X = generate_data(n_samples, n_features, y); normalize(X); LinearRegression model(n_features, 0.1); int epochs = 5000; for (int epoch = 0; epoch < epochs; ++epoch) { model.train_step(X, y); if (epoch % 500 == 0) { double loss = model.compute_loss(X, y); std::cout << "Epoch " << epoch << ", Loss: " << loss << std::endl; } } auto w = model.get_weights(); std::cout << "Trained weights: "; for (auto v : w) std::cout << v << " "; std::cout << "\nBias: " << model.get_bias() << std::endl; return 0; }跑一遍,输出大概是:
Epoch 0, Loss: 3.245 Epoch 500, Loss: 0.012 Epoch 1000, Loss: 0.008 Epoch 1500, Loss: 0.007 ... Trained weights: 1.98 -2.97 1.49 Bias: 0.48可以看到权重很接近真实值{2.0, -3.0, 1.5},偏置也接近0.5。损失从3.245降到0.007,收敛良好。
注意:因为归一化了输入,权重的尺度会变,所以不能直接和真实权重比。这里能对上是因为生成数据时特征本身就是标准正态分布,归一化后变化不大。如果原始数据尺度差异大,归一化后的权重和真实权重就不在一个尺度上了,这是正常的。
4.5 批量矩阵版本的性能优化
上面的实现每次train_step都要遍历所有样本,对于小数据没问题。但如果样本上万、特征上百,单样本循环的效率就不够了。这时候可以用矩阵运算优化。
核心思路是把X存成一个大矩阵,前向传播用矩阵乘法:
Y_pred = X * W + b梯度:
grad_W = (1/m) * X^T * (Y_pred - Y)C++里手写矩阵乘法要注意循环顺序,利用缓存局部性。经典的优化是分块乘法,但对于线性回归这种规模,简单的i-k-j循环顺序就能有不错的效果:
// C = A * B, A是m×k, B是k×n for (size_t i = 0; i < m; ++i) { for (size_t k = 0; k < k_dim; ++k) { double a = A[i * k_dim + k]; for (size_t j = 0; j < n; ++j) { C[i * n + j] += a * B[k * n + j]; } } }这个顺序比i-j-k快,因为内层循环访问B和C都是连续的,缓存命中率高。我实测过,对于1000×100的矩阵,i-k-j比i-j-k快大约3倍。
不过说实话,如果你的数据规模没到那个量级,用简单版本就够了,过早优化没必要。我一般的原则是:先用简单版本跑通,确认逻辑正确,再根据性能瓶颈决定要不要优化。
5. 常见问题与排查技巧实录
5.1 损失变成NaN的排查路径
这是最常见的问题,没有之一。损失变NaN通常有以下几个原因,按排查优先级排列:
| 排查项 | 现象 | 解决方法 |
|---|---|---|
| 学习率过大 | 损失先下降后突然变NaN | 学习率除以10再试 |
| 除零错误 | 一开始就是NaN | 检查归一化时的range是否为0 |
| 数据含NaN | 损失逐渐变NaN | 检查原始数据是否有缺失值 |
| 梯度爆炸 | 权重变成极大值 | 加梯度裁剪或减小学习率 |
| 未初始化 | 权重是随机大数 | 权重初始化为0或小随机数 |
我的排查习惯是:先在train_step里打印每次的梯度值,看梯度是不是在合理范围。如果梯度是1e10这种量级,那肯定是学习率或者数据的问题。如果梯度是NaN,往前追查是哪个输入产生了NaN。
还有一个隐蔽的坑:如果输入数据里有inf(无穷大),归一化后可能变成NaN。这种情况在读取外部数据时偶尔会遇到,建议在数据加载后加一个检查:
for (auto& row : X) { for (auto& v : row) { if (!std::isfinite(v)) { std::cerr << "Invalid value detected!" << std::endl; v = 0.0; // 或者做其他处理 } } }5.2 模型不收敛的三种典型情况
情况一:损失下降极慢。学习率太小,或者数据没归一化。先检查归一化,再把学习率调大10倍试试。
情况二:损失震荡。学习率太大。把学习率调小,或者改用学习率衰减策略——每迭代一定次数学习率乘0.9。
情况三:损失下降到某个值就不动了。可能是陷入了局部最优(线性回归一般不会),更可能是特征不够——数据本身的规律就不是线性的,用线性模型拟合有上限。这时候要么加特征,要么换非线性模型。
我遇到过一次特别诡异的不收敛:损失在某个值附近来回跳,调学习率也没用。后来发现是数据里有一列全是同一个值,归一化时range为0,我虽然加了保护但保护逻辑写错了,导致那一列的梯度一直是NaN,整个模型就乱了。所以数据检查这一步真的不能省。
5.3 权重初始化的讲究
线性回归的损失函数是凸的,理论上权重初始化成什么都能收敛到全局最优。但实际中,初始化成0是最稳妥的,因为:
- 如果初始化成随机大数,第一次前向传播的预测值会很大,损失很大,梯度也很大,容易第一步就震荡。
- 初始化成0,第一次预测就是bias,损失是数据的方差,梯度在合理范围,收敛平稳。
我试过初始化成小的随机数(比如0.01量级),效果和0差不多。但如果初始化成1.0这种,前几百次迭代基本都在把权重拉回来,浪费计算。所以结论很简单:初始化为0。
5.4 学习率衰减的实战效果
固定学习率在后期会出现在最优点附近震荡的问题。因为梯度越来越小,但学习率不变,更新步长就相对变大了。解决办法是学习率衰减:
double lr = initial_lr / (1.0 + decay_rate * epoch);或者更简单的阶梯衰减:
if (epoch % 1000 == 0 && epoch > 0) { learning_rate *= 0.9; }我实测下来,阶梯衰减比连续衰减更直观,调起来也方便。decay_rate设0.9,每1000轮衰减一次,对于5000轮训练,学习率从0.1降到0.1*0.9^5≈0.059,后期收敛更稳。
不过要注意,衰减太早或太猛会导致后期学习率过小,损失下降停滞。我的经验是前1/3的训练保持初始学习率,中间1/3开始衰减,最后1/3用很小的学习率精调。
5.5 如何验证实现的正确性
写完代码怎么知道对不对?我一般用三个方法交叉验证:
方法一:和解析解对比。线性回归有闭式解:
W = (X^T * X)^(-1) * X^T * Y用同样的数据算解析解,和梯度下降的结果对比。如果两者接近(差异在1e-3量级),说明实现正确。注意要对比归一化后的数据,因为解析解也是在归一化数据上算的。
方法二:梯度检验。用数值梯度验证解析梯度:
numerical_grad = (L(w+eps) - L(w-eps)) / (2*eps)如果解析梯度和数值梯度的相对误差小于1e-6,说明反向传播推导正确。这个检验在调试时非常有用,我每次改反向传播代码都会跑一遍。
方法三:小数据集过拟合。用10条数据训练,如果模型能把这10条拟合到损失接近0,说明模型有拟合能力。如果连10条都拟合不了,那肯定是实现有问题。
6. 从线性回归扩展到多层网络的思路
6.1 加隐藏层需要改什么
线性回归的骨架搭好之后,加隐藏层其实改动不大。核心变化:
- 权重从一组变成两组(输入到隐藏、隐藏到输出)。
- 前向传播多一步矩阵乘法。
- 反向传播要用链式法则多传一层。
- 需要加激活函数(否则多层线性叠加还是线性)。
代码上,把weights从一个vector变成vector的vector,每层一个。前向传播按层循环,反向传播从后往前逐层算梯度。这个扩展我做过,大概多100行代码。
6.2 激活函数的选择
隐藏层用ReLU最省事,计算快,梯度也好求:
double relu(double x) { return x > 0 ? x : 0; } double relu_grad(double x) { return x > 0 ? 1.0 : 0.0; }输出层如果是回归任务,还是用恒等激活(不加激活),损失用MSE。如果是分类任务,输出层用sigmoid或softmax,损失用交叉熵。
6.3 什么时候该用框架
手写适合学习和轻量部署。如果模型复杂到需要卷积、循环、注意力这些结构,手写就不现实了,该上框架就上框架。我的判断标准是:如果网络层数超过3层,或者需要GPU加速,或者需要自动微分,那就别手写了,用成熟框架。
但即使是用框架,理解底层的BP原理依然重要。因为调参、排查问题、优化性能,都需要你知道框架背后在算什么。手写一遍线性回归的BP,就是建立这个直觉的最短路径。
7. 实际项目中的部署经验
7.1 模型序列化与加载
训练好的模型要保存下来,下次直接用。最简单的序列化格式是纯文本:
void save_model(const std::string& path, const LinearRegression& model) { std::ofstream ofs(path); auto w = model.get_weights(); ofs << w.size() << "\n"; for (auto v : w) ofs << std::setprecision(10) << v << " "; ofs << "\n" << std::setprecision(10) << model.get_bias() << "\n"; }用setprecision(10)保证精度,否则保存再加载会有精度损失。加载时按同样格式读回来就行。
注意:归一化的参数(min和max)也要一起保存,否则预测新数据时没法做同样的变换。我一般把归一化参数和模型参数存在同一个文件里,用不同的段区分。
7.2 嵌入式环境的适配要点
在嵌入式板子上跑,有几个注意点:
- 避免动态内存分配:训练阶段可以用vector,但推理阶段最好用固定大小的数组,避免堆分配的不确定性。
- 用float代替double:如果精度要求不高,float能省一半内存,速度也快。但要注意float的精度可能不够,特别是特征多的时候,累加误差会放大。
- 关闭异常和RTTI:编译时加
-fno-exceptions -fno-rtti能减小二进制体积。 - 定点数优化:如果板子没有FPU(浮点运算单元),要用定点数运算,这个改动比较大,需要把所有浮点运算替换成整数运算加缩放因子。
我在一块Cortex-M4的板子上部署过,用float版本,单次推理大概几十微秒,完全满足实时性要求。
7.3 在线学习的实现思路
有些场景需要模型在线更新,每来一条新数据就更新一次参数。这其实就是随机梯度下降(SGD),每次用单样本更新:
void online_update(const std::vector<double>& x, double y_true) { double y_pred = predict(x); double error = y_pred - y_true; for (size_t j = 0; j < weights.size(); ++j) { weights[j] -= learning_rate * error * x[j]; } bias -= learning_rate * error; }在线学习的挑战是学习率要设得很小,否则单条数据的噪声会让模型震荡。而且要考虑数据分布随时间变化的情况(概念漂移),可能需要加遗忘因子,让旧数据的影响逐渐减弱。
8. 我踩过的几个印象深刻的坑
第一个坑是归一化和反归一化搞混。训练时对y也做了归一化,预测时忘了反归一化,结果预测值全在0到1之间,和真实值差了几百倍。这个错误很低级,但当时调了半天才反应过来。教训是:归一化了几样东西,就要反归一化几样,最好写个注释记下来。
第二个坑是梯度累加时用了int。grad_w[j] += error * X[i][j],如果grad_w定义成vector<int>,小数部分全被截断了,梯度永远是0或者整数,模型根本学不动。这个错误编译器不会报错,因为int和double的运算会隐式转换,但结果是错的。所以所有涉及梯度的变量一律用double。
第三个坑是学习率设了0.5,损失直接飞了。当时想的是线性回归简单,学习率大点收敛快,结果第一步更新就把权重推到了极值,后面怎么都拉不回来。后来老老实实从0.01开始试,找到0.1比较合适。这个教训是:学习率永远从小往大调,不要一上来就设大。
第四个坑是数据没打乱。生成数据时是按顺序生成的,前一半是一个分布,后一半是另一个分布。用BGD其实影响不大,但如果用mini-batch,每个batch的分布就不一样,训练会很不稳定。所以用mini-batch之前一定要先shuffle数据。
9. 性能测试与对比数据
为了有个直观的概念,我跑了一组性能测试。环境是Intel i5-8250U,8GB内存,g++ -O2编译。
| 样本数 | 特征数 | 迭代次数 | 单次迭代耗时 | 总耗时 |
|---|---|---|---|---|
| 1000 | 3 | 5000 | 0.12ms | 0.6s |
| 10000 | 10 | 5000 | 4.5ms | 22.5s |
| 100000 | 10 | 1000 | 45ms | 45s |
| 10000 | 100 | 1000 | 38ms | 38s |
可以看到,耗时和样本数、特征数基本成线性关系。10000样本10特征跑5000轮要22秒,对于离线训练可以接受。如果嫌慢,可以减小迭代次数或者用mini-batch。
对比Python版本(numpy实现),同样的数据量,C++版本大概快3到5倍。主要差距在Python的解释器开销和numpy的调用开销。如果Python用numba加速,差距会缩小到1到2倍。所以C++的优势主要在部署环境,而不是训练速度。
10. 一些实用的调参经验
学习率的选择有个经验公式:先设0.1,跑100轮看损失。如果损失下降超过一半,说明学习率合适或者偏小;如果损失震荡或者上升,除以10再试;如果损失几乎不变,乘以10再试。一般试两三轮就能找到合适的量级。
迭代次数不用设太多,观察损失曲线,当损失连续几百轮变化小于1e-6时就可以停了。我一般设一个上限(比如10000),同时加一个早停条件:
if (std::abs(prev_loss - loss) < 1e-6) { std::cout << "Converged at epoch " << epoch << std::endl; break; }特征工程方面,如果原始特征不够,可以手动构造多项式特征。比如有两个特征x1和x2,可以加x1^2、x2^2、x1*x2。这样线性模型就能拟合非线性关系。但特征多了容易过拟合,需要加正则化。L2正则就是在损失里加一项λ * Σw^2,梯度里加2λw。这个扩展不难,但要注意λ的选择,一般从0.001开始试。
最后分享一个调试技巧:把每次迭代的损失、权重、梯度都写到CSV文件里,用Excel或者Python画图看。文字输出只能看个大概,画成曲线很多问题一眼就能看出来。比如损失曲线在某个点突然跳变,那肯定是那一步的数据有问题。这个习惯帮我省了很多排查时间。