机器学习从零开始——C++ 手写实现全课程(完整版)

本文档合并自 docs/part01 ~ part13 共 13 章教学文档。
每章结构:直觉引入 → 从零推导 → 手算例子(与实验 0 逐位对账)→ 公式↔代码对照 → 实验 → 练习 → 小结。
配套代码见《机器学习课程-完整代码.md》(或 src/ 目录)。

目录

  1. Part 1 · 机器学习入门:从数据开始
  2. Part 2 · 线性代数:矩阵与自研 Matrix 库
  3. Part 3 · 线性回归与梯度下降:第一次"学习"
  4. Part 4 · 逻辑回归与分类:直线弯成 S 形
  5. Part 5 · 神经网络与反向传播:把"线性+激活"堆成多层
  6. Part 6:正则化与优化器——管住模型的野心的两把工具
  7. Part 7:卷积神经网络——让网络学会"看"
  8. Part 8:k 近邻(kNN)——不学习,只查表的"懒"算法
  9. Part 9:朴素贝叶斯——用"先验 + 证据"推理
  10. Part 10:决策树——用"提问"切开数据
  11. Part 11:随机森林——民主投票的树军团
  12. Part 12:支持向量机——分开得最体面的那条线
  13. Part 13 · Q-learning:没有老师的学习(最终篇)

Part 1 · 机器学习入门:从数据开始

学习目标:理解机器学习是什么(而不是背概念)、搭建好 C++ 学习工程、掌握描述一组数据的数学工具(均值、方差、标准差、中位数),并用 C++ 从零实现它们。

对应代码:src/part01_foundations/(静态库 part01 + 可执行程序 ml_part01,产物在 bin/


1. 什么是机器学习?

1.1 传统编程 vs 机器学习

先建立一个最重要的思维转变。

传统编程:人写出"规则",程序对"数据"应用规则,得到"结果"。

1
数据 + 规则 --[程序]--> 结果

例如:写一个函数判断邮件是否包含垃圾邮件关键词(规则),对邮件(数据)执行,输出是否为垃圾邮件(结果)。

机器学习:反过来。我们提供"数据"和"结果"(称为标签/答案),让机器自动归纳出"规则"(称为模型)。

1
数据 + 结果 --[学习算法]--> 规则(模型)

例如:给机器 10 万封已标注"垃圾/正常"的邮件,它自动学出判断规则。以后来了新邮件,模型就能预测结果。

一句话:机器学习 = 用算法从数据中自动归纳规律,并用规律对新情况做预测。

1.2 三大学习范式

范式 数据形态 典型任务 例子
监督学习 数据 + 标准答案(标签) 分类、回归 垃圾邮件识别(分类)、房价预测(回归)
无监督学习 只有数据,没有标签 聚类、降维 用户分群、数据压缩
强化学习 环境反馈(奖励/惩罚) 决策序列 下棋、机器人控制

我们前几个阶段主要学习监督学习——它是现代机器学习(包括神经网络)的地基。

1.3 监督学习的核心术语

以"预测房价"为例:

  • 样本(sample):一条数据记录,如"一套 80㎡、3 室的房子"。
  • 特征(feature):描述样本的属性,如面积、房间数、地段。通常记作向量 x=(x1,x2,,xn)x = (x_1, x_2, \dots, x_n)
  • 标签(label / target):要预测的答案,如成交价 yy
  • 数据集(dataset):许多样本的集合,常记作 {(x(1),y(1)),(x(2),y(2)),,(x(m),y(m))}\{(x^{(1)}, y^{(1)}), (x^{(2)}, y^{(2)}), \dots, (x^{(m)}, y^{(m)})\}mm 为样本数。
  • 模型(model):一个函数 ff,输入特征输出预测 y^=f(x)\hat{y} = f(x)y^\hat{y} 读作 “y-hat”,表示预测值)。
  • 训练(training):调整模型内部参数,使预测 y^\hat{y} 尽量接近真实 yy 的过程。
  • 损失函数(loss function):量化"预测得有多差"的函数,训练就是让它变小。

1.4 机器学习的通用工作流程

后面每个阶段我们都在实践这条流程,今天先做第 1 步:

1
2
3
4
5
① 理解数据(统计描述、可视化)
② 选择模型(线性回归?神经网络?)
③ 定义损失函数(预测和答案差多少?)
④ 用优化算法训练(让损失变小)
⑤ 评估(在没见过的数据上测表现)

第 1 步"理解数据"正是本阶段的实战内容:拿到一列数字,如何用几个统计量刻画它的全貌?

1.5 为什么用 C++ 学机器学习?

  • 看清本质:Python 的 numpy.mean() 一行就出结果,但你看不到循环、累加、数值陷阱。亲手实现一遍,原理就刻进脑子里了。
  • 工业价值:PyTorch、TensorFlow 的底层核心都是 C++。学完本课程,你能读懂深度学习框架的骨架。
  • 性能直觉:理解内存布局、缓存友好、值/引用语义,是从"会调包"到"会造轮子"的分水岭。

2. 环境:我们的工程如何组织

2.1 工具链

  • 编译器:MSVC(Visual Studio 2022)
  • 构建系统:CMake ≥ 3.21
  • C++ 标准:C++20(CMAKE_CXX_STANDARD_REQUIRED True
  • 编译选项:MSVC 强制 /utf-8(默认按 GBK 解读源码,UTF-8 中文注释会被误读)
  • 绘图:matplotlib-cpp(单头文件库,调用本机 Python 的 matplotlib 后端),仅绘图用途的外部库,统一放在 external/,由 cmake/AddMatplotlibCpp.cmake 引入;运行期依赖本机 Python 3 + numpy + matplotlib
  • 产物路径:可执行文件 → bin/,库 → lib/,图表 → output/(由根 CMakeLists 统一控制)

控制台中文乱码的根治:Windows 控制台默认代码页是 GBK(936),而我们的源码与字符串字面量都是 UTF-8,直接打印中文必乱码。解决办法是在 main() 入口把输出代码页切到 UTF-8(65001):

1
2
3
#ifdef _WIN32
SetConsoleOutputCP(CP_UTF8); // 需先 #include <windows.h>
#endif

#ifdef _WIN32 包住是为了保证同一份代码在 Linux/macOS 上也能编译(那些平台本来就是 UTF-8)。

2.2 目录结构(贯穿整个学习过程的约定,对齐既有 C++ 工程风格)

每个学习阶段是一个模块:对外提供静态库 + 一个实验用可执行程序。模块内部遵循 include/ + src/ 分层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
MachineLearning/
├── CMakeLists.txt # 根构建:C++20 + /utf-8 + 产物路径
├── .gitignore # 忽略 /.vs/ /bin/ /lib/ /build/ /output/
├── cmake/ # 外部依赖引入模块(Add<Lib>.cmake 模式)
│ └── AddMatplotlibCpp.cmake # 绘图库:探测 Python/numpy 并包装成目标
├── external/ # 仅绘图用途的外部库,统一在此管理
│ └── matplotlibcpp/
│ └── include/matplotlibcpp.h # 单头文件库(含本地兼容性补丁)
├── docs/ # 每个阶段一份学习文档
│ ├── codestyle.md # 编码风格参照(既有工程风格导出)
│ └── part01_foundations.md # ← 你正在读的这份
├── output/ # 程序生成的图表(已 gitignore)
└── src/
└── part01_foundations/ # 每个阶段一个模块
├── CMakeLists.txt # project(part01) + 静态库 + 可执行
├── include/
│ └── part01/
│ └── stats.h # 公开头文件:声明“有什么”(接口)
└── src/
├── stats.cpp # 实现“怎么做”
└── main.cpp # 可执行入口:演示 + 实验场

为什么 include/ 与 src/ 分层?

  • include/part01/stats.h 是模块的公开契约:使用方只 #include <part01/stats.h>(尖括号,include 路径由 CMake 的 target_include_directories ... PUBLIC 对外导出),无需关心实现。
  • src/ 下的 .cpp私有实现:不对外暴露,改实现不影响调用方(只要契约不变)。
  • 静态库 + 可执行的组合:part01 库沉淀可复用组件(Part 3 会直接链接它做数据标准化),ml_part01 只是实验入口。后续阶段的矩阵库、神经网络库都会按这个模式生长。

2.3 C++ 风格约定(本课程统一遵守,提炼自 codestyle.md

约定 内容
命名 类/结构体 PascalCaseStatistics);函数/方法 camelCasecomputeStatistics);成员变量 m_ 前缀;文件级静态 s_ 前缀;常量 UPPER_CASE(命名空间内 constexpr
命名空间 短小写:ml::part01
枚举 enum class + PascalCase 值
文件名 小写 + 下划线;头文件用 .h 后缀(不是 .hpp
缩进 Tab
括号 多行体开括号另起一行(Allman);单行简单体可与声明同行(如 getter)
初始化 统一大括号:{ 0.0 } 内侧留空格;空初始化 {} 紧贴
include 顺序:自身头 → 项目头(尖括号 <part01/stats.h>)→ 标准库 → 第三方;同目录私有头用引号
常量正确性 getter 标 const;参数用 const 引用;不修改的变量标 const
匿名命名空间 .cpp 内部工具函数/常量放匿名命名空间隐藏(内部链接)
注释 // 中文注释,解释“为什么”而不是复述“做了什么”;不用 doxygen 风格
浮点比较 永远不用 == 比较两个 double,用误差容忍

3. 数学热身:描述一组数据

假设我们统计了 7 天的气温(℃):

1
x = { 23, 25, 21, 27, 25, 30, 22 }

机器学习的第一课:用几个数字概括这组数据的"中心"和"离散程度"——因为模型训练时最怕的就是"不知道数据长什么样"。

3.1 均值(Mean)—— 数据的"重心"

xˉ=1mi=1mxi\bar{x} = \frac{1}{m}\sum_{i=1}^{m} x_i

读法:所有数据加起来除以个数。直觉:把每个数据点看成一个等重的小球放在数轴上,均值就是它们的平衡点

C++ 实现要点:均值是很简单的累加,但有两个工程细节:

  1. 空数据防御m=0m = 0 时除以零会得到 NaN(Not a Number),必须显式处理;
  2. 数值稳定性:本阶段先用朴素累加。之后实现方差时你会看到,"先求和再相减"和"边算边更新"两种写法在浮点数下精度并不相同——这是数值计算的经典陷阱,Part 3 讲梯度时会更深入。

3.2 方差(Variance)与标准差(Standard Deviation)—— 数据的"分散程度"

Var(x)=1mi=1m(xixˉ)2,σ=Var(x)\text{Var}(x) = \frac{1}{m}\sum_{i=1}^{m} (x_i - \bar{x})^2, \qquad \sigma = \sqrt{\text{Var}(x)}

为什么平方? 衡量"每个点离重心有多远",直接求偏差 (xixˉ)(x_i - \bar{x}) 的和会正负抵消(恒为 0)。平方让所有偏差变成正数,且离群点被放大惩罚——这本身就是一种"误差度量"的思想。

为什么开方得到标准差? 方差的单位是"℃²",开方后回到与原数据相同的单位"℃",才可与均值直接比较、才有物理直觉。

总体方差 vs 样本方差:除以 mm 是"总体方差"(把数据当成全部);统计学的"样本方差"除以 m1m-1(无偏估计,因为样本均值本身也是从数据估出来的,损失了一个自由度)。机器学习里两种都常见,本阶段实现总体方差并在文档中说明区别——知道自己在用哪个,比用哪个更重要。

机器学习视角:方差思想贯穿全程。第 3 部分的损失函数(均方误差 MSE)就是"方差"的亲戚——方差度量数据围绕均值的离散,MSE 度量预测围绕真值的偏差。你现在学的公式,就是未来损失函数的原型。

3.3 中位数(Median)—— 抗干扰的"中间值"

把数据排序后取正中间的数(偶数个则取中间两数平均)。

为什么需要它? 均值对离群点极其敏感:工资数据 {5k, 6k, 6k, 5k, 200k} 均值 44.4k,被一个异常值带飞;中位数 6k 才真实反映"一般水平"。

C++ 实现要点:求中位数必须排序,而排序会修改数据。这里有一个重要的 C++ 语义知识点:

  • const std::vector<double>&(常量引用)→ 不拷贝但不能修改 → 无法原地排序;
  • std::vector<double>(值)→ 拷贝一份,随便排序,调用者原件不动。

所以 median 的签名故意用传值——用一次拷贝换取"无副作用"。这就是 C++ 值语义与引用语义的经典权衡。

3.4 三个量的分工

统计量 回答的问题 弱点
均值 数据重心在哪? 怕离群点
中位数 典型值在哪? 丢失两端信息
标准差 数据波动多大? 同样怕离群点

数据标准化预告:第 3 部分训练模型前,我们会用 (xxˉ)/σ(x - \bar{x})/\sigma 把特征缩放到同一量纲(面积 80㎡ 和房间数 3 直接混在一起算,量纲差异会让训练失衡)。今天写的 mean/stddev 到那时会直接复用——每一部分都在为后面打地基


4. 代码精讲

4.1 include/part01/stats.h —— 接口设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#pragma once                      // 防止头文件被重复包含
#include <cstddef>
#include <vector>

namespace ml::part01 // 嵌套命名空间:ml 是总项目,part01 是本阶段
{

struct Statistics // 聚合统计结果:一次算全,整体返回
{
std::size_t count{ 0 }; // 样本数
double mean{ 0.0 };
double variance{ 0.0 };
double stddev{ 0.0 };
double min{ 0.0 };
double max{ 0.0 };
double median{ 0.0 };
};

double mean(const std::vector<double>& data); // 只读访问,不拷贝
double variance(const std::vector<double>& data);
double stddev(const std::vector<double>& data);
double median(std::vector<double> data); // 故意传值:内部要排序
Statistics computeStatistics(const std::vector<double>& data);

} // namespace ml::part01

注:文档中为阅读方便用空格示意缩进,实际代码采用 Tab 缩进 + Allman 括号(开括号另起一行),函数名用 camelCase(computeStatistics)——完整风格见 2.3 节。

逐行理解几个 C++ 关键点:

  1. #pragma once:C++ 没有 Python 的模块系统,#include 本质是“文本粘贴”。如果两个文件都 include 了 stats.h,类和函数会被定义两次导致编译错误。#pragma once 告诉编译器“本文件只粘贴一次”(所有主流编译器都支持,比传统的 #ifndef 卫兵简洁)。

  2. namespace ml::part01:C++17 起支持嵌套命名空间简写,等价于 namespace ml { namespace part01 { ... } }。作用:① 防止你的 mean 与别人(或标准库将来)的同名函数冲突;② 让读代码的人一眼看出归属——Part 2 我们会有 ml::part02::Matrix

  3. struct Statistics 的"聚合返回":与其让调用方连续调用 5 个函数(数据被遍历 5 遍),不如一次遍历算出全部结果打包返回。struct 默认公有成员,适合这种纯数据载体。

  4. 两个签名,两种语义(重点!):

    1
    2
    double mean(const std::vector<double>& data);   // 常量引用:零拷贝 + 只读
    double median(std::vector<double> data); // 值传递:拷贝一份供我修改

    引用(&)是"别名",不复制数据;const 是对调用者的承诺"我不改你的东西"。选择哪种传递方式,是设计 C++ 接口时每一次都要有意识做的决定

4.2 src/stats.cpp —— 实现细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <part01/stats.h>       // 尖括号:include 路径由 CMake 对外导出
#include <algorithm> // std::sort, std::min_element, std::max_element
#include <cmath> // std::sqrt
#include <numeric> // std::accumulate
#include <stdexcept> // std::invalid_argument

namespace ml::part01
{

double mean(const std::vector<double>& data)
{
if (data.empty())
throw std::invalid_argument("mean: data must not be empty");

// std::accumulate:从初值 0.0 出发,把每个元素累加起来。
// 注意初值必须写 0.0(double)而不是 0(int),
// 否则整段加法会按 int 进行,浮点精度全丢!
const double sum = std::accumulate(data.begin(), data.end(), 0.0);
return sum / static_cast<double>(data.size());
}

double variance(const std::vector<double>& data)
{
if (data.empty())
throw std::invalid_argument("variance: data must not be empty");

const double mu = mean(data); // 第一步:先求重心
double acc = 0.0;
for (double x : data) // 第二步:累加平方偏差
{
const double diff = x - mu;
acc += diff * diff; // diff*diff 比 std::pow(diff,2) 快且精确
}
return acc / static_cast<double>(data.size()); // 总体方差:除以 m
}

double stddev(const std::vector<double>& data)
{
return std::sqrt(variance(data));
}

double median(std::vector<double> data) // 注意:data 是自己的拷贝
{
if (data.empty())
throw std::invalid_argument("median: data must not be empty");

std::sort(data.begin(), data.end()); // 原地排序,只动拷贝
const std::size_t n = data.size();
if (n % 2 == 1)
return data[n / 2]; // 奇数个:正中间
return 0.5 * (data[n / 2 - 1] + data[n / 2]); // 偶数个:中间两数平均
}

Statistics computeStatistics(const std::vector<double>& data)
{
if (data.empty())
throw std::invalid_argument("computeStatistics: data must not be empty");

Statistics s;
s.count = data.size();
s.mean = mean(data);
s.variance = variance(data);
s.stddev = std::sqrt(s.variance);
s.min = *std::min_element(data.begin(), data.end()); // 返回迭代器,解引用取值
s.max = *std::max_element(data.begin(), data.end());
s.median = median(data); // 传入实参时发生拷贝
return s;
}

} // namespace ml::part01

值得停下咀嚼的四个点:

  1. 异常而不是返回 NaNthrow std::invalid_argument 是"失败要响亮"的设计哲学。静默返回 NaN 会让错误在下游传播几百行后才爆发,那时已无从定位。(当然,异常策略在库设计中是有争议的,等你学了更多我们再讨论替代方案。)

  2. std::accumulate 的初值陷阱:这是本阶段最重要的 C++ 细节。模板推导让 accumulate(..., 0) 变成整数累加{1, 2, 3} 的浮点场景会在你不知不觉中丢精度。永远显式写 0.0

  3. diff * diff vs std::pow(diff, 2)pow 是通用幂函数(按对数/指数实现),编译器通常能优化,但手写乘法保证只有一次浮点乘法,语义上也更直白。数值代码里"用最简单的操作表达意图"是好习惯。

  4. 错误信息带函数名"mean: data must not be empty" 让异常在日志里自带定位信息,省下未来的调试时间。

4.3 main.cpp —— 实验:气温数据

main.cpp 做了四件事,对应机器学习工作流的第一步“理解数据”:

  1. 用一份 7 天气温数据算完整统计;
  2. 做一个对比实验:往数据里塞一个极端值(热浪 41℃),观察均值被拉动 1℃+ 而中位数几乎不动——亲手验证 3.3 节的结论;
  3. 空数据防御:异常被捕获并优雅处理(try/catch);
  4. 把数据画出来(matplotlib-cpp):图表能看出数字看不到的东西。

关于实验 4 的三个工程细节:

  • 绘图失败不应让程序崩溃:绘图依赖外部 Python 环境(缺 numpy/matplotlib 时会抛异常),所以用 try/catch 包住——数据实验照常出结果,绘图降级为提示信息。这是“可选增强功能”的标准容错模式。
  • 图内文字用英文:matplotlib 默认字体不含中文字形,中文标签会渲染成方块;后续如有需要可配置字体,现阶段英文标签足够。
  • Debug 构建的 Python 链接陷阱:MSVC 的 Debug 配置定义 _DEBUG,Python 的 pyconfig.h 检测到它会自动链接调试版库 python311_d.lib(普通安装不带,报 LNK1104)。解决办法:包含 matplotlibcpp.h 时临时 #undef _DEBUG,包含后立即恢复(见 main.cpp,也是 pybind11 官方推荐做法)——我们通过 C API 调用的是发布版 Python,与调试版无关。

图表保存在 output/part01/temperature.png:蓝线是 7 天真实气温,橙色虚线第 8 天飙到 41℃(离群点),绿色点线是均值参考线——离群点对均值/方差的扰动一眼可见。

运行后你会看到类似输出:

1
2
3
4
5
6
7
8
9
10
11
=== 实验 1:7 天气温(正常数据) ===
样本数 均值 方差 标准差 最小值 最大值 中位数
7 24.71 8.20 2.86 21.00 30.00 25.00

=== 实验 2:混入一个 41℃ 的热浪天 ===
样本数 均值 方差 标准差 最小值 最大值 中位数
8 26.75 36.19 6.02 21.00 41.00 25.00

(此处省略“观察”总结行)
实验 3:空数据被正确拒绝,异常信息 = "mean: data must not be empty"
实验 4:图表已保存到 output/part01/temperature.png

读数的直觉:标准差从 2.86 跳到 6.02,翻了一倍——方差/标准差对离群点的敏感是平方级的(这正是 3.2 节“平方放大惩罚”的直接体现)。

4.4 构建与运行

1
2
3
4
5
6
# 加载 MSVC 环境后(见根目录说明),在项目根目录执行:
cmake -S . -B build
cmake --build build --config Release
# 可执行产物由根 CMakeLists 统一输出到项目根的 bin/ 目录
# (Visual Studio 多配置生成器会自动追加 Release 子目录)
.\bin\Release\ml_part01.exe

5. 练习(强烈建议动手)

  1. 改数据:把 main.cpp 的气温换成你所在城市最近的真实数据,重新运行,观察统计量变化。
  2. 实现 mode(众数):出现次数最多的值。提示:排序后相邻相同元素计数(暂不引入哈希表)。
  3. 实现样本方差:新增 sampleVariance,除以 m1m-1,用小数据对比两种方差的差异,思考:样本数多大时差异可以忽略?
  4. 四分位数(选做):实现 iqr = Q3 - Q1(四分位距),它像中位数一样抗离群点。
  5. 思考题:为什么 std::accumulate 初值写 0 不会编译报错,却会造成错误?(提示:模板参数推导 + 隐式转换的方向。)

6. 本部分小结与下一站

✅ 你已经掌握:

  • 机器学习与传统编程的本质区别(规则从"人来写"变为"从数据归纳")
  • 监督学习基本术语:特征、标签、模型、损失、训练
  • 均值/方差/标准差/中位数的数学定义、直觉与相互关系
  • C++ 工程组织:include/src 分层、静态库+可执行、命名空间、const 引用 vs 值传递、异常防御、Tab 缩进与 Allman 括号的风格规范

🚀 Part 2 预告 —— 线性代数与 Matrix 库:单个特征只是一列数,真实样本是"多列数"(面积、房间数、楼层…)。多列数据就是矩阵。我们将从零实现一个 Matrix 类(运算符重载 + - *、矩阵乘法、转置),它是从线性回归到神经网络唯一的数学语言。


反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。


Part 2 · 线性代数:矩阵与自研 Matrix 库

学习目标:理解机器学习中数据的"第二视角"——数据集就是矩阵,模型就是矩阵运算;掌握矩阵加法、数乘、转置、矩阵乘法的定义与几何直觉;从零实现一个工业风格的 Matrix 类(存储布局、运算符重载、形状检查、缓存友好遍历)。

对应代码:src/part02_linear_algebra/(静态库 part02 + 可执行程序 ml_part02


1. 为什么机器学习离不开线性代数

1.1 Part 1 的局限:一列数装不下真实世界

Part 1 我们用一列 vector<double> 描述数据——那是因为实验里只有一个特征(气温)。但真实样本是多特征的:

1
2
3
样本 1:面积 80㎡ | 房间 3 | 楼层 5 | 房龄 12 年 → 价格 350 万
样本 2:面积 60㎡ | 房间 2 | 楼层 3 | 房龄 8 年 → 价格 220 万
...

把每一行样本对齐摞起来,就是一个表格——数学上叫矩阵。机器学习里几乎一切数据都以这种形态出现:

  • 数据集 Xm×nm \times n 矩阵(mm 个样本 × nn 个特征)
  • 标签 ym×1m \times 1 列向量
  • 模型参数 wn×1n \times 1 列向量

1.2 模型预测 = 一次矩阵乘法

Part 3 将实现的线性回归,它的预测公式是:

y^=Xw\hat{y} = Xw

XX(m×n)(m \times n)ww(n×1)(n \times 1),乘出来 y^\hat{y}(m×1)(m \times 1)——一次矩阵乘法,同时算出全部 mm 个样本的预测值。这就是为什么所有深度学习框架(PyTorch、TensorFlow)的核心都是矩阵运算库:没有矩阵,就没有批量计算;没有批量计算,就没有现代机器学习

1.3 本章产出

一个我们自己写的 Matrix 类。它将贯穿 Part 3~6 的所有算法:线性回归的梯度 XT(Xwy)X^T(Xw - y)、神经网络的层间传播 Wx+bW x + b,全部建立在本章的代码之上。


2. 数学基础:从向量到矩阵乘法

2.1 概念阶梯:标量 → 向量 → 矩阵 → 张量

对象 数学记号 形状 例子 直觉
标量 scalar aa 3.14 一个数
向量 vector xx n×1n \times 1 (23,25,21)(23, 25, 21) 一列数(一个样本/一组标签)
矩阵 matrix AA m×nm \times n 见 1.1 的房价表 一张表(整个数据集)
张量 tensor TT 任意维 RGB 图片 H×W×3H \times W \times 3 多维数组(CNN 的输入)

关键视角:向量是矩阵的特例(只有一列)。我们的 Matrix 类因此只实现矩阵,"列向量"就是 n×1 的矩阵——一个抽象服务所有场景(NumPy 也是这个思路)。

2.2 矩阵的形状(Shape)—— 贯穿始终的第一公民

矩阵 AAmmnn 列,记形状为 (m,n)(m, n)。机器学习里形状对了,公式才成立

  • 加法要求同形状(m,n)+(m,n)(m,n) + (m,n)
  • 乘法要求内维相等(m,  n)(n,  p)=(m,  p)(m,\;n) \cdot (n,\;p) = (m,\;p)

工程习惯:以后看到任何矩阵公式,第一反应就是检查形状。形状对不上的运算在数学上无定义,在我们的代码里则必须抛异常——这是本章代码防御的重点。

2.3 矩阵加法与数乘—— 逐元素的"低级"运算

(A+B)ij=Aij+Bij(λA)ij=λAij(A + B)_{ij} = A_{ij} + B_{ij} \qquad (\lambda A)_{ij} = \lambda A_{ij}

加法就是对应位置相加(同形状才有意义);数乘就是每个元素乘同一个数。没什么玄机,但要记住:它们都是逐元素(element-wise)运算——后面 Part 4 的 sigmoid 激活函数也是逐元素作用于矩阵的每个元素。

2.4 转置 —— 行列互换

(AT)ij=Aji(A^T)_{ij} = A_{ji}

ii 行变第 ii 列。(m,n)(m,n) 转置后是 (n,m)(n,m)机器学习出镜率最高的操作之一:梯度公式里的 XTX^T(把"样本×特征"的矩阵翻转,用于把误差"反向投射"回每个特征的参数上,Part 3 详解)。

2.5 矩阵乘法 —— 全章最重要、最值得慢慢读的公式

(AB)ij=k=1nAikBkj(AB)_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj}

读法:结果矩阵的第 ii 行第 jj 列 = AA 的第 ii 行与 BB 的第 jj 列的对应相乘再求和(点积)。

形状规则:AA(m,n)(m, n)BB(n,p)(n, p),结果是 (m,p)(m, p)AA 的列数必须等于 BB 的行数——“中间的两个 nn 相消”。

手算一个例子(本章实验会验证它):

(123456)(789101112)=(17+29+31118+210+31247+59+61148+510+612)=(5864139154)\begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{pmatrix} \begin{pmatrix} 7 & 8 \\ 9 & 10 \\ 11 & 12 \end{pmatrix} = \begin{pmatrix} 1\cdot7+2\cdot9+3\cdot11 & 1\cdot8+2\cdot10+3\cdot12 \\ 4\cdot7+5\cdot9+6\cdot11 & 4\cdot8+5\cdot10+6\cdot12 \end{pmatrix} = \begin{pmatrix} 58 & 64 \\ 139 & 154 \end{pmatrix}

2.6 为什么乘法要这样定义?—— 矩阵 = 线性变换

死记公式没有意义。矩阵乘法的定义不是"人为规定",而是"线性变换复合"的自然结果:

把一个 n×pn \times p 矩阵 BB 看成一台机器:吃进去一个 nn 维向量,吐出一个 mm 维向量,规则是"每行做一次加权求和"。那么:

  • BBpp 维空间线性变换nn 维空间(旋转、拉伸、剪切、投影……或它们的组合);
  • AA 再把 nn 维空间变换到 mm 维;
  • "先做 BB 再做 AA"这个复合动作,等价于直接乘一个矩阵 ABAB

几何直觉(本章实验 4 会画出来):

S=(10.501)(水平剪切:正方形被推成平行四边形)S = \begin{pmatrix} 1 & 0.5 \\ 0 & 1 \end{pmatrix} \quad\text{(水平剪切:正方形被推成平行四边形)}

R=(cosθsinθsinθcosθ)(逆时针旋转 θ 角)R = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix} \quad\text{(逆时针旋转 $\theta$ 角)}

单位正方形经过 SSRR、以及复合 RSRS 变换后的形状,能让你亲眼看到矩阵在"操作空间"——这正是神经网络每一层在做的事:affine 变换(矩阵乘)+ 非线性激活

2.7 单位矩阵 —— 矩阵世界的"1"

In=(11),AI=IA=AI_n = \begin{pmatrix} 1 & & \\ & \ddots & \\ & & 1 \end{pmatrix}, \qquad AI = IA = A

对角线为 1、其余为 0 的方阵,乘任何矩阵都不改变它——如同数乘 1。Part 5 神经网络权重初始化、Part 3 的正规方程 (XTX)1(X^TX)^{-1} 都会见到它。


3. C++ 实现要点:设计决策逐个讲

3.1 存储布局:一维扁平数组 + 行优先,而不是嵌套 vector

1
2
std::vector<std::vector<double>> m_data;  // ❌ 嵌套方案
std::vector<double> m_data; // ✅ 我们的选择

为什么扁平存储更好?

  1. 内存连续:嵌套 vector 的每一行是独立堆分配,散落在内存各处;扁平 vector 是一整块连续内存——CPU 缓存行加载一次就能喂饱后面的多次访问(缓存局部性,对矩阵乘法这种 O(mnp)O(mnp) 的运算影响数倍性能)。
  2. 一次分配:构造 1000×1000 矩阵,扁平方案只 new 一次;嵌套方案 new 1001 次。
  3. 这正是 NumPy、Eigen、PyTorch 的做法。

元素 (i,j)(i,j) 在扁平数组中的下标(行优先 row-major,C/C++ 传统):

index=i×ncols+j\text{index} = i \times n_{cols} + j

3.2 两种元素访问:operator() 快路径 vs at() 检查路径

1
2
double& operator()(std::size_t r, std::size_t c);  // 无检查——热路径(内层循环)
double at(std::size_t r, std::size_t c) const; // 带边界检查——调试与外部输入

为什么不用 operator[]?因为 C++ 的 [] 只能接收一个参数,二维下标得玩代理对象的花招(m[i][j]);而 operator() 是函数调用运算符,天然接受多个参数:m(i, j)。这是 C++ 数值库(Eigen、arma)的通行做法。

为什么不全部检查? 边界检查每次访问都要比较两次,矩阵乘法内层循环会执行 m×n×pm \times n \times p 次。策略是:公开接口(运算符、构造)统一做形状检查(低频、必查),元素访问提供快慢双通道(高频、自担责任)。这是性能与安全的经典折中。

3.3 运算符重载:自由函数而非成员函数

1
2
3
Matrix operator+(const Matrix& a, const Matrix& b);   // 自由函数
Matrix operator*(const Matrix& a, const Matrix& b);
Matrix operator*(double s, const Matrix& a); // 标量在左边也成立!

若做成成员函数 a.operator*(b),则 2.0 * a 无法编译(左操作数必须是 Matrix)。自由函数让 a * 2.02.0 * a 都成立——数学里怎么写,代码里就怎么用。

3.4 按值返回:信任移动语义

1
Matrix transpose() const;   // 返回新矩阵,而不是原地翻转

初学者常担心"返回整个矩阵会不会拷贝很贵"。现代 C++(C++11 起)有返回值优化(RVO/移动语义):返回局部构造的矩阵,编译器直接把结果"放进"调用方的变量,零拷贝。所以数值代码放心按值返回,语义清晰(不修改原矩阵,函数式风格)。

3.5 矩阵乘法的循环顺序:缓存友好的 i-k-j

教科书三重循环直觉写法是 i-j-k(固定 i 行 j 列,遍历 k 求和)。但对行优先存储,i-k-j 顺序对缓存更友好:

1
2
3
// i-k-j:B 的第 k 行在内存中连续,j 递增时顺序扫描 B 与 C
for (i...) for (k...) for (j...)
C(i,j) += A(i,k) * B(k,j);

i-j-k 中内层 k 递增时对 B 是按列跳着访问(跨度 = 列数 × 8 字节),缓存命中率差;i-k-j 内层对 B 和 C 都是顺序内存访问。同样的乘法次数,大矩阵下 i-k-j 快数倍——不改数学,只改访问顺序。这是"性能直觉"训练的第一课(Part 5 反向传播还会回来用)。

3.6 形状检查与异常信息

所有运算入口先检查形状,不匹配抛 std::invalid_argument,消息带上两个操作数的实际形状(排错时一眼定位):

1
operator*: shape mismatch (2x3) * (2x3); left cols must equal right rows

4. 代码精讲

4.1 include/part02/matrix.h —— 接口契约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#pragma once
#include <cstddef>
#include <vector>

namespace ml::part02
{
// 行优先扁平存储的稠密矩阵。
// 列向量/行向量统一建模为 n×1 / 1×n 矩阵(与 NumPy 同思路)。
class Matrix
{
public:
Matrix() = default;
Matrix(std::size_t rows, std::size_t cols, double init = 0.0);
static Matrix identity(std::size_t n); // 单位矩阵
static Matrix columnVector(const std::vector<double>& v); // n×1

std::size_t rows() const { return m_rows; }
std::size_t cols() const { return m_cols; }

// 快路径:无边界检查(内层循环/已保证合法下标时使用)
double& operator()(std::size_t r, std::size_t c);
double operator()(std::size_t r, std::size_t c) const;
// 慢路径:带边界检查(调试/外部输入)
double at(std::size_t r, std::size_t c) const;

Matrix transpose() const;

private:
std::size_t m_rows{ 0 };
std::size_t m_cols{ 0 };
std::vector<double> m_data; // 行优先:index = r * m_cols + c
};

Matrix operator+(const Matrix& a, const Matrix& b);
Matrix operator-(const Matrix& a, const Matrix& b);
Matrix operator*(const Matrix& a, const Matrix& b); // 矩阵乘法
Matrix operator*(double s, const Matrix& a);
Matrix operator*(const Matrix& a, double s);
}

设计取舍速记:

  • Matrix() = default + 成员默认值:默认构造得到 (0,0) 空矩阵,任何操作都会被形状检查拦下,不存在"未初始化"状态。
  • identity / columnVector 静态工厂:具名构造比记住参数含义更可读(Matrix(3,1) 不如 columnVector({1,2,3}) 直白)。
  • 转置是 const 成员:不修改自身,返回新矩阵——函数式语义,与数学记号 ATA^T 一致。

4.2 src/matrix.cpp —— 实现细节

核心三处(完整实现见源码,逐行注释):

  1. 构造m_data.assign(rows * cols, init) 一次分配;乘法溢出前检查 rows * cols 上限(防御性:极端形状会溢出 size_t)。
  2. 矩阵乘法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Matrix operator*(const Matrix& a, const Matrix& b)
{
if (a.m_cols != b.m_rows)
throw std::invalid_argument("operator*: shape mismatch ...");

Matrix c(a.m_rows, b.m_cols); // 结果清零
for (std::size_t i = 0; i < a.m_rows; ++i)
for (std::size_t k = 0; k < a.m_cols; ++k)
{
const double aik = a.m_data[i * a.m_cols + k]; // A(i,k) 提出内层循环
if (aik == 0.0) continue; // 稀疏加速:乘数为 0 跳过整行
for (std::size_t j = 0; j < b.m_cols; ++j)
c.m_data[i * c.m_cols + j] += aik * b.m_data[k * b.m_cols + j];
}
return c;
}

aik 提到内层循环外(寄存器复用)、i-k-j 顺序(见 3.5)、跳零(顺手的小优化)。

  1. 转置:新矩阵 B(j,i) = A(i,j),双重循环一次拷贝。

4.3 src/main.cpp —— 四个实验

  1. 矩阵的构造与打印:2×3 矩阵、列向量,格式化对齐输出;
  2. 手算对照:验证 2.5 节的手算例子 A(2×3) × B(3×2) = [[58,64],[139,154]],再验证结合律友好的 (AB)T=BTAT(AB)^T = B^T A^T
  3. 形状防御(2×3) × (2×3) 抛异常并打印异常信息;
  4. 线性变换可视化(matplotlib-cpp):把单位正方形的四个角点排成矩阵,分别左乘剪切矩阵 SS、旋转矩阵 R(θ)R(\theta)、复合 RSRS,画出四个四边形——亲眼看矩阵如何"操作"空间

4.4 构建与运行

1
2
3
4
5
# 项目根目录(MSVC 环境已加载):
cmake -S . -B build
cmake --build build --config Release
.\bin\Release\ml_part02.exe
# 图表输出到 output/part02/linear_transform.png(每章一个子目录,按讲解需求可多张)

5. 练习(强烈建议动手)

  1. trace(矩阵的迹):对角线元素之和 trace(A)。要求方阵,思考:非方阵该抛什么异常?
  2. element-wise 乘法(Hadamard 积)hadamard(a, b),同形状逐元素相乘。和矩阵乘法对比形状规则差异。
  3. rowSlice / colSlice:取出第 i 行(1×n)与第 j 列(n×1)。思考:列切片在行优先存储上为什么比行切片慢?(缓存!)
  4. 验证 (AB)T=BTAT(AB)^T = B^TA^T:写一个小函数比较两个矩阵"近似相等"(逐元素差 < 1e-12),这是后续所有算法验证的基础工具,值得放进公共代码。
  5. 思考题operator*if (aik == 0.0) continue; 在什么数据分布下收益最大?在什么情况下反而是白判断?(提示:图像像素/one-hot 特征 vs 全随机矩阵。)

6. 本部分小结与下一站

✅ 你已经掌握:

  • 矩阵视角看 ML:数据集 = XX,标签 = yy,预测 = XwXw
  • 加法/数乘/转置/矩阵乘法的定义、形状规则与几何直觉(矩阵 = 线性变换)
  • 行优先扁平存储与缓存局部性、operator() 双通道访问、自由函数运算符、按值返回与移动语义、i-k-j 循环
  • 静态工厂(identity/columnVector)、形状防御性异常

🚀 Part 3 预告 —— 线性回归与梯度下降:有了 XXww、矩阵乘法,就可以完整写出 y^=Xw\hat{y} = Xw。下一章我们定义损失函数 MSE =1mXwy2= \frac{1}{m}\|Xw - y\|^2,推导它的梯度 XT(Xwy)X^T(Xw-y),然后用梯度下降让损失一步步变小——那是你第一次亲手让机器"学习"。Part 1 的 mean/stddev 也将归队,负责训练前的数据标准化。


反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。


Part 3 · 线性回归与梯度下降:第一次"学习"

学习目标:完整走通机器学习工作流的闭环——模型(假设)→ 损失函数 → 梯度 → 优化。本章的写法是"公式必配代码、手算必能上机验证":一个贯穿全文的两样本迷你例子,先在纸面上手算,再用程序复现,最后放大到 80 个样本的房价数据。

对应代码:src/part03_linear_regression/(静态库 part03 + 可执行程序 ml_part03

前置:Part 1 的 mean/stddev(数据标准化)、Part 2 的 MatrixXXyyww 与矩阵乘法)


1. 问题:从数据中学一条直线

1.1 场景

已知一组房子数据:面积 xx(㎡)与成交价 yy(万)。问题:给定新面积 100㎡,预测价格?

答案:让机器从数据里出面积与价格的关系。最简单的关系假设是直线:

y^=w1x+w0\hat{y} = w_1 x + w_0

w1w_1(斜率)与 w0w_0(截距)就是模型参数——机器学习的全部目标,就是找到让预测最准的那组 ww。注意 y^\hat{y} 带帽子——它是预测值,与实测值 yy 是两个东西,后文这个区别至关重要。

1.2 矩阵形式:一次乘法预测所有样本

mm 个样本的特征排成 XX、标签排成 yy(Part 2 的视角):

y^=Xw,X=(1x(1)1x(2)1x(m)),w=(w0w1)\hat{y} = Xw, \qquad X = \begin{pmatrix} 1 & x^{(1)} \\ 1 & x^{(2)} \\ \vdots & \vdots \\ 1 & x^{(m)} \end{pmatrix}, \quad w = \begin{pmatrix} w_0 \\ w_1 \end{pmatrix}

把它按行展开看清楚:

y^(1)=w0+w1x(1),y^(2)=w0+w1x(2),y^(m)=w0+w1x(m)\hat{y}^{(1)} = w_0 + w_1 x^{(1)}, \quad \hat{y}^{(2)} = w_0 + w_1 x^{(2)}, \quad \dots \quad \hat{y}^{(m)} = w_0 + w_1 x^{(m)}

读法的关键:w0,w1w_0, w_1 在每个式子里都是同一对未知数。 不存在"每组 x,yx,y 各求一组 ww"——恰恰相反,我们要找唯一一组 ww 同时服务所有样本。

“一次矩阵乘法"说的是预测阶段的效率:给定学好的 wwXwXw 内部就是"对每一行做一次 w0+w1x(i)w_0 + w_1 x^{(i)}”,矩阵乘法替你把循环写好了(Part 2 学的 Cij=kAikBkjC_{ij}=\sum_k A_{ik}B_{kj},乘法定义里本来就藏着一个循环)。不是说学习只需一次乘法。

注意 XX 的第一列全是 1——这样 w0w_0(截距)就像普通参数一样参与矩阵乘法,不需要单独处理。这一列叫偏置列(bias column),神经网络里的 bias bb 是同一个思想。形状检查:XX(m,2)(m,2)ww(2,1)(2,1)y^\hat{y}(m,1)(m,1)——与每个 y(i)y^{(i)} 一一对应。

1.3 关键跃迁:从"解方程"到"学参数"

直觉上你可能会想:把 x,yx, y 代进 y^=w1x+w0\hat{y} = w_1 x + w_0,解出 ww 不就行了?在恰好两个样本、无噪声时,这真的成立

1
2
3
样本1:100㎡→370万 ⇒ w0 + 100·w1 = 370
样本2: 60㎡→230万 ⇒ w0 + 60·w1 = 230
相减:40·w1 = 140 ⇒ w1 = 3.5,w0 = 20 ✓ 二元一次方程组,精确解

但再添一个带噪声的样本(80㎡→305 万),代入 20+3.5×80=30030520 + 3.5\times80 = 300 \neq 305——矛盾。真实数据是 80 个方程、2 个未知数(超定方程组),噪声让它们互相打架,不存在任何一组 ww 能同时满足所有方程

没法"解",只能"妥协":找让总误差最小的那组 ww。这个妥协的标准就是损失函数(第 3 节),这个搜索的过程就是梯度下降(第 4 节)。

无噪声、方程数=未知数解方程(数学问题)有噪声、方程数未知数学参数(机器学习问题)\text{无噪声、方程数}=\text{未知数} \Rightarrow \text{解方程(数学问题)} \qquad \text{有噪声、方程数}\gg\text{未知数} \Rightarrow \text{学参数(机器学习问题)}

机器学习的本质:数据多到解不出精确解,于是退而求最优解。


2. 先补三个数学工具(每个五分钟,本章后无拦路虎)

2.1 工具一:求和符号 Σ——被数学家发明的 for 循环

i=15ai=a1+a2+a3+a4+a5\sum_{i=1}^{5} a_i = a_1 + a_2 + a_3 + a_4 + a_5

读作:“对 ii 从 1 到 5,把 aia_i 全部加起来”。逐零件对应代码:

Σ 零件 含义 对应代码
Σ\Sigma(S = Sum) 求和动作 sum +=
下标 i=1i{=}1 循环变量与起点 i = 1
上标 mm 终点(mm = 样本数,像函数参数不写死) i <= m
求和体 aia_i 每轮加什么 a[i]

两个要点:

  • 哑变量ii 只在 Σ 内部有意义(像 for 循环的 i 出了循环体没人认识),i=15ai=k=15ak\sum_{i=1}^{5}a_i = \sum_{k=1}^{5}a_k,换名不改结果;
  • 常数外提icai=ciai\sum_i c\cdot a_i = c\cdot\sum_i a_i——所以公式里的 12m\frac{1}{2m} 能一直安然待在 Σ 外面。

其实你早就在 stats.cpp 写过它:均值 xˉ=1mixi\bar{x}=\frac{1}{m}\sum_i x_i、方差 1mi(xixˉ)2\frac{1}{m}\sum_i(x_i-\bar{x})^2——全是"Σ 循环 + 乘个平均系数"这一个模板

2.2 工具二:导数——把旋钮拧一点点,看函数变多少

导数 = 某点的变化率。把它变成一个可执行的动作

把变量 xx 往右轻轻挪一点点,看函数值 ff 变了多少,两者相除。

f(x)=limΔx0f(x+Δx)f(x)Δxf'(x) = \lim_{\Delta x \to 0}\frac{f(x+\Delta x) - f(x)}{\Delta x}

例:f(x)=x2f(x)=x^2 站在 x=3x=3:往右挪 0.01,函数从 9 变到 9.0601,变化率 0.0601/0.016\approx 0.0601/0.01 \approx 6(精确值 f(x)=2xf'(x)=2x2×3=62\times3=6)。导数就是此刻的坡度:正 = 上坡,负 = 下坡,绝对值 = 陡峭程度。单变量函数只有一个旋钮,所以无需说明"关于谁"。

2.3 工具三:偏导——拧一个旋钮时,把其他旋钮焊死

L 有两个旋钮 w0,w1w_0, w_1,都能拧、都影响 L。那"L 变化多快"没有唯一答案——必须先回答:拧哪个? 想象站在碗状曲面上(横轴 w0w_0、纵轴 w1w_1、高度是 L):同一点可以朝 w0w_0 方向看一眼坡度,也可以朝 w1w_1 方向看一眼——两个不同的数字,各就是一个偏导数

定义极简:把其他变量当成常数(焊死),只对这一个变量求普通导数。记号从 dd 换成 \partial 只是提醒"多变量函数",求法没有任何新东西。

2.4 合体:梯度——所有偏导拼成的导航箭头

L=(L/w0L/w1)\nabla L = \begin{pmatrix} \partial L/\partial w_0 \\ \partial L/\partial w_1 \end{pmatrix}

几何性质:梯度指向 L 上升最陡的方向(各方向坡度的加权合成)。于是:

  • 梯度方向 = 最陡上坡 → 负梯度 = 最陡下坡 → 更新公式里的减号;
  • 梯度大小 = 陡峭程度 → 远处陡大步、近底缓小步 → 步长的"自动刹车"(4.1 节);
  • 代码对应:grad 矩阵第 0 行是 L/w0\partial L/\partial w_0,第 1 行是 L/w1\partial L/\partial w_1——一次矩阵乘法把两个偏导同时算完。

一句话总结:导数是单旋钮的坡度;偏导是多旋钮时"焊死其余、只拧一个"测出的坡度;梯度是所有偏导拼成的向量,指向最陡上坡——所以下坡就朝它的反方向走,这就是梯度下降的全部数学。


3. 损失函数 L(w):一台打分机

3.1 定义与逐零件阅读

参数好不好,需要一个可计算的分数。标准答案是均方误差(Mean Squared Error):

L(w)=12mi=1m(y^(i)y(i))2L(w) = \frac{1}{2m}\sum_{i=1}^{m}\left(\hat{y}^{(i)} - y^{(i)}\right)^2

用 2.1 节的"Σ 翻译器"读它:对 80 个样本循环,每轮加"误差的平方",最后乘 1160\frac{1}{160}。对照 lossAt() 的实现——

1
2
3
4
5
6
7
double sum = 0.0;
for (std::size_t i = 0; i < error.rows(); ++i) // Σ_{i=1}^{m}:对样本循环
{
const double diff = error(i, 0); // err⁽ⁱ⁾ = ŷ⁽ⁱ⁾ - y⁽ⁱ⁾
sum += diff * diff; // 每轮加 err²
}
return sum / (2.0 * static_cast<double>(x.rows())); // 乘 1/(2m)(外提的常数)

公式与代码逐行对上:循环是 Σ,循环体是求和体,返回时的除法是外提的 12m\frac{1}{2m}

3.2 关键认知:w 是输入,x/y 是常数

把定义式展开看清楚谁是变量:

L(w0,w1输入:一组候选答案)=12mi(w0+w1x(i)y(i))2L(\underbrace{w_0, w_1}_{\text{输入:一组候选答案}}) = \frac{1}{2m}\sum_i\left(w_0 + w_1 x^{(i)} - y^{(i)}\right)^2

数据 (x(i),y(i))(x^{(i)}, y^{(i)}) 采集完就固定不动(烧进函数里的常数),ww 才是输入。L(0,0)L(0,0)L(20,3.5)L(20, 3.5)L(20,3.5)L(20, -3.5) 是三个完全不同的分数。

L 是一台打分机:递给它一组候选 ww,它告诉你这组答案有多烂。 手里没有 ww,L 根本无从计算——所以不存在"从 x,y 算出 L,再从 L 反推 w"这条路。ww 不是推出来的,是搜出来的(第 4 节),L 在搜索过程中的角色是导航系统

3.3 为什么是"平方"?为什么有个 12\frac{1}{2}

  • 平方:误差有正有负,直接求和会互相抵消;绝对值也可以但不光滑(0 点不可导,无法用导数优化);平方处处光滑可导,且大误差被平方级放大——模型被逼着优先修正离谱的预测。还记得 Part 1 的方差吗?1m(xixˉ)2\frac{1}{m}\sum(x_i-\bar{x})^2 与 MSE 结构完全相同:方差度量数据围绕均值的离散,MSE 度量预测围绕真值的偏差——同一个数学思想(2.1 节说过的同一个模板)。
  • 12\frac{1}{2}:纯粹为了求导后消掉平方带来的系数 2,让梯度公式干净。不影响最小值位置(把整个损失乘常数,谷底不动)。

3.4 损失曲面:w 空间上的一片"山谷"

只有两个参数时,L(w0,w1)L(w_0, w_1) 是一个开口向上的碗状曲面(抛物面)。训练就是从某个出发点走到碗底。问题是:站在山坡上,怎么知道谷底在哪?


4. 梯度下降:把 w 搜出来

4.1 三个问题:起点、方向、步长

Q1:从哪出发?——(0,0)(0,0)。① 零初始化无偏私,不押注任何方向;② 损失是凸函数、碗只有一个底,起点不影响终点,选最简单的(Part 5 神经网络会失去这份礼物——多谷底世界里初始化变成大学问);③ 标准化后 z=0z=0 是均值点,(0,0)(0,0) 邻域本就是"猜均值"的合理起点。

Q2:朝哪走?——负梯度。2.4 节的结论:梯度指最陡上坡,所以下坡朝它的反方向。

Q3:走多远?——步长 =η×=\eta\times 梯度大小,不是固定值,自带刹车:

1
2
3
远离碗底:坡陡 → 梯度大 → 大步流星
接近碗底:坡缓 → 梯度小 → 小步微调
到达碗底:坡度=0 → 步长=0 → 自动停住(不会冲过碗底)

而且 w0,w1w_0, w_1 各有各的步长——梯度是向量,每个分量独立更新:哪个参数对应的特征与误差"相关"强,它就被修正得多。

三问的答案合起来就是那个著名公式:

wwηL(w)w \leftarrow w - \eta \, \nabla L(w)

η\eta(eta)叫学习率:步长的总标定系数,是人调的超参数

4.2 完整手算一遍(贯穿全文的迷你例子,拿计算器跟按)

两个无噪声样本(x=1,y=3)(x{=}1, y{=}3)(x=2,y=5)(x{=}2, y{=}5)——真实关系 y=2x+1y = 2x+1,即碗底在 (w0,w1)=(1,2)(w_0, w_1) = (1, 2)站在起点 w=(0,0)w=(0,0)η=0.1\eta = 0.1

第 1 步:算预测与误差(每个样本一行):

样本 预测 y^=w0+w1x\hat{y} = w_0 + w_1 x 误差 y^y\hat{y}-y
(1, 3) 0+0×1=00 + 0\times1 = 0 03=30-3 = -3
(2, 5) 0+0×2=00 + 0\times2 = 0 05=50-5 = -5

第 2 步:算两个偏导(用 2.3 节"焊死另一个旋钮"):

Lw0=12[(3)1+(5)1]=4Lw1=12[(3)1+(5)2]=6.5\frac{\partial L}{\partial w_0} = \frac{1}{2}\big[(-3)\cdot 1 + (-5)\cdot 1\big] = -4 \qquad \frac{\partial L}{\partial w_1} = \frac{1}{2}\big[(-3)\cdot 1 + (-5)\cdot 2\big] = -6.5

两个 Σ 各是 2 轮循环:对 w0w_0 每轮加"误差",对 w1w_1 每轮加"误差×特征"。(差一个 12\frac{1}{2}:严格版梯度带 1/m1/m,这里先看方向和比例,下节推导补上。)

第 3 步:更新(两个旋钮各拧一点):

w000.1×(4)=0.4,w100.1×(6.5)=0.65w_0 \leftarrow 0 - 0.1\times(-4) = 0.4, \qquad w_1 \leftarrow 0 - 0.1\times(-6.5) = 0.65

第 4 步:验证真的下坡了:新预测 0.4+0.65=1.050.4+0.65=1.050.4+1.3=1.70.4+1.3=1.7,误差 1.95-1.953.3-3.3,损失从 8.58.5 降到约 3.673.67 ✓。

重复循环,ww 一路滑向 (1,2)(1, 2)这段手算就是 main.cpp 的"实验 0"——程序算出的第一轮和最终结果与你手算的完全一致。

4.3 梯度公式的推导(链式法则,一步到位)

wjw_j 求偏导(外层平方 → 内层线性,链式法则把两层导数相乘):

Lwj=wj12mi=1m(y^(i)y(i))2=12mi2(y^(i)y(i))y^(i)wj\frac{\partial L}{\partial w_j} = \frac{\partial}{\partial w_j} \frac{1}{2m}\sum_{i=1}^{m}\left(\hat{y}^{(i)} - y^{(i)}\right)^2 = \frac{1}{2m}\sum_i 2\left(\hat{y}^{(i)} - y^{(i)}\right)\cdot\frac{\partial \hat{y}^{(i)}}{\partial w_j}

系数 12m2=1m\frac{1}{2m}\cdot 2 = \frac{1}{m}12\frac{1}{2} 就是为了这里消掉 2);而 y^(i)=kwkxk(i)\hat{y}^{(i)} = \sum_k w_k x^{(i)}_kwjw_j 求偏导,其余项全焊死,只剩 xj(i)x^{(i)}_j

Lwj=1mi=1m(y^(i)y(i))xj(i)\frac{\partial L}{\partial w_j} = \frac{1}{m}\sum_{i=1}^{m}\left(\hat{y}^{(i)} - y^{(i)}\right) x^{(i)}_j

一句话读它:参数 wjw_j 的梯度 = “误差加权平均的特征值”——哪个特征在预测偏差大的样本上取值大,它的参数就被狠狠修正。4.2 手算的两个 Σ 正是它的 j=0j=0(特征=偏置列恒为 1)和 j=1j=1(特征=xx)两个特例。

nn 个偏导拼成向量,恰好是一个矩阵乘法(形状自检:XTX^T(n,m)(n,m),误差 XwyXw-y(m,1)(m,1),乘积 (n,1)(n,1)ww 同形 ✓):

L(w)=1mXT(Xwy)\nabla L(w) = \frac{1}{m}X^T(Xw - y)

这就是全文反复出现的 XT(Xwy)X^T(Xw-y)——转置 XTX^T 的几何意义:把每个样本上的误差"反向投射"回每个参数的修正量。Part 5 反向传播的"误差回传"思想在此初现。

4.4 学习率 η\eta:全程最重要的超参数

  • 太小(如 10610^{-6}):每步挪动极小,几千轮也到不了谷底(练习 1 会看到爬行般的损失曲线);
  • 太大(如 5.0):一步跨过谷底跳到对面更高的坡上,损失越走越大甚至爆炸到 inf/NaN
  • 合适:损失单调下降,逐渐趋平。

4.5 什么时候停?

三种常用停止条件(我们的实现组合使用后两者):

  1. 固定轮数:跑满 maxIterations 就停(保底);
  2. 损失几乎不再下降:LprevLcur<tolerance|L_{prev} - L_{cur}| < \text{tolerance},认为已到谷底附近;
  3. 梯度范数接近 0:理论上最严格,本阶段先用 2。

本章我们花大力气用迭代法“爬”到谷底。但你可能已经想到一个问题——下面整整一章来回答它。


5. 正规方程:一步到位的闭式解

5.1 疑问:既然知道是碗,为什么不直接走到碗底?

微积分的基本常识:光滑函数在极小值点处,导数为零(碗底的坡度是水平的,否则还能往下滚)。我们的损失曲面是个碗(凸函数)——那把梯度直接设成 0,解方程不就行了?

5.2 推导:令梯度为零,四步解出 w

起点是 4.3 节推好的梯度:

L(w)=1mXT(Xwy)=0\nabla L(w) = \frac{1}{m}X^T(Xw - y) = 0

第一步:等式两边同乘 mm(0 乘什么都是 0,常数不影响解):

XT(Xwy)=0X^T(Xw - y) = 0

第二步:展开括号:

XTXwXTy=0X^TXw - X^Ty = 0

第三步:移项(XTyX^Ty 是已知量——训练数据算出来的常数向量):

XTXw=XTyX^TXw = X^Ty

第四步:两边左乘 (XTX)1(X^TX)^{-1}(如果可逆),解出:

w=(XTX)1XTy\boxed{w = (X^TX)^{-1}X^Ty}

这就是正规方程(Normal Equation)——不需要学习率、不需要迭代、没有收敛问题,一次矩阵运算直接给出最优解。sklearn 的 LinearRegression 内部就是它。

形状自检(老习惯):XTXX^TX(n,n)(n,n)XTyX^Ty(n,1)(n,1),逆乘过去 (n,1)(n,1)——与 ww 同形 ✓。

5.3 手算例子:与 4.2 节殊途同归

用 4.2 节的迷你数据 (1,3),(2,5)(1,3),(2,5)(偏置列 + 单特征):

X=(1112),y=(35)X = \begin{pmatrix}1 & 1\\ 1 & 2\end{pmatrix}, \quad y = \begin{pmatrix}3\\ 5\end{pmatrix}

XTXX^TX

XTX=(1+11+21+21+4)=(2335)X^TX = \begin{pmatrix}1+1 & 1+2\\ 1+2 & 1+4\end{pmatrix} = \begin{pmatrix}2 & 3\\ 3 & 5\end{pmatrix}

XTyX^Ty

XTy=(13+1513+25)=(813)X^Ty = \begin{pmatrix}1\cdot3+1\cdot5\\ 1\cdot3+2\cdot5\end{pmatrix} = \begin{pmatrix}8\\ 13\end{pmatrix}

求逆(2×2 公式:交换对角、负化反对角、除以行列式;行列式 =2×53×3=1= 2\times5 - 3\times3 = 1):

(XTX)1=11(5332)=(5332)(X^TX)^{-1} = \frac{1}{1}\begin{pmatrix}5 & -3\\ -3 & 2\end{pmatrix} = \begin{pmatrix}5 & -3\\ -3 & 2\end{pmatrix}

乘出结果

w=(5332)(813)=(403924+26)=(12)w = \begin{pmatrix}5 & -3\\ -3 & 2\end{pmatrix}\begin{pmatrix}8\\ 13\end{pmatrix} = \begin{pmatrix}40-39\\ -24+26\end{pmatrix} = \begin{pmatrix}1\\ 2\end{pmatrix}

(w0,w1)=(1,2)(w_0, w_1) = (1, 2)——与梯度下降迭代 179 轮殊途同归! 一行矩阵运算就到了同一个碗底。这正是“梯度为零的点”的含义:迭代法一步步逼近它,解析法一步算出它。

5.4 那为什么整个课程还要学梯度下降?

三条硬理由:

理由一:算不动。 (XTX)1(X^TX)^{-1} 要求逆的矩阵大小是 n×nn \times nnn = 特征数),求逆代价 O(n3)O(n^3)n=100n=100 时瞬间;n=104n = 10^4 时约 101210^{12} 次浮点运算;n=106n = 10^6(图像、文本的常规操作)时宇宙等不起。而梯度下降每轮只要 O(mn)O(mn)

