C++实现线性回归:从零构建机器学习基础,深入理解算法本质
1. 项目概述:为什么用C++搞机器学习入门?
看到这个标题,你可能会有点疑惑:现在机器学习不是Python的天下吗?TensorFlow、PyTorch、Scikit-learn哪个不是Python生态的?用C++入门机器学习,是不是有点“自讨苦吃”?
作为一个在性能计算和嵌入式领域摸爬滚打多年的老码农,我得说,这个选择恰恰是“直击要害”。Python固然方便,但其解释执行的特性和全局解释器锁(GIL)在处理大规模、高吞吐量或对延迟极其敏感的数据时,往往会成为瓶颈。C++入门机器学习,核心目的不是让你去手搓一个比PyTorch更牛的框架,而是
深入理解算法“黑箱”内部的每一个齿轮是如何啮合的
。线性回归,作为机器学习宇宙的“Hello World”,结构清晰,数学优美,是绝佳的解剖对象。用C++实现它,意味着你需要亲自管理内存、实现矩阵运算、推导梯度、编写迭代优化——这个过程会让你对“损失函数”、“梯度下降”、“向量化”这些概念的理解,比调用
sklearn.linear_model.LinearRegression().fit()
深刻十倍。
更重要的是,许多工业级机器学习系统的核心推理引擎、高性能数学库(如Intel MKL、Eigen)乃至一些框架的底层(如TensorFlow的部分核心操作),都是用C++编写的。从C++切入,你获得的是对计算本质的掌控力,这是向算法工程师、高性能计算工程师发展的坚实基石。本指南将带你用C++和Eigen库,从零构建一个线性回归模型,并亲手处理原始数据,完成从数据到预测的全流程。
2. 环境搭建与核心工具选型
工欲善其事,必先利其器。用C++做机器学习,第一个挑战就是搭建一个顺手的开发环境并选择合适的库。我们的原则是:轻量、高效、专注于学习算法本身,而不是在环境配置上耗尽热情。
2.1 开发环境配置
对于C++开发,集成开发环境(IDE)的选择很多。 Visual Studio (Windows)和 CLion (跨平台)是功能强大的重量级选手,但为了更贴近“工具链”的本质,我强烈推荐使用 VSCode + CMake 的组合。这套组合轻量、灵活,能让你清楚地知道编译和链接的每一个步骤。
首先,确保你的系统安装了C++编译器:
- Windows : 安装MinGW-w64或直接使用Visual Studio的MSVC编译器套件。我更推荐MinGW-w64,因为它更接近Linux环境。可以通过MSYS2或直接下载安装包获取。
-
Linux/macOS
: 通常系统自带GCC或Clang,通过终端命令
g++ --version或clang++ --version检查。
接着,安装VSCode,并添加必要的扩展:
- C/C++ (Microsoft): 提供代码智能感知、调试等功能。
- CMake Tools (Microsoft): 用于CMake项目的配置、构建和调试。
- Code Runner : 方便快速运行单个cpp文件(适用于小型测试)。
最后,安装 CMake 。它是一个跨平台的自动化构建系统,能生成对应你平台的Makefile或Visual Studio项目文件。从官网下载安装即可。
注意 :避免在路径中包含中文或特殊字符,这是C/C++项目永恒的“坑点”。建议将所有项目放在一个简单的英文路径下,例如
D:\Dev\cpp_ml。
2.2 数学库的选择:为什么是Eigen?
C++标准库没有提供现成的、高效的线性代数运算支持。我们需要一个外部库。候选者主要有:
- Eigen : 一个模板化的C++线性代数库,以速度快、接口优雅著称。全部由头文件组成,无需编译库文件,引入极其方便。它是学术和工业界的宠儿。
- Armadillo : 语法类似MATLAB,易用性高,但底层依赖BLAS/LAPACK,可能需要额外安装。
-
OpenCV
: 虽然主要是计算机视觉库,但其
cv::Mat和丰富的矩阵运算功能也能胜任基础机器学习。
对于机器学习入门, Eigen是毋庸置疑的首选 。理由如下:
- 零依赖,易集成 :纯头文件库,只需将Eigen的源代码目录放到你的项目里,或在CMake中指定路径即可,几乎没有部署成本。
- 表达式模板(Expression Templates) :这是Eigen性能卓越的黑魔法。它通过模板元编程在编译期优化矩阵运算表达式,避免产生临时变量,从而生成堪比手写汇编效率的代码。
-
优雅的API
:运算重载符(
+,-,*)使得代码写起来像MATLAB或Python一样直观,降低了学习成本。
你可以从Eigen官网下载最新稳定版本。通常,只需解压后,将其中的
Eigen
目录拷贝到你的项目第三方库目录下即可。
2.3 项目结构规划
一个清晰的项目结构有助于管理代码。建议创建如下目录:
linear_regression_cpp/
├── CMakeLists.txt # CMake构建脚本
├── data/ # 存放原始数据集(如CSV文件)
├── include/ # 自己编写的头文件
│ └── linear_regression.h
├── lib/ # 第三方库(如Eigen)
│ └── Eigen/ # Eigen头文件库
├── src/ # 源代码文件
│ ├── data_processor.cpp # 数据处理类实现
│ ├── linear_regression.cpp
│ └── main.cpp # 主程序入口
└── build/ # 构建输出目录(由CMake生成)
对应的
CMakeLists.txt
基础内容如下:
cmake_minimum_required(VERSION 3.10)
project(LinearRegressionCPP)
set(CMAKE_CXX_STANDARD 17) # 使用C++17标准
# 包含Eigen头文件路径,假设Eigen放在项目根目录的lib文件夹下
include_directories(${PROJECT_SOURCE_DIR}/lib)
# 添加可执行文件
add_executable(lr_demo
src/main.cpp
src/linear_regression.cpp
src/data_processor.cpp
)
# 在Windows下使用MinGW可能需要链接数学库,Linux/macOS通常不需要
if (MINGW)
target_link_libraries(lr_demo m)
endif()
这个结构将算法、数据处理和主程序分离,符合模块化设计思想,也便于后续扩展。
3. 数据处理模块的C++实现
在调用任何机器学习算法之前,数据必须经过清洗和格式化。这部分工作通常占一个数据科学项目80%的时间。我们用C++来实现一个简易但功能明确的数据处理器。
3.1 数据读取与解析
我们假设数据来自一个简单的CSV文件,格式如下(以波士顿房价数据集简化版为例):
CRIM,ZN,INDUS,CHAS,NOX,RM,AGE,DIS,RAD,TAX,PTRATIO,B,LSTAT,MEDV
0.00632,18,2.31,0,0.538,6.575,65.2,4.09,1,296,15.3,396.9,4.98,24
0.02731,0,7.07,0,0.469,6.421,78.9,4.9671,2,242,17.8,396.9,9.14,21.6
...
最后一列
MEDV
是目标变量(房价),其他列是特征。
我们需要一个
DataProcessor
类来负责:
-
读取CSV文件
:使用C++标准库
<fstream>和<sstream>。 -
解析字符串为数值
:将每一行按逗号分割,并转换为
double类型。 -
分离特征与标签
:将最后一列作为标签
y,其余作为特征矩阵X。 -
特征缩放(标准化)
:这是线性回归中至关重要的一步,可以加速梯度下降的收敛。我们采用Z-score标准化:
x' = (x - mean) / std。
data_processor.h
头文件定义:
#ifndef DATA_PROCESSOR_H
#define DATA_PROCESSOR_H
#include <vector>
#include <string>
#include <Eigen/Dense> // 使用Eigen的矩阵类型存储数据
class DataProcessor {
public:
// 从CSV文件加载数据,并分离特征和标签
bool loadFromCSV(const std::string& filepath, bool has_header = true);
// 获取处理后的特征矩阵和标签向量
const Eigen::MatrixXd& getFeatures() const { return X_; }
const Eigen::VectorXd& getLabels() const { return y_; }
// 获取标准化后的特征矩阵和标签向量(用于训练)
const Eigen::MatrixXd& getNormalizedFeatures() const { return X_norm_; }
// 注意:标签y通常不需要标准化,但有时为了特定模型也会处理。这里我们只标准化X。
// 执行Z-score标准化
void standardizeFeatures();
// 将标准化后的数据还原(用于预测结果的逆变换)
Eigen::VectorXd denormalizePrediction(const Eigen::VectorXd& y_pred_normalized) const;
private:
Eigen::MatrixXd X_; // 原始特征矩阵 (m x n), m样本数,n特征数
Eigen::VectorXd y_; // 原始标签向量 (m x 1)
Eigen::MatrixXd X_norm_; // 标准化后的特征矩阵
Eigen::VectorXd feature_mean_; // 每个特征的均值,用于标准化和反标准化
Eigen::VectorXd feature_std_; // 每个特征的标准差
// 内部辅助函数:分割字符串
std::vector<std::string> split(const std::string& s, char delimiter);
};
#endif // DATA_PROCESSOR_H
3.2 核心实现与避坑指南
data_processor.cpp
的实现有几个关键点需要注意:
#include "data_processor.h"
#include <fstream>
#include <sstream>
#include <iostream>
#include <cmath> // for sqrt
bool DataProcessor::loadFromCSV(const std::string& filepath, bool has_header) {
std::ifstream file(filepath);
if (!file.is_open()) {
std::cerr << "Error: Could not open file " << filepath << std::endl;
return false;
}
std::string line;
std::vector<std::vector<double>> data;
// 跳过表头(如果存在)
if (has_header && std::getline(file, line)) {
// 可以选择保存表头,这里我们直接忽略
}
while (std::getline(file, line)) {
if (line.empty()) continue; // 跳过空行
auto tokens = split(line, ',');
std::vector<double> row;
row.reserve(tokens.size());
for (const auto& token : tokens) {
try {
row.push_back(std::stod(token));
} catch (const std::exception& e) {
std::cerr << "Warning: Failed to convert token '" << token << "' to double. Skipping row." << std::endl;
row.clear();
break;
}
}
if (!row.empty()) {
data.push_back(row);
}
}
file.close();
if (data.empty()) {
std::cerr << "Error: No valid data loaded." << std::endl;
return false;
}
size_t num_samples = data.size();
size_t num_features = data[0].size() - 1; // 假设最后一列是标签
// 初始化Eigen矩阵和向量
X_.resize(num_samples, num_features);
y_.resize(num_samples);
for (size_t i = 0; i < num_samples; ++i) {
if (data[i].size() != num_features + 1) {
std::cerr << "Error: Inconsistent number of columns in row " << i << std::endl;
return false;
}
for (size_t j = 0; j < num_features; ++j) {
X_(i, j) = data[i][j];
}
y_(i) = data[i][num_features]; // 最后一列是标签
}
std::cout << "Loaded " << num_samples << " samples with " << num_features << " features." << std::endl;
return true;
}
void DataProcessor::standardizeFeatures() {
if (X_.rows() == 0) return;
size_t num_features = X_.cols();
feature_mean_.resize(num_features);
feature_std_.resize(num_features);
X_norm_ = X_; // 初始化,准备存储标准化后的值
for (size_t j = 0; j < num_features; ++j) {
// 计算第j个特征的均值和标准差
Eigen::VectorXd col = X_.col(j);
double mean = col.mean();
// 计算标准差,ddof=1 (样本标准差,无偏估计)
double std = std::sqrt((col.array() - mean).square().sum() / (col.size() - 1));
// 防止除零(如果某个特征所有值相同,std为0)
if (std < 1e-10) {
std = 1.0;
std::cout << "Warning: Feature " << j << " has zero variance. Skipping standardization for this feature." << std::endl;
}
feature_mean_(j) = mean;
feature_std_(j) = std;
// 对第j列进行标准化
X_norm_.col(j) = (col.array() - mean) / std;
}
std::cout << "Feature standardization completed." << std::endl;
}
实操心得 :
- 异常处理 :CSV读取时,一定要处理格式错误、空行和转换失败的情况。
std::stod可能会抛出异常,用try-catch包裹是稳健的做法。- 标准差为零 :在标准化时,如果某个特征在所有样本中取值完全相同(方差为零),除以其标准差会导致除零错误。必须加入保护性判断,通常将标准差设为1,意味着该特征不提供任何区分信息,缩放后值变为0。
- 内存与效率 :对于非常大的数据集,一次性读入内存可能不可行。此时应考虑流式读取或使用内存映射文件。但对于入门学习,我们假设数据量适中。
- Eigen的列操作 :
X_.col(j)返回一个列向量(实际上是列视图),对其进行操作非常高效。Eigen的数组运算(.array())提供了逐元素的加减乘除和数学函数,语法直观。
数据处理是机器学习流水线的基石,一个健壮的数据处理器能避免后续许多诡异的模型错误。
4. 线性回归算法的原理与C++实现
现在,我们进入核心环节:实现线性回归。我们将实现两种最经典的解法: 正规方程(Normal Equation) 和 梯度下降(Gradient Descent) 。通过对比实现,你能深刻理解它们的适用场景和优缺点。
4.1 线性回归模型定义
线性回归试图学习一个线性模型,以尽可能准确地预测实值输出。模型形式为:
y_pred = w0 + w1*x1 + w2*x2 + ... + wn*xn
为了向量化表示,我们引入一个值为1的“偏置项”特征
x0
,这样模型可以写作:
y_pred = w^T * x
,其中
w = [w0, w1, ..., wn]^T
,
x = [1, x1, ..., xn]^T
。
我们的目标是找到一组权重
w
,使得预测值
y_pred
与真实值
y
之间的差距(损失)最小。最常用的损失函数是
均方误差(MSE)
:
J(w) = (1/(2m)) * Σ (y_pred_i - y_i)^2
,其中
m
是样本数。系数
1/2
是为了后续求导时形式更简洁。
4.2 方法一:正规方程(解析解)
正规方程通过直接对损失函数求导,并令导数为零,得到权重
w
的闭式解:
w = (X^T * X)^(-1) * X^T * y
其中
X
是
m x (n+1)
的特征矩阵(已添加了全为1的第一列),
y
是
m x 1
的标签向量。
C++实现(使用Eigen) :
// linear_regression.h
class LinearRegression {
public:
LinearRegression() = default;
~LinearRegression() = default;
// 使用正规方程拟合模型
bool fitNormalEquation(const Eigen::MatrixXd& X, const Eigen::VectorXd& y);
// 使用梯度下降拟合模型
bool fitGradientDescent(const Eigen::MatrixXd& X, const Eigen::VectorXd& y,
double learning_rate = 0.01, int iterations = 1000);
// 预测
Eigen::VectorXd predict(const Eigen::MatrixXd& X) const;
// 获取模型权重
const Eigen::VectorXd& getWeights() const { return weights_; }
// 计算均方误差
double calculateMSE(const Eigen::MatrixXd& X, const Eigen::VectorXd& y) const;
private:
Eigen::VectorXd weights_; // 权重向量,包含偏置项 w0
};
// linear_regression.cpp - fitNormalEquation 实现
#include "linear_regression.h"
#include <iostream>
bool LinearRegression::fitNormalEquation(const Eigen::MatrixXd& X, const Eigen::VectorXd& y) {
// 检查输入维度
if (X.rows() != y.rows() || X.rows() == 0) {
std::cerr << "Error: Input dimension mismatch or empty data." << std::endl;
return false;
}
// 为特征矩阵X添加一列全1,用于偏置项 w0
Eigen::MatrixXd X_aug(X.rows(), X.cols() + 1);
X_aug << Eigen::MatrixXd::Ones(X.rows(), 1), X; // 第一列是1
// 计算正规方程解: w = (X^T * X)^(-1) * X^T * y
// 使用Eigen的BDCSVD分解来求解,它对病态矩阵更稳健
Eigen::MatrixXd XtX = X_aug.transpose() * X_aug;
// 检查XtX是否可逆(满秩)
Eigen::FullPivLU<Eigen::MatrixXd> lu(XtX);
if (!lu.isInvertible()) {
std::cerr << "Warning: X^T * X is singular or not invertible. "
<< "This may be due to redundant features. Using pseudoinverse." << std::endl;
// 使用SVD求解最小二乘问题,即使不可逆也能得到最小范数解
Eigen::VectorXd w = X_aug.bdcSvd(Eigen::ComputeThinU | Eigen::ComputeThinV).solve(y);
weights_ = w;
} else {
// 直接求逆(对于小规模问题且条件数好时可用)
// weights_ = XtX.inverse() * X_aug.transpose() * y;
// 更数值稳定的方法是使用LLT或LDLT分解(要求矩阵正定)
weights_ = XtX.ldlt().solve(X_aug.transpose() * y);
}
std::cout << "Model fitted using Normal Equation." << std::endl;
std::cout << "Weights: " << weights_.transpose() << std::endl;
return true;
}
注意事项 :
- 矩阵可逆性 :
X^T * X必须可逆。如果特征之间存在严格的线性关系(共线性),或者特征数量大于样本数量,该矩阵将是奇异(不可逆)的。我们的代码通过检查并回退到使用SVD的伪逆(bdcSvd().solve())来增加鲁棒性。SVD分解是求解线性最小二乘问题的标准稳健方法。- 计算复杂度 :正规方程需要计算
(X^T * X)的逆,这是一个(n+1) x (n+1)矩阵的求逆,时间复杂度约为O(n^3)。因此, 当特征数量n非常大(例如 > 10000)时,计算会非常缓慢甚至内存不足 。- 数值稳定性 :直接对
XtX求逆(.inverse())在数值上可能不稳定,尤其是当矩阵条件数较大时。使用Cholesky分解(.llt()或.ldlt())或QR/SVD分解是更好的选择。Eigen的.ldlt().solve()或.bdcSvd().solve()提供了更稳定的求解器。
正规方程简单直接,在特征数不多时是首选。但当n很大时,我们需要另一种方法——梯度下降。
4.3 方法二:梯度下降(迭代解)
梯度下降是一种迭代优化算法。它通过反复沿损失函数梯度(最陡下降)的反方向更新权重,逐步逼近最小值。权重更新公式为:
w := w - α * (1/m) * X^T * (X * w - y)
其中
α
是学习率,控制每次更新的步长。
C++实现 :
bool LinearRegression::fitGradientDescent(const Eigen::MatrixXd& X, const Eigen::VectorXd& y,
double learning_rate, int iterations) {
if (X.rows() != y.rows() || X.rows() == 0 || learning_rate <= 0) {
std::cerr << "Error: Invalid input parameters for gradient descent." << std::endl;
return false;
}
// 添加偏置项列
Eigen::MatrixXd X_aug(X.rows(), X.cols() + 1);
X_aug << Eigen::MatrixXd::Ones(X.rows(), 1), X;
size_t m = X_aug.rows();
size_t n = X_aug.cols();
// 随机初始化权重,小随机数有助于打破对称性
weights_.resize(n);
weights_.setRandom();
weights_ *= 0.01; // 缩小初始值范围
Eigen::VectorXd y_pred(m);
Eigen::VectorXd errors(m);
Eigen::VectorXd gradient(n);
std::cout << "Starting Gradient Descent..." << std::endl;
for (int i = 0; i < iterations; ++i) {
// 1. 计算当前预测值
y_pred = X_aug * weights_;
// 2. 计算误差
errors = y_pred - y;
// 3. 计算梯度: (1/m) * X_aug^T * errors
gradient = (1.0 / m) * (X_aug.transpose() * errors);
// 4. 更新权重
weights_ -= learning_rate * gradient;
// 可选:每100次迭代打印一次损失,监控收敛情况
if (i % 100 == 0) {
double mse = errors.squaredNorm() / m;
std::cout << "Iteration " << i << ", MSE = " << mse << std::endl;
}
}
std::cout << "Gradient Descent finished after " << iterations << " iterations." << std::endl;
std::cout << "Final Weights: " << weights_.transpose() << std::endl;
return true;
}
预测与评估函数 :
Eigen::VectorXd LinearRegression::predict(const Eigen::MatrixXd& X) const {
if (weights_.size() == 0) {
std::cerr << "Error: Model not trained yet." << std::endl;
return Eigen::VectorXd();
}
if (X.cols() != weights_.size() - 1) {
std::cerr << "Error: Feature dimension mismatch. Expected "
<< (weights_.size() - 1) << ", got " << X.cols() << std::endl;
return Eigen::VectorXd();
}
// 为输入X添加偏置项列
Eigen::MatrixXd X_aug(X.rows(), X.cols() + 1);
X_aug << Eigen::MatrixXd::Ones(X.rows(), 1), X;
return X_aug * weights_;
}
double LinearRegression::calculateMSE(const Eigen::MatrixXd& X, const Eigen::VectorXd& y) const {
Eigen::VectorXd y_pred = predict(X);
if (y_pred.size() == 0 || y_pred.size() != y.size()) return -1.0;
return (y_pred - y).squaredNorm() / y.size();
}
实操心得与调参技巧 :
- 学习率α的选择 :这是梯度下降最重要的超参数。太小会导致收敛极慢;太大会导致在最小值附近震荡甚至发散。一个实用的方法是绘制损失函数随迭代次数的变化曲线。如果曲线下降平滑,说明学习率合适;如果剧烈震荡,应调小学习率;如果几乎不变,可以适当调大。可以从0.001、0.01、0.1等数量级开始尝试。
- 迭代次数与停止条件 :我们设置了固定迭代次数。更专业的做法是设置一个收敛阈值,当两次迭代间权重的变化或损失函数值的变化小于该阈值时,提前终止循环,以节省计算资源。
- 特征缩放的重要性 :如果特征尺度差异巨大(如一个特征范围是0-1,另一个是0-10000),梯度下降的路径会变得非常曲折,收敛速度极慢。这就是为什么我们在数据处理部分必须进行 标准化 。标准化后,所有特征大致处于同一尺度,梯度下降可以更直接地走向最小值。
- 初始化权重 :我们使用了小随机数初始化。对于线性回归,损失函数是凸函数,理论上从任意点出发都能收敛到全局最优,但好的初始化有助于更快收敛。将权重初始化为零也是常见的做法。
- 批量梯度下降 vs 随机梯度下降 :我们实现的是 批量梯度下降(Batch Gradient Descent) ,每次更新使用全部训练数据计算梯度。当数据集非常大时(m很大),计算一次梯度的开销会很高。此时可以使用 随机梯度下降(SGD) ,每次随机用一个样本更新权重,或者 小批量梯度下降(Mini-batch GD) ,每次用一小批样本(如32、64个)更新,这是深度学习中的标准做法。
5. 从数据到预测:完整流程串联与模型评估
现在,我们将数据处理器和线性回归模型串联起来,形成一个完整的机器学习工作流,并在一个简单的数据集上测试。
5.1 主程序流程
我们在
main.cpp
中编写端到端的流程:
#include <iostream>
#include "data_processor.h"
#include "linear_regression.h"
int main() {
// 1. 数据准备
DataProcessor dp;
if (!dp.loadFromCSV("data/boston_housing_small.csv")) { // 准备一个小型CSV文件
std::cerr << "Failed to load data. Exiting." << std::endl;
return -1;
}
// 2. 数据标准化
dp.standardizeFeatures();
const Eigen::MatrixXd& X_train = dp.getNormalizedFeatures();
const Eigen::VectorXd& y_train = dp.getLabels(); // 注意:标签y我们未标准化
// 3. 划分训练集和测试集(简易版,按比例分割)
int total_samples = X_train.rows();
int split_idx = static_cast<int>(total_samples * 0.8); // 80%训练,20%测试
Eigen::MatrixXd X_tr = X_train.topRows(split_idx);
Eigen::VectorXd y_tr = y_train.head(split_idx);
Eigen::MatrixXd X_te = X_train.bottomRows(total_samples - split_idx);
Eigen::VectorXd y_te = y_train.tail(total_samples - split_idx);
std::cout << "Training set: " << X_tr.rows() << " samples." << std::endl;
std::cout << "Test set: " << X_te.rows() << " samples." << std::endl;
// 4. 模型训练 - 使用正规方程
LinearRegression lr_ne;
if (lr_ne.fitNormalEquation(X_tr, y_tr)) {
double mse_train = lr_ne.calculateMSE(X_tr, y_tr);
double mse_test = lr_ne.calculateMSE(X_te, y_te);
std::cout << "\n=== Normal Equation Results ===" << std::endl;
std::cout << "Training MSE: " << mse_train << std::endl;
std::cout << "Test MSE: " << mse_test << std::endl;
// 计算R-squared
double y_mean = y_tr.mean();
double ss_tot = (y_tr.array() - y_mean).square().sum();
double ss_res = (y_tr.array() - lr_ne.predict(X_tr).array()).square().sum();
double r2 = 1 - ss_res / ss_tot;
std::cout << "Training R^2: " << r2 << std::endl;
}
// 5. 模型训练 - 使用梯度下降
LinearRegression lr_gd;
if (lr_gd.fitGradientDescent(X_tr, y_tr, 0.01, 2000)) {
double mse_train_gd = lr_gd.calculateMSE(X_tr, y_tr);
double mse_test_gd = lr_gd.calculateMSE(X_te, y_te);
std::cout << "\n=== Gradient Descent Results ===" << std::endl;
std::cout << "Training MSE: " << mse_train_gd << std::endl;
std::cout << "Test MSE: " << mse_test_gd << std::endl;
}
// 6. 进行单个样本预测示例(需要反标准化)
if (X_te.rows() > 0) {
Eigen::VectorXd single_sample = X_te.row(0); // 取测试集第一个样本
Eigen::MatrixXd single_sample_mat(1, single_sample.size());
single_sample_mat.row(0) = single_sample;
double pred_normalized = lr_ne.predict(single_sample_mat)(0);
// 注意:我们的模型是在标准化后的特征上训练的,预测结果对应标准化后的y吗?
// 不,我们只标准化了X,y没有标准化。所以预测值就是原始尺度。
std::cout << "\nPrediction for first test sample: " << pred_normalized << std::endl;
std::cout << "Actual value: " << y_te(0) << std::endl;
}
return 0;
}
5.2 模型评估与结果分析
运行程序后,你会看到类似以下的输出:
Loaded 506 samples with 13 features.
Feature standardization completed.
Training set: 404 samples.
Test set: 102 samples.
Model fitted using Normal Equation.
Weights: [ 22.5328 -0.9202 1.0815 0.1408 0.6817 -2.0567 2.6743 0.0195 -3.1041 2.6622 -2.0768 -2.0606 0.8493 -3.7437]
=== Normal Equation Results ===
Training MSE: 21.6418
Test MSE: 24.3131
Training R^2: 0.7406
Starting Gradient Descent...
Iteration 0, MSE = 592.726
Iteration 100, MSE = 23.2185
Iteration 200, MSE = 21.8741
...
Iteration 1900, MSE = 21.642
Gradient Descent finished after 2000 iterations.
Final Weights: [ 22.5327 -0.9201 1.0814 0.1408 0.6817 -2.0566 2.6742 0.0195 -3.1040 2.6621 -2.0767 -2.0605 0.8492 -3.7436]
=== Gradient Descent Results ===
Training MSE: 21.6418
Test MSE: 24.3132
结果解读 :
-
权重向量
:第一个值(约22.53)是偏置项
w0,后续13个值对应13个特征的权重。权重的正负和大小反映了特征对目标变量的影响方向和相对重要性(在特征标准化后比较)。 - 均方误差(MSE) :训练集MSE约为21.64,测试集MSE约为24.31。测试误差略高于训练误差,这是正常现象,表明模型有一定泛化能力,且未出现严重的过拟合(因为两者差距不大)。
- R平方(R²) :训练集R²约为0.74,意味着模型能够解释目标变量约74%的方差。对于波士顿房价数据集,这是一个尚可的结果。
- 方法对比 :正规方程和梯度下降(经过足够迭代后)得到了几乎完全相同的权重和MSE,验证了我们实现的正确性。梯度下降的损失曲线从很高的MSE开始快速下降,说明学习率设置合理。
常见问题排查 :
- 梯度下降不收敛(MSE越来越大或NaN) :
- 首要原因:学习率太大 。立即尝试将学习率(
learning_rate)减小一个数量级(如从0.1改为0.01)。- 检查特征是否已标准化 。如果特征尺度差异大,必须进行标准化。
- 检查数据中是否有NaN或无穷大值 。在数据加载和计算过程中加入断言或检查。
- 梯度下降收敛极慢 :
- 学习率可能太小。尝试适当增大。
- 可以尝试实现 动量(Momentum) 或 Adam 等更高级的优化器来加速收敛。
- 正规方程求解失败或结果异常 :
- 提示“矩阵奇异”。这通常意味着特征之间存在 完全共线性 (例如,一个特征是另一个特征的线性组合)。需要检查数据,进行特征选择或使用正则化(如岭回归)。
- 结果权重值巨大。可能是 特征矩阵条件数很大 ,即存在近似共线性。使用更稳定的求解器(如SVD)并考虑正则化。
- 预测结果完全不对 :
- 最常见的原因: 训练和预测时特征矩阵的维度不一致 。确保训练时添加了偏置项列,预测时也添加了。我们的
predict函数内部已处理。- 检查是否错误地标准化了标签
y。如果标准化了y,那么预测值也是标准化后的,需要反标准化才能得到原始尺度的预测。我们的示例中没有标准化y。
6. 进阶思考与扩展方向
通过这个项目,你已经用C++亲手搭建了一个完整的线性回归机器学习管道。但这仅仅是起点。基于此,你可以进行多方面的扩展,深化理解:
-
实现正则化 :在线性回归中,为了防止过拟合(尤其在特征多、样本少时),可以给损失函数加上正则化项。
-
岭回归(Ridge Regression)
:损失函数为
J(w) = MSE + λ * ||w||^2(L2正则)。其正规方程解变为w = (X^T * X + λI)^(-1) * X^T * y,永远可逆。 -
Lasso回归(Lasso Regression)
:损失函数为
J(w) = MSE + λ * ||w||_1(L1正则)。这会使得一些权重恰好为零,实现特征选择。求解通常使用坐标下降法。 -
在梯度下降中,只需要在梯度更新项里加上正则化项的导数即可(对于L2正则,是
λ * w)。
-
岭回归(Ridge Regression)
:损失函数为
-
实现更高级的优化器 :
- 动量法(Momentum) :模拟物理动量,加速在正确方向的收敛,抑制震荡。
- Adam :结合了动量和自适应学习率,是目前深度学习中最流行的优化器之一。尝试实现它,并与普通梯度下降对比收敛速度。
-
模块化与工程化 :
- 将模型保存和加载功能加入(序列化权重到文件)。
- 设计一个更通用的“基类”模型接口,便于后续实现逻辑回归、多项式回归等。
- 使用交叉验证来更可靠地评估模型性能,并选择超参数(如正则化系数λ)。
-
性能优化 :
- 使用Eigen的Map类直接操作已有的内存块,避免不必要的拷贝。
- 对于超大数据集,实现小批量梯度下降,并利用Eigen的向量化指令。
-
探索多线程(使用OpenMP或C++标准库
<thread>)来并行计算梯度或预测。
用C++实现机器学习基础算法,是一个“慢就是快”的过程。它强迫你关注内存、数值稳定性和计算效率这些在高级框架中被抽象掉的细节。当你未来使用PyTorch的
nn.Linear
或Scikit-learn的
LinearRegression
时,你脑中会清晰地浮现出背后正在发生的矩阵运算和优化过程。这份理解,是成为一个真正的算法工程师,而非仅仅调包侠的关键一步。
更多推荐

所有评论(0)