XGBoost (eXtreme Gradient Boosting) 自诞生以来,凭借其在 Kaggle 竞赛中的统治级表现和工业界的广泛应用,成为了机器学习领域的“神兵利器”。
不同于传统的 GBDT,XGBoost 不仅在数学层面引入了二阶泰勒展开和正则化项,更在工程实现上通过稀疏感知、分块并行等技术将效率推向了极致。本文将深入剖析 XGBoost 的算法流程、核心推导及工程优化逻辑。
XGBoost (eXtreme Gradient Boosting) 自诞生以来,凭借其在 Kaggle 竞赛中的统治级表现和工业界的广泛应用,成为了机器学习领域的“神兵利器”。
不同于传统的 GBDT,XGBoost 不仅在数学层面引入了二阶泰勒展开和正则化项,更在工程实现上通过稀疏感知、分块并行等技术将效率推向了极致。本文将深入剖析 XGBoost 的算法流程、核心推导及工程优化逻辑。
感知机(Perceptron)是神经网络和支持向量机(SVM)的鼻祖。虽然它只是一个简单的线性二分类模型,但其背后的思想——包括Novikoff收敛定理、对偶形式以及核技巧(Kernel Trick)的应用,构成了现代机器学习的重要基石。
本文将基于相关课程内容,系统梳理感知机的核心逻辑,并重点展开介绍其对偶形式与核感知机的原理。
详解操作系统的中断机制,包括外部中断与异常的区别、内核态被中断时的处理流程,以及进程用户栈、内核栈与中断栈的内存架构设计。
如果你曾遇到过“在我机器上跑得好好的,怎么在你那就不行了?”这种程序员终极悖论,那么 Docker 就是为你准备的救命稻草。
想象你要从上海搬家到新加坡。
Docker 就是那个集装箱。 它把你的代码、配置、库文件、甚至操作系统环境全部打包,确保“一次构建,到处运行”。
Docker 为什么比虚拟机(VM)快?
| 特性 | 虚拟机 (VM) | Docker 容器 |
|---|---|---|
| 内核 | 每个 VM 都有独立的操作系统内核 | 共享宿主机的内核 |
| 启动时间 | 分钟级(需要加载整个系统) | 秒级(本质上是一个受限的进程) |
| 资源占用 | 很大(预分配 GB 级内存) | 极小(按需使用) |
底层技术简述:
| 命令 | 适用场景 |
|---|---|
docker pull nginx |
“买蓝图”:从网上下载一个 Nginx 镜像。 |
docker images |
“看库存”:查看本地电脑里存了哪些镜像。 |
docker rmi <image_id> |
“撕蓝图”:删除不再需要的本地镜像。 |
| 命令 | 适用场景 |
|---|---|
docker run -d -p 8080:80 --name my-web nginx |
“盖房子”:-d 后台运行,-p 把容器的 80 端口映射到电脑的 8080,并起个名字叫 my-web。 |
docker ps |
“查户口”:看看现在有哪些容器正在运行。 |
docker ps -a |
“翻旧账”:查看所有容器,包括那些已经停止运行运行的。 |
docker stop/start <name> |
“开关灯”:停止或启动现有的容器。 |
docker rm -f <name> |
“强拆”:强制删除一个运行中的容器。 |
| 命令 | 适用场景 |
|---|---|
docker exec -it my-web /bin/bash |
“进屋看看”:以交互模式进入正在运行的容器内部进行排查。 |
docker logs -f my-web |
“听听动静”:实时查看容器打印出来的日志。 |
docker inspect my-web |
“深度查验”:查看容器的 IP 地址、配置、挂载卷等底层信息。 |
只需要一行命令,你就可以拥有一个 Nginx 服务器:
1 | # 下载并启动 Nginx,映射到本地 80 端口 |
docker save 极其吃空间,通常需要镜像体积 2-3 倍 的剩余空间作为临时缓存。docker export 是唯一的救命稻草。它采用流式处理,几乎不产生临时文件。conda clean --all 和 rm -rf ~/.cache/pip,通常能瞬间释放数 GB 空间。docker save:适合完整备份。保留所有层(Layers)、环境变量、工作目录和入口脚本(Entrypoint)。docker export:适合环境交付。它将镜像“压扁”成一层,丢失所有历史元数据,但换来的是更小的体积和更高的导入兼容性。export 导出的镜像在 import 时必须手动补回关键变量(如 PATH、LD_LIBRARY_PATH)。docker run --env ...),而应在 docker import 时通过 --change 选项将配置固化进镜像,实现“开箱即用”。| 命令 | 作用对象 | 功能描述 |
|---|---|---|
systemctl start docker |
Docker 服务进程 | 在宿主机上启动 Docker 守护进程(Daemon)。如果不先运行此命令,任何 docker 命令都无法执行。 |
docker start <容器ID> |
具体容器 | 启动一个已经存在但处于停止状态(Exited)的容器。它不会创建新容器,只是唤醒旧容器。 |
.iso 安装光盘或类定义)。它存储了运行环境和代码,但不能直接运行代码。1 | # 常用参数:-it (交互模式), --gpus all (调用显卡), -v (挂载目录) |
1 | docker exec -it <容器ID> /bin/bash |
当你修改了容器内的代码或环境,想把它存下来时:
1 | docker commit <容器ID> <新镜像名>:<新标签> |
| 命令 | 适用场景 | 特点 |
|---|---|---|
docker commit |
本地迭代 | 产生新镜像,保留所有层。 |
docker save |
完整迁移 | 包含镜像所有历史记录(Metadata),体积大,还原简单(load)。 |
docker export |
环境分发 | 只导出当前容器文件系统,体积最小,需手动补回配置(import)。 |
docker imagesdocker psdocker ps -adocker rm <容器ID>docker rmi <镜像ID>docker system prune(慎用,会删除所有停止的容器和孤儿镜像)。镜像打包完成后,建议先执行一次 docker commit 将改动固化,然后再进行 export 或 save 操作,以防导出过程中意外丢失未保存的容器修改。
一个标准的、高质量的 Dockerfile 通常遵循“从基础到应用”的逻辑。
1 | # 1. FROM: 指定基础镜像。就像选择地基,这里选的是带 CUDA 的 Ubuntu 20.04 |
| 指令 | 作用 | 技巧 |
|---|---|---|
FROM |
声明基础镜像 | 尽量使用官方维护的镜像(如 nvidia/cuda)。 |
RUN |
构建时运行命令 | 每一条 RUN 都会增加一层镜像,用 && 合并命令。 |
COPY |
拷贝本地文件 | COPY . . 会拷贝当前目录下所有文件。 |
ENV |
设置环境变量 | 比如 ENV MUJOCO_GL=osmesa。 |
WORKDIR |
切换工作目录 | 避免频繁使用 RUN cd ...,因为 cd 只在当前 RUN 生效。 |
ARG |
构建时变量 | 只在镜像构建过程中有效,容器运行时无效。 |
EXPOSE |
声明端口 | 比如运行 Jupyter 时需要 EXPOSE 8888。 |
编写完 Dockerfile 后,在同级目录下执行:
1 | # -t 指定镜像名和标签,后面的点 "." 代表当前目录(寻找 Dockerfile) |
RUN 指令中完成安装和清理(如 rm -rf /var/lib/apt/lists/*),否则清理操作只会增加一个“删除层”,而不会真正减小镜像体积。ENV DEBIAN_FRONTEND=noninteractive 防止构建过程中弹出对话框导致失败。docker build -t <镜像名>:<标签> .docker history <镜像名>docker run -d --name my_container <镜像名>docker build --no-cache -t <镜像名> .本文详细推导了支持向量机(SVM)的数学原理。从最基础的拉格朗日乘子法和KKT条件入手,逐步推导硬间隔SVM、软间隔SVM以及核技巧(Kernel Trick),旨在理清每一步推导的数学目的与几何意义。
本文总结了计算机存储层次结构、数据传输机制(DMA/PIO)、缓存写入策略(写回/写通)以及文件系统中的硬链接与软链接的本质区别。
在深度学习中,全连接层(Linear Layer)是最基础的模块。理解其反向传播中的梯度计算是掌握整个神经网络训练流程的关键。本文将以 $Y = WX + b$ 为例,演示两种核心梯度推导方法:维度检查法(Shape Check)和全微分法(Matrix Differential)。
假设我们在计算图中的某一层进行以下运算:
变量定义与维度(Shape):
已知条件(上游传回来的梯度):
我们已算出 Loss 对本层输出 $Y$ 的梯度:
我们的目标:
求出 $L$ 关于 $W$ 的梯度 $\frac{\partial L}{\partial W}$ 和 关于 $X$ 的梯度 $\frac{\partial L}{\partial X}$(用于继续往前传)。
核心思想: 梯度的形状必须与其对应的变量形状一致。通过线性代数知识,用已知变量拼凑出正确的形状。
点评:维度检查法速度极快,是日常代码实现和面试推导的首选。
核心思想: 利用标量函数 $L$ 的微分 $dL$ 与梯度的关系,通过迹(Trace)的性质进行严谨推导。这是处理复杂矩阵运算的终极武器。
对于标量函数 $L$,其微分 $dL$ 与梯度 $\nabla A$ 的关系是:
我们已知 $dL = \text{tr}(G^T dY)$。将 $dY$ 代入:
利用迹的线性性质:
1. 求 $\frac{\partial L}{\partial W}$: 关注 $\text{Term}_W$
将 $dW$ 挪到最后面:
对比核心公式 $dL = \text{tr}((\frac{\partial L}{\partial W})^T dW)$ 可得:
两边转置:
2. 求 $\frac{\partial L}{\partial X}$: 关注 $\text{Term}_X$
对比核心公式 $dL = \text{tr}((\frac{\partial L}{\partial X})^T dX)$ 可得:
两边转置:
3. 求 $\frac{\partial L}{\partial b}$: 关注 $\text{Term}_b$
对比可得:
| 方法 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 维度检查法 (Shape Check) | 速度极快,无需微积分知识,直观。 | 缺乏严谨性,在复杂运算(如带 Hadamard 乘积)时容易出错。 | 日常编码、调试、面试、标准层(全连接/卷积)。 |
| 全微分法 (Matrix Differential) | 严谨可靠,可以处理任何复杂的矩阵运算,确保 100% 正确。 | 涉及矩阵微积分和迹的性质,需要一定的数学基础。 | 推导新型网络层、复杂 Loss 函数、进行学术研究。 |
最佳实践:
在日常工作中,应熟练使用维度检查法快速推导和验证;在遇到非标准或复杂的矩阵运算时,则使用全微分法确保推导的准确性。
| 类型 | 性质/公式 | 描述 |
|---|---|---|
| 迹的性质 | $\text{tr}(A^T) = \text{tr}(A)$ | 迹的转置不变性。 |
| $\text{tr}(A B C) = \text{tr}(B C A) = \text{tr}(C A B)$ | 迹的循环不变性 (Trace Cycling Property)。这是推导的核心工具。 | |
| $\text{tr}(A + B) = \text{tr}(A) + \text{tr}(B)$ | 迹的线性性质。 | |
| 微分性质 | $d(A B) = dA \cdot B + A \cdot dB$ | 矩阵乘积的微分规则。 |
| $d(A^T) = (dA)^T$ | 矩阵转置的微分。 | |
| 常用微分公式 | $d(X^n) = \sum_{i=1}^n X^{i-1} dX X^{n-i}$ | 矩阵幂的微分(非循环)。 |
| $d(\text{tr}(A)) = \text{tr}(dA)$ | 迹函数的微分。 | |
| 特殊公式 (针对迹) | $\text{tr}(A) = A$ (当 $A$ 为标量时) | 标量是它自己的迹。 |
| $\text{tr}(X^T A) = \text{tr}(A X^T)$ | 迹的循环性质应用。 |
以二次型函数 $f(\mathbf{x}) = \mathbf{x}^T \mathbf{Ax}$ 对向量 $\mathbf{x}$ 求导为例,这是机器学习(如最小二乘法、正态分布)中最经典的推导。
我们可以通过之前提到的“迹法则(Trace Trick)”和“微分法”三步走来实现:
由于二次型 $f(\mathbf{x})$ 的结果是一个标量,标量的迹等于其自身,即 $f = \text{tr}(\mathbf{x}^T \mathbf{Ax})$。
根据矩阵乘法的微分法则 $d(\mathbf{UV}) = (d\mathbf{U})\mathbf{V} + \mathbf{U}(d\mathbf{V})$,我们对 $\mathbf{x}$ 求微分:
我们的目标是将所有的 $d\mathbf{x}$ 统一移到表达式的右侧,并包裹在 $\text{tr}(\cdot)$ 中。
合并两项:
根据定义 $df = \text{tr}((\frac{\partial f}{\partial \mathbf{x}})^T d\mathbf{x})$,对比上面的等式:
两边同时转置,得到最终导数公式:
这与标量求导 $\frac{d(ax^2)}{dx} = 2ax$ 在形式上非常相似,非常好记。
本文根据 Hexo 与 GitHub Pages 的标准实践,简要总结了从环境配置到最终部署上线的四个主要阶段,帮助您快速建立自己的静态博客。
这是本地工作的核心基础,确保 Hexo 和 Git 能够正常运行。
npm install -g hexo-cli在本地创建一个 Hexo 博客项目文件夹,并进行必要的测试。
hexo init <文件夹名称>cd <文件夹名称>hexo s (访问 http://localhost:4000) 这一步将创建博客的托管空间,是实现公开访问的关键。
<YourUserName>.github.io 的格式(这是搭建个人主页网站的要求)。npm install hexo-deployer-git --save_config.yml 文件,配置 deploy 部分,指向您的 GitHub 仓库地址。1 | deploy: |
博客内容上线发布的核心操作。通常只需要这三个命令循环执行。
| 序号 | 命令 | 作用 |
|---|---|---|
| 1. | hexo clean |
清除本地已生成的静态文件,确保重新构建是基于最新内容。 |
| 2. | hexo g (hexo generate) |
将 Markdown 源文件转换为最终的 HTML、CSS 等静态网页文件,生成到 public 文件夹。 |
| 3. | hexo d (hexo deploy) |
将 public 文件夹中的静态文件推送到 GitHub 仓库,完成在线发布。 |
常用命令合集:
hexo new "文章标题"hexo clean && hexo g && hexo dHexo + GitHub Pages 的核心工作流是:
本地 Hexo 将 Markdown 编译成静态 HTML $\rightarrow$ Git 将 HTML 推送到 GitHub 仓库 $\rightarrow$ GitHub Pages 服务将该仓库内容作为网站发布。
部署成功后,您的博客即可通过地址 https://<YourUserName>.github.io 访问。
_config.yml 中配置 theme: 字段。CNAME 文件。条件变量(Condition Variable)是多线程编程中实现线程间高效通信与同步的核心机制。无论是使用 C 语言的 POSIX 线程库 (pthread) 还是现代 C++ 标准库,理解其背后的语义和正确用法至关重要。
本文将深入解析 C++ std::condition_variable 与 POSIX pthread_cond_t 的异同,并强调条件变量使用中最关键的两个原则。
条件变量总是与互斥量(Mutex)和共享状态(Shared State/Predicate)一起工作。
它的核心作用是:
条件变量规范主要有两种语义,它们定义了唤醒线程如何重新获得锁,以及是否需要重新检查条件。
MESA 语义 是指 POSIX pthread_cond_t 和 C++ std::condition_variable 在绝大多数平台上的实现所采用的语义。
| 特性 | 描述 |
|---|---|
| 锁的转移 | 通知线程发出 notify 后,它会继续持有互斥锁,直到它自己主动释放(退出临界区)。被唤醒的等待线程将进入锁的竞争队列,等待通知线程释放锁。 |
| 条件保证 | 唤醒不保证条件满足。由于锁竞争和虚假唤醒的存在,被唤醒的线程重新获得锁后,条件可能再次变为假。 |
| 结果 | 被唤醒的线程必须在 while 循环中重新检查共享条件。 |
为什么采用 MESA 语义?
MESA 语义的实现成本较低,效率更高。它避免了复杂的内核操作来原子性地转移锁的所有权。它将“唤醒线程”和“线程重新获取锁”这两个步骤解耦,代价就是将安全检查的责任转移给了程序员(即 while 循环)。
BTW
posix的条件变量的工作方式有点像进程的管理方法,即有三种状态:等待->就绪->运行。pthread_cond_wait后会进入等待队列,被pthread_cond_signal后会进入就绪队列,但是不保证条件一定满足(MESA语义只是暗示有条件发生了改变)
HOARE 语义 源于条件变量的原始理论模型。
| 特性 | 描述 |
|---|---|
| 锁的转移 | 通知线程发出 notify 后,它会立即将互斥锁的所有权转移给被唤醒的等待线程。通知线程自己会立即阻塞,直到等待线程执行完毕并释放锁。 |
| 条件保证 | 被唤醒的线程保证在获得锁时条件是满足的。 |
| 结果 | 理论上,被唤醒的线程不需要在 while 循环中检查条件。 |
为什么 HOARE 语义不常用?
虽然 HOARE 语义在逻辑上更优雅,但其实现涉及内核必须原子性地完成“释放通知线程的 CPU -> 唤醒等待线程 -> 转移锁”这一复杂操作,开销较大,且难以在不同的操作系统内核中高效实现。
基于 MESA 语义,无论是 C 还是 C++,其核心原则都保持一致:
pthread_cond_t (C 风格)POSIX 库不提供异常安全和 RAII。开发者必须手动管理资源和同步逻辑,并显式实现条件检查的 while 循环。
1 | // POSIX C 示例:必须使用 while 循环 |
std::condition_variable (C++ 风格)C++ 标准库将互斥锁和条件检查进行了完美封装,提供了异常安全和类型安全的 API。
while)这种写法与 POSIX 风格逻辑相同,需手动编写 while 循环来确保 MESA 语义的安全。
1 | std::unique_lock<std::mutex> lock(m); |
这是对 MESA 语义的最佳应对方案。std::condition_variable::wait(lock, predicate) 的重载版本在函数内部为你实现了 while 循环和条件检查。
你不需要外部 while 循环,因为它已内置在 wait 函数中。
1 | std::unique_lock<std::mutex> lock(m); |
| 原则 | 描述 | 最佳实践 |
|---|---|---|
| 核心语义 | MESA 语义(唤醒不保证条件)要求等待线程必须重新检查条件。 | C++: 永远使用 cond.wait(lock, predicate)。 |
| 资源安全 | C++ 标准库提供了 RAII 和异常安全保证。 | C++: 使用 std::condition_variable 和 std::unique_lock。 |
| 通知方法 | 确定唤醒范围。 | 如果只有一个线程或一类线程等待,使用 notify_one();如果所有等待线程都需要知道改变,使用 notify_all()。 |
强烈推荐: 在 C++ 项目中,始终优先使用 std::condition_variable 并采用带 谓词 (Predicate) 的 wait 重载,以最大限度地保证代码的健壮性和异常安全性。
OSTEP ch30
我们将强化学习建模为一个随机过程。
轨迹 (Trajectory) 定义为
其中 $\rho(s1)$ 是初始状态分布,$P(s{t+1}|s_t,a_t)$ 是环境转移概率。
目标函数 (Optimization Objective)
最常见的写法是最大化折扣累计回报:
为避免符号干扰,下面有时会省略 $\gamma$,但不影响核心推导。
❗ 痛点 1:不能直接对奖励求导
奖励来自环境交互,环境通常是黑箱;而且期望又定义在依赖于 $\theta$ 的轨迹分布上,因此不能像监督学习那样直接把梯度穿过环境。
从定义出发:
其中 $R(\tau)$ 表示整条轨迹的累计回报。
对参数求导:
利用恒等式
得到
再展开 $\log p_\theta(\tau)$:
由于环境转移概率 $P(s_{t+1}|s_t,a_t)$ 与策略参数 $\theta$ 无关,因此
代回得
这就是最基础的 REINFORCE 形式。
直观解释:
如果某条轨迹总回报高,就提高这条轨迹中所有动作的概率;如果回报低,就降低这些动作的概率。
❗ 痛点 2:高方差
每个时间步都乘上整条轨迹的总回报,这会把大量与当前动作无关的噪声也一起灌进梯度里。
REINFORCE 的一个关键问题在于:动作 $a_t$ 不应该为过去的奖励负责。
形式化地说,对于当前时间步 $t$,过去的奖励 $r1,\dots,r{t-1}$ 不会受到当前动作 $a_t$ 的影响。因此在期望意义下,过去奖励对应的项会消失,留下的只需要是从当前时刻开始的未来回报:
于是梯度可改写为
这一步常被称为 reward-to-go,它不改变期望梯度,但显著降低了方差。
直观解释:
在时间步 $t$ 采取的动作,只该根据它对未来回报的影响来被奖励或惩罚,而不该背锅过去已经发生的事。
虽然 reward-to-go 已经降低了方差,但它仍然会遇到一个问题:
即使当前动作只是“平均水平”,只要该状态本身未来回报大,梯度也可能把这个动作无差别地推高。
这时我们引入一个只依赖状态、与动作无关的 baseline:$b(s_t)$。
关键性质是:
证明非常直接:
因此我们可以在不改变期望梯度的前提下,把 $G_t$ 替换为 $G_t-b(s_t)$。
最常用的选择是令 baseline 近似状态价值函数:
定义优势函数(Advantage):
在采样估计里,常用
于是我们得到更实用的 PG 形式:
本质含义:
不是问“这个状态未来回报高不高”,而是问“这个动作是否比这个状态下的平均动作更好”。
从 REINFORCE 出发:
利用期望的线性性,将 trajectory 按时间步拆开:
对每一项使用条件期望(固定 (s_t, a_t)):
注意到:
因此:
最后,将对时间步的求和吸收到状态访问分布中(定义 ($d^{\pi_\theta}(s)$) 为在策略下访问 state 的频率):
得到:
到这里为止,主线还是标准的 on-policy policy gradient。接下来的核心问题是:
每次策略一更新,旧数据就和新策略不再完全匹配;如果每次都只用最新样本做一步更新,样本利用率会很低。
这里需要特别澄清一件事:
PPO 不是传统意义上的 fully off-policy 方法。
它的本质仍然是 on-policy / near-on-policy:
因此,下面这部分不是在把 PPO 推成一个一般意义上的 off-policy 算法,而是在解释:
为什么 PPO 会自然出现 likelihood ratio,以及为什么需要限制新旧策略不要偏得太远。
上面从轨迹概率出发得到的是 REINFORCE 形式。更进一步,我们可以把梯度写成状态-动作分布上的形式:
其中 $d^{\pi\theta}(s)$ 表示策略 $\pi\theta$ 诱导的状态访问分布。
再减去任意只依赖状态的 baseline,可以写成
这就是从 REINFORCE 过渡到 actor-critic 的标准形式。
设旧策略为
我们用它采样得到一批数据 $(st,a_t,r_t)$。现在想优化一个新策略 $\pi\theta$。
对于任意固定的状态动作对 $(s_t,a_t)$,定义概率比值:
那么,对旧策略样本上的一个自然 surrogate objective 是:
其中
这个目标非常重要,因为它满足两个关键性质:
因此,它是一个一阶正确(first-order correct)的局部 surrogate objective。
这正是 TRPO / PPO 的起点。
如果直接在旧策略样本上写
那它与当前策略 $\pi_\theta$ 无关,根本无法优化。
而如果我们想在旧数据上评估“当前策略对这些动作的偏好是否应该增大或减小”,就必须把当前策略和旧策略在同一样本上的概率联系起来。最自然的量正是
它的含义非常直观:
再乘上优势 $\hat A_t$,就得到“应该把这个动作的概率往哪个方向调”的局部优化信号。
很多讲法会先写出全轨迹 importance sampling:
再把轨迹比值展开成
这个公式本身是正确的,因为环境转移概率会在比值中消掉。
但它更适合作为直觉背景,而不适合作为 PPO 的主推导。原因在于:
所以,在理解 PPO 时,trajectory-level importance sampling 可以作为“为什么 ratio 会出现”的背景说明,但不应把它当成 PPO clip 目标的正式来源。
到这里,已经有了一个局部正确的目标:
它解决了“如何在旧策略样本上优化当前策略”的问题,但新的问题马上出现:
如果新策略和旧策略相差太大,这个局部近似就会失效。
这才是 TRPO 与 PPO 真正要解决的核心难点。
看单个时间步上的项:
如果 $\hat A_t>0$,最大化它会推动 $\rho_t(\theta)$ 尽可能变大,也就是大幅提高该动作概率;
如果 $\hat A_t<0$,则会推动 $\rho_t(\theta)$ 尽可能变小,也就是大幅降低该动作概率。
问题在于:
这个目标本身并没有限制“改多大”。一旦 $\pi\theta$ 相对 $\pi{\text{old}}$ 偏移过大,
因此,真正的问题不在于有没有 surrogate,而在于:
如何让每次更新都保持“足够近端(proximal)”。
TRPO 的做法是:
在最大化 surrogate objective 的同时,显式约束新旧策略之间的 KL 距离不要太大:
它的思想非常清楚:
TRPO 具有很强的理论动机,但工程实现较复杂,因为它通常涉及二阶近似、共轭梯度等操作。
PPO 的目标不是推翻 TRPO,而是用更简单、更稳定、更适合深度学习训练的方式,近似实现“不要偏离太远”这一思想。
PPO 最常见的版本是 PPO-Clip。
它并不显式加一个硬 KL 约束,而是直接在每个样本的 ratio 上做截断:
定义裁剪函数:
然后构造目标:
这就是 PPO 的核心公式。
如果只有
那优化器会想办法把对目标有利的 $\rho_t$ 推得越来越极端:
这会使策略更新缺乏约束,导致 surrogate 失真。
若 $\hat A_t>0$,则我们希望提高该动作概率,也就是希望 $\rho_t(\theta)$ 变大。
此时:
当 $\rho_t \le 1+\epsilon$ 时,裁剪不起作用;
当 $\rho_t > 1+\epsilon$ 时,第二项变成 $(1+\epsilon)\hat A_t$,而它比第一项更小,因此 min 会选择被裁剪后的那一项。
含义:
这个动作是好的,可以提高它的概率;但一旦提高幅度超过阈值,就不再继续鼓励。
若 $\hat A_t<0$,则我们希望降低该动作概率,也就是希望 $\rho_t(\theta)$ 变小。
当 $\rho_t < 1-\epsilon$ 时,裁剪后的比值变成 $1-\epsilon$。因为此时乘的是一个负数,
所以 min 会选更小的那个,也就是裁剪后的项。
含义:
这个动作是坏的,可以降低它的概率;但一旦降得过猛,也不再继续鼓励。
min 的真正作用min 并不是在构造一个对真实目标的严格数学下界,而是在构造一个更保守的 surrogate objective。
它的效果是:
因此,PPO-Clip 的核心不是“精确逼近 TRPO 的 KL 约束”,而是用一种非常简单的样本级机制,达到“近端更新”的效果。
到目前为止,公式里一直写的是 $\hat A_t$。但在实际算法里,优势函数并不是直接已知的,需要估计。
最简单的估计是 Monte Carlo:
但它仍然可能方差较大,因此 PPO 中更常见的是 GAE (Generalized Advantage Estimation)。
先定义 TD residual:
然后定义 GAE:
它可以看作在 bias 与 variance 之间做折中:
在工程实践中,PPO 几乎总会和 GAE 搭配使用。
PPO 通常采用 actor-critic 架构,因此除了策略目标之外,还会同时训练价值函数,并加上熵正则以鼓励探索。
若将 actor 参数记为 $\theta$,critic 参数记为 $\phi$,则一个常见的总目标写作:
其中:
如果 actor 与 critic 共享部分网络参数,上式也常被合并写成单个参数 $\theta$ 的形式;但从概念上区分 $\theta$ 与 $\phi$ 会更清晰。
到这里,PPO 的推导已经闭环了。它的实际训练流程可以概括为:
这也解释了为什么 PPO 通常被称为 on-policy / near-on-policy:
它虽然会对同一批旧数据重复优化多轮,但这些数据仍然只来自最近一轮旧策略,而不是像 DQN/SAC 那样在长期 replay buffer 中反复混用来自很多历史策略的数据。
从 Policy Gradient 到 PPO 的演进,本质上是一条不断解决“高方差”和“更新不稳定”问题的路线:
REINFORCE:
用 log-derivative trick 绕开环境不可导,但梯度方差很大。
Reward-to-Go + Baseline/Advantage:
利用因果性去掉过去奖励的噪声,再通过 baseline 判断动作是否“优于平均”,进一步降低方差。
Policy Gradient Theorem + Actor-Critic:
把轨迹级视角转成状态-动作级视角,得到更适合实际训练的优势函数形式。
Old-policy surrogate objective:
为了在旧策略采样的数据上优化当前策略,引入 likelihood ratio
构造局部正确的 surrogate objective
TRPO / PPO:
当新旧策略偏得太远时,surrogate 会失效;TRPO 用 KL 约束显式限制步长,PPO 用 clipping 给出更简单、更稳定的近端更新机制。
所以,PPO 的本质并不是“把 off-policy importance sampling 做得更稳定”,而是:在基于旧策略样本的局部 surrogate objective 上,通过限制新旧策略偏离,稳定地进行策略优化。
这才是从 Policy Gradient 到 PPO 最核心的那条逻辑主线。