第 10 章 人工智能、机器学习与优化
第 91 节 经典机器学习
901. k-Means 聚类
k-Means 聚类是最简单、最流行的无监督学习算法之一。它将数据划分为 k 个簇,使得每个点都属于离其最近的质心所在的簇。你可以把它想象成寻找将相似点聚集在一起的“引力中心”。
我们要解决什么问题?
我们经常拥有没有标签的原始数据,并希望发现自然的分组。 k-Means 回答了这个问题:“哪些点属于同一组?”
给定:
- 一个点的数据集 $X = {x_1, x_2, \dots, x_n}$
- 目标簇的数量 $k$
我们希望找到 $k$ 个质心 $\mu_1, \mu_2, \dots, \mu_k$,以最小化总平方距离:
$$ \text{目标函数:} \min_{\mu} \sum_{i=1}^{n} \lVert x_i - \mu_{c(i)} \rVert^2 $$
其中 $c(i)$ 是点 $x_i$ 被分配的簇。
它是如何工作的(通俗解释)?
想象在你的数据中撒下 k 颗种子。每个点选择离它最近的种子。然后种子移动到其所属点的中心。重复此过程直到种子不再移动。这就是 k-Means。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 | 选择 k 个质心(随机或智能初始化) |
| 2 | 分配步骤 | 将每个点分配到最近的质心 |
| 3 | 更新步骤 | 将每个质心移动到其所属点的均值位置 |
| 4 | 重复 | 直到质心停止变化(收敛) |
示例(2 个簇):
| 迭代次数 | 质心 1 | 质心 2 | 描述 |
|---|---|---|---|
| 0 | 随机点 | 随机点 | 从任意处开始 |
| 1 | 移动到簇的均值位置 | 移动到簇的均值位置 | 簇进行调整 |
| 2 | 微小变化 | 微小变化 | 已收敛 |
微型代码(简易版本)
Python
import numpy as np
def kmeans(X, k, iters=100):
n = X.shape[0]
centroids = X[np.random.choice(n, k, replace=False)]
for _ in range(iters):
distances = np.linalg.norm(X[:, None] - centroids[None, :], axis=2)
labels = np.argmin(distances, axis=1)
new_centroids = np.array([X[labels == j].mean(axis=0) for j in range(k)])
if np.allclose(centroids, new_centroids):
break
centroids = new_centroids
return centroids, labelsC(概要)
// 简单的伪代码风格结构
for (iter = 0; iter < MAX_ITER; iter++) {
assign_points_to_nearest_centroid(); // 将点分配到最近的质心
recompute_centroids_as_means(); // 将质心重新计算为均值
if (centroids_converged()) break; // 如果质心收敛则退出循环
}为什么它很重要
- 揭示未标记数据中的隐藏结构。
- 是聚类、图像压缩和矢量量化的基础。
- 建立对迭代优化(EM 算法、Lloyd 算法)的直观理解。
- 运行快速,易于实现,广泛用作基线方法。
一个温和的证明(为什么它有效)
每次迭代都会减少平方距离的总和。
- 分配步骤:选择最近的质心永远不会增加成本。
- 更新步骤:移动到均值位置可以最小化平方误差。
由于可能的聚类方式是有限的,算法必须收敛(尽管不一定收敛到全局最优解)。
目标函数: $$ J = \sum_{i=1}^n | x_i - \mu_{c(i)} |^2 $$
每一步都有: ( J_{\text{new}} \le J_{\text{old}} )
亲自尝试
- 在二维点上运行:((1,1), (1.5,2), (5,8), (8,8)),设置 (k=2)。
- 尝试随机初始化与 k-Means++ 初始化。
- 绘制收敛后的簇和质心。
- 增加 (k):簇的大小会发生什么变化?
- 与层次聚类的结果进行比较。
测试用例
| 数据集 | k | 预期行为 |
|---|---|---|
| 4 个点,分成 2 组 | 2 | 分成 2 个簇 |
| 所有点都在一条线上 | 3 | 分成若干段 |
| 相同的点 | 2 | 两个质心收敛到同一点 |
| 随机点云 | 3 | 形成大致相等的分区 |
复杂度
- 时间: ( O(n \times k \times d \times t) ) (n 个点,k 个簇,d 个维度,t 次迭代)
- 空间: ( O(n + k) )
k-Means 聚类是你观察结构的透镜,一个简单的“分配与更新”循环,能在没有标签的地方揭示模式。
902. k-Medoids (PAM)
k-Medoids 聚类类似于 k-Means,但它不是使用点的均值作为中心,而是选择实际的数据点(中心点)作为聚类的代表。这使得它对异常值和非欧几里得距离更加鲁棒。
我们要解决什么问题?
有时点的“均值”没有意义,特别是当:
- 数据存在扭曲平均值的异常值时。
- 距离不是欧几里得距离时(例如编辑距离、曼哈顿距离)。
- 我们希望聚类中心是数据集中的真实点。
k-Medoids 通过选择实际的样本作为中心(中心点)来最小化总相异度,从而解决这个问题:
$$ \text{目标: } \min_{M} \sum_{i=1}^n d(x_i, m_{c(i)}) $$
其中 ( M = {m_1, \dots, m_k} ) 是中心点,( d(\cdot,\cdot) ) 是任意距离度量。
它是如何工作的(通俗解释)?
可以把 k-Medoids 看作是“寻找最具代表性的中心点”。 每个聚类选择一个点作为中心点,该点使得到所有其他点的总距离最小。
逐步说明(PAM:围绕中心点的划分):
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 | 随机选择 k 个点作为中心点 |
| 2 | 分配 | 将每个点分配到最近的中心点 |
| 3 | 交换 | 对于每个非中心点,尝试与一个中心点交换 |
| 4 | 评估 | 如果交换降低了总成本,则接受它 |
| 5 | 重复 | 直到找不到更好的交换(收敛) |
示例(k=2):
| 迭代次数 | 中心点 | 描述 |
|---|---|---|
| 0 | 随机选择 2 个点 | 从任意点开始 |
| 1 | 重新分配聚类,测试交换 | 减少总距离 |
| 2 | 没有更好的交换 | 停止 |
微型代码(简易版本)
Python(简化版 PAM)
import numpy as np
def pam(X, k, dist_fn):
n = len(X)
medoids = np.random.choice(n, k, replace=False)
while True:
# 将点分配到最近的中心点
distances = np.array([[dist_fn(X[i], X[m]) for m in medoids] for i in range(n)])
labels = np.argmin(distances, axis=1)
# 尝试交换
improved = False
for i in range(n):
if i in medoids: continue
for m in medoids:
new_medoids = medoids.copy()
new_medoids[new_medoids == m] = i
new_cost = sum(dist_fn(X[j], X[new_medoids[labels[j]]]) for j in range(n))
old_cost = sum(dist_fn(X[j], X[medoids[labels[j]]]) for j in range(n))
if new_cost < old_cost:
medoids = new_medoids
improved = True
if not improved:
break
return medoids, labels为什么它很重要
- 对噪声和异常值鲁棒,中心点不会被极端值拉偏。
- 适用于任何距离度量(欧几里得、余弦、编辑距离)。
- 用于生物信息学、文本聚类和异常检测。
- 是从 k-Means 到更灵活聚类方法的重要概念桥梁。
一个温和的证明(为什么它有效)
每次交换都保证不会增加总成本: $$ J = \sum_{i=1}^{n} d(x_i, m_{c(i)}) $$ 由于可能的中心点集合数量是有限的,并且每次迭代都严格改进或保持成本不变,因此算法收敛到一个局部最小值。
与 k-Means 不同,它不需要求平均值,只需要进行距离比较。
亲自尝试
- 在二维点上使用曼哈顿距离运行 k-Medoids。
- 添加一个异常值,观察中心点是否抵抗偏移。
- 将结果与 k-Means(相同的 k)进行比较。
- 在字符串数据上测试(例如 Levenshtein 距离)。
- 将中心点可视化为“选定的代表”。
测试用例
| 数据集 | k | 度量 | 预期行为 |
|---|---|---|---|
| 包含异常值的点 | 2 | 欧几里得 | 中心点忽略异常值 |
| 字符串 ("cat", "bat", "rat") | 2 | 编辑距离 | 根据相似性聚类 |
| 点构成的线 | 3 | 曼哈顿 | 中心是实际的数据点 |
| 随机散点 | 2 | 欧几里得 | 像 k-Means 一样划分,但基于中心点 |
复杂度
- 时间: ( O(k (n-k)^2) ) (PAM)
- 空间: ( O(n) )
k-Medoids 聚类寻找的是真实的范例,而不是平均值,这种方法忠实于你的数据,并能坚定地抵抗异常值。
903. 高斯混合模型(EM)
高斯混合模型(GMM)将聚类带入了概率世界。它不让每个点只属于一个簇,而是让每个点以不同的概率属于多个簇。该模型假设数据是由多个高斯分布的混合生成的。
我们要解决什么问题?
有时簇会重叠或边界模糊。硬分配(如 k-Means 中的做法)可能会产生误导。 我们想要软聚类,即每个数据点都有一个属于每个簇的概率。
给定:
- 数据点 ( X = {x_1, x_2, \dots, x_n} )
- 选定的分量数量 ( k )
我们建模: $$ p(x) = \sum_{j=1}^k \pi_j , \mathcal{N}(x \mid \mu_j, \Sigma_j) $$ 其中:
- ( \pi_j ):权重(混合比例)
- ( \mu_j ):分量 ( j ) 的均值
- ( \Sigma_j ):分量 ( j ) 的协方差
它是如何工作的(通俗解释)?
可以把 GMM 看作“软 k-Means”。 每个点不是被分配到一个簇,而是获得部分隶属度,例如“70% 属于簇 A,30% 属于簇 B”。
我们使用期望最大化(EM)算法来估计参数。
| 步骤 | 名称 | 描述 |
|---|---|---|
| 1 | 初始化 | 选择 ( \mu_j, \Sigma_j, \pi_j ) |
| 2 | E步(期望) | 计算隶属概率(责任) |
| 3 | M步(最大化) | 使用加权平均值更新参数 |
| 4 | 重复 | 直到收敛(对数似然稳定) |
E步公式: $$ \gamma_{ij} = \frac{\pi_j \mathcal{N}(x_i \mid \mu_j, \Sigma_j)}{\sum_{l=1}^k \pi_l \mathcal{N}(x_i \mid \mu_l, \Sigma_l)} $$
M步更新: $$ \mu_j = \frac{\sum_i \gamma_{ij} x_i}{\sum_i \gamma_{ij}}, \quad \Sigma_j = \frac{\sum_i \gamma_{ij} (x_i - \mu_j)(x_i - \mu_j)^T}{\sum_i \gamma_{ij}}, \quad \pi_j = \frac{1}{n} \sum_i \gamma_{ij} $$
微型代码(简易版本)
Python(使用 NumPy,最小化 EM)
import numpy as np
from scipy.stats import multivariate_normal
def gmm_em(X, k, iters=100):
n, d = X.shape
# 初始化
np.random.seed(0)
mu = X[np.random.choice(n, k, replace=False)]
sigma = [np.eye(d)] * k
pi = np.ones(k) / k
for _ in range(iters):
# E步
gamma = np.zeros((n, k))
for j in range(k):
gamma[:, j] = pi[j] * multivariate_normal.pdf(X, mu[j], sigma[j])
gamma /= gamma.sum(axis=1, keepdims=True)
# M步
Nk = gamma.sum(axis=0)
for j in range(k):
mu[j] = (gamma[:, j][:, None] * X).sum(axis=0) / Nk[j]
x_centered = X - mu[j]
sigma[j] = (gamma[:, j][:, None, None] *
np.einsum('ni,nj->nij', x_centered, x_centered)).sum(axis=0) / Nk[j]
pi[j] = Nk[j] / n
return mu, sigma, pi为什么它很重要
- 自然地处理重叠的簇。
- 产生概率分配而非硬标签。
- 通过协方差支持椭圆形(不仅仅是球形)簇。
- 是 EM 算法、软聚类和潜变量模型的基础。
- 用于语音识别、图像分割和密度估计。
一个温和的证明(为什么它有效)
EM 算法交替进行:
- E步:估计隐藏变量(责任)。
- M步:在给定责任的情况下最大化似然。
每次迭代都不会降低数据的对数似然: $$ \log p(X \mid \theta) = \sum_{i=1}^{n} \log \sum_{j=1}^{k} \pi_j \mathcal{N}(x_i | \mu_j, \Sigma_j) $$ 因此,保证收敛(尽管可能收敛到局部最大值)。
亲自尝试
- 用重叠的高斯分布对二维点进行聚类。
- 可视化椭圆((\Sigma_j))以查看簇的形状。
- 在同一数据上比较 GMM 聚类与 k-Means。
- 改变 (k):观察欠拟合与过拟合。
- 添加少量噪声,GMM 比 k-Means 处理得更好。
测试用例
| 数据集 | k | 行为 |
|---|---|---|
| 二维斑点(重叠) | 2 | 平滑边界 |
| 非球形簇 | 3 | 椭圆形 |
| 完全分离的数据 | 2 | 与 k-Means 匹配 |
| 单一高斯分布 | 1 | 学习均值和协方差 |
复杂度
- 时间:每次迭代 ( O(nkd) )
- 空间: ( O(nk) )(责任)
高斯混合模型将几何与概率融合在一起,它不是强制将点放入盒子,而是让它们属于它们最可能适合的地方。
904. 朴素贝叶斯分类器
朴素贝叶斯是一种简单而强大的概率分类器,它基于贝叶斯定理,并做了一个大胆的假设:在给定类别的情况下,所有特征都是条件独立的。尽管这个假设很"朴素",但它在文本分类、垃圾邮件过滤和许多现实世界任务中表现得出奇地好。
我们要解决什么问题?
我们想根据一组特征来预测一个类别标签,使用的是概率,而不是距离或超平面。
给定:
- 训练数据
(x_i, y_i) - 特征
x = (x_1, x_2, \dots, x_d) - 标签
y \in {1, 2, \dots, K}
我们想要: $$ \hat{y} = \arg\max_y P(y \mid x) $$
根据贝叶斯定理: $$ P(y \mid x) = \frac{P(x \mid y) P(y)}{P(x)} $$
由于 P(x) 在各个类别中是常数: $$ \hat{y} = \arg\max_y P(y) , P(x \mid y) $$
在特征独立的假设下: $$ P(x \mid y) = \prod_{j=1}^d P(x_j \mid y) $$
因此,我们只需要每个特征在给定类别下的条件概率。
它是如何工作的(通俗解释)?
朴素贝叶斯分别查看每个特征,将它们在一个类别下出现的可能性相乘,然后选择综合概率最大的那个类别。
逐步说明:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 计数 | 估计 P(y) = 类别频率 |
| 2 | 估计 | 对于每个特征,估计 P(x_j \mid y) |
| 3 | 预测 | 对于一个新样本,计算 P(y) \prod_j P(x_j \mid y) |
| 4 | 选择 | 选择概率最高的类别 |
示例(垃圾邮件过滤):
- 类别:垃圾邮件 / 非垃圾邮件
- 特征:像 "buy"、"free"、"click" 这样的词
- 每个词都为垃圾邮件的概率增加权重。
微型代码(简易版本)
Python(离散朴素贝叶斯)
from collections import defaultdict, Counter
class NaiveBayes:
def __init__(self):
self.class_counts = Counter()
self.feature_counts = defaultdict(Counter)
self.total = 0
def fit(self, X, y):
self.total = len(y)
for xi, yi in zip(X, y):
self.class_counts[yi] += 1
for f in xi:
self.feature_counts[yi][f] += 1
def predict(self, x):
scores = {}
for c in self.class_counts:
log_prob = 0
for f in x:
count = self.feature_counts[c][f]
total = sum(self.feature_counts[c].values())
log_prob += np.log((count + 1) / (total + len(self.feature_counts[c])))
prior = np.log(self.class_counts[c] / self.total)
scores[c] = prior + log_prob
return max(scores, key=scores.get)为什么它很重要
- 快速:简单的计数,无需迭代。
- 可扩展:在大规模文本语料上效果很好。
- 鲁棒:能处理高维稀疏数据。
- 可解释:概率可以解释预测结果。
- 通用:有多种变体可以处理连续(高斯)或多重数据。
常见变体:
| 类型 | 使用场景 |
|---|---|
| 伯努利 | 二元特征(词是否出现) |
| 多项式 | 词频计数(文本) |
| 高斯 | 连续数据(例如传感器读数) |
一个温和的证明(为什么它有效)
根据贝叶斯定理: $$ P(y|x) \propto P(y) \prod_{j=1}^d P(x_j|y) $$
独立性假设将联合概率简化为每个特征项的乘积,从而将指数级的复杂度降低到线性级。 尽管独立性假设很少成立,但当相对似然度正确时,得到的分类器仍然表现良好,这是概率代数的一个幸运巧合。
亲自尝试
- 用 "free"、"offer"、"hello" 构建一个垃圾邮件检测器。
- 为小型数据集手动计算概率。
- 在数值特征上尝试高斯版本。
- 与逻辑回归比较准确率。
- 观察拉普拉斯平滑如何影响零计数。
测试用例
| 数据集 | 变体 | 预期结果 |
|---|---|---|
| 文本垃圾邮件检测 | 多项式 | 良好的准确率 |
| 二元词特征 | 伯努利 | 鲁棒的预测 |
| 传感器数据 | 高斯 | 平滑的决策边界 |
| 小型数据集 | 任意 | 需要平滑处理 |
复杂度
- 训练:
O(nd)(计数) - 预测:每个样本
O(kd) - 空间:
O(kd)
朴素贝叶斯是你的概率指南针,简单的计数和乘法将不确定性转化为决策。
905. 逻辑回归
逻辑回归是分类任务的主力军,它是一个简单而强大的模型,用于预测概率而非原始分数。它使用逻辑(Sigmoid)函数在特征空间中绘制决策边界,将线性组合映射到 (0, 1) 的范围内。
我们要解决什么问题?
我们希望根据特征向量 $x \in \mathbb{R}^d$ 来预测一个二元标签 $y \in {0, 1}$,
其中输出是一个概率,而非离散的类别。
我们建模如下: $$ P(y = 1 \mid x) = \sigma(w^\top x + b) $$ 其中: $$ \sigma(z) = \frac{1}{1 + e^{-z}} $$ 是 Sigmoid 函数。
决策规则: $$ \hat{y} = \begin{cases} 1, & \text{if } \sigma(w^\top x + b) > 0.5,\ 0, & \text{otherwise.} \end{cases} $$
我们通过最大似然估计来学习 ( w, b ),这等价于最小化对数损失: $$ L(w, b) = -\frac{1}{n} \sum_{i=1}^{n} \big[ y_i \log \hat{p}_i + (1 - y_i) \log (1 - \hat{p}_i) \big] $$
它是如何工作的(通俗解释)?
想象一下拟合一条平滑的 S 形曲线来分隔两个类别。逻辑回归不是给出硬性截断,而是为每个预测提供置信度。
逐步过程:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 初始化 | 从随机权重 ( w, b ) 开始 |
| 2 | 预测 | 计算 $\hat{p}_i = \sigma(w^\top x_i + b)$ |
| 3 | 计算损失 | 预测标签与真实标签之间的交叉熵 |
| 4 | 更新 | 使用梯度下降调整权重 |
| 5 | 重复 | 直到损失收敛 |
梯度更新: $$ w := w - \eta \frac{\partial L}{\partial w} = w - \eta (X^\top (\hat{p} - y)) $$ $$ b := b - \eta \sum_i (\hat{p}_i - y_i) $$
微型代码(简易版本)
Python(从零开始)
import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def logistic_regression(X, y, lr=0.1, epochs=1000):
n, d = X.shape
w = np.zeros(d)
b = 0
for _ in range(epochs):
z = X @ w + b
p = sigmoid(z)
dw = (1/n) * X.T @ (p - y)
db = (1/n) * np.sum(p - y)
w -= lr * dw
b -= lr * db
return w, b
def predict(X, w, b):
return (sigmoid(X @ w + b) >= 0.5).astype(int)C(概述)
// 对于每次迭代:
// 1. 计算 z = w·x + b
// 2. 计算 p = 1 / (1 + exp(-z))
// 3. 计算梯度 dw, db
// 4. 更新参数为什么它很重要
- 可解释性强:系数显示了特征的影响。
- 概率输出:不同于硬间隔模型。
- 神经网络的基础(Sigmoid 神经元)。
- 适用于大规模分类,效率高。
- 可扩展性强:支持正则化(L1, L2)。
变体:
| 类型 | 描述 |
|---|---|
| L1 (Lasso) | 稀疏权重 |
| L2 (Ridge) | 平滑正则化 |
| 多项逻辑回归 | 通过 Softmax 进行多分类 |
一个温和的证明(为什么它有效)
逻辑回归源于对伯努利分布标签的最大似然估计: $$ P(y_i \mid x_i) = \hat{p}_i^{y_i} (1 - \hat{p}_i)^{1 - y_i} $$
最大化似然等价于最小化对数损失。 梯度下降保证了收敛到一个凸的全局最小值,没有局部陷阱。
自己动手试试
- 创建一个包含两个类别的二维数据集。
- 绘制 Sigmoid 边界和概率。
- 添加正则化,观察特征权重收缩。
- 在相同数据上与朴素贝叶斯进行比较。
- 使用 Softmax 扩展到多分类。
测试用例
| 数据集 | 预期边界 | 备注 |
|---|---|---|
| 线性可分 | 直线 | 完美分离 |
| 类别重叠 | 平滑过渡 | 概率性 |
| 高维文本 | 稀疏权重 | L1 正则化 |
| 类别不平衡 | 偏向多数类 | 使用类别权重 |
复杂度
- 训练:每轮迭代 ( O(nd) )
- 预测:每个样本 ( O(d) )
- 空间: ( O(d) )
逻辑回归是你从几何思维通往概率思维的桥梁,它绘制出的决策曲线思考的是置信度,而非绝对答案。
906. 感知机
感知机是最早、最简单的神经元模型之一,是一种通过试错学习的线性分类器。它绘制一个超平面来分隔两个类别,每当犯错时就调整其权重。
我们要解决什么问题?
给定特征向量 $x \in \mathbb{R}^d$,我们希望找到一个线性边界来分隔两个类别 $y \in {-1, +1}$。
感知机寻找权重 $w$ 和偏置 $b$,使得:
$$ y_i (w^\top x_i + b) > 0 \quad \forall i $$
这意味着所有正例位于边界的一侧,所有负例位于另一侧。
它是如何工作的(通俗解释)?
想象一条(或一个)对点进行分类的直线(或平面)。
如果一个点被错误分类,感知机会逐步将直线向该点“推近”,直到所有点都被正确分类(如果可能的话)。
逐步说明:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 初始化 | 设置 $w = 0$, $b = 0$ |
| 2 | 迭代 | 对于每个训练样本: |
| 3 | 预测 | $\hat{y} = \text{sign}(w^\top x + b)$ |
| 4 | 更新 | 如果错误,则调整:$w \gets w + \eta y x$, $b \gets b + \eta y$ |
| 5 | 重复 | 直到没有错误或达到最大迭代次数 |
每次更新都将边界向被错误分类的点移动,从而改善对齐情况。
微型代码(简易版本)
Python
import numpy as np
def perceptron(X, y, lr=1.0, epochs=100):
n, d = X.shape
w = np.zeros(d)
b = 0
for _ in range(epochs):
errors = 0
for xi, yi in zip(X, y):
if yi * (np.dot(w, xi) + b) <= 0:
w += lr * yi * xi
b += lr * yi
errors += 1
if errors == 0:
break
return w, b
def predict(X, w, b):
return np.sign(X @ w + b)C (大纲)
// 对于每个 (x_i, y_i):
// 如果 y_i * (dot(w, x_i) + b) <= 0:
// w = w + lr * y_i * x_i
// b = b + lr * y_i为什么它很重要
- 神经网络的历史基石。
- 简单的在线学习:仅在犯错时更新。
- 如果数据线性可分,则收敛。
- 现代模型(SVM、SGDClassifier)的基础。
你可以把它想象成一个“单神经元大脑”,小巧、快速,并且在边界是线性时出奇地有效。
一个温和的证明(为什么它有效)
如果数据是线性可分的,感知机会在有限步数内收敛。
令间隔为:
$$ \gamma = \min_i \frac{y_i (w^* \cdot x_i)}{\lVert w^* \rVert} $$
那么更新次数 $U$ 满足:
$$ U \le \left(\frac{R}{\gamma}\right)^2 $$
其中 $R = \max_i \lVert x_i \rVert$。
每次犯错都会改善与真实分隔器的对齐,因此算法不会无限循环。
亲自尝试
- 在二维线性可分数据上训练(例如,两个不同的簇)。
- 可视化每个迭代周期后的决策边界。
- 翻转几个标签以观察不收敛的情况。
- 尝试不同的学习率,例如 $\eta = 0.1$ 和 $\eta = 1.0$。
- 在相同数据上与逻辑回归比较行为。
测试用例
| 数据集 | 预期行为 |
|---|---|
| 线性可分 | 收敛到正确的超平面 |
| 重叠类别 | 振荡或永不收敛 |
| 高维 | 如果可分则学习 |
| 随机噪声 | 可能不收敛 |
复杂度
- 时间:$O(nd \times \text{epochs})$
- 空间:$O(d)$
感知机是你对学习机器的第一瞥,一个看到错误、学习并继续前进的简单规则。
907. 决策树 (CART)
决策树通过逐步分割数据,形成一个决策的层次结构,用于分类或预测结果。每个节点都会针对某个特征提出一个是/否问题,将数据不断划分,直到数据变得"纯净",即主要属于一个类别或数值紧密聚集。
我们要解决什么问题?
我们想要一个这样的模型:
- 可解释性强 — 推理过程在树结构中清晰可见。
- 灵活 — 能够处理数值型和分类型数据。
- 递归性 — 通过将数据分割成更小、更简单的子集来构建结构。
给定训练数据 $(x_i, y_i)$,目标是找到一系列分割,以最小化不纯度或方差。 形式化地说,在每个节点,我们选择一个特征 $j$ 和一个阈值 $t$ 来最小化:
$$ \min_{j, t} \Bigg( \frac{n_L}{n} \cdot \text{Impurity}(\text{left}) + \frac{n_R}{n} \cdot \text{Impurity}(\text{right}) \Bigg) $$
其中 $n_L$ 和 $n_R$ 分别是左子集和右子集的大小。
分类任务中常用的不纯度度量:
基尼不纯度 $$ G = 1 - \sum_c p_c^2 $$
熵 $$ H = -\sum_c p_c \log_2 p_c $$
对于回归任务,不纯度通常用方差来衡量。
它是如何工作的(通俗解释)?
可以把决策树想象成一个流程图。每个节点提出一个简单的是/否问题, 例如"特征 $x_j \le t$ 吗?",并根据答案将样本发送到左边或右边。 算法递归地重复这个过程,在每个节点选择最佳的问题。
逐步过程(CART 算法):
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 开始 | 所有数据位于根节点 |
| 2 | 搜索分割点 | 对于每个特征 $j$ 和阈值 $t$,计算不纯度减少量 |
| 3 | 最佳分割 | 选择能最大化不纯度增益的 $(j, t)$ |
| 4 | 分割 | 将数据集分割成左/右子集 |
| 5 | 重复 | 对每个子集递归执行,直到满足停止规则 |
| 6 | 标记叶节点 | 分配多数类或平均值 |
停止规则包括:
- 达到最大深度
- 叶节点中样本数达到最小值
- 不纯度没有改善
微型代码(简易版本)
Python(简化的二叉树分割点查找器)
import numpy as np
def gini(y):
_, counts = np.unique(y, return_counts=True)
p = counts / len(y)
return 1 - np.sum(p 2)
def best_split(X, y):
best_gain, best_j, best_t = 0, None, None
base_impurity = gini(y)
n, d = X.shape
for j in range(d):
thresholds = np.unique(X[:, j])
for t in thresholds:
left = y[X[:, j] <= t]
right = y[X[:, j] > t]
if len(left) == 0 or len(right) == 0:
continue
impurity = (len(left) * gini(left) + len(right) * gini(right)) / len(y)
gain = base_impurity - impurity
if gain > best_gain:
best_gain, best_j, best_t = gain, j, t
return best_j, best_t, best_gainC(大纲)
// 对于每个节点:
// 1. 计算节点处的不纯度
// 2. 对于每个特征,测试可能的阈值
// 3. 选择不纯度减少量最高的分割点
// 4. 在左子集和右子集上递归执行为什么它很重要
- 可解释性强:每次分割都是一条人类可读的规则。
- 非线性边界:树可以表示分段决策区域。
- 用途广泛:支持分类和回归任务。
- 可扩展的基础:用于随机森林和梯度提升。
- 无需预处理:不需要对特征进行归一化或缩放。
一个温和的证明(为什么它有效)
每次分割都会减少总不纯度:
$$ \Delta = I_{\text{parent}} - \frac{n_L}{n} I_{\text{left}} - \frac{n_R}{n} I_{\text{right}} $$
因为不纯度度量 $I(\cdot)$ 是非负的,并且分割总是会降低不纯度(或者在无改善时停止),所以算法最终会达到一个无法进一步获得增益的点。因此,决策树会收敛到一个具有局部最优分割的结构。
亲自动手试试
- 在一个简单数据集(例如两个簇)上训练一棵树。
- 打印规则:"如果特征 ≤ 阈值,则向左走。"
- 限制最大深度以避免过拟合。
- 比较同一数据上基尼不纯度与熵的效果。
- 在二维空间中可视化决策边界。
测试用例
| 数据集 | 预期行为 |
|---|---|
| 线性可分数据 | 单个根节点分割 |
| 分类型与数值型混合数据 | 两者都能处理 |
| 过拟合示例 | 深度树会记住数据 |
| 经过剪枝的树 | 泛化能力更好 |
复杂度
- 训练:$O(n d \log n)$
- 预测:$O(\text{depth})$
- 空间:与节点数成正比
决策树是一种分而治之的学习器,每个问题都在分割不确定性,划出清晰的区域,直到数据被整齐地分类。
908. ID3 算法
ID3(迭代二分器 3)算法使用信息增益来构建决策树,信息增益衡量了一个特征在多大程度上帮助减少不确定性(熵)。它是最早且最具影响力的树学习算法之一,为后来的方法(如 C4.5 和 CART)奠定了基础。
我们要解决什么问题?
我们希望学习一个分类树,它能通过信息丰富的划分来解释数据。 每次划分都应最大化熵的减少,使得产生的子集尽可能纯净。
给定一个包含类别 $C_1, C_2, \ldots, C_k$ 的数据集 $D$,该集合的熵为:
$$ H(D) = - \sum_{c=1}^{k} p_c \log_2 p_c $$
其中 $p_c$ 是属于类别 $C_c$ 的样本比例。
当我们使用一个可能取值为 ${v_1, v_2, \dots, v_m}$ 的特征 $A$ 来划分 $D$ 时, 信息增益为:
$$ \text{Gain}(D, A) = H(D) - \sum_{i=1}^{m} \frac{|D_{v_i}|}{|D|} , H(D_{v_i}) $$
我们选择能最大化 $\text{Gain}(D, A)$ 的特征 $A$。
它是如何工作的(通俗解释)?
可以把 ID3 想象成一个“二十个问题”的学习者,每个问题(特征)都会划分数据,使其更具可预测性。 它总是先选择信息量最大的问题,然后递归进行。
逐步说明:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 计算熵 | 衡量当前数据集的不纯度 |
| 2 | 对每个特征 | 计算划分后的期望熵 |
| 3 | 选择最佳特征 | 选择具有最大信息增益的特征 |
| 4 | 划分 | 按特征值划分数据 |
| 5 | 递归 | 为每个子集构建子树 |
| 6 | 停止 | 如果所有样本属于同一类别,或者没有剩余特征 |
示例
假设我们有天气数据:
| 天气状况 | 温度 | 湿度 | 风力 | 打球 |
|---|---|---|---|---|
| Sunny | Hot | High | Weak | No |
| Overcast | Cool | Normal | Strong | Yes |
| Rain | Mild | High | Weak | Yes |
ID3 计算 Play 的熵,评估每个特征的信息增益,并在信息增益最高的特征(例如 Outlook)上进行划分。
微型代码(简易版本)
Python(简化版 ID3)
import numpy as np
def entropy(y):
_, counts = np.unique(y, return_counts=True)
p = counts / len(y)
return -np.sum(p * np.log2(p + 1e-9))
def info_gain(X_col, y):
values, counts = np.unique(X_col, return_counts=True)
weighted_entropy = 0
for v, c in zip(values, counts):
subset = y[X_col == v]
weighted_entropy += (c / len(y)) * entropy(subset)
return entropy(y) - weighted_entropy
def best_feature(X, y):
gains = [info_gain(X[:, j], y) for j in range(X.shape[1])]
return np.argmax(gains)C(大纲)
// 1. 计算当前数据集的熵
// 2. 对每个特征,按值划分并计算加权熵
// 3. 选择具有最大增益的特征
// 4. 在子集上递归为什么它很重要
- 可解释性:产生可读的决策规则。
- 贪心但有效:选择最佳的局部划分。
- 基础性:构成了 C4.5(处理连续值、剪枝)的基础。
- 无需假设:直接根据数据频率工作。
ID3 是符号人工智能和机器学习领域的重要一步,它表明决策可以从数据本身学习。
一个温和的证明(为什么它有效)
熵衡量平均不确定性:
$$ H(D) = - \sum_c p_c \log_2 p_c $$
划分通过创建平均上更纯净的子集来减少熵。 因为 $\text{Gain}(D, A) \ge 0$,所以每次划分要么改善熵,要么保持不变。 当子集完全纯净($H = 0$)或没有剩余特征时,递归终止。
亲自尝试
- 构建一个小型数据集(如“打网球”)。
- 手动计算熵。
- 评估每个特征的信息增益。
- 画出生成的树。
- 与基于基尼系数的 CART 结果进行比较。
测试用例
| 数据集 | 预期行为 |
|---|---|
| 纯净的类别标签 | 立即停止 |
| 混合特征 | 选择信息量最大的特征 |
| 重复样本 | 一致处理 |
| 数值特征 | 需要离散化 |
复杂度
- 训练:$O(n d \log n)$(取决于划分)
- 预测:$O(\text{深度})$
- 空间:与节点数量成正比
ID3 算法通过首先提出正确的问题来学习,每次划分都是迈向确定性的一步,是从熵中构建知识的一步。
909. k-最近邻算法 (kNN)
k-最近邻算法通过查看训练数据中与新点最接近的样本来对其进行分类。它是一种简单的、基于记忆的方法:相似的点往往具有相同的标签。kNN 不学习参数,而是存储所有训练数据,并利用邻近性来推断预测。
我们要解决什么问题?
我们想要一种纯粹基于相似性的、非参数化的方式来进行分类或回归。
给定:
- 一个训练集 $(x_1, y_1), \dots, (x_n, y_n)$
- 一个距离函数 $d(x, x')$
- 一个选定的邻居数量 $k$
目标是为一个新的查询点 $x$ 预测 $\hat{y}$。
对于分类:
$$ \hat{y} = \arg\max_{c} \sum_{i \in \mathcal{N}_k(x)} \mathbf{1}(y_i = c) $$
对于回归:
$$ \hat{y} = \frac{1}{k} \sum_{i \in \mathcal{N}_k(x)} y_i $$
其中 $\mathcal{N}_k(x)$ 是与 $x$ 的 $k$ 个最近邻对应的索引集合。
它是如何工作的(通俗解释)?
想象一下把你的数据集画在一个平面上。当一个新的点出现时,你测量它与每个已知点的距离,选取 $k$ 个最近的,并使用它们的标签来做决定。这个模型不进行泛化,它记住数据。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 选择 $k$ | 决定要考虑多少个邻居 |
| 2 | 计算距离 | 测量查询点与所有训练点之间的距离 |
| 3 | 寻找邻居 | 选择 $k$ 个最近的样本 |
| 4 | 聚合 | 多数投票(分类)或取平均值(回归) |
| 5 | 预测 | 返回最终的标签或值 |
示例
假设 $k = 3$:
- 最近邻的标签:
[A, A, B] - 多数类:$A \Rightarrow \hat{y} = A$
对于回归:
- 最近邻的值:$[4.0, 5.0, 3.0]$
- 预测:
$$ \hat{y} = \frac{4.0 + 5.0 + 3.0}{3} = 4.0 $$
微型代码(简易版本)
Python(简易 kNN 分类器)
import numpy as np
from collections import Counter
def knn_predict(X_train, y_train, x_query, k=3):
distances = np.linalg.norm(X_train - x_query, axis=1)
k_idx = np.argsort(distances)[:k]
k_labels = y_train[k_idx]
most_common = Counter(k_labels).most_common(1)
return most_common[0][0]C(大纲)
// 对于每个查询点:
// 1. 计算到所有训练点的距离
// 2. 排序并选取 top-k 邻居
// 3. 取多数标签(分类)或平均值(回归)为什么它重要
- 直观:通过相似性进行分类
- 无需训练:所有计算都在查询时进行
- 用途广泛:适用于分类和回归
- 强大的基线:通常是与其他模型进行比较的第一个模型
一个温和的证明(为什么它有效)
如果 $n \to \infty$, $k \to \infty$, 且 $\frac{k}{n} \to 0$, 那么 kNN 分类器会逼近贝叶斯最优分类器,即理论上的最佳分类器。
推理:
- 邻居近似于 $x$ 附近的局部分布
- 多数投票收敛于最可能的类别
因此,在这些条件下,kNN 在统计上是一致的。
亲自尝试
- 生成一个包含两个彩色簇的二维数据集
- 尝试 $k = 1, 3, 5$ 并可视化决策边界
- 添加噪声并增大 $k$,观察平滑效果
- 尝试不同的距离度量(欧几里得距离、曼哈顿距离)
- 将结果与决策树或逻辑回归进行比较
测试用例
| 数据集 | $k$ | 行为 |
|---|---|---|
| 两个簇 | 1 | 尖锐、不规则的边界 |
| 两个簇 | 5 | 平滑的边界 |
| 带噪声的标签 | 大 | 更鲁棒 |
| 重叠的类别 | 小 | 灵活但不稳定 |
复杂度
- 训练:$O(1)$(惰性学习器)
- 预测:$O(n \cdot d)$(计算所有距离)
- 空间:$O(n \cdot d)$
k-最近邻算法是一种样本记忆:它不显式地学习任何东西,而是通过环顾四周来给出答案。
910. 线性判别分析 (LDA)
线性判别分析 (LDA) 是一种概率分类器,它通过将每个类别建模为高斯分布并假设它们共享相同的协方差矩阵,来寻找类别之间的线性边界。它结合了几何和概率,在似然平衡的地方绘制决策线。
我们要解决什么问题?
我们希望通过以下假设将样本分类到 $K$ 个类别中:
- 每个类别 $C_k$ 都服从高斯(正态)分布
- 所有类别共享同一个协方差矩阵 $\Sigma$
给定一个点 $x$,我们选择后验概率最高的类别:
$$ \hat{y} = \arg\max_k ; P(C_k \mid x) $$
根据贝叶斯定理:
$$ P(C_k \mid x) \propto P(x \mid C_k) , P(C_k) $$
由于 $P(x)$ 在各个类别中是常数,我们比较判别分数:
$$ \delta_k(x) = x^\top \Sigma^{-1} \mu_k - \frac{1}{2} \mu_k^\top \Sigma^{-1} \mu_k + \log P(C_k) $$
预测的类别是 $\delta_k(x)$ 最大的那个。
它是如何工作的(通俗解释)?
LDA 将每个类别想象成一个椭圆形状的云(高斯分布)。 它计算一个新点来自每个云的可能性,然后选择可能性最高的类别。 因为协方差是共享的,所以云之间的边界是线性的。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 估计均值 | $\mu_k = \frac{1}{n_k} \sum_{i \in C_k} x_i$ |
| 2 | 估计共享协方差矩阵 | $\Sigma = \frac{1}{n - K} \sum_{k=1}^K \sum_{i \in C_k} (x_i - \mu_k)(x_i - \mu_k)^\top$ |
| 3 | 估计先验概率 | $P(C_k) = \frac{n_k}{n}$ |
| 4 | 计算判别分数 | 为每个类别计算 $\delta_k(x)$ |
| 5 | 预测 | 选择 $\delta_k(x)$ 最大的类别 |
示例(二分类情况)
当 $K=2$ 时,决策边界是一条直线:
$$ (w^\top x) + w_0 = 0 $$
其中
$$ w = \Sigma^{-1}(\mu_1 - \mu_2), \quad w_0 = -\frac{1}{2} (\mu_1 + \mu_2)^\top \Sigma^{-1}(\mu_1 - \mu_2) + \log\frac{P(C_1)}{P(C_2)} $$
$(w^\top x + w_0)$ 的符号决定了预测的类别。
微型代码(简易版本)
Python (二分类 LDA)
import numpy as np
def lda_fit(X, y):
classes = np.unique(y)
n, d = X.shape
means = {c: X[y == c].mean(axis=0) for c in classes}
priors = {c: len(X[y == c]) / n for c in classes}
# 共享协方差矩阵
cov = np.zeros((d, d))
for c in classes:
Xc = X[y == c] - means[c]
cov += Xc.T @ Xc
cov /= (n - len(classes))
inv_cov = np.linalg.inv(cov)
return means, priors, inv_cov
def lda_predict(X, means, priors, inv_cov):
scores = []
for c in means:
term = X @ inv_cov @ means[c]
const = -0.5 * means[c].T @ inv_cov @ means[c] + np.log(priors[c])
scores.append(term + const)
return np.array(scores).argmax(axis=0)为什么它很重要
- 可解释性强:给出明确的线性边界。
- 概率化:输出类别后验概率。
- 高效:有闭式解(无需梯度下降)。
- 鲁棒性好:能很好地处理小数据集。
- 基础性强:是费希尔判别分析和 QDA 的基础。
一个温和的证明(为什么它有效)
根据贝叶斯规则:
$$ P(C_k \mid x) \propto \exp\left(-\frac{1}{2}(x - \mu_k)^\top \Sigma^{-1}(x - \mu_k)\right) P(C_k) $$
取对数并简化,由于 $\Sigma$ 是共享的,$x$ 的二次项会抵消。 结果是一个关于 $x$ 的线性函数:
$$ \delta_k(x) = x^\top \Sigma^{-1} \mu_k - \frac{1}{2} \mu_k^\top \Sigma^{-1} \mu_k + \log P(C_k) $$
因此,类别边界是线性超平面。
亲自尝试
- 创建一个包含两个高斯簇的二维数据集。
- 拟合 LDA 并绘制边界线。
- 与逻辑回归比较,注意相似之处。
- 添加不平衡性,检查先验概率如何移动边界线。
- 尝试 $K > 2$ 并可视化多类别区域。
测试用例
| 数据集 | 类别数 | 预期边界 |
|---|---|---|
| 两个高斯斑点 | 2 | 质心之间的直线 |
| 协方差不等 | 2 | LDA 会表现不佳(尝试 QDA) |
| 多类别 | 3 | 分段线性区域 |
| 类别不平衡 | 2 | 边界向多数类方向移动 |
复杂度
- 训练:$O(n d^2)$(协方差计算)
- 预测:每个样本 $O(K d^2)$
- 空间:$O(K d + d^2)$
线性判别分析是统计学与几何学的交汇点,边界从似然中浮现,用信念相等的直线将类别分开。
第 92 节. 集成方法
911. Bagging(自助聚集法)
Bagging,是 Bootstrap Aggregation 的缩写,是一种集成方法,它通过组合在数据的随机子集上训练的同一算法的多个版本来提高机器学习模型的稳定性和准确性。它对于像决策树这样的高方差模型特别有效。
我们要解决什么问题?
许多模型,尤其是决策树,是不稳定的,训练数据的微小变化可能会产生非常不同的结果。 Bagging 通过在不同的自助样本上训练多个模型并平均它们的预测来减少方差。
我们的目标是构建一个集成预测器:
$$ \hat{f}{\text{bag}}(x) = \frac{1}{B} \sum{b=1}^{B} \hat{f}^{(b)}(x) $$
其中每个 $\hat{f}^{(b)}$ 都在不同的自助样本(有放回的随机样本)上训练。
它是如何工作的(通俗解释)?
Bagging 就像收集许多意见。 每个模型看到一个略有不同的数据集(由于随机抽样),学习自己的视角,然后集成对所有预测进行平均或投票。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 有放回抽样 | 创建 $B$ 个自助数据集,每个数据集大小与原始数据集相同 |
| 2 | 训练模型 | 在每个样本上训练基学习器(例如,决策树) |
| 3 | 聚合预测 | 对于回归:取平均;对于分类:多数投票 |
| 4 | 最终输出 | 组合的集成预测 |
因为每个基模型看到的数据版本略有不同,所以当聚合时,它们的错误会部分抵消。
示例
如果 $B = 3$ 且模型预测为 $[0.8, 0.6, 0.9]$(回归), 则集成预测为:
$$ \hat{y} = \frac{0.8 + 0.6 + 0.9}{3} = 0.7667 $$
对于分类,如果投票是 [A, A, B],多数投票给出 A。
微型代码(简易版本)
Python(使用决策树的 Bagging)
import numpy as np
from sklearn.tree import DecisionTreeClassifier
def bagging_predict(X_train, y_train, X_test, B=10):
n = len(X_train)
preds = []
for _ in range(B):
idx = np.random.choice(n, n, replace=True)
Xb, yb = X_train[idx], y_train[idx]
model = DecisionTreeClassifier()
model.fit(Xb, yb)
preds.append(model.predict(X_test))
preds = np.array(preds)
# 跨模型的多数投票
y_pred = [np.bincount(col).argmax() for col in preds.T]
return np.array(y_pred)C(大纲)
// 对于每个自助迭代:
// 1. 有放回地抽样训练数据
// 2. 训练基模型(例如决策树)
// 3. 存储预测
// 4. 通过多数投票或平均进行组合为何重要
- 减少方差:稳定有噪声的学习器。
- 提高泛化能力:特别是对于决策树。
- 可并行化:模型独立训练。
- 随机森林的基础:随机森林增加了随机特征选择。
Bagging 在以下情况下特别有用:
- 基模型具有高方差(如决策树)
- 有足够的数据来创建多样化的自助样本
一个温和的证明(为何有效)
令 $\hat{f}(x)$ 为一个基学习器,其方差为 $\text{Var}[\hat{f}(x)]$,学习器间的协方差为 $\rho$。 集成方差为:
$$ \text{Var}[\hat{f}_{\text{bag}}(x)] = \rho , \text{Var}[\hat{f}(x)] + \frac{1 - \rho}{B} , \text{Var}[\hat{f}(x)] $$
随着 $B$ 增大,第二项缩小,从而减少总方差。 如果基模型不相关($\rho \approx 0$),bagging 能极大地提高稳定性。
亲自尝试
- 训练一个单独的决策树,记下其准确率。
- 训练一个包含 20 棵树的 bagging 集成,比较稳定性。
- 绘制决策边界,bagging 能平滑锯齿状边缘。
- 在噪声数据集上尝试,方差减少效果明显。
- 与随机森林(增加了特征随机性)进行比较。
测试用例
| 数据集 | 基学习器 | 行为 |
|---|---|---|
| 噪声数据 | 决策树 | 方差减少 |
| 平滑数据 | 线性模型 | 改进很小 |
| 大型数据集 | 任意 | 集成收敛 |
| 小型数据集 | 树 | 自助抽样增加多样性 |
复杂度
- 训练:$O(B \times T)$,其中 $T$ 是基学习器的成本
- 预测:每个样本 $O(B)$
- 空间:$O(B)$ 个模型
Bagging 是群体的智慧,许多不稳定的学习器结合它们的声音,产生一个强大、稳定的预测。
912. 随机森林
随机森林是由多个决策树组成的集成模型,每棵树都在不同的随机数据子集和特征子集上训练。通过结合自助采样法和特征随机性,它构建了一组多样化的树,其集体投票减少了过拟合并提高了泛化能力。
我们要解决什么问题?
即使使用自助采样法,如果每棵决策树看到相同的特征,它们可能会进行相似的分裂,从而导致相关的错误。 随机森林通过添加特征随机性来解决这个问题,确保每棵树探索特征空间的不同维度。
给定:
- $B$ 棵树
- 每次分裂时随机选择 $m$ 个特征(对于分类问题,通常 $m = \sqrt{d}$)
最终的集成预测是:
对于分类问题: $$ \hat{y} = \arg\max_c \sum_{b=1}^{B} \mathbf{1}!\big(\hat{y}^{(b)} = c\big) $$
对于回归问题: $$ \hat{y} = \frac{1}{B} \sum_{b=1}^{B} \hat{y}^{(b)} $$
它是如何工作的?(通俗解释)
可以把森林想象成一个决策树团队,每棵树都在略有不同的数据集上生长,并关注不同的特征。 单棵树可能会过拟合,但作为一个整体,它们能很好地泛化。
逐步过程:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 自助采样法 | 为每棵树抽取随机样本(有放回) |
| 2 | 特征子采样 | 在每个节点处,随机选取 $m$ 个特征 |
| 3 | 训练树 | 生长完整的决策树,不进行剪枝 |
| 4 | 聚合预测 | 多数投票或对树的预测结果取平均 |
| 5 | 袋外估计 | 使用未使用的样本进行验证(无需额外的测试集) |
示例
假设你训练了 $B = 5$ 棵树,每棵树使用不同的自助采样样本和随机的特征子集。 它们对一个新输入的预测结果是 [A, B, A, A, B]。 最终的多数投票结果是:
$$ \hat{y} = A $$
微型代码(简易版本)
Python(简化版随机森林)
import numpy as np
from sklearn.tree import DecisionTreeClassifier
def random_forest_predict(X_train, y_train, X_test, B=10, m=None):
n, d = X_train.shape
if m is None:
m = int(np.sqrt(d))
preds = []
for _ in range(B):
# 自助采样
idx = np.random.choice(n, n, replace=True)
Xb, yb = X_train[idx], y_train[idx]
# 使用特征子采样训练树
features = np.random.choice(d, m, replace=False)
model = DecisionTreeClassifier(max_features=m)
model.fit(Xb[:, features], yb)
preds.append(model.predict(X_test[:, features]))
preds = np.array(preds)
y_pred = [np.bincount(col).argmax() for col in preds.T]
return np.array(y_pred)C(概述)
// 对于每棵树:
// 1. 有放回地采样数据
// 2. 在每次分裂时随机选择特征子集
// 3. 训练决策树
// 4. 通过投票或平均聚合预测为什么它很重要
- 减少方差:不相关的树 = 更稳定的集成
- 处理高维数据:特征子采样提高了可扩展性
- 内置验证:袋外(OOB)分数估计测试误差
- 鲁棒性强:适用于分类和数值特征
- 默认的强基线:只需极少调参即可获得出色性能
一个温和的证明(为什么它有效)
自助集成模型的方差: $$ \text{Var}\big[\hat{f}_{\text{bag}}\big] = \rho , \sigma^2 + \frac{1 - \rho}{B} \sigma^2 $$
添加特征随机性降低了树之间的相关性 $\rho$,从而进一步降低了集成方差。 因此,当特征相关时,随机森林的表现优于普通的自助采样法。
亲自尝试
- 在一个有噪声的数据集上训练一个随机森林。
- 将其准确率与单个决策树进行比较。
- 检查
feature_importances_,哪些特征最重要? - 调整树的数量 $B$ 和特征数量 $m$。
- 评估袋外(OOB)误差与测试集误差。
测试用例
| 数据集 | 基学习器 | 行为 |
|---|---|---|
| 噪声数据 | 决策树 | 方差更低 |
| 相关特征 | 决策树 | 特征子采样有帮助 |
| 高维数据 | 决策树 | 随机特征选择至关重要 |
| 小数据集 | 决策树 | 自助采样的多样性增加了鲁棒性 |
复杂度
- 训练:$O(B \times n \log n)$(每棵树)
- 预测:每个样本 $O(B \times \text{depth})$
- 空间:存储 $O(B)$ 棵树
随机森林是一群决策树,每棵树都以不同的方式看待世界,它们共同做出平衡、有根据的判断。
913. AdaBoost(自适应提升)
AdaBoost,全称 Adaptive Boosting(自适应提升),是一种强大的集成方法,它通过组合多个弱学习器(通常是浅层决策树桩)来构建一个强分类器。随着集成的增长,每个学习器都更专注于前一个学习器犯的错误,从而实现“自适应”。
我们要解决什么问题?
许多简单模型(如单层决策树)的性能仅比随机猜测略好。 AdaBoost 通过将它们组合成一个加权集成来放大它们的力量,该集成专注于难以分类的样本。
我们的目标是构建一个最终分类器:
$$ F(x) = \sum_{t=1}^{T} \alpha_t , h_t(x) $$
其中:
- $h_t(x)$ 是第 $t$ 次迭代的弱学习器
- $\alpha_t$ 是其权重(我们给予它的信任度)
最终预测为:
$$ \hat{y} = \text{sign}\big(F(x)\big) $$
它是如何工作的(通俗解释)?
AdaBoost 就像一个不断重新讲解最难问题的老师。 在每一轮之后,它会增加被错误分类样本的权重,以便下一个学习器更加关注它们。
逐步过程:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 | 所有样本获得相同权重 $w_i = \frac{1}{n}$ |
| 2 | 训练弱学习器 | 在加权数据上拟合 $h_t(x)$ |
| 3 | 计算误差 | $\varepsilon_t = \sum_i w_i , \mathbf{1}(h_t(x_i) \ne y_i)$ |
| 4 | 计算学习器权重 | $\alpha_t = \frac{1}{2} \ln \frac{1 - \varepsilon_t}{\varepsilon_t}$ |
| 5 | 更新权重 | $w_i \leftarrow w_i , e^{-\alpha_t y_i h_t(x_i)}$ |
| 6 | 归一化 | 缩放 $w_i$ 使得 $\sum_i w_i = 1$ |
| 7 | 重复 | 对于 $t = 1, 2, \dots, T$ |
困难的样本获得更高的权重,因此后续的学习器会专注于它们。
示例
假设一个弱学习器获得了 80% 的准确率($\varepsilon = 0.2$)。 它的权重为:
$$ \alpha = \frac{1}{2} \ln\frac{1 - 0.2}{0.2} = 0.693 $$
被错误分类的样本其权重增加了 $e^{+\alpha}$,使它们在下一轮中更重要。
微型代码(简易版本)
Python(使用决策树桩的二元 AdaBoost)
import numpy as np
def adaboost(X, y, T=10):
n = len(y)
w = np.ones(n) / n
models, alphas = [], []
for _ in range(T):
# 训练弱学习器(简单阈值)
thresh = np.random.choice(X[:, 0])
preds = np.where(X[:, 0] < thresh, 1, -1)
err = np.sum(w * (preds != y)) / np.sum(w)
if err > 0.5:
continue
alpha = 0.5 * np.log((1 - err) / (err + 1e-9))
w *= np.exp(-alpha * y * preds)
w /= np.sum(w)
models.append(thresh)
alphas.append(alpha)
def predict(X_test):
total = np.zeros(len(X_test))
for thresh, alpha in zip(models, alphas):
preds = np.where(X_test[:, 0] < thresh, 1, -1)
total += alpha * preds
return np.sign(total)
return predictC(大纲)
// 等权重初始化样本权重
// 对于每一轮:
// 1. 在加权数据上训练弱学习器
// 2. 计算误差和 alpha
// 3. 更新样本权重
// 使用加权投票组合所有弱学习器为什么它很重要
- 将弱模型提升为强模型。
- 专注于困难案例,自适应加权。
- 理论保证:最小化指数损失。
- 对于小的 T 不会过拟合(如果弱学习器简单)。
- 是梯度提升方法的基础。
常用的基学习器:
- 决策树桩(单次分裂的树)
- 小深度决策树
- 简单线性分类器
一个温和的证明(为什么它有效)
AdaBoost 最小化指数损失:
$$ L = \sum_{i=1}^{n} e^{-y_i F(x_i)} $$
每次迭代贪婪地选择 $h_t$ 和 $\alpha_t$ 来减少 $L$。 随着 $F(x)$ 的增长,正确分类的点($y_i F(x_i) > 0$)获得极小的权重,而错误则主导下一轮的目标。 因此,集成模型自然地专注于错误,并随着时间的推移构建边界。
自己动手试试
- 使用 $T = 10$ 个决策树桩训练 AdaBoost。
- 绘制样本权重图,观察焦点如何转移到错误上。
- 增加 $T$:偏差减小,方差稳定。
- 与 Bagging 比较,AdaBoost 是顺序的,而非并行的。
- 在噪声数据上检查性能,$T$ 过大可能导致过拟合。
测试用例
| 数据集 | 基学习器 | 行为 |
|---|---|---|
| 简单线性可分 | 树桩 | 提升至完美准确率 |
| 重叠类别 | 树桩 | 专注于模糊点 |
| 高噪声 | 树桩 | 如果 $T$ 过大则过拟合 |
| 平衡数据集 | 树桩 | 快速收敛 |
复杂度
- 训练:$O(T \cdot n \cdot C)$(弱学习器成本 $C$)
- 预测:每个样本 $O(T)$
- 空间:$O(T)$ 个弱模型
AdaBoost 是一个迭代放大器,它倾听每一个错误,从中学习,并将众多微弱的声音组合成一个强大而自信的决策。
914. 梯度提升
梯度提升是一种强大的集成技术,它分阶段构建模型,每个新的学习器通过遵循损失函数的梯度来纠正先前集成的残差误差。它将 AdaBoost 推广到任意可微损失和基学习器。
我们要解决什么问题?
我们想要一种方法,通过最小化损失函数,将许多弱学习器(如浅层树)组合成一个强学习器:
$$ L = \sum_{i=1}^n \ell(y_i, F(x_i)) $$
其中:
- $F(x)$ 是集成模型,构建为弱学习器的和
- $\ell(y, \hat{y})$ 是一个可微损失(例如,平方误差、对数损失)
我们迭代地添加学习器,这些学习器使 $F(x)$ 沿着损失相对于预测的负梯度方向移动。
它是如何工作的(通俗解释)?
可以将其视为函数空间中的梯度下降。 每个弱学习器 $h_t(x)$ 学习预测残差,即减少误差的方向,而不是直接预测标签。 然后,我们通过添加该学习器的缩放版本来更新模型。
逐步说明(针对回归):
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化模型 | $F_0(x) = \arg\min_c \sum_i \ell(y_i, c)$ |
| 2 | 对于每次迭代 $t = 1..T$: | |
| 2a | 计算伪残差 | $r_i^{(t)} = -\frac{\partial \ell(y_i, F_{t-1}(x_i))}{\partial F_{t-1}(x_i)}$ |
| 2b | 拟合弱学习器 | $h_t(x)$ 拟合 $(x_i, r_i^{(t)})$ |
| 2c | 计算步长 | $\gamma_t = \arg\min_\gamma \sum_i \ell(y_i, F_{t-1}(x_i) + \gamma h_t(x_i))$ |
| 2d | 更新模型 | $F_t(x) = F_{t-1}(x) + \nu \gamma_t h_t(x)$ |
| 3 | 输出最终模型 | $F_T(x)$ |
学习率 $\nu$ ($0 < \nu \le 1$) 控制每个学习器的贡献程度。
示例(平方误差)
对于 $\ell(y, F(x)) = \frac{1}{2}(y - F(x))^2$,
$$ r_i^{(t)} = y_i - F_{t-1}(x_i) $$
因此,每个新学习器直接拟合残差(误差)。
微型代码(简易版本)
Python(简化的梯度提升回归)
import numpy as np
from sklearn.tree import DecisionTreeRegressor
def gradient_boosting(X, y, T=10, lr=0.1, depth=1):
n = len(y)
F = np.mean(y) * np.ones(n)
trees = []
for _ in range(T):
residuals = y - F
tree = DecisionTreeRegressor(max_depth=depth)
tree.fit(X, residuals)
F += lr * tree.predict(X)
trees.append(tree)
return F, treesC(大纲)
// 初始化模型预测为 mean(y)
// 对于每次迭代:
// 1. 计算残差 = y - 预测值
// 2. 拟合弱学习器到残差
// 3. 更新预测值 += 学习率 * 新预测值为什么它很重要
- 通用框架:支持多种损失(回归、分类)。
- 灵活:适用于任何可微损失函数。
- 准确:连续的校正产生高精度。
- 可控:学习率 + 树深度平衡偏差/方差。
- 基础:是现代实现(如 XGBoost、LightGBM、CatBoost)的基础。
一个温和的证明(为什么它有效)
我们增量地构建 $F(x)$ 以最小化:
$$ L(F) = \sum_i \ell(y_i, F(x_i)) $$
每一步添加近似负梯度的 $h_t(x)$:
$$ r_i^{(t)} = -\frac{\partial L}{\partial F(x_i)} = -\frac{\partial \ell(y_i, F(x_i))}{\partial F(x_i)} $$
因此,更新:
$$ F_t(x) = F_{t-1}(x) + \nu \gamma_t h_t(x) $$
就像梯度下降一样,每次迭代都减少损失。
自己动手试试
- 在简单的回归数据上用 $T=10$, $lr=0.1$ 拟合梯度提升。
- 绘制随着学习器累积的预测值,观察拟合效果的改善。
- 与 AdaBoost 比较,注意更新逻辑的差异。
- 尝试使用更深的树(减少偏差)。
- 降低学习率(增加 $T$),观察更平滑的收敛。
测试用例
| 数据集 | 损失 | 预期行为 |
|---|---|---|
| 线性回归 | 平方误差 | 线性拟合 |
| 非线性模式 | 平方误差 | 近似曲线 |
| 分类 | 对数损失 | 逻辑提升 |
| 高噪声 | 平方误差 | 如果 $T$ 过高则过拟合 |
复杂度
- 训练:$O(T \cdot n \cdot C)$(弱学习器成本 $C$)
- 预测:每个样本 $O(T)$
- 空间:$O(T)$ 个学习器
梯度提升是带有方向的提升,每一步都遵循梯度,其改进不是通过重新加权错误,而是通过学习损失函数本身的形状来实现的。
915. XGBoost (极限梯度提升)
XGBoost (极限梯度提升) 是一种高性能、正则化的梯度提升实现。它通过二阶优化、收缩、列采样和内置的正则化扩展了基本框架,从而在大规模数据集上实现了速度和准确性的双重提升。
我们要解决什么问题?
虽然梯度提升提供了很强的准确性,但它可能速度较慢且容易过拟合。 XGBoost 通过以下方式解决这些问题:
- 添加正则化项 (L1 和 L2) 来惩罚模型复杂度。
- 使用二阶梯度进行更精确的更新。
- 采用列子采样和收缩来提高泛化能力。
- 为大型数据集实现优化的树构建算法。
我们仍然构建一个模型:
$$ F(x) = \sum_{t=1}^T f_t(x), \quad f_t \in \mathcal{F} $$
其中每个 $f_t$ 是一棵回归树,$\mathcal{F}$ 是树的空间。
目标函数
在第 $t$ 步的训练目标函数是:
$$ \mathcal{L}^{(t)} = \sum_{i=1}^n \ell(y_i, \hat{y}_i^{(t-1)} + f_t(x_i)) + \Omega(f_t) $$
使用二阶泰勒展开,我们近似为:
$$ \mathcal{L}^{(t)} \approx \sum_{i=1}^n \Big[ g_i f_t(x_i) + \tfrac{1}{2} h_i f_t(x_i)^2 \Big] + \Omega(f_t) $$
其中:
- $g_i = \frac{\partial \ell(y_i, \hat{y}_i^{(t-1)})}{\partial \hat{y}_i^{(t-1)}}$
- $h_i = \frac{\partial^2 \ell(y_i, \hat{y}_i^{(t-1)})}{\partial \hat{y}_i^{(t-1)2}}$
正则化项为:
$$ \Omega(f_t) = \gamma T + \frac{1}{2} \lambda \sum_{j=1}^T w_j^2 $$
其中:
- $\gamma$: 对叶子节点数量的惩罚
- $\lambda$: 对叶子权重 $w_j$ 的 L2 正则化
它是如何工作的(通俗解释)?
在每次迭代中,XGBoost 根据损失函数的一阶和二阶梯度(曲率)拟合一棵新树 $f_t$。 每个叶子节点都有一个通过解析计算得到的权重,以最小化近似损失。 与简单的残差拟合相比,这种二阶信息允许进行更精确的优化。
逐步过程:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 初始化 | $\hat{y}^{(0)} = \arg\min_c \sum_i \ell(y_i, c)$ |
| 2 | 计算梯度 | 为每个训练点计算 $g_i, h_i$ |
| 3 | 拟合树 | 使用 $g_i, h_i$ 寻找最佳分割和叶子权重 |
| 4 | 计算叶子权重 | $w_j^* = - \frac{\sum_{i \in I_j} g_i}{\sum_{i \in I_j} h_i + \lambda}$ |
| 5 | 计算分割增益 | $\text{Gain} = \frac{1}{2}\Big[\frac{G_L^2}{H_L + \lambda} + \frac{G_R^2}{H_R + \lambda} - \frac{(G_L + G_R)^2}{H_L + H_R + \lambda}\Big] - \gamma$ |
| 6 | 添加树 | $\hat{y}^{(t)} = \hat{y}^{(t-1)} + \eta f_t(x)$ |
| 7 | 重复 | 直到 $T$ 棵树或提前停止 |
微型代码(概念骨架)
Python (概念性梯度步骤)
# 伪代码(非实际实现)
for t in range(T):
g = grad(loss, y_pred) # 计算梯度
h = hess(loss, y_pred) # 计算海森矩阵(二阶梯度)
tree = build_tree(X, g, h, lambda_, gamma) # 构建树
y_pred += eta * tree.predict(X) # 更新预测值XGBoost 的 C++ 后端使用直方图和并行化高效地构建树。
为什么它很重要
- 正则化:通过 $\lambda$ 和 $\gamma$ 避免过拟合。
- 二阶优化:更快、更精确的更新步骤。
- 收缩(学习率):逐步更新以改善泛化能力。
- 列子采样:减少树之间的相关性,降低方差。
- 高度优化:支持并行和分布式训练。
XGBoost 因其在速度、准确性和鲁棒性之间的平衡,被广泛应用于 Kaggle 竞赛、金融和工业机器学习系统。
一个温和的证明(为什么它有效)
目标函数被近似为:
$$ \tilde{\mathcal{L}}^{(t)} = \sum_{j=1}^T \Big[ G_j w_j + \tfrac{1}{2}(H_j + \lambda)w_j^2 \Big] + \gamma T $$
对 $w_j$ 求最小化得到:
$$ w_j^* = -\frac{G_j}{H_j + \lambda} $$
代回原式得到最优分割增益:
$$ \text{Gain} = \frac{1}{2}\left(\frac{G_L^2}{H_L + \lambda} + \frac{G_R^2}{H_R + \lambda} - \frac{(G_L + G_R)^2}{H_L + H_R + \lambda}\right) - \gamma $$
每次分割都最大化增益,通过正则化贪婪地减少损失。
亲自尝试
- 在一个小数据集(二分类)上训练 XGBoost。
- 检查特征重要性,哪些特征占主导地位?
- 调整 $\eta$(学习率):小的 $\eta$ + 大的 $T$ = 更平滑的收敛。
- 调整 $\lambda$, $\gamma$ 以平衡偏差和方差。
- 与普通梯度提升比较,收敛更快,过拟合更少。
测试案例
| 数据集 | 任务 | 表现 |
|---|---|---|
| 二分类 | 对数损失 | 快速收敛 |
| 带噪声的回归 | 均方误差 | 通过正则化保持稳定 |
| 宽数据 | 多特征 | 列子采样有帮助 |
| 易过拟合 | 任意 | 由 $\lambda, \gamma$ 控制 |
复杂度
- 训练:$O(T \cdot n \log n)$(优化的树分割)
- 预测:每个样本 $O(T \cdot \text{depth})$
- 空间:$O(T)$ 棵树
XGBoost 是梯度提升的进化版,它将二阶学习、正则化和计算工程相结合,形成了一个针对结构化数据的快速、可靠的强大工具。
916. LightGBM(轻量梯度提升机)
LightGBM 是一种高效的梯度提升实现,专注于速度、可扩展性和内存效率。它引入了基于直方图的学习、叶子优先生长和基于梯度的单边采样(GOSS)等创新技术,以在不牺牲准确性的情况下处理海量数据集。
我们要解决什么问题?
传统的梯度提升(甚至 XGBoost)在大型、高维数据上可能会变得缓慢且占用大量内存。 LightGBM 通过以下方式解决这个问题:
- 使用基于直方图的分割来减少计算量。
- 以叶子优先而非层优先的方式生长树(生成更深、更准确的树)。
- 使用采样技术聚焦于有影响力的数据点。
- 原生支持类别特征。
其目标是:快速训练、低内存占用、高准确率,尤其适用于大规模结构化数据。
核心思想
LightGBM 构建加法模型:
$$ F(x) = \sum_{t=1}^T f_t(x) $$
每个 $f_t(x)$ 都是一个决策树,训练目标是最小化二阶损失近似:
$$ \mathcal{L}^{(t)} \approx \sum_{i=1}^{n} \Big[g_i f_t(x_i) + \tfrac{1}{2} h_i f_t(x_i)^2\Big] + \Omega(f_t) $$
但采用了基于直方图的分割和优化的采样。
它是如何工作的(通俗解释)?
LightGBM 通过三项创新简化了树提升过程:
基于直方图的分割: 连续特征被分桶到离散的箱子中。 这极大地将分割搜索成本从 $O(n \cdot d)$ 降低到 $O(B \cdot d)$, 其中 $B$ 是箱子数量(例如 255)。
叶子优先的树生长(最佳优先): LightGBM 不是均匀地扩展所有叶子(层优先),而是选择损失减少最大的叶子进行下一次生长。 这允许生成更深、更聚焦的树。
GOSS(基于梯度的单边采样): 保留具有大梯度的样本(对减少损失很重要), 并随机丢弃一些具有小梯度的样本,在减少数据量的同时保持学习方向。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 计算梯度 | 计算所有样本的 $g_i, h_i$ |
| 2 | 特征分桶 | 按箱构建梯度的直方图 |
| 3 | 分割搜索 | 对每个特征,评估每个箱的增益 |
| 4 | 选择分割 | 最大化损失减少 |
| 5 | 叶子优先生长 | 扩展增益最大的叶子 |
| 6 | 应用 GOSS | 保留高梯度样本,对低梯度样本进行采样 |
| 7 | 重复 | 直到达到最大叶子数或停止准则 |
示例:分割增益公式
分割叶子 $j$ 的增益计算如下:
$$ \text{Gain} = \frac{1}{2}\left( \frac{G_L^2}{H_L + \lambda} + \frac{G_R^2}{H_R + \lambda} - \frac{(G_L + G_R)^2}{H_L + H_R + \lambda} \right) - \gamma $$
其中 $G$ 和 $H$ 分别是左/右子叶子中梯度和海森矩阵的和。
微型代码(概念草图)
Python(概念性)
# 概念性说明,非完整实现
for t in range(T):
g, h = compute_gradients(y, y_pred)
hist = build_histograms(X, g, h, bins=255)
best_split = find_best_leafwise_split(hist)
tree = grow_tree(best_split)
y_pred += lr * tree.predict(X)实践中:使用库中的 lightgbm.LGBMClassifier 或 LGBMRegressor。
为什么它很重要
- 速度:直方图分桶 + GOSS = 比 XGBoost 更快。
- 准确性:叶子优先的树专注于高增益分割。
- 内存效率:使用压缩的箱子,而非原始浮点数。
- 可扩展性:处理数百万行和特征。
- 原生支持:类别变量和 GPU 加速。
LightGBM 在金融、推荐系统和竞赛中常见的大型表格数据集上尤其有效。
一个温和的证明(为什么它有效)
直方图技巧将连续特征近似到箱子 $B_j$ 中:
$$ \sum_{i \in B_j} g_i, \quad \sum_{i \in B_j} h_i $$
使用这些聚合值来评估分割,在保留梯度统计信息的同时降低了成本。
对于 GOSS:
- 保留前 $a%$ 的高梯度样本。
- 采样 $b%$ 的低梯度样本。
- 应用校正因子 $\frac{1-a}{b}$ 以保持无偏性。
这保持了总梯度方向的有效估计,确保收敛到与完整数据相同的优化点。
亲自尝试
- 在包含 100 万个样本的数据集上训练 LightGBM,注意速度。
- 与 XGBoost 比较,可以看到训练速度快 2-3 倍。
- 调整
num_leaves:更高 → 偏差更低,方差更高。 - 调整
max_bin以权衡准确性与速度。 - 启用
categorical_feature,无需独热编码。
测试用例
| 数据集 | 任务 | 行为 |
|---|---|---|
| 大型表格数据 | 分类 | 快速,高准确率 |
| 小型数据集 | 回归 | 与 XGBoost 类似 |
| 类别特征较多 | 分类 | 内置处理 |
| 噪声数据 | 任意 | 可能过拟合,调整 num_leaves |
复杂度
- 训练:$O(T \cdot B \cdot d)$($B$ 个箱子,$d$ 个特征)
- 预测:$O(T \cdot \text{depth})$
- 空间:直方图占用 $O(B \cdot d)$
LightGBM 是处于涡轮增压模式的梯度提升,快速、内存高效且高度聚焦,专为现代数据规模和速度而设计。
917. CatBoost(分类提升)
CatBoost 是一个梯度提升库,专门设计用于高效处理分类特征并避免预测偏移(目标泄漏)。它使用有序提升和基于排列统计的目标编码,能生成快速、准确且稳定的模型,尤其适用于表格数据。
我们要解决什么问题?
许多梯度提升库需要对分类特征进行手动编码(如独热编码或标签编码),这可能导致:
- 内存使用效率低下(尤其是对于高基数特征)
- 由于目标泄漏(目标信息泄露到训练中)而导致的过拟合
CatBoost 通过以下方式解决这些问题:
- 使用基于排列驱动的目标统计量对分类特征进行原生编码。
- 使用有序提升,防止训练期间数据"看到未来"。
模型仍然遵循标准的提升公式:
$$ F(x) = \sum_{t=1}^T f_t(x) $$
但对分类变量和有序学习进行了特殊处理。
它是如何工作的(通俗解释)?
CatBoost 像梯度提升一样构建树,但增加了两个关键创新:
有序目标编码(OTEs): 它不是使用全局目标均值对类别进行编码(这会泄露信息),而是计算数据集随机排列上的渐进平均值。每个样本的编码仅使用过去的样本。
对于类别 $c$ 的示例: $$ \text{Enc}(x_i) = \frac{\sum_{j < i, x_j = c} y_j + a \cdot P}{N_{<i, c} + a} $$ 其中:
- $N_{<i, c}$ = 在位置 $i$ 之前类别 $c$ 的计数
- $P$ = 先验值(全局均值)
- $a$ = 平滑参数
有序提升: 它不是在相同数据集上拟合残差,而是模拟在线学习:每次迭代仅使用在该时间点可用的数据。这避免了目标泄漏并提高了泛化能力。
逐步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 随机排列 | 打乱训练数据 |
| 2 | 编码分类特征 | 按顺序计算目标统计量 |
| 3 | 计算梯度 | 基于当前预测 |
| 4 | 拟合弱学习器 | 在转换后的数据上训练树 |
| 5 | 应用有序提升 | 仅使用过去的信息 |
| 6 | 添加树 | 更新集成 $F_t(x) = F_{t-1}(x) + \eta f_t(x)$ |
示例:目标编码
假设我们有一个分类特征"颜色",样本顺序如下:
| 样本 | 颜色 | 目标值 | 编码(逐步) |
|---|---|---|---|
| 1 | 红 | 1 | $(a \cdot P) / a$ |
| 2 | 蓝 | 0 | $(a \cdot P) / a$ |
| 3 | 红 | 0 | $(a \cdot P + 1) / (a + 1)$ |
| 4 | 蓝 | 1 | $(a \cdot P + 0) / (a + 1)$ |
每个样本仅使用过去的目标值进行编码,避免了泄漏。
微型代码(简易版本)
Python(使用 CatBoost 库)
from catboost import CatBoostClassifier
model = CatBoostClassifier(
iterations=100,
learning_rate=0.1,
depth=6,
cat_features=['color', 'brand', 'city'],
loss_function='Logloss',
verbose=0
)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)C(概述)
// 伪代码:
// 1. 随机打乱数据
// 2. 对于每个分类特征,计算有序目标编码
// 3. 使用有序提升在编码后的数据上训练梯度提升树为什么它很重要
- 无需预处理:原生处理分类特征。
- 避免目标泄漏:使用有序统计量和提升。
- 快速且准确:高效的 C++ 核心,支持 CPU/GPU。
- 非常适合表格数据:尤其是具有混合特征类型的数据。
- 鲁棒性强:与 XGBoost 或 LightGBM 相比,需要的调参更少。
当数据集包含分类变量、规模中小型且存在非线性交互时,CatBoost 表现出色。
一个温和的证明(为什么它有效)
CatBoost 通过使特征变换具有因果一致性来对抗目标泄漏:
- 对于编码,只有较早的样本影响当前样本。
- 对于提升,每个模型看到的预测计算都没有访问同一样本。
这确保了无偏的梯度估计,提高了稳定性和泛化能力。
亲自尝试
- 在具有分类列的数据集上训练 CatBoost。
- 与独热编码 + XGBoost 进行比较。
- 观察更好的准确性和更少的过拟合。
- 可视化特征重要性,类别是有意义的。
- 尝试
model.get_feature_importance(prettified=True)以获得洞察。
测试用例
| 数据集 | 特征类型 | 行为 |
|---|---|---|
| 分类特征多 | 许多字符串 | 优于独热编码方法 |
| 仅连续特征 | 数值型 | 与 XGBoost 类似 |
| 高基数 | ID 或名称 | 通过 OTE 高效处理 |
| 小型数据集 | 混合型 | 稳定,过拟合程度低 |
复杂度
- 训练:$O(T \cdot n \log n)$(构建树)
- 预测:每个样本 $O(T \cdot \text{depth})$
- 空间:$O(T)$ 棵树 + 编码特征
CatBoost 是为分类数据正确实现的提升算法,将概率编码、有序学习和梯度优化融合为一个连贯的系统。
918. 堆叠(堆叠泛化)
堆叠(或称堆叠泛化)是一种集成学习技术,它通过训练一个元模型来学习如何最佳地融合多个基模型的预测结果。它不是简单的平均或投票,而是直接从数据中学习最优组合。
我们正在解决什么问题?
单个模型捕捉数据中不同的模式或偏差。 简单的集成方法(如装袋法或提升法)可能无法有效利用这些互补优势。
堆叠学习一个元级模型,以最优地加权或组合基学习器的输出,通常能产生更高的准确率和更强的泛化能力。
我们的目标是构建一个模型:
$$ \hat{y} = g(f_1(x), f_2(x), \ldots, f_m(x)) $$
其中:
- $f_i(x)$ = 基学习器的预测
- $g(\cdot)$ = 元模型(例如线性回归、逻辑回归)
它是如何工作的(通俗解释)?
堆叠是一个两级学习系统:
- 第0层(基学习器):训练多个不同的模型(树、支持向量机、神经网络等)。
- 第1层(元学习器):在基学习器的折外预测上训练一个模型,以预测最终输出。
通过使用折外预测,我们确保元模型是在未见过的数据上训练的,从而防止信息泄露。
逐步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 将数据分割为折 | 创建 $K$ 折用于交叉验证 |
| 2 | 训练基模型 | 每个 $f_i$ 在 $(K-1)$ 折上训练,预测保留折 |
| 3 | 收集折外预测 | 构建基模型预测的新数据集 |
| 4 | 训练元模型 | 在折外预测与真实标签上拟合 $g$ |
| 5 | 最终模型 | 在全数据上训练的基学习器 + 在全折外预测上训练的元模型 |
对于新的 $x$ 的最终预测:
$$ \hat{y} = g(f_1(x), f_2(x), \ldots, f_m(x)) $$
示例
假设你有 3 个基模型:逻辑回归 ($f_1$)、随机森林 ($f_2$) 和支持向量机 ($f_3$)。 你在验证折上生成它们的预测,并将它们堆叠为新特征:
| 样本 | $f_1(x)$ | $f_2(x)$ | $f_3(x)$ | $y$ |
|---|---|---|---|---|
| 1 | 0.2 | 0.4 | 0.3 | 0 |
| 2 | 0.8 | 0.9 | 0.7 | 1 |
| 3 | 0.6 | 0.5 | 0.4 | 1 |
然后在此表上训练一个元模型 $g$(如逻辑回归)。
微型代码(简易版本)
Python(堆叠示例)
import numpy as np
from sklearn.model_selection import KFold
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
def stacking_train_predict(X, y, X_test, folds=5):
base_models = [
LogisticRegression(max_iter=1000),
RandomForestClassifier(n_estimators=100),
GradientBoostingClassifier(),
SVC(probability=True)
]
kf = KFold(n_splits=folds, shuffle=True, random_state=42)
meta_features = np.zeros((len(y), len(base_models)))
# 第0层:生成折外预测
for j, model in enumerate(base_models):
oof_pred = np.zeros(len(y))
for train_idx, val_idx in kf.split(X):
model.fit(X[train_idx], y[train_idx])
oof_pred[val_idx] = model.predict_proba(X[val_idx])[:, 1]
meta_features[:, j] = oof_pred
# 第1层:训练元模型
meta_model = LogisticRegression()
meta_model.fit(meta_features, y)
# 预测
test_meta = np.column_stack([m.fit(X, y).predict_proba(X_test)[:, 1] for m in base_models])
return meta_model.predict(test_meta)C(大纲)
// 1. 在每个基模型上训练 K-1 折,预测保留折
// 2. 将折外预测组合成元数据集
// 3. 在此新数据集上训练元模型
// 4. 使用训练好的基模型 + 元模型进行最终预测为什么它很重要
- 结合了不同模型的优势。
- 学习组合权重,而不是假设同等重要。
- 通过利用互补模式来减少泛化误差。
- 灵活的框架:任何模型都可以作为基学习器或元学习器。
堆叠通常是竞争性机器学习流水线的最后一层,融合了基于树的模型、线性模型和深度模型。
一个温和的证明(为什么它有效)
通过在折外预测上训练 $g$,堆叠近似于给定模型输出时 $y$ 的条件期望:
$$ g^*(f_1(x), \dots, f_m(x)) = \mathbb{E}[y \mid f_1(x), \dots, f_m(x)] $$
这确保了无偏学习,因为 $g$ 只看到在拟合过程中未使用真实折生成的预测,避免了过拟合并改善了泛化能力。
亲自尝试
- 选择 3-5 个基学习器(不同的架构)。
- 使用 5 折折外堆叠来创建元特征。
- 训练一个简单的元模型(逻辑回归或岭回归)。
- 与平均或投票法比较,堆叠通常胜出。
- 对于高级设置,尝试 2 级堆叠(堆叠的堆叠)。
测试用例
| 数据集 | 基模型 | 元模型 | 行为 |
|---|---|---|---|
| 混合线性/非线性 | Logistic, RF, GBM | Logistic | 两者最佳 |
| 噪声数据 | Trees, SVM | Ridge | 平滑组合 |
| 小数据集 | Simple models | Low depth | 避免过拟合 |
| 大数据集 | Many base learners | Strong meta-model | 提高准确率 |
复杂度
- 训练:$O(K \cdot M \cdot C)$(折数 × 基模型数 × 成本)
- 预测:每个样本 $O(M + 1)$
- 空间:$O(M)$ 个模型 + 元数据集
堆叠是模型学习协作的地方,一个元学习器协调它们的声音,将预测的合唱变成和谐的交响乐。
919. 投票分类器
投票分类器是最简单的集成方法之一,它结合多个模型的预测结果,并通过多数投票(用于分类)或平均(用于回归)来决定最终输出。它不学习组合权重,而是依赖于其模型的集体“智慧”。
我们正在解决什么问题?
单个模型可能不稳定或有偏差。 平均它们的意见通常可以减少方差并提高鲁棒性。
投票分类器提供了一种快速、可解释的方式来聚合多个模型,而无需额外的训练。
我们的目标是组合 $M$ 个模型:
对于分类: $$ \hat{y} = \arg\max_{c} \sum_{m=1}^{M} \mathbf{1}(\hat{y}^{(m)} = c) $$
对于回归: $$ \hat{y} = \frac{1}{M} \sum_{m=1}^{M} \hat{y}^{(m)} $$
它是如何工作的(通俗解释)?
想象一下咨询几位专家。每个人都给出一个预测。 你不需要判断谁更好,你只需采纳多数意见(硬投票)或平均它们的置信度(软投票)。
两种主要模式:
- 硬投票:使用预测的类别标签;多数票获胜。
- 软投票:使用预测的概率;选择平均概率最高的类别。
逐步总结
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 训练模型 | 在同一数据集上独立拟合每个基模型 |
| 2 | 获取预测 | 对于每个模型,计算 $\hat{y}^{(m)}$(硬投票)或 $p^{(m)}(y \mid x)$(软投票) |
| 3 | 组合 | 通过多数票(硬投票)或平均值(软投票)进行聚合 |
| 4 | 决定输出 | 返回获得最多票数或最高平均概率的类别 |
示例
假设 3 个分类器给出预测:
| 模型 | 预测 |
|---|---|
| 逻辑回归 | A |
| 决策树 | A |
| SVM | B |
硬投票:
- 票数:A(2), B(1) → 最终结果:A
软投票(概率):
| 模型 | P(A) | P(B) |
|---|---|---|
| 逻辑回归 | 0.7 | 0.3 |
| 决策树 | 0.6 | 0.4 |
| SVM | 0.4 | 0.6 |
平均:
- $P(A) = 0.57$, $P(B) = 0.43$ → 最终结果:A
微型代码(简易版本)
Python(硬投票和软投票示例)
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
from sklearn.ensemble import VotingClassifier
clf1 = LogisticRegression(max_iter=1000)
clf2 = DecisionTreeClassifier()
clf3 = SVC(probability=True)
voting_clf = VotingClassifier(
estimators=[('lr', clf1), ('dt', clf2), ('svm', clf3)],
voting='soft' # 'hard' 表示多数投票
)
voting_clf.fit(X_train, y_train)
y_pred = voting_clf.predict(X_test)C(概要)
// 1. 训练多个基模型
// 2. 收集预测结果
// 3. 硬投票:选择获得多数票的类别
// 4. 软投票:平均预测概率,选择最高的为什么它很重要
- 简单有效:在不增加额外复杂性的情况下提高准确性。
- 风险低:不需要调参或元学习。
- 稳定:平滑单个模型的误差。
- 通用:适用于任何分类器或回归器。
理想使用场景:
- 模型是独立的或具有不同的偏差。
- 你希望快速获得集成增益,而无需交叉验证或元模型。
一个温和的证明(为什么它有效)
如果每个模型的错误率为 $p < 0.5$ 且错误是独立的, 那么多数投票出错的概率随着模型数量的增加呈指数级下降:
$$ P(\text{多数票错误}) = \sum_{k = \lceil M/2 \rceil}^{M} \binom{M}{k} p^k (1-p)^{M-k} $$
这就是孔多塞陪审团定理:假设独立性和能力,多数决策比任何单个投票者更可能是正确的。
亲自尝试
- 组合逻辑回归、决策树和 kNN。
- 尝试
voting='hard'和voting='soft'。 - 与单个模型的准确性进行比较。
- 添加一个性能较差的模型,观察它如何稀释性能。
- 在噪声数据上测试,集成方法能抵抗过拟合。
测试用例
| 数据集 | 模式 | 行为 |
|---|---|---|
| 类别平衡 | 硬投票 | 稳定的准确性 |
| 概率模型 | 软投票 | 更好的校准 |
| 相关模型 | 任一 | 增益有限 |
| 多样化模型 | 软投票 | 显著的改进 |
复杂度
- 训练:$O(\sum_m C_m)$(单个成本之和)
- 预测:每个样本 $O(M)$
- 空间:$O(M)$ 个模型
投票分类器是最简单的集成方法,没有混合,没有提升,只是一个民主投票,每个模型都有发言权,共识驱动着准确性。
920. 快照集成
快照集成是一种优雅的技术,它通过在训练过程中捕获网络在不同时间点的权重(通常是在使用周期性学习率计划使其收敛到不同局部最小值时),将单次训练运行转化为多个模型。这些快照随后被组合起来形成一个集成模型,从而在不增加额外训练成本的情况下提升泛化能力。
我们要解决什么问题?
集成方法通过对多个不同模型的预测结果进行平均来提高准确性,但训练多个深度网络成本高昂。 快照集成通过复用一条训练轨迹来解决这个问题,它捕获同一模型在振荡穿越不同局部最小值时的多个不同状态。
我们希望近似一个集成模型:
$$ \hat{y} = \frac{1}{M} \sum_{m=1}^{M} f(x; \theta_m) $$
其中每个 $\theta_m$ 是训练过程中不同时间点的模型参数快照。
它是如何工作的(通俗解释)?
我们不独立训练 $M$ 个模型,而是周期性地改变学习率,让优化器进入并离开多个最小值。 在每个周期结束时(当学习率较小时),我们保存权重。 每个保存的模型随后被用于最终的集成模型。
步骤分解:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化模型 | 从随机权重开始 |
| 2 | 使用周期性学习率 | 学习率计划(如余弦退火)重复 $M$ 个周期 |
| 3 | 在每个周期结束时 | 保存权重(快照) |
| 4 | 继续训练 | 重新将学习率调高 |
| 5 | 训练完成后 | 组合所有快照(平均预测结果) |
因此,$M$ 个快照 $\Rightarrow$ $M$ 个模型,全部来自一次训练过程。
学习率计划(余弦退火)
学习率 $\eta(t)$ 周期性衰减并重启:
$$ \eta(t) = \eta_{\min} + \frac{1}{2}(\eta_{\max} - \eta_{\min}) \left( 1 + \cos\left( \frac{\pi \bmod(t-1, T/M)}{T/M} \right) \right) $$
其中:
- $T$ = 总迭代次数
- $M$ = 周期数
- $\eta_{\max}, \eta_{\min}$ = 学习率上下界
这种周期性计划确保了对多个吸引盆的探索。
示例
假设你计划在 90 个训练周期内获得 $M = 3$ 个快照:
- 每个周期 = 30 个周期
- 学习率每 30 个周期重启一次
- 在第 30、60、90 个周期保存模型
最终预测:
$$ \hat{y} = \frac{1}{3}(f_1(x) + f_2(x) + f_3(x)) $$
微型代码(简易版本)
Python (PyTorch 风格示例)
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
model = MyNetwork()
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
scheduler = CosineAnnealingLR(optimizer, T_max=30)
snapshots = []
for epoch in range(90):
train_one_epoch(model, optimizer, data_loader)
scheduler.step()
if (epoch + 1) % 30 == 0:
snapshots.append(model.state_dict().copy())
# 集成预测
def ensemble_predict(x):
preds = []
for s in snapshots:
model.load_state_dict(s)
preds.append(model(x))
return sum(preds) / len(preds)C (概要)
// 使用余弦退火学习率训练模型
// 每个周期:
// 1. 保存模型权重(快照)
// 2. 将学习率重新调高
// 训练完成后:对所有快照的预测结果进行平均为什么它很重要
- 免费的集成:无需额外的训练运行。
- 单条轨迹带来的多样性:每个快照代表一个不同的局部最小值。
- 改进的泛化能力:平均了方差和噪声。
- 优雅且高效:与训练单个模型的时间相同。
在深度学习领域,完整的集成模型成本高昂,因此这项技术尤其有用。
一个温和的证明(为什么它有效)
在非凸优化中,随机梯度下降可能会根据学习率收敛到多个局部最小值。 通过重启学习率,我们将模型从一个吸引盆推入另一个,从而捕获多个不同的假设。 对它们的输出进行平均可以减少泛化误差:
$$ E[(f(x) - y)^2] = \text{bias}^2 + \text{variance} $$
快照平均降低了方差项。
亲自尝试
使用余弦退火和 $M = 3$ 个周期训练一个 CNN。
在每个周期结束时保存权重。
比较准确率:
- 单个最终模型
- 快照集成(平均预测结果)
观察验证准确率的提升和学习曲线的平滑。
增加 $M$ 以获得更多多样性(直到收益递减)。
测试用例
| 数据集 | 模型 | 行为 |
|---|---|---|
| CIFAR-10 | CNN | 更高的测试准确率 |
| MNIST | MLP | 更稳定的预测结果 |
| 大型网络 | ResNet | 与多模型集成类似 |
| 有限的计算资源 | 任意 | 很好的权衡:以单次训练成本获得集成效果 |
复杂度
- 训练:$O(T)$(与单个模型相同)
- 预测:每个样本 $O(M)$(集成平均)
- 空间:$O(M)$ 个快照
快照集成让你鱼与熊掌兼得,既获得了集成的强大能力,又保持了单模型训练的效率,它利用学习率周期来探索和捕获多样化的解。
第 93 节. 梯度方法
921. 梯度下降
梯度下降是训练机器学习模型的基础优化算法。它通过沿着损失函数的斜率下山方向迭代地移动参数,直到达到一个(局部)最小值。
我们要解决什么问题?
大多数学习问题都涉及在参数 $\theta$ 上最小化一个损失函数 $$ L(\theta) $$ 。对于非线性函数,闭式解很少见,因此我们需要一种迭代方法来逼近最小值。
梯度下降通过向损失函数的梯度反方向移动来更新参数:
$$ \theta \leftarrow \theta - \eta , \nabla_\theta L(\theta) $$
其中:
- $\nabla_\theta L(\theta)$ 是梯度(最陡上升方向),
- $\eta$ 是学习率,控制步长。
直观理解: 如果梯度指向山上,那么朝相反方向移动就会减少损失。
它是如何工作的(通俗解释)?
想象你站在一个代表损失函数的曲面上。 你想找到最低的山谷。 在每一步:
- 测量斜率(梯度)。
- 向山下走一小步。
- 重复直到斜率变平(接近最小值)。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化参数 | 随机选择 $\theta_0$ |
| 2 | 计算梯度 | $\nabla_\theta L(\theta_t)$ |
| 3 | 更新 | $\theta_{t+1} = \theta_t - \eta , \nabla_\theta L(\theta_t)$ |
| 4 | 检查收敛性 | 如果梯度很小或变化极小则停止 |
| 5 | 重复 | 直到损失稳定 |
示例
假设 $$ L(\theta) = \theta^2 $$ 那么 $$ \nabla_\theta L = 2\theta $$ 取 $\eta = 0.1$ 和 $\theta_0 = 1.0$:
| 迭代次数 | $\theta$ | $L(\theta)$ |
|---|---|---|
| 0 | 1.000 | 1.000 |
| 1 | 0.800 | 0.640 |
| 2 | 0.640 | 0.410 |
| 3 | 0.512 | 0.262 |
每一步都将 $\theta$ 移向 0(最小值)。
微型代码(简易版本)
Python
import numpy as np
def gradient_descent(L_grad, theta0, lr=0.1, steps=100):
theta = theta0
for _ in range(steps):
grad = L_grad(theta)
theta -= lr * grad
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = gradient_descent(L_grad, theta0=1.0, lr=0.1)
print(theta_opt)C(大纲)
double theta = 1.0, lr = 0.1;
for (int i = 0; i < 100; i++) {
double grad = 2 * theta; // d/dθ (θ²)
theta -= lr * grad;
}
printf("最优 θ: %f\n", theta);为什么它很重要
- 通用优化器:适用于任何可微损失函数。
- 深度学习的基础:每个神经网络都是通过梯度下降(或其变体)训练的。
- 概念桥梁:引入了学习率、收敛性和曲率直观理解。
- 可扩展性:引出了 SGD、Momentum、Adam 等算法。
一个温和的证明(为什么它有效)
在最小值附近,泰勒展开:
$$ L(\theta + \Delta \theta) \approx L(\theta) + \nabla_\theta L(\theta)^\top \Delta \theta $$
为了减小 $L$,选择 $\Delta \theta$ 的方向为 $-\nabla_\theta L$。 如果 $\eta$ 足够小,每次更新都会单调地减少损失。
收敛速度(对于凸函数 $L$):
$$ L(\theta_t) - L(\theta^*) \le \frac{C}{t} $$
其中 $C$ 取决于 $L$ 的光滑性。
亲自尝试
- 最小化 $L(\theta) = (\theta - 3)^2$。
- 尝试 $\eta = 0.01, 0.1, 1.0$。
- 绘制 $\theta_t$ 随迭代次数的轨迹。
- 尝试多元函数 $L(\theta_1, \theta_2) = \theta_1^2 + 2\theta_2^2$。
- 在等高线图上可视化梯度箭头。
测试用例
| 损失函数 | 初始 $\theta$ | $\eta$ | 收敛性 |
|---|---|---|---|
| $\theta^2$ | 1.0 | 0.1 | 平滑下降 |
| $\theta^2$ | 1.0 | 1.0 | 超调振荡 |
| $\theta^2$ | 1.0 | 0.01 | 缓慢收敛 |
复杂度
- 每次迭代:$O(d)$(对于 $d$ 个参数)
- 内存:$O(d)$
- 收敛性:取决于曲率和步长
梯度下降是学习的引擎,一种简单而稳定的下山行进,为从线性回归到深度神经网络的一切提供动力。
922. 随机梯度下降 (SGD)
随机梯度下降 (SGD) 是经典梯度下降的一个更快、更“嘈杂”的变体。 它不是使用所有训练样本来计算每一步的梯度,而是使用单个样本(或小批量)来更新参数,从而获得了速度、随机性以及逃离浅层极小值的能力。
我们要解决什么问题?
在大规模学习中,计算完整梯度
$$ \nabla_\theta L(\theta) = \frac{1}{N} \sum_{i=1}^{N} \nabla_\theta \ell(x_i, y_i; \theta) $$
太慢了,尤其是当 $N$ 非常大时。
SGD 使用一个随机样本(或小批量)来近似完整梯度:
$$ \theta \leftarrow \theta - \eta , \nabla_\theta \ell(x_i, y_i; \theta) $$
这提供了一个廉价、嘈杂的估计,但在平均意义上效果很好。
它是如何工作的(通俗解释)?
可以把经典梯度下降想象成在迈出每一步之前检查每一个数据点,非常精确,但很慢。 而 SGD 则相反,它快速瞥一眼一个样本,做出一个猜测,稍微调整一下,然后继续前进,通过许多小而嘈杂的更新进行学习。
随着时间的推移,这些嘈杂的步骤平均起来会大致朝着正确的方向移动。
逐步说明:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 打乱数据 | 随机化训练样本的顺序 |
| 2 | 选取一个样本 | $(x_i, y_i)$ |
| 3 | 计算梯度 | $g_i = \nabla_\theta \ell(x_i, y_i; \theta)$ |
| 4 | 更新参数 | $\theta \gets \theta - \eta g_i$ |
| 5 | 重复 | 对所有样本执行(一个周期),然后重新打乱 |
示例
假设损失函数为 $$ L(\theta) = \frac{1}{N}\sum_{i=1}^{N} (\theta - x_i)^2 $$
完整梯度: $$ \nabla_\theta L = 2(\theta - \bar{x}) $$
使用一个样本 $x_i$ 的 SGD 更新: $$ \theta \gets \theta - \eta \cdot 2(\theta - x_i) $$
每次更新都将 $\theta$ 稍微拉向一个数据点,逐渐收敛到 $\bar{x}$。
微型代码(简易版本)
Python
import numpy as np
def sgd(X, y, lr=0.1, epochs=10):
n = len(y)
theta = 0.0
for _ in range(epochs):
for i in np.random.permutation(n):
grad = 2 * (theta - X[i])
theta -= lr * grad
return thetaC (大纲)
for (int epoch = 0; epoch < epochs; epoch++) {
shuffle(data);
for (int i = 0; i < n; i++) {
double grad = 2 * (theta - x[i]);
theta -= lr * grad;
}
}为什么它很重要
- 快速且可扩展:每个样本进行一次更新。
- 在线学习:可以随着新数据的到来持续适应。
- 噪声有助于逃离局部极小值。
- 深度学习的基础:现代优化器(Adam、RMSProp)都建立在 SGD 之上。
正是这个优化器使得神经网络能够在海量数据集上进行训练,而无需等待过长时间。
一个温和的证明(为什么它有效)
如果 $\mathbb{E}[\nabla_\theta \ell(x_i, y_i; \theta)] = \nabla_\theta L(\theta)$, 那么在平均意义上,SGD 遵循真实的梯度:
$$ \mathbb{E}[\theta_{t+1}] = \theta_t - \eta , \nabla_\theta L(\theta_t) $$
因此,即使每一步都是嘈杂的,期望的方向是正确的。 对于衰减的学习率 $\eta_t$,SGD 对于凸函数会收敛到全局最小值。
亲自尝试
- 为线性回归实现 SGD。
- 比较其收敛速度与全批量梯度下降。
- 在损失的等高线图上绘制其嘈杂的轨迹。
- 尝试不同的学习率:$\eta = 0.01$, $0.1$, $1.0$。
- 添加一个小批量大小(例如 16 或 32)并观察平滑效果。
测试用例
| 数据集 | 批量大小 | 行为 |
|---|---|---|
| 小型 | 1 | 高方差,快速更新 |
| 大型 | 32 | 平滑路径,稳定收敛 |
| 非凸 | 1 | 逃离局部极小值 |
| 流式数据 | 1 | 在线适应 |
复杂度
- 每次迭代:$O(1)$
- 每个周期:$O(N)$
- 内存:$O(d)$
- 收敛性:取决于学习率调度和噪声水平
随机梯度下降以精度换取速度,通过试验、噪声和许多小步进行学习,使其成为现代优化的核心动力。
923. 小批量随机梯度下降(Mini-Batch SGD)
小批量随机梯度下降在批量梯度下降和纯随机更新之间取得了平衡。它不使用所有样本或仅使用一个样本,而是使用小批量数据来更新参数,结合了效率、稳定性和速度,使其成为深度学习中的默认选择。
我们要解决什么问题?
批量梯度下降:
$$ \nabla_\theta L(\theta) = \frac{1}{N} \sum_{i=1}^{N} \nabla_\theta \ell(x_i, y_i; \theta) $$
准确但缓慢。
随机梯度下降:
$$ \nabla_\theta L(\theta) \approx \nabla_\theta \ell(x_i, y_i; \theta) $$
快速但有噪声。
小批量随机梯度下降找到了折中方案:
$$ \nabla_\theta L(\theta) \approx \frac{1}{B} \sum_{i=1}^{B} \nabla_\theta \ell(x_i, y_i; \theta) $$
其中 $B$ 是批量大小(例如 16, 32, 64)。这种方法利用了并行计算和更平滑的梯度。
它是如何工作的(通俗解释)?
我们不是看一个数据点(SGD)或所有数据点(批量),而是取一小撮,即一个小批量,来近似下坡方向。这就像在决策前听取几个意见,而不是询问所有人或只问一个人。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 打乱数据集 | 随机化顺序 |
| 2 | 分割成批次 | 每个批次大小为 $B$ |
| 3 | 遍历批次 | 计算每个批次的梯度 |
| 4 | 更新参数 | $\theta \gets \theta - \eta , g_{\text{batch}}$ |
| 5 | 每个周期重复 | 持续直到收敛 |
每个批次提供的梯度估计比 SGD 的方差更低,并且比批量梯度下降快得多。
示例
假设有 $N = 1000$ 个样本,批量大小 $B = 50$: 每个周期 = $1000 / 50 = 20$ 次更新。 每次更新:
$$ g = \frac{1}{50} \sum_{i=1}^{50} \nabla_\theta \ell(x_i, y_i) $$
并且 $$ \theta \gets \theta - \eta g $$
模型在每个周期内看到所有数据,但是分块处理的,这对于向量化计算(例如 GPU)是高效的。
微型代码(简易版本)
Python
import numpy as np
def minibatch_sgd(X, y, lr=0.1, batch_size=32, epochs=10):
n = len(y)
theta = 0.0
for _ in range(epochs):
indices = np.random.permutation(n)
for start in range(0, n, batch_size):
end = start + batch_size
batch = indices[start:end]
grad = np.mean(2 * (theta - X[batch]))
theta -= lr * grad
return thetaC(大纲)
for (int epoch = 0; epoch < epochs; epoch++) {
shuffle(data);
for (int i = 0; i < n; i += batch_size) {
int end = min(i + batch_size, n);
double grad = 0;
for (int j = i; j < end; j++)
grad += 2 * (theta - x[j]);
grad /= (end - i);
theta -= lr * grad;
}
}为什么它很重要
- 计算高效:利用了向量化和并行性。
- 噪声更小:梯度估计比单样本 SGD 更准确。
- 收敛更快:到达最小值的路径更平滑。
- GPU 友好:批次符合硬件限制。
它是训练神经网络的标准优化器。
一个温和的证明(为什么它有效)
如果 $\mathbb{E}[g_B] = \nabla_\theta L(\theta)$, 那么批次梯度 $g_B$ 是真实梯度的无偏估计量。
方差随着批量大小 $B$ 的增加而减小:
$$ \text{Var}(g_B) = \frac{\sigma^2}{B} $$
因此,更大的批次提供更平滑但成本更高的更新。小批量在成本和稳定性之间找到了一个最佳点。
亲自尝试
- 使用批量大小 1、32、128 训练线性回归。
- 绘制损失与迭代次数的关系图,观察更大批量对应的更平滑曲线。
- 观察每个周期的计算时间。
- 使用批量大小 = 32(常见默认值)。
- 尝试衰减学习率 $\eta_t$。
测试用例
| 批量大小 | 行为 |
|---|---|
| 1 | 高噪声,更新快 |
| 32 | 平衡的性能 |
| 512 | 路径平滑,每步计算量更大 |
| N(全批量) | 精确梯度,慢 |
复杂度
- 每次迭代:$O(B \cdot d)$
- 每个周期:$O(N \cdot d)$
- 内存:$O(B \cdot d)$
- 收敛性:由于方差减小,在实践中更快
小批量随机梯度下降是现代优化的主力,高效、平滑,并且完美适配并行计算。
924. 动量法
动量法是对梯度下降的一种强大改进,能帮助其在正确方向上更快移动并平滑振荡。它通过添加一个记录过去更新的速度项来实现这一点,使得优化器能够在长斜坡上加速并抵抗噪声引起的锯齿状波动。
我们正在解决什么问题?
当曲面在不同方向上曲率不同时(例如狭窄的山谷),普通的梯度下降可能会陷入停滞或减慢速度。
它可能会在陡峭的壁面间振荡,缓慢地向前移动:
$$ \theta \leftarrow \theta - \eta , \nabla_\theta L(\theta) $$
动量法通过在速度项中累积过去的梯度来解决这个问题,从而允许在主导方向上保持一致的移动。
更新规则
令 $v_t$ 为第 $t$ 步的速度。则有:
$$ v_t = \beta v_{t-1} + (1 - \beta) \nabla_\theta L(\theta_t) $$
$$ \theta_{t+1} = \theta_t - \eta v_t $$
其中:
- $\beta$ (0.8–0.99) 控制保留多少过去的动量,
- $\eta$ 是学习率。
这相当于对梯度应用指数移动平均。
它是如何工作的(通俗解释)?
想象一下将一个球滚下山。 没有动量时,它在每个凸起处都会停下来。 有了动量,它会继续滚动,携带来自先前步骤的能量,从而平滑小的振荡并加速下降。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 $\theta_0$, $v_0 = 0$ | 起始参数和速度 |
| 2 | 计算梯度 $g_t$ | $g_t = \nabla_\theta L(\theta_t)$ |
| 3 | 更新速度 | $v_t = \beta v_{t-1} + (1 - \beta) g_t$ |
| 4 | 更新参数 | $\theta_{t+1} = \theta_t - \eta v_t$ |
| 5 | 重复 | 直到收敛 |
示例
假设我们正在最小化 $$ L(\theta_1, \theta_2) = 100\theta_1^2 + \theta_2^2 $$
曲面沿 $\theta_1$ 方向陡峭,沿 $\theta_2$ 方向平坦。 没有动量:步长会沿 $\theta_1$ 方向振荡。 有动量:速度沿 $\theta_2$ 方向累积,减少锯齿状波动并更快收敛。
微型代码(简易版本)
Python
import numpy as np
def gradient_descent_momentum(L_grad, theta0, lr=0.1, beta=0.9, steps=100):
theta = theta0
v = 0
for _ in range(steps):
grad = L_grad(theta)
v = beta * v + (1 - beta) * grad
theta -= lr * v
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = gradient_descent_momentum(L_grad, theta0=1.0)
print(theta_opt)C (大纲)
double theta = 1.0, v = 0.0, lr = 0.1, beta = 0.9;
for (int i = 0; i < 100; i++) {
double grad = 2 * theta;
v = beta * v + (1 - beta) * grad;
theta -= lr * v;
}
printf("Optimal θ: %f\n", theta);为何重要
- 在平坦区域加速收敛。
- 减少陡峭山谷中的振荡。
- 简单且鲁棒,适用于任何基于梯度的优化器。
- 是高级方法(Nesterov、Adam、RMSProp)的基础。
动量法将梯度下降从一个谨慎的步行者转变为一个滚动的球,平滑、果断且快速。
一个温和的证明(为何有效)
动量法近似于对梯度进行一阶低通滤波:
$$ v_t \approx \sum_{k=0}^{t} (1 - \beta)\beta^{t-k} \nabla_\theta L(\theta_k) $$
因此更新方向变成了过去梯度的加权平均。 这种平均抑制了高频噪声,稳定了收敛。
亲自尝试
- 在 $L(\theta_1, \theta_2) = 100\theta_1^2 + \theta_2^2$ 上训练梯度下降。
- 比较使用和不使用动量法的情况。
- 尝试 $\beta = 0.8, 0.9, 0.99$,更高的 $\beta$ = 更平滑的运动。
- 在等高线图上绘制轨迹。
- 尝试使用大的学习率 $\eta$,动量法允许更大的步长。
测试用例
| 曲面 | $\beta$ | 行为 |
|---|---|---|
| 平坦 | 0.9 | 加速更快 |
| 陡峭山谷 | 0.9 | 振荡更少 |
| 噪声梯度 | 0.99 | 更新更平滑 |
| 凸曲面 | 0.8 | 稳定收敛 |
复杂度
- 每次迭代:$O(d)$
- 内存:$O(d)$ (用于存储速度)
- 收敛性:在病态曲面上比普通梯度下降更快
动量法记住了它曾经的位置,让优化器滑过凸起,加速穿过山谷,奔向最小值。
925. Nesterov 加速梯度法 (NAG)
Nesterov 加速梯度法 (NAG) 通过增加一个“前瞻”步骤改进了标准动量法,它预测参数将移动到的位置,并在该位置计算梯度。这一微小改动提供了更强的收敛保证,并在最小值附近实现了更好的控制。
我们要解决什么问题?
常规动量法帮助梯度下降获得速度,但在接近最优解时可能会超调。Nesterov 通过在实际应用梯度之前先“窥视”前方来修复这个问题,确保更新是由参数将要去往的位置引导,而不是由它们曾经所在的位置引导。
动量更新: $$ v_t = \beta v_{t-1} + (1 - \beta)\nabla_\theta L(\theta_t) $$
Nesterov 更新: $$ v_t = \beta v_{t-1} + (1 - \beta)\nabla_\theta L(\theta_t - \eta \beta v_{t-1}) $$
然后: $$ \theta_{t+1} = \theta_t - \eta v_t $$
梯度是在前瞻点处计算的,从而提供了更早的修正。
它是如何工作的?(通俗解释)
想象一下跑下山:
- 动量法:你基于当前速度移动,然后在看到自己最终位置后进行修正。
- Nesterov 法:你在迈步之前先向前看,主动调整你的方向。
这种前瞻性有助于防止超调,并使收敛更平滑。
逐步说明:
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 计算前瞻点 | $\theta_{\text{look}} = \theta_t - \eta \beta v_{t-1}$ |
| 2 | 计算梯度 | $g_t = \nabla_\theta L(\theta_{\text{look}})$ |
| 3 | 更新速度 | $v_t = \beta v_{t-1} + (1 - \beta) g_t$ |
| 4 | 更新参数 | $\theta_{t+1} = \theta_t - \eta v_t$ |
示例
假设 $L(\theta) = \theta^2$。 我们从 $\theta_0 = 1.0$, $\beta = 0.9$, $\eta = 0.1$ 开始。
每一步:
- 前瞻:$\theta_{\text{look}} = \theta - 0.1 \times 0.9 v$
- 在 $\theta_{\text{look}}$ 处计算梯度
- 更新 $v$,然后更新 $\theta$
与标准动量法相比,这种“窥视”允许更快、更稳定的收敛。
微型代码(简易版本)
Python
import numpy as np
def nesterov(L_grad, theta0, lr=0.1, beta=0.9, steps=100):
theta = theta0
v = 0.0
for _ in range(steps):
lookahead = theta - lr * beta * v
grad = L_grad(lookahead)
v = beta * v + (1 - beta) * grad
theta -= lr * v
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = nesterov(L_grad, theta0=1.0)
print(theta_opt)C 语言(概要)
double theta = 1.0, v = 0.0, lr = 0.1, beta = 0.9;
for (int i = 0; i < 100; i++) {
double lookahead = theta - lr * beta * v;
double grad = 2 * lookahead;
v = beta * v + (1 - beta) * grad;
theta -= lr * v;
}
printf("Optimal θ: %f\n", theta);为什么它很重要?
- 在凸问题上收敛更快。
- 在最小值附近更稳定,在迈步之前就修正方向。
- 广泛应用于深度学习(例如,带 Nesterov 的 SGD)。
- 理论上很强:对于凸优化问题被证明是最优的。
在实践中,NAG 提高了速度和稳定性,通常优于普通动量法。
一个温和的证明(为什么它有效)
Nesterov 方法源于凸优化理论。对于光滑凸函数:
$$ L(\theta_t) - L(\theta^*) \le O\left(\frac{1}{t^2}\right) $$
相比之下,标准梯度下降为 $O\left(\frac{1}{t}\right)$。关键在于前瞻性:在 $\theta_t - \eta \beta v_{t-1}$ 处评估梯度会导致更早的修正和更紧的界。
自己动手试试
- 用动量法和 NAG 在 $L(\theta) = \theta^2$ 上进行训练。
- 绘制收敛曲线,NAG 更快更平滑。
- 测试 $\beta = 0.8, 0.9, 0.99$。
- 尝试非凸损失函数(例如多盆地函数),注意其改进的控制能力。
- 在一个小型神经网络上比较训练速度。
测试用例
| 曲面类型 | 优化器 | 行为 |
|---|---|---|
| 凸二次型 | NAG | 比动量法更快 |
| 非凸 | NAG | 更稳定 |
| 山谷形 | NAG | 振荡更少 |
| 高曲率 | NAG | 受控的更新 |
复杂度
- 每次迭代:$O(d)$
- 内存:$O(d)$
- 收敛速度:$O(1/t^2)$(凸函数),在实践中更快
Nesterov 加速梯度法是具有远见的动量法,就像一个在迈步前会先瞥一眼前方的跑步者,能更快、更优雅地到达终点线。
926. AdaGrad(自适应梯度)
AdaGrad,全称自适应梯度,根据每个参数被更新的频率自动调整其学习率。 梯度大且频繁更新的参数采取较小的步长,而很少更新的参数则采取较大的步长,这非常适用于稀疏特征或不平衡数据。
我们要解决什么问题?
标准梯度下降使用单一的全局学习率:
$$ \theta_{t+1} = \theta_t - \eta \nabla_\theta L(\theta_t) $$
但在真实数据中:
- 有些参数更新频繁(梯度频率高),
- 有些参数很少变化(频率低)。
单一的学习率可能导致某些参数更新过度,而另一些则更新不足。 AdaGrad 为每个维度单独调整 $\eta$。
更新规则
每个参数 $\theta_i$ 维护一个梯度平方的累积和:
$$ G_{t,i} = G_{t-1,i} + g_{t,i}^2 $$
然后进行更新:
$$ \theta_{t+1,i} = \theta_{t,i} - \frac{\eta}{\sqrt{G_{t,i}} + \epsilon} , g_{t,i} $$
其中:
- $g_{t,i}$ 是第 $t$ 步时参数 $i$ 的梯度,
- $\epsilon$ 用于避免除零错误(例如 $10^{-8}$),
- $\eta$ 是初始学习率。
因此,有效的学习率会随时间衰减:
$$ \eta_{t,i} = \frac{\eta}{\sqrt{G_{t,i}} + \epsilon} $$
它是如何工作的(通俗解释)?
可以把 AdaGrad 想象成给每个参数分配自己的步长,步长与其被更新的频率成反比。
- 频繁变化的参数采取较小的步长。
- 很少更新的参数采取较大的步长以跟上进度。
这在 NLP、推荐系统和稀疏向量(例如词嵌入)中尤其有用。
逐步过程:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 $\theta$, $G=0$ | 初始化参数和累加器 |
| 2 | 计算梯度 $g$ | $\nabla_\theta L(\theta_t)$ |
| 3 | 更新累加器 | $G \gets G + g \odot g$ |
| 4 | 缩放学习率 | $\eta_t = \eta / \sqrt{G + \epsilon}$ |
| 5 | 更新参数 | $\theta \gets \theta - \eta_t \odot g$ |
示例
假设我们优化 $L(\theta_1, \theta_2)$。 随时间变化的梯度:
| 步骤 | $g_1$ | $g_2$ |
|---|---|---|
| 1 | 1.0 | 0.1 |
| 2 | 1.0 | 0.1 |
| 3 | 1.0 | 0.1 |
那么 $G_1$ 的增长速度比 $G_2$ 快:
- $\eta_1$ 衰减更多,减缓更新,
- $\eta_2$ 保持较大,适应移动较慢的参数。
微型代码(简易版本)
Python
import numpy as np
def adagrad(L_grad, theta0, lr=0.1, eps=1e-8, steps=100):
theta = theta0
G = np.zeros_like(theta)
for _ in range(steps):
grad = L_grad(theta)
G += grad 2
theta -= lr * grad / (np.sqrt(G) + eps)
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = adagrad(L_grad, np.array([1.0]))
print(theta_opt)C(大纲)
double theta = 1.0, G = 0.0, lr = 0.1, eps = 1e-8;
for (int i = 0; i < 100; i++) {
double grad = 2 * theta;
G += grad * grad;
theta -= lr * grad / (sqrt(G) + eps);
}
printf("最优 θ: %f\n", theta);为什么它很重要
- 自适应步长:无需为每个参数手动调优。
- 适用于稀疏特征。
- 单调的学习率衰减:稳定收敛。
- 为 RMSProp 和 Adam 奠定了基础。
AdaGrad 是最早的自适应优化器之一,为现代深度学习方法铺平了道路。
一个温和的证明(为什么有效)
AdaGrad 对梯度执行对角预处理:
$$ \theta_{t+1} = \theta_t - \eta G_t^{-1/2} g_t $$
这里 $G_t^{-1/2}$ 根据历史幅度重新缩放每个维度。 这提供了一种有效的二阶修正,其思想类似于牛顿法,但更简单。
亲自尝试
- 优化 $L(\theta_1, \theta_2) = \theta_1^2 + 100\theta_2^2$。
- 比较普通梯度下降与 AdaGrad。
- 观察更平滑的路径和自适应速度。
- 在稀疏特征向量(许多零)上尝试。
- 跟踪 $\eta_t$,观察其如何衰减。
测试用例
| 场景 | 行为 |
|---|---|
| 稀疏 | 快速收敛 |
| 密集 | 步长衰减过快 |
| 长时间训练 | 提前停止 |
| NLP 词嵌入 | 非常有效 |
复杂度
- 每次迭代:$O(d)$
- 内存:$O(d)$(累加器 $G$)
- 收敛性:平滑但可能停滞($\eta_t$ 过小)
AdaGrad 学会了如何学习,单独缩放每个维度,让很少更新的特征发出更大的声音,让频繁更新的特征安静下来。
927. RMSProp(均方根传播)
RMSProp 改进了 AdaGrad,防止其学习率衰减过快。 它维护一个平方梯度的指数加权移动平均值,而不是无限累加,从而在保持适应性的同时避免停滞。
我们要解决什么问题?
在 AdaGrad 中,分母
$$ \sqrt{G_t} = \sqrt{\sum_{i=1}^t g_i^2} $$
持续增长,因此有效学习率
$$ \eta_t = \frac{\eta}{\sqrt{G_t} + \epsilon} $$
会随时间推移而过度缩小。 训练可能过早停止,尤其是在像深度神经网络这样的非凸场景中。
RMSProp 通过使用平方梯度的衰减平均值来解决这个问题,在适应性和持久性之间取得平衡。
更新规则
维护平方梯度的指数移动平均值:
$$ E[g^2]t = \beta E[g^2]{t-1} + (1 - \beta) g_t^2 $$
然后更新参数:
$$ \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{E[g^2]_t} + \epsilon} , g_t $$
其中:
- $\beta$ 是衰减率(通常为 0.9),
- $\epsilon$(例如 $10^{-8}$)防止除以零,
- $\eta$ 是学习率(通常为 $10^{-3}$ 或 $10^{-4}$)。
它是如何工作的(通俗解释)?
RMSProp 跟踪梯度的近期平均幅度。 如果某个方向的梯度很大,它就在那里缩小学习率;如果梯度很小,就增加学习率。 与 AdaGrad 不同,它会遗忘旧的梯度,使训练在整个过程中保持适应性。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 $\theta$, $E[g^2]=0$ | 参数 + 运行平均值 |
| 2 | 计算梯度 $g_t$ | $\nabla_\theta L(\theta_t)$ |
| 3 | 更新平均值 | $E[g^2]t = \beta E[g^2]{t-1} + (1 - \beta) g_t^2$ |
| 4 | 缩放学习率 | $\eta_t = \eta / \sqrt{E[g^2]_t + \epsilon}$ |
| 5 | 更新参数 | $\theta \gets \theta - \eta_t g_t$ |
示例
假设梯度振荡:
- 步骤 1: $g = 1.0$
- 步骤 2: $g = 0.2$
- 步骤 3: $g = 0.5$
RMSProp 不是求和($1.0^2 + 0.2^2 + 0.5^2$),而是使用:
$$ E[g^2]t = 0.9 E[g^2]{t-1} + 0.1 g_t^2 $$
这强调了最近的梯度,使学习率能够持续适应。
微型代码(简易版本)
Python
import numpy as np
def rmsprop(L_grad, theta0, lr=0.01, beta=0.9, eps=1e-8, steps=100):
theta = theta0
Eg2 = np.zeros_like(theta)
for _ in range(steps):
grad = L_grad(theta)
Eg2 = beta * Eg2 + (1 - beta) * grad 2
theta -= lr * grad / (np.sqrt(Eg2) + eps)
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = rmsprop(L_grad, np.array([1.0]))
print(theta_opt)C(大纲)
double theta = 1.0, Eg2 = 0.0, lr = 0.01, beta = 0.9, eps = 1e-8;
for (int i = 0; i < 100; i++) {
double grad = 2 * theta;
Eg2 = beta * Eg2 + (1 - beta) * grad * grad;
theta -= lr * grad / (sqrt(Eg2) + eps);
}
printf("最优 θ: %f\n", theta);为什么它很重要
- 跨维度的自适应学习率。
- 不像 AdaGrad 那样过早衰减。
- 为深度网络(尤其是 RNN)提供稳定的训练。
- 许多早期深度学习框架的默认优化器(例如 Adam 之前的 TensorFlow 默认优化器)。
它就像学习率的恒温器,根据最近的“温度”(梯度能量)不断调整。
一个温和的证明(为什么它有效)
RMSProp 使用移动平均值来近似二阶曲率:
$$ \mathbb{E}[g^2]_t \approx \text{diag}(H) $$
因此每个维度的步长变为:
$$ \Delta \theta_i \propto \frac{1}{\sqrt{\mathbb{E}[g_i^2]_t}} $$
充当预处理器,稳定沿陡峭或平坦方向的更新。
亲自尝试
- 训练 $L(\theta) = 100\theta_1^2 + \theta_2^2$。
- 比较 AdaGrad 与 RMSProp。
- 绘制学习率随时间变化的图,RMSProp 保持活跃的时间更长。
- 尝试 $\beta = 0.9, 0.99$。
- 观察在非平稳梯度上更平滑的收敛。
测试用例
| 曲面 | 优化器 | 行为 |
|---|---|---|
| 陡峭山谷 | RMSProp | 平滑收敛 |
| 稀疏数据 | RMSProp | 按维度适应 |
| 长时间训练 | RMSProp | 持续学习 |
| 非平稳 | RMSProp | 稳定更新 |
复杂度
- 每次迭代:$O(d)$
- 内存:$O(d)$(运行平均值)
- 收敛性:在深度网络中比 AdaGrad 更快、更稳定
RMSProp 是带有记忆的 AdaGrad,具有适应性、遗忘性,并针对深度学习的动态环境进行了调整。
928. Adam(自适应矩估计)
Adam,全称自适应矩估计,结合了动量法和 RMSProp 的优点。 它同时跟踪梯度的均值(一阶矩)和方差(二阶矩),以提供自适应学习率和平滑、稳定的收敛,使其成为深度学习中最广泛使用的优化器。
我们要解决什么问题?
SGD 可能会振荡或需要仔细调整学习率。 动量法加速了收敛但缺乏自适应性。 RMSProp 自适应调整学习率但忽略了方向历史。
Adam 融合了这两种思想,维护:
- 速度(一阶矩):梯度的指数移动平均。
- 尺度(二阶矩):梯度平方的指数移动平均。
两者共同作用,稳定了更新并为每个参数自适应调整步长。
更新规则
在每一步 $t$:
计算梯度: $$ g_t = \nabla_\theta L(\theta_t) $$
更新有偏一阶矩(均值): $$ m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t $$
更新有偏二阶矩(方差): $$ v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 $$
偏差校正: $$ \hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t} $$
参数更新: $$ \theta_{t+1} = \theta_t - \eta , \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} $$
典型超参数:
- $\eta = 10^{-3}$
- $\beta_1 = 0.9$
- $\beta_2 = 0.999$
- $\epsilon = 10^{-8}$
它是如何工作的(通俗解释)?
Adam 融合了动量法的方向记忆和 RMSProp 的自适应缩放。
可以把它想象成一个聪明的自动驾驶仪:
- 一阶矩(类似速度)保持持续向下的移动。
- 二阶矩(类似减震器)根据地形粗糙度调整步长。
两者结合,使得学习过程快速、平滑且对噪声鲁棒。
逐步说明:
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 计算 $g_t$ | 当前步骤的梯度 |
| 2 | 更新 $m_t$, $v_t$ | 梯度和梯度平方的移动平均 |
| 3 | 校正偏差 | 调整初始化偏差 |
| 4 | 计算更新 | 用 $\sqrt{v_t}$ 缩放梯度 |
| 5 | 应用步长 | $\theta \gets \theta - \eta \hat{m}_t / (\sqrt{\hat{v}_t} + \epsilon)$ |
示例
设 $L(\theta) = \theta^2$。 从 $\theta_0 = 1.0$ 开始:
- $m_t$ 平滑梯度方向,
- $v_t$ 根据最近的梯度能量进行缩放,
- 学习率每步自适应调整,早期较大,后期较小。
微型代码(简易版本)
Python
import numpy as np
def adam(L_grad, theta0, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8, steps=100):
theta = theta0
m = np.zeros_like(theta)
v = np.zeros_like(theta)
for t in range(1, steps + 1):
g = L_grad(theta)
m = beta1 * m + (1 - beta1) * g
v = beta2 * v + (1 - beta2) * (g 2)
m_hat = m / (1 - beta1 t)
v_hat = v / (1 - beta2 t)
theta -= lr * m_hat / (np.sqrt(v_hat) + eps)
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = adam(L_grad, np.array([1.0]))
print(theta_opt)C(大纲)
double theta = 1.0, m = 0.0, v = 0.0;
double lr = 0.001, beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
for (int t = 1; t <= 100; t++) {
double g = 2 * theta;
m = beta1 * m + (1 - beta1) * g;
v = beta2 * v + (1 - beta2) * g * g;
double m_hat = m / (1 - pow(beta1, t));
double v_hat = v / (1 - pow(beta2, t));
theta -= lr * m_hat / (sqrt(v_hat) + eps);
}
printf("最优 θ: %f\n", theta);为什么它很重要
- 每个参数的自适应学习率。
- 动量法提供速度,RMS 缩放提供稳定性。
- 几乎不需要调整超参数。
- 在深度网络中效果良好,尤其是在有噪声梯度的情况下。
Adam 是大多数现代深度学习模型(从 CNN 到 Transformer)的默认优化器。
一个温和的证明(为什么它有效)
偏差校正确保了一阶矩和二阶矩的无偏估计:
$$ \mathbb{E}[m_t] = \nabla_\theta L(\theta_t), \quad \mathbb{E}[v_t] = \mathbb{E}[g_t^2] $$
利用这些,Adam 近似于一个二阶预处理器:
$$ \Delta \theta \propto \frac{m_t}{\sqrt{v_t}} $$
这稳定了在病态地形中的步长,从而实现了更快、更安全的收敛。
亲自尝试
- 用 Adam 和 SGD 分别训练一个神经网络。
- 观察更快的收敛速度。
- 尝试 $\beta_1 = 0.9$, $\beta_2 = 0.999$, $\eta = 10^{-3}$。
- 尝试解耦权重衰减(AdamW)。
- 绘制学习曲线与迭代次数的关系图。
测试用例
| 数据集 | 优化器 | 行为 |
|---|---|---|
| 深度网络 | Adam | 快速、平滑的收敛 |
| 稀疏特征 | Adam | 稳定的逐参数步长 |
| 噪声梯度 | Adam | 鲁棒的更新 |
| 平坦最小值 | Adam | 优雅地收敛 |
复杂度
- 每次迭代:$O(d)$
- 内存:$O(2d)$(用于 $m$, $v$)
- 收敛性:快速、稳定、广泛适用
Adam 是优化领域的"自动变速箱",为现代机器学习结合了速度、控制和自适应性。
929. AdamW(解耦权重衰减的 Adam)
AdamW 是 Adam 的一个改进版本,它修复了一个微妙但重要的问题:权重衰减(L2 正则化)的应用方式。 在标准的 Adam 中,L2 惩罚项与自适应学习率的交互不正确。 AdamW 将权重衰减与梯度更新解耦,从而产生更准确的正则化和更好的泛化能力。
我们要解决什么问题?
在经典的梯度下降中,L2 正则化(权重衰减)的应用方式如下:
$$ \theta \leftarrow \theta - \eta , (\nabla_\theta L + \lambda \theta) $$
但在 Adam 中,梯度被 $\sqrt{v_t}$ 重新缩放,因此将 $\lambda \theta$ 添加到梯度项内会导致惩罚项在每个参数上的缩放不均匀,这不是真正的权重衰减。
AdamW 通过将权重衰减与梯度缩放解耦来解决这个问题。
更新规则
与 Adam 相同,但权重衰减是独立的:
梯度矩: $$ m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t $$ $$ v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 $$
偏差校正: $$ \hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t} $$
带解耦衰减的参数更新: $$ \theta_{t+1} = \theta_t - \eta \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_t \right) $$
其中 $\lambda$ 是权重衰减系数。
它是如何工作的?(通俗解释)
在 Adam 中,L2 正则化被自适应学习率错误地缩放。 在 AdamW 中,权重衰减直接应用于参数,就像在 SGD 中一样。
这确保了每一步都有一致的收缩,与梯度大小无关。
可以这样理解:
- Adam:"重新缩放"的惩罚 → 不规则的正则化。
- AdamW:"纯粹"的衰减 → 一致的正则化强度。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 计算梯度 $g_t$ | $\nabla_\theta L(\theta_t)$ |
| 2 | 更新矩 | $m_t$, $v_t$ |
| 3 | 应用偏差校正 | $\hat{m}_t$, $\hat{v}_t$ |
| 4 | 更新权重 | $\theta \gets \theta - \eta , \hat{m}_t / \sqrt{\hat{v}_t + \epsilon}$ |
| 5 | 应用衰减 | $\theta \gets \theta - \eta \lambda \theta$ |
示例
对于一个参数 $\theta = 1.0$, 设 $\eta = 0.01$, $\lambda = 0.1$。
衰减步骤很简单:
$$ \theta \gets \theta - \eta \lambda \theta = \theta(1 - 0.001) $$
这种衰减独立于梯度大小发生,提供了清晰的正则化。
微型代码(简易版本)
Python
import numpy as np
def adamw(L_grad, theta0, lr=0.001, beta1=0.9, beta2=0.999, wd=0.01, eps=1e-8, steps=100):
theta = theta0
m = np.zeros_like(theta)
v = np.zeros_like(theta)
for t in range(1, steps + 1):
g = L_grad(theta)
m = beta1 * m + (1 - beta1) * g
v = beta2 * v + (1 - beta2) * (g 2)
m_hat = m / (1 - beta1 t)
v_hat = v / (1 - beta2 t)
theta -= lr * (m_hat / (np.sqrt(v_hat) + eps) + wd * theta)
return theta
# 示例:最小化 L(theta) = theta^2
L_grad = lambda t: 2 * t
theta_opt = adamw(L_grad, np.array([1.0]))
print(theta_opt)C(大纲)
double theta = 1.0, m = 0.0, v = 0.0;
double lr = 0.001, beta1 = 0.9, beta2 = 0.999, wd = 0.01, eps = 1e-8;
for (int t = 1; t <= 100; t++) {
double g = 2 * theta;
m = beta1 * m + (1 - beta1) * g;
v = beta2 * v + (1 - beta2) * g * g;
double m_hat = m / (1 - pow(beta1, t));
double v_hat = v / (1 - pow(beta2, t));
theta -= lr * (m_hat / (sqrt(v_hat) + eps) + wd * theta);
}
printf("最优 θ: %f\n", theta);为什么它很重要
- 真正的权重衰减:一致的 L2 惩罚。
- 更好的泛化能力:尤其是在深度网络中。
- 修复了 Adam 倾向于较大权重的问题。
- 是 PyTorch (
torch.optim.AdamW) 和 Transformers 中的默认优化器。
AdamW 是训练大型神经网络(如 BERT 和 GPT)的必备工具。
一个温和的证明(为什么它有效)
在 Adam 中,L2 惩罚项被 $\frac{1}{\sqrt{v_t}}$ 缩放,这破坏了与权重衰减的理论等价性。
通过解耦:
$$ \text{更新: } \theta \gets \theta - \eta \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon} $$
$$ \text{衰减: } \theta \gets (1 - \eta \lambda)\theta $$
正则化项线性且一致地作用,独立于梯度统计量。
亲自尝试
- 使用 Adam 与 AdamW 训练一个小型 CNN。
- 比较验证损失和权重范数。
- 尝试 $\lambda = 0.01, 0.1, 0.001$。
- 观察更平滑的收敛和更好的测试性能。
- 跟踪 $\theta$ 的范数,AdamW 能很好地控制它。
测试用例
| 模型 | 优化器 | 行为 |
|---|---|---|
| MLP | Adam | 过拟合(权重增长) |
| MLP | AdamW | 更好的泛化能力 |
| Transformer | AdamW | 训练稳定 |
| CNN | AdamW | 受控的权重增长 |
复杂度
- 每次迭代:$O(d)$
- 内存:$O(2d)$
- 收敛性:与 Adam 相同,但泛化能力更好
AdamW 保留了 Adam 的自适应优势,但恢复了正确的正则化,这是一个简单的修复,却对现代深度学习产生了深远的影响。
930. L-BFGS(有限内存 Broyden–Fletcher–Goldfarb–Shanno)
L-BFGS 是一种强大的拟牛顿优化方法,它仅使用少量内存来近似二阶曲率(海森矩阵)。 通过建模损失的局部形状,它比 SGD 等一阶方法收敛更快,且无需显式计算海森矩阵。
我们要解决什么问题?
在优化中,牛顿法使用逆海森矩阵更新参数:
$$ \theta_{t+1} = \theta_t - H_t^{-1} \nabla_\theta L(\theta_t) $$
但对于高维问题,存储和求逆 $H_t$(大小为 $d \times d$)成本太高,需要 $O(d^2)$ 的内存和 $O(d^3)$ 的计算量。
L-BFGS(有限内存 BFGS)通过以下方式解决此问题:
- 从不存储完整的海森矩阵。
- 维护梯度和参数变化的滚动历史记录,以高效地近似 $H_t^{-1}$。
更新规则(概念形式)
给定梯度 $g_t$ 和参数步长 $s_t = \theta_{t+1} - \theta_t$:
计算梯度变化: $$ y_t = g_{t+1} - g_t $$
使用秩-2 校正(BFGS 规则)更新逆海森矩阵估计 $H_{t+1}$:
$$ H_{t+1} = (I - \rho_t s_t y_t^\top) H_t (I - \rho_t y_t s_t^\top) + \rho_t s_t s_t^\top $$
其中 $\rho_t = \frac{1}{y_t^\top s_t}$。
使用 $H_{t+1}$ 计算下一个更新方向: $$ p_t = -H_{t+1} g_{t+1} $$
L-BFGS 仅保留少量 $(s_t, y_t)$ 对(例如最后 10 个),从而将内存从 $O(d^2)$ 减少到 $O(md)$。
它是如何工作的(通俗解释)?
把 L-BFGS 想象成一种智能的梯度下降:
- 它“记住”了当你移动时梯度是如何变化的。
- 从这些变化中,它推断出曲率(有多陡或多平),就像构建一个局部地图。
- 然后,它会调整每个方向的步长,以更快地取得进展。
你不需要完整的海森矩阵,只需要步骤的历史记录。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 计算梯度 $g_t$ | 在当前 $\theta_t$ 处评估 |
| 2 | 确定搜索方向 | $p_t = -H_t g_t$ |
| 3 | 线搜索 | 找到步长 $\alpha_t$ 以最小化 $L(\theta_t + \alpha_t p_t)$ |
| 4 | 更新参数 | $\theta_{t+1} = \theta_t + \alpha_t p_t$ |
| 5 | 存储历史记录 | 为下一次迭代存储 $(s_t, y_t)$ 对 |
| 6 | 更新逆海森矩阵 | 使用有限内存公式 |
示例
假设你最小化 $L(\theta) = \theta_1^2 + 10 \theta_2^2$(一个拉长的碗形)。 普通梯度下降会沿着陡峭的轴曲折前进,收敛缓慢。 L-BFGS 估计曲率并对梯度进行预处理,沿着直接的对角线路径走向最小值。
微型代码(简易版本)
Python(使用 SciPy)
import numpy as np
from scipy.optimize import fmin_l_bfgs_b
def loss(theta):
return np.sum(theta 2), 2 * theta # 返回(损失,梯度)
theta0 = np.array([5.0, 1.0])
theta_opt, f_min, info = fmin_l_bfgs_b(loss, theta0)
print("最优 θ:", theta_opt)C(大纲)
// 仅伪代码:手动实现 L-BFGS 需要线搜索和历史缓冲区
// 1. 初始化 theta, grad
// 2. 维护数组用于 s[i] = delta_theta, y[i] = delta_grad(有限 m 历史)
// 3. 双循环递归计算搜索方向
// 4. 执行线搜索以找到最优步长
// 5. 更新 theta,存储新的 s, y为什么它很重要
- 比一阶方法收敛更快。
- 不需要海森矩阵,使用梯度差。
- 适用于凸、平滑函数。
- 常用于逻辑回归、支持向量机和经典机器学习(在深度网络之前)。
在深度学习中,L-BFGS 用于微调或小型网络(其中全批次训练是可行的)。
一个温和的证明(为什么它有效)
如果 $y_t^\top s_t > 0$,BFGS 确保 $H_t$ 的正定性,从而保证下降方向:
$$ g_t^\top p_t = -g_t^\top H_t g_t < 0 $$
因此,每一步都向下移动。
L-BFGS 近似牛顿步:
$$ \Delta \theta = -H_t^{-1} g_t $$
使用紧凑的双循环递归,从存储的 $(s, y)$ 对中重构 $H_t g_t$。
自己动手试试
- 在二次函数 $L(\theta) = \theta_1^2 + 100\theta_2^2$ 上比较 L-BFGS 与 SGD。
- 可视化轨迹,L-BFGS 以更少的步骤收敛。
- 改变历史大小 $m = 3, 5, 10$。
- 尝试使用和不使用线搜索。
- 在 SciPy 中使用
fmin_l_bfgs_b进行逻辑回归。
测试用例
| 函数 | 优化器 | 行为 |
|---|---|---|
| 二次碗形 | L-BFGS | 快速收敛 |
| 病态条件 | L-BFGS | 调整曲率 |
| 非凸 | L-BFGS | 可能陷入局部最小值 |
| 深度网络 | L-BFGS | 仅在全批次训练时有效 |
复杂度
- 每次迭代:$O(md)$(内存为 $m$)
- 内存:$O(md)$
- 收敛性:对于凸平滑函数具有超线性收敛
L-BFGS 是一种“内存高效的牛顿法”,它利用历史记录而非海森矩阵来获取曲率,从而在平滑地形上加速收敛。
第 94 节 深度学习
931. 反向传播
反向传播是训练神经网络的基石算法。它通过在整个网络中应用微积分链式法则,高效地计算损失相对于每个权重的梯度,使得基于梯度的优化器(如 SGD 或 Adam)能够更新参数。
我们要解决什么问题?
在神经网络中,我们希望最小化关于参数 $\theta$(权重和偏置)的损失函数 $$ L(\theta) $$ 。手动为每个参数直接计算 $\frac{\partial L}{\partial \theta}$ 是不可行的,因为依赖关系太多,路径太多。
反向传播提供了一种系统、高效的方法,利用链式法则在一次反向传播中计算所有梯度。
核心思想
网络中的每一层都对输入进行变换: $$ a^{(l)} = f^{(l)}(W^{(l)} a^{(l-1)} + b^{(l)}) $$
损失 $L$ 取决于最终输出 $a^{(L)}$。 通过应用链式法则,我们将导数从输出层反向传播到每一层。
对于每一层 $l$,我们计算:
输出误差: $$ \delta^{(L)} = \nabla_{a^{(L)}} L \odot f'^{(L)}(z^{(L)}) $$
反向递归: $$ \delta^{(l)} = (W^{(l+1)})^\top \delta^{(l+1)} \odot f'^{(l)}(z^{(l)}) $$
梯度: $$ \frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} (a^{(l-1)})^\top, \quad \frac{\partial L}{\partial b^{(l)}} = \delta^{(l)} $$
然后使用任何基于梯度的优化器更新参数。
它是如何工作的(通俗解释)?
可以把反向传播想象成归咎错误:
- 输出层将其预测与目标进行比较,并计算误差。
- 每个隐藏层会问:“我对那个误差贡献了多少?” 它通过连接将功劳(或过错)向后传递。
通过重复这个过程,每个神经元都学会了如何调整其权重以减少未来的错误。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 前向传播 | 逐层计算激活值 |
| 2 | 计算损失 | 比较预测值与目标值 |
| 3 | 初始化输出误差 | 损失相对于最终层的导数 |
| 4 | 反向传播 | 逐层应用链式法则 |
| 5 | 计算梯度 | 针对所有权重和偏置 |
| 6 | 更新参数 | 使用优化器(例如 SGD, Adam) |
示例
对于一个 2 层网络: $$ a^{(1)} = \sigma(W^{(1)} x + b^{(1)}) $$ $$ a^{(2)} = \text{softmax}(W^{(2)} a^{(1)} + b^{(2)}) $$ $$ L = \text{cross_entropy}(a^{(2)}, y) $$
那么:
- 输出层误差: $$ \delta^{(2)} = a^{(2)} - y $$
- 隐藏层误差: $$ \delta^{(1)} = (W^{(2)})^\top \delta^{(2)} \odot \sigma'(z^{(1)}) $$
微型代码(简易版本)
Python
import numpy as np
# 简单的 1 隐藏层神经网络
def sigmoid(x): return 1 / (1 + np.exp(-x))
def sigmoid_deriv(x): return x * (1 - x)
# 前向传播
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([[0], [1], [1], [0]])
W1 = np.random.randn(2, 2)
b1 = np.zeros((1, 2))
W2 = np.random.randn(2, 1)
b2 = np.zeros((1, 1))
lr = 0.1
for epoch in range(1000):
# 前向传播
z1 = X @ W1 + b1
a1 = sigmoid(z1)
z2 = a1 @ W2 + b2
a2 = sigmoid(z2)
loss = np.mean((y - a2) 2)
# 反向传播
dL_da2 = -(y - a2)
da2_dz2 = sigmoid_deriv(a2)
dz2_dW2 = a1
delta2 = dL_da2 * da2_dz2
dW2 = dz2_dW2.T @ delta2
db2 = np.sum(delta2, axis=0, keepdims=True)
delta1 = (delta2 @ W2.T) * sigmoid_deriv(a1)
dW1 = X.T @ delta1
db1 = np.sum(delta1, axis=0, keepdims=True)
# 更新
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2C(大纲)
// 1. 前向传播:计算激活值
// 2. 计算损失和输出误差
// 3. 反向传播:使用链式法则传播误差
// 4. 计算权重梯度
// 5. 更新权重:W -= lr * grad为什么它很重要
- 高效:以 $O(\text{#参数})$ 的复杂度计算所有梯度。
- 可扩展:适用于任何可微分的网络。
- 深度学习的基础:每一个现代网络,如 CNN、RNN、Transformer,都使用它。
- 实现了自动微分(autograd)。
反向传播将神经网络从理论变为实践,推动了现代人工智能革命。
一个温和的证明(为什么它有效)
对于复合函数 $$ f(x) = f_3(f_2(f_1(x))) $$ 根据链式法则: $$ \frac{df}{dx} = f_3'(f_2(f_1(x))) \cdot f_2'(f_1(x)) \cdot f_1'(x) $$
反向传播高效地递归应用此法则,缓存前向传播中的中间结果(激活值),避免了重复计算。
亲自尝试
- 实现一个 2 层感知机。
- 手动计算梯度(小型网络)并与反向传播的结果进行比较。
- 可视化每层的 $\delta$ 值。
- 添加 ReLU 激活函数,并注意其导数的简洁性。
- 比较缓存激活值和不缓存时的训练速度。
测试用例
| 网络 | 层数 | 行为 |
|---|---|---|
| 线性 | 1 | 梯度与解析解匹配 |
| 多层感知机 | 2 | 平滑收敛 |
| 深度网络 | 10 | 配合缓存工作 |
| 非线性 | ReLU | 稀疏梯度,稳定 |
复杂度
- 前向传播:$O(P)$
- 反向传播:$O(P)$
- 内存:$O(P)$ 用于存储激活值 ($P$ = 参数数量)
反向传播是学习的核心,它反向应用微积分,教会机器如何调整每一个连接,以做出更好的预测。
932. Xavier / He 初始化
神经网络通过前向传播信号和反向传播梯度来学习。但如果权重过大或过小,激活值会在层间爆炸或消失。 Xavier 和 He 初始化通过根据输入(有时也包括输出)连接的数量来缩放初始权重,从而解决这个问题,使信号在网络中的方差保持稳定。
我们正在解决什么问题?
在初始化神经网络权重时,经常会出现两个问题:
激活值爆炸: 如果权重太大,激活值会随着每一层呈指数级增长。
激活值消失: 如果权重太小,信号会缩小到零,梯度消失。
我们希望激活值的方差在各层之间保持大致恒定: $$ \mathrm{Var}[a^{(l)}] \approx \mathrm{Var}[a^{(l-1)}] $$
Xavier 和 He 初始化使用统计推理来找到合适的缩放比例。
核心思想
对于一个神经元: $$ a^{(l)} = f(W^{(l)} a^{(l-1)} + b^{(l)}) $$
假设每个输入的方差为 $\mathrm{Var}[a^{(l-1)}] = v$, 每个权重的方差为 $\mathrm{Var}[W^{(l)}] = \sigma^2$。
如果我们想保持方差,需要: $$ n_{\text{in}} \sigma^2 = 1 $$
因此我们设定: $$ \sigma^2 = \frac{1}{n_{\text{in}}} $$
这就得到了 Xavier (Glorot) 初始化。
公式
Xavier (Glorot) 初始化
对于 tanh 或 sigmoid 激活函数(零中心):
- 正态分布: $$ W \sim \mathcal{N}\left(0, \frac{1}{n_{\text{in}}}\right) $$
- 均匀分布: $$ W \sim U\left[-\sqrt{\frac{6}{n_{\text{in}} + n_{\text{out}}}}, \sqrt{\frac{6}{n_{\text{in}} + n_{\text{out}}}}\right] $$
He 初始化
对于 ReLU 激活函数(一半的输入被置零):
- 正态: $$ W \sim \mathcal{N}\left(0, \frac{2}{n_{\text{in}}}\right) $$
- 均匀: $$ W \sim U\left[-\sqrt{\frac{6}{n_{\text{in}}}}, \sqrt{\frac{6}{n_{\text{in}}}}\right] $$
He 初始化将方差加倍,以应对 ReLU 的稀疏性。
它是如何工作的(通俗解释)?
想象一下水流通过一系列管道(层)。 如果有些管道变宽,有些变窄,水流就会发生不可预测的变化。 Xavier 和 He 使每一层管道的直径与其输入数量成比例,从而保持“流量”(方差)一致。
- Xavier:平衡前向和后向流量,以实现平滑的梯度传播。
- He:为 ReLU 提升方差,因为一半的激活值被置零。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 选择激活函数 | tanh → Xavier, ReLU → He |
| 2 | 计算 fan-in | $n_{\text{in}} = \text{# 输入数量}$ |
| 3 | 采样权重 | 从缩放的正态/均匀分布中采样 |
| 4 | 初始化偏置 | 通常为零 |
| 5 | 开始训练 | 激活值保持健康的方差 |
示例
假设一个层有 256 个输入和 128 个输出。
Xavier (tanh): $$ \text{std} = \sqrt{\frac{2}{256 + 128}} = 0.05 $$ $$ W \sim \mathcal{N}(0, 0.05^2) $$
He (ReLU): $$ \text{std} = \sqrt{\frac{2}{256}} = 0.088 $$ $$ W \sim \mathcal{N}(0, 0.088^2) $$
微型代码(简易版本)
Python (NumPy)
import numpy as np
def xavier_init(n_in, n_out):
limit = np.sqrt(6 / (n_in + n_out))
return np.random.uniform(-limit, limit, (n_in, n_out))
def he_init(n_in, n_out):
std = np.sqrt(2 / n_in)
return np.random.randn(n_in, n_out) * std
# 示例
W1 = xavier_init(256, 128)
W2 = he_init(256, 128)C (大纲)
#include <math.h>
#include <stdlib.h>
double rand_uniform(double a, double b) {
return a + (b - a) * ((double) rand() / RAND_MAX);
}
void xavier_init(double* W, int n_in, int n_out) {
double limit = sqrt(6.0 / (n_in + n_out));
for (int i = 0; i < n_in * n_out; i++)
W[i] = rand_uniform(-limit, limit);
}为什么它很重要
- 防止激活值消失/爆炸。
- 保持梯度方差在各层之间稳定。
- 对于深度网络(10+ 层)至关重要。
- 简单的改变 → 训练稳定性的大幅提升。
没有适当的初始化,即使是最好的优化器也无法收敛。
一个温和的证明(为什么它有效)
神经元输出的方差: $$ \mathrm{Var}[a^{(l)}] = n_{\text{in}} \mathrm{Var}[W^{(l)}] \mathrm{Var}[a^{(l-1)}] $$
为了保持 $\mathrm{Var}[a^{(l)}] = \mathrm{Var}[a^{(l-1)}]$,设定: $$ \mathrm{Var}[W^{(l)}] = \frac{1}{n_{\text{in}}} $$
ReLU 将有效方差减半,因此乘以 2 → He 初始化。
亲自尝试
- 用随机小权重训练一个深度 MLP → 观察梯度消失。
- 用 Xavier 重复 → 学习稳定。
- 用 He 尝试 ReLU → 收敛更快。
- 逐层比较激活值的方差。
- 绘制前向传播过程中激活值的直方图。
测试用例
| 激活函数 | 方法 | 结果 |
|---|---|---|
| tanh | Xavier | 平衡的激活值 |
| sigmoid | Xavier | 稳定的早期层 |
| ReLU | He | 非零稳定方差 |
| LeakyReLU | He | 效果良好 |
| None | 随机小权重 | 梯度消失 |
复杂度
- 时间:$O(P)$(一次性设置)
- 空间:$O(P)$(权重)
- 好处:从一开始就稳定信号传播
正确的初始化是学习的第一步,Xavier 和 He 为你的优化器搭建了舞台,使其无需在开始学习之前就与糟糕的缩放比例作斗争。
933. Dropout
Dropout 是一种用于神经网络的正则化技术,通过在训练过程中随机关闭神经元来防止过拟合。 每次训练前向传播都会采样一个较小的子网络,迫使模型依赖分布式表示而非记忆特定模式。
我们正在解决什么问题?
拥有数百万参数的深度网络很容易过拟合,它们会记忆训练数据而非学习通用模式。 即使使用早停法或权重衰减,神经元之间仍可能过度协同适应(彼此依赖过强)。
Dropout 通过在训练过程中使每个神经元变得不可靠来打破这种协同适应,就像训练一个必须全部表现良好的小型网络集合。
核心思想
在训练期间,每个神经元的输出以概率 $p$ 被随机"丢弃"。 形式上,对于第 $l$ 层的激活值 $a_i^{(l)}$:
$$ \tilde{a}_i^{(l)} = r_i^{(l)} \cdot a_i^{(l)}, \quad r_i^{(l)} \sim \text{Bernoulli}(1 - p) $$
其中:
- $p$ = 丢弃率(例如 0.5)
- $r_i^{(l)}$ = 随机掩码(0 或 1)
在推理阶段,不丢弃任何神经元,而是将激活值乘以 $(1 - p)$ 以匹配期望输出。
它是如何工作的(通俗解释)?
想象一个教室,每次讨论时随机要求一半的学生离开。 剩下的学生仍然必须解决问题,因此每个人都学会了独立学习,并且以后能够处理队友缺席的情况。
Dropout 对神经元做同样的事情:
- 训练期间 → 随机神经元静默。
- 测试期间 → 所有神经元都返回,但每个神经元的输出被按比例缩小。
这创造了鲁棒性,并防止了对任何单一特征的依赖。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 选择丢弃率 $p$ | 常用值:0.2–0.5 |
| 2 | 采样掩码 $r_i \sim \text{Bernoulli}(1 - p)$ | 每个神经元一个 |
| 3 | 应用掩码 | $\tilde{a}_i = r_i \cdot a_i$ |
| 4 | 前向传播 | 使用掩码后的激活值 |
| 5 | 反向传播 | 梯度仅通过活跃的神经元流动 |
| 6 | 推理 | 使用所有神经元,将激活值乘以 $(1 - p)$ |
示例
对于一个隐藏层: $$ a^{(l)} = f(W^{(l)} a^{(l-1)} + b^{(l)}) $$ 应用 Dropout: $$ r^{(l)} \sim \text{Bernoulli}(1 - p) $$ $$ \tilde{a}^{(l)} = r^{(l)} \odot a^{(l)} $$
推理时: $$ a^{(l)}{\text{test}} = (1 - p) a^{(l)}{\text{train}} $$
微型代码(简易版本)
Python
import numpy as np
def dropout_layer(a, p=0.5, train=True):
if train:
mask = (np.random.rand(*a.shape) > p).astype(float)
return a * mask / (1 - p)
else:
return a # 在训练期间已缩放C (概要)
#include <stdlib.h>
void dropout(double* a, int n, double p) {
for (int i = 0; i < n; i++) {
double r = (double) rand() / RAND_MAX;
a[i] = (r > p) ? a[i] / (1.0 - p) : 0.0;
}
}为什么它重要
- 减少过拟合:防止依赖单个神经元。
- 起到集成平均的作用:每次训练迭代采样一个子网络。
- 提高泛化能力:特别是对于全连接层。
- 简单有效:无需额外参数或增加复杂性。
Dropout 是使深度神经网络能够良好泛化的主要突破之一。
一个温和的证明(为什么它有效)
期望激活值保持恒定:
$$ \mathbb{E}[\tilde{a}_i] = (1 - p)a_i $$
为了保持量级,我们在训练期间按 $\frac{1}{1 - p}$ 进行缩放: $$ \tilde{a}_i = \frac{r_i}{1 - p} a_i $$
因此: $$ \mathbb{E}[\tilde{a}_i] = a_i $$
这使得平均激活值在训练和推理之间保持不变。
亲自尝试
- 在 MNIST 上训练一个有 Dropout 和一个没有 Dropout 的小型 MLP。
- 比较训练和测试准确率。
- 尝试 $p = 0.2, 0.5, 0.8$。
- 仅对隐藏层应用 Dropout。
- 观察训练变慢但泛化能力提高。
测试用例
| 网络 | 丢弃率 | 效果 |
|---|---|---|
| 0 (无) | 0.0 | 过拟合 |
| 中等 | 0.5 | 最佳泛化能力 |
| 高 | 0.8 | 欠拟合 |
| 卷积网络 | 0.3 | 稳定结果 |
复杂度
- 时间:每层 $O(N)$(掩码采样)
- 内存:$O(N)$(掩码存储)
- 推理成本:无
Dropout 让你的网络遗忘,恰到好处地记住正确的东西。
934. 批量归一化
批量归一化(BatchNorm)通过对每个小批量中的激活值进行归一化,来稳定和加速训练过程。它使层输出保持在良好状态,既不爆炸也不消失,因此网络可以训练得更深、更快,并使用更高的学习率。
我们正在解决什么问题?
在训练过程中,随着前面层的变化,后面层的输入分布也会发生变化,这个问题被称为内部协变量偏移。这使得优化变得更加困难,因为每一层都必须不断适应其输入的变化分布。
批量归一化通过将层激活值归一化为零均值和单位方差来减少这种漂移,然后允许网络重新学习合适的缩放和偏移。
核心思想
对于每个小批量和特征通道:
$$ \mu_B = \frac{1}{m} \sum_{i=1}^m x_i $$ $$ \sigma_B^2 = \frac{1}{m} \sum_{i=1}^m (x_i - \mu_B)^2 $$
归一化并重新缩放:
$$ \hat{x}_i = \frac{x_i - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} $$ $$ y_i = \gamma \hat{x}_i + \beta $$
其中:
- $\gamma$ 和 $\beta$ 是用于缩放和偏移的可学习参数。
- $\epsilon$ 用于防止除以零。
在推理时,使用 $\mu_B$ 和 $\sigma_B^2$ 的移动平均值,而不是批次统计量。
它是如何工作的(通俗解释)?
想象每个神经元的输出就像学生的考试成绩,有些高,有些低,每个班级的平均分差异很大。批量归一化对这些分数进行中心化(减去均值)和缩放(除以标准差),因此下一层总是接收到相似范围内的值。然后,它让模型通过可学习的 $\gamma$ 和 $\beta$ 重新引入个性。
这使得优化地形更加平滑,更容易遍历。
逐步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 计算均值和方差 | 从小批量中计算 |
| 2 | 归一化 | 减去均值,除以标准差 |
| 3 | 缩放和偏移 | 应用可学习的 $\gamma$、$\beta$ |
| 4 | 前向传播 | 使用归一化的激活值 |
| 5 | 反向传播 | 计算 $\gamma$、$\beta$ 的梯度 |
| 6 | 推理 | 使用 $\mu$、$\sigma^2$ 的移动平均值 |
示例
对于一个输入为 $x = [x_1, x_2, \dots, x_m]$ 的层:
$$ \mu_B = \frac{1}{m} \sum_i x_i, \quad \sigma_B^2 = \frac{1}{m} \sum_i (x_i - \mu_B)^2 $$
然后:
$$ \hat{x}_i = \frac{x_i - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}}, \quad y_i = \gamma \hat{x}_i + \beta $$
在测试时: $$ y_i = \frac{x_i - \mu_{\text{running}}}{\sqrt{\sigma_{\text{running}}^2 + \epsilon}} \gamma + \beta $$
微型代码(简易版本)
Python (NumPy)
import numpy as np
def batchnorm_forward(x, gamma, beta, eps=1e-5):
mu = np.mean(x, axis=0)
var = np.var(x, axis=0)
x_hat = (x - mu) / np.sqrt(var + eps)
y = gamma * x_hat + beta
return yC (大纲)
#include <math.h>
void batchnorm(double* x, double* y, int n, double gamma, double beta, double eps) {
double mean = 0.0, var = 0.0;
for (int i = 0; i < n; i++) mean += x[i];
mean /= n;
for (int i = 0; i < n; i++) var += (x[i] - mean) * (x[i] - mean);
var /= n;
for (int i = 0; i < n; i++)
y[i] = gamma * ((x[i] - mean) / sqrt(var + eps)) + beta;
}为什么它很重要
- 更快的收敛:允许更高的学习率。
- 改进的稳定性:将梯度保持在健康范围内。
- 正则化效果:来自批次统计量的轻微噪声减少了过拟合。
- 更容易的初始化:对初始权重不那么敏感。
在层归一化接管非卷积模型之前,批量归一化成为了 CNN、RNN 和 Transformer 中的默认归一化方法。
一个温和的证明(为什么它有效)
当激活方差随着深度增长时,会发生梯度爆炸。批量归一化将方差约束在 $\approx 1$,防止爆炸。
对于任何神经元: $$ \mathrm{Var}[\hat{x}] = \frac{\mathrm{Var}[x]}{\sigma_B^2 + \epsilon} \approx 1 $$
因此,关于 $x$ 的梯度也得到了稳定,从而实现了更平滑的优化和更可靠的更新。
亲自尝试
- 训练一个 5 层 MLP,分别使用和不使用批量归一化。
- 观察使用归一化后更快的收敛速度。
- 改变批次大小,小批次会使批量归一化噪声更大。
- 与层归一化(在 Transformer 中使用)进行比较。
- 训练后检查 $\gamma$、$\beta$,它们会自适应地重新缩放输出。
测试用例
| 架构 | 归一化 | 效果 |
|---|---|---|
| MLP | 无 | 收敛缓慢 |
| MLP | BatchNorm | 更快且稳定 |
| CNN | BatchNorm | 梯度更平滑 |
| Transformer | LayerNorm | 对序列处理效果更好 |
复杂度
- 时间:每批次 $O(N)$
- 内存:存储均值、方差需要 $O(N)$
- 推理:缓存的移动平均值
批量归一化是深度学习中最简单但最具变革性的技巧之一,它是一个归一化层,将不稳定的深度网络变成了可训练的网络。
935. 层归一化
层归一化(LayerNorm)对每个样本内部的激活值进行归一化,而不是跨批次进行。它为那些批次统计不可靠的模型提供了稳定的训练,特别是 RNN、Transformer 以及其他序列模型。
我们要解决什么问题?
批归一化依赖于小批次统计。但在序列建模、强化学习或变长输入等任务中:
- 批次大小可能非常小。
- 同一层可能处理非常不同的序列。
- 批次统计波动太大。
层归一化通过归一化每个独立样本的特征维度上的激活值来解决这个问题,使其独立于批次大小。
核心思想
给定一个样本的输入向量: $$ x = [x_1, x_2, \dots, x_H] $$ 其中 $H$ 是隐藏单元(特征)的数量。
计算每个样本的统计量: $$ \mu = \frac{1}{H} \sum_{i=1}^H x_i $$ $$ \sigma^2 = \frac{1}{H} \sum_{i=1}^H (x_i - \mu)^2 $$
归一化并重新缩放: $$ \hat{x}_i = \frac{x_i - \mu}{\sqrt{\sigma^2 + \epsilon}}, \quad y_i = \gamma_i \hat{x}_i + \beta_i $$
这里 $\gamma_i$ 和 $\beta_i$ 是可学习的、针对每个特征的缩放和偏移参数。
它是如何工作的(通俗解释)?
将单个输入中每个神经元的激活值想象成一个微型生态系统。层归一化确保对于每个输入,无论批次或序列如何,其激活值都有一个一致的“气候”(均值为 0,方差为 1)。
它不是跨样本进行比较,而是在每个样本内部进行标准化。
这使得它对小批次、序列数据和基于注意力的架构具有鲁棒性。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 取一个样本(激活值向量) | $x = [x_1, ..., x_H]$ |
| 2 | 计算均值和方差 | 跨特征维度,而非批次 |
| 3 | 归一化 | 减去均值,除以标准差 |
| 4 | 重新缩放 | 乘以 $\gamma$,加上 $\beta$ |
| 5 | 使用输出 | 将归一化的激活值前向传播 |
示例
对于一个 Transformer 隐藏状态 $x \in \mathbb{R}^{d_{\text{model}}}$:
$$ \mu = \frac{1}{d_{\text{model}}} \sum_i x_i, \quad \sigma^2 = \frac{1}{d_{\text{model}}} \sum_i (x_i - \mu)^2 $$ $$ \text{LayerNorm}(x) = \gamma \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta $$
微型代码(简易版本)
Python
import numpy as np
def layernorm(x, gamma, beta, eps=1e-5):
mu = np.mean(x, axis=-1, keepdims=True)
var = np.var(x, axis=-1, keepdims=True)
x_hat = (x - mu) / np.sqrt(var + eps)
return gamma * x_hat + betaC (Outline)
#include <math.h>
void layernorm(double* x, double* y, int H, double* gamma, double* beta, double eps) {
double mean = 0.0, var = 0.0;
for (int i = 0; i < H; i++) mean += x[i];
mean /= H;
for (int i = 0; i < H; i++) var += (x[i] - mean) * (x[i] - mean);
var /= H;
for (int i = 0; i < H; i++)
y[i] = gamma[i] * ((x[i] - mean) / sqrt(var + eps)) + beta[i];
}为何重要
- 适用于任何批次大小,甚至为 1。
- 在批归一化失效的 RNN 和 Transformer 中能稳定训练。
- 平滑优化曲面和梯度流。
- 增加的计算开销极小。
层归一化是使 Transformer(如 GPT 和 BERT)能够在长序列和可变上下文中稳定训练的关键创新之一。
一个温和的证明(为何有效)
对于每个样本: $$ \mathbb{E}[\hat{x}] = 0, \quad \mathrm{Var}[\hat{x}] = 1 $$
因此,归一化的激活值保持一致的幅度,确保梯度保持稳定: $$ \frac{\partial L}{\partial x_i} = \frac{\gamma}{\sqrt{\sigma^2 + \epsilon}} \left( \frac{\partial L}{\partial \hat{x}_i} - \frac{1}{H} \sum_j \frac{\partial L}{\partial \hat{x}_j} - \frac{\hat{x}_i}{H} \sum_j \frac{\partial L}{\partial \hat{x}_j} \hat{x}_j \right) $$
这保持了零均值梯度,避免了跨层的漂移。
亲自尝试
- 训练一个带和不带层归一化的 Transformer 块。
- 比较损失稳定性,层归一化能平滑训练过程。
- 尝试极小的批次大小($B=1$ 或 $B=2$)。
- 在残差连接之前或之后应用层归一化。
- 检查学习到的 $\gamma$、$\beta$,它们会适应输出范围。
测试用例
| 模型 | 归一化方法 | 结果 |
|---|---|---|
| MLP | BatchNorm | 正常 |
| RNN | BatchNorm | 不稳定 |
| RNN | LayerNorm | 稳定 |
| Transformer | LayerNorm | 标准选择 |
复杂度
- 时间:每个样本 $O(H)$
- 内存:用于存储均值、方差 $O(H)$
- 推理:成本相同
层归一化是现代深度网络的稳定之手,即使在批次很小或序列很长的情况下,也能确保激活值的一致性。
936. 梯度裁剪
梯度裁剪是一种稳定化技术,用于防止反向传播过程中梯度变得过大。它在深度网络和循环神经网络(RNN)中尤为重要,因为在这些网络中梯度爆炸可能导致参数更新剧烈,从而破坏训练进程。
我们要解决什么问题?
在训练过程中,梯度有时会爆炸,通过许多层或时间步呈指数级增长。当在链式法则中反复乘以大权重时,就会发生这种情况。
如果梯度变得非常大:
- 优化器会采取巨大的步长,越过最小值。
- 由于数值溢出,损失函数会变成 NaN。
- 学习过程完全发散。
梯度裁剪通过在更新权重之前限制梯度的大小来解决这个问题。
核心思想
令 $g$ 为完整的梯度向量。计算其 L2 范数: $$ |g|_2 = \sqrt{\sum_i g_i^2} $$
如果 $\vert g\vert _2$ 超过阈值 $c$,则将其按比例缩小: $$ g' = g \cdot \frac{c}{|g|_2} $$
然后使用 $g'$ 进行更新。
这确保了: $$ |g'|_2 \le c $$
它是如何工作的(通俗解释)?
想象一下试图驾驶一辆汽车下山,如果你油门踩得太猛,车子就会失控。梯度裁剪就像设置一个速度限制器:它允许运动,但将其限制在安全速度内。
如果梯度很小 → 什么都不做。 如果梯度爆炸 → 平滑地将它们缩放回目标范围。
逐步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 计算梯度 | $g = \nabla_\theta L$ |
| 2 | 计算范数 | $\vert g\vert_2 = \sqrt{\sum_i g_i^2}$ |
| 3 | 与阈值 $c$ 比较 | 典型的 $c = 1.0$ 或 $5.0$ |
| 4 | 如果太大,则重新缩放 | $g \gets g \cdot \frac{c}{\vert g\vert_2}$ |
| 5 | 更新参数 | $\theta \gets \theta - \eta g$ |
示例
假设: $$ g = [3, 4], \quad |g|_2 = 5 $$ 且阈值 $c = 2$。
那么: $$ g' = g \cdot \frac{2}{5} = [1.2, 1.6] $$ 现在 $\vert g'\vert _2 = 2$,安全地处于限制之内。
变体
全局范数裁剪 对所有参数应用相同的缩放因子。 $$ g' = g \cdot \frac{c}{\max(|g|_2, c)} $$
逐层或逐参数裁剪 每个张量的梯度被单独裁剪。
值裁剪 对每个梯度分量进行裁剪: $$ g_i' = \text{clip}(g_i, -c, c) $$
微型代码(简易版本)
Python
import numpy as np
def clip_gradients(grads, clip_value=1.0):
norm = np.sqrt(sum(np.sum(g 2) for g in grads))
if norm > clip_value:
scale = clip_value / norm
grads = [g * scale for g in grads]
return gradsC(大纲)
#include <math.h>
void clip_gradient(double* g, int n, double c) {
double norm = 0.0;
for (int i = 0; i < n; i++) norm += g[i] * g[i];
norm = sqrt(norm);
if (norm > c) {
double scale = c / norm;
for (int i = 0; i < n; i++) g[i] *= scale;
}
}为什么它很重要
- 防止梯度爆炸,尤其是在 RNN 中。
- 使深度网络的训练稳定。
- 允许安全地使用更大的学习率。
- 可与 SGD、Adam 和 RMSProp 等优化器一起使用。
如果没有梯度裁剪,像 LSTM 和 Transformer 这样的长序列模型几乎不可能可靠地训练。
一个温和的证明(为什么它有效)
裁剪限制了更新步长的大小: $$ |\Delta \theta|_2 = \eta |g'|_2 \le \eta c $$
因此,即使原始梯度爆炸,每一步的参数变化仍然有界,从而防止了数值不稳定。
它不会显著地偏向某个方向(因为缩放是均匀的),只影响大小。
亲自尝试
- 在有裁剪和无裁剪的情况下,在长序列上训练一个 RNN。
- 观察未裁剪时的损失发散情况。
- 尝试 $c = 0.1, 1.0, 5.0$。
- 绘制每一步的 $\vert g\vert _2$,裁剪会限制峰值。
- 与 Adam 结合使用,裁剪仍然有效。
测试用例
| 模型 | 阈值 $c$ | 结果 |
|---|---|---|
| RNN | 无 | 梯度爆炸 |
| RNN | 1.0 | 稳定 |
| LSTM | 5.0 | 平滑训练 |
| Transformer | 1.0 | 标准设置 |
复杂度
- 时间:$O(P)$(求和和重新缩放)
- 内存:可忽略不计
- 效果:稳定更新而不损害收敛
梯度裁剪是一个微小、几乎看不见的技巧,但它却是深度学习训练中混乱与可控之间的分水岭。
937. 早停法
早停法是一种简单而强大的正则化技术,当验证集上的性能停止改善时,它会停止训练。 它通过在模型开始记忆训练数据之前,在其最佳泛化点保存模型来防止过拟合。
我们要解决什么问题?
当训练深度网络时,训练集上的损失通常会持续下降, 但验证集上的损失可能在经过一定数量的训练轮次后开始上升。 这就是模型开始过拟合的点,此时它学习的是噪声而非结构。
与其猜测正确的训练轮次数,早停法会自动检测到这个转折点并在此处冻结模型。
核心思想
监控验证损失 $L_{\text{val}}$ 随训练轮次的变化:
跟踪迄今为止的最佳验证损失: $$ L^* = \min_t L_{\text{val}}(t) $$
如果损失在 $p$ 个轮次(耐心参数)内没有改善,则停止训练。
形式化地:
$$ \text{if } L_{\text{val}}(t) > L^* - \delta \text{ for } p \text{ epochs, stop.} $$
其中:
- $\delta$ = 所需的最小改进量
- $p$ = 耐心值(停止前的容忍度)
它是如何工作的(通俗解释)?
将训练想象成烤面包。 如果烤的时间太短,面包就没熟(欠拟合)。 如果烤的时间太长,面包就烤焦了(过拟合)。 早停法就像看着烤箱,在验证损失“闻起来”最完美的那一刻,也就是恰好的时机停止。
它是一种隐式的正则化形式,没有惩罚项,只是一个智能的暂停。
逐步总结
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 分割数据 | 训练集 + 验证集 |
| 2 | 训练模型 | 每轮更新权重 |
| 3 | 在验证集上评估 | 计算 $L_{\text{val}}$ |
| 4 | 跟踪最佳分数 | 当验证集性能改善时保存模型 |
| 5 | 应用耐心值 | 在停止前等待几个轮次 |
| 6 | 恢复最佳权重 | 恢复到具有最低验证损失的模型快照 |
示例
假设我们训练 100 个轮次:
| 轮次 | 训练损失 | 验证损失 | 动作 |
|---|---|---|---|
| 1–10 | ↓ | ↓ | 改善中 |
| 11–30 | ↓ | 平台期 | 观察 |
| 31–40 | ↓ | ↑ | 停止! |
如果耐心值 = 10,我们会在第 40 轮后停止,并恢复第 30 轮的权重,即最佳泛化点。
微型代码(简易版本)
Python
best_val = float('inf')
patience, wait = 10, 0
best_weights = None
for epoch in range(100):
train_one_epoch(model)
val_loss = evaluate(model)
if val_loss < best_val - 1e-4:
best_val = val_loss
best_weights = model.copy_weights()
wait = 0
else:
wait += 1
if wait >= patience:
print("Early stopping at epoch", epoch)
model.load_weights(best_weights)
breakC (Outline)
double best_val = 1e9;
int patience = 10, wait = 0;
for (int epoch = 0; epoch < 100; epoch++) {
double val_loss = evaluate_model();
if (val_loss < best_val - 1e-4) {
best_val = val_loss;
save_model();
wait = 0;
} else {
wait++;
}
if (wait >= patience) {
printf("Early stopping at epoch %d\n", epoch);
load_best_model();
break;
}
}为什么它很重要
- 无需调整额外的超参数即可防止过拟合。
- 自动选择最佳泛化轮次。
- 减少训练时间,在收益递减前停止。
- 常见于深度学习框架(例如
Keras.callbacks.EarlyStopping)。
一个温和的证明(为什么它有效)
泛化误差 $E_g$ 通常遵循 U 形曲线:
$$ E_g(t) = E_{\text{train}}(t) + \text{overfit}(t) $$
早停法在过拟合项占主导地位之前停止,从而捕捉到 $E_g(t)$ 的最小值: $$ t^* = \arg\min_t E_{\text{val}}(t) $$
这在效果上类似于 L2 正则化,它通过限制优化时间来限制总权重的增长。
亲自尝试
- 在 MNIST 数据集上训练一个小的 MLP,耐心值分别设为 5、10、20。
- 绘制训练和验证损失曲线。
- 观察更长的耐心值如何略微增加过拟合。
- 与 dropout 结合使用以获得额外的正则化效果。
- 与 L2 权重衰减进行比较,效果相似,但机制不同。
测试用例
| 模型 | 早停法 | 结果 |
|---|---|---|
| MLP | 无 | 约 40 轮后过拟合 |
| MLP | Patience=10 | 第 30 轮时验证集性能最佳 |
| CNN | Patience=5 | 提前停止,获得最佳准确率 |
| Transformer | 启用 | 稳定收敛 |
复杂度
- 时间:通过提前停止节省时间
- 内存:$O(P)$(最佳权重的快照)
- 超参数:耐心值、最小改进量
早停法是机器学习中最简单的一种智慧,它不与过拟合对抗,只是知道何时该离开。
938. 权重衰减
权重衰减(也称为 L2 正则化)通过在损失函数中添加一个小项来惩罚较大的参数值,从而抑制模型过于复杂。 它温和地将权重"收缩"向零,防止过拟合并提高泛化能力。
我们要解决什么问题?
在神经网络中,较大的权重可能导致模型记忆训练数据或在优化过程中变得不稳定。 我们希望模型保持平滑,即输入的微小变化不会导致输出的大幅波动。
权重衰减添加了一个与权重平方大小成比例的惩罚项,引导优化过程趋向更简单的解。
核心思想
我们将目标函数从单纯的数据损失 $L(\theta)$ 修改为包含正则化项的形式:
$$ L_{\text{total}}(\theta) = L(\theta) + \frac{\lambda}{2} |\theta|_2^2 $$
其中:
- $\theta$ = 所有模型参数的向量
- $\lambda$ = 权重衰减系数(正则化强度)
梯度更新变为:
$$ \theta \gets \theta - \eta (\nabla_\theta L(\theta) + \lambda \theta) $$
额外的项 $\lambda \theta$ 就像一个弹簧,在每一步中温和地将权重拉向零。
它是如何工作的(通俗解释)?
想象你的权重就像被训练向不同方向拉伸的橡皮筋。 权重衰减不断地将它们向原点拉回,力度不足以阻止学习,但足以防止它们拉伸得太远。
结果是:
- 更简单的函数
- 更平滑的决策边界
- 更好的泛化能力
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 定义损失函数 | $L_{\text{total}} = L + \frac{\lambda}{2}\vert \theta\vert ^2$ |
| 2 | 计算梯度 | $\nabla_\theta L_{\text{total}} = \nabla_\theta L + \lambda \theta$ |
| 3 | 更新权重 | $\theta \gets \theta - \eta \nabla_\theta L_{\text{total}}$ |
| 4 | 重复 | 继续使用正则化更新进行训练 |
示例
假设: $$ L(\theta) = (y - \theta x)^2, \quad \lambda = 0.1 $$
那么总损失为: $$ L_{\text{total}}(\theta) = (y - \theta x)^2 + 0.05 \theta^2 $$
梯度: $$ \frac{dL_{\text{total}}}{d\theta} = -2x(y - \theta x) + 0.1 \theta $$
因此,即使数据项很小,$\theta$ 仍会缓慢地向零收缩。
微型代码(简易版本)
Python
import numpy as np
def weight_decay_update(theta, grad, lr=0.01, wd=0.1):
return theta - lr * (grad + wd * theta)
# 示例
theta = np.array([1.0, -2.0])
grad = np.array([0.5, -0.5])
new_theta = weight_decay_update(theta, grad)
print(new_theta)C (Outline)
void weight_decay_update(double* theta, double* grad, int n, double lr, double wd) {
for (int i = 0; i < n; i++) {
theta[i] -= lr * (grad[i] + wd * theta[i]);
}
}为什么它很重要
- 通过惩罚大权重来防止过拟合。
- 提高泛化能力并使决策边界更平滑。
- 与 SGD 和大多数优化器配合良好。
- 通过避免尖锐的极小值来帮助优化。
注意:在 AdamW 中,权重衰减是独立(解耦)于梯度更新应用的,以获得更好的一致性。
一个温和的证明(为什么它有效)
正则化改变了优化轨迹。
对于二次损失: $$ \min_\theta |X\theta - y|^2 + \lambda |\theta|^2 $$ 有闭式解: $$ \theta^* = (X^\top X + \lambda I)^{-1} X^\top y $$
这里,$\lambda I$ 抑制了具有小特征值的方向,降低了对噪声的敏感性,这等价于岭回归。
亲自尝试
- 训练一个带权重衰减和不带权重衰减的线性回归模型。
- 绘制权重图,使用衰减后,权重保持更小、更平滑。
- 调整 $\lambda$:
- 太小 → 过拟合
- 太大 → 欠拟合
- 与 dropout 或早停法结合使用。
- 观察训练与验证准确率的差异。
测试案例
| 模型 | 正则化 | 效果 |
|---|---|---|
| 线性回归 | 无 | 过拟合噪声 |
| 线性回归 | L2 (λ=0.1) | 平滑权重 |
| CNN | λ=1e-4 | 更好的泛化能力 |
| Transformer | λ=0.01 | 常见默认值 |
复杂度
- 时间:可忽略不计($O(P)$)
- 内存:可忽略不计(参数大小相同)
- 效果:逐渐收缩,得到更平滑的模型
权重衰减是神经网络的温和约束,一种微小的拉力,使学习在灵活性与克制之间保持平衡。
939. 学习率调度
学习率调度在训练过程中动态调整优化器的学习率,以平衡探索(大步长)和收敛(小步长)。 它是实现更快收敛、更平滑优化和更好泛化能力的关键技术。
我们要解决什么问题?
固定的学习率 $\eta$ 可能导致问题:
- 如果 $\eta$ 太高,训练会振荡或发散。
- 如果 $\eta$ 太低,收敛速度极慢或陷入局部最小值。
解决方案:开始时使用较大的学习率进行探索,随着训练趋于稳定而逐渐减小它。 这使得优化器早期可以迈出大步,然后在最小值附近进行精细调整。
核心思想
我们将学习率 $\eta_t$ 修改为时间(轮次或步数)的函数:
$$ \theta_{t+1} = \theta_t - \eta_t \nabla_\theta L(\theta_t) $$
其中 $\eta_t$ 由一个调度函数更新。
常见的调度策略:
| 调度策略 | 公式 | 行为 |
|---|---|---|
| 阶梯衰减 | $\eta_t = \eta_0 \cdot \gamma^{\lfloor t/s \rfloor}$ | 每 $s$ 个轮次下降一次 |
| 指数衰减 | $\eta_t = \eta_0 e^{-kt}$ | 平滑的指数下降 |
| 余弦退火 | $\eta_t = \eta_{\min} + \frac{1}{2}(\eta_0 - \eta_{\min})(1 + \cos(\pi t / T))$ | 从暖到冷的逐渐变化 |
| 线性衰减 | $\eta_t = \eta_0 (1 - t/T)$ | 简单的线性下降 |
| 循环学习率 (CLR) | 在 $\eta_{\min}$ 和 $\eta_{\max}$ 之间振荡 | 周期性的探索 |
| 预热 | 在早期轮次中逐渐增加 $\eta_t$ | 防止不稳定 |
它是如何工作的(通俗解释)?
想象训练就像徒步下山。 开始时,你迈出自信的大步以快速移动。 当你接近山谷时,你放慢速度,小心调整以避免越过最小值。
学习率调度做同样的事情,它控制你穿越损失"地形"的"行走"速度。
分步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 选择基础学习率 $\eta_0$ | 通常是 0.1 或 0.001 |
| 2 | 选择调度类型 | 阶梯、指数、余弦等 |
| 3 | 在每一步更新 $\eta_t$ | 根据所选规则 |
| 4 | 将 $\eta_t$ 传递给优化器 | 动态调整步长 |
| 5 | 监控训练 | 验证稳定的收敛 |
示例:阶梯衰减
$$ \eta_t = \eta_0 \cdot \gamma^{\lfloor t/s \rfloor} $$
如果 $\eta_0 = 0.1$, $\gamma = 0.5$, $s = 10$:
| 轮次 | 学习率 |
|---|---|
| 0–9 | 0.1 |
| 10–19 | 0.05 |
| 20–29 | 0.025 |
示例:余弦退火
平滑下降并周期性重启:
$$ \eta_t = \eta_{\min} + \frac{1}{2}(\eta_0 - \eta_{\min})(1 + \cos(\pi t / T)) $$
产生平缓的振荡,早期大步长,接近最小值时小步长,然后重启以进行探索。
微型代码(简易版本)
Python
import numpy as np
def cosine_annealing_lr(t, T, eta_min=1e-5, eta_max=1e-2):
return eta_min + 0.5 * (eta_max - eta_min) * (1 + np.cos(np.pi * t / T))
# 示例
for epoch in range(0, 100, 10):
print(epoch, cosine_annealing_lr(epoch, 100))C (大纲)
#include <math.h>
double cosine_annealing_lr(int t, int T, double eta_min, double eta_max) {
return eta_min + 0.5 * (eta_max - eta_min) * (1 + cos(M_PI * t / T));
}为什么它很重要
- 改善收敛,比固定 $\eta$ 更快更平滑。
- 减少过拟合,最小值附近的小步长泛化能力更好。
- 能够安全地使用较大的初始学习率。
- 用于大多数现代训练流程(例如 ResNet、Transformers)。
调度器不仅仅是关于速度,它们塑造了优化器如何探索损失曲面并在其中稳定下来。
一个温和的证明(它为何有效)
在凸优化中,收敛速度取决于 $\eta_t$:
$$ L(\theta_t) - L^* \le \frac{C}{\sum_{i=1}^t \eta_i} $$
因此,减小 $\eta_t$ 确保了累积步长的收敛,当模型接近最优解时稳定更新。
对于余弦调度,周期性重启避免了陷入平坦的最小值,提供了隐式正则化。
亲自尝试
使用恒定学习率与调度学习率训练一个 CNN。
比较收敛速度和验证准确率。
尝试:
- 阶梯衰减(每 10 个轮次 $\gamma=0.5$)
- 余弦退火
- 循环学习率 (CLR)
绘制跨轮次的 $\eta_t$,可视化调度曲线的形状。
与 Transformer 的预热结合使用(例如,在前 5% 的步数中线性增加 $\eta$)。
测试用例
| 调度策略 | 模型 | 行为 |
|---|---|---|
| 恒定 | CNN | 缓慢,早期即进入平台期 |
| 阶梯衰减 | CNN | 急剧收敛 |
| 余弦退火 | ResNet | 平滑衰减,最佳准确率 |
| 循环学习率 | Transformer | 循环探索 |
复杂度
- 时间:每步 $O(1)$(轻量级更新)
- 内存:可忽略不计
- 收益:更快、更稳定的收敛
学习率调度就像赋予你的优化器直觉,知道何时冲刺,何时减速,以及何时在再次攀登前深呼吸。
940. 残差连接
残差连接(也称为跳跃连接)允许信息绕过神经网络中的一层或多层。 它们由 ResNet(2015)引入,旨在解决梯度消失问题,并使得成功训练非常深的网络成为可能,有时可达数百甚至数千层。
我们要解决什么问题?
随着网络变深:
- 梯度在反向传播过程中可能消失或爆炸。
- 即使模型容量更大,训练误差也可能增加。
- 更深的网络可能学习更慢或陷入较差的局部极小值。
残差连接通过提供捷径路径来解决此问题,让梯度能更直接地在网络中流动。
核心思想
标准层学习一个映射:
$$ y = \mathcal{F}(x) $$
而残差层则学习:
$$ y = \mathcal{F}(x) + x $$
其中:
- $\mathcal{F}(x)$ = 残差映射(例如卷积、批量归一化、激活函数)
- $x$ = 恒等捷径(输入)
在反向传播期间,梯度可以同时通过 $\mathcal{F}(x)$ 和直接通过 $x$ 流动,从而避免消失。
它是如何工作的(通俗解释)?
想象搭建一个积木塔,每一层都对之前的输出进行修改。 残差连接让一些积木块将其输出原封不动地直接向上传递。 这保持了信息的"新鲜度",并防止随着塔越来越高,先前的知识逐渐消失。
换句话说,层不必学习整个变换,它们只需要学习差异(即残差)。
逐步总结
| 步骤 | 动作 | 描述 |
|---|---|---|
| 1 | 输入 | $x$ 进入块 |
| 2 | 变换 | 应用操作计算 $\mathcal{F}(x)$ |
| 3 | 跳跃 | 将 $x$ 直接传递到输出 |
| 4 | 组合 | 相加:$y = \mathcal{F}(x) + x$ |
| 5 | 下一块 | 将 $y$ 前馈到下一层 |
示例
在一个简单的前馈块中: $$ \mathcal{F}(x) = W_2 \sigma(W_1 x) $$ 那么: $$ y = W_2 \sigma(W_1 x) + x $$
如果 $\mathcal{F}(x)$ 变得非常小(接近零), 该块就简单地向前传递输入:$y \approx x$, 这使得在需要时训练恒等映射变得容易。
微型代码(简易版本)
Python (NumPy)
import numpy as np
def relu(x): return np.maximum(0, x)
def residual_block(x, W1, W2):
F = relu(x @ W1)
F = F @ W2
return F + x # 跳跃连接C (大纲)
void residual_block(double* x, double* W1, double* W2, double* y, int n, int m) {
// 计算 F(x)
double* F = malloc(sizeof(double) * n);
for (int i = 0; i < n; i++) {
F[i] = 0.0;
for (int j = 0; j < m; j++)
F[i] += x[j] * W1[j * n + i];
if (F[i] < 0) F[i] = 0; // ReLU
}
// 第二次线性变换 + 跳跃连接
for (int i = 0; i < n; i++) {
y[i] = F[i];
y[i] += x[i]; // 添加跳跃
}
free(F);
}为何重要
- 修复梯度消失:梯度通过恒等路径流动。
- 实现超深网络:ResNet 可达 1000+ 层,训练稳定。
- 改善泛化能力:鼓励小的、增量的改进。
- 简化学习:层学习残差修正,而非完整映射。
- 在现代架构中无处不在:ResNet、DenseNet、Transformers。
一个温和的证明(为何有效)
在反向传播期间,梯度通过两条路径流动:
$$ \frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \left( \frac{\partial \mathcal{F}(x)}{\partial x} + I \right) $$
恒等项 ($I$) 确保了即使 $\frac{\partial \mathcal{F}(x)}{\partial x}$ 消失,梯度也永远不会变为零,从而在深层堆叠中保持稳定的梯度流动。
变体
预激活 ResNet 在 $\mathcal{F}(x)$ 之前应用 BatchNorm 和 ReLU,以获得更平滑的训练。 $$ y = x + \mathcal{F}(\text{BN}(\text{ReLU}(x))) $$
投影捷径(用于维度不匹配) 如果 $\mathcal{F}(x)$ 改变了维度: $$ y = \mathcal{F}(x) + W_s x $$ 其中 $W_s$ 是一个 $1 \times 1$ 卷积或线性投影。
亲自尝试
- 训练一个有跳跃连接和一个没有跳跃连接的 10 层 MLP,注意哪个收敛更快。
- 可视化每层的梯度范数。
- 尝试堆叠 100+ 个残差块。
- 将加法替换为连接(DenseNet 风格)。
- 结合归一化和激活函数顺序(预激活)。
测试用例
| 架构 | 残差连接 | 结果 |
|---|---|---|
| 10 层 MLP | 无 | 梯度消失 |
| 10 层 MLP | 跳跃连接 | 稳定 |
| 100 层 CNN | 无 | 发散 |
| 100 层 CNN | 残差连接 | 成功训练 |
复杂度
- 时间:开销可忽略不计(仅加法)
- 内存:为跳跃张量增加一个缓冲区
- 效果:稳定了深度模型的训练
残差连接将深度从一个问题转变为了一个超能力,它们让网络可以变得更深、学得更快、看得更远,同时不忘其本源。
第 95 节. 序列模型
941. 隐马尔可夫模型(前向-后向算法)
隐马尔可夫模型(HMM)是处理序列数据的基础概率模型,我们观察到的结果依赖于隐藏(不可见)的状态。前向-后向算法使我们能够高效地计算序列的概率并估计随时间变化的隐藏状态。
我们要解决什么问题?
假设我们观察到一系列输出(如单词、声音或传感器读数),但我们怀疑这些是由隐藏的内部状态生成的,例如:
- 在语音识别中:隐藏的音素 → 观察到的声音
- 在金融中:隐藏的市场状态 → 观察到的价格
- 在生物学中:隐藏的基因状态 → 观察到的核苷酸序列
我们想要计算:
- 给定模型参数,观察到序列的可能性。
- 随时间变化的隐藏状态的后验概率。
这就是前向-后向算法所做的事情。
核心思想
我们定义:
- 隐藏状态:$S = {s_1, s_2, \dots, s_N}$
- 观测值:$O = (o_1, o_2, \dots, o_T)$
- 转移概率:$A_{ij} = P(s_j \mid s_i)$
- 发射概率:$B_j(o_t) = P(o_t \mid s_j)$
- 初始概率:$\pi_i = P(s_i \text{ at } t=1)$
我们要求 $P(O \mid \text{model})$。
步骤 1:前向传递(α 值)
计算到时间 $t$ 为止的部分观测值序列且结束于状态 $i$ 的概率:
$$ \alpha_t(i) = P(o_1, o_2, \dots, o_t, s_i \mid \text{model}) $$
递推关系:
$$ \alpha_t(i) = \left[\sum_{j=1}^N \alpha_{t-1}(j) A_{ji}\right] B_i(o_t) $$
初始化:
$$ \alpha_1(i) = \pi_i B_i(o_1) $$
步骤 2:后向传递(β 值)
计算给定时间 $t$ 处于状态 $i$ 时,从 $t+1$ 到 $T$ 的剩余观测值序列的概率:
$$ \beta_t(i) = P(o_{t+1}, \dots, o_T \mid s_i, \text{model}) $$
递推关系:
$$ \beta_t(i) = \sum_{j=1}^N A_{ij} B_j(o_{t+1}) \beta_{t+1}(j) $$
初始化:
$$ \beta_T(i) = 1 $$
步骤 3:合并(后验概率)
在时间 $t$ 处于状态 $i$ 的概率:
$$ \gamma_t(i) = \frac{\alpha_t(i) \beta_t(i)}{\sum_{k=1}^N \alpha_t(k) \beta_t(k)} $$
整个序列的似然度:
$$ P(O \mid \text{model}) = \sum_{i=1}^N \alpha_T(i) $$
工作原理(通俗解释)
前向传递从过去收集证据,“考虑到目前为止的一切,我们到达这个状态的可能性有多大?” 后向传递从未来收集证据,“假设我们现在在这里,看到剩余数据的可能性有多大?”
通过将它们相乘,我们看到了完整的图景:每个隐藏状态在每个时间步活跃的概率。
微型代码(简易版本)
Python
import numpy as np
def forward_backward(A, B, pi, O):
N = A.shape[0]
T = len(O)
# 前向
alpha = np.zeros((T, N))
alpha[0] = pi * B[:, O[0]]
for t in range(1, T):
alpha[t] = (alpha[t-1] @ A) * B[:, O[t]]
# 后向
beta = np.zeros((T, N))
beta[-1] = 1
for t in reversed(range(T-1)):
beta[t] = (A @ (B[:, O[t+1]] * beta[t+1]))
# 后验
gamma = alpha * beta
gamma /= gamma.sum(axis=1, keepdims=True)
return alpha, beta, gammaC(大纲)
for (int i = 0; i < N; i++)
alpha[0][i] = pi[i] * B[i][O[0]];
for (int t = 1; t < T; t++)
for (int i = 0; i < N; i++) {
alpha[t][i] = 0;
for (int j = 0; j < N; j++)
alpha[t][i] += alpha[t-1][j] * A[j][i];
alpha[t][i] *= B[i][O[t]];
}为什么它很重要
- 概率序列模型的基础,用于语音、自然语言处理和生物信息学。
- 高效的动态规划,避免了指数复杂度。
- 构成了 EM 训练(Baum-Welch 算法)的基础。
- 可解释性强,给出随时间变化的后验状态概率。
没有它,计算所有可能状态路径的概率将需要 $O(N^T)$ 的时间;而该算法将其减少到 $O(N^2T)$。
一个温和的证明(为什么它有效)
因为模型是马尔可夫的,到达某个状态的概率仅依赖于前一个状态,而不依赖于整个历史。 这允许递归计算部分概率(前向)和剩余概率(后向)。
乘积 $\alpha_t(i)\beta_t(i)$ 覆盖了序列在时间 $t$ 前后的两半,给出了完整的联合概率。
亲自尝试
- 构建一个简单的天气模型(晴天/雨天)。
- 定义 $A$、$B$、$\pi$ 和观测序列。
- 运行前向-后向算法。
- 绘制每个状态的 $\gamma_t(i)$,观察隐藏状态如何波动。
- 与已知标签进行比较以验证解释。
测试用例
| 示例 | 状态 | 观测值 | 目标 |
|---|---|---|---|
| 天气 | 雨天,晴天 | 散步,购物,清洁 | 推断最可能的天气 |
| 语音 | 音素 | 音频特征 | 计算状态概率 |
| DNA | 隐藏模体 | 核苷酸符号 | 寻找可能的模体位置 |
复杂度
- 时间:$O(N^2T)$
- 空间:$O(NT)$
- 优点:在隐藏状态模型中实现高效推断
前向-后向算法是我们“倾听”隐藏过程的方式,它融合了过去和未来的证据,以解码我们无法直接看到的事物。
942. Viterbi(维特比)算法
Viterbi(维特比)算法是一种动态规划方法,用于在给定观测序列的情况下,在隐马尔可夫模型(HMM)中找到最可能的隐藏状态序列。 它是现代语音识别、词性标注、基因序列解码以及许多其他序列标注任务的核心。
我们要解决什么问题?
在 HMM 中,许多不同的状态序列可能产生相同的观测结果。 我们想要找到最可能的单个隐藏状态路径:
$$ S^* = \arg\max_S P(S \mid O, \text{model}) $$
朴素的方法需要检查所有可能的路径,其数量在 $T$ 上是呈指数增长的。 Viterbi 算法利用动态规划高效地解决了这个问题。
核心思想
我们递归地计算在每个时间步,以给定状态结束的任何路径的最大概率。
定义:
- $A_{ij} = P(s_j \mid s_i)$ (状态转移概率)
- $B_j(o_t) = P(o_t \mid s_j)$ (发射概率)
- $\pi_i = P(s_i)$ (初始状态概率)
- 观测序列 $O = (o_1, o_2, \dots, o_T)$
我们定义:
$$ \delta_t(i) = \max_{s_1, \dots, s_{t-1}} P(s_1, \dots, s_t = i, o_1, \dots, o_t \mid \text{model}) $$
以及一个回溯指针 $\psi_t(i)$ 来记录哪条路径是最优的。
逐步算法
- 初始化
$$ \delta_1(i) = \pi_i B_i(o_1), \quad \psi_1(i) = 0 $$
- 递归
对于每个时间 $t = 2, \dots, T$:
$$ \delta_t(i) = \max_j [\delta_{t-1}(j) A_{ji}] , B_i(o_t) $$
并记录使上式取得最大值的 $j$:
$$ \psi_t(i) = \arg\max_j [\delta_{t-1}(j) A_{ji}] $$
- 终止
$$ P^* = \max_i \delta_T(i), \quad s_T^* = \arg\max_i \delta_T(i) $$
- 路径回溯
对于 $t = T-1, T-2, \dots, 1$:
$$ s_t^* = \psi_{t+1}(s_{t+1}^*) $$
工作原理(通俗解释)
可以把它想象成在一个迷宫中导航,每个岔路口代表一个隐藏状态,每一步代表一个观测。 在每一步,你只保留到达每个可能状态的最佳路径,丢弃所有其他路径。 最后,你只需沿着保存的指针回溯,就能恢复最可能的路线。
示例
假设我们有两个隐藏状态: 雨天(R)和晴天(S),以及观测到的行为:散步、购物、打扫。
我们可以使用 Viterbi 算法计算出最能解释这些行为的天气序列,就像教一个模型根据某人的习惯来“猜测天气”一样。
简易代码
Python
import numpy as np
def viterbi(A, B, pi, O):
N = A.shape[0]
T = len(O)
delta = np.zeros((T, N))
psi = np.zeros((T, N), dtype=int)
# 初始化
delta[0] = pi * B[:, O[0]]
# 递归
for t in range(1, T):
for i in range(N):
seq_probs = delta[t-1] * A[:, i]
psi[t, i] = np.argmax(seq_probs)
delta[t, i] = np.max(seq_probs) * B[i, O[t]]
# 终止
states = np.zeros(T, dtype=int)
states[-1] = np.argmax(delta[-1])
for t in reversed(range(T-1)):
states[t] = psi[t+1, states[t+1]]
return statesC(概要)
for (int i = 0; i < N; i++)
delta[0][i] = pi[i] * B[i][O[0]];
for (int t = 1; t < T; t++)
for (int i = 0; i < N; i++) {
double max_val = 0.0;
int argmax = 0;
for (int j = 0; j < N; j++) {
double val = delta[t-1][j] * A[j][i];
if (val > max_val) { max_val = val; argmax = j; }
}
delta[t][i] = max_val * B[i][O[t]];
psi[t][i] = argmax;
}为什么它很重要
- 高效解码,将指数级搜索减少到 $O(N^2T)$。
- 得到最可能的状态序列,而不仅仅是每个状态的概率。
- 应用广泛:语音、词性标注、手势识别、DNA测序。
- 是序列模型中结构化预测的基础。
一个温和的证明(为什么它有效)
我们使用动态规划: 在每个时间 $t$,到达状态 $i$ 的最优路径必然来自在时间 $t-1$ 到达某个先前状态 $j$ 的最优路径。
形式上,贝尔曼最优性原理保证了:
$$ \max_{s_1,\dots,s_t} P(s_1,\dots,s_t,o_1,\dots,o_t) = \max_j [\max_{s_1,\dots,s_{t-1}} P(s_1,\dots,s_{t-1},o_1,\dots,o_{t-1}) P(s_t=i \mid s_{t-1}=j) P(o_t \mid s_t)] $$
这个递归允许概率的高效累积。
自己动手试试
- 定义一个具有 2 个状态(雨天、晴天)和 3 个观测(散步、购物、打扫)的 HMM。
- 手动或在 Python 中运行 Viterbi 算法。
- 在表格中跟踪每一步的 $\delta_t(i)$。
- 恢复最佳状态路径,并与你的直觉进行比较。
- 可视化路径概率,看看决策在哪里发生转变。
测试用例
| 示例 | 状态 | 观测 | 输出 |
|---|---|---|---|
| 天气模型 | 雨天, 晴天 | 散步, 购物, 打扫 | [雨天, 雨天, 晴天] |
| 语音音素 | 隐藏音素 | 声学信号 | 最可能的音素序列 |
| DNA 解码 | 隐藏区域 | 碱基对 | 基因区域序列 |
复杂度
- 时间:$O(N^2T)$
- 空间:$O(NT)$
- 输出:单个最可能的隐藏状态序列
Viterbi 算法将不确定性转化为结构,它不是猜测,而是重建最能解释每个观测序列背后故事的隐藏路径。
943. Baum–Welch 算法
Baum–Welch 算法是在隐状态未知时训练隐马尔可夫模型(HMM)的经典方法。 它是期望最大化(EM)算法的一个实例,在估计转移和发射的期望计数与相应更新模型参数之间交替进行。
它使得 HMM 能够从数据中学习,发现最能解释观测序列的转移概率、发射概率和初始分布。
我们要解决什么问题?
假设我们有一个观测序列的数据集 $O = (o_1, o_2, \dots, o_T)$,但不知道是哪些隐状态产生了它们。 我们想要学习能够最大化似然度的 HMM 参数 $\lambda = (A, B, \pi)$:
$$ P(O \mid \lambda) $$
直接优化这是不可行的,因为存在指数级多的隐状态序列。 Baum–Welch 通过使用由前向-后向算法计算出的转移和发射的期望计数来解决这个问题。
核心思想
每次迭代包含两个步骤:
- 期望步(E-step) 给定当前模型,计算每个转移或发射发生的期望次数。
- 最大化步(M-step) 使用这些期望计数重新估计模型参数 $A, B, \pi$。
重复直到收敛。
步骤 1:计算概率
使用之前的前向-后向算法,计算:
前向概率:
$$ \alpha_t(i) = P(o_1, \dots, o_t, s_t = i \mid \lambda) $$
后向概率:
$$ \beta_t(i) = P(o_{t+1}, \dots, o_T \mid s_t = i, \lambda) $$
然后定义两个期望量:
状态占用概率:
$$ \gamma_t(i) = \frac{\alpha_t(i)\beta_t(i)}{P(O \mid \lambda)} $$
转移概率:
$$ \xi_t(i,j) = \frac{\alpha_t(i) A_{ij} B_j(o_{t+1}) \beta_{t+1}(j)}{P(O \mid \lambda)} $$
步骤 2:重估公式
计算得到 $\gamma_t(i)$ 和 $\xi_t(i,j)$ 后,更新:
初始状态分布:
$$ \pi_i' = \gamma_1(i) $$
转移矩阵:
$$ A_{ij}' = \frac{\sum_{t=1}^{T-1} \xi_t(i,j)}{\sum_{t=1}^{T-1} \gamma_t(i)} $$
发射概率:
$$ B_j'(k) = \frac{\sum_{t: o_t = k} \gamma_t(j)}{\sum_{t=1}^T \gamma_t(j)} $$
重复直到 $P(O \mid \lambda)$ 收敛(或增长小于一个小的 $\epsilon$)。
工作原理(通俗解释)
将 Baum–Welch 视为猜测、解释和调整的过程:
- 猜测模型参数。
- 解释数据,使用前向-后向算法估计哪些隐状态可能生成了每个观测。
- 调整,将概率质量重新分配给那些更符合隐状态期望的转移和发射。
每个循环都会改进模型,使解释更加合理。
示例
对于一个天气模型:
- 隐状态:雨天,晴天
- 观测:散步,购物,清洁
如果算法看到许多“散步”观测,它会逐渐增加 $P(\text{散步} \mid \text{晴天})$。 如果“清洁”经常跟在“散步”之后,它会调整转移概率 $P(\text{晴天} \to \text{雨天})$。 这种迭代学习会持续进行,直到模型稳定。
简化代码
Python
import numpy as np
def baum_welch(O, N, M, max_iters=100):
T = len(O)
A = np.full((N, N), 1.0 / N)
B = np.full((N, M), 1.0 / M)
pi = np.full(N, 1.0 / N)
for _ in range(max_iters):
alpha = np.zeros((T, N))
beta = np.zeros((T, N))
# 前向
alpha[0] = pi * B[:, O[0]]
for t in range(1, T):
alpha[t] = (alpha[t-1] @ A) * B[:, O[t]]
# 后向
beta[-1] = 1
for t in reversed(range(T-1)):
beta[t] = (A @ (B[:, O[t+1]] * beta[t+1]))
# Gamma 和 Xi
denom = (alpha[-1].sum())
gamma = (alpha * beta) / denom
xi = np.zeros((T-1, N, N))
for t in range(T-1):
xi[t] = (alpha[t][:, None] * A * B[:, O[t+1]] * beta[t+1]) / denom
# 重估
pi = gamma[0]
A = xi.sum(axis=0) / gamma[:-1].sum(axis=0)[:, None]
for j in range(N):
for k in range(M):
mask = np.array(O) == k
B[j, k] = gamma[mask, j].sum() / gamma[:, j].sum()
return A, B, pi为何重要
- 教会 HMM 从数据中学习,无需标注状态。
- 是语音识别、NLP 标注和生物序列建模的基础。
- 无监督 EM 框架,可解释且统计上可靠。
- 在神经序列模型主导现代 AI 之前被广泛使用。
即使在今天,它仍然是 EM 和隐变量模型的概念基础。
简要证明(为何有效)
Baum–Welch 利用 EM 原理最大化 $\log P(O \mid \lambda)$ 的一个下界。
- E步:给定 $\lambda^{(old)}$,计算隐变量的期望充分统计量。
- M步:最大化关于 $\lambda$ 的期望对数似然。
每次迭代保证:
$$ P(O \mid \lambda^{(new)}) \ge P(O \mid \lambda^{(old)}) $$
这种单调改进持续进行直到收敛。
亲自尝试
- 从一个已知的 HMM 生成一个合成序列。
- 擦除隐状态。
- 初始化随机的 $A$, $B$, $\pi$。
- 运行 Baum–Welch 若干次迭代。
- 将学习到的参数与真实参数进行比较。
你会看到模型仅从观测中“重新发现”隐藏的动态。
测试用例
| 示例 | 隐状态 | 观测 | 目标 |
|---|---|---|---|
| 天气 | 雨天,晴天 | 散步,购物,清洁 | 学习 $A$, $B$, $\pi$ |
| DNA | 基因/非基因 | 核苷酸 | 估计转移 + 发射结构 |
| 词性标注 | 名词,动词,形容词 | 单词 | 学习发射模式 |
复杂度
- 时间:每次迭代 $O(N^2T)$
- 空间:$O(NT)$
- 收敛性:单调但不保证达到全局最优
Baum–Welch 是隐马尔可夫模型的“老师”,它聆听序列,猜测看不见的原因,并不断改进其理解,直到世界的隐藏结构变得清晰。
944. 束搜索(Beam Search)
束搜索是一种广泛应用于序列解码的启发式搜索算法,尤其在语音识别、神经机器翻译和文本生成中。 它在贪婪搜索(快速但目光短浅)和穷举搜索(准确但代价高昂)之间取得了巧妙的折衷,通过每一步只探索最有希望的候选路径,在效率和质量之间取得平衡。
我们要解决什么问题?
许多序列模型(如 RNN、Transformer 或 HMM)逐步生成输出,基于先前的符号预测下一个符号。 可能的序列数量随着长度呈指数级增长,使得精确解码变得不可行。
我们希望在不探索所有可能路径的情况下,找到一个高概率的输出序列。 束搜索通过在每一步只保留前 $k$ 个候选(即"束")来实现这一点。
核心思想
让模型定义条件概率:
$$ P(y_1, y_2, \dots, y_T) = \prod_{t=1}^{T} P(y_t \mid y_{<t}) $$
在每个解码步骤 $t$,束搜索不是只取单个最可能的标记(贪婪),也不是取所有标记(穷举),而是根据其累积对数概率保留 $k$ 个最可能的部分序列。
形式上,定义:
- 束宽 $k$(例如,3–10)
- 累积对数概率:
$$ \text{score}(y_{1:t}) = \sum_{i=1}^t \log P(y_i \mid y_{<i}) $$
在每一步,将所有候选序列扩展一个标记,计算新的分数,并只保留最好的 $k$ 个。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 从初始标记(例如,<start>)和空束开始 |
| 2 | 用所有可能的下一个标记扩展束中的每个序列 |
| 3 | 计算所有扩展序列的对数概率 |
| 4 | 选择分数最高的前 $k$ 个序列 |
| 5 | 重复直到达到序列结束标记 <eos> 或长度限制 |
| 6 | 返回分数最高(或归一化分数最高)的序列 |
工作原理(通俗解释)
想象你正在逐词猜测一个句子。 在每一步,你写下目前为止最可能的几个句子,比如前 3 个。 然后,你将每一个句子扩展一个新词,计算它们的新概率,并再次只保留总体上最好的 3 个。 你一直继续,直到句子结束。
束搜索不能保证找到绝对最好的句子,但它几乎总能找到一个非常好的句子,而且比尝试所有句子快得多。
示例
假设你的模型在每一步只能生成 A、B 或 C,并且束宽 $k=2$。
第一步:
| 候选 | 概率 |
|---|---|
| A | 0.6 |
| B | 0.3 |
| C | 0.1 |
保留前 2 个 → {A, B}
第二步(扩展 A 和 B):
| 序列 | 累积概率 |
|---|---|
| AA | 0.6×0.5=0.30 |
| AB | 0.6×0.3=0.18 |
| BA | 0.3×0.4=0.12 |
| BB | 0.3×0.6=0.18 |
保留前 2 个 → {AA, AB} 继续直到结束。
微型代码(简易版本)
Python
import numpy as np
def beam_search_step(probs, beam, k):
new_beam = []
for seq, score in beam:
for token, p in enumerate(probs):
new_beam.append((seq + [token], score + np.log(p)))
# 保留前 k 个
new_beam = sorted(new_beam, key=lambda x: x[1], reverse=True)[:k]
return new_beam
# 示例
probs_t1 = [0.6, 0.3, 0.1]
beam = [([], 0)]
beam = beam_search_step(probs_t1, beam, 2)
print(beam)C(大纲)
typedef struct {
int seq[100];
double score;
} Beam;
void beam_search_step(double *probs, Beam *beam, Beam *next, int k, int vocab_size) {
int count = 0;
for (int i = 0; i < k; i++)
for (int t = 0; t < vocab_size; t++) {
next[count] = beam[i];
next[count].seq[i+1] = t;
next[count].score += log(probs[t]);
count++;
}
// 排序并修剪至前 k 个(为简洁起见省略)
}为什么它很重要
- 在贪婪搜索和穷举搜索之间平衡了准确性和效率。
- 是神经序列模型解码的标准方法。
- 允许多种假设的探索。
- 常与归一化技巧(如长度惩罚)结合使用。
- 简单、通用且可调。
束宽控制着权衡:
- 小的 $k$ → 快速但有时次优。
- 大的 $k$ → 结果更好但解码更慢。
一个温和的证明(为什么它有效)
束搜索不是最优的(它会剪枝),但它是对最佳优先搜索的启发式近似。 通过根据累积概率保留前 $k$ 个部分假设,它近似于:
$$ S^* = \arg\max_S \prod_t P(y_t \mid y_{<t}) $$
因为对数函数是单调的,最大化对数之和与最大化概率乘积的顺序相同。
自己动手试试
- 训练一个简单的 RNN 用于文本生成。
- 比较三种解码模式:
- 贪婪解码($k=1$)
- 束搜索($k=3$)
- 采样(随机解码)
- 观察连贯性和多样性之间的权衡。
- 添加长度归一化: $$ \text{score}' = \frac{\text{score}}{(5 + |y|)^\alpha / (5 + 1)^\alpha} $$ 以防止短句子占主导地位。
测试用例
| 模型 | 任务 | 束宽 | 结果 |
|---|---|---|---|
| RNN | 文本生成 | 1 | 连贯但平淡 |
| RNN | 文本生成 | 5 | 更流畅,上下文更长 |
| Transformer | 翻译 | 4–8 | 最先进的结果 |
| HMM | 序列标注 | 3 | 接近最优的解码 |
复杂度
- 时间:$O(k \cdot T \cdot V)$ (束宽 × 序列长度 × 词汇表大小)
- 空间:$O(k \cdot T)$
- 权衡:质量 vs 计算量
束搜索是解码器的得力工具,通过明智地剪枝,它找到了感觉正确的路径,即使它不是唯一可能的路径。
945. 贪心解码
贪心解码是序列模型最简单的解码方法。 在每个时间步,它根据模型当前的预测选择概率最高的下一个词元,而不重新考虑之前的选择。
它速度快、确定性高,并且通常出人意料地有效,尽管它可能会错过全局最优或更多样化的解。
我们要解决什么问题?
给定一个定义序列概率的模型:
$$ P(y_1, y_2, \dots, y_T) = \prod_{t=1}^{T} P(y_t \mid y_{<t}) $$
我们想要找到概率最高的输出序列。
完全搜索的时间复杂度是 $T$ 的指数级,因此我们采用近似方法。 贪心解码在每一步选择局部最优:
$$ y_t^* = \arg\max_y P(y \mid y_{<t}) $$
这能快速生成一个单一序列。
核心思想
我们不探索多个候选序列(如集束搜索),而是沿着一条单一的路径构建序列,总是选择条件概率最高的下一个词元。
形式化表示:
$$ \hat{y} = [,y_1^, y_2^, \dots, y_T^,], \quad y_t^ = \arg\max_y P(y \mid y_{<t}) $$
没有回溯,没有搜索,只是直接进行下一步预测。
工作原理(通俗解释)
想象你正在逐词构建一个句子,总是选择当前“感觉最合适”的词。 你从不回头修正之前的错误,也从不探索其他可能的表达方式。
贪心解码就是这样,在模型的概率空间中沿着一条直线前进。
逐步总结
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 开始 | 从一个特殊的 <start> 词元开始 |
| 2 | 预测 | 计算下一个词元的概率 $P(y_t \mid y_{<t})$ |
| 3 | 选择 | 选择概率最高的词元 |
| 4 | 追加 | 将其添加到输出序列中 |
| 5 | 重复 | 继续直到遇到 <eos> 或达到长度限制 |
示例
如果你的模型输出的词元概率如下:
| 步骤 | 词元概率 | 选择 |
|---|---|---|
| 1 | {"A": 0.6, "B": 0.4} | "A" |
| 2 | {"A": 0.3, "C": 0.7} | "C" |
| 3 | {"B": 0.8, "D": 0.2} | "B" |
那么贪心解码的输出是:A C B。
它很快,但如果 "A C B" 导致死胡同,而 "A B B" 的总概率更高,贪心解码将无法发现这一点。
微型代码(简易版本)
Python
import numpy as np
def greedy_decode(model, start_token, max_len):
seq = [start_token]
for _ in range(max_len):
probs = model.predict_next(seq)
next_token = np.argmax(probs)
seq.append(next_token)
if next_token == model.eos_token:
break
return seqC(大纲)
int greedy_decode(double (*predict_next)(int*, int), int start, int eos, int max_len, int *output) {
output[0] = start;
for (int t = 1; t < max_len; t++) {
int next = argmax(predict_next(output, t));
output[t] = next;
if (next == eos) break;
}
return 0;
}为什么重要
- 快速且简单:无需管理集束或记录概率。
- 确定性:相同的输入 → 相同的输出。
- 适用于调试或生成基线。
- 对于置信度高的模型效果强:当概率分布尖锐时效果很好。
- 对于模糊或不确定的模型效果弱:可能会错过更好的长期路径。
贪心解码广泛用于经过良好校准的模型推理,例如某些分类或字幕生成系统。
一个温和的证明(为什么有效以及何时无效)
让我们将全局最优定义为:
$$ y^* = \arg\max_y P(y) $$
将贪心序列定义为:
$$ \hat{y}t = \arg\max_y P(y_t \mid \hat{y}{<t}) $$
贪心解码在每一步进行局部最大化,而不是全局最大化。 根据不等式:
$$ \max_y \prod_t P(y_t \mid y_{<t}) \neq \prod_t \max_y P(y_t \mid y_{<t}) $$
它可能无法达到全局最大值。 然而,如果 $P(y_t \mid y_{<t})$ 分布尖锐(低熵),贪心解码通常能近似找到真正的最佳序列。
亲自尝试
训练一个小型字符级语言模型。
使用以下方法解码文本:
- 贪心解码
- 集束搜索 ($k=3$)
- 采样(随机)
比较输出质量和多样性。
观察:贪心输出流畅但重复;集束输出更连贯;采样更具创造性。
测试用例
| 模型 | 任务 | 解码方式 | 结果 |
|---|---|---|---|
| Seq2Seq | 翻译 | 贪心 | 快速,有时句子较短 |
| Transformer | 文本生成 | 贪心 | 流畅但确定性高 |
| 语音模型 | 转录 | 贪心 | 当信号清晰时效果很好 |
| RNN | 诗歌生成 | 贪心 | 重复的短语 |
复杂度
- 时间:$O(T \cdot V)$(与模型推理相同)
- 空间:$O(T)$
- 权衡:速度 vs 探索
贪心解码就像一个快速而自信的讲故事者,它从不怀疑自己的下一个词,即使一点点犹豫本可能带来更精彩的内容。
946. 连接时序分类 (CTC)
连接时序分类 (CTC) 是一种为序列到序列任务设计的训练和解码方法,这类任务中输入和输出之间的对齐关系是未知的,例如语音识别、手写识别或手势解码。
它允许神经网络(特别是 RNN 或 Transformer)将可变长度的输入序列映射到较短的输出序列,而无需显式的帧级对齐。
我们要解决什么问题?
假设我们有:
- 输入序列 $X = (x_1, x_2, \dots, x_T)$(例如音频帧)
- 目标输出序列 $Y = (y_1, y_2, \dots, y_L)$(例如字母或单词)
我们不知道哪个输入帧对应哪个输出标记。 传统的监督学习需要对齐,但标注每一帧的成本很高。 CTC 通过对输入和输出之间所有可能的对齐进行边缘化来解决这个问题。
核心思想
CTC 引入了一个额外的“空白”符号 (∅) 并允许标签重复。 一个有效的对齐 $\pi = (\pi_1, \pi_2, \dots, \pi_T)$ 通过以下方式映射到最终输出 $Y$:
- 移除所有空白 (∅)。
- 合并重复的符号。
示例: $\pi = [∅, h, h, ∅, e, e, ∅, l, l, o, ∅]$ → $Y = [h, e, l, o]$
模型预测所有可能对齐的概率分布。
输出 $Y$ 的总概率为:
$$ P(Y \mid X) = \sum_{\pi \in \mathcal{B}^{-1}(Y)} P(\pi \mid X) $$
其中 $\mathcal{B}$ 是合并函数(移除空白 + 合并重复项)。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 定义包含空白 (∅) 符号的词汇表 |
| 2 | 网络为每一帧输出概率 $P(\pi_t \mid x_t)$ |
| 3 | 枚举所有映射到 $Y$ 的对齐 $\pi$ |
| 4 | 对所有有效路径的概率求和(前向-后向算法) |
| 5 | 在训练期间最大化 $\log P(Y \mid X)$ |
CTC 目标函数
对于目标序列 $Y = (y_1, \dots, y_L)$,通过在符号之间和两端插入空白来定义一个扩展序列:
$$ \tilde{Y} = (∅, y_1, ∅, y_2, ∅, \dots, y_L, ∅) $$
然后递归地计算 $\tilde{Y}$ 中位置 $s$ 上的前向概率 $\alpha_t(s)$:
初始化: $$ \alpha_1(1) = P(∅ \mid x_1), \quad \alpha_1(2) = P(y_1 \mid x_1) $$
递推关系: $$ \alpha_t(s) = P(\tilde{Y}s \mid x_t) \times \left[ \alpha{t-1}(s) + \alpha_{t-1}(s-1) + \mathbb{1}{\tilde{Y}s \ne \tilde{Y}{s-2}} \alpha{t-1}(s-2) \right] $$
最终概率: $$ P(Y \mid X) = \alpha_T(S-1) + \alpha_T(S) $$ 其中 $S = \vert \tilde{Y}\vert $。
工作原理(通俗解释)
想象听一个句子:有些音被拉长,有些被跳过,有些模糊不清。 CTC 告诉网络:
“不用担心时间点。只要确保在你的预测中的某个地方,正确的符号序列按顺序出现即可。”
空白符号允许停顿和可变的时间点;通过对齐求和,模型可以自动学习灵活的映射关系。
示例
如果输入有 5 帧,目标是 "HI",可能的对齐包括:
| 帧序列 | 合并后的输出 |
|---|---|
| [H, H, ∅, I, I] | HI |
| [∅, H, ∅, I, ∅] | HI |
| [H, ∅, H, I, ∅] | HI |
CTC 对所有可能性求和,而不仅仅是其中一种。
简化代码示例
Python (伪代码)
import numpy as np
def ctc_forward(log_probs, target, blank=0):
T, V = log_probs.shape
target_ext = [blank] + [t for t in target for _ in (0, 1)] + [blank]
S = len(target_ext)
alpha = np.full((T, S), -np.inf)
alpha[0, 0] = log_probs[0, blank]
alpha[0, 1] = log_probs[0, target_ext[1]]
for t in range(1, T):
for s in range(S):
prev = [alpha[t-1, s]]
if s > 0: prev.append(alpha[t-1, s-1])
if s > 1 and target_ext[s] != target_ext[s-2]:
prev.append(alpha[t-1, s-2])
alpha[t, s] = np.logaddexp.reduce(prev) + log_probs[t, target_ext[s]]
return np.logaddexp(alpha[T-1, S-1], alpha[T-1, S-2])C (概述)
// 计算前向矩阵 alpha[t][s](对数求和指数形式)
// 需要仔细处理数值稳定性为什么它很重要
- 无需预对齐数据,自动学习时间点。
- 对可变长度的输入和输出具有鲁棒性。
- 是 DeepSpeech 等语音识别模型的基础。
- 仍用于现代架构(例如 wav2vec 2.0 预训练)。
- 可微分且可端到端训练。
如果没有 CTC,许多序列问题(如语音或手写)将需要劳动密集型的帧级标注。
一个温和的证明(为什么它有效)
因为每个输出符号可以出现在多个时间帧中,我们对对齐进行边缘化。 使用前向-后向算法,我们高效地对指数级数量的路径求和:
$$ P(Y \mid X) = \sum_{\pi \in \mathcal{B}^{-1}(Y)} \prod_{t=1}^T P(\pi_t \mid x_t) $$
这可以在 $O(TL)$ 时间内递归计算,而不是 $O(V^T)$。
亲自尝试
- 生成一个包含 5 个时间步和目标单词 "CAT" 的玩具输入。
- 枚举所有可能的对齐。
- 使用空白规则合并它们。
- 手动计算概率并用 CTC 前向递归验证。
- 尝试训练一个小的 RNN 将正弦波模式映射到字母序列。
测试用例
| 应用 | 输入 | 输出 | 对齐已知? |
|---|---|---|---|
| 语音识别 | 音频帧 | 文本 | 否 |
| 手写识别 | 笔画 | 文本 | 否 |
| 手语识别 | 视频帧 | 注释 | 否 |
复杂度
- 时间:$O(TL)$
- 空间:$O(TL)$
- 关键优势:可微分的对齐边缘化
CTC 让神经网络学习说什么,而无需被告知何时说,将混乱的序列转化为结构化的意义,一次一个空白。
947. 注意力机制
注意力机制是现代人工智能中最具变革性的思想之一。它允许模型在做决策时有选择地关注其输入中最相关的部分,就像人类在阅读、听讲或推理时所做的那样。
注意力机制最初是为神经机器翻译而引入的,如今已成为 Transformer 架构的核心组件,驱动着 GPT、BERT 以及几乎所有语言、视觉等领域最先进的模型。
我们要解决什么问题?
像 RNN 这样的序列模型会将所有过去的信息压缩成一个单一的隐藏向量。这使得它们难以记住长上下文,会忘记较早的单词或事件。
我们希望有一个模型能够:
- 回顾所有先前的位置,
- 根据相关性对它们进行加权,
- 在每一步动态地组合它们。
这正是注意力机制所做的。
核心思想
在每个解码步骤 $t$,模型计算它应该以多强的程度关注每个编码器状态 $h_i$(其中 $i = 1, 2, \dots, T$)。
我们定义:
- 查询向量 $q_t$(来自解码器状态)
- 键向量 $k_i$(来自编码器状态)
- 值向量 $v_i$(要提取的信息)
注意力权重 $\alpha_{t,i}$ 衡量了 $h_i$ 与 $q_t$ 的相关性:
$$ e_{t,i} = \text{score}(q_t, k_i) $$
然后通过 softmax 进行归一化:
$$ \alpha_{t,i} = \frac{\exp(e_{t,i})}{\sum_j \exp(e_{t,j})} $$
最后,计算上下文向量作为加权平均:
$$ c_t = \sum_i \alpha_{t,i} v_i $$
解码器使用 $c_t$ 作为额外信息来预测下一个词元。
常见的评分函数
| 类型 | 公式 | 备注 |
|---|---|---|
| 点积 | $e_{t,i} = q_t^\top k_i$ | 简单,快速 |
| 缩放点积 | $e_{t,i} = \frac{q_t^\top k_i}{\sqrt{d_k}}$ | Transformer 中使用 |
| 加性 (Bahdanau) | $e_{t,i} = v_a^\top \tanh(W_q q_t + W_k k_i)$ | 可学习的组合方式 |
工作原理(通俗解释)
想象一下逐词翻译一个句子。当翻译 "bank" 这个词时,模型应该仔细查看附近的词,比如 "river" 或 "loan",以决定其含义。注意力机制不是依赖单一的记忆,而是让模型回顾所有单词,对它们进行加权,并使用该上下文来做当前的决策。
注意力机制将序列建模从线性记忆转变为可微分的查找表,模型可以根据需要"查阅"所有先前的信息。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 计算查询 $q_t$ 与每个键 $k_i$ 之间的相似度 $e_{t,i}$ |
| 2 | 应用 softmax 得到注意力权重 $\alpha_{t,i}$ |
| 3 | 计算加权和 $c_t = \sum_i \alpha_{t,i} v_i$ |
| 4 | 将 $c_t$ 与解码器状态结合以生成下一个输出 |
| 5 | 对每个解码步骤重复此过程 |
示例
对于一个简单的翻译模型: 输入句子:"Le chat dort." 当前输出:"The cat ..." 当预测 "sleeps" 时,注意力权重在 "dort" 处达到峰值,使模型能够获取正确的上下文。
| 源词 | 注意力权重 |
|---|---|
| Le | 0.05 |
| chat | 0.20 |
| dort | 0.75 |
微型代码(简易版本)
Python (缩放点积注意力)
import numpy as np
def attention(Q, K, V):
d_k = K.shape[-1]
scores = Q @ K.T / np.sqrt(d_k)
weights = np.exp(scores) / np.exp(scores).sum(axis=-1, keepdims=True)
return weights @ V, weightsC (大纲)
void attention(double *Q, double *K, double *V, double *out, int n, int d) {
double scale = 1.0 / sqrt((double)d);
for (int i = 0; i < n; i++) {
double sum = 0.0;
for (int j = 0; j < n; j++) {
double score = 0;
for (int k = 0; k < d; k++)
score += Q[i*d + k] * K[j*d + k];
score *= scale;
double expv = exp(score);
sum += expv;
out[i*d + k] += (expv / sum) * V[j*d + k];
}
}
}为何重要
- 解决了 RNN 中的长程依赖问题。
- 完全可微分,可端到端训练。
- 可解释性强,注意力权重可视化展示了模型"关注"什么。
- 构成了 Transformer 架构的核心(通过多头自注意力)。
- 通用机制,适用于语言、视觉、时间序列、强化学习等。
一个温和的证明(为何有效)
上下文向量 $c_t$ 是在注意力分布 $\alpha_t$ 下编码器表示的期望值:
$$ c_t = \mathbb{E}_{i \sim \alpha_t}[v_i] $$
由于 $\alpha_t$ 和为 1 且平滑地依赖于 $q_t$,整个机制是可微分的,并且可以通过反向传播进行训练。它学会为那些能减少损失的输入分配高权重,从而动态地对齐相关特征。
变体
- 加性注意力 (Bahdanau),非线性评分函数。
- 乘性 / 缩放点积注意力 (Luong, Vaswani),高效的矩阵乘法形式。
- 自注意力,查询、键和值来自同一序列。
- 多头注意力,多个子空间捕捉不同的关系。
亲自尝试
- 在小随机向量上实现点积注意力。
- 可视化不同查询的注意力权重。
- 通过对 Q、K、V 使用相同的矩阵扩展到自注意力。
- 观察多头注意力如何混合特征。
- 将注意力权重应用于句子中的单词,观察哪些部分被高亮。
测试用例
| 任务 | 模型 | 注意力类型 | 结果 |
|---|---|---|---|
| 机器翻译 | Seq2Seq | Bahdanau | 改善对齐 |
| 文本摘要 | Transformer | 自注意力 | 捕捉上下文 |
| 视觉 | ViT | 多头注意力 | 全局像素上下文 |
| 语音识别 | Transformer-ASR | 交叉注意力 | 稳定解码 |
复杂度
- 时间:$O(T^2 d)$
- 空间:$O(T^2)$
- 权衡:更好的上下文 vs 二次方成本
注意力是记忆与推理之间的桥梁,让神经网络不仅能记住,还能选择记住什么。
948. Transformer 解码器
Transformer 解码器是现代生成式 AI 的引擎,它是一种使用自注意力而非循环来处理上下文的序列模型。它是 GPT 风格模型背后的关键组件,能够高效地建模长距离依赖关系,并生成连贯的文本、代码等。
我们要解决什么问题?
传统的序列模型(RNN、LSTM)逐步处理输入。这使得它们速度慢、顺序化,并且在记忆遥远上下文方面能力有限。Transformer 用并行自注意力取代了循环,实现了全局上下文访问和巨大的可扩展性。
解码器部分专门处理自回归生成,即给定所有先前标记来预测下一个标记。
核心思想
在每个解码步骤 $t$,模型会关注:
- 输出序列中的所有先前标记(通过掩码自注意力)。
- 所有编码器输出(通过编码器-解码器注意力)。
- 其自身逐层学习到的隐藏表示。
每个解码器层包含三个子层:
掩码自注意力
- 仅计算对过去位置的注意力(无未来信息泄露)。
- 使用三角掩码 $M$,其中: $$ M_{ij} = \begin{cases} 0, & j \le i,\ -\infty, & j > i \end{cases} $$ 这样 softmax 就会忽略未来的标记。
编码器-解码器注意力
- 关注编码器输出(在翻译或其他配对任务中很有用)。
- 查询来自解码器,键/值来自编码器。
前馈网络
- 两个带 ReLU 或 GELU 的线性变换: $$ \text{FFN}(x) = \max(0, xW_1 + b_1)W_2 + b_2 $$
每个子层都包含:
- 残差连接
- 层归一化
Transformer 解码器块
对于每个标记表示 $x_t$:
$$ \begin{aligned} z_1 &= \text{LayerNorm}(x_t + \text{SelfAttention}(x_t)) \ z_2 &= \text{LayerNorm}(z_1 + \text{CrossAttention}(z_1, E)) \ y_t &= \text{LayerNorm}(z_2 + \text{FFN}(z_2)) \end{aligned} $$
其中 $E$ 是编码器输出(用于翻译等任务)。在纯语言模型(如 GPT)中,编码器被省略,仅使用自注意力。
工作原理(通俗解释)
想象一下写一个故事。在每个单词处,你会回顾到目前为止写过的所有内容(但不会向前看),来决定接下来写什么。你还会参考一个“事实”记忆(来自编码器或上下文)。解码器做着同样的事情,结合自注意力和前馈推理,逐词构建意义。
分步总结
| 步骤 | 子层 | 功能 |
|---|---|---|
| 1 | 掩码自注意力 | 查看先前的标记 |
| 2 | 编码器-解码器注意力 | 查看编码后的输入(可选) |
| 3 | 前馈网络 | 非线性变换 |
| 4 | 残差连接 + 归一化 | 稳定性和梯度流动 |
| 5 | 输出投影 | 将隐藏状态映射到词汇表逻辑值 |
示例:自回归生成
在推理时:
- 以
<BOS>(序列开始)标记开始。 - 使用输出逻辑值的 softmax 预测下一个标记: $$ P(y_t \mid y_{<t}) = \text{softmax}(W_o h_t) $$
- 将 $y_t$ 追加并反馈给模型。
- 重复直到
<EOS>或达到长度限制。
微型代码(简化版)
Python (NumPy 原型)
import numpy as np
def softmax(x):
e = np.exp(x - np.max(x))
return e / e.sum(axis=-1, keepdims=True)
def attention(Q, K, V, mask=None):
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k)
if mask is not None:
scores += mask # 对掩码位置应用 -inf
weights = softmax(scores)
return weights @ V
def transformer_decoder_step(Q, K, V, mask):
context = attention(Q, K, V, mask)
return context + Q # 简单的残差形式C (大纲)
// 对于每个标记 t:计算 Q,K,V;掩码未来位置;应用点积注意力。
// 添加残差和归一化步骤(为简洁起见省略)。为何重要
- 用注意力取代循环 → 可并行化且速度更快。
- 捕获长序列中的全局上下文。
- 扩展性极佳 → 支持超大模型(数十亿参数)。
- 是 GPT、BERT、T5、LLaMA 等模型的基础。
- 语言、视觉、音频和多模态的统一。
解码器架构是大型语言模型能够通过简单地关注海量上下文窗口来撰写文章、代码和诗歌的原因。
一个温和的证明(为何有效)
自注意力在序列中所有位置之间构建了成对依赖关系。与依赖链式循环的 RNN 不同:
$$ h_t = f(h_{t-1}, x_t) $$
Transformer 通过注意力权重同时计算所有 $h_t$。因此,模型在单步中学习上下文关系,实现了高效的长距离推理。
掩码确保了因果性,不会“偷看”未来的标记。
亲自尝试
- 在玩具数据集上实现一个 2 层的仅解码器 Transformer。
- 与 RNN 比较:注意更快的收敛速度和更好的长期记忆。
- 可视化解码过程中的注意力权重,观察标记如何向后关注。
- 尝试不同的掩码大小(上下文窗口)。
- 尝试贪婪解码与束搜索解码。
测试用例
| 模型 | 任务 | 类型 | 仅解码器? |
|---|---|---|---|
| GPT | 文本生成 | 语言 | 是 |
| T5 | 翻译 | 编码器-解码器 | 否 |
| Whisper | 语音识别 | 编码器-解码器 | 否 |
| LLaMA | 聊天模型 | 语言 | 是 |
复杂度
- 时间:每层 $O(T^2 d)$
- 空间:$O(T^2)$
- 并行性:每个标记完全并行
Transformer 解码器是现代的故事讲述者,它阅读自己写过的所有内容,精确地记住上下文,无需在时间上循环往复,就能编织出连贯的序列。
949. 带注意力机制的序列到序列模型
带注意力机制的序列到序列模型结合了序列到序列建模和注意力机制这两个强大的思想,实现了从一个序列到另一个序列的灵活映射,尤其是在输入和输出长度不同的情况下。 它标志着神经机器翻译领域的一项重大突破,后来演变为现代 Transformer 架构的基础。
我们要解决什么问题?
在使用 RNN 的经典序列到序列模型中:
- 编码器将整个输入序列读入一个固定长度的向量。
- 解码器仅基于该向量逐步生成输出。
问题:长序列会导致信息瓶颈,早期的标记会被遗忘,上下文变得模糊。
解决方案:添加注意力机制,使解码器可以回顾所有编码器状态,而不仅仅是最终状态。
核心思想
带注意力机制的序列到序列模型有两个主要组成部分:
编码器 读取输入序列 $(x_1, \dots, x_T)$ 并产生一系列隐藏状态: $$ h_i = \text{Encoder}(x_i, h_{i-1}) $$
解码器 通过注意力权重 $\alpha_{t,i}$ 关注所有编码器状态,同时生成每个输出标记 $y_t$。
在每个解码步骤 $t$:
- 计算对齐分数: $$ e_{t,i} = \text{score}(s_{t-1}, h_i) $$
- 使用 softmax 归一化: $$ \alpha_{t,i} = \frac{\exp(e_{t,i})}{\sum_j \exp(e_{t,j})} $$
- 计算上下文向量: $$ c_t = \sum_i \alpha_{t,i} h_i $$
- 更新解码器状态: $$ s_t = f(s_{t-1}, y_{t-1}, c_t) $$
- 预测下一个标记: $$ P(y_t \mid y_{<t}, X) = \text{softmax}(W[s_t; c_t]) $$
工作原理(通俗解释)
以翻译为例: 编码器读取 "Le chat dort." → 它为每个单词构建记忆。 解码器开始生成 "The cat sleeps.",并且在每一步,都会回顾编码器的记忆,将注意力集中在最相关的地方。
当生成 "cat" 时,注意力集中在 "chat" 上。 当生成 "sleeps" 时,注意力集中在 "dort" 上。
这种选择性聚焦消除了瓶颈,并赋予了模型可解释性。
逐步总结
| 步骤 | 组件 | 描述 |
|---|---|---|
| 1 | 编码器 | 读取输入序列,产生隐藏状态 |
| 2 | 注意力机制 | 计算每个编码器状态与解码器状态的相关性 |
| 3 | 上下文 | 编码器隐藏状态的加权和 |
| 4 | 解码器 | 使用前一个标记、上下文和隐藏状态来预测下一个标记 |
| 5 | 输出 | 在词汇表上应用 softmax 以预测下一个词 |
示例:简单翻译
输入:"je t'aime" 编码器输出隐藏状态 $h_1, h_2, h_3$。 解码器生成:
| 步骤 | 输出 | 注意力峰值 |
|---|---|---|
| 1 | I | $h_1$ (je) |
| 2 | love | $h_3$ (aime) |
| 3 | you | $h_2$ (t') |
这种动态对齐实现了流畅的翻译,而无需显式的词对齐。
简化代码
Python
import numpy as np
def softmax(x): return np.exp(x) / np.exp(x).sum()
def attention(decoder_state, encoder_states):
scores = encoder_states @ decoder_state
weights = softmax(scores)
context = (encoder_states.T @ weights).T
return context, weights
def seq2seq_step(prev_state, prev_output, encoder_states):
context, _ = attention(prev_state, encoder_states)
new_state = np.tanh(prev_state + context + prev_output)
return new_stateC (大纲)
// 对于每个解码步骤:
// 1. 计算 scores = dot(decoder_state, encoder_states)
// 2. 应用 softmax 得到注意力权重
// 3. 计算加权的上下文向量
// 4. 与解码器 RNN 结合以生成下一个输出为什么它很重要
- 解决了基础 Seq2Seq 模型中的信息瓶颈问题。
- 改进了翻译、摘要和语音任务。
- 通过注意力热图实现可解释性。
- 是 Transformer 的前身:用自注意力取代了循环。
- 适用于可变长度的输入/输出,非常灵活。
注意力机制将序列学习从“压缩并解码”转变为“咨询并组合”。
一个温和的证明(为什么它有效)
在没有注意力的情况下,条件概率是:
$$ P(y_t \mid y_{<t}, X) = f(s_{t-1}, h_T) $$
有了注意力机制,我们改为对所有编码器状态进行边缘化:
$$ P(y_t \mid y_{<t}, X) = f\left(s_{t-1}, \sum_i \alpha_{t,i} h_i\right) $$
这将单上下文模型转变为上下文分布模型,表达能力更强且可微分,改善了梯度流动和对齐学习。
自己动手试试
- 在简短的英法句对上训练一个小型 Seq2Seq 模型。
- 可视化每个解码步骤的注意力权重。
- 增加句子长度,观察注意力如何保持上下文。
- 与无注意力机制的基线模型进行比较。
- 尝试 Bahdanau(加性)评分与 Luong(点积)评分。
测试用例
| 任务 | 输入 | 输出 | 注意力机制的作用 |
|---|---|---|---|
| 翻译 | "guten morgen" | "good morning" | 对齐单词 |
| 摘要 | 文章 | 摘要 | 聚焦于主要短语 |
| 语音识别 | 音频帧 | 文本 | 时间对齐 |
| 问答 | 上下文 + 问题 | 答案 | 聚焦于相关片段 |
复杂度
- 时间:$O(T_x T_y d)$
- 空间:$O(T_x d)$
- 改进:可并行化且可解释
带注意力机制的序列到序列模型是第一个能够“边看边说”的神经模型,它是记忆与聚焦之间一座优雅的桥梁,为 Transformer 革命开辟了道路。
950. 指针网络
指针网络是对标准序列到序列架构的一种巧妙改进,它不从一个固定词汇表中预测标记,而是将输入序列中的元素作为输出进行“指向”。
当输出是输入的重排序或子集时,它特别有用,例如在排序、路径规划或组合优化问题中。
我们要解决什么问题?
普通的 Seq2Seq 模型从一个固定大小的词汇表中生成输出。 但如果输出必须引用输入中的特定位置呢? 例如:
- 数字排序(输出是输入索引的一个排列)
- 解决旅行商问题(输出是城市索引的序列)
- 抽取式摘要(输出是标记的一个子集)
基于固定词汇表的普通 softmax 无法表达这一点, 我们需要一个能够动态选择输入元素作为输出的模型。
核心思想
指针网络将注意力机制重新用作指针,而不是加权函数。 在每个解码步骤,模型计算对编码器输入的注意力,并选择一个位置作为下一个输出。
对于输入序列 $X = (x_1, x_2, \dots, x_n)$ 和解码器状态 $s_t$:
为每个输入计算得分: $$ e_{t,i} = v_a^\top \tanh(W_1 h_i + W_2 s_t) $$
使用 softmax 进行归一化: $$ p_{t,i} = \frac{\exp(e_{t,i})}{\sum_j \exp(e_{t,j})} $$
步骤 $t$ 的输出是具有最高概率的索引: $$ y_t = \arg\max_i p_{t,i} $$
因此,它不是生成标记,而是“指向”相关的输入位置。
工作原理(通俗解释)
假设你有一个城市列表,你想让模型规划最短路线。 它不是生成城市名称,而是指向输入列表中下一个城市的索引,就像手指在追踪一条最优路径。
每一步的注意力分布就像一张覆盖输入位置的概率图。
分步总结
| 步骤 | 组件 | 描述 |
|---|---|---|
| 1 | 编码器 | 将所有输入元素读入隐藏状态 $h_i$ |
| 2 | 解码器 | 维护一个循环状态 $s_t$ |
| 3 | 注意力机制 | 为每个输入位置计算得分 $e_{t,i}$ |
| 4 | Softmax | 转换为概率分布 $p_{t,i}$ |
| 5 | 输出 | 选择概率最高的输入索引 |
示例:对三个数字排序
输入:[3, 1, 2] 编码器隐藏状态表示每个数字。 解码器输出索引 [2, 3, 1],对应于排序后的顺序 [1, 2, 3]。
| 步骤 | 解码器输出 | 选中的输入 | 含义 |
|---|---|---|---|
| 1 | 2 | 1 | 最小的数字 |
| 2 | 3 | 2 | 次小的数字 |
| 3 | 1 | 3 | 最大的数字 |
简化代码
Python
import numpy as np
def pointer_network_step(encoder_h, decoder_s):
scores = np.tanh(encoder_h @ W1 + decoder_s @ W2)
scores = scores @ v_a
probs = np.exp(scores) / np.exp(scores).sum()
pointer = np.argmax(probs)
return pointer, probsC (大纲)
// 计算解码器状态和编码器状态之间的注意力得分
// 对输入位置应用 softmax 以获得概率
// 输出概率最高的索引为什么它很重要
- 动态输出大小:无需固定词汇表。
- 非常适合结构化任务:排序、匹配、路径规划。
- 可解释:注意力权重显示了选择概率。
- 连接了深度学习和组合优化。
该模型学习选择而非生成, 这一微妙的转变为深度学习打开了解决图论和运筹学问题的大门。
一个温和的证明(为什么它有效)
在普通的基于注意力的 Seq2Seq 模型中,注意力权重 $\alpha_{t,i}$ 充当上下文权重:
$$ c_t = \sum_i \alpha_{t,i} h_i $$
在指针网络中,这些相同的权重构成了输出分布:
$$ P(y_t = i \mid X, y_{<t}) = \alpha_{t,i} $$
由于 $\alpha_{t,i}$ 依赖于输入表示和解码器状态,模型通过标准的反向传播学习可微的选择行为。
亲自尝试
- 训练一个指针网络来对小的整数列表进行排序。
- 将注意力权重随时间变化可视化为“指针”。
- 扩展到旅行商问题(TSP),模型输出城市顺序。
- 尝试抽取式摘要,从文本中选择关键句子。
- 与固定词汇表的 Seq2Seq 基线进行比较。
测试用例
| 任务 | 输入 | 输出 | 模型行为 |
|---|---|---|---|
| 排序 | [5, 1, 4] | [2, 3, 1] | 选择最小优先的顺序 |
| TSP | 5 个城市坐标 | 索引序列 | 高效地为城市规划路线 |
| 文本摘要 | 句子 | 选中的子集 | 提取关键行 |
| 问答 | 上下文词语 | 词语索引 | 指向答案的跨度 |
复杂度
- 时间:$O(n^2)$(对所有输入计算注意力)
- 空间:$O(n^2)$
- 输出灵活性:最多到输入的长度
指针网络表明,深度学习模型并不总是需要创造新词,有时,它们只需要指向已经存在的东西。
第 96 节. 元启发式算法
951. 遗传算法 (GA)
遗传算法 (GA) 是一种受自然选择启发的进化搜索技术。它通过选择、交叉和变异来演化候选解种群,从而探索庞大的解空间,就像生物进化通过世代更迭来改善物种一样。
我们要解决什么问题?
许多现实世界的问题,如调度、设计、路由、优化,其搜索空间对于精确算法来说过于庞大或复杂。遗传算法提供了一种随机、自适应的方法来寻找足够好的解,而无需暴力穷举。
它们在以下情况下特别有用:
- 问题没有梯度或具有非线性目标函数
- 解空间是离散的或组合的
- 我们只需要快速得到一个接近最优的答案
核心思想
遗传算法维护一个候选解(染色体)的种群。每个解被表示为一个字符串(例如,比特位、数字、符号)。经过多代演化,种群会朝着更高的适应度方向"进化"。
主要步骤:
- 初始化:创建随机种群 $P_0$
- 选择:选择较优的个体进行繁殖
- 交叉:配对组合以产生后代
- 变异:随机调整后代以保持多样性
- 替换:形成下一代 $P_{t+1}$
- 重复直到收敛或达到最大代数
遗传循环(通俗解释)
- 随机开始:猜测一堆解。
- 评估它们:使用适应度函数 $f(x)$ 来衡量质量。
- 繁殖幸存者:最适应的"父母"交配产生后代。
- 混合与变异:小的随机变化引入变异。
- 保留最优者:为下一代选择顶级候选者。
- 重复:进化持续进行,直到性能稳定。
数学视角
令:
- $x_i^{(t)}$ 表示第 $t$ 代中的第 $i$ 个个体
- $f(x_i^{(t)})$ 表示其适应度得分
种群根据以下方式演化:
$$ P^{(t+1)} = \text{Select}\big(\text{Mutate}(\text{Crossover}(P^{(t)}))\big) $$
这个过程平衡了探索(通过变异)和利用(通过选择)。
逐步示例(二进制优化)
目标:最大化 $f(x) = x^2$,其中 $x$ 是一个 5 位二进制数。
| 代数 | 种群 | 适应度 | 被选中的 | 后代 | 变异 |
|---|---|---|---|---|---|
| 0 | [01011, 10000, 00101, 11100] | [121, 256, 25, 784] | [11100, 10000] | [11100, 10000] | [11110, 10100] |
| 1 | [11110, 10100, …] | [900, 400, …] | … | … | … |
经过几代之后,种群收敛于 $x=11111$ (31),即全局最优解。
微型代码
Python
import random
def fitness(x):
return x * x
def mutate(x):
mask = 1 << random.randint(0, 4)
return x ^ mask
def crossover(a, b):
point = random.randint(1, 4)
mask = (1 << point) - 1
return (a & mask) | (b & ~mask)
# 初始化
pop = [random.randint(0, 31) for _ in range(4)]
for gen in range(10):
pop = sorted(pop, key=fitness, reverse=True)
a, b = pop[:2]
child = mutate(crossover(a, b))
pop[-1] = child
print(f"Gen {gen}: best {a}, fitness={fitness(a)}")C (概略)
// 将每个候选解表示为一个无符号整数。
// 通过位掩码进行交叉,通过翻转随机位进行变异。
// 评估适应度并在每一代保留顶级个体。为何重要
- 适用于不可微、离散或黑盒问题。
- 天然具有并行性和随机性。
- 易于实现和调整。
- 用于设计优化、路径规划、神经架构搜索等领域。
一个温和的证明(为何有效)
遗传算法通过种群多样性近似随机爬山法。根据模式定理,如果代表良好部分解的模式(模式)贡献了高于平均水平的适应度,它们将以指数级速度传播:
$$ E[m(H, t+1)] \ge m(H, t) \frac{f(H)}{\bar{f}} (1 - p_c - p_m) $$
其中:
- $m(H, t)$ 是模式实例的数量
- $f(H)$ 是模式的平均适应度
- $\bar{f}$ 是种群的平均适应度
- $p_c, p_m$ 是交叉和变异的概率
随着时间的推移,有益的模式将主导种群。
亲自尝试
- 实现遗传算法以在 $[0, 2\pi]$ 上最大化函数 $f(x) = \sin(x)$。
- 改变变异率并观察多样性变化。
- 比较轮盘赌选择与锦标赛选择。
- 可视化最佳适应度随代数的收敛情况。
- 使用遗传算法为一个小型神经网络演化权重。
测试用例
| 任务 | 表示方式 | 适应度函数 | 备注 |
|---|---|---|---|
| 比特串优化 | 二进制 | 问题定义 | 经典案例 |
| TSP(旅行商问题) | 城市序列 | 路径长度的倒数 | 常用基准测试 |
| 符号回归 | 表达式树 | 与数据的误差 | GP(遗传编程)变体 |
| 调度 | 作业顺序 | 最小化延迟 | 离散问题 |
| 神经架构 | 网络结构 | 验证准确率 | 元学习 |
复杂度
- 时间复杂度:$O(P \cdot G \cdot C)$
- $P$:种群大小
- $G$:代数
- $C$:适应度计算成本
- 空间复杂度:$O(P)$
遗传算法提醒我们,进步不需要精确性,它可以从变异、竞争和坚持中涌现出来。
952. 模拟退火 (SA)
模拟退火是一种受金属加热并缓慢冷却以消除缺陷的物理过程启发的概率优化算法。 它是一种单解搜索方法,利用受控的随机性来平衡探索(尝试新解)和利用(保留好解)。
我们要解决什么问题?
许多优化问题存在许多局部极小值,即解看起来不错但并非全局最优的地方。 简单的爬山算法可能会被困在那里。
模拟退火通过早期偶尔接受更差的解来逃离局部陷阱,然后逐渐减少随机性以收敛到一个好的区域,从而解决这个问题。
它在以下情况下特别有用:
- 搜索空间大或非凸
- 梯度不可用或存在噪声
- 确定性方法因局部极小值而失败
核心思想
该算法模拟材料如何退火、加热然后缓慢冷却,使粒子沉降到低能量(稳定)状态。
在每次迭代中:
从当前解 $x$ 提出一个新解 $x'$。
计算能量(目标)差 $\Delta E = f(x') - f(x)$。
在以下情况下接受新解:
- $\Delta E < 0$(更好),或
- 以概率 $p = \exp(-\Delta E / T)$(更差但允许)
逐渐降低温度 $T$ → 减少随机性。
当 $T$ 非常小或解稳定时停止。
工作原理(通俗解释)
想象一个球在丘陵地形上滚动。 在"高温"下,它可以自由弹跳,有时会爬上山丘。 随着冷却,它的运动变得更加受限,直到最终停在一个深谷中,理想情况下是全局最小值。
早期的随机性有助于探索,后期的冷却有助于收敛。
数学公式
设 $f(x)$ 为要最小化的能量(目标函数)。 在迭代 $t$ 时:
- 通过扰动 $x$ 提出新候选解 $x'$。
- 计算 $\Delta E = f(x') - f(x)$。
- 以以下概率接受 $x'$:
$$ P(\text{接受}) = \begin{cases} 1, & \text{如果 } \Delta E < 0,\ \exp(-\Delta E / T_t), & \text{否则。} \end{cases} $$
- 更新温度 $T_t = \alpha T_{t-1}$,其中 $0 < \alpha < 1$ 是冷却速率。
逐步示例
目标:最小化 $f(x) = x^2 + 10 \sin(x)$
| 迭代次数 | 当前 $x$ | 候选 $x'$ | $\Delta E$ | $T$ | 是否接受? |
|---|---|---|---|---|---|
| 1 | 2.0 | 3.1 | +4.7 | 1.0 | 是(随机接受) |
| 2 | 3.1 | 1.5 | -6.0 | 0.9 | 是 |
| 3 | 1.5 | 1.9 | +0.5 | 0.81 | 可能(取决于 $e^{-0.5/T}$) |
随着时间的推移,接受更差移动的情况会变得越来越罕见。
微型代码
Python
import math, random
def f(x): return x**2 + 10 * math.sin(x)
x = random.uniform(-10, 10)
T = 1.0
alpha = 0.95
for i in range(1000):
x_new = x + random.uniform(-1, 1)
dE = f(x_new) - f(x)
if dE < 0 or random.random() < math.exp(-dE / T):
x = x_new
T *= alpha
print("最佳 x:", x, "f(x):", f(x))C (概略)
// 随机初始化 x,设置 T = 1
// 循环:提出 x',计算 dE = f(x') - f(x)
// 如果 dE < 0 或 exp(-dE/T) > rand(),接受 x'
// 逐渐降低 T为什么它很重要
- 逃离局部极小值,这是相对于贪婪方法的一个关键优势。
- 即使在非光滑、不连续或离散问题上也能工作。
- 实现简单,调参最少。
- 在调度、电路设计、路径规划和布局优化中很有用。
一个温和的证明(为什么它有效)
模拟退火植根于马尔可夫链蒙特卡洛 (MCMC) 理论。 如果温度下降得足够慢,算法(在概率上)会收敛到 $f(x)$ 的全局最小值。
在平衡状态下,系统根据玻尔兹曼分布对状态进行采样:
$$ P(x) \propto \exp(-f(x) / T) $$
当 $T \to 0$ 时,概率质量集中在全局最优解上。
亲自尝试
最小化 $f(x) = x^2 + 10\sin(x)$ 并可视化轨迹。
尝试不同的冷却计划:
- 指数:$T_t = \alpha^t T_0$
- 线性:$T_t = T_0 / (1 + \beta t)$
- 对数:$T_t = T_0 / \log(1 + t)$
绘制接受率与温度的关系图。
将 SA 应用于旅行商问题。
测试用例
| 问题 | 搜索空间 | 备注 |
|---|---|---|
| 函数最小化 | 连续 | 简单基准测试 |
| TSP | 排列 | 经典组合测试 |
| 作业调度 | 离散 | 局部极小值数量多 |
| 神经网络权重 | 连续 | 实验性元启发式方法 |
复杂度
时间:$O(N \times k)$
- $N$:迭代次数
- $k$:评估 $f(x)$ 的成本
空间:$O(1)$
收敛取决于冷却计划,越慢越好。
模拟退火就像受控的混沌,起初漫无目的地游荡,然后慢慢找到秩序,通过一次次的概率下降冷却成一个近乎完美的解。
953. 禁忌搜索
禁忌搜索是一种元启发式算法,它通过记忆并避免最近访问过的解,来引导局部搜索算法逃离局部最小值。它为优化过程添加了短期记忆,帮助算法探索搜索空间的新区域,而不会循环返回。
我们要解决什么问题?
局部搜索算法(如爬山法)可能会陷入局部最优,无限次地重新访问相同或相似的解。禁忌搜索引入了一种基于记忆的策略来克服这个问题,它禁止(使其成为禁忌)那些会撤销近期进展的移动。
这在困难的组合问题中非常有用,例如:
- 旅行商问题
- 调度与排班
- 图着色
- 资源分配
核心思想
禁忌搜索通过三个关键机制增强了简单的爬山法:
禁忌表(记忆) 一个存储近期移动或属性的短期记忆,用于防止循环。
渴望准则 如果某个移动能产生明显更好的解,则允许覆盖禁忌限制。
邻域探索 系统地评估附近的解(邻域),并选择最佳的非禁忌解。
算法持续运行,直到满足停止条件,例如达到时间限制或经过多次迭代后没有改进。
工作原理(通俗解释)
想象你在黑暗中徒步穿越山丘。你只能感觉到脚下的地面,所以你沿着最陡峭的路径向上爬。但如果你总是这样做,最终会困在一个小山丘上。
禁忌搜索通过记录你已经去过的地方来帮助你,禁止你过早地退回原路,并且如果看起来有希望,偶尔允许冒险绕行。
数学表述
设:
- $S$ 为搜索空间
- $f(s)$ 为成本(需要最小化)
- $N(s)$ 为 $s$ 的邻域
在第 $t$ 次迭代时:
- 从当前解 $s_t$ 生成邻域 $N(s_t)$。
- 排除禁忌移动,即存储在禁忌表 $T_t$ 中的那些移动。
- 选择最佳候选解: $$ s_{t+1} = \arg\min_{s' \in N(s_t) \setminus T_t} f(s') $$
- 更新禁忌表: $$ T_{t+1} = (T_t \cup {\text{move}(s_t, s_{t+1})}) - \text{expired entries} $$
- 如果 $f(s_{t+1})$ 改进了全局最优解,则更新它。
逐步示例
目标:找到经过城市 A–E 的最短路线。
| 迭代次数 | 当前路线 | 成本 | 禁忌移动 | 最佳移动 | 下一路线 |
|---|---|---|---|---|---|
| 1 | A–B–C–D–E | 34 | (交换 A–B) | 交换 C–D | A–B–D–C–E |
| 2 | A–B–D–C–E | 31 | (交换 C–D) | 交换 B–D | A–D–B–C–E |
| 3 | A–D–B–C–E | 29 | (交换 B–D) | 交换 A–D | A–B–C–D–E (禁忌,跳过) |
| 4 | … | … | … | … | … |
算法避免过早地返回之前的路线,从而探索新的配置。
微型代码
Python(简化版)
import random
def tabu_search(init, neighbor_fn, cost_fn, max_iter=100, tabu_len=5):
current = init
best = init
tabu_list = []
for _ in range(max_iter):
neighbors = neighbor_fn(current)
candidates = [n for n in neighbors if n not in tabu_list]
if not candidates:
candidates = neighbors # 如果全部禁忌,暂时忽略限制
next_candidate = min(candidates, key=cost_fn)
if cost_fn(next_candidate) < cost_fn(best):
best = next_candidate
tabu_list.append(next_candidate)
if len(tabu_list) > tabu_len:
tabu_list.pop(0)
current = next_candidate
return bestC(草图)
// 将近期移动存储在固定大小的数组中(禁忌表)。
// 每次迭代,生成所有邻居,跳过禁忌的。
// 选择最佳允许的邻居;更新禁忌表。为何重要
- 避免循环和过早收敛。
- 无需随机跳跃即可逃离局部最小值。
- 适用于许多组合优化问题。
- 通常能高效地找到接近最优的解。
这就像是给爬山法赋予了记忆,让它不会重复犯同样的错误。
一个温和的证明(为何有效)
禁忌搜索通过对近期状态实施短期禁止来保持探索区域的多样性。这迫使搜索轨迹在解图中向外移动。渴望准则确保即使一个禁忌移动能带来全局改进,它也可以被接受,从而确保向强候选解的收敛。
随着时间的推移,该算法近似实现了强化(在良好区域附近进行局部搜索)和多样化(探索新区域)之间的平衡。
亲自尝试
- 为八皇后问题实现一个禁忌搜索。
- 改变禁忌表长度,短表会导致循环,长表会减慢进展。
- 添加一条渴望规则,如果成本改进了当前最优解,则允许禁忌移动。
- 可视化每次迭代的成本路径。
- 与爬山法和模拟退火法进行比较。
测试用例
| 任务 | 表示方式 | 禁忌记忆 | 备注 |
|---|---|---|---|
| TSP | 路线顺序 | 近期交换 | 经典基准测试 |
| 作业调度 | 作业顺序 | 近期交换 | 工业优化 |
| 图着色 | 节点颜色 | 近期颜色变化 | NP 难问题 |
| 数独 | 网格状态 | 单元格赋值 | 约束满足 |
复杂度
- 时间:每次迭代 $O(n \cdot \vert N(s)\vert )$
- 空间:$O(\vert T\vert )$(禁忌表长度)
- 收敛性:取决于邻域大小和禁忌长度
禁忌搜索是记忆与好奇心之间的巧妙平衡,它记住足够多以避免循环,但又遗忘得恰到好处以保持探索。
954. 粒子群优化算法 (PSO)
粒子群优化算法 (PSO) 是一种基于群体的元启发式算法,其灵感来源于鸟群或鱼群如何共同向食物源移动。每个“粒子”代表一个潜在的解决方案,它在其自身经验和群体中最佳表现者的引导下在搜索空间中移动。
我们要解决什么问题?
我们经常面临以下优化问题:
- 目标函数是非线性或不可微的
- 搜索空间有许多局部最优解
- 梯度信息不可用
PSO 提供了一种无需导数、并行且自适应的方式来高效探索此类空间。它广泛应用于控制、参数调优、神经网络训练和设计优化等领域。
核心思想
每个粒子 $i$ 维护:
- 位置 $x_i$,当前解
- 速度 $v_i$,方向和步长
- 个体最优 $p_i$,迄今为止找到的最佳位置
- 全局最优 $g$,所有粒子中的最佳位置
在每次迭代中:
- 更新速度(惯性 + 向最优位置的吸引力)
- 更新位置
- 评估新位置,如果有所改进则更新 $p_i$ 和 $g$
数学公式
对于迭代 $t$ 时的粒子 $i$:
速度更新: $$ v_i(t+1) = \omega v_i(t) + c_1 r_1 (p_i - x_i(t)) + c_2 r_2 (g - x_i(t)) $$
位置更新: $$ x_i(t+1) = x_i(t) + v_i(t+1) $$
其中
- $\omega$ 是惯性权重(控制探索)
- $c_1, c_2$ 是加速系数(自我影响和社会影响)
- $r_1, r_2$ 是 $[0,1]$ 区间内的随机数
惯性权重和吸引力之间的平衡决定了群体如何探索和收敛。
工作原理(通俗解释)
想象一群鸟在一片区域寻找食物。每只鸟都记得它找到过的最佳地点,同时也关注着任何一只鸟找到的最佳地点。它们都会调整自己的飞行方向,略微偏向它们之前找到食物的方向,也略微偏向飞向最佳地点的鸟的方向。随着时间的推移,鸟群自然会聚集在最优区域周围。
分步示例
目标:最小化 $f(x) = x^2 + 3\sin(x)$
| 粒子 | 初始 $x$ | 最佳 $p_i$ | 全局 $g$ | 更新规则 |
|---|---|---|---|---|
| 1 | 2.5 | 2.5 | 1.7 | 向左移动 |
| 2 | -1.0 | -1.0 | 1.7 | 向右移动 |
| 3 | 3.1 | 3.1 | 1.7 | 向左移动 |
| … | … | … | … | … |
最终,所有粒子都收敛到 $x = 1.7$ 附近(全局最小值)。
微型代码
Python
import random
import math
def f(x): return x**2 + 3 * math.sin(x) # 定义目标函数
num_particles = 10 # 粒子数量
x = [random.uniform(-5, 5) for _ in range(num_particles)] # 初始化位置
v = [0 for _ in range(num_particles)] # 初始化速度
p = x[:] # 初始化个体最优位置
g = min(p, key=f) # 初始化全局最优位置
w, c1, c2 = 0.7, 1.5, 1.5 # 参数设置
for t in range(100): # 迭代
for i in range(num_particles):
r1, r2 = random.random(), random.random()
v[i] = w*v[i] + c1*r1*(p[i]-x[i]) + c2*r2*(g-x[i]) # 更新速度
x[i] += v[i] # 更新位置
if f(x[i]) < f(p[i]): # 更新个体最优
p[i] = x[i]
if f(x[i]) < f(g): # 更新全局最优
g = x[i]
print("最佳解:", g, "f(x):", f(g))C (草图)
// 为 N 个粒子初始化数组 x[], v[], p[]。
// 循环:更新 v[i], x[i],评估 f(x[i]),
// 跟踪全局最优 g 和个体最优 p[i]。为何重要
- 无需梯度,非常适合黑盒问题。
- 快速、可并行化且鲁棒性强。
- 自然地平衡了探索(通过惯性)和利用(通过社会学习)。
- 在连续和组合空间中均有效。
PSO 已成为工程、人工智能和科学建模中参数优化的首选算法。
温和的证明(为何有效)
群体的行为源于速度、位置和最佳记忆之间的反馈循环。当惯性较大($\omega$ 大)时,粒子进行广泛探索。当 $\omega$ 较小时,对 $p_i$ 和 $g$ 的吸引力占主导地位,导致收敛。
稳定性分析表明,在合适的 $\omega, c_1, c_2$ 值下(通常 $\omega \in [0.4,0.9]$, $c_1=c_2=2$),系统会以概率方式收敛到最优解附近的平衡点。
亲自尝试
- 为 $f(x)=x^2$ 绘制粒子轨迹。
- 调整 $\omega$:
- 太高 → 振荡
- 太低 → 过早收敛
- 在同一函数上比较 PSO 与模拟退火算法。
- 扩展到二维函数,如 Rastrigin 或 Rosenbrock 函数。
- 可视化观察群体动态。
测试用例
| 任务 | 维度 | 目标函数 | 行为 |
|---|---|---|---|
| Sphere 函数 | 1–10D | $x^2$ | 快速收敛 |
| Rastrigin 函数 | 2D | 多模态 | 良好的全局搜索能力 |
| 神经网络调优 | 高维 | 验证误差 | 平滑探索 |
| PID 控制器调优 | 3D | 控制误差 | 稳定收敛 |
复杂度
- 时间:$O(N_p \cdot T)$
- $N_p$:粒子数量
- $T$:迭代次数
- 空间:$O(N_p)$
- 收敛性:取决于参数平衡和噪声水平
粒子群优化是没有基因的进化,是搜索者相互学习、最终围绕真理聚集的编排舞蹈。
955. 蚁群优化算法 (ACO)
蚁群优化算法 (ACO) 是一种元启发式算法,其灵感来源于真实蚂蚁如何利用信息素路径找到通往食物源的最短路径。 它将集体行为转化为一种计算方法,用于解决困难的组合优化问题,如路径规划、调度和网络优化。
我们要解决什么问题?
许多组合优化问题,例如旅行商问题 (TSP) 或网络路由,其搜索空间太大,无法进行穷举搜索。 我们需要能够高效探索并共享有关有希望路径信息的算法。
蚁群优化提供了一种分布式概率搜索,通过强化学习逐步增强好的解决方案。
核心思想
蚂蚁探索可能的解决方案(路径),并通过释放信息素进行间接通信,信息素代表了每个选择的吸引力。 随着时间的推移:
- 更短或更好的路径会积累更多的信息素
- 信息素会蒸发,从而降低对不良路径的吸引力
- 整个蚁群会收敛于高质量的解决方案
数学表述
每只蚂蚁根据以下因素决定的概率逐步构建路径:
- 边 $(i, j)$ 上的信息素强度 $\tau_{ij}$
- 启发式吸引力 $\eta_{ij}$(例如,距离的倒数)
一只蚂蚁从 $i$ 移动到 $j$ 的概率是:
$$ P_{ij} = \frac{\tau_{ij}^\alpha \eta_{ij}^\beta} {\sum_{k \in \text{allowed}} \tau_{ik}^\alpha \eta_{ik}^\beta} $$
其中:
- $\alpha$ 控制信息素的影响力
- $\beta$ 控制启发式信息的影响力
在所有蚂蚁构建完解决方案后,信息素会更新:
$$ \tau_{ij} \leftarrow (1 - \rho) \tau_{ij} + \sum_k \Delta \tau_{ij}^k $$
其中 $\rho$ 是蒸发率,并且 $$ \Delta \tau_{ij}^k = \begin{cases} \frac{Q}{L_k}, & \text{如果蚂蚁 } k \text{ 使用了边 } (i,j),\ 0, & \text{否则。} \end{cases} $$
其中 $L_k$ 是蚂蚁 $k$ 路径的总长度(成本)。
工作原理(通俗解释)
想象一群蚂蚁在寻找食物。 最初,它们随机游走,留下信息素路径。 那些碰巧找到返回蚁巢短路径的蚂蚁会用更多的信息素加强这条路径。 后来的蚂蚁倾向于选择更强的路径,使得短路径更有可能被重复使用。 蒸发机制确保探索不会过早停止。
随着时间的推移,蚁群集体“发现”了最佳路线。
逐步示例(旅行商问题)
| 步骤 | 阶段 | 描述 |
|---|---|---|
| 1 | 初始化 | 在所有边上设置信息素 $\tau_{ij} = \tau_0$ |
| 2 | 构建 | 每只蚂蚁以概率方式构建一个完整的回路 |
| 3 | 评估 | 计算每个回路的长度 $L_k$ |
| 4 | 更新 | 释放与 $1/L_k$ 成比例的信息素;蒸发 $\rho$ |
| 5 | 重复 | 直到收敛或达到最大迭代次数 |
蚂蚁通过信息素反馈间接协作,无需直接通信。
微型代码
Python(简化版 TSP)
import math, random
def distance(a, b): return math.hypot(a[0]-b[0], a[1]-b[1])
cities = [(0,0), (1,5), (5,2), (6,6)]
n = len(cities)
pheromone = [[1 for _ in range(n)] for _ in range(n)]
alpha, beta, rho, Q = 1.0, 3.0, 0.5, 100
def tour_length(tour):
return sum(distance(cities[tour[i]], cities[tour[(i+1)%n]]) for i in range(n))
def choose_next(current, visited):
probs = []
for j in range(n):
if j not in visited:
tau = pheromone[current][j]alpha
eta = (1 / (distance(cities[current], cities[j]) + 1e-9))beta
probs.append((j, tau * eta))
total = sum(p for _, p in probs)
r = random.random() * total
s = 0
for j, p in probs:
s += p
if s >= r:
return j
for iteration in range(100):
tours = []
for _ in range(5): # 蚂蚁
tour = [random.randint(0, n-1)]
while len(tour) < n:
next_city = choose_next(tour[-1], tour)
tour.append(next_city)
tours.append(tour)
# 蒸发
for i in range(n):
for j in range(n):
pheromone[i][j] *= (1 - rho)
# 释放
for tour in tours:
L = tour_length(tour)
for i in range(n):
a, b = tour[i], tour[(i+1)%n]
pheromone[a][b] += Q / L为何重要
- 对于 NP 难问题(TSP、VRP、调度)非常有效。
- 能适应动态环境(例如,变化的网络)。
- 本质上是分布式和并行的。
- 结合了利用(信息素)和探索(随机选择)。
ACO 是优化和物流领域最成功的群体智能算法之一。
一个温和的证明(为何有效)
蚂蚁集体近似于一个强化学习过程。 每次信息素更新就像一个加权奖励信号:
$$ \tau_{ij}(t+1) = (1-\rho)\tau_{ij}(t) + \mathbb{E}\left[\frac{Q}{L}\right] $$
蒸发确保了对不良路径的遗忘; 强化则加强了好的路径。 蚁群渐进地将信息素集中在最优(或接近最优)的路径上。
亲自尝试
- 为 10 个城市的 TSP 实现 ACO。
- 绘制每次迭代后的信息素强度。
- 调整 $\alpha$、$\beta$ 和 $\rho$:
- 高 $\alpha$ → 利用占主导。
- 高 $\beta$ → 贪婪启发式偏好。
- 低 $\rho$ → 信息素饱和。
- 比较不同参数下的收敛速度。
- 应用于网络路由或作业排序。
测试用例
| 问题 | 搜索空间 | 目标 | 备注 |
|---|---|---|---|
| TSP | 城市和边 | 最短回路 | 经典基准测试 |
| 作业调度 | 任务 | 最小化完工时间 | 工业规划 |
| 图着色 | 节点 | 更少的颜色 | 约束满足 |
| 网络路由 | 路径 | 最小化延迟 | 动态版本 |
复杂度
- 时间:每次迭代 $O(m \cdot n^2)$
- $m$:蚂蚁数量
- $n$:节点数量
- 空间:$O(n^2)$(信息素矩阵)
- 收敛性:受蒸发率 $\rho$ 的强烈影响
蚁群优化展示了 具有局部规则的简单智能体 如何能够创造出智能的、涌现的全局行为, 这是自然界最精妙的优化,在代码中得以重新构想。
956. 差分进化算法 (DE)
差分进化算法 (DE) 是一种基于种群的优化算法,专为连续值函数设计。 它简单、鲁棒且非常有效,是遗传算法的一个极简主义"表亲",用一个优雅的操作——向量差分变异——取代了复杂的交叉规则。
我们要解决什么问题?
DE 用于解决连续优化问题,其中:
- 函数是非线性或多峰的
- 导数不可用或不可靠
- 我们需要在探索和精度之间取得平衡
对于梯度难以计算的工程设计、神经调优和仿真校准任务,它尤其有效。
核心思想
DE 种群由候选解(向量)组成,这些解通过组合其他个体来创建新的试验向量,从而在代际间进化。
每一代都涉及三个关键步骤:
- 变异:通过将两个种群向量之间的缩放差值加到第三个向量上,创建一个试验向量。
- 交叉:将试验向量的分量与当前向量混合。
- 选择:根据适应度保留更好的向量。
这个简单的算术过程无需导数即可高效地探索空间。
数学表述
令:
- $x_i^{(t)}$ 为第 $t$ 代的第 $i$ 个个体
- $f(x)$ 为要最小化的目标函数
那么对于每个 $x_i$:
变异:
$$ v_i = x_{r_1} + F \cdot (x_{r_2} - x_{r_3}) $$
其中 $r_1, r_2, r_3$ 是互不相同的随机索引,$F \in [0, 2]$ 是控制变异强度的缩放因子。
交叉:
对于每个维度 $j$:
$$ u_{i,j} = \begin{cases} v_{i,j}, & \text{if } r_j < C_r \text{ or } j = j_{\text{rand}},\ x_{i,j}, & \text{otherwise.} \end{cases} $$
其中 $C_r$ 是交叉率。
选择:
$$ x_i^{(t+1)} = \begin{cases} u_i, & \text{if } f(u_i) < f(x_i^{(t)}),\ x_i^{(t)}, & \text{otherwise.} \end{cases} $$
工作原理(通俗解释)
想象一群探险者散布在山脉(搜索空间)中。 每个探险者通过组合其他三个探险者的位置来尝试新的方向,实际上是沿着它们之间的向量前进。 如果一个新位置更好(海拔更低),它就取代旧的位置。 随着时间的推移,整个群体集体向山下漂移,趋向全局最小值。
分步示例
目标:最小化 $f(x, y) = x^2 + y^2$
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化种群 | 随机 2D 点 |
| 2 | 变异 | $v_i = x_{r_1} + F(x_{r_2} - x_{r_3})$ |
| 3 | 交叉 | 混合 $v_i$ 与 $x_i$ |
| 4 | 选择 | 保留 $f(x)$ 更小的那个 |
| 5 | 重复 | 直到收敛 |
经过几代之后,所有个体都收敛到最优点 $(0, 0)$ 附近。
微型代码
Python
import random
def f(x, y): return x**2 + y**2
F, CR, NP, D = 0.8, 0.9, 10, 2
pop = [[random.uniform(-5, 5) for _ in range(D)] for _ in range(NP)]
for gen in range(100):
for i in range(NP):
r1, r2, r3 = random.sample(range(NP), 3)
v = [pop[r1][j] + F * (pop[r2][j] - pop[r3][j]) for j in range(D)]
u = [v[j] if random.random() < CR else pop[i][j] for j in range(D)]
if f(*u) < f(*pop[i]):
pop[i] = u
best = min(pop, key=lambda p: f(*p))
print("最佳解:", best, "f(x):", f(*best))C (概略)
// 初始化 D 维向量种群。
// 对于每个个体:
// 选择互不相同的 r1, r2, r3。
// 计算变异向量 v = x[r1] + F*(x[r2]-x[r3])。
// 执行交叉,评估试验向量 u。
// 如果 f(u) < f(x[i]),则替换 x[i]。为何重要
- 参数少,只有 $F$ 和 $C_r$。
- 无需导数,适用于黑盒目标函数。
- 能处理噪声和不连续函数。
- 在许多连续基准测试上具有出色的全局收敛性。
- 简单而强大,通常优于更复杂的方法。
差分进化算法体现了"少即是多"的原则,规则极简,性能极佳。
一个温和的证明(为何有效)
变异规则确保了有导向的多样性: 随机个体之间的差异引导探索走向新的、可能更好的区域。 这保持了种群多样性并防止过早收敛。
在标准假设下,只要变异和选择在种群中保留了足够的方差,DE 就能随机收敛到全局最小值。
亲自尝试
- 优化 $f(x, y) = (x - 3)^2 + (y + 2)^2$。
- 调整 $F$ 和 $C_r$,尝试 $F=0.5, 0.9, 1.2$。
- 跟踪不同种群大小下的收敛速度。
- 在相同函数上与粒子群优化算法进行比较。
- 应用 DE 直接训练神经网络的权重。
测试用例
| 问题 | 领域 | 备注 |
|---|---|---|
| Sphere | 连续 | 光滑凸函数 |
| Rastrigin | 多峰 | 测试全局搜索能力 |
| Ackley | 非线性 | 陡峭梯度 |
| 工程设计 | 实值 | 实际用例 |
复杂度
时间:$O(NP \cdot D \cdot G)$
- $NP$:种群大小
- $D$:维度
- $G$:代数
空间:$O(NP \cdot D)$
收敛性:对于小参数集,通常快速且稳定
差分进化算法是优化算法的本质体现, 一种安静但持续的空间漂移,仅由差异和选择驱动。
957. 和声搜索
和声搜索(HS)是一种受音乐家即兴创作和声过程启发的元启发式优化算法。 正如音乐家调整音高以获得悦耳的声音,该算法调整解变量以最小化(或最大化)目标函数。
它简单、灵活,对于连续和离散优化都有效,尤其是在搜索空间不规则或存在噪声时。
我们正在解决什么问题?
许多优化问题就像音乐作品:它们涉及平衡多个变量(或音符)以获得最佳结果。 传统算法可能会陷入困境或需要梯度信息,但和声搜索通过创造性和变化来探索空间,无需导数。
它广泛应用于:
- 工程设计优化
- 调度和分配问题
- 神经网络参数调优
- 结构设计
核心思想
和声搜索基于一个和声记忆库(HM),即解向量(和声)的集合。 新解通过从记忆库中即兴创作生成,使用三条规则:
- 记忆考量,从现有和声中选取一个值。
- 音高调整,对该值进行微调。
- 随机选择,引入全新的值以增加多样性。
最好的和声被保留,替换最差的和声,迭代地改进"作品"。
数学表述
设优化问题为:
$$ \min f(x_1, x_2, \dots, x_N) $$
其中每个变量 $x_i$ 的范围为 $[L_i, U_i]$。
在每次迭代中:
初始化和谐记忆库(HM): $$ HM = {x^{(1)}, x^{(2)}, \dots, x^{(HMS)}} $$ 其中 HMS = 和声记忆库大小。
即兴创作一个新和声 $x' = (x'_1, x'_2, \dots, x'_N)$: 对于每个变量 $x_i$:
- 以概率 HMCR(和声记忆考量率),从 $HM$ 中选择 $x_i$。
- 否则,从 $[L_i, U_i]$ 中随机选择。
- 以概率 PAR(音高调整率),对其进行微调: $$ x'_i \leftarrow x'_i + \delta, \quad \delta \in [-bw, bw] $$ 其中 $bw$ 是带宽(调优参数)。
评估 $f(x')$。
更新 HM,如果 $x'$ 优于最差的和声,则替换它。
重复直到满足停止准则(迭代次数或收敛)。
工作原理(通俗解释)
想象一个爵士乐队在即兴演奏。 每位乐手(变量)倾听他人,并从过去的和声中选取一个音符(值)或发明一个新的音符。 偶尔,他们会微调自己的音高(微调参数)。 随着他们演奏,整体声音(目标函数)得到改善,逐渐趋向和谐,即最优解。
逐步示例
目标:最小化 $f(x, y) = x^2 + y^2$
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 初始化 HM | 3 个随机对 $(x, y)$ |
| 2 | 即兴创作 | 从 HM 中选取 $x, y$ 或随机生成 |
| 3 | 调整音高 | 微调 $x, y$ |
| 4 | 评估 | 计算 $f(x, y)$ |
| 5 | 更新 HM | 保留前 3 个和声 |
经过几次迭代后,HM 收敛到全局最优解 $(0, 0)$ 附近。
微型代码
Python
import random
def f(x, y): return x2 + y2
HMS, HMCR, PAR, bw = 3, 0.9, 0.3, 0.1
HM = [[random.uniform(-5, 5), random.uniform(-5, 5)] for _ in range(HMS)]
for _ in range(1000):
new = []
for i in range(2):
if random.random() < HMCR:
new.append(random.choice(HM)[i])
if random.random() < PAR:
new[i] += random.uniform(-bw, bw)
else:
new.append(random.uniform(-5, 5))
if f(*new) < max(HM, key=lambda h: f(*h), default=new):
HM[-1] = new
HM.sort(key=lambda h: f(*h))
best = HM[0]
print("最佳:", best, "f(x):", f(*best))C(草图)
// 将和声记忆库存储为二维数组。
// 使用 HMCR、PAR 和 bw 参数生成新和声。
// 如果新和声更好,则替换最差的和声。为什么它很重要
- 无导数优化。
- 在记忆和创造性之间取得平衡。
- 参数少:HMCR、PAR 和 bw。
- 对复杂、非线性、多模态问题有效。
- 天然支持多目标扩展。
和声搜索抓住了探索、精炼和记忆的本质,就像真正的音乐家完善旋律一样。
温和的证明(为什么有效)
和声搜索通过强化(使用记忆)和多样化(随机变异)的随机组合来工作。 随着时间的推移,HM 向最优解收敛源于概率采样,高适应度的和声在群体中占据主导地位。
通过较高的 HMCR 和适中的 PAR,算法在重用和创新之间保持平衡,在精炼解的同时防止停滞。
自己动手试试
- 使用 HS 优化 $f(x, y) = x^2 + y^2 + 3\sin(2x)$。
- 改变 HMCR(0.5–0.95)和 PAR(0.1–0.5),观察探索与利用。
- 对于更高维度的问题,使用更大的 HM。
- 与遗传算法和差分进化进行比较。
- 应用于参数调优(例如,机器学习超参数)。
测试用例
| 问题 | 领域 | 备注 |
|---|---|---|
| Sphere | 连续 | 简单基准测试 |
| Rastrigin | 非线性 | 多模态曲面 |
| Scheduling | 离散 | 组合使用 |
| Neural tuning | 连续 | 评估次数少,效率高 |
复杂度
时间:$O(HMS \times N \times T)$
- $HMS$: 和声记忆库大小
- $N$: 变量数量
- $T$: 迭代次数
空间:$O(HMS \times N)$
和声搜索将优化变成了一种艺术形式, 解"协同演奏",逐渐在可能性的图景中找到最和谐的和弦。
958. 萤火虫算法
萤火虫算法(FA)是一种受萤火虫闪烁模式启发的群体智能方法。 每只萤火虫代表一个候选解,它会向更亮(更好)的萤火虫移动,移动强度由光吸收和随机扰动控制。
它简单、并行,并且对于连续、非线性和多模态优化问题非常强大。
我们要解决什么问题?
许多优化地形具有多个局部极小值。 经典的基于梯度的算法很容易陷入其中一个。 萤火虫算法引入了集体吸引力和受控随机性,在保留局部精细搜索的同时进行全局搜索。
它在以下方面特别有效:
- 工程优化
- 图像处理和特征选择
- 机器学习参数调优
- 基准函数优化
核心思想
每只萤火虫的亮度与其适应度(解的质量)成正比。 较暗的萤火虫会向较亮的萤火虫移动,并且亮度随距离增加而减弱。 当没有更亮的邻居时,随机运动确保了探索。
数学表述
对于位置在 $x_i$ 和 $x_j$ 的萤火虫 $i$ 和 $j$,它们的移动由以下公式控制:
吸引力函数: $$ \beta(r) = \beta_0 e^{-\gamma r^2} $$ 其中
- $\beta_0$ 是最大吸引力,
- $\gamma$ 是光吸收系数,
- $r = \vert x_i - x_j\vert $ 是距离。
移动规则: $$ x_i \leftarrow x_i + \beta_0 e^{-\gamma r^2} (x_j - x_i) + \alpha (\text{rand} - 0.5) $$
其中:
- 第二项使 $i$ 向更亮的萤火虫 $j$ 移动,
- 第三项添加了由 $\alpha$ 控制的随机噪声。
工作原理(通俗解释)
想象黑暗田野上的萤火虫。 每只萤火虫根据其位置的"好坏"程度发光。 较暗的萤火虫飞向较亮的萤火虫。 它们距离越近,吸引力越强。 随着时间的推移,萤火虫群聚集在最亮的点周围,即最佳解。
随机运动防止它们过早地陷入次优区域。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 初始化随机的萤火虫种群 |
| 2 | 使用目标函数评估亮度 |
| 3 | 对于每对 $(i,j)$,如果 $j$ 更亮,则将 $i$ 移向 $j$ |
| 4 | 添加小的随机运动 |
| 5 | 更新亮度并重复直到收敛 |
示例
目标:最小化 $f(x) = x^2 + 3\sin(x)$
| 迭代次数 | 萤火虫位置 | 最佳位置 | 备注 |
|---|---|---|---|
| 1 | 随机分散 | 1.7 | 最亮($f$ 值最低) |
| 5 | 聚集在 1.8 附近 | 稳定区域 | 随机跳跃较少 |
| 10 | 全部接近 1.7 | 收敛到最优解 |
微型代码
Python
import random, math
def f(x): return x2 + 3 * math.sin(x)
n = 10 # 萤火虫数量
beta0, gamma, alpha = 1.0, 0.5, 0.2
fireflies = [random.uniform(-5, 5) for _ in range(n)]
for _ in range(100):
for i in range(n):
for j in range(n):
if f(fireflies[j]) < f(fireflies[i]):
r = abs(fireflies[i] - fireflies[j])
beta = beta0 * math.exp(-gamma * r2)
fireflies[i] += beta * (fireflies[j] - fireflies[i]) + alpha * (random.random() - 0.5)
best = min(fireflies, key=f)
print("最佳解:", best, "f(x):", f(best))C(示意)
// 初始化位置 x[i]
// 对于每次迭代:
// 对于每对 (i,j):
// 如果 f(x[j]) < f(x[i]): 将 x[i] 移向 x[j]
// 应用随机扰动
// 跟踪最佳解。为什么它重要
- 全局和局部搜索的平衡,由 $\gamma$ 和 $\alpha$ 控制。
- 对于连续问题,简单而鲁棒。
- 处理非凸、不连续或有噪声的目标函数。
- 可适应多目标和离散变体。
- 天然可并行化,每只萤火虫独立行动。
一个温和的证明(为什么它有效)
吸引力随距离呈指数衰减,确保围绕良好解的局部收敛,同时通过随机扰动保持全局多样性。
形式上,每次迭代的预期位移幅度:
$$ E[|x_i^{t+1} - x_i^t|] \le \beta_0 e^{-\gamma r^2} |x_j - x_i| + O(\alpha) $$
随着 $\gamma$ 增加或 $\alpha$ 减少而减小,保证一旦接近极小值就具有稳定性。
亲自尝试
优化 $f(x) = x^2 + 10\sin(5x)$ 并可视化移动过程。
调整参数:
- $\alpha$(随机性)
- $\beta_0$(吸引力)
- $\gamma$(光吸收)
与粒子群优化算法进行比较。
在二维空间中运行并绘制轨迹。
尝试多模态函数,如 Rastrigin 或 Ackley 函数。
测试用例
| 问题 | 定义域 | 备注 |
|---|---|---|
| Sphere | 连续 | 快速收敛 |
| Rosenbrock | 非线性 | 需要适度的随机性 |
| Rastrigin | 多模态 | 全局搜索测试 |
| 工程设计 | 连续 | 实际应用性 |
复杂度
- 时间:每次迭代 $O(n^2)$(成对吸引力计算)
- 空间:$O(n)$
- 收敛性:由 $\alpha$、$\gamma$ 和迭代次数限制控制
萤火虫算法展示了集体吸引力——简单、局部而明亮——如何照亮通往全局优化的道路。
959. 蜂群优化算法
蜂群优化算法(BCO)模拟真实蜜蜂采蜜的行为,通过交流、招募和局部搜索来平衡探索与利用。 每只蜜蜂代表一个潜在的解决方案,探索或改进搜索空间的不同区域。随着时间的推移,整个蜂群会共同汇聚到最有希望的“蜜源”,即最优或接近最优的解。
我们要解决什么问题?
BCO 是一种基于群体的元启发式算法,用于连续和组合优化问题,尤其适用于以下情况:
- 目标函数是非凸的或存在噪声
- 无法获取梯度信息
- 解决方案需要在局部改进和全局发现之间取得平衡
应用领域包括:
- 路径规划和调度
- 特征选择
- 工程设计
- 资源分配
核心思想
一个蜂群由以下成员组成:
- 雇佣蜂:探索已知的食物源(解决方案)
- 观察蜂:根据共享的信息选择食物源
- 侦察蜂:随机探索新的区域
通过交流和移动的循环,蜂群逐渐向最优解改进。
数学公式
设:
- $x_i$ 为蜜蜂 $i$ 的位置(解决方案)
- $f(x_i)$ 为适应度(花蜜质量)
- $N$ 为食物源的数量
每次迭代包括:
雇佣蜂阶段: 为每个 $x_i$ 生成一个邻近解: $$ v_{ij} = x_{ij} + \phi_{ij}(x_{ij} - x_{kj}) $$ 其中 $k \ne i$,且 $\phi_{ij} \in [-1, 1]$ 是一个随机系数。
适应度评估: $$ \text{fit}(x_i) = \frac{1}{1 + f(x_i)} $$
观察蜂阶段: 根据与适应度成比例的概率选择食物源: $$ p_i = \frac{\text{fit}(x_i)}{\sum_{j=1}^{N} \text{fit}(x_j)} $$
侦察蜂阶段: 如果某个解决方案在预设的迭代次数内没有改进,则用随机解替换它: $$ x_i = \text{random}(L, U) $$
记忆最优解。
工作原理(通俗解释)
想象一个蜂群正在寻找花朵。 每只蜜蜂首先探索一个区域(解决方案),并在蜂巢中分享其花蜜量(适应度)。 观察蜂观察并选择跟随成功的蜜蜂前往富饶的花田。 如果某只蜜蜂的蜜源枯竭,它就会变成侦察蜂,重新开始搜索。 随着时间的推移,蜜蜂会聚集在最富饶的花朵周围,即全局最优解。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 初始化随机食物源 |
| 2 | 雇佣蜂探索邻近区域 |
| 3 | 观察蜂选择最佳蜜源 |
| 4 | 侦察蜂搜索新区域 |
| 5 | 记忆最优解并重复 |
示例
目标:最小化 $f(x) = x^2 + 4\sin(x)$
在每次迭代中:
- 蜜蜂围绕当前最佳 $x$ 进行探索
- 观察蜂加强有希望的区域
- 侦察蜂通过引入随机性防止停滞
最终,蜜蜂聚集在全局最小值附近,大约 $x \approx -1.4$。
微型代码
Python
import random, math
def f(x): return x2 + 4 * math.sin(x)
N, limit, max_iter = 10, 10, 100
L, U = -5, 5
bees = [random.uniform(L, U) for _ in range(N)]
trial = [0]*N
for _ in range(max_iter):
for i in range(N):
k = random.choice([j for j in range(N) if j != i])
phi = random.uniform(-1, 1)
v = bees[i] + phi * (bees[i] - bees[k])
if f(v) < f(bees[i]):
bees[i], trial[i] = v, 0
else:
trial[i] += 1
prob = [1/(1+f(b)) for b in bees]
s = sum(prob)
prob = [p/s for p in prob]
for i in range(N):
if random.random() < prob[i]:
k = random.choice([j for j in range(N) if j != i])
phi = random.uniform(-1, 1)
v = bees[i] + phi * (bees[i] - bees[k])
if f(v) < f(bees[i]):
bees[i], trial[i] = v, 0
for i in range(N):
if trial[i] > limit:
bees[i] = random.uniform(L, U)
trial[i] = 0
best = min(bees, key=f)
print("Best:", best, "f(x):", f(best))为何重要
- 结合了探索(侦察蜂)和利用(雇佣蜂)。
- 天然可并行化。
- 参数需求少,仅需蜂群规模和侦察限制。
- 在噪声、高维和多模态空间中表现良好。
- 通过蜜蜂间的反馈动态适应。
一个温和的证明(为何有效)
在每个循环中,由于按比例招募,选择更高适应度解的概率会增加。 侦察蜂替换停滞的蜜源确保了持续的多样性。 在持续探索的前提下,通过重复采样,算法在概率上保证会向全局最优解收敛。
亲自尝试
- 使用 20 只蜜蜂最小化 $f(x, y) = x^2 + y^2$。
- 调整蜂群规模 $N$ 和侦察限制。
- 与粒子群优化和萤火虫算法比较收敛性。
- 可视化迭代过程中的群体行为。
- 尝试针对旅行商问题的离散化适应。
测试用例
| 问题 | 领域 | 备注 |
|---|---|---|
| Sphere | 连续 | 简单基准测试 |
| Ackley | 非线性 | 测试探索能力 |
| Scheduling | 离散 | 适用于 BCO 变体 |
| Neural tuning | 连续 | 可并行化 |
复杂度
- 时间:$O(N \times \text{iterations})$
- 空间:$O(N)$
- 收敛性:随机的,但对于大规模群体是稳定的
蜂群优化算法捕捉了合作的力量, 一群简单的智能体,每个个体的洞察力有限,却通过共享发现找到了秩序和智慧。
960. 爬山算法
爬山算法是最简单的优化算法之一。 它模仿登山者如何通过始终向更高处(更好的解决方案)迈步来攀登山峰。 尽管简单,但它是许多局部搜索和元启发式算法的基础。
我们要解决什么问题?
爬山算法用于无梯度优化,当我们能够评估解决方案的质量但无法计算其导数时。 它特别适用于:
- 离散或组合优化
- 启发式搜索问题
- 特征选择
- 参数调优
然而,它可能会陷入局部最优解,即那些在局部看起来最好但并非全局最优的点。
核心思想
在每一步,爬山算法评估相邻的解决方案,并移动到改进最大的那个。 如果没有相邻解更好,它就停止,假设已经到达了一个峰值(局部最优解)。
数学形式化
令 $f(x)$ 为我们想要最大化的适应度或目标函数。 我们从初始解 $x_0$ 开始迭代:
- 在 $x_t$ 附近生成一个邻居 $x'$。
- 如果 $f(x') > f(x_t)$,则移动到那里: $$ x_{t+1} = x' $$ 否则停止。
- 重复直到找不到改进。
形式化地:
$$ x_{t+1} = \begin{cases} x', & \text{如果 } f(x') > f(x_t),\ x_t, & \text{否则。} \end{cases} $$
工作原理(通俗解释)
想象你站在一个有雾的山上。 你看不远,但能感觉到坡度。 你向上坡迈出小步,始终朝着海拔增加的方向。 最终,当附近所有方向都是下坡时,你会停下来,此时你到达了一个峰顶(尽管可能不是最高的那个)。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 从一个随机解开始 |
| 2 | 生成一个相邻解 |
| 3 | 如果相邻解更好,则移动到那里 |
| 4 | 重复直到没有更好的相邻解存在 |
变体方法会增加随机性或从新位置重新启动,以逃离局部最优解。
示例
在 $x \in [0, 5]$ 上最大化 $f(x) = -x^2 + 5x$
从 $x = 1$ 开始 邻居步长:$x' = x + 0.1$
计算: $$ f(x=1) = 4, \quad f(x'=1.1) = 4.39 $$ 向上坡移动。 继续直到斜率变为负值,在 $x = 2.5$ 附近,$f(x)$ 达到峰值。
微型代码
Python
import random
def f(x): return -x2 + 5*x
x = random.uniform(0, 5)
step = 0.1
for _ in range(100):
x_new = x + random.choice([-step, step])
if 0 <= x_new <= 5 and f(x_new) > f(x):
x = x_new
print("最佳 x:", x, "f(x):", f(x))C (概略)
// 从 x = 随机值 开始
// 循环:生成邻居 x_new
// 如果 f(x_new) > f(x),则移动到 x_new
// 当找不到更好的邻居时停止为何重要
是局部搜索和随机优化的基础。
在梯度未知或不可微时有效。
构成了高级方法的基础:
- 模拟退火(增加了基于温度的随机性)
- 禁忌搜索(增加了记忆)
- 遗传算法(增加了基于种群的探索)
一个温和的证明(为何有效)
如果搜索空间是有限的,并且每一步都改进了 $f(x)$,那么算法必须在局部最优解处终止,因为状态数量是有限的。 收敛性是有保证的,但不一定是全局最优解。
形式化地,由于 $f(x_{t+1}) > f(x_t)$ 且 $f$ 有上界, $$ \lim_{t \to \infty} (f(x_{t+1}) - f(x_t)) = 0 $$ 因此爬山算法会达到一个稳定点。
亲自尝试
- 使用爬山算法在 $[0, 2\pi]$ 上最大化 $f(x) = \sin(x)$。
- 添加随机重启以逃离局部最大值。
- 尝试随机爬山算法,有时接受更差的解。
- 与模拟退火进行比较。
- 在像 $f(x, y) = \sin(x)\cos(y)$ 这样的二维曲面上可视化步骤。
测试用例
| 函数 | 定义域 | 行为 |
|---|---|---|
| $-x^2 + 5x$ | 连续 | 平滑单峰 |
| $\sin(x)$ | 多模态 | 需要随机重启 |
| Rastrigin | 多模态 | 局部最优测试 |
| Traveling Salesman | 离散 | 邻域交换启发式 |
复杂度
- 时间:$O(T)$ (T = 迭代次数)
- 空间:$O(1)$
- 收敛性:确定性的,局部的
爬山算法是最纯粹的搜索形式, 一步一步,始终向上,直到没有更高的地方。 从这个朴素的方法开始,现代元启发式算法的广阔天地就此展开。
第 97 节. 强化学习
961. 蒙特卡洛控制
蒙特卡洛控制是强化学习中的一个核心算法,它通过采样完整的回合并对观察到的回报进行平均来估计最优策略。它从经验中学习,不需要环境模型,通过在许多回合中重复试错来实现。
我们要解决什么问题?
我们希望在不知道底层转移概率或奖励模型的情况下,学习如何在环境中行动以最大化期望累积奖励。
给定:
- 状态集合 $S$
- 动作集合 $A$
- 奖励函数 $R(s, a)$(未知)
- 折扣因子 $\gamma$
蒙特卡洛控制学习:
- 动作价值函数 $Q(s,a)$,即在状态 $s$ 采取动作 $a$ 并遵循策略 $\pi$ 后的期望回报
- 最优策略 $\pi^*(s) = \arg\max_a Q(s, a)$
核心思想
蒙特卡洛方法通过运行完整的回合并对每个 $(s,a)$ 对之后的回报进行平均来估计 $Q(s,a)$。然后,根据估计的 $Q$ 贪婪地改进策略。评估和改进的反复交替会收敛到最优策略。
数学表述
回报定义 $$ G_t = R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \dots $$
动作价值更新 $$ Q(s,a) \leftarrow Q(s,a) + \alpha [G_t - Q(s,a)] $$ 其中 $G_t$ 是 $(s,a)$ 首次出现后的回报。
策略改进 $$ \pi(s) = \arg\max_a Q(s,a) $$
探索控制(ε-贪婪):
$$ \pi(a|s) = \begin{cases} 1 - \varepsilon + \dfrac{\varepsilon}{|A(s)|}, & \text{if } a = \arg\max_a Q(s,a),\ \dfrac{\varepsilon}{|A(s)|}, & \text{otherwise.} \end{cases} $$
工作原理(通俗解释)
想象一个玩家通过反复玩一个游戏(如二十一点)来学习:
- 每一整局游戏(回合)以一个分数(总奖励)结束。
- 玩家记录哪些操作导致了哪些结果。
- 随着时间的推移,每个操作的平均结果形成了对其真实价值的可靠估计。
- 玩家逐渐转向能带来更高平均回报的操作。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 任意初始化 $Q(s,a)$ |
| 2 | 初始化一个策略 $\pi$(例如,随机或 ε-贪婪) |
| 3 | 遵循 $\pi$ 生成一个回合 |
| 4 | 计算所有访问过的 $(s,a)$ 的回报 $G_t$ |
| 5 | 通过平均回报来更新 $Q(s,a)$ |
| 6 | 根据更新后的 $Q$ 贪婪地改进 $\pi$ |
| 7 | 重复许多回合 |
示例
假设一个智能体玩二十一点。每个回合,它记录状态-动作对的序列和最终结果(+1 赢,-1 输,0 平局)。经过许多回合,它估计每个 $(s,a)$ 的价值,例如“在 15 点时要牌”或“在 18 点时停牌”,并相应地调整其策略。
微型代码
Python
import random
# 环境(示例):简化的二十一点
actions = ["hit", "stand"]
Q = {}
returns = {}
def policy(s, eps=0.1):
if random.random() < eps:
return random.choice(actions)
return max(actions, key=lambda a: Q.get((s, a), 0))
for _ in range(10000):
episode = []
state = random.randint(4, 21)
while True:
a = policy(state)
reward = random.choice([-1, 0, 1]) # 环境响应的占位符
episode.append((state, a, reward))
if random.random() < 0.3: # 终止
break
state = min(21, state + random.choice([-2, 1, 2]))
G = 0
for (s, a, r) in reversed(episode):
G = r + 0.9 * G
if not any(x[0] == s and x[1] == a for x in episode[:-1]):
returns.setdefault((s,a), []).append(G)
Q[(s,a)] = sum(returns[(s,a)]) / len(returns[(s,a)])重要性
- 无模型:直接从经验中学习,不需要转移概率。
- 简单而强大:是许多强化学习方法(如 SARSA、Q-learning 和 Actor-Critic)的基础。
- 利于探索:ε-贪婪确保所有动作都被尝试足够多次。
- 理论保证:在无限探索下,$Q \to Q^*$。
温和的证明(为什么有效)
根据大数定律,$(s,a)$ 的回报的经验平均值收敛到其在策略 $\pi$ 下的期望值: $$ E[G_t | S_t = s, A_t = a] = q_\pi(s,a) $$ 连续的策略改进确保: $$ q_{\pi_{k+1}}(s,a) \ge q_{\pi_k}(s,a) $$ 因此,评估和改进的反复交替收敛到 $\pi^*$。
亲自尝试
- 为网格世界或二十一点实现蒙特卡洛控制。
- 比较首次访问与每次访问蒙特卡洛更新。
- 可视化 $Q(s,a)$ 如何随着回合数趋于稳定。
- 尝试不同的 $\varepsilon$ 进行探索。
- 尝试衰减 $\varepsilon_t$ 以进行后期利用。
测试用例
| 环境 | 描述 | 奖励信号 |
|---|---|---|
| 二十一点 | 回合式 | +1 赢,-1 输 |
| 网格世界 | 有限时域 | 步进成本和目标奖励 |
| 井字棋 | 离散动作 | +1 赢,-1 输,0 平局 |
| 迷宫导航 | 连续 | 稀疏的终止奖励 |
复杂度
- 时间:每回合 $O(N \times T)$(N 个状态,每回合 T 步)
- 空间:$O(\vert S\vert \vert A\vert )$
- 收敛性:在 $\varepsilon$-贪婪策略下,通过无限采样保证收敛
蒙特卡洛控制体现了从经验中学习的精神,无需模型,无需运动方程,只需反复尝试和仔细平均,直到知识浮现。
962. 时序差分学习
时序差分学习是一种强化学习算法,它融合了蒙特卡洛方法和动态规划的思想。 它通过使用对未来估计的预测来更新价值估计,从而直接、逐步地从经验中学习,而无需等待完整回合结束。
我们正在解决什么问题?
蒙特卡洛控制需要等到每个回合结束后才更新其价值估计。 这在持续任务中可能很慢或不可行。
时序差分学习通过自举法来解决这个问题,即部分基于现有估计来更新估计:
- 学习状态价值($V(s)$)或动作价值($Q(s,a)$)
- 无需知道环境模型
- 在回合进行中(而非结束后)学习
它被用于:
- 游戏对弈(例如,TD-Gammon)
- 在线预测
- 机器人控制
- 金融预测
核心思想
时序差分学习基于以下两者之间的差异来更新价值函数:
- 一个时间步的预测回报,以及
- 下一个时间步信息更充分的预测。
这个差异被称为时序差分误差。
数学表述
价值更新规则(TD(0)): $$ V(S_t) \leftarrow V(S_t) + \alpha [R_{t+1} + \gamma V(S_{t+1}) - V(S_t)] $$ 其中
- $\alpha$ 是学习率
- $\gamma$ 是折扣因子
项 $$ \delta_t = R_{t+1} + \gamma V(S_{t+1}) - V(S_t) $$ 是 TD 误差,表示下一个观测的意外程度。
同样的思想可以扩展到 Q 值: $$ Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha [R_{t+1} + \gamma Q(S_{t+1}, A_{t+1}) - Q(S_t, A_t)] $$
工作原理(通俗解释)
想象一下你在观看一部电影时预测它的评分。 每看完一个场景,你都会根据电影到目前为止的进展来调整你的预期,而无需等到片尾字幕出现。
这就是时序差分学习:你利用当前的预测作为垫脚石,随着新证据的到来不断优化你的预测。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 任意初始化 $V(s)$ 或 $Q(s,a)$ |
| 2 | 从状态 $S_0$ 开始一个回合 |
| 3 | 选择动作 $A_t$(ε-贪心策略) |
| 4 | 观察奖励 $R_{t+1}$ 和下一个状态 $S_{t+1}$ |
| 5 | 计算 TD 误差 $\delta_t$ |
| 6 | 更新 $V(S_t)$ 或 $Q(S_t, A_t)$ |
| 7 | 对所有时间步重复此过程 |
示例
令 $V(A)=0.5$,$\gamma=0.9$,$\alpha=0.1$ 你从状态 A 移动到 B,获得奖励 $R=1$,且 $V(B)=0.6$。
计算: $$ \delta = R + \gamma V(B) - V(A) = 1 + 0.9 \times 0.6 - 0.5 = 1.04 $$ 更新: $$ V(A) \leftarrow 0.5 + 0.1 \times 1.04 = 0.604 $$
微型代码
Python
import random
V = {s: 0.0 for s in range(5)}
alpha, gamma = 0.1, 0.9
for episode in range(100):
s = random.choice(list(V.keys()))
for _ in range(5):
next_s = random.choice(list(V.keys()))
r = random.uniform(-1, 1)
V[s] += alpha * (r + gamma * V[next_s] - V[s])
s = next_s
print(V)C(示意)
// 对于每一步:
// delta = R + gamma * V[next_s] - V[s];
// V[s] += alpha * delta;重要性
- 在线学习,更新随着经验的展开而进行。
- 高效,无需存储完整回合。
- 自举法,从经验和自身预测中学习。
- 是 SARSA、Q-learning 和 Actor–Critic 等高级算法的基础。
一个温和的证明(为何有效)
根据贝尔曼算子的收缩性质,在固定策略 $\pi$ 下,重复的 TD 更新会收敛到真实的价值函数 $V_\pi(s)$。 每次更新都将 $V(s)$ 移向期望的折扣回报: $$ E[V(S_t)] \to v_\pi(s) $$ 只要每个状态被无限频繁地访问,且 $\alpha_t$ 满足标准条件($\sum \alpha_t = \infty$,$\sum \alpha_t^2 < \infty$)。
亲自尝试
- 为随机游走环境实现 TD(0)。
- 比较不同 $\alpha$ 的学习曲线。
- 可视化 $V(s)$ 如何收敛到真实值。
- 扩展到 TD(λ),多步备份平均。
- 与蒙特卡洛估计进行比较。
测试用例
| 环境 | 描述 | 备注 |
|---|---|---|
| 随机游走 | 经典示例 | 平滑收敛 |
| 网格世界 | 状态转移 | 在线更新可视化 |
| 井字棋 | 预测游戏结果 | 无模型 |
| 机器人路径 | 连续控制 | 实时学习 |
复杂度
- 时间:每个回合 $O(T)$(T = 步数)
- 空间:$O(\vert S\vert )$ 或 $O(\vert S\vert \vert A\vert )$
- 收敛性:在策略内探索下,对于小的 $\alpha$ 保证收敛
时序差分学习展示了自举预测的力量, 它不是通过事后回顾来学习,而是通过边预测、边更新、边改进的方式进行学习。
963. SARSA(同策略时序差分学习)
SARSA(状态-动作-奖励-状态-动作)是一种经典的同策略强化学习算法。 它将时序差分学习扩展到直接估计动作价值 $Q(s,a)$,而不仅仅是状态价值。 其名称来源于每次更新所使用的五个元素:$(S_t, A_t, R_{t+1}, S_{t+1}, A_{t+1})$。
我们要解决什么问题?
我们希望学习能最大化长期奖励的最优策略,但与蒙特卡洛方法不同,我们不需要等到一个回合结束。 与 Q-learning 不同,SARSA 使用当前策略实际采取的动作进行更新,而不是贪婪动作,这使其成为"同策略"算法。
它适用于:
- 在线控制和导航任务
- 机器人和自动驾驶车辆控制
- 任何需要安全或渐进式探索的环境
核心思想
SARSA 使用以下内容更新当前状态-动作对的价值:
- 即时奖励 $R_{t+1}$
- 在当前策略下下一个状态-动作对的预测价值
它在遵循正在评估的策略的同时进行学习,平衡探索(通过 ε-贪婪策略)和利用(选择最佳动作)。
数学公式
TD 更新规则: $$ Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha [R_{t+1} + \gamma Q(S_{t+1}, A_{t+1}) - Q(S_t, A_t)] $$
动作选择(ε-贪婪策略):
$$ A_t = \begin{cases} \arg\max_a Q(S_t, a), & \text{概率为 } 1 - \varepsilon,\ \text{随机动作}, & \text{概率为 } \varepsilon. \end{cases} $$
工作原理(通俗解释)
想象教一个机器人走路。 每次移动都会改变它的位置(状态)并给予反馈(奖励)。 机器人根据实际发生的情况,而不是根据它没有采取的某个假设的"最佳"移动,来更新它对哪个移动是好是坏的理解。 这就是 SARSA:通过遵循自身策略的经验进行学习。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 任意初始化 $Q(s,a)$ |
| 2 | 从初始状态 $S_0$ 开始 |
| 3 | 使用 ε-贪婪策略从 $S_0$ 选择 $A_0$ |
| 4 | 对每一步重复: |
| a. 采取 $A_t$,观察 $R_{t+1}$ 和 $S_{t+1}$ | |
| b. 选择下一个动作 $A_{t+1}$(ε-贪婪策略) | |
| c. 使用 TD 规则更新 $Q(S_t, A_t)$ | |
| d. 设置 $S_t \leftarrow S_{t+1}$, $A_t \leftarrow A_{t+1}$ |
示例
假设 $Q(s,a)$ 初始化为零。 在时间 $t$:
- $S_t = s_1$, $A_t = \text{right}$, 奖励 $R_{t+1} = 1$
- 下一个状态 $S_{t+1} = s_2$, 动作 $A_{t+1} = \text{up}$
- $\alpha = 0.1$, $\gamma = 0.9$
那么: $$ Q(s_1, \text{right}) \leftarrow 0 + 0.1 [1 + 0.9 Q(s_2, \text{up}) - 0] = 0.1 $$
微型代码
Python
import random
Q = {}
states = ["A", "B", "C"]
actions = ["left", "right"]
alpha, gamma, eps = 0.1, 0.9, 0.1
def policy(s):
if random.random() < eps:
return random.choice(actions)
return max(actions, key=lambda a: Q.get((s,a), 0))
for episode in range(1000):
s = random.choice(states)
a = policy(s)
for _ in range(10):
r = random.uniform(-1, 1)
s_next = random.choice(states)
a_next = policy(s_next)
q = Q.get((s,a), 0)
q_next = Q.get((s_next,a_next), 0)
Q[(s,a)] = q + alpha * (r + gamma * q_next - q)
s, a = s_next, a_next为什么它很重要
- 同策略控制,使用与其行为相同的策略进行安全学习。
- 平稳地从探索过渡到利用。
- 在衰减的 $\varepsilon$ 和 $\alpha$ 下保证收敛。
- 是 Expected SARSA、n-step SARSA 和 TD(λ) 的基础。
一个温和的证明(为什么它有效)
对于一个固定策略 $\pi$,SARSA 更新近似于贝尔曼方程: $$ Q_\pi(S_t, A_t) = E[R_{t+1} + \gamma Q_\pi(S_{t+1}, A_{t+1})] $$ 每次更新步骤都是对这个期望的随机近似。 给定足够的探索和递减的学习率,$Q \to Q_\pi$,并且贪婪改进会导向 $\pi^*$。
亲自尝试
- 在网格世界(如 OpenAI Gym 的 CliffWalking)中实现 SARSA。
- 与 Q-learning(异策略)比较性能。
- 测试不同的 ε 值以观察探索效果。
- 尝试 n-step SARSA 以获得更快的收敛速度。
- 可视化学习到的 $Q(s,a)$ 热力图。
测试用例
| 环境 | 描述 | 备注 |
|---|---|---|
| CliffWalking | 经典的同策略测试 | 使用安全策略避开悬崖 |
| Gridworld | 确定性环境 | 适合可视化 |
| Taxi-v3 | 离散导航 | 需要探索 |
| FrozenLake | 随机环境 | 突出 ε-贪婪策略的平衡性 |
复杂度
- 时间:每次更新步骤 $O(\vert S\vert \vert A\vert )$
- 空间:$O(\vert S\vert \vert A\vert )$
- 收敛性:在 GLIE(无限探索下的极限贪婪)条件下保证收敛
SARSA 展现了边做边学的精髓, 每个决策都完善了做出该决策的策略,将行动与反思融合成一个持续改进的循环。
964. Q-学习(离策略时序差分控制)
Q-学习是最具影响力的强化学习算法之一。 它直接学习最优动作价值函数,即使遵循的是另一个(探索性的)行为策略。 与 SARSA 从它实际采取的动作中学习不同,Q-学习从它可能采取的最佳动作中学习,这使其成为一种离策略方法。
我们要解决什么问题?
我们希望找到最优策略 $\pi^*(s)$,以在未知环境中最大化累积奖励。 Q-学习无需环境动态模型即可做到这一点。
它被用于:
- 游戏(例如,AlphaGo 的早期版本)
- 导航与控制
- 自主决策系统
- 连续适应任务
核心思想
在每一步,Q-学习将 $Q(s,a)$ 的估计值更新为最佳可能的下一动作价值,而不一定是当前策略所采取的那个动作。
这使得 Q-学习是离策略的:
- 行为策略(探索)决定做什么。
- 目标策略(贪婪)决定学习者的目标是什么。
数学公式
更新规则: $$ Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha \big[ R_{t+1} + \gamma \max_{a'} Q(S_{t+1}, a') - Q(S_t, A_t) \big] $$
贪婪目标: 项 $\max_{a'} Q(S_{t+1}, a')$ 表示从下一状态出发的最佳估计未来回报。
动作选择(ε-贪婪):
$$ A_t = \begin{cases} \arg\max_a Q(S_t, a), & \text{概率为 } 1 - \varepsilon,\ \text{随机动作}, & \text{概率为 } \varepsilon. \end{cases} $$
工作原理(通俗解释)
将 Q-学习视为学习环境中的奖励地图。 每次移动时,你都会更新你认为的最佳未来收益,不仅基于你做了什么,还基于你本可以做得更好的事情。 经过许多步之后,这张地图会收敛到真实的最优奖励分布图。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 任意初始化 $Q(s,a)$ |
| 2 | 对于每个回合: |
| a. 从初始状态 $S_0$ 开始 | |
| b. 使用 ε-贪婪策略选择动作 $A_t$ | |
| c. 执行动作,观察 $R_{t+1}$ 和 $S_{t+1}$ | |
| d. 使用 TD 规则更新 $Q(S_t, A_t)$ | |
| e. 设置 $S_t \leftarrow S_{t+1}$ 并重复直到终止状态 |
示例
假设:
- $S_t = s_1$, $A_t = \text{right}$
- 奖励 $R_{t+1} = 1$, 下一状态 $S_{t+1} = s_2$
- $\max_a Q(s_2, a) = 4$
- $\alpha = 0.1$, $\gamma = 0.9$, $Q(s_1, \text{right}) = 2$
则: $$ Q(s_1, \text{right}) \leftarrow 2 + 0.1 [1 + 0.9 \times 4 - 2] = 2 + 0.1 \times 3.6 = 2.36 $$
微型代码
Python
import random
Q = {}
states = ["A", "B", "C"]
actions = ["left", "right"]
alpha, gamma, eps = 0.1, 0.9, 0.1
def policy(s):
if random.random() < eps:
return random.choice(actions)
return max(actions, key=lambda a: Q.get((s,a), 0))
for episode in range(1000):
s = random.choice(states)
while True:
a = policy(s)
r = random.uniform(-1, 1)
s_next = random.choice(states)
q = Q.get((s,a), 0)
q_next_max = max([Q.get((s_next,a2), 0) for a2 in actions])
Q[(s,a)] = q + alpha * (r + gamma * q_next_max - q)
s = s_next
if random.random() < 0.2: # 终止
break为何重要
- 无模型,直接从经验中学习。
- 离策略,可以在随机探索的同时学习最优策略。
- 在特定条件下保证收敛到 $Q^*$(Watkins & Dayan, 1992)。
- 构成了深度 Q 网络(DQN)和现代深度强化学习的基础。
一个温和的证明(为何有效)
对于确定性的学习率 $\alpha_t$ 和充分的探索,Q-学习近似于贝尔曼最优方程: $$ Q^(s,a) = E[R_{t+1} + \gamma \max_{a'} Q^(S_{t+1}, a')] $$ 由于贝尔曼算子是压缩映射,重复应用保证收敛: $$ | Q_{t+1} - Q^* |\infty \le \gamma | Q_t - Q^ |\infty $$ 因此当 $t \to \infty$ 时,$Q_t \to Q^$。
亲自尝试
- 为 FrozenLake 环境实现 Q-学习。
- 与 SARSA 比较学习曲线。
- 调整 $\varepsilon$ 以平衡探索和收敛速度。
- 添加衰减的 $\alpha_t$ 以稳定学习。
- 可视化学习到的 $Q$ 表或策略热图。
测试用例
| 环境 | 描述 | 说明 |
|---|---|---|
| FrozenLake | 离散网格 | 离策略优势 |
| Taxi-v3 | 导航 | 比 SARSA 收敛更快 |
| CliffWalking | 危险地形 | SARSA 更安全,Q-学习更大胆 |
| Gridworld | 小型测试平台 | 非常适合 Q 表可视化 |
复杂度
- 时间:每次更新 $O(\vert S\vert \vert A\vert )$
- 空间:$O(\vert S\vert \vert A\vert )$
- 收敛性:在 GLIE 和 Robbins–Monro 学习率条件下保证收敛
Q-学习是现代强化学习的核心, 它是一个想象更美好未来并一步步朝着它们改进自身的学习者。
965. Double Q-Learning
Double Q-Learning 通过解决标准 Q-Learning 最大的弱点之一——高估偏差——对其进行了改进。 在常规 Q-Learning 中,选择和评估动作使用的是相同的值,这倾向于高估某些动作,尤其是在有噪声的环境中。 Double Q-Learning 通过维护两个独立的值估计器来解决这个问题,它们相互制衡,确保估计的准确性。
我们要解决什么问题?
标准 Q-Learning 使用同一个 $Q$ 函数来同时完成以下两件事:
- 通过 $\max_a Q(S', a)$ 选择最佳的下一个动作
- 评估所选动作的价值
这种自我参照的步骤可能会产生乐观的高估,从而导致学习不稳定或缓慢。
Double Q-Learning 通过解耦动作选择和动作评估来减少这种偏差。
核心思想
使用两个价值函数,$Q^A$ 和 $Q^B$,它们轮流向对方学习。
- 一个函数($Q^A$)选择下一个动作(选择)
- 另一个函数($Q^B$)评估该动作(评估)
通过交替更新,系统能学习到对真实 $Q^*$ 更准确、更稳定的估计。
数学表述
- 维护两个估计器,$Q^A$ 和 $Q^B$
- 每一步以相等概率更新其中一个:
如果更新 $Q^A$: $$ Q^A(S_t, A_t) \leftarrow Q^A(S_t, A_t) + \alpha [R_{t+1} + \gamma Q^B(S_{t+1}, \arg\max_a Q^A(S_{t+1}, a)) - Q^A(S_t, A_t)] $$
如果更新 $Q^B$: $$ Q^B(S_t, A_t) \leftarrow Q^B(S_t, A_t) + \alpha [R_{t+1} + \gamma Q^A(S_{t+1}, \arg\max_a Q^B(S_{t+1}, a)) - Q^B(S_t, A_t)] $$
工作原理(通俗解释)
想象有两个朋友,Alice 和 Bob,他们都试图估计每个动作的好坏。 Alice 根据她的表格选择看起来最好的动作,但由 Bob 来打分。 下一次,则由 Bob 选择,Alice 打分。 因为他们互相检查对方的乐观估计,所以他们共同的知识变得更加可靠。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 任意初始化 $Q^A$ 和 $Q^B$ |
| 2 | 对于每个回合: |
| a. 使用 ε-贪心策略基于 $(Q^A + Q^B)$ 选择 $A_t$ | |
| b. 执行动作 $A_t$,观察 $R_{t+1}$ 和 $S_{t+1}$ | |
| c. 随机选择更新哪个 $Q$ 函数 | |
| d. 使用一个函数进行动作选择,另一个进行动作评估 | |
| e. 重复直到回合结束 |
示例
假设:
- $S_t = s_1$, $A_t = \text{right}$
- $R_{t+1} = 2$, $S_{t+1} = s_2$
- $\gamma = 0.9$, $\alpha = 0.1$
- $Q^A(s_1,\text{right}) = 1.5$
- $Q^A(s_2,\text{up}) = 2.0$, $Q^B(s_2,\text{up}) = 1.8$
如果我们更新 $Q^A$: $$ Q^A(s_1,\text{right}) \leftarrow 1.5 + 0.1 [2 + 0.9 \times Q^B(s_2, \arg\max_a Q^A(s_2,a)) - 1.5] $$ 由于 $\arg\max_a Q^A(s_2,a)$ 是 "up", $$ Q^A(s_1,\text{right}) = 1.5 + 0.1 [2 + 0.9 \times 1.8 - 1.5] = 1.5 + 0.1 \times 2.12 = 1.712 $$
微型代码
Python
import random
Q_A, Q_B = {}, {}
states = ["A", "B", "C"]
actions = ["left", "right"]
alpha, gamma, eps = 0.1, 0.9, 0.1
def policy(s):
Q_total = {a: Q_A.get((s,a),0) + Q_B.get((s,a),0) for a in actions}
if random.random() < eps:
return random.choice(actions)
return max(actions, key=lambda a: Q_total[a])
for episode in range(1000):
s = random.choice(states)
while True:
a = policy(s)
r = random.uniform(-1, 1)
s_next = random.choice(states)
if random.random() < 0.5:
a_next = max(actions, key=lambda a: Q_A.get((s_next,a),0))
Q_A[(s,a)] = Q_A.get((s,a),0) + alpha * (
r + gamma * Q_B.get((s_next,a_next),0) - Q_A.get((s,a),0))
else:
a_next = max(actions, key=lambda a: Q_B.get((s_next,a),0))
Q_B[(s,a)] = Q_B.get((s,a),0) + alpha * (
r + gamma * Q_A.get((s_next,a_next),0) - Q_B.get((s,a),0))
s = s_next
if random.random() < 0.2:
break为什么它很重要
- 减少高估,相比 Q-Learning 学习曲线更稳定
- 在随机环境中收敛更平滑
- 是现代强化学习中广泛使用的深度变体 Double DQN 的基础
- 鼓励在不确定领域进行更好的价值校准
一个温和的证明(为什么它有效)
在 Q-Learning 中,由于采样噪声,$\max_a Q(S',a)$ 倾向于高估。 Double Q-Learning 打破了这种耦合: $$ E[Q^B(S', \arg\max_a Q^A(S',a))] \le E[\max_a Q^A(S',a)] $$ 因此,在保持与贝尔曼最优方程一致性的同时,偏差得以减少。 在标准条件下,该方法仍然收敛到 $Q^*$。
亲自尝试
- 在相同的环境中运行 Q-Learning 和 Double Q-Learning。
- 绘制价值估计图,注意 Q-Learning 的乐观偏差。
- 调整学习率 $\alpha$ 和折扣因子 $\gamma$。
- 使用神经网络扩展到 Double DQN。
- 尝试使用随机奖励函数来突出偏差效应。
测试用例
| 环境 | 描述 | 观察结果 |
|---|---|---|
| CliffWalking | 有噪声的地形 | 比 Q-Learning 更稳定 |
| FrozenLake | 随机状态转移 | 方差更小 |
| Taxi-v3 | 稀疏奖励 | 收敛更平滑 |
| Gridworld | 简单的网格世界 | 更容易可视化 $Q^A$ 与 $Q^B$ 对比 |
复杂度
- 时间:$O(2\vert S\vert \vert A\vert )$(双 Q 表)
- 空间:$O(2\vert S\vert \vert A\vert )$
- 收敛性:无偏,对于小的 $\alpha$ 和持续的探索是稳定的
Double Q-Learning 就像向伙伴学习, 系统的每一半都交叉检查另一半,将乐观转化为平衡,将收敛转化为信心。
966. Deep Q-Network (DQN)
Deep Q-Network (DQN) 通过使用神经网络来近似动作价值函数 $Q(s, a)$,从而扩展了经典的 Q-Learning。 它不再为每个状态-动作对存储一个表格,而是能够泛化到巨大或连续的状态空间,使得强化学习能够处理像图像、游戏和传感器数据这样的高维输入。
我们要解决什么问题?
传统的 Q-Learning 在以下情况下会失效:
- 状态空间太大,无法显式存储 $Q(s,a)$
- 状态是连续的(例如,机器人位置、像素)
- 需要函数近似
DQN 通过学习 $Q_\theta(s, a)$ 来解决这个问题,这是一个由权重为 $\theta$ 的深度神经网络近似的参数化函数。
应用包括:
- Atari 游戏(DeepMind,2015)
- 自主控制系统
- 从原始感官输入进行决策
核心思想
用神经网络近似最优 Q 函数 $Q^(s,a)$: $$ Q(s,a;\theta) \approx Q^(s,a) $$
然后应用 Q-Learning 更新规则,但不是更新表格中的单个条目,而是最小化当前预测与目标之间的均方误差: $$ L(\theta) = \mathbb{E}\Big[ \big( y - Q(s,a;\theta) \big)^2 \Big] $$ 其中目标值是: $$ y = R + \gamma \max_{a'} Q(s',a';\theta^-) $$
$\theta^-$ 是目标网络的参数,为了稳定性会定期更新。
工作原理(通俗解释)
DQN 通过用神经网络预测未来奖励来学习每个动作的好坏。 它观察状态转移 $(s, a, r, s')$,存储它们,并采样随机的小批量数据进行训练。 这种“经验回放”有助于避免对最近经验的过拟合并稳定学习过程。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 初始化回放缓冲区 $D$ |
| 2 | 用随机权重 $\theta$ 初始化 Q 网络 |
| 3 | 用权重 $\theta^- = \theta$ 初始化目标网络 |
| 4 | 对于每个回合: |
| a. 观察状态 $s$,根据 $Q(s,a;\theta)$ 使用 ε-贪婪策略选择动作 $a$ | |
| b. 执行 $a$,观察奖励 $r$ 和下一个状态 $s'$ | |
| c. 将 $(s,a,r,s')$ 存储到回放缓冲区中 | |
| d. 从 $D$ 中采样随机批次 | |
| e. 计算目标 $y = r + \gamma \max_{a'} Q(s',a';\theta^-)$ | |
| f. 最小化损失 $(y - Q(s,a;\theta))^2$ | |
| g. 定期更新 $\theta^- \leftarrow \theta$ |
数学公式
损失函数 $$ L(\theta) = \mathbb{E}{(s,a,r,s') \sim D} \Big[ \big( R + \gamma \max{a'} Q(s',a';\theta^-) - Q(s,a;\theta) \big)^2 \Big] $$
梯度下降更新 $$ \theta \leftarrow \theta - \alpha \nabla_\theta L(\theta) $$
目标网络更新 $$ \theta^- \leftarrow \theta \text{ 每 } C \text{ 步。} $$
示例
在 Atari "Breakout" 游戏中:
- 输入:84×84 灰度游戏帧
- 输出:4 个可能动作的预测 Q 值
- 奖励:当球击碎砖块时 +1,否则为 0 网络学习如何最优地移动挡板以最大化累积分数。
微型代码
Python(简化的 DQN 框架)
import torch, torch.nn as nn, torch.optim as optim, random
import numpy as np
class DQN(nn.Module):
def __init__(self, n_states, n_actions):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_states, 128), nn.ReLU(),
nn.Linear(128, n_actions)
)
def forward(self, x):
return self.net(x)
q_net = DQN(4, 2)
target_net = DQN(4, 2)
target_net.load_state_dict(q_net.state_dict())
optimizer = optim.Adam(q_net.parameters(), lr=1e-3)
gamma = 0.99
def update(batch):
s, a, r, s_next = batch
q_values = q_net(torch.tensor(s, dtype=torch.float32))
next_q = target_net(torch.tensor(s_next, dtype=torch.float32)).max(1)[0].detach()
y = torch.tensor(r) + gamma * next_q
loss = ((q_values.gather(1, torch.tensor(a).unsqueeze(1)).squeeze() - y)2).mean()
optimizer.zero_grad(); loss.backward(); optimizer.step()为什么它很重要
- 可扩展到高维输入(例如,图像、传感器)。
- 经验回放打破了连续样本之间的相关性。
- 目标网络防止了反馈循环和不稳定性。
- 重要里程碑:仅使用像素作为输入,在 Atari 游戏中实现了超越人类的表现。
一个温和的证明(为什么它有效)
贝尔曼最优算子: $$ \mathcal{T}Q(s,a) = \mathbb{E}[R + \gamma \max_{a'} Q(s',a')] $$ 在 $\vert \cdot\vert _\infty$ 范数下是一个压缩映射。 通过最小化 $L(\theta)$,DQN 试图近似 $\mathcal{T}$ 的不动点 $Q^*$。 目标网络和回放缓冲区稳定了这种迭代近似,确保了期望上的收敛。
亲自尝试
- 为 CartPole-v1(OpenAI Gym)实现 DQN。
- 尝试不同的架构,线性网络 vs 卷积网络。
- 与表格型 Q-Learning 的性能进行比较。
- 调整缓冲区大小、批次大小和目标更新频率。
- 可视化训练奖励随时间的变化。
测试用例
| 环境 | 描述 | 备注 |
|---|---|---|
| CartPole-v1 | 经典基准测试 | 收敛速度快 |
| MountainCar-v0 | 稀疏奖励 | 需要探索 |
| Atari Breakout | 视觉输入 | 需要深度 CNN |
| LunarLander-v2 | 连续状态 | 对超参数敏感 |
复杂度
- 时间:$O(B \times E)$(批次大小 × 回合数)
- 空间:$O(\vert D\vert )$(回放缓冲区大小)
- 收敛性:近似收敛;可通过 Double DQN 和 Dueling DQN 改进
DQN 标志着深度强化学习的诞生, 它是神经表示与时序学习的融合,使得机器能够直接从像素和经验中学习复杂的行为。
967. REINFORCE(基于采样的策略梯度)
REINFORCE 是最简单、最基础的策略梯度算法之一。 与 Q-Learning 等学习价值函数的方法不同,它直接学习策略,即如何行动。 通过调整其参数以增加带来奖励的行动的概率,REINFORCE 抓住了从经验中学习的精髓。
我们要解决什么问题?
基于价值的方法(如 Q-Learning 或 DQN)近似 $Q(s,a)$ 并采取贪婪行动。 但在许多问题中:
- 动作空间是连续的
- 确定性策略表现不佳
- 随机探索至关重要
REINFORCE 通过优化参数化的随机策略 $\pi_\theta(a\vert s)$ 来解决这个问题,而无需学习 $Q(s,a)$。
它非常适用于:
- 连续控制(机器人、游戏智能体)
- 具有随机状态转移的环境中的策略优化
- 参数化动作(例如概率、扭矩、速度)的学习
核心思想
最大化期望累积奖励: $$ J(\theta) = \mathbb{E}{\pi\theta}[R] $$
使用对数导数技巧: $$ \nabla_\theta J(\theta) = \mathbb{E}{\pi\theta}\big[ \nabla_\theta \log \pi_\theta(a|s) , G_t \big] $$
其中 $G_t$ 是回报(从时间 $t$ 开始的折扣奖励总和)。
更新规则变为: $$ \theta \leftarrow \theta + \alpha , G_t , \nabla_\theta \log \pi_\theta(a_t|s_t) $$
这会将策略参数 $\theta$ 朝着增加带来更高奖励的行动的对数概率方向移动。
工作原理(通俗解释)
想象一个玩家尝试不同的策略,获得分数,并记住哪些选择带来了更好的结果。 REINFORCE 只是轻微调整这些行动的概率,使它们在将来更有可能发生。 随着时间的推移,智能体学会哪些行动往往能产生更高的奖励,从而直接塑造其行为。
逐步总结
| 步骤 | 描述 | |
|---|---|---|
| 1 | 随机初始化策略参数 $\theta$ | |
| 2 | 对于每个回合: | |
| a. 运行策略 $\pi_\theta(a \vert s)$ 生成轨迹 $(s_0,a_0,r_1,s_1,...)$ | ||
| b. 为每个时间步计算回报 $G_t = r_{t+1} + \gamma r_{t+2} + \dots$ | ||
| c. 更新参数:$\theta \leftarrow \theta + \alpha , G_t , \nabla_\theta \log \pi_\theta(a_t \mid s_t)$ | ||
| d. 重复直到收敛 |
数学公式
期望回报 $$ J(\theta) = \mathbb{E}{\pi\theta}\Big[ \sum_{t=0}^T \gamma^t R_t \Big] $$
策略梯度定理 $$ \nabla_\theta J(\theta) = \mathbb{E}{\pi\theta}\Big[ \nabla_\theta \log \pi_\theta(a_t|s_t) (G_t - b_t) \Big] $$
基线项 $b_t$ 减去一个基线(通常是价值估计)可以在不改变期望的情况下减少方差。
示例
假设:
$\pi_\theta(a\vert s)$ 是一个 softmax 策略: $$ \pi_\theta(a|s) = \frac{e^{\theta^\top f(s,a)}}{\sum_b e^{\theta^\top f(s,b)}} $$
奖励:好的行动 $R_t = +1$,坏的行动 $R_t = -1$。 那么 REINFORCE 会增加 $G_t$ 为正的行动对应的 $\theta$,否则会减少它。
微型代码
Python(使用 softmax 策略)
import numpy as np
def softmax(x):
e = np.exp(x - np.max(x))
return e / e.sum()
theta = np.random.randn(2) # 参数向量
alpha, gamma = 0.01, 0.99
for episode in range(1000):
states, actions, rewards = [], [], []
s = 0
for t in range(10):
probs = softmax(theta)
a = np.random.choice(len(probs), p=probs)
r = 1 if a == 1 else -1 # 示例奖励
states.append(s); actions.append(a); rewards.append(r)
G = 0
for t in reversed(range(len(rewards))):
G = rewards[t] + gamma * G
grad_log = np.zeros_like(theta)
grad_log[actions[t]] = 1 - softmax(theta)[actions[t]]
theta += alpha * G * grad_log为什么它很重要
- 直接优化策略,无需 Q 表
- 支持连续动作(与 Q-learning 不同)
- 简单通用,是所有策略梯度方法的基础
- 可与神经网络配合使用(例如在演员-评论家模型中)
一个温和的证明(为什么它有效)
使用对数导数恒等式: $$ \nabla_\theta \pi_\theta(a|s) = \pi_\theta(a|s) \nabla_\theta \log \pi_\theta(a|s) $$
那么: $$ \nabla_\theta J(\theta) = \sum_s d_\pi(s) \sum_a Q^\pi(s,a) \nabla_\theta \log \pi_\theta(a|s) $$
梯度是无偏的但方差很大;基线项 $b_t$ 可以在不使结果产生偏差的情况下减少这种方差。
亲自尝试
- 为 CartPole-v1 实现 REINFORCE。
- 与演员-评论家方法比较,注意方差差异。
- 添加一个价值函数基线以减少方差。
- 使用神经网络表示 $\pi_\theta(a\vert s)$。
- 绘制总奖励与回合数的关系图。
测试用例
| 环境 | 描述 | 观察结果 |
|---|---|---|
| CartPole-v1 | 离散控制 | 收敛缓慢但稳定 |
| MountainCarContinuous | 连续动作 | 需要良好的归一化 |
| LunarLander-v2 | 复杂奖励 | 对学习率敏感 |
| Pendulum-v1 | 连续扭矩控制 | 需要基线 |
复杂度
- 时间:每回合 $O(T)$(轨迹长度)
- 空间:$O(T)$(存储奖励和梯度)
- 收敛性:无偏但方差大;没有基线时收敛慢
REINFORCE 展示了强化学习中最简单的真理, 增加有效行动的概率。 每一个成功的行动都会留下一小条通往更好行为的梯度轨迹。
968. Actor–Critic(价值引导的策略更新)
Actor–Critic 算法融合了两个思想:行动者学习该做什么,而评判者学习这个决策有多好。这种策略梯度和价值估计的结合,使得学习比纯基于策略的方法(如 REINFORCE)或纯基于价值的方法(如 Q-Learning)更快、更稳定。
我们要解决什么问题?
REINFORCE 直接从整个回合中学习,这导致了:
- 高方差(更新依赖于长期奖励)
- 学习速度慢(没有中间反馈)
Actor–Critic 通过增加一个评判者来解决这个问题,该评判者估计价值函数 $V_\phi(s)$,为行动者在每一步提供实时反馈。它弥合了蒙特卡洛学习和时序差分学习之间的差距。
核心思想
我们维护两个网络:
- 行动者:策略 $\pi_\theta(a\vert s)$,决定该做什么
- 评判者:价值函数 $V_\phi(s)$,判断当前状态(或动作)有多好
行动者按照评判者反馈所建议的方向进行更新: $$ \nabla_\theta J(\theta) = \mathbb{E} \big[ \nabla_\theta \log \pi_\theta(a_t|s_t) , \delta_t \big] $$ 其中 TD 误差(优势)为: $$ \delta_t = R_{t+1} + \gamma V_\phi(s_{t+1}) - V_\phi(s_t) $$
评判者学习以最小化均方 TD 误差: $$ L(\phi) = \big( \delta_t \big)^2 $$
工作原理(通俗解释)
将行动者想象成一个探索者,评判者想象成一个教练。行动者尝试不同的动作,评判者立即说"这比预期的好/差"。然后行动者相应地调整其概率,而评判者则不断改进其对价值的感知。
分步总结
| 步骤 | 描述 | |
|---|---|---|
| 1 | 初始化行动者参数 $\theta$ 和评判者参数 $\phi$ | |
| 2 | 对于每个回合: | |
| a. 观察状态 $s_t$,选择动作 $a_t \sim \pi_\theta(a_t \vert s_t)$ | ||
| b. 执行 $a_t$,观察 $R_{t+1}$ 和 $s_{t+1}$ | ||
| c. 计算 TD 误差:$\delta_t = R_{t+1} + \gamma V_\phi(s_{t+1}) - V_\phi(s_t)$ | ||
| d. 更新评判者:$\phi \leftarrow \phi + \beta , \delta_t , \nabla_\phi V_\phi(s_t)$ | ||
| e. 更新行动者:$\theta \leftarrow \theta + \alpha , \delta_t , \nabla_\theta \log \pi_\theta(a_t \vert s_t)$ | ||
| f. 重复直到回合结束 |
数学公式
行动者更新 $$ \theta \leftarrow \theta + \alpha , \delta_t , \nabla_\theta \log \pi_\theta(a_t|s_t) $$
评判者更新 $$ \phi \leftarrow \phi + \beta , \delta_t , \nabla_\phi V_\phi(s_t) $$
TD 误差(优势) $$ \delta_t = R_{t+1} + \gamma V_\phi(s_{t+1}) - V_\phi(s_t) $$
示例
考虑一个学习走路的机器人:
- 行动者控制肌肉激活(策略)
- 评判者预测未来的稳定性(价值) 如果一步改善了平衡,评判者的 $\delta_t$ 为正,奖励这些动作。 如果机器人绊倒,$\delta_t$ 变为负,行动者会降低这些动作的概率。
微型代码
Python(简化的 Actor–Critic 框架)
import torch, torch.nn as nn, torch.optim as optim
import numpy as np
class Actor(nn.Module):
def __init__(self, n_states, n_actions):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_states, 128), nn.ReLU(),
nn.Linear(128, n_actions), nn.Softmax(dim=-1))
def forward(self, x):
return self.net(x)
class Critic(nn.Module):
def __init__(self, n_states):
super().__init__()
self.net = nn.Sequential(nn.Linear(n_states, 128), nn.ReLU(), nn.Linear(128, 1))
def forward(self, x):
return self.net(x)
actor, critic = Actor(4, 2), Critic(4)
opt_a = optim.Adam(actor.parameters(), lr=1e-3)
opt_c = optim.Adam(critic.parameters(), lr=1e-3)
gamma = 0.99
def update(s, a, r, s_next):
s = torch.tensor(s, dtype=torch.float32)
s_next = torch.tensor(s_next, dtype=torch.float32)
v_s = critic(s)
v_next = critic(s_next).detach()
delta = r + gamma * v_next - v_s
# 评判者更新
loss_c = delta.pow(2)
opt_c.zero_grad(); loss_c.backward(); opt_c.step()
# 行动者更新
probs = actor(s)
log_prob = torch.log(probs[a])
loss_a = -log_prob * delta.detach()
opt_a.zero_grad(); loss_a.backward(); opt_a.step()为什么它很重要
- 比 REINFORCE 方差更低(得益于评判者)
- 在线学习,可以在每一步之后更新,而不是整个回合
- 是 A2C、PPO、DDPG 和 SAC 等高级方法的基础
- 通过持续反馈平衡探索和利用
一个温和的证明(为什么它有效)
带有基线 $b(s)=V(s)$ 的策略梯度定理给出: $$ \nabla_\theta J(\theta) = \mathbb{E}{\pi\theta} \big[ \nabla_\theta \log \pi_\theta(a|s) (Q(s,a) - V(s)) \big] $$
评判者近似 $V(s)$,所以 $Q(s,a) - V(s) \approx \delta_t$。 因此,行动者更新使用 TD 误差 $\delta_t$ 作为优势的无偏估计量。
自己动手试试
- 为 CartPole-v1 实现 Actor–Critic。
- 与 REINFORCE 比较性能,注意更平滑的学习过程。
- 尝试使用高斯策略处理连续动作。
- 添加熵正则化以鼓励探索。
- 尝试不同的学习率 $\alpha$ 和 $\beta$。
测试用例
| 环境 | 描述 | 观察结果 |
|---|---|---|
| CartPole-v1 | 离散控制 | 比 REINFORCE 收敛更快 |
| Pendulum-v1 | 连续控制 | 适用于高斯策略 |
| LunarLander-v2 | 复杂动力学 | 学习稳定,方差小 |
| MountainCar-v0 | 稀疏奖励 | 需要耐心和探索 |
复杂度
- 时间:每个回合 $O(T)$(每一步进行 TD 更新)
- 空间:$O(\vert \theta\vert + \vert \phi\vert )$
- 收敛性:比 REINFORCE 更快更平滑,但对评判者偏差敏感
Actor–Critic 就像一个由两部分组成的思维, 行动者凭直觉行动,评判者凭理性评估, 它们共同协作,趋于精通。
969. PPO(近端策略优化)
近端策略优化(PPO)是最流行和最稳定的策略梯度算法之一。它改进了 Actor-Critic 方法,通过仔细控制每次更新时策略可以改变的程度,防止破坏性的步骤导致训练不稳定。
我们要解决什么问题?
在策略梯度和 Actor-Critic 方法中,大的策略更新可能导致:
- 不稳定性:新策略与旧策略偏离太多
- 崩溃:对噪声优势值过拟合
- 灾难性遗忘:好的行为突然丢失
PPO 通过强制执行一个信任区域来解决这个问题,它限制了新策略在改进的同时可以偏离旧策略的最大距离。
核心思想
PPO 优化一个裁剪后的替代目标函数: $$ L^{CLIP}(\theta) = \mathbb{E}_t \Big[ \min\big( r_t(\theta) A_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) A_t \big) \Big] $$
其中:
- $r_t(\theta) = \frac{\pi_\theta(a_t\vert s_t)}{\pi_{\theta_{\text{old}}}(a_t\vert s_t)}$ 是概率比
- $A_t$ 是优势估计值
- $\epsilon$(例如 0.1–0.3)定义了裁剪范围
"min" 表达式确保当 $r_t(\theta)$ 偏离 1 太远时,梯度被切断,从而保持更新安全稳定。
工作原理(通俗解释)
可以把 PPO 看作是对 Actor 说:
"你可以改进策略,但一次只能改进一点点。"
它通过限制所选动作的概率可以改变的程度,来防止 Actor "跳下悬崖"。这种受控的调整即使在复杂环境中也能保持学习过程的稳定。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 使用当前策略 $\pi_\theta$ 收集经验 $(s_t, a_t, r_t, s_{t+1})$ |
| 2 | 估计优势 $A_t = R_t + \gamma V(s_{t+1}) - V(s_t)$ |
| 3 | 计算概率比 $r_t(\theta)$ |
| 4 | 通过最大化裁剪目标函数 $L^{CLIP}(\theta)$ 来更新策略 |
| 5 | 通过最小化平方误差来更新价值函数 $V_\phi(s)$ |
| 6 | 在同一批次上重复多个轮次(小批量更新) |
数学公式
策略损失(裁剪目标函数): $$ L^{CLIP}(\theta) = \mathbb{E}\Big[\min(r_t(\theta)A_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon)A_t)\Big] $$
价值损失: $$ L^{V}(\phi) = \mathbb{E}\Big[ (V_\phi(s_t) - R_t)^2 \Big] $$
熵正则化(可选): $$ L^{S} = \mathbb{E}[ -\beta , H(\pi_\theta(\cdot|s_t)) ] $$
组合目标函数: $$ L = L^{CLIP} - c_1 L^{V} + c_2 L^{S} $$
示例
假设旧策略预测: $$ \pi_{\text{old}}(a|s) = 0.5, \quad A_t = +2 $$ 如果新策略预测 $\pi_\theta(a\vert s) = 0.8$, 那么 $r_t = 1.6$,由于它超过了 $(1+\epsilon)$,梯度被裁剪,确保稳定的改进。
微型代码
Python(简化的 PPO 训练循环)
import torch, torch.nn as nn, torch.optim as optim
class ActorCritic(nn.Module):
def __init__(self, n_states, n_actions):
super().__init__()
self.shared = nn.Sequential(nn.Linear(n_states, 64), nn.Tanh())
self.actor = nn.Sequential(nn.Linear(64, n_actions), nn.Softmax(dim=-1))
self.critic = nn.Linear(64, 1)
def forward(self, x):
h = self.shared(x)
return self.actor(h), self.critic(h)
env_n_states, env_n_actions = 4, 2
model = ActorCritic(env_n_states, env_n_actions)
optimizer = optim.Adam(model.parameters(), lr=3e-4)
epsilon = 0.2
gamma = 0.99
def ppo_update(batch):
s, a, r, s_next, old_logprob, adv = batch
probs, values = model(torch.tensor(s, dtype=torch.float32))
dist = torch.distributions.Categorical(probs)
logprob = dist.log_prob(torch.tensor(a))
ratio = torch.exp(logprob - torch.tensor(old_logprob))
clipped = torch.clamp(ratio, 1 - epsilon, 1 + epsilon) * torch.tensor(adv)
loss = -torch.min(ratio * torch.tensor(adv), clipped).mean()
optimizer.zero_grad(); loss.backward(); optimizer.step()为什么它很重要
- 稳定性:避免灾难性更新
- 效率:多轮次的小批量优化
- 简单性:易于实现(没有像 TRPO 那样的复杂约束)
- 性能:在机器人、游戏和控制领域达到最先进水平
PPO 广泛应用于:
- OpenAI Gym 环境
- 机器人(MuJoCo, Isaac Gym)
- 游戏智能体(Atari, StarCraft, Dota 2)
- 仿真到真实世界的控制迁移
一个温和的证明(为什么它有效)
PPO 成功的关键在于其替代目标函数的边界。当策略更新很小时($r_t \approx 1$),PPO 的行为类似于标准策略梯度。当更新变得太大时,裁剪项限制了目标函数,从而限制了步长。这就像一个软信任区域,确保了期望回报的单调改进。
自己动手试试
- 为 CartPole-v1 或 LunarLander-v2 实现 PPO。
- 比较裁剪更新与非裁剪更新,观察稳定性差异。
- 在 0.1–0.3 之间调整 $\epsilon$ 以获得最佳结果。
- 绘制策略比 $r_t$ 随时间的变化图,可视化约束效果。
- 扩展到使用高斯分布的连续动作。
测试用例
| 环境 | 描述 | 观察结果 |
|---|---|---|
| CartPole-v1 | 离散控制 | 快速且稳定的学习 |
| LunarLander-v2 | 稀疏奖励 | 平滑的训练曲线 |
| Hopper-v2 (MuJoCo) | 连续控制 | 强大的性能 |
| Humanoid-v2 | 高维 | 能很好地随小批量更新进行扩展 |
复杂度
- 时间:$O(E \times B)$(轮次 × 批次大小)
- 空间:$O(B)$(存储的轨迹)
- 收敛性:稳定,单调改进
PPO 是现代策略优化的黄金标准, 易于实现,对超参数鲁棒, 也是强化学习能够走出实验室、实现规模化应用的原因。
970. DDPG / SAC(连续动作强化学习)
当环境需要连续动作时,例如驾驶汽车或控制机械臂,像 Q-learning 或 PPO 这样的离散算法就需要进行调整。 DDPG(深度确定性策略梯度)和 SAC(柔性演员-评论家)是为这些连续动作领域设计的两个强大的演员-评论家框架,它们平衡了精确性、稳定性和探索性。
我们要解决什么问题?
大多数强化学习算法假设动作是离散的(例如,向左或向右移动)。 但现实世界的控制问题通常需要连续控制(例如,油门 = 0.73)。 对于这种情况:
- Q-learning 无法直接应用(它需要计算 $\max_a Q(s,a)$)。
- 策略梯度方法(如 REINFORCE)噪声太大。
- PPO 难以实现细粒度的精确控制。
DDPG 和 SAC 通过结合基于价值的学习(为了稳定性)和基于策略的学习(为了灵活性)来解决这个问题。
1. 深度确定性策略梯度(DDPG)
DDPG 是一种离策略的确定性行动者-评论者算法。 它使用两个网络:
- 行动者:输出确定性动作 $a = \mu_\theta(s)$
- 评论者:通过 $Q_\phi(s, a)$ 评估该动作
核心更新规则
评论者更新(时序差分误差): $$ L(\phi) = \big(Q_\phi(s_t, a_t) - y_t\big)^2 $$ 其中 $$ y_t = r_t + \gamma Q_{\phi'}(s_{t+1}, \mu_{\theta'}(s_{t+1})) $$ (带撇的网络是用于稳定性的目标网络。)
行动者更新(策略梯度): $$ \nabla_\theta J(\theta) = \mathbb{E}\big[ \nabla_a Q_\phi(s,a) \big|{a=\mu\theta(s)} \nabla_\theta \mu_\theta(s) \big] $$
关键特性
- 确定性动作 $\to$ 更新稳定
- 目标网络 $\to$ 防止发散
- 经验回放 $\to$ 采样效率高
- 噪声(如 Ornstein–Uhlenbeck) $\to$ 在连续空间中进行探索
微型代码(DDPG 骨架)
import torch, torch.nn as nn, torch.optim as optim
class Actor(nn.Module):
def __init__(self, s_dim, a_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(s_dim, 256), nn.ReLU(),
nn.Linear(256, a_dim), nn.Tanh())
def forward(self, s): return self.net(s)
class Critic(nn.Module):
def __init__(self, s_dim, a_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(s_dim + a_dim, 256), nn.ReLU(),
nn.Linear(256, 1))
def forward(self, s, a): return self.net(torch.cat([s,a], dim=-1))为何重要
- 非常适合机器人控制、自动驾驶汽车和游戏物理。
- 能平滑处理高维连续动作。
- 是 TD3 和 SAC 等更先进变体的基础。
2. 软演员-评论家 (SAC)
SAC 通过最大熵原理扩展了 DDPG,鼓励探索和鲁棒性。 它学习的是随机策略而非确定性策略,同时兼顾奖励和熵。
目标
最大化带熵的期望回报: $$ J(\pi) = \sum_t \mathbb{E}_{(s_t,a_t)\sim\pi}\big[ r(s_t,a_t) + \alpha , \mathcal{H}(\pi(\cdot|s_t)) \big] $$
其中,$\alpha$ 控制探索与利用的权衡:
- $\alpha$ 大 → 更多的随机探索
- $\alpha$ 小 → 更侧重于奖励
策略更新
SAC 最小化软策略损失: $$ L_\pi(\theta) = \mathbb{E}\big[ \alpha \log \pi_\theta(a_t|s_t) - Q_\phi(s_t, a_t) \big] $$
以及评论家损失: $$ L_Q(\phi) = \big(Q_\phi(s_t,a_t) - (r_t + \gamma V_{\bar{\phi}}(s_{t+1}))\big)^2 $$
其中软价值函数为: $$ V_{\bar{\phi}}(s) = \mathbb{E}{a\sim\pi\theta}\big[ Q_\phi(s,a) - \alpha \log \pi_\theta(a|s) \big] $$
工作原理(通俗解释)
SAC 不仅仅寻找好的动作;它更倾向于有许多好的选择。 通过奖励不确定性(熵),它避免了陷入狭窄、脆弱的行为模式。 结果是:平滑、稳定且具有探索性的控制。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 收集经验 $(s,a,r,s')$ |
| 2 | 使用软目标 $r + \gamma (Q - \alpha \log \pi)$ 更新评论家 |
| 3 | 更新演员以最大化 $Q - \alpha \log \pi$ |
| 4 | 自动调整 $\alpha$ 以维持目标熵 |
| 5 | 使用经验回放缓冲区样本重复 |
微型代码 (SAC 骨架)
def soft_q_loss(s, a, r, s_next, done, actor, critic, target, alpha):
with torch.no_grad():
a_next, logp = actor.sample(s_next)
q_target = r + (1 - done) * gamma * (target(s_next, a_next) - alpha * logp)
q = critic(s, a)
return ((q - q_target)2).mean()重要性
- 比 DDPG 更稳定(得益于熵项)
- 更好的探索性,避免过早收敛
- 在随机、高维任务中表现良好
- 自动熵调优简化了超参数
SAC 在诸如 HalfCheetah、Walker2D 和 Ant 等基准测试中通常优于所有其他连续控制方法。
对比表
| 特性 | DDPG | SAC |
|---|---|---|
| 策略类型 | 确定性 | 随机 |
| 探索 | 噪声过程 | 熵项 |
| 稳定性 | 中等 | 非常高 |
| 样本效率 | 高 | 高 |
| 熵调优 | 手动 | 自动 |
| 典型应用领域 | 机器人、模拟器 | 机器人、物理、控制 |
亲自尝试
- 在 Pendulum-v1 上同时训练 DDPG 和 SAC。
- 比较学习曲线,SAC 的会更平滑。
- 调整 $\alpha$ 以观察熵对探索的影响。
- 扩展到 Humanoid-v2 进行大规模控制。
- 尝试不同的经验回放缓冲区大小和目标更新率。
测试用例
| 环境 | 算法 | 观察 |
|---|---|---|
| Pendulum-v1 | DDPG | 有效,但对噪声敏感 |
| Hopper-v2 | SAC | 稳定,持续学习 |
| Ant-v2 | SAC | 平滑收敛 |
| Reacher-v2 | DDPG | 可能过拟合或振荡 |
复杂度
- 时间:每批次 $O(N)$(每步更新两个网络)
- 空间:$O(\vert \theta\vert + \vert \phi\vert )$(演员和评论家参数)
- 收敛性:在稳定性和探索性方面 SAC > DDPG
简而言之: DDPG 追求精确;SAC 追求稳定。 它们共同定义了连续控制学习的现代标准,在这种标准下,智能体不仅仅是选择动作,而是流动于动作之间。
第 98 节. 近似与在线算法
971. 贪心集合覆盖(ln n 近似算法)
集合覆盖问题问的是:给定一个元素的全集和一个覆盖这些元素的集合族,需要最少多少个集合才能覆盖所有元素? 这个问题是 NP 难的,但一个简单的贪心算法能给出对数因子的近似解,这是近似算法中最优雅的结果之一。
我们要解决什么问题?
形式化定义: 设全集为 $$ U = {e_1, e_2, \dots, e_n} $$ 以及一个子集族 $$ S = {S_1, S_2, \dots, S_m}, \quad S_i \subseteq U. $$
我们希望选择最小的子集族 $C \subseteq S$,使得 $$ \bigcup_{S_i \in C} S_i = U. $$
该问题出现在:
- 传感器布置(覆盖所有区域)
- 特征选择
- 测试套件最小化
- 网络监控
精确解的时间复杂度在 $m$ 上是指数级的,因此我们转而寻求一个良好的近似解。
核心思想
每一步都选择能覆盖最多未被覆盖元素的集合。 重复此过程,直到每个元素都被覆盖。
虽然贪心算法可能找不到完美解,但它保证了解不会比最优解差 $\ln n$ 倍。
逐步示例
假设 $U = {1,2,3,4,5,6}$ 且 $$ S_1 = {1,2,3}, \quad S_2 = {2,4}, \quad S_3 = {3,4,5}, \quad S_4 = {5,6}. $$
贪心步骤:
| 迭代次数 | 可用集合 | 选择的集合 | 新覆盖的元素 | 目前已覆盖 |
|---|---|---|---|---|
| 1 | 全部 | $S_1$ | {1,2,3} | {1,2,3} |
| 2 | 剩余 | $S_3$ | {4,5} | {1,2,3,4,5} |
| 3 | 剩余 | $S_4$ | {6} | {1,2,3,4,5,6} |
使用的集合总数 = 3(最优解是 2 或 3)。 仍在 $\ln n$ 的界限内。
算法(伪代码)
GreedySetCover(U, S):
C = ∅
while U not empty:
在 S 中选择覆盖最多未被覆盖元素的集合 S_i
C = C ∪ {S_i}
U = U \ S_i
return C数学保证
令 OPT 为最优集合数量。 那么贪心算法选择的集合数最多为 $$ H_n = 1 + \frac{1}{2} + \frac{1}{3} + \dots + \frac{1}{n} \le \ln n + 1 $$ 乘以 OPT。
所以: $$ |C_{\text{greedy}}| \le (\ln n + 1) , |C_{\text{opt}}|. $$
除非 $P = NP$,否则这是可能达到的最佳结果。
微型代码
Python
def greedy_set_cover(universe, sets):
U = set(universe)
cover = []
while U:
best = max(sets, key=lambda s: len(U & s))
cover.append(best)
U -= best
return cover
U = {1,2,3,4,5,6}
S = [{1,2,3}, {2,4}, {3,4,5}, {5,6}]
C = greedy_set_cover(U, S)
print("覆盖集合:", C)为何重要
- 贪心集合覆盖是许多覆盖和选择问题的模板。
- 它达到了紧的对数近似界限。
- 它易于实现,在实践中效果良好。
应用包括:
- 选择最少的训练样本
- 减少特征集中的冗余
- 构建最小的监控系统
亲自尝试
- 生成随机子集,测试贪心算法选取的集合数量与最优解的比较(对于小的 $n$)。
- 可视化每一步后的覆盖情况。
- 实现带权重的版本,优先选择“价值/成本”比率更高的集合。
- 在文档摘要上测试:将句子视为集合,单词视为元素。
测试用例
| 全集 | 集合族 | 结果 |
|---|---|---|
| {1,2,3,4} | {{1,2}, {2,3}, {3,4}} | 选取 3 个集合 |
| {1,2,3,4,5,6} | {{1,2,3}, {3,4,5}, {5,6}} | 选取 3 个集合 |
| 随机子集 (n=100) | 达到接近最优的 ln(n) 比率 |
复杂度
- 时间:$O(nm)$(每次迭代扫描所有集合)
- 空间:$O(n+m)$
- 近似比:$\ln n + 1$
贪心集合覆盖是一个简洁的小奇迹, 一个单行的启发式方法,悄无声息地达到了计算机科学中最难问题之一的最佳可证明界限。
972. 顶点覆盖近似算法(双匹配启发式)
顶点覆盖问题要求找出图中覆盖每条边的最小顶点集合。 这是一个核心的 NP 难问题,但令人惊讶的是,一个非常简单的算法就能实现 2-近似,这意味着它最多是最优解的两倍大。
我们要解决什么问题?
给定一个无向图 $$ G = (V, E) $$ 找到最小的顶点子集 $$ C \subseteq V $$ 使得对于每条边 $(u, v) \in E$,$u$ 或 $v$ 中至少有一个在 $C$ 中。
换句话说,通过选择足够多的顶点来"覆盖"所有的边。
应用包括:
- 网络监控(每条链路都有一个监视器)
- 设施选址(每个连接由一个站点服务)
- 资源分配和安全系统
核心思想
选取任意一条未被覆盖的边 $(u,v)$,将其两个端点都加入覆盖集, 然后移除所有与 $u$ 或 $v$ 关联的边。 重复此过程,直到没有边剩余。
这种简单的类贪心方法保证了: $$ |C_{\text{approx}}| \le 2 , |C_{\text{opt}}| $$
逐步示例
图:
边 = {(1,2), (2,3), (3,4), (4,5)}算法:
| 步骤 | 选取的边 | 添加的顶点 | 剩余的边 | 当前覆盖集 |
|---|---|---|---|---|
| 1 | (1,2) | {1,2} | (3,4), (4,5) | {1,2} |
| 2 | (3,4) | {3,4} | (4,5) 已被覆盖 | {1,2,3,4} |
最终覆盖集:{1,2,3,4}。 最优覆盖集是 {2,4}(大小为 2), 因此比率 = 4 / 2 = 2,完全在界限内。
算法(伪代码)
近似顶点覆盖(G):
C = ∅
while E 不为空:
选取任意边 (u, v) ∈ E
C = C ∪ {u, v}
移除所有与 u 或 v 关联的边
return C数学保证
令 OPT 为最小顶点覆盖。 算法为每条选中的边选取两个端点, 但由于任何边必须至少与 OPT 中的一个顶点关联, 所选边的数量 ≤ |OPT|, 因此:
$$ |C| \le 2 |OPT| $$
所以该算法是一个 2-近似算法。
微型代码
Python
def vertex_cover_approx(graph):
cover = set()
edges = set(graph)
while edges:
u, v = edges.pop()
cover.update([u, v])
edges = {e for e in edges if u not in e and v not in e}
return cover
edges = {(1,2), (2,3), (3,4), (4,5)}
print(vertex_cover_approx(edges))为何重要
- 快速、简单且可证明接近最优
- 是众多网络优化启发式算法的基础
- 有助于为其他图问题(如集合覆盖、团覆盖等)设计更好的界限
- 构成了组合优化中"原始-对偶"框架的一部分
亲自尝试
- 生成随机图,并通过暴力方法(对于 $n < 10$)与最优顶点覆盖进行比较。
- 实现带权顶点覆盖(优先选择成本较低的顶点)。
- 可视化图,在每次迭代后将边标记为"已覆盖"。
- 与基于匹配的近似算法进行比较(最大匹配给出相同的近似因子)。
测试用例
| 图 | 近似覆盖 | 最优覆盖 | 比率 |
|---|---|---|---|
| 路径 (1–2–3–4–5) | {1,2,3,4} | {2,4} | 2.0 |
| 三角形 (1–2–3–1) | {1,2} | {1,2} | 1.0 |
| 星形 (1–{2,3,4,5}) | {1,2} | {1} | 2.0 |
| 随机 6 节点图 | ~2× | ~1× | ≤ 2.0 |
复杂度
- 时间:$O(\vert E\vert )$
- 空间:$O(\vert V\vert )$
- 近似比:≤ 2
一个温和的证明(为何有效)
每条选中的边 $(u,v)$ 为覆盖集贡献两个顶点。 在任何最优覆盖中,$u$ 或 $v$ 中至少有一个必须出现,因此每条边至少为 OPT 贡献一个"令牌"。 由于我们可能选取两个端点,我们最多会多算一倍。
总结表
| 属性 | 值 |
|---|---|
| 算法类型 | 贪心 / 基于匹配 |
| 保证 | 2 倍最优 |
| 确定性 | 是 |
| 适用对象 | 无权图 |
| 扩展 | 通过 LP 舍入实现带权版本 |
顶点覆盖近似算法是简洁之美的完美范例, 一个小小的加倍步骤,将一个困难的组合难题带入了实际可行的范围。
973. 旅行商问题近似算法(基于最小生成树的 2-近似算法)
旅行商问题(TSP)要求找到一条最短的可能路径,该路径恰好访问所有城市一次并返回起点。 这是组合优化中最著名的 NP 难问题之一,然而,当距离满足三角不等式时,存在一种基于最小生成树(MST)的简单而优雅的 2-近似算法。
我们正在解决什么问题?
给定一个完全加权图 $$ G = (V, E) $$ 其中 $w(u,v)$ 是顶点 $u$ 和 $v$ 之间的距离(成本), 找到一个总成本最小的、恰好访问所有顶点一次的环。
形式化定义:
$$ \min_{\pi} \sum_{i=1}^{n} w(\pi_i, \pi_{i+1}) $$ 其中 $\pi$ 是顶点的排列,且 $\pi_{n+1} = \pi_1$。
当距离满足三角不等式时:
$$ w(u,v) \le w(u,x) + w(x,v), $$
我们可以使用 MST 加倍和遍历来近似最优路径。
核心思想
- 计算图的一个 MST(连接所有节点的最小成本)。
- 以前序方式(深度优先遍历)遍历 MST。
- 利用三角不等式对重复出现的顶点进行"捷径"处理。
最终路径的总成本 ≤ 2 × 最优成本。
逐步示例
考虑一个包含 4 个城市的完全图:
| 边 | 成本 |
|---|---|
| (A,B) | 1 |
| (A,C) | 3 |
| (A,D) | 4 |
| (B,C) | 2 |
| (B,D) | 5 |
| (C,D) | 3 |
步骤 1:构建 MST MST 中的边 = {(A,B), (B,C), (C,D)} MST 总成本 = 1 + 2 + 3 = 6。
步骤 2:前序遍历:A → B → C → D → A 原始遍历成本 = 8。
步骤 3:必要时进行"捷径"处理(此例中无需)。 最终 TSP 路径 = 8 ≤ 2 × 6 = 12(在界限内)。
算法(伪代码)
ApproxTSPviaMST(G):
T = MinimumSpanningTree(G)
tour = PreorderTraversal(T)
return tour with shortcuts数学保证
令 OPT 为最优 TSP 路径的成本。
- MST 的成本 ≤ OPT(因为从最优路径中移除一条边会得到一个生成树)。
- 遍历 MST 两次的总成本 ≤ 2 × MST ≤ 2 × OPT。
- 进行"捷径"处理(利用三角不等式)永远不会增加成本。
因此:
$$ \text{Cost}{\text{approx}} \le 2 \times \text{Cost}{\text{OPT}}. $$
微型代码
Python
import networkx as nx
def tsp_mst_approx(G):
T = nx.minimum_spanning_tree(G) # 计算最小生成树
preorder = list(nx.dfs_preorder_nodes(T, source=list(T.nodes)[0])) # 前序遍历
tour = preorder + [preorder[0]] # 完成环
cost = sum(G[tour[i]][tour[i+1]]['weight'] for i in range(len(tour)-1)) # 计算总成本
return tour, cost为何重要
- 对于度量 TSP(满足三角不等式)简单而有效。
- 是更好启发式算法(Christofides 算法,1.5-近似)的基础。
- 应用于路由、网络布线、机器人运动、配送物流等领域。
MST 启发式算法捕捉了高效连接的"骨架", 将其加倍确保了覆盖范围,而"捷径"处理则消除了冗余。
亲自尝试
- 生成随机的二维点并计算欧几里得距离。
- 将基于 MST 的 TSP 成本与暴力搜索最优解(对于 $n \le 10$)进行比较。
- 可视化 MST 与生成的 TSP 路径。
- 扩展到 Christofides 算法(在奇度顶点上添加最小匹配)。
测试用例
| 图类型 | 近似比 | 观察结果 |
|---|---|---|
| 4 城市欧几里得图 | ≤ 2× | 符合界限 |
| 10 个随机城市 | ≤ 2× | 始终有效 |
| 度量图 | ≤ 2× | 保证界限 |
| 非度量图 | > 2× | 界限不保证 |
复杂度
- 时间:MST 为 $O(E \log V)$ + 遍历为 $O(V)$
- 空间:$O(V)$
- 近似比:≤ 2
温和的证明(为何有效)
令 $T$ 为 MST,令 $H$ 为通过将 $T$ 中每条边加倍得到的欧拉回路。 由于 $T$ 是连通的,$H$ 至少访问每个顶点一次,且成本为 $2w(T)$。 通过对重复顶点进行"捷径"处理(利用三角不等式),我们得到一个哈密顿环,其成本 ≤ $2w(T)$。 由于 $w(T) \le w(\text{OPT})$,我们得出结论:
$$ w(\text{tour}) \le 2 w(\text{OPT}). $$
总结表
| 属性 | 值 |
|---|---|
| 算法类型 | 基于 MST 的启发式算法 |
| 近似比 | ≤ 2 |
| 要求条件 | 三角不等式 |
| 确定性 | 是 |
| 扩展 | Christofides (1.5× OPT) |
基于 MST 的 TSP 近似算法是数学美感与计算实用性之间的完美平衡, 它加倍了你需要的,然后修剪了你不需要的。
974. k-中心近似(最远点启发式)
k-中心问题提出:给定一组点和一个距离度量,我们如何选择 k 个中心,使得任意点到其最近中心的最大距离尽可能小? 这是一个经典的聚类和设施选址问题,是 NP 难问题,但可以使用简单的最远点启发式方法在 2 倍近似内求解。
我们要解决什么问题?
给定一组点 $V$ 和一个距离函数 $d(u,v)$, 选择 $k$ 个中心 $C = {c_1, c_2, \dots, c_k}$, 以最小化任意点到其最近中心的最大距离:
$$ r^* = \min_{C \subseteq V, |C|=k} \max_{v \in V} \min_{c \in C} d(v, c) $$
目标是确保没有点离所选中心太远。
这模拟了诸如以下的问题:
- 放置医院以最小化最远患者的距离
- 设计数据中心以实现最小延迟
- 建设具有保证覆盖半径的蜂窝基站
核心思想
- 从一个任意点作为第一个中心开始。
- 重复选择距离所有当前中心最远的点。
- 在选择 $k$ 个中心后停止。
这种贪心选择确保每个新中心都覆盖了迄今为止服务最差的区域。 结果距离最优半径最多只有两倍远。
分步示例
线上的点: $V = {0, 1, 2, 5, 8, 11}$ 且 $k = 2$。
算法:
| 步骤 | 已选中心 | 最远点 | 到最近中心的距离 | 步骤后的中心 |
|---|---|---|---|---|
| 1 | 任意选择 → {0} | 最远点 = 11 | 距离 = 11 | {0, 11} |
| 2 | 停止(2 个中心) | , | , | 最终 = {0, 11} |
达到的半径: 每个点到最近中心的距离 ≤ 5.5。 最优半径:5 → 所以比率 = 1.1 ≤ 2。
算法(伪代码)
GreedyKCenter(V, k):
选择任意点 v 作为第一个中心
C = {v}
while |C| < k:
选择 V 中点 x,使得 min_{c in C} d(x, c) 最大
将 x 加入 C
return C数学保证
令 $r^$ 为最优半径。 在每一步,算法选择一个距离现有中心至少 $r^$ 远的点, 确保所选中心彼此之间至少相距 $r^$。 当选择了 $k$ 个中心时,每个剩余点都在 $2r^$ 范围内。
因此:
$$ r_{\text{贪心}} \le 2r^* $$
微型代码
Python
import numpy as np
def k_center(points, k):
centers = [points[0]]
while len(centers) < k:
dist = [min(np.linalg.norm(p - c) for c in centers) for p in points]
next_center = points[np.argmax(dist)]
centers.append(next_center)
return np.array(centers)为何重要
- 组合优化中最简单的 2 倍近似算法之一
- 随输入规模线性扩展,非常适合聚类大型数据集
- 直观且几何化,反复覆盖最远的区域
- 是设施选址、图划分和分布式聚类的基础
亲自尝试
- 生成随机的二维点,并对不同的 $k$ 运行算法。
- 绘制中心点,它们应该均匀分布。
- 与 K-means 比较(最小化平均距离,而非最大距离)。
- 观察增加一个中心如何显著减小最大半径。
- 在图距离而非欧几里得距离上进行测试。
测试用例
| 数据集 | k | 近似半径 | 最优半径 | 比率 |
|---|---|---|---|---|
| 直线 [0–10] | 2 | 5.5 | 5.0 | 1.1 |
| 网格 3×3 | 3 | 1.9 | 1.0 | ≤ 2 |
| 随机 50 点 | 5 | ~2× 最优 | 一致 | |
| 城市图 | 4 | ≤ 2× | 对度量图成立 |
复杂度
- 时间:$O(k \vert V\vert ^2)$(可使用距离缓存优化)
- 空间:$O(\vert V\vert )$
- 近似比:≤ 2
温和的证明(为何有效)
令 $C^$ 为具有半径 $r^$ 的最优中心。 当贪心算法选择中心时,每个新中心必须位于某个最优中心周围半径为 $r^*$ 的不同球内。 因此,在选择 $k$ 个中心后,所有最优簇都被覆盖。 每个剩余点距离某个所选中心最多在两个半径范围内。
$$ r_{\text{贪心}} \le 2r^* $$
总结表
| 属性 | 值 |
|---|---|
| 算法类型 | 贪心(最远点) |
| 目标 | 最小化最大距离 |
| 近似比 | ≤ 2 |
| 是否需要度量 | 是(三角不等式) |
| 用例 | 覆盖、聚类、设施布置 |
最远点启发式抓住了覆盖问题的本质: 均匀扩展,先到达最远点,并保证每个点都有一个"家",其距离不超过最佳可能距离的两倍。
975. 在线分页(LRU – 最近最少使用)
在线分页问题模拟了操作系统或缓存如何在处理一系列请求时管理有限的快速内存(缓存)。 由于未来的请求是未知的,算法必须实时决定驱逐哪个项目,这是在线算法的一个核心例子。
我们要解决什么问题?
我们有以下设定:
- 一个最多能容纳 $k$ 个页面的缓存。
- 一个页面请求序列 $p_1, p_2, \dots, p_n$。
- 如果请求的页面不在缓存中(一次未命中),则成本为 1;如果在缓存中(一次命中),则成本为 0。
当缓存已满且请求一个新页面时,我们必须驱逐一个页面以腾出空间,但我们不知道未来的请求。
目标: 最小化缓存未命中的总次数。
核心思想(LRU 策略)
最近最少使用(LRU)策略驱逐最长时间未被访问的页面。 它依赖于时间局部性假设:最近使用的页面很可能很快再次被使用。
LRU 会记录访问顺序,并总是移除最旧的条目。
示例
缓存大小 $k = 3$ 请求序列:A, B, C, A, D, B, A, E
| 步骤 | 请求 | 操作前缓存 | 操作 | 操作后缓存 | 命中/未命中 |
|---|---|---|---|---|---|
| 1 | A | {} | 加载 A | {A} | 未命中 |
| 2 | B | {A} | 加载 B | {A,B} | 未命中 |
| 3 | C | {A,B} | 加载 C | {A,B,C} | 未命中 |
| 4 | A | {A,B,C} | 命中 | {A,B,C} | 命中 |
| 5 | D | {A,B,C} | 驱逐 B(最旧) | {A,C,D} | 未命中 |
| 6 | B | {A,C,D} | 驱逐 C | {A,D,B} | 未命中 |
| 7 | A | {A,D,B} | 命中 | {A,D,B} | 命中 |
| 8 | E | {A,D,B} | 驱逐 D | {A,B,E} | 未命中 |
总计:8 次请求,6 次未命中,2 次命中。
算法(伪代码)
LRU(缓存大小, 请求序列):
缓存 = 空列表
for 页面 in 请求序列:
if 页面 in 缓存:
将页面移到最前面(最近使用)
else:
if len(缓存) == 缓存大小:
移除最后一个元素(最近最少使用)
将页面插入到最前面数学保证
在线算法的竞争比将其性能与最优离线算法(知道未来请求)进行比较。
对于 LRU:
$$ \text{竞争比} = k $$
这意味着: LRU 的总成本最多是最优离线算法(记为 OPT)成本的 $k$ 倍。
微型代码
Python
from collections import deque
def lru(cache_size, requests):
cache = deque()
hits = 0
for page in requests:
if page in cache:
cache.remove(page)
cache.appendleft(page)
hits += 1
else:
if len(cache) == cache_size:
cache.pop()
cache.appendleft(page)
return hits, len(requests) - hits
reqs = ['A','B','C','A','D','B','A','E']
print(lru(3, reqs))为什么它很重要
- LRU 用于 CPU 缓存、操作系统内存管理、Web 缓存和数据库。
- 展示了在线算法的设计,即在没有未来知识的情况下进行决策。
- 对于研究竞争分析和最坏情况保证至关重要。
亲自尝试
- 比较 LRU、FIFO 和随机替换策略。
- 模拟具有重复访问模式和随机访问模式的序列。
- 增加缓存大小 $k$,观察未命中次数的减少。
- 通过实验测量竞争比。
测试用例
| 序列 | 缓存大小 | 算法 | 未命中次数 | 命中次数 |
|---|---|---|---|---|
| A,B,C,A,B,C | 2 | LRU | 4 | 2 |
| A,B,C,A,D,B | 3 | LRU | 5 | 1 |
| 随机 | 4 | LRU | ≈ k×OPT | , |
复杂度
- 时间复杂度:朴素实现 $O(nk)$,或使用哈希表 + 链表实现 $O(n)$
- 空间复杂度:$O(k)$
- 竞争比:≤ $k$
一个温和的证明(为什么它有效)
在任何序列中,考虑一次未命中之前访问的最后 $k$ 个不同的页面,这些页面也必须在最优缓存中。 由于 LRU 只驱逐最近最少使用的页面,它的性能最多比 OPT 差 $k$ 倍。
形式化地: $$ \text{成本(LRU)} \le k \cdot \text{成本(OPT)} $$
总结表
| 属性 | 值 |
|---|---|
| 类型 | 在线,确定性 |
| 竞争比 | ≤ k |
| 缓存策略 | 最近最少使用 |
| 在以下情况表现良好 | 基于局部性的序列 |
| 应用于 | 操作系统、CPU 缓存、数据库 |
LRU 是直觉与理论之间永恒的平衡, 它是一种算法,遗忘掉足够多的过去,以跟上不可预测的未来。
976. 在线匹配(排序算法)
在线二分图匹配问题模拟了这样一种场景:二分图的一侧(例如用户)一个接一个地到达,我们必须立即决定将他们与哪个资源(例如服务器或广告位)进行匹配,而无法预知未来的到达情况。 排序算法实现了 $(1 - 1/e)$ 的竞争比,这是此设置下随机算法所能达到的最佳可能。
我们要解决什么问题?
我们给定一个二分图 $$ G = (U, V, E) $$ 其中 $U$(离线顶点)是预先已知的,而 $V$(在线顶点)一个接一个地到达。
当每个顶点 $v \in V$ 到达时:
- 我们看到它所有的关联边 $(u,v)$,其中 $u \in U$。
- 我们必须立即且不可撤销地决定是否将 $v$ 与一个未匹配的 $u$ 进行匹配。
目标: 在结束时最大化匹配的总数。
核心思想(排序算法)
排序算法是由 Karp、Vazirani 和 Vazirani(1990)提出的一种随机策略。 它在开始时为离线顶点分配一次随机排序,并利用该排序来一致地打破平局。
算法概要:
随机排列离线顶点 $U$。
对于每个到达的顶点 $v \in V$:
- 在所有当前可用的邻居中,将 $v$ 匹配给排名最高(编号最小)的那个。
这个简单的策略实现了 $(1 - 1/e) ≈ 0.632$ 的竞争比,被证明是该问题的最优解。
示例
令 $U = {A, B, C}$,$V = {1, 2, 3}$ 按顺序到达。
边:
- 1 连接到 {A, B}
- 2 连接到 {B, C}
- 3 连接到 {A, C}
$U$ 的随机排序:A=1, B=2, C=3
逐步过程:
| 到达顶点 | 邻居 | 是否空闲? | 选择的匹配 |
|---|---|---|---|
| 1 | {A, B} | 全部空闲 | A(排名最高) |
| 2 | {B, C} | 全部空闲 | B |
| 3 | {A, C} | A 已用,C 空闲 | C |
最终匹配 = {(1,A), (2,B), (3,C)}(完美匹配)。
算法(伪代码)
RankingMatching(U, V, E):
为每个 u in U 分配随机排序 π(u)
for each arriving v in V:
N = {u ∈ U | (u,v) ∈ E and u unmatched}
if N ≠ ∅:
选择 N 中 π(u) 最小的 u
匹配 (u,v)数学保证
令 OPT 表示最优离线算法(知道完整到达序列)所能实现的匹配数。
那么:
$$ E[\text{matches(Ranking)}] \ge (1 - 1/e) \cdot \text{OPT} $$
对于所有在对抗性到达顺序下的随机在线算法,这个界限是紧的。
微型代码
Python
import random
def online_matching_ranking(U, edges, arrivals):
# 为离线顶点分配随机排序
rank = {u: i for i, u in enumerate(random.sample(U, len(U)))}
matched = {}
for v in arrivals:
# 找出当前可匹配的候选顶点
candidates = [u for u in U if (u, v) in edges and u not in matched.values()]
if candidates:
# 选择排序值最小的顶点
chosen = min(candidates, key=lambda u: rank[u])
matched[v] = chosen
return matched为何重要
- 在线广告、任务分配、资源分配等领域的基础。
- 平衡随机性和贪婪性以实现可证明的保证。
- 竞争分析和在线学习算法的基础模型。
动手尝试
- 构造一个允许多种匹配可能的二分图。
- 模拟不同顺序的顶点到达。
- 比较排序算法与贪婪算法(匹配到任意可用顶点)。
- 测量随机排序下的平均匹配数。
- 观察在对抗性序列中,排序算法始终优于贪婪算法。
测试用例
| 场景 | 算法 | 匹配数 | 竞争比 |
|---|---|---|---|
| 简单的 3×3 完全图 | 排序算法 | 3 | 1.0 |
| 对抗性顺序 | 排序算法 | 0.63×OPT | ≈ (1 - 1/e) |
| 随机图 | 排序算法 | ≥ 0.63×OPT | 一致 |
复杂度
- 时间:$O(\vert E\vert )$
- 空间:$O(\vert U\vert + \vert V\vert )$
- 竞争比:$(1 - 1/e)$
一个温和的证明(为何有效)
随机排序使得每个离线顶点在其最佳可能的在线伙伴到达时,都有平等的机会可用。 通过概率分析,匹配顶点的期望比例满足:
$$ \frac{dM}{dx} = 1 - M, \quad \Rightarrow \quad M = 1 - e^{-x} $$
在满容量时($x=1$),$M = 1 - 1/e$。 因此,期望性能是最优解的 $(1 - 1/e)$ 倍。
总结表
| 属性 | 值 |
|---|---|
| 类型 | 在线随机算法 |
| 竞争比 | $(1 - 1/e)$ |
| 确定性版本 | 贪婪算法(较差:0.5×OPT) |
| 应用领域 | 在线广告、资源分配 |
| 关键思想 | 预先为离线侧排序一次 |
排序算法是在线优化领域的一颗明珠, 初始的简单随机性带来了对未来任何情况的深刻鲁棒性。
977. 在线背包问题(基于比率的接受策略)
在线背包问题模拟了容量约束下的实时决策过程: 物品一个接一个地到达,每个物品都有价值和重量,你必须立即决定是否接受它,而不知道接下来会出现什么。 这个问题捕捉了广告拍卖、作业调度和不确定性下的实时资源分配等场景。
我们解决的是什么问题?
给定:
- 一个容量为 $W$ 的背包
- 一个按顺序到达的物品序列 $(v_i, w_i)$,每次一个 其中 $v_i$ 是价值,$w_i$ 是重量。
对于每个物品:
- 如果接受,它将消耗 $w_i$ 的容量。
- 决策是不可撤销的,我们之后不能移除物品。
目标: 在保持总重量 $\le W$ 的前提下,最大化所接受物品的总价值。
这是经典 0/1 背包问题的在线版本。
核心思想(贪心比率阈值)
在没有未来知识的情况下,我们无法完美规划。 相反,我们使用基于价值重量比的阈值策略:
$$ \rho_i = \frac{v_i}{w_i} $$
只接受比率 $\rho_i$ 高于动态阈值的物品,该阈值随着背包被逐渐填满而降低。
算法直觉
将背包视为逐渐被填满。 起初,我们很挑剔,只接受高价值的物品。 随着容量减少,我们降低阈值并接受价值较低的物品。
如果我们定义 $x$ 为已填满的容量比例, 那么一个常见的阈值规则是:
$$ \rho(x) = \rho_{\max} e^{x-1} $$
到达的物品如果满足 $\rho_i \ge \rho(x)$ 则被接受。
这确保了我们在平滑使用容量的同时,保留了接近最优的价值。
示例
容量 $W = 10$
| 物品 | 价值 ($v_i$) | 重量 ($w_i$) | 比率 ($v_i/w_i$) | 决策 |
|---|---|---|---|---|
| 1 | 20 | 4 | 5.0 | 接受 |
| 2 | 10 | 4 | 2.5 | 拒绝(比率太低) |
| 3 | 15 | 3 | 5.0 | 接受 |
| 4 | 8 | 4 | 2.0 | 拒绝 |
| 5 | 12 | 3 | 4.0 | 接受(有足够剩余空间) |
总价值 = 20 + 15 + 12 = 47 总重量 = 4 + 3 + 3 = 10
算法(伪代码)
OnlineKnapsack(W, items):
used = 0
accepted = []
for (v, w) in items:
rho = v / w
x = used / W
threshold = rho_max * exp(x - 1)
if used + w <= W and rho >= threshold:
accepted.append((v, w))
used += w
return accepted竞争比
令 OPT 为最优离线算法获得的价值。
对于连续到达和可微的阈值函数, 指数阈值规则实现了 $(1 - 1/e)$ 的竞争比, 与在线匹配问题相同。
形式化地:
$$ \frac{E[\text{ALG}]}{\text{OPT}} \ge 1 - \frac{1}{e} \approx 0.632 $$
精简代码
Python
import math
def online_knapsack(items, W, rho_max):
used, value = 0, 0
for v, w in items:
rho = v / w
x = used / W
threshold = rho_max * math.exp(x - 1)
if used + w <= W and rho >= threshold:
used += w
value += v
return value为什么它重要
- 现实世界的系统(广告、云作业、CPU 时间)必须立即做出分配决策。
- 展示了指数阈值在线优化中的强大能力。
- 与竞争分析、先知不等式和机制设计紧密相连。
亲自尝试
- 模拟 100 个具有随机重量和价值的物品。
- 将你的在线规则与离线最优解(按比率排序)进行比较。
- 调整衰减函数 $\rho(x)$ 以观察其影响。
- 观察指数衰减如何持续实现接近最优的结果。
测试用例
| 物品类型 | W | 算法 | 竞争比 |
|---|---|---|---|
| 随机均匀分布 | 10 | 指数阈值 | ≈ 0.63×OPT |
| 广告流 | 100 | 基于比率 | 0.6–0.65 |
| 恒定价值 | 20 | 任何策略 | ≈ OPT |
复杂度
- 时间:$O(n)$(单次遍历)
- 空间:$O(1)$(仅存储当前填充比例)
- 竞争比:$(1 - 1/e)$
一个温和的证明(为什么它有效)
令 $f(x)$ 表示在填充比例为 $x$ 时的总期望价值。 在指数阈值规则下对 $x$ 求导:
$$ \frac{df}{dx} = \rho_{\max} e^{x-1} $$
在 $x \in [0,1]$ 上积分得到:
$$ f(1) = \rho_{\max} (1 - 1/e) $$
因此,总期望价值达到了最佳可能值的 $(1 - 1/e)$。
总结表
| 属性 | 值 |
|---|---|
| 类型 | 在线,基于比率 |
| 竞争比 | $(1 - 1/e)$ |
| 决策规则 | 基于 $v_i/w_i$ 的阈值 |
| 应用领域 | 广告拍卖、调度、动态定价 |
| 最优性 | 对于对抗性输入是紧的 |
在线背包问题体现了权衡的艺术, 在贪婪与耐心之间取得平衡,在未来展开之前最大化价值。
978. 竞争比评估
竞争比是衡量在线算法性能的核心工具,在线算法是指那些必须在不知道未来的情况下做出决策的算法。 它提供了一种形式化的方法,将在线算法的结果与最优离线算法(预先知道整个输入序列)的结果进行比较。
我们解决的是什么问题?
在在线问题中,数据按顺序到达,算法必须立即做出反应。
例子包括:
- 在线分页(LRU):在没有未来访问信息的情况下淘汰页面
- 在线匹配(Ranking):实时为用户分配广告
- 在线背包问题:决定是否接受新物品
- 在线调度:在作业到达时将其分配给机器
由于没有未来的知识,我们不能用传统意义上的"最优性"来讨论。 相反,我们衡量在线算法能多接近离线最优解。
定义
令:
- $\text{ALG}(I)$ = 在线算法在输入 $I$ 上的成本(或收益)
- $\text{OPT}(I)$ = 最优离线算法(知道未来)的成本(或收益)
那么,竞争比 $c$ 定义为:
对于最小化问题: $$ c = \sup_I \frac{\text{ALG}(I)}{\text{OPT}(I)} $$
或者等价地,对于最大化问题: $$ c = \inf_I \frac{\text{ALG}(I)}{\text{OPT}(I)} $$
直观含义
- 如果 $c = 1$,在线算法与离线算法一样好,达到完美最优。
- 如果 $c = 2$,其最坏表现是离线算法的两倍差。
- 如果 $c = (1 - 1/e)$,它达到了离线最优解的约 63%,这在随机化算法(如在线匹配或背包问题)中很常见。
因此,$c$ 越接近 1,算法越好。
示例 1 – 分页(LRU)
对于一个大小为 $k$ 的缓存: $$ \text{Cost(LRU)} \le k \cdot \text{Cost(OPT)} $$
所以竞争比是 $k$。
LRU 的表现可能比最优离线缓存差 $k$ 倍,但没有确定性算法能做得更好。
示例 2 – 在线匹配(Ranking)
$$ E[\text{ALG}] \ge (1 - 1/e) \cdot \text{OPT} $$
所以竞争比是 $(1 - 1/e) \approx 0.632$。
这被证明是随机化在线匹配算法的最优竞争比。
示例 3 – 滑雪租赁问题
你可以每天以成本 $r$ 租赁滑雪板,或者一次性以成本 $b$ 购买。
最优策略:
- 租赁直到总租赁成本等于 $b$,然后购买。
竞争比:
$$ c = \frac{2b - r}{b} \approx 2 - \frac{r}{b} $$
这给出了一个 2-竞争的确定性界限。
算法(评估框架)
评估算法的竞争比:
- 定义输入空间 $\mathcal{I}$(所有可能的请求序列)。
- 为每个 $I$ 定义 $\text{ALG}(I)$ 和 $\text{OPT}(I)$。
- 计算所有输入中的最坏情况比率: $$ c = \max_I \frac{\text{ALG}(I)}{\text{OPT}(I)} $$
- 可选地,对于随机化算法,计算期望竞争比。
微型代码(模拟示例)
Python
def competitive_ratio(alg_func, opt_func, inputs):
worst_ratio = 0
for I in inputs:
alg = alg_func(I)
opt = opt_func(I)
ratio = alg / opt if opt > 0 else float('inf') # 如果 opt 大于 0 则计算比率,否则设为无穷大
worst_ratio = max(worst_ratio, ratio)
return worst_ratio这个框架可以模拟多个输入以找到经验比率。
为什么它很重要
- 竞争比为不确定环境提供了理论保证。
- 有助于在无需假设概率输入的情况下公平地比较算法。
- 连接理论与系统:LRU、调度、缓存和动态定价都依赖于它。
动手尝试
- 实现在线分页、背包问题或匹配算法。
- 模拟对抗性输入以找到最坏情况。
- 计算比率 $\text{ALG}/\text{OPT}$。
- 观察随机化如何改善平均比率。
测试用例
| 问题 | 算法 | 竞争比 |
|---|---|---|
| 分页 | LRU | $k$ |
| 在线匹配 | Ranking | $1 - 1/e$ |
| 滑雪租赁 | Rent-then-buy | $2$ |
| 在线背包问题 | Ratio threshold | $1 - 1/e$ |
| 负载均衡 | Greedy | $2 - 1/m$ |
复杂度
评估竞争比通常需要:
- 通过动态规划或线性规划计算离线最优解
- 通过模拟运行在线算法
- 在生成的输入序列中搜索最坏情况
时间复杂度取决于离线算法(通常是 $O(n^2)$ 或更高)。
一个温和的证明(核心思想)
对于任何在线算法 $\text{ALG}$, 竞争比界定了其相对低效性:
$$ \forall I, \quad \text{ALG}(I) \le c \cdot \text{OPT}(I) $$
如果 $c$ 很小,即使面对对抗者,$\text{ALG}$ 也接近最优。 对于随机化算法,保证是在期望意义上的:
$$ E[\text{ALG}(I)] \le c \cdot \text{OPT}(I) $$
总结表
| 概念 | 定义 |
|---|---|
| 度量 | $\text{ALG}/\text{OPT}$ 比率 |
| 目的 | 量化在线低效性 |
| 确定性界限 | 最坏情况比率 |
| 随机化界限 | 期望比率 |
| 理想值 | 1(完美) |
| 常见值 | 2, $1 - 1/e$, $k$ |
竞争比是在线算法的哲学罗盘, 它不承诺确定性,而是衡量我们面对未知时的优雅程度。
979. PTAS 与 FPTAS 方案(多项式时间近似)
并非所有优化问题都能被高效解决。 有些问题是 NP 难的,这意味着没有已知算法能在多项式时间内找到精确解。 但在许多实际场景中,我们并不需要完美的答案,只需要一个足够好的答案。 这就是近似方案发挥作用的地方:这些算法能在可控精度内,任意接近最优解。
我们要解决什么问题?
给定一个优化问题(通常是 NP 难的),我们希望有一个算法能产生一个解,其值在最优解的 $\varepsilon$ 比例范围内。
对于最小化问题,目标是:
$$ \frac{\text{ALG}}{\text{OPT}} \le 1 + \varepsilon $$
对于最大化问题,目标是:
$$ \frac{\text{ALG}}{\text{OPT}} \ge 1 - \varepsilon $$
这里,$\varepsilon > 0$ 是用户选择的误差容忍度。
概览
主要有两种类型:
| 类型 | 定义 | 运行时间 |
|---|---|---|
| PTAS(多项式时间近似方案) | 对于任意 $\varepsilon > 0$,运行时间是 $n$ 的多项式,但可能是 $1/\varepsilon$ 的指数级。 | $O(n^{f(1/\varepsilon)})$ |
| FPTAS(完全多项式时间近似方案) | 运行时间在 $n$ 和 $1/\varepsilon$ 上都是多项式。 | $O(\text{poly}(n, 1/\varepsilon))$ |
因此 FPTAS ⊂ PTAS,它是一个更严格、更高效的子类。
示例 1 – 背包问题
对于经典的 0/1 背包问题,我们可以通过缩放价值来使用 FPTAS。
原始问题:
$$ \max \sum_i v_i x_i \quad \text{s.t.} \quad \sum_i w_i x_i \le W, \quad x_i \in {0,1} $$
我们按因子 $K = \frac{\varepsilon \cdot V_{\max}}{n}$ 缩小物品价值并取整:
$$ v_i' = \left\lfloor \frac{v_i}{K} \right\rfloor $$
然后在这些缩放后的价值上运行动态规划算法,这些价值现在范围更小。 结果保证:
$$ (1 - \varepsilon) \cdot \text{OPT} \le \text{ALG} \le \text{OPT} $$
运行时间为:
$$ O\left( \frac{n^3}{\varepsilon} \right) $$
示例 2 – 欧几里得旅行商问题 (TSP)
对于平面上的点(欧几里得度量),Arora (1996) 开发了一个 PTAS。 通过巧妙地将平面细分为网格并限制穿过每个边界的路径,它实现了:
$$ \text{ALG} \le (1 + \varepsilon) \cdot \text{OPT} $$
运行时间为:
$$ O(n (\log n)^{O(1/\varepsilon)}) $$
这是一个里程碑式的结果,TSP 是 NP 难的,但在几何上却能高效地获得接近最优的解。
示例 3 – 机器上的作业调度
问题:将 $n$ 个作业分配给 $m$ 台相同的机器,以最小化完工时间(最大负载)。 可以通过以下方式构建 PTAS:
- 通过枚举最优地调度最大的 $k = O(1/\varepsilon^2)$ 个作业。
- 贪心地调度其余作业。
这给出了:
$$ \text{ALG} \le (1 + \varepsilon) \cdot \text{OPT} $$
在多项式时间内。
算法(通用方案)
PTAS(问题, ε):
对于每个可能的简化配置(受限于 f(1/ε)):
最优地解决缩减后的子问题
返回找到的最佳解FPTAS 使用缩放技巧来降低数值精度,使得动态规划在 $1/ε$ 上保持多项式复杂度。
微型代码(背包问题的 FPTAS)
Python
def knapsack_fptas(values, weights, W, ε):
n = len(values)
vmax = max(values)
K = ε * vmax / n
scaled = [int(v / K) for v in values]
Vsum = sum(scaled)
dp = [float('inf')] * (Vsum + 1)
dp[0] = 0
for i in range(n):
for v in range(Vsum, scaled[i] - 1, -1):
dp[v] = min(dp[v], dp[v - scaled[i]] + weights[i])
for v in range(Vsum, -1, -1):
if dp[v] <= W:
return v * K为什么重要
- PTAS 和 FPTAS 让我们能够实际解决不可能的问题。
- 它们在速度和精度之间提供了权衡。
- 广泛应用于优化、运筹学、AI 规划和数据科学。
- 它们在理论(NP 难性)和实践(快速获得良好解)之间架起了桥梁。
亲自尝试
- 在小规模背包实例上运行 FPTAS。
- 改变 $\varepsilon$ 并观察运行时间和质量如何变化。
- 与精确的 DP 解进行比较。
- 观察小的 $\varepsilon$ 如何迅速增加计算量。
测试用例
| 问题 | 方案 | 保证 | 类型 |
|---|---|---|---|
| 背包 | 缩放 DP | $(1 - \varepsilon)$ | FPTAS |
| 欧几里得 TSP | Arora (1996) | $(1 + \varepsilon)$ | PTAS |
| 作业调度 | 枚举 + 贪心 | $(1 + \varepsilon)$ | PTAS |
| 装箱 | 取整 + 首次适应 | $(1 + \varepsilon)$ | PTAS |
复杂度
| 方案 | 时间 | 示例问题 |
|---|---|---|
| PTAS | $O(n^{f(1/\varepsilon)})$ | TSP |
| FPTAS | $O(\text{poly}(n, 1/\varepsilon))$ | 背包 |
| 常数近似 | $O(n)$ | 贪心集合覆盖 |
一个温和的证明(为什么有效)
近似方案依赖于取整和界定。 令 $\text{OPT}$ 为最优值,$\text{ALG}$ 为近似值。
如果每个物品的价值被取整到其真实值的 $(1 - \varepsilon)$ 范围内:
$$ \text{ALG} \ge (1 - \varepsilon) \cdot \text{OPT} $$
由于取整误差是线性累积的,而非指数级的, 精度随 $\varepsilon$ 优雅地缩放,保持了多项式运行时间。
总结表
| 属性 | PTAS | FPTAS |
|---|---|---|
| 精度 | 任意 $\varepsilon$ | 任意 $\varepsilon$ |
| 时间 | Poly($n$), exp($1/\varepsilon$) | Poly($n, 1/\varepsilon$) |
| 保证 | $(1 \pm \varepsilon)$ | $(1 \pm \varepsilon)$ |
| 示例 | 欧几里得 TSP | 背包 |
| 用例 | 理论与几何 | 实际数值 |
近似方案提醒我们,完美往往不切实际, 但接近完美却可以非常高效。
980. 原始-对偶方法(近似组合优化)
原始-对偶方法是设计 NP 难问题近似算法的一个强大框架。它不精确求解优化问题,而是同时构建原始和对偶线性规划,维持可行或接近可行的解,并在成本和覆盖范围之间达到平衡时停止。
我们要解决什么问题?
许多组合优化问题可以表示为线性规划(LP)。
原始线性规划可能如下所示:
$$ \min c^T x \quad \text{s.t.} \quad A x \ge b, ; x \ge 0 $$
其对偶线性规划为:
$$ \max b^T y \quad \text{s.t.} \quad A^T y \le c, ; y \ge 0 $$
原始-对偶方法同时为两者构建解,维持 $x$ 和 $y$ 之间的关系,使它们的成本保持接近。
即使我们无法精确求解线性规划,这也能提供近似保证。
直观思想
将原始问题和对偶问题视为两个参与者:
- 原始参与者选择元素(边、集合、设施)以满足约束。
- 对偶参与者提高未满足约束的价格或惩罚。
算法迭代地提高对偶变量,直到某个约束变得“紧”,然后添加相应的原始变量(例如,选择一条边,开设一个设施)。此过程重复直到所有约束都得到满足。
总原始成本与总对偶值之间的比率给出了近似因子。
示例 – 顶点覆盖问题
目标:选择覆盖图 $G = (V, E)$ 中所有边的最小顶点集合。
原始问题(最小化):
$$ \begin{aligned} \text{最小化} \quad & \sum_{v \in V} x_v
\text{约束条件} \quad & x_u + x_v \ge 1 \quad \forall (u,v) \in E
& x_v \ge 0 \end{aligned} $$
对偶问题(最大化):
$$ \begin{aligned} \text{最大化} \quad & \sum_{(u,v) \in E} y_{uv}
\text{约束条件} \quad & \sum_{(u,v): v \in (u,v)} y_{uv} \le 1 \quad \forall v \in V
& y_{uv} \ge 0 \end{aligned} $$
算法(原始-对偶):
- 初始化所有 $y_{uv} = 0$,所有边未被覆盖。
- 提高未被覆盖边的 $y_{uv}$,直到某个顶点约束变紧(关联的 $y_{uv}$ 之和 = 1)。
- 将该顶点添加到覆盖中(设置 $x_v = 1$)。
- 重复直到所有边都被覆盖。
结束时:
- 对偶成本 = 提高的 $y_{uv}$ 总和
- 原始成本 = 所选顶点数量
该算法产生一个 2-近似,因为每条边最多“收费”两次。
示例 – 集合覆盖问题
集合 $S_1, S_2, \dots, S_m$ 具有成本 $c_i$,需要覆盖所有元素 $U$。
原始问题:
$$ \min \sum_i c_i x_i \quad \text{s.t.} \quad \sum_{i: e \in S_i} x_i \ge 1, ; x_i \ge 0 $$
对偶问题:
$$ \max \sum_{e \in U} y_e \quad \text{s.t.} \quad \sum_{e \in S_i} y_e \le c_i, ; y_e \ge 0 $$
算法概览:
- 从 $y_e = 0$ 开始。
- 均匀提高未被覆盖元素的 $y_e$,直到某个集合约束变紧($\sum_{e \in S_i} y_e = c_i$)。
- 将该集合 $S_i$ 选入覆盖。
- 重复直到所有元素都被覆盖。
近似比: $$ H_n = 1 + \frac{1}{2} + \frac{1}{3} + \dots + \frac{1}{n} = O(\log n) $$ 与贪心算法相同。
小段代码(简化顶点覆盖示例)
Python
def primal_dual_vertex_cover(edges, n):
y = {e: 0 for e in edges} # 初始化所有边对应的对偶变量 y 为 0
cover = set() # 初始化覆盖集合为空
while edges: # 当还有边未被覆盖时
(u, v) = edges.pop() # 取出一条边
if u not in cover and v not in cover: # 如果该边的两个端点都未被覆盖
cover.add(u) # 将端点 u 加入覆盖
cover.add(v) # 将端点 v 加入覆盖
return cover # 返回覆盖集合这种对“覆盖两个端点”的简单原始-对偶解释产生了一个 2-近似。
为什么它很重要
- 提供了一种构造性的、组合的方式来推导近似界。
- 避免了直接求解线性规划,同时仍然利用了线性规划的结构。
- 为 NP 难问题带来了许多已知的最佳结果:
- 顶点覆盖的 2-近似
- 集合覆盖的 $O(\log n)$ 近似
- 设施选址和斯坦纳树的常数因子近似
动手尝试
- 推导设施选址问题的原始形式和对偶形式。
- 实现一个提高客户“价格”的原始-对偶算法。
- 跟踪设施开设约束何时变紧。
- 将解的成本与线性规划松弛解进行比较。
测试用例
| 问题 | 近似比 | 方法 |
|---|---|---|
| 顶点覆盖 | 2 | 基于紧性的原始-对偶 |
| 集合覆盖 | $O(\log n)$ | 对偶提升 |
| 设施选址 | 1.61 | Jain–Vazirani 原始-对偶 |
| 斯坦纳树 | 2 | 边增长 |
复杂度
- 时间:对于图问题为 $O(\vert E\vert + \vert V\vert )$,对于线性规划结构问题为多项式时间
- 空间:$O(\vert V\vert )$
- 近似比:取决于问题,通常是常数或对数级别
一个温和的证明(为什么它有效)
根据弱对偶性:
$$ b^T y \le c^T x $$
在每一步,对偶值增长直到某个约束变紧,这确保了:
$$ c^T x \le \alpha \cdot b^T y $$
对于某个 $\alpha$,即近似因子。这成立是因为每个原始约束在其对应的对偶变量停止增加时即得到满足。
例如,在顶点覆盖中,$\alpha = 2$,因为每条边可以贡献给两个顶点。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 同时构建原始和对偶线性规划 |
| 策略 | 提高对偶变量直到原始约束变紧 |
| 保证 | $\text{cost(ALG)} \le \alpha \cdot \text{OPT}$ |
| 常见 $\alpha$ | 2(顶点覆盖),$O(\log n)$(集合覆盖) |
| 优势 | 无需直接求解线性规划 |
原始-对偶方法是近似理论中默默无闻的支柱,它平衡着同一问题的两面镜子,直到它们的映像呈现出近乎最优的美感。
第 99 节. 公平性、因果推断与鲁棒优化
981. 公平性重加权
重加权是一种简单而强大的算法策略,用于减少机器学习模型中的偏见。它调整训练样本的重要性权重,使得不同的人口统计或敏感群体对学习过程的贡献均等。
这构成了预处理公平性方法的基础,即在训练前调整数据以纠正表征或结果中的不平衡。
我们要解决什么问题?
现实世界的数据集常常反映历史偏见。例如,一个贷款审批数据集可能由于系统性歧视,对某个特定群体包含较少的正例。直接在此类数据上训练会导致不公平的预测。
我们想要一种方法,在保持准确性的同时,使模型在不同群体(例如,性别、种族或年龄)间的结果更加公平。
基本思想
如果数据存在偏见,那么某些群体成员身份和标签的组合就会代表性不足。重加权旨在纠正这种不平衡。
每个实例获得一个权重:
$$ w(x, a, y) = \frac{P(A=a) , P(Y=y)}{P(A=a, Y=y)} $$
其中:
- $A$ = 受保护属性(例如,性别)
- $Y$ = 真实标签(例如,贷款获批)
- $P(A=a, Y=y)$ = 观测数据中的联合分布
- $P(A=a) P(Y=y)$ = 边际分布的乘积,即如果 $A$ 和 $Y$ 独立时我们期望的分布
这种调整打破了训练数据中敏感属性与标签之间的依赖关系。
工作原理(通俗解释)
- 统计数据中每对 $(A=a, Y=y)$ 出现的频率。
- 计算在独立假设下的期望频率:$P(A=a) P(Y=y)$。
- 计算重加权因子 $w(a, y)$ = 期望频率与实际频率之比。
- 在训练模型时应用这些权重,每个样本的损失贡献按其权重进行缩放。
这确保了: $$ A \perp Y \quad \text{(在期望上强制统计独立性)} $$
示例
假设:
| 群体 | 标签 = 1 | 标签 = 0 | 总计 |
|---|---|---|---|
| A=男性 | 700 | 300 | 1000 |
| A=女性 | 300 | 700 | 1000 |
边际分布:
- $P(A=\text{男性}) = P(A=\text{女性}) = 0.5$
- $P(Y=1) = 0.5$, $P(Y=0) = 0.5$
那么在独立假设下,理想的联合分布是每个 $(A,Y)$ 组合均为 $0.25$。 观测概率:
| (A,Y) | 观测值 | 期望值 | 权重 |
|---|---|---|---|
| (男性,1) | 0.35 | 0.25 | 0.714 |
| (男性,0) | 0.15 | 0.25 | 1.667 |
| (女性,1) | 0.15 | 0.25 | 1.667 |
| (女性,0) | 0.35 | 0.25 | 0.714 |
在训练期间,每个实例都相应地加权。这使得男性和女性群体在正负结果上的贡献达到平衡。
微型代码
Python(使用 sklearn)
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.utils.class_weight import compute_sample_weight
# 示例数据
A = np.array(['M', 'M', 'F', 'F', 'M', 'F'])
Y = np.array([1, 0, 1, 0, 1, 0])
# 计算重加权因子
unique_A, unique_Y = np.unique(A), np.unique(Y)
weights = np.zeros(len(A))
for a in unique_A:
for y in unique_Y:
idx = np.where((A == a) & (Y == y))[0]
pa = np.mean(A == a)
py = np.mean(Y == y)
pay = len(idx) / len(A)
w = (pa * py) / pay
weights[idx] = w
# 训练加权模型
clf = LogisticRegression()
clf.fit(np.ones((len(A), 1)), Y, sample_weight=weights)为何重要
- 它在模型训练之前(预处理阶段)减少偏见。
- 适用于任何支持加权样本的分类器。
- 保持可解释性和可控性,易于解释和审计。
- 为更高级的方法(如对抗性去偏见或公平重加权(Zafar 等人))奠定了基础。
亲自尝试
- 为你的数据集计算重加权因子。
- 比较重加权前后模型的准确性。
- 评估公平性指标(例如,人口统计均等差异、机会均等)。
- 调整 $\varepsilon$ 阈值以找到可接受的公平性-准确性权衡点。
测试案例
| 数据集 | 公平性指标 | 重加权前 | 重加权后 |
|---|---|---|---|
| Adult Income | 人口统计均等 | 0.23 | 0.05 |
| COMPAS | 机会均等 | 0.18 | 0.07 |
| Loan Approval | 统计均等 | 0.20 | 0.04 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 统计群体-标签频率 | $O(n)$ | $O(k)$ |
| 计算权重 | $O(k)$ | $O(k)$ |
| 加权训练 | 取决于模型 |
通常与训练时间相比,开销可以忽略不计。
温和的证明(为何有效)
公平性校正依赖于使联合分布 $(A, Y)$ 分解为边际分布:
$$ P(A, Y) = P(A)P(Y) $$
通过分配权重:
$$ w(a, y) = \frac{P(A=a)P(Y=y)}{P(A=a, Y=y)} $$
加权经验分布 $\hat{P}_w$ 满足:
$$ \hat{P}_w(A=a, Y=y) = P(A=a) P(Y=y) $$
因此,任何最小化加权损失的模型都是在更公平、更平衡的分布下学习的。
总结表
| 概念 | 描述 |
|---|---|
| 目标 | 移除 $A$(群体)和 $Y$(标签)之间的依赖关系 |
| 公式 | $w(a,y) = \frac{P(A=a) P(Y=y)}{P(A=a, Y=y)}$ |
| 类型 | 预处理公平性 |
| 优点 | 简单,与模型无关 |
| 保证 | 人口统计均等(近似) |
重加权是通过再平衡实现公平性:不是改变世界,而是改变模型看待世界的视角。
982. 人口统计均等约束
人口统计均等(Demographic Parity,简称 DP),也称为统计均等,是机器学习中最基本的公平性标准之一。 它确保预测结果独立于敏感属性,如性别、种族或年龄。
简而言之:模型应为所有群体提供正结果的比率相同。
我们要解决什么问题?
即使模型准确,它们也可能产生有偏见的预测。 例如:
- 一个贷款审批模型可能批准 80% 的男性申请人,但只批准 40% 的女性申请人。
- 一个招聘算法可能偏爱更年轻的候选人,即使他们资质相同。
人口统计均等旨在通过约束模型,使其在不同群体间产生相同的接受率,来消除这些差异。
公平性条件
令:
- $A$ = 敏感属性(例如,性别)
- $\hat{Y}$ = 模型的预测标签(例如,批准 = 1,拒绝 = 0)
那么人口统计均等要求:
$$ P(\hat{Y} = 1 \mid A = 0) = P(\hat{Y} = 1 \mid A = 1) $$
也就是说,无论 $A$ 如何,获得正结果的概率应该相同。 在实践中,我们稍微放宽要求,允许一个容差 $\varepsilon$:
$$ \left| P(\hat{Y} = 1 \mid A = 0) - P(\hat{Y} = 1 \mid A = 1) \right| \le \varepsilon $$
直观示例
假设我们在求职申请上训练一个分类器。 应用 DP 约束前的结果:
| 群体 | 正结果比率 |
|---|---|
| 男性 | 0.70 |
| 女性 | 0.45 |
DP 要求调整模型或阈值,使两个群体获得大致相等的正结果比率(例如 0.57 ± 0.02)。
我们可以通过以下方式实现:
- 按组改变决策阈值,或者
- 在训练期间向损失函数添加约束。
带有人口统计均等惩罚的损失
标准的经验损失是:
$$ L = \frac{1}{n} \sum_i \ell(f(x_i), y_i) $$
我们添加一个公平性惩罚项:
$$ L_{\text{fair}} = L + \lambda , \left| , E[\hat{Y} | A = 0] - E[\hat{Y} | A = 1] , \right| $$
其中 $\lambda$ 控制准确性和公平性之间的权衡。 更高的 $\lambda$ 会以模型性能为代价强制更强的公平性。
微型代码(带 DP 惩罚的公平逻辑回归)
Python(简化版)
import torch
import torch.nn as nn
import torch.optim as optim
class FairLogReg(nn.Module):
def __init__(self, d):
super().__init__()
self.w = nn.Linear(d, 1)
def forward(self, x):
return torch.sigmoid(self.w(x))
def demographic_parity_penalty(pred, A):
# 计算每组的平均正结果率
p0 = pred[A == 0].mean()
p1 = pred[A == 1].mean()
return torch.abs(p0 - p1)
# 训练循环
model = FairLogReg(d=5)
opt = optim.Adam(model.parameters(), lr=0.01)
λ = 1.0
for x, y, A in data_loader:
pred = model(x).squeeze()
loss = nn.BCELoss()(pred, y)
dp_penalty = demographic_parity_penalty(pred, A)
total_loss = loss + λ * dp_penalty
opt.zero_grad()
total_loss.backward()
opt.step()该模型在学习的同时,积极惩罚预测率中的组级不平衡。
为什么这很重要
- 防止结果中的歧视,而不仅仅是标签中的歧视。
- 在许多系统(例如信用评分、广告)中作为一线公平性约束。
- 构成了其他标准(如均等几率、预测均等)的基础。
- 当群体成员身份已知且与公平性分析相关时效果良好。
亲自尝试
- 训练一个标准的逻辑回归模型。
- 测量 $P(\hat{Y}=1 \mid A=0)$ 和 $P(\hat{Y}=1 \mid A=1)$。
- 添加一个公平性惩罚项(或调整阈值)。
- 观察准确性和公平性如何权衡。
- 调整 $\lambda$ 以平衡公平性和效用。
测试用例
| 数据集 | 指标 | 约束前 | 应用 DP 约束后 |
|---|---|---|---|
| Adult Income | $\Delta P(\hat{Y}=1)$ | 0.24 | 0.05 |
| COMPAS | $\Delta P(\hat{Y}=1)$ | 0.18 | 0.07 |
| Bank Marketing | $\Delta P(\hat{Y}=1)$ | 0.21 | 0.03 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 计算组均值 | $O(n)$ | $O(k)$ |
| 向损失添加惩罚项 | $O(1)$ | , |
| 训练成本 | 略高于基线 | , |
公平性强制带来的计算开销可以忽略不计。
一个温和的证明(为什么它有效)
如果 $\hat{Y}$ 的训练目标是最小化
$$ L_{\text{fair}} = L + \lambda , | E[\hat{Y}|A=0] - E[\hat{Y}|A=1] | $$
那么在平衡点时:
$$ E[\hat{Y}|A=0] \approx E[\hat{Y}|A=1] $$
这意味着近似独立性:
$$ \hat{Y} \perp A $$
因此,群体成员身份不再影响结果,这是人口统计均等的核心公平性条件。
总结表
| 概念 | 描述 | ||
|---|---|---|---|
| 公平性类型 | 人口统计均等(统计均等) | ||
| 数学形式 | $P(\hat{Y}=1 \vert A=0) = P(\hat{Y}=1 \vert A=1)$ | ||
| 实现方式 | 惩罚项或后处理 | ||
| 优势 | 组级平等 | ||
| 局限性 | 可能降低准确性或忽略标签相关性 |
人口统计均等是最纯粹的公平性形式, 它不问结果为何不同,只要求它们根本不应该不同。
983. 均衡几率
均衡几率(EO)是一种比人口统计均等更深入的公平性标准。 它不仅要求总体预测率相等,还要求在真实结果条件下的平等,确保模型在不同人口群体之间同等准确(且同等错误)。
这是一个关注错误平衡而非仅仅结果率的公平性定义。
我们要解决什么问题?
人口统计均等确保正例率相等,但忽略了这些预测是否正确。 一个模型可以通过抛硬币来轻易满足 DP,在各群体间随机给予批准。 这在形式上是公平的,但在实质上并非如此。
均衡几率通过强制要求各群体间的真正例率和假正例率都相等来解决这个问题。
公平性条件
设:
- $A$ = 敏感属性(例如,性别)
- $Y$ = 真实标签
- $\hat{Y}$ = 预测标签
那么均衡几率要求:
$$ P(\hat{Y} = 1 \mid Y = y, A = 0) = P(\hat{Y} = 1 \mid Y = y, A = 1) \quad \text{对于 } y \in {0, 1} $$
即:
- 各群体间真正例率(TPR)相等
- 各群体间假正例率(FPR)相等
在实践中,我们测量: $$ \text{TPR 差距} = | P(\hat{Y}=1|Y=1,A=0) - P(\hat{Y}=1|Y=1,A=1) | $$ $$ \text{FPR 差距} = | P(\hat{Y}=1|Y=0,A=0) - P(\hat{Y}=1|Y=0,A=1) | $$
两者都应该很小,理想情况下低于一个阈值 $\varepsilon$。
直观示例
假设我们构建一个医疗诊断模型。
| 群体 | 真正例率 | 假正例率 |
|---|---|---|
| 男性 | 0.85 | 0.10 |
| 女性 | 0.70 | 0.05 |
该模型在检测男性的阳性病例方面表现更好。 均衡几率将要求进行调整(例如,移动决策阈值),使得两个群体具有大致相等的 TPR 和 FPR,例如分别为 0.78 和 0.07。
实施策略
强制实施均衡几率主要有三种方法:
预处理 修改数据或样本权重,使错误在各群体间平衡(例如,重新加权、重采样)。
处理中 向损失函数添加公平性正则化项: $$ L_{\text{fair}} = L + \lambda_1 | \text{TPR}_0 - \text{TPR}_1 | + \lambda_2 | \text{FPR}_0 - \text{FPR}_1 | $$
后处理 训练后按群体调整决策阈值以均衡错误率(Hardt 等人,2016)。
微型代码(EO 的阈值调整)
Python(后处理)
import numpy as np
def equalized_odds_thresholds(y_true, y_pred, A):
thresholds = {}
for a in np.unique(A):
yg = y_pred[A == a]
tg = y_true[A == a]
# 计算最佳阈值以均衡 TPR/FPR
best_t, best_gap = 0.5, 1.0
for t in np.linspace(0, 1, 101):
pred_bin = (yg >= t).astype(int)
tpr = np.mean(pred_bin[tg == 1])
fpr = np.mean(pred_bin[tg == 0])
gap = abs(tpr - fpr)
if gap < best_gap:
best_t, best_gap = t, gap
thresholds[a] = best_t
return thresholds此代码计算能最小化错误不平衡的按群体阈值。
为何重要
- 确保在正确性方面的平等待遇,而不仅仅是结果率。
- 适用于医疗、司法或招聘等高风险领域。
- 避免了"公平但无用"的模型(与人口统计均等不同)。
- 提供了在公平性和准确性之间更具伦理意义的权衡。
亲自尝试
- 训练任意一个二分类器,并记录每个群体的预测结果。
- 计算每个群体的 TPR 和 FPR。
- 调整阈值或权重以最小化两个差距。
- 比较调整前后的模型公平性(EO 差距)和准确性。
测试案例
| 数据集 | 指标 | 调整前 | 均衡几率调整后 |
|---|---|---|---|
| COMPAS | TPR 差距 | 0.22 | 0.05 |
| COMPAS | FPR 差距 | 0.18 | 0.04 |
| Adult Income | TPR 差距 | 0.15 | 0.03 |
| Adult Income | FPR 差距 | 0.12 | 0.02 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 阈值搜索 | $O(k \cdot n)$ | $O(k)$ |
| 计算比率 | $O(n)$ | $O(1)$ |
| 训练惩罚(处理中) | 轻微 | , |
此处 $k$ 是候选阈值的数量。
一个温和的证明(为何有效)
令 $\hat{Y}_A$ 表示按群体的预测。 通过调整阈值使得 $P(\hat{Y}=1\vert Y=y,A=a)$ 在 $A$ 上相等,我们强制实现了:
$$ \hat{Y} \perp A \mid Y $$
这种条件独立性表达了公平性: 一旦已知真实结果,模型的错误就不再依赖于群体身份。
总结表
| 概念 | 描述 |
|---|---|
| 公平性类型 | 均衡几率 |
| 条件 | $\hat{Y} \perp A \mid Y$ |
| 约束 | 各群体间 TPR 和 FPR 相等 |
| 实施 | 预处理、处理中或后处理 |
| 优点 | 确保平等的错误行为 |
| 局限性 | 可能需要特定于群体的阈值 |
均衡几率是具有认知的公平性, 并非假装每个人都一样,而是确保每个人在正确或错误方面被平等对待。
984. 对抗性去偏
对抗性去偏是实现机器学习公平性最优雅且最强大的方法之一。 它采用了与生成对抗网络(GANs)相同的哲学思想:两个模型相互竞争,一个试图做出准确的预测,而另一个则试图检测不公平的偏见。 通过这种对抗,预测器学会隐藏关于敏感属性(如性别或种族)的信息,从而产生更公平的结果。
我们要解决什么问题?
许多学习算法,即使在重新加权或平衡的数据上训练,仍然会泄露关于敏感属性($A$)的信息。 模型可能不会显式地使用 $A$,但它可以从相关的特征(如邮政编码或职业)中推断出 $A$。
我们希望训练一个预测器 $f_\theta(x)$,它能够:
- 准确地预测目标 $Y$,并且
- 产生的预测 $\hat{Y}$ 尽可能少地包含关于 $A$ 的信息。
核心思想
我们构建两个网络(或模块):
预测器(主模型) 学习 $f_\theta(x)$ 来预测真实标签 $Y$。
对抗器(公平性判别器) 学习 $g_\phi(\hat{Y})$(有时使用内部表示 $g_\phi(h)$) 来从预测器的输出中预测敏感属性 $A$。
预测器被训练以最小化预测损失,同时最大化对抗器的损失,使得对抗器难以恢复 $A$。
目标函数
设 $L_y$ 为预测损失(例如,对于 $Y$ 的交叉熵), $L_a$ 为对抗器损失(用于预测 $A$)。
我们优化一个最小-最大目标:
$$ \min_{\theta} \max_{\phi} , \big( L_y(f_\theta(x), y) - \lambda L_a(g_\phi(f_\theta(x)), a) \big) $$
其中 $\lambda$ 控制公平性与准确性之间的权衡。
- 预测器希望最小化 $L_y$ 并最大化 $L_a$(愚弄对抗器)。
- 对抗器希望最小化 $L_a$(尽可能好地预测 $A$)。
当系统达到均衡时: $$ \hat{Y} \perp A $$ 即,预测不再携带关于群体归属的信息。
微型代码(对抗性去偏框架)
PyTorch 风格的伪代码
import torch
import torch.nn as nn
import torch.optim as optim
# 预测器模型(主任务)
class Predictor(nn.Module):
def __init__(self, d):
super().__init__()
self.net = nn.Sequential(nn.Linear(d, 16), nn.ReLU(), nn.Linear(16, 1), nn.Sigmoid())
def forward(self, x):
return self.net(x)
# 对抗器模型(公平性判别器)
class Adversary(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(nn.Linear(1, 8), nn.ReLU(), nn.Linear(8, 1), nn.Sigmoid())
def forward(self, y_pred):
return self.net(y_pred)
predictor = Predictor(d=10)
adversary = Adversary()
opt_pred = optim.Adam(predictor.parameters(), lr=0.001)
opt_adv = optim.Adam(adversary.parameters(), lr=0.001)
λ = 1.0
for x, y, a in data_loader:
y_pred = predictor(x)
# 训练对抗器
a_pred = adversary(y_pred.detach())
loss_a = nn.BCELoss()(a_pred, a.float())
opt_adv.zero_grad()
loss_a.backward()
opt_adv.step()
# 训练预测器
y_pred = predictor(x)
a_pred = adversary(y_pred)
loss_y = nn.BCELoss()(y_pred, y.float())
loss_total = loss_y - λ * nn.BCELoss()(a_pred, a.float())
opt_pred.zero_grad()
loss_total.backward()
opt_pred.step()这个循环交替进行:先增强对抗器,然后削弱其对预测器的影响,从而动态地平衡公平性与准确性。
为什么它很重要
- 学习表示层面的公平性,比重新加权或阈值调整更深入。
- 即使敏感信息是隐含的或相关的,也能工作。
- 适用于文本、视觉、表格或图数据。
- 通过 $\lambda$ 实现灵活的权衡。
- 受信息论启发:减少 $\hat{Y}$ 和 $A$ 之间的互信息。
亲自尝试
- 从一个数据集开始,例如成人收入数据集(以
sex作为 $A$)。 - 训练一个基线分类器,测量人口统计差异。
- 添加一个对抗性头部来预测 $A$。
- 调整 $\lambda$:
- $\lambda = 0$ → 最大准确性,无公平性。
- $\lambda > 1$ → 强公平性,可能降低准确性。
- 观察公平性指标中的权衡。
测试案例
| 数据集 | 指标 | 去偏前 | 对抗性去偏后 |
|---|---|---|---|
| 成人收入数据集 | 人口统计均等性 | 0.23 | 0.05 |
| COMPAS | 均衡几率差距 | 0.21 | 0.07 |
| 德国信用数据集 | 真正率差距 | 0.19 | 0.06 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 预测器更新 | $O(n d)$ | $O(d)$ |
| 对抗器更新 | $O(n)$ | $O(1)$ |
| 总计 | 比标准训练稍慢 |
训练在两个优化器之间交替进行,通常会使运行时间加倍,但仍然是多项式级别的。
一个温和的证明(为什么它有效)
在最小-最大博弈的均衡点: $$ \nabla_\phi L_a = 0, \quad \nabla_\theta (L_y - \lambda L_a) = 0 $$
这意味着对抗器无法从 $\hat{Y}$ 中提取关于 $A$ 的信息。 因此,互信息 $I(\hat{Y}; A)$ 被最小化: $$ I(\hat{Y}; A) \approx 0 $$
所以预测变得与敏感属性统计独立,这是人口统计均等性下公平性的正式定义。
总结表
| 概念 | 描述 |
|---|---|
| 公平性类型 | 处理中(对抗性) |
| 目标函数 | $\min_\theta \max_\phi (L_y - \lambda L_a)$ |
| 机制 | 对抗器试图检测偏见;预测器隐藏它 |
| 核心理念 | 通过混淆实现公平性 |
| 优势 | 学习公平的潜在表示 |
| 局限性 | 需要调整对抗器并保持联合训练的稳定性 |
对抗性去偏是通过竞争实现的公平性, 两个网络在一场博弈中相互制衡,正义从平衡中涌现。
985. 因果有向无环图发现
因果有向无环图发现是从数据中揭示因果关系的过程。与基于相关性的学习不同,因果发现试图回答如果我们进行干预会发生什么,超越了预测,深入到现实本身的结构。
我们要解决什么问题?
机器学习模型经常发现关联:
- "吸烟和黄牙是相关的。" 但只有因果分析能告诉我们:
- "吸烟导致黄牙。"
因果发现通过识别变量 $V$ 上的图结构 $G = (V, E)$ 来形式化这一点,其中边 $X_i \to X_j$ 代表直接的因果影响。
因果图基础
因果有向无环图是一个有向无环图,其中:
- 每个节点代表一个变量。
- 每条边代表一个因果关系($X_i \to X_j$ 意味着 $X_i$ 直接导致 $X_j$)。
- 不存在有向环(无反馈循环)。
我们假设数据遵循因果马尔可夫条件: $$ P(X_1, \dots, X_n) = \prod_i P(X_i \mid \text{Pa}(X_i)) $$ 其中 $\text{Pa}(X_i)$ 是图中 $X_i$ 的父节点(直接原因)。
两种主要方法
基于约束的方法(条件独立性)
- 检验变量之间的统计独立性。
- 构建与这些检验一致的边。
- 示例:PC 算法(Peter–Clark)。
基于评分的方法(优化)
- 根据每个有向无环图对数据的拟合程度(例如,BIC 分数)为其分配一个分数。
- 在有向无环图空间中搜索以最大化分数。
- 示例:GES(贪婪等价搜索)。
PC 算法(概述)
输入:包含 $n$ 个变量的数据集。 目标:构建捕获因果依赖关系的有向无环图。
| 步骤 | 描述 |
|---|---|
| 1 | 从一个完全连通的无向图开始。 |
| 2 | 对于每一对 $(X_i, X_j)$,检验在给定其他变量的某个子集 $S$ 的条件下,它们是否条件独立。 |
| 3 | 如果独立,则移除它们之间的边。 |
| 4 | 使用逻辑规则(例如,碰撞子规则)对剩余的边进行定向。 |
| 5 | 输出得到的部分有向有向无环图。 |
这个过程结合了统计学和逻辑来推断因果方向。
微型代码(PC 算法简化版)
Python 伪代码(使用偏相关)
import itertools
import numpy as np
from scipy.stats import pearsonr
def cond_independent(x, y, cond, data, alpha=0.05):
if not cond:
r, _ = pearsonr(data[:, x], data[:, y])
else:
# 简单的线性回归残差方法
Xc = data[:, cond]
beta_x = np.linalg.lstsq(Xc, data[:, x], rcond=None)[0]
beta_y = np.linalg.lstsq(Xc, data[:, y], rcond=None)[0]
rx = data[:, x] - Xc @ beta_x
ry = data[:, y] - Xc @ beta_y
r, _ = pearsonr(rx, ry)
return abs(r) < alpha
def pc_algorithm(data):
n_vars = data.shape[1]
adj = np.ones((n_vars, n_vars)) - np.eye(n_vars)
for l in range(n_vars - 2):
for (i, j) in itertools.combinations(range(n_vars), 2):
if adj[i, j]:
for cond in itertools.combinations([k for k in range(n_vars) if k not in [i, j]], l):
if cond_independent(i, j, cond, data):
adj[i, j] = adj[j, i] = 0
break
return adj这个简化版本移除了显示条件独立性的边。
为什么它很重要
- 揭示机制性理解,而不仅仅是相关性。
- 对于干预下的推理至关重要:"如果我们改变 $X$ 会怎样?"
- 是公平感知模型和人工智能中因果推断的基础。
- 有助于防止误导决策的虚假关联。
亲自尝试
- 生成合成数据: $$ X \to Y, \quad Z = X + \epsilon, \quad Y = X + Z + \text{噪声} $$
- 运行一个因果发现算法(例如,PC 或 GES)。
- 检查它是否恢复了原始的因果方向。
- 与相关性进行比较,注意它们不同的地方。
测试用例
| 数据集 | 真实因果结构 | 发现的边(PC) |
|---|---|---|
| 合成数据(3变量) | $X \to Y \to Z$ | $X \to Y$, $Y \to Z$ |
| 线性高斯 | $A \to B$, $A \to C$ | $A \to B$, $A \to C$ |
| 非线性 | $X \to Y$, $Y \not\to X$ | 部分有向无环图(方向模糊) |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 独立性检验 | $O(n^k)$ | $O(n^2)$ |
| 边定向 | $O(n^2)$ | $O(n^2)$ |
其中 $k$ 是最大条件集大小(通常很小)。因果发现随维度增加而扩展性差,但可以利用稀疏性假设进行优化。
一个温和的证明(为什么它有效)
在因果马尔可夫条件和忠实性假设下:
- 如果 $X_i$ 和 $X_j$ 在给定 $S$ 的条件下是独立的,那么 $X_i$ 和 $X_j$ 之间就没有直接的边。
因此,基于约束的方法可以恢复真实有向无环图的马尔可夫等价类。也就是说,所有编码相同条件独立性的有向无环图。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 因果推断 |
| 目标 | 从数据中发现因果结构(有向无环图) |
| 关键假设 | 马尔可夫性 + 忠实性 |
| 主要方法 | PC(约束),GES(评分),NOTEARS(连续) |
| 输出 | 因果有向无环图或 CPDAG |
| 局限性 | 不能总是唯一地定向所有边 |
因果有向无环图发现教会算法事物为什么发生,而不仅仅是它们何时同时发生,将数据转化为原因,而非巧合。
986. 倾向得分匹配
倾向得分匹配(PSM)是因果推断中的一项基石技术,它提供了一种从观测数据中模拟随机实验的方法。它通过匹配那些在给定观测协变量下具有相似接受治疗概率的样本来平衡处理组和对照组。
核心思想是:如果两个个体具有相同的接受治疗的倾向,那么他们之间结果的任何差异都可以归因于治疗本身。
我们要解决什么问题?
当治疗分配不是随机的时候,直接比较处理组和未处理组会得到有偏的结果。例如,在一项医学研究中:
- 更健康的患者可能更有可能接受治疗。
- 因此,观察到的结果差异既反映了治疗也反映了他们的健康状况。
我们需要一种方法来控制混杂变量 $X$,以估计治疗 $T$ 对结果 $Y$ 的真实因果效应。
核心思想
将倾向得分定义为给定协变量下接受治疗的概率: $$ e(x) = P(T = 1 \mid X = x) $$
该方法分为三个步骤:
- 估计 $e(x)$,通常使用逻辑回归或机器学习分类器。
- 匹配具有相似 $e(x)$ 的处理样本和未处理样本。
- 比较匹配对之间的结果 $Y$ 以估计处理效应。
一旦匹配完成,处理组和对照组在 $X$ 方面应该是统计上不可区分的。
数学基础
在强可忽略性假设下: $$ Y(0), Y(1) \perp T \mid X, \quad 0 < e(X) < 1 $$
平均处理效应(ATE)可以估计为: $$ \text{ATE} = E[Y(1) - Y(0)] = E_T\left[\frac{Y T}{e(X)} - \frac{Y (1 - T)}{1 - e(X)}\right] $$
匹配后,我们计算处理组的平均处理效应(ATT): $$ \text{ATT} = E[Y(1) - Y(0) \mid T = 1] $$
其中,处理单元的 $Y(0)$ 由匹配的控制单元的结果替代。
微型代码(倾向得分匹配)
使用逻辑回归和最近邻匹配的 Python 示例
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors
def propensity_score_matching(X, T, Y):
# 步骤 1: 估计倾向得分
model = LogisticRegression()
model.fit(X, T)
e = model.predict_proba(X)[:, 1]
# 步骤 2: 通过最近的倾向得分将处理组与对照组匹配
treated_idx = np.where(T == 1)[0]
control_idx = np.where(T == 0)[0]
nbrs = NearestNeighbors(n_neighbors=1).fit(e[control_idx].reshape(-1, 1))
_, match_idx = nbrs.kneighbors(e[treated_idx].reshape(-1, 1))
matched_control = control_idx[match_idx.flatten()]
# 步骤 3: 计算 ATT
att = np.mean(Y[treated_idx] - Y[matched_control])
return att, e为什么它很重要
- 当无法进行真实实验时,模拟随机化。
- 通过平衡协变量来减少选择偏误。
- 在经济学、医学和社会科学中效果良好。
- 提供可解释的因果估计。
但请注意:它无法解决隐藏混杂,只能调整观测到的变量。
亲自尝试
- 使用包含治疗 $T$、结果 $Y$ 和协变量 $X$ 的数据集。
- 使用逻辑回归估计倾向得分。
- 将每个处理单元与一个具有相似 $e(x)$ 的控制单元匹配。
- 计算结果的均值差异。
- 检查匹配前后的协变量平衡性。
测试用例
| 数据集 | 方法 | 估计的 ATT | 评论 |
|---|---|---|---|
| 合成线性数据 | PSM | +2.1 | 真实效应 = +2 |
| Lalonde | PSM | +1.6 | 匹配实验基准 |
| IHDP | PSM | +3.3 | 接近真实因果值 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 倾向模型 | $O(n d)$ | $O(d)$ |
| 匹配 | $O(n \log n)$ | $O(n)$ |
| ATT 计算 | $O(n)$ | $O(1)$ |
其中 $d$ = 协变量数量。 总体而言,对于中等规模的数据集,该方法是高效且可扩展的。
一个温和的证明(为什么它有效)
倾向得分是一个平衡得分,给定 $e(X)$,治疗分配与协变量独立: $$ T \perp X \mid e(X) $$ 因此,基于 $e(X)$ 进行匹配确保了处理组和对照组具有可比性,就像在随机实验中一样。
通过以 $e(X)$ 而非完整的 $X$ 为条件,PSM 将高维调整压缩为一个单一的标量,即治疗概率。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 因果推断 |
| 关键假设 | 无未观测的混杂因素 |
| 核心步骤 | 基于 $P(T=1 \mid X)$ 匹配 |
| 输出 | 平均处理效应(ATE/ATT) |
| 优点 | 模拟随机对照 |
| 局限性 | 对模型设定错误敏感 |
倾向得分匹配弥合了我们拥有的数据与我们希望进行的实验之间的鸿沟,通过平衡世界来揭示相关性之下的因果关系。
987. 工具变量估计
工具变量(IV)估计是因果推断中的一种经典方法,用于处理存在未观测混杂因子(即同时影响处理变量和结果变量的变量)的情况,此时普通回归会产生偏差。它引入一个外部变量,称为工具变量,该变量影响处理变量,但不直接影响结果变量(除了通过该处理变量的影响)。
当随机化不可行时,这种方法在计量经济学、流行病学和政策分析中至关重要。
我们要解决什么问题?
假设我们想要估计处理变量 $T$ 对结果变量 $Y$ 的因果效应。一个简单的线性模型可能如下所示: $$ Y = \beta T + \gamma X + \epsilon $$
如果 $T$ 与误差项 $\epsilon$ 相关(由于隐藏的混杂因子),那么普通最小二乘法(OLS)给出的 $\beta$ 估计值是有偏的。
我们需要一个 $T$ 的外部变异来源,该来源与 $\epsilon$ 无关。
核心思想
我们找到一个工具变量 $Z$,它满足:
- 相关性:$Z$ 与处理变量 $T$ 相关 $$\text{Cov}(Z, T) \neq 0$$
- 外生性:$Z$ 仅通过 $T$ 影响 $Y$ $$Z \perp \epsilon$$
这样,$Z$ 就充当了一个自然实验,提供了与混杂因子无关的 $T$ 的变异。
关键的因果关系变为: $$ Z \to T \to Y $$ 并且没有从 $Z$ 直接指向 $Y$ 的边。
两阶段最小二乘法(2SLS)
工具变量估计的标准流程是两阶段最小二乘法(2SLS)。
| 阶段 | 描述 |
|---|---|
| 第一阶段 | 将 $T$ 对 $Z$ 和协变量 $X$ 进行回归: $$T = \pi_0 + \pi_1 Z + \pi_2 X + \nu$$ |
| 第二阶段 | 将 $Y$ 对第一阶段得到的预测值 $\hat{T}$ 进行回归: $$Y = \beta_0 + \beta_1 \hat{T} + \beta_2 X + \eta$$ |
系数 $\beta_1$ 就是 $T$ 对 $Y$ 的因果效应。
直观地说,$\hat{T}$ 是 $T$ 中由 $Z$ 解释的“干净”部分,去除了混杂因素的影响。
数学推导
在没有协变量的简单情况下: $$ Y = \beta T + \epsilon $$ 且 $Z$ 是工具变量。
工具变量估计量由下式给出: $$ \hat{\beta}_{IV} = \frac{\text{Cov}(Z, Y)}{\text{Cov}(Z, T)} $$
这个比率衡量了由工具变量 $Z$ 引起的 $T$ 的每单位变化,会导致 $Y$ 发生多大变化。
微型代码(2SLS 示例)
使用 NumPy 的 Python 示例
import numpy as np
def iv_estimate(Y, T, Z):
# 第一阶段:使用工具变量预测处理变量
Z = np.column_stack((np.ones(len(Z)), Z))
beta_stage1 = np.linalg.inv(Z.T @ Z) @ Z.T @ T
T_hat = Z @ beta_stage1
# 第二阶段:使用预测的处理变量预测结果变量
X = np.column_stack((np.ones(len(T_hat)), T_hat))
beta_stage2 = np.linalg.inv(X.T @ X) @ X.T @ Y
return beta_stage2[1] # 因果系数使用示例:
# 模拟数据
np.random.seed(0)
Z = np.random.randn(1000)
T = 2*Z + np.random.randn(1000)
Y = 3*T + 0.5*np.random.randn(1000)
beta_iv = iv_estimate(Y, T, Z)
print(beta_iv) # ≈ 3 (真实的因果效应)为何重要
- 解决内生性问题(当处理变量与噪声相关时)。
- 无需完全随机化即可进行因果推断。
- 构成自然实验的基础,例如:
- 使用征兵抽签作为兵役的工具变量。
- 使用到大学的距离作为教育的工具变量。
- 使用降雨量作为农业生产的工具变量。
动手试试
- 选择一个数据集,其中处理变量 $T$ 是内生的(与混杂因子相关)。
- 确定一个影响 $T$ 但不直接影响 $Y$ 的外部变量 $Z$。
- 同时运行 OLS 和 IV(2SLS)回归。
- 比较估计值,IV 应该能纠正偏差。
- 检查工具变量的强度(例如,第一阶段 F 统计量 > 10)。
测试案例
| 场景 | 工具变量 | 真实效应 | OLS 估计值 | IV 估计值 |
|---|---|---|---|---|
| 教育 → 工资 | 到大学的距离 | +0.10 | +0.18 | +0.11 |
| 吸烟 → 出生体重 | 香烟税 | -200 | -80 | -190 |
| 酒精 → 事故率 | 啤酒税 | +1.5 | +0.7 | +1.4 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 第一阶段回归 | $O(n d^2)$ | $O(d^2)$ |
| 第二阶段回归 | $O(n d^2)$ | $O(d^2)$ |
| 总计 | $O(n d^2)$ | $O(d)$ |
对于中等维度的线性模型来说,这是高效且可扩展的。
一个温和的证明(为何有效)
由于 $Z$ 独立于 $\epsilon$,我们可以取期望: $$ E[Z Y] = E[Z (\beta T + \epsilon)] = \beta E[Z T] $$ 因此: $$ \beta = \frac{E[Z Y]}{E[Z T]} $$ 这证明了 $\hat{\beta}_{IV}$ 一致地估计了真实的因果效应。
只要 $Z$ 不与 $\epsilon$ 相关,即使 $T$ 与 $\epsilon$ 相关,这个结论也成立。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 因果推断(内生性) |
| 关键思想 | 使用外生变量 $Z$ 作为随机化的代理 |
| 核心方程 | $\hat{\beta}_{IV} = \frac{\text{Cov}(Z, Y)}{\text{Cov}(Z, T)}$ |
| 算法 | 两阶段最小二乘法(2SLS) |
| 优点 | 处理未观测的混杂 |
| 局限性 | 需要强且有效的工具变量 |
工具变量估计是一门在世界上寻找自然随机性的艺术,将日常的变异转化为大自然已经为我们运行过的实验。
988. 鲁棒优化
鲁棒优化(RO)是一种在不确定性下做出有效决策的框架。它不假设参数完全已知,而是为最坏可能情况做准备,确保即使现实偏离模型,解决方案也能表现良好。
它广泛应用于金融、运筹学和机器学习领域,以防范估计误差、数据噪声和对抗性不确定性。
我们要解决什么问题?
许多优化问题假设参数是精确的,但在实践中,系数、成本或概率可能是不确定的。例如:
- 在投资组合优化中,预期收益是从有噪声的数据中估计出来的。
- 在供应链中,需求和成本预测每天都在波动。
- 在机器学习中,损失函数曲面可能在不同领域间发生变化。
当输入与预期不同时,传统优化可能会灾难性地失败。鲁棒优化明确地对不确定性进行建模,并保护解决方案免受其影响。
核心思想
让我们从一个标准的线性优化问题开始:
$$ \min_x ; c^T x \quad \text{s.t.} \quad A x \le b $$
在鲁棒优化中,我们将系数视为在指定的不确定集内不确定。例如,每个系数 $a_{ij}$ 可能在已知范围内变化: $$ a_{ij} \in [\bar{a}{ij} - \Delta{ij}, ; \bar{a}{ij} + \Delta{ij}] $$
然后我们要求约束对所有可能的实现都成立: $$ A x \le b, \quad \forall A \in \mathcal{U} $$
其中 $\mathcal{U}$ 是不确定集(例如,区间、椭球体或多面体)。
这将问题转化为极小-极大形式: $$ \min_x ; \max_{u \in \mathcal{U}} f(x, u) $$
我们寻求一个能最小化最坏情况损失的解 $x^*$。
不确定集的类型
| 类型 | 示例 | 数学形式 | ||
|---|---|---|---|---|
| 箱型 | 每个参数在固定边界内变化 | $\mathcal{U} = {a: \vert a_i - \bar{a}_i \vert \le \Delta_i}$ | ||
| 椭球型 | 参数间存在相关的不确定性 | $\mathcal{U} = {a: (a - \bar{a})^T Q^{-1}(a - \bar{a}) \le 1}$ | ||
| 多面体型 | 约束定义了可行区域 | $\mathcal{U} = {a: F a \le g}$ |
每种选择都会产生不同的计算复杂度和保守性。
示例:鲁棒线性规划
名义问题: $$ \min_x ; c^T x \quad \text{s.t.} \quad A x \le b $$
鲁棒版本(箱型不确定性): $$ A = \bar{A} + \Delta, \quad |\Delta_{ij}| \le \rho_{ij} $$
那么鲁棒约束变为: $$ \bar{a}i^T x + \sum_j \rho{ij} |x_j| \le b_i $$
这产生了一个凸重构问题,可以使用标准线性规划或锥规划求解器求解。
微型代码(鲁棒线性规划示例)
使用 cvxpy 的 Python 示例
import cvxpy as cp
import numpy as np
# 名义数据
A = np.array([[2, 1], [1, 3]])
b = np.array([5, 7])
c = np.array([1, 2])
rho = np.array([[0.1, 0.2], [0.3, 0.1]])
# 决策变量
x = cp.Variable(2, nonneg=True)
# 鲁棒约束
constraints = []
for i in range(A.shape[0]):
constraints.append(A[i] @ x + np.sum(rho[i] * cp.abs(x)) <= b[i])
# 目标函数
objective = cp.Minimize(c @ x)
problem = cp.Problem(objective, constraints)
problem.solve()
print("鲁棒解:", x.value)这确保了即使系数偏离 ±ρ,约束仍然成立。
为什么它很重要
- 保证在不确定性下的可行性。
- 防止对单一估计的过拟合。
- 在对抗性机器学习、金融、物流和资源分配中很有用。
- 可推广到鲁棒回归、鲁棒支持向量机和鲁棒神经网络训练。
例如,在机器学习中,鲁棒优化是对抗性训练的基础: $$ \min_\theta \max_{\delta \in \mathcal{U}} L(f_\theta(x + \delta), y) $$ 这确保了模型即使在最坏情况的扰动下也能表现良好。
动手尝试
- 创建一个简单的线性规划模型 $\min c^T x$,约束条件为 $A x \le b$。
- 在 $A$ 上添加箱型不确定性。
- 比较名义解和鲁棒解。
- 增加不确定性幅度 $\rho$,观察 $x^*$ 如何变得更加保守。
测试用例
| 场景 | 不确定集 | 鲁棒 vs. 名义成本 | 备注 |
|---|---|---|---|
| 投资组合优化 | 椭球型 | 风险调整后收益略高 | 对估计噪声较不敏感 |
| 供应链 | 箱型 | 成本更高,缺货更少 | 在需求不确定性下可靠 |
| 调度 | 多面体型 | 稳定的调度 | 对延迟变化具有抵抗力 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 线性规划(名义) | $O(n^3)$ | $O(n^2)$ |
| 鲁棒线性规划 | $O(n^3)$ | $O(n^2)$ |
| 鲁棒二阶锥规划 | $O(n^3)$ | $O(n^2)$ |
当 $\mathcal{U}$ 是凸集(箱型或椭球型)时,复杂度保持为多项式级别。
一个温和的证明(为什么它有效)
令不确定约束为: $$ a^T x \le b, \quad \forall a \in \mathcal{U} $$ 那么对于一个凸不确定集 $\mathcal{U}$,最坏情况的 $a$ 出现在 $\mathcal{U}$ 的边界上,从而将无限个约束转化为有限的确定性约束。
对于箱型不确定性: $$ \max_{|a_i - \bar{a}_i| \le \rho_i} a^T x = \bar{a}^T x + \sum_i \rho_i |x_i| $$ 这将问题转化为一个凸问题,即名义线性规划的鲁棒版本。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 不确定性下的优化 |
| 核心理念 | 防范最坏情况的输入 |
| 典型形式 | $\min_x \max_{u \in \mathcal{U}} f(x, u)$ |
| 方法 | 箱型、椭球型、多面体型鲁棒优化 |
| 优点 | 可靠、保守的解决方案 |
| 局限性 | 可能过于谨慎 |
鲁棒优化将不确定性从威胁转变为设计原则,这是一种为混乱做计划的方式,而不是害怕它。
989. 分布鲁棒优化
分布鲁棒优化(DRO)是鲁棒优化的一种强大泛化,它不仅针对最坏情况的参数做准备,还针对最坏情况的不确定性分布做准备。DRO 不假设数据来自已知的概率分布,而是假设真实分布可能位于一个围绕名义估计的模糊集内,并寻求在最可能的分布下表现最佳的决策。
这连接了经典的随机优化和对抗学习,并与现代机器学习、公平性和泛化性有着深刻的联系。
我们要解决什么问题?
在许多现实世界的任务中,我们基于从未知分布 $P$ 中抽取的数据进行训练或优化,但我们只有一个有限的经验样本 $\hat{P}n$。一个标准的随机优化问题是: $$ \min{x} ; E_P[L(x, \xi)] $$
但 $\hat{P}_n$ 只是 $P$ 的一个估计。如果训练数据存在偏差、不完整或非平稳,那么解决方案在未来数据上可能表现不佳。
DRO 通过考虑所有接近 $\hat{P}n$ 的分布,并最小化最坏情况下的期望损失来解决这个问题: $$ \min{x} ; \max_{Q \in \mathcal{P}} E_Q[L(x, \xi)] $$
其中 $\mathcal{P}$ 是一个可能分布的模糊集。
核心思想
模糊集 $\mathcal{P}$ 定义了允许与名义分布有多少偏差。常见的选择包括:
| 类型 | 定义 | 备注 |
|---|---|---|
| $\phi$-散度 | $\mathcal{P} = { Q : D_\phi(Q \Vert \hat{P}) \le \rho }$ | 包括 KL 散度、$\chi^2$ 散度和总变差距离 |
| Wasserstein 球 | $\mathcal{P} = { Q : W(Q, \hat{P}) \le \epsilon }$ | 概率度量空间中的距离 |
| 基于矩 | $\mathcal{P} = { Q : E_Q[\xi] = \mu, \text{Var}_Q[\xi] \le \Sigma }$ | 控制矩而非分布形状 |
然后,DRO 找到一个决策 $x^$,它在 $\mathcal{P}$ 内的最坏分布 $Q$ 下最小化期望损失*。
数学形式
给定损失函数 $L(x, \xi)$ 和模糊集 $\mathcal{P}$: $$ \min_x ; \max_{Q \in \mathcal{P}} E_Q[L(x, \xi)] $$
对 $Q$ 的内部最大化代表了一个对抗性的“自然”在 $\mathcal{P}$ 内选择最坏情况的分布。
在温和的凸性假设下,这个问题在双重意义上等价于一个正则化的经验风险最小化问题: $$ \min_x ; E_{\hat{P}}[L(x, \xi)] + \lambda , \Omega(x) $$ 其中 $\Omega(x)$ 是一个依赖于模糊集类型的正则化器。
因此,DRO 统一了鲁棒性和正则化,这是现代机器学习的核心见解之一。
示例:使用 Wasserstein 距离的 DRO
Wasserstein 模糊集定义为: $$ \mathcal{P} = { Q : W(Q, \hat{P}_n) \le \epsilon } $$ 其中 $W$ 是最优传输(推土机)距离。
DRO 目标变为: $$ \min_x \max_{W(Q, \hat{P}_n) \le \epsilon} E_Q[L(x, \xi)] $$
这有一个对偶形式: $$ \min_x ; E_{\hat{P}_n}[L(x, \xi)] + \epsilon \cdot | \nabla_x L(x, \xi) | $$ 这类似于神经网络中的对抗训练。
微型代码(Wasserstein DRO 示例)
使用凸损失的 Python 示例
import numpy as np
def wasserstein_dro_loss(L, grad_L, x, epsilon):
"""近似 DRO 正则化损失。"""
empirical_loss = np.mean(L(x))
grad_norm = np.linalg.norm(np.mean(grad_L(x)), ord=2)
return empirical_loss + epsilon * grad_norm这种简单的结构类似于: $$ \text{DRO损失} = \text{经验损失} + \epsilon \times \text{梯度范数} $$ ——一种针对分布偏移的鲁棒化损失。
为什么它很重要
- 提供超越训练数据的鲁棒泛化能力。
- 防止数据偏移、偏差和采样噪声。
- 连接了优化和统计学习理论。
- 在深度学习中可解释为对抗正则化。
在机器学习中,DRO 产生的模型在不同领域表现一致,这对于公平性、分布外泛化和可靠的人工智能至关重要。
亲自尝试
- 在有偏差的数据集上训练一个线性回归模型。
- 计算基于 Wasserstein 距离的 DRO 目标。
- 调整 $\epsilon$,观察小的 $\epsilon$ 提高鲁棒性,大的 $\epsilon$ 产生过于保守的解。
- 比较在领域偏移下的测试性能。
测试用例
| 任务 | 模糊集 | 效果 | 结果 |
|---|---|---|---|
| 逻辑回归 | KL 散度 | 正则化权重 | 改进泛化能力 |
| 投资组合优化 | Wasserstein | 对冲冲击 | 降低波动性 |
| 图像分类 | Wasserstein | 对抗鲁棒性 | 对 FGSM/PGD 攻击更强 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 内部分布优化 | $O(n)$ – $O(n^2)$ | $O(n)$ |
| 对偶形式 | $O(d^3)$ | $O(d^2)$ |
| 基于梯度的更新 | $O(n d)$ | $O(d)$ |
现代 DRO 求解器利用凸对偶性或随机梯度方法来实现可扩展性。
一个温和的证明(为什么它有效)
根据强对偶性,对于许多 $\mathcal{P}$,内部上确界允许一个闭式表达式: $$ \max_{Q \in \mathcal{P}} E_Q[L(x, \xi)] = E_{\hat{P}_n}[L(x, \xi)] + \text{鲁棒惩罚项} $$ 其中惩罚项对应于与 $\mathcal{P}$ 一致的最坏扰动。
这意味着 DRO 隐式地正则化解,使其在数据扰动下保持稳定,这连接了 Tikhonov 正则化和对抗正则化。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 分布不确定性下的优化 |
| 核心思想 | 在最可能的数据分布下最小化损失 |
| 关键形式 | $\min_x \max_{Q \in \mathcal{P}} E_Q[L(x, \xi)]$ |
| 常见集合 | Wasserstein, KL, $\chi^2$, 基于矩 |
| 对偶形式 | 正则化经验风险 |
| 应用 | 机器学习鲁棒性、公平性、金融 |
| 局限性 | 如果 $\mathcal{P}$ 太大,可能过于保守 |
分布鲁棒优化是可信赖人工智能的数学核心,确保我们的模型不仅仅拟合它们看到的数据,还能承受它们尚未遇到的世界。
990. 反事实公平性
反事实公平性是一种基于因果推理的公平性准则。 它提出的问题是:如果个体属于不同的人口统计群体,其他条件不变,这个决策是否会保持不变?
反事实公平性不依赖于受保护属性(如种族或性别)与结果之间的相关性,而是使用因果模型来推理改变敏感属性将如何影响预测。
我们要解决什么问题?
机器学习模型常常编码了历史数据中的偏见。即使排除了敏感属性,代理变量或相关变量仍然可能泄露偏见。
例如:
- 信用评分模型可能基于与种族相关的邮政编码进行歧视。
- 招聘模型可能偏向于与社会经济地位相关的某些大学。
我们希望模型对同一个人做出相同的决策,即使我们假设性地改变其受保护属性,也就是在反事实世界中。
核心理念
设:
- $A$: 受保护属性(例如,性别、种族)
- $X$: 观测特征(例如,教育、收入)
- $Y$: 结果(例如,贷款批准)
- $\hat{Y}$: 模型预测
如果满足以下条件,则模型是反事实公平的: $$ P(\hat{Y}{A \leftarrow a}(U) = y \mid X = x, A = a) = P(\hat{Y}{A \leftarrow a'}(U) = y \mid X = x, A = a) $$ 对于受保护属性的所有可能值 $a, a'$ 都成立。
这里,$\hat{Y}_{A \leftarrow a}(U)$ 表示如果我们进行干预,在因果模型中将 $A$ 设为 $a$,同时保持所有其他潜在因素 $U$ 不变时的反事实预测。
直观地说:
如果一个模型对个体的预测在一个假设的、仅其敏感属性不同的世界中不会改变,那么该模型就是公平的。
因果图视角
使用结构因果模型(SCM)对因果结构进行建模:
$$ A \to X \to \hat{Y}, \quad A \to \hat{Y} $$
反事实公平性涉及移除或阻断从 $A$ 到 $\hat{Y}$ 的、不经过合法中介的不公平因果路径。
例如:
- 路径 $A \to X \to \hat{Y}$(例如,教育)可能是可接受的。
- 直接路径 $A \to \hat{Y}$(例如,种族影响预测)是不公平的。
示例场景
招聘决策模型 变量:
- $A$: 性别
- $X$: 工作年限、教育程度
- $\hat{Y}$: 预测的适合度
如果性别影响教育(由于结构性不平等)但不影响内在能力,我们可能希望移除直接影响 $A \to \hat{Y}$,但保留中介路径 $A \to X \to \hat{Y}$。
我们模拟反事实:
- 保持潜在能力 $U$ 不变。
- 将 $A$ 从男性改为女性。
- 在修改后的图下重新计算 $\hat{Y}$。
如果 $\hat{Y}$ 发生变化,则存在不公平性。
微型代码(简化的反事实模拟)
使用 DoWhy 的 Python 伪代码
import dowhy
from dowhy import CausalModel
# 带有偏见的数据示例
data = {
"A": [0, 1, 0, 1, 0], # 性别
"X": [10, 12, 9, 11, 10], # 经验
"Y": [1, 0, 1, 0, 1] # 招聘决策
}
model = CausalModel(
data=data,
treatment="A",
outcome="Y",
common_causes=["X"]
)
identified_estimand = model.identify_effect()
estimate = model.estimate_effect(identified_estimand, method_name="backdoor.linear_regression")
print("性别的估计因果效应:", estimate.value)然后,您可以执行一个 do-干预(do(A=0) 对比 do(A=1))来测试在反事实场景下 $\hat{Y}$ 是否会改变。
为何重要
- 确保基于因果关系的公平性,而不仅仅是相关性。
- 检测即使在从特征中移除 $A$ 后仍然存在的隐藏歧视。
- 鼓励与法律和伦理推理相一致的、可解释的公平性定义。
应用包括:
- 信用风险和贷款审批
- 招聘和晋升模型
- 累犯风险评估
- 医疗保健优先级排序
亲自尝试
- 构建数据集的因果图(例如,使用领域知识)。
- 识别从 $A$ 到 $\hat{Y}$ 的路径。
- 移除或中和不公平路径。
- 测试当您对 $A$ 进行干预时,$\hat{Y}$ 是否会改变。
- 使用公平表示或不变特征训练模型。
测试用例
| 场景 | 受保护属性 | 技术 | 结果 |
|---|---|---|---|
| 招聘 | 性别 | 路径阻断 (A → Y) | 预测结果均等化 |
| 贷款 | 种族 | 反事实重加权 | 偏见减少 |
| 医疗保健 | 年龄 | 因果调整 | 跨群体结果稳定 |
复杂度
| 步骤 | 时间 | 空间 |
|---|---|---|
| 因果图构建 | $O(n^2)$ | $O(n^2)$ |
| 干预计算 | $O(n)$ | $O(n)$ |
| 反事实模拟 | $O(m n)$ | $O(n)$ |
大多数现代框架(如 DoWhy、EconML 或 CausalML)都能高效地自动化这些步骤。
一个温和的证明(为何有效)
在结构因果模型下: $$ \hat{Y} = f(A, X, U) $$ 其中 $U$ 是独立于 $A$ 的潜在背景变量。
如果 $\hat{Y}$ 是反事实公平的,那么: $$ f(a, X_{A \leftarrow a}, U) = f(a', X_{A \leftarrow a'}, U) $$ 对于所有 $a, a'$ 和固定的 $U$ 都成立。
这意味着 $\hat{Y}$ 仅依赖于合法特征,而不依赖于 $A$ 或其不公平的后代变量。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 决策系统中的因果公平性 |
| 关键理念 | 在敏感属性的反事实变化下预测不变 |
| 形式化准则 | $P(\hat{Y}{A \leftarrow a} = y \mid X, A=a) = P(\hat{Y}{A \leftarrow a'} = y \mid X, A=a)$ |
| 实现 | 结构因果模型 + do-演算 |
| 益处 | 检测并纠正隐藏的偏见 |
| 局限性 | 需要因果知识和假设 |
反事实公平性是算法公平性的黄金标准,它问的不是两个群体是否平均上被平等对待,而是每个个体在每个可能的世界中是否会被同样对待。
第 100 节. AI 规划、搜索与学习系统
991. 广度优先搜索 (BFS)
广度优先搜索 (BFS) 是最基础的图遍历算法之一。 它逐层探索图,先发现距离为 1 的所有顶点,然后是距离为 2 的顶点,依此类推。 这使得 BFS 非常适合在无权图中寻找最短路径,或对网络、树或状态空间进行系统性的探索。
我们要解决什么问题?
我们希望遍历或搜索一个图,访问从给定起始节点出发所有可达的顶点。
给定一个图 $G = (V, E)$ 和一个起始节点 $s$,BFS 可以找到:
- 从 $s$ 出发所有可达的节点。
- 每个顶点 $v$ 的最短距离 $d(s, v)$。
- 父节点或前驱关系(用于重建路径)。
形式化地说,BFS 按照与源点距离递增的顺序探索顶点。
核心思想
- 维护一个待探索的顶点队列。
- 从源节点开始,并将其标记为已访问。
- 反复从队列中取出一个节点,访问其所有未访问的邻居,并将它们加入队列。
- 持续此过程直到队列为空。
该算法保证每个顶点只被访问一次,并且边按照广度优先的顺序被"松弛"。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 初始化队列 $Q$,加入起始节点 $s$。 |
| 2 | 标记 $s$ 为已访问,并设置 $d(s) = 0$。 |
| 3 | 当 $Q$ 不为空时: |
| a. 从 $Q$ 中弹出顶点 $u$。 | |
| b. 对于 $u$ 的每个邻居 $v$: | |
| i. 如果 $v$ 未被访问:标记为已访问,设置 $d(v) = d(u) + 1$,将 $v$ 入队。 |
微型代码 (经典 BFS)
C 语言示例
#include <stdio.h>
#include <stdbool.h>
#define N 100
int adj[N][N]; // 邻接矩阵
bool visited[N];
int queue[N];
int front = 0, rear = 0;
void enqueue(int v) { queue[rear++] = v; }
int dequeue() { return queue[front++]; }
void bfs(int start, int n) {
for (int i = 0; i < n; i++) visited[i] = false;
visited[start] = true;
enqueue(start);
while (front < rear) {
int u = dequeue();
printf("%d ", u);
for (int v = 0; v < n; v++) {
if (adj[u][v] && !visited[v]) {
visited[v] = true;
enqueue(v);
}
}
}
}Python 示例
from collections import deque
def bfs(graph, start):
visited = set([start])
queue = deque([start])
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in graph[u]:
if v not in visited:
visited.add(v)
queue.append(v)
return order使用示例:
G = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
print(bfs(G, 'A')) # ['A', 'B', 'C', 'D', 'E', 'F']为什么它很重要
- 在无权图中寻找最短路径。
- 在图和网络中进行连通性检查。
- 在无向图中检测连通分量。
- 在人工智能规划或谜题中进行分层搜索。
BFS 是许多算法的基础:
- Dijkstra(带权图)
- Ford–Fulkerson(增广路径)
- Edmonds–Karp(最大流)
- 在有向无环图 (DAG) 中检测拓扑顺序
动手试试
- 使用邻接表实现 BFS,并验证遍历顺序。
- 计算每个顶点到源点的距离。
- 使用 BFS 在无权迷宫中找到最短路径。
- 修改 BFS 以计算无向图中的连通分量数量。
测试用例
| 图 | 起始点 | 期望的顺序 |
|---|---|---|
| A–B–C–D | A | A, B, C, D |
| 三角形 A–B–C–A | B | B, A, C |
| 星型图 (1 连接到 2,3,4) | 1 | 1, 2, 3, 4 |
复杂度
| 度量项 | 复杂度 |
|---|---|
| 时间 | $O(V + E)$ |
| 空间 | $O(V)$ (队列 + 访问标记数组) |
每个顶点和每条边都被处理一次,因此时间复杂度与图的大小呈线性关系。
一个温和的证明 (为什么它有效)
在每次迭代中,BFS 取出距离源点最近的顶点。 由于它按照距离递增的顺序访问邻居,因此一个顶点首次被发现时对应的就是从源点出发的最短路径。
形式化地,通过归纳法证明:
- 基础:$d(s) = 0$。
- 步骤:如果 $u$ 有正确的距离 $d(u)$,那么所有未访问的邻居将得到 $d(u) + 1$,从而保持顺序。
因此,BFS 在无权图中能产生正确的最短距离。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 图遍历 |
| 核心思想 | 逐层探索邻居 |
| 数据结构 | 队列 (先进先出) |
| 保证 | 在无权图中找到最短路径 |
| 时间复杂度 | $O(V + E)$ |
| 空间复杂度 | $O(V)$ |
| 应用 | 搜索、连通性、最短路径 |
广度优先搜索是探索的算法核心,它冷静、有序且公平:在深入图的未知领域之前,先访问每一个邻居。
992. 深度优先搜索 (DFS)
深度优先搜索 (DFS) 通过沿着一条分支尽可能深入探索图,然后再回溯。它与广度优先搜索 (BFS) 相反,不是逐层扩展,而是沿着边向下深入,揭示诸如路径、环和连通模式等隐藏结构。
我们要解决什么问题?
给定一个图 $G = (V, E)$ 和一个起始顶点 $s$,我们想要探索从 $s$ 出发可达的所有顶点。与保证最短路径的 BFS 不同,DFS 侧重于完全探索,适用于:
- 检测连通分量
- 检查图中的环
- 生成拓扑排序
- 解决迷宫遍历和寻路任务
核心思想
DFS 使用栈(显式或递归)来记住它来自哪里。其递归工作方式如下:
- 访问一个顶点。
- 递归进入每个未访问的邻居。
- 当没有未访问的邻居时回溯。
这种"深度优先"的模式确保在转向其他路径之前,每条路径都被探索到尽头。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 将起始节点标记为已访问。 |
| 2 | 对于节点的每个邻居 $v$: |
| a. 如果 $v$ 未被访问,则递归调用 DFS($v$)。 | |
| 3 | 继续直到从起点可达的所有顶点都被访问。 |
微型代码(经典 DFS)
C 语言示例(递归)
#include <stdio.h>
#include <stdbool.h>
#define N 100
int adj[N][N];
bool visited[N];
void dfs(int v, int n) {
visited[v] = true;
printf("%d ", v);
for (int u = 0; u < n; u++) {
if (adj[v][u] && !visited[u]) {
dfs(u, n);
}
}
}Python 示例
def dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
for v in graph[start]:
if v not in visited:
dfs(graph, v, visited)
return visited使用示例:
G = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
print(dfs(G, 'A')) # {'A', 'B', 'D', 'E', 'F', 'C'}为什么它很重要
DFS 是许多高级算法的基础:
- 在有向图或无向图中检测环
- 在有向无环图 (DAG) 中进行拓扑排序
- 寻找关节点和桥
- Tarjan 的强连通分量 (SCC) 算法
- 谜题中的回溯(数独、N皇后问题、寻路)
在树中,DFS 是前序、中序和后序遍历的自然选择。
动手试试
- 修改 DFS 以记录发现时间和完成时间。
- 使用 DFS 检测图是否连通。
- 在有向图上应用 DFS 并执行拓扑排序。
- 可视化 DFS 递归树,标记后向边和前向边。
测试用例
| 图 | 起点 | 期望的顺序 |
|---|---|---|
| A–B–C | A | A, B, C |
| 三角形 (A–B–C–A) | B | B, A, C |
| 星形 (1→2,3,4) | 1 | 1, 2, 3, 4 (深度顺序可能变化) |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间 | $O(V + E)$ |
| 空间 | $O(V)$ (栈或递归深度) |
每个顶点和每条边都被访问一次。空间复杂度等于递归深度,在最坏情况下可能达到 $V$。
一个温和的证明(为什么它有效)
DFS 沿着一条路径探索,直到没有未访问的邻居为止,然后回溯。每条边 $(u, v)$ 在 $v$ 首次被发现时恰好被考虑一次。通过归纳法,DFS 保证每个可达顶点都被恰好访问一次。
变体
| 变体 | 描述 |
|---|---|
| 递归 DFS | 更简单,使用调用栈 |
| 迭代 DFS | 显式栈,避免递归限制 |
| 修改版 DFS | 跟踪父边或发现时间 |
| DFS 森林 | 针对不连通图的 DFS 树集合 |
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 图遍历 |
| 核心思想 | 先深入探索,再回溯 |
| 数据结构 | 栈或递归 |
| 保证 | 连通分量的完全探索 |
| 时间复杂度 | $O(V + E)$ |
| 空间复杂度 | $O(V)$ |
| 应用 | 拓扑排序、强连通分量、寻路、环检测 |
深度优先搜索体现了一种探索者的心态:无畏、有条不紊且充满好奇,沿着一条路径深入探索,直到看清其下的一切。
993. A* 搜索
A*(读作“A星”)是人工智能领域最具影响力的搜索算法之一。它结合了当前已花费的成本与到达目标的启发式估计成本,在效率和最优性之间取得了平衡。A* 优雅地推广了 Dijkstra(迪杰斯特拉)算法和贪心最佳优先搜索,其应用遍及游戏 AI、机器人技术和路径规划等众多领域。
我们要解决什么问题?
我们希望在加权图中找到起始节点和目标节点之间的最短路径。每条边都有一个成本 $c(u, v)$,同时我们还有一个启发式函数 $h(v)$,用于估计从 $v$ 到目标的剩余成本。
如果我们只使用实际成本 $g(v)$ → 得到 Dijkstra 算法。 如果我们只使用启发式 $h(v)$ → 得到贪心最佳优先搜索。 A* 将两者结合起来: $$ f(v) = g(v) + h(v) $$ 该算法总是扩展总估计成本 $f(v)$ 最小的节点。
核心思想
- 维护两个集合:
- 开放集(前沿):待探索的节点
- 封闭集:已访问过的节点
- 从源节点 $s$ 开始,设置 $g(s) = 0$。
- 当开放集不为空时:
- 选择 $f(u) = g(u) + h(u)$ 最小的节点 $u$。
- 如果 $u$ 是目标节点,则重建路径并停止。
- 对于 $u$ 的每个邻居 $v$: * 计算试探性成本 $g'(v) = g(u) + c(u, v)$。 * 如果 $g'(v) < g(v)$,则更新并记录父节点。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 用起始节点 $s$ 初始化开放集。 |
| 2 | 设置 $g(s) = 0$,$f(s) = h(s)$。 |
| 3 | 当开放集不为空时: |
| a. 选择 $f(u)$ 最小的节点 $u$。 | |
| b. 如果 $u$ 是目标节点 → 返回路径。 | |
| c. 对于 $u$ 的每个邻居 $v$:更新 $g(v)$ 和 $f(v)$。 | |
| d. 将 $u$ 移动到封闭集。 |
微型代码(Python 示例)
import heapq
def a_star(graph, start, goal, h):
open_set = [(0, start)]
came_from = {}
g = {start: 0}
f = {start: h(start)}
while open_set:
_, u = heapq.heappop(open_set)
if u == goal:
path = [u]
while u in came_from:
u = came_from[u]
path.append(u)
return list(reversed(path))
for v, cost in graph[u]:
g_new = g[u] + cost
if v not in g or g_new < g[v]:
g[v] = g_new
f[v] = g_new + h(v)
came_from[v] = u
heapq.heappush(open_set, (f[v], v))
return None示例:
graph = {
'A': [('B', 1), ('C', 3)],
'B': [('D', 3)],
'C': [('D', 1)],
'D': []
}
h = lambda x: {'A': 3, 'B': 2, 'C': 1, 'D': 0}[x]
print(a_star(graph, 'A', 'D', h)) # ['A', 'C', 'D']为何重要
- 如果启发函数 $h$ 是可采纳的($h(v) \le h^*(v)$,即不大于真实成本),则能找到最优路径。
- 高效地剪枝不太可能通向目标的路径。
- 为以下领域的算法提供动力:
- 游戏路径寻找(例如,网格地图、导航网格)
- 机器人运动规划
- 路线优化(地图、物流)
- 自然语言解析和 AI 规划
亲自尝试
- 在有障碍物的二维网格上实现 A*。
- 使用以下启发函数进行测试:
- 曼哈顿距离
- 欧几里得距离
- 零启发函数(退化为 Dijkstra 算法)
- 可视化搜索前沿的扩展过程。
- 尝试使用高估的启发函数(不可采纳),观察最优性何时被破坏。
测试用例
| 图 | 启发函数 | 预期路径 |
|---|---|---|
| A–B–C(单位成本) | $h=0$ | A → B → C |
| 网格(曼哈顿距离) | 可采纳的 | 最短几何路径 |
| 随机图 | 高估的 | 可能跳过最优路径 |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间 | $O(E \log V)$(使用优先队列) |
| 空间 | $O(V)$(存储 $g$、$f$、开放/封闭集) |
效率在很大程度上取决于启发函数的准确性,一个好的 $h$ 能显著减少需要探索的节点数。
一个温和的证明(为何有效)
如果 $h(v)$ 是可采纳的(从不高估真实成本)且一致的($h(u) \le c(u,v) + h(v)$),那么 A* 总是按照节点到目标的真实成本递增的顺序扩展节点。
因此,当目标节点第一次从开放集中被移除时,最短路径就已经找到了。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 启发式图搜索 |
| 核心思想 | 最小化总成本 $f = g + h$ |
| 保证 | 如果 $h$ 可采纳,则结果最优 |
| 数据结构 | 优先队列(最小堆) |
| 时间复杂度 | $O(E \log V)$ |
| 空间复杂度 | $O(V)$ |
| 应用 | 路径寻找、机器人技术、AI 规划 |
A* 是逻辑与直觉的完美结合,这种搜索不仅知道自己去过哪里,还对下一步该去哪里有敏锐的感知。
994. 迭代加深 A* (IDA*)
迭代加深 A* 结合了深度优先搜索的空间效率与 A* 的启发式能力和最优性。当图或状态空间过大,导致 A* 无法完全装入内存,而我们仍需要启发式引导的最优解时,它尤其有用。
我们要解决什么问题?
标准的 A* 会在内存中存储所有已探索的节点,其数量可能呈指数级增长。对于大型问题(例如谜题或规划任务),这变得不可行。
IDA* 通过执行迭代的深度优先搜索来解决这个问题,每次搜索都受到一个递增的成本阈值限制。我们不是按深度探索,而是按估计的总成本 $f(n) = g(n) + h(n)$ 进行探索,并不断用更大的限制值重复此过程,直到找到目标。
核心思想
- 从等于 $h(start)$ 的初始阈值开始。
- 执行深度优先搜索,但剪掉任何 $f(n) > threshold$ 的节点。
- 如果未找到目标,则将阈值增加到最小的被剪枝值,然后重复。
这个过程确保第一次到达目标时,其 $f$ 值是最小的,从而保证了最优性。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 初始化 threshold = $h(start)$。 |
| 2 | 循环执行:使用 $f(n) = g(n) + h(n)$ 进行深度受限的 DFS。 |
| 3 | 如果找到目标,返回路径。 |
| 4 | 如果在限制内没有路径,则将阈值增加到遇到的下一个更高的 $f$ 值。 |
微型代码(Python 示例)
def ida_star(start, goal, h, neighbors):
def search(path, g, bound):
node = path[-1]
f = g + h(node)
if f > bound:
return f
if node == goal:
return path
min_bound = float('inf')
for next_node, cost in neighbors(node):
if next_node not in path:
path.append(next_node)
t = search(path, g + cost, bound)
if isinstance(t, list):
return t
if t < min_bound:
min_bound = t
path.pop()
return min_bound
bound = h(start)
path = [start]
while True:
t = search(path, 0, bound)
if isinstance(t, list):
return t
if t == float('inf'):
return None
bound = t示例:
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('D', 1)],
'C': [('D', 1)],
'D': []
}
def neighbors(n): return graph.get(n, [])
h = lambda x: {'A': 3, 'B': 2, 'C': 1, 'D': 0}[x]
print(ida_star('A', 'D', h, lambda n: graph.get(n, []))) # ['A', 'B', 'D']为什么它很重要
- 内存高效的 A*:使用 $O(d)$ 空间(仅深度)。
- 如果启发函数是可采纳的,则保证最优解。
- 应用于以下领域:
- 15 拼图和滑块谜题
- 机器人运动规划
- 人工智能游戏搜索
- A* 无法装入 RAM 的大规模路径查找
亲自尝试
- 在带有障碍物的 $8 \times 8$ 网格上使用 IDA*。
- 比较与 A* 的内存使用情况和探索的节点数。
- 尝试不同的启发函数紧密度(例如,曼哈顿距离与欧几里得距离)。
- 实现截止可视化,显示每次迭代阈值如何扩展。
测试用例
| 图 | 启发函数 | 期望路径 |
|---|---|---|
| A–B–D(单位成本) | 可采纳的 | A → B → D |
| 带有 C 的分支 | $h(C) > h^*(C)$ | 仍能找到 A → B → D |
| 网格世界 | 曼哈顿距离 | 逐步找到最优路径 |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间 | $O(b^{d})$ 最坏情况(类似 DFS) |
| 空间 | $O(d)$(递归深度) |
尽管 IDA* 可能在迭代中重新访问节点,但与 A* 相比,它节省了指数量级的内存。
一个温和的证明(为什么它有效)
每次迭代都会探索所有 $f(n) \le$ 当前阈值的路径。由于阈值按 $f$ 值的递增顺序增长,因此第一次达到目标的 $f$ 值时,它必然是最小的。因此,只要 $h$ 是可采纳的,IDA* 就保持了完备性和最优性。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 有限内存的启发式搜索 |
| 核心思想 | 迭代加深成本阈值 $f = g + h$ |
| 保证 | 如果启发函数是可采纳的,则结果最优 |
| 数据结构 | 递归 DFS |
| 时间复杂度 | $O(b^{d})$ |
| 空间复杂度 | $O(d)$ |
| 应用 | 谜题求解、路径查找、规划 |
IDA* 耐心而明智地进行搜索,就像以稳定、谨慎的阈值攀登一座山,知道每一步更高的攀登都以最少的努力让你更接近顶峰。
995. 一致代价搜索(UCS)
一致代价搜索(UCS)是一种经典算法,用于在所有边成本均为非负的情况下寻找节点之间的最小成本路径。 它本质上是将 Dijkstra(迪杰斯特拉)算法框架化为搜索问题,优先扩展成本最小的节点,无需任何启发式信息即可确保最优性。
我们要解决什么问题?
给定一个加权图 $G = (V, E)$,其中每条边 $(u, v)$ 都有一个非负成本 $c(u, v)$, 寻找从起始节点 $s$ 到目标节点 $g$ 的路径,使得总路径成本最小化: $$ C(s, g) = \sum_{(u, v) \in \text{path}} c(u, v) $$
与 BFS(假设单位成本)不同,UCS 推广到了任意的正成本。
核心思想
- 总是扩展已知累积成本 $g(n)$ 最小的节点。
- 跟踪为每个节点找到的最佳成本。
- 一旦目标节点从边界队列中出列,其成本保证是最小的。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 用起始节点 $(s, 0)$ 初始化优先队列 |
| 2 | 当队列不为空时: |
| a. 弹出具有最小成本 $g(u)$ 的节点 $u$ | |
| b. 如果 $u$ 是目标节点 → 返回路径 | |
| c. 对于每个邻居节点 $v$: | |
| 计算 $g'(v) = g(u) + c(u, v)$ | |
| 如果 $v$ 未被访问或找到更便宜的路径 → 更新并推入队列 |
微型代码(Python 示例)
import heapq
def uniform_cost_search(graph, start, goal):
pq = [(0, start, [])]
visited = {}
while pq:
cost, node, path = heapq.heappop(pq)
if node == goal:
return path + [node], cost
if node in visited and visited[node] <= cost:
continue
visited[node] = cost
for neighbor, edge_cost in graph[node]:
heapq.heappush(pq, (cost + edge_cost, neighbor, path + [node]))
return None, float('inf')示例:
graph = {
'A': [('B', 1), ('C', 5)],
'B': [('C', 2), ('D', 4)],
'C': [('D', 1)],
'D': []
}
path, cost = uniform_cost_search(graph, 'A', 'D')
print(path, cost) # ['A', 'B', 'C', 'D'], 4为什么它重要
- 无需启发式信息即可在加权图中找到最优路径。
- 构成了 A* 算法的基础(在 A* 中加入了启发式信息)。
- 在以下领域很有用:
- 导航和物流
- 网络路由
- 规划与调度
- 无启发式信息可用时的人工智能寻路
亲自尝试
- 修改 UCS,使其在达到特定深度后停止。
- 比较 UCS 与 Dijkstra(迪杰斯特拉)算法,注意其边界逻辑是相同的。
- 在一个网格世界中使用 UCS,其中不同的地形具有不同的穿越成本。
- 可视化 UCS 如何按成本递增的顺序扩展节点。
测试用例
| 图结构 | 起始点 | 目标点 | 期望路径 | 成本 |
|---|---|---|---|---|
| A–B–C (1, 2) | A | C | A → B → C | 3 |
| A–C (5), A–B–C (1, 2) | A | C | A → B → C | 3 |
| 加权网格 | (0,0) | (2,2) | 最小成本路线 | 可变 |
复杂度
| 度量指标 | 复杂度 |
|---|---|
| 时间 | $O(E \log V)$ |
| 空间 | $O(V)$ |
UCS 按成本递增的顺序探索节点,类似于 Dijkstra(迪杰斯特拉)算法。 在稠密图中,由于在剪枝前会保留到节点的多条路径,内存使用量可能会增长。
一个温和的证明(为什么它有效)
UCS 是最优的,因为它按路径成本 $g(n)$ 非递减的顺序扩展节点。 当选择目标节点 $g$ 进行扩展时,队列中不可能存在到 $g$ 的更便宜路径。 这源于边成本的单调性($c(u, v) \ge 0$)。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 无信息的基于成本的搜索 |
| 核心思想 | 扩展具有最低累积成本的节点 |
| 保证 | 如果边成本非负,则结果最优 |
| 数据结构 | 优先队列(最小堆) |
| 时间复杂度 | $O(E \log V)$ |
| 空间复杂度 | $O(V)$ |
| 应用 | 寻路、路由、规划 |
一致代价搜索是稳健且有耐心的,它从不猜测,从不匆忙。 它只是遵循阻力最小的路径,一次一个最小成本,直到确定无疑地到达目标。
996. 蒙特卡洛树搜索 (MCTS)
蒙特卡洛树搜索 (MCTS) 是现代游戏 AI 的算法核心。 它结合了随机采样、增量式树构建和统计推理,无需穷举搜索即可做出近乎最优的决策。 MCTS 因在 AlphaGo、AlphaZero 等通过模拟探索可能性来学习的智能体中的应用而闻名。
我们要解决什么问题?
我们希望在巨大的搜索空间(如围棋、国际象棋或规划任务)中找到最佳着法或行动,其中完整的博弈树过于庞大,无法完全探索。
MCTS 通过重复模拟随机对局来评估行动的价值,从而避免完全展开。
每次迭代包含四个步骤:选择、扩展、模拟、反向传播。
核心思想
树中的每个节点代表一个状态。 算法非对称地增长树,更多地关注有希望的路径。
每次迭代:
选择: 使用树策略(如 UCT)从根节点开始遍历树,直到遇到一个未完全展开的节点。 选择最大化以下公式的子节点 $i$: $$ UCT(i) = \bar{X}_i + c \sqrt{\frac{\ln N}{n_i}} $$ 其中 $\bar{X}_i$ 是平均奖励,$n_i$ 是访问次数,$N$ 是父节点的总访问次数。
扩展: 添加一个或多个未探索的子节点。
模拟: 从该新节点开始运行随机走子(对局),直到游戏结束。 记录结果(胜/负/得分)。
反向传播: 沿着路径回溯到根节点,更新访问次数和奖励值。
随着时间的推移,估计值会收敛,根节点下平均奖励最高的子节点就对应着最佳着法。
算法大纲
| 步骤 | 描述 |
|---|---|
| 1 | 从根节点(当前游戏状态)开始。 |
| 2 | 重复 N 次迭代: |
| a. 使用 UCT 选择有希望的节点。 | |
| b. 通过生成一个新子节点进行扩展。 | |
| c. 从该节点开始模拟一个随机游戏。 | |
| d. 将结果反向传播回树中。 | |
| 3 | 返回具有最高平均奖励的子节点。 |
微型代码(Python 示例)
import math, random
class Node:
def __init__(self, state, parent=None):
self.state = state
self.parent = parent
self.children = []
self.visits = 0
self.value = 0
def uct(node, c=1.414):
if node.visits == 0:
return float('inf')
return node.value / node.visits + c * math.sqrt(math.log(node.parent.visits) / node.visits)
def mcts(root, iter_limit, get_moves, simulate, make_move):
for _ in range(iter_limit):
node = root
# 选择
while node.children and all(c.visits > 0 for c in node.children):
node = max(node.children, key=uct)
# 扩展
moves = get_moves(node.state)
if moves:
move = random.choice(moves)
new_state = make_move(node.state, move)
child = Node(new_state, parent=node)
node.children.append(child)
node = child
# 模拟
result = simulate(node.state)
# 反向传播
while node:
node.visits += 1
node.value += result
node = node.parent
return max(root.children, key=lambda n: n.value / n.visits)为何重要
- 平衡了探索(尝试新着法)和利用(偏向已知的好着法)。
- 能很好地扩展到极大的搜索空间。
- 应用于:
- 游戏 AI(围棋、国际象棋、六边形棋、将棋、视频游戏)
- 规划和机器人学
- 强化学习混合体(AlphaZero)
亲自尝试
- 在井字棋或四子棋上实现 MCTS。
- 改变探索常数 $c$ 以观察权衡行为。
- 比较纯随机走子与启发式引导走子。
- 添加时间限制而非迭代次数限制。
测试用例
| 领域 | 行动空间 | 行为 |
|---|---|---|
| 井字棋 | 小 | 快速收敛 |
| 围棋 | 巨大 | 需要 10 万+ 次模拟 |
| 网格导航 | 连续 | 需要状态抽象 |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间 | $O(N \times D)$ (迭代次数 × 深度) |
| 空间 | $O(N)$ (树节点) |
每次迭代都会逐步增长树。 性能取决于模拟策略的好坏。
一个温和的证明(为何有效)
经过足够多的迭代后,MCTS 会收敛到最优的极小极大值, 因为所有行动都会被无限次地探索,并且根据大数定律,它们的价值估计值会接近期望回报。
UCT 规则确保了探索和利用之间的对数平衡。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 树搜索中的蒙特卡洛采样 |
| 核心思想 | 通过模拟对局增长搜索树 |
| 探索 | 置信上限 (UCT) |
| 保证 | 渐近收敛到最优策略 |
| 时间复杂度 | $O(ND)$ |
| 空间复杂度 | $O(N)$ |
| 应用 | 游戏、规划、RL 混合体 |
MCTS 的学习不是通过查看每一条路径,而是通过明智地采样。 每一次模拟都是对未来的一瞥,它们共同引导搜索走向精通。
997. Minimax(极小化极大)算法
Minimax 算法是游戏人工智能的基石。它模拟了两个对手之间的决策过程,一方试图最大化得分,另一方则试图最小化得分。Minimax 通过探索游戏树,并假设双方都采取最优策略进行游戏。
它被用于国际象棋、井字棋、跳棋以及任何确定性的、回合制的、零和游戏中。
我们要解决什么问题?
我们想要在一个双人游戏中选出最佳的可能走法,在这个游戏中,每个回合的走法在最大化结果和最小化结果之间交替进行。
对于一个回合交替的游戏树:
- MAX 试图最大化评估函数的值。
- MIN 试图最小化评估函数的值。
最终结果是通过在树中递归地应用这些对立的目标来确定的。
核心思想
在每个节点(游戏状态):
- 如果是 MAX 的回合,则在子节点中选择具有最大值的走法。
- 如果是 MIN 的回合,则在子节点中选择具有最小值的走法。
节点的值通过递归计算:
$$ V(s) = \begin{cases} \text{Utility}(s), & \text{如果 } s \text{ 是终止状态},\ \max_{a \in \text{Actions}(s)} V(\text{Result}(s,a)), & \text{如果 } s \text{ 是 MAX 的回合},\ \min_{a \in \text{Actions}(s)} V(\text{Result}(s,a)), & \text{如果 } s \text{ 是 MIN 的回合.} \end{cases} $$
这在完美对弈的前提下产生了一个最优策略。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 构建或模拟游戏树到一个固定的深度,或者直到达到终止状态。 |
| 2 | 为终止节点分配评估分数(赢/输/平局或启发式分数)。 |
| 3 | 向上传播分数:在每一层交替进行最大化或最小化操作。 |
| 4 | 根节点的最佳走法是具有最高传播分数的子节点。 |
微型代码(Python 示例)
def minimax(state, depth, maximizing_player, evaluate, get_moves, make_move):
if depth == 0 or not get_moves(state):
return evaluate(state)
if maximizing_player:
max_eval = float('-inf')
for move in get_moves(state):
new_state = make_move(state, move)
eval = minimax(new_state, depth - 1, False, evaluate, get_moves, make_move)
max_eval = max(max_eval, eval)
return max_eval
else:
min_eval = float('inf')
for move in get_moves(state):
new_state = make_move(state, move)
eval = minimax(new_state, depth - 1, True, evaluate, get_moves, make_move)
min_eval = min(min_eval, eval)
return min_eval示例(井字棋启发式函数):
def evaluate(state):
if state == 'win': return 1
if state == 'lose': return -1
return 0为何重要
- 捕捉了确定性游戏中完美的理性对弈。
- 为 Alpha-Beta 剪枝和蒙特卡洛树搜索提供了基础。
- 展示了搜索与策略推理之间的联系。
应用于:
- 回合制游戏(国际象棋、跳棋、四子棋)
- 具有对抗动态的简单规划
亲自尝试
- 为井字棋实现 Minimax 算法。
- 为非终止位置添加一个启发式评估器。
- 比较使用剪枝和不使用剪枝的性能。
- 可视化游戏树的扩展过程。
测试用例
| 游戏 | 深度 | 行为 | 结果 |
|---|---|---|---|
| 井字棋 | 完全 | 最优对弈 | 平局 |
| 四子棋 | 4 | 近似最优 | 具有竞争力的对弈 |
| 简化版国际象棋 | 2 | 贪心对弈 | 次优但有效 |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间复杂度 | $O(b^d)$ |
| 空间复杂度 | $O(bd)$ |
其中 $b$ 是分支因子,$d$ 是搜索深度。
一个温和的证明(为何有效)
因为假设双方玩家都是最优的,所以每个节点的值代表了当前玩家在假设对手也采取最优策略的情况下,所能达到的最佳结果。
通过逆向归纳法,这得出了双人确定性游戏的纳什均衡。
总结表格
| 概念 | 描述 |
|---|---|
| 框架 | 对抗性搜索 |
| 核心思想 | 交替进行最大化与最小化决策 |
| 保证 | 在完美对弈下最优 |
| 数据结构 | 游戏树 |
| 时间复杂度 | $O(b^d)$ |
| 空间复杂度 | $O(bd)$ |
| 应用 | 棋盘游戏、策略规划 |
Minimax 扮演着完美策略家的角色,它不会感情用事,不会赌博,只是遵循逻辑走向必然的结局:要么完美对弈,要么被击败。
998. Alpha–Beta 剪枝
Alpha–Beta 剪枝是 Minimax 算法的一种优化。 它剪掉那些不会影响最终决策的分支,使我们能够在不改变结果的情况下进行更深的搜索。
本质上,它告诉计算机:“不要探索那步棋,它不可能改变结果。”
我们要解决什么问题?
Minimax 会探索博弈树中的每一个节点,即使某些走法已经被证明更差。 Alpha–Beta 剪枝通过跟踪两个边界值来避免这种情况:
- α (alpha),到目前为止,最大化玩家(MAX)能保证的最佳值。
- β (beta),到目前为止,最小化玩家(MIN)能保证的最佳值。
如果在任何时候 α ≥ β,我们就停止探索该分支,因为它无法影响决策。
核心思想
在遍历树时:
- MAX 节点更新 α = max(α, value)。
- MIN 节点更新 β = min(β, value)。
- 如果 α ≥ β,则剪掉该分支的其余部分(剪枝)。
这减少了需要评估的节点数量,同时保证了正确性。
算法
| 步骤 | 描述 |
|---|---|
| 1 | 初始化 α = −∞, β = +∞。 |
| 2 | 像 Minimax 一样递归地评估博弈树。 |
| 3 | 对于 MAX 节点,更新 α;对于 MIN 节点,更新 β。 |
| 4 | 如果 α ≥ β,剪掉剩余的子节点。 |
| 5 | 根据玩家类型返回 α 或 β。 |
微型代码(Python 示例)
def alphabeta(state, depth, alpha, beta, maximizing, evaluate, get_moves, make_move):
if depth == 0 or not get_moves(state):
return evaluate(state)
if maximizing:
value = float('-inf')
for move in get_moves(state):
new_state = make_move(state, move)
value = max(value, alphabeta(new_state, depth-1, alpha, beta, False, evaluate, get_moves, make_move))
alpha = max(alpha, value)
if alpha >= beta: # Beta 剪枝
break
return value
else:
value = float('inf')
for move in get_moves(state):
new_state = make_move(state, move)
value = min(value, alphabeta(new_state, depth-1, alpha, beta, True, evaluate, get_moves, make_move))
beta = min(beta, value)
if beta <= alpha: # Alpha 剪枝
break
return value示例:
score = alphabeta(start_state, 4, float('-inf'), float('inf'), True, evaluate, get_moves, make_move)为什么它很重要
- 与 Minimax 结果相同,但评估的节点数量要少得多。
- 能够在复杂的游戏中实现更深的搜索。
- 构成了国际象棋、跳棋和围棋引擎的基础。
它是经典游戏 AI 的标准搜索核心。
亲自尝试
- 比较 Minimax 和 Alpha–Beta 扩展的节点数量。
- 实现走法排序,观察剪枝效果如何显著提升。
- 将其应用于井字棋、四子棋或小型国际象棋子集。
- 添加置换表以跳过重复状态。
测试用例
| 游戏 | 深度 | 算法 | 评估的节点数 |
|---|---|---|---|
| 井字棋 | 6 | Minimax | ~550 |
| 井字棋 | 6 | Alpha–Beta | ~80 |
| 四子棋 | 6 | Alpha–Beta | 节点数减少约 40% |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间 | $O(b^{m})$ 最佳情况 $O(b^{d})$ 最坏情况 |
| 空间 | $O(bd)$ |
其中:
- $b$ = 分支因子
- $d$ = 搜索深度
- $m$ = 剪枝后的有效深度(取决于走法顺序)
一个温和的证明(为什么它有效)
如果在某个节点 α ≥ β,意味着当前玩家已经在其他地方找到了更好的走法, 并且这个分支无法改善结果。 因此剪枝是安全的,最优值保持不变。
形式上,Alpha–Beta 保留了 Minimax 的逆向归纳性质。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 优化的对抗性搜索 |
| 核心思想 | 剪掉不影响结果的分支 |
| 保证 | 与 Minimax 结果相同 |
| 关键变量 | α (MAX 的最佳值), β (MIN 的最佳值) |
| 时间复杂度 | $O(b^m)$ (带剪枝) |
| 空间复杂度 | $O(bd)$ |
| 应用领域 | 游戏引擎,决策型 AI |
Alpha–Beta 剪枝是高效的 Minimax, 它是一位大师级战略家,知道何时不该浪费时间思考无关紧要的走法。
999. STRIPS 规划
STRIPS(斯坦福研究所问题求解器)是人工智能规划领域的一个基础算法,旨在自动生成从初始状态到达期望目标的一系列动作序列。
它于 20 世纪 70 年代初被开发出来,至今仍是现代规划器和符号推理系统的概念基础。
我们要解决什么问题?
我们想要一个能规划动作以实现目标的算法,而不仅仅是反应性地行动。
给定:
- 一个初始状态(关于世界的事实)
- 一个目标(需要被满足的条件)
- 一组动作,每个动作包含:
- 前提条件(应用该动作前必须为真的事实)
- 添加效果(动作执行后变为真的事实)
- 删除效果(动作执行后变为假的事实)
STRIPS 系统地搜索一个动作序列,该序列能将初始状态转换为满足目标的状态。
核心思想
STRIPS 通过符号化的状态空间搜索来工作,应用改变世界事实的算子。
每个动作定义为:
$$ \text{Action}(A) = \langle \text{Pre}(A), \text{Add}(A), \text{Del}(A) \rangle $$
当应用于状态 $S$ 时:
$$ \text{Result}(S, A) = (S - \text{Del}(A)) \cup \text{Add}(A) $$
规划器使用前向搜索(从起点到目标)或后向搜索(从目标到起点)来寻找一个有效的计划,即一个能导向目标的动作序列 $\langle A_1, A_2, ..., A_n \rangle$。
示例
假设我们想让机器人从房间 1 移动到房间 2:
- 初始状态:In(Room1)
- 目标:In(Room2)
- 动作:Move(Room1, Room2)
| 动作 | 前提条件 | 添加效果 | 删除效果 |
|---|---|---|---|
| Move(x, y) | In(x) | In(y) | In(x) |
前向搜索应用此算子:
$$ S_0 = { In(Room1) } $$ $$ Move(Room1, Room2):\quad S_1 = (S_0 - { In(Room1) }) \cup { In(Room2) } = { In(Room2) } $$
目标达成。
算法概述
| 步骤 | 描述 |
|---|---|
| 1 | 使用谓词表示初始状态、目标和动作。 |
| 2 | 从初始状态开始。 |
| 3 | 选择可应用的动作(其前提条件为真)。 |
| 4 | 应用动作以生成后继状态。 |
| 5 | 持续进行,直到满足目标条件。 |
可以使用 BFS、DFS 或基于启发式的搜索(如 A*)。
微型代码(Python 示例)
from collections import deque
def strips_plan(initial, goal, actions):
queue = deque([(initial, [])])
while queue:
state, plan = queue.popleft()
if goal <= state: # 所有目标事实均被满足
return plan
for name, pre, add, delete in actions:
if pre <= state:
new_state = (state - delete) | add
queue.append((new_state, plan + [name]))
return None
# 示例用法
actions = [
("Move(Room1, Room2)", {"In(Room1)"}, {"In(Room2)"}, {"In(Room1)"}),
$$
plan = strips_plan({"In(Room1)"}, {"In(Room2)"}, actions)
print(plan) # ['Move(Room1, Room2)']为何重要
- 引入了形式化的符号规划,使机器能够推理必须做什么,而不仅仅是现在要做什么。
- 是 PDDL(规划领域定义语言)、自动定理证明和机器人运动规划的基础。
- 其概念在现代 AI 规划器和带有符号模型的强化学习中仍在使用。
亲自尝试
- 对一个积木堆叠问题(积木 A 在 B 上,B 在 C 上等)进行建模。
- 实现后向搜索(回归)规划。
- 添加多个动作并测试分支。
- 在同一领域比较 STRIPS 规划与 A* 搜索。
测试用例
| 领域 | 初始状态 | 目标 | 计划 |
|---|---|---|---|
| 移动 | In(Room1) | In(Room2) | [Move(Room1, Room2)] |
| 积木 | On(A, Table), Clear(B) | On(A, B) | [Pick(A), Stack(A, B)] |
| 物流 | In(Truck, Depot), At(Package, Depot) | Delivered(Package) | [Load, Drive, Unload] |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间 | 相对于动作数量呈指数级 |
| 空间 | 相对于状态表示大小呈指数级 |
一个温和的证明(为何有效)
对于可用命题逻辑表达的领域,STRIPS 是可靠且完备的:如果存在满足目标的计划,STRIPS 最终会找到它,因为所有可应用的动作都被系统地探索了。
它将世界编码为逻辑转换,确保每一步都保持一致性。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 符号化 AI 规划 |
| 核心思想 | 使用逻辑动作算子进行搜索 |
| 保证 | 可靠且完备(有限领域) |
| 数据结构 | 状态表示为谓词集合 |
| 时间复杂度 | 指数级 |
| 空间复杂度 | 指数级 |
| 应用 | 机器人学、规划、自动推理 |
STRIPS 标志着 AI 作为推理的黎明,机器开始从必须为真和必须改变什么的角度来思考,以使世界与目标相匹配。
1000. 分层任务网络(HTN)规划
分层任务网络(HTN)规划通过为计划形成方式添加结构,扩展了经典的 STRIPS 规划。 它不是搜索扁平、原子的动作,而是将动作组织成任务,其中一些是抽象的(高层级的),另一些是原始的(可执行的)。 这反映了人类的思维方式:"要旅行,先打包,然后去机场,然后飞行。"
我们要解决什么问题?
经典的 STRIPS 擅长探索所有可能的动作,但它的扩展性不好,也不能编码关于任务通常如何完成的知识。 HTN 规划引入了分层分解,让规划器能够在领域知识的指导下,将复杂目标分解为可管理的子任务。
给定:
- 一个初始状态
- 一组任务,其中
- 原始任务是可执行的动作
- 复合任务使用方法分解为子任务
规划器递归地将主要目标任务细化为更小的动作,直到只剩下原始步骤。
核心思想
每个方法都根据当前上下文,告诉规划器如何实现一个复合任务。
设:
- $T$ = 任务集合
- $M$ = 方法集合
- $A$ = 原始动作集合
一个方法 $m$ 定义为:
$$ m = \langle \text{name}(m), \text{task}(m), \text{subtasks}(m), \text{preconditions}(m) \rangle $$
规划器从顶层任务 $t_0$ 开始,使用适用的方法递归地分解它, 并持续进行,直到计划完全由可执行动作组成。
示例
让我们规划 Deliver(Package, Destination):
初始状态: At(Truck, Depot), At(Package, Depot)
目标任务: Deliver(Package, Destination)
方法:
| 方法 | 前置条件 | 子任务 |
|---|---|---|
| Deliver-by-truck | At(Truck, x), At(Package, x) | [Load(Package, Truck), Drive(Truck, x, Destination), Unload(Package, Truck)] |
原始动作(STRIPS 风格):
Load(p, t):前置条件 {At(p, x), At(t, x)},添加效果 {In(p, t)},删除效果 {At(p, x)}Drive(t, x, y):前置条件 {At(t, x)},添加效果 {At(t, y)},删除效果 {At(t, x)}Unload(p, t):前置条件 {In(p, t), At(t, y)},添加效果 {At(p, y)},删除效果 {In(p, t)}
分解后得到最终的可执行计划:
- Load(Package, Truck)
- Drive(Truck, Depot, Destination)
- Unload(Package, Truck)
算法大纲
| 步骤 | 描述 |
|---|---|
| 1 | 从任务网络(要完成的任务列表)开始。 |
| 2 | 选取一个任务:如果是原始任务,检查前置条件并执行;如果是复合任务,找到一个适用的方法。 |
| 3 | 用其子任务替换复合任务。 |
| 4 | 重复直到只剩下原始动作为止。 |
| 5 | 执行原始动作以达到目标。 |
微型代码(Python 示例)
def htn_plan(tasks, methods, actions, state):
if not tasks:
return []
task = tasks[0]
rest = tasks[1:]
# 原始任务
if task in actions:
pre, add, delete = actions[task]
if pre <= state:
new_state = (state - delete) | add
return [task] + htn_plan(rest, methods, actions, new_state)
else:
return None
# 复合任务
for name, goal, subtasks, pre in methods:
if goal == task and pre <= state:
new_tasks = subtasks + rest
plan = htn_plan(new_tasks, methods, actions, state)
if plan:
return plan
return None示例领域设置:
actions = {
"Load": ({"At(Package, Depot)", "At(Truck, Depot)"}, {"In(Package, Truck)"}, {"At(Package, Depot)"}),
"Drive": ({"At(Truck, Depot)"}, {"At(Truck, Destination)"}, {"At(Truck, Depot)"}),
"Unload": ({"In(Package, Truck)", "At(Truck, Destination)"}, {"At(Package, Destination)"}, {"In(Package, Truck)"})
}
methods = [
("Deliver-by-truck", "Deliver", ["Load", "Drive", "Unload"], {"At(Truck, Depot)", "At(Package, Depot)"})
$$
plan = htn_plan(["Deliver"], methods, actions, {"At(Truck, Depot)", "At(Package, Depot)"})
print(plan)输出:
$$'Load', 'Drive', 'Unload']为什么它很重要
- 明确地编码领域知识,即任务通常如何分解。
- 为复杂系统(机器人、物流、仿真)实现可扩展的规划。
- 弥合了符号规划与现实世界过程性知识之间的差距。
应用领域:
- 机器人学(多步骤操作任务)
- 游戏 AI(单位控制、故事规划)
- 仿真引擎(多智能体协调)
亲自尝试
- 为家庭机器人构建一个 HTN 规划器。
- 为 CookMeal、CleanRoom、MakeTea 添加方法。
- 可视化分解树。
- 比较有分层指导和无分层指导时的计划长度。
测试用例
| 领域 | 任务 | 计划 |
|---|---|---|
| 物流 | Deliver(Package) | [Load, Drive, Unload] |
| 家庭 | MakeTea | [BoilWater, AddTea, Pour] |
| 游戏 AI | AttackEnemy | [MoveToTarget, Aim, Fire] |
复杂度
| 度量 | 复杂度 |
|---|---|
| 时间复杂度 | 指数级(取决于方法的分支情况) |
| 空间复杂度 | 与分解深度成线性关系 |
一个温和的证明(为什么它有效)
HTN 保持了可靠性: 如果每个方法都是有效的(其子任务实现了其父任务的目标),那么整个计划就是有效的。
它并不完备(如果不存在适用的方法,它可能会错过有效的计划), 但它实用得多,用类似人类的结构来指导搜索。
总结表
| 概念 | 描述 |
|---|---|
| 框架 | 分层符号规划 |
| 核心思想 | 将复杂任务分解为更简单的子任务 |
| 保证 | 可靠但不完备 |
| 数据结构 | 任务网络 |
| 时间复杂度 | 指数级 |
| 空间复杂度 | 线性 |
| 应用 | 机器人、仿真、游戏 AI、工作流自动化 |
HTN 规划结束了我们 1000 个算法的旅程, 从数字排序到组织智能行为。 它展示了结构、知识和递归如何结合,将推理转化为行动, 并将行动转化为目的。