机器学习从零开始——C++ 手写实现全课程(完整版)
本文档合并自
docs/part01~part13共 13 章教学文档。
每章结构:直觉引入 → 从零推导 → 手算例子(与实验 0 逐位对账)→ 公式↔代码对照 → 实验 → 练习 → 小结。
配套代码见《机器学习课程-完整代码.md》(或src/目录)。
目录
- Part 1 · 机器学习入门:从数据开始
- Part 2 · 线性代数:矩阵与自研 Matrix 库
- Part 3 · 线性回归与梯度下降:第一次"学习"
- Part 4 · 逻辑回归与分类:直线弯成 S 形
- Part 5 · 神经网络与反向传播:把"线性+激活"堆成多层
- Part 6:正则化与优化器——管住模型的野心的两把工具
- Part 7:卷积神经网络——让网络学会"看"
- Part 8:k 近邻(kNN)——不学习,只查表的"懒"算法
- Part 9:朴素贝叶斯——用"先验 + 证据"推理
- Part 10:决策树——用"提问"切开数据
- Part 11:随机森林——民主投票的树军团
- Part 12:支持向量机——分开得最体面的那条线
- 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):描述样本的属性,如面积、房间数、地段。通常记作向量 。
- 标签(label / target):要预测的答案,如成交价 。
- 数据集(dataset):许多样本的集合,常记作 , 为样本数。
- 模型(model):一个函数 ,输入特征输出预测 ( 读作 “y-hat”,表示预测值)。
- 训练(training):调整模型内部参数,使预测 尽量接近真实 的过程。
- 损失函数(loss function):量化"预测得有多差"的函数,训练就是让它变小。
1.4 机器学习的通用工作流程
后面每个阶段我们都在实践这条流程,今天先做第 1 步:
1 | ① 理解数据(统计描述、可视化) |
第 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 |
|
用 #ifdef _WIN32 包住是为了保证同一份代码在 Linux/macOS 上也能编译(那些平台本来就是 UTF-8)。
2.2 目录结构(贯穿整个学习过程的约定,对齐既有 C++ 工程风格)
每个学习阶段是一个模块:对外提供静态库 + 一个实验用可执行程序。模块内部遵循 include/ + src/ 分层:
1 | MachineLearning/ |
为什么 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)
| 约定 | 内容 |
|---|---|
| 命名 | 类/结构体 PascalCase(Statistics);函数/方法 camelCase(computeStatistics);成员变量 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)—— 数据的"重心"
读法:所有数据加起来除以个数。直觉:把每个数据点看成一个等重的小球放在数轴上,均值就是它们的平衡点。
C++ 实现要点:均值是很简单的累加,但有两个工程细节:
- 空数据防御: 时除以零会得到
NaN(Not a Number),必须显式处理; - 数值稳定性:本阶段先用朴素累加。之后实现方差时你会看到,"先求和再相减"和"边算边更新"两种写法在浮点数下精度并不相同——这是数值计算的经典陷阱,Part 3 讲梯度时会更深入。
3.2 方差(Variance)与标准差(Standard Deviation)—— 数据的"分散程度"
为什么平方? 衡量"每个点离重心有多远",直接求偏差 的和会正负抵消(恒为 0)。平方让所有偏差变成正数,且离群点被放大惩罚——这本身就是一种"误差度量"的思想。
为什么开方得到标准差? 方差的单位是"℃²",开方后回到与原数据相同的单位"℃",才可与均值直接比较、才有物理直觉。
总体方差 vs 样本方差:除以 是"总体方差"(把数据当成全部);统计学的"样本方差"除以 (无偏估计,因为样本均值本身也是从数据估出来的,损失了一个自由度)。机器学习里两种都常见,本阶段实现总体方差并在文档中说明区别——知道自己在用哪个,比用哪个更重要。
机器学习视角:方差思想贯穿全程。第 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 部分训练模型前,我们会用 把特征缩放到同一量纲(面积 80㎡ 和房间数 3 直接混在一起算,量纲差异会让训练失衡)。今天写的 mean/stddev 到那时会直接复用——每一部分都在为后面打地基。
4. 代码精讲
4.1 include/part01/stats.h —— 接口设计
1 |
|
注:文档中为阅读方便用空格示意缩进,实际代码采用 Tab 缩进 + Allman 括号(开括号另起一行),函数名用 camelCase(
computeStatistics)——完整风格见 2.3 节。
逐行理解几个 C++ 关键点:
-
#pragma once:C++ 没有 Python 的模块系统,#include本质是“文本粘贴”。如果两个文件都 include 了stats.h,类和函数会被定义两次导致编译错误。#pragma once告诉编译器“本文件只粘贴一次”(所有主流编译器都支持,比传统的#ifndef卫兵简洁)。 -
namespace ml::part01:C++17 起支持嵌套命名空间简写,等价于namespace ml { namespace part01 { ... } }。作用:① 防止你的mean与别人(或标准库将来)的同名函数冲突;② 让读代码的人一眼看出归属——Part 2 我们会有ml::part02::Matrix。 -
struct Statistics的"聚合返回":与其让调用方连续调用 5 个函数(数据被遍历 5 遍),不如一次遍历算出全部结果打包返回。struct默认公有成员,适合这种纯数据载体。 -
两个签名,两种语义(重点!):
1
2double mean(const std::vector<double>& data); // 常量引用:零拷贝 + 只读
double median(std::vector<double> data); // 值传递:拷贝一份供我修改引用(
&)是"别名",不复制数据;const是对调用者的承诺"我不改你的东西"。选择哪种传递方式,是设计 C++ 接口时每一次都要有意识做的决定。
4.2 src/stats.cpp —— 实现细节
1 |
|
值得停下咀嚼的四个点:
-
异常而不是返回 NaN:
throw std::invalid_argument是"失败要响亮"的设计哲学。静默返回 NaN 会让错误在下游传播几百行后才爆发,那时已无从定位。(当然,异常策略在库设计中是有争议的,等你学了更多我们再讨论替代方案。) -
std::accumulate的初值陷阱:这是本阶段最重要的 C++ 细节。模板推导让accumulate(..., 0)变成整数累加,{1, 2, 3}的浮点场景会在你不知不觉中丢精度。永远显式写0.0。 -
diff * diffvsstd::pow(diff, 2):pow是通用幂函数(按对数/指数实现),编译器通常能优化,但手写乘法保证只有一次浮点乘法,语义上也更直白。数值代码里"用最简单的操作表达意图"是好习惯。 -
错误信息带函数名:
"mean: data must not be empty"让异常在日志里自带定位信息,省下未来的调试时间。
4.3 main.cpp —— 实验:气温数据
main.cpp 做了四件事,对应机器学习工作流的第一步“理解数据”:
- 用一份 7 天气温数据算完整统计;
- 做一个对比实验:往数据里塞一个极端值(热浪 41℃),观察均值被拉动 1℃+ 而中位数几乎不动——亲手验证 3.3 节的结论;
- 空数据防御:异常被捕获并优雅处理(
try/catch); - 把数据画出来(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 | === 实验 1:7 天气温(正常数据) === |
读数的直觉:标准差从 2.86 跳到 6.02,翻了一倍——方差/标准差对离群点的敏感是平方级的(这正是 3.2 节“平方放大惩罚”的直接体现)。
4.4 构建与运行
1 | # 加载 MSVC 环境后(见根目录说明),在项目根目录执行: |
5. 练习(强烈建议动手)
- 改数据:把
main.cpp的气温换成你所在城市最近的真实数据,重新运行,观察统计量变化。 - 实现
mode(众数):出现次数最多的值。提示:排序后相邻相同元素计数(暂不引入哈希表)。 - 实现样本方差:新增
sampleVariance,除以 ,用小数据对比两种方差的差异,思考:样本数多大时差异可以忽略? - 四分位数(选做):实现
iqr = Q3 - Q1(四分位距),它像中位数一样抗离群点。 - 思考题:为什么
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 | 样本 1:面积 80㎡ | 房间 3 | 楼层 5 | 房龄 12 年 → 价格 350 万 |
把每一行样本对齐摞起来,就是一个表格——数学上叫矩阵。机器学习里几乎一切数据都以这种形态出现:
- 数据集 X: 矩阵( 个样本 × 个特征)
- 标签 y: 列向量
- 模型参数 w: 列向量
1.2 模型预测 = 一次矩阵乘法
Part 3 将实现的线性回归,它的预测公式是:
是 , 是 ,乘出来 是 ——一次矩阵乘法,同时算出全部 个样本的预测值。这就是为什么所有深度学习框架(PyTorch、TensorFlow)的核心都是矩阵运算库:没有矩阵,就没有批量计算;没有批量计算,就没有现代机器学习。
1.3 本章产出
一个我们自己写的 Matrix 类。它将贯穿 Part 3~6 的所有算法:线性回归的梯度 、神经网络的层间传播 ,全部建立在本章的代码之上。
2. 数学基础:从向量到矩阵乘法
2.1 概念阶梯:标量 → 向量 → 矩阵 → 张量
| 对象 | 数学记号 | 形状 | 例子 | 直觉 |
|---|---|---|---|---|
| 标量 scalar | 无 | 3.14 | 一个数 | |
| 向量 vector | 一列数(一个样本/一组标签) | |||
| 矩阵 matrix | 见 1.1 的房价表 | 一张表(整个数据集) | ||
| 张量 tensor | 任意维 | RGB 图片 | 多维数组(CNN 的输入) |
关键视角:向量是矩阵的特例(只有一列)。我们的 Matrix 类因此只实现矩阵,"列向量"就是 n×1 的矩阵——一个抽象服务所有场景(NumPy 也是这个思路)。
2.2 矩阵的形状(Shape)—— 贯穿始终的第一公民
矩阵 有 行 列,记形状为 。机器学习里形状对了,公式才成立:
- 加法要求同形状:
- 乘法要求内维相等:
工程习惯:以后看到任何矩阵公式,第一反应就是检查形状。形状对不上的运算在数学上无定义,在我们的代码里则必须抛异常——这是本章代码防御的重点。
2.3 矩阵加法与数乘—— 逐元素的"低级"运算
加法就是对应位置相加(同形状才有意义);数乘就是每个元素乘同一个数。没什么玄机,但要记住:它们都是逐元素(element-wise)运算——后面 Part 4 的 sigmoid 激活函数也是逐元素作用于矩阵的每个元素。
2.4 转置 —— 行列互换
第 行变第 列。 转置后是 。机器学习出镜率最高的操作之一:梯度公式里的 (把"样本×特征"的矩阵翻转,用于把误差"反向投射"回每个特征的参数上,Part 3 详解)。
2.5 矩阵乘法 —— 全章最重要、最值得慢慢读的公式
读法:结果矩阵的第 行第 列 = 的第 行与 的第 列的对应相乘再求和(点积)。
形状规则: 是 , 是 ,结果是 。 的列数必须等于 的行数——“中间的两个 相消”。
手算一个例子(本章实验会验证它):
2.6 为什么乘法要这样定义?—— 矩阵 = 线性变换
死记公式没有意义。矩阵乘法的定义不是"人为规定",而是"线性变换复合"的自然结果:
把一个 矩阵 看成一台机器:吃进去一个 维向量,吐出一个 维向量,规则是"每行做一次加权求和"。那么:
- 把 维空间线性变换到 维空间(旋转、拉伸、剪切、投影……或它们的组合);
- 再把 维空间变换到 维;
- "先做 再做 "这个复合动作,等价于直接乘一个矩阵 。
几何直觉(本章实验 4 会画出来):
单位正方形经过 、、以及复合 变换后的形状,能让你亲眼看到矩阵在"操作空间"——这正是神经网络每一层在做的事:affine 变换(矩阵乘)+ 非线性激活。
2.7 单位矩阵 —— 矩阵世界的"1"
对角线为 1、其余为 0 的方阵,乘任何矩阵都不改变它——如同数乘 1。Part 5 神经网络权重初始化、Part 3 的正规方程 都会见到它。
3. C++ 实现要点:设计决策逐个讲
3.1 存储布局:一维扁平数组 + 行优先,而不是嵌套 vector
1 | std::vector<std::vector<double>> m_data; // ❌ 嵌套方案 |
为什么扁平存储更好?
- 内存连续:嵌套 vector 的每一行是独立堆分配,散落在内存各处;扁平 vector 是一整块连续内存——CPU 缓存行加载一次就能喂饱后面的多次访问(缓存局部性,对矩阵乘法这种 的运算影响数倍性能)。
- 一次分配:构造 1000×1000 矩阵,扁平方案只 new 一次;嵌套方案 new 1001 次。
- 这正是 NumPy、Eigen、PyTorch 的做法。
元素 在扁平数组中的下标(行优先 row-major,C/C++ 传统):
3.2 两种元素访问:operator() 快路径 vs at() 检查路径
1 | double& operator()(std::size_t r, std::size_t c); // 无检查——热路径(内层循环) |
为什么不用 operator[]?因为 C++ 的 [] 只能接收一个参数,二维下标得玩代理对象的花招(m[i][j]);而 operator() 是函数调用运算符,天然接受多个参数:m(i, j)。这是 C++ 数值库(Eigen、arma)的通行做法。
为什么不全部检查? 边界检查每次访问都要比较两次,矩阵乘法内层循环会执行 次。策略是:公开接口(运算符、构造)统一做形状检查(低频、必查),元素访问提供快慢双通道(高频、自担责任)。这是性能与安全的经典折中。
3.3 运算符重载:自由函数而非成员函数
1 | Matrix operator+(const Matrix& a, const Matrix& b); // 自由函数 |
若做成成员函数 a.operator*(b),则 2.0 * a 无法编译(左操作数必须是 Matrix)。自由函数让 a * 2.0 和 2.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 | // i-k-j:B 的第 k 行在内存中连续,j 递增时顺序扫描 B 与 C |
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 |
|
设计取舍速记:
Matrix() = default+ 成员默认值:默认构造得到(0,0)空矩阵,任何操作都会被形状检查拦下,不存在"未初始化"状态。identity/columnVector静态工厂:具名构造比记住参数含义更可读(Matrix(3,1)不如columnVector({1,2,3})直白)。- 转置是 const 成员:不修改自身,返回新矩阵——函数式语义,与数学记号 一致。
4.2 src/matrix.cpp —— 实现细节
核心三处(完整实现见源码,逐行注释):
- 构造:
m_data.assign(rows * cols, init)一次分配;乘法溢出前检查rows * cols上限(防御性:极端形状会溢出 size_t)。 - 矩阵乘法:
1 | Matrix operator*(const Matrix& a, const Matrix& b) |
aik 提到内层循环外(寄存器复用)、i-k-j 顺序(见 3.5)、跳零(顺手的小优化)。
- 转置:新矩阵
B(j,i) = A(i,j),双重循环一次拷贝。
4.3 src/main.cpp —— 四个实验
- 矩阵的构造与打印:2×3 矩阵、列向量,格式化对齐输出;
- 手算对照:验证 2.5 节的手算例子
A(2×3) × B(3×2) = [[58,64],[139,154]],再验证结合律友好的 ; - 形状防御:
(2×3) × (2×3)抛异常并打印异常信息; - 线性变换可视化(matplotlib-cpp):把单位正方形的四个角点排成矩阵,分别左乘剪切矩阵 、旋转矩阵 、复合 ,画出四个四边形——亲眼看矩阵如何"操作"空间。
4.4 构建与运行
1 | # 项目根目录(MSVC 环境已加载): |
5. 练习(强烈建议动手)
- trace(矩阵的迹):对角线元素之和
trace(A)。要求方阵,思考:非方阵该抛什么异常? - element-wise 乘法(Hadamard 积):
hadamard(a, b),同形状逐元素相乘。和矩阵乘法对比形状规则差异。 - rowSlice / colSlice:取出第 i 行(1×n)与第 j 列(n×1)。思考:列切片在行优先存储上为什么比行切片慢?(缓存!)
- 验证 :写一个小函数比较两个矩阵"近似相等"(逐元素差 < 1e-12),这是后续所有算法验证的基础工具,值得放进公共代码。
- 思考题:
operator*的if (aik == 0.0) continue;在什么数据分布下收益最大?在什么情况下反而是白判断?(提示:图像像素/one-hot 特征 vs 全随机矩阵。)
6. 本部分小结与下一站
✅ 你已经掌握:
- 矩阵视角看 ML:数据集 = ,标签 = ,预测 =
- 加法/数乘/转置/矩阵乘法的定义、形状规则与几何直觉(矩阵 = 线性变换)
- 行优先扁平存储与缓存局部性、
operator()双通道访问、自由函数运算符、按值返回与移动语义、i-k-j 循环 - 静态工厂(
identity/columnVector)、形状防御性异常
🚀 Part 3 预告 —— 线性回归与梯度下降:有了 、、矩阵乘法,就可以完整写出 。下一章我们定义损失函数 MSE ,推导它的梯度 ,然后用梯度下降让损失一步步变小——那是你第一次亲手让机器"学习"。Part 1 的 mean/stddev 也将归队,负责训练前的数据标准化。
反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。
Part 3 · 线性回归与梯度下降:第一次"学习"
学习目标:完整走通机器学习工作流的闭环——模型(假设)→ 损失函数 → 梯度 → 优化。本章的写法是"公式必配代码、手算必能上机验证":一个贯穿全文的两样本迷你例子,先在纸面上手算,再用程序复现,最后放大到 80 个样本的房价数据。
对应代码:
src/part03_linear_regression/(静态库part03+ 可执行程序ml_part03)前置:Part 1 的
mean/stddev(数据标准化)、Part 2 的Matrix(、、 与矩阵乘法)
1. 问题:从数据中学一条直线
1.1 场景
已知一组房子数据:面积 (㎡)与成交价 (万)。问题:给定新面积 100㎡,预测价格?
答案:让机器从数据里学出面积与价格的关系。最简单的关系假设是直线:
(斜率)与 (截距)就是模型参数——机器学习的全部目标,就是找到让预测最准的那组 。注意 带帽子——它是预测值,与实测值 是两个东西,后文这个区别至关重要。
1.2 矩阵形式:一次乘法预测所有样本
把 个样本的特征排成 、标签排成 (Part 2 的视角):
把它按行展开看清楚:
读法的关键: 在每个式子里都是同一对未知数。 不存在"每组 各求一组 "——恰恰相反,我们要找唯一一组 同时服务所有样本。
“一次矩阵乘法"说的是预测阶段的效率:给定学好的 , 内部就是"对每一行做一次 ”,矩阵乘法替你把循环写好了(Part 2 学的 ,乘法定义里本来就藏着一个循环)。不是说学习只需一次乘法。
注意 的第一列全是 1——这样 (截距)就像普通参数一样参与矩阵乘法,不需要单独处理。这一列叫偏置列(bias column),神经网络里的 bias 是同一个思想。形状检查: 是 , 是 , 是 ——与每个 一一对应。
1.3 关键跃迁:从"解方程"到"学参数"
直觉上你可能会想:把 代进 ,解出 不就行了?在恰好两个样本、无噪声时,这真的成立:
1 | 样本1:100㎡→370万 ⇒ w0 + 100·w1 = 370 |
但再添一个带噪声的样本(80㎡→305 万),代入 ——矛盾。真实数据是 80 个方程、2 个未知数(超定方程组),噪声让它们互相打架,不存在任何一组 能同时满足所有方程。
没法"解",只能"妥协":找让总误差最小的那组 。这个妥协的标准就是损失函数(第 3 节),这个搜索的过程就是梯度下降(第 4 节)。
机器学习的本质:数据多到解不出精确解,于是退而求最优解。
2. 先补三个数学工具(每个五分钟,本章后无拦路虎)
2.1 工具一:求和符号 Σ——被数学家发明的 for 循环
读作:“对 从 1 到 5,把 全部加起来”。逐零件对应代码:
| Σ 零件 | 含义 | 对应代码 |
|---|---|---|
| (S = Sum) | 求和动作 | sum += |
| 下标 | 循环变量与起点 | i = 1 |
| 上标 | 终点( = 样本数,像函数参数不写死) | i <= m |
| 求和体 | 每轮加什么 | a[i] |
两个要点:
- 哑变量: 只在 Σ 内部有意义(像 for 循环的
i出了循环体没人认识),,换名不改结果; - 常数外提:——所以公式里的 能一直安然待在 Σ 外面。
其实你早就在 stats.cpp 写过它:均值 、方差 ——全是"Σ 循环 + 乘个平均系数"这一个模板。
2.2 工具二:导数——把旋钮拧一点点,看函数变多少
导数 = 某点的变化率。把它变成一个可执行的动作:
把变量 往右轻轻挪一点点,看函数值 变了多少,两者相除。
例: 站在 :往右挪 0.01,函数从 9 变到 9.0601,变化率 (精确值 ,)。导数就是此刻的坡度:正 = 上坡,负 = 下坡,绝对值 = 陡峭程度。单变量函数只有一个旋钮,所以无需说明"关于谁"。
2.3 工具三:偏导——拧一个旋钮时,把其他旋钮焊死
L 有两个旋钮 ,都能拧、都影响 L。那"L 变化多快"没有唯一答案——必须先回答:拧哪个? 想象站在碗状曲面上(横轴 、纵轴 、高度是 L):同一点可以朝 方向看一眼坡度,也可以朝 方向看一眼——两个不同的数字,各就是一个偏导数。
定义极简:把其他变量当成常数(焊死),只对这一个变量求普通导数。记号从 换成 只是提醒"多变量函数",求法没有任何新东西。
2.4 合体:梯度——所有偏导拼成的导航箭头
几何性质:梯度指向 L 上升最陡的方向(各方向坡度的加权合成)。于是:
- 梯度方向 = 最陡上坡 → 负梯度 = 最陡下坡 → 更新公式里的减号;
- 梯度大小 = 陡峭程度 → 远处陡大步、近底缓小步 → 步长的"自动刹车"(4.1 节);
- 代码对应:
grad矩阵第 0 行是 ,第 1 行是 ——一次矩阵乘法把两个偏导同时算完。
一句话总结:导数是单旋钮的坡度;偏导是多旋钮时"焊死其余、只拧一个"测出的坡度;梯度是所有偏导拼成的向量,指向最陡上坡——所以下坡就朝它的反方向走,这就是梯度下降的全部数学。
3. 损失函数 L(w):一台打分机
3.1 定义与逐零件阅读
参数好不好,需要一个可计算的分数。标准答案是均方误差(Mean Squared Error):
用 2.1 节的"Σ 翻译器"读它:对 80 个样本循环,每轮加"误差的平方",最后乘 。对照 lossAt() 的实现——
1 | double sum = 0.0; |
公式与代码逐行对上:循环是 Σ,循环体是求和体,返回时的除法是外提的 。
3.2 关键认知:w 是输入,x/y 是常数
把定义式展开看清楚谁是变量:
数据 采集完就固定不动(烧进函数里的常数), 才是输入。、、 是三个完全不同的分数。
L 是一台打分机:递给它一组候选 ,它告诉你这组答案有多烂。 手里没有 ,L 根本无从计算——所以不存在"从 x,y 算出 L,再从 L 反推 w"这条路。 不是推出来的,是搜出来的(第 4 节),L 在搜索过程中的角色是导航系统。
3.3 为什么是"平方"?为什么有个 ?
- 平方:误差有正有负,直接求和会互相抵消;绝对值也可以但不光滑(0 点不可导,无法用导数优化);平方处处光滑可导,且大误差被平方级放大——模型被逼着优先修正离谱的预测。还记得 Part 1 的方差吗? 与 MSE 结构完全相同:方差度量数据围绕均值的离散,MSE 度量预测围绕真值的偏差——同一个数学思想(2.1 节说过的同一个模板)。
- :纯粹为了求导后消掉平方带来的系数 2,让梯度公式干净。不影响最小值位置(把整个损失乘常数,谷底不动)。
3.4 损失曲面:w 空间上的一片"山谷"
只有两个参数时, 是一个开口向上的碗状曲面(抛物面)。训练就是从某个出发点走到碗底。问题是:站在山坡上,怎么知道谷底在哪?
4. 梯度下降:把 w 搜出来
4.1 三个问题:起点、方向、步长
Q1:从哪出发?——。① 零初始化无偏私,不押注任何方向;② 损失是凸函数、碗只有一个底,起点不影响终点,选最简单的(Part 5 神经网络会失去这份礼物——多谷底世界里初始化变成大学问);③ 标准化后 是均值点, 邻域本就是"猜均值"的合理起点。
Q2:朝哪走?——负梯度。2.4 节的结论:梯度指最陡上坡,所以下坡朝它的反方向。
Q3:走多远?——步长 梯度大小,不是固定值,自带刹车:
1 | 远离碗底:坡陡 → 梯度大 → 大步流星 |
而且 各有各的步长——梯度是向量,每个分量独立更新:哪个参数对应的特征与误差"相关"强,它就被修正得多。
三问的答案合起来就是那个著名公式:
(eta)叫学习率:步长的总标定系数,是人调的超参数。
4.2 完整手算一遍(贯穿全文的迷你例子,拿计算器跟按)
取两个无噪声样本:、——真实关系 ,即碗底在 。站在起点 ,:
第 1 步:算预测与误差(每个样本一行):
| 样本 | 预测 | 误差 |
|---|---|---|
| (1, 3) | ||
| (2, 5) |
第 2 步:算两个偏导(用 2.3 节"焊死另一个旋钮"):
两个 Σ 各是 2 轮循环:对 每轮加"误差",对 每轮加"误差×特征"。(差一个 :严格版梯度带 ,这里先看方向和比例,下节推导补上。)
第 3 步:更新(两个旋钮各拧一点):
第 4 步:验证真的下坡了:新预测 、,误差 、,损失从 降到约 ✓。
重复循环, 一路滑向 。这段手算就是 main.cpp 的"实验 0"——程序算出的第一轮和最终结果与你手算的完全一致。
4.3 梯度公式的推导(链式法则,一步到位)
对 求偏导(外层平方 → 内层线性,链式法则把两层导数相乘):
系数 ( 就是为了这里消掉 2);而 对 求偏导,其余项全焊死,只剩 :
一句话读它:参数 的梯度 = “误差加权平均的特征值”——哪个特征在预测偏差大的样本上取值大,它的参数就被狠狠修正。4.2 手算的两个 Σ 正是它的 (特征=偏置列恒为 1)和 (特征=)两个特例。
把 个偏导拼成向量,恰好是一个矩阵乘法(形状自检: 是 ,误差 是 ,乘积 与 同形 ✓):
这就是全文反复出现的 ——转置 的几何意义:把每个样本上的误差"反向投射"回每个参数的修正量。Part 5 反向传播的"误差回传"思想在此初现。
4.4 学习率 :全程最重要的超参数
- 太小(如 ):每步挪动极小,几千轮也到不了谷底(练习 1 会看到爬行般的损失曲线);
- 太大(如 5.0):一步跨过谷底跳到对面更高的坡上,损失越走越大甚至爆炸到 inf/NaN;
- 合适:损失单调下降,逐渐趋平。
4.5 什么时候停?
三种常用停止条件(我们的实现组合使用后两者):
- 固定轮数:跑满
maxIterations就停(保底); - 损失几乎不再下降:,认为已到谷底附近;
- 梯度范数接近 0:理论上最严格,本阶段先用 2。
本章我们花大力气用迭代法“爬”到谷底。但你可能已经想到一个问题——下面整整一章来回答它。
5. 正规方程:一步到位的闭式解
5.1 疑问:既然知道是碗,为什么不直接走到碗底?
微积分的基本常识:光滑函数在极小值点处,导数为零(碗底的坡度是水平的,否则还能往下滚)。我们的损失曲面是个碗(凸函数)——那把梯度直接设成 0,解方程不就行了?
5.2 推导:令梯度为零,四步解出 w
起点是 4.3 节推好的梯度:
第一步:等式两边同乘 (0 乘什么都是 0,常数不影响解):
第二步:展开括号:
第三步:移项( 是已知量——训练数据算出来的常数向量):
第四步:两边左乘 (如果可逆),解出:
这就是正规方程(Normal Equation)——不需要学习率、不需要迭代、没有收敛问题,一次矩阵运算直接给出最优解。sklearn 的 LinearRegression 内部就是它。
形状自检(老习惯): 是 , 是 ,逆乘过去 ——与 同形 ✓。
5.3 手算例子:与 4.2 节殊途同归
用 4.2 节的迷你数据 (偏置列 + 单特征):
算 :
算 :
求逆(2×2 公式:交换对角、负化反对角、除以行列式;行列式 ):
乘出结果:
——与梯度下降迭代 179 轮殊途同归! 一行矩阵运算就到了同一个碗底。这正是“梯度为零的点”的含义:迭代法一步步逼近它,解析法一步算出它。
5.4 那为什么整个课程还要学梯度下降?
三条硬理由:
理由一:算不动。 要求逆的矩阵大小是 ( = 特征数),求逆代价 。 时瞬间; 时约 次浮点运算;(图像、文本的常规操作)时宇宙等不起。而梯度下降每轮只要 。
理由二:可能不可逆。 若两个特征线性相关(如“面积(㎡)”和“面积(平方英尺)”同时入模), 奇异无逆——正规方程直接报错,梯度下降只是走得慢一点。岭回归(Part 6)的 L2 罚款恰好能“拱”出可逆性( 恒可逆),这是两种思想的美妙相遇。
理由三:无法推广(最致命)。 线性回归的碗是二次的,“梯度=0”才能解出来。逻辑回归(Part 4)、神经网络(Part 5)、CNN(Part 7)的损失曲面不是碗,令梯度为零得到的方程解不出来——梯度下降是它们唯一的希望。线性回归只是梯度下降最温柔的练手场。
5.5 两条路怎么选(工程对照表)
| 梯度下降 | 正规方程 | |
|---|---|---|
| 每次成本 | /轮 × 几百轮 | 一次 |
| 特征多(n > 10⁴) | ✓ 照常跑 | ✗ 算不动 |
| 特征共线 | 慢但能走 | ✗ 不可逆 |
| 超参数 | η 要调(踩坑重灾区) | 无 |
| 可推广到非线性模型 | ✓(唯一选择) | ✗(仅线性回归) |
| 结果 | 近似解(谷底附近) | 精确解 |
口诀:特征少(几百以内)、纯线性 → 正规方程(或 sklearn 默认);其他一切 → 梯度下降。但学习阶段必须两者都手写一遍——实验 5 会让它们在同一份数据上对账。
6. 数据标准化:训练前的必备仪式
6.1 为什么必须做
面积是几十到几百(㎡),房间数是 1~5——量纲相差两个数量级。后果:损失曲面被拉成狭长椭圆: 方向坡很陡、 方向坡很缓,梯度下降在狭长谷里反复震荡(z 字形),收敛极慢;且学习率难以统一——对陡方向合适的步长,对缓方向根本走不动。
6.2 怎么做(Part 1 函数正式归队)
把每个特征列变成均值 0、标准差 1:。标准化后损失曲面接近圆形,梯度直指谷底。
注意:标准化用的 必须在训练集上统计并保存——预测新样本时要用同一组统计量变换,否则训练与预测的特征空间不一致。预测结果(价格)我们不标准化,让模型直接输出真实量纲。
7. 代码精讲
7.1 include/part03/linear_regression.h —— 接口契约
1 | class LinearRegression |
设计要点:
- 训练器是"有状态"的:与 Part 2 纯函数式的 Matrix 不同,回归器把学到的 与训练历史存在成员里——
fit改变对象,predict只读。这是"模型"类的基本形态,Part 5 的神经网络沿用。 - 超参数集中在构造函数且给默认值——先跑通默认,再按 4.4 节的知识调。
lossHistory暴露出来:可视化是调试训练的第一手段,损失曲线应该像日志一样随手可取。
7.2 fit 的核心循环:公式↔代码逐行对照
1 | part02::Matrix w{ x.cols(), 1 }; // 起点 (0,0):全零矩阵 |
四行核心循环与公式的逐行对应表(本章的压舱石):
| 数学公式 | 代码行 | 作用 |
|---|---|---|
| (1.2 节) | x * w |
一次乘法算出所有样本的预测 |
- y |
每个样本的误差 | |
| (4.3 节) | x.transpose() * error |
两个偏导拼成的导航箭头 |
| (4.1 节) | w = w - m_learningRate * grad |
每个旋钮各拧一点,步长自带刹车 |
| (3.1 节) | lossAt(x, y, w) |
打分:这轮的 w 有多烂 |
读懂公式就能读懂代码,反之亦然——这是本课程的追求。
7.3 开发实录:一个"假收敛" bug(值得记住的陷阱)
第一版 fit 循环里直接调 computeLoss(x, y) 记录损失——但成员函数读的是 m_weights,它要到循环结束才被赋值,于是每轮算的都是 的损失,恒定不变,收敛判断第一轮就触发,模型只"学习"了一步就停了(症状:迭代 1 轮收敛、参数只挪了一步)。修复:抽静态辅助 lossAt(x, y, w),fit 用局部 w 算损失。
教训:有状态对象的方法读成员时,先问一句"此刻成员是我想要的那个值吗"——训练循环里"公式中的 w"与"对象里的 m_weights"是两个不同的东西,直到最后一行 m_weights = w 它们才合二为一。
7.4 src/main.cpp —— 六个实验
- 手算例子的代码验证:文档 4.2 节逐行手算过的 迷你数据——先手工执行一轮更新(打印梯度 、更新后 ,与手算对账),再交给
fit训练到底,验证收敛到 ; - 造数据:真实关系 (
<random>的mt19937引擎 + 正态分布,固定种子保证可复现)——先有真相,再看机器能否还原它; - 标准化 + 训练:用 Part 1 的
mean/stddev标准化面积、手工拼偏置列,调用fit,打印学到的 与真实参数对照; - 预测演示:预测 100㎡ 的房价;
- 正规方程对照(第 5 章上机):手写 2×2 求逆(交换对角、负化反对角、除以行列式),一步算出 ,与梯度下降 179 轮的结果逐位对账——两条路殊途同归的现场证明;
- 可视化(两张图,
output/part03/):fit.png:散点(数据)+ 直线(学到的模型)——眼睛验证拟合质量;loss.png:损失随迭代下降的曲线——亲眼看见"学习"发生(曲线先陡后缓 = 4.1 节"自动刹车"的直观呈现)。
7.5 构建与运行
1 | # 项目根目录(MSVC 环境已加载): |
关于实验 4 的工程细节:
- 绘图失败不应让程序崩溃:绘图依赖外部 Python 环境,用
try/catch包住——数据实验照常出结果,绘图降级为提示信息; - 图内文字用英文:matplotlib 默认字体不含中文字形;
- Debug 构建的 Python 链接陷阱:包含
matplotlibcpp.h时临时#undef _DEBUG(详见 main.cpp 注释与 Part 1 文档),否则链接器找python311_d.lib报 LNK1104。
8. 练习(强烈建议动手)
- 学习率实验:把
learningRate改成1e-6与5.0分别重跑,观察损失曲线形状(极慢 / 发散),并思考:发散时为什么会出现inf甚至nan? - 梯度数值检查(必做,价值极高):写一个函数用有限差分估计梯度:(),与解析梯度 逐元素比较(容差 1e-6)。提示:这正是 2.2 节"拧一点点看变化"的动作——不用公式也能测出坡度,公式只是它的精确化。这个技巧叫 gradient check,是 Part 5 反向传播调试的救命工具。
- 不标准化的后果:注释掉标准化代码直接训练,对比收敛所需轮数,体会 5.1 节的"狭长椭圆"。
- 双特征回归:数据改为 ,验证机器能同时还原两个权重。注意标准化每一列。
- 思考题:偏置列全 1,标准化前后有区别吗?我们标准化的时机在拼偏置列之前——如果反过来(先拼 1 再整列标准化)会发生什么?
- 自测(4.2 节迷你例子):站在 ,如果只允许拧 ,L 变化多快?(答案:,即 。)
9. 本部分小结与下一站
✅ 你已经掌握:
- 完整学习闭环:假设 → 损失 MSE → 梯度 → 更新
- 关键跃迁:方程组无解(超定+噪声)→ 退而求最优(损失最小化);L 是打分机,w 是搜出来的
- 数学工具箱:Σ = for 循环;导数 = 坡度;偏导 = 焊死其他旋钮;梯度 = 最陡上坡箭头
- 起点为何 (0,0)、步长为何自带刹车、学习率的两难、停止条件、为什么需要标准化
- 有状态模型类的 C++ 形态、
<random>合成可复现数据、假收敛 bug 的教训
🚀 Part 4 预告 —— 逻辑回归与分类:回归预测连续值(价格),分类预测离散标签(垃圾/正常邮件)。直线被"弯"成 S 形的 sigmoid,损失换成交叉熵——同样的梯度下降框架,一次华丽转身。至此你将拥有二分类的全部武器。
反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。
Part 4 · 逻辑回归与分类:直线弯成 S 形
学习目标:模型从"预测数值"升级为"预测类别 + 置信度"。sigmoid 把直线弯成 S 形,损失换成交叉熵——但你会发现梯度竟然还是 。手算例子贯穿全文 + 决策边界可视化。
对应代码:
src/part04_logistic_regression/(静态库part04+ 可执行程序ml_part04)前置:Part 3 全部(尤其梯度下降三问与"打分机"世界观)
1. 新问题:分类
1.1 场景变了
Part 3 的房价是连续值(回归)。现在要预测的是离散类别(分类):垃圾邮件(1)/ 正常邮件(0)、恶性肿瘤(1)/ 良性(0)。这类只有两个类别的问题叫二分类。
1.2 为什么不能拿线性回归硬拟合 0/1?
直觉做法:把标签当数值(0 或 1),还是用 拟合。三个致命伤:
- 输出无意义:模型会预测出 1.7 或 −0.3——"1.7 的垃圾程度"不是概率,无法解释;
- 对离群样本极度敏感:一个特别"垃圾"的样本(哪怕标签还是 1)会把直线拉得过头,把大片本来是 1 的区域判成 0;
- 没有"置信度":医生需要的是"恶性概率 93%",不是一个是非答案。
正确姿势分两步:先预测是 1 的概率 ,再按阈值(默认 0.5)硬化成类别。
2. sigmoid:把任意数压进 (0,1)
我们需要一个函数把 (可正可负、无界)压进 。标准答案:
先手算三个值找感觉(拿计算器按):
| 计算 | 直觉 | ||
|---|---|---|---|
| 0 | 0.5 | 正中间,一半一半 | |
| 2 | 0.88 | 强烈倾向 1 | |
| −2 | 0.12 | 强烈倾向 0 |
性质(都在图上能看到):
- 单调递增的 S 形: 越大越接近 1,越小越接近 0,永不到达;
- 对称:;
- 分界点:——这是下一节决策边界的钥匙。
模型就是"线性回归 + 一层弯折":
直线被 sigmoid 弯成了 S 形——逻辑回归的名字里带"回归",因为它的骨架还是线性回归。
3. 决策边界:一条看不见的直线
分类决策规则: 判类 1,否则判类 0。而 ,所以:
这仍然是一条直线(高维是超平面)!sigmoid 只是让边界两侧的概率平滑过渡,边界本身还是直的。(除偏置外)正是这条直线的法向量——指向"类 1"那一侧。
手算验证:设 (偏置 0,特征权重 1、2):
| 点 | 判类 | ||
|---|---|---|---|
| 1 | |||
| 0 | |||
| 正好在边界上 |
4. 损失函数:交叉熵——“自信的错误罚得最狠”
4.1 直觉
标签只有 0/1,交叉熵(Cross-Entropy)的定义按 的取值二选一:
用"Σ = for 循环"翻译器读:对每个样本,若 只罚 ,若 只罚 。手算几个罚金( 是递减的:p 越接近真相罚越轻):
| 真值 | 预测 | 罚金 | 评语 |
|---|---|---|---|
| 1 | 0.9 | 判对了且自信,轻罚 | |
| 1 | 0.5 | 模棱两可 | |
| 1 | 0.1 | 自信地错了,重罚 | |
| 1 | 0.01 | 非常自信地错,罚到疼 |
这正是我们要的性格:模型被逼着"不确定时别说满"。
4.2 为什么不用 MSE?
两个原因,一个直觉一个数学:
- 直觉:MSE 对 的错判(罚 0.25)和 的自信错判(罚 )差距不够狠;交叉熵把后者罚到 4.6;
数学:sigmoid + MSE 的损失曲面非凸(多个谷,梯度下降可能困在半山腰);而 sigmoid + 交叉熵是一对天作之合——下一节你会看到求导后公式干净得出奇。
但"天作之合"终究是事后总结——交叉熵这个公式凭什么长这样?下面整整一章回答:它不是谁发明的,是从"极大似然"这个原则里推导出来的。
5. 交叉熵从哪来?——极大似然的化身
5.1 换个问法:不问"罚多狠",问"参数多合理"
4.1 节给了交叉熵的性格(自信的错误罚得狠),但那是在验收一个现成的公式。现在换一个视角,从零把它造出来:
不要想"怎么罚模型",想"如果这组参数是对的,眼前这批数据有多大概率发生"——挑让数据看起来最"不意外"的那组参数。
这个原则叫极大似然(Maximum Likelihood):已发生的事实,就是最该被参数解释的事实。
5.2 似然函数:数据在 w 手里发生的概率
逻辑回归面对样本 输出概率 。把它想成一枚"硬币工厂":遇到 就掷一枚正面率为 的硬币,落地就是预测。那么观测到真实标签的概率(Bernoulli 分布的标准写法):
( → 就是 ; → 就是 。指数 0/1 自动"二选一"。)
样本独立,整个训练集一起发生的概率是连乘——这就是似然函数:
极大似然原则:选让 最大的 。
5.3 手算迷你例子(两个候选参数对决)
两个样本 。候选 w₁ 给出预测 ,候选 w₂ 给出 :
| 第 1 个样本(y=1) | 第 2 个样本(y=0) | 似然(连乘) | log 似然(连加) | |
|---|---|---|---|---|
| w₁ | ||||
| w₂ |
w₁ 的似然是 w₂ 的 8 倍——它让"已发生的事实"看起来更顺理成章。极大似然选 w₁,直觉也选 w₁(它对两个样本都判得更准)。
注意表格最右列:log 把连乘变成了连加(log 单调,argmax 不变)——这是你第三次见到这个技巧(Part 4 本章、Part 9 的 log 后验、Part 12 的 SVM 还会重逢)。
5.4 揭晓:−log 似然 ÷ m 就是交叉熵
把 log 似然展开:
最大化它,等价于最小化它的相反数再除以 m(除以 m 不改变谁是最小,只把尺度变成"平均每个样本"):
对照 4.1 节——一个字都不差。
交叉熵 = 平均每个样本的 −log 似然。 它不是谁拍脑袋发明的损失函数,是"挑最合理的参数"这个原则算出来的必然结果。4.1 的罚金表(自信的错误罚得狠)只是它的副作用: 似然天然讨厌"把真事看得很难得"的参数。
5.5 彩蛋:MSE 也是极大似然(Part 3 的伏笔到此回收)
同一把尺子回去量 Part 3。线性回归的假设:,噪声 。那么观测到 的概率就是高斯密度:
取 −log、丢掉与 w 无关的常数(、):
最小二乘 = 高斯噪声假设下的极大似然。两个老朋友原来是同一个原则的两次落地:
| 假设(数据怎么生成) | 极大似然导出的损失 | |
|---|---|---|
| 线性回归(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 |
| 假设 | 特征给定类别后独立(“朴素”) | 边界是线性的 |
两个彩蛋把这门课的概率线缝起来了(都不需要会证,知道即可):
- Part 9 5.3 节已经看到:后验 odds 取 log 后自动长出 sigmoid;
- 数学上可以证明:若两类的特征分布是同协方差的高斯,朴素贝叶斯的后验恰好就是 形式——生成式和判别式在特殊情形殊途同归(练习 5 给推导思路)。
概率不是机器学习的一个章节,是躲在每一章背后的同一套语法。
6. 梯度:又是 !(本章最大惊喜)
6.1 推导(链式法则,三行)
先备一个零件:sigmoid 的导数有个漂亮的自指形式 (用 写就是 )。
对单个样本的 求导( 是 0/1 常数):
注意分子上的 被约掉了——这就是 4.2 节说的"天作之合":交叉熵的 恰好抵消 sigmoid 导数的 。若用 MSE,这个 会留下来作除不掉的尾巴,在饱和区( 接近 0 或 1)趋近 0,梯度消失,学习停滞。
再走一遍 Part 3 的老路(,Σ 拼成矩阵):
6.2 与线性回归逐行对照(本章的压舱石)
| 线性回归(Part 3) | 逻辑回归(本章) | |
|---|---|---|
| 预测 | (多套一层 sigmoid) | |
| 损失 | MSE | 交叉熵 |
| 误差项 | (形式一样!) | |
| 梯度 | (完全同形) | |
| 更新 | 一字不差 |
同一个梯度下降框架,换的只是"预测头"和"打分机"——这就是当初 Part 3 坚持走梯度下降路线的回报:框架可以无限复用。
7. 代码精讲
7.1 接口(include/part04/logistic_regression.h)
1 | class LogisticRegression |
与 LinearRegression 逐一对应的成员——结构上几乎是复制粘贴,只改了三处:sigmoid、交叉熵、predict 的阈值硬化。这本身就是 5.2 节表格的代码证据。
7.2 fit 循环:与 Part 3 的 diff 只有几行
1 | for (...) |
7.3 数值稳定性:log(0) 是 −inf
交叉熵要对 取对数。若某轮 因浮点误差变成 0 或 1, 直接污染损失。标准防御:把 **夹紧(clip)**到 :
1 | const double clipped = std::min(std::max(p, 1e-12), 1.0 - 1e-12); |
这是数值代码的常见手法:数学上不变(正常值夹不动),工程上挡住边界灾难。面试与实战都常考。
8. 实验(src/main.cpp,对应输出 output/part04/)
- 手算例子的代码验证:两个样本 、,起点 :
- ,两个预测都是 0.5;
- 梯度:,;
- 走一步:——程序逐步打印对账,再训练到底验证预测正确;
- 合成数据:两团二维高斯(类 0 中心 ,类 1 中心 ),每类 50 个样本,逐特征标准化(Part 1 的
mean/stddev); - 训练 + 准确率;
- 可视化:
decision_boundary.png(两团散点 + 学到的边界直线)、loss.png(交叉熵下降曲线)。
9. 构建与运行
1 | cmake -S . -B build |
10. 练习(强烈建议动手)
- 阈值实验:
predict的 0.5 改成 0.9(保守,宁可放过)或 0.3(激进,宁可错杀),重新统计准确率——什么场景该保守(癌症筛查)?什么场景该激进(垃圾邮件)? - sigmoid 手算:(答案 0.731);再验证 。
- 交叉熵手算:, 从 0.5 → 0.9 → 0.99,罚金如何变化?(0.693 → 0.105 → 0.01)
- 预习思考(Part 5 揭晓):决策边界是直线——如果两团数据长成"双月牙"互相缠绕,一条直线还切得开吗?怎么办?
- (进阶)NB = LR 的特殊情形:设两类特征都是同协方差高斯( 相同、均值 )。写出后验比 ,取 log,证明它是 的线性函数(提示:二次项 在分子分母相同而抵消)——即朴素贝叶斯的决策面与逻辑回归同形。
11. 本部分小结与下一站
✅ 你已经掌握:
- 分类的正确姿势:预测概率 + 阈值硬化,而不是硬回归 0/1
- sigmoid 的形状与三件套:、单调、
- 决策边界: 的直线, 是法向量
- 交叉熵:自信的错误罚得最狠;与 sigmoid 天作之合(推导约掉 )
- 交叉熵的出身:Bernoulli 极大似然(MSE 的出身:高斯噪声极大似然)——损失函数不是发明的,是"假设 + 概率"算出来的
- 判别式 vs 生成式:逻辑回归直接建 P(y|x),朴素贝叶斯建 P(x|y)P(y) 再换向
- 惊喜:梯度还是 ——框架复用,只换预测头和损失
- 工程点:概率 clip 防 log(0)
🚀 Part 5 预告 —— 神经网络与反向传播:把本章的"线性 + sigmoid"堆好多层,中间加 ReLU,就能学出弯曲的决策边界,切开双月牙。代价是梯度没法一步写出——需要链式法则逐层回传,这就是大名鼎鼎的反向传播。Part 3埋的"误差回传"伏笔正式揭晓。
反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。
Part 5 · 神经网络与反向传播:把"线性+激活"堆成多层
学习目标:把 Part 4 的"线性 + sigmoid"堆好多层、中间加 ReLU,就能学出弯曲的决策边界,切开直线切不开的双月牙数据。核心代价是梯度没法一步写出——需要链式法则逐层回传,这就是大名鼎鼎的反向传播(backpropagation)。Part 3 埋下的" = 误差回传"伏笔在此正式揭晓。
对应代码:
src/part05_neural_network/(静态库part05+ 可执行程序ml_part05)前置:Part 3(链式法则、梯度下降)、Part 4(sigmoid + 交叉熵,尤其"梯度 = "那步推导)
1. 从逻辑回归到神经元
1.1 逻辑回归就是一个神经元
回看 Part 4 的模型:
画成图:输入 → 加权求和 → 激活函数 → 输出。这个结构在神经网络术语里就叫一个神经元(neuron), 叫权重, 叫偏置(bias),sigmoid 叫激活函数(activation)。你已经学了半个学期的"神经元"了。
1.2 直线的天花板
Part 4 练习 4 的思考题:双月牙数据(两个互相缠绕的半圆)——逻辑回归的决策边界必须是一条直线( 是线性方程),而月牙缠在一起,任何直线都会切错一大片。这不是训练不充分,是模型表达能力(容量)的天花板。
1.3 破局:在中间加一层"特征加工厂"
思路:与其直接拿原始特征 去拟合,不如先让网络自己学出几个更好的中间特征 (每个 是一个神经元的输出,本身就是一个逻辑回归式的"弯折特征"),再用这些中间特征做最终判断:
每个隐藏神经元学一个"分段线性的小特征"(ReLU 的样子),组合起来就能拼出弯曲的决策边界。层数越多、每层越宽,能拼出的形状越复杂——这就是"深度"学习的"深"。
2. 前向传播:一层一个矩阵乘法
2.1 公式(以两层网络 {2, 8, 1} 为例)
每层就是 Part 2/3/4 反复出现的那一步:矩阵乘法 + 逐元素激活。整批 个样本一起算(矩阵的每一行是一个样本)——前向传播就是从左到右把各层乘过去。
2.2 偏置的矩阵化:给每层输入加一列 1
技巧与 Part 3 的偏置列完全一致:把每层输入 ()左侧拼一列全 1 得到 (),权重 是 ——第 0 行就是偏置。这样 一次把"加权和 + 偏置"全算了。
2.3 激活函数:为什么隐藏层用 ReLU 而不是 sigmoid
- 计算极简(一个比较),导数只有两个值( 时 1,否则 0);
- 不饱和:sigmoid 在两端( 或 )导数趋近 0,多层相乘后梯度指数级缩小(梯度消失),深层网络几乎学不动;ReLU 正区间导数恒为 1,梯度畅通无阻;
- 输出层仍用 sigmoid——因为我们要概率(Part 4 的角色不变)。
3. 反向传播:链式法则的逐层接力
3.1 问题:损失对第 0 层权重的梯度是什么?
Part 4 的梯度 一步到位,因为只有一层。现在 影响 , 影响 , 影响 ,最后才影响 。链式法则(Part 3 附录 A 的"逐层剥洋葱")说:梯度 = 路径上每一步导数的连乘,而且可以从输出端往输入端逐层算——算某一层时,只需要"上一层回传来的误差",这就是反向传播的名字由来。
3.2 逐分量推导:链式法则怎么长出矩阵公式
四行公式不是天上掉下来的。这一节从一个权重出发,把下一节的矩阵公式一步一步推出来——看完你就明白“为什么偏偏是转置 × 误差”。
第 1 步:盯住一个权重,写出它的完整影响链。
是“第 层第 个神经元的输出 → 第 层第 个神经元”的连线强度。动它一下,链条是:
第 2 步:定义 δ——把长链条折叠的记号。
链条中段 之后再怎么走,跟 无关(那是下游的事)。干脆定义误差:
(误差 = 损失对该层预激活的敏感度。)于是链式法则立刻给出权重梯度:
——本层输出 × 下游误差,一行结束。这就是四公式里 ③ 的逐分量版本:把 摆回矩阵就是 。
第 3 步:δ 自己怎么往回退一层?(核心一步)
。 先变成 ,然后 汇入下一层每一个神经元的 ——每条连线一条路径!链式法则对所有路径求和:
读出来:一个神经元的误差 = 它每条下游连线上“对端误差 × 连线权重”的总和,再乘自己的激活导数。求和是因为它的输出分流给了所有下游——每条路径都带回一份影响。
第 4 步:把求和写成矩阵乘。
正是 的第 个元素——转置的几何意义就是“把下行流改成上行流”(权重矩阵正向是“上一层的谁流向我”,转置后是“我流向下一层的谁”)。逐元素的 ReLU‘ 用 对齐。于是:
四公式里的 ② 就位。加上 ①(Part 4 已推:sigmoid+交叉熵 → )和 ④(梯度下降原样),四行公式全部到手——矩阵形式不是额外假设,是逐分量求和的自然打包。
第 5 步:验算。 3.4 节的微型网络 {1,1,1} 里“求和只剩一项”:,正是第 3 步公式的特例——到时逐位对账。
3.3 四行核心公式(本章的压舱石)
从输出层往回走( 是逐元素相乘, 是带偏置列的输入):
| 步骤 | 公式 | 直觉 |
|---|---|---|
| ① 输出层误差 | Part 4 推导过:sigmoid+交叉熵的梯度就是 | |
| ② 误差回传到隐藏层 | 把误差反向投射回上一层——Part 3 的 伏笔! | |
| ③ 权重梯度 | 又是"转置 × 误差"!与 Part 3/4 完全同构 | |
| ④ 更新 | 一字不差的梯度下降 |
注意三个结构上眼熟的零件:(Part 4 的误差项)、 反向投射(Part 3 的 )、(Part 3 的 )。反向传播没有任何新数学——只是链式法则把熟悉的零件按层串起来(3.2 节刚从零验证了这一点)。
3.4 逐元素手算(实验 0 会对账)
微型网络 {1, 1, 1}(1 输入、1 隐藏、1 输出),单样本 ,手设权重 (偏置 0.1、权重 0.5),:
1 | 前向:z₁ = 0.1 + 0.5×1 = 0.6 → h = ReLU(0.6) = 0.6 |
若真值 :输出误差 (预测偏低,误差为负,权重该往上调)。反向到隐藏层:。梯度符号一路保持"该往哪调"的信息——这就是"误差信号回传"。
3.5 为什么必须随机初始化(Part 3 的礼物正式收回)
Part 3 说"起点选 (0,0) 无妨,碗只有一个底"。网络世界不一样:
- 若所有权重为 0(或相同值),每个隐藏神经元算出完全相同的输出、收到完全相同的梯度、做完全相同的更新——8 个隐藏单元塌缩成 1 个,网络永远学不出多样的特征。这叫对称性问题;
- 解决:随机小值初始化打破对称。我们用 He 初始化:(方差与输入宽度挂钩,信号不随层深爆炸/消失);
- 代价:损失曲面非凸(多个谷),不同初始化可能到达不同的谷——Part 3 的"起点不影响终点"的凸性礼物,从本章起正式失效。
3.6 梯度检查:反向传播的救命测试
反向传播代码极易写错(下标差一位、偏置列漏处理),而且错误是静默的——网络照跑,只是学得慢/差,你根本不知道有 bug。救命工具就是 Part 3 练习 2 的有限差分:
不用任何推导就能测出真梯度,与反向传播的结果逐元素对账(容差 1e-6)。工业界铁律:任何新写的反向传播,先过 gradient check 再说。我们的实验 1 就做这件事。
4. 代码精讲
4.1 接口(include/part05/neural_network.h)
1 | class NeuralNetwork |
4.2 形状速查表(排错时贴在手边)
| 记号 | 形状 | 说明 |
|---|---|---|
| 本类自带偏置:隐藏层偏置由 的第 0 行承担, 不需要拼 1 列(与 Part 3/4 不同!) | ||
| 第 0 行是偏置 | ||
| 运行时拼出来的带偏置输入 | ||
| 每个样本在该层每个神经元的误差 | ||
| 与 同形(必须同形!) |
4.3 公式↔代码对照(核心循环)
1 | // 前向:左到右,每层 = 拼偏置列 → 矩阵乘 → 激活 |
与 Part 3 的 fit 循环对照:外圈还是同一个梯度下降,只是"梯度"从一步公式变成了逐层回传的函数。
5. 实验(src/main.cpp,对应输出 output/part05/)
- 前向手算对账:3.4 节的微型网络 {1,1,1},手设权重,程序前向算出 与手算一致;
- 梯度检查:网络 {2,3,1} + 5 个样本,逐权重做有限差分 vs 反向传播,报告最大偏差(应 < 1e-6);
- 双月牙对决:同一个月牙数据集上,逻辑回归(Part 4)vs 神经网络 {2,8,1},比较准确率;
- 可视化:
decision_boundary.png(左右两个子图:逻辑回归的直边界 vs 神经网络的弯边界)、loss.png(两者损失曲线)。
6. 构建与运行
1 | cmake -S . -B build |
7. 练习(强烈建议动手)
- 隐藏层宽度实验:{2,4,1} / {2,16,1} / {2,8,8,1}(两层隐藏),对比决策边界的平滑度与准确率;
- 对称性实验(必做):把初始化改成全零(临时改
setWeight),重训双月牙——亲眼见证网络塌缩、准确率掉到 ~50%(等价于瞎猜); - 初始化种子实验:换几个随机种子重训,观察最终损失略有不同——非凸世界的日常;
- 手算:3.4 节例子中若 ,输出误差 是多少?(,预测偏高。)
- 思考:为什么 回传时要"去掉偏置列"?(偏置列不是任何神经元的输出,没有误差可回传——它只是给下一层送常数 1。)
8. 本部分小结与下一站
✅ 你已经掌握:
- 神经元 = 逻辑回归;网络 = 神经元分层堆叠,隐藏层是"特征加工厂"
- 前向传播 = 逐层"矩阵乘法 + 激活";偏置矩阵化的拼 1 列技巧
- ReLU vs sigmoid(饱和与梯度消失);He 初始化与对称性问题(凸性礼物收回)
- 反向传播四公式:输出 、 回传、、同款更新——全是旧零件的层间接力;逐分量推导证明矩阵形式 = 路径求和的自然打包(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 次多项式
有 10 个自由度。给它 12 个带噪声的训练点,λ=0(不约束)时它会穿过每一个点——连噪声都当成了规律。新数据一来,这条疯狂震荡的曲线预测得一塌糊涂。
一句话:参数越多、约束越少,模型越有本事把噪声也背下来。
1.2 治法预告
- 正则化(本篇上半场):在损失里加一条"罚款规则"——权重越大罚得越狠,逼模型只用"够用"的弯曲。
- 优化器(下半场)不解决过拟合,解决"下山太笨"——但它是训练一切大模型的发动机,必须现在学。
2. 上半场:L2 正则化(Ridge 回归)
2.1 给损失加一条家规
原来的损失(Part 3):
Ridge 回归把它改成:
读法:
- 第一项还是老朋友——“预测得准不准”;
- 第二项是家规——“每个权重的平方都要交罚款”,权重越大罚款越高。
模型现在要在两个愿望之间权衡:想拟合数据(第一项小)又怕交罚款(第二项小)。λ(lambda)就是罚款力度旋钮:
| λ | 效果 | 极端情况 |
|---|---|---|
| λ = 0 | 不罚款,退化为 Part 3 的线性回归 | 想怎么弯就怎么弯 |
| λ 适中 | 权重被压小,曲线变平滑 | 该弯的弯、不该弯的收着 |
| λ → ∞ | 罚款压倒一切,所有权重 → 0 | 躺平成一条水平线 |
2.2 梯度多了一项(推导三行)
对 w_j 求偏导,旧项 Part 3 已推过,新项:
所以总梯度:
就是 w 把第 0 个位置(偏置)抠成 0 的版本。
为什么偏置不罚款? 偏置 w₀ 只是个整体抬升/下沉的量,它不控制"弯曲程度",罚它没有意义——家规只管"形状",不管"高低"。
2.3 手算例子(实验 0 会逐位对账)
单样本,x = (1, 2)(第 0 列是偏置列 1),y = 1,当前 w = (1, 1),λ = 1,m = 1。
第一步,预测与残差:
第二步,旧梯度(Part 3 同款):
第三步,罚款梯度(偏置位置清零):
第四步,合计:
第五步,一步更新(学习率 η = 0.1,Part 3 同款):
注意看:罚款项只把 w₁ 从 1 压向 0.5,偏置 w₀ 完全没被罚款动过(它只被数据梯度从 1 推到 0.8)。家规长眼睛。
2.4 闭式解:一步到位的另一种解法(了解)
梯度下降不是唯一的路。Ridge 的目标是二次函数,梯度置零可以直接解出 w(不带偏置,两边约去 m):
(推导:把 2.2 的梯度置零、移项,三行。)两个观察:
- λ 顺手治了另一种病:XᵀX 在特征强相关(多重共线性)时接近奇异、求逆数值爆炸;+λI 后必正定可逆——λ 不只管过拟合,还管数值稳定(岭回归的“岭”就是给山脊加土)。λ→0 退化为 Part 3 的正规方程;
- 为什么本篇代码仍用梯度下降?求逆 O(n³),特征上千就贵得离谱;更重要的是闭式解只属于二次损失这一小族——梯度下降属于一切可导模型(Part 5 的网络永远没有闭式解)。学梯度下降是买了一张万能票,也让 Ridge 与 Part 3/4/5 共用同一套训练循环。
3. 下半场:优化器族——下山的三种姿势
Part 3 学的梯度下降有个没说破的弱点。先看清楚它。
3.1 困境:又窄又长的峡谷
想象损失曲面不是圆碗,而是一条又窄又长的峡谷:横向(w₁ 方向)坡度平缓,纵向(w₂ 方向)坡度陡峭。数学上就是一个"各向异性"的二次函数:
梯度 。两个方向陡峭程度差 500 倍。
从 (10, 10) 出发用固定步长下山:
- 步子敢迈大(η 接近 0.4)→ 陡方向(w₂)反复冲过谷底、弹回来,来回震荡甚至发散;
- 步子只敢迈小 → 陡方向倒是稳了,平缓方向(w₁)以龟速挪动,几千轮都走不完。
这不是假想敌——标准化做不好、或深层网络里不同参数需要不同步长,就是这种峡谷。真实场景里它才是常态。
3.2 姿势一:SGD(基准)
就是我们一直在用的:
一个步长走天下。在峡谷里它只能"照顾陡的一头"(步长被陡方向钳制),平缓方向慢得像蜗牛。
术语说明:严格说 SGD 指"每次只取一小批样本算梯度"(随机性来自抽样)。本篇把全体样本的版本也叫 SGD——因为相比后面两位,它的特征是"没有任何额外机制,就是裸的梯度下降"。
3.3 姿势二:动量(Momentum)——惯性小球
把"每轮独立决策"改成"滚下山的小球"——记住之前跑过的方向:
- v 是速度(历史梯度的指数加权平均):方向一致时 v 越滚越大——谷底方向持续加速;
- 方向反复(震荡方向)时,正负梯度相互抵消,v 自动变小——震荡被惯性抹平;
- β(常用 0.9)= “记忆长度”:每一步保留 90% 旧速度。
一箭双雕:平缓方向因为方向恒定被加速约 倍,陡峭方向因为来回变向被自动刹车。
手算(加速从哪来):设某平缓方向梯度恒为 g = 1(η = 0.1、β = 0.9、v₀ = 0):
几何级数,——同样 η = 0.1,动量版的步长从 0.1 涨到 1.0,恰好 10 倍(公式 的兑现)。而震荡方向的梯度正负交替(+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、v 都还小,除以一个小于 1 的数把估值抬回真实量级;t 是轮数)。
直觉读法:
- 分子 :这个参数的"平均下坡方向"(动量的思想);
- 分母 :这个参数的"历史坡度尺子"——坡一直很陡的参数,分母大,步子自动变小;坡一直平缓的参数,分母小,步子自动变大;
- 比值 量级约为 ±1:相当于每一步统一迈"标准化大小"的步子,再由 η 统一缩放。
峡谷问题被釜底抽薪:陡参数自己刹车,缓参数自己踩油门。推荐默认值 。
手算(偏差修正的妙处):第一步(m = v = 0,梯度 g 任意大小):
修正后的第一步恰好迈 η 个单位——与梯度大小无关:g = 100 和 g = 0.001 迈同样的步。这就是“每个参数配私人步长”的数学兑现:梯度只定方向,尺度被 归一。偏差修正不是装饰细节——没有它,第一步的 m 被低估 10 倍、v 被低估 1000 倍,冷启动直接跛脚。
3.5 三兄弟对照表
| 步长 | 记忆 | 一句话 | |
|---|---|---|---|
| SGD | 全局一个 η | 无 | 裸梯度下降,峡谷里顾此失彼 |
| 动量 | 全局一个 η | 方向(一阶矩) | 惯性小球:直道加速、弯道刹车 |
| Adam | 每个参数一个 | 方向 + 坡度(一阶 + 二阶矩) | 私人定制步长,深度学习默认款 |
4. 深化:正则化的家族视角——各家都在管同一个野心
学到现在你已经见过七八种模型了。回头看会发现一件事:每个模型都有自己的"管野心"手段,而且它们的骨架惊人地一致。本章做两件事:先搞清楚 L2 罚款的"真实身份",再把全课程的正则化手段收进一张族谱。
4.1 L2 的另一重身份:高斯先验(贝叶斯视角)
Part 4 揭示了交叉熵的身世——它就是极大似然。极大似然找的是"最能解释数据的 w":
但 Part 9 的贝叶斯定理提醒我们:数据不是唯一的信息源。完整的问法应该是"看了数据之后,最可能的 w 是谁"——后验:
求让它最大的 w(最大后验,MAP)。P(D) 与 w 无关扔掉,取 log:
对比极大似然,MAP 就多了一项 ——先验:看到数据之前,你对 w 的信念。
现在指定信念:“我认为每个权重多半在 0 附近,多大都不奇怪但越大越可疑”——这正是零均值高斯 。代入:
常数项与 w 无关扔掉,最大化 等价于最小化
逐字就是 Ridge 罚款(对应关系 )。
罚款不是拍脑袋定的家规,是一句信念的数学化:“权重多半不大”(高斯先验)。罚得越狠 = 信念越强(τ 越小 → λ 越大)。2.3 节手算用 λ=1、m=1——换算过去 τ²=1,即"我认为 w 大概在 ±1 量级",与例子里 w=(1,1) 的设定严丝合缝。
顺带掲晓练习 4 的彩蛋:L1 罚款 = 拉普拉斯先验 ,−log 它正好正比于 。拉普拉斯先验在 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 | // ridge_regression.cpp 的 fit 主循环(节选) |
关注点分离:模型负责"算坡度"(数学),优化器负责"怎么迈步"(策略)。换优化器不用动模型一行代码——这正是 PyTorch 里 loss.backward() 与 optimizer.step() 分离的同款设计。
5.2 公式 ↔ 代码对照表
RidgeRegression::computeGradients(2.2 节公式):
| 数学 | 代码 |
|---|---|
(x.transpose() * (x * w - y)) 逐元素 × 1/m |
|
跳过第 0 行,其余 grad(r,0) += lambda/m * w(r,0) |
|
| 偏置不罚 | 循环从 r = 1 开始 |
SGD(3.2 节):
| 数学 | 代码 |
|---|---|
w = w - m_lr * grad(一步,与我们写了三篇的相同) |
动量(3.3 节):
| 数学 | 代码 |
|---|---|
m_velocity = m_beta * m_velocity + grad |
|
w = w - m_lr * m_velocity |
Adam(3.4 节):逐参数循环(Matrix 没有逐元素乘除,老老实实 for):
| 数学 | 代码 |
|---|---|
m_m(r,c) = b1*m_m(r,c) + (1-b1)*g(r,c) |
|
m_v(r,c) = b2*m_v(r,c) + (1-b2)*g(r,c)*g(r,c) |
|
mhat = m_m(r,c) / (1 - pow(b1, t)) |
|
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 节峡谷 ,同一起点 (10, 10):
- 图 2:三种优化器的损失曲线(对数纵轴),SGD 龟速、动量快一个量级、Adam 又快又稳。
7. 练习
- 把实验 1 的 λ 改成 0.1 和 10,观察测试损失的"倒 U 形"——正合适在中间,两头都不行。
- 实验 2 里把动量的 β 从 0.9 改成 0.99:记忆更长,直道加速更猛,但拐弯更容易过冲——找到它开始发散的临界点。
- 把 Adam 的学习率放大 10 倍(0.05 → 0.5):它还能收敛吗?换成 SGD 同样放大试试,体会"自适应"三个字的分量。
- 给 RidgeRegression 换 L1 罚款(,梯度符号函数 ),观察权重被压成精确的 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 万个输入——第一层权重瞬间上亿,训练数据永远喂不饱。
但图像有两个与生俱来的规律,全连接网络完全无视:
- 局部性:判断一个像素是不是猫耳朵边缘,只需要看它周围几个像素,不需要看图片对角;
- 平移不变:猫挪到图片右下角还是猫。同一个"耳朵边缘检测器"应该在任何位置都能用。
卷积神经网络(CNN)就是把这两条规律焊进架构里。
2. 卷积:一个模板扫全图
2.1 滤波器是什么
滤波器(filter / 卷积核)是一个小小的权重方阵,比如 3×3:
它是一个模板:在图像上从左到右、从上到下滑动,每个位置做一次"对齐相乘再求和"——和 Part 3 的"加权求和"是同一个动作,只是权重不再是每个位置一套,而是全图共享一套。
输出图的一个格子:
这个双层求和你已经认识:for 循环套 for 循环(附录 B 的老朋友)。“卷积"听起来吓人,本质就是"拿模板和局部小块对一下眼神,看看像不像”。
2.2 手算例子(实验 0 逐位对账)
输入 2×3 的图,滤波器 2×2,无偏置:
(这个 F 只做"对角线相加":左上 + 右下。)
滑动两个位置:
输出 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 圈)输出尺寸不变——边缘信息不被白白削掉,深层网络的尺寸链也好记。
两个旋钮合起来的完整通式:
(取整是因为滤波器不能出界——含填充后。)手算三档(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 的小立方体,一次卷积把三个通道的匹配分加在一起输出一张响应图。参数账升到 (输入 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 的小块,每块只留最大值:
两个作用:
- 减半尺寸:10×10 → 5×5,后面的全连接层负担小;
- 轻微平移不变:特征在 2×2 窗口里挪一挪,最大值不变——“猫挪动半步还是猫”。
3.3 本篇的端到端架构
1 | 输入 12×12 |
前半段(conv+relu+pool)是特征提取器——参数是滤波器;后半段就是 Part 4 的逻辑回归——输入从手工特征换成了"学出来的特征"。整个网络一起用反向传播训练。
3.4 感受野:每个输出格"看得见"多大的输入
感受野(receptive field):响应图上一个格子的取值,取决于输入图上多大的一块区域。一层 3×3 卷积:每个输出格只看输入的 3×3——这就是它的全部视野(局部性规律的定量化)。
堆叠两层的账:第二层的一个格子看第一层输出的 3×3,而第一层每个格子又各看输入的 3×3——第二层中心那条线看 in[中心±1],边缘那条线看更远 1 格:
通式(第 层滤波器边长 、步幅 ):
(步幅让格子跳着走,前面所有步幅连乘放大了偏移。)本篇架构手算:
| 层 | 感受野 |
|---|---|
| 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:梯度只给赢家
前向时每块只留了最大值,反向时梯度也只还给它:
其余三个位置的梯度是 0——它们前向时没上场,反向时也不背锅。
4.2 穿过卷积:谁的模板谁领误差
回忆前向:out(r,c) = Σ F·in(patch)。求滤波器梯度——滤波器在每个位置都干了活,每个位置的误差都按贡献算到它头上:
读法:把响应图上每个位置的误差 δ(r,c),乘上那个位置当时用的输入小块,全部累加。滤波器梯度 = “误差加权的输入 patch 累计”。
偏置梯度更简单(每格都有它):。
手算例子(滤波器 2.2 节的 [6, 8],若误差 δ = [1, 0]):
误差只来自第一个位置,就只有第一个 patch 贡献梯度。
4.3 穿过卷积层(续):对输入的梯度
本篇的卷积是第一层,输入是图像不是参数,不需要对它求梯度。但深层 CNN 的中间卷积层,"输入"是上一层的响应图——梯度要继续往回传,这一步躲不掉。现在从零推。
问题: 等于多少?换个问法:输入格子 in(p,q) 影响了哪些输出格?——所有覆盖它的 patch(每个覆盖它的输出格,前向时都用了它一份)。对每个这样的 (r,c) 写一条链式法则再求和:
(只有覆盖 (p,q) 的 (r,c) 贡献非零项——其余位置 F 的下标越界。)
读法:把 δ 当作"图",把 F 上下左右翻转后当作"模板"去扫它。有趣的名字来历:正向传播其实是数学上的互相关(模板不翻转);反向传播对输入的梯度恰好是数学上的真·卷积(翻转模板)——"卷积神经网络"的名字是从反向传播来的。
手算(同 2.2 节例子,):F 对称,翻转后不变。逐格:
(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 的 = 第一个 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 |
| 梯度只给赢家 | backward:gradIn[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. 练习
- 把滤波器数量从 4 改成 1:条纹任务还能学出来吗?改成 2 呢?——找出"刚好够用"的数量。
- 去掉 MaxPool(conv 输出直接摊平接全连接):参数多了多少?训练还稳吗?
- 实现"对输入的梯度"(4.3 节推导):把 δ 当作滤波器对输入做"翻转卷积",验证梯度检查仍然通过——这是看懂深层 CNN 反向传播的门票。
- 给条纹任务加第三类“斜条纹”(输出改 3 单元 + softmax)——分类头升级的完整练习。
- 把 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 | 1. 定一个带参数的公式 ŷ = f(x; w) |
参数 w 就是"学到的知识"——训练完,原始数据可以扔掉,模型是数据的"压缩摘要"。
kNN 完全相反:
新样本来了?在训练数据里找离它最近的 k 个邻居,看它们大多数是哪类,就判它哪类。
- 没有 w,没有损失函数,没有梯度下降;
- 训练 = 把数据原封不动存起来(所以叫"懒学习" lazy learning);
- 所有计算都推迟到预测那一刻(所以预测慢、训练零成本)。
一句话对比:
| 参数模型(Part 3-7) | kNN(非参数模型) | |
|---|---|---|
| 知识存在哪 | 权重 w 里 | 训练数据本身 |
| 训练成本 | 高(迭代学 w) | 零(存数据) |
| 预测成本 | 低(一次矩阵乘法) | 高(和每个训练点算距离) |
| 训练完数据 | 可扔 | 必须留着 |
| 新增训练数据 | 要重新训练 | 直接追加进表里 |
这不是玩具算法——在特征空间设计合理时,kNN 至今仍是工业界的常用基线(推荐系统的"相似用户"、欺诈检测的"相似交易"本质都是近邻思想)。
2. 数学工具箱:距离是怎么"被逼出来"的
kNN 的全部数学就一个词:距离。但这个概念值得从零推一遍——它也是第 12 章 SVM 的地基。
2.1 从数轴开始
一维情况:两个数 a、b 的距离就是差的绝对值:
手算:3 和 7 的距离 = |3−7| = 4。直观、无争议。
2.2 二维:勾股定理登场
平面上两个点 P₁=(x₁, y₁)、P₂=(x₂, y₂) 怎么算距离?画个直角三角形:
- 横向差 |x₁−x₂| 是一条直角边;
- 纵向差 |y₁−y₂| 是另一条直角边;
- 两点连线是斜边。
勾股定理:
手算:P₁=(0,0)、P₂=(3,4):
2.3 n 维:模式外推
三维就是三根"直角边"(对每对坐标用两次勾股可验证),n 维就是把"每个坐标差的平方"全部加起来再开方:
这个 Σ 你已经认识(for 循环)。这就是欧氏距离——我们从小到大直觉里的"直线距离"。
手算(n=3):x = (1, 2, 3)、z = (4, 6, 3):
2.4 实现优化:不开方的距离
比较"谁更近"时,开方是单调变换——d 大则 d² 大。所以代码里通常直接比较平方距离,省一次 sqrt(对每个训练点、每次预测都省):
1 | double sqDist = 0.0; |
这是本课程第一次遇到"数学公式 ≠ 代码实现":公式写 d,代码算 d²,答案等价、速度更快。工程直觉:先问"这个变换保序吗?"保序就可以省。
2.5 距离的亲戚(了解即可)
| 名字 | 公式 | 直觉 |
|---|---|---|
| 欧氏距离 | 直线飞行(默认款) | |
| 曼哈顿距离 | $\Sigma | x_i - z_i |
| 切比雪夫距离 | 国际象棋国王走一格 |
本篇用欧氏距离。练习 1 会让你体验换距离的差别。
2.6 距离度量怎么"选型":范数家族
把上面的公式统一看:欧氏是"平方和开根"(L2 范数),曼哈顿是"绝对值和"(L1 范数),切比雪夫是"最大值"(L∞ 范数)。更一般地:
- 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 | 1. 算 x 到全部 m 个训练点的距离 |
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 个点的平方距离(省开方):
| 点 | 计算 | d² |
|---|---|---|
| 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 的误差 满足
是贝叶斯误差——任何分类器的理论下界(两类数据本身重叠的部分,谁也做不到全对;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 | // 无超参数学习、无梯度——类只存数据 + 一个 k |
对比 Part 3-7 的模型类:fit 从"几百行训练循环"缩水成"两行拷贝"——这是算法本性,不是偷工减料。
5.2 公式 ↔ 代码对照表
平方距离(2.4 节):
| 数学 | 代码 |
|---|---|
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 | // 距离带上"身份"打包:{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. 练习
- 把欧氏距离换成曼哈顿距离重跑实验 2:边界形状会从"圆弧"变"直线拼贴"——L1 的几何天性。
- 维数灾难定量验证:随机生成 n 维均匀分布的点对,n 从 1 到 500 递增,画出"最近邻距离 / 最远邻距离"比值曲线——看它如何逼近 1(最近 = 最远 = 没意义)。
- 加权投票:给近邻的票加权 1/d²(离得越近话语权越大),对比 4.2 节例子的结果是否改变。
- 交叉验证选 k:把训练集 5 折切分,k 从 1 到 21 扫一遍,画"k-平均验证准确率"曲线,找到最优点。
- (进阶)实现简单 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 | 流感季(12 月):"现在流感高发,你这症状大概率是流感。" |
症状一样,判断不同——差别不在证据(发烧),而在底色(季节先给了"流感"一个基础概率)。医生脑子里发生的事,拆开是三步:
- 先验:还没看病人之前,"流感"的底概率(流感季 30%,六月 2%);
- 似然:假如真是流感,出现"发烧"的概率有多大?假如是感冒呢?(证据对每种解释的"支持度");
- 后验:把两者结合,得到"看到发烧之后,是流感的概率"——先验被证据更新了。
这就是贝叶斯推理的全部灵魂:
判断 = 底色 × 证据的修正。新证据到来,旧信念更新。
和 kNN 对照一下(两种完全不同的"懒/勤"):
| kNN | 朴素贝叶斯 | |
|---|---|---|
| 预测逻辑 | 查相似的旧案例,抄多数答案 | 算每个类的概率,选大的 |
| 需要什么 | 把数据全存着(预测时才干活) | 把数据压缩成统计量(均值/方差/频率),原始数据可以扔 |
| 世界观 | “近朱者赤”(几何) | “万物皆概率”(统计) |
2. 贝叶斯定理:从零推导(四步,不是背公式)
目标:给定特征 x,类别是 c 的概率 P(c | x)。这个方向难算(特征组合是未来才出现的新情况,历史里未必见过)。贝叶斯定理就是一台"调转概率方向"的机器。它不是发明出来的,是条件概率定义的必然推论——四步推导:
2.1 第一步:条件概率的定义
“在 B 发生的前提下 A 发生的概率”:
直觉:把范围缩小到 B 的地盘(分母),再看 A 占其中多少(分子)。
2.2 第二步:同一件事,两个入口
联合概率 P(A∩B)(两件事都发生)有两种算法——先看 A 再看 B,或者先看 B 再看 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) 除到对面去:
贝叶斯定理就这么出现了——它只是"联合概率有两个入口"这件事的重新排列,没有任何新假设。
2.4 第四步:翻译成分类语言
把 A 换成类别 c、B 换成特征 x:
四个术语各司其职:
| 术语 | 含义 | 怎么得到 |
|---|---|---|
| 先验 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 | 3 个二值特征:2³ = 8 个格子 |
训练集 100 封邮件,根本填不满这些格子——绝大多数格子永远是 0(不是不可能发生,只是没见过)。
3.2 假设来了:条件独立
朴素贝叶斯的"朴素"(naive)= 假设给定类别后,各特征相互独立:
一个联合概率拆成 n 个一维概率的乘积——100 万个格子瞬间变成 3 个格子 × 20 个特征。每个 P(xᵢ|c) 单独统计,数据绰绰有余。
这个假设几乎总是错的("免费"和"中奖"在垃圾邮件里明显结伴出现)。但——
3.3 错得离谱,却好用到爆:三个理由
- 我们只要排序对,不要数值准。分类 = argmax_c P(c|x)。而分母 P(x) 对所有类相同,可以直接扔掉:
独立假设带来的失真,乘在每个类头上的"歪"往往方向一致——比大小时抵消了大半。
- 错误相互抵消:实践里(垃圾邮件过滤是这个算法最著名的胜利)独立假设造成的偏差不系统性偏向某一类。
- 它是"生成模型":假设了数据怎么生成(每类一个分布),哪怕假设不完美,学到的均值/方差本身就有解释价值。
历史注脚:朴素贝叶斯垃圾邮件过滤 1998 年上线,比神经网络方法早了二十年,至今仍在服役。
3.4 连续特征:高斯假设
本课程的数据是连续的(房价、坐标)。P(xᵢ|c) 怎么算?朴素贝叶斯最常用的做法:假设每个类、每个特征的分布是正态分布(Part 1 见过的钟形),只需从训练集统计两个数:
其中 μᵢc = c 类样本在第 i 个特征上的均值,σ²ᵢc 同理(用总体方差,除以 m_c——和 Part 1 一致)。
直觉读法:“假如这个点是 c 类,它离 c 类的‘大本营’(均值)有多近?”——距离越远,钟形给出的概率指数衰减。你会在实验 1 看到:两个方差相同的圆团,贝叶斯边界是一条直线(离谁的大本营近归谁)。
3.5 参数从哪来:极大似然把均值/方差“逼”出来
3.4 说“只需统计两个数”——为什么恰好是均值和方差?这不是规定,是算出来的。思路是极大似然(Part 4 从交叉熵里见过它):这批样本是 c 类的,找一组 让“它们看起来最像从这条钟形里抽出来的”——即最大化对数似然:
对 μ 求导置零:,解出
对 σ² 求导置零(代入 μ̂):,解出
均值和方差的真实身份:钟形家族里最像你家数据的那一个成员的参数。Part 1 教了怎么算,这里补上了“为什么是它们”——4.2 节手算里“除以 3 而不是 2”的出处也在这(练习 4 的伏笔)。
3.6 边界长什么样:把 log 后验差写开
实验 1 说“等方差边界是直线、方差不同变二次曲线”——这句话可以直接推出来。分类边界是 (单特征、先验任意):
把右边展开,x² 项的系数是 :
- σ₀ = σ₁ → x² 项消失,一次方程 → 边界是一个点(多维情形:一条直线);等先验时解出 ——两均值中点。手算验证:4.2 例子 ,正是实验 0 的第二问、练习 1 的 0.5 平手点 ✓;
- σ₀ ≠ σ₁ → 二次方程,最多两个根——边界可以“拐弯”:远处方差大的类反超(高斯尾部衰减慢的一方在远处“更便宜”);多维情形是二次曲面。
(注:等协方差的高斯贝叶斯边界是直线——这也是 Part 4 逻辑回归边界为直线的深层原因:两朵形状相同的云,分界自然是直的。)
3.7 生成 vs 判别:两种押注
朴素贝叶斯是生成模型(generative):建模 ——把“数据是怎么长出来的”整个故事都学了。逻辑回归是判别模型(discriminative):只建模 ——“给定 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 第二步:代高斯密度,算两个似然
常数部分(两类方差相同,系数一样):
似然 P(x=3 | 类 0)(离 μ₀=2 距离 1):
似然 P(x=3 | 类 1)(离 μ₁=6 距离 3):
x=3 离两个大本营的距离是 1 : 3,钟形把这点差距指数放大成 403 倍——这就是高斯假设的"杠杆"。
4.4 第三步:贝叶斯定理合体
预测:类 0,把握 99.75%。(分母 P(x) 这次算了——要输出真概率时它不能省;只要比大小时才能省,见 3.3。)
4.5 除法变加法:log 技巧(工程必需)
特征一多,n 个小概率连乘(如 0.01²⁰ = 1e−40)会下溢成 0——double 的极限约 1e−308。解药是取对数把乘法变加法:
argmax 不变(log 单调),数值稳如老狗。本篇实现全部用 log 后验——这是你第二次见到"log 救数值"(第一次是 Part 4 交叉熵,后面 Part 12 SVM 还会重逢)。
4.6 拉普拉斯平滑:零概率陷阱(离散特征的保底)
设想离散场景:训练集 6 封正常邮件里"免费"出现 0 次 → P(免费|正常) = 0 → 哪怕这封邮件其他 19 个词都无比正常,一个"免费"就把它一票否决成垃圾邮件。
病根:“没见过” ≠ “不可能”。药方是拉普拉斯平滑——给每个格子预存 1 个"幽灵计数":
(分母 +2 是二值特征的两个取值各补 1。)“先验敲门”:谁都没发生过的事,给个保底概率,别一票否决。连续特征不用这招(高斯密度处处大于 0,天然平滑)——本篇实现是高斯版,这条留作练习 3 的离散版作业。
5. 代码精讲
模块 src/part09_naive_bayes/,类 GaussianNaiveBayes(高斯朴素贝叶斯,二分类)。
5.1 训练:把数据压缩成 6+2n 个数
1 | // fit 只做统计——训练完原始数据即可丢弃(对比 kNN:必须全存) |
没有梯度、没有迭代、没有超参数——一次遍历统计完毕,训练速度 O(mn)。朴素贝叶斯是"勤快"的:把学到的知识压缩进参数(kNN 是"懒"的:知识就是数据本身)。
5.2 公式 ↔ 代码对照表
高斯密度(4.3 节):
| 数学 | 代码 |
|---|---|
norm = 1/sqrt(2π·var);exp(-diff²/(2·var)) |
log 后验(4.5 节):
| 数学 | 代码 |
|---|---|
logPrior + Σ logGaussian(x(j), mean(j), var(j)) |
预测(3.3 节 argmax):
| 数学 | 代码 |
|---|---|
score0 > score1 ? 0 : 1 |
5.3 predictProba:logit 差 → sigmoid(与 Part 4 意外重逢)
要输出真概率(不是只比大小),把两个 log 后验的差喂给 sigmoid:
手算验证(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. 练习
- 手算 x=4 的后验(先验不变)。答案:两个似然对称相等,P(0|x) = 0.5——离两个大本营等距的点上,钟形完全平手 ✓。
- 先验的杠杆:把训练集改成类 0 有 9 个样本、类 1 有 3 个(先验 0.75 : 0.25),重算 4.4 节的 P(0|x)。感受"证据要压过先验得有多强"。
- 实现离散版:多项式朴素贝叶斯 + 拉普拉斯平滑(4.6 节),用词频特征做垃圾邮件分类——本算法最经典的原始战场。
- 方差用"总体方差(除以 m_c)“还是"样本方差(除以 m_c−1)”?对预测有影响吗?(提示:同一类内比较时它是公共倍率……吗?两类 m_c 不同时会怎样?)
- (进阶)特征独立假设错得有多惨:造一份 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 | 差劲的提问(一次排除太少): |
决策树就是把这套"聪明提问"自动化:
每次挑一个最能"分开两类"的问题(特征 ≤ 阈值?),把数据劈成两半,对每一半递归地继续问,直到问出答案。
一个训练好的树长这样(预测时从根往下走):
1 | 面积 ≤ 100㎡ ? |
没有任何数学黑盒——每个判断都写在那里。这是决策树最大的魅力(也是银行、医疗偏爱它的原因:模型必须能解释)。
2. 熵:不确定性的价格(从零推导)
要自动挑"最聪明的提问",先得回答:怎么给"一堆数据的混乱程度"打分? 这个分数就叫熵(entropy)。我们不直接背公式,从零把它逼出来。
2.1 设计指标:先定规矩
设一个数据集里正类占比 p、负类占比 1−p。想造一个函数 H(p) 度量"猜一个随机样本的类别时,我有多没底"。它必须满足三条常识:
- 确定无疑时 = 0:全是同类(p=0 或 p=1),闭着眼睛猜都对,不确定性为零;
- 五五开时最大:p=0.5 最抓狂——没有任何线索,比这更乱不存在;
- 越多类越乱: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 发生的——平均惊讶度要把每类按概率加权:
这就是熵。逐条验收 2.1 的规矩:
- p=1:H = 1·(−log₂1) = 0 ✓
- p=0.5:H = 0.5·1 + 0.5·1 = 1 比特(两类情形的最大值)✓
- 均匀 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 越大越值得问。
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
信息增益:
一次提问把 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
同样的提问机会,收益天差地别(1.0 vs 0.236)——决策树的全部智慧就是永远挑 IG 最大的那个。
3.3 构建算法(递归贪心)
1 | build(D, depth): |
三个停止条件各管一件事:纯了(问到底了)、太深了(防过拟合,见第 5 节)、样本太少(统计不可靠)。没有梯度、没有损失函数、没有迭代——一次自顶向下的递归就把树建完了,这也是它训练飞快的原因。
为什么叫“贪心”?每层只选当前最优分裂,不回头不全局规划。贪心不保证最优树(找全局最优是 NP 难),但实践出奇地好——也是第 11 章随机森林能“集成一堆贪心树”的地基。
3.4 基尼不纯度:熵的廉价替身
工业界另一半(CART,scikit-learn 默认)不用熵,用基尼不纯度:
读法:随机抽一个样本、再按占比随机猜它的类,猜错的概率。手算两档(与 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。两者其实是近亲:二类时 ;把 在 处泰勒展开(利用 在 1 处的展开 ):
同为 的常数倍——凸性、最大/最小点、排序全部一致,只差刻度。用 G 选分裂和用 H 选分裂,选出来的几乎是同一棵树;而 G 免掉对数,在扫描成千上万候选阈值时快一截(练习 2 上机验证“几乎同一棵树”)。
3.5 IG 的一个偏见:偏爱“话多”的特征
IG 有个隐蔽的偏好:取值类别越多的特征越占便宜。极端例子:给每个样本发一个唯一编号的特征(身份证号)——按它分裂每个叶子全纯,IG = H(D) 直接拉满,但学到的“规律”下次必失灵。分裂越碎、IG 虚高越狠。
C4.5 的修正叫增益率(gain ratio):IG 除以“这次分裂本身把样本切得多碎”(分裂信息):
分裂越碎、分母越大、越扣分。本篇只做连续特征(阈值二分,永远劈两半),偏见不严重——但知道这个坑是必要的(练习 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 | struct Node |
用 std::vector<Node> 数组存树:节点 i 的孩子在 m_nodes[i].left/right 下标里——比指针树好维护、好遍历(Part 11 随机森林直接复用整个类)。
4.2 公式 ↔ 代码对照表
熵(2.2 节):
| 数学 | 代码 |
|---|---|
数正类数 k → p = k/m;H -= 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) 是训练误差,|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. 练习
- 手算 {7 正, 3 负} 和 {7 正, 7 负} 的熵(答案 0.881 和 1.0),再算"先劈成 {7,0} 和 {0,3}"的 IG。
- 把准则换成基尼不纯度(3.4 节,CART 的默认):重跑实验 1,比较树的形状差异——预测“几乎同一棵树”。
- 给
printTree加上每个节点的样本数和正类占比——读树时这些数字是"这条规则有多可信"的关键。 - (进阶)实现后剪枝:建满树后自底向上尝试合并叶节点,用验证集决定去留。
- 把分裂换成“两特征的线性组合”(斜的分裂面):树能切开 Part 8 双月牙吗?代价是什么?
- 亲手撞一次 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 次(有放回,彼此独立):
手算三档(对照用,实验 0 上机验证):
| m | 计算 | 落榜率 | 入选率 |
|---|---|---|---|
| 10 | 34.87% | 65.13% | |
| 100 | 36.60% | 63.40% | |
| ∞ | 36.79% | 63.21% |
(那个极限是微积分的经典结论: 的孪生兄弟。)
每棵树平均只见到 63.2% 的不同样本——两棵树的训练集天然不同,这是去相关的第一针。
2.3 免费午餐:袋外(OOB)误差
36.8% 落榜样本对那棵树来说是没见过的——它们天生就是那棵树的测试集。对每个样本,用所有"没见过它"的树投票预测它,再和真值比——不用额外划分数据,就得到了一个诚实的泛化误差估计。这叫袋外(Out-Of-Bag)误差。数据稀缺时这简直是免费的午餐(实验 2 上机)。
2.4 Bagging 全流程
1 | 对 t = 1..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̂ 本身是随机变量。考察均方误差
期望对两个随机源取:训练集的抽样 + 噪声 ε。
再记平均模型 ——“无穷多份平行宇宙的数据、各训一个模型、再取平均”得到的那个模型。它是想象的产物(没人真能算出来),但作为推导的锚点非常好用:它是“这套学习方法平均而言会学出什么”的化身。
4.2 推导:加减同一项(统计学的经典戏法)
在 y − f̂ 中间塞进一个 f̄,硬拆成两块:
平方取期望,交叉项:(y 与 f̂ 独立,可拆期望;而 f̄ − E[f̂] = 0 是 f̄ 的定义)。于是只剩两个平方项:
第一块再拆一次:代入 y = f + ε,交叉项(ε 零均值、与 f̄ 独立)照例消失,得 。第二块就是方差 。合起来:
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(Σ) = ΣVar + ΣΣCov——Part 1 方差性质的推广。)代入 σ² 和 ρσ²:
三档 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 的曲线会亲眼看到)
- 加树的收益递减:n 从 1 到 100 降幅巨大,从 100 到 1000 几乎不动(只剩地板)——实践里 100~500 棵就够;
- 降相关性比加树更值钱:ρ 从 0.9 降到 0.3 的收益,加多少棵树都换不来——这就是为什么需要两针随机而不只是一针。
6. 代码精讲
模块 src/part11_random_forest/,类 RandomForest——自己不实现任何树的逻辑,全部复用 Part 10 的 DecisionTree:
1 | // fit 的骨架:两针随机 + 复用零件 |
公式 ↔ 代码对照:
| 数学 | 代码 |
|---|---|
| 落榜 | 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. 练习
- 手算 m=20 的落榜率 (答案 ≈ 0.358),验证它已很接近 36.8%。
- 把特征抽奖关掉(maxFeatures = n,只用 bootstrap):用实验 1 的数据重跑,测试准确率掉多少?——亲手感受"第二针"的必要性。
- 极限树(Extremely Randomized Trees):连分裂阈值也随机抽,不挑最优——实现之并与随机森林对比。更多随机、更弱的相关性,何时赚何时亏?
- 特征重要性:分裂时记录 gain × 样本数,按特征累加、对树平均——给月牙数据算 x₁/x₂ 的重要性(答案:约 2:1,月牙的形状主要靠 x₁ 的两次拐弯)。
- 森林能切开 Part 9 的双月牙吗?和 kNN(97.5%)五五开还是完胜?(动手前先猜。)
9. 小结
| 概念 | 一句话 | 代码落点 |
|---|---|---|
| 集成 | 一群片面专家投票,错误互相抵消 | m_trees 军团 |
| Bootstrap | 有放回抽 m 个 = 数据的平行宇宙 | bootstrapIndices() |
| 63.2% 定理 | ,落榜 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 一图看懂本篇路线
- 把"街道最宽"翻译成数学 → 间隔 = 2/‖w‖(第 2 章,纯几何推导);
- "最宽"变成一个约束优化问题 → 拉格朗日对偶 → 支持向量现身(第 3 章);
- 现实数据有噪声、分不开 → 软间隔 + C 旋钮(第 4 章,接 Part 6 全家福);
- 月牙分不开怎么办 → 核技巧:升维打击(第 5 章)。
2. 间隔:从零推导"街道宽度"
2.1 先备工具:点到直线的距离
决策边界是一条直线 (Part 3 同款,w 是法向量)。任意一点 到它的距离是多少?
从零推。设直线上离 最近的点是 (垂足),那么 沿着法方向 w,长度就是距离 d:
两边同时点乘 w:
垂足 在直线上,,即 。代入:
记忆法:代入直线方程的左边,除以法向量长度。分母 ‖w‖ 在提醒你:w 变长,同样的"代入值"只代表更近的距离。
2.2 换标签:+1 / −1
本篇把标签从 {0, 1} 换成 。不为别的,就为一个好处:y 和 (w·x + b) 同号时乘积为正。定义每个样本的"函数间隔":
- 预测正确 ⟺ (符号一致);
- 预测错误 ⟺ 。
2.3 把"最近点"顶到 ±1(本篇最绕的一步,慢慢来)
我们要的是"离最近的点都尽量远"。先做个约定:设最近的那批点满足
现在注意一个坑:w 和 b 同时放大 k 倍,直线根本没动( 是同一条线),但 却放大了 k 倍——光看 会被缩放骗到。而 2.1 节的真距离 不受缩放影响。
既然 w、b 可以随意缩放而不改变直线,我们干脆利用这个自由度:缩放 w、b,让最近的点恰好满足
(把 M 归一到 1。取 1 不失一般性——取 2、取 0.5 都只是又一次缩放。)
2.4 街道宽度 = 2/‖w‖(三行推导)
在这个约定下,最近的正类点落在 上,最近的负类点落在 上。这两条平行线就是街道的两条路缘,中间那条 是路中线。
两条路缘相距多远?用 2.1 的距离公式:。
街道宽度(间隔)= 2/‖w‖。
2.5 优化问题登场
目标"街道最宽" ⟺ 最大化 ⟺ 最小化 ⟺ 最小化 (平方是为了好求导,½ 是为了消掉求导出来的 2)。于是:
读法:在"所有点都正确分类且留白至少 1"的前提下,把 w 压到最小。约束管"对",目标管"稳"。这是一个凸二次规划——凸意味着只有一个坑,找到就是全局最优(对比神经网络的多坑地形,Part 5 的随机性困扰在这里不存在)。
3. 对偶:支持向量现身
3.1 拉格朗日乘子:把约束搬进目标
带"不许越界"约束的优化怎么解?拉格朗日的思路:每条约束配一个乘子 ,越界就罚款,无约束地优化"目标 + 罚款":
原问题等价于 (约束满足时罚款项最多为 0,α 抓不到把柄)。把它翻转成 ,就是对偶问题——凸问题里两者同解(强对偶)。
3.2 对 w、b 求导置零(四行代数)
得 (α 的总约束);
得
代回去化简——把 和 代进 :
数一数:第一项按范数平方展开是 ;第二项的中括号里同一个双重和又出现一次(没有 ½)——两项相减,只剩下负的 ½;含 b 的项是 (约束把它杀了)。活着走出代换的只有:
圈重点:x 从头到尾只以两两点积 的形式出现——这句话是第 5 章核技巧的全部伏笔。
3.3 支持向量:只有边缘上的点说了算
最优解处有 KKT 互补条件:(恰好顶在路缘上)。
翻过来读更有味:离得远的点(留白 > 1)必然 ——它们在 里根本不出现!
删掉所有非支持向量,重新训练,答案一个字都不变。 决策边界只由贴着路缘的那几个点(支持向量)决定——它们用手"撑"出了这条街。这也是模型名字的由来:支持向量的机器。
对照 Part 8 的 kNN(每个测试点要数全体邻居)和 Part 11 的森林(预测时 100 棵树全上):SVM 预测只需少数几个支持向量——训练完就可以把其余数据扔了。
3.4 手算例子(实验 0 逐位对账)
三个点:负类 ,正类 、。
猜 x³ 离得远、大概率不是支持向量。验证:只用 x¹、x² 求解。两点都在路缘上:
第二式给 ;代回第一式 ,即 。与对偶解对照:,x¹ 是原点贡献为 0,且对称性给 ,所以 ,即 。联立 :
验算间隔:。
关键验算:x³ 真的不顶路缘吗? ✓——留白充足,约束自动满足,α₃ = 0 成立。三个点的数据,答案只由两个点决定,第三个点删掉世界不变。
4. 软间隔:给现实留余地
4.1 硬间隔的困境
第 2 章的约束要求每个点都正确且留白 ≥ 1。但现实数据有噪声:
- 一个混进负类地盘的正类离群点,能把整条街带歪(为了迁就它,街道又窄又斜);
- 月牙这种 fundamentally 线性不可分的数据,干脆无解。
4.2 松弛变量:允许犯错,但要罚款
给每个点发一张"赦免券" ,约束放宽成 (ξ 是"欠的留白"),同时按总量罚款:
C 是罚款单价。对偶问题几乎不变,只多了一个盒子上界:
读法(接入 Part 6 全家福):C 就是 SVM 家的复杂度旋钮——
| C | 语气 | 效果 | 对应 |
|---|---|---|---|
| C → ∞ | “一个都不许错!” | 逼出硬间隔,离群点全数迁就 | 过拟合风险(λ→0) |
| C 适中 | “大错别犯,小错罚款” | 街道合理宽 + 个别点豁免 | 正合适 |
| C → 0 | “错就错吧” | 街道极宽但错误成堆 | 欠拟合(λ→∞) |
注意方向:C 大 = 正则弱,和 Part 6 的 λ 正好反过来(数学上 C ∝ 1/λ)。
4.3 软间隔的另一张脸:hinge 损失
把软间隔问题换个写法,SVM 会露出第二身份。ξᵢ 在目标里只以 出现,约束只要求 和 ——最优解必取下界:
(罚款取“刚好够用”:留白够(yf ≥ 1)就不交钱,缺多少交多少。)代回去,约束全部消失,软间隔 SVM 等价于无约束的
hinge 损失:留白 ≥ 1 时损失为 0(合页闭合),不足时线性上升。对照 Part 4 的对数损失:
| 对数损失(逻辑回归) | hinge 损失(SVM) | |
|---|---|---|
| 形状 | 处处光滑,永远 > 0 | 有“零区”,过了 1 就不管 |
| 梯度 | 处处连续 | 零区梯度 = 0——这正是“只有支持向量交学费”的损失函数版解释 |
| 输出 | 概率(强项) | 只管边界,不输出概率 |
这张脸让 SVM 正式入籍 Part 6 全家福:SVM = hinge 损失 + L2 罚款,C 与 λ 一一对应。同一个模型三张身份证:几何(最宽街道)、优化(二次规划/对偶)、统计(正则化的合页回归)——三张脸说的是同一个它。
4.3 软间隔的另一张脸:hinge 损失
把软间隔问题换个写法,SVM 会露出第二身份。ξᵢ 在目标里只以 出现,约束只要求 和 ——最优解必取下界:
(罚款取“刚好够用”:留白够(yf ≥ 1)就不交钱,缺多少交多少。)代回去,约束全部消失,软间隔 SVM 等价于无约束的
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 维分不开的点,映射到高维可能就分开了。想象把平面上每个点 拋到 3 维 ——内圈外圈的点被"拋"到了不同高度,一张平面就能切开。维度越高,可分的希望越大(极端:样本数有限时,升到足够高的维度总能分开)。
5.2 障碍与技巧
直接做法:把所有 换成 再训练。问题是:高维 φ(x) 本身又长又贵(RBF 核甚至是无穷维,根本写不出来)。
回头看 3.2 节圈的重点:对偶问题和预测公式里,x 只以点积出现。而"先映射再点积"这件事,也许有一个直接用原坐标算的捷径:
K 叫核函数。核技巧 = 不显式构造 φ,直接用 K 算出高维点积。
5.3 手算:多项式核的恒等式(实验 0 对账)
二次多项式核(c=1):
它对应的映射是 ——把 2 维升到 6 维。取 、:
左边(核捷径):,。
右边(老实升维再点积):
一次乘加 vs 先算两个 6 维向量再点积——核函数不升维却拿到了升维后的点积。这就是"技巧"二字的分量。
5.4 常用核函数
| 核 | 公式 | 升到几维 | 适合 |
|---|---|---|---|
| 线性 | 不升 | 本来就近似线性可分 | |
| 多项式 | 维 | 边界带弯曲的交互 | |
| RBF(高斯) | 无穷维 | 月牙、同心圆等任意边界(默认款) |
RBF 的直觉:每个样本点周围放一座高斯小山,测试点离谁的山头近就归谁——它其实和 kNN(Part 8:看距离)是远房亲戚,只是"距离表"被无穷维拉伸过。
γ 旋钮:小 γ → 山很平缓 → 边界平滑;大 γ → 每座山又高又尖 → 边界碎(又一个 Part 6 全家福里的旋钮,拧大拧小都不行)。
6. 代码精讲
模块 src/part12_svm/:svm.h/.cpp(SVM 类)+ main.cpp(三个实验)。
6.1 训练:简化版 SMO——每次只优化一对 α
对偶问题是带等式约束的二次规划( 把所有 α 拴在一起),梯度下降一步一个方向的“小碎步”收敛很慢;这里用简化版 SMO:主循环 = 找一对违反 KKT 的 ,解析地优化这一对,重复。
为什么是“一对”?单独动一个 α 必然违反等式约束;同时动两个,让 ,等式依然成立(分组拔河,两队同时增减相等的力,绳子不动)。
固定其余 α 后,目标函数沿约束方向只是一条开口向上的抛物线,顶点一步到位:
( 是预测误差;η 是两点在高维空间的距离平方。)再把顶点裁剪回可行区间 ——由等式约束和盒子 联合决定,异号/同号两套公式。
| 数学 | 代码 |
|---|---|
| fit 开头一次性算 m×m 核矩阵缓存 | |
| 违反 KKT: 且 ,或 且 | 主循环轮转扫描找 i(3.3 节两种形态) |
| 顶点公式 + 裁剪 | tryStep 里两行(先算顶点再 std::clamp) |
| 等式约束反解 | aINew 一行 |
| f 的增量更新(O(m),不全量重算) | fVal[p] += ... |
| b 由盒内 α 的 KKT 等式()更新 | tryStep 尾部(两个都贴边取平均) |
| 首选搭档:误差差最大;撞墙就挨个试 | jBest + for 循环遍历兜底 |
| (线性核) | 诊断用:weights() 直接由 α 重构 |
相比投影梯度的“小碎步”,SMO 每步都是这条抛物线的精确最优——实验 2 里 RBF 核训练 100 个样本几千轮内收敛。
6.2 预测:只靠支持向量
- b 不单独估计——SMO 每步顺手更新:优先用落在盒内()的样本的 KKT 等式 反解,两个都贴边就取平均。训练结束 b 就是现成的;
- 预测符号 ;
decisionFunction公开(画边界图用连续值,跟 Part 4 的 sigmoid 概率平行); supportIndices()用相对阈值(最大 α 的 1%)划线,忽略数值残余——实验里数一数支持向量个数,亲眼看"少数点撑起整条街"。
6.3 核的实现
三个核都是一行的活儿:线性(点积)、多项式()、RBF()。注意 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. 练习
- 把实验 0 的 x³ 从 (4,4) 挪到 (2.5, 2.5):它还坐得住非支持向量的位置吗?手动求新的 w、b、α(三行代数,别用程序)。
- 实验 2 把 RBF 的 γ 从 6 拧到 100:边界碎成什么样?支持向量变成多少个?——体验"γ 也是正则化旋钮"(往小拧到 0.5 再看一眼,两头都不行)。
- 证明:线性核 + 对偶解下,决策函数 (把 展开与 3.2 节的 对照——"升维后的世界"和"原世界"算的是同一个 f)。
- 对照实验:同一份月牙数据,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 走一步拿一个奖励,走到终点一共拿了 。"这局打得好不好"该用一个数衡量——最直接的想法是把它们全加起来。但两个麻烦:
- 游戏可能不结束(无限走),加总发散;
- "现在拿到的 1 分"和"100 步之后的 1 分"一样值钱吗?直觉上不等——未来还没到手。
2.2 折扣:未来的 1 分今天值多少
给每一步的奖励乘一个折扣率 :
直觉:下一秒的 1 分值 γ 分,下下秒的值 γ² 分……遥远未来的奖励几乎不计。两个麻烦同时解决:级数收敛(即使无限走,),且"早拿分、早收工"自动成为偏好。
手算(γ = 0.9):奖励序列 (三步各拿 1)的回报
而序列 (先空走一步)的回报 ——同样的总分,晚一步到手便宜了整整 1 分。γ 越小越短视:γ=0 时只看眼前一步(贪心),γ→1 时越有远见。它就是 Part 6 全家福里又一个旋钮——这次拧的是时间视野。
3. 值函数与贝尔曼方程(从零推导)
3.1 目标的形式化:找让回报最大的策略
评价一个策略好不好,标准是期望回报。定义状态价值:
(“在状态 s 出发、从今往后按策略 π 走,平均能拿多少回报”。)最优策略 就是每个状态都取到最大价值的那一个。直接算 V 要对整条轨迹求期望——轨迹指数爆炸,算不动。贝尔曼方程的伟大在于把这个大问题折叠成一步的递推。
3.2 推导:拆掉第一项,剩下的是同一个问题
把 的第一项拆出来,剩下的部分 恰好是 :
(一行代数,整个 RL 的地基。)两边对策略 π 取期望:左边是 ;右边第一项 是确定的一步奖励,第二项的期望是"走完这一步落到的状态 的价值"——大问题的期望,等于一步奖励 + 子问题的期望:
这就是贝尔曼期望方程。是不是眼熟?决策树(Part 10)的最优性也是"一步分裂 + 子节点递归最优"——凡是递归结构的最优解,都长这个样子。
3.3 动作价值 Q 与贝尔曼最优方程
V 只看状态,不看动作。更实用的记号是动作价值函数:
(“在 s 这格、走 a 这步,之后按 π 走,平均回报多少”。)最优的 Q 记作 。对最优策略,走完 (s, a) 之后,下一步自然挑最优的(不再对 π 求期望,直接取 max):
这就是贝尔曼最优方程——注意它是个方程(左右两边的 Q* 要相等),不是计算公式。Q-learning 做的事就是把它变成算法。
3.4 V 与 Q:一本账的两张读法
定义了 V(3.1)和 Q(3.3)之后,把两者的关系挑明——它们不是两个东西,是同一本账的两种读法:
第一句(期望版):策略 π 在 s 的价值 = 各动作按 π 的概率加权的 Q。第二句(最优版):最优价值 = 挑最好的动作。第三句是贝尔曼最优方程的 V 版写法:走一步拿 r,剩下的问题恰好是“新状态的 V”——一步递归的结构在这里最清晰。(本篇环境确定:走“下”必然到终点,期望号可以扔;随机环境第三句右边要套 E。)
为什么工程上存 Q 不存 V:光有 选不出动作——还得知道“哪个动作通向哪个 s’”(环境的转移模型)。Q 表自带答案: 查表即得。Q-learning 免模型(model-free)的钥匙就在这:它从头到尾不需要转移概率,只需要“实际走一步看看”——4.1 节“用采样代替期望”能落地,正因为学的是 Q。
3.5 手算:方程右边到底在算什么(实验 0 对账)
设 γ = 0.9,格 (2,3) 的正下方是终点。走"下"这步:r = +1,s’ = 终点(没有下一步,max 项 = 0):
再看格 (2,2):走"右"到 (2,3)(假设已算出 ,即"下"):
值沿着路径倒着流:终点的 1 分,隔着一步变 0.9,隔着两步变 0.81……离终点越远值越低——值函数热图(实验 2)上会看到一圈一圈亮起来的“梯度”,agent 只要永远往值更高的格子走,就自动走到终点。策略是免费的:π*(s) = argmax_a Q*(s, a),Q 表到手,策略查表即得。
顺手用 3.4 的两张读法互验:(撞墙原地不动那项是自我引用,,赢不了“下”)——与 一致 ✓;反过来 ✓。一本账两张读法,读出的是同一个数。
4. Q-learning:把方程变成算法
4.1 核心障碍:期望算不出来
贝尔曼最优方程里有期望 ——要对"下一步的所有可能"求期望,需要环境的转移概率模型(走"上"有 20% 概率滑倒之类)。Q-learning 的天才之处:不求期望,用采样代替——实际走一步,看见哪个 s’ 就用哪个 s’,期望用一次真实体验来近似(Part 1 就认识的老朋友:样本均值逼近期望)。
4.2 一行更新规则(本篇的全部算法)
用 Q 表(一张 (状态×动作) 的表格)当 的草稿。每真实走一步 (s, a, r, s’),把表格朝贝尔曼方程的右边挪一小步(学习率 α,Part 6 的老旋钮):
中括号里那项叫 TD 误差(temporal difference error,时序差分)——“现实与预期的差”。整个式子读作:预测错了多少,就朝修正方向改多少——和 Part 3 梯度下降 的精神一模一样,只是"梯度"换成了"贝尔曼残差"。没有任何新数学,又是旧零件。
手算(实验 0 逐位对账):α = 0.5、γ = 0.9、Q 表初始全 0。
第一步:在 (2,3) 走"下"到终点,r = +1,s’ = 终点(done,max 项 = 0):
同样的步再走一遍(TD 误差减半):
第三步换个起点:在 (2,2) 走"右"(r = 0),落到 (2,3),此时 ("下"那格):
三个数: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 随机近似条件
Watkins(1989,Q-learning 的发明人)证明:满足这组条件 + 无限访问的 Q-learning 以概率 1 收敛到 。实践里“ε 从 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 对噪声是单向放大的:
(max 是凸函数,琴生不等式的直接推论。)
手算:两个动作的真实值都是 0,估计各带独立的 ±0.1 均匀噪声——两个估计值的 max 平均约为 +0.05 > 0 = max(E, E)。噪声被 max 只往上推:运气好的动作被高估,而 target 永远引用“当前最高的那个”估计——高估被写回 Q 表、再被下一轮 max 引用,Q 值系统性偏高。这叫过估计偏差(maximization bias),γ 越大、噪声越大越严重。
朴素解法是双 Q 学习(Double Q-learning):维护两张独立 Q 表,一张选动作(“谁分最高”)、另一张给这个动作估值(“他值多少”)——评委和打分员分开,噪声不再自证。本篇环境确定、TD 信号无噪声(),过估计不会现身;但走到 DQN(Q 表换神经网络,估计噪声巨大)时它是头号妖怪——届时这张底牌直接回收(练习 6)。
5. GridWorld:RL 的双月牙
5.1 规则(一张 4×4 的地图)
1 | S . . . S = 起点 (0,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 | class GridWorld |
环境是纯函数:(状态, 动作) → (新状态, 奖励, 是否结束),不保存 agent 位置(agent 自己记)。这让"手动喂一步"(实验 0 对账)和训练循环用同一个接口——能对账的代码才是可信任的代码(全课程一以贯之的实验 0 传统)。
6.2 agent:QLearning
Q 表就是一张 Matrix(stateCount × 4)——Part 2 的矩阵从第 2 章干到第 13 章,最后一份工作还是它。
1 | class QLearning |
| 数学 | 代码 |
|---|---|
updateStep 三行:算 target、算误差、加回去 |
|
| (done 时 max 项 = 0) | target = done ? reward : reward + gamma * q(next).max() |
| ε-greedy | chooseAction:rng() < ε ? 随机 : argmax |
greedyAction |
7. 实验(main.cpp,输出 output/part13/)
- 手算对账:4.2 节三步——Q((2,3),下) 0 → 0.5 → 0.75,再 Q((2,2),右) = 0.3375。逐位对账,确认更新规则没写错;
- 学习曲线:2000 局,ε 从 1.0 线性衰减到 0.05;每 50 局记录滑窗成功率和平均回报——看"先乱走、后收敛"的完整过程(
learning_curve.png); - 看它怎么想:训练后的值函数热图(每格 ,一圈圈亮起来的“势能场”)+ 每格的贪心箭头(策略图),叠加最优路径(
value_policy.png)。终点最亮、陷阱最暗、值沿路径衰减——3.5 节的预测眼见为实。
8. 练习(强烈建议动手)
- γ 视野实验:γ = 0(纯贪心)agent 学得出来吗?γ = 0.99 呢?对照热图看"光圈"大小的变化——亲手拧 Part 6 全家福里最后这个旋钮;
- 陷阱移位:把陷阱挪到 (1,1)(必经之路旁边),重训——策略绕行的弯有多大?γ 拧小会不会开始冒险穿窄缝?
- α 的影响:α = 1.0(一步到位)vs α = 0.1(小碎步)——学习曲线的方差差多少?为什么 α = 1 在确定性环境里反而没问题(提示:本篇环境的转移是确定的,TD 信号无噪声);
- 手算:若 α = 0.1,4.2 节第一步的 Q 变成多少?(;同样三步之后远没到位——α 是"信这次体验几分");
- 思考:Q-learning 为什么是 off-policy?如果把更新规则里的 max 换成“实际走的下一个动作的 Q”(SARSA),agent 的性格会变保守还是激进?(提示:SARSA 连“探索的险”也要计入值。)
- 过估计现身:给环境加 10% 滑倒概率(每步有 1/10 机会随机转向),重跑实验 1,对比训练后的 Q 值与理论值——看 max 是否把它系统性抬高;再实现双 Q 学习(两张表轮流更新,target 用另一张表估值)对照修正效果。
9. 小结 + 全课程毕业
| 概念 | 一句话 | 代码落点 |
|---|---|---|
| 回报 G | 折扣加总的未来奖励:γ 越大越有远见 | runEpisode 累计 |
| 贝尔曼方程 | :大问题 = 一步 + 子问题 | 3.2 节推导 |
| V ↔ Q | ;:一本账两张读法 | 3.4 节 |
| Q* / 贝尔曼最优方程 | updateStep 的 target |
|
| TD 误差 | 现实 − 预期:预测错了多少就改多少 | updateStep 中括号 |
| Q-learning | 采样代替期望 + α 小步逼近贝尔曼解 | updateStep |
| ε-greedy | ε 概率乱走保证每个动作都被试过 | chooseAction |
| 过估计偏差 | :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 章零件的新组合——路已经修到门口了。
反馈式学习:对任何概念有疑问,直接问我;我会据此更新本文档与代码。