理由二:可能不可逆。 若两个特征线性相关(如“面积(㎡)”和“面积(平方英尺)”同时入模),XTXX^TX 奇异无逆——正规方程直接报错,梯度下降只是走得慢一点。岭回归(Part 6)的 L2 罚款恰好能“拱”出可逆性(XTX+λIX^TX + \lambda I 恒可逆),这是两种思想的美妙相遇。

理由三:无法推广(最致命)。 线性回归的碗是二次的,“梯度=0”才能解出来。逻辑回归(Part 4)、神经网络(Part 5)、CNN(Part 7)的损失曲面不是碗,令梯度为零得到的方程解不出来——梯度下降是它们唯一的希望。线性回归只是梯度下降最温柔的练手场。

5.5 两条路怎么选(工程对照表)

梯度下降 正规方程
每次成本 O(mn)O(mn)/轮 × 几百轮 O(n3)O(n^3) 一次
特征多(n > 10⁴) ✓ 照常跑 ✗ 算不动
特征共线 慢但能走 ✗ 不可逆
超参数 η 要调(踩坑重灾区)
可推广到非线性模型 ✓(唯一选择) ✗(仅线性回归)
结果 近似解(谷底附近) 精确解

口诀:特征少(几百以内)、纯线性 → 正规方程(或 sklearn 默认);其他一切 → 梯度下降。但学习阶段必须两者都手写一遍——实验 5 会让它们在同一份数据上对账。


6. 数据标准化:训练前的必备仪式

6.1 为什么必须做

面积是几十到几百(㎡),房间数是 1~5——量纲相差两个数量级。后果:损失曲面被拉成狭长椭圆w1w_1 方向坡很陡、w0w_0 方向坡很缓,梯度下降在狭长谷里反复震荡(z 字形),收敛极慢;且学习率难以统一——对陡方向合适的步长,对缓方向根本走不动。

6.2 怎么做(Part 1 函数正式归队)

把每个特征列变成均值 0、标准差 1x=(xxˉ)/σx' = (x - \bar{x})/\sigma。标准化后损失曲面接近圆形,梯度直指谷底。

注意:标准化用的 xˉ,σ\bar{x}, \sigma 必须在训练集上统计并保存——预测新样本时要用同一组统计量变换,否则训练与预测的特征空间不一致。预测结果(价格)我们不标准化,让模型直接输出真实量纲。


7. 代码精讲

7.1 include/part03/linear_regression.h —— 接口契约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class LinearRegression
{
public:
LinearRegression(double learningRate = 0.1, std::size_t maxIterations = 1000,
double tolerance = 1e-10);

void fit(const Matrix& X, const Matrix& y); // 训练:梯度下降
Matrix predict(const Matrix& X) const; // 预测:Xw
double computeLoss(const Matrix& X, const Matrix& y) const;

const Matrix& weights() const { return m_weights; }
const std::vector<double>& lossHistory() const { return m_lossHistory; }

private:
static double lossAt(const Matrix& X, const Matrix& y, const Matrix& w);
...
};

设计要点:

  • 训练器是"有状态"的:与 Part 2 纯函数式的 Matrix 不同,回归器把学到的 ww 与训练历史存在成员里——fit 改变对象,predict 只读。这是"模型"类的基本形态,Part 5 的神经网络沿用。
  • 超参数集中在构造函数且给默认值——先跑通默认,再按 4.4 节的知识调。
  • lossHistory 暴露出来:可视化是调试训练的第一手段,损失曲线应该像日志一样随手可取。

7.2 fit 的核心循环:公式↔代码逐行对照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
part02::Matrix w{ x.cols(), 1 };                     // 起点 (0,0):全零矩阵
for (std::size_t iter = 0; iter < m_maxIterations; ++iter)
{
const part02::Matrix error = x * w - y; // Xw - y (m×1)
const part02::Matrix grad = (1.0 / m) * x.transpose() * error; // (1/m)Xᵀ(Xw-y) (n×1)
w = w - m_learningRate * grad; // w ← w - η·grad

const double currentLoss = lossAt(x, y, w); // L(w):打分
m_lossHistory.push_back(currentLoss); // → 记入历史(loss.png 的原料)

if (std::fabs(previousLoss - currentLoss) < m_tolerance) // 停止条件(4.5 节条件 2)
break;
previousLoss = currentLoss;
}
m_weights = w;

四行核心循环与公式的逐行对应表(本章的压舱石):

数学公式 代码行 作用
y^=Xw\hat{y} = Xw(1.2 节) x * w 一次乘法算出所有样本的预测
err=y^y\text{err} = \hat{y} - y - y 每个样本的误差
L=1mXT(Xwy)\nabla L = \frac{1}{m}X^T(Xw-y)(4.3 节) x.transpose() * error 两个偏导拼成的导航箭头
wwηLw \leftarrow w - \eta\nabla L(4.1 节) w = w - m_learningRate * grad 每个旋钮各拧一点,步长自带刹车
L(w)L(w)(3.1 节) lossAt(x, y, w) 打分:这轮的 w 有多烂

读懂公式就能读懂代码,反之亦然——这是本课程的追求。

7.3 开发实录:一个"假收敛" bug(值得记住的陷阱)

第一版 fit 循环里直接调 computeLoss(x, y) 记录损失——但成员函数读的是 m_weights,它要到循环结束才被赋值,于是每轮算的都是 w=0w=0 的损失,恒定不变,收敛判断第一轮就触发,模型只"学习"了一步就停了(症状:迭代 1 轮收敛、参数只挪了一步)。修复:抽静态辅助 lossAt(x, y, w)fit局部 w 算损失。

教训:有状态对象的方法读成员时,先问一句"此刻成员是我想要的那个值吗"——训练循环里"公式中的 w"与"对象里的 m_weights"是两个不同的东西,直到最后一行 m_weights = w 它们才合二为一。

7.4 src/main.cpp —— 六个实验

  1. 手算例子的代码验证:文档 4.2 节逐行手算过的 (1,3),(2,5)(1,3),(2,5) 迷你数据——先手工执行一轮更新(打印梯度 (4,6.5)(-4, -6.5)、更新后 (0.4,0.65)(0.4, 0.65),与手算对账),再交给 fit 训练到底,验证收敛到 (1,2)(1,2)
  2. 造数据:真实关系 y=3.5x+20+噪声y = 3.5x + 20 + \text{噪声}<random>mt19937 引擎 + 正态分布,固定种子保证可复现)——先有真相,再看机器能否还原它
  3. 标准化 + 训练:用 Part 1 的 mean/stddev 标准化面积、手工拼偏置列,调用 fit,打印学到的 ww 与真实参数对照;
  4. 预测演示:预测 100㎡ 的房价;
  5. 正规方程对照(第 5 章上机):手写 2×2 求逆(交换对角、负化反对角、除以行列式),一步算出 w=(XTX)1XTyw=(X^TX)^{-1}X^Ty,与梯度下降 179 轮的结果逐位对账——两条路殊途同归的现场证明
  6. 可视化(两张图,output/part03/
    • fit.png:散点(数据)+ 直线(学到的模型)——眼睛验证拟合质量
    • loss.png:损失随迭代下降的曲线——亲眼看见"学习"发生(曲线先陡后缓 = 4.1 节"自动刹车"的直观呈现)。

7.5 构建与运行

1
2
3
4
5
# 项目根目录(MSVC 环境已加载):
cmake -S . -B build
cmake --build build --config Release
.\bin\Release\ml_part03.exe
# 图表输出到 output/part03/(fit.png + loss.png)

关于实验 4 的工程细节:

  • 绘图失败不应让程序崩溃:绘图依赖外部 Python 环境,用 try/catch 包住——数据实验照常出结果,绘图降级为提示信息;
  • 图内文字用英文:matplotlib 默认字体不含中文字形;
  • Debug 构建的 Python 链接陷阱:包含 matplotlibcpp.h 时临时 #undef _DEBUG(详见 main.cpp 注释与 Part 1 文档),否则链接器找 python311_d.lib 报 LNK1104。

8. 练习(强烈建议动手)

  1. 学习率实验:把 learningRate 改成 1e-65.0 分别重跑,观察损失曲线形状(极慢 / 发散),并思考:发散时为什么会出现 inf 甚至 nan
  2. 梯度数值检查(必做,价值极高):写一个函数用有限差分估计梯度:LwjL(w+ϵej)L(wϵej)2ϵ\frac{\partial L}{\partial w_j} \approx \frac{L(w+\epsilon e_j) - L(w-\epsilon e_j)}{2\epsilon}ϵ=105\epsilon=10^{-5}),与解析梯度 XT(Xwy)/mX^T(Xw-y)/m 逐元素比较(容差 1e-6)。提示:这正是 2.2 节"拧一点点看变化"的动作——不用公式也能测出坡度,公式只是它的精确化。这个技巧叫 gradient check,是 Part 5 反向传播调试的救命工具。
  3. 不标准化的后果:注释掉标准化代码直接训练,对比收敛所需轮数,体会 5.1 节的"狭长椭圆"。
  4. 双特征回归:数据改为 y=2x13x2+5+噪声y = 2x_1 - 3x_2 + 5 + \text{噪声},验证机器能同时还原两个权重。注意标准化每一列。
  5. 思考题:偏置列全 1,标准化前后有区别吗?我们标准化的时机在拼偏置列之前——如果反过来(先拼 1 再整列标准化)会发生什么?
  6. 自测(4.2 节迷你例子):站在 w=(0,0)w=(0,0),如果只允许拧 w1w_1,L 变化多快?(答案:6.5-6.5,即 L/w1\partial L/\partial w_1。)

