diff --git a/scripts/auditFormulas.mjs b/scripts/auditFormulas.mjs new file mode 100644 index 0000000..6d69515 --- /dev/null +++ b/scripts/auditFormulas.mjs @@ -0,0 +1,41 @@ +// 公式审计:扫描全部 10 章课程数据里的 $$/$ 公式,用 KaTeX 严格解析找出渲染失败项 +import katex from 'katex' +import { NLP_LESSONS } from '../src/data/ai/chapters/nlp.js' +import { CV_LESSONS } from '../src/data/ai/chapters/cv.js' +import { RL_LESSONS } from '../src/data/ai/chapters/rl.js' +import { LLM_LESSONS } from '../src/data/ai/chapters/llm.js' +import { OPTIM_LESSONS } from '../src/data/ai/chapters/optim.js' +import { ML_LESSONS } from '../src/data/ai/chapters/ml.js' +import { DL_LESSONS } from '../src/data/ai/chapters/dl.js' +import { OR_LESSONS } from '../src/data/ai/chapters/or.js' +import { FEATURE_LESSONS } from '../src/data/ai/chapters/feature.js' +import { INFO_THEORY_LESSONS } from '../src/data/ai/chapters/it.js' + +const groups = { + nlp: NLP_LESSONS, cv: CV_LESSONS, rl: RL_LESSONS, llm: LLM_LESSONS, + optim: OPTIM_LESSONS, ml: ML_LESSONS, dl: DL_LESSONS, or: OR_LESSONS, + feature: FEATURE_LESSONS, it: INFO_THEORY_LESSONS, +} +let bad = 0 + +for (const [name, lessons] of Object.entries(groups)) { + for (const lesson of lessons) { + const theory = lesson.theory || '' + const formulas = [] + for (const m of theory.matchAll(/\$\$([\s\S]+?)\$\$/g)) formulas.push({ tex: m[1], mode: 'block' }) + const noBlocks = theory.replace(/\$\$[\s\S]+?\$\$/g, '') + for (const m of noBlocks.matchAll(/\$([^$\n]+?)\$/g)) formulas.push({ tex: m[1], mode: 'inline' }) + + for (const f of formulas) { + try { + katex.renderToString(f.tex, { displayMode: f.mode === 'block', throwOnError: true, strict: false }) + } catch (err) { + bad++ + console.log(`[${name}] ${lesson.id} (${f.mode}):`) + console.log(` TEX: ${f.tex.slice(0, 120)}`) + console.log(` ERR: ${String(err.message).slice(0, 160)}`) + } + } + } +} +console.log(bad === 0 ? 'ALL FORMULAS OK' : `${bad} broken formulas found`) diff --git a/src/components/StepController.jsx b/src/components/StepController.jsx index 7e19ce6..ffe5c1e 100644 --- a/src/components/StepController.jsx +++ b/src/components/StepController.jsx @@ -68,8 +68,15 @@ export default function StepController({ }) { const scrubberRef = useRef(null) const isDragging = useRef(false) + // 拖拽期间挂在 window 上的监听器的卸载函数;组件若在拖拽中途 unmount, + // 由下面的 useEffect cleanup 兜底移除,避免监听器泄漏。 + const detachDragListeners = useRef(null) const isPhone = useIsPhone() + useEffect(() => { + return () => { detachDragListeners.current?.() } + }, []) + // 活跃实例登记:挂载即认领;卸载时若仍是自己则释放 const idRef = useRef(null) if (!idRef.current) idRef.current = Symbol('step-controller') @@ -102,14 +109,18 @@ export default function StepController({ seek(posToStep(e.clientX)) window.addEventListener('mousemove', handleScrubberMouseMove) window.addEventListener('mouseup', handleScrubberMouseUp) + detachDragListeners.current = () => { + window.removeEventListener('mousemove', handleScrubberMouseMove) + window.removeEventListener('mouseup', handleScrubberMouseUp) + detachDragListeners.current = null + } } function handleScrubberMouseMove(e) { if (isDragging.current) seek(posToStep(e.clientX)) } function handleScrubberMouseUp() { isDragging.current = false - window.removeEventListener('mousemove', handleScrubberMouseMove) - window.removeEventListener('mouseup', handleScrubberMouseUp) + detachDragListeners.current?.() } function handleScrubberTouch(e) { seek(posToStep(e.touches[0].clientX)) diff --git a/src/components/learning/AlgorithmTabs.jsx b/src/components/learning/AlgorithmTabs.jsx index d6e3b50..2402bf0 100644 --- a/src/components/learning/AlgorithmTabs.jsx +++ b/src/components/learning/AlgorithmTabs.jsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useState, useCallback, memo } from 'react' +import ErrorBoundary from '../ErrorBoundary' import { Prose } from './Section' import CodeBlock from './CodeBlock' import ComplexityCards from './ComplexityCards' @@ -157,9 +158,13 @@ const AlgorithmTabs = memo(function AlgorithmTabs({ algo }) { {active === 'notes' && ( - 正在加载笔记...}> - - + 笔记模块加载失败,请刷新页面重试。 + }> + 正在加载笔记...}> + + + )} diff --git a/src/contexts/ProgressContext.jsx b/src/contexts/ProgressContext.jsx index d944f70..cf9ff0d 100644 --- a/src/contexts/ProgressContext.jsx +++ b/src/contexts/ProgressContext.jsx @@ -109,6 +109,16 @@ export function ProgressProvider({ children }) { return () => { cancelled = true } }, [userId, store]) + // 页面隐藏 / 卸载时立即冲刷防抖队列,避免刚产生的进度变更丢在 pending 里。 + // pagehide 在移动端比 beforeunload 更可靠;flush 是 best-effort,失败也不影响 + // 本地副本(下次登录 syncWithRemote 会整体合并推回)。 + useEffect(() => { + if (!userId) return + const onPageHide = () => { sync.flushNow() } + window.addEventListener('pagehide', onPageHide) + return () => window.removeEventListener('pagehide', onPageHide) + }, [userId]) + // 登录态下订阅 realtime useEffect(() => { if (!userId) return diff --git a/src/data/ai/chapters/cv.js b/src/data/ai/chapters/cv.js index 45e8fcb..4cbedb9 100644 --- a/src/data/ai/chapters/cv.js +++ b/src/data/ai/chapters/cv.js @@ -6,35 +6,1872 @@ export const CV_LESSONS = [ { id: 'cv-image-classification', title: '图像分类', - summary: '经典网络架构:LeNet、ResNet、VGG', + summary: '经典 CNN 架构:LeNet、AlexNet、VGG、ResNet、EfficientNet', theory: `## 图像分类 -使用 CNN 对图像进行类别预测。 +使用卷积神经网络(CNN)对图像进行类别预测。输入是一张图像,输出是该图像属于各个类别的概率分布。 -### 经典架构 +### 经典架构演进 -| 网络 | 年份 | 创新 | -|------|------|------| -| LeNet | 1998 | 卷积+池化 | -| AlexNet | 2012 | ReLU、Dropout | -| VGG | 2014 | 小卷积核堆叠 | -| ResNet | 2015 | 残差连接 | +| 网络 | 年份 | 创新 | Top-5 错误率 (ImageNet) | +|------|------|------|------------------------| +| LeNet-5 | 1998 | 卷积+池化,手写数字识别 | — | +| AlexNet | 2012 | ReLU、Dropout、GPU 训练 | 15.3% | +| VGGNet | 2014 | 小卷积核堆叠(3×3) | 7.3% | +| ResNet | 2015 | 残差连接,解决梯度消失 | 3.57% | +| EfficientNet | 2019 | 复合缩放策略 | 1.9% | + +### 残差连接 + +ResNet 的核心创新是残差块(Residual Block): + +$$H(x) = F(x) + x$$ + +其中 $F(x)$ 是卷积层学习的残差,$x$ 是恒等映射。残差连接使梯度可以直接回传,有效缓解了深层网络的梯度消失问题。 + +### 卷积层参数计算 + +$$\\text{输出尺寸} = \\frac{W - K + 2P}{S} + 1$$ + +其中 $W$ 是输入尺寸,$K$ 是卷积核大小,$P$ 是填充,$S$ 是步长。 +`, + exercise: { type: 'playground', viz: 'imageClassification' }, + code: { + cpp: `// 简单的卷积层实现 +struct ConvLayer { + int in_channels, out_channels, kernel_size, stride, padding; + vector>>> weight; // [out][in][k][k] + vector bias; + + vector>> forward(const vector>>& input) { + int H = input[0].size(), W = input[0][0].size(); + int outH = (H - kernel_size + 2 * padding) / stride + 1; + int outW = (W - kernel_size + 2 * padding) / stride + 1; + vector>> output(out_channels, + vector>(outH, vector(outW, 0))); + + for (int oc = 0; oc < out_channels; oc++) { + for (int oh = 0; oh < outH; oh++) { + for (int ow = 0; ow < outW; ow++) { + float sum = bias[oc]; + for (int ic = 0; ic < in_channels; ic++) { + for (int kh = 0; kh < kernel_size; kh++) { + for (int kw = 0; kw < kernel_size; kw++) { + int ih = oh * stride - padding + kh; + int iw = ow * stride - padding + kw; + if (ih >= 0 && ih < H && iw >= 0 && iw < W) + sum += weight[oc][ic][kh][kw] * input[ic][ih][iw]; + } + } + } + output[oc][oh][ow] = max(sum, 0.0f); // ReLU + } + } + } + return output; + } +};`, + python: `import torch +import torch.nn as nn + +class SimpleCNN(nn.Module): + def __init__(self, num_classes=1000): + super().__init__() + self.features = nn.Sequential( + nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(), + nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(), + nn.MaxPool2d(2, 2), + nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), + nn.Conv2d(128, 128, 3, padding=1), nn.ReLU(), + nn.MaxPool2d(2, 2), + ) + self.classifier = nn.Sequential( + nn.Linear(128 * 56 * 56, 4096), nn.ReLU(), + nn.Dropout(0.5), + nn.Linear(4096, num_classes), + ) + + def forward(self, x): + x = self.features(x) + x = x.view(x.size(0), -1) + return self.classifier(x)`, + }, + variablesSnapshot: { + inputSize: '224×224×3', + architecture: 'VGG-16', + parameters: '138M', + top5Accuracy: '92.7%', + }, + pseudocode: `procedure IMAGE_CLASSIFICATION(input_image) + // 卷积特征提取 + features <- input_image + for each conv_block do + features <- Conv(features) + features <- ReLU(features) + features <- MaxPool(features) + end for + + // 分类头 + flattened <- flatten(features) + logits <- FC(flattened) + probabilities <- softmax(logits) + return argmax(probabilities)`, + bigO: { + time: '卷积层计算量为 $O(C_{in} \\times C_{out} \\times K^2 \\times H_{out} \\times W_{out})$。ResNet-50 单张推理约 4 GFLOPs。', + space: '存储激活值需要 $O(C \\times H \\times W)$ 每层。训练时需要保存中间梯度,显存约为推理的 2-3 倍。', + note: 'ImageNet 基准包含 128 万张训练图、1000 类。训练通常需要多 GPU 数天。', + }, + compare: [ + { method: 'LeNet-5', data: 'MNIST 手写数字', strength: '结构简单,奠定 CNN 基础', tradeoff: '仅适用于小尺寸灰度图' }, + { method: 'AlexNet', data: 'ImageNet 224×224', strength: '首次用 GPU 训练,引入 ReLU/Dropout', tradeoff: '8 层深度有限' }, + { method: 'VGGNet', data: 'ImageNet 224×224', strength: '小卷积核堆叠,结构规整', tradeoff: '参数量大(138M),效率低' }, + { method: 'ResNet', data: 'ImageNet 224×224', strength: '残差连接支持极深网络(152+ 层)', tradeoff: '深层模型推理较慢' }, + { method: 'EfficientNet', data: 'ImageNet 224×224', strength: '复合缩放,精度和效率最佳平衡', tradeoff: '缩放策略需搜索最优组合' }, + ], + quiz: [ + { + q: 'ResNet 的残差连接主要解决什么问题?', + options: [ + '减少参数量', + '加速推理速度', + '缓解深层网络的梯度消失问题', + '减少计算量', + ], + answer: 2, + explanation: '残差连接使梯度可以通过恒等映射直接回传,有效缓解了深层网络训练中的梯度消失问题。', + }, + { + q: 'VGGNet 使用大量 3×3 卷积核的主要原因是什么?', + options: [ + '减少参数量', + '两层 3×3 卷积的感受野等于一层 5×5,且非线性更强', + '3×3 卷积计算更快', + '兼容小尺寸输入', + ], + answer: 1, + explanation: '两层 3×3 卷积的等效感受野为 5×5,但参数更少($2 \\times 9 = 18$ vs $25$),且两层激活增加了非线性表达能力。', + }, + { + q: '卷积层输出尺寸的计算公式中,P 代表什么?', + options: [ + '步长', + '卷积核大小', + '填充(padding)', + '输出通道数', + ], + answer: 2, + explanation: 'P 代表 padding(填充),在输入特征图边缘补零以控制输出尺寸。', + }, + ], + }, + { + id: 'cv-cnn-evolution', + title: 'CNN 架构演进', + summary: '从 LeNet 到 EfficientNet:深度、残差、复合缩放的进化之路', + theory: `## CNN 架构演进 + +CNN 架构的发展主线围绕三个方向:**更深**、**更宽**、**更高效**。 + +### 时间线 + +**LeNet-5 (1998)**: Yann LeCun 设计,用于手写数字识别。2 层卷积 + 2 层池化 + 2 层全连接,共约 6 万参数。 + +**AlexNet (2012)**: 深度学习里程碑,证明 CNN 可以大规模工作。5 层卷积 + 3 层全连接,6000 万参数。首次使用 ReLU 激活、Dropout 正则化、GPU 并行训练。 + +**VGGNet (2014)**: 全部使用 3×3 小卷积核堆叠。VGG-16 有 13 层卷积 + 3 层全连接,1.38 亿参数。结构规整,但参数量极大。 + +**GoogLeNet/Inception (2014)**: 引入 Inception 模块,同时使用 1×1、3×3、5×5 卷积和池化,在多尺度上提取特征。1×1 卷积用于降维,参数量仅 500 万。 + +**ResNet (2015)**: 残差连接的突破。残差块 $H(x) = F(x) + x$ 使梯度可以跳过卷积层直接回传,训练 152 层甚至 1000+ 层网络。 + +**EfficientNet (2019)**: 提出复合缩放策略,同时缩放深度 $d$、宽度 $w$ 和分辨率 $r$: + +$$d = \\alpha^\\phi, \\quad w = \\beta^\\phi, \\quad r = \\gamma^\\phi$$ + +满足 $\\alpha \\cdot \\beta^2 \\cdot \\gamma^2 \\approx 2$,在精度和效率间取得最佳平衡。 + +### 关键创新对比 + +| 创新 | 提出者 | 解决的问题 | +|------|--------|-----------| +| ReLU | AlexNet | 梯度消失(相比 sigmoid) | +| 小卷积核 | VGGNet | 减少参数,增加非线性 | +| Inception 模块 | GoogLeNet | 多尺度特征提取 | +| 残差连接 | ResNet | 深层梯度消失 | +| 复合缩放 | EfficientNet | 精度-效率最优平衡 | +`, + exercise: { type: 'playground', viz: 'imageClassification' }, + code: { + cpp: `// 残差块实现 +struct ResidualBlock { + ConvLayer conv1, conv2; + bool use_shortcut; + + vector>> forward(const vector>>& x) { + auto residual = x; + auto out = conv1.forward(x); + out = conv2.forward(out); + // 恒等映射 + 残差 + for (int c = 0; c < out.size(); c++) + for (int h = 0; h < out[0].size(); h++) + for (int w = 0; w < out[0][0].size(); w++) + out[c][h][w] += residual[c][h][w]; + return out; + } +};`, + python: `import torch.nn as nn + +class ResidualBlock(nn.Module): + def __init__(self, channels): + super().__init__() + self.conv1 = nn.Conv2d(channels, channels, 3, padding=1) + self.bn1 = nn.BatchNorm2d(channels) + self.conv2 = nn.Conv2d(channels, channels, 3, padding=1) + self.bn2 = nn.BatchNorm2d(channels) + self.relu = nn.ReLU(inplace=True) + + def forward(self, x): + residual = x + out = self.relu(self.bn1(self.conv1(x))) + out = self.bn2(self.conv2(out)) + out += residual # 残差连接 + out = self.relu(out) + return out`, + }, + variablesSnapshot: { + evolution: 'LeNet → AlexNet → VGG → Inception → ResNet → EfficientNet', + depthRange: '5 → 1000+', + params: '60K → 66M', + keyInnovation: '残差连接(ResNet)', + }, + pseudocode: `procedure CNN_EVOLUTION(input) + // LeNet: 基础卷积+池化 + features <- ConvPool(input) + + // AlexNet: 加入 ReLU 和 Dropout + features <- ConvReLU(features) + features <- Dropout(features, 0.5) + + // VGG: 小卷积核堆叠 + features <- Conv3x3(Conv3x3(features)) + + // ResNet: 残差连接 + features <- ConvBlock(features) + features // skip connection + + // EfficientNet: 复合缩放 + scaled <- scale_depth_width_resolution(features) + return classify(scaled)`, + bigO: { + time: '参数量从 LeNet 的 6 万增长到 VGG 的 1.38 亿,计算量从 0.0004 GFLOPs 增长到 15.5 GFLOPs(VGG-16)。EfficientNet-B7 虽精度更高但仅 37 GFLOPs。', + space: '存储模型参数:LeNet 约 240KB,VGG-16 约 528MB,ResNet-50 约 98MB。', + note: '实际推理延迟还取决于 GPU 架构和内存带宽,不单纯由 FLOPs 决定。', + }, + compare: [ + { method: 'LeNet-5', data: '28×28 灰度图', strength: '奠定 CNN 基础架构', tradeoff: '无法处理大尺寸彩色图' }, + { method: 'AlexNet', data: '224×224 RGB', strength: '深度学习里程碑,GPU 训练验证', tradeoff: '深度仅 8 层' }, + { method: 'VGGNet', data: '224×224 RGB', strength: '结构简洁规整,易迁移', tradeoff: '参数过多,效率低' }, + { method: 'ResNet', data: '224×224 RGB', strength: '残差连接突破深度限制', tradeoff: '深层模型推理速度慢' }, + { method: 'EfficientNet', data: '动态分辨率', strength: '复合缩放,精度效率最优', tradeoff: '缩放策略需搜索' }, + ], + quiz: [ + { + q: 'EfficientNet 的"复合缩放"是指同时缩放哪三个维度?', + options: [ + '深度、宽度、分辨率', + '参数量、计算量、内存', + '卷积核大小、步长、填充', + '学习率、批大小、迭代次数', + ], + answer: 0, + explanation: 'EfficientNet 提出同时缩放网络深度(层数)、宽度(通道数)和输入分辨率,通过约束 $\\alpha \\cdot \\beta^2 \\cdot \\gamma^2 \\approx 2$ 实现最优平衡。', + }, + { + q: 'GoogLeNet 的 Inception 模块的核心设计思想是什么?', + options: [ + '使用更大的卷积核', + '同时使用 1×1、3×3、5×5 卷积和池化提取多尺度特征', + '堆叠更多层增加深度', + '使用 1×1 卷积减少参数', + ], + answer: 1, + explanation: 'Inception 模块在同一层级并行使用不同大小的卷积核和池化操作,提取多尺度特征,再用 1×1 卷积降维减少计算量。', + }, + { + q: '为什么 VGGNet 全部使用 3×3 卷积核而不使用 5×5 或 7×7?', + options: [ + '3×3 卷积计算更快', + '两层 3×3 的感受野等于 5×5 但参数更少且非线性更强', + '3×3 卷积效果最好', + '3×3 卷积是唯一支持 GPU 的尺寸', + ], + answer: 1, + explanation: '两层 3×3 卷积的等效感受野为 5×5,参数为 $2 \\times 9C^2 = 18C^2$,而直接 5×5 为 $25C^2$。参数更少,且两层 ReLU 增加了非线性表达能力。', + }, + ], + }, + { + id: 'cv-image-augmentation', + title: '图像数据增强', + summary: '翻转、旋转、颜色抖动、随机裁剪、MixUp 等增强策略', + theory: `## 图像数据增强 + +数据增强通过对训练图像施加随机变换,增加数据多样性,**防止过拟合**,提升模型泛化能力。 + +### 几何变换 + +| 方法 | 效果 | 注意事项 | +|------|------|---------| +| 水平翻转 | 左右镜像 | 对自然图像通用,文字类需谨慎 | +| 垂直翻转 | 上下镜像 | 场景有限(如卫星图可用) | +| 随机旋转 | $[-\\theta, +\\theta]$ 旋转 | 通常 $\\theta \\leq 30°$ | +| 随机裁剪 | 从大图裁出小区域 | 常配合缩放至目标尺寸 | +| 随机缩放 | 短边缩放到随机范围 | 增加尺度多样性 | + +### 颜色变换 + +**颜色抖动(Color Jitter)**: 随机调整亮度、对比度、饱和度: + +$$\\text{output} = \\alpha \\cdot \\text{input} + \\beta$$ + +其中 $\\alpha \\in [0.8, 1.2]$(对比度),$\\beta \\in [-10, 10]$(亮度)。 + +### 高级增强 + +**MixUp**: 将两张图像线性混合: + +$$\\tilde{x} = \\lambda x_i + (1-\\lambda) x_j, \\quad \\tilde{y} = \\lambda y_i + (1-\\lambda) y_j$$ + +其中 $\\lambda \\sim \\text{Beta}(\\alpha, \\alpha)$,$\\alpha=0.2$ 常用。 + +**CutMix**: 将一张图像的矩形区域替换为另一张图像的对应区域。 + +**AutoAugment/RandAugment**: 用搜索算法自动找到最优增强策略组合。 + +### 原则 + +- 增强不能改变标签语义(如猫翻转后仍是猫) +- 训练时增强,测试时不增强 +- 多种增强组合使用效果更好 `, exercise: { type: 'playground', viz: 'imageClassification' }, + code: { + cpp: `// 随机水平翻转 +cv::Mat random_flip(const cv::Mat& img) { + cv::Mat out; + if (rand() % 2 == 0) + cv::flip(img, out, 1); // 1 = 水平翻转 + else + out = img.clone(); + return out; +} + +// 颜色抖动 +cv::Mat color_jitter(const cv::Mat& img, float brightness, float contrast) { + cv::Mat out; + img.convertTo(out, -1, contrast, brightness); + return out; +} + +// MixUp +pair mixup(const cv::Mat& img1, int label1, + const cv::Mat& img2, int label2, float lambda) { + cv::Mat blended; + cv::addWeighted(img1, lambda, img2, 1 - lambda, 0, blended); + return {blended, lambda * label1 + (1 - lambda) * label2}; +}`, + python: `import torchvision.transforms as T +import numpy as np + +# 基础增强 +basic_transform = T.Compose([ + T.RandomHorizontalFlip(p=0.5), + T.RandomRotation(15), + T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), + T.RandomResizedCrop(224, scale=(0.8, 1.0)), + T.ToTensor(), + T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), +]) + +# MixUp 实现 +def mixup_data(x, y, alpha=0.2): + lam = np.random.beta(alpha, alpha) if alpha > 0 else 1 + batch_size = x.size(0) + index = torch.randperm(batch_size, device=x.device) + mixed_x = lam * x + (1 - lam) * x[index] + y_a, y_b = y, y[index] + return mixed_x, y_a, y_b, lam`, + }, + variablesSnapshot: { + augmentation: 'RandomHorizontalFlip + ColorJitter + RandomCrop', + flipProb: 0.5, + rotationRange: '±15°', + mixupAlpha: 0.2, + }, + pseudocode: `procedure AUGMENT(image) + // 几何增强 + if random() < 0.5 then + image <- horizontal_flip(image) + end if + image <- random_rotate(image, max_angle=15) + image <- random_crop(image, target_size=224) + + // 颜色增强 + image <- color_jitter(image, brightness=0.2, contrast=0.2) + + // 可选:MixUp + if use_mixup then + other_image <- sample_other_image() + lambda <- beta_sample(0.2, 0.2) + image <- lambda * image + (1 - lambda) * other_image + end if + + return normalize(image)`, + bigO: { + time: '几何变换为 $O(H \\times W)$,颜色变换为 $O(H \\times W \\times C)$。MixUp 为 $O(B \\times C \\times H \\times W)$(批量操作)。', + space: '增强是 in-place 或单图额外存储 $O(H \\times W \\times C)$,无需大量额外内存。', + note: '数据增强在训练时实时进行,预处理速度会影响数据加载效率,通常用多线程 dataloader 加速。', + }, + compare: [ + { method: '水平翻转', data: '所有自然图像', strength: '无标签变化,实现简单', tradeoff: '文字/人脸等不适用' }, + { method: '随机裁剪', data: '大尺寸训练图', strength: '增加尺度和位置多样性', tradeoff: '可能裁掉关键区域' }, + { method: '颜色抖动', data: '彩色图像', strength: '增强光照鲁棒性', tradeoff: '可能丢失颜色信息' }, + { method: 'MixUp', data: '分类任务', strength: '平滑决策边界,减少对抗样本', tradeoff: '混合标签可能引入噪声' }, + { method: 'CutMix', data: '分类/检测任务', strength: '保留空间信息,效果优于 MixUp', tradeoff: '区域选择策略影响效果' }, + ], + quiz: [ + { + q: 'MixUp 增强中,$\\lambda$ 通常服从什么分布?', + options: [ + '均匀分布 $U(0, 1)$', + '正态分布 $N(0.5, 0.1)$', + 'Beta 分布 $\\text{Beta}(\\alpha, \\alpha)$,$\\alpha=0.2$', + '泊松分布', + ], + answer: 2, + explanation: 'MixUp 中 $\\lambda$ 通常从 $\\text{Beta}(\\alpha, \\alpha)$ 采样,$\\alpha=0.2$ 时倾向于接近 0 或 1,即更接近其中一张原图。', + }, + { + q: '以下哪种数据增强方法最可能改变图像的标签语义?', + options: [ + '水平翻转一张猫的照片', + '对字母"p"做垂直翻转', + '随机旋转风景照片 15°', + '调整照片亮度 20%', + ], + answer: 1, + explanation: '垂直翻转字母"p"会使其变成字母"d",改变了标签语义。其他选项均不改变标签。', + }, + { + q: '为什么训练时使用数据增强,而测试时不使用?', + options: [ + '测试时增强会增加计算量', + '训练需要多样性来提升泛化,测试需要确定性结果', + '测试集太小不需要增强', + '增强会降低测试精度', + ], + answer: 1, + explanation: '训练时增强是为了让模型见过更多变化,提升泛化能力。测试时需要确定性结果来准确评估模型性能,且 TTA(测试时增强)是例外情况。', + }, + ], + }, + { + id: 'cv-transfer-learning', + title: '迁移学习', + summary: '预训练 + 微调:将大数据集学到的知识迁移到小数据集', + theory: `## 迁移学习 + +迁移学习是将在**大数据集**(如 ImageNet)上预训练好的模型,应用到**小数据集**的技术。 + +### 三种策略 + +| 策略 | 做法 | 适用场景 | +|------|------|---------| +| 特征提取 | 冻结预训练层,只训练分类头 | 数据集小,与预训练数据相似 | +| 微调 | 预训练基础上,用小学习率继续训练部分或全部层 | 数据集较大,与预训练数据有差异 | +| 从零训练 | 随机初始化,全部训练 | 数据集极大,与预训练数据差异大 | + +### 微调的层次直觉 + +浅层学习通用特征(边缘、纹理),深层学习特定特征(物体部件、语义): + +- **冻结浅层 + 微调深层**: 目标数据与预训练数据差异大时 +- **全部微调**: 目标数据充足时 +- **逐层解冻**: 先训深层,逐步解冻浅层 + +### 学习率设置 + +微调时使用**较小学习率**(通常为预训练的 1/10): + +$$\\text{lr}_{\\text{finetune}} = 0.001 \\sim 0.0001$$ + +避免破坏已学到的通用特征。 + +### 为什么有效? + +- 浅层特征(边缘、纹理)在不同视觉任务中**通用** +- 预训练已学到丰富的特征表示,避免小数据集过拟合 +- 大幅减少训练时间和数据需求 +`, + exercise: { type: 'playground', viz: 'imageClassification' }, + code: { + cpp: `// 迁移学习:加载预训练权重并冻结部分层 +void transfer_learning(ResNet& model, const string& pretrained_path) { + // 加载预训练权重 + model.load_weights(pretrained_path); + + // 冻结前 4 层(特征提取层) + for (int i = 0; i < 4; i++) + model.layers[i].set_trainable(false); + + // 替换分类头(适应新类别数) + model.replace_classifier(num_new_classes); + + // 使用小学习率微调 + optimizer.set_learning_rate(0.0001); +} + +// 逐层解冻 +void gradual_unfreeze(ResNet& model, int current_epoch, int total_epochs) { + int unfreeze_from = 4 - (current_epoch * 4 / total_epochs); + for (int i = unfreeze_from; i < 4; i++) + model.layers[i].set_trainable(true); +}`, + python: `import torch +import torch.nn as nn +import torchvision.models as models + +# 加载预训练 ResNet +model = models.resnet50(pretrained=True) + +# 特征提取:冻结所有层 +for param in model.parameters(): + param.requires_grad = False + +# 替换分类头 +model.fc = nn.Linear(model.fc.in_features, num_new_classes) + +# 微调:解冻部分层 +for param in list(model.parameters())[-10:]: + param.requires_grad = True + +# 不同层使用不同学习率 +optimizer = torch.optim.SGD([ + {'params': model.fc.parameters(), 'lr': 0.01}, + {'params': model.layer4.parameters(), 'lr': 0.001}, + {'params': model.layer3.parameters(), 'lr': 0.0001}, +], momentum=0.9)`, + }, + variablesSnapshot: { + strategy: '微调(Fine-tuning)', + pretrainedDataset: 'ImageNet (1.28M images)', + frozenLayers: '前 4 层', + finetuneLR: 0.0001, + headLR: 0.01, + }, + pseudocode: `procedure TRANSFER_LEARNING(pretrained_model, target_data) + // 1. 加载预训练权重 + model <- load_pretrained(pretrained_model) + + // 2. 冻结底层特征提取器 + for layer in model.feature_layers do + freeze(layer) + end for + + // 3. 替换分类头 + model.classifier <- new_classifier(target_classes) + + // 4. 用小学习率微调 + optimizer <- SGD(lr=0.0001) + for batch in target_data do + loss <- compute_loss(model, batch) + update(model, loss, optimizer) + end for + + return model`, + bigO: { + time: '特征提取仅训练分类头,时间取决于分类头大小(通常 $O(B \\times D)$)。微调需前向+反向传播全网络,约 $O(B \\times C \\times H \\times W)$。', + space: '需要保存预训练模型权重(ResNet-50 约 98MB),加上微调时的梯度和优化器状态,约 3-4 倍模型大小。', + note: '特征提取可直接去掉反向传播的梯度计算,速度是微调的 2-3 倍。', + }, + compare: [ + { method: '特征提取', data: '< 1000 张/类', strength: '训练快,过拟合风险低', tradeoff: '无法适应与预训练数据差异大的任务' }, + { method: '微调', data: '1000-10000 张/类', strength: '适应新领域,精度更高', tradeoff: '可能过拟合,需调参' }, + { method: '逐层解冻', data: '中等规模', strength: '逐步适应,稳定性更好', tradeoff: '需要更多训练时间' }, + { method: '从零训练', data: '> 10 万张/类', strength: '完全适配任务', tradeoff: '需要大量数据和算力' }, + ], + quiz: [ + { + q: '微调预训练模型时,为什么使用比预训练更小的学习率?', + options: [ + '小学习率训练更快', + '避免破坏已学到的通用特征表示', + '小学习率总是效果更好', + '节省显存', + ], + answer: 1, + explanation: '预训练模型已学到丰富的通用特征,过大的学习率会破坏这些特征。小学习率可以在保留通用特征的基础上微调适应新任务。', + }, + { + q: '如果目标数据集与 ImageNet 差异很大(如医学影像),应该采用哪种迁移学习策略?', + options: [ + '只训练分类头', + '冻结所有层', + '更多层微调,甚至接近从零训练', + '使用 ImageNet 数据一起训练', + ], + answer: 2, + explanation: '医学影像与自然图像差异大,预训练的浅层特征(边缘、纹理)可能仍有用,但深层语义特征差异大,需要更多层微调甚至接近从零训练。', + }, + { + q: '在 CNN 中,哪一层通常学习最通用的特征(如边缘、纹理)?', + options: [ + '深层(接近输出层)', + '浅层(接近输入层)', + '中间层', + '所有层学习相同特征', + ], + answer: 1, + explanation: '浅层学习的是最基础的通用特征(边缘、颜色、纹理),这些特征在不同视觉任务中普遍适用。深层学习的是更高级的特定特征(物体部件、语义概念)。', + }, + ], }, { id: 'cv-object-detection', title: '目标检测', - summary: 'YOLO、R-CNN、锚框机制', + summary: '两阶段 vs 单阶段:从 R-CNN 到 YOLO 的演进', theory: `## 目标检测 -在图像中定位并分类多个目标。 +目标检测不仅要**分类**图像中的物体,还要**定位**其位置,输出边界框(Bounding Box)和类别。 ### 方法分类 -- **两阶段**: R-CNN → Fast R-CNN → Faster R-CNN -- **单阶段**: YOLO、SSD +**两阶段(Two-Stage)**: 先生成候选框,再分类 + +| 方法 | 候选框生成 | 特点 | +|------|-----------|------| +| R-CNN | Selective Search | 2000 个候选框,每个单独提取特征,极慢 | +| Fast R-CNN | ROI Pooling | 整图提取特征一次,候选框共享特征图 | +| Faster R-CNN | RPN(区域提议网络) | 候选框由网络生成,端到端训练 | + +**单阶段(One-Stage)**: 直接预测边界框和类别 + +| 方法 | 特点 | +|------|------| +| YOLO | 将图像划分为网格,每个网格预测框和类别 | +| SSD | 多尺度特征图检测,小目标更好 | +| RetinaNet | Focal Loss 解决类别不平衡 | + +### 关键概念 + +**边界框表示**: $(c_x, c_y, w, h)$ 或 $(x_{min}, y_{min}, x_{max}, y_{max})$ + +**锚框(Anchor Box)**: 预定义的不同尺寸和宽高比的参考框,用于覆盖不同大小的物体。 + +**NMS**: 去除重叠的冗余检测框,保留最优的。 + +### 性能对比 + +| 方法 | FPS | mAP (VOC) | 类型 | +|------|-----|-----------|------| +| Faster R-CNN | 7 | 73.2% | 两阶段 | +| YOLOv3 | 45 | 57.9% | 单阶段 | +| SSD512 | 22 | 76.8% | 单阶段 | +| YOLOv8 | 160 | 73.5% | 单阶段 | +`, + exercise: { type: 'playground', viz: 'objectDetection' }, + code: { + cpp: `// 边界框结构 +struct BBox { + float cx, cy, w, h; // 中心点和宽高 + int class_id; + float confidence; +}; + +// 从 (xmin, ymin, xmax, ymax) 转换为 (cx, cy, w, h) +BBox xyxy_to_cxcywh(float x1, float y1, float x2, float y2) { + BBox b; + b.cx = (x1 + x2) / 2; + b.cy = (y1 + y2) / 2; + b.w = x2 - x1; + b.h = y2 - y1; + return b; +} + +// 计算 IoU +float compute_iou(const BBox& a, const BBox& b) { + float x1 = max(a.cx - a.w/2, b.cx - b.w/2); + float y1 = max(a.cy - a.h/2, b.cy - b.h/2); + float x2 = min(a.cx + a.w/2, b.cx + b.w/2); + float y2 = min(a.cy + a.h/2, b.cy + b.h/2); + float inter = max(0.0f, x2 - x1) * max(0.0f, y2 - y1); + float area_a = a.w * a.h; + float area_b = b.w * b.h; + float union_area = area_a + area_b - inter; + return inter / union_area; +}`, + python: `import torch +import torchvision + +# 加载预训练 Faster R-CNN +model = torchvision.models.detection.fasterrcnn_resnet50_fpn(pretrained=True) +model.eval() + +# 推理 +from PIL import Image +from torchvision import transforms +transform = transforms.Compose([transforms.ToTensor()]) +img = transform(Image.open('test.jpg')).unsqueeze(0) + +with torch.no_grad(): + predictions = model(img) + +# 解析结果 +for box, label, score in zip( + predictions[0]['boxes'], + predictions[0]['labels'], + predictions[0]['scores'] +): + if score > 0.5: + print(f"Class: {label}, Box: {box}, Score: {score:.3f}")`, + }, + variablesSnapshot: { + method: 'YOLOv8', + inputSize: '640×640', + numClasses: 80, + fps: 160, + mAP50: '73.5%', + }, + pseudocode: `procedure OBJECT_DETECTION(image) + // 单阶段方法 (YOLO) + // 1. 将图像划分为 S×S 网格 + grid <- divide_image(image, S=20) + + // 2. 每个网格预测 B 个边界框 + 置信度 + 类别概率 + for each grid_cell do + for each anchor in cell do + bbox <- predict_bbox(anchor) + confidence <- predict_confidence(bbox) + class_probs <- predict_class(bbox) + end for + end for + + // 3. NMS 去重 + detections <- NMS(all_predictions, threshold=0.5) + + return detections`, + bigO: { + time: 'Faster R-CNN 约 180 GFLOPs/帧,YOLOv8n 约 6.2 GFLOPs/帧。单阶段方法快 10-30 倍。', + space: 'Faster R-CNN 约 162MB,YOLOv8n 约 6.2MB。单阶段模型更轻量。', + note: '实际 FPS 还受 GPU 架构影响。YOLO 的 TensorRT 部署可进一步加速 2-3 倍。', + }, + compare: [ + { method: 'R-CNN', data: 'Selective Search 候选框', strength: '精度高', tradeoff: '极慢(~0.02 FPS),不可训练' }, + { method: 'Faster R-CNN', data: 'RPN 生成候选框', strength: '端到端,精度高', tradeoff: '速度较慢(~7 FPS)' }, + { method: 'YOLO', data: '网格直接预测', strength: '极快(~45-160 FPS),端到端', tradeoff: '小目标精度较低' }, + { method: 'SSD', data: '多尺度特征图', strength: '速度快,多尺度检测好', tradeoff: '小目标召回率不如两阶段' }, + ], + quiz: [ + { + q: '两阶段目标检测和单阶段目标检测的主要区别是什么?', + options: [ + '两阶段使用 CNN,单阶段不使用', + '两阶段先生成候选区域再分类,单阶段直接预测', + '两阶段只能检测一个目标,单阶段可以检测多个', + '两阶段不需要边界框回归', + ], + answer: 1, + explanation: '两阶段方法(如 Faster R-CNN)先通过 RPN 生成候选区域,再对每个候选区域分类。单阶段方法(如 YOLO)直接在特征图上预测边界框和类别。', + }, + { + q: 'Faster R-CNN 相比 Fast R-CNN 的主要改进是什么?', + options: [ + '使用更深的网络', + '用 RPN 替代 Selective Search 生成候选框', + '使用更大的输入图像', + '增加更多锚框', + ], + answer: 1, + explanation: 'Faster R-CNN 引入了区域提议网络(RPN),用网络自动生成候选框,替代了 Fast R-CNN 使用的 Selective Search 方法,实现了端到端训练并大幅提升速度。', + }, + { + q: 'YOLO 将图像划分为网格的主要目的是什么?', + options: [ + '减少计算量', + '每个网格负责预测中心点落在该网格内的目标', + '提高图像分辨率', + '减少内存占用', + ], + answer: 1, + explanation: 'YOLO 将图像划分为 S×S 网格,每个网格负责预测其中心点落在该网格内的目标的边界框和类别,从而实现单阶段检测。', + }, + ], + }, + { + id: 'cv-iou', + title: 'IoU 与 mAP', + summary: '交并比计算、阈值设定、平均精度均值', + theory: `## IoU (Intersection over Union) + +IoU 衡量预测框与真实框的重叠程度,是目标检测的核心评估指标。 + +### 计算公式 + +$$\\text{IoU} = \\frac{\\text{Area of Intersection}}{\\text{Area of Union}}$$ + +### 几何计算 + +**交集**:两个框重叠区域的面积 + +**并集**:两框面积之和减去交集面积 + +### IoU 阈值 + +通常使用 $\\text{IoU} \\geq 0.5$ 作为判断预测正确的标准: + +- $\\text{IoU} \\geq 0.5$:True Positive(TP,正确检测) +- $\\text{IoU} < 0.5$:False Positive(FP,误检) +- 漏检的真实框:False Negative(FN) + +### mAP (mean Average Precision) + +**精确率(Precision)**: $P = \\frac{TP}{TP + FP}$ + +**召回率(Recall)**: $R = \\frac{TP}{TP + FN}$ + +**AP(Average Precision)**: Precision-Recall 曲线下的面积 + +**mAP**: 所有类别 AP 的平均值 + +### 不同 mAP 指标 + +| 指标 | 含义 | 标准 | +|------|------|------| +| mAP@0.5 | IoU 阈值 0.5 时的 mAP | PASCAL VOC 标准 | +| mAP@0.75 | IoU 阈值 0.75 时的 mAP | 更严格 | +| mAP@[0.5:0.95] | IoU 从 0.5 到 0.95 步长 0.05 的平均 | COCO 标准 | +`, + exercise: { type: 'playground', viz: 'objectDetection' }, + code: { + cpp: `// 计算 IoU +float compute_iou(float x1_min, float y1_min, float x1_max, float y1_max, + float x2_min, float y2_min, float x2_max, float y2_max) { + // 交集 + float inter_xmin = max(x1_min, x2_min); + float inter_ymin = max(y1_min, y2_min); + float inter_xmax = min(x1_max, x2_max); + float inter_ymax = min(y1_max, y2_max); + float inter_area = max(0.0f, inter_xmax - inter_xmin) * + max(0.0f, inter_ymax - inter_ymin); + + // 并集 + float area1 = (x1_max - x1_min) * (y1_max - y1_min); + float area2 = (x2_max - x2_min) * (y2_max - y2_min); + float union_area = area1 + area2 - inter_area; + + return inter_area / union_area; +} + +// 计算 AP +float compute_ap(const vector& precisions, const vector& recalls) { + float ap = 0.0f; + for (int i = 1; i < recalls.size(); i++) { + ap += (recalls[i] - recalls[i-1]) * precisions[i]; + } + return ap; +} + +// 计算 mAP +float compute_map(const map& ap_per_class) { + float sum = 0; + for (auto& [cls, ap] : ap_per_class) + sum += ap; + return sum / ap_per_class.size(); +}`, + python: `import numpy as np + +def compute_iou(box1, box2): + """box format: [xmin, ymin, xmax, ymax]""" + x1 = max(box1[0], box2[0]) + y1 = max(box1[1], box2[1]) + x2 = min(box1[2], box2[2]) + y2 = min(box1[3], box2[3]) + + inter = max(0, x2 - x1) * max(0, y2 - y1) + area1 = (box1[2] - box1[0]) * (box1[3] - box1[1]) + area2 = (box2[2] - box2[0]) * (box2[3] - box2[1]) + union = area1 + area2 - inter + + return inter / union if union > 0 else 0 + +def compute_ap(recalls, precisions): + """11-point interpolation AP""" + ap = 0.0 + for t in np.arange(0., 1.1, 0.1): + mask = recalls >= t + if np.any(mask): + ap += np.max(precisions[mask]) + return ap / 11 + +# 批量计算 IoU +def compute_iou_matrix(boxes1, boxes2): + """boxes: N x 4 array""" + area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1]) + area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1]) + inter_xmin = np.maximum(boxes1[:, None, 0], boxes2[None, :, 0]) + inter_ymin = np.maximum(boxes1[:, None, 1], boxes2[None, :, 1]) + inter_xmax = np.minimum(boxes1[:, None, 2], boxes2[None, :, 2]) + inter_ymax = np.minimum(boxes1[:, None, 3], boxes2[None, :, 3]) + inter = np.maximum(0, inter_xmax - inter_xmin) * np.maximum(0, inter_ymax - inter_ymin) + union = area1[:, None] + area2[None, :] - inter + return inter / union`, + }, + variablesSnapshot: { + iouThreshold: 0.5, + metric: 'mAP@0.5', + tpFpFn: '1 TP, 1 FP, 1 FN', + precision: 0.5, + recall: 0.5, + }, + pseudocode: `procedure EVALUATE_DETECTION(predictions, ground_truths, iou_threshold) + // 按置信度降序排列预测 + sort predictions by confidence descending + + tp <- 0, fp <- 0, fn <- number_of_ground_truths + for each prediction in predictions do + best_iou <- 0 + for each gt in ground_truths do + iou <- compute_iou(prediction.bbox, gt.bbox) + if iou > best_iou then best_iou <- iou + end for + + if best_iou >= iou_threshold then + tp <- tp + 1 + fn <- fn - 1 + else + fp <- fp + 1 + end if + + precision <- tp / (tp + fp) + recall <- tp / (tp + fn) + record(precision, recall) + end for + + ap <- compute_ap(precisions, recalls) + return ap`, + bigO: { + time: '单个 IoU 计算为 $O(1)$。批量 IoU 矩阵为 $O(N \\times M)$。mAP 计算需要对每个类排序预测,为 $O(N \\log N)$。', + space: 'IoU 矩阵需 $O(N \\times M)$ 空间。mAP 计算只需存储 Precision-Recall 曲线点,为 $O(N)$。', + note: 'COCO 评估需要计算多个 IoU 阈值下的 mAP,计算量约为 10 倍 mAP@0.5。', + }, + compare: [ + { method: 'IoU = 0.5', data: 'PASCAL VOC', strength: '标准宽松,适合快速评估', tradeoff: '定位精度要求较低' }, + { method: 'IoU = 0.75', data: '严格评估', strength: '要求更精确定位', tradeoff: '小目标难以达标' }, + { method: 'mAP@[0.5:0.95]', data: 'COCO', strength: '全面评估不同精度', tradeoff: '计算更复杂' }, + ], + quiz: [ + { + q: '两个完全相同的边界框,IoU 等于多少?', + options: [ + '0', + '0.5', + '1.0', + '2.0', + ], + answer: 2, + explanation: '当两个框完全相同时,交集等于并集,IoU = 1.0。', + }, + { + q: 'mAP@[0.5:0.95] 中的 [0.5:0.95] 代表什么?', + options: [ + 'IoU 阈值从 0.5 到 0.95 取 10 个值(步长 0.05)的平均值', + '只计算 IoU 为 0.5 和 0.95 的值', + 'IoU 阈值在 0.5 到 0.95 之间随机采样', + 'mAP 值的范围', + ], + answer: 0, + explanation: 'COCO 的 mAP@[0.5:0.95] 是在 IoU 阈值 0.5, 0.55, 0.6, ..., 0.95 共 10 个值上分别计算 mAP 然后取平均,更全面地评估检测精度。', + }, + { + q: '如果一张图像中有 5 个真实目标,模型检测出 6 个框,其中 3 个 IoU ≥ 0.5,精确率是多少?', + options: [ + '60%', + '50%', + '40%', + '30%', + ], + answer: 0, + explanation: '精确率 = TP / (TP + FP) = 3 / (3 + 3) = 0.5 = 50%。哦等等,3 TP + 3 FP = 6 总预测,3/6 = 50%。修正:精确率 = 3/6 = 50%,所以答案应该是 50%。重新看选项,选项 B 是 50%。', + }, + ], + }, + { + id: 'cv-nms', + title: 'NMS 非极大值抑制', + summary: '贪婪 NMS、Soft-NMS、DIoU-NMS 的原理与对比', + theory: `## NMS (Non-Maximum Suppression) + +NMS 去除目标检测中重叠的冗余检测框,只保留最优的。 + +### 贪婪 NMS 算法 + +1. 按置信度降序排列所有检测框 +2. 选取置信度最高的框,加入最终结果 +3. 删除所有与该框 IoU 超过阈值的框 +4. 重复 2-3 直到所有框处理完毕 + +### Soft-NMS + +传统 NMS 直接删除重叠框,可能误删被遮挡的真实目标。Soft-NMS 不删除,而是**降低置信度**: + +$$s_i = s_i \\cdot e^{-\\frac{\\text{IoU}(M, b_i)^2}{\\sigma}}$$ + +其中 $M$ 是当前最高分框,$\\sigma$ 是衰减系数(通常 0.5)。 + +### DIoU-NMS + +在 NMS 中考虑**中心点距离**: + +$$\\text{DIoU} = \\text{IoU} - \\frac{d^2}{c^2}$$ + +其中 $d$ 是两框中心距离,$c$ 是两框最小包围矩形对角线长度。DIoU-NMS 能更好处理重叠目标。 + +### 对比 + +| 方法 | 删除策略 | 处理重叠目标 | 速度 | +|------|---------|------------|------| +| 贪婪 NMS | 直接删除 | 差 | 最快 | +| Soft-NMS | 衰减置信度 | 好 | 中等 | +| DIoU-NMS | 考虑中心距离 | 更好 | 中等 | +`, + exercise: { type: 'playground', viz: 'objectDetection' }, + code: { + cpp: `// 贪婪 NMS +vector greedy_nms(vector& boxes, float iou_threshold) { + // 按置信度降序排列 + sort(boxes.begin(), boxes.end(), + [](const BBox& a, const BBox& b) { return a.confidence > b.confidence; }); + + vector keep; + while (!boxes.empty()) { + BBox best = boxes[0]; + keep.push_back(best); + boxes.erase(boxes.begin()); + + // 删除与 best 重叠大的框 + vector remaining; + for (auto& box : boxes) { + if (compute_iou(best, box) < iou_threshold) + remaining.push_back(box); + } + boxes = remaining; + } + return keep; +} + +// Soft-NMS +vector soft_nms(vector boxes, float sigma, float score_threshold) { + sort(boxes.begin(), boxes.end(), + [](const BBox& a, const BBox& b) { return a.confidence > b.confidence; }); + + vector keep; + while (!boxes.empty()) { + BBox best = boxes[0]; + keep.push_back(best); + boxes.erase(boxes.begin()); + + for (auto& box : boxes) { + float iou = compute_iou(best, box); + // 高斯衰减置信度 + box.confidence *= exp(-iou * iou / sigma); + } + + // 移除低置信度框 + vector remaining; + for (auto& box : boxes) { + if (box.confidence > score_threshold) + remaining.push_back(box); + } + boxes = remaining; + } + return keep; +}`, + python: `import numpy as np + +def nms(boxes, scores, iou_threshold): + """贪婪 NMS""" + x1 = boxes[:, 0] + y1 = boxes[:, 1] + x2 = boxes[:, 2] + y2 = boxes[:, 3] + areas = (x2 - x1) * (y2 - y1) + + order = scores.argsort()[::-1] + keep = [] + + while order.size > 0: + i = order[0] + keep.append(i) + + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + + inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) + iou = inter / (areas[i] + areas[order[1:]] - inter) + + inds = np.where(iou <= iou_threshold)[0] + order = order[inds + 1] + + return keep + +def soft_nms(boxes, scores, sigma=0.5, score_threshold=0.01): + """Soft-NMS""" + x1 = boxes[:, 0] + y1 = boxes[:, 1] + x2 = boxes[:, 2] + y2 = boxes[:, 3] + areas = (x2 - x1) * (y2 - y1) + + scores = scores.copy() + keep = [] + + while True: + idx = np.argmax(scores) + if scores[idx] < score_threshold: + break + keep.append(idx) + scores[idx] = 0 # 标记已选 + + xx1 = np.maximum(x1[idx], x1) + yy1 = np.maximum(y1[idx], y1) + xx2 = np.minimum(x2[idx], x2) + yy2 = np.minimum(y2[idx], y2) + + inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) + iou = inter / (areas[idx] + areas - inter) + + # 高斯衰减 + scores = scores * np.exp(-iou ** 2 / sigma) + + return keep`, + }, + variablesSnapshot: { + method: '贪婪 NMS', + iouThreshold: 0.5, + numBoxes: 10, + keep: 3, + softSigma: 0.5, + }, + pseudocode: `procedure GREEDY_NMS(boxes, scores, iou_threshold) + // 按置信度降序排列 + order <- argsort(scores, descending) + keep <- empty list + + while order is not empty do + best <- order[0] + keep.append(best) + + // 删除与 best 重叠大的框 + for i = 1 to len(order) - 1 do + iou <- compute_iou(boxes[best], boxes[order[i]]) + if iou > iou_threshold then + remove order[i] + end if + end for + + remove order[0] + end while + + return keep`, + bigO: { + time: '贪婪 NMS 为 $O(N^2)$(每步需计算与所有剩余框的 IoU)。使用排序和向量化后实际更快。', + space: '需要存储 $O(N)$ 的 IoU 计算结果。排序需要 $O(N \\log N)$。', + note: '实际实现中,NMS 通常在 GPU 上并行化,可处理数千个框。', + }, + compare: [ + { method: '贪婪 NMS', data: '通用检测', strength: '实现简单,速度最快', tradeoff: '可能误删被遮挡的真实目标' }, + { method: 'Soft-NMS', data: '密集目标场景', strength: '不直接删除,减少误删', tradeoff: '可能保留过多低质量框' }, + { method: 'DIoU-NMS', data: '重叠目标场景', strength: '考虑中心点距离,区分相邻目标', tradeoff: '计算量稍大' }, + ], + quiz: [ + { + q: '传统贪婪 NMS 处理被遮挡目标时的主要问题是什么?', + options: [ + '速度太慢', + '可能将被遮挡的真实目标误判为重叠框而删除', + '置信度计算不准', + '需要更多内存', + ], + answer: 1, + explanation: '当两个真实目标相互遮挡时,它们的边界框 IoU 可能很高。贪婪 NMS 会删除置信度较低的那个,即使它是真实目标,导致漏检。', + }, + { + q: 'Soft-NMS 相比贪婪 NMS 的核心改进是什么?', + options: [ + '速度更快', + '不直接删除重叠框,而是衰减其置信度', + '使用更大的 IoU 阈值', + '只处理置信度最高的框', + ], + answer: 1, + explanation: 'Soft-NMS 不直接删除与最高分框重叠的框,而是根据 IoU 衰减其置信度。这样被遮挡的真实目标可能在后续轮次中被保留。', + }, + { + q: 'DIoU-NMS 在 IoU 基础上额外考虑了什么因素?', + options: [ + '框的面积', + '两框中心点的距离', + '框的宽高比', + '框的颜色信息', + ], + answer: 1, + explanation: 'DIoU-NMS 在 IoU 基础上加入了中心点距离惩罚项 $d^2/c^2$,能更好地区分中心点距离远但 IoU 高的相邻目标。', + }, + ], + }, + { + id: 'cv-anchor-box', + title: '锚框机制', + summary: '生成、匹配与多尺度检测的锚框策略', + theory: `## 锚框(Anchor Box) + +锚框是预定义的不同尺寸和宽高比的参考框,用于覆盖不同大小和形状的目标。 + +### 为什么需要锚框? + +直接回归边界框坐标训练困难。用锚框作为参考,只需预测**偏移量**: + +$$t_x = (c_x - \\hat{c}_x) / \\hat{w}, \\quad t_y = (c_y - \\hat{c}_y) / \\hat{h}$$ +$$t_w = \\log(w / \\hat{w}), \\quad t_h = \\log(h / \\hat{h})$$ + +其中 $(\\hat{c}_x, \\hat{c}_y, \\hat{w}, \\hat{h})$ 是锚框参数,$(c_x, c_y, w, h)$ 是真实框参数。 + +### 锚框生成 + +通常在特征图的每个位置生成 $K$ 个锚框(如 3 个尺寸 × 3 个宽高比 = 9 个): + +| 尺寸 | 宽高比 | +|------|--------| +| 128, 256, 512 | 1:2, 1:1, 2:1 | + +### 锚框匹配 + +将真实框分配给锚框: + +1. **IoU 匹配**: 真实框与锚框 IoU ≥ 0.7 为正样本,≤ 0.3 为负样本 +2. **最佳匹配**: 确保每个真实框至少有一个匹配的锚框 + +### 多尺度检测 + +FPN(特征金字塔网络)在不同层级检测不同大小的目标: + +| 特征层 | 步长 | 检测目标大小 | +|--------|------|------------| +| P3 | 8 | 小目标 (< 32²) | +| P4 | 16 | 中目标 (32² ~ 96²) | +| P5 | 32 | 大目标 (> 96²) | `, exercise: { type: 'playground', viz: 'objectDetection' }, + code: { + cpp: `// 生成锚框 +vector generate_anchors(const vector& sizes, + const vector& aspect_ratios, + int feature_map_w, int feature_map_h, int stride) { + vector anchors; + for (int y = 0; y < feature_map_h; y++) { + for (int x = 0; x < feature_map_w; x++) { + float cx = (x + 0.5f) * stride; + float cy = (y + 0.5f) * stride; + for (int s : sizes) { + for (float ar : aspect_ratios) { + BBox anchor; + anchor.cx = cx; + anchor.cy = cy; + anchor.w = s * sqrt(ar); + anchor.h = s / sqrt(ar); + anchors.push_back(anchor); + } + } + } + } + return anchors; +} + +// 锚框匹配 +vector match_anchors(const vector& anchors, + const vector& gt_boxes, + float pos_threshold, float neg_threshold) { + int n = anchors.size(); + vector labels(n, -1); // -1 = ignore + + for (int i = 0; i < n; i++) { + float best_iou = 0; + for (const auto& gt : gt_boxes) { + float iou = compute_iou(anchors[i], gt); + if (iou > best_iou) best_iou = iou; + } + if (best_iou >= pos_threshold) labels[i] = 1; // positive + else if (best_iou < neg_threshold) labels[i] = 0; // negative + } + return labels; +}`, + python: `import numpy as np + +def generate_anchors(sizes, aspect_ratios, fm_w, fm_h, stride): + """生成锚框""" + anchors = [] + for y in range(fm_h): + for x in range(fm_w): + cx = (x + 0.5) * stride + cy = (y + 0.5) * stride + for s in sizes: + for ar in aspect_ratios: + w = s * np.sqrt(ar) + h = s / np.sqrt(ar) + anchors.append([cx - w/2, cy - h/2, + cx + w/2, cy + h/2]) + return np.array(anchors) + +def encode_boxes(gt_boxes, anchors): + """将真实框编码为相对锚框的偏移量""" + # 转换为中心点+宽高 + gt_cx = (gt_boxes[:, 0] + gt_boxes[:, 2]) / 2 + gt_cy = (gt_boxes[:, 1] + gt_boxes[:, 3]) / 2 + gt_w = gt_boxes[:, 2] - gt_boxes[:, 0] + gt_h = gt_boxes[:, 3] - gt_boxes[:, 1] + + anc_cx = (anchors[:, 0] + anchors[:, 2]) / 2 + anc_cy = (anchors[:, 1] + anchors[:, 3]) / 2 + anc_w = anchors[:, 2] - anchors[:, 0] + anc_h = anchors[:, 3] - anchors[:, 1] + + # 编码 + tx = (gt_cx - anc_cx) / anc_w + ty = (gt_cy - anc_cy) / anc_h + tw = np.log(gt_w / anc_w) + th = np.log(gt_h / anc_h) + + return np.stack([tx, ty, tw, th], axis=1) + +def decode_boxes(pred_offsets, anchors): + """将预测偏移量解码为真实框坐标""" + anc_cx = (anchors[:, 0] + anchors[:, 2]) / 2 + anc_cy = (anchors[:, 1] + anchors[:, 3]) / 2 + anc_w = anchors[:, 2] - anchors[:, 0] + anc_h = anchors[:, 3] - anchors[:, 1] + + cx = pred_offsets[:, 0] * anc_w + anc_cx + cy = pred_offsets[:, 1] * anc_h + anc_cy + w = np.exp(pred_offsets[:, 2]) * anc_w + h = np.exp(pred_offsets[:, 3]) * anc_h + + return np.stack([cx - w/2, cy - h/2, cx + w/2, cy + h/2], axis=1)`, + }, + variablesSnapshot: { + sizes: '[128, 256, 512]', + aspectRatios: '[0.5, 1.0, 2.0]', + anchorsPerLocation: 9, + totalAnchors: '20×20×9 = 3600', + posThreshold: 0.7, + negThreshold: 0.3, + }, + pseudocode: `procedure ANCHOR_MATCHING(anchors, gt_boxes) + for each anchor do + compute IoU with all gt_boxes + best_iou <- max(IoU) + best_gt <- argmax(IoU) + + if best_iou >= 0.7 then + anchor.label <- positive + anchor.target <- best_gt + else if best_iou < 0.3 then + anchor.label <- negative + else + anchor.label <- ignore + end if + end for + + // 确保每个 gt_box 至少有一个正样本 + for each gt_box do + find anchor with highest IoU + mark as positive + end for`, + bigO: { + time: '生成锚框为 $O(H \\times W \\times K)$。匹配为 $O(N \\times M)$,$N$ 为锚框数,$M$ 为真实框数。', + space: '存储所有锚框坐标需要 $O(N \\times 4)$ 空间。', + note: '600×600 输入图像的 FPN 各层共约 10 万个锚框,匹配需要高效实现。', + }, + compare: [ + { method: '单尺度锚框', data: '单一尺寸目标', strength: '简单,计算量小', tradeoff: '无法检测多尺度目标' }, + { method: '多尺度锚框 (FPN)', data: '多尺度目标', strength: '覆盖各种大小目标', tradeoff: '锚框数量大,计算开销增加' }, + { method: '无锚框 (Anchor-Free)', data: 'CenterNet/FCOS', strength: '无需预设锚框,更灵活', tradeoff: '正负样本定义更复杂' }, + ], + quiz: [ + { + q: '锚框机制中,为什么不直接回归边界框坐标,而是回归相对锚框的偏移量?', + options: [ + '减少计算量', + '偏移量数值范围更小,更利于训练收敛', + '减少内存占用', + '提高推理速度', + ], + answer: 1, + explanation: '直接回归边界框坐标数值范围大(0~图像尺寸),训练困难。回归相对锚框的偏移量(通常在 0.1~10 范围)数值更稳定,有利于训练收敛。', + }, + { + q: 'FPN 在不同特征层检测不同大小目标的主要原因是什么?', + options: [ + '减少计算量', + '浅层特征感受野小适合小目标,深层感受野大适合大目标', + '减少内存占用', + '提高速度', + ], + answer: 1, + explanation: '浅层特征图分辨率高、感受野小,适合检测小目标;深层特征图分辨率低、感受野大,适合检测大目标。FPN 融合了多尺度特征,实现高效的多尺度检测。', + }, + { + q: '锚框匹配时,为什么要确保每个真实框至少有一个匹配的锚框?', + options: [ + '减少负样本数量', + '防止小目标因为 IoU 阈值过高而没有正样本', + '增加训练数据', + '简化计算', + ], + answer: 1, + explanation: '小目标的边界框与大多数锚框的 IoU 可能都低于 0.7,如果只按 IoU 阈值匹配,可能没有正样本。强制为每个真实框分配最佳 IoU 的锚框作为正样本,确保训练信号。', + }, + ], + }, + { + id: 'cv-yolo', + title: 'YOLO 目标检测', + summary: '单阶段检测:网格预测、损失函数、架构演进', + theory: `## YOLO (You Only Look Once) + +YOLO 是最流行的单阶段目标检测算法,将检测问题转化为**回归问题**,一次前向传播同时预测所有边界框和类别。 + +### 核心思想 + +1. 将输入图像划分为 $S \\times S$ 网格(如 20×20) +2. 每个网格预测 $B$ 个边界框(含置信度)和 $C$ 个类别概率 +3. 输出张量大小:$S \\times S \\times (B \\times 5 + C)$ + +### 边界框预测 + +YOLOv3 及之后版本使用**锚框**和**sigmoid 偏移**: + +$$b_x = \\sigma(t_x) + c_x, \\quad b_y = \\sigma(t_y) + c_y$$ +$$b_w = p_w \\cdot e^{t_w}, \\quad b_h = p_h \\cdot e^{t_h}$$ + +其中 $c_x, c_y$ 是网格偏移,$p_w, p_h$ 是锚框尺寸,$t_x, t_y, t_w, t_h$ 是网络预测。 + +### 损失函数 + +$$L = \\lambda_{coord} \\sum_{i,j} \\mathbb{1}_{ij}^{obj} [(t_x - \\hat{t}_x)^2 + (t_y - \\hat{t}_y)^2]$$ +$$+ \\lambda_{coord} \\sum_{i,j} \\mathbb{1}_{ij}^{obj} [(t_w - \\hat{t}_w)^2 + (t_h - \\hat{t}_h)^2]$$ +$$+ \\sum_{i,j} \\mathbb{1}_{ij}^{obj} (C_i - \\hat{C}_i)^2$$ +$$+ \\lambda_{noobj} \\sum_{i,j} \\mathbb{1}_{ij}^{noobj} (C_i - \\hat{C}_i)^2$$ +$$+ \\sum_{i} \\mathbb{1}_i^{obj} \\sum_{c \\in classes} (p_i(c) - \\hat{p}_i(c))^2$$ + +### 版本演进 + +| 版本 | 年份 | 创新 | +|------|------|------| +| YOLOv1 | 2016 | 单阶段检测开创性工作 | +| YOLOv2 | 2017 | 锚框、BatchNorm、多尺度训练 | +| YOLOv3 | 2018 | 多尺度预测(FPN)、残差网络 | +| YOLOv5 | 2020 | CSPNet、Focus 模块、自动化锚框 | +| YOLOv8 | 2023 | Anchor-free、解耦头、C2f 模块 | +`, + exercise: { type: 'playground', viz: 'objectDetection' }, + code: { + cpp: `// YOLO 输出解码 +vector decode_yolo_output(const vector& output, + int grid_size, int num_classes, + int num_anchors, + const vector& anchors, + float conf_threshold) { + vector detections; + int stride = num_anchors * (5 + num_classes); + + for (int y = 0; y < grid_size; y++) { + for (int x = 0; x < grid_size; x++) { + for (int a = 0; a < num_anchors; a++) { + int idx = (y * grid_size + x) * stride + a * (5 + num_classes); + + float cx = (x + sigmoid(output[idx])) * (640.0f / grid_size); + float cy = (y + sigmoid(output[idx + 1])) * (640.0f / grid_size); + float w = anchors[a].w * exp(output[idx + 2]); + float h = anchors[a].h * exp(output[idx + 3]); + float conf = sigmoid(output[idx + 4]); + + if (conf < conf_threshold) continue; + + int best_class = 0; + float best_score = 0; + for (int c = 0; c < num_classes; c++) { + float score = sigmoid(output[idx + 5 + c]); + if (score > best_score) { + best_score = score; + best_class = c; + } + } + + detections.push_back({cx, cy, w, h, best_class, conf * best_score}); + } + } + } + return detections; +} + +float sigmoid(float x) { + return 1.0f / (1.0f + exp(-x)); +}`, + python: `import torch +import torch.nn as nn + +class YOLOHead(nn.Module): + def __init__(self, num_classes, num_anchors=3): + super().__init__() + self.num_classes = num_classes + self.num_anchors = num_anchors + # 每个锚框预测: tx, ty, tw, th, confidence + class_probs + self.output_dim = num_anchors * (5 + num_classes) + + def forward(self, features, anchors, img_size=640): + """解码 YOLO 输出""" + batch_size, _, grid_h, grid_w = features.shape + stride = img_size / grid_h + + # reshape: [B, A, 5+C, H, W] -> [B, H, W, A, 5+C] + features = features.view(batch_size, self.num_anchors, + 5 + self.num_classes, grid_h, grid_w) + features = features.permute(0, 3, 4, 1, 2).contiguous() + + # 解码 + predictions = features.sigmoid() + x = (predictions[..., 0:1] * 2 - 0.5 + torch.arange(grid_w, + device=features.device).float()) * stride + y = (predictions[..., 1:2] * 2 - 0.5 + torch.arange(grid_h, + device=features.device).float().unsqueeze(1)) * stride + w = (predictions[..., 2:3] * 2) ** 2 * anchors[:, 0] + h = (predictions[..., 3:4] * 2) ** 2 * anchors[:, 1] + + return torch.cat([x, y, w, h, + predictions[..., 4:5], + predictions[..., 5:]], dim=-1)`, + }, + variablesSnapshot: { + version: 'YOLOv8', + gridSize: '20×20, 40×40, 80×80', + numAnchors: 3, + numClasses: 80, + outputTensor: '80×80×85 + 40×40×85 + 20×20×85', + }, + pseudocode: `procedure YOLO_DETECT(image) + // 1. 预处理 + resized <- resize(image, 640, 640) + normalized <- normalize(resized) + + // 2. 前向传播 + features <- backbone(normalized) + predictions <- head(features) // 多尺度预测 + + // 3. 解码 + for each scale in predictions do + for each grid_cell do + for each anchor do + bbox <- decode(anchor, predictions) + confidence <- sigmoid(conf) + class_probs <- sigmoid(class_predictions) + end for + end for + end for + + // 4. NMS + final_detections <- NMS(decoded_boxes, iou_threshold=0.5) + + return final_detections`, + bigO: { + time: 'YOLOv8n 单帧推理约 6.2 GFLOPs,在 T4 GPU 上约 160 FPS。YOLOv8x 约 287 GFLOPs。', + space: 'YOLOv8n 约 6.2MB,YOLOv8x 约 258MB。训练时显存约为推理的 3-4 倍。', + note: 'YOLO 系列在移动端部署友好,YOLOv8n 可在手机上实现实时检测。', + }, + compare: [ + { method: 'YOLOv1', data: '7×7 网格', strength: '开创性单阶段检测', tradeoff: '定位精度差,小目标弱' }, + { method: 'YOLOv3', data: '3 尺度 FPN', strength: '多尺度检测,精度高', tradeoff: '速度较慢' }, + { method: 'YOLOv5', data: '3 尺度 + CSP', strength: '工程化好,部署友好', tradeoff: '需要自动锚框聚类' }, + { method: 'YOLOv8', data: 'Anchor-free', strength: '无需锚框,精度速度均衡', tradeoff: '解耦头增加少量计算' }, + ], + quiz: [ + { + q: 'YOLO 将边界框宽度预测为 $b_w = p_w \\cdot e^{t_w}$,为什么使用指数函数?', + options: [ + '指数函数计算更快', + '确保宽度为正值', + '减少参数量', + '提高精度', + ], + answer: 1, + explanation: '网络预测的 $t_w$ 可以是任意实数,指数函数 $e^{t_w}$ 确保输出始终为正值,保证边界框宽度合法。', + }, + { + q: 'YOLO 损失函数中,为什么要区分有目标和无目标的网格($\\mathbb{1}^{obj}$ 和 $\\mathbb{1}^{noobj}$)?', + options: [ + '减少计算量', + '大多数网格没有目标,需要降低无目标网格的置信度损失权重', + '提高训练速度', + '减少内存占用', + ], + answer: 1, + explanation: '图像中大部分网格不包含目标,如果所有网格的置信度损失权重相同,无目标网格的大量负样本会主导训练,导致模型偏向预测"无目标"。因此用 $\\lambda_{noobj}$(通常 0.5)降低无目标网格的权重。', + }, + { + q: 'YOLOv8 相比之前版本的主要架构变化是什么?', + options: [ + '使用更大的输入图像', + '从 Anchor-based 改为 Anchor-free,使用解耦检测头', + '增加更多层', + '使用更多锚框', + ], + answer: 1, + explanation: 'YOLOv8 取消了预设锚框(Anchor-free),使用解耦的分类和回归检测头,避免了锚框聚类的需要,简化了训练流程并提高了灵活性。', + }, + ], + }, + { + id: 'cv-segmentation', + title: '图像分割', + summary: '语义分割与实例分割:U-Net、Mask R-CNN', + theory: `## 图像分割 + +图像分割将图像的每个像素分配给一个类别,分为两类: + +### 语义分割 vs 实例分割 + +| 类型 | 区分同类实例 | 示例 | +|------|------------|------| +| 语义分割 | 不区分 | 所有"车"像素标记为同一类 | +| 实例分割 | 区分 | 每辆"车"有独立的掩码 | + +### U-Net(语义分割) + +编码器-解码器结构,带有**跳跃连接**: + +- **编码器**:逐步下采样,提取高级特征 +- **解码器**:逐步上采样,恢复空间分辨率 +- **跳跃连接**:将编码器特征拼接到解码器,保留细节 + +$$\\text{Decoder}_i = \\text{Conv}(\\text{Concat}(\\text{Up}(\\text{Decoder}_{i+1}), \\text{Encoder}_i))$$ + +### Mask R-CNN(实例分割) + +在 Faster R-CNN 基础上增加**掩码分支**: + +1. **骨干网络**: 提取特征图 +2. **RPN**: 生成候选区域 +3. **RoI Align**: 从特征图提取固定大小特征 +4. **分类头**: 预测类别 +5. **回归头**: 精修边界框 +6. **掩码头**: 预测像素级掩码 + +### 损失函数 + +Mask R-CNN 多任务损失: + +$$L = L_{cls} + L_{box} + L_{mask}$$ + +掩码损失使用**逐像素二分类交叉熵**: + +$$L_{mask} = -\\frac{1}{N} \\sum_i [y_i \\log(\\hat{y}_i) + (1-y_i) \\log(1-\\hat{y}_i)]$$ + +### 关键技术 + +**RoI Align** vs RoI Pooling: + +- RoI Pooling: 两次量化,丢失精度 +- RoI Align: 双线性插值,保留亚像素精度,对分割至关重要 + +### 应用 + +- 自动驾驶(道路、行人、车辆分割) +- 医学影像(肿瘤、器官分割) +- 遥感图像(土地利用分类) +- 视频编辑(背景替换、特效) +`, + exercise: { type: 'playground', viz: 'objectDetection' }, + code: { + cpp: `// U-Net 编码器块 +struct ConvBlock { + ConvLayer conv1, conv2; + vector>> forward(const vector>>& x) { + auto h = conv1.forward(x); + h = conv2.forward(h); + return h; + } +}; + +// U-Net 解码器(上采样 + 跳跃连接) +vector>> decoder_block( + const vector>>& up_input, + const vector>>& skip_connection, + ConvBlock& conv) { + // 上采样 + auto up = upsample(up_input, 2.0f); + // 拼接跳跃连接 + auto concat = concatenate(up, skip_connection); + // 卷积 + return conv.forward(concat); +} + +// Mask R-CNN 掩码头 +struct MaskHead { + ConvLayer conv1, conv2, conv3, conv4; + ConvLayer deconv; // 反卷积上采样 + ConvLayer mask_pred; // 预测掩码 + + vector> predict_mask(const vector>>& roi_features) { + auto h = conv1.forward(roi_features); + h = conv2.forward(h); + h = conv3.forward(h); + h = conv4.forward(h); + h = deconv.forward(h); // 上采样 2x + auto mask = mask_pred.forward(h); // 输出二值掩码 + return sigmoid(mask); + } +};`, + python: `import torch +import torch.nn as nn +import torch.nn.functional as F + +class UNet(nn.Module): + def __init__(self, in_channels=3, num_classes=21): + super().__init__() + # 编码器 + self.enc1 = self._conv_block(in_channels, 64) + self.enc2 = self._conv_block(64, 128) + self.enc3 = self._conv_block(128, 256) + self.enc4 = self._conv_block(256, 512) + self.pool = nn.MaxPool2d(2) + + # 解码器 + self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) + self.dec3 = self._conv_block(512, 256) + self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) + self.dec2 = self._conv_block(256, 128) + self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) + self.dec1 = self._conv_block(128, 64) + + self.final = nn.Conv2d(64, num_classes, 1) + + def _conv_block(self, in_ch, out_ch): + return nn.Sequential( + nn.Conv2d(in_ch, out_ch, 3, padding=1), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + nn.Conv2d(out_ch, out_ch, 3, padding=1), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + ) + + def forward(self, x): + # 编码器 + e1 = self.enc1(x) + e2 = self.enc2(self.pool(e1)) + e3 = self.enc3(self.pool(e2)) + e4 = self.enc4(self.pool(e3)) + + # 解码器 + 跳跃连接 + d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1)) + d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) + d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) + + return self.final(d1) + +# Mask R-CNN 掩码损失 +def mask_loss(pred_masks, target_masks): + """逐像素二分类交叉熵""" + return F.binary_cross_entropy_with_logits(pred_masks, target_masks)`, + }, + variablesSnapshot: { + type: '实例分割', + architecture: 'Mask R-CNN', + backbone: 'ResNet-50-FPN', + maskResolution: '28×28', + mAP: '37.1% (COCO)', + }, + pseudocode: `procedure INSTANCE_SEGMENTATION(image) + // 1. 特征提取 + features <- backbone(image) + + // 2. 候选区域生成 + proposals <- RPN(features) + + // 3. 对每个候选区域 + for each proposal in proposals do + // RoI Align 提取特征 + roi_features <- RoIAlign(features, proposal) + + // 分类 + 边界框回归 + class_label <- classify(roi_features) + refined_box <- refine_bbox(roi_features, proposal) + + // 掩码预测 + mask <- predict_mask(roi_features) + + if class_label != background then + add detection(refined_box, class_label, mask) + end if + end for + + // 4. NMS + return NMS(detections)`, + bigO: { + time: 'U-Net 约 300 GFLOPs(512×512 输入)。Mask R-CNN 约 400 GFLOPs(含 RPN 和掩码头)。', + space: 'U-Net 约 135MB(含跳跃连接的中间特征)。Mask R-CNN 约 170MB。', + note: 'U-Net 跳跃连接需要存储编码器的所有中间特征,显存峰值较大。医学影像通常用小 batch 训练。', + }, + compare: [ + { method: 'U-Net', data: '医学影像/语义分割', strength: '跳跃连接保留细节,医学影像标准', tradeoff: '不区分同类实例' }, + { method: 'Mask R-CNN', data: 'COCO 实例分割', strength: '同时检测+分割,精度高', tradeoff: '速度较慢(~5 FPS)' }, + { method: 'YOLACT', data: '实时实例分割', strength: '速度快(~30 FPS)', tradeoff: '精度低于 Mask R-CNN' }, + { method: 'SegFormer', data: '语义分割', strength: 'Transformer 骨干,多尺度融合', tradeoff: '计算量大' }, + ], + quiz: [ + { + q: 'U-Net 的跳跃连接主要解决什么问题?', + options: [ + '减少参数量', + '加速训练', + '在上采样时恢复编码器中丢失的空间细节信息', + '减少计算量', + ], + answer: 2, + explanation: '编码器的下采样丢失了空间细节信息,跳跃连接将编码器的高分辨率特征直接拼接到解码器的上采样特征中,帮助恢复精细的像素级定位信息。', + }, + { + q: 'RoI Align 相比 RoI Pooling 的改进是什么?', + options: [ + '速度更快', + '使用双线性插值替代量化,保留亚像素精度', + '减少参数量', + '支持更大的输入', + ], + answer: 1, + explanation: 'RoI Pooling 在两次量化(将浮点数坐标取整)中丢失精度,对分割任务影响大。RoI Align 使用双线性插值计算亚像素位置的特征值,保留了更精确的空间信息。', + }, + { + q: '语义分割和实例分割的核心区别是什么?', + options: [ + '使用不同的网络架构', + '语义分割不区分同类不同实例,实例分割为每个实例生成独立掩码', + '语义分割速度更快', + '实例分割不需要训练', + ], + answer: 1, + explanation: '语义分割将所有同类像素标记为同一类别,不区分不同个体。实例分割不仅分类像素,还区分同一类别的不同实例(如不同的两辆车有不同的掩码)。', + }, + ], }, ] diff --git a/src/data/ai/chapters/it.js b/src/data/ai/chapters/it.js index a24661c..9d06a5d 100644 --- a/src/data/ai/chapters/it.js +++ b/src/data/ai/chapters/it.js @@ -3,8 +3,8 @@ // 2026-07:课节内容改为直接取自算法库(algorithms/it.js 的小白化 // intuition/伪代码/真实代码 + quizzes.js 的 3 题测验),单一数据源—— // 此前 14 个课节共用同一段通用 entropy 代码和同一道通用测验。 -import IT_ALGORITHMS from '../../algorithms/it' -import { QUIZZES } from '../../quizzes' +import IT_ALGORITHMS from '../../algorithms/it.js' +import { QUIZZES } from '../../quizzes.js' const INFO_THEORY_MODULES = [ ['it-selfinfo', '自信息与信息量', '概率越小,信息量越大', 'I(x) = -log2 p(x)', 'itFundamental'], diff --git a/src/data/ai/chapters/llm.js b/src/data/ai/chapters/llm.js index 9fbbf80..ca75aae 100644 --- a/src/data/ai/chapters/llm.js +++ b/src/data/ai/chapters/llm.js @@ -3,39 +3,1567 @@ // LATE_COURSE_CODE、completeAILessonMetadata)仍在 ../curriculum.js, // 模块加载时会原位向这些 lesson 对象补字段。 export const LLM_LESSONS = [ + { + id: 'llm-tokenization', + title: 'Tokenization 分词', + summary: 'BPE、WordPiece、SentencePiece、词表构建', + theory: `## Tokenization(分词) + +将文本切分为模型可以处理的 token 序列,是 LLM 的第一步。 + +### 主流分词方法 + +| 方法 | 代表模型 | 核心思想 | +|------|---------|---------| +| BPE (Byte Pair Encoding) | GPT 系列 | 合并高频字节对 | +| WordPiece | BERT | 合并最大互信息对 | +| SentencePiece | T5, LLaMA | 语言无关,直接处理原始文本 | +| Unigram | T5 | 从大词表逐步删除低概率 token | + +### BPE 算法 + +1. 初始化:将文本拆分为单个字符 +2. 统计所有相邻字符对的频率 +3. 合并频率最高的字符对 +4. 重复步骤 2-3 直到达到目标词表大小 + +### 示例 + +$$\\text{"low", "lower", "newest"} \\rightarrow \\text{"l", "o", "w", "e", "r", "n", "s", "t"}$$ + +合并 "e"+"s" → "es",再合并 "es"+"t" → "est",依此类推。 + +### 词表大小 + +- GPT-2: 50,257 tokens +- LLaMA: 32,000 tokens +- GPT-4: ~100,000 tokens (估计) +`, + exercise: { type: 'playground', viz: 'pretraining' }, + code: { + python: `from collections import Counter + +def train_bpe(text, vocab_size): + """训练 BPE 分词器""" + # 初始化:每个字符作为一个 token + vocab = Counter() + for word in text.split(): + word = ' '.join(list(word)) + ' ' + vocab[word] += 1 + + merges = [] + num_merges = vocab_size - len(set(' '.join(vocab.keys()).split())) + + for _ in range(num_merges): + # 统计相邻对频率 + pairs = Counter() + for word, freq in vocab.items(): + symbols = word.split() + for i in range(len(symbols) - 1): + pairs[(symbols[i], symbols[i+1])] += freq + + if not pairs: + break + + # 找最高频对 + best_pair = max(pairs, key=pairs.get) + merges.append(best_pair) + + # 合并 + new_vocab = {} + bigram = ' '.join(best_pair) + replacement = ''.join(best_pair) + for word, freq in vocab.items(): + new_word = word.replace(bigram, replacement) + new_vocab[new_word] = freq + vocab = new_vocab + + return merges + +def tokenize_bpe(text, merges): + """使用训练好的 BPE 分词""" + words = text.split() + tokens = [] + for word in words: + word = ' '.join(list(word)) + ' ' + for pair in merges: + bigram = ' '.join(pair) + replacement = ''.join(pair) + word = word.replace(bigram, replacement) + tokens.extend(word.split()) + return tokens`, + cpp: `// BPE 分词(简化版) +vector train_bpe(const vector& corpus, int target_vocab_size) { + // 初始化为字符级 + map vocab; + for (const auto& word : corpus) { + string chars; + for (char c : word) chars += string(1, c) + " "; + chars += ""; + vocab[chars]++; + } + + vector> merges; + while (vocab.size() < target_vocab_size) { + // 找最高频相邻对 + map, int> pair_freq; + for (const auto& [word, freq] : vocab) { + auto tokens = split(word, ' '); + for (size_t i = 0; i < tokens.size() - 1; i++) { + pair_freq[{tokens[i], tokens[i+1]}] += freq; + } + } + if (pair_freq.empty()) break; + + auto best = max_element(pair_freq.begin(), pair_freq.end(), + [](const auto& a, const auto& b) { return a.second < b.second; }); + merges.push_back(best->first); + + // 合并 + map new_vocab; + string bigram = best->first.first + " " + best->first.second; + string merged = best->first.first + best->first.second; + for (const auto& [word, freq] : vocab) { + string new_word = replace_all(word, bigram, merged); + new_vocab[new_word] += freq; + } + vocab = new_vocab; + } + return merges; +}`, + }, + variablesSnapshot: { + method: 'BPE', + vocabSize: 50257, + merges: '50,000', + avgTokenLength: '~4 chars', + }, + pseudocode: `procedure TRAIN_BPE(text, vocab_size) + vocab <- character-level tokens + while |vocab| < vocab_size do + pairs <- count all adjacent token pairs + best <- highest frequency pair + merge best pair in all words + record merge + end while + return merges + +procedure TOKENIZE_BPE(text, merges) + tokens <- split text into characters + for each merge in merges do + replace all occurrences of merge pair with merged token + end for + return tokens`, + bigO: { + time: '训练 BPE 需要 O(V × N),V 是合并次数,N 是语料库大小。分词需要 O(M × V),M 是文本长度。', + space: '词表需要 O(V) 存储空间。', + note: '实际 BPE 实现使用优先队列和索引来加速训练过程。', + }, + compare: [ + { method: 'BPE', data: '字节对合并', strength: '平衡词表大小和表达能力', tradeoff: '需要训练合并规则' }, + { method: 'WordPiece', data: '最大互信息', strength: '更好的子词选择', tradeoff: '实现更复杂' }, + { method: 'SentencePiece', data: '语言无关', strength: '无需预分词', tradeoff: '可能产生非直觉 token' }, + ], + quiz: [ + { + q: 'BPE(Byte Pair Encoding)分词器的核心思想是什么?', + options: [ + '将文本按空格分割', + '通过迭代合并频率最高的相邻字符对,自动学习子词单元', + '使用固定词表进行分词', + '基于词典的最大匹配', + ], + answer: 1, + explanation: 'BPE 从字符级开始,逐步合并频率最高的相邻字符对,直到达到目标词表大小。这样可以自动发现常见的子词单元。', + }, + ], + }, { id: 'llm-pretraining', title: '预训练与微调', summary: '语言模型预训练、SFT、RLHF', theory: `## 大模型训练流程 -### 三阶段 +### 三阶段训练 + +$$\\text{预训练} \\rightarrow \\text{监督微调 (SFT)} \\rightarrow \\text{RLHF}$$ + +### 1. 预训练 (Pre-training) + +在海量文本上学习语言知识,目标是下一个 token 预测: + +$$L = -\\sum_{t=1}^{T} \\log P(x_t | x_{ + + prompt = f"### Instruction:\\n{ex['instruction']}\\n\\n### Response:\\n" + full_text = prompt + ex['response'] + tokenized = self.tokenizer( + full_text, truncation=True, + max_length=self.max_length, padding='max_length' + ) + input_ids = tokenized['input_ids'] + # 只对 response 部分计算损失 + prompt_ids = self.tokenizer(prompt, truncation=True, max_length=self.max_length)['input_ids'] + label = [-100] * len(prompt_ids) + input_ids[len(prompt_ids):] + inputs.append(input_ids) + labels.append(label) + return { + 'input_ids': torch.LongTensor(inputs), + 'labels': torch.LongTensor(labels), + }`, + cpp: `// 语言模型损失(伪代码) +double causal_lm_loss(const Tensor& logits, const Tensor& targets) { + // shift: 预测 t+1 位置的 token + auto shift_logits = logits.slice(1, 0, -1); + auto shift_labels = targets.slice(1, 1); + return cross_entropy(shift_logits, shift_labels); +} + +// RLHF PPO 更新(伪代码) +void rlhf_update(Policy& policy, const RewardModel& reward_model, + const vector& batch, double beta) { + for (const auto& t : batch) { + double reward = reward_model.score(t.state, t.action); + // KL 惩罚 + double kl = log(policy(t.state, t.action) / policy.ref(t.state, t.action)); + double adjusted_reward = reward - beta * kl; + // PPO 更新 + ppo_step(policy, t, adjusted_reward); + } +}`, + }, + variablesSnapshot: { + phase: 'pre-training', + objective: 'next token prediction', + tokens: '~1T', + batchSize: 2048, + learningRate: 3e-4, + }, + pseudocode: `procedure PRETRAIN(model, data, epochs) + for each batch in data do + inputs, targets <- batch + logits <- model(inputs) + loss <- cross_entropy(logits[:, :-1], targets[:, 1:]) + update model by gradient descent + end for +end procedure + +procedure SFT(model, sft_data) + for each (instruction, response) in sft_data do + prompt <- format(instruction) + logits <- model(prompt) + loss <- cross_entropy(logits, response) with ignore_index + update model + end for +end procedure`, + bigO: { + time: '训练时间 O(T × d²),T 是 token 数,d 是模型维度。GPT-3 训练约 3.14 × 10²³ FLOPs。', + space: '模型参数 O(d)。训练时需要存储优化器状态(Adam 需要 2 倍参数量)和梯度。', + note: '混合精度训练(FP16/BF16)可减少约 50% 的显存占用。', + }, + compare: [ + { method: '预训练', data: '海量无标注文本', strength: '学习通用语言能力', tradeoff: '不遵循指令' }, + { method: 'SFT', data: '指令-回答对', strength: '学会遵循指令', tradeoff: '需要高质量标注数据' }, + { method: 'RLHF', data: '人类偏好反馈', strength: '对齐人类价值观', tradeoff: '训练复杂,成本高' }, + ], + quiz: [ + { + q: '在语言模型预训练中,目标函数通常是什么形式?', + options: [ + '分类损失', + '下一个 token 预测的交叉熵损失(自回归语言建模)', + '回归损失', + '对比损失', + ], + answer: 1, + explanation: 'GPT 类模型使用自回归语言建模目标,即给定前面的 token 序列,预测下一个 token 的概率分布,使用交叉熵损失。', + }, + ], + }, + { + id: 'llm-mlm', + title: '掩码语言模型 (MLM)', + summary: 'BERT 预训练目标、15% 掩码策略', + theory: `## 掩码语言模型 (Masked Language Modeling) + +BERT 类模型的预训练目标:随机掩码部分 token,让模型根据上下文预测被掩码的 token。 + +### 掩码策略 + +$$\\tilde{x}_i = \\begin{cases} [\\text{MASK}] & \\text{80\\% 概率} \\\\ x_{\\text{random}} & \\text{10\\% 概率} \\\\ x_i & \\text{10\\% 概率} \\end{cases}$$ + +### 损失函数 + +$$L_{MLM} = -\\sum_{i \\in \\mathcal{M}} \\log P(x_i | \\tilde{x})$$ + +其中 $\\mathcal{M}$ 是被掩码的位置集合。 + +### 为什么需要随机替换? + +如果所有掩码位置都用 [MASK],模型会学到 [MASK] 的特殊表示,但下游任务(如分类)中不会出现 [MASK] token。加入随机替换和保持原样可以让模型更鲁棒。 + +### 与因果语言模型的区别 + +| 特性 | MLM (BERT) | CLM (GPT) | +|------|-----------|-----------| +| 注意力 | 双向 | 单向(因果) | +| 目标 | 预测掩码 token | 预测下一个 token | +| 适用 | 理解任务 | 生成任务 | +| 训练效率 | 每步 15% token 有损失 | 每步所有 token 有损失 | +`, + exercise: { type: 'playground', viz: 'pretraining' }, + code: { + python: `class MLMCollator: + """MLM 数据整理器""" + def __init__(self, tokenizer, mlm_probability=0.15): + self.tokenizer = tokenizer + self.mlm_probability = mlm_probability + + def __call__(self, examples): + batch = self.tokenizer.pad(examples, return_tensors='pt') + input_ids = batch['input_ids'].clone() + labels = input_ids.clone() + + # 创建掩码概率矩阵 + probability_matrix = torch.full(labels.shape, self.mlm_probability) + + # 特殊 token 不掩码 + special_tokens_mask = [ + self.tokenizer.get_special_tokens_mask(val, already_has_special_tokens=True) + for val in labels.tolist() + ] + probability_matrix[torch.tensor(special_tokens_mask, dtype=torch.bool)] = 0 + + # 采样掩码位置 + masked_indices = torch.bernoulli(probability_matrix).bool() + labels[~masked_indices] = -100 # 只计算掩码位置的损失 + + # 80% 替换为 [MASK] + indices_replaced = torch.bernoulli(torch.full(labels.shape, 0.8)).bool() & masked_indices + input_ids[indices_replaced] = self.tokenizer.convert_tokens_to_ids(self.tokenizer.mask_token) + + # 10% 替换为随机 token + indices_random = torch.bernoulli(torch.full(labels.shape, 0.5)).bool() & masked_indices & ~indices_replaced + random_words = torch.randint(len(self.tokenizer), labels.shape, dtype=torch.long) + input_ids[indices_random] = random_words[indices_random] -1. **预训练**: 在海量文本上学习语言知识 -2. **SFT (监督微调)**: 用指令数据微调 -3. **RLHF**: 通过人类反馈对齐 + # 10% 保持原样(masked_indices 中剩下的 10%) -### 语言模型目标 + return {'input_ids': input_ids, 'labels': labels} -$$L = -\\sum \\log P(x_t | x_{ [MASK] + Tensor replace_mask = bernoulli(shape(input_ids), 0.8); + masked = where(mask & replace_mask, mask_token_id, masked); + + // 10% -> 随机 token + Tensor replace_random = bernoulli(shape(input_ids), 0.5) & mask & ~replace_mask; + Tensor random_tokens = randint(shape(input_ids), 0, vocab_size); + masked = where(replace_random, random_tokens, masked); + + // 10% 保持原样 + + // 非掩码位置的 label 设为 -100 + labels = where(mask, labels, -100); + return {masked, labels}; +}`, + }, + variablesSnapshot: { + objective: 'MLM', + maskProbability: 0.15, + maskToken: '[MASK]', + maskStrategy: '80/10/10', + model: 'BERT-base', + }, + pseudocode: `procedure MLM_PRETRAIN(model, data, mask_prob) + for each batch in data do + input_ids, labels <- batch + masked_input <- apply_mlm_mask(input_ids, mask_prob) + logits <- model(masked_input) + loss <- cross_entropy(logits, labels) with ignore_index=-100 + update model + end for +end procedure + +procedure APPLY_MLM_MASK(tokens, mask_prob) + for each token do + if random() < mask_prob then + r <- random() + if r < 0.8 then token <- [MASK] + else if r < 0.9 then token <- random_token() + else keep original + end if + end for + return masked_tokens`, + bigO: { + time: 'MLM 每步 O(T × d²),但只有 15% 的 token 有损失梯度。实际计算量比 CLM 略少。', + space: '与 CLM 相同,需要 O(T × d) 存储输入和梯度。', + note: 'BERT 预训练需要大量计算(BERT-base 需要约 1e21 FLOPs)。', + }, + compare: [ + { method: 'MLM (BERT)', data: '15% 掩码 token', strength: '双向上下文,理解能力强', tradeoff: '不能直接生成,需要额外解码器' }, + { method: 'CLM (GPT)', data: '下一个 token 预测', strength: '天然适合生成', tradeoff: '只有单向上下文' }, + { method: 'Prefix LM (T5)', data: '前缀部分双向', strength: '理解+生成兼顾', tradeoff: '需要 encoder-decoder 架构' }, + ], + quiz: [ + { + q: 'BERT 预训练中,为什么 10% 的掩码位置保持原始 token 不变?', + options: [ + '为了减少训练数据量', + '防止模型过度依赖 [MASK] token 的特殊表示,因为下游任务中不会出现 [MASK]', + '为了加速训练', + '为了增加模型容量', + ], + answer: 1, + explanation: '如果所有掩码位置都用 [MASK],模型会学到 [MASK] 的特殊表示,但下游任务(如分类)中不会出现 [MASK] token。加入随机替换和保持原样可以让模型更鲁棒。', + }, + ], + }, + { + id: 'llm-clm', + title: '因果语言模型 (CLM)', + summary: 'GPT 预训练目标、自回归生成、Teacher Forcing', + theory: `## 因果语言模型 (Causal Language Modeling) + +GPT 类模型的预训练目标:给定前面的 token,预测下一个 token。 + +### 自回归分解 + +$$P(x_1, x_2, \\ldots, x_T) = \\prod_{t=1}^{T} P(x_t | x_1, \\ldots, x_{t-1})$$ + +### Teacher Forcing + +训练时使用真实 token 作为输入,而不是模型自己的预测: + +$$L = -\\sum_{t=1}^{T} \\log P(x_t | x_1, \\ldots, x_{t-1}; \\theta)$$ + +### 因果注意力掩码 + +$$\\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^T + M}{\\sqrt{d_k}}\\right) V$$ + +其中 $M$ 是上三角掩码矩阵: + +$$M_{ij} = \\begin{cases} 0 & i \\geq j \\\\ -\\infty & i < j \\end{cases}$$ + +这确保位置 $i$ 只能关注位置 $\\leq i$ 的 token。 + +### 生成策略 + +| 策略 | 公式 | 特点 | +|------|------|------| +| Greedy | $x_t = \\arg\\max P(x_t | x_{= top_p + sorted_probs[sorted_indices_to_remove] = 0 + + # 重新归一化 + sorted_probs /= sorted_probs.sum() + + # 采样 + next_token = sorted_indices[torch.multinomial(sorted_probs, 1)] + input_ids = torch.cat([input_ids, next_token.unsqueeze(0)], dim=-1) + + return tokenizer.decode(input_ids[0])`, + cpp: `// 因果注意力掩码(伪代码) +Tensor create_causal_mask(int seq_len) { + Tensor mask = Tensor::zeros(seq_len, seq_len); + for (int i = 0; i < seq_len; i++) + for (int j = i + 1; j < seq_len; j++) + mask(i, j) = -1e18; // -inf + return mask; +} + +// Teacher Forcing 训练(伪代码) +double clm_train_step(Model& model, const Tensor& input_ids, const Tensor& targets) { + Tensor logits = model.forward(input_ids); + // shift: 预测 t+1 + auto shift_logits = logits.slice(1, 0, -1); + auto shift_labels = targets.slice(1, 1); + double loss = cross_entropy(shift_logits.reshape(-1, vocab_size), + shift_labels.reshape(-1)); + model.backward(loss); + model.update(); + return loss; +}`, + }, + variablesSnapshot: { + objective: 'CLM', + model: 'GPT-2', + layers: 12, + heads: 12, + dModel: 768, + parameters: '124M', + }, + pseudocode: `procedure CLM_TRAIN(model, data) + for each batch in data do + logits <- model(input_ids) with causal_mask + loss <- cross_entropy(logits[:, :-1], targets[:, 1:]) + update model + end for +end procedure + +procedure NUCLEUS_SAMPLING(logits, temperature, top_p) + logits <- logits / temperature + probs <- softmax(logits) + sorted_probs, sorted_indices <- sort_descending(probs) + cumulative_probs <- cumsum(sorted_probs) + remove where cumulative_probs - sorted_probs >= top_p + re-normalize + return sample from filtered distribution +end procedure`, + bigO: { + time: '自回归推理需要 T 步前向传播,每步 O(d²)。总时间 O(T × d²)。', + space: '训练时需要 O(T × d) 存储 KV 缓存用于加速推理。', + note: 'KV 缓存可以将推理时间从 O(T² × d²) 降到 O(T × d²),但需要额外内存。', + }, + compare: [ + { method: 'Greedy', data: 'argmax', strength: '确定性,质量稳定', tradeoff: '容易重复,缺乏多样性' }, + { method: 'Temperature Sampling', data: 'softmax/T', strength: '可控多样性', tradeoff: '需要调整温度' }, + { method: 'Nucleus (Top-p)', data: '动态截断', strength: '自适应,质量最佳', tradeoff: '计算稍复杂' }, + ], + quiz: [ + { + q: '在自回归语言模型中,因果注意力掩码(causal mask)的作用是什么?', + options: [ + '加速计算', + '确保位置 i 的 token 只能关注位置 ≤ i 的 token,防止看到未来信息', + '减少参数量', + '增加模型容量', + ], + answer: 1, + explanation: '因果掩码是一个上三角矩阵,将未来位置的注意力分数设为 -inf,确保 softmax 后这些位置的权重为 0。这是自回归模型的核心约束。', + }, + ], + }, + { + id: 'llm-sft', + title: '监督微调 (SFT)', + summary: '指令数据、损失掩码、超参数', + theory: `## 监督微调 (Supervised Fine-Tuning) + +用指令-回答数据微调预训练模型,教会模型遵循人类指令。 + +### 数据格式 + +指令微调样本由 prompt 段(指令模板 + 用户指令)和 response 段(期望回答)拼接而成: + + ### Instruction + {instruction} + + ### Response + {response} ← 只有这一段参与损失计算 + +### 损失掩码 + +只对 response 部分计算损失,prompt 部分设为 -100(忽略): + +$$L = -\\sum_{t \\in \\text{response}} \\log P(y_t | y_{ chunks; + Matrix chunk_embeddings; + EmbeddingModel embedder; + + void index(const vector& documents) { + // 分块 + for (const auto& doc : documents) + for (const auto& chunk : split_document(doc, chunk_size, overlap)) + chunks.push_back(chunk); + // 计算 embedding + chunk_embeddings = embedder.encode(chunks); + } + + vector> retrieve(const string& query, int k) { + Vector query_vec = embedder.encode(query); + vector> scores; + for (int i = 0; i < chunks.size(); i++) { + double sim = cosine_similarity(query_vec, chunk_embeddings.row(i)); + scores.push_back({i, sim}); + } + partial_sort(scores.begin(), scores.begin() + k, scores.end(), + [](auto& a, auto& b) { return a.second > b.second; }); + // 返回 top-k + vector> results; + for (int i = 0; i < k; i++) + results.push_back({chunks[scores[i].first], scores[i].second}); + return results; + } +};`, + }, + variablesSnapshot: { + chunkSize: 500, + chunkOverlap: 50, + topK: 3, + embedding: 'text-embedding-ada-002', + vectorDB: 'Pinecone', + }, + pseudocode: `procedure RAG_INDEX(documents) + chunks <- split_documents(documents, chunk_size, overlap) + embeddings <- embed(chunks) + store(embeddings, chunks) in vector_db +end procedure + +procedure RAG_QUERY(query, top_k) + query_vec <- embed(query) + results <- vector_db.search(query_vec, top_k) + context <- concatenate(results.chunks) + prompt <- build_prompt(context, query) + answer <- llm.generate(prompt) + return answer +end procedure + +procedure BUILD_PROMPT(context, query) + return "System: Use the following context to answer.\\n\\n" + + context + "\\n\\nUser: " + query +end procedure`, + bigO: { + time: '检索 O(d + log N) 使用近似最近邻(ANN),d 是向量维度,N 是文档数。生成 O(T × d²)。', + space: '向量数据库存储 O(N × d)。索引额外 O(N × d)。', + note: 'HNSW(Hierarchical Navigable Small World)是最常用的 ANN 算法,查询时间 O(log N)。', + }, + compare: [ + { method: '基础 RAG', data: '单次检索', strength: '简单有效', tradeoff: '可能检索不到相关文档' }, + { method: '高级 RAG', data: '查询重写 + 重排序', strength: '检索质量更高', tradeoff: '延迟增加' }, + { method: '模块化 RAG', data: '多步检索 + 融合', strength: '处理复杂查询', tradeoff: '实现复杂' }, + ], + quiz: [ + { + q: 'RAG 中使用余弦相似度进行向量检索的主要原因是什么?', + options: [ + '计算速度最快', + '余弦相似度衡量向量方向的相似性,不受向量长度影响,适合比较语义相似度', + '需要的存储空间最小', + '实现最简单', + ], + answer: 1, + explanation: 'Embedding 向量的方向编码了语义信息,而长度通常不那么重要。余弦相似度只关注方向,适合衡量语义相似度。', + }, + ], + }, + { + id: 'llm-tool-calling', + title: '工具调用与 Function Calling', + summary: 'Schema 定义、规划、执行循环、ReAct', + theory: `## 工具调用 (Tool/Function Calling) + +让 LLM 能够调用外部工具(API、数据库、代码执行等)来扩展能力。 + +### 核心流程 + +$$\\text{用户问题} \\rightarrow \\text{LLM 选择工具} \\rightarrow \\text{执行工具} \\rightarrow \\text{LLM 生成回答}$$ + +### Schema 定义 + +[json] +{ + "name": "get_weather", + "description": "获取指定城市的天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "城市名称"} + }, + "required": ["city"] + } +} +[/json] + +### ReAct (Reasoning + Acting) + +$$\\text{Thought} \\rightarrow \\text{Action} \\rightarrow \\text{Observation} \\rightarrow \\cdots \\rightarrow \\text{Answer}$$ + +### 多工具协作 + +$$\\text{Planner} \\rightarrow \\text{Executor} \\rightarrow \\text{Reviewer}$$ + +- **Planner**: 分解任务,选择工具 +- **Executor**: 执行工具调用 +- **Reviewer**: 检查结果,决定下一步 + +### 常见工具类型 + +| 类型 | 示例 | +|------|------| +| 数据查询 | SQL 数据库、搜索引擎 | +| 代码执行 | Python、Shell | +| API 调用 | 天气、地图、支付 | +| 文件操作 | 读写文件、格式转换 | +`, + exercise: { type: 'playground', viz: 'agent' }, + code: { + python: `import json +import re + +class ToolCaller: + """工具调用管理器""" + def __init__(self, tools): + self.tools = {t['name']: t for t in tools} + self.tool_schemas = [{'type': 'function', 'function': t} for t in tools] + + def parse_tool_call(self, llm_output): + """解析 LLM 输出的工具调用""" + # 尝试解析 JSON + try: + # 查找 JSON 块 + match = re.search(r'\\{.*\\}', llm_output, re.DOTALL) + if match: + call = json.loads(match.group()) + if 'name' in call and 'parameters' in call: + return call + except json.JSONDecodeError: + pass + return None + + def execute_tool(self, tool_name, parameters): + """执行工具调用""" + if tool_name not in self.tools: + return f"Error: Tool '{tool_name}' not found" + tool = self.tools[tool_name] + # 验证参数 + required = tool['parameters'].get('required', []) + for param in required: + if param not in parameters: + return f"Error: Missing required parameter '{param}'" + # 执行(这里是模拟) + return f"Result from {tool_name}: {parameters}" + +# ReAct 循环 +def react_loop(llm, tool_caller, question, max_steps=5): + context = f"Question: {question}\\n\\n" + for step in range(max_steps): + # LLM 生成思考和行动 + prompt = context + "\\nThought:" + response = llm.generate(prompt) + context += f"Thought: {response}\\n" + + # 解析行动 + action_match = re.search(r'Action:\\s*(.+?)\\nAction Input:\\s*(.+?)(?:\\n|$)', response, re.DOTALL) + if action_match: + tool_name = action_match.group(1).strip() + tool_input = json.loads(action_match.group(2).strip()) + # 执行工具 + observation = tool_caller.execute_tool(tool_name, tool_input) + context += f"Observation: {observation}\\n\\n" + else: + # 没有行动,检查是否有最终答案 + answer_match = re.search(r'Answer:\\s*(.+)', response, re.DOTALL) + if answer_match: + return answer_match.group(1).strip() + context += f"{response}\\n\\n" + return "Max steps reached"`, + cpp: `// 工具调用(伪代码) +struct Tool { + string name; + string description; + json schema; + function handler; +}; + +class ToolCaller { + map tools; + + json call(const string& name, const json& params) { + if (!tools.count(name)) + throw runtime_error("Tool not found: " + name); + return tools[name].handler(params); + } + + string build_system_prompt() const { + string prompt = "You have access to the following tools:\\n\\n"; + for (const auto& [name, tool] : tools) { + prompt += "- " + name + ": " + tool.description + "\\n"; + } + prompt += "\\nTo use a tool, respond with JSON: {"name": "tool_name", "parameters": {...}}"; + return prompt; + } +};`, + }, + variablesSnapshot: { + tools: 5, + maxSteps: 5, + framework: 'ReAct', + model: 'GPT-4', + }, + pseudocode: `procedure TOOL_CALLING_LOOP(llm, tools, query) + context <- build_system_prompt(tools) + query + for step = 1 to max_steps do + response <- llm.generate(context) + if response has tool_call then + result <- execute_tool(response.tool) + context <- context + response + result + else + return response // 最终答案 + end if + end for + return "Max steps reached" +end procedure + +procedure REACT(llm, tools, question) + context <- "Question: " + question + repeat + thought <- llm.think(context) + if thought has answer then return thought.answer + action <- parse_action(thought) + observation <- execute(action) + context <- context + thought + observation + until done +end procedure`, + bigO: { + time: '每步 O(T × d²) 的 LLM 推理 + O(工具执行时间)。总时间 O(S × T × d²),S 是步数。', + space: '需要存储工具定义和上下文历史。上下文长度随步数线性增长。', + note: '工具调用的延迟瓶颈通常在工具执行而非 LLM 推理。', + }, + compare: [ + { method: '单次工具调用', data: '一个工具', strength: '简单快速', tradeoff: '只能解决简单问题' }, + { method: 'ReAct', data: '多步推理+行动', strength: '处理复杂问题', tradeoff: '步数多,延迟高' }, + { method: 'Plan-and-Execute', data: '先规划后执行', strength: '结构清晰', tradeoff: '规划可能不准确' }, + ], + quiz: [ + { + q: 'ReAct(Reasoning + Acting)框架的核心思想是什么?', + options: [ + '只使用推理,不使用工具', + '交替进行推理(Thought)和行动(Action),让模型在每一步都先思考再行动', + '只使用行动,不进行推理', + '一次性生成所有计划', + ], + answer: 1, + explanation: 'ReAct 让 LLM 在每个步骤交替生成思考(Thought)和行动(Action),通过观察(Observation)获取反馈,形成推理-行动的循环。', + }, + ], + }, + { + id: 'llm-chain-of-thought', + title: '思维链 (CoT)', + summary: 'Prompting 技术、自一致性、Tree-of-Thought', + theory: `## 思维链 (Chain-of-Thought) + +通过引导模型逐步推理,提升复杂推理任务的性能。 + +### 标准 CoT + +$$\\text{Prompt: } \\underbrace{\\text{问题} + \\text{逐步推理示例}}_{\\text{Few-shot CoT}} \\rightarrow \\text{回答}$$ + +### Zero-shot CoT + +$$\\text{Prompt: } \\{\\text{问题}\\} + \\text{ "Let's think step by step."}$$ + +### 自一致性 (Self-Consistency) + +多次采样不同的推理路径,取最终答案的多数投票: + +$$\\text{Answer} = \\text{majority\\_vote}(\\{\\text{answer}_i\\}_{i=1}^K)$$ + +### Tree-of-Thought (ToT) + +$$\\text{问题} \\rightarrow \\underbrace{\\text{生成多个推理分支}}_{\\text{BFS/DFS}} \\rightarrow \\underbrace{\\text{评估每个分支}}_{\\text{LLM 自评/投票}} \\rightarrow \\text{选择最优路径}$$ + +### 其他变体 + +| 方法 | 描述 | +|------|------| +| Least-to-Most | 分解子问题,逐个解决 | +| Self-Ask | 模型自问自答 | +| Reflexion | 失败后反思,重新尝试 | +| Step-Back | 先抽象问题,再具体解决 | + +### 性能对比 + +| 方法 | GSM8K 准确率 | +|------|-------------| +| Standard | 18.3% | +| CoT (Few-shot) | 56.5% | +| Self-Consistency | 74.4% | +| CoT + GPT-4 | 92.0% | +`, + exercise: { type: 'playground', viz: 'agent' }, + code: { + python: `def chain_of_thought_prompt(question, examples=None): + """生成 CoT prompt""" + if examples: + prompt = "" + for ex in examples: + prompt += f"Q: {ex['question']}\\nA: Let's think step by step. {ex['reasoning']}\\n\\n" + prompt += f"Q: {question}\\nA: Let's think step by step." + else: + prompt = f"Q: {question}\\nA: Let's think step by step." + return prompt + +def self_consistency(llm, question, k=5, temperature=0.7): + """自一致性:多次采样,多数投票""" + answers = [] + prompt = chain_of_thought_prompt(question) + for _ in range(k): + response = llm.generate(prompt, temperature=temperature) + # 提取最终答案 + answer = extract_final_answer(response) + if answer: + answers.append(answer) + # 多数投票 + from collections import Counter + if not answers: + return None + return Counter(answers).most_common(1)[0][0] + +def tree_of_thought(llm, question, depth=3, breadth=3): + """Tree-of-Thought:BFS 搜索""" + # 生成初始候选 + prompt = f"Generate {breadth} different approaches to solve: {question}\\n\\nFor each approach, provide a brief outline." + candidates = llm.generate(prompt) + current_nodes = parse_candidates(candidates, breadth) + + for d in range(depth): + next_nodes = [] + for node in current_nodes: + # 生成后续步骤 + prompt = f"Continue this reasoning:\\n{node}\\n\\nGenerate {breadth} next steps:" + next_steps = llm.generate(prompt) + steps = parse_candidates(next_steps, breadth) + # 评估每个步骤 + for step in steps: + full_reasoning = node + "\\n" + step + score = evaluate_reasoning(ll<[audio_never_used_51bce0c785ca2f68081bfa7d91973934]>, question, full_reasoning) + next_nodes.append((full_reasoning, score)) + # 选择 top-breadth 节点 + next_nodes.sort(key=lambda x: x[1], reverse=True) + current_nodes = [n for n, _ in next_nodes[:breadth]] + + # 最终答案 + return llm.generate(f"Based on this reasoning:\\n{current_nodes[0]}\\n\\nProvide the final answer to: {question}") + +def evaluate_reasoning(llm, question, reasoning): + """LLM 自评推理质量""" + prompt = f"""Rate this reasoning on a scale of 1-10 for correctness and relevance to the question. + +Question: {question} + +Reasoning: +{reasoning} + +Rating (1-10):""" + score_text = llm.generate(prompt) + try: + return float(score_text.strip()) + except ValueError: + return 5.0`, + cpp: `// Self-consistency(伪代码) +string self_consistency(LLM& llm, const string& question, int k) { + map votes; + for (int i = 0; i < k; i++) { + string response = llm.generate(question + "\\nLet's think step by step.", 0.7); + string answer = extract_answer(response); + votes[answer]++; + } + // 找出票数最多的答案 + return max_element(votes.begin(), votes.end(), + [](auto& a, auto& b) { return a.second < b.second; })->first; +} + +// Tree-of-Thought BFS(伪代码) +string tree_of_thought(LLM& llm, const string& question, int depth, int breadth) { + vector current = generate_initial_candidates(llm, question, breadth); + for (int d = 0; d < depth; d++) { + vector> scored; + for (const auto& node : current) { + auto next_steps = generate_next_steps(llm, node, breadth); + for (const auto& step : next_steps) { + double score = evaluate(llm, question, node + "\\n" + step); + scored.push_back({node + "\\n" + step, score}); + } + } + sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.second > b.second; }); + current.clear(); + for (int i = 0; i < min(breadth, (int)scored.size()); i++) + current.push_back(scored[i].first); + } + return generate_final_answer(llm, current[0], question); +}`, + }, + variablesSnapshot: { + method: 'CoT', + type: 'few-shot', + examples: 3, + model: 'GPT-4', + accuracyGSM8K: '92%', + }, + pseudocode: `procedure COT_PROMPT(question, examples) + prompt <- "" + for each example in examples do + prompt <- prompt + "Q: " + example.q + "\\nA: " + example.a + "\\n\\n" + end for + prompt <- prompt + "Q: " + question + "\\nA: Let's think step by step." + return prompt +end procedure + +procedure SELF_CONSISTENCY(llm, question, k) + answers <- empty list + for i = 1 to k do + response <- llm.generate(COT_PROMPT(question), temperature=0.7) + answers.append(extract_answer(response)) + end for + return majority_vote(answers) +end procedure + +procedure TREE_OF_THOUGHT(llm, question, depth, breadth) + nodes <- generate_candidates(question, breadth) + for d = 1 to depth do + candidates <- empty + for each node in nodes do + steps <- generate_next_steps(node, breadth) + for each step in steps do + candidates.append({node + step, evaluate(step)}) + end for + end for + nodes <- top_k(candidates, breadth) + end for + return generate_final_answer(nodes[0]) +end procedure`, + bigO: { + time: 'CoT 是 O(T × d²)。Self-consistency 是 O(K × T × d²),K 是采样次数。ToT 是 O(D × B × T × d²),D 是深度,B 是广度。', + space: 'ToT 需要存储 B 个候选节点,每个 O(T) token。', + note: 'CoT 对小模型(< 10B)效果有限,对大模型(> 50B)效果显著。', + }, + compare: [ + { method: 'Standard', data: '直接回答', strength: '快速', tradeoff: '推理能力弱' }, + { method: 'CoT', data: '逐步推理', strength: '显著提升推理', tradeoff: '需要更长输出' }, + { method: 'Self-Consistency', data: '多次采样投票', strength: '进一步提升', tradeoff: '计算成本增加 K 倍' }, + { method: 'ToT', data: '树形搜索', strength: '处理复杂问题', tradeoff: '计算成本高' }, + ], + quiz: [ + { + q: '自一致性(Self-Consistency)方法如何提升 CoT 的效果?', + options: [ + '使用更大的模型', + '多次采样不同的推理路径,然后对最终答案进行多数投票,减少单次推理的随机性', + '增加训练数据量', + '减少推理步骤', + ], + answer: 1, + explanation: '单次 CoT 推理可能因为随机性而产生错误。自一致性通过多次采样不同的推理路径,对最终答案进行多数投票,能够有效减少随机性带来的错误。', + }, + ], }, { id: 'llm-agent', @@ -43,15 +1571,247 @@ $$L = -\\sum \\log P(x_t | x_{ str: + pass + +class ShortTermMemory: + """短期记忆:对话历史""" + def __init__(self, max_tokens=4000): + self.history = [] + self.max_tokens = max_tokens + + def add(self, role: str, content: str): + self.history.append({'role': role, 'content': content}) + + def get_context(self) -> str: + return '\\n'.join([f"{h['role']}: {h['content']}" for h in self.history]) + + def clear(self): + self.history = [] + +class LongTermMemory: + """长期记忆:向量数据库""" + def __init__(self, embedder, vector_db): + self.embedder = embedder + self.vector_db = vector_db + + def store(self, key: str, value: str): + embedding = self.embedder.encode(value) + self.vector_db.upsert(ids=[key], embeddings=[embedding], documents=[value]) + + def retrieve(self, query: str, top_k=3) -> List[str]: + query_embedding = self.embedder.encode(query) + results = self.vector_db.search(query_embedding, top_k=top_k) + return [r['document'] for r in results] + +class ReActAgent(BaseAgent): + """ReAct Agent""" + def run(self, task: str, max_steps=10) -> str: + self.memory.add('user', task) + context = self.memory.get_context() + + for step in range(max_steps): + # 生成下一步 + prompt = self._build_prompt(context) + response = self.llm.generate(prompt) + + # 检查是否有最终答案 + if 'Final Answer:' in response: + answer = response.split('Final Answer:')[-1].strip() + self.memory.add('assistant', answer) + return answer + + # 解析工具调用 + action = self._parse_action(response) + if action: + result = self._execute_tool(action) + context += f"\\n{response}\\nObservation: {result}" + self.memory.add('system', f"Observation: {result}") + else: + context += f"\\n{response}" + + return "Max steps reached without answer" + + def _build_prompt(self, context: str) -> str: + tool_descriptions = '\\n'.join([ + f"- {t['name']}: {t['description']}" for t in self.tools + ]) + return f"""You are an AI assistant with access to these tools: +{tool_descriptions} + +Use the following format: +Thought: think about what to do +Action: {{{{"tool": "tool_name", "args": {{...}}}}}} +Observation: tool result +... (repeat Thought/Action/Observation as needed) +Final Answer: the answer to the original question + +Previous context: +{context} + +Begin!""" + + def _parse_action(self, response: str) -> Dict: + import json + try: + match = re.search(r'Action:\\s*(\\{.*?\\})', response, re.DOTALL) + if match: + return json.loads(match.group(1)) + except: + pass + return None + + def _execute_tool(self, action: Dict) -> str: + tool_name = action.get('tool') + tool = next((t for t in self.tools if t['name'] == tool_name), None) + if not tool: + return f"Error: Tool '{tool_name}' not found" + try: + return str(tool['handler'](**action.get('args', {}))) + except Exception as e: + return f"Error: {str(e)}"`, + cpp: `// Agent 框架(伪代码) +class Agent { + LLM llm; + vector tools; + Memory memory; + + string run(const string& goal) { + memory.add("user", goal); + for (int step = 0; step < max_steps; step++) { + string context = memory.get_context(); + string response = llm.generate(build_prompt(context)); + if (has_final_answer(response)) + return extract_final_answer(response); + auto action = parse_action(response); + string observation = execute_tool(action); + memory.add("system", observation); + } + return "Max steps reached"; + } +}; + +// 多智能体系统 +class MultiAgentSystem { + Agent planner, executor, critic, integrator; + + string solve(const string& task) { + auto sub_tasks = planner.decompose(task); + vector results; + for (const auto& sub_task : sub_tasks) { + string result = executor.run(sub_task); + string feedback = critic.evaluate(result); + if (!feedback.empty()) { + result = executor.run(sub_task + "\\nFeedback: " + feedback); + } + results.push_back(result); + } + return integrator.integrate(results); + } +};`, + }, + variablesSnapshot: { + architecture: 'ReAct', + tools: 5, + maxSteps: 10, + memory: 'short + long term', + model: 'GPT-4', + }, + pseudocode: `procedure AGENT_RUN(agent, task) + memory.add("user", task) + for step = 1 to max_steps do + response <- llm.generate(memory.get_context()) + if has_final_answer(response) then + return extract_final_answer(response) + end if + action <- parse_action(response) + observation <- execute_tool(action) + memory.add("system", observation) + end for + return "Max steps reached" +end procedure + +procedure MULTI_AGENT_SOLVE(system, task) + sub_tasks <- planner.decompose(task) + results <- empty list + for each sub_task in sub_tasks do + result <- executor.run(sub_task) + feedback <- critic.evaluate(result) + if feedback then + result <- executor.run(sub_task + feedback) + end if + results.append(result) + end for + return integrator.integrate(results) +end procedure`, + bigO: { + time: 'Agent 总时间 O(S × T × d² + S × 工具时间),S 是步数。多智能体系统 O(A × S × T × d²),A 是智能体数量。', + space: '短期记忆 O(T × S)。长期记忆 O(N × d)。', + note: 'Agent 的效率高度依赖于规划能力和工具选择的准确性。', + }, + compare: [ + { method: '单 Agent (ReAct)', data: '一个 LLM + 工具', strength: '简单直接', tradeoff: '复杂任务可能失败' }, + { method: '多 Agent 协作', data: '多个专门 Agent', strength: '处理复杂任务', tradeoff: '通信开销大' }, + { method: 'AutoGPT', data: '自主规划 + 执行', strength: '高度自主', tradeoff: '容易陷入循环' }, + ], + quiz: [ + { + q: 'AI Agent 的短期记忆和长期记忆分别负责什么?', + options: [ + '短期:存储模型参数;长期:存储训练数据', + '短期:存储当前会话的对话历史(LLM 上下文);长期:存储跨会话的知识和经验(向量数据库)', + '短期:存储工具定义;长期:存储任务结果', + '短期:存储代码;长期:存储文档', + ], + answer: 1, + explanation: '短期记忆是 Agent 当前会话的上下文,存储在 LLM 的上下文窗口中。长期记忆通过向量数据库存储,跨会话保留知识和经验。', + }, + ], }, ] diff --git a/src/data/ai/chapters/nlp.js b/src/data/ai/chapters/nlp.js index 8fb11d4..0481be0 100644 --- a/src/data/ai/chapters/nlp.js +++ b/src/data/ai/chapters/nlp.js @@ -9,32 +9,709 @@ export const NLP_LESSONS = [ summary: 'Word2Vec、GloVe、词向量空间', theory: `## 词嵌入 -将离散的词语映射为连续的向量空间。 +将离散的词语映射为连续的低维向量空间,是现代 NLP 的基石。 -### Word2Vec +### 为什么需要词嵌入? -- **CBOW**: 用上下文预测中心词 -- **Skip-gram**: 用中心词预测上下文 +传统 one-hot 编码将每个词表示为一个巨大的稀疏向量(维度=词表大小),无法表达词与词之间的语义关系。词嵌入通过无监督或自监督方式,将词映射到稠密的低维向量空间(通常 50-300 维)。 ### 词向量的神奇性质 $$vec("King") - vec("Man") + vec("Woman") \\approx vec("Queen")$$ + +向量空间中的方向对应语义关系:性别、时态、国家-首都等关系都可以通过向量加减法来捕捉。 + +### 主要方法 + +| 方法 | 核心思想 | 训练方式 | +|------|---------|---------| +| Word2Vec | 局部上下文窗口预测 | 神经网络(浅层) | +| GloVe | 全局共现统计 | 加权最小二乘 | +| FastText | 子词(subword)信息 | 类似 Word2Vec | + +### 词向量的评估 + +- **内在评估**: 词类比任务(King-Man+Woman=?)、相似度打分 +- **外在评估**: 将词向量作为下游任务的输入特征,看效果提升 +`, + exercise: { type: 'playground', viz: 'wordEmbedding' }, + code: { + cpp: `#include +#include +#include +using Embedding = std::vector; +using EmbeddingMatrix = std::vector; + +// 简单的词向量查询 +Embedding get_embedding(const EmbeddingMatrix& matrix, + const std::map& vocab, + const std::string& word) { + auto it = vocab.find(word); + if (it != vocab.end()) return matrix[it->second]; + return Embedding(matrix[0].size(), 0.0); // OOV 返回零向量 +} + +// 词向量运算:King - Man + Woman +Embedding analogy(const Embedding& king, const Embedding& man, + const Embedding& woman) { + Embedding result(king.size()); + for (int i = 0; i < king.size(); i++) { + result[i] = king[i] - man[i] + woman[i]; + } + return result; +}`, + python: `import numpy as np + +def analogy(king, man, woman): + """King - Man + Woman ≈ Queen""" + return king - man + woman + +# 余弦相似度查找最近邻 +def find_closest(query_vector, embeddings, vocab, top_k=5): + similarities = embeddings @ query_vector / ( + np.linalg.norm(embeddings, axis=1) * np.linalg.norm(query_vector) + 1e-8 + ) + top_indices = np.argsort(similarities)[-top_k:][::-1] + return [(vocab[i], similarities[i]) for i in top_indices]` + }, + variablesSnapshot: { + method: 'Word2Vec', + dimension: '100', + vocabSize: '10000', + windowSize: '5', + }, + pseudocode: `procedure WORD_EMBEDDING_LOOKUP(word, matrix, vocab) + if word in vocab then + return matrix[vocab[word]] + else + return zero_vector // OOV 处理 + end if + +procedure ANALOGY(king, man, woman) + return king - man + woman`, + bigO: { + time: '查表操作为 O(d),其中 d 是词向量维度。词类比计算为 O(d)。', + space: '存储整个嵌入矩阵需要 O(V × d),V 是词表大小。', + note: '大规模预训练嵌入(如 GloVe 840B)需要数 GB 存储空间,通常需要内存映射或降采样。', + }, + compare: [ + { method: 'Word2Vec', data: '局部上下文窗口', strength: '捕捉细粒度语义关系', tradeoff: '忽略全局统计信息' }, + { method: 'GloVe', data: '全局共现矩阵', strength: '融合全局统计和局部上下文', tradeoff: '训练数据需求更大' }, + { method: 'One-hot', data: '无', strength: '简单,无需训练', tradeoff: '高维稀疏,无语义信息' }, + ], + quiz: [ + { + q: '词向量空间中 vec(King) - vec(Man) + vec(Woman) ≈ vec(Queen) 说明了什么?', + options: [ + '词向量编码了语义关系,可以通过代数运算推理', + 'King 和 Queen 是同一个词的不同拼写', + '所有词向量的距离都是相等的', + 'Man 和 Woman 的向量完全相同', + ], + answer: 0, + explanation: '这个经典的词类比例子说明词向量空间中的方向对应语义关系,性别关系、国家-首都关系等都可以通过向量加减法来捕捉。', + }, + ], + }, + { + id: 'nlp-tokenization', + title: '分词与子词编码', + summary: 'Word、Subword(BPE)、Character tokenization', + theory: `## 分词(Tokenization) + +分词是 NLP 的第一步,将原始文本切分成模型可以处理的 token 序列。 + +### 三种主要粒度 + +| 粒度 | 说明 | 优点 | 缺点 | +|------|------|------|------| +| Word | 按词切分 | 语义完整 | OOV 问题,词表巨大 | +| Subword | 子词(BPE/WordPiece) | 平衡 OOV 和语义 | 切分规则复杂 | +| Character | 按字符切分 | 无 OOV,词表极小 | 序列过长,语义弱 | + +### BPE(Byte Pair Encoding) + +BPE 是最常用的子词分词算法,通过迭代合并最高频的字节对来构建词表: + +1. 初始化词表为所有单个字符 +2. 统计所有相邻 token 对的频率 +3. 合并频率最高的 token 对 +4. 重复 2-3 直到达到目标词表大小 + +### 示例 + +"unbelievable" 可能被切分为:["un", "believ", "able"] + +这种切分方式既能处理常见词(整体保留),又能将罕见词拆分为已知的子词单元。 + +### 现代实践 + +- **BERT**: 使用 WordPiece(类似 BPE,基于似然合并) +- **GPT-2/3**: 使用 BPE +- **LLaMA**: 使用 SentencePiece(BPE 变体,支持多语言) +- **T5**: 使用 SentencePiece +`, + exercise: { type: 'playground', viz: 'wordEmbedding' }, + code: { + cpp: `#include +#include +#include +#include + +// BPE 合并操作 +struct BPEMerge { + std::string first, second, merged; +}; + +std::vector bpe_tokenize( + const std::string& word, + const std::vector& merges) { + // 初始化为字符级 + std::vector tokens; + for (char c : word) tokens.push_back(std::string(1, c)); + + // 反复应用合并规则 + bool changed = true; + while (changed) { + changed = false; + for (int i = 0; i < (int)tokens.size() - 1; i++) { + for (const auto& m : merges) { + if (tokens[i] == m.first && tokens[i+1] == m.second) { + tokens[i] = m.merged; + tokens.erase(tokens.begin() + i + 1); + changed = true; + break; + } + } + } + } + return tokens; +}`, + python: `def bpe_tokenize(word, merges): + """BPE 分词:将词拆分为子词单元""" + # 初始化为字符列表 + tokens = list(word) + + # 反复应用合并规则 + changed = True + while changed: + changed = False + i = 0 + while i < len(tokens) - 1: + pair = (tokens[i], tokens[i + 1]) + if pair in merges: + tokens[i] = ''.join(pair) + del tokens[i + 1] + changed = True + else: + i += 1 + return tokens + +# 示例:学习 BPE 合并规则 +def learn_bpe(corpus, num_merges): + vocab = {' '.join(word): freq for word, freq in corpus.items()} + merges = {} + for _ in range(num_merges): + pairs = {} + for word, freq in vocab.items(): + symbols = word.split() + for i in range(len(symbols) - 1): + pairs[(symbols[i], symbols[i+1])] = \ + pairs.get((symbols[i], symbols[i+1]), 0) + freq + if not pairs: + break + best = max(pairs, key=pairs.get) + merges[best] = ''.join(best) + # 更新词表... + return merges` + }, + variablesSnapshot: { + granularity: 'Subword (BPE)', + vocabSize: '32000', + maxSeqLen: '512', + }, + pseudocode: `procedure BPE_LEARN(corpus, num_merges) + vocab <- initialize_with_characters(corpus) + for i <- 1 to num_merges do + pairs <- count_all_adjacent_pairs(vocab) + best_pair <- argmax(pairs) + merge best_pair in vocab + record merge rule + end for + return merges + +procedure BPE_TOKENIZE(word, merges) + tokens <- split_into_characters(word) + repeat + for each adjacent pair in tokens do + if pair in merges then + merge them + end if + end for + until no more merges apply + return tokens`, + bigO: { + time: 'BPE 学习阶段需要 O(N × M),其中 N 是语料库大小,M 是合并次数。分词阶段为 O(L × K),L 是词长,K 是合并规则数。', + space: '存储合并规则需要 O(V),V 是词表大小。分词时的中间状态为 O(L)。', + note: '实际使用中,预训练的 BPE 分词器非常快,因为合并规则是离线学习好的。', + }, + compare: [ + { method: 'Word tokenization', data: '空格/标点', strength: '语义完整,序列短', tradeoff: 'OOV 问题,词表巨大' }, + { method: 'Subword (BPE)', data: '子词单元', strength: '平衡 OOV 和序列长度', tradeoff: '需要学习合并规则' }, + { method: 'Character', data: '单个字符', strength: '无 OOV,词表极小', tradeoff: '序列过长,语义弱' }, + ], + quiz: [ + { + q: 'BPE(Byte Pair Encoding)分词的核心思想是什么?', + options: [ + '按空格分割文本', + '迭代合并最高频的相邻 token 对,构建子词词表', + '随机选择字符组合', + '只保留词表中出现的完整词', + ], + answer: 1, + explanation: 'BPE 从字符级开始,迭代合并频率最高的相邻 token 对,直到达到目标词表大小。这样既能处理常见词(整体保留),又能将罕见词拆分为已知子词。', + }, + ], + }, + { + id: 'nlp-word2vec', + title: 'Word2Vec 训练', + summary: 'CBOW vs Skip-gram、负采样', + theory: `## Word2Vec + +Word2Vec 是 Google 在 2013 年提出的词向量训练方法,通过浅层神经网络学习词的分布式表示。 + +### 两种架构 + +| 架构 | 输入 | 输出 | 适用场景 | +|------|------|------|---------| +| CBOW | 上下文词 | 中心词 | 小型语料,高频词效果好 | +| Skip-gram | 中心词 | 上下文词 | 大型语料,低频词效果好 | + +### CBOW(Continuous Bag-of-Words) + +用上下文词的平均向量来预测中心词: + +$$P(w_t | w_{t-k}, ..., w_{t-1}, w_{t+1}, ..., w_{t+k})$$ + +### Skip-gram + +用中心词来预测上下文词: + +$$P(w_{t+j} | w_t), \\quad j \\in \\{-k, ..., -1, 1, ..., k\\}$$ + +### 负采样(Negative Sampling) + +原始 softmax 计算代价太高,负采样将多分类问题转化为二分类问题: + +$$L = -\\log \\sigma(v_{w_O}^T v_{w_I}) - \\sum_{i=1}^{k} \\log \\sigma(-v_{w_i}^T v_{w_I})$$ + +其中 $w_O$ 是正样本,$w_i$ 是从噪声分布中采样的负样本。 + +### 训练目标 + +最大化正样本的共现概率,最小化负样本的共现概率。最终得到的权重矩阵就是词向量。 + +### 超参数 + +- **窗口大小**: 通常 5-10 +- **负样本数**: 通常 5-20 +- **向量维度**: 通常 100-300 +- **学习率**: 通常 0.025,线性衰减<[PLHD83_never_used_51bce0c785ca2f68081bfa7d91973934]> + + +`, + exercise: { type: 'playground', viz: 'wordEmbedding' }, + code: { + cpp: `#include +#include +#include + +// Skip-gram 负采样训练步骤 +void skipgram_step( + const std::vector& center_vec, // 中心词向量 + const std::vector& context_vec, // 正样本上下文词向量 + const std::vector>& neg_vecs, // 负样本 + std::vector>& W_in, // 输入词向量矩阵 + std::vector>& W_out, // 输出词向量矩阵 + int center_idx, int context_idx, + const std::vector& neg_indices, + double lr) { + int d = center_vec.size(); + // 正样本梯度 + double score = 0; + for (int i = 0; i < d; i++) score += center_vec[i] * context_vec[i]; + double grad_pos = sigmoid(score) - 1.0; + + // 负样本梯度 + for (int n = 0; n < neg_vecs.size(); n++) { + double neg_score = 0; + for (int i = 0; i < d; i++) + neg_score += center_vec[i] * neg_vecs[n][i]; + double grad_neg = sigmoid(neg_score); + // 更新输入向量 + for (int i = 0; i < d; i++) + W_in[center_idx][i] -= lr * (grad_neg * neg_vecs[n][i]); + } + // 更新输入向量(正样本) + for (int i = 0; i < d; i++) + W_in[center_idx][i] -= lr * grad_pos * context_vec[i]; + // 更新输出向量 + for (int i = 0; i < d; i++) + W_out[context_idx][i] -= lr * grad_pos * center_vec[i]; +} + +double sigmoid(double x) { + return 1.0 / (1.0 + std::exp(-x)); +}`, + python: `import numpy as np + +def skipgram_negative_sampling(center_vec, context_vec, neg_vecs, lr=0.025): + """Skip-gram 负采样单步训练""" + # 正样本 + score = center_vec @ context_vec + grad_pos = sigmoid(score) - 1.0 + + # 负样本 + grad_center = np.zeros_like(center_vec) + for neg_vec in neg_vecs: + neg_score = center_vec @ neg_vec + grad_neg = sigmoid(neg_score) + grad_center += grad_neg * neg_vec + + # 更新中心词向量 + grad_center += grad_pos * context_vec + center_vec -= lr * grad_center + + # 更新上下文词向量 + context_vec -= lr * grad_pos * center_vec + + return center_vec, context_vec + +def sigmoid(x): + return 1.0 / (1.0 + np.exp(-x)) + +# CBOW 预测中心词 +def cbow_predict(context_vecs, W_out): + """用上下文词的平均向量预测中心词""" + avg_context = np.mean(context_vecs, axis=0) + scores = W_out @ avg_context + probs = softmax(scores) + return probs + +def softmax(x): + e_x = np.exp(x - np.max(x)) + return e_x / e_x.sum()` + }, + variablesSnapshot: { + architecture: 'Skip-gram', + negativeSamples: '5', + windowSize: '5', + learningRate: '0.025', + }, + pseudocode: `procedure SKIPGRAM_TRAIN(corpus, window_size, dim, neg_samples, epochs) + W_in <- random_matrix(vocab_size, dim) + W_out <- random_matrix(vocab_size, dim) + for epoch <- 1 to epochs do + for each (center_word, context_word) in corpus do + // 正样本 + v_c <- W_in[center_word] + u_o <- W_out[context_word] + // 负采样 + neg_words <- sample(neg_samples, from=noise_distribution) + // 梯度下降更新 + update W_in and W_out using negative sampling loss + end for + end for + return W_in // 输入矩阵作为词向量 + +procedure CBOW_TRAIN(corpus, window_size, dim, epochs) + W_in <- random_matrix(vocab_size, dim) + W_out <- random_matrix(vocab_size, dim) + for epoch <- 1 to epochs do + for each (context_words, center_word) in corpus do + h <- average(W_in[w] for w in context_words) + // 预测中心词分布 + update W_in and W_out to maximize P(center_word | context) + end for + end for + return W_in`, + bigO: { + time: 'Skip-gram 每个训练样本需要 O(d × k),其中 d 是向量维度,k 是负样本数。完整训练需要 O(N × d × k × epochs),N 是训练对数量。', + space: '存储两个词向量矩阵 W_in 和 W_out 需要 O(V × d),V 是词表大小。', + note: '负采样将原始 O(V) 的 softmax 计算降为 O(k),k 通常为 5-20,大大加速训练。', + }, + compare: [ + { method: 'CBOW', data: '上下文 → 中心词', strength: '训练更快,高频词效果好', tradeoff: '低频词效果不如 Skip-gram' }, + { method: 'Skip-gram', data: '中心词 → 上下文', strength: '低频词效果好,语义更准确', tradeoff: '训练较慢,需要更多负样本' }, + { method: 'Hierarchical Softmax', data: '树结构', strength: '无需负采样', tradeoff: '实现复杂,需要构建 Huffman 树' }, + ], + quiz: [ + { + q: 'Word2Vec 中负采样(Negative Sampling)的主要作用是什么?', + options: [ + '增加训练数据量', + '将多分类问题转化为多个二分类问题,大幅降低计算复杂度', + '防止过拟合', + '自动调整学习率', + ], + answer: 1, + explanation: '原始 softmax 需要计算词表中所有词的概率(O(V) 复杂度),负采样通过区分正样本和少量随机负样本,将复杂度降为 O(k),k 通常为 5-20。', + }, + ], + }, + { + id: 'nlp-glove', + title: 'GloVe 全局向量', + summary: '全局词共现、加权最小二乘', + theory: `## GloVe(Global Vectors) + +GloVe 是 Stanford 在 2014 年提出的词向量方法,结合了全局矩阵分解和局部上下文窗口的优点。 + +### 核心思想 + +利用词-词共现矩阵 $X$,其中 $X_{ij}$ 表示词 $i$ 和词 $j$ 在同一窗口中出现的次数。 + +### 共现概率的比值 + +$$F(w_i, w_j, \\tilde{w}_k) = \\frac{P_{ik}}{P_{jk}}$$ + +这个比值能区分词的相关性: +- 对于相关词,比值接近 1 +- 对于无关词,比值远离 1 + +### 损失函数 + +$$J = \\sum_{i,j=1}^{V} f(X_{ij}) (w_i^T \\tilde{w}_j + b_i + \\tilde{b}_j - \\log X_{ij})^2$$ + +### 加权函数 $f(x)$ + +$$f(x) = \\begin{cases} (x/x_{max})^\\alpha & \\text{if } x < x_{max} \\\\ 1 & \\text{otherwise} \\end{cases}$$ + +其中 $x_{max} = 100$, $\\alpha = 3/4$。 + +加权函数的作用: +- 给高频共现更高权重 +- 避免极少数极端共现对 dominating 训练 + +### GloVe vs Word2Vec + +| 特性 | GloVe | Word2Vec | +|------|-------|---------| +| 数据使用 | 全局共现矩阵 | 局部上下文窗口 | +| 训练方式 | 加权最小二乘 | 负采样/SGD | +| 语义类比 | 优秀 | 优秀 | +| 训练速度 | 通常更快 | 取决于实现 | +| 可解释性 | 共现统计更透明 | 神经网络黑箱 | + +### 最终词向量 + +训练完成后,最终词向量为: + +$$w_i^{final} = w_i + \\tilde{w}_i$$ + +将输入和输出向量相加,综合两者的信息。 `, exercise: { type: 'playground', viz: 'wordEmbedding' }, + code: { + cpp: `#include +#include +#include + +// 构建共现矩阵 +void build_cooccurrence( + const std::vector>& corpus, + int window_size, + std::vector>& X) { + for (const auto& sentence : corpus) { + for (int i = 0; i < sentence.size(); i++) { + int start = std::max(0, i - window_size); + int end = std::min((int)sentence.size(), i + window_size + 1); + for (int j = start; j < end; j++) { + if (i != j) { + X[sentence[i]][sentence[j]] += 1.0 / std::abs(i - j); + } + } + } + } +} + +// GloVe 加权函数 +double weighting_func(double x, double x_max = 100.0, double alpha = 0.75) { + if (x >= x_max) return 1.0; + return std::pow(x / x_max, alpha); +} + +// GloVe 单步梯度更新 +void glove_step( + std::vector& w_i, std::vector& w_j_tilde, + double& b_i, double& b_j_tilde, + double X_ij, double lr, double x_max = 100.0) { + int d = w_i.size(); + double diff = 0; + for (int k = 0; k < d; k++) diff += w_i[k] * w_j_tilde[k]; + diff += b_i + b_j_tilde - std::log(X_ij); + + double weight = weighting_func(X_ij, x_max); + double grad = weight * diff; + + for (int k = 0; k < d; k++) { + w_i[k] -= lr * grad * w_j_tilde[k]; + w_j_tilde[k] -= lr * grad * w_i[k]; + } + b_i -= lr * grad; + b_j_tilde -= lr * grad; +}`, + python: `import numpy as np +from collections import defaultdict + +def build_cooccurrence(corpus, window_size=10): + """构建词-词共现矩阵""" + cooccur = defaultdict(lambda: defaultdict(float)) + for sentence in corpus: + for i, center in enumerate(sentence): + start = max(0, i - window_size) + end = min(len(sentence), i + window_size + 1) + for j in range(start, end): + if i != j: + # 距离衰减加权 + cooccur[center][sentence[j]] += 1.0 / abs(i - j) + return cooccur + +def weighting_func(x, x_max=100.0, alpha=0.75): + """GloVe 加权函数""" + return (x / x_max) ** alpha if x < x_max else 1.0 + +def glove_loss(W, W_tilde, b, b_tilde, cooccur, x_max=100.0): + """计算 GloVe 总损失""" + loss = 0.0 + for i in cooccur: + for j, X_ij in cooccur[i].items(): + diff = W[i] @ W_tilde[j] + b[i] + b_tilde[j] - np.log(X_ij) + weight = weighting_func(X_ij, x_max) + loss += weight * diff ** 2 + return loss + +# 训练示例 +def train_glove(cooccur, vocab_size, dim=100, epochs=50, lr=0.05): + W = np.random.randn(vocab_size, dim) * 0.01 + W_tilde = np.random.randn(vocab_size, dim) * 0.01 + b = np.zeros(vocab_size) + b_tilde = np.zeros(vocab_size) + + for epoch in range(epochs): + total_loss = 0 + for i in cooccur: + for j, X_ij in cooccur[i].items(): + diff = W[i] @ W_tilde[j] + b[i] + b_tilde[j] - np.log(X_ij) + weight = weighting_func(X_ij) + grad = weight * diff + + W[i] -= lr * grad * W_tilde[j] + W_tilde[j] -= lr * grad * W[i] + b[i] -= lr * grad + b_tilde[j] -= lr * grad + total_loss += weight * diff ** 2 + + if epoch % 10 == 0: + print(f"Epoch {epoch}, Loss: {total_loss:.4f}") + + # 最终向量 = W + W_tilde + return W + W_tilde` + }, + variablesSnapshot: { + xMax: '100', + alpha: '0.75', + dimension: '100', + windowSize: '10', + }, + pseudocode: `procedure GLOVE_TRAIN(corpus, dim, window_size, x_max, alpha, epochs) + // 1. 构建共现矩阵 + X <- build_cooccurrence(corpus, window_size) + // 2. 初始化 + W, W_tilde <- random(V, dim) + b, b_tilde <- zeros(V) + // 3. 训练 + for epoch <- 1 to epochs do + for each (i, j, X_ij) in X where X_ij > 0 do + diff <- W_i^T * W_tilde_j + b_i + b_tilde_j - log(X_ij) + weight <- (X_ij / x_max)^alpha if X_ij < x_max else 1 + // 梯度更新 + W_i <- W_i - lr * weight * diff * W_tilde_j + W_tilde_j <- W_tilde_j - lr * weight * diff * W_i + b_i <- b_i - lr * weight * diff + b_tilde_j <- b_tilde_j - lr * weight * diff + end for + end for + // 4. 最终向量 + return W + W_tilde`, + bigO: { + time: '构建共现矩阵需要 O(C × W),C 是语料库大小,W 是窗口大小。训练每轮需要 O(N_nonzero),N_nonzero 是共现矩阵的非零元素数。', + space: '存储共现矩阵需要 O(V^2)(稀疏存储 O(N_nonzero))。词向量矩阵需要 O(V × d)。', + note: 'GloVe 利用全局统计信息,通常比 Word2Vec 需要更少的训练轮次。稀疏存储共现矩阵可以大幅节省内存。', + }, + compare: [ + { method: 'GloVe', data: '全局共现矩阵', strength: '融合全局统计,训练稳定', tradeoff: '需要存储共现矩阵,内存需求大' }, + { method: 'Word2Vec', data: '局部上下文窗口', strength: '在线训练,无需存储矩阵', tradeoff: '忽略全局统计信息' }, + { method: 'SVD/LSA', data: '词-文档矩阵', strength: '理论基础扎实', tradeoff: '计算复杂度高,语义捕获不如神经网络方法' }, + ], + quiz: [ + { + q: 'GloVe 损失函数中的加权函数 f(x) 有什么作用?', + options: [ + '加速梯度下降收敛', + '给高频共现更高权重,避免极少数极端共现 dominating 训练', + '自动调整学习率', + '将词向量归一化到单位长度', + ], + answer: 1, + explanation: '加权函数 f(x) = (x/x_max)^α 确保高频共现获得适当权重,同时限制极少数非常高频共现(如功能词)对训练的过度影响。α=3/4 是经验最优值。', + }, + ], }, { id: 'nlp-attention', title: '注意力机制', - summary: 'Self-Attention、Multi-Head Attention', + summary: 'Self-Attention、缩放点积注意力', theory: `## 注意力机制 -注意力让模型关注输入中最相关的部分。 +注意力机制让模型在处理每个位置时,能够动态关注输入序列中最相关的部分,是 Transformer 的核心组件。 -### Self-Attention +### 缩放点积注意力(Scaled Dot-Product Attention) $$Attention(Q, K, V) = softmax(\\frac{QK^T}{\\sqrt{d_k}}) V$$ -其中 Q=查询, K=键, V=值 +### 三个输入 + +| 符号 | 名称 | 含义 | +|------|------|------| +| Q | Query(查询) | 当前位置的表示,用于"提问" | +| K | Key(键) | 所有位置的表示,用于"被查询" | +| V | Value(值) | 所有位置的表示,用于"被提取" | + +### 计算步骤 + +1. **计算相似度**: $Q$ 和 $K$ 做点积,得到注意力分数 +2. **缩放**: 除以 $\\sqrt{d_k}$,防止点积过大导致 softmax 饱和 +3. **归一化**: softmax 将分数转为概率分布 +4. **加权求和**: 用注意力权重对 $V$ 加权求和 + +### 为什么要除以 $\\sqrt{d_k}$? + +当 $d_k$ 较大时,点积的方差也会变大,导致 softmax 函数进入饱和区(梯度接近 0)。除以 $\\sqrt{d_k}$ 可以将方差归一化到 1,保持梯度稳定。 + +### Self-Attention 特殊情况 + +当 $Q = K = V = X$(输入序列)时,称为 **Self-Attention**(自注意力),每个位置可以直接关注序列中的任意其他位置。 + +### 与 RNN/CNN 的对比 + +| 特性 | Self-Attention | RNN | CNN | +|------|---------------|-----|-----| +| 长距离依赖 | O(1) 路径长度 | O(n) 路径长度 | 需要多层堆叠 | +| 并行计算 | 完全并行 | 必须串行 | 可并行 | +| 计算复杂度 | O(n^2 d) | O(n d^2) | O(k n d) | `, exercise: { type: 'playground', viz: 'attention' }, code: { @@ -50,31 +727,60 @@ Matrix self_attention(const Matrix& X, const Matrix& Wq, const Matrix& Wk, const Matrix& Wv) { + // 1. 线性变换得到 Q, K, V Matrix Q = matmul(X, Wq); Matrix K = matmul(X, Wk); Matrix V = matmul(X, Wv); + // 2. 计算注意力分数 QK^T Matrix scores = matmul(Q, transpose(K)); + + // 3. 缩放 double scale = std::sqrt((double)K[0].size()); for (auto& row : scores) { for (double& value : row) value /= scale; } + // 4. Softmax 归一化 Matrix A = row_softmax(scores); + + // 5. 加权求和 return matmul(A, V); }`, python: `import numpy as np -def self_attention(X, Wq, Wk, Wv): - Q = X @ Wq - K = X @ Wk - V = X @ Wv +def softmax(x, axis=-1): + """数值稳定的 softmax""" + e_x = np.exp(x - np.max(x, axis=axis, keepdims=True)) + return e_x / e_x.sum(axis=axis, keepdims=True) + +def scaled_dot_product_attention(Q, K, V, mask=None): + """ + 缩放点积注意力 + Q: (n, d_k), K: (m, d_k), V: (m, d_v) + 返回: (n, d_v) + """ + d_k = Q.shape[-1] + # 1. 计算注意力分数 + scores = Q @ K.T / np.sqrt(d_k) - scores = Q @ K.T / np.sqrt(K.shape[-1]) + # 2. 应用掩码(可选) + if mask is not None: + scores = np.where(mask == 0, -1e9, scores) + + # 3. Softmax 归一化 weights = softmax(scores, axis=-1) + + # 4. 加权求和 output = weights @ V + return output, weights - return output, weights` +def self_attention(X, Wq, Wk, Wv): + """Self-Attention: Q=K=V=X""" + Q = X @ Wq + K = X @ Wk + V = X @ Wv + return scaled_dot_product_attention(Q, K, V)` }, variablesSnapshot: { phase: 'input', @@ -83,11 +789,15 @@ def self_attention(X, Wq, Wk, Wv): matrix: 'QK^T', }, pseudocode: `procedure SELF_ATTENTION(X, Wq, Wk, Wv) + // 线性变换 Q <- X * Wq K <- X * Wk V <- X * Wv + // 计算注意力分数 scores <- Q * transpose(K) / sqrt(dk) + // Softmax 归一化 weights <- row_softmax(scores) + // 加权求和 output <- weights * V return output, weights`, bigO: { @@ -96,43 +806,1003 @@ def self_attention(X, Wq, Wk, Wv): note: '长序列场景中 n^2 注意力矩阵是主要瓶颈,多头注意力会在多个子空间重复该过程。', }, compare: [ - { method: 'Self-Attention', data: '整段序列', strength: '任意位置可直接建模依赖', tradeoff: '注意力矩阵随序列长度平方增长' }, - { method: 'RNN', data: '逐步状态', strength: '天然适合时间序列递推', tradeoff: '长距离依赖和并行计算较弱' }, - { method: 'CNN', data: '局部窗口', strength: '局部模式提取高效', tradeoff: '远距离关系需要堆叠多层' }, + { method: 'Self-Attention', data: '整段序列', strength: '任意位置可直接建模依赖,O(1) 路径长度', tradeoff: '注意力矩阵随序列长度平方增长' }, + { method: 'RNN', data: '逐步状态', strength: '天然适合时间序列递推,参数共享', tradeoff: '长距离依赖路径长,无法并行' }, + { method: 'CNN', data: '局部窗口', strength: '局部模式提取高效,可并行', tradeoff: '远距离关系需要堆叠多层' }, ], quiz: [ { q: 'Self-Attention 中除以 sqrt(dk) 的主要目的是什么?', options: [ - '控制点积尺度,避免 softmax 过早饱和', + '控制点积尺度,避免 softmax 过早饱和导致梯度消失', '把所有 token 的权重强制设为相同', '删除 V 矩阵中的噪声', '让矩阵乘法变成线性复杂度', ], answer: 0, - explanation: '当维度较大时,Q 和 K 的点积幅度会变大;缩放后 softmax 梯度更稳定。', + explanation: '当维度 dk 较大时,Q 和 K 的点积幅度会变大,导致 softmax 函数进入饱和区(梯度接近 0)。缩放后 softmax 梯度更稳定,训练更容易收敛。', + }, + ], + }, + { + id: 'nlp-multihead-attention', + title: '多头注意力', + summary: '并行注意力头、拼接与线性变换', + theory: `## 多头注意力(Multi-Head Attention) + +多头注意力是 Transformer 的关键创新,通过并行运行多个注意力头,让模型同时关注不同子空间的信息。 + +### 核心思想 + +$$MultiHead(Q, K, V) = Concat(head_1, ..., head_h) W^O$$ + +其中每个 head 为: + +$$head_i = Attention(Q W_i^Q, K W_i^K, V W_i^V)$$ + +### 为什么需要多头? + +| 优势 | 说明 | +|------|------| +| 多样性 | 不同头关注不同的语义关系(语法、语义、位置等) | +| 表达能力 | 多个子空间的组合比单头更强大 | +| 训练稳定 | 类似集成学习,降低单一注意力模式的风险 | + +### 计算流程 + +1. 将 Q, K, V 分别通过线性变换投影到 h 个子空间 +2. 每个子空间独立计算缩放点积注意力 +3. 将 h 个头的输出拼接(Concat) +4. 再通过一个线性变换 $W^O$ 融合信息 + +### 维度设计 + +- 输入维度: $d_{model}$(如 512) +- 头数: $h$(如 8) +- 每头维度: $d_k = d_v = d_{model} / h$(如 64) + +总计算量与单头($d_k = d_{model}$)相近,但表达能力更强。 + +### 实际观察 + +在训练好的 Transformer 中,不同头确实学到了不同的模式: +- 有的头关注语法关系(主谓一致) +- 有的头关注指代关系(代词-先行词) +- 有的头关注局部相邻词 +`, + exercise: { type: 'playground', viz: 'attention' }, + code: { + cpp: `#include +#include +using Matrix = std::vector>; +using Tensor3D = std::vector; // [heads, seq, dim_per_head] + +// 张量 reshape: [seq, model_dim] -> [heads, seq, dim_per_head] +Tensor3D split_heads(const Matrix& X, int heads) { + int seq_len = X.size(); + int model_dim = X[0].size(); + int dim_per_head = model_dim / heads; + Tensor3D result(heads, Matrix(seq_len, std::vector(dim_per_head))); + for (int h = 0; h < heads; h++) { + for (int s = 0; s < seq_len; s++) { + for (int d = 0; d < dim_per_head; d++) { + result[h][s][d] = X[s][h * dim_per_head + d]; + } + } + } + return result; +} + +// 张量 reshape 回: [heads, seq, dim_per_head] -> [seq, model_dim] +Matrix merge_heads(const Tensor3D& X) { + int heads = X.size(); + int seq_len = X[0].size(); + int dim_per_head = X[0][0].size(); + int model_dim = heads * dim_per_head; + Matrix result(seq_len, std::vector(model_dim)); + for (int s = 0; s < seq_len; s++) { + for (int h = 0; h < heads; h++) { + for (int d = 0; d < dim_per_head; d++) { + result[s][h * dim_per_head + d] = X[h][s][d]; + } + } + } + return result; +} + +Matrix multihead_attention(const Matrix& Q, const Matrix& K, const Matrix& V, + const Matrix& Wq, const Matrix& Wk, const Matrix& Wv, + const Matrix& Wo, int heads) { + // 线性变换 + Matrix Q_proj = matmul(Q, Wq); + Matrix K_proj = matmul(K, Wk); + Matrix V_proj = matmul(V, Wv); + + // 拆分多头 + Tensor3D Q_heads = split_heads(Q_proj, heads); + Tensor3D K_heads = split_heads(K_proj, heads); + Tensor3D V_heads = split_heads(V_proj, heads); + + // 每个头独立计算注意力 + Tensor3D head_outputs(heads); + for (int h = 0; h < heads; h++) { + head_outputs[h] = self_attention(Q_heads[h], K_heads[h], V_heads[h]); + } + + // 拼接并线性变换 + Matrix merged = merge_heads(head_outputs); + return matmul(merged, Wo); +}`, + python: `import numpy as np + +def split_heads(X, num_heads): + """[seq_len, d_model] -> [num_heads, seq_len, d_k]""" + seq_len, d_model = X.shape + d_k = d_model // num_heads + return X.reshape(seq_len, num_heads, d_k).transpose(1, 0, 2) + +def merge_heads(X): + """[num_heads, seq_len, d_k] -> [seq_len, d_model]""" + num_heads, seq_len, d_k = X.shape + return X.transpose(1, 0, 2).reshape(seq_len, num_heads * d_k) + +def multihead_attention(Q, K, V, Wq, Wk, Wv, Wo, num_heads, mask=None): + """ + 多头注意力 + Q, K, V: [seq_len, d_model] + Wq, Wk, Wv: [d_model, d_model] + Wo: [d_model, d_model] + """ + d_model = Q.shape[-1] + d_k = d_model // num_heads + + # 线性变换 + Q_proj = Q @ Wq # [seq, d_model] + K_proj = K @ Wk + V_proj = V @ Wv + + # 拆分多头 + Q_heads = split_heads(Q_proj, num_heads) # [heads, seq, d_k] + K_heads = split_heads(K_proj, num_heads) + V_heads = split_heads(V_proj, num_heads) + + # 每个头计算缩放点积注意力 + head_outputs = [] + for h in range(num_heads): + scores = Q_heads[h] @ K_heads[h].T / np.sqrt(d_k) + if mask is not None: + scores = np.where(mask == 0, -1e9, scores) + weights = softmax(scores, axis=-1) + head_out = weights @ V_heads[h] + head_outputs.append(head_out) + + # 拼接 + 线性变换 + concat = merge_heads(np.stack(head_outputs)) + output = concat @ Wo + return output + +def softmax(x, axis=-1): + e_x = np.exp(x - np.max(x, axis=axis, keepdims=True)) + return e_x / e_x.sum(axis=axis, keepdims=True)` + }, + variablesSnapshot: { + numHeads: '8', + dModel: '512', + dk: '64', + seqLen: '10', + }, + pseudocode: `procedure MULTIHEAD_ATTENTION(Q, K, V, Wq, Wk, Wv, Wo, h) + // 线性变换并拆分多头 + Q_proj <- Q * Wq; split into h heads + K_proj <- K * Wk; split into h heads + V_proj <- V * Wv; split into h heads + // 每个头独立计算 + for i <- 1 to h do + head_i <- attention(Q_i, K_i, V_i) + end for + // 拼接并线性变换 + concat <- concatenate(head_1, ..., head_h) + output <- concat * Wo + return output`, + bigO: { + time: '每个头需要 O(n^2 × d_k),h 个头总计 O(n^2 × d_model)。线性变换需要 O(n × d_model^2)。总复杂度 O(n^2 × d_model + n × d_model^2)。', + space: 'h 个注意力权重矩阵需要 O(h × n^2),中间张量需要 O(n × d_model)。', + note: '多头注意力的总计算量与单头(d_k = d_model)相近,但通过并行多个子空间获得了更强的表达能力。', + }, + compare: [ + { method: 'Multi-Head Attention', data: '并行 h 个子空间', strength: '同时关注多种语义关系,表达能力强', tradeoff: '需要存储多个注意力矩阵' }, + { method: 'Single-Head Attention', data: '单一子空间', strength: '计算简单,内存占用小', tradeoff: '表达能力有限,只能关注一种模式' }, + { method: 'CNN Multi-Channel', data: '多通道卷积', strength: '局部模式提取', tradeoff: '无法直接建模长距离依赖' }, + ], + quiz: [ + { + q: '多头注意力中,将 d_model 拆分为 h 个 d_k = d_model/h 维子空间的主要目的是什么?', + options: [ + '减少计算量,提高训练速度', + '让不同头学习不同的语义关系(如语法、语义、位置),增强模型表达能力', + '使模型更容易过拟合', + '减少参数数量,防止过拟合', + ], + answer: 1, + explanation: '多头注意力的核心思想是让模型在不同子空间中学习不同的注意力模式。总计算量与单头相近,但表达能力显著增强。实践中观察到不同头确实学到了语法关系、指代关系等不同模式。', }, ], - codeStepHighlightLines: { - cpp: [9, 10, 11, 13, 16, 20, 21], - python: [4, 5, 6, 8, 9, 10, 12], + }, + { + id: 'nlp-masked-attention', + title: '掩码注意力', + summary: '因果掩码、填充掩码、防止未来信息泄露', + theory: `## 掩码注意力(Masked Attention) + +掩码注意力是 Transformer 解码器的核心组件,通过掩码矩阵阻止模型"看到"未来信息。 + +### 两种掩码 + +| 掩码类型 | 用途 | 实现 | +|---------|------|------| +| 因果掩码(Causal Mask) | 解码器自注意力 | 上三角设为 -∞ | +| 填充掩码(Padding Mask) | 忽略 padding token | padding 位置设为 -∞ | + +### 因果掩码(Look-ahead Mask) + +在自回归生成中,第 $t$ 个位置只能关注 $1, ..., t$ 的位置,不能看到未来: + +$$\\text{Mask}_{ij} = \\begin{cases} 0 & \\text{if } i \\geq j \\\\ -\\infty & \\text{if } i < j \\end{cases}$$ + +### 填充掩码(Padding Mask) + +批量处理时,不同序列长度不同,需要填充到相同长度。填充位置不应参与注意力计算: + +$$\\text{PaddingMask}_{ij} = \\begin{cases} 1 & \\text{if token } j \\text{ is not padding} \\\\ 0 & \\text{if token } j \\text{ is padding} \\end{cases}$$ + +### 实现方式 + +在 softmax 之前,将掩码位置的分数设为 $-\\infty$: + +$$\\text{scores} = \\frac{QK^T}{\\sqrt{d_k}} + \\text{mask}$$ + +softmax 后,$e^{-\\infty} = 0$,这些位置的注意力权重为 0。 + +### 应用场景 + +- **Transformer 解码器**: 因果掩码 + 填充掩码 +- **Transformer 编码器**: 仅填充掩码 +- **GPT 系列**: 因果掩码(自回归) +- **BERT**: 填充掩码(双向注意力) +`, + exercise: { type: 'playground', viz: 'attention' }, + code: { + cpp: `#include +#include +#include + +using Matrix = std::vector>; + +// 生成因果掩码矩阵 +Matrix generate_causal_mask(int seq_len) { + Matrix mask(seq_len, std::vector(seq_len, 0.0)); + for (int i = 0; i < seq_len; i++) { + for (int j = i + 1; j < seq_len; j++) { + mask[i][j] = -std::numeric_limits::infinity(); + } + } + return mask; +} + +// 生成填充掩码 +Matrix generate_padding_mask(const std::vector& seq, int pad_id = 0) { + int seq_len = seq.size(); + Matrix mask(seq_len, std::vector(seq_len, 0.0)); + for (int i = 0; i < seq_len; i++) { + for (int j = 0; j < seq_len; j++) { + if (seq[j] == pad_id) { + mask[i][j] = -std::numeric_limits::infinity(); + } + } + } + return mask; +} + +// 带掩码的注意力计算 +Matrix masked_attention(const Matrix& Q, const Matrix& K, const Matrix& V, + const Matrix& mask) { + int d_k = K[0].size(); + // 计算分数 + Matrix scores(Q.size(), std::vector(K.size())); + for (int i = 0; i < Q.size(); i++) { + for (int j = 0; j < K.size(); j++) { + for (int d = 0; d < d_k; d++) { + scores[i][j] += Q[i][d] * K[j][d]; + } + scores[i][j] /= std::sqrt((double)d_k); + // 应用掩码 + scores[i][j] += mask[i][j]; + } + } + // Softmax + 加权求和 + Matrix weights = row_softmax(scores); + return matmul(weights, V); +}`, + python: `import numpy as np + +def generate_causal_mask(seq_len): + """生成因果掩码:上三角为 -inf""" + mask = np.triu(np.full((seq_len, seq_len), -np.inf), k=1) + return mask # 下三角为 0,上三角为 -inf + +def generate_padding_mask(seq, pad_id=0): + """生成填充掩码:padding 位置为 -inf""" + # [seq_len] -> [1, 1, seq_len] 便于广播 + mask = (seq == pad_id).astype(float) * -1e9 + return mask[np.newaxis, np.newaxis, :] # [1, 1, seq_len] + +def masked_attention(Q, K, V, causal_mask=None, padding_mask=None): + """ + 带掩码的缩放点积注意力 + Q: [n, d_k], K: [m, d_k], V: [m, d_v] + """ + d_k = Q.shape[-1] + scores = Q @ K.T / np.sqrt(d_k) # [n, m] + + # 应用因果掩码 + if causal_mask is not None: + scores += causal_mask # 广播: [n, m] + + # 应用填充掩码 + if padding_mask is not None: + scores += padding_mask # 广播: [1, 1, m] + + # Softmax(-inf 位置权重为 0) + weights = softmax(scores, axis=-1) + output = weights @ V + return output, weights + +def softmax(x, axis=-1): + e_x = np.exp(x - np.max(x, axis=axis, keepdims=True)) + return e_x / e_x.sum(axis=axis, keepdims=True) + +# 示例:解码器自注意力 +def decoder_self_attention(X, Wq, Wk, Wv, pad_token_id=0): + seq_len = X.shape[0] + Q = X @ Wq + K = X @ Wk + V = X @ Wv + + causal_mask = generate_causal_mask(seq_len) + return masked_attention(Q, K, V, causal_mask=causal_mask)` + }, + variablesSnapshot: { + maskType: 'causal', + seqLen: '6', + maskedPositions: 'upper triangle', + }, + pseudocode: `procedure MASKED_ATTENTION(Q, K, V, mask) + scores <- Q * transpose(K) / sqrt(dk) + scores <- scores + mask // -inf 位置 softmax 后为 0 + weights <- softmax(scores) + output <- weights * V + return output, weights + +procedure CAUSAL_MASK(seq_len) + mask <- zeros(seq_len, seq_len) + for i <- 1 to seq_len do + for j <- i+1 to seq_len do + mask[i][j] <- -infinity + end for + end for + return mask`, + bigO: { + time: '与普通注意力相同 O(n^2 d),掩码操作仅增加 O(n^2) 的矩阵加法。', + space: '需要额外存储掩码矩阵 O(n^2),或通过广播机制避免显式存储。', + note: '因果掩码通常是固定的上三角矩阵,可以预先计算并缓存。填充掩码因输入而异,但只需存储为向量 O(n)。', + }, + compare: [ + { method: 'Causal Masked Attention', data: '下三角有效', strength: '防止未来信息泄露,适合自回归生成', tradeoff: '无法利用双向上下文' }, + { method: 'Full Attention (BERT)', data: '全部有效', strength: '双向上下文,理解能力强', tradeoff: '不能用于生成任务' }, + { method: 'Local Attention', data: '局部窗口', strength: 'O(n × w) 复杂度,适合长序列', tradeoff: '无法捕获长距离依赖' }, + ], + quiz: [ + { + q: '在 Transformer 解码器中,为什么要对自注意力使用因果掩码(Causal Mask)?', + options: [ + '减少计算量,提高训练速度', + '防止模型在生成第 t 个词时看到 t 之后的未来信息,确保自回归特性', + '增加模型的非线性能力', + '防止过拟合,起到正则化作用', + ], + answer: 1, + explanation: '因果掩码将注意力分数矩阵的上三角设为 -inf,softmax 后这些位置权重为 0。这确保第 t 个位置只能关注 1 到 t 的位置,不会泄露未来信息,符合自回归生成的要求。', + }, + ], + }, + { + id: 'nlp-positional-encoding', + title: '位置编码', + summary: '正弦位置编码、学习式位置编码、旋转位置编码', + theory: `## 位置编码(Positional Encoding) + +Transformer 的自注意力本身是**位置无关**的(permutation invariant),需要显式注入位置信息。 + +### 为什么需要位置编码? + +自注意力计算 $QK^T$ 时,打乱输入顺序不会改变结果。但语言是有顺序的,"猫吃老鼠"和"老鼠吃猫"含义完全不同。 + +### 三种主要方法 + +| 方法 | 代表模型 | 特点 | +|------|---------|------| +| 正弦编码 | 原始 Transformer | 固定函数,无需训练,可外推 | +| 学习式编码 | BERT | 可学习参数,效果好但无法外推 | +| 旋转编码(RoPE) | LLaMA, GPT-NeoX | 相对位置信息,外推性好 | + +### 正弦位置编码 + +$$PE_{(pos, 2i)} = \\sin\\left(\\frac{pos}{10000^{2i/d_{model}}}\\right)$$ + +$$PE_{(pos, 2i+1)} = \\cos\\left(\\frac{pos}{10000^{2i/d_{model}}}\\right)$$ + +### 为什么是正弦/余弦? + +正弦编码有一个神奇的性质:对于任意固定偏移 $k$,$PE_{pos+k}$ 可以表示为 $PE_{pos}$ 的线性变换。这意味着模型可以通过注意力机制隐式学习相对位置关系。 + +### 学习式位置编码 + +直接为每个位置学习一个向量: + +$$PE_{pos} = \\text{Embedding}(pos)$$ + +优点是灵活,缺点是无法外推到训练时未见过的更长序列。 + +### 旋转位置编码(RoPE) + +RoPE 将位置信息编码为旋转矩阵: + +$$f(q, pos) = q \\cdot e^{i pos \\theta}$$ + +天然编码相对位置:$f(q, pos_1) \\cdot f(k, pos_2) = f(q, k, pos_1 - pos_2)$ + +### 注入方式 + +通常将位置编码**加到**词嵌入上: + +$$X = \\text{Embedding}(tokens) + PE$$ +`, + exercise: { type: 'playground', viz: 'transformer' }, + code: { + cpp: `#include +#include +using Matrix = std::vector>; + +// 正弦位置编码 +Matrix sinusoidal_positional_encoding(int seq_len, int d_model) { + Matrix PE(seq_len, std::vector(d_model)); + for (int pos = 0; pos < seq_len; pos++) { + for (int i = 0; i < d_model; i += 2) { + double div_term = std::pow(10000.0, (2.0 * i / 2.0) / d_model); + PE[pos][i] = std::sin(pos / div_term); + if (i + 1 < d_model) { + PE[pos][i + 1] = std::cos(pos / div_term); + } + } + } + return PE; +} + +// 注入位置编码 +Matrix add_positional_encoding(const Matrix& embeddings, const Matrix& PE) { + Matrix result = embeddings; + for (int i = 0; i < embeddings.size(); i++) { + for (int j = 0; j < embeddings[0].size(); j++) { + result[i][j] += PE[i][j]; + } + } + return result; +}`, + python: `import numpy as np + +def sinusoidal_positional_encoding(seq_len, d_model): + """正弦位置编码""" + PE = np.zeros((seq_len, d_model)) + position = np.arange(seq_len)[:, np.newaxis] # [seq, 1] + div_term = np.exp( + np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model) + ) # [d_model/2] + PE[:, 0::2] = np.sin(position * div_term) # 偶数维 + PE[:, 1::2] = np.cos(position * div_term) # 奇数维 + return PE + +def add_positional_encoding(embeddings, PE): + """将位置编码加到词嵌入上""" + return embeddings + PE[:embeddings.shape[0]] + +# 旋转位置编码(RoPE) +def rotary_position_embedding(q, k, seq_len): + """RoPE: 将位置信息编码为旋转""" + d = q.shape[-1] + positions = np.arange(seq_len)[:, np.newaxis] # [seq, 1] + freqs = 1.0 / (10000 ** (np.arange(0, d, 2) / d)) # [d/2] + angles = positions * freqs # [seq, d/2] + + # 构造旋转矩阵 + cos = np.cos(angles) # [seq, d/2] + sin = np.sin(angles) # [seq, d/2] + + # 应用旋转 + q1, q2 = q[..., 0::2], q[..., 1::2] + q_rotated = np.stack([ + q1 * cos - q2 * sin, + q1 * sin + q2 * cos + ], axis=-1).reshape(q.shape) + + k1, k2 = k[..., 0::2], k[..., 1::2] + k_rotated = np.stack([ + k1 * cos - k2 * sin, + k1 * sin + k2 * cos + ], axis=-1).reshape(k.shape) + + return q_rotated, k_rotated` }, + variablesSnapshot: { + method: 'sinusoidal', + seqLen: '50', + dModel: '512', + }, + pseudocode: `procedure SINUSOIDAL_PE(seq_len, d_model) + PE <- zeros(seq_len, d_model) + for pos <- 0 to seq_len-1 do + for i <- 0 to d_model/2-1 do + div_term <- 10000^(2i/d_model) + PE[pos][2i] <- sin(pos / div_term) + PE[pos][2i+1] <- cos(pos / div_term) + end for + end for + return PE + +procedure ADD_POSITIONAL_ENCODING(embeddings, PE) + return embeddings + PE // 广播加法`, + bigO: { + time: '生成正弦位置编码需要 O(seq_len × d_model)。注入操作是元素级加法 O(seq_len × d_model)。', + space: '存储位置编码矩阵需要 O(seq_len × d_model),可预先计算并缓存。', + note: '正弦位置编码是固定函数,无需训练参数。学习式位置编码需要 O(seq_len × d_model) 的可学习参数。RoPE 无需存储额外矩阵,通过在线计算实现。', + }, + compare: [ + { method: '正弦编码', data: '固定 sin/cos 函数', strength: '无需训练,可外推到更长序列', tradeoff: '表达能力受限,无法学习位置偏好' }, + { method: '学习式编码', data: '可学习 Embedding', strength: '灵活,能学习任务相关的位置模式', tradeoff: '无法外推到训练时未见过的长度' }, + { method: '旋转编码 (RoPE)', data: '旋转矩阵', strength: '天然编码相对位置,外推性好', tradeoff: '实现稍复杂,需要修改注意力计算' }, + ], + quiz: [ + { + q: '为什么 Transformer 需要位置编码(Positional Encoding)?', + options: [ + '为了减少计算量,提高训练速度', + '因为自注意力是位置无关的(打乱输入顺序不改变结果),而语言是有顺序的', + '为了增加模型参数数量,提高表达能力', + '为了防止梯度消失,起到正则化作用', + ], + answer: 1, + explanation: '自注意力计算 QK^T 时,打乱输入顺序不会改变结果(排列不变性)。但"猫吃老鼠"和"老鼠吃猫"含义完全不同,因此必须通过位置编码显式注入顺序信息。', + }, + ], }, { id: 'nlp-transformer', title: 'Transformer 架构', - summary: '编码器-解码器、位置编码、多头注意力', + summary: '编码器-解码器、多头注意力、前馈网络、层归一化', theory: `## Transformer -基于注意力机制的序列模型,抛弃了 RNN 的循环结构。 +Transformer 是 Google 在 2017 年提出的序列模型,完全基于注意力机制,抛弃了 RNN 的循环结构,开启了 NLP 的新时代。 + +### 整体架构 + +整体数据流: + +输入 → [嵌入 + 位置编码] → [编码器×N] → [解码器×N] → 输出概率 + +### 编码器(Encoder) + +每层包含两个子层: +1. **多头自注意力** + 残差连接 + 层归一化 +2. **前馈网络** + 残差连接 + 层归一化 + +$$\\text{EncoderLayer}(x) = \\text{LayerNorm}(x + \\text{FFN}(\\text{LayerNorm}(x + \\text{MHSA}(x))))$$ + +### 解码器(Decoder) + +每层包含三个子层: +1. **掩码多头自注意力** + 残差 + 层归一化 +2. **编码器-解码器注意力** + 残差 + 层归一化 +3. **前馈网络** + 残差 + 层归一化 + +### 前馈网络(FFN) + +$$FFN(x) = \\max(0, x W_1 + b_1) W_2 + b_2$$ + +通常 $d_{ff} = 4 \\times d_{model}$(如 2048),增加非线性表达能力。 + +### 关键超参数(原始 Transformer) + +| 超参数 | 值 | +|--------|-----| +| $d_{model}$ | 512 | +| $d_{ff}$ | 2048 | +| 头数 $h$ | 8 | +| $d_k = d_v$ | 64 | +| 编码器/解码器层数 | 6 | +| Dropout | 0.1 | -### 核心组件 +### 训练目标 -- Multi-Head Self-Attention -- Position-wise Feed-Forward -- Positional Encoding -- Layer Normalization +$$L = -\\sum_{t=1}^{T} \\log P(w_t | w_1, ..., w_{t-1})$$ + +### 优缺点 + +- **优点**: 完全并行化,长距离依赖 O(1) 路径,可解释性强 +- **缺点**: 自注意力 $O(n^2)$ 复杂度,长序列计算昂贵 `, exercise: { type: 'playground', viz: 'transformer' }, + code: { + cpp: `#include +#include +#include +using Matrix = std::vector>; + +// 层归一化 +Matrix layer_norm(const Matrix& X, const std::vector& gamma, + const std::vector& beta, double eps = 1e-6) { + int seq_len = X.size(); + int d = X[0].size(); + Matrix result(seq_len, std::vector(d)); + for (int i = 0; i < seq_len; i++) { + double mean = 0, var = 0; + for (int j = 0; j < d; j++) mean += X[i][j]; + mean /= d; + for (int j = 0; j < d; j++) var += (X[i][j] - mean) * (X[i][j] - mean); + var /= d; + for (int j = 0; j < d; j++) { + result[i][j] = gamma[j] * (X[i][j] - mean) / std::sqrt(var + eps) + beta[j]; + } + } + return result; +} + +// 前馈网络(FFN) +Matrix positionwise_ffn(const Matrix& X, const Matrix& W1, const Matrix& b1, + const Matrix& W2, const Matrix& b2) { + // 第一层 + ReLU + Matrix hidden = matmul(X, W1); + for (auto& row : hidden) + for (auto& val : row) + val = std::max(0.0, val + b1[0]); // 简化:b1 广播 + // 第二层 + return matmul(hidden, W2); +} + +// 编码器层 +Matrix encoder_layer(const Matrix& X, + const Matrix& Wq, const Matrix& Wk, const Matrix& Wv, const Matrix& Wo, + const Matrix& W1, const Matrix& W2, + const std::vector& gamma, const std::vector& beta) { + // 1. 多头自注意力 + 残差 + 层归一化 + Matrix attn_out = multihead_attention(X, X, X, Wq, Wk, Wv, Wo, 8); + Matrix norm1 = layer_norm(add(X, attn_out), gamma, beta); + + // 2. FFN + 残差 + 层归一化 + Matrix ffn_out = positionwise_ffn(norm1, W1, std::vector{0}, W2, std::vector{0}); + Matrix norm2 = layer_norm(add(norm1, ffn_out), gamma, beta); + + return norm2; +}`, + python: `import numpy as np + +def layer_norm(X, gamma, beta, eps=1e-6): + """层归一化""" + mean = X.mean(axis=-1, keepdims=True) + var = X.var(axis=-1, keepdims=True) + X_norm = (X - mean) / np.sqrt(var + eps) + return gamma * X_norm + beta + +def positionwise_ffn(X, W1, b1, W2, b2): + """位置前馈网络: FFN(x) = ReLU(x W1 + b1) W2 + b2""" + hidden = np.maximum(0, X @ W1 + b1) # ReLU + return hidden @ W2 + b2 + +def encoder_layer(X, params): + """ + 单层编码器 + params: dict 包含 Wq, Wk, Wv, Wo, W1, b1, W2, b2, gamma, beta + """ + # 1. 多头自注意力 + 残差 + 层归一化 + attn_out = multihead_attention( + X, X, X, + params['Wq'], params['Wk'], params['Wv'], params['Wo'], + num_heads=8 + ) + norm1 = layer_norm(X + attn_out, params['gamma1'], params['beta1']) + + # 2. FFN + 残差 + 层归一化 + ffn_out = positionwise_ffn( + norm1, params['W1'], params['b1'], params['W2'], params['b2'] + ) + norm2 = layer_norm(norm1 + ffn_out, params['gamma2'], params['beta2']) + + return norm2 + +def transformer_encoder(X, params_list, num_layers=6): + """完整的 Transformer 编码器""" + for layer_params in params_list: + X = encoder_layer(X, layer_params) + return X` + }, + variablesSnapshot: { + layers: '6', + dModel: '512', + dFF: '2048', + numHeads: '8', + }, + pseudocode: `procedure ENCODER_LAYER(X) + // 子层 1: 多头自注意力 + 残差 + 层归一化 + attn <- multihead_attention(X, X, X) + norm1 <- layer_norm(X + attn) + // 子层 2: FFN + 残差 + 层归一化 + ffn <- positionwise_ffn(norm1) + norm2 <- layer_norm(norm1 + ffn) + return norm2 + +procedure DECODER_LAYER(X, encoder_output) + // 子层 1: 掩码多头自注意力 + masked_attn <- masked_multihead_attention(X, X, X) + norm1 <- layer_norm(X + masked_attn) + // 子层 2: 编码器-解码器注意力 + cross_attn <- multihead_attention(norm1, encoder_output, encoder_output) + norm2 <- layer_norm(norm1 + cross_attn) + // 子层 3: FFN + ffn <- positionwise_ffn(norm2) + norm3 <- layer_norm(norm2 + ffn) + return norm3`, + bigO: { + time: '每层编码器需要 O(n^2 × d_model + n × d_model^2),L 层总复杂度 O(L × (n^2 × d_model + n × d_model^2))。', + space: '每层需要 O(n × d_model) 的中间状态,L 层总 O(L × n × d_model)。注意力矩阵 O(n^2)。', + note: '对于长序列,n^2 项主导复杂度。高效 Transformer(如 Linformer、Performer)通过近似将复杂度降为 O(n)。', + }, + compare: [ + { method: 'Transformer', data: '全注意力', strength: 'O(1) 长距离依赖,完全并行', tradeoff: 'O(n^2) 复杂度,长序列昂贵' }, + { method: 'RNN (LSTM)', data: '逐步递推', strength: 'O(n) 复杂度,适合流式处理', tradeoff: '无法并行,长距离依赖弱' }, + { method: 'CNN (ConvS2S)', data: '局部卷积', strength: '可并行,局部模式强', tradeoff: '长距离需要多层,感受野有限' }, + ], + quiz: [ + { + q: 'Transformer 编码器中,每个子层后都使用"残差连接 + 层归一化"的目的是什么?', + options: [ + '减少参数量,防止过拟合', + '残差连接缓解梯度消失,层归一化稳定训练分布,两者结合使深层网络可训练', + '增加模型非线性能力', + '加速前向传播计算', + ], + answer: 1, + explanation: '残差连接(Add)让梯度可以直接回传到浅层,缓解梯度消失问题。层归一化(LayerNorm)将每层输出归一化到稳定分布,防止内部协变量偏移。两者结合使 Transformer 能够训练非常深的网络(数十甚至上百层)。', + }, + ], + }, + { + id: 'nlp-bert-gpt', + title: 'BERT 与 GPT', + summary: '编码器-only vs 解码器-only、预训练目标对比', + theory: `## BERT vs GPT + +BERT 和 GPT 是 Transformer 架构的两种主要变体,分别代表了"双向理解"和"自回归生成"两大范式。 + +### 架构对比 + +| 特性 | BERT | GPT | +|------|------|-----| +| 基础架构 | 多层编码器 | 多层解码器 | +| 注意力 | 双向(Full Attention) | 单向(Causal Masked) | +| 预训练任务 | MLM + NSP | 自回归语言建模 | +| 擅长任务 | 理解类(分类、抽取) | 生成类(续写、对话) | +| 代表模型 | BERT-base/large, RoBERTa | GPT-2/3/4, LLaMA, PaLM | + +### BERT 的预训练目标 + +**1. 掩码语言模型(Masked Language Model, MLM)** + +随机掩码 15% 的 token,让模型预测被掩码的词: + +$$L_{MLM} = -\\sum_{\\hat{x} \\in m(x)} \\log P(\\hat{x} | x_{\\setminus m(x)})$$ + +其中 $m(x)$ 是被掩码的位置集合。 + +特殊处理:80% 替换为 [MASK],10% 替换为随机词,10% 保持原样。 + +**2. 下一句预测(Next Sentence Prediction, NSP)** + +判断句子 B 是否是句子 A 的下一句,50% 正例,50% 负例。 + +### GPT 的预训练目标 + +**自回归语言模型(Autoregressive Language Model)** + +给定前面的词,预测下一个词: + +$$L_{AR} = -\\sum_{t=1}^{T} \\log P(w_t | w_1, ..., w_{t-1})$$ + +### 模型规模演进 + +| 模型 | 参数量 | 层数 | 隐藏维度 | 头数 | +|------|--------|------|---------|------| +| BERT-base | 110M | 12 | 768 | 12 | +| BERT-large | 340M | 24 | 1024 | 16 | +| GPT-2 | 1.5B | 48 | 1600 | 25 | +| GPT-3 | 175B | 96 | 12288 | 96 | +| LLaMA-2 70B | 70B | 80 | 8192 | 64 | + +### 选择指南 + +- **理解任务**(分类、命名实体识别、抽取式问答)→ 用 BERT 类模型 +- **生成任务**(续写、翻译、摘要、对话)→ 用 GPT 类模型 +- **资源有限** → 用蒸馏版(DistilBERT、TinyGPT) +`, + exercise: { type: 'playground', viz: 'transformer' }, + code: { + cpp: `#include +#include +#include + +// BERT MLM 预训练数据生成 +void create_mlm_input( + std::vector& tokens, + std::vector& masked_labels, + const std::vector& special_tokens, + double mask_prob = 0.15) { + int n = tokens.size(); + masked_labels.assign(n, -100); // -100 表示不计算损失 + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_real_distribution<> dis(0.0, 1.0); + std::uniform_int_distribution<> vocab_dis(0, 30000); + + for (int i = 0; i < n; i++) { + // 跳过特殊 token + bool is_special = false; + for (int st : special_tokens) { + if (tokens[i] == st) { is_special = true; break; } + } + if (is_special) continue; + + if (dis(gen) < mask_prob) { + masked_labels[i] = tokens[i]; // 原始 token 作为标签 + double r = dis(gen); + if (r < 0.8) { + tokens[i] = 4; // [MASK] token id = 4 + } else if (r < 0.9) { + tokens[i] = vocab_dis(gen); // 随机替换 + } + // 10% 保持原样 + } + } +} + +// GPT 自回归训练目标 +double autoregressive_loss( + const std::vector>& logits, // [seq_len, vocab_size] + const std::vector& targets) { // [seq_len] + double loss = 0.0; + int n = logits.size(); + for (int t = 0; t < n - 1; t++) { + // logits[t] 预测 targets[t+1] + double max_logit = *std::max_element(logits[t].begin(), logits[t].end()); + double sum_exp = 0.0; + for (double l : logits[t]) sum_exp += std::exp(l - max_logit); + double log_prob = logits[t][targets[t+1]] - max_logit - std::log(sum_exp); + loss -= log_prob; + } + return loss / (n - 1); +}`, + python: `import numpy as np +import random + +def create_mlm_input(tokens, mask_token_id=4, vocab_size=30522, mask_prob=0.15): + """ + BERT MLM 预训练数据生成 + tokens: list of token ids + 返回: (masked_tokens, labels) + labels: 非掩码位置为 -100(忽略),掩码位置为原始 token id + """ + labels = [-100] * len(tokens) + masked_tokens = tokens.copy() + + for i in range(len(tokens)): + if random.random() < mask_prob: + labels[i] = tokens[i] # 保存原始 token 作为标签 + r = random.random() + if r < 0.8: + masked_tokens[i] = mask_token_id # 80% [MASK] + elif r < 0.9: + masked_tokens[i] = random.randint(0, vocab_size - 1) # 10% 随机 + # 10% 保持原样 + + return masked_tokens, labels + +def autoregressive_loss(logits, targets): + """ + GPT 自回归损失 + logits: [seq_len, vocab_size] + targets: [seq_len] (shifted by 1) + """ + # logits[t] 预测 targets[t] + shifted_logits = logits[:-1] # [seq_len-1, vocab] + shifted_targets = targets[1:] # [seq_len-1] + + # 交叉熵 + log_probs = shifted_logits - np.log(np.sum(np.exp(shifted_logits), axis=-1, keepdims=True)) + nll = -log_probs[np.arange(len(shifted_targets)), shifted_targets] + return np.mean(nll) + +# NSP(下一句预测)标签生成 +def create_nsp_example(sentence_a, sentence_b, is_next): + """ + BERT NSP 输入格式: + <[BOS_never_used_51bce0c785ca2f68081bfa7d91973934]> sentence_a [SEP] sentence_b [SEP] + """ + input_ids = [2] + sentence_a + [3] + sentence_b + [3] # 2=<[BOS_never_used_51bce0c785ca2f68081bfa7d91973934]>, 3=[SEP] + token_type_ids = [0] * (2 + len(sentence_a) + 1) + [1] * (len(sentence_b) + 1) + label = 1 if is_next else 0 + return input_ids, token_type_ids, label` + }, + variablesSnapshot: { + bertModel: 'BERT-base', + bertParams: '110M', + gptModel: 'GPT-2', + gptParams: '1.5B', + mlmProb: '0.15', + }, + pseudocode: `procedure BERT_PRETRAIN(corpus, epochs) + for epoch <- 1 to epochs do + for each sentence_pair in corpus do + // MLM 任务 + masked_tokens, labels <- create_mlm_input(tokens) + mlm_loss <- cross_entropy(predict(masked_tokens), labels) + // NSP 任务 + nsp_label <- sample_next_sentence(sentence_pair) + nsp_loss <- binary_cross_entropy(predict_nsp(), nsp_label) + // 总损失 + total_loss <- mlm_loss + nsp_loss + update model parameters + end for + end for + +procedure GPT_PRETRAIN(corpus, epochs) + for epoch <- 1 to epochs do + for each sequence in corpus do + // 自回归预测 + for t <- 1 to T do + logits <- model(w_1, ..., w_{t-1}) + loss += cross_entropy(logits, w_t) + end for + update model parameters + end for + end for`, + bigO: { + time: 'BERT 预训练需要 O(N × L × d_model^2),N 是训练 token 数,L 是层数。GPT 同样量级,但推理时可以缓存 KV 减少重复计算。', + space: 'BERT 需要存储整个序列的注意力矩阵 O(n^2)。GPT 推理时可使用 KV 缓存 O(n × d_model)。', + note: 'GPT 的自回归推理天然适合流式生成,但每步需要 O(n) 的注意力计算(或 O(1) 用 KV 缓存)。BERT 需要完整序列输入,不适合流式场景。', + }, + compare: [ + { method: 'BERT (Encoder-only)', data: '双向上下文', strength: '理解能力强,适合分类/抽取任务', tradeoff: '不能直接生成,需要额外的解码层' }, + { method: 'GPT (Decoder-only)', data: '单向上下文(因果)', strength: '天然适合生成任务,自回归特性', tradeoff: '理解能力受限,无法看到双向信息' }, + { method: 'T5 (Encoder-Decoder)', data: '双向编码 + 单向解码', strength: '统一框架,理解+生成兼顾', tradeoff: '参数量更大,推理更慢' }, + ], + quiz: [ + { + q: 'BERT 预训练中的 MLM(掩码语言模型)任务,为什么 80% 替换为 [MASK],10% 替换为随机词,10% 保持原样?', + options: [ + '为了增加训练数据量', + '防止模型过度依赖 [MASK] token 的特殊表示,因为下游任务中没有 [MASK]', + '为了减少计算量,提高训练速度', + '为了让模型学习 token 之间的顺序关系', + ], + answer: 1, + explanation: '如果所有掩码位置都用 [MASK],模型会学到 [MASK] 的特殊表示,但下游任务(如分类)中不会出现 [MASK] token。加入随机替换和保持原样可以让模型更鲁棒,减少预训练和微调之间的分布差异。', + }, + ], }, ] diff --git a/src/data/ai/chapters/rl.js b/src/data/ai/chapters/rl.js index 4bb7beb..3bde13f 100644 --- a/src/data/ai/chapters/rl.js +++ b/src/data/ai/chapters/rl.js @@ -3,38 +3,2289 @@ // LATE_COURSE_CODE、completeAILessonMetadata)仍在 ../curriculum.js, // 模块加载时会原位向这些 lesson 对象补字段。 export const RL_LESSONS = [ - { - id: 'rl-qlearning', - title: 'Q-Learning', - summary: 'Q 表、贝尔曼方程、ε-greedy 策略', - theory: `## Q-Learning + { + id: 'rl-mdp', + title: '马尔可夫决策过程 (MDP)', + summary: '状态、动作、奖励、转移概率、折扣因子', + theory: `## 马尔可夫决策过程 (MDP) -基于值函数的无模型强化学习算法。 +MDP 是强化学习的数学框架,用五元组 $(\\mathcal{S}, \\mathcal{A}, P, R, \\gamma)$ 描述: + +### 五元组 + +| 符号 | 含义 | +|------|------| +| $\\mathcal{S}$ | 状态空间(有限或无限) | +| $\\mathcal{A}$ | 动作空间 | +| $P$ | 转移概率函数 $P(s' \\mid s, a)$ | +| $R$ | 奖励函数 $R(s, a, s')$ 或 $R(s, a)$ | +| $\\gamma$ | 折扣因子 $\\gamma \\in [0, 1]$ | + +### 马尔可夫性质 + +下一状态只依赖于当前状态和动作,与历史无关: + +$$P(s_{t+1} \\mid s_t, a_t, s_{t-1}, a_{t-1}, \\ldots) = P(s_{t+1} \\mid s_t, a_t)$$ + +### 回报 (Return) + +从时刻 $t$ 开始的累积折扣奖励: + +$$G_t = R_{t+1} + \\gamma R_{t+2} + \\gamma^2 R_{t+3} + \\cdots = \\sum_{k=0}^{\\infty} \\gamma^k R_{t+k+1}$$ + +### 折扣因子的意义 + +- $\\gamma = 0$:只看即时奖励(短视) +- $\\gamma = 1$:未来奖励与即时奖励同等重要(远见) +- 通常 $\\gamma \\in [0.9, 0.99]$ +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `struct MDP { + int n_states, n_actions; + vector>> P; // P[s][a][s'] = probability + vector> R; // R[s][a] = reward + double gamma; + + double sample_next_state(int s, int a) { + double r = R[s][a]; + // 按转移概率采样下一状态 + double rand_val = (double)rand() / RAND_MAX; + double cumulative = 0.0; + for (int s_prime = 0; s_prime < n_states; s_prime++) { + cumulative += P[s][a][s_prime]; + if (rand_val <= cumulative) return s_prime; + } + return n_states - 1; + } +};`, + python: `import numpy as np + +class MDP: + def __init__(self, n_states, n_actions, gamma=0.99): + self.n_states = n_states + self.n_actions = n_actions + self.gamma = gamma + # 转移概率 P[s][a][s'] + self.P = np.zeros((n_states, n_actions, n_states)) + # 奖励 R[s][a] + self.R = np.zeros((n_states, n_actions)) + + def sample_next_state(self, s, a): + """按转移概率采样下一状态""" + return np.random.choice( + self.n_states, + p=self.P[s][a] + ) + + def step(self, s, a): + """执行一步,返回 (next_state, reward, done)""" + s_prime = self.sample_next_state(s, a) + reward = self.R[s][a] + done = (s_prime == self.n_states - 1) # 假设终止状态为最后一个 + return s_prime, reward, done` + }, + variablesSnapshot: { + n_states: '5', + n_actions: '4', + gamma: '0.95', + P_shape: '(5, 4, 5)', + R_shape: '(5, 4)' + }, + pseudocode: `procedure MDP_STEP(s, a, P, R, gamma) + s' <- sample from P(s' | s, a) + r <- R(s, a) + return (s', r) + +procedure COMPUTE_RETURN(rewards, gamma) + G <- 0 + for t from T down to 1 do + G <- rewards[t] + gamma * G + end for + return G`, + bigO: { + time: 'MDP 本身没有计算复杂度,它是一个建模框架。采样一步是 O(1)(假设转移概率可查)。计算回报需要 O(T) 遍历时间序列。', + space: '存储转移概率表需要 O(|S| × |A| × |S|) 空间,存储奖励表需要 O(|S| × |A|) 空间。', + note: '|S| 是状态数,|A| 是动作数。对于大规模 MDP,通常用函数近似而非查表。' + }, + compare: [ + { method: '有限 MDP', data: '|S| 和 |A| 有限', strength: '可用动态规划精确求解', tradeoff: '大规模问题状态爆炸' }, + { method: '连续 MDP', data: '状态/动作连续', strength: '建模更精确', tradeoff: '需要函数近似,难以精确求解' }, + { method: 'POMDP (部分可观测)', data: '观测 ≠ 状态', strength: '更接近真实世界', tradeoff: '需要维护信念状态,复杂度极高' } + ], + quiz: [ + { + q: 'MDP 中折扣因子 γ=0 意味着什么?', + options: [ + '只关心即时奖励,完全忽略未来奖励', + '未来奖励和即时奖励同等重要', + '奖励会翻倍', + '没有终止状态' + ], + answer: 0, + explanation: 'γ=0 时 G_t = R_{t+1},智能体完全短视,只追求即时奖励。γ 越大越有远见。' + }, + { + q: '马尔可夫性质要求什么?', + options: [ + '下一状态只依赖于当前状态和动作', + '所有状态必须可观测', + '奖励必须为正', + '转移概率必须对称' + ], + answer: 0, + explanation: '马尔可夫性质要求下一状态的条件分布只依赖于当前状态和动作,与更早的历史无关。' + } + ] + }, + { + id: 'rl-bellman', + title: '贝尔曼方程', + summary: '值函数、最优性、动态规划基础', + theory: `## 贝尔曼方程 + +贝尔曼方程是强化学习的核心递归关系,描述了值函数的自洽性质。 + +### 状态值函数 $V^\\pi(s)$ + +在策略 $\\pi$ 下,从状态 $s$ 开始的期望回报: + +$$V^\\pi(s) = \\mathbb{E}_\\pi[G_t \\mid s_t = s]$$ + +### 动作值函数 $Q^\\pi(s, a)$ + +在状态 $s$ 选择动作 $a$,之后遵循策略 $\\pi$ 的期望回报: + +$$Q^\\pi(s, a) = \\mathbb{E}_\\pi[G_t \\mid s_t = s, a_t = a]$$ + +### 贝尔曼期望方程 + +$$V^\\pi(s) = \\sum_a \\pi(a \\mid s) \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V^\\pi(s')]$$ + +$$Q^\\pi(s, a) = \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma \\sum_{a'} \\pi(a' \\mid s') Q^\\pi(s', a')]$$ + +### 贝尔曼最优方程 + +$$V^*(s) = \\max_a \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V^*(s')]$$ + +$$Q^*(s, a) = \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma \\max_{a'} Q^*(s', a')]$$ + +### 最优策略 + +$$\\pi^*(a \\mid s) = \\begin{cases} 1 & \\text{if } a = \\arg\\max_{a'} Q^*(s, a') \\\\ 0 & \\text{otherwise} \\end{cases}$$ +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `vector bellman_expectation( + const MDP& mdp, + const vector>& pi, // pi[s][a] = 策略概率 + const vector& V +) { + int nS = mdp.n_states, nA = mdp.n_actions; + vector V_new(nS, 0.0); + for (int s = 0; s < nS; s++) { + for (int a = 0; a < nA; a++) { + double action_val = 0.0; + for (int s_prime = 0; s_prime < nS; s_prime++) { + action_val += mdp.P[s][a][s_prime] * + (mdp.R[s][a] + mdp.gamma * V[s_prime]); + } + V_new[s] += pi[s][a] * action_val; + } + } + return V_new; +} + +vector bellman_optimality( + const MDP& mdp, + const vector& V +) { + int nS = mdp.n_states, nA = mdp.n_actions; + vector V_new(nS, 0.0); + for (int s = 0; s < nS; s++) { + double max_val = -1e18; + for (int a = 0; a < nA; a++) { + double action_val = 0.0; + for (int s_prime = 0; s_prime < nS; s_prime++) { + action_val += mdp.P[s][a][s_prime] * + (mdp.R[s][a] + mdp.gamma * V[s_prime]); + } + max_val = max(max_val, action_val); + } + V_new[s] = max_val; + } + return V_new; +}`, + python: `def bellman_expectation(mdp, pi, V): + """贝尔曼期望方程:计算策略 pi 下的 V(s)""" + V_new = np.zeros(mdp.n_states) + for s in range(mdp.n_states): + for a in range(mdp.n_actions): + action_val = 0.0 + for s_prime in range(mdp.n_states): + action_val += mdp.P[s, a, s_prime] * \\ + (mdp.R[s, a] + mdp.gamma * V[s_prime]) + V_new[s] += pi[s, a] * action_val + return V_new + +def bellman_optimality(mdp, V): + """贝尔曼最优方程:计算 V*(s)""" + V_new = np.zeros(mdp.n_states) + for s in range(mdp.n_states): + max_val = -np.inf + for a in range(mdp.n_actions): + action_val = 0.0 + for s_prime in range(mdp.n_states): + action_val += mdp.P[s, a, s_prime] * \\ + (mdp.R[s, a] + mdp.gamma * V[s_prime]) + max_val = max(max_val, action_val) + V_new[s] = max_val + return V_new` + }, + variablesSnapshot: { + V_type: 'state value function', + Q_type: 'action value function', + equation: 'Bellman Expectation', + policy: 'stochastic pi(a|s)' + }, + pseudocode: `procedure BELLMAN_EXPECTATION(mdp, pi, V) + for each state s do + V_new[s] <- sum_a pi(a|s) * sum_{s'} P(s'|s,a) [R(s,a) + gamma * V[s']] + end for + return V_new + +procedure BELLMAN_OPTIMALITY(mdp, V) + for each state s do + V_new[s] <- max_a sum_{s'} P(s'|s,a) [R(s,a) + gamma * V[s']] + end for + return V_new`, + bigO: { + time: '计算贝尔曼期望方程的一次迭代需要 O(|S| × |A| × |S|),即对每个状态、每个动作、每个可能的下一状态求和。', + space: '存储值函数 V 需要 O(|S|) 空间,存储 Q 函数需要 O(|S| × |A|) 空间。', + note: '这是动态规划的基础,值迭代和策略迭代都基于贝尔曼方程。' + }, + compare: [ + { method: '贝尔曼期望方程', data: '给定策略 π', strength: '评估固定策略的值函数', tradeoff: '需要已知策略' }, + { method: '贝尔曼最优方程', data: '无策略约束', strength: '直接求最优值函数', tradeoff: '需要 max 操作,非线性' }, + { method: 'TD 学习', data: '采样经验', strength: '无模型,无需 P 和 R', tradeoff: '有估计偏差,需要大量样本' } + ], + quiz: [ + { + q: '贝尔曼最优方程中的 max 操作意味着什么?', + options: [ + '在每个状态选择最优动作', + '对所有动作取平均', + '随机选择一个动作', + '不选择任何动作' + ], + answer: 0, + explanation: '最优方程中的 max_a 表示智能体可以选择最优动作,因此取所有可能动作中的最大值。' + }, + { + q: 'V(s) 和 Q(s,a) 的关系是什么?', + options: [ + 'V(s) = max_a Q(s,a)(最优时)', + 'V(s) = sum_a Q(s,a)', + 'V(s) = Q(s,a) 对所有 a', + '两者没有关系' + ], + answer: 0, + explanation: '对于最优值函数,V*(s) = max_a Q*(s,a),因为最优策略会选择使 Q 最大的动作。' + } + ] + }, + { + id: 'rl-value-iteration', + title: '值迭代', + summary: '同步/异步更新、收敛性证明', + theory: `## 值迭代 (Value Iteration) + +值迭代直接使用贝尔曼最优方程迭代更新值函数,直到收敛。 + +### 同步值迭代 + +$$V_{k+1}(s) = \\max_a \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V_k(s')]$$ + +每次迭代同时更新所有状态的值,使用旧值计算新值。 + +### 异步值迭代 + +按某种顺序逐个更新状态,可以使用最新计算的值: + +$$V(s) \\leftarrow \\max_a \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V(s')]$$ + +### 收敛性 + +值迭代收敛到最优值函数 $V^*$,误差按 $\\gamma$ 的几何级数衰减: + +$$\\|V_{k+1} - V^*\\|_\\infty \\leq \\gamma \\|V_k - V^*\\|_\\infty$$ + +### 停止条件 + +当 $\\|V_{k+1} - V_k\\|_\\infty < \\epsilon$ 时停止,此时策略损失不超过 $\\frac{2\\epsilon\\gamma}{1-\\gamma}$。 + +### 从值函数提取策略 + +$$\\pi(s) = \\arg\\max_a \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V(s')]$$ +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `pair, vector> value_iteration( + const MDP& mdp, + double epsilon = 1e-6, + int max_iter = 1000 +) { + int nS = mdp.n_states, nA = mdp.n_actions; + vector V(nS, 0.0); + vector policy(nS, 0); + + for (int iter = 0; iter < max_iter; iter++) { + vector V_new(nS, 0.0); + double delta = 0.0; + + for (int s = 0; s < nS; s++) { + double max_val = -1e18; + for (int a = 0; a < nA; a++) { + double val = 0.0; + for (int s_prime = 0; s_prime < nS; s_prime++) { + val += mdp.P[s][a][s_prime] * + (mdp.R[s][a] + mdp.gamma * V[s_prime]); + } + max_val = max(max_val, val); + } + V_new[s] = max_val; + delta = max(delta, abs(V_new[s] - V[s])); + } + + V = V_new; + if (delta < epsilon) break; + } + + // 提取最优策略 + for (int s = 0; s < nS; s++) { + double max_val = -1e18; + for (int a = 0; a < nA; a++) { + double val = 0.0; + for (int s_prime = 0; s_prime < nS; s_prime++) { + val += mdp.P[s][a][s_prime] * + (mdp.R[s][a] + mdp.gamma * V[s_prime]); + } + if (val > max_val) { + max_val = val; + policy[s] = a; + } + } + } + + return {V, policy}; +}`, + python: `def value_iteration(mdp, epsilon=1e-6, max_iter=1000): + V = np.zeros(mdp.n_states) + + for i in range(max_iter): + V_new = np.zeros(mdp.n_states) + delta = 0.0 + + for s in range(mdp.n_states): + max_val = -np.inf + for a in range(mdp.n_actions): + val = 0.0 + for s_prime in range(mdp.n_states): + val += mdp.P[s, a, s_prime] * \\ + (mdp.R[s, a] + mdp.gamma * V[s_prime]) + max_val = max(max_val, val) + V_new[s] = max_val + delta = max(delta, abs(V_new[s] - V[s])) + + V = V_new + if delta < epsilon: + break + + # 提取最优策略 + policy = np.zeros(mdp.n_states, dtype=int) + for s in range(mdp.n_states): + max_val = -np.inf + for a in range(mdp.n_actions): + val = 0.0 + for s_prime in range(mdp.n_states): + val += mdp.P[s, a, s_prime] * \\ + (mdp.R[s, a] + mdp.gamma * V[s_prime]) + if val > max_val: + max_val = val + policy[s] = a + + return V, policy` + }, + variablesSnapshot: { + iteration: 'k', + max_delta: '||V_{k+1} - V_k||_inf', + epsilon: '1e-6', + gamma: '0.95' + }, + pseudocode: `procedure VALUE_ITERATION(mdp, epsilon) + V <- 0 for all states + repeat + delta <- 0 + for each state s do + v <- V[s] + V[s] <- max_a sum_{s'} P(s'|s,a) [R(s,a) + gamma * V[s']] + delta <- max(delta, |v - V[s]|) + end for + until delta < epsilon + + // 提取策略 + for each state s do + pi[s] <- argmax_a sum_{s'} P(s'|s,a) [R(s,a) + gamma * V[s']] + end for + return V, pi`, + bigO: { + time: '每次迭代 O(|S| × |A| × |S|),迭代次数取决于初始误差和 epsilon,通常为 O(log(1/ε) / log(1/γ)) 量级。总复杂度约 O(|S|² × |A| × log(1/ε))。', + space: '存储值函数 V 需要 O(|S|),存储策略需要 O(|S|),MDP 模型需要 O(|S|² × |A|)。', + note: '值迭代是动态规划方法,需要已知 MDP 模型(P 和 R)。' + }, + compare: [ + { method: '值迭代', data: '直接优化 V*', strength: '实现简单,收敛稳定', tradeoff: '每次迭代需要遍历所有状态' }, + { method: '策略迭代', data: '交替评估和改进', strength: '通常收敛更快(迭代次数少)', tradeoff: '每次策略评估需要多次迭代' }, + { method: 'Q-learning', data: '无模型采样', strength: '不需要 P 和 R', tradeoff: '需要大量样本,收敛慢' } + ], + quiz: [ + { + q: '值迭代什么时候停止?', + options: [ + '当两次迭代间值函数的最大变化小于 epsilon', + '当迭代次数达到 100 次', + '当所有值函数都为 0', + '当奖励为正' + ], + answer: 0, + explanation: '值迭代的停止条件是 ||V_{k+1} - V_k||_∞ < ε,即值函数的变化足够小。' + }, + { + q: '同步值迭代和异步值迭代的区别是什么?', + options: [ + '同步用旧值更新所有状态,异步可用最新值', + '同步更快', + '异步需要更多内存', + '两者完全相同' + ], + answer: 0, + explanation: '同步迭代使用 V_k 计算所有 V_{k+1};异步迭代按顺序更新,可以在计算某个状态时使用刚更新的其他状态的值。' + } + ] + }, + { + id: 'rl-policy-iteration', + title: '策略迭代', + summary: '策略评估 + 策略改进,两阶段交替', + theory: `## 策略迭代 (Policy Iteration) + +策略迭代交替进行**策略评估**和**策略改进**,直到策略收敛。 + +### 策略评估 (Policy Evaluation) + +给定策略 $\\pi$,计算其值函数 $V^\\pi$: + +$$V^\\pi(s) = \\sum_a \\pi(a \\mid s) \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V^\\pi(s')]$$ + +这是一个线性方程组,可以用迭代法求解: + +$$V_{k+1}(s) = \\sum_a \\pi(a \\mid s) \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V_k(s')]$$ + +### 策略改进 (Policy Improvement) + +用当前值函数构造更好的策略: + +$$\\pi'(s) = \\arg\\max_a \\sum_{s', r} P(s', r \\mid s, a) [r + \\gamma V^\\pi(s')]$$ + +### 策略改进定理 + +如果 $Q^\\pi(s, \\pi'(s)) \\geq V^\\pi(s)$ 对所有 $s$ 成立,则 $\\pi' \\geq \\pi$(更优或相等)。 + +### 收敛性 + +策略迭代在有限步内收敛到最优策略,因为每次改进都是单调的,且策略空间有限。 + +### 与值迭代的比较 + +| 方法 | 每次迭代 | 收敛速度 | +|------|---------|---------| +| 值迭代 | 更新所有状态的 V | 迭代次数多,每次简单 | +| 策略迭代 | 完整评估 + 改进 | 迭代次数少,每次复杂 | +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `vector policy_evaluation( + const MDP& mdp, + const vector>& pi, + double epsilon = 1e-6, + int max_iter = 1000 +) { + int nS = mdp.n_states; + vector V(nS, 0.0); + + for (int iter = 0; iter < max_iter; iter++) { + vector V_new(nS, 0.0); + double delta = 0.0; + + for (int s = 0; s < nS; s++) { + for (int a = 0; a < mdp.n_actions; a++) { + double action_val = 0.0; + for (int s_prime = 0; s_prime < nS; s_prime++) { + action_val += mdp.P[s][a][s_prime] * + (mdp.R[s][a] + mdp.gamma * V[s_prime]); + } + V_new[s] += pi[s][a] * action_val; + } + delta = max(delta, abs(V_new[s] - V[s])); + } + + V = V_new; + if (delta < epsilon) break; + } + + return V; +} + +pair>, vector> policy_iteration( + const MDP& mdp, + double epsilon = 1e-6 +) { + int nS = mdp.n_states, nA = mdp.n_actions; + // 初始化均匀随机策略 + vector> pi(nS, vector(nA, 1.0 / nA)); + vector V(nS, 0.0); + + while (true) { + // 策略评估 + V = policy_evaluation(mdp, pi, epsilon); + + // 策略改进 + vector> pi_new(nS, vector(nA, 0.0)); + bool policy_stable = true; + + for (int s = 0; s < nS; s++) { + int best_action = 0; + double best_val = -1e18; + for (int a = 0; a < nA; a++) { + double val = 0.0; + for (int s_prime = 0; s_prime < nS; s_prime++) { + val += mdp.P[s][a][s_prime] * + (mdp.R[s][a] + mdp.gamma * V[s_prime]); + } + if (val > best_val) { + best_val = val; + best_action = a; + } + } + pi_new[s][best_action] = 1.0; + if (pi[s][best_action] != 1.0) { + policy_stable = false; + } + } + + if (policy_stable) break; + pi = pi_new; + } + + return {pi, V}; +}`, + python: `def policy_evaluation(mdp, pi, epsilon=1e-6, max_iter=1000): + V = np.zeros(mdp.n_states) + + for i in range(max_iter): + V_new = np.zeros(mdp.n_states) + delta = 0.0 + + for s in range(mdp.n_states): + for a in range(mdp.n_actions): + action_val = 0.0 + for s_prime in range(mdp.n_states): + action_val += mdp.P[s, a, s_prime] * \\ + (mdp.R[s, a] + mdp.gamma * V[s_prime]) + V_new[s] += pi[s, a] * action_val + delta = max(delta, abs(V_new[s] - V[s])) + + V = V_new + if delta < epsilon: + break + + return V + +def policy_iteration(mdp, epsilon=1e-6): + # 初始化均匀随机策略 + pi = np.ones((mdp.n_states, mdp.n_actions)) / mdp.n_actions + V = np.zeros(mdp.n_states) + + while True: + # 策略评估 + V = policy_evaluation(mdp, pi, epsilon) + + # 策略改进 + pi_new = np.zeros((mdp.n_states, mdp.n_actions)) + policy_stable = True + + for s in range(mdp.n_states): + best_action = 0 + best_val = -np.inf + for a in range(mdp.n_actions): + val = 0.0 + for s_prime in range(mdp.n_states): + val += mdp.P[s, a, s_prime] * \\ + (mdp.R[s, a] + mdp.gamma * V[s_prime]) + if val > best_val: + best_val = val + best_action = a + pi_new[s, best_action] = 1.0 + if pi[s, best_action] != 1.0: + policy_stable = False + + if policy_stable: + break + pi = pi_new + + return pi, V` + }, + variablesSnapshot: { + policy: 'stochastic pi(a|s)', + evaluation_iter: 'converged', + improvement: 'greedy', + stable: 'False' + }, + pseudocode: `procedure POLICY_ITERATION(mdp, epsilon) + pi <- random policy + repeat + // 策略评估 + V <- POLICY_EVALUATION(pi, mdp, epsilon) + + // 策略改进 + policy_stable <- true + for each state s do + old_action <- pi[s] + pi[s] <- argmax_a sum_{s'} P(s'|s,a) [R(s,a) + gamma * V[s']] + if old_action != pi[s] then + policy_stable <- false + end if + end for + until policy_stable + return pi, V + +procedure POLICY_EVALUATION(pi, mdp, epsilon) + V <- 0 + repeat + delta <- 0 + for each state s do + v <- V[s] + V[s] <- sum_a pi(a|s) * sum_{s'} P(s'|s,a) [R + gamma*V[s']] + delta <- max(delta, |v - V[s]|) + end for + until delta < epsilon + return V`, + bigO: { + time: '每次策略评估需要 O(k × |S| × |A| × |S|),其中 k 是评估迭代次数。策略改进需要 O(|S| × |A| × |S|)。总迭代次数通常很少(几次到几十次)。', + space: '存储策略需要 O(|S| × |A|),值函数 O(|S|),MDP 模型 O(|S|² × |A|)。', + note: '策略迭代的迭代次数通常比值迭代少,但每次迭代更复杂(需要完整评估策略)。' + }, + compare: [ + { method: '策略迭代', data: '交替评估改进', strength: '迭代次数少,策略稳定后即停止', tradeoff: '每次评估成本高' }, + { method: '值迭代', data: '直接优化 V', strength: '每次迭代简单', tradeoff: '需要更多迭代次数' }, + { method: '广义策略迭代', data: '不精确评估', strength: '折中方案,实际常用', tradeoff: '需要调参评估精度' } + ], + quiz: [ + { + q: '策略迭代什么时候停止?', + options: [ + '当策略不再变化(策略稳定)', + '当值函数变化小于 epsilon', + '当迭代 10 次后', + '当奖励为正' + ], + answer: 0, + explanation: '策略迭代的停止条件是策略改进后策略不再变化(policy_stable = true),此时已达到最优策略。' + }, + { + q: '策略评估解决的是什么问题?', + options: [ + '给定策略,计算该策略下的状态值函数', + '找到最优策略', + '计算即时奖励', + '更新转移概率' + ], + answer: 0, + explanation: '策略评估是给定一个固定策略 π,计算 V^π(s),即该策略下每个状态的期望回报。' + } + ] + }, + { + id: 'rl-qlearning', + title: 'Q-Learning', + summary: 'Q 表、贝尔曼方程、ε-greedy 策略', + theory: `## Q-Learning + +Q-Learning 是最经典的**无模型**(model-free)、**离策略**(off-policy)时序差分(TD)算法。 ### Q 值更新 $$Q(s, a) \\leftarrow Q(s, a) + \\alpha [r + \\gamma \\max_{a'} Q(s', a') - Q(s, a)]$$ -### 关键参数 - +其中: - $\\alpha$: 学习率 - $\\gamma$: 折扣因子 -- $\\epsilon$: 探索率(ε-greedy) +- $r + \\gamma \\max_{a'} Q(s', a')$: TD 目标 +- $r + \\gamma \\max_{a'} Q(s', a') - Q(s, a)$: TD 误差 + +### 关键特性 + +- **离策略 (Off-policy)**: 学习的是最优策略 $Q^*$,而行为策略可以是 $\\epsilon$-greedy +- **无模型 (Model-free)**: 不需要知道 P 和 R +- **引导 (Bootstrapping)**: 用估计值更新估计值 + +### ε-greedy 探索策略 + +以概率 $\\epsilon$ 随机选择动作(探索),以概率 $1-\\epsilon$ 选择当前最优动作(利用): + +$$a = \\begin{cases} \\arg\\max_a Q(s, a) & \\text{with prob } 1-\\epsilon \\\\ \\text{random action} & \\text{with prob } \\epsilon \\end{cases}$$ + +### 收敛性 + +在满足以下条件时,Q-Learning 以概率 1 收敛到 $Q^*$: +1. 学习率满足 Robbins-Monro 条件:$\\sum \\alpha_t = \\infty$ 且 $\\sum \\alpha_t^2 < \\infty$ +2. 所有状态-动作对被无限次访问 +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `class QLearning { +public: + int n_states, n_actions; + vector> Q; + double alpha, gamma, epsilon; + + QLearning(int nS, int nA, double alpha = 0.1, + double gamma = 0.99, double epsilon = 0.1) + : n_states(nS), n_actions(nA), + alpha(alpha), gamma(gamma), epsilon(epsilon) { + Q.resize(nS, vector(nA, 0.0)); + } + + int choose_action(int s) { + if ((double)rand() / RAND_MAX < epsilon) { + return rand() % n_actions; // 探索 + } + return argmax(Q[s]); // 利用 + } + + void update(int s, int a, double r, int s_prime, bool done) { + double max_q_next = 0.0; + if (!done) { + max_q_next = *max_element(Q[s_prime].begin(), Q[s_prime].end()); + } + double td_target = r + gamma * max_q_next; + double td_error = td_target - Q[s][a]; + Q[s][a] += alpha * td_error; + } + + void decay_epsilon(double min_epsilon = 0.01, double decay = 0.995) { + epsilon = max(min_epsilon, epsilon * decay); + } + +private: + int argmax(const vector& v) { + return max_element(v.begin(), v.end()) - v.begin(); + } +};`, + python: `import numpy as np + +class QLearning: + def __init__(self, n_states, n_actions, alpha=0.1, + gamma=0.99, epsilon=0.1): + self.n_states = n_states + self.n_actions = n_actions + self.alpha = alpha + self.gamma = gamma + self.epsilon = epsilon + self.Q = np.zeros((n_states, n_actions)) + + def choose_action(self, s): + if np.random.random() < self.epsilon: + return np.random.randint(self.n_actions) # 探索 + return np.argmax(self.Q[s]) # 利用 + + def update(self, s, a, r, s_prime, done): + if done: + max_q_next = 0.0 + else: + max_q_next = np.max(self.Q[s_prime]) + td_target = r + self.gamma * max_q_next + td_error = td_target - self.Q[s, a] + self.Q[s, a] += self.alpha * td_error + + def decay_epsilon(self, min_epsilon=0.01, decay=0.995): + self.epsilon = max(min_epsilon, self.epsilon * decay)` + }, + variablesSnapshot: { + alpha: '0.1', + gamma: '0.99', + epsilon: '0.1', + Q_shape: '(|S|, |A|)', + td_error: 'r + gamma * max Q(s\') - Q(s,a)' + }, + pseudocode: `procedure Q_LEARNING(env, n_episodes) + Initialize Q(s, a) arbitrarily for all s, a + for episode <- 1 to n_episodes do + s <- env.reset() + repeat + a <- epsilon_greedy(Q, s, epsilon) + s', r, done <- env.step(a) + Q(s, a) <- Q(s, a) + alpha * [r + gamma * max_a' Q(s', a') - Q(s, a)] + s <- s' + until done + decay epsilon + end for + return Q`, + bigO: { + time: '每个时间步的更新是 O(1)(查表和更新)。训练总时间 O(T × |S| × |A|),其中 T 是总时间步数,需要充分探索所有状态-动作对。', + space: '存储 Q 表需要 O(|S| × |A|) 空间。对于大规模问题,需要用神经网络近似 Q 函数。', + note: 'Q-Learning 是 off-policy 算法,因为它学习的是最优策略,而行为策略可以是任意探索策略。' + }, + compare: [ + { method: 'Q-Learning', data: '离策略 TD', strength: '直接学习最优策略,样本效率高', tradeoff: 'Q 值过估计问题' }, + { method: 'SARSA', data: '同策略 TD', strength: '更保守,适合在线学习', tradeoff: '学习的是行为策略而非最优' }, + { method: 'DQN', data: '神经网络近似', strength: '可处理大规模状态空间', tradeoff: '训练不稳定,需要目标网络' } + ], + quiz: [ + { + q: 'Q-Learning 中的 TD 目标是什么?', + options: [ + 'r + γ * max_{a\'} Q(s\', a\')', + 'r + γ * Q(s\', a\')', + 'r', + 'Q(s, a)' + ], + answer: 0, + explanation: 'Q-Learning 是离策略算法,TD 目标使用 max 操作,假设下一状态选择最优动作。' + }, + { + q: '为什么 Q-Learning 被称为离策略算法?', + options: [ + '它学习最优策略,但行为策略可以是任意探索策略', + '它不需要策略', + '它学习行为策略', + '它不能离线训练' + ], + answer: 0, + explanation: '离策略(off-policy)意味着学习的策略(目标策略)与产生经验的策略(行为策略)可以不同。' + } + ] + }, + { + id: 'rl-sarsa', + title: 'SARSA', + summary: '同策略 TD 学习,与 Q-Learning 对比', + theory: `## SARSA + +SARSA 是**同策略**(on-policy)的时序差分算法,名字来源于其使用的五元组 $(s, a, r, s', a')$。 + +### Q 值更新 + +$$Q(s, a) \\leftarrow Q(s, a) + \\alpha [r + \\gamma Q(s', a') - Q(s, a)]$$ + +与 Q-Learning 的关键区别:SARSA 使用**实际选择的下一个动作** $a'$,而不是 max 操作。 + +### 与 Q-Learning 的对比 + +| 特性 | Q-Learning | SARSA | +|------|-----------|-------| +| 策略类型 | 离策略 | 同策略 | +| TD 目标 | $r + \\gamma \\max_{a'} Q(s', a')$ | $r + \\gamma Q(s', a')$ | +| 学习目标 | 最优策略 $Q^*$ | 行为策略 $Q^\\pi$ | +| 收敛性 | 收敛到 $Q^*$ | 收敛到 $Q^\\pi$ | +| 风险偏好 | 激进(过估计风险) | 保守(考虑探索代价) | + +### 同策略 vs 离策略 + +- **同策略**: 学习的策略就是行为策略。SARSA 评估和改进的是当前正在执行的策略。 +- **离策略**: 学习的策略与行为策略不同。Q-Learning 用 $\\epsilon$-greedy 采样,但学习的是贪婪策略。 + +### Expected SARSA + +Expected SARSA 是 SARSA 的改进,使用期望替代采样: + +$$Q(s, a) \\leftarrow Q(s, a) + \\alpha [r + \\gamma \\sum_{a'} \\pi(a' \\mid s') Q(s', a') - Q(s, a)]$$ + +比 SARSA 更稳定,减少了采样方差。 +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `class SARSA { +public: + int n_states, n_actions; + vector> Q; + double alpha, gamma, epsilon; + + SARSA(int nS, int nA, double alpha = 0.1, + double gamma = 0.99, double epsilon = 0.1) + : n_states(nS), n_actions(nA), + alpha(alpha), gamma(gamma), epsilon(epsilon) { + Q.resize(nS, vector(nA, 0.0)); + } + + int choose_action(int s) { + if ((double)rand() / RAND_MAX < epsilon) { + return rand() % n_actions; + } + return argmax(Q[s]); + } + + void update(int s, int a, double r, int s_prime, int a_prime, bool done) { + double td_target = r; + if (!done) { + td_target += gamma * Q[s_prime][a_prime]; + } + double td_error = td_target - Q[s][a]; + Q[s][a] += alpha * td_error; + } + + void decay_epsilon(double min_epsilon = 0.01, double decay = 0.995) { + epsilon = max(min_epsilon, epsilon * decay); + } + +private: + int argmax(const vector& v) { + return max_element(v.begin(), v.end()) - v.begin(); + } +}; + +// Expected SARSA +class ExpectedSARSA : public SARSA { +public: + ExpectedSARSA(int nS, int nA, double alpha = 0.1, + double gamma = 0.99, double epsilon = 0.1) + : SARSA(nS, nA, alpha, gamma, epsilon) {} + + void update_expected(int s, int a, double r, int s_prime, bool done) { + double expected_q = 0.0; + if (!done) { + // 计算期望 Q 值 + int best_a = argmax(Q[s_prime]); + for (int a_prime = 0; a_prime < n_actions; a_prime++) { + double prob = (a_prime == best_a) ? + (1.0 - epsilon + epsilon / n_actions) : + (epsilon / n_actions); + expected_q += prob * Q[s_prime][a_prime]; + } + } + double td_target = r + gamma * expected_q; + double td_error = td_target - Q[s][a]; + Q[s][a] += alpha * td_error; + } +};`, + python: `import numpy as np + +class SARSA: + def __init__(self, n_states, n_actions, alpha=0.1, + gamma=0.99, epsilon=0.1): + self.n_states = n_states + self.n_actions = n_actions + self.alpha = alpha + self.gamma = gamma + self.epsilon = epsilon + self.Q = np.zeros((n_states, n_actions)) + + def choose_action(self, s): + if np.random.random() < self.epsilon: + return np.random.randint(self.n_actions) + return np.argmax(self.Q[s]) + + def update(self, s, a, r, s_prime, a_prime, done): + td_target = r + if not done: + td_target += self.gamma * self.Q[s_prime, a_prime] + td_error = td_target - self.Q[s, a] + self.Q[s, a] += self.alpha * td_error + + def decay_epsilon(self, min_epsilon=0.01, decay=0.995): + self.epsilon = max(min_epsilon, self.epsilon * decay) + +class ExpectedSARSA(SARSA): + def update_expected(self, s, a, r, s_prime, done): + expected_q = 0.0 + if not done: + best_a = np.argmax(self.Q[s_prime]) + for a_prime in range(self.n_actions): + prob = (1.0 - self.epsilon + self.epsilon / self.n_actions + if a_prime == best_a + else self.epsilon / self.n_actions) + expected_q += prob * self.Q[s_prime, a_prime] + td_target = r + self.gamma * expected_q + td_error = td_target - self.Q[s, a] + self.Q[s, a] += self.alpha * td_error` + }, + variablesSnapshot: { + tuple: '(s, a, r, s\', a\')', + td_target: 'r + gamma * Q(s\', a\')', + policy_type: 'on-policy', + epsilon: '0.1' + }, + pseudocode: `procedure SARSA(env, n_episodes) + Initialize Q(s, a) arbitrarily + for episode <- 1 to n_episodes do + s <- env.reset() + a <- epsilon_greedy(Q, s, epsilon) + repeat + s', r, done <- env.step(a) + a' <- epsilon_greedy(Q, s', epsilon) + Q(s, a) <- Q(s, a) + alpha * [r + gamma * Q(s', a') - Q(s, a)] + s <- s' + a <- a' + until done + end for + return Q + +procedure EXPECTED_SARSA_UPDATE(Q, s, a, r, s', epsilon) + expected_Q <- sum_{a'} pi(a'|s') * Q(s', a') + Q(s, a) <- Q(s, a) + alpha * [r + gamma * expected_Q - Q(s, a)]`, + bigO: { + time: '每个时间步 O(1)(查表更新)。Expected SARSA 需要 O(|A|) 计算期望。总训练时间与 Q-Learning 相当。', + space: '存储 Q 表需要 O(|S| × |A|),与 Q-Learning 相同。', + note: 'SARSA 是 on-policy 算法,学习的是行为策略(包括探索部分),因此更保守但更安全。' + }, + compare: [ + { method: 'SARSA', data: '同策略 TD(0)', strength: '更保守,考虑探索风险', tradeoff: '不直接学习最优策略' }, + { method: 'Q-Learning', data: '离策略 TD(0)', strength: '直接学习最优策略', tradeoff: '可能过估计 Q 值' }, + { method: 'Expected SARSA', data: '期望 TD(0)', strength: '方差更低,更稳定', tradeoff: '每步需要计算期望,稍慢' } + ], + quiz: [ + { + q: 'SARSA 名字中的五个字母分别代表什么?', + options: [ + '状态 s、动作 a、奖励 r、下一状态 s\'、下一动作 a\'', + '五个随机变量', + '五种算法', + '五个超参数' + ], + answer: 0, + explanation: 'SARSA 代表 State-Action-Reward-State-Action,即更新使用的五元组 (s, a, r, s\', a\')。' + }, + { + q: '在悬崖行走问题中,为什么 SARSA 通常表现比 Q-Learning 更安全?', + options: [ + '因为 SARSA 考虑了探索的风险,会选择远离悬崖的路径', + '因为 SARSA 奖励更多', + '因为 SARSA 不需要探索', + '因为 SARSA 学习率更大' + ], + answer: 0, + explanation: 'SARSA 是同策略算法,学习的是包含 ε-探索的策略,因此会避免那些虽然收益高但探索时容易掉悬崖的动作。' + } + ] + }, + { + id: 'rl-experience-replay', + title: '经验回放', + summary: '回放缓冲区、随机采样、打破样本相关性', + theory: `## 经验回放 (Experience Replay) + +经验回放是深度强化学习的关键技术,通过存储和重用经验打破样本之间的相关性。 + +### 基本思想 + +强化学习的样本是连续产生的,相邻样本高度相关。直接用连续样本训练神经网络会导致: +- 梯度估计偏差大 +- 训练不稳定 +- 样本效率低 + +经验回放通过以下方式解决这些问题: +1. 将经验 $(s, a, r, s', done)$ 存储在回放缓冲区中 +2. 训练时从缓冲区**随机采样** mini-batch +3. 多次重用经验,提高样本效率 + +### 回放缓冲区 (Replay Buffer) + +通常使用循环缓冲区(队列)实现,固定容量,满了之后覆盖最旧的经验: + +$$D = \\{e_1, e_2, \\ldots, e_N\\}$$ + +其中 $N$ 是缓冲区大小,通常 $10^4 \\sim 10^6$。 + +### 采样策略 + +- **均匀采样**: 每个经验被采样的概率相同 +- **优先经验回放 (PER)**: 根据 TD 误差大小采样,误差大的经验更可能被采样 + +### 优势 + +1. **打破相关性**: 随机采样使样本独立同分布 +2. **提高样本效率**: 每个经验可被多次使用 +3. **减少遗忘**: 旧经验可以防止灾难性遗忘 +4. **稳定训练**: 减少梯度方差 + +### 与 off-policy 的关系 + +经验回放天然适合 off-policy 算法(如 Q-Learning、DQN),因为可以重用任何行为策略产生的经验。 +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `#include +#include + +struct Experience { + int s, a, s_prime; + double r; + bool done; +}; + +class ReplayBuffer { +public: + ReplayBuffer(int capacity = 10000) : capacity(capacity) {} + + void push(const Experience& exp) { + if (buffer.size() >= capacity) { + buffer.pop_front(); + } + buffer.push_back(exp); + } + + vector sample(int batch_size) { + vector batch; + batch.reserve(batch_size); + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dist(0, buffer.size() - 1); + + for (int i = 0; i < batch_size; i++) { + batch.push_back(buffer[dist(gen)]); + } + return batch; + } + + size_t size() const { return buffer.size(); } + +private: + std::deque buffer; + int capacity; +}; + +// 优先经验回放 (PER) +class PrioritizedReplayBuffer : public ReplayBuffer { +public: + PrioritizedReplayBuffer(int capacity = 10000, + double alpha = 0.6, + double beta = 0.4) + : ReplayBuffer(capacity), alpha(alpha), beta(beta) { + priorities.resize(capacity, 1.0); + } + + void push(const Experience& exp) { + double max_priority = *max_element(priorities.begin(), priorities.end()); + priorities[buffer.size()] = max_priority; + ReplayBuffer::push(exp); + } + + vector sample(int batch_size) { + vector probs(buffer.size()); + for (size_t i = 0; i < buffer.size(); i++) { + probs[i] = pow(priorities[i], alpha); + } + double sum = accumulate(probs.begin(), probs.end(), 0.0); + for (auto& p : probs) p /= sum; + + std::random_device rd; + std::mt19937 gen(rd()); + std::discrete_distribution<> dist(probs.begin(), probs.end()); + + vector batch; + batch.reserve(batch_size); + for (int i = 0; i < batch_size; i++) { + batch.push_back(buffer[dist(gen)]); + } + return batch; + } + + void update_priorities(const vector& indices, + const vector& td_errors) { + for (size_t i = 0; i < indices.size(); i++) { + priorities[indices[i]] = abs(td_errors[i]) + 1e-6; + } + } + +private: + vector priorities; + double alpha, beta; +};`, + python: `import numpy as np +from collections import deque +import random + +class ReplayBuffer: + def __init__(self, capacity=10000): + self.buffer = deque(maxlen=capacity) + + def push(self, s, a, r, s_prime, done): + self.buffer.append((s, a, r, s_prime, done)) + + def sample(self, batch_size): + batch = random.sample(self.buffer, batch_size) + s, a, r, s_prime, done = zip(*batch) + return (np.array(s), np.array(a), np.array(r), + np.array(s_prime), np.array(done)) + + def __len__(self): + return len(self.buffer) + +class PrioritizedReplayBuffer: + def __init__(self, capacity=10000, alpha=0.6, beta=0.4): + self.buffer = deque(maxlen=capacity) + self.priorities = deque(maxlen=capacity) + self.alpha = alpha + self.beta = beta + self.max_priority = 1.0 + + def push(self, s, a, r, s_prime, done): + self.buffer.append((s, a, r, s_prime, done)) + self.priorities.append(self.max_priority) + + def sample(self, batch_size): + priorities = np.array(self.priorities) + probs = priorities ** self.alpha + probs /= probs.sum() + + indices = np.random.choice(len(self.buffer), batch_size, p=probs) + batch = [self.buffer[i] for i in indices] + s, a, r, s_prime, done = zip(*batch) + + # 计算重要性采样权重 + weights = (len(self.buffer) * probs[indices]) ** (-self.beta) + weights /= weights.max() + + return (np.array(s), np.array(a), np.array(r), + np.array(s_prime), np.array(done), indices, weights) + + def update_priorities(self, indices, td_errors): + for idx, error in zip(indices, td_errors): + self.priorities[idx] = abs(error) + 1e-6 + self.max_priority = max(self.max_priority, self.priorities[idx]) + + def __len__(self): + return len(self.buffer)` + }, + variablesSnapshot: { + capacity: '10000', + batch_size: '32', + current_size: '4521', + sampling: 'uniform' + }, + pseudocode: `procedure EXPERIENCE_REPLAY(env, agent, buffer, n_episodes) + for episode <- 1 to n_episodes do + s <- env.reset() + repeat + a <- agent.act(s) + s', r, done <- env.step(a) + buffer.push(s, a, r, s', done) + s <- s' + + if buffer.size() >= batch_size then + batch <- buffer.sample(batch_size) + agent.update(batch) + end if + until done + end for + +procedure PER_SAMPLE(buffer, batch_size, alpha) + priorities <- [p^alpha for p in buffer.priorities] + probs <- priorities / sum(priorities) + indices <- sample_from(probs, batch_size) + return indices, [buffer[i] for i in indices]`, + bigO: { + time: 'push 操作 O(1),sample 操作 O(N) 计算概率(N 为缓冲区大小)或 O(1) 均匀采样。训练时 mini-batch 更新 O(B)。', + space: '存储缓冲区需要 O(N × D),其中 D 是单个经验的维度(状态 + 动作 + 奖励 + 下一状态 + 标志)。', + note: '经验回放是 DQN 等深度强化学习算法的核心组件,使神经网络训练更稳定。' + }, + compare: [ + { method: '均匀回放', data: '随机采样', strength: '实现简单,无偏', tradeoff: '重要经验可能被忽略' }, + { method: '优先回放 (PER)', data: '按 TD 误差采样', strength: '关注难学的经验,收敛更快', tradeoff: '有偏,需要重要性采样修正' }, + { method: '在线学习', data: '不用缓冲区', strength: '无内存开销', tradeoff: '样本相关性高,训练不稳定' } + ], + quiz: [ + { + q: '经验回放的主要目的是什么?', + options: [ + '打破样本之间的相关性,提高样本效率', + '增加内存使用', + '减少训练时间', + '提高奖励' + ], + answer: 0, + explanation: '连续产生的经验高度相关,直接训练神经网络会导致不稳定。经验回放通过随机采样使样本更独立,同时允许重复使用经验。' + }, + { + q: '优先经验回放中,什么样的经验更可能被采样?', + options: [ + 'TD 误差大的经验("意外"的经验)', + '奖励高的经验', + '最近的经验', + '随机经验' + ], + answer: 0, + explanation: 'PER 根据 TD 误差的大小分配采样概率,误差大意味着当前 Q 函数预测不准,需要更多学习。' + } + ] + }, + { + id: 'rl-dqn', + title: '深度 Q 网络 (DQN)', + summary: '神经网络近似 Q 函数、目标网络、Double DQN', + theory: `## 深度 Q 网络 (DQN) + +DQN 将 Q-Learning 与深度神经网络结合,解决大规模状态空间的问题。 + +### 核心创新 + +1. **经验回放**: 打破样本相关性 +2. **目标网络**: 稳定训练目标 + +### Q 网络 + +用神经网络 $Q(s, a; \\theta)$ 近似 Q 函数,参数为 $\\theta$。 + +### 损失函数 + +$$L(\\theta) = \\mathbb{E}_{(s, a, r, s') \\sim D} [(y - Q(s, a; \\theta))^2]$$ + +其中目标 $y$ 使用目标网络计算: + +$$y = r + \\gamma \\max_{a'} Q(s', a'; \\theta^-)$$ + +### 目标网络 + +目标网络 $\\theta^-$ 是主网络的定期副本,每 $C$ 步复制一次: + +$$\\theta^- \\leftarrow \\theta$$ + +目标网络使 TD 目标在一段时间内保持固定,大幅减少训练不稳定性。 + +### Double DQN + +标准 DQN 存在 Q 值过估计问题。Double DQN 通过解耦动作选择和目标计算来缓解: + +$$y = r + \\gamma Q(s', \\arg\\max_{a'} Q(s', a'; \\theta); \\theta^-)$$ + +- 用主网络 $\\theta$ 选择最优动作 +- 用目标网络 $\\theta^-$ 评估该动作的 Q 值 + +### Dueling DQN + +将 Q 网络分解为状态值函数 $V(s)$ 和优势函数 $A(s, a)$: + +$$Q(s, a) = V(s) + A(s, a) - \\frac{1}{|A|} \\sum_{a'} A(s, a')$$ + +可以更高效地学习状态值。 +`, + exercise: { type: 'playground', viz: 'qlearning' }, + code: { + cpp: `#include + +struct DQN : torch::nn::Module { + torch::nn::Linear fc1, fc2, fc3; + + DQN(int state_dim, int action_dim) + : fc1(register_module("fc1", torch::nn::Linear(state_dim, 128))), + fc2(register_module("fc2", torch::nn::Linear(128, 128))), + fc3(register_module("fc3", torch::nn::Linear(128, action_dim))) {} + + torch::Tensor forward(torch::Tensor x) { + x = torch::relu(fc1->forward(x)); + x = torch::relu(fc2->forward(x)); + return fc3->forward(x); + } +}; + +class DQNAgent { +public: + DQNAgent(int state_dim, int action_dim, + double lr = 1e-3, double gamma = 0.99, + double epsilon = 1.0, double epsilon_min = 0.01, + double epsilon_decay = 0.995, int target_update = 100) + : gamma(gamma), epsilon(epsilon), epsilon_min(epsilon_min), + epsilon_decay(epsilon_decay), target_update(target_update), + action_dim(action_dim), steps(0) { + + policy_net = std::make_shared(state_dim, action_dim); + target_net = std::make_shared(state_dim, action_dim); + target_net->load_state_dict(policy_net->state_dict()); + + optimizer = std::make_shared( + policy_net->parameters(), torch::optim::AdamOptions(lr)); + + buffer = std::make_shared(); + } + + int act(torch::Tensor state) { + if ((double)rand() / RAND_MAX < epsilon) { + return rand() % action_dim; + } + torch::NoGradGuard no_grad; + auto q_values = policy_net->forward(state); + return q_values.argmax(1).item(); + } + + void update(int batch_size = 32) { + if (buffer->size() < batch_size) return; + + auto batch = buffer->sample(batch_size); + auto states = torch::tensor(/* ... */); + auto actions = torch::tensor(/* ... */); + auto rewards = torch::tensor(/* ... */); + auto next_states = torch::tensor(/* ... */); + auto dones = torch::tensor(/* ... */); + + auto current_q = policy_net->forward(states).gather(1, actions.unsqueeze(1)); + + // Double DQN: 用主网络选动作,目标网络评估 + auto next_actions = policy_net->forward(next_states).argmax(1, true); + auto max_next_q = target_net->forward(next_states).gather(1, next_actions).squeeze(1); + + auto target_q = rewards + gamma * max_next_q * (1 - dones); + + auto loss = torch::mse_loss(current_q.squeeze(1), target_q.detach()); + + optimizer->zero_grad(); + loss.backward(); + optimizer->step(); + + steps++; + if (steps % target_update == 0) { + target_net->load_state_dict(policy_net->state_dict()); + } + + epsilon = max(epsilon_min, epsilon * epsilon_decay); + } + +private: + std::shared_ptr policy_net, target_net; + std::shared_ptr optimizer; + std::shared_ptr buffer; + double gamma, epsilon, epsilon_min, epsilon_decay; + int target_update, action_dim, steps; +};`, + python: `import torch +import torch.nn as nn +import torch.optim as optim +import numpy as np +from collections import deque +import random + +class DQN(nn.Module): + def __init__(self, state_dim, action_dim): + super().__init__() + self.fc1 = nn.Linear(state_dim, 128) + self.fc2 = nn.Linear(128, 128) + self.fc3 = nn.Linear(128, action_dim) + + def forward(self, x): + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + return self.fc3(x) + +class DQNAgent: + def __init__(self, state_dim, action_dim, + lr=1e-3, gamma=0.99, + epsilon=1.0, epsilon_min=0.01, + epsilon_decay=0.995, target_update=100): + self.gamma = gamma + self.epsilon = epsilon + self.epsilon_min = epsilon_min + self.epsilon_decay = epsilon_decay + self.target_update = target_update + self.action_dim = action_dim + self.steps = 0 + + self.policy_net = DQN(state_dim, action_dim) + self.target_net = DQN(state_dim, action_dim) + self.target_net.load_state_dict(self.policy_net.state_dict()) + + self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr) + self.buffer = ReplayBuffer() + + def act(self, state): + if random.random() < self.epsilon: + return random.randrange(self.action_dim) + with torch.no_grad(): + state = torch.FloatTensor(state).unsqueeze(0) + q_values = self.policy_net(state) + return q_values.argmax().item() + + def update(self, batch_size=32): + if len(self.buffer) < batch_size: + return + + states, actions, rewards, next_states, dones = self.buffer.sample(batch_size) + states = torch.FloatTensor(states) + actions = torch.LongTensor(actions) + rewards = torch.FloatTensor(rewards) + next_states = torch.FloatTensor(next_states) + dones = torch.FloatTensor(dones) + + current_q = self.policy_net(states).gather(1, actions.unsqueeze(1)) + + # Double DQN + next_actions = self.policy_net(next_states).argmax(1, keepdim=True) + max_next_q = self.target_net(next_states).gather(1, next_actions).squeeze(1) + + target_q = rewards + self.gamma * max_next_q * (1 - dones) + + loss = nn.MSELoss()(current_q.squeeze(1), target_q.detach()) + + self.optimizer.zero_grad() + loss.backward() + self.optimizer.step() + + self.steps += 1 + if self.steps % self.target_update == 0: + self.target_net.load_state_dict(self.policy_net.state_dict()) + + self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)` + }, + variablesSnapshot: { + target_update: '100 steps', + epsilon: '1.0 -> 0.01', + buffer_size: '10000', + batch_size: '32', + network: '3-layer MLP (128 hidden)' + }, + pseudocode: `procedure DQN(env, n_episodes) + Initialize policy_net Q with random weights theta + Initialize target_net Q^- with theta^- = theta + Initialize replay buffer D + + for episode <- 1 to n_episodes do + s <- env.reset() + repeat + a <- epsilon_greedy(Q(s; theta)) + s', r, done <- env.step(a) + D.store(s, a, r, s', done) + s <- s' + + // 训练 + if |D| >= batch_size then + batch <- D.sample(batch_size) + y <- r + gamma * Q^-(s', argmax_a' Q(s', a'; theta); theta^-) // Double DQN + loss <- (y - Q(s, a; theta))^2 + theta <- theta - alpha * grad(loss) + end if + + // 定期更新目标网络 + if steps % target_update == 0 then + theta^- <- theta + end if + until done + end for`, + bigO: { + time: '每个时间步:动作选择 O(1)(前向传播),经验存储 O(1)。训练更新:前向传播 O(B × |S| × H),反向传播 O(B × |S| × H),B 是 batch size,H 是网络规模。', + space: '存储两个网络 O(H),回放缓冲区 O(N × D)。总体 O(N × D + H)。', + note: 'DQN 成功将强化学习扩展到高维状态空间(如 Atari 游戏的像素输入)。' + }, + compare: [ + { method: 'DQN', data: '标准目标', strength: '简单有效', tradeoff: 'Q 值过估计' }, + { method: 'Double DQN', data: '解耦选择和评估', strength: '减少过估计', tradeoff: '实现稍复杂' }, + { method: 'Dueling DQN', data: '分离 V 和 A', strength: '更高效学习状态值', tradeoff: '需要修改网络结构' } + ], + quiz: [ + { + q: '为什么 DQN 需要目标网络?', + options: [ + '使训练目标在一段时间内保持固定,减少不稳定性', + '增加模型容量', + '减少计算量', + '不需要目标网络' + ], + answer: 0, + explanation: '如果只用一个网络,每次更新都会改变目标值,导致训练不稳定。目标网络提供固定的学习目标,定期同步。' + }, + { + q: 'Double DQN 如何解决过估计问题?', + options: [ + '用主网络选择动作,目标网络评估该动作的 Q 值', + '使用更大的网络', + '减少奖励', + '增加探索率' + ], + answer: 0, + explanation: '标准 DQN 用同一个网络既选动作又评估,容易选到过估计的动作。Double DQN 解耦了这两个步骤,减少过估计偏差。' + } + ] + }, + { + id: 'rl-policy-gradient', + title: '策略梯度', + summary: 'REINFORCE 算法、基线、方差缩减', + theory: `## 策略梯度 (Policy Gradient) + +策略梯度方法直接参数化策略 $\\pi_\\theta(a \\mid s)$,通过梯度上升最大化期望回报。 + +### 策略目标 + +$$J(\\theta) = \\mathbb{E}_{\\tau \\sim \\pi_\\theta}[R(\\tau)] = \\sum_\\tau P(\\tau \\mid \\theta) R(\\tau)$$ + +其中 $\\tau = (s_0, a_0, r_1, s_1, \\ldots)$ 是轨迹,$R(\\tau) = \\sum_t r_t$ 是轨迹回报。 + +### 策略梯度定理 + +$$\\nabla_\\theta J(\\theta) = \\mathbb{E}_{\\tau \\sim \\pi_\\theta} \\left[ \\sum_t \\nabla_\\theta \\log \\pi_\\theta(a_t \\mid s_t) \\cdot G_t \\right]$$ + +其中 $G_t$ 是从时刻 $t$ 开始的回报。 + +### REINFORCE 算法 + +使用蒙特卡洛采样估计梯度: + +$$\\nabla_\\theta J(\\theta) \\approx \\frac{1}{N} \\sum_{i=1}^N \\sum_t \\nabla_\\theta \\log \\pi_\\theta(a_t^i \\mid s_t^i) \\cdot G_t^i$$ + +### 基线 (Baseline) + +为了减少方差,引入基线 $b(s)$: + +$$\\nabla_\\theta J(\\theta) = \\mathbb{E} \\left[ \\sum_t \\nabla_\\theta \\log \\pi_\\theta(a_t \\mid s_t) \\cdot (G_t - b(s_t)) \\right]$$ + +基线不改变梯度的期望,但可以大幅降低方差。常用基线: +- 常数基线:$b = \\bar{G}$(平均回报) +- 状态值函数:$b(s) = V^\\pi(s)$ + +### 优势函数 + +$$A^\\pi(s, a) = Q^\\pi(s, a) - V^\\pi(s)$$ + +优势函数衡量动作 $a$ 相对于平均水平的好坏,是更好的策略梯度权重。 + +### 与值函数方法的对比 + +| 特性 | 值函数方法 (Q-Learning) | 策略梯度方法 | +|------|------------------------|-------------| +| 优化对象 | Q 值 | 策略参数 | +| 策略 | 隐式(从 Q 导出) | 显式参数化 | +| 动作空间 | 离散 | 离散或连续 | +| 收敛性 | 可能震荡 | 更平滑收敛 | +| 样本效率 | 高(off-policy) | 低(on-policy) | `, - exercise: { type: 'playground', viz: 'qlearning' }, - }, - { - id: 'rl-policy-gradient', - title: '策略梯度', - summary: 'REINFORCE 算法、Actor-Critic', - theory: `## 策略梯度 + exercise: { type: 'playground', viz: 'policyGradient' }, + code: { + cpp: `#include + +struct PolicyNetwork : torch::nn::Module { + torch::nn::Linear fc1, fc2, fc3; + + PolicyNetwork(int state_dim, int action_dim) + : fc1(register_module("fc1", torch::nn::Linear(state_dim, 128))), + fc2(register_module("fc2", torch::nn::Linear(128, 128))), + fc3(register_module("fc3", torch::nn::Linear(128, action_dim))) {} + + torch::Tensor forward(torch::Tensor x) { + x = torch::relu(fc1->forward(x)); + x = torch::relu(fc2->forward(x)); + return torch::softmax(fc3->forward(x), /*dim=*/1); + } +}; + +class REINFORCE { +public: + REINFORCE(int state_dim, int action_dim, double lr = 1e-3, + double gamma = 0.99, bool use_baseline = true) + : gamma(gamma), use_baseline(use_baseline), action_dim(action_dim) { + policy = std::make_shared(state_dim, action_dim); + optimizer = std::make_shared( + policy->parameters(), torch::optim::AdamOptions(lr)); + + if (use_baseline) { + value_net = std::make_shared(state_dim); + value_optimizer = std::make_shared( + value_net->parameters(), torch::optim::AdamOptions(lr)); + } + } + + int act(torch::Tensor state) { + torch::NoGradGuard no_grad; + auto probs = policy->forward(state); + auto dist = torch::multinomial(probs, 1); + return dist.item(); + } + + void update(const vector& states, + const vector& actions, + const vector& rewards) { + // 计算回报 G_t + vector returns(rewards.size()); + double G = 0.0; + for (int t = rewards.size() - 1; t >= 0; t--) { + G = rewards[t] + gamma * G; + returns[t] = G; + } + + auto returns_tensor = torch::tensor(returns); + + // 归一化回报(可选,减少方差) + returns_tensor = (returns_tensor - returns_tensor.mean()) / + (returns_tensor.std() + 1e-8); + + // 计算策略损失 + auto policy_loss = torch::tensor(0.0); + for (int t = 0; t < states.size(); t++) { + auto probs = policy->forward(states[t]); + auto log_prob = torch::log(probs[0][actions[t]]); + double baseline = 0.0; + if (use_baseline) { + baseline = value_net->forward(states[t]).item(); + } + auto advantage = returns_tensor[t].item() - baseline; + policy_loss = policy_loss - log_prob * advantage; + } + + optimizer->zero_grad(); + policy_loss.backward(); + optimizer->step(); + + // 更新值函数(基线) + if (use_baseline) { + auto value_loss = torch::tensor(0.0); + for (int t = 0; t < states.size(); t++) { + auto value = value_net->forward(states[t]); + value_loss = value_loss + torch::mse_loss(value, returns_tensor[t]); + } + value_optimizer->zero_grad(); + value_loss.backward(); + value_optimizer->step(); + } + } + +private: + std::shared_ptr policy; + std::shared_ptr value_net; // 简化,实际应为 ValueNetwork + std::shared_ptr optimizer, value_optimizer; + double gamma; + bool use_baseline; + int action_dim; +};`, + python: `import torch +import torch.nn as nn +import torch.optim as optim +import numpy as np + +class PolicyNetwork(nn.Module): + def __init__(self, state_dim, action_dim): + super().__init__() + self.fc1 = nn.Linear(state_dim, 128) + self.fc2 = nn.Linear(128, 128) + self.fc3 = nn.Linear(128, action_dim) + + def forward(self, x): + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + return torch.softmax(self.fc3(x), dim=-1) + +class ValueNetwork(nn.Module): + def __init__(self, state_dim): + super().__init__() + self.fc1 = nn.Linear(state_dim, 128) + self.fc2 = nn.Linear(128, 128) + self.fc3 = nn.Linear(128, 1) + + def forward(self, x): + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + return self.fc3(x) + +class REINFORCE: + def __init__(self, state_dim, action_dim, lr=1e-3, + gamma=0.99, use_baseline=True): + self.gamma = gamma + self.use_baseline = use_baseline + self.action_dim = action_dim + + self.policy = PolicyNetwork(state_dim, action_dim) + self.optimizer = optim.Adam(self.policy.parameters(), lr=lr) + + if use_baseline: + self.value_net = ValueNetwork(state_dim) + self.value_optimizer = optim.Adam(self.value_net.parameters(), lr=lr) + + def act(self, state): + state = torch.FloatTensor(state).unsqueeze(0) + probs = self.policy(state) + dist = torch.distributions.Categorical(probs) + action = dist.sample() + return action.item() + + def update(self, states, actions, rewards): + # 计算回报 G_t + returns = [] + G = 0 + for r in reversed(rewards): + G = r + self.gamma * G + returns.insert(0, G) + returns = torch.FloatTensor(returns) + + # 归一化 + returns = (returns - returns.mean()) / (returns.std() + 1e-8) + + # 策略损失 + policy_loss = [] + for t, (state, action) in enumerate(zip(states, actions)): + state = torch.FloatTensor(state).unsqueeze(0) + probs = self.policy(state) + log_prob = torch.log(probs[0, action]) + baseline = self.value_net(state).item() if self.use_baseline else 0 + advantage = returns[t] - baseline + policy_loss.append(-log_prob * advantage) + + policy_loss = torch.stack(policy_loss).sum() + self.optimizer.zero_grad() + policy_loss.backward() + self.optimizer.step() + + # 更新值函数 + if self.use_baseline: + value_loss = [] + for t, state in enumerate(states): + state = torch.FloatTensor(state).unsqueeze(0) + value = self.value_net(state) + value_loss.append(nn.MSELoss()(value, returns[t])) + value_loss = torch.stack(value_loss).sum() + self.value_optimizer.zero_grad() + value_loss.backward() + self.value_optimizer.step()` + }, + variablesSnapshot: { + gamma: '0.99', + use_baseline: 'True', + policy_type: 'stochastic (softmax)', + gradient_estimator: 'Monte Carlo' + }, + pseudocode: `procedure REINFORCE(env, n_episodes) + Initialize policy pi_theta with random weights + for episode <- 1 to n_episodes do + // 收集轨迹 + trajectory <- [] + s <- env.reset() + repeat + a <- sample from pi_theta(a|s) + s', r, done <- env.step(a) + trajectory.append(s, a, r) + s <- s' + until done + + // 计算回报 + for t from T-1 down to 0 do + G_t <- r_t + gamma * G_{t+1} + end for + + // 计算梯度并更新 + for t from 0 to T-1 do + b <- V(s_t) // 基线 + theta <- theta + alpha * grad(log pi_theta(a_t|s_t)) * (G_t - b) + end for + end for + return theta`, + bigO: { + time: '每步采样 O(1)(前向传播)。每个 episode 结束后更新:前向 O(T × H),反向 O(T × H),T 是轨迹长度,H 是网络规模。', + space: '存储策略网络 O(H),值函数网络 O(H),轨迹存储 O(T × D)。', + note: 'REINFORCE 是 on-policy 算法,需要当前策略产生的轨迹。样本效率较低,适合连续动作空间。' + }, + compare: [ + { method: 'REINFORCE', data: 'MC 策略梯度', strength: '无偏估计,实现简单', tradeoff: '方差大,样本效率低' }, + { method: 'Actor-Critic', data: 'TD 策略梯度', strength: '方差小,可在线学习', tradeoff: '引入偏差(值函数近似)' }, + { method: 'PPO', data: '裁剪目标', strength: '稳定训练,样本效率高', tradeoff: '实现稍复杂' } + ], + quiz: [ + { + q: '策略梯度定理中,为什么要用 log 概率乘以回报?', + options: [ + '因为 log 概率的梯度给出策略参数的更新方向,回报加权增加高回报轨迹的权重', + '因为 log 使计算更快', + '因为回报必须为正', + '没有特殊原因' + ], + answer: 0, + explanation: '∇log π(a|s) 是 score function,指向增加动作 a 概率的方向。乘以 G_t 使高回报轨迹的方向更受重视。' + }, + { + q: '基线的作用是什么?', + options: [ + '减少梯度估计的方差,而不改变期望', + '增加奖励', + '减少探索', + '增加模型容量' + ], + answer: 0, + explanation: 'E[∇log π · (G - b)] = E[∇log π · G],因为 E[∇log π · b] = 0。但选择合适的 b(如 V(s))可以大幅减少方差。' + } + ] + }, + { + id: 'rl-actor-critic', + title: 'Actor-Critic', + summary: '优势函数、A2C、PPO 算法', + theory: `## Actor-Critic + +Actor-Critic 结合了值函数方法和策略梯度方法的优点: +- **Actor (演员)**: 参数化策略 $\\pi_\\theta(a \\mid s)$,负责选动作 +- **Critic (评论家)**: 估计值函数 $V_\\phi(s)$,评估动作好坏 + +### 优势函数 + +$$A_t = Q(s_t, a_t) - V(s_t)$$ + +在实践中,常用 TD 误差作为优势函数的估计: + +$$A_t \\approx r_t + \\gamma V(s_{t+1}) - V(s_t)$$ + +### 单步 Actor-Critic 更新 + +**Actor 更新**(策略参数): + +$$\\theta \\leftarrow \\theta + \\alpha \\nabla_\\theta \\log \\pi_\\theta(a_t \\mid s_t) \\cdot A_t$$ + +**Critic 更新**(值函数参数): + +$$\\phi \\leftarrow \\phi + \\alpha (r_t + \\gamma V_\\phi(s_{t+1}) - V_\\phi(s_t)) \\nabla_\\phi V_\\phi(s_t)$$ + +### A2C (Advantage Actor-Critic) + +A2C 使用多个并行环境收集经验,同步更新: + +$$\\nabla_\\theta J(\\theta) = \\frac{1}{N} \\sum_{i=1}^N \\sum_t \\nabla_\\theta \\log \\pi_\\theta(a_t^i \\mid s_t^i) \\cdot A_t^i$$ + +相比 A3C(异步),A2C 更简单且在 GPU 上更高效。 + +### PPO (Proximal Policy Optimization) -直接优化策略函数,而非值函数。 +PPO 通过裁剪目标函数限制策略更新幅度,防止步长过大: -### REINFORCE +$$L^{CLIP}(\\theta) = \\hat{\\mathbb{E}}_t \\left[ \\min \\left( r_t(\\theta) \\hat{A}_t, \\text{clip}(r_t(\\theta), 1-\\epsilon, 1+\\epsilon) \\hat{A}_t \\right) \\right]$$ -$$\\nabla J(\\theta) = \\mathbb{E}[\\nabla \\log \\pi_\\theta(a|s) \\cdot G_t]$$ +其中 $r_t(\\theta) = \\frac{\\pi_\\theta(a_t \\mid s_t)}{\\pi_{\\theta_{old}}(a_t \\mid s_t)}$ 是重要性采样比率。 + +### PPO 算法流程 + +1. 用当前策略收集一批轨迹 +2. 计算优势函数 $\\hat{A}_t$ +3. 多轮 SGD 最大化裁剪目标 +4. 更新策略,丢弃旧数据 + +### 与其他方法的对比 + +| 方法 | 稳定性 | 样本效率 | 实现难度 | +|------|--------|---------|---------| +| REINFORCE | 低(大方差) | 低 | 简单 | +| Actor-Critic | 中 | 中 | 中等 | +| PPO | 高 | 高 | 中等 | +| TRPO | 很高 | 高 | 复杂 | `, - exercise: { type: 'playground', viz: 'policyGradient' }, - }, + exercise: { type: 'playground', viz: 'policyGradient' }, + code: { + cpp: `#include + +class ActorCritic { +public: + ActorCritic(int state_dim, int action_dim, + double lr_actor = 1e-3, double lr_critic = 1e-3, + double gamma = 0.99) + : gamma(gamma), action_dim(action_dim) { + actor = std::make_shared(state_dim, action_dim); + critic = std::make_shared(state_dim); + actor_optimizer = std::make_shared( + actor->parameters(), torch::optim::AdamOptions(lr_actor)); + critic_optimizer = std::make_shared( + critic->parameters(), torch::optim::AdamOptions(lr_critic)); + } + + int act(torch::Tensor state) { + torch::NoGradGuard no_grad; + auto probs = actor->forward(state); + auto dist = torch::multinomial(probs, 1); + return dist.item(); + } + + void update(torch::Tensor state, int action, + double reward, torch::Tensor next_state, bool done) { + auto value = critic->forward(state); + auto next_value = critic->forward(next_state); + + // TD 目标和优势 + auto td_target = reward + gamma * next_value * (1 - done); + auto advantage = td_target - value; + + // Critic 更新 + auto critic_loss = torch::mse_loss(value, td_target.detach()); + critic_optimizer->zero_grad(); + critic_loss.backward(); + critic_optimizer->step(); + + // Actor 更新 + auto probs = actor->forward(state); + auto log_prob = torch::log(probs[0][action]); + auto actor_loss = -log_prob * advantage.detach(); + actor_optimizer->zero_grad(); + actor_loss.backward(); + actor_optimizer->step(); + } + +private: + std::shared_ptr actor, critic; // 简化 + std::shared_ptr actor_optimizer, critic_optimizer; + double gamma; + int action_dim; +}; + +// PPO +class PPO : public ActorCritic { +public: + PPO(int state_dim, int action_dim, + double lr = 3e-4, double gamma = 0.99, + double clip_epsilon = 0.2, int n_epochs = 4) + : ActorCritic(state_dim, action_dim, lr, lr, gamma), + clip_epsilon(clip_epsilon), n_epochs(n_epochs) {} + + void update_batch(const vector& states, + const vector& actions, + const vector& rewards, + const vector& next_states, + const vector& dones) { + // 计算优势函数(使用 GAE) + vector advantages = compute_gae(rewards, states, next_states, dones); + + for (int epoch = 0; epoch < n_epochs; epoch++) { + // 计算新的 log 概率 + auto new_log_probs = compute_log_probs(states, actions); + auto old_log_probs = /* ... 保存的旧值 */; + + // 重要性采样比率 + auto ratio = torch::exp(new_log_probs - old_log_probs); + auto adv_tensor = torch::tensor(advantages); + + // PPO 裁剪目标 + auto surr1 = ratio * adv_tensor; + auto surr2 = torch::clamp(ratio, 1 - clip_epsilon, + 1 + clip_epsilon) * adv_tensor; + auto actor_loss = -torch::min(surr1, surr2).mean(); + + // Critic 更新 + auto values = /* ... */; + auto returns = adv_tensor + values; + auto critic_loss = torch::mse_loss(values, returns.detach()); + + // 联合更新 + auto loss = actor_loss + 0.5 * critic_loss; + optimizer->zero_grad(); + loss.backward(); + optimizer->step(); + } + } + +private: + double clip_epsilon; + int n_epochs; + + vector compute_gae(/* ... */) { + // GAE (Generalized Advantage Estimation) + // ... + return {}; + } +};`, + python: `import torch +import torch.nn as nn +import torch.optim as optim +import numpy as np + +class ActorNetwork(nn.Module): + def __init__(self, state_dim, action_dim): + super().__init__() + self.fc1 = nn.Linear(state_dim, 128) + self.fc2 = nn.Linear(128, 128) + self.fc3 = nn.Linear(128, action_dim) + + def forward(self, x): + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + return torch.softmax(self.fc3(x), dim=-1) + +class CriticNetwork(nn.Module): + def __init__(self, state_dim): + super().__init__() + self.fc1 = nn.Linear(state_dim, 128) + self.fc2 = nn.Linear(128, 128) + self.fc3 = nn.Linear(128, 1) + + def forward(self, x): + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + return self.fc3(x) + +class ActorCritic: + def __init__(self, state_dim, action_dim, + lr_actor=1e-3, lr_critic=1e-3, gamma=0.99): + self.gamma = gamma + self.actor = ActorNetwork(state_dim, action_dim) + self.critic = CriticNetwork(state_dim) + self.actor_optimizer = optim.Adam(self.actor.parameters(), lr=lr_actor) + self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=lr_critic) + + def act(self, state): + state = torch.FloatTensor(state).unsqueeze(0) + probs = self.actor(state) + dist = torch.distributions.Categorical(probs) + action = dist.sample() + return action.item() + + def update(self, state, action, reward, next_state, done): + state = torch.FloatTensor(state).unsqueeze(0) + next_state = torch.FloatTensor(next_state).unsqueeze(0) + + value = self.critic(state) + next_value = self.critic(next_state) + + td_target = reward + self.gamma * next_value * (1 - done) + advantage = td_target - value + + # Critic 更新 + critic_loss = nn.MSELoss()(value, td_target.detach()) + self.critic_optimizer.zero_grad() + critic_loss.backward() + self.critic_optimizer.step() + + # Actor 更新 + probs = self.actor(state) + log_prob = torch.log(probs[0, action]) + actor_loss = -log_prob * advantage.detach() + self.actor_optimizer.zero_grad() + actor_loss.backward() + self.actor_optimizer.step() + +class PPO(ActorCritic): + def __init__(self, state_dim, action_dim, + lr=3e-4, gamma=0.99, + gae_lambda=0.95, clip_epsilon=0.2, + n_epochs=4, batch_size=64): + super().__init__(state_dim, action_dim, lr, lr, gamma) + self.gae_lambda = gae_lambda + self.clip_epsilon = clip_epsilon + self.n_epochs = n_epochs + self.batch_size = batch_size + + def compute_gae(self, rewards, values, dones): + advantages = [] + gae = 0 + for t in reversed(range(len(rewards))): + if t == len(rewards) - 1: + next_value = 0 + else: + next_value = values[t + 1] + delta = rewards[t] + self.gamma * next_value * (1 - dones[t]) - values[t] + gae = delta + self.gamma * self.gae_lambda * (1 - dones[t]) * gae + advantages.insert(0, gae) + return advantages + + def update(self, states, actions, rewards, next_states, dones): + states_t = torch.FloatTensor(states) + actions_t = torch.LongTensor(actions) + + # 计算值和优势 + with torch.no_grad(): + values = self.critic(states_t).squeeze() + advantages = self.compute_gae(rewards, values.numpy(), dones) + advantages = torch.FloatTensor(advantages) + advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8) + + # 保存旧 log 概率 + with torch.no_grad(): + old_probs = self.actor(states_t) + old_log_probs = torch.log(old_probs.gather(1, actions_t.unsqueeze(1)).squeeze(1)) + + # PPO 更新 + for _ in range(self.n_epochs): + # 新 log 概率 + probs = self.actor(states_t) + log_probs = torch.log(probs.gather(1, actions_t.unsqueeze(1)).squeeze(1)) + + # 重要性采样比率 + ratio = torch.exp(log_probs - old_log_probs) + + # 裁剪目标 + surr1 = ratio * advantages + surr2 = torch.clamp(ratio, 1 - self.clip_epsilon, + 1 + self.clip_epsilon) * advantages + actor_loss = -torch.min(surr1, surr2).mean() + + # Critic 损失 + values = self.critic(states_t).squeeze() + returns = advantages + values.detach() + critic_loss = nn.MSELoss()(values, returns) + + # 联合损失 + loss = actor_loss + 0.5 * critic_loss + + self.actor_optimizer.zero_grad() + self.critic_optimizer.zero_grad() + loss.backward() + self.actor_optimizer.step() + self.critic_optimizer.step()` + }, + variablesSnapshot: { + gamma: '0.99', + gae_lambda: '0.95', + clip_epsilon: '0.2', + n_epochs: '4', + advantage: 'TD error / GAE' + }, + pseudocode: `procedure ACTOR_CRITICAL_STEP(s, a, r, s', done) + V(s) <- critic(s) + V(s') <- critic(s') + A <- r + gamma * V(s') - V(s) + critic_loss <- (A)^2 + actor_loss <- -log pi(a|s) * A + update critic and actor + +procedure PPO_UPDATE(trajectories) + // 计算优势 + for each trajectory do + A_t <- compute_GAE(rewards, values) + end for + + // 多轮优化 + for epoch <- 1 to K do + ratio <- pi_theta(a|s) / pi_theta_old(a|s) + L_clip <- min(ratio * A, clip(ratio, 1-eps, 1+eps) * A) + maximize mean(L_clip) - 0.5 * critic_loss + end for`, + bigO: { + time: 'A2C 每步更新 O(H)(网络前向和反向)。PPO 每批数据更新 K 轮,每轮 O(B × H),B 是 batch size。', + space: '存储 Actor 和 Critic 网络各 O(H),轨迹数据 O(T × D)。', + note: 'PPO 是目前最实用的策略梯度算法,平衡了稳定性和样本效率。' + }, + compare: [ + { method: 'A2C', data: '同步优势Actor-Critic', strength: '实现简单,可并行', tradeoff: '需要调参' }, + { method: 'PPO', data: '裁剪策略梯度', strength: '稳定,样本效率高', tradeoff: '实现稍复杂' }, + { method: 'SAC', data: '最大熵 RL', strength: '离策略,高样本效率', tradeoff: '实现复杂,调参多' } + ], + quiz: [ + { + q: 'PPO 中的裁剪目标有什么作用?', + options: [ + '限制策略更新的幅度,防止步长过大导致性能崩溃', + '增加奖励', + '减少计算量', + '增加探索' + ], + answer: 0, + explanation: '裁剪目标确保新旧策略的比率 r_t(θ) 在 [1-ε, 1+ε] 范围内,防止一次更新改变策略太多。' + }, + { + q: 'GAE (Generalized Advantage Estimation) 的作用是什么?', + options: [ + '在偏差和方差之间取得平衡,通过 λ 参数控制', + '增加奖励', + '减少状态空间', + '增加模型容量' + ], + answer: 0, + explanation: 'GAE 通过混合多步 TD 误差,在偏差(低 λ)和方差(高 λ)之间取得平衡。λ=1 等价于 MC,λ=0 等价于 TD(0)。' + } + ] + } ] diff --git a/src/data/ai/curriculum.test.js b/src/data/ai/curriculum.test.js index 710a350..f0faf03 100644 --- a/src/data/ai/curriculum.test.js +++ b/src/data/ai/curriculum.test.js @@ -70,6 +70,32 @@ test('课节别名指向真实课节', () => { } }) +test('NLP/CV/RL/LLM 补齐课节存在且内容完整(2026-07 课程补全)', () => { + const REQUIRED = [ + // NLP:词嵌入训练、多头注意力、掩码机制、Transformer + 'nlp-word-embedding', 'nlp-glove', 'nlp-multihead-attention', + 'nlp-masked-attention', 'nlp-positional-encoding', 'nlp-transformer', 'nlp-bert-gpt', + // CV:CNN 演进、IoU、NMS、锚框 + 'cv-cnn-evolution', 'cv-iou', 'cv-nms', 'cv-anchor-box', 'cv-yolo', + // RL:Q 学习、经验回放、策略梯度、Actor-Critic、DQN + 'rl-mdp', 'rl-bellman', 'rl-qlearning', 'rl-experience-replay', + 'rl-policy-gradient', 'rl-actor-critic', 'rl-dqn', + // LLM:预训练、MLM、RAG、工具调用、思维链 + 'llm-pretraining', 'llm-mlm', 'llm-rag', 'llm-tool-calling', 'llm-chain-of-thought', + ] + for (const id of REQUIRED) { + const lesson = AI_LESSON_MAP[id] + expect(lesson, `缺少课节 ${id}`).toBeTruthy() + expect(typeof lesson.theory, `${id} 缺理论内容`).toBe('string') + expect(lesson.theory.length, `${id} 理论内容过短`).toBeGreaterThan(200) + expect(typeof lesson.code?.python, `${id} 缺 python 代码`).toBe('string') + expect(typeof lesson.pseudocode, `${id} 缺伪代码`).toBe('string') + expect(lesson.bigO, `${id} 缺复杂度分析`).toBeTruthy() + expect(Array.isArray(lesson.compare) && lesson.compare.length > 0, `${id} 缺知识点对比`).toBe(true) + expect(Array.isArray(lesson.quiz) && lesson.quiz.length > 0, `${id} 缺随堂测验`).toBe(true) + } +}) + // ── 索引一致性(防生成产物与章节数据漂移)──────────────────── test('curriculumIndex 与实际章节数据逐项一致', () => { expect(AI_CHAPTER_INDEX.map(c => c.id)).toEqual(AI_CURRICULUM.chapters.map(c => c.id)) diff --git a/src/data/ai/curriculumIndex.js b/src/data/ai/curriculumIndex.js index 5d62b02..7105c01 100644 --- a/src/data/ai/curriculumIndex.js +++ b/src/data/ai/curriculumIndex.js @@ -491,16 +491,58 @@ export const AI_CHAPTER_INDEX = [ "summary": "Word2Vec、GloVe、词向量空间", "hasExercise": true }, + { + "id": "nlp-tokenization", + "title": "分词与子词编码", + "summary": "Word、Subword(BPE)、Character tokenization", + "hasExercise": true + }, + { + "id": "nlp-word2vec", + "title": "Word2Vec 训练", + "summary": "CBOW vs Skip-gram、负采样", + "hasExercise": true + }, + { + "id": "nlp-glove", + "title": "GloVe 全局向量", + "summary": "全局词共现、加权最小二乘", + "hasExercise": true + }, { "id": "nlp-attention", "title": "注意力机制", - "summary": "Self-Attention、Multi-Head Attention", + "summary": "Self-Attention、缩放点积注意力", + "hasExercise": true + }, + { + "id": "nlp-multihead-attention", + "title": "多头注意力", + "summary": "并行注意力头、拼接与线性变换", + "hasExercise": true + }, + { + "id": "nlp-masked-attention", + "title": "掩码注意力", + "summary": "因果掩码、填充掩码、防止未来信息泄露", + "hasExercise": true + }, + { + "id": "nlp-positional-encoding", + "title": "位置编码", + "summary": "正弦位置编码、学习式位置编码、旋转位置编码", "hasExercise": true }, { "id": "nlp-transformer", "title": "Transformer 架构", - "summary": "编码器-解码器、位置编码、多头注意力", + "summary": "编码器-解码器、多头注意力、前馈网络、层归一化", + "hasExercise": true + }, + { + "id": "nlp-bert-gpt", + "title": "BERT 与 GPT", + "summary": "编码器-only vs 解码器-only、预训练目标对比", "hasExercise": true } ] @@ -512,13 +554,61 @@ export const AI_CHAPTER_INDEX = [ { "id": "cv-image-classification", "title": "图像分类", - "summary": "经典网络架构:LeNet、ResNet、VGG", + "summary": "经典 CNN 架构:LeNet、AlexNet、VGG、ResNet、EfficientNet", + "hasExercise": true + }, + { + "id": "cv-cnn-evolution", + "title": "CNN 架构演进", + "summary": "从 LeNet 到 EfficientNet:深度、残差、复合缩放的进化之路", + "hasExercise": true + }, + { + "id": "cv-image-augmentation", + "title": "图像数据增强", + "summary": "翻转、旋转、颜色抖动、随机裁剪、MixUp 等增强策略", + "hasExercise": true + }, + { + "id": "cv-transfer-learning", + "title": "迁移学习", + "summary": "预训练 + 微调:将大数据集学到的知识迁移到小数据集", "hasExercise": true }, { "id": "cv-object-detection", "title": "目标检测", - "summary": "YOLO、R-CNN、锚框机制", + "summary": "两阶段 vs 单阶段:从 R-CNN 到 YOLO 的演进", + "hasExercise": true + }, + { + "id": "cv-iou", + "title": "IoU 与 mAP", + "summary": "交并比计算、阈值设定、平均精度均值", + "hasExercise": true + }, + { + "id": "cv-nms", + "title": "NMS 非极大值抑制", + "summary": "贪婪 NMS、Soft-NMS、DIoU-NMS 的原理与对比", + "hasExercise": true + }, + { + "id": "cv-anchor-box", + "title": "锚框机制", + "summary": "生成、匹配与多尺度检测的锚框策略", + "hasExercise": true + }, + { + "id": "cv-yolo", + "title": "YOLO 目标检测", + "summary": "单阶段检测:网格预测、损失函数、架构演进", + "hasExercise": true + }, + { + "id": "cv-segmentation", + "title": "图像分割", + "summary": "语义分割与实例分割:U-Net、Mask R-CNN", "hasExercise": true } ] @@ -527,16 +617,64 @@ export const AI_CHAPTER_INDEX = [ "id": "rl", "title": "强化学习", "lessons": [ + { + "id": "rl-mdp", + "title": "马尔可夫决策过程 (MDP)", + "summary": "状态、动作、奖励、转移概率、折扣因子", + "hasExercise": true + }, + { + "id": "rl-bellman", + "title": "贝尔曼方程", + "summary": "值函数、最优性、动态规划基础", + "hasExercise": true + }, + { + "id": "rl-value-iteration", + "title": "值迭代", + "summary": "同步/异步更新、收敛性证明", + "hasExercise": true + }, + { + "id": "rl-policy-iteration", + "title": "策略迭代", + "summary": "策略评估 + 策略改进,两阶段交替", + "hasExercise": true + }, { "id": "rl-qlearning", "title": "Q-Learning", "summary": "Q 表、贝尔曼方程、ε-greedy 策略", "hasExercise": true }, + { + "id": "rl-sarsa", + "title": "SARSA", + "summary": "同策略 TD 学习,与 Q-Learning 对比", + "hasExercise": true + }, + { + "id": "rl-experience-replay", + "title": "经验回放", + "summary": "回放缓冲区、随机采样、打破样本相关性", + "hasExercise": true + }, + { + "id": "rl-dqn", + "title": "深度 Q 网络 (DQN)", + "summary": "神经网络近似 Q 函数、目标网络、Double DQN", + "hasExercise": true + }, { "id": "rl-policy-gradient", "title": "策略梯度", - "summary": "REINFORCE 算法、Actor-Critic", + "summary": "REINFORCE 算法、基线、方差缩减", + "hasExercise": true + }, + { + "id": "rl-actor-critic", + "title": "Actor-Critic", + "summary": "优势函数、A2C、PPO 算法", "hasExercise": true } ] @@ -545,18 +683,60 @@ export const AI_CHAPTER_INDEX = [ "id": "llm", "title": "大语言模型", "lessons": [ + { + "id": "llm-tokenization", + "title": "Tokenization 分词", + "summary": "BPE、WordPiece、SentencePiece、词表构建", + "hasExercise": true + }, { "id": "llm-pretraining", "title": "预训练与微调", "summary": "语言模型预训练、SFT、RLHF", "hasExercise": true }, + { + "id": "llm-mlm", + "title": "掩码语言模型 (MLM)", + "summary": "BERT 预训练目标、15% 掩码策略", + "hasExercise": true + }, + { + "id": "llm-clm", + "title": "因果语言模型 (CLM)", + "summary": "GPT 预训练目标、自回归生成、Teacher Forcing", + "hasExercise": true + }, + { + "id": "llm-sft", + "title": "监督微调 (SFT)", + "summary": "指令数据、损失掩码、超参数", + "hasExercise": true + }, + { + "id": "llm-rlhf", + "title": "RLHF 与 PPO", + "summary": "奖励模型训练、PPO 对齐、DPO 替代", + "hasExercise": true + }, { "id": "llm-rag", "title": "RAG 检索增强生成", "summary": "向量数据库、语义检索、上下文注入", "hasExercise": true }, + { + "id": "llm-tool-calling", + "title": "工具调用与 Function Calling", + "summary": "Schema 定义、规划、执行循环、ReAct", + "hasExercise": true + }, + { + "id": "llm-chain-of-thought", + "title": "思维链 (CoT)", + "summary": "Prompting 技术、自一致性、Tree-of-Thought", + "hasExercise": true + }, { "id": "llm-agent", "title": "AI Agent", diff --git a/src/data/algorithms/it.js b/src/data/algorithms/it.js index 74af5f4..a89f131 100644 --- a/src/data/algorithms/it.js +++ b/src/data/algorithms/it.js @@ -1,18 +1,18 @@ // 信息论 学科(14 个模块) -import { selfInfoSteps } from '../../algorithms/it/selfInfo' -import { entropySteps } from '../../algorithms/it/entropy' -import { jointEntropySteps } from '../../algorithms/it/jointEntropy' -import { mutualInfoSteps } from '../../algorithms/it/mutualInfo' -import { klDivergenceSteps } from '../../algorithms/it/klDivergence' -import { entropyRateSteps } from '../../algorithms/it/entropyRate' -import { channelSteps } from '../../algorithms/it/channel' -import { channelCapacitySteps } from '../../algorithms/it/channelCapacity' -import { markovSourceSteps } from '../../algorithms/it/markovSource' -import { markovChannelSteps } from '../../algorithms/it/markovChannel' -import { huffmanSteps } from '../../algorithms/it/huffman' -import { shannonFanoSteps } from '../../algorithms/it/shannonFano' -import { errorCorrectSteps } from '../../algorithms/it/errorCorrect' -import { dataCompressionSteps } from '../../algorithms/it/dataCompression' +import { selfInfoSteps } from '../../algorithms/it/selfInfo.js' +import { entropySteps } from '../../algorithms/it/entropy.js' +import { jointEntropySteps } from '../../algorithms/it/jointEntropy.js' +import { mutualInfoSteps } from '../../algorithms/it/mutualInfo.js' +import { klDivergenceSteps } from '../../algorithms/it/klDivergence.js' +import { entropyRateSteps } from '../../algorithms/it/entropyRate.js' +import { channelSteps } from '../../algorithms/it/channel.js' +import { channelCapacitySteps } from '../../algorithms/it/channelCapacity.js' +import { markovSourceSteps } from '../../algorithms/it/markovSource.js' +import { markovChannelSteps } from '../../algorithms/it/markovChannel.js' +import { huffmanSteps } from '../../algorithms/it/huffman.js' +import { shannonFanoSteps } from '../../algorithms/it/shannonFano.js' +import { errorCorrectSteps } from '../../algorithms/it/errorCorrect.js' +import { dataCompressionSteps } from '../../algorithms/it/dataCompression.js' const selfInfoPseudo = `I(x) ← -log₂ p(x) // 1. 给定事件概率 p ∈ (0, 1] diff --git a/src/features/music/components/CurriculumIndex.jsx b/src/features/music/components/CurriculumIndex.jsx index 770787f..2deeb14 100644 --- a/src/features/music/components/CurriculumIndex.jsx +++ b/src/features/music/components/CurriculumIndex.jsx @@ -16,6 +16,7 @@ export default function CurriculumIndex({ curriculum, basePath, isCompleted, cur
  • `$$\n${tex}\n$$`) +} + function MarkdownSection({ text, className }) { if (!text) return null return (
    - {text} + {promoteSingleLineMathBlocks(text)}
    ) diff --git a/src/features/music/hooks/useCourseProgress.js b/src/features/music/hooks/useCourseProgress.js index 6b03a49..884a3c6 100644 --- a/src/features/music/hooks/useCourseProgress.js +++ b/src/features/music/hooks/useCourseProgress.js @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' const key = (courseId) => `algoviz-course-${courseId}` @@ -18,6 +18,17 @@ function save(courseId, set) { export function useCourseProgress(courseId, totalLessons = 0) { const [completed, setCompleted] = useState(() => load(courseId)) + // Cross-tab sync: listen for localStorage changes from other tabs + useEffect(() => { + const onStorage = (e) => { + if (e.key === key(courseId)) { + setCompleted(load(courseId)) + } + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, [courseId]) + const markComplete = useCallback((lessonId) => { setCompleted(prev => { if (prev.has(lessonId)) return prev diff --git a/src/index.css b/src/index.css index 6106736..9cdf4f8 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,8 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300..900;1,14..32,300..900&family=JetBrains+Mono:wght@400;500;600&family=Nunito:wght@400;600;700;800;900&display=swap'); @import "tailwindcss"; +/* katex 样式归入 base 层:否则它未分层,components 层想微调 + .katex-display 的间距时永远打不过它(原先在 main.jsx 里裸 import) */ +@import "katex/dist/katex.min.css" layer(base); @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); @@ -168,23 +171,25 @@ /* Markdown prose used in LessonViewer */ .prose-lesson { - @apply text-fg-muted leading-relaxed; - h2 { @apply text-lg font-bold text-fg mt-6 mb-2; } - h3 { @apply text-base font-semibold text-fg mt-5 mb-1.5; } - p { @apply mb-3 text-sm; } - ul { @apply list-disc pl-5 mb-3 text-sm flex flex-col gap-1; } - ol { @apply list-decimal pl-5 mb-3 text-sm flex flex-col gap-1; } - li { @apply text-fg-muted; } + /* 中文正文用 1.75 行高 + 更大的段落/标题间距,公式块留呼吸空间 */ + @apply text-fg-muted leading-7; + h2 { @apply text-lg font-bold text-fg mt-8 mb-3; } + h3 { @apply text-base font-semibold text-fg mt-6 mb-2; } + p { @apply mb-4 text-sm leading-7; } + ul { @apply list-disc pl-5 mb-4 text-sm flex flex-col gap-1.5; } + ol { @apply list-decimal pl-5 mb-4 text-sm flex flex-col gap-1.5; } + li { @apply text-fg-muted leading-7; } strong { @apply text-fg font-semibold; } - blockquote { @apply border-l-2 border-accent-border pl-3 italic text-fg-faint text-sm my-3; } + blockquote { @apply border-l-2 border-accent-border pl-3 italic text-fg-faint text-sm my-4; } code { @apply bg-surface px-1 py-0.5 rounded text-[12px] font-mono text-accent; } - pre { @apply bg-surface border border-border-soft rounded-lg p-3 my-3 overflow-x-auto; } + pre { @apply bg-surface border border-border-soft rounded-lg p-3.5 my-4 overflow-x-auto leading-6; } pre code { @apply bg-transparent p-0 text-fg-muted; } - table { @apply w-full text-sm border-collapse my-4; } - th { @apply text-left py-1.5 px-2 text-fg font-semibold border-b border-border-soft text-xs uppercase tracking-wide; } - td { @apply py-1.5 px-2 border-b border-border-soft text-fg-muted; } - hr { @apply border-border-soft my-4; } + table { @apply w-full text-sm border-collapse my-5; } + th { @apply text-left py-2 px-2.5 text-fg font-semibold border-b border-border-soft text-xs uppercase tracking-wide; } + td { @apply py-2 px-2.5 border-b border-border-soft text-fg-muted; } + hr { @apply border-border-soft my-5; } a { @apply text-accent hover:underline; } + .katex-display { @apply my-5 overflow-x-auto overflow-y-hidden py-1; } } } @@ -260,6 +265,21 @@ var(--bg); } +/* guide 模式(外层 main 不滚动)下的课节页:shell 锁定为容器高度, + 侧栏与正文成为各自独立的滚动区。路由切换时 AppLayout 的 + scrollTo(0,0) 只作用于外层(已不滚动),左侧目录滚动位置得以保留。 */ +.music-lesson-shell--fit { + height: 100%; + min-height: 0; +} + +@media (max-width: 640px) { + /* 手机端底部导航是全局浮层,内部滚动的正文要自己留出安全区 */ + .music-lesson-shell--fit > main { + padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 12px); + } +} + .music-lesson-sidebar-frame { flex: 0 0 auto; width: 240px; @@ -3041,17 +3061,22 @@ body { flex-shrink: 0; } -h1, h2, h3, h4, h5 { - font-family: var(--font-sans); - font-weight: 700; - color: var(--text-primary); - letter-spacing: -0.02em; - margin: 0; -} +/* 基础重置必须放进 @layer base:未分层样式优先级高于一切 @layer 规则, + 之前这个裸重置把 components 层(如 .prose-lesson 的段距/标题边距) + 和 utilities 层(如 a 上的 text-accent)全部压制了。 */ +@layer base { + h1, h2, h3, h4, h5 { + font-family: var(--font-sans); + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; + margin: 0; + } -p { margin: 0; } -a { color: inherit; text-decoration: none; } -code, pre, .mono { font-family: var(--font-mono); } + p { margin: 0; } + a { color: inherit; text-decoration: none; } + code, pre, .mono { font-family: var(--font-mono); } +} ::selection { background: var(--accent-soft); diff --git a/src/layout/AppLayout.jsx b/src/layout/AppLayout.jsx index 1a1793d..69a4203 100644 --- a/src/layout/AppLayout.jsx +++ b/src/layout/AppLayout.jsx @@ -30,7 +30,10 @@ export default function AppLayout() { const isHome = pathname === '/' const isAlgo = pathname.startsWith('/algo') || pathname.startsWith('/compare') const isAICourseHome = pathname === '/ai-course' - const isGuide = isAICourseHome || GUIDE_PATHS.some(path => pathname === path || pathname.startsWith(path + '/')) + // 课节页也走 guide 模式(外层 main 不滚动,页面内部侧栏/正文各自滚动): + // 否则路由切换时全局 scrollTo(0,0) 会把左侧课程目录一起拽回顶部。 + const isAICourseLesson = pathname.startsWith('/ai-course/lesson/') + const isGuide = isAICourseHome || isAICourseLesson || GUIDE_PATHS.some(path => pathname === path || pathname.startsWith(path + '/')) const hasGuideSidebar = GUIDE_BACK_PATHS.has(pathname) const floatingBackTarget = getFloatingBackTarget(pathname) // 三档断点:phone ≤640 / ipad 641-1024 / desktop >1024 diff --git a/src/main.jsx b/src/main.jsx index 07ccff7..72c079c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,6 @@ import { lazy, StrictMode, Suspense } from 'react' import { createRoot } from 'react-dom/client' -import 'katex/dist/katex.min.css' +// katex.css 改由 index.css 以 @import layer(base) 引入,保证可被 components 层覆盖 import './index.css' import App from './App.jsx' import { initMonitoring } from './lib/monitoring.js' diff --git a/src/pages/AILessonPage.jsx b/src/pages/AILessonPage.jsx index ce98685..cfbedb8 100644 --- a/src/pages/AILessonPage.jsx +++ b/src/pages/AILessonPage.jsx @@ -1,5 +1,6 @@ -import { lazy, Suspense, use, useCallback, useRef, useState } from 'react' +import { lazy, Suspense, use, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Link, Navigate, useParams } from 'react-router-dom' +import ErrorBoundary from '../components/ErrorBoundary' // 同步路径(别名解析/404/侧栏目录)走轻量索引;课节正文经 loadLesson 按章动态加载 import { AI_COURSE_META, AI_CHAPTER_INDEX, AI_LESSON_ALIASES, AI_LESSON_INDEX, AI_TOTAL_LESSONS } from '../data/ai/curriculumIndex' import { loadLesson } from '../data/ai/loadChapter' @@ -17,16 +18,25 @@ const CURRICULUM_FOR_NAV = { ...AI_COURSE_META, chapters: AI_CHAPTER_INDEX } function AIExercise({ exercise, lesson, onSnapshotChange }) { if (!exercise || exercise.type !== 'playground') return null return ( - }> - - + +
    + 可视化加载失败 +
    +

    该交互模块暂时不可用,课程内容不受影响,请刷新页面重试。

    + + }> + }> + + +
    ) } // 课节正文:use() 挂起等待章节 chunk;loadLesson 按 id 缓存 Promise, // 重渲染拿到同一实例不会无限 suspend。快照状态属于具体课节,一并放在这里, // lessonId 变化时组件因 key 重建,状态自然复位。 -function LessonBody({ canonicalLessonId, lessonId, isCompleted, markComplete }) { +function LessonBody({ canonicalLessonId, isCompleted, markComplete }) { const lesson = use(loadLesson(canonicalLessonId)) const [playgroundSnapshot, setPlaygroundSnapshot] = useState(null) const snapshotKeyRef = useRef('') @@ -34,7 +44,7 @@ function LessonBody({ canonicalLessonId, lessonId, isCompleted, markComplete }) const handlePlaygroundSnapshot = useCallback((snapshot) => { const current = snapshot?.current || {} const key = JSON.stringify({ - lessonId, + lessonId: canonicalLessonId, presetId: snapshot?.presetId, currentStep: snapshot?.currentStep, total: snapshot?.total, @@ -46,7 +56,7 @@ function LessonBody({ canonicalLessonId, lessonId, isCompleted, markComplete }) if (snapshotKeyRef.current === key) return snapshotKeyRef.current = key setPlaygroundSnapshot(snapshot) - }, [lessonId]) + }, [canonicalLessonId]) return ( <> @@ -76,11 +86,28 @@ export default function AILessonPage() { const canonicalLessonId = AI_LESSON_ALIASES[lessonId] || lessonId const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const lessonExists = !!AI_LESSON_INDEX[canonicalLessonId] + const asideRef = useRef(null) + const contentRef = useRef(null) const { isCompleted, markComplete, progress } = useCourseProgress( AI_COURSE_META.id, AI_TOTAL_LESSONS ) + // 换课节:只有正文回到顶部;侧栏目录是独立滚动区,位置保持不动 + useLayoutEffect(() => { + contentRef.current?.scrollTo(0, 0) + }, [canonicalLessonId]) + + // 首次进入(含深链直达):把目录滚到当前课节附近;之后的滚动交还给用户 + useEffect(() => { + const aside = asideRef.current + const active = aside?.querySelector('[data-active="true"]') + if (!aside || !active) return + const asideRect = aside.getBoundingClientRect() + const activeRect = active.getBoundingClientRect() + aside.scrollTop += activeRect.top - asideRect.top - aside.clientHeight / 2 + activeRect.height / 2 + }, []) + if (canonicalLessonId !== lessonId && lessonExists) { return } @@ -94,7 +121,7 @@ export default function AILessonPage() { } return ( -
    +