69、C++项目实战:线性回归与逻辑回归
说实话,很多搞C++的朋友一听到「回归」两个字,第一反应就是「这不是Python的活吗?」。我以前也这么想,直到我在一个嵌入式预测项目里被逼着用C++做推理——模型已经训练好了,但设备上压根跑不了Python。嗯,从那以后我就老老实实把线性回归和逻辑回归用C++撸了一遍。
这一章,我们就来聊聊怎么用纯C++实现这两个最经典的机器学习模型。不依赖任何第三方库,就靠标准库和一点点数学功底。
线性回归:从一条直线说起
线性回归说白了就是找一条直线,让它尽可能穿过所有的数据点。你想想看,给定一堆(x, y)点,我们想找到y = wx + b里的w和b。
怎么找?最常用的方法是最小二乘法。我习惯用正规方程直接求解,尤其是数据量不大的时候,简单粗暴。
这里θ就是我们要的w和b。X是输入矩阵,第一列全1(对应偏置b),第二列是x值。
来看代码实现:
#include <vector>
#include <cmath>
#include <stdexcept>
struct LinearRegression {
double w = 0.0; // 斜率
double b = 0.0; // 截距
// 使用正规方程训练
void fit(const std::vector<double>& x, const std::vector<double>& y) {
if (x.size() != y.size() || x.empty())
throw std::invalid_argument("数据维度不匹配");
int n = x.size();
double sum_x = 0, sum_y = 0, sum_xy = 0, sum_xx = 0;
for (int i = 0; i < n; ++i) {
sum_x += x[i];
sum_y += y[i];
sum_xy += x[i] * y[i];
sum_xx += x[i] * x[i];
}
// 正规方程求解
double denom = n * sum_xx - sum_x * sum_x;
if (std::abs(denom) < 1e-10)
throw std::runtime_error("分母为零,数据可能共线");
w = (n * sum_xy - sum_x * sum_y) / denom;
b = (sum_y - w * sum_x) / n;
}
double predict(double x_val) const {
return w * x_val + b;
}
};
我在项目中遇到过一个问题:数据量一旦上了几十万,正规方程里的矩阵求逆就变得非常慢。这时候我一般改用梯度下降法,虽然迭代次数多,但每一步的计算量小很多。
梯度下降法实现
梯度下降的思路很简单:沿着误差函数的负梯度方向,一步步调整w和b,直到误差最小。
void fit_gd(const std::vector<double>& x, const std::vector<double>& y,
double lr = 0.01, int epochs = 1000) {
int n = x.size();
w = 0.0; b = 0.0;
for (int epoch = 0; epoch < epochs; ++epoch) {
double dw = 0.0, db = 0.0;
for (int i = 0; i < n; ++i) {
double pred = w * x[i] + b;
double err = pred - y[i];
dw += err * x[i];
db += err;
}
w -= lr * dw / n;
b -= lr * db / n;
}
}
这里有个坑——学习率lr。我曾经调一个模型,lr设成0.1,结果loss直接炸了,数值一路飙到无穷大。后来改成0.001才收敛。所以我的建议是:先从很小的学习率开始试,慢慢往上加。
逻辑回归:不只是「回归」
名字叫回归,干的却是分类的活。逻辑回归本质上是线性回归加了一个sigmoid函数,把输出压缩到0到1之间,表示属于某个类别的概率。
sigmoid函数长这样:
double sigmoid(double z) {
return 1.0 / (1.0 + std::exp(-z));
}
训练逻辑回归用的是最大似然估计,对应的损失函数是交叉熵。我刚开始学的时候总觉得这玩意儿很玄,后来想明白了——其实就是让模型对正确类别的预测概率尽可能大。
来看完整实现:
struct LogisticRegression {
std::vector<double> weights;
double bias = 0.0;
void fit(const std::vector<std::vector<double>>& X,
const std::vector<int>& y,
double lr = 0.01, int epochs = 1000) {
int m = X.size(); // 样本数
int n = X[0].size(); // 特征数
weights.assign(n, 0.0);
bias = 0.0;
for (int epoch = 0; epoch < epochs; ++epoch) {
std::vector<double> dw(n, 0.0);
double db = 0.0;
for (int i = 0; i < m; ++i) {
double z = bias;
for (int j = 0; j < n; ++j)
z += weights[j] * X[i][j];
double pred = sigmoid(z);
double err = pred - y[i];
for (int j = 0; j < n; ++j)
dw[j] += err * X[i][j];
db += err;
}
for (int j = 0; j < n; ++j)
weights[j] -= lr * dw[j] / m;
bias -= lr * db / m;
}
}
double predict_proba(const std::vector<double>& x) const {
double z = bias;
for (size_t j = 0; j < x.size(); ++j)
z += weights[j] * x[j];
return sigmoid(z);
}
int predict(const std::vector<double>& x) const {
return predict_proba(x) >= 0.5 ? 1 : 0;
}
};
知识体系总览
下面这张图把线性回归和逻辑回归的核心脉络梳理了一遍,我建议你保存下来,写代码的时候对照着看:
评估模型:不只是看准确率
模型训练完了,怎么知道它好不好?对于线性回归,我习惯看均方误差(MSE)和R²分数。对于逻辑回归,除了准确率,还要看精确率、召回率和F1分数。
| 指标 | 线性回归 | 逻辑回归 |
|---|---|---|
| MSE | ✓ 常用 | ✗ 不适用 |
| R² | ✓ 常用 | ✗ 不适用 |
| 准确率 | ✗ 不适用 | ✓ 常用 |
| 精确率/召回率 | ✗ 不适用 | ✓ 推荐 |
| F1分数 | ✗ 不适用 | ✓ 推荐 |
完整项目结构
最后,我建议你把代码组织成下面这样,方便复用和测试:
project/
├── include/
│ ├── linear_regression.h
│ └── logistic_regression.h
├── src/
│ ├── linear_regression.cpp
│ └── logistic_regression.cpp
├── tests/
│ ├── test_linear.cpp
│ └── test_logistic.cpp
├── data/
│ ├── housing.csv # 线性回归示例数据
│ └── cancer.csv # 逻辑回归示例数据
└── main.cpp
嗯,这一章的内容就到这里。线性回归和逻辑回归虽然基础,但它们是很多复杂模型的基石。把这两个吃透了,后面学神经网络、支持向量机都会轻松很多。
公众号:蓝海资料掘金营,微信deep3321