9. 本部分小结与下一站

✅ 你已经掌握:

  • 完整学习闭环:假设 y^=Xw\hat{y}=Xw → 损失 MSE → 梯度 1mXT(Xwy)\frac{1}{m}X^T(Xw-y) → 更新 wwηLw \leftarrow w - \eta\nabla L
  • 关键跃迁:方程组无解(超定+噪声)→ 退而求最优(损失最小化);L 是打分机,w 是搜出来的
  • 数学工具箱:Σ = for 循环;导数 = 坡度;偏导 = 焊死其他旋钮;梯度 = 最陡上坡箭头
  • 起点为何 (0,0)、步长为何自带刹车、学习率的两难、停止条件、为什么需要标准化
  • 有状态模型类的 C++ 形态、<random> 合成可复现数据、假收敛 bug 的教训

🚀 Part 4 预告 —— 逻辑回归与分类:回归预测连续值(价格),分类预测离散标签(垃圾/正常邮件)。直线被"弯"成 S 形的 sigmoid,损失换成交叉熵——同样的梯度下降框架,一次华丽转身。至此你将拥有二分类的全部武器。


反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。


Part 4 · 逻辑回归与分类:直线弯成 S 形

学习目标:模型从"预测数值"升级为"预测类别 + 置信度"。sigmoid 把直线弯成 S 形,损失换成交叉熵——但你会发现梯度竟然还是 XT(y^y)/mX^T(\hat{y}-y)/m。手算例子贯穿全文 + 决策边界可视化。

对应代码:src/part04_logistic_regression/(静态库 part04 + 可执行程序 ml_part04

前置:Part 3 全部(尤其梯度下降三问与"打分机"世界观)


1. 新问题:分类

1.1 场景变了

Part 3 的房价是连续值(回归)。现在要预测的是离散类别(分类):垃圾邮件(1)/ 正常邮件(0)、恶性肿瘤(1)/ 良性(0)。这类只有两个类别的问题叫二分类

1.2 为什么不能拿线性回归硬拟合 0/1?

直觉做法:把标签当数值(0 或 1),还是用 XwXw 拟合。三个致命伤:

  1. 输出无意义:模型会预测出 1.7 或 −0.3——"1.7 的垃圾程度"不是概率,无法解释;
  2. 对离群样本极度敏感:一个特别"垃圾"的样本(哪怕标签还是 1)会把直线拉得过头,把大片本来是 1 的区域判成 0;
  3. 没有"置信度":医生需要的是"恶性概率 93%",不是一个是非答案。

正确姿势分两步:先预测是 1 的概率 p(0,1)p \in (0,1),再按阈值(默认 0.5)硬化成类别。

2. sigmoid:把任意数压进 (0,1)

我们需要一个函数把 z=w0+w1x1+z = w_0 + w_1 x_1 + \dots(可正可负、无界)压进 (0,1)(0,1)。标准答案:

σ(z)=11+ez\sigma(z) = \frac{1}{1 + e^{-z}}

先手算三个值找感觉(拿计算器按):

zz 计算 σ(z)\sigma(z) 直觉
0 1/(1+1)1/(1+1) 0.5 正中间,一半一半
2 1/(1+0.135)1/(1+0.135) 0.88 强烈倾向 1
−2 1/(1+7.39)1/(1+7.39) 0.12 强烈倾向 0

性质(都在图上能看到):

  • 单调递增的 S 形:zz 越大越接近 1,越小越接近 0,永不到达;
  • 对称σ(z)=1σ(z)\sigma(-z) = 1 - \sigma(z)
  • 分界点σ(0)=0.5\sigma(0) = 0.5——这是下一节决策边界的钥匙。

模型就是"线性回归 + 一层弯折":

z=w0+w1x1+w2x2,p=σ(z)z = w_0 + w_1 x_1 + w_2 x_2, \qquad p = \sigma(z)

直线被 sigmoid 弯成了 S 形——逻辑回归的名字里带"回归",因为它的骨架还是线性回归。

3. 决策边界:一条看不见的直线

分类决策规则:p0.5p \geq 0.5 判类 1,否则判类 0。而 p=0.5    σ(z)=0.5    z=0p = 0.5 \iff \sigma(z)=0.5 \iff z = 0,所以:

边界:w0+w1x1+w2x2=0\text{边界}:\quad w_0 + w_1 x_1 + w_2 x_2 = 0

这仍然是一条直线(高维是超平面)!sigmoid 只是让边界两侧的概率平滑过渡,边界本身还是直的。ww(除偏置外)正是这条直线的法向量——指向"类 1"那一侧。

手算验证:设 w=(0,1,2)w = (0, 1, 2)(偏置 0,特征权重 1、2):

z=x1+2x2z = x_1 + 2x_2 p=σ(z)p=\sigma(z) 判类
(1,1)(1, 1) 33 0.950.95 1
(2,2)(-2, -2) 6-6 0.0020.002 0
(2,1)(2, -1) 00 0.50.5 正好在边界上

4. 损失函数:交叉熵——“自信的错误罚得最狠”

4.1 直觉

标签只有 0/1,交叉熵(Cross-Entropy)的定义按 yy 的取值二选一

L(w)=1mi=1m[y(i)logp(i)+(1y(i))log(1p(i))]L(w) = -\frac{1}{m}\sum_{i=1}^{m}\Big[y^{(i)}\log p^{(i)} + (1-y^{(i)})\log(1-p^{(i)})\Big]

用"Σ = for 循环"翻译器读:对每个样本,若 y=1y=1 只罚 logp-\log p,若 y=0y=0 只罚 log(1p)-\log(1-p)。手算几个罚金(log-\log 是递减的:p 越接近真相罚越轻):

真值 yy 预测 pp 罚金 log()-\log(\cdot) 评语
1 0.9 0.1050.105 判对了且自信,轻罚
1 0.5 0.6930.693 模棱两可
1 0.1 2.3032.303 自信地错了,重罚
1 0.01 4.6054.605 非常自信地错,罚到疼

这正是我们要的性格:模型被逼着"不确定时别说满"。

4.2 为什么不用 MSE?

两个原因,一个直觉一个数学:

  • 直觉:MSE 对 p=0.5p=0.5 的错判(罚 0.25)和 p=0.01p=0.01 的自信错判(罚 0.9820.960.98^2\approx0.96)差距不够狠;交叉熵把后者罚到 4.6;
    数学:sigmoid + MSE 的损失曲面非凸(多个谷,梯度下降可能困在半山腰);而 sigmoid + 交叉熵是一对天作之合——下一节你会看到求导后公式干净得出奇。

但"天作之合"终究是事后总结——交叉熵这个公式凭什么长这样?下面整整一章回答:它不是谁发明的,是从"极大似然"这个原则里推导出来的。


5. 交叉熵从哪来?——极大似然的化身

5.1 换个问法:不问"罚多狠",问"参数多合理"

4.1 节给了交叉熵的性格(自信的错误罚得狠),但那是在验收一个现成的公式。现在换一个视角,从零把它造出来

不要想"怎么罚模型",想"如果这组参数是对的,眼前这批数据有多大概率发生"——挑让数据看起来最"不意外"的那组参数。

这个原则叫极大似然(Maximum Likelihood):已发生的事实,就是最该被参数解释的事实

5.2 似然函数:数据在 w 手里发生的概率

逻辑回归面对样本 x(i)x^{(i)} 输出概率 p(i)=σ(wx(i))p^{(i)} = \sigma(w \cdot x^{(i)})。把它想成一枚"硬币工厂":遇到 x(i)x^{(i)} 就掷一枚正面率为 p(i)p^{(i)} 的硬币,落地就是预测。那么观测到真实标签的概率(Bernoulli 分布的标准写法):

P(y(i)x(i);w)=(p(i))y(i)(1p(i))1y(i)P(y^{(i)}\,|\,x^{(i)}; w) = \big(p^{(i)}\big)^{y^{(i)}}\big(1-p^{(i)}\big)^{1-y^{(i)}}

y=1y=1 → 就是 ppy=0y=0 → 就是 1p1-p。指数 0/1 自动"二选一"。)

样本独立,整个训练集一起发生的概率是连乘——这就是似然函数

L(w)=i=1m(p(i))y(i)(1p(i))1y(i)L(w) = \prod_{i=1}^{m}\big(p^{(i)}\big)^{y^{(i)}}\big(1-p^{(i)}\big)^{1-y^{(i)}}

极大似然原则:选让 L(w)L(w) 最大的 ww

5.3 手算迷你例子(两个候选参数对决)

两个样本 y=(1,0)y = (1, 0)。候选 w₁ 给出预测 p=(0.9,0.2)p = (0.9, 0.2),候选 w₂ 给出 p=(0.3,0.7)p = (0.3, 0.7)

第 1 个样本(y=1) 第 2 个样本(y=0) 似然(连乘) log 似然(连加)
w₁ 0.90.9 10.2=0.81-0.2=0.8 0.9×0.8=0.720.9\times0.8=\mathbf{0.72} 0.1050.223=0.328-0.105-0.223=-\mathbf{0.328}
w₂ 0.30.3 10.7=0.31-0.7=0.3 0.3×0.3=0.090.3\times0.3=0.09 1.2041.204=2.408-1.204-1.204=-2.408

w₁ 的似然是 w₂ 的 8 倍——它让"已发生的事实"看起来更顺理成章。极大似然选 w₁,直觉也选 w₁(它对两个样本都判得更准)。

注意表格最右列:log 把连乘变成了连加(log 单调,argmax 不变)——这是你第三次见到这个技巧(Part 4 本章、Part 9 的 log 后验、Part 12 的 SVM 还会重逢)。

5.4 揭晓:−log 似然 ÷ m 就是交叉熵

把 log 似然展开:

logL(w)=i=1m[y(i)logp(i)+(1y(i))log(1p(i))]\log L(w) = \sum_{i=1}^{m}\Big[y^{(i)}\log p^{(i)} + (1-y^{(i)})\log(1-p^{(i)})\Big]

最大化它,等价于最小化它的相反数再除以 m(除以 m 不改变谁是最小,只把尺度变成"平均每个样本"):

1mlogL(w)=1mi=1m[y(i)logp(i)+(1y(i))log(1p(i))]  =?  L(w)交叉熵-\frac{1}{m}\log L(w) = -\frac{1}{m}\sum_{i=1}^{m}\Big[y^{(i)}\log p^{(i)} + (1-y^{(i)})\log(1-p^{(i)})\Big] \;\overset{?}{=}\; L(w)_{\text{交叉熵}}

对照 4.1 节——一个字都不差

交叉熵 = 平均每个样本的 −log 似然。 它不是谁拍脑袋发明的损失函数,是"挑最合理的参数"这个原则算出来的必然结果。4.1 的罚金表(自信的错误罚得狠)只是它的副作用:log-\log 似然天然讨厌"把真事看得很难得"的参数。

5.5 彩蛋:MSE 也是极大似然(Part 3 的伏笔到此回收)

同一把尺子回去量 Part 3。线性回归的假设:y=Xw+εy = Xw + \varepsilon,噪声 εN(0,σ2)\varepsilon \sim \mathcal{N}(0, \sigma^2)。那么观测到 yy 的概率就是高斯密度:

P(yx;w)=12πσ2exp ⁣((yXw)22σ2)P(y\,|\,x; w) = \frac{1}{\sqrt{2\pi\sigma^2}}\exp\!\left(-\frac{(y - Xw)^2}{2\sigma^2}\right)

取 −log、丢掉与 w 无关的常数(σ\sigma2π\sqrt{2\pi}):

logL(w)    i(y(i)Xw(i))2-\log L(w) \;\propto\; \sum_i \big(y^{(i)} - Xw^{(i)}\big)^2

最小二乘 = 高斯噪声假设下的极大似然。两个老朋友原来是同一个原则的两次落地:

假设(数据怎么生成) 极大似然导出的损失
线性回归(Part 3) 标签 = 直线 + 高斯噪声 MSE
逻辑回归(本章) 标签 = 概率 p 的硬币(Bernoulli) 交叉熵

损失函数的"设计",本质是写下你对数据生成过程的假设——这也是朴素贝叶斯(Part 9)整个模型的出发点。

5.6 与朴素贝叶斯汇合:判别式 vs 生成式

Part 9 刚见过另一套概率玩法,和本章对照:

朴素贝叶斯(Part 9) 逻辑回归(本章)
建模方向 生成式:先建 P(x|y)P(y),再用贝叶斯定理换出 P(y|x) 判别式:直接建 P(y|x) = σ(w·x)
学什么 每类的均值/方差/先验 一条边界直线 w
假设 特征给定类别后独立(“朴素”) 边界是线性的

两个彩蛋把这门课的概率线缝起来了(都不需要会证,知道即可):

  1. Part 9 5.3 节已经看到:后验 odds 取 log 后自动长出 sigmoid
  2. 数学上可以证明:若两类的特征分布是同协方差的高斯,朴素贝叶斯的后验恰好就是 σ(wx)\sigma(w\cdot x) 形式——生成式和判别式在特殊情形殊途同归(练习 5 给推导思路)。

概率不是机器学习的一个章节,是躲在每一章背后的同一套语法。


6. 梯度:又是 XT(y^y)/mX^T(\hat{y}-y)/m!(本章最大惊喜)

6.1 推导(链式法则,三行)

先备一个零件:sigmoid 的导数有个漂亮的自指形式 σ(z)=σ(z)(1σ(z))\sigma'(z) = \sigma(z)(1-\sigma(z))(用 pp 写就是 p(1p)p(1-p))。

对单个样本的 zz 求导(yy 是 0/1 常数):

(ylogp(1y)log(1p))z=ypp(1p)+1y1pp(1p)=(1y)py(1p)通分整理=py\frac{\partial(-y\log p - (1-y)\log(1-p))}{\partial z} = -\frac{y}{p}p(1-p) + \frac{1-y}{1-p}p(1-p) = \underbrace{(1-y)p - y(1-p)}_{\text{通分整理}} = p - y

注意分子上的 p(1p)p(1-p) 被约掉了——这就是 4.2 节说的"天作之合":交叉熵的 1/p1/p 恰好抵消 sigmoid 导数的 p(1p)p(1-p)。若用 MSE,这个 p(1p)p(1-p) 会留下来作除不掉的尾巴,在饱和区(pp 接近 0 或 1)趋近 0,梯度消失,学习停滞。

再走一遍 Part 3 的老路(z/wj=xj\partial z/\partial w_j = x_j,Σ 拼成矩阵):

L(w)=1mXT(py)\nabla L(w) = \frac{1}{m}X^T(p - y)

6.2 与线性回归逐行对照(本章的压舱石)

线性回归(Part 3) 逻辑回归(本章)
预测 y^=Xw\hat{y} = Xw p=σ(Xw)p = \sigma(Xw)(多套一层 sigmoid)
损失 MSE 12m(y^y)2\frac{1}{2m}\sum(\hat{y}-y)^2 交叉熵 1m[ylogp+]-\frac{1}{m}\sum[y\log p + \dots]
误差项 y^y\hat{y} - y pyp - y(形式一样!)
梯度 1mXT(y^y)\frac{1}{m}X^T(\hat{y}-y) 1mXT(py)\frac{1}{m}X^T(p-y)完全同形
更新 wwηLw \leftarrow w - \eta\nabla L 一字不差

同一个梯度下降框架,换的只是"预测头"和"打分机"——这就是当初 Part 3 坚持走梯度下降路线的回报:框架可以无限复用。

7. 代码精讲

