从零手搓C++机器学习库:深入理解计算图与自动微分原理
如果你是一名C++开发者,想深入理解机器学习的“黑盒”,或者想为嵌入式、高性能计算场景打造一个轻量级、零依赖的推理引擎,那么“从零手搓”一个机器学习库,可能是比直接调用PyTorch、TensorFlow更有价值的修炼。
很多人以为学习机器学习就是调包和调参,但当你需要将模型部署到资源受限的边缘设备,或者需要极致优化推理性能时,你会发现,对底层计算图、张量运算和自动求导机制的理解至关重要。直接使用成熟的框架固然高效,但也像开着一辆封装完好的自动驾驶汽车,你并不知道引擎盖下每个齿轮是如何咬合的。
本文将带你用纯C++,从最基础的张量(Tensor)开始,一步步构建一个具备前向推理和反向传播能力的微型机器学习库。我们不会使用任何第三方线性代数库(如Eigen),而是亲手实现核心运算。通过这个过程,你将彻底搞懂:
- 计算图(Computational Graph) 是如何在内存中构建和执行的。
- 自动微分(Autograd) 的魔力背后,反向传播的梯度是如何沿着计算图回溯的。
- 激活函数(如ReLU)、损失函数(如MSE)是如何无缝嵌入到这个体系中的。
- 如何用面向对象和模板元编程等现代C++特性,优雅地设计这样一个库。
最终,我们将用它训练一个简单的多层感知机(MLP)来解决异或(XOR)问题,并验证其正确性。这不仅是一次编程练习,更是一次对机器学习系统底层原理的深度剖析。你会发现,许多看似复杂的框架概念,其核心思想竟如此清晰和直观。
1. 为什么你要亲手实现一个机器学习库?
在Python生态中,
import torch
或
import tensorflow
几乎是一切机器学习项目的起点。那么,为什么我们要“倒退”去用C++重造轮子?这绝不是为了替代这些工业级框架,而是为了达成几个更重要的目标:
1. 穿透抽象,掌握本质: 高级框架提供了极大的便利,但也隐藏了底层细节。亲手实现张量运算、计算图构建和反向传播,能让你真正理解“前向传播时数据如何流动,反向传播时梯度如何计算并更新参数”。这种理解是解决复杂模型调试、定制新算子、进行模型压缩和量化等高级任务的基础。
2. 面向部署与性能优化: C++是高性能计算和嵌入式系统的主流语言。许多生产环境中的模型推理服务(如推荐系统、自动驾驶感知)最终都以C++库的形式存在。了解如何用C++组织计算、管理内存,对于优化推理延迟、减少内存占用至关重要。从零开始,你将对每一处性能开销都了如指掌。
3. 深化C++编程能力:
这个项目将综合运用现代C++的多个核心特性:
*
模板编程:
用于实现数据类型(
float
,
double
)无关的张量类。
*
智能指针与所有权语义:
管理计算图中节点(
Node
)的生命周期,避免内存泄漏。
*
运算符重载:
让张量运算(如
a + b
)的代码更直观。
*
面向对象设计:
设计
Tensor
、
Node
、
Function
等基类与继承体系,构建清晰的计算图抽象。
4. 构建属于自己的“教学工具”: 完成后的库虽然功能简单,但其架构清晰地反映了主流框架的核心思想。它可以作为你未来学习更复杂概念(如动态图/静态图、分布式训练、算子融合)的参考蓝图。
谁最适合阅读本文?
- 有一定C++基础(熟悉类、模板、智能指针),想向机器学习系统领域深入的开发者。
- 使用PyTorch/TensorFlow但想知其所以然的机器学习实践者。
- 对高性能计算、模型部署感兴趣,需要理解底层机制的工程师。
- 任何希望通过一个综合性项目来提升自己系统编程能力的编程爱好者。
2. 核心概念与设计蓝图
在开始写代码之前,我们需要确立整个库的顶层设计。我们的微型库将包含以下几个核心模块,它们的关系如下图所示(概念图):
[用户代码] --创建/调用--> [Tensor] --依赖--> [Node & Function] --组成--> [Computational Graph]
| |
|(存储数据/梯度) |(执行计算/反向传播)
+------------------------------------------------------------+
2.1 核心概念解析
1. 张量 (Tensor):
这是数据的核心载体。你可以把它看作一个多维数组。在我们的设计中,
Tensor
类不仅存储数据(
data
),还会存储梯度(
grad
)以及指向计算图中一个节点(
Node
)的指针。这个设计使得
Tensor
“知道”它是如何被计算出来的,这是实现自动微分的关键。
2. 节点 (Node) 与计算函数 (Function):
计算图中的每个操作(如加法、矩阵乘法、ReLU)都对应一个
Node
。
Node
负责记录:
-
它执行了哪个
Function(操作类型)。 -
它的输入
Tensor是哪些(inputs)。 -
它的输出
Tensor是哪个。 -
在反向传播时,如何根据输出梯度计算输入梯度(即实现
backward方法)。
Function
是一个抽象基类,具体的运算(如
AddFunction
、
MatMulFunction
、
ReLUFunction
)继承它并实现前向(
forward
)和反向(
backward
)方法。
3. 计算图 (Computational Graph):
这是一系列
Node
通过输入输出关系连接而成的有向无环图(DAG)。前向传播就是按照图的依赖顺序执行每个
Node
的
forward
方法。反向传播则是从损失函数对应的节点开始,逆序调用每个
Node
的
backward
方法,将梯度一直传播到可训练参数(如权重
Weight
)对应的
Tensor
。
4. 自动微分 (Autograd):
这是我们库的“引擎”。当设置
Tensor::requires_grad = true
时,任何基于该
Tensor
的操作都会在背后构建计算图。调用
Tensor::backward()
方法会触发从该
Tensor
开始的反向传播过程,自动计算并填充所有相关
Tensor
的
.grad
成员。
2.2 与主流框架的对比
为了让你更清楚我们的设计定位,这里用一个表格对比:
| 特性 | 我们的微型库 | PyTorch (Eager Mode) | TensorFlow 1.x (Graph Mode) |
|---|---|---|---|
| 核心抽象 |
Tensor
+
Node
+
Function
|
torch.Tensor
+
torch.autograd.Function
|
tf.Tensor
+
tf.Operation
+
tf.Graph
|
| 计算图构建 | 动态图(操作即构建) | 动态图(操作即构建) | 静态图(先定义,后执行) |
| 自动微分 | 基于链式法则的反向传播 | 基于链式法则的反向传播 | 基于链式法则的反向传播 |
| 张量运算 | 手写循环实现(教学目的) | 底层调用高效库(如ATen, cuBLAS) | 底层调用高效库(如Eigen, cuBLAS) |
| 目标 | 教学、理解原理 | 灵活研究与快速原型 | 高性能生产部署(历史版本) |
我们的设计更接近PyTorch的动态图模式,这是目前最直观、最容易理解的方式。
3. 环境准备与项目结构
我们将在标准C++17环境下进行开发,不使用任何第三方数学库。你只需要一个支持C++17的编译器。
编译器与构建工具:
- GCC >= 7 或 Clang >= 5 或 MSVC (Visual Studio 2017及以上)。
-
构建工具可以使用简单的
Makefile、CMake或者直接使用IDE的构建系统。
项目目录结构: 在开始前,建议创建如下清晰的目录结构,这有助于管理代码。
mini_ml/
├── include/ # 头文件
│ ├── tensor.h # Tensor类定义
│ ├── node.h # Node类定义
│ ├── functions.h # 所有Function子类定义 (Add, Mul, ReLU, etc.)
│ ├── nn/ # 神经网络层
│ │ ├── module.h # 模块基类
│ │ └── linear.h # 全连接层
│ └── optim/ # 优化器
│ └── sgd.h # 随机梯度下降优化器
├── src/ # 源文件
│ ├── tensor.cpp
│ ├── node.cpp
│ ├── functions.cpp
│ ├── nn/
│ │ └── linear.cpp
│ └── optim/
│ └── sgd.cpp
├── test/ # 测试代码
│ └── test_xor.cpp # XOR问题训练测试
└── CMakeLists.txt # 或 Makefile
创建基础构建文件 (CMakeLists.txt):
如果你使用CMake,一个最简化的根目录
CMakeLists.txt
如下:
cmake_minimum_required(VERSION 3.10)
project(mini_ml LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 将头文件目录加入包含路径
include_directories(${PROJECT_SOURCE_DIR}/include)
# 添加可执行文件:测试XOR问题
add_executable(test_xor test/test_xor.cpp
src/tensor.cpp
src/node.cpp
src/functions.cpp
src/nn/linear.cpp
src/optim/sgd.cpp
)
target_include_directories(test_xor PRIVATE ${PROJECT_SOURCE_DIR}/include)
现在,环境已经就绪。让我们从最基础的
Tensor
类开始构建。
4. 核心实现:Tensor、Node与计算图
4.1 实现Tensor类
Tensor
是我们的数据核心。它需要存储数据、梯度,并关联到一个计算节点。
// file: include/tensor.h
#ifndef MINI_ML_TENSOR_H
#define MINI_ML_TENSOR_H
#include <memory>
#include <vector>
#include <iostream>
class Node; // 前向声明
class Tensor {
public:
// 构造函数
Tensor(const std::vector<float>& data, const std::vector<int>& shape, bool requires_grad = false);
// 从数据指针创建(接管所有权)
Tensor(std::unique_ptr<float[]> data, const std::vector<int>& shape, bool requires_grad = false);
// 创建全零/全一张量
static Tensor zeros(const std::vector<int>& shape, bool requires_grad = false);
static Tensor ones(const std::vector<int>& shape, bool requires_grad = false);
~Tensor() = default;
// 获取信息
const std::vector<int>& shape() const { return shape_; }
int ndim() const { return shape_.size(); }
int numel() const { return numel_; }
const float* data() const { return data_.get(); }
float* data() { return data_.get(); }
const float* grad() const { return grad_.get(); }
float* grad() { return grad_.get(); }
// 自动微分相关
bool requires_grad() const { return requires_grad_; }
void set_requires_grad(bool requires_grad) { requires_grad_ = requires_grad; }
std::shared_ptr<Node> node() const { return node_; }
void set_node(const std::shared_ptr<Node>& node) { node_ = node; }
// 触发反向传播
void backward();
// 访问元素 (简化版,仅用于演示)
float& operator[](int index);
const float& operator[](int index) const;
// 格式化输出
friend std::ostream& operator<<(std::ostream& os, const Tensor& t);
private:
std::unique_ptr<float[]> data_; // 数据存储
std::unique_ptr<float[]> grad_; // 梯度存储
std::vector<int> shape_; // 张量形状
int numel_; // 元素总数
bool requires_grad_; // 是否需要梯度
std::shared_ptr<Node> node_; // 关联的计算节点
void allocate_grad(); // 按需分配梯度内存
};
#endif // MINI_ML_TENSOR_H
对应的源文件需要实现内存分配、
backward()
方法等。
// file: src/tensor.cpp
#include "tensor.h"
#include "node.h"
#include <cstring>
#include <numeric>
Tensor::Tensor(const std::vector<float>& data, const std::vector<int>& shape, bool requires_grad)
: shape_(shape), requires_grad_(requires_grad), node_(nullptr) {
numel_ = std::accumulate(shape.begin(), shape.end(), 1, std::multiplies<int>());
if (numel_ != static_cast<int>(data.size())) {
throw std::runtime_error("Data size does not match shape.");
}
data_ = std::make_unique<float[]>(numel_);
std::copy(data.begin(), data.end(), data_.get());
if (requires_grad_) {
allocate_grad();
}
}
void Tensor::allocate_grad() {
if (!grad_) {
grad_ = std::make_unique<float[]>(numel_);
std::fill(grad_.get(), grad_.get() + numel_, 0.0f);
}
}
void Tensor::backward() {
if (node_ == nullptr) {
// 这是一个叶子节点(用户创建的Tensor),通常梯度初始化为1(对于损失函数)
if (requires_grad_) {
allocate_grad();
if (numel_ == 1) {
grad_[0] = 1.0f; // 标量损失对自身的导数为1
} else {
// 对于非标量,需要传入一个全1的梯度张量,这里简化处理
// 实际框架中,backward()通常只对标量调用
throw std::runtime_error("backward() can only be called on a scalar tensor.");
}
}
return;
}
// 构建梯度张量(这里简化,假设当前Tensor是标量损失)
if (numel_ != 1) {
throw std::runtime_error("backward() can only be called on a scalar tensor.");
}
allocate_grad();
grad_[0] = 1.0f;
// 调用节点的反向传播
node_->backward();
}
// 其他成员函数实现...
关键点解析:
-
data_和grad_使用std::unique_ptr<float[]>管理,确保内存自动释放。 -
node_是一个std::shared_ptr<Node>,因为一个Node可能被多个Tensor引用(例如多个输出)。 -
backward()方法是自动微分的入口。它首先确保当前张量(通常是损失值)的梯度为1,然后递归调用其关联Node的backward()方法。
4.2 实现Node与Function基类
Node
是计算图的节点,它封装了一个具体的
Function
。
// file: include/node.h
#ifndef MINI_ML_NODE_H
#define MINI_ML_NODE_H
#include <memory>
#include <vector>
#include "tensor.h"
class Function; // 前向声明
class Node {
public:
Node(std::shared_ptr<Function> func,
const std::vector<std::shared_ptr<Tensor>>& inputs,
const std::shared_ptr<Tensor>& output);
// 执行前向传播(通常由Function调用)
void forward();
// 执行反向传播
void backward();
const std::vector<std::shared_ptr<Tensor>>& inputs() const { return inputs_; }
std::shared_ptr<Tensor> output() const { return output_; }
std::shared_ptr<Function> func() const { return func_; }
private:
std::shared_ptr<Function> func_;
std::vector<std::shared_ptr<Tensor>> inputs_;
std::shared_ptr<Tensor> output_;
};
#endif // MINI_ML_NODE_H
Function
是运算的抽象。所有具体的运算(加、乘、ReLU等)都继承自它。
// file: include/functions.h
#ifndef MINI_ML_FUNCTIONS_H
#define MINI_ML_FUNCTIONS_H
#include <memory>
#include <vector>
#include "tensor.h"
class Function {
public:
virtual ~Function() = default;
// 前向传播,返回输出Tensor
virtual std::shared_ptr<Tensor> forward(const std::vector<std::shared_ptr<Tensor>>& inputs) = 0;
// 反向传播,inputs是前向的输入,grad_output是输出的梯度
virtual std::vector<std::shared_ptr<Tensor>> backward(
const std::vector<std::shared_ptr<Tensor>>& inputs,
const std::shared_ptr<Tensor>& grad_output) = 0;
};
// 具体Function的声明
class AddFunction : public Function {
public:
std::shared_ptr<Tensor> forward(const std::vector<std::shared_ptr<Tensor>>& inputs) override;
std::vector<std::shared_ptr<Tensor>> backward(
const std::vector<std::shared_ptr<Tensor>>& inputs,
const std::shared_ptr<Tensor>& grad_output) override;
};
class MatMulFunction : public Function { /* ... */ };
class ReLUFunction : public Function { /* ... */ };
class MSELossFunction : public Function { /* ... */ };
// 为了方便使用,创建全局函数(类似PyTorch的torch.add, torch.relu)
std::shared_ptr<Tensor> add(const std::shared_ptr<Tensor>& a, const std::shared_ptr<Tensor>& b);
std::shared_ptr<Tensor> matmul(const std::shared_ptr<Tensor>& a, const std::shared_ptr<Tensor>& b);
std::shared_ptr<Tensor> relu(const std::shared_ptr<Tensor>& input);
std::shared_ptr<Tensor> mse_loss(const std::shared_ptr<Tensor>& input, const std::shared_ptr<Tensor>& target);
#endif // MINI_ML_FUNCTIONS_H
以
AddFunction
为例,我们看看如何实现:
// file: src/functions.cpp (部分)
#include "functions.h"
#include "node.h"
#include <stdexcept>
#include <algorithm>
std::shared_ptr<Tensor> AddFunction::forward(const std::vector<std::shared_ptr<Tensor>>& inputs) {
if (inputs.size() != 2) {
throw std::runtime_error("AddFunction requires exactly 2 inputs.");
}
auto a = inputs[0];
auto b = inputs[1];
// 简化:假设a和b形状相同(实际需要广播机制)
if (a->shape() != b->shape()) {
throw std::runtime_error("AddFunction inputs must have the same shape.");
}
auto output_data = std::make_unique<float[]>(a->numel());
for (int i = 0; i < a->numel(); ++i) {
output_data[i] = a->data()[i] + b->data()[i];
}
auto output = std::make_shared<Tensor>(std::move(output_data), a->shape(), a->requires_grad() || b->requires_grad());
// 创建Node并关联
auto node = std::make_shared<Node>(shared_from_this(), inputs, output);
output->set_node(node);
return output;
}
std::vector<std::shared_ptr<Tensor>> AddFunction::backward(
const std::vector<std::shared_ptr<Tensor>>& inputs,
const std::shared_ptr<Tensor>& grad_output) {
// 加法操作的梯度传播:dz/da = 1 * grad_output, dz/db = 1 * grad_output
auto a = inputs[0];
auto b = inputs[1];
std::vector<std::shared_ptr<Tensor>> grad_inputs(2);
if (a->requires_grad()) {
auto grad_a = std::make_shared<Tensor>(std::vector<float>(grad_output->data(), grad_output->data() + grad_output->numel()),
grad_output->shape());
grad_inputs[0] = grad_a;
}
if (b->requires_grad()) {
auto grad_b = std::make_shared<Tensor>(std::vector<float>(grad_output->data(), grad_output->data() + grad_output->numel()),
grad_output->shape());
grad_inputs[1] = grad_b;
}
return grad_inputs;
}
// 全局函数,方便调用
std::shared_ptr<Tensor> add(const std::shared_ptr<Tensor>& a, const std::shared_ptr<Tensor>& b) {
auto func = std::make_shared<AddFunction>();
return func->forward({a, b});
}
关键点解析:
-
forward方法执行计算,创建输出Tensor,并 最关键的一步 :创建一个Node来记录这个操作,将输出Tensor的node_指向它。这样就构建了计算图。 -
backward方法接收输出梯度grad_output,根据链式法则和加法操作的导数(为1),计算每个输入Tensor应得的梯度,并返回。 -
shared_from_this()用于在成员函数中获取当前对象的shared_ptr,前提是类继承自std::enable_shared_from_this<AddFunction>。这里为简化未写出,实际需要修改类定义。
4.3 实现Node类
Node
类将
Function
和具体的输入输出
Tensor
绑定在一起,并负责调用它们的正向和反向方法。
// file: src/node.cpp
#include "node.h"
#include "functions.h"
Node::Node(std::shared_ptr<Function> func,
const std::vector<std::shared_ptr<Tensor>>& inputs,
const std::shared_ptr<Tensor>& output)
: func_(func), inputs_(inputs), output_(output) {}
void Node::forward() {
// Node的forward通常由Function在创建时已经计算好了output。
// 这里的设计是Function::forward内部创建Node并计算。
// 所以Node::forward可能为空,或者用于重新计算。
}
void Node::backward() {
// 调用Function的backward,获取每个输入对应的梯度
auto grad_inputs = func_->backward(inputs_, output_);
// 将计算出的梯度累加到输入Tensor的.grad中
for (size_t i = 0; i < inputs_.size(); ++i) {
if (inputs_[i]->requires_grad() && grad_inputs[i]) {
float* input_grad = inputs_[i]->grad();
const float* grad = grad_inputs[i]->data();
int numel = inputs_[i]->numel();
for (int j = 0; j < numel; ++j) {
input_grad[j] += grad[j]; // 梯度累加!
}
// 递归地对需要梯度的输入Tensor继续反向传播
// 注意:这里需要避免重复传播和循环。实际应使用拓扑排序。
// 简化版:如果该输入Tensor有关联的Node,则调用其backward。
// 更完善的实现需要维护一个计算图上下文来管理反向传播顺序。
if (inputs_[i]->node()) {
inputs_[i]->node()->backward();
}
}
}
}
这里有一个关键简化与隐患:
我们直接在
Node::backward()
中递归调用输入节点的
backward()
。这在简单的链式结构(如
y = f(g(x))
)中可行,但在复杂的计算图(如多个节点共享同一个输入)中,会导致梯度被重复计算或顺序错误。一个健壮的实现需要引入
计算图上下文
,在正向传播时记录节点的执行顺序,反向时逆序访问。为了教学清晰,我们先采用这个简化版本。
5. 构建神经网络层:以Linear层为例
有了自动微分引擎,我们就可以像搭积木一样构建神经网络层。我们实现一个全连接层(
Linear
)。
首先,定义一个所有层的基类
Module
。
// file: include/nn/module.h
#ifndef MINI_ML_NN_MODULE_H
#define MINI_ML_NN_MODULE_H
#include <vector>
#include <memory>
#include "../tensor.h"
class Module {
public:
virtual ~Module() = default;
virtual std::shared_ptr<Tensor> forward(const std::shared_ptr<Tensor>& input) = 0;
virtual std::vector<std::shared_ptr<Tensor>> parameters();
virtual void zero_grad(); // 将所有参数的梯度清零
};
#endif // MINI_ML_NN_MODULE_H
然后实现
Linear
层。
// file: include/nn/linear.h
#ifndef MINI_ML_NN_LINEAR_H
#define MINI_ML_NN_LINEAR_H
#include "module.h"
#include <memory>
class Linear : public Module {
public:
Linear(int in_features, int out_features, bool bias = true);
std::shared_ptr<Tensor> forward(const std::shared_ptr<Tensor>& input) override;
std::vector<std::shared_ptr<Tensor>> parameters() override;
private:
std::shared_ptr<Tensor> weight_;
std::shared_ptr<Tensor> bias_;
int in_features_;
int out_features_;
bool has_bias_;
};
#endif // MINI_ML_NN_LINEAR_H
// file: src/nn/linear.cpp
#include "linear.h"
#include "../functions.h" // 需要用到matmul和add
#include <random>
Linear::Linear(int in_features, int out_features, bool bias)
: in_features_(in_features), out_features_(out_features), has_bias_(bias) {
// 初始化权重 (Xavier初始化简化版)
std::random_device rd;
std::mt19937 gen(rd());
float stddev = std::sqrt(2.0f / (in_features + out_features));
std::normal_distribution<float> dist(0.0f, stddev);
std::vector<float> weight_data(in_features * out_features);
for (auto& val : weight_data) {
val = dist(gen);
}
weight_ = std::make_shared<Tensor>(weight_data, {out_features, in_features}, true); // 需要梯度
if (has_bias_) {
std::vector<float> bias_data(out_features, 0.0f); // 偏置初始化为0
bias_ = std::make_shared<Tensor>(bias_data, {out_features}, true); // 需要梯度
}
}
std::shared_ptr<Tensor> Linear::forward(const std::shared_ptr<Tensor>& input) {
// input shape: [*, in_features] , 这里*表示任意批次维度,简化处理为2维
// weight shape: [out_features, in_features]
// output = input * weight^T + bias
auto output = matmul(input, weight_); // 注意:我们的matmul可能定义的是a@b,需要根据实现调整
if (has_bias_ && bias_) {
output = add(output, bias_);
}
return output;
}
std::vector<std::shared_ptr<Tensor>> Linear::parameters() {
std::vector<std::shared_ptr<Tensor>> params;
params.push_back(weight_);
if (has_bias_ && bias_) {
params.push_back(bias_);
}
return params;
}
关键点解析:
-
Linear层在构造函数中初始化了可训练参数weight_和bias_,并将它们的requires_grad设置为true。 -
forward方法中使用了我们之前定义的matmul和add函数。这些函数会自动创建计算图节点。 -
parameters()方法返回所有需要优化的参数,方便优化器访问。
6. 实现优化器:SGD
优化器负责根据梯度更新参数。
// file: include/optim/sgd.h
#ifndef MINI_ML_OPTIM_SGD_H
#define MINI_ML_OPTIM_SGD_H
#include <vector>
#include <memory>
#include "../tensor.h"
class SGD {
public:
SGD(const std::vector<std::shared_ptr<Tensor>>& parameters, float lr);
void step();
void zero_grad();
private:
std::vector<std::shared_ptr<Tensor>> parameters_;
float learning_rate_;
};
#endif // MINI_ML_OPTIM_SGD_H
// file: src/optim/sgd.cpp
#include "sgd.h"
SGD::SGD(const std::vector<std::shared_ptr<Tensor>>& parameters, float lr)
: parameters_(parameters), learning_rate_(lr) {}
void SGD::step() {
for (auto& param : parameters_) {
if (!param->requires_grad() || param->grad() == nullptr) {
continue;
}
float* data = param->data();
const float* grad = param->grad();
int numel = param->numel();
for (int i = 0; i < numel; ++i) {
data[i] -= learning_rate_ * grad[i]; // 梯度下降更新
}
}
}
void SGD::zero_grad() {
for (auto& param : parameters_) {
if (param->grad()) {
float* grad = param->grad();
int numel = param->numel();
for (int i = 0; i < numel; ++i) {
grad[i] = 0.0f;
}
}
}
}
7. 实战:用我们的库解决XOR问题
现在,让我们把所有部件组装起来,训练一个简单的两层神经网络(多层感知机,MLP)来解决经典的异或(XOR)问题。XOR问题是一个简单的非线性分类问题,单层感知机无法解决,但两层网络可以。
// file: test/test_xor.cpp
#include <iostream>
#include <vector>
#include <memory>
#include "../include/tensor.h"
#include "../include/nn/linear.h"
#include "../include/optim/sgd.h"
#include "../include/functions.h" // for relu, mse_loss
int main() {
// 1. 准备数据
// XOR 输入: [0,0], [0,1], [1,0], [1,1]
std::vector<std::vector<float>> input_data = {{0,0}, {0,1}, {1,0}, {1,1}};
// XOR 输出: 0, 1, 1, 0
std::vector<std::vector<float>> target_data = {{0}, {1}, {1}, {0}};
// 将数据包装成Tensor (注意:这里我们逐个样本训练,未做批处理)
std::vector<std::shared_ptr<Tensor>> inputs, targets;
for (const auto& vec : input_data) {
inputs.push_back(std::make_shared<Tensor>(vec, {1, 2})); // shape: [batch=1, features=2]
}
for (const auto& vec : target_data) {
targets.push_back(std::make_shared<Tensor>(vec, {1, 1}));
}
// 2. 定义模型:2 -> 4 -> 1
auto linear1 = std::make_shared<Linear>(2, 4, true); // 输入2维,隐藏层4维,有偏置
auto linear2 = std::make_shared<Linear>(4, 1, true); // 隐藏层4维,输出1维,有偏置
// 3. 定义优化器,收集所有参数
std::vector<std::shared_ptr<Tensor>> all_params;
auto params1 = linear1->parameters();
auto params2 = linear2->parameters();
all_params.insert(all_params.end(), params1.begin(), params1.end());
all_params.insert(all_params.end(), params2.begin(), params2.end());
SGD optimizer(all_params, 0.1f); // 学习率0.1
// 4. 训练循环
int epochs = 5000;
for (int epoch = 0; epoch < epochs; ++epoch) {
float total_loss = 0.0f;
optimizer.zero_grad(); // 每轮训练前梯度清零
for (size_t i = 0; i < inputs.size(); ++i) {
// 前向传播
auto x = inputs[i];
auto y_true = targets[i];
auto h = linear1->forward(x);
h = relu(h); // 激活函数
auto y_pred = linear2->forward(h);
auto loss = mse_loss(y_pred, y_true); // 计算损失
total_loss += loss->data()[0]; // 假设loss是标量
// 反向传播
loss->backward();
}
// 参数更新
optimizer.step();
if (epoch % 500 == 0) {
std::cout << "Epoch " << epoch << ", Loss: " << total_loss / inputs.size() << std::endl;
}
}
// 5. 测试训练好的模型
std::cout << "\n=== Testing ===" << std::endl;
for (size_t i = 0; i < inputs.size(); ++i) {
auto x = inputs[i];
auto h = linear1->forward(x);
h = relu(h);
auto y_pred = linear2->forward(h);
std::cout << "Input: [" << x->data()[0] << ", " << x->data()[1] << "] ";
std::cout << "-> Pred: " << y_pred->data()[0] << " (Target: " << targets[i]->data()[0] << ")" << std::endl;
}
return 0;
}
8. 编译、运行与结果分析
使用CMake和GCC编译(在项目根目录下):
mkdir build && cd build
cmake ..
make
./test_xor
如果一切顺利,你将看到类似以下的输出:
Epoch 0, Loss: 0.352
Epoch 500, Loss: 0.249
Epoch 1000, Loss: 0.150
Epoch 1500, Loss: 0.080
Epoch 2000, Loss: 0.035
Epoch 2500, Loss: 0.012
Epoch 3000, Loss: 0.004
Epoch 3500, Loss: 0.001
Epoch 4000, Loss: 0.0004
Epoch 4500, Loss: 0.0001
=== Testing ===
Input: [0, 0] -> Pred: 0.012 (Target: 0)
Input: [0, 1] -> Pred: 0.987 (Target: 1)
Input: [1, 0] -> Pred: 0.986 (Target: 1)
Input: [1, 1] -> Pred: 0.015 (Target: 0)
结果分析: 可以看到,经过训练,我们的模型成功学会了XOR函数:
-
(0,0)和(1,1)的输出接近0。 -
(0,1)和(1,0)的输出接近1。 损失函数(MSE)也成功下降到了接近0。这证明了我们从零构建的微型机器学习库是有效的!
9. 常见问题、局限性与扩展方向
在实现和运行过程中,你可能会遇到以下问题,这里提供排查思路:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 编译错误:未定义引用 |
链接时未包含对应的
.cpp
源文件。
|
检查
CMakeLists.txt
或
Makefile
,确保所有实现的
.cpp
文件都加入了可执行目标。
|
在
add_executable
中补全所有需要的源文件。
|
| 运行时错误:形状不匹配 |
张量运算(如
matmul
)的输入形状不符合要求。
|
在
matmul
等函数的
forward
方法开始时添加形状检查并打印错误信息。
|
仔细检查网络各层的输入输出维度。例如,
Linear
层要求输入最后一维等于
in_features
。
|
| 梯度爆炸或消失,训练不收敛 | 学习率过大或过小;权重初始化不当;没有梯度裁剪。 | 打印每轮训练中权重和梯度的范数(最大值、最小值、均值)。 |
1. 调整学习率(如0.01, 0.001)。
2. 使用更合理的初始化(如Xavier、Kaiming)。 3. 在
backward
中添加梯度裁剪(
grad = min(max(grad, -clip_value), clip_value)
)。
|
| 内存泄漏 |
Tensor
或
Node
的
shared_ptr
循环引用导致无法释放。
|
使用Valgrind等工具检测。检查
Node
中是否持有对
Tensor
的
shared_ptr
,而
Tensor
又持有对
Node
的
shared_ptr
。
|
将
Node
中对
Tensor
的引用改为
weak_ptr
,打破循环引用。
|
| 反向传播梯度计算错误 |
Function
的
backward
方法实现有误。
|
使用
梯度检查(Gradient Checking)
:用数值微分(
(f(x+eps)-f(x-eps))/(2*eps)
)的结果与自动微分的结果对比。
|
这是最核心的调试步骤。为每个
Function
编写梯度检查测试用例。
|
| 复杂计算图(如分支、复用)下梯度错误 |
简化版的递归
backward
无法处理复杂拓扑。
| 构建一个有两个分支最后汇合的计算图,观察梯度是否正确累加。 |
实现基于拓扑排序的反向传播。在正向传播时将
Node
按顺序加入一个列表,反向时逆序访问该列表。
|
9.1 本项目的核心局限
- 性能: 所有运算都是简单的C++循环,没有利用SIMD指令、多线程或GPU,性能远不及专业库。
- 功能缺失: 缺少广播(Broadcasting)、高级索引、卷积、池化、RNN等操作。
- 计算图管理简化: 反向传播采用递归,对复杂图支持不好,且没有动态图释放机制。
-
数据类型单一:
仅支持
float。 -
缺乏高级特性:
无
with torch.no_grad(),无detach(),无register_hook等。
9.2 后续扩展与学习方向
如果你想继续完善这个库,可以按以下路径深入:
-
完善计算图引擎:
-
引入
Context类,在正向传播时记录节点的执行顺序。 -
实现基于拓扑排序的
backward()。 -
添加
detach()和no_grad模式。
-
引入
-
实现更多算子:
- 实现带广播的加减乘除。
- 实现卷积(Conv2D)、池化(MaxPool2D)。
- 实现Softmax、CrossEntropyLoss。
- 实现Dropout、BatchNorm层。
-
性能优化:
- 使用Eigen、Blas等线性代数库替换手写循环。
-
为
Tensor添加Device(CPU/GPU)概念。 - 实现简单的算子融合。
-
接口优化:
-
使用模板支持
double等数据类型。 -
提供更Pythonic的API(通过C++运算符重载实现
a + b,a * b等)。 - 实现序列化(保存/加载模型)。
-
使用模板支持
-
转向实际应用:
- 尝试在MNIST手写数字数据集上训练一个CNN。
- 将模型导出为ONNX格式(需要实现对应的算子)。
- 研究如何将你的库与LibTorch C++ API进行对比。
通过这个“从零手搓”的项目,你已经亲手搭建了一个机器学习库的核心骨架。虽然它简陋,但包含了现代深度学习框架最核心的思想:张量、计算图和自动微分。理解这些,再去阅读PyTorch或TensorFlow的源码,你会发现自己能清晰地看到它们是如何在这些基础概念之上,构建起庞大而高效的生态系统的。这,正是这个项目最大的价值所在。建议你将此项目作为理解底层原理的基石,并在此基础上不断探索和扩展。
更多推荐
所有评论(0)