69、C++项目实战:线性回归与逻辑回归

说实话,很多搞C++的朋友一听到「回归」两个字,第一反应就是「这不是Python的活吗?」。我以前也这么想,直到我在一个嵌入式预测项目里被逼着用C++做推理——模型已经训练好了,但设备上压根跑不了Python。嗯,从那以后我就老老实实把线性回归和逻辑回归用C++撸了一遍。

这一章,我们就来聊聊怎么用纯C++实现这两个最经典的机器学习模型。不依赖任何第三方库,就靠标准库和一点点数学功底。

线性回归:从一条直线说起

线性回归说白了就是找一条直线,让它尽可能穿过所有的数据点。你想想看,给定一堆(x, y)点,我们想找到y = wx + b里的w和b。

怎么找?最常用的方法是最小二乘法。我习惯用正规方程直接求解,尤其是数据量不大的时候,简单粗暴。

核心公式: θ = (XTX)-1XTy

这里θ就是我们要的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;
    }
};

我在项目中遇到过一个问题:数据量一旦上了几十万,正规方程里的矩阵求逆就变得非常慢。这时候我一般改用梯度下降法,虽然迭代次数多,但每一步的计算量小很多。

小技巧: 如果特征维度很高(比如上千维),正规方程的时间复杂度是O(n³),基本跑不动。这时候梯度下降是更好的选择。

梯度下降法实现

梯度下降的思路很简单:沿着误差函数的负梯度方向,一步步调整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才收敛。所以我的建议是:先从很小的学习率开始试,慢慢往上加。

注意: 梯度下降对特征尺度敏感。如果x的范围是0到100000,y的范围是0到1,梯度会非常不稳定。记得先做归一化!

逻辑回归:不只是「回归」

名字叫回归,干的却是分类的活。逻辑回归本质上是线性回归加了一个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;
    }
};
关键点: 逻辑回归的决策边界是线性的。如果你的数据分布是螺旋状或者环形,逻辑回归基本无能为力。这时候可以考虑加多项式特征,或者直接上核方法。

知识体系总览

下面这张图把线性回归和逻辑回归的核心脉络梳理了一遍,我建议你保存下来,写代码的时候对照着看:

线性回归 vs 逻辑回归 知识体系 线性回归 正规方程 梯度下降 O(n³) 小数据 O(n) 大数据 逻辑回归 Sigmoid 交叉熵 概率输出 二分类 共同基础:梯度下降 + 损失函数 线性回归预测连续值,逻辑回归预测离散类别 两者都可用梯度下降训练,核心区别在输出层和损失函数

评估模型:不只是看准确率

模型训练完了,怎么知道它好不好?对于线性回归,我习惯看均方误差(MSE)和R²分数。对于逻辑回归,除了准确率,还要看精确率、召回率和F1分数。

指标 线性回归 逻辑回归
MSE ✓ 常用 ✗ 不适用
✓ 常用 ✗ 不适用
准确率 ✗ 不适用 ✓ 常用
精确率/召回率 ✗ 不适用 ✓ 推荐
F1分数 ✗ 不适用 ✓ 推荐
我的经验: 分类问题里如果正负样本严重不平衡(比如99:1),准确率就是骗人的。这时候看精确率和召回率才有意义。我曾经在一个欺诈检测项目里,模型准确率99.5%,但所有欺诈样本都没抓住——就是因为只看准确率踩的坑。

完整项目结构

最后,我建议你把代码组织成下面这样,方便复用和测试:

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