7.1 接口(include/part04/logistic_regression.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class LogisticRegression
{
public:
LogisticRegression(double learningRate = 0.5, std::size_t maxIterations = 2000,
double tolerance = 1e-12);

void fit(const Matrix& X, const Matrix& y); // 训练:梯度下降(y 取 0/1)
Matrix predictProba(const Matrix& X) const; // 概率:σ(Xw) ∈ (0,1)
Matrix predict(const Matrix& X) const; // 类别:p≥0.5 → 1
double computeLoss(const Matrix& X, const Matrix& y) const; // 交叉熵
double computeAccuracy(const Matrix& X, const Matrix& y) const; // 分类正确率

const Matrix& weights() const;
const std::vector<double>& lossHistory() const;

private:
static double sigmoid(double z);
static double lossAt(const Matrix& X, const Matrix& y, const Matrix& w);
...
};

LinearRegression 逐一对应的成员——结构上几乎是复制粘贴,只改了三处:sigmoid、交叉熵、predict 的阈值硬化。这本身就是 5.2 节表格的代码证据。

7.2 fit 循环:与 Part 3 的 diff 只有几行

1
2
3
4
5
6
7
8
9
10
for (...)
{
const part02::Matrix z = x * w; // z = Xw
part02::Matrix p{ z.rows(), 1 };
for (std::size_t i = 0; i < z.rows(); ++i)
p(i, 0) = sigmoid(z(i, 0)); // p = σ(z) ← 新增
const part02::Matrix grad = (1.0 / m) * x.transpose() * (p - y); // X^T(p−y)/m
w = w - m_learningRate * grad; // 与 Part 3 一字不差
...
}

7.3 数值稳定性:log(0) 是 −inf

交叉熵要对 pp 取对数。若某轮 pp 因浮点误差变成 0 或 1,log(0)=\log(0) = -\infty 直接污染损失。标准防御:把 pp **夹紧(clip)**到 [1012,11012][10^{-12}, 1-10^{-12}]

1
const double clipped = std::min(std::max(p, 1e-12), 1.0 - 1e-12);

这是数值代码的常见手法:数学上不变(正常值夹不动),工程上挡住边界灾难。面试与实战都常考

8. 实验(src/main.cpp,对应输出 output/part04/

  1. 手算例子的代码验证:两个样本 (x=0,y=0)(x{=}0,y{=}0)(x=1,y=1)(x{=}1,y{=}1),起点 w=(0,0)w=(0,0)
    • σ(0)=0.5\sigma(0)=0.5,两个预测都是 0.5;
    • 梯度:L/w0=12(0.5+(0.5))=0\partial L/\partial w_0 = \frac{1}{2}(0.5+(-0.5))=0L/w1=12(00.5+1(0.5))=0.25\partial L/\partial w_1 = \frac{1}{2}(0\cdot0.5+1\cdot(-0.5))=-0.25
    • η=1\eta=1 走一步:w=(0,0.25)w=(0, 0.25)——程序逐步打印对账,再训练到底验证预测正确;
  2. 合成数据:两团二维高斯(类 0 中心 (2,2)(-2,-2),类 1 中心 (2,2)(2,2)),每类 50 个样本,逐特征标准化(Part 1 的 mean/stddev);
  3. 训练 + 准确率
  4. 可视化decision_boundary.png(两团散点 + 学到的边界直线)、loss.png(交叉熵下降曲线)。

9. 构建与运行

1
2
3
4
cmake -S . -B build
cmake --build build --config Release
.\bin\Release\ml_part04.exe
# 图表输出到 output/part04/(decision_boundary.png + loss.png)

10. 练习(强烈建议动手)

  1. 阈值实验predict 的 0.5 改成 0.9(保守,宁可放过)或 0.3(激进,宁可错杀),重新统计准确率——什么场景该保守(癌症筛查)?什么场景该激进(垃圾邮件)?
  2. sigmoid 手算σ(1)=?\sigma(1) = ?(答案 0.731);再验证 σ(1)=1σ(1)\sigma(-1) = 1 - \sigma(1)
  3. 交叉熵手算y=1y=1pp 从 0.5 → 0.9 → 0.99,罚金如何变化?(0.693 → 0.105 → 0.01)
  4. 预习思考(Part 5 揭晓):决策边界是直线——如果两团数据长成"双月牙"互相缠绕,一条直线还切得开吗?怎么办?
  5. (进阶)NB = LR 的特殊情形:设两类特征都是同协方差高斯(Σ\Sigma 相同、均值 μ0,μ1\mu_0,\mu_1)。写出后验比 P(y=1x)P(y=0x)\frac{P(y=1|x)}{P(y=0|x)},取 log,证明它是 xx线性函数(提示:二次项 xTΣ1xx^T\Sigma^{-1}x 在分子分母相同而抵消)——即朴素贝叶斯的决策面与逻辑回归同形。

11. 本部分小结与下一站

✅ 你已经掌握:

  • 分类的正确姿势:预测概率 + 阈值硬化,而不是硬回归 0/1
  • sigmoid 的形状与三件套:σ(0)=0.5\sigma(0)=0.5、单调、σ(z)=1σ(z)\sigma(-z)=1-\sigma(z)
  • 决策边界:z=0z=0 的直线,ww 是法向量
  • 交叉熵:自信的错误罚得最狠;与 sigmoid 天作之合(推导约掉 p(1p)p(1-p)
  • 交叉熵的出身:Bernoulli 极大似然(MSE 的出身:高斯噪声极大似然)——损失函数不是发明的,是"假设 + 概率"算出来的
  • 判别式 vs 生成式:逻辑回归直接建 P(y|x),朴素贝叶斯建 P(x|y)P(y) 再换向
  • 惊喜:梯度还是 XT(y^y)/mX^T(\hat{y}-y)/m——框架复用,只换预测头和损失
  • 工程点:概率 clip 防 log(0)

🚀 Part 5 预告 —— 神经网络与反向传播:把本章的"线性 + sigmoid"堆好多层,中间加 ReLU,就能学出弯曲的决策边界,切开双月牙。代价是梯度没法一步写出——需要链式法则逐层回传,这就是大名鼎鼎的反向传播。Part 3埋的"误差回传"伏笔正式揭晓。


反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。


Part 5 · 神经网络与反向传播:把"线性+激活"堆成多层

学习目标:把 Part 4 的"线性 + sigmoid"堆好多层、中间加 ReLU,就能学出弯曲的决策边界,切开直线切不开的双月牙数据。核心代价是梯度没法一步写出——需要链式法则逐层回传,这就是大名鼎鼎的反向传播(backpropagation)。Part 3 埋下的"XTX^T = 误差回传"伏笔在此正式揭晓。

对应代码:src/part05_neural_network/(静态库 part05 + 可执行程序 ml_part05

前置:Part 3(链式法则、梯度下降)、Part 4(sigmoid + 交叉熵,尤其"梯度 = pyp-y"那步推导)


1. 从逻辑回归到神经元

1.1 逻辑回归就是一个神经元

回看 Part 4 的模型:

z=w0+w1x1+w2x2p=σ(z)z = w_0 + w_1 x_1 + w_2 x_2 \qquad p = \sigma(z)

画成图:输入 → 加权求和激活函数 → 输出。这个结构在神经网络术语里就叫一个神经元(neuron)ww权重w0w_0偏置(bias),sigmoid 叫激活函数(activation)。你已经学了半个学期的"神经元"了。

1.2 直线的天花板

Part 4 练习 4 的思考题:双月牙数据(两个互相缠绕的半圆)——逻辑回归的决策边界必须是一条直线z=0z=0 是线性方程),而月牙缠在一起,任何直线都会切错一大片。这不是训练不充分,是模型表达能力(容量)的天花板

1.3 破局:在中间加一层"特征加工厂"

思路:与其直接拿原始特征 x1,x2x_1, x_2 去拟合,不如先让网络自己学出几个更好的中间特征 h1,h2,h_1, h_2, \dots(每个 hjh_j 是一个神经元的输出,本身就是一个逻辑回归式的"弯折特征"),再用这些中间特征做最终判断:

x输入层h=ReLU(W0x)隐藏层:学特征p=σ(W1h)输出层:做判断\underbrace{x}_{\text{输入层}} \to \underbrace{h = \text{ReLU}(W_0 x)}_{\text{隐藏层:学特征}} \to \underbrace{p = \sigma(W_1 h)}_{\text{输出层:做判断}}

每个隐藏神经元学一个"分段线性的小特征"(ReLU 的样子),组合起来就能拼出弯曲的决策边界。层数越多、每层越宽,能拼出的形状越复杂——这就是"深度"学习的"深"。

2. 前向传播:一层一个矩阵乘法

2.1 公式(以两层网络 {2, 8, 1} 为例)

z(1)=a(0)W0,a(1)=ReLU(z(1)),z(2)=a(1)W1,p=σ(z(2))z^{(1)} = a^{(0)} W_0, \quad a^{(1)} = \text{ReLU}(z^{(1)}), \quad z^{(2)} = a^{(1)} W_1, \quad p = \sigma(z^{(2)})

每层就是 Part 2/3/4 反复出现的那一步:矩阵乘法 + 逐元素激活。整批 mm 个样本一起算(矩阵的每一行是一个样本)——前向传播就是从左到右把各层乘过去。

2.2 偏置的矩阵化:给每层输入加一列 1

技巧与 Part 3 的偏置列完全一致:把每层输入 a(l)a^{(l)}m×nlm \times n_l左侧拼一列全 1 得到 a~(l)\tilde{a}^{(l)}m×(nl+1)m \times (n_l{+}1)),权重 WlW_l(nl+1)×nl+1(n_l{+}1) \times n_{l+1}——第 0 行就是偏置。这样 a~(l)Wl\tilde{a}^{(l)} W_l 一次把"加权和 + 偏置"全算了。

2.3 激活函数:为什么隐藏层用 ReLU 而不是 sigmoid

ReLU(z)=max(0,z)\text{ReLU}(z) = \max(0, z)

  • 计算极简(一个比较),导数只有两个值(z>0z>0 时 1,否则 0);
  • 不饱和:sigmoid 在两端(p0p\to011)导数趋近 0,多层相乘后梯度指数级缩小(梯度消失),深层网络几乎学不动;ReLU 正区间导数恒为 1,梯度畅通无阻;
  • 输出层仍用 sigmoid——因为我们要概率(Part 4 的角色不变)。

3. 反向传播:链式法则的逐层接力

3.1 问题:损失对第 0 层权重的梯度是什么?

Part 4 的梯度 1mXT(py)\frac{1}{m}X^T(p-y) 一步到位,因为只有一层。现在 W0W_0 影响 z(1)z^{(1)}z(1)z^{(1)} 影响 a(1)a^{(1)}a(1)a^{(1)} 影响 z(2)z^{(2)},最后才影响 LL。链式法则(Part 3 附录 A 的"逐层剥洋葱")说:梯度 = 路径上每一步导数的连乘,而且可以从输出端往输入端逐层算——算某一层时,只需要"上一层回传来的误差",这就是反向传播的名字由来。

3.2 逐分量推导:链式法则怎么长出矩阵公式

四行公式不是天上掉下来的。这一节从一个权重出发,把下一节的矩阵公式一步一步推出来——看完你就明白“为什么偏偏是转置 × 误差”。

第 1 步:盯住一个权重,写出它的完整影响链。

wjk(l)w_{jk}^{(l)} 是“第 ll 层第 jj 个神经元的输出 → 第 l+1l{+}1 层第 kk 个神经元”的连线强度。动它一下,链条是:

wjk(l)zk(l+1)ak(l+1)Lw_{jk}^{(l)} \to z_k^{(l+1)} \to a_k^{(l+1)} \to \cdots \to L

第 2 步:定义 δ——把长链条折叠的记号。

链条中段 zk(l+1)z_k^{(l+1)} 之后再怎么走,跟 wjkw_{jk} 无关(那是下游的事)。干脆定义误差

δk(l+1)  =def  Lzk(l+1)\delta_k^{(l+1)} \;\overset{\text{def}}{=}\; \frac{\partial L}{\partial z_k^{(l+1)}}

(误差 = 损失对该层预激活的敏感度。)于是链式法则立刻给出权重梯度

Lwjk(l)=zk(l+1)wjk(l)=aj(l)δk(l+1)=aj(l)δk(l+1)\frac{\partial L}{\partial w_{jk}^{(l)}} = \underbrace{\frac{\partial z_k^{(l+1)}}{\partial w_{jk}^{(l)}}}_{=\,a_j^{(l)}} \cdot \delta_k^{(l+1)} = a_j^{(l)}\, \delta_k^{(l+1)}

——本层输出 × 下游误差,一行结束。这就是四公式里 ③ 的逐分量版本:把 j,kj, k 摆回矩阵就是 a~Tδ\tilde{a}^T\delta

第 3 步:δ 自己怎么往回退一层?(核心一步)

δj(l)=L/zj(l)\delta_j^{(l)} = \partial L/\partial z_j^{(l)}zj(l)z_j^{(l)} 先变成 aj(l)=ReLU(zj(l))a_j^{(l)} = \text{ReLU}(z_j^{(l)}),然后 aj(l)a_j^{(l)} 汇入下一层每一个神经元的 zk(l+1)z_k^{(l+1)}——每条连线一条路径!链式法则对所有路径求和

δj(l)=kLzk(l+1)zk(l+1)aj(l)aj(l)zj(l)=ReLU(zj(l))kwjk(l)δk(l+1)\delta_j^{(l)} = \sum_k \frac{\partial L}{\partial z_k^{(l+1)}} \cdot \frac{\partial z_k^{(l+1)}}{\partial a_j^{(l)}} \cdot \frac{\partial a_j^{(l)}}{\partial z_j^{(l)}} = \text{ReLU}'(z_j^{(l)}) \sum_k w_{jk}^{(l)}\, \delta_k^{(l+1)}

读出来:一个神经元的误差 = 它每条下游连线上“对端误差 × 连线权重”的总和,再乘自己的激活导数。求和是因为它的输出分流给了所有下游——每条路径都带回一份影响。

第 4 步:把求和写成矩阵乘。

kwjk(l)δk(l+1)\sum_k w_{jk}^{(l)} \delta_k^{(l+1)} 正是 δ(l+1)WlT\delta^{(l+1)} W_l^T 的第 jj 个元素——转置的几何意义就是“把下行流改成上行流”(权重矩阵正向是“上一层的谁流向我”,转置后是“我流向下一层的谁”)。逐元素的 ReLU‘ 用 \odot 对齐。于是:

δ(l)=(δ(l+1)WlT)ReLU(z(l))\delta^{(l)} = \big(\delta^{(l+1)} W_l^T\big) \odot \text{ReLU}'(z^{(l)})

四公式里的 ② 就位。加上 ①(Part 4 已推:sigmoid+交叉熵 → δ(L)=py\delta^{(L)} = p - y)和 ④(梯度下降原样),四行公式全部到手——矩阵形式不是额外假设,是逐分量求和的自然打包

第 5 步:验算。 3.4 节的微型网络 {1,1,1} 里“求和只剩一项”:δ1=w1δ2ReLU(z1)\delta_1 = w_1 \cdot \delta_2 \cdot \text{ReLU}'(z_1),正是第 3 步公式的特例——到时逐位对账。

3.3 四行核心公式(本章的压舱石)

从输出层往回走(\odot 是逐元素相乘,a~\tilde{a} 是带偏置列的输入):

步骤 公式 直觉
① 输出层误差 δ(L)=py\delta^{(L)} = p - y Part 4 推导过:sigmoid+交叉熵的梯度就是 pyp-y
② 误差回传到隐藏层 δ(l)=(δ(l+1)Wl+1T)去掉偏置列ReLU(z(l))\delta^{(l)} = \big(\delta^{(l+1)} W_{l+1}^T\big)_{\text{去掉偏置列}} \odot \text{ReLU}'(z^{(l)}) WTW^T 把误差反向投射回上一层——Part 3 的 XTX^T 伏笔!
③ 权重梯度 LWl=1ma~(l)Tδ(l+1)\frac{\partial L}{\partial W_l} = \frac{1}{m}\tilde{a}^{(l)T} \delta^{(l+1)} 又是"转置 × 误差"!与 Part 3/4 完全同构
④ 更新 WlWlηLWlW_l \leftarrow W_l - \eta \frac{\partial L}{\partial W_l} 一字不差的梯度下降

注意三个结构上眼熟的零件δ(L)=py\delta^{(L)} = p - y(Part 4 的误差项)、WTW^T 反向投射(Part 3 的 XTX^T)、1ma~Tδ\frac{1}{m}\tilde{a}^T\delta(Part 3 的 1mXTerr\frac{1}{m}X^T\text{err})。反向传播没有任何新数学——只是链式法则把熟悉的零件按层串起来(3.2 节刚从零验证了这一点)。

3.4 逐元素手算(实验 0 会对账)

微型网络 {1, 1, 1}(1 输入、1 隐藏、1 输出),单样本 x=1x=1,手设权重 W0=(0.10.5)W_0 = \begin{pmatrix}0.1 \\ 0.5\end{pmatrix}(偏置 0.1、权重 0.5),W1=(0.20.3)W_1 = \begin{pmatrix}0.2 \\ 0.3\end{pmatrix}

1
2
前向:z₁ = 0.1 + 0.5×1 = 0.6 → h = ReLU(0.6) = 0.6
z₂ = 0.2 + 0.3×0.6 = 0.38 → p = σ(0.38) ≈ 0.5939

若真值 y=1y=1:输出误差 δ2=py0.4061\delta_2 = p - y \approx -0.4061(预测偏低,误差为负,权重该往上调)。反向到隐藏层:δ1=δ2×0.3×ReLU(0.6)=0.4061×0.3×10.1218\delta_1 = \delta_2 \times 0.3 \times \text{ReLU}'(0.6) = -0.4061 \times 0.3 \times 1 \approx -0.1218梯度符号一路保持"该往哪调"的信息——这就是"误差信号回传"。

3.5 为什么必须随机初始化(Part 3 的礼物正式收回)

Part 3 说"起点选 (0,0) 无妨,碗只有一个底"。网络世界不一样:

  • 若所有权重为 0(或相同值),每个隐藏神经元算出完全相同的输出、收到完全相同的梯度、做完全相同的更新——8 个隐藏单元塌缩成 1 个,网络永远学不出多样的特征。这叫对称性问题
  • 解决:随机小值初始化打破对称。我们用 He 初始化:N(0,2/nin)\mathcal{N}(0, \sqrt{2/n_{in}})(方差与输入宽度挂钩,信号不随层深爆炸/消失);
  • 代价:损失曲面非凸(多个谷),不同初始化可能到达不同的谷——Part 3 的"起点不影响终点"的凸性礼物,从本章起正式失效。

3.6 梯度检查:反向传播的救命测试

反向传播代码极易写错(下标差一位、偏置列漏处理),而且错误是静默的——网络照跑,只是学得慢/差,你根本不知道有 bug。救命工具就是 Part 3 练习 2 的有限差分:

LWjL(W+ϵej)L(Wϵej)2ϵ,ϵ=105\frac{\partial L}{\partial W_j} \approx \frac{L(W + \epsilon e_j) - L(W - \epsilon e_j)}{2\epsilon}, \qquad \epsilon = 10^{-5}

不用任何推导就能测出真梯度,与反向传播的结果逐元素对账(容差 1e-6)。工业界铁律:任何新写的反向传播,先过 gradient check 再说。我们的实验 1 就做这件事。

4. 代码精讲

4.1 接口(include/part05/neural_network.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NeuralNetwork
{
public:
// layerSizes 如 {2, 8, 1}:输入 2、隐藏 8(ReLU)、输出 1(sigmoid)
NeuralNetwork(const std::vector<std::size_t>& layerSizes,
double learningRate = 1.0, std::size_t maxIterations = 3000,
double tolerance = 1e-12);

void fit(const Matrix& X, const Matrix& y);
Matrix predictProba(const Matrix& X) const;
Matrix predict(const Matrix& X) const;
double computeLoss(const Matrix& X, const Matrix& y) const;
double computeAccuracy(const Matrix& X, const Matrix& y) const;

const std::vector<Matrix>& weights() const; // W[l]:(n_l+1)×n_{l+1},第 0 行是偏置
void setWeight(std::size_t layer, std::size_t r, std::size_t c, double v); // 梯度检查用
std::vector<Matrix> computeGradients(const Matrix& X, const Matrix& y) const; // 反向传播
const std::vector<double>& lossHistory() const;
};

4.2 形状速查表(排错时贴在手边)

记号 形状 说明
XX m×n0m \times n_0 本类自带偏置:隐藏层偏置由 WW 的第 0 行承担,XX 不需要拼 1 列(与 Part 3/4 不同!)
WlW_l (nl+1)×nl+1(n_l{+}1) \times n_{l+1} 第 0 行是偏置
a~(l)\tilde{a}^{(l)} m×(nl+1)m \times (n_l{+}1) 运行时拼出来的带偏置输入
δ(l)\delta^{(l)} m×nlm \times n_l 每个样本在该层每个神经元的误差
L/Wl\partial L/\partial W_l (nl+1)×nl+1(n_l{+}1) \times n_{l+1} WlW_l 同形(必须同形!)

4.3 公式↔代码对照(核心循环)

1
2
3
4
5
6
// 前向:左到右,每层 = 拼偏置列 → 矩阵乘 → 激活
// 反向:右到左
// delta = p - y ← 公式①
// grad = (1/m) * aT[l].transpose() * delta ← 公式③
// deltaPrev = (delta * W^T 去偏置列) ⊙ relu'(z) ← 公式②
// 更新:W -= lr * grad ← 公式④

与 Part 3 的 fit 循环对照:外圈还是同一个梯度下降,只是"梯度"从一步公式变成了逐层回传的函数

5. 实验(src/main.cpp,对应输出 output/part05/

  1. 前向手算对账:3.4 节的微型网络 {1,1,1},手设权重,程序前向算出 p0.5939p \approx 0.5939 与手算一致;
  2. 梯度检查:网络 {2,3,1} + 5 个样本,逐权重做有限差分 vs 反向传播,报告最大偏差(应 < 1e-6);
  3. 双月牙对决:同一个月牙数据集上,逻辑回归(Part 4)vs 神经网络 {2,8,1},比较准确率;
  4. 可视化decision_boundary.png(左右两个子图:逻辑回归的直边界 vs 神经网络的弯边界)、loss.png(两者损失曲线)。

6. 构建与运行

1
2
3
4
cmake -S . -B build
cmake --build build --config Release
.\bin\Release\ml_part05.exe
# 图表输出到 output/part05/(decision_boundary.png + loss.png)

7. 练习(强烈建议动手)

  1. 隐藏层宽度实验:{2,4,1} / {2,16,1} / {2,8,8,1}(两层隐藏),对比决策边界的平滑度与准确率;
  2. 对称性实验(必做):把初始化改成全零(临时改 setWeight),重训双月牙——亲眼见证网络塌缩、准确率掉到 ~50%(等价于瞎猜);
  3. 初始化种子实验:换几个随机种子重训,观察最终损失略有不同——非凸世界的日常;
  4. 手算:3.4 节例子中若 y=0y=0,输出误差 δ2\delta_2 是多少?(p0+0.5939p - 0 \approx +0.5939,预测偏高。)
  5. 思考:为什么 δ(l)\delta^{(l)} 回传时要"去掉偏置列"?(偏置列不是任何神经元的输出,没有误差可回传——它只是给下一层送常数 1。)

8. 本部分小结与下一站

✅ 你已经掌握:

  • 神经元 = 逻辑回归;网络 = 神经元分层堆叠,隐藏层是"特征加工厂"
  • 前向传播 = 逐层"矩阵乘法 + 激活";偏置矩阵化的拼 1 列技巧
  • ReLU vs sigmoid(饱和与梯度消失);He 初始化与对称性问题(凸性礼物收回)
  • 反向传播四公式:输出 pyp-yWTW^T 回传、1ma~Tδ\frac{1}{m}\tilde{a}^T\delta、同款更新——全是旧零件的层间接力;逐分量推导证明矩阵形式 = 路径求和的自然打包(3.2 节)
  • 梯度检查:有限差分对账,反向传播的救命测试

🚀 Part 6 预告 —— 正则化与优化器:网络容量大了会过拟合(训练集背得滚瓜烂熟,新数据一塌糊涂),需要 L2 正则化"熨平"模型;梯度下降也有升级版——动量(惯性冲过狭长谷)与 Adam(自适应步长),训练速度天差地别。


反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。


Part 6:正则化与优化器——管住模型的野心的两把工具

前置:Part 3(梯度下降 + 损失函数)、Part 4(逻辑回归)、Part 5(神经网络)。
本篇回答两个独立但都至关重要的问题:
① 模型太"聪明"反而坏事(过拟合)——怎么管?→ L2 正则化
② 梯度下降下山太笨(震荡/龟速)——怎么快?→ 动量、Adam


1. 问题:背题的学生

想象两个学生备考:

  • 学生 A:把历年真题的答案逐字背下来,真题卷满分;
  • 学生 B:只学解题思路,真题卷 85 分。

考试换了新题:学生 A 崩了(20 分),学生 B 照样 80 分。

模型也一样。把前面的模型在训练数据上的损失称为训练损失,在没见过的新数据上的损失称为测试损失

现象 训练损失 测试损失 诊断
学生 B 中等 中等 拟合得当 ✓
学生 A 极低 很高 过拟合(overfitting)
没复习 很高 很高 欠拟合(underfitting)

判据:过拟合 = 训练损失和测试损失裂开巨大的缝

1.1 为什么会过拟合:自由度太多

用多项式拟合看最直观。直线 y = w₀ + w₁x 有 2 个自由度;9 次多项式

y^=w0+w1x+w2x2++w9x9\hat{y} = w_0 + w_1 x + w_2 x^2 + \cdots + w_9 x^9

有 10 个自由度。给它 12 个带噪声的训练点,λ=0(不约束)时它会穿过每一个点——连噪声都当成了规律。新数据一来,这条疯狂震荡的曲线预测得一塌糊涂。

一句话:参数越多、约束越少,模型越有本事把噪声也背下来。

1.2 治法预告

  • 正则化(本篇上半场):在损失里加一条"罚款规则"——权重越大罚得越狠,逼模型只用"够用"的弯曲。
  • 优化器(下半场)不解决过拟合,解决"下山太笨"——但它是训练一切大模型的发动机,必须现在学。

2. 上半场:L2 正则化(Ridge 回归)

2.1 给损失加一条家规

原来的损失(Part 3):

L(w)=12mi=1m(y^(i)y(i))2L(w) = \frac{1}{2m}\sum_{i=1}^{m}(\hat{y}^{(i)} - y^{(i)})^2

Ridge 回归把它改成:

L(w)=12mi=1m(y^(i)y(i))2旧损失:拟合数据+λ2mj=1nwj2新增:权重罚款L(w) = \underbrace{\frac{1}{2m}\sum_{i=1}^{m}(\hat{y}^{(i)} - y^{(i)})^2}_{\text{旧损失:拟合数据}} + \underbrace{\frac{\lambda}{2m}\sum_{j=1}^{n} w_j^2}_{\text{新增:权重罚款}}

读法:

  • 第一项还是老朋友——“预测得准不准”;
  • 第二项是家规——“每个权重的平方都要交罚款”,权重越大罚款越高。

模型现在要在两个愿望之间权衡:想拟合数据(第一项小)又怕交罚款(第二项小)。λ(lambda)就是罚款力度旋钮

λ 效果 极端情况
λ = 0 不罚款,退化为 Part 3 的线性回归 想怎么弯就怎么弯
λ 适中 权重被压小,曲线变平滑 该弯的弯、不该弯的收着
λ → ∞ 罚款压倒一切,所有权重 → 0 躺平成一条水平线

2.2 梯度多了一项(推导三行)

对 w_j 求偏导,旧项 Part 3 已推过,新项:

wjλ2mjwj2=λmwj\frac{\partial}{\partial w_j}\frac{\lambda}{2m}\sum_j w_j^2 = \frac{\lambda}{m}w_j

所以总梯度:

L(w)=1mXT(Xwy)+λmw~,w~=(0,w1,w2,)T\nabla L(w) = \frac{1}{m}X^T(Xw - y) + \frac{\lambda}{m}\tilde{w}, \qquad \tilde{w} = (0, w_1, w_2, \ldots)^T

w~\tilde{w} 就是 w 把第 0 个位置(偏置)抠成 0 的版本。

为什么偏置不罚款? 偏置 w₀ 只是个整体抬升/下沉的量,它不控制"弯曲程度",罚它没有意义——家规只管"形状",不管"高低"。

2.3 手算例子(实验 0 会逐位对账)

单样本,x = (1, 2)(第 0 列是偏置列 1),y = 1,当前 w = (1, 1),λ = 1,m = 1。

第一步,预测与残差:

y^=1×1+1×2=3,y^y=2\hat{y} = 1 \times 1 + 1 \times 2 = 3, \qquad \hat{y} - y = 2

第二步,旧梯度(Part 3 同款):

1mXT(Xwy)=(1×22×2)=(24)\frac{1}{m}X^T(Xw - y) = \begin{pmatrix}1 \times 2 \\ 2 \times 2\end{pmatrix} = \begin{pmatrix}2 \\ 4\end{pmatrix}

第三步,罚款梯度(偏置位置清零):

λmw~=(01×1)=(01)\frac{\lambda}{m}\tilde{w} = \begin{pmatrix}0 \\ 1 \times 1\end{pmatrix} = \begin{pmatrix}0 \\ 1\end{pmatrix}

第四步,合计:

L=(24)+(01)=(25)\nabla L = \begin{pmatrix}2 \\ 4\end{pmatrix} + \begin{pmatrix}0 \\ 1\end{pmatrix} = \begin{pmatrix}2 \\ 5\end{pmatrix}

第五步,一步更新(学习率 η = 0.1,Part 3 同款):

w=w0.1×(25)=(0.80.5)w' = w - 0.1 \times \begin{pmatrix}2 \\ 5\end{pmatrix} = \begin{pmatrix}0.8 \\ 0.5\end{pmatrix}

注意看:罚款项只把 w₁ 从 1 压向 0.5,偏置 w₀ 完全没被罚款动过(它只被数据梯度从 1 推到 0.8)。家规长眼睛。

2.4 闭式解:一步到位的另一种解法(了解)

梯度下降不是唯一的路。Ridge 的目标是二次函数,梯度置零可以直接解出 w(不带偏置,两边约去 m):

XTXw+λw=XTy w=(XTX+λI)1XTy X^TXw + \lambda w = X^Ty \quad\Longrightarrow\quad \boxed{\ w = (X^TX + \lambda I)^{-1}X^Ty\ }

(推导:把 2.2 的梯度置零、移项,三行。)两个观察:

  1. λ 顺手治了另一种病:XᵀX 在特征强相关(多重共线性)时接近奇异、求逆数值爆炸;+λI 后必正定可逆——λ 不只管过拟合,还管数值稳定(岭回归的“岭”就是给山脊加土)。λ→0 退化为 Part 3 的正规方程;
  2. 为什么本篇代码仍用梯度下降?求逆 O(n³),特征上千就贵得离谱;更重要的是闭式解只属于二次损失这一小族——梯度下降属于一切可导模型(Part 5 的网络永远没有闭式解)。学梯度下降是买了一张万能票,也让 Ridge 与 Part 3/4/5 共用同一套训练循环。

3. 下半场:优化器族——下山的三种姿势

Part 3 学的梯度下降有个没说破的弱点。先看清楚它。

3.1 困境:又窄又长的峡谷

想象损失曲面不是圆碗,而是一条又窄又长的峡谷:横向(w₁ 方向)坡度平缓,纵向(w₂ 方向)坡度陡峭。数学上就是一个"各向异性"的二次函数:

L(w)=12(0.01w12+5w22)L(w) = \frac{1}{2}(0.01\,w_1^2 + 5\,w_2^2)

梯度 L=(0.01w1, 5w2)\nabla L = (0.01w_1,\ 5w_2)。两个方向陡峭程度差 500 倍

从 (10, 10) 出发用固定步长下山:

  • 步子敢迈大(η 接近 0.4)→ 陡方向(w₂)反复冲过谷底、弹回来,来回震荡甚至发散;
  • 步子只敢迈小 → 陡方向倒是稳了,平缓方向(w₁)以龟速挪动,几千轮都走不完。

这不是假想敌——标准化做不好、或深层网络里不同参数需要不同步长,就是这种峡谷。真实场景里它才是常态。

3.2 姿势一:SGD(基准)

就是我们一直在用的:

wwηLw \leftarrow w - \eta \cdot \nabla L

一个步长走天下。在峡谷里它只能"照顾陡的一头"(步长被陡方向钳制),平缓方向慢得像蜗牛。

术语说明:严格说 SGD 指"每次只取一小批样本算梯度"(随机性来自抽样)。本篇把全体样本的版本也叫 SGD——因为相比后面两位,它的特征是"没有任何额外机制,就是裸的梯度下降"。

3.3 姿势二:动量(Momentum)——惯性小球

把"每轮独立决策"改成"滚下山的小球"——记住之前跑过的方向

vβv+Lwwηv\begin{aligned} v &\leftarrow \beta v + \nabla L \\ w &\leftarrow w - \eta \, v \end{aligned}

  • v 是速度(历史梯度的指数加权平均):方向一致时 v 越滚越大——谷底方向持续加速
  • 方向反复(震荡方向)时,正负梯度相互抵消,v 自动变小——震荡被惯性抹平
  • β(常用 0.9)= “记忆长度”:每一步保留 90% 旧速度。

一箭双雕:平缓方向因为方向恒定被加速约 1/(1β)=101/(1-\beta) = 10 倍,陡峭方向因为来回变向被自动刹车。

手算(加速从哪来):设某平缓方向梯度恒为 g = 1(η = 0.1、β = 0.9、v₀ = 0):

v1=1,v2=0.9×1+1=1.9,v3=0.9×1.9+1=2.71, v_1 = 1,\quad v_2 = 0.9\times1 + 1 = 1.9,\quad v_3 = 0.9\times1.9 + 1 = 2.71,\ \dots

几何级数,v110.9=10v \to \frac{1}{1-0.9} = 10——同样 η = 0.1,动量版的步长从 0.1 涨到 1.0,恰好 10 倍(公式 1/(1β)1/(1-\beta) 的兑现)。而震荡方向的梯度正负交替(+1, −1, …):v₁ = 1、v₂ = −0.1、v₃ = 0.91、v₄ = −0.181……摆动幅度稳定在 ±0.5 附近——和平缓方向的 10 相比不到二十分之一。同一只小球:直道 10 倍油门,弯道自动刹车。

3.4 姿势三:Adam——每个参数配私人步长

动量还有个盲区:它仍然对所有参数用同一个 η。Adam(2015,深度学习默认优化器)的思路:给每个参数单独测坡度,再决定步长

三个量(对每一个参数分量 j 分别维护):

\begin{aligned} m_j &\leftarrow \beta_1 m_j + (1-\beta_1)\, g_j && \text{梯度的一阶矩(方向·平均坡度)}\\ v_j &\leftarrow \beta_2 v_j + (1-\beta_2)\, g_j^2 && \text{梯度的二阶矩(坡度平方的平均)}\\ w_j &\leftarrow w_j - \eta \cdot \frac{\hat{m}_j}{\sqrt{\hat{v}_j} + \varepsilon} \end{aligned}

其中 m^j=mj/(1β1t)\hat{m}_j = m_j/(1-\beta_1^t)v^j=vj/(1β2t)\hat{v}_j = v_j/(1-\beta_2^t)偏差修正(开头几轮 m、v 都还小,除以一个小于 1 的数把估值抬回真实量级;t 是轮数)。

直觉读法:

  • 分子 m^j\hat{m}_j:这个参数的"平均下坡方向"(动量的思想);
  • 分母 v^j\sqrt{\hat{v}_j}:这个参数的"历史坡度尺子"——坡一直很陡的参数,分母大,步子自动变小;坡一直平缓的参数,分母小,步子自动变大
  • 比值 m^j/v^j\hat{m}_j/\sqrt{\hat{v}_j} 量级约为 ±1:相当于每一步统一迈"标准化大小"的步子,再由 η 统一缩放。

峡谷问题被釜底抽薪:陡参数自己刹车,缓参数自己踩油门。推荐默认值 β1=0.9, β2=0.999, ε=108\beta_1 = 0.9,\ \beta_2 = 0.999,\ \varepsilon = 10^{-8}

手算(偏差修正的妙处):第一步(m = v = 0,梯度 g 任意大小):

m1=0.1g,  m^1=0.1g10.9=g;v1=0.001g2,  v^1=0.001g210.999=g2m_1 = 0.1g,\ \ \hat{m}_1 = \frac{0.1g}{1-0.9} = g;\qquad v_1 = 0.001g^2,\ \ \hat{v}_1 = \frac{0.001g^2}{1-0.999} = g^2

步长=ηgg2+εηsign(g)\text{步长} = \eta\cdot\frac{g}{\sqrt{g^2}+\varepsilon} \approx \eta\cdot\mathrm{sign}(g)

修正后的第一步恰好迈 η 个单位——与梯度大小无关:g = 100 和 g = 0.001 迈同样的步。这就是“每个参数配私人步长”的数学兑现:梯度只定方向,尺度被 v^\sqrt{\hat v} 归一。偏差修正不是装饰细节——没有它,第一步的 m 被低估 10 倍、v 被低估 1000 倍,冷启动直接跛脚。

3.5 三兄弟对照表

步长 记忆 一句话
SGD 全局一个 η 裸梯度下降,峡谷里顾此失彼
动量 全局一个 η 方向(一阶矩) 惯性小球:直道加速、弯道刹车
Adam 每个参数一个 方向 + 坡度(一阶 + 二阶矩) 私人定制步长,深度学习默认款

4. 深化:正则化的家族视角——各家都在管同一个野心

学到现在你已经见过七八种模型了。回头看会发现一件事:每个模型都有自己的"管野心"手段,而且它们的骨架惊人地一致。本章做两件事:先搞清楚 L2 罚款的"真实身份",再把全课程的正则化手段收进一张族谱。

4.1 L2 的另一重身份:高斯先验(贝叶斯视角)

Part 4 揭示了交叉熵的身世——它就是极大似然。极大似然找的是"最能解释数据的 w":

wML=argmaxwP(Dw)w_{\text{ML}} = \arg\max_w P(D \mid w)

但 Part 9 的贝叶斯定理提醒我们:数据不是唯一的信息源。完整的问法应该是"看了数据之后,最可能的 w 是谁"——后验:

P(wD)=P(Dw)P(w)P(D)P(w \mid D) = \frac{P(D \mid w)\,P(w)}{P(D)}

求让它最大的 w(最大后验,MAP)。P(D) 与 w 无关扔掉,取 log:

wMAP=argmaxw[logP(Dw)+logP(w)]w_{\text{MAP}} = \arg\max_w \left[ \log P(D \mid w) + \log P(w) \right]

对比极大似然,MAP 就多了一项 logP(w)\log P(w)——先验:看到数据之前,你对 w 的信念。

现在指定信念:“我认为每个权重多半在 0 附近,多大都不奇怪但越大越可疑”——这正是零均值高斯 wjN(0,τ2)w_j \sim N(0, \tau^2)。代入:

logP(w)=j(wj22τ212log(2πτ2))\log P(w) = \sum_j \left( -\frac{w_j^2}{2\tau^2} - \tfrac{1}{2}\log(2\pi\tau^2) \right)

常数项与 w 无关扔掉,最大化 logP(Dw)12τ2jwj2\log P(D|w) - \frac{1}{2\tau^2}\sum_j w_j^2 等价于最小化

旧损失+12τ2jwj2\text{旧损失} + \frac{1}{2\tau^2}\sum_j w_j^2

逐字就是 Ridge 罚款(对应关系 λ/m=1/τ2\lambda/m = 1/\tau^2)。

罚款不是拍脑袋定的家规,是一句信念的数学化:“权重多半不大”(高斯先验)。罚得越狠 = 信念越强(τ 越小 → λ 越大)。2.3 节手算用 λ=1、m=1——换算过去 τ²=1,即"我认为 w 大概在 ±1 量级",与例子里 w=(1,1) 的设定严丝合缝。

顺带掲晓练习 4 的彩蛋:L1 罚款 = 拉普拉斯先验 p(w)=12bew/bp(w) = \frac{1}{2b}e^{-|w|/b},−log 它正好正比于 jwj\sum_j |w_j|。拉普拉斯先验在 0 处有个尖峰(比高斯"更相信 w 恰好是 0"),所以 L1 会把权重压成精确的 0——这就是它"稀疏化"身世的由来。

到这里三条线合流了:

视角 说法 出处
损失视角 拟合项 + 罚款项拔河 本篇 2.1
概率视角 似然 × 先验 Part 9 贝叶斯定理
合流 罚款 = −log 先验;正则化 = 把信念写进损失 本节

4.2 复杂度控制全家福:每家都有自己的 λ

把 Part 6 到 Part 12(预告)所有"管野心"的手段拉到一张表里:

模型 怎么限制自由度 旋钮 代码落点
线性/逻辑回归 + L2 损失里加罚款(高斯先验) λ part06 computeGradients
CNN(Part 7) 结构上锁死:权重共享,全图只用一套卷积核 卷积核大小 part07 卷积层
kNN(Part 8) 用 k 个邻居投票抹平:k 越大边界越平滑 k part08
朴素贝叶斯(Part 9) 计数垫底(拉普拉斯平滑)+ 方差下限 α part09
决策树(Part 10) 预剪枝:限深度、限叶内样本数 maxDepth 等 part10
随机森林(Part 11) 不减单树复杂度,靠平均治方差 树数 + 双重随机 part11
SVM(Part 12) 边界要留白:间隔即约束 C part12(预告)

读法:每一行都是同一场拔河——"拟合数据"和"先验偏好"的拔河,只不过每家把偏好写在了不同的地方:

  • 有的写在损失里(L2/L1、SVM 的 C);
  • 有的写在结构里(CNN 的共享、树的深度上限);
  • 有的写在算法里(kNN 的 k、森林的双重随机)。

甚至还有一条"零号手段":早停(early stopping)——训练到一半偷懒停下,别背太熟。优化器篇讲它最合适:它把"训练轮数"当作免费的正则化旋钮,是深度学习里最常用的正则化之一。

4.3 旋钮的宿命:两头都不行

注意全家福里每个旋钮的共性:拧小了过拟合,拧大了欠拟合

  • λ:0 → 背题(实验 1 亲眼见过),∞ → 躺平;
  • kNN 的 k:k=1 碎边界,k=m 全体投票成一锅;
  • 树的 maxDepth:深了背噪声,浅了连月牙都啃不动(Part 10 实验 2 的跷跷板);
  • 森林树数:这个例外很有意思——树数只减方差不加偏差,所以"越多越好但有地板"(Part 11 方差公式),不存在过拧。

所以选正则化不是选"更优的技术",而是选世界观(Part 9 的词):你相信权重小(L2)?相信稀疏(L1)?相信局部平滑(k)?相信层次规则(树)?数据不够时,信念来凑——这就是正则化的全部哲学。


5. 代码精讲

模块 src/part06_regularization_optimizers/,两个组件:

  • ridge_regression.h/.cpp——带 L2 罚款的线性回归(骨架同 Part 3)
  • optimizer.h/.cpp——优化器族(SGD / 动量 / Adam)

5.1 关键设计:模型管梯度,优化器管更新

Ridge 回归不再自己写 w -= lr * grad,而是把梯度交给一个优化器对象:

1
2
3
// ridge_regression.cpp 的 fit 主循环(节选)
const part02::Matrix grad = computeGradients(x, y); // 含正则项的完整梯度
m_optimizer->update(m_weights, grad); // 怎么走,优化器说了算

关注点分离:模型负责"算坡度"(数学),优化器负责"怎么迈步"(策略)。换优化器不用动模型一行代码——这正是 PyTorch 里 loss.backward()optimizer.step() 分离的同款设计。

5.2 公式 ↔ 代码对照表

RidgeRegression::computeGradients(2.2 节公式):

数学 代码
1mXT(Xwy)\frac{1}{m}X^T(Xw - y) (x.transpose() * (x * w - y)) 逐元素 × 1/m
λmw~\frac{\lambda}{m}\tilde{w} 跳过第 0 行,其余 grad(r,0) += lambda/m * w(r,0)
偏置不罚 循环从 r = 1 开始

SGD(3.2 节):

数学 代码
wwηgw \leftarrow w - \eta g w = w - m_lr * grad(一步,与我们写了三篇的相同)

动量(3.3 节):

数学 代码
vβv+gv \leftarrow \beta v + g m_velocity = m_beta * m_velocity + grad
wwηvw \leftarrow w - \eta v w = w - m_lr * m_velocity

Adam(3.4 节):逐参数循环(Matrix 没有逐元素乘除,老老实实 for):

数学 代码
mjβ1mj+(1β1)gjm_j \leftarrow \beta_1 m_j + (1-\beta_1)g_j m_m(r,c) = b1*m_m(r,c) + (1-b1)*g(r,c)
vjβ2vj+(1β2)gj2v_j \leftarrow \beta_2 v_j + (1-\beta_2)g_j^2 m_v(r,c) = b2*m_v(r,c) + (1-b2)*g(r,c)*g(r,c)
m^j=mj/(1β1t)\hat{m}_j = m_j/(1-\beta_1^t) mhat = m_m(r,c) / (1 - pow(b1, t))
wjwjηm^j/(v^j+ε)w_j \leftarrow w_j - \eta\hat{m}_j/(\sqrt{\hat{v}_j}+\varepsilon) w(r,c) -= m_lr * mhat / (sqrt(vhat) + eps)

实现细节:

  • 动量/Adam 的状态(v、m、v)在第一次 update 时按 w 的形状惰性初始化——这样优化器构造时不必知道参数长什么样;
  • 优化器实例不要复用给两个不同任务(状态是按参数记的),文档注释里写明;
  • t 从 1 开始计,std::pow(β, t) 就是偏差修正的分母。

6. 实验(main.cpp)

  • 实验 0:手算对账——2.3 节例子:梯度 (2, 5)、更新后 w = (0.8, 0.5),逐位核对。
  • 实验 1:过拟合对决——12 个带噪声样本学 y = sin(1.2x),9 次多项式特征,λ ∈ {0, 0.01, 1}:
    • λ=0:训练损失极低、测试损失爆炸、权重范数巨大(背题学生);
    • λ=0.01:训练/测试都好(会学习的学生);
    • λ=1:训练/测试都平庸(躺平学生,欠拟合)。
    • 图 1:三条拟合曲线 + 数据点,眼见为实。
  • 实验 2:优化器峡谷赛跑——3.1 节峡谷 L=12(0.01w12+5w22)L = \frac{1}{2}(0.01w_1^2 + 5w_2^2),同一起点 (10, 10):
    • 图 2:三种优化器的损失曲线(对数纵轴),SGD 龟速、动量快一个量级、Adam 又快又稳。

7. 练习

  1. 把实验 1 的 λ 改成 0.1 和 10,观察测试损失的"倒 U 形"——正合适在中间,两头都不行。
  2. 实验 2 里把动量的 β 从 0.9 改成 0.99:记忆更长,直道加速更猛,但拐弯更容易过冲——找到它开始发散的临界点。
  3. 把 Adam 的学习率放大 10 倍(0.05 → 0.5):它还能收敛吗?换成 SGD 同样放大试试,体会"自适应"三个字的分量。
  4. 给 RidgeRegression 换 L1 罚款(λwj\lambda|w_j|,梯度符号函数 λsign(wj)\lambda\,\text{sign}(w_j)),观察权重被压成精确的 0(L1 稀疏化)与 L2 压小但非零的区别。

8. 小结

概念 一句话 代码落点
过拟合 训练损失低、测试损失高的裂缝 实验 1 对比表
L2 正则化 损失加权重平方罚款,λ 控力度 computeGradients 第二项
偏置豁免 罚款只管形状不管高低 循环从 r=1 开始
闭式解 (XᵀX+λI)⁻¹Xᵀy:λ 顺带治了奇异性 2.4 节
SGD 裸梯度下降 SgdOptimizer::update
动量 指数加权平均的历史方向,直道加速弯道刹车 MomentumOptimizer::update
动量 10 倍速 v → 1/(1−β):几何级数手算兑现 3.3 手算
Adam 一阶矩定方向、二阶矩定步长,每参数自适应 AdamOptimizer::update
模型/优化器分离 模型算梯度,优化器迈步 fit 里的 m_optimizer->update
罚款 = −log 先验 L2 = 高斯先验、L1 = 拉普拉斯先验的 MAP 4.1 推导
正则化全家福 各家都在"拟合数据 ↔ 先验偏好"拔河 4.2 族谱表

下一篇(Part 7)把 Part 5 的全连接网络升级成卷积网络——用"权重共享"管住参数数量(全家福表里"结构正则化"一栏的代表),训练则直接用上本篇的 Adam。


Part 7:卷积神经网络——让网络学会"看"

前置:Part 4(逻辑回归分类头)、Part 5(反向传播)、Part 6(优化器)。
本篇把前面所有零件组装成一台能看图的机器
并揭开深度学习三件套——卷积、池化、滤波器可视化——的盖子。


1. 问题:图像的"体积"碾压全连接网络

Part 5 的全连接网络输入双月牙数据只有 2 个特征,一切都好。换成图像呢?

一张 12×12 的小灰度图,摊平(flatten)成向量有 144 个数。接一个 100 个单元的隐藏层,仅这一层的权重就有 144 × 100 = 14400 个。一张手机照片 4000×3000,摊平是 1200 万个输入——第一层权重瞬间上亿,训练数据永远喂不饱。

但图像有两个与生俱来的规律,全连接网络完全无视:

  1. 局部性:判断一个像素是不是猫耳朵边缘,只需要看它周围几个像素,不需要看图片对角;
  2. 平移不变:猫挪到图片右下角还是猫。同一个"耳朵边缘检测器"应该在任何位置都能用

卷积神经网络(CNN)就是把这两条规律焊进架构里。


2. 卷积:一个模板扫全图

2.1 滤波器是什么

滤波器(filter / 卷积核)是一个小小的权重方阵,比如 3×3:

F=(f11f12f13f21f22f23f31f32f33)F = \begin{pmatrix} f_{11} & f_{12} & f_{13} \\ f_{21} & f_{22} & f_{23} \\ f_{31} & f_{32} & f_{33} \end{pmatrix}

它是一个模板:在图像上从左到右、从上到下滑动,每个位置做一次"对齐相乘再求和"——和 Part 3 的"加权求和"是同一个动作,只是权重不再是每个位置一套,而是全图共享一套

输出图的一个格子:

out(r,c)=i=1Fj=1FFijin(r+i1, c+j1)\text{out}(r, c) = \sum_{i=1}^{F}\sum_{j=1}^{F} F_{ij} \cdot \text{in}(r+i-1,\ c+j-1)

这个双层求和你已经认识:for 循环套 for 循环(附录 B 的老朋友)。“卷积"听起来吓人,本质就是"拿模板和局部小块对一下眼神,看看像不像”。

2.2 手算例子(实验 0 逐位对账)

输入 2×3 的图,滤波器 2×2,无偏置:

in=(123456),F=(1001)\text{in} = \begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{pmatrix}, \qquad F = \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}

(这个 F 只做"对角线相加":左上 + 右下。)

滑动两个位置:

out(0,0)=11+20+40+51=6\text{out}(0,0) = 1{\cdot}1 + 2{\cdot}0 + 4{\cdot}0 + 5{\cdot}1 = 6

out(0,1)=21+30+50+61=8\text{out}(0,1) = 2{\cdot}1 + 3{\cdot}0 + 5{\cdot}0 + 6{\cdot}1 = 8

输出 1×2 的小图 [6, 8]。若滤波器带偏置 b = 0.5,输出就是 [6.5, 8.5]。

注意尺寸账:输入 2×3,滤波器 2×2,输出 (2−2+1)×(3−2+1) = 1×2。通式:输出边长 = 输入边长 − 滤波器边长 + 1(这是步幅 1、零填充的特例,完整通式见 2.3)。

2.3 步幅与填充:尺寸账的通式

上面的账隐含两个默认值:滤波器每次挪 1 格(步幅 S = 1)、输入四周不补数(填充 P = 0)。把两个旋钮拧开:

  • 步幅 S:滤波器每次跳 S 格再扫。S = 2 时输出边长大约减半——下采样不靠池化也能做(现代架构常这么干);
  • 填充 P:在输入四周补 P 圈 0。不填充时输出比输入小——边缘像素被“剥削”了(它们参与的卷积次数比中心像素少,1 节的规律在边缘看得不清)。补 P = ⌊F/2⌋ 圈(F=3 补 1 圈)输出尺寸不变——边缘信息不被白白削掉,深层网络的尺寸链也好记。

两个旋钮合起来的完整通式:

out=inF+2PS+1\text{out} = \left\lfloor \frac{\text{in} - F + 2P}{S} \right\rfloor + 1

(取整是因为滤波器不能出界——含填充后。)手算三档(in = 12、F = 3):

S P 计算 输出 用途
1 0 12−3+1 10 本篇默认
1 1 (12−3+2)/1+1 12 “same”模式:尺寸不变
2 1 ⌊11/2⌋+1 6 步幅下采样:代替池化

2.4 权重共享:参数从 14400 掉到 9

对比同一个任务两种接法(12×12 图 → 100 个特征):

参数量 说明
全连接 144 × 100 = 14400 每个输入位置一套专属权重
卷积(一个 3×3 滤波器扫全图) 9 同一套权重在所有位置工作

一个 3×3 滤波器输出 10×10 的"响应图"(100 格)——每格都是"这个位置与模板的匹配程度"。想要 100 种特征?用 100 个不同滤波器各扫一遍(输出 100 张响应图)。参数也只有 100 × 9 = 900。

响应图 = 特征地图(feature map):滤波器是“什么算特征”的定义,响应图是“这个特征出现在哪”的地图。

多通道:真图像的账。真实图像是三通道(RGB),滤波器随之变厚:一个滤波器是 3×3×3 的小立方体,一次卷积把三个通道的匹配分加在一起输出一张响应图。参数账升到 F2CinCoutF^2\, C_{\text{in}} C_{\text{out}}(输入 C_in 通道、要 C_out 张特征图):例如 3×3×3 的 64 个滤波器 = 5184 个参数。权重共享的直觉不变——同一套模板扫全图,只是模板从纸片变成了砖块。本篇是灰度图(C = 1),账单退化为 F²;中间层的“通道”就是上一层的特征图张数,3.3 的 4×10×10 已经是四通道张量了。

2.5 为什么初始随机、后来有意义

初始滤波器是随机小数——100 个随机模板。训练中,对分类有用的模板(“竖直边缘”“横条纹”“明暗对比”)的梯度会把它们越推越像真特征探测器;没用的模板得不到强化。滤波器不是设计的,是学出来的——这是 CNN 最迷人的地方,实验 3 会把学出来的滤波器画给你看。


3. ReLU 与最大池化:接棒

3.1 ReLU(Part 5 的老朋友)

卷积输出套一层 ReLU:负响应归零。直觉:模板"不像"(负分)就闭嘴,只报告"像的程度"。

3.2 最大池化(Max Pooling):赢者通吃的压缩

2×2 最大池化:把响应图切成 2×2 的小块,每块只留最大值

pool(r,c)=maxi,j{0,1}in(2r+i, 2c+j)\text{pool}(r, c) = \max_{i,j \in \{0,1\}} \text{in}(2r+i,\ 2c+j)

两个作用:

  • 减半尺寸:10×10 → 5×5,后面的全连接层负担小;
  • 轻微平移不变:特征在 2×2 窗口里挪一挪,最大值不变——“猫挪动半步还是猫”。

3.3 本篇的端到端架构

1
2
3
4
5
输入 12×12
→ Conv 4 个 3×3 滤波器(共享权重,含偏置) → 4×10×10 响应图
→ ReLU → 4×10×10
→ MaxPool 2×2 → 4×5×5(摊平 = 100 维)
→ 全连接 100 → 1 + sigmoid → 概率 p

前半段(conv+relu+pool)是特征提取器——参数是滤波器;后半段就是 Part 4 的逻辑回归——输入从手工特征换成了"学出来的特征"。整个网络一起用反向传播训练。

3.4 感受野:每个输出格"看得见"多大的输入

感受野(receptive field):响应图上一个格子的取值,取决于输入图上多大的一块区域。一层 3×3 卷积:每个输出格只看输入的 3×3——这就是它的全部视野(局部性规律的定量化)。

堆叠两层的账:第二层的一个格子看第一层输出的 3×3,而第一层每个格子又各看输入的 3×3——第二层中心那条线看 in[中心±1],边缘那条线看更远 1 格:

r2=3+(31)=5r_2 = 3 + (3 - 1) = 5

通式(第 ll 层滤波器边长 FlF_l、步幅 sls_l):

rl+1=rl+(Fl1)klskr_{l+1} = r_l + (F_l - 1)\prod_{k \le l} s_k

(步幅让格子跳着走,前面所有步幅连乘放大了偏移。)本篇架构手算:

感受野
conv 3×3(s=1) 3×3
pool 2×2(s=2) 3 + (2−1)×1 = 4×4

(pool 窗口盖住 conv 输出的 2×2 格,这两格的 conv 感受野起点差 1,合并成 4×4。)

小滤波器堆叠的性价比(VGG 的核心论证):两个 3×3 堆叠的感受野 = 一个 5×5,参数 2×9 = 18 < 25,还多一次非线性;三个 3×3 = 7×7(27 < 49,多两次非线性)。深而窄胜过浅而宽——"深度"的另一半含义(另一半是 Part 5 的特征加工厂)。


4. 反向传播穿过卷积层

误差 δ = p − y 从输出往回走(Part 5 的公式①),穿过全连接层是老套路(公式②③)。新情况只有两处:穿过池化层、穿过卷积层。

4.1 穿过 MaxPool:梯度只给赢家

前向时每块只留了最大值,反向时梯度也只还给它

Lin(argmax)+=Lout\frac{\partial L}{\partial \text{in}(\text{argmax})} \mathrel{+}= \frac{\partial L}{\partial \text{out}}

其余三个位置的梯度是 0——它们前向时没上场,反向时也不背锅。

4.2 穿过卷积:谁的模板谁领误差

回忆前向:out(r,c) = Σ F·in(patch)。求滤波器梯度——滤波器在每个位置都干了活,每个位置的误差都按贡献算到它头上

LFij=r,cδ(r,c)in(r+i1, c+j1)\frac{\partial L}{\partial F_{ij}} = \sum_{r,c} \delta(r, c) \cdot \text{in}(r+i-1,\ c+j-1)

读法:把响应图上每个位置的误差 δ(r,c),乘上那个位置当时用的输入小块,全部累加。滤波器梯度 = “误差加权的输入 patch 累计”。

偏置梯度更简单(每格都有它):L/b=r,cδ(r,c)\partial L/\partial b = \sum_{r,c} \delta(r,c)

手算例子(滤波器 2.2 节的 [6, 8],若误差 δ = [1, 0]):

LF=1(1245)+0(2356)=(1245),Lb=1\frac{\partial L}{\partial F} = 1 \cdot \begin{pmatrix}1&2\\4&5\end{pmatrix} + 0 \cdot \begin{pmatrix}2&3\\5&6\end{pmatrix} = \begin{pmatrix}1&2\\4&5\end{pmatrix}, \qquad \frac{\partial L}{\partial b} = 1

误差只来自第一个位置,就只有第一个 patch 贡献梯度。

4.3 穿过卷积层(续):对输入的梯度

本篇的卷积是第一层,输入是图像不是参数,不需要对它求梯度。但深层 CNN 的中间卷积层,"输入"是上一层的响应图——梯度要继续往回传,这一步躲不掉。现在从零推。

问题:L/in(p,q)\partial L/\partial \text{in}(p,q) 等于多少?换个问法:输入格子 in(p,q) 影响了哪些输出格?——所有覆盖它的 patch(每个覆盖它的输出格,前向时都用了它一份)。对每个这样的 (r,c) 写一条链式法则再求和:

Lin(p,q)=r,cδ(r,c)F(pr+1, qc+1)\frac{\partial L}{\partial \text{in}(p,q)} = \sum_{r,c} \delta(r,c) \cdot F(p-r+1,\ q-c+1)

(只有覆盖 (p,q) 的 (r,c) 贡献非零项——其余位置 F 的下标越界。)

读法:把 δ 当作"图",把 F 上下左右翻转后当作"模板"去扫它。有趣的名字来历:正向传播其实是数学上的互相关(模板不翻转);反向传播对输入的梯度恰好是数学上的真·卷积(翻转模板)——"卷积神经网络"的名字是从反向传播来的。

手算(同 2.2 节例子,δ=[1,0]\delta = [1, 0]):F 对称,翻转后不变。逐格:

Lin=(100010)\frac{\partial L}{\partial \text{in}} = \begin{pmatrix} 1&0&0\\ 0&1&0 \end{pmatrix}

(in(0,0)=δ(0,0)F(1,1)=1;in(1,1)=δ(0,0)F(2,2)+δ(0,1)F(2,1)=1+0=1;其余 = 0。)

直觉对账:误差只来自第一个位置,第一个 patch 的对角线(左上、右下)是 F 中非零权重的连线,只有它们把误差传回输入。与 4.2 的 L/F\partial L/\partial F = 第一个 patch 本身互为镜像——梯度把"误差"和"数据"沿每条连线各发一份:滤波器收数据,输入收误差。

4.4 公式 ↔ 代码对照

数学 代码(ConvLayer)
out(r,c) = ΣF·in(patch) forward 三重循环:h 扫行、w 扫列、(i,j) 扫滤波器
∂L/∂F_ij = Σ δ·in(patch) backwardFilter 同样三重循环,方向反过来(累加到滤波器)
∂L/∂b = Σ δ gradBias[f] += delta(f, h, w)
数学 代码(MaxPool)
out = max(块) forward:扫块取最大,顺手记下赢家下标 argmax
梯度只给赢家 backwardgradIn[argmax] += delta(out)

全连接 + sigmoid 头与 Part 5 完全同款(δ = p − y、grad = δ·a),不再重复。


5. 实验(main.cpp)

  • 实验 0:卷积前向手算对账——2.2 节例子:输入 [[1,2,3],[4,5,6]],滤波器 [[1,0],[0,1]] + 偏置 0.5,输出应为 [6.5, 8.5]。
  • 实验 1:梯度检查——5×5 小图、2 个 2×2 滤波器的迷你网络,19 个参数逐个有限差分对账(工业级铁律,同 Part 5)。
  • 实验 2:条纹分类——12×12 图,横条纹 vs 竖条纹(随机相位 + 噪声),每类 100 张训练 / 50 张测试,Adam 训练。
  • 实验 3:可视化——学出来的 4 个滤波器热图(看它自己长成了什么样子)+ 两类样本图 + 损失曲线。

6. 练习

  1. 把滤波器数量从 4 改成 1:条纹任务还能学出来吗?改成 2 呢?——找出"刚好够用"的数量。
  2. 去掉 MaxPool(conv 输出直接摊平接全连接):参数多了多少?训练还稳吗?
  3. 实现"对输入的梯度"(4.3 节推导):把 δ 当作滤波器对输入做"翻转卷积",验证梯度检查仍然通过——这是看懂深层 CNN 反向传播的门票。
  4. 给条纹任务加第三类“斜条纹”(输出改 3 单元 + softmax)——分类头升级的完整练习。
  5. 把 conv 改成步幅 2、去掉 MaxPool(2.3 节第三行):参数量、感受野、训练稳定性各怎么变?重跑实验 2 对比。

7. 小结 + 全课程总结

本篇概念

概念 一句话 代码落点
卷积 一套小权重模板扫全图,输出“特征出现在哪”的地图 ConvLayer::forward
步幅/填充 out = ⌊(in−F+2P)/S⌋+1:尺寸账的通式 2.3 节
多通道 滤波器变厚 F×F×C,通道结果相加 2.4 节
权重共享 9 个参数干 14400 个参数的活 参数只有 F×F
响应图/特征图 每个滤波器一张"匹配度地图" Tensor3
MaxPool 每块留最大值:压缩 + 轻微平移不变 MaxPool::forward/backward
感受野 输出格看得见的输入区域:堆叠逐层长大小,小滤波器堆叠性价比高 3.4 节公式
卷积反向(滤波器) 误差按贡献还给模板(δ × 输入 patch 累加) ConvLayer::backwardFilter
卷积反向(输入) δ 扫翻转模板(真·卷积,名字的出处) 4.3 节推导
CNN 组装 conv 特征提取 + 逻辑回归头,端到端反向传播 ConvNet::fit

前七章之旅:一张图看全

你学会了什么 留下的零件
Part 1 数据的地基:均值/方差/标准化 stats.h
Part 2 一切计算的载体:矩阵乘法/转置 Matrix
Part 3 学习的第一原理:损失 + 梯度下降 LinearRegression
Part 4 从预测数到分类:sigmoid + 交叉熵 LogisticRegression
Part 5 万能函数逼近器:隐藏层 + 反向传播 NeuralNetwork
Part 6 管住模型(L2)与加速训练(Adam) RidgeRegression + Optimizer
Part 7 让网络看图:卷积 + 池化 ConvNet

回头看,每一章只比上一章多一两个新想法,但七个台阶叠起来,你已经从"什么是均值"走到了"手写一个能训练、能反向传播、能可视化滤波器的卷积神经网络"——没有调用任何 ML 库,每一行梯度都是自己推的、自己验的。

从这里出发的路标:softmax 多分类 → 学习率调度与批归一化 → ResNet 残差连接 → 注意力机制与 Transformer。它们都是你已经掌握的零件的新组合。

另一条路先走:Part 8 起离开神经网络一家,把经典机器学习的几个"世界观"(几何、概率、规则、集成、优化)逐个从零推一遍,最后在强化学习里告别"老师"——数据与标签都不是给的,是 agent 自己挣的。


Part 8:k 近邻(kNN)——不学习,只查表的"懒"算法

前置:Part 1(均值/方差/标准化)、Part 2(Matrix)。
本篇开启课程的第二主线:经典机器学习算法家族
kNN 是这个家族里最简单、也最颠覆直觉的一个——它没有"训练"这回事。


1. 一个全新的思路:模型去哪了?

回顾前七章的套路,全都是同一副骨架:

1
2
3
4
1. 定一个带参数的公式 ŷ = f(x; w)
2. 定一个损失 L(w)
3. 梯度下降学出 w
4. 预测 = 把 x 代进公式

参数 w 就是"学到的知识"——训练完,原始数据可以扔掉,模型是数据的"压缩摘要"。

kNN 完全相反:

新样本来了?在训练数据里找离它最近的 k 个邻居,看它们大多数是哪类,就判它哪类。

  • 没有 w,没有损失函数,没有梯度下降;
  • 训练 = 把数据原封不动存起来(所以叫"懒学习" lazy learning);
  • 所有计算都推迟到预测那一刻(所以预测慢、训练零成本)。

一句话对比:

参数模型(Part 3-7) kNN(非参数模型)
知识存在哪 权重 w 里 训练数据本身
训练成本 高(迭代学 w) 零(存数据)
预测成本 低(一次矩阵乘法) 高(和每个训练点算距离)
训练完数据 可扔 必须留着
新增训练数据 要重新训练 直接追加进表里

这不是玩具算法——在特征空间设计合理时,kNN 至今仍是工业界的常用基线(推荐系统的"相似用户"、欺诈检测的"相似交易"本质都是近邻思想)。


2. 数学工具箱:距离是怎么"被逼出来"的

kNN 的全部数学就一个词:距离。但这个概念值得从零推一遍——它也是第 12 章 SVM 的地基。

2.1 从数轴开始

一维情况:两个数 a、b 的距离就是差的绝对值:

d(a,b)=abd(a, b) = |a - b|

手算:3 和 7 的距离 = |3−7| = 4。直观、无争议。

2.2 二维:勾股定理登场

平面上两个点 P₁=(x₁, y₁)、P₂=(x₂, y₂) 怎么算距离?画个直角三角形:

  • 横向差 |x₁−x₂| 是一条直角边;
  • 纵向差 |y₁−y₂| 是另一条直角边;
  • 两点连线是斜边。

勾股定理:

d=(x1x2)2+(y1y2)2d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2}

手算:P₁=(0,0)、P₂=(3,4):

d=32+42=9+16=25=5d = \sqrt{3^2 + 4^2} = \sqrt{9 + 16} = \sqrt{25} = 5

2.3 n 维:模式外推

三维就是三根"直角边"(对每对坐标用两次勾股可验证),n 维就是把"每个坐标差的平方"全部加起来再开方:

d(x,z)=i=1n(xizi)2d(\mathbf{x}, \mathbf{z}) = \sqrt{\sum_{i=1}^{n}(x_i - z_i)^2}

这个 Σ 你已经认识(for 循环)。这就是欧氏距离——我们从小到大直觉里的"直线距离"。

手算(n=3):x = (1, 2, 3)、z = (4, 6, 3):

d=(14)2+(26)2+(33)2=9+16+0=25=5d = \sqrt{(1-4)^2 + (2-6)^2 + (3-3)^2} = \sqrt{9 + 16 + 0} = \sqrt{25} = 5

2.4 实现优化:不开方的距离

比较"谁更近"时,开方是单调变换——d 大则 d² 大。所以代码里通常直接比较平方距离,省一次 sqrt(对每个训练点、每次预测都省):

1
2
3
4
5
6
double sqDist = 0.0;
for (std::size_t i = 0; i < n; ++i)
{
const double diff = a(i, 0) - b(i, 0);
sqDist += diff * diff; // 只加平方,不开方
}

这是本课程第一次遇到"数学公式 ≠ 代码实现":公式写 d,代码算 d²,答案等价、速度更快。工程直觉:先问"这个变换保序吗?"保序就可以省。

2.5 距离的亲戚(了解即可)

名字 公式 直觉
欧氏距离 Σ(xizi)2\sqrt{\Sigma(x_i - z_i)^2} 直线飞行(默认款)
曼哈顿距离 $\Sigma x_i - z_i
切比雪夫距离 maxixizi\max_i|x_i - z_i| 国际象棋国王走一格

本篇用欧氏距离。练习 1 会让你体验换距离的差别。

2.6 距离度量怎么"选型":范数家族

把上面的公式统一看:欧氏是"平方和开根"(L2 范数),曼哈顿是"绝对值和"(L1 范数),切比雪夫是"最大值"(L∞ 范数)。更一般地:

Lp(x,z)=(ixizip)1/pL_p(\mathbf{x}, \mathbf{z}) = \left(\sum_i |x_i - z_i|^p\right)^{1/p}

  • p=1 曼哈顿、p=2 欧氏、p→∞ 切比雪夫;
  • p 越大,越只在乎最大的那个差距;p 越小,越在乎"所有维度都别差太多";
  • 高维数据(p=2 时距离容易趋同)常换 p=1"救命"——这叫维数灾难的一个侧面,练习 2 验证。

3. 距离之前先标准化(Part 1 知识的实战)

一个致命细节:距离把所有维度"一视同仁"地加起来。如果量纲不齐,距离就被大数值维度绑架。

反例:特征 1 是"身高(米)“(取值 ~1.7),特征 2 是"体重(克)”(取值 ~65000)。算距离时体重差完全碾压身高差——模型实际上只看体重

解法就是 Part 1 的标准化:每维减均值除标准差,把所有特征拉回同一个量纲(均值 0、标准差 1)。这样每个维度对距离的贡献才公平。

铁律:凡是基于距离的算法(kNN、K-Means、SVM 的核),先标准化。 本篇实验代码会看到它的实际影响。


4. 投票:k 个邻居怎么说了算

4.1 完整流程

预测一个新样本 x:

1
2
3
4
1. 算 x 到全部 m 个训练点的距离
2. 按距离升序排序,取前 k 个(k 个最近邻)
3. 数这 k 个邻居里各类别的票数
4. 票数最多的类 = 预测结果

4.2 手算例子(实验 0 上机对账)

训练集 5 个点(已标准化):

x₁ x₂ 类别
A 0.0 0.0 0
B 1.0 0.0 0
C 0.0 1.0 0
D 3.0 0.0 1
E 3.0 1.0 1

新样本 x = (1.0, 1.0),k = 3。

第一步:算 x 到 5 个点的平方距离(省开方):

计算
A (1−0)² + (1−0)² 2
B (1−1)² + (1−0)² 1
C (1−0)² + (1−1)² 1
D (1−3)² + (1−0)² 5
E (1−3)² + (1−1)² 4

第二步:升序排序 → B(1)、C(1)、A(2)、E(4)、D(5)。取前 3 个:B、C、A

第三步:投票。B 是 0 类、C 是 0 类、A 是 0 类 → 0 类得 3 票,1 类得 0 票

第四步:x 判为 0 类

直觉验证:x=(1,1) 恰好在 0 类三点围成的"势力范围"中间,离 1 类的两个点(x₁=3)很远——判对了。

注意 B、C 并列(d² 都是 1):排序时谁先谁后不影响本例结果(两个都是 0 类),但代码要保证排序是稳定的(同距离时按训练顺序),否则并列样本的类别可能影响边缘案例——这是实现细节里的“暗坑”,5.3 节说。

k = 1 的边界长什么样(Voronoi 图):把平面按“离哪个训练点最近”切块——每两个训练点之间画一条垂直平分线,平分线拼出的拼图叫 Voronoi 图。k = 1 的决策边界就是这张拼图的着色边界:每块内部一个主人、边界笔直。实验 2 里“碎格子”的几何出身就是它;k 变大 = 用更大的邻域投票,尖锐的拼贴被抹平——从马赛克变成水彩画

4.3 k 怎么选:模型复杂度的旋钮

k 是 kNN 唯一的超参数(不用梯度下降学、要人来定的数)。它的选择直接决定模型性格:

k = 1:只听最近的一个邻居。

  • 邻居是什么类,我就是什么类;
  • 决策边界完全贴着训练点,训练数据里的每个噪声点都会造出一小块"私人领地"
  • 训练准确率永远 100%(自己离自己最近),但新数据一来,噪声领地里就翻车——过拟合

k = 全体训练点数 m:听所有人的。

  • 永远返回全局最多的类——模型退化成"背答案";
  • 完全无视新样本长什么样——欠拟合

k 适中(如 5、7):局部多数票。

  • 单个噪声点翻不了案(它周围 k−1 个邻居会纠正它);
  • 边界变得平滑。

用"投票民主"类比:

k 政治类比 后果
1 独裁(最近的说了算) 一个疯子就能带偏
适中 委员会表决 多数理性压过个别噪声
m 全民公投(不听取证) 多数派永远赢,个案被无视

经验法则:k 取奇数(避免二分类平票);从 k=3、5、7 试起,用验证集挑。更精细的办法是交叉验证——把训练集切成几份轮流当验证集,每档 k 都试一遍选平均表现最好的(练习 4 实现)。

4.4 k = 1 的理论地位:误差不超过两倍贝叶斯下界

k = 1 看着最粗暴,理论地位却出奇地硬(Cover & Hart 1967):数据无限多时,1-NN 的误差 R1-NNR_{1\text{-NN}} 满足

RR1-NN2RR^* \le R_{1\text{-NN}} \le 2R^*

RR^*贝叶斯误差——任何分类器的理论下界(两类数据本身重叠的部分,谁也做不到全对;Part 9 的“噪声”同一个概念)。粗略论证:1-NN 预测错,意味着最近邻的类别 ≠ 查询点的真实类别。而“邻居类别 ≠ 真实类别”不超过两件事的并——“邻居类别 ≠ 该点最优类”(数据无限多时邻居无限近,这一块趋于消失,只剩下“该点本身就不纯”)和“真实类别 ≠ 最优类”(这正是 R* 的来源)。两块各贡献一份,凑出 2 倍上界。

读法:再粗暴的近邻,也最多比理论最优差一倍——“距离 = 相似性”这个朴素信念的数学保底。而合适的 k(或距离加权)能逼近 R* 本身——代价与代价之间的取舍,正是下一节的偏差-方差。

4.5 k 的数学视角:偏差-方差权衡第一次见面

  • k 小 → 方差大:训练数据换一批(重新采样),边界形状变化剧烈——模型对数据太敏感;
  • k 大 → 偏差大:无论数据怎么换,边界都趋近"多数类占一半"的呆板形状——模型对数据不敏感,系统性偏离真相。

这个 偏差-方差权衡(bias-variance tradeoff)是机器学习的第一性原理,之后每一章都会以不同面目重逢(正则化 λ、树的深度、SVM 的 C……全都踩在同一条跷跷板上)。实验 2 会画出 k=1/5/25 的边界图,眼见为实。(Part 11 会把“偏差”和“方差”正式拆开推导——现在先记住这幅跷跷板图景。)


5. 代码精讲

模块 src/part08_knn/,一个类 KnnClassifier

5.1 类设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 无超参数学习、无梯度——类只存数据 + 一个 k
class KnnClassifier
{
public:
KnnClassifier(std::size_t k = 5);
void fit(const part02::Matrix& x, const part02::Matrix& y); // 只是拷贝数据
part02::Matrix predict(const part02::Matrix& x) const; // 全部计算在这
double computeAccuracy(const part02::Matrix& x, const part02::Matrix& y) const;
// 概率版预测:得票比例("0.6 置信度")
part02::Matrix predictProba(const part02::Matrix& x) const;
private:
std::size_t m_k;
part02::Matrix m_x; // 训练特征(原样保存——"懒")
part02::Matrix m_y; // 训练标签
};

对比 Part 3-7 的模型类:fit 从"几百行训练循环"缩水成"两行拷贝"——这是算法本性,不是偷工减料。

5.2 公式 ↔ 代码对照表

平方距离(2.4 节):

数学 代码
d2=i=1n(xizi)2d^2 = \sum_{i=1}^{n}(x_i - z_i)^2 for 循环累加 diff * diff

选 k 近邻(4.1 节流程 1-2):

数学/算法 代码
距离升序排序取前 k std::partial_sort(只排前 k 个,比全排序快)
并列距离稳定 自定义比较器:d² 相同时按训练下标(std::tuple 天然字典序)

投票(4.1 节流程 3-4):

数学 代码
类别 c 的票数 = Σ(邻居类别 == c) counts[label]++
argmax 票数 扫一遍 counts 取最大

5.3 关键实现:partial_sort 的妙用

对每个待预测点,我们不需要知道全部 m 个距离的完整排名——只要前 k 名。std::partial_sort 恰好做这件事:把"最小的 k 个"放到容器前 k 位,其余不管。复杂度从 O(m log m) 降到 O(m log k)。m=10000、k=5 时约快 3 倍。

1
2
3
4
5
6
7
8
// 距离带上"身份"打包:{d², 训练下标}——下标既能查标签,又天然保证并列时稳定
std::vector<std::pair<double, std::size_t>> dists;
dists.reserve(m);
for (std::size_t i = 0; i < m; ++i)
dists.push_back({ squaredDistance(xRow, m_x, i), i });

std::partial_sort(dists.begin(), dists.begin() + k, dists.end());
// 现在 dists[0..k) 就是 k 个最近邻(按 d² 升序,并列按下标)

5.4 一个诚实的复杂度账本

操作 复杂度 说明
fit O(1) 拷贝指针/数据,零计算
predict 单点 O(m·n + m log k) m 个点各算 n 维距离 + 部分排序
m=10000, n=100, k=5 ~百万次浮点运算 vs 逻辑回归预测的 ~100 次

这就是“懒”的代价:训练时省的,预测时加倍还。工业界的解法是给空间建索引

  • KD-树:沿方差最大的维度取中位数切一刀,递归把空间切成小方块;查询时沿树枝下行到查询点所在方块,再回溯访问“可能与近邻球相交”的分支——平均 O(log m) 拿到近邻;
  • KD-树的命门:维度一高同样退化(切了几十刀后每块里没剩几个点,回溯几乎要访问所有分支)——维数灾难的索引版;
  • 于是高维场景(嵌入向量检索)改用近似最近邻(FAISS、HNSW 图索引):放弃“绝对最近”、换“大概率很近”,把十亿级向量的查询压到毫秒。

本篇按从零原则不引入,练习 5 给 KD-树的思路。


6. 实验(main.cpp)

  • 实验 0:手算对账——4.2 节的 5 点训练集 + x=(1,1):程序应输出 3 个邻居 {B,C,A}、投票 3:0、预测 0 类,与手算逐步核对。
  • 实验 1:标准化前后的天壤之别——同一组两团高斯数据,特征 2 数值放大 100 倍(模拟量纲失衡):不标准化时准确率崩塌、标准化后恢复。距离被大数值维度绑架的现场演示
  • 实验 2:k 的跷跷板——k ∈ {1, 5, 25} 三档训练同一数据,三张决策区域图并排:k=1 边界破碎(每个点一块私人领地)、k=5 平滑贴切、k=25 边界呆板(欠拟合)。训练/测试准确率对照表量化直觉。

7. kNN 的优缺点(工业选型的诚实账)

优点

  • 零训练成本、概念极简、天然支持多分类(投票不限于两类);
  • 对数据分布零假设(逻辑回归假设线性边界、贝叶斯假设条件独立——kNN 什么都不假设);
  • 新数据直接追加,不用重训(在线场景友好)。

缺点

  • 预测慢(O(m) 距离计算)、内存要装下全部训练数据;
  • 维数灾难:维度一高,所有点对之间的距离趋同,"最近"失去意义(练习 2 定量验证);
  • 类别不平衡时吃亏:少数类很难在投票中胜出(哪怕新样本就在少数类堆里)。

选型口诀:特征少而精(<20 维)、数据中等规模、需要快速搭基线 → 先上 kNN;特征成百上千、要毫秒级响应 → 换路。


8. 练习

  1. 把欧氏距离换成曼哈顿距离重跑实验 2:边界形状会从"圆弧"变"直线拼贴"——L1 的几何天性。
  2. 维数灾难定量验证:随机生成 n 维均匀分布的点对,n 从 1 到 500 递增,画出"最近邻距离 / 最远邻距离"比值曲线——看它如何逼近 1(最近 = 最远 = 没意义)。
  3. 加权投票:给近邻的票加权 1/d²(离得越近话语权越大),对比 4.2 节例子的结果是否改变。
  4. 交叉验证选 k:把训练集 5 折切分,k 从 1 到 21 扫一遍,画"k-平均验证准确率"曲线,找到最优点。
  5. (进阶)实现简单 KD-树索引替代暴力搜索,验证预测结果一致、大 m 时加速明显。

9. 小结

概念 一句话 代码落点
懒学习 训练=存数据,预测=查表 fit 两行拷贝
欧氏距离 勾股定理的 n 维推广 squaredDistance
平方距离优化 比较保序,开方可省 代码算 d² 不算 d
标准化前置 量纲不齐则距离被绑架 实验 1
k = 复杂度旋钮 k 小方差大,k 大偏差大 实验 2 三档边界
Voronoi 图 k=1 的边界 = 最近邻拼图的着色边界 4.2 节
1-NN 定理 渐近误差 ≤ 2×贝叶斯下界:距离信念的保底 4.4 节
偏差-方差权衡 全机器学习的第一性原理 4.5 节(Part 11 正式分解)
partial_sort 只要前 k 名就不必全排序 5.3 节

下一篇(Part 9)换一个完全不同的视角:概率。朴素贝叶斯从"先验 + 证据"反推后验——医生看化验单诊断、你看到鸟会飞猜它是鸟,人牌天生就会的推理方式,机器怎么学?


Part 9:朴素贝叶斯——用"先验 + 证据"推理

前置:Part 1(均值/方差/正态分布)、Part 2(Matrix)。
第二种视角:概率。kNN 靠"查相似案例",贝叶斯靠"算发生概率"——
它是本课程第一个天然输出概率的模型:逻辑回归的 sigmoid 概率是"压"出来的,贝叶斯的后验是老老实实算出来的。


1. 思路:医生是怎么诊断的

你发烧 38.5°C 去医院。同样的症状,两位医生给出不同判断:

1
2
流感季(12 月):"现在流感高发,你这症状大概率是流感。"
六月份:"流感基本绝迹了,你这更可能是普通感冒。"

症状一样,判断不同——差别不在证据(发烧),而在底色(季节先给了"流感"一个基础概率)。医生脑子里发生的事,拆开是三步:

  1. 先验:还没看病人之前,"流感"的底概率(流感季 30%,六月 2%);
  2. 似然:假如真是流感,出现"发烧"的概率有多大?假如是感冒呢?(证据对每种解释的"支持度");
  3. 后验:把两者结合,得到"看到发烧之后,是流感的概率"——先验被证据更新了。

这就是贝叶斯推理的全部灵魂:

判断 = 底色 × 证据的修正。新证据到来,旧信念更新。

和 kNN 对照一下(两种完全不同的"懒/勤"):

kNN 朴素贝叶斯
预测逻辑 查相似的旧案例,抄多数答案 算每个类的概率,选大的
需要什么 把数据全存着(预测时才干活) 把数据压缩成统计量(均值/方差/频率),原始数据可以扔
世界观 “近朱者赤”(几何) “万物皆概率”(统计)

2. 贝叶斯定理:从零推导(四步,不是背公式)

目标:给定特征 x,类别是 c 的概率 P(c | x)。这个方向难算(特征组合是未来才出现的新情况,历史里未必见过)。贝叶斯定理就是一台"调转概率方向"的机器。它不是发明出来的,是条件概率定义的必然推论——四步推导:

2.1 第一步:条件概率的定义

“在 B 发生的前提下 A 发生的概率”:

P(AB)=P(AB)P(B)P(A|B) = \frac{P(A \cap B)}{P(B)}

直觉:把范围缩小到 B 的地盘(分母),再看 A 占其中多少(分子)。

2.2 第二步:同一件事,两个入口

联合概率 P(A∩B)(两件事都发生)有两种算法——先看 A 再看 B,或者先看 B 再看 A

P(AB)=P(AB)P(B)=P(BA)P(A)P(A \cap B) = P(A|B)\,P(B) = P(B|A)\,P(A)

验货(掷骰子):A = “点数 > 3”,B = “偶数”。A∩B = {4, 6},P = 2/6 = 1/3。

  • 入口 1:P(A|B)·P(B) = (2/3)·(1/2) = 1/3 ✓
  • 入口 2:P(B|A)·P(A) = (2/3)·(1/2) = 1/3 ✓

2.3 第三步:一除,定理诞生

两个入口相等,把 P(B) 除到对面去:

P(AB)=P(BA)P(A)P(B)P(A|B) = \frac{P(B|A)\,P(A)}{P(B)}

贝叶斯定理就这么出现了——它只是"联合概率有两个入口"这件事的重新排列,没有任何新假设。

2.4 第四步:翻译成分类语言

把 A 换成类别 c、B 换成特征 x:

P(cx)后验=P(xc)似然 P(c)先验P(x)证据\underbrace{P(c|x)}_{\text{后验}} = \frac{\overbrace{P(x|c)}^{\text{似然}}\ \overbrace{P(c)}^{\text{先验}}}{\underbrace{P(x)}_{\text{证据}}}

四个术语各司其职:

术语 含义 怎么得到
先验 P© 没看特征前,类 c 的底概率 训练集里数一数:count(c)/m
似然 P(x|c) 假如是 c 类,长成 x 这样的概率 训练集里 c 类样本的分布(假设钟形 → 均值/方差)
证据 P(x) 特征 x 本身有多常见 全部类的加权平均(比较时可省略,见 3.3)
后验 P(c|x) 看到 x 之后,是 c 的概率 我们想要的,定理替我们算出来

关键洞察再喊一遍:P(c|x) 直接算没数据(新样本的组合没见过),P(x|c) 却能从历史统计(每个类的样本就在训练集里)——定理把"没法算的方向"换成了"能统计的方向"。


3. “朴素”:一个大胆到离谱的假设

3.1 先看困难:组合爆炸

特征不只一个。垃圾邮件有"免费"“中奖”"发票"三个关键词时,似然是联合概率 P(免费, 中奖, 发票 | 垃圾)。离散特征要为每种组合数格子:

1
2
3
3 个二值特征:2³ = 8 个格子
20 个二值特征:2²⁰ ≈ 100 万个格子
100 个特征:宇宙原子数都不够装

训练集 100 封邮件,根本填不满这些格子——绝大多数格子永远是 0(不是不可能发生,只是没见过)。

3.2 假设来了:条件独立

朴素贝叶斯的"朴素"(naive)= 假设给定类别后,各特征相互独立

P(x1,x2,,xnc)=i=1nP(xic)P(x_1, x_2, \ldots, x_n | c) = \prod_{i=1}^{n} P(x_i | c)

一个联合概率拆成 n 个一维概率的乘积——100 万个格子瞬间变成 3 个格子 × 20 个特征。每个 P(xᵢ|c) 单独统计,数据绰绰有余。

这个假设几乎总是错的("免费"和"中奖"在垃圾邮件里明显结伴出现)。但——

3.3 错得离谱,却好用到爆:三个理由

  1. 我们只要排序对,不要数值准。分类 = argmax_c P(c|x)。而分母 P(x) 对所有类相同,可以直接扔掉:

c^=argmaxc  P(xc)P(c)\hat{c} = \arg\max_c \; P(x|c)\,P(c)

独立假设带来的失真,乘在每个类头上的"歪"往往方向一致——比大小时抵消了大半。

  1. 错误相互抵消:实践里(垃圾邮件过滤是这个算法最著名的胜利)独立假设造成的偏差不系统性偏向某一类。
  2. 它是"生成模型":假设了数据怎么生成(每类一个分布),哪怕假设不完美,学到的均值/方差本身就有解释价值。

历史注脚:朴素贝叶斯垃圾邮件过滤 1998 年上线,比神经网络方法早了二十年,至今仍在服役。

3.4 连续特征:高斯假设

本课程的数据是连续的(房价、坐标)。P(xᵢ|c) 怎么算?朴素贝叶斯最常用的做法:假设每个类、每个特征的分布是正态分布(Part 1 见过的钟形),只需从训练集统计两个数:

P(xic)=N(xi; μic, σic2)=12πσic2exp ⁣((xiμic)22σic2)P(x_i | c) = \mathcal{N}(x_i;\ \mu_{ic},\ \sigma_{ic}^2) = \frac{1}{\sqrt{2\pi\sigma_{ic}^2}} \exp\!\left(-\frac{(x_i-\mu_{ic})^2}{2\sigma_{ic}^2}\right)

其中 μᵢc = c 类样本在第 i 个特征上的均值,σ²ᵢc 同理(用总体方差,除以 m_c——和 Part 1 一致)。

直觉读法:“假如这个点是 c 类,它离 c 类的‘大本营’(均值)有多近?”——距离越远,钟形给出的概率指数衰减。你会在实验 1 看到:两个方差相同的圆团,贝叶斯边界是一条直线(离谁的大本营近归谁)。

3.5 参数从哪来:极大似然把均值/方差“逼”出来

3.4 说“只需统计两个数”——为什么恰好是均值和方差?这不是规定,是算出来的。思路是极大似然(Part 4 从交叉熵里见过它):这批样本是 c 类的,找一组 (μ,σ2)(\mu, \sigma^2) 让“它们看起来最像从这条钟形里抽出来的”——即最大化对数似然:

(μ,σ2)=k=1mclogN(x(k); μ,σ2)=mc2log(2πσ2)12σ2k(x(k)μ)2\ell(\mu, \sigma^2) = \sum_{k=1}^{m_c} \log \mathcal{N}(x^{(k)};\ \mu, \sigma^2) = -\frac{m_c}{2}\log(2\pi\sigma^2) - \frac{1}{2\sigma^2}\sum_k (x^{(k)}-\mu)^2

对 μ 求导置零:/μ=1σ2k(x(k)μ)=0\partial\ell/\partial\mu = \frac{1}{\sigma^2}\sum_k(x^{(k)}-\mu) = 0,解出

μ^=1mckx(k)(样本均值)\hat{\mu} = \frac{1}{m_c}\sum_k x^{(k)} \quad\text{(样本均值)}

对 σ² 求导置零(代入 μ̂):mc2σ2+12σ4k(x(k)μ^)2=0-\frac{m_c}{2\sigma^2} + \frac{1}{2\sigma^4}\sum_k(x^{(k)}-\hat{\mu})^2 = 0,解出

σ^2=1mck(x(k)μ^)2(总体方差,除以 mc\hat{\sigma}^2 = \frac{1}{m_c}\sum_k (x^{(k)}-\hat{\mu})^2 \quad\text{(总体方差,除以 } m_c\text{)}

均值和方差的真实身份:钟形家族里最像你家数据的那一个成员的参数。Part 1 教了怎么算,这里补上了“为什么是它们”——4.2 节手算里“除以 3 而不是 2”的出处也在这(练习 4 的伏笔)。

3.6 边界长什么样:把 log 后验差写开

实验 1 说“等方差边界是直线、方差不同变二次曲线”——这句话可以直接推出来。分类边界是 logP(1x)P(0x)=0\log\frac{P(1|x)}{P(0|x)} = 0(单特征、先验任意):

logP(1x)P(0x)=logσ0σ1(xμ1)22σ12+(xμ0)22σ02+logP(1)P(0)\log\frac{P(1|x)}{P(0|x)} = \log\frac{\sigma_0}{\sigma_1} - \frac{(x-\mu_1)^2}{2\sigma_1^2} + \frac{(x-\mu_0)^2}{2\sigma_0^2} + \log\frac{P(1)}{P(0)}

把右边展开,x² 项的系数是 12(1σ021σ12)\frac{1}{2}\left(\frac{1}{\sigma_0^2} - \frac{1}{\sigma_1^2}\right)

  • σ₀ = σ₁ → x² 项消失,一次方程 → 边界是一个点(多维情形:一条直线);等先验时解出 x=(μ0+μ1)/2x^* = (\mu_0+\mu_1)/2——两均值中点。手算验证:4.2 例子 x=(2+6)/2=4x^* = (2+6)/2 = 4,正是实验 0 的第二问、练习 1 的 0.5 平手点 ✓;
  • σ₀ ≠ σ₁ → 二次方程,最多两个根——边界可以“拐弯”:远处方差大的类反超(高斯尾部衰减慢的一方在远处“更便宜”);多维情形是二次曲面。

(注:等协方差的高斯贝叶斯边界是直线——这也是 Part 4 逻辑回归边界为直线的深层原因:两朵形状相同的云,分界自然是直的。)

3.7 生成 vs 判别:两种押注

朴素贝叶斯是生成模型(generative):建模 P(x,c)=P(c)iP(xic)P(x, c) = P(c)\prod_i P(x_i|c)——把“数据是怎么长出来的”整个故事都学了。逻辑回归是判别模型(discriminative):只建模 P(cx)P(c|x)——“给定 x 猜 c”,不管 x 本身怎么分布。5.3 节的 sigmoid 汇合说明两者是近亲,但训练哲学相反:

生成(朴素贝叶斯) 判别(逻辑回归)
建模对象 P(x|c)、P©——联合分布 P(c|x)
小数据 常更强:钟形结构 + 先验是免费知识 容易欠拟合
大数据 对 x 分布的错误押注拖后腿(天花板低) 不押注 x 分布,天花板更高
额外能力 能“生成”假样本、能处理缺失特征 只会判别

经典结论(Ng & Jordan 2002):NB 的样本需求是对数级的、LR 是线性级的——NB 先到但上限低,LR 后到但上限高,中等数据量处交叉。这也是偏差-方差(Part 8 初见、Part 11 分解)的又一张脸:NB 把结构当先验(偏差换样本效率),LR 把结构留给数据。


4. 手算完整例子(实验 0 上机对账)

4.1 题目

一维特征、两类,训练集:

1
类 0:x = {1, 2, 3}          类 1:x = {5, 6, 7}

预测新样本 x = 3 的类别。

4.2 第一步:统计参数(先验 + 每类的均值/方差)

  • 先验:两类各 3 个样本 → P(0) = P(1) = 3/6 = 0.5
  • 类 0:μ₀ = (1+2+3)/3 = 2;σ₀² = [(1−2)² + (2−2)² + (3−2)²]/3 = 2/3 ≈ 0.6667
  • 类 1:μ₁ = 6;σ₁² = 0.6667(对称)

4.3 第二步:代高斯密度,算两个似然

常数部分(两类方差相同,系数一样):

12π0.6667=14.18879=12.04665=0.48860\frac{1}{\sqrt{2\pi \cdot 0.6667}} = \frac{1}{\sqrt{4.18879}} = \frac{1}{2.04665} = 0.48860

似然 P(x=3 | 类 0)(离 μ₀=2 距离 1):

0.48860×exp ⁣((32)22×0.6667)=0.48860×e0.75=0.48860×0.47237=0.230790.48860 \times \exp\!\left(-\frac{(3-2)^2}{2 \times 0.6667}\right) = 0.48860 \times e^{-0.75} = 0.48860 \times 0.47237 = \mathbf{0.23079}

似然 P(x=3 | 类 1)(离 μ₁=6 距离 3):

0.48860×exp ⁣(91.3333)=0.48860×e6.75=0.48860×0.0011708=0.00057200.48860 \times \exp\!\left(-\frac{9}{1.3333}\right) = 0.48860 \times e^{-6.75} = 0.48860 \times 0.0011708 = \mathbf{0.0005720}

x=3 离两个大本营的距离是 1 : 3,钟形把这点差距指数放大成 403 倍——这就是高斯假设的"杠杆"。

4.4 第三步:贝叶斯定理合体

P(0x)=0.5×0.230790.5×0.23079+0.5×0.0005720=0.115400.11568=0.9975P(0|x) = \frac{0.5 \times 0.23079}{0.5 \times 0.23079 + 0.5 \times 0.0005720} = \frac{0.11540}{0.11568} = \mathbf{0.9975}

预测:类 0,把握 99.75%。(分母 P(x) 这次算了——要输出真概率时它不能省;只要比大小时才能省,见 3.3。)

4.5 除法变加法:log 技巧(工程必需)

特征一多,n 个小概率连乘(如 0.01²⁰ = 1e−40)会下溢成 0——double 的极限约 1e−308。解药是取对数把乘法变加法:

logP(cx)logP(c)+ilogP(xic)\log P(c|x) \propto \log P(c) + \sum_i \log P(x_i|c)

argmax 不变(log 单调),数值稳如老狗。本篇实现全部用 log 后验——这是你第二次见到"log 救数值"(第一次是 Part 4 交叉熵,后面 Part 12 SVM 还会重逢)。

4.6 拉普拉斯平滑:零概率陷阱(离散特征的保底)

设想离散场景:训练集 6 封正常邮件里"免费"出现 0 次 → P(免费|正常) = 0 → 哪怕这封邮件其他 19 个词都无比正常,一个"免费"就把它一票否决成垃圾邮件。

病根:“没见过” ≠ “不可能”。药方是拉普拉斯平滑——给每个格子预存 1 个"幽灵计数":

P(免费正常)=0+16+2=0.125P(\text{免费}|\text{正常}) = \frac{0 + 1}{6 + 2} = 0.125

(分母 +2 是二值特征的两个取值各补 1。)“先验敲门”:谁都没发生过的事,给个保底概率,别一票否决。连续特征不用这招(高斯密度处处大于 0,天然平滑)——本篇实现是高斯版,这条留作练习 3 的离散版作业。


5. 代码精讲

模块 src/part09_naive_bayes/,类 GaussianNaiveBayes(高斯朴素贝叶斯,二分类)。

5.1 训练:把数据压缩成 6+2n 个数

1
2
3
4
// fit 只做统计——训练完原始数据即可丢弃(对比 kNN:必须全存)
m_prior0 = count0 / m; // 先验
m_mean0[j] = 类 0 第 j 特征的均值; // 大本营坐标
m_var0[j] = 类 0 第 j 特征的方差; // 大本营的"松紧"

没有梯度、没有迭代、没有超参数——一次遍历统计完毕,训练速度 O(mn)。朴素贝叶斯是"勤快"的:把学到的知识压缩进参数(kNN 是"懒"的:知识就是数据本身)。

5.2 公式 ↔ 代码对照表

高斯密度(4.3 节):

数学 代码
P(xic)=12πσ2e(xiμ)2/2σ2P(x_i|c) = \frac{1}{\sqrt{2\pi\sigma^2}} e^{-(x_i-\mu)^2/2\sigma^2} norm = 1/sqrt(2π·var)exp(-diff²/(2·var))

log 后验(4.5 节):

数学 代码
logP(c)+ilogP(xic)\log P(c) + \sum_i \log P(x_i|c) logPrior + Σ logGaussian(x(j), mean(j), var(j))

预测(3.3 节 argmax):

数学 代码
c^=argmaxcP(xc)P(c)\hat{c} = \arg\max_c P(x|c)P(c) score0 > score1 ? 0 : 1

5.3 predictProba:logit 差 → sigmoid(与 Part 4 意外重逢)

要输出真概率(不是只比大小),把两个 log 后验的喂给 sigmoid:

P(1x)=11+e(logP0logP1)=σ(logP1logP0)P(1|x) = \frac{1}{1 + e^{\,(\log P_0 - \log P_1)}} = \sigma\big(\log P_1 - \log P_0\big)

手算验证(4.3 的例子):log P₁ − log P₀ = ln(0.0005721) − ln(0.23079) = −7.466 − (−1.466) = −6.000;σ(−6.000) = 1/(1+e^{6.000}) = 1/403.4 = 0.00248 ✓(先验 0.5 时与 4.4 的手算一致)。

你在 Part 4 见过完全相同的形状:logit 进、概率出。当时 sigmoid 是"硬选"的挤压函数;这次它是从贝叶斯定理自然长出来的——两种世界观在此汇合(深化 Part 4 会从极大似然角度把这条线彻底接通)。


6. 实验(main.cpp)

  • 实验 0:手算对账——4.1 节的一维数据集:程序打印两个 log 似然、log 后验、P(0|x)=0.9975,与文档 4.3-4.4 节逐位核对;再验证 x=4(两个大本营正中间)→ 后验恰为 0.5。
  • 实验 1:椭圆边界——二维双团数据(与 Part 8 同款,公平对比):
    • 子图 A:两团方差相同 → 边界是直线(“离谁的大本营近归谁”);
    • 子图 B:把类 1 的第二维方差放大 25 倍 → 直线弯成二次曲线(3.6 节:x² 项现身;松紧不同,远离处“便宜”的类 1 反超)——贝叶斯边界一般是二次曲线,直线只是等方差的巧合。
  • 实验 2:双月牙惨案——Part 8 的月牙数据:NB 的椭圆世界观切不出月牙形(准确率大跌),kNN 轻松 95%+。教训:假设错了,数据再多也救不回来——每个模型都自带世界观,选模型就是选世界观。

7. 练习

  1. 手算 x=4 的后验(先验不变)。答案:两个似然对称相等,P(0|x) = 0.5——离两个大本营等距的点上,钟形完全平手 ✓。
  2. 先验的杠杆:把训练集改成类 0 有 9 个样本、类 1 有 3 个(先验 0.75 : 0.25),重算 4.4 节的 P(0|x)。感受"证据要压过先验得有多强"。
  3. 实现离散版:多项式朴素贝叶斯 + 拉普拉斯平滑(4.6 节),用词频特征做垃圾邮件分类——本算法最经典的原始战场。
  4. 方差用"总体方差(除以 m_c)“还是"样本方差(除以 m_c−1)”?对预测有影响吗?(提示:同一类内比较时它是公共倍率……吗?两类 m_c 不同时会怎样?)
  5. (进阶)特征独立假设错得有多惨:造一份 x₂ = x₁ + 噪声 的数据(两特征强相关),看 NB 的边界如何被带偏,再对比决策树(根本不在乎相关性)。

8. 小结

概念 一句话 代码落点
贝叶斯定理 联合概率两个入口,一除诞生 2.3 节推导
先验/似然/后验 底色 × 证据修正 = 更新后的判断 m_prior / m_mean,m_var / 预测输出
方向调转 P(c|x) 难算,P(x|c) 可统计 定理的全部价值
朴素假设 给定类别后特征独立,乘法拆开 Σ log P(xᵢ|c)
极大似然出参数 均值/方差不是规定,是“最像这批数据的钟形”算出来的 3.5 节推导
边界形状 等方差线性(中点分界)、异方差二次 3.6 节推导
生成 vs 判别 P(x,c) vs P(c|x):NB 样本效率高、LR 天花板高 3.7 节
argmax 省分母 证据项各类相同,扔掉 score0 > score1
log 技巧 连乘下溢 → 取 log 变加法 logPosterior()
sigmoid 重逢 logit 差 → 概率 predictProba()
模型即世界观 高斯假设装不下月牙 实验 2

下一篇(Part 10)换第三种视角:规则。决策树不用距离、不用概率——它学一串 if-else 提问,把数据一刀一刀劈开,是人类唯一能整棵"读懂"的模型。


Part 10:决策树——用"提问"切开数据

前置:无新数学依赖(本篇自带熵的完整推导),Part 1(标准化,可选)。
决策树是第四种思路:不用距离(kNN)、不用概率(朴素贝叶斯)、不用梯度(回归家族),用一串 if-else 提问把数据切碎。
它是人类唯一能"看懂"的模型——学完能亲手读出模型的全部规则。


1. 思路:二十个问题游戏

小时候玩过的游戏:我心里想一个东西,你最多问 20 个"是/否"问题来猜。

会玩的人提问方式有讲究:

1
2
3
4
5
差劲的提问(一次排除太少):
"是猫吗?" → "不是" 才排除 1/N 种可能

聪明的提问(一次砍掉一半):
"是动物吗?" → 无论答案是什么,都排除一半候选

决策树就是把这套"聪明提问"自动化:

每次挑一个最能"分开两类"的问题(特征 ≤ 阈值?),把数据劈成两半,对每一半递归地继续问,直到问出答案。

一个训练好的树长这样(预测时从根往下走):

1
2
3
4
5
6
7
面积 ≤ 100㎡ ?
├─ 是 → 房龄 ≤ 5 年 ?
│ ├─ 是 → 预测:贵(0.9)
│ └─ 否 → 预测:中(0.6)
└─ 否 → 地铁 ≤ 500m ?
├─ 是 → 预测:贵(0.8)
└─ 否 → 预测:中(0.5)

没有任何数学黑盒——每个判断都写在那里。这是决策树最大的魅力(也是银行、医疗偏爱它的原因:模型必须能解释)。


2. 熵:不确定性的价格(从零推导)

要自动挑"最聪明的提问",先得回答:怎么给"一堆数据的混乱程度"打分? 这个分数就叫熵(entropy)。我们不直接背公式,从零把它逼出来。

2.1 设计指标:先定规矩

设一个数据集里正类占比 p、负类占比 1−p。想造一个函数 H(p) 度量"猜一个随机样本的类别时,我有多没底"。它必须满足三条常识:

  1. 确定无疑时 = 0:全是同类(p=0 或 p=1),闭着眼睛猜都对,不确定性为零;
  2. 五五开时最大:p=0.5 最抓狂——没有任何线索,比这更乱不存在;
  3. 越多类越乱:100 类均匀分布,比 2 类五五开更不确定。

2.2 从"提问次数"倒推公式

换个角度想:H = 平均要问几个"是/否"问题才能确定类别(每次提问最好也砍一半——问题的价格是一个"比特")。

  • 概率为 p 的类,出现它需要传递的信息量 = “排除多少种可能” = −log₂p 个比特:
    • p=1(必然发生):−log₂1 = 0 比特,不用问 ✓
    • p=1/2(五五开):−log₂(1/2) = 1 比特,问一个"是/否" ✓
    • p=1/1024(千分之一):−log₂(1/1024) = 10 比特——稀有事件一旦发生,信息量大 ✓
  • 一个类的"惊讶程度"是 −log₂p,但它是以概率 p 发生的——平均惊讶度要把每类按概率加权:

H=cpc(log2pc)H = \sum_{c} p_c \cdot (-\log_2 p_c)

这就是。逐条验收 2.1 的规矩:

  1. p=1:H = 1·(−log₂1) = 0
  2. p=0.5:H = 0.5·1 + 0.5·1 = 1 比特(两类情形的最大值)✓
  3. 均匀 100 类:H = log₂100 ≈ 6.64 比特 > 1 ✓

手算两个(反复用到,务必动手):

  • {9 正, 1 负}:H = 0.9·(−log₂0.9) + 0.1·(−log₂0.1) = 0.9×0.152 + 0.1×3.322 = 0.137 + 0.332 = 0.469 比特(比较纯)
  • {5 正, 5 负}:H = 0.5×1 + 0.5×1 = 1 比特(最混乱)

记忆钩子:熵 = 平均"惊讶"次数;纯 = 0,五五开 = 1。

2.3 为什么是 −log p 而不是别的?

因为只有对数同时满足"可加性":两个独立事件同时发生的信息量 = 各自信息量相加(问两次问题 = 两个比特)。概率上独立事件是相乘(p·q),想让乘法变加法,唯一的函数就是对数。−log(p·q) = −log p − log q。这不是发明,是被三条规矩逼出来的(信息论奠基人 Shannon 1948 年的定理严格证明了唯一性)。

——你已经见过第二次"公式被逼出来"了:第一次是 Part 3 的 MSE 求导消掉 1/2。


3. 信息增益:一次提问值多少

3.1 定义

用问题 q 把数据集 D 劈成两半 D₁、D₂(样本数 m₁、m₂)。劈开的信息增益

IG(q)=H(D)(m1mH(D1)+m2mH(D2))劈开后子节点的加权平均熵IG(q) = H(D) - \underbrace{\left(\frac{m_1}{m}H(D_1) + \frac{m_2}{m}H(D_2)\right)}_{\text{劈开后子节点的加权平均熵}}

读法:提问前的不确定性 − 提问后还剩的不确定性 = 这次提问买到了多少信息。买菜讲性价比,提问讲"信息增益"——IG 越大越值得问。

3.2 手算完整例子(实验 0 上机对账)

10 个样本,特征 x 和标签 y:

样本 1 2 3 4 5 6 7 8 9 10
x 1 2 3 4 5 6 7 8 9 10
y 0 0 0 0 0 1 1 1 1 1

候选分裂点(连续特征的习惯做法:取相邻值的中点,本例每对中点都等价,取 x ≤ 5.5)。

分裂前的熵:{5 正, 5 负} → H(D) = 1 比特(2.2 节刚算过)。

分裂后

  • 左(x ≤ 5.5):5 个全 0 类 → 纯 → H(D₁) = 0
  • 右(x > 5.5):5 个全 1 类 → 纯 → H(D₂) = 0

信息增益

IG=1(5100+5100)=10=1.0IG = 1 - \left(\frac{5}{10}\cdot 0 + \frac{5}{10}\cdot 0\right) = 1 - 0 = \mathbf{1.0}

一次提问把 1 比特的不确定性全部买断——完美分裂。

再算一个平庸的(感受差距):分裂点 x ≤ 2.5:

  • 左:{0, 0}(2 个,纯)→ H = 0
  • 右:{0,0,0, 1,1,1,1,1}(3 个 0 类、5 个 1 类)→ H = −(3/8)log₂(3/8) − (5/8)log₂(5/8) = 0.531 + 0.424 = 0.955

IG=1(2100+8100.955)=10.764=0.236IG = 1 - \left(\frac{2}{10}\cdot 0 + \frac{8}{10}\cdot 0.955\right) = 1 - 0.764 = \mathbf{0.236}

同样的提问机会,收益天差地别(1.0 vs 0.236)——决策树的全部智慧就是永远挑 IG 最大的那个

3.3 构建算法(递归贪心)

1
2
3
4
5
build(D, depth):
若 D 全同类 / depth 到上限 / D 太小 → 返回叶节点(预测多数类)
对每个特征 j、每个候选阈值 t:算 IG(j, t)
选 IG 最大的 (j*, t*),把 D 劈成 D_left、D_right
递归 build(D_left, depth+1)、build(D_right, depth+1)

三个停止条件各管一件事:纯了(问到底了)、太深了(防过拟合,见第 5 节)、样本太少(统计不可靠)。没有梯度、没有损失函数、没有迭代——一次自顶向下的递归就把树建完了,这也是它训练飞快的原因。

为什么叫“贪心”?每层只选当前最优分裂,不回头不全局规划。贪心不保证最优树(找全局最优是 NP 难),但实践出奇地好——也是第 11 章随机森林能“集成一堆贪心树”的地基。

3.4 基尼不纯度:熵的廉价替身

工业界另一半(CART,scikit-learn 默认)不用熵,用基尼不纯度

G=1cpc2G = 1 - \sum_c p_c^2

读法:随机抽一个样本、再按占比随机猜它的类,猜错的概率。手算两档(与 2.2 对照):

  • {9 正, 1 负}:G = 1 − 0.9² − 0.1² = 1 − 0.81 − 0.01 = 0.18(熵 0.469)
  • {5 正, 5 负}:G = 1 − 0.25 − 0.25 = 0.5(熵 1)

排序与熵完全一致(纯 = 0,五五开最大),且公式里没有 log。两者其实是近亲:二类时 G=2p(1p)G = 2p(1-p);把 H(p)H(p)p=1/2p = 1/2 处泰勒展开(利用 ln\ln 在 1 处的展开 lnx(x1)(x1)2/2\ln x \approx (x-1) - (x-1)^2/2):

H(p)12ln2(p12)2,G(p)=122(p12)2H(p) \approx 1 - \frac{2}{\ln 2}\big(p - \tfrac12\big)^2, \qquad G(p) = \tfrac12 - 2\big(p - \tfrac12\big)^2

同为 (p12)2(p-\tfrac12)^2 的常数倍——凸性、最大/最小点、排序全部一致,只差刻度。用 G 选分裂和用 H 选分裂,选出来的几乎是同一棵树;而 G 免掉对数,在扫描成千上万候选阈值时快一截(练习 2 上机验证“几乎同一棵树”)。

3.5 IG 的一个偏见:偏爱“话多”的特征

IG 有个隐蔽的偏好:取值类别越多的特征越占便宜。极端例子:给每个样本发一个唯一编号的特征(身份证号)——按它分裂每个叶子全纯,IG = H(D) 直接拉满,但学到的“规律”下次必失灵。分裂越碎、IG 虚高越狠。

C4.5 的修正叫增益率(gain ratio):IG 除以“这次分裂本身把样本切得多碎”(分裂信息):

GR(q)=IG(q)H({m1/m, m2/m})GR(q) = \frac{IG(q)}{H\big(\{m_1/m,\ m_2/m\}\big)}

分裂越碎、分母越大、越扣分。本篇只做连续特征(阈值二分,永远劈两半),偏见不严重——但知道这个坑是必要的(练习 6 亲手撞一次)。

3.6 同一台机器做回归(CART 的另一半)

把“多数类”换成“均值”、“熵下降”换成“MSE 下降”,分类树就变回归树

  • 叶子预测 = 落入该叶的训练样本的 y 均值(Part 1 的样本均值,第 13 章前最后一次回收它);
  • 分裂准则 = 父节点 MSE − 子节点加权 MSE = 方差减少量(分裂就是“把差别大的分开,让每边内部更齐”——与分类版“熵减少”同一句话);
  • 停止条件同款(深度、最小样本数)。

手算:叶内样本 y = {1, 2, 3},均值 = 2,MSE = ((1−2)² + (2−2)² + (3−2)²)/3 = 2/3。若另一次分裂把它劈成 {1, 2} 和 {3}:左边均值 1.5、MSE = 0.25;右边均值 3、MSE = 0——加权 MSE = (2/3)×0.25 = 1/6 < 2/3,方差减少 = 1/2,值得劈。

递归分治 + 均值,1 节的提问游戏原封不动。Part 11 的森林做回归就是“一百棵回归树取平均”——那里第 5 节的方差公式正好是它的说明书。


4. 代码精讲

模块 src/part10_decision_tree/,类 DecisionTree(二叉树、连续特征、信息增益准则)。

4.1 树的表示

1
2
3
4
5
6
7
8
struct Node
{
bool isLeaf = false;
double prediction = 0.0; // 叶:预测值(多数类)
std::size_t feature = 0; // 内部:分裂特征下标
double threshold = 0.0; // 内部:分裂阈值(≤ 走左,> 走右)
std::size_t left = 0, right = 0; // 子节点下标(数组存树,不用指针)
};

std::vector<Node> 数组存树:节点 i 的孩子在 m_nodes[i].left/right 下标里——比指针树好维护、好遍历(Part 11 随机森林直接复用整个类)。

4.2 公式 ↔ 代码对照表

(2.2 节):

数学 代码
H=cpclog2pcH = -\sum_c p_c \log_2 p_c 数正类数 k → p = k/mH -= p*log2(p) + (1-p)*log2(1-p)

信息增益(3.1 节):

数学 代码
H(D) − 加权 H(D₁) − 加权 H(D₂) entropy(labels) - (mL/m)*entropy(left) - (mR/m)*entropy(right)

候选阈值(3.2 节"相邻中点"):

数学 代码
t = (x₍ᵢ₎ + x₍ᵢ₊₁₎)/2 排序去重后相邻对的中点;跳过"劈不开"(左右标签同分布)的候选

递归建树(3.3 节):

算法 代码
纯/太深/太少 → 叶 if (纯 || depth == maxDepth || m < minSamplesLeaf)
选 IG 最大 双层循环扫 (特征, 阈值),记录最优

4.3 一个重要的实现细节:等价分裂去重

同一层可能有多个阈值给出相同最大 IG(比如 3.2 节例子里 x≤5.5 到 x≤9.5 的每个中点劈出的左右子集都一样)。实现里遇到并列取第一个(扫描顺序决定)——保证树确定可复现(随机森林时代这个细节会放大,届时在样本层加随机性而不是阈值层)。


5. 过拟合:树会疯长

5.1 病根:完美分裂的诱惑

不加限制的树会一直分裂到每个叶节点全纯——等价于把每个训练样本单独装进一个叶子。训练准确率 100%,噪声全背下来——和 kNN 的 k=1 同一种病(方差爆炸),药方也同款:限制复杂度

5.2 预剪枝(本篇实现)

超参数两兄弟:

  • maxDepth:树最深几层。越深越会拟合细节(方差大),越浅越呆板(偏差大)——又是偏差-方差跷跷板
  • minSamplesLeaf:一个叶子里至少几个样本。太少 = 给个别样本开私人小灶。

后剪枝(先长满再回砍)效果常更好但实现复杂,练习 4 给思路。最有名的是代价复杂度剪枝(CART 作者提出):给子树 T 定义带罚款的代价

Rα(T)=R(T)+αTR_\alpha(T) = R(T) + \alpha\,|T|

R(T) 是训练误差,|T| 是叶节点数——又是一条“拟合 + 罚款”的拔河公式(Part 6 全家福的树家版本:α 就是树家的 λ,复杂度正则化可以长在结构里,不只长在损失里)。α 固定时,自底向上把“合并后代价不升”的子树收编成一个叶;α 从 0 逐档加大扫一遍,得到一列从满树到光杆的嵌套子树,用验证集挑一棵。maxDepth 是“一刀切的统一宵禁”,代价复杂度剪枝是“按表现逐个减刑”——更精细,但要多一道验证工序。

5.3 树 vs 前面所有模型

kNN 线性/逻辑回归 神经网络 决策树
边界形状 任意但破碎 直线/超平面 任意光滑 轴对齐的矩形拼接
特征量纲敏感 是(距离) 是(梯度) (只比大小,天然免疫)
可解释 中(看权重符号) 最强(直接读规则)
训练成本 迭代 迭代慢 递归一遍(快)

"轴对齐"是树的天性:每次分裂只看一个特征和一个阈值——边界永远是横平竖直的。斜着的数据要靠"楼梯"去逼近(实验 2 的图会看到),也是它的根本局限(随机森林来救)。


6. 实验(main.cpp)

  • 实验 0:手算对账——3.2 节的 10 样本数据:手工算出根节点分裂 IG=1.0、阈值 5.5,与程序建树后打印的根节点(feature、threshold、IG)逐项核对;再手工算 x≤2.5 的 IG=0.236,用程序的扫描器验证。
  • 实验 1:真实建树——两团高斯数据(与 Part 8 同款,公平对比):建树、打印整棵树的规则、训练/测试准确率。
  • 实验 2:深度的跷跷板——maxDepth ∈ {1, 3, 12} 三档:决策区域图对比(深 1 = 一刀两半、深 3 = 楼梯、深 12 = 碎成格子的过拟合)+ 准确率表格。

7. 练习

  1. 手算 {7 正, 3 负} 和 {7 正, 7 负} 的熵(答案 0.881 和 1.0),再算"先劈成 {7,0} 和 {0,3}"的 IG。
  2. 把准则换成基尼不纯度(3.4 节,CART 的默认):重跑实验 1,比较树的形状差异——预测“几乎同一棵树”。
  3. printTree 加上每个节点的样本数和正类占比——读树时这些数字是"这条规则有多可信"的关键。
  4. (进阶)实现后剪枝:建满树后自底向上尝试合并叶节点,用验证集决定去留。
  5. 把分裂换成“两特征的线性组合”(斜的分裂面):树能切开 Part 8 双月牙吗?代价是什么?
  6. 亲手撞一次 3.5 的偏见:给 3.2 节数据加一个“身份证号”特征(每样本唯一值),看 IG 扫描会不会选中它;再用增益率重算——亲眼看虚高与修正。

8. 小结

概念 一句话 代码落点
决策树 一串自顶向下的 if-else 提问 Node 数组
熵 H 平均要问几个是/否问题(纯=0,五五开=1) entropy()
−log p 的必然性 可加性逼出来的唯一选择 2.3 节
信息增益 IG 提问前后的熵差 = 这次提问买到的信息 informationGain()
贪心递归 每层挑 IG 最大的分裂,不回头 buildRecursive()
基尼不纯度 1−Σp²:熵的廉价近亲,排序一致免 log 3.4 节
IG 偏见 / 增益率 多值特征 IG 虚高,除以分裂信息修正 3.5 节
回归树 叶 = 均值,准则 = 方差减少 3.6 节
预剪枝 maxDepth/minSamplesLeaf 踩住复杂度 停止条件
轴对齐边界 每次只看一个特征,横平竖直切 实验 2 楼梯图

下一篇(Part 11)随机森林:一棵树爱过拟合?那就民主投票一百棵——每棵树只看数据的一个随机切片,赌"集体智慧"强过任何个体。


Part 11:随机森林——民主投票的树军团

前置:Part 10(决策树——森林的零件)。
集成视角:一棵深树是过拟合大王(训练 100%、测试 82%),但一百棵各自片面的树投票,错误相互抵消——集体智慧强过任何个体。


1. 思路:一群平庸评委好过一个天才评委

选美比赛有一个评委,他有自己的偏好(偏爱红色礼服)。他打的分系统性偏高/偏低——这叫偏差。请一百个背景各异的评委,各自的偏好互相抵消,平均分反而公正。

把这个思路套回 Part 10 的痛点。决策树(尤其深树)的病是方差爆炸

换一份训练集,长出来的树完全不同——分裂点、特征、形状全都变。数据里的一点噪声,树都当成金科玉律背下来(实验 1:月牙再加八个纯噪声特征,单树训练 100%、测试 75%)。

统计学的老 wisdom 对症下药:

  • 偏差大的模型(如线性模型拟合月牙):平均救不了——一百条直线平均还是一条直线;
  • 方差大的模型(如深树):平均正好治它——n 个方差 σ²、互不相关的估计量,平均后方差变 σ²/n。

这两句话不该凭感觉信。第 4 节把误差拆开(偏差-方差分解,从零推导),第 5 节推平均的方差公式——森林的全部理论就是这两条公式的工程化。

但有个致命障碍:同一份数据训出来的树高度相关(它们看的是同一批样本,最会分裂的特征也相同)——相关性 0.9 的一百棵树,平均后跟一棵树没区别。随机森林的全部机巧,就是给树军团注入两种随机性让它们去相关

2. 第一针随机:Bootstrap(从零推导 63.2%)

2.1 有放回抽样:同一个数据的"平行宇宙"

Bagging(Bootstrap AGGregatING)的核心动作:从 m 个训练样本里有放回地抽 m 个,当作一棵树的训练集。

“有放回"意味着:同一个样本可能被抽中多次,也可能一次都没有。每棵树看到的都是"原数据的一个搅乱版”——像同一个世界的平行宇宙。

2.2 手推:一个样本"落榜"的概率

某棵树的构建中,一个特定样本一次都抽不中的概率是多少?每次抽取,它不被抽中的概率是 1 − 1/m;抽 m 次(有放回,彼此独立):

P(落榜)=(11m)mP(\text{落榜}) = \left(1 - \frac{1}{m}\right)^m

手算三档(对照用,实验 0 上机验证):

m 计算 落榜率 入选率
10 0.9100.9^{10} 34.87% 65.13%
100 0.991000.99^{100} 36.60% 63.40%
limm(11/m)m=e1\lim_{m\to\infty}(1-1/m)^m = e^{-1} 36.79% 63.21%

(那个极限是微积分的经典结论:(1+1/n)ne(1+1/n)^n \to e 的孪生兄弟。)

每棵树平均只见到 63.2% 的不同样本——两棵树的训练集天然不同,这是去相关的第一针。

2.3 免费午餐:袋外(OOB)误差

36.8% 落榜样本对那棵树来说是没见过的——它们天生就是那棵树的测试集。对每个样本,用所有"没见过它"的树投票预测它,再和真值比——不用额外划分数据,就得到了一个诚实的泛化误差估计。这叫袋外(Out-Of-Bag)误差。数据稀缺时这简直是免费的午餐(实验 2 上机)。

2.4 Bagging 全流程

1
2
3
4
对 t = 1..T 棵树:
bootstrap 抽一份 m 个样本的训练集
建一棵深树(故意不剪枝——方差交给平均去治)
预测 = T 棵树投票,多数票胜出

注意反直觉的一点:每棵树故意长到深、不严格剪枝。单棵树过拟合没关系——要的是"低偏差"(每棵树都尽力拟合),把"高方差"留给平均去消。森林的哲学:要一百个有主见的专家,不要一百个和稀泥的庸才

3. 第二针随机:特征抽奖

光靠 bootstrap 还不够——树太聪明了:只要最强特征(比如月牙数据的 x₁)还在,每棵树的前几刀几乎一样,相关性仍然高。

第二针:每次分裂,只从随机抽的 √n 个特征里挑(n 是总特征数)。

  • 月牙数据 n=2 → √2 ≈ 1:每次分裂只看一个随机特征——一半机会树被迫用 x₂ 分裂,长势立刻五花八门;
  • n=100 的数据 → 每次只看 10 个:最强特征经常不在抽奖名单里,弱特征获得上场机会(这也是随机森林的副作用红利:能暴露"被强特征掩盖的弱特征")。

这正是 Part 10 给 DecisionTree 预留 maxFeatures 参数的原因——钩子在此兑现:森林给每棵树传不同的随机种子,每层分裂抽不同的特征子集。

双重随机 = 样本轮盘(bootstrap)× 特征轮盘(√n 抽奖)。两针下去,树军团的相关性大降,平均的魔法才能生效。

副产品:特征重要性。抽奖让每个特征都有上场机会,每次分裂的收益(不纯度下降量 × 该节点样本数)按特征累加、再对全林平均——被强特征掩盖的弱特征,贡献就能显形(练习 4 动手算月牙的 x₁/x₂,答案约 2:1)。单个模型的“特征排名”不可靠(一棵树的排名换个种子就变),森林的平均把排名也稳住了——又一次“平均治方差”。

4. 偏差-方差分解:把误差拆开看(从零推导)

Part 8(k 的跷跷板)第一次提到“偏差-方差权衡”时只给了定性描述。现在正式从零推它——只有拆开误差,才能看清“平均”这一刀到底切在哪里。

4.1 设定:两个随机源

固定一个测试点 x,真值 y = f(x) + ε,其中 ε 是噪声:E[ε] = 0、Var(ε) = σ²(Part 1 的老设定:数据 = 规律 + 噪声)。模型 f̂(x) 是从一份数据学出来的,而数据是随机抽的——所以 f̂ 本身是随机变量。考察均方误差

E[(yf^(x))2]E\big[(y - \hat{f}(x))^2\big]

期望对两个随机源取:训练集的抽样 + 噪声 ε。

再记平均模型 fˉ(x)=E[f^(x)]\bar{f}(x) = E[\hat{f}(x)]——“无穷多份平行宇宙的数据、各训一个模型、再取平均”得到的那个模型。它是想象的产物(没人真能算出来),但作为推导的锚点非常好用:它是“这套学习方法平均而言会学出什么”的化身。

4.2 推导:加减同一项(统计学的经典戏法)

在 y − f̂ 中间塞进一个 f̄,硬拆成两块:

yf^=(yfˉ)平均模型高真值有多远+(fˉf^)这次手气绕平均值波动多少y - \hat{f} = \underbrace{(y - \bar{f})}_{\text{平均模型高真值有多远}} + \underbrace{(\bar{f} - \hat{f})}_{\text{这次手气绕平均值波动多少}}

平方取期望,交叉项:E[(yfˉ)(fˉf^)]=(E[y]fˉ)(fˉE[f^])=(E[y]fˉ)0=0E[(y-\bar{f})(\bar{f}-\hat{f})] = (E[y] - \bar{f})\cdot(\bar{f} - E[\hat{f}]) = (E[y]-\bar{f})\cdot 0 = 0(y 与 f̂ 独立,可拆期望;而 f̄ − E[f̂] = 0 是 f̄ 的定义)。于是只剩两个平方项:

E[(yf^)2]=E[(yfˉ)2]平均模型自己的误差+E[(f^fˉ)2]绕着平均模型的波动E\big[(y - \hat{f})^2\big] = \underbrace{E\big[(y - \bar{f})^2\big]}_{\text{平均模型自己的误差}} + \underbrace{E\big[(\hat{f} - \bar{f})^2\big]}_{\text{绕着平均模型的波动}}

第一块再拆一次:代入 y = f + ε,交叉项(ε 零均值、与 f̄ 独立)照例消失,得 (fˉf)2+σ2(\bar{f} - f)^2 + \sigma^2。第二块就是方差 Var(f^)\mathrm{Var}(\hat{f})。合起来:

 E[(yf^)2]=(fˉf)2偏差2+Var(f^)方差+σ2噪声 \boxed{\ E\big[(y - \hat{f})^2\big] = \underbrace{(\bar{f} - f)^2}_{\text{偏差}^2} + \underbrace{\mathrm{Var}(\hat{f})}_{\text{方差}} + \underbrace{\sigma^2}_{\text{噪声}}\ }

4.3 三块骨头:病历与手术方案

骨头 含义 典型患者 谁能治
偏差² 平均下来还是错的——模型能力不够 直线拟月牙 换更强的模型(更深、更灵活)
方差 换一份训练集答案就变——对数据太敏感 深树(1 节的痛点) 平均!
噪声 σ² 数据本身的不可约误差 谁都逃不掉 谁也治不了(误差的地板)

手算迷你例:真值 f(x) = x,无噪声(σ² = 0)。平行宇宙们学出的模型:f̂₁ = x+1、f̂₂ = x−1。平均模型 f̄ = x。逐块算:偏差² = 0;方差 = (1² + (−1)²)/2 = 1。分解预言总误差 = 0 + 1 + 0 = 1。直接验算:任取 f̂₁,(x+1−x)² = 1 ✓。

回到 1 节那两句话的数学版:平均不动 f̄,只压 Var——E[f̂₁,…,f̂ₙ 的平均] = f̄,所以偏差² 原封不动(一百条直线平均还是直线);而方差那块按第 5 节的公式缩小。Bagging 是一台方差手术,一刀只切方差那一块——这也解释了 2.4 节的反直觉:树要故意长深(把偏差拉到最低),把方差留给手术。

5. 平均为什么有效:方差公式(从零推导)

5.1 四行代数

n 个估计量 X₁,…,Xₙ(n 棵树对同一点的预测),各自方差 σ²、两两相关系数 ρ(即 i ≠ j 时 Cov(Xᵢ, Xⱼ) = ρσ²)。平均 X̄ = (1/n)ΣXᵢ 的方差:

Var(Xˉ)=1n2Var(iXi)=1n2[iVar(Xi)+ijCov(Xi,Xj)]\mathrm{Var}(\bar{X}) = \frac{1}{n^2}\mathrm{Var}\Big(\sum_i X_i\Big) = \frac{1}{n^2}\Big[\sum_i \mathrm{Var}(X_i) + \sum_{i \ne j} \mathrm{Cov}(X_i, X_j)\Big]

(方差的和公式:Var(Σ) = ΣVar + ΣΣCov——Part 1 方差性质的推广。)代入 σ² 和 ρσ²:

=1n2[nσ2+n(n1)ρσ2]=σ2n+n1nρσ2=ρσ2+1ρnσ2= \frac{1}{n^2}\big[n\sigma^2 + n(n-1)\rho\sigma^2\big] = \frac{\sigma^2}{n} + \frac{n-1}{n}\rho\sigma^2 = \rho\sigma^2 + \frac{1-\rho}{n}\sigma^2

三档 sanity check:ρ = 0 → σ²/n(独立,1 节的承诺兑现);ρ = 1 → σ²(完全同调,平均了个寂寞);n = 1 → σ²(单树)。三个极端全部对上,公式可信。

读法:第一项是相关性地板(树们犯同一种错,再多的树也平均不掉),第二项是独立噪声(各犯各的错,除以 n 被稀释)。

(顺带:森林做回归时把投票换成平均,这条公式直接就是它的说明书;本篇实现分类投票版。)

5.2 手算(σ² = 1)

ρ=1(完全同调) ρ=0.3(双针去相关) ρ=0
n=1(单树) 1.0 1.0 1.0
n=100 1.0 0.3 + 0.7/100 = 0.307 0.01
n=∞ 1.0 0.30 0

5.3 两个教训(实验 2 的曲线会亲眼看到)

  1. 加树的收益递减:n 从 1 到 100 降幅巨大,从 100 到 1000 几乎不动(只剩地板)——实践里 100~500 棵就够;
  2. 降相关性比加树更值钱:ρ 从 0.9 降到 0.3 的收益,加多少棵树都换不来——这就是为什么需要两针随机而不只是一针。

6. 代码精讲

模块 src/part11_random_forest/,类 RandomForest——自己不实现任何树的逻辑,全部复用 Part 10 的 DecisionTree

1
2
3
4
5
6
7
8
9
10
// fit 的骨架:两针随机 + 复用零件
for (std::size_t t = 0; t < m_numTrees; ++t)
{
std::mt19937 rng{ m_seed + t }; // 第一针:样本轮盘
bootstrap 抽 m 个(记录落榜者 → OOB 名单)
DecisionTree tree{ m_maxDepth, m_minSamplesLeaf,
maxFeatures(n), m_seed * 1000 + t }; // 第二针:特征轮盘(√n)
tree.fit(xBootstrap, yBootstrap);
m_trees.push_back(...) // 存进军团
}

公式 ↔ 代码对照:

数学 代码
(11/m)m(1-1/m)^m 落榜 bootstrap 循环 + oob 下标集合
每次分裂抽 √n 个特征 maxFeatures = max(1, (size_t)√n) 传给树
多数票 votes1 > T/2 ? 1 : 0
得票比例(概率) predictProba = votes1 / T
OOB 误差 只用"没见过它"的树投票,对比真值

设计呼应:Part 10 的 maxFeatures/featureSeed 钩子、findBestSplit 的节点级随机特征抽取,此刻全部兑现——好的接口预留像埋伏笔,后章回收

7. 实验(main.cpp)

  • 实验 0:手算对账——(1−1/m)^m 定理上机:m=10/100 的理论落榜率 34.87%/36.60%,bootstrap 万次模拟实测对照;再加 5.2 节方差公式手算(ρ=0.3、n=100 → 0.307)程序复算。
  • 实验 1:森林 vs 单树——噪声月牙再加八个纯噪声特征(共 10 维,每类 30):深 12 单树在噪声维度上找到假分裂、背到训练满分(训练 100%、测试 75%);100 棵深树投票把假分裂平均掉(测试 85%)。决策区域对比图(单树碎格子 vs 森林平滑边界)。
  • 实验 2:树数的饱和曲线——树数 ∈ {1, 5, 10, 25, 50, 100, 200}:测试准确率与 OOB 误差双曲线。OOB 不用测试集就贴近真实泛化误差——5.3 节“收益递减、百棵饱和”的眼见为实。

8. 练习

  1. 手算 m=20 的落榜率 0.95200.95^{20}(答案 ≈ 0.358),验证它已很接近 36.8%。
  2. 把特征抽奖关掉(maxFeatures = n,只用 bootstrap):用实验 1 的数据重跑,测试准确率掉多少?——亲手感受"第二针"的必要性。
  3. 极限树(Extremely Randomized Trees):连分裂阈值也随机抽,不挑最优——实现之并与随机森林对比。更多随机、更弱的相关性,何时赚何时亏?
  4. 特征重要性:分裂时记录 gain × 样本数,按特征累加、对树平均——给月牙数据算 x₁/x₂ 的重要性(答案:约 2:1,月牙的形状主要靠 x₁ 的两次拐弯)。
  5. 森林能切开 Part 9 的双月牙吗?和 kNN(97.5%)五五开还是完胜?(动手前先猜。)

9. 小结

概念 一句话 代码落点
集成 一群片面专家投票,错误互相抵消 m_trees 军团
Bootstrap 有放回抽 m 个 = 数据的平行宇宙 bootstrapIndices()
63.2% 定理 (11/m)me1(1-1/m)^m \to e^{-1},落榜 36.8% 实验 0
OOB 误差 落榜样本 = 免费的测试集 oobError()
特征抽奖 每次分裂只看 √n 个随机特征 maxFeatures 钩子
偏差-方差分解 bias² + 方差 + σ²:平均只切方差那一刀 4.2 推导
方差公式 ρσ² + (1−ρ)σ²/n:相关性是地板 5.1 推导 + 5.2 手算
投票 多数票 / 得票比例 predict/predictProba
不剪枝哲学 单树要低偏差,方差交给平均 maxDepth 给足

下一篇(Part 12)本课程的"最后一座大山":支持向量机。换到几何+优化的视角——不找"能分开"的边界,找分开得最体面的那条(最大间隔);对偶和核技巧,让"在高维空间里画直线"成为可能。


Part 12:支持向量机——分开得最体面的那条线

前置:Part 3(损失与梯度)、Part 4(逻辑回归)、Part 6(正则化家族视角)、Part 10(月牙基准)。
本篇是全课程的"最后一座大山":几何 + 优化的视角。前面每个模型都在回答"怎么分开两类点",SVM 换了一个问法:分开的方案有无数种,哪一种最体面?


1. 问题:边界不止一条

回到 Part 4 的老场景:平面上两类点,找一条直线分开它们。

逻辑回归会说:找一条让所有点的概率都尽量对的线。但它没回答一个问题——满足条件的线有无数条。下图的 A、B、C 三条线都能"零错误"地分开训练集:

  • 线 A 紧贴着正类点走——再来一个正类点稍微偏一点就判错;
  • 线 B 紧贴着负类点走——同理脆弱;
  • 线 C 从两类的"正中间"走过,离两边都尽可能远——新数据抖一抖也不怕。

直觉上人人都选 C。SVM 就是把这个直觉数学化:在所有能分开的线里,选离两边样本都最远的那条——"从正中间走过"的那条。

类比:在悬崖和峭壁之间修路,你不会贴着悬崖边修——你会修在正中间,两边留出尽可能宽的缓冲。SVM 找的就是这条"街道",而且要把街道修到最宽

1.1 一图看懂本篇路线

  1. 把"街道最宽"翻译成数学 → 间隔 = 2/‖w‖(第 2 章,纯几何推导);
  2. "最宽"变成一个约束优化问题 → 拉格朗日对偶 → 支持向量现身(第 3 章);
  3. 现实数据有噪声、分不开 → 软间隔 + C 旋钮(第 4 章,接 Part 6 全家福);
  4. 月牙分不开怎么办 → 核技巧:升维打击(第 5 章)。

2. 间隔:从零推导"街道宽度"

2.1 先备工具:点到直线的距离

决策边界是一条直线 w1x1+w2x2+b=0w_1x_1 + w_2x_2 + b = 0(Part 3 同款,w 是法向量)。任意一点 x0x_0 到它的距离是多少?

从零推。设直线上离 x0x_0 最近的点是 xpx_p(垂足),那么 x0xpx_0 - x_p 沿着法方向 w,长度就是距离 d:

x0xp=dwwx_0 - x_p = d\,\frac{w}{\|w\|}

两边同时点乘 w:

wx0wxp=dwww=dww \cdot x_0 - w \cdot x_p = d\,\frac{w \cdot w}{\|w\|} = d\,\|w\|

垂足 xpx_p 在直线上,wxp+b=0w \cdot x_p + b = 0,即 wxp=bw \cdot x_p = -b。代入:

wx0+b=dwd=wx0+bww \cdot x_0 + b = d\,\|w\| \quad\Longrightarrow\quad d = \frac{|w \cdot x_0 + b|}{\|w\|}

记忆法:代入直线方程的左边,除以法向量长度。分母 ‖w‖ 在提醒你:w 变长,同样的"代入值"只代表更近的距离。

2.2 换标签:+1 / −1

本篇把标签从 {0, 1} 换成 {1,+1}\{-1, +1\}。不为别的,就为一个好处:y 和 (w·x + b) 同号时乘积为正。定义每个样本的"函数间隔":

γ(i)=y(i)(wx(i)+b)\gamma^{(i)} = y^{(i)}(w \cdot x^{(i)} + b)

  • 预测正确 ⟺ γ(i)>0\gamma^{(i)} > 0(符号一致);
  • 预测错误 ⟺ γ(i)<0\gamma^{(i)} < 0

2.3 把"最近点"顶到 ±1(本篇最绕的一步,慢慢来)

我们要的是"离最近的点都尽量远"。先做个约定:设最近的那批点满足

mini γ(i)=M\min_i\ \gamma^{(i)} = M

现在注意一个:w 和 b 同时放大 k 倍,直线根本没动(kwx+kb=0kw \cdot x + kb = 0 是同一条线),但 γ(i)\gamma^{(i)} 却放大了 k 倍——光看 γ\gamma 会被缩放骗到。而 2.1 节的真距离 d=γ/wd = \gamma/\|w\| 不受缩放影响。

既然 w、b 可以随意缩放而不改变直线,我们干脆利用这个自由度:缩放 w、b,让最近的点恰好满足

y(i)(wx(i)+b)1对所有 iy^{(i)}(w \cdot x^{(i)} + b) \ge 1 \quad \text{对所有 } i

(把 M 归一到 1。取 1 不失一般性——取 2、取 0.5 都只是又一次缩放。)

2.4 街道宽度 = 2/‖w‖(三行推导)

在这个约定下,最近的正类点落在 wx+b=+1w \cdot x + b = +1 上,最近的负类点落在 wx+b=1w \cdot x + b = -1 上。这两条平行线就是街道的两条路缘,中间那条 wx+b=0w \cdot x + b = 0 是路中线。

两条路缘相距多远?用 2.1 的距离公式:d=1(1)/w=2/wd = |1 - (-1)|/\|w\| = 2/\|w\|

街道宽度(间隔)= 2/‖w‖

2.5 优化问题登场

目标"街道最宽" ⟺ 最大化 2/w2/\|w\| ⟺ 最小化 w\|w\| ⟺ 最小化 12w2\tfrac{1}{2}\|w\|^2(平方是为了好求导,½ 是为了消掉求导出来的 2)。于是:

minw,b 12w2s.t.y(i)(wx(i)+b)1, i\min_{w,b}\ \frac{1}{2}\|w\|^2 \qquad \text{s.t.}\quad y^{(i)}(w \cdot x^{(i)} + b) \ge 1,\ \forall i

读法:在"所有点都正确分类且留白至少 1"的前提下,把 w 压到最小。约束管"对",目标管"稳"。这是一个凸二次规划——凸意味着只有一个坑,找到就是全局最优(对比神经网络的多坑地形,Part 5 的随机性困扰在这里不存在)。


3. 对偶:支持向量现身

3.1 拉格朗日乘子:把约束搬进目标

带"不许越界"约束的优化怎么解?拉格朗日的思路:每条约束配一个乘子 αi0\alpha_i \ge 0,越界就罚款,无约束地优化"目标 + 罚款":

L(w,b,α)=12w2iαi[y(i)(wx(i)+b)1]\mathcal{L}(w, b, \alpha) = \frac{1}{2}\|w\|^2 - \sum_i \alpha_i\left[y^{(i)}(w \cdot x^{(i)} + b) - 1\right]

原问题等价于 minw,bmaxαL\min_{w,b}\max_\alpha \mathcal{L}(约束满足时罚款项最多为 0,α 抓不到把柄)。把它翻转成 maxαminw,bL\max_\alpha \min_{w,b} \mathcal{L},就是对偶问题——凸问题里两者同解(强对偶)。

3.2 对 w、b 求导置零(四行代数)

L/b=0\partial\mathcal{L}/\partial b = 0iαiy(i)=0\sum_i \alpha_i y^{(i)} = 0(α 的总约束);

L/w=0\partial\mathcal{L}/\partial w = 0

w=iαiy(i)x(i)w^* = \sum_i \alpha_i y^{(i)} x^{(i)}

代回去化简——把 ww^*iαiy(i)=0\sum_i\alpha_iy^{(i)} = 0 代进 L\mathcal{L}

L=12iαiy(i)x(i)2iαi[y(i)(jαjy(j)x(j)x(i)+b)1]\mathcal{L} = \frac{1}{2}\Big\|\sum_i \alpha_i y^{(i)} x^{(i)}\Big\|^2 - \sum_i \alpha_i\Big[y^{(i)}\Big(\sum_j \alpha_j y^{(j)}\, x^{(j)}\cdot x^{(i)} + b\Big) - 1\Big]

数一数:第一项按范数平方展开是 12ijαiαjy(i)y(j)(x(i)x(j))\frac{1}{2}\sum_i\sum_j \alpha_i\alpha_j y^{(i)}y^{(j)}(x^{(i)}\cdot x^{(j)});第二项的中括号里同一个双重和又出现一次(没有 ½)——两项相减,只剩下负的 ½;含 b 的项是 biαiy(i)=0-b\sum_i\alpha_iy^{(i)} = 0(约束把它杀了)。活着走出代换的只有:

maxα iαi12ijαiαjy(i)y(j)(x(i)x(j))样本点积 Kijs.t.αi0, iαiy(i)=0\max_\alpha\ \sum_i \alpha_i - \frac{1}{2}\sum_i\sum_j \alpha_i\alpha_j y^{(i)}y^{(j)}\,\underbrace{(x^{(i)} \cdot x^{(j)})}_{\text{样本点积 } K_{ij}} \qquad \text{s.t.}\quad \alpha_i \ge 0,\ \sum_i \alpha_i y^{(i)} = 0

圈重点:x 从头到尾只以两两点积 x(i)x(j)x^{(i)}\cdot x^{(j)} 的形式出现——这句话是第 5 章核技巧的全部伏笔。

3.3 支持向量:只有边缘上的点说了算

最优解处有 KKT 互补条件:αi>0y(i)(wx(i)+b)=1\alpha_i > 0 \Rightarrow y^{(i)}(w\cdot x^{(i)}+b) = 1(恰好顶在路缘上)。

翻过来读更有味:离得远的点(留白 > 1)必然 αi=0\alpha_i = 0——它们在 w=αiy(i)x(i)w^* = \sum \alpha_i y^{(i)}x^{(i)} 里根本不出现!

删掉所有非支持向量,重新训练,答案一个字都不变。 决策边界只由贴着路缘的那几个点(支持向量)决定——它们用手"撑"出了这条街。这也是模型名字的由来:支持向量的机器。

对照 Part 8 的 kNN(每个测试点要数全体邻居)和 Part 11 的森林(预测时 100 棵树全上):SVM 预测只需少数几个支持向量——训练完就可以把其余数据扔了。

3.4 手算例子(实验 0 逐位对账)

三个点:负类 x(1)=(0,0)x^{(1)} = (0,0),正类 x(2)=(2,2)x^{(2)} = (2,2)x(3)=(4,4)x^{(3)} = (4,4)

x³ 离得远、大概率不是支持向量。验证:只用 x¹、x² 求解。两点都在路缘上:

w(2,2)+b=+1,w(0,0)+b=1w \cdot (2,2) + b = +1,\qquad w \cdot (0,0) + b = -1

第二式给 b=1b = -1;代回第一式 2w1+2w21=12w_1 + 2w_2 - 1 = 1,即 w1+w2=1w_1 + w_2 = 1。与对偶解对照:w=α2x(2)α1x(1)w^* = \alpha_2 x^{(2)} - \alpha_1 x^{(1)},x¹ 是原点贡献为 0,且对称性给 α1=α2\alpha_1 = \alpha_2,所以 w=α2(2,2)w = \alpha_2(2,2),即 w1=w2w_1 = w_2。联立 w1+w2=1w_1 + w_2 = 1

w=(0.5, 0.5),b=1,α1=α2=0.25, α3=0w^* = (0.5,\ 0.5),\qquad b^* = -1,\qquad \alpha_1 = \alpha_2 = 0.25,\ \alpha_3 = 0

验算间隔:2/w=2/0.5=222.8282/\|w\| = 2/\sqrt{0.5} = 2\sqrt{2} \approx 2.828

关键验算:x³ 真的不顶路缘吗?γ(3)=+1×(wx(3)+b)=(2+2)1=3>1\gamma^{(3)} = +1 \times (w\cdot x^{(3)} + b) = (2+2) - 1 = 3 > 1 ✓——留白充足,约束自动满足,α₃ = 0 成立。三个点的数据,答案只由两个点决定,第三个点删掉世界不变。


4. 软间隔:给现实留余地

4.1 硬间隔的困境

第 2 章的约束要求每个点都正确且留白 ≥ 1。但现实数据有噪声:

  • 一个混进负类地盘的正类离群点,能把整条街带歪(为了迁就它,街道又窄又斜);
  • 月牙这种 fundamentally 线性不可分的数据,干脆无解。

4.2 松弛变量:允许犯错,但要罚款

给每个点发一张"赦免券" ξi0\xi_i \ge 0,约束放宽成 y(i)(wx(i)+b)1ξiy^{(i)}(w\cdot x^{(i)}+b) \ge 1 - \xi_i(ξ 是"欠的留白"),同时按总量罚款:

minw,b,ξ 12w2+Ciξi\min_{w,b,\xi}\ \frac{1}{2}\|w\|^2 + C\sum_i \xi_i

C 是罚款单价。对偶问题几乎不变,只多了一个盒子上界:

0αiC0 \le \alpha_i \le C

读法(接入 Part 6 全家福):C 就是 SVM 家的复杂度旋钮——

C 语气 效果 对应
C → ∞ “一个都不许错!” 逼出硬间隔,离群点全数迁就 过拟合风险(λ→0)
C 适中 “大错别犯,小错罚款” 街道合理宽 + 个别点豁免 正合适
C → 0 “错就错吧” 街道极宽但错误成堆 欠拟合(λ→∞)

注意方向:C 大 = 正则弱,和 Part 6 的 λ 正好反过来(数学上 C ∝ 1/λ)。

4.3 软间隔的另一张脸:hinge 损失

把软间隔问题换个写法,SVM 会露出第二身份。ξᵢ 在目标里只以 +Ciξi+C\sum_i\xi_i 出现,约束只要求 ξi1y(i)f(x(i))\xi_i \ge 1 - y^{(i)}f(x^{(i)})ξi0\xi_i \ge 0——最优解必取下界:

ξi=max(0, 1y(i)f(x(i)))\xi_i = \max\big(0,\ 1 - y^{(i)} f(x^{(i)})\big)

(罚款取“刚好够用”:留白够(yf ≥ 1)就不交钱,缺多少交多少。)代回去,约束全部消失,软间隔 SVM 等价于无约束

minw,b imax(0, 1y(i)(wx(i)+b))hinge(合页)损失+1C12w2\min_{w,b}\ \sum_i \underbrace{\max\big(0,\ 1 - y^{(i)}(w \cdot x^{(i)} + b)\big)}_{\text{hinge(合页)损失}} + \frac{1}{C}\cdot\frac{1}{2}\|w\|^2

hinge 损失:留白 ≥ 1 时损失为 0(合页闭合),不足时线性上升。对照 Part 4 的对数损失:

对数损失(逻辑回归) hinge 损失(SVM)
形状 处处光滑,永远 > 0 有“零区”,过了 1 就不管
梯度 处处连续 零区梯度 = 0——这正是“只有支持向量交学费”的损失函数版解释
输出 概率(强项) 只管边界,不输出概率

这张脸让 SVM 正式入籍 Part 6 全家福:SVM = hinge 损失 + L2 罚款,C 与 λ 一一对应。同一个模型三张身份证:几何(最宽街道)、优化(二次规划/对偶)、统计(正则化的合页回归)——三张脸说的是同一个它。

4.3 软间隔的另一张脸:hinge 损失

把软间隔问题换个写法,SVM 会露出第二身份。ξᵢ 在目标里只以 +Ciξi+C\sum_i\xi_i 出现,约束只要求 ξi1y(i)f(x(i))\xi_i \ge 1 - y^{(i)}f(x^{(i)})ξi0\xi_i \ge 0——最优解必取下界:

ξi=max(0, 1y(i)f(x(i)))\xi_i = \max\big(0,\ 1 - y^{(i)} f(x^{(i)})\big)

(罚款取“刚好够用”:留白够(yf ≥ 1)就不交钱,缺多少交多少。)代回去,约束全部消失,软间隔 SVM 等价于无约束

minw,b imax(0, 1y(i)(wx(i)+b))hinge(合页)损失+1C12w2\min_{w,b}\ \sum_i \underbrace{\max\big(0,\ 1 - y^{(i)}(w \cdot x^{(i)} + b)\big)}_{\text{hinge(合页)损失}} + \frac{1}{C}\cdot\frac{1}{2}\|w\|^2

hinge 损失:留白 ≥ 1 时损失为 0(合页闭合),不足时线性上升。对照 Part 4 的对数损失:

对数损失(逻辑回归) hinge 损失(SVM)
形状 处处光滑,永远 > 0 有“零区”,过了 1 就不管
梯度 处处连续 零区梯度 = 0——这正是“只有支持向量交学费”的损失函数版解释
输出 概率(强项) 只管边界,不输出概率

这张脸让 SVM 正式入籍 Part 6 全家福:SVM = hinge 损失 + L2 罚款,C 与 λ 一一对应。同一个模型三张身份证:几何(最宽街道)、优化(二次规划/对偶)、统计(正则化的合页回归)——三张脸说的是同一个它。


5. 核技巧:升维打击

5.1 月牙的绝望与转机

线性 SVM 打月牙(Part 8/9/10/11 全用过的基准数据):准确率 85% 上下,边界是一条斜线——在 2 维里它永远只能是直线

转机:2 维分不开的点,映射到高维可能就分开了。想象把平面上每个点 x=(x1,x2)x = (x_1, x_2) 拋到 3 维 ϕ(x)=(x12,2x1x2,x22)\phi(x) = (x_1^2, \sqrt{2}\,x_1x_2, x_2^2)——内圈外圈的点被"拋"到了不同高度,一张平面就能切开。维度越高,可分的希望越大(极端:样本数有限时,升到足够高的维度总能分开)。

5.2 障碍与技巧

直接做法:把所有 x(i)x^{(i)} 换成 ϕ(x(i))\phi(x^{(i)}) 再训练。问题是:高维 φ(x) 本身又长又贵(RBF 核甚至是无穷维,根本写不出来)。

回头看 3.2 节圈的重点:对偶问题和预测公式里,x 只以点积出现。而"先映射再点积"这件事,也许有一个直接用原坐标算的捷径:

K(x,z)=ϕ(x)ϕ(z)K(x, z) = \phi(x) \cdot \phi(z)

K 叫核函数。核技巧 = 不显式构造 φ,直接用 K 算出高维点积

5.3 手算:多项式核的恒等式(实验 0 对账)

二次多项式核(c=1):

K(x,z)=(xz+1)2K(x, z) = (x \cdot z + 1)^2

它对应的映射是 ϕ(x)=(x12, 2x1x2, x22, 2x1, 2x2, 1)\phi(x) = (x_1^2,\ \sqrt{2}x_1x_2,\ x_2^2,\ \sqrt{2}x_1,\ \sqrt{2}x_2,\ 1)——把 2 维升到 6 维。取 x=(1,2)x = (1,2)z=(3,4)z = (3,4)

左边(核捷径)xz=1×3+2×4=11x \cdot z = 1\times3 + 2\times4 = 11K=(11+1)2=144K = (11+1)^2 = 144

右边(老实升维再点积)

ϕ(x)=(1, 22, 4, 2, 22, 1),ϕ(z)=(9, 122, 16, 32, 42, 1)\phi(x) = (1,\ 2\sqrt2,\ 4,\ \sqrt2,\ 2\sqrt2,\ 1),\qquad \phi(z) = (9,\ 12\sqrt2,\ 16,\ 3\sqrt2,\ 4\sqrt2,\ 1)

ϕ(x)ϕ(z)=9+48+64+6+16+1=144\phi(x)\cdot\phi(z) = 9 + 48 + 64 + 6 + 16 + 1 = 144 \checkmark

一次乘加 vs 先算两个 6 维向量再点积——核函数不升维却拿到了升维后的点积。这就是"技巧"二字的分量。

5.4 常用核函数

公式 升到几维 适合
线性 xzx \cdot z 不升 本来就近似线性可分
多项式 (xz+c)d(x \cdot z + c)^d (n+dd)\binom{n+d}{d} 边界带弯曲的交互
RBF(高斯) exp(γxz2)\exp(-\gamma|x-z|^2) 无穷维 月牙、同心圆等任意边界(默认款)

RBF 的直觉:每个样本点周围放一座高斯小山,测试点离谁的山头近就归谁——它其实和 kNN(Part 8:看距离)是远房亲戚,只是"距离表"被无穷维拉伸过。

γ 旋钮:小 γ → 山很平缓 → 边界平滑;大 γ → 每座山又高又尖 → 边界碎(又一个 Part 6 全家福里的旋钮,拧大拧小都不行)。


6. 代码精讲

模块 src/part12_svm/svm.h/.cpp(SVM 类)+ main.cpp(三个实验)。

6.1 训练:简化版 SMO——每次只优化一对 α

对偶问题是带等式约束的二次规划(iαiy(i)=0\sum_i\alpha_iy^{(i)} = 0 把所有 α 拴在一起),梯度下降一步一个方向的“小碎步”收敛很慢;这里用简化版 SMO:主循环 = 找一对违反 KKT 的 (αi,αj)(\alpha_i, \alpha_j)解析地优化这一对,重复。

为什么是“一对”?单独动一个 α 必然违反等式约束;同时动两个,让 Δαiy(i)+Δαjy(j)=0\Delta\alpha_i y^{(i)} + \Delta\alpha_j y^{(j)} = 0,等式依然成立(分组拔河,两队同时增减相等的力,绳子不动)。

固定其余 α 后,目标函数沿约束方向只是一条开口向上的抛物线,顶点一步到位:

αjαj+y(j)(EiEj)η,η=Kii+Kjj2Kij=ϕ(x(i))ϕ(x(j))20\alpha_j \leftarrow \alpha_j + \frac{y^{(j)}(E_i - E_j)}{\eta},\qquad \eta = K_{ii} + K_{jj} - 2K_{ij} = \|\phi(x^{(i)}) - \phi(x^{(j)})\|^2 \ge 0

Ek=f(x(k))y(k)E_k = f(x^{(k)}) - y^{(k)} 是预测误差;η 是两点在高维空间的距离平方。)再把顶点裁剪回可行区间 [L,H][L, H]——由等式约束和盒子 [0,C][0, C] 联合决定,异号/同号两套公式。

数学 代码
Kij=K(x(i),x(j))K_{ij} = K(x^{(i)}, x^{(j)}) fit 开头一次性算 m×m 核矩阵缓存
违反 KKT:yf<1yf<1α<C\alpha<C,或 yf>1yf>1α>0\alpha>0 主循环轮转扫描找 i(3.3 节两种形态)
顶点公式 + 裁剪 [L,H][L, H] tryStep 里两行(先算顶点再 std::clamp
等式约束反解 αi=αiyiyjΔαj\alpha_i = \alpha_i - y_iy_j\Delta\alpha_j aINew 一行
f 的增量更新(O(m),不全量重算) fVal[p] += ...
b 由盒内 α 的 KKT 等式(yf=1yf=1)更新 tryStep 尾部(两个都贴边取平均)
首选搭档:误差差最大;撞墙就挨个试 jBest + for 循环遍历兜底
w=αiy(i)x(i)w^* = \sum \alpha_iy^{(i)}x^{(i)}(线性核) 诊断用:weights() 直接由 α 重构

相比投影梯度的“小碎步”,SMO 每步都是这条抛物线的精确最优——实验 2 里 RBF 核训练 100 个样本几千轮内收敛。

6.2 预测:只靠支持向量

f(x)=iαiy(i)K(x(i),x)+bf(x) = \sum_i \alpha_i y^{(i)} K(x^{(i)}, x) + b

  • b 不单独估计——SMO 每步顺手更新:优先用落在盒内(0<α<C0 < \alpha < C)的样本的 KKT 等式 yf=1yf = 1 反解,两个都贴边就取平均。训练结束 b 就是现成的;
  • 预测符号 sign(f(x))\text{sign}(f(x))decisionFunction 公开(画边界图用连续值,跟 Part 4 的 sigmoid 概率平行);
  • supportIndices()相对阈值(最大 α 的 1%)划线,忽略数值残余——实验里数一数支持向量个数,亲眼看"少数点撑起整条街"。

6.3 核的实现

三个核都是一行的活儿:线性(点积)、多项式(pow(xz+c,d)\text{pow}(x\cdot z + c, d))、RBF(exp(γxz2)\exp(-\gamma\|x-z\|^2))。注意 x、z 都是行向量,点积用手写循环(Matrix 没有逐元素乘)。换核不改训练循环一行——核矩阵 K 把"数据"和"算法"隔离开了,这正是 3.2 节伏笔的工程兑现。


7. 实验(main.cpp)

  • 实验 0:手算对账——3.4 节三点半例:训练后 w ≈ (0.5, 0.5)、b ≈ −1、间隔 ≈ 2√2、α = (0.25, 0.25, 0)、x³ 留白 = 3(支持向量结构);再加 5.3 节多项式核恒等式 K = φ·φ = 144。
  • 实验 1:C 旋钮与离群点——两团点 + 一个故意混入敌阵的离群点:C 大(硬间隔脾气)被离群点带歪整条街;C 适中稳如老狗。入全家福的眼见为实
  • 实验 2:核的力量(月牙会师)——月牙基准(Part 8/9/10/11 同款,噪声略小)三连:线性核 82%/86%(直线切不动月牙)、多项式核 (d=3) 95%/93%、RBF 核 (γ=6) 97%/90%,三张决策边界图 + 支持向量高亮。最有趣的读数:多项式核反而最高——月牙本来就是三角函数画的,多项式的世界观(边界是曲线)恰好对上了数据的出身;RBF 训练接近满分、测试却低几个点——无穷维不是免费午餐,γ 拧大了山头太尖、记住了噪声。没有银弹,选核 = 选世界观。全课程所有模型在月牙上会师的收官战。

8. 练习

  1. 把实验 0 的 x³ 从 (4,4) 挪到 (2.5, 2.5):它还坐得住非支持向量的位置吗?手动求新的 w、b、α(三行代数,别用程序)。
  2. 实验 2 把 RBF 的 γ 从 6 拧到 100:边界碎成什么样?支持向量变成多少个?——体验"γ 也是正则化旋钮"(往小拧到 0.5 再看一眼,两头都不行)。
  3. 证明:线性核 + 对偶解下,决策函数 f(x)=wx+bf(x) = w\cdot x + b(把 αiy(i)K(x(i),x)\sum\alpha_iy^{(i)}K(x^{(i)},x) 展开与 3.2 节的 ww^* 对照——"升维后的世界"和"原世界"算的是同一个 f)。
  4. 对照实验:同一份月牙数据,kNN(Part 8)vs SVM-RBF vs 随机森林(Part 11)的准确率和预测成本(kNN 要存全部 + 数邻居;森林 100 棵树;SVM 只存支持向量)。没有银弹——各家的" worldview"不同。

9. 小结

概念 一句话 代码落点
间隔 街道宽度 2/‖w‖,点到直线距离推出来 decisionFunction / 实验 0
归一约定 缩放自由度换来的 y(w·x+b) ≥ 1 文档 2.3
对偶 约束搬进目标,x 只以点积出现 fit 的核矩阵
支持向量 α>0 的少数点撑起边界,删其余答案不变 supportIndices
软间隔 松弛 ξ + 罚款 C:C 大管得严、C 小放得宽 m_c 盒约束 clip
hinge 损失 SVM 第二身份:max(0, 1−yf) + L2 罚款 4.3 节
hinge 损失 SVM 第二身份:max(0, 1−yf) + L2 罚款 4.3 节
核技巧 不显式升维,K 直接算出高维点积(144 恒等式) kernelValue
RBF 无穷维高斯山,与 kNN 是远房亲戚 kernelValue

SVM 收官了"给定数据学一个分类器"的主线。下一篇(Part 13,最终篇)换一个世界:没有数据、没有标签,只有动作和奖励—— agent 在试错中自己长出策略(Q-learning,贝尔曼方程从零推导)。前面所有模型的"老师"都消失了,唯一的老师是环境返回的一个数字。


Part 13 · Q-learning:没有老师的学习(最终篇)

学习目标:前面 12 章的所有模型都有一个"老师"——数据集带着标签(或带答案的样本)。本篇把老师请走:agent 在环境里试错,环境只回一个数字(奖励)。核心公式只有一个——贝尔曼方程(从零推导),核心算法只有一个——Q-learning(一行更新)。学完你会亲手训出一个自己找路的 agent,并用它的值函数热图看清"它是怎么想的"。

对应代码:src/part13_qlearning/(静态库 part13 + 可执行程序 ml_part13

前置:Part 3(“损失 = 预期与现实的差距”、梯度下降)、Part 6(γ 也是复杂度/视野旋钮)、Part 10(最优性来自递归——决策树的最优分裂也是"一步 + 子问题最优")


1. 新世界:没有标签,只有奖励

1.1 监督学习的最后一根拐杖

回看前 12 章:不管是回归(给数值)、分类(给标签)还是无参数模型(kNN 也要邻居的标签),每一条训练数据都自带正确答案。模型做的事千差万别,但学习信号永远是同一个格式:"你猜的"和"标准答案"差多少。

强化学习(Reinforcement Learning, RL)把这个拐杖也抽掉:agent(学习者)在环境(environment)里行动,环境不告诉它正确动作是什么,只对刚才的动作打一个分(奖励 reward)。打完分,世界变了(新状态 state),游戏继续。

监督学习(Part 1–12) 强化学习(本篇)
学习信号 标签(每个输入配正确答案) 奖励(每个动作一个数字,可能延迟)
谁出题 固定数据集 环境(状态随动作变化)
错误的代价 损失曲线难看一点 走错一步可能满盘皆输
数据分布 给定的 自己走出来的(你的策略决定你见到什么)

最后一行是 RL 最深的坑:你按现在的策略走,只会见到"现在的策略会走到的地方"——见不到的地方永远学不到。这叫探索(exploration)与利用(exploitation)的两难,4.3 节用 ε-greedy 给一个最朴素的解。

1.2 五个术语(本篇的全部词汇表)

  • 状态 s:环境此刻的样子(GridWorld 里就是"你在哪一格");
  • 动作 a:agent 能做的事(上下左右);
  • 奖励 r(s, a):环境对这步动作的即时打分(本篇:终点 +1、陷阱 −1、普通步 0);
  • 策略 π(a|s):状态到动作的映射——agent 的大脑,学习的最终产品
  • 回报 G:从现在起一直到结束的奖励累计(2 节定义——折扣在这里登场)。

2. 回报与折扣:把整个未来装进一个数

2.1 为什么不能直接加总

agent 走一步拿一个奖励,走到终点一共拿了 r0,r1,r2,r_0, r_1, r_2, \dots。"这局打得好不好"该用一个数衡量——最直接的想法是把它们全加起来。但两个麻烦:

  1. 游戏可能不结束(无限走),加总发散;
  2. "现在拿到的 1 分"和"100 步之后的 1 分"一样值钱吗?直觉上不等——未来还没到手。

2.2 折扣:未来的 1 分今天值多少

给每一步的奖励乘一个折扣率 γ[0,1)\gamma \in [0,1)

Gt=rt+γrt+1+γ2rt+2+=k=0γkrt+kG_t = r_t + \gamma r_{t+1} + \gamma^2 r_{t+2} + \cdots = \sum_{k=0}^{\infty} \gamma^k\, r_{t+k}

直觉:下一秒的 1 分值 γ 分,下下秒的值 γ² 分……遥远未来的奖励几乎不计。两个麻烦同时解决:级数收敛(即使无限走,Grmax/(1γ)G \le r_{\max}/(1-\gamma)),且"早拿分、早收工"自动成为偏好。

手算(γ = 0.9):奖励序列 (1,1,1)(1, 1, 1)(三步各拿 1)的回报

G=1+0.9×1+0.92×1=1+0.9+0.81=2.71G = 1 + 0.9\times1 + 0.9^2\times1 = 1 + 0.9 + 0.81 = 2.71

而序列 (0,1,1)(0, 1, 1)(先空走一步)的回报 G=0+0.9+0.81=1.71G = 0 + 0.9 + 0.81 = 1.71——同样的总分,晚一步到手便宜了整整 1 分。γ 越小越短视:γ=0 时只看眼前一步(贪心),γ→1 时越有远见。它就是 Part 6 全家福里又一个旋钮——这次拧的是时间视野

3. 值函数与贝尔曼方程(从零推导)

3.1 目标的形式化:找让回报最大的策略

评价一个策略好不好,标准是期望回报。定义状态价值:

Vπ(s)=Eπ[Gtst=s]V^\pi(s) = \mathbb{E}_\pi[G_t \mid s_t = s]

(“在状态 s 出发、从今往后按策略 π 走,平均能拿多少回报”。)最优策略 π\pi^* 就是每个状态都取到最大价值的那一个。直接算 V 要对整条轨迹求期望——轨迹指数爆炸,算不动。贝尔曼方程的伟大在于把这个大问题折叠成一步的递推

3.2 推导:拆掉第一项,剩下的是同一个问题

GtG_t 的第一项拆出来,剩下的部分 γrt+1+γ2rt+2+\gamma r_{t+1} + \gamma^2 r_{t+2} + \cdots 恰好是 γGt+1\gamma G_{t+1}

Gt=rt+γ(rt+1+γrt+2+)=rt+γGt+1G_t = r_t + \gamma\big(r_{t+1} + \gamma r_{t+2} + \cdots\big) = r_t + \gamma\, G_{t+1}

(一行代数,整个 RL 的地基。)两边对策略 π 取期望:左边是 Vπ(s)V^\pi(s);右边第一项 rtr_t 是确定的一步奖励,第二项的期望是"走完这一步落到的状态 ss' 的价值"——大问题的期望,等于一步奖励 + 子问题的期望

Vπ(s)=E[r+γVπ(s)]V^\pi(s) = \mathbb{E}\big[r + \gamma\, V^\pi(s')\big]

这就是贝尔曼期望方程。是不是眼熟?决策树(Part 10)的最优性也是"一步分裂 + 子节点递归最优"——凡是递归结构的最优解,都长这个样子

3.3 动作价值 Q 与贝尔曼最优方程

V 只看状态,不看动作。更实用的记号是动作价值函数

Qπ(s,a)=Eπ[Gtst=s, at=a]Q^\pi(s, a) = \mathbb{E}_\pi[G_t \mid s_t = s,\ a_t = a]

(“在 s 这格、走 a 这步,之后按 π 走,平均回报多少”。)最优的 Q 记作 QQ^*。对最优策略,走完 (s, a) 之后,下一步自然挑最优的(不再对 π 求期望,直接取 max):

 Q(s,a)=E[r+γmaxaQ(s,a)] \boxed{\ Q^*(s, a) = \mathbb{E}\big[r + \gamma \max_{a'} Q^*(s', a')\big]\ }

这就是贝尔曼最优方程——注意它是个方程(左右两边的 Q* 要相等),不是计算公式。Q-learning 做的事就是把它变成算法。

3.4 V 与 Q:一本账的两张读法

定义了 V(3.1)和 Q(3.3)之后,把两者的关系挑明——它们不是两个东西,是同一本账的两种读法

Vπ(s)=aπ(as)Qπ(s,a),V(s)=maxaQ(s,a),Q(s,a)=r+γV(s)V^\pi(s) = \sum_a \pi(a \mid s)\, Q^\pi(s,a), \qquad V^*(s) = \max_a Q^*(s,a), \qquad Q^*(s,a) = r + \gamma\, V^*(s')

第一句(期望版):策略 π 在 s 的价值 = 各动作按 π 的概率加权的 Q。第二句(最优版):最优价值 = 挑最好的动作。第三句是贝尔曼最优方程的 V 版写法:走一步拿 r,剩下的问题恰好是“新状态的 V”——一步递归的结构在这里最清晰。(本篇环境确定:走“下”必然到终点,期望号可以扔;随机环境第三句右边要套 E。)

为什么工程上存 Q 不存 V:光有 V(s)V^*(s) 选不出动作——还得知道“哪个动作通向哪个 s’”(环境的转移模型)。Q 表自带答案:π(s)=argmaxaQ(s,a)\pi^*(s) = \arg\max_a Q(s, a) 查表即得。Q-learning 免模型(model-free)的钥匙就在这:它从头到尾不需要转移概率,只需要“实际走一步看看”——4.1 节“用采样代替期望”能落地,正因为学的是 Q。

3.5 手算:方程右边到底在算什么(实验 0 对账)

设 γ = 0.9,格 (2,3) 的正下方是终点。走"下"这步:r = +1,s’ = 终点(没有下一步,max 项 = 0):

Q((2,3),)=1+0.9×0=1Q^*\big((2,3), \text{下}\big) = 1 + 0.9 \times 0 = 1

再看格 (2,2):走"右"到 (2,3)(假设已算出 maxaQ((2,3),a)=1\max_a Q^*((2,3),a) = 1,即"下"):

Q((2,2),)=0+0.9×1=0.9Q^*\big((2,2), \text{右}\big) = 0 + 0.9 \times 1 = 0.9

值沿着路径倒着流:终点的 1 分,隔着一步变 0.9,隔着两步变 0.81……离终点越远值越低——值函数热图(实验 2)上会看到一圈一圈亮起来的“梯度”,agent 只要永远往值更高的格子走,就自动走到终点。策略是免费的:π*(s) = argmax_a Q*(s, a),Q 表到手,策略查表即得。

顺手用 3.4 的两张读法互验:V((2,3))=max(1, 1, γV((2,2)), γV((2,3)))=1V^*((2,3)) = \max(-1,\ 1,\ \gamma V^*((2,2)),\ \gamma V^*((2,3))) = 1(撞墙原地不动那项是自我引用,0.9×1<10.9\times1 < 1,赢不了“下”)——与 Q((2,3),)=1Q^*((2,3),\text{下}) = 1 一致 ✓;反过来 Q((2,2),)=0+γV((2,3))=0.9Q^*((2,2),\text{右}) = 0 + \gamma V^*((2,3)) = 0.9 ✓。一本账两张读法,读出的是同一个数。

4. Q-learning:把方程变成算法

4.1 核心障碍:期望算不出来

贝尔曼最优方程里有期望 E[]\mathbb{E}[\cdot]——要对"下一步的所有可能"求期望,需要环境的转移概率模型(走"上"有 20% 概率滑倒之类)。Q-learning 的天才之处:不求期望,用采样代替——实际走一步,看见哪个 s’ 就用哪个 s’,期望用一次真实体验来近似(Part 1 就认识的老朋友:样本均值逼近期望)。

4.2 一行更新规则(本篇的全部算法)

用 Q 表(一张 (状态×动作) 的表格)当 QQ^* 的草稿。每真实走一步 (s, a, r, s’),把表格朝贝尔曼方程的右边挪一小步(学习率 α,Part 6 的老旋钮):

 Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)现实:这次体验说它值多少Q(s,a)预期:表格原以为它值多少] \boxed{\ Q(s, a) \leftarrow Q(s, a) + \alpha\Big[\underbrace{r + \gamma \max_{a'} Q(s', a')}_{\text{现实:这次体验说它值多少}} - \underbrace{Q(s, a)}_{\text{预期:表格原以为它值多少}}\Big]\ }

中括号里那项叫 TD 误差(temporal difference error,时序差分)——“现实与预期的差”。整个式子读作:预测错了多少,就朝修正方向改多少——和 Part 3 梯度下降  wwηL\ w \leftarrow w - \eta \nabla L 的精神一模一样,只是"梯度"换成了"贝尔曼残差"。没有任何新数学,又是旧零件。

手算(实验 0 逐位对账):α = 0.5、γ = 0.9、Q 表初始全 0。

第一步:在 (2,3) 走"下"到终点,r = +1,s’ = 终点(done,max 项 = 0):

Q((2,3),)0+0.5×(1+00)=0.5Q\big((2,3),\text{下}\big) \leftarrow 0 + 0.5 \times (1 + 0 - 0) = 0.5

同样的步再走一遍(TD 误差减半):

Q0.5+0.5×(10.5)=0.75Q \leftarrow 0.5 + 0.5\times(1 - 0.5) = 0.75

第三步换个起点:在 (2,2) 走"右"(r = 0),落到 (2,3),此时 maxaQ((2,3),a)=0.75\max_a Q((2,3), a) = 0.75("下"那格):

Q((2,2),)0+0.5×(0+0.9×0.750)=0.3375Q\big((2,2),\text{右}\big) \leftarrow 0 + 0.5\times(0 + 0.9\times0.75 - 0) = 0.3375

三个数:0.5 → 0.75 → 0.3375。看清楚两个机制:

  • 逐步逼近:同一个 (s,a) 反复体验,Q 一步步爬向真值 1(0.5、0.75、0.875、…、1)——样本均值的精神;
  • 折扣回传:(2,2) 的值来自 (2,3) 的值——信息沿着时间倒流,这正是"反向传播"在时间维度上的表亲(误差从终点往起点传)。

4.3 探索:ε-greedy(最朴素的解)

如果永远挑当前 Q 最大的动作(贪心),没试过的动作 Q 恒为 0,永远没机会证明自己——agent 会卡死在第一步的偏见里(1.1 节那个坑)。ε-greedy:以 ε 的概率随机乱走(探索),以 1−ε 的概率按 Q 表贪心(利用)。训练早期 ε 大(多试),后期衰减到小(多收)——又一个"先乱后收敛"的旋钮策略,与模拟退火、学习率衰减同一家族。

4.4 收敛:为什么这么随便的更新也敢说收敛到最优

严格的收敛定理条件很苛刻:每个 (s,a) 都被无限次访问、且 α 满足 Robbins–Monro 随机近似条件

tαt=(步子总量够走到任何地方),tαt2<(噪声累积不发散)\sum_t \alpha_t = \infty \quad \text{(步子总量够走到任何地方)}, \qquad \sum_t \alpha_t^2 < \infty \quad \text{(噪声累积不发散)}

Watkins(1989,Q-learning 的发明人)证明:满足这组条件 + 无限访问的 Q-learning 以概率 1 收敛到 QQ^*。实践里“ε 从 1.0 衰减到 0.05 + 固定小 α(0.1~0.5)+ 足够多的回合”就能稳定收敛(确定性环境里固定 α 也精确收敛——练习 3 的主题)。直观保证来自两件事:① TD 误差是无偏信号(用真实转移采样);② max 只取“已见过的”动作值,配合 ε-greedy 的兜底访问,冷门动作不会永远饿死。Q-learning 是 off-policy 的:更新的是贪心策略的 Q(target 里那个 max),走的却是 ε-greedy 策略——“用打野的经验,练正赛的大脑”。

4.5 max 的双刃剑:过估计

Q-learning 的 target 里有个 max——而 max 对噪声是单向放大的:

E[max(X1,X2)]  max(E[X1], E[X2])E\big[\max(X_1, X_2)\big] \ \ge\ \max\big(E[X_1],\ E[X_2]\big)

(max 是凸函数,琴生不等式的直接推论。)

手算:两个动作的真实值都是 0,估计各带独立的 ±0.1 均匀噪声——两个估计值的 max 平均约为 +0.05 > 0 = max(E, E)。噪声被 max 只往上推:运气好的动作被高估,而 target 永远引用“当前最高的那个”估计——高估被写回 Q 表、再被下一轮 max 引用,Q 值系统性偏高。这叫过估计偏差(maximization bias),γ 越大、噪声越大越严重。

朴素解法是双 Q 学习(Double Q-learning):维护两张独立 Q 表,一张选动作(“谁分最高”)、另一张给这个动作估值(“他值多少”)——评委和打分员分开,噪声不再自证。本篇环境确定、TD 信号无噪声(E[max]=maxEE[\max] = \max E),过估计不会现身;但走到 DQN(Q 表换神经网络,估计噪声巨大)时它是头号妖怪——届时这张底牌直接回收(练习 6)。

5. GridWorld:RL 的双月牙

5.1 规则(一张 4×4 的地图)

1
2
3
4
S . . .          S = 起点 (0,0)
. . . X X = 陷阱 (1,3),走进去 r = −1,游戏结束
. . . . G = 终点 (3,3),走进去 r = +1,游戏结束
. . . G 其余每步 r = 0;撞墙原地不动

动作:上/下/左/右。一局(episode)从 S 出发,到 G(成功)或 X(阵亡)或 200 步(超时)结束。这就是 GridWorld——小到 5 分钟能训完、直观到每格都能看、又完整包含"延迟奖励 + 探索两难 + 折扣视野"全部要素。

5.2 与前 12 章的呼应(收官的完整闭环)

Q-learning 的零件 出处
样本代替期望 Part 1(样本均值)
TD 误差驱动更新 = “预期 vs 现实” Part 3(损失的定义)
α 学习率、γ 视野旋钮 Part 6(全家福)
贝尔曼递归 = 一步 + 子问题最优 Part 10(决策树)
信息沿结构反向流动 Part 5/7(反向传播)
ε-greedy 的随机性 Part 11(森林的双重随机——打破偏见,同一招)

6. 代码精讲

模块 src/part13_qlearning/grid_world.h/.cpp(环境)+ q_learning.h/.cpp(agent)+ main.cpp(三个实验)。

6.1 环境:GridWorld

1
2
3
4
5
6
7
8
9
10
11
12
13
class GridWorld
{
public:
struct StepResult { std::size_t row, col; double reward; bool done; };

GridWorld(std::size_t rows, std::size_t cols,
std::size_t goalRow, std::size_t goalCol,
std::size_t trapRow, std::size_t trapCol);

StepResult step(std::size_t row, std::size_t col, int action); // 纯函数:不藏状态
std::size_t stateCount() const; // rows*cols
static constexpr int kActionCount = 4; // 0上 1下 2左 3右
};

环境是纯函数(状态, 动作) → (新状态, 奖励, 是否结束),不保存 agent 位置(agent 自己记)。这让"手动喂一步"(实验 0 对账)和训练循环用同一个接口——能对账的代码才是可信任的代码(全课程一以贯之的实验 0 传统)。

6.2 agent:QLearning

Q 表就是一张 Matrix(stateCount × 4)——Part 2 的矩阵从第 2 章干到第 13 章,最后一份工作还是它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class QLearning
{
public:
QLearning(std::size_t stateCount, double alpha = 0.5, double gamma = 0.9,
double epsilon = 1.0, std::uint64_t seed = 42);

// 单步更新——4.2 节那行公式的程序版(实验 0 逐位对账就调它)
double updateStep(std::size_t state, int action, double reward,
std::size_t nextState, bool done);

int chooseAction(std::size_t state); // ε-greedy
void setEpsilon(double e); // 探索率衰减
double runEpisode(const GridWorld& world, std::size_t startRow, std::size_t startCol,
std::size_t maxSteps); // 一局:返回累计回报

const Matrix& q() const; // Q 表(画热图用)
std::size_t greedyAction(std::size_t state) const; // π(s) = argmax_a Q(s,a)
};
数学 代码
Q(s,a)Q(s,a)+α[TD]Q(s,a) \leftarrow Q(s,a) + \alpha[\text{TD}] updateStep 三行:算 target、算误差、加回去
r+γmaxaQ(s,a)r + \gamma \max_{a'} Q(s', a')(done 时 max 项 = 0) target = done ? reward : reward + gamma * q(next).max()
ε-greedy chooseActionrng() < ε ? 随机 : argmax
π(s)=argmaxaQ(s,a)\pi^*(s) = \arg\max_a Q(s,a) greedyAction

7. 实验(main.cpp,输出 output/part13/

  1. 手算对账:4.2 节三步——Q((2,3),下) 0 → 0.5 → 0.75,再 Q((2,2),右) = 0.3375。逐位对账,确认更新规则没写错;
  2. 学习曲线:2000 局,ε 从 1.0 线性衰减到 0.05;每 50 局记录滑窗成功率和平均回报——看"先乱走、后收敛"的完整过程(learning_curve.png);
  3. 看它怎么想:训练后的值函数热图(每格 maxaQ\max_a Q,一圈圈亮起来的“势能场”)+ 每格的贪心箭头(策略图),叠加最优路径(value_policy.png)。终点最亮、陷阱最暗、值沿路径衰减——3.5 节的预测眼见为实。

8. 练习(强烈建议动手)

  1. γ 视野实验:γ = 0(纯贪心)agent 学得出来吗?γ = 0.99 呢?对照热图看"光圈"大小的变化——亲手拧 Part 6 全家福里最后这个旋钮;
  2. 陷阱移位:把陷阱挪到 (1,1)(必经之路旁边),重训——策略绕行的弯有多大?γ 拧小会不会开始冒险穿窄缝?
  3. α 的影响:α = 1.0(一步到位)vs α = 0.1(小碎步)——学习曲线的方差差多少?为什么 α = 1 在确定性环境里反而没问题(提示:本篇环境的转移是确定的,TD 信号无噪声);
  4. 手算:若 α = 0.1,4.2 节第一步的 Q 变成多少?(0+0.1×1=0.10 + 0.1\times1 = 0.1;同样三步之后远没到位——α 是"信这次体验几分");
  5. 思考:Q-learning 为什么是 off-policy?如果把更新规则里的 max 换成“实际走的下一个动作的 Q”(SARSA),agent 的性格会变保守还是激进?(提示:SARSA 连“探索的险”也要计入值。)
  6. 过估计现身:给环境加 10% 滑倒概率(每步有 1/10 机会随机转向),重跑实验 1,对比训练后的 Q 值与理论值——看 max 是否把它系统性抬高;再实现双 Q 学习(两张表轮流更新,target 用另一张表估值)对照修正效果。

9. 小结 + 全课程毕业

概念 一句话 代码落点
回报 G 折扣加总的未来奖励:γ 越大越有远见 runEpisode 累计
贝尔曼方程 Gt=rt+γGt+1G_t = r_t + \gamma G_{t+1}:大问题 = 一步 + 子问题 3.2 节推导
V ↔ Q V=maxaQV^* = \max_a Q^*Q=r+γVQ^* = r + \gamma V^*:一本账两张读法 3.4 节
Q* / 贝尔曼最优方程 Q(s,a)=r+γmaxQ(s,)Q^*(s,a) = r + \gamma\max Q^*(s',\cdot) updateStep 的 target
TD 误差 现实 − 预期:预测错了多少就改多少 updateStep 中括号
Q-learning 采样代替期望 + α 小步逼近贝尔曼解 updateStep
ε-greedy ε 概率乱走保证每个动作都被试过 chooseAction
过估计偏差 E[max]maxEE[\max] \ge \max E:max 只把噪声往上推,双 Q 可治 4.5 节
策略 免费的:π(s) = argmax_a Q(s,a) greedyAction

十三章毕业:一条线看全

主题 留下的零件
Part 1–2 地基:统计量 + 矩阵 stats / Matrix
Part 3 学习第一原理:损失 + 梯度下降 LinearRegression
Part 4 分类:sigmoid + 交叉熵 LogisticRegression
Part 5 深度:隐藏层 + 反向传播 NeuralNetwork
Part 6 管教:正则化 + 优化器 Ridge + Optimizer
Part 7 视觉:卷积 + 池化 ConvNet
Part 8 几何世界观:kNN Knn
Part 9 概率世界观:朴素贝叶斯 NaiveBayes
Part 10 规则世界观:决策树 DecisionTree
Part 11 集成世界观:随机森林 RandomForest
Part 12 优化世界观:SVM + 核 Svm
Part 13 没有老师:试错 + 贝尔曼 QLearning + GridWorld

从"均值是什么"到这里,你没有 import 过任何 ML 库:每个梯度都自己推过、手算对过、程序验过。监督学习的全套世界观的几何、概率、规则、集成、优化五条路都走了一遍,最后连老师都请走了——agent 在一张空表格上,靠一个数字一句反馈,自己长出了通往终点的路。

毕业不是终点:DQN(Q 表换神经网络——Part 5 + 本篇直接拼起来)、策略梯度(对策略本身求导)、Actor-Critic(两者合体)、Transformer(注意力做特征加工厂)。它们都是这 13 章零件的新组合——路已经修到门口了。


反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。