<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/scripts/pretty-feed-v3.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:h="http://www.w3.org/TR/html4/"><channel><title>观文听傑</title><description>记录大模型、软件开发与 AI for Science</description><link>https://zwjcode.cn</link><item><title>生成 4 个 Token 为何不一定要跑 4 次大模型？Speculative Decoding 的提议、验证与接受</title><link>https://zwjcode.cn/blog/speculative-decoding-draft-verify-acceptance</link><guid isPermaLink="true">https://zwjcode.cn/blog/speculative-decoding-draft-verify-acceptance</guid><description>从自回归解码的串行瓶颈出发，手算 draft–verify 接受过程，推导精确采样修正，追踪张量形状，并给出可检查实现、指标与失败边界。</description><pubDate>Sat, 19 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 Chunked Prefill（分块预填充）限制长 prompt 对 decode 的阻塞，但生成仍有一条硬约束：第 $t+1$ 个 token 要把第 $t$ 个 token 当作输入。普通自回归解码生成 4 个 token，目标大模型就要串行执行 4 次。&lt;/p&gt;
&lt;p&gt;Speculative Decoding（投机解码）引入一个便宜的 draft model（草稿模型）连续猜若干 token，再让 target model（目标模型）用一次前向并行检查这些位置。猜对得多，就用一次昂贵前向推进多个 token；猜错时通过接受—拒绝修正，仍保持目标模型的采样分布。&lt;/p&gt;
&lt;h2&gt;01 瓶颈为什么是串行轮数，而不只是 FLOPs？&lt;/h2&gt;
&lt;p&gt;设当前前缀为 $x_{1:t}$。普通解码依次执行：&lt;/p&gt;
&lt;p&gt;$$
p_1=p(\cdot\mid x_{1:t}),\quad x_{t+1}\sim p_1,
$$&lt;/p&gt;
&lt;p&gt;$$
p_2=p(\cdot\mid x_{1:t+1}),\quad x_{t+2}\sim p_2.
$$&lt;/p&gt;
&lt;p&gt;第二个分布必须等第一个采样结果产生。即便 GPU 在单轮 decode 中没有吃满，也不能直接把未知的未来 token 放进同一批。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;普通 decode： Target -&gt; 1 token -&gt; Target -&gt; 1 token -&gt; Target -&gt; 1 token
投机 decode： Draft  -&gt; a,b,c -&gt; Target 一次验证 -&gt; 接受若干 token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;目标不是减少所有计算：系统额外运行了 draft。真正要换取的是更少的 target 串行调用，以及更高的单次验证算术强度。&lt;/p&gt;
&lt;h2&gt;02 Draft 与 Target 各自产生什么？&lt;/h2&gt;
&lt;p&gt;给定 batch size $N$、草稿长度 $\gamma$、词表大小 $V$：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;draft 自回归提出 $\gamma$ 个 token，&lt;code&gt;draft_ids&lt;/code&gt; 为 &lt;code&gt;[N,γ]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;同时保存每一步的草稿分布 $q_i$，&lt;code&gt;draft_probs&lt;/code&gt; 为 &lt;code&gt;[N,γ,V]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;target 把原前缀与草稿 token 一起前向，得到验证分布 $p_i$ 以及一个 bonus 位置，&lt;code&gt;target_probs&lt;/code&gt; 为 &lt;code&gt;[N,γ+1,V]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;verifier（验证器）逐位置决定接受、拒绝与替换。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  C[已确认前缀 x1:t] --&gt; D[Draft 串行提出&amp;#x3C;br/&gt;y1,y2,y3]
  C --&gt; T[Target 一次前向]
  D --&gt; T
  T --&gt; V[逐位置验证]
  V --&gt;|接受前缀| A[提交 y1..yk]
  V --&gt;|首次拒绝| R[采样修正 token&amp;#x3C;br/&gt;丢弃其后草稿]
  V --&gt;|全部接受| B[再提交 1 个 bonus token]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;“一次验证”并不代表 verifier 可以乱序接受。位置 2 只有在位置 1 已接受后才仍处于 target 评估过的那个前缀上；首次拒绝之后，后面的草稿条件链已经失效。&lt;/p&gt;
&lt;h2&gt;03 Greedy 模式先看最直观版本&lt;/h2&gt;
&lt;p&gt;当 temperature 为 0，可把流程理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;draft 提议：       [A, B, C, D]
target 对各位置：  [A, B, X, ...]
提交：             [A, B, X]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前两个 token 与 target 的 argmax 相同，可以接受；第三个不同，就提交 target 的 &lt;code&gt;X&lt;/code&gt; 并丢弃 &lt;code&gt;C,D&lt;/code&gt;。一次 target 前向推进了 3 个 token，而不是 1 个。&lt;/p&gt;
&lt;p&gt;若四个草稿全部匹配，还可从 target 的第 5 个位置提交 bonus token，因此一次验证最多推进 $\gamma+1$ 个 token。&lt;/p&gt;
&lt;h2&gt;04 精确采样为何按概率比接受？&lt;/h2&gt;
&lt;p&gt;在某一步，draft 从 $q(x)$ 抽到候选 $y$，target 希望最终服从 $p(x)$。接受概率是&lt;/p&gt;
&lt;p&gt;$$
\alpha(y)=\min\left(1,\frac{p(y)}{q(y)}\right).
$$&lt;/p&gt;
&lt;p&gt;若 $p(y)\ge q(y)$，draft 对 $y$ 没有“提议过量”，直接接受；若 $q(y)&gt;p(y)$，只以 $p(y)/q(y)$ 接受，削掉多出来的概率质量。&lt;/p&gt;
&lt;p&gt;拒绝后不能直接再从 $p$ 采样，否则被拒候选的质量会重复计算。修正分布为&lt;/p&gt;
&lt;p&gt;$$
p&apos;(x)=\frac{\max(0,p(x)-q(x))}{\sum_z\max(0,p(z)-q(z))}.
$$&lt;/p&gt;
&lt;p&gt;接受分支贡献 $\min(p(x),q(x))$，拒绝分支再补上 $(p(x)-q(x))_+$；两者相加恰好回到 $p(x)$。这就是“无损”指向的数学含义：在相同采样变换与数值实现假设下，输出分布不因 draft 改变。&lt;/p&gt;
&lt;h2&gt;05 用两个词手算一次接受与修正&lt;/h2&gt;
&lt;p&gt;词表只有 &lt;code&gt;A/B&lt;/code&gt;。第二个位置的分布为：&lt;/p&gt;
&lt;p&gt;| token | draft $q$ | target $p$ | $p/q$ |
| ----- | --------: | ---------: | ----: |
| A     |       0.2 |        0.6 |   3.0 |
| B     |       0.8 |        0.4 |   0.5 |&lt;/p&gt;
&lt;p&gt;若 draft 提议 &lt;code&gt;B&lt;/code&gt;，接受概率为 0.5。抽到均匀随机数 $u=0.7$ 时拒绝。残差为：&lt;/p&gt;
&lt;p&gt;$$
(p-q)_+=(0.4,0),
$$&lt;/p&gt;
&lt;p&gt;归一化后必然选择 &lt;code&gt;A&lt;/code&gt;。于是该轮在首次拒绝处提交 &lt;code&gt;A&lt;/code&gt;，后续草稿全部作废。&lt;/p&gt;
&lt;p&gt;从总体概率看：&lt;code&gt;B&lt;/code&gt; 被提议的概率 0.8，乘接受率 0.5，最终质量为 0.4；其余 0.6 全由修正分支给 &lt;code&gt;A&lt;/code&gt;，正好得到 target 的 &lt;code&gt;(0.6,0.4)&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;06 一轮算法的透明伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;given confirmed prefix x
draft_tokens, q[1:γ] = draft_autoregressively(x, γ)
p[1:γ+1] = target_verify(x + draft_tokens)

accepted = []
for i in 1..γ:
    y = draft_tokens[i]
    α = min(1, p[i,y] / q[i,y])
    if uniform(0,1) &amp;#x3C; α:
        accepted.append(y)
    else:
        replacement ~ normalize(max(p[i] - q[i], 0))
        return accepted + [replacement]

bonus ~ p[γ+1]
return accepted + [bonus]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际实现还要在同一位置应用完全一致的 temperature、top-k、top-p、禁用 token、重复惩罚等 logits transforms。若 $p$ 和 $q$ 经过不同处理，精确性证明不再成立。&lt;/p&gt;
&lt;h2&gt;07 一个可单元测试的 PyTorch 验证器&lt;/h2&gt;
&lt;p&gt;下面只实现“一条序列的一轮验证”。输入是已归一化概率，不依赖模型封装。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

def verify_one(
    draft_ids: torch.Tensor,       # [G]
    draft_probs: torch.Tensor,     # [G,V]
    target_probs: torch.Tensor,    # [G+1,V]
    uniforms: torch.Tensor,        # [G]，测试时显式注入
) -&gt; torch.Tensor:
    G, V = draft_probs.shape
    assert draft_ids.shape == (G,)
    assert target_probs.shape == (G + 1, V)

    out: list[torch.Tensor] = []
    for i in range(G):
        token = draft_ids[i]
        q = draft_probs[i, token]
        p = target_probs[i, token]
        alpha = torch.minimum(torch.ones_like(p), p / q.clamp_min(1e-12))

        if uniforms[i] &amp;#x3C; alpha:
            out.append(token)
            continue

        residual = (target_probs[i] - draft_probs[i]).clamp_min(0)
        residual = residual / residual.sum().clamp_min(1e-12)
        replacement = torch.multinomial(residual, num_samples=1)[0]
        out.append(replacement)
        return torch.stack(out)

    bonus = torch.multinomial(target_probs[G], num_samples=1)[0]
    out.append(bonus)
    return torch.stack(out)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可测试例输入：&lt;code&gt;draft_ids=[B]&lt;/code&gt;，&lt;code&gt;draft_probs=[[0.2,0.8]]&lt;/code&gt;，&lt;code&gt;target_probs=[[0.6,0.4],[0.3,0.7]]&lt;/code&gt;，&lt;code&gt;uniforms=[0.7]&lt;/code&gt;。输出应为 &lt;code&gt;[A]&lt;/code&gt;。再用大量随机样本做频率检验，确认结果逼近 &lt;code&gt;(0.6,0.4)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;生产代码还需处理 logits 为 &lt;code&gt;-inf&lt;/code&gt;、零概率、浮点舍入、批内不同拒绝位置与 EOS；教学代码的 &lt;code&gt;clamp_min&lt;/code&gt; 只防除零，不能替代这些策略。&lt;/p&gt;
&lt;h2&gt;08 Target 为什么能并行验证未来位置？&lt;/h2&gt;
&lt;p&gt;draft 已经把候选 &lt;code&gt;y1..yγ&lt;/code&gt; 填进输入，target 可像 prefill 一样对这段做 causal forward。位置 $i$ 的 hidden state 只看确认前缀和 &lt;code&gt;y1..y{i-1}&lt;/code&gt;，正好给出验证 &lt;code&gt;yi&lt;/code&gt; 所需的条件分布。&lt;/p&gt;
&lt;p&gt;对每层，若历史 KV 为 &lt;code&gt;[N,Hkv,L,Dh]&lt;/code&gt;，验证块新产生 KV 为 &lt;code&gt;[N,Hkv,γ,Dh]&lt;/code&gt;；验证 logits 为 &lt;code&gt;[N,γ+1,V]&lt;/code&gt;。最后一维词表通常不必全部长期保存，kernel 可融合采样或只保留所需统计量。&lt;/p&gt;
&lt;p&gt;被拒位置之后的候选 KV 必须回滚或不提交：它们依赖已被替换的错误前缀。常见做法是先暂存 speculative slots，再按每条请求的接受长度更新有效 context length。&lt;/p&gt;
&lt;h2&gt;09 速度取决于接受长度，不只取决于 Draft 大小&lt;/h2&gt;
&lt;p&gt;令一次验证接受的草稿 token 数为 $A\in[0,\gamma]$，本轮推进量为：&lt;/p&gt;
&lt;p&gt;$$
S=A+1,
$$&lt;/p&gt;
&lt;p&gt;其中 &lt;code&gt;+1&lt;/code&gt; 是首次拒绝的修正 token，或全部接受后的 bonus token。平均每次 target 调用推进 $\mathbb E[S]$，但墙钟收益还要扣掉 draft、验证更长序列、采样与状态管理的成本。&lt;/p&gt;
&lt;p&gt;近似可写为：&lt;/p&gt;
&lt;p&gt;$$
\text{speedup}\approx
\frac{\mathbb E[S],T_{target,1}}
{T_{draft,\gamma}+T_{target,\gamma}+T_{verify}}.
$$&lt;/p&gt;
&lt;p&gt;它不是可直接代入的硬件公式，却说明三个杠杆：draft 要便宜、与 target 足够一致、验证 $\gamma$ 个位置不能比单 token target 慢太多。&lt;/p&gt;
&lt;h2&gt;10 Draft 长度为什么不是越大越好？&lt;/h2&gt;
&lt;p&gt;若每个位置条件接受率粗略为 $a$，到第 $k$ 个候选仍全部接受的概率约为 $a^k$。低一致性时，长草稿的大部分尾部都会被计算后丢弃。&lt;/p&gt;
&lt;p&gt;| 情况                                | 增大 $\gamma$ 的可能结果                  |
| ----------------------------------- | ----------------------------------------- |
| $a$ 高、低并发、target memory-bound | 一次推进更多 token，ITL 下降              |
| $a$ 低                              | 被拒后的尾部浪费快速增加                  |
| 高并发                              | 验证块抢占 batch token 预算，吞吐可能下降 |
| draft 很大                          | draft 本身接近一次 target 成本，收益消失  |&lt;/p&gt;
&lt;p&gt;因此需要按 prompt 类型、采样参数、batch size 与负载动态测量，不能只在单请求 greedy 上宣布加速比。&lt;/p&gt;
&lt;h2&gt;11 当前 vLLM 中怎样落地与核实？&lt;/h2&gt;
&lt;p&gt;当前官方文档把投机方法分为 draft model、EAGLE、Multi-Token Prediction（多 token 预测，MTP）、n-gram/suffix 等。配置入口为 &lt;code&gt;--speculative-config&lt;/code&gt;，具体字段与模型兼容性会随版本演进。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vllm serve your-org/target-model \
  --speculative-config \
  &apos;{&quot;method&quot;:&quot;draft_model&quot;,&quot;model&quot;:&quot;your-org/draft-model&quot;,&quot;num_speculative_tokens&quot;:4}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上线前必须用所安装版本的 &lt;code&gt;vllm serve --help&lt;/code&gt; 与官方版本页核对方法名；不要从旧文章复制参数。vLLM 文档也明确提醒：投机解码主要针对中低 QPS、decode 偏显存带宽受限的负载，并不保证所有 prompt 与采样设置都降低 ITL。&lt;/p&gt;
&lt;h2&gt;12 应记录哪些指标？&lt;/h2&gt;
&lt;p&gt;至少同时记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;proposal length：每轮实际提议数；&lt;/li&gt;
&lt;li&gt;accepted tokens 与 acceptance rate；&lt;/li&gt;
&lt;li&gt;mean accepted length / target step；&lt;/li&gt;
&lt;li&gt;draft、target verify、sampler 各自耗时；&lt;/li&gt;
&lt;li&gt;TTFT、ITL/TPOT、E2E 的 p50/p95/p99；&lt;/li&gt;
&lt;li&gt;output tokens/s 与 requests/s；&lt;/li&gt;
&lt;li&gt;batch size、prompt/output 长度、采样参数和拒绝位置分布。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只报 acceptance rate 会误导：接受率 80% 但 draft 占了一半时间，可能仍更慢；只报单请求 ITL 也会掩盖高并发吞吐退化。&lt;/p&gt;
&lt;h2&gt;13 正确性怎样验证？&lt;/h2&gt;
&lt;p&gt;确定性模式可做逐 token 对账：关闭投机与开启投机，在相同模型、tokenizer、greedy 设置下输出 token IDs 必须一致。&lt;/p&gt;
&lt;p&gt;随机模式不能要求相同 seed 后逐 token 完全一致，因为随机数消耗顺序可能不同。应做分布检验：构造极小词表或可枚举模型，运行大量样本，比较首 token 与短序列频率、总变差距离或卡方统计，并覆盖第一位拒绝、中间拒绝、全部接受、EOS 和零概率边界。&lt;/p&gt;
&lt;p&gt;还要断言：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;committed_length = accepted_draft_count + 1
rejected_suffix KV 不可见
每个请求独立维护 RNG 与接受位置
停止条件只检查已提交 token
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;14 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状               | 常见原因                           | 最短检查                             |
| ------------------ | ---------------------------------- | ------------------------------------ |
| Greedy 输出不同    | position、KV 回滚或 bonus 对齐错   | 在 &lt;code&gt;γ=2&lt;/code&gt; 下逐位置比较 target logits  |
| 随机输出分布漂移   | 拒绝后仍从原 $p$ 采样              | 枚举二词表，检查残差分布             |
| 接受率高但更慢     | draft/验证开销超过串行轮数收益     | 分段计时并与相同 batch 基线比较      |
| EOS 后仍提交 token | 对草稿尾部一次性应用停止规则       | 只对已提交前缀逐 token 检查          |
| batch 内串线       | 每条请求拒绝位置与 KV 长度未独立   | 构造一条全接受、一条首位拒绝的 batch |
| top-p 下无法复现   | $p,q$ 使用了不同 logits transforms | 记录过滤后的支持集和概率和           |&lt;/p&gt;
&lt;h2&gt;15 它与相近方法的边界&lt;/h2&gt;
&lt;p&gt;Chunked prefill 是把一个长输入拆到多轮，保护 decode 延迟；speculative decoding 是让一次 target decode 尝试推进多个输出。Prefix Caching（前缀缓存）复用其他请求已计算的 prompt KV，只减少重复 prefill。&lt;/p&gt;
&lt;p&gt;EAGLE、MTP 与独立小模型都可充当 proposer（提议器），差别在如何低成本预测未来 token，不改变“提出—验证—提交”的核心契约。Medusa 等多头方案可形成候选树；候选越多，target 验证与调度形状也越复杂。&lt;/p&gt;
&lt;h2&gt;16 失败场景&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;代码生成、罕见领域或高温采样使 draft 与 target 分歧大；&lt;/li&gt;
&lt;li&gt;target 很小，额外调度和 sampler 开销不可忽略；&lt;/li&gt;
&lt;li&gt;高 QPS 已通过大 continuous batch 吃满 GPU，验证块反而挤压吞吐；&lt;/li&gt;
&lt;li&gt;draft 占用额外权重显存，使 KV 容量下降并增加抢占；&lt;/li&gt;
&lt;li&gt;tokenizer、词表或 logits processor 不兼容，无法定义逐 token 概率比；&lt;/li&gt;
&lt;li&gt;多租户服务没有隔离草稿状态、RNG 与 metrics。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;投机解码减少的是昂贵 target 的串行调用次数，不保证减少总 FLOPs。&lt;/li&gt;
&lt;li&gt;target 可以并行验证草稿块，但只能提交首次拒绝之前的连续前缀。&lt;/li&gt;
&lt;li&gt;随机采样必须用 $\min(1,p/q)$ 接受率和 $(p-q)_+$ 残差修正，才能保持目标分布。&lt;/li&gt;
&lt;li&gt;性能由接受长度、draft 成本、验证成本和并发负载共同决定，必须同时看延迟、吞吐与正确性。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;18 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;词表为 &lt;code&gt;A/B/C&lt;/code&gt;，$q=(0.5,0.3,0.2)$，$p=(0.2,0.5,0.3)$。draft 提议 &lt;code&gt;A&lt;/code&gt; 时接受率是多少？若拒绝，写出归一化残差分布。&lt;/li&gt;
&lt;li&gt;令 $\gamma=4$，一轮在第 3 个候选拒绝。哪些 token 与 KV slots 可以提交，下一轮 context length 增加多少？&lt;/li&gt;
&lt;li&gt;为 &lt;code&gt;verify_one&lt;/code&gt; 增加 EOS 处理和确定性 RNG；用 100,000 次抽样验证输出频率逼近 target 分布。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Leviathan et al., &lt;a href=&quot;https://arxiv.org/abs/2211.17192&quot;&gt;Fast Inference from Transformers via Speculative Decoding&lt;/a&gt;，给出保持目标分布的经典 speculative sampling 算法。&lt;/li&gt;
&lt;li&gt;Chen et al., &lt;a href=&quot;https://arxiv.org/abs/2302.01318&quot;&gt;Accelerating Large Language Model Decoding with Speculative Sampling&lt;/a&gt;，独立系统化研究草稿—验证加速。&lt;/li&gt;
&lt;li&gt;Cai et al., &lt;a href=&quot;https://arxiv.org/abs/2401.10774&quot;&gt;Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads&lt;/a&gt;，用多解码头构造候选树。&lt;/li&gt;
&lt;li&gt;Li et al., &lt;a href=&quot;https://arxiv.org/abs/2401.15077&quot;&gt;EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty&lt;/a&gt;，从特征层改进草稿质量。&lt;/li&gt;
&lt;li&gt;vLLM, &lt;a href=&quot;https://docs.vllm.ai/en/latest/features/speculative_decoding/&quot;&gt;Speculative Decoding 官方文档&lt;/a&gt;，列出当前支持方法、配置入口与适用负载。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;19 下一篇预告&lt;/h2&gt;
&lt;p&gt;投机解码减少一条请求的 target decode 轮数，但大量请求若共享同一段 system prompt 或长文档，仍会反复做相同 prefill。下一篇将拆解 Automatic Prefix Caching：怎样用链式块哈希找到最长可复用前缀，又如何避免错误共享与多租户侧信道。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一个长 Prompt 为何让所有流式输出停顿？Chunked Prefill 的 Token 预算</title><link>https://zwjcode.cn/blog/chunked-prefill-token-budget-decode-latency</link><guid isPermaLink="true">https://zwjcode.cn/blog/chunked-prefill-token-budget-decode-latency</guid><description>从 prefill 阻塞 decode 的延迟尖峰出发，手算 token budget 下的分块调度，解释 KV 递增、优先级、容量保护与当前 vLLM 配置验证。</description><pubDate>Sat, 19 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把请求调度粒度降到每次模型迭代：短请求完成后，新请求下一轮就能补位。然而一个 32K-token prompt 若作为不可分割的 prefill 塞进某一轮，这一轮的 GPU 时间仍会突然变长；原本每 30 ms 输出一个 token 的聊天请求，可能数百毫秒没有新 token。&lt;/p&gt;
&lt;p&gt;Chunked Prefill（分块预填充）把长 prompt 拆成多个 token chunks（token 块），每轮只计算一部分，并把剩余预算留给 decode。它不截断 prompt，也不改变最终 KV Cache；改变的是计算发生的时间顺序。&lt;/p&gt;
&lt;h2&gt;01 Prefill 为什么会阻塞 Decode？&lt;/h2&gt;
&lt;p&gt;假设 GPU 上已有 64 条请求在 decode，每轮各新增 1 token。此时到达一个 4096-token prompt：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;不切分：
轮 t      [64 decode tokens]
轮 t+1    [4096 prefill tokens]  &amp;#x3C;- 超长迭代
轮 t+2    [64 decode tokens]

切分，预算 512：
轮 t+1    [64 decode] [448 prefill]
轮 t+2    [64 decode] [448 prefill]
...       保持每轮工作量有界
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Decode 请求并没有丢失或抢占；它们只是必须等待同一个 forward 完成。这个现象称为 head-of-line blocking（队首阻塞）：排在前面的重工作让后面的轻工作一起等待。&lt;/p&gt;
&lt;h2&gt;02 “切 prompt”不会破坏因果注意力吗？&lt;/h2&gt;
&lt;p&gt;对长度为 $P$ 的 prompt，完整 prefill 产生每层&lt;/p&gt;
&lt;p&gt;$$
K,V\in\mathbb R^{B\times H_{kv}\times P\times D_h}.
$$&lt;/p&gt;
&lt;p&gt;若先计算前 $c$ 个 token，就得到前缀 KV：&lt;/p&gt;
&lt;p&gt;$$
K_{0:c},V_{0:c}\in\mathbb R^{B\times H_{kv}\times c\times D_h}.
$$&lt;/p&gt;
&lt;p&gt;下一块位置 &lt;code&gt;[c:c+d)&lt;/code&gt; 的 query 读取已缓存的 &lt;code&gt;[0:c)&lt;/code&gt; KV，并与本块产生的 KV 做因果注意力，计算完成后把 cache 扩为 &lt;code&gt;[0:c+d)&lt;/code&gt;。因为 causal mask 下位置 $i$ 本来就只依赖 &lt;code&gt;0..i&lt;/code&gt;，按顺序分块与一次算完数学上等价。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  P0[chunk 0&amp;#x3C;br/&gt;tokens 0:4] --&gt; K0[KV 0:4]
  K0 --&gt; P1[chunk 1&amp;#x3C;br/&gt;tokens 4:8]
  P1 --&gt; K1[KV 0:8]
  K1 --&gt; P2[chunk 2&amp;#x3C;br/&gt;tokens 8:10]
  P2 --&gt; K2[完整 prompt KV 0:10]
  K2 --&gt; D[生成第一个输出 token]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 用 8-token 预算手算三轮调度&lt;/h2&gt;
&lt;p&gt;本轮 token budget 为 8，已有 3 条 decode 请求 D1/D2/D3，每条占 1 token；等待队列有一个 12-token prompt P。调度器优先保留 decode，剩余 5 个 token 给 prefill：&lt;/p&gt;
&lt;p&gt;| 轮次 | Decode tokens | P 本轮 chunk |    P 累计完成 | 预算使用 |
| ---: | ------------: | -----------: | ------------: | -------: |
|    1 |             3 |            5 |          5/12 |      8/8 |
|    2 |             3 |            5 |         10/12 |      8/8 |
|    3 |             3 |            2 |         12/12 |      5/8 |
|    4 |             4 |            0 | P 开始 decode |      4/8 |&lt;/p&gt;
&lt;p&gt;P 的 Time To First Token（首 token 延迟，TTFT）增加了调度轮数，但 D1–D3 的 Time Per Output Token（逐 token 间隔，TPOT）避免了单轮 12-token prefill 的尖峰。这是明确的延迟交换，不是免费优化。&lt;/p&gt;
&lt;h2&gt;04 Token Budget 是上限，不是精确耗时&lt;/h2&gt;
&lt;p&gt;令第 $t$ 轮 decode 序列集合为 $D_t$，prefill chunks 为 $C_t$：&lt;/p&gt;
&lt;p&gt;$$
|D_t|+\sum_{c\in C_t}|c|\le B_{tok},
$$&lt;/p&gt;
&lt;p&gt;其中 $B_{tok}$ 对应 &lt;code&gt;max_num_batched_tokens&lt;/code&gt;。但相同 token 数不保证相同耗时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;decode token 读取的历史长度不同；&lt;/li&gt;
&lt;li&gt;prefill chunk 在更长前缀后计算，attention 工作量更大；&lt;/li&gt;
&lt;li&gt;batch shape、kernel、量化与 GPU 架构会改变效率；&lt;/li&gt;
&lt;li&gt;多模态 token 可能对应额外 encoder 工作。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此 budget 是稳定迭代大小的第一近似。最终仍要从 per-iteration trace 拟合真实耗时，而不是把 token 数直接当毫秒数。&lt;/p&gt;
&lt;h2&gt;05 一个最小可检查的分块调度器&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from dataclasses import dataclass

@dataclass
class Prefill:
    rid: str
    remaining: int

def schedule_step(
    decode_ids: list[str],
    prefills: list[Prefill],
    token_budget: int,
):
    if len(decode_ids) &gt; token_budget:
        raise ValueError(&quot;decode 已超过本轮 token budget&quot;)

    budget = token_budget - len(decode_ids)
    chunks: list[tuple[str, int]] = []

    for req in prefills:          # 教学版 FCFS
        if budget == 0:
            break
        take = min(req.remaining, budget)
        if take:
            chunks.append((req.rid, take))
            req.remaining -= take
            budget -= take

    return {
        &quot;decode&quot;: decode_ids,
        &quot;prefill_chunks&quot;: chunks,
        &quot;unused_tokens&quot;: budget,
    }

p = [Prefill(&quot;P&quot;, 12)]
for _ in range(3):
    print(schedule_step([&quot;D1&quot;, &quot;D2&quot;, &quot;D3&quot;], p, token_budget=8))
# 每轮 P 分别取 5、5、2 tokens
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入是本轮 decode IDs、未完成 prompt 与 token budget；输出明确列出两类工作。测试应断言每轮使用量不超预算、chunk 长度为正、同一 prompt 的已计算区间不重叠且连续。&lt;/p&gt;
&lt;h2&gt;06 张量怎样从一个 Chunk 进入模型？&lt;/h2&gt;
&lt;p&gt;假设 P 的总长度为 12，本轮计算位置 &lt;code&gt;[5:10)&lt;/code&gt;，chunk 长度 $C=5$。概念上的输入包括：&lt;/p&gt;
&lt;p&gt;| 张量/元数据    | Shape          | 含义                         |
| -------------- | -------------- | ---------------------------- |
| &lt;code&gt;input_ids&lt;/code&gt;    | &lt;code&gt;[5]&lt;/code&gt;          | 本轮 5 个 prompt token       |
| &lt;code&gt;positions&lt;/code&gt;    | &lt;code&gt;[5]&lt;/code&gt;          | &lt;code&gt;[5,6,7,8,9]&lt;/code&gt;，不能从 0 重置 |
| &lt;code&gt;slot_mapping&lt;/code&gt; | &lt;code&gt;[5]&lt;/code&gt;          | 每个新 KV 写入哪个物理 slot  |
| &lt;code&gt;block_table&lt;/code&gt;  | &lt;code&gt;[ceil(10/B)]&lt;/code&gt; | 前缀与新块的物理寻址         |
| &lt;code&gt;context_len&lt;/code&gt;  | scalar         | 本轮结束后为 10              |&lt;/p&gt;
&lt;p&gt;模型内部 hidden states 为 &lt;code&gt;[5,H]&lt;/code&gt;；每层新 K/V 通常可看作 &lt;code&gt;[H_kv,5,D_h]&lt;/code&gt;，attention 还要读取此前 &lt;code&gt;[H_kv,5,D_h]&lt;/code&gt; 的前缀 cache。不同实现会 flatten token 维或合并层维，但这五类语义不能丢。&lt;/p&gt;
&lt;h2&gt;07 Position 与 Mask 最容易怎样错？&lt;/h2&gt;
&lt;p&gt;第二块从绝对位置 $c$ 开始。如果错误地把 position IDs 重置为 &lt;code&gt;0..d-1&lt;/code&gt;，RoPE（旋转位置编码）相位会重复，最终结果不再等价于完整 prefill。&lt;/p&gt;
&lt;p&gt;本块内位置 $i$ 的 query 可见：&lt;/p&gt;
&lt;p&gt;$$
{0,1,\dots,c+i},
$$&lt;/p&gt;
&lt;p&gt;而不是只看本块的 &lt;code&gt;c..c+i&lt;/code&gt;。调试时用一个 6-token prompt 比较：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;full_logits = model_full_prefill(tokens)
chunked_logits = model_chunked_prefill(tokens, chunks=[2, 3, 1])
torch.testing.assert_close(chunked_logits, full_logits, rtol=1e-4, atol=1e-5)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产引擎不一定公开上述两个函数；可分别启动开启/关闭 chunked prefill 的同版本服务，在 greedy、固定模型与固定 dtype 下比较生成 token IDs。&lt;/p&gt;
&lt;h2&gt;08 为什么通常让 Decode 优先？&lt;/h2&gt;
&lt;p&gt;已在流式输出的请求对停顿很敏感。先为每条 decode 分配 1 token，再用剩余 budget 做 prefill，可以限制 ITL 尖峰。伪代码是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;budget = max_num_batched_tokens
schedule running decode requests, each consumes 1
schedule cached/resumed work
fill remaining budget with prefill chunks
execute one model step
commit KV and request progress atomically
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但严格 decode-first 也可能使 prefill starvation：当活跃 decode 数持续占满预算，新 prompt 永远得不到首 token。解决办法包括保留 prefill 配额、限制 decode admission、按等待时间 aging，或为不同服务等级拆池。&lt;/p&gt;
&lt;h2&gt;09 Chunk Size 怎样影响 TTFT、TPOT 与吞吐？&lt;/h2&gt;
&lt;p&gt;小 chunk：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单轮更短，decode 的 TPOT/ITL 更平滑；&lt;/li&gt;
&lt;li&gt;prompt 要跨更多轮，调度与 kernel 开销增加；&lt;/li&gt;
&lt;li&gt;矩阵更小，GPU 利用率可能下降，TTFT 变长。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大 chunk：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;prefill GEMM 更高效、prompt 更快完成；&lt;/li&gt;
&lt;li&gt;单轮耗时尖峰更大，decode 尾延迟上升；&lt;/li&gt;
&lt;li&gt;更容易触碰 KV 容量并导致抢占。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要只 sweep 固定 &lt;code&gt;chunk_size&lt;/code&gt;。在 vLLM 中，实际 chunk 由本轮剩余的 &lt;code&gt;max_num_batched_tokens&lt;/code&gt; 决定，因此还会随 decode batch 大小动态变化。&lt;/p&gt;
&lt;h2&gt;10 当前 vLLM 配置的语义&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;code&gt;SchedulerConfig&lt;/code&gt; 中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;enable_chunked_prefill=True&lt;/code&gt;：prefill 可按剩余 token budget 分块；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_num_batched_tokens&lt;/code&gt;：一次迭代最多处理多少 tokens；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_num_seqs&lt;/code&gt;：一次迭代最多包含多少 sequences；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;long_prefill_token_threshold&lt;/code&gt;：多长才视作 long prefill；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_num_partial_prefills&lt;/code&gt;：最多同时部分完成多少条 prefill；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scheduler_reserve_full_isl&lt;/code&gt;：准入时检查完整 input sequence 是否能放入 KV cache，避免过度准入和反复抢占。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vllm serve your-org/your-model \
  --enable-chunked-prefill \
  --max-num-batched-tokens 2048 \
  --max-num-seqs 128 \
  --scheduler-reserve-full-isl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认值会随版本、模型与 usage context 变化。部署时保存 &lt;code&gt;vllm --version&lt;/code&gt; 和最终解析后的 engine config；不要从一篇旧博客复制默认值后假设它永久成立。&lt;/p&gt;
&lt;h2&gt;11 KV 容量为什么要按完整 Prompt 预留？&lt;/h2&gt;
&lt;p&gt;若只看到第一块 512 tokens 就准入一个 64K prompt，很多长 prompt 可以同时“付得起首付”，却都没有足够 KV blocks 完成。后续每条继续申请 blocks，系统便反复抢占与重算，形成 cache thrashing（缓存颠簸）。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;scheduler_reserve_full_isl&lt;/code&gt; 的思路是 admission 时按完整 Input Sequence Length（输入序列长度，ISL）检查容量。它更保守，可能降低瞬时并发，却避免把不可能同时完成的请求全部推进系统。是否预留还要结合 prefix caching、滑动窗口和模型的 KV 布局验证。&lt;/p&gt;
&lt;h2&gt;12 怎样设计一组有教学价值的压测？&lt;/h2&gt;
&lt;p&gt;构造三种可控流量：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;纯 decode 基线：prompt 16、output 512；&lt;/li&gt;
&lt;li&gt;短交互：prompt 256、output 64；&lt;/li&gt;
&lt;li&gt;长文档突发：prompt 16K/32K、output 32。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在相同 offered load 下比较 chunked prefill 开/关，并 sweep token budget。至少记录：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;TTFT / TPOT / ITL / E2E: p50, p95, p99
prompt tokens/s, output tokens/s
per-step prefill tokens, decode tokens, step latency
waiting/running requests, KV utilization, preemptions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键图不是单一平均柱状图，而是“长 prompt 到达时刻”附近的 step latency 与各流式请求 ITL 时间线。它能直接显示阻塞尖峰是否被摊平。&lt;/p&gt;
&lt;h2&gt;13 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                          | 常见原因                            | 最短检查                                 |
| ----------------------------- | ----------------------------------- | ---------------------------------------- |
| 开启后输出与完整 prefill 不同 | position、mask 或 KV 写入偏移错误   | 用 &lt;code&gt;[2,3,1]&lt;/code&gt; 不规则分块逐 token 对账     |
| TPOT 仍周期性尖峰             | token budget 过大或多长 prompt 同轮 | 记录每轮 prefill tokens 与 kernel 时间   |
| TTFT 急剧变差                 | chunk 太小或 decode 永久占满预算    | 查看每条 prefill 的 progress 与等待轮数  |
| KV 明明够却频繁抢占           | 只按首 chunk 准入，未考虑完整 ISL   | 打开完整 ISL 容量检查并对账 blocks       |
| GPU 利用率下降                | chunks 过碎、shape 变化导致图复用差 | sweep budget 并看 kernel/CUDA graph 命中 |
| 多模态输入在边界报错          | 图像 embedding 不允许任意切半       | 核对当前版本的 multimodal chunk 约束     |&lt;/p&gt;
&lt;h2&gt;14 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;Continuous batching 在每轮替换请求；chunked prefill 在多轮之间拆分同一个 prompt。Prefix Caching（前缀缓存）让相同前缀直接复用已有 KV，减少必须 prefill 的 token；它不能帮助从未见过的长 prompt。&lt;/p&gt;
&lt;p&gt;Disaggregated Prefill/Decode（预填充—解码分离）把两类工作放到不同 GPU 池，进一步隔离干扰，但增加 KV 传输、路由和容量规划复杂度。Speculative Decoding（投机解码）则用 draft model 一轮提出多个候选 token，目标是减少 decode 串行轮数；它解决的是另一条轴。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;不可分割的长 prefill 会拉长整次 forward，使同批 decode 请求出现 ITL/TPOT 尖峰。&lt;/li&gt;
&lt;li&gt;因果注意力允许 prompt 按顺序分块；每块必须继承绝对位置、前缀 KV 与正确可见范围。&lt;/li&gt;
&lt;li&gt;token budget 把单轮工作量控制在近似上限，通常以 TTFT 换取更平滑的 TPOT。&lt;/li&gt;
&lt;li&gt;过度准入部分 prefill 会造成 KV 颠簸；配置必须与完整输入长度、KV 容量和抢占指标联合验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;token budget 为 16，已有 6 条 decode；两个 prompt 分别剩 18 和 5 tokens。按 FCFS 写出前三轮 chunks，并说明第二个 prompt 的 TTFT 风险。&lt;/li&gt;
&lt;li&gt;对总长 10 的 prompt，按 &lt;code&gt;[4,3,3]&lt;/code&gt; 分块。写出每块的 position IDs、结束后的 context length，以及最后一块每个 query 能看到的 key 范围。&lt;/li&gt;
&lt;li&gt;修改教学调度器，为 prefill 保留每轮至少 25% budget；构造 decode 永不清空的流量，验证长 prompt 最终仍能完成。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Agrawal et al., &lt;a href=&quot;https://arxiv.org/abs/2308.16369&quot;&gt;SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills&lt;/a&gt;，提出用 chunked prefill 消除 prefill-decode 干扰气泡。&lt;/li&gt;
&lt;li&gt;Agrawal et al., &lt;a href=&quot;https://arxiv.org/abs/2403.02310&quot;&gt;Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve&lt;/a&gt;，系统评估吞吐与调度延迟权衡。&lt;/li&gt;
&lt;li&gt;Zhong et al., &lt;a href=&quot;https://arxiv.org/abs/2401.09670&quot;&gt;DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving&lt;/a&gt;，从分离部署角度隔离两阶段干扰。&lt;/li&gt;
&lt;li&gt;Patel et al., &lt;a href=&quot;https://arxiv.org/abs/2311.18677&quot;&gt;Splitwise: Efficient Generative LLM Inference Using Phase Splitting&lt;/a&gt;，研究 prefill/decode 分阶段资源配置。&lt;/li&gt;
&lt;li&gt;vLLM, &lt;a href=&quot;https://docs.vllm.ai/en/latest/api/vllm/config/scheduler/&quot;&gt;SchedulerConfig 官方文档&lt;/a&gt;，定义当前 chunked prefill、token budget 与容量保护参数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;Chunked prefill 控制了每轮输入工作量，却没有减少生成 $k$ 个输出通常需要 $k$ 次串行大模型前向。下一篇将进入 speculative decoding：小 draft model 一次提议多个 token，大模型如何并行验证并保持目标分布不变。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>请求不断到达时为何还要等整批结束？Continuous Batching 的逐轮调度</title><link>https://zwjcode.cn/blog/continuous-batching-iteration-level-scheduling</link><guid isPermaLink="true">https://zwjcode.cn/blog/continuous-batching-iteration-level-scheduling</guid><description>从静态批处理的尾部空洞出发，手算逐迭代请求替换，解释 prefill/decode 混合、token 预算、KV 容量、延迟指标与可验证的 vLLM 配置。</description><pubDate>Fri, 18 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 PagedAttention（分页注意力）让每条请求的 KV Cache 按块增长，避免为最大长度预留连续显存。但“能把更多请求放进显存”不等于“GPU 每一轮都在服务最有价值的请求”。若把 16 条请求组成固定批次，必须等最慢的一条生成结束才接纳下一批，早早结束的 15 个位置会一直空着。&lt;/p&gt;
&lt;p&gt;Continuous Batching（连续批处理，也称 iteration-level scheduling，逐迭代调度）把批次边界从“整条请求”下移到“模型的一次前向”。每生成一轮，scheduler（调度器）都移走已完成请求，再从等待队列补入新请求。&lt;/p&gt;
&lt;h2&gt;01 静态批处理浪费的不是 Padding，而是空轮次&lt;/h2&gt;
&lt;p&gt;设四条请求都已完成 prefill（提示词阶段），还需生成 &lt;code&gt;[1, 2, 4, 4]&lt;/code&gt; 个 token。静态批处理锁住这四个位置直到最长请求结束：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;decode 轮次       1    2    3    4
A: 还需 1         ●    -    -    -
B: 还需 2         ●    ●    -    -
C: 还需 4         ●    ●    ●    ●
D: 还需 4         ●    ●    ●    ●
有效槽位          4    3    2    2   =&gt; 11 / 16
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;-&lt;/code&gt; 不是输入张量里的 padding token，而是本可让新请求执行、却被批次边界闲置的 decode slot。若等待队列里还有 E、F，连续批处理能在下一轮立即补位。&lt;/p&gt;
&lt;h2&gt;02 调度器每一轮看见什么状态？&lt;/h2&gt;
&lt;p&gt;一条生成请求至少携带：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;prompt_tokens&lt;/code&gt;：尚未计算或已经写入 KV Cache 的输入 token；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generated_tokens&lt;/code&gt;：当前已经生成的输出；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_new_tokens&lt;/code&gt;、EOS 与停止字符串：完成条件；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;block_table&lt;/code&gt;：逻辑 token 到物理 KV blocks 的映射；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;arrival_time&lt;/code&gt; 与 priority：排队次序；&lt;/li&gt;
&lt;li&gt;sampling state：随机数、temperature、top-p 等采样状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;调度器维护三个集合：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  Q[waiting&amp;#x3C;br/&gt;等待 admission] --&gt;|KV 与 token 预算允许| R[running&amp;#x3C;br/&gt;本轮执行]
  R --&gt;|未结束，保留 KV| R
  R --&gt;|EOS / 长度 / stop| F[finished&amp;#x3C;br/&gt;释放 KV blocks]
  R --&gt;|显存压力，抢占| Q
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一次 scheduler step 输出的不是永远固定的 &lt;code&gt;[N,L]&lt;/code&gt; 批，而是一组本轮要计算的 token 与对应位置元数据。模型执行后，输出 token、KV 长度和完成状态再反馈给下一轮。&lt;/p&gt;
&lt;h2&gt;03 用四轮手算请求怎样被替换&lt;/h2&gt;
&lt;p&gt;令最大并发序列数为 3，A/B/C 在 &lt;code&gt;t=0&lt;/code&gt; 已进入 decode，所需输出分别为 1、3、2；D 在第 1 轮结束后到达。&lt;/p&gt;
&lt;p&gt;| 轮次 | 轮前 running | 本轮各算 1 token | 轮后完成   | 补入请求 |
| ---: | ------------ | ---------------- | ---------- | -------- |
|    1 | A, B, C      | A₁, B₁, C₁       | A          | D        |
|    2 | B, C, D      | B₂, C₂, D₁       | C          | E        |
|    3 | B, D, E      | B₃, D₂, E₁       | B          | F        |
|    4 | D, E, F      | D₃, E₂, F₁       | 视停止条件 | 下一条   |&lt;/p&gt;
&lt;p&gt;静态批会让 A 的位置闲置两轮，且 D 必须等 B、C 全结束；连续批让空位只存在于“本轮结束到下轮开始”的调度间隙。&lt;/p&gt;
&lt;h2&gt;04 Prefill 与 Decode 为何不能只按“请求数”计费？&lt;/h2&gt;
&lt;p&gt;对 decoder-only Transformer：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prefill（提示词预填充）一次计算 prompt 中很多 token，矩阵乘规模大，通常更偏 compute-bound（计算受限）；&lt;/li&gt;
&lt;li&gt;Decode（逐 token 解码）每条请求每轮通常只新增 1 token，却读取全部历史 KV，常更偏 memory-bandwidth-bound（显存带宽受限）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若本轮有 $n_d$ 条 decode 请求和若干 prefill token $p_i$，可把工作预算粗略写成&lt;/p&gt;
&lt;p&gt;$$
T_{\text{scheduled}}=n_d+\sum_i p_i.
$$&lt;/p&gt;
&lt;p&gt;它不是精确运行时间模型：一个 decode token 会读不同长度的 KV，一个 prefill token 的 attention 上下文也不同。但它比“本轮有多少条请求”更接近实际工作量，适合作为第一层容量阀门。&lt;/p&gt;
&lt;h2&gt;05 两个容量上限必须同时满足&lt;/h2&gt;
&lt;p&gt;当前 vLLM 的 scheduler 公开两个核心限制：&lt;/p&gt;
&lt;p&gt;$$
N_{\text{seq}}\leq \texttt{max_num_seqs},
$$&lt;/p&gt;
&lt;p&gt;$$
T_{\text{scheduled}}\leq \texttt{max_num_batched_tokens}.
$$&lt;/p&gt;
&lt;p&gt;前者约束序列数及其 CPU 元数据、采样和 kernel batch 维度；后者约束单轮 token 工作量。除此之外还必须满足 KV Cache 可用 blocks，否则请求不能 admission（准入），或已有请求需要 preemption（抢占）。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;waiting queue
    |
    v
[序列数上限] --不满足--&gt; 等待
    |
    v
[本轮 token 预算] --不满足--&gt; 等待/只取部分 prefill
    |
    v
[KV blocks 足够] --不满足--&gt; 等待/抢占
    |
    v
本轮 model runner
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;06 一个透明的教学版逐轮调度器&lt;/h2&gt;
&lt;p&gt;下面只模拟已完成 prefill 的 decode 请求。生产系统还要处理 KV blocks、prefill、sampling 和多 GPU 一致性。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import deque
from dataclasses import dataclass

@dataclass
class Request:
    rid: str
    remaining: int

def continuous_decode(requests: list[Request], max_num_seqs: int):
    waiting = deque(requests)
    running: list[Request] = []
    timeline: list[list[str]] = []

    while waiting or running:
        while waiting and len(running) &amp;#x3C; max_num_seqs:
            running.append(waiting.popleft())

        # 一次 forward：每条 running 请求恰好新增一个 token
        timeline.append([r.rid for r in running])
        for r in running:
            r.remaining -= 1

        # 在迭代边界释放完成请求；下一轮才补入新请求
        running = [r for r in running if r.remaining &gt; 0]

    return timeline

requests = [Request(&quot;A&quot;, 1), Request(&quot;B&quot;, 3), Request(&quot;C&quot;, 2), Request(&quot;D&quot;, 3)]
print(continuous_decode(requests, max_num_seqs=3))
# [[&apos;A&apos;, &apos;B&apos;, &apos;C&apos;], [&apos;B&apos;, &apos;C&apos;, &apos;D&apos;], [&apos;B&apos;, &apos;D&apos;], [&apos;D&apos;]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入是请求及剩余生成长度；输出是每轮真正参与 forward 的 request IDs。最重要的断言是：每条请求出现的轮数恰好等于其 &lt;code&gt;remaining&lt;/code&gt; 初值，且完成后不再出现。&lt;/p&gt;
&lt;h2&gt;07 为什么吞吐与延迟可能同时改善？&lt;/h2&gt;
&lt;p&gt;吞吐常以 output tokens/s 或 completed requests/s 衡量。连续补位提高 GPU 有效 batch，通常增加吞吐；短请求也不必等待同批长请求，可能同时降低排队时间。&lt;/p&gt;
&lt;p&gt;但“延迟”至少拆成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Time To First Token（首 token 延迟，TTFT）：到达至第一个输出；&lt;/li&gt;
&lt;li&gt;Time Per Output Token（逐 token 间隔，TPOT）：开始输出后的平均间隔；&lt;/li&gt;
&lt;li&gt;Inter-Token Latency（token 间延迟，ITL）：每两个流式 token 的具体时间间隔；&lt;/li&gt;
&lt;li&gt;End-to-End Latency（端到端延迟，E2E）：到达至完成。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若为追求吞吐不断扩大 batch，每轮 kernel 更久，已在 decode 的请求反而要更久才等到下一 token，TPOT/P99 可能恶化。没有 arrival rate、prompt/output 长度分布和延迟分位数的单个 tokens/s 没有可比性。&lt;/p&gt;
&lt;h2&gt;08 FCFS、Priority 与公平性&lt;/h2&gt;
&lt;p&gt;First-Come, First-Served（先到先服务，FCFS）容易解释，但超长 prompt 可能挡住短请求。Priority Scheduling（优先级调度）能为交互流量保留低延迟，却可能让低优先级请求 starvation（饥饿）。&lt;/p&gt;
&lt;p&gt;工程上要明确：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;priority 是否可由外部用户随意指定；&lt;/li&gt;
&lt;li&gt;同优先级怎样按到达时间打破平局；&lt;/li&gt;
&lt;li&gt;是否做 aging，让等待越久的请求逐渐提升权重；&lt;/li&gt;
&lt;li&gt;租户级配额是否在单请求 priority 之前生效。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当前 vLLM 的 &lt;code&gt;--scheduling-policy&lt;/code&gt; 支持 &lt;code&gt;fcfs&lt;/code&gt; 与 &lt;code&gt;priority&lt;/code&gt;。不要依赖内部 scheduler 类的私有字段；优先通过稳定 CLI/EngineArgs 配置，并锁定部署版本。&lt;/p&gt;
&lt;h2&gt;09 当前 vLLM 的可审计启动配置&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vllm serve your-org/your-model \
  --max-num-seqs 128 \
  --max-num-batched-tokens 4096 \
  --scheduling-policy fcfs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些数值只是实验起点，不是通用最佳值。记录模型、dtype、tensor parallel 大小、GPU、&lt;code&gt;max_model_len&lt;/code&gt;、KV cache 容量与流量分布，再分别 sweep 两个上限。&lt;/p&gt;
&lt;p&gt;一次完整实验至少输出：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;offered_rps, admitted_rps, completed_rps
prompt_tokens/s, output_tokens/s
TTFT p50/p95/p99, TPOT p50/p95/p99
queue_time, running_sequences, waiting_sequences
KV usage, preemptions, OOM/rejection count
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;10 抢占为什么可能让系统越忙越慢？&lt;/h2&gt;
&lt;p&gt;若调度器过度 admission，KV blocks 不够时必须暂停某些请求。被抢占请求可能需要 swap（换出）或稍后 recompute（重算）已有状态。两者都消耗带宽或算力，并拉长尾延迟。&lt;/p&gt;
&lt;p&gt;一种危险反馈环是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[并发上限过高] --&gt; B[KV 压力]
  B --&gt; C[频繁抢占]
  C --&gt; D[重算/换入开销]
  D --&gt; E[每轮变慢、队列增长]
  E --&gt; A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此 &lt;code&gt;max_num_seqs&lt;/code&gt; 不能只调到 OOM 前一格。看见 preemption 增多时，应同时检查请求长度分布、KV block 水位、token budget 与 prefix sharing，而非只加队列长度。&lt;/p&gt;
&lt;h2&gt;11 正确性要验证哪些跨轮状态？&lt;/h2&gt;
&lt;p&gt;连续替换 batch 后，最危险的错误是“张量位置变了，请求状态没有一起移动”。用确定性 greedy decoding 建立以下测试：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;单请求离线生成作为 reference；&lt;/li&gt;
&lt;li&gt;同一请求与不同到达时刻、不同输出长度的干扰请求混跑；&lt;/li&gt;
&lt;li&gt;比较最终 token IDs，而非只比字符串；&lt;/li&gt;
&lt;li&gt;在请求完成、取消、抢占、KV block 边界处记录 request ID；&lt;/li&gt;
&lt;li&gt;断言每条序列的 position、block table、sampling state 始终配套。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对 stochastic sampling（随机采样），若 RNG 依赖 batch position，请求重排可能改变输出。可按 request ID 派生独立随机流，并把“批次组成变化不改变同请求随机序列”写成测试契约。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                       | 常见原因                             | 最短检查                               |
| -------------------------- | ------------------------------------ | -------------------------------------- |
| 请求越多 tokens/s 反而下降 | batch 已过饱和或抢占频繁             | 画吞吐、TPOT、preemption 随并发曲线    |
| 短请求 P99 很高            | FCFS 前有长 prefill                  | 分开统计 queue 与 prefill 时间         |
| 流式输出偶发长停顿         | 混入大 prefill 或单轮 token 预算过大 | 记录每轮 prefill/decode token 数和耗时 |
| 混批结果与单请求不同       | KV/position/RNG 随 batch 重排错位    | 用两个请求在完成边界逐 token 对账      |
| 仍有显存却拒绝请求         | KV blocks、序列上限或预留水位命中    | 同时打印三个准入条件                   |
| 平均延迟好但用户仍投诉     | 尾延迟或不同租户被平均掩盖           | 按长度、租户与优先级看 p95/p99         |&lt;/p&gt;
&lt;h2&gt;13 它与 Dynamic Batching 有什么不同？&lt;/h2&gt;
&lt;p&gt;Dynamic Batching（动态批处理）常在请求进入模型前等待一个短窗口，把同时到达的请求凑成一批；批次一旦开始仍可能锁到整条请求结束。Continuous Batching 会在&lt;strong&gt;每次模型迭代后&lt;/strong&gt;重新组成批次。&lt;/p&gt;
&lt;p&gt;PagedAttention 解决 KV 的物理存放；continuous batching 决定谁在本轮运行；chunked prefill 再决定长 prompt 是否能拆成多轮。三者分别对应 memory manager、request scheduler 与 token-level work partition。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;固定请求批次会在短请求结束后留下空轮次；逐迭代调度能立刻补入等待请求。&lt;/li&gt;
&lt;li&gt;一轮能放多少工作同时受 sequence count、scheduled token budget 与 KV blocks 约束。&lt;/li&gt;
&lt;li&gt;更大 batch 通常提高吞吐，却可能拉长每轮时间与 TPOT；必须联合测 TTFT、TPOT、吞吐和抢占。&lt;/li&gt;
&lt;li&gt;调度改变 batch 位置时，KV、position、停止条件与 RNG 必须按 request ID 保持一致。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;三条请求还需生成 &lt;code&gt;[2,4,1]&lt;/code&gt; 个 token，&lt;code&gt;max_num_seqs=2&lt;/code&gt;。分别画静态批与连续批时间线，计算有效槽位比例。&lt;/li&gt;
&lt;li&gt;本轮已有 80 条 decode 请求，token 预算为 512。若一个新 prompt 有 600 tokens，在不切 prefill 时为何无法进入？切分后第一轮最多取多少 prompt tokens？&lt;/li&gt;
&lt;li&gt;扩展教学版调度器，加入 arrival step 与 priority；设计一个防止低优先级请求永久饥饿的 aging 规则。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Yu et al., &lt;a href=&quot;https://www.usenix.org/conference/osdi22/presentation/yu&quot;&gt;Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/a&gt;，系统化提出 iteration-level scheduling 与 selective batching。&lt;/li&gt;
&lt;li&gt;Kwon et al., &lt;a href=&quot;https://arxiv.org/abs/2309.06180&quot;&gt;Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a&gt;，把连续批处理与分页 KV 管理结合进 vLLM。&lt;/li&gt;
&lt;li&gt;Agrawal et al., &lt;a href=&quot;https://arxiv.org/abs/2308.16369&quot;&gt;SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills&lt;/a&gt;，分析 prefill/decode 混合造成的延迟干扰。&lt;/li&gt;
&lt;li&gt;vLLM, &lt;a href=&quot;https://docs.vllm.ai/en/latest/api/vllm/config/scheduler/&quot;&gt;SchedulerConfig 官方文档&lt;/a&gt;，给出当前 token、sequence、policy 与 chunked prefill 配置语义。&lt;/li&gt;
&lt;li&gt;vLLM, &lt;a href=&quot;https://docs.vllm.ai/en/stable/cli/serve/&quot;&gt;serve CLI 官方文档&lt;/a&gt;，给出当前服务端参数及容量限制。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;连续批处理能在请求完成后立刻补位，但一个 32K prompt 若必须整段 prefill，单次迭代仍会很长，正在流式生成的请求会集体停顿。下一篇将把 prefill 切成 token chunks，手算 chunked prefill 如何用同一轮预算保护 TPOT。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>KV Cache 还有空间为何新请求却进不来？PagedAttention 的块表与碎片控制</title><link>https://zwjcode.cn/blog/pagedattention-kv-cache-block-table-fragmentation</link><guid isPermaLink="true">https://zwjcode.cn/blog/pagedattention-kv-cache-block-table-fragmentation</guid><description>从变长生成的显存碎片出发，手算逻辑块到物理块的映射，解释 PagedAttention 的动态增长、Copy-on-Write、调度契约与服务端验证。</description><pubDate>Fri, 18 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 FlashAttention 避免把 $L\times L$ 中间矩阵写回 HBM。到了自回归服务，attention 每次只生成一个新 token，却必须读取此前所有 Key/Value；KV Cache（键值缓存）因此随每个请求的实际长度增长。&lt;/p&gt;
&lt;p&gt;若为每个请求一次预留 &lt;code&gt;max_model_len&lt;/code&gt; 的连续空间，短回答浪费尾部，长回答又可能找不到足够大的连续洞。PagedAttention（分页注意力）把 KV Cache 切成固定 token 数的 blocks（块），用 block table（块表）把逻辑连续序列映射到任意物理块。&lt;/p&gt;
&lt;h2&gt;01 KV Cache 一条请求到底占多少？&lt;/h2&gt;
&lt;p&gt;设层数为 $N_l$，KV heads 数为 $H_{kv}$，每头维度 $D_h$，序列长度 $T$，每元素 $b$ bytes。Key 与 Value 各一份，所以&lt;/p&gt;
&lt;p&gt;$$
M_{KV}=2N_lH_{kv}D_hTb.
$$&lt;/p&gt;
&lt;p&gt;例如 $N_l=32,H_{kv}=8,D_h=128$、BF16 的 $b=2$，每 token 是&lt;/p&gt;
&lt;p&gt;$$
2\times32\times8\times128\times2=131072\text{ bytes}=128\text{ KiB}.
$$&lt;/p&gt;
&lt;p&gt;一个 8K-token 请求仅 KV 就约 1 GiB。并发 40 个请求的长度还在每轮变化，管理方式直接决定可容纳 batch 大小。&lt;/p&gt;
&lt;h2&gt;02 连续预留会产生哪两种碎片？&lt;/h2&gt;
&lt;p&gt;假设最大长度 16 tokens，而请求 A 实际只用 5 个。若预留 16 个槽，11 个槽直到请求结束都不能给别人，这是 internal fragmentation（内部碎片/预留浪费）。&lt;/p&gt;
&lt;p&gt;若每次按实际长度扩展连续数组，旧请求释放后会留下大小不同的洞；总空闲量足够，却没有某个足够大的连续区，这是 external fragmentation（外部碎片）。搬迁并压紧 KV 成本很高，还会打断延迟敏感的 decode。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;连续预留: [AAAAA...........] [BBBBBBBBBB......]
物理显存: 已用 + 请求独占但尚未使用的尾部

分页分配: [A0][B0][A1][free][B1][free]...
逻辑顺序: A0 -&gt; A1；物理上无需相邻
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 块表怎样把逻辑位置翻译成物理地址？&lt;/h2&gt;
&lt;p&gt;令 block size $B=4$ tokens。请求 A 的 7 个 token 需要两个逻辑块，块表为 &lt;code&gt;[7, 1]&lt;/code&gt;，表示逻辑块 0 在物理块 7，逻辑块 1 在物理块 1。&lt;/p&gt;
&lt;p&gt;对逻辑 token 位置 $t$：&lt;/p&gt;
&lt;p&gt;$$
j=\lfloor t/B\rfloor,\qquad r=t\bmod B,
$$&lt;/p&gt;
&lt;p&gt;$$
p=\text{block_table}[j],
$$&lt;/p&gt;
&lt;p&gt;其中 $j$ 是逻辑块号，$r$ 是块内偏移，$p$ 是物理块号。于是：&lt;/p&gt;
&lt;p&gt;| 逻辑位置 $t$ | 逻辑块 $j$ | 块内偏移 $r$ | 物理位置        |
| -----------: | ---------: | -----------: | --------------- |
|            0 |          0 |            0 | block 7, slot 0 |
|            3 |          0 |            3 | block 7, slot 3 |
|            4 |          1 |            0 | block 1, slot 0 |
|            6 |          1 |            2 | block 1, slot 2 |&lt;/p&gt;
&lt;p&gt;Attention kernel 按块表读取物理块 7 再读物理块 1，在数学上仍把它们当作连续的前 7 个 token。&lt;/p&gt;
&lt;h2&gt;04 新 token 到来时只需按需扩一块&lt;/h2&gt;
&lt;p&gt;请求 A 当前长度为 7，物理块 1 还剩一个 slot。第 8 个 token 的 KV 直接写入 &lt;code&gt;(block 1, slot 3)&lt;/code&gt;；第 9 个 token 到来时，才从 free list 分配一个新物理块，例如 block 3，并把块表扩成 &lt;code&gt;[7,1,3]&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  T7[length=7&amp;#x3C;br/&gt;table 7,1] --&gt; W8[写 block 1 / slot 3]
  W8 --&gt; FULL[length=8&amp;#x3C;br/&gt;末块已满]
  FULL --&gt; ALLOC[free list 分配 block 3]
  ALLOC --&gt; W9[写 block 3 / slot 0&amp;#x3C;br/&gt;table 7,1,3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此每个请求最多只浪费最后一个物理块中的 $B-1$ 个 token slots，不需要为未知输出长度提前预留全部空间。&lt;/p&gt;
&lt;h2&gt;05 一个教学版 Block Manager&lt;/h2&gt;
&lt;p&gt;下面只管理 token slots，不存真实 KV。输入是物理块总数与 block size；输出是每个请求的块表。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import deque

class BlockManager:
    def __init__(self, num_blocks: int, block_size: int):
        self.block_size = block_size
        self.free = deque(range(num_blocks))
        self.tables: dict[str, list[int]] = {}
        self.lengths: dict[str, int] = {}

    def append_slot(self, request_id: str) -&gt; tuple[int, int]:
        table = self.tables.setdefault(request_id, [])
        length = self.lengths.get(request_id, 0)
        logical_block, offset = divmod(length, self.block_size)
        if logical_block == len(table):
            if not self.free:
                raise MemoryError(&quot;no free KV block&quot;)
            table.append(self.free.popleft())
        self.lengths[request_id] = length + 1
        return table[logical_block], offset

    def release(self, request_id: str) -&gt; None:
        for physical_block in self.tables.pop(request_id, []):
            self.free.append(physical_block)
        self.lengths.pop(request_id, None)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试时交错执行 &lt;code&gt;A,A,B,A,B...&lt;/code&gt;，确认 A、B 的物理块可以不连续，逻辑位置却不会串请求。真实系统还需要引用计数、GPU/CPU 元数据一致性、批量分配、抢占与多层多 head 的地址计算。&lt;/p&gt;
&lt;h2&gt;06 真实 KV 张量的 shape 与寻址&lt;/h2&gt;
&lt;p&gt;一种便于理解的物理布局是&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;K_cache: [N_layers, N_blocks, B, H_kv, D_h]
V_cache: [N_layers, N_blocks, B, H_kv, D_h]
block_table: [N_sequences, max_blocks_per_sequence]
context_lens: [N_sequences]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于某层、某序列、逻辑位置 $t$，kernel 先由 &lt;code&gt;block_table[seq, t // B]&lt;/code&gt; 取物理块，再用 &lt;code&gt;t % B&lt;/code&gt; 定位 slot。生产 kernel 常为向量化读取而调整维度顺序，不能据此假定 vLLM 内部的精确 stride；不变的是逻辑到物理的两级寻址契约。&lt;/p&gt;
&lt;p&gt;若使用 Grouped Query Attention（分组查询注意力，GQA），$H_q$ 可大于 $H_{kv}$；KV Cache 按 $H_{kv}$ 而非 query heads 数计算。容量估算误用 $H_q$ 会高估显存，反过来误用过小 head 数则会在运行时 OOM。&lt;/p&gt;
&lt;h2&gt;07 PagedAttention 怎样计算当前 query？&lt;/h2&gt;
&lt;p&gt;对 decode 时的 query $q_i\in\mathbb R^{H_q\times D_h}$，它需关注逻辑位置 $0\ldots i$。PagedAttention 逐个读取块表指向的 $K_j,V_j$，计算&lt;/p&gt;
&lt;p&gt;$$
s_t=q_i^\top k_t/\sqrt{D_h},\qquad
o_i=\sum_{t=0}^{i}\operatorname{softmax}(s)_t v_t.
$$&lt;/p&gt;
&lt;p&gt;块在物理上不连续，不影响位置、causal 范围或 Softmax 分母。kernel 可像上一篇一样用分块在线 Softmax 合并不同物理块；额外代价是块表寻址、分支以及不规则访存。&lt;/p&gt;
&lt;h2&gt;08 Copy-on-Write 怎样共享前缀？&lt;/h2&gt;
&lt;p&gt;并行采样或 beam search 的多个分支共享同一 prompt。若直接复制 prompt KV，分支数为 $k$ 时浪费 $k$ 份显存。分页后，多个逻辑块表可以指向同一物理块，并给该块维护 reference count（引用计数）。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;beam A table: [7, 1]
beam B table: [7, 1]     refcount(7)=2, refcount(1)=2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若两者要向尚未填满的物理块 1 写不同 token，必须 Copy-on-Write（写时复制）：为其中一个分支分配新块，复制已有 slots，再写入分叉 token。已填满且永不修改的前缀块可以一直共享。&lt;/p&gt;
&lt;p&gt;引用计数错误有两种危险：过早释放导致另一个请求读到已复用内容；忘记递减则产生显存泄漏。必须用请求完成、取消、超时、抢占和异常路径做状态机测试。&lt;/p&gt;
&lt;h2&gt;09 Block Size 是延迟与浪费的折中&lt;/h2&gt;
&lt;p&gt;块越小，末块内部碎片上界越低，也更容易共享细粒度前缀；但块表更长、分配与寻址更多，GPU 读取并行度可能不足。块越大，元数据和 kernel 调度更友好，却可能为大量短请求浪费尾部。&lt;/p&gt;
&lt;p&gt;不要照搬论文中的默认值。真实选择取决于模型、dtype、head shape、硬件、prompt/output 长度分布和 kernel。应对候选 block size 同时测：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;有效 KV bytes / 已分配 KV bytes；&lt;/li&gt;
&lt;li&gt;可同时驻留的 sequence 数与 token 数；&lt;/li&gt;
&lt;li&gt;decode tokens/s、time per output token 和 P99 latency；&lt;/li&gt;
&lt;li&gt;块分配频率、prefix cache hit、preemption 与 recomputation 次数。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10 Scheduler 为什么也是算法的一部分？&lt;/h2&gt;
&lt;p&gt;每轮 decode 前，scheduler 选择本轮进入 continuous batching（连续批处理）的请求，并为可能增长的序列预留新块。若 free blocks 不足，系统必须拒绝新请求、延后调度、抢占低优先级序列，或通过重算/交换恢复空间。&lt;/p&gt;
&lt;p&gt;一个安全顺序是：先计算本轮所需新块并原子式预留，再启动 GPU kernel，成功后提交新长度。若部分 worker 分配成功、部分失败，不能让块表和实际 KV 写入各走各的。多 GPU Tensor Parallel 下，各 rank 的同一请求必须保持一致的逻辑块状态，即使物理地址不同。&lt;/p&gt;
&lt;h2&gt;11 Prefill 与 Decode 的内核目标不同&lt;/h2&gt;
&lt;p&gt;Prefill（预填充）一次处理 prompt 的多个 query，矩阵较大，常适合 FlashAttention 一类高吞吐 kernel；decode 每个序列通常只有一个新 query，却读取长 KV，更偏 memory-bound，并且各序列长度不同。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;prefill: [许多新 Q] × [prompt K/V] -&gt; 建立整段 KV
decode : [每序列 1 个新 Q] × [各自历史 paged K/V] -&gt; 追加 1 个 KV slot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两阶段可以共享同一块管理器，却不应假设使用相同 kernel 或优化指标。只测长 prompt 的 prefill tokens/s，不能代表聊天服务的 inter-token latency。&lt;/p&gt;
&lt;h2&gt;12 如何接入当前 vLLM 而不依赖内部私有类？&lt;/h2&gt;
&lt;p&gt;vLLM 的 paged KV cache 与 scheduler 是执行引擎内部契约，内部类和 kernel signature 会快速演进。业务代码应使用公开的 &lt;code&gt;vllm serve&lt;/code&gt; OpenAI-compatible server 或公开 &lt;code&gt;LLM&lt;/code&gt; 接口，把内部 block table 当作可观测实现而非自行调用的稳定 API。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from vllm import LLM, SamplingParams

llm = LLM(model=&quot;your-model-id&quot;, max_model_len=8192)
params = SamplingParams(temperature=0.0, max_tokens=128)
outputs = llm.generate(
    [&quot;解释分页式 KV Cache&quot;, &quot;给出一个极小例子&quot;],
    params,
)

for output in outputs:
    print(output.outputs[0].text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型 ID、tensor parallel size、KV cache dtype、最大上下文和显存利用率属于部署配置；升级版本时以当前官方文档和启动日志为准。不要从旧博客复制已删除的 BlockManager 构造参数。&lt;/p&gt;
&lt;h2&gt;13 一条可执行的正确性验证路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;单请求、短序列、greedy decoding，与不分页的连续 KV 基线比较每步 logits 和 token。&lt;/li&gt;
&lt;li&gt;用 block size 2 强迫频繁跨块，覆盖长度 1、2、3、4、5。&lt;/li&gt;
&lt;li&gt;两请求交错增长并反复释放，给每个 KV slot 写唯一 &lt;code&gt;(request,position)&lt;/code&gt; 哨兵值。&lt;/li&gt;
&lt;li&gt;两个 beam 共享前缀后分叉，验证 Copy-on-Write 前共享、写入后隔离。&lt;/li&gt;
&lt;li&gt;模拟无 free block、请求取消和 worker 异常，检查所有引用计数与 free list 守恒。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;守恒式尤其有用：&lt;/p&gt;
&lt;p&gt;$$
N_{free}+N_{allocated}=N_{total},
$$&lt;/p&gt;
&lt;p&gt;且所有块引用计数为正的集合必须恰好等于已分配集合。每轮调度后都可在 debug 模式断言。&lt;/p&gt;
&lt;h2&gt;14 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                     | 常见原因                                   | 最短检查                               |
| ------------------------ | ------------------------------------------ | -------------------------------------- |
| 跨块边界后文本突然错乱   | &lt;code&gt;t//B&lt;/code&gt; 或 &lt;code&gt;t%B&lt;/code&gt;、长度提交时机错            | 用 block size 2 和哨兵 KV 打印地址     |
| 取消请求后显存不回升     | 异常路径未释放或引用计数泄漏               | 逐事件记录 alloc/free/refcount         |
| 一个 beam 改坏另一个     | 对共享未满块原地写，缺少 COW               | 分叉前后比较物理块 ID                  |
| 有空闲显存仍拒绝请求     | 容量预算、free list 或多 worker 状态不一致 | 对账 free blocks 与各表引用集合        |
| 吞吐升高但 P99 爆炸      | scheduler 过度批处理或频繁抢占             | 联合画 queue、batch、preemption 时间线 |
| 修改 block size 反而更慢 | 元数据/访存开销压过碎片收益                | 对工作负载分布做端到端 sweep           |&lt;/p&gt;
&lt;h2&gt;15 失败场景与相近方法边界&lt;/h2&gt;
&lt;p&gt;PagedAttention 主要提高 KV 容量利用率，不能减少单个 query 对长历史的 $O(T)$ 读取，也不能解决模型权重放不进显存。若服务始终只有一个固定长度请求，分页寻址可能只有额外开销。&lt;/p&gt;
&lt;p&gt;FlashAttention 优化一次 attention 的 IO；PagedAttention 管理跨请求、跨时间增长的 KV 地址。Prefix Caching（前缀缓存）决定哪些请求可复用已算 KV；continuous batching 决定每轮让哪些请求一起执行；quantized KV cache 则减少每个 slot bytes。它们互补但解决不同瓶颈。&lt;/p&gt;
&lt;h2&gt;16 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;自回归服务的 KV Cache 随实际 token 增长，按最大长度连续预留会产生严重浪费与碎片。&lt;/li&gt;
&lt;li&gt;固定大小物理块加 block table，使逻辑连续序列可落在非连续显存，按需增长且每请求最多浪费一个末块。&lt;/li&gt;
&lt;li&gt;引用计数与 Copy-on-Write 让并行采样、beam 和公共前缀共享 KV，同时保持分叉后的写隔离。&lt;/li&gt;
&lt;li&gt;PagedAttention 的收益必须与 scheduler、block size 和真实请求长度分布一起测，而不是只测一个 attention kernel。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;物理块大小为 4，块表 &lt;code&gt;[5,2,9]&lt;/code&gt;。写出逻辑位置 0、6、11 的物理块与块内偏移；若长度为 10，末块浪费几个 slots？&lt;/li&gt;
&lt;li&gt;使用前文模型配置，block size 16。计算一个物理块跨全部层的 KV bytes；若有 20 GiB 可用于 KV，理论最多多少块？&lt;/li&gt;
&lt;li&gt;扩展教学版 &lt;code&gt;BlockManager&lt;/code&gt;：加入引用计数与 &lt;code&gt;fork(request,new_id)&lt;/code&gt;，并为 Copy-on-Write 设计三个断言。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Kwon et al., &lt;a href=&quot;https://arxiv.org/abs/2309.06180&quot;&gt;Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a&gt;，提出分页 KV 管理与 vLLM 系统。&lt;/li&gt;
&lt;li&gt;Yu et al., &lt;a href=&quot;https://www.usenix.org/conference/osdi22/presentation/yu&quot;&gt;Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/a&gt;，提出 iteration-level scheduling 与 selective batching。&lt;/li&gt;
&lt;li&gt;Pope et al., &lt;a href=&quot;https://arxiv.org/abs/2211.05102&quot;&gt;Efficiently Scaling Transformer Inference&lt;/a&gt;，分析大模型推理的并行、内存与批处理。&lt;/li&gt;
&lt;li&gt;Sheng et al., &lt;a href=&quot;https://arxiv.org/abs/2303.06865&quot;&gt;FlexGen: High-Throughput Generative Inference of Large Language Models with a Single GPU&lt;/a&gt;，研究受限 GPU 内存下的分层卸载调度。&lt;/li&gt;
&lt;li&gt;vLLM, &lt;a href=&quot;https://docs.vllm.ai/en/latest/design/paged_attention/&quot;&gt;Paged Attention 设计文档&lt;/a&gt;，说明当前 paged KV kernel 的数据布局与执行方式。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;18 下一篇预告&lt;/h2&gt;
&lt;p&gt;分页让更多变长请求同时留在显存，但每个 decode step 仍可能被少量超长请求拖慢。下一篇将进入 continuous batching：scheduler 如何在吞吐、首 token 延迟、逐 token 延迟与抢占之间做可测量的取舍。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>注意力公式没变，为何还能快几倍？FlashAttention 的分块、在线 Softmax 与 IO</title><link>https://zwjcode.cn/blog/flashattention-online-softmax-tiling-io</link><guid isPermaLink="true">https://zwjcode.cn/blog/flashattention-online-softmax-tiling-io</guid><description>从显存读写瓶颈出发，手算分块在线 Softmax，解释 FlashAttention 如何不物化 L×L 矩阵、保持精确结果，并用 PyTorch 2.14 SDPA 验证后端与性能。</description><pubDate>Thu, 17 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 micro-batch 和 1F1B 填补流水线空洞，但 GPU 忙起来不等于算子已经高效。标准 Attention（注意力）会先写出完整分数矩阵，再读回来做 Softmax，最后又读一次乘 $V$。长序列下，真正拖慢它的常常不是浮点乘法，而是 High Bandwidth Memory（高带宽显存，HBM）与片上 SRAM 之间的数据搬运。&lt;/p&gt;
&lt;p&gt;FlashAttention（闪存注意力）没有改变注意力函数，也没有删掉任意一条注意力边。它通过 &lt;strong&gt;tiling（分块）、kernel fusion（算子融合）和 online softmax（在线 Softmax）&lt;/strong&gt;，让中间的 $L\times L$ 矩阵不必写回 HBM。&lt;/p&gt;
&lt;h2&gt;01 标准实现究竟把什么搬来搬去？&lt;/h2&gt;
&lt;p&gt;单个 batch、单个 head 的缩放点积注意力为&lt;/p&gt;
&lt;p&gt;$$
S=QK^\top/\sqrt d,\qquad P=\operatorname{softmax}(S),\qquad O=PV,
$$&lt;/p&gt;
&lt;p&gt;其中 $Q,K,V\in\mathbb R^{L\times d}$，$S,P\in\mathbb R^{L\times L}$，$O\in\mathbb R^{L\times d_v}$。朴素 GPU 流水线常把三个步骤拆成多个 kernel：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  QK[从 HBM 读 Q,K] --&gt; S[写回 S: L×L]
  S --&gt; SM[再读 S 做 Softmax]
  SM --&gt; P[写回 P: L×L]
  P --&gt; PV[再读 P,V 做矩阵乘]
  PV --&gt; O[写回 O: L×dᵥ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;计算量仍为 $\Theta(L^2d)$；但仅 $S$ 与 $P$ 就有 $2L^2$ 个元素。若 $L=8192$、FP16，每个矩阵约 128 MiB，每层每头组合后的中间读写很快压过 $Q,K,V,O$ 的线性存储。&lt;/p&gt;
&lt;h2&gt;02 FlashAttention 改的是执行顺序，不是数学目标&lt;/h2&gt;
&lt;p&gt;把 $Q$ 沿行切成块 $Q_i\in\mathbb R^{B_r\times d}$，把 $K,V$ 切成 $K_j,V_j\in\mathbb R^{B_c\times d}$。一个 $Q_i$ 留在片上，依次扫描各个 $K_j,V_j$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;HBM:  Q 块   K₀,V₀   K₁,V₁   K₂,V₂ ...
        |       |       |       |
        v       v       v       v
SRAM: [Qᵢ] -&gt; [局部分数] -&gt; 更新 m,l,O -&gt; 丢弃局部分数
                                      |
                                      v
HBM:                              只写最终 Oᵢ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;局部分数 $S_{ij}=Q_iK_j^\top/\sqrt d$ 只在片上短暂存在。难点是 Softmax 的分母依赖一整行全部 key；若按块各做一次 Softmax 再相加，结果一定错误。在线 Softmax 提供了可合并的行状态。&lt;/p&gt;
&lt;h2&gt;03 在线 Softmax 只需保留三个状态&lt;/h2&gt;
&lt;p&gt;对一行分数，处理到当前块时保留：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$m$：目前见过的最大分数，shape 为 &lt;code&gt;[B_r, 1]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;$\ell=\sum_t e^{s_t-m}$：以 $m$ 为基准的指数和，shape 为 &lt;code&gt;[B_r, 1]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;$a=\sum_t e^{s_t-m}v_t$：尚未除分母的加权值，shape 为 &lt;code&gt;[B_r,d_v]&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;新块的行最大值是 $m_b$，指数和与加权值是 $\ell_b,a_b$。合并时令&lt;/p&gt;
&lt;p&gt;$$
m&apos;=\max(m,m_b),
$$&lt;/p&gt;
&lt;p&gt;$$
\ell&apos;=e^{m-m&apos;}\ell+e^{m_b-m&apos;}\ell_b,
$$&lt;/p&gt;
&lt;p&gt;$$
a&apos;=e^{m-m&apos;}a+e^{m_b-m&apos;}a_b.
$$&lt;/p&gt;
&lt;p&gt;最后输出 $o=a/\ell$。当出现更大的最大值时，旧块的累计量会按 $e^{m-m&apos;}$ 重新缩放，因此数值稳定且不需要保存旧分数。&lt;/p&gt;
&lt;h2&gt;04 用三个分数手算两次合并&lt;/h2&gt;
&lt;p&gt;令一行分数为 &lt;code&gt;[1, 2, 3]&lt;/code&gt;，对应一维 value 为 &lt;code&gt;[10, 20, 40]&lt;/code&gt;。先处理第一块 &lt;code&gt;[1,2]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;$$
m=2,\quad \ell=e^{-1}+1\approx1.3679,
$$&lt;/p&gt;
&lt;p&gt;$$
a=e^{-1}\cdot10+1\cdot20\approx23.6788.
$$&lt;/p&gt;
&lt;p&gt;第二块只有分数 &lt;code&gt;3&lt;/code&gt;，所以 $m_b=3,\ell_b=1,a_b=40$。合并：&lt;/p&gt;
&lt;p&gt;$$
m&apos;=3,
$$&lt;/p&gt;
&lt;p&gt;$$
\ell&apos;=e^{-1}\cdot1.3679+1\approx1.5032,
$$&lt;/p&gt;
&lt;p&gt;$$
a&apos;=e^{-1}\cdot23.6788+40\approx48.7109.
$$&lt;/p&gt;
&lt;p&gt;因此 $o=a&apos;/\ell&apos;\approx32.405$。直接对 &lt;code&gt;[1,2,3]&lt;/code&gt; 做 Softmax 后乘 &lt;code&gt;[10,20,40]&lt;/code&gt; 也是同一结果；分块只改变求值顺序。&lt;/p&gt;
&lt;h2&gt;05 一个透明的教学版分块前向&lt;/h2&gt;
&lt;p&gt;下面代码故意用 PyTorch 普通算子表达算法，不会自动获得定制 CUDA kernel 的速度，但适合与稠密基线逐元素对照。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math
import torch

def tiled_attention(q, k, v, block_k=64, causal=False):
    # q:[N,H,L,D], k:[N,H,S,D], v:[N,H,S,Dv]
    n, h, l, d = q.shape
    s, dv = k.size(-2), v.size(-1)
    m = torch.full((n, h, l, 1), -torch.inf, device=q.device)
    ell = torch.zeros_like(m)
    acc = torch.zeros((n, h, l, dv), device=q.device, dtype=torch.float32)

    qf = q.float()
    q_pos = torch.arange(l, device=q.device)[:, None]
    for start in range(0, s, block_k):
        end = min(start + block_k, s)
        kb = k[..., start:end, :].float()
        vb = v[..., start:end, :].float()
        scores = qf @ kb.transpose(-2, -1) / math.sqrt(d)

        if causal:
            k_pos = torch.arange(start, end, device=q.device)[None, :]
            scores = scores.masked_fill(k_pos &gt; q_pos, -torch.inf)

        mb = scores.amax(dim=-1, keepdim=True)
        p = torch.exp(scores - mb)
        lb = p.sum(dim=-1, keepdim=True)
        ab = p @ vb

        m_new = torch.maximum(m, mb)
        old_scale = torch.exp(m - m_new)
        new_scale = torch.exp(mb - m_new)
        ell = old_scale * ell + new_scale * lb
        acc = old_scale * acc + new_scale * ab
        m = m_new

    return (acc / ell).to(q.dtype)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实 kernel 还会沿 query 维分块、控制寄存器与 shared memory 占用、融合 mask/dropout，并为反向传播设计重算。这里把累计状态保留为 FP32，是为了避免长行上的指数和在低精度下丢失有效位。&lt;/p&gt;
&lt;h2&gt;06 Causal Mask 怎样进入分块？&lt;/h2&gt;
&lt;p&gt;自回归 attention 只允许 query 位置 $i$ 看 key 位置 $j\le i$。分块不能用“块号相同就全部可见”的粗略判断，因为对角块内部仍是三角形。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;key block -&gt;   K0      K1      K2
Q0             三角    跳过    跳过
Q1             全部    三角    跳过
Q2             全部    全部    三角
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;完全位于对角线右侧的块可直接跳过；左侧块全算；对角块逐元素施加 causal mask。Padding、局部窗口和 attention bias 也必须在局部分数进入最大值与指数和之前处理，否则被屏蔽位置会污染归一化。&lt;/p&gt;
&lt;h2&gt;07 反向传播为何还能保持线性额外存储？&lt;/h2&gt;
&lt;p&gt;朴素 autograd 会保存 $P\in\mathbb R^{L\times L}$。FlashAttention 前向保存输出 $O$ 与每行 log-sum-exp 等线性大小统计量；反向时重新分块计算所需局部分数和概率，再累积 $dQ,dK,dV$。&lt;/p&gt;
&lt;p&gt;这是一种 recomputation（重算）：用额外 FLOPs 换掉二次方中间激活。它与上一篇之前讲过的 Activation Checkpointing 思想相似，但边界在专用 attention kernel 内，且重算公式针对 Softmax 导数高度融合。&lt;/p&gt;
&lt;h2&gt;08 用 PyTorch 2.14 当前 SDPA 选择后端&lt;/h2&gt;
&lt;p&gt;当前官方入口是 &lt;code&gt;torch.nn.functional.scaled_dot_product_attention&lt;/code&gt;。它会按 device、dtype、shape、mask 和 dropout 等条件选择后端；调试时可用 &lt;code&gt;sdpa_kernel&lt;/code&gt; 强制 Flash backend，让“不支持而回退”变成明确错误或警告。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F
from torch.nn.attention import SDPBackend, sdpa_kernel

q = torch.randn(2, 16, 2048, 64, device=&quot;cuda&quot;, dtype=torch.bfloat16,
                requires_grad=True)
k = torch.randn_like(q)
v = torch.randn_like(q)

with sdpa_kernel(SDPBackend.FLASH_ATTENTION):
    out = F.scaled_dot_product_attention(
        q, k, v,
        attn_mask=None,
        dropout_p=0.0,
        is_causal=True,
    )

assert out.shape == (2, 16, 2048, 64)
out.float().square().mean().backward()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dropout_p&lt;/code&gt; 无论模块是否处于 eval 模式都会按传入值执行；推理时应显式传 &lt;code&gt;0.0&lt;/code&gt;。&lt;code&gt;attn_mask&lt;/code&gt; 与 &lt;code&gt;is_causal&lt;/code&gt; 的组合限制、支持的 head dimension、dtype 和硬件能力都可能影响后端资格，不要把“调用了 SDPA”当作“运行了 FlashAttention”。&lt;/p&gt;
&lt;h2&gt;09 怎样证明后端、结果与梯度都正确？&lt;/h2&gt;
&lt;p&gt;建议固定一个很小的 FP32 数学基线，再测试生产 dtype：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch.nn.attention import SDPBackend, sdpa_kernel

def run(backend, q, k, v):
    with sdpa_kernel(backend):
        return F.scaled_dot_product_attention(q, k, v, is_causal=True)

q0 = torch.randn(1, 2, 17, 32, device=&quot;cuda&quot;, dtype=torch.float32)
k0 = torch.randn_like(q0)
v0 = torch.randn_like(q0)
ref = run(SDPBackend.MATH, q0, k0, v0)

q1, k1, v1 = (x.to(torch.bfloat16) for x in (q0, k0, v0))
got = run(SDPBackend.FLASH_ATTENTION, q1, k1, v1).float()
torch.testing.assert_close(got, ref, rtol=2e-2, atol=2e-2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;梯度测试需为两条路径分别 clone &lt;code&gt;requires_grad_()&lt;/code&gt;，对相同标量 loss 调 &lt;code&gt;backward()&lt;/code&gt;，再比较 $dQ,dK,dV$。包含全 mask 行、非整块长度、padding、causal、dropout 和真实 head dimension 的 case 才能覆盖边界。&lt;/p&gt;
&lt;h2&gt;10 性能验证不能只计一次 Python 时钟&lt;/h2&gt;
&lt;p&gt;CUDA 是异步执行的。应先 warmup，再用 CUDA Event 或 &lt;code&gt;torch.utils.benchmark&lt;/code&gt;，并在计时边界同步。训练要测 forward+backward，且记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;tokens/s 与 step time，而不只是单 kernel 微秒数；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torch.cuda.max_memory_allocated()&lt;/code&gt; 的峰值；&lt;/li&gt;
&lt;li&gt;profiler 中实际 SDPA kernel 名称、HBM 流量和 kernel gaps；&lt;/li&gt;
&lt;li&gt;多个 $L$、head dimension、dtype 与 mask 组合。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;短序列、小 batch 或不受支持的 shape 上，调度开销可能抵消收益。FlashAttention 省的是中间矩阵 IO，不会把 $\Theta(L^2d)$ 的点积计算变成线性。&lt;/p&gt;
&lt;h2&gt;11 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                  | 常见原因                                        | 最短检查                                    |
| --------------------- | ----------------------------------------------- | ------------------------------------------- |
| 强制 Flash 后报不支持 | dtype、设备、shape 或 mask 不满足后端约束       | 缩成已知支持的 BF16 CUDA case，再逐项加回   |
| 输出整行 NaN          | 某 query 的所有 key 都被 mask                   | 检查每行至少一个有效位置及 mask 语义        |
| 推理结果每次变化      | &lt;code&gt;dropout_p&lt;/code&gt; 仍非 0                              | 在 eval 路径显式传 &lt;code&gt;0.0&lt;/code&gt;                    |
| 内存仍呈 $L^2$ 增长   | 代码在 SDPA 前保存了完整 attention weights/bias | profiler 与 memory snapshot 找出 &lt;code&gt;L×L&lt;/code&gt; 分配 |
| 与基线不逐 bit 相同   | 浮点归约顺序不同                                | 改用 dtype 对应的 &lt;code&gt;rtol/atol&lt;/code&gt;，比较统计误差 |
| kernel 快但整步不快   | QKV 投影、通信或数据加载成为瓶颈                | 看端到端 profiler，不只 microbenchmark      |&lt;/p&gt;
&lt;h2&gt;12 与稀疏注意力、Checkpointing 有何区别？&lt;/h2&gt;
&lt;p&gt;FlashAttention 对稠密 attention 是 exact implementation（精确实现）：边数和数学目标不变。滑动窗口、块稀疏会删除连接，计算图本身变了。Activation Checkpointing 可包住任意子图；FlashAttention 的重算专门利用 attention 的分块与在线归一化。&lt;/p&gt;
&lt;p&gt;PagedAttention 则解决另一层问题：自回归服务中，历史 KV cache 怎样动态分配和寻址。它可以使用分块 attention kernel，但核心目标是减少并发请求的 KV 内存碎片；下一篇会把这条边界讲清楚。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;标准 attention 的瓶颈常是 $L\times L$ 中间矩阵反复进出 HBM，而非公式里的乘法数量本身。&lt;/li&gt;
&lt;li&gt;分块在线 Softmax 用每行的最大值、指数和与加权值就能合并任意 key blocks，保持同一数学结果。&lt;/li&gt;
&lt;li&gt;FlashAttention 不物化完整分数/概率矩阵，反向重算局部量，以更多片上工作换更少 HBM IO。&lt;/li&gt;
&lt;li&gt;调用 SDPA 不保证选中 Flash backend；必须强制后端做兼容测试，并用端到端指标验证收益。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;把分数 &lt;code&gt;[0, 2, 1, 3]&lt;/code&gt; 分成 &lt;code&gt;[0,2]&lt;/code&gt; 与 &lt;code&gt;[1,3]&lt;/code&gt; 两块，手算 $m,\ell$ 的两次状态并验证最终 Softmax 分母。&lt;/li&gt;
&lt;li&gt;对 $L=4096$、16 heads、FP16，估算显式保存一个 &lt;code&gt;[H,L,L]&lt;/code&gt; 概率张量的 MiB；再与 &lt;code&gt;[H,L,64]&lt;/code&gt; 输出比较。&lt;/li&gt;
&lt;li&gt;为教学版 &lt;code&gt;tiled_attention&lt;/code&gt; 增加 padding mask，并设计一个“整块被屏蔽但整行仍有有效 key”的测试。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Dao et al., &lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness&lt;/a&gt;，提出 IO-aware 的精确分块 attention。&lt;/li&gt;
&lt;li&gt;Dao, &lt;a href=&quot;https://arxiv.org/abs/2307.08691&quot;&gt;FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning&lt;/a&gt;，改进线程块与 warp 间工作划分。&lt;/li&gt;
&lt;li&gt;Milakov &amp;#x26; Gimelshein, &lt;a href=&quot;https://arxiv.org/abs/1805.02867&quot;&gt;Online Normalizer Calculation for Softmax&lt;/a&gt;，给出可流式更新的稳定 Softmax 归一化。&lt;/li&gt;
&lt;li&gt;Rabe &amp;#x26; Staats, &lt;a href=&quot;https://arxiv.org/abs/2112.05682&quot;&gt;Self-attention Does Not Need $O(n^2)$ Memory&lt;/a&gt;，讨论通过重算降低 attention 内存。&lt;/li&gt;
&lt;li&gt;PyTorch, &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention&quot;&gt;Scaled Dot Product Attention 官方文档&lt;/a&gt;，说明当前 API、shape 与 backend 选择。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;FlashAttention 解决了一次 attention 算子怎样少搬数据；但在线服务同时生成许多不同长度请求时，KV Cache 还会因预留与碎片让显存提前耗尽。下一篇将用 block table 手算 PagedAttention 如何让逻辑连续的 token 映射到非连续物理块。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>模型按层切开后 GPU 为何仍在等待？GPipe、1F1B 与 Micro-batch 调度</title><link>https://zwjcode.cn/blog/pipeline-parallel-gpipe-1f1b-microbatch-schedule</link><guid isPermaLink="true">https://zwjcode.cn/blog/pipeline-parallel-gpipe-1f1b-microbatch-schedule</guid><description>从流水线气泡出发，手算 GPipe 与 1F1B 的时间线、激活驻留和梯度累积，解释 stage 边界契约、PyTorch PipelineStage/Schedule1F1B 用法与调试方法。</description><pubDate>Thu, 17 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把同一层的矩阵切到多卡，让超宽 MLP 不必在单卡聚齐。另一类模型不是“某层太宽”，而是层数太多、跨节点链路又不适合每层 collective。Pipeline Parallel（流水线并行，PP）把连续层分成 stages（阶段），只在边界传激活与梯度。&lt;/p&gt;
&lt;p&gt;但把层各放一张卡并不会自动并行：若一个完整 batch 从头走到尾，后面的 GPU 先等，前面的 GPU 后等。本篇聚焦解决这一等待的核心：&lt;strong&gt;把 batch 切成 micro-batches（微批次），并用 GPipe 或 One-Forward-One-Backward（一次前向一次反向，1F1B）安排它们。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 先区分三种“批次”&lt;/h2&gt;
&lt;p&gt;设一次 optimizer update 的全局 batch 为 $B_{global}$，数据并行大小为 $D_p$，每个 pipeline replica 的 batch 为 $B=B_{global}/D_p$。再切成 $m$ 个 micro-batches，每个大小为&lt;/p&gt;
&lt;p&gt;$$
b_\mu=B/m.
$$&lt;/p&gt;
&lt;p&gt;PP 中的 micro-batch 不是额外的 optimizer step。$m$ 个 micro-batch 的梯度共同构成一次更新；若中途 &lt;code&gt;optimizer.step()&lt;/code&gt;，你改变了目标，也让后面的 micro-batch 使用更新后的权重，形成 weight staleness（权重陈旧/版本不一致）。&lt;/p&gt;
&lt;h2&gt;02 不切 micro-batch 时哪里在空转？&lt;/h2&gt;
&lt;p&gt;三段模型 $S_0,S_1,S_2$ 的单个 batch 前向：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;时间 -&gt;  t0  t1  t2
S0       F0  --  --
S1       --  F0  --
S2       --  --  F0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个 stage 只有三分之一时间在算。切出多个 micro-batches 后，$S_0$ 在 $S_1$ 处理 $\mu_0$ 时可处理 $\mu_1$，这才形成流水线。&lt;/p&gt;
&lt;p&gt;边界张量也必须明确。若 $S_0$ 输出 $A_0\in\mathbb R^{b_\mu\times L\times D}$，forward 发送激活；backward 则从 $S_1$ 收到同 shape 的 $\partial\mathcal L/\partial A_0$。参数只属于本 stage，不沿边界搬运。&lt;/p&gt;
&lt;h2&gt;03 GPipe：先全部前向，再全部反向&lt;/h2&gt;
&lt;p&gt;以 3 stages、4 micro-batches 为例，&lt;code&gt;Fj/Bj&lt;/code&gt; 表示 $\mu_j$ 的前向/反向。简化到每格等时：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;时间 -&gt;  0   1   2   3   4   5   6   7   8   9   10  11
S0       F0  F1  F2  F3  --  --  --  --  B3  B2  B1  B0
S1       --  F0  F1  F2  F3  --  --  B3  B2  B1  B0  --
S2       --  --  F0  F1  F2  F3  B3  B2  B1  B0  --  --
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这叫 fill-drain（填充—排空）：先让所有 micro-batches 完成 forward，再启动 backward。优点是顺序直观；缺点是前段 stage 必须长期保留多个尚未反传的激活。&lt;/p&gt;
&lt;p&gt;只看 forward，$p$ 个等速 stages、$m$ 个 micro-batches 需要 $m+p-1$ 个时间格，理想气泡比例为&lt;/p&gt;
&lt;p&gt;$$
\rho_{bubble}=\frac{p-1}{m+p-1}.
$$&lt;/p&gt;
&lt;p&gt;$p=3,m=4$ 时是 $2/6=33.3%$；增大 $m$ 可降低比例，但 micro-batch 太小会让 GEMM 利用率下降，并增加通信启动次数。&lt;/p&gt;
&lt;h2&gt;04 1F1B：稳定期交替前向与反向&lt;/h2&gt;
&lt;p&gt;1F1B 先 warmup（预热）填入若干 micro-batches；一旦某 stage 能对较早 micro-batch 反传，就交替做一次 forward 和一次 backward；最后 cooldown（冷却）排空。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  W[warmup&amp;#x3C;br/&gt;只做必要 forward] --&gt; S[steady state&amp;#x3C;br/&gt;交替 1F1B]
  S --&gt; C[cooldown&amp;#x3C;br/&gt;排空 backward]
  C --&gt; U[所有 micro-batch 完成&amp;#x3C;br/&gt;optimizer.step]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;与 GPipe 相比，经典 1F1B 并不神奇地消除所有气泡；主要收益是让 backward 更早开始，降低同时驻留的 activation 数量。对 stage $r$，warmup 长度和 pipeline 深度有关，越靠前通常越需先送入更多 micro-batches。&lt;/p&gt;
&lt;h2&gt;05 四个 micro-batch 的梯度怎样仍等价？&lt;/h2&gt;
&lt;p&gt;若每个 micro-batch loss 是元素平均 $\ell_j$，且每块有效 token 数都相同，完整 batch 平均 loss 为&lt;/p&gt;
&lt;p&gt;$$
\mathcal L=\frac1m\sum_{j=1}^{m}\ell_j.
$$&lt;/p&gt;
&lt;p&gt;于是每次 backward 的梯度贡献应缩放 $1/m$，或先累加 sum loss，最后除以完整有效 token 数。PyTorch 2.14 的 pipeline schedule 参数 &lt;code&gt;scale_grads=True&lt;/code&gt; 默认按 micro-batch 数缩放梯度，应该与返回平均 loss 的 &lt;code&gt;loss_fn&lt;/code&gt; 匹配；若 &lt;code&gt;loss_fn&lt;/code&gt; 返回 sum，应设置 &lt;code&gt;scale_grads=False&lt;/code&gt; 并自己按全局分母归一化。&lt;/p&gt;
&lt;p&gt;长度不同、padding 数不同的语言模型不能简单平均 $\ell_j$。正确目标仍是&lt;/p&gt;
&lt;p&gt;$$
\mathcal L=\frac{\sum_j S_j}{\sum_j n_j},&lt;/p&gt;
&lt;p&gt;$$&lt;/p&gt;
&lt;p&gt;其中 $S_j$ 是有效 token loss sum，$n_j$ 是有效 token 数。还叠加 DP 时，分母必须跨 DP group 汇总，但不能在 PP stages 上把同一批 token 重复计数。&lt;/p&gt;
&lt;h2&gt;06 Stage 边界不是“在层列表中间切一刀”&lt;/h2&gt;
&lt;p&gt;每个 stage 必须拥有自己使用的参数与 buffer，并定义完整 forward。边界还要传递后续真正需要的值：hidden states、attention mask、position ids 或 auxiliary loss，不能只传主 Tensor 就假设语义完整。&lt;/p&gt;
&lt;p&gt;切分点要同时平衡：&lt;/p&gt;
&lt;p&gt;| 维度 | 不能只看什么 | 应实际测什么                         |
| ---- | ------------ | ------------------------------------ |
| 计算 | 层数相同     | 每 stage 前向/反向 wall time         |
| 参数 | 参数量相同   | 参数、optimizer、临时 workspace 峰值 |
| 激活 | 一个 shape   | 边界 bytes、同时驻留 micro-batch 数  |
| 通信 | 网络标称带宽 | send/recv 延迟、等待与计算重叠       |&lt;/p&gt;
&lt;p&gt;Embedding、首层输入、末端 LM head 和词表 loss 往往很不均匀。四等份层数不等于四个等速 stages，最慢 stage 会给所有其他 stages 制造周期性等待。&lt;/p&gt;
&lt;h2&gt;07 用一个手算调度判断激活峰值&lt;/h2&gt;
&lt;p&gt;假设每个 micro-batch 在 $S_0$ 产生 2 GB 必须保留到 backward 的激活，忽略重算。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GPipe 连续做完 4 个 forward 后，$S_0$ 最多保留约 $4\times2=8$ GB。&lt;/li&gt;
&lt;li&gt;若 1F1B 在完成必要 warmup 后让最早 micro-batch 立即 backward，稳定期保留数被 pipeline 深度限制，而非随 $m$ 线性增长。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但若开启 Activation Checkpointing（激活检查点），保存量下降、backward 计算变长，原本均衡的 stages 可能失衡。调度、重算和切分点必须一起 profile，不能独立调优。&lt;/p&gt;
&lt;h2&gt;08 当前 PyTorch 的最小调度骨架&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;torch.distributed.pipelining&lt;/code&gt; 提供 &lt;code&gt;PipelineStage&lt;/code&gt; 与 &lt;code&gt;Schedule1F1B&lt;/code&gt;。Stage 负责通信 buffer、send/recv 与本段 backward；schedule 负责 micro-batch 顺序。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
import torch
from torch import nn
from torch.distributed.pipelining import PipelineStage, Schedule1F1B

torch.distributed.init_process_group(&quot;nccl&quot;)
rank = torch.distributed.get_rank()
world = torch.distributed.get_world_size()
assert world == 2
device = torch.device(&quot;cuda&quot;, int(os.environ[&quot;LOCAL_RANK&quot;]))
torch.cuda.set_device(device)

# build_stage_module 必须只返回本 rank 拥有的连续层
stage_module = build_stage_module(rank).to(device)
stage = PipelineStage(
    stage_module,
    stage_index=rank,
    num_stages=world,
    device=device,
)

def loss_fn(logits, labels):
    return nn.functional.cross_entropy(
        logits.flatten(0, 1), labels.flatten(), reduction=&quot;mean&quot;
    )

schedule = Schedule1F1B(
    stage,
    n_microbatches=4,
    loss_fn=loss_fn,
    scale_grads=True,
)

optimizer = torch.optim.AdamW(stage_module.parameters(), lr=3e-4)
optimizer.zero_grad(set_to_none=True)

if rank == 0:
    # 只有第一 stage 接收完整输入；schedule 自动沿 batch 维切块
    schedule.step(input_ids.to(device))
else:
    # 只有最后 stage 持有 target 并计算每个 micro-batch loss
    losses = []
    logits = schedule.step(target=labels.to(device), losses=losses)

optimizer.step()  # 所有 micro-batches 完成后，每个 stage 更新自己的参数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段骨架假设 &lt;code&gt;input_ids&lt;/code&gt;、&lt;code&gt;labels&lt;/code&gt; 可均匀按 batch 轴分成 4 份。当前 API 也允许用 &lt;code&gt;arg_mbs&lt;/code&gt;、&lt;code&gt;kwarg_mbs&lt;/code&gt;、&lt;code&gt;target_mbs&lt;/code&gt; 传入已经切好的 micro-batches，适合 token 数不等或复杂输入结构。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PipelineStage&lt;/code&gt; 需要正确的边界 shape/dtype 来分配通信 buffers。当前文档支持首次 micro-batch 动态推断，也可传 example tensors 做静态约束；混合精度和 TP 会改变实际 dtype/layout，必须按运行时契约构造，否则会触发 &lt;code&gt;PipeliningShapeError&lt;/code&gt; 或更隐蔽的通信错误。&lt;/p&gt;
&lt;h2&gt;09 训练与推理的调度不是一回事&lt;/h2&gt;
&lt;p&gt;训练需要 backward，1F1B 的目标是安排前后向并限制激活驻留。自回归推理则有 token 间依赖：下一 token 要等上一 token 采样完成，单请求很难形成训练式 micro-batch pipeline。通常依靠多个并发请求、prefill/decode 分离或模型副本提高利用率。&lt;/p&gt;
&lt;p&gt;因此“PP 训练吞吐高”不能推出“单请求生成延迟低”。推理还要传 KV cache 或在各 stage 保存自己层的 cache，测量指标应分为 time-to-first-token 与 inter-token latency。&lt;/p&gt;
&lt;h2&gt;10 与 TP、FSDP2 组合时谁切什么？&lt;/h2&gt;
&lt;p&gt;3D parallelism（3D 并行）常写成 &lt;code&gt;[dp,pp,tp]&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TP group 内的 ranks 同算一个 stage 内的矩阵；&lt;/li&gt;
&lt;li&gt;PP group 沿深度传同一 micro-batch 的激活/梯度；&lt;/li&gt;
&lt;li&gt;DP group 的 pipeline replicas 处理不同样本并同步对应参数 shards。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一个样本不能既被错误地分给 TP ranks，又在 PP ranks 上重复计数。调试时打印每个 rank 的三维坐标、样本 ID、stage ID 和所有 process group 成员，比只打印全局 rank 更有用。&lt;/p&gt;
&lt;p&gt;组合 FSDP2 时还要决定 all-gather 与 stage 执行的重叠；组合 TP 时 stage 边界可能是 DTensor。每增加一个维度，先用更小 world size 验证数值，再增加调度复杂度。&lt;/p&gt;
&lt;h2&gt;11 Checkpoint 必须保存流水线进度吗？&lt;/h2&gt;
&lt;p&gt;通常只在完整 optimizer update 后声明 checkpoint 成功，此时所有 micro-batches 已排空，无需保存“正在管道中的激活”。需要保存每个 stage 的参数、optimizer state、scaler、学习率日程、数据游标、随机状态与 mesh/stage 映射。&lt;/p&gt;
&lt;p&gt;若系统试图在任意 micro-batch 中途容错恢复，就必须记录权重版本、已完成的 forward/backward 集合与通信状态，复杂度陡增。工程上更常见的边界是让本轮失败并从上一个完整 update 重放，同时保证数据迭代器可重现。&lt;/p&gt;
&lt;h2&gt;12 一条最短的正确性与性能验证路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;两 stages、两个 micro-batches、FP32、无 dropout，和未切分模型比较 logits、loss、每层梯度。&lt;/li&gt;
&lt;li&gt;用样本 ID 日志证明每个 micro-batch 恰好经过所有 stages 一次。&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;m&lt;/code&gt; 从 1 改到 2、4，完整 batch 与 loss 分母不变时，更新后参数应近似相同。&lt;/li&gt;
&lt;li&gt;记录每个 stage 的 F/B 起止时间与 send/recv，画真实时间线并测 bubble。&lt;/li&gt;
&lt;li&gt;分开记录参数、激活、通信 buffer 峰值与 tokens/s，再调整切分点和 $m$。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                       | 常见原因                             | 最短检查                                     |
| -------------------------- | ------------------------------------ | -------------------------------------------- |
| 第二步突然 shape error     | 最后一小批或序列长度改变边界 shape   | 固定/补齐 shape，记录每个 micro-batch 元数据 |
| loss 随 micro-batch 数缩小 | schedule 和 loss 都除以 $m$          | 对照 &lt;code&gt;scale_grads&lt;/code&gt; 与 loss reduction         |
| loss 随 micro-batch 数放大 | sum loss 未按全局有效 token 归一化   | 汇总 $S_j,n_j$ 手算                          |
| GPU 呈周期性长空洞         | stage 不均衡或 $m$ 太小              | 画每 stage 的 F/B/通信时间线                 |
| 显存仍随 $m$ 线性增长      | 使用 fill-drain 或 backward 启动太晚 | 统计未反传 activation 数                     |
| 进程永久等待               | stage 数、边界结构或调用顺序不一致   | 对齐所有 rank 的 schedule 与 send/recv 日志  |
| 恢复后只有部分层改变       | 每 stage 独立保存但缺少全局提交清单  | 校验所有 shards 属于同一 update              |&lt;/p&gt;
&lt;h2&gt;14 GPipe、1F1B 与其他调度怎样选？&lt;/h2&gt;
&lt;p&gt;GPipe 最容易理解和验证，但 activation 驻留高；1F1B 通常以相近气泡换更低内存。Interleaved 1F1B（交错 1F1B）让每个 rank 持有多个虚拟 stages，可改善负载与气泡，却增加通信和排序复杂度。Zero-bubble 调度进一步拆分 backward-input 与 backward-weight 来填空，前提是运行时能安全调度且两类 backward 成本合适。&lt;/p&gt;
&lt;p&gt;不要因为调度名称更“先进”就直接采用。先确认瓶颈究竟是 activation、气泡、stage imbalance 还是网络；若 $p$ 很小、模型能单节点训练，PP 复杂度可能根本不值得。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;PP 按层深度切分；micro-batches 让不同 stages 同时处理不同数据，但共同构成一次 optimizer update。&lt;/li&gt;
&lt;li&gt;GPipe 先全 forward 再全 backward，简单但保存更多激活；1F1B 更早反传，主要降低激活驻留。&lt;/li&gt;
&lt;li&gt;增大 micro-batch 数可减气泡，却会缩小 GEMM、增加通信启动；切分点必须按真实时间和内存平衡。&lt;/li&gt;
&lt;li&gt;Loss 缩放、边界 shape/dtype、rank group 和 checkpoint 提交边界是最容易静默出错的接口。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;画出 4 stages、8 micro-batches 的 GPipe forward 时间线，计算理想 forward 气泡比例；若每格 20 ms，估算理想吞吐。&lt;/li&gt;
&lt;li&gt;四个 micro-batches 的有效 token 数为 &lt;code&gt;[8,4,8,2]&lt;/code&gt;，loss sum 为 &lt;code&gt;[16,12,8,6]&lt;/code&gt;。计算正确全局平均，并说明为何平均四个局部 mean 会错。&lt;/li&gt;
&lt;li&gt;某三段模型每个 micro-batch 的 forward 时间为 &lt;code&gt;[8,20,10]&lt;/code&gt; ms，边界传输各 3 ms。提出一种重新切层方案，并说明还需测哪些 backward 数据。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Huang et al., &lt;a href=&quot;https://arxiv.org/abs/1811.06965&quot;&gt;GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism&lt;/a&gt;，提出以 micro-batch 填充层间流水线的经典方案。&lt;/li&gt;
&lt;li&gt;Narayanan et al., &lt;a href=&quot;https://arxiv.org/abs/1806.03377&quot;&gt;PipeDream: Generalized Pipeline Parallelism for DNN Training&lt;/a&gt;，研究 1F1B 与权重版本问题。&lt;/li&gt;
&lt;li&gt;Narayanan et al., &lt;a href=&quot;https://arxiv.org/abs/2006.09503&quot;&gt;Memory-Efficient Pipeline-Parallel DNN Training&lt;/a&gt;，分析同步流水线的激活内存与调度。&lt;/li&gt;
&lt;li&gt;Narayanan et al., &lt;a href=&quot;https://arxiv.org/abs/2104.04473&quot;&gt;Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM&lt;/a&gt;，讨论交错流水线与 3D 并行。&lt;/li&gt;
&lt;li&gt;Qi et al., &lt;a href=&quot;https://arxiv.org/abs/2401.10241&quot;&gt;Zero Bubble Pipeline Parallelism&lt;/a&gt;，通过拆分 backward 工作填充流水线气泡。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;DP、TP、PP 与 FSDP 的切分和调度已经连成一套训练架构。下一篇将从“算力不变为何训练仍变快”出发，进入 FlashAttention：如何利用 tiling 减少 HBM 读写，同时保持精确 attention 结果。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一层 MLP 太宽而放不下时怎样切？列并行、行并行与块尾归约</title><link>https://zwjcode.cn/blog/mlp-tensor-parallel-column-row-collective</link><guid isPermaLink="true">https://zwjcode.cn/blog/mlp-tensor-parallel-column-row-collective</guid><description>从 FSDP2 仍需临时聚齐单层参数出发，手算 Transformer MLP 的列并行与行并行，解释局部张量形状、通信位置、反向传播与 PyTorch DTensor 落地。</description><pubDate>Wed, 16 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 FSDP2 把参数、梯度和 optimizer state 分片常驻，但某个模块计算前仍要 all-gather（全聚合）该模块的完整参数。如果一层超宽 MLP 本身就放不进单卡，或者反复聚齐它已经成为瓶颈，切数据轴不够，必须切开层内矩阵。&lt;/p&gt;
&lt;p&gt;本篇只讲透一个配对：&lt;strong&gt;先用 Column-wise Parallel（列并行）切上投影的输出维，再用 Row-wise Parallel（行并行）切下投影的输入维。中间激活一直分片，直到下投影的局部部分和需要合并。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 为什么两层必须一起设计？&lt;/h2&gt;
&lt;p&gt;忽略 bias，Transformer MLP 可写成&lt;/p&gt;
&lt;p&gt;$$
H=\phi(XW_1),\qquad Y=HW_2
$$&lt;/p&gt;
&lt;p&gt;其中 $X\in\mathbb R^{B\times L\times D}$，$W_1\in\mathbb R^{D\times F}$，$H\in\mathbb R^{B\times L\times F}$，$W_2\in\mathbb R^{F\times D}$。$F$ 常为 $D$ 的数倍，因此参数和中间激活都很大。&lt;/p&gt;
&lt;p&gt;若只切 $W_1$，算完就 all-gather 完整 $H$，下一层又要重新切开，通信抵消了分片收益。正确配对让 $W_1$ 的列 shard 恰好成为 $W_2$ 的行 shard 输入。&lt;/p&gt;
&lt;h2&gt;02 两卡的数据流与张量形状&lt;/h2&gt;
&lt;p&gt;设 Tensor Parallel（张量并行，TP）大小 $T=2$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  X[每卡复制 X&amp;#x3C;br/&gt;B×L×D] --&gt; W10[rank 0: W1⁰&amp;#x3C;br/&gt;D×F/2]
  X --&gt; W11[rank 1: W1¹&amp;#x3C;br/&gt;D×F/2]
  W10 --&gt; H0[H⁰&amp;#x3C;br/&gt;B×L×F/2]
  W11 --&gt; H1[H¹&amp;#x3C;br/&gt;B×L×F/2]
  H0 --&gt; W20[rank 0: W2⁰&amp;#x3C;br/&gt;F/2×D]
  H1 --&gt; W21[rank 1: W2¹&amp;#x3C;br/&gt;F/2×D]
  W20 --&gt; P0[部分和 P⁰&amp;#x3C;br/&gt;B×L×D]
  W21 --&gt; P1[部分和 P¹&amp;#x3C;br/&gt;B×L×D]
  P0 --&gt; R[all-reduce SUM]
  P1 --&gt; R
  R --&gt; Y[每卡复制 Y&amp;#x3C;br/&gt;B×L×D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;列并行写作&lt;/p&gt;
&lt;p&gt;$$
W_1=[W_1^{(0)};W_1^{(1)}],\quad
H^{(r)}=\phi(XW_1^{(r)})\in\mathbb R^{B\times L\times F/2}.
$$&lt;/p&gt;
&lt;p&gt;行并行把 $W_2$ 沿第一维对应切开：&lt;/p&gt;
&lt;p&gt;$$
P^{(r)}=H^{(r)}W_2^{(r)}\in\mathbb R^{B\times L\times D},\qquad
Y=P^{(0)}+P^{(1)}.
$$&lt;/p&gt;
&lt;p&gt;关键不是“每层各切一半”，而是两个切分轴能首尾相接。激活函数 $phi$ 是逐元素运算，各 rank 可直接在自己的 $F/2$ 个通道上计算，不需要通信。&lt;/p&gt;
&lt;h2&gt;03 用四维隐藏层手算一次&lt;/h2&gt;
&lt;p&gt;令一个 token 的输入 $x=[1,2]$，上投影为&lt;/p&gt;
&lt;p&gt;$$
W_1=\begin{bmatrix}1&amp;#x26;0&amp;#x26;1&amp;#x26;2\0&amp;#x26;1&amp;#x26;1&amp;#x26;-1\end{bmatrix}.
$$&lt;/p&gt;
&lt;p&gt;两卡各取两列：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rank 0: x @ [[1,0],[0,1]] = [1,2]
rank 1: x @ [[1,2],[1,-1]] = [3,0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 ReLU 后仍为 $h^{(0)}=[1,2]$、$h^{(1)}=[3,0]$。令&lt;/p&gt;
&lt;p&gt;$$
W_2=\begin{bmatrix}1&amp;#x26;0\0&amp;#x26;1\1&amp;#x26;1\2&amp;#x26;-1\end{bmatrix},
$$&lt;/p&gt;
&lt;p&gt;则行分片得到&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rank 0 部分和: [1,2] @ [[1,0],[0,1]] = [1,2]
rank 1 部分和: [3,0] @ [[1,1],[2,-1]] = [3,3]
all-reduce SUM: [1,2] + [3,3] = [4,5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接算完整 $\operatorname{ReLU}(xW_1)W_2$ 也是 &lt;code&gt;[4,5]&lt;/code&gt;。这个极小例子最适合作为多卡实现的 oracle（正确性基准）。&lt;/p&gt;
&lt;h2&gt;04 为什么只需在块尾合并？&lt;/h2&gt;
&lt;p&gt;上投影输出是拼接关系，若必须恢复完整 $H$，需要 all-gather；下投影输出则是求和关系，需要 all-reduce。把两层配对后，前者被省掉，只保留块尾一次逻辑归约。&lt;/p&gt;
&lt;p&gt;实际实现还能把 all-reduce 拆为 reduce-scatter，使残差流也保持分片，再在后续合适位置 all-gather。这会改变激活 layout（布局）契约，却不改变数学式：某处最终必须把各 rank 的部分和组合起来。&lt;/p&gt;
&lt;p&gt;通信量不能只按“collective 次数”比较。对 $N=B L$ 个 token，块尾传输的逻辑张量为 $N\times D$；当 $N$ 很小或 $D/T$ 太窄时，通信延迟与小矩阵低利用率可能让 TP 比单卡更慢。&lt;/p&gt;
&lt;h2&gt;05 Bias 为什么容易被加两次？&lt;/h2&gt;
&lt;p&gt;若每个 rank 在部分和 $P^{(r)}$ 上都加完整下投影 bias $b_2$，all-reduce 后会得到&lt;/p&gt;
&lt;p&gt;$$
\sum_r(P^{(r)}+b_2)=Y+Tb_2.
$$&lt;/p&gt;
&lt;p&gt;正确做法是先归约部分和再加一次 bias，或让每卡只贡献 $b_2/T$。框架的 RowwiseParallel 会管理兼容层的参数与通信；手写实现时必须把 bias 的所有权写进测试。上投影 bias 按输出列自然分片，不存在重复相加。&lt;/p&gt;
&lt;h2&gt;06 反向传播怎样沿相反方向流动？&lt;/h2&gt;
&lt;p&gt;前向输出 $Y$ 在每卡复制，因此每卡拿到相同的 $\partial\mathcal L/\partial Y$。行并行反向可在本地得到 $\partial\mathcal L/\partial W_2^{(r)}$ 与 $\partial\mathcal L/\partial H^{(r)}$；后者继续穿过本地激活和上投影。&lt;/p&gt;
&lt;p&gt;上投影对复制输入 $X$ 的梯度是各列 shard 的贡献之和：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial\mathcal L}{\partial X}
=\sum_r \frac{\partial\mathcal L}{\partial Z^{(r)}}(W_1^{(r)})^\top.
$$&lt;/p&gt;
&lt;p&gt;因此通信不会消失，只是前向与反向分别落在能保持中间分片的位置。分析性能时要同时查看两个方向，不能只数 forward collective。&lt;/p&gt;
&lt;h2&gt;07 当前 PyTorch 的最小落地&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 的 Tensor Parallel API 构建在 DTensor（分布式张量）之上，&lt;code&gt;parallelize_module&lt;/code&gt; 只接收一维 &lt;code&gt;DeviceMesh&lt;/code&gt;。多维 mesh 必须先取出 &lt;code&gt;tp&lt;/code&gt; 子 mesh。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.tensor.parallel import (
    ColwiseParallel,
    RowwiseParallel,
    parallelize_module,
)

class MLP(nn.Module):
    def __init__(self, d_model: int, d_ff: int):
        super().__init__()
        self.up_proj = nn.Linear(d_model, d_ff)
        self.act = nn.GELU()
        self.down_proj = nn.Linear(d_ff, d_model)

    def forward(self, x):                 # x: [B,L,D]，各 TP rank 复制
        return self.down_proj(self.act(self.up_proj(x)))

torch.distributed.init_process_group(&quot;nccl&quot;)
tp_mesh = init_device_mesh(&quot;cuda&quot;, (torch.distributed.get_world_size(),))
model = MLP(d_model=4096, d_ff=11008).cuda()

model = parallelize_module(
    model,
    tp_mesh,
    {
        # 默认输出沿最后一维分片；逐元素 GELU 可本地执行
        &quot;up_proj&quot;: ColwiseParallel(),
        # 默认假设输入最后一维已分片，并返回复制输出
        &quot;down_proj&quot;: RowwiseParallel(),
    },
)

x = torch.randn(2, 128, 4096, device=&quot;cuda&quot;)
y = model(x)                              # [2,128,4096]，复制布局
loss = y.square().mean()
loss.backward()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前 API 仍标记 experimental（实验性），应固定 PyTorch 版本。若列并行和行并行之间有依赖全局维度的 &lt;code&gt;view&lt;/code&gt;、split 或自定义 kernel，普通本地 Tensor 可能把 &lt;code&gt;F/2&lt;/code&gt; 错当成 $F$；此时使用 &lt;code&gt;use_local_output=False&lt;/code&gt; 保留 DTensor 的全局 shape/placement 信息，并逐个审计中间算子。&lt;/p&gt;
&lt;h2&gt;08 Gated MLP 怎样扩展同一配对？&lt;/h2&gt;
&lt;p&gt;SwiGLU 常写作&lt;/p&gt;
&lt;p&gt;$$
H=\operatorname{SiLU}(XW_g)\odot(XW_u),\qquad Y=HW_d.
$$&lt;/p&gt;
&lt;p&gt;$W_g$ 与 $W_u$ 必须使用相同的列切分，确保每卡拿到相同通道范围，才能本地逐元素相乘；$W_d$ 再按对应输入行切分。若两个上投影的 shard 顺序不同，shape 完全正确，通道语义却已经错位。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;tp_plan = {
    &quot;gate_proj&quot;: ColwiseParallel(),
    &quot;up_proj&quot;: ColwiseParallel(),
    &quot;down_proj&quot;: RowwiseParallel(),
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;09 初始化、保存与 FSDP2 组合&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;parallelize_module&lt;/code&gt; 会把参数转换为 DTensor。optimizer 应在并行化后创建，使 state 跟随本地 shards。保存 checkpoint 时使用理解 DTensor/sharded state 的分布式接口；不要每卡把本地 shard 当完整 &lt;code&gt;state_dict&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;二维 &lt;code&gt;[dp,tp]&lt;/code&gt; mesh 中，先在 &lt;code&gt;mesh[&quot;tp&quot;]&lt;/code&gt; 应用 TP，再在独立 &lt;code&gt;mesh[&quot;dp&quot;]&lt;/code&gt; 上应用 FSDP2。TP ranks 协作处理同一份样本，DP ranks 才处理不同 batch shards。若把两个 group 交换，程序可能在 collective 上挂起，或把 batch 错切两次。&lt;/p&gt;
&lt;p&gt;初始化也要验证全局等价性：用固定完整权重切片，比“每卡同 seed 各初始化一个较小矩阵”更可靠，因为不同局部 shape 会改变随机数消费顺序。&lt;/p&gt;
&lt;h2&gt;10 如何证明实现没有静默算错？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;用本篇 $2\to4\to2$ 的整数矩阵关闭 bias，比较每卡输出与完整矩阵答案。&lt;/li&gt;
&lt;li&gt;再加入不同的 bias，专门检查下投影 bias 没被乘以 $T$。&lt;/li&gt;
&lt;li&gt;保存单卡 FP32 的输出、输入梯度和完整参数梯度；TP 后 gather shards，用 &lt;code&gt;torch.testing.assert_close&lt;/code&gt; 比较。&lt;/li&gt;
&lt;li&gt;给 gate/up 投影设置可辨认的通道编号，检查两者 shard 对齐。&lt;/li&gt;
&lt;li&gt;profiler 中核对 collective 的张量元素数、process group 与调用顺序，再测 tokens/s。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                          | 常见原因                              | 最短检查                                        |
| ----------------------------- | ------------------------------------- | ----------------------------------------------- |
| 中间 shape 变成预期一半后报错 | 本地 shard 被当作全局 shape           | 打印 DTensor &lt;code&gt;placements&lt;/code&gt; 与 local/global shape |
| 输出整体多一个常数            | 下投影 bias 在每卡先加后归约          | bias 置为已知非零数做手算                       |
| 两卡正常、四卡结果改变        | 分母、bias 或 process group 依赖 $T$  | 对 $T=1,2,4$ 跑等价性测试                       |
| collective 永久等待           | ranks 走了不同控制流或 group 错       | 给每次 collective 编号并对齐日志                |
| 显存下降但吞吐变差            | shard 矩阵太窄或通信未被隐藏          | 同看 GEMM shape、带宽与端到端 tokens/s          |
| checkpoint 恢复后 loss 跳变   | shard 元数据或 optimizer state 不完整 | 做跨 world-size 恢复演练                        |&lt;/p&gt;
&lt;h2&gt;12 它与相近并行方式有什么区别？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;FSDP2 在层外分片、层内临时使用完整参数；TP 让层内算子本身跨卡执行。&lt;/li&gt;
&lt;li&gt;Sequence Parallel（序列并行）切激活的 token/sequence 维，常用于 LayerNorm、dropout 等算子，不能替代超宽权重的切分。&lt;/li&gt;
&lt;li&gt;Expert Parallel（专家并行）把不同 MoE experts 放到不同 rank，通信核心是 token dispatch 的 all-to-all，而非稠密 MLP 的部分和。&lt;/li&gt;
&lt;li&gt;Pipeline Parallel（流水线并行）按层深度切分，只在 stage 边界传激活；下一篇将讨论它的调度。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;TP 的失败边界也很明确：高速互连不足、层太小、分片数不能整除 head/FFN 维、频繁动态控制流，都会让复杂度高于收益。先用单机高速互连建立基线，再扩到跨节点。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;上投影列并行产生 $F/T$ 通道，下投影按对应输入行并行；二者配对让中间激活无需 all-gather。&lt;/li&gt;
&lt;li&gt;每卡下投影先得到完整输出形状的部分和，必须在正确位置归约；bias 只能逻辑上加一次。&lt;/li&gt;
&lt;li&gt;local shape 与 global shape 不同，逐元素算子通常安全，&lt;code&gt;view&lt;/code&gt;、分头与自定义 kernel 必须审计 DTensor layout。&lt;/li&gt;
&lt;li&gt;多卡“能跑”不是正确性证据：输出、梯度、初始化、checkpoint 与 process group 都要对单卡 oracle。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 $X[8,128,4096]$、$W_1[4096,16384]$、$W_2[16384,4096]$ 做 8 路 TP，写出每卡两块权重与所有中间激活 shape。&lt;/li&gt;
&lt;li&gt;若每个 rank 都给下投影部分和加 bias，证明 all-reduce 后 bias 被放大 $T$ 倍，并给出两种修复。&lt;/li&gt;
&lt;li&gt;把手算例改成带负数、使用 ReLU，分别算完整路径与两卡路径的输出和 $\partial Y_0/\partial x$。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Shoeybi et al., &lt;a href=&quot;https://arxiv.org/abs/1909.08053&quot;&gt;Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism&lt;/a&gt;，系统化展示 Transformer MLP 与 attention 的层内张量并行。&lt;/li&gt;
&lt;li&gt;Shazeer et al., &lt;a href=&quot;https://arxiv.org/abs/1811.02084&quot;&gt;Mesh-TensorFlow: Deep Learning for Supercomputers&lt;/a&gt;，用命名张量维度表达分布式切分。&lt;/li&gt;
&lt;li&gt;Xu et al., &lt;a href=&quot;https://arxiv.org/abs/2105.04663&quot;&gt;GSPMD: General and Scalable Parallelization for ML Computation Graphs&lt;/a&gt;，讨论编译器传播 sharding annotation 的方法。&lt;/li&gt;
&lt;li&gt;Lian et al., &lt;a href=&quot;https://arxiv.org/abs/2110.14883&quot;&gt;Colossal-AI: A Unified Deep Learning System For Large-Scale Parallel Training&lt;/a&gt;，总结多维并行的系统组合。&lt;/li&gt;
&lt;li&gt;Li et al., &lt;a href=&quot;https://arxiv.org/abs/2006.15704&quot;&gt;PyTorch Distributed: Experiences on Accelerating Data Parallel Training&lt;/a&gt;，提供 collective 与 PyTorch 分布式运行时背景。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;张量并行解决了单层太宽，却让每层都依赖高速 collective。下一篇转向按深度切分的 Pipeline Parallel：micro-batch 如何填满 stages，GPipe 与 1F1B 的气泡、激活驻留和梯度语义有何不同。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>每张卡为何还要保留完整模型？FSDP2 的参数全聚合、梯度归约分片与显存峰值</title><link>https://zwjcode.cn/blog/fsdp2-parameter-gradient-optimizer-state-sharding</link><guid isPermaLink="true">https://zwjcode.cn/blog/fsdp2-parameter-gradient-optimizer-state-sharding</guid><description>从普通 DDP 的复制成本出发，手算 FSDP2 如何分片参数、梯度和优化器状态，拆解 all-gather、reduce-scatter、reshard、分组边界与分布式 checkpoint。</description><pubDate>Wed, 16 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇比较了 Data Parallel（数据并行）、Tensor Parallel（张量并行）与 Pipeline Parallel（流水线并行）。普通 DDP 虽然把 batch 分给多卡，却在每个 rank 保存完整参数、梯度和 optimizer state：卡越多，总副本越多，单卡容量没有下降。&lt;/p&gt;
&lt;p&gt;本篇聚焦 Fully Sharded Data Parallel 2（全分片数据并行第二代，FSDP2）的一个核心机制：&lt;strong&gt;平时把模型状态分片保存，只在某个模块真正计算前临时聚齐参数，并在反向后把梯度归约回分片。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 DDP 到底复制了多少状态？&lt;/h2&gt;
&lt;p&gt;设参数量为 $P$，数据并行 world size 为 $R$。每参数字节数仍记为权重 $b_w$、梯度 $b_g$、optimizer state $b_o$。&lt;/p&gt;
&lt;p&gt;普通 DDP 每卡长期占用近似&lt;/p&gt;
&lt;p&gt;$$
M_{\text{DDP,state}}=P(b_w+b_g+b_o)
$$&lt;/p&gt;
&lt;p&gt;FSDP 理想稳定态则近似&lt;/p&gt;
&lt;p&gt;$$
M_{\text{shard,state}}\approx\frac{P(b_w+b_g+b_o)}{R}
$$&lt;/p&gt;
&lt;p&gt;但“除以 $R$”不是峰值答案。计算某个参数组前，FSDP 必须 all-gather（全聚合）完整参数；通信还可能同时持有下一组的预取 buffer。更实际的粗略式是&lt;/p&gt;
&lt;p&gt;$$
M_{\text{peak}}\approx M_{\text{shard,state}}+
M_{\text{largest unsharded group}}+M_{\text{prefetch}}+M_a+M_{\text{temp}}
$$&lt;/p&gt;
&lt;p&gt;因此模型总状态均分后能放下，不代表某个巨大的根分组也能安全 all-gather。&lt;/p&gt;
&lt;h2&gt;02 FSDP2 的一层在时间上怎样变化？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
  participant S as 每卡参数 shard (DTensor)
  participant C as Collective
  participant M as 本地模块计算
  S-&gt;&gt;C: pre-forward all-gather
  C-&gt;&gt;M: 临时完整参数 Tensor
  M-&gt;&gt;M: forward
  M-&gt;&gt;S: post-forward reshard / 释放完整参数
  S-&gt;&gt;C: pre-backward 再 all-gather
  C-&gt;&gt;M: 用完整参数计算 backward
  M-&gt;&gt;C: 完整局部梯度 reduce-scatter
  C-&gt;&gt;S: 每卡保留梯度 shard
  S-&gt;&gt;S: optimizer 更新本地 state shard
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;fully_shard(module)&lt;/code&gt; 会原地把参数转换为按第 0 维分片的 DTensor（分布式张量）。pre-forward/backward hook 暂时 all-gather 为普通完整 Tensor；post-forward/backward 再恢复分片表示。Optimizer（优化器）在 &lt;code&gt;fully_shard&lt;/code&gt; 之后创建，因而看到并更新本地参数 shard。&lt;/p&gt;
&lt;h2&gt;03 两张卡、8 个参数的极小手算&lt;/h2&gt;
&lt;p&gt;设一层参数向量为&lt;/p&gt;
&lt;p&gt;$$
w=[w_0,w_1,w_2,w_3,w_4,w_5,w_6,w_7]
$$&lt;/p&gt;
&lt;p&gt;两路 FSDP2 沿第 0 维切分：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rank 0 常驻: [w0,w1,w2,w3]
rank 1 常驻: [w4,w5,w6,w7]

forward 前 all-gather:
rank 0 临时得到 [w0,...,w7]
rank 1 临时得到 [w0,...,w7]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两 rank 分别用不同数据算出局部完整梯度 $g^{(0)},g^{(1)}\in\mathbb R^8$。reduce-scatter（归约分片）先按元素求和或平均，再让每卡只保留一半：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rank 0 留: reduce(g0[0:4], g1[0:4])
rank 1 留: reduce(g0[4:8], g1[4:8])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若每卡 AdamW 为自己的 4 个参数保存一阶矩、二阶矩和 master weight，optimizer state 也自然只占一半。下一次 forward 再从两个新 shard 聚齐更新后的完整参数。&lt;/p&gt;
&lt;h2&gt;04 &lt;code&gt;fully_shard&lt;/code&gt; 的最小正确顺序&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os

import torch
from torch import nn
from torch.distributed.fsdp import fully_shard

torch.distributed.init_process_group(backend=&quot;nccl&quot;)
local_rank = int(os.environ[&quot;LOCAL_RANK&quot;])
torch.cuda.set_device(local_rank)

model = Transformer().cuda(local_rank)

# 自底向上：先给每个 block 建通信组，再处理根模块剩余参数
for block in model.blocks:
    fully_shard(block, reshard_after_forward=True)
fully_shard(model, reshard_after_forward=True)

# 必须在 fully_shard 之后，让 optimizer 看到 DTensor parameter shards
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)

for input_ids, labels in loader:
    optimizer.zero_grad(set_to_none=True)
    logits = model(input_ids.cuda(local_rank))    # [B_r,L,V]
    loss = nn.functional.cross_entropy(
        logits.transpose(1, 2), labels.cuda(local_rank)
    )
    loss.backward()                               # grad reduce-scatter
    optimizer.step()                              # 更新本地 shards
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例假设各 rank 的 batch shape 与有效元素数相同，并且 &lt;code&gt;loader&lt;/code&gt; 已用 DistributedSampler（分布式采样器）无重复地切分数据；可变 token 数仍要使用上一篇的全局精确分母。多机环境必须使用 &lt;code&gt;LOCAL_RANK&lt;/code&gt; 选择本机设备，不能把全局 rank 直接当 GPU 编号。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fully_shard&lt;/code&gt; 应自底向上调用：子模块先成为各自通信组，根模块最后接管尚未分组的参数。若只对根模型调用一次，所有参数会成为一个巨大组，forward 开始前一次性 all-gather，全程几乎无法与计算重叠，峰值也接近重新放入完整模型。&lt;/p&gt;
&lt;h2&gt;05 分片组边界为何同时控制内存与吞吐？&lt;/h2&gt;
&lt;p&gt;假设四个 block 参数量分别为 &lt;code&gt;[2,2,2,2]&lt;/code&gt; GB。&lt;/p&gt;
&lt;h3&gt;只 shard 根模块&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;all-gather 8 GB -&gt; 依次算 block 1..4 -&gt; reduce-scatter 8 GB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通信次数少但消息巨大；完整 8 GB 参数长时间驻留，计算与通信难重叠。&lt;/p&gt;
&lt;h3&gt;每个 block 单独 shard&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;gather block1 2 GB -&gt; compute1 -&gt; free
gather block2 2 GB -&gt; compute2 -&gt; free
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;完整参数峰值下降，并可预取下一组；但组太碎会产生很多小 collective，延迟和 hook 开销上升。正确边界通常靠 Transformer block 或若干相邻 block，而不是每个小 Linear 都单独分组。&lt;/p&gt;
&lt;p&gt;选择边界时测四个量：最大 unsharded group、预取时双 buffer 峰值、collective 次数与大小、通信被计算隐藏的比例。&lt;/p&gt;
&lt;h2&gt;06 &lt;code&gt;reshard_after_forward&lt;/code&gt; 在换什么？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;reshard_after_forward=True&lt;/code&gt; 会在 forward 后释放完整参数，backward 前再 all-gather 一次：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：forward 与 backward 之间只保留 shards，激活高峰期显存更低；&lt;/li&gt;
&lt;li&gt;代价：每轮该组通常多一次参数 all-gather。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;设 False 则可能让完整参数跨过 forward 保留到 backward，减少一次通信但提高峰值。对最外层根模块、共享参数、梯度累积和不同 mesh，行为还需结合当前 API 契约验证，不能仅凭布尔值猜内存。&lt;/p&gt;
&lt;p&gt;一个稳妥实验是对同一 batch 分别记录：&lt;code&gt;max_memory_allocated()&lt;/code&gt;、每组 all-gather 次数和总字节、forward/backward 时间。若显存仍有余量而网络成为瓶颈，可以探索保留；若 activation 峰值已贴近上限，优先 reshard。&lt;/p&gt;
&lt;h2&gt;07 前向预取为何可能更快也可能 OOM？&lt;/h2&gt;
&lt;p&gt;若计算 block $k$ 时异步 all-gather block $k+1$，通信可以被当前 block 的矩阵乘隐藏：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;时间 -&gt;
compute k:       [==========]
gather k+1:        [------]
compute k+1:                 [==========]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但重叠窗口内同时存在 block $k$ 的完整参数、block $k+1$ 的接收 buffer 和当前激活。最大组各 3 GB 时，预取可能瞬间增加约 3 GB 峰值。出现“单步偶发 OOM”时，要把 prefetch buffer 纳入 memory snapshot，而不是只看稳定态 shard 大小。&lt;/p&gt;
&lt;h2&gt;08 与梯度累积组合时会发生什么？&lt;/h2&gt;
&lt;p&gt;梯度累积的每个 micro-batch 都要 forward/backward。若每次 forward 都 all-gather，累积 $K$ 次就可能重复参数通信 $K$ 次。&lt;code&gt;reshard_after_forward&lt;/code&gt;、是否在同步边界保留参数，以及 FSDP2 的梯度同步控制会影响容量与通信，不能照搬 DDP &lt;code&gt;no_sync()&lt;/code&gt; 的直觉。&lt;/p&gt;
&lt;p&gt;数学语义仍遵循上一篇：累积 loss sum，并用全局有效 token 数归一化。工程验证要额外统计每个 optimizer update 内的 all-gather/reduce-scatter 次数，确保为省 activation 采用的大 $K$ 没把网络吞吐压垮。&lt;/p&gt;
&lt;h2&gt;09 梯度为什么用 reduce-scatter 而不是 all-reduce？&lt;/h2&gt;
&lt;p&gt;DDP all-reduce 后，每卡都得到完整平均梯度；FSDP 的 optimizer 只需要自己参数 shard 对应的梯度，因此没必要保留完整结果。&lt;/p&gt;
&lt;p&gt;把长度 $P$ 的向量看成 $R$ 段：reduce-scatter 等价于“先跨 rank reduce，再把第 $r$ 段交给 rank $r$”。其每卡输出只有 $P/R$，既完成数据并行归约，也直接得到 optimizer 所需的本地梯度 shard。&lt;/p&gt;
&lt;p&gt;调试归一化时，不要假设库内部是 SUM 还是 AVG。构造两 rank 单参数例子，给 rank 0/1 不同输入，手算全局 batch 梯度，再与更新后的 full parameter 比较，比只观察 loss 更可靠。&lt;/p&gt;
&lt;h2&gt;10 分布式 checkpoint 为什么不能每卡随便 &lt;code&gt;torch.save&lt;/code&gt;？&lt;/h2&gt;
&lt;p&gt;FSDP2 参数是 DTensor shards。每个 rank 单独保存 &lt;code&gt;model.state_dict()&lt;/code&gt; 的结果并不自动构成一个可迁移的完整 checkpoint；还需要保存 shard 元数据、world-size/mesh 信息、optimizer shards、随机状态、数据游标和成功更新计数。&lt;/p&gt;
&lt;p&gt;PyTorch 官方建议通过 Distributed Checkpoint（分布式检查点）相关接口处理 sharded state dict，或显式用 DTensor API materialize full tensor。生产系统应测试：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;同 world size 原地恢复；&lt;/li&gt;
&lt;li&gt;不同 world size 的 reshard 恢复；&lt;/li&gt;
&lt;li&gt;rank/节点故障后是否只承认完整提交的 checkpoint；&lt;/li&gt;
&lt;li&gt;恢复后的下一步 loss、学习率、scaler 和数据位置是否连续。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要在每卡同时 gather 完整 state dict 后保存：它可能把 CPU/GPU 内存峰值重新推回未分片规模，并造成多份重复文件。&lt;/p&gt;
&lt;h2&gt;11 Shared Parameter（共享参数）与初始化陷阱&lt;/h2&gt;
&lt;p&gt;语言模型常让 token embedding 与 LM head 权重绑定。同一参数若被两个不一致的 shard group 重复管理，可能破坏共享关系或触发难懂错误。应用 &lt;code&gt;fully_shard&lt;/code&gt; 前后应检查 &lt;code&gt;data_ptr&lt;/code&gt;/参数对象关系以及 state dict key，确保 tied weight 仍是同一个逻辑参数。&lt;/p&gt;
&lt;p&gt;超大模型也不应先在每个 rank 的 CPU 上完整随机初始化，再搬到 GPU：CPU 内存会复制 $R$ 份。可用 meta device 建结构、先分片再 materialize，并明确参数初始化如何在 ranks 间保持一致。不同 rank 各自用不同随机数填 shard 并不天然等价于同一个全局初始化。&lt;/p&gt;
&lt;h2&gt;12 如何证明分片训练仍等价？&lt;/h2&gt;
&lt;p&gt;一个最小测试流程：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 1. 固定种子，构造极小模型与完整全局 batch
# 2. 路径 A：单卡 FP32 得到 loss、full grads、更新后参数
# 3. 路径 B：两 rank FSDP2，各取 batch 一半，执行一步
# 4. 将参数 shards 聚成 full tensor 后比较
torch.testing.assert_close(full_after_fsdp, after_single,
                           rtol=1e-5, atol=1e-7)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若失败，依次排查：初始 full parameter 是否一致、sampler 是否无重复无遗漏、loss 分母、collective group、共享权重、dropout/RNG、混合精度，最后才归因于浮点归约顺序。&lt;/p&gt;
&lt;h2&gt;13 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                              | 常见原因                           | 最短检查                                 |
| --------------------------------- | ---------------------------------- | ---------------------------------------- |
| shard 后仍在 forward 开始 OOM     | 根分组一次 all-gather 全模型       | 列出每个 FSDP group 参数字节数           |
| 显存偶发尖峰                      | prefetch 与当前完整参数双重驻留    | 对照 collective 时间线与 memory snapshot |
| optimizer state 仍像完整模型      | optimizer 在 &lt;code&gt;fully_shard&lt;/code&gt; 前创建  | 打印参数类型、本地 shape 与 state 元素数 |
| 卡数增加反而更慢                  | 模块分组太碎或网络延迟高           | 汇总 collective 次数、大小、未重叠时间   |
| checkpoint 单卡可读但整体不能恢复 | 只保存了局部 shard，缺少元数据     | 做全流程故障恢复演练                     |
| 两 rank 更新后参数不等            | sampler、归一化或 process group 错 | gather full gradient 对单卡基准          |
| tied embedding 不再共享           | 分片边界错误处理共享参数           | 对比分片前后参数身份与 state keys        |&lt;/p&gt;
&lt;h2&gt;14 FSDP2 不解决什么？&lt;/h2&gt;
&lt;p&gt;FSDP2 不能让单层计算天然跨卡：层执行时仍需要完整参数；巨型 embedding、超宽 Linear 或 attention 临时张量若单卡放不下，需 Tensor/Sequence Parallel。它也不会消除激活，长上下文仍可能需要 checkpointing、Flash Attention 或序列切分。&lt;/p&gt;
&lt;p&gt;当模型很小、网络较慢或 batch 已足够大时，重复 all-gather 的成本可能高于内存收益。普通 DDP 代码更简单、通信模式更成熟，能放下时通常应先作为基线。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;FSDP2 平时分片参数、梯度和 optimizer state，计算某组前临时 all-gather 完整参数，反向后 reduce-scatter 梯度。&lt;/li&gt;
&lt;li&gt;每卡稳定态约除以 world size，但真实峰值还包含最大完整参数组、预取 buffer、激活和临时 workspace。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fully_shard&lt;/code&gt; 的模块边界就是通信分组边界：太大导致峰值高且难重叠，太碎导致大量小 collective。&lt;/li&gt;
&lt;li&gt;optimizer 创建顺序、共享参数、梯度累积和分布式 checkpoint 都必须按分片语义重新验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;模型含 12 GB 权重、12 GB 梯度和 72 GB optimizer state。用 8 路理想全分片计算每卡稳定态；若最大 unsharded group 为 3 GB、预取下一组也为 3 GB，再估算不含激活的峰值下界。&lt;/li&gt;
&lt;li&gt;对两个 rank 的梯度 &lt;code&gt;g0=[1,2,3,4]&lt;/code&gt;、&lt;code&gt;g1=[5,6,7,8]&lt;/code&gt;，分别写出 SUM reduce-scatter 后每卡保留的 shard；若目标是全局平均，应如何缩放？&lt;/li&gt;
&lt;li&gt;为 24 个 Transformer blocks 设计两种 &lt;code&gt;fully_shard&lt;/code&gt; 分组方案，列出你会用哪些 profiler 指标决定选哪一种。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Rajbhandari et al., &lt;a href=&quot;https://arxiv.org/abs/1910.02054&quot;&gt;ZeRO: Memory Optimizations Toward Training Trillion Parameter Models&lt;/a&gt;，系统提出对 optimizer state、梯度和参数逐级分片。&lt;/li&gt;
&lt;li&gt;Zhao et al., &lt;a href=&quot;https://arxiv.org/abs/2304.11277&quot;&gt;PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel&lt;/a&gt;，总结 FSDP 的架构、通信与生产经验。&lt;/li&gt;
&lt;li&gt;Xu et al., &lt;a href=&quot;https://arxiv.org/abs/2004.13336&quot;&gt;Automatic Cross-Replica Sharding of Weight Update Computation in Data-Parallel Training&lt;/a&gt;，研究数据并行更新状态的自动分片。&lt;/li&gt;
&lt;li&gt;Sergeev and Del Balso, &lt;a href=&quot;https://arxiv.org/abs/1802.05799&quot;&gt;Horovod: fast and easy distributed deep learning in TensorFlow&lt;/a&gt;，说明基于 collective 的数据并行工程。&lt;/li&gt;
&lt;li&gt;Li et al., &lt;a href=&quot;https://arxiv.org/abs/2006.15704&quot;&gt;PyTorch Distributed: Experiences on Accelerating Data Parallel Training&lt;/a&gt;，解释 PyTorch 分布式梯度同步与通信重叠设计。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;FSDP2 让模型长期状态不再完整复制，但某一层计算时仍会临时聚齐参数。下一篇将深入 Tensor Parallel：如何把 MLP 的上投影做列并行、下投影做行并行，并用一次必要 collective 串起正确的张量形状。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>模型放不下一张卡时究竟该切什么？数据并行、张量并行与流水线并行的边界</title><link>https://zwjcode.cn/blog/model-parallelism-data-tensor-pipeline-boundary</link><guid isPermaLink="true">https://zwjcode.cn/blog/model-parallelism-data-tensor-pipeline-boundary</guid><description>从显存瓶颈和通信位置出发，用四卡手算比较数据并行、张量并行与流水线并行，解释三种切分的数据流、张量形状、性能代价与组合原则。</description><pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇解决了 Gradient Accumulation（梯度累积）如何在一张卡和 DistributedDataParallel（分布式数据并行，DDP）中保持大 batch 语义。但如果模型参数、激活或 optimizer state 本身已经放不进单卡，继续缩小 micro-batch 也无济于事。&lt;/p&gt;
&lt;p&gt;此时不能只问“有几张 GPU”，而要问：&lt;strong&gt;复制什么、切开什么，以及被切开的张量何时必须重新通信？&lt;/strong&gt; 本篇只建立 Data Parallel（数据并行，DP）、Tensor Parallel（张量并行，TP）与 Pipeline Parallel（流水线并行，PP）的选择框架；具体分片算法留到后文。&lt;/p&gt;
&lt;h2&gt;01 先把一轮训练拆成四类显存&lt;/h2&gt;
&lt;p&gt;设模型有 $P$ 个参数，每个参数的权重、梯度和优化器状态分别占 $b_w,b_g,b_o$ 字节，激活峰值为 $M_a(B,L)$。单卡粗略峰值为&lt;/p&gt;
&lt;p&gt;$$
M_{\text{single}}\approx P(b_w+b_g+b_o)+M_a(B,L)+M_{\text{temp}}
$$&lt;/p&gt;
&lt;p&gt;其中 $B$ 是 micro-batch 大小，$L$ 是序列长度，$M_{\text{temp}}$ 是 attention、通信和算子 workspace。以混合精度 AdamW 为例，若 FP16 权重 2 字节、FP16 梯度 2 字节、FP32 master weight、动量和二阶矩共 12 字节，则仅模型状态约为 $16P$ 字节；10 亿参数已经约 16 GB，还没有算激活和临时张量。&lt;/p&gt;
&lt;p&gt;先测清谁占满显存，才知道该切哪个轴：&lt;/p&gt;
&lt;p&gt;| 瓶颈                   | 仅缩小 batch         | 更直接的方向                           |
| ---------------------- | -------------------- | -------------------------------------- |
| 激活随 $B,L$ 增长      | 有效，但吞吐可能下降 | checkpoint、sequence/pipeline parallel |
| 参数与 optimizer state | 几乎无效             | FSDP/ZeRO、tensor parallel             |
| 单层矩阵本身放不下     | 无效                 | tensor parallel                        |
| 层数很多、跨机带宽较低 | 部分有效             | pipeline parallel                      |&lt;/p&gt;
&lt;h2&gt;02 三种并行究竟切哪一维？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart TB
  M[完整训练计算图] --&gt; DP[数据并行: 切 batch B]
  M --&gt; TP[张量并行: 切 hidden/head/FFN 维]
  M --&gt; PP[流水线并行: 切 layer 深度]
  DP --&gt; D1[每卡完整层&amp;#x3C;br/&gt;不同样本]
  TP --&gt; T1[每卡一层的部分矩阵&amp;#x3C;br/&gt;同一批样本]
  PP --&gt; P1[每卡连续若干层&amp;#x3C;br/&gt;micro-batch 流过 stages]
  D1 --&gt; C1[反向同步梯度或分片状态]
  T1 --&gt; C2[层内 collective]
  P1 --&gt; C3[stage 边界点对点传激活/梯度]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;DP 切 batch 轴：rank $r$ 看到 $X_r\in\mathbb R^{B/R\times L\times D}$，通常保存完整计算图。&lt;/li&gt;
&lt;li&gt;TP 切模型层内部：同一个 $X\in\mathbb R^{B\times L\times D}$ 被多个 rank 协作处理，权重或激活沿 hidden/head 维分片。&lt;/li&gt;
&lt;li&gt;PP 切层轴：stage 0 保存前几层，stage 1 保存后几层，边界激活从前向后传，边界梯度反向传回。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它们不是三种互斥框架，而是三个近乎正交的 mesh 维度。大规模训练常把总卡数写成&lt;/p&gt;
&lt;p&gt;$$
N_{\text{GPU}}=N_{\text{DP}}N_{\text{TP}}N_{\text{PP}}
$$&lt;/p&gt;
&lt;p&gt;但乘积相同不代表性能相同，因为三种通信发生的位置和频率不同。&lt;/p&gt;
&lt;h2&gt;03 用四张卡手算“复制与切分”&lt;/h2&gt;
&lt;p&gt;假设模型状态 32 GB，激活峰值 8 GB，每卡只有 24 GB，暂忽略通信 buffer。&lt;/p&gt;
&lt;h3&gt;方案 A：4 路普通 DDP&lt;/h3&gt;
&lt;p&gt;每卡都复制 32 GB 模型状态，再放自己的激活。峰值约 $32+8/4=34$ GB，仍然 OOM。DDP 增加了吞吐，却没有解决完整模型状态放不下的问题。&lt;/p&gt;
&lt;h3&gt;方案 B：4 路状态分片的数据并行&lt;/h3&gt;
&lt;p&gt;若参数、梯度与 optimizer state 理想地均分，每卡模型状态约 $32/4=8$ GB；再加约 2 GB 局部激活，静态估算 10 GB。实际还会在计算某层前短暂 all-gather（全聚合）该层参数，因此峰值取决于分片边界。&lt;/p&gt;
&lt;h3&gt;方案 C：2 路 TP × 2 路 PP&lt;/h3&gt;
&lt;p&gt;每个 pipeline stage 放一半层，每层又做 2 路 tensor shard。理想模型状态约 $32/(2\times2)=8$ GB；但 TP 每层可能做 all-reduce 或 reduce-scatter，PP 还会有 pipeline bubble（流水线气泡）。它能处理“单层也放不下”的情况，代价是更密集的调度与通信。&lt;/p&gt;
&lt;p&gt;这个例子说明：&lt;strong&gt;显存除法只是容量下界，collective 的时机才决定实际吞吐与峰值。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;04 数据并行：算子完整，样本不同&lt;/h2&gt;
&lt;p&gt;设线性层 $Y=XW$，$X\in\mathbb R^{B\times D}$，$W\in\mathbb R^{D\times H}$。两路 DP 得到：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rank 0: X0 [B/2,D] × W [D,H] -&gt; Y0 [B/2,H]
rank 1: X1 [B/2,D] × W [D,H] -&gt; Y1 [B/2,H]
backward: 对 dW0、dW1 做同步，保证副本下一步仍一致
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;优点是每张卡执行完整大矩阵，算子利用率通常好，模型代码改动少。限制是普通 DDP 复制完整权重、梯度和 optimizer state，单卡容量必须先容纳模型。上一篇的 &lt;code&gt;no_sync()&lt;/code&gt; 只减少梯度累积期间的通信次数，不减少长期保存的模型状态。&lt;/p&gt;
&lt;p&gt;数据采样也必须显式分片。DDP 不会替你切输入：训练集若在每个 rank 都按相同顺序遍历，等价于重复计算同一批样本。&lt;/p&gt;
&lt;h2&gt;05 张量并行：同一层由多卡合算&lt;/h2&gt;
&lt;p&gt;仍看 $Y=XW$。两路 Column-wise Parallel（列并行）把 $W$ 的输出维切开：&lt;/p&gt;
&lt;p&gt;$$
W=[W_0;W_1],\quad W_i\in\mathbb R^{D\times H/2},\qquad
Y_i=XW_i\in\mathbb R^{B\times H/2}
$$&lt;/p&gt;
&lt;p&gt;若下一层可直接消费分片 $Y_i$，无需马上拼回；若后续算子要求完整 $Y$，就要 all-gather。Row-wise Parallel（行并行）则把输入维与权重行切开：&lt;/p&gt;
&lt;p&gt;$$
X=[X_0;X_1],\quad W=\begin{bmatrix}W_0\W_1\end{bmatrix},\qquad
Y=X_0W_0+X_1W_1
$$&lt;/p&gt;
&lt;p&gt;每卡先算部分和 $Y_i\in\mathbb R^{B\times H}$，再 all-reduce 求 $Y$。Transformer 常把相邻的列并行与行并行配对，使中间激活保持分片，只在块的合适边界通信。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.tensor.parallel import (
    ColwiseParallel,
    RowwiseParallel,
    parallelize_module,
)

tp_mesh = init_device_mesh(&quot;cuda&quot;, (2,))
model = parallelize_module(
    model,
    tp_mesh,
    {
        &quot;mlp.up_proj&quot;: ColwiseParallel(),
        &quot;mlp.down_proj&quot;: RowwiseParallel(),
    },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是 PyTorch 2.14 的核心接口形状，真实模型还要声明输入/输出 DTensor layout，并检查两层之间的 reshape、分头和残差是否理解“全局 shape”与“本地 shard”。&lt;/p&gt;
&lt;h2&gt;06 流水线并行：按层切成 stages&lt;/h2&gt;
&lt;p&gt;若 12 层模型分到 3 个 stage，每个 stage 持有 4 层。一个完整 batch 若一次穿过所有 stage，后两张卡在开头空闲、前两张卡在结尾空闲。PP 因而把 batch 再切成 $m$ 个 micro-batch 并交错执行。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;时间 -&gt;    t0   t1   t2   t3   t4
stage 0:   μ0   μ1   μ2   μ3   --
stage 1:   --   μ0   μ1   μ2   μ3
stage 2:   --   --   μ0   μ1   μ2 ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对理想 GPipe 前向排程，$p$ 个 stage、$m$ 个 micro-batch 的气泡比例近似&lt;/p&gt;
&lt;p&gt;$$
\text{bubble}\approx\frac{p-1}{m+p-1}
$$&lt;/p&gt;
&lt;p&gt;例如 $p=4,m=4$ 时约为 $3/7\approx43%$；增至 $m=16$ 后约为 $3/19\approx16%$。但更多 micro-batch 会增加调度开销，并影响激活驻留和梯度累积语义。&lt;/p&gt;
&lt;p&gt;PP 的关键不是“平均分层数”，而是平衡每个 stage 的实际前后向时间与显存。Embedding、attention、MLP 和输出词表层成本不同，最慢 stage 决定流水线节拍。&lt;/p&gt;
&lt;h2&gt;07 通信原语比名称更重要&lt;/h2&gt;
&lt;p&gt;| 方法       | 主要通信                                      | 典型发生时机          | 对网络的要求       |
| ---------- | --------------------------------------------- | --------------------- | ------------------ |
| DDP        | gradient all-reduce                           | backward bucket ready | 可与反向重叠       |
| FSDP 类 DP | parameter all-gather、gradient reduce-scatter | 每个分片单元前/后     | 依赖预取与分组     |
| TP         | all-reduce、all-gather、reduce-scatter        | 层内部，频率高        | 适合机内高速互连   |
| PP         | send/recv activation 与 gradient              | stage 边界            | 消息较大但频率较低 |&lt;/p&gt;
&lt;p&gt;因此常见拓扑是：节点内用 TP，利用 NVLink 等高带宽互连；节点间用 FSDP/DP；层很多或跨机带宽有限时再加 PP。它是经验起点，不是无需测量的定律。&lt;/p&gt;
&lt;h2&gt;08 一个可执行的选择流程&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;profile 单卡峰值
├─ 模型状态能放下？
│  ├─ 是：先 DP；若激活爆炸，考虑 checkpoint/sequence parallel
│  └─ 否：状态分片 DP
├─ 最大单层 all-gather 后仍放不下？
│  └─ 是：给该层加 TP
└─ 层可自然分段且网络跨节点较慢？
   └─ 测 PP，并调 micro-batch 数与 stage balance
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;选择时至少记录：每卡参数/梯度/optimizer/activation 峰值、每种 collective 的字节数与次数、通信计算重叠率、最慢 rank、tokens/s 和收敛等价性。仅看 GPU utilization 很容易把等待 collective 的时间误读为有效工作。&lt;/p&gt;
&lt;h2&gt;09 组合成二维 mesh 时 shape 如何理解？&lt;/h2&gt;
&lt;p&gt;假设 8 张卡组成 &lt;code&gt;[dp=4,tp=2]&lt;/code&gt;：同一个 TP 小组的两卡共同计算一份样本；四个 DP 小组处理四份不同样本。若全局 batch 为 32：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个 DP replica 得到 8 个样本；&lt;/li&gt;
&lt;li&gt;replica 内两张 TP 卡都参与这 8 个样本，不是各拿 4 个；&lt;/li&gt;
&lt;li&gt;TP 切的是 hidden/head，不应再次除 batch；&lt;/li&gt;
&lt;li&gt;DP 同步发生在“持有相同参数 shard”的四个 rank 之间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把两个 mesh 维的 process group 搞反，轻则 shape mismatch，重则 collective 顺序不一致而永久 hang。&lt;/p&gt;
&lt;h2&gt;10 最小调试方法：先验证语义，再测性能&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;用两层小模型、关闭 dropout，保存单卡基准的 loss 与完整梯度。&lt;/li&gt;
&lt;li&gt;每加一个并行维度，就 gather 出等价张量，用 &lt;code&gt;torch.testing.assert_close&lt;/code&gt; 比较输出和梯度。&lt;/li&gt;
&lt;li&gt;打印每个 rank 的 mesh coordinate、输入样本 ID、局部参数 shape 和 collective 顺序。&lt;/li&gt;
&lt;li&gt;在极小 batch 上跑 2–3 个 optimizer step，比较更新后的完整 state dict。&lt;/li&gt;
&lt;li&gt;最后才扩大模型，使用 profiler 检查通信、气泡与峰值显存。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;多卡程序“没有报错”不代表正确。样本重复、梯度多除一次 world size、某个 shard 没参与 loss，都可能稳定运行却优化错误目标。&lt;/p&gt;
&lt;h2&gt;11 常见错误与症状&lt;/h2&gt;
&lt;p&gt;| 症状                        | 常见原因                        | 最短检查                              |
| --------------------------- | ------------------------------- | ------------------------------------- |
| 加卡后仍 OOM                | 使用复制模型的 DDP 解决参数容量 | 分开统计模型状态与激活                |
| TP 输出 shape 突然减半      | 本地 shard 当成全局 tensor 使用 | 打印 DTensor placement 与 local shape |
| PP 吞吐呈锯齿               | stage 不平衡或 micro-batch 太少 | 画每 stage 时间线                     |
| 程序永久 hang               | ranks 的 collective 顺序不同    | 为每个 collective 编号并比日志        |
| loss 与单卡差 world size 倍 | DP 与 TP group/归一化混淆       | 写出每个 reduction 的数学目标         |
| GPU 很忙但 tokens/s 低      | 频繁小 collective 或重算过多    | 同看 kernel、通信和端到端吞吐         |&lt;/p&gt;
&lt;h2&gt;12 三种方法各自会在哪里失败？&lt;/h2&gt;
&lt;p&gt;DP 的扩展受全局 batch 和数据并行通信限制；模型若已完整放不下，普通 DDP 无法启动。TP 会把通信插进每层，跨低带宽节点常被延迟拖垮，小矩阵分片后也可能失去算子效率。PP 要求可切分且 shape 契约稳定的图，stage 不平衡、气泡和跨 stage 状态会增加复杂度。&lt;/p&gt;
&lt;p&gt;混合并行也不会自动解决数据加载、checkpoint 保存、故障恢复或数值等价性。mesh 越多维，rank 映射与 state dict 越需要被当成正式接口测试。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;DP 切 batch，TP 切层内张量维，PP 切网络深度；先定位显存成分再选轴。&lt;/li&gt;
&lt;li&gt;容量估算只给下界，all-gather、all-reduce、reduce-scatter 和 pipeline bubble 决定真实性能。&lt;/li&gt;
&lt;li&gt;TP 适合高带宽域，PP 适合可平衡的层段，状态分片 DP 是模型状态放不下时的常见第一步。&lt;/li&gt;
&lt;li&gt;每增加一个并行维，都要用单卡基准验证输出、梯度、样本覆盖与归一化。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一个模型状态 48 GB、激活 12 GB，使用 4 张 24 GB 卡。分别估算 4 路 DDP 与理想 4 路状态分片的每卡静态占用，并指出估算遗漏了什么。&lt;/li&gt;
&lt;li&gt;对 $X[8,1024]W[1024,4096]$ 写出两路列并行与行并行的每卡权重、输入、局部输出 shape，以及需要的 collective。&lt;/li&gt;
&lt;li&gt;画出 3 个 stage、6 个 micro-batch 的前向流水线，计算理想气泡比例，并解释为何实际比例可能更高。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Dean et al., &lt;a href=&quot;https://proceedings.neurips.cc/paper/2012/hash/6aca97005c68f1206823815f66102863-Abstract.html&quot;&gt;Large Scale Distributed Deep Networks&lt;/a&gt;，系统讨论模型并行与数据并行的早期大规模实践。&lt;/li&gt;
&lt;li&gt;Shoeybi et al., &lt;a href=&quot;https://arxiv.org/abs/1909.08053&quot;&gt;Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism&lt;/a&gt;，展示 Transformer 层内张量并行。&lt;/li&gt;
&lt;li&gt;Huang et al., &lt;a href=&quot;https://arxiv.org/abs/1811.06965&quot;&gt;GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism&lt;/a&gt;，用 micro-batch pipeline 扩展深层模型。&lt;/li&gt;
&lt;li&gt;Narayanan et al., &lt;a href=&quot;https://arxiv.org/abs/2104.04473&quot;&gt;Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM&lt;/a&gt;，研究数据、张量与流水线并行的组合。&lt;/li&gt;
&lt;li&gt;Rajbhandari et al., &lt;a href=&quot;https://arxiv.org/abs/1910.02054&quot;&gt;ZeRO: Memory Optimizations Toward Training Trillion Parameter Models&lt;/a&gt;，通过分片数据并行状态降低副本内存。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;三种切分轴已经定位清楚。下一篇将聚焦状态分片数据并行：FSDP2 为什么在算某一层前 all-gather 参数、反向后 reduce-scatter 梯度，以及分片边界怎样同时决定峰值显存和通信重叠。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>大 Batch 放不进显存怎么办？梯度累积的精确归一化与 DDP no_sync</title><link>https://zwjcode.cn/blog/gradient-accumulation-token-normalization-ddp-nosync</link><guid isPermaLink="true">https://zwjcode.cn/blog/gradient-accumulation-token-normalization-ddp-nosync</guid><description>从梯度平均的分母出发，手算不等长 micro-batch 的累积误差，实现 token 精确归一化、AMP 与 DDP no_sync，并覆盖尾批、裁剪、调度和等价性调试。</description><pubDate>Tue, 15 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 Activation Checkpointing（激活检查点）重算中间激活，换回了部分显存。但单次前向能容纳的样本或 token 仍有上限。最直接的办法是把一个大 batch 拆成多个 Micro-batch（微批次），依次反向，把梯度留在参数的 &lt;code&gt;.grad&lt;/code&gt; 中，最后只更新一次。&lt;/p&gt;
&lt;p&gt;本篇聚焦一个容易被“除以累积步数”掩盖的问题：&lt;strong&gt;Gradient Accumulation（梯度累积）何时真的等价于一个大 batch，以及可变 token 数与 DDP 下怎样得到正确分母。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 梯度为什么能够相加？&lt;/h2&gt;
&lt;p&gt;一个更新窗口有 $N$ 个有效监督 token，每个 token 的 loss 为 $\ell_i(\theta)$。目标是&lt;/p&gt;
&lt;p&gt;$$
\mathcal L(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell_i(\theta),\qquad
\nabla_\theta\mathcal L=\frac{1}{N}\sum_{i=1}^{N}\nabla_\theta\ell_i
$$&lt;/p&gt;
&lt;p&gt;微批次 $k$ 包含有效 token 集 $S_k$，先对每批求 loss sum：&lt;/p&gt;
&lt;p&gt;$$
g_{\text{sum}}=\sum_{k=1}^{K}\nabla_\theta\sum_{i\in S_k}\ell_i,qquad
g=\frac{g_{\text{sum}}}{\sum_k |S_k|}
$$&lt;/p&gt;
&lt;p&gt;只要模型参数在窗口内不更新，反向的加法就与把这些 token 拼成一次大 batch 等价；浮点求和顺序、随机层和 BatchNorm 统计仍会造成小差异。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[micro 1: loss_sum + n1] --&gt; G[累加参数 .grad]
  B[micro 2: loss_sum + n2] --&gt; G
  C[micro K: loss_sum + nK] --&gt; G
  A --&gt; N[累加有效 token N]
  B --&gt; N
  C --&gt; N
  G --&gt; D[grad /= global N]
  N --&gt; D
  D --&gt; E[unscale / clip / optimizer.step]
  E --&gt; F[清空 grad 并推进 token 时钟]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;02 “每批 mean 再除以 K”为什么会错？&lt;/h2&gt;
&lt;p&gt;设两个 micro-batch 分别有 2 和 6 个有效 token，各自 token loss 为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;micro 1: [2, 4]             mean = 3
micro 2: [1, 1, 1, 1, 1, 1] mean = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若计算 &lt;code&gt;(3 + 1) / 2 = 2&lt;/code&gt;，两个 micro-batch 权重相同；真正的大 batch 平均是&lt;/p&gt;
&lt;p&gt;$$
\frac{2+4+1+1+1+1+1+1}{8}=1.5
$$&lt;/p&gt;
&lt;p&gt;只有每个 micro-batch 的有效元素数相等时，“各自 mean 再除以 $K$”才成立。语言模型有 padding、文档边界 mask 和不同长度，必须累加 loss sum 与有效 token 总数。&lt;/p&gt;
&lt;h2&gt;03 单卡上的精确实现&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;torch.nn.functional.cross_entropy(..., reduction=&quot;sum&quot;, ignore_index=-100)&lt;/code&gt; 会让被忽略位置不贡献 loss。下面的 &lt;code&gt;loss_sum.backward()&lt;/code&gt; 将未归一化梯度直接相加，窗口结束后统一除以有效 token 数。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F

optimizer.zero_grad(set_to_none=True)
window_tokens = 0

for micro in micro_batches:
    input_ids = micro[&quot;input_ids&quot;].cuda()       # [B_k,L_k]
    labels = micro[&quot;labels&quot;].cuda()             # [B_k,L_k], pad=-100
    logits = model(input_ids)                    # [B_k,L_k,V]
    loss_sum = F.cross_entropy(
        logits.transpose(1, 2), labels,
        ignore_index=-100,
        reduction=&quot;sum&quot;,
    )                                            # scalar
    loss_sum.backward()                          # 参数 .grad 累加 sum
    window_tokens += int(labels.ne(-100).sum())

if window_tokens == 0:
    raise RuntimeError(&quot;accumulation window has no supervised token&quot;)
for parameter in model.parameters():
    if parameter.grad is not None:
        parameter.grad.div_(window_tokens)

torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入为整数 token &lt;code&gt;[B_k,L_k]&lt;/code&gt;，logits 为 &lt;code&gt;[B_k,L_k,V]&lt;/code&gt;，loss 是标量，参数梯度 shape 与对应参数完全相同。&lt;code&gt;set_to_none=True&lt;/code&gt; 避免无意义清零写入，并让“本窗口没有梯度”和“梯度全是 0”更容易区分。&lt;/p&gt;
&lt;h2&gt;04 为什么 optimizer 不能在中途 step？&lt;/h2&gt;
&lt;p&gt;如果 micro 1 反向后就更新参数，micro 2 的梯度是在新参数 $\theta_1$ 上计算；目标变成两次小 batch SGD，不再是同一个 $\theta_0$ 上的大 batch 梯度。&lt;/p&gt;
&lt;p&gt;一个窗口内，以下操作都只能在最后执行一次：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;梯度归一化与 clipping；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;optimizer.step()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;optimizer.zero_grad()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;AMP scaler 的 &lt;code&gt;unscale_&lt;/code&gt;、&lt;code&gt;step&lt;/code&gt; 与 &lt;code&gt;update&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;按成功更新计数的学习率与 token 时钟。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;05 与 AMP 组合时正确顺序是什么？&lt;/h2&gt;
&lt;p&gt;上一篇已说明同一窗口必须保持同一 scale。为了让动态 GradScaler（梯度缩放器）检查正确的梯度，先让所有 micro-batch 用 &lt;code&gt;scaler.scale(loss_sum).backward()&lt;/code&gt;，窗口末尾再 unscale，然后除以全局 token 分母、裁剪和更新。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;optimizer.zero_grad(set_to_none=True)
window_tokens = 0

for micro in micro_batches:
    with torch.autocast(&quot;cuda&quot;, dtype=torch.float16):
        logits = model(micro[&quot;input_ids&quot;])
        loss_sum = F.cross_entropy(
            logits.transpose(1, 2), micro[&quot;labels&quot;],
            ignore_index=-100, reduction=&quot;sum&quot;,
        )
    scaler.scale(loss_sum).backward()
    window_tokens += int(micro[&quot;labels&quot;].ne(-100).sum())

scaler.unscale_(optimizer)                       # 每窗口一次
for p in model.parameters():
    if p.grad is not None:
        p.grad.div_(window_tokens)                # 现在已是 unscaled grad
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先除 token 分母再裁剪，因为阈值通常定义在平均梯度上。若先裁剪 loss sum 梯度，窗口 token 数翻倍就会凭空改变裁剪强度。&lt;/p&gt;
&lt;h2&gt;06 DDP 为什么会让中间 micro-batch 白白通信？&lt;/h2&gt;
&lt;p&gt;DistributedDataParallel（分布式数据并行，DDP）默认在 backward 中对梯度 bucket 做 all-reduce。若每个 micro-batch 都同步，通信发生 $K$ 次；但更新只需要最终累积和同步一次。&lt;/p&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;ddp.no_sync()&lt;/code&gt; 会暂缓梯度同步，第一次离开该上下文的 forward-backward 再同步累积梯度。官方特别提醒：&lt;strong&gt;forward 也必须放进 &lt;code&gt;no_sync()&lt;/code&gt; 上下文&lt;/strong&gt;，否则仍会同步。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from contextlib import nullcontext

optimizer.zero_grad(set_to_none=True)
local_tokens = 0

for i, micro in enumerate(micro_batches):
    is_last = i == len(micro_batches) - 1
    sync_context = nullcontext() if is_last else ddp.no_sync()
    with sync_context:                              # 包住 forward + backward
        logits = ddp(micro[&quot;input_ids&quot;])
        loss_sum = F.cross_entropy(
            logits.transpose(1, 2), micro[&quot;labels&quot;],
            ignore_index=-100, reduction=&quot;sum&quot;,
        )
        loss_sum.backward()
    local_tokens += int(micro[&quot;labels&quot;].ne(-100).sum())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后一个 backward 会把此前本地累积的梯度一起同步。若最后一次也用了 &lt;code&gt;no_sync()&lt;/code&gt;，各 rank 会拿不同梯度继续更新，模型副本从此分叉。&lt;/p&gt;
&lt;h2&gt;07 全局 token 分母为何还要乘 world size？&lt;/h2&gt;
&lt;p&gt;rank $r$ 的本地梯度和为 $G_r$。DDP 同步后参数 &lt;code&gt;.grad&lt;/code&gt; 是&lt;/p&gt;
&lt;p&gt;$$
G_{\text{ddp}}=\frac{1}{R}\sum_{r=1}^{R}G_r
$$&lt;/p&gt;
&lt;p&gt;全局平均目标应为&lt;/p&gt;
&lt;p&gt;$$
G=\frac{\sum_r G_r}{N_{\text{global}}}
=G_{\text{ddp}}\frac{R}{N_{\text{global}}}
$$&lt;/p&gt;
&lt;p&gt;因此先 all-reduce 各 rank 的 &lt;code&gt;local_tokens&lt;/code&gt; 得到 &lt;code&gt;global_tokens&lt;/code&gt;，再把已同步梯度乘 &lt;code&gt;world_size / global_tokens&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;count = torch.tensor(local_tokens, device=&quot;cuda&quot;, dtype=torch.float64)
torch.distributed.all_reduce(count, op=torch.distributed.ReduceOp.SUM)
global_tokens = count.item()
scale = torch.distributed.get_world_size() / global_tokens

for p in ddp.parameters():
    if p.grad is not None:
        p.grad.mul_(scale)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这允许各 rank 因长度分桶而有不同有效 token 数，只要它们执行相同数量的 forward-backward 并按同一时刻同步。&lt;/p&gt;
&lt;h2&gt;08 累积窗口末尾不足 K 批怎么办？&lt;/h2&gt;
&lt;p&gt;数据集结束、过滤坏样本或 OOM 重试都可能留下 remainder（余批）。三种选择要显式定义：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;照常更新&lt;/strong&gt;：用实际 &lt;code&gt;global_tokens&lt;/code&gt; 归一化；优化步的 batch 较小，但不丢数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨 epoch 延续&lt;/strong&gt;：保留梯度和计数到下一轮；数据顺序与 checkpoint 恢复更复杂。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;丢弃余批&lt;/strong&gt;：复现简单，但每轮系统性丢样本，分布式各 rank 必须一致。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;绝不能仍除以配置的 $K$。恢复 checkpoint 时若允许保存“半个窗口”，必须同时保存已累积梯度、micro-step、token 计数、scaler 与数据游标；工程上更常在更新边界保存。&lt;/p&gt;
&lt;h2&gt;09 有效 batch 大小应该怎样描述？&lt;/h2&gt;
&lt;p&gt;固定形状视觉任务常写&lt;/p&gt;
&lt;p&gt;$$
B_{\text{effective}}=B_{\text{micro}}\times K\times R
$$&lt;/p&gt;
&lt;p&gt;但语言模型更应报告每次 update 的全局有效 token：&lt;/p&gt;
&lt;p&gt;$$
N_{\text{effective}}=\sum_{r=1}^{R}\sum_{k=1}^{K}N_{r,k}
$$&lt;/p&gt;
&lt;p&gt;它同时反映 padding、loss mask、sequence packing 和 rank 间长度差异。日志至少保存 &lt;code&gt;micro_step&lt;/code&gt;、&lt;code&gt;optimizer_step&lt;/code&gt;、&lt;code&gt;local/global_effective_tokens&lt;/code&gt;、&lt;code&gt;physical_tokens&lt;/code&gt;、&lt;code&gt;grad_norm&lt;/code&gt; 与是否成功更新。&lt;/p&gt;
&lt;h2&gt;10 梯度累积等价性的边界&lt;/h2&gt;
&lt;p&gt;| 组件             | 是否通常等价 | 原因                                |
| ---------------- | ------------ | ----------------------------------- |
| Linear/LayerNorm | 近似等价     | 每样本计算不依赖 batch 统计         |
| BatchNorm        | 不等价       | 每个 micro-batch 分别计算均值方差   |
| Dropout          | 统计上接近   | 随机 mask 与大 batch 的调用顺序不同 |
| 梯度裁剪         | 可等价       | 必须在累积和归一化后只裁一次        |
| AdamW            | 可等价       | 每窗口只 step 一次，状态只更新一次  |
| 学习率日程       | 可等价       | 只在成功 optimizer step 后推进      |&lt;/p&gt;
&lt;p&gt;浮点加法不满足严格结合律，因此即使公式等价也不应要求 bitwise identical（逐位相同）。正确验收是 FP64/FP32 小模型中梯度误差在合理容差内，并且短程 loss 轨迹一致。&lt;/p&gt;
&lt;h2&gt;11 一个最小等价性测试&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def flatten_grads(model):
    return torch.cat([
        p.grad.detach().flatten()
        for p in model.parameters() if p.grad is not None
    ])

# model_big 与 model_acc 初始 state_dict 完全相同，关闭 dropout
# 路径 A：8 个 token 一次 mean backward
# 路径 B：2 + 6 个 token 分别 sum backward，最后除以 8
g_big = flatten_grads(model_big)
g_acc = flatten_grads(model_acc)
torch.testing.assert_close(g_acc, g_big, rtol=1e-5, atol=1e-7)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若失败，按顺序检查：初始参数、样本顺序与 mask、loss reduction、分母、是否中途 zero/step、随机层、BatchNorm，最后才考虑浮点累积顺序。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                        | 常见原因                      | 最短检查                             |
| --------------------------- | ----------------------------- | ------------------------------------ |
| 长短 batch 混合后 loss 漂移 | mean-of-means 权重错误        | 打印每批 loss sum 与有效 token       |
| 梯度小了 world size 倍      | DDP 平均后又直接除全局 token  | 检查 &lt;code&gt;world_size/global_tokens&lt;/code&gt; 因子 |
| 通信次数没有下降            | forward 没包在 &lt;code&gt;no_sync()&lt;/code&gt; 内 | profiler 统计每窗口 all-reduce 数    |
| 各 rank 参数逐渐不同        | 最后一个 micro 也禁了同步     | 每次更新后比参数 checksum            |
| clipping 随 K 改变          | 对未归一化 sum gradient 裁剪  | 归一化后再记录 grad norm             |
| 恢复后第一步异常            | 在半窗口保存却没恢复 &lt;code&gt;.grad&lt;/code&gt;  | 只在 update 边界保存或补齐状态       |
| OOM 后更新权重偏了          | 跳过一批却仍用固定分母        | 从实际成功 micro-batch 重算计数      |&lt;/p&gt;
&lt;h2&gt;13 性能上是不是 K 越大越好？&lt;/h2&gt;
&lt;p&gt;更大的 $K$ 降低每个 micro-batch 的激活峰值，并让 DDP 少同步；但它也增加 Python/launch 开销，延迟 optimizer step，并可能让单次矩阵太小而无法吃满 GPU。极大的有效 batch 会降低梯度噪声，未必提升样本效率，学习率也不能无条件线性放大。&lt;/p&gt;
&lt;p&gt;应对候选 &lt;code&gt;(micro_batch, K)&lt;/code&gt; 组合测 tokens/s、峰值显存、每次 update 时间、通信占比和验证质量。目标是满足显存约束后尽量提高端到端吞吐，而不是最大化累积次数。&lt;/p&gt;
&lt;h2&gt;14 它会在哪里失败？&lt;/h2&gt;
&lt;p&gt;如果模型依赖跨样本操作、批内负样本或 BatchNorm，大 batch 的交互无法由独立 micro-batch 的梯度相加复原。对比学习的分母若需要全局样本，必须先构造正确的跨卡/跨微批负样本集合；否则优化目标已经改变。&lt;/p&gt;
&lt;p&gt;梯度累积也不会减少一次 forward 内单个超长样本的激活；那仍需要 sequence parallel、切分 attention、activation checkpointing 或缩短上下文。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;梯度累积等价于大 batch 的前提，是在同一参数点累加 loss sum，最后除以全局有效元素数。&lt;/li&gt;
&lt;li&gt;可变长度任务不能用 mean-of-means；要显式记录 loss sum 与有效 token。&lt;/li&gt;
&lt;li&gt;DDP 中非最后 micro-batch 用 &lt;code&gt;no_sync()&lt;/code&gt;，且上下文必须同时包住 forward 和 backward。&lt;/li&gt;
&lt;li&gt;DDP 默认平均梯度，所以本地 sum loss 的最终缩放是 &lt;code&gt;world_size / global_tokens&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;三个 micro-batch 的有效 token 数为 &lt;code&gt;[3,5,2]&lt;/code&gt;，mean loss 为 &lt;code&gt;[2,1,4]&lt;/code&gt;。分别计算错误的 mean-of-means 与正确全局均值。&lt;/li&gt;
&lt;li&gt;写一个两进程 DDP 小测试，让两个 rank 分别拥有 2 和 6 个 token，验证缩放因子 &lt;code&gt;world_size/global_tokens&lt;/code&gt; 与单进程 8-token 梯度一致。&lt;/li&gt;
&lt;li&gt;为“尾窗口照常更新”设计 checkpoint 与日志字段，保证中断恢复不会重复或漏掉样本。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Goyal et al., &lt;a href=&quot;https://arxiv.org/abs/1706.02677&quot;&gt;Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour&lt;/a&gt;，讨论大 batch、学习率缩放与 warmup 的经验规律。&lt;/li&gt;
&lt;li&gt;McCandlish et al., &lt;a href=&quot;https://arxiv.org/abs/1812.06162&quot;&gt;An Empirical Model of Large-Batch Training&lt;/a&gt;，用梯度噪声尺度分析 batch 增大何时仍有效。&lt;/li&gt;
&lt;li&gt;Ott et al., &lt;a href=&quot;https://arxiv.org/abs/1806.00187&quot;&gt;Scaling Neural Machine Translation&lt;/a&gt;，展示梯度累积和大 batch 在神经机器翻译训练中的作用。&lt;/li&gt;
&lt;li&gt;Li et al., &lt;a href=&quot;https://arxiv.org/abs/2006.15704&quot;&gt;PyTorch Distributed: Experiences on Accelerating Data Parallel Training&lt;/a&gt;，解释 DDP 的梯度 bucket、同步与工程设计。&lt;/li&gt;
&lt;li&gt;Smith et al., &lt;a href=&quot;https://arxiv.org/abs/1711.00489&quot;&gt;Don&apos;t Decay the Learning Rate, Increase the Batch Size&lt;/a&gt;，比较学习率衰减与逐步增大 batch 的关系。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;单卡和数据并行的 batch 语义已经清楚，下一篇将继续研究大模型如何跨设备放置参数与 optimizer state，并比较 Data Parallel、Tensor Parallel 与 Pipeline Parallel 各自在切什么。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>激活占满显存时该丢掉什么？Activation Checkpointing 的重算、边界与随机数状态</title><link>https://zwjcode.cn/blog/activation-checkpointing-recompute-memory-boundary</link><guid isPermaLink="true">https://zwjcode.cn/blog/activation-checkpointing-recompute-memory-boundary</guid><description>从反向传播为何需要中间激活出发，手算显存—计算交换，拆解 PyTorch 2.14 非重入 checkpoint 的重算数据流，并给出边界选择、随机层、调试与性能验证方法。</description><pubDate>Mon, 14 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 Automatic Mixed Precision（自动混合精度，AMP）降低了部分张量的位宽，但 Transformer 的长序列激活仍可能比参数更早撑满显存。减小 batch 能继续训练，却会降低设备利用率并改变优化噪声；把张量转成半精度也不能消除随层数和序列长度增长的中间结果。&lt;/p&gt;
&lt;p&gt;本篇只解决一个核心问题：&lt;strong&gt;Activation Checkpointing（激活检查点）怎样丢弃一部分前向激活，在反向时重算它们，用额外计算换峰值显存。&lt;/strong&gt; 它不是保存到磁盘的训练 checkpoint，后者用于崩溃恢复。&lt;/p&gt;
&lt;h2&gt;01 反向传播为什么留着前向激活？&lt;/h2&gt;
&lt;p&gt;设一层为&lt;/p&gt;
&lt;p&gt;$$
H_1=H_0W_1,\qquad H_2=\operatorname{GELU}(H_1),\qquad H_3=H_2W_2
$$&lt;/p&gt;
&lt;p&gt;其中 $H_0\in\mathbb{R}^{B\times L\times D}$，$W_1\in\mathbb{R}^{D\times4D}$，$H_1,H_2\in\mathbb{R}^{B\times L\times4D}$，$W_2\in\mathbb{R}^{4D\times D}$，$H_3\in\mathbb{R}^{B\times L\times D}$。反向计算 $\partial\mathcal L/\partial W_2=H_2^\top(\partial\mathcal L/\partial H_3)$ 需要 $H_2$；GELU 的导数又需要前向输入 $H_1$。&lt;/p&gt;
&lt;p&gt;Autograd（自动微分）因此保存 backward 所需张量。它们不是参数，也不是最终输出，却会一直活到相应反向算子执行。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  X[边界输入 H0] --&gt; A[Linear 1]
  A --&gt; B[GELU]
  B --&gt; C[Linear 2]
  C --&gt; Y[边界输出 H3]
  A -.普通前向保存 H1.-&gt; M[(activation memory)]
  B -.普通前向保存 H2.-&gt; M
  Y --&gt; G[backward 到达本段]
  G --&gt;|checkpoint: 从 H0 重跑| A2[重算 Linear 1 + GELU]
  A2 --&gt; R[得到所需 H1/H2]
  R --&gt; D[计算参数与输入梯度]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;02 Checkpoint 究竟保存和丢弃什么？&lt;/h2&gt;
&lt;p&gt;对一段函数 $F$ 使用 checkpoint 时，第一次前向仍计算 $Y=F(X)$，但不保留段内所有中间激活；只保留边界输入等重算所需信息。反向到达这段时再次执行 $F(X)$，重建需要的激活，再照常求梯度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;普通训练: forward(F) -&gt; 保存 a,b,c -&gt; backward(F)
检查点训练: forward(F) -&gt; 只留边界 x -&gt; recompute(F) -&gt; backward(F)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;被丢掉的是&lt;strong&gt;可由边界输入重建的激活&lt;/strong&gt;，不是参数、参数梯度、optimizer state，也不是当前 batch。若显存主要被 Adam 状态或巨型 embedding 占据，activation checkpointing 的收益就有限。&lt;/p&gt;
&lt;h2&gt;03 用四层的极小例子手算交换&lt;/h2&gt;
&lt;p&gt;假设四层 $f_1,\ldots,f_4$ 的每个边界激活都占 10 MB，忽略参数和临时 workspace。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通前向保留四层所需激活，粗略为 $4\times10=40$ MB；每层前向执行 1 次。&lt;/li&gt;
&lt;li&gt;把 $f_1,f_2$ 作为一段、$f_3,f_4$ 作为一段，只保留两个段输入，边界约 20 MB；反向时两个段各重算 1 次。&lt;/li&gt;
&lt;li&gt;若每一层都切成独立 checkpoint，边界本身也要保存，切得越碎不一定继续省；调用和 RNG 管理开销反而增加。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理想化的均匀链可以用约 $O(\sqrt n)$ 个边界换取额外前向计算，但真实 Transformer 有 attention、MLP、残差分支和不同大小的临时张量，不能只按“层数均分”。应以 profiler 的实际 saved tensor 和峰值为准。&lt;/p&gt;
&lt;h2&gt;04 当前 PyTorch 的最小正确写法&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;torch.utils.checkpoint.checkpoint&lt;/code&gt; 要求显式传 &lt;code&gt;use_reentrant&lt;/code&gt;，官方推荐 &lt;code&gt;use_reentrant=False&lt;/code&gt;。非重入实现会记录前向 autograd graph，并在所需中间量重建完后提前停止重算。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn
from torch.utils.checkpoint import checkpoint

class Block(nn.Module):
    def __init__(self, d_model=512):
        super().__init__()
        self.norm = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, 4 * d_model),
            nn.GELU(),
            nn.Linear(4 * d_model, d_model),
        )

    def forward(self, x):              # x: [B,L,D]
        return x + self.ffn(self.norm(x))

class Stack(nn.Module):
    def __init__(self, depth=12, d_model=512):
        super().__init__()
        self.blocks = nn.ModuleList([Block(d_model) for _ in range(depth)])

    def forward(self, x):              # [B,L,D] -&gt; [B,L,D]
        for block in self.blocks:
            x = checkpoint(
                block,
                x,
                use_reentrant=False,
                preserve_rng_state=True,
            )
        return x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入 &lt;code&gt;x&lt;/code&gt;、参数和返回值的 shape 都不变；变化只发生在 autograd 保存策略和反向执行次数。先在同一模型、同一 batch 上验证 loss 与梯度，再衡量显存和吞吐。&lt;/p&gt;
&lt;h2&gt;05 为什么边界应包住完整的计算段？&lt;/h2&gt;
&lt;p&gt;一个 Transformer block 常包含 LayerNorm、attention、MLP 与残差。只 checkpoint 一个很小的 GELU，保存的边界张量可能和丢掉的激活一样大；把几十层整个包成一段，又会在反向重算过长路径。&lt;/p&gt;
&lt;p&gt;常用起点是“每个 block 一段”或“每 2–4 个 block 一段”，随后测量：&lt;/p&gt;
&lt;p&gt;| 切法         | 边界数量 | 重算粒度 | 常见结果               |
| ------------ | -------: | -------- | ---------------------- |
| 不切         |        0 | 无       | 最快，激活显存最高     |
| 每个 block   |       多 | 细       | 易实现，调用开销较多   |
| 每 2–4 block |       中 | 中       | 常是吞吐与显存折中     |
| 整个 stack   |       少 | 粗       | 重算峰值和时延可能过大 |&lt;/p&gt;
&lt;p&gt;应重点覆盖 $B\times L\times4D$ 的 MLP 激活、attention 中随 $L$ 增长的张量；不要凭模块名猜显存。&lt;/p&gt;
&lt;h2&gt;06 Dropout 重算为何可能得到另一张图？&lt;/h2&gt;
&lt;p&gt;Checkpointed function（被检查点函数）在前向和反向重算时必须等价。Dropout 会消费随机数：若两次 mask 不同，重算的是另一个函数，梯度就不再对应原前向。&lt;/p&gt;
&lt;p&gt;默认 &lt;code&gt;preserve_rng_state=True&lt;/code&gt; 会保存并恢复 CPU 与一个推断出的设备类型的随机数状态，使重算沿用相同随机结果，但会增加开销。只有当该段确定没有随机算子，或你明确接受不同随机轨迹时，才考虑设为 &lt;code&gt;False&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def segment(x, block):                 # block 内可能有 dropout
    return block(x)

y = checkpoint(
    segment, x, block,
    use_reentrant=False,
    preserve_rng_state=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若函数内部把张量移动到运行时新设备，官方文档提醒 RNG 状态可能无法完整预见；应把设备迁移放在 checkpoint 外。&lt;/p&gt;
&lt;h2&gt;07 副作用和可变全局状态为什么危险？&lt;/h2&gt;
&lt;p&gt;反向时函数会再执行一次。因此下面这些副作用可能发生两遍：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;code&gt;forward&lt;/code&gt; 内追加 Python 列表或递增全局计数器；&lt;/li&gt;
&lt;li&gt;更新不受正确控制的 cache；&lt;/li&gt;
&lt;li&gt;读取前向后已经改变的配置开关；&lt;/li&gt;
&lt;li&gt;让数据相关控制流在两次执行走不同分支。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;BatchNorm 的 running statistics、带状态的稀疏路由器和自定义随机 kernel 都应做针对性检查。最安全的 checkpointed segment 是输入相同就产生相同计算图的纯函数式区域。&lt;/p&gt;
&lt;h2&gt;08 AMP、编译和分布式训练怎样组合？&lt;/h2&gt;
&lt;p&gt;Checkpointing 与 AMP 解决不同维度：前者减少保存的激活，后者改变部分算子的 dtype。重算必须处于与原前向相容的 autocast 上下文；不要让重算偷偷用另一种精度。框架封装模型时，应做固定 batch 的 FP32、AMP、AMP+checkpoint 三路对照。&lt;/p&gt;
&lt;p&gt;DDP（Distributed Data Parallel，分布式数据并行）下，每个 rank 都在本地重算；通信量通常不因此减少。&lt;code&gt;torch.compile&lt;/code&gt;、FSDP 与 selective checkpointing 会改变图捕获或保存策略，组合后要重新 profile，不能把各自节省比例直接相乘。&lt;/p&gt;
&lt;h2&gt;09 如何测到真正的峰值显存与代价？&lt;/h2&gt;
&lt;p&gt;CUDA 是异步的，单看某一行之后的 &lt;code&gt;memory_allocated()&lt;/code&gt; 容易误判。至少预热若干步，再同步并记录完整训练 step。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)

start.record()
loss = train_step(batch)
end.record()
torch.cuda.synchronize()

peak = torch.cuda.max_memory_allocated() / 2**30
step_ms = start.elapsed_time(end)
print({&quot;loss&quot;: float(loss), &quot;peak_GiB&quot;: peak, &quot;step_ms&quot;: step_ms})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比较时固定模型、序列长度、micro-batch、dtype、编译设置和随机种子。报告有效 tokens/s、峰值 allocated/reserved 显存、重算算子时间与最终验证指标；只说“省了 40%”没有可迁移意义。&lt;/p&gt;
&lt;h2&gt;10 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                             | 常见原因                               | 最短检查                                 |
| -------------------------------- | -------------------------------------- | ---------------------------------------- |
| 显存几乎没降                     | 峰值来自参数/optimizer 或边界太碎      | 拆分 memory snapshot，统计 saved tensors |
| loss 相同但梯度不同              | Dropout RNG 未保持、全局状态改变       | 关随机层后复现，再检查 RNG 与副作用      |
| 反向突然很慢                     | 段过大或重复重算昂贵算子               | profiler 标记 recompute 区域             |
| checkpoint 后报 shape/dtype 不同 | 两次执行走了不同分支                   | 开 &lt;code&gt;debug=True&lt;/code&gt;，固定输入和配置          |
| 自定义 backward 失效             | 重入实现限制或图契约不兼容             | 显式改 &lt;code&gt;use_reentrant=False&lt;/code&gt; 做最小例    |
| 仍然 OOM                         | attention 临时量或通信 buffer 才是峰值 | 逐阶段测峰值，而非只看 forward 末尾      |&lt;/p&gt;
&lt;p&gt;非重入实现的默认 determinism check 会比较重算张量的 shape、dtype 与 device，但它不是数值相等证明。关键实验仍应比较参数梯度的有限性、方向与短程收敛。&lt;/p&gt;
&lt;h2&gt;11 它和哪些“检查点”不是一回事？&lt;/h2&gt;
&lt;p&gt;| 方法                     | 保存位置       | 解决的问题      | 主要代价        |
| ------------------------ | -------------- | --------------- | --------------- |
| Activation checkpointing | 运行时边界张量 | 单步激活显存    | 额外重算        |
| Training checkpoint      | 磁盘/对象存储  | 故障恢复、续训  | I/O 与存储      |
| CPU/NVMe offload         | 主存/磁盘      | GPU 常驻显存    | 传输时延        |
| Gradient accumulation    | 参数 &lt;code&gt;.grad&lt;/code&gt;   | 有效 batch 大小 | 更多 micro-step |&lt;/p&gt;
&lt;p&gt;Activation checkpointing 不会替你保存 optimizer、scaler、token 时钟或数据游标；机器中断后仍需要上一篇所示的训练 checkpoint。&lt;/p&gt;
&lt;h2&gt;12 失败边界&lt;/h2&gt;
&lt;p&gt;当模型是计算密集型且显存只差一点时，重算通常值得；当训练已受算力限制、模块极小、数据管线空转，额外 forward 可能让吞吐下降得更多。包含不可重放 I/O、跨设备随机状态或不可重复副作用的区域不适合直接 checkpoint。&lt;/p&gt;
&lt;p&gt;它也不会降低推理 KV Cache，因为推理没有同样的反向图；推理显存应从 cache 长度、量化、分页管理或并行策略入手。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;反向需要前向激活；checkpoint 只保留边界，反向到达时重算段内中间量。&lt;/li&gt;
&lt;li&gt;省下的是激活显存，代价是额外计算；切分边界必须用 profiler 验证。&lt;/li&gt;
&lt;li&gt;PyTorch 2.14 应显式使用 &lt;code&gt;use_reentrant=False&lt;/code&gt;；随机层默认保留 RNG 状态。&lt;/li&gt;
&lt;li&gt;前向与重算必须等价，副作用、设备迁移和动态分支是高风险区。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 12 个等成本 block，分别画出每层一段、每 3 层一段时保存的边界和反向重算顺序；估算每种方案的额外 forward 次数。&lt;/li&gt;
&lt;li&gt;给含 Dropout 的两层 MLP 写一个梯度对照测试：普通、&lt;code&gt;preserve_rng_state=True&lt;/code&gt;、&lt;code&gt;False&lt;/code&gt; 三种配置比较同一参数的梯度最大绝对误差。&lt;/li&gt;
&lt;li&gt;写一个 benchmark 同时记录峰值显存、step time 与 tokens/s，并解释为什么必须预热和 &lt;code&gt;torch.cuda.synchronize()&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Chen et al., &lt;a href=&quot;https://arxiv.org/abs/1604.06174&quot;&gt;Training Deep Nets with Sublinear Memory Cost&lt;/a&gt;，系统展示用重算把深网训练的激活内存降到次线性规模。&lt;/li&gt;
&lt;li&gt;Griewank &amp;#x26; Walther, &lt;a href=&quot;https://doi.org/10.1145/347837.347846&quot;&gt;Algorithm 799: Revolve&lt;/a&gt;，研究受限 checkpoint 数量下的最优反向重算调度。&lt;/li&gt;
&lt;li&gt;Jain et al., &lt;a href=&quot;https://arxiv.org/abs/1910.02653&quot;&gt;Checkmate: Breaking the Memory Wall with Optimal Tensor Rematerialization&lt;/a&gt;，把计算图上的重算边界选择建模为优化问题。&lt;/li&gt;
&lt;li&gt;Korthikanti et al., &lt;a href=&quot;https://arxiv.org/abs/2205.05198&quot;&gt;Reducing Activation Recomputation in Large Transformer Models&lt;/a&gt;，给出 Transformer 激活内存组成与选择性策略的工程分析。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;显存允许更长的计算图后，有效 batch 仍可能无法一次塞进设备。下一篇将把一个大 batch 拆成多个 micro-batch，解释梯度累积何时与一次性训练等价，以及 DDP 中如何只在最后一次反向同步。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>半精度为何会让梯度变成 0 或 NaN？Autocast、FP16/BF16 与 GradScaler</title><link>https://zwjcode.cn/blog/automatic-mixed-precision-fp16-bf16-gradscaler</link><guid isPermaLink="true">https://zwjcode.cn/blog/automatic-mixed-precision-fp16-bf16-gradscaler</guid><description>从浮点数指数与尾数预算出发，手算梯度下溢和 loss scaling，拆解 PyTorch 2.14 autocast 与 GradScaler 数据流，并给出裁剪、累积、调试和失败边界。</description><pubDate>Mon, 14 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把学习率绑定到成功消费的 token；现在每次更新何时发生已经清楚。新的瓶颈是数值格式：Transformer 的大矩阵乘法用 FP32 往往没有充分利用低精度硬件，而粗暴地对模型调用 &lt;code&gt;.half()&lt;/code&gt; 又可能让小梯度归零、大激活溢出。&lt;/p&gt;
&lt;p&gt;本篇聚焦一个核心问题：&lt;strong&gt;Automatic Mixed Precision（自动混合精度，AMP）怎样选择运算精度，以及 FP16 训练为什么需要 Gradient Scaling（梯度缩放）。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 “少一半位宽”究竟少了什么？&lt;/h2&gt;
&lt;p&gt;浮点数可抽象为&lt;/p&gt;
&lt;p&gt;$$
x=(-1)^s\times m\times 2^e
$$&lt;/p&gt;
&lt;p&gt;$s$ 是符号，$e$ 的位数决定动态范围，$m$ 的位数决定相邻可表示数的精细程度。&lt;/p&gt;
&lt;p&gt;| 格式 | 总位数 | 指数位 | 尾数位 | 核心取舍               |
| ---- | ------ | ------ | ------ | ---------------------- |
| FP32 | 32     | 8      | 23     | 范围和精度都较好       |
| FP16 | 16     | 5      | 10     | 精度较细，但范围窄     |
| BF16 | 16     | 8      | 7      | 接近 FP32 范围，精度粗 |&lt;/p&gt;
&lt;p&gt;FP16 最大有限值约为 65504；BF16 保留 8 位指数，因此更不容易因范围不足而 overflow（上溢），但它不是“更准确”，因为尾数更短。&lt;/p&gt;
&lt;h2&gt;02 为什么整个模型 &lt;code&gt;.half()&lt;/code&gt; 很危险？&lt;/h2&gt;
&lt;p&gt;神经网络不同运算需要不同数值性质：矩阵乘法通常能从低精度 Tensor Core 获益；softmax、归一化、指数和大规模 reduction（归约）更需要 FP32 的范围或累加精度。若所有参数、输入和运算一刀切成 FP16，模型失去高精度主权重与稳定运算的保护。&lt;/p&gt;
&lt;p&gt;Autocast（自动类型转换）按算子策略选 dtype，而不是把整张图永久转换：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[FP32 参数与输入] --&gt; B{autocast 算子策略}
  B --&gt;|matmul/linear/conv| C[FP16 或 BF16]
  B --&gt;|loss/reduction 等| D[FP32]
  C --&gt; E[FP32 loss]
  D --&gt; E
  E --&gt; F[scaled backward]
  F --&gt; G[unscale gradients]
  G --&gt; H{梯度有限?}
  H --&gt;|是| I[clip + optimizer.step]
  H --&gt;|否| J[跳过更新并减小 scale]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 小梯度如何在 FP16 中消失？&lt;/h2&gt;
&lt;p&gt;考虑参数 $w$ 的真实梯度 $g=2^{-30}$。若反向路径要把它存为 FP16，这个值可能低于可表示范围并舍入为 0。于是&lt;/p&gt;
&lt;p&gt;$$
w\leftarrow w-\eta\cdot0
$$&lt;/p&gt;
&lt;p&gt;该参数看似“没有梯度”。把 loss 乘尺度 $S=2^{16}$ 后，链式法则让梯度变成&lt;/p&gt;
&lt;p&gt;$$
g&apos;=Sg=2^{16}\cdot2^{-30}=2^{-14}
$$&lt;/p&gt;
&lt;p&gt;它更容易被 FP16 表示。优化前再除以 $S$，恢复 $g=g&apos;/S$。缩放不会改变理想数学更新，只是把反向中间量暂时搬进可表示区间。&lt;/p&gt;
&lt;h2&gt;04 为什么尺度不能无限大？&lt;/h2&gt;
&lt;p&gt;若另一处梯度为 $g=2$，同样乘 $2^{16}$ 得 131072，超过 FP16 最大有限值，成为 &lt;code&gt;inf&lt;/code&gt;。因此动态 scaler 在连续若干次梯度有限时增大 $S$，发现 &lt;code&gt;inf/NaN&lt;/code&gt; 时跳过本次更新并减小 $S$。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;scale=65536 -&gt; overflow -&gt; skip step -&gt; scale=32768
scale=32768 -&gt; finite   -&gt; update
...连续稳定若干步...
scale=65536
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 当前 PyTorch 的最小正确循环&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 推荐统一的 &lt;code&gt;torch.autocast&lt;/code&gt; 与 &lt;code&gt;torch.amp.GradScaler&lt;/code&gt;；旧的 &lt;code&gt;torch.cuda.amp.*&lt;/code&gt; 入口已弃用。Autocast 只包住 forward 和 loss，backward 放在上下文外。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F

model = MyModel().cuda()  # 参数保持 FP32
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
scaler = torch.amp.GradScaler(&quot;cuda&quot;)

for input_ids, labels in loader:
    input_ids = input_ids.cuda(non_blocking=True)  # [B,L]
    labels = labels.cuda(non_blocking=True)        # [B,L]
    optimizer.zero_grad(set_to_none=True)

    with torch.autocast(device_type=&quot;cuda&quot;, dtype=torch.float16):
        logits = model(input_ids)                  # [B,L,V]
        loss = F.cross_entropy(
            logits.transpose(1, 2), labels,
            ignore_index=-100,
        )                                          # scalar, normally FP32

    scaler.scale(loss).backward()
    scaler.step(optimizer)  # 先检查非有限梯度；必要时跳过
    scaler.update()         # 调整下一步 scale
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入 token 仍是 &lt;code&gt;int64&lt;/code&gt;；autocast 只影响符合条件的浮点运算，不会把类别索引变成浮点数。不要在进入 autocast 前对模型或输入手工调用 &lt;code&gt;.half()&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;06 &lt;code&gt;step&lt;/code&gt;、&lt;code&gt;update&lt;/code&gt; 与学习率日程怎样配合？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;scaler.step(optimizer)&lt;/code&gt; 会先 unscale 并在梯度非有限时跳过 &lt;code&gt;optimizer.step()&lt;/code&gt;；&lt;code&gt;scaler.update()&lt;/code&gt; 根据本轮结果调整 scale。若学习率日程按成功更新计时，就必须判断参数是否真的更新。&lt;/p&gt;
&lt;p&gt;一个可检查的方法是比较 update 前后的 scale：发生 overflow 时新 scale 通常下降，且 optimizer step 被跳过。训练框架最好显式返回 &lt;code&gt;update_succeeded&lt;/code&gt;，再决定是否推进 token 时钟与 EMA；不要无条件调用 scheduler。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;old_scale = scaler.get_scale()
scaler.step(optimizer)
scaler.update()
new_scale = scaler.get_scale()
update_succeeded = new_scale &gt;= old_scale
if update_succeeded:
    token_schedule.step(global_valid_tokens)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这依赖动态缩放的默认回退行为；封装层若改变 growth/backoff 策略，应使用其明确的 skipped-step 信号。&lt;/p&gt;
&lt;h2&gt;07 梯度裁剪为什么必须先 unscale？&lt;/h2&gt;
&lt;p&gt;若真实梯度范数是 2，而 scale 是 65536，直接裁剪看到的是 131072，会错误地把本来正常的梯度压小。顺序应是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;scaler.scale(loss).backward()
scaler.unscale_(optimizer)
grad_norm = torch.nn.utils.clip_grad_norm_(
    model.parameters(), max_norm=1.0,
)
scaler.step(optimizer)
scaler.update()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;unscale_&lt;/code&gt; 每个 optimizer 每步只能调用一次。多个 optimizer 时分别 unscale、检查和 step，并明确某一方 overflow 时是否允许另一方单独更新。排查问题时可临时给 &lt;code&gt;clip_grad_norm_&lt;/code&gt; 设置 &lt;code&gt;error_if_nonfinite=True&lt;/code&gt;，让首个坏窗口立刻失败；常规动态缩放则应把跳步交给 scaler。&lt;/p&gt;
&lt;h2&gt;08 与梯度累积组合时，scale 何时更新？&lt;/h2&gt;
&lt;p&gt;同一个有效 batch 的所有 micro-batch 必须使用同一 scale；只在完整 accumulation window 结束时 unscale、step 和 update。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;optimizer.zero_grad(set_to_none=True)
for micro in micro_batches:
    with torch.autocast(&quot;cuda&quot;, dtype=torch.float16):
        loss = loss_fn(model(micro.x), micro.y) / len(micro_batches)
    scaler.scale(loss).backward()

scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若每个 micro-batch 都 &lt;code&gt;update()&lt;/code&gt;，同一组累积梯度混入不同尺度，最后无法用一次除法恢复。上一篇讨论的按有效 token 精确归一化仍然适用：可以累积 &lt;code&gt;loss_sum&lt;/code&gt;，在 unscale 后再按全局 token 分母缩放 &lt;code&gt;.grad&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;09 FP16 与 BF16 应该怎样选？&lt;/h2&gt;
&lt;p&gt;BF16 的指数范围与 FP32 相近，通常不需要 GradScaler；代价是有效数字更少，而且硬件必须高效支持。FP16 尾数比 BF16 多，范围却窄，通常要动态缩放。选择流程应是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;确认目标 GPU/加速器对哪种低精度有原生高吞吐；&lt;/li&gt;
&lt;li&gt;跑 FP32 小基线，保存 loss 与梯度范数；&lt;/li&gt;
&lt;li&gt;优先测试 BF16 autocast（硬件支持时）；&lt;/li&gt;
&lt;li&gt;使用 FP16 时启用 GradScaler；&lt;/li&gt;
&lt;li&gt;比较吞吐、峰值显存、验证指标与非有限更新率。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;“没有 NaN”不等于数值等价。要对固定 batch 比较 logits、loss、梯度方向和短程收敛，而不是要求逐位相同。&lt;/p&gt;
&lt;h2&gt;10 哪些运算需要特别留意？&lt;/h2&gt;
&lt;p&gt;PyTorch autocast 有按设备维护的 op eligibility（算子资格）列表：某些算子转低精度，某些强制 FP32，另一些提升到最宽输入类型。自定义 CUDA op 或 &lt;code&gt;autograd.Function&lt;/code&gt; 不会自动获得正确策略。&lt;/p&gt;
&lt;p&gt;若某段在低精度不稳定，可嵌套禁用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with torch.autocast(&quot;cuda&quot;, dtype=torch.float16):
    hidden = encoder(x)                       # [B,L,D], maybe FP16
    with torch.autocast(&quot;cuda&quot;, enabled=False):
        stable = fragile_reduction(hidden.float())  # force FP32
    logits = head(stable)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Softmax 前手工减最大值、使用 &lt;code&gt;cross_entropy&lt;/code&gt; 而非先 softmax 再 log、归一化统计量用稳定实现，仍然重要。AMP 不能修复数学上不稳定的自定义公式。&lt;/p&gt;
&lt;h2&gt;11 性能和显存为何不一定正好翻倍？&lt;/h2&gt;
&lt;p&gt;低精度减小部分激活与临时张量，并加速合适尺寸的矩阵乘法；但 FP32 主参数、optimizer states、部分 FP32 运算和非浮点张量仍然存在。小模型可能受 Python、DataLoader 或 kernel launch 限制，AMP 转换开销反而盖过收益。&lt;/p&gt;
&lt;p&gt;| 指标                         | 说明                 |
| ---------------------------- | -------------------- |
| 有效 tokens/s                | 端到端学习吞吐       |
| 峰值 allocated/reserved 显存 | 区分真实张量与缓存池 |
| scaler scale                 | 是否持续回退         |
| skipped updates              | 数值失败频率         |
| FP32 对照 loss               | 精度漂移基线         |
| 验证指标                     | 最终目标是否受损     |&lt;/p&gt;
&lt;p&gt;预热若干步后再计时，并同步设备；否则异步 CUDA 会让 wall-clock 结果失真。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                      | 常见原因                      | 最短检查                         |
| ------------------------- | ----------------------------- | -------------------------------- |
| 梯度裁剪后几乎为 0        | 对 scaled gradient 直接裁剪   | clip 前调用 &lt;code&gt;unscale_&lt;/code&gt;           |
| 学习率日程偶尔抢跑        | overflow 跳步仍推进 scheduler | 同时记录 scale、lr、参数校验和   |
| BF16 模型转 FP16 后常溢出 | FP16 动态范围不足             | 改 BF16/FP32，检查激活最大值     |
| loss 正常但参数不再变化   | 小梯度下溢或连续 skip         | 统计零梯度比例与 skipped updates |
| 自定义 op 输出 NaN        | autocast 不知道其稳定 dtype   | 局部禁用并强制 FP32              |
| AMP 没有提速              | 瓶颈不在低精度矩阵乘法        | profiler + FP32/AMP 端到端对照   |
| 恢复后行为改变            | 未保存 scaler state           | 比较 &lt;code&gt;scaler.state_dict()&lt;/code&gt;       |&lt;/p&gt;
&lt;p&gt;定位 NaN 时先固定同一 batch：依次运行 FP32、BF16 autocast、FP16 autocast 无 scaler、FP16 + scaler；逐层 hook 只记录 &lt;code&gt;isfinite&lt;/code&gt;、绝对值最大值和 dtype，找到第一个异常算子，而不是等最终 loss 报错。&lt;/p&gt;
&lt;h2&gt;13 Checkpoint 还要多保存什么？&lt;/h2&gt;
&lt;p&gt;除了 model、optimizer 和上一篇的 token schedule，还要保存 scaler：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;torch.save({
    &quot;model&quot;: model.state_dict(),
    &quot;optimizer&quot;: optimizer.state_dict(),
    &quot;schedule&quot;: schedule.state_dict(),
    &quot;scaler&quot;: scaler.state_dict(),
    &quot;global_tokens&quot;: global_tokens,
}, path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若丢掉 scaler state，恢复后 scale 回到初始值，可能先连续 overflow，改变成功 update 的序列。保存只是第一步；恢复测试应从同一 checkpoint 分叉跑 3–5 步，比较 batch id、lr、scale、skip 标记和 loss。&lt;/p&gt;
&lt;h2&gt;14 失败场景与相近方法&lt;/h2&gt;
&lt;p&gt;AMP 是训练数值格式策略，不等于 Quantization（量化）：INT8/INT4 量化通常需要 scale/zero-point、校准或量化感知训练，目标常是推理压缩。TF32 只改变支持硬件上的 FP32 矩阵乘法内部精度，也不等同于把张量存成 FP16。&lt;/p&gt;
&lt;p&gt;模型若包含极端指数、病态线性代数、自定义低精度 kernel，AMP 仍可能失败。应允许局部 FP32 或整体回退，并优先修正异常初始化、错误 loss、未归一化输入和爆炸梯度。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Autocast 按算子选择低精度或 FP32；不要把模型和输入粗暴地全部 &lt;code&gt;.half()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;FP16 的窄范围会让小梯度下溢、大值上溢；GradScaler 通过 scale、检查、跳步和回退保护更新。&lt;/li&gt;
&lt;li&gt;裁剪前必须 unscale；梯度累积窗口内必须保持同一 scale。&lt;/li&gt;
&lt;li&gt;BF16 范围更宽但尾数更短，是否更快、更稳取决于硬件和模型，必须用 FP32 基线验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对梯度 &lt;code&gt;[2^-30, 2^-20, 2]&lt;/code&gt;，分别用 scale &lt;code&gt;2^10&lt;/code&gt; 和 &lt;code&gt;2^16&lt;/code&gt; 计算缩放值，判断哪个更可能下溢或上溢。&lt;/li&gt;
&lt;li&gt;给累积 3 个不同有效 token 数 micro-batch 的循环加入精确 token 归一化、unscale 与 gradient clipping，并标出每一步张量/标量 dtype。&lt;/li&gt;
&lt;li&gt;设计一个定位首个非有限激活的 hook；要求只保存层名、dtype、shape、最大绝对值与有限值比例，避免复制完整张量拖慢训练。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Micikevicius et al., &lt;a href=&quot;https://arxiv.org/abs/1710.03740&quot;&gt;Mixed Precision Training&lt;/a&gt;，系统化提出 FP16 主干、FP32 主权重与 loss scaling。&lt;/li&gt;
&lt;li&gt;Kalamkar et al., &lt;a href=&quot;https://arxiv.org/abs/1905.12322&quot;&gt;A Study of BFLOAT16 for Deep Learning Training&lt;/a&gt;，分析 BF16 的范围、精度与训练表现。&lt;/li&gt;
&lt;li&gt;Micikevicius et al., &lt;a href=&quot;https://arxiv.org/abs/2209.05433&quot;&gt;FP8 Formats for Deep Learning&lt;/a&gt;，把混合精度设计推进到 FP8 格式与缩放策略。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;混合精度减少了计算和激活成本，但大模型仍可能放不进单卡。下一篇将研究 activation checkpointing 如何用重算换显存，以及它与训练 checkpoint 文件为何只是同名、不是同一件事。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>训练步数相同为何学习进度不同？Token 学习率时钟、Warmup 与 Cosine Decay</title><link>https://zwjcode.cn/blog/token-learning-rate-warmup-cosine-schedule</link><guid isPermaLink="true">https://zwjcode.cn/blog/token-learning-rate-warmup-cosine-schedule</guid><description>从可变长度 batch 让每步 token 数漂移出发，手算 token 进度下的 warmup 与余弦衰减，实现可恢复的 PyTorch 学习率控制器，并解释预算、调用顺序与调试方法。</description><pubDate>Sun, 13 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 Token-based Batching（按 token 预算组批）让长样本少占几行、短样本多占几行。代价是每个 optimizer step 真正看见的有效 token 不再恒定：两个实验都跑了 10,000 步，可能已经消费了完全不同的数据量。&lt;/p&gt;
&lt;p&gt;本篇只解决一个核心问题：&lt;strong&gt;如何把学习率写成“已学习多少 token”的函数，并正确安排 Warmup（预热）与 Cosine Decay（余弦衰减）。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 为什么 epoch 和 step 都可能是错时钟？&lt;/h2&gt;
&lt;p&gt;Epoch（轮次）假设数据集边界稳定；流式语料、按权重重复采样和持续去重会让“一轮”含义模糊。Step（优化步）比 epoch 明确，但在可变 batch 下，第 $s$ 步的有效 token 数 $n_s$ 会变化。&lt;/p&gt;
&lt;p&gt;真正的数据进度是累计监督 token：&lt;/p&gt;
&lt;p&gt;$$
q_s=\sum_{i=1}^{s} n_i
$$&lt;/p&gt;
&lt;p&gt;其中 $q_s$ 是完成第 $s$ 次参数更新后累计消费的有效 token。若训练预算为 $Q$，日程进度就是 $p_s=\min(q_s/Q,1)$。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[micro-batches] --&gt; B[统计本窗口有效 token]
  B --&gt; C[反向传播与梯度归一化]
  C --&gt; D[optimizer.step]
  D --&gt; E[累计 global_tokens]
  E --&gt; F[计算下一个学习率]
  F --&gt; G[写入 param_groups]
  G --&gt; A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意数据流顺序：当前窗口用当前学习率更新；更新成功后推进 token 时钟，再为下一窗口设置学习率。&lt;/p&gt;
&lt;h2&gt;02 Warmup 在保护什么？&lt;/h2&gt;
&lt;p&gt;训练刚开始时，参数、激活尺度和 Adam 的一二阶矩估计都还没有稳定。直接使用峰值学习率 $\eta_{\max}$，一次噪声较大的更新就可能破坏表示。线性 warmup 在前 $W$ 个 token 将学习率从较小值升到峰值：&lt;/p&gt;
&lt;p&gt;$$
\eta(q)=\eta_{\max}\frac{q}{W},\qquad 0\le q&amp;#x3C;W
$$&lt;/p&gt;
&lt;p&gt;Warmup 不是“先不学习”，而是逐渐放大更新。它也不是修复错误归一化、异常梯度或过大峰值学习率的万能补丁。&lt;/p&gt;
&lt;h2&gt;03 Cosine Decay 怎样把更新慢慢收紧？&lt;/h2&gt;
&lt;p&gt;Warmup 后，令&lt;/p&gt;
&lt;p&gt;$$
r=\operatorname{clip}\left(\frac{q-W}{Q-W},0,1\right)
$$&lt;/p&gt;
&lt;p&gt;从峰值平滑衰减到最低比例 $\alpha\eta_{\max}$：&lt;/p&gt;
&lt;p&gt;$$
\eta(q)=\eta_{\max}\left[\alpha+(1-\alpha)\frac{1+\cos(\pi r)}{2}\right]
$$&lt;/p&gt;
&lt;p&gt;$Q$ 是总 token 预算，$W$ 是 warmup token，$\alpha\in[0,1]$。余弦的端点斜率为 0，切换平滑；但它并不自动知道最优训练长度，$Q$ 仍是实验设计。&lt;/p&gt;
&lt;h2&gt;04 用 100 个 token 手算完整日程&lt;/h2&gt;
&lt;p&gt;设 $Q=100$、$W=20$、$\eta_{\max}=10^{-3}$、$\alpha=0.1$。&lt;/p&gt;
&lt;p&gt;| 累计 token $q$ | 阶段   | 比例 $\eta/\eta_{\max}$ | 学习率   |
| -------------- | ------ | ----------------------- | -------- |
| 0              | warmup | 0                       | 0        |
| 10             | warmup | 0.5                     | $5.0e-4$ |
| 20             | 峰值   | 1                       | $1.0e-3$ |
| 60             | decay  | $0.1+0.9\times0.5=0.55$ | $5.5e-4$ |
| 100            | 末端   | 0.1                     | $1.0e-4$ |&lt;/p&gt;
&lt;p&gt;若每步 token 是 &lt;code&gt;[8, 12, 30, 10]&lt;/code&gt;，更新后的时钟依次为 &lt;code&gt;[8,20,50,60]&lt;/code&gt;，而不是 &lt;code&gt;[1,2,3,4]&lt;/code&gt;。第三步跨过多个“虚拟刻度”没有问题：日程是连续函数，不要求每个 token 都调用一次 scheduler。&lt;/p&gt;
&lt;h2&gt;05 Step 时钟什么时候仍然够用？&lt;/h2&gt;
&lt;p&gt;若世界大小、梯度累积次数和每个 micro-batch 的有效 token 都固定，则 $q_s=s\cdot n$，按 step 与按 token 只是横轴单位不同。只要以下任一项改变，固定 step 日程就会漂移：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;长度分桶导致有效 token 波动；&lt;/li&gt;
&lt;li&gt;OOM 后减小 micro-batch、增加累积次数；&lt;/li&gt;
&lt;li&gt;扩容改变 data-parallel world size；&lt;/li&gt;
&lt;li&gt;部分窗口因 FP16 overflow 被跳过；&lt;/li&gt;
&lt;li&gt;loss mask 改变有效监督位置数。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;06 一个与框架无关的日程函数&lt;/h2&gt;
&lt;p&gt;先把数学写成纯函数，边界条件才容易单测。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math

def warmup_cosine_multiplier(tokens, warmup_tokens, total_tokens, min_ratio=0.1):
    if not 0 &amp;#x3C;= warmup_tokens &amp;#x3C; total_tokens:
        raise ValueError(&quot;need 0 &amp;#x3C;= warmup_tokens &amp;#x3C; total_tokens&quot;)
    if not 0 &amp;#x3C;= min_ratio &amp;#x3C;= 1:
        raise ValueError(&quot;min_ratio must be in [0, 1]&quot;)
    q = min(max(int(tokens), 0), total_tokens)
    if warmup_tokens and q &amp;#x3C; warmup_tokens:
        return q / warmup_tokens
    progress = (q - warmup_tokens) / (total_tokens - warmup_tokens)
    cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
    return min_ratio + (1.0 - min_ratio) * cosine
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入和输出都是标量：&lt;code&gt;tokens: int -&gt; multiplier: float&lt;/code&gt;。至少断言 $m(0)=0$、$m(W)=1$、$m(Q)=\alpha$，并密集采样验证 warmup 单调递增、decay 单调递减且没有负数。&lt;/p&gt;
&lt;h2&gt;07 用当前 PyTorch API 落地&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;LambdaLR&lt;/code&gt; 接受整数计数并返回相对初始学习率的乘数；官方要求 &lt;code&gt;scheduler.step()&lt;/code&gt; 在 &lt;code&gt;optimizer.step()&lt;/code&gt; 之后调用。但它的参数名仍叫 &lt;code&gt;epoch&lt;/code&gt;，不会替你统计 token。因此可直接让训练循环维护 token 时钟，并把纯函数结果写入参数组，语义更清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TokenLRSchedule:
    def __init__(self, optimizer, warmup_tokens, total_tokens, min_ratio=0.1):
        self.optimizer = optimizer
        self.base_lrs = [g[&quot;lr&quot;] for g in optimizer.param_groups]
        self.warmup_tokens = warmup_tokens
        self.total_tokens = total_tokens
        self.min_ratio = min_ratio
        self.tokens = 0
        self._apply()

    def _apply(self):
        scale = warmup_cosine_multiplier(
            self.tokens, self.warmup_tokens,
            self.total_tokens, self.min_ratio,
        )
        for group, base_lr in zip(self.optimizer.param_groups, self.base_lrs):
            group[&quot;lr&quot;] = base_lr * scale

    def step(self, successful_tokens):
        if successful_tokens &amp;#x3C;= 0:
            raise ValueError(&quot;successful_tokens must be positive&quot;)
        self.tokens += int(successful_tokens)
        self._apply()

    def state_dict(self):
        return {&quot;tokens&quot;: self.tokens, &quot;base_lrs&quot;: self.base_lrs}

    def load_state_dict(self, state):
        self.tokens = int(state[&quot;tokens&quot;])
        self.base_lrs = list(state[&quot;base_lrs&quot;])
        self._apply()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多参数组时，例如 backbone 与 head 的初始学习率分别为 &lt;code&gt;1e-4&lt;/code&gt;、&lt;code&gt;1e-3&lt;/code&gt;，两者乘同一曲线并保留 10 倍比例。&lt;/p&gt;
&lt;h2&gt;08 训练循环中究竟统计哪一刻？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;optimizer.zero_grad(set_to_none=True)
window_tokens = 0

for micro in accumulation_window:
    logits = model(micro[&quot;input_ids&quot;])          # [B,L,V]
    loss_sum = token_loss_sum(logits, micro[&quot;labels&quot;])
    valid = micro[&quot;labels&quot;].ne(-100).sum()      # scalar int64
    loss_sum.backward()
    window_tokens += int(valid)

normalize_gradients(model.parameters(), window_tokens)
optimizer.step()
schedule.step(successful_tokens=window_tokens)
optimizer.zero_grad(set_to_none=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分布式训练中，&lt;code&gt;window_tokens&lt;/code&gt; 应先做全局 sum；每个 rank 必须得到相同的 &lt;code&gt;global_tokens&lt;/code&gt; 和学习率。若 AMP 的 scaler 检测到非有限梯度并跳过 &lt;code&gt;optimizer.step()&lt;/code&gt;，本窗口不应推进“成功优化”的日程时钟，否则曲线走了、参数却没走。&lt;/p&gt;
&lt;h2&gt;09 Token 日程与梯度累积不要混为一谈&lt;/h2&gt;
&lt;p&gt;梯度累积决定多少 micro-batch 合成一次参数更新；token 日程决定该更新使用多大学习率。即使两个窗口都含 32K token，一个由 8 个 micro-batch 累积、另一个由 4 个组成，只要梯度按同一全局 token 分母归一化，它们的时钟增量相同。&lt;/p&gt;
&lt;p&gt;反过来，若固定每 4 个 micro-batch 更新，但各批有效 token 不同，不能假装每步都是 32K。应记录三项：micro-step、optimizer-step、global-effective-tokens。&lt;/p&gt;
&lt;h2&gt;10 预算改变时能否中途重画曲线？&lt;/h2&gt;
&lt;p&gt;把 $Q$ 从 100B 临时改成 200B 会改变当前点之后的余弦位置，甚至让学习率瞬间升高。可选策略有：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;训练前固定预算，最易比较与复现；&lt;/li&gt;
&lt;li&gt;延长时从当前学习率重新定义一段连续曲线，并记录新阶段；&lt;/li&gt;
&lt;li&gt;使用与固定终点无关的逆平方根等日程，但仍需验证最终质量。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不要静默修改 &lt;code&gt;total_tokens&lt;/code&gt;。配置、日志和 checkpoint 必须保留每个阶段的边界，否则同名实验无法解释。&lt;/p&gt;
&lt;h2&gt;11 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                       | 常见原因                            | 最短检查                         |
| -------------------------- | ----------------------------------- | -------------------------------- |
| 第一次更新学习率为峰值     | 初始化后未应用 $q=0$ 的倍率         | 记录首三次 update 前后的 lr      |
| 日程比预期快 world_size 倍 | 每个 rank 各自累加全局 token        | all-reduce 后只用统一总数推进    |
| 恢复后学习率突然跳变       | 只恢复 optimizer，没恢复 token 时钟 | 比较 checkpoint 内 &lt;code&gt;tokens&lt;/code&gt;      |
| AMP overflow 时曲线偷跑    | 更新被跳过仍推进 schedule           | 同时记录 step 是否成功和 scale   |
| 换 batch 策略后结果漂移    | 仍按 optimizer-step 调度            | 画 lr 对 global token，而非 step |
| 末端学习率变成负数         | 没 clamp 超预算进度                 | 测试 $q&gt;Q$                       |&lt;/p&gt;
&lt;p&gt;一次有效的 dry run 不需要模型：喂入人工 token 序列，输出 &lt;code&gt;(step, delta_tokens, total_tokens, lr)&lt;/code&gt;，再与公式表逐项比对。&lt;/p&gt;
&lt;h2&gt;12 它会在哪里失败？&lt;/h2&gt;
&lt;p&gt;Token 不是所有任务的自然样本单位。图像分类可能按样本数，强化学习可能按环境步，生成式训练还可能区分输入 token 与产生 loss 的目标 token。关键不是迷信 token，而是选择与统计目标一致、可跨配置比较的进度单位。&lt;/p&gt;
&lt;p&gt;Warmup + cosine 也不是唯一日程。Inverse-square-root 在 Transformer 中常见；constant-with-warmup 适合还不知道终点的持续训练；ReduceLROnPlateau 依赖验证指标，但大规模预训练的验证噪声与成本可能让反馈滞后。日程选择不能替代峰值学习率搜索。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;可变 batch 下，optimizer step 不等于固定学习量；累计有效 token 是更稳定的训练时钟。&lt;/li&gt;
&lt;li&gt;Warmup 逐渐放大早期更新，cosine decay 在既定预算内平滑收紧更新。&lt;/li&gt;
&lt;li&gt;只有成功的参数更新才推进日程；分布式所有 rank 必须共享同一个 token 计数。&lt;/li&gt;
&lt;li&gt;日程状态与 optimizer 状态同样属于 checkpoint，预算变化必须显式版本化。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;设每步有效 token 为 &lt;code&gt;[8,12,30,10]&lt;/code&gt;，用本文参数计算每次更新后“下一步”的学习率，并与固定每步 15 token 的近似比较。&lt;/li&gt;
&lt;li&gt;给纯函数增加 5% 的非零起始倍率，写出所有端点和单调性测试。&lt;/li&gt;
&lt;li&gt;设计 DDP 日志字段，使你能发现某个 rank 少消费了一批但训练没有立刻死锁的问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Vaswani et al., &lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Attention Is All You Need&lt;/a&gt;，用 warmup 与逆平方根衰减训练原始 Transformer。&lt;/li&gt;
&lt;li&gt;Devlin et al., &lt;a href=&quot;https://arxiv.org/abs/1810.04805&quot;&gt;BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding&lt;/a&gt;，展示大规模预训练中的 warmup 与线性衰减配置。&lt;/li&gt;
&lt;li&gt;Loshchilov &amp;#x26; Hutter, &lt;a href=&quot;https://arxiv.org/abs/1608.03983&quot;&gt;SGDR: Stochastic Gradient Descent with Warm Restarts&lt;/a&gt;，系统提出余弦退火与重启。&lt;/li&gt;
&lt;li&gt;Kaplan et al., &lt;a href=&quot;https://arxiv.org/abs/2001.08361&quot;&gt;Scaling Laws for Neural Language Models&lt;/a&gt;，讨论模型、数据与计算预算的标度关系。&lt;/li&gt;
&lt;li&gt;Hoffmann et al., &lt;a href=&quot;https://arxiv.org/abs/2203.15556&quot;&gt;Training Compute-Optimal Large Language Models&lt;/a&gt;，说明 token 预算为何是预训练设计的核心变量。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;学习率曲线已经可解释，但矩阵乘法全用 FP32 会浪费现代加速器吞吐。下一篇将研究自动混合精度、FP16/BF16 的数值范围与动态梯度缩放。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>同一批短句为何被最长句拖慢？动态 Padding、长度分桶与 Token-based Batching</title><link>https://zwjcode.cn/blog/dynamic-padding-length-bucketing-token-batching</link><guid isPermaLink="true">https://zwjcode.cn/blog/dynamic-padding-length-bucketing-token-batching</guid><description>从变长样本的二次注意力浪费出发，手算长度分桶收益，实现当前 PyTorch 动态 padding 与 token 预算批处理，并解释 loss 归一化、分布式与编译形状陷阱。</description><pubDate>Sun, 13 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把多篇短文装入同一行，并用文档边界阻止交叉污染。但监督微调、分类与线上请求常要求“一行对应一个独立样本”，不能随意拼接。此时固定补到全局最大长度仍然浪费，而每批只补到本批最长长度又可能被一个异常长样本拖慢。&lt;/p&gt;
&lt;p&gt;本篇聚焦一个核心问题：&lt;strong&gt;怎样按长度组织样本，使每个 batch 的物理 token 接近有效 token，同时保持随机性、样本权重和梯度尺度可解释。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 动态 Padding 为什么仍可能很浪费？&lt;/h2&gt;
&lt;p&gt;对一个 batch 的长度 $\ell_1,\ldots,\ell_B$，右侧动态补齐到 $T=\max_b\ell_b$。token 利用率为：&lt;/p&gt;
&lt;p&gt;$$
\eta=\frac{\sum_b\ell_b}{BT}
$$&lt;/p&gt;
&lt;p&gt;Self-Attention 的粗略工作量却是 $O(BT^2D)$，因为补出的 query/key 仍占据物理张量。mask 能阻止 PAD 影响语义，不保证内核完全跳过这些位置。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[样本与真实长度] --&gt; B[打乱]
  B --&gt; C[长度桶]
  C --&gt; D[token 预算组 batch]
  D --&gt; E[本批动态 padding]
  E --&gt; F[input_ids B×T]
  E --&gt; G[attention_mask B×T]
  E --&gt; H[labels B×T]
  F --&gt; I[模型]
  G --&gt; I
  I --&gt; J[按有效 token 归一化 loss]
  H --&gt; J
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;02 四个样本怎样手算分桶收益？&lt;/h2&gt;
&lt;p&gt;长度为 &lt;code&gt;[8, 7, 2, 1]&lt;/code&gt;，每批 2 个。随机配对 &lt;code&gt;[8,1]&lt;/code&gt;、&lt;code&gt;[7,2]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;$$
\eta_{\text{random}}=\frac{18}{2\times8+2\times7}=60%
$$&lt;/p&gt;
&lt;p&gt;按相近长度配对 &lt;code&gt;[8,7]&lt;/code&gt;、&lt;code&gt;[2,1]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;$$
\eta_{\text{bucket}}=\frac{18}{2\times8+2\times2}=90%
$$&lt;/p&gt;
&lt;p&gt;用 $BT^2$ 粗估 attention 单元，随机配对为 $2\cdot8^2+2\cdot7^2=226$，分桶为 $2\cdot8^2+2\cdot2^2=136$。有效 token 完全相同，但物理计算减少约 40%。&lt;/p&gt;
&lt;h2&gt;03 分桶不是把数据永久排序&lt;/h2&gt;
&lt;p&gt;若每个 epoch 都从短到长，模型会先连续看到短样本、后连续看到长样本；长度若与类别、语言或难度相关，训练顺序就变成隐式课程。&lt;/p&gt;
&lt;p&gt;稳妥流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;全局样本索引
  └─按 epoch seed 打乱
       └─切成较大的局部池（例如 1,000 条）
            └─池内按长度排序
                 └─组成 batch，再打乱 batch 顺序
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;局部池越大，padding 越少但全局随机性越弱、等待时间越长。应记录池大小与 seed，并按来源/标签检查每批分布，而不是只看吞吐。&lt;/p&gt;
&lt;h2&gt;04 固定样本数为何不是固定工作量？&lt;/h2&gt;
&lt;p&gt;batch size 固定为 32 时，32 条 64-token 文本与 32 条 4,096-token 文本相差 64 倍 token，attention 粗略成本相差更多。Token-based Batching（按 token 预算组批）限制：&lt;/p&gt;
&lt;p&gt;$$
B\cdot T\le M
$$&lt;/p&gt;
&lt;p&gt;$M$ 是每批物理 token 上限。长样本自动减少行数，短样本增加行数。更精细的调度器可约束 $BT^2$，但 $BT$ 更简单，也更接近激活内存预算。&lt;/p&gt;
&lt;h2&gt;05 一个可检查的 Token Batch Sampler&lt;/h2&gt;
&lt;p&gt;下面输入的是已按局部长度桶组织的 &lt;code&gt;(index, length)&lt;/code&gt;。加入新样本后，若 &lt;code&gt;新批大小 × 新最大长度&lt;/code&gt; 超过预算，就先产出旧批。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def batches_by_padded_tokens(index_lengths, max_tokens, max_examples=None):
    batch, longest = [], 0
    for index, length in index_lengths:
        if length &amp;#x3C;= 0 or length &gt; max_tokens:
            raise ValueError(f&quot;invalid length {length} for sample {index}&quot;)
        next_longest = max(longest, length)
        next_size = len(batch) + 1
        over_tokens = next_size * next_longest &gt; max_tokens
        over_examples = max_examples is not None and next_size &gt; max_examples

        if batch and (over_tokens or over_examples):
            yield batch
            batch, longest = [], 0

        batch.append(index)
        longest = max(longest, length)

    if batch:
        yield batch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入长度 &lt;code&gt;[8,7,2,1]&lt;/code&gt;、&lt;code&gt;max_tokens=16&lt;/code&gt; 时，相近长度顺序会得到 &lt;code&gt;[8,7]&lt;/code&gt; 与 &lt;code&gt;[2,1]&lt;/code&gt;。应另设 &lt;code&gt;max_examples&lt;/code&gt;，避免极短样本一次堆入数千行，耗尽 CPU 元数据或改变归一化层行为。&lt;/p&gt;
&lt;h2&gt;06 当前 PyTorch 怎样动态补齐？&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;torch.nn.utils.rnn.pad_sequence&lt;/code&gt; 接收一组形如 &lt;code&gt;[L_i,*]&lt;/code&gt; 的张量；&lt;code&gt;batch_first=True&lt;/code&gt; 输出 &lt;code&gt;[B,T,*]&lt;/code&gt;，当前 API 还显式支持 &lt;code&gt;padding_side=&apos;right&apos;|&apos;left&apos;&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F
from torch.nn.utils.rnn import pad_sequence

PAD_ID = 0
IGNORE = -100

def collate_causal_lm(examples):
    # examples: list[LongTensor[Li]]，每条包含 EOS
    input_ids = pad_sequence(
        examples,
        batch_first=True,
        padding_value=PAD_ID,
        padding_side=&quot;right&quot;,
    )  # [B,T]
    lengths = torch.tensor([len(x) for x in examples])       # [B]
    positions = torch.arange(input_ids.size(1))[None, :]     # [1,T]
    attention_mask = positions &amp;#x3C; lengths[:, None]            # [B,T]

    labels = input_ids[:, 1:].clone()                        # [B,T-1]
    labels[~attention_mask[:, 1:]] = IGNORE
    model_inputs = input_ids[:, :-1]                         # [B,T-1]
    model_mask = attention_mask[:, :-1]                      # [B,T-1]
    return model_inputs, model_mask, labels, lengths
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入 token 是整数，因此 &lt;code&gt;padding_value&lt;/code&gt; 虽接受浮点参数，也要传合法的词表 id。&lt;code&gt;attention_mask&lt;/code&gt; 的 True/False 语义最终要按模型接口转换；不能假设所有库的布尔 attention mask 都同义。&lt;/p&gt;
&lt;h2&gt;07 右 Padding 与左 Padding 何时使用？&lt;/h2&gt;
&lt;p&gt;训练因果 LM 时常用右 padding：每行真实 token 都从位置 0 开始，标签右移直观。批量自回归生成常用左 padding，使所有行的最后一个真实 token 对齐到同一列，便于取 &lt;code&gt;logits[:, -1]&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;right: [A B C EOS PAD]    [D E EOS PAD PAD]
left:  [A B C EOS]        [PAD D E EOS]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;左 padding 时不能直接把物理列号当位置 id。一个常见构造是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;position_ids = attention_mask.long().cumsum(dim=-1) - 1
position_ids.masked_fill_(~attention_mask, 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样两行的第一个真实 token 都是位置 0。若模型用 RoPE 与 KV Cache，prefill 的 position id、cache 长度和后续 decode 位置必须遵守同一契约。&lt;/p&gt;
&lt;h2&gt;08 Loss 到底按 token 还是按样本平均？&lt;/h2&gt;
&lt;p&gt;按 token 平均：&lt;/p&gt;
&lt;p&gt;$$
\mathcal L_{\text{token}}=\frac{\sum_{b,t}m_{bt}\ell_{bt}}{\sum_{b,t}m_{bt}}
$$&lt;/p&gt;
&lt;p&gt;长样本贡献更多目标，适合把语料视为 token 流。按样本平均则先计算每行平均，再平均 $B$ 行，使短样本与长样本权重相同。两者都合理，但目标不同。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;token_loss = F.cross_entropy(
    logits.transpose(1, 2), labels,
    ignore_index=IGNORE, reduction=&quot;none&quot;,
)  # [B,T-1]
valid = labels.ne(IGNORE)

token_mean = token_loss.sum() / valid.sum().clamp_min(1)
per_example = token_loss.sum(1) / valid.sum(1).clamp_min(1)
example_mean = per_example.mean()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前 PyTorch 2.14 的 &lt;code&gt;cross_entropy(ignore_index=...)&lt;/code&gt; 只在目标是类别索引时忽略该值。不要把 PAD id 同时当 &lt;code&gt;ignore_index&lt;/code&gt;，因为 PAD id 可能是模型应预测的合法类别；使用词表外的 &lt;code&gt;-100&lt;/code&gt; 更清楚。&lt;/p&gt;
&lt;h2&gt;09 变 batch size 后，梯度尺度怎样保持？&lt;/h2&gt;
&lt;p&gt;若每步 &lt;code&gt;loss.mean().backward()&lt;/code&gt;，长批与短批先各自变成一个均值，再做梯度累积，会让不同 step 获得相同权重，而不是每个 token 相同权重。&lt;/p&gt;
&lt;p&gt;精确的 token 归一化应在一个优化窗口内累积 loss sum 与有效 token 总数。单进程可先对每个 micro-batch 的 &lt;code&gt;loss_sum&lt;/code&gt; 反传，更新前把梯度除以窗口总 token 数；分布式还要 all-reduce 分母，并考虑 DDP 默认的梯度平均因子。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;micro-batch 1: loss_sum=120, valid=80
micro-batch 2: loss_sum=30,  valid=20
window loss = (120+30)/(80+20)=1.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它不等于简单平均两个 micro-batch mean；当分母和平均 loss 不同时，差异会立刻出现。&lt;/p&gt;
&lt;h2&gt;10 分布式采样怎样避免重叠与失衡？&lt;/h2&gt;
&lt;p&gt;错误做法是每个 rank 独立打乱全量索引再分桶：不同 rank 会抽到重复样本，step 数也可能不同而死锁。更稳妥的顺序是先由全局 epoch seed 产生确定索引，再按 rank 分片，各 rank 在自己的分片内建立长度桶；或者由统一 batch plan 分发各 rank 的 micro-batch。&lt;/p&gt;
&lt;p&gt;需要验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同 epoch 的全局样本 id 是否恰好覆盖一次；&lt;/li&gt;
&lt;li&gt;各 rank 是否产生相同步数；&lt;/li&gt;
&lt;li&gt;每步最大 $T$ 是否严重不均，导致快卡等待慢卡；&lt;/li&gt;
&lt;li&gt;恢复训练后，epoch、seed、bucket cursor 与 batch plan 是否一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为保证相同步数而复制尾部样本时，必须记录重复并在统计权重中说明。&lt;/p&gt;
&lt;h2&gt;11 动态形状为何可能伤害编译性能？&lt;/h2&gt;
&lt;p&gt;动态 padding 让每批 $T$ 改变。GPU kernel、&lt;code&gt;torch.compile&lt;/code&gt; 或图捕获可能为许多形状反复编译，节省的 FLOPs 被编译和调度开销抵消。&lt;/p&gt;
&lt;p&gt;实用折中是把 $T$ 向上取到少数边界，如 &lt;code&gt;{128,256,512,1024}&lt;/code&gt;，或取某个 tile 的倍数。此时略增 padding，却提高形状复用、内存规划稳定性和 Tensor Core 对齐。不要只比较 tokens/s；同时记录首次编译时间、steady-state 吞吐、峰值显存与重编译次数。&lt;/p&gt;
&lt;h2&gt;12 性能指标不能只报“每秒多少 batch”&lt;/h2&gt;
&lt;p&gt;变长 batch 的行数不同，batches/s 会误导。至少同时报告：&lt;/p&gt;
&lt;p&gt;| 指标                | 回答的问题              |
| ------------------- | ----------------------- |
| 有效 tokens/s       | 模型真正学习多少目标    |
| 物理 tokens/s       | kernel 处理多少张量位置 |
| token 利用率        | padding 占比多大        |
| step latency 分位数 | 长尾 batch 是否卡顿     |
| 峰值显存            | token 上限是否安全      |
| 每来源/标签占比     | 分桶是否改变数据分布    |&lt;/p&gt;
&lt;p&gt;端到端 profile 要包含 tokenizer、DataLoader、host-to-device copy 与模型；GPU 变快后，CPU 长度排序可能成为新瓶颈。&lt;/p&gt;
&lt;h2&gt;13 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                | 常见原因               | 最短检查                   |
| ------------------- | ---------------------- | -------------------------- |
| loss 低得异常       | PAD 标签未设 &lt;code&gt;-100&lt;/code&gt;    | 数有效标签并查看末列       |
| 左 padding 生成错位 | position id 用物理列号 | 打印每行首个真实位置       |
| OOM 偶发            | 只限制样本数           | 记录每批 &lt;code&gt;B,T,B*T&lt;/code&gt;         |
| 吞吐未提升          | 形状过多导致重编译     | 统计唯一 $T$ 与编译次数    |
| 指标偏向短文本      | 使用按样本平均         | 同时报 token/sample mean   |
| 多卡偶发卡住        | rank step 数不同       | 启动前比较 batch-plan 长度 |
| 类别顺序成团        | 全局按长度永久排序     | 检查每批标签与来源直方图   |&lt;/p&gt;
&lt;p&gt;最小测试集应包含长度 1、恰好等于上限、超过上限、全 PAD 非法输入和极端长尾；并固定 seed 比较断点恢复后的前 20 个 batch id。&lt;/p&gt;
&lt;h2&gt;14 失败场景与相近方法&lt;/h2&gt;
&lt;p&gt;长度分桶只能减少同批长度差，无法消除每行尾部 padding；Sequence Packing 能继续提高利用率，但需要文档边界语义。梯度累积增加有效 batch token，不会减少单个 micro-batch padding。动态批处理改变 $B$，不等同于动态序列长度训练；后者可能特意改变上下文分布。&lt;/p&gt;
&lt;p&gt;当绝大多数样本同长，分桶收益很小；当严格在线到达、延迟优先时，等待同长度请求会增加排队时间。推理服务必须在吞吐与尾延迟之间设最大等待时间，不能照搬离线训练策略。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;动态 padding 只补到本批最大长度；长度分桶进一步缩小批内差异。&lt;/li&gt;
&lt;li&gt;token-based batching 用 $B\cdot T$ 约束物理预算，使长样本自动减少行数。&lt;/li&gt;
&lt;li&gt;可变 batch size 会暴露按 token/按样本 loss 的选择，也会影响梯度累积与多卡同步。&lt;/li&gt;
&lt;li&gt;最佳形状不一定最紧凑；少量离散长度常能在 padding 与编译/kernel 复用间取得更好平衡。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;长度 &lt;code&gt;[12,11,7,6,3,2]&lt;/code&gt;、&lt;code&gt;max_tokens=24&lt;/code&gt;，分别按原顺序与降序运行 sampler，计算每批 $B\cdot T$ 和总体利用率。&lt;/li&gt;
&lt;li&gt;实现按样本平均的因果 LM loss，并构造一条 2-token 与一条 8-token 样本，比较它和按 token 平均的权重。&lt;/li&gt;
&lt;li&gt;设计四个离散边界 &lt;code&gt;{128,256,512,1024}&lt;/code&gt; 的 benchmark，说明怎样区分 padding 收益、重编译成本与 DataLoader 瓶颈。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Kundu et al., &lt;a href=&quot;https://arxiv.org/abs/2010.05618&quot;&gt;Smart Batching: Fast Fine-Tuning of Transformer Language Models&lt;/a&gt;，研究长度感知的 Transformer 批处理。&lt;/li&gt;
&lt;li&gt;Krell et al., &lt;a href=&quot;https://arxiv.org/abs/2107.02027&quot;&gt;Efficient Sequence Packing without Cross-contamination&lt;/a&gt;，比较装箱与 padding 的效率边界。&lt;/li&gt;
&lt;li&gt;Dao et al., &lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness&lt;/a&gt;，从 IO 解释 attention 实际性能。&lt;/li&gt;
&lt;li&gt;You et al., &lt;a href=&quot;https://arxiv.org/abs/1909.08053&quot;&gt;Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism&lt;/a&gt;，展示大规模语言模型训练中的并行与批处理工程。&lt;/li&gt;
&lt;li&gt;Narayanan et al., &lt;a href=&quot;https://arxiv.org/abs/2104.04473&quot;&gt;Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM&lt;/a&gt;，讨论吞吐、micro-batch 与并行调度。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;数据已经去重、混合并高效组批，接下来要决定训练究竟持续多久。下一篇将研究 token 学习率日程、warmup、cosine decay 与按 step/按 token 计时的差异。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>短文拼进同一窗口会互相偷看吗？Sequence Packing 的边界掩码</title><link>https://zwjcode.cn/blog/sequence-packing-document-boundary-attention-loss-mask</link><guid isPermaLink="true">https://zwjcode.cn/blog/sequence-packing-document-boundary-attention-loss-mask</guid><description>从固定上下文窗口的 padding 浪费出发，手算 sequence packing，分别构造块对角因果注意力、跨文档 loss 屏蔽与位置编号，并给出可验证的 PyTorch 实现。</description><pubDate>Sat, 12 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇确定了不同数据来源应占多少 token 预算。可真正送入模型时，新闻可能只有 180 token，代码文件可能有 3,000 token，而训练内核希望每行长度固定为 $L=4{,}096$。每篇文档单独补齐会把大量算力花在 padding；直接首尾相接又可能让后一篇“看见”毫不相关的前文。&lt;/p&gt;
&lt;p&gt;序列装箱（Sequence Packing）要同时解决三个边界：&lt;strong&gt;哪些 token 放进同一行、谁能注意谁、哪些相邻对产生 next-token loss&lt;/strong&gt;。三者不是一张 mask，也不能只插一个 EOS 就假定问题消失。&lt;/p&gt;
&lt;h2&gt;01 Padding 浪费的究竟是什么？&lt;/h2&gt;
&lt;p&gt;设一个 batch 有 $N$ 行，每行物理长度 $L$，有效 token 数为 $u_n$。token 利用率为：&lt;/p&gt;
&lt;p&gt;$$
\eta_{\text{token}}=\frac{\sum_{n=1}^{N}u_n}{NL}
$$&lt;/p&gt;
&lt;p&gt;四篇含 EOS 的文档长度为 &lt;code&gt;[4, 2, 3, 3]&lt;/code&gt;，若每篇都补到 $L=6$，有效率只有 $12/24=50%$。把 &lt;code&gt;[4,2]&lt;/code&gt; 与 &lt;code&gt;[3,3]&lt;/code&gt; 分别装进两行，则 $12/12=100%$。&lt;/p&gt;
&lt;p&gt;但 Transformer 注意力的主要工作量近似与 $NL^2$ 成正比。少一半物理行，不只是少存 PAD，也减少了 QK 点积、激活与通信。&lt;/p&gt;
&lt;h2&gt;02 一行里必须保存哪些元数据？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[带来源和 doc_id 的文档] --&gt; B[tokenize + EOS]
  B --&gt; C[packing 算法]
  C --&gt; D[input_ids N×L]
  C --&gt; E[document_ids N×L]
  E --&gt; F[块对角 causal attention mask]
  E --&gt; G[跨文档 loss mask]
  E --&gt; H[position_ids N×L]
  D --&gt; I[Transformer]
  F --&gt; I
  H --&gt; I
  I --&gt; J[logits N×L×V]
  G --&gt; K[next-token loss]
  J --&gt; K
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;input_ids&lt;/code&gt; 只说明 token 是谁，不说明它属于哪篇文档。最小可审计表示应额外保留 &lt;code&gt;document_ids[N,L]&lt;/code&gt;；PAD 用 &lt;code&gt;-1&lt;/code&gt;，真实文档用稳定 id。来源、原文偏移和质量标记可作为旁路元数据，不要塞进模型词表。&lt;/p&gt;
&lt;h2&gt;03 一个 6-token 包怎样手算？&lt;/h2&gt;
&lt;p&gt;两篇文档（均已含 EOS）为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;doc 7: [A, B, EOS]      doc 9: [C, D, EOS]
input: [A, B, EOS, C, D, EOS]
docid: [7, 7,   7, 9, 9,   9]
pos:   [0, 1,   2, 0, 1,   2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通因果 mask 会让 &lt;code&gt;C&lt;/code&gt; 看见 &lt;code&gt;[A,B,EOS]&lt;/code&gt;。块对角因果 mask 只允许“同文档且 key 位置不晚于 query”：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;key →    0 1 2 3 4 5
query 0  ■ · · · · ·
      1  ■ ■ · · · ·
      2  ■ ■ ■ · · ·
      3  · · · ■ · ·
      4  · · · ■ ■ ·
      5  · · · ■ ■ ■
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是一张由两个下三角块组成的可见图。&lt;code&gt;C&lt;/code&gt; 的隐藏状态与 doc 7 无关，即使两篇物理上相邻。&lt;/p&gt;
&lt;h2&gt;04 Attention Mask 与 Loss Mask 阻止不同泄漏&lt;/h2&gt;
&lt;p&gt;对 token 位置 $i$ 查询 $j$，允许注意力的条件是：&lt;/p&gt;
&lt;p&gt;$$
M^{\text{attn}}_{ij}=[d_i=d_j]\land[j\le i]
$$&lt;/p&gt;
&lt;p&gt;next-token 标签通常是 $y_i=x_{i+1}$。只有上下文 token 与目标 token 属于同一文档时才计损失：&lt;/p&gt;
&lt;p&gt;$$
M^{\text{loss}}&lt;em&gt;i=[d_i=d&lt;/em&gt;{i+1}]
$$&lt;/p&gt;
&lt;p&gt;在上例中，&lt;code&gt;B → EOS&lt;/code&gt; 应监督，因为 EOS 属于 doc 7；&lt;code&gt;EOS → C&lt;/code&gt; 必须忽略。若只做 attention mask 而不做 loss mask，模型仍会被要求从 doc 7 的 EOS 猜 doc 9 的首词。若只做 loss mask，doc 9 的内部预测仍可能借用 doc 7 的隐藏信息。&lt;/p&gt;
&lt;h2&gt;05 位置编号应重置还是连续？&lt;/h2&gt;
&lt;p&gt;两种方案都可能成立，但训练、评测与推理必须一致：&lt;/p&gt;
&lt;p&gt;| 方案       | &lt;code&gt;position_ids&lt;/code&gt; | 优点             | 风险                         |
| ---------- | -------------- | ---------------- | ---------------------------- |
| 包内连续   | &lt;code&gt;0,1,2,3,4,5&lt;/code&gt;  | 实现简单         | 后装入的短文总从较大位置开始 |
| 文档内重置 | &lt;code&gt;0,1,2,0,1,2&lt;/code&gt;  | 每篇都像独立样本 | 必须配合文档隔离 mask        |&lt;/p&gt;
&lt;p&gt;对 RoPE，位置 id 直接决定 Q/K 的旋转角。本文选择文档内重置，使同一篇文档单独运行与装箱运行更容易逐元素对齐。不能只重置位置却保留跨文档注意力：两个文档会出现相同位置坐标并互相可见。&lt;/p&gt;
&lt;h2&gt;06 装箱算法怎样决定组合？&lt;/h2&gt;
&lt;p&gt;给定容量 $L$，离线数据可用首次适应递减（First-Fit Decreasing）：先按长度降序，再把文档放入第一个剩余空间足够的包。它不是最优装箱保证，却比随机相邻稳定。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;sort documents by length descending
for document in documents:
    for pack in open_packs:
        if pack.remaining &gt;= len(document):
            append document to pack
            break
    else:
        open a new pack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;超长文档不能悄悄丢弃。应明确采用截断、带重叠滑窗或保持文档状态的连续切块，并记录原文区间。在线训练还要限制缓冲区大小，否则“等待更合适的短文”会占满内存并改变采样顺序。&lt;/p&gt;
&lt;h2&gt;07 用 PyTorch 构造四个训练张量&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

IGNORE = -100

def build_packed_row(documents, doc_ids, block_size, pad_id):
    &quot;&quot;&quot;documents: list[LongTensor[Li]]，每篇已经以 EOS 结尾。&quot;&quot;&quot;
    if sum(map(len, documents)) &gt; block_size:
        raise ValueError(&quot;documents exceed block_size&quot;)

    tokens, owners, positions = [], [], []
    for ids, doc_id in zip(documents, doc_ids, strict=True):
        tokens.extend(ids.tolist())
        owners.extend([doc_id] * len(ids))
        positions.extend(range(len(ids)))

    pad = block_size - len(tokens)
    input_ids = torch.tensor(tokens + [pad_id] * pad)       # [L]
    document_ids = torch.tensor(owners + [-1] * pad)        # [L]
    position_ids = torch.tensor(positions + [0] * pad)      # [L]
    return input_ids, document_ids, position_ids
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Batch 后三者都是 &lt;code&gt;[N,L]&lt;/code&gt;。词嵌入输出为 &lt;code&gt;[N,L,D]&lt;/code&gt;；多头拆分后的 Q/K/V 为 &lt;code&gt;[N,H,L,d]&lt;/code&gt;，其中 $D=Hd$。&lt;/p&gt;
&lt;h2&gt;08 块对角因果 mask 怎样落到 SDPA？&lt;/h2&gt;
&lt;p&gt;当前 PyTorch 2.14 的 &lt;code&gt;scaled_dot_product_attention&lt;/code&gt; 中，布尔 &lt;code&gt;attn_mask=True&lt;/code&gt; 表示该位置&lt;strong&gt;允许参与&lt;/strong&gt;。自定义块对角因果 mask 已含因果关系，因此调用时设 &lt;code&gt;is_causal=False&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch.nn.functional as F

def packed_attention_mask(document_ids):
    # document_ids: [N,L]
    same_doc = document_ids[:, :, None] == document_ids[:, None, :]  # [N,L,L]
    valid = document_ids &gt;= 0
    causal = torch.ones(
        document_ids.size(1), document_ids.size(1), dtype=torch.bool,
        device=document_ids.device,
    ).tril()
    allowed = same_doc &amp;#x26; valid[:, :, None] &amp;#x26; valid[:, None, :] &amp;#x26; causal

    # PAD query 没有训练意义，但给它开放自身，避免整行均被屏蔽。
    eye = torch.eye(document_ids.size(1), dtype=torch.bool,
                    device=document_ids.device)
    allowed |= (~valid)[:, :, None] &amp;#x26; eye
    return allowed[:, None, :, :]  # [N,1,L,L]，广播到 H 个头

attn_mask = packed_attention_mask(document_ids)
hidden = F.scaled_dot_product_attention(
    q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False
)  # [N,H,L,d]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;教学实现显式生成 &lt;code&gt;[N,L,L]&lt;/code&gt;，便于检查但在长序列上占 $O(NL^2)$ 内存。生产系统应使用能表达变长/块对角布局的高效 kernel 或元数据接口；不要为了省 padding 又创建一张更昂贵的稠密 mask。&lt;/p&gt;
&lt;h2&gt;09 Loss Mask 怎样与标签右移对齐？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def packed_lm_loss(logits, input_ids, document_ids):
    # logits [N,L,V]；位置 i 预测 input_ids[:, i+1]
    pred = logits[:, :-1, :]               # [N,L-1,V]
    labels = input_ids[:, 1:].clone()       # [N,L-1]
    same_transition = (
        (document_ids[:, :-1] == document_ids[:, 1:])
        &amp;#x26; (document_ids[:, 1:] &gt;= 0)
    )
    labels[~same_transition] = IGNORE
    return F.cross_entropy(
        pred.transpose(1, 2), labels, ignore_index=IGNORE, reduction=&quot;sum&quot;
    ), same_transition.sum()

loss_sum, valid_tokens = packed_lm_loss(logits, input_ids, document_ids)
loss = loss_sum / valid_tokens.clamp_min(1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.14 的 &lt;code&gt;cross_entropy&lt;/code&gt; 会让 &lt;code&gt;ignore_index&lt;/code&gt; 目标不贡献梯度；&lt;code&gt;reduction=&quot;mean&quot;&lt;/code&gt; 也会按未忽略目标平均。这里显式返回和与有效 token 数，是为了多卡或梯度累积时按全局有效 token 归一化，而不是平均各卡的局部均值。&lt;/p&gt;
&lt;h2&gt;10 最强正确性测试：装箱前后必须等价&lt;/h2&gt;
&lt;p&gt;关闭 dropout，把每篇文档单独运行，再与 packed 行对应区间比较：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;model.eval()
with torch.no_grad():
    packed_logits = model(input_ids, position_ids, attn_mask)
    solo_a = model(doc_a[None], torch.arange(len(doc_a))[None], causal_a)
    solo_b = model(doc_b[None], torch.arange(len(doc_b))[None], causal_b)

torch.testing.assert_close(packed_logits[0, :len(doc_a)], solo_a[0])
start = len(doc_a)
torch.testing.assert_close(packed_logits[0, start:start+len(doc_b)], solo_b[0])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若不相等，按顺序检查：attention 可见图、position id、padding query、dropout 随机性，再检查是否存在依赖整行统计的自定义层。比较 tolerance 应结合 dtype；先用 float32 建立语义基线。&lt;/p&gt;
&lt;h2&gt;11 训练流水线还要记录什么？&lt;/h2&gt;
&lt;p&gt;每个 pack 至少记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原始文档 id、来源与 token 区间；&lt;/li&gt;
&lt;li&gt;装箱算法版本、容量 $L$ 与文档顺序；&lt;/li&gt;
&lt;li&gt;有效 loss token 数、padding 数、跨边界屏蔽数；&lt;/li&gt;
&lt;li&gt;超长文档的截断或切块策略；&lt;/li&gt;
&lt;li&gt;随机种子、worker/rank 分片和恢复 cursor。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若以“每 step 固定行数”控制训练，packing 提升会让每 step 的有效 token 增加，学习率与总 token 预算因此改变。比较实验必须固定有效 token 或明确报告差异。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                 | 常见原因                   | 最短检查                             |
| -------------------- | -------------------------- | ------------------------------------ |
| packed loss 异常更低 | 后文偷看前文               | 打印一个包的 &lt;code&gt;[L,L]&lt;/code&gt; 可见图          |
| 每篇首词 loss 很怪   | EOS→下一篇仍计标签         | 数 &lt;code&gt;doc[i] != doc[i+1]&lt;/code&gt; 的标签       |
| RoPE 结果不等价      | position id 未按契约重置   | 打印每篇首尾 position                |
| 出现 NaN             | PAD query 没有任何可见 key | 检查 mask 每行至少一个 True          |
| 吞吐反而下降         | 使用稠密块 mask            | profile mask 内存与 attention kernel |
| 恢复后样本变化       | 未保存 packer 缓冲区       | 对比恢复点后前 10 个文档 id          |&lt;/p&gt;
&lt;h2&gt;13 失败场景与相近方法&lt;/h2&gt;
&lt;p&gt;Sequence packing 不会减少有效 token 本身的计算，也不会让超长单篇文档突破上下文长度。长度分桶（Length Bucketing）只是让相近长度样本同 batch，仍有 padding；&lt;code&gt;PackedSequence&lt;/code&gt; 主要服务 RNN 的变长序列，并不自动给 Transformer 生成块对角注意力。把多篇文档简单 concatenate 后用普通 causal mask，属于连续 token 流训练，不等同于文档隔离 packing。&lt;/p&gt;
&lt;p&gt;有些语言模型有意允许跨文档注意力，借 EOS 学习边界。那是另一种训练分布，并非必然错误；但必须明确、做消融，并防止评测样本与训练样本被拼进同一上下文。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Packing 的目标是减少物理 padding；有效率要按有效 token 与实际 attention 工作量分别测。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;document_ids&lt;/code&gt; 同时派生 attention、loss 与 position 契约，但三者解决不同问题。&lt;/li&gt;
&lt;li&gt;同文档因果可见阻止信息泄漏，同文档标签转移阻止学习随机文档顺序。&lt;/li&gt;
&lt;li&gt;最有力的单元测试是：关闭随机性后，每篇文档单独运行与装箱运行逐位置等价。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;文档长度 &lt;code&gt;[5,4,3,2,2]&lt;/code&gt;、容量 8，用 First-Fit Decreasing 手算装箱结果、token 利用率与剩余空位。&lt;/li&gt;
&lt;li&gt;修改代码，使“允许跨文档注意力、但不计算跨文档 loss”，解释它与完全隔离方案的数据分布差异。&lt;/li&gt;
&lt;li&gt;为 packed batch 写三个断言：每个真实 query 至少看见自己、不能看未来、不同 &lt;code&gt;document_id&lt;/code&gt; 永不可见。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Vaswani et al., &lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Attention Is All You Need&lt;/a&gt;，Transformer 与因果/填充注意力的基础。&lt;/li&gt;
&lt;li&gt;Raffel et al., &lt;a href=&quot;https://arxiv.org/abs/1910.10683&quot;&gt;Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer&lt;/a&gt;，讨论大规模文本预训练数据与序列构造。&lt;/li&gt;
&lt;li&gt;Krell et al., &lt;a href=&quot;https://arxiv.org/abs/2107.02027&quot;&gt;Efficient Sequence Packing without Cross-contamination&lt;/a&gt;，系统研究无交叉污染的高效装箱。&lt;/li&gt;
&lt;li&gt;Dao et al., &lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness&lt;/a&gt;，解释高效 attention kernel 的 IO 约束。&lt;/li&gt;
&lt;li&gt;Kundu et al., &lt;a href=&quot;https://arxiv.org/abs/2010.05618&quot;&gt;Smart Batching: Fast Fine-Tuning of Transformer Language Models&lt;/a&gt;，比较长度感知批处理与 padding 效率。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;完全装箱适合可重排的预训练语料，但微调和在线任务常保留“一行一个样本”。下一篇将研究动态 padding、长度分桶与 token-based batching，回答怎样减少尾部浪费，又不改变样本权重和梯度尺度。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>网页占九成就该训练九成吗？数据混合权重、温度采样与 Token 预算</title><link>https://zwjcode.cn/blog/pretraining-data-mixture-token-sampling-temperature</link><guid isPermaLink="true">https://zwjcode.cn/blog/pretraining-data-mixture-token-sampling-temperature</guid><description>从数据量比例压倒小语种出发，手算温度平滑，区分文档与 token 采样，并用 PyTorch 构造可复现、可审计的多来源预训练批次。</description><pubDate>Sat, 12 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇删除了精确重复与近重复，让镜像网页不再暗中获得额外权重。但去重后的可用数据仍可能是网页 900 亿 token、代码 80 亿、中文教材 20 亿。若把文件顺序打乱后直接读取，模型看到每类数据的概率只由“谁体积大”决定。&lt;/p&gt;
&lt;p&gt;数据混合（Data Mixture）把这个隐含选择变成显式分布。本文只追踪三个问题：&lt;strong&gt;怎样把来源规模变成目标采样概率、温度平滑怎样在覆盖与过采样之间取舍，以及怎样按 token 而非按文件实现可复现的训练预算。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 自然比例为什么不一定是学习目标？&lt;/h2&gt;
&lt;p&gt;设共有 $K$ 个来源，第 $i$ 个来源有 $n_i$ 个可训练 token。自然比例为：&lt;/p&gt;
&lt;p&gt;$$
p_i=\frac{n_i}{\sum_{j=1}^{K}n_j}
$$&lt;/p&gt;
&lt;p&gt;它回答“仓库中随机一个 token 来自哪里”，并不回答“单位计算应花在哪里”。网页来源大可能只是更容易抓取；小来源可能更相关、更干净，也可能因反复过采样而很快记住。&lt;/p&gt;
&lt;p&gt;混合权重 $q_i$ 必须满足 $q_i\ge0,\sum_iq_i=1$。总预算为 $B$ 个训练 token 时，期望分配是 $Bq_i$。&lt;/p&gt;
&lt;h2&gt;02 从来源到一个 batch 的数据流&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[各来源去重文档] --&gt; B[tokenize]
  B --&gt; C[按 EOS/边界拼接]
  C --&gt; D[每来源 token 流]
  Q[q source probabilities] --&gt; E[采样 source_id]
  D --&gt; E
  E --&gt; F[截取长度 L 的 block]
  F --&gt; G[叠成 input_ids N×L]
  G --&gt; H[因果 LM loss]
  H --&gt; I[按来源记录 token/loss/reuse]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;来源选择、文档选择、拼接、切块是不同随机过程。日志至少要能从一个 batch 追溯到 &lt;code&gt;source_id&lt;/code&gt;、文档 id 与 token 区间。&lt;/p&gt;
&lt;h2&gt;03 温度平滑怎样从 $p$ 得到 $q$？&lt;/h2&gt;
&lt;p&gt;一种常见规则是幂次平滑：&lt;/p&gt;
&lt;p&gt;$$
q_i(\alpha)=\frac{p_i^\alpha}{\sum_jp_j^\alpha},\qquad 0\le\alpha\le1
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\alpha=1$：保持自然 token 比例；&lt;/li&gt;
&lt;li&gt;$\alpha=0$：所有非空来源等概率；&lt;/li&gt;
&lt;li&gt;$0&amp;#x3C;\alpha&amp;#x3C;1$：压低大来源、抬高小来源。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两来源分别有 900 与 100 个 token，$p=[0.9,0.1]$。取 $\alpha=0.5$：&lt;/p&gt;
&lt;p&gt;$$
\sqrt p=[0.949,0.316],\qquad q=[0.75,0.25]
$$&lt;/p&gt;
&lt;p&gt;小来源从 10% 升到 25%，但不是 50%。若预算 $B=10{,}000$，期望抽到 2,500 个小来源 token。&lt;/p&gt;
&lt;h2&gt;04 “抬高权重”意味着重复看多少次？&lt;/h2&gt;
&lt;p&gt;定义来源 $i$ 的期望 epoch 倍数：&lt;/p&gt;
&lt;p&gt;$$
e_i=\frac{Bq_i}{n_i}
$$&lt;/p&gt;
&lt;p&gt;在上例中，若 $B=10{,}000$，大来源 $e_1=8.33$，小来源 $e_2=25$。这提醒我们：公平的来源概率不等于公平的样本复用。应同时设置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;目标权重 $q_i$；&lt;/li&gt;
&lt;li&gt;总 token 预算 $B$；&lt;/li&gt;
&lt;li&gt;每来源最大复用倍数或耗尽策略；&lt;/li&gt;
&lt;li&gt;去重后而非去重前的 $n_i$。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;05 为什么按文档采样会悄悄改错比例？&lt;/h2&gt;
&lt;p&gt;来源 A 有 100 篇、每篇 1,000 token；来源 B 有 100 篇、每篇 100 token。若以 50/50 选择来源并各抽一篇，则 token 期望比例约是 10:1，而不是 1:1。&lt;/p&gt;
&lt;p&gt;目标若定义在 token 上，应把每个来源先变成 token 流，再取固定长度 $L$ 的 block。输入 batch：&lt;/p&gt;
&lt;p&gt;$$
X\in{0,\ldots,V-1}^{N\times L}
$$&lt;/p&gt;
&lt;p&gt;一个 block 含 $L$ 个输入 token，通常产生约 $L$ 个 next-token 目标；边界或 padding mask 会让有效数略少。配比统计应数有效 loss token，而不是文件数或 batch 数。&lt;/p&gt;
&lt;h2&gt;06 跨文档拼接要保留什么边界？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;doc A tokens ─ EOS ─ doc B tokens ─ EOS ─► block [L]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若允许跨文档注意力，模型可能学习到人为相邻关系；若做 document mask，则需要额外的块状因果掩码。无论哪种选择，EOS 插入、丢弃尾部还是跨块延续，都必须固定。&lt;/p&gt;
&lt;p&gt;极短文档不应因“每篇至少一个 block”而被系统性过采样。先拼接为来源 token 流，再统一切块更接近按 token 配比。&lt;/p&gt;
&lt;h2&gt;07 用 PyTorch 采样来源&lt;/h2&gt;
&lt;p&gt;当前稳定 PyTorch 的 &lt;code&gt;torch.multinomial&lt;/code&gt; 接受一维非负权重，权重不必预先归一化。要独立抽取每个 block 的来源，应设 &lt;code&gt;replacement=True&lt;/code&gt;，并传入专用 &lt;code&gt;Generator&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

def temperature_weights(token_counts: torch.Tensor, alpha: float) -&gt; torch.Tensor:
    if token_counts.ndim != 1 or (token_counts &amp;#x3C;= 0).any():
        raise ValueError(&quot;token_counts must be positive [K]&quot;)
    if not 0.0 &amp;#x3C;= alpha &amp;#x3C;= 1.0:
        raise ValueError(&quot;alpha must be in [0, 1]&quot;)
    log_w = alpha * token_counts.double().log()
    return torch.softmax(log_w, dim=0)  # [K], sum=1

counts = torch.tensor([900, 100])
q = temperature_weights(counts, alpha=0.5)
torch.testing.assert_close(q, torch.tensor([0.75, 0.25], dtype=torch.float64))

generator = torch.Generator(device=&quot;cpu&quot;).manual_seed(20260912)
source_ids = torch.multinomial(
    q, num_samples=8, replacement=True, generator=generator
)  # [num_blocks=8]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 log-space 计算可避免超大计数先做幂运算溢出。&lt;code&gt;source_ids&lt;/code&gt; 的形状是 &lt;code&gt;[num_blocks]&lt;/code&gt;，不是 token 张量。&lt;/p&gt;
&lt;h2&gt;08 从来源流组装 &lt;code&gt;[N,L]&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TokenStream:
    def __init__(self, blocks):
        self.blocks = blocks  # list[LongTensor[L]]
        self.cursor = 0

    def next_block(self, generator):
        if self.cursor == len(self.blocks):
            order = torch.randperm(len(self.blocks), generator=generator).tolist()
            self.blocks = [self.blocks[i] for i in order]
            self.cursor = 0
        block = self.blocks[self.cursor]
        self.cursor += 1
        return block

def sample_batch(streams, q, batch_size, generator):
    chosen = torch.multinomial(
        q, batch_size, replacement=True, generator=generator
    )  # [N]
    rows = [streams[i].next_block(generator) for i in chosen.tolist()]
    input_ids = torch.stack(rows)  # [N,L]
    return input_ids, chosen
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出 &lt;code&gt;input_ids[N,L]&lt;/code&gt; 和 &lt;code&gt;chosen[N]&lt;/code&gt;。训练端再按此前文章的规则生成右移标签。真实分布式任务不可让各 rank 读取同一 block；需把全局 step、rank、worker 和 epoch 纳入种子，或使用可恢复的计数器式随机数生成方案。&lt;/p&gt;
&lt;h2&gt;09 一次训练步怎样审计实际配比？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;token_tally = torch.zeros(len(streams), dtype=torch.long)
loss_tally = torch.zeros(len(streams), dtype=torch.float64)

logits = model(input_ids)  # [N,L,V]
token_loss = torch.nn.functional.cross_entropy(
    logits[:, :-1].reshape(-1, logits.size(-1)),
    input_ids[:, 1:].reshape(-1),
    reduction=&quot;none&quot;,
).view(input_ids.size(0), -1)  # [N,L-1]

for row, source_id in enumerate(chosen):
    sid = int(source_id)
    token_tally[sid] += token_loss.size(1)
    loss_tally[sid] += token_loss[row].sum().double()
loss = token_loss.mean()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;短窗口中的观测比例会随机波动；长窗口才应接近 $q$。用二项分布标准差 $\sqrt{Mq_i(1-q_i)}$ 为 $M$ 个 block 的计数设置告警带，别要求每 8 个 batch 精确等于目标。&lt;/p&gt;
&lt;h2&gt;10 静态权重与动态权重有什么不同？&lt;/h2&gt;
&lt;p&gt;静态混合在训练前固定 $q$，简单、可复现，也便于消融。动态混合则根据每来源验证 loss、梯度信号或小型代理模型调整权重，但会形成反馈回路：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;source batch ─► update model ─► validation loss
      ▲                              │
      └──── optimizer of mixture ◄───┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;某来源 loss 高，可能表示“仍值得学习”，也可能表示“噪声极大”；仅按高 loss 加权会追逐坏数据。动态方法必须使用独立目标集、权重上下限和慢更新频率，并记录每次版本。&lt;/p&gt;
&lt;h2&gt;11 预算、耗尽与课程学习&lt;/h2&gt;
&lt;p&gt;| 策略         | 行为            | 主要风险           |
| ------------ | --------------- | ------------------ |
| 有放回混合   | 始终按 $q$ 抽取 | 小来源多轮记忆     |
| 无放回后重算 | 来源耗尽便移除  | 后期分布突变       |
| 分阶段课程   | 预先定义 $q(t)$ | 阶段边界与归因复杂 |&lt;/p&gt;
&lt;p&gt;课程学习（Curriculum Learning）改变权重随 step $t$ 的函数，不等同于随机采样。若后期提高代码比例，应保存完整 schedule，恢复训练时从同一 global step 继续。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                    | 常见原因              | 最短检查                   |
| ----------------------- | --------------------- | -------------------------- |
| 小来源仍几乎看不到      | 用文档数计算 $p_i$    | 改数有效 token             |
| 权重正确但 token 比例错 | 每来源 block 长度不同 | 统一 $L$ 或按 token 加权   |
| 多卡吞吐翻倍但数据没变  | rank 共用相同随机流   | 记录前 20 个全局 block id  |
| 恢复后 loss 跳变        | 未保存 sampler 状态   | 保存 seed、cursor、step    |
| 小来源训练 loss 极低    | 复用倍数过高          | 统计 unique/repeated token |
| 动态采样追逐噪声        | 把高 loss 当高价值    | 查看独立验证与质量分层     |&lt;/p&gt;
&lt;p&gt;最短验证是：构造三个只有唯一 token id 的来源，抽取 100,000 个固定长度 block，核对观测频率、跨 rank 重复率和断点恢复后序列完全一致。&lt;/p&gt;
&lt;h2&gt;13 相近方法不要混为一谈&lt;/h2&gt;
&lt;p&gt;损失加权是在样本已进入 batch 后改变梯度；采样权重改变它进入 batch 的概率，两者组合会相乘。温度采样只利用规模，不学习“价值”。质量过滤决定样本是否可用；去重消除重复；课程学习决定随时间的顺序；重要性采样若要得到无偏估计，还需按目标分布与提议分布之比校正。&lt;/p&gt;
&lt;p&gt;模型也可能在固定配比下出现能力干扰：代码增加帮助推理却损害自然语言，或低资源语言因 tokenizer 过碎获得更少语义内容。最终选择应以每来源独立验证集、目标任务与固定总 FLOPs 的实验为准。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;自然 token 比例 $p$ 是数据体积分布，不自动等于训练目标 $q$。&lt;/li&gt;
&lt;li&gt;$q_i\propto p_i^\alpha$ 用一个可解释参数平滑来源规模，但必须同时检查复用倍数 $Bq_i/n_i$。&lt;/li&gt;
&lt;li&gt;想控制 token 配比，就用等长 block 和有效 loss token 计数，不能只数文档或 batch。&lt;/li&gt;
&lt;li&gt;随机种子、来源 cursor、global step 和分布式分片共同决定能否复现训练。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;三来源 token 数为 $[10^6,10^4,10^2]$，分别计算 $\alpha=1,0.5,0$ 的 $q$，解释最小来源的复用风险。&lt;/li&gt;
&lt;li&gt;预算 $B=10^9$、某来源 $n_i=10^7,q_i=0.03$，计算期望 epoch 倍数；设计一个最大 5 次复用的处理策略。&lt;/li&gt;
&lt;li&gt;修改示例 sampler，使断点保存后能精确恢复 &lt;code&gt;source_ids&lt;/code&gt; 与每个来源 cursor，并写重启一致性测试。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Raffel et al., &lt;a href=&quot;https://arxiv.org/abs/1910.10683&quot;&gt;Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer&lt;/a&gt;，记录 C4 构建与多任务混合策略。&lt;/li&gt;
&lt;li&gt;Xue et al., &lt;a href=&quot;https://arxiv.org/abs/2010.11934&quot;&gt;mT5: A Massively Multilingual Pre-trained Text-to-Text Transformer&lt;/a&gt;，使用温度采样平衡多语言数据。&lt;/li&gt;
&lt;li&gt;Gao et al., &lt;a href=&quot;https://arxiv.org/abs/2101.00027&quot;&gt;The Pile: An 800GB Dataset of Diverse Text for Language Modeling&lt;/a&gt;，构建并记录多来源语言模型语料。&lt;/li&gt;
&lt;li&gt;Xie et al., &lt;a href=&quot;https://arxiv.org/abs/2305.10429&quot;&gt;DoReMi: Optimizing Data Mixtures Speeds Up Language Model Pretraining&lt;/a&gt;，用代理模型学习领域混合权重。&lt;/li&gt;
&lt;li&gt;Longpre et al., &lt;a href=&quot;https://arxiv.org/abs/2305.13169&quot;&gt;A Pretrainer&apos;s Guide to Training Data&lt;/a&gt;，研究来源组成对预训练迁移的影响。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;数据来源与预算已确定，下一步仍不能把任意长度文档直接塞进固定上下文窗口。下一篇将研究文档拼接、sequence packing、attention/loss 边界与 padding 浪费，追踪每个 token 到训练 block 的位置。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>十亿段语料怎样避免反复背诵？精确去重、MinHash 与评测污染</title><link>https://zwjcode.cn/blog/pretraining-data-exact-near-dedup-minhash</link><guid isPermaLink="true">https://zwjcode.cn/blog/pretraining-data-exact-near-dedup-minhash</guid><description>从重复网页造成的隐式加权出发，手算 Jaccard 与 MinHash，构造可审计的精确—近重复去重流水线，并处理数据切分、阈值和分布式工程陷阱。</description><pubDate>Fri, 11 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把 token id 映射为 Embedding，并让 LM Head 把隐藏状态投回词表。模型闭环已经完整，但它会忠实放大数据分布：同一篇公告被镜像 100 次，就相当于在损失中给它 100 倍权重；测试题若混进训练集，漂亮分数也不再表示泛化。&lt;/p&gt;
&lt;p&gt;数据去重（Data Deduplication）不是“删掉看起来相似的文本”这么简单。本文只讲透三个紧密环节：&lt;strong&gt;用内容哈希删除精确重复、用分片 Jaccard 与 MinHash 发现近重复，以及在切分之前建立不可泄漏、可复现的删除规则。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 重复数据怎样改变训练目标？&lt;/h2&gt;
&lt;p&gt;设文档 $d_i$ 含 $T_i$ 个可预测 token，逐 token 平均损失为 $\bar\ell_i$：&lt;/p&gt;
&lt;p&gt;$$
\mathcal L=\frac{\sum_i T_i\bar\ell_i}{\sum_iT_i}
$$&lt;/p&gt;
&lt;p&gt;若 $d_1$ 被复制 $k$ 次，它的权重从 $T_1$ 变成 $kT_1$。重复不是只浪费磁盘，而是在未声明的情况下重写采样分布，并增加逐字记忆、隐私暴露与训练—评测污染（Train–Evaluation Contamination）的风险。&lt;/p&gt;
&lt;h2&gt;02 去重流水线放在哪里？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[原始文档 + source/id] --&gt; B[解析正文]
  B --&gt; C[仅供匹配的规范化副本]
  C --&gt; D[内容哈希：精确桶]
  D --&gt; E[shingle 集合]
  E --&gt; F[MinHash + LSH 候选]
  F --&gt; G[真实 Jaccard 复核]
  G --&gt; H[重复图/连通分量]
  H --&gt; I[确定性保留代表]
  I --&gt; J[训练/验证/测试切分]
  J --&gt; K[tokenize 与混合采样]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原文与匹配副本必须分开保存。去重规范化可以折叠空白、统一换行，却不应悄悄改写最终训练文本。每步输出原因码，例如 &lt;code&gt;exact_hash&lt;/code&gt;、&lt;code&gt;near_duplicate&lt;/code&gt;、&lt;code&gt;eval_overlap&lt;/code&gt;，否则删错后无法追踪。&lt;/p&gt;
&lt;h2&gt;03 精确重复：关键是哈希什么&lt;/h2&gt;
&lt;p&gt;对规范化字节串 $b_i$ 计算摘要 $h_i=H(b_i)$。相同摘要先进入同一桶，再比较字节确认；这样即使理论上发生哈希碰撞，也不会误删。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import hashlib
import re
import unicodedata

def match_view(text: str) -&gt; str:
    text = unicodedata.normalize(&quot;NFC&quot;, text)
    return re.sub(r&quot;\s+&quot;, &quot; &quot;, text).strip()

def exact_key(text: str) -&gt; tuple[str, bytes]:
    payload = match_view(text).encode(&quot;utf-8&quot;)
    digest = hashlib.blake2b(payload, digest_size=16).hexdigest()
    return digest, payload

seen: dict[str, list[bytes]] = {}
def is_exact_duplicate(text: str) -&gt; bool:
    digest, payload = exact_key(text)
    bucket = seen.setdefault(digest, [])
    duplicate = payload in bucket
    if not duplicate:
        bucket.append(payload)
    return duplicate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入是 Unicode 文本，输出是布尔值；生产系统还应输出匹配文档 id。不要用 Python 的 &lt;code&gt;hash()&lt;/code&gt;：它不是跨进程稳定的内容指纹。&lt;/p&gt;
&lt;h2&gt;04 为什么精确哈希抓不到网页近重复？&lt;/h2&gt;
&lt;p&gt;页眉、日期或一句免责声明不同，整篇摘要就完全不同。把文档切成长度为 $k$ 的连续 token shingle（词片）：&lt;/p&gt;
&lt;p&gt;$$
S(d)={(x_j,\ldots,x_{j+k-1})\mid 0\le j\le L-k}
$$&lt;/p&gt;
&lt;p&gt;两个集合的 Jaccard 相似度为：&lt;/p&gt;
&lt;p&gt;$$
J(A,B)=\frac{|A\cap B|}{|A\cup B|}
$$&lt;/p&gt;
&lt;p&gt;令 $A={ab,bc,cd}$，$B={ab,bc,ce}$，交集有 2 个、并集有 4 个，所以 $J=2/4=0.5$。集合忽略重复次数；若频次本身重要，应使用加权 Jaccard。&lt;/p&gt;
&lt;h2&gt;05 MinHash 为什么能用短签名估计 Jaccard？&lt;/h2&gt;
&lt;p&gt;对集合元素使用随机排列 $\pi_r$，第 $r$ 个签名值是：&lt;/p&gt;
&lt;p&gt;$$
m_r(S)=\min_{x\in S}\pi_r(x)
$$&lt;/p&gt;
&lt;p&gt;关键性质是 $\Pr[m_r(A)=m_r(B)]=J(A,B)$。若用 $R=4$ 个排列，两个签名有 3 位相同，则估计 $\hat J=3/4=0.75$。标准误差约为 $\sqrt{J(1-J)/R}$，增加 $R$ 才会稳定。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def minhash(shingles: set[bytes], seeds: list[int]) -&gt; tuple[int, ...]:
    signature = []
    for seed in seeds:
        key = seed.to_bytes(8, &quot;little&quot;)
        values = [
            int.from_bytes(
                hashlib.blake2b(item, key=key, digest_size=8).digest(), &quot;little&quot;
            )
            for item in shingles
        ]
        signature.append(min(values))
    return tuple(signature)

def estimate_jaccard(a: tuple[int, ...], b: tuple[int, ...]) -&gt; float:
    assert len(a) == len(b) and a
    return sum(x == y for x, y in zip(a, b)) / len(a)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;seeds&lt;/code&gt;、分词器、$k$ 和规范化版本必须固定。空文档要提前过滤，否则 &lt;code&gt;min()&lt;/code&gt; 没有定义。&lt;/p&gt;
&lt;h2&gt;06 LSH 怎样避免所有文档两两比较？&lt;/h2&gt;
&lt;p&gt;$M$ 篇文档全比较需要 $O(M^2)$ 对。局部敏感哈希（Locality-Sensitive Hashing，LSH）把 $R=br$ 个签名切成 $b$ 个 band，每 band 含 $r$ 行；任一 band 完全相同才成为候选。&lt;/p&gt;
&lt;p&gt;$$
P(\text{candidate}\mid J)=1-(1-J^r)^b
$$&lt;/p&gt;
&lt;p&gt;例如 $b=4,r=2,J=0.8$，候选概率约 $0.983$；当 $J=0.2$ 时约 $0.151$。LSH 只负责召回候选，最终仍应用真实 shingle 集合计算 Jaccard。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;signature [R=8]
 ├─ band0 [2] ─┐
 ├─ band1 [2] ─┼─ 同桶文档对 ─► 真实 Jaccard ─► 重复边
 ├─ band2 [2] ─┤
 └─ band3 [2] ─┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;07 重复关系为什么要建图？&lt;/h2&gt;
&lt;p&gt;相似关系不一定传递：$J(A,B)$ 和 $J(B,C)$ 都过阈值，不代表 $J(A,C)$ 也过阈值。常见工程做法把文档视为节点、过阈值候选视为边，再用并查集求连通分量。&lt;/p&gt;
&lt;p&gt;每个分量只保留一个代表时，规则必须确定：依次比较质量分、正文长度、来源优先级、抓取时间和稳定 id。不要“谁先被 worker 扫到就保留谁”，否则并行度改变数据集。&lt;/p&gt;
&lt;h2&gt;08 切分与评测污染应怎样处理？&lt;/h2&gt;
&lt;p&gt;最安全的顺序是把训练、验证、测试候选放进同一近重复图，再按优先级保留：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;基准测试与人工保留集拥有最高保护优先级；&lt;/li&gt;
&lt;li&gt;与评测集近重复的训练样本删除，而不是反过来；&lt;/li&gt;
&lt;li&gt;同一重复簇不可跨 split；&lt;/li&gt;
&lt;li&gt;最终只在训练 split 上拟合 tokenizer 或其他数据统计量。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;若合规要求不允许跨集合读取正文，可交换不可逆指纹或 shingle 哈希，并记录覆盖率局限。&lt;/p&gt;
&lt;h2&gt;09 参数怎样选，不能只看一个阈值&lt;/h2&gt;
&lt;p&gt;| 参数                | 过小           | 过大           | 应看什么              |
| ------------------- | -------------- | -------------- | --------------------- |
| shingle 长度 $k$    | 常用短语误报   | 局部改写漏报   | 文档类型分层标注      |
| 签名数 $R$          | 估计方差大     | 内存与计算增加 | 候选召回稳定性        |
| Jaccard 阈值 $\tau$ | 误删同主题文章 | 漏掉模板变体   | 人工 precision/recall |
| 最短正文            | 菜单模板主导   | 丢失短问答     | 长度分桶审计          |&lt;/p&gt;
&lt;p&gt;在已标注文档对上画 precision–recall，而不是从论文复制一个 $\tau$。代码、中文短文本与英文长网页通常需要不同参数。&lt;/p&gt;
&lt;h2&gt;10 分布式实现的数据契约&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入: {doc_id, source, raw_text, crawl_time}
中间: {doc_id, norm_version, exact_key, minhash[R], quality}
删除: {removed_id, kept_id, reason, score, pipeline_version}
输出: {doc_id, source, raw_text}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先按 exact key 分区，再按 LSH band key shuffle 候选。候选对要排序去重；并查集结果按稳定 id 归并。保存各阶段计数、每来源删除率及阈值附近样本，才能发现某种语言被过度删除。&lt;/p&gt;
&lt;h2&gt;11 最短验证与调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;用 golden pairs 覆盖完全相同、空白变化、页眉变化、同主题不同事实和代码重命名；&lt;/li&gt;
&lt;li&gt;打印规范化文本、shingle 交并集、MinHash 估计和最终原因码；&lt;/li&gt;
&lt;li&gt;在 1%、10%、100% 数据上检查删除率是否突变；&lt;/li&gt;
&lt;li&gt;比较单进程与多 worker 的保留 id 集合；&lt;/li&gt;
&lt;li&gt;扫描训练集与评测集 overlap，并人工复核高分对。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;| 症状               | 常见原因                  | 最短检查              |
| ------------------ | ------------------------- | --------------------- |
| 每次保留样本不同   | seed 或代表规则不稳定     | 固定 seed，按 id 排序 |
| 中文几乎全被判重复 | shingle 太短              | 查看真实交集片段      |
| 内存爆炸           | 热门 LSH 桶形成笛卡尔积   | 限制模板桶并分层处理  |
| 评测异常升高       | 先切分后仅在 split 内去重 | 做跨 split overlap    |
| 去重后小语种骤减   | 来源本就高度镜像          | 按来源统计并重配权重  |&lt;/p&gt;
&lt;h2&gt;12 失败场景与相近方法&lt;/h2&gt;
&lt;p&gt;MinHash 适合集合重叠，不理解语义改写；事实相同但措辞不同可能漏掉，模板相同但事实字段不同又可能误报。Embedding 相似度更擅长语义，却更昂贵且容易把同主题合法样本混为重复。后缀数组适合长公共子串；SimHash 更接近余弦式指纹；图像、音频需要模态专用感知哈希。&lt;/p&gt;
&lt;p&gt;去重也不能修复错误事实、隐私、许可问题和来源偏差。它只是数据治理的一层，不是质量过滤的代名词。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;重复文档等于隐式提高其 token 权重，会影响泛化、记忆和评测可信度。&lt;/li&gt;
&lt;li&gt;精确哈希解决字节级重复；shingle Jaccard 定义近重复；MinHash+LSH 只加速候选召回。&lt;/li&gt;
&lt;li&gt;评测集应受保护，跨 split 重复必须在切分前解决。&lt;/li&gt;
&lt;li&gt;规范化、seed、代表选择与删除日志都是可复现训练数据的一部分。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对集合 ${a,b,c,d}$ 与 ${b,c,d,e,f}$ 手算 Jaccard；若 8 位 MinHash 有 5 位相同，比较估计误差。&lt;/li&gt;
&lt;li&gt;固定 $b=20,r=5$，计算 $J=0.5,0.8,0.95$ 的候选概率，并解释 S 曲线如何影响召回。&lt;/li&gt;
&lt;li&gt;为新闻、GitHub 代码和论坛短帖各设计一条代表保留规则，说明可能引入的偏差。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Broder, &lt;a href=&quot;https://ieeexplore.ieee.org/document/666900&quot;&gt;On the Resemblance and Containment of Documents&lt;/a&gt;，提出用 MinHash 估计文档集合相似度。&lt;/li&gt;
&lt;li&gt;Lee et al., &lt;a href=&quot;https://arxiv.org/abs/2107.06499&quot;&gt;Deduplicating Training Data Makes Language Models Better&lt;/a&gt;，系统研究语言模型训练语料去重。&lt;/li&gt;
&lt;li&gt;Kandpal et al., &lt;a href=&quot;https://arxiv.org/abs/2202.06539&quot;&gt;Deduplicating Training Data Mitigates Privacy Risks in Language Models&lt;/a&gt;，分析重复、记忆与隐私风险。&lt;/li&gt;
&lt;li&gt;Dodge et al., &lt;a href=&quot;https://arxiv.org/abs/2104.08758&quot;&gt;Documenting Large Webtext Corpora&lt;/a&gt;，审计大型网页语料与下游基准重叠。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;去重后，每份来源终于不再因镜像数量获得隐式权重；但高质量小语种、代码和网页正文仍相差几个数量级。下一篇将把“数据源比例”写成明确的按 token 采样分布，并讨论温度平滑、预算与可复现批次。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>整数编号怎样变成可学习语义？Token Embedding、梯度累加与权重绑定</title><link>https://zwjcode.cn/blog/token-embedding-lm-head-weight-tying</link><guid isPermaLink="true">https://zwjcode.cn/blog/token-embedding-lm-head-weight-tying</guid><description>从 token id 的无序性出发，手算 Embedding 查表和重复行梯度，追踪 LM Head 的词表 logits，并用 PyTorch 2.14 正确实现权重绑定。</description><pubDate>Fri, 11 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 BPE 把开放文本切成有限词表中的整数 id。但 id 只是地址：token 7 不比 token 3 “大 4”，对编号直接做加减会把任意词表顺序误当作语义几何。模型需要把每个地址映射成可学习向量，并在输出端把隐藏状态重新与整个词表比较。&lt;/p&gt;
&lt;p&gt;Token Embedding（词元嵌入）与语言模型输出头（Language-Model Head，LM Head）正好位于这条数据流的两端。本文只讲透三件事：&lt;strong&gt;查表怎样等价于 one-hot 乘矩阵、重复 token 的梯度怎样累加，以及为何输入表与输出分类器常共享同一张权重。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 token id 为什么不能作为数值特征？&lt;/h2&gt;
&lt;p&gt;假设词表是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;0:&amp;#x3C;pad&gt;  1:猫  2:银行  3:跑  4:狗
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编号来自 tokenizer 的构建顺序，不表达距离。把 id &lt;code&gt;4&lt;/code&gt; 当浮点输入会暗示“狗”是“猫”的四倍，并且重排词表就改变模型函数。正确做法是用 id 选择矩阵的一行。&lt;/p&gt;
&lt;p&gt;设词表大小 $V$、宽度 $D$：&lt;/p&gt;
&lt;p&gt;$$
E\in\mathbb R^{V\times D},\qquad X_{n,t}=E_{I_{n,t}}
$$&lt;/p&gt;
&lt;p&gt;$I\in{0,\ldots,V-1}^{N\times L}$，输出 $X\in\mathbb R^{N\times L\times D}$。查表不会平均相邻 id，也不要求 id 连续地表达语义。&lt;/p&gt;
&lt;h2&gt;02 查表为何等价于 one-hot 矩阵乘法？&lt;/h2&gt;
&lt;p&gt;若 id 为 2，对应 one-hot 向量 $o=[0,0,1,0,0]$：&lt;/p&gt;
&lt;p&gt;$$
x=oE=E_{2,:}
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ids I [N,L]
   │ 每个整数选择 E 的一行
   ▼
E [V,D] ───────────────► X [N,L,D]
                         │ Transformer blocks
                         ▼
                       H [N,L,D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;one-hot 解释清楚了数学，但生产实现不会显式构造 &lt;code&gt;[N,L,V]&lt;/code&gt; 的零矩阵；索引读取只取需要的行。&lt;/p&gt;
&lt;h2&gt;03 用一个 4×2 表手算前向&lt;/h2&gt;
&lt;p&gt;令：&lt;/p&gt;
&lt;p&gt;$$
E=\begin{bmatrix}
0&amp;#x26;0\
1&amp;#x26;0\
0&amp;#x26;2\
1&amp;#x26;1
\end{bmatrix},\qquad I=[1,3,1]
$$&lt;/p&gt;
&lt;p&gt;则：&lt;/p&gt;
&lt;p&gt;$$
X=E[I]=\begin{bmatrix}1&amp;#x26;0\1&amp;#x26;1\1&amp;#x26;0\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;输入形状 &lt;code&gt;[L]=[3]&lt;/code&gt;，输出 &lt;code&gt;[L,D]=[3,2]&lt;/code&gt;。批量输入 &lt;code&gt;[N,L]&lt;/code&gt; 只是在前面再保留 batch 轴，不会把三个向量相加。&lt;/p&gt;
&lt;h2&gt;04 重复 token 的梯度流向哪一行？&lt;/h2&gt;
&lt;p&gt;若上游对三个输出位置的梯度为：&lt;/p&gt;
&lt;p&gt;$$
G_X=\begin{bmatrix}1&amp;#x26;2\3&amp;#x26;4\5&amp;#x26;6\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;id 1 在位置 0、2 出现，所以：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial E_{1,:}}=[1,2]+[5,6]=[6,8]
$$&lt;/p&gt;
&lt;p&gt;id 3 的梯度是 &lt;code&gt;[3,4]&lt;/code&gt;，未访问行梯度为 0。这是 scatter-add，而不是“后一次覆盖前一次”。高频 token 每批获得更多直接更新机会，稀有行可能长期不动。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

embedding = nn.Embedding(num_embeddings=4, embedding_dim=2)
with torch.no_grad():
    embedding.weight.copy_(torch.tensor([[0.,0.],[1.,0.],[0.,2.],[1.,1.]]))
ids = torch.tensor([[1, 3, 1]], dtype=torch.long)  # [N=1,L=3]
x = embedding(ids)                                # [1,3,2]
x.backward(torch.tensor([[[1.,2.],[3.,4.],[5.,6.]]]))
torch.testing.assert_close(embedding.weight.grad[1], torch.tensor([6.,8.]))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 LM Head 怎样把隐藏状态投回词表？&lt;/h2&gt;
&lt;p&gt;Transformer 输出 $H\in\mathbb R^{N\times L\times D}$。独立输出权重 $W_{out}\in\mathbb R^{V\times D}$ 与偏置 $b\in\mathbb R^V$ 产生：&lt;/p&gt;
&lt;p&gt;$$
z_{n,t}=W_{out}h_{n,t}+b\in\mathbb R^V
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;H [N,L,D] @ W_out.T [D,V] + b [V]
                         │
                         ▼
                   logits [N,L,V]
                         │ 与右移 labels [N,L] 做 CE
                         ▼
                       loss []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;logit 是兼容分数，不是概率；上一篇的 &lt;code&gt;cross_entropy&lt;/code&gt; 会内部执行稳定的 LogSoftmax。对全部位置显式 softmax 还会产生额外内存。&lt;/p&gt;
&lt;h2&gt;06 为什么输入与输出能共享同一张表？&lt;/h2&gt;
&lt;p&gt;权重绑定（Weight Tying）令：&lt;/p&gt;
&lt;p&gt;$$
W_{out}=E,qquad z_{n,t,v}=h_{n,t}^{\top}E_v+b_v
$$&lt;/p&gt;
&lt;p&gt;同一行 $E_v$ 有两个角色：输入中出现 token $v$ 时，它被取出；输出预测时，它作为“候选 token $v$”的方向与所有隐藏状态比较。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                    ┌──────── E[v,:] ◄──── input id v
                    │             │
所有 ids ─ lookup ──┘             │ 同一 Parameter
                                  ▼
hidden h ─────────────────── dot(E[v,:]) ─► logit_v
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不绑定时输入、输出两表共 $2VD$ 个参数；绑定后是 $VD$，偏置另计。例如 $V=50,000,D=4096$、float16 权重时，一张表约 390.6 MiB，绑定可少一张同尺寸参数表；训练中的梯度和优化器状态节省更大。&lt;/p&gt;
&lt;p&gt;绑定也施加归纳偏置：适合“读入 token 的几何”也用于“判断下一个 token”。它减少自由度，不保证所有架构或跨词表 Encoder–Decoder 都更好。&lt;/p&gt;
&lt;h2&gt;07 绑定后梯度为何来自两条路径？&lt;/h2&gt;
&lt;p&gt;对共享行 $E_v$：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial E_v}=
+\left.\frac{\partial L}{\partial E_v}\right|&lt;em&gt;{input}
+\left.\frac{\partial L}{\partial E_v}\right|&lt;/em&gt;{output}
$$&lt;/p&gt;
&lt;p&gt;输入项只来自 batch 中实际出现的 $v$；输出项来自每个有效位置的 $V$ 类交叉熵，因为即使 $v$ 不是目标，其预测概率也影响梯度。这意味着绑定后“未作为输入出现的行”仍可能通过输出端更新。&lt;/p&gt;
&lt;h2&gt;08 用 PyTorch 2.14 正确实现&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 官方 &lt;code&gt;nn.Embedding(num_embeddings, embedding_dim, padding_idx=...)&lt;/code&gt; 接受任意形状整数索引，并在末尾追加 &lt;code&gt;embedding_dim&lt;/code&gt;。&lt;code&gt;padding_idx&lt;/code&gt; 行默认不从&lt;strong&gt;Embedding 查表路径&lt;/strong&gt;获得梯度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch.nn.functional as F

class TiedTokenIO(nn.Module):
    def __init__(self, vocab_size: int, d_model: int, pad_id: int):
        super().__init__()
        self.token_embedding = nn.Embedding(vocab_size, d_model, padding_idx=pad_id)
        self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
        self.lm_head.weight = self.token_embedding.weight  # 同一个 Parameter

    def forward(self, input_ids, hidden_fn):
        x = self.token_embedding(input_ids)  # [N,L,D]
        hidden = hidden_fn(x)                # [N,L,D]
        return self.lm_head(hidden)           # [N,L,V]

model = TiedTokenIO(vocab_size=32_000, d_model=768, pad_id=0)
assert model.lm_head.weight is model.token_embedding.weight
assert model.token_embedding.weight.shape == (32_000, 768)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先初始化再绑定，避免后续分别初始化两个模块而意外改写共享参数。建立 optimizer 前完成绑定，并用对象身份和 &lt;code&gt;data_ptr()&lt;/code&gt; 检查，不要只比较数值相等。&lt;/p&gt;
&lt;h2&gt;09 一个完整训练步怎样检查？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ids = torch.tensor([[2, 7, 9, 0]], dtype=torch.long)  # [N,L]
labels = ids.clone()
labels[ids == 0] = -100
logits = model(ids, transformer)                       # [N,L,V]
loss = F.cross_entropy(
    logits[:, :-1].reshape(-1, logits.size(-1)),
    labels[:, 1:].reshape(-1),
    ignore_index=-100,
)
optimizer.zero_grad(set_to_none=True)
loss.backward()
assert model.token_embedding.weight.grad is model.lm_head.weight.grad
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查 id 范围、dtype、形状与右移方向。若 &lt;code&gt;max(input_ids) &gt;= V&lt;/code&gt;，错误在 tokenizer—checkpoint 契约，不该用取模“修复”。&lt;/p&gt;
&lt;h2&gt;10 &lt;code&gt;padding_idx&lt;/code&gt;、稀疏梯度和 &lt;code&gt;max_norm&lt;/code&gt; 的边界&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;padding_idx&lt;/code&gt; 适合让 padding 查表向量稳定，但 attention mask 与 loss mask 仍不可省；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sparse=True&lt;/code&gt; 可让查表梯度稀疏，但只有部分 optimizer 支持，而且绑定到稠密 LM Head 后输出路径本来就是稠密的；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_norm&lt;/code&gt; 会在 forward 中原地重整被访问行。若 forward 前还对 &lt;code&gt;weight&lt;/code&gt; 做可微操作，可能触发 autograd 版本错误；&lt;/li&gt;
&lt;li&gt;重复 id 的梯度会累加，分布式训练中还要确认稀疏参数通信方案。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;权重绑定的大型语言模型通常使用稠密梯度；不要因为“Embedding 是查表”就默认 &lt;code&gt;sparse=True&lt;/code&gt; 一定省总成本。&lt;/p&gt;
&lt;h2&gt;11 tokenizer 与模型怎样一起扩词表？&lt;/h2&gt;
&lt;p&gt;增加特殊 token 不是只在 tokenizer 末尾追加字符串。若旧词表 $V$ 扩到 $V+k$：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;固定旧 token 的 id，不要重排；&lt;/li&gt;
&lt;li&gt;把 $E[V,D]$ 扩成 &lt;code&gt;[V+k,D]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;初始化新增行并保持 LM Head 绑定；&lt;/li&gt;
&lt;li&gt;更新配置中的 &lt;code&gt;vocab_size&lt;/code&gt; 与特殊 id；&lt;/li&gt;
&lt;li&gt;检查 checkpoint 加载缺失行、optimizer state 与服务端 tokenizer 哈希。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;随机新行一开始既不会正确读入新 token，也不会正确输出它。应给新增 token 足够训练数据，或用组成它的旧子词向量初始化，再验证实际收益。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状                    | 常见原因                     | 最短检查                   |
| ----------------------- | ---------------------------- | -------------------------- |
| &lt;code&gt;index out of range&lt;/code&gt;    | tokenizer $V$ 大于模型词表   | 打印 min/max id 与权重形状 |
| 参数量没有减少          | 复制了数值而非共享 Parameter | 比较 &lt;code&gt;is&lt;/code&gt; 与 &lt;code&gt;data_ptr()&lt;/code&gt;  |
| 两表训练后不再相等      | 绑定发生在 optimizer 之后    | 检查参数列表中的对象身份   |
| PAD 行仍变化            | 绑定后的输出梯度             | 分开记录输入/输出梯度贡献  |
| 稀有 token 几乎不学习   | 数据频次低、切分过细         | 统计每行访问数与梯度范数   |
| logits 显存爆炸         | &lt;code&gt;[N,L,V]&lt;/code&gt; 过大               | 分块 loss 或词表并行分析   |
| 换 tokenizer 后语义崩坏 | 相同 id 指向不同 token       | 比对 tokenizer 哈希和映射  |&lt;/p&gt;
&lt;h2&gt;13 失败场景与相近表示&lt;/h2&gt;
&lt;p&gt;静态 token 行并不等于上下文语义：“银行”的输入行固定，但 Transformer 后的 hidden 会随“河岸/金融”上下文变化。Embedding 也无法自动处理 tokenizer 切得过碎、训练语料偏差或新 token 数据不足。&lt;/p&gt;
&lt;p&gt;One-hot 没有学习参数且维度为 $V$；Embedding 是其低维可学习投影。位置表示表达顺序而非 token 身份。预训练词向量可初始化表，却未必匹配新 tokenizer。Adaptive Softmax、采样 softmax 或词表并行主要缓解大 $V$ 输出成本，不等同于输入查表，也不必与权重绑定同时使用。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;token id 是地址；&lt;code&gt;E[I]&lt;/code&gt; 把 &lt;code&gt;[N,L]&lt;/code&gt; 变成 &lt;code&gt;[N,L,D]&lt;/code&gt;，数学上等价于 one-hot 乘表。&lt;/li&gt;
&lt;li&gt;重复 id 对同一行的梯度做加法累积，未访问行在纯输入路径上为零。&lt;/li&gt;
&lt;li&gt;LM Head 用 &lt;code&gt;[V,D]&lt;/code&gt; 权重把 hidden 投到词表 logits；权重绑定令它与输入表共享 Parameter。&lt;/li&gt;
&lt;li&gt;绑定节省 $VD$ 参数并让一行同时学习“怎样读”和“怎样被预测”，也带来 PAD 梯度与版本契约等工程细节。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 id &lt;code&gt;[2,2,3]&lt;/code&gt; 和三个二维上游梯度，手算完整 &lt;code&gt;[V,D]&lt;/code&gt; 梯度，并用 &lt;code&gt;nn.Embedding&lt;/code&gt; 验证。&lt;/li&gt;
&lt;li&gt;取 $V=4,D=2$ 的共享表和一个 hidden，手算四个 logits、softmax 与目标 token 的交叉熵梯度。&lt;/li&gt;
&lt;li&gt;比较 $V\in{32k,64k,128k}$、$D=4096$ 时绑定前后的参数、BF16 权重和 Adam 状态内存。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Bengio et al., &lt;a href=&quot;https://www.jmlr.org/papers/v3/bengio03a.html&quot;&gt;A Neural Probabilistic Language Model&lt;/a&gt;，以可学习分布式词表示构造神经语言模型。&lt;/li&gt;
&lt;li&gt;Mikolov et al., &lt;a href=&quot;https://arxiv.org/abs/1301.3781&quot;&gt;Efficient Estimation of Word Representations in Vector Space&lt;/a&gt;，高效学习词向量。&lt;/li&gt;
&lt;li&gt;Press &amp;#x26; Wolf, &lt;a href=&quot;https://arxiv.org/abs/1608.05859&quot;&gt;Using the Output Embedding to Improve Language Models&lt;/a&gt;，系统研究输出表示与权重绑定。&lt;/li&gt;
&lt;li&gt;Inan et al., &lt;a href=&quot;https://arxiv.org/abs/1611.01462&quot;&gt;Tying Word Vectors and Word Classifiers&lt;/a&gt;，从损失框架解释输入输出共享。&lt;/li&gt;
&lt;li&gt;Grave et al., &lt;a href=&quot;https://arxiv.org/abs/1609.04309&quot;&gt;Efficient Softmax Approximation for GPUs&lt;/a&gt;，面向大词表的 Adaptive Softmax。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;现在文本已经经历 tokenizer、Embedding、Transformer 与 LM Head，形成完整 next-token 训练闭环。但不同来源语料该按什么比例进入训练、重复样本为何会造成记忆与评测污染，仍决定模型究竟学到什么。下一篇将研究预训练数据去重、数据混合权重与按 token 采样。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>词表装不下所有单词怎么办？BPE 的高频合并、字节回退与长度代价</title><link>https://zwjcode.cn/blog/bpe-subword-tokenization-merge-rules-byte-fallback</link><guid isPermaLink="true">https://zwjcode.cn/blog/bpe-subword-tokenization-merge-rules-byte-fallback</guid><description>从整词词表的未知词问题出发，手算 BPE 合并与编码顺序，解释字节级覆盖、词表—序列长度权衡，并用 Tokenizers 0.23.2 构建可复现流水线。</description><pubDate>Thu, 10 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把 token 序列错开一位，构造了因果语言模型的 next-token loss。但“机器学习”究竟是 1 个 token、4 个汉字 token，还是 12 个 UTF-8 字节 token，并不是损失函数决定的。整词词表会把罕见词和新名字压成 &lt;code&gt;&amp;#x3C;unk&gt;&lt;/code&gt;；纯字符或纯字节虽然总能覆盖输入，又会把序列拉长。&lt;/p&gt;
&lt;p&gt;字节对编码（Byte-Pair Encoding，BPE）在两者之间学习合并规则：高频相邻单元逐步合成较长 token，低频字符串仍由短单元拼出。本文只追踪三个问题：&lt;strong&gt;合并规则怎样学出、编码时为何必须按规则顺序应用，以及字节级初始字母表怎样用长度换开放词表覆盖。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 整词与字符为什么都不理想？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;整词：  [relearner] ─► &amp;#x3C;unk&gt;        细节丢失
字符：  r e l e a r n e r          永不未知，序列很长
BPE：   re learn er                 复用常见片段
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设词表大小 $V$、模型宽度 $D$、序列长度 $L$。Embedding 至少有 $VD$ 个参数；全注意力通常随 $L^2$ 增长；输出 softmax 又要产生 &lt;code&gt;[N,L,V]&lt;/code&gt;。分词器是在参数、计算、覆盖率和复用之间取舍，不存在孤立的“最佳词表大小”。&lt;/p&gt;
&lt;h2&gt;02 tokenizer 是流水线，不只是 BPE&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原始文本
  │ normalizer：Unicode、大小写策略
  ▼
规范化文本
  │ pre-tokenizer：空格、标点或字节边界
  ▼
预分片 + 原文 offsets
  │ BPE model：按有序 merges 切成子词
  ▼
token strings ─► vocabulary ─► token ids [L]
  │ post-processor：BOS/EOS
  ▼
input_ids / attention_mask / offsets
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;normalizer、pre-tokenizer、merge 顺序、特殊 token 及其 id、decoder 都是模型输入契约。&lt;/p&gt;
&lt;h2&gt;03 BPE 训练到底在做什么？&lt;/h2&gt;
&lt;p&gt;把词拆成初始单元，并用 &lt;code&gt;&amp;#x3C;/w&gt;&lt;/code&gt; 标记词尾：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&quot;low&quot;   × 2  -&gt;  l o w &amp;#x3C;/w&gt;
&quot;lower&quot; × 1  -&gt;  l o w e r &amp;#x3C;/w&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;| 相邻对      | 频次 |
| ----------- | ---: |
| &lt;code&gt;(l, o)&lt;/code&gt;    |    3 |
| &lt;code&gt;(o, w)&lt;/code&gt;    |    3 |
| &lt;code&gt;(w, &amp;#x3C;/w&gt;)&lt;/code&gt; |    2 |
| &lt;code&gt;(w, e)&lt;/code&gt;    |    1 |
| &lt;code&gt;(e, r)&lt;/code&gt;    |    1 |&lt;/p&gt;
&lt;p&gt;选 &lt;code&gt;(l,o)&lt;/code&gt; 后重写语料为 &lt;code&gt;lo w ...&lt;/code&gt;，再统计；下一轮 &lt;code&gt;(lo,w)&lt;/code&gt; 得到 &lt;code&gt;low&lt;/code&gt;。经典 BPE 每轮贪心选择：&lt;/p&gt;
&lt;p&gt;$$
(a^&lt;em&gt;,b^&lt;/em&gt;)=\arg\max_{(a,b)}\sum_{u\in\mathcal C}c(u)n_{ab}(u)
$$&lt;/p&gt;
&lt;p&gt;$\mathcal C$ 是预分片集合，$c(u)$ 是频次，$n_{ab}(u)$ 是相邻对出现数。它不直接最小化语言模型 loss，也不保证全局最优。&lt;/p&gt;
&lt;h2&gt;04 编码为何必须遵守 merge rank？&lt;/h2&gt;
&lt;p&gt;假设规则依次是 &lt;code&gt;l+o→lo&lt;/code&gt;、&lt;code&gt;lo+w→low&lt;/code&gt;、&lt;code&gt;e+r→er&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[l,o,w,e,r,&amp;#x3C;/w&gt;]
 └ rank 0 ─► [lo,w,e,r,&amp;#x3C;/w&gt;]
                └ rank 1 ─► [low,e,r,&amp;#x3C;/w&gt;]
                                  └ rank 2 ─► [low,er,&amp;#x3C;/w&gt;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编码依据训练所得 rank，不会重新统计测试文本，也不是任意最长匹配。重叠规则若应用次序不同，结果就可能不同。&lt;/p&gt;
&lt;h2&gt;05 教学版训练器与输入输出&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import Counter

def pair_counts(corpus):
    counts = Counter()
    for symbols, frequency in corpus.items():
        for pair in zip(symbols, symbols[1:]):
            counts[pair] += frequency
    return counts

def merge_pair(corpus, chosen):
    merged = {}
    left, right = chosen
    for symbols, frequency in corpus.items():
        out, i = [], 0
        while i &amp;#x3C; len(symbols):
            if i + 1 &amp;#x3C; len(symbols) and symbols[i:i+2] == chosen:
                out.append(left + right); i += 2
            else:
                out.append(symbols[i]); i += 1
        merged[tuple(out)] = merged.get(tuple(out), 0) + frequency
    return merged

def train_bpe(word_counts, num_merges):
    corpus = {tuple(w) + (&apos;&amp;#x3C;/w&gt;&apos;,): f for w, f in word_counts.items()}
    merges = []
    for _ in range(num_merges):
        counts = pair_counts(corpus)
        if not counts: break
        chosen = min(counts, key=lambda p: (-counts[p], p))
        merges.append(chosen)
        corpus = merge_pair(corpus, chosen)
    return merges, corpus

merges, corpus = train_bpe({&apos;low&apos;: 2, &apos;lower&apos;: 1}, 3)
assert merges[:2] == [(&apos;l&apos;, &apos;o&apos;), (&apos;lo&apos;, &apos;w&apos;)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入是“片段到频次”，输出是有序 &lt;code&gt;merges&lt;/code&gt; 与最终切分。固定同频 tie-break 才能复现。真实实现用优先队列增量更新计数，但应先用这个小版本核对语义。&lt;/p&gt;
&lt;h2&gt;06 编码器也要可检查&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def encode_symbols(symbols, merges):
    ranks = {pair: rank for rank, pair in enumerate(merges)}
    pieces = list(symbols)
    while len(pieces) &gt; 1:
        candidates = [(ranks[(a,b)], i, a+b)
                      for i, (a,b) in enumerate(zip(pieces, pieces[1:]))
                      if (a,b) in ranks]
        if not candidates: break
        _, i, merged = min(candidates)
        pieces[i:i+2] = [merged]
    return pieces
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试训练中词、新词、空串、标点与非拉丁字符。若 normalizer 会小写或规范化，&lt;code&gt;decode(encode(text))&lt;/code&gt; 只能对规范化文本保证可逆。&lt;/p&gt;
&lt;h2&gt;07 字符 BPE 为什么仍可能 &lt;code&gt;&amp;#x3C;unk&gt;&lt;/code&gt;？&lt;/h2&gt;
&lt;p&gt;训练没见过 &lt;code&gt;Ω&lt;/code&gt;，字符级初始字母表便没有可分解起点。字节级 BPE（Byte-Level BPE）从 256 个字节开始，任何 UTF-8 文本都可表示：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&quot;学&quot; ─UTF-8─► [0xE5,0xAD,0xA6]
              │ 高频时再逐步 merge
              ▼
          1 个合并 token，或 3 个基础 byte token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它消除普通文本未知字符，却会让罕见脚本或乱码产生更长序列。英文字母常占 1 字节，常用汉字通常占 3 字节，“字节级”不等于“字符级”。&lt;/p&gt;
&lt;h2&gt;08 用 Tokenizers 0.23.2 落地&lt;/h2&gt;
&lt;p&gt;官方当前稳定版文档为 0.23.2。&lt;code&gt;BpeTrainer&lt;/code&gt; 的核心参数包括 &lt;code&gt;vocab_size&lt;/code&gt;、&lt;code&gt;min_frequency&lt;/code&gt;、&lt;code&gt;special_tokens&lt;/code&gt; 与 &lt;code&gt;initial_alphabet&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers

tokenizer = Tokenizer(models.BPE(unk_token=&apos;&amp;#x3C;unk&gt;&apos;))
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tokenizer.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(
    vocab_size=32_000,
    min_frequency=2,
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
    special_tokens=[&apos;&amp;#x3C;unk&gt;&apos;, &apos;&amp;#x3C;pad&gt;&apos;, &apos;&amp;#x3C;bos&gt;&apos;, &apos;&amp;#x3C;eos&gt;&apos;],
)

def batches():
    yield [&apos;第一批文本&apos;, &apos;the first batch&apos;]
    yield [&apos;第二批文本&apos;, &apos;identifier: zxq_42&apos;]

tokenizer.train_from_iterator(batches(), trainer=trainer)
encoding = tokenizer.encode(&apos;机器学习 zxq_42&apos;)
print(encoding.tokens)   # list[str]，长度 L
print(encoding.ids)      # list[int]，元素在 [0,V)
print(encoding.offsets)  # 回指原文区间
tokenizer.save(&apos;tokenizer.json&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特殊 token 列表顺序影响 id。&lt;code&gt;tokenizer.json&lt;/code&gt; 必须与模型 checkpoint 一同版本化；只记录 $V$ 无法复现输入。&lt;/p&gt;
&lt;h2&gt;09 normalization、offsets 与长度预算&lt;/h2&gt;
&lt;p&gt;Unicode 规范化会减少表面变体，也可能合并本应区分的标识符、破坏代码，或令字符标注错位。高亮和序列标注应使用 offsets 回指原文，不要用 token 字符串猜位置。&lt;/p&gt;
&lt;p&gt;| 指标                | 太小词表       | 太大词表             |
| ------------------- | -------------- | -------------------- |
| tokens / byte       | 序列长         | 通常更短             |
| 稀有片段            | 共享短单元     | 许多词表行几乎不更新 |
| Embedding 参数 $VD$ | 少             | 多                   |
| logits &lt;code&gt;[N,L,V]&lt;/code&gt;    | $V$ 小、$L$ 大 | $L$ 小、每位置分类贵 |
| 低资源语言          | 可能被切得很碎 | 取决于语料和词表配额 |&lt;/p&gt;
&lt;p&gt;应按语言统计 tokens/byte 的 50、95、99 分位数，并在固定计算预算下比较验证 loss、吞吐与显存。不同 tokenizer 的每-token perplexity 分母不同，不能直接比较。&lt;/p&gt;
&lt;h2&gt;10 上线前验证与常见错误&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;冻结覆盖空白、emoji、URL、数字、代码和多语言的 golden strings；&lt;/li&gt;
&lt;li&gt;比较保存前后 tokens、ids、offsets 与 decode；&lt;/li&gt;
&lt;li&gt;核对特殊 id 与模型配置；&lt;/li&gt;
&lt;li&gt;扫描 &lt;code&gt;&amp;#x3C;unk&gt;&lt;/code&gt; 率、长度尾部和被截断的文本侧；&lt;/li&gt;
&lt;li&gt;比较 &lt;code&gt;tokenizer.json&lt;/code&gt; 哈希，禁止服务端悄悄替换。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;| 症状               | 常见原因                     | 最短检查                 |
| ------------------ | ---------------------------- | ------------------------ |
| 线上 id 改变       | merge/normalizer 版本错配    | 比较 tokenizer 哈希      |
| 新文字大量 &lt;code&gt;&amp;#x3C;unk&gt;&lt;/code&gt; | 初始字母表不覆盖             | 打印码点或启用字节级     |
| 中文/代码异常长    | 语料偏科、预分片不当         | 分语言统计 tokens/byte   |
| decode 空格错误    | ByteLevel decoder 不匹配     | 测 &lt;code&gt;add_prefix_space&lt;/code&gt;    |
| 标注位置漂移       | 用 token 文本猜原位          | 检查 offsets 及其单位    |
| loss 忽然“变好”    | 更换 tokenizer 后按 token 比 | 改报 byte-normalized NLL |&lt;/p&gt;
&lt;h2&gt;11 失败场景与相近方法&lt;/h2&gt;
&lt;p&gt;BPE 偏向高频表面片段，不保证 token 对应语素；语料失衡会让低资源语言承担更长上下文成本。重复日志和分隔符还会污染词表。&lt;/p&gt;
&lt;p&gt;WordPiece 常用似然增益式打分；Unigram 从较大候选集出发删除片段并可保留多种切分；字符 tokenizer 词表小但序列长；词级 tokenizer 序列短却难处理开放词表。它们只产生离散 id，并未赋予连续语义。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;BPE 反复合并高频相邻对，得到&lt;strong&gt;有顺序&lt;/strong&gt;的 merge 规则。&lt;/li&gt;
&lt;li&gt;编码按 rank 应用规则；vocab、merges 与规范化流水线缺一不可。&lt;/li&gt;
&lt;li&gt;字节级字母表覆盖任意 UTF-8 输入，但罕见文本可能变长。&lt;/li&gt;
&lt;li&gt;tokenizer 改变 $L$、$V$、成本和 loss 分母，是模型版本的一部分。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 &lt;code&gt;aaab×2&lt;/code&gt;、&lt;code&gt;aaac×1&lt;/code&gt; 手算前两轮 BPE；同频 tie-break 改变会怎样？&lt;/li&gt;
&lt;li&gt;找一个 emoji，比较 Python 字符长度与 UTF-8 字节数。&lt;/li&gt;
&lt;li&gt;训练 $V\in{2k,8k,32k}$ 三个 tokenizer，分语言比较 tokens/byte 的 50、95、99 分位数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Gage, &lt;a href=&quot;http://www.pennelynn.com/Documents/CUJ/HTML/94HTML/19940045.HTM&quot;&gt;A New Algorithm for Data Compression&lt;/a&gt;，早期字节对压缩算法。&lt;/li&gt;
&lt;li&gt;Sennrich et al., &lt;a href=&quot;https://arxiv.org/abs/1508.07909&quot;&gt;Neural Machine Translation of Rare Words with Subword Units&lt;/a&gt;，将 BPE 用于开放词表翻译。&lt;/li&gt;
&lt;li&gt;Wu et al., &lt;a href=&quot;https://arxiv.org/abs/1609.08144&quot;&gt;Google&apos;s Neural Machine Translation System&lt;/a&gt;，大规模 WordPiece 系统。&lt;/li&gt;
&lt;li&gt;Kudo, &lt;a href=&quot;https://arxiv.org/abs/1804.10959&quot;&gt;Subword Regularization&lt;/a&gt;，用多种切分增强鲁棒性。&lt;/li&gt;
&lt;li&gt;Wang et al., &lt;a href=&quot;https://arxiv.org/abs/1909.03341&quot;&gt;Neural Machine Translation with Byte-Level Subwords&lt;/a&gt;，字节级子词研究。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;BPE 已把文本变成 &lt;code&gt;[N,L]&lt;/code&gt; 整数 id，但 Transformer 不能直接在“编号 42”上计算语义。下一篇将拆解 token Embedding 的查表与梯度累加，并解释为什么输入 Embedding 和输出 LM Head 常绑定为同一张 &lt;code&gt;[V,D]&lt;/code&gt; 权重矩阵。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一句文本怎样变成训练信号？因果语言模型的标签右移与 Next-Token Loss</title><link>https://zwjcode.cn/blog/causal-language-model-next-token-loss-label-shift</link><guid isPermaLink="true">https://zwjcode.cn/blog/causal-language-model-next-token-loss-label-shift</guid><description>从原始 token 序列出发，手算标签错位与交叉熵，追踪 logits、padding 和文档边界，并用 PyTorch 2.14 写出可检查的语言模型训练步。</description><pubDate>Thu, 10 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用滑动窗口、块稀疏与全局 token 规定了长序列中“谁能读谁”。但一个注意力结构即使信息边完全正确，也不会自己知道该学什么。给定“机器 学习 很 有趣”，训练代码必须把每个前缀变成监督信号：看到“机器”预测“学习”，看到“机器 学习”预测“很”。&lt;/p&gt;
&lt;p&gt;这就是因果语言建模（Causal Language Modeling, CLM）的下一个 token 预测（Next-Token Prediction）。本文只追踪一条主线：&lt;strong&gt;同一条 token 序列怎样错开一位成为输入与标签，模型输出的 &lt;code&gt;[N,L,V]&lt;/code&gt; logits 又怎样变成一个可反传的交叉熵标量。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 语言模型的训练样本到底是什么？&lt;/h2&gt;
&lt;p&gt;设词表大小为 $V$，一段文本经分词器得到 $x_0,x_1,\ldots,x_{L-1}$。自回归分解（Autoregressive Factorization）把联合概率写成：&lt;/p&gt;
&lt;p&gt;$$
p(x_0,\ldots,x_{L-1})=\prod_{t=0}^{L-1}p(x_t\mid x_{&amp;#x3C;t})
$$&lt;/p&gt;
&lt;p&gt;若序列前放置开头符 &lt;code&gt;&amp;#x3C;BOS&gt;&lt;/code&gt;，训练对齐就是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;模型输入 input_ids:  [BOS, 机器, 学习, 很, 有趣]
监督标签 labels:     [机器, 学习, 很, 有趣, EOS]
                         ↑ 每一列预测右边那个 token

完整 tokens: [BOS, 机器, 学习, 很, 有趣, EOS]  长度 6
shift_logits: 位置 [0, 1, 2, 3, 4]              长度 5
shift_labels: id   [机器, 学习, 很, 有趣, EOS]   长度 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后一个输入位置没有序列内的下一个 token，第一个 token 也没有更早位置产生的预测，所以有效配对数是 $L-1$。&lt;/p&gt;
&lt;h2&gt;02 因果掩码与标签右移解决不同问题&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;因果掩码（Causal Mask）限制隐藏状态 $h_t$ 只能由 $x_{\le t}$ 计算，防止读取未来；&lt;/li&gt;
&lt;li&gt;标签右移（Label Shift）规定 $h_t$ 的输出要与 $x_{t+1}$ 比较。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input_ids [N,L] ─► embedding [N,L,D] ─► causal Transformer
                                                │
                                                ▼
hidden [N,L,D] ─► linear W [V,D] ─► logits [N,L,V]
                                                │ shift one step
                                                ▼
                                         labels [N,L]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有右移而没有因果 mask，$h_t$ 可偷看 $x_{t+1}$；只有因果 mask 而标签不右移，模型会学习复制当前位置。二者都正确，训练目标才和部署时“只给前缀、生成未来”一致。&lt;/p&gt;
&lt;h2&gt;03 logits 为什么不要先做 softmax？&lt;/h2&gt;
&lt;p&gt;模型对位置 $t$ 输出未归一化分数 $z_t\in\mathbb R^V$。目标 token 为 $y_t$ 时：&lt;/p&gt;
&lt;p&gt;$$
\ell_t=-\log\frac{\exp(z_{t,y_t})}{\sum_{v=0}^{V-1}\exp(z_{t,v})}
=-z_{t,y_t}+\log\sum_v\exp(z_{t,v})
$$&lt;/p&gt;
&lt;p&gt;第二种写法可用 LogSumExp 稳定计算。PyTorch 2.14 当前官方 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.cross_entropy.html&quot;&gt;&lt;code&gt;torch.nn.functional.cross_entropy&lt;/code&gt;&lt;/a&gt; 直接接收 logits；若先 &lt;code&gt;softmax&lt;/code&gt; 再传入，函数会把概率误当 logits，数值稳定性和梯度都会变差。&lt;/p&gt;
&lt;p&gt;对有效位置集合 $\mathcal T$，$\mathcal L=|\mathcal T|^{-1}\sum_{(n,t)\in\mathcal T}\ell_{n,t}$。分母是&lt;strong&gt;有效 token 数&lt;/strong&gt;，不是固定的 $N(L-1)$；padding 或边界位置被忽略后，分母也要改变。&lt;/p&gt;
&lt;h2&gt;04 用三个词和三个 logits 手算一次&lt;/h2&gt;
&lt;p&gt;假设词表为 &lt;code&gt;[甲, 乙, 丙]&lt;/code&gt;，某位置的 logits 为 $z=[2,1,0]$，目标 $y=\text{乙}=1$。归一化项为：&lt;/p&gt;
&lt;p&gt;$$
e^2+e^1+e^0\approx7.389+2.718+1=11.107
$$&lt;/p&gt;
&lt;p&gt;目标概率约为 $2.718/11.107=0.245$，损失 $-\log(0.245)\approx1.408$。梯度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial\ell}{\partial z_v}=p_v-\mathbb 1(v=y)
\approx[0.665,-0.755,0.090]
$$&lt;/p&gt;
&lt;p&gt;梯度下降会提高“乙”的 logit，压低另外两项。若完全均匀猜测，loss 为 $\log V$；$V=50,000$ 时约为 10.82。这是有用的初始化基线：一开始远低于它，要检查标签泄漏；远高于它，要检查 logit 尺度和数值异常。&lt;/p&gt;
&lt;h2&gt;05 &lt;code&gt;[N,L,V]&lt;/code&gt; 怎样交给 Cross-Entropy？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F

def causal_lm_loss(
    logits: torch.Tensor,       # [N,L,V]，未 softmax
    token_ids: torch.Tensor,    # [N,L]，torch.long
    *,
    ignore_index: int = -100,
) -&gt; torch.Tensor:
    n, length, vocab = logits.shape
    assert token_ids.shape == (n, length)
    assert token_ids.dtype == torch.long

    shift_logits = logits[:, :-1, :].contiguous()  # [N,L-1,V]
    shift_labels = token_ids[:, 1:].contiguous()   # [N,L-1]
    return F.cross_entropy(
        shift_logits.view(-1, vocab),               # [N(L-1),V]
        shift_labels.view(-1),                      # [N(L-1)]
        ignore_index=ignore_index,
        reduction=&apos;mean&apos;,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;view(-1, V)&lt;/code&gt; 前调用 &lt;code&gt;contiguous()&lt;/code&gt;，因为切片后的张量不保证内存连续。也可以用 &lt;code&gt;reshape&lt;/code&gt;，但仍应理解最终把每个有效位置当成一次 $V$ 类分类。&lt;/p&gt;
&lt;h2&gt;06 padding 为什么不能当作普通词来学？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A: [BOS, 甲, 乙, EOS, PAD, PAD]
B: [BOS, 丙, 丁, 戊, 己, EOS]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若直接使用所有 token id，A 会产生“EOS 后预测 PAD、PAD 后预测 PAD”的简单监督。模型被错误训练，长 padding 样本还会获得不成比例的权重。应把无需预测的位置替换成 &lt;code&gt;ignore_index&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def labels_from_attention_mask(input_ids, attention_mask):
    # input_ids/attention_mask: [N,L]；1 表示真实 token
    labels = input_ids.clone()
    labels[attention_mask == 0] = -100
    return labels

labels = labels_from_attention_mask(input_ids, attention_mask)
loss = causal_lm_loss(logits, labels)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入侧 padding attention mask 仍要交给 Transformer，阻止真实 Query 读取 padding Key。&lt;strong&gt;注意力 mask 控制信息流，loss mask 控制计分位置，两者不能替代。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;07 拼接文档时，边界是否应该产生监督？&lt;/h2&gt;
&lt;p&gt;数据管道常把文档拼接再切成定长 block。若没有边界符：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;文档 A 末尾 ... a_last | b_first ... 文档 B 开头
                        ↑ 被迫学习 p(b_first | A)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;至少应插入 &lt;code&gt;&amp;#x3C;EOS&gt;&lt;/code&gt;。是否训练 &lt;code&gt;&amp;#x3C;EOS&gt; → 下一文档首 token&lt;/code&gt; 取决于语义：连续章节可能允许跨块连续；无关网页通常应重置注意力，或忽略跨文档标签；packed sequence 中不同样本还必须使用块对角因果 mask，避免互相偷看。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def mask_cross_document_targets(labels, document_ids):
    # 两者 [N,L]；labels[:,t+1] 是 logits[:,t] 的目标
    labels = labels.clone()
    crosses = document_ids[:, 1:] != document_ids[:, :-1]
    labels[:, 1:][crosses] = -100
    return labels
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若保留 &lt;code&gt;&amp;#x3C;EOS&gt;&lt;/code&gt; 预测，要先决定忽略 &lt;code&gt;a_last→EOS&lt;/code&gt; 还是 &lt;code&gt;EOS→b_first&lt;/code&gt;，不要用一句“屏蔽边界”掩盖方向差异。&lt;/p&gt;
&lt;h2&gt;08 一个最小因果语言模型训练步&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch import nn

class TinyCausalLM(nn.Module):
    def __init__(self, vocab_size: int, d_model: int, nhead: int):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        layer = nn.TransformerEncoderLayer(
            d_model=d_model, nhead=nhead,
            dim_feedforward=4 * d_model,
            batch_first=True, norm_first=True,
        )
        self.blocks = nn.TransformerEncoder(layer, num_layers=2)
        self.lm_head = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, input_ids: torch.Tensor) -&gt; torch.Tensor:
        hidden = self.embedding(input_ids)             # [N,L,D]
        length = input_ids.size(1)
        causal = nn.Transformer.generate_square_subsequent_mask(
            length, device=input_ids.device
        )                                              # [L,L]
        hidden = self.blocks(hidden, mask=causal, is_causal=True)
        return self.lm_head(hidden)                     # [N,L,V]

model = TinyCausalLM(vocab_size=1000, d_model=128, nhead=4)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
logits = model(input_ids)
labels = labels_from_attention_mask(input_ids, attention_mask)
loss = causal_lm_loss(logits, labels)                  # scalar []
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实模型还要加入位置表示，并把 padding mask 传入 block；此处省略是为了突出 loss，不能直接当作 packed-batch 生产实现。&lt;/p&gt;
&lt;h2&gt;09 从语料到更新的完整伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;for documents in corpus:
    ids = tokenize(documents)
    ids = insert_boundary_tokens(ids)
    blocks, attention_mask, document_ids = pack(ids, max_length=L)

    logits = causal_transformer(blocks)          # [N,L,V]
    labels = copy(blocks)                        # [N,L]
    labels[padding_targets] = IGNORE
    labels[cross_document_targets] = IGNORE

    loss = CE(logits[:, :-1, :], labels[:, 1:])
    backward(loss)
    clip_gradients()
    optimizer_step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;相同模型、相同 token 数，如果 block 长度、边界处理、重复数据和有效 token 比例不同，学到的条件分布也会不同。&lt;/p&gt;
&lt;h2&gt;10 困惑度该怎样从 loss 得到？&lt;/h2&gt;
&lt;p&gt;若 loss 是按 token 平均的自然对数负似然，$\mathrm{PPL}=\exp(\mathcal L)$。loss 为 2 时困惑度约 7.39。它可理解为每一步的“有效候选规模”，但不能跨不同 tokenizer 直接比较。&lt;/p&gt;
&lt;p&gt;跨 batch 也不能简单平均各 batch 的均值。应累加未约简 loss 与有效 token 数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;token_loss = F.cross_entropy(
    shift_logits.reshape(-1, vocab),
    shift_labels.reshape(-1),
    ignore_index=-100,
    reduction=&apos;sum&apos;,
)
valid_tokens = (shift_labels != -100).sum()

# 全部 batch 累加后
mean_nll = total_token_loss / total_valid_tokens
perplexity = torch.exp(mean_nll)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;否则 padding 较多的小 batch 会和有效 token 很多的大 batch 获得相同权重。&lt;/p&gt;
&lt;h2&gt;11 怎样调试标签错位而不是猜？&lt;/h2&gt;
&lt;p&gt;构造递增 id &lt;code&gt;[10, 11, 12, 13]&lt;/code&gt;，要求逐位置目标必须是 &lt;code&gt;[11, 12, 13]&lt;/code&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;打印 &lt;code&gt;input_ids[:, :-1]&lt;/code&gt; 与 &lt;code&gt;labels[:, 1:]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;把正确目标 logit 设为 20、其余为 0，loss 应接近 0；&lt;/li&gt;
&lt;li&gt;扰动未来 token，确认较早位置 logits 不变；&lt;/li&gt;
&lt;li&gt;改变 padding token id，loss 必须不变；&lt;/li&gt;
&lt;li&gt;全零 logits 的 loss 应接近 $\log V$。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def perfect_logits(labels: torch.Tensor, vocab: int):
    n, length = labels.shape
    logits = torch.zeros(n, length - 1, vocab)
    targets = labels[:, 1:].clamp_min(0)
    logits.scatter_(2, targets[..., None], 20.0)
    return logits
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对 &lt;code&gt;-100&lt;/code&gt; 不能直接 scatter；这里 &lt;code&gt;clamp_min(0)&lt;/code&gt; 只负责构造，最终 loss 仍用原始 labels 忽略对应位置。&lt;/p&gt;
&lt;h2&gt;12 常见的“loss 会降，但目标错了”&lt;/h2&gt;
&lt;p&gt;| 错误                            | 表面现象      | 真正后果           |
| ------------------------------- | ------------- | ------------------ |
| logits 与同位置 token 比较      | loss 很快下降 | 学会复制输入       |
| 外部和模型内部各 shift 一次     | 也可能收敛    | 实际预测 $t+2$     |
| 先 softmax 再交叉熵             | 数值仍能运行  | 把概率当 logits    |
| padding 未忽略                  | loss 很漂亮   | 大量学习 PAD 转移  |
| batch loss 直接平均             | 曲线平滑      | 不同长度权重错误   |
| packed 文档共用普通 causal mask | 吞吐高        | 不相关样本泄漏     |
| &lt;code&gt;EOS&lt;/code&gt; 与 &lt;code&gt;PAD&lt;/code&gt; 混用             | 生成异常      | 结束与补齐无法区分 |&lt;/p&gt;
&lt;h2&gt;13 训练与推理为何仍有差异？&lt;/h2&gt;
&lt;p&gt;训练时使用教师强制（Teacher Forcing）：位置 $t$ 总能看到真实历史 $x_{&amp;#x3C;t}$；推理时看到模型自己采样的历史。一次错误会改变后续条件，形成暴露偏差（Exposure Bias）。&lt;/p&gt;
&lt;p&gt;Scheduled Sampling、序列级强化学习等方法试图缩小差异，却各自引入偏差或高方差。标准 next-token loss 的优势是目标明确、容易规模化、梯度稳定；它不保证事实正确、长程规划或人类偏好一致。&lt;/p&gt;
&lt;h2&gt;14 失败场景与相近目标&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;语料包含错误、偏见或重复时，最大似然会拟合这些分布；&lt;/li&gt;
&lt;li&gt;稀有但重要事实只贡献很少 token，平均 loss 对它不敏感；&lt;/li&gt;
&lt;li&gt;多种合理表达只给一条参考时，目标只奖励其中一路；&lt;/li&gt;
&lt;li&gt;长链推理按局部 token 计分，不直接优化最终任务成功；&lt;/li&gt;
&lt;li&gt;低 loss 可能来自记忆训练文本，需去污染测试区分。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;掩码语言模型从双向上下文恢复遮盖 token；序列到序列 Decoder 还条件于源序列；对比学习比较表示的相对相似性；偏好优化比较整段回答。它们都不能简单等同于从左到右 next-token loss。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;因果 mask 决定“能看什么”，标签右移决定“预测什么”。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;[N,L,V]&lt;/code&gt; logits 与右移后的整数标签在有效位置做词表交叉熵。&lt;/li&gt;
&lt;li&gt;padding、文档边界和 packed sequence 要分别处理信息 mask 与 loss mask。&lt;/li&gt;
&lt;li&gt;验证指标必须按有效 token 加权；困惑度只在 tokenizer 和协议一致时可比。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 &lt;code&gt;[BOS, A, B, EOS, PAD]&lt;/code&gt; 写出 &lt;code&gt;shift_logits&lt;/code&gt; 位置、&lt;code&gt;shift_labels&lt;/code&gt; 和有效 mask。若 &lt;code&gt;EOS&lt;/code&gt; 也忽略，模型失去什么能力？&lt;/li&gt;
&lt;li&gt;构造 $V=4$ 的两位置 logits，手算每项 loss、平均 loss 与 perplexity；再屏蔽一项，说明分母变化。&lt;/li&gt;
&lt;li&gt;写单元测试识别“双重 shift”：让位置 $t$ 的最高 logit 指向 $x_{t+1}$，比较正确标签和再右移标签的 loss。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Bengio et al., &lt;a href=&quot;https://www.jmlr.org/papers/v3/bengio03a.html&quot;&gt;A Neural Probabilistic Language Model&lt;/a&gt;，早期神经自回归语言模型。&lt;/li&gt;
&lt;li&gt;Radford et al., &lt;a href=&quot;https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf&quot;&gt;Improving Language Understanding by Generative Pre-Training&lt;/a&gt;，生成式预训练再微调。&lt;/li&gt;
&lt;li&gt;Radford et al., &lt;a href=&quot;https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf&quot;&gt;Language Models are Unsupervised Multitask Learners&lt;/a&gt;，规模化 next-token 训练的任务行为。&lt;/li&gt;
&lt;li&gt;Kaplan et al., &lt;a href=&quot;https://arxiv.org/abs/2001.08361&quot;&gt;Scaling Laws for Neural Language Models&lt;/a&gt;，模型、数据、计算与交叉熵的关系。&lt;/li&gt;
&lt;li&gt;Hoffmann et al., &lt;a href=&quot;https://arxiv.org/abs/2203.15556&quot;&gt;Training Compute-Optimal Large Language Models&lt;/a&gt;，计算预算下参数与 token 配比。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;next-token loss 已把每个位置变成一次 $V$ 类预测，但词表可能有数万到数十万项，Embedding 与输出头随之成为巨大的参数和计算入口。下一篇将拆解子词分词：BPE 怎样从字符对合并出词表、未知词如何被组合，以及 tokenizer 为什么会改变长度、成本与 loss 的可比性。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>长序列为何不必让每个 token 看见全部历史？滑动窗口、块稀疏与全局 token</title><link>https://zwjcode.cn/blog/sparse-attention-sliding-window-block-global-token</link><guid isPermaLink="true">https://zwjcode.cn/blog/sparse-attention-sliding-window-block-global-token</guid><description>从全注意力的平方成本出发，手算稀疏可见图，拆解局部窗口、块布局和全局 token，并用 PyTorch 2.14 验证掩码语义与输出。</description><pubDate>Wed, 09 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把 RoPE 模型的名义窗口扩到了训练外，并强调“能放进 32K token”不等于“能用好 32K token”。即便位置外推可靠，标准自注意力还要为长度为 $L$ 的序列生成 $L\times L$ 个 Query–Key 分数：长度扩大 8 倍，分数矩阵扩大 64 倍。&lt;/p&gt;
&lt;p&gt;稀疏注意力（Sparse Attention）不再让每个 Query 读取所有 Key，而是预先规定一张可见图。本文只讲三种紧密相连的边：滑动窗口保留邻近上下文，块稀疏让布局贴合硬件，全局 token 提供远距离中转站。核心问题不是“怎样把矩阵画得更空”，而是：&lt;strong&gt;哪些信息路径可以删除，仍不破坏任务真正需要的通信？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 全注意力的瓶颈究竟在哪里？&lt;/h2&gt;
&lt;p&gt;设 Query、Key、Value 的形状分别为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Q [N,H,L,d] ─┐
K [N,H,L,d] ─┼─► scores [N,H,L,L] ─► softmax ─► output [N,H,L,d]
V [N,H,L,d] ─┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$N$ 是 batch 大小，$H$ 是头数，$L$ 是序列长度，$d$ 是每头维度。每个头的分数为：&lt;/p&gt;
&lt;p&gt;$$
S_{ij}=\frac{q_i^\top k_j}{\sqrt d}
$$&lt;/p&gt;
&lt;p&gt;计算 $QK^\top$ 约需 $O(L^2d)$ 次运算；若显式保存分数或概率，则激活占用为 $O(L^2)$。FlashAttention 能通过分块重算减少显存读写，却没有把任意一个 $q_i^\top k_j$ 从数学上删除；序列足够长时，平方级计算仍在。&lt;/p&gt;
&lt;p&gt;| 方法        |  允许的 Q–K 边 |   理论边数 | 主要作用           |
| ----------- | -------------: | ---------: | ------------------ | ------------ | -------- |
| 全注意力    |     所有 $i,j$ |      $L^2$ | 任意两点一步通信   |
| 滑动窗口    |              $ |        i-j | \le w$             | 约 $L(2w+1)$ | 局部依赖 |
| 块稀疏      |     选中的块对 | 取决于布局 | 高效执行结构稀疏   |
| 局部 + 全局 | 窗口边与全局边 | 约 $Lw+Lg$ | 局部计算与远程汇聚 |&lt;/p&gt;
&lt;h2&gt;02 稀疏注意力其实是一张有向图&lt;/h2&gt;
&lt;p&gt;定义布尔邻接矩阵 $M\in{0,1}^{L\times L}$。若 Query $i$ 允许读取 Key $j$，则 $M_{ij}=1$。注意力变为：&lt;/p&gt;
&lt;p&gt;$$
A_{ij}=\frac{\exp(S_{ij})M_{ij}}{\sum_{k:M_{ik}=1}\exp(S_{ik})},\qquad
o_i=\sum_{j:M_{ij}=1}A_{ij}v_j
$$&lt;/p&gt;
&lt;p&gt;实现时通常先把不可见位置加上 $-\infty$，再做 softmax。每一行必须至少有一个可见 Key，否则全为 $-\infty$ 的 softmax 会产生 &lt;code&gt;NaN&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;token 是节点；“Query i 能读 Key j”是一条 j ─► i 的信息边。

全注意力：每对节点直接相连
局部窗口：0 ─ 1 ─ 2 ─ 3 ─ 4 ─ 5
局部+全局：0 ════════════════════╗
            └─ 1 ─ 2 ─ 3 ─ 4 ─ 5 ╝  （0 是全局中转站）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;稀疏化改变的不只是速度，也改变归纳偏置（Inductive Bias）：一层能传播到哪里、多层后信息要走几跳、哪个位置承担压缩远程信息的责任，都会变化。&lt;/p&gt;
&lt;h2&gt;03 滑动窗口怎样把平方边数降成线性？&lt;/h2&gt;
&lt;p&gt;双向窗口半径为 $w$ 时，$M_{ij}=\mathbb{1}(|i-j|\le w)$。因果语言模型还必须满足 $j\le i$，所以：&lt;/p&gt;
&lt;p&gt;$$
M_{ij}=\mathbb{1}(0\le i-j\le w)
$$&lt;/p&gt;
&lt;p&gt;每个 Query 最多读取 $w+1$ 个 Key，总边数约为 $L(w+1)$。若 $w$ 固定，复杂度随 $L$ 线性增长。&lt;/p&gt;
&lt;p&gt;但“一层只能看 $w$ 个历史 token”不等于“模型永远只能利用 $w$ 个”。堆叠 $K$ 层时，理论感受野可扩展到约 $Kw$。代价是远程证据要经过多个非线性中间状态，路径更长，也可能被压缩或遗忘。&lt;/p&gt;
&lt;h2&gt;04 用 8 个 token 手算可见图&lt;/h2&gt;
&lt;p&gt;令 $L=8$、因果窗口 $w=2$，token 0 为全局 token。普通 token 能读取合法的全局 Key；全局 Query 仍遵守因果性。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;列是 Key j →   0 1 2 3 4 5 6 7
Query i
0               ● · · · · · · ·
1               ● ● · · · · · ·
2               ● ● ● · · · · ·
3               ● ● ● ● · · · ·
4               ● · ● ● ● · · ·
5               ● · · ● ● ● · ·
6               ● · · · ● ● ● ·
7               ● · · · · ● ● ●
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第 7 行只计算 Key 0、5、6、7，共 4 个分数，而不是 8 个。若全局 token 位于因果序列开头，它不能读取未来，因此只能充当后续位置共享的锚点；双向编码器中的全局 token 才能同时读全序列并被全序列读取。&lt;/p&gt;
&lt;h2&gt;05 为什么还要从 token 稀疏改成块稀疏？&lt;/h2&gt;
&lt;p&gt;逐元素掩码很灵活，却不保证更快。GPU 擅长对连续矩形做矩阵乘法；若先算完整 $L\times L$ 分数再把大部分设为 $-\infty$，计算量仍是全注意力。&lt;/p&gt;
&lt;p&gt;块稀疏注意力（Block-Sparse Attention）把 Query 与 Key 轴切成大小为 $B_q,B_k$ 的块。只有被选中的块对才进入内核：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Key blocks →   K0 K1 K2 K3
Query blocks
Q0              ■  ·  ·  ·
Q1              ■  ■  ·  ·
Q2              ■  ■  ■  ·
Q3              ■  ·  ■  ■

■：执行连续的小矩阵乘法；·：整块跳过
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;块边界会引入粒度误差：块中只要存在少数有效 token，内核可能仍需计算整块，再在块内应用细粒度 mask。块越大，矩阵乘效率通常越好，但多算的无效位置也可能越多。布局应根据长度、窗口和硬件实测，而不是只看理论稀疏率。&lt;/p&gt;
&lt;h2&gt;06 三种边怎样组合成可用模式？&lt;/h2&gt;
&lt;p&gt;常见组合可写成 $\mathcal E=\mathcal E_{local}\cup\mathcal E_{global}\cup\mathcal E_{task}$：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;local&lt;/code&gt; 保留语法、局部视觉纹理或相邻时间步；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;global&lt;/code&gt; 让少量摘要、问题或特殊 token 与所有位置通信；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;task&lt;/code&gt; 由文档段落、图边、检索结果或成对字段决定。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;随机边也能缩短图直径，但可重复性、解释和硬件调度更复杂。设计时先问“任务中的远距离信息通过哪条路径到达”，比先照搬某篇论文的图案可靠。&lt;/p&gt;
&lt;h2&gt;07 先用稠密掩码验证语义&lt;/h2&gt;
&lt;p&gt;下面构造 &lt;code&gt;[L,L]&lt;/code&gt; 布尔矩阵。它适合单元测试，不是长序列性能方案。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

def causal_local_global_mask(
    length: int,
    window: int,
    global_tokens: tuple[int, ...] = (0,),
    *,
    device: torch.device | str | None = None,
) -&gt; torch.Tensor:
    assert length &gt; 0 and window &gt;= 0
    q = torch.arange(length, device=device)[:, None]  # [L,1]
    k = torch.arange(length, device=device)[None, :]  # [1,L]
    causal_local = (k &amp;#x3C;= q) &amp;#x26; ((q - k) &amp;#x3C;= window)     # [L,L]

    is_global_k = torch.zeros(length, dtype=torch.bool, device=device)
    is_global_k[list(global_tokens)] = True
    mask = causal_local | (is_global_k[None, :] &amp;#x26; (k &amp;#x3C;= q))

    for g in global_tokens:       # 全局 Query 也不能读未来
        mask[g] = k[0] &amp;#x3C;= g
    return mask                    # True = 允许参与 SDPA

mask = causal_local_global_mask(8, 2)
assert mask[7].nonzero().flatten().tolist() == [0, 5, 6, 7]
assert not mask[3, 4]
assert mask.any(dim=-1).all()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里特意先写稠密“真值版本”。生产稀疏内核的输出、梯度与可见边都应和它在小尺寸上对齐。&lt;/p&gt;
&lt;h2&gt;08 用 PyTorch 2.14 SDPA 做正确性基线&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 当前官方 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention&quot;&gt;&lt;code&gt;torch.nn.functional.scaled_dot_product_attention&lt;/code&gt;&lt;/a&gt; 接受 &lt;code&gt;[N,H,L,d]&lt;/code&gt;，其布尔 &lt;code&gt;attn_mask&lt;/code&gt; 中 &lt;code&gt;True&lt;/code&gt; 表示&lt;strong&gt;允许&lt;/strong&gt;参与；这与 &lt;code&gt;nn.MultiheadAttention&lt;/code&gt; 的 &lt;code&gt;key_padding_mask&lt;/code&gt; 语义相反。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch.nn.functional as F

def dense_reference(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor):
    # q/k/v: [N,H,L,d]
    length = q.size(-2)
    mask = causal_local_global_mask(length, 128, device=q.device)
    return F.scaled_dot_product_attention(
        q, k, v,
        attn_mask=mask[None, None, :, :],  # [1,1,L,L] 广播
        dropout_p=0.0,
    )                                     # [N,H,L,d]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应在一个可测试的 mask 中明确合并因果与稀疏条件。模块训练时若启用 dropout，还要显式传 &lt;code&gt;dropout_p=self.p if self.training else 0.0&lt;/code&gt;，因为 SDPA 会按传入值执行 dropout。&lt;/p&gt;
&lt;h2&gt;09 用 FlexAttention 表达真正的块布局&lt;/h2&gt;
&lt;p&gt;PyTorch 2.14 当前官方 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/nn.attention.flex_attention.html&quot;&gt;FlexAttention API&lt;/a&gt; 中，&lt;code&gt;mask_mod&lt;/code&gt; 接收 batch、head、Query 索引和 Key/Value 索引。&lt;code&gt;create_block_mask&lt;/code&gt; 把 token 条件压成 &lt;code&gt;BlockMask&lt;/code&gt;，让内核跳过完整不可见块。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch.nn.attention.flex_attention import create_block_mask, flex_attention

WINDOW = 128
GLOBAL = 0

def causal_local_global(b, h, q_idx, kv_idx):
    local = (kv_idx &amp;#x3C;= q_idx) &amp;#x26; ((q_idx - kv_idx) &amp;#x3C;= WINDOW)
    read_global = (kv_idx == GLOBAL) &amp;#x26; (kv_idx &amp;#x3C;= q_idx)
    global_query = (q_idx == GLOBAL) &amp;#x26; (kv_idx &amp;#x3C;= q_idx)
    return local | read_global | global_query

def sparse_forward(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor):
    n, heads, q_len, _ = q.shape       # [N,H,L,d]
    kv_len = k.size(-2)
    block_mask = create_block_mask(
        causal_local_global,
        B=n, H=heads, Q_LEN=q_len, KV_LEN=kv_len,
        device=q.device,
    )
    return flex_attention(q, k, v, block_mask=block_mask), block_mask
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BlockMask&lt;/code&gt; 描述“哪些块可能含有效元素”，块内仍由 &lt;code&gt;mask_mod&lt;/code&gt; 保证精确语义。固定长度与布局时应复用 mask，避免每个 step 重建；变长 batch 要把 padding 边界并入条件，或按长度分桶。&lt;/p&gt;
&lt;h2&gt;10 怎样证明稀疏实现没有算错？&lt;/h2&gt;
&lt;p&gt;最短验证路径是让 $L=8,d=4$：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;逐行打印稠密 mask，人工核对因果方向、窗口端点与全局边；&lt;/li&gt;
&lt;li&gt;用相同 Q/K/V 比较稠密基线和稀疏内核的前向输出；&lt;/li&gt;
&lt;li&gt;分别反传同一个标量，比较 Q/K/V 梯度；&lt;/li&gt;
&lt;li&gt;把被屏蔽 Key 的 Value 改成极大值，确认对应 Query 输出不变；&lt;/li&gt;
&lt;li&gt;最后才测长序列的峰值显存、tokens/s 与任务质量。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;mask = causal_local_global_mask(8, 2)
out1 = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
v2 = v.clone()
assert not mask[7, 3]
v2[..., 3, :] += 10_000
out2 = F.scaled_dot_product_attention(q, k, v2, attn_mask=mask)
torch.testing.assert_close(out1[..., 7, :], out2[..., 7, :])
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;11 训练与增量推理的数据流有什么不同？&lt;/h2&gt;
&lt;p&gt;训练时通常一次输入完整 &lt;code&gt;[N,H,L,d]&lt;/code&gt;。带 KV Cache 的解码步只有 &lt;code&gt;Q_LEN=1&lt;/code&gt;，而 &lt;code&gt;KV_LEN=P+1&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;新 Query [N,H,1,d] ─────────┐
缓存+新 Key [N,H,P+1,d] ───┼─► 最近 w 个 Key + 合法全局 Key
缓存+新 Value [N,H,P+1,d] ─┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;窗口注意力并不自动让 KV Cache 有界。若还允许读取开头的全局 token，需要保留“全局槽 + 最近 $w$ 个槽”，并维护真实全局位置供 RoPE 使用。截断缓存后把位置重新编号为 0，会破坏前文建立的位置契约。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;p&gt;| 症状           | 常见原因                  | 最短检查                     |
| -------------- | ------------------------- | ---------------------------- |
| loss 异常低    | 因果不等号写反，读到未来  | 所有允许边是否满足 $j\le i$  |
| 输出 NaN       | 某 Query 没有合法 Key     | 检查 &lt;code&gt;mask.any(-1)&lt;/code&gt;          |
| 显存没下降     | 构造了完整分数或稠密 mask | profiler 中找 &lt;code&gt;[L,L]&lt;/code&gt; 分配   |
| 稀疏内核更慢   | 序列短、块碎或反复建 mask | 分离编译、建 mask 与稳态计时 |
| 长依赖骤降     | 窗口小且没有远程路径      | 画多层可达图，按证据距离分桶 |
| 推理训练不一致 | cache、位置或全局边不同   | 同前缀逐 token 对齐 logits   |&lt;/p&gt;
&lt;p&gt;性能比较必须固定 dtype、batch、头宽、序列长度和反向设置；先 warm-up，再同步设备计时。只报告“稀疏率 90%”不能说明端到端更快。&lt;/p&gt;
&lt;h2&gt;13 失败场景与相近方法&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;精确复制远处字符串、代码符号解析或跨文档引用时，局部路径可能太长；&lt;/li&gt;
&lt;li&gt;全局 token 太少会形成信息瓶颈，太多又把成本拉回 $O(Lg)$；&lt;/li&gt;
&lt;li&gt;固定窗口与语义边界不一致，可能在段落交界处删掉关键边；&lt;/li&gt;
&lt;li&gt;不规则稀疏在通用硬件上利用率低，理论 FLOPs 减少不等于延迟下降。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还要区分：FlashAttention 精确计算全注意力，主要优化 IO；线性注意力通过核分解或状态递推改变计算顺序，不一定定义稀疏图；检索增强先从外部语料选内容；KV Cache 复用历史 K/V。这些方法可以组合，却不回答同一个问题。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;稀疏注意力先定义信息可达性，再谈加速；mask 是模型结构的一部分。&lt;/li&gt;
&lt;li&gt;滑动窗口把边数从 $L^2$ 降到约 $Lw$，全局 token 用 $Lg$ 条边补充远程中转。&lt;/li&gt;
&lt;li&gt;逐元素 mask 只验证语义；真正省计算需要能跳过整块的内核。&lt;/li&gt;
&lt;li&gt;正确性用小尺寸稠密真值、梯度和隔离测试证明，效率用稳态端到端基准证明。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 $L=10,w=2$ 的因果窗口，列出第 0、1、5、9 个 Query 的可见 Key，再求总边数。边界处为何少于 $L(w+1)$？&lt;/li&gt;
&lt;li&gt;两层窗口半径为 1 的注意力中，位置 5 最早能间接接收位置几的信息？加入全局 token 后路径如何改变？&lt;/li&gt;
&lt;li&gt;实现稠密 reference 和块稀疏版本，测 $L\in{512,2048,8192}$ 的显存与耗时，找出开始获益的交叉点。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Child et al., &lt;a href=&quot;https://arxiv.org/abs/1904.10509&quot;&gt;Generating Long Sequences with Sparse Transformers&lt;/a&gt;，系统探索固定与分步稀疏模式。&lt;/li&gt;
&lt;li&gt;Beltagy et al., &lt;a href=&quot;https://arxiv.org/abs/2004.05150&quot;&gt;Longformer&lt;/a&gt;，组合局部窗口与任务相关全局注意力。&lt;/li&gt;
&lt;li&gt;Zaheer et al., &lt;a href=&quot;https://arxiv.org/abs/2007.14062&quot;&gt;Big Bird&lt;/a&gt;，结合局部、随机与全局边并分析表达能力。&lt;/li&gt;
&lt;li&gt;Dao et al., &lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;FlashAttention&lt;/a&gt;，展示精确全注意力的 IO 优化边界。&lt;/li&gt;
&lt;li&gt;FlexAttention 团队，&lt;a href=&quot;https://pytorch.org/blog/flexattention/&quot;&gt;FlexAttention&lt;/a&gt;，介绍可编程 mask 与块稀疏执行。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;到这里，Transformer 已能在可控成本下读取长前缀，但模型为什么会学会生成下一个 token 还没有被完整展开。下一篇将从文本切片开始，追踪输入与标签怎样错开一位，推导因果语言模型的 next-token cross-entropy，并解释 padding、文档边界和 loss mask 如何决定模型究竟在学什么。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>模型能接收 32K 就真的会用 32K 吗？RoPE 位置插值、频率缩放与长上下文验证</title><link>https://zwjcode.cn/blog/rope-context-extension-position-interpolation-evaluation</link><guid isPermaLink="true">https://zwjcode.cn/blog/rope-context-extension-position-interpolation-evaluation</guid><description>从训练长度外的 RoPE 相位失配出发，推导位置插值与非均匀频率缩放，并用长度、证据位置和任务复杂度分桶验证有效上下文。</description><pubDate>Wed, 09 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇说明了位置函数“怎样算”：RoPE 把每层每头的 Query、Key 按全局位置旋转，使点积显式依赖相对位移。但一个危险的推论是：既然 &lt;code&gt;sin&lt;/code&gt; 和 &lt;code&gt;cos&lt;/code&gt; 能为任意整数位置返回数值，把模型配置里的最大长度从 4K 改成 32K，模型就自然拥有 32K 上下文。&lt;/p&gt;
&lt;p&gt;这混淆了&lt;strong&gt;名义上下文长度&lt;/strong&gt;（Claimed Context Length）和&lt;strong&gt;有效上下文长度&lt;/strong&gt;（Effective Context Length）。前者只说明接口与显存允许输入多长；后者才说明模型在该长度上仍能定位、提取并组合信息。本文聚焦一件事：RoPE 模型如何把训练外的位置映回较熟悉的相位范围，以及怎样证明扩窗真的有效。&lt;/p&gt;
&lt;h2&gt;01 “能放进去”为什么不等于“能用起来”？&lt;/h2&gt;
&lt;p&gt;设预训练最大长度为 $L_0$，目标长度为 $L_1&gt;L_0$，每头宽度为偶数 $d$。原始 RoPE 的第 $i$ 个二维频率为：&lt;/p&gt;
&lt;p&gt;$$
\omega_i=b^{-2i/d},\qquad i=0,1,\ldots,d/2-1
$$&lt;/p&gt;
&lt;p&gt;位置 $p$ 对应角度 $p\omega_i$。训练期间，模型只在相对位移
$\Delta=s-p\in[-(L_0-1),L_0-1]$ 内见过这些相位怎样影响 Q–K 分数。直接输入更长序列时，代码仍能算出：&lt;/p&gt;
&lt;p&gt;$$
q_p^\top R(\Delta)k_s
$$&lt;/p&gt;
&lt;p&gt;但 $|\Delta|\ge L_0$ 的相位组合位于训练分布之外。神经网络在已见区间内拟合良好，并不约束它在区间外继续平滑。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;预训练：位置 0 ───────────────────── L₀-1
                   模型见过的相位与距离

直接扩窗：位置 0 ───────────────────── L₀-1 ───────────── L₁-1
                                               未见相位区间

结果可能是：张量形状合法、无越界、无 OOM，但长距离注意力已失真。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以至少要区分四层能力：&lt;/p&gt;
&lt;p&gt;| 层级     | 要回答的问题                              | 仅修改最大长度能否证明 |
| -------- | ----------------------------------------- | ---------------------- |
| 接口容量 | tokenizer 与模型是否接受 $L_1$ 个 token？ | 能                     |
| 系统容量 | 显存、prefill 延迟与吞吐是否可承受？      | 不能                   |
| 语言建模 | 长度增加时损失/困惑度是否稳定？           | 不能                   |
| 信息利用 | 能否在不同位置检索、推理与聚合？          | 不能                   |&lt;/p&gt;
&lt;h2&gt;02 直接外推究竟把什么送出了训练区间？&lt;/h2&gt;
&lt;p&gt;把每对 Q/K 维度写成二维向量，RoPE 分数可以看成许多三角基函数的加权组合。内容投影学到的系数只在训练位移区间内受到损失约束。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;content q_p [N,H,L,d] ─► R(p; ω) ─┐
                                    ├─ q̃k̃ᵀ / √d ─► logits [N,H,L,S]
content k_s [N,H,S,d] ─► R(s; ω) ─┘

位置部分：R(p)ᵀR(s) = R(s-p) = R(Δ)
训练只约束 |Δ| &amp;#x3C; L₀；直接扩窗要求模型在 |Δ| &amp;#x3C; L₁ 上外推。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;高频维的角度变化快，善于区分局部位置，却会在长距离上经历很多次绕圈；低频维变化慢，覆盖更大的距离尺度。扩窗不是简单“多生成几行 sin/cos 表”，而是让所有频率共同进入未受训练约束的新组合。&lt;/p&gt;
&lt;h2&gt;03 位置插值怎样把长窗口压回旧范围？&lt;/h2&gt;
&lt;p&gt;位置插值（Position Interpolation, PI）不再直接使用目标位置 $p$，而是令：&lt;/p&gt;
&lt;p&gt;$$
\alpha=\frac{L_0}{L_1}=\frac{1}{s},\qquad
p&apos;=\alpha p
$$&lt;/p&gt;
&lt;p&gt;其中 $s=L_1/L_0$ 是扩展因子。于是目标位置区间 $[0,L_1)$ 被压到 $[0,L_0)$；相对位移也变成：&lt;/p&gt;
&lt;p&gt;$$
\Delta&apos; = \alpha(s_2-p_2)=\frac{\Delta}{s}
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;目标窗口 L₁： 0──1──2──3──4──5──6──7
                 │  除以扩展因子 s=2
                 ▼
旧相位范围 L₀：0─0.5─1─1.5─2─2.5─3─3.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里产生的是连续位置，不要求落在整数网格上。PI 的关键假设是：在旧区间内做插值，通常比把模型推向整个训练区间之外更稳定。它没有改变 Q/K 的形状，也没有增加可训练位置表。&lt;/p&gt;
&lt;h2&gt;04 用 4→8 token 手算“稳定”与“代价”&lt;/h2&gt;
&lt;p&gt;令 $L_0=4$、$L_1=8$，所以 $s=2$。只看一个角频率 $\omega=1$ rad/token。&lt;/p&gt;
&lt;p&gt;长序列中，位置 1 的 Key 与位置 7 的 Query 相距 6：&lt;/p&gt;
&lt;p&gt;| 方案     | 有效位移 | 旋转角度 |
| -------- | -------- | -------- |
| 直接外推 | $6$      | $6$ rad  |
| 位置插值 | $6/2=3$  | $3$ rad  |&lt;/p&gt;
&lt;p&gt;插值把长距离重新放回训练见过的 $|\Delta|\le3$ 范围。但相邻 token 的位移也从 1 压成了 0.5：&lt;/p&gt;
&lt;p&gt;$$
\cos(1)\approx0.540,qquad \cos(0.5)\approx0.878
$$&lt;/p&gt;
&lt;p&gt;若内容向量恰好使这一对维度的点积退化为 $\cos(\Delta\omega)$，相邻位置会显得更相似。PI 解决了“远距离外推”，却同时牺牲了部分“局部分辨率”。这正是后续频率缩放不应一刀切的原因。&lt;/p&gt;
&lt;h2&gt;05 缩位置与缩频率为什么等价？&lt;/h2&gt;
&lt;p&gt;RoPE 角度只由位置与频率的乘积决定：&lt;/p&gt;
&lt;p&gt;$$
\underbrace{\frac{p}{s}}&lt;em&gt;{\text{缩位置}}\omega_i
=p\underbrace{\frac{\omega_i}{s}}&lt;/em&gt;{\text{缩频率}}
$$&lt;/p&gt;
&lt;p&gt;所以线性 RoPE 缩放（Linear RoPE Scaling）可以保留整数 &lt;code&gt;position_ids&lt;/code&gt;，改用：&lt;/p&gt;
&lt;p&gt;$$
\omega_i&apos;=\frac{\omega_i}{s}
$$&lt;/p&gt;
&lt;p&gt;这对 KV Cache 很方便：缓存逻辑位置仍是 &lt;code&gt;0,1,2,...&lt;/code&gt;，只在生成 &lt;code&gt;cos/sin&lt;/code&gt; 时使用缩放后的频率。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;position_ids [L] ───────────────┐
                                ├─ outer product ─► angles [L,d/2]
base b ─► inv_freq [d/2] ─► / s ┘
                                      │
                                      ├─ cos/sin ─► rotate Q [N,H,L,d]
                                      └─ cos/sin ─► rotate K [N,H,L,d]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：这里的 $s$ 是扩展因子，不是前文用作 Key 位置的下标。实现中应使用 &lt;code&gt;factor&lt;/code&gt;、&lt;code&gt;key_position&lt;/code&gt; 等清晰名称，避免同一个字母承担两种语义。&lt;/p&gt;
&lt;h2&gt;06 为什么非均匀频率缩放能保住更多局部信息？&lt;/h2&gt;
&lt;p&gt;线性 PI 把每个 $\omega_i$ 都除以相同的 $s$。更细的思路是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高频、短波维主要解析近邻，少缩放或不缩放；&lt;/li&gt;
&lt;li&gt;低频、长波维负责远距离，接近完整缩放；&lt;/li&gt;
&lt;li&gt;中间频率平滑过渡，避免频谱断层。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一种有代表性的基底缩放把 RoPE base 从 $b$ 改成：&lt;/p&gt;
&lt;p&gt;$$
b&apos;=b\cdot s^{d/(d-2)},\qquad d&gt;2
$$&lt;/p&gt;
&lt;p&gt;于是：&lt;/p&gt;
&lt;p&gt;$$
\omega_i&apos;=(b&apos;)^{-2i/d}
$$&lt;/p&gt;
&lt;p&gt;当 $i=0$ 时，最高频 $\omega_0&apos;=1$ 不变；到最低频附近，缩放量逐渐接近 $1/s$。这常被称为 NTK-aware 一类缩放。它是一种频率分配策略，不是“神经切线核已给出质量证明”。&lt;/p&gt;
&lt;p&gt;取 $d=8$、$b=10000$、$s=2$，原频率恰为 &lt;code&gt;[1, 0.1, 0.01, 0.001]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;| 维度对 | 原频率 | 线性 PI | 基底缩放（约） |
| ------ | ------ | ------- | -------------- |
| 0      | 1      | 0.5     | 1              |
| 1      | 0.1    | 0.05    | 0.079          |
| 2      | 0.01   | 0.005   | 0.0063         |
| 3      | 0.001  | 0.0005  | 0.0005         |&lt;/p&gt;
&lt;p&gt;YaRN 在此方向上进一步按波长区间混合插值与外推，并调整注意力尺度。工程上应把 &lt;code&gt;linear&lt;/code&gt;、&lt;code&gt;dynamic&lt;/code&gt;、&lt;code&gt;yarn&lt;/code&gt; 等视为不同的频率契约；不能只看到相同 &lt;code&gt;factor&lt;/code&gt; 就认为生成的角度相同。&lt;/p&gt;
&lt;h2&gt;07 用 PyTorch 写出两种可检查的频率&lt;/h2&gt;
&lt;p&gt;下面只生成 RoPE 的逆频率 &lt;code&gt;inv_freq [d/2]&lt;/code&gt;，不隐藏缩放发生在哪里：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math
from typing import Literal

import torch

RopeScaling = Literal[&quot;none&quot;, &quot;linear&quot;, &quot;base&quot;]

def rope_inv_freq(
    head_dim: int,
    *,
    base: float = 10000.0,
    factor: float = 1.0,
    scaling: RopeScaling = &quot;none&quot;,
    device: torch.device | None = None,
) -&gt; torch.Tensor:
    assert head_dim % 2 == 0
    assert base &gt; 0.0 and factor &gt;= 1.0

    if scaling == &quot;none&quot;:
        scaled_base = base
        divisor = 1.0
    elif scaling == &quot;linear&quot;:
        scaled_base = base
        divisor = factor
    elif scaling == &quot;base&quot;:
        assert head_dim &gt; 2
        scaled_base = base * factor ** (head_dim / (head_dim - 2))
        divisor = 1.0
    else:
        raise ValueError(f&quot;unknown scaling: {scaling}&quot;)

    pair_index = torch.arange(
        0, head_dim, 2, device=device, dtype=torch.float32
    )                                                   # [d/2]
    inv_freq = torch.exp(
        -math.log(scaled_base) * pair_index / head_dim
    )                                                   # [d/2]
    return inv_freq / divisor

original = rope_inv_freq(8)
linear = rope_inv_freq(8, factor=2.0, scaling=&quot;linear&quot;)
base_scaled = rope_inv_freq(8, factor=2.0, scaling=&quot;base&quot;)

torch.testing.assert_close(linear, original / 2.0)
torch.testing.assert_close(base_scaled[0], original[0])
torch.testing.assert_close(base_scaled[-1], original[-1] / 2.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里使用的 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.arange.html&quot;&gt;&lt;code&gt;torch.arange&lt;/code&gt;&lt;/a&gt;、&lt;code&gt;torch.exp&lt;/code&gt; 与张量广播都是 PyTorch 2.13 当前稳定 API。频率用 float32 生成；长位置若直接用低精度计算角度，舍入误差会随位置放大。&lt;/p&gt;
&lt;h2&gt;08 把缩放频率接回 Q/K 旋转&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def apply_scaled_rope(
    x: torch.Tensor,             # [N,H,L,d]
    position_ids: torch.Tensor,  # [L] or [N,L]
    *,
    factor: float,
    scaling: RopeScaling,
    base: float = 10000.0,
) -&gt; torch.Tensor:
    n, heads, length, head_dim = x.shape
    if position_ids.ndim == 1:
        assert position_ids.shape == (length,)
        position_ids = position_ids[None, :].expand(n, -1)
    else:
        assert position_ids.shape == (n, length)

    inv_freq = rope_inv_freq(
        head_dim,
        base=base,
        factor=factor,
        scaling=scaling,
        device=x.device,
    )                                                   # [d/2]
    angles = (
        position_ids.to(torch.float32)[..., None]
        * inv_freq[None, None, :]
    )                                                   # [N,L,d/2]
    cos = angles.cos().to(x.dtype)[:, None, :, :]       # [N,1,L,d/2]
    sin = angles.sin().to(x.dtype)[:, None, :, :]       # [N,1,L,d/2]

    even, odd = x[..., 0::2], x[..., 1::2]
    rotated = torch.stack(
        (even * cos - odd * sin, even * sin + odd * cos),
        dim=-1,
    ).flatten(-2)                                       # [N,H,L,d]
    return rotated

x = torch.randn(2, 4, 8, 16)
positions = torch.arange(8)

y = apply_scaled_rope(
    x, positions, factor=4.0, scaling=&quot;linear&quot;
)
assert y.shape == x.shape

# 旋转只改变方向，不改变每个二维对的 L2 范数。
torch.testing.assert_close(
    y.float().square().sum(dim=-1),
    x.float().square().sum(dim=-1),
    rtol=1e-5,
    atol=1e-5,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;函数输入是 Q 或 K，而不是 token ids；标准 RoPE 仍发生在每层每头的 Q/K 投影之后。输出形状与输入相同，所以后续仍可交给 &lt;code&gt;torch.nn.functional.scaled_dot_product_attention&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;09 当前 Transformers 配置表达了什么？&lt;/h2&gt;
&lt;p&gt;Hugging Face Transformers 当前官方文档用 &lt;code&gt;rope_parameters&lt;/code&gt; 声明 RoPE 变体。线性缩放示例为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from transformers import LlamaConfig

config = LlamaConfig()
config.rope_parameters = {
    &quot;rope_type&quot;: &quot;linear&quot;,
    &quot;rope_theta&quot;: 10000.0,
    &quot;factor&quot;: 8.0,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方当前列出的 &lt;code&gt;rope_type&lt;/code&gt; 包含 &lt;code&gt;default&lt;/code&gt;、&lt;code&gt;linear&lt;/code&gt;、&lt;code&gt;dynamic&lt;/code&gt;、&lt;code&gt;yarn&lt;/code&gt;、&lt;code&gt;longrope&lt;/code&gt; 与 &lt;code&gt;llama3&lt;/code&gt;；不同类型要求的键并不相同，缺失参数会报错。部分混合注意力模型还可按 layer type 分别配置。&lt;/p&gt;
&lt;h2&gt;10 扩窗微调时，数据流怎样变化？&lt;/h2&gt;
&lt;p&gt;PI 类方法通常从已有 checkpoint 继续训练，而不是只在推理时改配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;长文档 ─► tokenize / pack ─► input_ids [N,L₁]
                              │
                              ├─ position_ids [N,L₁]
                              │       │
                              │       └─ RoPE scaling ─► Q̃,K̃
                              │
                              └─ causal LM labels [N,L₁]
                                          │
                                          ▼
                                next-token loss [N,L₁]
                                          │
                                          ▼
                                   更新原 checkpoint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心训练契约包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;训练和部署使用完全相同的 &lt;code&gt;rope_type&lt;/code&gt;、&lt;code&gt;factor&lt;/code&gt;、&lt;code&gt;rope_theta&lt;/code&gt; 与原始最大长度。&lt;/li&gt;
&lt;li&gt;长样本必须覆盖所需距离；只把许多短样本拼成一批，不会自动产生跨片段监督。&lt;/li&gt;
&lt;li&gt;若 packed sequence 不允许片段互相注意，attention mask 与 position reset 必须一致。&lt;/li&gt;
&lt;li&gt;除长序列外保留一部分短序列，监测扩窗是否损害原窗口质量。&lt;/li&gt;
&lt;li&gt;自注意力的计算和 logits 存储通常随 $L^2$ 增长；位置缩放只处理位置分布，不消除计算代价。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;伪代码可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;for batch in mixture(short_sequences, long_sequences):
    ids, valid, segment = pack(batch, target_length=L₁)
    pos = build_positions(valid, segment_policy)
    logits = model(ids, position_ids=pos, attention_mask=valid)
    loss = next_token_loss(logits, ids, valid, segment)
    backward(loss)
    update_parameters()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;11 KV Cache 为什么也属于缩放契约？&lt;/h2&gt;
&lt;p&gt;prefill 与逐 token decode 必须对同一全局位置生成同一角度：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# cache.key 已保存按各自位置旋转后的历史 Key。
past_length = 0 if cache is None else cache.key.size(-2)
new_positions = torch.arange(
    past_length,
    past_length + new_q.size(-2),
    device=new_q.device,
)

rotated_q = apply_scaled_rope(
    new_q, new_positions, factor=8.0, scaling=&quot;linear&quot;
)
rotated_k = apply_scaled_rope(
    new_k, new_positions, factor=8.0, scaling=&quot;linear&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若运行中更改 &lt;code&gt;factor&lt;/code&gt;、&lt;code&gt;base&lt;/code&gt; 或缩放类型，历史 Key 与新 Query 会处在不同坐标系，旧 cache 必须失效。动态缩放还可能让频率依赖当前或目标序列长度；手写实现若在长度跨阈值后改变频率，就要保证历史 Key 被一致重算，不能只旋转新 Key。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;正确：整个请求固定一份 RoPE contract
prefill K(0..P-1) ─► cache ─► decode Q/K(P), Q/K(P+1), ...

错误：prefill factor=4，decode 中途改成 factor=8
旧 K 坐标系 A ────────────────┐
                               ├─ 点积没有统一位置语义
新 Q 坐标系 B ────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;12 怎样定义“有效上下文长度”？&lt;/h2&gt;
&lt;p&gt;不要把单个最大长度准确率当成答案。一个可执行的评估立方体至少有三条轴：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         任务复杂度
                    单证据 / 多证据 / 多跳 / 聚合
                              ▲
                              │
证据位置  开头 / 中间 / 末尾 ◄─┼─► 上下文长度  L₀ / 2L₀ / 4L₀ / L₁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对每个格子重复不同随机种子并报告置信区间。建议同时保留四类指标：&lt;/p&gt;
&lt;p&gt;| 指标族     | 例子                            | 能发现什么                 |
| ---------- | ------------------------------- | -------------------------- |
| 语言建模   | 滑窗 loss、perplexity           | 长度增加后整体分布是否崩坏 |
| 定位检索   | passkey / key-value exact match | 是否能在远处找到单条证据   |
| 组合推理   | 多 needle、多跳追踪、计数与聚合 | 是否真正联合使用多处信息   |
| 原窗口回归 | 原任务分数、短上下文 perplexity | 扩窗是否破坏已有能力       |&lt;/p&gt;
&lt;p&gt;“在 128K 的一个末尾 passkey 上成功”最多证明一个格子通过。RULER 的动机正是：简单单针检索接近满分时，增加 needle 数量、追踪和聚合后仍可能明显下降。&lt;/p&gt;
&lt;h2&gt;13 用分桶程序避免只报一个平均数&lt;/h2&gt;
&lt;p&gt;下面的骨架把模型调用留成显式接口，输出记录可直接聚合为长度×位置表：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections.abc import Callable, Iterable
from dataclasses import dataclass

@dataclass(frozen=True)
class LongContextCase:
    context_length: int
    evidence_fraction: float  # 0.1=靠前，0.5=中间，0.9=靠后
    seed: int

def evaluate_context_grid(
    predict: Callable[[str], str],
    make_case: Callable[[LongContextCase], tuple[str, str]],
    lengths: Iterable[int],
    fractions: Iterable[float] = (0.1, 0.5, 0.9),
    seeds: Iterable[int] = range(10),
) -&gt; list[dict[str, int | float | bool]]:
    rows = []
    for length in lengths:
        for fraction in fractions:
            assert 0.0 &amp;#x3C; fraction &amp;#x3C; 1.0
            for seed in seeds:
                case = LongContextCase(length, fraction, seed)
                prompt, expected = make_case(case)
                prediction = predict(prompt)
                rows.append({
                    &quot;length&quot;: length,
                    &quot;evidence_fraction&quot;: fraction,
                    &quot;seed&quot;: seed,
                    &quot;correct&quot;: prediction.strip() == expected,
                })
    return rows

rows = evaluate_context_grid(
    predict=model_generate,
    make_case=make_passkey_case,
    lengths=(4096, 8192, 16384, 32768),
)
# 输出 4×3×10=120 行；之后按 length 与 evidence_fraction 分组求准确率。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;evidence_fraction&lt;/code&gt; 要按 token 位置而不是字符位置计算，否则中英文、代码和空格会让证据落点偏移。生成式 exact match 还应固定解码策略、最大新 token 数与答案规范化规则。&lt;/p&gt;
&lt;h2&gt;14 系统指标为什么必须与质量一起测？&lt;/h2&gt;
&lt;p&gt;即使质量稳定，长上下文也可能因成本失去工程价值：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;完整自注意力的关系矩阵规模从 $L_0^2$ 增到 $(sL_0)^2=s^2L_0^2$。&lt;/li&gt;
&lt;li&gt;KV Cache 容量随层数、KV 头数、序列长度与 head width 近似线性增长。&lt;/li&gt;
&lt;li&gt;prefill 处理整个输入，通常比单步 decode 更受长序列影响。&lt;/li&gt;
&lt;li&gt;更长输入可能降低 batch size，引发吞吐骤降和排队延迟上升。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此每个长度桶还应记录：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入长度 L
   ├─► 质量：loss / retrieval / reasoning accuracy
   ├─► 延迟：time-to-first-token、inter-token latency
   ├─► 吞吐：tokens/s、requests/s
   └─► 资源：峰值显存、KV bytes/request、可用 batch size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;滑动窗口注意力、稀疏注意力、检索增强生成（Retrieval-Augmented Generation, RAG）与压缩记忆可以减少成本或筛掉无关文本，但它们改变了信息可见范围，不属于单纯 RoPE 缩放。&lt;/p&gt;
&lt;h2&gt;15 一条可执行的调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先冻结随机 Q/K 做角度测试。&lt;/strong&gt; 比较原始、线性与基底缩放的 &lt;code&gt;inv_freq&lt;/code&gt;，确认最高频和最低频是否按设计变化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证旋转保范数。&lt;/strong&gt; 每个位置的 Q/K 在旋转前后 L2 范数应保持接近；否则偶奇配对或广播轴有误。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证共同平移。&lt;/strong&gt; 固定相同缩放参数，同时给 Query/Key 位置加偏移，RoPE 点积应保持不变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证完整前向与 cache。&lt;/strong&gt; 关闭 dropout，逐位置比较 full causal forward 与 prefill+decode，而不是只看最终 token。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;打印实际 token 长度。&lt;/strong&gt; tokenizer 截断可能让所谓 32K 样本仍只有 4K；检查进入模型后的 &lt;code&gt;input_ids.shape&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按长度画 loss。&lt;/strong&gt; 从 $L_0$ 逐步扫到 $L_1$，寻找突然断崖，而不是只测两个端点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按证据位置画热图。&lt;/strong&gt; 开头、中央、末尾分别测，避免平均数掩盖 Lost-in-the-Middle。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;回归短上下文。&lt;/strong&gt; 用未改 checkpoint 的同一批短样本作基线，确认扩窗没有用明显短程退化换取长程分数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录系统曲线。&lt;/strong&gt; 在固定硬件、batch 与解码参数下测 TTFT、吞吐和峰值显存。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 最常见的长上下文误判&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只改 &lt;code&gt;max_position_embeddings&lt;/code&gt;。&lt;/strong&gt; 接口接受更长输入，不代表权重适配了新频率。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把无 OOM 当作成功。&lt;/strong&gt; 这只验证系统容量，没验证语言建模或信息利用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只测一个末尾 passkey。&lt;/strong&gt; 模型可能在中央证据、多针或聚合任务上失败。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只报全局平均准确率。&lt;/strong&gt; U 形位置偏差会被平均数掩盖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;扩窗后不测短任务。&lt;/strong&gt; 均匀插值压缩局部距离，原窗口能力可能回退。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练与推理 &lt;code&gt;factor&lt;/code&gt; 不同。&lt;/strong&gt; Q/K 进入不同频谱，checkpoint 语义被破坏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运行中更改缩放却复用 KV Cache。&lt;/strong&gt; 历史 Key 与新 Query 坐标系不一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;混淆 RoPE base 与扩展因子。&lt;/strong&gt; &lt;code&gt;rope_theta=10000&lt;/code&gt; 不是窗口长度，&lt;code&gt;factor=8&lt;/code&gt; 也不是角频率。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;低精度生成超长位置角度。&lt;/strong&gt; float16/bfloat16 的位置舍入可能让相邻大整数无法可靠区分。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;宣称插值消除了 $O(L^2)$。&lt;/strong&gt; 它只改位置相位，不改变标准注意力的二次计算。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 与相近方案的边界&lt;/h2&gt;
&lt;p&gt;| 方案                        | 改变什么                           | 主要收益              | 主要边界                       |
| --------------------------- | ---------------------------------- | --------------------- | ------------------------------ |
| 直接 RoPE 外推              | 只允许更大的 position id           | 零结构改动            | 未见相位可能失效               |
| 线性 Position Interpolation | 所有位置/频率统一除以 factor       | 把长距离压回旧范围    | 局部距离也被压缩               |
| NTK-aware / YaRN 类         | 按频率非均匀缩放，可能调注意力尺度 | 更好平衡局部与长程    | 方法与 checkpoint 参数必须匹配 |
| LongRoPE 类                 | 搜索分维、分位置的非均匀插值       | 支持更激进扩展        | 搜索、训练与验证更复杂         |
| 滑动窗口注意力              | 限制每个 Query 可见的 Key          | 控制计算与 cache 上界 | 窗口外信息不能直接访问         |
| RAG                         | 先检索再把相关片段放入上下文       | 减少无关 token 与成本 | 受检索召回、切块和排序影响     |&lt;/p&gt;
&lt;p&gt;PI、YaRN 和 LongRoPE 仍属于“让 RoPE checkpoint 适配更长距离”；滑动窗口与 RAG 则改变了模型实际读取哪些 token。它们可以组合，但实验必须分别归因。&lt;/p&gt;
&lt;h2&gt;18 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;RoPE 能计算任意位置，只说明位置函数有定义；有效上下文还取决于权重是否学会使用训练外距离。&lt;/li&gt;
&lt;li&gt;Position Interpolation 用 $p&apos;=p/s$ 把目标窗口映回旧相位范围，等价于把所有频率除以 $s$。&lt;/li&gt;
&lt;li&gt;均匀缩放会压缩局部距离；非均匀频率方案尝试保留高频局部分辨率，同时拉伸低频长程尺度。&lt;/li&gt;
&lt;li&gt;缩放参数是训练、完整前向、KV Cache 与部署共同遵守的坐标契约，不能在请求中途切换。&lt;/li&gt;
&lt;li&gt;有效窗口必须按长度、证据位置和任务复杂度分桶，并同时回归短上下文质量与系统成本。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;19 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一个模型从 $L_0=2048$ 扩到 $L_1=8192$。分别计算原位置 &lt;code&gt;0, 1024, 4096, 8191&lt;/code&gt; 在线性 PI 下的位置；再计算原本相隔 8 token 的局部距离被压成多少。&lt;/li&gt;
&lt;li&gt;修改 &lt;code&gt;rope_inv_freq&lt;/code&gt;，打印 &lt;code&gt;head_dim=64&lt;/code&gt;、&lt;code&gt;factor=8&lt;/code&gt; 时原始、线性与基底缩放的前 3/后 3 个频率。解释为什么两种缩放不能只比较最后一个频率。&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;evaluate_context_grid&lt;/code&gt; 扩成两针任务：两条证据分别放在 &lt;code&gt;(0.1,0.9)&lt;/code&gt;、&lt;code&gt;(0.3,0.7)&lt;/code&gt; 与 &lt;code&gt;(0.45,0.55)&lt;/code&gt;。除了 exact match，还应记录哪些失败类型？&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2306.15595&quot;&gt;Chen et al. (2023), Extending Context Window of Large Language Models via Positional Interpolation&lt;/a&gt;：提出把长位置线性压回预训练范围，并用长序列建模与 passkey 检索验证。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openreview.net/forum?id=wHBfxhZu1u&quot;&gt;Peng et al. (2024), YaRN: Efficient Context Window Extension of Large Language Models&lt;/a&gt;：按频率区间改进 RoPE 插值，并加入注意力尺度修正。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openreview.net/forum?id=ONOtpXLqqw&quot;&gt;Ding et al. (2024), LongRoPE: Extending LLM Context Window Beyond 2 Million Tokens&lt;/a&gt;：探索分维度、分位置的非均匀插值与渐进式扩窗。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://aclanthology.org/2024.tacl-1.9/&quot;&gt;Liu et al. (2024), Lost in the Middle: How Language Models Use Long Contexts&lt;/a&gt;：展示相关信息位于长输入中央时的显著位置敏感性。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2404.06654&quot;&gt;Hsieh et al. (2024), RULER: What&apos;s the Real Context Size of Your Long-Context Language Models?&lt;/a&gt;：将单针检索扩展到多针、追踪与聚合任务，评估真实有效窗口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;20 下一篇预告&lt;/h2&gt;
&lt;p&gt;位置缩放解决的是“远距离坐标怎样进入注意力”，没有解决标准注意力随序列长度二次增长的问题。下一篇将从一个 token 到底能读哪些 Key 出发，比较滑动窗口、块稀疏与全局 token 如何改变可见性图、复杂度和 KV Cache。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>词相同但顺序不同，注意力怎样辨别位置？从正弦编码到 RoPE</title><link>https://zwjcode.cn/blog/transformer-positional-encoding-rope-relative-dot-product</link><guid isPermaLink="true">https://zwjcode.cn/blog/transformer-positional-encoding-rope-relative-dot-product</guid><description>从自注意力的置换等变性出发，比较正弦与学习式绝对位置，并推导 RoPE 如何把相对位移写进 Query–Key 点积。</description><pubDate>Tue, 08 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把自回归推理改造成 KV Cache：历史 Key/Value 可以复用，新 token 只需用自己的 Query 读取缓存。但那里留下了一个不能靠缓存解决的问题：新 token 明明位于全局位置 $P+t$，若每一步都把它当成位置 0，增量前向就不再等价于完整前向。&lt;/p&gt;
&lt;p&gt;原因更基础：自注意力只比较内容，本身没有“第几个 token”的坐标。位置表示（Positional Representation）要补上的不是一个装饰性编号，而是让模型能够区分顺序、方向与距离。本文比较三种紧密相关的方法，并重点推导旋转位置编码（Rotary Position Embedding, RoPE）为何能把相对位移直接写进 Query–Key 点积。&lt;/p&gt;
&lt;h2&gt;01 没有位置时，注意力究竟看不见什么？&lt;/h2&gt;
&lt;p&gt;设输入 $X\in\mathbb{R}^{N\times L\times D}$，忽略 mask 时：&lt;/p&gt;
&lt;p&gt;$$
Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V
$$&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Attention}(X)=\operatorname{softmax}\left(\frac{QK^\top}{\sqrt{d}}\right)V
$$&lt;/p&gt;
&lt;p&gt;若用同一个置换矩阵 $P\in\mathbb{R}^{L\times L}$ 重排 token 行，则 $Q,K,V$ 都被同样重排，结果满足：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Attention}(PX)=P\operatorname{Attention}(X)
$$&lt;/p&gt;
&lt;p&gt;这叫置换等变（Permutation Equivariance）：输入行换顺序，输出行只会跟着换顺序。模型能看到有哪些 token，却没有天然坐标说明谁先谁后。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入 A: [狗, 咬, 人] ─► 只按内容投影 Q/K/V ─► 输出 [o狗, o咬, o人]
输入 B: [人, 咬, 狗] ─► 以同样方式重排行 ─► 输出 [o人, o咬, o狗]

没有位置表示时，模型没有额外信号区分：
“狗在咬之前” 与 “狗在咬之后”
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因果 mask 能阻止位置 $i$ 读取未来 $j&gt;i$，但它只规定“能连哪条边”，不告诉模型两个可见 token 相隔 1 步还是 100 步。位置表示与 mask 解决的是不同问题。&lt;/p&gt;
&lt;h2&gt;02 位置信息可以注入哪一处？&lt;/h2&gt;
&lt;p&gt;三种常见思路的关键差异，不是名字，而是位置进入数据流的位置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;绝对位置：
token embedding [N,L,D] + position [L,D]
                    │
                    ▼
             hidden [N,L,D] ─► Q/K/V projection ─► attention

RoPE：
hidden [N,L,D] ─► Q/K projection ─► 按位置旋转 Q、K ─► QKᵀ
                         └───────► V 通常不旋转 ─────► 加权和

相对偏置（边界比较）：
QKᵀ [N,H,L,S] + bias(查询位置 - 键位置) ─► softmax
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;| 方法           | 位置进入哪里        | 是否学习位置参数 | 直接表达什么                    |
| -------------- | ------------------- | ---------------- | ------------------------------- |
| 正弦绝对位置   | 加到输入 hidden     | 否               | 每个位置的多频率绝对坐标        |
| 学习式绝对位置 | 加到输入 hidden     | 是               | 训练数据自行塑造的离散位置向量  |
| RoPE           | 旋转每个头的 Q 与 K | 否               | 绝对角度产生的相对 Q–K 几何关系 |&lt;/p&gt;
&lt;p&gt;这三种方法都不自动处理 padding，也不替代 causal mask。位置只提供坐标；可见性仍由 mask 决定。&lt;/p&gt;
&lt;h2&gt;03 正弦位置编码怎样把整数变成多尺度坐标？&lt;/h2&gt;
&lt;p&gt;原始 Transformer 对偶数维 $D$ 定义多组频率：&lt;/p&gt;
&lt;p&gt;$$
\omega_i=10000^{-2i/D},\qquad i=0,1,\ldots,D/2-1
$$&lt;/p&gt;
&lt;p&gt;位置 $p$ 的第 $i$ 个二维坐标对为：&lt;/p&gt;
&lt;p&gt;$$
PE_i(p)=[\sin(p\omega_i),\cos(p\omega_i)]
$$&lt;/p&gt;
&lt;p&gt;高频对随位置快速旋转，擅长区分近邻；低频对变化缓慢，提供更大尺度的坐标。把所有频率拼起来得到 $PE(p)\in\mathbb{R}^{D}$，再与 token embedding 相加：&lt;/p&gt;
&lt;p&gt;$$
X_p=E_{token(p)}+PE(p)
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;位置 p ─► [快频率 sin/cos | 中频率 sin/cos | 慢频率 sin/cos] ─► PE(p) [D]
token id ───────────────────────────────► embedding [D]
                                                     │ 相加
                                                     ▼
                                              X_p [D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么每个频率要成对使用正弦和余弦？因为平移 $\Delta$ 对每一对坐标都是固定旋转：&lt;/p&gt;
&lt;h1&gt;$$
\begin{bmatrix}
\sin((p+\Delta)\omega)\
\cos((p+\Delta)\omega)
\end{bmatrix}&lt;/h1&gt;
&lt;p&gt;\begin{bmatrix}
\cos(\Delta\omega)&amp;#x26;\sin(\Delta\omega)\
-\sin(\Delta\omega)&amp;#x26;\cos(\Delta\omega)
\end{bmatrix}
\begin{bmatrix}
\sin(p\omega)\
\cos(p\omega)
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;对固定 $\Delta$，右侧矩阵与绝对位置 $p$ 无关。线性层因此有机会从绝对坐标中恢复相对偏移，但这种关系只是“可被利用”，并没有像 RoPE 那样直接约束 Q–K 点积。&lt;/p&gt;
&lt;h2&gt;04 用一个频率手算绝对坐标与相对距离&lt;/h2&gt;
&lt;p&gt;取单个频率 $\omega=\pi/2$：&lt;/p&gt;
&lt;p&gt;| 位置 $p$ | $[\sin(p\omega),\cos(p\omega)]$ |
| -------- | ------------------------------- |
| 0        | &lt;code&gt;[0, 1]&lt;/code&gt;                        |
| 1        | &lt;code&gt;[1, 0]&lt;/code&gt;                        |
| 2        | &lt;code&gt;[0, -1]&lt;/code&gt;                       |
| 3        | &lt;code&gt;[-1, 0]&lt;/code&gt;                       |&lt;/p&gt;
&lt;p&gt;任意两个位置向量的点积为：&lt;/p&gt;
&lt;p&gt;$$
PE_\omega(m)^\top PE_\omega(n)=\cos((m-n)\omega)
$$&lt;/p&gt;
&lt;p&gt;所以位置 &lt;code&gt;(0,1)&lt;/code&gt; 与 &lt;code&gt;(2,3)&lt;/code&gt; 虽然绝对编号不同，但位移都为 1，点积都等于 0。位置 &lt;code&gt;(0,2)&lt;/code&gt; 与 &lt;code&gt;(1,3)&lt;/code&gt; 的位移都为 2，点积都等于 $-1$。&lt;/p&gt;
&lt;p&gt;真实编码用许多频率，不同频率会给同一位移不同相位。模型不是从单个数字读取“距离”，而是从多尺度相位组合中学习位置关系。&lt;/p&gt;
&lt;h2&gt;05 不依赖框架封装，生成正弦位置&lt;/h2&gt;
&lt;p&gt;下面函数返回 &lt;code&gt;[L,D]&lt;/code&gt;，偶数列放正弦、奇数列放余弦。它故意把频率和角度留在代码中，便于检查每个张量。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math

import torch

def sinusoidal_positions(
    length: int,
    model_dim: int,
    *,
    device: torch.device | None = None,
) -&gt; torch.Tensor:
    assert model_dim % 2 == 0
    positions = torch.arange(
        length, device=device, dtype=torch.float32
    )[:, None]                                  # [L,1]
    pair_index = torch.arange(
        0, model_dim, 2, device=device, dtype=torch.float32
    )                                           # [D/2]
    frequencies = torch.exp(
        -math.log(10000.0) * pair_index / model_dim
    )                                           # [D/2]
    angles = positions * frequencies[None, :]   # [L,D/2]

    encoding = torch.empty(length, model_dim, device=device)
    encoding[:, 0::2] = angles.sin()
    encoding[:, 1::2] = angles.cos()
    return encoding                             # [L,D]

token_hidden = torch.randn(2, 5, 8)             # [N=2,L=5,D=8]
position = sinusoidal_positions(5, 8)           # [5,8]
positioned = token_hidden + position[None, :, :] # [2,5,8]

assert positioned.shape == token_hidden.shape
torch.testing.assert_close(position[0, 0::2], torch.zeros(4))
torch.testing.assert_close(position[0, 1::2], torch.ones(4))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正弦表不需要梯度。生产模块若预先缓存它，可用 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.Module.html#torch.nn.Module.register_buffer&quot;&gt;&lt;code&gt;register_buffer&lt;/code&gt;&lt;/a&gt; 注册为 buffer：它不是可训练参数，但会跟随模块迁移设备；&lt;code&gt;persistent=False&lt;/code&gt; 还可避免把可重建的大表写进 &lt;code&gt;state_dict&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;06 学习式绝对位置改变了什么？&lt;/h2&gt;
&lt;p&gt;学习式绝对位置（Learned Absolute Position Embedding）直接建立参数表 $P\in\mathbb{R}^{M\times D}$，其中 $M$ 是最大位置数。第 $p$ 行与 token embedding 相加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch import nn

class LearnedAbsoluteInput(nn.Module):
    def __init__(self, vocab_size: int, model_dim: int, max_length: int):
        super().__init__()
        self.max_length = max_length
        self.token = nn.Embedding(vocab_size, model_dim)
        self.position = nn.Embedding(max_length, model_dim)

    def forward(self, token_ids: torch.Tensor) -&gt; torch.Tensor:
        # token_ids: [N,L]
        n, length = token_ids.shape
        assert length &amp;#x3C;= self.max_length
        position_ids = torch.arange(length, device=token_ids.device) # [L]
        token = self.token(token_ids)                                # [N,L,D]
        position = self.position(position_ids)                       # [L,D]
        return token + position[None, :, :]                          # [N,L,D]

inputs = LearnedAbsoluteInput(vocab_size=1000, model_dim=64, max_length=512)
hidden = inputs(torch.randint(0, 1000, (3, 20)))
assert hidden.shape == (3, 20, 64)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.13 的 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.Embedding&quot;&gt;&lt;code&gt;nn.Embedding&lt;/code&gt;&lt;/a&gt; 接收任意形状的整数索引 &lt;code&gt;(*)&lt;/code&gt;，输出 &lt;code&gt;(*, embedding_dim)&lt;/code&gt;；其权重形状为 &lt;code&gt;[num_embeddings, embedding_dim]&lt;/code&gt;。这里 token 表与 position 表都是查表，但行的语义完全不同。&lt;/p&gt;
&lt;p&gt;学习式位置能让数据自行决定哪些位置应相似，却有一个硬边界：位置 $p\ge M$ 根本没有参数行。直接增大 $M$ 后，新行也未受训练。正弦编码可以计算未见过的整数位置，但“函数有值”不等于模型能可靠外推到任意长度。&lt;/p&gt;
&lt;h2&gt;07 RoPE 为什么旋转 Q/K，而不是加到 hidden？&lt;/h2&gt;
&lt;p&gt;RoPE 把每个注意力头的 Query 和 Key 沿最后一维两两分组。对第 $i$ 对维度、位置 $p$，用二维旋转矩阵：&lt;/p&gt;
&lt;p&gt;$$
R_i(p)=
\begin{bmatrix}
\cos(p\theta_i)&amp;#x26;-\sin(p\theta_i)\
\sin(p\theta_i)&amp;#x26;\cos(p\theta_i)
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;其中 $\theta_i=10000^{-2i/d}$，$d$ 是每头宽度。对未加位置的内容向量 $q_p,k_s\in\mathbb{R}^{d}$：&lt;/p&gt;
&lt;p&gt;$$
\widetilde q_p=R(p)q_p,\qquad \widetilde k_s=R(s)k_s
$$&lt;/p&gt;
&lt;p&gt;注意力分数变为：&lt;/p&gt;
&lt;p&gt;$$
\widetilde q_p^\top\widetilde k_s
=q_p^\top R(p)^\top R(s)k_s
=q_p^\top R(s-p)k_s
$$&lt;/p&gt;
&lt;p&gt;最后一式只通过 $s-p$ 依赖位置。若把 Query 与 Key 同时平移 $\Delta$：&lt;/p&gt;
&lt;p&gt;$$
R((s+\Delta)-(p+\Delta))=R(s-p)
$$&lt;/p&gt;
&lt;p&gt;点积不变。这就是 RoPE 的核心：每个 Q/K 仍按绝对位置旋转，但二者相乘时显式出现相对位移。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;content q_p [d] ─► R(p) ─► rotated q_p ─┐
                                         ├─ dot / sqrt(d) ─► score(p,s)
content k_s [d] ─► R(s) ─► rotated k_s ─┘

共同平移：p→p+Δ, s→s+Δ
相对位移：(s+Δ)-(p+Δ)=s-p ─► score 的位置部分不变
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Value 通常不旋转，因为 RoPE 的目标是改变“Query 与哪个 Key 相似”，而不是直接把位置混进被加权取回的内容。&lt;/p&gt;
&lt;h2&gt;08 用二维向量手算一次旋转点积&lt;/h2&gt;
&lt;p&gt;只取一对维度，令 $q=k=[1,0]$，角频率 $\theta=\pi/2$。&lt;/p&gt;
&lt;p&gt;查询在 $p=1$，Key 在 $s=3$：&lt;/p&gt;
&lt;p&gt;$$
R(1)q=[0,1],\qquad R(3)k=[0,-1]
$$&lt;/p&gt;
&lt;p&gt;旋转后点积为 $-1$。用相对形式计算：&lt;/p&gt;
&lt;p&gt;$$
q^\top R(s-p)k=[1,0]^\top R(2)[1,0]=-1
$$&lt;/p&gt;
&lt;p&gt;再把二者都向右平移 4 个位置，得到 $p=5,s=7$。相对位移仍是 2，所以点积仍为 $-1$。&lt;/p&gt;
&lt;p&gt;注意，这不意味着 RoPE 分数只由距离决定。完整表达式仍含内容向量 $q_p,k_s$；RoPE 只是让位置对点积的作用具有相对结构。&lt;/p&gt;
&lt;h2&gt;09 用 PyTorch 写出透明 RoPE 与注意力数据流&lt;/h2&gt;
&lt;p&gt;下面实现接受 Query/Key &lt;code&gt;x [N,H,L,d]&lt;/code&gt; 和位置 &lt;code&gt;position_ids [L]&lt;/code&gt; 或 &lt;code&gt;[N,L]&lt;/code&gt;。角度先以 float32 计算，再把正余弦转换到输入 dtype，避免在长位置上过早用低精度累计角度误差。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math

import torch
from torch.nn import functional as F

def apply_rope(
    x: torch.Tensor,             # [N,H,L,d]
    position_ids: torch.Tensor,  # [L] or [N,L]
    base: float = 10000.0,
) -&gt; torch.Tensor:
    n, heads, length, head_dim = x.shape
    assert head_dim % 2 == 0

    if position_ids.ndim == 1:
        assert position_ids.shape == (length,)
        position_ids = position_ids[None, :].expand(n, -1) # [N,L]
    else:
        assert position_ids.shape == (n, length)

    pair_index = torch.arange(
        0, head_dim, 2, device=x.device, dtype=torch.float32
    )                                                        # [d/2]
    inv_freq = torch.exp(
        -math.log(base) * pair_index / head_dim
    )                                                        # [d/2]
    angles = position_ids.to(torch.float32)[..., None] * inv_freq
    cos = angles.cos().to(x.dtype)[:, None, :, :]             # [N,1,L,d/2]
    sin = angles.sin().to(x.dtype)[:, None, :, :]             # [N,1,L,d/2]

    even = x[..., 0::2]                                       # [N,H,L,d/2]
    odd = x[..., 1::2]                                        # [N,H,L,d/2]
    rotated_even = even * cos - odd * sin
    rotated_odd = even * sin + odd * cos
    return torch.stack(
        (rotated_even, rotated_odd), dim=-1
    ).flatten(-2)                                             # [N,H,L,d]

def rope_attention(
    q: torch.Tensor,             # [N,H,L,d]
    k: torch.Tensor,             # [N,H,S,d]
    v: torch.Tensor,             # [N,H,S,d]
    query_positions: torch.Tensor,
    key_positions: torch.Tensor,
    *,
    is_causal: bool,
) -&gt; torch.Tensor:
    q = apply_rope(q, query_positions)
    k = apply_rope(k, key_positions)
    return F.scaled_dot_product_attention(
        q, k, v,
        is_causal=is_causal,
        dropout_p=0.0,
    )                           # [N,H,L,d]

q = torch.randn(2, 4, 6, 8)
k = torch.randn(2, 4, 6, 8)
v = torch.randn(2, 4, 6, 8)
positions = torch.arange(6)
output = rope_attention(
    q, k, v, positions, positions, is_causal=True
)
assert output.shape == (2, 4, 6, 8)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.13 当前的 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention&quot;&gt;&lt;code&gt;scaled_dot_product_attention&lt;/code&gt;&lt;/a&gt; 接收 Query &lt;code&gt;[N,...,H_q,L,E]&lt;/code&gt;、Key &lt;code&gt;[N,...,H,S,E]&lt;/code&gt;、Value &lt;code&gt;[N,...,H,S,E_v]&lt;/code&gt;。RoPE 不改变这些形状，只在调用 SDPA 前旋转 Q/K；SDPA 本身不会替你添加 RoPE。&lt;/p&gt;
&lt;p&gt;教学实现每次重建频率和角度，便于观察但有额外开销。生产实现通常缓存一定长度的 &lt;code&gt;cos/sin&lt;/code&gt; 表，在需要更长位置时扩展，并针对设备、dtype 与编译策略管理缓存生命周期。&lt;/p&gt;
&lt;h2&gt;10 RoPE 与 KV Cache 怎样保持等价？&lt;/h2&gt;
&lt;p&gt;完整因果前向中，第 $p$ 个 Key 应先用 $R(p)$ 旋转。增量解码时，缓存已有长度为 &lt;code&gt;past_length&lt;/code&gt;，新 token 的位置必须从这里开始：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# new_q/new_k/new_v: [N,H,L_new,d]，decode 时 L_new 通常为 1
past_length = 0 if cache is None else cache.key.size(-2)
position_ids = torch.arange(
    past_length,
    past_length + new_q.size(-2),
    device=new_q.device,
)

rotated_q = apply_rope(new_q, position_ids)
rotated_new_k = apply_rope(new_k, position_ids)

if cache is None:
    key = rotated_new_k
    value = new_v
else:
    key = torch.cat([cache.key, rotated_new_k], dim=-2)
    value = torch.cat([cache.value, new_v], dim=-2)

# cache.key 保存“已经按各自全局位置旋转”的 Key。
# 历史 Key 不能在每一步按新位置重复旋转。
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;prefill 位置: 0 1 2 3 ─► 各自旋转 K ─► cache length 4
decode 新位置: 4       ─► R(4) 旋转 q4,k4 ─► append k4
decode 新位置: 5       ─► R(5) 旋转 q5,k5 ─► append k5

错误做法 A：每步新 token 都使用 R(0)
错误做法 B：cache 中历史 K 在每步又旋转一次
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试时应关闭 dropout，比较完整 causal forward 与“prefill + 逐 token cache”的所有位置输出。只比最终 argmax 可能掩盖小但系统性的错位。&lt;/p&gt;
&lt;h2&gt;11 左 padding、拼接与打包为何最容易错？&lt;/h2&gt;
&lt;p&gt;位置 id 不能总由一条共享的 &lt;code&gt;arange(L)&lt;/code&gt; 机械生成。&lt;/p&gt;
&lt;p&gt;设两条序列左侧补齐：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;token:      [PAD, PAD, 我, 爱, ML]
valid:      [  0,   0, 1,  1,  1]
语义位置:   [  -,   -, 0,  1,  2]

token:      [你, 也, 爱, ML, 吗]
valid:      [ 1, 1,  1,  1,  1]
语义位置:   [ 0, 1,  2,  3,  4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若直接给两个样本都用 &lt;code&gt;[0,1,2,3,4]&lt;/code&gt;，第一条真实 token 从位置 2 开始。某些训练方案允许这种绝对偏移，某些模型则要求首个真实 token 从 0 开始；训练和推理必须一致。可按有效 mask 生成每行位置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;valid = torch.tensor([
    [False, False, True, True, True],
    [True, True, True, True, True],
])
position_ids = valid.long().cumsum(dim=-1) - 1
position_ids = position_ids.clamp_min(0)
# [[0,0,0,1,2],
#  [0,1,2,3,4]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;padding 位置最终仍要由 attention mask 禁止作为 Key，损失也要忽略其标签。这里把 padding 的 position id 暂设 0，只是避免非法索引，不会自动让 padding 无效。&lt;/p&gt;
&lt;p&gt;文档拼接、检索片段、滑动窗口和 packed sequence 还要明确：位置是全局连续、每段重置，还是保留原文坐标。没有唯一答案，但训练、缓存、mask 与推理服务必须使用同一契约。&lt;/p&gt;
&lt;h2&gt;12 一条可执行的调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先做无位置对照。&lt;/strong&gt; 关闭位置表示和 dropout，同时重排输入与输出，验证自注意力近似满足置换等变，确认实验对象正确。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;打印三份张量。&lt;/strong&gt; token ids、valid mask、position ids 必须分别检查；不要从形状相同推断语义相同。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做共同平移测试。&lt;/strong&gt; 固定同一组内容 Q/K，把查询与键位置同时加 $\Delta$；RoPE 点积应在数值容差内保持一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做相对位移测试。&lt;/strong&gt; 只移动 Key 位置，确认点积确实随 $s-p$ 改变，而不是 RoPE 函数根本没接到注意力前。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证偶奇配对。&lt;/strong&gt; 最后一维必须按 &lt;code&gt;(0,1),(2,3),...&lt;/code&gt; 配对；错误地把前半维与后半维配对会得到另一种旋转布局，权重不能混用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比较完整前向与 KV Cache。&lt;/strong&gt; 检查 &lt;code&gt;past_length&lt;/code&gt;、新 position id 和已旋转 Key 的追加轴，逐位置 &lt;code&gt;assert_close&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测试左右 padding。&lt;/strong&gt; 同一真实序列换一种 padding 方式后，若模型契约要求语义位置从 0 开始，则真实 token 输出应保持接近。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测试边界长度。&lt;/strong&gt; 学习式绝对位置在 &lt;code&gt;L=max_length&lt;/code&gt; 与 &lt;code&gt;L=max_length+1&lt;/code&gt; 应有明确行为，不要等线上才遇到越界。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 float32 计算长位置角度。&lt;/strong&gt; 若误差随位置增长，先排查低精度角度、错误缩放和缓存重复旋转，再怀疑注意力内核。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 最常见的“形状正确，位置却错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只加 token embedding。&lt;/strong&gt; attention 能运行，却没有顺序坐标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;以为 causal mask 已经编码距离。&lt;/strong&gt; 它只阻止看未来，不区分相隔多远。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;token id 当 position id。&lt;/strong&gt; 重复词得到重复位置，语义完全颠倒。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;学习式 position 表越界后临时取模。&lt;/strong&gt; 不报错，但远位置被映射成错误的旧坐标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RoPE 在 hidden 上旋转一次。&lt;/strong&gt; 标准 RoPE 作用于每层每头的 Q/K，不能用一次输入旋转替代。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;旋转了 Q 却没旋转 K。&lt;/strong&gt; 相对位移恒等式不成立。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;连 Value 一起旋转并误称标准 RoPE。&lt;/strong&gt; 这改变了被取回内容的坐标系，是不同设计。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练右 padding、推理左 padding 却仍共享 &lt;code&gt;arange(L)&lt;/code&gt;。&lt;/strong&gt; 真实 token 的位置发生整体偏移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KV Cache 每步从 0 开始。&lt;/strong&gt; 新 token 的 RoPE 角度错误，完整与增量前向失配。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存未旋转 K，读取时按当前步统一旋转历史 K。&lt;/strong&gt; 历史 token 的各自位置被抹掉。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把“公式能算更长位置”当成可靠长上下文。&lt;/strong&gt; 模型可能从未学过那些相位、距离或注意力模式。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 三类方法会在哪里失败？&lt;/h2&gt;
&lt;p&gt;| 方法           | 主要优点                       | 主要边界                                   |
| -------------- | ------------------------------ | ------------------------------------------ |
| 正弦绝对位置   | 无位置参数，可生成任意整数位置 | 间接要求模型从 hidden 中学习相对关系       |
| 学习式绝对位置 | 灵活，能拟合训练数据的位置模式 | 最大表长固定，未训练行没有可靠语义         |
| RoPE           | Q–K 点积显式依赖相对位移       | 超训练长度仍可能外推失效，角度与缓存要一致 |&lt;/p&gt;
&lt;p&gt;三者都可能学到训练数据中的位置捷径：例如标签总出现在开头，模型可能依赖绝对位置而不是内容。随机裁剪、片段偏移或适合任务的数据增强能缓解部分问题，但不能取代正确的评估。&lt;/p&gt;
&lt;p&gt;长上下文尤其容易出现“能运行但质量下降”：显存允许把序列从 2K 扩到 32K，不代表位置表示、注意力分布和训练数据支持这种距离。应按长度分桶报告损失、检索准确率和位置敏感测试，而不是只验证没有 OOM。&lt;/p&gt;
&lt;h2&gt;15 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法                           | 如何影响注意力                       | 本文是否展开 |
| ------------------------------ | ------------------------------------ | ------------ |
| Shaw 相对位置表示              | 将相对距离向量写进注意力关系         | 仅作边界比较 |
| T5 相对位置偏置                | 按距离桶给 attention logits 加偏置   | 否           |
| ALiBi                          | 按头给距离施加线性惩罚               | 否           |
| RoPE                           | 旋转 Q/K，使点积含相对位移           | 是           |
| Position Interpolation/Scaling | 改写 RoPE 位置或频率以适配更长上下文 | 下一阶段     |&lt;/p&gt;
&lt;p&gt;相对位置偏置直接修改 &lt;code&gt;[N,H,L,S]&lt;/code&gt; 的分数矩阵；RoPE 修改 &lt;code&gt;[N,H,L,d]&lt;/code&gt; 的 Q/K 后再计算分数。两者都能表达相对结构，但参数化、内存访问与外推行为不同，不能只因都含“相对位置”就视为同一种实现。&lt;/p&gt;
&lt;h2&gt;16 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;自注意力本身对 token 排列置换等变；causal mask 只限制可见边，不能单独表达完整的位置与距离。&lt;/li&gt;
&lt;li&gt;正弦与学习式绝对位置都先进入 hidden，再影响 Q/K/V；前者是固定多频率坐标，后者是有限长度参数表。&lt;/li&gt;
&lt;li&gt;RoPE 对每层每头的 Q/K 成对旋转，利用 $R(p)^\top R(s)=R(s-p)$ 让注意力点积显式依赖相对位移。&lt;/li&gt;
&lt;li&gt;RoPE 通常不旋转 Value，也不改变 SDPA 张量形状；它发生在 Q/K 投影之后、点积之前。&lt;/li&gt;
&lt;li&gt;KV Cache 必须用全局 position id 旋转新 Q/K，并缓存已经按各自位置旋转的历史 Key。&lt;/li&gt;
&lt;li&gt;position ids、padding mask 与 token ids 是三份不同契约；左右 padding、拼接和打包时必须显式定义。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;取 $q=[1,2],k=[3,4]$、$\theta=\pi/3$。分别计算 &lt;code&gt;(p=1,s=2)&lt;/code&gt; 与 &lt;code&gt;(p=5,s=6)&lt;/code&gt; 的 RoPE 点积，验证共同平移后不变；再将第二组改为 &lt;code&gt;(p=5,s=7)&lt;/code&gt;，观察相对位移变化。&lt;/li&gt;
&lt;li&gt;修改 &lt;code&gt;apply_rope&lt;/code&gt;，故意只旋转 Query、不旋转 Key。写一个共同平移测试并解释为什么会失败。&lt;/li&gt;
&lt;li&gt;为长度 4 的完整 causal forward 与“前 2 token prefill + 后 2 token 逐步 decode”写等价测试。加入错误版本：每个 decode token 都使用 position 0，比较每个位置的最大绝对误差。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Vaswani et al. (2017), Attention Is All You Need&lt;/a&gt;：提出原始 Transformer 与固定正弦位置编码。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1803.02155&quot;&gt;Shaw, Uszkoreit &amp;#x26; Vaswani (2018), Self-Attention with Relative Position Representations&lt;/a&gt;：将相对距离表示直接引入自注意力关系。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2104.09864&quot;&gt;Su et al. (2021/2024), RoFormer: Enhanced Transformer with Rotary Position Embedding&lt;/a&gt;：提出 RoPE 并推导其绝对旋转与相对点积性质。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jmlr.org/papers/v21/20-074.html&quot;&gt;Raffel et al. (2020), Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer&lt;/a&gt;：T5 使用按相对距离分桶的可学习偏置。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2108.12409&quot;&gt;Press, Smith &amp;#x26; Lewis (2022), Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation&lt;/a&gt;：提出 ALiBi，以按头线性距离偏置探索长度外推。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;18 下一篇预告&lt;/h2&gt;
&lt;p&gt;RoPE 的公式可以为任意位置生成角度，但模型在 2K 长度上训练，并不会自动在 32K 上可靠工作。下一篇将区分“位置函数可计算”与“模型真正会外推”，拆解 Position Interpolation、频率缩放以及长上下文评估为何必须按距离和位置分桶。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>每生成一个词为何又重算全文？KV Cache 的增量解码</title><link>https://zwjcode.cn/blog/transformer-kv-cache-incremental-decoding</link><guid isPermaLink="true">https://zwjcode.cn/blog/transformer-kv-cache-incremental-decoding</guid><description>从自回归推理的重复前缀计算出发，追踪 Key/Value 缓存的张量生长、等价性、复杂度与工程边界。</description><pubDate>Tue, 08 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇组装了 Transformer Decoder 的三条信息流：因果自注意力读目标前缀，交叉注意力读源记忆，FFN 逐位置加工特征。透明的推理循环每次都把完整前缀重新送入模型，虽然答案正确，却在不断重做已经做过的投影和注意力。&lt;/p&gt;
&lt;p&gt;Key–Value Cache（KV Cache）的核心不是改变模型数学，而是保存每层历史 token 已经算好的 Key 和 Value。新一步只投影新 token，用它的 Query 读取“历史缓存 + 当前 token”。本文只讲透这个增量数据流。&lt;/p&gt;
&lt;h2&gt;01 无缓存解码到底重复了什么？&lt;/h2&gt;
&lt;p&gt;设 prompt 长度为 $P$，已生成 $t$ 个 token。无缓存方式在第 $t+1$ 步将长度 $P+t$ 的整个序列重新前向：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;第 1 步: [prompt]                         ─► 重算 prompt 所有 Q/K/V
第 2 步: [prompt, y1]                     ─► 又重算 prompt 与 y1
第 3 步: [prompt, y1, y2]                 ─► 又重算 prompt、y1、y2
...
第 t 步: [prompt, y1, ..., y(t-1)]        ─► 重算全部前缀
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对一层自注意力，每个历史 token 的 Key/Value 只由它进入该层时的隐状态与已固定权重决定。在因果模型中，后来的 token 不能反过来改写历史位置的隐状态，因此历史 K/V 可以复用。&lt;/p&gt;
&lt;p&gt;若每一步都重算长度 $t$ 的完整自注意力，仅分数矩阵工作量的累积就约为：&lt;/p&gt;
&lt;p&gt;$$
\sum_{t=1}^{T}O(t^2D)=O(T^3D)
$$&lt;/p&gt;
&lt;p&gt;用 KV Cache 后，每步只有一个新 Query 与 $t$ 个 Key 计算分数：&lt;/p&gt;
&lt;p&gt;$$
\sum_{t=1}^{T}O(tD)=O(T^2D)
$$&lt;/p&gt;
&lt;p&gt;这是简化的单层注意力量级，不包括 FFN、投影、内核常数、prompt 预填充和内存带宽。它说明缓存消除了哪类重复，不代表真实延迟会按同一比例下降。&lt;/p&gt;
&lt;h2&gt;02 每层缓存里究竟放什么？&lt;/h2&gt;
&lt;p&gt;对 $H$ 个头、每头宽度 $d$ 的自注意力，在第 $t$ 步开始时：&lt;/p&gt;
&lt;p&gt;| 张量               | 形状        | 含义                                |
| ------------------ | ----------- | ----------------------------------- |
| 新 token 隐状态    | &lt;code&gt;[N,1,D]&lt;/code&gt;   | 当前层只处理一个新位置              |
| 新 Query           | &lt;code&gt;[N,H,1,d]&lt;/code&gt; | 询问历史与当前信息                  |
| 新 Key/Value       | &lt;code&gt;[N,H,1,d]&lt;/code&gt; | 把当前 token 加入可被未来查询的记忆 |
| 更新后 Key Cache   | &lt;code&gt;[N,H,t,d]&lt;/code&gt; | 位置 1 到 $t$ 的全部 Key            |
| 更新后 Value Cache | &lt;code&gt;[N,H,t,d]&lt;/code&gt; | 位置 1 到 $t$ 的全部 Value          |
| 当步注意力分数     | &lt;code&gt;[N,H,1,t]&lt;/code&gt; | 一个新 Query 读取 $t$ 个 Key        |
| 当步输出           | &lt;code&gt;[N,1,D]&lt;/code&gt;   | 仅产生最新位置的表示                |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;历史缓存
K_cache [N,H,t-1,d] ─┐
V_cache [N,H,t-1,d] ─┤
                        ├─ append new K/V ─► K,V [N,H,t,d]
新 token x_t [N,1,D]     │
  └─ Q/K/V projection ─┘
          │
          └─ q_t [N,H,1,d] @ K^T [N,H,d,t]
                         ▼
                    scores [N,H,1,t]
                         ▼ softmax @ V
                    output [N,H,1,d] ─► [N,1,D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;缓存是&lt;strong&gt;每一层各自一份&lt;/strong&gt;。第 7 层的 Key/Value 来自第 7 层的输入表示，不能与第 3 层共用。若有 $L_{layers}$ 层，标准多头注意力缓存的元素数约为：&lt;/p&gt;
&lt;p&gt;$$
2,L_{layers},N,H,T,d
$$&lt;/p&gt;
&lt;p&gt;开头的 2 分别对应 Key 和 Value。若用 fp16/bfloat16，每元素通常 2 字节；批大小、层数和上下文长度都会线性放大缓存。&lt;/p&gt;
&lt;h2&gt;03 为什么缓存 K/V，通常不缓存 Query？&lt;/h2&gt;
&lt;p&gt;在因果生成的第 $t$ 步，我们只需要计算最新位置的输出。它的 Query $q_t$ 会读所有 $k_{\le t},v_{\le t}$。上一步的 $q_{t-1}$ 已经用于产生上一位置输出，新 token 不会回头重算该输出，因此历史 Query 没有再次被使用。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;时间 t-1： q_(t-1) 读 [k_1 ... k_(t-1)] ─► 输出已完成
时间 t：   q_t     读 [k_1 ... k_(t-1), k_t]
未来 t+1： q_(t+1) 读 [k_1 ... k_t, k_(t+1)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key/Value 是未来 Query 反复查询的记忆，Query 是当步一次性的读取请求。“不缓存 Q”指不为未来步保留历史 Query；当前内核在计算期间当然仍需要 $q_t$。&lt;/p&gt;
&lt;h2&gt;04 用一维头手算两步缓存&lt;/h2&gt;
&lt;p&gt;为了可手算，令单头宽度 $d=1$，缩放因子为 1。预填充后缓存为：&lt;/p&gt;
&lt;p&gt;$$
K^{(2)}=[1,2],\qquad V^{(2)}=[10,20]
$$&lt;/p&gt;
&lt;p&gt;第 3 个 token 投影得到 $q_3=1,k_3=0,v_3=30$。追加后：&lt;/p&gt;
&lt;p&gt;$$
K^{(3)}=[1,2,0],\qquad V^{(3)}=[10,20,30]
$$&lt;/p&gt;
&lt;p&gt;分数为 &lt;code&gt;[1,2,0]&lt;/code&gt;，softmax 近似为 &lt;code&gt;[0.245,0.665,0.090]&lt;/code&gt;，所以：&lt;/p&gt;
&lt;p&gt;$$
o_3\approx0.245(10)+0.665(20)+0.090(30)=18.45
$$&lt;/p&gt;
&lt;p&gt;第 4 步只需追加 $k_4,v_4$ 并计算新 $q_4$。已缓存的 &lt;code&gt;[1,2,0]&lt;/code&gt; 和 &lt;code&gt;[10,20,30]&lt;/code&gt; 不变。若无缓存，模型会从前缀隐状态再算一次这三个 Key/Value，最终 $o_3$ 不会因此更正确，只会更费计算。&lt;/p&gt;
&lt;h2&gt;05 预填充与逐 token 解码是两个阶段&lt;/h2&gt;
&lt;p&gt;KV Cache 推理常分成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;预填充（Prefill）&lt;/strong&gt;：一次输入整个 prompt，使用 causal attention 并行计算其表示，同时写入每层 prompt K/V。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;解码（Decode）&lt;/strong&gt;：每次只输入一个新 token，追加该 token 的 K/V，再用新 Query 读全部缓存。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;prefill:
prompt [N,P] ─► 并行 causal forward ─► 每层 K/V [N,H,P,d] + 下一 token logits

decode step 1:
y1 [N,1] ─► 新 K/V ─► cache length P+1 ─► y2 logits

decode step 2:
y2 [N,1] ─► 新 K/V ─► cache length P+2 ─► y3 logits
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预填充往往更偏计算密集，因为可以并行处理许多 prompt token；单 token decode 往往更受缓存读写和内存带宽限制。性能报告应分开首 token 延迟（Time to First Token）与后续 token 间隔，不要只给一个平均数。&lt;/p&gt;
&lt;h2&gt;06 用 PyTorch 2.13 SDPA 写透明的动态 KV Cache&lt;/h2&gt;
&lt;p&gt;下面的实现只展示单层 causal self-attention。为便于理解，它用 &lt;code&gt;torch.cat&lt;/code&gt; 追加缓存；生产实现应避免每步重新分配并复制已有缓存。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from typing import NamedTuple

import torch
from torch import nn
from torch.nn import functional as F

class KVCache(NamedTuple):
    key: torch.Tensor    # [N,H,S,d]
    value: torch.Tensor  # [N,H,S,d]

class CachedCausalSelfAttention(nn.Module):
    def __init__(self, model_dim: int, num_heads: int, dropout: float = 0.0):
        super().__init__()
        assert model_dim % num_heads == 0
        self.num_heads = num_heads
        self.head_dim = model_dim // num_heads
        self.dropout = dropout
        self.qkv = nn.Linear(model_dim, 3 * model_dim)
        self.out = nn.Linear(model_dim, model_dim)

    def _split_heads(self, x: torch.Tensor) -&gt; torch.Tensor:
        n, length, _ = x.shape
        x = x.view(n, length, self.num_heads, self.head_dim)
        return x.transpose(1, 2)  # [N,H,L,d]

    def forward(
        self,
        x: torch.Tensor,          # prefill: [N,P,D]; decode: [N,1,D]
        cache: KVCache | None = None,
        use_cache: bool = False,
    ) -&gt; tuple[torch.Tensor, KVCache | None]:
        n, length, model_dim = x.shape
        projected = self.qkv(x)
        q_raw, k_raw, v_raw = projected.chunk(3, dim=-1)
        q = self._split_heads(q_raw)
        new_k = self._split_heads(k_raw)
        new_v = self._split_heads(v_raw)

        if cache is None:
            key, value = new_k, new_v
            # prefill 长度 &gt; 1 时必须阻止 prompt 内的未来泄漏。
            causal = length &gt; 1
        else:
            assert length == 1, &quot;增量 decode 每次只应输入一个新 token&quot;
            assert cache.key.shape[:-2] == new_k.shape[:-2]
            key = torch.cat([cache.key, new_k], dim=-2)
            value = torch.cat([cache.value, new_v], dim=-2)
            # Key 只包含过去+当前，根本没有未来位置可见。
            causal = False

        attended = F.scaled_dot_product_attention(
            q, key, value,
            is_causal=causal,
            dropout_p=self.dropout if self.training else 0.0,
        )  # [N,H,L,d]
        merged = attended.transpose(1, 2).contiguous().view(n, length, model_dim)
        output = self.out(merged)
        next_cache = KVCache(key, value) if use_cache else None
        return output, next_cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.13 的 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention&quot;&gt;&lt;code&gt;scaled_dot_product_attention&lt;/code&gt;&lt;/a&gt; 接收 Query &lt;code&gt;[N,...,H_q,L,E]&lt;/code&gt;、Key &lt;code&gt;[N,...,H,S,E]&lt;/code&gt;与 Value &lt;code&gt;[N,...,H,S,E_v]&lt;/code&gt;，输出 &lt;code&gt;[N,...,H_q,L,E_v]&lt;/code&gt;。当前 API 仍标记为 Beta，并会根据输入和硬件选择可用后端。&lt;/p&gt;
&lt;p&gt;函数式 SDPA 会按 &lt;code&gt;dropout_p&lt;/code&gt; 无条件应用 dropout，不会自动读取 &lt;code&gt;module.eval()&lt;/code&gt;；因此代码必须在 eval 时显式传 &lt;code&gt;0.0&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;07 如何证明缓存没有改变结果？&lt;/h2&gt;
&lt;p&gt;在 dropout 关闭、相同精度与后端下，完整因果前向的每个位置输出，应与“预填充首个 token + 逐 token 缓存”的对应输出接近：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;torch.manual_seed(0)
attention = CachedCausalSelfAttention(16, 4, dropout=0.0).eval()
x = torch.randn(2, 6, 16)

with torch.inference_mode():
    full, _ = attention(x, cache=None, use_cache=False)

    pieces = []
    cache = None
    for position in range(x.size(1)):
        step, cache = attention(
            x[:, position:position + 1],
            cache=cache,
            use_cache=True,
        )
        pieces.append(step)
    incremental = torch.cat(pieces, dim=1)

torch.testing.assert_close(incremental, full, rtol=1e-5, atol=1e-6)
assert cache is not None
assert cache.key.shape == (2, 4, 6, 4)
assert cache.value.shape == (2, 4, 6, 4)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;融合内核、低精度累加和不同计算顺序可使末位出现微小数值差异，因此使用容差比较，不要用逐 bit 相等。但若误差很大或随长度急剧放大，应先查位置编码偏移、层缓存对应、mask 与 cache 追加轴。&lt;/p&gt;
&lt;h2&gt;08 位置索引为什么必须跟着缓存长度走？&lt;/h2&gt;
&lt;p&gt;增量步只输入 &lt;code&gt;[N,1]&lt;/code&gt; 的 token，但它不是“位置 0”。若缓存已有 $P+t$ 个位置，新 token 的绝对位置应是 $P+t$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;past_length = 0 if cache is None else cache.key.size(-2)
position_ids = torch.arange(
    past_length,
    past_length + input_ids.size(1),
    device=input_ids.device,
)  # prefill 可能是 [0..P-1]，decode 通常只有 [P+t]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;学习式绝对位置要查正确行；旋转位置编码（Rotary Position Embedding, RoPE）要用正确角度旋转新 Q/K；相对位置偏置要知道 Query 的全局位置。若每步都把新 token 当成位置 0，形状不会报错，缓存等价性却会立即失效。&lt;/p&gt;
&lt;h2&gt;09 Encoder–Decoder 的 cross-attention 还能缓存什么？&lt;/h2&gt;
&lt;p&gt;对 Encoder–Decoder Transformer，编码器 memory $M[N,S,D]$ 在整个目标生成期间不变。每个解码层的 cross-attention 可以将 memory 投影成本层的 $K_{mem},V_{mem}$ 一次，后续每步只从新目标表示计算 Query：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;生成前一次：
encoder memory [N,S,D] ─► 每层 cross-attn K/V projection
                           ─► K_mem,V_mem [N,H,S,d]

每个 decode step：
新目标表示 [N,1,D] ─► q_t [N,H,1,d]
                           ─► 读固定 K_mem,V_mem
                           ─► cross-attn output [N,1,D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这份 cross-attention K/V 的长度固定为 $S$；目标自注意力 K/V 则会随生成长度增长。两者的来源和生命周期不同，工程上不要放进一个无类型的“cache”列表里靠顺序猜。&lt;/p&gt;
&lt;h2&gt;10 从透明 &lt;code&gt;cat&lt;/code&gt; 到生产缓存&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;torch.cat([old,new], dim=-2)&lt;/code&gt; 每步都要为更长张量分配存储并复制历史内容。它适合教学和等价性测试，不是高并发服务的缓存管理策略。&lt;/p&gt;
&lt;p&gt;| 策略             | 写入方式                 | 优点                 | 主要代价                 |
| ---------------- | ------------------------ | -------------------- | ------------------------ |
| 动态 &lt;code&gt;cat&lt;/code&gt;       | 每步生成新张量           | 代码最透明           | 重复分配和拷贝           |
| 预分配静态 cache | 写入预定位置             | 形状稳定、少分配     | 需要最大长度和安全边界   |
| 分页 cache       | 用固定大小块映射逻辑序列 | 易共享、减少外部碎片 | 需要块表、调度和专用内核 |
| 滑动窗口         | 仅保留最近 $W$ 个位置    | 显存上界固定         | 丢弃窗口外直接证据       |&lt;/p&gt;
&lt;p&gt;静态 cache 常用 &lt;code&gt;cache_position&lt;/code&gt; 或等价索引写入，但“预分配成功”不代表 mask 正确：未写入槽位必须对当前 Query 不可见。分页 cache 还要在逻辑 token 位置和物理块地址之间维护正确映射。&lt;/p&gt;
&lt;h2&gt;11 Batch、EOS 与 Beam Search 为什么让缓存更难？&lt;/h2&gt;
&lt;p&gt;同一 batch 中的序列可能在不同时刻产生 &lt;code&gt;&amp;#x3C;eos&gt;&lt;/code&gt;。有三种常见处理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;保留已完成行并对它们生成 padding，调度简单但继续占用计算。&lt;/li&gt;
&lt;li&gt;将已完成序列从活跃 batch 移除，需要同步重排所有层 K/V 和输出索引。&lt;/li&gt;
&lt;li&gt;用连续批处理调度不同请求，需要更完整的块管理与隔离。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Beam Search 会让一条前缀分叉成多个候选。新 beam 在分叉前的 K/V 完全相同，可以逻辑共享；候选重排后，缓存的 batch/beam 维也必须按中选 parent beam 同步重排。只重排 token id 而忘了 K/V，形状仍然合法，每条 beam 却在读别人的历史。&lt;/p&gt;
&lt;h2&gt;12 一条可执行的调试与性能验证路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先做逐位置等价测试。&lt;/strong&gt; 关闭 dropout，比较完整因果前向与逐 token 缓存的所有位置，不只比最后 token。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查每层 cache 长度。&lt;/strong&gt; prefill 后应为 $P$，每步只增加 1；不同层必须一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查追加轴。&lt;/strong&gt; 序列轴是 &lt;code&gt;-2&lt;/code&gt;，头宽轴是 &lt;code&gt;-1&lt;/code&gt;；拼错轴有时会因数值巧合而暂时不报错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;打印新 Query 的全局位置。&lt;/strong&gt; 它应等于已有 cache 长度，而不是每步都回到 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做 cache 污染测试。&lt;/strong&gt; 两个请求交替生成，验证它们的缓存存储不共享可变写入区。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做 beam 重排测试。&lt;/strong&gt; 人工交换 parent beam 索引，检查所有层的 K/V 首维都按同一映射更新。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分开测 prefill 和 decode。&lt;/strong&gt; 分别记录首 token 延迟、每 token 延迟、吞吐、峰值缓存显存，并固定 batch、prompt 长度和生成长度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 profiler 确认少算了，而不是只看 wall time。&lt;/strong&gt; 检查 decode 步的 QKV 投影输入长度是 1，且没有隐式重建全前缀。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 最常见的缓存错误&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;prefill 没有 causal mask。&lt;/strong&gt; prompt 内的早期位置在预填充阶段偷看了后面。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对 &lt;code&gt;[L=1,S&gt;1]&lt;/code&gt; 的增量 SDPA 盲传 &lt;code&gt;is_causal=True&lt;/code&gt;。&lt;/strong&gt; 非方形 mask 对齐与想象不同，新 Query 可能读不到全部历史。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只缓存最后一层。&lt;/strong&gt; 前面每层仍重算整个前缀，计算没有真正增量化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复用了不同模型版本的 cache。&lt;/strong&gt; 权重更新后历史 K/V 已失效，请求内更不能热切模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;位置索引每步从 0 开始。&lt;/strong&gt; 学习式位置或 RoPE 与无缓存前向不等价。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将不同请求的缓存写进同一槽位。&lt;/strong&gt; 这不只是质量问题，还可能成为跨请求数据泄漏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;beam 排序后未重排缓存。&lt;/strong&gt; token 前缀与 K/V 历史不再对应。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动态 &lt;code&gt;cat&lt;/code&gt; 误当生产优化。&lt;/strong&gt; 注意力少算了，缓存却在每步整体复制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为 KV Cache 减少训练显存。&lt;/strong&gt; 它主要是自回归推理优化；标准训练还需要前向激活以供反向。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只报告 tokens/s，不报告测试形状。&lt;/strong&gt; batch、prompt、生成长度不同时，数字无法比较。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 缓存会在哪些场景失去优势？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;显存容量先到上限。&lt;/strong&gt; 长上下文、大 batch、多层多头会让 K/V 显存线性增长，可能迫使 batch 降低并伤害吞吐。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只生成极少 token。&lt;/strong&gt; 短输出时，缓存管理与内核启动的收益可能不明显，prompt prefill 仍占主导。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模型要修改历史表示。&lt;/strong&gt; 非因果、双向或对整段反复编辑的架构不满足“未来不改写过去”前提。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;窗口被截断。&lt;/strong&gt; 滑动窗口缓存把超出 $W$ 的 K/V 丢弃后，数学上已不再等价于全上下文模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存量化误差。&lt;/strong&gt; 低比特 K/V 可降显存与带宽，但误差会影响后续每一个 Query，需要按任务和长度验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;优化层次不同。&lt;/strong&gt; FlashAttention 类内核改善注意力内存访问，KV Cache 避免跨解码步重算；两者可同时使用，不是二选一。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 与相近技术怎样区分？&lt;/h2&gt;
&lt;p&gt;| 技术                     | 主要减少什么                   | 是否改变注意力连接 | 主要代价                 |
| ------------------------ | ------------------------------ | ------------------ | ------------------------ |
| KV Cache                 | 跨生成步的历史 K/V 重算        | 否                 | 持久显存与管理复杂度     |
| FlashAttention           | 精确注意力中间矩阵的 HBM 访问  | 否                 | 内核与硬件约束           |
| Multi-Query Attention    | 让所有 Query 头共享更少 K/V 头 | 改变参数化         | 表示容量与训练选择       |
| Grouped-Query Attention  | 让一组 Query 头共享 K/V 头     | 改变参数化         | 头数约束与质量折中       |
| Sliding-Window Attention | 每个 Query 的可见 Key 数       | 是                 | 无法直接读取窗口外 token |
| Speculative Decoding     | 大模型串行验证步数             | 不必然             | 草稿模型、验证与调度     |&lt;/p&gt;
&lt;p&gt;PyTorch 当前的 SDPA 还支持实验性 &lt;code&gt;enable_gqa=True&lt;/code&gt;，要求 Query 头数能整除 Key/Value 头数，且 Key 头数等于 Value 头数；当前后端和 Nested Tensor 仍有限制。这是减少缓存宽度的模型结构选择，不是打开 KV Cache 所必需的开关。&lt;/p&gt;
&lt;h2&gt;16 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;因果模型中，后来 token 不会改写历史位置，因此每层历史 Key/Value 可以在解码步之间复用。&lt;/li&gt;
&lt;li&gt;增量步的新 Query 是 &lt;code&gt;[N,H,1,d]&lt;/code&gt;，缓存为 &lt;code&gt;[N,H,t,d]&lt;/code&gt;，当步分数只是 &lt;code&gt;[N,H,1,t]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;历史 Key/Value 会被未来 Query 重复读取，历史 Query 不会；所以通常只持久缓存 K/V。&lt;/li&gt;
&lt;li&gt;prefill 是并行处理 prompt 并建立缓存，decode 是每步追加一个 K/V；两者的性能瓶颈不同。&lt;/li&gt;
&lt;li&gt;KV Cache 是用显存换重复计算，不会消除逐 token 依赖；长上下文、大 batch 和多 beam 时，缓存管理可变成主要问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 $L_{layers}=24,N=4,H=16,T=2048,d=64$ 的标准多头注意力，计算 fp16 K/V Cache 的元素数与约多少 GiB。若 Key/Value 头数降为 4，理论缓存降为原来多少？&lt;/li&gt;
&lt;li&gt;修改 &lt;code&gt;CachedCausalSelfAttention&lt;/code&gt; 的等价测试，先用前 4 个 token 做一次 prefill，再逐个输入后 2 个 token。验证与长度 6 的完整 causal forward 逐位置一致。&lt;/li&gt;
&lt;li&gt;将增量分支的 &lt;code&gt;is_causal=False&lt;/code&gt; 改成 &lt;code&gt;True&lt;/code&gt;，用三个可区分的 Value 打印输出。解释非方形因果对齐为何让唯一 Query 没有读到想象中的全部过去。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1901.02860&quot;&gt;Dai et al. (2019), Transformer-XL&lt;/a&gt;：复用跨片段隐状态并引入相对位置，展示历史状态复用对长依赖的价值。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1911.02150&quot;&gt;Shazeer (2019), Fast Transformer Decoding: One Write-Head is All You Need&lt;/a&gt;：提出 Multi-Query Attention，通过共享 K/V 头减少自回归解码的缓存带宽。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2305.13245&quot;&gt;Ainslie et al. (2023), GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints&lt;/a&gt;：用 Grouped-Query Attention 在多头容量与 MQA 缓存成本之间折中。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2309.06180&quot;&gt;Kwon et al. (2023), Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a&gt;：将分页内存思路用于 KV Cache，降低碎片并支持高吞吐服务。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;Dao et al. (2022), FlashAttention&lt;/a&gt;：从 IO 复杂度优化精确注意力，与跨步复用 K/V 的优化层次不同。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;18 下一篇预告&lt;/h2&gt;
&lt;p&gt;KV Cache 假设每个 token 带着正确的位置进入注意力。下一篇将回到这个前提，系统比较正弦绝对位置、学习式位置与旋转位置编码，并追踪位置信息究竟是加到隐状态，还是直接改写 Query–Key 的相似度。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>目标序列为何既要看过去又要看源文？Transformer Decoder 的三条信息流</title><link>https://zwjcode.cn/blog/transformer-decoder-causal-cross-attention</link><guid isPermaLink="true">https://zwjcode.cn/blog/transformer-decoder-causal-cross-attention</guid><description>从条件生成的信息边界出发，组装因果自注意力、交叉注意力与 FFN，并拆解并行训练和逐 token 生成。</description><pubDate>Mon, 07 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把自注意力、逐位置 FFN、残差和 LayerNorm 组成了 Transformer Encoder block。它能让源序列的每个 token 双向交换信息，却不能直接解决翻译这类问题：生成第 $t$ 个目标 token 时，模型既要读取已生成的目标前缀，又要回到整条源序列取证。&lt;/p&gt;
&lt;p&gt;Transformer Decoder 用三个子层分开这三种职责：&lt;strong&gt;因果自注意力&lt;/strong&gt;读目标前缀，&lt;strong&gt;交叉注意力&lt;/strong&gt;（Cross-Attention）读编码器记忆，FFN 在每个目标位置内加工特征。本文只追踪这三条信息流以及训练—推理边界；下一篇再专门解决逐 token 生成的重复计算。&lt;/p&gt;
&lt;h2&gt;01 只复制 Encoder block，会泄漏什么？&lt;/h2&gt;
&lt;p&gt;设源 token 长度为 $S$，目标 token 长度为 $T$，模型宽度为 $D$：&lt;/p&gt;
&lt;p&gt;$$
X_{src}\in\mathbb{R}^{N\times S\times D},\qquad
X_{tgt}\in\mathbb{R}^{N\times T\times D}
$$&lt;/p&gt;
&lt;p&gt;编码器允许源序列内双向注意，输出记忆（Encoder Memory）：&lt;/p&gt;
&lt;p&gt;$$
M=\operatorname{Encoder}(X_{src})\in\mathbb{R}^{N\times S\times D}
$$&lt;/p&gt;
&lt;p&gt;若目标侧也用无 mask 的双向自注意力，位置 $t$ 就能读到标签中的 $y_{t+1},y_{t+2},\ldots$。训练损失会虚假地降低，而真正生成时这些未来 token 根本不存在。&lt;/p&gt;
&lt;p&gt;因此解码器的第一条边界是：&lt;/p&gt;
&lt;p&gt;$$
p(y_t\mid y_{&amp;#x3C;t},x)
$$&lt;/p&gt;
&lt;p&gt;而不是 $p(y_t\mid y_{\ne t},x)$。因果 mask 不是可选的正则化，而是把训练信息集合限制成推理时真实可见信息的契约。&lt;/p&gt;
&lt;h2&gt;02 三个子层分别读什么？&lt;/h2&gt;
&lt;p&gt;以 Pre-LN 解码块为例，记子层输入为 $Y$：&lt;/p&gt;
&lt;p&gt;$$
U=Y+\operatorname{CausalSelfAttn}(\operatorname{LN}_1(Y))
$$&lt;/p&gt;
&lt;p&gt;$$
V=U+\operatorname{CrossAttn}(Q=\operatorname{LN}_2(U),K=M,V=M)
$$&lt;/p&gt;
&lt;p&gt;$$
Z=V+\operatorname{FFN}(\operatorname{LN}_3(V))
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;目标前缀 Y [N,T,D]
   │
   ├─ LN1 ─► causal self-attention ─► 残差相加 ─► U [N,T,D]
   │                                      只读目标位置 &amp;#x3C;= t
   │
   ├─ LN2 ─► Query [N,T,D] ─┐
   │                            ├─ cross-attention ─► 残差相加 ─► V
源记忆 M [N,S,D] ─► Key/Value ─┘                 可读所有真实源位置
   │
   └─ LN3 ─► FFN: D → F → D ─► 残差相加 ─► Z [N,T,D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最容易混淆的是 Query、Key、Value 的来源：&lt;/p&gt;
&lt;p&gt;| 子层                       | Query 来源 | Key 来源   | Value 来源 | 关系矩阵    |
| -------------------------- | ---------- | ---------- | ---------- | ----------- |
| 目标 causal self-attention | 目标表示   | 目标表示   | 目标表示   | &lt;code&gt;[N,H,T,T]&lt;/code&gt; |
| 源—目标 cross-attention    | 目标表示   | 编码器记忆 | 编码器记忆 | &lt;code&gt;[N,H,T,S]&lt;/code&gt; |
| 逐位置 FFN                 | 无 Q/K/V   | 无         | 无         | 不混合位置  |&lt;/p&gt;
&lt;p&gt;交叉注意力的输出长度是 Query 长度 $T$，不是源长度 $S$。它为每个目标位置从 $S$ 个源 Value 中取回一个 $D$ 维向量。&lt;/p&gt;
&lt;h2&gt;03 两种 mask 分别屏蔽哪条边？&lt;/h2&gt;
&lt;p&gt;解码器通常同时需要两类布尔 mask：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tgt_causal_mask [T,T]&lt;/code&gt;：第 $t$ 行禁止读取所有 $j&gt;t$ 的目标 Key。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;memory_key_padding_mask [N,S]&lt;/code&gt;：每个样本禁止读取源序列的 padding Key。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;目标因果 mask（✓=可读，×=禁止）
Query\Key   &amp;#x3C;bos&gt;   y1   y2   y3
&amp;#x3C;bos&gt;         ✓      ×    ×    ×
y1            ✓      ✓    ×    ×
y2            ✓      ✓    ✓    ×
y3            ✓      ✓    ✓    ✓

源 padding mask（对每个目标 Query 都屏蔽 PAD 列）
Source key    x1     x2   &amp;#x3C;pad&gt;
任意目标位置  ✓      ✓     ×
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;tgt_key_padding_mask [N,T]&lt;/code&gt; 还可用来阻止真实目标 Query 读取目标 padding 列。它不会自动清空 padding 查询行；计算 token 损失时仍要用 &lt;code&gt;ignore_index&lt;/code&gt; 或等价 mask 排除 padding 标签。&lt;/p&gt;
&lt;h2&gt;04 用一个 Query 手算交叉注意力&lt;/h2&gt;
&lt;p&gt;只看一个头，令 $d_k=2$。某目标位置的 Query 与两个源 Key 为：&lt;/p&gt;
&lt;p&gt;$$
q=[1,1],\quad k_1=[1,0],\quad k_2=[0,1]
$$&lt;/p&gt;
&lt;p&gt;缩放分数均为 $1/\sqrt{2}\approx0.707$，因此 softmax 权重是 &lt;code&gt;[0.5,0.5]&lt;/code&gt;。再令：&lt;/p&gt;
&lt;p&gt;$$
v_1=[2,0],\qquad v_2=[0,4]
$$&lt;/p&gt;
&lt;p&gt;取回的源信息为：&lt;/p&gt;
&lt;p&gt;$$
c=0.5v_1+0.5v_2=[1,2]
$$&lt;/p&gt;
&lt;p&gt;若第二个源位置是 padding，将它的分数改为 $-\infty$，权重变为 &lt;code&gt;[1,0]&lt;/code&gt;，输出就是 &lt;code&gt;[2,0]&lt;/code&gt;。这个例子也说明：交叉注意力可以同时取回多个源位置的混合证据，它不是必然选中单个对齐词。&lt;/p&gt;
&lt;h2&gt;05 为什么训练要把目标序列错开一位？&lt;/h2&gt;
&lt;p&gt;设真实目标为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;target:          [我, 喜欢, 机器, 学习, &amp;#x3C;eos&gt;]
decoder_input:   [&amp;#x3C;bos&gt;, 我, 喜欢, 机器, 学习]
prediction_for:  [我, 喜欢, 机器, 学习, &amp;#x3C;eos&gt;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解码器输入右移一位（Shift Right）后，位置 $t$ 使用真实前缀预测下一个 token。这是 Teacher Forcing：它允许训练时一次并行计算所有位置，但因果 mask 仍保证每个位置看不见右侧答案。&lt;/p&gt;
&lt;p&gt;若不错开一位，把同一 token 同时作为该位置输入和标签，模型可通过残差路径轻易复制当前 token，学到的不是下一 token 分布。&lt;/p&gt;
&lt;h2&gt;06 不调用 Decoder 封装，组装一个 Pre-LN 解码块&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class PreNormDecoderBlock(nn.Module):
    def __init__(self, model_dim, num_heads, ffn_dim, dropout=0.1):
        super().__init__()
        assert model_dim % num_heads == 0
        self.norm1 = nn.LayerNorm(model_dim)
        self.norm2 = nn.LayerNorm(model_dim)
        self.norm3 = nn.LayerNorm(model_dim)
        self.self_attn = nn.MultiheadAttention(
            model_dim, num_heads, dropout=dropout, batch_first=True
        )
        self.cross_attn = nn.MultiheadAttention(
            model_dim, num_heads, dropout=dropout, batch_first=True
        )
        self.ffn = nn.Sequential(
            nn.Linear(model_dim, ffn_dim),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(ffn_dim, model_dim),
        )
        self.drop1 = nn.Dropout(dropout)
        self.drop2 = nn.Dropout(dropout)
        self.drop3 = nn.Dropout(dropout)

    def forward(
        self,
        target,                 # [N,T,D]
        memory,                 # [N,S,D]
        target_valid,           # [N,T], True = 真实 token
        memory_valid,           # [N,S], True = 真实 token
    ):
        n, target_len, width = target.shape
        assert memory.shape[0] == n and memory.shape[2] == width
        assert target_valid.shape == (n, target_len)
        assert memory_valid.shape == memory.shape[:2]

        # MHA 的布尔 attn_mask=True 表示“禁止读取”。
        causal_block = torch.ones(
            target_len, target_len, dtype=torch.bool, device=target.device
        ).triu(diagonal=1)

        qkv = self.norm1(target)
        self_out, _ = self.self_attn(
            qkv, qkv, qkv,
            attn_mask=causal_block,
            key_padding_mask=~target_valid,
            need_weights=False,
        )
        target = target + self.drop1(self_out)

        query = self.norm2(target)
        cross_out, _ = self.cross_attn(
            query, memory, memory,
            key_padding_mask=~memory_valid,
            need_weights=False,
        )
        target = target + self.drop2(cross_out)
        target = target + self.drop3(self.ffn(self.norm3(target)))
        return target

block = PreNormDecoderBlock(32, 4, 128, dropout=0.0)
target = torch.randn(2, 5, 32)
memory = torch.randn(2, 7, 32)
target_valid = torch.ones(2, 5, dtype=torch.bool)
memory_valid = torch.tensor([
    [True, True, True, True, True, False, False],
    [True, True, True, True, True, True, True],
])
decoded = block(target, memory, target_valid, memory_valid)
assert decoded.shape == (2, 5, 32)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三次残差相加都要求输出为 &lt;code&gt;[N,T,D]&lt;/code&gt;。交叉注意力内部虽然建立 &lt;code&gt;[T,S]&lt;/code&gt; 关系，最终仍为每个目标 Query 返回一个 $D$ 维结果。&lt;/p&gt;
&lt;h2&gt;07 与 PyTorch 2.13 当前官方层对齐&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 的 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.TransformerDecoderLayer.html&quot;&gt;&lt;code&gt;nn.TransformerDecoderLayer&lt;/code&gt;&lt;/a&gt; 是用于理解原始架构的参考实现：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;official = nn.TransformerDecoderLayer(
    d_model=32,
    nhead=4,
    dim_feedforward=128,
    dropout=0.1,
    activation=&quot;gelu&quot;,
    batch_first=True,
    norm_first=True,
)

target_block = torch.ones(5, 5, dtype=torch.bool).triu(diagonal=1)
out = official(
    tgt=target,                         # [N,T,D]
    memory=memory,                      # [N,S,D]
    tgt_mask=target_block,              # [T,T], True = 禁止
    tgt_key_padding_mask=~target_valid, # [N,T], True = 忽略
    memory_key_padding_mask=~memory_valid,
)
assert out.shape == target.shape
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前 API 的关键契约是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;batch_first=True&lt;/code&gt; 才使用 &lt;code&gt;[N,T,D]&lt;/code&gt; 与 &lt;code&gt;[N,S,D]&lt;/code&gt;；默认仍是序列维在前。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;norm_first=True&lt;/code&gt; 对应 Pre-LN；默认 &lt;code&gt;False&lt;/code&gt; 是 Post-LN。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tgt_mask&lt;/code&gt; 管目标位置之间的边，&lt;code&gt;memory_key_padding_mask&lt;/code&gt; 管源 Key 列，它们不能互相替代。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tgt_is_causal&lt;/code&gt; 和 &lt;code&gt;memory_is_causal&lt;/code&gt; 是因果性提示；官方文档警告，错误提示可导致前向、反向或版本兼容性错误。&lt;/li&gt;
&lt;li&gt;标准的编码器记忆不是因果序列，因此不应随手设置 &lt;code&gt;memory_is_causal=True&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;官方层定位为基础参考实现，只提供有限的现代 Transformer 特性；生产推理的缓存、调度与融合内核需要另行设计。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;08 一次并行训练如何流动？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;source_ids [N,S] ─► source embedding + position ─► Encoder ─► memory [N,S,D]

target_ids [N,T+1]
   ├─ [:,:-1] ─► decoder_input [N,T] ─► embedding + position ─┐
   └─ [:, 1:] ─► labels [N,T]                                      │
                                                                            ▼
memory [N,S,D] ────────────────────────► K 个 Decoder blocks ─► hidden [N,T,D]
                                                                            │
                                                                      Linear(D,Vocab)
                                                                            ▼
                                                                    logits [N,T,V]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch.nn import functional as F

decoder_input_ids = target_ids[:, :-1]
labels = target_ids[:, 1:]

logits = model(source_ids, decoder_input_ids)  # [N,T,V]
loss = F.cross_entropy(
    logits.reshape(-1, logits.size(-1)),
    labels.reshape(-1),
    ignore_index=pad_id,
)

optimizer.zero_grad(set_to_none=True)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0, error_if_nonfinite=True)
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cross_entropy&lt;/code&gt; 接收未经 softmax 的 logits。将 &lt;code&gt;[N,T,V]&lt;/code&gt; 展平为 &lt;code&gt;[N*T,V]&lt;/code&gt; 时，标签也必须用相同的 batch-major 顺序展平；否则形状合法，token 却全部错位。&lt;/p&gt;
&lt;h2&gt;09 为什么生成不能像训练一样并行？&lt;/h2&gt;
&lt;p&gt;训练时，完整真实目标已知，因果 mask 使所有位置在一次前向中各自只看到合法前缀。开放式生成时，$y_t$ 必须先被选出，才能成为预测 $y_{t+1}$ 的输入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;model.eval()
generated = torch.full((n, 1), bos_id, device=device, dtype=torch.long)

with torch.inference_mode():
    memory, memory_valid = model.encode(source_ids)
    for _ in range(max_new_tokens):
        # 透明但低效：每轮重算整个已生成前缀。
        logits = model.decode(generated, memory, memory_valid)
        next_id = logits[:, -1].argmax(dim=-1, keepdim=True)
        generated = torch.cat([generated, next_id], dim=1)
        if (next_id == eos_id).all():
            break
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;贪心选择 &lt;code&gt;argmax&lt;/code&gt; 只是一种解码策略。采样、top-k、nucleus sampling 和 beam search 改变的是如何从当前 logits 选 token，不改变解码块的三条信息流。&lt;/p&gt;
&lt;p&gt;上面的循环每次把完整 &lt;code&gt;generated&lt;/code&gt; 重新送入所有解码层，正是下一篇 KV Cache 要修复的工程瓶颈。&lt;/p&gt;
&lt;h2&gt;10 一条可执行的调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先过拟合一个极小 batch。&lt;/strong&gt; 关闭 dropout，用两个短序列反复训练，确认 token 损失能接近 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做未来泄漏测试。&lt;/strong&gt; 固定目标前缀至位置 $t$，任意替换右侧 token；位置 $\le t$ 的 hidden state 和 logits 必须不变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做源信息依赖测试。&lt;/strong&gt; 保持目标前缀不变，交换两条差异很大的源序列；若 logits 完全不变，检查 cross-attention 是否被旁路或 mask 掉。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;打印两张关系矩阵的形状。&lt;/strong&gt; 自注意力应是 &lt;code&gt;[N,H,T,T]&lt;/code&gt;，交叉注意力应是 &lt;code&gt;[N,H,T,S]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查错位输入。&lt;/strong&gt; 直接打印 &lt;code&gt;decoder_input_ids[0]&lt;/code&gt; 和 &lt;code&gt;labels[0]&lt;/code&gt;，逐位验证左边比右边早一个 token。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分别测试两种 padding。&lt;/strong&gt; 替换源 padding token 不应改变任何真实目标位置；目标 padding 标签不应进入损失。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在 &lt;code&gt;dropout=0&lt;/code&gt; 下对齐手写层和官方层。&lt;/strong&gt; 复制参数后逐子层 &lt;code&gt;assert_close&lt;/code&gt;，别只比较最终 loss。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分开 train/eval 与 autograd 开关。&lt;/strong&gt; &lt;code&gt;model.eval()&lt;/code&gt; 关闭模块式 dropout，&lt;code&gt;inference_mode()&lt;/code&gt; 关闭梯度记录，两者不互相替代。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 最常见的“形状正确，语义却错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;自注意力没有 causal mask。&lt;/strong&gt; 训练 loss 异常好看，生成时却失效。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;目标输入和标签没有错开。&lt;/strong&gt; 模型通过残差路径复制当前 token。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 cross-attention 写成又一次 self-attention。&lt;/strong&gt; Q/K/V 都来自目标侧，源序列从未进入解码器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把编码器记忆当 Query。&lt;/strong&gt; 输出长度变成 $S$，不再与目标位置一一对应。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对 memory 错用 causal mask。&lt;/strong&gt; 普通翻译解码的每个目标位置都应能读取全部真实源 token。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只屏蔽目标 padding，漏掉源 padding。&lt;/strong&gt; cross-attention 会把补齐位置当成证据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只 mask 注意力，不 mask 损失。&lt;/strong&gt; padding 标签仍会改变梯度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;True&lt;/code&gt; 在 SDPA 和 MHA 中当成同一语义。&lt;/strong&gt; 两个接口的布尔 &lt;code&gt;attn_mask&lt;/code&gt; 方向相反。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;将 Teacher Forcing 误解为推理算法。&lt;/strong&gt; 生成时没有真实目标前缀可供喂入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;并行训练速度外推为并行生成速度。&lt;/strong&gt; 逐 token 依赖仍然是串行的。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 它会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;暴露偏差（Exposure Bias）。&lt;/strong&gt; 训练总看到正确前缀，推理却要接着自己的错误继续生成，小错可能累积。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;长序列成本。&lt;/strong&gt; 目标自注意力为 $T^2$，交叉注意力为 $TS$；训练显存和生成延迟都会增长。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;源序列含噪或过长。&lt;/strong&gt; 全局 cross-attention 不保证精确检索，相似证据可能被混合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练与业务解码目标不一致。&lt;/strong&gt; token 交叉熵不直接优化事实性、全局结构或人类偏好。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无条件生成。&lt;/strong&gt; Decoder-only 模型通常只保留 causal self-attention 和 FFN，并没有可读的编码器 memory；不应强行塞入空 cross-attention。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;非自回归任务。&lt;/strong&gt; 序列标注、双向理解或固定输出的回归问题未必需要 causal Decoder。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 与相近结构的边界&lt;/h2&gt;
&lt;p&gt;| 结构                         | 目标侧可见性     | 如何读源信息              | 典型用途             |
| ---------------------------- | ---------------- | ------------------------- | -------------------- |
| Transformer Encoder          | 通常双向         | 输入本身就是源序列        | 理解、表示、分类     |
| Encoder–Decoder Transformer  | 目标侧因果       | 专门的 cross-attention    | 翻译、摘要、条件生成 |
| Decoder-only Transformer     | 整个拼接序列因果 | 条件也作为左侧 token 前缀 | 语言建模、通用生成   |
| RNN Encoder–Decoder + 注意力 | 逐步递推         | 每步用隐状态查询源状态    | 经典序列转换         |&lt;/p&gt;
&lt;p&gt;Decoder-only 模型把 prompt 和输出放进同一条因果序列，不等于它内部隐藏了一个 cross-attention。Encoder–Decoder 的源记忆可以一次编码、被每个解码层重复查询；两者的 mask、缓存和部署契约不同。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Transformer Decoder block 有三条不同的信息流：目标前缀的 causal self-attention、目标查询源记忆的 cross-attention，以及逐位置 FFN。&lt;/li&gt;
&lt;li&gt;交叉注意力中 Query 来自目标侧，Key/Value 来自编码器 memory，权重形状是 &lt;code&gt;[N,H,T,S]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;目标 causal mask 禁止读未来，源 padding mask 禁止读补齐 Key，token 损失还要单独忽略 padding 标签。&lt;/li&gt;
&lt;li&gt;训练通过右移目标和 Teacher Forcing 并行计算所有位置；推理的下一 token 依赖已生成前缀，仍然逐步进行。&lt;/li&gt;
&lt;li&gt;最小未来泄漏测试和源扰动测试比单纯的形状断言更能捕捉解码器语义错误。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;设 $T=3,S=4,H=2,D=8$，写出目标自注意力和交叉注意力的 Q/K/V、分数与输出形状。解释为什么交叉注意力输出长度是 3 而不是 4。&lt;/li&gt;
&lt;li&gt;为 &lt;code&gt;PreNormDecoderBlock&lt;/code&gt; 写未来泄漏测试：固定位置 0–2，替换位置 3 以后的 target，验证前三个输出不变；再去掉 &lt;code&gt;causal_block&lt;/code&gt; 观察失败。&lt;/li&gt;
&lt;li&gt;把一个长度为 5 的 target 写成 &lt;code&gt;decoder_input&lt;/code&gt; 和 &lt;code&gt;labels&lt;/code&gt;，人为在末尾加两个 padding，列出因果 mask、目标 padding mask 和损失 mask 各自作用的位置。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Vaswani et al. (2017), Attention Is All You Need&lt;/a&gt;：提出由 masked self-attention、encoder–decoder attention 和 FFN 组成的 Transformer Decoder。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1409.0473&quot;&gt;Bahdanau, Cho &amp;#x26; Bengio (2015), Neural Machine Translation by Jointly Learning to Align and Translate&lt;/a&gt;：在循环 Encoder–Decoder 中引入可学习对齐，是 cross-attention 信息流的重要前身。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1409.3215&quot;&gt;Sutskever, Vinyals &amp;#x26; Le (2014), Sequence to Sequence Learning with Neural Networks&lt;/a&gt;：展示了早期无注意力序列转换的定长语境瓶颈。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/neco.1989.1.2.270&quot;&gt;Williams &amp;#x26; Zipser (1989), A Learning Algorithm for Continually Running Fully Recurrent Neural Networks&lt;/a&gt;：早期 Teacher Forcing 与循环网络训练语境的经典工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1506.03099&quot;&gt;Bengio et al. (2015), Scheduled Sampling for Sequence Prediction with Recurrent Neural Networks&lt;/a&gt;：尝试缓解训练真实前缀与推理模型前缀之间的暴露偏差。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;解码器的信息边界已经正确，但上面的推理循环每生成一个 token 都重算整个前缀。下一篇将拆解 KV Cache：为什么历史 Key/Value 可以复用、Query 通常不缓存，以及缓存如何改变每步张量形状、计算量与显存占用。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>注意力之后为何还要逐位置变换？Transformer Block 的 FFN、残差与 Pre-LN</title><link>https://zwjcode.cn/blog/transformer-block-ffn-residual-preln-postln</link><guid isPermaLink="true">https://zwjcode.cn/blog/transformer-block-ffn-residual-preln-postln</guid><description>从注意力只负责位置混合的局限出发，拆解逐位置前馈网络、两条残差路径，并比较 Pre-LN 与 Post-LN 的数据流和梯度路径。</description><pubDate>Mon, 07 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把缩放点积自注意力（Scaled Dot-Product Self-Attention）拆成了 $QK^\top$、mask、softmax 与 $V$ 的加权和。它让一个 token 能读取其他位置，却没有回答另一个问题：读回来的信息怎样在每个位置内部完成非线性加工？&lt;/p&gt;
&lt;p&gt;Transformer Block 用两个互补子层回答它：多头自注意力负责&lt;strong&gt;沿序列位置混合信息&lt;/strong&gt;，逐位置前馈网络（Position-wise Feed-Forward Network, FFN）负责&lt;strong&gt;沿特征维度变换信息&lt;/strong&gt;。每个子层外再放残差连接、LayerNorm 与 dropout，才组成可堆叠的块。&lt;/p&gt;
&lt;p&gt;本文只讲透这一个块：先追踪注意力与 FFN 的张量轴，再比较归一化放在残差分支之前还是之后。Encoder–Decoder 的交叉注意力、KV Cache 与完整语言模型生成留到后续文章。&lt;/p&gt;
&lt;h2&gt;01 只有自注意力，为什么仍然不够？&lt;/h2&gt;
&lt;p&gt;设输入为：&lt;/p&gt;
&lt;p&gt;$$
X\in\mathbb{R}^{N\times L\times D}
$$&lt;/p&gt;
&lt;p&gt;$N$ 是 batch 大小，$L$ 是序列长度，$D$ 是模型宽度。自注意力对第 $i$ 个位置输出：&lt;/p&gt;
&lt;p&gt;$$
a_i=\sum_{j=1}^{L}\alpha_{ij}v_j
$$&lt;/p&gt;
&lt;p&gt;它擅长决定“第 $i$ 个位置该从哪些位置读取什么”，但输出仍以 Value 的加权混合为核心。若一个位置读回了“主语”“否定词”和“动作”三类线索，还需要一个共享的非线性函数把这些特征组合成新的表示。&lt;/p&gt;
&lt;p&gt;FFN 对每个位置独立应用同一组参数：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{FFN}(x_i)
=W_2,\phi(W_1x_i+b_1)+b_2
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;p&gt;| 变量   | 形状    | 作用                            |
| ------ | ------- | ------------------------------- |
| $x_i$  | &lt;code&gt;[D]&lt;/code&gt;   | 第 $i$ 个 token 当前表示        |
| $W_1$  | &lt;code&gt;[F,D]&lt;/code&gt; | 把特征从 $D$ 扩张到中间宽度 $F$ |
| $b_1$  | &lt;code&gt;[F]&lt;/code&gt;   | 第一层偏置                      |
| $W_2$  | &lt;code&gt;[D,F]&lt;/code&gt; | 把中间特征投影回 $D$            |
| $b_2$  | &lt;code&gt;[D]&lt;/code&gt;   | 第二层偏置                      |
| $\phi$ | 逐元素  | ReLU、GELU 等非线性             |&lt;/p&gt;
&lt;p&gt;对整个 batch，&lt;code&gt;nn.Linear&lt;/code&gt; 只改变最后一维：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,L,D]
   │
   ├─ Linear(D → F) ─► H [N,L,F]
   ├─ activation     ─► H&apos; [N,L,F]
   ├─ dropout        ─► H&apos;&apos;[N,L,F]
   └─ Linear(F → D) ─► Z [N,L,D]

位置 1、2、...、L 使用相同的 W1/W2，但彼此不做求和。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以两类子层分工非常清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;self-attention：沿 L 轴交换信息，关系矩阵是 [L,L]
FFN：           沿 D/F 轴加工信息，每个位置独立
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有位置混合，FFN 看不到别的 token；没有特征变换，自注意力读回的信息缺少逐位置的非线性加工。&lt;/p&gt;
&lt;h2&gt;02 一个 Transformer Block 的完整数据流&lt;/h2&gt;
&lt;p&gt;先看 Pre-LN（Pre-Layer Normalization）版本。&lt;code&gt;LN&lt;/code&gt; 位于每个残差分支的输入端：&lt;/p&gt;
&lt;p&gt;$$
U=X+\operatorname{Dropout}(\operatorname{MHA}(\operatorname{LN}_1(X)))
$$&lt;/p&gt;
&lt;p&gt;$$
Y=U+\operatorname{Dropout}(\operatorname{FFN}(\operatorname{LN}_2(U)))
$$&lt;/p&gt;
&lt;p&gt;完整数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,L,D]
│
├─────────────────────────────────────────────────────┐
└─ LN1 ─► Multi-Head Self-Attention ─► Dropout ─► (+) ─► U [N,L,D]
                                                      │
                           ┌──────────────────────────┘
                           ├─────────────────────────────────────────────┐
                           └─ LN2 ─► Linear D→F ─► GELU ─► Linear F→D  │
                                                        └─► Dropout ─► (+) ─► Y [N,L,D]
                                                                         ▲
                           U ──────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为避免图中紧凑标注造成误读，FFN 的精确宽度变化是 &lt;code&gt;D → F → D&lt;/code&gt;。两次残差相加都要求分支输入与输出为 &lt;code&gt;[N,L,D]&lt;/code&gt;；$F$ 只存在于 FFN 内部。&lt;/p&gt;
&lt;p&gt;若 $F=4D$，仅 FFN 两个权重矩阵就约有：&lt;/p&gt;
&lt;p&gt;$$
DF+FD=8D^2
$$&lt;/p&gt;
&lt;p&gt;个参数；标准 Q/K/V 与输出投影合计约 $4D^2$。忽略偏置时，FFN 常比注意力投影拥有更多参数。注意力矩阵可能主导长序列的激活显存，FFN 则常主导块内参数与逐 token 计算量；不能只优化其中一边。&lt;/p&gt;
&lt;h2&gt;03 用两个特征手算一次 FFN 与残差&lt;/h2&gt;
&lt;p&gt;暂时只看一个 token，令 $D=2,F=2$：&lt;/p&gt;
&lt;p&gt;$$
x=\begin{bmatrix}1\2\end{bmatrix},\quad
W_1=\begin{bmatrix}1&amp;#x26;-1\0.5&amp;#x26;0.5\end{bmatrix},\quad
b_1=\begin{bmatrix}0\0\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;第一层得到：&lt;/p&gt;
&lt;p&gt;$$
h=W_1x+b_1
=\begin{bmatrix}-1\1.5\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;使用 ReLU 后：&lt;/p&gt;
&lt;p&gt;$$
\phi(h)=\begin{bmatrix}0\1.5\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;再令：&lt;/p&gt;
&lt;p&gt;$$
W_2=\begin{bmatrix}2&amp;#x26;0\0&amp;#x26;-1\end{bmatrix},\quad b_2=0
$$&lt;/p&gt;
&lt;p&gt;则 FFN 修正量为：&lt;/p&gt;
&lt;p&gt;$$
z=W_2\phi(h)=\begin{bmatrix}0\-1.5\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;残差相加后：&lt;/p&gt;
&lt;p&gt;$$
y=x+z=\begin{bmatrix}1\0.5\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;FFN 没有重新生成整个 token 表示，而是把第二个特征向下修正了 1.5。若第二个序列位置输入不同，它会独立经过完全相同的 $W_1,W_2$；两位置在这一步不会相互读取。&lt;/p&gt;
&lt;p&gt;现在忽略 $\epsilon$，对 $y=[1,0.5]$ 做不带仿射参数的 LayerNorm。均值为 $0.75$，方差为：&lt;/p&gt;
&lt;p&gt;$$
\sigma^2=\frac{(1-0.75)^2+(0.5-0.75)^2}{2}=0.0625
$$&lt;/p&gt;
&lt;p&gt;所以标准化结果为 &lt;code&gt;[1,-1]&lt;/code&gt;。LayerNorm 改变的是单个 token 内特征的中心与尺度；它不沿 batch 或序列长度统计，也不会让两个 token 互相通信。&lt;/p&gt;
&lt;h2&gt;04 Post-LN 与 Pre-LN 究竟差在哪里？&lt;/h2&gt;
&lt;p&gt;原始 Transformer 使用 Post-LN（Post-Layer Normalization）。对任一子层 $S$：&lt;/p&gt;
&lt;p&gt;$$
y_{post}=\operatorname{LN}(x+S(x))
$$&lt;/p&gt;
&lt;p&gt;Pre-LN 改为：&lt;/p&gt;
&lt;p&gt;$$
y_{pre}=x+S(\operatorname{LN}(x))
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Post-LN：
x ───────────────┐
└─ Sublayer ─────┴─ (+) ─► LayerNorm ─► y

Pre-LN：
x ─────────────────────────────┐
└─ LayerNorm ─► Sublayer ──────┴─ (+) ─► y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前向形状完全相同，差别却不只是“代码顺序”。设 $J_S$ 和 $J_{LN}$ 分别为子层与 LayerNorm 对输入的雅可比矩阵，则局部梯度路径可写为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial y_{post}}{\partial x}
=J_{LN}(x+S(x))\left(I+J_S(x)\right)
$$&lt;/p&gt;
&lt;p&gt;Post-LN 中，残差相加后的所有信号还要经过 LayerNorm 的雅可比。Pre-LN 则有：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial y_{pre}}{\partial x}
=I+J_S(\operatorname{LN}(x))J_{LN}(x)
$$&lt;/p&gt;
&lt;p&gt;这里出现一条显式恒等项 $I$：即使残差分支的局部梯度很小，仍有一条不经过本块 LayerNorm 与子层的直接路径。深层网络中，Pre-LN 往往更容易在训练初期维持梯度传播；这也是许多现代 Transformer 采用它的原因。&lt;/p&gt;
&lt;p&gt;Pre-LN 堆叠后通常还会在整个栈末尾加一次最终 LayerNorm：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tokens ─► embedding + position ─► Block₁ ─► ... ─► Block_K ─► final LN ─► task head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;漏掉最终归一化，数值范围和已有实现的输出契约都会改变。&lt;/p&gt;
&lt;h2&gt;05 LayerNorm 到底沿哪条轴计算？&lt;/h2&gt;
&lt;p&gt;对 token 向量 $x_i\in\mathbb{R}^{D}$，LayerNorm（Layer Normalization）计算：&lt;/p&gt;
&lt;p&gt;$$
\mu_i=\frac1D\sum_{r=1}^{D}x_{i,r},\qquad
\sigma_i^2=\frac1D\sum_{r=1}^{D}(x_{i,r}-\mu_i)^2
$$&lt;/p&gt;
&lt;p&gt;$$
\operatorname{LN}(x_i)&lt;em&gt;r
=\gamma_r\frac{x&lt;/em&gt;{i,r}-\mu_i}{\sqrt{\sigma_i^2+\epsilon}}+\beta_r
$$&lt;/p&gt;
&lt;p&gt;对 &lt;code&gt;[N,L,D]&lt;/code&gt; 调用 &lt;code&gt;nn.LayerNorm(D)&lt;/code&gt; 时，每个 batch、每个位置分别沿最后一维 $D$ 统计，输出仍是 &lt;code&gt;[N,L,D]&lt;/code&gt;。$\gamma,\beta\in\mathbb{R}^{D}$ 是逐特征可学习参数；统计量来自当前 token，在训练和推理时都这样计算，不维护 BatchNorm 式 running mean/variance。&lt;/p&gt;
&lt;p&gt;这里有三个常见混淆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LayerNorm(D)&lt;/code&gt; 不会跨 batch 统计，batch size 从 32 改成 1 不会切换统计公式。&lt;/li&gt;
&lt;li&gt;它也不会沿 $L$ 统计，因此 padding 位置不会直接污染真实 token 的 LayerNorm 统计；padding 仍需在注意力和损失端处理。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eps&lt;/code&gt; 在平方根内用于数值稳定；混合精度下若出现非有限值，既要检查 &lt;code&gt;eps&lt;/code&gt;，也要检查进入归一化前的激活范围。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;06 不调用 Transformer 封装，写出透明的 Pre-LN Block&lt;/h2&gt;
&lt;p&gt;下面只用基础模块组装一个 Encoder block。它使用非因果自注意力；&lt;code&gt;valid_tokens=True&lt;/code&gt; 表示真实 token，而 &lt;code&gt;nn.MultiheadAttention&lt;/code&gt; 的布尔 &lt;code&gt;key_padding_mask=True&lt;/code&gt; 表示应忽略，所以传入时要取反。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class PreNormEncoderBlock(nn.Module):
    def __init__(
        self,
        model_dim: int,
        num_heads: int,
        ffn_dim: int,
        dropout: float = 0.1,
    ) -&gt; None:
        super().__init__()
        assert model_dim % num_heads == 0
        assert ffn_dim &gt;= model_dim

        self.norm1 = nn.LayerNorm(model_dim)
        self.self_attn = nn.MultiheadAttention(
            embed_dim=model_dim,
            num_heads=num_heads,
            dropout=dropout,      # attention 权重上的 dropout
            batch_first=True,
        )
        self.attn_output_dropout = nn.Dropout(dropout)

        self.norm2 = nn.LayerNorm(model_dim)
        self.ffn = nn.Sequential(
            nn.Linear(model_dim, ffn_dim),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(ffn_dim, model_dim),
        )
        self.ffn_output_dropout = nn.Dropout(dropout)

    def forward(
        self,
        x: torch.Tensor,             # [N,L,D]
        valid_tokens: torch.Tensor,  # [N,L], bool, True = 真实 token
    ) -&gt; torch.Tensor:
        n, length, width = x.shape
        assert valid_tokens.shape == (n, length)
        assert valid_tokens.dtype == torch.bool
        assert valid_tokens.any(dim=1).all()

        qkv = self.norm1(x)  # [N,L,D]
        attn_out, _ = self.self_attn(
            qkv, qkv, qkv,
            key_padding_mask=~valid_tokens,  # MHA 中 True = 忽略 key
            need_weights=False,
        )  # [N,L,D], None
        x = x + self.attn_output_dropout(attn_out)

        ffn_out = self.ffn(self.norm2(x))  # [N,L,D]
        x = x + self.ffn_output_dropout(ffn_out)
        return x

block = PreNormEncoderBlock(
    model_dim=32, num_heads=4, ffn_dim=128, dropout=0.1
)
tokens = torch.randn(2, 6, 32)
valid = torch.tensor([
    [True, True, True, True, False, False],
    [True, True, True, True, True, True],
])
output = block(tokens, valid)
assert output.shape == (2, 6, 32)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;self_attn&lt;/code&gt; 内部的 &lt;code&gt;dropout&lt;/code&gt; 作用于注意力权重；残差分支输出处的 dropout 是另一处随机化，不能因为都叫 dropout 就合并。&lt;code&gt;nn.Dropout&lt;/code&gt; 在训练时把保留元素按 $1/(1-p)$ 缩放，在 &lt;code&gt;eval()&lt;/code&gt; 时成为恒等映射。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;key_padding_mask&lt;/code&gt; 只阻止真实查询读取 padding &lt;strong&gt;键列&lt;/strong&gt;。padding 查询行仍可能产生非零输出，残差也会继续携带它们。若任务头做平均池化，应显式用 &lt;code&gt;valid_tokens&lt;/code&gt; 做 masked mean；若做 token 级损失，应使用 &lt;code&gt;ignore_index&lt;/code&gt; 或等价 mask。&lt;/p&gt;
&lt;h2&gt;07 怎样改成 Post-LN？&lt;/h2&gt;
&lt;p&gt;模块参数可以不变，只改前向顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def post_norm_forward(self, x, valid_tokens):
    attn_out, _ = self.self_attn(
        x, x, x,
        key_padding_mask=~valid_tokens,
        need_weights=False,
    )
    x = self.norm1(x + self.attn_output_dropout(attn_out))
    x = self.norm2(x + self.ffn_output_dropout(self.ffn(x)))
    return x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要在 Pre-LN 代码上“顺手”保留相加后的第二次归一化，否则会变成第三种结构。架构实验必须把每个 LayerNorm 的输入、输出和残差相加位置画出来，而不是只在配置里记录一个含糊的 &lt;code&gt;pre_norm=True&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;若要让 Pre-LN 块在初始化时接近恒等映射，可把残差分支的最后输出投影初始化得很小或为零；但这会改变默认初始化，必须记录并单独验证，不能默默加入“透明实现”。&lt;/p&gt;
&lt;h2&gt;08 与 PyTorch 2.13 当前官方层对齐&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 的 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.TransformerEncoderLayer.html&quot;&gt;&lt;code&gt;nn.TransformerEncoderLayer&lt;/code&gt;&lt;/a&gt; 是用于理解基础架构的参考实现。关键参数为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from torch import nn

official = nn.TransformerEncoderLayer(
    d_model=32,
    nhead=4,
    dim_feedforward=128,
    dropout=0.1,
    activation=&quot;gelu&quot;,
    batch_first=True,
    norm_first=True,   # True = Pre-LN；默认 False = Post-LN
    layer_norm_eps=1e-5,
    bias=True,
)

src = torch.randn(2, 6, 32)
src_key_padding_mask = ~valid
out = official(src, src_key_padding_mask=src_key_padding_mask)
assert out.shape == src.shape
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前 API 有几项值得写进契约测试：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;batch_first=True&lt;/code&gt; 才使用 &lt;code&gt;[N,L,D]&lt;/code&gt;；默认仍是 &lt;code&gt;[L,N,D]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;norm_first=True&lt;/code&gt; 表示注意力和 FFN 之前做 LayerNorm；默认 &lt;code&gt;False&lt;/code&gt; 对应 Post-LN。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dim_feedforward&lt;/code&gt; 是 $F$，不会改变最终输出宽度 $D$。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;activation&lt;/code&gt; 当前可用字符串 &lt;code&gt;&quot;relu&quot;&lt;/code&gt;、&lt;code&gt;&quot;gelu&quot;&lt;/code&gt; 或一元 callable；默认是 ReLU。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src_key_padding_mask=True&lt;/code&gt; 表示忽略该键；&lt;code&gt;is_causal&lt;/code&gt; 是因果 mask 的提示，错误提示可能导致不正确执行。&lt;/li&gt;
&lt;li&gt;该层是基础参考实现，只提供有限的现代 Transformer 特性；不能把“官方类”误解为所有场景下最快或最完整的生产实现。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;官方文档还列出了推理优化路径的条件，例如 &lt;code&gt;.eval()&lt;/code&gt;、关闭 autograd、三维 batch-first 输入、受支持激活，以及 mask 组合限制。优化是否命中应以 profiler 和所用版本为准，不要靠类名猜测。&lt;/p&gt;
&lt;h2&gt;09 从 token 到分类结果，一次训练怎样流动？&lt;/h2&gt;
&lt;p&gt;以文本分类为例，$V$ 是词表大小，$C$ 是类别数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;token_ids [N,L]
  ├─ Embedding(V,D) ─► token vectors [N,L,D]
  ├─ position vectors [L,D]（广播到 batch）
  ▼
X [N,L,D]
  └─ K 个 Pre-LN Block ─► final LayerNorm ─► H [N,L,D]
                                        │
valid_tokens [N,L] ─► masked mean ──────┘
                                        ▼
                                  pooled [N,D]
                                        ▼
                                  Linear(D,C)
                                        ▼
                                  logits [N,C]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键部分可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def masked_mean(x, valid_tokens):
    weights = valid_tokens.unsqueeze(-1).to(x.dtype)  # [N,L,1]
    summed = (x * weights).sum(dim=1)                  # [N,D]
    counts = weights.sum(dim=1).clamp_min(1.0)         # [N,1]
    return summed / counts

model.train()
logits = model(token_ids, valid_tokens)  # [N,C]，未经 softmax
loss = nn.functional.cross_entropy(logits, labels)  # labels [N]

optimizer.zero_grad(set_to_none=True)
loss.backward()
grad_norm = nn.utils.clip_grad_norm_(
    model.parameters(), max_norm=1.0, error_if_nonfinite=True
)
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推理时需要同时切换模块行为与关闭梯度记录：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;model.eval()
with torch.inference_mode():
    logits = model(token_ids, valid_tokens)  # [N,C]
    probabilities = logits.softmax(dim=-1)   # [N,C]
    predictions = probabilities.argmax(dim=-1)  # [N]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;model.eval()&lt;/code&gt; 会关闭模块式 Dropout，但它本身不关闭 autograd；&lt;code&gt;torch.inference_mode()&lt;/code&gt; 关闭梯度记录，却不会替你把模型切到 eval。两者职责不同。&lt;/p&gt;
&lt;h2&gt;10 一条可执行的调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先过拟合一个极小 batch。&lt;/strong&gt; 用 4 条固定长度样本，关闭 dropout，确认损失能快速接近 0；否则先查标签、mask 和残差顺序。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐点打印形状。&lt;/strong&gt; 注意力、两次残差相加、块输出都应为 &lt;code&gt;[N,L,D]&lt;/code&gt;；FFN 中间才是 &lt;code&gt;[N,L,F]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做 padding 不变性测试。&lt;/strong&gt; 固定真实前缀，只替换 padding token 的 embedding；真实位置输出和 pooled logits 应保持不变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单独关掉子层。&lt;/strong&gt; 把注意力输出投影或 FFN 第二个 Linear 置零，Pre-LN 块应分别退化为另一子层加恒等路径。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录逐层残差比例。&lt;/strong&gt; 监控 &lt;code&gt;||S(LN(x))|| / ||x||&lt;/code&gt;；突然从小量级跳到数十倍常预示学习率、初始化或数值问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录深度方向梯度。&lt;/strong&gt; 对每层输入保留梯度，比较浅层到深层的范数；只看全模型总梯度会掩盖 Post-LN 的局部衰减。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定随机性比较 train/eval。&lt;/strong&gt; Dropout 开启时两次训练前向可以不同；eval 前向应一致。若验证仍抖动，检查是否调用了函数式 dropout 且忘传 &lt;code&gt;training=self.training&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对齐官方层。&lt;/strong&gt; 在小维度、dropout=0 下复制参数，逐子层比较输出；不要只比较最终 loss。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;padding 不变性测试应同时覆盖“真实位置表示”和“任务头输出”。只检查注意力权重的 padding 列为零，仍可能在无 mask 的平均池化处把 padding 查询混入结果。&lt;/p&gt;
&lt;h2&gt;11 最常见的“形状正确，结构却错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;把 FFN 写成跨序列卷积或先展平 &lt;code&gt;[L,D]&lt;/code&gt;。&lt;/strong&gt; 标准逐位置 FFN 共享参数但不混合位置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第一层扩到 $F$ 后忘记投回 $D$。&lt;/strong&gt; 残差相加因此失败，或被迫引入未经设计的投影。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把两次 LayerNorm 复用成同一个实例。&lt;/strong&gt; 两个位置通常各有自己的 $\gamma,\beta$；共享会改变参数化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在 Pre-LN 相加后又做 LayerNorm。&lt;/strong&gt; 这不再是本文公式中的 Pre-LN。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只给 FFN 加残差，漏掉注意力残差。&lt;/strong&gt; 信息与梯度都必须穿过注意力分支。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一个 Dropout 实例并非错误，但把不同 dropout 位置当成一次操作是错误。&lt;/strong&gt; 注意力权重、FFN 隐层和残差分支输出的作用点不同。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;key_padding_mask&lt;/code&gt; 真值方向反了。&lt;/strong&gt; 在 MHA 中 &lt;code&gt;True=忽略&lt;/code&gt;，与上一篇 SDPA 布尔 mask 的语义相反。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只 mask 注意力，不 mask 池化或损失。&lt;/strong&gt; padding 查询仍可进入任务头。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pre-LN 栈漏掉 final LayerNorm。&lt;/strong&gt; 最终表示尺度与常见实现不一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 &lt;code&gt;eval()&lt;/code&gt; 代替关闭梯度。&lt;/strong&gt; 仍会构建 autograd 图并占用内存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 &lt;code&gt;inference_mode()&lt;/code&gt; 代替 &lt;code&gt;eval()&lt;/code&gt;。&lt;/strong&gt; Dropout 仍可能保持训练行为。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比较 Pre/Post-LN 时沿用同一最优学习率就下结论。&lt;/strong&gt; 两者优化条件不同，应分别调参并报告初始化与 warmup。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 这个块会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;超长序列。&lt;/strong&gt; 标准自注意力的 $L^2$ 关系矩阵仍是主要瓶颈；FFN 不会修复它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FFN 宽度过大。&lt;/strong&gt; 参数、激活显存和逐 token 计算迅速增长，尤其在大词表或长 batch 下。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;残差分支尺度失控。&lt;/strong&gt; 恒等路径不能阻止非有限激活、过大学习率或错误 mask 注入异常值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据很少。&lt;/strong&gt; 宽 FFN 与多头注意力提供高容量，也更容易记忆训练集；需要学习曲线和任务匹配的正则化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;精确算法任务。&lt;/strong&gt; 连续向量与有限深度未必可靠执行长位数算术、栈操作或长度外推。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;padding 占比极高。&lt;/strong&gt; 逻辑 mask 保证语义正确，却不自动省掉所有密集计算；要另行评估打包、Nested Tensor 或变长内核。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分布外长度。&lt;/strong&gt; 位置表示、残差尺度和训练上下文共同限制长度外推，不能只替换 FFN 激活就解决。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 与相近结构怎样区分？&lt;/h2&gt;
&lt;p&gt;| 结构                      | 跨位置混合                        | 每位置特征变换              | 归一化位置        | 主要用途                        |
| ------------------------- | --------------------------------- | --------------------------- | ----------------- | ------------------------------- |
| 仅自注意力层              | 是                                | 主要是 Q/K/V 与输出线性投影 | 未规定            | 建立 token 关系                 |
| Transformer Encoder Block | 自注意力                          | FFN                         | Pre-LN 或 Post-LN | 双向上下文编码                  |
| Transformer Decoder Block | causal 自注意力，可再加交叉注意力 | FFN                         | 依架构而定        | 自回归生成/条件生成             |
| 卷积残差块                | 局部卷积                          | 通道与空间共同变换          | BN/LN 等          | 图像或局部序列建模              |
| MLP-Mixer 类块            | 显式 token-mixing MLP             | channel-mixing MLP          | 通常有            | 不用注意力的全局混合            |
| MoE Transformer           | 注意力不变                        | 只路由到部分专家 FFN        | 通常沿用主干      | 增大参数容量而控制单 token 计算 |&lt;/p&gt;
&lt;p&gt;门控 FFN（如 GLU/SwiGLU）改变的是逐位置非线性分支；稀疏注意力改变的是位置混合图；FlashAttention 优化的是精确注意力的内存访问。它们解决不同层面的问题，不能都笼统称为“更快的 Transformer”。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;自注意力沿序列轴混合 token，FFN 用共享的 &lt;code&gt;D → F → D&lt;/code&gt; 非线性网络独立加工每个位置，两者缺一不可。&lt;/li&gt;
&lt;li&gt;一个标准块包含两次残差相加：注意力子层一次、FFN 子层一次；每次分支输出都必须回到 &lt;code&gt;[N,L,D]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;Post-LN 是 &lt;code&gt;LN(x+S(x))&lt;/code&gt;，Pre-LN 是 &lt;code&gt;x+S(LN(x))&lt;/code&gt;；后者的局部梯度含显式恒等项，深层训练常更稳定。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LayerNorm(D)&lt;/code&gt; 对 &lt;code&gt;[N,L,D]&lt;/code&gt; 的最后一维逐 token 统计，训练与推理都使用当前输入统计。&lt;/li&gt;
&lt;li&gt;mask、池化 mask 与损失 mask 负责不同边界；&lt;code&gt;eval()&lt;/code&gt; 与 &lt;code&gt;inference_mode()&lt;/code&gt; 也不能互相替代。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;延续手算例，把第二个 token 设为 &lt;code&gt;[-1,1]&lt;/code&gt;，用同一组 $W_1,W_2$ 计算其 FFN 输出。说明两个 token 为何共享函数却没有在 FFN 中互相影响。&lt;/li&gt;
&lt;li&gt;为 &lt;code&gt;PreNormEncoderBlock&lt;/code&gt; 写 padding 不变性测试：保持 &lt;code&gt;valid_tokens&lt;/code&gt; 不变，随机替换 padding 位置输入，验证所有真实位置输出不变；再故意去掉 &lt;code&gt;key_padding_mask&lt;/code&gt;，观察测试失败。&lt;/li&gt;
&lt;li&gt;令 $D=512,F=2048,H=8$，忽略偏置，计算 FFN 与 Q/K/V+输出投影各自的参数量。再让序列长度从 512 翻倍到 1024，解释参数量为何不变，而注意力分数元素数为何约增至 4 倍。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Vaswani et al. (2017), Attention Is All You Need&lt;/a&gt;：提出由多头注意力、逐位置 FFN、残差与归一化组成的原始 Post-LN Transformer。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2002.04745&quot;&gt;Xiong et al. (2020), On Layer Normalization in the Transformer Architecture&lt;/a&gt;：分析 Pre-LN 与 Post-LN 在初始化时的梯度行为及 warmup 需求。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2002.05202&quot;&gt;Shazeer (2020), GLU Variants Improve Transformer&lt;/a&gt;：研究门控逐位置前馈网络及其激活变体。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2203.00555&quot;&gt;Wang et al. (2022), DeepNet: Scaling Transformers to 1,000 Layers&lt;/a&gt;：通过残差与初始化缩放研究极深 Transformer 的稳定训练。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;Dao et al. (2022), FlashAttention&lt;/a&gt;：优化精确注意力的 IO 路径，帮助区分架构数学与内核实现问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;一个 Encoder block 已能让所有 token 交换并加工信息，但自回归生成还要求“只能看过去”，条件生成还要求解码器读取另一条源序列。下一篇将组装 Transformer Decoder，逐层区分 causal self-attention、cross-attention 与 FFN 的 Query/Key/Value 来源，并追踪训练和逐 token 推理的数据流。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>所有词怎样一次看见彼此？Transformer 的缩放点积自注意力与掩码</title><link>https://zwjcode.cn/blog/transformer-scaled-dot-product-self-attention-masks</link><guid isPermaLink="true">https://zwjcode.cn/blog/transformer-scaled-dot-product-self-attention-masks</guid><description>从循环注意力的串行瓶颈出发，手算 Query、Key、Value 的缩放点积，解释多头分工、位置编码与两类掩码，并对齐 PyTorch 2.13 当前 API。</description><pubDate>Sun, 06 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的加性注意力让第 $t$ 个解码步直接读取全部源状态，突破了定长上下文瓶颈。但循环编码器仍要先完成第 1、2、3……步，循环解码器也要逐 token 生成；序列越长，时间轴上的串行依赖越明显。&lt;/p&gt;
&lt;p&gt;Transformer 的核心转向是自注意力（Self-Attention）：让同一层中的每个位置用查询（Query）寻找其他位置的键（Key），再汇总对应的值（Value）。本文只讲透缩放点积自注意力（Scaled Dot-Product Attention）这一个算子，并把与正确使用它不可分的多头拆分、位置信息和掩码一起说明。前馈网络、完整 Encoder–Decoder 堆叠和大模型训练留到后续文章。&lt;/p&gt;
&lt;h2&gt;01 循环结构的限制不只是“记不住”&lt;/h2&gt;
&lt;p&gt;循环网络的状态更新是：&lt;/p&gt;
&lt;p&gt;$$
h_t=f(x_t,h_{t-1})
$$&lt;/p&gt;
&lt;p&gt;即使 LSTM 缓解了长程梯度问题，即使加性注意力能回看全部源状态，计算 $h_t$ 仍必须等待 $h_{t-1}$。如果第 1 个词要与第 100 个词交换信息，它至少要跨越许多递推或等到解码查询发生。&lt;/p&gt;
&lt;p&gt;自注意力把一层序列写成矩阵：&lt;/p&gt;
&lt;p&gt;$$
X\in\mathbb{R}^{N\times L\times D_{model}}
$$&lt;/p&gt;
&lt;p&gt;同一层一次生成所有位置的 $Q,K,V$，再用一个 &lt;code&gt;[L,L]&lt;/code&gt; 关系矩阵完成位置间的信息交换：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;循环层：
x1 ─► h1 ─► h2 ─► h3 ─► h4          时间轴串行
          x2    x3    x4

自注意力层：
x1 ─┬────────────► every output position
x2 ─┼────────────► 通过同一次 QK^T 建立 L×L 连接
x3 ─┼────────────►
x4 ─┴────────────►
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这让训练阶段的序列位置更易并行，但关系矩阵的时间和显存通常随 $L^2$ 增长。并行不是免费消除复杂度，而是把串行递推换成密集矩阵计算。&lt;/p&gt;
&lt;h2&gt;02 Query、Key、Value 各自做什么？&lt;/h2&gt;
&lt;p&gt;对输入位置表示 $X$ 做三组线性投影：&lt;/p&gt;
&lt;p&gt;$$
Q=XW^Q,\qquad K=XW^K,\qquad V=XW^V
$$&lt;/p&gt;
&lt;p&gt;单头时可设：&lt;/p&gt;
&lt;p&gt;$$
Q,K\in\mathbb{R}^{N\times L\times d_k},
\qquad V\in\mathbb{R}^{N\times L\times d_v}
$$&lt;/p&gt;
&lt;p&gt;第 $i$ 个位置的查询 $q_i$ 与第 $j$ 个位置的键 $k_j$ 做点积，决定 $i$ 从 $j$ 读取多少；真正被加权汇总的是 $v_j$：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Attention}(Q,K,V)
=\operatorname{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}+M\right)V
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,L,D_model]
 ├─► Linear Q ─► Q [N,L,d_k] ─────────┐
 ├─► Linear K ─► K [N,L,d_k] ─► 转置 ├─► QK^T / sqrt(d_k) [N,L,L]
 └─► Linear V ─► V [N,L,d_v]          │                 │
                                      mask M ────────────┤
                                                        ▼
                                               softmax(dim=key_position)
                                                        │ A [N,L,L]
                                                        ▼
                                              A @ V = O [N,L,d_v]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;“查询、键、值”是计算角色，不是三份独立输入数据。在自注意力中它们都由同一个 $X$ 投影得到；在交叉注意力中，查询可来自解码器，键和值来自编码器。&lt;/p&gt;
&lt;h2&gt;03 为什么点积要除以 $\sqrt{d_k}$？&lt;/h2&gt;
&lt;p&gt;假设 $q$ 和 $k$ 各维独立、均值 0、方差 1，则点积：&lt;/p&gt;
&lt;p&gt;$$
q^\top k=\sum_{r=1}^{d_k}q_rk_r
$$&lt;/p&gt;
&lt;p&gt;其方差约为 $d_k$，标准差约为 $\sqrt{d_k}$。维度增大时，未经缩放的 logits 会越来越极端，softmax 接近 one-hot，非最大项梯度变小。&lt;/p&gt;
&lt;p&gt;例如两个键的未缩放分数为 &lt;code&gt;[8,0]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{softmax}([8,0])\approx[0.9997,0.0003]
$$&lt;/p&gt;
&lt;p&gt;若 $d_k=16$，缩放后是 &lt;code&gt;[2,0]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{softmax}([2,0])\approx[0.8808,0.1192]
$$&lt;/p&gt;
&lt;p&gt;缩放不是为了让权重“平均”，而是让点积尺度在不同头维度下更可控，使训练初期不过早饱和。若框架 API 已经完成缩放，再手动除一次会把分布错误地变平。&lt;/p&gt;
&lt;h2&gt;04 用两个 token 手算完整前向&lt;/h2&gt;
&lt;p&gt;令单个样本、单头、$L=2,d_k=d_v=2$：&lt;/p&gt;
&lt;p&gt;$$
Q=K=\begin{bmatrix}1&amp;#x26;0\0&amp;#x26;1\end{bmatrix},
\qquad
V=\begin{bmatrix}2&amp;#x26;0\0&amp;#x26;4\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;缩放前的相似度矩阵是单位矩阵，除以 $\sqrt2$ 后：&lt;/p&gt;
&lt;p&gt;$$
S=\frac{QK^\top}{\sqrt2}
=\begin{bmatrix}0.707&amp;#x26;0\0&amp;#x26;0.707\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;逐行 softmax，令 $a=\frac{e^{0.707}}{e^{0.707}+1}\approx0.6698$：&lt;/p&gt;
&lt;p&gt;$$
A=\begin{bmatrix}a&amp;#x26;1-a\1-a&amp;#x26;a\end{bmatrix}
\approx
\begin{bmatrix}0.6698&amp;#x26;0.3302\0.3302&amp;#x26;0.6698\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;输出为：&lt;/p&gt;
&lt;p&gt;$$
O=AV
\approx
\begin{bmatrix}
1.3396&amp;#x26;1.3208\
0.6604&amp;#x26;2.6792
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;第一位置仍更重视自己，却读取了第二位置约 33% 的值；第二位置同理。注意输出不是对 $Q$ 或 $K$ 加权，而是对 $V$ 加权。&lt;/p&gt;
&lt;p&gt;若这是自回归语言模型，第一个位置不能偷看第二个 token。加入因果掩码后，第一行第二列变为 $-\infty$：&lt;/p&gt;
&lt;p&gt;$$
S_{causal}=\begin{bmatrix}0.707&amp;#x26;-\infty\0&amp;#x26;0.707\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;第一行权重变为 &lt;code&gt;[1,0]&lt;/code&gt;，输出严格等于第一个 value；第二位置仍能读取位置 1 和 2。&lt;/p&gt;
&lt;h2&gt;05 多头不是“把同一注意力复制几遍”&lt;/h2&gt;
&lt;p&gt;多头注意力（Multi-Head Attention）把 $D_{model}$ 投影成 $H$ 个子空间。常见设置为 $d_k=d_v=D_{model}/H$：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{head}_r
=\operatorname{Attention}(QW_r^Q,KW_r^K,VW_r^V)
$$&lt;/p&gt;
&lt;p&gt;$$
\operatorname{MultiHead}(Q,K,V)
=\operatorname{Concat}(\operatorname{head}_1,\ldots,\operatorname{head}_H)W^O
$$&lt;/p&gt;
&lt;p&gt;| 张量           | 形状             |
| -------------- | ---------------- |
| 输入 &lt;code&gt;x&lt;/code&gt;       | &lt;code&gt;[N,L,D_model]&lt;/code&gt;  |
| 投影后 &lt;code&gt;q,k,v&lt;/code&gt; | &lt;code&gt;[N,L,H*d_head]&lt;/code&gt; |
| 拆头并换轴     | &lt;code&gt;[N,H,L,d_head]&lt;/code&gt; |
| 每头权重       | &lt;code&gt;[N,H,L,L]&lt;/code&gt;      |
| 每头输出       | &lt;code&gt;[N,H,L,d_head]&lt;/code&gt; |
| 拼接后         | &lt;code&gt;[N,L,H*d_head]&lt;/code&gt; |
| 输出投影       | &lt;code&gt;[N,L,D_model]&lt;/code&gt;  |&lt;/p&gt;
&lt;p&gt;不同头有独立投影，因而可以学习不同关系子空间；但不能仅凭某个头的图案给它命名为“句法头”或“实体头”。头也可能冗余、塌缩或在不同随机种子下交换角色。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;D_model&lt;/code&gt; 必须能按头数切分，是最基本的形状约束：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert model_dim % num_heads == 0
head_dim = model_dim // num_heads
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;06 自注意力为什么仍需要位置信息？&lt;/h2&gt;
&lt;p&gt;如果没有任何位置表示，自注意力对 token 排列是置换等变的：把输入行按同一种顺序重排，输出也只会跟着重排。模型知道「狗」「咬」「人」有哪些内容，却没有天然坐标区分「狗咬人」和「人咬狗」。&lt;/p&gt;
&lt;p&gt;原始 Transformer 把正弦位置编码（Sinusoidal Positional Encoding）加到 token embedding：&lt;/p&gt;
&lt;p&gt;$$
PE(pos,2i)=\sin\left(pos/10000^{2i/D_{model}}\right)
$$&lt;/p&gt;
&lt;p&gt;$$
PE(pos,2i+1)=\cos\left(pos/10000^{2i/D_{model}}\right)
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;token_ids [N,L] ─► Embedding [N,L,D]
position  [L]   ─► Position  [L,D]
                         │ broadcast batch
                         ▼
                    X = token + position [N,L,D]
                         │
                    self-attention
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;学习式绝对位置、相对位置偏置、旋转位置编码等都在改变“位置如何进入相似度或表示”，但都不应与 padding mask 混为一谈：位置编码提供顺序；mask 禁止某些连接。&lt;/p&gt;
&lt;h2&gt;07 padding mask 与 causal mask 阻止不同错误&lt;/h2&gt;
&lt;p&gt;两类 mask 经常同时出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;键 padding mask&lt;/strong&gt;：每个样本真实长度不同，任何查询都不应读取补齐键位置。典型语义为 &lt;code&gt;[N,L]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;因果 mask（Causal Mask）&lt;/strong&gt;：语言模型位置 $i$ 不应读取 $j&gt;i$ 的未来内容。典型语义为 &lt;code&gt;[L,L]&lt;/code&gt; 下三角。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;序列： [A, B, C, PAD]

key padding mask（列规则，每一行都屏蔽 PAD）：
      A B C P
  A   ✓ ✓ ✓ ×
  B   ✓ ✓ ✓ ×
  C   ✓ ✓ ✓ ×
  P   ✓ ✓ ✓ ×   ← 查询 PAD 是否保留还需在输出/损失端处理

causal mask（时间规则）：
      A B C P
  A   ✓ × × ×
  B   ✓ ✓ × ×
  C   ✓ ✓ ✓ ×
  P   ✓ ✓ ✓ ✓

组合后：同时满足“非未来”与“非 padding”才可见。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mask 通常屏蔽的是 &lt;strong&gt;key 列&lt;/strong&gt;。一个 padding 查询行仍可能产生输出；若后续不需要它，应在残差输出或目标损失处再次屏蔽。只屏蔽查询行而保留 padding 键，会让真实 token 读取无效内容。&lt;/p&gt;
&lt;h2&gt;08 不调用封装，写出可检查的多头本体&lt;/h2&gt;
&lt;p&gt;下面用 &lt;code&gt;valid_keys=True&lt;/code&gt; 表示真实 token，内部统一把不可见位置填为 $-\infty$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math
import torch
from torch import nn

class TransparentSelfAttention(nn.Module):
    def __init__(self, model_dim: int, num_heads: int, dropout: float = 0.0) -&gt; None:
        super().__init__()
        assert model_dim % num_heads == 0
        self.model_dim = model_dim
        self.num_heads = num_heads
        self.head_dim = model_dim // num_heads
        self.qkv = nn.Linear(model_dim, 3 * model_dim)
        self.output = nn.Linear(model_dim, model_dim)
        self.dropout = nn.Dropout(dropout)

    def _split_heads(self, x: torch.Tensor) -&gt; torch.Tensor:
        n, length, _ = x.shape
        return x.view(n, length, self.num_heads, self.head_dim).transpose(1, 2)
        # [N,H,L,d]

    def forward(
        self,
        x: torch.Tensor,                  # [N,L,D]
        valid_keys: torch.Tensor,         # [N,L], True = 可读
        causal: bool = False,
    ) -&gt; tuple[torch.Tensor, torch.Tensor]:
        n, length, width = x.shape
        assert width == self.model_dim
        assert valid_keys.shape == (n, length)
        assert valid_keys.dtype == torch.bool
        assert valid_keys.any(dim=1).all()

        q_raw, k_raw, v_raw = self.qkv(x).chunk(3, dim=-1)
        q = self._split_heads(q_raw)  # [N,H,L,d]
        k = self._split_heads(k_raw)  # [N,H,L,d]
        v = self._split_heads(v_raw)  # [N,H,L,d]

        scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim)
        # [N,H,query=L,key=L]
        allowed = valid_keys[:, None, None, :]  # [N,1,1,L]
        if causal:
            lower_triangle = torch.ones(
                length, length, dtype=torch.bool, device=x.device
            ).tril()
            allowed = allowed &amp;#x26; lower_triangle[None, None, :, :]

        scores = scores.masked_fill(~allowed, float(&quot;-inf&quot;))
        weights = scores.softmax(dim=-1)  # 沿 key 位置归一化
        weights = self.dropout(weights)
        attended = weights @ v            # [N,H,L,d]
        merged = attended.transpose(1, 2).contiguous().view(n, length, width)
        return self.output(merged), weights  # [N,L,D], [N,H,L,L]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;transpose&lt;/code&gt; 后调用 &lt;code&gt;contiguous()&lt;/code&gt; 再 &lt;code&gt;view&lt;/code&gt;，避免把非连续内存按错误步长解释。也可以使用 &lt;code&gt;reshape&lt;/code&gt;，但仍应理解最终布局从 &lt;code&gt;[N,H,L,d]&lt;/code&gt; 变为 &lt;code&gt;[N,L,H,d]&lt;/code&gt; 后才能拼头。&lt;/p&gt;
&lt;p&gt;训练时注意力通常位于残差块中：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x ─► LayerNorm ─► SelfAttention ─► dropout ─► + ─► y
└──────────────────────────────────────────────▲
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只输出注意力结果而不加残差、归一化和后续前馈层，不等于一个完整 Transformer block；本文刻意只验证注意力算子。&lt;/p&gt;
&lt;h2&gt;09 与 PyTorch 2.13 当前 SDPA API 对齐&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 的 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.functional.scaled_dot_product_attention.html&quot;&gt;&lt;code&gt;torch.nn.functional.scaled_dot_product_attention&lt;/code&gt;&lt;/a&gt; 接收形如 &lt;code&gt;[N,H,L,d]&lt;/code&gt; 的 Query 和 &lt;code&gt;[N,H,S,d]&lt;/code&gt; 的 Key/Value，并在可能时选择优化内核：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch.nn import functional as F

q = torch.randn(2, 4, 6, 8)  # [N=2,H=4,L=6,d=8]
k = torch.randn(2, 4, 6, 8)
v = torch.randn(2, 4, 6, 8)
valid_keys = torch.tensor([
    [True, True, True, True, False, False],
    [True, True, True, True, True, True],
])  # [N,S]

# SDPA 布尔 attn_mask 中 True = 允许参与；广播到 [N,H,L,S]
allowed = valid_keys[:, None, None, :]
output = F.scaled_dot_product_attention(
    q, k, v,
    attn_mask=allowed,
    dropout_p=0.0,
    is_causal=False,
)  # [2,4,6,8]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前官方契约中有四个高风险点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;默认缩放已经是 $1/\sqrt{d_k}$，不要在传入 &lt;code&gt;q&lt;/code&gt; 前再除一次。&lt;/li&gt;
&lt;li&gt;布尔 &lt;code&gt;attn_mask=True&lt;/code&gt; 表示该连接允许参与；浮点 mask 则直接加到分数。&lt;/li&gt;
&lt;li&gt;当前接口不允许同时显式传 &lt;code&gt;attn_mask&lt;/code&gt; 和 &lt;code&gt;is_causal=True&lt;/code&gt;；padding 与 causal 需要按 API/版本选择合并策略或更高层封装。&lt;/li&gt;
&lt;li&gt;该函数只要 &lt;code&gt;dropout_p&gt;0&lt;/code&gt; 就会应用 dropout，不会自动读取外层模块的 &lt;code&gt;training&lt;/code&gt;；模块中应传 &lt;code&gt;self.p if self.training else 0.0&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;该函数当前仍标为 Beta。固定 PyTorch 版本、对 mask 做数值单测，比只依赖“代码能运行”更可靠。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;nn.MultiheadAttention(batch_first=True)&lt;/code&gt; 则接收 &lt;code&gt;[N,L,D]&lt;/code&gt;。其布尔 &lt;code&gt;key_padding_mask [N,S]&lt;/code&gt; 中 &lt;code&gt;True&lt;/code&gt; 表示忽略，与上面的 SDPA 布尔 mask 相反：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;mha = nn.MultiheadAttention(
    embed_dim=32, num_heads=4, dropout=0.1, batch_first=True
)
x = torch.randn(2, 6, 32)
key_padding_mask = ~valid_keys  # MHA 中 True = 忽略

mha.train()
output, per_head = mha(
    x, x, x,
    key_padding_mask=key_padding_mask,
    need_weights=True,
    average_attn_weights=False,
)  # output [2,6,32], per_head [2,4,6,6]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产路径若不需要权重，设置 &lt;code&gt;need_weights=False&lt;/code&gt; 更容易使用优化后的 scaled dot-product attention；诊断时再在小 batch 上取每头权重。&lt;/p&gt;
&lt;h2&gt;10 一个语言模型训练步的输入输出&lt;/h2&gt;
&lt;p&gt;自回归语言模型把 token 序列错开一位：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原序列： &amp;#x3C;bos&gt; 今 天 下 雨 &amp;#x3C;eos&gt; &amp;#x3C;pad&gt;
输入 x： &amp;#x3C;bos&gt; 今 天 下 雨
标签 y：  今   天 下 雨 &amp;#x3C;eos&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入先加位置表示，经过带 causal mask 的若干 Transformer block，再投影到词表 logits：&lt;/p&gt;
&lt;p&gt;$$
\text{tokens }[N,L]
\rightarrow X[N,L,D]
\rightarrow Z[N,L,D]
\rightarrow \text{logits }[N,L,V]
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;logits = model(input_ids, valid_keys=input_ids.ne(pad_id), causal=True)
# logits [N,L,V]，未经 softmax
loss = nn.functional.cross_entropy(
    logits.reshape(-1, logits.shape[-1]),
    target_ids.reshape(-1),
    ignore_index=pad_id,
)

optimizer.zero_grad(set_to_none=True)
loss.backward()
grad_norm = nn.utils.clip_grad_norm_(
    model.parameters(), max_norm=1.0, error_if_nonfinite=True
)
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ignore_index&lt;/code&gt; 只使目标 padding 不贡献损失；它不会阻止真实查询在注意力中读取 padding 键。反过来，注意力 key mask 也不会自动让 padding 目标不计损失。二者必须分别存在。&lt;/p&gt;
&lt;p&gt;训练时整段目标已知，可用 causal mask 并行计算所有位置；推理时未来 token 尚不存在，仍然要自回归逐步生成。键值缓存（KV Cache）可以复用已生成 token 的 Key/Value，避免每步重新计算整段历史，但不会让依赖关系消失。&lt;/p&gt;
&lt;h2&gt;11 一条可执行的调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先过拟合一个 batch。&lt;/strong&gt; 用 2 条、长度 4 的复制或 next-token 数据，确认损失可接近 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查权重和。&lt;/strong&gt; dropout 关闭时，&lt;code&gt;weights.sum(-1)&lt;/code&gt; 应约为 1；padding 列应为 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做未来泄漏测试。&lt;/strong&gt; 固定前缀，只替换位置 $t$ 之后的 token；causal 模型在 $\le t$ 位置的 logits 应完全不变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做排列测试。&lt;/strong&gt; 暂时移除位置表示，成对重排输入，输出应同样重排；加入位置后该对称性应被打破。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比较手写与官方输出。&lt;/strong&gt; 关闭 dropout，复制投影参数或直接给相同 $Q,K,V$，使用 &lt;code&gt;torch.testing.assert_close&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录注意力熵与 logits 范数。&lt;/strong&gt; 全头长期均匀可能未学到关系；极早 one-hot 可能是缩放、初始化或 mask 问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 profiler 看长度曲线。&lt;/strong&gt; 将 $L$ 翻倍，分别记录注意力矩阵显存、吞吐和数据加载时间，确认瓶颈是否真在 $L^2$ 算子。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;未来泄漏的最小测试尤其重要：模型的训练损失会因偷看答案而异常漂亮，普通形状断言却抓不到它。&lt;/p&gt;
&lt;h2&gt;12 最常见的“形状正确，语义错误”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;softmax 沿查询轴。&lt;/strong&gt; 每个查询应沿 key 位置归一化，即最后一维。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忘记转置 Key。&lt;/strong&gt; 需要 &lt;code&gt;[N,H,L,d] @ [N,H,d,S]&lt;/code&gt; 才得到 &lt;code&gt;[N,H,L,S]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 $D_{model}$ 而不是 $d_k$ 缩放每个头。&lt;/strong&gt; 缩放由单头 Query/Key 宽度决定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;手动缩放后又调用 SDPA。&lt;/strong&gt; 重复除以平方根会让注意力过平。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;混淆 SDPA 与 MHA 的布尔 mask。&lt;/strong&gt; 同一个 &lt;code&gt;True&lt;/code&gt; 在两个接口里可表示相反语义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只 mask padding 查询，不 mask padding 键。&lt;/strong&gt; 真实 token 仍可能读取补齐位置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;causal 三角方向反了。&lt;/strong&gt; 打印一个 $4\times4$ mask，并用“替换未来不改变过去 logits”测试。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;所有键都被屏蔽。&lt;/strong&gt; 对全 $-\infty$ 行做 softmax 会产生非有限结果；入口拒绝空序列。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有位置表示却期待词序。&lt;/strong&gt; 内容相同的排列无法仅靠无位置自注意力区分。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;拆头后直接 &lt;code&gt;view&lt;/code&gt;。&lt;/strong&gt; 先换回 &lt;code&gt;[N,L,H,d]&lt;/code&gt; 并确保内存布局正确。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证时 SDPA 仍传训练 dropout。&lt;/strong&gt; 当前函数会按 &lt;code&gt;dropout_p&lt;/code&gt; 无条件应用 dropout。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看平均头权重。&lt;/strong&gt; 平均会抹去头间差异；诊断时取 &lt;code&gt;[N,H,L,S]&lt;/code&gt;，生产时可关闭权重返回。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把训练并行误解为生成并行。&lt;/strong&gt; causal 训练能一次计算所有已知标签，开放式推理仍依赖已生成前缀。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 它会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;超长序列。&lt;/strong&gt; 标准注意力构造 $L\times L$ 分数，长文档、视频和高分辨率网格的显存迅速增长。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;局部模式占主导。&lt;/strong&gt; 没有适当位置归纳偏置时，小数据上可能不如卷积或精心设计的局部模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;精确外推到更长长度。&lt;/strong&gt; 训练长度、位置表示和数值范围都可能限制长度外推。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;因果生成延迟。&lt;/strong&gt; 训练能并行位置，逐 token 推理仍受串行采样和 KV cache 带宽约束。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意力不是可靠检索。&lt;/strong&gt; 有限精度的加权平均可能混合多个相似值，不能替代带标识符的精确数据库读取。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;权重不等于解释。&lt;/strong&gt; 改变 Value 或后续层可能在权重相似时改变答案；解释需要干预和多种证据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据捷径。&lt;/strong&gt; 全局连接让模型更容易利用非因果元数据、模板位置或重复样本，数据切分仍是第一道防线。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;稀疏注意力、线性注意力、局部窗口和 FlashAttention 分别优化连接模式、数学近似或内存访问；不能仅因都“更快”就认为它们等价。&lt;/p&gt;
&lt;h2&gt;14 与前后方法怎样区分？&lt;/h2&gt;
&lt;p&gt;| 方法                 | 位置间路径               | 训练时序列并行  | 主要代价/限制                |
| -------------------- | ------------------------ | --------------- | ---------------------------- |
| RNN/LSTM             | 逐步状态递推             | 否              | 长路径、吞吐受串行依赖限制   |
| 循环 + 加性注意力    | 解码查询读取全部编码状态 | 编码/解码仍递推 | 每步对源序列打分             |
| Transformer 自注意力 | 一层内所有位置直接连接   | 是              | 标准形式为 $O(L^2)$ 关系矩阵 |
| 卷积序列模型         | 固定局部窗口逐层扩大     | 是              | 长程关系需更多层或膨胀卷积   |&lt;/p&gt;
&lt;p&gt;自注意力算子本身没有定义完整 Transformer。标准块还包含残差连接、LayerNorm、逐位置前馈网络、dropout；Encoder–Decoder Transformer 还包含跨源—目标的交叉注意力。先把一个算子的轴、缩放和 mask 测对，再讨论堆叠深度和架构变体。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;自注意力用 $QK^\top$ 建立“哪个查询读取哪个键”的关系，再用归一化权重汇总 $V$；输出位置可以在同一层直接交换信息。&lt;/li&gt;
&lt;li&gt;除以 $\sqrt{d_k}$ 是为控制点积方差和 softmax 饱和，单头缩放宽度是 $d_k$。&lt;/li&gt;
&lt;li&gt;多头通过独立投影学习不同子空间，数据流核心是 &lt;code&gt;[N,L,D] → [N,H,L,d] → [N,H,L,L] → [N,L,D]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;位置表示提供顺序，padding mask 禁止读取补齐键，causal mask 禁止读取未来；三者职责不同。&lt;/li&gt;
&lt;li&gt;PyTorch 2.13 的 SDPA 与 MultiheadAttention 对布尔 mask 的 &lt;code&gt;True&lt;/code&gt; 语义不同，且 SDPA dropout 不自动随 &lt;code&gt;eval()&lt;/code&gt; 关闭，必须写契约测试。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;延续二 token 手算，把 $V$ 改为 &lt;code&gt;[[1,1],[3,-1]]&lt;/code&gt;，计算无 mask 与 causal mask 下两个位置的输出；说明 mask 改变的是权重可见性而不是 Value 本身。&lt;/li&gt;
&lt;li&gt;为 &lt;code&gt;TransparentSelfAttention&lt;/code&gt; 写未来泄漏测试：随机替换位置 3 之后的输入，验证 causal 模式下位置 0–3 的输出不变；再故意把三角 mask 翻转，观察测试如何失败。&lt;/li&gt;
&lt;li&gt;固定 &lt;code&gt;D_model=128&lt;/code&gt;，比较 &lt;code&gt;H∈{1,4,8}&lt;/code&gt; 的每头宽度、注意力矩阵元素数、参数量和吞吐。解释增加头数为何不必然增加 QKV 投影参数，却会改变每头的缩放与表示子空间。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;Vaswani et al. (2017), Attention Is All You Need&lt;/a&gt;：提出以多头自注意力为核心、无需循环与卷积的 Transformer。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1803.02155&quot;&gt;Shaw, Uszkoreit &amp;#x26; Vaswani (2018), Self-Attention with Relative Position Representations&lt;/a&gt;：将相对位置信息直接加入自注意力关系计算。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1901.02860&quot;&gt;Dai et al. (2019), Transformer-XL&lt;/a&gt;：用片段级状态复用与相对位置缓解固定上下文和长度依赖。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2205.14135&quot;&gt;Dao et al. (2022), FlashAttention&lt;/a&gt;：通过 IO 感知的精确注意力算法减少显存访问，而非改变注意力数学结果。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2108.12409&quot;&gt;Press, Smith &amp;#x26; Lewis (2022), Train Short, Test Long: Attention with Linear Biases&lt;/a&gt;：用注意力线性偏置研究长度外推与位置表示。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;自注意力已经让位置在同一层直接交换信息，但如果没有残差、归一化和逐位置非线性，整层仍只是一次加权混合。下一篇将把这些部件组装成 Transformer block，比较 Pre-LN 与 Post-LN 的数据流和梯度路径，并追踪一个 token 如何经过注意力子层与前馈子层完成更新。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>整句话为何不能只压成一个向量？Encoder–Decoder 的加性注意力与对齐</title><link>https://zwjcode.cn/blog/seq2seq-additive-attention-alignment</link><guid isPermaLink="true">https://zwjcode.cn/blog/seq2seq-additive-attention-alignment</guid><description>从定长上下文瓶颈出发，手算 Bahdanau 加性注意力的能量、掩码与加权和，并用 PyTorch 2.13 实现可检查的序列到序列训练和自回归推理。</description><pubDate>Sun, 06 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把长短期记忆网络（Long Short-Term Memory, LSTM）的保留、写入和遗忘拆成了可学习的门。它让较早的信息更容易到达序列末尾，却仍留下一个容量问题：若机器翻译编码器只把整句源文本交给最后一个状态，十几个实体、修饰关系和词序都要竞争同一个定长向量。&lt;/p&gt;
&lt;p&gt;编码器—解码器（Encoder–Decoder）先把输入变成表示，再逐步生成输出。本文只研究它的一项关键改造：Bahdanau 加性注意力（Additive Attention）如何让第 $t$ 个解码步直接读取全部源位置，而不是每一步都依赖编码器最后一个状态。我们会追踪打分、padding 掩码、softmax 和加权和的完整数据流；多头自注意力留到下一篇。&lt;/p&gt;
&lt;h2&gt;01 定长上下文究竟丢了什么？&lt;/h2&gt;
&lt;p&gt;考虑把「黑猫坐在红垫子上」翻译成英文。无注意力的循环 Encoder–Decoder 可以写成：&lt;/p&gt;
&lt;p&gt;$$
h_s^{enc}=\operatorname{GRU}&lt;em&gt;{enc}(x_s,h&lt;/em&gt;{s-1}^{enc}),
\qquad c=h_S^{enc}
$$&lt;/p&gt;
&lt;p&gt;$$
s_t=\operatorname{GRU}&lt;em&gt;{dec}([y&lt;/em&gt;{t-1};c],s_{t-1}),
\qquad p(y_t)=\operatorname{softmax}(W_os_t+b_o)
$$&lt;/p&gt;
&lt;p&gt;源长度为 $S$，目标长度为 $T$。不论 $S=5$ 还是 $S=500$，解码器始终只收到同一个 $c\in\mathbb{R}^{H}$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;源词： 黑猫 ─► 坐在 ─► 红色 ─► 垫子 ─► 上
        enc     enc      enc      enc     enc
                                           │
                                           ▼
                                  c = 最后状态 [N,H]
                                           │ 每一步重复使用同一个 c
                                           ▼
目标： &amp;#x3C;bos&gt; ─► the ─► black ─► cat ─► ... ─► &amp;#x3C;eos&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不是说最后状态必然“一无所知”，而是所有细节只能通过同一条压缩通道。长句增长时，早期位置到 $c$ 的路径更长；解码到「mat」时，也无法显式回到源词「垫子」重新取证。&lt;/p&gt;
&lt;p&gt;注意力把接口从单个 $c$ 改为全部编码器状态：&lt;/p&gt;
&lt;p&gt;$$
H^{enc}=[h_1^{enc},\ldots,h_S^{enc}]\in\mathbb{R}^{N\times S\times H_e}
$$&lt;/p&gt;
&lt;p&gt;每个解码步生成自己的上下文 $c_t$。于是容量仍有限，但不再要求一个向量提前猜中所有未来解码步需要的细节。&lt;/p&gt;
&lt;h2&gt;02 加性注意力怎样回答“现在该看哪里”？&lt;/h2&gt;
&lt;p&gt;第 $t$ 步已有上一解码状态 $s_{t-1}\in\mathbb{R}^{N\times H_d}$。对每个源位置 $j$，先计算未归一化能量（Energy）：&lt;/p&gt;
&lt;p&gt;$$
e_{t,j}=v_a^\top\tanh(W_hh_j^{enc}+W_ss_{t-1}+b_a)
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;p&gt;| 变量        | 形状      | 含义                         |
| ----------- | --------- | ---------------------------- |
| $h_j^{enc}$ | &lt;code&gt;[N,H_e]&lt;/code&gt; | 第 $j$ 个源位置的编码表示    |
| $s_{t-1}$   | &lt;code&gt;[N,H_d]&lt;/code&gt; | 当前查询所依据的上一解码状态 |
| $W_h$       | &lt;code&gt;[A,H_e]&lt;/code&gt; | 把源表示投影到对齐空间       |
| $W_s$       | &lt;code&gt;[A,H_d]&lt;/code&gt; | 把解码状态投影到同一空间     |
| $v_a$       | &lt;code&gt;[A]&lt;/code&gt;     | 把对齐特征压成一个标量分数   |
| $e_t$       | &lt;code&gt;[N,S]&lt;/code&gt;   | 当前解码步对所有源位置的能量 |&lt;/p&gt;
&lt;p&gt;$A$ 是注意力内部维度。加性注意力不要求 $H_e=H_d$；两个投影负责把不同宽度送入同一空间。&lt;/p&gt;
&lt;p&gt;对 padding 位置加 $-\infty$ 后，沿源长度维归一化：&lt;/p&gt;
&lt;p&gt;$$
\alpha_{t,j}=\frac{\exp(e_{t,j})}{\sum_{k=1}^{S}\exp(e_{t,k})},
\qquad
c_t=\sum_{j=1}^{S}\alpha_{t,j}h_j^{enc}
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;encoder states H [N,S,H_e] ───────► W_h ───────────┐
                                                    ├─► tanh ─► v_a ─► energy [N,S]
decoder state s_(t-1) [N,H_d] ─► W_s ─► [N,1,A] ──┘
                                                                  │
source_valid [N,S] ─► padding 处填 -inf ──────────────────────────┤
                                                                  ▼
                                                         softmax(dim=source)
                                                                  │ alpha [N,S]
                                                                  ▼
H [N,S,H_e] ──────────────────────────────────────────► 加权和 c_t [N,H_e]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;能量不是概率；只有掩码后做 softmax 才得到总和为 1 的注意力权重。上下文 $c_t$ 是源状态的凸组合，它的宽度仍是 $H_e$，不会因源长度变化。&lt;/p&gt;
&lt;h2&gt;03 用三个源位置手算一次“重新取证”&lt;/h2&gt;
&lt;p&gt;为隔离注意力本体，假设当前解码步已经得到三个标量能量：&lt;/p&gt;
&lt;p&gt;$$
e_t=[0,\ln2,\ln3]
$$&lt;/p&gt;
&lt;p&gt;源状态只有二维：&lt;/p&gt;
&lt;p&gt;$$
h_1=[1,0],\quad h_2=[0,2],\quad h_3=[2,2]
$$&lt;/p&gt;
&lt;p&gt;softmax 的分子为 $[1,2,3]$，所以：&lt;/p&gt;
&lt;p&gt;$$
\alpha_t=\left[\frac16,\frac26,\frac36\right]
$$&lt;/p&gt;
&lt;p&gt;上下文为：&lt;/p&gt;
&lt;p&gt;$$
c_t=\frac16[1,0]+\frac26[0,2]+\frac36[2,2]
=\left[\frac76,\frac53\right]
$$&lt;/p&gt;
&lt;p&gt;若第三个位置是 padding，就必须在 softmax &lt;strong&gt;之前&lt;/strong&gt; 把其能量变为 $-\infty$：&lt;/p&gt;
&lt;p&gt;$$
\alpha_t^{masked}=\left[\frac13,\frac23,0\right],
\qquad
c_t^{masked}=\left[\frac13,\frac43\right]
$$&lt;/p&gt;
&lt;p&gt;先 softmax 再把 padding 权重乘零会让剩余权重之和小于 1，改变上下文尺度；只把 padding 词向量设为零也不够，因为它仍会从 softmax 分走概率质量。&lt;/p&gt;
&lt;h2&gt;04 从源 token 到目标 token，数据怎样流？&lt;/h2&gt;
&lt;p&gt;本文采用带注意力的循环序列到序列（Sequence-to-Sequence, Seq2Seq）模型：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src_tokens [N,S] ─► src Embedding [N,S,D]
                          │
                    bidirectional GRU
                          │
               enc_out [N,S,2H] ──────────────────────┐
               enc_hidden [2,N,H]                      │
                          │ 拼接并投影                   │
                          ▼                             │
                 decoder state [N,H_d]                 │
                          │                             │
tgt_in[:,t] [N] ─► Embedding [N,D]                    │
                          │                             │
                          ├─► additive attention ◄─────┘
                          │       context [N,2H]
                          ▼
                     decoder GRUCell
                          │
                     logits_t [N,V_tgt]
                          │
                  target_t [N] / ignore PAD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;双向编码器能在离线任务中让每个源位置同时包含左右上下文，所以 $H_e=2H$。严格流式输入不能使用反向分支；那是部署条件，不是一个可以被验证分数掩盖的小细节。&lt;/p&gt;
&lt;p&gt;训练时，目标序列通常错开一位：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tgt:    &amp;#x3C;bos&gt;   the   black   cat   &amp;#x3C;eos&gt;   &amp;#x3C;pad&gt;
输入:   &amp;#x3C;bos&gt;   the   black   cat
标签:    the   black   cat   &amp;#x3C;eos&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;教师强制（Teacher Forcing）指训练第 $t$ 步喂入真实的 $y_{t-1}$；推理没有真实未来目标，只能喂入模型上一步预测。这两条数据流必须分别测试。&lt;/p&gt;
&lt;h2&gt;05 不调用注意力封装，先写出本体&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class AdditiveAttention(nn.Module):
    def __init__(self, encoder_dim: int, decoder_dim: int, attention_dim: int) -&gt; None:
        super().__init__()
        self.key = nn.Linear(encoder_dim, attention_dim, bias=False)
        self.query = nn.Linear(decoder_dim, attention_dim, bias=False)
        self.energy = nn.Linear(attention_dim, 1, bias=True)

    def forward(
        self,
        encoder_states: torch.Tensor,  # [N,S,H_e]
        decoder_state: torch.Tensor,   # [N,H_d]
        source_valid: torch.Tensor,    # [N,S], True 表示真实 token
    ) -&gt; tuple[torch.Tensor, torch.Tensor]:
        assert encoder_states.ndim == 3
        n, source_steps, _ = encoder_states.shape
        assert decoder_state.shape[0] == n
        assert source_valid.shape == (n, source_steps)
        assert source_valid.dtype == torch.bool
        assert source_valid.any(dim=1).all(), &quot;每条源序列至少要有一个有效位置&quot;

        features = torch.tanh(
            self.key(encoder_states)              # [N,S,A]
            + self.query(decoder_state)[:, None]  # [N,1,A]
        )
        scores = self.energy(features).squeeze(-1)  # [N,S]
        scores = scores.masked_fill(~source_valid, float(&quot;-inf&quot;))
        weights = scores.softmax(dim=-1)            # [N,S]
        context = torch.bmm(
            weights.unsqueeze(1), encoder_states
        ).squeeze(1)                                 # [N,H_e]

        torch.testing.assert_close(
            weights.sum(dim=-1), torch.ones(n, device=weights.device)
        )
        return context, weights
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;decoder_state[:, None]&lt;/code&gt; 增加源位置轴，通过广播与 &lt;code&gt;[N,S,A]&lt;/code&gt; 相加；&lt;code&gt;torch.bmm&lt;/code&gt; 接收两个三维 batch 矩阵，&lt;code&gt;[N,1,S] @ [N,S,H_e]&lt;/code&gt; 输出 &lt;code&gt;[N,1,H_e]&lt;/code&gt;。这两处最容易“形状能广播，语义却错轴”。&lt;/p&gt;
&lt;h2&gt;06 一个可训练的注意力解码步&lt;/h2&gt;
&lt;p&gt;下面只展示核心模块，不把分词、词表和数据管道藏在类里：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class AttentiveDecoder(nn.Module):
    def __init__(
        self,
        vocab_size: int,
        embed_dim: int,
        encoder_dim: int,
        decoder_dim: int,
        attention_dim: int,
        pad_id: int,
    ) -&gt; None:
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_id)
        self.attention = AdditiveAttention(encoder_dim, decoder_dim, attention_dim)
        self.cell = nn.GRUCell(embed_dim + encoder_dim, decoder_dim)
        self.output = nn.Linear(decoder_dim + encoder_dim, vocab_size)

    def step(
        self,
        previous_token: torch.Tensor,  # [N]
        state: torch.Tensor,           # [N,H_d]
        encoder_states: torch.Tensor,  # [N,S,H_e]
        source_valid: torch.Tensor,    # [N,S]
    ) -&gt; tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
        token_vector = self.embedding(previous_token)  # [N,D]
        context, weights = self.attention(
            encoder_states, state, source_valid
        )                                               # [N,H_e], [N,S]
        state = self.cell(
            torch.cat([token_vector, context], dim=-1), state
        )                                               # [N,H_d]
        logits = self.output(torch.cat([state, context], dim=-1))
        return logits, state, weights                   # [N,V], [N,H_d], [N,S]

    def forward(
        self,
        target_input: torch.Tensor,    # [N,T], 以 BOS 开始
        initial_state: torch.Tensor,   # [N,H_d]
        encoder_states: torch.Tensor,  # [N,S,H_e]
        source_valid: torch.Tensor,    # [N,S]
    ) -&gt; tuple[torch.Tensor, torch.Tensor]:
        state = initial_state
        logits_steps, attention_steps = [], []
        for t in range(target_input.shape[1]):
            logits, state, weights = self.step(
                target_input[:, t], state, encoder_states, source_valid
            )
            logits_steps.append(logits)
            attention_steps.append(weights)
        return (
            torch.stack(logits_steps, dim=1),       # [N,T,V]
            torch.stack(attention_steps, dim=1),    # [N,T,S]
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;损失应直接接收 logits，并忽略目标 padding：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;pad_id = 0
logits, alignments = decoder(
    target_input, initial_state, encoder_states, source_valid
)  # [N,T,V], [N,T,S]

loss = nn.functional.cross_entropy(
    logits.reshape(-1, logits.shape[-1]),
    target_output.reshape(-1),
    ignore_index=pad_id,
)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.13 的 &lt;code&gt;nn.Embedding(..., padding_idx=pad_id)&lt;/code&gt; 使该词表行不接收梯度；它不会自动生成源注意力掩码。&lt;code&gt;CrossEntropyLoss(ignore_index=pad_id)&lt;/code&gt; 只忽略目标损失位置；它也不会替源端注意力屏蔽 padding。两端 padding 是两份不同契约。&lt;/p&gt;
&lt;h2&gt;07 推理为什么不能复制训练循环？&lt;/h2&gt;
&lt;p&gt;贪心自回归推理从 &lt;code&gt;&amp;#x3C;bos&gt;&lt;/code&gt; 开始，每步选择一个 token，再作为下一步输入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@torch.inference_mode()
def greedy_decode(
    decoder: AttentiveDecoder,
    initial_state: torch.Tensor,   # [N,H_d]
    encoder_states: torch.Tensor,  # [N,S,H_e]
    source_valid: torch.Tensor,    # [N,S]
    bos_id: int,
    eos_id: int,
    max_steps: int,
) -&gt; tuple[torch.Tensor, torch.Tensor]:
    n = encoder_states.shape[0]
    token = torch.full((n,), bos_id, device=encoder_states.device)
    state = initial_state
    finished = torch.zeros(n, dtype=torch.bool, device=token.device)
    outputs, alignments = [], []

    for _ in range(max_steps):
        logits, state, weights = decoder.step(
            token, state, encoder_states, source_valid
        )
        token = logits.argmax(dim=-1)  # [N]
        token = torch.where(finished, eos_id, token)
        outputs.append(token)
        alignments.append(weights)
        finished |= token.eq(eos_id)
        if finished.all():
            break

    return torch.stack(outputs, dim=1), torch.stack(alignments, dim=1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;必须有 &lt;code&gt;max_steps&lt;/code&gt;，否则没学会输出 &lt;code&gt;&amp;#x3C;eos&gt;&lt;/code&gt; 的模型会无限生成。batch 内较早结束的样本应冻结或屏蔽状态；上面的最小版本只冻结其 token，生产实现还应避免它的无效后续状态污染指标。束搜索（Beam Search）可以保留多个候选，但不会修复错误词表、掩码或训练—推理分布差异。&lt;/p&gt;
&lt;h2&gt;08 如何证明模型真的在使用源位置？&lt;/h2&gt;
&lt;p&gt;注意力矩阵 &lt;code&gt;alignments&lt;/code&gt; 的形状为 &lt;code&gt;[N,T,S]&lt;/code&gt;，适合画目标—源热力图，但“权重大”不自动等于“因果解释”。至少同时做三种检查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;数值不变量。&lt;/strong&gt; 每个有效解码步沿 $S$ 求和应约等于 1，padding 权重应约等于 0，所有值应有限。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;受控任务。&lt;/strong&gt; 构造输入 &lt;code&gt;[a,b,c]&lt;/code&gt;、目标 &lt;code&gt;[c,b,a]&lt;/code&gt; 的反转任务；期望峰值随解码步从右向左移动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;干预。&lt;/strong&gt; 删除或替换被认为关键的源 token，或强制屏蔽其位置，观察目标分布是否按任务语义变化。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;反转任务的期望对齐：

          source
target      a    b    c
  c        .1   .1   .8
  b        .1   .8   .1
  a        .8   .1   .1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若注意力永远盯住最后一个源位置，先检查初始状态是否仍过强、mask 是否反了、源输出是否误用了最终状态广播，以及训练数据是否存在位置捷径。&lt;/p&gt;
&lt;h2&gt;09 最常见的“能训练，但对齐错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;softmax 轴写成目标维或 batch 维。&lt;/strong&gt; 对每个解码步，权重必须沿源位置 $S$ 归一化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;掩码语义反转。&lt;/strong&gt; 本文 &lt;code&gt;source_valid=True&lt;/code&gt; 表示可见，填 &lt;code&gt;-inf&lt;/code&gt; 时使用 &lt;code&gt;~source_valid&lt;/code&gt;；变量名应写进断言。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;softmax 后再清零 padding。&lt;/strong&gt; 剩余权重不再归一；掩码必须进入 softmax 之前的能量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;整条源序列全是 padding。&lt;/strong&gt; 所有分数均为 $-\infty$ 会产生 &lt;code&gt;NaN&lt;/code&gt;；数据入口应拒绝零长度样本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把双向编码器宽度仍写成 $H$。&lt;/strong&gt; 拼接方向后是 &lt;code&gt;2H&lt;/code&gt;，初始解码状态和注意力投影都要显式适配。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;目标输入与标签没有错开。&lt;/strong&gt; 把当前真实 token 同时作为输入和标签，会形成复制捷径。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练时总看真值，验证也看真值。&lt;/strong&gt; 教师强制损失可能很好，自回归生成却会因误差累积崩溃；两种评估都要记录。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在输出后手动 softmax 再送入交叉熵。&lt;/strong&gt; 官方交叉熵要求未归一化 logits。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 attention heatmap 当作翻译质量。&lt;/strong&gt; 对齐清晰不代表 token 正确，权重分散也不必然表示失败。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐步 Python 循环成为吞吐瓶颈。&lt;/strong&gt; 循环注意力本身按目标步串行；生产前要用 profiler 区分模型限制与数据管道等待。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10 它与相近方法的边界在哪里？&lt;/h2&gt;
&lt;p&gt;| 方法                 | 查询来自哪里 | 键和值来自哪里   | 主要用途                             |
| -------------------- | ------------ | ---------------- | ------------------------------------ |
| 定长 Encoder–Decoder | 无逐步查询   | 只有编码器末状态 | 短序列基线、接口简单                 |
| 加性注意力           | 上一解码状态 | 全部编码器状态   | 不同隐宽度下学习非线性对齐           |
| 点积交叉注意力       | 解码器表示   | 编码器表示       | 矩阵乘法高效，但通常要求投影到同宽度 |
| 自注意力             | 同一序列位置 | 同一序列位置     | 在序列内部交换信息                   |&lt;/p&gt;
&lt;p&gt;本文的注意力仍嵌在循环解码器里：第 $t$ 步依赖第 $t-1$ 步，不能并行生成目标。它也没有消除有限词表、暴露偏差、长度偏好和搜索误差。注意力解决的是“每一步能否回到源序列取证”，不是全部生成问题。&lt;/p&gt;
&lt;h2&gt;11 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;定长 Encoder–Decoder 强迫所有源信息先进入一个末状态；加性注意力把接口改为全部源状态和逐步上下文 $c_t$。&lt;/li&gt;
&lt;li&gt;完整数据流是“能量打分 → 源 padding 掩码 → 沿源维 softmax → 对编码状态加权求和”，顺序不能交换。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;encoder_states [N,S,H_e]&lt;/code&gt;、&lt;code&gt;decoder_state [N,H_d]&lt;/code&gt;、&lt;code&gt;weights [N,S]&lt;/code&gt;、&lt;code&gt;context [N,H_e]&lt;/code&gt; 是最值得写进断言的契约。&lt;/li&gt;
&lt;li&gt;教师强制训练与自回归推理读取不同的上一 token；验证只跑前者会掩盖误差累积。&lt;/li&gt;
&lt;li&gt;注意力图是可诊断的中间变量，不是天然因果解释；应结合反转任务、padding 不变量和输入干预。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;将手算例的能量改为 &lt;code&gt;[ln 4, ln 2, 0]&lt;/code&gt;，分别计算无掩码和“第二位置为 padding”时的权重与上下文。解释为什么不能只把第二个源状态设为零。&lt;/li&gt;
&lt;li&gt;为 &lt;code&gt;AdditiveAttention&lt;/code&gt; 写四个测试：不同 $H_e/H_d$、不同源长度、含 padding、全 padding。前三者验证权重和与 padding 权重，第四个验证入口断言。&lt;/li&gt;
&lt;li&gt;在序列反转任务上比较定长 Encoder–Decoder 与加性注意力，将源长度从 5 增到 50；同时报告 token 准确率、完整序列准确率、注意力熵和每秒样本数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1409.3215&quot;&gt;Sutskever, Vinyals &amp;#x26; Le (2014), Sequence to Sequence Learning with Neural Networks&lt;/a&gt;：展示用深层 LSTM 编码定长表示并自回归解码的 Seq2Seq 框架。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1406.1078&quot;&gt;Cho et al. (2014), Learning Phrase Representations using RNN Encoder–Decoder&lt;/a&gt;：提出门控循环编码器—解码器并分析短语表示。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1409.0473&quot;&gt;Bahdanau, Cho &amp;#x26; Bengio (2015), Neural Machine Translation by Jointly Learning to Align and Translate&lt;/a&gt;：提出本文使用的可学习加性对齐，让解码器按步读取源状态。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1508.04025&quot;&gt;Luong, Pham &amp;#x26; Manning (2015), Effective Approaches to Attention-based Neural Machine Translation&lt;/a&gt;：系统比较全局/局部注意力及多种点积与拼接评分。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1609.08144&quot;&gt;Wu et al. (2016), Google&apos;s Neural Machine Translation System&lt;/a&gt;：讨论注意力 Seq2Seq 在大规模翻译中的训练、推理与工程设计。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 下一篇预告&lt;/h2&gt;
&lt;p&gt;加性注意力允许每个解码步重新读取源序列，但编码器与解码器仍沿时间逐步递推。下一篇将把“查询—键—值”写成矩阵乘法，解释 Transformer 的缩放点积自注意力如何让所有位置并行交换信息，以及位置编码、padding mask 与因果 mask 分别阻止什么错误。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>旧信息何时该忘、何时该写入？LSTM 的门控与加法记忆路径</title><link>https://zwjcode.cn/blog/lstm-gates-additive-memory-path</link><guid isPermaLink="true">https://zwjcode.cn/blog/lstm-gates-additive-memory-path</guid><description>从普通 RNN 的梯度连乘出发，手算 LSTM 的遗忘、写入与输出门，拆解加法记忆路径，并用 PyTorch 2.13 对齐实现与变长序列训练。</description><pubDate>Sat, 05 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把普通循环神经网络（Recurrent Neural Network, RNN）沿时间展开后，我们看见了长程学习的症结：从损失回到很早的状态，梯度必须反复穿过同一个循环矩阵和 &lt;code&gt;tanh&lt;/code&gt; 导数。即使前向状态仍非零，远处的训练信号也可能已经小到无法使用。&lt;/p&gt;
&lt;p&gt;长短期记忆网络（Long Short-Term Memory, LSTM）没有取消时间递推。它做的关键改造是：把“对外工作的隐状态”和“沿时间保存的单元状态”分开，再用可学习的门决定旧信息保留多少、新信息写入多少、当前暴露多少。&lt;/p&gt;
&lt;p&gt;本文只讲透一个核心问题：LSTM 如何把普通 RNN 的“每步整体重写”改成受控的加法记忆路径。我们会依次拆解四个信号、手算三步状态、追踪梯度，再与 PyTorch 2.13 的官方实现逐张量对齐。&lt;/p&gt;
&lt;h2&gt;01 普通 RNN 为什么很难选择性地记忆？&lt;/h2&gt;
&lt;p&gt;普通 &lt;code&gt;tanh&lt;/code&gt; RNN 将旧状态和新输入混在一次变换里：&lt;/p&gt;
&lt;p&gt;$$
h_t=\tanh(W_{xh}x_t+W_{hh}h_{t-1}+b)
$$&lt;/p&gt;
&lt;p&gt;假设一个客服对话在第 1 步说明“订单已经退款”，中间 40 步讨论物流，最后才问钱何时到账。模型需要同时做到：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;长时间保留“已经退款”；&lt;/li&gt;
&lt;li&gt;不把每个物流细节都同等写进有限状态；&lt;/li&gt;
&lt;li&gt;在回答时取出与到账问题有关的信息。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;普通 RNN 只有一个整体更新，没有独立的保留、写入和读取开关。其局部梯度还包含：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial h_t}{\partial h_{t-1}}
=\operatorname{diag}(1-h_t^2)W_{hh}
$$&lt;/p&gt;
&lt;p&gt;每跨一步都要再次乘矩阵和非线性导数。梯度裁剪可以压住爆炸，却不能恢复已经消失的梯度；增大隐状态维度可以增加容量，也不会自动创造稳定的长程路径。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;普通 RNN：

h_(t-1) ──┐
           ├─► 仿射变换 ─► tanh ─► h_t
x_t ──────┘                 每一步都整体重写

LSTM：

c_(t-1) ══× 保留量 ══＋ 写入量 ══► c_t   长程记忆主干
             ▲            ▲          │
             └──── gates(x_t,h_(t-1))┘
                                      × 输出门 ─► h_t
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;粗线 &lt;code&gt;c&lt;/code&gt; 是 LSTM 新增的单元状态（Cell State）；它不是永不改变的存储器，而是一个由乘法门控制、用加法更新的状态通道。&lt;/p&gt;
&lt;h2&gt;02 一个 LSTM 单元究竟计算哪些信号？&lt;/h2&gt;
&lt;p&gt;令当前输入 $x_t\in\mathbb{R}^{D}$，上一隐状态 $h_{t-1}\in\mathbb{R}^{H}$，上一单元状态 $c_{t-1}\in\mathbb{R}^{H}$。PyTorch 2.13 当前采用以下方程：&lt;/p&gt;
&lt;p&gt;$$
i_t=\sigma(W_{ii}x_t+b_{ii}+W_{hi}h_{t-1}+b_{hi})
$$&lt;/p&gt;
&lt;p&gt;$$
f_t=\sigma(W_{if}x_t+b_{if}+W_{hf}h_{t-1}+b_{hf})
$$&lt;/p&gt;
&lt;p&gt;$$
g_t=\tanh(W_{ig}x_t+b_{ig}+W_{hg}h_{t-1}+b_{hg})
$$&lt;/p&gt;
&lt;p&gt;$$
o_t=\sigma(W_{io}x_t+b_{io}+W_{ho}h_{t-1}+b_{ho})
$$&lt;/p&gt;
&lt;p&gt;$$
c_t=f_t\odot c_{t-1}+i_t\odot g_t,
\qquad
h_t=o_t\odot\tanh(c_t)
$$&lt;/p&gt;
&lt;p&gt;每个变量的职责和形状如下：&lt;/p&gt;
&lt;p&gt;| 信号  | 形状    | 数值范围 | 作用                                         |
| ----- | ------- | -------- | -------------------------------------------- |
| $i_t$ | &lt;code&gt;[N,H]&lt;/code&gt; | $(0,1)$  | 输入门（Input Gate），控制候选内容写入多少   |
| $f_t$ | &lt;code&gt;[N,H]&lt;/code&gt; | $(0,1)$  | 遗忘门（Forget Gate），控制旧状态保留多少    |
| $g_t$ | &lt;code&gt;[N,H]&lt;/code&gt; | $(-1,1)$ | 候选记忆（Candidate Memory），提供待写入内容 |
| $o_t$ | &lt;code&gt;[N,H]&lt;/code&gt; | $(0,1)$  | 输出门（Output Gate），控制当前暴露多少      |
| $c_t$ | &lt;code&gt;[N,H]&lt;/code&gt; | 不固定   | 单元状态，沿时间保存与累积信息               |
| $h_t$ | &lt;code&gt;[N,H]&lt;/code&gt; | $(-1,1)$ | 隐状态，传给下一步并对外提供当前表示         |&lt;/p&gt;
&lt;p&gt;其中 $\odot$ 是逐元素乘法（Hadamard Product）。门和状态都是向量，不是整个单元只有一个开关：第 7 个状态维度可以选择保留，第 19 个维度可以同时覆写。&lt;/p&gt;
&lt;h2&gt;03 加法记忆路径怎样改变数据流？&lt;/h2&gt;
&lt;p&gt;把一次更新拆开看，LSTM 先计算“保留项”和“写入项”，然后相加：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         ┌───────────────┐
c_(t-1) [N,H] ──────────× f_t [N,H]─────┤
                         │               │
                         │               ＋ ──► c_t [N,H]
x_t [N,D] ──┐            │               │          │
             ├─► gates ──┼─ i_t × g_t ──┘          tanh
h_(t-1)[N,H]┘            │                          │
                         └──────── o_t ─────────────× ──► h_t [N,H]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条图表达了三个不同问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;f_t × c_(t-1)&lt;/code&gt;：过去的哪些维度继续留下？&lt;/li&gt;
&lt;li&gt;&lt;code&gt;i_t × g_t&lt;/code&gt;：当前产生了什么候选内容，其中多少应该写入？&lt;/li&gt;
&lt;li&gt;&lt;code&gt;o_t × tanh(c_t)&lt;/code&gt;：已保存的信息中，当前需要对外暴露哪些？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;关键不是“用了更多激活函数”，而是 $c_t$ 中出现了显式加法。旧状态可以沿第一项直接到达新状态，不必每一步都被完整压进一次新的 &lt;code&gt;tanh&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;04 用一个标量手算三步保留与改写&lt;/h2&gt;
&lt;p&gt;先不计算门的仿射层，直接给出它们的输出，以隔离记忆更新。令 $H=1$、$c_0=0$：&lt;/p&gt;
&lt;p&gt;| 时刻 | $f_t$ | $i_t$ | $g_t$ | $o_t$ | 解释                   |
| ---- | ----: | ----: | ----: | ----: | ---------------------- |
| 1    |  0.90 |  0.80 |  1.00 |  0.70 | 写入一个正向事实       |
| 2    |  0.90 |  0.10 |  0.00 |  0.70 | 几乎不写入，只继续保留 |
| 3    |  0.20 |  0.70 | -1.00 |  0.70 | 大量遗忘并写入反向事实 |&lt;/p&gt;
&lt;p&gt;第一步：&lt;/p&gt;
&lt;p&gt;$$
c_1=0.90\times0+0.80\times1=0.80
$$&lt;/p&gt;
&lt;p&gt;$$
h_1=0.70\tanh(0.80)\approx0.4648
$$&lt;/p&gt;
&lt;p&gt;第二步没有有用新内容：&lt;/p&gt;
&lt;p&gt;$$
c_2=0.90\times0.80+0.10\times0=0.72
$$&lt;/p&gt;
&lt;p&gt;$$
h_2=0.70\tanh(0.72)\approx0.4318
$$&lt;/p&gt;
&lt;p&gt;第三步出现冲突证据：&lt;/p&gt;
&lt;p&gt;$$
c_3=0.20\times0.72+0.70\times(-1)=-0.556
$$&lt;/p&gt;
&lt;p&gt;$$
h_3=0.70\tanh(-0.556)\approx-0.3535
$$&lt;/p&gt;
&lt;p&gt;第二步把旧内容从 $0.80$ 平滑保留到 $0.72$；第三步先把旧内容缩到 $0.144$，再写入 $-0.70$。这就是“门控加法”的可计算含义。&lt;/p&gt;
&lt;p&gt;注意 $h_t$ 和 $c_t$ 不相等。$c_t$ 是内部记忆主干；$h_t$ 经过 &lt;code&gt;tanh&lt;/code&gt; 和输出门，是当前提供给上层、读出头以及下一时间步门控网络的工作表示。&lt;/p&gt;
&lt;h2&gt;05 梯度为什么能沿单元状态走得更远？&lt;/h2&gt;
&lt;p&gt;若暂时只看 $c_{t-1}\rightarrow c_t$ 的直接路径，把门值视为当前前向已确定的系数，则：&lt;/p&gt;
&lt;p&gt;$$
\left.\frac{\partial c_t}{\partial c_{t-1}}\right|_{\text{direct}}
=f_t
$$&lt;/p&gt;
&lt;p&gt;跨越多步的直接梯度路径是：&lt;/p&gt;
&lt;p&gt;$$
\left.\frac{\partial c_T}{\partial c_k}\right|&lt;em&gt;{\text{direct}}
=\prod&lt;/em&gt;{t=k+1}^{T}f_t
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;loss ─► c_T ──× f_T──► c_(T-1) ──× f_(T-1)──► ... ──× f_(k+1)──► c_k

普通 RNN 长链：每步穿过循环矩阵和 tanh 导数
LSTM 直接路径：每步主要由可学习的遗忘门决定保留比例
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若 50 步的遗忘门都约为 $0.95$，直接路径还剩：&lt;/p&gt;
&lt;p&gt;$$
0.95^{50}\approx0.0769
$$&lt;/p&gt;
&lt;p&gt;而每步局部增益为 $0.5$ 的链只剩 $0.5^{50}\approx8.88\times10^{-16}$。LSTM 因而能学习把某些 $f_t$ 推近 1，让对应状态维度有一条较稳定的梯度通路。&lt;/p&gt;
&lt;p&gt;但这不是“梯度永不消失”的证明。门本身依赖 $x_t$ 和 $h_{t-1}$，完整导数还包含其他路径；若 $f_t$ 长期远小于 1，乘积照样衰减；若 sigmoid 饱和，门控参数也会收到很弱的梯度。LSTM 改善了优化几何，没有消除所有长程学习困难。&lt;/p&gt;
&lt;h2&gt;06 放回 batch 后，张量和参数是什么形状？&lt;/h2&gt;
&lt;p&gt;本文采用 &lt;code&gt;batch_first=True&lt;/code&gt;、单层单向、无投影的基本设置：&lt;/p&gt;
&lt;p&gt;| 名称        | 形状      | 含义                            |
| ----------- | --------- | ------------------------------- |
| &lt;code&gt;x&lt;/code&gt;         | &lt;code&gt;[N,T,D]&lt;/code&gt; | $N$ 条序列、$T$ 步、每步 $D$ 维 |
| &lt;code&gt;h0&lt;/code&gt;, &lt;code&gt;c0&lt;/code&gt;  | &lt;code&gt;[1,N,H]&lt;/code&gt; | 初始隐状态与初始单元状态        |
| &lt;code&gt;output&lt;/code&gt;    | &lt;code&gt;[N,T,H]&lt;/code&gt; | 最后一层在每个时刻的 $h_t$      |
| &lt;code&gt;h_n&lt;/code&gt;,&lt;code&gt;c_n&lt;/code&gt; | &lt;code&gt;[1,N,H]&lt;/code&gt; | 最终隐状态与最终单元状态        |
| &lt;code&gt;logits&lt;/code&gt;    | &lt;code&gt;[N,C]&lt;/code&gt;   | 序列级任务的 $C$ 类未归一化分数 |&lt;/p&gt;
&lt;p&gt;四组门通常合并为一次输入仿射和一次循环仿射：&lt;/p&gt;
&lt;p&gt;$$
[a_i;a_f;a_g;a_o]
=W_{ih}x_t+b_{ih}+W_{hh}h_{t-1}+b_{hh}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;p&gt;$$
W_{ih}\in\mathbb{R}^{4H\times D},\quad
W_{hh}\in\mathbb{R}^{4H\times H},\quad
b_{ih},b_{hh}\in\mathbb{R}^{4H}
$$&lt;/p&gt;
&lt;p&gt;因此一层单向 LSTM 的参数量为：&lt;/p&gt;
&lt;p&gt;$$
4HD+4H^2+8H=4H(D+H+2)
$$&lt;/p&gt;
&lt;p&gt;同尺寸普通 RNN 只有 $HD+H^2+2H$ 个参数。LSTM 以约四倍的循环层参数和更多中间激活，换取可学习的记忆控制。&lt;/p&gt;
&lt;h2&gt;07 不调用 LSTM 封装，先写出循环本体&lt;/h2&gt;
&lt;p&gt;下面实现单层、单向 LSTM。&lt;code&gt;chunk(4, dim=-1)&lt;/code&gt; 的顺序必须是 PyTorch 官方约定的 &lt;code&gt;i,f,g,o&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class TransparentLSTM(nn.Module):
    def __init__(self, input_size: int, hidden_size: int) -&gt; None:
        super().__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.weight_ih = nn.Parameter(torch.empty(4 * hidden_size, input_size))
        self.weight_hh = nn.Parameter(torch.empty(4 * hidden_size, hidden_size))
        self.bias_ih = nn.Parameter(torch.zeros(4 * hidden_size))
        self.bias_hh = nn.Parameter(torch.zeros(4 * hidden_size))
        nn.init.xavier_uniform_(self.weight_ih)
        nn.init.orthogonal_(self.weight_hh)

    def forward(
        self,
        x: torch.Tensor,  # [N,T,D]
        state: tuple[torch.Tensor, torch.Tensor] | None = None,
    ) -&gt; tuple[
        torch.Tensor,
        tuple[torch.Tensor, torch.Tensor],
        dict[str, torch.Tensor],
    ]:
        assert x.ndim == 3 and x.shape[-1] == self.input_size
        n, time_steps, _ = x.shape
        if state is None:
            h = x.new_zeros(n, self.hidden_size)  # [N,H]
            c = x.new_zeros(n, self.hidden_size)  # [N,H]
        else:
            h0, c0 = state
            assert h0.shape == c0.shape == (1, n, self.hidden_size)
            h, c = h0[0], c0[0]

        outputs = []
        gate_history = {name: [] for name in (&quot;input&quot;, &quot;forget&quot;, &quot;candidate&quot;, &quot;output&quot;)}
        for t in range(time_steps):
            affine = (
                x[:, t] @ self.weight_ih.T
                + self.bias_ih
                + h @ self.weight_hh.T
                + self.bias_hh
            )  # [N,4H]
            a_i, a_f, a_g, a_o = affine.chunk(4, dim=-1)
            i = torch.sigmoid(a_i)
            f = torch.sigmoid(a_f)
            g = torch.tanh(a_g)
            o = torch.sigmoid(a_o)

            c = f * c + i * g
            h = o * torch.tanh(c)
            outputs.append(h)
            for name, value in zip(gate_history, (i, f, g, o), strict=True):
                gate_history[name].append(value)

        output = torch.stack(outputs, dim=1)  # [N,T,H]
        gates = {
            name: torch.stack(values, dim=1)  # each [N,T,H]
            for name, values in gate_history.items()
        }
        return output, (h.unsqueeze(0), c.unsqueeze(0)), gates
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;显式返回门值只用于教学和诊断。生产训练若不需要门统计，应使用框架融合实现，避免 Python 时间循环和额外激活保存拖慢吞吐。&lt;/p&gt;
&lt;h2&gt;08 与 PyTorch 2.13 官方实现逐张量对齐&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 当前的 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.LSTM.html&quot;&gt;&lt;code&gt;torch.nn.LSTM&lt;/code&gt;&lt;/a&gt; 接口为 &lt;code&gt;input_size&lt;/code&gt;、&lt;code&gt;hidden_size&lt;/code&gt;、&lt;code&gt;num_layers&lt;/code&gt;、&lt;code&gt;bias&lt;/code&gt;、&lt;code&gt;batch_first&lt;/code&gt;、&lt;code&gt;dropout&lt;/code&gt;、&lt;code&gt;bidirectional&lt;/code&gt; 和 &lt;code&gt;proj_size&lt;/code&gt; 等。下面把手写参数复制给官方层，比较每个时刻和两个最终状态：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

torch.manual_seed(11)
x = torch.randn(2, 5, 3)  # [N=2,T=5,D=3]
manual = TransparentLSTM(input_size=3, hidden_size=4)
official = nn.LSTM(
    input_size=3,
    hidden_size=4,
    num_layers=1,
    batch_first=True,
    bidirectional=False,
)

with torch.no_grad():
    official.weight_ih_l0.copy_(manual.weight_ih)
    official.weight_hh_l0.copy_(manual.weight_hh)
    official.bias_ih_l0.copy_(manual.bias_ih)
    official.bias_hh_l0.copy_(manual.bias_hh)

manual_output, (manual_hn, manual_cn), gates = manual(x)
output, (h_n, c_n) = official(x)

assert output.shape == (2, 5, 4)
assert h_n.shape == c_n.shape == (1, 2, 4)
assert gates[&quot;forget&quot;].shape == (2, 5, 4)
torch.testing.assert_close(output, manual_output)
torch.testing.assert_close(h_n, manual_hn)
torch.testing.assert_close(c_n, manual_cn)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方 API 还有五个必须明确的契约：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;batch_first=True&lt;/code&gt; 只改变 &lt;code&gt;input&lt;/code&gt; 和 &lt;code&gt;output&lt;/code&gt;；&lt;code&gt;h_0&lt;/code&gt;、&lt;code&gt;c_0&lt;/code&gt;、&lt;code&gt;h_n&lt;/code&gt;、&lt;code&gt;c_n&lt;/code&gt; 仍以层/方向维开头。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;output&lt;/code&gt; 是最后一层每个时刻的 $h_t$；它不包含全部层，也不返回 $c_t$ 序列。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dropout&gt;0&lt;/code&gt; 只放在相邻 LSTM 层之间，最后一层后不放；&lt;code&gt;num_layers=1&lt;/code&gt; 时不会得到循环时间步 dropout。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bidirectional=True&lt;/code&gt; 令方向数 $R=2$，&lt;code&gt;output&lt;/code&gt; 最后一维变成 $2H$；它使用未来信息，不适用于严格在线预测。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;proj_size&gt;0&lt;/code&gt; 会让隐状态/输出宽度变成投影宽度，但单元状态仍保持 &lt;code&gt;hidden_size&lt;/code&gt;；此时 &lt;code&gt;h_n&lt;/code&gt; 与 &lt;code&gt;c_n&lt;/code&gt; 最后一维不同。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;09 变长序列如何完成一次真实训练？&lt;/h2&gt;
&lt;p&gt;一个 batch 的文本长度可能是 &lt;code&gt;[7,4,2]&lt;/code&gt;。若补齐到 &lt;code&gt;T_max=7&lt;/code&gt; 后直接取 &lt;code&gt;output[:, -1]&lt;/code&gt;，后两个样本读到的是 padding 后位置。打包序列（Packed Sequence）让 LSTM 跳过无效步，并让 &lt;code&gt;h_n&lt;/code&gt; 对应每条序列的真实末尾。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tokens [N,T_max] ─► Embedding ─► x [N,T_max,D]
       lengths [N] ─────────────► pack_padded_sequence
                                      │
                                      ▼
                                  nn.LSTM
                                      │
                               h_n [L,N,H]
                                      │ 取最后一层
                                      ▼
                                  Linear(H,C)
                                      │
                                  logits [N,C]
                                      │
                           CrossEntropyLoss(logits,y[N])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 2.13 当前的 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.utils.rnn.pack_padded_sequence.html&quot;&gt;&lt;code&gt;pack_padded_sequence&lt;/code&gt;&lt;/a&gt; 在 &lt;code&gt;batch_first=True&lt;/code&gt; 时接收 &lt;code&gt;[N,T,*]&lt;/code&gt;。若 &lt;code&gt;lengths&lt;/code&gt; 是张量，它必须位于 CPU；&lt;code&gt;enforce_sorted=False&lt;/code&gt; 允许输入 batch 未按长度降序排列。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn
from torch.nn.utils.rnn import pack_padded_sequence

class PackedLSTMClassifier(nn.Module):
    def __init__(self, vocab_size: int, embed_dim: int, hidden_size: int, classes: int) -&gt; None:
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(
            input_size=embed_dim,
            hidden_size=hidden_size,
            num_layers=1,
            batch_first=True,
        )
        self.head = nn.Linear(hidden_size, classes)

    def forward(self, tokens: torch.Tensor, lengths: torch.Tensor) -&gt; torch.Tensor:
        assert tokens.ndim == 2 and lengths.shape == (tokens.shape[0],)
        x = self.embedding(tokens)  # [N,T_max,D]
        packed = pack_padded_sequence(
            x,
            lengths.cpu(),
            batch_first=True,
            enforce_sorted=False,
        )
        _, (h_n, c_n) = self.lstm(packed)
        assert h_n.shape == c_n.shape
        return self.head(h_n[-1])  # [N,C]，单向模型最后一层

model = PackedLSTMClassifier(vocab_size=5000, embed_dim=64, hidden_size=96, classes=3)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)

tokens = torch.tensor([
    [8, 4, 9, 2, 7, 3, 6],
    [5, 1, 2, 4, 0, 0, 0],
    [7, 3, 0, 0, 0, 0, 0],
])  # [N=3,T_max=7]
lengths = torch.tensor([7, 4, 2])  # [N]，CPU 整数张量
targets = torch.tensor([2, 0, 1])  # [N]，类别索引

model.train()
optimizer.zero_grad(set_to_none=True)
logits = model(tokens, lengths)  # [3,3]，未经 softmax
loss = nn.functional.cross_entropy(logits, targets)
loss.backward()
grad_norm = nn.utils.clip_grad_norm_(
    model.parameters(), max_norm=1.0, error_if_nonfinite=True
)
optimizer.step()

model.eval()
with torch.inference_mode():
    probabilities = model(tokens, lengths).softmax(dim=-1)  # [3,3]
    predictions = probabilities.argmax(dim=-1)             # [3]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;padding_idx=0&lt;/code&gt; 使 padding 词向量不被更新，但它本身不会让 LSTM 跳过 padding；真正跳过无效时间步的是打包。&lt;code&gt;cross_entropy&lt;/code&gt; 接收 logits 和 &lt;code&gt;int64&lt;/code&gt; 类别索引，不能先手动 softmax。&lt;/p&gt;
&lt;h2&gt;10 训练和流式推理的状态边界&lt;/h2&gt;
&lt;p&gt;训练独立样本时，通常让每个 batch 从零状态开始；连续传感器流则可能把 &lt;code&gt;(h_n,c_n)&lt;/code&gt; 传给下一个 chunk：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;state = None
model.eval()
with torch.inference_mode():
    for x_chunk in stream:  # each [N,K,D]，同一批连续会话
        output, state = model.lstm(x_chunk, state)
        h_n, c_n = state
        consume(output)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;截断时间反向传播（Truncated BPTT）中既要传状态数值，又要在 chunk 边界切断旧计算图：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;h, c = h_n.detach(), c_n.detach()
state = (h, c)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个边界不能混用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;独立样本之间复用状态，会造成跨用户或跨序列信息泄漏；&lt;/li&gt;
&lt;li&gt;连续流每个 chunk 清零状态，会把有效上下文硬性限制为 chunk 长度；&lt;/li&gt;
&lt;li&gt;训练连续流长期不 &lt;code&gt;detach()&lt;/code&gt;，计算图和内存会随时间增长。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;生产流式系统还要定义会话结束、超时、乱序、设备迁移和 batch 内某条流提前结束时怎样重置两份状态。LSTM 有 &lt;code&gt;(h,c)&lt;/code&gt; 两个状态，漏重置任何一个都会留下历史。&lt;/p&gt;
&lt;h2&gt;11 怎样证明门真的在完成任务？&lt;/h2&gt;
&lt;p&gt;只看验证损失下降，无法确认模型是否学会长程保留。可以建立一个可证伪的“延迟复制”任务：序列第 1 步给出比特，随后填充噪声，最后一步要求复原该比特。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入：   bit  noise  noise  ...  query
标签：                              bit
距离：    &amp;#x3C;────────── Δ ───────────&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一条可执行的诊断路径是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先用 $\Delta=3$ 过拟合 32 条样本，排除损失、标签与形状错误。&lt;/li&gt;
&lt;li&gt;将 $\Delta$ 逐步增加到 10、30、100，画准确率而不是只看一次终值。&lt;/li&gt;
&lt;li&gt;用教学版 &lt;code&gt;TransparentLSTM&lt;/code&gt; 记录 &lt;code&gt;forget/input/output&lt;/code&gt; 的 &lt;code&gt;[N,T,H]&lt;/code&gt; 分布。&lt;/li&gt;
&lt;li&gt;对第一个输入调用 &lt;code&gt;retain_grad()&lt;/code&gt;，记录最终损失对早期输入的梯度范数。&lt;/li&gt;
&lt;li&gt;同时记录 &lt;code&gt;clip_grad_norm_&lt;/code&gt; 返回的裁剪前总范数和实际发生裁剪的比例。&lt;/li&gt;
&lt;li&gt;将序列中段打乱或将第一步置零，检查预测是否按任务预期改变。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;理想现象不是所有遗忘门都接近 1。若所有维度永远保留，旧内容会持续累积并挤占容量；模型应在需要跨越噪声时保留，在证据失效或被修正时遗忘。&lt;/p&gt;
&lt;h2&gt;12 最常见的“能运行，但记忆语义错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;交换门顺序。&lt;/strong&gt; PyTorch 参数拼接顺序是 &lt;code&gt;i,f,g,o&lt;/code&gt;；若手写代码按其他教材的排法切片，形状完全相同但结果错误。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;h_n&lt;/code&gt; 和 &lt;code&gt;c_n&lt;/code&gt; 当成同一个状态。&lt;/strong&gt; 二者形状通常相同、职责不同，流式传递和重置必须成对进行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为 &lt;code&gt;batch_first&lt;/code&gt; 改变状态布局。&lt;/strong&gt; 它只改变输入和输出；状态仍是 &lt;code&gt;[L·R,N,*]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;变长 batch 使用 &lt;code&gt;output[:, -1]&lt;/code&gt;。&lt;/strong&gt; 短序列读到 padding 后位置；应使用打包后的 &lt;code&gt;h_n&lt;/code&gt; 或按真实长度索引。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 GPU &lt;code&gt;lengths&lt;/code&gt; 直接送进打包函数。&lt;/strong&gt; 当前官方契约要求张量形式的 &lt;code&gt;lengths&lt;/code&gt; 位于 CPU。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在单层 LSTM 上设置 &lt;code&gt;dropout&lt;/code&gt; 就以为完成正则化。&lt;/strong&gt; 内置 dropout 只作用于相邻循环层之间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;序列分类前先 softmax。&lt;/strong&gt; &lt;code&gt;cross_entropy&lt;/code&gt; 要求 logits；提前 softmax 会改变梯度并降低数值稳定性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;双向模型用于在线预测。&lt;/strong&gt; 反向分支需要未来输入，离线指标无法直接转化为实时能力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只记录裁剪后梯度。&lt;/strong&gt; 每步都爆炸再被压平会看似稳定；必须记录裁剪前范数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把遗忘门偏置切错位置。&lt;/strong&gt; 两个偏置向量都按四门拼接，修改前要断言切片并做前向对齐测试。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把门热力图当作因果解释。&lt;/strong&gt; 高门值只说明该坐标的数值通路强，不能单独证明某个词导致答案。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 LSTM 与 GRU 的边界在哪里？&lt;/h2&gt;
&lt;p&gt;门控循环单元（Gated Recurrent Unit, GRU）把记忆接口进一步压缩：没有独立的 $c_t$，而用更新门在旧隐状态和候选状态之间插值，并用重置门控制候选计算读取多少过去。&lt;/p&gt;
&lt;p&gt;| 方法        | 状态接口    | 主要门控           | 一层循环参数量 | 直接取舍                   |
| ----------- | ----------- | ------------------ | -------------- | -------------------------- |
| vanilla RNN | $h_t$       | 无                 | $H(D+H+2)$     | 最简单，但长程梯度路径脆弱 |
| LSTM        | $(h_t,c_t)$ | 输入、遗忘、输出门 | $4H(D+H+2)$    | 控制更细，参数与状态更多   |
| GRU         | $h_t$       | 更新、重置门       | 约 $3H(D+H+2)$ | 接口更紧凑，少一份单元状态 |&lt;/p&gt;
&lt;p&gt;不能仅凭“GRU 参数少”或“LSTM 门更多”预先宣布胜者。公平比较至少要控制数据切分、参数规模、训练预算、序列长度和延迟，并同时报告效果、吞吐、显存与部署状态大小。&lt;/p&gt;
&lt;p&gt;二者仍然逐步递推，都不能在时间维像卷积或 Transformer 那样完全并行。LSTM 解决的是普通 RNN 的记忆更新与梯度路径问题，不是所有序列计算问题。&lt;/p&gt;
&lt;h2&gt;14 LSTM 会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;极长而精确的检索。&lt;/strong&gt; 门值的长乘积仍会衰减，有限维状态也可能被后续事件覆盖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要同时保留大量细节。&lt;/strong&gt; 所有历史必须压入固定宽度 $(h,c)$；长文档中的多个实体会竞争容量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练吞吐受时间依赖限制。&lt;/strong&gt; 同一层的第 $t$ 步依赖第 $t-1$ 步，长序列难以沿时间并行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;门饱和。&lt;/strong&gt; sigmoid 接近 0 或 1 时导数很小，模型可能陷入“几乎总忘”或“几乎总留”的策略。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不规则采样。&lt;/strong&gt; 普通 LSTM 默认相邻步时间间隔等价，医疗事件流等任务需显式加入时间差或改用连续时间模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要指出证据位置。&lt;/strong&gt; 最终状态不给出可审计的来源位置，需要注意力、检索或归因机制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态管理不可靠。&lt;/strong&gt; 在线服务中的漏重置、乱序和跨请求复用，会把模型问题放大成数据隔离问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;增加层数、隐藏宽度或梯度裁剪阈值都不能自动解决这些限制。先把失败归因到容量、优化、计算还是状态边界，再决定结构改造。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;LSTM 将对外隐状态 $h_t$ 与长程单元状态 $c_t$ 分开，让保留、写入和输出成为三个可学习决定。&lt;/li&gt;
&lt;li&gt;核心更新 $c_t=f_t\odot c_{t-1}+i_t\odot g_t$ 是受门控制的加法路径；它比普通 RNN 每步整体经过非线性更利于长程梯度传播。&lt;/li&gt;
&lt;li&gt;直接梯度路径仍包含 $\prod f_t$，所以 LSTM 是缓解而不是消灭梯度消失；门饱和、容量竞争和顺序计算仍存在。&lt;/li&gt;
&lt;li&gt;PyTorch 的门顺序、状态形状、打包长度、层间 dropout 和投影宽度都是必须测试的接口契约。&lt;/li&gt;
&lt;li&gt;调试记忆要使用可控延迟任务、门分布、早期输入梯度和干预实验，不能只看最终损失。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;延续本文的标量例子，把第三步改为 $f_3=0.95,i_3=0.05,g_3=-1,o_3=0.7$，手算 $c_3,h_3$。比较原设置，解释“新证据出现”不等于模型一定会覆写旧记忆。&lt;/li&gt;
&lt;li&gt;为 &lt;code&gt;TransparentLSTM&lt;/code&gt; 写测试：将参数复制到 &lt;code&gt;nn.LSTM&lt;/code&gt;，分别验证零初始状态、自定义 &lt;code&gt;(h0,c0)&lt;/code&gt; 和两个 batch size 下的全部输出。然后故意交换 &lt;code&gt;f&lt;/code&gt; 与 &lt;code&gt;g&lt;/code&gt; 的切片，观察哪项断言最先失败。&lt;/li&gt;
&lt;li&gt;构造延迟复制数据集，让距离 $\Delta\in{5,20,80}$。在相近参数量下比较 vanilla RNN、LSTM 和 GRU 的准确率、裁剪前梯度范数、每秒样本数与状态大小，并说明仅比较最终准确率会遗漏什么。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/neco.1997.9.8.1735&quot;&gt;Hochreiter &amp;#x26; Schmidhuber (1997), Long Short-Term Memory&lt;/a&gt;：提出 LSTM 的长程误差信号与记忆单元框架。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/089976600300015015&quot;&gt;Gers, Schmidhuber &amp;#x26; Cummins (2000), Learning to Forget: Continual Prediction with LSTM&lt;/a&gt;：引入并分析遗忘门，使持续任务能主动释放旧状态。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1303.5778&quot;&gt;Graves, Mohamed &amp;#x26; Hinton (2013), Speech Recognition with Deep Recurrent Neural Networks&lt;/a&gt;：展示深层双向 LSTM 在语音识别中的代表性应用。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1406.1078&quot;&gt;Cho et al. (2014), Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation&lt;/a&gt;：提出带门控的编码器—解码器单元，即后来常称的 GRU。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jmlr.org/papers/v18/17-221.html&quot;&gt;Greff et al. (2017), LSTM: A Search Space Odyssey&lt;/a&gt;：系统比较 LSTM 变体，分析常见组件的实际贡献。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;LSTM 能把较长历史压进最终状态，但当整段输入必须塞进一个定长向量时，信息瓶颈仍然存在。下一篇将进入编码器—解码器（Encoder–Decoder）与注意力机制（Attention Mechanism），追踪解码器如何在每一步直接选择不同的源位置，而不是只依赖最后一个状态。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>固定窗口为何记不住更早的信息？RNN 的隐状态、时间展开与梯度链</title><link>https://zwjcode.cn/blog/rnn-hidden-state-time-unrolling-gradient-memory</link><guid isPermaLink="true">https://zwjcode.cn/blog/rnn-hidden-state-time-unrolling-gradient-memory</guid><description>从变长序列的固定窗口局限出发，手算 RNN 隐状态，拆解时间反向传播的梯度乘积，并用 PyTorch 2.13 验证张量契约与工程边界。</description><pubDate>Sat, 05 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用特征金字塔解决了图像中的空间尺度问题：同一张图的不同位置可以并行计算。但文本、语音和传感器流多了一条不能随意打乱的时间轴：「银行」出现在句尾时，它的含义可能取决于很早以前的「河岸」或「存款」。&lt;/p&gt;
&lt;p&gt;固定窗口只能看最近的 $K$ 个输入；增大 $K$ 又会让参数量、内存和边界处理随窗口改变。循环神经网络（Recurrent Neural Network, RNN）的核心想法是：不把全部历史拼进输入，而是用一个定长隐状态（Hidden State）逐步压缩过去。&lt;/p&gt;
&lt;p&gt;本文只追问三件紧密相连的事：隐状态怎样更新，同一个单元怎样沿时间展开，以及这条长链为何会忘记远处信息。&lt;/p&gt;
&lt;h2&gt;01 固定窗口的不足究竟在哪里？&lt;/h2&gt;
&lt;p&gt;假设每个时刻的特征是 $x_t\in\mathbb{R}^{D}$，序列长度是 $T$。一个长度为 $K$ 的多层感知机（Multilayer Perceptron, MLP）需要拼接：&lt;/p&gt;
&lt;p&gt;$$
z_t=[x_{t-K+1};\ldots;x_t]\in\mathbb{R}^{KD}
$$&lt;/p&gt;
&lt;p&gt;这会带来三个直接问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;$t-K$ 以前的证据必然不可见；&lt;/li&gt;
&lt;li&gt;修改 $K$ 会改变第一层参数形状，不能直接处理任意长序列；&lt;/li&gt;
&lt;li&gt;同一种局部模式出现在不同位置时，普通 MLP 不会自动共享对应参数。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;RNN 将历史压缩为 $h_{t-1}\in\mathbb{R}^{H}$，再与当前 $x_t$ 一起生成 $h_t$。因此输入接口始终是 $D+H$，不随已经看过多少步而变。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;固定窗口 K=3： x1  x2  x3  x4  x5  x6
                              └─── [x4,x5,x6] ──► 预测
                 x1,x2,x3 的细节不可达

RNN：            x1 ─► h1 ─► h2 ─► h3 ─► h4 ─► h5 ─► h6
                         ▲     ▲     ▲     ▲     ▲
                         x2    x3    x4    x5    x6
                 历史以隐状态的形式向右传递
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;02 一个 RNN 单元究竟计算什么？&lt;/h2&gt;
&lt;p&gt;最经典的 Elman RNN 在时刻 $t$ 计算：&lt;/p&gt;
&lt;p&gt;$$
a_t=W_{xh}x_t+b_{xh}+W_{hh}h_{t-1}+b_{hh}
$$&lt;/p&gt;
&lt;p&gt;$$
h_t=\tanh(a_t)
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x_t\in\mathbb{R}^{D}$：当前输入；&lt;/li&gt;
&lt;li&gt;$h_{t-1},h_t\in\mathbb{R}^{H}$：上一步与当前隐状态；&lt;/li&gt;
&lt;li&gt;$W_{xh}\in\mathbb{R}^{H\times D}$：输入到隐状态的权重；&lt;/li&gt;
&lt;li&gt;$W_{hh}\in\mathbb{R}^{H\times H}$：隐状态到隐状态的循环权重；&lt;/li&gt;
&lt;li&gt;$b_{xh},b_{hh}\in\mathbb{R}^{H}$：两条仿射路径的偏置；&lt;/li&gt;
&lt;li&gt;$\tanh$：将每维压到 $(-1,1)$ 的双曲正切激活。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若要在每一步输出 $C$ 类分数，再加一个读出层：&lt;/p&gt;
&lt;p&gt;$$
o_t=W_{hy}h_t+b_y,\qquad
W_{hy}\in\mathbb{R}^{C\times H}
$$&lt;/p&gt;
&lt;p&gt;隐状态是「内部记忆」，读出是「任务答案」。二者不要混为一个变量：同一串 $h_t$ 可以支持序列分类、逐步标注或下一步预测。&lt;/p&gt;
&lt;h2&gt;03 「循环」怎样变成可求导的时间展开？&lt;/h2&gt;
&lt;p&gt;代码里只有一组 $W_{xh},W_{hh}$，计算图里却会出现 $T$ 个时间节点。这称为时间展开（Unrolling Through Time）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                  同一组 W_xh, W_hh 被重复使用

h0 ──► [ RNN cell ] ──► h1 ──► [ RNN cell ] ──► h2 ──► [ RNN cell ] ──► h3
           ▲                            ▲                            ▲
           x1                           x2                           x3
           │                            │                            │
          o1                           o2                           o3 ──► loss

参数量不随 T 增长；中间激活和反向路径却随 T 增长。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;共享参数让模型学的是「遇到一个新输入时如何更新记忆」，而不是「第 17 个位置专用什么权重」。不过，展开后的计算必须按 $t=1,2,\ldots,T$ 依次发生，因为 $h_t$ 依赖 $h_{t-1}$。这也是基础 RNN 不如卷积和 Transformer 容易在时间维并行的根源。&lt;/p&gt;
&lt;h2&gt;04 用三个标量手算一次记忆更新&lt;/h2&gt;
&lt;p&gt;为了只看清数据流，令 $D=H=1$，$h_0=0$，并设：&lt;/p&gt;
&lt;p&gt;$$
W_{xh}=0.5,quad W_{hh}=0.5,quad b_{xh}=b_{hh}=0
$$&lt;/p&gt;
&lt;p&gt;输入序列为 $(x_1,x_2,x_3)=(1,0,1)$。逐步计算：&lt;/p&gt;
&lt;p&gt;$$
h_1=\tanh(0.5\times1+0.5\times0)\approx0.4621
$$&lt;/p&gt;
&lt;p&gt;$$
h_2=\tanh(0.5\times0+0.5\times0.4621)\approx0.2270
$$&lt;/p&gt;
&lt;p&gt;$$
h_3=\tanh(0.5\times1+0.5\times0.2270)\approx0.5466
$$&lt;/p&gt;
&lt;p&gt;$x_2=0$ 时，$h_2$ 仍非零，说明第一步的影响已通过状态传到后面；但 $0.4621$ 经一步已衰减到 $0.2270$。&lt;/p&gt;
&lt;p&gt;若最后的二分类读出为 $z=2h_3-0.5$，则：&lt;/p&gt;
&lt;p&gt;$$
z\approx0.5932,\qquad \sigma(z)\approx0.6441
$$&lt;/p&gt;
&lt;p&gt;这个概率不是某个输入单独给出的，而是前三步按顺序压入 $h_3$ 后的结果。如果交换 $x_1$ 和 $x_3$，即使元素集合不变，路径和结果也可能改变。&lt;/p&gt;
&lt;h2&gt;05 放回 batch 后，每个张量是什么形状？&lt;/h2&gt;
&lt;p&gt;采用 batch-first 约定时：&lt;/p&gt;
&lt;p&gt;| 名称         | 形状                 | 含义                                 |
| ------------ | -------------------- | ------------------------------------ |
| &lt;code&gt;x&lt;/code&gt;          | &lt;code&gt;[N,T,D]&lt;/code&gt;            | $N$ 个序列，每个 $T$ 步，每步 $D$ 维 |
| &lt;code&gt;x[:, t, :]&lt;/code&gt; | &lt;code&gt;[N,D]&lt;/code&gt;              | 整个 batch 在时刻 $t$ 的输入         |
| &lt;code&gt;h0&lt;/code&gt;         | &lt;code&gt;[L·R,N,H]&lt;/code&gt;          | $L$ 层、$R$ 个方向的初始状态         |
| &lt;code&gt;output&lt;/code&gt;     | &lt;code&gt;[N,T,R·H]&lt;/code&gt;          | 最后一层在每个时刻的状态             |
| &lt;code&gt;h_n&lt;/code&gt;        | &lt;code&gt;[L·R,N,H]&lt;/code&gt;          | 每层、每个方向的最终状态             |
| &lt;code&gt;logits&lt;/code&gt;     | &lt;code&gt;[N,C]&lt;/code&gt; 或 &lt;code&gt;[N,T,C]&lt;/code&gt; | 序列级或时间步级的分类分数           |&lt;/p&gt;
&lt;p&gt;这里 $L=\text{num_layers}$；单向时 $R=1$，双向时 $R=2$。单层单向 RNN 本体的参数量是：&lt;/p&gt;
&lt;p&gt;$$
HD+H^2+2H
$$&lt;/p&gt;
&lt;p&gt;它不含读出层，也不随 $T$ 增长。但训练时为反向传播保存的中间状态大致按 $O(NTH)$ 增长。&lt;/p&gt;
&lt;h2&gt;06 不调用 RNN 封装，先写出循环本体&lt;/h2&gt;
&lt;p&gt;下面实现单层、单向、&lt;code&gt;tanh&lt;/code&gt; RNN。注意参数只创建一次，循环里重用它们。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class TransparentRNN(nn.Module):
    def __init__(self, input_size: int, hidden_size: int) -&gt; None:
        super().__init__()
        self.hidden_size = hidden_size
        self.weight_ih = nn.Parameter(torch.empty(hidden_size, input_size))
        self.weight_hh = nn.Parameter(torch.empty(hidden_size, hidden_size))
        self.bias_ih = nn.Parameter(torch.zeros(hidden_size))
        self.bias_hh = nn.Parameter(torch.zeros(hidden_size))
        nn.init.xavier_uniform_(self.weight_ih)
        nn.init.orthogonal_(self.weight_hh)

    def forward(
        self,
        x: torch.Tensor,              # [N, T, D]
        h0: torch.Tensor | None = None,  # [1, N, H]
    ) -&gt; tuple[torch.Tensor, torch.Tensor]:
        assert x.ndim == 3
        n, time_steps, _ = x.shape
        if h0 is None:
            h = x.new_zeros(n, self.hidden_size)  # [N, H]
        else:
            assert h0.shape == (1, n, self.hidden_size)
            h = h0[0]

        states = []
        for t in range(time_steps):
            h = torch.tanh(
                x[:, t] @ self.weight_ih.T
                + self.bias_ih
                + h @ self.weight_hh.T
                + self.bias_hh
            )  # [N, H]
            states.append(h)

        output = torch.stack(states, dim=1)  # [N, T, H]
        h_n = h.unsqueeze(0)                 # [1, N, H]
        return output, h_n

x = torch.tensor([[[1.0], [0.0], [1.0]]])  # [N=1,T=3,D=1]
model = TransparentRNN(input_size=1, hidden_size=1)
with torch.no_grad():
    model.weight_ih.fill_(0.5)
    model.weight_hh.fill_(0.5)

output, h_n = model(x)
expected = torch.tensor([[[0.4621], [0.2270], [0.5466]]])
assert output.shape == (1, 3, 1)
assert h_n.shape == (1, 1, 1)
assert torch.allclose(output, expected, atol=1e-4)
assert torch.allclose(output[:, -1], h_n[0])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码没有隐藏任何时间操作：&lt;code&gt;states[t]&lt;/code&gt; 就是 $h_{t+1}$，&lt;code&gt;h_n&lt;/code&gt; 只是最后一个状态的分层接口形状。&lt;/p&gt;
&lt;h2&gt;07 用 PyTorch 2.13 官方 API 验证同一个计算&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 当前的 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.RNN.html&quot;&gt;&lt;code&gt;torch.nn.RNN&lt;/code&gt;&lt;/a&gt; 接收 &lt;code&gt;input_size&lt;/code&gt;、&lt;code&gt;hidden_size&lt;/code&gt;、&lt;code&gt;num_layers&lt;/code&gt;、&lt;code&gt;nonlinearity&lt;/code&gt;、&lt;code&gt;batch_first&lt;/code&gt;、&lt;code&gt;dropout&lt;/code&gt; 和 &lt;code&gt;bidirectional&lt;/code&gt; 等参数。下面把手写模型的参数复制给官方实现，而不是只相信形状一样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

torch.manual_seed(7)
x = torch.randn(2, 4, 3)  # [N=2,T=4,D=3]
manual = TransparentRNN(input_size=3, hidden_size=5)
official = nn.RNN(
    input_size=3,
    hidden_size=5,
    num_layers=1,
    nonlinearity=&quot;tanh&quot;,
    batch_first=True,
    bidirectional=False,
)

with torch.no_grad():
    official.weight_ih_l0.copy_(manual.weight_ih)
    official.weight_hh_l0.copy_(manual.weight_hh)
    official.bias_ih_l0.copy_(manual.bias_ih)
    official.bias_hh_l0.copy_(manual.bias_hh)

manual_output, manual_hn = manual(x)
output, h_n = official(x)

assert output.shape == (2, 4, 5)
assert h_n.shape == (1, 2, 5)
torch.testing.assert_close(output, manual_output)
torch.testing.assert_close(h_n, manual_hn)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方契约中，&lt;code&gt;output&lt;/code&gt; 包含最后一层的每个时刻，&lt;code&gt;h_n&lt;/code&gt; 包含每层、每个方向的最终状态。单层单向时 &lt;code&gt;output[:, -1] == h_n[0]&lt;/code&gt;；双向、多层或变长 batch 中不要无条件照搬这条索引。&lt;/p&gt;
&lt;p&gt;还有三个 API 细节容易被误读：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dropout&gt;0&lt;/code&gt; 只作用在相邻 RNN 层之间，最后一层之后不用；单层 RNN 不会因此获得时间步间 dropout。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bidirectional=True&lt;/code&gt; 会让输出最后一维变为 $2H$；反向分支使用未来输入，不适用于严格在线预测。&lt;/li&gt;
&lt;li&gt;某些 cuDNN/CUDA 组合的 RNN 运算存在已知非确定性；复现问题时要记录软硬件版本和确定性设置。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;08 一个序列分类器的训练与推理数据流&lt;/h2&gt;
&lt;p&gt;假设任务是判断一段长度固定的传感器序列是否异常，每步 $D=8$ 个特征，最后输出 $C=2$ 类。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x [N,T,8]
    │
    ▼
RNN(input_size=8, hidden_size=32, batch_first=True)
    │
    ├─ output [N,T,32]   逐步标注时使用
    └─ h_n [1,N,32]
             │
             ▼
         Linear(32,2)
             │
             ▼
         logits [N,2] ─► CrossEntropyLoss(logits, y[N])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class SequenceClassifier(nn.Module):
    def __init__(self, input_size: int = 8, hidden_size: int = 32) -&gt; None:
        super().__init__()
        self.rnn = nn.RNN(
            input_size=input_size,
            hidden_size=hidden_size,
            batch_first=True,
        )
        self.head = nn.Linear(hidden_size, 2)

    def forward(self, x: torch.Tensor) -&gt; torch.Tensor:
        assert x.ndim == 3 and x.shape[-1] == self.rnn.input_size
        _, h_n = self.rnn(x)       # [1,N,H]
        return self.head(h_n[-1])  # [N,2]

model = SequenceClassifier()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

x = torch.randn(16, 40, 8)                 # [N,T,D]
y = torch.randint(0, 2, size=(16,))         # [N], int64

model.train()
optimizer.zero_grad(set_to_none=True)
logits = model(x)                           # [N,C]
loss = nn.functional.cross_entropy(logits, y)
loss.backward()
total_norm = nn.utils.clip_grad_norm_(
    model.parameters(), max_norm=1.0, error_if_nonfinite=True
)
optimizer.step()

model.eval()
with torch.inference_mode():
    probabilities = model(x[:2]).softmax(dim=-1)  # [2,2]
    predictions = probabilities.argmax(dim=-1)    # [2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cross_entropy&lt;/code&gt; 直接接收未经 softmax 的 &lt;code&gt;logits&lt;/code&gt;。当前 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.utils.clip_grad_norm_.html&quot;&gt;&lt;code&gt;clip_grad_norm_&lt;/code&gt;&lt;/a&gt; 会将全部参数梯度视为一个连接向量计算总范数，就地修改梯度，并返回裁剪前的总范数。因此应当在 &lt;code&gt;backward()&lt;/code&gt; 之后、&lt;code&gt;step()&lt;/code&gt; 之前调用，并把返回值写入训练日志。&lt;/p&gt;
&lt;h2&gt;09 时间反向传播为何是一串乘法？&lt;/h2&gt;
&lt;p&gt;将普通反向传播应用到展开图，就得到时间反向传播（Backpropagation Through Time, BPTT）。若损失 $\mathcal L$ 只依赖最后状态 $h_T$，较早状态 $h_k$ 收到的信号是：&lt;/p&gt;
&lt;h1&gt;$$
\frac{\partial \mathcal L}{\partial h_k}&lt;/h1&gt;
&lt;p&gt;\frac{\partial \mathcal L}{\partial h_T}
\prod_{t=k+1}^{T}
\frac{\partial h_t}{\partial h_{t-1}}
$$&lt;/p&gt;
&lt;p&gt;对 &lt;code&gt;tanh&lt;/code&gt; RNN：&lt;/p&gt;
&lt;h1&gt;$$
\frac{\partial h_t}{\partial h_{t-1}}&lt;/h1&gt;
&lt;p&gt;\operatorname{diag}(1-h_t^2)W_{hh}
$$&lt;/p&gt;
&lt;p&gt;因此长程信号会反复乘上 $W_{hh}$ 和 &lt;code&gt;tanh&lt;/code&gt; 导数。直觉上：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;loss
  │ × J_T
 h_T
  │ × J_(T-1)
h_(T-1)
  │ × J_(T-2)       J_t = diag(1 - h_t²) W_hh
  ⋮
  │ × J_(k+1)
 h_k        每穿过一步就再乘一次
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在标量、线性化的极简情况中，若每步局部导数都近似 $0.5$，跨 10 步只剩：&lt;/p&gt;
&lt;p&gt;$$
0.5^{10}\approx9.77\times10^{-4}
$$&lt;/p&gt;
&lt;p&gt;跨 50 步则约为 $8.88\times10^{-16}$，这就是梯度消失（Vanishing Gradient）：模型几乎收不到「应该修改很早状态」的信号。反之，若连乘方向的增益持续大于 1，梯度会指数增长，形成梯度爆炸（Exploding Gradient）。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tanh&lt;/code&gt; 饱和时 $1-h_t^2$ 接近 0，会进一步截断梯度。因此「状态数值没有变成 0」并不能证明长程依赖正在被学习；还必须检查对早期输入和状态的梯度。&lt;/p&gt;
&lt;h2&gt;10 用一段最小代码观察梯度乘积&lt;/h2&gt;
&lt;p&gt;下面先去掉输入和激活，只保留 $h_t=wh_{t-1}$，便可直接验证 $\partial h_T/\partial h_0=w^T$。这不是完整 RNN，而是隔离「连乘」的调试实验。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

def gradient_across_time(weight: float, steps: int) -&gt; float:
    h0 = torch.tensor(1.0, requires_grad=True)
    h = h0
    for _ in range(steps):
        h = weight * h
    h.backward()
    assert h0.grad is not None
    return h0.grad.item()

assert abs(gradient_across_time(0.5, 10) - 0.5**10) &amp;#x3C; 1e-9
assert abs(gradient_across_time(1.5, 10) - 1.5**10) &amp;#x3C; 1e-4

for steps in (1, 5, 10, 50):
    print(steps, gradient_across_time(0.5, steps))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实 RNN 中的雅可比是矩阵，不能只看某个权重元素是否小于 1。更可操作的方法是：在人工长程任务上改变延迟步数，记录梯度总范数、分层梯度、早期输入梯度与准确率随距离的曲线。&lt;/p&gt;
&lt;h2&gt;11 完整 BPTT 与截断 BPTT 分别做了什么？&lt;/h2&gt;
&lt;p&gt;完整 BPTT 的逻辑是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;h = h0
states = []
for t = 1 ... T:
    h = rnn_cell(x[t], h)   # 前向时保留计算图
    states.append(h)

loss = task_loss(states, targets)
loss.backward()             # 从后向前穿过全部 T 步
clip_grad_norm(parameters)
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当连续流太长时，常把它切成长度 $K$ 的块，每块传入上一块的数值状态，但用 &lt;code&gt;detach()&lt;/code&gt; 切断跨块计算图。这是截断时间反向传播（Truncated BPTT）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;h = None
for x_chunk, y_chunk in stream:  # x_chunk: [N,K,D]
    optimizer.zero_grad(set_to_none=True)
    output, h = model.rnn(x_chunk, h)
    logits = model.head(output)  # [N,K,C]
    loss = nn.functional.cross_entropy(
        logits.reshape(-1, logits.shape[-1]),
        y_chunk.reshape(-1),
    )
    loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    optimizer.step()
    h = h.detach()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里传给下一块的是状态的数值，所以推理上下文仍连续；被切断的是梯度路径，因此训练信号最多跨 $K$ 步。&lt;code&gt;detach()&lt;/code&gt; 不是解决长程依赖的方法，而是用优化偏差换内存和吞吐。若每个 chunk 都把 &lt;code&gt;h&lt;/code&gt; 重置为零，连前向记忆也一起丢了；若从不 &lt;code&gt;detach()&lt;/code&gt;，图会持续增长，第二次反向还可能触发已释放图错误。&lt;/p&gt;
&lt;h2&gt;12 变长序列为什么不能直接拿最后一列？&lt;/h2&gt;
&lt;p&gt;一个 batch 的真实长度可能是 &lt;code&gt;[7,4,2]&lt;/code&gt;。补零后张量是 &lt;code&gt;[3,7,D]&lt;/code&gt;，但 &lt;code&gt;output[:, -1]&lt;/code&gt; 对后两个样本对应的是 padding 位置，不是真实末尾。至少有两种正确路径：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用长度索引每个样本的 &lt;code&gt;output[n, length[n]-1]&lt;/code&gt;，同时确保 padding 不污染后续计算；&lt;/li&gt;
&lt;li&gt;用打包序列（Packed Sequence）让 RNN 跳过 padding。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;PyTorch 2.13 当前的 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.utils.rnn.pack_padded_sequence.html&quot;&gt;&lt;code&gt;pack_padded_sequence&lt;/code&gt;&lt;/a&gt; 在 &lt;code&gt;batch_first=True&lt;/code&gt; 时接收 &lt;code&gt;[N,T,*]&lt;/code&gt;；若 &lt;code&gt;lengths&lt;/code&gt; 是张量，它必须位于 CPU。&lt;code&gt;enforce_sorted=False&lt;/code&gt; 可接受未按长度降序排列的 batch。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn
from torch.nn.utils.rnn import pack_padded_sequence

rnn = nn.RNN(input_size=8, hidden_size=16, batch_first=True)
x_padded = torch.randn(3, 7, 8)       # [N=3,T_max=7,D=8]
lengths = torch.tensor([7, 4, 2])     # CPU int tensor

packed = pack_padded_sequence(
    x_padded,
    lengths.cpu(),
    batch_first=True,
    enforce_sorted=False,
)
packed_output, h_n = rnn(packed)
assert h_n.shape == (1, 3, 16)        # each sample&apos;s real final state
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;打包只解决无效 padding 计算与末尾定位，不会解决梯度消失。做逐步损失时还要明确标签的 padding 掩码（Mask）或同样打包标签，不能让补零位置进入损失均值。&lt;/p&gt;
&lt;h2&gt;13 一条可执行的 RNN 调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先固定形状契约。&lt;/strong&gt; 在入口断言 &lt;code&gt;x=[N,T,D]&lt;/code&gt;、&lt;code&gt;lengths=[N]&lt;/code&gt;、&lt;code&gt;h0=[L·R,N,H]&lt;/code&gt;；不要用变量名 &lt;code&gt;batch&lt;/code&gt; 同时代指数据和 batch size。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用三步序列对齐手写实现。&lt;/strong&gt; 将手写单元参数复制到 &lt;code&gt;nn.RNN&lt;/code&gt;，比较全部 &lt;code&gt;output&lt;/code&gt;，而不是只比较最后一项。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做顺序敏感性测试。&lt;/strong&gt; 对同一批样本分别输入原序列与时间反转序列；若任务应依赖顺序而输出始终相同，检查维度是否被错误求和或展平。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做记忆探针。&lt;/strong&gt; 构造“第一位决定标签，中间全是噪声”的任务，将 $T$ 从 5 增到 100，画准确率和早期输入梯度随距离的变化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录裁剪前梯度。&lt;/strong&gt; &lt;code&gt;clip_grad_norm_&lt;/code&gt; 返回的总范数才显示爆炸是否发生；只记录裁剪后范数会把所有异常伪装成阈值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;区分数值状态与计算图。&lt;/strong&gt; 流式推理用 &lt;code&gt;inference_mode()&lt;/code&gt; 并显式传递状态；截断训练在 chunk 边界 &lt;code&gt;detach()&lt;/code&gt;；独立样本之间必须重置状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先过拟合一个极小 batch。&lt;/strong&gt; 若 8 个短序列都不能把训练损失压低，优先检查标签对齐、最后状态索引、损失输入和 &lt;code&gt;zero_grad&lt;/code&gt;，不要先加层数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 最常见的“能运行，但序列语义错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;[N,T,D]&lt;/code&gt; 送给默认 &lt;code&gt;batch_first=False&lt;/code&gt;。&lt;/strong&gt; 模型会把 $N$ 当时间、$T$ 当 batch，形状有时仍能通过。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为 &lt;code&gt;batch_first&lt;/code&gt; 也会改变 &lt;code&gt;h_n&lt;/code&gt;。&lt;/strong&gt; 于是错误地读取 &lt;code&gt;h_n[:, -1]&lt;/code&gt;，拿到的是最后一个样本而不是最后一层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;变长序列直接取 &lt;code&gt;output[:, -1]&lt;/code&gt;。&lt;/strong&gt; 短样本读到 padding 后状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在线任务使用双向 RNN。&lt;/strong&gt; 离线验证很好，上线时却需要尚未到达的未来输入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 batch 之间的状态无条件复用。&lt;/strong&gt; 若样本互不相关，这会把上一位用户的信息泄漏给下一位用户。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;切 chunk 时每次清零状态。&lt;/strong&gt; 这把有效上下文上限硬性改成 chunk 长度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;长期不 &lt;code&gt;detach()&lt;/code&gt;。&lt;/strong&gt; 连续流的计算图与显存不断增长，或在重复反向时出错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只裁剪梯度却不记录裁剪比例。&lt;/strong&gt; 模型可能每一步都撞上阈值，训练看似稳定，实际更新方向长期失真。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分类前先 &lt;code&gt;softmax&lt;/code&gt; 再传给交叉熵。&lt;/strong&gt; 破坏数值稳定的 logits 接口，并改变梯度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;h_n&lt;/code&gt; 当成人类可读摘要。&lt;/strong&gt; 隐状态坐标由任务共同学习，单维数值通常没有稳定语义。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 它会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;很长的精确记忆。&lt;/strong&gt; vanilla RNN 很难把早期一个比特可靠保留几百步，BPTT 的连乘会让学习信号消失或爆炸。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;必须保留大量细节。&lt;/strong&gt; 固定 $H$ 的状态是瓶颈；长文档、长音频或多事件流会竞争有限容量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要大规模并行训练。&lt;/strong&gt; $h_t$ 对 $h_{t-1}$ 的依赖限制了时间维并行，长序列吞吐通常不如卷积或自注意力结构。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不规则时间间隔。&lt;/strong&gt; 普通 RNN 默认相邻步间隔等价；医疗记录或事件流需要显式加入时间差，甚至使用连续时间模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分布漂移的流式状态。&lt;/strong&gt; 状态会积累旧分布影响；缺少重置、超时和会话边界时，错误可跨很长时间传播。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要解释具体证据位置。&lt;/strong&gt; 单一最终状态不直接告诉人们答案主要来自哪个时间步，必须另加探针、注意力或归因分析。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;梯度裁剪只能限制爆炸，不能把已接近 0 的梯度放大成有用信号；正交初始化可改善早期训练，也不能保证 &lt;code&gt;tanh&lt;/code&gt; 长链永久保真。这些是缓解手段，不是结构性保证。&lt;/p&gt;
&lt;h2&gt;16 与相近序列方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法                | 怎样读取历史             | 时间维并行 | 长程信息的主要瓶颈                     |
| ------------------- | ------------------------ | ---------- | -------------------------------------- |
| 固定窗口 MLP        | 拼接最近 $K$ 步          | 高         | $K$ 之外绝对不可见                     |
| 一维因果卷积        | 局部卷积核逐层扩大感受野 | 高         | 感受野由深度、卷积核和 dilation 决定   |
| vanilla RNN         | 单一隐状态逐步递推       | 低         | 状态瓶颈与雅可比连乘                   |
| 长短期记忆网络 LSTM | 门控单元状态与隐状态     | 低         | 门控仍可能饱和，且顺序计算仍存在       |
| 门控循环单元 GRU    | 更紧凑的更新/重置门状态  | 低         | 与 LSTM 类似但状态接口更少             |
| Transformer         | 每个位置直接聚合其他位置 | 训练时高   | 标准全局注意力的时间/显存随 $T^2$ 增长 |&lt;/p&gt;
&lt;p&gt;vanilla RNN 的价值不只在于今天是否是最强模型。它把“状态、共享转移、时间展开、BPTT”放进最小系统；LSTM、GRU、状态空间模型和自回归推理都在不同程度上继承或改造这些问题。&lt;/p&gt;
&lt;h2&gt;17 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;RNN 用 $h_t=f(x_t,h_{t-1})$ 将任意长历史压入定长状态；参数不随序列长度增长，但激活内存和顺序计算会增长。&lt;/li&gt;
&lt;li&gt;循环单元在代码中只有一份，沿时间展开后成为共享参数的长计算图；&lt;code&gt;output&lt;/code&gt; 与 &lt;code&gt;h_n&lt;/code&gt; 表达不同接口。&lt;/li&gt;
&lt;li&gt;BPTT 让远处梯度反复乘以循环雅可比；乘积持续收缩会消失，持续放大会爆炸。&lt;/li&gt;
&lt;li&gt;梯度裁剪、截断 BPTT 和打包变长序列分别处理爆炸、图长度和 padding，它们不能互相替代。&lt;/li&gt;
&lt;li&gt;调试 RNN 要同时检查时间顺序、长度、状态边界与梯度随距离的变化，不能只看最终损失。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;18 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;令 $D=H=1$、$W_{xh}=1$、$W_{hh}=0.25$、$h_0=0$，手算输入 $(1,1,0)$ 的三个隐状态。再将输入反转为 $(0,1,1)$，解释为何元素相同而最终状态不同。&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;TransparentRNN&lt;/code&gt; 扩展为返回每一步预激活 $a_t$，对 $T\in{5,20,50}$ 保留早期状态梯度并画范数。分别尝试循环权重缩放为 0.5、1.0 和 1.5，观察 &lt;code&gt;tanh&lt;/code&gt; 饱和如何改变纯线性结论。&lt;/li&gt;
&lt;li&gt;构造真实长度 &lt;code&gt;[7,4,2]&lt;/code&gt; 的 batch，分别用长度索引和 &lt;code&gt;pack_padded_sequence&lt;/code&gt; 取得最终状态，验证两者一致；然后故意使用 &lt;code&gt;output[:, -1]&lt;/code&gt;，定位短样本偏差从哪一步开始。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1207/s15516709cog1402_1&quot;&gt;Elman (1990), Finding Structure in Time&lt;/a&gt;：展示简单循环网络如何通过上下文单元学习序列结构。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1038/323533a0&quot;&gt;Rumelhart, Hinton &amp;#x26; Williams (1986), Learning Representations by Back-propagating Errors&lt;/a&gt;：系统阐述多层网络的反向传播学习机制。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/5.58337&quot;&gt;Werbos (1990), Backpropagation Through Time: What It Does and How to Do It&lt;/a&gt;：给出时间展开网络的反向传播分析与实践说明。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/72.279181&quot;&gt;Bengio, Simard &amp;#x26; Frasconi (1994), Learning Long-Term Dependencies with Gradient Descent Is Difficult&lt;/a&gt;：分析梯度方法学习长程依赖的根本困难。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1211.5063&quot;&gt;Pascanu, Mikolov &amp;#x26; Bengio (2013), On the Difficulty of Training Recurrent Neural Networks&lt;/a&gt;：从几何角度分析消失/爆炸梯度并讨论范数裁剪。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;19 下一篇预告&lt;/h2&gt;
&lt;p&gt;vanilla RNN 已经给了历史一条通路，却让信息和梯度每一步都必须穿过同一个非线性变换。下一篇将继续追问：长短期记忆网络（Long Short-Term Memory, LSTM）与门控循环单元（Gated Recurrent Unit, GRU）怎样用门控加法路径决定何时写入、保留和遗忘。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>同一个目标该交给哪层负责？FPN 的尺度分配与跨层去重</title><link>https://zwjcode.cn/blog/fpn-scale-assignment-cross-level-nms</link><guid isPermaLink="true">https://zwjcode.cn/blog/fpn-scale-assignment-cross-level-nms</guid><description>从密集检测的责任歧义出发，手算 FCOS 尺度范围、中心采样与跨层 NMS，并用 PyTorch 与 torchvision 0.28 检查训练—推理坐标契约。</description><pubDate>Fri, 04 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇已经得到了语义更一致的 &lt;code&gt;P3...P5&lt;/code&gt; 特征金字塔（Feature Pyramid）。但“每层都能看见目标”并不等于“每层都应该学这个目标”。若一辆车同时在 P3、P4 和 P5 上被标为正样本，训练时三层会争抢责任；推理时又会吐出多个几乎重合的框。&lt;/p&gt;
&lt;p&gt;本文只讲透这一个接口问题：以无锚框检测器 FCOS（Fully Convolutional One-Stage Object Detection）为例，看清一个原图目标如何按回归距离分给某个 FPN 层，各层预测如何还原到同一坐标系，以及非极大值抑制（Non-maximum Suppression, NMS）如何清理跨层重复框。&lt;/p&gt;
&lt;h2&gt;01 FPN 输出之后，还缺一份“责任表”&lt;/h2&gt;
&lt;p&gt;假设原图是 &lt;code&gt;[N,3,256,256]&lt;/code&gt;，FPN 通道数都是 $D=256$：&lt;/p&gt;
&lt;p&gt;| 层级 | 步幅 $s_l$ | 特征形状        | 空间位置数 | 更适合的回归距离示例 |
| ---- | ---------- | --------------- | ---------- | -------------------- |
| P3   | 8          | &lt;code&gt;[N,256,32,32]&lt;/code&gt; | 1024       | $(0,64)$             |
| P4   | 16         | &lt;code&gt;[N,256,16,16]&lt;/code&gt; | 256        | $(64,128)$           |
| P5   | 32         | &lt;code&gt;[N,256,8,8]&lt;/code&gt;   | 64         | $(128,+\infty)$      |&lt;/p&gt;
&lt;p&gt;密集检测头（Dense Detection Head）会在每个空间位置输出类别、边界框和中心度。若有 $K$ 个类别，一层的数据流可写成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;P_l [N,D,H_l,W_l]
  │
  ├─ 分类分支 ──► cls_l [N,K,H_l,W_l] ─► [N,H_l W_l,K]
  ├─ 回归分支 ──► box_l [N,4,H_l,W_l] ─► [N,H_l W_l,4]
  └─ 中心分支 ──► ctr_l [N,1,H_l,W_l] ─► [N,H_l W_l,1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;金字塔解决了“特征从哪里来”，但没有自动定义以下契约：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;训练时，哪些位置是某个真值框（Ground-truth Box）的正样本；&lt;/li&gt;
&lt;li&gt;一个目标应由哪个尺度层负责；&lt;/li&gt;
&lt;li&gt;推理时，各层的局部预测怎样还原为原图像素坐标。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这三条若有一条在数据增强、训练和推理之间不一致，张量形状仍可完全正确，模型却会学到错位的标签。&lt;/p&gt;
&lt;h2&gt;02 先把网格位置送回原图坐标系&lt;/h2&gt;
&lt;p&gt;对步幅为 $s_l$ 的特征层，一种常见约定是把网格 &lt;code&gt;(i,j)&lt;/code&gt; 的预测点放在原图中：&lt;/p&gt;
&lt;p&gt;$$
x=(j+0.5)s_l,\qquad y=(i+0.5)s_l
$$&lt;/p&gt;
&lt;p&gt;因此 P3 的 &lt;code&gt;(i=7,j=7)&lt;/code&gt; 对应 $(x,y)=(60,60)$，而不是 $(7,7)$。不同检测实现可能使用不同的网格原点；关键不是强行选 &lt;code&gt;0.5&lt;/code&gt;，而是标签生成、框解码和可视化共用同一约定。&lt;/p&gt;
&lt;p&gt;设真值框为 $B=(x_0,y_0,x_1,y_1)$，且不含逆序或零面积框。点 $(x,y)$ 到四条边的距离是：&lt;/p&gt;
&lt;p&gt;$$
(l,t,r,b)=(x-x_0,\ y-y_0,\ x_1-x,\ y_1-y)
$$&lt;/p&gt;
&lt;p&gt;它们都大于 0 时，该点严格位于框内。反过来，已知点和预测距离 $\hat l,\hat t,\hat r,\hat b$，就能解码为：&lt;/p&gt;
&lt;p&gt;$$
\hat B=(x-\hat l,\ y-\hat t,\ x+\hat r,\ y+\hat b)
$$&lt;/p&gt;
&lt;p&gt;这里的边界框与预测点必须在同一度量下。若回归目标除以了步幅，推理解码前必须乘回同一步幅。&lt;/p&gt;
&lt;h2&gt;03 只要点在框内，为什么还不够？&lt;/h2&gt;
&lt;p&gt;一个大框可能包含数百个 P3 位置。若它们全是正样本，靠近边缘的点必须预测非常不对称的长距离，而邻近物体的重叠区又容易出现归属歧义。&lt;/p&gt;
&lt;p&gt;FCOS 引入中心采样（Center Sampling）：候选点除了位于真值框内，还要靠近框中心 $(c_x,c_y)$。若半径系数为 $\rho$，常用条件是：&lt;/p&gt;
&lt;p&gt;$$
\max(|x-c_x|,|y-c_y|)&amp;#x3C;\rho s_l
$$&lt;/p&gt;
&lt;p&gt;这是以 $L_\infty$ 距离定义的中心方形，不是欧氏圆。半径随层级步幅放大，使不同密度网格拥有近似的“几个单元”责任范围。&lt;/p&gt;
&lt;h2&gt;04 尺度范围怎样把目标分给某一层？&lt;/h2&gt;
&lt;p&gt;对某个候选点和真值框，定义回归难度的尺度：&lt;/p&gt;
&lt;p&gt;$$
m=\max(l,t,r,b)
$$&lt;/p&gt;
&lt;p&gt;然后为每个层级设定责任区间 $(a_l,b_l)$，只有 $a_l&amp;#x3C;m&amp;#x3C;b_l$ 时才允许匹配。它不是直接用目标面积或长边，而是看“这个位置需要回归多远”。&lt;/p&gt;
&lt;p&gt;torchvision 0.28 当前 FCOS 源码中，默认每层的点由一个边长等于步幅的单锚框表示，匹配下界为 $4s_l$，上界为 $8s_l$；最浅层下界改为 0，最深层上界改为 $+\infty$。因此用 P3/P4/P5 的简化金字塔时，就得到表中的 $(0,64)$、$(64,128)$ 和 $(128,+\infty)$。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;小回归距离                         大回归距离
0                 64                128                         +∞
├───── P3 负责 ────┤───── P4 负责 ────┤─────── P5 负责 ───────►
网格密，定位细                     中等尺度                  网格稀，感受野大
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些边界不是物理定律。更换输入分辨率、金字塔层数、数据集目标分布或匹配器后，都要重新检查正样本是否均衡，不能只复制一组阈值。&lt;/p&gt;
&lt;h2&gt;05 用一个框手算 P3 与 P4 的归属&lt;/h2&gt;
&lt;p&gt;设真值框为：&lt;/p&gt;
&lt;p&gt;$$
B=(36,36,92,84),\qquad (c_x,c_y)=(64,60)
$$&lt;/p&gt;
&lt;p&gt;在 P3 上取预测点 $q_3=(60,60)$，步幅 $s_3=8$，中心半径系数 $\rho=1.5$。四边距离为：&lt;/p&gt;
&lt;p&gt;$$
(l,t,r,b)=(24,24,32,24),\qquad m=32
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;四个距离都为正，点在框内；&lt;/li&gt;
&lt;li&gt;$\max(|60-64|,|60-60|)=4&amp;#x3C;1.5\times8=12$，通过中心采样；&lt;/li&gt;
&lt;li&gt;$0&amp;#x3C;32&amp;#x3C;64$，落在 P3 责任区间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此 $q_3$ 是该框的 P3 正样本。&lt;/p&gt;
&lt;p&gt;再看 P4 上靠近中心的点 $q_4=(56,56)$，$s_4=16$：&lt;/p&gt;
&lt;p&gt;$$
(l,t,r,b)=(20,20,36,28),\qquad m=36
$$&lt;/p&gt;
&lt;p&gt;它在框内，且 $\max(8,4)=8&amp;#x3C;1.5\times16=24$，却不满足 P4 的 $64&amp;#x3C;m&amp;#x3C;128$。这正是尺度分配的作用：P4 “看得到”这个框，但不对它负责。&lt;/p&gt;
&lt;p&gt;若同一个点同时满足多个重叠真值框，torchvision FCOS 当前会匹配面积更小的那一个。这个冲突规则也是标签契约的一部分。&lt;/p&gt;
&lt;h2&gt;06 不调用检测器，先写出匹配本体&lt;/h2&gt;
&lt;p&gt;下面的函数把所有层的点展平后一次匹配。&lt;code&gt;points&lt;/code&gt; 形状为 &lt;code&gt;[P,2]&lt;/code&gt;，&lt;code&gt;boxes&lt;/code&gt; 为 &lt;code&gt;[M,4]&lt;/code&gt;，&lt;code&gt;regress_ranges&lt;/code&gt; 为 &lt;code&gt;[P,2]&lt;/code&gt;；中间距离张量是 &lt;code&gt;[P,M,4]&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

def assign_fcos_points(
    points: torch.Tensor,          # [P, 2], image-space (x, y)
    strides: torch.Tensor,         # [P]
    regress_ranges: torch.Tensor,  # [P, 2], image-space (low, high)
    boxes: torch.Tensor,           # [M, 4], (x0, y0, x1, y1)
    labels: torch.Tensor,          # [M], int64
    center_radius: float = 1.5,
) -&gt; tuple[torch.Tensor, torch.Tensor]:
    assert points.ndim == 2 and points.shape[1] == 2
    assert boxes.ndim == 2 and boxes.shape[1] == 4
    assert len(boxes) &gt; 0  # empty-target images should take a separate background path
    assert strides.shape == (len(points),)
    assert regress_ranges.shape == (len(points), 2)
    assert labels.dtype == torch.int64 and labels.shape == (len(boxes),)

    px, py = points[:, None, 0], points[:, None, 1]  # [P, 1]
    x0, y0, x1, y1 = boxes[None].unbind(dim=-1)      # each [1, M]
    distances = torch.stack(
        (px - x0, py - y0, x1 - px, y1 - py),
        dim=-1,
    )                                                # [P, M, 4]

    inside_box = distances.amin(dim=-1) &gt; 0          # [P, M]
    centers = (boxes[:, :2] + boxes[:, 2:]) / 2      # [M, 2]
    center_delta = (points[:, None] - centers[None]).abs()
    inside_center = center_delta.amax(dim=-1) &amp;#x3C; (
        center_radius * strides[:, None]
    )                                                # [P, M]

    max_distance = distances.amax(dim=-1)            # [P, M]
    low = regress_ranges[:, None, 0]
    high = regress_ranges[:, None, 1]
    inside_scale = (max_distance &gt; low) &amp;#x26; (max_distance &amp;#x3C; high)
    valid = inside_box &amp;#x26; inside_center &amp;#x26; inside_scale

    areas = (
        (boxes[:, 2] - boxes[:, 0])
        * (boxes[:, 3] - boxes[:, 1])
    )                                                # [M]
    candidate_areas = areas[None].expand(len(points), -1).clone()
    candidate_areas.masked_fill_(~valid, float(&quot;inf&quot;))
    best_area, best_gt = candidate_areas.min(dim=1)  # each [P]

    matched_labels = labels[best_gt].clone()
    background = torch.isinf(best_area)
    matched_labels[background] = -1

    row = torch.arange(len(points), device=points.device)
    matched_distances = distances[row, best_gt].clone()  # [P, 4]
    matched_distances[background] = 0
    return matched_labels, matched_distances

points = torch.tensor([[60.0, 60.0], [56.0, 56.0]])
strides = torch.tensor([8.0, 16.0])
ranges = torch.tensor([[0.0, 64.0], [64.0, 128.0]])
boxes = torch.tensor([[36.0, 36.0, 92.0, 84.0]])
labels = torch.tensor([2], dtype=torch.int64)

matched_labels, distances = assign_fcos_points(
    points, strides, ranges, boxes, labels
)
assert matched_labels.tolist() == [2, -1]
assert distances[0].tolist() == [24.0, 24.0, 32.0, 24.0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码刻意不计算分类损失：匹配先决定“谁是正样本”，损失再决定“错多少要罚多重”。把两者混成一个黑盒时，正样本全空往往只表现为损失“很稳定”。&lt;/p&gt;
&lt;h2&gt;07 训练时的完整数据流&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原图 + 原图框
      │  resize / crop / pad：图像和框必须同步变换
      ▼
训练坐标系中的 image [N,3,H,W] 与 boxes [M,4]
      │
      ├─► backbone + FPN ─► P3...P7 ─► cls / ltrb / centerness
      │                                      │
      └─► 生成各层 points ─► 中心采样 + 尺度匹配 ─┼─► losses
                                             │
loss.backward() ◄─ head ◄─ FPN ◄─ backbone ◄───────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对每个匹配点，常见的中心度目标为：&lt;/p&gt;
&lt;p&gt;$$
c^*=\sqrt{
\frac{\min(l,r)}{\max(l,r)}
\frac{\min(t,b)}{\max(t,b)}
}
$$&lt;/p&gt;
&lt;p&gt;在框中心，$l\approx r$ 且 $t\approx b$，$c^*$ 接近 1；越贴近边缘则越小。torchvision FCOS 当前用分类损失、GIoU 框回归损失和中心度损失训练三条分支，并按前景点数归一化。&lt;/p&gt;
&lt;p&gt;训练前至少要记录每层：&lt;code&gt;num_points&lt;/code&gt;、&lt;code&gt;num_positive&lt;/code&gt;、&lt;code&gt;positive / gt&lt;/code&gt;、最大回归距离分布和三项损失。总损失下降时，P5 可能仍然从未得到正样本。&lt;/p&gt;
&lt;h2&gt;08 推理时为什么还会出现跨层重复框？&lt;/h2&gt;
&lt;p&gt;尺度匹配是训练目标，不是推理时的硬门。学到的决策边界不会严格停在 64 或 128 像素，相邻层也可能对同一物体输出高分框。&lt;/p&gt;
&lt;p&gt;常见后处理数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;每个层级：
logits + centerness ─► score ─► 阈值过滤 ─► level top-k
ltrb + point       ─► 解码为训练图像坐标 ─► clip
                                      │
所有层 boxes / scores / labels ─► concat ─► 分类别 NMS
                                      │
                                      └─► 缩放回原始图像坐标
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;torchvision FCOS 当前将每个候选的分类概率 $p$ 与中心度 $c$ 合成为 $\sqrt{pc}$，逐层做阈值和 &lt;code&gt;topk_candidates&lt;/code&gt; 过滤，再拼接全部层级。这样先减少候选数，避免对数万个低分框直接做 NMS。&lt;/p&gt;
&lt;h2&gt;09 用两个框手算 NMS&lt;/h2&gt;
&lt;p&gt;设 P3 和 P4 对同一辆车分别输出：&lt;/p&gt;
&lt;p&gt;$$
A=(36,36,92,84),\quad s_A=0.90
$$&lt;/p&gt;
&lt;p&gt;$$
B=(38,35,94,85),\quad s_B=0.82
$$&lt;/p&gt;
&lt;p&gt;$A$ 面积为 $56\times48=2688$，$B$ 面积为 $56\times50=2800$。交集宽高为 $54$ 和 $48$，交集面积为 2592，所以交并比（Intersection over Union, IoU）为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{IoU}(A,B)=
\frac{2592}{2688+2800-2592}
=\frac{2592}{2896}\approx0.895
$$&lt;/p&gt;
&lt;p&gt;若 NMS 阈值是 0.6，先保留得分更高的 $A$，再因 $0.895&gt;0.6$ 删除 $B$。层级编号不应阻止这次比较：两个框虽来自不同 FPN 层，但类别相同、坐标系相同，就是跨层重复候选。&lt;/p&gt;
&lt;p&gt;反之，若两个高重叠框分别预测为“自行车”与“摩托车”，分类别 NMS 不会互相抑制。是否需要跨类别竞争属于另一个任务决策。&lt;/p&gt;
&lt;h2&gt;10 用 torchvision 0.28 当前官方 API 做跨层去重&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.pytorch.org/vision/stable/generated/torchvision.ops.batched_nms.html&quot;&gt;&lt;code&gt;torchvision.ops.batched_nms&lt;/code&gt;&lt;/a&gt; 接收 &lt;code&gt;boxes[N,4]&lt;/code&gt;、&lt;code&gt;scores[N]&lt;/code&gt; 和用于分组的 &lt;code&gt;idxs[N]&lt;/code&gt;。只有 &lt;code&gt;idxs&lt;/code&gt; 相同的框会互相抑制；因此这里传类别编号，不要传 FPN 层级编号。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torchvision.ops import batched_nms, box_iou

boxes = torch.tensor([
    [36.0, 36.0, 92.0, 84.0],  # from P3
    [38.0, 35.0, 94.0, 85.0],  # from P4, same object
    [38.0, 35.0, 94.0, 85.0],  # another class hypothesis
])                              # [N=3, 4]
scores = torch.tensor([0.90, 0.82, 0.78])  # [N]
labels = torch.tensor([1, 1, 2])           # [N], not pyramid levels

iou = box_iou(boxes[:1], boxes[1:2])       # [1, 1]
assert torch.allclose(iou, torch.tensor([[2592 / 2896]]))

keep = batched_nms(boxes, scores, labels, iou_threshold=0.6)
assert keep.tolist() == [0, 2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前官方文档规定框为 &lt;code&gt;(x1,y1,x2,y2)&lt;/code&gt;，宽高必须为正；返回的 &lt;code&gt;int64&lt;/code&gt; 索引按保留框得分降序排列。在得分完全相同时，CPU 与 GPU 对被保留框的选择不一定相同；评测代码不要依赖并列框的固定顺序。&lt;/p&gt;
&lt;h2&gt;11 完整 FCOS API 的输入与输出&lt;/h2&gt;
&lt;p&gt;torchvision 0.28 当前的 &lt;a href=&quot;https://docs.pytorch.org/vision/stable/models/generated/torchvision.models.detection.fcos_resnet50_fpn.html&quot;&gt;&lt;code&gt;fcos_resnet50_fpn&lt;/code&gt;&lt;/a&gt; 检测模块仍标记为 Beta，不保证向后兼容。无权重的最小调用如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torchvision.models.detection import fcos_resnet50_fpn

model = fcos_resnet50_fpn(
    weights=None,
    weights_backbone=None,
    num_classes=4,          # current API: includes background
    min_size=320,
    max_size=512,
    center_sampling_radius=1.5,
    score_thresh=0.25,
    nms_thresh=0.6,
    topk_candidates=300,
    detections_per_img=100,
)

images = [torch.rand(3, 300, 420)]  # list of [C,H,W], values in [0,1]
targets = [{
    &quot;boxes&quot;: torch.tensor([[36.0, 36.0, 92.0, 84.0]]),  # [M,4]
    &quot;labels&quot;: torch.tensor([1], dtype=torch.int64),       # [M]
}]

model.train()
losses = model(images, targets)     # dict[str, scalar Tensor]
loss = sum(losses.values())
loss.backward()

model.eval()
with torch.inference_mode():
    predictions = model(images)     # list[dict[str, Tensor]]

result = predictions[0]
assert result[&quot;boxes&quot;].ndim == 2 and result[&quot;boxes&quot;].shape[1] == 4
assert result[&quot;scores&quot;].shape == result[&quot;labels&quot;].shape
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该模型先在内部保持宽高比缩放图像，将短边尽量调到 &lt;code&gt;min_size&lt;/code&gt;，同时不让长边超过 &lt;code&gt;max_size&lt;/code&gt;。训练框会同步变换，推理结果再还原到每张输入图的原始尺寸。因此若在模型外自己写匹配或后处理，必须先说清代码位于内部变换之前还是之后。&lt;/p&gt;
&lt;h2&gt;12 一条可执行的调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;单独画坐标网格。&lt;/strong&gt; 在 resize/pad 后图像上画出 P3–P5 预测点，检查原点、$x/y$ 顺序、步幅和半像素偏移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对一个框画匹配掩码。&lt;/strong&gt; 用不同颜色显示 &lt;code&gt;inside_box&lt;/code&gt;、&lt;code&gt;inside_center&lt;/code&gt; 和 &lt;code&gt;inside_scale&lt;/code&gt;，不要只画最终正样本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐层统计正样本。&lt;/strong&gt; 检查每个真值框至少有一个匹配，也检查是否大量框卡在区间边界。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用真值距离做解码回环。&lt;/strong&gt; 将生成的 &lt;code&gt;(l,t,r,b)&lt;/code&gt; 立即解码，应在浮点误差内还原原框。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跟踪三个坐标系。&lt;/strong&gt; 同时打印原图尺寸、模型变换后尺寸、padding 后 batch 尺寸，不要用一个 &lt;code&gt;H,W&lt;/code&gt; 变量代指全部。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在 NMS 前保留来源层。&lt;/strong&gt; 分层统计阈值前数量、top-k 后数量和被 NMS 删除数量，才能看到跨层重复来自哪里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先过拟合一张图。&lt;/strong&gt; 只留一个边界不在阈值附近的目标；若无法学到近乎完美的框，先修坐标和匹配，不要先调学习率。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 最常见的“能训练，但契约错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;用特征图坐标减原图框。&lt;/strong&gt; 一边是 7，另一边是 92，距离值没有意义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据增强只改图像，没改框。&lt;/strong&gt; 水平翻转后训练仍可运行，却等于主动制造错标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用目标面积代替最大回归距离。&lt;/strong&gt; 两者相关但不等价，尤其对极端长宽比目标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;区间边界两边都用闭区间。&lt;/strong&gt; $m=64$ 可能同时分给 P3 和 P4；若改规则，必须明确哪一层包含边界。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;回归目标除以步幅，解码时没乘回。&lt;/strong&gt; 小框会缩在预测点周围，且误差随层级变化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;合并前没把各层框解码到同一图像坐标。&lt;/strong&gt; 此时 IoU 与 NMS 结果都不可解释。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;batched_nms&lt;/code&gt; 的 &lt;code&gt;idxs&lt;/code&gt; 传了金字塔层级。&lt;/strong&gt; 这会禁止跨层抑制，正好保留了要删除的重复框。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只凭 NMS 后框数判断阈值。&lt;/strong&gt; 框少可能是正确去重，也可能是密集物体被误删，必须与召回率和分类别案例一起看。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 它会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;尺度分布严重偏斜。&lt;/strong&gt; 遥感小目标可能几乎都落到最浅层，其他层缺乏监督；固定范围没有自动平衡能力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密集或遮挡场景。&lt;/strong&gt; 同类物体高度重叠时，硬 NMS 可能把真实的第二个实例删除。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;长条形目标。&lt;/strong&gt; $m=\max(l,t,r,b)$ 可能因一条长边把窄小目标分到过深层，损失横向细节。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;区间边界抖动。&lt;/strong&gt; 目标稍微 resize 就可能从 P3 跳到 P4，使监督对尺度增强不连续。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后处理成为延迟瓶颈。&lt;/strong&gt; 阈值太低或逐层 top-k 太大会让 NMS 候选数暴涨，尤其在类别很多时。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练与上线前处理不一致。&lt;/strong&gt; 上线端若用拉伸代替保宽高比 resize，尺度归属和框坐标会同时改变。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法                | 训练时如何定义候选         | 尺度责任                  | 需要的去重/筛选              |
| ------------------- | -------------------------- | ------------------------- | ---------------------------- |
| FCOS 固定范围       | 网格点 + 中心采样          | 按 &lt;code&gt;(l,t,r,b)&lt;/code&gt; 最大值区间 | 通常需要 NMS                 |
| Anchor-based 检测   | 预设尺寸/比例的锚框        | 通常按锚框与真值 IoU      | 通常需要 NMS                 |
| MultiScale RoIAlign | 先有 proposal/RoI          | 按 RoI 尺寸选池化层       | 候选生成与最终阶段可各有 NMS |
| ATSS                | 多层候选中选距中心近的样本 | 用候选 IoU 统计自适应阈值 | 通常需要 NMS                 |
| 学习式匹配          | 用分类与定位代价联合分配   | 由当前模型质量动态决定    | 取决于检测器设计             |&lt;/p&gt;
&lt;p&gt;固定尺度范围的优点是规则清晰、向量化简单、调试可视化。它的局限也来自同一点：责任边界是手工的，不知道当前样本在哪一层实际预测得更好。&lt;/p&gt;
&lt;h2&gt;16 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;FPN 只产生多尺度特征；检测器还必须定义网格点、真值框、尺度范围与冲突处理的训练契约。&lt;/li&gt;
&lt;li&gt;FCOS 用点到四边的 &lt;code&gt;(l,t,r,b)&lt;/code&gt; 回归框；框内、中心采样和尺度范围需同时满足。&lt;/li&gt;
&lt;li&gt;尺度分配只是训练责任，不能保证推理时只有一层响应；所有层候选必须先解码到同一坐标系。&lt;/li&gt;
&lt;li&gt;分类别 NMS 应比较同类别的跨层框；把层级号当成 &lt;code&gt;batched_nms&lt;/code&gt; 分组会让重复框永远无法相遇。&lt;/li&gt;
&lt;li&gt;最有价值的调试信号不是总损失，而是每层正样本、解码回环、NMS 前后候选数和原图/变换图/pad 图的坐标记录。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;真值框为 &lt;code&gt;(16,24,144,88)&lt;/code&gt;，分别对 P3 点 &lt;code&gt;(76,60)&lt;/code&gt; 和 P4 点 &lt;code&gt;(72,56)&lt;/code&gt; 计算 &lt;code&gt;(l,t,r,b)&lt;/code&gt; 与 $m$。按本文范围判断它们是否通过尺度匹配，再检查 $\rho=1.5$ 的中心采样。&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;assign_fcos_points&lt;/code&gt; 扩展为返回 &lt;code&gt;[P]&lt;/code&gt; 的真值索引和中心度。构造两个重叠框，验证同时合法时面积更小者获得该点。&lt;/li&gt;
&lt;li&gt;在三层候选中保留 &lt;code&gt;source_level&lt;/code&gt;，分别将类别号和层级号传给 &lt;code&gt;batched_nms&lt;/code&gt; 的 &lt;code&gt;idxs&lt;/code&gt;。统计两种结果的重复框数，并解释密集目标上为何不能只用“框更少”作为优劣标准。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2017/html/Lin_Feature_Pyramid_Networks_CVPR_2017_paper.html&quot;&gt;Lin et al. (2017), Feature Pyramid Networks for Object Detection&lt;/a&gt;：用自顶向下路径和横向连接建立语义一致的多尺度特征。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_iccv_2017/html/Lin_Focal_Loss_for_ICCV_2017_paper.html&quot;&gt;Lin et al. (2017), Focal Loss for Dense Object Detection&lt;/a&gt;：以 Focal Loss 处理密集检测中前景与背景的极度不平衡。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1904.01355&quot;&gt;Tian et al. (2019), FCOS: Fully Convolutional One-Stage Object Detection&lt;/a&gt;：把目标检测表达为逐点分类、四边距离回归和中心度预测。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_CVPR_2020/html/Zhang_Bridging_the_Gap_Between_Anchor-Based_and_Anchor-Free_Detection_via_Adaptive_Training_Sample_CVPR_2020_paper.html&quot;&gt;Zhang et al. (2020), Bridging the Gap Between Anchor-based and Anchor-free Detection via Adaptive Training Sample Selection&lt;/a&gt;：用候选样本 IoU 统计自适应选择正样本。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content/CVPR2021/html/Ge_OTA_Optimal_Transport_Assignment_for_Object_Detection_CVPR_2021_paper.html&quot;&gt;Ge et al. (2021), OTA: Optimal Transport Assignment for Object Detection&lt;/a&gt;：将标签分配表达为考虑全局代价的最优输运问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;18 下一篇预告&lt;/h2&gt;
&lt;p&gt;至此，图像主线已从卷积、下采样走到了多尺度特征与检测责任分配。下一篇将转向序列建模：固定窗口为什么记不住任意长的上下文，循环神经网络（Recurrent Neural Network, RNN）如何用隐状态在时间上传递信息。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>小物体为何在深层特征里消失？FPN 的自顶向下路径与横向连接</title><link>https://zwjcode.cn/blog/fpn-top-down-lateral-connection-semantic-localization</link><guid isPermaLink="true">https://zwjcode.cn/blog/fpn-top-down-lateral-connection-semantic-localization</guid><description>从深层语义与浅层定位的冲突出发，手算特征金字塔融合，追踪张量形状，并用 PyTorch 2.13 与 torchvision 0.26 实现可调试 FPN。</description><pubDate>Fri, 04 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇看到，下采样让卷积网络用更低成本获得更大感受野，却可能在深层特征中抹掉小物体和精确位置。只保留浅层也不行：浅层虽然网格细，却还在响应边缘、纹理等局部模式，未必知道这些模式属于“行人”还是“路灯”。&lt;/p&gt;
&lt;p&gt;本文只解决这个冲突：特征金字塔网络（Feature Pyramid Network, FPN）怎样用&lt;strong&gt;自顶向下路径&lt;/strong&gt;（Top-down Pathway）把深层语义送回高分辨率，用&lt;strong&gt;横向连接&lt;/strong&gt;（Lateral Connection）补回对应位置的浅层细节，并让每一层都得到通道数统一、可供下游预测的特征。&lt;/p&gt;
&lt;h2&gt;01 为什么直接选择某一层总会吃亏？&lt;/h2&gt;
&lt;p&gt;设骨干网络（Backbone）每经过一次步幅 2 下采样，就输出一个阶段特征。以输入 &lt;code&gt;[N,3,256,256]&lt;/code&gt; 为例：&lt;/p&gt;
&lt;p&gt;| 阶段 | 相对输入步幅 | 张量形状示例     | 优势                 | 缺口                     |
| ---- | ------------ | ---------------- | -------------------- | ------------------------ |
| C2   | 4            | &lt;code&gt;[N,256,64,64]&lt;/code&gt;  | 网格细、定位较精确   | 语义弱、背景响应多       |
| C3   | 8            | &lt;code&gt;[N,512,32,32]&lt;/code&gt;  | 开始组合局部部件     | 小目标仍容易混入背景     |
| C4   | 16           | &lt;code&gt;[N,1024,16,16]&lt;/code&gt; | 语义与位置较折中     | 极小目标只剩少数单元     |
| C5   | 32           | &lt;code&gt;[N,2048,8,8]&lt;/code&gt;   | 感受野大、类别语义强 | 空间粗，细小结构可能消失 |&lt;/p&gt;
&lt;p&gt;若一个行人在原图中高 12 像素，那么在步幅 4 的 C2 上约覆盖 3 个单元，在步幅 32 的 C5 上只覆盖 $12/32=0.375$ 个单元。C5 可能知道“这里像人”，却难以给出边界；C2 看见几条边缘，却未必知道它们应组成什么。&lt;/p&gt;
&lt;p&gt;传统图像金字塔（Image Pyramid）会把原图缩放成多种尺寸，再分别运行特征提取器：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;图像 256² ── Backbone ──► 强语义特征（高计算）
图像 128² ── Backbone ──► 强语义特征（再算一次）
图像  64² ── Backbone ──► 强语义特征（再算一次）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FPN 不重复跑整套骨干网络，而是复用骨干网络已经自然形成的多尺度层次。&lt;/p&gt;
&lt;h2&gt;02 FPN 的两条信息流怎样相遇？&lt;/h2&gt;
&lt;p&gt;FPN 有三类操作：&lt;code&gt;1×1&lt;/code&gt; 横向投影、上采样与逐元素相加、&lt;code&gt;3×3&lt;/code&gt; 输出卷积。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;自底向上的骨干网络                 自顶向下路径                 输出金字塔

C2 [N, 256,64,64] ──1×1──► L2 ──(+)◄── up(M3) ──► M2 ──3×3──► P2 [N,D,64,64]
        │                                  ▲
        ▼                                  │
C3 [N, 512,32,32] ──1×1──► L3 ──(+)◄── up(M4) ──► M3 ──3×3──► P3 [N,D,32,32]
        │                                  ▲
        ▼                                  │
C4 [N,1024,16,16] ──1×1──► L4 ──(+)◄── up(M5) ──► M4 ──3×3──► P4 [N,D,16,16]
        │                                  ▲
        ▼                                  │
C5 [N,2048, 8, 8] ──1×1───────────────►  M5 ──3×3──► P5 [N,D, 8, 8]

骨干：分辨率逐层降低、语义增强        FPN：深层语义逐层回流         D 通常固定为 256
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记 $C_l\in\mathbb{R}^{N\times C_l^{ch}\times H_l\times W_l}$ 为骨干第 $l$ 层，$D$ 为 FPN 的统一通道数。横向投影 $\phi_l$ 是 &lt;code&gt;1×1&lt;/code&gt; 卷积：&lt;/p&gt;
&lt;p&gt;$$
L_l=\phi_l(C_l),\qquad
L_l\in\mathbb{R}^{N\times D\times H_l\times W_l}
$$&lt;/p&gt;
&lt;p&gt;最深层先取 $M_5=L_5$；其余层递推为：&lt;/p&gt;
&lt;p&gt;$$
M_l=L_l+\operatorname{Up}(M_{l+1};H_l,W_l),
\qquad l\in{4,3,2}
$$&lt;/p&gt;
&lt;p&gt;最后用 &lt;code&gt;3×3&lt;/code&gt; 卷积 $\psi_l$ 得到输出：&lt;/p&gt;
&lt;p&gt;$$
P_l=\psi_l(M_l),\qquad
P_l\in\mathbb{R}^{N\times D\times H_l\times W_l}
$$&lt;/p&gt;
&lt;p&gt;横向路径把“这个位置原来有什么”交给融合点；自顶向下路径把“更大区域整体像什么”交给同一个融合点。二者必须空间尺寸和通道数都一致，才能逐元素相加。&lt;/p&gt;
&lt;h2&gt;03 &lt;code&gt;1×1&lt;/code&gt; 卷积为什么不是可有可无？&lt;/h2&gt;
&lt;p&gt;C2 到 C5 的通道数常为 256、512、1024、2048，不能直接相加。&lt;code&gt;1×1&lt;/code&gt; 卷积在每个空间位置独立完成通道线性变换：&lt;/p&gt;
&lt;p&gt;$$
L_{n,d,i,j}=b_d+\sum_{c=1}^{C_l^{ch}}W_{d,c}C_{n,c,i,j}
$$&lt;/p&gt;
&lt;p&gt;其中 $W\in\mathbb{R}^{D\times C_l^{ch}\times1\times1}$，输入位置 &lt;code&gt;[n,:,i,j]&lt;/code&gt; 的 $C_l^{ch}$ 维向量被投影为 $D$ 维，不改变 $H_l,W_l$。它有两个作用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;把不同阶段统一到同一通道维度 $D$，使加法合法；&lt;/li&gt;
&lt;li&gt;学习每个阶段哪些通道应该进入同一融合坐标系，而不是机械截断或补零。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;04 用五个数手算一次自顶向下融合&lt;/h2&gt;
&lt;p&gt;先把通道数简化为 $D=1$。设最深层投影后只有一个空间值：&lt;/p&gt;
&lt;p&gt;$$
M_5=[3]\in\mathbb{R}^{1\times1}
$$&lt;/p&gt;
&lt;p&gt;上一层的横向投影为：&lt;/p&gt;
&lt;p&gt;$$
L_4=
\begin{bmatrix}
1&amp;#x26;0\
2&amp;#x26;-1
\end{bmatrix}
\in\mathbb{R}^{2\times2}
$$&lt;/p&gt;
&lt;p&gt;最近邻上采样（Nearest-neighbor Upsampling）把 $M_5$ 的值复制到目标尺寸：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Up}(M_5;2,2)=
\begin{bmatrix}
3&amp;#x26;3\
3&amp;#x26;3
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;逐元素相加得到：&lt;/p&gt;
&lt;p&gt;$$
M_4=L_4+\operatorname{Up}(M_5)=
\begin{bmatrix}
4&amp;#x26;3\
5&amp;#x26;2
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;这里的 3 是深层上下文，告诉所有四个细位置“这一片区域整体具有某种语义”；$L_4$ 仍区分四个位置的局部证据。相加不会增加空间信息：若小物体在 C4 之前已经完全消失，FPN 不能从常量上采样中把它凭空重建出来。&lt;/p&gt;
&lt;p&gt;随后 &lt;code&gt;3×3&lt;/code&gt; 卷积在 $M_4$ 上融合邻域并生成 $P_4$。它既让每个输出位置看到相邻融合结果，也减弱最近邻复制形成的块状边界；它不是第二次跨尺度融合。&lt;/p&gt;
&lt;h2&gt;05 为什么用相加，而不是拼接？&lt;/h2&gt;
&lt;p&gt;设两个输入都是 &lt;code&gt;[N,D,H,W]&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;| 融合方式 | 融合后形状   | 后续参数/显存    | 信息路径                         |
| -------- | ------------ | ---------------- | -------------------------------- |
| 相加     | &lt;code&gt;[N,D,H,W]&lt;/code&gt;  | 较低             | 两路落在同一通道，梯度直接分流   |
| 拼接     | &lt;code&gt;[N,2D,H,W]&lt;/code&gt; | 后续卷积约更昂贵 | 保留两路身份，再由卷积决定如何混 |
| 加权和   | &lt;code&gt;[N,D,H,W]&lt;/code&gt;  | 多少量融合权重   | 可学习各尺度贡献，但需约束稳定性 |&lt;/p&gt;
&lt;p&gt;原始 FPN 选择相加，计算简单且所有金字塔输出可共享同样的下游头。它隐含一个假设：横向 &lt;code&gt;1×1&lt;/code&gt; 投影已把两路特征变换到可相加的表示空间。若任务需要保留来源身份，拼接或显式加权可能更合适，但那已经是另一种融合设计。&lt;/p&gt;
&lt;p&gt;反向传播时，若 $M_l=L_l+U_l$，则：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial\mathcal{L}}{\partial L_l}
=\frac{\partial\mathcal{L}}{\partial M_l},\qquad
\frac{\partial\mathcal{L}}{\partial U_l}
=\frac{\partial\mathcal{L}}{\partial M_l}
$$&lt;/p&gt;
&lt;p&gt;同一上游梯度分别进入横向路径和更深层路径；更深的 $M_{l+1}$ 还会接收多个较浅输出经上采样链汇集的梯度。&lt;/p&gt;
&lt;h2&gt;06 完整前向伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入：按分辨率从高到低排列的 C2...CL

for l = 2...L:
    lateral[l] = Conv1x1_l(C[l])       # [N,Cl,Hl,Wl] -&gt; [N,D,Hl,Wl]

inner[L] = lateral[L]
output[L] = Conv3x3_L(inner[L])

for l = L-1...2:                       # 从深到浅
    top_down = Resize(inner[l+1], size=(Hl,Wl))
    assert top_down.shape == lateral[l].shape
    inner[l] = lateral[l] + top_down
    output[l] = Conv3x3_l(inner[l])

返回：P2...PL，顺序仍为高分辨率到低分辨率
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意上采样的是尚未经过输出 &lt;code&gt;3×3&lt;/code&gt; 卷积的 &lt;code&gt;inner[l+1]&lt;/code&gt;，而不是随手取 &lt;code&gt;P[l+1]&lt;/code&gt;。两种写法形状相同，计算图和参数语义却不同。&lt;/p&gt;
&lt;h2&gt;07 不调用 FPN 封装，先写出 PyTorch 本体&lt;/h2&gt;
&lt;p&gt;下面实现接收一个有序字典，特征必须按“最高分辨率、最浅阶段”到“最低分辨率、最深阶段”排列：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import OrderedDict

import torch
import torch.nn.functional as F
from torch import nn

class TinyFPN(nn.Module):
    def __init__(self, in_channels: list[int], out_channels: int):
        super().__init__()
        assert len(in_channels) &gt;= 2 and out_channels &gt; 0
        self.in_channels = in_channels
        self.lateral = nn.ModuleList([
            nn.Conv2d(c, out_channels, kernel_size=1)
            for c in in_channels
        ])
        self.output = nn.ModuleList([
            nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
            for _ in in_channels
        ])

    def forward(
        self,
        features: &quot;OrderedDict[str, torch.Tensor]&quot;,
    ) -&gt; &quot;OrderedDict[str, torch.Tensor]&quot;:
        names = list(features.keys())
        xs = list(features.values())
        assert len(xs) == len(self.in_channels)

        for i, (x, expected_c) in enumerate(zip(xs, self.in_channels)):
            assert x.ndim == 4 and x.shape[1] == expected_c
            if i &gt; 0:
                assert x.shape[-2] &amp;#x3C;= xs[i - 1].shape[-2]
                assert x.shape[-1] &amp;#x3C;= xs[i - 1].shape[-1]

        lateral = [conv(x) for conv, x in zip(self.lateral, xs)]
        results: list[torch.Tensor | None] = [None] * len(xs)

        inner = lateral[-1]
        results[-1] = self.output[-1](inner)

        for i in range(len(xs) - 2, -1, -1):
            top_down = F.interpolate(
                inner,
                size=lateral[i].shape[-2:],
                mode=&quot;nearest&quot;,
            )
            assert top_down.shape == lateral[i].shape
            inner = lateral[i] + top_down
            results[i] = self.output[i](inner)

        assert all(result is not None for result in results)
        return OrderedDict(zip(names, results))  # type: ignore[arg-type]

features = OrderedDict([
    (&quot;c3&quot;, torch.randn(2, 64, 31, 33)),
    (&quot;c4&quot;, torch.randn(2, 128, 16, 17)),
    (&quot;c5&quot;, torch.randn(2, 256, 8, 9)),
])

fpn = TinyFPN([64, 128, 256], out_channels=96)
pyramid = fpn(features)

assert pyramid[&quot;c3&quot;].shape == (2, 96, 31, 33)
assert pyramid[&quot;c4&quot;].shape == (2, 96, 16, 17)
assert pyramid[&quot;c5&quot;].shape == (2, 96, 8, 9)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;示例故意使用奇数尺寸。若写成 &lt;code&gt;scale_factor=2&lt;/code&gt;，&lt;code&gt;[8,9]&lt;/code&gt; 只能先变成 &lt;code&gt;[16,18]&lt;/code&gt;，无法与 &lt;code&gt;[16,17]&lt;/code&gt; 相加；指定 &lt;code&gt;size=lateral.shape[-2:]&lt;/code&gt; 才以横向分支为对齐真值。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mode=&quot;nearest&quot;&lt;/code&gt; 不会创造插值混合值，且与经典 FPN 的简单设计一致。若改为双线性插值，要显式理解 &lt;code&gt;align_corners&lt;/code&gt; 的几何语义，并对边界框、掩码或关键点做像素对齐测试。PyTorch 2.13 当前官方 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html&quot;&gt;&lt;code&gt;interpolate&lt;/code&gt;&lt;/a&gt; 接受 4D NCHW 张量，并提醒部分 CUDA 上采样反向可能非确定。&lt;/p&gt;
&lt;h2&gt;08 训练时每层怎样收到监督？&lt;/h2&gt;
&lt;p&gt;FPN 只生成多尺度表示，不定义任务损失。检测、分割或关键点头会消费 $P_l$。下面用最小二分类稠密头展示梯度数据流；真实任务需要按目标尺寸分配层级并构造合法标签。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F
from torch import nn

head = nn.Conv2d(96, 1, kernel_size=1)  # 所有 P 层通道相同，可共享头
optimizer = torch.optim.AdamW(
    list(fpn.parameters()) + list(head.parameters()),
    lr=1e-3,
)

logits = {name: head(feature) for name, feature in pyramid.items()}
targets = {
    name: torch.zeros_like(level_logits)
    for name, level_logits in logits.items()
}
targets[&quot;c3&quot;][:, :, 10:13, 12:15] = 1.0  # 仅作形状完整的玩具标签

loss_by_level = {
    name: F.binary_cross_entropy_with_logits(logits[name], targets[name])
    for name in logits
}
loss = torch.stack(list(loss_by_level.values())).mean()

optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()

assert fpn.lateral[0].weight.grad is not None
assert fpn.lateral[-1].weight.grad is not None
assert torch.isfinite(loss)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;完整训练流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;image -&gt; backbone -&gt; {C2...C5} -&gt; FPN -&gt; {P2...P5} -&gt; shared/task heads
                                                          │
target -&gt; level assignment -&gt; per-level targets -&gt; losses ┘
                                                          │
loss.backward() -&gt; head -&gt; every P level -&gt; lateral + top-down -&gt; backbone
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若把每层损失简单求平均，大尺寸 P2 因位置更多并不一定贡献更多，但正负样本比例、有效掩码和任务损失尺度仍可能严重失衡。必须逐层记录正样本数、损失和梯度范数，而不是只看总损失。&lt;/p&gt;
&lt;h2&gt;09 用 torchvision 0.26 当前官方 API 落地&lt;/h2&gt;
&lt;p&gt;torchvision 当前官方 &lt;a href=&quot;https://docs.pytorch.org/vision/stable/generated/torchvision.ops.FeaturePyramidNetwork.html&quot;&gt;&lt;code&gt;FeaturePyramidNetwork&lt;/code&gt;&lt;/a&gt; 接收按深度递增排列的有序特征字典；也就是浅层高分辨率在前、深层低分辨率在后。&lt;code&gt;in_channels_list&lt;/code&gt; 必须与这个顺序逐一对应：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import OrderedDict

import torch
from torchvision.ops import FeaturePyramidNetwork

features = OrderedDict([
    (&quot;c3&quot;, torch.rand(2, 64, 64, 64)),
    (&quot;c4&quot;, torch.rand(2, 128, 32, 32)),
    (&quot;c5&quot;, torch.rand(2, 256, 16, 16)),
])

fpn = FeaturePyramidNetwork(
    in_channels_list=[64, 128, 256],
    out_channels=96,
    extra_blocks=None,
    norm_layer=None,
)
outputs = fpn(features)

assert list(outputs) == [&quot;c3&quot;, &quot;c4&quot;, &quot;c5&quot;]
assert [x.shape for x in outputs.values()] == [
    torch.Size([2, 96, 64, 64]),
    torch.Size([2, 96, 32, 32]),
    torch.Size([2, 96, 16, 16]),
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前接口返回的结果按最高分辨率优先排列；&lt;code&gt;extra_blocks&lt;/code&gt; 可在标准金字塔之外生成额外层，&lt;code&gt;norm_layer&lt;/code&gt; 可指定卷积后的归一化模块。两者都应由下游架构需求驱动，不要为了“更完整”盲目开启。&lt;/p&gt;
&lt;h2&gt;10 一条最短的 FPN 调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;打印每个 C 层。&lt;/strong&gt; 对每层记录名字、&lt;code&gt;[N,C,H,W]&lt;/code&gt;、相对输入步幅和有限值比例；先确认骨干本身没有错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查顺序与通道表。&lt;/strong&gt; &lt;code&gt;names&lt;/code&gt;、实际 &lt;code&gt;x.shape[1]&lt;/code&gt; 与 &lt;code&gt;in_channels_list&lt;/code&gt; 必须逐项一致，空间尺寸应随深度非增。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用目标尺寸上采样。&lt;/strong&gt; 对奇数高宽测试所有融合点，逐次断言 &lt;code&gt;top_down.shape == lateral.shape&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分开观察三种张量。&lt;/strong&gt; 保存 &lt;code&gt;lateral&lt;/code&gt;、&lt;code&gt;top_down&lt;/code&gt; 和相加后的 &lt;code&gt;inner&lt;/code&gt;；若一条支路幅度大几个数量级，加法会近似只剩强支路。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐层检查梯度。&lt;/strong&gt; 对每个输出单独反向，记录各横向卷积和骨干阶段的梯度范数，确认深浅路径都能收到信号。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;画跨尺度一致性。&lt;/strong&gt; 对同一物体在 P2–P5 上可视化激活热点；热点应随步幅缩放落在对应位置，而不是系统性偏一格。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做最小过拟合。&lt;/strong&gt; 用 2–4 张带单个目标的图训练到接近零损失；做不到时先查标签层级、坐标缩放与有效掩码。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一个实用的支路幅度诊断为：&lt;/p&gt;
&lt;p&gt;$$
R_l=\frac{\operatorname{RMS}(L_l)}
{\operatorname{RMS}(\operatorname{Up}(M_{l+1}))+\epsilon}
$$&lt;/p&gt;
&lt;p&gt;$R_l\gg1$ 表示横向分支主导，$R_l\ll1$ 表示自顶向下分支主导。理想区间依赖任务；这个比值用于发现异常，不是要求所有层都等于 1。&lt;/p&gt;
&lt;h2&gt;11 最常见的“形状对了，语义却错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;层级顺序反了。&lt;/strong&gt; 通道表若也被错误地一起反转，网络甚至可能正常运行，却把低分辨率当成浅层开始融合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;scale_factor=2&lt;/code&gt; 当成尺寸真值。&lt;/strong&gt; 奇数输入、不同补边和向下取整会累积一像素错位。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;融合前坐标系不一致。&lt;/strong&gt; 不同裁剪、补边或 &lt;code&gt;align_corners&lt;/code&gt; 设置会让两个相同形状的张量代表不同原图位置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;误以为上采样恢复细节。&lt;/strong&gt; 最近邻或双线性只能重排已有深层值；真正的高分辨率信息来自横向连接。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;直接相加原始 C 层。&lt;/strong&gt; 通道不匹配会报错；即使通道碰巧相同，也缺少可学习的语义对齐。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;输出层没有独立卷积。&lt;/strong&gt; 只返回 &lt;code&gt;inner&lt;/code&gt; 不是必然错误，但已经偏离经典 FPN；应记录变体，而不是仍声称实现完全相同。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每层都复制一个独立预测头。&lt;/strong&gt; 这增加参数且失去统一表示的好处；是否共享应由任务和消融实验决定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看总体检测指标。&lt;/strong&gt; FPN 的目标与尺度相关，应至少分小、中、大目标或按面积分桶报告性能。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 它会在哪里失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;细节在最浅输入层前已经丢失。&lt;/strong&gt; 若骨干的 stem 过早以大步幅下采样，C2 也没有小目标证据，横向连接无物可补。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨层语义鸿沟太大。&lt;/strong&gt; 一个 &lt;code&gt;1×1&lt;/code&gt; 线性投影未必足以让极浅纹理与极深语义直接相加；更强融合也会增加成本和优化难度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定位坐标被预处理破坏。&lt;/strong&gt; 图像 resize、裁剪、padding 与标签变换稍有不一致，多尺度热点都会系统偏移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高分辨率输出耗尽显存。&lt;/strong&gt; P2 的元素数通常是 P5 的 64 倍；提高 $D$ 或输入尺寸时，激活和预测头成本迅速增长。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定等权相加不适合所有样本。&lt;/strong&gt; 某些任务需要按尺度、位置或通道自适应选择来源；普通 FPN 没有显式学习融合权重。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分布外尺度没有被训练覆盖。&lt;/strong&gt; 有金字塔不等于天然尺度不变；极端大小目标仍可能落在所有训练层级之外。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 与相近结构的边界&lt;/h2&gt;
&lt;p&gt;| 结构       | 信息路径                   | 主要目标                 | 与 FPN 的关键区别                    |
| ---------- | -------------------------- | ------------------------ | ------------------------------------ |
| 图像金字塔 | 多尺度原图分别提特征       | 显式覆盖输入尺度         | 重复骨干计算                         |
| 残差连接   | 同一阶段的恒等/投影捷径    | 改善深层优化与信息路径   | 不专门构造多尺度输出                 |
| U-Net      | 编码器浅层拼接到解码器     | 恢复像素级密集输出       | 常用拼接和完整解码，仅末端输出也常见 |
| FPN        | 自顶向下 + 横向相加        | 各尺度都获得强语义特征   | 输出多层统一通道金字塔               |
| PANet      | 再增加自底向上的路径增强   | 缩短低层定位到高层的路径 | 在 FPN 后继续双向聚合                |
| BiFPN      | 重复双向路径并学习融合权重 | 高效、可缩放的多尺度融合 | 删除部分单输入节点并做加权融合       |&lt;/p&gt;
&lt;p&gt;FPN 也不是目标检测器本身。它不产生类别、边界框或掩码，不定义目标该去哪个层，也不执行非极大值抑制（Non-maximum Suppression, NMS）；它只是让下游头获得语义更一致的多尺度输入。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;骨干浅层保留高分辨率定位，深层拥有更强语义；只选一层会在小目标细节与上下文之间取舍。&lt;/li&gt;
&lt;li&gt;FPN 用 &lt;code&gt;1×1&lt;/code&gt; 横向投影统一通道，再把深层特征按目标尺寸上采样并逐元素相加，最后以 &lt;code&gt;3×3&lt;/code&gt; 卷积生成各层输出。&lt;/li&gt;
&lt;li&gt;$P_l$ 与 $C_l$ 的空间尺寸相同，但统一为 $D$ 个通道；上采样传递语义，横向连接提供真实高分辨率证据。&lt;/li&gt;
&lt;li&gt;工程中最危险的不是明显的通道报错，而是字典顺序、奇数尺寸和坐标约定造成的静默错位。&lt;/li&gt;
&lt;li&gt;FPN 不能恢复已经丢失的信息，也不自动解决层级分配、损失平衡、显存和分布外尺度问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;输入 &lt;code&gt;[N=2,3,255,257]&lt;/code&gt; 的骨干输出分别是 &lt;code&gt;C3=[2,64,32,33]&lt;/code&gt;、&lt;code&gt;C4=[2,128,16,17]&lt;/code&gt;、&lt;code&gt;C5=[2,256,8,9]&lt;/code&gt;。令 $D=96$，写出每个横向权重、&lt;code&gt;M&lt;/code&gt; 和 &lt;code&gt;P&lt;/code&gt; 的形状；解释为什么连续两次 &lt;code&gt;scale_factor=2&lt;/code&gt; 不能安全对齐 C3。&lt;/li&gt;
&lt;li&gt;把手算例中的 $M_5$ 改为 &lt;code&gt;[[-2]]&lt;/code&gt;，重算 $M_4$。再假设损失对 $M_4$ 每个元素的梯度都是 1，写出传给 $L_4$ 与上采样结果的梯度；最近邻复制反向后，$M_5$ 的梯度是多少？&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;TinyFPN&lt;/code&gt; 中分别单独对 &lt;code&gt;pyramid[&quot;c3&quot;].mean()&lt;/code&gt;、&lt;code&gt;pyramid[&quot;c5&quot;].mean()&lt;/code&gt; 反向，记录三组横向卷积的梯度范数。哪些参数在两次实验中收到梯度？这揭示了自顶向下路径怎样改变监督范围？&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2017/html/Lin_Feature_Pyramid_Networks_CVPR_2017_paper.html&quot;&gt;Lin et al. (2017), Feature Pyramid Networks for Object Detection&lt;/a&gt;：提出用自顶向下路径和横向连接构造语义一致的多尺度特征。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1505.04597&quot;&gt;Ronneberger, Fischer &amp;#x26; Brox (2015), U-Net&lt;/a&gt;：以编码器—解码器和高分辨率跳接推动生物医学图像分割。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2018/html/Liu_Path_Aggregation_Network_CVPR_2018_paper.html&quot;&gt;Liu et al. (2018), Path Aggregation Network&lt;/a&gt;：在 FPN 上增加自底向上路径，缩短定位信息传播距离。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_CVPR_2020/html/Tan_EfficientDet_Scalable_and_Efficient_Object_Detection_CVPR_2020_paper.html&quot;&gt;Tan, Pang &amp;#x26; Le (2020), EfficientDet&lt;/a&gt;：提出可重复、带学习权重的双向特征金字塔 BiFPN。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytorch.org/vision/stable/generated/torchvision.ops.FeaturePyramidNetwork.html&quot;&gt;torchvision 0.26: FeaturePyramidNetwork&lt;/a&gt;：当前输入顺序、通道参数、额外层与返回顺序的官方接口说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;FPN 已让每个尺度都有较强语义，但下游仍要决定“一个 12 像素目标该由 P2 还是 P3 负责”。下一篇将研究目标尺寸如何映射到金字塔层级，解释跨层重复预测为何产生，以及训练标签与推理结果怎样保持同一尺度契约。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>特征图减半为何会凭空改变图案？池化、步幅卷积与抗混叠下采样</title><link>https://zwjcode.cn/blog/downsampling-pooling-strided-convolution-antialiasing</link><guid isPermaLink="true">https://zwjcode.cn/blog/downsampling-pooling-strided-convolution-antialiasing</guid><description>从直接隔点取样的位移敏感出发，手算频率混叠，比较最大池化、平均池化与步幅卷积，并用 PyTorch 2.13 实现可调试的抗混叠下采样。</description><pubDate>Thu, 03 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把卷积拆成了局部连接、权重共享与感受野，但它的示例网络用了一个 &lt;code&gt;stride=2&lt;/code&gt; 的卷积，直接把 &lt;code&gt;32×32&lt;/code&gt; 特征图变成 &lt;code&gt;16×16&lt;/code&gt;。空间尺寸减半能大幅节省计算与显存，却也隐藏了一个问题：&lt;strong&gt;当新网格不足以表示原特征的快速变化时，被丢掉的高频会伪装成错误的低频图案。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;本文只讲透这一次“缩小”：池化（Pooling）、带步幅卷积（Strided Convolution）分别保留什么，为什么先低通再抽样能减少混叠（Aliasing），以及下采样如何生成卷积神经网络（Convolutional Neural Network, CNN）的多尺度特征层次。&lt;/p&gt;
&lt;h2&gt;01 为什么不一直保留高分辨率？&lt;/h2&gt;
&lt;p&gt;设一层卷积的输入为 $X\in\mathbb{R}^{N\times C_{in}\times H\times W}$，输出通道数为 $C_{out}$，核为 $K×K$。忽略常数和内核实现，乘加量约为：&lt;/p&gt;
&lt;p&gt;$$
N H W C_{in} C_{out} K^2
$$&lt;/p&gt;
&lt;p&gt;若 $H,W$ 同时减半，下一层的空间乘加量只剩约 $1/4$，激活张量也从 &lt;code&gt;[N,C,H,W]&lt;/code&gt; 变成 &lt;code&gt;[N,C,H/2,W/2]&lt;/code&gt;。这使网络能在后面增加通道数和层数，并让每个新位置对应原图上更大的区域。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;高分辨率阶段                   下采样                   低分辨率阶段
[N, 64, 56, 56]  ── 低通/聚合 ──► [N, 64, 28, 28] ── 卷积 ──► [N, 128, 28, 28]
  细边缘、小纹理              └─ 网格间距×2              大轮廓、组合模式
  位置多、显存大                                      位置少、单点视野大
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下采样不是免费压缩。新网格中每个位置要代表更大区域，究竟先聚合哪些信息，决定了小物体、细边缘和纹理是被保留、平滑，还是误读。&lt;/p&gt;
&lt;h2&gt;02 “隔一个取一个”为什么会创造假图案？&lt;/h2&gt;
&lt;p&gt;下采样因子 $s=2$ 表示每两个位置只保留一个：&lt;/p&gt;
&lt;p&gt;$$
y[m]=x[2m]
$$&lt;/p&gt;
&lt;p&gt;考察一条交替亮暗信号与它右移一格的版本：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原信号 x:       1 0 1 0 1 0 1 0
取样位置:       ↑   ↑   ↑   ↑       y      = [1, 1, 1, 1]

右移 1 格 x&apos;:  0 1 0 1 0 1 0 1
取样位置:       ↑   ↑   ↑   ↑       y&apos;     = [0, 0, 0, 0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入只移了一格，输出却从全 1 变成全 0。新网格无法表示“每格翻转一次”的变化，于是取样相位决定了看见哪个假的常量。这就是频率混叠：超过新奈奎斯特频率（Nyquist Frequency）的变化，在低分辨率网格上冒充成另一种较慢的模式。&lt;/p&gt;
&lt;p&gt;根据采样定理（Sampling Theorem），抽样前应先用低通滤波器（Low-pass Filter）压低新网格无法表示的快速变化：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;不抗混叠： x[n] ───────── 每 2 点取 1 点 ──► y[m]   高频折叠到低频

抗混叠：   x[n] ──► 低通 h[n] ──► 每 2 点取 1 点 ──► y[m]
                         先删除新网格表示不了的成分
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 用八个数手算“先平滑再抽样”&lt;/h2&gt;
&lt;p&gt;对上面的交替信号使用不重叠的两点平均：&lt;/p&gt;
&lt;p&gt;$$
y[m]=\frac{x[2m]+x[2m+1]}{2}
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x : [1,0] [1,0] [1,0] [1,0]  ── 每组求平均 ──► [0.5, 0.5, 0.5, 0.5]
x&apos;: [0,1] [0,1] [0,1] [0,1]  ── 每组求平均 ──► [0.5, 0.5, 0.5, 0.5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一格位移不再把整个输出翻转。但两点均值只是最简单的盒式滤波器（Box Filter），频率截止并不锐利，且不重叠窗口仍会受分组边界影响。&lt;/p&gt;
&lt;p&gt;一个常用的小型平滑核是二项式核（Binomial Kernel）：&lt;/p&gt;
&lt;p&gt;$$
h=\frac{1}{4}[1,2,1]
$$&lt;/p&gt;
&lt;p&gt;对二维特征图可用可分离外积 $H=h^\top h$：&lt;/p&gt;
&lt;p&gt;$$
H=\frac{1}{16}
\begin{bmatrix}
1&amp;#x26;2&amp;#x26;1\
2&amp;#x26;4&amp;#x26;2\
1&amp;#x26;2&amp;#x26;1
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;它先将中心与周围邻域做加权平均，再以步幅 2 保留结果。权重和为 1，因此常量区域的幅度不会平白放大或缩小。&lt;/p&gt;
&lt;h2&gt;04 最大池化与平均池化保留了什么？&lt;/h2&gt;
&lt;p&gt;对 $X\in\mathbb{R}^{N\times C\times H\times W}$，&lt;code&gt;2×2,stride=2&lt;/code&gt; 池化不混合通道，只在每个通道的局部窗口聚合：&lt;/p&gt;
&lt;p&gt;$$
Y^{max}&lt;em&gt;{n,c,i,j}=\max&lt;/em&gt;{0\le u,v&amp;#x3C;2}X_{n,c,2i+u,2j+v}
$$&lt;/p&gt;
&lt;p&gt;$$
Y^{avg}&lt;em&gt;{n,c,i,j}=\frac{1}{4}
\sum&lt;/em&gt;{u=0}^{1}\sum_{v=0}^{1}X_{n,c,2i+u,2j+v}
$$&lt;/p&gt;
&lt;p&gt;对窗口 $\begin{bmatrix}1&amp;#x26;2\7&amp;#x26;0\end{bmatrix}$，最大池化输出 7，平均池化输出 2.5。它们对同一邻域做了不同的摘要：&lt;/p&gt;
&lt;p&gt;| 方法       | 窗口内保留什么       | 参数   | 反传到哪里             | 主要风险                       |
| ---------- | -------------------- | ------ | ---------------------- | ------------------------------ |
| 最大池化   | 最强响应的幅度       | 0      | 通常只到 &lt;code&gt;argmax&lt;/code&gt; 位置 | 一个尖峰/噪声支配，仍可混叠    |
| 平均池化   | 局部直流与粗略能量   | 0      | 均匀分给窗口元素       | 边缘、小物体可被模糊           |
| 步幅卷积   | 从数据学得的局部组合 | 有     | 由学得的核分配         | 核不保证是低通，训练可偏爱高频 |
| 低通后抽样 | 可控带宽内的平滑信号 | 可为 0 | 按固定/可学核分配      | 滤波过强会删除任务需要的细纹理 |&lt;/p&gt;
&lt;p&gt;最大池化对窗口内重排不敏感，但它不是标准低通；对交替强弱信号，它仍可在不同取样相位上产生大幅差异。平均池化同时完成粗糙低通和降采样，但其固定权重不会根据任务选择边缘方向。&lt;/p&gt;
&lt;h2&gt;05 带步幅卷积为何不自动抗混叠？&lt;/h2&gt;
&lt;p&gt;带步幅卷积在一步中同时完成局部线性组合和抽样：&lt;/p&gt;
&lt;p&gt;$$
Y_{n,o,i,j}=b_o+
\sum_c\sum_u\sum_v
K_{o,c,u,v}X_{n,c,,si+u-p,,sj+v-p}
$$&lt;/p&gt;
&lt;p&gt;若 $K$ 学成平滑核，它可在抽样前压低高频；但训练目标通常只要求降低任务损失，并未约束卷积核的频率响应。一只边缘检测核恰好会放大高频，然后紧接着以步幅 2 抽样，依然可产生混叠。&lt;/p&gt;
&lt;p&gt;更清楚的设计是拆开两种职责：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;可学特征变换                 明确带宽限制             抽样
Conv2d(stride=1) ──► 非线性 ──► low-pass filter ──► take every 2nd point
[N,Cin,H,W]          [N,Cout,H,W]         [N,Cout,H,W]       [N,Cout,⌈H/2⌉,⌈W/2⌉]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不表示所有 &lt;code&gt;stride=2&lt;/code&gt; 卷积都必须替换。它表示当模型对一像素位移过度敏感、输入含周期纹理，或训练与部署缩放链不一致时，“变换”和“抽样”应分开检查。&lt;/p&gt;
&lt;h2&gt;06 输出尺寸与奇数边长如何处理？&lt;/h2&gt;
&lt;p&gt;对核 $k$、步幅 $s$、补边 $p$、空洞率 $d$，最大池化和卷积的高度输出公式是：&lt;/p&gt;
&lt;p&gt;$$
H_{out}=\left\lfloor
\frac{H_{in}+2p-d(k-1)-1}{s}+1
\right\rfloor
$$&lt;/p&gt;
&lt;p&gt;平均池化没有 &lt;code&gt;dilation&lt;/code&gt; 参数，令 $d=1$ 即可。对 $H_{in}=7,k=2,s=2,p=0$，默认向下取整得 $H_{out}=3$，最后一行没有进入任何完整窗口。&lt;/p&gt;
&lt;p&gt;PyTorch 2.13 当前官方 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.modules.pooling.MaxPool2d.html&quot;&gt;&lt;code&gt;MaxPool2d&lt;/code&gt;&lt;/a&gt; 和 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.AvgPool2d.html&quot;&gt;&lt;code&gt;AvgPool2d&lt;/code&gt;&lt;/a&gt; 都提供 &lt;code&gt;ceil_mode=True&lt;/code&gt;，允许从左侧补边或有效输入内开始的窗口越过右边界。这能覆盖奇数尺寸的边缘，但也会改变形状规则；与跳连接或分割标签对齐时，必须显式断言。&lt;/p&gt;
&lt;h2&gt;07 不依赖深度学习框架，先写出一维本体&lt;/h2&gt;
&lt;p&gt;下面代码只处理 &lt;code&gt;[L]&lt;/code&gt; 信号，用对称补边、二项式低通和步幅 2 抽样暴露数据流：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def decimate(x, factor=2):
    assert x.ndim == 1 and factor &gt;= 1
    return x[::factor]

def blur_then_decimate(x, factor=2):
    assert x.ndim == 1 and factor == 2
    kernel = np.array([1.0, 2.0, 1.0]) / 4.0
    padded = np.pad(x, (1, 1), mode=&quot;reflect&quot;)
    blurred = np.convolve(padded, kernel, mode=&quot;valid&quot;)
    return blurred[::factor]

x = np.array([1., 0., 1., 0., 1., 0., 1., 0.])
x_shift = np.array([0., 1., 0., 1., 0., 1., 0., 1.])

np.testing.assert_array_equal(decimate(x), [1., 1., 1., 1.])
np.testing.assert_array_equal(decimate(x_shift), [0., 0., 0., 0.])

y = blur_then_decimate(x)
y_shift = blur_then_decimate(x_shift)
assert y.shape == y_shift.shape == (4,)
assert np.max(np.abs(y - y_shift)) &amp;#x3C; 1.0  # 不再出现 1 的整幅翻转
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后的不等式只是此极小例的回归检查，不是“抗混叠必然位移不变”的定理。补边、非线性、有限核长和后续网络都会影响结果。&lt;/p&gt;
&lt;h2&gt;08 用 PyTorch 2.13 拆开可学变换与降采样&lt;/h2&gt;
&lt;p&gt;先实现一个无可学参数的逐通道模糊下采样层。输入 &lt;code&gt;[N,C,H,W]&lt;/code&gt; 使用相同 &lt;code&gt;3×3&lt;/code&gt; 核独立处理每个通道，再以 &lt;code&gt;stride=2&lt;/code&gt; 抽样：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F
from torch import nn

class BlurDownsample2d(nn.Module):
    def __init__(self, channels: int):
        super().__init__()
        one_d = torch.tensor([1.0, 2.0, 1.0])
        kernel_2d = one_d[:, None] * one_d[None, :]
        kernel_2d = kernel_2d / kernel_2d.sum()
        # [C,1,3,3]；groups=C 表示各通道独立滤波
        weight = kernel_2d.expand(channels, 1, 3, 3).contiguous()
        self.channels = channels
        self.register_buffer(&quot;weight&quot;, weight)

    def forward(self, x: torch.Tensor) -&gt; torch.Tensor:
        assert x.ndim == 4 and x.shape[1] == self.channels
        return F.conv2d(
            x,
            self.weight,
            stride=2,
            padding=1,
            groups=self.channels,
        )

class AntiAliasedConvBlock(nn.Module):
    def __init__(self, in_channels: int, out_channels: int):
        super().__init__()
        self.transform = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, 3, stride=1, padding=1),
            nn.ReLU(),
        )
        self.downsample = BlurDownsample2d(out_channels)

    def forward(self, x: torch.Tensor) -&gt; torch.Tensor:
        return self.downsample(self.transform(x))

x = torch.randn(4, 3, 32, 32, requires_grad=True)
block = AntiAliasedConvBlock(3, 16)
y = block(x)

assert y.shape == (4, 16, 16, 16)
assert dict(block.downsample.named_parameters()) == {}
assert &quot;weight&quot; in dict(block.downsample.named_buffers())

y.square().mean().backward()
assert block.transform[0].weight.grad is not None
assert torch.isfinite(x.grad).all()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;register_buffer&lt;/code&gt; 让固定核随模型进入 &lt;code&gt;state_dict&lt;/code&gt;、设备和数据类型，却不会被优化器当成参数。&lt;code&gt;groups=C&lt;/code&gt; 是逐通道卷积，它平滑空间维度，不混合通道。这里的零补边是明确取舍；若边界语义重要，可先用 &lt;code&gt;F.pad(..., mode=&quot;reflect&quot;)&lt;/code&gt; 再做 &lt;code&gt;padding=0&lt;/code&gt; 的卷积，并重新验证尺寸。&lt;/p&gt;
&lt;p&gt;训练时，数据流是 &lt;code&gt;images → transform → blur/downsample → 后续网络 → logits → loss&lt;/code&gt;；&lt;code&gt;loss.backward()&lt;/code&gt; 会穿过固定滤波计算输入和前面可学卷积的梯度，但优化器不会更新 buffer。推理时使用 &lt;code&gt;model.eval()&lt;/code&gt; 与 &lt;code&gt;torch.inference_mode()&lt;/code&gt;；该滤波层本身没有训练/推理两套行为，但同一模型内的批归一化和随机失活有。&lt;/p&gt;
&lt;h2&gt;09 数据缩放时如何使用官方抗混叠 API？&lt;/h2&gt;
&lt;p&gt;对输入图像或连续特征图做明确的尺寸变换时，PyTorch 当前官方 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html&quot;&gt;&lt;code&gt;torch.nn.functional.interpolate&lt;/code&gt;&lt;/a&gt; 提供 &lt;code&gt;antialias=True&lt;/code&gt;。对以“批量—通道—高度—宽度”（Batch-Channel-Height-Width, NCHW）排列的四维张量，可写成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn.functional as F

images = torch.rand(8, 3, 224, 224)
small = F.interpolate(
    images,
    size=(112, 112),
    mode=&quot;bilinear&quot;,
    align_corners=False,
    antialias=True,
)

assert small.shape == (8, 3, 112, 112)
assert small.dtype == images.dtype
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前文档说明 &lt;code&gt;antialias&lt;/code&gt; 支持二线性（Bilinear）、双三次（Bicubic）和 Lanczos 模式；对下采样，&lt;code&gt;align_corners=False&lt;/code&gt; 与抗混叠的组合还有明确的图像库对齐语义。不要在没有检查模式支持时只是“加上这个参数”。&lt;/p&gt;
&lt;p&gt;这个 API 适合尺寸重采样，不会自动学习任务特征。在 CNN 块内，你仍需决定它放在可学卷积之前还是之后，并保证训练、验证和服务端使用同一条缩放链。&lt;/p&gt;
&lt;h2&gt;10 下采样如何生成多尺度特征层次？&lt;/h2&gt;
&lt;p&gt;记第 $l$ 层感受野为 $r_l$，相邻特征位置在原图上的间距为 $j_l$。沿用上一篇的递推：&lt;/p&gt;
&lt;p&gt;$$
r_l=r_{l-1}+(k_l-1)d_lj_{l-1},\qquad
j_l=j_{l-1}s_l
$$&lt;/p&gt;
&lt;p&gt;一次 &lt;code&gt;2×2,stride=2&lt;/code&gt; 池化使 $j$ 翻倍。后续同样的 &lt;code&gt;3×3&lt;/code&gt; 卷积，每次会在原图上扩张更多像素：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;层                 张量形状              r（原图感受野）    j（原图间距）
输入               [N,  3, 32,32]          1                 1
Conv 3×3,s=1       [N, 32, 32,32]          3                 1
Pool 2×2,s=2       [N, 32, 16,16]          4                 2
Conv 3×3,s=1       [N, 64, 16,16]          8                 2
Pool 2×2,s=2       [N, 64,  8, 8]         10                 4
Conv 3×3,s=1       [N,128,  8, 8]         18                 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;高分辨率层保留精确位置和小边缘，低分辨率层用更少位置表示更大区域和更抽象模式。这就是多尺度特征层次（Multi-scale Feature Hierarchy）。但一旦只保留最深的 &lt;code&gt;8×8&lt;/code&gt; 层，小物体可能在早期下采样中消失；后续的上采样无法凭空恢复它。&lt;/p&gt;
&lt;h2&gt;11 一条可执行的位移敏感调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先定位所有降采样点。&lt;/strong&gt; 搜索 &lt;code&gt;stride&gt;1&lt;/code&gt;、池化、&lt;code&gt;interpolate/resize&lt;/code&gt;和数据增强，画出从原图到 logits 的尺寸链。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用可视化的高频输入。&lt;/strong&gt; 棋盘格、一像素细线和斜条纹能比自然图像更快暴露混叠；逐层保存特征图。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;做一像素压力测试。&lt;/strong&gt; 将输入上下左右各移一格，忽略补边影响的边缘，比较中间特征和最终概率，不要只比较 &lt;code&gt;argmax&lt;/code&gt; 类别。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只替换一个下采样点。&lt;/strong&gt; 在相同权重、输入和 &lt;code&gt;eval()&lt;/code&gt; 模式下，比较原池化/步幅卷积与低通后抽样，找到第一个过度敏感层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同时看任务指标与稳定性。&lt;/strong&gt; 统计原图与小位移版的 logits 差、一致预测率和分组指标；抗混叠不应以丢掉关键小结构为代价。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;确保对比可复现。&lt;/strong&gt; 固定随机种子、输入批次和软件版本。官方文档提醒，统一计算设备架构（Compute Unified Device Architecture, CUDA）上的某些卷积与插值反向可为非确定，严格实验要单独配置确定性并记录性能代价。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一个简单的最终表示稳定性指标可写成：&lt;/p&gt;
&lt;p&gt;$$
S=\frac{1}{N}\sum_{i=1}^{N}
\frac{\lVert z_i-z_i^{shift}\rVert_2}
{\lVert z_i\rVert_2+\epsilon}
$$&lt;/p&gt;
&lt;p&gt;$z_i\in\mathbb{R}^{K}$ 是第 $i$ 个样本的 $K$ 维未归一化类别分数（logits），$z_i^{shift}$ 是位移版输出，$\epsilon$ 防止分母为 0。$S$ 越小只表示对该扰动更稳定，不能替代准确率、校准度和任务安全性。&lt;/p&gt;
&lt;h2&gt;12 最常见的“能跑，但缩错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;把步幅当成纯计算优化。&lt;/strong&gt; &lt;code&gt;stride=2&lt;/code&gt; 改变了采样网格与可表示频率，不是与 &lt;code&gt;stride=1&lt;/code&gt; 近似等价的快速实现。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为最大池化天然平移不变。&lt;/strong&gt; 窗口内的小移动可保持最大值，但峰值跨过窗口边界就会跳到另一个输出单元。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略奇数尺寸。&lt;/strong&gt; 向下取整、&lt;code&gt;ceil_mode&lt;/code&gt;、不对称补边会影响哪一侧被丢掉，并在编码器—解码器或残差分支相加时引发一格错位。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练和推理的 resize 不一致。&lt;/strong&gt; 不同图像库、插值模式、&lt;code&gt;align_corners&lt;/code&gt; 和抗混叠选项会产生系统性输入偏移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用循环移位冒充真实平移。&lt;/strong&gt; &lt;code&gt;torch.roll&lt;/code&gt; 会把右边像素绕回左边；除非数据本来是周期的，否则应用明确补边和裁剪实现位移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只检查特征图是否“更平滑”。&lt;/strong&gt; 过强低通可让图看起来干净，却同时删掉微小病灶、细裂纹或文字笔画。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 它们分别会在哪里失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;小目标与密集预测。&lt;/strong&gt; 目标只有几个像素时，早期降采样可将其整体平均掉；检测、分割和关键点需保留高分辨率分支或融合浅层特征。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;纹理本身就是信号。&lt;/strong&gt; 材料缺陷、显微图像和遥感中的细颗粒可能是分类依据；抗混叠带宽必须用验证集和频段压力测试选择。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单个异常峰值。&lt;/strong&gt; 最大池化会保留热像素、脉冲噪声或污点，并将大部分梯度集中到它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;边界包含语义。&lt;/strong&gt; 医学扫描视野、地图瓦片或周期经纬网格需要不同补边；固定零补边可创造虚假边缘。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只追求位移稳定。&lt;/strong&gt; 姿态估计和像素定位要求的是平移等变（Translation Equivariance），不是所有中间响应不变；终端任务决定该对齐还是该聚合。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法                               | 空间尺寸 | 是否可学 | 抗混叠保证           | 典型用途                          |
| ---------------------------------- | -------- | -------- | -------------------- | --------------------------------- |
| &lt;code&gt;MaxPool2d&lt;/code&gt;                        | 通常减小 | 否       | 无                   | 保留最强局部响应                  |
| &lt;code&gt;AvgPool2d&lt;/code&gt;                        | 通常减小 | 否       | 有限的盒式平滑       | 局部均值、全局平均池化            |
| &lt;code&gt;Conv2d(stride=2)&lt;/code&gt;                 | 减小     | 是       | 无显式保证           | 联合学习特征变换和降采样          |
| 固定低通 + 抽样                    | 减小     | 低通核否 | 由滤波器带宽决定     | 降低位移敏感和高频折叠            |
| &lt;code&gt;interpolate(..., antialias=True)&lt;/code&gt; | 可大可小 | 否       | 降采样时显式抗混叠   | 图像/特征尺寸重采样               |
| 空洞卷积                           | 可保持   | 是       | 不是降采样抗混叠方法 | 不减小特征图而扩大理论感受野      |
| 自适应池化                         | 指定输出 | 否       | 不自动保证           | 将不同输入尺寸归约到固定网格/向量 |&lt;/p&gt;
&lt;p&gt;自适应池化的“自适应”指它根据输入尺寸计算窗口与步幅，不是学习参数。空洞卷积虽然也“隔点读取”卷积核，但它通常保留输出网格，与把整张特征图降采样不是一件事。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;下采样将 $H×W$ 减小一半可使后续空间计算约降为 $1/4$，并快速扩大感受野，但它必然改变可表示的空间频率。&lt;/li&gt;
&lt;li&gt;直接隔点取样会让新网格表示不了的高频折叠成假低频；先低通再抽样能减少这种混叠，但不能使信息损失可逆。&lt;/li&gt;
&lt;li&gt;最大池化保留最强局部响应，平均池化做固定局部平滑，步幅卷积学习局部组合；“可学”不等于“必然抗混叠”。&lt;/li&gt;
&lt;li&gt;下采样逐步把精确空间位置换成更大视野与更低成本，从而构成多尺度特征层次；小目标是否在换取中消失必须实测。&lt;/li&gt;
&lt;li&gt;调试时应用棋盘格、一像素平移、逐层形状和 logits 差找到第一个敏感降采样点，并与真实任务指标一起评估。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 &lt;code&gt;[N=2,C=8,H=15,W=17]&lt;/code&gt; 输入使用 &lt;code&gt;MaxPool2d(kernel_size=3,stride=2,padding=1)&lt;/code&gt;。分别在 &lt;code&gt;ceil_mode=False/True&lt;/code&gt; 下手算输出形状，画出最后一个窗口覆盖了哪些有效位置，再用 PyTorch 断言。&lt;/li&gt;
&lt;li&gt;生成 &lt;code&gt;32×32&lt;/code&gt; 棋盘格及其右移一像素版本，比较直接隔点取样、&lt;code&gt;AvgPool2d(2)&lt;/code&gt;、&lt;code&gt;MaxPool2d(2)&lt;/code&gt; 和 &lt;code&gt;BlurDownsample2d&lt;/code&gt;。记录四种输出的均方差，解释为什么“输出更接近”不能单独证明任务更好。&lt;/li&gt;
&lt;li&gt;在一个小型 CNN 中只替换第一个 &lt;code&gt;stride=2&lt;/code&gt; 层，对验证集的原图、四个一像素平移版和带细纹理子集分别统计准确率与稳定性 $S$。哪些结果能支持使用抗混叠，哪些表明低通过强？&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;http://yann.lecun.com/exdb/publis/pdf/lecun-01a.pdf&quot;&gt;LeCun et al. (1998), Gradient-Based Learning Applied to Document Recognition&lt;/a&gt;：LeNet-5 以可训系数的子采样层建立早期 CNN 多尺度层次。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/978-3-642-15825-4_10&quot;&gt;Scherer, Müller &amp;#x26; Behnke (2010), Evaluation of Pooling Operations in Convolutional Architectures for Object Recognition&lt;/a&gt;：系统比较多种池化操作在物体识别中的表现。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1412.6806&quot;&gt;Springenberg et al. (2015), Striving for Simplicity: The All Convolutional Net&lt;/a&gt;：展示用带步幅卷积替代显式池化的全卷积架构。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jmlr.org/papers/v20/19-519.html&quot;&gt;Azulay &amp;#x26; Weiss (2019), Why Do Deep Convolutional Networks Generalize So Poorly to Small Image Transformations?&lt;/a&gt;：分析现代 CNN 对小平移和缩放的意外敏感性。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v97/zhang19a.html&quot;&gt;Zhang (2019), Making Convolutional Networks Shift-Invariant Again&lt;/a&gt;：将低通滤波显式放回降采样路径，检验抗混叠与位移稳定性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;下采样让深层特征拥有大感受野，却可把小物体和精确位置丢在浅层。下一篇将以特征金字塔网络（Feature Pyramid Network, FPN）为主线，追踪自顶向下路径与横向连接如何把深层语义送回高分辨率特征。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一张图像为何不该先拉平？卷积的局部连接、权重共享与感受野</title><link>https://zwjcode.cn/blog/cnn-local-connectivity-weight-sharing-receptive-field</link><guid isPermaLink="true">https://zwjcode.cn/blog/cnn-local-connectivity-weight-sharing-receptive-field</guid><description>从全连接层丢失空间结构出发，手算二维卷积，追踪 NCHW 张量、输出尺寸与感受野，并用 NumPy 和 PyTorch 2.13 实现可调试 CNN。</description><pubDate>Thu, 03 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用残差连接给深层网络铺了一条更直接的信息和梯度路径，其中的图像残差块却还把 &lt;code&gt;Conv2d&lt;/code&gt; 当成了黑盒。本文只拆开这个核心算子：它怎样用&lt;strong&gt;局部连接&lt;/strong&gt;接住二维邻域，用&lt;strong&gt;权重共享&lt;/strong&gt;在整张图上寻找同一模式，以及多层堆叠后&lt;strong&gt;感受野&lt;/strong&gt;如何扩大。&lt;/p&gt;
&lt;h2&gt;01 先拉平图像，究竟丢了什么？&lt;/h2&gt;
&lt;p&gt;假设一张红绿蓝图像（Red-Green-Blue, RGB）形状为 &lt;code&gt;[3,224,224]&lt;/code&gt;。把它拉平后接 64 个神经元，单层参数量就是：&lt;/p&gt;
&lt;p&gt;$$
64\times(3\times224\times224+1)=9,633,856
$$&lt;/p&gt;
&lt;p&gt;更根本的问题是，拉平后的向量没有显式告诉模型“哪些像素互为邻居”。图案向右移一格，原来的每条连接都要换到另一组权重。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;3×3&lt;/code&gt; 卷积则让每个输出只看一个局部窗口，并让同一组权重滑遍所有位置。若输入 3 通道、输出 64 通道，带偏置的参数量只有：&lt;/p&gt;
&lt;p&gt;$$
64\times(3\times3\times3+1)=1,792
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;全连接：整张图 [3,224,224] ─ flatten ─► [150528] ─每个位置独立权重─► [64]
                                               └─ 二维位置被隐藏

卷积：  局部 3×3×3 窗口 ─相同卷积核在 H、W 上滑动─► [64,H_out,W_out]
                                    └─ 空间网格被保留
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个假设是卷积神经网络（Convolutional Neural Network, CNN）的归纳偏置（Inductive Bias）：近处像素更可能联合成有用模式，而同一模式可能出现在不同位置。它们适合图像，却不是所有数据的普遍真理。&lt;/p&gt;
&lt;h2&gt;02 一个输出像素究竟怎样算？&lt;/h2&gt;
&lt;p&gt;先固定 PyTorch 的“批量—通道—高度—宽度”（Batch-Channel-Height-Width, NCHW）布局：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输入 $X\in\mathbb{R}^{N\times C_{in}\times H_{in}\times W_{in}}$；&lt;/li&gt;
&lt;li&gt;权重 $K\in\mathbb{R}^{C_{out}\times C_{in}\times K_h\times K_w}$；&lt;/li&gt;
&lt;li&gt;偏置 $b\in\mathbb{R}^{C_{out}}$；&lt;/li&gt;
&lt;li&gt;输出 $Y\in\mathbb{R}^{N\times C_{out}\times H_{out}\times W_{out}}$。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$N$ 是批量大小，$C_{in},C_{out}$ 是输入/输出通道数，$H,W$ 是空间高度/宽度，$K_h,K_w$ 是核的高度/宽度。&lt;/p&gt;
&lt;p&gt;对输出位置 $(n,o,i,j)$，二维卷积层计算：&lt;/p&gt;
&lt;p&gt;$$
Y_{n,o,i,j}=b_o+
\sum_{c=0}^{C_{in}-1}\sum_{u=0}^{K_h-1}\sum_{v=0}^{K_w-1}
K_{o,c,u,v},
X_{n,c,,i s_h+u d_h-p_h,,j s_w+v d_w-p_w}
$$&lt;/p&gt;
&lt;p&gt;$s_h,s_w$ 是步幅（Stride），$d_h,d_w$ 是空洞率（Dilation），$p_h,p_w$ 是补零（Padding）宽度；落在边界外的 $X$ 按零处理。一个输出通道 $o$ 使用一只覆盖所有 $C_{in}$ 输入通道的卷积核，而不是“每个 RGB 通道各算各的”。&lt;/p&gt;
&lt;h2&gt;03 用九个数手算一次滑窗&lt;/h2&gt;
&lt;p&gt;令单样本、单通道输入和一只 &lt;code&gt;2×2&lt;/code&gt; 核为：&lt;/p&gt;
&lt;p&gt;$$
X=\begin{bmatrix}1&amp;#x26;2&amp;#x26;0\0&amp;#x26;1&amp;#x26;3\2&amp;#x26;2&amp;#x26;1\end{bmatrix},\qquad
K=\begin{bmatrix}1&amp;#x26;0\-1&amp;#x26;1\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;设步幅为 1、不补零、偏置为 0。同一只 $K$ 在四个位置复用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;窗口 (0,0)        窗口 (0,1)        窗口 (1,0)        窗口 (1,1)
[1 2] ⊙ [ 1 0]      [2 0] ⊙ [ 1 0]      [0 1] ⊙ [ 1 0]      [1 3] ⊙ [ 1 0]
[0 1]   [-1 1]      [1 3]   [-1 1]      [2 2]   [-1 1]      [2 1]   [-1 1]
  = 2                  = 4                  = 0                  = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此：&lt;/p&gt;
&lt;p&gt;$$
Y=\begin{bmatrix}2&amp;#x26;4\0&amp;#x26;0\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;“局部连接”表示 $Y_{0,0}$ 只依赖 $X$ 左上 &lt;code&gt;2×2&lt;/code&gt; 邻域；“权重共享”表示四个窗口都乘同一只 $K$。如果为每个窗口分别学一只核，它仍是局部连接层（Locally Connected Layer），却已不是标准卷积。&lt;/p&gt;
&lt;h2&gt;04 权重共享为什么带来平移等变？&lt;/h2&gt;
&lt;p&gt;记 $T_\Delta X$ 为把输入平移 $\Delta$ 个格子。忽略边界与下采样时，卷积满足：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Conv}(T_\Delta X)=T_\Delta\operatorname{Conv}(X)
$$&lt;/p&gt;
&lt;p&gt;这叫平移等变性（Translation Equivariance）：输入中的边缘向右移，特征图中的响应也向右移。它不等于平移不变性（Translation Invariance）；后者要求图案移动后最终输出不变，通常还需要全局池化、数据增强或其他聚合。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入左移/右移 Δ             同一只核 K                特征响应同向移动 Δ
□ □ ■ ■ □ □   ─────────────►   [K 在每个位置复用]   ─────────────►   · · ↑ ↑ · ·

边界补零改变了邻域；stride &gt; 1 丢弃了中间位置。
所以真实 CNN 只是近似、有边界的平移等变，不是任意平移下的完美定理。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 输出尺寸为什么总容易算错？&lt;/h2&gt;
&lt;p&gt;有效卷积核高度为 $d_h(K_h-1)+1$。能放下多少个起点，决定输出高度：&lt;/p&gt;
&lt;p&gt;$$
H_{out}=\left\lfloor
\frac{H_{in}+2p_h-d_h(K_h-1)-1}{s_h}+1
\right\rfloor
$$&lt;/p&gt;
&lt;p&gt;$W_{out}$ 使用完全对称的公式。例如 $H_{in}=32,K_h=3,s_h=2,p_h=1,d_h=1$：&lt;/p&gt;
&lt;p&gt;$$
H_{out}=\left\lfloor\frac{32+2-2-1}{2}+1\right\rfloor=16
$$&lt;/p&gt;
&lt;p&gt;| 参数           | 改变了什么                     | 常见误解                          |
| -------------- | ------------------------------ | --------------------------------- |
| &lt;code&gt;kernel_size&lt;/code&gt;  | 单层观察窗口与参数量           | 核大就必然更好                    |
| &lt;code&gt;stride&lt;/code&gt;       | 滑窗间隔，通常同时下采样       | 只减少计算，不丢空间信息          |
| &lt;code&gt;padding&lt;/code&gt;      | 边界处可用的上下文             | &lt;code&gt;&apos;same&apos;&lt;/code&gt; 对任意 stride 都保持尺寸 |
| &lt;code&gt;dilation&lt;/code&gt;     | 核采样点的间隔，不增加权重数   | 等价于更大的稠密核                |
| &lt;code&gt;out_channels&lt;/code&gt; | 学习多少种局部模式             | 等于 RGB 等原始颜色通道           |
| &lt;code&gt;groups&lt;/code&gt;       | 哪些输入通道可连到哪些输出通道 | 只改变速度，不改变函数族          |&lt;/p&gt;
&lt;p&gt;PyTorch 2.13 官方 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.Conv2d.html&quot;&gt;&lt;code&gt;Conv2d&lt;/code&gt;&lt;/a&gt; 文档说明：&lt;code&gt;padding=&apos;same&apos;&lt;/code&gt; 保持空间尺寸，但当前不支持 &lt;code&gt;stride != 1&lt;/code&gt;；&lt;code&gt;in_channels&lt;/code&gt; 和 &lt;code&gt;out_channels&lt;/code&gt; 都必须能被 &lt;code&gt;groups&lt;/code&gt; 整除。不要用猜测替代形状断言。&lt;/p&gt;
&lt;h2&gt;06 感受野怎样从 &lt;code&gt;3×3&lt;/code&gt; 长成 &lt;code&gt;9×9&lt;/code&gt;？&lt;/h2&gt;
&lt;p&gt;某层一个特征位置能依赖原图的范围，叫感受野（Receptive Field）。记第 $l$ 层的感受野边长为 $r_l$，相邻特征位置在原图上的间隔为 $j_l$：&lt;/p&gt;
&lt;p&gt;$$
r_l=r_{l-1}+(k_l-1)d_lj_{l-1},\qquad
j_l=j_{l-1}s_l
$$&lt;/p&gt;
&lt;p&gt;从 $r_0=1,j_0=1$ 开始，追踪三层 &lt;code&gt;3×3&lt;/code&gt; 卷积：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原图单点        Conv 3×3,s=1       Conv 3×3,s=2       Conv 3×3,s=1
r=1, j=1   ───►   r=3, j=1    ───►   r=5, j=2    ───►   r=9, j=2
  1×1                    3×3 视野              5×5 视野              9×9 视野
                                               └─ 下采样后，相邻特征跨原图 2 格
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个小核逐层把局部边缘组成更大图案，中间还能插入非线性。但公式给出的是理论感受野；训练后的有效感受野往往只在其中心区域有较大影响，不代表框内所有像素贡献相等。&lt;/p&gt;
&lt;h2&gt;07 不依赖框架，写出卷积本体&lt;/h2&gt;
&lt;p&gt;下面实现 &lt;code&gt;groups=1&lt;/code&gt;、&lt;code&gt;dilation=1&lt;/code&gt; 的 NCHW 二维互相关。输入是 &lt;code&gt;[N,C_in,H,W]&lt;/code&gt;，权重是 &lt;code&gt;[C_out,C_in,K_h,K_w]&lt;/code&gt;，输出是 &lt;code&gt;[N,C_out,H_out,W_out]&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def conv2d_nchw(x, weight, bias=None, stride=1, padding=0):
    assert x.ndim == 4 and weight.ndim == 4
    n, c_in, h_in, w_in = x.shape
    c_out, c_weight, k_h, k_w = weight.shape
    assert c_weight == c_in

    h_out = (h_in + 2 * padding - k_h) // stride + 1
    w_out = (w_in + 2 * padding - k_w) // stride + 1
    assert h_out &gt; 0 and w_out &gt; 0

    x_pad = np.pad(
        x,
        ((0, 0), (0, 0), (padding, padding), (padding, padding)),
    )
    y = np.empty((n, c_out, h_out, w_out), dtype=np.result_type(x, weight))

    for batch in range(n):
        for out_ch in range(c_out):
            for i in range(h_out):
                for j in range(w_out):
                    row, col = i * stride, j * stride
                    window = x_pad[
                        batch, :, row:row + k_h, col:col + k_w
                    ]
                    y[batch, out_ch, i, j] = np.sum(
                        window * weight[out_ch]
                    )
            if bias is not None:
                y[batch, out_ch] += bias[out_ch]
    return y

x = np.array([[[[1, 2, 0], [0, 1, 3], [2, 2, 1]]]], dtype=float)
weight = np.array([[[[1, 0], [-1, 1]]]], dtype=float)
y = conv2d_nchw(x, weight)

np.testing.assert_allclose(y, [[[[2, 4], [0, 0]]]])
assert y.shape == (1, 1, 2, 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段循环为了暴露数据流，不适合生产训练。高性能库会使用向量化、专用中央处理器（Central Processing Unit, CPU）/图形处理器（Graphics Processing Unit, GPU）内核和自动算法选择；它们改变实现方式，不改变上面的张量语义。&lt;/p&gt;
&lt;h2&gt;08 从特征图到分类结果，数据怎样流？&lt;/h2&gt;
&lt;p&gt;下面用线性整流激活（Rectified Linear Unit, ReLU）引入非线性，再把空间特征聚合为分类向量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;images [N,3,32,32]
   │
   ├─ Conv2d(3→8,k=3,p=1)  → [N,8,32,32]
   ├─ ReLU                    → [N,8,32,32]
   ├─ Conv2d(8→16,k=3,s=2,p=1)
   │                           → [N,16,16,16]
   ├─ ReLU                    → [N,16,16,16]
   ├─ AdaptiveAvgPool2d(1)    → [N,16,1,1]
   ├─ flatten(start_dim=1)    → [N,16]
   └─ Linear(16→4)           → logits [N,4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;卷积不直接“输出类别”。它把每只核对局部模式的响应保存成一个特征图（Feature Map）；非线性和后续卷积再把低层边缘组成高层结构。最后的聚合与线性头才生成分类分数（logit）。&lt;/p&gt;
&lt;h2&gt;09 用 PyTorch 2.13 写出可检查的 CNN&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class TinyCNN(nn.Module):
    def __init__(self, num_classes=4):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 8, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(8, 16, kernel_size=3, stride=2, padding=1),
            nn.ReLU(),
        )
        self.pool = nn.AdaptiveAvgPool2d((1, 1))
        self.classifier = nn.Linear(16, num_classes)

    def forward(self, x):
        assert x.ndim == 4 and x.shape[1] == 3
        features = self.features(x)           # [N,16,H_out,W_out]
        pooled = self.pool(features)           # [N,16,1,1]
        vector = torch.flatten(pooled, 1)      # [N,16]
        logits = self.classifier(vector)       # [N,num_classes]
        return logits

torch.manual_seed(7)
model = TinyCNN(num_classes=4)
images = torch.randn(5, 3, 32, 32)             # float32, NCHW
labels = torch.tensor([0, 1, 2, 3, 1])         # int64, [N]

logits = model(images)
assert logits.shape == (5, 4)

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

optimizer.zero_grad(set_to_none=True)
loss = loss_fn(logits, labels)                 # 标量
loss.backward()

assert model.features[0].weight.grad is not None
assert torch.isfinite(model.features[0].weight.grad).all()
optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Conv2d(3,8,3)&lt;/code&gt; 的权重形状是 &lt;code&gt;[8,3,3,3]&lt;/code&gt;，它为 8 种可学习的局部模式各保存一只覆盖 3 个输入通道的核。&lt;code&gt;CrossEntropyLoss&lt;/code&gt; 接收未经 Softmax 的 &lt;code&gt;[N,4]&lt;/code&gt; logits 和 &lt;code&gt;[N]&lt;/code&gt; 整数类别索引；不要在模型中先转成 &lt;code&gt;argmax&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;完整训练会对每个小批量（mini-batch）重复“前向→损失→清梯度→反向→更新”；验证和推理则使用 &lt;code&gt;model.eval()&lt;/code&gt; 与 &lt;code&gt;torch.inference_mode()&lt;/code&gt;。卷积层自身没有训练/评估（train/eval）两套行为，但与它组合的批归一化（Batch Normalization, BatchNorm）和随机失活（Dropout）有。&lt;/p&gt;
&lt;h2&gt;10 一条最短的卷积调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先查输入契约。&lt;/strong&gt; 打印 &lt;code&gt;shape/dtype/device/min/max&lt;/code&gt;；图像 batch 应是 &lt;code&gt;[N,C,H,W]&lt;/code&gt; 浮点张量，不是常见图像库的 &lt;code&gt;[N,H,W,C]&lt;/code&gt; 整数数组。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用一个冲激点测位置。&lt;/strong&gt; 令输入只有中央一点为 1，卷积核全为 1；响应区域可以暴露 padding、stride 和通道顺序错误。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐层断言形状。&lt;/strong&gt; 不要直到 &lt;code&gt;Linear&lt;/code&gt; 报矩阵乘法错误才回头；对照输出公式查第一个偏离层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;与手算本体对齐。&lt;/strong&gt; 把同一组小数据和权重复制到 NumPy 与 &lt;code&gt;Conv2d&lt;/code&gt;，用 &lt;code&gt;torch.testing.assert_close&lt;/code&gt; 比较输出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尝试过拟合一个极小 batch。&lt;/strong&gt; 若 8 个样本都无法几乎记住，先查标签、损失、学习率、梯度与数据处理，而不是继续加层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;观察激活和梯度。&lt;/strong&gt; 记录每层非零比例、均值、标准差与梯度范数，找到第一个全零、NaN 或尺度突变的层。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 最常见的“能跑，但语义错了”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;NHWC/NCHW 混淆。&lt;/strong&gt; 若高度恰好等于期望通道数，错布局甚至可能不立即报错；在数据边界只转换一次并断言。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;过早 flatten。&lt;/strong&gt; 一旦在特征提取前拉平，后续就无法使用二维权重共享。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为 padding 只改尺寸。&lt;/strong&gt; 零填充还改变边界的输入分布；反射、复制与循环填充具有不同语义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略 &lt;code&gt;groups&lt;/code&gt; 的连接约束。&lt;/strong&gt; &lt;code&gt;groups=C_in&lt;/code&gt; 且 &lt;code&gt;C_out=K C_in&lt;/code&gt; 时是深度卷积（Depthwise Convolution）；它不自动混合通道，通常还要配 &lt;code&gt;1×1&lt;/code&gt; 点卷积。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把理论感受野当成实际利用范围。&lt;/strong&gt; 可用输出对输入的梯度热图检查有效感受野，而不只算层数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把平移等变当成不变。&lt;/strong&gt; 对输入做一像素平移测试，同时观察中间特征与最终 logits；两者应用不同标准。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略非确定算法。&lt;/strong&gt; 官方文档提醒，统一计算设备架构（Compute Unified Device Architecture, CUDA）上的深度神经网络库（CUDA Deep Neural Network library, cuDNN）在某些形状下可能选择非确定内核；严格复现时要单独设置确定性策略，并接受性能代价。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 卷积与相近操作的边界&lt;/h2&gt;
&lt;p&gt;| 方法           | 连接范围             | 空间位置间共享参数     | 主要用途/代价                        |
| -------------- | -------------------- | ---------------------- | ------------------------------------ |
| 全连接层       | 所有输入             | 不适用                 | 全局混合；忽略网格邻接，参数多       |
| 局部连接层     | 局部窗口             | 否                     | 可学位置特定模式；参数随空间尺寸增长 |
| 标准卷积       | 局部窗口、全输入通道 | 是                     | 空间等变特征；长距依赖需堆叠         |
| 深度可分离卷积 | 局部窗口后再混通道   | 是                     | 减少计算；表达约束更强               |
| 池化           | 局部统计             | 通常无参数             | 下采样/聚合；不学习线性模式          |
| 自注意力       | 通常可全局交互       | 投影权重共享，关系动态 | 长距建模；计算和数据需求不同         |&lt;/p&gt;
&lt;p&gt;卷积在网格不规则、空间位置语义完全不同、需要一层就建模全局交互，或关键模式会任意旋转、缩放时不再天然合适。数据增强、群等变卷积、图神经网络或注意力可以针对不同结构假设，但没有一种方法只因为更“现代”就在所有数据规模上更好。&lt;/p&gt;
&lt;h2&gt;13 工程中还要防哪些失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;下采样混叠。&lt;/strong&gt; &lt;code&gt;stride&gt;1&lt;/code&gt; 直接丢弃位置；输入含高频纹理时可产生别名（Aliasing），必要时先低通滤波并做平移压力测试。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;边界成为捷径。&lt;/strong&gt; 固定裁剪、零填充或水印可让模型学到位置偏见；可视化错误样本的激活/梯度热图。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;名义感受野大，中心仍过度主导。&lt;/strong&gt; 小物体、长条结构和远距关系仍可被忽略；需要多尺度特征、空洞卷积或全局交互。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;小数据上过拟合。&lt;/strong&gt; 参数少于全连接层不等于不过拟合；仍要独立验证集、合法增强、权重衰减与早停。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看准确率。&lt;/strong&gt; 医学、遥感等任务还应分析群体、设备、时间和空间分布偏移，并检查模型是否利用了背景捷径。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;卷积的两个核心假设是局部连接与空间权重共享；它们保留网格结构、减少参数并带来有边界的平移等变性。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Conv2d&lt;/code&gt; 把 &lt;code&gt;[N,C_in,H,W]&lt;/code&gt; 与 &lt;code&gt;[C_out,C_in/groups,K_h,K_w]&lt;/code&gt; 权重组合为 &lt;code&gt;[N,C_out,H_out,W_out]&lt;/code&gt;；每个维度都有明确语义。&lt;/li&gt;
&lt;li&gt;输出尺寸由核、步幅、填充和空洞率共同决定；每层应用公式和运行时断言双重核对。&lt;/li&gt;
&lt;li&gt;多层小核通过堆叠扩大理论感受野，但有效感受野、边界效应与下采样信息损失仍需实测。&lt;/li&gt;
&lt;li&gt;最可靠的卷积调试不是盲目换架构，而是从小数据、冲激输入、逐层形状、框架对齐和小 batch 过拟合逐步缩小问题。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对一个 &lt;code&gt;[N=2,C=3,H=7,W=7]&lt;/code&gt; 输入使用 &lt;code&gt;Conv2d(3,5,kernel_size=3,stride=2,padding=2,dilation=2)&lt;/code&gt;。手算输出形状、权重形状与含偏置的参数量，再用 PyTorch 断言。&lt;/li&gt;
&lt;li&gt;把手算例的输入右移一格并在左侧补零。分别在 &lt;code&gt;padding=0&lt;/code&gt; 与 &lt;code&gt;padding=1&lt;/code&gt; 下比较输出；哪些位置符合平移等变，哪些因边界失效？&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;TinyCNN&lt;/code&gt; 的每个卷积后保留激活梯度，对一个 logit 反向传播并画输入梯度热图。把模型改为三层后，比较理论感受野和非零/高强度梯度区域。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;http://yann.lecun.com/exdb/publis/pdf/lecun-01a.pdf&quot;&gt;LeCun et al. (1998), Gradient-Based Learning Applied to Document Recognition&lt;/a&gt;：以 LeNet-5 系统展示局部感受野、权重共享与端到端视觉学习。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2012/hash/c399862d3b9d6b76c8436e924a68c45b-Abstract.html&quot;&gt;Krizhevsky, Sutskever &amp;#x26; Hinton (2012), ImageNet Classification with Deep Convolutional Neural Networks&lt;/a&gt;：大规模 GPU 卷积网络在 ImageNet 上的标志性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1409.1556&quot;&gt;Simonyan &amp;#x26; Zisserman (2015), Very Deep Convolutional Networks for Large-Scale Image Recognition&lt;/a&gt;：展示堆叠小 &lt;code&gt;3×3&lt;/code&gt; 核构造深层特征层次的经典设计。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1603.07285&quot;&gt;Dumoulin &amp;#x26; Visin (2016), A Guide to Convolution Arithmetic for Deep Learning&lt;/a&gt;：系统梳理卷积、填充、步幅、转置卷积与输出尺寸。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2016/hash/c8067ad1937f728f51288b3eb986afaa-Abstract.html&quot;&gt;Luo et al. (2016), Understanding the Effective Receptive Field in Deep Convolutional Neural Networks&lt;/a&gt;：区分理论感受野与实际梯度影响集中的有效感受野。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;卷积已经能在空间网格上复用局部模式，但高分辨率特征图会让计算和显存快速增长。下一篇将比较池化、带步幅卷积与抗混叠下采样，追踪它们怎样生成多尺度特征层次，又分别丢掉什么。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>网络加深后训练误差为何反而升高？残差连接的恒等捷径与梯度分流</title><link>https://zwjcode.cn/blog/residual-connection-identity-gradient-path</link><guid isPermaLink="true">https://zwjcode.cn/blog/residual-connection-identity-gradient-path</guid><description>从深层网络的退化问题出发，手算残差块的前向叠加与反向梯度分流，拆解投影捷径、预归一化和 PyTorch 2.13 实现。</description><pubDate>Wed, 02 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 BatchNorm 与 LayerNorm 控制中间激活的尺度，但即使每个变换内部都很稳定，信息仍要依次穿过所有权重、非线性与归一化层。网络越深，这条串行路径越长，优化器就越难找到“多加几层但至少别比浅层差”的参数。&lt;/p&gt;
&lt;p&gt;残差连接（Residual Connection）给每个变换旁边增加一条恒等捷径（Identity Shortcut）：让新层先学习“应该在输入上改多少”，再把改动加回输入。本文只讲透三个紧密相关的问题：前向为何改学残差、反向梯度如何分流，以及形状改变和预归一化时怎样保住捷径。&lt;/p&gt;
&lt;h2&gt;01 更深却有更高训练误差，不等于过拟合&lt;/h2&gt;
&lt;p&gt;若 20 层网络已经得到可用函数，理论上给它再接若干恒等映射，新网络至少可以复现原来的输出。然而普通串行层要求优化器从随机参数中学出这些恒等映射；这并不容易。深层模型可能出现&lt;strong&gt;训练误差也升高&lt;/strong&gt;的退化问题（Degradation Problem），它和“训练误差低、验证误差高”的过拟合不是一回事。&lt;/p&gt;
&lt;p&gt;普通两层变换写成：&lt;/p&gt;
&lt;p&gt;$$
x_{l+1}=H_l(x_l)=\phi(W_{l,2},\phi(W_{l,1}x_l))
$$&lt;/p&gt;
&lt;p&gt;其中 $x_l\in\mathbb{R}^{N\times D}$ 是第 $l$ 个块的输入，$N$ 是 batch 大小，$D$ 是特征维度；$W_{l,1},W_{l,2}\in\mathbb{R}^{D\times D}$；$\phi$ 是逐元素非线性。每加一块，前向信号和反向梯度都必须再穿过两次矩阵乘法和非线性。&lt;/p&gt;
&lt;p&gt;残差块改成：&lt;/p&gt;
&lt;p&gt;$$
x_{l+1}=x_l+F_l(x_l;\theta_l)
$$&lt;/p&gt;
&lt;p&gt;$F_l$ 是残差分支（Residual Branch），参数集合为 $\theta_l$；捷径分支直接复制 $x_l$。若最优选择是“不改变输入”，只需让 $F_l(x_l)\approx 0$，而不必让一串非线性层精确拟合恒等函数。&lt;/p&gt;
&lt;h2&gt;02 前向数据到底怎样走？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                              残差分支 F(x) [N,D]
                         ┌─ Linear/Conv ─ Norm ─ Act ─ Linear/Conv ─┐
                         │                                           ▼
x_l [N,D] ── split ──────┤                                         (+) ──► x_{l+1} [N,D]
                         │                                           ▲
                         └──────── 恒等捷径 identity [N,D] ──────────┘

约束：相加两端必须具有相同 shape、device 和可兼容 dtype。
语义：输出 = 原信息 + 本块学到的修正。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这不是把两路特征拼接。相加（Addition）保持维度 $D$ 不变；拼接（Concatenation）会得到 &lt;code&gt;[N,2D]&lt;/code&gt;，后续层的参数量和语义都会变化。&lt;/p&gt;
&lt;p&gt;连续堆叠时，先忽略块尾额外的非线性：&lt;/p&gt;
&lt;p&gt;$$
x_L=x_l+\sum_{i=l}^{L-1}F_i(x_i;\theta_i)
$$&lt;/p&gt;
&lt;p&gt;浅层表示 $x_l$ 可以沿捷径直接进入深层，残差分支则逐块累积修正。注意 $F_i$ 的输入是不断更新的 $x_i$，上式并不表示各块彼此独立或可以任意交换顺序。&lt;/p&gt;
&lt;h2&gt;03 用两个数手算一次前向叠加&lt;/h2&gt;
&lt;p&gt;令输入与残差分支为：&lt;/p&gt;
&lt;p&gt;$$
x=\begin{bmatrix}1\2\end{bmatrix},\qquad
F(x)=Wx,\qquad
W=\begin{bmatrix}0.5&amp;#x26;0\0&amp;#x26;-0.5\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;先算修正量：&lt;/p&gt;
&lt;p&gt;$$
F(x)=\begin{bmatrix}0.5\-1\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;普通串行块只输出 $F(x)=[0.5,-1]^\top$；残差块输出：&lt;/p&gt;
&lt;p&gt;$$
y=x+F(x)=\begin{bmatrix}1.5\1\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;第一个特征被增强 0.5，第二个特征被削弱 1，但原输入并未要求残差分支重新生成。若把 $W$ 初始化为零，残差块从 $y=x$ 开始；若 $W$ 很小，它从“接近恒等”开始。&lt;/p&gt;
&lt;h2&gt;04 反向传播为什么会多出一条直接路径？&lt;/h2&gt;
&lt;p&gt;设损失为 $\mathcal{L}$，上游梯度 $g_{l+1}=\partial\mathcal{L}/\partial x_{l+1}$ 与 $x_{l+1}$ 同形。由链式法则：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial\mathcal{L}}{\partial x_l}
=\frac{\partial\mathcal{L}}{\partial x_{l+1}}
\left(I+\frac{\partial F_l}{\partial x_l}\right)
$$&lt;/p&gt;
&lt;p&gt;按列向量记法写得更明确：&lt;/p&gt;
&lt;p&gt;$$
g_l=g_{l+1}+J_{F_l}(x_l)^\top g_{l+1}
$$&lt;/p&gt;
&lt;p&gt;$J_{F_l}\in\mathbb{R}^{D\times D}$ 是残差分支对输入的雅可比矩阵（Jacobian）。第一项 $g_{l+1}$ 来自恒等捷径，不乘权重；第二项才穿过残差分支。所谓“梯度高速公路”不是梯度永远等于 1，而是每个块都提供了一项不经过该块参数变换的贡献。&lt;/p&gt;
&lt;p&gt;回到上面的线性例子，令上游梯度 $g_y=[1,1]^\top$：&lt;/p&gt;
&lt;p&gt;$$
g_x=g_y+W^\top g_y
=\begin{bmatrix}1\1\end{bmatrix}
+\begin{bmatrix}0.5\-0.5\end{bmatrix}
=\begin{bmatrix}1.5\0.5\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;若没有捷径，梯度只有 $W^\top g_y=[0.5,-0.5]^\top$。两条路径也可能相互抵消，所以残差连接缓解而不保证消灭梯度消失或爆炸。&lt;/p&gt;
&lt;h2&gt;05 多层相乘时差异有多大？&lt;/h2&gt;
&lt;p&gt;考虑每层只有一个标量，残差函数 $F_l(x)=a_lx$。普通串行网络的梯度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial x_L}{\partial x_0}=\prod_{l=0}^{L-1}a_l
$$&lt;/p&gt;
&lt;p&gt;残差网络则为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial x_L}{\partial x_0}=\prod_{l=0}^{L-1}(1+a_l)
$$&lt;/p&gt;
&lt;p&gt;若四层都有 $a_l=-0.1$，普通路径的梯度大小是 $10^{-4}$，残差路径是 $0.9^4=0.6561$。这个例子只说明恒等项如何改变乘积，不代表参数越接近零越好：若 $a_l=-1$，残差因子反而为零；若很多 $a_l&gt;0$，乘积也可能爆炸。&lt;/p&gt;
&lt;h2&gt;06 不依赖框架，写出前向与反向本体&lt;/h2&gt;
&lt;p&gt;下面直接实现线性残差块。&lt;code&gt;x&lt;/code&gt; 的形状为 &lt;code&gt;[N,D]&lt;/code&gt;，&lt;code&gt;weight&lt;/code&gt; 为 &lt;code&gt;[D,D]&lt;/code&gt;，输出仍为 &lt;code&gt;[N,D]&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def residual_linear_forward(x, weight):
    &quot;&quot;&quot;y = x + x @ weight.T&quot;&quot;&quot;
    assert x.ndim == 2
    assert weight.shape == (x.shape[1], x.shape[1])
    residual = x @ weight.T
    return x + residual, (x, weight)

def residual_linear_backward(grad_y, cache):
    &quot;&quot;&quot;返回 grad_x [N,D] 与 grad_weight [D,D]。&quot;&quot;&quot;
    x, weight = cache
    grad_x_identity = grad_y
    grad_x_residual = grad_y @ weight
    grad_x = grad_x_identity + grad_x_residual
    grad_weight = grad_y.T @ x
    return grad_x, grad_weight

x = np.array([[1.0, 2.0]])                    # [N=1,D=2]
weight = np.diag([0.5, -0.5])                 # [D=2,D=2]
y, cache = residual_linear_forward(x, weight) # [1,2]
grad_x, grad_weight = residual_linear_backward(
    np.ones_like(y), cache
)

np.testing.assert_allclose(y, [[1.5, 1.0]])
np.testing.assert_allclose(grad_x, [[1.5, 0.5]])
assert grad_weight.shape == (2, 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;grad_weight = grad_y.T @ x&lt;/code&gt; 只来自残差分支，因为恒等捷径没有参数。&lt;code&gt;grad_x&lt;/code&gt; 则是两路梯度相加；若漏掉 &lt;code&gt;grad_x_identity&lt;/code&gt;，代码虽然形状正确，却已经退化成普通串行层。&lt;/p&gt;
&lt;h2&gt;07 训练与推理的完整伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;对每个残差块 l：
    输入 x_l [N,D] 或 [N,C,H,W]
    identity = x_l
    residual = F_l(x_l; θ_l)

    若 residual 与 identity 形状不同：
        identity = P_l(x_l)      # 只做必要的尺寸/通道对齐

    assert shape(identity) == shape(residual)
    x_{l+1} = identity + residual

训练：
    loss = criterion(model(X), y)
    清空梯度 -&gt; backward -&gt; 检查两分支梯度 -&gt; optimizer.step

推理：
    切换 eval 模式并关闭梯度记录
    使用同一相加数据流；只有 Norm/Dropout 等子模块行为可能切换
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;残差连接本身没有可训练参数，也没有 train/eval 两套行为；差异来自 $F_l$ 或投影 $P_l$ 内部的 BatchNorm、Dropout 等模块。&lt;/p&gt;
&lt;h2&gt;08 形状改变时，恒等捷径怎么办？&lt;/h2&gt;
&lt;p&gt;相加要求两路输出形状完全一致。卷积网络中，残差分支可能用 &lt;code&gt;stride=2&lt;/code&gt; 把 &lt;code&gt;[N,C,H,W]&lt;/code&gt; 变为 &lt;code&gt;[N,2C,H/2,W/2]&lt;/code&gt;，原始输入不能直接相加。&lt;/p&gt;
&lt;p&gt;常用投影捷径（Projection Shortcut）是一个 &lt;code&gt;1×1&lt;/code&gt; 卷积：&lt;/p&gt;
&lt;p&gt;$$
P(x)=W_s*x,qquad
W_s\in\mathbb{R}^{C_{out}\times C_{in}\times1\times1}
$$&lt;/p&gt;
&lt;p&gt;若步幅为 2，它同时改变通道数和空间尺寸：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x [N,64,56,56]
  ├─ residual: 3×3 conv, stride=2 ─ ... ─► [N,128,28,28] ─┐
  └─ shortcut: 1×1 conv, stride=2 ───────► [N,128,28,28] ─┤ (+)
                                                               │
                                                               ▼
                                                       y [N,128,28,28]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;投影路径不再是严格恒等映射，也会引入参数和雅可比矩阵。因此只在形状必须改变时使用；同形块优先保留无参数的 &lt;code&gt;nn.Identity()&lt;/code&gt; 或直接使用 &lt;code&gt;x&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;| 场景                         | 捷径               |     是否有参数 | 梯度直接项       |
| ---------------------------- | ------------------ | -------------: | ---------------- |
| 同形 &lt;code&gt;[N,C,H,W] → [N,C,H,W]&lt;/code&gt; | &lt;code&gt;x&lt;/code&gt; / &lt;code&gt;Identity&lt;/code&gt;   |             否 | $I$              |
| 改通道或下采样               | &lt;code&gt;1×1 Conv(stride)&lt;/code&gt; |             是 | $J_P$            |
| 拼接而不是相加               | &lt;code&gt;concat([x,F(x)])&lt;/code&gt; | 否，但后层变宽 | 不是标准残差加法 |&lt;/p&gt;
&lt;h2&gt;09 预归一化为什么更接近“干净捷径”？&lt;/h2&gt;
&lt;p&gt;上一篇介绍了 LayerNorm。把归一化放在相加之前还是之后，会改变捷径的数据流。&lt;/p&gt;
&lt;p&gt;后归一化（Post-Norm）常写为：&lt;/p&gt;
&lt;p&gt;$$
x_{l+1}=\operatorname{LN}(x_l+F_l(x_l))
$$&lt;/p&gt;
&lt;p&gt;即使走捷径，信号和梯度仍必须穿过块尾 LayerNorm。预归一化（Pre-Norm）改为：&lt;/p&gt;
&lt;p&gt;$$
x_{l+1}=x_l+F_l(\operatorname{LN}(x_l))
$$&lt;/p&gt;
&lt;p&gt;此时相加后的 $x_l$ 到 $x_{l+1}$ 是真正的恒等路径，归一化只在残差分支内。它通常让很深的序列模型更容易优化，但也会改变表征尺度、最终归一化位置和训练动态，不能只移动一行代码后假设模型完全等价。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Post-Norm: x ─┬─────────────┐
              └─ F(x) ──────┴─ (+) ─ LN ─► y
                                         捷径仍穿过 LN

Pre-Norm:  x ─┬───────────────────────┐
              └─ LN ─ F(·) ──────────┴─ (+) ─► y
              相加后的主路径保持 identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在原始卷积 ResNet 中还会看到“卷积—BatchNorm—ReLU”的块内顺序以及全预激活（Full Pre-activation）设计。它们和 Transformer 的 Pre-LN 共享“把变换留在残差分支、让相加路径更直接”的思想，但具体归一化轴和块结构不同，不能把名称当作同一个实现。&lt;/p&gt;
&lt;h2&gt;10 用 PyTorch 2.13 实现可检查的卷积残差块&lt;/h2&gt;
&lt;p&gt;当前 PyTorch 2.13 官方 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.Conv2d.html&quot;&gt;&lt;code&gt;Conv2d&lt;/code&gt;&lt;/a&gt; 接收 &lt;code&gt;[N,C,H,W]&lt;/code&gt;；&lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.BatchNorm2d.html&quot;&gt;&lt;code&gt;BatchNorm2d&lt;/code&gt;&lt;/a&gt; 保持同形并按通道归一化。下面显式构造残差分支和必要的投影捷径：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class BasicResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(
            in_channels, out_channels, kernel_size=3,
            stride=stride, padding=1, bias=False,
        )
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(
            out_channels, out_channels, kernel_size=3,
            padding=1, bias=False,
        )
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.activation = nn.ReLU()

        if stride == 1 and in_channels == out_channels:
            self.shortcut = nn.Identity()
        else:
            self.shortcut = nn.Sequential(
                nn.Conv2d(
                    in_channels, out_channels, kernel_size=1,
                    stride=stride, bias=False,
                ),
                nn.BatchNorm2d(out_channels),
            )

        # 让残差分支末端初始接近 0；初始化函数在 no_grad 下执行。
        nn.init.zeros_(self.bn2.weight)

    def forward(self, x):
        identity = self.shortcut(x)
        residual = self.conv1(x)
        residual = self.activation(self.bn1(residual))
        residual = self.bn2(self.conv2(residual))
        assert residual.shape == identity.shape
        return self.activation(identity + residual)

same_shape = BasicResidualBlock(64, 64, stride=1)
downsample = BasicResidualBlock(64, 128, stride=2)
x = torch.randn(8, 64, 56, 56)        # [N=8,C=64,H=56,W=56]
y = same_shape(x)                       # [8,64,56,56]
z = downsample(y)                       # [8,128,28,28]
assert y.shape == (8, 64, 56, 56)
assert z.shape == (8, 128, 28, 28)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;卷积后接 BatchNorm 时把卷积 &lt;code&gt;bias=False&lt;/code&gt;，因为归一化层默认已有可学习偏置。&lt;code&gt;nn.init.zeros_(self.bn2.weight)&lt;/code&gt; 把最后一次 BatchNorm 的缩放参数 $\gamma$ 置零，使同形块初始残差接近零；这是一种可选初始化策略，不应误写成把所有卷积权重都置零。&lt;/p&gt;
&lt;h2&gt;11 怎样检查梯度确实走了两条路？&lt;/h2&gt;
&lt;p&gt;给输入注册 hook，并分别保留捷径与残差输出的梯度：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class InspectResidual(nn.Module):
    def __init__(self, width):
        super().__init__()
        self.branch = nn.Linear(width, width, bias=False)

    def forward(self, x):
        residual = self.branch(x)
        residual.retain_grad()
        output = x + residual
        return output, residual

torch.manual_seed(7)
block = InspectResidual(width=4)
x = torch.randn(3, 4, requires_grad=True)  # [N=3,D=4]
output, residual = block(x)                # 两者都是 [3,4]
loss = output.square().mean()              # 标量
loss.backward()

assert x.grad is not None
assert residual.grad is not None
assert block.branch.weight.grad is not None
print({
    &quot;input_grad_norm&quot;: x.grad.norm().item(),
    &quot;residual_grad_norm&quot;: residual.grad.norm().item(),
    &quot;weight_grad_norm&quot;: block.branch.weight.grad.norm().item(),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 &lt;code&gt;residual.grad&lt;/code&gt; 就是相加节点传入残差分支的上游梯度；&lt;code&gt;x.grad&lt;/code&gt; 还多接收一份恒等捷径的梯度。不要期待两个范数满足简单相加，因为梯度是向量，方向不同会增强或抵消。&lt;/p&gt;
&lt;p&gt;更严格的数值检查是把同一个 &lt;code&gt;x&lt;/code&gt; 分别送入 &lt;code&gt;x + branch(x)&lt;/code&gt; 与手写矩阵公式，再用 &lt;code&gt;torch.testing.assert_close&lt;/code&gt; 比较前向和 &lt;code&gt;torch.autograd.grad&lt;/code&gt; 结果。若自定义算子参与残差分支，可用双精度 &lt;code&gt;torch.autograd.gradcheck&lt;/code&gt; 验证局部导数。&lt;/p&gt;
&lt;h2&gt;12 最常见的错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;相加两端 shape 不同。&lt;/strong&gt; 先打印两路 &lt;code&gt;[N,C,H,W]&lt;/code&gt;；通道或步幅变化时再加 &lt;code&gt;1×1&lt;/code&gt; 投影，不要靠错误广播“凑巧运行”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;原地操作破坏反向需要的值。&lt;/strong&gt; 复杂分支中谨慎使用 &lt;code&gt;inplace=True&lt;/code&gt; 或 &lt;code&gt;+=&lt;/code&gt;；出现 autograd version 报错时先换成非原地写法。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把拼接当残差连接。&lt;/strong&gt; &lt;code&gt;torch.cat&lt;/code&gt; 会改变通道数；若设计确实需要 DenseNet 式拼接，应同步计算后续层输入维度与内存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个同形块都加投影。&lt;/strong&gt; 这会丢掉无参数恒等路径并增加计算；只在尺寸变化时投影。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;错误地把所有参数初始化为零。&lt;/strong&gt; 同层神经元会保持对称；只将残差分支末端特定缩放置零，与上一篇的 He/Xavier 初始化配合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略 BatchNorm 模式。&lt;/strong&gt; 残差加法不区分训练和推理，但分支中的 BatchNorm 区分；验证必须同时使用 &lt;code&gt;eval()&lt;/code&gt; 与 &lt;code&gt;no_grad()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;残差分支压过主干。&lt;/strong&gt; 记录 &lt;code&gt;||F(x)|| / (||x|| + ε)&lt;/code&gt;；若长期极大，检查学习率、初始化、归一化与输入尺度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看总梯度范数。&lt;/strong&gt; 分层记录输入梯度、分支参数梯度和投影梯度，才能定位第一个异常块。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最短路径：固定随机种子与一个 batch → 打印相加前两路形状、均值和范数 → 暂时令 $F(x)=0$ 验证同形块是否近似恒等 → 对一个标量损失跑 backward → 从输出向输入逐块检查梯度 → 最后再恢复完整训练。&lt;/p&gt;
&lt;h2&gt;13 残差连接会在哪里失败？&lt;/h2&gt;
&lt;p&gt;残差网络仍可能因为学习率过大、归一化统计失配、低精度溢出或分支尺度持续增长而不稳定。恒等捷径也不会自动提升泛化：模型容量增加后，数据不足或正则化不当仍会过拟合。&lt;/p&gt;
&lt;p&gt;它与相近结构的区别在于信息合并方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高速网络（Highway Network）用可学习门控制变换分支和携带分支，灵活但多出门参数；&lt;/li&gt;
&lt;li&gt;DenseNet 把此前层输出沿通道拼接，鼓励特征复用，却会让通道数和显存增长；&lt;/li&gt;
&lt;li&gt;U-Net 的长跳连常连接编码器与解码器的同尺度特征，主要服务空间细节恢复，不等于每个块的局部残差；&lt;/li&gt;
&lt;li&gt;ReZero 用可学习标量从零缩放残差分支，显式让深层网络从恒等映射开始；&lt;/li&gt;
&lt;li&gt;随机深度（Stochastic Depth）训练时随机跳过部分残差分支，是正则化和缩短有效训练路径的方法，不改变推理时完整网络结构。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;残差块学习 $F(x)=H(x)-x$，输出 $x+F(x)$；当额外变换不必要时，让残差接近零比让深层串行函数精确学出恒等映射更容易。&lt;/li&gt;
&lt;li&gt;反向梯度为 $g_x=g_y+J_F^\top g_y$；恒等项提供直接路径，但仍可能与残差项抵消或一起爆炸。&lt;/li&gt;
&lt;li&gt;相加两路必须同形；尺寸改变时才使用投影捷径，同形块应保留无参数恒等路径。&lt;/li&gt;
&lt;li&gt;Pre-Norm 把归一化留在残差分支内，使块间相加路径更直接；它与 Post-Norm 的训练动态并不等价。&lt;/li&gt;
&lt;li&gt;调试残差网络要同时观察形状、分支尺度、逐块梯度和归一化状态，不能只看“用了 ResNet”这个名字。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;令 $x=[2,-1]^\top$、$W=\begin{bmatrix}-0.25&amp;#x26;0.5\0&amp;#x26;0.25\end{bmatrix}$、$y=x+Wx$，手算 $y$；若 $\partial\mathcal{L}/\partial y=[1,2]^\top$，再手算 $\partial\mathcal{L}/\partial x$，标出两条路径各自的贡献。&lt;/li&gt;
&lt;li&gt;输入为 &lt;code&gt;[N=4,C=32,H=28,W=28]&lt;/code&gt;，残差分支输出 &lt;code&gt;[4,64,14,14]&lt;/code&gt;。设计最小投影捷径，写出卷积核、步幅、权重张量形状和输出形状，并说明为何 &lt;code&gt;nn.Identity()&lt;/code&gt; 不能使用。&lt;/li&gt;
&lt;li&gt;各实现一个 &lt;code&gt;PostNormBlock&lt;/code&gt; 与 &lt;code&gt;PreNormBlock&lt;/code&gt;，共享同一残差分支和初始参数。堆叠 24 层后，对同一标量损失记录每层输入梯度范数；比较曲线前先确认两种块的最终归一化位置。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2016/html/He_Deep_Residual_Learning_CVPR_2016_paper.html&quot;&gt;He et al. (2016), Deep Residual Learning for Image Recognition&lt;/a&gt;：提出残差学习框架，系统展示深层网络的退化问题与恒等捷径。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1603.05027&quot;&gt;He et al. (2016), Identity Mappings in Deep Residual Networks&lt;/a&gt;：分析恒等映射与全预激活残差单元的前向、反向传播路径。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1505.00387&quot;&gt;Srivastava et al. (2015), Highway Networks&lt;/a&gt;：用门控机制在变换与携带路径之间选择，为极深网络提供另一种路径设计。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2017/html/Huang_Densely_Connected_Convolutional_CVPR_2017_paper.html&quot;&gt;Huang et al. (2017), Densely Connected Convolutional Networks&lt;/a&gt;：以特征拼接连接所有先前层，展示不同于残差相加的特征复用方式。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v119/xiong20b.html&quot;&gt;Xiong et al. (2020), On Layer Normalization in the Transformer Architecture&lt;/a&gt;：从梯度角度比较 Transformer 中 Post-LN 与 Pre-LN 的优化行为。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;残差连接让更深的特征提取器更容易训练，但本文的卷积仍只在局部窗口内共享权重。下一篇将从图像平移出发，拆解卷积的局部连接、权重共享、感受野与输出尺寸，说明 CNN 如何把二维空间结构写进模型。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>同一个张量究竟该沿哪条轴标准化？BatchNorm 与 LayerNorm 的训练/推理差异</title><link>https://zwjcode.cn/blog/batchnorm-layernorm-statistical-axes</link><guid isPermaLink="true">https://zwjcode.cn/blog/batchnorm-layernorm-statistical-axes</guid><description>从批量大小变化引发的预测漂移出发，手算 BatchNorm 与 LayerNorm 的统计轴，拆解运行统计量、仿射参数和 PyTorch 2.13 训练/推理语义。</description><pubDate>Wed, 02 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 Momentum 与 Adam 重塑参数更新，但优化器只能处理反向传来的梯度。随着权重不断变化，中间激活的尺度仍可能漂移：同一学习率在不同层代表不同大小的函数变化，深层网络也更容易进入激活饱和或数值不稳定区域。&lt;/p&gt;
&lt;p&gt;归一化层（Normalization Layer）试图在网络内部重新控制张量的尺度。真正困难的不是背下“减均值、除标准差”，而是回答：&lt;strong&gt;均值和方差究竟沿哪些轴计算？训练和推理时又使用谁的统计量？&lt;/strong&gt; 本文只比较批归一化（Batch Normalization，BatchNorm）与层归一化（Layer Normalization，LayerNorm），把一个形状为 &lt;code&gt;[N, C, L]&lt;/code&gt; 的张量逐轴拆开。&lt;/p&gt;
&lt;h2&gt;01 为什么“把整个张量标准化”会出错？&lt;/h2&gt;
&lt;p&gt;设一批序列特征 &lt;code&gt;x&lt;/code&gt; 的形状为 &lt;code&gt;[N,C,L]&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$N$：batch 中的样本数；&lt;/li&gt;
&lt;li&gt;$C$：通道或特征数；&lt;/li&gt;
&lt;li&gt;$L$：每个样本的序列位置数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若直接对全部 $NCL$ 个数求一个均值和方差，通道 0 与通道 1 会互相改变尺度；一个样本也会影响另一个样本。这样虽然能让全局均值接近 0，却可能破坏“每个通道代表不同特征”的语义。&lt;/p&gt;
&lt;p&gt;BatchNorm 与 LayerNorm 使用同一基础公式：&lt;/p&gt;
&lt;p&gt;$$
\hat{x}&lt;em&gt;i=\frac{x_i-\mu&lt;/em&gt;{S(i)}}{\sqrt{\sigma^2_{S(i)}+\epsilon}},
\qquad
y_i=\gamma_i\hat{x}_i+\beta_i
$$&lt;/p&gt;
&lt;p&gt;差别全在集合 $S(i)$：为了计算位置 $i$ 的统计量，哪些元素被分到同一组？$\epsilon$ 是防止分母过小的数值稳定项；$\gamma,\beta$ 是可学习的缩放和平移，使网络在需要时能够恢复或重塑归一化后的尺度。&lt;/p&gt;
&lt;h2&gt;02 BatchNorm 沿哪些轴计算？&lt;/h2&gt;
&lt;p&gt;对 &lt;code&gt;BatchNorm1d(C)&lt;/code&gt; 的三维输入 $X\in\mathbb{R}^{N\times C\times L}$，每个通道 $c$ 单独计算：&lt;/p&gt;
&lt;p&gt;$$
\mu_c=\frac{1}{NL}\sum_{n=1}^{N}\sum_{l=1}^{L}X_{ncl}
$$&lt;/p&gt;
&lt;p&gt;$$
\sigma_c^2=\frac{1}{NL}\sum_{n=1}^{N}\sum_{l=1}^{L}
(X_{ncl}-\mu_c)^2
$$&lt;/p&gt;
&lt;p&gt;$$
Y_{ncl}=\gamma_c
\frac{X_{ncl}-\mu_c}{\sqrt{\sigma_c^2+\epsilon}}+\beta_c
$$&lt;/p&gt;
&lt;p&gt;也就是保留 $C$ 轴，沿 $N,L$ 轴聚合。$\mu,\sigma^2,\gamma,\beta$ 的形状都是 &lt;code&gt;[C]&lt;/code&gt;，通过广播作用到 &lt;code&gt;[N,C,L]&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;BatchNorm1d：同一通道跨样本、跨位置统计

X [N,C,L]
     │
     ├─ channel 0: X[:,0,:] ─► mean₀,var₀ ─► γ₀,β₀
     ├─ channel 1: X[:,1,:] ─► mean₁,var₁ ─► γ₁,β₁
     └─ channel c: X[:,c,:] ─► mean_c,var_c ─► γ_c,β_c

归约轴：(N,L)       保留轴：C       输出：Y [N,C,L]
一个样本的输出会受同一 batch 中其他样本影响。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对图像 &lt;code&gt;BatchNorm2d(C)&lt;/code&gt; 的输入 &lt;code&gt;[N,C,H,W]&lt;/code&gt;，逻辑相同：保留通道 $C$，沿 &lt;code&gt;(N,H,W)&lt;/code&gt; 统计。这里的 “1d/2d” 描述输入的空间结构，不是“只对一维向量求均值”。&lt;/p&gt;
&lt;h2&gt;03 LayerNorm 沿哪些轴计算？&lt;/h2&gt;
&lt;p&gt;对序列模型常见输入 $X\in\mathbb{R}^{N\times L\times D}$，使用 &lt;code&gt;LayerNorm(D)&lt;/code&gt; 时，每个样本、每个 token 都沿最后一个特征轴独立计算：&lt;/p&gt;
&lt;p&gt;$$
\mu_{nl}=\frac{1}{D}\sum_{d=1}^{D}X_{nld}
$$&lt;/p&gt;
&lt;p&gt;$$
\sigma_{nl}^2=\frac{1}{D}\sum_{d=1}^{D}
(X_{nld}-\mu_{nl})^2
$$&lt;/p&gt;
&lt;p&gt;$$
Y_{nld}=\gamma_d
\frac{X_{nld}-\mu_{nl}}{\sqrt{\sigma_{nl}^2+\epsilon}}+\beta_d
$$&lt;/p&gt;
&lt;p&gt;$\mu,\sigma^2$ 的可广播形状是 &lt;code&gt;[N,L,1]&lt;/code&gt;；$\gamma,\beta$ 的形状是 &lt;code&gt;[D]&lt;/code&gt;。统计量不跨 batch，也不跨 token，所以改变其他样本不会改变当前 token 的输出。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;LayerNorm(D)：每个 token 在自己的 D 个特征内统计

X [N,L,D]
     │
     ├─ X[0,0,:] ─► mean₀₀,var₀₀ ─► 同一组 γ[:],β[:]
     ├─ X[0,1,:] ─► mean₀₁,var₀₁ ─► 同一组 γ[:],β[:]
     └─ X[n,l,:] ─► mean_nl,var_nl ─► 同一组 γ[:],β[:]

归约轴：最后的 D     保留轴：(N,L)     输出：Y [N,L,D]
每个 token 的统计量只由自身特征决定。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LayerNorm((C,H,W))&lt;/code&gt; 则沿输入最后三个维度一起统计，且可学习参数形状也是 &lt;code&gt;[C,H,W]&lt;/code&gt;。因此不能只说“LayerNorm 沿通道归一化”；必须同时写出 &lt;code&gt;normalized_shape&lt;/code&gt; 和输入布局。&lt;/p&gt;
&lt;h2&gt;04 用四个数手算：同一输入为何得到两种答案？&lt;/h2&gt;
&lt;p&gt;先忽略 $\epsilon$，令 $\gamma=1,\beta=0$。考虑两样本、两特征矩阵：&lt;/p&gt;
&lt;p&gt;$$
X=
\begin{bmatrix}
1 &amp;#x26; 3\
5 &amp;#x26; 7
\end{bmatrix}
\quad [N=2,D=2]
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BatchNorm：逐列跨样本。&lt;/strong&gt; 第一列 &lt;code&gt;[1,5]&lt;/code&gt; 的均值为 3、方差为 4；第二列 &lt;code&gt;[3,7]&lt;/code&gt; 的均值为 5、方差也为 4。因此：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{BN}(X)=
\begin{bmatrix}
-1 &amp;#x26; -1\
1 &amp;#x26; 1
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LayerNorm：逐行跨特征。&lt;/strong&gt; 第一行 &lt;code&gt;[1,3]&lt;/code&gt; 的均值为 2、方差为 1；第二行 &lt;code&gt;[5,7]&lt;/code&gt; 的均值为 6、方差为 1。因此：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{LN}(X)=
\begin{bmatrix}
-1 &amp;#x26; 1\
-1 &amp;#x26; 1
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;现在把第二个样本改成 &lt;code&gt;[105,107]&lt;/code&gt;。第一个样本的 LayerNorm 输出仍是 &lt;code&gt;[-1,1]&lt;/code&gt;；BatchNorm 的列统计量却被新样本改变，第一个样本的输出也随之变化。这就是 BatchNorm 的批间耦合（Batch Coupling）。&lt;/p&gt;
&lt;h2&gt;05 BatchNorm 为什么需要两套数据流？&lt;/h2&gt;
&lt;p&gt;训练时，BatchNorm 用当前 mini-batch 的 $\mu_B,\sigma_B^2$ 归一化，同时更新运行均值（Running Mean）与运行方差（Running Variance）：&lt;/p&gt;
&lt;p&gt;$$
\text{running}&lt;em&gt;{new}=(1-m),\text{running}&lt;/em&gt;{old}+m,\text{batch}
$$&lt;/p&gt;
&lt;p&gt;其中 PyTorch 参数 &lt;code&gt;momentum=m&lt;/code&gt; 默认是 0.1。它与上一篇优化器的 Momentum 定义不同：这里 $m$ 越大，新 batch 的权重越高。&lt;/p&gt;
&lt;p&gt;推理时，BatchNorm 默认不再依赖当前 batch，而使用训练期积累的 &lt;code&gt;running_mean&lt;/code&gt; 和 &lt;code&gt;running_var&lt;/code&gt;。这样单样本推理才不会因为“恰好和谁同批”而改变答案。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         ┌─ batch mean/var ─► 当前训练输出
train(): X [N,C,...] ────┤
                         └─ 更新 running_mean/running_var（buffer）

eval():  X [N,C,...] ───── running_mean/running_var ─► 推理输出

γ, β：Parameter，反向传播更新
running_mean, running_var：Buffer，前向时更新，不接收梯度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;model.eval()&lt;/code&gt; 切换的是模块行为；&lt;code&gt;torch.no_grad()&lt;/code&gt; 关闭的是梯度记录。二者不是同一件事：验证时通常两者都需要，少任何一个都可能造成错误或浪费。&lt;/p&gt;
&lt;p&gt;若 &lt;code&gt;track_running_stats=False&lt;/code&gt;，BatchNorm 不保存运行统计量，训练和评估都使用当前 batch 统计量。此时 &lt;code&gt;eval()&lt;/code&gt; 也无法让输出脱离批组成，必须明确接受这一语义。&lt;/p&gt;
&lt;h2&gt;06 LayerNorm 为什么不区分训练和推理统计量？&lt;/h2&gt;
&lt;p&gt;LayerNorm 每次都从当前样本的指定末尾维度计算统计量，不需要跨 batch 积累运行均值或方差。于是同一输入在 &lt;code&gt;train()&lt;/code&gt; 与 &lt;code&gt;eval()&lt;/code&gt; 下，单独看 LayerNorm 模块会使用相同的数据流。&lt;/p&gt;
&lt;p&gt;这让 LayerNorm 适合批量大小变化大、逐样本推理以及序列长度动态变化的模型。但“没有运行统计量”不等于“没有可学习参数”：默认的逐元素 $\gamma,\beta$ 仍通过反向传播训练。&lt;/p&gt;
&lt;p&gt;| 比较维度         | BatchNorm                  | LayerNorm                                  |
| ---------------- | -------------------------- | ------------------------------------------ |
| 典型输入         | CNN 的 &lt;code&gt;[N,C,H,W]&lt;/code&gt;         | Transformer 的 &lt;code&gt;[N,L,D]&lt;/code&gt;                   |
| 统计轴           | 每通道沿 &lt;code&gt;(N,H,W)&lt;/code&gt;         | 每个 token 沿最后的 &lt;code&gt;D&lt;/code&gt;                    |
| 统计量是否跨样本 | 是                         | 否                                         |
| 训练/推理统计    | 当前 batch / running stats | 都来自当前输入                             |
| 仿射参数         | 通常每通道 &lt;code&gt;[C]&lt;/code&gt;           | 每个归一化元素 &lt;code&gt;[D]&lt;/code&gt; 或 &lt;code&gt;normalized_shape&lt;/code&gt; |
| 小 batch 风险    | 统计噪声与训练/推理偏差    | 基本不受 batch 大小影响                    |
| 主要代价         | 依赖批组成与状态同步       | 会消去每个样本归一化轴上的整体尺度信息     |&lt;/p&gt;
&lt;h2&gt;07 不调用归一化层，先写出 NumPy 本体&lt;/h2&gt;
&lt;p&gt;下面让 &lt;code&gt;axis&lt;/code&gt; 明确表达统计轴，并保留维度以便广播：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def normalize(x, *, axis, gamma, beta, eps=1e-5):
    &quot;&quot;&quot;x、gamma、beta 均可广播；axis 是被归约的轴。&quot;&quot;&quot;
    mean = x.mean(axis=axis, keepdims=True)
    variance = x.var(axis=axis, keepdims=True, ddof=0)
    x_hat = (x - mean) / np.sqrt(variance + eps)
    return gamma * x_hat + beta, mean, variance

x = np.array([[1.0, 3.0], [5.0, 7.0]])  # [N=2,D=2]

# BatchNorm：沿 N 统计，gamma/beta 对应 D
bn, bn_mean, bn_var = normalize(
    x,
    axis=(0,),
    gamma=np.ones((1, 2)),
    beta=np.zeros((1, 2)),
)

# LayerNorm：沿 D 统计，gamma/beta 仍对应 D
ln, ln_mean, ln_var = normalize(
    x,
    axis=(-1,),
    gamma=np.ones((1, 2)),
    beta=np.zeros((1, 2)),
)

assert bn.shape == ln.shape == x.shape
np.testing.assert_allclose(bn_mean, [[3.0, 5.0]])
np.testing.assert_allclose(ln_mean, [[2.0], [6.0]])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个函数只复现单次前向，没有 BatchNorm 的运行统计量、可学习参数、反向传播和分布式同步。它的价值是让 &lt;code&gt;axis&lt;/code&gt;、&lt;code&gt;keepdims&lt;/code&gt; 和广播关系可以直接检查，而不是替代框架实现。&lt;/p&gt;
&lt;h2&gt;08 完整训练与推理伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练 BatchNorm：
    设置 model.train()
    对每个 mini-batch X：
        沿“除通道外”的指定轴计算 batch mean/variance
        用 batch 统计量归一化并做 γ、β 仿射变换
        用 momentum 更新 running mean/variance
        完成后续前向、loss、backward、optimizer.step

验证 BatchNorm：
    设置 model.eval()
    进入 no_grad 上下文
    用冻结的 running mean/variance 归一化
    不更新 running stats，不更新参数

LayerNorm：
    train/eval 都沿 normalized_shape 对应的末尾轴计算当前输入统计量
    只有 γ、β 随训练更新；没有 running stats
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;09 用 PyTorch 2.13 正确落地&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 官方 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.BatchNorm1d.html&quot;&gt;&lt;code&gt;BatchNorm1d&lt;/code&gt;&lt;/a&gt; 接收 &lt;code&gt;[N,C]&lt;/code&gt; 或 &lt;code&gt;[N,C,L]&lt;/code&gt;，&lt;code&gt;num_features&lt;/code&gt; 必须等于 $C$。官方 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.nn.LayerNorm.html&quot;&gt;&lt;code&gt;LayerNorm&lt;/code&gt;&lt;/a&gt; 则对 &lt;code&gt;normalized_shape&lt;/code&gt; 指定的最后 $D$ 个维度求统计量。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

# 卷积/通道布局：[N,C,L]
conv_block = nn.Sequential(
    nn.Conv1d(in_channels=8, out_channels=16, kernel_size=3, padding=1),
    nn.BatchNorm1d(num_features=16, eps=1e-5, momentum=0.1),
    nn.ReLU(),
)
channels_first = torch.randn(32, 8, 50)       # [N=32,C=8,L=50]
conv_output = conv_block(channels_first)       # [32,16,50]

# 序列/特征布局：[N,L,D]
token_block = nn.Sequential(
    nn.Linear(64, 64),
    nn.LayerNorm(normalized_shape=64, eps=1e-5),
    nn.GELU(),
)
tokens = torch.randn(32, 50, 64)               # [N=32,L=50,D=64]
token_output = token_block(tokens)              # [32,50,64]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LayerNorm(64)&lt;/code&gt; 只检查并归一化最后一维。若输入误写成 &lt;code&gt;[N,D,L]&lt;/code&gt;，最后一维是 $L$，要么尺寸不匹配直接报错，要么尺寸碰巧相同而静默归一化错轴。进入层前用断言固定布局：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert tokens.ndim == 3 and tokens.shape[-1] == 64
token_output = token_block(tokens)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方 API 中，&lt;code&gt;BatchNorm1d(..., affine=True, bias=True)&lt;/code&gt; 默认学习每通道缩放和偏置；&lt;code&gt;LayerNorm(..., elementwise_affine=True, bias=True)&lt;/code&gt; 默认学习 &lt;code&gt;normalized_shape&lt;/code&gt; 大小的逐元素缩放和偏置。&lt;code&gt;bias=False&lt;/code&gt; 只关闭加性偏置，不等于关闭全部仿射参数。&lt;/p&gt;
&lt;h2&gt;10 怎样验证训练/推理切换没有写错？&lt;/h2&gt;
&lt;p&gt;用固定样本做三个检查：同批重复、换同伴、切模式。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

torch.manual_seed(7)
bn = nn.BatchNorm1d(2)

anchor = torch.tensor([[1.0, 3.0]])
near_partner = torch.tensor([[5.0, 7.0]])
far_partner = torch.tensor([[105.0, 107.0]])

bn.train()
with torch.no_grad():
    train_near = bn(torch.cat([anchor, near_partner]))[0]
    train_far = bn(torch.cat([anchor, far_partner]))[0]

assert not torch.allclose(train_near, train_far)

bn.eval()
with torch.no_grad():
    eval_single = bn(anchor)
    eval_with_partner = bn(torch.cat([anchor, far_partner]))[:1]

torch.testing.assert_close(eval_single, eval_with_partner)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;训练阶段两次调用还会分别更新 running stats，所以这个例子用于验证“批组成会影响当前训练输出”，不是比较完全相同的模块状态。要做严格 A/B，应复制同一 &lt;code&gt;state_dict&lt;/code&gt; 到两个模块后各前向一次。&lt;/p&gt;
&lt;p&gt;部署前还应检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;model.training&lt;/code&gt; 与每个归一化子模块的 &lt;code&gt;.training&lt;/code&gt; 是否符合预期；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;running_mean&lt;/code&gt;、&lt;code&gt;running_var&lt;/code&gt;、&lt;code&gt;num_batches_tracked&lt;/code&gt; 是否有限且已更新；&lt;/li&gt;
&lt;li&gt;训练与部署的通道布局、预处理和输入尺度是否一致；&lt;/li&gt;
&lt;li&gt;校准数据跑过后的指标是否比随机初始化 running stats 更合理；&lt;/li&gt;
&lt;li&gt;分布式训练中，各设备局部 batch 是否小到需要 &lt;code&gt;SyncBatchNorm&lt;/code&gt; 或其他方案。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;11 最常见的错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘记 &lt;code&gt;model.eval()&lt;/code&gt;。&lt;/strong&gt; 单样本服务仍使用当前 batch 统计量，输出随请求拼批方式漂移。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;no_grad()&lt;/code&gt; 当成 &lt;code&gt;eval()&lt;/code&gt;。&lt;/strong&gt; 梯度虽不记录，BatchNorm 仍可能更新 running stats，Dropout 也仍随机。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;轴与内存布局混淆。&lt;/strong&gt; &lt;code&gt;BatchNorm1d(C)&lt;/code&gt; 期待通道在第 2 维；&lt;code&gt;LayerNorm(D)&lt;/code&gt; 期待被归一化维度在末尾。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;batch 太小。&lt;/strong&gt; BatchNorm 的估计噪声大；&lt;code&gt;N=1&lt;/code&gt; 且每通道只有一个值时，训练统计甚至无法成立。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 BN 的 &lt;code&gt;momentum&lt;/code&gt; 当优化器动量。&lt;/strong&gt; 其更新式权重方向相反；调参前先写出运行统计更新公式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只加载权重、不加载 buffer。&lt;/strong&gt; 不完整 checkpoint 会丢失 BatchNorm 的运行统计量；应加载完整 &lt;code&gt;state_dict&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;冻结参数却仍污染统计量。&lt;/strong&gt; &lt;code&gt;requires_grad=False&lt;/code&gt; 不会阻止 BN buffer 在训练模式更新；需单独管理模块模式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在梯度累积中误判有效 batch。&lt;/strong&gt; BN 每次前向只看 micro-batch，不会因为累积多步梯度就自动得到大 batch 统计量。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最短路径：打印输入形状与归约轴 → 在归一化前后打印逐组均值/方差 → 固定 anchor 更换 batch 同伴 → 分别跑 &lt;code&gt;train()&lt;/code&gt;/&lt;code&gt;eval()&lt;/code&gt; → 检查 state_dict 中参数与 buffer → 最后再比较端到端指标。&lt;/p&gt;
&lt;h2&gt;12 它们各自会在哪里失败？&lt;/h2&gt;
&lt;p&gt;BatchNorm 在小 batch、非独立同分布 batch、在线学习和单样本自回归推理中容易产生统计不稳或训练/部署错位。领域分布改变时，旧 running stats 也可能不再代表线上数据。&lt;/p&gt;
&lt;p&gt;LayerNorm 不依赖 batch，但会消去每个样本在归一化轴上的共同平移与整体尺度；如果任务恰好需要这些绝对幅度信息，模型必须从旁路或其他特征重新获得。它也不是数值问题的万能修复：输入已有 NaN/Inf 时，增大 &lt;code&gt;eps&lt;/code&gt; 通常只会掩盖而非解决根因。&lt;/p&gt;
&lt;p&gt;邻近方法不能仅凭名字互换：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;实例归一化（Instance Normalization）通常对每个样本、每通道沿空间轴统计，常见于风格迁移；&lt;/li&gt;
&lt;li&gt;组归一化（Group Normalization）把通道分组，在每个样本内沿组内通道与空间轴统计，适合小 batch 视觉任务；&lt;/li&gt;
&lt;li&gt;RMSNorm 只按均方根缩放，通常不减均值，也没有 BatchNorm 的运行统计量；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SyncBatchNorm&lt;/code&gt; 跨分布式进程同步 batch 统计量，能扩大统计样本，但增加通信成本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;选择归一化方法的第一步永远是写清楚输入布局、归约轴和部署时可用的信息，而不是先从模型名字推断。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;归一化的核心选择是统计集合：BatchNorm 保留通道、跨 batch 聚合；LayerNorm 保留样本位置、沿末尾特征聚合。&lt;/li&gt;
&lt;li&gt;BatchNorm 训练时使用 batch 统计并更新 buffer，推理时默认使用 running stats；LayerNorm 两种模式都从当前输入计算。&lt;/li&gt;
&lt;li&gt;$\gamma,\beta$ 是可训练参数，running mean/variance 是 buffer；它们必须一起进入完整 checkpoint。&lt;/li&gt;
&lt;li&gt;写出 &lt;code&gt;[N,C,L]&lt;/code&gt; 或 &lt;code&gt;[N,L,D]&lt;/code&gt; 及每个中间张量形状，是发现错轴、错布局和广播错误最快的方法。&lt;/li&gt;
&lt;li&gt;BatchNorm 与 LayerNorm 改变了优化几何和网络可表达方式，但都不能替代正确初始化、学习率、数据处理与数值诊断。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 $X=[[0,2],[2,4],[4,6]]$，忽略 $\epsilon$ 且令 $\gamma=1,\beta=0$，分别手算 BatchNorm 与 LayerNorm 输出。将第三个样本改为 &lt;code&gt;[40,60]&lt;/code&gt; 后，哪些输出会变化？&lt;/li&gt;
&lt;li&gt;给定输入 &lt;code&gt;[N=4,C=3,H=2,W=2]&lt;/code&gt;，分别写出 &lt;code&gt;BatchNorm2d(3)&lt;/code&gt; 与 &lt;code&gt;LayerNorm((3,2,2))&lt;/code&gt; 的统计量形状、仿射参数形状和归约轴，并计算各自每个统计组包含多少个数。&lt;/li&gt;
&lt;li&gt;训练一个含 BatchNorm 的微型网络，分别只保存 &lt;code&gt;named_parameters()&lt;/code&gt; 与保存完整 &lt;code&gt;state_dict()&lt;/code&gt;；恢复后用同一输入比较 &lt;code&gt;eval()&lt;/code&gt; 输出，并定位差异来自哪个 buffer。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v37/ioffe15.html&quot;&gt;Ioffe &amp;#x26; Szegedy (2015), Batch Normalization&lt;/a&gt;：提出用 mini-batch 统计量归一化中间激活及运行统计推理。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1607.06450&quot;&gt;Ba, Kiros &amp;#x26; Hinton (2016), Layer Normalization&lt;/a&gt;：改为在单个样本内部沿特征统计，摆脱对 batch 的依赖。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1607.08022&quot;&gt;Ulyanov, Vedaldi &amp;#x26; Lempitsky (2016), Instance Normalization&lt;/a&gt;：展示逐实例、逐通道归一化对快速风格化的作用。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_ECCV_2018/html/Yuxin_Wu_Group_Normalization_ECCV_2018_paper.html&quot;&gt;Wu &amp;#x26; He (2018), Group Normalization&lt;/a&gt;：以通道分组替代 batch 统计，改善小批量视觉训练。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2019/hash/1e8a19426224ca89e83cef47f1e7f53b-Abstract.html&quot;&gt;Zhang &amp;#x26; Sennrich (2019), Root Mean Square Layer Normalization&lt;/a&gt;：讨论省略重中心化的 RMSNorm。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;归一化能控制每个块内部的尺度，却没有解决“网络越深，信息必须穿过越多非线性变换”的路径问题。下一篇将研究残差连接如何建立恒等捷径，手算前向叠加与反向梯度分流，并比较普通残差块和预归一化结构。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>梯度方向反复横跳怎么办？Momentum 与 Adam 如何重塑更新步长</title><link>https://zwjcode.cn/blog/momentum-adam-optimizer-update-geometry</link><guid isPermaLink="true">https://zwjcode.cn/blog/momentum-adam-optimizer-update-geometry</guid><description>从狭长谷底中的 SGD 振荡出发，手算 Momentum 与 Adam 的状态更新、偏差修正和逐参数缩放，并用 PyTorch 2.13 构建可调试训练循环。</description><pubDate>Tue, 01 Sep 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 Xavier/He 初始化守住了训练起点的信号尺度。现在反向传播能给出每个参数的梯度 $g_t$，但“知道当前位置最陡的下坡方向”不等于“能快速走到谷底”：在狭长曲面中，普通随机梯度下降（Stochastic Gradient Descent，SGD）会横向来回摆动，纵向却进展缓慢。&lt;/p&gt;
&lt;p&gt;本文只研究一个核心问题：&lt;strong&gt;优化器怎样把当前梯度和历史状态组合成真正的参数更新？&lt;/strong&gt; 我们从 Momentum 的方向平滑走到 Adam 的逐参数尺度适配，手算状态、写出张量数据流，并明确 PyTorch 2.13 中最容易被忽略的实现语义。&lt;/p&gt;
&lt;h2&gt;01 同一个学习率为什么顾不过来两个方向？&lt;/h2&gt;
&lt;p&gt;考虑二维二次目标：&lt;/p&gt;
&lt;p&gt;$$
L(x,y)=50x^2+\frac12y^2
$$&lt;/p&gt;
&lt;p&gt;梯度为：&lt;/p&gt;
&lt;p&gt;$$
\nabla L(x,y)=(100x, y)
$$&lt;/p&gt;
&lt;p&gt;$x$ 方向曲率很大，稍微偏离就产生大梯度；$y$ 方向平缓，梯度很小。SGD 更新：&lt;/p&gt;
&lt;p&gt;$$
\theta_t=\theta_{t-1}-\eta g_t
$$&lt;/p&gt;
&lt;p&gt;若学习率 $eta$ 足够大以快速推进 $y$，$x$ 可能越过谷底甚至发散；若把 $eta$ 降到稳定，$y$ 又移动得很慢。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;等高线中的更新轨迹（示意）

陡峭 x 方向  ◄────────►
             ╲       ╱
              ╲  SGD╱   左右梯度交替，更新抵消
               ╲ ╱
                │
                │  平缓 y 方向：真正希望持续前进
                ▼

Momentum：削弱反复变号的横向分量，积累方向一致的纵向分量
Adam：再按每个参数近期梯度平方的尺度归一化更新
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mini-batch 噪声还会让 $g_t$ 抖动。我们需要的不是抛弃梯度，而是为每个参数保存少量历史状态，将短期噪声与长期方向分开。&lt;/p&gt;
&lt;h2&gt;02 Momentum 怎样积累“速度”？&lt;/h2&gt;
&lt;p&gt;动量法（Momentum）维护与参数同形的缓冲 $v_t$。一种常见写法是：&lt;/p&gt;
&lt;p&gt;$$
v_t=\mu v_{t-1}+g_t
$$&lt;/p&gt;
&lt;p&gt;$$
\theta_t=\theta_{t-1}-\eta v_t
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\theta_t\in\mathbb{R}^{P}$：第 $t$ 步后的全部参数；&lt;/li&gt;
&lt;li&gt;$g_t=\nabla_\theta L_t(\theta_{t-1})\in\mathbb{R}^{P}$：当前 mini-batch 梯度；&lt;/li&gt;
&lt;li&gt;$v_t\in\mathbb{R}^{P}$：动量缓冲，与参数逐元素对应；&lt;/li&gt;
&lt;li&gt;$\eta&gt;0$：学习率；$\mu\in[0,1)$：动量系数，常见起点是 0.9。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;展开递推：&lt;/p&gt;
&lt;p&gt;$$
v_t=g_t+\mu g_{t-1}+\mu^2g_{t-2}+\cdots
$$&lt;/p&gt;
&lt;p&gt;越早的梯度按指数衰减。若某方向的梯度一直同号，贡献会累积；若正负交替，贡献会互相抵消。这正好对应狭长谷底中的“纵向加速、横向减振”。&lt;/p&gt;
&lt;h2&gt;03 用两维梯度手算三步 Momentum&lt;/h2&gt;
&lt;p&gt;设 $\eta=0.1,\mu=0.9,v_0=(0,0),\theta_0=(0,0)$，三步梯度为：&lt;/p&gt;
&lt;p&gt;$$
g_1=(4,1),\quad g_2=(-4,1),\quad g_3=(4,1)
$$&lt;/p&gt;
&lt;p&gt;第一个分量反复变号，第二个始终为正。&lt;/p&gt;
&lt;p&gt;第 1 步：&lt;/p&gt;
&lt;p&gt;$$
v_1=(4,1),\qquad\theta_1=(-0.4,-0.1)
$$&lt;/p&gt;
&lt;p&gt;第 2 步：&lt;/p&gt;
&lt;p&gt;$$
v_2=0.9(4,1)+(-4,1)=(-0.4,1.9)
$$&lt;/p&gt;
&lt;p&gt;$$
\theta_2=(-0.4,-0.1)-0.1(-0.4,1.9)=(-0.36,-0.29)
$$&lt;/p&gt;
&lt;p&gt;第 3 步：&lt;/p&gt;
&lt;p&gt;$$
v_3=0.9(-0.4,1.9)+(4,1)=(3.64,2.71)
$$&lt;/p&gt;
&lt;p&gt;$$
\theta_3=(-0.724,-0.561)
$$&lt;/p&gt;
&lt;p&gt;三步原始梯度求和为 $(4,3)$；动量缓冲末值为 $(3.64,2.71)$。第一个方向因反复变号没有无界积累，第二个方向从 1 增至 2.71。注意 Momentum 并不知道哪个方向是“正确的”，它只利用了梯度方向的时间一致性。&lt;/p&gt;
&lt;h2&gt;04 Nesterov 为什么要在“将要到达的位置”看梯度？&lt;/h2&gt;
&lt;p&gt;Nesterov 动量（Nesterov Accelerated Gradient，NAG）的思想是先按历史速度向前看，再在预估位置计算梯度。不同教材和框架会使用代数等价或尺度不同的缓冲定义，因此代码审查时不能只凭变量名 &lt;code&gt;velocity&lt;/code&gt; 判断公式。&lt;/p&gt;
&lt;p&gt;概念形式可写为：&lt;/p&gt;
&lt;p&gt;$$
g_t=\nabla L(\theta_{t-1}-\eta\mu v_{t-1})
$$&lt;/p&gt;
&lt;p&gt;$$
v_t=\mu v_{t-1}+g_t,\qquad
\theta_t=\theta_{t-1}-\eta v_t
$$&lt;/p&gt;
&lt;p&gt;“提前看”可以更早纠正高速越过谷底的趋势。但 Nesterov 不是免费提速开关；学习率和动量仍需一起验证，而且应以所用框架的官方算法说明为准。&lt;/p&gt;
&lt;h2&gt;05 Adam 为什么还要记录梯度平方？&lt;/h2&gt;
&lt;p&gt;Momentum 对所有参数使用同一全局学习率。Adam（Adaptive Moment Estimation）再维护梯度的一阶矩与二阶原始矩指数平均：&lt;/p&gt;
&lt;p&gt;$$
m_t=\beta_1m_{t-1}+(1-\beta_1)g_t
$$&lt;/p&gt;
&lt;p&gt;$$
v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2
$$&lt;/p&gt;
&lt;p&gt;平方是逐元素的。$m_t$ 平滑方向，$v_t$ 估计每个参数近期梯度平方尺度。最终更新为：&lt;/p&gt;
&lt;p&gt;$$
\theta_t=\theta_{t-1}-\eta\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}
$$&lt;/p&gt;
&lt;p&gt;其中 $m_t,v_t,\hat m_t,\hat v_t$ 都与参数同形。梯度长期较大的参数分母也大，单步会被缩小；稀疏或尺度较小的方向可能得到相对更大的有效步长。&lt;/p&gt;
&lt;p&gt;这不是近似 Hessian 的完整二阶优化。Adam 只使用逐坐标的梯度平方，没有保存参数间的曲率耦合。&lt;/p&gt;
&lt;h2&gt;06 为什么必须做偏差修正？&lt;/h2&gt;
&lt;p&gt;$m_0=v_0=0$。训练早期，指数平均会因为从 0 启动而偏小。Adam 使用：&lt;/p&gt;
&lt;p&gt;$$
\hat m_t=\frac{m_t}{1-\beta_1^t},\qquad
\hat v_t=\frac{v_t}{1-\beta_2^t}
$$&lt;/p&gt;
&lt;p&gt;用一个常梯度 $g_t=2$ 的标量例子，取 $\beta_1=0.9,\beta_2=0.99$。&lt;/p&gt;
&lt;p&gt;第 1 步：&lt;/p&gt;
&lt;p&gt;$$
m_1=0.1\times2=0.2,qquad v_1=0.01\times4=0.04
$$&lt;/p&gt;
&lt;p&gt;修正前两者明显小于真实一、二阶矩；修正后：&lt;/p&gt;
&lt;p&gt;$$
\hat m_1=\frac{0.2}{0.1}=2,qquad
\hat v_1=\frac{0.04}{0.01}=4
$$&lt;/p&gt;
&lt;p&gt;忽略很小的 $\epsilon$，第 1 步更新量为：&lt;/p&gt;
&lt;p&gt;$$
\eta\frac{2}{\sqrt4}=\eta
$$&lt;/p&gt;
&lt;p&gt;若漏掉偏差修正，早期有效步长会被错误地改变，尤其 $\beta_2$ 很接近 1 时更明显。&lt;/p&gt;
&lt;h2&gt;07 两个参数尺度相差百倍时会怎样？&lt;/h2&gt;
&lt;p&gt;设同一步梯度为 $g_1=(0.1,10)$，$\beta_1=0.9,\beta_2=0.999$。第 1 步偏差修正后：&lt;/p&gt;
&lt;p&gt;$$
\hat m_1=(0.1,10),\qquad
\hat v_1=(0.01,100)
$$&lt;/p&gt;
&lt;p&gt;于是：&lt;/p&gt;
&lt;p&gt;$$
\frac{\hat m_1}{\sqrt{\hat v_1}+\epsilon}
\approx(1,1)
$$&lt;/p&gt;
&lt;p&gt;Adam 的首次更新几乎只保留符号，两个参数都走约一个 $\eta$。这解释了它对梯度尺度差异的适应性，也揭示一个限制：参数真实需要的函数空间步长未必应该相同；逐坐标归一化可能改变隐含优化偏好。&lt;/p&gt;
&lt;h2&gt;08 从梯度到更新的完整数据流&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D] ─► model(θ) ─► prediction [N,C] ─► loss []
                                              │
                                              ▼ backward
                                     gradient g_t，形状同 θ
                                              │
                         ┌────────────────────┴───────────────────┐
                         ▼                                        ▼
Momentum: v_t = μv_{t-1}+g_t       Adam: m_t, v_t, step=t
                         │                    │ 偏差修正 + 逐元素除法
                         └────────────────────┬───────────────────┘
                                              ▼
                                      update Δθ，形状同 θ
                                              │
                                              ▼
                                         θ ← θ - Δθ

optimizer state 不是梯度：
参数 θ [P]；梯度 g [P]；Momentum 额外约 [P]；Adam 额外约 [2P] + step
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以十亿参数模型为例，仅 Adam 的两个同精度状态就约等于额外二十亿个数，还未计参数、梯度、主权重副本和激活。这是选择优化器时真实的显存/内存成本。&lt;/p&gt;
&lt;h2&gt;09 不依赖优化器黑盒，写出最小 Adam&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def adam_step(parameter, gradient, state, *, lr=1e-3,
              beta1=0.9, beta2=0.999, eps=1e-8):
    &quot;&quot;&quot;所有数组形状相同；返回新参数与新状态。&quot;&quot;&quot;
    step = state[&quot;step&quot;] + 1
    first = beta1 * state[&quot;first&quot;] + (1.0 - beta1) * gradient
    second = beta2 * state[&quot;second&quot;] + (1.0 - beta2) * gradient**2

    first_hat = first / (1.0 - beta1**step)
    second_hat = second / (1.0 - beta2**step)
    update = lr * first_hat / (np.sqrt(second_hat) + eps)
    new_parameter = parameter - update

    new_state = {&quot;step&quot;: step, &quot;first&quot;: first, &quot;second&quot;: second}
    return new_parameter, new_state

parameter = np.array([1.0, -1.0])
state = {
    &quot;step&quot;: 0,
    &quot;first&quot;: np.zeros_like(parameter),
    &quot;second&quot;: np.zeros_like(parameter),
}

parameter, state = adam_step(
    parameter,
    gradient=np.array([0.1, 10.0]),
    state=state,
    lr=0.01,
)
np.testing.assert_allclose(parameter, [0.99, -1.01], rtol=0, atol=1e-7)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个实现刻意没有权重衰减、AMSGrad、稀疏梯度和混合精度分支，目的是让每个状态可手查。生产代码应使用经过测试的官方优化器，但先理解状态转移，才能解释 checkpoint、恢复训练与显存占用。&lt;/p&gt;
&lt;h2&gt;10 用 PyTorch 2.13 正确落地&lt;/h2&gt;
&lt;p&gt;当前 PyTorch 2.13 官方 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.optim.SGD.html&quot;&gt;&lt;code&gt;torch.optim.SGD&lt;/code&gt;&lt;/a&gt; 与 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/generated/torch.optim.Adam.html&quot;&gt;&lt;code&gt;torch.optim.Adam&lt;/code&gt;&lt;/a&gt; 都接收参数迭代器，并在 &lt;code&gt;step()&lt;/code&gt; 时读取参数的 &lt;code&gt;.grad&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

model = nn.Sequential(
    nn.Linear(64, 128),
    nn.ReLU(),
    nn.Linear(128, 1),
)
loss_fn = nn.BCEWithLogitsLoss()

# 二选一，而不是同时更新同一批参数
optimizer = torch.optim.Adam(
    model.parameters(),
    lr=3e-4,
    betas=(0.9, 0.999),
    eps=1e-8,
    weight_decay=0.0,
)

model.train()
for features, targets in train_loader:
    # features [N,64] float；targets [N] float，值为 0/1
    optimizer.zero_grad(set_to_none=True)
    logits = model(features).squeeze(-1)       # [N]
    loss = loss_fn(logits, targets)            # []
    loss.backward()                            # 写入 parameter.grad

    grad_norm = torch.nn.utils.clip_grad_norm_(
        model.parameters(), max_norm=5.0,
    )
    if not torch.isfinite(grad_norm):
        raise FloatingPointError(&quot;non-finite gradient&quot;)

    optimizer.step()                           # 更新参数与 optimizer.state
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若选择 Momentum SGD：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;optimizer = torch.optim.SGD(
    model.parameters(),
    lr=0.05,
    momentum=0.9,
    dampening=0.0,
    nesterov=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档指出，PyTorch SGD 的动量缓冲在第一步初始化为当前梯度，而不是全 0；因此第一步动量不受 &lt;code&gt;dampening&lt;/code&gt; 缩放，&lt;code&gt;dampening&lt;/code&gt; 从第二步开始生效。Nesterov 还要求非零 &lt;code&gt;momentum&lt;/code&gt;，并需满足该 API 的参数约束。复现实验时应记录框架、版本和完整优化器参数，不能只写“用了 Momentum”。&lt;/p&gt;
&lt;h2&gt;11 参数组怎样表达“同一模型，不同学习率”？&lt;/h2&gt;
&lt;p&gt;参数组（Parameter Group）允许给不同参数设置不同超参数，例如对预训练骨干使用更小学习率：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;optimizer = torch.optim.AdamW(
    [
        {&quot;params&quot;: backbone.parameters(), &quot;lr&quot;: 1e-5},
        {&quot;params&quot;: head.parameters(), &quot;lr&quot;: 3e-4},
    ],
    betas=(0.9, 0.999),
    weight_decay=0.01,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个参数只能出现在一个参数组。构造后应检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;seen = set()
for group_index, group in enumerate(optimizer.param_groups):
    print(group_index, group[&quot;lr&quot;], group[&quot;weight_decay&quot;])
    for parameter in group[&quot;params&quot;]:
        assert id(parameter) not in seen, &quot;parameter appears twice&quot;
        seen.add(id(parameter))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AdamW&lt;/code&gt; 使用解耦权重衰减（Decoupled Weight Decay）：衰减不先混入 Adam 的一、二阶矩。它与把 $\lambda\theta$ 加进梯度的 L2 惩罚，在自适应优化器中并不等价。偏置和归一化参数是否衰减应由模型与实验决定，不应机械套用。&lt;/p&gt;
&lt;h2&gt;12 保存模型时为什么还必须保存优化器？&lt;/h2&gt;
&lt;p&gt;Momentum 的 $v_t$、Adam 的 $m_t,v_t$ 和步数 $t$ 都会影响下一步更新。只恢复参数 &lt;code&gt;model.state_dict()&lt;/code&gt;，却新建空优化器，相当于中途清空速度、二阶矩和偏差修正时钟。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;checkpoint = {
    &quot;model&quot;: model.state_dict(),
    &quot;optimizer&quot;: optimizer.state_dict(),
    &quot;epoch&quot;: epoch,
    &quot;global_step&quot;: global_step,
}
torch.save(checkpoint, &quot;checkpoint.pt&quot;)

# 恢复时先构造相同模型与优化器，再加载状态
checkpoint = torch.load(&quot;checkpoint.pt&quot;, map_location=&quot;cpu&quot;, weights_only=True)
model.load_state_dict(checkpoint[&quot;model&quot;])
optimizer.load_state_dict(checkpoint[&quot;optimizer&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若还有学习率调度器（Learning-rate Scheduler）和混合精度 scaler，也要一起保存。加载后打印每个参数组学习率，并用连续小数据对比“不中断训练”和“保存后恢复”的下一步结果。&lt;/p&gt;
&lt;h2&gt;13 怎样观察优化器到底做了什么？&lt;/h2&gt;
&lt;p&gt;只看 loss 曲线，无法区分“梯度太小”“学习率太小”和“Adam 分母太大”。每隔一段步数记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;全局与逐层参数范数 $|\theta|$、梯度范数 $|g|$；&lt;/li&gt;
&lt;li&gt;相对更新比 $|\Delta\theta|/(|\theta|+\epsilon)$；&lt;/li&gt;
&lt;li&gt;当前真实学习率（调度后），而不是配置初值；&lt;/li&gt;
&lt;li&gt;Momentum 缓冲范数，或 Adam 的 &lt;code&gt;exp_avg&lt;/code&gt;、&lt;code&gt;exp_avg_sq&lt;/code&gt; 分布；&lt;/li&gt;
&lt;li&gt;梯度非有限、裁剪触发频率与被裁剪前的范数；&lt;/li&gt;
&lt;li&gt;训练 loss、验证指标和 wall-clock/每步耗时。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可在一次 &lt;code&gt;step()&lt;/code&gt; 前后做差：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;before = {
    name: parameter.detach().clone()
    for name, parameter in model.named_parameters()
}
optimizer.step()

for name, parameter in model.named_parameters():
    delta = parameter.detach() - before[name]
    relative = delta.norm() / (before[name].norm() + 1e-12)
    print(name, &quot;update_norm=&quot;, delta.norm().item(),
          &quot;relative_update=&quot;, relative.item())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该方法会复制参数，只适合短期诊断。大模型可按层采样或在优化器状态中读取统计，避免每步翻倍显存。&lt;/p&gt;
&lt;h2&gt;14 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘记 &lt;code&gt;zero_grad&lt;/code&gt;。&lt;/strong&gt; 新梯度会累加，优化器看到的是多批之和；若确实做梯度累积，应按累积步数缩放 loss，并只在边界 &lt;code&gt;step()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先 &lt;code&gt;step()&lt;/code&gt; 后 &lt;code&gt;backward()&lt;/code&gt;。&lt;/strong&gt; 此时没有当前梯度，参数不会按预期更新。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练中途无意重建 optimizer。&lt;/strong&gt; Adam/Momentum 状态被清空；检查 &lt;code&gt;global_step&lt;/code&gt; 和 &lt;code&gt;optimizer.state&lt;/code&gt; 大小。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只调 betas，不先扫学习率。&lt;/strong&gt; 学习率通常是一阶敏感项；先在合理范围做短跑，再精调动量与衰减。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把梯度裁剪当学习率调度。&lt;/strong&gt; 高频裁剪会改变更新方向和尺度；记录触发率并修复发散根因。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adam 的 &lt;code&gt;eps&lt;/code&gt; 在低精度中过小。&lt;/strong&gt; 先确认状态张量 dtype、混合精度策略和非有限值来源，再依据官方实现与硬件调节。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 weight decay 当成完全等价的 L2。&lt;/strong&gt; 对 Adam 应明确使用耦合还是解耦形式，并记录实现。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;恢复 checkpoint 后学习率错位。&lt;/strong&gt; 优化器和 scheduler 的加载顺序、参数组结构必须与保存时一致；恢复后立即打印核对。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最短调试路径：固定一个小 batch → 关闭随机数据增强 → 验证 loss 与梯度有限 → 检查参数确实变化 → 打印真实学习率与更新比 → 比较 SGD、Momentum、Adam 的短轨迹，而不是直接跑完整实验。&lt;/p&gt;
&lt;h2&gt;15 它们会在哪些场景失败？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Momentum 在梯度方向长期错误或学习率过大时会带着惯性冲得更远；&lt;/li&gt;
&lt;li&gt;Adam 对超参数更宽容不等于无需调参，也不保证验证集泛化优于 SGD；&lt;/li&gt;
&lt;li&gt;稀疏参数、嵌入表和超大模型可能受优化器状态内存限制，需要专用稀疏或分片方案；&lt;/li&gt;
&lt;li&gt;强噪声、非平稳目标会让历史矩过时，$\beta$ 太大时适应变慢；&lt;/li&gt;
&lt;li&gt;逐坐标自适应依赖参数化方式，重参数化后轨迹可能明显改变；&lt;/li&gt;
&lt;li&gt;优化训练损失更快，不代表解决数据泄漏、标签噪声、分布偏移或过拟合。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| 方法     | 保存状态           | 每步核心             | 典型优势                   | 主要代价/边界            |
| -------- | ------------------ | -------------------- | -------------------------- | ------------------------ |
| SGD      | 无                 | 当前梯度             | 简单、省内存、基线清晰     | 狭长谷底易振荡           |
| Momentum | 一阶缓冲           | 历史方向平滑         | 抑制交替方向，持续方向加速 | 多一份参数级状态         |
| RMSProp  | 二阶平方平均       | 逐参数尺度归一化     | 适应梯度尺度               | 不含 Adam 式一阶矩组合   |
| Adam     | 一阶 + 二阶 + 步数 | 平滑方向并自适应缩放 | 常见任务起步稳、调试友好   | 约两份状态，泛化并非总优 |
| AdamW    | 同 Adam            | Adam + 解耦衰减      | 衰减语义更清晰             | 衰减率仍需验证           |&lt;/p&gt;
&lt;h2&gt;16 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;SGD 的单一学习率在不同曲率方向间会冲突，mini-batch 噪声又会放大轨迹抖动。&lt;/li&gt;
&lt;li&gt;Momentum 累积方向一致的梯度、抵消反复变号的梯度；缓冲与参数同形。&lt;/li&gt;
&lt;li&gt;Adam 用一阶矩平滑方向、二阶原始矩缩放每个参数，并用 $1-\beta^t$ 修正零初始化偏差。&lt;/li&gt;
&lt;li&gt;优化器是有状态算法；checkpoint 若不保存 optimizer state，就没有真正连续训练。&lt;/li&gt;
&lt;li&gt;选择优化器要同时看验证表现、更新比、状态内存与每步吞吐，不能只比较前几百步训练 loss。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;17 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对梯度序列 $g_1=2,g_2=-2,g_3=2$，手算 $\mu=0.5$ 时三步 Momentum 缓冲和参数变化。再把梯度全改为 2，比较最终速度。&lt;/li&gt;
&lt;li&gt;取 Adam 的 $g_1=1,g_2=3$、$\beta_1=0.5,\beta_2=0.75,\epsilon=0$，手算每一步 $m,v,\hat m,\hat v$ 和单位学习率更新量。&lt;/li&gt;
&lt;li&gt;在同一微型二次问题上分别运行 SGD、Momentum 和 Adam；记录每步参数、梯度、更新比与状态。保存第 20 步 checkpoint，恢复后验证第 21 步与不中断运行完全一致。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.mathnet.ru/eng/zvmmf7713&quot;&gt;Polyak (1964), Some Methods of Speeding Up the Convergence of Iteration Methods&lt;/a&gt;：重球动量方法的经典来源。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.mathnet.ru/eng/dan46009&quot;&gt;Nesterov (1983), A Method for Solving the Convex Programming Problem with Convergence Rate O(1/k²)&lt;/a&gt;：加速梯度方法的早期工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jmlr.org/papers/v12/duchi11a.html&quot;&gt;Duchi, Hazan &amp;#x26; Singer (2011), Adaptive Subgradient Methods for Online Learning and Stochastic Optimization&lt;/a&gt;：AdaGrad 逐坐标自适应学习率的代表性论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openreview.net/forum?id=8gmWwjFyLj&quot;&gt;Kingma &amp;#x26; Ba (2015), Adam: A Method for Stochastic Optimization&lt;/a&gt;：Adam 的一、二阶矩估计与偏差修正。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openreview.net/forum?id=Bkg6RiCqY7&quot;&gt;Loshchilov &amp;#x26; Hutter (2019), Decoupled Weight Decay Regularization&lt;/a&gt;：阐明自适应优化器中 L2 惩罚与解耦权重衰减的差异。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;18 下一篇预告&lt;/h2&gt;
&lt;p&gt;初始化只控制训练起点，Momentum/Adam 只重塑参数更新；随着权重变化，中间激活的尺度仍会漂移。下一篇将比较 Batch Normalization 与 Layer Normalization 的统计轴、训练/推理数据流和适用架构，解释归一化为何不是“把所有张量都标准化”。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>网络加深后信号为何消失？Xavier 与 He 初始化如何守住方差</title><link>https://zwjcode.cn/blog/xavier-he-initialization-signal-variance</link><guid isPermaLink="true">https://zwjcode.cn/blog/xavier-he-initialization-signal-variance</guid><description>从深层网络的方差连乘出发，手算前向与反向信号尺度，推导 Xavier/He 初始化，并用 PyTorch 2.13 逐层诊断激活和梯度。</description><pubDate>Tue, 01 Sep 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把损失沿计算图反传到了每一层，也看到深度会让局部 Jacobian 连乘。反向传播算法本身没有错，网络却可能在第一步更新前就已经“失声”：前向激活逐层缩成接近 0，或放大到溢出；反向梯度也会发生同样的指数级变化。&lt;/p&gt;
&lt;p&gt;参数初始化（Parameter Initialization）不是随便给权重一点随机噪声。它要解决一个具体问题：&lt;strong&gt;在训练刚开始时，让信号和梯度穿过很多层后仍保持可用尺度，同时打破神经元之间的对称。&lt;/strong&gt; 本文只追踪独立同分布近似下的方差，推导 Xavier 与 He 初始化，并把推导变成可执行的逐层体检。&lt;/p&gt;
&lt;h2&gt;01 全部设成 0 为什么不是最稳妥？&lt;/h2&gt;
&lt;p&gt;考虑含两个隐单元的全连接层。若两个神经元的权重和偏置都相同，它们接收同一输入、输出同一激活、反传得到同一梯度，之后每一步也仍然相同。宽度为 2 的层实际只学出一个重复特征，这叫对称性（Symmetry）没有被打破。&lt;/p&gt;
&lt;p&gt;随机初始化可以打破对称，但“随机”仍缺少尺度。设每层权重标准差都为 0.01，深层信号可能不断缩小；设为 1，又可能不断放大。初始化真正需要回答的是：权重方差应该怎样随输入连接数变化？&lt;/p&gt;
&lt;h2&gt;02 一层线性变换怎样改变方差？&lt;/h2&gt;
&lt;p&gt;先看第 $l$ 层某个神经元的预激活：&lt;/p&gt;
&lt;p&gt;$$
z_j^{(l)}=\sum_{i=1}^{n_{\text{in}}}W_{ji}^{(l)}a_i^{(l-1)}+b_j^{(l)}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$a^{(l-1)}\in\mathbb{R}^{n_{\text{in}}}$ 是上一层激活；&lt;/li&gt;
&lt;li&gt;$W^{(l)}\in\mathbb{R}^{n_{\text{out}}\times n_{\text{in}}}$；&lt;/li&gt;
&lt;li&gt;$z^{(l)},b^{(l)}\in\mathbb{R}^{n_{\text{out}}}$；&lt;/li&gt;
&lt;li&gt;mini-batch 版本中，$A^{(l-1)}[N,n_{\text{in}}]$ 与 &lt;code&gt;W.T[n_in,n_out]&lt;/code&gt; 相乘，得到 $Z^{(l)}[N,n_{\text{out}}]$。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在初始化分析的近似里，假设权重与输入相互独立、均值为 0，各分量方差相同，偏置先设为 0。不同求和项的协方差于是近似为 0：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(z_j^{(l)})
\approx n_{\text{in}}\operatorname{Var}(W_{ji}^{(l)})
\operatorname{Var}(a_i^{(l-1)})
$$&lt;/p&gt;
&lt;p&gt;这条式子揭示了危险：若 $n_{\text{in}}\operatorname{Var}(W)=0.5$，线性信号每层约减半；若等于 2，则每层约翻倍。连续 20 层后，倍率分别是 $0.5^{20}\approx9.5\times10^{-7}$ 与 $2^{20}\approx10^6$。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A⁽ˡ⁻¹⁾ [N,n_in]
   │  每个输出累加 n_in 项
   ▼
Z⁽ˡ⁾ = A⁽ˡ⁻¹⁾ @ W⁽ˡ⁾ᵀ + b⁽ˡ⁾   [N,n_out]
   │
   │  非线性还会截断或压缩分布
   ▼
A⁽ˡ⁾ = φ(Z⁽ˡ⁾)                  [N,n_out]

目标：Var(A⁽ˡ⁾) 不随 l 指数消失或爆炸
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 用四个输入手算“尺度错误”&lt;/h2&gt;
&lt;p&gt;设四个独立输入均值为 0、方差为 1，即 $n_{\text{in}}=4$。&lt;/p&gt;
&lt;p&gt;若权重方差为 $1$：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(z)=4\times1\times1=4
$$&lt;/p&gt;
&lt;p&gt;一层后标准差从 1 变为 2。十个相同线性层后，标准差近似变为 $2^{10}=1024$。&lt;/p&gt;
&lt;p&gt;若权重方差为 $1/4$：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(z)=4\times\frac14\times1=1
$$&lt;/p&gt;
&lt;p&gt;线性层前后的方差保持不变。于是最直接的前向原则是：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(W)\approx\frac{1}{n_{\text{in}}}
$$&lt;/p&gt;
&lt;p&gt;但神经网络在线性层后还有激活函数。上一篇使用的 ReLU 会把负值截成 0，不能继续沿用纯线性结论。&lt;/p&gt;
&lt;h2&gt;04 ReLU 为什么把 He 方差变成 2/fan_in？&lt;/h2&gt;
&lt;p&gt;若 $z$ 的分布关于 0 对称，ReLU 输出 $a=\max(0,z)$。大约一半样本被置 0；在忽略非零均值带来的细微差别时，其二阶矩约保留一半：&lt;/p&gt;
&lt;p&gt;$$
\mathbb{E}[a^2]\approx\frac12\mathbb{E}[z^2]
$$&lt;/p&gt;
&lt;p&gt;要让经过“线性层 + ReLU”后的二阶矩仍与输入接近，线性层应先放大约 2 倍：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(W)=\frac{2}{n_{\text{in}}}
$$&lt;/p&gt;
&lt;p&gt;这就是 He 初始化（He Initialization），在 PyTorch API 中也称 Kaiming 初始化（Kaiming Initialization）。若从均匀分布 $U(-a,a)$ 采样，因为其方差为 $a^2/3$，需要：&lt;/p&gt;
&lt;p&gt;$$
a=\sqrt{\frac{6}{n_{\text{in}}}}
$$&lt;/p&gt;
&lt;p&gt;若从正态分布采样，则标准差为：&lt;/p&gt;
&lt;p&gt;$$
\sigma=\sqrt{\frac{2}{n_{\text{in}}}}
$$&lt;/p&gt;
&lt;h2&gt;05 Xavier 为什么同时看 fan_in 与 fan_out？&lt;/h2&gt;
&lt;p&gt;对 tanh 或近似线性的激活，前向保持方差希望：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(W)\approx\frac{1}{n_{\text{in}}}
$$&lt;/p&gt;
&lt;p&gt;反向时，设流入本层的梯度为 $\delta^{(l)}[N,n_{\text{out}}]$，传向上一层的梯度为：&lt;/p&gt;
&lt;p&gt;$$
\delta^{(l-1)}=\delta^{(l)}W^{(l)}
$$&lt;/p&gt;
&lt;p&gt;每个输入方向累加 $n_{\text{out}}$ 项，因此反向保持梯度方差希望：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(W)\approx\frac{1}{n_{\text{out}}}
$$&lt;/p&gt;
&lt;p&gt;当扇入（Fan-in）与扇出（Fan-out）不同，两者无法同时精确满足。Xavier/Glorot 初始化取折中：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(W)=\frac{2}{n_{\text{in}}+n_{\text{out}}}
$$&lt;/p&gt;
&lt;p&gt;正态版标准差为 $\sqrt{2/(n_{\text{in}}+n_{\text{out}})}$；均匀版边界为 $\sqrt{6/(n_{\text{in}}+n_{\text{out}})}$。它通常适合 tanh、sigmoid 的近线性区域或无激活线性层；ReLU 隐层更常使用 He。&lt;/p&gt;
&lt;p&gt;| 初始化     |       权重方差（典型） | 主要匹配对象      | 直觉                 |
| ---------- | ---------------------: | ----------------- | -------------------- |
| 固定小高斯 |             与宽度无关 | 无                | 层越宽，累计方差越大 |
| Xavier     | $2/(fan_in+fan_out)$ | tanh / 近线性     | 折中前向与反向       |
| He/Kaiming |            $2/fan_in$ | ReLU 前向         | 补偿约一半信号被截断 |
| 正交初始化 |       由矩阵奇异值控制 | 深线性/循环结构等 | 尽量保持方向长度     |&lt;/p&gt;
&lt;h2&gt;06 完整初始化伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入：层列表、每层激活类型、随机种子

设置随机数生成器
对每个可训练层：
    若是 ReLU / Leaky ReLU 前的 Linear 或 Conv：
        按 fan_in 与负半轴斜率选择 He/Kaiming 尺度
    否则若是 tanh 或近线性隐藏层：
        按 fan_in、fan_out 选择 Xavier 尺度与 gain
    偏置初始化为 0（除非架构给出明确理由）

用一批真实尺度的输入只做前向：
    记录每层激活均值、标准差、零值比例、最大绝对值

构造标量损失并反向一次：
    记录每层权重梯度标准差与范数
    找到第一个突然衰减、爆炸或非有限的层
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初始化必须与“这个权重后面接什么激活”匹配，而不是按层类名机械选择。输出层的尺度还取决于损失：二分类 logits、回归值和 softmax 前分数不应盲目套用同一 gain。&lt;/p&gt;
&lt;h2&gt;07 不依赖框架，验证方差公式&lt;/h2&gt;
&lt;p&gt;下面用 NumPy 构造 20 层 ReLU 网络，不训练，只比较固定小方差、Xavier 与 He。代码显式写出 $W[n_{out},n_{in}]$ 与 &lt;code&gt;x @ W.T&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

rng = np.random.default_rng(7)
batch_size, width, depth = 4096, 256, 20
x0 = rng.standard_normal((batch_size, width))  # [N,D]

def sample_weight(kind, fan_in, fan_out):
    if kind == &quot;small&quot;:
        std = 0.01
    elif kind == &quot;xavier&quot;:
        std = np.sqrt(2.0 / (fan_in + fan_out))
    elif kind == &quot;he&quot;:
        std = np.sqrt(2.0 / fan_in)
    else:
        raise ValueError(kind)
    return rng.normal(0.0, std, size=(fan_out, fan_in))

def forward_stats(kind):
    x = x0.copy()
    stats = []
    for layer_idx in range(depth):
        weight = sample_weight(kind, width, width)  # [out,in]
        z = x @ weight.T                           # [N,out]
        x = np.maximum(z, 0.0)                    # ReLU
        stats.append((layer_idx + 1, x.std(), (x == 0).mean()))
    return stats

for kind in (&quot;small&quot;, &quot;xavier&quot;, &quot;he&quot;):
    stats = forward_stats(kind)
    print(kind, &quot;layer 1/20 std:&quot;, stats[0][1], stats[-1][1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预期不是每层标准差精确等于 1，而是看数量级：&lt;code&gt;small&lt;/code&gt; 很快接近 0；Xavier 在 ReLU 链中通常逐层衰减；He 在有限宽度的随机波动下更能维持可用尺度。若把激活换成 tanh，结论和合适 gain 也会变化。&lt;/p&gt;
&lt;h2&gt;08 用 PyTorch 2.13 正确初始化&lt;/h2&gt;
&lt;p&gt;PyTorch 2.13 官方 &lt;a href=&quot;https://docs.pytorch.org/docs/2.13/nn.init.html&quot;&gt;&lt;code&gt;torch.nn.init&lt;/code&gt;&lt;/a&gt; 提供 &lt;code&gt;xavier_uniform_&lt;/code&gt;、&lt;code&gt;xavier_normal_&lt;/code&gt;、&lt;code&gt;kaiming_uniform_&lt;/code&gt;、&lt;code&gt;kaiming_normal_&lt;/code&gt; 和 &lt;code&gt;calculate_gain&lt;/code&gt;。这些函数会原地修改参数，并在 &lt;code&gt;torch.no_grad()&lt;/code&gt; 语义下执行。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

class DeepMLP(nn.Module):
    def __init__(self, input_dim=64, width=128, depth=8, output_dim=1):
        super().__init__()
        layers = []
        in_features = input_dim
        for _ in range(depth):
            layers.extend([nn.Linear(in_features, width), nn.ReLU()])
            in_features = width
        layers.append(nn.Linear(width, output_dim))
        self.network = nn.Sequential(*layers)
        self.reset_parameters()

    def reset_parameters(self):
        for module in self.modules():
            if isinstance(module, nn.Linear):
                # 隐层先按 ReLU 初始化；最后一层随后单独覆盖
                nn.init.kaiming_normal_(
                    module.weight,
                    mode=&quot;fan_in&quot;,
                    nonlinearity=&quot;relu&quot;,
                )
                nn.init.zeros_(module.bias)

        output_layer = self.network[-1]
        nn.init.xavier_uniform_(output_layer.weight, gain=1.0)

    def forward(self, x):
        return self.network(x)

model = DeepMLP()
x = torch.randn(32, 64)       # [N,D]
logits = model(x)             # [N,1]
assert logits.shape == (32, 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档对 Kaiming 的一个关键提醒是：函数假设线性层按 &lt;code&gt;x @ weight.T&lt;/code&gt; 使用，&lt;code&gt;weight.shape=[fan_out,fan_in]&lt;/code&gt;，这正是 &lt;code&gt;nn.Linear&lt;/code&gt; 的约定。若你自己保存的是 &lt;code&gt;[fan_in,fan_out]&lt;/code&gt; 且计算 &lt;code&gt;x @ weight&lt;/code&gt;，应把 &lt;code&gt;weight.T&lt;/code&gt; 传给初始化函数，否则 fan 方向会算反。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mode=&quot;fan_in&quot;&lt;/code&gt; 更关注前向方差，&lt;code&gt;mode=&quot;fan_out&quot;&lt;/code&gt; 更关注反向方差。普通前馈网络常从 &lt;code&gt;fan_in&lt;/code&gt; 开始；不要仅因为“梯度也重要”就给每层混用不同模式。&lt;/p&gt;
&lt;h2&gt;09 一次前向加反向，怎样定位第一层异常？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

def inspect_signal_flow(model, x, target):
    activations = {}
    handles = []

    for name, module in model.named_modules():
        if isinstance(module, (nn.Linear, nn.ReLU)):
            def save_output(_module, _inputs, output, layer_name=name):
                value = output.detach()
                activations[layer_name] = {
                    &quot;shape&quot;: tuple(value.shape),
                    &quot;mean&quot;: value.mean().item(),
                    &quot;std&quot;: value.std().item(),
                    &quot;zero_frac&quot;: (value == 0).float().mean().item(),
                    &quot;finite&quot;: torch.isfinite(value).all().item(),
                }
            handles.append(module.register_forward_hook(save_output))

    try:
        logits = model(x).squeeze(-1)            # [N]
        loss = nn.BCEWithLogitsLoss()(logits, target)  # []
        model.zero_grad(set_to_none=True)
        loss.backward()
    finally:
        for handle in handles:
            handle.remove()

    gradients = {}
    for name, parameter in model.named_parameters():
        if parameter.grad is not None:
            gradients[name] = {
                &quot;shape&quot;: tuple(parameter.grad.shape),
                &quot;std&quot;: parameter.grad.std().item(),
                &quot;norm&quot;: parameter.grad.norm().item(),
                &quot;finite&quot;: torch.isfinite(parameter.grad).all().item(),
            }
    return loss.item(), activations, gradients

model = DeepMLP(input_dim=64, width=128, depth=8)
x = torch.randn(256, 64)
target = torch.randint(0, 2, (256,), dtype=torch.float32)
loss, activation_stats, gradient_stats = inspect_signal_flow(model, x, target)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前向钩子（Forward Hook）适合短期诊断，不应无界保存带计算图的输出；这里立即 &lt;code&gt;detach()&lt;/code&gt; 并在结束时移除 handle。检查时重点找相邻层之间的突变，而不是迷信“std 必须等于 1”。&lt;/p&gt;
&lt;h2&gt;10 卷积层的 fan 怎样数？&lt;/h2&gt;
&lt;p&gt;对二维卷积权重 &lt;code&gt;W[out_channels,in_channels,k_h,k_w]&lt;/code&gt;，每个输出位置累加的输入数是：&lt;/p&gt;
&lt;p&gt;$$
fan_in=in_channels\times k_h\times k_w
$$&lt;/p&gt;
&lt;p&gt;每个输入方向影响的典型输出连接数是：&lt;/p&gt;
&lt;p&gt;$$
fan_out=out_channels\times k_h\times k_w
$$&lt;/p&gt;
&lt;p&gt;因此不能只拿通道数计算初始化尺度。PyTorch 的初始化函数会根据张量形状处理常见线性与卷积权重；分组卷积、转置卷积或自定义张量布局则应额外核对 fan 的语义和真实乘法方向。&lt;/p&gt;
&lt;h2&gt;11 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;每层复制同一组随机权重。&lt;/strong&gt; 数值不是 0 也可能保留神经元对称；每个参数需要独立采样。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ReLU 隐层使用过小固定标准差。&lt;/strong&gt; 先打印每层激活 std，找到首次跌落一个数量级的层。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;fan_in&lt;/code&gt; 当成权重第 0 维。&lt;/strong&gt; &lt;code&gt;nn.Linear.weight&lt;/code&gt; 是 &lt;code&gt;[fan_out,fan_in]&lt;/code&gt;，先写出实际矩阵乘法。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leaky ReLU 忘记负斜率 &lt;code&gt;a&lt;/code&gt;。&lt;/strong&gt; &lt;code&gt;kaiming_*&lt;/code&gt; 的 &lt;code&gt;a&lt;/code&gt; 应与激活的 &lt;code&gt;negative_slope&lt;/code&gt; 一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;初始化后又被构造器或 checkpoint 覆盖。&lt;/strong&gt; 在加载权重之后检查参数统计；恢复训练通常不应重新初始化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看参数分布，不看真实输入。&lt;/strong&gt; 输入尺度、偏置、残差相加与归一化都会改变信号；至少用一批经过真实预处理的数据体检。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把随机波动误判为错误。&lt;/strong&gt; 固定种子，多次初始化比较分布区间；宽度很小时理论近似本来就噪声更大。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一个实用的排查顺序是：输入均值/标准差 → 每层预激活 → 激活零比例 → loss 是否有限 → 每层梯度 → 一步更新比。第一个异常层比最后的 NaN 更有信息。&lt;/p&gt;
&lt;h2&gt;12 初始化解决不了什么？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;极深网络中的相关性和 Jacobian 奇异值分布，不由逐层标量方差完全描述；&lt;/li&gt;
&lt;li&gt;tanh/sigmoid 若被大偏置或异常输入推入饱和区，合适权重方差也救不回局部导数；&lt;/li&gt;
&lt;li&gt;ReLU 神经元训练中可能长期落在负区，He 初始化只能降低起步时大面积死亡的风险；&lt;/li&gt;
&lt;li&gt;注意力、门控循环、残差分支和输出头常有架构特定初始化规则；&lt;/li&gt;
&lt;li&gt;混合精度下的溢出、错误损失缩放与过大学习率属于训练数值问题；&lt;/li&gt;
&lt;li&gt;初始化不能替代归一化、残差连接、合理优化器和数据标准化。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Xavier/He 关注的是分量方差。正交初始化进一步控制线性变换的奇异值，但经过非线性、宽度变化和训练后也不再精确等距。归一化则在运行时根据激活统计调整尺度，与只在训练开始执行一次的初始化完全不同。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;随机初始化同时承担打破对称与控制尺度；“不是 0”远远不够。&lt;/li&gt;
&lt;li&gt;线性层近似满足 $\operatorname{Var}(z)\approx fan_in\operatorname{Var}(W)\operatorname{Var}(a)$。&lt;/li&gt;
&lt;li&gt;Xavier 用 $fan_in$ 与 $fan_out$ 折中前向和反向，He 用 $2/fan_in$ 补偿 ReLU 截断。&lt;/li&gt;
&lt;li&gt;选择初始化时必须同时写出权重布局、真实矩阵乘法和后续激活。&lt;/li&gt;
&lt;li&gt;最可靠的验收是一批真实输入上的逐层激活与梯度体检，而不是只打印首层权重标准差。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一个 &lt;code&gt;Linear(200, 50)&lt;/code&gt; 后接 ReLU。分别计算 He 正态初始化的标准差、He 均匀初始化的边界，以及 Xavier 正态初始化的标准差。为什么三者不同？&lt;/li&gt;
&lt;li&gt;把 NumPy 实验中的 ReLU 改成纯线性和 tanh，分别比较三种初始化在 20 层后的标准差。tanh 的均值、饱和比例还应怎样记录？&lt;/li&gt;
&lt;li&gt;构造一个宽度依次为 &lt;code&gt;[64, 512, 32, 256]&lt;/code&gt; 的 MLP，故意把自定义权重保存为 &lt;code&gt;[fan_in,fan_out]&lt;/code&gt;。先错误初始化，再按官方说明传入转置张量，比较逐层激活统计。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;http://yann.lecun.com/exdb/publis/pdf/lecun-98b.pdf&quot;&gt;LeCun et al. (1998), Efficient BackProp&lt;/a&gt;：从输入中心化、曲率与权重尺度讨论高效训练的经典章节。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v9/glorot10a.html&quot;&gt;Glorot &amp;#x26; Bengio (2010), Understanding the Difficulty of Training Deep Feedforward Neural Networks&lt;/a&gt;：系统分析深层前馈网络信号传播并提出 Xavier/Glorot 初始化。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openreview.net/forum?id=_wzZwKpTDF_9C&quot;&gt;Saxe, McClelland &amp;#x26; Ganguli (2014), Exact Solutions to the Nonlinear Dynamics of Learning in Deep Linear Neural Networks&lt;/a&gt;：讨论深线性网络的学习动力学与正交初始化。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openaccess.thecvf.com/content_iccv_2015/html/He_Delving_Deep_into_ICCV_2015_paper.html&quot;&gt;He et al. (2015), Delving Deep into Rectifiers&lt;/a&gt;：针对 ReLU/PReLU 推导尺度感知初始化并训练更深网络。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2016/hash/148510031349642de5ca0c544f31b2ef-Abstract.html&quot;&gt;Poole et al. (2016), Exponential Expressivity in Deep Neural Networks Through Transient Chaos&lt;/a&gt;：从信号传播与临界性研究随机深网的有序/混沌区间。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;健康初始化让第一批信号能够穿过网络，但每一步仍可能沿狭长损失谷底左右振荡，或让不同参数因梯度尺度差异而进度悬殊。下一篇将把反传得到的梯度变成更新轨迹，手算 Momentum 的速度累积与 Adam 的一、二阶矩和偏差修正。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一个标量误差怎样找到所有参数？计算图、链式法则与反向传播</title><link>https://zwjcode.cn/blog/backpropagation-computational-graph-autograd</link><guid isPermaLink="true">https://zwjcode.cn/blog/backpropagation-computational-graph-autograd</guid><description>从有限差分的高成本出发，手算微型计算图的反向传播，解释向量—雅可比积、梯度累加与 PyTorch 2.13 autograd 调试。</description><pubDate>Mon, 31 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用两个 ReLU 隐单元手工分开了 XOR，说明激活函数能让多层网络学非线性表示。但手写那组权重只适用于四个点；真实网络可能有上亿个参数，我们只能观察到最后一个损失标量。&lt;/p&gt;
&lt;p&gt;反向传播（Backpropagation）解决的核心问题是：&lt;strong&gt;怎样从这一个数出发，高效计算它对每个参数的偏导数？&lt;/strong&gt; 本文将前向计算展开成计算图（Computational Graph），只围绕局部导数、反向模式自动微分和可检查的 PyTorch autograd 这条主线。&lt;/p&gt;
&lt;h2&gt;01 为每个参数单独试一次为什么太贵？&lt;/h2&gt;
&lt;p&gt;有限差分（Finite Difference）可以用轻微扰动近似一个参数的梯度：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial \theta_j}
\approx
\frac{L(\theta_j+\varepsilon)-L(\theta_j-\varepsilon)}{2\varepsilon}
$$&lt;/p&gt;
&lt;p&gt;若模型有 $P$ 个参数，中心差分需要约 $2P$ 次前向计算。$P=10^8$ 时显然无法每个 mini-batch 都这样做；而 $\varepsilon$ 太大会有截断误差，太小又会被浮点舍入差污染。&lt;/p&gt;
&lt;p&gt;有限差分适合当小规模“验算器”，不适合当训练引擎。反向传播则复用一次前向中已经计算的中间量，以与前向同一数量级的代价求出所有参数梯度。&lt;/p&gt;
&lt;h2&gt;02 先把一条公式拆成计算图&lt;/h2&gt;
&lt;p&gt;考虑一个标量神经元：&lt;/p&gt;
&lt;p&gt;$$
z=wx+b,\qquad a=\operatorname{ReLU}(z),\qquad
L=\frac12(a-y)^2
$$&lt;/p&gt;
&lt;p&gt;不把它当作一条长公式，而是记录每次操作：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x ─┐
   ├─ multiply ─► m=wx ─┐
w ─┘                    ├─ add ─► z ─► ReLU ─► a ─┐
b ──────────────────────┘                         ├─ subtract ─► e ─► square/2 ─► L
y ────────────────────────────────────────────────┘

前向：从左到右算数值，保存反向所需中间量
反向：从 L 到参数，沿边传递“上游梯度 × 局部导数”
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是一张有向无环图（Directed Acyclic Graph，DAG）：节点是张量或操作，边表示“这个结果依赖那个输入”。同一参数可以通过多条路径影响损失，反向时必须把这些路径的贡献相加。&lt;/p&gt;
&lt;h2&gt;03 用五个数手算一次完整反传&lt;/h2&gt;
&lt;p&gt;取：&lt;/p&gt;
&lt;p&gt;$$
x=2,\quad w=-1,\quad b=3,\quad y=0
$$&lt;/p&gt;
&lt;p&gt;前向传播（Forward Pass）：&lt;/p&gt;
&lt;p&gt;$$
m=wx=-2
$$&lt;/p&gt;
&lt;p&gt;$$
z=m+b=1
$$&lt;/p&gt;
&lt;p&gt;$$
a=\operatorname{ReLU}(1)=1
$$&lt;/p&gt;
&lt;p&gt;$$
L=\frac12(1-0)^2=0.5
$$&lt;/p&gt;
&lt;p&gt;反向从 $\partial L/\partial L=1$ 开始：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial a}=a-y=1
$$&lt;/p&gt;
&lt;p&gt;因为 $z=1&gt;0$，ReLU 当前分支的局部导数是 1：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial z}
=\frac{\partial L}{\partial a}
\frac{\partial a}{\partial z}
=1\times1=1
$$&lt;/p&gt;
&lt;p&gt;加法节点把上游梯度原样分发给 $m$ 和 $b$：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial m}=1,\qquad
\frac{\partial L}{\partial b}=1
$$&lt;/p&gt;
&lt;p&gt;乘法节点使用另一个输入作为局部导数：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial w}
=\frac{\partial L}{\partial m}
\frac{\partial m}{\partial w}
=1\times x=2
$$&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial x}
=\frac{\partial L}{\partial m}
\frac{\partial m}{\partial x}
=1\times w=-1
$$&lt;/p&gt;
&lt;p&gt;若学习率 $\eta=0.1$，梯度下降只更新参数 $w,b$：&lt;/p&gt;
&lt;p&gt;$$
w\leftarrow-1-0.1\times2=-1.2,\qquad
b\leftarrow3-0.1\times1=2.9
$$&lt;/p&gt;
&lt;p&gt;$x$ 的梯度有用于继续传向更早层，但输入数据本身通常不由优化器更新。&lt;strong&gt;反向传播计算梯度，梯度下降或 Adam 使用梯度更新参数；两者不是同一个算法。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;04 链式法则为什么能局部化？&lt;/h2&gt;
&lt;p&gt;若 $L=f(g(h(w)))$，链式法则（Chain Rule）给出：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial w}
=\frac{\partial L}{\partial f}
\frac{\partial f}{\partial g}
\frac{\partial g}{\partial h}
\frac{\partial h}{\partial w}
$$&lt;/p&gt;
&lt;p&gt;每个操作只需知道自己的局部导数，不需要理解整个网络。从右到左计算时，一个下游结果的梯度只算一次，然后被所有上游路径复用；这就是动态规划式的效率来源。&lt;/p&gt;
&lt;p&gt;当一个量同时走向两条支路，梯度要相加。例如 $L=w^2+3w$：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial w}=2w+3
$$&lt;/p&gt;
&lt;p&gt;&lt;code&gt;2w&lt;/code&gt; 来自平方分支，&lt;code&gt;3&lt;/code&gt; 来自线性分支。若实现只保留最后到达的一条路径，共享参数、残差连接和循环展开结构都会得到错误梯度。&lt;/p&gt;
&lt;h2&gt;05 张量情况下传的不是整张雅可比矩阵&lt;/h2&gt;
&lt;p&gt;对两层网络：&lt;/p&gt;
&lt;p&gt;$$
Z_1=XW_1+b_1,\quad H_1=\phi(Z_1),\quad
Z_2=H_1W_2+b_2,\quad L=\ell(Z_2,y)
$$&lt;/p&gt;
&lt;p&gt;形状为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D] ─► Z₁ [N,H] ─► H₁ [N,H] ─► Z₂ [N,C] ─► L []
             W₁ [D,H]                  W₂ [H,C]
             b₁ [H]                    b₂ [C]

反向：
dL/dZ₂ [N,C]
  ├─► dL/dW₂ = H₁ᵀ @ dL/dZ₂                 [H,C]
  ├─► dL/db₂ = sum_batch(dL/dZ₂)             [C]
  └─► dL/dH₁ = dL/dZ₂ @ W₂ᵀ                 [N,H]
          │ 逐元素乘 φ&apos;(Z₁)
          ▼
        dL/dZ₁ [N,H]
          ├─► dL/dW₁ = Xᵀ @ dL/dZ₁              [D,H]
          └─► dL/db₁ = sum_batch(dL/dZ₁)       [H]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个向量输出对一个向量输入的全部导数是雅可比矩阵（Jacobian Matrix）。反向模式自动微分（Reverse-mode Automatic Differentiation）并不逐层显式存储巨大 Jacobian，而是把上游向量与局部 Jacobian 相乘，即向量—雅可比积（Vector-Jacobian Product，VJP）。&lt;/p&gt;
&lt;p&gt;对“大量参数 $\rightarrow$ 一个标量损失”的训练问题，一次反向模式正好得到全部参数梯度。如果输出不是标量，&lt;code&gt;backward(gradient=v)&lt;/code&gt; 计算的也是给定 $v$ 的 VJP，并非默认构造整张 Jacobian。&lt;/p&gt;
&lt;h2&gt;06 反向传播的完整伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;# 前向：计算并保存反向需要的中间量
按拓扑顺序遍历操作：
    output = op.forward(inputs)
    记录 op、inputs 和必需中间量

# 标量损失的种子梯度
grad[loss] = 1

# 反向：逆拓扑顺序
对每个 op：
    input_grads = op.backward(grad[op.output], saved_values)
    对每个 input：
        grad[input] += input_grads[input]  # 多路径必须累加

# 优化是独立步骤
对每个可训练参数 parameter：
    parameter -= learning_rate * grad[parameter]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;保存中间量会占内存；不保存就需要反向时重算。激活检查点（Activation Checkpointing）正是在二者之间换取：用更多计算节省激活内存。&lt;/p&gt;
&lt;h2&gt;07 不依赖 autograd，手写标量反向&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def relu(value):
    return max(0.0, value)

x, w, b, y = 2.0, -1.0, 3.0, 0.0

# forward
m = w * x
z = m + b
a = relu(z)
loss = 0.5 * (a - y) ** 2

# backward
grad_a = a - y
grad_z = grad_a * (1.0 if z &gt; 0.0 else 0.0)
grad_m = grad_z
grad_b = grad_z
grad_w = grad_m * x
grad_x = grad_m * w

assert loss == 0.5
assert grad_w == 2.0
assert grad_b == 1.0
assert grad_x == -1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ReLU 在 $z=0$ 不可导。库会选定一个次梯度（Subgradient）约定；在 PyTorch 中该点的 ReLU 梯度为 0。这不影响几乎处处的求导，却会让恰好落在 0 的有限差分检查变得含糊；检查点应避开不光滑拐点。&lt;/p&gt;
&lt;h2&gt;08 PyTorch 2.13 autograd 究竟记录了什么？&lt;/h2&gt;
&lt;p&gt;根据当前稳定版 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/autograd&quot;&gt;&lt;code&gt;torch.autograd&lt;/code&gt;&lt;/a&gt;、&lt;a href=&quot;https://docs.pytorch.org/docs/stable/notes/autograd.html&quot;&gt;Autograd Mechanics&lt;/a&gt; 与官方教程，PyTorch 在执行张量操作时动态创建 DAG，通过 &lt;code&gt;grad_fn&lt;/code&gt; 保留反向入口；每轮前向都会重建图，因而普通 Python 分支和循环可以改变实际执行路径。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

x = torch.tensor(2.0)                         # 数据，不需存参数梯度
w = torch.tensor(-1.0, requires_grad=True)    # 叶子张量
b = torch.tensor(3.0, requires_grad=True)     # 叶子张量
y = torch.tensor(0.0)

z = w * x + b                                 # 非叶子，有 grad_fn
a = torch.relu(z)
loss = 0.5 * (a - y).square()                 # 标量 []

print(loss.shape, loss.grad_fn)
loss.backward()

print(w.grad, b.grad)                         # tensor(2.), tensor(1.)
assert torch.allclose(w.grad, torch.tensor(2.0))
assert torch.allclose(b.grad, torch.tensor(1.0))
assert z.grad is None                         # 非叶子默认不保留 .grad
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;requires_grad=True&lt;/code&gt; 要求跟踪对该叶子张量有影响的运算；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loss.backward()&lt;/code&gt; 从标量损失播种子梯度 1，并把结果&lt;strong&gt;累加&lt;/strong&gt;到叶子的 &lt;code&gt;.grad&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;非叶子中间张量默认不保留 &lt;code&gt;.grad&lt;/code&gt;，调试时可在前向后、反向前调用 &lt;code&gt;retain_grad()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;detach()&lt;/code&gt; 返回与当前图断开的张量，不是“复制一份但仍传梯度”；&lt;/li&gt;
&lt;li&gt;一次 &lt;code&gt;backward()&lt;/code&gt; 后中间结果通常被释放；不应为了绕开错误而默认加 &lt;code&gt;retain_graph=True&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;09 从两层网络看一次真实训练步&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

model = nn.Sequential(
    nn.Linear(2, 4),
    nn.ReLU(),
    nn.Linear(4, 1),
)
loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=3e-3)

# X: [N,2] float32；y: [N] float32，元素为 0 或 1
optimizer.zero_grad(set_to_none=True)

hidden_pre = model[0](X)                       # [N,4]
hidden_pre.retain_grad()                       # 仅用于本次调试
hidden = model[1](hidden_pre)                  # [N,4]
logits = model[2](hidden).squeeze(-1)          # [N]
loss = loss_fn(logits, y)                     # []

loss.backward()                               # 只计算/累加梯度

for name, parameter in model.named_parameters():
    assert parameter.grad is not None, f&apos;{name} 断开了计算图&apos;
    assert torch.isfinite(parameter.grad).all(), f&apos;{name} 梯度非有限&apos;
    print(name, tuple(parameter.shape), parameter.grad.norm().item())

print(&apos;hidden_pre grad shape:&apos;, hidden_pre.grad.shape)  # [N,4]
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()                                # 使用已计算的梯度更新
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;clip_grad_norm_&lt;/code&gt; 是一个需要根据任务验证的保护阀，不是梯度爆炸的根治。若频繁触发裁剪，应继续检查学习率、初始化、输入尺度、损失 &lt;code&gt;reduction&lt;/code&gt; 和发散的第一层。&lt;/p&gt;
&lt;h2&gt;10 怎样证明 autograd 没有帮你计算“正确的错误”？&lt;/h2&gt;
&lt;p&gt;autograd 只保证对&lt;strong&gt;实际执行的可微运算&lt;/strong&gt;求导。如果标签错位、损失定义错、广播扩张了张量或数据泄漏，它仍可以给出数学上一致的梯度。调试应分层：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;语义层：&lt;/strong&gt; 手算一个样本，检查损失和梯度方向是否符合任务。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;形状层：&lt;/strong&gt; 为每个主要张量写出轴语义与断言，尤其是 batch、class 和 sequence 维。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数值层：&lt;/strong&gt; 找第一个非有限的前向值或反向梯度，不要只在最后看 NaN loss。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;导数层：&lt;/strong&gt; 用 double 精度和极小输入做有限差分或 &lt;code&gt;gradcheck&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;优化层：&lt;/strong&gt; 过拟合一个小 batch，确认梯度非零、参数真的变化、损失能显著降低。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;自定义可微操作时，当前官方 API 提供 &lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.autograd.gradcheck.html&quot;&gt;&lt;code&gt;torch.autograd.gradcheck&lt;/code&gt;&lt;/a&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

def smooth_function(w):
    return torch.sin(w).mul(w.square()).sum()

w = torch.randn(4, dtype=torch.float64, requires_grad=True)
assert torch.autograd.gradcheck(smooth_function, (w,))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gradcheck&lt;/code&gt; 默认按 double 精度调校；不光滑点、随机操作、低精度和共享存储的重叠张量都可能让数值检查失败，应先理解前提，不要随意放宽容差。&lt;/p&gt;
&lt;h2&gt;11 最常见的断图、累加与内存错误&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘记清梯度。&lt;/strong&gt; 连续两次 &lt;code&gt;backward()&lt;/code&gt; 会把新梯度加到 &lt;code&gt;.grad&lt;/code&gt;，不是覆盖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;意外 &lt;code&gt;detach()&lt;/code&gt;、&lt;code&gt;.item()&lt;/code&gt; 或转 NumPy。&lt;/strong&gt; 从图中拿出 Python 数值/数组后再组装损失，梯度路径已断。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在训练前向中使用 &lt;code&gt;no_grad()&lt;/code&gt; 或 &lt;code&gt;inference_mode()&lt;/code&gt;。&lt;/strong&gt; 这些上下文用于不需要反传的评估或推理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对反向需要的张量就地改写。&lt;/strong&gt; autograd 会检查版本并可能报错；即使某次不报错，也不应把 &lt;code&gt;inplace=True&lt;/code&gt; 当默认优化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对同一张图二次反传。&lt;/strong&gt; 如果是新训练步，应重做前向；只在算法真的需要复用同一张图时才考虑 &lt;code&gt;retain_graph=True&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用输出张量直接 &lt;code&gt;backward()&lt;/code&gt; 却没给上游向量。&lt;/strong&gt; 非标量输出需要同形 &lt;code&gt;gradient&lt;/code&gt;，或先通过合理的 &lt;code&gt;sum/mean&lt;/code&gt; 得到标量目标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用损失后置 mask 掩盖无效前向。&lt;/strong&gt; 例如先除以 0 产生 Inf，再在损失前排除该元素，无效操作仍已进入图，反向可产生 NaN；应在危险操作之前做 mask。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对难定位的 NaN，可短时启用 &lt;code&gt;torch.autograd.detect_anomaly()&lt;/code&gt; 获取导致错误反向函数的前向追踪；它会明显变慢，不应默认常驻生产训练。&lt;/p&gt;
&lt;h2&gt;12 梯度消失与爆炸从哪里来？&lt;/h2&gt;
&lt;p&gt;链式法则会沿深度连乘局部 Jacobian。若典型奇异值长期小于 1，早层梯度会越传越小；若长期大于 1，则会迅速放大。Sigmoid/tanh 的饱和区局部导数很小，ReLU 负区导数为 0，权重尺度又决定线性变换如何放大向量。&lt;/p&gt;
&lt;p&gt;有效调试不是只看全局梯度范数，而是沿层记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数范数 $|\theta_l|$ 与梯度范数 $|g_l|$；&lt;/li&gt;
&lt;li&gt;更新比 $\eta|g_l|/(|\theta_l|+\epsilon)$；&lt;/li&gt;
&lt;li&gt;激活的均值、标准差、零值比例与极值；&lt;/li&gt;
&lt;li&gt;第一个出现 NaN/Inf 或突变的层；&lt;/li&gt;
&lt;li&gt;裁剪前后的全局梯度范数和触发频率。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;初始化、归一化、残差连接和优化器都会改变梯度流。本篇先建立反传观测方法，不把这些后续主题塞进一篇。&lt;/p&gt;
&lt;h2&gt;13 与相近求导方法的区别&lt;/h2&gt;
&lt;p&gt;| 方法        | 主要代价                           | 精度     | 适合场景               |
| ----------- | ---------------------------------- | -------- | ---------------------- |
| 符号微分    | 表达式可急剧膨胀                   | 解析     | 小型闭式公式、数学化简 |
| 有限差分    | 每个参数额外前向                   | 近似     | 小规模梯度验算         |
| 前向模式 AD | 约随输入方向数增长                 | 机器精度 | 少量输入、大量输出     |
| 反向模式 AD | 约随标量输出数增长                 | 机器精度 | 大量参数、少量损失     |
| 反向传播    | 反向模式 AD 在层级网络中的高效应用 | 机器精度 | 神经网络训练           |&lt;/p&gt;
&lt;p&gt;反向传播也有明确边界：离散的 &lt;code&gt;argmax&lt;/code&gt;、数据库查询或外部黑箱不会自动变得可微；长计算图需要激活内存；正确梯度也不保证非凸目标找到全局最优或模型能泛化。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;计算图把长公式拆成局部操作；反传按逆拓扑顺序传递“上游梯度 × 局部导数”。&lt;/li&gt;
&lt;li&gt;同一量经多条路径影响损失时，各路径梯度必须相加。&lt;/li&gt;
&lt;li&gt;反向模式通过 VJP 避免显式构建巨大 Jacobian，非常适合“多参数到标量损失”。&lt;/li&gt;
&lt;li&gt;PyTorch 动态记录实际执行的图，&lt;code&gt;.backward()&lt;/code&gt; 把梯度累加到叶子 &lt;code&gt;.grad&lt;/code&gt;；清梯度和更新参数是独立步骤。&lt;/li&gt;
&lt;li&gt;autograd 会忠实地对错误程序求导；手算方向、形状断言、有限差分、分层梯度和小 batch 过拟合缺一不可。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;把手算例中的 $b$ 改为 1，重算前向与所有梯度。为什么 $w$ 和 $b$ 都不再更新？这与上一篇的死亡 ReLU 有什么关系？&lt;/li&gt;
&lt;li&gt;对 $L=(w^2+3w)^2$ 画出有分支和合流的计算图，用 $w=2$ 手算每个节点的反向值，再与直接求导对照。&lt;/li&gt;
&lt;li&gt;在两层网络中故意删掉 &lt;code&gt;zero_grad&lt;/code&gt;，连续两次对同一 batch 重做前向和反向，比较每个参数的 &lt;code&gt;.grad&lt;/code&gt;。再用 &lt;code&gt;set_to_none=True&lt;/code&gt; 修复并验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://kansalliskirjasto.finna.fi/Record/helka.9933382303506253&quot;&gt;Linnainmaa (1970), The Representation of the Cumulative Rounding Error of an Algorithm as a Taylor Expansion of the Local Rounding Errors&lt;/a&gt;：反向累积模式的早期系统表述与芬兰原始学位论文记录。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.researchgate.net/publication/35657389_Beyond_regression_New_tools_for_prediction_and_analysis_in_the_behavioral_sciences&quot;&gt;Werbos (1974), Beyond Regression: New Tools for Prediction and Analysis in the Behavioral Sciences&lt;/a&gt;：将反向动态观点用于学习系统的早期博士论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1038/323533a0&quot;&gt;Rumelhart, Hinton &amp;#x26; Williams (1986), Learning Representations by Back-propagating Errors&lt;/a&gt;：使多层网络反向传播广为所知的经典工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jmlr.org/papers/v18/17-468.html&quot;&gt;Baydin et al. (2018), Automatic Differentiation in Machine Learning: A Survey&lt;/a&gt;：系统区分符号微分、数值微分与前向/反向模式 AD。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2019/hash/bdbca288fee7f92f2bfa9f7012727740-Abstract.html&quot;&gt;Paszke et al. (2019), PyTorch: An Imperative Style, High-Performance Deep Learning Library&lt;/a&gt;：PyTorch 动态张量计算与自动微分系统的代表性论文。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;反向传播已经能把损失分配到每层参数，但网络变深后，连乘的 Jacobian 可能让信号和梯度逐层放大或衰减。下一篇将追踪初始化时的前向方差与反向梯度，解释 Xavier/He 初始化为什么要根据扇入和激活函数设计尺度。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>叠得更深为何仍只是一条直线？激活函数如何让神经网络表达 XOR</title><link>https://zwjcode.cn/blog/neural-network-activation-xor-nonlinearity</link><guid isPermaLink="true">https://zwjcode.cn/blog/neural-network-activation-xor-nonlinearity</guid><description>从多层线性变换仍会折叠成一层出发，手算 ReLU 网络如何分开 XOR，追踪张量形状，并用 PyTorch 2.13 实现可调试的两层感知机。</description><pubDate>Mon, 31 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 UMAP 在无标签数据上构建近邻图，再为每个训练样本直接优化一个低维坐标。它能展开复杂形状，却没有用任务损失学到一个快速的多层映射。&lt;/p&gt;
&lt;p&gt;神经网络（Neural Network）正是把简单变换逐层组合。但有一个看似反直觉的门槛：&lt;strong&gt;如果层与层之间没有激活函数，叠再多线性层也只是一层。&lt;/strong&gt; 本文只追问这一个核心问题：仿射变换、激活函数（Activation Function）与非线性决策边界究竟怎样连在一起？&lt;/p&gt;
&lt;h2&gt;01 为什么逻辑回归无法分开 XOR？&lt;/h2&gt;
&lt;p&gt;异或（Exclusive OR，XOR）只有四个样本：两个输入不同时标签为 1，相同时为 0。&lt;/p&gt;
&lt;p&gt;| $x_1$ | $x_2$ | $y$ |
| ----: | ----: | --: |
|     0 |     0 |   0 |
|     0 |     1 |   1 |
|     1 |     0 |   1 |
|     1 |     1 |   0 |&lt;/p&gt;
&lt;p&gt;任何逻辑回归都先计算 $z=w_1x_1+w_2x_2+b$，再以 $z=0$ 作为一条直线边界。可 XOR 的两个正类落在对角，无法用一条直线与另两个点分开。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂
▲   y=1 ●        ○ y=0
│
│   y=0 ○        ● y=1
└──────────────────────► x₁

不论直线如何旋转，都会把一个对角点分错。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为原始特征手工加入 $x_1x_2$ 能解决这个例子，但真实任务有数以千计的特征和未知交互，穷举特征工程很快失控。我们需要让模型从数据中学出有用的中间表示。&lt;/p&gt;
&lt;h2&gt;02 一个人工神经元究竟计算什么？&lt;/h2&gt;
&lt;p&gt;人工神经元（Artificial Neuron）先做仿射变换（Affine Transformation），再通过激活函数 $\phi$：&lt;/p&gt;
&lt;p&gt;$$
z=w^\top x+b,\qquad h=\phi(z)
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x\in\mathbb R^D$：一个样本的 $D$ 个输入特征；&lt;/li&gt;
&lt;li&gt;$w\in\mathbb R^D$：神经元对每个特征的权重；&lt;/li&gt;
&lt;li&gt;$b\in\mathbb R$：偏置（Bias），让切分不必穿过原点；&lt;/li&gt;
&lt;li&gt;$z\in\mathbb R$：激活前值（Pre-activation）；&lt;/li&gt;
&lt;li&gt;$h\in\mathbb R$：传给下一层的激活值（Activation）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一层中并排 $H$ 个神经元，对 $N$ 个样本一次计算：&lt;/p&gt;
&lt;p&gt;$$
Z=XW+b,\qquad H_1=\phi(Z)
$$&lt;/p&gt;
&lt;p&gt;| 张量    | 形状    | 每个轴的含义                 |
| ------- | ------- | ---------------------------- |
| $X$     | $[N,D]$ | $N$ 个样本，每个 $D$ 个特征  |
| $W$     | $[D,H]$ | 从 $D$ 维输入到 $H$ 个隐单元 |
| $b$     | $[H]$   | 沿 batch 维广播的偏置        |
| $Z,H_1$ | $[N,H]$ | 每个样本的 $H$ 个中间特征    |&lt;/p&gt;
&lt;h2&gt;03 为什么叠两个线性层仍是一层？&lt;/h2&gt;
&lt;p&gt;假设中间没有激活函数：&lt;/p&gt;
&lt;p&gt;$$
H_1=XW_1+b_1
$$&lt;/p&gt;
&lt;p&gt;$$
Z_2=H_1W_2+b_2
$$&lt;/p&gt;
&lt;p&gt;代入并合并同类项：&lt;/p&gt;
&lt;p&gt;$$
Z_2=X(W_1W_2)+(b_1W_2+b_2)=XW_&lt;em&gt;+b_&lt;/em&gt;
$$&lt;/p&gt;
&lt;p&gt;$W_&lt;em&gt;$ 和 $b_&lt;/em&gt;$ 仍只定义了一次仿射变换。层数增加了，可表达的函数类别没有增加；它仍无法分开 XOR。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;无激活： X ── W₁,b₁ ── W₂,b₂ ──► Z
                         可折叠为 W*,b*

有激活： X ── W₁,b₁ ── ReLU ── W₂,b₂ ──► Z
                               ▲
                         分段折叠，不可整体合并
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;04 ReLU 怎样创造分段线性区域？&lt;/h2&gt;
&lt;p&gt;整流线性单元（Rectified Linear Unit，ReLU）逐元素计算：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{ReLU}(z)=\max(0,z)
$$&lt;/p&gt;
&lt;p&gt;它在 $z&amp;#x3C;0$ 时关闭单元，在 $z&gt;0$ 时保留线性值。每个隐单元都用 $w^\top x+b=0$ 在输入空间划出一个开关边界；多个开关的组合把空间分成多个区域，每个区域内是线性函数，区域之间可以改变斜率。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;z &amp;#x3C; 0                 z &gt; 0
──────────────●╱──────────────► z
              0
ReLU 输出 0       ReLU 输出 z
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此 ReLU 网络虽然由线性片段组成，整体却可以形成弯折的分段线性边界。&lt;/p&gt;
&lt;h2&gt;05 用两个隐单元手算 XOR&lt;/h2&gt;
&lt;p&gt;令 $s=x_1+x_2$，设置两个隐单元：&lt;/p&gt;
&lt;p&gt;$$
h_1=\operatorname{ReLU}(s),\qquad
h_2=\operatorname{ReLU}(s-1)
$$&lt;/p&gt;
&lt;p&gt;输出 logit 为：&lt;/p&gt;
&lt;p&gt;$$
z=2h_1-4h_2-1
$$&lt;/p&gt;
&lt;p&gt;| $(x_1,x_2)$ | $s$ | $h_1$ | $h_2$ | $z$ | $z\ge0$ |
| ----------- | --: | ----: | ----: | --: | ------: |
| $(0,0)$     |   0 |     0 |     0 |  -1 |       0 |
| $(0,1)$     |   1 |     1 |     0 |   1 |       1 |
| $(1,0)$     |   1 |     1 |     0 |   1 |       1 |
| $(1,1)$     |   2 |     2 |     1 |  -1 |       0 |&lt;/p&gt;
&lt;p&gt;四个点全部正确。两个 ReLU 不是在“记住四张表格”：第一个编码总和，第二个只在两个输入同时为 1 时开启，输出层用后者把过大的总和拉回负类。这就是一个可解释的中间表示。&lt;/p&gt;
&lt;h2&gt;06 Sigmoid、tanh 和 ReLU 应该放在哪里？&lt;/h2&gt;
&lt;p&gt;| 激活       | 定义               | 输出范围     | 常见用法                 | 主要风险              |
| ---------- | ------------------ | ------------ | ------------------------ | --------------------- |
| Sigmoid    | $1/(1+e^{-z})$     | $(0,1)$      | 二分类推理概率、门控     | 大绝对值时梯度接近 0  |
| tanh       | $\tanh z$          | $(-1,1)$     | 需要零中心有界激活的结构 | 两端饱和              |
| ReLU       | $\max(0,z)$        | $[0,\infty)$ | 普通 MLP 隐藏层的强基线  | 负区域长期无梯度      |
| Leaky ReLU | $\max(\alpha z,z)$ | $\mathbb R$  | 希望负区域仍有小梯度     | $\alpha$ 是新设计选择 |&lt;/p&gt;
&lt;p&gt;隐藏层和输出层的职责不同。二分类训练时，隐藏层可用 ReLU，最后一层应输出无界 logits，直接交给 &lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;。该损失在内部合并 Sigmoid 与 BCE，利用 log-sum-exp 获得更稳定的数值计算。只在推理或展示概率时手动 &lt;code&gt;sigmoid(logits)&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;07 完整数据流与张量形状&lt;/h2&gt;
&lt;p&gt;对 $N$ 个二维样本、$H=4$ 个隐单元：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,2]
  │ Linear(2,4): X @ W₁ᵀ + b₁
  ▼
Z₁ [N,4]
  │ ReLU，形状不变
  ▼
H₁ [N,4]
  │ Linear(4,1): H₁ @ W₂ᵀ + b₂
  ▼
logits [N,1]
  │ squeeze(-1)，只删除最后的单例维
  ▼
logits [N] ── BCEWithLogitsLoss(y [N]) ──► loss []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyTorch 的 &lt;code&gt;nn.Linear(in_features, out_features)&lt;/code&gt; 把权重存成 &lt;code&gt;[out_features,in_features]&lt;/code&gt;，因此数学上计算 $XW^\top+b$。不要因为纸上把 $W$ 写成 &lt;code&gt;[D,H]&lt;/code&gt; 就手动改变模块参数的维度约定。&lt;/p&gt;
&lt;h2&gt;08 训练和推理的最小伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;初始化 W₁,b₁,W₂,b₂

对每个 epoch：
    对每个 mini-batch (X, y)：
        Z₁ = affine(X, W₁, b₁)
        H₁ = ReLU(Z₁)
        logits = affine(H₁, W₂, b₂)
        loss = stable_binary_cross_entropy(logits, y)
        清空旧梯度
        求 loss 对所有参数的梯度
        更新参数

推理：
    关闭梯度记录
    probability = sigmoid(model(X_new))
    prediction = probability &gt;= validated_threshold
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本篇聚焦前向表示；“求所有参数的梯度”不是黑箱口号，下一篇会沿计算图逐边手算。&lt;/p&gt;
&lt;h2&gt;09 用 PyTorch 2.13 实现可检查的两层网络&lt;/h2&gt;
&lt;p&gt;当前稳定 API 中，&lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html&quot;&gt;&lt;code&gt;nn.Linear&lt;/code&gt;&lt;/a&gt; 完成仿射变换，&lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.ReLU.html&quot;&gt;&lt;code&gt;nn.ReLU&lt;/code&gt;&lt;/a&gt; 保持输入输出形状，&lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.BCEWithLogitsLoss.html&quot;&gt;&lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;&lt;/a&gt; 要求输入与目标形状一致。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn

torch.manual_seed(7)

X = torch.tensor([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
], dtype=torch.float32)                              # [N=4,D=2]
y = torch.tensor([0.0, 1.0, 1.0, 0.0])             # [4]

model = nn.Sequential(
    nn.Linear(in_features=2, out_features=4),        # [4,2] -&gt; [4,4]
    nn.ReLU(),
    nn.Linear(in_features=4, out_features=1),        # [4,4] -&gt; [4,1]
)
loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.03)

for step in range(2000):
    optimizer.zero_grad(set_to_none=True)
    logits = model(X).squeeze(-1)                    # [4]
    loss = loss_fn(logits, y)                        # []
    loss.backward()
    optimizer.step()

    if step % 200 == 0:
        assert torch.isfinite(loss)

model.eval()
with torch.inference_mode():
    logits = model(X).squeeze(-1)                    # [4]
    probabilities = torch.sigmoid(logits)            # [4]
    predictions = (probabilities &gt;= 0.5).to(torch.int64)

print(&apos;logits:&apos;, logits)
print(&apos;probabilities:&apos;, probabilities)
print(&apos;predictions:&apos;, predictions)
assert torch.equal(predictions, y.to(torch.int64))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个数据集小到只能验证表达能力，不能证明泛化。随机初始化和优化路径也可能使极小网络未在固定步数内学会 XOR；因此测试代码应固定种子并检查最终预测，不要只看损失打印。&lt;/p&gt;
&lt;h2&gt;10 从第一个失败开始调试&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先查形状。&lt;/strong&gt; 打印每层输入输出；二分类的 &lt;code&gt;logits&lt;/code&gt; 和 &lt;code&gt;target&lt;/code&gt; 必须完全同形。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;再查数值。&lt;/strong&gt; 在前向、损失、梯度和更新后分别查验 &lt;code&gt;isfinite&lt;/code&gt;，定位第一个 NaN/Inf。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;查激活占比。&lt;/strong&gt; 记录 &lt;code&gt;(hidden &gt; 0).float().mean()&lt;/code&gt;；所有 ReLU 长期为 0 表示“死亡 ReLU”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;查最后一层。&lt;/strong&gt; &lt;code&gt;BCEWithLogitsLoss&lt;/code&gt; 前不能再加 Sigmoid；否则损失把概率误当成 logit。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尝试过拟合一个极小 batch。&lt;/strong&gt; 若一个有可学信号的小 batch 都无法拟合，优先怀疑实现、标签和学习率，而非泛化。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以用显式模块捕获中间值：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;linear1 = model[0]
relu = model[1]

with torch.no_grad():
    preactivation = linear1(X)                       # [4,4]
    hidden = relu(preactivation)                     # [4,4]
    print(&apos;active_fraction:&apos;, (hidden &gt; 0).float().mean().item())
    print(&apos;weight_shapes:&apos;, [tuple(p.shape) for p in model.parameters()])
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;11 常见错误、工程边界与失败场景&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;用 &lt;code&gt;squeeze()&lt;/code&gt; 删掉所有单例维。&lt;/strong&gt; batch size 为 1 时可能连 batch 维也消失；优先 &lt;code&gt;squeeze(-1)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;利用广播隐藏了标签形状错误。&lt;/strong&gt; &lt;code&gt;[N,1]&lt;/code&gt; 与 &lt;code&gt;[N]&lt;/code&gt; 的组合可能在其他损失中扩展成 &lt;code&gt;[N,N]&lt;/code&gt;；每次明确断言。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;为所有层都加 ReLU。&lt;/strong&gt; 最后 logit 被截断为非负后，模型对负类的表达会被破坏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用很大学习率把所有 ReLU 推入负区域。&lt;/strong&gt; 降低学习率，检查初始化，或在确有需要时比较 Leaky ReLU。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把两层 MLP 当成万能结构。&lt;/strong&gt; 它没有显式利用图像平移、时序因果或图邻接等结构，往往需要更多样本和参数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽视数据边界。&lt;/strong&gt; 网络容量更大，并不会自动阻止泄漏、标签错位或分布漂移。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法                  | 非线性从哪里来      | 学习对象         | 主要特点                       |
| --------------------- | ------------------- | ---------------- | ------------------------------ |
| 逻辑回归              | 原始特征上没有      | 一组线性权重     | 几何清晰，表达有限             |
| 多项式特征 + 线性模型 | 人工交互项          | 扩展后的线性权重 | 可解释，特征数可爆炸           |
| 核 SVM                | 核函数隐式特征      | 支持向量系数     | 中小数据强，大样本可扩展性受限 |
| 决策树                | 轴对齐分裂          | 递归规则         | 表格数据强，不用梯度           |
| ReLU MLP              | 可学仿射 + 分段开关 | 多层特征与输出   | 表示灵活，优化与调试更复杂     |&lt;/p&gt;
&lt;p&gt;理论上，足够宽的单隐藏层网络可以近似很广的连续函数类；但这不保证有限数据下能学到、优化器能找到，或分布外能正确。“能表达”、“能训练”和“能泛化”是三个不同问题。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;仿射层先计算 $XW+b$；多个无激活的仿射层可合并为一层。&lt;/li&gt;
&lt;li&gt;ReLU 为每个隐单元引入开关边界，多个边界组合出分段线性的非线性模型。&lt;/li&gt;
&lt;li&gt;XOR 用两个 ReLU 隐单元就能手算分开，关键是学中间表示，而不是单纯增加层数。&lt;/li&gt;
&lt;li&gt;PyTorch &lt;code&gt;nn.Linear&lt;/code&gt; 的权重存储为 &lt;code&gt;[out,in]&lt;/code&gt;；二分类训练应输出 logits 并交给 &lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;表达能力不等于可优化性或泛化；形状、数值、激活占比和小 batch 过拟合是最先的调试证据。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;删掉示例中的 &lt;code&gt;nn.ReLU()&lt;/code&gt;，保留两个 &lt;code&gt;nn.Linear&lt;/code&gt;，多次改变隐藏宽度和种子。为什么它无法稳定把 XOR 四点全部分对？&lt;/li&gt;
&lt;li&gt;在手算网络中把输入改为 $(0.2,0.9)$ 和 $(0.8,0.8)$，计算 $h_1,h_2,z$ 与概率。画出 $z=0$ 在 $[0,1]^2$ 中的分段边界。&lt;/li&gt;
&lt;li&gt;分别使用 ReLU、tanh 和 LeakyReLU 训练同一个小网络，记录损失、隐单元零值比例和每层梯度范数；先固定数据、初始权重与优化器。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF02478259&quot;&gt;McCulloch &amp;#x26; Pitts (1943), A Logical Calculus of the Ideas Immanent in Nervous Activity&lt;/a&gt;：将简化神经元表述为逻辑计算单元的早期奠基工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1037/h0042519&quot;&gt;Rosenblatt (1958), The Perceptron&lt;/a&gt;：感知机学习与线性可分类问题的经典论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF02551274&quot;&gt;Cybenko (1989), Approximation by Superpositions of a Sigmoidal Function&lt;/a&gt;：单隐藏层网络通用近似性的代表性结果。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v9/glorot10a.html&quot;&gt;Glorot &amp;#x26; Bengio (2010), Understanding the Difficulty of Training Deep Feedforward Neural Networks&lt;/a&gt;：连接激活、初始化与深层优化困难的系统分析。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v15/nair11a.html&quot;&gt;Nair &amp;#x26; Hinton (2010), Rectified Linear Units Improve Restricted Boltzmann Machines&lt;/a&gt;：推动整流线性单元广泛使用的代表性工作。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;现在一个两层网络已经能把 XOR 折成可分的中间表示，但手算权重不会扩展到真实数据。下一篇将把前向计算展开成有向计算图，用链式法则追踪输出误差如何分配给每一层的权重与偏置。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>局部邻域怎样拼成可外推的图？UMAP 的模糊边权与交叉熵</title><link>https://zwjcode.cn/blog/umap-fuzzy-neighborhood-cross-entropy</link><guid isPermaLink="true">https://zwjcode.cn/blog/umap-fuzzy-neighborhood-cross-entropy</guid><description>从 t-SNE 难外推与全局尺度难解释出发，推导 UMAP 的局部半径、模糊近邻图和交叉熵优化，并解释关键参数与工程边界。</description><pubDate>Sun, 30 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 t-SNE 把高维邻域写成概率，再用重尾相似度把可靠邻居拉近、伪邻居推远。它很适合二维探索，但标准形式直接联合优化训练样本坐标，没有自然的 &lt;code&gt;transform&lt;/code&gt;；困惑度又通过每点带宽间接控制尺度，图上的全局关系不宜解释。&lt;/p&gt;
&lt;p&gt;统一流形逼近与投影（Uniform Manifold Approximation and Projection，UMAP）也从近邻出发，却先构造一张&lt;strong&gt;模糊近邻图（Fuzzy Neighborhood Graph）&lt;/strong&gt;：局部距离被换成 $[0,1]$ 边权，再在低维中寻找一张边权相似的图。本文聚焦局部半径与尺度、模糊并集、交叉熵优化这条主线；拓扑理论只解释设计动机，不做百科式展开。&lt;/p&gt;
&lt;h2&gt;01 t-SNE 已能看邻居，为什么还要换成图？&lt;/h2&gt;
&lt;p&gt;真实项目常同时需要：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;局部相似样本在低维仍靠近；&lt;/li&gt;
&lt;li&gt;数十万样本时不要构造完整 $N\times N$ 概率矩阵；&lt;/li&gt;
&lt;li&gt;新样本能进入已有表示，供下游验证或交互查询；&lt;/li&gt;
&lt;li&gt;能明确调节“看多大的邻域”和“低维团块压多紧”。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;UMAP 用稀疏 K 近邻（K-Nearest Neighbors，KNN）图承载前两点，用训练图上的局部插值与优化近似实现第三点，用 &lt;code&gt;n_neighbors&lt;/code&gt; 和 &lt;code&gt;min_dist&lt;/code&gt; 分别控制后两种几何偏好。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;高维样本 X
   │ KNN：只保留每点 K 条候选边
   ▼
有向局部边权 vⱼ|ᵢ
   │ 模糊并集：融合 i→j 与 j→i
   ▼
无向稀疏图 V
   │ 在低维建立边权 W(Y)，匹配 V
   ▼
低维坐标 Y ──► 新样本可相对训练邻居定位
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;“可外推”仍是近似能力，不代表学到了神经网络式的全局解析映射；远离训练流形的新点依然可能被错误安放。&lt;/p&gt;
&lt;h2&gt;02 完整数据流与张量形状&lt;/h2&gt;
&lt;p&gt;设训练输入 $X\in\mathbb R^{N\times D}$，每点取 $K$ 个邻居，输出 $Y\in\mathbb R^{N\times d}$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D]
  │ 近似或精确 KNN
  ▼
J [N,K]：邻居索引       R [N,K]：邻居距离
  │ 每行求局部连通半径 ρᵢ 与尺度 σᵢ
  ▼
V_dir：稀疏有向权重，最多 NK 个非零
  │ vᵢⱼ + vⱼᵢ - vᵢⱼvⱼᵢ
  ▼
V：稀疏对称高维图 [N,N]

Y₀ [N,d]（常用谱初始化）
  │ 低维相似度 wᵢⱼ(Y)
  │ 对正边采样吸引，对非边负采样排斥
  ▼
Y [N,d]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;与 t-SNE 的密集联合概率不同，UMAP 的高维对象主要是至多 $O(NK)$ 条稀疏边。近邻搜索、图构造和随机优化仍会消耗可观时间与内存，但不必默认保存所有点对。&lt;/p&gt;
&lt;h2&gt;03 每个点为什么先减去局部半径 ρᵢ？&lt;/h2&gt;
&lt;p&gt;对点 $x_i$，将第一个非零近邻距离记为局部连通半径 $\rho_i$。最常见的 &lt;code&gt;local_connectivity=1&lt;/code&gt; 意味着至少最近的一个邻居应被视为完全连通。&lt;/p&gt;
&lt;p&gt;有向边 $i\to j$ 的强度定义为：&lt;/p&gt;
&lt;p&gt;$$
v_{j\mid i}=
\exp\left(-\frac{\max(0,d(x_i,x_j)-\rho_i)}{\sigma_i}\right)
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$d(x_i,x_j)$：输入度量下的邻居距离；&lt;/li&gt;
&lt;li&gt;$\rho_i\ge0$：点 $i$ 的局部连通半径；&lt;/li&gt;
&lt;li&gt;$\sigma_i&gt;0$：点 $i$ 的局部尺度；&lt;/li&gt;
&lt;li&gt;$v_{j\mid i}\in(0,1]$：有向模糊成员强度（Membership Strength）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只要 $d(x_i,x_j)\le\rho_i$，边权就是 1。这样每个点至少与一个近邻牢固连接，减少稀疏区域成为孤点的风险。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;同样的绝对距离 0.4

稠密区 i：ρᵢ=0.05, σᵢ=0.10 → 权重很小
稀疏区 k：ρₖ=0.30, σₖ=0.40 → 权重较大

UMAP 比较的是“相对本地尺度有多近”，不是统一半径下的绝对距离
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这能适应采样密度，却也弱化了低维团块面积对原始密度的直接解释。需要显式保留密度时，应验证 densMAP 等扩展，而不是从普通 UMAP 的点团大小下结论。&lt;/p&gt;
&lt;h2&gt;04 σᵢ 怎样让每个局部邻域可比较？&lt;/h2&gt;
&lt;p&gt;UMAP 为每一行寻找 $\sigma_i$，使邻居边权总量接近一个与 $K$ 有关的目标，常写成：&lt;/p&gt;
&lt;p&gt;$$
\sum_{j=1}^{K}
\exp\left(-\frac{\max(0,d_{ij}-\rho_i)}{\sigma_i}\right)
\approx\log_2K
$$&lt;/p&gt;
&lt;p&gt;这一步叫平滑 K 近邻距离（Smooth K-Nearest-Neighbor Distance）。$\sigma_i$ 小会让权重快速衰减，$\sigma_i$ 大会让更多邻居保留明显边权；实现通过单调二分搜索求解。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;n_neighbors=K&lt;/code&gt; 因而不只是“图里有几条边”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;小 $K$ 只观察很局部的片段，细节多但图易断；&lt;/li&gt;
&lt;li&gt;大 $K$ 让局部尺度覆盖更广，更强调宏观连续性但可能抹掉细粒度结构；&lt;/li&gt;
&lt;li&gt;同一个 $K$ 在不同数据规模、噪声和密度下含义不同，必须做稳定性扫描。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;05 四条距离怎样手算有向边权？&lt;/h2&gt;
&lt;p&gt;假设点 $i$ 的三个近邻距离为：&lt;/p&gt;
&lt;p&gt;$$
[0.2,\ 0.5,\ 1.0]
$$&lt;/p&gt;
&lt;p&gt;为便于手算，取 $\rho_i=0.2,\sigma_i=0.3$。三条有向边为：&lt;/p&gt;
&lt;p&gt;$$
v_{1\mid i}=e^0=1
$$&lt;/p&gt;
&lt;p&gt;$$
v_{2\mid i}=e^{-(0.5-0.2)/0.3}=e^{-1}\approx0.368
$$&lt;/p&gt;
&lt;p&gt;$$
v_{3\mid i}=e^{-(1.0-0.2)/0.3}\approx0.069
$$&lt;/p&gt;
&lt;p&gt;最近邻被完全连接，之后按超出本地半径的距离指数衰减。这里的 $\sigma_i$ 是演示给定值；真实算法会二分搜索，让边权总量达到目标。&lt;/p&gt;
&lt;p&gt;若反方向 $v_{i\mid2}=0.4$，怎样融合 $i\to2$ 和 $2\to i$？UMAP 使用模糊集合并集：&lt;/p&gt;
&lt;p&gt;$$
v_{i2}=v_{2\mid i}+v_{i\mid2}-v_{2\mid i}v_{i\mid2}
$$&lt;/p&gt;
&lt;p&gt;$$
v_{i2}\approx0.368+0.4-0.368\times0.4=0.621
$$&lt;/p&gt;
&lt;p&gt;只要任一方向很强，无向边就会较强；两边都弱时，并集仍弱。它等价于“至少一条方向关系成立”的软逻辑或概率和。&lt;/p&gt;
&lt;h2&gt;06 模糊并集怎样把局部视角拼成全局图？&lt;/h2&gt;
&lt;p&gt;令有向权重矩阵为 $V^{\rightarrow}$，转置代表反向视角。默认模糊并集为逐元素：&lt;/p&gt;
&lt;p&gt;$$
V=V^{\rightarrow}+(V^{\rightarrow})^\top
-V^{\rightarrow}\odot(V^{\rightarrow})^\top
$$&lt;/p&gt;
&lt;p&gt;$V\in[0,1]^{N\times N}$ 对称但稀疏。每一条边表示两个局部邻域对“这对样本相连”的综合信心。&lt;/p&gt;
&lt;p&gt;图并不自动忠实：错误度量会连错边，近似近邻会漏边，过大的 $K$ 会跨越流形折叠或不同群体。UMAP 后面的优化只能尽量表现这张图，不能修复图构造阶段的语义错误。&lt;/p&gt;
&lt;p&gt;工程上至少检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;近邻距离的中位数、长尾与异常小值；&lt;/li&gt;
&lt;li&gt;图的连通分量、孤立点和节点度分布；&lt;/li&gt;
&lt;li&gt;不同 $K$ 下边集合的重合率；&lt;/li&gt;
&lt;li&gt;已知成对约束或领域近邻的召回率；&lt;/li&gt;
&lt;li&gt;标准化、余弦距离、领域距离改变了哪些关键邻居。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;07 低维边权怎样由 min_dist 控制？&lt;/h2&gt;
&lt;p&gt;在低维中，UMAP 用一条重尾曲线把距离映射为边权：&lt;/p&gt;
&lt;p&gt;$$
w_{ij}(Y)=\frac{1}{1+a\lVert y_i-y_j\rVert_2^{2b}}
$$&lt;/p&gt;
&lt;p&gt;$a,b&gt;0$ 由用户指定的 &lt;code&gt;min_dist&lt;/code&gt; 和 &lt;code&gt;spread&lt;/code&gt; 拟合得到。&lt;code&gt;spread&lt;/code&gt; 控制嵌入的有效尺度，&lt;code&gt;min_dist&lt;/code&gt; 控制近邻在低维允许压得多紧：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;min_dist 小                         min_dist 大

   ●●●       ●●                      ●  ●      ●  ●
  ●●●●      ●●●       对比          ●   ●    ●   ●
   紧密团块、细小丝状结构             更均匀展开、局部留白更多
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它不是所有点对都必须满足的硬性最小欧氏距离，而是通过目标曲线改变“多近仍算强边”的有效尺度。将 &lt;code&gt;min_dist=0.1&lt;/code&gt; 解释成图上任意两点至少相距 0.1 是错误的。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;n_neighbors&lt;/code&gt; 主要改变高维图看多大范围，&lt;code&gt;min_dist&lt;/code&gt; 主要改变低维如何打包强边。二者职责不同，应交叉扫描而不是互相替代。&lt;/p&gt;
&lt;h2&gt;08 交叉熵怎样同时吸引边与排斥非边？&lt;/h2&gt;
&lt;p&gt;把高维边权记为 $v_{ij}$，低维边权记为 $w_{ij}$，UMAP 最小化二元交叉熵形式：&lt;/p&gt;
&lt;p&gt;$$
C=\sum_{i&amp;#x3C;j}
\left[
v_{ij}\log\frac{v_{ij}}{w_{ij}}
+(1-v_{ij})\log\frac{1-v_{ij}}{1-w_{ij}}
\right]
$$&lt;/p&gt;
&lt;p&gt;第一部分让高权重真边在低维也有高 $w_{ij}$，形成吸引；第二部分让高维非边保持低 $w_{ij}$，形成排斥。&lt;/p&gt;
&lt;p&gt;所有非边数量接近 $N^2$，无法逐对计算。UMAP 采用随机梯度下降（Stochastic Gradient Descent，SGD）：按边权频率采样正边，再为每条正边抽取若干负样本近似排斥项。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;一次随机更新

正边 (i,j), vᵢⱼ 高：    yᵢ ←──→ yⱼ  吸引
随机非边 (i,k)：        yᵢ ──←  yₖ  排斥
随机非边 (i,l)：        yᵢ ──←  yₗ  排斥

重复多个 epoch，边采样频率与 vᵢⱼ 相关
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;负采样让算法可扩展，也引入随机性。二维图是一个随机优化结果，不是图的唯一精确解；多种子和重采样仍是必需诊断。&lt;/p&gt;
&lt;h2&gt;09 不调用 UMAP，写出可检查的模糊图&lt;/h2&gt;
&lt;p&gt;下面用 scikit-learn 只做近邻搜索，手动计算 $\rho_i$、$\sigma_i$、有向边权和模糊并集。它省略了重复点插值、局部连通度非整数处理与近似近邻优化，适合小数据验证公式：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.neighbors import NearestNeighbors

def _solve_sigma(distances, rho, target, steps=64):
    lo, hi = 1e-6, 1.0

    def mass(sigma):
        return np.exp(-np.maximum(0.0, distances - rho) / sigma).sum()

    while mass(hi) &amp;#x3C; target:
        hi *= 2.0
    for _ in range(steps):
        mid = (lo + hi) / 2.0
        if mass(mid) &amp;#x3C; target:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2.0

def fuzzy_knn_graph(X, n_neighbors=15):
    X = np.asarray(X, dtype=float)                         # [N,D]
    n = len(X)
    if not 2 &amp;#x3C;= n_neighbors &amp;#x3C; n:
        raise ValueError(&apos;需要 2 &amp;#x3C;= n_neighbors &amp;#x3C; N&apos;)

    search = NearestNeighbors(n_neighbors=n_neighbors + 1)
    distances, indices = search.fit(X).kneighbors(X)      # [N,K+1]
    distances, indices = distances[:, 1:], indices[:, 1:] # 排除自身

    directed = np.zeros((n, n))                           # 教学用稠密矩阵
    rhos = np.zeros(n)
    sigmas = np.zeros(n)
    target = np.log2(n_neighbors)

    for i in range(n):
        positive = distances[i][distances[i] &gt; 0]
        rhos[i] = positive[0] if len(positive) else 0.0
        sigmas[i] = _solve_sigma(distances[i], rhos[i], target)
        weights = np.exp(
            -np.maximum(0.0, distances[i] - rhos[i]) / sigmas[i]
        )
        directed[i, indices[i]] = weights

    graph = directed + directed.T - directed * directed.T
    np.fill_diagonal(graph, 0.0)
    assert np.all((0.0 &amp;#x3C;= graph) &amp;#x26; (graph &amp;#x3C;= 1.0))
    return graph, indices, distances, rhos, sigmas
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方实现将图保存为稀疏矩阵，并使用近邻下降（Nearest Neighbor Descent，NN-descent）等近似近邻机制。教学版的 $N\times N$ 稠密数组只适合几十或几百个点，不能用于生产规模。&lt;/p&gt;
&lt;h2&gt;10 用 UMAP 0.5.8 当前官方 API 落地&lt;/h2&gt;
&lt;p&gt;官方 &lt;a href=&quot;https://umap-learn.readthedocs.io/en/latest/api.html&quot;&gt;&lt;code&gt;UMAP&lt;/code&gt; API&lt;/a&gt; 来自 &lt;code&gt;umap-learn&lt;/code&gt; 包，使用 scikit-learn 风格接口：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
import umap
from sklearn.manifold import trustworthiness
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)           # [N,D]

reducer = umap.UMAP(
    n_neighbors=15,
    n_components=2,
    metric=&apos;euclidean&apos;,
    min_dist=0.1,
    spread=1.0,
    init=&apos;spectral&apos;,
    n_epochs=None,              # 让实现按数据规模选择
    learning_rate=1.0,
    low_memory=True,
    random_state=42,
    transform_seed=42,
)
Y_train = reducer.fit_transform(X_train_scaled)          # [N,2]

assert reducer.embedding_.shape == (len(X_train), 2)
assert reducer.graph_.shape == (len(X_train), len(X_train))
assert np.isfinite(Y_train).all()
print(trustworthiness(X_train_scaled, Y_train, n_neighbors=15))

X_valid_scaled = scaler.transform(X_valid)               # [Q,D]
Y_valid = reducer.transform(X_valid_scaled)               # [Q,2]
assert Y_valid.shape == (len(X_valid), 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;metric&lt;/code&gt; 定义原空间的“近”。文本嵌入常需比较 &lt;code&gt;cosine&lt;/code&gt;，非负计数、二元向量、地理坐标和混合特征也可能需要不同度量；不要把默认欧氏距离当无假设选择。&lt;/p&gt;
&lt;p&gt;设置 &lt;code&gt;random_state&lt;/code&gt; 有利于复现，但官方复现说明指出，多线程随机优化的执行顺序本身可能不确定；要求精确复现通常会牺牲并行速度。探索阶段可使用并行，最终报告固定种子、线程策略、版本与坐标产物。&lt;/p&gt;
&lt;h2&gt;11 transform 如何安放新样本？&lt;/h2&gt;
&lt;p&gt;对新样本 $X_{new}\in\mathbb R^{Q\times D}$，UMAP 大致执行：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在训练样本中寻找近邻；&lt;/li&gt;
&lt;li&gt;用训练时的局部尺度形成新点到旧点的边权；&lt;/li&gt;
&lt;li&gt;由旧邻居低维坐标初始化新点；&lt;/li&gt;
&lt;li&gt;保持训练嵌入不变，对新点坐标做有限优化。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练图：   A────B────C
                 ╲
新点 x：          x  在高维最像 B、C

低维：     yA──yB──yC
                 ╲
                 yx  由 B、C 的旧坐标定位，旧点不重排
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这使 UMAP 能进入管线，但不消除泄漏：若先在全部数据上 &lt;code&gt;fit_transform&lt;/code&gt;，验证样本已经改变近邻图和训练坐标。正确流程是在每个训练折内拟合缩放器与 UMAP，再对验证折调用 &lt;code&gt;transform&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;若新点到训练邻居的距离远超训练分布，仍会被迫分给“最不远”的旧邻居。上线时应监控新点最近邻距离、图边权总量和特征漂移，并设置拒绝或回退逻辑。&lt;/p&gt;
&lt;h2&gt;12 n_neighbors 与 min_dist 怎样联合选择？&lt;/h2&gt;
&lt;p&gt;| &lt;code&gt;n_neighbors&lt;/code&gt; | &lt;code&gt;min_dist&lt;/code&gt; | 常见视觉倾向                   | 主要风险           |
| ------------- | ---------- | ------------------------------ | ------------------ |
| 小            | 小         | 很紧的微小岛与细丝             | 放大噪声、连通性差 |
| 小            | 大         | 局部片段分散展开               | 宏观关系仍断裂     |
| 大            | 小         | 较大尺度连续，但局部仍可压成团 | 跨群体错误连边     |
| 大            | 大         | 更平滑、均匀、强调整体轮廓     | 细粒度类别被抹平   |&lt;/p&gt;
&lt;p&gt;可靠选择不应只追求“图更漂亮”。建议扫描二维网格，同时记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原空间 K 近邻召回率和低维信任度；&lt;/li&gt;
&lt;li&gt;高维模糊图的连通分量、度分布与边稳定性；&lt;/li&gt;
&lt;li&gt;不同种子/重采样后近邻重合率与 Procrustes 对齐误差；&lt;/li&gt;
&lt;li&gt;若用于下游任务，严格交叉验证后的分数与方差；&lt;/li&gt;
&lt;li&gt;拟合、&lt;code&gt;transform&lt;/code&gt; 时间，峰值内存和模型产物大小。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;n_components&lt;/code&gt; 不必等于 2。可视化用 2 或 3 维；下游表示可验证更高维，但维数越高越不能凭二维直觉选择参数。&lt;/p&gt;
&lt;h2&gt;13 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;安装错包&lt;/strong&gt;：PyPI 包名是 &lt;code&gt;umap-learn&lt;/code&gt;，导入名是 &lt;code&gt;umap&lt;/code&gt;；不要误装另一个同名包。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;全数据拟合造成泄漏&lt;/strong&gt;：将“缩放 → UMAP → 下游模型”放入训练折，验证折只调用已拟合变换。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把颜色标签传给 &lt;code&gt;fit_transform&lt;/code&gt;&lt;/strong&gt;：监督 UMAP 会让标签参与图构造；若目标是无监督探索，不要传 &lt;code&gt;y&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为图轴有固定语义&lt;/strong&gt;：坐标可旋转、反射、缩放；解释邻域，不解释“横轴增加代表什么”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把点团当真实类别&lt;/strong&gt;：回到原空间检查组内距离、可分性、重采样稳定性和领域意义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只固定种子却改变线程与版本&lt;/strong&gt;：记录 &lt;code&gt;umap-learn&lt;/code&gt;、NumPy、Numba、scikit-learn 版本及线程设置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略断开顶点警告&lt;/strong&gt;：检查度为 0 的点、最大距离、重复点和 &lt;code&gt;disconnection_distance&lt;/code&gt;，不要只隐藏警告。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对稀疏高维数据先转稠密&lt;/strong&gt;：会瞬间耗尽内存；保留稀疏表示并选择兼容度量与预降维。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;相信 &lt;code&gt;inverse_transform&lt;/code&gt; 精确重构&lt;/strong&gt;：低维压缩本来就丢信息，逆变换只是近似，应报告重构误差和适用范围。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最短调试路径：验证输入与距离 → 检查近邻和图连通性 → 扫 $K$/&lt;code&gt;min_dist&lt;/code&gt; 与种子 → 检查训练外距离 → 最后才解释颜色和团块。&lt;/p&gt;
&lt;h2&gt;14 失败场景与相近方法边界&lt;/h2&gt;
&lt;p&gt;| 方法     | 高维目标               | 优化/求解             | 训练外能力与主要边界             |
| -------- | ---------------------- | --------------------- | -------------------------------- |
| PCA      | 全局线性重构与方差     | SVD/特征分解          | 精确线性变换；不能展开非线性流形 |
| Isomap   | 图测地距离             | 最短路 + 经典 MDS     | 近似外推；怕短路和断图           |
| LLE      | 局部线性重构权重       | 局部线性系统 + 谱分解 | 局部外推；怕病态邻域             |
| t-SNE    | 高低维邻域联合概率     | KL + 吸引/排斥优化    | 标准形式无变换；局部图强         |
| UMAP     | 模糊近邻图边权         | 交叉熵 + 负采样 SGD   | 有近似变换；结果依赖图与随机优化 |
| 自编码器 | 参数化重构或自定义目标 | 神经网络反向传播      | 快速前向；需更多数据与训练设计   |&lt;/p&gt;
&lt;p&gt;UMAP 仍假设局部近邻有意义，且数据可由某种局部流形结构近似。离散组合对象、相交流形、强批次效应、极不均匀采样、严重噪声或领域距离错误都会让模糊图先天失真。它不是聚类器，也不会自动给坐标赋予可解释因子。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;UMAP 先为每个点用 $\rho_i$ 保证局部连通，再用 $\sigma_i$ 把 K 近邻距离变成有向模糊边权；通过模糊并集合并双向关系，得到稀疏高维图。低维用重尾边权和交叉熵匹配这张图，正边吸引、负采样排斥。&lt;code&gt;n_neighbors&lt;/code&gt; 控制观察尺度，&lt;code&gt;min_dist&lt;/code&gt; 控制低维打包；&lt;code&gt;transform&lt;/code&gt; 提供有边界的训练外定位，而非对分布外样本的保证。&lt;/p&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;延续手算例，把 $\sigma_i$ 从 0.3 增到 0.6，重新计算三条有向边权。哪条边变化比例最大？这说明局部尺度怎样影响远邻？&lt;/li&gt;
&lt;li&gt;构造两个月牙外加 5% 均匀噪声，扫描 &lt;code&gt;n_neighbors={5,15,50}&lt;/code&gt; 与 &lt;code&gt;min_dist={0.0,0.3,0.8}&lt;/code&gt;，同时报告图连通分量和信任度，不看颜色先判断哪些结构稳定。&lt;/li&gt;
&lt;li&gt;将数据按时间划分，只在早期数据上拟合 UMAP，对晚期数据 &lt;code&gt;transform&lt;/code&gt;。比较新点最近邻距离与低维位置，设计一个“超出训练流形则拒绝解释”的阈值规则。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1802.03426&quot;&gt;McInnes, Healy &amp;#x26; Melville (2018), UMAP: Uniform Manifold Approximation and Projection&lt;/a&gt;：UMAP 的模糊拓扑建模、图构造与低维优化原始论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1602.00370&quot;&gt;Tang et al. (2016), Visualizing Large-scale and High-dimensional Data&lt;/a&gt;：LargeVis 的近邻图与负采样思想，是可扩展嵌入的重要前序工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.nature.com/articles/s41587-020-00801-7&quot;&gt;Narayan et al. (2021), Assessing Single-Cell Transcriptomic Variability through Density-Preserving Data Visualization&lt;/a&gt;：densMAP 对局部密度保持的扩展。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jmlr.org/papers/v22/20-1061.html&quot;&gt;Wang et al. (2021), Understanding How Dimension Reduction Tools Work&lt;/a&gt;：从吸引—排斥谱系比较 t-SNE、UMAP、LargeVis 与相关方法。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://umap-learn.readthedocs.io/en/latest/api.html&quot;&gt;UMAP 0.5.8 官方 API&lt;/a&gt;：当前参数、属性、训练外变换与扩展接口说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;PCA、Isomap、LLE、t-SNE 与 UMAP 都在无标签条件下寻找表示，但它们没有通过任务误差学习多层特征。下一阶段将进入神经网络：先从一个人工神经元讲清线性变换、激活函数和计算图，再追踪多层感知机怎样用反向传播把输出误差分配到每一层参数。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>高维邻居挤进二维后为何成团？t-SNE 的困惑度与重尾相似度</title><link>https://zwjcode.cn/blog/tsne-perplexity-student-t-crowding</link><guid isPermaLink="true">https://zwjcode.cn/blog/tsne-perplexity-student-t-crowding</guid><description>从流形方法难以突出局部簇出发，推导 t-SNE 的邻域概率、困惑度、Student-t 相似度与 KL 目标，并解释图形误读和调试方法。</description><pubDate>Sun, 30 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的局部线性嵌入（Locally Linear Embedding，LLE）保存每个点的局部重构配方，再用谱分解求全局坐标。它适合研究流形参数化，却不一定能在二维图上突出“谁真正像谁”：高维邻居被迫挤进只有两个方向的平面，许多本来不近的点也可能占据相近位置。&lt;/p&gt;
&lt;p&gt;t 分布随机邻域嵌入（t-distributed Stochastic Neighbor Embedding，t-SNE）改变了问题：**不要求低维坐标复现距离本身，而要求高维与低维空间对“哪些点互为邻居”给出相似概率。**本文只讲透三件紧密相连的事：高维邻域概率、困惑度（Perplexity），以及用重尾分布缓解拥挤。&lt;/p&gt;
&lt;h2&gt;01 为什么保存距离仍不等于看清邻居？&lt;/h2&gt;
&lt;p&gt;假设高维点 $x_i\in\mathbb R^D$ 位于多个弯曲、密度不同的局部群体。Isomap 关心全局测地距离，LLE 关心局部重构；而探索性可视化常问的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;与样本 $i$ 最相似的少数样本，在二维图中是否仍围绕它？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;从 $D$ 维压到 2 维时，球壳可用面积急剧减少，远近关系不能全部满足：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;高维局部邻域                     二维直接使用高斯相似度

        ·  ·                              ··
     ·   xᵢ   ·        压缩             ·xᵢ·
        ·  ·             ──►              ··
  大量“中等远”样本                         外围没有足够空间

结果：本来只算普通邻居的点也被推到中心附近，局部结构发生拥挤
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这叫拥挤问题（Crowding Problem）。t-SNE 的关键不是凭空制造更多二维面积，而是让低维相似度的尾部下降得更慢，使中等距离的点仍能被放得更远。&lt;/p&gt;
&lt;h2&gt;02 完整数据流与张量形状&lt;/h2&gt;
&lt;p&gt;设输入 $X\in\mathbb R^{N\times D}$，目标坐标 $Y\in\mathbb R^{N\times d}$，可视化通常取 $d=2$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D]
  │ 两两平方距离
  ▼
Δˣ [N,N]
  │ 每行选择 σᵢ，使困惑度达到目标
  ▼
P_cond [N,N]：p(j|i)，对角为 0，每行和为 1
  │ 对称化并除以 2N
  ▼
P [N,N]：高维联合邻域概率，总和为 1

Y [N,d]（随机或主成分分析 Principal Component Analysis/PCA 初始化）
  │ Student-t 相似度并全局归一化
  ▼
Q [N,N]：低维联合邻域概率，总和为 1
  │ 最小化 KL(P || Q)
  ▼
更新 Y [N,d]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$P$ 是由原数据固定下来的训练目标；$Q$ 随低维坐标改变。标准 t-SNE 不学习从 $x$ 到 $y$ 的显式函数，它直接优化这 $N\times d$ 个坐标。&lt;/p&gt;
&lt;h2&gt;03 高维距离怎样变成“以 i 为中心”的概率？&lt;/h2&gt;
&lt;p&gt;对中心点 $x_i$，先用带宽为 $\sigma_i$ 的高斯核定义条件概率：&lt;/p&gt;
&lt;p&gt;$$
p_{j\mid i}=
\frac{\exp\left(-\lVert x_i-x_j\rVert_2^2/(2\sigma_i^2)\right)}
{\sum_{k\ne i}\exp\left(-\lVert x_i-x_k\rVert_2^2/(2\sigma_i^2)\right)},
\qquad p_{i\mid i}=0
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x_i,x_j\in\mathbb R^D$：第 $i,j$ 个高维样本；&lt;/li&gt;
&lt;li&gt;$\sigma_i&gt;0$：只属于中心点 $i$ 的局部带宽；&lt;/li&gt;
&lt;li&gt;$p_{j\mid i}$：从 $i$ 看过去，选择 $j$ 作为邻居的概率；&lt;/li&gt;
&lt;li&gt;每一行 $p_{\cdot\mid i}\in\mathbb R^N$ 的和为 1。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;带宽小，概率集中在最近的几个点；带宽大，较远点也获得质量。每个点使用自己的 $\sigma_i$，因此稠密区和稀疏区可以采用不同的距离尺度。&lt;/p&gt;
&lt;p&gt;条件概率有方向：$p_{j\mid i}$ 不必等于 $p_{i\mid j}$。t-SNE 将它对称化为联合概率：&lt;/p&gt;
&lt;p&gt;$$
p_{ij}=\frac{p_{j\mid i}+p_{i\mid j}}{2N},
\qquad p_{ii}=0
$$&lt;/p&gt;
&lt;p&gt;于是 $P\in\mathbb R^{N\times N}$ 对称，且 $\sum_{i\ne j}p_{ij}=1$。&lt;/p&gt;
&lt;h2&gt;04 困惑度究竟控制多少邻居？&lt;/h2&gt;
&lt;p&gt;用户不直接指定 $\sigma_i$，而指定每行概率分布的困惑度：&lt;/p&gt;
&lt;p&gt;$$
H(P_i)=-\sum_{j\ne i}p_{j\mid i}\log_2p_{j\mid i}
$$&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Perp}(P_i)=2^{H(P_i)}
$$&lt;/p&gt;
&lt;p&gt;若概率恰好均匀落在 $K$ 个邻居上，熵为 $\log_2K$，困惑度就是 $K$。所以它可粗略理解为“有效邻居数”，但不是硬性的 K 近邻数量。&lt;/p&gt;
&lt;p&gt;算法对每个 $i$ 用二分搜索寻找 $\sigma_i$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;目标困惑度太小于当前值 → 分布太平，缩小 σᵢ
目标困惑度太大于当前值 → 分布太尖，增大 σᵢ
直到 |当前困惑度 - 目标| 足够小
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 三个一维点怎样手算一行概率？&lt;/h2&gt;
&lt;p&gt;取 $x=[0,1,3]$，关注中间点 $x_i=1$，暂定 $\sigma_i=1$。它到另外两点的平方距离分别为 1 和 4：&lt;/p&gt;
&lt;p&gt;$$
w_{0\mid1}=e^{-1/2}\approx0.6065,
\qquad
w_{2\mid1}=e^{-4/2}\approx0.1353
$$&lt;/p&gt;
&lt;p&gt;归一化后：&lt;/p&gt;
&lt;p&gt;$$
p_{0\mid1}=\frac{0.6065}{0.7418}\approx0.8176,
\qquad
p_{2\mid1}\approx0.1824
$$&lt;/p&gt;
&lt;p&gt;这一行的熵约为：&lt;/p&gt;
&lt;p&gt;$$
H\approx-0.8176\log_2 0.8176-0.1824\log_2 0.1824\approx0.685
$$&lt;/p&gt;
&lt;p&gt;所以困惑度约为 $2^{0.685}=1.61$。若目标困惑度是 2，当前分布太集中，应增大 $\sigma_i$，让远处的 $x=3$ 获得更多概率。&lt;/p&gt;
&lt;p&gt;这个例子也揭示了一个边界：困惑度相同不代表物理半径相同。稠密区域的 $\sigma_i$ 可能很小，稀疏区域的 $\sigma_i$ 可能很大。&lt;/p&gt;
&lt;h2&gt;06 为什么低维空间改用 Student-t 重尾分布？&lt;/h2&gt;
&lt;p&gt;给定低维坐标 $y_i,y_j\in\mathbb R^d$，t-SNE 使用自由度为 1 的 Student-t 核，也就是柯西核：&lt;/p&gt;
&lt;p&gt;$$
q_{ij}=
\frac{(1+\lVert y_i-y_j\rVert_2^2)^{-1}}
{\sum_{k\ne l}(1+\lVert y_k-y_l\rVert_2^2)^{-1}},
\qquad q_{ii}=0
$$&lt;/p&gt;
&lt;p&gt;对比尾部下降速度：&lt;/p&gt;
&lt;p&gt;| 低维距离 $r$ |  高斯核 $e^{-r^2}$ | Student-t 核 $(1+r^2)^{-1}$ |
| -----------: | -----------------: | --------------------------: |
|            1 |              0.368 |                       0.500 |
|            2 |              0.018 |                       0.200 |
|            4 | $1.1\times10^{-7}$ |                       0.059 |&lt;/p&gt;
&lt;p&gt;重尾意味着：一对点被分开后，仍保留可观的低维相似度和排斥作用；普通高斯在中等距离处已经近似为零，难以继续把不相似点推出拥挤区域。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;低维作用力

近邻：  pᵢⱼ &gt; qᵢⱼ  → 吸引，缩短 yᵢ 与 yⱼ
伪邻：  pᵢⱼ &amp;#x3C; qᵢⱼ  → 排斥，拉开 yᵢ 与 yⱼ
重尾：  中等距离仍能“感到”排斥
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;07 KL 目标为何更在意“丢失真邻居”？&lt;/h2&gt;
&lt;p&gt;t-SNE 最小化 Kullback-Leibler 散度（Kullback-Leibler Divergence，KL）：&lt;/p&gt;
&lt;p&gt;$$
C(Y)=\operatorname{KL}(P|Q)
=\sum_{i\ne j}p_{ij}\log\frac{p_{ij}}{q_{ij}}
$$&lt;/p&gt;
&lt;p&gt;若高维认为一对点是邻居，$p_{ij}$ 大，但二维把它们分得很远、$q_{ij}$ 小，这一项惩罚很大。反过来，若 $p_{ij}$ 极小，即使二维把它们画得较近，单项权重也较小。&lt;/p&gt;
&lt;p&gt;因此 KL 的方向不对称：t-SNE 优先避免漏掉高维邻居，而不是同等精确地保存所有远点关系。这正适合局部可视化，也意味着&lt;strong&gt;簇间距离、方向和面积不能按原空间尺度解释&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;目标对坐标的梯度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial C}{\partial y_i}
=4\sum_j(p_{ij}-q_{ij})
(1+\lVert y_i-y_j\rVert^2)^{-1}(y_i-y_j)
$$&lt;/p&gt;
&lt;p&gt;求和中的每一项都是一对点的吸引或排斥。训练常配合动量、学习率和早期夸大（Early Exaggeration）：开始阶段临时放大 $P$，先拉紧可靠邻域并拉开群体，之后再优化真实目标。&lt;/p&gt;
&lt;h2&gt;08 不调用 TSNE，写出可检查的概率本体&lt;/h2&gt;
&lt;p&gt;下面实现高维联合概率和低维 KL；它使用 $O(N^2)$ 内存，只用于小数据教学与单元测试：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def _conditional_row(dist2, target_perplexity, max_steps=60):
    lo, hi = -20.0, 20.0  # 搜索 log(sigma)
    for _ in range(max_steps):
        sigma = np.exp((lo + hi) / 2)
        weights = np.exp(-dist2 / (2 * sigma**2))
        probs = weights / weights.sum()
        entropy = -(probs * np.log2(np.maximum(probs, 1e-300))).sum()
        perplexity = 2**entropy
        if perplexity &gt; target_perplexity:
            hi = np.log(sigma)       # 分布太平，缩小 sigma
        else:
            lo = np.log(sigma)       # 分布太尖，增大 sigma
    return probs, sigma

def joint_probabilities(X, perplexity=5.0):
    X = np.asarray(X, dtype=float)                       # [N,D]
    n = len(X)
    if not 1 &amp;#x3C;= perplexity &amp;#x3C; n:
        raise ValueError(&apos;perplexity 必须位于 [1, N)&apos;)
    dist2 = ((X[:, None] - X[None, :]) ** 2).sum(axis=2)  # [N,N]
    conditional = np.zeros((n, n))
    sigmas = np.zeros(n)
    for i in range(n):
        mask = np.arange(n) != i
        conditional[i, mask], sigmas[i] = _conditional_row(
            dist2[i, mask], perplexity
        )
    P = (conditional + conditional.T) / (2 * n)
    np.fill_diagonal(P, 0.0)
    return P, sigmas

def low_dimensional_probabilities(Y):
    dist2 = ((Y[:, None] - Y[None, :]) ** 2).sum(axis=2)  # [N,N]
    numerator = 1.0 / (1.0 + dist2)
    np.fill_diagonal(numerator, 0.0)
    return numerator / numerator.sum()

def kl_loss(P, Y):
    Q = low_dimensional_probabilities(Y)
    mask = P &gt; 0
    return np.sum(P[mask] * np.log(P[mask] / np.maximum(Q[mask], 1e-300)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可先断言 &lt;code&gt;P&lt;/code&gt;、&lt;code&gt;Q&lt;/code&gt; 对称、对角为 0、总和为 1，再实现梯度。生产实现还需要近似近邻、Barnes-Hut 或 FFT 加速、稳定优化和停止条件，不应直接扩展这段稠密代码。&lt;/p&gt;
&lt;h2&gt;09 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html&quot;&gt;&lt;code&gt;TSNE&lt;/code&gt; API&lt;/a&gt; 使用 &lt;code&gt;max_iter&lt;/code&gt;，默认 &lt;code&gt;learning_rate=&apos;auto&apos;&lt;/code&gt; 与 &lt;code&gt;init=&apos;pca&apos;&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE, trustworthiness
from sklearn.preprocessing import StandardScaler

X_scaled = StandardScaler().fit_transform(X)             # [N,D]

# 高维稠密数据先降到约 50 维，可抑制噪声并减少距离计算成本
n_pre = min(50, X_scaled.shape[1], X_scaled.shape[0] - 1)
X_pre = PCA(n_components=n_pre, random_state=42).fit_transform(X_scaled)

tsne = TSNE(
    n_components=2,
    perplexity=30.0,          # 必须小于 N，应扫描多个尺度
    early_exaggeration=12.0,
    learning_rate=&apos;auto&apos;,
    max_iter=1000,
    init=&apos;pca&apos;,
    method=&apos;barnes_hut&apos;,
    angle=0.5,
    random_state=42,
    verbose=1,
)
Y = tsne.fit_transform(X_pre)                            # [N,2]

assert Y.shape == (len(X), 2)
assert np.isfinite(Y).all()
print(&apos;final KL:&apos;, tsne.kl_divergence_)
print(&apos;iterations:&apos;, tsne.n_iter_)
print(&apos;trustworthiness:&apos;, trustworthiness(X_pre, Y, n_neighbors=10))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;稀疏输入应考虑 &lt;code&gt;TruncatedSVD&lt;/code&gt;，而不是强行中心化后做稠密 PCA。官方建议在特征极多时先压到合理维数，例如 50，再运行 t-SNE。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;method=&apos;barnes_hut&apos;&lt;/code&gt; 将排斥项近似到约 $O(N\log N)$，但只支持较低输出维数；&lt;code&gt;angle&lt;/code&gt; 越小通常越精确也越慢。&lt;code&gt;method=&apos;exact&apos;&lt;/code&gt; 约为 $O(N^2)$，适合小数据核对，不是大样本默认选择。&lt;/p&gt;
&lt;h2&gt;10 为什么它没有自然的 transform？&lt;/h2&gt;
&lt;p&gt;scikit-learn 的 &lt;code&gt;TSNE&lt;/code&gt; 只有 &lt;code&gt;fit_transform&lt;/code&gt;，没有把新样本映射到旧图的 &lt;code&gt;transform&lt;/code&gt;。原因不是接口遗漏，而是标准目标把&lt;strong&gt;所有训练坐标联合优化&lt;/strong&gt;：加入新点会改变归一化、吸引和排斥关系，旧点的最佳位置也可能移动。&lt;/p&gt;
&lt;p&gt;若必须处理新样本，应明确改变了算法语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用支持插值/参数化映射的实现，并在独立数据上验证；&lt;/li&gt;
&lt;li&gt;训练监督模型从原特征预测已有 t-SNE 坐标，但它只是近似器；&lt;/li&gt;
&lt;li&gt;改用有训练外变换的 UMAP、PCA 或自编码器；&lt;/li&gt;
&lt;li&gt;为可复现报告固定训练样本集合，不把每批新数据悄悄拼入旧图。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;二维 t-SNE 坐标通常用于探索和展示，不应未经验证直接作为稳定线上特征。&lt;/p&gt;
&lt;h2&gt;11 困惑度、学习率和初始化怎样诊断？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;扫描多个困惑度&lt;/strong&gt;：例如 5、15、30、50；只相信跨合理范围反复出现的局部关系。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定样本与预处理&lt;/strong&gt;：每次图变化只能归因于参数或种子，而不是数据过滤。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比较多个随机种子&lt;/strong&gt;：目标非凸；局部群体若不断拆合，结论不稳定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;查看 KL 轨迹&lt;/strong&gt;：早期夸大阶段若代价持续上升，夸大因子或学习率可能过高。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;识别典型形状&lt;/strong&gt;：学习率过高常出现近似等距的“球”；过低常挤成密云并伴随少量离群点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同时看邻域指标&lt;/strong&gt;：信任度（Trustworthiness）检查二维近邻中有多少是原空间伪邻居，但仍不能验证全局距离。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 最常见的图形误读与调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把簇间空白当真实距离&lt;/strong&gt;：t-SNE 没有保存全局尺度；对候选簇回到原特征计算距离、分类可分性与稳定性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把点团面积当样本方差&lt;/strong&gt;：每点自适应带宽会部分平衡密度，二维团块面积不等于原空间密度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;看到分团就宣布类别存在&lt;/strong&gt;：随机连续流形也可能呈现岛状；用已知模拟数据、重采样和外部标签盲测。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用标签参与调图再声称发现标签&lt;/strong&gt;：颜色与参数选择会引入确认偏差；先固定无标签流程。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略尺度与度量&lt;/strong&gt;：标准化、余弦距离或领域距离会改写高维邻居；先验证近邻检索本身。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把重复点画成一个点&lt;/strong&gt;：记录重复行计数与透明度；重叠不代表只有一个样本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只保存图片&lt;/strong&gt;：同时保存数据版本、样本顺序、预处理器、全部参数、库版本、种子与坐标数组。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最短调试顺序是：检查非有限值和重复点 → 检查距离与近邻 → 扫困惑度和种子 → 观察 KL 与信任度 → 回到原空间验证视觉假设。&lt;/p&gt;
&lt;h2&gt;13 失败场景与相近方法边界&lt;/h2&gt;
&lt;p&gt;| 方法   | 主要保存对象           | 新样本映射         | 适合回答的问题                   |
| ------ | ---------------------- | ------------------ | -------------------------------- |
| PCA    | 全局线性方差/重构      | 有确定 &lt;code&gt;transform&lt;/code&gt; | 大方向、压缩、稳定下游特征       |
| Isomap | 近邻图上的全局测地距离 | 有近似 &lt;code&gt;transform&lt;/code&gt; | 单一流形的全局展开               |
| LLE    | 局部线性重构权重       | 有局部重构式变换   | 局部仿射结构是否可保持           |
| t-SNE  | 高低维邻域联合概率     | 标准形式没有       | 哪些样本在多个局部尺度上互为邻居 |
| UMAP   | 模糊近邻图与低维边权   | 有近似 &lt;code&gt;transform&lt;/code&gt; | 更可扩展的局部拓扑可视化与表示   |&lt;/p&gt;
&lt;p&gt;t-SNE 在样本极少、噪声邻居不可靠、距离度量不合语义或数据持续变化时尤其容易误导。它也不会自动完成聚类：先做 t-SNE 再对二维坐标运行 K 均值，得到的更多是可视化目标下的分组，而非原空间聚类保证。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;t-SNE 先为每个高维样本选择满足目标困惑度的局部带宽，构造对称联合概率 $P$；再用 Student-t 重尾核从低维坐标构造 $Q$，最小化 $\operatorname{KL}(P|Q)$。它擅长保存局部邻居，却故意放弃可直接解释的全局距离、面积和方向；可靠使用依赖多尺度、多种子、原空间复核和完整实验记录。&lt;/p&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 $x=[0,1,3]$ 的手算例，把 $\sigma_i$ 从 1 增到 2，重新计算 $p_{0\mid1}$、$p_{2\mid1}$ 与困惑度。为什么困惑度会上升？&lt;/li&gt;
&lt;li&gt;构造两条连续相连但密度不同的曲线，扫描 &lt;code&gt;perplexity={5,30,80}&lt;/code&gt;。哪些岛状结构跨尺度稳定？哪些只是参数造成的断裂？&lt;/li&gt;
&lt;li&gt;在同一数据上比较 &lt;code&gt;init=&apos;pca&apos;&lt;/code&gt; 与 &lt;code&gt;init=&apos;random&apos;&lt;/code&gt; 的五个种子，先做普罗克拉斯特对齐（Procrustes Alignment），再比较 10 近邻重合率。坐标旋转与邻域改变应怎样区分？&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jmlr.org/papers/v9/vandermaaten08a.html&quot;&gt;van der Maaten &amp;#x26; Hinton (2008), Visualizing Data using t-SNE&lt;/a&gt;：t-SNE 的高低维概率、重尾核与优化方法原始论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jmlr.org/papers/v15/vandermaaten14a.html&quot;&gt;van der Maaten (2014), Accelerating t-SNE using Tree-Based Algorithms&lt;/a&gt;：Barnes-Hut 近似如何加速排斥项。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.nature.com/articles/s41592-018-0308-4&quot;&gt;Linderman et al. (2019), Fast Interpolation-based t-SNE&lt;/a&gt;：用插值和 FFT 扩展到更大样本的代表工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.nature.com/articles/s41467-019-13056-x&quot;&gt;Kobak &amp;#x26; Berens (2019), The Art of Using t-SNE for Single-Cell Transcriptomics&lt;/a&gt;：多尺度参数、初始化和生物数据解释的系统实践。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html&quot;&gt;scikit-learn 1.9: TSNE&lt;/a&gt;：当前参数、属性、复杂度与官方实践提示。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;t-SNE 用概率邻域做出了清晰的局部图，却没有自然的新样本映射，且全局结构很难解释。下一篇将研究 UMAP：它怎样把每个点的近邻半径变成模糊图边权，再用交叉熵同时吸引真邻居、采样排斥非邻居，并由 &lt;code&gt;n_neighbors&lt;/code&gt; 与 &lt;code&gt;min_dist&lt;/code&gt; 控制局部—全局和团块紧密度。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>只保留邻域配方，弯曲流形怎样展开？LLE 的重构权重与谱嵌入</title><link>https://zwjcode.cn/blog/lle-local-reconstruction-spectral-embedding</link><guid isPermaLink="true">https://zwjcode.cn/blog/lle-local-reconstruction-spectral-embedding</guid><description>从全局最短路对捷径敏感的问题出发，推导 LLE 的局部重构权重与全局特征向量解，手算病态邻域并实现可诊断流程。</description><pubDate>Sat, 29 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 Isomap 用近邻图最短路近似测地距离，再让低维坐标复现所有样本对的全局距离。它能把瑞士卷摊开，但一条跨折叠捷径会缩短许多路径；当全局距离不够可靠时，我们能否只保存每个点周围的小块几何？&lt;/p&gt;
&lt;p&gt;局部线性嵌入（Locally Linear Embedding，LLE）的答案是：**先学习每个点由邻居线性重构的配方，再寻找一组低维坐标，使同一配方仍能重构它。**本文聚焦标准 LLE 的邻域、权重与特征向量解；改进变体只用于说明边界。&lt;/p&gt;
&lt;h2&gt;01 为什么局部线性比全局距离更容易相信？&lt;/h2&gt;
&lt;p&gt;光滑曲面放大一小块后近似平面。即使整条曲线弯曲，一个点通常仍可由两侧邻居线性插值得到：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;高维弯曲流形                       低维展开

      x₂                              y₂
    ╱    ╲                           ╱  ╲
  x₁   xᵢ   x₃        ──►           y₁  yᵢ  y₃
       ↑                              ↑
  xᵢ ≈ 0.5x₁+0.5x₃              yᵢ ≈ 0.5y₁+0.5y₃

保存的不是绝对坐标，而是邻域里的重构权重
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;旋转、平移或把整块流形弯起来，局部“谁能由谁拼出”的关系可能仍保持。LLE 用这些局部配方代替 Isomap 的全局测地距离。&lt;/p&gt;
&lt;h2&gt;02 完整数据流与张量形状&lt;/h2&gt;
&lt;p&gt;设 $X\in\mathbb R^{N\times D}$，每点有 $K$ 个邻居，输出维数为 $d$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D]
  │ KNN
  ▼
J [N,K]：每点邻居索引
  │ 每点解一个 K×K 线性系统
  ▼
W [N,N]：稀疏重构权重，每行仅 K 个非零，行和为 1
  │ M=(I-W)ᵀ(I-W)
  ▼
M [N,N]：全局嵌入代价矩阵
  │ 最小的 d+1 个特征向量，丢弃常数向量
  ▼
Y [N,d]：低维坐标
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;训练分两次优化：先固定原数据学习 $W$，再固定 $W$ 学习 $Y$。它不是用梯度下降同时更新一个神经网络。&lt;/p&gt;
&lt;h2&gt;03 每个点的重构权重优化什么？&lt;/h2&gt;
&lt;p&gt;记点 $x_i\in\mathbb R^D$ 的近邻集合为 $\mathcal N(i)$，LLE 求：&lt;/p&gt;
&lt;p&gt;$$
\min_{w_i}\left\lVert x_i-\sum_{j\in\mathcal N(i)}w_{ij}x_j\right\rVert_2^2,
\qquad
\sum_jw_{ij}=1
$$&lt;/p&gt;
&lt;p&gt;和为 1 的约束让权重对整体平移不变。若所有点都加向量 $a$：&lt;/p&gt;
&lt;p&gt;$$
\sum_jw_{ij}(x_j+a)=\sum_jw_{ij}x_j+a
$$&lt;/p&gt;
&lt;p&gt;因此重构误差不因坐标原点改变。标准 LLE 的权重可以为负；它是局部仿射坐标，不是概率。&lt;/p&gt;
&lt;p&gt;把邻居相对中心点的差写成：&lt;/p&gt;
&lt;p&gt;$$
Z_i=\begin{bmatrix}
(x_{j_1}-x_i)^\top\
\vdots\
(x_{j_K}-x_i)^\top
\end{bmatrix}\in\mathbb R^{K\times D}
$$&lt;/p&gt;
&lt;p&gt;局部协方差（更准确地说是局部 Gram 矩阵）为：&lt;/p&gt;
&lt;p&gt;$$
C_i=Z_iZ_i^\top\in\mathbb R^{K\times K}
$$&lt;/p&gt;
&lt;p&gt;目标变成 $w_i^\top C_iw_i$。带和约束的解可由一次线性方程得到：&lt;/p&gt;
&lt;p&gt;$$
C_i\tilde w_i=\mathbf1,
\qquad
w_i=\frac{\tilde w_i}{\mathbf1^\top\tilde w_i}
$$&lt;/p&gt;
&lt;p&gt;实现时应使用 &lt;code&gt;solve&lt;/code&gt;，不要显式计算 $C_i^{-1}$。&lt;/p&gt;
&lt;h2&gt;04 两个邻居的手算例为何暴露病态矩阵？&lt;/h2&gt;
&lt;p&gt;一维点 $x_i=1$ 的两个邻居是 $x_1=0,x_2=2$。直觉上：&lt;/p&gt;
&lt;p&gt;$$
x_i=0.5x_1+0.5x_2
$$&lt;/p&gt;
&lt;p&gt;相对差与局部矩阵是：&lt;/p&gt;
&lt;p&gt;$$
Z_i=\begin{bmatrix}-1\1\end{bmatrix},\qquad
C_i=\begin{bmatrix}1&amp;#x26;-1\-1&amp;#x26;1\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;$C_i$ 的两行互为相反数，行列式为 0，无法直接求解。这不是偶然：当 $K&gt;D$ 或邻域位于更低维平面时，$C_i$ 最多只有秩 $D$。&lt;/p&gt;
&lt;p&gt;标准做法是按局部尺度加对角正则：&lt;/p&gt;
&lt;p&gt;$$
C_i&apos; = C_i + r\operatorname{tr}(C_i)I
$$&lt;/p&gt;
&lt;p&gt;若 $r=10^{-3}$，这里对角线各加 0.002。由对称性，求解并归一化后仍得 $w_i=[0.5,0.5]$，但线性系统现在可逆。&lt;/p&gt;
&lt;h2&gt;05 低维坐标为何仍用同一组权重？&lt;/h2&gt;
&lt;p&gt;权重固定后，LLE 求低维点 $y_i\in\mathbb R^d$：&lt;/p&gt;
&lt;p&gt;$$
\min_Y\Phi(Y)=\sum_i\left\lVert y_i-\sum_jw_{ij}y_j\right\rVert_2^2
$$&lt;/p&gt;
&lt;p&gt;把所有权重放入 $W\in\mathbb R^{N\times N}$，则：&lt;/p&gt;
&lt;p&gt;$$
\Phi(Y)=\lVert(I-W)Y\rVert_F^2
=\operatorname{tr}(Y^\top MY),
\qquad
M=(I-W)^\top(I-W)
$$&lt;/p&gt;
&lt;p&gt;若不加约束，所有 $y_i=0$ 就能让代价为 0。为排除平移与塌缩，常要求：&lt;/p&gt;
&lt;p&gt;$$
Y^\top\mathbf1=0,
\qquad
\frac1N Y^\top Y=I_d
$$&lt;/p&gt;
&lt;p&gt;于是解为 $M$ 最小的非平凡特征向量。由于 $W\mathbf1=\mathbf1$，有 $(I-W)\mathbf1=0$；最小特征值对应常数向量，只表示整体平移，必须丢弃。接下来的 $d$ 个特征向量形成嵌入。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;M 的特征值：  0       λ₁       λ₂       λ₃ ...
               │        │        │
             常数     坐标1    坐标2
             丢弃      └── Y [N,2] ──┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特征向量可整体变号，多个接近的特征值还允许子空间内旋转。因此比较两次嵌入时，应先做 Procrustes 对齐，不能逐坐标要求完全相等。&lt;/p&gt;
&lt;h2&gt;06 训练算法伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input: X [N,D], K, d, reg
J = knn_indices(X, K)                            # [N,K]
W = zeros(N,N)

for i in 0..N-1:
    Z = X[J[i]] - X[i]                          # [K,D]
    C = Z @ Z.T                                 # [K,K]
    C += reg * trace(C) * I                     # 局部尺度正则
    raw = solve(C, ones(K))                     # [K]
    W[i, J[i]] = raw / sum(raw)                 # 行和为 1

M = (I-W).T @ (I-W)                            # [N,N]
values, vectors = eigh(M)                       # 升序
Y = sqrt(N) * vectors[:, 1:d+1]                 # 跳过常数向量
return Y, W, J
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;工程实现通常保留稀疏 $W$ 并使用部分特征分解；教学版的稠密矩阵只适合小数据。&lt;/p&gt;
&lt;h2&gt;07 不调用 LLE，写出最小 NumPy 实现&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

def standard_lle(X, n_neighbors=8, n_components=2, reg=1e-3):
    X = np.asarray(X, dtype=float)                    # [N,D]
    n_samples = X.shape[0]
    if not n_components &amp;#x3C; n_neighbors &amp;#x3C; n_samples:
        raise ValueError(&apos;需要 n_components &amp;#x3C; n_neighbors &amp;#x3C; n_samples&apos;)

    distances2 = ((X[:, None, :] - X[None, :, :]) ** 2).sum(axis=2)
    np.fill_diagonal(distances2, np.inf)
    neighbors = np.argsort(distances2, axis=1)[:, :n_neighbors]  # [N,K]

    W = np.zeros((n_samples, n_samples))              # 教学用稠密 [N,N]
    ones = np.ones(n_neighbors)
    for i, idx in enumerate(neighbors):
        Z = X[idx] - X[i]                             # [K,D]
        C = Z @ Z.T                                   # [K,K]
        scale = np.trace(C)
        C.flat[:: n_neighbors + 1] += reg * max(scale, 1e-12)
        weights = np.linalg.solve(C, ones)
        weights /= weights.sum()
        W[i, idx] = weights

    A = np.eye(n_samples) - W
    M = A.T @ A                                       # [N,N]
    eigenvalues, eigenvectors = np.linalg.eigh(M)
    Y = np.sqrt(n_samples) * eigenvectors[:, 1:n_components + 1]

    assert np.allclose(W.sum(axis=1), 1.0)
    assert np.allclose(Y.mean(axis=0), 0.0, atol=1e-8)
    return Y, W, neighbors, eigenvalues
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段实现的近邻搜索、$W$ 和特征分解都是平方级内存，不用于大规模生产；它的作用是让每个数组和约束都可检查。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.manifold.LocallyLinearEmbedding.html&quot;&gt;&lt;code&gt;LocallyLinearEmbedding&lt;/code&gt; API&lt;/a&gt; 提供标准方法和三个变体：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.manifold import LocallyLinearEmbedding, trustworthiness
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)       # [N,D]

lle = LocallyLinearEmbedding(
    n_neighbors=12,
    n_components=2,
    reg=1e-3,
    method=&apos;standard&apos;,       # standard / modified / hessian / ltsa
    eigen_solver=&apos;auto&apos;,     # auto / arpack / dense
    random_state=42,         # eigen_solver=&apos;arpack&apos; 时控制随机性
    n_jobs=-1,
)
Y_train = lle.fit_transform(X_train_scaled)           # [N,2]

assert Y_train.shape == (len(X_train), 2)
assert lle.embedding_.shape == Y_train.shape
assert np.isfinite(lle.reconstruction_error_)

score = trustworthiness(
    X_train_scaled,
    Y_train,
    n_neighbors=12,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;embedding_&lt;/code&gt; 是训练坐标，&lt;code&gt;reconstruction_error_&lt;/code&gt; 是当前嵌入的重构代价，&lt;code&gt;nbrs_&lt;/code&gt; 保存拟合后的近邻搜索器。误差只衡量模型自己的目标；一个过大的邻域仍可能得到数值较小却几何错误的结果。&lt;/p&gt;
&lt;p&gt;官方文档提醒 ARPACK 在部分问题上不稳定。若出现不收敛或不同种子差异大，先检查近邻图和局部病态性，再比较多个随机种子或小数据上的 &lt;code&gt;eigen_solver=&apos;dense&apos;&lt;/code&gt;；不要只机械增加 &lt;code&gt;max_iter&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;09 新样本 transform 的能力与边界&lt;/h2&gt;
&lt;p&gt;scikit-learn 1.9 提供训练外变换：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;X_valid_scaled = scaler.transform(X_valid)           # [Q,D]
Y_valid = lle.transform(X_valid_scaled)               # [Q,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;新点会在训练集邻域中获得局部权重，再用训练嵌入坐标重构位置；训练点不会为它重新移动。这种扩展适合靠近训练流形的新点，不能证明远离流形的查询仍有意义。&lt;/p&gt;
&lt;p&gt;官方还特别提醒：&lt;code&gt;transform&lt;/code&gt; 的缩放行为不适合直接与 SVM 等非尺度不变方法无检查组合。若低维坐标要进入下游模型，应在每个训练折内拟合“原特征缩放 → LLE → 低维再缩放 → 预测器”，并用部署时完全相同的顺序变换验证集。&lt;/p&gt;
&lt;h2&gt;10 K、正则和维数怎样调？&lt;/h2&gt;
&lt;p&gt;邻域数 $K$ 同时控制几何与数值：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;K 太小&lt;/strong&gt;：图可能断开，局部估计对噪声和单点删除敏感。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;K 太大&lt;/strong&gt;：一个邻域跨过弯折、分支或不同层，局部线性假设被破坏；当 $K&gt;D$ 时局部矩阵还天然秩亏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;正则太小&lt;/strong&gt;：病态线性系统产生巨大正负权重；太大则抹平真实局部方向。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;d 太小&lt;/strong&gt;：必须折叠本来不同的内在方向；太大可能保存噪声与不稳定特征向量。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;建议联合记录：近邻图连通分量、邻居距离分位数、每个 $C_i$ 的条件数、最大 $|w_{ij}|$、负权重比例、特征值间隙、&lt;code&gt;reconstruction_error_&lt;/code&gt;、信任度、重采样稳定性和下游验证分数。&lt;/p&gt;
&lt;h2&gt;11 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把权重当概率&lt;/strong&gt;：检查是否存在负值；它们是仿射重构系数，行和为 1 不代表非负。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忘记排除自身近邻&lt;/strong&gt;：自身权重为 1 会让局部目标退化；断言邻居索引不含行号。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;显式求逆&lt;/strong&gt;：用 &lt;code&gt;solve(C, ones)&lt;/code&gt;，并记录条件数和失败点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;统一加固定绝对正则&lt;/strong&gt;：不同尺度邻域受影响不同；标准 LLE 按 &lt;code&gt;trace(C)&lt;/code&gt; 缩放正则。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看二维图挑参数&lt;/strong&gt;：视觉判断易受旋转、颜色与抽样影响；加入信任度、稳定性和任务指标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;全数据先拟合再切分&lt;/strong&gt;：验证样本参与近邻和全局特征向量；所有无监督步骤也必须只在训练折拟合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;逐坐标比较两次结果&lt;/strong&gt;：先消除平移、旋转、反射与尺度自由度，再判断结构是否改变。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 它会在哪些流形上失败？&lt;/h2&gt;
&lt;p&gt;标准 LLE 对非均匀采样、强噪声、异常点、相交流形和分支结构敏感。曲率半径小于邻域尺度时，一个局部块不再近似平面；靠近边界的点只有单侧邻居，权重也更不稳定。&lt;/p&gt;
&lt;p&gt;大规模时，近邻搜索之外还要为每个点解 $K\times K$ 线性系统，并求 $N\times N$ 稀疏矩阵的部分特征向量。官方给出的标准 LLE 复杂度包含 $O(DNK^3)$ 的权重构造和约 $O(dN^2)$ 的特征步骤；它并非百万样本默认降维器。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;method=&apos;modified&apos;&lt;/code&gt; 用多组权重缓解标准 LLE 的正则问题；&lt;code&gt;hessian&lt;/code&gt; 与 &lt;code&gt;ltsa&lt;/code&gt; 保存不同的局部微分结构，并有更严格的邻居数要求。它们不是无条件升级，仍需基于数据、稳定性和官方约束验证。&lt;/p&gt;
&lt;h2&gt;13 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法                | 保存的核心关系         | 错误传播方式                   | 主要适用与限制                   |
| ------------------- | ---------------------- | ------------------------------ | -------------------------------- |
| PCA                 | 全局线性投影与方差     | 全局但目标凸、稳定             | 快、可外推；不能展开非线性流形   |
| Isomap              | 所有点对的图测地距离   | 一条捷径可改写许多最短路       | 保留全局距离；怕断图与短路       |
| LLE                 | 每点的局部线性重构权重 | 先局部建权重，再由谱解全局耦合 | 保存局部配方；怕病态邻域         |
| Laplacian Eigenmaps | 邻边在低维仍靠近       | 由图拉普拉斯传播               | 与谱聚类紧密；不显式保存重构配方 |
| t-SNE               | 高低维邻域概率匹配     | 强调局部概率、牺牲全局尺度     | 可视化强；簇距与面积不宜直接解释 |&lt;/p&gt;
&lt;p&gt;LLE 适合“局部可以近似为线性片，并且局部重构关系比全局距离更可信”的中小规模数据。它仍是基于样本图的转导式几何方法，不会自动学得可泛化的深层语义表示。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;LLE 先在每个高维邻域求和为 1 的线性重构权重，再用 $M=(I-W)^\top(I-W)$ 的最小非平凡特征向量寻找低维坐标。核心风险在邻域病态、正则敏感和全局谱求解；可靠实践必须检查权重、条件数、特征值、重采样稳定性、训练外接入和数据泄漏，而不是把 &lt;code&gt;fit_transform&lt;/code&gt; 当成黑盒绘图函数。&lt;/p&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;在手算例中把邻居改为 $x_1=0,x_2=3$，在和为 1 的约束下求能精确重构 $x_i=1$ 的两个权重。它们是否仍各为 0.5？&lt;/li&gt;
&lt;li&gt;对 S 曲线扫描 &lt;code&gt;n_neighbors={4,8,16,32}&lt;/code&gt; 和 &lt;code&gt;reg={1e-5,1e-3,1e-1}&lt;/code&gt;，记录最大绝对权重、负权重比例、重构误差与信任度，解释几何错误和数值错误的区别。&lt;/li&gt;
&lt;li&gt;删除一个近邻图中的“桥点”，对两次 LLE 嵌入做 Procrustes 对齐后比较。哪些变化只是坐标自由度，哪些表示全局结构真的不稳定？&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1126/science.290.5500.2323&quot;&gt;Roweis &amp;#x26; Saul (2000), Nonlinear Dimensionality Reduction by Locally Linear Embedding&lt;/a&gt;：LLE 原始论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jmlr.org/papers/v4/saul03a.html&quot;&gt;Saul &amp;#x26; Roweis (2003), Think Globally, Fit Locally&lt;/a&gt;：LLE 算法、性质与实践的系统展开。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2006/hash/9cfdf10e8fc047a44b08ed031e1f0ed1-Abstract.html&quot;&gt;Zhang &amp;#x26; Wang (2007), MLLE: Modified Locally Linear Embedding Using Multiple Weights&lt;/a&gt;：用多重权重缓解局部正则问题。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1073/pnas.1031596100&quot;&gt;Donoho &amp;#x26; Grimes (2003), Hessian Eigenmaps&lt;/a&gt;：用局部 Hessian 约束恢复参数化坐标。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.manifold.LocallyLinearEmbedding.html&quot;&gt;scikit-learn 1.9: LocallyLinearEmbedding&lt;/a&gt;：当前参数、变体、属性与训练外变换说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;Isomap 与 LLE 都试图保存确定性的几何关系。下一篇将转向面向可视化的概率邻域：t-SNE 怎样把高维相似度变成条件概率，并用重尾分布缓解低维空间的拥挤问题，同时看清“簇之间很远”为什么不等于原数据真的相距很远。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>卷起来的二维面怎样被摊平？Isomap 的测地距离与经典 MDS</title><link>https://zwjcode.cn/blog/isomap-geodesic-shortest-path-mds</link><guid isPermaLink="true">https://zwjcode.cn/blog/isomap-geodesic-shortest-path-mds</guid><description>从欧氏距离穿透弯曲流形的问题出发，手算近邻图最短路与经典 MDS，并用 NumPy 和 scikit-learn 1.9 实现可诊断的 Isomap。</description><pubDate>Sat, 29 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的谱聚类把样本变成相似图，再用图拉普拉斯的低频特征向量寻找离散簇。但一卷带颜色的纸并没有天然簇：我们更想恢复纸面上连续的横纵坐标，让相邻颜色仍相邻、沿纸面很远的点也不要被折叠误导。&lt;/p&gt;
&lt;p&gt;等距映射（Isometric Mapping，Isomap）把这个问题拆成三步：**用近邻图限制可走的局部边，用最短路近似沿流形的测地距离，再用经典多维尺度分析恢复低维坐标。**本文只讲透这条数据流。&lt;/p&gt;
&lt;h2&gt;01 PCA 为什么会把卷起来的纸压扁？&lt;/h2&gt;
&lt;p&gt;主成分分析（Principal Component Analysis，PCA）只能寻找一个全局线性子空间。对瑞士卷（Swiss Roll）式数据，纸面上相距很远的两层可能在三维空间中几乎贴在一起：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;纸面真实顺序                  卷起后的三维截面

A—B—C—D—E—F                  C ··· D
沿纸面距离逐步增加           B       E
                             A ··· F

A 到 F：沿纸面很远           A 到 F：直线穿过空气却很近
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接保存原空间欧氏距离会把 &lt;code&gt;A—F&lt;/code&gt; 误当近邻；PCA 的直线投影也可能把不同层叠在一起。Isomap 的流形假设（Manifold Assumption）是：高维观测位于一个低维、局部近似平坦的曲面上。小范围欧氏距离可信，全局距离必须沿曲面累加。&lt;/p&gt;
&lt;h2&gt;02 从高维样本到低维坐标经历什么？&lt;/h2&gt;
&lt;p&gt;设输入 $X\in\mathbb R^{N\times D}$，近邻数为 $K$，输出维数为 $d$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D]
  │ 每点找 K 个近邻
  ▼
G [N,N] 稀疏加权图：局部边权 = 原空间距离
  │ 全源最短路
  ▼
D_geo [N,N]：图上的测地距离估计
  │ 平方、双中心化
  ▼
B = -1/2 H (D_geo ⊙ D_geo) H [N,N]
  │ 最大 d 个正特征值/特征向量
  ▼
Y = V_d Λ_d^(1/2) [N,d]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$G$ 只允许沿局部边移动；$D_{geo,ij}$ 是从 $i$ 到 $j$ 的最短路径长度。经典多维尺度分析（Classical Multidimensional Scaling，Classical MDS）再寻找一组点，使其两两欧氏距离尽量复现 $D_{geo}$。&lt;/p&gt;
&lt;h2&gt;03 近邻图怎样决定“能走哪里”？&lt;/h2&gt;
&lt;p&gt;常见做法是把每个样本连到 $K$ 个最近邻，边权保留原始距离：&lt;/p&gt;
&lt;p&gt;$$
w_{ij}=\lVert x_i-x_j\rVert_2
$$&lt;/p&gt;
&lt;p&gt;注意这里的权重是&lt;strong&gt;距离&lt;/strong&gt;，越小越近；上一篇谱聚类的 affinity 是相似度，越大越近。无边位置应视为 $+\infty$，不能填 0。&lt;/p&gt;
&lt;p&gt;有向 K 近邻关系通常会被对称化。$K$ 太小，图会断开，跨分量距离为无穷；$K$ 太大，近邻边可能从卷的一层穿到另一层，形成短路（Short Circuit）。因此 $K$ 不是普通的“越大越平滑”，而是在连通性与局部性之间取舍。&lt;/p&gt;
&lt;h2&gt;04 最短路如何近似测地距离？&lt;/h2&gt;
&lt;p&gt;设四个点沿一条弯曲细带依次相邻，每条局部边长度为 1：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A ━1━ B ━1━ C ━1━ D
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然高维坐标里 A 与 D 可能靠得很近，近邻图没有 &lt;code&gt;A—D&lt;/code&gt; 穿透边。Dijkstra 或 Floyd–Warshall 算法得到：&lt;/p&gt;
&lt;p&gt;$$
D_{geo}=\begin{bmatrix}
0&amp;#x26;1&amp;#x26;2&amp;#x26;3\
1&amp;#x26;0&amp;#x26;1&amp;#x26;2\
2&amp;#x26;1&amp;#x26;0&amp;#x26;1\
3&amp;#x26;2&amp;#x26;1&amp;#x26;0
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;例如 $D_{geo}(A,D)=1+1+1=3$。采样足够密、图边确实局部时，许多小弦长度之和可逼近曲面上的弧长；采样稀疏或噪声大时，这个近似会失真。&lt;/p&gt;
&lt;h2&gt;05 经典 MDS 怎样从距离恢复坐标？&lt;/h2&gt;
&lt;p&gt;坐标若已中心化，Gram 矩阵 $B=YY^\top$ 保存内积。距离平方满足：&lt;/p&gt;
&lt;p&gt;$$
d_{ij}^2=B_{ii}+B_{jj}-2B_{ij}
$$&lt;/p&gt;
&lt;p&gt;令中心化矩阵&lt;/p&gt;
&lt;p&gt;$$
H=I-\frac1N\mathbf1\mathbf1^\top
$$&lt;/p&gt;
&lt;p&gt;对距离平方矩阵做双中心化，可消去每行、每列的未知平方范数：&lt;/p&gt;
&lt;p&gt;$$
B=-\frac12H(D_{geo}\odot D_{geo})H
$$&lt;/p&gt;
&lt;p&gt;对上面的四点链，结果恰为：&lt;/p&gt;
&lt;p&gt;$$
B=zz^\top,\qquad z=[-1.5,-0.5,0.5,1.5]^\top
$$&lt;/p&gt;
&lt;p&gt;它只有一个正特征值 5。取对应单位特征向量 $v$，坐标 $y=v\sqrt5$ 就恢复了 $z$（整体翻转也等价）。原本弯曲的四点被摊成等间距直线。&lt;/p&gt;
&lt;p&gt;若 $B=V\Lambda V^\top$，取最大的 $d$ 个正特征值：&lt;/p&gt;
&lt;p&gt;$$
Y=V_{[:,1:d]}\Lambda_{1:d}^{1/2}\in\mathbb R^{N\times d}
$$&lt;/p&gt;
&lt;p&gt;负特征值表示输入距离并不能被目标欧氏空间精确实现；大量或很大的负值常提示图短路、噪声、错误度量或目标维数/流形假设不合适。&lt;/p&gt;
&lt;h2&gt;06 完整训练伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input: X [N,D], K, d
neighbors = knn(X, K)                         # [N,K]
G = infinity(N,N); diagonal(G) = 0            # [N,N]
for each local edge (i,j):
    G[i,j] = distance(X[i], X[j])
G = symmetric_union(G)

D_geo = all_pairs_shortest_path(G)            # [N,N]
assert every entry is finite
H = I - ones(N,N) / N                          # [N,N]
B = -0.5 * H @ (D_geo ** 2) @ H               # [N,N]
eigenvalues, V = eigh(B)                       # 升序
take largest d positive eigenpairs
Y = V_d * sqrt(eigenvalues_d)                  # [N,d]
return Y, G, D_geo
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;07 用 NumPy 写出手算例的核心&lt;/h2&gt;
&lt;p&gt;下面从已知局部图开始，不调用 Isomap。Floyd–Warshall 的三重循环明确展示“是否经过节点 $k$”的动态更新：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

inf = np.inf
G = np.array([
    [0.0, 1.0, inf, inf],
    [1.0, 0.0, 1.0, inf],
    [inf, 1.0, 0.0, 1.0],
    [inf, inf, 1.0, 0.0],
])                                                   # [N=4,N=4]

D_geo = G.copy()
for k in range(4):
    D_geo = np.minimum(
        D_geo,
        D_geo[:, k, None] + D_geo[None, k, :],
    )                                               # [N,N]

H = np.eye(4) - np.ones((4, 4)) / 4
B = -0.5 * H @ (D_geo ** 2) @ H
eigenvalues, eigenvectors = np.linalg.eigh(B)
order = np.argsort(eigenvalues)[::-1]
value = eigenvalues[order[0]]
Y = eigenvectors[:, order[:1]] * np.sqrt(value)     # [N,1]

assert np.allclose(D_geo[0], [0, 1, 2, 3])
assert np.allclose(np.sort(np.diff(np.sort(Y[:, 0]))), [1, 1, 1])
assert np.allclose(Y.mean(axis=0), 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实数据不应自己写 $O(N^3)$ 的 Python 循环；这段代码只用于验证公式和张量语义。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.manifold.Isomap.html&quot;&gt;&lt;code&gt;Isomap&lt;/code&gt; API&lt;/a&gt; 同时支持近邻数或半径图、不同距离度量、最短路与特征求解器：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.manifold import Isomap, trustworthiness
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)      # [N,D]

isomap = Isomap(
    n_neighbors=12,         # 使用 KNN 时 radius 必须为 None
    radius=None,
    n_components=2,
    metric=&apos;minkowski&apos;,
    p=2,
    path_method=&apos;auto&apos;,     # auto / Dijkstra(&apos;D&apos;) / Floyd-Warshall(&apos;FW&apos;)
    eigen_solver=&apos;auto&apos;,    # auto / arpack / dense
    n_jobs=-1,
)
Y_train = isomap.fit_transform(X_train_scaled)      # [N,2]

assert Y_train.shape == (len(X_train), 2)
assert isomap.dist_matrix_.shape == (len(X_train), len(X_train))
assert np.isfinite(isomap.dist_matrix_).all()

local_score = trustworthiness(
    X_train_scaled,
    Y_train,
    n_neighbors=12,
)
global_error = isomap.reconstruction_error()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dist_matrix_&lt;/code&gt; 保存训练样本的测地距离矩阵，内存至少是 $O(N^2)$；&lt;code&gt;reconstruction_error()&lt;/code&gt; 比较测地距离核与嵌入距离核，但不能单独证明下游任务更好。&lt;code&gt;trustworthiness&lt;/code&gt; 检查低维近邻中有多少是高维里的虚假近邻，也应与可视化、稳定性和下游验证一起看。&lt;/p&gt;
&lt;h2&gt;09 新样本怎样进入已有坐标？&lt;/h2&gt;
&lt;p&gt;与上一篇的 &lt;code&gt;SpectralClustering&lt;/code&gt; 不同，scikit-learn 的 Isomap 提供 &lt;code&gt;transform(X_new)&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;X_valid_scaled = scaler.transform(X_valid)          # [Q,D]
Y_valid = isomap.transform(X_valid_scaled)           # [Q,2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对每个查询点，算法先找训练集近邻，把它接入训练测地图，得到它到全部训练点的最短距离，再把由这些距离构造的核投影到训练嵌入特征向量。它不是重新联合优化训练点与新点。&lt;/p&gt;
&lt;p&gt;若新点远离训练流形、落在另一个分量或需要超长接入边，输出坐标可能看似正常却不可信。上线应记录最近邻距离、接入边长度和训练分布覆盖，而不只检查 &lt;code&gt;transform&lt;/code&gt; 是否报错。&lt;/p&gt;
&lt;h2&gt;10 邻域、尺度和维数怎样选择？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;缩放必须只在训练集拟合&lt;/strong&gt;：距离由量纲支配时，图语义已经错了；但标准化也未必符合物理距离，应优先使用有意义的度量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;扫描邻域而非押一个 K&lt;/strong&gt;：记录连通分量数、最长边、测地距离分位数、信任度、重构误差和下游分数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查短路边&lt;/strong&gt;：查看距离特别长却被纳入近邻的边，或利用已知时间/空间拓扑检查跨段连接。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;目标维数用证据决定&lt;/strong&gt;：比较正特征值谱、重构误差和下游交叉验证；二维可视化方便，不代表真实内在维数就是 2。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;划分后拟合&lt;/strong&gt;：若嵌入用于预测，缩放器和 Isomap 都只能在训练折拟合，否则验证样本已参与图和坐标系构造。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 复杂度、失败场景与调试路径&lt;/h2&gt;
&lt;p&gt;Isomap 的主要成本来自近邻搜索、全源最短路和 $N\times N$ 核矩阵的特征分解。官方文档给出的最短路成本可达 $O(N^3)$，距离矩阵与核矩阵又需要平方级内存，因此它更适合中小规模离线表示，而不是百万样本默认方案。&lt;/p&gt;
&lt;p&gt;常见故障可按以下顺序定位：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;出现图不连通警告&lt;/strong&gt;：先查样本覆盖、尺度与邻域数；不要只为消除警告盲目增大 K。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;展开结果被撕裂&lt;/strong&gt;：K 太小、采样有空洞或异常点切断路径；检查各点度数和最长有限测地距离。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;远处区域粘在一起&lt;/strong&gt;：K 太大或噪声造成跨折叠短路；画出最长/最可疑近邻边。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不同抽样结果差异大&lt;/strong&gt;：测地距离依赖路径，少数关键点删除就会改写全局；做重采样稳定性与 Procrustes 对齐后比较。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;负特征值很大&lt;/strong&gt;：图距离不近似欧氏低维流形；检查拓扑、目标维数和距离度量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;下游模型变差&lt;/strong&gt;：无监督几何目标未使用标签；在严格训练折内比较 PCA、原特征和 Isomap，而不是用漂亮散点图代替验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;分支流形、相交曲面、强噪声、极不均匀密度、稀疏离散特征以及会随时间改变的邻接语义，都可能破坏 Isomap 的假设。&lt;/p&gt;
&lt;h2&gt;12 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法          | 主要保留对象           | 全局/局部          | 新样本与主要边界                           |
| ------------- | ---------------------- | ------------------ | ------------------------------------------ |
| PCA           | 全局线性方差与重构     | 全局线性           | 可稳定 transform；不能展开弯曲流形         |
| Classical MDS | 给定的两两距离         | 全局               | 需距离矩阵；Isomap 用测地距离喂给它        |
| Isomap        | 图最短路近似的测地距离 | 局部建图、全局距离 | sklearn 可 transform；怕短路与断图         |
| LLE           | 每点由邻居重构的权重   | 更局部             | 怕病态邻域；不传播全局最短路误差           |
| t-SNE         | 邻域概率               | 强局部、偏可视化   | 全局距离不可直接解释，通常不作通用特征管线 |&lt;/p&gt;
&lt;p&gt;Isomap 适合“局部欧氏距离可信，并希望保留沿流形的全局距离”的问题。若只关心局部邻域配方，不想让一条错误边污染大量最短路，下一种方法会更自然。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;Isomap 先用近邻图阻止距离穿透弯曲流形，再用最短路估计测地距离，最后通过距离平方的双中心化与特征分解恢复低维坐标。它的关键超参数是邻域定义，不是散点图配色；可靠使用必须检查断图、短路、负特征值、平方级资源、新样本接入距离与严格无泄漏评估。&lt;/p&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;在四点链中加入一条权重 1.2 的 &lt;code&gt;A—D&lt;/code&gt; 边，重新计算 $D_{geo}$。哪些样本对的距离被这条短路改写？&lt;/li&gt;
&lt;li&gt;对瑞士卷扫描 &lt;code&gt;n_neighbors={4,8,16,32}&lt;/code&gt;，记录连通分量、最长近邻边、&lt;code&gt;reconstruction_error()&lt;/code&gt; 与 &lt;code&gt;trustworthiness&lt;/code&gt;。找出断图、合理和短路三个区域。&lt;/li&gt;
&lt;li&gt;把 Isomap 放进“缩放 → 嵌入 → 回归”流程，比较在全数据先拟合嵌入与每个训练折内拟合的验证差异，解释泄漏来自哪里。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1126/science.290.5500.2319&quot;&gt;Tenenbaum, de Silva &amp;#x26; Langford (2000), A Global Geometric Framework for Nonlinear Dimensionality Reduction&lt;/a&gt;：Isomap 原始论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF02288916&quot;&gt;Torgerson (1952), Multidimensional Scaling: I. Theory and Method&lt;/a&gt;：经典 MDS 与距离双中心化的早期工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2002/hash/5bce843dd76db8c939d5323dd3e54ec9-Abstract.html&quot;&gt;de Silva &amp;#x26; Tenenbaum (2003), Global versus Local Methods in Nonlinear Dimensionality Reduction&lt;/a&gt;：分析全局测地方法与局部方法的关系。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/TVCG.2004.1260789&quot;&gt;Saxena et al. (2004), How to Compute Pairwise Geodesic Distances on a Triangulated Mesh&lt;/a&gt;：测地距离计算与近似误差的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/manifold.html&quot;&gt;scikit-learn 1.9: Manifold learning&lt;/a&gt;：当前 Isomap 阶段、复杂度与实现接口说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;Isomap 用全局最短路串起局部距离，但一条错误捷径可能改写大量样本对。下一篇将研究局部线性嵌入：每个点只记住“怎样由邻居线性拼出来”，再寻找能保留这些局部重构配方的低维坐标。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>两条弯曲带在原空间难分，谱聚类怎样用图拉普拉斯把它们展开？</title><link>https://zwjcode.cn/blog/spectral-clustering-graph-laplacian</link><guid isPermaLink="true">https://zwjcode.cn/blog/spectral-clustering-graph-laplacian</guid><description>从非凸簇的中心与 linkage 困境出发，构造相似图，推导图拉普拉斯与归一化切割，手算弱桥图，并实现可诊断的谱聚类。</description><pubDate>Fri, 28 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的层次聚类用 linkage 保存了多粒度合并树，但它仍直接在原特征空间比较距离。对同心圆、两条月牙或社区网络，“离中心近”没有意义，少数跨簇近邻还可能让 single linkage 链化。&lt;/p&gt;
&lt;p&gt;谱聚类（Spectral Clustering）先把样本变成相似图，再寻找图上内部连接强、跨组连接弱的切分。它的关键不是最后调用 K 均值，而是中间的&lt;strong&gt;相似矩阵、图拉普拉斯（Graph Laplacian）与特征向量嵌入&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;01 为什么换成图，弯曲结构会更容易？&lt;/h2&gt;
&lt;p&gt;在原二维平面，两条月牙的质心可能接近；但若只连接局部邻居，每条月牙内部有连续路径，两条月牙之间只有很弱或没有边：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原空间                         相似图

   ●—●—●                          ●━━●━━●
 ●       ●                       ┃     ┃
          ○—○          -&gt;        ●     ●     弱边  ···
        ○     ○                   ···
                                      ○━━○━━○

坐标看起来缠绕                   强连接留在各自子图
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;图的节点是样本，边权 $w_{ij}\ge 0$ 表示相似度。谱聚类希望切断的总边权很小，同时避免把一个孤立点当作“完美小簇”。&lt;/p&gt;
&lt;h2&gt;02 从数据到标签经历哪些张量？&lt;/h2&gt;
&lt;p&gt;设 $X\in\mathbb R^{N\times D}$，目标簇数为 $K$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D]
  │ RBF 或 k 近邻图
  ▼
W [N,N]  相似矩阵，Wᵢⱼ 越大越相似
  │ 行和
  ├──► degree d [N] ──► D=diag(d) [N,N]
  │
  ▼
L_sym = I - D^(-1/2) W D^(-1/2) [N,N]
  │ 最小 K 个特征向量
  ▼
U [N,K] ──逐行归一化──► Y [N,K]
  │ 在新坐标中聚类
  ▼
labels [N]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个样本 $x_i$ 不再由原来的 $D$ 个特征直接分簇，而由矩阵 $Y$ 的第 $i$ 行——它在图上的低频坐标——表示。&lt;/p&gt;
&lt;h2&gt;03 相似矩阵不是距离矩阵&lt;/h2&gt;
&lt;p&gt;常见的径向基函数（Radial Basis Function，RBF）相似度为：&lt;/p&gt;
&lt;p&gt;$$
w_{ij}=\exp(-\gamma|x_i-x_j|_2^2)
$$&lt;/p&gt;
&lt;p&gt;距离越小，$w_{ij}$ 越接近 1；距离越大，权重越接近 0。&lt;code&gt;gamma&lt;/code&gt; 控制衰减速度：过大时图碎成许多近孤立节点，过小时几乎所有点都相似，簇边界消失。&lt;/p&gt;
&lt;p&gt;另一种做法是建立 $k$ 近邻图，只保留局部边。它可得到稀疏 $W$，但必须决定邻居数与对称化方式：$i$ 把 $j$ 视为邻居，不保证反向也成立；谱分解通常需要对称图，可用并集、交集或对称加权。&lt;/p&gt;
&lt;h2&gt;04 图拉普拉斯为什么衡量“切边代价”？&lt;/h2&gt;
&lt;p&gt;定义每个节点的度：&lt;/p&gt;
&lt;p&gt;$$
d_i=\sum_j w_{ij},\qquad D=\operatorname{diag}(d_1,\ldots,d_N)
$$&lt;/p&gt;
&lt;p&gt;非归一化图拉普拉斯为：&lt;/p&gt;
&lt;p&gt;$$
L=D-W
$$&lt;/p&gt;
&lt;p&gt;对任意节点信号 $f\in\mathbb R^N$：&lt;/p&gt;
&lt;p&gt;$$
f^\top Lf=\frac12\sum_{i,j}w_{ij}(f_i-f_j)^2
$$&lt;/p&gt;
&lt;p&gt;若强连接节点取值相近，右侧很小；若在强边两端给出不同值，代价很大。因此最小特征值对应的特征向量，是图上变化最平滑的坐标。常数向量满足 $L\mathbf1=0$；若图恰有 $K$ 个互不连通分量，0 特征值的重数就是 $K$。&lt;/p&gt;
&lt;p&gt;为避免切出低度孤立点，实践常用对称归一化拉普拉斯：&lt;/p&gt;
&lt;p&gt;$$
L_{sym}=I-D^{-1/2}WD^{-1/2}
$$&lt;/p&gt;
&lt;p&gt;它把节点度纳入尺度，对应归一化切割（Normalized Cut）的连续松弛。&lt;/p&gt;
&lt;h2&gt;05 用四节点弱桥图手算一次切分&lt;/h2&gt;
&lt;p&gt;设两个内部边权为 1 的点对，中间只有权重 0.1 的桥：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;节点 0 ━1.0━ 节点 1 ··0.1·· 节点 2 ━1.0━ 节点 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;相似矩阵与度为：&lt;/p&gt;
&lt;p&gt;$$
W=\begin{bmatrix}
0&amp;#x26;1&amp;#x26;0&amp;#x26;0\
1&amp;#x26;0&amp;#x26;0.1&amp;#x26;0\
0&amp;#x26;0.1&amp;#x26;0&amp;#x26;1\
0&amp;#x26;0&amp;#x26;1&amp;#x26;0
\end{bmatrix},\qquad
d=[1,1.1,1.1,1]
$$&lt;/p&gt;
&lt;p&gt;取候选分区信号 $f=[1,1,-1,-1]^\top$。内部强边两端取值相同，只在弱桥上变化。按无向边计一次：&lt;/p&gt;
&lt;p&gt;$$
f^\top Lf=0.1(1-(-1))^2=0.4
$$&lt;/p&gt;
&lt;p&gt;若错误地把节点 0 单独分开，强边 $(0,1)$ 被切断，单这一项就是 $1\times(1-(-1))^2=4$。因此低能量特征向量自然倾向在弱桥处改变符号。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原节点信号       第二个低频方向（示意）       嵌入后

0—1··2—3         +0.7 +0.6 | -0.6 -0.7       ● ●     ○ ○
                       弱桥处跳变             一条轴即可分开
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是“谱”二字的来源：算法读取拉普拉斯的特征值与特征向量，而不是直接在原坐标画直线。&lt;/p&gt;
&lt;h2&gt;06 归一化切割如何变成特征向量问题？&lt;/h2&gt;
&lt;p&gt;将节点分为 $A$ 与 $\bar A$，跨组边权为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{cut}(A,\bar A)=\sum_{i\in A,j\in\bar A}w_{ij}
$$&lt;/p&gt;
&lt;p&gt;只最小化 &lt;code&gt;cut&lt;/code&gt; 会偏爱孤立单点。归一化切割再除以各组总度：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Ncut}(A,\bar A)=
\frac{\operatorname{cut}(A,\bar A)}{\operatorname{vol}(A)}+
\frac{\operatorname{cut}(A,\bar A)}{\operatorname{vol}(\bar A)}
$$&lt;/p&gt;
&lt;p&gt;其中 $\operatorname{vol}(A)=\sum_{i\in A}d_i$。离散分区求解是困难的组合优化；放松节点只能取两个离散值的约束后，可转化为拉普拉斯特征向量问题。多簇时取 $K$ 个低频方向形成 $U\in\mathbb R^{N\times K}$，再把每一行当作新样本聚类。&lt;/p&gt;
&lt;p&gt;谱松弛不是原离散目标的魔法精确解。最后从连续嵌入恢复离散标签仍可能受 K 均值初始化、特征值接近和图构造影响。&lt;/p&gt;
&lt;h2&gt;07 不依赖聚类器，写出最小谱嵌入&lt;/h2&gt;
&lt;p&gt;下面从手算图直接构造 $L_{sym}$，取两个最小特征向量，再对行归一化。为保持透明，最后按第二个特征向量符号二分：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

W = np.array([
    [0.0, 1.0, 0.0, 0.0],
    [1.0, 0.0, 0.1, 0.0],
    [0.0, 0.1, 0.0, 1.0],
    [0.0, 0.0, 1.0, 0.0],
])                                                  # [N=4,N=4]

degree = W.sum(axis=1)                              # [N]
inv_sqrt = 1.0 / np.sqrt(degree)
L_sym = np.eye(4) - inv_sqrt[:, None] * W * inv_sqrt[None, :]

eigenvalues, eigenvectors = np.linalg.eigh(L_sym)   # 升序；列为特征向量
U = eigenvectors[:, :2]                             # [N,K=2]
Y = U / np.linalg.norm(U, axis=1, keepdims=True)    # [N,K]
labels = (U[:, 1] &gt; 0).astype(int)                  # 仅适合这个二分示例

assert np.allclose(L_sym, L_sym.T)
assert eigenvalues[0] &amp;#x3C; 1e-12
assert labels[0] == labels[1]
assert labels[2] == labels[3]
assert labels[0] != labels[2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特征向量整体乘以 $-1$ 仍是同一个解，所以标签 0/1 可能交换。一般 $K&gt;2$ 时不要按符号逐列切分，应在归一化后的 $Y$ 中使用 K 均值、离散化或 &lt;code&gt;cluster_qr&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.cluster.SpectralClustering.html&quot;&gt;&lt;code&gt;SpectralClustering&lt;/code&gt; API&lt;/a&gt; 可自行构造 RBF 或近邻 affinity：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.cluster import SpectralClustering
from sklearn.preprocessing import StandardScaler

X_scaled = StandardScaler().fit_transform(X_train)  # [N,D]

model = SpectralClustering(
    n_clusters=2,
    affinity=&apos;nearest_neighbors&apos;,
    n_neighbors=12,
    n_components=2,
    eigen_solver=&apos;arpack&apos;,
    eigen_tol=&apos;auto&apos;,
    assign_labels=&apos;cluster_qr&apos;,
    random_state=42,
    n_jobs=-1,
)
labels = model.fit_predict(X_scaled)                 # [N]

assert model.affinity_matrix_.shape == (
    X_scaled.shape[0], X_scaled.shape[0]
)
assert model.labels_.shape == (X_scaled.shape[0],)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;assign_labels=&apos;cluster_qr&apos;&lt;/code&gt; 直接从特征向量提取簇，不需要 K 均值迭代；&lt;code&gt;&apos;kmeans&apos;&lt;/code&gt; 则使用 &lt;code&gt;n_init&lt;/code&gt; 和随机初始化。若用 RBF：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;rbf_model = SpectralClustering(
    n_clusters=2,
    affinity=&apos;rbf&apos;,
    gamma=3.0,
    assign_labels=&apos;kmeans&apos;,
    n_init=20,
    random_state=42,
).fit(X_scaled)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gamma&lt;/code&gt; 对 &lt;code&gt;nearest_neighbors&lt;/code&gt;、&lt;code&gt;precomputed&lt;/code&gt; 和 &lt;code&gt;precomputed_nearest_neighbors&lt;/code&gt; 会被忽略。&lt;code&gt;n_jobs&lt;/code&gt; 主要用于近邻图构造，不会让所有特征分解自动线性扩展。&lt;/p&gt;
&lt;h2&gt;09 使用业务图时怎样传入预计算 affinity？&lt;/h2&gt;
&lt;p&gt;当边来自网页链接、交易关系或图像邻接，而不是特征欧氏距离，可直接传对称非负矩阵：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from scipy import sparse
from sklearn.cluster import SpectralClustering

# row、col、weight 描述无向边；同时放入两个方向
rows = np.array([0, 1, 1, 2, 2, 3])
cols = np.array([1, 0, 2, 1, 3, 2])
weights = np.array([1.0, 1.0, 0.1, 0.1, 1.0, 1.0])
W_sparse = sparse.csr_matrix((weights, (rows, cols)), shape=(4, 4))

labels = SpectralClustering(
    n_clusters=2,
    affinity=&apos;precomputed&apos;,
    assign_labels=&apos;cluster_qr&apos;,
    random_state=42,
).fit_predict(W_sparse)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;传入前至少断言 &lt;code&gt;W.shape == (N,N)&lt;/code&gt;、非负、近似对称，并检查零度节点。负权相似度需要专门的 signed graph 方法，不能假设当前实现会替你验证并修复。&lt;/p&gt;
&lt;h2&gt;10 怎样选择图、K 与求解器？&lt;/h2&gt;
&lt;p&gt;图构造通常比最后的标签器更关键：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;RBF 图：画距离分位数与权重分布；避免几乎全 0 或几乎全 1。&lt;/li&gt;
&lt;li&gt;kNN 图：检查连通分量、节点度分布和互为近邻比例；在相邻 &lt;code&gt;n_neighbors&lt;/code&gt; 上比较稳定性。&lt;/li&gt;
&lt;li&gt;簇数 $K$：查看最小特征值序列中的 eigengap 只能提供候选，还要结合稳定性、簇大小和领域需求。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;arpack&lt;/code&gt;：默认常用；大而稀疏的问题可评估 &lt;code&gt;lobpcg&lt;/code&gt;。&lt;code&gt;amg&lt;/code&gt; 需要额外安装 &lt;code&gt;pyamg&lt;/code&gt;，官方也提示可能不稳定。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eigen_tol=&apos;auto&apos;&lt;/code&gt;：让求解器选择容差；对 &lt;code&gt;lobpcg/amg&lt;/code&gt; 强行设小于 $10^{-5}$ 的容差可能导致收敛问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若图实际有很多连通分量，却只要求两个簇，特征空间会退化且答案不唯一。先检查图，再调最后的 &lt;code&gt;assign_labels&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;11 为什么它也没有自然的新样本预测？&lt;/h2&gt;
&lt;p&gt;谱坐标来自&lt;strong&gt;当前整张图&lt;/strong&gt;的特征分解。加入一个新样本会增加一行一列并改变全局特征向量，所以 scikit-learn 的 &lt;code&gt;SpectralClustering&lt;/code&gt; 提供训练标签，没有原生 &lt;code&gt;predict(X_new)&lt;/code&gt;；它是传导式学习（Transductive Learning）方法。&lt;/p&gt;
&lt;p&gt;需要外推时可选择：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;冻结训练图，用 Nyström 延拓近似新点的谱坐标；&lt;/li&gt;
&lt;li&gt;在训练谱嵌入与标签上训练一个监督分类器；&lt;/li&gt;
&lt;li&gt;定期重建图与重聚类，并处理簇身份匹配；&lt;/li&gt;
&lt;li&gt;若持续在线预测是核心需求，改用有显式映射的表示学习或聚类方法。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;每种方案都改变了原算法语义，必须单独验证训练外样本。&lt;/p&gt;
&lt;h2&gt;12 复杂度、失败场景与最短调试路径&lt;/h2&gt;
&lt;p&gt;稠密 $W$ 占 $O(N^2)$ 内存，完整特征分解更昂贵；谱聚类通常适合中等样本量、较少簇。排查顺序应沿数据流：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;尺度错误&lt;/strong&gt;：检查标准化与距离样本，确认相似图表达了真正关系。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图过稀&lt;/strong&gt;：连通分量暴增、零度节点出现；增加邻居或修正数据覆盖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图过密&lt;/strong&gt;：权重近似常数，边界被抹平；减小邻居数或增大 RBF &lt;code&gt;gamma&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;误传距离矩阵&lt;/strong&gt;：打印近点和远点的矩阵值，确认近点值更大。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;标签随机变化&lt;/strong&gt;：先区分编号置换，再固定 &lt;code&gt;random_state&lt;/code&gt;、增大 &lt;code&gt;n_init&lt;/code&gt; 或使用 &lt;code&gt;cluster_qr&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;特征值扎堆&lt;/strong&gt;：不存在清晰 eigengap，簇可能不稳定；做重采样和图参数扰动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内存爆炸&lt;/strong&gt;：使用稀疏近邻图与稀疏求解器，先估计边数；不要无意识创建稠密 RBF 矩阵。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把簇当真类别&lt;/strong&gt;：图由人为相似度定义，结果只能说明“按这张图容易切”。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;线上或批处理至少记录：图边数、连通分量、度分位数、前若干特征值、eigengap、簇大小、参数扰动稳定性、求解器耗时与峰值内存。&lt;/p&gt;
&lt;h2&gt;13 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法      | 关键表示             | 非凸结构   | 主要限制                             |
| --------- | -------------------- | ---------- | ------------------------------------ |
| K 均值    | 原空间中心           | 弱         | 偏好球形；可原生预测                 |
| DBSCAN    | 半径近邻与密度连通   | 强         | 单一密度阈值；可标噪声               |
| 层次聚类  | linkage 合并树       | 视准则而定 | 保存多粒度；可能链化                 |
| 谱聚类    | 图拉普拉斯低频嵌入   | 强         | 需簇数；图与特征分解昂贵、难外推     |
| 核 K 均值 | 核诱导特征空间的中心 | 强         | 与谱方法关系紧密，但目标与归一化不同 |&lt;/p&gt;
&lt;p&gt;谱聚类适合“局部相似关系可信、全局中心不可信”的问题；它不是所有非凸数据的默认答案。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;谱聚类把样本变成非负相似图，用 $L$ 或 $L_{sym}$ 的低频特征向量寻找强连接内部几乎不变、弱连接处发生变化的坐标，再在谱嵌入中离散化标签。结果依赖相似图、归一化、簇数与数值求解；可靠实践必须检查图连通性、度分布、特征值、稳定性和平方级资源风险，并承认它通常不能直接预测新样本。&lt;/p&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;把弱桥权重从 0.1 改为 0、0.5 和 1，分别计算 $f=[1,1,-1,-1]^\top$ 的 $f^\top Lf$，并解释图从两个分量走向均匀链时切分证据怎样变化。&lt;/li&gt;
&lt;li&gt;在同心圆数据上扫描 &lt;code&gt;n_neighbors&lt;/code&gt;，记录连通分量数、度分位数、前五个特征值与 ARI（仅用于有模拟真值的实验）。找到图碎裂、合理和过密三个区域。&lt;/li&gt;
&lt;li&gt;构造同一数据的距离矩阵与 RBF 相似矩阵，分别传给 &lt;code&gt;affinity=&apos;precomputed&apos;&lt;/code&gt;。错误输入产生什么警告或异常结果？写断言在训练前阻止它。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF01896828&quot;&gt;Fiedler (1973), Algebraic Connectivity of Graphs&lt;/a&gt;：用第二小拉普拉斯特征值与特征向量刻画图连通性。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/34.868688&quot;&gt;Shi &amp;#x26; Malik (2000), Normalized Cuts and Image Segmentation&lt;/a&gt;：归一化切割用于图像分割的经典论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2001/hash/801272ee79cfde7fa5960571fee36b9b-Abstract.html&quot;&gt;Ng, Jordan &amp;#x26; Weiss (2002), On Spectral Clustering: Analysis and an Algorithm&lt;/a&gt;：常用归一化谱聚类算法与分析。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/s11222-007-9033-z&quot;&gt;von Luxburg (2007), A Tutorial on Spectral Clustering&lt;/a&gt;：图拉普拉斯、不同切割与实践的系统教程。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/089976603321780317&quot;&gt;Belkin &amp;#x26; Niyogi (2003), Laplacian Eigenmaps for Dimensionality Reduction and Data Representation&lt;/a&gt;：把同一谱几何用于非线性表示学习。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;谱嵌入已经把“图上的邻近关系”转成新坐标，但它主要服务于分簇。下一篇将继续无监督表示学习，研究流形学习：如何在不要求离散簇的情况下保留局部邻域，把高维弯曲流形展开为可视化或下游建模坐标。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>不预先选定粒度，样本怎样长成一棵簇树？层次聚类的 linkage 与树状图</title><link>https://zwjcode.cn/blog/hierarchical-clustering-linkage-dendrogram</link><guid isPermaLink="true">https://zwjcode.cn/blog/hierarchical-clustering-linkage-dendrogram</guid><description>从一次性扁平分组的不足出发，手算凝聚层次聚类，解释 single、complete、average 与 Ward linkage，并从树状图安全选择粒度。</description><pubDate>Fri, 28 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 DBSCAN 用一组 &lt;code&gt;eps/min_samples&lt;/code&gt; 切出密度连通簇。但商品目录可能同时有“食品—饮料—无糖饮料”三层结构，用户分群也可能既需要 3 个大类，又需要 12 个细分群。一次只给一组扁平标签，会丢掉这些嵌套关系。&lt;/p&gt;
&lt;p&gt;凝聚层次聚类（Agglomerative Hierarchical Clustering）先把每个样本看成一个簇，再反复合并最接近的两个簇，形成一棵树。本文只讲透三个环节：&lt;strong&gt;簇间距离 linkage、逐步合并，以及如何切树得到不同粒度。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 为什么一组标签不够？&lt;/h2&gt;
&lt;p&gt;扁平聚类只回答“现在分几组”，层次结构还记录“哪些小组先组成大组”：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;样本层          局部小组             全部样本

A   B   C   D     {A,B}   {C,D}       {A,B,C,D}
│   │   │   │       \       /             │
└─1─┘   └─2─┘        \─4.5─/              根

在高度 1.5 切：{A,B}, {C}, {D}
在高度 3.0 切：{A,B}, {C,D}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;树状图（Dendrogram）的叶子是样本，内部节点是一次合并，纵轴通常是本次合并的距离或代价。它保存了从 $N$ 个单点簇到 1 个总簇的全部嵌套过程；最终标签只是选择一条水平切线后的结果。&lt;/p&gt;
&lt;h2&gt;02 算法每一轮究竟比较什么？&lt;/h2&gt;
&lt;p&gt;输入 $X\in\mathbb R^{N\times D}$。初始化时：&lt;/p&gt;
&lt;p&gt;$$
\mathcal C^{(0)}={{x_1},\ldots,{x_N}}
$$&lt;/p&gt;
&lt;p&gt;第 $t$ 轮在当前簇集合中寻找：&lt;/p&gt;
&lt;p&gt;$$
(A^&lt;em&gt;,B^&lt;/em&gt;)=\arg\min_{A\ne B} d_{\text{link}}(A,B)
$$&lt;/p&gt;
&lt;p&gt;再用 $A^&lt;em&gt;\cup B^&lt;/em&gt;$ 替换这两个簇。真正决定几何的不是“合并”二字，而是簇间距离 $d_{\text{link}}$，也称联接准则（Linkage Criterion）。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D]
  │ 两两样本距离
  ▼
距离/近邻结构 ──► 当前所有簇对的 linkage
                         │ 取最小
                         ▼
              children[t] = [left,right]
              distances[t] = merge_height
                         │ 重复 N-1 次
                         ▼
              合并树 ──切割──► labels [N]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 四种 linkage 如何改变“最近的两个簇”？&lt;/h2&gt;
&lt;p&gt;设簇 $A,B$ 中点对距离为 $d(x_i,x_j)$：&lt;/p&gt;
&lt;p&gt;$$
d_{single}(A,B)=\min_{i\in A,j\in B}d(x_i,x_j)
$$&lt;/p&gt;
&lt;p&gt;$$
d_{complete}(A,B)=\max_{i\in A,j\in B}d(x_i,x_j)
$$&lt;/p&gt;
&lt;p&gt;$$
d_{average}(A,B)=\frac{1}{|A||B|}\sum_{i\in A}\sum_{j\in B}d(x_i,x_j)
$$&lt;/p&gt;
&lt;p&gt;| linkage    | 关注什么         | 常见几何             | 典型风险                |
| ---------- | ---------------- | -------------------- | ----------------------- |
| &lt;code&gt;single&lt;/code&gt;   | 最近的一对点     | 可沿细长或弯曲链延伸 | 少量桥接点造成 chaining |
| &lt;code&gt;complete&lt;/code&gt; | 最远的一对点     | 偏好直径较小的紧凑簇 | 对极端点敏感            |
| &lt;code&gt;average&lt;/code&gt;  | 所有跨簇点对平均 | 在两者之间折中       | 仍依赖距离与尺度        |
| &lt;code&gt;ward&lt;/code&gt;     | 合并后增加的 SSE | 偏好紧凑、方差小的簇 | 只适合欧氏几何          |&lt;/p&gt;
&lt;p&gt;Ward 联接（Ward Linkage）不是简单点对距离。若 $A,B$ 的样本数为 $n_A,n_B$，均值为 $\mu_A,\mu_B$，合并导致的簇内平方和增量为：&lt;/p&gt;
&lt;p&gt;$$
\Delta(A,B)=\frac{n_An_B}{n_A+n_B}|\mu_A-\mu_B|_2^2
$$&lt;/p&gt;
&lt;p&gt;它选择 $\Delta$ 最小的合并，因此和 K 均值一样偏好欧氏空间中的紧凑簇。&lt;code&gt;ward&lt;/code&gt; 不能随意换成余弦距离。&lt;/p&gt;
&lt;h2&gt;04 用四个一维点手算完整合并树&lt;/h2&gt;
&lt;p&gt;取 $A=0,B=1,C=4,D=6$，使用绝对距离与 average linkage。初始最小距离是 $d(A,B)=1$，先合并 ${A,B}$。剩余候选：&lt;/p&gt;
&lt;p&gt;$$
d_{avg}({A,B},{C})=\frac{|0-4|+|1-4|}{2}=3.5
$$&lt;/p&gt;
&lt;p&gt;$$
d(C,D)=2
$$&lt;/p&gt;
&lt;p&gt;所以第二步合并 ${C,D}$。最后两个簇的距离为：&lt;/p&gt;
&lt;p&gt;$$
\frac{|0-4|+|0-6|+|1-4|+|1-6|}{4}
=\frac{4+6+3+5}{4}=4.5
$$&lt;/p&gt;
&lt;p&gt;| 步骤 $t$ | 合并节点       | 新节点编号 | 高度 | 新簇大小 |
| -------- | -------------- | ---------- | ---: | -------: |
| 0        | A(0), B(1)     | 4          |  1.0 |        2 |
| 1        | C(2), D(3)     | 5          |  2.0 |        2 |
| 2        | 节点 4, 节点 5 | 6          |  4.5 |        4 |&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;高度
4.5          ┌───────────────┐
             │               │
2.0          │          ┌────┴────┐
1.0     ┌────┴────┐     │         │
0       A         B     C         D

             在 3.0 处切割 -&gt; 两簇 {A,B} 与 {C,D}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;节点编号大于等于 $N$ 时表示先前产生的内部节点；第 $t$ 个新节点编号就是 $N+t$。这正是 scikit-learn &lt;code&gt;children_&lt;/code&gt; 的编码方式。&lt;/p&gt;
&lt;h2&gt;05 不调用聚类器，写出最小 average linkage&lt;/h2&gt;
&lt;p&gt;下面实现故意每轮枚举所有簇对，只适合小数据教学核对：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from itertools import combinations
import numpy as np

def average_linkage_small(X: np.ndarray):
    X = np.asarray(X, dtype=np.float64)             # [N,D]
    n = X.shape[0]
    pairwise = np.linalg.norm(
        X[:, None, :] - X[None, :, :], axis=2
    )                                               # [N,N]

    clusters = {i: [i] for i in range(n)}
    children, heights, sizes = [], [], []

    for step in range(n - 1):
        best = None
        for left, right in combinations(sorted(clusters), 2):
            rows = clusters[left]
            cols = clusters[right]
            distance = pairwise[np.ix_(rows, cols)].mean()
            candidate = (distance, left, right)
            if best is None or candidate &amp;#x3C; best:
                best = candidate

        distance, left, right = best
        members = clusters.pop(left) + clusters.pop(right)
        clusters[n + step] = members
        children.append([left, right])
        heights.append(distance)
        sizes.append(len(members))

    return (
        np.asarray(children, dtype=int),             # [N-1,2]
        np.asarray(heights),                         # [N-1]
        np.asarray(sizes, dtype=int),                # [N-1]
    )

X = np.array([[0.0], [1.0], [4.0], [6.0]])
children, heights, sizes = average_linkage_small(X)

assert children.tolist() == [[0, 1], [2, 3], [4, 5]]
assert np.allclose(heights, [1.0, 2.0, 4.5])
assert sizes.tolist() == [2, 2, 4]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实实现会维护距离更新和优先结构；不要把这段 $O(N^3)$ 风格的教学代码用于生产数据。&lt;/p&gt;
&lt;h2&gt;06 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.cluster.AgglomerativeClustering.html&quot;&gt;&lt;code&gt;AgglomerativeClustering&lt;/code&gt; API&lt;/a&gt; 使用 &lt;code&gt;metric&lt;/code&gt; 而不是旧参数 &lt;code&gt;affinity&lt;/code&gt;。若要保存完整树及合并高度：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.cluster import AgglomerativeClustering
from sklearn.preprocessing import StandardScaler

X_scaled = StandardScaler().fit_transform(X_train)  # [N,D]

model = AgglomerativeClustering(
    n_clusters=4,
    metric=&apos;euclidean&apos;,
    linkage=&apos;average&apos;,
    compute_full_tree=True,
    compute_distances=True,
)
labels = model.fit_predict(X_scaled)                 # [N]

assert model.children_.shape == (X_scaled.shape[0] - 1, 2)
assert model.distances_.shape == (X_scaled.shape[0] - 1,)
assert model.n_leaves_ == X_scaled.shape[0]
print(np.bincount(labels))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以不用预先指定簇数，改用距离阈值切树：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;cut = AgglomerativeClustering(
    n_clusters=None,
    distance_threshold=2.4,
    metric=&apos;euclidean&apos;,
    linkage=&apos;complete&apos;,
    compute_full_tree=True,
    compute_distances=True,
)
labels = cut.fit_predict(X_scaled)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;distance_threshold&lt;/code&gt; 非空时，&lt;code&gt;n_clusters&lt;/code&gt; 必须为 &lt;code&gt;None&lt;/code&gt;，完整树必须计算。&lt;code&gt;compute_distances=True&lt;/code&gt; 便于画树状图，但会增加计算与内存开销。&lt;/p&gt;
&lt;h2&gt;07 连通约束怎样阻止“不该发生的跨越”？&lt;/h2&gt;
&lt;p&gt;图像像素只应与空间邻居先合并，网页也可能只允许沿链接关系合并。连接矩阵 &lt;code&gt;connectivity [N,N]&lt;/code&gt; 指定哪些样本对有资格跨簇连接：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.cluster import AgglomerativeClustering
from sklearn.neighbors import kneighbors_graph

graph = kneighbors_graph(
    X_scaled,
    n_neighbors=12,
    mode=&apos;connectivity&apos;,
    include_self=False,
)                                                    # 稀疏 [N,N]

model = AgglomerativeClustering(
    n_clusters=6,
    linkage=&apos;ward&apos;,
    metric=&apos;euclidean&apos;,
    connectivity=graph,
    compute_full_tree=&apos;auto&apos;,
).fit(X_scaled)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;约束能保留局部结构并减少候选合并，但图若断裂或 &lt;code&gt;n_neighbors&lt;/code&gt; 太小，会让结果被人为拓扑主导。记录连通分量数、节点度分布，并在相邻的邻居数上做稳定性检查。&lt;/p&gt;
&lt;h2&gt;08 怎样切树，而不是对树状图“看图说话”？&lt;/h2&gt;
&lt;p&gt;树状图可帮助发现明显的高度跳跃，但切线仍是模型选择：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在业务可接受的簇数范围内比较多个切法；&lt;/li&gt;
&lt;li&gt;检查重采样、时间切片和小幅参数变化后的簇匹配稳定性；&lt;/li&gt;
&lt;li&gt;同时报告簇大小，防止产生大量单点簇；&lt;/li&gt;
&lt;li&gt;有标签时才使用 ARI 等外部指标，不能偷看测试标签选切线；&lt;/li&gt;
&lt;li&gt;无标签时结合轮廓系数、领域解释与下游任务，但不要把任一内部指标当真值。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;合并高度的绝对值依赖特征缩放、距离和 linkage。不同设置下的“高度 2.4”不能直接比较。&lt;/p&gt;
&lt;h2&gt;09 为什么它没有自然的 &lt;code&gt;predict(X_new)&lt;/code&gt;？&lt;/h2&gt;
&lt;p&gt;层次聚类的树是训练样本集合上的全局合并结果。加入一个新点，可能改变早期最近簇对，随后整棵树都不同。因此 &lt;code&gt;AgglomerativeClustering&lt;/code&gt; 提供 &lt;code&gt;fit&lt;/code&gt;/&lt;code&gt;fit_predict&lt;/code&gt; 和训练标签，没有原生 &lt;code&gt;predict&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果业务必须持续接收新样本，应明确改写语义：冻结旧簇后训练一个监督分类器进行近似外推；定期全量重聚类并匹配簇身份；或使用有原生新样本分配规则的方法。不要把“分到最近质心”说成层次聚类本身。&lt;/p&gt;
&lt;h2&gt;10 复杂度、失败场景与调试路径&lt;/h2&gt;
&lt;p&gt;层次方法通常至少需要平方级距离或候选关系；稠密大样本时，时间和内存会先于公式成为瓶颈。最短检查路径是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;特征量纲主导&lt;/strong&gt;：打印缩放前后各列分位数，抽查最近点是否符合语义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;single 链化&lt;/strong&gt;：检查连接两个主体簇的少数点，改用 average/complete 或处理噪声。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;complete 被异常点拉高&lt;/strong&gt;：定位决定最大跨簇距离的样本，不要只看最终颜色。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ward 用错距离&lt;/strong&gt;：Ward 只能用欧氏距离；类别/文本数据应另选度量和 linkage。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;切线不稳定&lt;/strong&gt;：对样本重采样，比较树切割后的共聚类矩阵，而非直接比较可置换的编号。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;大数据耗尽内存&lt;/strong&gt;：先抽样验证，构建有意义的稀疏 connectivity，或评估 BIRCH/mini-batch 方法。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把树当因果分类学&lt;/strong&gt;：层次只反映输入表示与准则，不证明真实世界存在对应物种式层级。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法     | 输出结构       | 主要几何/参数          | 噪声与新样本               |
| -------- | -------------- | ---------------------- | -------------------------- |
| K 均值   | 一层扁平标签   | 中心、簇数、欧氏 SSE   | 强制分配；可按中心预测     |
| DBSCAN   | 密度连通标签   | &lt;code&gt;eps/min_samples&lt;/code&gt;      | 可标噪声；无原生预测       |
| 凝聚层次 | 合并树与切割   | linkage、距离、切线    | 通常强制入树；无原生预测   |
| 谱聚类   | 图嵌入后的标签 | 相似图、特征向量、簇数 | 非凸结构；通常是传导式方法 |
| BIRCH    | CF 压缩树      | 阈值、分支因子         | 面向大数据增量压缩         |&lt;/p&gt;
&lt;p&gt;层次聚类的核心价值是保留多粒度关系，不是自动免除“选多少簇”的判断。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;凝聚层次聚类从单点簇出发，每轮按 linkage 合并最近簇，并用 &lt;code&gt;children_&lt;/code&gt; 与合并高度保存一棵树。single、complete、average 和 Ward 对“簇有多近”给出不同答案；切树得到的标签依赖尺度、距离、联接与粒度选择。可靠实践要检查树的稳定性、簇大小和计算成本，并承认它没有自然的新样本预测。&lt;/p&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对 $[0,1,4,6]$ 分别手算 single 与 complete linkage 的三次合并高度。最后一次高度为何分别是 3 与 6？&lt;/li&gt;
&lt;li&gt;给四点例子加入异常点 100，比较 average、complete 与 Ward 的树。哪个联接最直接受到最大距离影响？&lt;/li&gt;
&lt;li&gt;在月牙数据上比较无约束和 10 近邻 connectivity 的 Ward 聚类；画出近邻图、合并高度和两种切割，并解释差异来自哪里。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1099/00221287-17-1-201&quot;&gt;Sneath (1957), The Application of Computers to Taxonomy&lt;/a&gt;：数值分类与层次思想的早期代表工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1080/01621459.1963.10500845&quot;&gt;Ward (1963), Hierarchical Grouping to Optimize an Objective Function&lt;/a&gt;：最小化组内信息损失的 Ward 方法。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1093/comjnl/9.4.373&quot;&gt;Lance &amp;#x26; Williams (1967), A General Theory of Classificatory Sorting Strategies&lt;/a&gt;：统一描述多种层次距离更新的经典框架。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/T-C.1971.223083&quot;&gt;Zahn (1971), Graph-Theoretical Methods for Detecting and Describing Gestalt Clusters&lt;/a&gt;：连接单联接、最小生成树与几何簇结构。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;linkage 在原空间里决定合并顺序，但两条缠绕曲线上的“全局欧氏距离”仍可能误导。下一篇将把样本变成相似图，推导图拉普拉斯与谱嵌入：为什么在原空间难切的非凸簇，能在特征向量坐标中被简单分开。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>弯曲簇与噪声点怎样同时识别？DBSCAN 的密度连通</title><link>https://zwjcode.cn/blog/dbscan-density-connectivity-noise</link><guid isPermaLink="true">https://zwjcode.cn/blog/dbscan-density-connectivity-noise</guid><description>从中心模型切碎月牙数据出发，手算 ε 邻域、核心点与密度扩展，实现最小 DBSCAN，并解释参数选择、内存风险和新样本推理边界。</description><pubDate>Thu, 27 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的高斯混合模型用椭圆成分表达重叠与不确定性。但两条月牙、蜿蜒道路轨迹或环形材料裂纹并没有一个合适的“中心”；强迫几个高斯覆盖它们，往往会把同一条曲线切成多段，还会让离群点被某个成分勉强解释。&lt;/p&gt;
&lt;p&gt;基于密度的含噪空间聚类（Density-Based Spatial Clustering of Applications with Noise，DBSCAN）换了问题：**不寻找中心，而寻找局部足够密的点，再沿密度连通关系扩展簇。**本文只围绕 $\varepsilon$ 邻域、核心/边界/噪声三类点，以及基于队列的簇扩展展开。&lt;/p&gt;
&lt;h2&gt;01 为什么“离哪个中心近”不是唯一聚类方式？&lt;/h2&gt;
&lt;p&gt;看两条弯曲带状数据：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;中心式分割                     密度连通

   ●●●●●                          ●—●—●—●
 ●●     ●●       │              ●       ●
             ○○  │                         ○—○
          ○○   ○○│                      ○○   ○—○

直线边界切断形状                 局部邻域沿曲线接力
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;K 均值和 GMM 的成分都有全局中心与整体几何；DBSCAN 只要求相邻的局部区域足够密，因此簇可以弯曲。它还允许一些点不属于任何簇，标签为噪声，而不是把所有观测都强制收编。&lt;/p&gt;
&lt;h2&gt;02 ε 邻域精确定义了什么？&lt;/h2&gt;
&lt;p&gt;给定距离 $d(\cdot,\cdot)$ 与半径 $\varepsilon&gt;0$，点 $x_i$ 的闭邻域是：&lt;/p&gt;
&lt;p&gt;$$
N_\varepsilon(x_i)={x_j\mid d(x_i,x_j)\le\varepsilon}
$$&lt;/p&gt;
&lt;p&gt;注意邻域&lt;strong&gt;包含点自身&lt;/strong&gt;。输入 $X\in\mathbb R^{N\times D}$，若使用欧氏距离，全部两两距离矩阵为 $[N,N]$；实际实现通常借助 KD 树、球树或分块近邻查询，避免永远显式保存稠密矩阵。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;eps&lt;/code&gt; 不是簇内任意两点的最大距离。只要点与点之间能用短邻域链连接，同一个簇的两端可以相隔很远。&lt;/p&gt;
&lt;h2&gt;03 核心点、边界点与噪声点如何判定？&lt;/h2&gt;
&lt;p&gt;给定 &lt;code&gt;min_samples&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若 $|N_\varepsilon(x_i)|\ge\text{min_samples}$，$x_i$ 是核心点（Core Point）；&lt;/li&gt;
&lt;li&gt;非核心点若落在某个核心点的 $\varepsilon$ 邻域内，是边界点（Border Point）；&lt;/li&gt;
&lt;li&gt;既不是核心点也不邻接任何核心点，是噪声点（Noise Point）。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;              ε 邻域
          ┌───────────┐
          │  ·  ●  · │       ● 核心点：邻域计数达标
          │ ·  ●  ●  │       · 邻居
          │    ○     │       ○ 边界点：自己不够密，但邻接核心点
          └───────────┘

                         × 噪声点：远离任何核心点
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;边界点不是“小一点的核心点”：它能加入簇，但不能继续把自己的稀疏邻居扩展进来。&lt;/p&gt;
&lt;h2&gt;04 “密度连通”为什么能形成弯曲簇？&lt;/h2&gt;
&lt;p&gt;若 $q\in N_\varepsilon(p)$ 且 $p$ 是核心点，则称 $q$ 从 $p$ 直接密度可达（Directly Density-Reachable）。一串核心点可以把这种关系向外传递；两个点若都能由某个核心点经这样的链到达，就属于同一密度连通分量。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;核心链： ●────●────●────●
          ╲              ╱
          ○              ○    两端边界点被吸收

每条边长度 ≤ ε；整条链长度可以远大于 ε
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接可达有方向：边界点可从核心点到达，但边界点本身不能向外扩展。最终同簇关系由核心点连通分量加其邻接边界点构成。&lt;/p&gt;
&lt;h2&gt;05 用七个一维点手算簇扩展&lt;/h2&gt;
&lt;p&gt;取：&lt;/p&gt;
&lt;p&gt;$$
X=[0.0,0.1,0.2,0.3,1.0,1.1,3.0]
$$&lt;/p&gt;
&lt;p&gt;设置 $\varepsilon=0.11$、&lt;code&gt;min_samples=3&lt;/code&gt;，距离等于绝对差：&lt;/p&gt;
&lt;p&gt;| 点  | $N_\varepsilon(x)$ | 数量 | 类型     |
| --- | ------------------ | ---: | -------- |
| 0.0 | {0.0, 0.1}         |    2 | 边界候选 |
| 0.1 | {0.0, 0.1, 0.2}    |    3 | 核心     |
| 0.2 | {0.1, 0.2, 0.3}    |    3 | 核心     |
| 0.3 | {0.2, 0.3}         |    2 | 边界候选 |
| 1.0 | {1.0, 1.1}         |    2 | 噪声     |
| 1.1 | {1.0, 1.1}         |    2 | 噪声     |
| 3.0 | {3.0}              |    1 | 噪声     |&lt;/p&gt;
&lt;p&gt;从核心点 0.1 开始，邻域先纳入 0.0 与 0.2；0.2 也是核心点，继续纳入 0.3。于是前四点形成簇 0，0.0 与 0.3 最终是边界点。1.0 与 1.1 虽彼此很近，却没有达到三点密度门槛，仍是噪声；3.0 也是噪声。&lt;/p&gt;
&lt;p&gt;最终标签可写为：&lt;/p&gt;
&lt;p&gt;$$
[0,0,0,0,-1,-1,-1]
$$&lt;/p&gt;
&lt;p&gt;这个例子也说明 &lt;code&gt;min_samples&lt;/code&gt; 不是“一个簇至少多少点”，而是局部核心密度阈值。&lt;/p&gt;
&lt;h2&gt;06 队列如何完成一次簇扩展？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入：X [N,D]，eps，min_samples
预计算或按需查询 neighbors[i]
core[i] = len(neighbors[i]) &gt;= min_samples
labels[:] = UNVISITED
cluster_id = 0

for i in 0..N-1:
    if labels[i] 已访问: continue
    if not core[i]:
        labels[i] = NOISE
        continue

    labels[i] = cluster_id
    queue = neighbors[i]
    while queue 非空:
        j = queue.pop()
        if labels[j] == NOISE:
            labels[j] = cluster_id       # 噪声可改判为边界点
        if labels[j] 已归入某簇: continue
        labels[j] = cluster_id
        if core[j]:
            queue 加入 neighbors[j]     # 只有核心点继续扩展
    cluster_id += 1

输出：labels [N]，其中 -1 表示噪声
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个容易漏掉的细节是：先前暂记为噪声的点，之后遇到相邻核心点时必须能被改判为边界点。&lt;/p&gt;
&lt;h2&gt;07 不调用聚类器，写出最小 NumPy 实现&lt;/h2&gt;
&lt;p&gt;下面实现用于教学与小数据核对，显式构造 $[N,N]$ 距离矩阵，空间复杂度为 $O(N^2)$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import deque
import numpy as np

def dbscan_small(X: np.ndarray, eps: float, min_samples: int):
    X = np.asarray(X, dtype=np.float64)             # [N,D]
    distances = np.linalg.norm(
        X[:, None, :] - X[None, :, :], axis=2
    )                                               # [N,N]
    neighbors = [
        np.flatnonzero(distances[i] &amp;#x3C;= eps)
        for i in range(X.shape[0])
    ]
    is_core = np.array([
        len(ids) &gt;= min_samples for ids in neighbors
    ])                                              # [N]

    UNVISITED, NOISE = -2, -1
    labels = np.full(X.shape[0], UNVISITED, dtype=int)
    cluster_id = 0

    for seed in range(X.shape[0]):
        if labels[seed] != UNVISITED:
            continue
        if not is_core[seed]:
            labels[seed] = NOISE
            continue

        labels[seed] = cluster_id
        queue = deque(neighbors[seed].tolist())
        while queue:
            point = queue.popleft()
            if labels[point] == NOISE:
                labels[point] = cluster_id
            if labels[point] != UNVISITED:
                continue
            labels[point] = cluster_id
            if is_core[point]:
                queue.extend(neighbors[point].tolist())
        cluster_id += 1

    return labels, is_core

X = np.array([[0.0], [0.1], [0.2], [0.3], [1.0], [1.1], [3.0]])
labels, is_core = dbscan_small(X, eps=0.11, min_samples=3)

assert labels.tolist() == [0, 0, 0, 0, -1, -1, -1]
assert np.flatnonzero(is_core).tolist() == [1, 2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;队列中可能重复加入索引；&lt;code&gt;labels&lt;/code&gt; 检查保证每个点只真正扩展一次。生产实现仍应使用经过优化的近邻索引。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html&quot;&gt;&lt;code&gt;DBSCAN&lt;/code&gt; API&lt;/a&gt; 的主要输入是 &lt;code&gt;eps&lt;/code&gt;、&lt;code&gt;min_samples&lt;/code&gt;、距离 &lt;code&gt;metric&lt;/code&gt; 与近邻算法；输出包括全部标签和核心样本索引：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_train)           # [N,D]

db = DBSCAN(
    eps=0.35,
    min_samples=8,
    metric=&apos;euclidean&apos;,
    algorithm=&apos;auto&apos;,
    leaf_size=30,
    n_jobs=-1,
)
labels = db.fit_predict(X_scaled)                   # [N]

core_mask = np.zeros(X_scaled.shape[0], dtype=bool)
core_mask[db.core_sample_indices_] = True
n_clusters = len(set(labels)) - int(-1 in labels)
noise_ratio = np.mean(labels == -1)

assert db.components_.shape[0] == core_mask.sum()
assert db.components_.shape[1] == X_scaled.shape[1]
print(n_clusters, noise_ratio)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;components_&lt;/code&gt; 是核心样本的副本，形状为 &lt;code&gt;[n_core_samples,D]&lt;/code&gt;；&lt;code&gt;labels_&lt;/code&gt; 包含训练样本标签，噪声为 &lt;code&gt;-1&lt;/code&gt;。&lt;code&gt;algorithm&lt;/code&gt; 只影响近邻搜索策略，不改变 DBSCAN 的数学定义；高维或特殊距离下可能退化为暴力搜索。&lt;/p&gt;
&lt;h2&gt;09 eps 与 min_samples 应该怎样选择？&lt;/h2&gt;
&lt;p&gt;两者共同定义密度，不能独立机械调节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;eps&lt;/code&gt; 太小：多数点成噪声，真实簇被切碎；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eps&lt;/code&gt; 太大：稀疏桥把多个簇串成一个，邻域内存也会上升；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;min_samples&lt;/code&gt; 太小：随机小团也会成为核心；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;min_samples&lt;/code&gt; 太大：小而真实的簇消失。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用诊断是第 $k$ 近邻距离图，其中 $k$ 与 &lt;code&gt;min_samples&lt;/code&gt; 的计数约定保持一致：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.neighbors import NearestNeighbors

min_samples = 8
nn = NearestNeighbors(n_neighbors=min_samples)
nn.fit(X_scaled)
distances, indices = nn.kneighbors(X_scaled)       # [N,min_samples]
k_distance = np.sort(distances[:, -1])

# 画样本排序索引 -&gt; k_distance，拐点只提供 eps 候选，不是自动真值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为查询结果包含样本自身的零距离，第 &lt;code&gt;min_samples&lt;/code&gt; 个条目正对应核心点阈值所需的最远邻居。选定候选后，应画核心/边界/噪声分布，并检查参数小范围变化时簇是否稳定。&lt;/p&gt;
&lt;p&gt;特征尺度与距离度量比参数搜索更基础。经纬度不能直接当普通欧氏坐标；文本稀疏向量可能更适合余弦距离；混合数值与类别数据需要先定义有意义的距离。&lt;code&gt;eps=0.35&lt;/code&gt; 只在特定缩放与度量下有意义。&lt;/p&gt;
&lt;h2&gt;10 为什么大 eps 可能突然耗尽内存？&lt;/h2&gt;
&lt;p&gt;scikit-learn 当前实现会批量计算邻域，平均每点有 $d$ 个邻居时，存储可达 $O(Nd)$；当 &lt;code&gt;eps&lt;/code&gt; 很大、&lt;code&gt;min_samples&lt;/code&gt; 很小时，最坏情况达到 $O(N^2)$。这与原始论文可做到的线性内存不同。&lt;/p&gt;
&lt;p&gt;工程上应：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在样本子集上统计邻居数量分位数，再扩大数据；&lt;/li&gt;
&lt;li&gt;避免用超大 &lt;code&gt;eps&lt;/code&gt; 把所有点连成稠密图；&lt;/li&gt;
&lt;li&gt;对重复点去重，并通过 &lt;code&gt;sample_weight&lt;/code&gt; 保留计数；&lt;/li&gt;
&lt;li&gt;必要时用 &lt;code&gt;NearestNeighbors.radius_neighbors_graph(mode=&apos;distance&apos;)&lt;/code&gt; 分块预计算稀疏图，再设置 &lt;code&gt;metric=&apos;precomputed&apos;&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;需要多尺度结构或更低内存时评估 &lt;code&gt;OPTICS&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;n_jobs=-1&lt;/code&gt; 能并行部分邻域查询，不会消除平方级邻域本身。&lt;/p&gt;
&lt;h2&gt;11 训练完成后为什么没有自然的 predict？&lt;/h2&gt;
&lt;p&gt;DBSCAN 的簇是&lt;strong&gt;训练样本集合上的密度连通分量&lt;/strong&gt;。加入一个新点可能让原本的噪声变成核心点，甚至桥接两个旧簇，因此 scikit-learn 的 &lt;code&gt;DBSCAN&lt;/code&gt; 没有像 K 均值那样的原生 &lt;code&gt;predict(X_new)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;若业务必须处理流式新样本，有三种不同语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;冻结旧簇，只把新点分给附近核心样本：这是近似分类规则，不是重新运行 DBSCAN；&lt;/li&gt;
&lt;li&gt;定期把新旧数据一起重新聚类，并处理簇编号匹配；&lt;/li&gt;
&lt;li&gt;改用有显式外推函数的模型，或专门的增量密度方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不要把“离某核心点最近”伪装成原算法保证。还应为离所有核心点超过 &lt;code&gt;eps&lt;/code&gt; 的新样本保留拒绝/噪声结果。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘记缩放&lt;/strong&gt;：先打印每列量纲与标准差，再解释距离由哪些特征主导。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;误以为噪声就是真异常&lt;/strong&gt;：&lt;code&gt;-1&lt;/code&gt; 只表示当前密度阈值下不连通；边缘小群体可能被误伤。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把簇标签当有序类别&lt;/strong&gt;：0、1、2 只是编号，不表示大小或优先级。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用轮廓系数掩盖噪声处理&lt;/strong&gt;：明确指标是否剔除 &lt;code&gt;-1&lt;/code&gt;；同时报告覆盖率与噪声率。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稀疏桥合并簇&lt;/strong&gt;：查看连接两个主体的核心点链，而不只看最终颜色。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密度差异很大&lt;/strong&gt;：一组 &lt;code&gt;eps/min_samples&lt;/code&gt; 无法兼顾密簇和稀簇；考虑 OPTICS/HDBSCAN 或分层建模。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高维距离集中&lt;/strong&gt;：检查最近/最远距离比与近邻稳定性，必要时先做有验证的表示学习或降维。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;建议对每次运行记录：簇数、簇大小、核心点比例、噪声率、每点邻居数分位数、参数、缩放器和距离度量。对重采样数据比较簇匹配后的稳定性，比追求某个单一内部指标更可靠。&lt;/p&gt;
&lt;h2&gt;13 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法    | 是否需指定簇数 | 形状与噪声                 | 主要限制               |
| ------- | -------------- | -------------------------- | ---------------------- |
| K 均值  | 是             | 偏好球形，强制分配全部点   | 怕异常值与非凸形状     |
| GMM     | 是或给上限     | 椭圆软分配，通常解释全部点 | 参数假设、奇异协方差   |
| DBSCAN  | 否             | 任意密度连通形状，可标噪声 | 单一密度阈值、难外推   |
| OPTICS  | 否             | 保留多尺度可达顺序         | 结果解释与提取更复杂   |
| HDBSCAN | 否             | 构建密度层次并选稳定簇     | 超参数与层次语义更复杂 |&lt;/p&gt;
&lt;p&gt;DBSCAN 找到的是特定度量和密度阈值下的连通结构，不是对“自然类别”的无假设恢复。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;DBSCAN 用 $\varepsilon$ 邻域和 &lt;code&gt;min_samples&lt;/code&gt; 定义核心密度，再沿核心点邻接链扩展簇；边界点可加入但不能继续扩展，其余点记为噪声。它能表达非凸簇且无需预设簇数，但结果高度依赖尺度、距离和单一密度阈值；工程上还必须关注邻域内存与没有原生新样本预测这一边界。&lt;/p&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对手算例分别把 &lt;code&gt;eps&lt;/code&gt; 改为 0.09 和 0.81，重新列出核心点与标签。哪个设置会切碎，哪个会通过邻域链合并？&lt;/li&gt;
&lt;li&gt;构造两个核心簇共享一个非核心边界点的数据，改变输入行顺序并运行 DBSCAN；哪些标签变化只是编号置换，哪个点的簇归属真的改变？&lt;/li&gt;
&lt;li&gt;在月牙数据上比较 K 均值、GMM 与 DBSCAN。除可视化外，同时报告噪声率、核心点比例、参数扰动稳定性和查询新点时各方法能否给出原生输出。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://cdn.aaai.org/KDD/1996/KDD96-037.pdf&quot;&gt;Ester et al. (1996), A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise&lt;/a&gt;：DBSCAN 原始论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/304182.304187&quot;&gt;Ankerst et al. (1999), OPTICS: Ordering Points To Identify the Clustering Structure&lt;/a&gt;：把不同密度尺度组织为可达顺序。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/978-3-642-37456-2_14&quot;&gt;Campello, Moulavi &amp;#x26; Sander (2013), Density-Based Clustering Based on Hierarchical Density Estimates&lt;/a&gt;：层次密度聚类的重要工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/3068335&quot;&gt;Schubert et al. (2017), DBSCAN Revisited, Revisited&lt;/a&gt;：重新梳理算法、参数与复杂度实践。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;DBSCAN 用一条密度阈值切出连通簇，但真实数据常同时存在大类、小类与嵌套子类。下一篇将进入层次聚类：怎样从样本间距离逐步合并簇，用 linkage 决定“两个簇有多近”，并从树状图选择不同粒度的结构。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一个样本能同时像两个簇吗？高斯混合模型与 EM 软聚类</title><link>https://zwjcode.cn/blog/gmm-em-soft-clustering</link><guid isPermaLink="true">https://zwjcode.cn/blog/gmm-em-soft-clustering</guid><description>从重叠客群的硬分配不足出发，推导高斯混合模型的责任度与 EM 更新，手算一轮软聚类，并实现数值稳定、可诊断的概率聚类。</description><pubDate>Thu, 27 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 PCA 用连续坐标压缩数据；更早的 K 均值则把每个样本硬分给唯一中心。但在两类用户的消费行为明显重叠时，“只属于 A 或 B”会抹掉边界样本的不确定性，而且圆形等方差簇也未必符合真实几何。&lt;/p&gt;
&lt;p&gt;高斯混合模型（Gaussian Mixture Model，GMM）假设数据由若干高斯成分混合生成。它不只学习中心，还学习成分比例与协方差，并输出每个样本来自各成分的后验概率。本文只讲透三个紧密环节：&lt;strong&gt;混合似然、软责任度，以及期望最大化（Expectation-Maximization，EM）的 E 步—M 步循环。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 为什么“最近中心”表达不了重叠？&lt;/h2&gt;
&lt;p&gt;设两个顾客与两类中心的距离几乎相同。K 均值仍必须输出标签 0 或 1；标签在分界线两侧会突然翻转，也无法回答“模型有多确定”。GMM 改为描述生成过程：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;先选潜在成分 zᵢ                 再由该成分生成 xᵢ

π = [π₁,...,πₖ] ──抽样──► zᵢ ──选择 μₖ,Σₖ──► xᵢ [D]
                               │
观测训练数据只有 X [N,D] ◄─────┘  zᵢ 未被观测

推理：xᵢ ──各成分密度──► 未归一化证据 [K]
              └────────► 责任度 γᵢ [K]，各项和为 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对第 $i$ 个样本，先以概率 $\pi_k$ 选择成分 $z_i=k$，再生成：&lt;/p&gt;
&lt;p&gt;$$
x_i\mid z_i=k\sim\mathcal N(\mu_k,\Sigma_k)
$$&lt;/p&gt;
&lt;p&gt;其中 $x_i,\mu_k\in\mathbb R^D$，$\Sigma_k\in\mathbb R^{D\times D}$，混合权重满足 $\pi_k\ge 0$ 且 $\sum_{k=1}^K\pi_k=1$。&lt;/p&gt;
&lt;h2&gt;02 混合模型优化的是什么？&lt;/h2&gt;
&lt;p&gt;边缘化看不见的 $z_i$，单个样本密度是：&lt;/p&gt;
&lt;p&gt;$$
p(x_i\mid\theta)=\sum_{k=1}^{K}\pi_k\mathcal N(x_i\mid\mu_k,\Sigma_k)
$$&lt;/p&gt;
&lt;p&gt;训练最大化全部样本的对数似然（Log-Likelihood）：&lt;/p&gt;
&lt;p&gt;$$
\ell(\theta)=\sum_{i=1}^{N}\log\left[
\sum_{k=1}^{K}\pi_k\mathcal N(x_i\mid\mu_k,\Sigma_k)
\right]
$$&lt;/p&gt;
&lt;p&gt;困难来自“对数里面还有求和”。若每个 $z_i$ 已知，就能分别统计每个成分；若参数已知，又能推断 $z_i$。EM 正是交替解决这两个容易的子问题。&lt;/p&gt;
&lt;h2&gt;03 E 步怎样把证据变成责任度？&lt;/h2&gt;
&lt;p&gt;给定旧参数 $\theta^{old}$，E 步计算后验：&lt;/p&gt;
&lt;p&gt;$$
\gamma_{ik}=p(z_i=k\mid x_i,\theta^{old})
=\frac{\pi_k\mathcal N(x_i\mid\mu_k,\Sigma_k)}
{\sum_{j=1}^{K}\pi_j\mathcal N(x_i\mid\mu_j,\Sigma_j)}
$$&lt;/p&gt;
&lt;p&gt;$\Gamma=[\gamma_{ik}]\in\mathbb R^{N\times K}$ 称为责任度（Responsibility）矩阵。每行和为 1；每列和&lt;/p&gt;
&lt;p&gt;$$
N_k=\sum_{i=1}^{N}\gamma_{ik}
$$&lt;/p&gt;
&lt;p&gt;是成分 $k$ 的有效样本数，不必是整数。与 K 均值的 one-hot 分配相比，$\Gamma$ 保留了重叠区域的不确定性。&lt;/p&gt;
&lt;h2&gt;04 M 步为何是加权均值与协方差？&lt;/h2&gt;
&lt;p&gt;M 步固定 $\Gamma$，最大化完整数据对数似然的期望。更新式为：&lt;/p&gt;
&lt;p&gt;$$
\pi_k^{new}=\frac{N_k}{N},\qquad
\mu_k^{new}=\frac{1}{N_k}\sum_{i=1}^{N}\gamma_{ik}x_i
$$&lt;/p&gt;
&lt;p&gt;$$
\Sigma_k^{new}=\frac{1}{N_k}\sum_{i=1}^{N}
\gamma_{ik}(x_i-\mu_k^{new})(x_i-\mu_k^{new})^\top
$$&lt;/p&gt;
&lt;p&gt;数据流与形状如下：&lt;/p&gt;
&lt;p&gt;| 量       | 形状      | 含义                       |
| -------- | --------- | -------------------------- |
| $X$      | $[N,D]$   | $N$ 个 $D$ 维样本          |
| $\Gamma$ | $[N,K]$   | 每个样本对每个成分的责任度 |
| $N_k$    | $[K]$     | 各成分有效样本数           |
| $\mu$    | $[K,D]$   | 各成分均值                 |
| $\Sigma$ | $[K,D,D]$ | &lt;code&gt;full&lt;/code&gt; 协方差              |
| $\pi$    | $[K]$     | 混合权重                   |&lt;/p&gt;
&lt;p&gt;EM 每轮不会降低训练似然，但只保证走向局部最优或鞍点附近；初始化不同，答案可能不同。&lt;/p&gt;
&lt;h2&gt;05 用三个一维点手算一轮 E 步&lt;/h2&gt;
&lt;p&gt;取 $x=[0,1,2]$，两个成分初始权重都为 $0.5$，均值 $\mu_1=0$、$\mu_2=2$，方差都为 1。省略两个成分共有的 $1/\sqrt{2\pi}$，高斯密度只需比较 $\exp[-(x-\mu)^2/2]$。&lt;/p&gt;
&lt;p&gt;对 $x=0$，两个未归一化证据为：&lt;/p&gt;
&lt;p&gt;$$
a_1=0.5e^0=0.5,\qquad a_2=0.5e^{-2}\approx0.0677
$$&lt;/p&gt;
&lt;p&gt;所以 $\gamma_{01}\approx0.881$、$\gamma_{02}\approx0.119$。同理：&lt;/p&gt;
&lt;p&gt;$$
\Gamma\approx
\begin{bmatrix}
0.881 &amp;#x26; 0.119\
0.500 &amp;#x26; 0.500\
0.119 &amp;#x26; 0.881
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;于是 $N_1=N_2=1.5$，新的权重仍为 $0.5$，均值更新为：&lt;/p&gt;
&lt;p&gt;$$
\mu_1^{new}=\frac{0\times0.881+1\times0.5+2\times0.119}{1.5}
\approx0.492
$$&lt;/p&gt;
&lt;p&gt;对称地 $\mu_2^{new}\approx1.508$。中间点没有被武断地独占，而是向两个均值各贡献 $0.5$ 个样本。&lt;/p&gt;
&lt;h2&gt;06 训练与推理的完整伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入：X [N,D]，成分数 K
初始化：π [K]，μ [K,D]，Σ [K,D,D]

repeat:
    # E 步：必须在 log 空间计算
    log_joint[i,k] = log π[k] + log Normal(X[i] | μ[k], Σ[k])
    log_norm[i] = logsumexp(log_joint[i,:])
    Γ[i,k] = exp(log_joint[i,k] - log_norm[i])

    # M 步
    N_k[k] = sum_i Γ[i,k]
    π[k] = N_k[k] / N
    μ[k] = sum_i Γ[i,k] X[i] / N_k[k]
    Σ[k] = weighted covariance around new μ[k] + reg_covar · I

    lower_bound = mean_i log_norm[i]
until lower_bound improvement &amp;#x3C; tol or max_iter reached

输出：π、μ、Σ；推理时重新执行 E 步得到概率 [Q,K]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;logsumexp&lt;/code&gt; 先减最大值再求指数，避免高维高斯密度下溢到 0。直接计算许多很小的密度再相除，常会得到 &lt;code&gt;0 / 0 -&gt; NaN&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;07 用 NumPy 写出可检查的一维 EM&lt;/h2&gt;
&lt;p&gt;下面刻意限制为一维、对角方差，让更新本体保持透明：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([[0.0], [1.0], [2.0]])               # [N=3,D=1]
means = np.array([[0.0], [2.0]])                  # [K=2,D=1]
variances = np.ones((2, 1))                       # [K,D]
weights = np.array([0.5, 0.5])                    # [K]

for _ in range(20):
    diff = X[:, None, :] - means[None, :, :]      # [N,K,D]
    log_gaussian = -0.5 * (
        np.log(2 * np.pi * variances)[None, :, :]
        + diff**2 / variances[None, :, :]
    ).sum(axis=2)                                 # [N,K]
    log_joint = np.log(weights)[None, :] + log_gaussian

    row_max = log_joint.max(axis=1, keepdims=True)
    log_norm = row_max + np.log(
        np.exp(log_joint - row_max).sum(axis=1, keepdims=True)
    )                                             # [N,1]
    responsibilities = np.exp(log_joint - log_norm)  # [N,K]

    effective_count = responsibilities.sum(axis=0)    # [K]
    weights = effective_count / X.shape[0]
    means = responsibilities.T @ X / effective_count[:, None]
    diff = X[:, None, :] - means[None, :, :]
    variances = (
        responsibilities[:, :, None] * diff**2
    ).sum(axis=0) / effective_count[:, None]
    variances = np.maximum(variances, 1e-6)

assert responsibilities.shape == (3, 2)
assert np.allclose(responsibilities.sum(axis=1), 1.0)
assert np.isclose(weights.sum(), 1.0)
assert np.all(variances &gt; 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多维 &lt;code&gt;full&lt;/code&gt; 协方差还需稳定计算 log-determinant 与线性方程，生产代码不应手写矩阵逆。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.mixture.GaussianMixture.html&quot;&gt;&lt;code&gt;GaussianMixture&lt;/code&gt; API&lt;/a&gt; 提供 &lt;code&gt;predict_proba&lt;/code&gt;、逐样本 &lt;code&gt;score_samples&lt;/code&gt;、平均对数似然 &lt;code&gt;score&lt;/code&gt;、AIC/BIC 与收敛属性：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.mixture import GaussianMixture
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

model = make_pipeline(
    StandardScaler(),
    GaussianMixture(
        n_components=2,
        covariance_type=&apos;full&apos;,
        init_params=&apos;k-means++&apos;,
        n_init=10,
        reg_covar=1e-6,
        tol=1e-3,
        max_iter=300,
        random_state=42,
    ),
)
model.fit(X_train)                                # X_train [N,D]

gmm = model.named_steps[&apos;gaussianmixture&apos;]
prob = model.predict_proba(X_new)                  # [Q,K]
label = model.predict(X_new)                       # [Q]，argmax(prob)
log_density = model.score_samples(X_new)           # [Q]

assert prob.shape == (X_new.shape[0], 2)
assert np.allclose(prob.sum(axis=1), 1.0)
assert gmm.means_.shape == (2, X_train.shape[1])
assert gmm.covariances_.shape == (2, X_train.shape[1], X_train.shape[1])
print(gmm.converged_, gmm.n_iter_, gmm.lower_bound_)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fit_predict&lt;/code&gt; 的最终标签可能与“先 &lt;code&gt;fit&lt;/code&gt; 再 &lt;code&gt;predict(X_train)&lt;/code&gt;”在边界样本上略有不同，因为前者返回最后一次 E 步的标签，而参数还可能在最后一次 M 步改变。若业务需要最终参数下的统一推理语义，应显式 &lt;code&gt;fit&lt;/code&gt; 后调用 &lt;code&gt;predict&lt;/code&gt; 或 &lt;code&gt;predict_proba&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;09 协方差类型控制什么几何？&lt;/h2&gt;
&lt;p&gt;| &lt;code&gt;covariance_type&lt;/code&gt; | 参数形状  | 等概率线几何        | 代价与偏差           |
| ----------------- | --------- | ------------------- | -------------------- |
| &lt;code&gt;spherical&lt;/code&gt;       | $[K]$     | 每簇一个半径的圆/球 | 最省参数，限制最强   |
| &lt;code&gt;diag&lt;/code&gt;            | $[K,D]$   | 轴对齐椭圆          | 不能表达特征相关     |
| &lt;code&gt;tied&lt;/code&gt;            | $[D,D]$   | 各簇共享形状        | 类似共享协方差思想   |
| &lt;code&gt;full&lt;/code&gt;            | $[K,D,D]$ | 每簇可旋转椭圆      | 最灵活，也最易过拟合 |&lt;/p&gt;
&lt;p&gt;&lt;code&gt;full&lt;/code&gt; 的协方差参数量随 $KD^2$ 增长。小样本高维数据中，应先考虑降维、&lt;code&gt;diag&lt;/code&gt;/&lt;code&gt;tied&lt;/code&gt;、更强 &lt;code&gt;reg_covar&lt;/code&gt; 或带先验的 &lt;code&gt;BayesianGaussianMixture&lt;/code&gt;，而不是默认使用最自由的模型。&lt;/p&gt;
&lt;h2&gt;10 怎样选择 K，而不是只看训练似然？&lt;/h2&gt;
&lt;p&gt;增加成分几乎总能提高训练似然，因此不能用它单独选 $K$。常用贝叶斯信息准则（Bayesian Information Criterion，BIC）：&lt;/p&gt;
&lt;p&gt;$$
\mathrm{BIC}=p\log N-2\ell(\hat\theta)
$$&lt;/p&gt;
&lt;p&gt;$p$ 是自由参数数目，值越小越好。还应结合留出集平均对数似然、成分稳定性、最小有效样本数和业务可解释性：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.mixture import GaussianMixture

candidates = []
for k in range(1, 7):
    gm = GaussianMixture(
        n_components=k,
        covariance_type=&apos;full&apos;,
        n_init=10,
        reg_covar=1e-5,
        random_state=42,
    ).fit(X_train_scaled)
    candidates.append({
        &apos;k&apos;: k,
        &apos;bic_train&apos;: gm.bic(X_train_scaled),
        &apos;valid_log_likelihood&apos;: gm.score(X_valid_scaled),
        &apos;smallest_weight&apos;: gm.weights_.min(),
        &apos;converged&apos;: gm.converged_,
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;BIC 假设候选模型与独立同分布数据足够吻合；它不是“真实簇数探测器”。&lt;/p&gt;
&lt;h2&gt;11 最常见的失败与调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;协方差塌缩&lt;/strong&gt;：某成分抓住单个点，方差趋近 0、似然趋向无穷。检查最小特征值、&lt;code&gt;weights_&lt;/code&gt;，提高 &lt;code&gt;reg_covar&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有收敛&lt;/strong&gt;：检查 &lt;code&gt;converged_&lt;/code&gt;、&lt;code&gt;n_iter_&lt;/code&gt; 和警告；增加 &lt;code&gt;max_iter&lt;/code&gt; 前先缩放特征、换初始化并排查异常值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;局部最优&lt;/strong&gt;：提高 &lt;code&gt;n_init&lt;/code&gt;，比较不同种子的 &lt;code&gt;lower_bound_&lt;/code&gt; 和留出似然，不只看一次结果。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;概率过度解读&lt;/strong&gt;：&lt;code&gt;predict_proba&lt;/code&gt; 是模型假设下的成分后验，不是经真实类别校准的置信度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据泄漏&lt;/strong&gt;：缩放器只能在训练集拟合；时序数据必须用过去训练、未来验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;离群点牵引&lt;/strong&gt;：高斯尾部仍可能用巨大协方差解释异常点；检查稳健预处理或显式异常模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;维度过高&lt;/strong&gt;：样本协方差近奇异；画特征值谱，核对每个成分的有效样本数是否远小于维数。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;线上至少监控平均 &lt;code&gt;score_samples&lt;/code&gt;、低密度样本比例、成分权重、均值漂移、协方差特征值和最大责任度分布。最大责任度普遍下降，可能表示簇开始重叠或出现了训练外模式。&lt;/p&gt;
&lt;h2&gt;12 GMM 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法       | 分配/表示      | 核心假设         | 主要边界                       |
| ---------- | -------------- | ---------------- | ------------------------------ |
| K 均值     | 最近中心硬分配 | 近似等方差球形簇 | 不给概率，不建模协方差         |
| GMM        | 后验概率软分配 | 有限个高斯混合   | 需给上限或 $K$，怕奇异与异常值 |
| 核密度估计 | 每个样本贡献核 | 平滑密度         | 不直接产生少数全局成分         |
| LDA/QDA    | 有标签的类后验 | 类条件高斯       | 是监督分类，标签已知           |
| DBSCAN     | 密度连通与噪声 | 局部密度阈值     | 可找非凸簇，但不输出生成概率   |&lt;/p&gt;
&lt;p&gt;当真实结构是月牙、环或不同密度的连通区域时，多加几个高斯也许能近似密度，却未必给出符合语义的簇。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;GMM 用 $\pi_k$、$\mu_k$ 和 $\Sigma_k$ 描述多个高斯成分；E 步把每个样本的成分证据归一化为责任度，M 步用责任度做加权统计。EM 单调改善训练目标，却不保证全局最优，也不能自动证明簇真实存在。可靠实践需要 log 空间计算、多次初始化、协方差正则、留出似然与稳定性诊断。&lt;/p&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;延续三点例子，用更新后的两个均值和原方差再做一次 E 步。中间点责任度是否改变？两端样本为何变得更不确定或更确定？&lt;/li&gt;
&lt;li&gt;对同一二维数据分别拟合 &lt;code&gt;spherical&lt;/code&gt;、&lt;code&gt;diag&lt;/code&gt;、&lt;code&gt;tied&lt;/code&gt; 与 &lt;code&gt;full&lt;/code&gt; GMM，列出参数形状、BIC、留出似然和最小协方差特征值。&lt;/li&gt;
&lt;li&gt;人为加入一个远离主体的孤立点，逐渐减小 &lt;code&gt;reg_covar&lt;/code&gt;，观察成分权重、协方差行列式与训练似然；解释“似然更高但模型更坏”的原因。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1111/j.2517-6161.1977.tb01600.x&quot;&gt;Dempster, Laird &amp;#x26; Rubin (1977), Maximum Likelihood from Incomplete Data via the EM Algorithm&lt;/a&gt;：EM 算法的经典统一表述。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1137/0904030&quot;&gt;Redner &amp;#x26; Walker (1984), Mixture Densities, Maximum Likelihood and the EM Algorithm&lt;/a&gt;：有限混合模型极大似然与 EM 的系统综述。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1214/aos/1176344136&quot;&gt;Schwarz (1978), Estimating the Dimension of a Model&lt;/a&gt;：BIC 的理论来源。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jstor.org/stable/2680726&quot;&gt;Tipping &amp;#x26; Bishop (1999), Probabilistic Principal Component Analysis&lt;/a&gt;：把上一篇 PCA 放入概率潜变量模型。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;GMM 能把圆团推广成重叠椭圆，却仍用若干参数化分布解释全部样本。下一篇将研究 DBSCAN：不预先指定簇数，只用 $\varepsilon$ 邻域、核心点和密度可达关系，怎样沿弯曲形状扩展簇并把稀疏点标为噪声。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>高维数据怎样压成几个方向？PCA 的最大方差、重构误差与 SVD</title><link>https://zwjcode.cn/blog/pca-variance-reconstruction-svd</link><guid isPermaLink="true">https://zwjcode.cn/blog/pca-variance-reconstruction-svd</guid><description>从相关传感器的冗余出发，统一 PCA 的最大方差与最小重构视角，手算主轴，并用 SVD 和 scikit-learn 1.9 实现可诊断降维。</description><pubDate>Wed, 26 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 K 均值在原始特征空间比较样本到中心的欧氏距离。若 100 个传感器中许多通道测量同一种物理变化，模型会在大量相关坐标上重复计算；可视化、存储和后续学习也都会变难。&lt;/p&gt;
&lt;p&gt;主成分分析（Principal Component Analysis，PCA）不使用标签，而是寻找数据变化最强的一组正交方向，再把样本投影过去。本文只围绕三个等价视角展开：&lt;strong&gt;最大化投影方差、最小化线性重构误差，以及用奇异值分解稳定地算出主轴。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 删除某些列为何不是理想的降维？&lt;/h2&gt;
&lt;p&gt;假设温度传感器 A 和 B 几乎同步变化。直接删掉 B 能减少一维，但会完全丢弃 B 的独立噪声与校准信息。PCA 不选择某一根坐标轴，而是学习一个组合方向，例如：&lt;/p&gt;
&lt;p&gt;$$
v_1=0.71,\text{A}+0.70,\text{B}
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂（传感器 B）
▲
│             ●
│          ●
│       ●          数据主要沿斜线变化
│    ●        ╱ v₁（第一主轴）
│ ●        ╱
└────────╱──────────────────► x₁（传感器 A）
         垂直于 v₁ 的变化很小
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原始变量仍可通过主轴系数解释，但新特征不再等于单独某一列。PCA 是特征提取（Feature Extraction），不是特征选择（Feature Selection）。&lt;/p&gt;
&lt;h2&gt;02 第一步为什么必须中心化？&lt;/h2&gt;
&lt;p&gt;输入：&lt;/p&gt;
&lt;p&gt;$$
X\in\mathbb{R}^{N\times D}
$$&lt;/p&gt;
&lt;p&gt;先计算每个特征的训练均值：&lt;/p&gt;
&lt;p&gt;$$
\bar x=\frac1N\sum_{i=1}^{N}x_i\in\mathbb{R}^{D}
$$&lt;/p&gt;
&lt;p&gt;中心化矩阵：&lt;/p&gt;
&lt;p&gt;$$
X_c=X-\mathbf{1}\bar x^\top\in\mathbb{R}^{N\times D}
$$&lt;/p&gt;
&lt;p&gt;若不减均值，最大的能量方向可能只是从原点指向数据云中心，而不是数据真正变化的方向。之后所有验证集和新样本都必须减&lt;strong&gt;训练集&lt;/strong&gt;均值，不能各自重新中心化。&lt;/p&gt;
&lt;p&gt;样本协方差矩阵为：&lt;/p&gt;
&lt;p&gt;$$
S=\frac{1}{N-1}X_c^\top X_c\in\mathbb{R}^{D\times D}
$$&lt;/p&gt;
&lt;p&gt;它的对角线是各特征方差，非对角线是共同变化。PCA 要在这个几何中寻找主轴。&lt;/p&gt;
&lt;h2&gt;03 “最大方差方向”怎样写成优化问题？&lt;/h2&gt;
&lt;p&gt;取单位向量 $v\in\mathbb{R}^{D}$，满足 $\lVert v\rVert_2=1$。每个样本投影为：&lt;/p&gt;
&lt;p&gt;$$
z_i=x_{c,i}^\top v
$$&lt;/p&gt;
&lt;p&gt;全部投影 $z=X_cv\in\mathbb{R}^{N}$。投影方差为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(z)=\frac{1}{N-1}z^\top z=v^\top Sv
$$&lt;/p&gt;
&lt;p&gt;第一主成分方向解决：&lt;/p&gt;
&lt;p&gt;$$
v_1=\arg\max_{\lVert v\rVert_2=1}v^\top Sv
$$&lt;/p&gt;
&lt;p&gt;用拉格朗日乘子求导：&lt;/p&gt;
&lt;p&gt;$$
Sv_1=\lambda_1v_1
$$&lt;/p&gt;
&lt;p&gt;因此 $v_1$ 是协方差矩阵最大特征值 $\lambda_1$ 对应的特征向量。后续方向继续最大化方差，并要求与已有方向正交。保留 $M$ 个方向时：&lt;/p&gt;
&lt;p&gt;$$
V_M=[v_1,\ldots,v_M]\in\mathbb{R}^{D\times M},\qquad
V_M^\top V_M=I_M
$$&lt;/p&gt;
&lt;p&gt;低维表示为：&lt;/p&gt;
&lt;p&gt;$$
Z=X_cV_M\in\mathbb{R}^{N\times M}
$$&lt;/p&gt;
&lt;h2&gt;04 最大方差为何等价于最小重构误差？&lt;/h2&gt;
&lt;p&gt;投影后可把样本映回原空间：&lt;/p&gt;
&lt;p&gt;$$
\hat X=ZV_M^\top+\mathbf{1}\bar x^\top\in\mathbb{R}^{N\times D}
$$&lt;/p&gt;
&lt;p&gt;PCA 同时解决秩为 $M$ 的正交线性重构问题：&lt;/p&gt;
&lt;p&gt;$$
\min_{V_M^\top V_M=I_M}
\left\lVert X_c-X_cV_MV_M^\top\right\rVert_F^2
$$&lt;/p&gt;
&lt;p&gt;总中心化能量是固定的。投影方向保留得越多，垂直方向丢掉的能量越少，因此“最大保留方差”和“最小平方重构误差”是同一件事的两面。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练：X [N,D]
       │ 减 mean [D]
       ▼
     Xc [N,D] ── SVD ──► components [M,D]
       │                         │
       └──── @ components.T ─────┘
                     ▼
                   Z [N,M]

重构：Z [N,M] @ components [M,D] + mean [D]
                     ▼
                  X_hat [N,D]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 用三个二维点手算第一主轴&lt;/h2&gt;
&lt;p&gt;取三个已中心化样本：&lt;/p&gt;
&lt;p&gt;$$
X_c=
\begin{bmatrix}
-1 &amp;#x26; -1\
0 &amp;#x26; 0\
1 &amp;#x26; 1
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;协方差矩阵为：&lt;/p&gt;
&lt;p&gt;$$
S=\frac{1}{3-1}X_c^\top X_c=
\begin{bmatrix}
1 &amp;#x26; 1\
1 &amp;#x26; 1
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;两个特征值为 $\lambda_1=2$、$\lambda_2=0$，对应单位方向可取：&lt;/p&gt;
&lt;p&gt;$$
v_1=\frac{1}{\sqrt2}\begin{bmatrix}1\1\end{bmatrix},\qquad
v_2=\frac{1}{\sqrt2}\begin{bmatrix}1\-1\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;只保留第一主成分：&lt;/p&gt;
&lt;p&gt;$$
Z=X_cv_1=\begin{bmatrix}-\sqrt2\0\\sqrt2\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;重构 $\hat X_c=Zv_1^\top$ 恰好等于原矩阵，误差为 0，因为三个点完全落在直线 $x_1=x_2$ 上。解释方差比为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\lambda_1}{\lambda_1+\lambda_2}=1
$$&lt;/p&gt;
&lt;p&gt;注意 $v_1$ 取相反数也同样正确：主轴的符号不唯一，相应的 $Z$ 也会整体变号，但重构与解释方差不变。&lt;/p&gt;
&lt;h2&gt;06 为什么实现通常直接做 SVD？&lt;/h2&gt;
&lt;p&gt;显式形成 $D\times D$ 协方差矩阵可能耗费大量内存，还会放大数值条件问题。对中心化矩阵做奇异值分解（Singular Value Decomposition，SVD）：&lt;/p&gt;
&lt;p&gt;$$
X_c=U\Sigma V^\top
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$U\in\mathbb{R}^{N\times R}$：左奇异向量；&lt;/li&gt;
&lt;li&gt;$\Sigma=\operatorname{diag}(\sigma_1,\ldots,\sigma_R)\in\mathbb{R}^{R\times R}$；&lt;/li&gt;
&lt;li&gt;$V^\top\in\mathbb{R}^{R\times D}$：右奇异向量，行就是主轴；&lt;/li&gt;
&lt;li&gt;$R=\min(N,D)$（经济型分解下再受矩阵秩限制）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;协方差特征值与奇异值满足：&lt;/p&gt;
&lt;p&gt;$$
\lambda_j=\frac{\sigma_j^2}{N-1}
$$&lt;/p&gt;
&lt;p&gt;所以不需要先计算 $S$ 也能得到主轴与解释方差。&lt;/p&gt;
&lt;h2&gt;07 用 NumPy 写出 PCA 本体&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([
    [-1.0, -1.0],
    [0.0, 0.0],
    [1.0, 1.0],
], dtype=np.float64)                              # [N=3,D=2]

mean = X.mean(axis=0)                             # [D]
X_centered = X - mean                             # [N,D]

U, singular_values, Vt = np.linalg.svd(
    X_centered,
    full_matrices=False,
)

n_components = 1
components = Vt[:n_components]                    # [M=1,D]
Z = X_centered @ components.T                     # [N,M]
X_reconstructed = Z @ components + mean           # [N,D]

explained_variance = singular_values**2 / (X.shape[0] - 1)
explained_variance_ratio = explained_variance / explained_variance.sum()
reconstruction_mse = np.mean((X - X_reconstructed) ** 2)

assert components.shape == (1, 2)
assert Z.shape == (3, 1)
assert np.allclose(components @ components.T, np.eye(1))
assert np.isclose(explained_variance_ratio[0], 1.0)
assert np.isclose(reconstruction_mse, 0.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际数据可能存在相等或非常接近的奇异值，此时对应子空间仍稳定，但子空间内部的单根主轴可能旋转。不要仅凭某个载荷（loading）的微小变化就断言数据机制改变。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;按照当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html&quot;&gt;&lt;code&gt;PCA&lt;/code&gt; 应用程序接口（Application Programming Interface，API）&lt;/a&gt;，模型会自动中心化输入，但不会自动按列缩放。若各特征单位不同且希望等权，可把缩放放进同一条流水线（Pipeline）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pipeline = make_pipeline(
    StandardScaler(),
    PCA(
        n_components=0.95,
        svd_solver=&apos;full&apos;,
        whiten=False,
    ),
)

Z_train = pipeline.fit_transform(X_train)         # [N,M]
Z_new = pipeline.transform(X_new)                  # [Q,M]

pca = pipeline.named_steps[&apos;pca&apos;]
scaled_reconstruction = pca.inverse_transform(Z_new)  # [Q,D]
X_reconstructed = pipeline.named_steps[&apos;standardscaler&apos;].inverse_transform(
    scaled_reconstruction
)

print(pca.n_components_)                           # 实际 M
print(pca.components_.shape)                       # [M,D]
print(pca.explained_variance_.shape)               # [M]
print(pca.explained_variance_ratio_.sum())         # 至少约 0.95
print(pca.singular_values_.shape)                  # [M]
print(pca.mean_.shape)                             # [D]，缩放空间中的训练均值

assert Z_new.shape[1] == pca.n_components_
assert np.all(np.diff(pca.explained_variance_) &amp;#x3C;= 0)
assert np.allclose(
    pca.components_ @ pca.components_.T,
    np.eye(pca.n_components_),
    atol=1e-10,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 &lt;code&gt;n_components=0.95&lt;/code&gt; 只在 &lt;code&gt;svd_solver=&apos;full&apos;&lt;/code&gt; 时按累计解释方差阈值选维数。&lt;code&gt;fit_transform&lt;/code&gt; 的训练输出和后续 &lt;code&gt;transform&lt;/code&gt; 都使用训练均值与训练主轴；不能在测试集上重新 &lt;code&gt;fit&lt;/code&gt; 以“适配”测试分布。&lt;/p&gt;
&lt;h2&gt;09 求解器怎样影响时间、内存与精度？&lt;/h2&gt;
&lt;p&gt;scikit-learn 1.9 的主要选择如下：&lt;/p&gt;
&lt;p&gt;| &lt;code&gt;svd_solver&lt;/code&gt;      | 核心计算             | 适用形状               | 重要限制                            |
| ----------------- | -------------------- | ---------------------- | ----------------------------------- |
| &lt;code&gt;full&lt;/code&gt;            | 精确完整 SVD 后截断  | 中小数据、需要精确基线 | 大矩阵成本高                        |
| &lt;code&gt;covariance_eigh&lt;/code&gt; | 构造协方差后特征分解 | $N\gg D$ 且 $D$ 较小   | 协方差占 $D^2$ 内存，数值条件更差   |
| &lt;code&gt;randomized&lt;/code&gt;      | 随机截断 SVD         | 大矩阵且只取少量 $M$   | 近似；固定 &lt;code&gt;random_state&lt;/code&gt;，检查误差 |
| &lt;code&gt;arpack&lt;/code&gt;          | 稀疏截断 SVD         | 只取少数分量           | 必须 $0&amp;#x3C;M&amp;#x3C;\min(N,D)$                |
| &lt;code&gt;auto&lt;/code&gt;            | 根据形状与 $M$ 选择  | 合理默认起点           | 版本升级可能改变选择策略            |&lt;/p&gt;
&lt;p&gt;&lt;code&gt;randomized&lt;/code&gt; 的 &lt;code&gt;n_oversamples&lt;/code&gt;、&lt;code&gt;iterated_power&lt;/code&gt; 与 &lt;code&gt;power_iteration_normalizer&lt;/code&gt; 控制速度—精度权衡。不要在没有误差基线的情况下随意压低它们。若可复现性或跨版本一致性重要，应显式写求解器和随机种子，并记录库版本。&lt;/p&gt;
&lt;h2&gt;10 维数 M 应该怎样选？&lt;/h2&gt;
&lt;p&gt;常见做法是画累计解释方差：&lt;/p&gt;
&lt;p&gt;$$
c_M=\frac{\sum_{j=1}^{M}\lambda_j}{\sum_{j=1}^{R}\lambda_j}
$$&lt;/p&gt;
&lt;p&gt;但 $c_M=95%$ 不是普适规则。应至少同时检查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;重构误差&lt;/strong&gt;：验证样本在不同 $M$ 下的均方重构误差；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;下游任务&lt;/strong&gt;：在完整 Pipeline 内交叉验证分类、回归、检索或聚类指标；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稳定性&lt;/strong&gt;：重采样后比较主子空间，而不只比较单根向量符号；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;资源约束&lt;/strong&gt;：确认存储、延迟和可视化维数是否达到项目目标。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;若 PCA 是监督模型的预处理，$M$ 必须在每个训练折内部选择。先对全数据拟合 PCA 再交叉验证会把验证折的均值与协方差信息泄漏给训练过程。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV

model = make_pipeline(
    StandardScaler(),
    PCA(svd_solver=&apos;full&apos;),
    LogisticRegression(max_iter=2000),
)

# GridSearchCV：带交叉验证的网格搜索
search = GridSearchCV(
    model,
    param_grid={&apos;pca__n_components&apos;: [2, 5, 10, 20]},
    scoring=&apos;neg_log_loss&apos;,
    cv=5,
)
search.fit(X_train, y_train)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;11 标准化、白化和稀疏输入不要混为一谈&lt;/h2&gt;
&lt;p&gt;**标准化（Standardization）**发生在 PCA 之前，把每个原始特征缩放到相近方差；它改变主轴关注的几何。若米、千克和元混在一起，通常需要明确处理量纲。若所有像素采用同一测量尺度，保留自然方差也可能更合理。&lt;/p&gt;
&lt;p&gt;**白化（Whitening）**发生在 PCA 投影时。&lt;code&gt;whiten=True&lt;/code&gt; 会进一步按奇异值缩放各主成分，使输出分量方差约为 1。它去掉了分量之间原有的相对方差尺度，可能帮助假设各向同性的下游模型，也可能丢掉有用强度信息。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;稀疏输入&lt;/strong&gt;不能随意中心化，因为减去均值通常会把零填成非零并造成内存爆炸。当前 &lt;code&gt;PCA&lt;/code&gt; 只在部分求解器上支持稀疏输入；大规模词袋数据常用不中心化的 &lt;code&gt;TruncatedSVD&lt;/code&gt;。后者有时被称为潜在语义分析，但它与严格中心化 PCA 的目标并不相同。&lt;/p&gt;
&lt;h2&gt;12 怎样调试与监控 PCA？&lt;/h2&gt;
&lt;p&gt;建议记录以下量，而不只是二维散点图：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ratio = pca.explained_variance_ratio_
assert np.isfinite(ratio).all()
assert np.all(ratio &gt;= 0)
assert ratio.sum() &amp;#x3C;= 1.0 + 1e-12

reconstruction = pipeline.inverse_transform(Z_train)
per_feature_mse = np.mean((X_train - reconstruction) ** 2, axis=0)  # [D]

print(&apos;selected dimensions:&apos;, pca.n_components_)
print(&apos;cumulative explained variance:&apos;, ratio.sum())
print(&apos;worst reconstructed feature:&apos;, np.argmax(per_feature_mse))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署后还应监控训练均值与新数据均值的偏移、投影分数分布、各主成分方差、重构误差和缺失值比例。重构误差突然上升可能表示分布漂移、传感器故障或新模式出现，但不是自动的异常因果诊断。&lt;/p&gt;
&lt;p&gt;常见错误包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在划分数据前拟合缩放器或 PCA；&lt;/li&gt;
&lt;li&gt;忘记中心化，或让训练与推理使用不同均值；&lt;/li&gt;
&lt;li&gt;把 &lt;code&gt;components_&lt;/code&gt; 当成 &lt;code&gt;[D,M]&lt;/code&gt;，实际形状是 &lt;code&gt;[M,D]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;用绝对载荷大小解释变量，却忽略标准化方式和主轴符号不唯一；&lt;/li&gt;
&lt;li&gt;把二维投影视图中的重叠直接当成原空间不可分；&lt;/li&gt;
&lt;li&gt;认为 95% 方差就等于保留 95% 任务信息；&lt;/li&gt;
&lt;li&gt;对稀疏词袋先 &lt;code&gt;toarray()&lt;/code&gt;，导致内存耗尽。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 PCA 会在哪些场景失败？&lt;/h2&gt;
&lt;p&gt;PCA 是全局线性方法，主要失败边界是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据位于弯曲流形，例如环形或瑞士卷；&lt;/li&gt;
&lt;li&gt;少数异常值制造巨大方差，主轴被异常方向吸引；&lt;/li&gt;
&lt;li&gt;高方差是无关噪声，低方差才是目标信号；&lt;/li&gt;
&lt;li&gt;主成分是许多特征的稠密组合，难以解释和部署；&lt;/li&gt;
&lt;li&gt;不同群体拥有不同局部方向，一个全局子空间会混合它们；&lt;/li&gt;
&lt;li&gt;新数据分布漂移，旧均值和主轴不再代表当前结构。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法         | 是否用标签     | 核心目标                   | 主要区别                         |
| ------------ | -------------- | -------------------------- | -------------------------------- |
| PCA          | 否             | 最大方差、最小线性重构误差 | 全局线性、正交稠密主轴           |
| LDA 降维     | 是             | 增大类间散布、减小类内散布 | 监督投影，最多 $K-1$ 个判别方向  |
| 特征选择     | 可选           | 保留部分原始列             | 不组合特征，解释更直接           |
| TruncatedSVD | 否             | 低秩近似，通常不中心化     | 适合大型稀疏矩阵                 |
| Kernel PCA   | 否             | 在核特征空间做 PCA         | 可表达非线性，但扩展与逆变换更难 |
| 自编码器     | 否或带辅助目标 | 神经网络重构               | 可非线性，训练与调参成本更高     |&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;PCA 先用训练均值中心化数据，再寻找方差最大的正交方向。保留前 $M$ 个方向既最大化投影方差，也最小化正交线性重构误差；SVD 可以不显式构造协方差地得到主轴。它压缩的是 PCA 所定义的“方差信息”，不是自动保留语义、因果或下游预测信号。&lt;/p&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对样本 $(0,0),(2,0),(0,2),(2,2)$ 手算均值与协方差。两个特征值有什么关系？第一主轴是否唯一？&lt;/li&gt;
&lt;li&gt;将某二维数据的第二列乘以 100，比较不标准化 PCA 与 &lt;code&gt;StandardScaler + PCA&lt;/code&gt; 的第一主轴；解释两者分别回答什么问题。&lt;/li&gt;
&lt;li&gt;在一个监督任务上，把 &lt;code&gt;PCA&lt;/code&gt; 放在交叉验证外和 Pipeline 内各评估一次。若分数不同，定位泄漏发生在哪个统计量，并画 $M$—性能—重构误差三条曲线。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1037/h0071325&quot;&gt;Hotelling (1933), Analysis of a Complex of Statistical Variables into Principal Components&lt;/a&gt;：主成分分析的经典统计学表述。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF02288367&quot;&gt;Eckart &amp;#x26; Young (1936), The Approximation of One Matrix by Another of Lower Rank&lt;/a&gt;：截断 SVD 最优低秩近似的理论基础。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jstor.org/stable/2680726&quot;&gt;Tipping &amp;#x26; Bishop (1999), Probabilistic Principal Component Analysis&lt;/a&gt;：把 PCA 写成带各向同性噪声的概率潜变量模型。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1137/090771806&quot;&gt;Halko, Martinsson &amp;#x26; Tropp (2011), Finding Structure with Randomness&lt;/a&gt;：大规模随机矩阵分解与随机 SVD 的系统方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;PCA 用连续坐标表示样本，K 均值则把每个样本硬分给一个中心。若两个簇明显重叠，我们还希望表达“这个点属于两边的概率各是多少”。下一篇将进入高斯混合模型，从混合分布、软责任度与期望最大化（Expectation-Maximization，EM）的 E 步—M 步循环推导概率聚类。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>没有标签怎样自动分组？K 均值的分配—更新循环与失败几何</title><link>https://zwjcode.cn/blog/kmeans-assignment-update-failure-geometry</link><guid isPermaLink="true">https://zwjcode.cn/blog/kmeans-assignment-update-failure-geometry</guid><description>从无标签用户分群出发，推导 K 均值的簇内平方和，手算分配与中心更新，并解释初始化、尺度、选 K、调试方法和失败几何。</description><pubDate>Wed, 26 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的线性判别分析（Linear Discriminant Analysis，LDA）知道每个训练样本属于哪一类，因此能估计各类均值、先验和共享协方差。但用户画像、设备状态或新药分子常常只有特征，没有现成标签：我们甚至不知道应当寻找哪些类别。&lt;/p&gt;
&lt;p&gt;聚类（Clustering）试图从样本之间的相似性发现结构。K 均值（K-Means）是最基本的原型聚类方法：预先指定 $K$ 个中心，让每个样本靠近某个中心，再让中心移动到所负责样本的均值。本文只讲透四件紧密相关的事：&lt;strong&gt;目标函数、分配—更新循环、初始化与尺度，以及它为什么会被错误几何欺骗。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 没有标签时，“分得好”是什么意思？&lt;/h2&gt;
&lt;p&gt;设输入矩阵为：&lt;/p&gt;
&lt;p&gt;$$
X=[x_1^\top;\ldots;x_N^\top]\in\mathbb{R}^{N\times D}
$$&lt;/p&gt;
&lt;p&gt;$N$ 是样本数，$D$ 是特征数。我们希望得到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;簇编号 $z_i\in{0,\ldots,K-1}$，全部编号 $z\in\mathbb{N}^{N}$；&lt;/li&gt;
&lt;li&gt;簇中心 $\mu_k\in\mathbb{R}^{D}$，全部中心 $M\in\mathbb{R}^{K\times D}$。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;K 均值把“好分组”定义为最小化簇内平方和（Within-Cluster Sum of Squares，WCSS）：&lt;/p&gt;
&lt;p&gt;$$
J(z,M)=\sum_{i=1}^{N}\left\lVert x_i-\mu_{z_i}\right\rVert_2^2
$$&lt;/p&gt;
&lt;p&gt;scikit-learn 把这个值称为 &lt;code&gt;inertia_&lt;/code&gt;。它只关心样本到最近中心的平方欧氏距离，不知道“客户类型”“疾病亚型”等语义，也没有分类准确率可供优化。&lt;/p&gt;
&lt;h2&gt;02 为什么一次不能同时求出编号和中心？&lt;/h2&gt;
&lt;p&gt;若中心 $M$ 已知，每个样本的最佳编号很直接：&lt;/p&gt;
&lt;p&gt;$$
z_i\leftarrow\arg\min_k\lVert x_i-\mu_k\rVert_2^2
$$&lt;/p&gt;
&lt;p&gt;若编号 $z$ 已知，第 $k$ 个中心的最佳位置是该簇样本均值：&lt;/p&gt;
&lt;p&gt;$$
\mu_k\leftarrow\frac{1}{|C_k|}\sum_{i:z_i=k}x_i
$$&lt;/p&gt;
&lt;p&gt;其中 $C_k={i:z_i=k}$。困难在于两者互相依赖：不知道中心就无法分配，不知道分配又无法算中心。Lloyd 算法采用交替优化（Alternating Optimization）：固定一边优化另一边。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D] + 初始中心 M⁽⁰⁾ [K,D]
              │
              ▼
      两两平方距离 [N,K]
              │ 每行 argmin
              ▼
        簇编号 z [N]
              │ 按编号分组求均值
              ▼
        新中心 M⁽¹⁾ [K,D]
              │
       中心移动是否足够小？
          ├── 否：继续循环
          └── 是：输出 z、M、J
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个分配步不会增大 $J$，每个更新步也不会增大 $J$，所以目标会下降并最终停止。但这只保证到达一个局部最优解，不保证全局最好。&lt;/p&gt;
&lt;h2&gt;03 为什么更新一定是“均值”？&lt;/h2&gt;
&lt;p&gt;先看一个簇，固定它包含的样本集合 $C$，中心为 $\mu$：&lt;/p&gt;
&lt;p&gt;$$
J_C(\mu)=\sum_{i\in C}\lVert x_i-\mu\rVert_2^2
$$&lt;/p&gt;
&lt;p&gt;对向量 $\mu$ 求梯度：&lt;/p&gt;
&lt;p&gt;$$
\nabla_\mu J_C=2\sum_{i\in C}(\mu-x_i)
$$&lt;/p&gt;
&lt;p&gt;令梯度为零：&lt;/p&gt;
&lt;p&gt;$$
|C|\mu=\sum_{i\in C}x_i
\quad\Longrightarrow\quad
\mu=\frac{1}{|C|}\sum_{i\in C}x_i
$$&lt;/p&gt;
&lt;p&gt;所以“均值”并非经验规则，而是平方欧氏距离下的最优代表点。若把损失换成绝对距离，最优代表会转向中位数；算法名称与几何也随之改变。&lt;/p&gt;
&lt;h2&gt;04 用四个二维点手算一轮&lt;/h2&gt;
&lt;p&gt;四个样本为：&lt;/p&gt;
&lt;p&gt;$$
x_1=(0,0),;x_2=(0,2),;x_3=(6,0),;x_4=(6,2)
$$&lt;/p&gt;
&lt;p&gt;令 $K=2$，初始中心取 $\mu_0^{(0)}=(0,0)$、$\mu_1^{(0)}=(6,2)$。&lt;/p&gt;
&lt;p&gt;分配步的平方距离矩阵为：&lt;/p&gt;
&lt;p&gt;$$
D^{(0)}=
\begin{bmatrix}
0 &amp;#x26; 40\
4 &amp;#x26; 36\
36 &amp;#x26; 4\
40 &amp;#x26; 0
\end{bmatrix}\in\mathbb{R}^{4\times2}
$$&lt;/p&gt;
&lt;p&gt;每行取最小值位置，得到 $z=[0,0,1,1]$。更新中心：&lt;/p&gt;
&lt;p&gt;$$
\mu_0^{(1)}=\frac{(0,0)+(0,2)}{2}=(0,1)
$$&lt;/p&gt;
&lt;p&gt;$$
\mu_1^{(1)}=\frac{(6,0)+(6,2)}{2}=(6,1)
$$&lt;/p&gt;
&lt;p&gt;再次分配时编号不变，算法收敛。最终目标为：&lt;/p&gt;
&lt;p&gt;$$
J=1+1+1+1=4
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂
2   ● x₂          ● x₄
1   × μ₀          × μ₁
0   ● x₁          ● x₃
    0              6          x₁

    左右两个圆团适合 K 均值；× 是均值中心。
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 用广播写出可检查的 NumPy 核心&lt;/h2&gt;
&lt;p&gt;下面的实现故意不调用 &lt;code&gt;fit&lt;/code&gt;，以暴露每个中间张量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([
    [0.0, 0.0],
    [0.0, 2.0],
    [6.0, 0.0],
    [6.0, 2.0],
], dtype=np.float64)                              # [N=4,D=2]

centers = np.array([[0.0, 0.0], [6.0, 2.0]])     # [K=2,D=2]

for step in range(20):
    delta = X[:, None, :] - centers[None, :, :]   # [N,K,D]
    squared_distances = np.sum(delta**2, axis=2)  # [N,K]
    labels = np.argmin(squared_distances, axis=1) # [N]

    new_centers = np.stack([
        X[labels == k].mean(axis=0)
        for k in range(centers.shape[0])
    ])                                            # [K,D]

    movement = np.linalg.norm(new_centers - centers)
    centers = new_centers
    if movement &amp;#x3C; 1e-8:
        break

inertia = np.sum((X - centers[labels]) ** 2)

assert labels.shape == (4,)
assert centers.shape == (2, 2)
assert np.array_equal(labels, [0, 0, 1, 1])
assert np.allclose(centers, [[0.0, 1.0], [6.0, 1.0]])
assert np.isclose(inertia, 4.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实实现还必须处理空簇、样本权重、稀疏矩阵、停止容差和高效距离计算。上面的列表推导若某个 &lt;code&gt;labels == k&lt;/code&gt; 没有样本，会产生非数（Not a Number，&lt;code&gt;NaN&lt;/code&gt;）；这是手写实现最先应添加的保护。&lt;/p&gt;
&lt;h2&gt;06 初始化为什么能改变最终答案？&lt;/h2&gt;
&lt;p&gt;K 均值目标非凸。若初始中心挤在同一个真实簇附近，算法可能把另一个大簇与少数离群点错误合并，最后停在较差的局部最优。&lt;/p&gt;
&lt;p&gt;K-Means++ 初始化的核心思路是让后续中心更倾向于从“离已有中心很远”的样本中产生：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;随机选择第一个中心；&lt;/li&gt;
&lt;li&gt;计算每个样本到最近已有中心的平方距离 $d_i^2$；&lt;/li&gt;
&lt;li&gt;按与 $d_i^2$ 成比例的概率选择下一个中心；&lt;/li&gt;
&lt;li&gt;重复到获得 $K$ 个中心。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这不是最终聚类，只是为 Lloyd 循环提供更分散的起点。scikit-learn 1.9 默认 &lt;code&gt;init=&apos;k-means++&apos;&lt;/code&gt;，其实现会对候选做多次试探；&lt;code&gt;n_init=&apos;auto&apos;&lt;/code&gt; 在该初始化下只运行一次。对高维、稀疏或重要任务，显式设 &lt;code&gt;n_init=10&lt;/code&gt; 并比较不同种子通常更稳妥。&lt;/p&gt;
&lt;h2&gt;07 特征尺度如何偷偷改写“相似”？&lt;/h2&gt;
&lt;p&gt;假设年龄范围约 20–60，而年收入以元计，范围 30 000–1 000 000。平方欧氏距离中收入差会压倒年龄差。此时模型不是“发现收入更重要”，而是被单位选择支配。&lt;/p&gt;
&lt;p&gt;若所有连续特征都应等权，可在训练数据上标准化：&lt;/p&gt;
&lt;p&gt;$$
x&apos;&lt;em&gt;{ij}=\frac{x&lt;/em&gt;{ij}-\hat\mu_j}{\hat\sigma_j}
$$&lt;/p&gt;
&lt;p&gt;但标准化也不是无条件正确：经纬度、周期角度、计数、类别变量和有明确业务权重的特征需要合适的距离或编码。异常值还会同时拉动标准差与簇中心；必要时比较稳健缩放、截尾或专门的异常检测。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;按照当前官方 &lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html&quot;&gt;&lt;code&gt;KMeans&lt;/code&gt; 应用程序接口（Application Programming Interface，API）&lt;/a&gt;，把会学习数据统计量的预处理和聚类放进同一条流水线（Pipeline）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.cluster import KMeans
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pipeline = make_pipeline(
    StandardScaler(),
    KMeans(
        n_clusters=3,
        init=&apos;k-means++&apos;,
        n_init=10,
        max_iter=300,
        tol=1e-4,
        algorithm=&apos;lloyd&apos;,
        random_state=42,
    ),
)

labels = pipeline.fit_predict(X_train)        # [N]
new_labels = pipeline.predict(X_new)          # [Q]
distances = pipeline.transform(X_new)         # [Q,K]

model = pipeline.named_steps[&apos;kmeans&apos;]
print(model.cluster_centers_.shape)            # [K,D]，位于标准化空间
print(model.labels_.shape)                     # [N]
print(model.inertia_)                          # 标量：训练集 WCSS
print(model.n_iter_)                           # 实际迭代轮数

assert distances.shape == (X_new.shape[0], 3)
assert np.all(np.isfinite(model.cluster_centers_))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个 API 细节值得明确：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fit_predict(X)&lt;/code&gt; 等价于拟合后返回训练样本编号；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;predict(X_new)&lt;/code&gt; 只把新样本分配给最近的既有中心，不会更新中心；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;transform(X_new)&lt;/code&gt; 输出到每个中心的欧氏距离，而不是平方距离；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;score(X)&lt;/code&gt; 返回 K 均值目标的相反数，因此越大越好但通常为负；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;algorithm=&apos;elkan&apos;&lt;/code&gt; 可用三角不等式减少部分距离计算，但额外需要 &lt;code&gt;[N,K]&lt;/code&gt; 内存；是否更快取决于簇是否分离良好和实际数据表示。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若样本量很大，可评估 &lt;code&gt;MiniBatchKMeans&lt;/code&gt;。它用小批量近似更新中心，速度更快，但最终 &lt;code&gt;inertia_&lt;/code&gt; 和稳定性可能略差；不能只因名称相近就假设与全量 K 均值结果完全一致。&lt;/p&gt;
&lt;h2&gt;09 K 应该怎样选？&lt;/h2&gt;
&lt;p&gt;训练目标会随 $K$ 增大而单调下降：当 $K=N$ 时每个点自成一簇，$J=0$，却通常毫无概括价值。因此不能用最小训练 &lt;code&gt;inertia_&lt;/code&gt; 直接选 K。&lt;/p&gt;
&lt;p&gt;可联合使用三类证据：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;肘部图（Elbow Plot）&lt;/strong&gt;：画 $K$ 与 WCSS，寻找继续增加中心后收益明显变缓的位置；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;轮廓系数（Silhouette Coefficient）&lt;/strong&gt;：比较样本与本簇的紧密度和最近其他簇的分离度，范围约为 $[-1,1]$；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稳定性与可用性&lt;/strong&gt;：在重采样、时间切片和不同种子下重复聚类，检查簇大小、中心与业务解释能否稳定复现。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.metrics import silhouette_score

records = []
for k in range(2, 9):
    candidate = make_pipeline(
        StandardScaler(),
        KMeans(n_clusters=k, n_init=10, random_state=42),
    )
    labels = candidate.fit_predict(X_train)
    scaled = candidate[:-1].transform(X_train)
    records.append({
        &apos;k&apos;: k,
        &apos;inertia&apos;: candidate[-1].inertia_,
        &apos;silhouette&apos;: silhouette_score(scaled, labels),
        &apos;smallest_cluster&apos;: np.bincount(labels).min(),
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;轮廓系数也偏好分离良好的凸簇；它不是领域真相。若业务必须得到 5 个可执行人群，而统计曲线在 3–6 都接近，应把约束、稳定性和后续效用一起写进决策。&lt;/p&gt;
&lt;h2&gt;10 怎样调试一个“能运行但分错了”的聚类？&lt;/h2&gt;
&lt;p&gt;按数据流逐层检查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;输入&lt;/strong&gt;：确认无非数（Not a Number，&lt;code&gt;NaN&lt;/code&gt;）或无穷值（Infinity，&lt;code&gt;Inf&lt;/code&gt;），重复行、单位和类别编码符合预期；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尺度&lt;/strong&gt;：打印缩放后每列均值与标准差，定位支配距离的特征；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;优化&lt;/strong&gt;：记录多种 &lt;code&gt;random_state&lt;/code&gt; 的 &lt;code&gt;inertia_&lt;/code&gt;、&lt;code&gt;n_iter_&lt;/code&gt; 与簇大小；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;几何&lt;/strong&gt;：在原特征和二维投影中画样本、中心和边界，但不要把二维图等同于全部高维结构；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稳定性&lt;/strong&gt;：对重采样数据重新拟合，用调整兰德指数等置换不变指标比较分区；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;外部效用&lt;/strong&gt;：只在分群确定后，用未参与聚类的结果变量检查群体是否产生可复现差异。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小检查代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;counts = np.bincount(labels, minlength=model.n_clusters)
assert counts.sum() == X_train.shape[0]
assert np.all(counts &gt; 0)
assert model.n_iter_ &amp;#x3C;= model.max_iter
print(&apos;cluster sizes:&apos;, counts)
print(&apos;inertia per sample:&apos;, model.inertia_ / X_train.shape[0])
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;11 K 均值会在哪些几何中失败？&lt;/h2&gt;
&lt;p&gt;K 均值隐含偏好大小相近、密度相近、近似球形且可由维诺（Voronoi）边界分开的簇。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;适合：两个紧凑圆团        失败：两个月牙          失败：密度悬殊

  ●●      ○○             ●●●○○○               ●●●●●      ○  ○
 ●●●    ○○○           ●●       ○○              ●●●       ○
  ●●      ○○          ●           ○              ●●

最近中心边界合理         直线切碎弯曲流形         大簇被拆、小簇被吞
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;典型失败场景包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同心圆、月牙和细长流形；&lt;/li&gt;
&lt;li&gt;不同簇方差或样本量相差悬殊；&lt;/li&gt;
&lt;li&gt;离群点把均值中心拉远；&lt;/li&gt;
&lt;li&gt;高维空间距离集中，最近与最远差别变小；&lt;/li&gt;
&lt;li&gt;簇重叠而任务需要概率或不确定性；&lt;/li&gt;
&lt;li&gt;纯类别数据，均值本身没有意义；&lt;/li&gt;
&lt;li&gt;数据持续漂移，但部署端仍使用旧中心。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;基于密度的含噪空间聚类（Density-Based Spatial Clustering of Applications with Noise，DBSCAN）通过密度连通形成簇。&lt;/p&gt;
&lt;p&gt;| 方法         | 分配方式           | 主要几何/假设            | 更适合什么             |
| ------------ | ------------------ | ------------------------ | ---------------------- |
| K 均值       | 到最近均值的硬分配 | 近似球形、平方欧氏距离   | 快速基线、向量量化     |
| 高斯混合模型 | 概率软分配         | 椭圆高斯、可估计协方差   | 重叠簇与不确定性       |
| DBSCAN       | 核心点密度连通     | 任意形状、密度阈值       | 噪声点与非凸簇         |
| 层次聚类     | 逐步合并或拆分     | 由距离与 linkage 决定    | 需要树状层级、小中数据 |
| K 中心点     | 最近真实样本       | 可配更一般距离、较抗异常 | 中心必须可解释为样本   |&lt;/p&gt;
&lt;p&gt;表中这些方法回答的不是同一道题；换算法前先说明你希望保持的结构：中心、密度、连通性、概率，还是层次。&lt;/p&gt;
&lt;h2&gt;13 今天真正需要记住什么？&lt;/h2&gt;
&lt;p&gt;K 均值通过两步循环降低簇内平方和：给定中心时分配到最近中心，给定分配时把中心更新为均值。它快速、可扩展、容易解释，但结果依赖 $K$、初始化、特征尺度和近似球形几何。一个低 &lt;code&gt;inertia_&lt;/code&gt; 只说明模型优化了自己的目标，不代表发现了真实类别。&lt;/p&gt;
&lt;h2&gt;14 思考题与小练习&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;对点 $0,2,3,10$ 做一维 $K=2$ 聚类，初始中心为 $0$ 和 $2$。手算每轮编号、中心和 WCSS，观察是否得到直觉中的分组。&lt;/li&gt;
&lt;li&gt;若把四点例子中的第二维整体乘以 100，分配会不会改变？构造一个确实改变的六点数据集，并解释单位为何等价于特征权重。&lt;/li&gt;
&lt;li&gt;在同一数据上用 20 个种子运行 &lt;code&gt;KMeans(n_init=1)&lt;/code&gt;，记录最优与最差 &lt;code&gt;inertia_&lt;/code&gt;、簇大小和轮廓系数；再与 &lt;code&gt;n_init=20&lt;/code&gt; 比较。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/TIT.1982.1056489&quot;&gt;Lloyd (1982), Least Squares Quantization in PCM&lt;/a&gt;：现代 K 均值分配—更新算法的经典表述。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://projecteuclid.org/euclid.bsmsp/1200512992&quot;&gt;MacQueen (1967), Some Methods for Classification and Analysis of Multivariate Observations&lt;/a&gt;：提出 “k-means” 名称并讨论在线式更新。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://theory.stanford.edu/~sergei/papers/kMeansPP-soda.pdf&quot;&gt;Arthur &amp;#x26; Vassilvitskii (2007), k-means++: The Advantages of Careful Seeding&lt;/a&gt;：用距离加权初始化改善期望质量。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1016/0377-0427%2887%2990125-7&quot;&gt;Rousseeuw (1987), Silhouettes: A Graphical Aid to the Interpretation and Validation of Cluster Analysis&lt;/a&gt;：轮廓系数的原始工作。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;15 下一篇预告&lt;/h2&gt;
&lt;p&gt;K 均值直接在原始特征空间计算距离；当几十个传感器高度相关或图像像素维度巨大时，冗余方向会增加计算并掩盖结构。下一篇将进入主成分分析：怎样在尽量保留方差的前提下，把高维样本投影到少数正交方向，并从重构误差看清“保留信息”到底是什么意思。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>两团高斯数据为何产生直线边界？从共享协方差到 LDA</title><link>https://zwjcode.cn/blog/lda-shared-covariance-linear-boundary</link><guid isPermaLink="true">https://zwjcode.cn/blog/lda-shared-covariance-linear-boundary</guid><description>从朴素贝叶斯忽略特征相关性出发，推导类高斯分布、马氏距离与共享协方差消去二次项，并实现可诊断的线性判别分析。</description><pubDate>Tue, 25 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的多项式朴素贝叶斯用条件独立假设，把高维词频联合分布拆成许多一维计数。这让模型在小数据、稀疏文本中极其高效，却会把“优惠”和“领取”这类相关特征重复当成独立证据。&lt;/p&gt;
&lt;p&gt;在体检或传感器数据中，相关性更难忽略：身高与体重通常一起变化，温度与压力可能沿某个斜方向波动。若模型只看每一维自己的均值和方差，就会把数据云的方向丢掉。&lt;/p&gt;
&lt;p&gt;线性判别分析（Linear Discriminant Analysis，LDA）保留生成式分类框架，用多元高斯分布显式描述特征共同变化。本文只回答三个紧密问题：&lt;strong&gt;每一类的高斯数据云怎样写成概率、共享协方差为何让二次项消失，以及马氏距离怎样变成可计算的线性分类分数。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 独立方差为什么看不见“斜着的数据云”？&lt;/h2&gt;
&lt;p&gt;设两个类别的二维测量数据如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂
▲                         类别 1
│                    ●  ●
│                 ●  ●
│              ●
│        ○  ○                  两类数据都沿右上方向延展
│     ○  ○
│  ○                         类别 0
└──────────────────────────────────► x₁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每一维的方差只能回答沿坐标轴有多分散；协方差（Covariance）还回答两个特征是否一起增减。二维协方差矩阵为：&lt;/p&gt;
&lt;p&gt;$$
\Sigma=
\begin{bmatrix}
\operatorname{Var}(x_1) &amp;#x26; \operatorname{Cov}(x_1,x_2)\
\operatorname{Cov}(x_2,x_1) &amp;#x26; \operatorname{Var}(x_2)
\end{bmatrix}
$$&lt;/p&gt;
&lt;p&gt;对 $D$ 维输入，$\Sigma\in\mathbb{R}^{D\times D}$。对角线保存各特征方差，非对角线保存成对协方差。高斯朴素贝叶斯相当于只保留对角线；LDA 则保留完整矩阵，但要求各类别共享它。&lt;/p&gt;
&lt;h2&gt;02 LDA 对数据分布作了什么假设？&lt;/h2&gt;
&lt;p&gt;训练集为：&lt;/p&gt;
&lt;p&gt;$$
X\in\mathbb{R}^{N\times D},\qquad
y\in{0,1,\ldots,K-1}^{N}
$$&lt;/p&gt;
&lt;p&gt;LDA 假设类别 $k$ 的特征服从多元高斯分布（Multivariate Gaussian Distribution）：&lt;/p&gt;
&lt;p&gt;$$
x\mid y=k\sim\mathcal{N}(\mu_k,\Sigma)
$$&lt;/p&gt;
&lt;p&gt;密度为：&lt;/p&gt;
&lt;p&gt;$$
p(x\mid y=k)=
\frac{1}{(2\pi)^{D/2}|\Sigma|^{1/2}}
\exp\left[-\frac12(x-\mu_k)^\top\Sigma^{-1}(x-\mu_k)\right]
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\mu_k\in\mathbb{R}^{D}$：类别 $k$ 的均值向量；&lt;/li&gt;
&lt;li&gt;$\Sigma\in\mathbb{R}^{D\times D}$：所有类别共享的类内协方差矩阵；&lt;/li&gt;
&lt;li&gt;$|\Sigma|$：协方差行列式，反映数据云总体体积；&lt;/li&gt;
&lt;li&gt;$\Sigma^{-1}$：精度矩阵（Precision Matrix）；&lt;/li&gt;
&lt;li&gt;$\pi_k=P(y=k)$：类别先验，全部先验组成 $\pi\in\mathbb{R}^{K}$。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;模型训练后保存的主要状态是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;class means       mu       [K,D]
shared covariance Sigma    [D,D]
class priors      pi       [K]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;与朴素贝叶斯一样，LDA 先建模 $p(x\mid y=k)$ 与 $P(y=k)$，再用贝叶斯定理计算 $P(y=k\mid x)$；它不是把标签作为连续值做最小二乘回归。&lt;/p&gt;
&lt;h2&gt;03 马氏距离为什么比欧氏距离多看了一层？&lt;/h2&gt;
&lt;p&gt;高斯指数中的二次型：&lt;/p&gt;
&lt;p&gt;$$
d_k^2(x)=(x-\mu_k)^\top\Sigma^{-1}(x-\mu_k)
$$&lt;/p&gt;
&lt;p&gt;叫作平方马氏距离（Squared Mahalanobis Distance）。欧氏距离把每个方向视为同等尺度；马氏距离先按协方差“白化”数据：高方差方向上的同样位移惩罚较小，低方差方向上的位移惩罚较大，并考虑特征相关导致的椭圆方向。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原空间：共享斜椭圆                 白化空间：协方差变成单位阵

      ╱╱ ● 类1                         ○      ● 类1
   ○ ╱╱                              类0
 类0                                      距离可直接用圆形欧氏距离

按 Sigma^{-1} 拉伸、旋转 ───────────────────────────────►
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 LDA 可以直观理解为：在共享协方差定义的几何中，选择马氏距离更近的类中心，同时用类别先验修正答案。&lt;/p&gt;
&lt;h2&gt;04 从高斯后验推导线性分数&lt;/h2&gt;
&lt;p&gt;贝叶斯分类选择最大后验类别：&lt;/p&gt;
&lt;p&gt;$$
\hat y=\arg\max_k p(x\mid y=k)\pi_k
$$&lt;/p&gt;
&lt;p&gt;取对数并去掉对所有类别相同的常数：&lt;/p&gt;
&lt;p&gt;$$
\delta_k(x)=
-\frac12(x-\mu_k)^\top\Sigma^{-1}(x-\mu_k)+\log\pi_k
$$&lt;/p&gt;
&lt;p&gt;展开二次项：&lt;/p&gt;
&lt;p&gt;$$
(x-\mu_k)^\top\Sigma^{-1}(x-\mu_k)
=x^\top\Sigma^{-1}x
-2x^\top\Sigma^{-1}\mu_k
+\mu_k^\top\Sigma^{-1}\mu_k
$$&lt;/p&gt;
&lt;p&gt;关键在第一项 $x^\top\Sigma^{-1}x$：因为所有类别共享同一个 $\Sigma$，它对每个 $k$ 完全相同，比较类别时消去。剩下：&lt;/p&gt;
&lt;p&gt;$$
\delta_k(x)=
x^\top\Sigma^{-1}\mu_k
-\frac12\mu_k^\top\Sigma^{-1}\mu_k
+\log\pi_k
$$&lt;/p&gt;
&lt;p&gt;定义：&lt;/p&gt;
&lt;p&gt;$$
w_k=\Sigma^{-1}\mu_k\in\mathbb{R}^{D}
$$&lt;/p&gt;
&lt;p&gt;$$
b_k=-\frac12\mu_k^\top\Sigma^{-1}\mu_k+\log\pi_k\in\mathbb{R}
$$&lt;/p&gt;
&lt;p&gt;便得到熟悉的线性分数：&lt;/p&gt;
&lt;p&gt;$$
\delta_k(x)=w_k^\top x+b_k
$$&lt;/p&gt;
&lt;p&gt;任意两个类别 $a,b$ 的边界满足 $\delta_a(x)=\delta_b(x)$，也就是：&lt;/p&gt;
&lt;p&gt;$$
(w_a-w_b)^\top x+(b_a-b_b)=0
$$&lt;/p&gt;
&lt;p&gt;这是一条超平面。LDA 的“线性”不是先拍脑袋规定边界必须直，而是共享协方差使所有类别共同的输入二次项在后验比较中抵消。&lt;/p&gt;
&lt;h2&gt;05 用两个二维高斯手算边界&lt;/h2&gt;
&lt;p&gt;设两个类别参数已经由训练数据估计为：&lt;/p&gt;
&lt;p&gt;$$
\mu_0=\begin{bmatrix}0\0\end{bmatrix},\qquad
\mu_1=\begin{bmatrix}2\1\end{bmatrix},\qquad
\Sigma=I_2,qquad
\pi_0=\pi_1=\frac12
$$&lt;/p&gt;
&lt;p&gt;因为 $\Sigma^{-1}=I_2$：&lt;/p&gt;
&lt;p&gt;$$
\delta_0(x)=\log\frac12
$$&lt;/p&gt;
&lt;p&gt;$$
\delta_1(x)=2x_1+x_2-\frac12(2^2+1^2)+\log\frac12
=2x_1+x_2-2.5+\log\frac12
$$&lt;/p&gt;
&lt;p&gt;令两分数相等，公共先验抵消，决策边界为：&lt;/p&gt;
&lt;p&gt;$$
2x_1+x_2=2.5
$$&lt;/p&gt;
&lt;p&gt;对查询点 $x=[1,1]^\top$，两类分数差为：&lt;/p&gt;
&lt;p&gt;$$
\delta_1(x)-\delta_0(x)=2\times1+1-2.5=0.5
$$&lt;/p&gt;
&lt;p&gt;因此预测类别 1。二分类时，将两个联合分数归一化等价于对差值做 Sigmoid：&lt;/p&gt;
&lt;p&gt;$$
P(y=1\mid x)=\sigma(0.5)\approx0.622
$$&lt;/p&gt;
&lt;p&gt;先验差变为 $\log0.2-\log0.8=\log0.25\approx-1.386$。查询点原来的分数差 0.5 变为 $-0.886$，预测翻转为类别 0。先验改变只平移边界，不改变法向量 $[2,1]$。&lt;/p&gt;
&lt;h2&gt;06 均值、先验和共享协方差怎样从数据得到？&lt;/h2&gt;
&lt;p&gt;类别 $k$ 有 $N_k$ 个样本，样本集合记为 $C_k$。均值与先验的自然估计是：&lt;/p&gt;
&lt;p&gt;$$
\hat\mu_k=\frac{1}{N_k}\sum_{i:y_i=k}x_i,qquad
\hat\pi_k=\frac{N_k}{N}
$$&lt;/p&gt;
&lt;p&gt;每类先计算围绕本类均值的残差，再把全部类内散布汇总为共享协方差：&lt;/p&gt;
&lt;p&gt;$$
\hat\Sigma=
\frac{1}{N}\sum_{k=1}^{K}\sum_{i:y_i=k}
(x_i-\hat\mu_k)(x_i-\hat\mu_k)^\top
$$&lt;/p&gt;
&lt;p&gt;这里写的是有偏最大似然形式，分母为 $N$；不同统计语境可能使用自由度修正。scikit-learn 的 &lt;code&gt;covariance_&lt;/code&gt; 是按类别先验加权的类内协方差，并使用有偏估计。不要把它与围绕全局均值计算的总协方差混淆，否则类间均值差会被错误计入类内噪声。&lt;/p&gt;
&lt;p&gt;训练与推理的数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练 X [N,D] + y [N]
    │ 按类别分组
    ├──► means [K,D]
    ├──► priors [K]
    └──► 类内残差 ─► shared covariance [D,D]
                              │ 线性求解 Sigma w_k = mu_k
                              ▼
                       weights [K,D] + bias [K]

推理 Xq [Q,D]
    │ Xq @ weights.T + bias
    ▼
scores [Q,K] ─► softmax ─► probability [Q,K]
       │ argmax
       ▼
prediction [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;07 不显式求逆：用 NumPy 写出 LDA 核心&lt;/h2&gt;
&lt;p&gt;公式里出现 $\Sigma^{-1}$，实现时不应先计算完整逆矩阵。求解线性方程 $\Sigma w_k=\mu_k$ 通常更稳定：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([
    [0.0, 0.0],
    [0.2, -0.1],
    [-0.2, 0.1],
    [2.0, 1.0],
    [2.2, 0.9],
    [1.8, 1.1],
], dtype=np.float64)                         # [N=6, D=2]
y = np.array([0, 0, 0, 1, 1, 1])            # [N]
classes = np.unique(y)                       # [K=2]

means = np.stack([X[y == c].mean(axis=0) for c in classes])  # [K,D]
priors = np.array([(y == c).mean() for c in classes])        # [K]

residual_blocks = [X[y == c] - means[i] for i, c in enumerate(classes)]
residuals = np.concatenate(residual_blocks, axis=0)           # [N,D]
covariance = residuals.T @ residuals / X.shape[0]             # [D,D]

# 极小样本使协方差接近奇异；仅为示例加入很小的岭项
ridge = 1e-6
regularized_covariance = covariance + ridge * np.eye(X.shape[1])

weights = np.linalg.solve(regularized_covariance, means.T).T  # [K,D]
bias = -0.5 * np.sum(means * weights, axis=1) + np.log(priors) # [K]

X_query = np.array([[1.0, 1.0]])              # [Q=1,D=2]
scores = X_query @ weights.T + bias            # [Q,K]
prediction = classes[np.argmax(scores, axis=1)]

shifted = scores - scores.max(axis=1, keepdims=True)
probability = np.exp(shifted)
probability /= probability.sum(axis=1, keepdims=True)         # [Q,K]

assert means.shape == weights.shape == (2, 2)
assert covariance.shape == (2, 2)
assert np.allclose(covariance, covariance.T)
assert np.all(np.linalg.eigvalsh(regularized_covariance) &gt; 0)
assert np.allclose(probability.sum(axis=1), 1.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码的 &lt;code&gt;ridge&lt;/code&gt; 只用于展示数值保护，不是完整的 shrinkage 选择策略。生产中应在训练数据内部用交叉验证评估正则化，而不是遇到报错就不断增大常数。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确选择求解器&lt;/h2&gt;
&lt;p&gt;当前 &lt;code&gt;LinearDiscriminantAnalysis&lt;/code&gt; 提供 &lt;code&gt;svd&lt;/code&gt;、&lt;code&gt;lsqr&lt;/code&gt; 和 &lt;code&gt;eigen&lt;/code&gt; 三种求解器。先用默认 &lt;code&gt;svd&lt;/code&gt; 建立基线：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pipeline = make_pipeline(
    StandardScaler(),
    LinearDiscriminantAnalysis(
        solver=&apos;svd&apos;,
        store_covariance=True,
        tol=1e-4,
    ),
)

pipeline.fit(X_train, y_train)
scores = pipeline.decision_function(X_val)   # 二分类 [Q]，多分类 [Q,K]
probability = pipeline.predict_proba(X_val)  # [Q,K]
prediction = pipeline.predict(X_val)         # [Q]

model = pipeline.named_steps[&apos;lineardiscriminantanalysis&apos;]
print(&apos;classes:&apos;, model.classes_)
print(&apos;means:&apos;, model.means_.shape)           # [K,D]
print(&apos;priors:&apos;, model.priors_.shape)         # [K]
print(&apos;covariance:&apos;, model.covariance_.shape) # [D,D]
print(&apos;coef:&apos;, model.coef_.shape)

positive_column = np.flatnonzero(model.classes_ == 1).item()
positive_probability = probability[:, positive_column]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三种求解器不是单纯的速度开关：&lt;/p&gt;
&lt;p&gt;| 求解器  | 是否显式依赖协方差 | shrinkage | &lt;code&gt;transform&lt;/code&gt; | 适用提醒                          |
| ------- | ------------------ | --------- | ----------- | --------------------------------- |
| &lt;code&gt;svd&lt;/code&gt;   | 默认不显式计算     | 不支持    | 支持        | 默认选择；高维时避免显式协方差    |
| &lt;code&gt;lsqr&lt;/code&gt;  | 计算并求解线性系统 | 支持      | 不支持      | 只做分类，可配 &lt;code&gt;shrinkage=&apos;auto&apos;&lt;/code&gt; |
| &lt;code&gt;eigen&lt;/code&gt; | 显式计算           | 支持      | 支持        | 可分类和投影，但高维协方差成本高  |&lt;/p&gt;
&lt;p&gt;当特征数接近或超过样本数，经验协方差会不稳定。可比较：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;regularized_pipeline = make_pipeline(
    StandardScaler(),
    LinearDiscriminantAnalysis(
        solver=&apos;lsqr&apos;,
        shrinkage=&apos;auto&apos;,
    ),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;shrinkage&lt;/code&gt; 只支持 &lt;code&gt;lsqr&lt;/code&gt; 与 &lt;code&gt;eigen&lt;/code&gt;；若传 &lt;code&gt;covariance_estimator&lt;/code&gt;，必须让 &lt;code&gt;shrinkage=None&lt;/code&gt;，两者不能同时控制协方差。&lt;code&gt;n_components&lt;/code&gt; 只影响 &lt;code&gt;transform&lt;/code&gt; 的监督降维输出，不会改变 &lt;code&gt;fit&lt;/code&gt; 和 &lt;code&gt;predict&lt;/code&gt; 的分类结果。&lt;/p&gt;
&lt;h2&gt;09 怎样诊断协方差而不是只看准确率？&lt;/h2&gt;
&lt;p&gt;先用分层交叉验证评价模型，再检查训练状态：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
result = cross_validate(
    regularized_pipeline,
    X,
    y,
    cv=cv,
    scoring=[&apos;balanced_accuracy&apos;, &apos;neg_log_loss&apos;],
    return_train_score=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;遇到不稳定或概率极端时，按下面顺序排查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;检查每类样本数。&lt;/strong&gt; 某类样本少于特征维数时，均值与协方差方向都很不可靠；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查秩与条件数。&lt;/strong&gt; 重复列、线性组合列和常数列会使协方差奇异或病态；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;画每类均值与协方差椭圆。&lt;/strong&gt; 若各类椭圆方向、长短明显不同，共享协方差假设可能不成立；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比较训练与验证概率。&lt;/strong&gt; 极端分数但验证对数损失很差，常提示协方差估计或分布假设有问题；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;核对 &lt;code&gt;classes_&lt;/code&gt;。&lt;/strong&gt; &lt;code&gt;predict_proba&lt;/code&gt; 列顺序按类别排序，二分类 &lt;code&gt;decision_function&lt;/code&gt; 是 &lt;code&gt;classes_[1]&lt;/code&gt; 对 &lt;code&gt;classes_[0]&lt;/code&gt; 的对数后验差。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小数值检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert np.isfinite(model.means_).all()
assert np.isfinite(model.priors_).all()
assert np.isclose(model.priors_.sum(), 1.0)
assert np.allclose(model.covariance_, model.covariance_.T)
print(&apos;covariance eigenvalues:&apos;, np.linalg.eigvalsh(model.covariance_))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;10 常见错误与失败场景&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把总协方差当成类内协方差。&lt;/strong&gt; 必须先减各自类均值，不能只减全局均值；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;显式调用 &lt;code&gt;np.linalg.inv&lt;/code&gt;。&lt;/strong&gt; 直接解线性方程或使用稳定求解器，避免放大数值误差；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在全数据上标准化后交叉验证。&lt;/strong&gt; 预处理必须置于 Pipeline，防止验证折泄漏；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高维小样本仍使用未经收缩的经验协方差。&lt;/strong&gt; 比较 &lt;code&gt;svd&lt;/code&gt; 与带 shrinkage 的 &lt;code&gt;lsqr/eigen&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;盲信高斯后验概率。&lt;/strong&gt; 异常值、重尾、类别混合和分布漂移都会让概率失真；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 LDA 投影当成无监督降维。&lt;/strong&gt; 它使用标签寻找判别方向，不能在划分前对全数据拟合；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把系数解释为因果作用。&lt;/strong&gt; $w_k=\Sigma^{-1}\mu_k$ 同时受相关特征与量纲影响，只表达模型中的分类关联。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;共享协方差假设尤其容易在以下情况失败：不同类别数据云形状明显不同；每类本身由多个簇组成；边界是同心圆或 XOR；存在强异常值；部署数据的均值、协方差或类别先验发生变化。&lt;/p&gt;
&lt;h2&gt;11 LDA、QDA、高斯 NB 和逻辑回归的边界&lt;/h2&gt;
&lt;p&gt;| 方法     | 类条件分布          | 协方差假设         | 边界             | 参数与数据需求     |
| -------- | ------------------- | ------------------ | ---------------- | ------------------ |
| 高斯 NB  | 每类高斯            | 每类对角协方差     | 通常二次、轴对齐 | 参数少，忽略相关性 |
| LDA      | 每类高斯            | 各类共享完整协方差 | 线性             | 共享信息，较省样本 |
| QDA      | 每类高斯            | 每类独立完整协方差 | 二次             | 更灵活，也更需样本 |
| 逻辑回归 | 不建模 $p(x\mid y)$ | 无高斯要求         | 线性             | 直接优化条件似然   |&lt;/p&gt;
&lt;p&gt;LDA 与逻辑回归都能产生线性边界，但来源不同。LDA 假设每类特征高斯且共享协方差，在假设接近真实且样本不多时可高效利用结构；逻辑回归只规定后验对数几率线性，分布假设更弱，数据足够时通常更稳健。&lt;/p&gt;
&lt;p&gt;LDA 还有监督降维用途：&lt;code&gt;transform&lt;/code&gt; 最多输出 $\min(K-1,D)$ 个方向，因为 $K$ 个类中心在中心化后最多张成 $K-1$ 维。PCA 则完全不看标签，只保留总体方差最大的方向；高方差方向未必最能分开类别。本文的核心仍是分类器，不要把两种用途混成同一件事。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;LDA 为每个类别拟合一个均值，并让所有类别共享完整类内协方差；&lt;/li&gt;
&lt;li&gt;马氏距离用协方差修正尺度和相关性，相当于在白化空间比较类中心；&lt;/li&gt;
&lt;li&gt;共享协方差让后验中的 $x^\top\Sigma^{-1}x$ 对所有类别相同并消去，因此边界是线性的；&lt;/li&gt;
&lt;li&gt;实现时求解 $\Sigma w_k=\mu_k$，不要先显式求逆；高维小样本应评估稳定求解与 shrinkage；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n_components&lt;/code&gt; 只控制监督投影，不改变分类拟合与预测，验证时仍要防止标签泄漏。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;p&gt;在手算例中令 $\pi_0=0.8,\pi_1=0.2$。边界满足 $2x_1+x_2-2.5+\log(0.2/0.8)=0$，即 $2x_1+x_2\approx3.886$。稀有的类别 1 需要更强特征证据才会被预测。&lt;/p&gt;
&lt;p&gt;分别展开 $\delta_1(x)$ 与 $\delta_0(x)$，相减后标出 $-\frac12x^\top\Sigma^{-1}x$。它在两式中完全相同；若改为 $\Sigma_1$ 与 $\Sigma_0$，两项不能抵消，边界便保留二次项。&lt;/p&gt;
&lt;p&gt;在代码中新增第三列 &lt;code&gt;x3=x1+x2&lt;/code&gt;，计算协方差特征值与条件数。比较未经正则化的手写求解、&lt;code&gt;solver=&apos;svd&apos;&lt;/code&gt; 和 &lt;code&gt;solver=&apos;lsqr&apos;, shrinkage=&apos;auto&apos;&lt;/code&gt;，解释完全线性相关为何导致零特征值。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1111/j.1469-1809.1936.tb02137.x&quot;&gt;Fisher: The Use of Multiple Measurements in Taxonomic Problems&lt;/a&gt;：线性判别思想的奠基论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1111/j.1469-1809.1948.tb02362.x&quot;&gt;Rao: The Utilization of Multiple Measurements in Problems of Biological Classification&lt;/a&gt;：多变量分类与判别函数的经典拓展。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.3905/jpm.2004.110&quot;&gt;Ledoit &amp;#x26; Wolf: Honey, I Shrunk the Sample Covariance Matrix&lt;/a&gt;：高维小样本协方差收缩的重要工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1080/01621459.1994.10476866&quot;&gt;Hastie, Tibshirani &amp;#x26; Buja: Flexible Discriminant Analysis by Optimal Scoring&lt;/a&gt;：将判别分析扩展到非线性基函数与更灵活边界。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.LinearDiscriminantAnalysis.html&quot;&gt;scikit-learn: LinearDiscriminantAnalysis&lt;/a&gt;：当前求解器、shrinkage、属性与输入输出语义。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;到这里，经典监督学习已经从线性概率、局部邻域、树集成、最大间隔推进到生成式判别。下一步将移除标签：只有一堆样本而没有类别答案时，怎样自动寻找代表性中心？下一篇进入无监督学习，从 K 均值的“分配—更新”循环与失败几何开始。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>只统计词频，朴素贝叶斯怎样判断垃圾邮件？从先验到对数似然</title><link>https://zwjcode.cn/blog/naive-bayes-text-counts-log-space</link><guid isPermaLink="true">https://zwjcode.cn/blog/naive-bayes-text-counts-log-space</guid><description>从小样本文本分类出发，手算类别先验、词频似然与拉普拉斯平滑，追踪对数空间推理，并实现可检查的多项式朴素贝叶斯。</description><pubDate>Tue, 25 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 RBF 支持向量机（Support Vector Machine，SVM）直接寻找分类边界：哪些样本靠近边界、边界怎样弯曲，是训练的中心。但在垃圾邮件过滤中，我们经常面对数万维词表、许多零计数和很少的标注样本。先学习一条复杂边界未必是最经济的起点。&lt;/p&gt;
&lt;p&gt;另一条路线是先问：垃圾邮件通常会产生哪些词？正常邮件又会产生哪些词？收到新邮件后，再反过来判断哪一类更可能生成它。这就是生成式分类（Generative Classification）的思路。&lt;/p&gt;
&lt;p&gt;本文只讲透三个紧密环节：&lt;strong&gt;类别先验与词频似然怎样组成后验分数、条件独立假设怎样把高维联合概率拆开，以及平滑与对数空间怎样让计算真正可用。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 为什么从“边界”转向“数据怎样生成”？&lt;/h2&gt;
&lt;p&gt;设训练集有 $N$ 封邮件，词表包含 $V$ 个词。把第 $i$ 封邮件写成词频向量：&lt;/p&gt;
&lt;p&gt;$$
x_i=[x_{i1},x_{i2},\ldots,x_{iV}]\in\mathbb{N}_0^V
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x_{ij}$：第 $i$ 封邮件中词 $j$ 出现的次数；&lt;/li&gt;
&lt;li&gt;$X\in\mathbb{N}_0^{N\times V}$：全部训练邮件的稀疏计数矩阵；&lt;/li&gt;
&lt;li&gt;$y\in{0,1,\ldots,K-1}^{N}$：每封邮件的类别；&lt;/li&gt;
&lt;li&gt;$K$：类别数，二分类垃圾邮件任务中通常为 2。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;逻辑回归与 SVM 学习 $P(y\mid x)$ 或直接学习决策分数，属于判别式方法（Discriminative Method）。朴素贝叶斯（Naive Bayes，NB）则估计：&lt;/p&gt;
&lt;p&gt;$$
P(y=c),\qquad P(x\mid y=c)
$$&lt;/p&gt;
&lt;p&gt;前者是类别先验（Class Prior），后者是类条件似然（Class-Conditional Likelihood）。再用贝叶斯定理（Bayes&apos; Theorem）反转方向：&lt;/p&gt;
&lt;p&gt;$$
P(y=c\mid x)=\frac{P(x\mid y=c)P(y=c)}{P(x)}
$$&lt;/p&gt;
&lt;p&gt;预测同一封邮件时，分母 $P(x)$ 对所有候选类别相同，所以分类只需比较分子：&lt;/p&gt;
&lt;p&gt;$$
\hat y=\arg\max_c P(y=c)P(x\mid y=c)
$$&lt;/p&gt;
&lt;p&gt;这一步很重要：训练不是在背诵“优惠”必然等于垃圾邮件，而是在分别统计“类别本来多常见”和“该类别中各词多常见”。&lt;/p&gt;
&lt;h2&gt;02 “朴素”究竟假设了什么？&lt;/h2&gt;
&lt;p&gt;若直接估计 $V$ 个词的联合分布 $P(x_1,\ldots,x_V\mid y=c)$，可能的词频组合多得无法覆盖。朴素贝叶斯作出条件独立（Conditional Independence）假设：&lt;strong&gt;给定类别后，每个特征的出现不再依赖其他特征。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对一般特征，它把联合似然近似为：&lt;/p&gt;
&lt;p&gt;$$
P(x\mid y=c)\approx\prod_{j=1}^{V}P(x_j\mid y=c)
$$&lt;/p&gt;
&lt;p&gt;文本中的“免费”和“领取”显然不是现实独立的，因此这不是对语言的忠实描述。它的价值在于把一个几乎无法估计的高维联合分布，变成每类一组可由计数估计的参数。&lt;/p&gt;
&lt;h2&gt;03 多项式模型怎样把一封邮件变成似然？&lt;/h2&gt;
&lt;p&gt;多项式朴素贝叶斯（Multinomial Naive Bayes）把一类邮件中的每个词看成从词表分类分布中反复抽取。对类别 $c$，参数向量为：&lt;/p&gt;
&lt;p&gt;$$
\theta_c=[\theta_{c1},\ldots,\theta_{cV}],\qquad
\theta_{cj}\ge0,\quad\sum_{j=1}^{V}\theta_{cj}=1
$$&lt;/p&gt;
&lt;p&gt;$\theta_{cj}$ 表示类别 $c$ 中一次词元抽取为词 $j$ 的概率。若新邮件词频为 $x$，总词数 $M=\sum_jx_j$，其多项式似然为：&lt;/p&gt;
&lt;p&gt;$$
P(x\mid y=c)=\frac{M!}{\prod_jx_j!}\prod_{j=1}^{V}\theta_{cj}^{x_j}
$$&lt;/p&gt;
&lt;p&gt;组合系数只由当前邮件 $x$ 决定，比较类别时可以消去。因此联合分数与下式成正比：&lt;/p&gt;
&lt;p&gt;$$
P(y=c,x)\propto\pi_c\prod_{j=1}^{V}\theta_{cj}^{x_j}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\pi_c=P(y=c)$：类别 $c$ 的先验；&lt;/li&gt;
&lt;li&gt;$x_j$：新邮件中词 $j$ 的次数；&lt;/li&gt;
&lt;li&gt;$\theta_{cj}$：类别 $c$ 下词 $j$ 的概率。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意指数 $x_j$ 的含义：词出现两次，就把相应证据乘两次；没出现的词满足 $\theta_{cj}^0=1$，不贡献这一项。&lt;/p&gt;
&lt;h2&gt;04 从训练计数估计先验和词概率&lt;/h2&gt;
&lt;p&gt;类别先验可用样本比例估计：&lt;/p&gt;
&lt;p&gt;$$
\hat\pi_c=\frac{N_c}{N}
$$&lt;/p&gt;
&lt;p&gt;记 $N_{cj}$ 为训练集中类别 $c$ 的所有邮件里，词 $j$ 的总出现次数；$N_c^{word}=\sum_jN_{cj}$ 为该类总词数。带加性平滑（Additive Smoothing）的词概率是：&lt;/p&gt;
&lt;p&gt;$$
\hat\theta_{cj}=\frac{N_{cj}+\alpha}{N_c^{word}+\alpha V}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\alpha\ge0$：平滑强度；&lt;/li&gt;
&lt;li&gt;$\alpha=1$：拉普拉斯平滑（Laplace Smoothing）；&lt;/li&gt;
&lt;li&gt;$0&amp;#x3C;\alpha&amp;#x3C;1$：Lidstone 平滑（Lidstone Smoothing）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若不平滑，某词从未在某类训练邮件中出现时 $\theta_{cj}=0$。新邮件只要含这个词，整类似然的乘积就立刻变为 0；一个有限训练集中的“没见过”被错误解释成“绝不可能”。平滑给每个词增加伪计数，避免这类一票否决。&lt;/p&gt;
&lt;h2&gt;05 用三个词手算一封新邮件&lt;/h2&gt;
&lt;p&gt;词表固定为 &lt;code&gt;[优惠, 会议, 项目]&lt;/code&gt;，训练集有两封垃圾邮件、两封正常邮件。汇总词频如下：&lt;/p&gt;
&lt;p&gt;| 类别     | 邮件数 | 优惠 | 会议 | 项目 | 总词数 |
| -------- | -----: | ---: | ---: | ---: | -----: |
| 垃圾 $S$ |      2 |    3 |    0 |    1 |      4 |
| 正常 $H$ |      2 |    0 |    2 |    2 |      4 |&lt;/p&gt;
&lt;p&gt;两类先验相同：&lt;/p&gt;
&lt;p&gt;$$
\pi_S=\pi_H=\frac{2}{4}=\frac12
$$&lt;/p&gt;
&lt;p&gt;取 $\alpha=1,V=3$，平滑后：&lt;/p&gt;
&lt;p&gt;$$
\theta_S=\left[\frac47,\frac17,\frac27\right],\qquad
\theta_H=\left[\frac17,\frac37,\frac37\right]
$$&lt;/p&gt;
&lt;p&gt;新邮件“优惠 会议”的计数向量为 $x=[1,1,0]$。忽略两类共有的组合系数，联合分数为：&lt;/p&gt;
&lt;p&gt;$$
s_S=\frac12\times\frac47\times\frac17=\frac{2}{49}
$$&lt;/p&gt;
&lt;p&gt;$$
s_H=\frac12\times\frac17\times\frac37=\frac{3}{98}
$$&lt;/p&gt;
&lt;p&gt;因为 $2/49=4/98&gt;3/98$，模型预测垃圾邮件。若把两个分数归一化：&lt;/p&gt;
&lt;p&gt;$$
P(S\mid x)=\frac{4/98}{4/98+3/98}=\frac47\approx0.571
$$&lt;/p&gt;
&lt;p&gt;这是一个刻意做小的例子：同一封邮件同时包含两类各自偏爱的词，因此结论并不强。它也展示了先验怎样参与竞争——若正常邮件在真实流量中远多于垃圾邮件，较大的 $\pi_H$ 可能翻转结果。&lt;/p&gt;
&lt;p&gt;此时 $x=[2,1,0]$。垃圾类与正常类的未归一化分数分别为 $\frac12(\frac47)^2\frac17=\frac{8}{343}$ 与 $\frac12(\frac17)^2\frac37=\frac{3}{686}$。两者比值从 $4:3$ 扩大到 $16:3$，重复出现的词会重复贡献证据。&lt;/p&gt;
&lt;h2&gt;06 为什么工程实现必须进入对数空间？&lt;/h2&gt;
&lt;p&gt;真实词表可能有十万维，一封邮件会连乘许多小于 1 的概率。浮点数很快下溢为 0，类别之间便无法比较。取对数把乘法改成加法：&lt;/p&gt;
&lt;p&gt;$$
\ell_c(x)=\log\pi_c+\sum_{j=1}^{V}x_j\log\theta_{cj}
$$&lt;/p&gt;
&lt;p&gt;$$
\hat y=\arg\max_c\ell_c(x)
$$&lt;/p&gt;
&lt;p&gt;对数是单调函数，不改变最大值所在类别。完整数据流如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练阶段
文本 [N] ─► 分词/词表 ─► 稀疏计数 X [N,V] + 标签 y [N]
                              │
               ┌──────────────┴──────────────┐
               ▼                             ▼
        类别计数 [K]                  类别-词计数 [K,V]
               │                             │ + alpha
               ▼                             ▼
        log prior [K]                 log theta [K,V]

推理阶段
新文本 [Q] ─► 同一词表 ─► Xq [Q,V]
                              │  Xq @ log_theta.T + log_prior
                              ▼
                      joint log score [Q,K]
                              │ argmax / log-sum-exp
                              ▼
                    类别 [Q] / 后验概率 [Q,K]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;词表也是模型状态。推理时若重新拟合词表，同一列可能从“优惠”变成“项目”，维度即使相同，语义也已经错位。&lt;/p&gt;
&lt;h2&gt;07 不调用 &lt;code&gt;fit&lt;/code&gt;，先写出训练本体&lt;/h2&gt;
&lt;p&gt;下面的 NumPy 实现把计数、平滑和矩阵推理全部暴露出来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([
    [2, 0, 1],
    [1, 0, 0],
    [0, 1, 1],
    [0, 1, 1],
], dtype=np.float64)                     # [N=4, V=3]
y = np.array([1, 1, 0, 0])              # [N=4]
classes = np.unique(y)                   # [K=2]，升序为 [0, 1]
alpha = 1.0

class_count = np.array([
    np.sum(y == c) for c in classes
], dtype=np.float64)                     # [K]

feature_count = np.stack([
    X[y == c].sum(axis=0) for c in classes
])                                       # [K, V]

class_log_prior = np.log(class_count / len(y))
smoothed_count = feature_count + alpha
feature_log_prob = np.log(
    smoothed_count / smoothed_count.sum(axis=1, keepdims=True)
)                                        # [K, V]

X_query = np.array([[1, 1, 0]], dtype=np.float64)  # [Q=1, V=3]
joint_log_score = (
    X_query @ feature_log_prob.T + class_log_prior
)                                        # [Q, K]
prediction = classes[np.argmax(joint_log_score, axis=1)]  # [Q]

# 稳定归一化为后验概率
shifted = joint_log_score - joint_log_score.max(axis=1, keepdims=True)
posterior = np.exp(shifted)
posterior /= posterior.sum(axis=1, keepdims=True)          # [Q, K]

assert np.all(X &gt;= 0) and np.all(X_query &gt;= 0)
assert feature_count.shape == feature_log_prob.shape == (2, 3)
assert np.allclose(np.exp(feature_log_prob).sum(axis=1), 1.0)
assert np.allclose(posterior.sum(axis=1), 1.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;训练的本质不是迭代梯度下降，而是一次分组计数与归一化；推理则是稀疏矩阵乘法。这解释了它为什么在超高维文本上训练和预测都很快。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 建立可复现流程&lt;/h2&gt;
&lt;p&gt;截至本文写作时，scikit-learn 1.9 的 &lt;code&gt;CountVectorizer&lt;/code&gt; 直接产生稀疏词频矩阵，&lt;code&gt;MultinomialNB&lt;/code&gt; 接受稠密或稀疏的 &lt;code&gt;[N,V]&lt;/code&gt; 非负特征。把两者放进 Pipeline，能保证验证与线上推理复用训练词表：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline

train_texts = [
    &apos;优惠 优惠 项目&apos;,
    &apos;优惠&apos;,
    &apos;会议 项目&apos;,
    &apos;会议 项目&apos;,
]
y_train = np.array([&apos;垃圾&apos;, &apos;垃圾&apos;, &apos;正常&apos;, &apos;正常&apos;])

pipeline = make_pipeline(
    CountVectorizer(token_pattern=r&apos;(?u)\b\w+\b&apos;),
    MultinomialNB(alpha=1.0, force_alpha=True, fit_prior=True),
)

pipeline.fit(train_texts, y_train)
query = [&apos;优惠 会议&apos;]
prediction = pipeline.predict(query)             # [Q=1]
log_probability = pipeline.predict_log_proba(query)  # [1, K]
probability = pipeline.predict_proba(query)       # [1, K]

vectorizer = pipeline.named_steps[&apos;countvectorizer&apos;]
model = pipeline.named_steps[&apos;multinomialnb&apos;]
vocabulary = vectorizer.get_feature_names_out()  # [V]

print(&apos;classes:&apos;, model.classes_)
print(&apos;vocabulary:&apos;, vocabulary)
print(&apos;class counts:&apos;, model.class_count_)        # [K]
print(&apos;word counts:&apos;, model.feature_count_)       # [K, V]
print(&apos;log word probabilities:&apos;, model.feature_log_prob_)  # [K, V]

positive_column = np.flatnonzero(model.classes_ == &apos;垃圾&apos;).item()
spam_probability = probability[:, positive_column]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要接口语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;alpha&lt;/code&gt; 可为标量或 &lt;code&gt;[V]&lt;/code&gt; 数组；&lt;code&gt;alpha=1.0&lt;/code&gt; 是拉普拉斯平滑；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;force_alpha=True&lt;/code&gt; 会保留调用者给出的极小 &lt;code&gt;alpha&lt;/code&gt;，也意味着过小值可能造成数值问题；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fit_prior=True&lt;/code&gt; 从训练类别频率学习先验，&lt;code&gt;class_prior&lt;/code&gt; 可显式注入已知部署先验；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature_count_&lt;/code&gt; 形状为 &lt;code&gt;[K,V]&lt;/code&gt;，&lt;code&gt;feature_log_prob_&lt;/code&gt; 与其同形；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;predict_proba&lt;/code&gt; 的列顺序永远由 &lt;code&gt;classes_&lt;/code&gt; 决定，不能凭业务名称猜列号；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;partial_fit&lt;/code&gt; 可按批增量累计计数，但第一次调用必须传入所有可能的 &lt;code&gt;classes&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;中文文本的示例用空格表示已经分好词。真实项目不能默认把 &lt;code&gt;CountVectorizer&lt;/code&gt; 当中文分词器：应把经过验证的 tokenizer 放进 Pipeline，并把分词词典、规范化规则与模型一起版本化。&lt;/p&gt;
&lt;h2&gt;09 训练、验证和上线时要检查什么？&lt;/h2&gt;
&lt;p&gt;平滑强度 $\alpha$、是否使用二元出现特征、n-gram 范围与最低词频都属于超参数，必须在训练折内学习词表并在验证折评价。不要先对全数据执行 &lt;code&gt;fit_transform&lt;/code&gt; 再交叉验证，否则验证文本已经参与词表筛选。&lt;/p&gt;
&lt;p&gt;可以用分层交叉验证同时记录概率质量与分类质量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;cv = StratifiedKFold(n_splits=2, shuffle=True, random_state=42)
scores = cross_validate(
    pipeline,
    train_texts,
    y_train,
    cv=cv,
    scoring=[&apos;neg_log_loss&apos;, &apos;balanced_accuracy&apos;],
    return_train_score=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四条最短调试路径：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;确认矩阵非负。&lt;/strong&gt; 中心化或某些降维会产生负数，不符合多项式计数模型；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;抽查列语义。&lt;/strong&gt; 打印词表与一行非零位置，确认分词、大小写和停用词处理没有错位；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查未登录词比例。&lt;/strong&gt; 线上大量词不在训练词表时，它们会被忽略，输入可能接近全零；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查分数而非只看标签。&lt;/strong&gt; 查看 &lt;code&gt;predict_joint_log_proba&lt;/code&gt; 或 &lt;code&gt;predict_log_proba&lt;/code&gt;，定位是先验压倒似然，还是某些重复词贡献过大。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;10 常见错误与失败场景&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;训练和推理词表不一致。&lt;/strong&gt; 必须保存整个 Pipeline，而不是只保存分类器；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把类别字段直接整数化后交给 &lt;code&gt;MultinomialNB&lt;/code&gt;。&lt;/strong&gt; 它适合计数或非负强度，不等于一般类别特征；一般类别特征应评估 &lt;code&gt;CategoricalNB&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;alpha=0&lt;/code&gt; 当成“更无偏”。&lt;/strong&gt; 零计数会制造零概率，且 &lt;code&gt;force_alpha=True&lt;/code&gt; 不会替你兜底；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别失衡时只看准确率。&lt;/strong&gt; 同时看混淆矩阵、每类召回率、PR 曲线与部署先验；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让重复模板词支配结果。&lt;/strong&gt; 邮件签名、页脚和转发历史可能重复计算无关证据，应清洗或限制特征；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练分布与线上分布变化。&lt;/strong&gt; 垃圾邮件用词快速漂移，需要监控未登录词、类别比例和错误样本；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把相关性当成因果。&lt;/strong&gt; “优惠”提高垃圾分数，只说明训练关联，不说明该词导致邮件有害。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 与相近方法怎样区分？&lt;/h2&gt;
&lt;p&gt;| 方法      | 输入假设           | 训练方式         | 主要边界                   |
| --------- | ------------------ | ---------------- | -------------------------- |
| 多项式 NB | 非负词频或强度     | 每类累计词计数   | 重复出现会重复贡献证据     |
| 伯努利 NB | 每个词出现/未出现  | 每类累计二元事件 | 关心是否出现，不关心次数   |
| 补集 NB   | 用其他类别估计权重 | 计数闭式估计     | 常用于类别失衡文本         |
| 高斯 NB   | 每类每维独立高斯   | 估计均值和方差   | 适合连续特征，不建模相关性 |
| 逻辑回归  | 对数几率线性       | 优化条件似然     | 不生成词，常需更多标注数据 |&lt;/p&gt;
&lt;p&gt;多项式 NB 与逻辑回归在词袋特征上都能形成线性决策分数，但参数来源不同：前者由每类词频概率之比得到，后者直接优化分类条件似然。数据很少、维度很高时 NB 往往启动快；数据增多且特征相关明显时，正则化逻辑回归常更稳。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;朴素贝叶斯先估计类别先验和类条件似然，再用贝叶斯定理比较后验分数；&lt;/li&gt;
&lt;li&gt;条件独立假设把高维联合概率拆成每个特征的证据，但可能造成过度自信；&lt;/li&gt;
&lt;li&gt;多项式 NB 用类别内词频估计 $\theta_{cj}$，拉普拉斯平滑防止未见词把整类概率归零；&lt;/li&gt;
&lt;li&gt;推理必须在对数空间累加：&lt;code&gt;X @ feature_log_prob_.T + class_log_prior_&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;词表、分词与分类器是同一个模型状态，必须一起验证和部署。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;p&gt;手算例中新邮件的类条件似然比为 $P(x\mid S):P(x\mid H)=4:3$。若部署先验变为 $\pi_S:\pi_H=1:4$，联合分数比变成 $1:3$，预测会翻转为正常邮件。&lt;/p&gt;
&lt;p&gt;将 &lt;code&gt;x=[3,0,1]&lt;/code&gt; 二值化为 &lt;code&gt;[1,0,1]&lt;/code&gt;。解释为什么多项式模型会把第一个词的证据计算三次，而伯努利模型只计算一次；再思考哪种更适合短标题，哪种更适合长文档。&lt;/p&gt;
&lt;p&gt;分别对训练文本和查询文本调用两个 &lt;code&gt;CountVectorizer().fit_transform&lt;/code&gt;，然后打印两份 &lt;code&gt;get_feature_names_out()&lt;/code&gt;。即使矩阵列数相同，列语义也可能不同；说明为什么只保存 &lt;code&gt;MultinomialNB&lt;/code&gt; 不足以上线。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/321075.321084&quot;&gt;Maron: Automatic Indexing—An Experimental Inquiry&lt;/a&gt;：用概率方法进行文档自动索引的早期经典工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cdn.aaai.org/Workshops/1998/WS-98-05/WS98-05-007.pdf&quot;&gt;McCallum &amp;#x26; Nigam: A Comparison of Event Models for Naive Bayes Text Classification&lt;/a&gt;：系统比较文本朴素贝叶斯的多项式与伯努利事件模型。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2001/hash/7b7a53e239400a13bd6be6c91c4f6c4e-Abstract.html&quot;&gt;Ng &amp;#x26; Jordan: On Discriminative vs. Generative Classifiers&lt;/a&gt;：比较朴素贝叶斯与逻辑回归学习行为的代表性研究。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://people.csail.mit.edu/jrennie/papers/icml03-nb.pdf&quot;&gt;Rennie et al.: Tackling the Poor Assumptions of Naive Bayes Text Classifiers&lt;/a&gt;：提出补集朴素贝叶斯等改进以缓解文本中的失衡与偏差。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.naive_bayes.MultinomialNB.html&quot;&gt;scikit-learn: MultinomialNB&lt;/a&gt;：当前参数、属性、稀疏输入和增量训练接口说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;朴素贝叶斯为了可估计性，假设给定类别后各特征独立。若身高和体重、多个传感器读数明显相关，我们能否保留生成式分类的概率框架，同时显式建模特征协方差？下一篇将从共享高斯协方差推导线性判别分析，并解释它为什么仍然得到一条直线。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>不显式增加维度，SVM 怎样画出弯曲边界？从核技巧到 RBF</title><link>https://zwjcode.cn/blog/svm-kernel-trick-rbf-gamma</link><guid isPermaLink="true">https://zwjcode.cn/blog/svm-kernel-trick-rbf-gamma</guid><description>从线性 SVM 无法分开同心圆出发，推导对偶表示与核技巧，手算 RBF 相似度，解释 C、gamma、支持向量和可扩展近似。</description><pubDate>Mon, 24 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的线性支持向量机（Support Vector Machine，SVM）用最大间隔选择稳定超平面，并用软间隔容忍噪声。但同心圆、弯月形和异或（XOR）数据在原始空间中根本不存在一条直线能分开。&lt;/p&gt;
&lt;p&gt;一种办法是手工增加 $x_1^2$、$x_1x_2$ 等非线性特征，再训练线性 SVM。问题是高阶组合的维数会快速爆炸，而且我们最后真正需要的，往往不是每个新坐标本身，而只是样本在新空间中的点积。&lt;/p&gt;
&lt;p&gt;核技巧（Kernel Trick）利用这个缺口：&lt;strong&gt;不显式构造高维特征，只计算两点映射后的内积。&lt;/strong&gt; 本文沿“对偶表示 → 核函数 → RBF 相似度 → 工程选择”讲透这条数据流。&lt;/p&gt;
&lt;h2&gt;01 一条直线为什么分不开同心圆？&lt;/h2&gt;
&lt;p&gt;设二维样本的类别只取决于半径：圆心附近是负类，外环是正类。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂
▲          +   +   +
│       +           +
│     +     ○ ○       +
│     +    ○ ○ ○      +
│       +    ○      +
│          +   +
└────────────────────► x₁

原始空间：任何直线都会同时切到内圈和外圈
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若增加一个特征：&lt;/p&gt;
&lt;p&gt;$$
z=x_1^2+x_2^2
$$&lt;/p&gt;
&lt;p&gt;内圈的 $z$ 小，外圈的 $z$ 大，只需在 $z$ 轴设阈值就能线性分开。也就是说，原空间中的弯曲边界，可能是另一个特征空间中的超平面。&lt;/p&gt;
&lt;p&gt;更一般地，用映射 $\phi$ 把输入送入新的特征空间：&lt;/p&gt;
&lt;p&gt;$$
\phi:\mathbb{R}^{D}\rightarrow\mathcal H
$$&lt;/p&gt;
&lt;p&gt;然后学习：&lt;/p&gt;
&lt;p&gt;$$
f(x)=w^\top\phi(x)+b
$$&lt;/p&gt;
&lt;p&gt;困难在于 $\mathcal H$ 可能有成千上万维，甚至无限维；显式计算和存储 $\phi(X)$ 会很昂贵。&lt;/p&gt;
&lt;h2&gt;02 为什么 SVM 最终只需要样本之间的点积？&lt;/h2&gt;
&lt;p&gt;上一篇写过软间隔原始问题（Primal Problem）：&lt;/p&gt;
&lt;p&gt;$$
\min_{w,b,\xi}\frac12|w|_2^2+C\sum_i\xi_i
$$&lt;/p&gt;
&lt;p&gt;满足：&lt;/p&gt;
&lt;p&gt;$$
y_i(w^\top x_i+b)\ge1-\xi_i,\qquad \xi_i\ge0
$$&lt;/p&gt;
&lt;p&gt;通过拉格朗日乘子把约束并入目标，可以得到对偶问题（Dual Problem）：&lt;/p&gt;
&lt;p&gt;$$
\max_{\alpha}\quad
\sum_{i=1}^{N}\alpha_i-rac12
\sum_{i=1}^{N}\sum_{j=1}^{N}
\alpha_i\alpha_jy_iy_j\langle x_i,x_j\rangle
$$&lt;/p&gt;
&lt;p&gt;满足：&lt;/p&gt;
&lt;p&gt;$$
0\le\alpha_i\le C,\qquad \sum_i\alpha_i y_i=0
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\alpha_i$：第 $i$ 个训练约束的乘子；&lt;/li&gt;
&lt;li&gt;$C$：每个乘子的上界，同时控制软间隔违规代价；&lt;/li&gt;
&lt;li&gt;$\langle x_i,x_j\rangle=x_i^\top x_j$：两个训练样本的点积；&lt;/li&gt;
&lt;li&gt;只有 $\alpha_i&gt;0$ 的训练点会进入最终决策函数，它们就是支持向量。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最优权重可写成训练样本的线性组合：&lt;/p&gt;
&lt;p&gt;$$
w=\sum_{i=1}^{N}\alpha_i y_i x_i
$$&lt;/p&gt;
&lt;p&gt;因此新样本的分数是：&lt;/p&gt;
&lt;p&gt;$$
f(x)=\sum_{i=1}^{N}\alpha_i y_i\langle x_i,x\rangle+b
$$&lt;/p&gt;
&lt;p&gt;许多 $\alpha_i$ 为 0，实际只需支持向量集合 $\mathcal S$：&lt;/p&gt;
&lt;p&gt;$$
f(x)=\sum_{i\in\mathcal S}\alpha_i y_i\langle x_i,x\rangle+b
$$&lt;/p&gt;
&lt;p&gt;关键线索出现了：训练和预测都只通过&lt;strong&gt;点积&lt;/strong&gt;比较样本。&lt;/p&gt;
&lt;h2&gt;03 核技巧到底替换了什么？&lt;/h2&gt;
&lt;p&gt;若先映射 $\phi(x)$，对偶中出现的是：&lt;/p&gt;
&lt;p&gt;$$
\langle\phi(x_i),\phi(x_j)\rangle
$$&lt;/p&gt;
&lt;p&gt;核函数（Kernel Function）直接返回这个内积：&lt;/p&gt;
&lt;p&gt;$$
K(x_i,x_j)=\langle\phi(x_i),\phi(x_j)\rangle
$$&lt;/p&gt;
&lt;p&gt;于是决策函数变成：&lt;/p&gt;
&lt;p&gt;$$
f(x)=\sum_{i\in\mathcal S}\alpha_i y_iK(x_i,x)+b
$$&lt;/p&gt;
&lt;p&gt;数据流从“显式造特征”变成“计算相似度矩阵”：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练：X_train [N,D]
          │ 两两核函数 K(x_i,x_j)
          ▼
      Gram matrix K_train [N,N]
          │ 对偶优化
          ▼
  support vectors [S,D] + dual coefficients [S]

推理：X_query [Q,D] × support vectors [S,D]
          │ 核函数
          ▼
      K_query [Q,S]
          │ 加权求和 + b
          ▼
       decision score [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的格拉姆矩阵（Gram Matrix）第 $(i,j)$ 项是 $K(x_i,x_j)$。并非任意“相似度”都能安全作为核；合法核必须对应某个内积空间，常用充分条件是对任意有限样本得到的 Gram 矩阵为半正定。&lt;/p&gt;
&lt;h3&gt;一个显式可验证的二次核&lt;/h3&gt;
&lt;p&gt;对二维输入，令：&lt;/p&gt;
&lt;p&gt;$$
\phi(x)=[x_1^2,\sqrt2x_1x_2,x_2^2]^\top
$$&lt;/p&gt;
&lt;p&gt;则：&lt;/p&gt;
&lt;p&gt;$$
\phi(x)^\top\phi(z)
=x_1^2z_1^2+2x_1x_2z_1z_2+x_2^2z_2^2
=(x^\top z)^2
$$&lt;/p&gt;
&lt;p&gt;因此 $K(x,z)=(x^\top z)^2$ 隐式包含所有二次交互。我们只算原空间的点积再平方，不必为每个样本显式保存三维 $\phi(x)$。高维、高阶时节省更明显。&lt;/p&gt;
&lt;h2&gt;04 RBF 核怎样把“近”变成影响力？&lt;/h2&gt;
&lt;p&gt;径向基函数核（Radial Basis Function Kernel，RBF），也常称高斯核，定义为：&lt;/p&gt;
&lt;p&gt;$$
K(x,z)=\exp\left(-\gamma|x-z|_2^2\right)
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x,z\in\mathbb{R}^{D}$：两个已按训练统计量缩放的样本；&lt;/li&gt;
&lt;li&gt;$|x-z|_2^2$：平方欧氏距离；&lt;/li&gt;
&lt;li&gt;$\gamma&gt;0$：单个样本影响范围的倒数尺度；&lt;/li&gt;
&lt;li&gt;$K(x,z)\in(0,1]$：两点相同为 1，距离增大时趋近 0。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;| 距离 $|x-z|$ | $\gamma=0.25$ | $\gamma=1$ | $\gamma=4$ |
| -------------: | ------------: | ---------: | ---------: |
|              0 |         1.000 |      1.000 |      1.000 |
|              1 |         0.779 |      0.368 |      0.018 |
|              2 |         0.368 |      0.018 | $\approx0$ |&lt;/p&gt;
&lt;p&gt;小 $\gamma$ 让一个支持向量影响很远，边界更平滑；大 $\gamma$ 让影响集中在很小邻域，模型能绕着单个样本急转弯，也更容易追逐噪声。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;K(x,z)
▲      gamma 大
│       /\
│      /  \             gamma 小
│ ____/    \____       __/¯¯¯¯\__
└────────────────────────────────► distance
        窄影响              宽影响
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 用两个支持向量手算一次预测&lt;/h2&gt;
&lt;p&gt;考虑一维的两个支持向量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₁ = -1, y₁ = -1, alpha₁ = 1
x₂ = +1, y₂ = +1, alpha₂ = 1
b = 0, gamma = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;决策函数为：&lt;/p&gt;
&lt;p&gt;$$
f(x)=-K(-1,x)+K(1,x)
$$&lt;/p&gt;
&lt;p&gt;查询 $x=0$ 时，两边距离相同：&lt;/p&gt;
&lt;p&gt;$$
K(-1,0)=K(1,0)=e^{-1}\approx0.368
$$&lt;/p&gt;
&lt;p&gt;所以 $f(0)=0$，正好在边界上。&lt;/p&gt;
&lt;p&gt;查询 $x=0.5$ 时：&lt;/p&gt;
&lt;p&gt;$$
K(-1,0.5)=e^{-2.25}\approx0.105
$$&lt;/p&gt;
&lt;p&gt;$$
K(1,0.5)=e^{-0.25}\approx0.779
$$&lt;/p&gt;
&lt;p&gt;$$
f(0.5)=-0.105+0.779=0.674
$$&lt;/p&gt;
&lt;p&gt;因此预测正类。这个分数不是“最近邻投票”，而是所有支持向量的带符号、带系数相似度之和。&lt;/p&gt;
&lt;p&gt;$K(-1,3)=e^{-16}\approx0$，$K(1,3)=e^{-4}\approx0.018$，所以分数仍略为正，但非常接近 0。RBF SVM 不擅长在训练范围外产生线性外推；远离所有支持向量时，各项都会衰减。&lt;/p&gt;
&lt;h2&gt;06 C 与 gamma 为什么必须联合选择？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;C&lt;/code&gt; 和 &lt;code&gt;gamma&lt;/code&gt; 控制不同维度，却会共同决定边界：&lt;/p&gt;
&lt;p&gt;| 设置                | 单点影响范围 | 违反训练间隔的价格 | 常见边界               |
| ------------------- | ------------ | ------------------ | ---------------------- |
| 小 $\gamma$、小 $C$ | 宽           | 低                 | 很平滑，可能欠拟合     |
| 小 $\gamma$、大 $C$ | 宽           | 高                 | 努力用平滑边界分对     |
| 大 $\gamma$、小 $C$ | 窄           | 低                 | 局部影响强但允许错误   |
| 大 $\gamma$、大 $C$ | 窄           | 高                 | 绕样本急转，容易过拟合 |&lt;/p&gt;
&lt;p&gt;只调一个参数会误判另一个参数的作用。例如大 $\gamma$ 提供了极强局部容量，但若 $C$ 很小，优化器仍可能宁可容忍训练错误；反过来，大 $C$ 也无法让过小 $\gamma$ 表达细小结构。&lt;/p&gt;
&lt;p&gt;scikit-learn 1.9 中 &lt;code&gt;gamma=&apos;scale&apos;&lt;/code&gt; 使用：&lt;/p&gt;
&lt;p&gt;$$
\gamma=\frac{1}{D\cdot\operatorname{Var}(X)}
$$&lt;/p&gt;
&lt;p&gt;它是合理起点，不是经过验证的最优值。由于方差和距离都受单位影响，标准化仍必须放进 Pipeline。&lt;/p&gt;
&lt;h2&gt;07 不调用 SVC：先验证核矩阵和数据流&lt;/h2&gt;
&lt;p&gt;下面用 NumPy 显式计算 RBF Gram 矩阵，并用前面的两个支持向量完成预测：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np


def rbf_kernel(X, Z, gamma):
    # X [N,D], Z [M,D] -&gt; squared_distance [N,M]
    x_norm = np.sum(X**2, axis=1, keepdims=True)       # [N,1]
    z_norm = np.sum(Z**2, axis=1, keepdims=True).T     # [1,M]
    squared_distance = x_norm + z_norm - 2.0 * X @ Z.T
    squared_distance = np.maximum(squared_distance, 0.0)
    return np.exp(-gamma * squared_distance)           # [N,M]


support = np.array([[-1.0], [1.0]])       # [S=2,D=1]
dual_coef = np.array([-1.0, 1.0])         # alpha_i * y_i, [S]
query = np.array([[0.0], [0.5], [3.0]])   # [Q=3,D=1]

K_query = rbf_kernel(query, support, gamma=1.0)  # [Q,S]
score = K_query @ dual_coef + 0.0                 # [Q]
prediction = np.where(score &gt;= 0.0, 1, -1)       # [Q]

K_train = rbf_kernel(support, support, gamma=1.0) # [S,S]
eigenvalues = np.linalg.eigvalsh(K_train)
assert np.allclose(K_train, K_train.T)
assert eigenvalues.min() &gt;= -1e-10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;距离公式用矩阵乘法避免创建 &lt;code&gt;[N,M,D]&lt;/code&gt; 的巨大差值张量；由于浮点舍入，理论上非负的平方距离可能出现极小负数，所以在指数前截到 0。&lt;/p&gt;
&lt;p&gt;真实 &lt;code&gt;SVC&lt;/code&gt; 的 &lt;code&gt;dual_coef_&lt;/code&gt; 已经包含类别符号；多分类时其布局更复杂，不要把二分类示例直接推广为手写多分类推理。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 训练 RBF SVM&lt;/h2&gt;
&lt;p&gt;官方当前接口中 &lt;code&gt;SVC&lt;/code&gt; 默认核就是 RBF，但正式代码应显式写出关键选择，并在对数尺度联合搜索 $C$ 与 $\gamma$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

pipeline = make_pipeline(
    StandardScaler(),
    SVC(
        kernel=&apos;rbf&apos;,
        cache_size=512,
        class_weight=None,
    ),
)

search = GridSearchCV(
    estimator=pipeline,
    param_grid={
        &apos;svc__C&apos;: np.logspace(-2, 3, 6),
        &apos;svc__gamma&apos;: np.logspace(-4, 1, 6),
    },
    scoring=&apos;balanced_accuracy&apos;,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    n_jobs=-1,
    refit=True,
)
search.fit(X_train, y_train)                 # [N,D], [N]

score = search.decision_function(X_val)      # binary: [num_val]
prediction = search.predict(X_val)           # [num_val]
svc = search.best_estimator_.named_steps[&apos;svc&apos;]

print(&apos;best parameters:&apos;, search.best_params_)
print(&apos;support vectors:&apos;, svc.support_vectors_.shape) # [S,D]
print(&apos;per-class support:&apos;, svc.n_support_)           # [num_classes]
print(&apos;dual coefficients:&apos;, svc.dual_coef_.shape)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要接口语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;support_&lt;/code&gt; 是训练行索引 &lt;code&gt;[S]&lt;/code&gt;；&lt;code&gt;support_vectors_&lt;/code&gt; 是经过 Pipeline 标准化后的支持向量 &lt;code&gt;[S,D]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;二分类 &lt;code&gt;decision_function&lt;/code&gt; 返回 &lt;code&gt;[Q]&lt;/code&gt;，正负方向对应 &lt;code&gt;classes_&lt;/code&gt; 的顺序；它不是概率；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cache_size&lt;/code&gt; 单位是 MB，增大核缓存可能提速，但会增加每个并发训练进程的内存；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;class_weight=&apos;balanced&apos;&lt;/code&gt; 会按类别频率缩放各类有效 $C$，不能替代合适指标和阈值设计；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SVC&lt;/code&gt; 内部多分类使用一对一（One-vs-One），默认只把决策输出整理成一对其余风格。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如在超参数冻结后重新以交叉验证校准：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.calibration import CalibratedClassifierCV

calibrated = CalibratedClassifierCV(
    estimator=search.best_estimator_,
    method=&apos;sigmoid&apos;,
    cv=5,
    ensemble=False,
)
calibrated.fit(X_train, y_train)
probability = calibrated.predict_proba(X_val)  # [num_val, num_classes]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;校准本身也是模型选择的一部分。测试集不能用于拟合校准器或选择 &lt;code&gt;sigmoid&lt;/code&gt;/&lt;code&gt;isotonic&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;09 训练为何会突然变得很慢？&lt;/h2&gt;
&lt;p&gt;核 SVM 的代价来自样本两两关系。完整 Gram 矩阵有 $N^2$ 个元素；&lt;code&gt;SVC&lt;/code&gt; 的训练时间至少随样本数二次增长，在数万样本以上常变得不实用。预测成本又约随支持向量数 $S$ 增长：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;单批推理核计算量 ≈ Q × S × D
模型状态至少包含 S 个支持向量
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果类别高度重叠，很多训练点会成为支持向量，模型体积和延迟都会上升。工程检查应同时记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;样本数 $N$ 与支持向量数 $S$；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;S/N&lt;/code&gt; 支持向量比例；&lt;/li&gt;
&lt;li&gt;交叉验证每折训练时间和峰值内存；&lt;/li&gt;
&lt;li&gt;批量吞吐、P50/P99 推理延迟；&lt;/li&gt;
&lt;li&gt;标准化器、支持向量和库版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;数据较大时有三条常见替代路线：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;边界近似线性：用 &lt;code&gt;LinearSVC&lt;/code&gt; 或 &lt;code&gt;SGDClassifier(loss=&apos;hinge&apos;)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;仍需要 RBF 形状：用 Nyström 或随机傅里叶特征近似核，再接线性模型；&lt;/li&gt;
&lt;li&gt;表格任务允许别的归纳偏置：比较直方图梯度提升等强基线。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Nyström 近似把隐式无限维核压成显式的 $M$ 维特征：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.kernel_approximation import Nystroem
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import LinearSVC

approximate_rbf = make_pipeline(
    StandardScaler(),
    Nystroem(
        kernel=&apos;rbf&apos;,
        gamma=0.1,
        n_components=1000,
        random_state=42,
    ),
    LinearSVC(C=1.0, dual=&apos;auto&apos;, max_iter=10_000),
)
approximate_rbf.fit(X_train, y_train)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;n_components&lt;/code&gt; 越大通常越接近原核，也增加变换、内存与线性模型成本；它与 &lt;code&gt;gamma&lt;/code&gt;、&lt;code&gt;C&lt;/code&gt; 一样要在开发数据上验证。&lt;/p&gt;
&lt;h2&gt;10 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘记标准化。&lt;/strong&gt; RBF 直接使用平方距离，一个大尺度特征会吞没其余维度；缩放必须只在训练折拟合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只调 C，不调 gamma。&lt;/strong&gt; 二者共同控制容量，应在对数网格联合搜索。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 gamma 当影响半径。&lt;/strong&gt; gamma 越大，实际影响越窄；可先手算距离 1 时的 $e^{-\gamma}$。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 decision score 当概率。&lt;/strong&gt; 分数可为任意实数；用校准器并验证 Brier 分数、对数损失和校准曲线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对全数据先算 Gram 矩阵再交叉验证。&lt;/strong&gt; 若核前还包含可学习预处理，会发生泄漏；优先使用 Pipeline。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自定义预计算核形状错误。&lt;/strong&gt; 训练应为 &lt;code&gt;[N_train,N_train]&lt;/code&gt;，验证/推理应为 &lt;code&gt;[N_query,N_train]&lt;/code&gt;，第二维始终对应训练样本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无限扩大 cache_size 或 n_jobs。&lt;/strong&gt; 每个并发折都可能占用自己的核缓存，外层并行会放大内存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;支持向量比例接近 100% 仍忽略延迟。&lt;/strong&gt; 先检查重叠、噪声、C/gamma 和线性/近似替代方案。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在训练范围外相信分数大小。&lt;/strong&gt; RBF 相似度远离所有支持向量会共同衰减，外推行为并不等同于置信度。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小诊断代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == 2 and y_train.ndim == 1
assert X_train.shape[0] == y_train.shape[0]
assert np.isfinite(X_train).all()
assert np.isfinite(score).all()
assert svc.support_vectors_.shape[1] == X_train.shape[1]
assert svc.dual_coef_.shape[1] == len(svc.support_)
print(&apos;classes:&apos;, svc.classes_)
print(&apos;support ratio:&apos;, len(svc.support_) / len(X_train))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若训练和验证都差，优先检查缩放、gamma 是否过小和特征是否有信号；若训练接近完美而验证差，优先减小 gamma、减小 C、排查泄漏和标签噪声。&lt;/p&gt;
&lt;h2&gt;11 与相近方法的边界&lt;/h2&gt;
&lt;p&gt;| 方法          | 非线性从哪里来    | 训练/推理主要状态  | 主要限制                 |
| ------------- | ----------------- | ------------------ | ------------------------ |
| RBF SVM       | 支持向量与 RBF 核 | 支持向量和对偶系数 | 样本规模大时昂贵         |
| KNN           | 原始空间局部距离  | 几乎全部训练样本   | 高维距离退化、推理慢     |
| 核逻辑回归    | 核特征 + 对数损失 | 通常更稠密的系数   | 优化和存储可能更重       |
| 决策树/提升树 | 轴对齐分裂组合    | 规则节点           | 不做平滑外推             |
| 神经网络      | 多层可学习表示    | 网络参数           | 训练设计与数据需求更复杂 |&lt;/p&gt;
&lt;p&gt;RBF SVM 适合中小规模、经过良好缩放、边界非线性且局部平滑的数据。它不是所有非线性问题的默认答案：图像、文本、序列和图结构通常需要更合适的表示；高维稀疏文本常先比较线性 SVM；需要概率时要把校准成本纳入方案。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;SVM 的对偶目标和预测只依赖样本点积，因此可以用核函数替换高维映射后的点积。&lt;/li&gt;
&lt;li&gt;决策函数是支持向量核相似度的带符号加权和：$\sum\alpha_i y_iK(x_i,x)+b$。&lt;/li&gt;
&lt;li&gt;RBF 核按平方距离衰减；&lt;code&gt;gamma&lt;/code&gt; 越大，单个样本影响越窄，边界容量通常越高。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;C&lt;/code&gt; 控制违规价格，&lt;code&gt;gamma&lt;/code&gt; 控制局部影响范围，二者必须在无泄漏 Pipeline 中联合验证。&lt;/li&gt;
&lt;li&gt;精确核 SVM 的训练至少二次扩展，支持向量过多还会拖慢推理；大数据要比较线性或核近似方案。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;p&gt;令 $x=[0,0]$、$z=[1,1]$、$\gamma=0.5$。平方距离为 2，因此 $K(x,z)=e^{-1}\approx0.368$。若先把第二维放大 100 倍，核几乎变为 0，这说明尺度为何会决定相似度。&lt;/p&gt;
&lt;p&gt;训练集有 800 个样本，测试批有 32 个样本。若 &lt;code&gt;kernel=&apos;precomputed&apos;&lt;/code&gt;，训练 Gram 矩阵是 &lt;code&gt;[800,800]&lt;/code&gt;，测试核矩阵是 &lt;code&gt;[32,800]&lt;/code&gt;；测试矩阵不是 &lt;code&gt;[32,32]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;固定划分和缩放，对 &lt;code&gt;C,gamma in {0.01,1,100}&lt;/code&gt; 的 9 个组合记录训练分数、验证分数、支持向量比例、训练时间和 P99 延迟。解释哪一角欠拟合，哪一角最可能追逐噪声。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/130385.130401&quot;&gt;Boser, Guyon &amp;#x26; Vapnik: A Training Algorithm for Optimal Margin Classifiers&lt;/a&gt;：核化最大间隔分类器的经典工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/089976698300017467&quot;&gt;Schölkopf, Smola &amp;#x26; Müller: Nonlinear Component Analysis as a Kernel Eigenvalue Problem&lt;/a&gt;：核技巧用于非线性特征分析的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2007/hash/013a006f03dbc5392effeb8f18fda755-Abstract.html&quot;&gt;Rahimi &amp;#x26; Recht: Random Features for Large-Scale Kernel Machines&lt;/a&gt;：用随机显式特征扩展平移不变核。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2000/hash/19de10adbaa1b2ee13f77f679fa1483a-Abstract.html&quot;&gt;Williams &amp;#x26; Seeger: Using the Nyström Method to Speed Up Kernel Machines&lt;/a&gt;：Nyström 核矩阵低秩近似的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html&quot;&gt;scikit-learn: SVC&lt;/a&gt;：当前 RBF 参数、复杂度、支持向量属性与概率接口变化。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;SVM 从几何间隔出发，通过支持向量决定边界。下一篇将换到概率生成视角：朴素贝叶斯怎样用类别先验与条件似然组合证据，并用对数空间避免许多小概率相乘后下溢。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>能分开数据的直线有很多，SVM 为什么选择最大间隔？</title><link>https://zwjcode.cn/blog/svm-maximum-margin-soft-margin</link><guid isPermaLink="true">https://zwjcode.cn/blog/svm-maximum-margin-soft-margin</guid><description>从任意分界线对扰动不稳出发，推导硬间隔、软间隔与合页损失，手算 C 的作用，并用 PyTorch 和 scikit-learn 实现可诊断的线性 SVM。</description><pubDate>Mon, 24 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 XGBoost 通过许多轴对齐分裂逐步修正预测。树模型能表达复杂边界，但边界由大量局部规则拼成；当特征是高维稀疏向量，或者我们希望得到一条全局、稳定的分界面时，线性模型仍然很有价值。&lt;/p&gt;
&lt;p&gt;逻辑回归会用交叉熵学习一条线性边界。不过只要求把训练样本分对时，通常存在许多可行直线。哪一条更不怕测量误差和样本轻微移动？&lt;/p&gt;
&lt;p&gt;支持向量机（Support Vector Machine，SVM）的选择是：&lt;strong&gt;让分界面离两类中最近的训练样本尽可能远。&lt;/strong&gt; 本文只讲透最大间隔、软间隔和合页损失这条主线；核技巧留到下一篇。&lt;/p&gt;
&lt;h2&gt;01 只找到一条分界线为什么不够？&lt;/h2&gt;
&lt;p&gt;二分类线性模型先计算决策分数：&lt;/p&gt;
&lt;p&gt;$$
f(x)=w^\top x+b
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x\in\mathbb{R}^{D}$：一个 $D$ 维样本；&lt;/li&gt;
&lt;li&gt;$w\in\mathbb{R}^{D}$：边界的法向量；&lt;/li&gt;
&lt;li&gt;$b\in\mathbb{R}$：截距；&lt;/li&gt;
&lt;li&gt;$f(x)\in\mathbb{R}$：有符号决策分数；&lt;/li&gt;
&lt;li&gt;标签改写成 $y\in{-1,+1}$，预测为 $\operatorname{sign}(f(x))$。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;边界是 $w^\top x+b=0$。二维中，下面三条线都能分对训练点，但中间那条给两类留下了更大的缓冲带：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂
▲       +     +
│          +          wᵀx+b = +1
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│  ═══════════════════════════  wᵀx+b = 0
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  wᵀx+b = -1
│       ○       ○
│    ○
└────────────────────────────► x₁
        最近的 + / ○ 决定缓冲带宽度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若边界紧贴某个训练点，这个点的特征只要有一点测量误差就会越界。最大间隔（Maximum Margin）不是直接保证未来正确，而是加入一种几何归纳偏置：在训练数据允许的范围内，选择最有余量的边界。&lt;/p&gt;
&lt;h2&gt;02 函数间隔为什么必须归一化？&lt;/h2&gt;
&lt;p&gt;一个样本的有符号函数间隔是：&lt;/p&gt;
&lt;p&gt;$$
m_i=y_i(w^\top x_i+b)
$$&lt;/p&gt;
&lt;p&gt;分对时 $m_i&gt;0$，分错时 $m_i&amp;#x3C;0$。但把 $w,b$ 同时乘以 10，边界位置完全不变，$m_i$ 却会放大 10 倍。因此不能只靠增大参数制造“更大间隔”。&lt;/p&gt;
&lt;p&gt;点到超平面的几何距离为：&lt;/p&gt;
&lt;p&gt;$$
d_i=\frac{|w^\top x_i+b|}{|w|_2}
$$&lt;/p&gt;
&lt;p&gt;对分对的样本，可写成：&lt;/p&gt;
&lt;p&gt;$$
\gamma_i=\frac{y_i(w^\top x_i+b)}{|w|_2}
$$&lt;/p&gt;
&lt;p&gt;固定缩放，使离边界最近的样本满足 $y_i(w^\top x_i+b)=1$，两条间隔边界就是：&lt;/p&gt;
&lt;p&gt;$$
w^\top x+b=+1,\qquad w^\top x+b=-1
$$&lt;/p&gt;
&lt;p&gt;每一侧到中央边界的距离是 $1/|w|_2$，完整间隔宽度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{2}{|w|_2}
$$&lt;/p&gt;
&lt;p&gt;所以最大化间隔等价于最小化 $|w|_2$。&lt;/p&gt;
&lt;h2&gt;03 硬间隔 SVM 怎样写成优化问题？&lt;/h2&gt;
&lt;p&gt;线性可分时，硬间隔支持向量机（Hard-margin SVM）求解：&lt;/p&gt;
&lt;p&gt;$$
\min_{w,b}\ \frac12|w|_2^2
$$&lt;/p&gt;
&lt;p&gt;满足每个训练样本：&lt;/p&gt;
&lt;p&gt;$$
y_i(w^\top x_i+b)\ge1,\qquad i=1,\ldots,N
$$&lt;/p&gt;
&lt;p&gt;这两个部分各司其职：&lt;/p&gt;
&lt;p&gt;| 部分             | 约束了什么         | 几何意义             |
| ---------------- | ------------------ | -------------------- |
| $\frac12|w|^2$ | 目标尽量小         | 间隔尽量宽           |
| $y_if(x_i)\ge1$  | 所有样本在正确一侧 | 不允许进入间隔或分错 |&lt;/p&gt;
&lt;p&gt;刚好满足等号 $y_if(x_i)=1$ 的点叫支持向量（Support Vector）。它们像撑住两侧间隔的支点；离边界更远的样本约束有余量，轻微移动通常不会改变最优边界。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;全部训练样本 X [N,D] + y [N]
          │ 求解带约束优化
          ▼
       w [D], b []
          │
          ├── 最近样本：support vectors [S,D]
          │
X_query [Q,D] ─► X_query @ w + b ─► scores [Q]
                                      │ sign
                                      ▼
                                  labels [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;04 一维例子：为什么只有两个点“撑住”边界？&lt;/h2&gt;
&lt;p&gt;考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x: [-2, -1, 1, 2]
y: [-1, -1,+1,+1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;取 $w=1,b=0$，四个函数间隔为：&lt;/p&gt;
&lt;p&gt;$$
y_i(wx_i+b)=[2,1,1,2]
$$&lt;/p&gt;
&lt;p&gt;所有约束都满足。$x=-1$ 与 $x=1$ 恰好位于 $f(x)=-1$ 和 $f(x)=+1$，是支持向量；$x=-2$ 与 $x=2$ 更远。&lt;/p&gt;
&lt;p&gt;边界在 $x=0$，完整间隔宽度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{2}{|w|}=2
$$&lt;/p&gt;
&lt;p&gt;如果把远点从 $\pm2$ 改成 $\pm20$，最近的两个点不变，最优边界也不变。这是“支持向量”名称最直接的含义：最终解主要由靠近边界的困难样本支撑，而不是由每个远处样本平均决定。&lt;/p&gt;
&lt;p&gt;最近负类仍是 $-1$，最近正类变成 $3$。对称的最大间隔边界移动到 $x=1$，两侧支持向量到边界的距离都是 2，完整间隔变为 4。远处点只要没有成为新的最近点，就不会撑住边界。&lt;/p&gt;
&lt;h2&gt;05 一个异常点为何让硬间隔失效？&lt;/h2&gt;
&lt;p&gt;真实数据常有重叠和错标。若在上例加入一个负类点 $x=0.2$，就无法用原来的宽间隔把所有点都放到正确区域；更极端时，两类完全交错，硬间隔问题没有可行解。&lt;/p&gt;
&lt;p&gt;软间隔支持向量机（Soft-margin SVM）为每个样本加入松弛变量（Slack Variable）$\xi_i\ge0$：&lt;/p&gt;
&lt;p&gt;$$
\min_{w,b,\xi}\ \frac12|w|&lt;em&gt;2^2+C\sum&lt;/em&gt;{i=1}^{N}\xi_i
$$&lt;/p&gt;
&lt;p&gt;约束改成：&lt;/p&gt;
&lt;p&gt;$$
y_i(w^\top x_i+b)\ge1-\xi_i
$$&lt;/p&gt;
&lt;p&gt;松弛量可以直接读成违反间隔的程度：&lt;/p&gt;
&lt;p&gt;| $m_i=y_if(x_i)$ | 最小 $\xi_i$ | 状态                   |
| --------------: | -----------: | ---------------------- |
|         $m_i&gt;1$ |            0 | 正确且在间隔外         |
|         $m_i=1$ |            0 | 位于间隔边界，支持向量 |
|       $0&amp;#x3C;m_i&amp;#x3C;1$ |      $1-m_i$ | 分对但进入间隔         |
|       $m_i\le0$ |  $1-m_i\ge1$ | 分错或恰在决策边界     |&lt;/p&gt;
&lt;p&gt;$C&gt;0$ 是违反间隔的价格：大 $C$ 更努力减少训练违规，可能得到窄而曲折的边界；小 $C$ 更愿意容忍违规，以换取更小的 $|w|$ 和更宽的间隔。&lt;/p&gt;
&lt;h2&gt;06 合页损失把约束变成可优化目标&lt;/h2&gt;
&lt;p&gt;对固定 $w,b$，最小松弛量就是合页损失（Hinge Loss）：&lt;/p&gt;
&lt;p&gt;$$
L_{hinge}(y,f)=\max(0,1-yf)
$$&lt;/p&gt;
&lt;p&gt;软间隔目标可写成：&lt;/p&gt;
&lt;p&gt;$$
J(w,b)=\frac12|w|&lt;em&gt;2^2+C\sum&lt;/em&gt;{i=1}^{N}\max(0,1-y_i f(x_i))
$$&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;hinge loss
▲
│\
│ \
│  \
│   \__________
└───────────────► margin m = y f(x)
0        1

m &amp;#x3C; 1：仍有线性梯度
m &gt; 1：损失为 0，不再推动远处样本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这与逻辑回归的交叉熵很不一样。交叉熵会继续奖励更高的正确置信度；标准合页损失在样本越过 $m=1$ 后变成 0。SVM 因而把优化重点放在间隔内和边界附近的点。&lt;/p&gt;
&lt;h3&gt;手算 C 如何改变选择&lt;/h3&gt;
&lt;p&gt;仍用五个样本：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x = [-2,-1,0.2,1,2]
y = [-1,-1, -1,+1,+1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比较两个无截距候选模型：&lt;/p&gt;
&lt;p&gt;| 候选       | $\frac12w^2$ | 合页损失之和 | $C=1$ 的目标 | $C=0.1$ 的目标 |
| ---------- | -----------: | -----------: | -----------: | -------------: |
| A：$w=1$   |        0.500 |        1.200 |        1.700 |          0.620 |
| B：$w=0.5$ |        0.125 |        2.100 |        2.225 |          0.335 |&lt;/p&gt;
&lt;p&gt;$C=1$ 时，A 用更大权重换来较少违规；$C=0.1$ 时，B 的更宽间隔更值得，哪怕更多样本进入间隔。真实训练不是只在两个候选间选择，但权衡完全相同。&lt;/p&gt;
&lt;h2&gt;07 不依赖黑盒：用 PyTorch 写出线性软间隔&lt;/h2&gt;
&lt;p&gt;下面直接优化“权重平方 + 平均合页损失”。为了让偏置不被正则化，显式拆出 &lt;code&gt;weight&lt;/code&gt; 和 &lt;code&gt;bias&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

torch.manual_seed(42)

X = torch.tensor([
    [-2.0, -1.0],
    [-1.0, -1.0],
    [-0.5,  0.2],
    [ 0.8,  1.0],
    [ 2.0,  1.0],
])                                      # [N=5, D=2]
y = torch.tensor([-1.0, -1.0, -1.0, 1.0, 1.0])  # [5]

weight = torch.zeros(2, requires_grad=True)       # [D]
bias = torch.zeros((), requires_grad=True)        # []
optimizer = torch.optim.SGD([weight, bias], lr=0.05)
C = 1.0

for step in range(1000):
    score = X @ weight + bias              # [N]
    margin = y * score                     # [N]
    per_sample_hinge = torch.relu(1.0 - margin)  # [N]
    regularizer = 0.5 * weight.square().sum()    # []
    objective = regularizer + C * per_sample_hinge.mean()

    optimizer.zero_grad(set_to_none=True)
    objective.backward()
    optimizer.step()

with torch.no_grad():
    query = torch.tensor([[0.0, 0.5], [1.5, 0.0]])  # [Q=2,D=2]
    query_score = query @ weight + bias              # [2]
    prediction = torch.where(query_score &gt;= 0, 1, -1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键调试量不是只有准确率：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with torch.no_grad():
    margin = y * (X @ weight + bias)
    print(&apos;weight norm:&apos;, weight.norm().item())
    print(&apos;minimum margin:&apos;, margin.min().item())
    print(&apos;inside margin:&apos;, (margin &amp;#x3C;= 1.0).sum().item())
    print(&apos;misclassified:&apos;, (margin &amp;#x3C;= 0.0).sum().item())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本文代码对合页损失取均值，而经典 $C$-SVM 常写成求和。因此这里的 &lt;code&gt;C=1&lt;/code&gt; 不等于任意库中的同名数值；比较实验必须固定样本数、缩放方式和目标约定。&lt;/p&gt;
&lt;h2&gt;08 用 scikit-learn 1.9 正确落地&lt;/h2&gt;
&lt;p&gt;若希望直接检查支持向量，可用 &lt;code&gt;SVC(kernel=&apos;linear&apos;)&lt;/code&gt;。SVM 对尺度不具不变性，标准化必须放进 Pipeline，避免交叉验证泄漏：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

pipeline = make_pipeline(
    StandardScaler(),
    SVC(kernel=&apos;linear&apos;),
)

search = GridSearchCV(
    estimator=pipeline,
    param_grid={&apos;svc__C&apos;: np.logspace(-3, 3, 13)},
    scoring=&apos;balanced_accuracy&apos;,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    n_jobs=-1,
    refit=True,
)
search.fit(X_train, y_train)             # X_train [N,D], y_train [N]

scores = search.decision_function(X_val) # [num_val]
labels = search.predict(X_val)           # [num_val]
svc = search.best_estimator_.named_steps[&apos;svc&apos;]

print(&apos;classes:&apos;, svc.classes_)
print(&apos;support indices:&apos;, svc.support_)             # [S]
print(&apos;support vectors:&apos;, svc.support_vectors_.shape)  # [S,D]
print(&apos;weight shape:&apos;, svc.coef_.shape)              # [1,D]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;support_vectors_&lt;/code&gt; 位于&lt;strong&gt;标准化后的空间&lt;/strong&gt;，因为 SVC 接收的是 Pipeline 上一步输出。原始训练样本索引可用 &lt;code&gt;support_&lt;/code&gt; 回到 &lt;code&gt;X_train&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当样本很多且只需要线性边界，&lt;code&gt;LinearSVC&lt;/code&gt; 通常更可扩展：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.svm import LinearSVC

linear_pipeline = make_pipeline(
    StandardScaler(),
    LinearSVC(
        C=1.0,
        loss=&apos;hinge&apos;,
        penalty=&apos;l2&apos;,
        dual=&apos;auto&apos;,
        max_iter=10_000,
        random_state=42,
    ),
)
linear_pipeline.fit(X_train, y_train)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;scikit-learn 1.9 中，&lt;code&gt;LinearSVC&lt;/code&gt; 默认损失其实是 &lt;code&gt;squared_hinge&lt;/code&gt;；上面显式设为 &lt;code&gt;hinge&lt;/code&gt; 才与本文公式一致。它由 liblinear 实现，规模通常优于 &lt;code&gt;SVC(kernel=&apos;linear&apos;)&lt;/code&gt;，但不提供 &lt;code&gt;support_&lt;/code&gt;；二者的损失默认值和截距正则化也不同，不能期待逐点完全相同。&lt;/p&gt;
&lt;h2&gt;09 工程中最常见的错误与调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;没有缩放特征。&lt;/strong&gt; 若收入数值是年龄的几千倍，$|w|$ 与几何距离会被单位扭曲。只在训练折拟合 &lt;code&gt;StandardScaler&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把分数当概率。&lt;/strong&gt; SVM 原生输出间隔分数，不在 $[0,1]$。需要概率时应在独立交叉验证框架中校准，而不是手动 Sigmoid。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;误解 &lt;code&gt;C&lt;/code&gt;。&lt;/strong&gt; 大 &lt;code&gt;C&lt;/code&gt; 更在乎训练违规，小 &lt;code&gt;C&lt;/code&gt; 正则更强；画验证曲线并同时记录支持向量比例。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 &lt;code&gt;LinearSVC&lt;/code&gt; 查 &lt;code&gt;support_&lt;/code&gt;。&lt;/strong&gt; 它不保存 libsvm 意义下的支持向量；需要这些属性时用 &lt;code&gt;SVC(kernel=&apos;linear&apos;)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;标签仍是 0/1 却手写 $yf(x)$。&lt;/strong&gt; 数学推导要求 $y\in{-1,+1}$；代码里先显式映射并断言。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只加大 &lt;code&gt;max_iter&lt;/code&gt;。&lt;/strong&gt; 出现收敛警告时先检查尺度、重复特征、异常值和 &lt;code&gt;tol&lt;/code&gt;，再提高迭代上限。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别失衡只看准确率。&lt;/strong&gt; 检查每类召回率和间隔分布；&lt;code&gt;class_weight=&apos;balanced&apos;&lt;/code&gt; 会按类别缩放有效 $C$，仍需验证。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小不变量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == 2 and y_train.ndim == 1
assert X_train.shape[0] == y_train.shape[0]
assert np.isfinite(X_train).all()
assert scores.shape == (X_val.shape[0],)
assert np.isfinite(scores).all()
print(&apos;best C:&apos;, search.best_params_[&apos;svc__C&apos;])
print(&apos;support ratio:&apos;, len(svc.support_) / len(X_train))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;支持向量比例非常高，可能说明类别重叠严重、特征噪声大、&lt;code&gt;C&lt;/code&gt; 选择不合适，或线性边界根本不匹配；它不是一个单独的质量指标。&lt;/p&gt;
&lt;h2&gt;10 它什么时候失败？与逻辑回归有什么不同？&lt;/h2&gt;
&lt;p&gt;| 方法     | 训练目标             | 远离边界的正确样本 | 原生概率           | 线性边界   |
| -------- | -------------------- | ------------------ | ------------------ | ---------- |
| 线性 SVM | 权重范数 + 合页损失  | 超过间隔后损失为 0 | 否                 | 是         |
| 逻辑回归 | L2 等正则 + 对数损失 | 仍有很小损失       | 是，但仍需校准检查 | 是         |
| 感知机   | 错分样本触发更新     | 分对后不管间隔     | 否                 | 是         |
| 决策树   | 不纯度贪心分裂       | 不适用             | 叶频率             | 轴对齐分段 |&lt;/p&gt;
&lt;p&gt;线性 SVM 会在以下场景失效或失去优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原始空间边界明显弯曲，例如同心圆；&lt;/li&gt;
&lt;li&gt;标签严重重叠或错标，支持向量过多，边界对困难点敏感；&lt;/li&gt;
&lt;li&gt;业务必须直接得到可靠概率，却没有足够校准数据；&lt;/li&gt;
&lt;li&gt;数据分布漂移使旧支持向量不再代表新边界；&lt;/li&gt;
&lt;li&gt;特征量纲、缺失值或线上预处理与训练不一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最大间隔提升的是特定假设空间下的稳健性，不会修复数据泄漏、错误标签或缺失特征。&lt;/p&gt;
&lt;h2&gt;11 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;线性 SVM 在所有可行边界中选择几何间隔最大的一个；间隔宽度由 $2/|w|_2$ 给出。&lt;/li&gt;
&lt;li&gt;支持向量位于间隔边界内或附近，它们支撑最优解；远处样本越过间隔后不再产生合页损失。&lt;/li&gt;
&lt;li&gt;软间隔用松弛变量容忍重叠与噪声，&lt;code&gt;C&lt;/code&gt; 在小权重和少违规之间权衡。&lt;/li&gt;
&lt;li&gt;合页损失 $\max(0,1-yf)$ 让“分对但太靠近边界”的样本仍参与训练。&lt;/li&gt;
&lt;li&gt;SVM 必须重视尺度、数据划分和分数语义；&lt;code&gt;decision_function&lt;/code&gt; 不是概率。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 思考题与小练习&lt;/h2&gt;
&lt;p&gt;设 $y_if(x_i)$ 分别为 &lt;code&gt;1.4、0.6、-0.2&lt;/code&gt;。对应合页损失是 &lt;code&gt;0、0.4、1.2&lt;/code&gt;；第一个在间隔外，第二个分对但进入间隔，第三个分错。&lt;/p&gt;
&lt;p&gt;若 $w=[3,4]$，则 $|w|_2=5$。中央边界到任一间隔面的距离是 $1/5$，完整间隔宽度是 $2/5=0.4$。把 $w,b$ 同时乘 2 不改变边界，却不再满足本文固定的规范化约束。&lt;/p&gt;
&lt;p&gt;在 Pipeline 中比较 &lt;code&gt;C=10^{-3},...,10^3&lt;/code&gt;，记录训练/验证平衡准确率、$|w|$ 和支持向量比例。解释为何训练分数通常随 C 增大而改善，验证分数却可能先升后降。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF00994018&quot;&gt;Cortes &amp;#x26; Vapnik: Support-Vector Networks&lt;/a&gt;：软间隔支持向量网络的奠基论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/130385.130401&quot;&gt;Boser, Guyon &amp;#x26; Vapnik: A Training Algorithm for Optimal Margin Classifiers&lt;/a&gt;：最大间隔与核方法的重要早期工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.microsoft.com/en-us/research/publication/sequential-minimal-optimization-a-fast-algorithm-for-training-support-vector-machines/&quot;&gt;Platt: Sequential Minimal Optimization&lt;/a&gt;：高效求解 SVM 对偶问题的经典算法。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.csie.ntu.edu.tw/~cjlin/papers/libsvm.pdf&quot;&gt;LIBSVM: A Library for Support Vector Machines&lt;/a&gt;：libsvm 的算法与工程实现论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/svm.html&quot;&gt;scikit-learn: Support Vector Machines&lt;/a&gt;：当前数学形式、复杂度、缩放与实现差异说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 下一篇预告&lt;/h2&gt;
&lt;p&gt;线性软间隔能容忍少量重叠，却仍只能画一条超平面。下一篇将把点积替换成核函数，手算 RBF 相似度如何让支持向量拼出弯曲边界，并看清 &lt;code&gt;gamma&lt;/code&gt; 与 &lt;code&gt;C&lt;/code&gt; 为什么必须联合选择。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>XGBoost 为何要看二阶梯度？从叶权重到正则化分裂增益</title><link>https://zwjcode.cn/blog/xgboost-second-order-regularized-split</link><guid isPermaLink="true">https://zwjcode.cn/blog/xgboost-second-order-regularized-split</guid><description>从普通 GBDT 只看纠错方向的不足出发，手算二阶泰勒目标、叶权重与分裂增益，解释 XGBoost 的正则化和工程接口。</description><pubDate>Sun, 23 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把梯度提升树写成逐步加法模型：每一轮计算损失对当前预测的负梯度，再训练一棵浅树去逼近它。这样回答了“下一步往哪里修正”，却留下三个工程问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;同样大小的一阶梯度，处在平坦区域和陡峭区域时，修正幅度应该一样吗？&lt;/li&gt;
&lt;li&gt;一片叶子最终该输出多大，能否由同一个目标直接算出？&lt;/li&gt;
&lt;li&gt;候选分裂虽然降低训练损失，但收益很小、叶子证据很少时，怎样让它不要发生？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;XGBoost（Extreme Gradient Boosting，极端梯度提升）用损失的二阶近似和显式树复杂度惩罚，把“叶子输出多少”和“这刀值不值得切”统一进一个可计算目标。本文只讲透这条推导，再把公式逐项映射到当前 Python API。&lt;/p&gt;
&lt;h2&gt;01 一阶方向为什么还不够？&lt;/h2&gt;
&lt;p&gt;设当前模型输出为 $F_{t-1}(x_i)$，第 $t$ 棵树的输出为 $f_t(x_i)$：&lt;/p&gt;
&lt;p&gt;$$
F_t(x_i)=F_{t-1}(x_i)+f_t(x_i)
$$&lt;/p&gt;
&lt;p&gt;普通梯度下降只看当前斜率。用地形类比：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;损失
 ▲              陡峭窄谷：斜率变化快
 │       \     /
 │        \___/
 │
 │  \____________  平缓长坡：斜率变化慢
 └────────────────────────────► 模型输出 F
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;二阶导数描述斜率本身变化多快，也就是局部曲率（Curvature）。若曲率大，相同的一阶梯度通常对应更保守的步长；若曲率小，则可以允许更大修正。XGBoost 用牛顿法（Newton Method）式的二阶信息决定叶值。&lt;/p&gt;
&lt;p&gt;这并不意味着它为每个神经网络参数构造巨大 Hessian 矩阵。对常见逐样本可加损失，只需为每个样本保存两个标量：&lt;/p&gt;
&lt;p&gt;$$
g_i=\frac{\partial L(y_i,F)}{\partial F}
$$&lt;/p&gt;
&lt;p&gt;$$
h_i=\frac{\partial^2 L(y_i,F)}{\partial F^2}
$$&lt;/p&gt;
&lt;p&gt;一批 $N$ 个样本中，&lt;code&gt;gradient&lt;/code&gt; 和 &lt;code&gt;hessian&lt;/code&gt; 都是形状 &lt;code&gt;[N]&lt;/code&gt; 的向量。&lt;/p&gt;
&lt;h2&gt;02 用二阶泰勒展开近似新一轮目标&lt;/h2&gt;
&lt;p&gt;第 $t$ 轮希望选择一棵树 $f_t$，最小化：&lt;/p&gt;
&lt;p&gt;$$
\mathcal{L}^{(t)}
=\sum_{i=1}^{N}L\left(y_i,F_{t-1}(x_i)+f_t(x_i)\right)
+\Omega(f_t)
$$&lt;/p&gt;
&lt;p&gt;在当前预测 $F_{t-1}(x_i)$ 附近，对新树输出做二阶泰勒展开：&lt;/p&gt;
&lt;p&gt;$$
L(y_i,F_{t-1}+f_t)
\approx
L(y_i,F_{t-1})+g_i f_t(x_i)+\frac12h_i f_t^2(x_i)
$$&lt;/p&gt;
&lt;p&gt;第一项在本轮固定，去掉后得到需要优化的近似目标：&lt;/p&gt;
&lt;p&gt;$$
\widetilde{\mathcal{L}}^{(t)}
=\sum_{i=1}^{N}
\left[g_i f_t(x_i)+\frac12h_i f_t^2(x_i)\right]
+\Omega(f_t)
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$g_i$：样本 $i$ 希望预测向哪个方向变化；&lt;/li&gt;
&lt;li&gt;$h_i$：该位置损失曲率，对修正大小提供尺度；&lt;/li&gt;
&lt;li&gt;$f_t(x_i)$：新树对样本 $i$ 的叶输出；&lt;/li&gt;
&lt;li&gt;$\Omega(f_t)$：树结构与叶权重的复杂度成本。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;03 树结构怎样进入公式？&lt;/h2&gt;
&lt;p&gt;假设第 $t$ 棵树有 $T$ 个叶子，$q(x_i)=j$ 表示样本 $i$ 被路由到叶子 $j$，该叶输出常数 $w_j$：&lt;/p&gt;
&lt;p&gt;$$
f_t(x_i)=w_{q(x_i)}
$$&lt;/p&gt;
&lt;p&gt;XGBoost 的经典复杂度项写成：&lt;/p&gt;
&lt;p&gt;$$
\Omega(f_t)=\gamma T+\frac12\lambda\sum_{j=1}^{T}w_j^2
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\gamma\ge0$：每增加一个叶子的结构成本；Python 参数常写成 &lt;code&gt;gamma&lt;/code&gt;，别名是 &lt;code&gt;min_split_loss&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;$\lambda\ge0$：叶权重的 L2 正则化；Python 参数是 &lt;code&gt;reg_lambda&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;$T$：叶子数量；&lt;/li&gt;
&lt;li&gt;$w_j$：第 $j$ 片叶子的输出，不是普通输入特征的线性系数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把落入叶子 $j$ 的样本集合记为 $I_j$，聚合一阶和二阶统计量：&lt;/p&gt;
&lt;p&gt;$$
G_j=\sum_{i\in I_j}g_i,qquad H_j=\sum_{i\in I_j}h_i
$$&lt;/p&gt;
&lt;p&gt;整棵树的近似目标可以按叶子拆开：&lt;/p&gt;
&lt;p&gt;$$
\widetilde{\mathcal{L}}^{(t)}
=\sum_{j=1}^{T}
\left[G_jw_j+\frac12(H_j+\lambda)w_j^2\right]
+\gamma T
$$&lt;/p&gt;
&lt;p&gt;到这里，树结构一旦固定，每个叶子的最优输出都能独立求解。&lt;/p&gt;
&lt;h2&gt;04 叶权重为什么是负梯度除以曲率？&lt;/h2&gt;
&lt;p&gt;对某一叶子的 $w_j$ 求导并令其为 0：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial\widetilde{\mathcal{L}}_j}{\partial w_j}
=G_j+(H_j+\lambda)w_j=0
$$&lt;/p&gt;
&lt;p&gt;因此：&lt;/p&gt;
&lt;p&gt;$$
w_j^*=-\frac{G_j}{H_j+\lambda}
$$&lt;/p&gt;
&lt;p&gt;这个式子同时编码方向、尺度和正则化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$G_j&gt;0$ 时，叶值为负，降低这一组样本的当前预测；&lt;/li&gt;
&lt;li&gt;$G_j&amp;#x3C;0$ 时，叶值为正，提高预测；&lt;/li&gt;
&lt;li&gt;$H_j$ 大表示局部曲率或总证据更大，分母增大；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reg_lambda&lt;/code&gt; 增大时，叶值连续向 0 收缩。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若再乘学习率 $\eta$，实际加入模型的是 $\eta w_j^*$：&lt;/p&gt;
&lt;p&gt;$$
F_t(x)=F_{t-1}(x)+\eta w_{q(x)}^*
$$&lt;/p&gt;
&lt;h2&gt;05 用四个样本手算叶值&lt;/h2&gt;
&lt;p&gt;沿用上一篇的数据：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x:                   [1, 2, 3, 4]
y:                   [3, 5, 4, 8]
current prediction:  [5, 5, 5, 5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用半平方误差：&lt;/p&gt;
&lt;p&gt;$$
L(y,F)=\frac12(y-F)^2
$$&lt;/p&gt;
&lt;p&gt;则：&lt;/p&gt;
&lt;p&gt;$$
g_i=F_i-y_i,qquad h_i=1
$$&lt;/p&gt;
&lt;p&gt;所以：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;g: [ 2, 0, 1,-3]
h: [ 1, 1, 1, 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;考虑候选分裂 $x\le2$，并设 $\lambda=1$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;左叶 I_L = {1,2}:  G_L = 2,  H_L = 2
右叶 I_R = {3,4}:  G_R = -2, H_R = 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最优叶值为：&lt;/p&gt;
&lt;p&gt;$$
w_L^*=-\frac{2}{2+1}=-\frac23
$$&lt;/p&gt;
&lt;p&gt;$$
w_R^*=-\frac{-2}{2+1}=\frac23
$$&lt;/p&gt;
&lt;p&gt;若学习率 $\eta=0.3$，本轮实际修正：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;η f_t(X):       [-0.2,-0.2, 0.2, 0.2]
new prediction: [ 4.8, 4.8, 5.2, 5.2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;左叶整体预测偏高，所以向下修；右叶总体偏低，所以向上修。&lt;code&gt;reg_lambda=0&lt;/code&gt; 时叶值会是 &lt;code&gt;[-1,1]&lt;/code&gt;，说明 L2 正则化把修正幅度从 1 收缩到了 $2/3$。&lt;/p&gt;
&lt;p&gt;左右叶值变成 $-2/(2+3)=-0.4$ 与 $2/(2+3)=0.4$。若 &lt;code&gt;η=0.3&lt;/code&gt;，实际只修正 &lt;code&gt;±0.12&lt;/code&gt;。训练更保守，但需要更多轮才能达到相近拟合程度。&lt;/p&gt;
&lt;h2&gt;06 一刀分裂的收益怎样计算？&lt;/h2&gt;
&lt;p&gt;把最优叶值代回目标，固定树结构的最优分数为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Score}
=-\frac12\sum_{j=1}^{T}\frac{G_j^2}{H_j+\lambda}+\gamma T
$$&lt;/p&gt;
&lt;p&gt;把父叶分成左、右两片后，目标下降量——也就是分裂增益（Split Gain）——为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Gain}
=\frac12\left[
\frac{G_L^2}{H_L+\lambda}
+\frac{G_R^2}{H_R+\lambda}
-\frac{(G_L+G_R)^2}{H_L+H_R+\lambda}
\right]-\gamma
$$&lt;/p&gt;
&lt;p&gt;继续计算 $x\le2$。父节点 $G=0,H=4$，设 $\lambda=1,\gamma=0.2$：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Gain}
=\frac12\left(\frac{2^2}{2+1}+\frac{(-2)^2}{2+1}-0\right)-0.2
\approx1.133
$$&lt;/p&gt;
&lt;p&gt;增益为正，这刀值得切。&lt;/p&gt;
&lt;p&gt;再看 $x\le3$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;左叶：G_L = 2 + 0 + 1 = 3, H_L = 3
右叶：G_R = -3,            H_R = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$$
\operatorname{Gain}
=\frac12\left(\frac{9}{4}+\frac{9}{2}\right)-0.2
=3.175
$$&lt;/p&gt;
&lt;p&gt;仅看该公式，&lt;code&gt;x&amp;#x3C;=3&lt;/code&gt; 的增益更大。但右叶只有 $H_R=1$。如果 &lt;code&gt;min_child_weight=2&lt;/code&gt;，这刀会被拒绝，因为新子节点的 Hessian 总和不足。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;候选阈值
   │
   ├── 任一子叶 H &amp;#x3C; min_child_weight ─► 拒绝
   │
   └── 两边证据足够
          │
          ├── Gain &amp;#x3C;= gamma ─► 拒绝
          └── Gain &gt; gamma  ─► 接受
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gamma&lt;/code&gt; 问“收益是否足以支付新增叶子的结构成本”，&lt;code&gt;min_child_weight&lt;/code&gt; 问“每个子叶是否有足够二阶证据”。二者不是同一个旋钮。&lt;/p&gt;
&lt;h2&gt;07 为什么 min_child_weight 不是固定样本数？&lt;/h2&gt;
&lt;p&gt;平方误差中 $h_i=1$，所以：&lt;/p&gt;
&lt;p&gt;$$
H_j=\sum_{i\in I_j}1=|I_j|
$$&lt;/p&gt;
&lt;p&gt;此时 &lt;code&gt;min_child_weight&lt;/code&gt; 数值上等于最小叶样本数。但对二分类对数损失，若模型输出 logit $F_i$、概率 $p_i=\sigma(F_i)$：&lt;/p&gt;
&lt;p&gt;$$
g_i=p_i-y_i
$$&lt;/p&gt;
&lt;p&gt;$$
h_i=p_i(1-p_i)
$$&lt;/p&gt;
&lt;p&gt;当 $p_i=0.5$ 时，$h_i=0.25$；当模型非常自信，如 $p_i=0.99$ 时，$h_i=0.0099$。因此 100 个样本的 Hessian 总和不一定是 100，也不一定比 20 个不确定样本更大。&lt;/p&gt;
&lt;p&gt;| 当前概率 $p$ | 单样本 Hessian $p(1-p)$ | 局部含义               |
| -----------: | ----------------------: | ---------------------- |
|         0.50 |                  0.2500 | 曲率最大，决策边界附近 |
|         0.90 |                  0.0900 | 已较自信               |
|         0.99 |                  0.0099 | 极度自信，曲率很小     |&lt;/p&gt;
&lt;p&gt;所以在分类任务中把 &lt;code&gt;min_child_weight=10&lt;/code&gt; 解读为“每叶至少 10 个样本”是错误的。应结合目标函数、样本权重和实际叶统计量理解。&lt;/p&gt;
&lt;h2&gt;08 从公式到一轮树生长伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input:
    X [N,D], y [N]
    current margin F [N]

compute per-row statistics:
    g[i] = d loss(y[i], F[i]) / d F[i]       # [N]
    h[i] = d²loss(y[i], F[i]) / d F[i]²      # [N]

start with one root containing all row indices

for each growable leaf:
    for each sampled feature:
        scan candidate histogram boundaries:
            accumulate G_L, H_L
            derive G_R, H_R from parent totals
            reject if either H &amp;#x3C; min_child_weight
            compute regularized Gain
    choose the candidate with maximum positive Gain
    split only if Gain passes gamma and other constraints

for each final leaf j:
    weight[j] = -G_j / (H_j + reg_lambda)

F[i] += learning_rate * weight[leaf_of(i)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入矩阵仍是 &lt;code&gt;[N,D]&lt;/code&gt;，但建树时不再反复携带原始标签；核心统计变成每行的 &lt;code&gt;(g_i,h_i)&lt;/code&gt;，候选箱只需聚合 &lt;code&gt;(G,H)&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;09 用 NumPy验证公式&lt;/h2&gt;
&lt;p&gt;下面代码计算本文两个候选阈值的正则化增益：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

x = np.array([1.0, 2.0, 3.0, 4.0])  # [N]
y = np.array([3.0, 5.0, 4.0, 8.0])  # [N]
margin = np.full_like(y, 5.0)         # [N]

gradient = margin - y                 # [N]
hessian = np.ones_like(y)             # [N]
reg_lambda = 1.0
gamma = 0.2


def leaf_weight(G, H):
    return -G / (H + reg_lambda)


def split_gain(left):
    right = ~left
    G_left, H_left = gradient[left].sum(), hessian[left].sum()
    G_right, H_right = gradient[right].sum(), hessian[right].sum()
    G_parent = G_left + G_right
    H_parent = H_left + H_right

    gain = 0.5 * (
        G_left**2 / (H_left + reg_lambda)
        + G_right**2 / (H_right + reg_lambda)
        - G_parent**2 / (H_parent + reg_lambda)
    ) - gamma
    return gain, leaf_weight(G_left, H_left), leaf_weight(G_right, H_right)


for threshold in [2.5, 3.5]:
    gain, left_weight, right_weight = split_gain(x &amp;#x3C;= threshold)
    print(threshold, gain, left_weight, right_weight)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预期结果约为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;2.5  1.133  -0.667   0.667
3.5  3.175  -0.750   1.500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若手算与代码不一致，最常见原因是把梯度写成 &lt;code&gt;y - margin&lt;/code&gt;、遗漏公式中的 $1/2$，或在增益中多减/少减一次 &lt;code&gt;gamma&lt;/code&gt;。只要梯度符号约定一致也能实现算法，但叶值公式必须同步改变；不要混用两套符号。&lt;/p&gt;
&lt;h2&gt;10 用当前 XGBoost Python API 落地&lt;/h2&gt;
&lt;p&gt;当前 XGBoost 3.3 的 scikit-learn 接口把提升轮数写成 &lt;code&gt;n_estimators&lt;/code&gt;，直方图建树用 &lt;code&gt;tree_method=&apos;hist&apos;&lt;/code&gt;。早停参数放在估计器构造中，验证数据通过 &lt;code&gt;fit(..., eval_set=...)&lt;/code&gt; 提供：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
import xgboost as xgb
from sklearn.metrics import root_mean_squared_error
from sklearn.model_selection import train_test_split

X_train, X_temp, y_train, y_temp = train_test_split(
    X, y, test_size=0.30, random_state=42
)
X_val, X_test, y_val, y_test = train_test_split(
    X_temp, y_temp, test_size=0.50, random_state=42
)

model = xgb.XGBRegressor(
    objective=&apos;reg:squarederror&apos;,
    n_estimators=3000,
    learning_rate=0.03,
    max_depth=4,
    min_child_weight=5.0,
    gamma=0.1,
    reg_lambda=1.0,
    reg_alpha=0.0,
    subsample=0.8,
    colsample_bytree=0.8,
    tree_method=&apos;hist&apos;,
    eval_metric=&apos;rmse&apos;,
    early_stopping_rounds=100,
    n_jobs=-1,
    random_state=42,
)

model.fit(
    X_train,              # [num_train,D]
    y_train,              # [num_train]
    eval_set=[(X_val, y_val)],
    verbose=False,
)

prediction = model.predict(X_test)  # [num_test]
print(&apos;best iteration:&apos;, model.best_iteration)
print(&apos;best validation score:&apos;, model.best_score)
print(&apos;test RMSE:&apos;, root_mean_squared_error(y_test, prediction))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前 scikit-learn 包装器在启用早停后，默认 &lt;code&gt;predict&lt;/code&gt; 会使用最佳迭代范围。若显式检查某段树，应使用半开区间：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;best_prediction = model.predict(
    X_test,
    iteration_range=(0, model.best_iteration + 1),
)

history = model.evals_result()
validation_rmse = np.asarray(history[&apos;validation_0&apos;][&apos;rmse&apos;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个参数与公式的对应关系：&lt;/p&gt;
&lt;p&gt;| Python 参数        | 公式或数据流  | 作用                           |
| ------------------ | ------------- | ------------------------------ |
| &lt;code&gt;learning_rate&lt;/code&gt;    | $\eta$        | 缩小整棵新树的输出             |
| &lt;code&gt;reg_lambda&lt;/code&gt;       | $\lambda$     | L2 收缩叶值并降低分裂收益      |
| &lt;code&gt;reg_alpha&lt;/code&gt;        | 叶值 L1 惩罚  | 软阈值化聚合梯度，使叶值更稀疏 |
| &lt;code&gt;gamma&lt;/code&gt;            | $\gamma$      | 要求分裂提供最低损失下降       |
| &lt;code&gt;min_child_weight&lt;/code&gt; | 子叶 $H$ 下限 | 阻止二阶证据不足的叶子         |
| &lt;code&gt;subsample&lt;/code&gt;        | 行采样        | 降低方差，也减少每轮数据       |
| &lt;code&gt;colsample_bytree&lt;/code&gt; | 列采样        | 降低树间相关性和计算量         |
| &lt;code&gt;max_depth&lt;/code&gt;        | 树结构上限    | 控制单轮交互复杂度与内存       |&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tree_method=&apos;hist&apos;&lt;/code&gt; 先对特征分箱并聚合梯度直方图，通常是当前通用起点；GPU 训练应显式评估 &lt;code&gt;device=&apos;cuda&apos;&lt;/code&gt; 与输入所在设备，避免 CPU 数据隐式转换成为瓶颈。&lt;/p&gt;
&lt;h2&gt;11 早停、测试集与模型保存不要混在一起&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练集 ─► 学习树结构、叶值
验证集 ─► 监控 eval_metric、确定 best_iteration
测试集 ─► 全部选择冻结后的最终一次评估
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若 &lt;code&gt;eval_set&lt;/code&gt; 放入测试集，早停轮数就已经对测试数据发生了拟合。模型即使没有直接用测试标签计算树梯度，测试指标也参与了方案选择。&lt;/p&gt;
&lt;p&gt;长期保存应使用 XGBoost 的稳定模型表示：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;model.save_model(&apos;house-price-xgb.ubj&apos;)
# 或使用可读 JSON
model.save_model(&apos;house-price-xgb.json&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JSON/UBJSON 保存树与目标等模型状态；&lt;code&gt;pickle&lt;/code&gt;、&lt;code&gt;joblib&lt;/code&gt; 更接近版本相关的内存快照，不适合作为跨版本长期归档。部署时还要单独版本化特征顺序、类别编码、缺失值语义和全部训练配置。&lt;/p&gt;
&lt;h2&gt;12 工程上为什么“极端”不只是一条公式？&lt;/h2&gt;
&lt;p&gt;XGBoost 的目标推导解释了统计量怎样计算，实际可扩展性还来自系统设计：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;直方图聚合。&lt;/strong&gt; 把连续特征压到有限箱，在每箱累加 $G,H$，减少候选阈值和内存访问。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稀疏与缺失路由。&lt;/strong&gt; 每个分裂学习缺失值默认方向；推理必须复用同一规则。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;行列采样。&lt;/strong&gt; &lt;code&gt;subsample&lt;/code&gt; 和 &lt;code&gt;colsample_*&lt;/code&gt; 同时影响统计精度、相关性与速度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存与并行。&lt;/strong&gt; 同层候选统计可并行，但提升轮次仍存在顺序依赖。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;外存与分布式接口。&lt;/strong&gt; 数据大于单机内存时可以换数据结构和执行后端，但数据划分与泄漏原则不变。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些优化让建树更快，不会自动修复错误标签、时间穿越、离线线上特征不一致或错误评价指标。&lt;/p&gt;
&lt;h2&gt;13 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把 $g_i$ 当负梯度却仍使用 $w=-G/(H+\lambda)$。&lt;/strong&gt; 本文的 $g_i$ 定义为正的一阶导数；若改成负梯度，叶值符号也要同步调整。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;min_child_weight&lt;/code&gt; 当最小样本数。&lt;/strong&gt; 只有平方误差且样本权重为 1 时二者数值相同；分类中它约束 Hessian 总和。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为 &lt;code&gt;gamma&lt;/code&gt; 会直接缩小已有叶值。&lt;/strong&gt; &lt;code&gt;gamma&lt;/code&gt; 主要收取新增叶子的结构成本；&lt;code&gt;reg_lambda&lt;/code&gt; 才进入叶值分母。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高学习率配深树。&lt;/strong&gt; 二阶叶值也可能过拟合；牛顿步不是自动安全步，仍需收缩、结构限制和验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对时间数据随机早停。&lt;/strong&gt; &lt;code&gt;eval_set&lt;/code&gt; 必须模拟未来部署方向，不能让未来样本影响最佳轮数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别标签或特征顺序漂移。&lt;/strong&gt; 训练列 &lt;code&gt;[age,income]&lt;/code&gt; 在服务中变成 &lt;code&gt;[income,age]&lt;/code&gt; 仍能输出数字，却完全错误；保持特征名验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看内置 importance。&lt;/strong&gt; &lt;code&gt;weight&lt;/code&gt;、&lt;code&gt;gain&lt;/code&gt;、&lt;code&gt;cover&lt;/code&gt; 含义不同；相关特征和高基数仍会误导，应在验证集补充置换或局部分析。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;交叉验证与模型内部都开满线程。&lt;/strong&gt; 外层并行乘内部 &lt;code&gt;n_jobs=-1&lt;/code&gt; 会导致 CPU 和内存争用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;直接 pickle 长期归档。&lt;/strong&gt; 保存稳定模型格式，并记录库版本、参数、特征 schema 与最佳轮数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;错误理解不平衡参数。&lt;/strong&gt; &lt;code&gt;scale_pos_weight&lt;/code&gt; 改变梯度统计和优化重点，不等同于调预测阈值；验证概率、排序和最终决策要分开。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小诊断代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == 2 and y_train.ndim == 1
assert X_train.shape[0] == y_train.shape[0]
assert model.n_features_in_ == X_train.shape[1]
assert model.best_iteration &amp;#x3C; model.n_estimators
assert np.isfinite(prediction).all()

booster = model.get_booster()
print(&apos;boosted rounds:&apos;, booster.num_boosted_rounds())
print(&apos;feature names:&apos;, booster.feature_names)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若训练和验证指标都不动，检查目标/指标是否匹配、标签是否近似常数、&lt;code&gt;min_child_weight&lt;/code&gt; 与 &lt;code&gt;gamma&lt;/code&gt; 是否过大；若训练迅速变好而验证恶化，依次减小深度、降低学习率、增大叶子约束并检查泄漏，不要只增加 &lt;code&gt;reg_lambda&lt;/code&gt; 期待包治百病。&lt;/p&gt;
&lt;h2&gt;14 失败场景与相近方法边界&lt;/h2&gt;
&lt;p&gt;| 方法              | 使用的局部信息     | 树的评分与约束             | 典型定位               |
| ----------------- | ------------------ | -------------------------- | ---------------------- |
| 经典 GBDT         | 一阶负梯度         | 依实现的叶值与树约束       | 教学清晰、小中型数据   |
| XGBoost           | 一阶 + 二阶统计    | 叶权重、结构成本、采样统一 | 强表格基线与可扩展训练 |
| sklearn HistGBDT  | 一阶/二阶损失实现  | 直方图、L2、早停与约束     | sklearn 原生中大型表格 |
| 随机森林          | 不显式使用损失梯度 | Bootstrap + 特征子采样     | 稳健、少调参、易并行   |
| 线性/广义线性模型 | 参数梯度或闭式结构 | 参数正则化                 | 高维稀疏、解释与外推   |&lt;/p&gt;
&lt;p&gt;XGBoost 仍有明确局限：树模型对训练范围外的连续趋势不擅长平滑外推；大量高基数类别若编码不当会制造碎片；图像、文本和语音的原始表示通常需要专门表征学习；高度漂移的数据会让任何离线分裂增益迅速过期；极低延迟设备也可能无法承受数千棵树的模型体积与分支访问。&lt;/p&gt;
&lt;h2&gt;15 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;XGBoost 用每个样本的一阶梯度 $g_i$ 表示修正方向，用二阶导数 $h_i$ 表示局部曲率。&lt;/li&gt;
&lt;li&gt;固定树结构后，叶子的最优输出是 $-G/(H+\lambda)$；&lt;code&gt;reg_lambda&lt;/code&gt; 通过分母收缩叶值。&lt;/li&gt;
&lt;li&gt;分裂增益比较左右叶与父叶的正则化得分，再减去 &lt;code&gt;gamma&lt;/code&gt; 的结构成本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;min_child_weight&lt;/code&gt; 限制子叶 Hessian 总和，不普遍等于最小样本数。&lt;/li&gt;
&lt;li&gt;二阶优化不会替代数据划分、早停和部署验证；学习率、深度、采样、正则化仍需联合选择。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;16 思考题与小练习&lt;/h2&gt;
&lt;p&gt;对候选分裂 &lt;code&gt;x&amp;#x3C;=2&lt;/code&gt;，分别令 &lt;code&gt;reg_lambda=0&lt;/code&gt; 与 &lt;code&gt;reg_lambda=4&lt;/code&gt;，计算左右叶值和增益（保持 &lt;code&gt;gamma=0.2&lt;/code&gt;）。解释为什么 &lt;code&gt;reg_lambda&lt;/code&gt; 同时缩小叶输出和候选分裂收益。&lt;/p&gt;
&lt;p&gt;一片候选叶有 20 个样本，其中 10 个的预测概率为 0.5，另 10 个为 0.99。忽略样本权重，计算 $H=\sum p_i(1-p_i)$。若 &lt;code&gt;min_child_weight=3&lt;/code&gt;，该叶是否满足约束？&lt;/p&gt;
&lt;p&gt;固定数据划分和随机种子，单独改变 &lt;code&gt;max_depth&lt;/code&gt;、&lt;code&gt;min_child_weight&lt;/code&gt;、&lt;code&gt;gamma&lt;/code&gt;、&lt;code&gt;reg_lambda&lt;/code&gt;。同时记录最佳轮数、训练/验证差距、叶数、模型大小与 P99 延迟，说明哪些参数主要改变结构，哪些主要改变叶值。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/2939672.2939785&quot;&gt;Chen &amp;#x26; Guestrin: XGBoost—A Scalable Tree Boosting System&lt;/a&gt;：XGBoost 的正则化目标、稀疏感知算法与系统设计论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1214/aos/1013203451&quot;&gt;Friedman: Greedy Function Approximation—A Gradient Boosting Machine&lt;/a&gt;：函数空间梯度提升的理论基础。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2017/hash/6449f44a102fde848669bdd9eb6b76fa-Abstract.html&quot;&gt;Ke et al.: LightGBM—A Highly Efficient Gradient Boosting Decision Tree&lt;/a&gt;：直方图、单边梯度采样与叶子优先生长的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://xgboost.readthedocs.io/en/stable/tutorials/model.html&quot;&gt;XGBoost: Introduction to Boosted Trees&lt;/a&gt;：二阶目标、叶权重和结构评分的官方推导。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://xgboost.readthedocs.io/en/stable/python/python_api.html&quot;&gt;XGBoost: Python API Reference&lt;/a&gt;：当前估计器、早停、预测区间与模型保存接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;17 下一篇预告&lt;/h2&gt;
&lt;p&gt;树模型通过不断切分坐标轴构造非线性边界。下一篇将回到另一类经典几何方法：支持向量机为什么不只找一条能分开的直线，而要最大化离最近样本的间隔，并如何用核技巧得到弯曲边界。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>后一棵树怎样修正前一棵？从残差到梯度提升树</title><link>https://zwjcode.cn/blog/gradient-boosting-residual-functional-gradient</link><guid isPermaLink="true">https://zwjcode.cn/blog/gradient-boosting-residual-functional-gradient</guid><description>从随机森林无法继续降低共同偏差出发，手算两轮残差拟合，推导负梯度与逐步加法模型，并实现可诊断的梯度提升回归。</description><pubDate>Sun, 23 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的随机森林让许多深树并行生长，再平均它们的预测。它擅长降低单棵树的高方差，但所有树若都漏掉同一种规律，平均只会稳定地保留这份共同偏差。&lt;/p&gt;
&lt;p&gt;例如房价真实关系包含一个细小的“学区边界”，而每棵树都因叶子限制没有学到它。再增加 1,000 棵相似的树，遗漏不会自动消失。我们需要让新树看到旧模型&lt;strong&gt;还错在哪里&lt;/strong&gt;，而不是从头回答同一个问题。&lt;/p&gt;
&lt;p&gt;梯度提升（Gradient Boosting）采取顺序纠错：当前模型先预测，下一棵浅树拟合损失最希望修正的方向，再把这份修正加回模型。本文只讲透这条数据流，以及它为什么从“拟合残差”推广为“拟合负梯度”。&lt;/p&gt;
&lt;h2&gt;01 从并行平均转向顺序加法&lt;/h2&gt;
&lt;p&gt;随机森林中的树可以独立训练：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;同一训练集
  ├── 随机数据/特征 ─► Tree 1 ─┐
  ├── 随机数据/特征 ─► Tree 2 ─┼─► 平均
  └── 随机数据/特征 ─► Tree M ─┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;梯度提升树（Gradient Boosted Decision Trees，GBDT）则有严格的先后依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D] ─► 当前模型 F₀ ─► prediction₀ [N]
                            │ 与 y [N] 比较
                            ▼
                      correction₁ [N]
                            │ 拟合
                            ▼
                         Tree 1
                            │ 加到 F₀
                            ▼
             F₁ = F₀ + η Tree 1
                            │
                     重复 M 轮
                            ▼
             F_M(x) = F₀(x) + ηΣ Tree_m(x)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第 $m$ 轮的模型写成逐步加法模型（Stage-wise Additive Model）：&lt;/p&gt;
&lt;p&gt;$$
F_m(x)=F_{m-1}(x)+\eta h_m(x)
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$F_{m-1}$：前 $m-1$ 轮树的总和；&lt;/li&gt;
&lt;li&gt;$h_m$：第 $m$ 棵回归树输出的修正值；&lt;/li&gt;
&lt;li&gt;$\eta\in(0,1]$：学习率（Learning Rate），也叫收缩系数；&lt;/li&gt;
&lt;li&gt;$F_m(x)$：加入新树后的预测。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里每棵树通常很浅。单棵树只负责一小块可解释的修正，复杂函数由许多小步累积出来。&lt;/p&gt;
&lt;h2&gt;02 为什么平方误差下会拟合残差？&lt;/h2&gt;
&lt;p&gt;先考虑回归和半平方误差：&lt;/p&gt;
&lt;p&gt;$$
L(y_i,F(x_i))=\frac12\left(y_i-F(x_i)\right)^2
$$&lt;/p&gt;
&lt;p&gt;定义当前残差：&lt;/p&gt;
&lt;p&gt;$$
r_{im}=y_i-F_{m-1}(x_i)
$$&lt;/p&gt;
&lt;p&gt;若下一棵树能精确输出 $r_{im}$，且 $\eta=1$，新预测就会变成真实值：&lt;/p&gt;
&lt;p&gt;$$
F_m(x_i)=F_{m-1}(x_i)+r_{im}=y_i
$$&lt;/p&gt;
&lt;p&gt;树无法逐点记住所有残差时，会把特征空间切成若干叶子，在每个叶子输出相近残差的平均值。因此它学到的是：“什么样的样本，目前被系统性低估或高估？”&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;当前预测太高：r &amp;#x3C; 0 ─► 新树输出负修正 ─► 预测下降
当前预测正确：r ≈ 0 ─► 新树输出接近 0 ─► 基本不动
当前预测太低：r &gt; 0 ─► 新树输出正修正 ─► 预测上升
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;03 用四个样本手算两轮提升&lt;/h2&gt;
&lt;p&gt;给定一个特征 $x$ 和四个回归目标：&lt;/p&gt;
&lt;p&gt;| 样本 $i$ | $x_i$ | $y_i$ |
| -------: | ----: | ----: |
|        1 |     1 |     3 |
|        2 |     2 |     5 |
|        3 |     3 |     4 |
|        4 |     4 |     8 |&lt;/p&gt;
&lt;h3&gt;第 0 轮：从常数预测开始&lt;/h3&gt;
&lt;p&gt;平方误差下最好的常数是目标均值：&lt;/p&gt;
&lt;p&gt;$$
F_0(x)=\bar y=\frac{3+5+4+8}{4}=5
$$&lt;/p&gt;
&lt;p&gt;因此：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;y:              [ 3, 5, 4, 8 ]
prediction_0:   [ 5, 5, 5, 5 ]
residual_1:     [-2, 0,-1, 3 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初始均方误差为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{MSE}_0=\frac{(-2)^2+0^2+(-1)^2+3^2}{4}=3.5
$$&lt;/p&gt;
&lt;h3&gt;第 1 轮：用树桩拟合残差&lt;/h3&gt;
&lt;p&gt;假设树桩选择 $x\le2$：左叶残差均值为 $(-2+0)/2=-1$，右叶为 $(-1+3)/2=1$。&lt;/p&gt;
&lt;p&gt;令学习率 $\eta=0.5$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;h_1(x):         [-1,-1, 1, 1]
η h_1(x):       [-0.5,-0.5, 0.5, 0.5]
prediction_1:   [ 4.5, 4.5, 5.5, 5.5]
residual_2:     [-1.5, 0.5,-1.5, 2.5]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;新均方误差为 $2.75$。第一棵树没有直接预测房价，它只把低 $x$ 区域整体向下修正、高 $x$ 区域整体向上修正。&lt;/p&gt;
&lt;h3&gt;第 2 轮：只看剩下的错误&lt;/h3&gt;
&lt;p&gt;第二棵树拟合 &lt;code&gt;residual_2&lt;/code&gt;。假设它选择 $x\le3$：&lt;/p&gt;
&lt;p&gt;$$
h_2(x\le3)=\frac{-1.5+0.5-1.5}{3}\approx-0.833
$$&lt;/p&gt;
&lt;p&gt;$$
h_2(x&gt;3)=2.5
$$&lt;/p&gt;
&lt;p&gt;乘上 $\eta=0.5$ 后：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;η h_2(x):       [-0.417,-0.417,-0.417, 1.250]
prediction_2:   [ 4.083, 4.083, 5.083, 6.750]
residual_3:     [-1.083, 0.917,-1.083, 1.250]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;均方误差降到约 $1.19$。两棵简单树叠加后，已经能表达任何一棵树桩单独无法表达的三级预测。&lt;/p&gt;
&lt;p&gt;第一轮预测会变成 &lt;code&gt;[4,4,6,6]&lt;/code&gt;，MSE 为 2，比 &lt;code&gt;η=0.5&lt;/code&gt; 的 2.75 更低。但单步下降更多不保证验证误差更好；较小学习率让后续树有机会用更细的步骤修正，通常需要更多轮数。&lt;/p&gt;
&lt;h2&gt;04 “负梯度”怎样统一不同损失？&lt;/h2&gt;
&lt;p&gt;残差只在平方误差下恰好成立。若目标改成稳健回归的绝对误差、二分类的对数损失，临时目标该是什么？&lt;/p&gt;
&lt;p&gt;把每个训练点当前的预测 $F_{m-1}(x_i)$ 看成一个可调整变量。损失对它的导数是最陡上升方向，所以最陡下降方向为：&lt;/p&gt;
&lt;p&gt;$$
g_{im}
=-left.
\frac{\partial L(y_i,F(x_i))}{\partial F(x_i)}
\right|&lt;em&gt;{F=F&lt;/em&gt;{m-1}}
$$&lt;/p&gt;
&lt;p&gt;这叫伪残差（Pseudo-residual）或负梯度。第 $m$ 棵树拟合训练对：&lt;/p&gt;
&lt;p&gt;$$
{(x_i,g_{im})}_{i=1}^{N}
$$&lt;/p&gt;
&lt;p&gt;对半平方误差：&lt;/p&gt;
&lt;p&gt;$$
-\frac{\partial}{\partial F}\frac12(y-F)^2=y-F
$$&lt;/p&gt;
&lt;p&gt;负梯度正好就是普通残差。于是“拟合残差”不是一条孤立技巧，而是函数空间梯度下降（Gradient Descent in Function Space）在平方误差下的具体形式。&lt;/p&gt;
&lt;p&gt;| 损失         | 当前模型最关注的方向         | 直觉                   |
| ------------ | ---------------------------- | ---------------------- |
| 平方误差     | $y-F$                        | 大残差得到更大修正     |
| 绝对误差     | 残差符号（不可导点取次梯度） | 降低异常值支配         |
| 二元对数损失 | $y-p$（以 logit 为模型输出） | 修正预测概率与标签的差 |&lt;/p&gt;
&lt;h2&gt;05 训练与推理的完整伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input:
    X [N,D], y [N]
    rounds M, learning rate η

initialize F_0 as the best constant for the loss

for m = 1 ... M:
    prediction = F_{m-1}(X)                  # [N]
    pseudo_residual = -dL(y, prediction)
                    / d prediction           # [N]
    fit a shallow regression tree h_m:
        X [N,D] -&gt; pseudo_residual [N]
    optionally solve the best value per leaf
    F_m(x) = F_{m-1}(x) + η h_m(x)

return F_M
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;推理时没有残差、标签或反向传播：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X_query [Q,D]
  ├── F_0                         [Q]
  ├── η h_1(X_query)              [Q]
  ├── η h_2(X_query)              [Q]
  └── ... + η h_M(X_query)        [Q]
               │ element-wise sum
               ▼
          prediction [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这也解释了为什么提升树难以像随机森林那样并行训练，却可以在树建好后批量并行计算部分节点和样本。&lt;/p&gt;
&lt;h2&gt;06 不依赖黑盒，写出最小平方误差提升&lt;/h2&gt;
&lt;p&gt;下面只用 NumPy 写两轮“决策树桩 + 残差”，把关键状态全部暴露出来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([[1.0], [2.0], [3.0], [4.0]])  # [N=4,D=1]
y = np.array([3.0, 5.0, 4.0, 8.0])          # [N]
learning_rate = 0.5


def fit_stump(feature, target):
    &quot;&quot;&quot;返回使叶内平方误差最小的阈值和两个叶值。&quot;&quot;&quot;
    candidates = (feature[:-1] + feature[1:]) / 2
    best = None

    for threshold in candidates:
        left = feature &amp;#x3C;= threshold
        right = ~left
        left_value = target[left].mean()
        right_value = target[right].mean()
        output = np.where(left, left_value, right_value)
        squared_error = np.sum((target - output) ** 2)

        if best is None or squared_error &amp;#x3C; best[&apos;error&apos;]:
            best = {
                &apos;threshold&apos;: threshold,
                &apos;left_value&apos;: left_value,
                &apos;right_value&apos;: right_value,
                &apos;error&apos;: squared_error,
            }
    return best


prediction = np.full_like(y, y.mean())       # [N]
trees = []

for round_id in range(2):
    negative_gradient = y - prediction       # [N]
    stump = fit_stump(X[:, 0], negative_gradient)
    correction = np.where(
        X[:, 0] &amp;#x3C;= stump[&apos;threshold&apos;],
        stump[&apos;left_value&apos;],
        stump[&apos;right_value&apos;],
    )                                        # [N]
    prediction += learning_rate * correction
    trees.append(stump)

    mse = np.mean((y - prediction) ** 2)
    print(round_id + 1, stump, prediction, mse)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实实现需要处理多特征、深树、任意损失、叶值优化、采样和高效直方图，但训练语义没有改变：先计算当前模型的方向，再拟合一棵树并缩小后相加。&lt;/p&gt;
&lt;h2&gt;07 用当前 scikit-learn API 落地并观察每一轮&lt;/h2&gt;
&lt;p&gt;scikit-learn 1.9 的 &lt;code&gt;GradientBoostingRegressor&lt;/code&gt; 是经典的精确梯度提升实现。下面显式留出测试集；估计器内部再从训练数据中留出早停验证集：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import root_mean_squared_error
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.20, random_state=42
)

model = GradientBoostingRegressor(
    loss=&apos;squared_error&apos;,
    learning_rate=0.05,
    n_estimators=1000,
    max_depth=2,
    min_samples_leaf=5,
    subsample=0.8,
    validation_fraction=0.15,
    n_iter_no_change=30,
    tol=1e-5,
    random_state=42,
)
model.fit(X_train, y_train)

test_prediction = model.predict(X_test)  # [num_test]
print(&apos;trees actually used:&apos;, model.n_estimators_)
print(&apos;test RMSE:&apos;, root_mean_squared_error(y_test, test_prediction))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键参数不是彼此独立的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n_estimators&lt;/code&gt;：最大提升轮数；回归的每轮通常增加一棵树；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;learning_rate&lt;/code&gt;：每棵树加入总模型前的缩放；更小通常需要更多树；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_depth&lt;/code&gt;：单棵修正树的交互复杂度；深度 2 最多直接表达二阶特征交互；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;min_samples_leaf&lt;/code&gt;：阻止极小叶对少数样本给出激进修正；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;subsample &amp;#x3C; 1&lt;/code&gt;：每轮用部分训练样本，形成随机梯度提升并降低相关性；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n_iter_no_change&lt;/code&gt;：内部验证损失连续若干轮没有足够改善就停止。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;使用 &lt;code&gt;staged_predict&lt;/code&gt; 可以看到模型随树数增加的验证轨迹：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;validation_rmse = []

for round_id, prediction in enumerate(model.staged_predict(X_test), start=1):
    validation_rmse.append(root_mean_squared_error(y_test, prediction))

best_round = int(np.argmin(validation_rmse)) + 1
print(&apos;best observed round:&apos;, best_round)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段示例为了展示 API 使用 &lt;code&gt;X_test&lt;/code&gt; 画轨迹；正式项目中应改用验证集，测试集只能在轮数和全部超参数冻结后评估一次。&lt;/p&gt;
&lt;h2&gt;08 数据变大时为什么常用直方图提升？&lt;/h2&gt;
&lt;p&gt;精确枚举许多连续特征的候选阈值代价较高。直方图梯度提升（Histogram-based Gradient Boosting）先把连续值分箱，再在箱边界上累计梯度统计量。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原始特征值：0.13 0.18 0.19 0.44 0.47 0.91 ...
                 │ quantile / histogram bins
                 ▼
箱编号：      0    0    0    1    1    3  ...
                 │ 每箱聚合梯度
                 ▼
            只比较有限箱边界
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;scikit-learn 1.9 对中大型数据推荐 &lt;code&gt;HistGradientBoostingRegressor&lt;/code&gt;；文档给出的经验起点是 $N\ge10,000$。它使用 &lt;code&gt;max_iter&lt;/code&gt; 表示轮数，并原生处理数值缺失值：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.ensemble import HistGradientBoostingRegressor

hist_model = HistGradientBoostingRegressor(
    loss=&apos;squared_error&apos;,
    learning_rate=0.05,
    max_iter=1000,
    max_leaf_nodes=15,
    min_samples_leaf=20,
    l2_regularization=1.0,
    early_stopping=True,
    validation_fraction=0.15,
    n_iter_no_change=30,
    random_state=42,
)
hist_model.fit(X_train, y_train)

print(&apos;iterations:&apos;, hist_model.n_iter_)
print(&apos;prediction shape:&apos;, hist_model.predict(X_test).shape)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原生接收 &lt;code&gt;NaN&lt;/code&gt; 只表示算法能学习缺失值路由，并不证明缺失机制合理。训练与线上缺失率差异、把“未知”编码成 0、类别列类型漂移，仍会让模型失效。&lt;/p&gt;
&lt;h2&gt;09 学习率、树深与轮数如何一起控制容量？&lt;/h2&gt;
&lt;p&gt;| 旋钮               | 调大后的直接变化     | 常见风险                     |
| ------------------ | -------------------- | ---------------------------- |
| &lt;code&gt;learning_rate&lt;/code&gt;    | 单棵树修正更强       | 很快追逐噪声、验证损失反弹   |
| 树深 / 叶数        | 单轮可表达更复杂交互 | 小叶、不稳定、内存与延迟增加 |
| 提升轮数           | 累积更多修正         | 训练损失继续降而验证损失升   |
| &lt;code&gt;min_samples_leaf&lt;/code&gt; | 每个叶子证据更多     | 过大时遗漏局部结构           |
| &lt;code&gt;subsample&lt;/code&gt; 降低   | 每轮数据更随机       | 太低时单树方向噪声过大       |&lt;/p&gt;
&lt;p&gt;最稳妥的流程是：先用较浅的树和较小学习率，给足较大的最大轮数，再用独立验证信号早停。不要只按“100 棵树是默认值”决定训练长度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;training loss   ╲________________
validation loss ╲______╱
                       ▲
                  最佳轮数附近
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;10 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把每轮目标固定成原始标签。&lt;/strong&gt; 第二棵树开始必须依赖当前预测的负梯度；否则只是训练了很多重复树。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为残差适用于任意损失。&lt;/strong&gt; 非平方误差要按损失对当前输出求负梯度，并可能重新求每个叶子的最佳值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用训练损失选择树数。&lt;/strong&gt; 训练损失通常随轮数继续下降；树数应由验证曲线和业务指标决定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同时使用深树、高学习率和很多轮。&lt;/strong&gt; 三个容量旋钮会叠加，常导致快速过拟合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;早停验证集发生泄漏。&lt;/strong&gt; 时间、患者或用户分组任务不能由内部随机切分代替结构化验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把特征重要性当因果解释。&lt;/strong&gt; 分裂增益只描述模型使用了什么；相关特征、高基数和泄漏列都会误导。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;线上漏加部分树。&lt;/strong&gt; 推理模型必须保存初始常数、树顺序、学习率和实际最佳轮数，任一不一致都会改变输出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只监控平均延迟。&lt;/strong&gt; 提升树按顺序累加大量成员；同时测模型大小、P50/P99 延迟和峰值内存。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小数值检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == 2 and y_train.ndim == 1
assert X_train.shape[0] == y_train.shape[0]
assert np.isfinite(y_train).all()
assert model.n_estimators_ &amp;#x3C;= model.n_estimators
assert np.isfinite(model.predict(X_test)).all()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若第一棵树后训练损失完全不变，检查目标是否为常数、特征是否都缺失、&lt;code&gt;min_samples_leaf&lt;/code&gt; 是否大到无法分裂；若训练损失下降而验证损失从第一轮就上升，优先检查数据划分、泄漏、分布偏移和目标定义，不要先堆更多树。&lt;/p&gt;
&lt;h2&gt;11 它与相近方法有什么区别？&lt;/h2&gt;
&lt;p&gt;| 方法             | 成员关系       | 每个成员学什么       | 主要改善             |
| ---------------- | -------------- | -------------------- | -------------------- |
| 单棵决策树       | 无集成         | 直接拟合标签         | 可追踪规则           |
| 随机森林         | 并行、近似独立 | 各自拟合标签         | 通过平均降低方差     |
| AdaBoost         | 顺序           | 提高错分样本权重     | 聚焦困难样本         |
| GBDT             | 顺序           | 拟合当前损失的负梯度 | 逐轮降低一般可导损失 |
| 神经网络梯度下降 | 同一网络内迭代 | 更新参数张量         | 在固定可微结构中优化 |&lt;/p&gt;
&lt;p&gt;GBDT 在表格数据、非线性阈值和特征交互上通常很强，但它不会自然向训练范围外做平滑线性外推；面对超高维稀疏文本、图像原始像素、序列结构或需要端到端表示学习的任务，线性模型和神经网络往往有更合适的归纳偏置。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;梯度提升不是平均许多独立树，而是让新树顺序修正当前模型仍犯的错误。&lt;/li&gt;
&lt;li&gt;平方误差下，损失对预测的负梯度恰好等于残差，所以新树拟合残差。&lt;/li&gt;
&lt;li&gt;一般损失下，每轮树拟合伪残差，即损失对当前函数输出的负梯度。&lt;/li&gt;
&lt;li&gt;学习率缩小单轮修正，树深控制单轮交互，轮数控制累积容量；三者必须联合验证。&lt;/li&gt;
&lt;li&gt;训练损失持续下降不代表应该继续加树，结构化验证和早停决定可用轮数。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;p&gt;从本文第二轮残差 &lt;code&gt;[-1.083, 0.917, -1.083, 1.250]&lt;/code&gt; 出发，分别尝试阈值 1.5、2.5、3.5，计算左右叶均值与叶内平方误差。选出最佳树桩，再用 &lt;code&gt;η=0.5&lt;/code&gt; 更新预测。&lt;/p&gt;
&lt;p&gt;令模型输出 logit $F$，概率 $p=\sigma(F)$，二元交叉熵为 $-[y\log p+(1-y)\log(1-p)]$。证明损失对 $F$ 的负梯度是 $y-p$，并解释为什么“自信地预测错”会得到较大修正。&lt;/p&gt;
&lt;p&gt;固定 &lt;code&gt;max_depth=2&lt;/code&gt;，比较 &lt;code&gt;(learning_rate, n_estimators)&lt;/code&gt; 为 &lt;code&gt;(0.2,100)&lt;/code&gt;、&lt;code&gt;(0.05,400)&lt;/code&gt;、&lt;code&gt;(0.01,2000)&lt;/code&gt; 的训练与验证 RMSE。记录最佳轮数、模型大小和推理时间，而不只比较最低 RMSE。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1214/aos/1013203451&quot;&gt;Friedman: Greedy Function Approximation—A Gradient Boosting Machine&lt;/a&gt;：把 Boosting 表述为函数空间数值优化的奠基论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1016/S0167-9473(01)00065-2&quot;&gt;Friedman: Stochastic Gradient Boosting&lt;/a&gt;：引入行采样，讨论随机性对准确率与稳健性的作用。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/1999/hash/96a93ba89a5b5c6c226e49b88973f46e-Abstract.html&quot;&gt;Mason et al.: Boosting Algorithms as Gradient Descent&lt;/a&gt;：从函数空间梯度下降统一理解 Boosting。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingRegressor.html&quot;&gt;scikit-learn: GradientBoostingRegressor&lt;/a&gt;：当前逐步加法、负梯度、早停与参数接口。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html&quot;&gt;scikit-learn: HistGradientBoostingRegressor&lt;/a&gt;：当前直方图、缺失值、类别特征和约束接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;经典 GBDT 用一阶负梯度告诉新树“往哪里改”，但没有直接利用损失曲率回答“应该改多大”，树的叶值与分裂也缺少统一的正则化评分。下一篇将从二阶泰勒展开推导 XGBoost 的叶权重和分裂增益，手算 &lt;code&gt;reg_lambda&lt;/code&gt;、&lt;code&gt;gamma&lt;/code&gt; 与 &lt;code&gt;min_child_weight&lt;/code&gt; 究竟拦住了什么。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>多棵不稳定的树为何能更可靠？随机森林的 Bagging、特征子采样与 OOB 估计</title><link>https://zwjcode.cn/blog/random-forest-bagging-oob-feature-subsampling</link><guid isPermaLink="true">https://zwjcode.cn/blog/random-forest-bagging-oob-feature-subsampling</guid><description>从单棵树的高方差出发，手算自助采样、概率平均与袋外预测，解释特征子采样如何降低相关性，并实现可诊断的 sklearn 随机森林。</description><pubDate>Sat, 22 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇构造了一棵决策树：每个节点贪心选择最能降低不纯度的特征与阈值。它能表达非线性规则、推理很快，但训练数据只改动几个样本，根节点就可能换掉，后续规则也随之重排。&lt;/p&gt;
&lt;p&gt;这是一类典型的高方差（High Variance）学习器。把树剪得更浅可以降低方差，却也可能丢掉真实的细粒度规律。有没有办法保留许多较深的树，同时让最终预测不那么依赖某一份训练样本？&lt;/p&gt;
&lt;p&gt;随机森林（Random Forest）的答案是：&lt;strong&gt;制造许多“各不相同但还算有用”的树，再平均它们的预测。&lt;/strong&gt; 本文只聚焦三件紧密相连的事：自助采样怎样改变每棵树的数据、特征子采样怎样降低树之间的相关性，以及袋外样本怎样提供无需额外切分的诊断信号。&lt;/p&gt;
&lt;h2&gt;01 随机森林不是“把同一棵树复制很多次”&lt;/h2&gt;
&lt;p&gt;如果 $M$ 棵树看到完全相同的数据和特征，并用相同算法训练，它们通常会得到相同规则；复制再多次，投票也没有新增信息。&lt;/p&gt;
&lt;p&gt;随机森林给每棵树注入两类随机性：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;自助采样（Bootstrap Sampling）&lt;/strong&gt;：每棵树从 $N$ 个训练样本中有放回地抽取 $N$ 次；有些样本重复，有些没有被抽中。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;特征子采样（Feature Subsampling）&lt;/strong&gt;：每个节点只随机查看部分特征，再从其中选择最佳分裂。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;完整数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X_train [N,D] + y_train [N]
       │
       ├─ bootstrap #1 + 每节点随机特征 ─► Tree 1
       ├─ bootstrap #2 + 每节点随机特征 ─► Tree 2
       ├─ bootstrap #3 + 每节点随机特征 ─► Tree 3
       │                         ...
       └─ bootstrap #M + 每节点随机特征 ─► Tree M

X_query [Q,D]
       │
       ├─► proba_1 [Q,C] ─┐
       ├─► proba_2 [Q,C] ─┼─► 按树平均 ─► forest_proba [Q,C]
       └─► proba_M [Q,C] ─┘                    │ argmax
                                              ▼
                                         prediction [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每棵树仍执行上一篇的不纯度下降搜索；森林改变的是训练样本与每个节点可见的候选特征，最后再聚合预测。&lt;/p&gt;
&lt;h2&gt;02 自助采样怎样制造不同训练集？&lt;/h2&gt;
&lt;p&gt;假设训练集只有 4 个样本，编号为 &lt;code&gt;[1,2,3,4]&lt;/code&gt;。每棵树有放回抽 4 次：&lt;/p&gt;
&lt;p&gt;| 树     | 抽到的样本编号 | 重复样本 | 未抽中的样本 |
| ------ | -------------- | -------- | ------------ |
| Tree 1 | &lt;code&gt;[1,1,2,4]&lt;/code&gt;    | 1        | 3            |
| Tree 2 | &lt;code&gt;[2,3,3,4]&lt;/code&gt;    | 3        | 1            |
| Tree 3 | &lt;code&gt;[1,2,2,3]&lt;/code&gt;    | 2        | 4            |&lt;/p&gt;
&lt;p&gt;同一编号可以出现多次，因为每次抽取后都放回。每棵树看到的经验分布不同，于是容易在不同位置分裂。&lt;/p&gt;
&lt;p&gt;对某个固定样本，一次抽取没有选中它的概率是 $1-1/N$；连续抽 $N$ 次仍未选中的概率为：&lt;/p&gt;
&lt;p&gt;$$
\left(1-\frac1N\right)^N\xrightarrow[N\to\infty]{}e^{-1}\approx0.368
$$&lt;/p&gt;
&lt;p&gt;因此，大样本下每棵树平均约有 36.8% 的训练样本没有被抽中。这些样本叫袋外样本（Out-of-Bag Samples，OOB）；被抽中的不同样本比例约为 63.2%，不是说树只执行了 $0.632N$ 次抽取——重复项仍占据训练权重。&lt;/p&gt;
&lt;h2&gt;03 多棵树究竟怎样投票？&lt;/h2&gt;
&lt;p&gt;分类森林通常平均成员树的类别概率，再取最大值。假设三个树对同一查询点给出正类概率：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Tree 1: P(y=1|x) = 1.0
Tree 2: P(y=1|x) = 0.0
Tree 3: P(y=1|x) = 1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;森林结果为：&lt;/p&gt;
&lt;p&gt;$$
\hat p(y=1\mid x)=\frac{1+0+1}{3}=\frac23
$$&lt;/p&gt;
&lt;p&gt;阈值为 0.5 时预测正类。真实树的叶节点未必纯净，例如三个概率也可能是 &lt;code&gt;[0.8,0.4,0.7]&lt;/code&gt;，森林平均为约 0.633。&lt;/p&gt;
&lt;p&gt;对 $C$ 类分类：&lt;/p&gt;
&lt;p&gt;$$
\hat p_c(x)=\frac1M\sum_{m=1}^{M}\hat p_{m,c}(x)
$$&lt;/p&gt;
&lt;p&gt;$$
\hat y(x)=\arg\max_c\hat p_c(x)
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$M$：树的数量；&lt;/li&gt;
&lt;li&gt;$\hat p_{m,c}(x)$：第 $m$ 棵树给类别 $c$ 的叶节点比例；&lt;/li&gt;
&lt;li&gt;$\hat p_c(x)$：森林平均后的类别概率。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;平均能削弱某一棵树因噪声产生的极端预测，但不能自动保证概率校准。所有树共享同一训练来源且叶节点很小时，0.9 仍未必对应真实事件 90% 的发生率。&lt;/p&gt;
&lt;h2&gt;04 为什么平均能降低方差？&lt;/h2&gt;
&lt;p&gt;先把每棵树对某个目标的预测看成方差为 $\sigma^2$ 的随机变量。若树之间彼此独立，$M$ 棵树均值的方差是：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(\bar f)=\frac{\sigma^2}{M}
$$&lt;/p&gt;
&lt;p&gt;但真实森林中的树共享数据，预测相关。若任意两棵树的相关系数近似为 $\rho$：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{Var}(\bar f)
=\rho\sigma^2+\frac{1-\rho}{M}\sigma^2
$$&lt;/p&gt;
&lt;p&gt;这个式子揭示两个不同旋钮：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;增大 $M$ 会缩小第二项，但无法消除由相关性留下的 $\rho\sigma^2$；&lt;/li&gt;
&lt;li&gt;降低树之间的相关性 $\rho$，才能继续压低共同波动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;自助采样改变每棵树的数据；特征子采样进一步迫使树寻找不同规则。它们不是为了让单棵树更准确，而是在“成员树足够强”和“成员之间不要太相似”之间做折中。&lt;/p&gt;
&lt;h2&gt;05 特征子采样为何必须发生在每个节点？&lt;/h2&gt;
&lt;p&gt;假设有 100 个特征，其中一个强特征几乎总能带来最大不纯度下降。若每棵树的每个节点都查看全部特征，许多树会在根节点选择同一特征，随后结构高度相似；Bootstrap 带来的差异可能不够。&lt;/p&gt;
&lt;p&gt;分类森林常在每个节点随机查看约 $\sqrt D$ 个特征：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Tree 1 根节点候选：{x₂, x₇, x₉}  ─► 选 x₇
Tree 2 根节点候选：{x₁, x₄, x₈}  ─► 选 x₁
Tree 3 根节点候选：{x₃, x₇, x₁₀} ─► 选 x₃
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键是“每个节点重新抽候选特征”，不是每棵树只抽一次固定列子集。子树的不同节点仍可能使用全部 $D$ 个特征中的不同部分。&lt;/p&gt;
&lt;p&gt;| &lt;code&gt;max_features&lt;/code&gt;   | 单节点候选数 | 常见影响                           |
| ---------------- | -----------: | ---------------------------------- |
| &lt;code&gt;None&lt;/code&gt;           |          $D$ | 单树较强，但树更相似               |
| &lt;code&gt;&apos;sqrt&apos;&lt;/code&gt;         |    $\sqrt D$ | 分类森林常用折中                   |
| 很小的整数或比例 |         更少 | 相关性下降，但节点可能错过有效特征 |&lt;/p&gt;
&lt;p&gt;不一定。它可能降低树间相关性，却也让每棵树更弱。如果大多数节点看不到任何有用特征，偏差会明显上升。应在开发数据上结合准确性、方差和成本验证。&lt;/p&gt;
&lt;h2&gt;06 用四个样本手算 OOB 预测&lt;/h2&gt;
&lt;p&gt;回到前三棵树的抽样结果：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Tree 1 没见过样本 3
Tree 2 没见过样本 1
Tree 3 没见过样本 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;袋外估计（OOB Estimate）只让“训练时没见过该样本”的树为它预测：&lt;/p&gt;
&lt;p&gt;| 样本 | 可参与 OOB 预测的树 |         正类概率 |
| ---- | ------------------- | ---------------: |
| 1    | Tree 2              |              0.2 |
| 3    | Tree 1              |              0.8 |
| 4    | Tree 3              |              0.6 |
| 2    | 本例没有            | &lt;code&gt;NaN&lt;/code&gt; / 不可估计 |&lt;/p&gt;
&lt;p&gt;若样本 1 的真实标签是 0，OOB 概率 0.2 对应预测正确；样本 3 的真实标签是 1，概率 0.8 也正确。样本 2 在这个极小森林中被每棵树抽到，无法得到 OOB 预测。&lt;/p&gt;
&lt;p&gt;树数量足够多时，大部分样本都会被若干棵树留在袋外。OOB 分数可以作为快速诊断，尤其在数据宝贵、不想额外划出验证集时有用。&lt;/p&gt;
&lt;p&gt;但 OOB 不是测试集：你若反复根据 OOB 结果选择特征、超参数和数据清洗规则，它就参与了模型开发。最终泛化结论仍应由封存测试集给出；时间序列、分组数据等不满足普通 Bootstrap 结构的任务也不能机械依赖 OOB。&lt;/p&gt;
&lt;h2&gt;07 Bagging 训练的伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input: X [N,D], y [N], number of trees M

for m in 1 ... M:
    bootstrap_indices = sample N indices with replacement
    X_m, y_m = X[bootstrap_indices], y[bootstrap_indices]

    grow a decision tree:
        at every node:
            randomly choose max_features candidate columns
            among those columns, select the best impurity split

for query batch X_query [Q,D]:
    probabilities = stack(
        tree_m.predict_proba(X_query) for m in 1 ... M
    )                                  # [M,Q,C]
    forest_proba = mean(probabilities, axis=0)  # [Q,C]
    prediction = argmax(forest_proba, axis=1)   # [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;训练阶段每棵树可以相对独立地构建，因此易于并行；推理时也可以分树并行，但最终必须归并所有成员输出。&lt;/p&gt;
&lt;h2&gt;08 用当前 scikit-learn API 落地&lt;/h2&gt;
&lt;p&gt;截至本文写作时，scikit-learn 1.9 的 &lt;code&gt;RandomForestClassifier&lt;/code&gt; 默认 &lt;code&gt;n_estimators=100&lt;/code&gt;、&lt;code&gt;max_features=&apos;sqrt&apos;&lt;/code&gt;、&lt;code&gt;bootstrap=True&lt;/code&gt;。以下代码显式设置关键参数，避免默认值或实验意图含糊：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import balanced_accuracy_score, log_loss

# X_train: [N,D]；y_train: [N]
forest = RandomForestClassifier(
    n_estimators=500,
    criterion=&apos;gini&apos;,
    max_features=&apos;sqrt&apos;,
    min_samples_leaf=2,
    bootstrap=True,
    oob_score=True,
    n_jobs=-1,
    random_state=42,
)
forest.fit(X_train, y_train)

val_proba = forest.predict_proba(X_val)    # [num_val, C]
val_pred = forest.predict(X_val)           # [num_val]

print(&apos;classes:&apos;, forest.classes_)
print(&apos;OOB score:&apos;, forest.oob_score_)
print(&apos;validation balanced accuracy:&apos;,
      balanced_accuracy_score(y_val, val_pred))
print(&apos;validation log loss:&apos;, log_loss(y_val, val_proba))

oob_proba = forest.oob_decision_function_  # [N,C]
valid_oob_rows = np.isfinite(oob_proba).all(axis=1)
print(&apos;OOB coverage:&apos;, valid_oob_rows.mean())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要 API 与属性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;n_estimators&lt;/code&gt;：森林中的树数量；增加它通常让结果更稳定，也线性增加模型大小和计算；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bootstrap=True&lt;/code&gt;：每棵树使用有放回样本；&lt;code&gt;oob_score&lt;/code&gt; 仅在此时可用；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_samples&lt;/code&gt;：控制每棵树抽取多少次，默认为与训练集样本数相同；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_features=&apos;sqrt&apos;&lt;/code&gt;：每个节点随机候选特征数约为 $\sqrt D$；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;oob_score_&lt;/code&gt;：默认以准确率计算的袋外总体分数；当前 API 也允许给 &lt;code&gt;oob_score&lt;/code&gt; 传入指标函数；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;oob_decision_function_&lt;/code&gt;：每个训练样本的 OOB 类别概率，形状 &lt;code&gt;[N,C]&lt;/code&gt;；树太少时某些行可能含 &lt;code&gt;NaN&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;estimators_&lt;/code&gt;：已训练成员树列表；&lt;code&gt;estimators_samples_&lt;/code&gt; 可查看各树抽中的训练索引；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n_jobs=-1&lt;/code&gt;：让 &lt;code&gt;fit&lt;/code&gt;、&lt;code&gt;predict&lt;/code&gt;、&lt;code&gt;decision_path&lt;/code&gt; 和 &lt;code&gt;apply&lt;/code&gt; 在树之间使用可用处理器并行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;scikit-learn 1.9 的随机森林还原生支持数值特征中的 &lt;code&gt;NaN&lt;/code&gt; 路由：训练时学习缺失值应走左右哪一侧。不过这不等于缺失机制已被正确建模；线上缺失率、缺失含义和数据管道仍必须单独监控。&lt;/p&gt;
&lt;h2&gt;09 OOB、交叉验证与测试集如何分工？&lt;/h2&gt;
&lt;p&gt;| 评估来源   | 每个样本由谁预测               | 适合用途               | 主要限制                            |
| ---------- | ------------------------------ | ---------------------- | ----------------------------------- |
| OOB        | 没抽到该样本的树               | 快速监控、减少额外切分 | 只适用于 Bootstrap 语义；会参与调参 |
| 交叉验证   | 当前折未用该样本训练的整个森林 | 比较超参数、估计波动   | 计算成本约乘折数                    |
| 独立测试集 | 完全冻结方案后训练的最终模型   | 一次最终验收           | 不能反复查看后继续修改              |&lt;/p&gt;
&lt;p&gt;OOB 与交叉验证结果不一致时，先检查：样本是否有时间或群组结构、类别是否极不平衡、树数量是否足够、两者使用的评分指标是否相同。不要只选择数值更好看的那一个。&lt;/p&gt;
&lt;h2&gt;10 怎样判断树的数量够不够？&lt;/h2&gt;
&lt;p&gt;增加树数量一般不会像增加单树深度那样直接加剧过拟合，但收益会递减。可以观察 OOB 指标或固定验证集指标随树数量是否稳定：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;tree_counts = [50, 100, 200, 500, 1000]

for count in tree_counts:
    model = RandomForestClassifier(
        n_estimators=count,
        max_features=&apos;sqrt&apos;,
        min_samples_leaf=2,
        bootstrap=True,
        oob_score=True,
        n_jobs=-1,
        random_state=42,
    )
    model.fit(X_train, y_train)
    print(count, model.oob_score_)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记录的不应只有分数，还要包括模型文件大小、训练时长、P50/P99 推理延迟和峰值内存。当曲线已稳定而成本继续线性增长时，再增加树没有工程收益。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;warm_start=True&lt;/code&gt; 可以在后续 &lt;code&gt;fit&lt;/code&gt; 中保留旧树并增加 &lt;code&gt;n_estimators&lt;/code&gt;，适合逐步观察树数；它不会让旧树用新超参数重训，也不是任意增量学习接口。正式交叉验证中更清晰的做法通常是独立拟合各候选模型。&lt;/p&gt;
&lt;h2&gt;11 特征重要性为何容易被误读？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;feature_importances_&lt;/code&gt; 汇总所有树中某特征带来的加权不纯度下降。它计算快，但有两个常见陷阱：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;可选切分点多的连续或高基数特征更容易偶然获得大下降；&lt;/li&gt;
&lt;li&gt;两个高度相关特征可以互相替代，重要性会被分散或随机偏向其中一个。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;更可靠的补充是只在验证集上做置换重要性（Permutation Importance）：随机打乱一列，观察指标下降多少。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.inspection import permutation_importance

result = permutation_importance(
    estimator=forest,
    X=X_val,
    y=y_val,
    scoring=&apos;balanced_accuracy&apos;,
    n_repeats=20,
    random_state=42,
    n_jobs=-1,
)

order = np.argsort(result.importances_mean)[::-1]
for feature_id in order[:10]:
    print(
        feature_names[feature_id],
        result.importances_mean[feature_id],
        result.importances_std[feature_id],
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即使置换重要性很高，也只能说明模型在当前验证分布下依赖该特征，不代表特征对真实结果具有因果作用。&lt;/p&gt;
&lt;h2&gt;12 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;以为每棵树只随机一次特征。&lt;/strong&gt; &lt;code&gt;max_features&lt;/code&gt; 在每个节点生效；检查成员树的 &lt;code&gt;tree_.feature&lt;/code&gt; 可看到不同路径使用不同列。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;关闭 Bootstrap 却打开 OOB。&lt;/strong&gt; &lt;code&gt;oob_score&lt;/code&gt; 需要 &lt;code&gt;bootstrap=True&lt;/code&gt;，否则没有“未被抽中”的定义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;树太少就相信 OOB 概率。&lt;/strong&gt; 先检查 &lt;code&gt;oob_decision_function_&lt;/code&gt; 是否含 &lt;code&gt;NaN&lt;/code&gt; 以及每个样本有多少 OOB 成员。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只增加树，不控制单树叶子。&lt;/strong&gt; 平均能降方差，却不能修复泄漏、无意义 ID、错误标签或极端小叶产生的不可靠概率。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;嵌套并行耗尽资源。&lt;/strong&gt; 外层交叉验证和森林都设 &lt;code&gt;n_jobs=-1&lt;/code&gt; 可能造成 CPU 与内存争用；只让一层负责并行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 OOB 当最终测试。&lt;/strong&gt; OOB 被反复用于决策后就是开发信号，仍需封存测试集。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;部署时只测平均延迟。&lt;/strong&gt; 500 棵深树会带来模型体积、缓存未命中和尾延迟；同时测 P99 与并发吞吐。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别失衡只看默认 OOB 准确率。&lt;/strong&gt; 改用适当指标、&lt;code&gt;class_weight&lt;/code&gt; 或独立验证，并检查每类召回率。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小诊断代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == 2 and y_train.ndim == 1
assert len(forest.estimators_) == forest.n_estimators
assert forest.oob_decision_function_.shape == (
    X_train.shape[0], len(forest.classes_)
)

depths = np.array([tree.get_depth() for tree in forest.estimators_])
leaves = np.array([tree.get_n_leaves() for tree in forest.estimators_])
print(&apos;depth median/max:&apos;, np.median(depths), depths.max())
print(&apos;leaves median/max:&apos;, np.median(leaves), leaves.max())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若训练分数接近 1、OOB 与验证都明显较低，优先调大 &lt;code&gt;min_samples_leaf&lt;/code&gt;、限制深度或改善数据；若 OOB 很好而时间切分验证很差，优先怀疑分布漂移或划分结构，而不是继续增加树。&lt;/p&gt;
&lt;h2&gt;13 它与相近集成方法有什么区别？&lt;/h2&gt;
&lt;p&gt;| 方法              | 样本随机性            | 特征随机性                 | 树之间关系                | 主要目标                 |
| ----------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
| 单棵决策树        | 无                    | 通常看全部特征             | 只有一棵                  | 得到可追踪规则           |
| Bagging Trees     | Bootstrap             | 通常看全部特征             | 并行、独立训练            | 通过平均降低方差         |
| 随机森林          | Bootstrap             | 每节点随机子集             | 并行、独立训练            | 降低方差并降低树间相关性 |
| Extra Trees       | 可选 Bootstrap        | 每节点随机子集且阈值更随机 | 并行                      | 进一步随机化、常更快     |
| Gradient Boosting | 通常非 Bootstrap 核心 | 依实现而定                 | 后一棵拟合前一轮残差/梯度 | 顺序降低偏差             |&lt;/p&gt;
&lt;p&gt;随机森林的成员树可以并行训练；Boosting 的树有顺序依赖，不能把它理解成“另一种投票森林”。随机森林通常是表格数据的强基线，但面对高维稀疏线性问题、需要平滑外推的回归、极严格模型体积或延迟约束时，线性模型、专门梯度提升实现或更紧凑模型可能更合适。&lt;/p&gt;
&lt;h2&gt;14 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;随机森林让每棵树看到不同的 Bootstrap 样本，并在每个节点随机限制候选特征。&lt;/li&gt;
&lt;li&gt;平均多棵高方差树能降低独立波动；树间相关性越高，继续增加树的收益越有限。&lt;/li&gt;
&lt;li&gt;特征子采样的作用是降低成员相关性，不保证单棵树更准确；&lt;code&gt;max_features&lt;/code&gt; 过小也会增加偏差。&lt;/li&gt;
&lt;li&gt;每棵树约有 36.8% 的样本位于袋外，可用于 OOB 诊断，但 OOB 参与调参后不能替代最终测试集。&lt;/li&gt;
&lt;li&gt;树数量要结合指标稳定性、模型大小、训练成本和尾延迟选择；特征重要性只能说明模型依赖，不能证明因果。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 思考题与小练习&lt;/h2&gt;
&lt;p&gt;训练编号为 &lt;code&gt;[1,2,3,4,5]&lt;/code&gt;，某棵树抽到 &lt;code&gt;[2,2,5,1,2]&lt;/code&gt;。不同袋内样本是 &lt;code&gt;{1,2,5}&lt;/code&gt;，袋外集合是 &lt;code&gt;{3,4}&lt;/code&gt;；虽然只出现 3 个不同样本，训练序列仍有 5 个位置。&lt;/p&gt;
&lt;p&gt;若 $\sigma^2=1,\rho=0.2,M=100$，均值方差为 $0.2+0.8/100=0.208$。把树从 100 增到 1000 只能降到约 0.201；降低相关性比继续堆树更有潜力。&lt;/p&gt;
&lt;p&gt;在带日期的数据上同时计算随机森林 OOB 分数和“过去训练、未来验证”的分数。若 OOB 明显更高，解释随机 Bootstrap 如何混合时间分布，以及为何线上预期应更相信时间验证。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF00058655&quot;&gt;Breiman: Bagging Predictors&lt;/a&gt;：用 Bootstrap 聚合不稳定学习器、降低预测方差的奠基论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/ICDAR.1995.598994&quot;&gt;Ho: Random Decision Forests&lt;/a&gt;：随机子空间构造决策森林的早期代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/neco.1997.9.7.1545&quot;&gt;Amit &amp;#x26; Geman: Shape Quantization and Recognition with Randomized Trees&lt;/a&gt;：随机化树用于识别问题的重要前置工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1023/A:1010933404324&quot;&gt;Breiman: Random Forests&lt;/a&gt;：系统提出随机森林并分析强度、相关性与泛化误差。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html&quot;&gt;scikit-learn: RandomForestClassifier&lt;/a&gt;：当前 Bootstrap、OOB、特征子采样、缺失值和并行接口的官方说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;16 下一篇预告&lt;/h2&gt;
&lt;p&gt;随机森林让许多树并行、独立地生长，再通过平均降低方差。下一篇将转向另一条集成路线：让后一棵浅树专门修正前一轮仍然犯错的方向，建立梯度提升树的逐步加法模型。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>决策树怎样选出第一条规则？从基尼不纯度到剪枝</title><link>https://zwjcode.cn/blog/decision-tree-split-impurity-pruning</link><guid isPermaLink="true">https://zwjcode.cn/blog/decision-tree-split-impurity-pruning</guid><description>用六个样本手算特征阈值与不纯度下降，追踪决策树的递归生长、预测路径和代价复杂度剪枝，并实现可调试的 sklearn 流程。</description><pubDate>Sat, 22 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的 K 近邻（K-Nearest Neighbors，KNN）能用局部样本拼出弯曲边界，但每次预测都要保存并搜索训练数据。如果希望把训练数据压缩成一组快速执行、可以逐步解释的规则，可以换一种思路：反复询问“某个特征是否不大于某个阈值”。&lt;/p&gt;
&lt;p&gt;这就是决策树（Decision Tree）。它不要求线性边界，也不依赖特征之间的欧氏距离；真正困难的部分是：候选特征和阈值那么多，树怎样选出第一条规则，又怎样避免一路分到只记住训练样本？&lt;/p&gt;
&lt;p&gt;本文只讲透三个紧密环节：&lt;strong&gt;不纯度如何衡量节点混杂程度、一次分裂如何比较候选规则，以及树如何通过限制生长和剪枝控制过拟合。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 一棵树怎样把输入变成预测？&lt;/h2&gt;
&lt;p&gt;二分类树的内部节点保存一个判断：&lt;/p&gt;
&lt;p&gt;$$
x_j\le t
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x\in\mathbb{R}^{D}$：一个含 $D$ 个特征的样本；&lt;/li&gt;
&lt;li&gt;$j\in{1,\ldots,D}$：当前检查的特征编号；&lt;/li&gt;
&lt;li&gt;$t\in\mathbb{R}$：阈值（Threshold）；&lt;/li&gt;
&lt;li&gt;条件成立进入左子树，否则进入右子树。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;训练数据形状和预测数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练：X [N,D] + y [N]
          │ 搜索 (feature j, threshold t)
          ▼
     二叉规则树（节点保存 j、t、样本统计）

推理：X_query [Q,D]
          │ 每个样本从根节点向下
          ▼
     leaf_id [Q] ──► 叶节点类别比例 [Q,C]
                         │ argmax
                         ▼
                    prediction [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;$N$：训练样本数；&lt;/li&gt;
&lt;li&gt;$Q$：查询样本数；&lt;/li&gt;
&lt;li&gt;$C$：类别数；&lt;/li&gt;
&lt;li&gt;每个叶节点保存到达该处的训练样本类别计数，分类概率由计数比例得到。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 x₁ &amp;#x3C;= 2.5 ?
                 /          \
              是/            \否
               /              \
          预测类别 0       x₁ &amp;#x3C;= 7.5 ?
                           /          \
                        类别 1       类别 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;新样本只需执行根到叶的一串 &lt;code&gt;if-then&lt;/code&gt;，不再扫描全部训练样本。若树深为 $h$，单样本预测通常只访问约 $h$ 个节点。&lt;/p&gt;
&lt;h2&gt;02 节点“不纯”到底是什么意思？&lt;/h2&gt;
&lt;p&gt;若一个节点中的样本全属同一类，它已经足够纯，不必继续分裂；若各类混在一起，则需要寻找规则将它们分开。&lt;/p&gt;
&lt;p&gt;对含 $C$ 类的节点 $S$，第 $c$ 类比例为：&lt;/p&gt;
&lt;p&gt;$$
p_c=\frac{n_c}{|S|}
$$&lt;/p&gt;
&lt;p&gt;基尼不纯度（Gini Impurity）定义为：&lt;/p&gt;
&lt;p&gt;$$
G(S)=1-\sum_{c=1}^{C}p_c^2
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$n_c$：节点中第 $c$ 类样本数；&lt;/li&gt;
&lt;li&gt;$|S|$：节点样本总数；&lt;/li&gt;
&lt;li&gt;$G(S)$：一个非负标量，越小表示越纯。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;二分类中若正类比例为 $p$：&lt;/p&gt;
&lt;p&gt;$$
G(S)=1-p^2-(1-p)^2=2p(1-p)
$$&lt;/p&gt;
&lt;p&gt;| 正负样本数 | 正类比例 $p$ |          基尼不纯度 |
| ---------- | -----------: | ------------------: |
| 6 正、0 负 |            1 |                   0 |
| 3 正、3 负 |          0.5 |                 0.5 |
| 1 正、5 负 |        $1/6$ | $10/36\approx0.278$ |&lt;/p&gt;
&lt;p&gt;它可以理解为：按节点类别比例随机给一个样本贴标签时，标签与真实类别不一致的概率。这里用它来比较分裂，不要把它与基尼系数或训练后的业务指标混为一谈。&lt;/p&gt;
&lt;h2&gt;03 一条候选规则怎样打分？&lt;/h2&gt;
&lt;p&gt;候选规则 $(j,t)$ 把父节点 $S$ 分成：&lt;/p&gt;
&lt;p&gt;$$
S_L={(x_i,y_i)\in S\mid x_{ij}\le t}
$$&lt;/p&gt;
&lt;p&gt;$$
S_R=S\setminus S_L
$$&lt;/p&gt;
&lt;p&gt;不能直接相加左右不纯度，因为含 100 个样本的子节点应比含 1 个样本的子节点影响更大。分裂后的加权不纯度为：&lt;/p&gt;
&lt;p&gt;$$
G_{split}=\frac{|S_L|}{|S|}G(S_L)+\frac{|S_R|}{|S|}G(S_R)
$$&lt;/p&gt;
&lt;p&gt;不纯度下降（Impurity Decrease）为：&lt;/p&gt;
&lt;p&gt;$$
\Delta G=G(S)-G_{split}
$$&lt;/p&gt;
&lt;p&gt;训练算法选择让 $\Delta G$ 最大的候选分裂。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;父节点样本 S
   │ 枚举每个特征 j
   │ 枚举相邻取值之间的候选阈值 t
   ▼
计算左、右类别计数
   │
   ▼
加权子节点不纯度 G_split
   │
   ▼
选取最大 ΔG 的 (j,t)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只需要考察排序后相邻不同取值之间的阈值。相同特征值之间放阈值不会产生新的数据划分。&lt;/p&gt;
&lt;h2&gt;04 用六个样本手算根节点&lt;/h2&gt;
&lt;p&gt;先只看一个特征 $x_1$：&lt;/p&gt;
&lt;p&gt;| 样本 | $x_1$ | 标签 $y$ |
| ---- | ----: | -------: |
| A    |     1 |        0 |
| B    |     2 |        0 |
| C    |     3 |        1 |
| D    |     6 |        1 |
| E    |     7 |        1 |
| F    |     8 |        0 |&lt;/p&gt;
&lt;p&gt;父节点有 3 个正类、3 个负类：&lt;/p&gt;
&lt;p&gt;$$
G(S)=1-(3/6)^2-(3/6)^2=0.5
$$&lt;/p&gt;
&lt;p&gt;候选阈值取相邻值中点：$1.5,2.5,4.5,6.5,7.5$。&lt;/p&gt;
&lt;h3&gt;比较阈值 $t=2.5$&lt;/h3&gt;
&lt;p&gt;左侧标签为 $[0,0]$，完全纯：&lt;/p&gt;
&lt;p&gt;$$
G(S_L)=0
$$&lt;/p&gt;
&lt;p&gt;右侧标签为 $[1,1,1,0]$，正类比例为 $3/4$：&lt;/p&gt;
&lt;p&gt;$$
G(S_R)=1-(3/4)^2-(1/4)^2=0.375
$$&lt;/p&gt;
&lt;p&gt;因此：&lt;/p&gt;
&lt;p&gt;$$
G_{split}=\frac{2}{6}\times0+\frac{4}{6}\times0.375=0.25
$$&lt;/p&gt;
&lt;p&gt;$$
\Delta G=0.5-0.25=0.25
$$&lt;/p&gt;
&lt;h3&gt;比较阈值 $t=4.5$&lt;/h3&gt;
&lt;p&gt;左右标签分别为 $[0,0,1]$ 和 $[1,1,0]$，两边不纯度都是 $4/9$：&lt;/p&gt;
&lt;p&gt;$$
G_{split}=\frac36\times\frac49+\frac36\times\frac49=\frac49
$$&lt;/p&gt;
&lt;p&gt;$$
\Delta G=0.5-\frac49\approx0.056
$$&lt;/p&gt;
&lt;p&gt;完整比较为：&lt;/p&gt;
&lt;p&gt;| 阈值 $t$ | 分裂后加权不纯度 | 不纯度下降 $\Delta G$ |
| -------: | ---------------: | --------------------: |
|      1.5 |            0.400 |                 0.100 |
|      2.5 |            0.250 |             &lt;strong&gt;0.250&lt;/strong&gt; |
|      4.5 |            0.444 |                 0.056 |
|      6.5 |            0.500 |                 0.000 |
|      7.5 |            0.400 |                 0.100 |&lt;/p&gt;
&lt;p&gt;所以根节点选择 $x_1\le2.5$。对右侧四个样本继续搜索，阈值 7.5 能把三个正类与一个负类完全分开，最终得到前面的三叶规则。&lt;/p&gt;
&lt;p&gt;阈值 1.5 能让只含 A 的左节点完全纯，但右节点仍混杂；若忽略样本数和另一个子节点，会偏爱不断剥离单个样本的规则。加权不纯度同时评价两个子节点。&lt;/p&gt;
&lt;h2&gt;05 递归生长的伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;grow(node_samples, depth):
    if labels are pure:
        return leaf(class proportions)

    if depth or sample constraints forbid splitting:
        return leaf(class proportions)

    best_gain = -infinity
    for feature j in candidate_features:
        sort node samples by X[:, j]
        for threshold t between adjacent distinct values:
            split samples into left and right
            gain = parent_impurity - weighted_child_impurity
            keep (j, t) if gain is larger

    if best_gain is below required minimum:
        return leaf(class proportions)

    return node(
        rule=(best_feature, best_threshold),
        left=grow(left_samples, depth + 1),
        right=grow(right_samples, depth + 1),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是贪心算法（Greedy Algorithm）：每个节点只选择当前下降最多的规则，不会穷举所有可能的整棵树。局部最优分裂未必组成全局最小的树，但完整搜索的组合数量过大，贪心递归提供了实用折中。&lt;/p&gt;
&lt;p&gt;对连续特征，训练阶段通常需要排序和扫描候选阈值；预测阶段只做比较。树不需要像 KNN 那样先做标准化，因为单调缩放不会改变单个特征的排序与可实现划分。&lt;/p&gt;
&lt;h2&gt;06 为什么完全生长会过拟合？&lt;/h2&gt;
&lt;p&gt;若没有限制，树可以继续分裂到叶节点只剩一个或很少样本。此时训练误差可能为 0，但一个异常点就会生成很深、很窄的规则区域。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;浅树：                         过深的树：

        x₂ &amp;#x3C;= 4                    x₂ &amp;#x3C;= 4
       /       \                  /       \
    类别 0     类别 1         类别 0      x₁ &amp;#x3C;= 7.01
                                           /       \
                                      x₂ &amp;#x3C;= 4.03   类别 1
                                      /       \
                                  类别 1    单个噪声点
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见的预剪枝（Pre-pruning）控制包括：&lt;/p&gt;
&lt;p&gt;| 参数                    | 阻止什么               | 增大限制后的效果       |
| ----------------------- | ---------------------- | ---------------------- |
| &lt;code&gt;max_depth&lt;/code&gt;             | 路径无限加深           | 规则更短、偏差可能升高 |
| &lt;code&gt;min_samples_split&lt;/code&gt;     | 太小的内部节点继续分   | 减少脆弱分裂           |
| &lt;code&gt;min_samples_leaf&lt;/code&gt;      | 产生只含极少样本的叶子 | 概率更平滑             |
| &lt;code&gt;max_leaf_nodes&lt;/code&gt;        | 叶子总数过多           | 直接限制模型大小       |
| &lt;code&gt;min_impurity_decrease&lt;/code&gt; | 收益太小仍分裂         | 忽略微弱局部改进       |&lt;/p&gt;
&lt;p&gt;这些值必须用开发数据选择，不能根据封存测试集反复调整。&lt;/p&gt;
&lt;h2&gt;07 代价复杂度剪枝怎样删掉枝条？&lt;/h2&gt;
&lt;p&gt;另一种办法是先生成较大的树，再做后剪枝（Post-pruning）。最小代价复杂度剪枝（Minimal Cost-Complexity Pruning）比较：&lt;/p&gt;
&lt;p&gt;$$
R_\alpha(T)=R(T)+\alpha|\widetilde T|
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$T$：某棵候选子树；&lt;/li&gt;
&lt;li&gt;$R(T)$：叶节点不纯度按样本权重汇总后的训练代价；&lt;/li&gt;
&lt;li&gt;$|\widetilde T|$：叶节点数量；&lt;/li&gt;
&lt;li&gt;$\alpha\ge0$：每增加一个叶子要支付的复杂度价格。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$\alpha=0$ 时几乎不惩罚树大小；$\alpha$ 增大后，只有能显著降低不纯度的枝条值得保留。scikit-learn 中对应 &lt;code&gt;ccp_alpha&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cost_complexity_pruning_path(X_train, y_train)&lt;/code&gt; 会返回一串候选 &lt;code&gt;ccp_alphas&lt;/code&gt; 及对应叶节点不纯度。应在训练数据上产生候选，再由交叉验证选择，而不是挑训练准确率最高的值。&lt;/p&gt;
&lt;h2&gt;08 用当前 scikit-learn API 实现&lt;/h2&gt;
&lt;p&gt;截至本文写作时，scikit-learn 1.9 的 &lt;code&gt;DecisionTreeClassifier&lt;/code&gt; 默认 &lt;code&gt;criterion=&apos;gini&apos;&lt;/code&gt;、&lt;code&gt;splitter=&apos;best&apos;&lt;/code&gt;、&lt;code&gt;ccp_alpha=0.0&lt;/code&gt;。默认大小限制会让树一直长到叶子纯净或无法再分，因此实际项目应显式验证复杂度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.tree import DecisionTreeClassifier, export_text

# X_train: [N, D]；y_train: [N]
base_tree = DecisionTreeClassifier(
    criterion=&apos;gini&apos;,
    random_state=42,
)

path = base_tree.cost_complexity_pruning_path(X_train, y_train)
candidate_alphas = np.unique(path.ccp_alphas)  # [num_subtrees]

search = GridSearchCV(
    estimator=base_tree,
    param_grid={
        &apos;ccp_alpha&apos;: candidate_alphas,
        &apos;min_samples_leaf&apos;: [1, 5, 20],
        &apos;max_depth&apos;: [None, 3, 6],
    },
    scoring=&apos;balanced_accuracy&apos;,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    n_jobs=-1,
    refit=True,
)
search.fit(X_train, y_train)

tree = search.best_estimator_
predictions = tree.predict(X_val)          # [num_val]
probabilities = tree.predict_proba(X_val)  # [num_val, C]
leaf_ids = tree.apply(X_val)               # [num_val]
paths = tree.decision_path(X_val)           # [num_val, num_nodes] 稀疏矩阵

print(search.best_params_)
print(export_text(tree, feature_names=list(feature_names)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要 API 的语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fit(X,y)&lt;/code&gt; 接收 &lt;code&gt;[N,D]&lt;/code&gt; 和 &lt;code&gt;[N]&lt;/code&gt;，学习树结构与叶节点类别统计；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;predict_proba(X)&lt;/code&gt; 返回 &lt;code&gt;[Q,C]&lt;/code&gt;，列顺序由 &lt;code&gt;classes_&lt;/code&gt; 给出；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;apply(X)&lt;/code&gt; 返回每个样本到达的叶节点编号 &lt;code&gt;[Q]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decision_path(X)&lt;/code&gt; 返回 &lt;code&gt;[Q,num_nodes]&lt;/code&gt; 的稀疏指示矩阵，指出每个样本访问了哪些节点；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tree_.feature&lt;/code&gt; 和 &lt;code&gt;tree_.threshold&lt;/code&gt; 保存内部节点使用的特征编号与阈值；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature_importances_&lt;/code&gt; 是基于训练不纯度下降的汇总，不等于因果重要性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;09 怎样解释一条预测，而不是只画整棵树？&lt;/h2&gt;
&lt;p&gt;大树的全图很快变得不可读。调试单个样本时，更有效的是打印它经过的规则：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;sample = X_val[[0]]                       # [1, D]
node_indicator = tree.decision_path(sample)
leaf_id = tree.apply(sample)[0]

for node_id in node_indicator.indices:
    if node_id == leaf_id:
        print(&apos;leaf&apos;, node_id, tree.predict_proba(sample)[0])
        continue

    feature_id = tree.tree_.feature[node_id]
    threshold = tree.tree_.threshold[node_id]
    value = sample[0, feature_id]
    operator = &apos;&amp;#x3C;=&apos; if value &amp;#x3C;= threshold else &apos;&gt;&apos;
    print(feature_names[feature_id], value, operator, threshold)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要同时记录原始样本 ID、使用的模型版本和特征定义。若线上特征顺序改变，而模型仍按旧列号读取，路径看起来合法却会执行完全错误的规则。&lt;/p&gt;
&lt;h2&gt;10 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;用训练准确率挑深度。&lt;/strong&gt; 完全生长的树几乎总占优势；应比较交叉验证或独立验证指标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把标准化当必需步骤。&lt;/strong&gt; 树按单特征阈值切分，通常不需要缩放；真正要固定的是列顺序、单位和缺失值语义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别失衡仍只看普通准确率。&lt;/strong&gt; 打印每类召回率、混淆矩阵和叶节点类别计数，必要时验证 &lt;code&gt;class_weight&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把高基数 ID 当普通数值。&lt;/strong&gt; 树可能用设备 ID、订单号切出偶然群组；这通常不具备可推广含义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;feature_importances_&lt;/code&gt; 当真相。&lt;/strong&gt; 基于不纯度的重要性偏爱可切分点多的特征，也会在相关特征间任意分配功劳；用留出集置换重要性补充检查。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略概率的样本量。&lt;/strong&gt; 叶子只有 2 个训练样本且都为正时，&lt;code&gt;predict_proba&lt;/code&gt; 会给出 1.0，但证据远弱于含 2000 个正样本的叶子。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果无法复现。&lt;/strong&gt; 多个候选分裂收益相同时选择可能变化；固定 &lt;code&gt;random_state&lt;/code&gt; 并记录库版本。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小结构检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == 2 and y_train.ndim == 1
assert X_train.shape[0] == y_train.shape[0]
assert np.isfinite(X_train).all()
assert tree.get_depth() &amp;#x3C;= 6 or search.best_params_[&apos;max_depth&apos;] is None
assert tree.get_n_leaves() &gt;= 2
print(&apos;depth:&apos;, tree.get_depth(), &apos;leaves:&apos;, tree.get_n_leaves())
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;11 失败场景与相近方法边界&lt;/h2&gt;
&lt;p&gt;单棵树的最大弱点是高方差（High Variance）：训练数据稍微变化，靠近根部的最佳分裂就可能改变，整棵下游结构随之重排。&lt;/p&gt;
&lt;p&gt;| 方法     | 边界形状         | 是否需要缩放 | 主要优点                   | 主要限制                     |
| -------- | ---------------- | ------------ | -------------------------- | ---------------------------- |
| 决策树   | 轴对齐、分段常数 | 通常不需要   | 非线性、规则可追踪、推理快 | 不稳定、易过拟合             |
| KNN      | 局部距离形成     | 通常需要     | 几乎不训练、局部灵活       | 推理和存储昂贵、高维退化     |
| 逻辑回归 | 全局线性         | 通常有益     | 系数紧凑、概率目标清晰     | 原始空间只能线性分割         |
| 规则系统 | 人工定义         | 取决于规则   | 业务约束明确               | 维护成本高、难从数据自动适应 |&lt;/p&gt;
&lt;p&gt;决策树还不擅长需要平滑外推的回归：叶节点输出训练目标均值，超出训练特征范围后不会像线性模型那样自然延伸。若数据噪声很高、特征轻微变化不应造成预测跳变，也要特别检查树边界的稳定性。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;决策树用一串单特征阈值把特征空间递归切成叶节点，预测时只沿根到叶执行规则。&lt;/li&gt;
&lt;li&gt;基尼不纯度衡量类别混杂程度；候选分裂由左右子节点的样本数加权后比较。&lt;/li&gt;
&lt;li&gt;训练在每个节点贪心选择不纯度下降最大的 &lt;code&gt;(feature, threshold)&lt;/code&gt;，并不保证得到全局最优整棵树。&lt;/li&gt;
&lt;li&gt;完全生长容易记住噪声，应在开发数据上选择深度、最小叶样本数或 &lt;code&gt;ccp_alpha&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;解释时既要看经过的规则，也要看叶节点样本量；不纯度重要性不能直接解释为因果作用。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;p&gt;父节点标签为 &lt;code&gt;[0,0,0,1,1]&lt;/code&gt;，某规则分出左侧 &lt;code&gt;[0,0]&lt;/code&gt;、右侧 &lt;code&gt;[0,1,1]&lt;/code&gt;。父节点基尼不纯度为 $1-(3/5)^2-(2/5)^2=0.48$；右侧为 $4/9$，加权子节点不纯度为 $(2/5)0+(3/5)(4/9)=4/15\approx0.267$，下降约 0.213。&lt;/p&gt;
&lt;p&gt;每个叶子必须由更多样本支持，单个异常点无法独占一个叶子；类别比例的方差通常下降，但太大也会抹去真实的小群体。&lt;/p&gt;
&lt;p&gt;把一个连续特征从“元”改成“万元”，重新训练并比较预测。严格单调缩放通常只改变阈值数值，不改变划分；若数据预处理发生舍入、截断或缺失值替换，划分仍可能改变。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1201/9781315139470&quot;&gt;Breiman et al.: Classification and Regression Trees&lt;/a&gt;：系统建立分类回归树、递归分割与剪枝的经典专著。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/BF00116251&quot;&gt;Quinlan: Induction of Decision Trees&lt;/a&gt;：ID3 与信息增益决策树的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.sciencedirect.com/book/9781558602380/c45&quot;&gt;Quinlan: C4.5: Programs for Machine Learning&lt;/a&gt;：连续特征、剪枝与工程化决策树的重要发展。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.html&quot;&gt;scikit-learn: DecisionTreeClassifier&lt;/a&gt;：当前参数、属性、形状和预测语义的官方说明。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/auto_examples/tree/plot_cost_complexity_pruning.html&quot;&gt;scikit-learn: Post pruning decision trees&lt;/a&gt;：&lt;code&gt;cost_complexity_pruning_path&lt;/code&gt; 与 &lt;code&gt;ccp_alpha&lt;/code&gt; 的官方示例。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;剪枝能让一棵树更简单，却没有消除“训练数据稍变，整棵树就可能重排”的高方差。下一篇将让许多棵树分别观察不同的自助样本和特征子集，再把它们的概率平均，解释随机森林为何常比任何一棵成员树稳定。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>不训练参数也能分类吗？K 近邻的距离投票与维度灾难</title><link>https://zwjcode.cn/blog/knn-distance-voting-curse-dimensionality</link><guid isPermaLink="true">https://zwjcode.cn/blog/knn-distance-voting-curse-dimensionality</guid><description>从弯曲边界的局部分类出发，手算 K 近邻的距离与投票，解释特征尺度、K 值、索引复杂度和维度灾难，并实现可调试 KNN。</description><pubDate>Fri, 21 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇的逻辑回归用一组全局权重画出线性决策边界。它快速、清晰，却无法在原始特征中直接分开同心圆或弯月形类别。&lt;/p&gt;
&lt;p&gt;如果问题满足另一种规律——&lt;strong&gt;相似样本往往有相似标签&lt;/strong&gt;——可以不先假设一条全局公式。收到新样本时，直接寻找训练集中最相似的若干样本，再让它们投票。这就是 K 近邻（K-Nearest Neighbors，KNN）。&lt;/p&gt;
&lt;p&gt;KNN 的 &lt;code&gt;fit&lt;/code&gt; 几乎不学习参数，却把大量工作留到推理阶段。本文只讲透四个紧密环节：&lt;strong&gt;距离定义、邻居选择、局部投票，以及它为什么在高维空间逐渐失效。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 从一条全局边界转向局部证据&lt;/h2&gt;
&lt;p&gt;考虑二维分类：横轴是用户最近 7 天活跃次数，纵轴是平均会话时长。正负样本形成两个弯曲区域，一条直线无法分开，但新用户附近的训练用户大多属于同一类。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;会话时长 x₂
▲
│  ○ ○ ○             + + +
│ ○     ○           +     +
│ ○     ○    ?      +     +
│  ○ ○ ○      + + +
└────────────────────────────► 活跃次数 x₁

? 的类别不由一条全局直线决定，
而由它周围最近的 K 个已标记样本决定。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这叫基于实例的学习（Instance-Based Learning）：训练阶段保留实例，预测阶段才针对查询点进行局部计算。也常称惰性学习（Lazy Learning），因为昂贵的决策被推迟到查询时。&lt;/p&gt;
&lt;p&gt;KNN 分类的数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练：X_train [N,D] + y_train [N] ──► 保存 / 建索引

查询：X_query [Q,D]
          │ 与训练样本计算距离
          ▼
      distances [Q,N]
          │ 每行选最小 K 个
          ▼
      neighbor_indices [Q,K]
          │ 取标签、投票
          ▼
      class_votes [Q,C] ──► prediction [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;$N$：训练样本数；&lt;/li&gt;
&lt;li&gt;$Q$：一次查询的样本数；&lt;/li&gt;
&lt;li&gt;$D$：特征维数；&lt;/li&gt;
&lt;li&gt;$C$：类别数；&lt;/li&gt;
&lt;li&gt;$K$：每个查询点使用的邻居数量。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;02 “最近”必须先定义距离&lt;/h2&gt;
&lt;p&gt;最常见的是欧氏距离（Euclidean Distance）：&lt;/p&gt;
&lt;p&gt;$$
d_2(x,q)=\sqrt{\sum_{j=1}^{D}(x_j-q_j)^2}
$$&lt;/p&gt;
&lt;p&gt;$x$ 是训练样本，$q$ 是查询样本。若只比较远近，平方根不改变排序，也可比较平方距离。&lt;/p&gt;
&lt;p&gt;更一般的闵可夫斯基距离（Minkowski Distance）为：&lt;/p&gt;
&lt;p&gt;$$
d_p(x,q)=\left(\sum_{j=1}^{D}|x_j-q_j|^p\right)^{1/p}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$p=1$：曼哈顿距离（Manhattan Distance），各维绝对差之和；&lt;/li&gt;
&lt;li&gt;$p=2$：欧氏距离，直线距离；&lt;/li&gt;
&lt;li&gt;$p$ 越大：越强调单个维度上的最大差异。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;一个可手算的三邻居查询&lt;/h3&gt;
&lt;p&gt;训练数据如下，&lt;code&gt;A/B&lt;/code&gt; 是类别：&lt;/p&gt;
&lt;p&gt;| 点    | 坐标 $(x_1,x_2)$ | 标签 | 到查询点 $q=(2,2)$ 的欧氏距离 |
| ----- | ---------------- | ---- | ----------------------------: |
| $x_1$ | $(1,1)$          | A    |          $\sqrt2\approx1.414$ |
| $x_2$ | $(2,3)$          | A    |                           $1$ |
| $x_3$ | $(3,2)$          | B    |                           $1$ |
| $x_4$ | $(5,5)$          | B    |       $\sqrt{18}\approx4.243$ |
| $x_5$ | $(0,4)$          | B    |          $\sqrt8\approx2.828$ |&lt;/p&gt;
&lt;p&gt;若 $K=3$，最近邻依次是 $x_2(A)$、$x_3(B)$、$x_1(A)$，A 得 2 票，B 得 1 票，所以预测 A。&lt;/p&gt;
&lt;p&gt;均匀投票的类别分数可写为：&lt;/p&gt;
&lt;p&gt;$$
s_c(q)=\sum_{i\in\mathcal{N}_K(q)}\mathbb{1}[y_i=c]
$$&lt;/p&gt;
&lt;p&gt;$$
\hat y(q)=\arg\max_c s_c(q)
$$&lt;/p&gt;
&lt;p&gt;$\mathcal{N}_K(q)$ 是查询点的 K 个最近邻索引集合。&lt;/p&gt;
&lt;h2&gt;03 距离加权怎样改变投票？&lt;/h2&gt;
&lt;p&gt;均匀投票让第 1 近和第 K 近拥有相同影响。距离加权（Distance Weighting）则让近邻权重更高，常用：&lt;/p&gt;
&lt;p&gt;$$
w_i(q)=\frac{1}{d(x_i,q)+\varepsilon}
$$&lt;/p&gt;
&lt;p&gt;$$
s_c(q)=\sum_{i\in\mathcal{N}_K(q)}w_i(q)\mathbb{1}[y_i=c]
$$&lt;/p&gt;
&lt;p&gt;$\varepsilon$ 是防止手写实现除以 0 的小正数。&lt;/p&gt;
&lt;p&gt;假设三个最近邻变为：A 距离 1.4、A 距离 1.6、B 距离 0.1。均匀投票仍判 A；倒数距离权重为：&lt;/p&gt;
&lt;p&gt;$$
s_A=\frac{1}{1.4}+\frac{1}{1.6}\approx1.339
$$&lt;/p&gt;
&lt;p&gt;$$
s_B=\frac{1}{0.1}=10
$$&lt;/p&gt;
&lt;p&gt;加权结果改判 B，因为一个极近的 B 比两个较远的 A 更有证据。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;predict_proba&lt;/code&gt; 给出的也不是经似然训练的参数概率，而是邻域中各类别的加权票数比例。邻域很小、类别密度变化或数据漂移时，它可能不校准。&lt;/p&gt;
&lt;h2&gt;04 特征尺度为什么可以直接改写答案？&lt;/h2&gt;
&lt;p&gt;假设两个特征是年龄（年）和年收入（元）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;查询用户 q = (年龄 30, 收入 100000)
用户 A     = (年龄 31, 收入 100000)
用户 B     = (年龄 30, 收入 101000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原始欧氏距离：&lt;/p&gt;
&lt;p&gt;$$
d(q,A)=1,qquad d(q,B)=1000
$$&lt;/p&gt;
&lt;p&gt;模型会认为 A 远比 B 相似，几乎完全忽略年龄之外的语义权衡。若收入改用“万元”，B 的距离又变成 0.1；只换单位就可能交换邻居次序。&lt;/p&gt;
&lt;p&gt;常见处理是用训练集统计量做标准化：&lt;/p&gt;
&lt;p&gt;$$
z_j=\frac{x_j-\mu_j}{\sigma_j}
$$&lt;/p&gt;
&lt;p&gt;使每个连续特征的数值尺度更接近。但标准化只解决量纲，不保证距离符合任务语义：邮政编码、用户 ID 等类别编号即使标准化，也不应按数值远近比较。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原始 X_train ── fit μ,σ ──► 标准化训练数据 ──► KNN 保存
X_query       ── 用同一 μ,σ ─► 标准化查询点   ──► 距离查询
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;均值和标准差只能从当前训练折学习，因此缩放器必须放入 Pipeline。否则交叉验证的验证折会泄漏进距离定义。&lt;/p&gt;
&lt;h2&gt;05 K 值控制的是怎样的偏差—方差权衡？&lt;/h2&gt;
&lt;p&gt;$K$ 太小时，决策高度依赖单个样本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$K=1$ 的训练误差常常极低；&lt;/li&gt;
&lt;li&gt;一个错标样本或异常点就能制造小片错误区域；&lt;/li&gt;
&lt;li&gt;边界曲折，方差高。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$K$ 很大时，局部信息被大范围多数类淹没：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;边界过度平滑；&lt;/li&gt;
&lt;li&gt;小类别区域可能消失；&lt;/li&gt;
&lt;li&gt;偏差高，最终甚至接近“永远预测全局多数类”。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;K=1：边界追随每个点          K 较大：边界更平滑

 +++○++  局部小岛               +++++++
 ++○○++                           +++++
 ○○++○+                         ─────────
 ○○○○○+                           ○○○○○
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证集或交叉验证负责选 $K$。候选值无需只取奇数：奇数只能减少二分类均匀投票中的一部分平票，无法解决相同距离、重复样本、多分类或加权票数相等。&lt;/p&gt;
&lt;h2&gt;06 不依赖 &lt;code&gt;fit&lt;/code&gt;，写出一个可检查的 KNN&lt;/h2&gt;
&lt;p&gt;下面实现欧氏距离和均匀投票。它刻意保留中间数组，便于看清数据流；大数据时不应一次构造完整 &lt;code&gt;[Q,N,D]&lt;/code&gt; 张量。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X_train = np.array([
    [1.0, 1.0],
    [2.0, 3.0],
    [3.0, 2.0],
    [5.0, 5.0],
    [0.0, 4.0],
])                                         # [N=5, D=2]
y_train = np.array([0, 0, 1, 1, 1])       # [5]
X_query = np.array([[2.0, 2.0]])           # [Q=1, D=2]
k = 3

# [Q,1,D] - [1,N,D] -&gt; [Q,N,D]
differences = X_query[:, None, :] - X_train[None, :, :]
squared_distances = np.sum(differences ** 2, axis=2)  # [Q,N]

# argpartition 只保证前 k 个是最小集合，不保证它们内部有序
neighbor_indices = np.argpartition(
    squared_distances,
    kth=k - 1,
    axis=1,
)[:, :k]                                  # [Q,K]
neighbor_labels = y_train[neighbor_indices]            # [Q,K]

classes = np.unique(y_train)               # [C]
votes = np.stack([
    np.sum(neighbor_labels == class_label, axis=1)
    for class_label in classes
], axis=1)                                 # [Q,C]
predictions = classes[np.argmax(votes, axis=1)]        # [Q]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最小调试检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X_train.ndim == X_query.ndim == 2
assert X_train.shape[1] == X_query.shape[1]
assert y_train.shape == (X_train.shape[0],)
assert 1 &amp;#x3C;= k &amp;#x3C;= X_train.shape[0]
assert neighbor_indices.shape == (X_query.shape[0], k)
assert np.isfinite(squared_distances).all()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若需要解释一个预测，应打印邻居的原始样本 ID、距离、标签和票权，而不只返回类别。KNN 的局部可解释性来自“哪些实例参与了决策”，不是来自全局特征系数。&lt;/p&gt;
&lt;h2&gt;07 用当前 scikit-learn API 建立无泄漏流程&lt;/h2&gt;
&lt;p&gt;截至本文写作时，scikit-learn 1.9 的 &lt;code&gt;KNeighborsClassifier&lt;/code&gt; 默认参数包括 &lt;code&gt;n_neighbors=5&lt;/code&gt;、&lt;code&gt;weights=&apos;uniform&apos;&lt;/code&gt;、&lt;code&gt;metric=&apos;minkowski&apos;&lt;/code&gt;、&lt;code&gt;p=2&lt;/code&gt; 和 &lt;code&gt;algorithm=&apos;auto&apos;&lt;/code&gt;。下面把缩放和 KNN 放入同一 Pipeline，并只在开发数据内选超参数。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pipeline = make_pipeline(
    StandardScaler(),
    KNeighborsClassifier(
        algorithm=&apos;auto&apos;,
        metric=&apos;minkowski&apos;,
        n_jobs=None,  # 让外层 GridSearchCV 负责并行，避免双层抢占 CPU
    ),
)

search = GridSearchCV(
    estimator=pipeline,
    param_grid={
        &apos;kneighborsclassifier__n_neighbors&apos;: [1, 3, 5, 9, 15, 31],
        &apos;kneighborsclassifier__weights&apos;: [&apos;uniform&apos;, &apos;distance&apos;],
        &apos;kneighborsclassifier__p&apos;: [1, 2],
    },
    scoring=&apos;balanced_accuracy&apos;,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    n_jobs=-1,
    refit=True,
)
search.fit(X_train, y_train)

predictions = search.predict(X_val)          # [num_val]
probabilities = search.predict_proba(X_val)  # [num_val, num_classes]

best_knn = search.best_estimator_.named_steps[&apos;kneighborsclassifier&apos;]
scaled_query = search.best_estimator_.named_steps[&apos;standardscaler&apos;].transform(
    X_val[:2]
)                                           # [2, D]
distances, indices = best_knn.kneighbors(scaled_query)
# distances: [2, K]；indices: [2, K]，索引指向训练样本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键 API 的输入输出：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fit(X,y)&lt;/code&gt; 接收 &lt;code&gt;[N,D]&lt;/code&gt; 与 &lt;code&gt;[N]&lt;/code&gt;；对 KNN 而言主要是保存训练数据并准备查询结构；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kneighbors(X)&lt;/code&gt; 返回 &lt;code&gt;(distances, indices)&lt;/code&gt;，形状都是 &lt;code&gt;[Q,K]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;predict_proba(X)&lt;/code&gt; 返回 &lt;code&gt;[Q,C]&lt;/code&gt;，列顺序按 &lt;code&gt;classes_&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;weights=&apos;uniform&apos;&lt;/code&gt; 等票投票，&lt;code&gt;weights=&apos;distance&apos;&lt;/code&gt; 使用距离倒数加权；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;p=1&lt;/code&gt; 与 &lt;code&gt;p=2&lt;/code&gt; 分别对应曼哈顿和欧氏距离；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;algorithm=&apos;auto&apos;&lt;/code&gt; 让实现根据输入选择查询策略，但稀疏输入会使用暴力搜索；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n_jobs&lt;/code&gt; 控制邻居搜索并行度；外层交叉验证已经并行时，要防止双层并行导致 CPU 过度抢占；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;leaf_size&lt;/code&gt; 影响 KD 树或球树的构建、查询与内存折中，不改变数学预测规则。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;08 “几乎不训练”不等于计算便宜&lt;/h2&gt;
&lt;p&gt;暴力搜索对每个查询点计算到全部训练样本的距离，时间复杂度近似：&lt;/p&gt;
&lt;p&gt;$$
O(ND)
$$&lt;/p&gt;
&lt;p&gt;一次查询还要维护最近的 K 个候选。训练数据本身通常也要保存在内存中，空间至少为 $O(ND)$。&lt;/p&gt;
&lt;p&gt;KD 树（KD-Tree）沿坐标维递归切分空间，球树（Ball Tree）用嵌套超球组织样本；在低维、距离结构合适时，它们能跳过大量不可能成为近邻的区域。但维数升高后，剪枝效率下降，查询会逐渐接近暴力扫描。&lt;/p&gt;
&lt;p&gt;| 阶段         | 逻辑回归         | KNN 暴力查询             |
| ------------ | ---------------- | ------------------------ |
| 训练         | 迭代优化参数     | 主要保存数据             |
| 模型大小     | $O(D)$           | $O(ND)$                  |
| 单样本推理   | $O(D)$           | 约 $O(ND)$               |
| 新增少量样本 | 通常要重训       | 可加入索引，但要管理重建 |
| 删除隐私数据 | 删除模型未必足够 | 必须从样本库与索引清除   |&lt;/p&gt;
&lt;p&gt;工程上要同时测量构建索引时间、P50/P99 查询延迟、内存、吞吐和召回一致性。若使用近似最近邻（Approximate Nearest Neighbor，ANN）换取速度，还要额外评估“没有找回真正近邻”对分类的影响。&lt;/p&gt;
&lt;h2&gt;09 维度灾难为什么让所有点都显得差不多远？&lt;/h2&gt;
&lt;p&gt;维度灾难（Curse of Dimensionality）不是“维度多所以计算慢”这么简单。在高维空间中，数据变得极度稀疏；要覆盖与低维相同的局部密度，样本数会随维度指数增长。&lt;/p&gt;
&lt;p&gt;看单位超立方体 $[0,1]^D$。若希望一个轴对齐邻域在每个维度只覆盖长度 0.1，它的体积为：&lt;/p&gt;
&lt;p&gt;$$
V=0.1^D
$$&lt;/p&gt;
&lt;p&gt;| 维数 $D$ | 邻域体积 $0.1^D$ | 平均放入 1 个样本约需总样本数 |
| -------: | ---------------: | ----------------------------: |
|        1 |        $10^{-1}$ |                          $10$ |
|        2 |        $10^{-2}$ |                         $100$ |
|       10 |       $10^{-10}$ |                     $10^{10}$ |&lt;/p&gt;
&lt;p&gt;高维中，查询点的最近邻也可能很远，最近距离与最远距离的相对差距往往缩小。此时“最近”不再代表真正相似，局部投票失去基础。&lt;/p&gt;
&lt;p&gt;缓解方向包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;删除噪声、重复和无语义的特征；&lt;/li&gt;
&lt;li&gt;用领域知识设计真正代表相似性的度量；&lt;/li&gt;
&lt;li&gt;先做特征选择、主成分分析或学习低维表示；&lt;/li&gt;
&lt;li&gt;增加覆盖真实分布的样本，而不是盲目生成更多维度；&lt;/li&gt;
&lt;li&gt;在验证集上比较 KNN 与不依赖局部欧氏距离的模型。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;降维步骤同样必须只在训练折拟合，并放进 Pipeline。&lt;/p&gt;
&lt;h2&gt;10 常见错误与最短调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;未缩放连续特征。&lt;/strong&gt; 打印每列范围和标准差；检查邻居是否只由某一大数值特征决定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把类别编号当连续坐标。&lt;/strong&gt; 性别编码、邮编、设备 ID 的数字差不代表语义距离；应用合适编码或度量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在全数据上标准化后交叉验证。&lt;/strong&gt; 预处理必须在每个训练折内 &lt;code&gt;fit&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只调 K，不调距离。&lt;/strong&gt; 同时验证 &lt;code&gt;p&lt;/code&gt;、权重和特征表示；它们共同定义“邻域”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别失衡仍用普通准确率。&lt;/strong&gt; 大 K 容易吞没少数类；结合平衡准确率、每类召回率和邻域标签比例。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;请求批次过大导致内存峰值。&lt;/strong&gt; 暴力距离矩阵可非常大；分块查询，并监测实际内存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;重复点标签冲突。&lt;/strong&gt; 距离相同但标签不同会产生不稳定平票；先检查去重、标注一致性和稳定样本顺序。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;误把邻域票数当可信概率。&lt;/strong&gt; 用可靠验证集检查校准，不要直接把 3/5 解释成真实风险 60%。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;诊断单个预测：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(&apos;classes:&apos;, best_knn.classes_)
for rank, (distance, row_index) in enumerate(
    zip(distances[0], indices[0]), start=1
):
    print(rank, distance, row_index, y_train[row_index])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若最近邻肉眼看起来毫不相似，优先怀疑特征表示和距离，而不是继续调 K；若近邻合理但标签混乱，问题可能来自标签噪声或该区域本身不可分。&lt;/p&gt;
&lt;h2&gt;11 失败场景与相近方法边界&lt;/h2&gt;
&lt;p&gt;KNN 依赖局部平滑假设：彼此接近的点应该有相近标签。若标签在微小尺度上快速交替，或者所选特征无法表达真实相似性，增加样本也未必修复。&lt;/p&gt;
&lt;p&gt;| 方法     | 预测依据           | 边界形状           | 训练 / 推理重心    | 适合场景                   |
| -------- | ------------------ | ------------------ | ------------------ | -------------------------- |
| KNN      | 查询点附近实例投票 | 局部、可高度非线性 | 训练轻，推理重     | 中小型低维数据、距离有意义 |
| 逻辑回归 | 全局线性 logit     | 线性               | 训练优化，推理轻   | 可分性近似线性、要全局系数 |
| 最近质心 | 到每类中心的距离   | 分段线性           | 压缩为每类一个中心 | 类内近似紧凑、需要更快推理 |
| 半径邻居 | 固定半径内投票     | 局部               | 邻居数随密度变化   | 不同区域密度可解释         |
| 核方法   | 样本间核相似度     | 非线性             | 训练通常更重       | 需要光滑非线性边界         |&lt;/p&gt;
&lt;p&gt;KNN 还不适合训练集巨大且延迟苛刻、每个特征都含大量噪声、数据隐私要求不能长期保存原始实例、流量分布快速漂移而索引更新滞后的系统。&lt;/p&gt;
&lt;h2&gt;12 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;KNN 不学习显式全局参数，而是在预测时寻找 K 个最近训练实例并局部投票。&lt;/li&gt;
&lt;li&gt;特征表示、缩放和距离度量共同定义“相似”，它们比 K 值本身更基础。&lt;/li&gt;
&lt;li&gt;小 K 方差高、易追随噪声；大 K 偏差高、易被全局多数类支配，应在开发数据内选择。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fit&lt;/code&gt; 便宜不代表系统便宜：KNN 保存训练数据，暴力单查询约需 $O(ND)$ 计算。&lt;/li&gt;
&lt;li&gt;高维中局部空间极度稀疏、距离对比变弱，KNN 会遭遇维度灾难。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;13 思考题与小练习&lt;/h2&gt;
&lt;p&gt;查询点 $q=(0,0)$，训练点 A 为 $(3,0)$，B 为 $(2,2)$。欧氏距离下 A 为 3、B 为 $\sqrt8\approx2.828$，所以 B 更近；曼哈顿距离下 A 为 3、B 为 4，所以 A 更近。距离定义可以直接改变预测。&lt;/p&gt;
&lt;p&gt;三个邻居为 A@0.5、B@1、B@2。均匀投票判 B；倒数距离权重下 A 得 2，B 得 $1+0.5=1.5$，因此改判 A。&lt;/p&gt;
&lt;p&gt;分别在 2、20、200 维单位超立方体中随机生成点，计算每个查询的最近和最远距离，再记录 &lt;code&gt;(d_max-d_min)/d_min&lt;/code&gt;。随着维度上升，观察距离对比如何变化，并解释这会怎样影响局部投票。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1109/TIT.1967.1053964&quot;&gt;Cover &amp;#x26; Hart: Nearest Neighbor Pattern Classification&lt;/a&gt;：最近邻分类误差性质的奠基论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/361002.361007&quot;&gt;Bentley: Multidimensional Binary Search Trees Used for Associative Searching&lt;/a&gt;：KD 树及多维检索的经典工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1145/355744.355745&quot;&gt;Friedman, Bentley &amp;#x26; Finkel: An Algorithm for Finding Best Matches in Logarithmic Expected Time&lt;/a&gt;：高效最近邻搜索的经典算法研究。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/3-540-49257-7_15&quot;&gt;Beyer et al.: When Is “Nearest Neighbor” Meaningful?&lt;/a&gt;：分析高维中最近邻意义退化的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html&quot;&gt;scikit-learn: KNeighborsClassifier&lt;/a&gt;：当前参数、搜索策略、距离与输入输出形状说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;14 下一篇预告&lt;/h2&gt;
&lt;p&gt;KNN 用许多局部实例拼出弯曲边界，却要在推理时保存并搜索训练数据。下一篇将学习决策树：怎样在训练阶段反复选择“哪个特征、哪个阈值最能降低不纯度”，把非线性边界压缩成一组可执行的 if-then 规则。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一条直线怎样输出分类概率？从对数几率到逻辑回归决策边界</title><link>https://zwjcode.cn/blog/logistic-regression-probability-boundary</link><guid isPermaLink="true">https://zwjcode.cn/blog/logistic-regression-probability-boundary</guid><description>从线性回归阈值化的失败出发，推导逻辑回归的 logit、Sigmoid、决策边界与梯度，并用 NumPy 和当前 sklearn API 完成可调试实现。</description><pubDate>Fri, 21 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用 L2 正则化约束了连续预测模型的权重。现在进入经典机器学习的第一个分类模型：面对“客户是否流失”“肿瘤是否恶性”这类二分类问题，我们既要一个类别，也常常需要一个可比较的风险分数。&lt;/p&gt;
&lt;p&gt;最直接的想法是先用线性回归预测 0 或 1，再以 0.5 为阈值。但线性回归输出没有上下界，加入一个极端样本就可能把整条拟合直线拉偏；&lt;code&gt;1.7&lt;/code&gt; 或 &lt;code&gt;-0.4&lt;/code&gt; 也不能直接解释为概率。&lt;/p&gt;
&lt;p&gt;逻辑回归（Logistic Regression）保留了线性模型清晰的几何结构，却改变了输出的含义。本文只回答三个紧密问题：&lt;strong&gt;线性分数如何变成概率、概率阈值如何变成决策边界，以及交叉熵如何学习这条边界。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 为什么“线性回归后截断”不是好分类器？&lt;/h2&gt;
&lt;p&gt;设一维输入 $x$ 表示某项风险指标，标签 $y\in{0,1}$。线性回归拟合：&lt;/p&gt;
&lt;p&gt;$$
\hat y=wx+b
$$&lt;/p&gt;
&lt;p&gt;若训练点是 &lt;code&gt;(1,0)、(2,0)、(3,1)、(4,1)&lt;/code&gt;，一条直线也许能让 $x\ge 2.5$ 的样本超过 0.5。但加入一个很远的正类点 &lt;code&gt;(20,1)&lt;/code&gt; 后，平方误差会强烈关注这个点的残差，斜率和截距都可能改变，原来的分类阈值随之移动。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;y
1.0 │          ●  ●                         ● 极端正类点
    │        ╱                       _______╱
0.5 │──────╳──── 分类阈值       ───╳────────  新交点
    │    ╱
0.0 │ ●  ●
    └────────────────────────────────────────► x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题不是“输出后裁剪到 $[0,1]$”就能修复的：裁剪区间外梯度为 0，而且平方误差仍在拟合连续数值 0 和 1，没有直接刻画类别概率的似然。&lt;/p&gt;
&lt;p&gt;我们需要一个模型满足：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;输出严格位于 $(0,1)$，可解释为正类概率；&lt;/li&gt;
&lt;li&gt;参数仍通过可导目标学习；&lt;/li&gt;
&lt;li&gt;最终边界仍能看出每个特征怎样推动预测。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;02 先线性打分，再映射为概率&lt;/h2&gt;
&lt;p&gt;对一批 $N$ 个样本、每个样本 $D$ 个特征，逻辑回归先计算线性分数：&lt;/p&gt;
&lt;p&gt;$$
z=Xw+b
$$&lt;/p&gt;
&lt;p&gt;再通过 S 形函数（Sigmoid Function）：&lt;/p&gt;
&lt;p&gt;$$
p=\sigma(z)=\frac{1}{1+e^{-z}}
$$&lt;/p&gt;
&lt;p&gt;张量形状沿数据流变化如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,D] ──矩阵乘──► z=Xw+b [N]
             w [D]       │
             b []        ▼ sigmoid
                      p=P(y=1|x) [N]
                           │ threshold τ
                           ▼
                     y_pred [N]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;$X\in\mathbb{R}^{N\times D}$：设计矩阵，每行一个样本；&lt;/li&gt;
&lt;li&gt;$w\in\mathbb{R}^{D}$：特征权重；&lt;/li&gt;
&lt;li&gt;$b\in\mathbb{R}$：截距；&lt;/li&gt;
&lt;li&gt;$z\in\mathbb{R}^{N}$：对数几率分数（Logit）；&lt;/li&gt;
&lt;li&gt;$p\in(0,1)^N$：每个样本属于正类的估计概率。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sigmoid 单调递增，因此 $z$ 越大，正类概率越高：&lt;/p&gt;
&lt;p&gt;|      $z$ | $p=\sigma(z)$ | 模型含义                |
| -------: | ------------: | ----------------------- |
| $-2.197$ |        $0.10$ | 负类几率约为正类的 9 倍 |
|      $0$ |        $0.50$ | 两类等可能              |
|  $2.197$ |        $0.90$ | 正类几率约为负类的 9 倍 |&lt;/p&gt;
&lt;h2&gt;03 “对数几率”为什么会变成线性函数？&lt;/h2&gt;
&lt;p&gt;概率 $p$ 对应的几率（Odds）为：&lt;/p&gt;
&lt;p&gt;$$
\operatorname{odds}=\frac{p}{1-p}
$$&lt;/p&gt;
&lt;p&gt;它表示“正类概率是负类概率的多少倍”。对几率取自然对数，得到对数几率（Log-Odds）：&lt;/p&gt;
&lt;p&gt;$$
\log\frac{p}{1-p}=z=w^\top x+b
$$&lt;/p&gt;
&lt;p&gt;把上式对 $p$ 求解，正好得到 Sigmoid。因此逻辑回归的核心假设不是“概率与特征线性”，而是：&lt;strong&gt;对数几率与特征线性。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;若某个特征 $x_j$ 增加 1，而其他特征不变，对数几率增加 $w_j$；几率则乘以 $e^{w_j}$。例如 $w_j=\log2\approx0.693$，该特征每增加一个单位，预测正类几率乘以 2。&lt;/p&gt;
&lt;p&gt;特征尺度同样影响系数含义：年龄增加 1 岁与收入增加 1 元不是可比较的变化。解释系数前必须记录单位；若加入正则化，通常还应在防泄漏 Pipeline 中标准化。&lt;/p&gt;
&lt;h2&gt;04 决策边界究竟在哪里？&lt;/h2&gt;
&lt;p&gt;默认阈值 $\tau=0.5$ 时：&lt;/p&gt;
&lt;p&gt;$$
p\ge0.5
\iff z\ge0
\iff w^\top x+b\ge0
$$&lt;/p&gt;
&lt;p&gt;所以决策边界是：&lt;/p&gt;
&lt;p&gt;$$
w^\top x+b=0
$$&lt;/p&gt;
&lt;p&gt;二维情况下，$x=[x_1,x_2]^\top$，边界为一条直线；更高维时是超平面（Hyperplane）。向量 $w$ 垂直于边界，并指向正类分数增大的方向。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x₂
▲        +       +      正类：wᵀx+b &gt; 0
│     +       +
│  - - - - - - - - -   边界：wᵀx+b = 0
│    ○      ○
│ ○     ○                 负类：wᵀx+b &amp;#x3C; 0
└──────────────────────► x₁
             ↗ w（边界法向量）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若业务阈值改成任意 $\tau\in(0,1)$，边界不再是 $z=0$，而是：&lt;/p&gt;
&lt;p&gt;$$
z\ge\log\frac{\tau}{1-\tau}
$$&lt;/p&gt;
&lt;p&gt;例如要求 $p\ge0.8$ 才触发人工复核，则 logit 至少为 $\log4\approx1.386$。这会沿法向量平移边界，却不重新训练模型。阈值必须在验证集上按漏报和误报成本选择，不能用测试集调。&lt;/p&gt;
&lt;h2&gt;05 用三个点手算概率、损失与一步更新&lt;/h2&gt;
&lt;p&gt;只用一个特征且设 $b=0$：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x = [1, 2, 3]
y = [0, 0, 1]
w = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时三个 logit 都是 0，概率都是 0.5。平均二元交叉熵（Binary Cross-Entropy，BCE）为：&lt;/p&gt;
&lt;p&gt;$$
J(w)=-\frac{1}{3}\sum_{i=1}^{3}
\left[y_i\log p_i+(1-y_i)\log(1-p_i)\right]
=-\log0.5\approx0.693
$$&lt;/p&gt;
&lt;p&gt;上一篇分类损失文章已经推导过单样本 $\partial L_i/\partial z_i=p_i-y_i$。结合 $z_i=wx_i$：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial J}{\partial w}
=\frac{1}{3}\sum_{i=1}^{3}x_i(p_i-y_i)
$$&lt;/p&gt;
&lt;p&gt;代入数值：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial J}{\partial w}
=\frac{1(0.5-0)+2(0.5-0)+3(0.5-1)}{3}=0
$$&lt;/p&gt;
&lt;p&gt;梯度竟然为 0。不是模型已经学好，而是“两个较小的负类点”和“一个较大的正类点”在当前坐标中刚好抵消。若同时学习截距：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial J}{\partial b}=\frac{1}{3}\sum_i(p_i-y_i)=\frac{0.5}{3}&gt;0
$$&lt;/p&gt;
&lt;p&gt;截距仍会向负方向更新。这个极小例子提醒我们：截距不是装饰；中心化、样本分布和特征相关性都会影响梯度。&lt;/p&gt;
&lt;p&gt;再看单个正样本 $x=2,y=1,w=0,b=0$。梯度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial w}=x(p-y)=2(0.5-1)=-1
$$&lt;/p&gt;
&lt;p&gt;取学习率 $\eta=0.2$：&lt;/p&gt;
&lt;p&gt;$$
w_{new}=0-0.2(-1)=0.2
$$&lt;/p&gt;
&lt;p&gt;新 logit 为 $0.4$，新概率为 $\sigma(0.4)\approx0.599$，损失从 $0.693$ 降到 $-\log0.599\approx0.513$。&lt;/p&gt;
&lt;h2&gt;06 不调用 &lt;code&gt;fit&lt;/code&gt;，先写出训练本体&lt;/h2&gt;
&lt;p&gt;下面用 NumPy 明确完成前向、稳定交叉熵、梯度和更新。&lt;code&gt;np.logaddexp(0, z)-yz&lt;/code&gt; 等价于二分类负对数似然，能避免直接计算 &lt;code&gt;log(sigmoid(z))&lt;/code&gt; 的溢出问题。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np

X = np.array([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
    [2.0, 1.0],
], dtype=np.float64)                         # [N=5, D=2]
y = np.array([0.0, 0.0, 0.0, 1.0, 1.0])    # [5]

w = np.zeros(X.shape[1], dtype=np.float64)  # [D=2]
b = 0.0                                     # []
learning_rate = 0.1
l2 = 0.01

for step in range(2000):
    logits = X @ w + b                       # [5]
    probabilities = 1.0 / (1.0 + np.exp(-logits))

    data_loss = np.mean(np.logaddexp(0.0, logits) - y * logits)
    objective = data_loss + 0.5 * l2 * np.dot(w, w)

    residual = probabilities - y            # [5]
    grad_w = X.T @ residual / X.shape[0] + l2 * w  # [2]
    grad_b = residual.mean()                 # []

    w -= learning_rate * grad_w
    b -= learning_rate * grad_b

new_X = np.array([[1.5, 0.5]])              # [num_queries=1, D=2]
new_logit = new_X @ w + b                    # [1]
new_probability = 1.0 / (1.0 + np.exp(-new_logit))
new_prediction = (new_probability &gt;= 0.5).astype(np.int64)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要调试的最小不变量：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X.ndim == 2 and y.shape == (X.shape[0],)
assert w.shape == (X.shape[1],)
assert logits.shape == probabilities.shape == y.shape
assert np.isfinite(objective)
assert np.isfinite(grad_w).all() and np.isfinite(grad_b)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;07 用当前 scikit-learn API 落地&lt;/h2&gt;
&lt;p&gt;截至本文写作时，scikit-learn 1.9 的 &lt;code&gt;LogisticRegression&lt;/code&gt; 默认执行正则化逻辑回归；&lt;code&gt;C&lt;/code&gt; 是正则化强度的倒数，越小约束越强。1.8 起 &lt;code&gt;penalty&lt;/code&gt; 参数已经弃用，因此新代码用 &lt;code&gt;l1_ratio&lt;/code&gt; 和 &lt;code&gt;C&lt;/code&gt; 表达正则化类型。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# X_train: [num_train, num_features]
# y_train: [num_train]，元素是类别标签 0/1
pipeline = make_pipeline(
    StandardScaler(),
    LogisticRegression(
        C=1.0,
        l1_ratio=0.0,     # 0.0 表示纯 L2；不再显式传已弃用的 penalty
        solver=&apos;lbfgs&apos;,
        max_iter=1000,
    ),
)

search = GridSearchCV(
    estimator=pipeline,
    param_grid={&apos;logisticregression__C&apos;: np.logspace(-3, 3, 13)},
    scoring=&apos;neg_log_loss&apos;,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    n_jobs=-1,
    refit=True,
)
search.fit(X_train, y_train)

logits = search.decision_function(X_val)     # [num_val]
probabilities = search.predict_proba(X_val)  # [num_val, 2]
predictions = search.predict(X_val)          # [num_val]

positive_column = np.flatnonzero(search.classes_ == 1).item()
positive_probability = probabilities[:, positive_column]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要接口语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;decision_function(X)&lt;/code&gt; 返回二分类中 &lt;code&gt;classes_[1]&lt;/code&gt; 的有符号分数；大于 0 时默认预测该类；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;predict_proba(X)&lt;/code&gt; 的列顺序由 &lt;code&gt;classes_&lt;/code&gt; 决定，不能永远假设第二列就是业务正类；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coef_&lt;/code&gt; 在二分类中形状为 &lt;code&gt;[1,D]&lt;/code&gt;，&lt;code&gt;intercept_&lt;/code&gt; 为 &lt;code&gt;[1]&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;C=np.inf&lt;/code&gt; 表示无正则化，但通常应通过交叉验证选择有限 &lt;code&gt;C&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max_iter&lt;/code&gt; 是求解器迭代上限，不是小批量训练的 epoch 数；若出现 &lt;code&gt;ConvergenceWarning&lt;/code&gt;，先标准化并检查尺度，再考虑增加它。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;标准化必须在 Pipeline 内部，让每个交叉验证折只用本折训练数据计算均值和方差。&lt;/p&gt;
&lt;h2&gt;08 训练、阈值选择与推理不要混成一步&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练集
  └── 学 w,b 与预处理统计量
           │
验证集     ▼
  ├── 选 C、特征与模型
  └── 按成本选概率阈值 τ
           │ 全部冻结
测试集     ▼
  └── 一次最终验收
           │
线上请求 ──► 同一预处理 ─► p ─► τ ─► 动作
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型输出 0.7 并不自动意味着“必须判正类”。若漏掉恶性病例代价远大于误报，可以降低阈值；若人工复核资源紧张，可以提高阈值。阈值改变的是决策规则，不改变模型已经学到的概率排序。&lt;/p&gt;
&lt;p&gt;还要区分 &lt;strong&gt;区分能力（Discrimination）&lt;/strong&gt; 与 &lt;strong&gt;概率校准（Calibration）&lt;/strong&gt;：模型可能把正样本普遍排在负样本前面，却让“预测 0.8”的样本只有 60% 真为正类。交叉熵训练有概率语义，但有限数据、正则化和分布漂移仍会破坏校准。&lt;/p&gt;
&lt;h2&gt;09 常见错误与调试路径&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把类别编码顺序想当然。&lt;/strong&gt; 总是打印 &lt;code&gt;classes_&lt;/code&gt;，按业务正类定位概率列。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 logit 当概率。&lt;/strong&gt; &lt;code&gt;decision_function&lt;/code&gt; 可为任意实数；只有 &lt;code&gt;predict_proba&lt;/code&gt; 位于 $[0,1]$。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略标准化。&lt;/strong&gt; 不同尺度会让求解器收敛变慢，也让 L2 对各系数的约束失衡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;误解 &lt;code&gt;C&lt;/code&gt;。&lt;/strong&gt; &lt;code&gt;C&lt;/code&gt; 越大正则化越弱，与 Ridge 的 &lt;code&gt;alpha&lt;/code&gt; 方向相反。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;盲目提高 &lt;code&gt;max_iter&lt;/code&gt;。&lt;/strong&gt; 若损失不收敛，先查非有限值、尺度、完全分离和重复特征。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在测试集选阈值。&lt;/strong&gt; 这会把测试信息带入开发；阈值是超参数，应在验证集确定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看准确率。&lt;/strong&gt; 类别失衡时，同时记录对数损失、精确率、召回率和混淆矩阵。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把系数当因果效应。&lt;/strong&gt; 相关特征、选择偏差和遗漏变量会改变系数；预测关联不等于干预因果。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可先检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;model = search.best_estimator_.named_steps[&apos;logisticregression&apos;]
assert np.isfinite(model.coef_).all()
assert np.isfinite(model.intercept_).all()
assert np.allclose(probabilities.sum(axis=1), 1.0)
print(&apos;classes:&apos;, search.classes_)
print(&apos;iterations:&apos;, model.n_iter_)
print(&apos;logit range:&apos;, logits.min(), logits.max())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若训练和验证都接近随机，检查特征是否真的含信号、标签是否错位；若训练很好而验证很差，回到学习曲线与正则化诊断；若线上概率整体偏高或偏低，检查分布漂移与校准，而不是只移动阈值掩盖问题。&lt;/p&gt;
&lt;h2&gt;10 它何时会失败？与相近方法怎样区分？&lt;/h2&gt;
&lt;p&gt;逻辑回归默认只有线性决策边界。对“同心圆”或异或（XOR）结构，原始特征空间中不存在一条直线分开两类；必须构造非线性特征、使用核方法、树模型或神经网络。&lt;/p&gt;
&lt;p&gt;完全分离时，未正则化最大似然会不断放大系数，让训练概率逼近 0 和 1，却没有有限最优参数；默认正则化能缓解，但不能创造新信息。&lt;/p&gt;
&lt;p&gt;| 方法           | 学到什么               | 概率输出               | 决策边界   | 主要区别                     |
| -------------- | ---------------------- | ---------------------- | ---------- | ---------------------------- |
| 逻辑回归       | 一组全局线性权重       | 原生 Sigmoid / Softmax | 线性       | 可解释、训练与推理快         |
| 线性回归阈值化 | 连续目标的平方误差拟合 | 无可靠概率含义         | 线性       | 目标与分类不匹配             |
| 线性 SVM       | 最大间隔超平面         | 默认不是概率           | 线性       | 更关注边界附近样本与间隔     |
| K 近邻         | 保存训练实例并局部投票 | 邻域票数比例           | 高度非线性 | 几乎不训练，推理成本高       |
| 决策树         | 特征阈值规则           | 叶节点频率             | 轴对齐分段 | 自动表示非线性交互但易高方差 |&lt;/p&gt;
&lt;p&gt;它还会在标签噪声严重、极端类别失衡、预测人群与训练人群不同、关键交互未进入特征时失败。清晰的线性边界是优点，也是表达能力的上限。&lt;/p&gt;
&lt;h2&gt;11 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;逻辑回归先计算线性 logit，再用 Sigmoid 得到正类概率；线性的是对数几率，不是概率本身。&lt;/li&gt;
&lt;li&gt;默认 0.5 阈值对应 $w^\top x+b=0$，所以二分类边界是超平面，$w$ 是其法向量。&lt;/li&gt;
&lt;li&gt;BCE 梯度通过 $p-y$ 调整权重；稳定实现应直接在 logits 上计算损失。&lt;/li&gt;
&lt;li&gt;scikit-learn 的 &lt;code&gt;C&lt;/code&gt; 越小正则化越强，概率列顺序必须按 &lt;code&gt;classes_&lt;/code&gt; 读取。&lt;/li&gt;
&lt;li&gt;训练参数、选择正则化和选择业务阈值属于不同阶段，测试集只做最后验收。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 思考题与小练习&lt;/h2&gt;
&lt;p&gt;若模型输出正类概率 0.8，则 $z=\log[0.8/(1-0.8)]=\log4\approx1.386$。若阈值也是 0.8，这个样本恰好位于新决策边界上。&lt;/p&gt;
&lt;p&gt;给定 $w=[2,-1]$、$b=-3$，边界为 $2x_1-x_2-3=0$，即 $x_2=2x_1-3$。判断点 $(2,0)$ 的类别：logit 为 1，默认阈值下预测正类，概率约为 0.731。&lt;/p&gt;
&lt;p&gt;把网格改为 &lt;code&gt;C=[0.001, 1, 1000]&lt;/code&gt;，记录训练对数损失、验证对数损失和系数范数。解释为什么小 &lt;code&gt;C&lt;/code&gt; 往往让系数更小，而最大 &lt;code&gt;C&lt;/code&gt; 不一定给出最好验证结果。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jstor.org/stable/2983890&quot;&gt;Cox: The Regression Analysis of Binary Sequences&lt;/a&gt;：逻辑模型用于二元响应分析的经典论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.2307/2344614&quot;&gt;Nelder &amp;#x26; Wedderburn: Generalized Linear Models&lt;/a&gt;：将逻辑回归放入广义线性模型统一框架的奠基工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.microsoft.com/en-us/research/publication/probabilistic-outputs-for-support-vector-machines-and-comparisons-to-regularized-likelihood-methods/&quot;&gt;Platt: Probabilistic Outputs for Support Vector Machines&lt;/a&gt;：从概率校准角度比较间隔模型与正则化似然方法。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.cs.cornell.edu/~alexn/papers/calibration.icml05.crc.rev3.pdf&quot;&gt;Niculescu-Mizil &amp;#x26; Caruana: Predicting Good Probabilities&lt;/a&gt;：比较分类器概率质量与校准方法的代表性研究。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html&quot;&gt;scikit-learn: LogisticRegression&lt;/a&gt;：当前参数、求解器、正则化与输入输出形状说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 下一篇预告&lt;/h2&gt;
&lt;p&gt;逻辑回归用一组全局权重画出一条线性边界。如果类别边界弯弯曲曲，但相似样本往往拥有相同标签，能否不假设全局公式，直接询问“离新样本最近的训练点是谁”？下一篇将进入 K 近邻，追踪距离计算、局部投票、特征尺度与维度灾难。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>权重为何会越学越小？从过拟合到 L2 正则化与岭回归</title><link>https://zwjcode.cn/blog/l2-regularization-ridge-regression</link><guid isPermaLink="true">https://zwjcode.cn/blog/l2-regularization-ridge-regression</guid><description>从高次多项式追逐噪声出发，推导 L2 惩罚、手算参数收缩，解释特征尺度与 alpha，并用 sklearn 和 PyTorch 实现可调试训练。</description><pubDate>Thu, 20 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇用学习曲线识别出一种典型症状：训练误差很低，验证误差却明显更高。减少模型容量或增加数据都可能有效，但如果我们仍希望保留高次特征，又不允许模型用极端系数追逐每一个噪声点，该怎样改变训练目标？&lt;/p&gt;
&lt;p&gt;一个直接办法是告诉优化器：&lt;strong&gt;拟合数据很重要，但使用过大的参数也要付出代价。&lt;/strong&gt; 这就是正则化（Regularization）的基本思想。&lt;/p&gt;
&lt;p&gt;本文只聚焦 L2 正则化及其在线性回归中的形式——岭回归（Ridge Regression），回答三个紧密问题：惩罚项怎样改变最优参数、为什么必须关注特征尺度，以及正则化强度如何选择。&lt;/p&gt;
&lt;h2&gt;01 旧目标为何会偏爱极端参数？&lt;/h2&gt;
&lt;p&gt;设多项式回归把一个输入 $x$ 展开成：&lt;/p&gt;
&lt;p&gt;$$
\phi(x)=[x,x^2,\ldots,x^p]^\top\in\mathbb{R}^{p}
$$&lt;/p&gt;
&lt;p&gt;模型为：&lt;/p&gt;
&lt;p&gt;$$
\hat y=w^\top\phi(x)+b
$$&lt;/p&gt;
&lt;p&gt;一批 $N$ 个样本的数据流是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X [N,1]
   │ 多项式展开 φ
   ▼
Φ [N,p] ──► Φw+b ──► y_hat [N]
                         │
y [N] ──────────────────┘
                         ▼
                    数据损失
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通最小二乘只最小化残差平方和：&lt;/p&gt;
&lt;p&gt;$$
J_{data}(w,b)=|y-\Phi w-b\mathbf{1}|_2^2
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\Phi\in\mathbb{R}^{N\times p}$：展开后的设计矩阵。&lt;/li&gt;
&lt;li&gt;$w\in\mathbb{R}^{p}$：每个多项式特征的系数。&lt;/li&gt;
&lt;li&gt;$b\in\mathbb{R}$：截距。&lt;/li&gt;
&lt;li&gt;$y,\hat y\in\mathbb{R}^{N}$：真实值与预测值。&lt;/li&gt;
&lt;li&gt;$|v|_2^2=\sum_j v_j^2$：向量元素平方和。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当 $p$ 很大、样本很少或特征高度相关时，许多系数组合都能把训练残差压得很低。有些组合依赖巨大的正负系数相互抵消：在训练点上恰好准确，输入稍有变化便剧烈摆动。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;y
▲                高次模型：穿过噪声点但剧烈弯折
│       ●     _/\__●
│   ●  / \___/      \_/\
│    _/                  ●
│ ●╱      平滑趋势
└──────────────────────────► x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通训练目标只关心最终残差，不关心取得这个残差用了多大的系数。因此需要给“极端解”增加成本。&lt;/p&gt;
&lt;h2&gt;02 L2 惩罚怎样写进目标？&lt;/h2&gt;
&lt;p&gt;在数据损失后加入参数平方和：&lt;/p&gt;
&lt;p&gt;$$
J(w,b)=\underbrace{|y-\Phi w-b\mathbf{1}|&lt;em&gt;2^2}&lt;/em&gt;{\text{拟合数据}}
+\underbrace{\alpha|w|&lt;em&gt;2^2}&lt;/em&gt;{\text{限制权重}}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\alpha\ge 0$：正则化强度（Regularization Strength）。&lt;/li&gt;
&lt;li&gt;$|w|&lt;em&gt;2^2=\sum&lt;/em&gt;{j=1}^{p}w_j^2$：L2 惩罚。&lt;/li&gt;
&lt;li&gt;截距 $b$ 通常不惩罚，因为它只移动整体基线，不控制输入方向的敏感度。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;优化器现在必须在两件事之间权衡：减小预测残差，或减小权重范数。&lt;/p&gt;
&lt;p&gt;| $\alpha$ | 数据拟合压力 | 权重收缩压力 | 常见风险           |
| -------: | ------------ | ------------ | ------------------ |
|      $0$ | 最大         | 无           | 高方差、追逐噪声   |
|     适中 | 平衡         | 适中         | 可能改善验证表现   |
|     很大 | 较弱         | 很强         | 系数接近 0、欠拟合 |&lt;/p&gt;
&lt;h2&gt;03 一个参数也能手算“收缩”&lt;/h2&gt;
&lt;p&gt;只看一个样本 $x=1,y=3$，模型 $\hat y=wx$，目标为：&lt;/p&gt;
&lt;p&gt;$$
J(w)=(3-w)^2+\alpha w^2
$$&lt;/p&gt;
&lt;p&gt;求导并令其为 0：&lt;/p&gt;
&lt;p&gt;$$
\frac{dJ}{dw}=2(w-3)+2\alpha w=0
$$&lt;/p&gt;
&lt;p&gt;所以：&lt;/p&gt;
&lt;p&gt;$$
w^*=\frac{3}{1+\alpha}
$$&lt;/p&gt;
&lt;p&gt;当 $\alpha=0$ 时，$w^*=3$，训练残差为 0；当 $\alpha=2$ 时：&lt;/p&gt;
&lt;p&gt;$$
w^*=1
$$&lt;/p&gt;
&lt;p&gt;此时数据损失为 $(3-1)^2=4$，惩罚为 $2\times1^2=2$，总目标为 6。若仍取 $w=3$，数据损失虽为 0，惩罚却为 $2\times9=18$。正则化目标因此选择了较小的 $w$。&lt;/p&gt;
&lt;p&gt;$w^*=3/(1+9)=0.3$。正则化越强，权重越接近 0；但预测也从 3 退到 0.3，说明过强惩罚会制造欠拟合。&lt;/p&gt;
&lt;p&gt;这个单样本例子只展示机制，不证明泛化改善。真实项目必须在未参与拟合的验证数据上比较不同 $\alpha$。&lt;/p&gt;
&lt;h2&gt;04 梯度下降中发生了什么？&lt;/h2&gt;
&lt;p&gt;若数据损失对第 $j$ 个权重的梯度为 $g_j$，则：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial J}{\partial w_j}=g_j+2\alpha w_j
$$&lt;/p&gt;
&lt;p&gt;学习率为 $\eta$ 时：&lt;/p&gt;
&lt;p&gt;$$
w_j\leftarrow w_j-\eta(g_j+2\alpha w_j)
$$&lt;/p&gt;
&lt;p&gt;重新整理：&lt;/p&gt;
&lt;p&gt;$$
w_j\leftarrow(1-2\eta\alpha)w_j-\eta g_j
$$&lt;/p&gt;
&lt;p&gt;即使当前 batch 的数据梯度 $g_j=0$，权重也会乘上一个小于 1 的因子，向 0 收缩。这种更新视角常被称为权重衰减（Weight Decay）。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;当前权重 w
   │
   ├── 数据梯度 g_data ──────────┐
   │                              │ 相加
   └── L2 梯度 2αw ──────────────┤
                                  ▼
                           总梯度 g_total
                                  │ -ηg_total
                                  ▼
                               新权重
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过不同库可能把目标写成 $\alpha|w|^2$、$\frac{\alpha}{2}|w|^2$ 或按样本数取平均，所以梯度里是否出现 2、参数名叫 &lt;code&gt;alpha&lt;/code&gt; 还是 &lt;code&gt;weight_decay&lt;/code&gt;，不能脱离接口定义直接比较。&lt;/p&gt;
&lt;h2&gt;05 为什么特征尺度会改变惩罚含义？&lt;/h2&gt;
&lt;p&gt;假设同一个房屋面积既可用平方米，也可用平方千米表示：&lt;/p&gt;
&lt;p&gt;$$
100\ \text{m}^2=0.0001\ \text{km}^2
$$&lt;/p&gt;
&lt;p&gt;若模型预测不变，使用平方千米时对应权重必须比平方米时大 $10^6$ 倍。L2 惩罚直接作用在数值权重上，于是仅仅换单位，就会受到完全不同的惩罚。&lt;/p&gt;
&lt;p&gt;因此，不同量纲特征进入岭回归前通常要标准化：&lt;/p&gt;
&lt;p&gt;$$
z_{ij}=\frac{x_{ij}-\mu_j}{\sigma_j}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$x_{ij}$：第 $i$ 个样本的第 $j$ 个特征。&lt;/li&gt;
&lt;li&gt;$\mu_j,\sigma_j$：仅从训练集估计的均值和标准差。&lt;/li&gt;
&lt;li&gt;$z_{ij}$：标准化后的特征。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;标准化后，一个单位权重更接近“特征变化一个标准差时的影响”，L2 对各方向的约束才更可比。&lt;/p&gt;
&lt;p&gt;这里再次出现数据泄漏边界：均值和标准差必须在每个训练折内拟合，验证折只能复用。因此标准化、特征展开与 Ridge 都应放入同一 Pipeline。&lt;/p&gt;
&lt;h2&gt;06 用当前 scikit-learn API 完成岭回归&lt;/h2&gt;
&lt;p&gt;继续使用一维非线性数据，并把它展开成 8 个多项式特征：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.linear_model import Ridge
from sklearn.model_selection import GridSearchCV, KFold, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler

rng = np.random.default_rng(42)
X = np.linspace(-3, 3, 120, dtype=np.float64).reshape(-1, 1)  # [120, 1]
y = np.sin(X[:, 0]) + rng.normal(0.0, 0.20, size=120)         # [120]

X_dev, X_test, y_dev, y_test = train_test_split(
    X, y, test_size=0.20, random_state=42
)

pipeline = Pipeline([
    (&apos;poly&apos;, PolynomialFeatures(degree=8, include_bias=False)),
    (&apos;scale&apos;, StandardScaler()),
    (&apos;ridge&apos;, Ridge()),
])

search = GridSearchCV(
    estimator=pipeline,
    param_grid={&apos;ridge__alpha&apos;: np.logspace(-4, 4, 25)},
    scoring=&apos;neg_root_mean_squared_error&apos;,
    cv=KFold(n_splits=5, shuffle=True, random_state=42),
    n_jobs=-1,
    refit=True,
)
search.fit(X_dev, y_dev)

test_prediction = search.predict(X_test)  # [24]
best_ridge = search.best_estimator_.named_steps[&apos;ridge&apos;]

print(&apos;best alpha:&apos;, search.best_params_[&apos;ridge__alpha&apos;])
print(&apos;validation RMSE:&apos;, -search.best_score_)
print(&apos;coefficient shape:&apos;, best_ridge.coef_.shape)  # (8,)
print(&apos;prediction shape:&apos;, test_prediction.shape)     # (24,)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数据在 Pipeline 中的形状变化：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X_dev [96,1]
   │ PolynomialFeatures(degree=8)
   ▼
Φ_dev [96,8]
   │ StandardScaler：每列用训练折统计量变换
   ▼
Z_dev [96,8]
   │ Ridge：拟合 w [8] 与 b []
   ▼
prediction [96]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前 &lt;code&gt;Ridge&lt;/code&gt; 官方目标是：&lt;/p&gt;
&lt;p&gt;$$
|y-Xw|_2^2+\texttt{alpha}|w|_2^2
$$&lt;/p&gt;
&lt;p&gt;几个重要 API 细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Ridge(alpha=...)&lt;/code&gt;：&lt;code&gt;alpha&lt;/code&gt; 必须非负；越大通常收缩越强。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;poly__...&lt;/code&gt;、&lt;code&gt;ridge__alpha&lt;/code&gt;：Pipeline 使用 &lt;code&gt;步骤名__参数名&lt;/code&gt; 暴露内部超参数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GridSearchCV(..., refit=True)&lt;/code&gt;：用交叉验证选择最佳 &lt;code&gt;alpha&lt;/code&gt; 后，在全部开发数据 &lt;code&gt;X_dev&lt;/code&gt; 上重新拟合最佳 Pipeline。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;best_score_&lt;/code&gt;：这里仍是负 RMSE，报告误差时取负号。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;predict(X_test)&lt;/code&gt;：只在全部选择冻结后调用一次；测试集从未进入网格搜索。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;官方文档建议当 &lt;code&gt;alpha=0&lt;/code&gt; 时直接使用 &lt;code&gt;LinearRegression&lt;/code&gt;，而不是把 &lt;code&gt;Ridge(alpha=0)&lt;/code&gt; 当普通最小二乘求解器。&lt;/p&gt;
&lt;h2&gt;07 不依赖黑盒：用 PyTorch 明确写出惩罚&lt;/h2&gt;
&lt;p&gt;下面用线性层展示训练过程中的数据损失和 L2 项如何汇合。为了让公式完全对应代码，我们手动构造 &lt;code&gt;l2_penalty&lt;/code&gt;，并且只惩罚 &lt;code&gt;weight&lt;/code&gt;，不惩罚 &lt;code&gt;bias&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

torch.manual_seed(42)

X = torch.randn(32, 8)                  # [batch=32, features=8]
true_w = torch.tensor([[2.0], [-1.0], [0.5], [0.0],
                       [0.0], [0.0], [0.0], [0.0]])  # [8, 1]
y = X @ true_w + 0.1 * torch.randn(32, 1)             # [32, 1]

model = torch.nn.Linear(8, 1)           # weight [1,8], bias [1]
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
alpha = 0.01

for step in range(300):
    prediction = model(X)               # [32, 1]
    data_loss = torch.mean((prediction - y) ** 2)  # []
    l2_penalty = model.weight.pow(2).sum()          # []
    objective = data_loss + alpha * l2_penalty      # []

    optimizer.zero_grad(set_to_none=True)
    objective.backward()
    optimizer.step()

with torch.no_grad():
    test_X = torch.randn(4, 8)           # [4, 8]
    test_prediction = model(test_X)      # [4, 1]

print(&apos;data loss:&apos;, data_loss.item())
print(&apos;weight norm:&apos;, model.weight.norm().item())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调试时要分别记录 &lt;code&gt;data_loss&lt;/code&gt;、&lt;code&gt;l2_penalty&lt;/code&gt; 和 &lt;code&gt;objective&lt;/code&gt;。只看总目标下降，可能掩盖“数据损失已经变差很多，但惩罚项变小更多”的过强正则化。&lt;/p&gt;
&lt;p&gt;若改用 &lt;code&gt;torch.optim.SGD(..., weight_decay=...)&lt;/code&gt;，必须先确认当前优化器文档的精确定义与系数约定；不要假设它与这段手写目标在任意优化器上都逐步一致。&lt;/p&gt;
&lt;h2&gt;08 alpha 应该怎样选择？&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;alpha&lt;/code&gt; 是超参数，不由训练集残差直接决定。合理流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;候选 alpha（对数尺度）
        │
        ▼
每个 alpha 在开发数据内做交叉验证
        │
        ├── 太小：训练好，验证差
        ├── 适中：验证误差最低
        └── 太大：训练与验证都差
        │
        ▼
冻结 alpha 与全部流程
        │
        ▼
只在最终测试集验收一次
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通常用对数网格，例如 $10^{-4},10^{-3},\ldots,10^4$，因为有效尺度可能跨多个数量级。找到较优区间后再细化，而不是一开始在线性刻度上试 &lt;code&gt;0.1, 0.2, 0.3&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;同时记录三组量：训练误差、验证误差、$|w|_2$。随着 &lt;code&gt;alpha&lt;/code&gt; 增大，权重范数应整体下降；如果完全不变，可能是参数名写错、Pipeline 网格未命中或代码没有使用预期估计器。&lt;/p&gt;
&lt;h2&gt;09 常见错误与调试方法&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不标准化就比较系数或统一惩罚。&lt;/strong&gt; 不同单位让相同预测对应不同权重大小。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用测试集选择 &lt;code&gt;alpha&lt;/code&gt;。&lt;/strong&gt; 测试集一旦参与选择，就不再是最终无偏验收。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;正则化了截距。&lt;/strong&gt; 某些手写实现把所有参数一起平方；先明确是否真的希望惩罚全局基线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;混淆总和与均值。&lt;/strong&gt; 数据损失从 &lt;code&gt;sum&lt;/code&gt; 改成 &lt;code&gt;mean&lt;/code&gt; 后，正则项的相对强度会随样本数变化，&lt;code&gt;alpha&lt;/code&gt; 不能机械照搬。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只观察训练损失。&lt;/strong&gt; 正则化本来就可能提高训练误差，关键证据来自验证误差。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把权重变小当成特征不重要。&lt;/strong&gt; 高度相关特征会共享权重；缩小后的系数不等价于因果重要性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多项式次数爆炸。&lt;/strong&gt; 输入有 $d$ 个特征时，高次组合数量迅速增长，内存和数值条件都可能先失控。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小数值检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;coef = search.best_estimator_.named_steps[&apos;ridge&apos;].coef_
assert coef.shape == (8,)
assert np.isfinite(coef).all()
assert np.isfinite(search.best_score_)
assert search.best_params_[&apos;ridge__alpha&apos;] &gt;= 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果最佳值总落在搜索边界，应扩展网格；如果各折分数方差很大，应先检查数据划分和样本代表性，而不是把更多小数位当成稳定结论。&lt;/p&gt;
&lt;h2&gt;10 它与相近方法有什么不同？&lt;/h2&gt;
&lt;p&gt;| 方法           | 惩罚或约束                      | 典型效果                 | 主要区别                             |
| -------------- | ------------------------------- | ------------------------ | ------------------------------------ |
| L2 / Ridge     | $\alpha\sum_j w_j^2$            | 连续收缩，多数系数不为 0 | 对共线与高方差问题常很稳健           |
| L1 / Lasso     | $\alpha\sum_j \lvert w_j\rvert$ | 可产生精确的 0           | 可做稀疏选择，但相关特征中可能不稳定 |
| Elastic Net    | L1 与 L2 组合                   | 稀疏且带平滑收缩         | 多一个混合比例超参数                 |
| Early Stopping | 限制训练步数                    | 阻止继续拟合噪声         | 通过优化路径约束，不显式惩罚参数     |
| 减小模型容量   | 删除特征、降阶、减小网络        | 缩小假设空间             | 直接移除表达能力，而非柔性收缩       |&lt;/p&gt;
&lt;p&gt;L2 也有失败场景：真正关系需要少数极大系数时会被过度收缩；训练与部署分布改变时无法补救；标签泄漏时甚至可能让一个错误流程显得更稳定；非线性结构根本没有进入特征时，收缩线性权重也不能创造缺失规律。&lt;/p&gt;
&lt;h2&gt;11 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;L2 正则化在数据损失之外惩罚权重平方和，用一点训练拟合换取更稳定的未见数据预测。&lt;/li&gt;
&lt;li&gt;梯度中增加与当前权重成比例的项，使参数在每步更新中向 0 收缩。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alpha&lt;/code&gt; 越大不代表越好；过强正则化会从过拟合走向欠拟合，必须由验证集选择。&lt;/li&gt;
&lt;li&gt;L2 对数值权重施加惩罚，因此特征尺度决定惩罚含义；标准化要放进防泄漏 Pipeline。&lt;/li&gt;
&lt;li&gt;不同库对损失的求和、平均与系数约定可能不同，比较参数前先读目标函数定义。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 思考题与小练习&lt;/h2&gt;
&lt;p&gt;设 $w=[2,-1]$，数据梯度 $g=[0.4,0.2]$，$\eta=0.1$，$\alpha=0.5$，目标使用 $J=J_{data}+\alpha|w|^2$。总梯度是 $g+2\alpha w=[2.4,-0.8]$，更新后 $w=[1.76,-0.92]$。&lt;/p&gt;
&lt;p&gt;将米换成千米后，为保持预测相同，权重数值要放大 1,000 倍，平方惩罚放大 $10^6$ 倍。若不标准化，同一个 &lt;code&gt;alpha&lt;/code&gt; 实际施加了完全不同的约束。&lt;/p&gt;
&lt;p&gt;在代码中记录每个 &lt;code&gt;ridge__alpha&lt;/code&gt; 的训练 RMSE、验证 RMSE 与系数范数。找出验证误差最低点，并解释其左侧为何更像过拟合、右侧为何更像欠拟合。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1080/00401706.1970.10488634&quot;&gt;Hoerl &amp;#x26; Kennard: Ridge Regression—Biased Estimation for Nonorthogonal Problems&lt;/a&gt;：岭回归的奠基论文，讨论用有偏估计降低不稳定性。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1080/00411457108231474&quot;&gt;Tikhonov: Solution of Incorrectly Formulated Problems and the Regularization Method&lt;/a&gt;：正则化逆问题的经典来源。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jstor.org/stable/2346178&quot;&gt;Tibshirani: Regression Shrinkage and Selection via the Lasso&lt;/a&gt;：以 L1 同时实现收缩与变量选择的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1111/j.1467-9868.2005.00503.x&quot;&gt;Zou &amp;#x26; Hastie: Regularization and Variable Selection via the Elastic Net&lt;/a&gt;：结合 L1 与 L2 的经典方法。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.Ridge.html&quot;&gt;scikit-learn: Ridge&lt;/a&gt;：当前官方目标函数、&lt;code&gt;alpha&lt;/code&gt;、输入形状和求解器说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 下一篇预告&lt;/h2&gt;
&lt;p&gt;岭回归展示了怎样在连续预测中控制线性模型的复杂度。下一篇将进入经典机器学习模型，完整推导逻辑回归：线性分数如何变成决策边界、概率如何产生，以及它与“线性回归后强行阈值化”究竟差在哪里。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>训练误差很低为何仍失效？用学习曲线诊断欠拟合与过拟合</title><link>https://zwjcode.cn/blog/learning-curves-underfitting-overfitting</link><guid isPermaLink="true">https://zwjcode.cn/blog/learning-curves-underfitting-overfitting</guid><description>从训练误差与验证误差的差距出发，手算学习曲线，区分欠拟合、过拟合与优化失败，并用 sklearn 复现可执行的诊断流程。</description><pubDate>Thu, 20 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇中，我们已经能把样本组成小批量，让损失沿着梯度稳定下降。假设训练结束后，训练集均方根误差只有 0.1，你会认为模型已经足够好吗？&lt;/p&gt;
&lt;p&gt;未必。房价模型可能把训练社区的偶然噪声记得一清二楚，换到新社区误差却变成 5.0；另一种模型在训练集和验证集上的误差都很高，说明它连已有规律也没有学会。只报一个训练损失，会把这两种完全不同的问题混在一起。&lt;/p&gt;
&lt;p&gt;今天只讲透一个核心问题：&lt;strong&gt;如何用训练误差、验证误差随数据量的变化，判断模型是欠拟合、过拟合，还是根本没有正确完成优化？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 一次低训练误差只回答了半个问题&lt;/h2&gt;
&lt;p&gt;设训练集为 $D_{train}$，验证集为 $D_{val}$，回归任务用均方根误差（Root Mean Squared Error，RMSE）：&lt;/p&gt;
&lt;p&gt;$$
E(D,\theta)=\sqrt{\frac{1}{|D|}\sum_{(x_i,y_i)\in D}\left(f_\theta(x_i)-y_i\right)^2}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$D$：被评价的数据集合。&lt;/li&gt;
&lt;li&gt;$|D|$：集合中的样本数。&lt;/li&gt;
&lt;li&gt;$x_i\in\mathbb{R}^{d}$：第 $i$ 个样本的 $d$ 维特征。&lt;/li&gt;
&lt;li&gt;$y_i\in\mathbb{R}$：真实连续目标。&lt;/li&gt;
&lt;li&gt;$f_\theta(x_i)\in\mathbb{R}$：模型预测。&lt;/li&gt;
&lt;li&gt;$E(D,\theta)\in\mathbb{R}_{\ge 0}$：以原目标单位表示的误差。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;训练后同时计算：&lt;/p&gt;
&lt;p&gt;$$
E_{train}=E(D_{train},\theta),\qquad
E_{val}=E(D_{val},\theta)
$$&lt;/p&gt;
&lt;p&gt;二者分别回答不同问题：训练误差说明模型能否拟合已经见过的数据，验证误差说明同一参数能否迁移到未参与拟合的数据。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;D_train [m,d] ──► fit ──► 参数 θ
      │                      │
      └──── predict ─────────┴──► E_train

D_val   [v,d] ──► predict(同一 θ) ──► E_val
                  不 backward，不更新
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的验证集仍承担上一篇定义的模型选择职责，测试集继续封存。不要画一次曲线就提前消费最终测试集。&lt;/p&gt;
&lt;h2&gt;02 欠拟合和过拟合究竟差在哪里？&lt;/h2&gt;
&lt;p&gt;**欠拟合（Underfitting）**指模型连训练数据中的主要规律都表达或学不好，训练误差已经偏高。**过拟合（Overfitting）**指模型在训练数据上表现很好，却把噪声或偶然模式当成可推广规律，验证误差明显更高。&lt;/p&gt;
&lt;p&gt;二者可以先用一张表区分：&lt;/p&gt;
&lt;p&gt;| 现象       | 训练误差 | 验证误差 | 泛化差距 $E_{val}-E_{train}$ | 首要检查                     |
| ---------- | -------: | -------: | ---------------------------: | ---------------------------- |
| 欠拟合候选 |       高 |       高 |                           小 | 表达能力、特征、训练是否充分 |
| 过拟合候选 |       低 |       高 |                           大 | 数据量、模型容量、正则化     |
| 理想状态   |       低 |       低 |                           小 | 分布与业务指标是否仍匹配     |
| 优化失败   | 高且波动 |       高 |                       不稳定 | 学习率、梯度、输入尺度、代码 |&lt;/p&gt;
&lt;p&gt;“低”和“高”必须相对于任务基线判断。房价 RMSE 为 5 万元可能很好，也可能不可用；应同时比较简单基线、标签噪声和业务容忍度。&lt;/p&gt;
&lt;h2&gt;03 学习曲线为什么要改变训练样本数？&lt;/h2&gt;
&lt;p&gt;单次训练只给出一个点。**学习曲线（Learning Curve）**保持模型结构和训练规则不变，逐步增加训练样本数 $m$，每次重新拟合并记录：&lt;/p&gt;
&lt;p&gt;$$
E_{train}(m),\qquad E_{val}(m)
$$&lt;/p&gt;
&lt;p&gt;伪代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;split development data into K folds

for m in increasing_train_sizes:
    for fold in 1 ... K:
        take m samples from this fold&apos;s training portion
        fit a fresh model from scratch
        record score on those m training samples
        record score on the untouched validation fold
    average the K training scores and K validation scores

plot error against m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意每个点都要从头拟合一个新模型。把同一个模型继续训练更久，改变的是训练步数，不是训练集大小；那叫训练过程曲线，不是这里的样本量学习曲线。&lt;/p&gt;
&lt;h3&gt;两种典型形状&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;误差                         误差
 ▲                            ▲
 │ val  ╲                     │ val  ╲____
 │       ╲____                │           ╲__
 │            ╲__             │ train ____╱
 │ train _______╱             │
 └──────────────► m           └──────────────► m
    仍有明显间隔                  很快汇合在高误差
       过拟合                         欠拟合
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;数据增加后差距仍大，但验证误差持续下降：更多代表性数据通常有帮助。&lt;/li&gt;
&lt;li&gt;两条曲线很快汇合，却停在不可接受的高误差：继续收集同分布数据往往收益有限，应增加有效特征或模型能力。&lt;/li&gt;
&lt;li&gt;两条曲线都低且接近：当前容量与数据量较匹配，但仍需检查测试集和部署分布。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;04 用六个点手算一条最小学习曲线&lt;/h2&gt;
&lt;p&gt;假设真实关系接近 $y=2x$，但观测含少量噪声。我们固定两个验证样本，只改变训练子集大小，并比较同一个高容量模型：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;训练池：(0,0), (1,2), (2,4), (3,6.5), (4,8), (5,9.5)
验证集：(1.5,3.1), (4.5,9.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每次重新拟合后得到：&lt;/p&gt;
&lt;p&gt;| 训练样本数 $m$ | $E_{train}(m)$ | $E_{val}(m)$ | 泛化差距 |
| -------------: | -------------: | -----------: | -------: |
|              2 |           0.00 |         3.00 |     3.00 |
|              4 |           0.10 |         1.50 |     1.40 |
|              6 |           0.25 |         0.80 |     0.55 |&lt;/p&gt;
&lt;p&gt;训练样本很少时，模型可以穿过每个训练点，所以训练误差为 0，却无法预测两点之间的位置。加入更多样本后，约束变多，训练误差反而略升；与此同时，验证误差和泛化差距下降。&lt;/p&gt;
&lt;p&gt;这说明一个容易误判的事实：&lt;strong&gt;训练误差略微变差，不一定是退步；它可能意味着模型不再只服务于少量样本。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;若验证误差随 $m$ 仍明显下降，新增同分布、可靠标注的数据值得尝试；若成本很高，也可以同时测试减小容量或加强正则化。曲线提供诊断证据，不会替代成本与任务约束。&lt;/p&gt;
&lt;h2&gt;05 用当前 scikit-learn API 复现&lt;/h2&gt;
&lt;p&gt;下面构造一个一维非线性回归任务，用 8 次多项式制造容易过拟合的模型。输入输出形状为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原始 X:               [N=120, D=1]
PolynomialFeatures:   [N=120, P=8]
y:                     [N=120]
train_scores:          [num_sizes=5, cv_folds=5]
validation_scores:     [5, 5]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import KFold, learning_curve
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler

rng = np.random.default_rng(42)
X = np.linspace(-3, 3, 120, dtype=np.float64).reshape(-1, 1)
y = np.sin(X[:, 0]) + rng.normal(0.0, 0.20, size=X.shape[0])

model = make_pipeline(
    PolynomialFeatures(degree=8, include_bias=False),
    StandardScaler(),
    LinearRegression(),
)

cv = KFold(n_splits=5, shuffle=True, random_state=42)
sizes, train_scores, validation_scores = learning_curve(
    estimator=model,
    X=X,
    y=y,
    train_sizes=np.linspace(0.2, 1.0, 5),
    cv=cv,
    scoring=&apos;neg_root_mean_squared_error&apos;,
    shuffle=True,
    random_state=42,
    n_jobs=-1,
)

train_rmse = -train_scores.mean(axis=1)
validation_rmse = -validation_scores.mean(axis=1)
validation_std = validation_scores.std(axis=1)

for m, tr, va, spread in zip(
    sizes, train_rmse, validation_rmse, validation_std
):
    print(f&apos;{m:3d} train={tr:.3f} validation={va:.3f} ± {spread:.3f}&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键 API 的语义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PolynomialFeatures(degree=8, include_bias=False)&lt;/code&gt;：把单特征 $x$ 展开为 $[x,x^2,\ldots,x^8]$；不额外生成常数列，因为线性回归会拟合截距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;make_pipeline(...)&lt;/code&gt;：让特征展开、标准化和回归在每个交叉验证折内重新拟合，避免验证折影响预处理。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;KFold(..., shuffle=True)&lt;/code&gt;：产生 5 组训练/验证索引；时间或分组数据应换成与部署结构一致的划分器。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;learning_curve(...)&lt;/code&gt;：对每个训练规模和每个折克隆估计器并重新拟合，返回训练分数与验证分数矩阵。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;neg_root_mean_squared_error&lt;/code&gt;：scikit-learn 的评分器遵循“越大越好”，因此返回负 RMSE；转回误差时必须取负号。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;官方当前接口把第三个返回值命名为 &lt;code&gt;test_scores&lt;/code&gt;，但它来自交叉验证中暂时留出的验证折，并不等于你封存的最终测试集。代码里改名为 &lt;code&gt;validation_scores&lt;/code&gt;，是为了避免职责混淆。&lt;/p&gt;
&lt;h2&gt;06 怎样把曲线变成下一步行动？&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;先确认训练流程能优化
        │
        ├── E_train 高且曲线异常抖动
        │      └── 查学习率、梯度、标签、尺度与实现
        │
        └── 优化基本正常
               │
               ├── E_train 与 E_val 都高且接近
               │      └── 更有效特征、更强模型、训练更充分
               │
               └── E_train 低，E_val 高
                      └── 更多数据、减小容量、正则化、数据增强
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;欠拟合候选&lt;/h3&gt;
&lt;p&gt;可依次尝试：确认优化收敛；增加表达任务结构的特征；降低过强正则化；增加模型容量。若标签本身噪声很大，则两条误差曲线可能存在无法跨越的下限。&lt;/p&gt;
&lt;h3&gt;过拟合候选&lt;/h3&gt;
&lt;p&gt;可尝试：增加代表性训练数据；减小树深、次数或网络宽度；加强正则化；使用符合问题结构的数据增强。选择哪一个，取决于验证曲线是否仍从更多数据中获益，以及工程成本。&lt;/p&gt;
&lt;h3&gt;不要只看均值&lt;/h3&gt;
&lt;p&gt;交叉验证折间标准差很大，意味着结论依赖具体划分。此时应检查样本量、类别/群组分布和异常样本，而不是只相信一条平滑均值线。&lt;/p&gt;
&lt;h2&gt;07 常见错误与最小调试法&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;用测试集画学习曲线。&lt;/strong&gt; 曲线会参与模型决策，应只使用训练/验证开发数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不同训练规模使用不同训练预算。&lt;/strong&gt; 小数据训练 100 轮、大数据只训练 100 步，会混入优化不足；记录每个规模的收敛状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;预处理在交叉验证外拟合。&lt;/strong&gt; 标准化、特征选择和填补必须放入 Pipeline。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把准确率差距直接当误差差距。&lt;/strong&gt; 分数越大越好、损失越小越好；先统一方向再解释曲线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只画一个随机划分。&lt;/strong&gt; 小数据中一次划分方差很大，应报告多折均值和波动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;训练子集不具代表性。&lt;/strong&gt; 有序数据直接取前缀，可能让小规模点只包含某一类别；需要正确洗牌或结构化采样。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小检查清单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert X.ndim == 2 and y.ndim == 1
assert X.shape[0] == y.shape[0]
assert np.isfinite(X).all() and np.isfinite(y).all()
assert train_scores.shape == validation_scores.shape
assert np.all(np.diff(sizes) &gt; 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若训练误差随样本量剧烈乱跳，先固定随机种子并打印每个折的分数；若某些折出现 &lt;code&gt;nan&lt;/code&gt;，将 &lt;code&gt;error_score=&apos;raise&apos;&lt;/code&gt; 临时传给 &lt;code&gt;learning_curve&lt;/code&gt;，让真实拟合异常直接暴露。&lt;/p&gt;
&lt;h2&gt;08 学习曲线不能告诉你什么？&lt;/h2&gt;
&lt;p&gt;学习曲线是诊断工具，不是因果证明。它有明确边界：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;训练与验证分布都过时，两条曲线再漂亮也不能保证上线有效。&lt;/li&gt;
&lt;li&gt;标签泄漏会让两条误差同时虚假地低。&lt;/li&gt;
&lt;li&gt;概念漂移任务中，随机交叉验证可能掩盖时间变化。&lt;/li&gt;
&lt;li&gt;不同类别的平均误差可能相互抵消，需要再看分组指标。&lt;/li&gt;
&lt;li&gt;曲线无法自动区分“模型类不合适”和“特征缺少关键信息”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它也不同于另外两类曲线：&lt;/p&gt;
&lt;p&gt;| 曲线         | 横轴         | 主要回答                       |
| ------------ | ------------ | ------------------------------ |
| 学习曲线     | 训练样本数   | 更多数据是否可能有用？         |
| 训练过程曲线 | epoch / step | 优化是否收敛、何时开始过拟合？ |
| 验证曲线     | 某个超参数值 | 哪个容量或正则化强度更合适？   |&lt;/p&gt;
&lt;h2&gt;09 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;训练误差衡量拟合已有样本的能力，验证误差衡量同一参数的迁移能力；二者缺一不可。&lt;/li&gt;
&lt;li&gt;欠拟合常表现为训练与验证误差都高且接近；过拟合常表现为训练误差低、验证误差高且差距大。&lt;/li&gt;
&lt;li&gt;学习曲线通过改变训练样本数并反复重新拟合，判断更多数据是否仍可能缩小泛化差距。&lt;/li&gt;
&lt;li&gt;高训练误差也可能来自优化或数据故障，诊断容量之前先验证训练管道。&lt;/li&gt;
&lt;li&gt;测试集不参与画曲线，预处理必须留在每个交叉验证折内部。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;10 思考题与小练习&lt;/h2&gt;
&lt;p&gt;当样本数从 1,000 增到 10,000 时，训练 RMSE 从 0.3 升到 0.5，验证 RMSE 从 2.0 降到 0.8，且仍在下降。主要证据指向过拟合正在缓解，继续增加代表性数据可能有效；训练误差略升不是训练失败。&lt;/p&gt;
&lt;p&gt;某脚本固定训练集，只记录第 1、5、20、100 个 epoch 的训练与验证误差。这是训练过程曲线，因为横轴是训练时间；真正的学习曲线要改变样本数，并在每个规模重新拟合。&lt;/p&gt;
&lt;p&gt;把示例中的多项式次数依次改为 1、3、15，比较曲线末端的训练误差、验证误差和差距。解释为什么次数 1 更容易欠拟合，而次数 15 在小数据区域更容易过拟合。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://link.springer.com/book/10.1007/978-1-4757-3264-1&quot;&gt;Vapnik: The Nature of Statistical Learning Theory&lt;/a&gt;：从容量控制与泛化角度建立统计学习理论基础。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1162/neco.1992.4.1.1&quot;&gt;Geman, Bienenstock &amp;#x26; Doursat: Neural Networks and the Bias/Variance Dilemma&lt;/a&gt;：系统讨论学习系统中的偏差与方差权衡。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://homes.cs.washington.edu/~pedrod/papers/mlc00a.pdf&quot;&gt;Domingos: A Unified Bias-Variance Decomposition&lt;/a&gt;：将偏差—方差分析推广到更一般损失的代表性工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.learning_curve.html&quot;&gt;scikit-learn: Learning curve&lt;/a&gt;：当前官方函数签名、返回形状与交叉验证语义。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.PolynomialFeatures.html&quot;&gt;scikit-learn: PolynomialFeatures&lt;/a&gt;：当前多项式特征生成规则与高次数过拟合提示。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;11 下一篇预告&lt;/h2&gt;
&lt;p&gt;学习曲线能指出“训练很好、验证很差”，却不会自动修复它。下一篇将给经验风险加上一项对大权重的惩罚，手算 L2 正则化如何收缩参数，并看清岭回归为什么能让高次多项式不再追逐每一个噪声点。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>一批样本如何完成一次参数更新？从全批量到小批量 SGD</title><link>https://zwjcode.cn/blog/mini-batch-gradient-descent</link><guid isPermaLink="true">https://zwjcode.cn/blog/mini-batch-gradient-descent</guid><description>沿一次训练迭代追踪张量与梯度，手算两个 mini-batch 的参数更新，并用 PyTorch DataLoader 实现可调试的小批量训练循环。</description><pubDate>Wed, 19 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇把二分类目标写成了可导的二元交叉熵。只要对损失求导，似乎就能更新参数。&lt;/p&gt;
&lt;p&gt;但现实训练集可能有一千万个样本：每更新一次都扫描全量数据，等待太久；只看一个样本再更新，方向又会剧烈摇摆。工程实践为何几乎总把数据切成小批量（Mini-batch）？一个 batch 究竟对应几次更新？&lt;/p&gt;
&lt;p&gt;今天只追踪一件事：&lt;strong&gt;一批样本如何从磁盘或内存进入模型，并最终变成一次参数更新。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 一次“训练步”到底包含什么？&lt;/h2&gt;
&lt;p&gt;设模型参数为 $\theta$，第 $i$ 个样本损失为 $L_i(\theta)$。拿到含 $B$ 个样本的小批后，通常先求批内平均损失：&lt;/p&gt;
&lt;p&gt;$$
L_{batch}(\theta)=\frac{1}{B}\sum_{i=1}^{B}L_i(\theta)
$$&lt;/p&gt;
&lt;p&gt;再计算平均梯度并更新：&lt;/p&gt;
&lt;p&gt;$$
g=\nabla_\theta L_{batch}(\theta)
=\frac{1}{B}\sum_{i=1}^{B}\nabla_\theta L_i(\theta)
$$&lt;/p&gt;
&lt;p&gt;$$
\theta\leftarrow\theta-\eta g
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$B$：批大小（Batch Size）。&lt;/li&gt;
&lt;li&gt;$g$：当前批对全部参数的梯度估计。&lt;/li&gt;
&lt;li&gt;$\eta$：学习率（Learning Rate）。&lt;/li&gt;
&lt;li&gt;一次 &lt;code&gt;optimizer.step()&lt;/code&gt;：一次参数更新，也常叫一个训练步（Training Step）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;完整数据流如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Dataset 中的 B 个样本
        │ 组批
        ▼
X_batch [B,D]，y_batch [B,1]
        │ forward
        ▼
预测 [B,1] ──► 每样本损失 [B,1]
                         │ mean
                         ▼
                    标量 loss []
                         │ backward
                         ▼
              每个参数的 .grad
                         │ optimizer.step
                         ▼
                    新参数 θ
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;02 全批量、随机与小批量差在哪里？&lt;/h2&gt;
&lt;p&gt;设训练集共有 $N$ 个样本：&lt;/p&gt;
&lt;p&gt;| 方法                                             | 每次更新使用样本数 |       每轮更新次数 | 梯度特点             |
| ------------------------------------------------ | -----------------: | -----------------: | -------------------- |
| 全批量梯度下降（Batch Gradient Descent）         |                $N$ |                  1 | 精确但单步昂贵       |
| 随机梯度下降（Stochastic Gradient Descent，SGD） |                  1 |                $N$ | 便宜但噪声大         |
| 小批量梯度下降（Mini-batch Gradient Descent）    |            $1&amp;#x3C;B&amp;#x3C;N$ | $\lceil N/B\rceil$ | 并行效率与噪声的折中 |&lt;/p&gt;
&lt;p&gt;这里容易产生一个术语歧义：深度学习代码中的 &lt;code&gt;torch.optim.SGD&lt;/code&gt; 通常也用于小批量训练。优化器只接收当前 &lt;code&gt;.grad&lt;/code&gt;，并不知道这个梯度来自 1 个、32 个还是全体样本。&lt;/p&gt;
&lt;h3&gt;Epoch、iteration 与 step&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Epoch（训练轮次）&lt;/strong&gt;：所有训练样本大致被使用一遍。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Iteration（迭代）&lt;/strong&gt;：取出一个 batch 并运行一次训练循环。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Step（参数更新步）&lt;/strong&gt;：调用一次优化器更新参数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;没有梯度累积时，一次 iteration 通常对应一次 step。若 $N=10{,}000,B=128,drop_last=False$，一轮有：&lt;/p&gt;
&lt;p&gt;$$
\left\lceil\frac{10{,}000}{128}\right\rceil=79
$$&lt;/p&gt;
&lt;p&gt;个 batch，因此通常更新 79 次；最后一个 batch 只有 16 个样本。&lt;/p&gt;
&lt;h2&gt;03 手算两个 mini-batch 的更新&lt;/h2&gt;
&lt;p&gt;继续使用最简单的无偏置线性模型：&lt;/p&gt;
&lt;p&gt;$$
\hat y=wx,\qquad L_i=\frac{1}{2}(wx_i-y_i)^2
$$&lt;/p&gt;
&lt;p&gt;单样本梯度为：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L_i}{\partial w}=(wx_i-y_i)x_i
$$&lt;/p&gt;
&lt;p&gt;数据集有 4 个样本，批大小为 2：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Batch 1: (x=1,y=2), (x=2,y=4)
Batch 2: (x=3,y=6), (x=4,y=8)

初始 w=0，学习率 η=0.1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;第一个 batch&lt;/h3&gt;
&lt;p&gt;两个样本在 $w=0$ 时的梯度：&lt;/p&gt;
&lt;p&gt;$$
g_1=(0\times1-2)\times1=-2
$$&lt;/p&gt;
&lt;p&gt;$$
g_2=(0\times2-4)\times2=-8
$$&lt;/p&gt;
&lt;p&gt;批内平均梯度：&lt;/p&gt;
&lt;p&gt;$$
g_{batch1}=\frac{-2-8}{2}=-5
$$&lt;/p&gt;
&lt;p&gt;更新参数：&lt;/p&gt;
&lt;p&gt;$$
w\leftarrow0-0.1\times(-5)=0.5
$$&lt;/p&gt;
&lt;h3&gt;第二个 batch&lt;/h3&gt;
&lt;p&gt;注意：第二批使用的是已经更新后的 $w=0.5$。&lt;/p&gt;
&lt;p&gt;$$
g_3=(0.5\times3-6)\times3=-13.5
$$&lt;/p&gt;
&lt;p&gt;$$
g_4=(0.5\times4-8)\times4=-24
$$&lt;/p&gt;
&lt;p&gt;$$
g_{batch2}=\frac{-13.5-24}{2}=-18.75
$$&lt;/p&gt;
&lt;p&gt;$$
w\leftarrow0.5-0.1\times(-18.75)=2.375
$$&lt;/p&gt;
&lt;p&gt;一轮结束后 $w=2.375$。它越过了真实关系 $y=2x$ 的最优值 2，说明后一个 batch 的大尺度特征产生了更大梯度。这个例子同时暴露两个工程事实：样本顺序会影响轨迹，特征尺度会影响更新幅度。&lt;/p&gt;
&lt;p&gt;在 $w=0$ 时，第二批平均梯度为 $[(-6)\times3+(-8)\times4]/2=-25$，一步就把 $w$ 更新到 2.5。随后 Batch 1 又会把它拉回。最终值与原顺序不同，因此每轮打乱数据通常很重要。&lt;/p&gt;
&lt;h2&gt;04 为什么小批量的“噪声”有时有帮助？&lt;/h2&gt;
&lt;p&gt;全量经验风险是：&lt;/p&gt;
&lt;p&gt;$$
\hat R(\theta)=\frac{1}{N}\sum_{i=1}^{N}L_i(\theta)
$$&lt;/p&gt;
&lt;p&gt;随机抽取的小批梯度：&lt;/p&gt;
&lt;p&gt;$$
\hat g_B=\frac{1}{B}\sum_{i\in\mathcal B}\nabla_\theta L_i(\theta)
$$&lt;/p&gt;
&lt;p&gt;在均匀抽样等条件下，$\hat g_B$ 是全量梯度的近似估计。不同 batch 会给出不同方向，形成 &lt;strong&gt;梯度噪声（Gradient Noise）&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;参数空间中的损失等高线

              · batch 2 梯度
             ↘
        ┌──────────┐
      ┌─┘    ● θ   └─┐────► 全量梯度
        └──────────┘
             ↗
              · batch 1 梯度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;批越大，估计通常越稳定，但单步计算和显存占用越高；批越小，更新频繁且噪声更大。适量噪声可能帮助离开狭窄区域，但太大时损失会剧烈震荡甚至发散。&lt;/p&gt;
&lt;p&gt;批大小改变后，不能机械保持其余设置不变。损失的 &lt;code&gt;mean&lt;/code&gt; 或 &lt;code&gt;sum&lt;/code&gt;、学习率、归一化层和分布式并行方式都会改变有效更新。&lt;/p&gt;
&lt;h2&gt;05 先写不依赖框架的伪代码&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;initialize θ

for epoch in 1 ... E:
    shuffle(training_examples)

    for (X_batch, y_batch) in batches:
        predictions = model(X_batch; θ)
        per_example_loss = loss(predictions, y_batch)
        batch_loss = mean(per_example_loss)

        gradients = backward(batch_loss, θ)
        θ = θ - learning_rate * gradients

    evaluate once on validation set without updating θ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键边界是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;每轮只打乱训练集，不打乱时间顺序任务中不能交换的因果顺序。&lt;/li&gt;
&lt;li&gt;每个 batch 先前向并汇总为标量损失，再反向。&lt;/li&gt;
&lt;li&gt;验证阶段不调用 &lt;code&gt;backward()&lt;/code&gt; 和 &lt;code&gt;step()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;一个 epoch 内参数会更新很多次，所以后面的 batch 看到的是更新后的模型。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;06 用当前 PyTorch API 落地&lt;/h2&gt;
&lt;p&gt;PyTorch 的 &lt;code&gt;Dataset&lt;/code&gt; 描述“怎样按索引得到一个样本”，&lt;code&gt;DataLoader&lt;/code&gt; 负责采样、组批和可选的多进程加载。下面仍拟合 $y=2x$，但保留明确的二维形状：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)

X = torch.arange(1, 9, dtype=torch.float32).view(-1, 1)  # [N=8, D=1]
y = 2 * X                                                # [8, 1]

dataset = TensorDataset(X, y)  # 沿第 0 维配对；两个张量长度必须相同
loader = DataLoader(
    dataset,
    batch_size=2,
    shuffle=True,
    drop_last=False,
    num_workers=0,
)

model = torch.nn.Linear(in_features=1, out_features=1)  # [B,1] -&gt; [B,1]
loss_fn = torch.nn.MSELoss(reduction=&apos;mean&apos;)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

for epoch in range(100):
    model.train()
    epoch_loss_sum = 0.0
    example_count = 0

    for X_batch, y_batch in loader:
        predictions = model(X_batch)           # [B_current, 1]
        loss = loss_fn(predictions, y_batch)   # []，当前批均值

        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        optimizer.step()

        current_batch_size = X_batch.shape[0]
        epoch_loss_sum += loss.item() * current_batch_size
        example_count += current_batch_size

    if epoch % 20 == 0:
        print(epoch, epoch_loss_sum / example_count)

model.eval()
with torch.no_grad():
    test_X = torch.tensor([[9.0]])              # [1, 1]
    prediction = model(test_X)                  # [1, 1]
    print(prediction.item())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;每个 API 真正控制什么？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TensorDataset(X, y)&lt;/code&gt;：按第 0 维索引所有张量，返回 &lt;code&gt;(X[i], y[i])&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DataLoader(..., batch_size=2)&lt;/code&gt;：把两个样本自动堆成一批；最后一批可能更小。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;shuffle=True&lt;/code&gt;：每个 epoch 重新排列索引，降低固定顺序偏差。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;drop_last=False&lt;/code&gt;：保留不足一个完整 batch 的最后样本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;num_workers=0&lt;/code&gt;：在主进程加载，调试最清楚；数据读取成为瓶颈后再测更大的值。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;optimizer.zero_grad(set_to_none=True)&lt;/code&gt;：清除旧梯度；当前官方默认语义也是把梯度设为 &lt;code&gt;None&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loss.item()&lt;/code&gt;：把零维张量取成 Python 数字，仅用于日志，不参与反向传播。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;07 &lt;code&gt;mean&lt;/code&gt;、&lt;code&gt;sum&lt;/code&gt; 与梯度规模&lt;/h2&gt;
&lt;p&gt;假设每个样本梯度都是 2：&lt;/p&gt;
&lt;p&gt;| batch size | &lt;code&gt;reduction=&apos;mean&apos;&lt;/code&gt; 后梯度 | &lt;code&gt;reduction=&apos;sum&apos;&lt;/code&gt; 后梯度 |
| ---------: | ------------------------: | -----------------------: |
|          2 |                         2 |                        4 |
|         32 |                         2 |                       64 |&lt;/p&gt;
&lt;p&gt;使用 &lt;code&gt;mean&lt;/code&gt; 时，扩大 batch 通常不会仅因为样本数增加就线性放大梯度；使用 &lt;code&gt;sum&lt;/code&gt; 时会。因此复现实验必须同时记录 batch size、损失 reduction 和学习率。&lt;/p&gt;
&lt;h3&gt;梯度累积不是免费等价&lt;/h3&gt;
&lt;p&gt;显存只能放 8 个样本，却想模拟 batch size 32，可以累计 4 个 micro-batch 的梯度，再调用一次 &lt;code&gt;step()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;optimizer.zero_grad(set_to_none=True)

for micro_step, (X_batch, y_batch) in enumerate(loader, start=1):
    loss = loss_fn(model(X_batch), y_batch) / 4
    loss.backward()

    if micro_step % 4 == 0:
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除以 4 是为了让四次 &lt;code&gt;mean&lt;/code&gt; 梯度的和仍对应大批均值。若最后不足 4 个 micro-batch，还要单独处理；含 Batch Normalization、随机增强或可变长度样本时，累计与真正大 batch 也未必完全等价。&lt;/p&gt;
&lt;h2&gt;08 训练循环最容易出错在哪里？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;忘记清梯度。&lt;/strong&gt; PyTorch 默认累加 &lt;code&gt;.grad&lt;/code&gt;；若非刻意做梯度累积，应在每步反向前清除。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;step()&lt;/code&gt; 放错层级。&lt;/strong&gt; 放到 epoch 外会变成整轮只更新一次；放到样本内又会破坏计划的 batch 语义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证时仍在更新。&lt;/strong&gt; 使用 &lt;code&gt;model.eval()&lt;/code&gt; 和 &lt;code&gt;torch.no_grad()&lt;/code&gt;，并且不调用优化器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;最后一批导致形状假设失败。&lt;/strong&gt; 不要把代码写死为固定 &lt;code&gt;batch_size&lt;/code&gt;，用 &lt;code&gt;X_batch.shape[0]&lt;/code&gt; 获取实际大小。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;打乱了不可打乱的数据。&lt;/strong&gt; 时间序列、语言序列内部顺序不能被普通随机采样破坏；要设计窗口与采样器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据加载比计算更慢。&lt;/strong&gt; 分别计时取 batch 与前向/反向，再调整 &lt;code&gt;num_workers&lt;/code&gt;、缓存和 &lt;code&gt;pin_memory&lt;/code&gt;，不要盲目加进程。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看 batch loss。&lt;/strong&gt; 单批噪声很大；同时记录按样本加权的 epoch 均值、验证损失和梯度范数。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;推荐在第一次运行中检查：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;X_batch, y_batch = next(iter(loader))
print(X_batch.shape, y_batch.shape)

predictions = model(X_batch)
loss = loss_fn(predictions, y_batch)
loss.backward()

for name, parameter in model.named_parameters():
    print(name, parameter.shape, parameter.grad.shape,
          torch.isfinite(parameter.grad).all().item())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先验证一个 batch 的数据、形状、损失和梯度都正确，再开始长时间训练。&lt;/p&gt;
&lt;h2&gt;09 batch size 应该怎样选？&lt;/h2&gt;
&lt;p&gt;没有脱离模型、硬件与数据的最佳 batch size。可以按以下顺序试验：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从能稳定运行的中小值开始，例如 32 或 64。&lt;/li&gt;
&lt;li&gt;测量每秒样本数、显存峰值和验证指标，而不只比较单步耗时。&lt;/li&gt;
&lt;li&gt;调大 batch 后重新检查学习率和训练步数；相同 epoch 不代表相同更新次数。&lt;/li&gt;
&lt;li&gt;若样本长度差异大，按长度分桶可减少填充浪费。&lt;/li&gt;
&lt;li&gt;分布式训练要区分每设备 batch 与全局 batch：全局大小通常是二者再乘设备数与累积步数。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;global_batch_size
= per_device_batch_size × device_count × accumulation_steps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;批太小的失败表现通常是曲线抖动、硬件利用率低；批太大的表现可能是显存溢出、每轮更新步数过少，以及固定训练轮数下优化不充分。&lt;/p&gt;
&lt;h2&gt;10 与相近概念的边界&lt;/h2&gt;
&lt;p&gt;| 概念             | 改变了什么                          | 没有自动解决什么         |
| ---------------- | ----------------------------------- | ------------------------ |
| 小批量 SGD       | 用子集估计当前梯度                  | 不自动选择学习率         |
| Momentum（动量） | 汇总历史方向，平滑更新              | 不改变数据如何组批       |
| 梯度累积         | 多次反向后再更新，降低单次显存      | 不加速数据读取           |
| 数据并行         | 多设备分别计算局部 batch 梯度再聚合 | 不保证随机性与复现       |
| 学习率调度       | 随训练过程改变步长                  | 不修复错误标签或数据泄漏 |&lt;/p&gt;
&lt;p&gt;本文只建立最基本的小批量训练语义；动量、自适应优化器和分布式训练都建立在这个循环上。&lt;/p&gt;
&lt;h2&gt;11 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一个 mini-batch 先产生批内平均损失，再反向得到梯度，最后通常对应一次参数更新。&lt;/li&gt;
&lt;li&gt;batch size 在梯度稳定性、更新频率、硬件吞吐和显存之间做折中。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DataLoader&lt;/code&gt; 负责取样和组批，优化器只看到当前参数梯度，不知道梯度来自多少样本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zero_grad → forward → loss → backward → step&lt;/code&gt; 的边界必须明确。&lt;/li&gt;
&lt;li&gt;调试时先让单个 batch 的形状、数值和梯度正确，再扩展到完整训练。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;12 思考题与小练习&lt;/h2&gt;
&lt;p&gt;$N=1001,B=64,drop_last=False$ 时，每轮有 $\lceil1001/64\rceil=16$ 次更新，最后一批 41 个样本；若 &lt;code&gt;drop_last=True&lt;/code&gt;，则只有 15 次更新且每轮丢掉 41 个样本。&lt;/p&gt;
&lt;p&gt;把 &lt;code&gt;MSELoss(reduction=&apos;mean&apos;)&lt;/code&gt; 改成 &lt;code&gt;&apos;sum&apos;&lt;/code&gt;，再将 batch size 从 2 改成 8。为什么相同学习率可能发散？因为梯度会随批内元素数近似线性放大。&lt;/p&gt;
&lt;p&gt;在手算例子中交换两个 batch 的顺序，完成两次更新。比较最终 $w$，并解释为什么每轮 shuffle 能减弱固定排序带来的系统偏差，却不能保证每次运行完全相同。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://projecteuclid.org/journals/annals-of-mathematical-statistics/volume-22/issue-3/A-Stochastic-Approximation-Method/10.1214/aoms/1177729586.full&quot;&gt;Robbins &amp;#x26; Monro: A Stochastic Approximation Method&lt;/a&gt;：随机近似与随机梯度思想的奠基工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.mlr.press/v19/bottou12a.html&quot;&gt;Bottou: Large-Scale Machine Learning with Stochastic Gradient Descent&lt;/a&gt;：SGD 用于大规模学习的经典综述。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1609.04836&quot;&gt;Keskar et al.: On Large-Batch Training for Deep Learning&lt;/a&gt;：讨论大批量训练与泛化行为的代表性研究。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1706.02677&quot;&gt;Goyal et al.: Accurate, Large Minibatch SGD&lt;/a&gt;：大批量训练中学习率与训练策略的实证工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytorch.org/docs/stable/data.html&quot;&gt;PyTorch: torch.utils.data&lt;/a&gt;：当前 &lt;code&gt;Dataset&lt;/code&gt;、&lt;code&gt;DataLoader&lt;/code&gt;、自动组批与多进程加载的官方接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;13 下一篇预告&lt;/h2&gt;
&lt;p&gt;现在我们已经能让损失稳定下降，但“训练得更好”仍可能只是记住训练集。下一篇将用训练曲线拆开欠拟合与过拟合，判断问题究竟来自模型容量、数据数量，还是训练过程本身。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>为什么分类器不直接优化准确率？从 0-1 损失到二元交叉熵</title><link>https://zwjcode.cn/blog/classification-loss-cross-entropy</link><guid isPermaLink="true">https://zwjcode.cn/blog/classification-loss-cross-entropy</guid><description>从准确率不可导的问题出发，推导 Sigmoid、二元交叉熵与 logit 梯度，手算损失，并用 PyTorch 写出数值稳定的二分类训练。</description><pubDate>Wed, 19 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇建立了训练、验证、测试三者的边界。边界解决了“用什么数据评价”，但还没有回答“训练时到底优化什么”。&lt;/p&gt;
&lt;p&gt;垃圾邮件分类最终只看预测对不对，最直观的目标似乎就是准确率（Accuracy）。可真实训练通常优化二元交叉熵（Binary Cross-Entropy，BCE），而不是准确率。为什么不直接优化真正关心的指标？&lt;/p&gt;
&lt;p&gt;今天只讲透一个核心问题：&lt;strong&gt;怎样把离散的分类对错，变成可优化、能表达置信程度的损失？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 准确率为什么不能直接指导更新？&lt;/h2&gt;
&lt;p&gt;二分类器先输出一个实数分数 $z$，再用阈值产生类别：&lt;/p&gt;
&lt;p&gt;$$
\hat y=\mathbb{1}[z\ge 0]
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$z\in\mathbb{R}$：模型尚未阈值化的原始分数。&lt;/li&gt;
&lt;li&gt;$\mathbb{1}[\cdot]$：条件成立时为 1，否则为 0。&lt;/li&gt;
&lt;li&gt;$\hat y\in{0,1}$：最终预测类别。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把预测错误记为 1、正确记为 0，得到 &lt;strong&gt;0-1 损失（0-1 Loss）&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;$$
L_{0/1}(\hat y,y)=\mathbb{1}[\hat y\ne y]
$$&lt;/p&gt;
&lt;p&gt;它正好对应“错了多少个”，却有一个致命问题：分数在阈值同一侧移动时，损失完全不变。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;真实标签 y = 1

损失 1  ◄───────────────○
                       z=0
损失 0                  ○────────────────► z
                -2  -1  0  1  2

z 从 -2 增加到 -0.01：仍然预测 0，损失一直是 1
z 从  0.01 增加到  5：仍然预测 1，损失一直是 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除阈值处外，它对 $z$ 的导数几乎处处为 0；阈值处又不可导。梯度无法告诉模型参数应该向哪个方向移动、移动多少。&lt;/p&gt;
&lt;h2&gt;02 从分数到概率：Sigmoid 做了什么？&lt;/h2&gt;
&lt;p&gt;我们希望模型不仅说“0 或 1”，还表达相信正类的程度。对线性二分类器：&lt;/p&gt;
&lt;p&gt;$$
z=Xw+b
$$&lt;/p&gt;
&lt;p&gt;若一批数据包含 $B$ 个样本、每个样本有 $D$ 个特征：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X:      [B, D]
w:      [D, 1]
b:      [1]       可广播到每个样本
z:      [B, 1]
y:      [B, 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$z$ 叫 &lt;strong&gt;对数几率分数（Logit）&lt;/strong&gt;，取值没有上下界。用 &lt;strong&gt;S 形函数（Sigmoid Function）&lt;/strong&gt; 将它压到 $(0,1)$：&lt;/p&gt;
&lt;p&gt;$$
p=\sigma(z)=\frac{1}{1+e^{-z}}
$$&lt;/p&gt;
&lt;p&gt;我们把 $p$ 解释为模型估计的 $P(y=1\mid x)$。若 $p\ge 0.5$，等价于 $z\ge 0$，预测正类。&lt;/p&gt;
&lt;p&gt;| logit $z$ | 正类概率 $p$ | 直觉         |
| --------: | -----------: | ------------ |
|  $-2.197$ |       $0.10$ | 强烈倾向负类 |
|       $0$ |       $0.50$ | 无法区分     |
|   $2.197$ |       $0.90$ | 强烈倾向正类 |&lt;/p&gt;
&lt;p&gt;概率保留了阈值丢掉的信息：&lt;code&gt;0.51&lt;/code&gt; 和 &lt;code&gt;0.99&lt;/code&gt; 都预测正类，但后者确信得多。&lt;/p&gt;
&lt;h2&gt;03 二元交叉熵怎样惩罚“自信地犯错”？&lt;/h2&gt;
&lt;p&gt;若真实标签 $y=1$，希望预测概率 $p$ 越大越好，可以使用 $-\log p$；若 $y=0$，希望 $1-p$ 越大越好，可以使用 $-\log(1-p)$。合并后就是 BCE：&lt;/p&gt;
&lt;p&gt;$$
L(p,y)=-\left[y\log p+(1-y)\log(1-p)\right]
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$y\in{0,1}$：真实标签。&lt;/li&gt;
&lt;li&gt;$p\in(0,1)$：正类预测概率。&lt;/li&gt;
&lt;li&gt;$L\in[0,+\infty)$：单个样本的标量损失。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为 $y$ 只能是 0 或 1，公式每次实际只保留一项：&lt;/p&gt;
&lt;p&gt;$$
L(p,1)=-\log p,\qquad L(p,0)=-\log(1-p)
$$&lt;/p&gt;
&lt;h3&gt;一个可手算的对比&lt;/h3&gt;
&lt;p&gt;有两个正样本，两个模型都把它们判对：&lt;/p&gt;
&lt;p&gt;| 模型        | $p_1$ | $p_2$ | 准确率 |                             平均 BCE |
| ----------- | ----: | ----: | -----: | -----------------------------------: |
| A：勉强正确 |  0.51 |  0.55 |   100% | $[-\log0.51-\log0.55]/2\approx0.636$ |
| B：更有把握 |  0.80 |  0.90 |   100% |   $[-\log0.8-\log0.9]/2\approx0.164$ |&lt;/p&gt;
&lt;p&gt;准确率看不出差别，BCE 能推动模型继续改进概率。&lt;/p&gt;
&lt;p&gt;再看真实标签 $y=1$ 时的错误预测：&lt;/p&gt;
&lt;p&gt;| 预测 $p$ |   BCE | 含义                       |
| -------: | ----: | -------------------------- |
|      0.4 | 0.916 | 判断错误，但仍保留不确定性 |
|      0.1 | 2.303 | 很自信地判断错误           |
|    0.001 | 6.908 | 极度自信地判断错误         |&lt;/p&gt;
&lt;p&gt;对数让“自信地犯错”付出巨大代价，同时仍保持平滑可导。&lt;/p&gt;
&lt;p&gt;此时 $y=0$，所以 $L=-\log(1-0.8)=-\log0.2\approx1.609$。若概率降到 0.2，损失变成 $-\log0.8\approx0.223$。&lt;/p&gt;
&lt;h2&gt;04 为什么它的梯度特别干净？&lt;/h2&gt;
&lt;p&gt;把 Sigmoid 和 BCE 连起来，对 logit $z$ 求导：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial z}=\sigma(z)-y=p-y
$$&lt;/p&gt;
&lt;p&gt;这个结果值得记住，因为它直接说明更新信号是什么：&lt;strong&gt;预测概率减真实标签&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;假设 $y=1,z=0$，则 $p=0.5$：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial z}=0.5-1=-0.5
$$&lt;/p&gt;
&lt;p&gt;梯度下降会减去负梯度，使 $z$ 增大，正类概率随之上升。若 $y=0,p=0.8$，梯度是 $0.8$，更新会压低 $z$。&lt;/p&gt;
&lt;p&gt;线性模型 $z=x^\top w+b$ 进一步得到：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial w}=x(p-y),\qquad
\frac{\partial L}{\partial b}=p-y
$$&lt;/p&gt;
&lt;p&gt;于是数据流完整闭环：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;输入 X [B,D]
    │
    ▼
线性层 Xw+b ──► logits z [B,1]
                     │
                     ▼
              BCEWithLogitsLoss ◄── 标签 y [B,1]
                     │
                     ▼
              标量 mean loss
                     │ backward
                     ▼
          w.grad [D,1]，b.grad [1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;05 为什么代码里不要先手动 Sigmoid？&lt;/h2&gt;
&lt;p&gt;直接计算 &lt;code&gt;sigmoid(z)&lt;/code&gt; 后再取对数，在 $|z|$ 很大时可能发生数值问题。例如 $z=-1000$ 的 Sigmoid 在有限精度中接近 0，随后 &lt;code&gt;log(0)&lt;/code&gt; 会得到负无穷。&lt;/p&gt;
&lt;p&gt;PyTorch 当前官方接口 &lt;code&gt;torch.nn.BCEWithLogitsLoss&lt;/code&gt; 把 Sigmoid 与 BCE 合并，并使用 &lt;strong&gt;对数和指数技巧（Log-Sum-Exp Trick）&lt;/strong&gt; 稳定计算。训练时应把原始 logits 直接传给它：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

X = torch.tensor([
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
    [2.0, 1.0],
])                                  # [B=4, D=2]
y = torch.tensor([[0.0], [0.0], [1.0], [1.0]])  # [4, 1]

model = torch.nn.Linear(in_features=2, out_features=1)
loss_fn = torch.nn.BCEWithLogitsLoss(reduction=&apos;mean&apos;)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

for step in range(200):
    logits = model(X)                # [4, 1]，不要先 sigmoid
    loss = loss_fn(logits, y)        # []，批内样本损失的均值

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

with torch.no_grad():
    logits = model(X)                # [4, 1]
    probabilities = torch.sigmoid(logits)
    predictions = (probabilities &gt;= 0.5).to(torch.int64)

print(probabilities.shape)           # torch.Size([4, 1])
print(predictions.shape)             # torch.Size([4, 1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重要 API 的输入输出：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;torch.nn.Linear(2, 1)&lt;/code&gt;：最后一维从 2 个特征映射为 1 个 logit。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;：&lt;code&gt;input&lt;/code&gt; 与 &lt;code&gt;target&lt;/code&gt; 形状必须一致；标签是浮点数且通常位于 $[0,1]$。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reduction=&apos;mean&apos;&lt;/code&gt;：先得到每个元素的损失，再取均值，最终输出零维标量。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;torch.sigmoid(logits)&lt;/code&gt;：只在需要概率做评价或推理时显式调用。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;06 从最大似然看交叉熵&lt;/h2&gt;
&lt;p&gt;二分类标签可以看作 &lt;strong&gt;伯努利分布（Bernoulli Distribution）&lt;/strong&gt; 的一次观测：&lt;/p&gt;
&lt;p&gt;$$
P(y\mid x)=p^y(1-p)^{1-y}
$$&lt;/p&gt;
&lt;p&gt;对 $N$ 个独立样本，最大化全部标签的似然，等价于最小化负对数似然：&lt;/p&gt;
&lt;p&gt;$$
-\frac{1}{N}\sum_{i=1}^N\log P(y_i\mid x_i)
=-\frac{1}{N}\sum_{i=1}^N
\left[y_i\log p_i+(1-y_i)\log(1-p_i)\right]
$$&lt;/p&gt;
&lt;p&gt;这正是平均 BCE。它不是随意选出的光滑函数，而是在模型输出被解释为概率时，对观测数据做 &lt;strong&gt;最大似然估计（Maximum Likelihood Estimation）&lt;/strong&gt; 的结果。&lt;/p&gt;
&lt;p&gt;不过，“概率输出”不自动等于“概率可信”。模型可能预测 0.9 的样本只有 70% 真为正类，这涉及后续会讲的 &lt;strong&gt;概率校准（Calibration）&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;07 BCE、均方误差和多分类交叉熵不要混用&lt;/h2&gt;
&lt;p&gt;| 任务         | 模型输出                | 常见训练损失           | 标签形状           |
| ------------ | ----------------------- | ---------------------- | ------------------ |
| 二分类       | 1 个 logit              | &lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;    | &lt;code&gt;[B, 1]&lt;/code&gt; 浮点 0/1  |
| 多标签分类   | 每个标签 1 个独立 logit | &lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;    | &lt;code&gt;[B, C]&lt;/code&gt; 浮点 0/1  |
| 单标签多分类 | $C$ 个互斥类别 logits   | &lt;code&gt;CrossEntropyLoss&lt;/code&gt;     | &lt;code&gt;[B]&lt;/code&gt; 整数类别索引 |
| 回归         | 连续实数                | &lt;code&gt;MSELoss&lt;/code&gt;、&lt;code&gt;L1Loss&lt;/code&gt; 等 | 与预测形状一致     |&lt;/p&gt;
&lt;p&gt;均方误差也能拟合 0/1 标签，但配合 Sigmoid 时，极端错误区域的梯度可能变弱；BCE 的概率解释和梯度结构更适合标准二分类。&lt;/p&gt;
&lt;p&gt;多标签任务中的每个类别可以同时为 1，例如一张图片既有“室内”又有“人物”，因此用独立 Sigmoid；单标签多分类只能选一个类别，类别之间竞争，因此用 Softmax 形式的多分类交叉熵。二者不能仅凭输出列数判断。&lt;/p&gt;
&lt;h2&gt;08 工程中最常见的错误与调试方法&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;logits 与概率混淆。&lt;/strong&gt; 打印最小值和最大值；概率必须在 $[0,1]$，logits 可以是任意实数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;标签形状触发广播。&lt;/strong&gt; 预测为 &lt;code&gt;[B,1]&lt;/code&gt; 而标签为 &lt;code&gt;[B]&lt;/code&gt; 时，先显式 &lt;code&gt;y = y.float().view(-1, 1)&lt;/code&gt;，再断言形状相同。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;标签类型错误。&lt;/strong&gt; BCE 需要浮点标签；整数类别索引是 &lt;code&gt;CrossEntropyLoss&lt;/code&gt; 的接口习惯。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类别极不平衡。&lt;/strong&gt; 只看平均损失可能忽视少数类；结合每类召回率，并在训练集上估计合适的 &lt;code&gt;pos_weight&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;阈值固定等于 0.5。&lt;/strong&gt; 0.5 是概率到类别的默认决策，不是普适业务最优点；应在验证集上按误报、漏报成本选择阈值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;损失突然为 &lt;code&gt;nan&lt;/code&gt;。&lt;/strong&gt; 检查输入是否含非有限值、学习率是否过大，并确认没有手动对 0 取对数。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最小调试断言：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert logits.shape == y.shape
assert y.dtype.is_floating_point
assert torch.isfinite(logits).all()
assert torch.isfinite(loss)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;09 它什么时候仍会失败？&lt;/h2&gt;
&lt;p&gt;BCE 假设训练标签值得信任，并把每个样本对目标的贡献通过平均值汇总。以下情况需要额外设计：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标签含大量错误时，对数损失会重罚模型与错误标签不一致的高置信预测。&lt;/li&gt;
&lt;li&gt;正负类成本不对称或比例极端失衡时，需要加权、重采样或专门损失。&lt;/li&gt;
&lt;li&gt;数据分布改变时，训练出的概率可能失去校准。&lt;/li&gt;
&lt;li&gt;只优化样本平均误差，不能自动满足公平性、延迟或业务约束。&lt;/li&gt;
&lt;li&gt;阈值后的准确率仍然不可导，训练损失下降也不保证目标业务指标单调上升。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此必须同时记录训练损失与验证指标：前者解释优化是否工作，后者判断任务是否真的变好。&lt;/p&gt;
&lt;h2&gt;10 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;0-1 损失对应分类对错，却几乎处处梯度为 0，无法有效指导参数更新。&lt;/li&gt;
&lt;li&gt;Sigmoid 把 logit 映射为正类概率，BCE 同时利用标签和置信程度。&lt;/li&gt;
&lt;li&gt;Sigmoid 与 BCE 组合后，对 logit 的梯度是 $p-y$。&lt;/li&gt;
&lt;li&gt;训练时把原始 logits 传给 &lt;code&gt;BCEWithLogitsLoss&lt;/code&gt;；推理时再显式计算概率和阈值。&lt;/li&gt;
&lt;li&gt;损失负责可优化性，指标负责任务评价，两者要分别监控。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;11 思考题与小练习&lt;/h2&gt;
&lt;p&gt;对正样本，模型 A 预测 0.6，模型 B 预测 0.9。两者准确率都为 1；BCE 分别约为 0.511 和 0.105，所以 B 的损失更小。&lt;/p&gt;
&lt;p&gt;若 $y=0,p=0.7$，则 $\partial L/\partial z=p-y=0.7$。梯度下降会减小 $z$，从而减小正类概率。&lt;/p&gt;
&lt;p&gt;把示例中的 &lt;code&gt;loss_fn(logits, y)&lt;/code&gt; 改成 &lt;code&gt;loss_fn(torch.sigmoid(logits), y)&lt;/code&gt;，比较收敛速度和最终概率。解释为什么接口仍接受输入，但语义已经错误。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://people.math.harvard.edu/~ctm/home/text/others/shannon/entropy/entropy.pdf&quot;&gt;Shannon: A Mathematical Theory of Communication&lt;/a&gt;：信息熵与对数度量的奠基工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.jstor.org/stable/2983890&quot;&gt;Cox: The Regression Analysis of Binary Sequences&lt;/a&gt;：二元响应逻辑模型的经典论文。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doi.org/10.1007/978-3-642-76153-9_28&quot;&gt;Bridle: Probabilistic Interpretation of Feedforward Classification Network Outputs&lt;/a&gt;：神经分类输出与交叉熵训练的早期系统讨论。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1708.02002&quot;&gt;Lin et al.: Focal Loss for Dense Object Detection&lt;/a&gt;：针对极端类别不平衡改造交叉熵的重要工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytorch.org/docs/stable/generated/torch.nn.BCEWithLogitsLoss.html&quot;&gt;PyTorch: BCEWithLogitsLoss&lt;/a&gt;：当前官方接口、形状、加权与数值稳定性说明。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;12 下一篇预告&lt;/h2&gt;
&lt;p&gt;损失函数已经把“错得多严重”变成了可导标量，但真实数据不可能每次全部塞进模型。下一篇将追踪一批样本如何经过前向、求平均、反向和更新，解释全批量、随机与小批量梯度下降究竟差在哪里。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>为什么不能用测试集调模型？一次看懂训练、验证与数据泄漏</title><link>https://zwjcode.cn/blog/train-validation-test-split</link><guid isPermaLink="true">https://zwjcode.cn/blog/train-validation-test-split</guid><description>用十个样本和标准化手算，解释训练集、验证集、测试集的职责边界，掌握无泄漏的数据划分与 sklearn Pipeline。</description><pubDate>Tue, 18 Aug 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;上一篇中，我们把训练写成经验风险最小化：模型不断调整参数，让训练样本上的平均损失下降。&lt;/p&gt;
&lt;p&gt;但这立刻带来一个问题：&lt;strong&gt;同一批数据既参与学习，又负责给模型打分，这个分数还能代表未来表现吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;通常不能。模型可能记住训练样本，研究者也可能在反复试验中“记住”测试集。今天只讲透一件事：怎样划分数据，才能得到可信的泛化评估。&lt;/p&gt;
&lt;h2&gt;01 三份数据不是简单切三刀&lt;/h2&gt;
&lt;p&gt;监督学习中，三类数据承担不同职责：&lt;/p&gt;
&lt;p&gt;| 数据 | 允许做什么 | 不允许做什么 |
| --- | --- | --- |
| 训练集（Training Set） | 学习权重、偏置和预处理统计量 | 代表最终泛化成绩 |
| 验证集（Validation Set） | 选择模型、学习率、正则化强度、停止轮次 | 参与参数拟合 |
| 测试集（Test Set） | 所有方案确定后做一次最终验收 | 反复查看并据此修改方案 |&lt;/p&gt;
&lt;p&gt;这里要区分两类参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Parameter（模型参数）&lt;/strong&gt;：由训练算法直接学到，如神经网络权重 $W$。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hyperparameter（超参数）&lt;/strong&gt;：训练前或训练外做出的选择，如学习率、树深度、隐藏层宽度。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;训练集学习参数，验证集帮助选择超参数，测试集估计最终方案面对未知数据时的表现。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;原始数据
   │
   ├── 训练集 ──► 拟合参数 ───────────────┐
   │                                      │
   ├── 验证集 ──► 比较方案、选择超参数 ───┤ 循环
   │                                      │
   └── 测试集 ──► 最终一次评估 ◄──────────┘
                         ▲
                   方案冻结后才使用
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;02 用十个样本看懂“选择偏差”&lt;/h2&gt;
&lt;p&gt;假设只有 10 个按编号排列的样本：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;样本编号： 1  2  3  4  5  6  7  8  9  10
划分结果： T  T  T  T  T  T  V  V  E   E
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;T&lt;/code&gt;：6 个训练样本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;V&lt;/code&gt;：2 个验证样本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;E&lt;/code&gt;：2 个测试样本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在训练三个模型：&lt;/p&gt;
&lt;p&gt;| 模型 | 训练误差 | 验证误差 | 测试误差 |
| --- | ---: | ---: | ---: |
| A：简单 | 0.20 | 0.22 | 未查看 |
| B：中等 | 0.08 | 0.12 | 未查看 |
| C：复杂 | 0.00 | 0.30 | 未查看 |&lt;/p&gt;
&lt;p&gt;训练误差会偏爱复杂模型 C，但验证误差提示 B 更可能泛化。因此我们选择 B，冻结全部方案，最后才查看测试集。&lt;/p&gt;
&lt;p&gt;假设 B 的测试误差是 0.15，这就是当前最可信的泛化估计。&lt;/p&gt;
&lt;p&gt;如果不满意 0.15，又回去增加特征、换模型，直到测试误差降到 0.08，会怎样？你已经针对这两个测试样本完成了人工调参。即使模型权重没有直接用测试集训练，&lt;strong&gt;人的决策过程也对测试集发生了拟合&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这叫 &lt;strong&gt;Selection Bias（选择偏差）&lt;/strong&gt;：尝试的方案越多，越容易偶然找到一个在固定测试集上表现很好的方案。&lt;/p&gt;
&lt;p&gt;算。数据增强是训练方案的一部分，你根据测试结果改变它，测试信息便参与了方案选择。应重新准备未被查看的测试集，或明确承认原测试集已降级为验证集。&lt;/p&gt;
&lt;h2&gt;03 数据泄漏到底泄漏了什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Data Leakage（数据泄漏）&lt;/strong&gt;：训练或模型选择阶段使用了在真实预测时不可能获得的信息，导致离线指标过于乐观。&lt;/p&gt;
&lt;p&gt;它不只意味着“把测试标签放进训练集”。更隐蔽的情况是：在划分数据前，对全部样本计算均值、做特征选择或填补缺失值。&lt;/p&gt;
&lt;h3&gt;一个可以手算的标准化泄漏&lt;/h3&gt;
&lt;p&gt;训练数据只有两个值：&lt;/p&gt;
&lt;p&gt;$$
X_{train}=[0,2]
$$&lt;/p&gt;
&lt;p&gt;测试数据只有一个值：&lt;/p&gt;
&lt;p&gt;$$
X_{test}=[100]
$$&lt;/p&gt;
&lt;p&gt;标准化公式是：&lt;/p&gt;
&lt;p&gt;$$
z=\frac{x-\mu}{\sigma}
$$&lt;/p&gt;
&lt;p&gt;其中 $\mu$ 和 $\sigma$ 分别是均值与标准差，它们不是固定常量，而是需要从数据中估计的&lt;strong&gt;预处理参数&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;正确做法只用训练集：&lt;/p&gt;
&lt;p&gt;$$
\mu_{train}=1,\qquad \sigma_{train}=1
$$&lt;/p&gt;
&lt;p&gt;因此：&lt;/p&gt;
&lt;p&gt;$$
X&apos;&lt;em&gt;{train}=[-1,1],\qquad X&apos;&lt;/em&gt;{test}=[99]
$$&lt;/p&gt;
&lt;p&gt;测试值 100 是明显的分布外样本，这个事实被保留下来。&lt;/p&gt;
&lt;p&gt;如果先合并全部数据再标准化：&lt;/p&gt;
&lt;p&gt;$$
\mu_{all}=\frac{0+2+100}{3}=34
$$&lt;/p&gt;
&lt;p&gt;此时训练样本的变换方式已经受测试样本 100 影响。模型在训练时虽然没看到测试标签，却提前获得了测试分布的信息。这就是泄漏。&lt;/p&gt;
&lt;p&gt;关键规则只有一句：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;任何需要通过 &lt;code&gt;fit&lt;/code&gt; 学习统计量的步骤，都只能在训练数据上 &lt;code&gt;fit&lt;/code&gt;；验证集和测试集只能 &lt;code&gt;transform&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;04 随机划分并不总是正确&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;train_test_split&lt;/code&gt; 很方便，但它默认随机打乱样本。只有当样本近似独立同分布时，随机划分才合理。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;IID（Independent and Identically Distributed，独立同分布）&lt;/strong&gt;：样本彼此近似独立，并来自同一个概率分布。&lt;/p&gt;
&lt;p&gt;下面三类任务需要更谨慎：&lt;/p&gt;
&lt;h3&gt;时间序列：必须尊重时间方向&lt;/h3&gt;
&lt;p&gt;用 2026 年数据训练，却随机把部分 2025 年样本放进测试集，可能让模型间接“看到未来”。合理划分是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;过去 ─────────────────────────────► 未来
[       Train       ][Validation][ Test ]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;同一主体有多条记录：按组划分&lt;/h3&gt;
&lt;p&gt;医学数据中，同一患者的多张影像高度相似。如果其中一些进入训练集、另一些进入测试集，模型可能识别患者而不是疾病。应让同一患者只属于一个集合。&lt;/p&gt;
&lt;h3&gt;分类比例失衡：考虑分层&lt;/h3&gt;
&lt;p&gt;若正样本只有 5%，纯随机划分可能让小验证集几乎没有正样本。**Stratified Split（分层划分）**会尽量保持各集合的类别比例。&lt;/p&gt;
&lt;p&gt;| 数据结构 | 推荐方法 | 关键约束 |
| --- | --- | --- |
| 普通表格 IID 数据 | 随机或分层划分 | 固定随机种子 |
| 时间序列 | 按时间向前划分 | 训练不能使用未来 |
| 患者、用户、设备多记录 | Group Split | 同一组不能跨集合 |
| 小样本 | 交叉验证 | 仍保留独立测试集 |&lt;/p&gt;
&lt;h2&gt;05 从原则落到 sklearn 代码&lt;/h2&gt;
&lt;p&gt;下面以二分类为例。输入输出形状是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;X: [num_samples, num_features]
y: [num_samples]

model.predict(X_test): [num_test_samples]
model.predict_proba(X_test): [num_test_samples, 2]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;先划分，再做任何需要拟合的预处理&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.model_selection import train_test_split

X_train_val, X_test, y_train_val, y_test = train_test_split(
    X,
    y,
    test_size=0.20,
    random_state=42,
    stratify=y,
)

X_train, X_val, y_train, y_val = train_test_split(
    X_train_val,
    y_train_val,
    test_size=0.25,       # 0.25 × 0.80 = 原数据的 0.20
    random_state=42,
    stratify=y_train_val,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终比例是 60% 训练、20% 验证、20% 测试。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;random_state=42&lt;/code&gt;：让划分可复现，不表示 42 有特殊统计意义。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stratify=y&lt;/code&gt;：让类别比例尽量一致。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;test_size&lt;/code&gt;：可以是比例，也可以是样本数量。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;用 Pipeline 把防泄漏规则写进代码&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.linear_model import LogisticRegression
from sklearn.metrics import log_loss, accuracy_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

model = make_pipeline(
    StandardScaler(),
    LogisticRegression(C=1.0, max_iter=1000),
)

model.fit(X_train, y_train)

val_prob = model.predict_proba(X_val)
val_pred = model.predict(X_val)

print(&quot;val log loss:&quot;, log_loss(y_val, val_prob))
print(&quot;val accuracy:&quot;, accuracy_score(y_val, val_pred))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Pipeline.fit(X_train, y_train)&lt;/code&gt; 会先让 &lt;code&gt;StandardScaler&lt;/code&gt; 只在训练集上计算均值和标准差，再把变换后的训练数据交给分类器。调用 &lt;code&gt;predict(X_val)&lt;/code&gt; 时，Pipeline 复用训练阶段保存的统计量，不会在验证集上重新拟合。&lt;/p&gt;
&lt;p&gt;确定 &lt;code&gt;C&lt;/code&gt;、特征和模型结构后，常见的最终流程是用训练集与验证集重新拟合一次，再在测试集上验收：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;final_model = make_pipeline(
    StandardScaler(),
    LogisticRegression(C=best_c, max_iter=1000),
)
final_model.fit(X_train_val, y_train_val)

test_pred = final_model.predict(X_test)
test_accuracy = accuracy_score(y_test, test_pred)
print(&quot;final test accuracy:&quot;, test_accuracy)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试指标应与模型版本、数据版本、划分规则和随机种子一起记录。&lt;/p&gt;
&lt;h2&gt;06 小数据怎么办？&lt;/h2&gt;
&lt;p&gt;数据很少时，单个验证集的结果可能高度依赖“恰好分到了哪些样本”。这时可以使用 &lt;strong&gt;Cross-Validation（交叉验证）&lt;/strong&gt;：把训练数据分成 $K$ 份，轮流用一份验证、其余训练，最后汇总 $K$ 次结果。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Fold 1: [Val][Train][Train][Train][Train]
Fold 2: [Train][Val][Train][Train][Train]
Fold 3: [Train][Train][Val][Train][Train]
Fold 4: [Train][Train][Train][Val][Train]
Fold 5: [Train][Train][Train][Train][Val]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;交叉验证替代的是固定验证集，不是独立测试集。所有超参数确定后，测试集仍只用于最后评估。&lt;/p&gt;
&lt;h2&gt;07 真正做项目时的检查清单&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先定义预测时刻。&lt;/strong&gt; 问清楚模型做预测时，哪些字段确实已经存在。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先划分，后预处理。&lt;/strong&gt; 缺失值填补、标准化、PCA、特征选择都遵守这一顺序。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按真实部署单位划分。&lt;/strong&gt; 患者、用户、设备、场站和时间都可能是隔离单位。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;保留数据划分清单。&lt;/strong&gt; 保存样本 ID，而不只是保存随机种子。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要盯着单一指标。&lt;/strong&gt; 同时观察均值、方差、类别指标和置信区间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测试集设访问边界。&lt;/strong&gt; 大型项目中可由独立脚本或负责人执行最终测试。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;08 什么时候仍会失败？&lt;/h2&gt;
&lt;p&gt;即使数据完全无泄漏，测试结果仍可能失真：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;测试集太小，估计方差很大。&lt;/li&gt;
&lt;li&gt;测试数据与上线流量不是同一分布。&lt;/li&gt;
&lt;li&gt;数据在时间上发生 &lt;strong&gt;Distribution Shift（分布漂移）&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;指标与业务目标不一致，例如只追求准确率却忽视少数类召回率。&lt;/li&gt;
&lt;li&gt;反复发表和比较结果，公共测试集逐渐被整个社区“过拟合”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，正确划分不是终点，而是可信实验的最低条件。&lt;/p&gt;
&lt;h2&gt;09 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;训练集学习参数，验证集选择方案，测试集只做最终验收。&lt;/li&gt;
&lt;li&gt;看过测试结果并据此修改方案，就已经对测试集发生了拟合。&lt;/li&gt;
&lt;li&gt;标准化、填补缺失值和特征选择也会学习参数，必须只在训练集上 &lt;code&gt;fit&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;时间、患者、用户等结构决定划分方式，随机切分不是万能答案。&lt;/li&gt;
&lt;li&gt;Pipeline 能把预处理与模型绑定，降低交叉验证和部署中的泄漏风险。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;10 思考题与小练习&lt;/h2&gt;
&lt;p&gt;某项目先对全部 10,000 个样本做 PCA，再划分训练集和测试集。问题在哪里？PCA 的主轴使用了测试集分布。正确做法是先划分，再只用训练集拟合 PCA；使用 Pipeline 能在交叉验证的每一折中正确执行。&lt;/p&gt;
&lt;p&gt;你要用过去 30 天的传感器数据预测下一小时是否故障。应按时间划分，训练使用更早数据，验证和测试使用更晚数据；随机划分会把未来状态混入训练。&lt;/p&gt;
&lt;p&gt;第一次留下 20% 测试集，第二次从剩余 80% 中留下 25% 验证集。最终训练、验证、测试比例是多少？答案是 60%、20%、20%。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/common_pitfalls.html&quot;&gt;scikit-learn: Common pitfalls and recommended practices&lt;/a&gt;：官方的数据泄漏案例与防范原则。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html&quot;&gt;scikit-learn: train_test_split&lt;/a&gt;：随机、分层划分接口及参数说明。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://scikit-learn.org/stable/modules/compose.html#pipeline&quot;&gt;scikit-learn: Pipeline&lt;/a&gt;：将预处理与估计器组合，避免交叉验证中的泄漏。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.ijcai.org/Proceedings/95-2/Papers/016.pdf&quot;&gt;Kohavi: A Study of Cross-Validation and Bootstrap&lt;/a&gt;：比较交叉验证与 Bootstrap 的经典研究。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://proceedings.neurips.cc/paper/2015/hash/86df7dcfd896fcaf2674f757a2463eba-Abstract.html&quot;&gt;Sculley et al.: Hidden Technical Debt in Machine Learning Systems&lt;/a&gt;：从系统角度讨论数据依赖和实验债务。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;11 下一篇预告&lt;/h2&gt;
&lt;p&gt;有了可信的数据边界，下一步才能讨论“模型究竟应该优化什么”。下一篇将聚焦损失函数：为什么评价指标是准确率时，分类模型通常不直接优化准确率；以及一个可优化的损失需要具备哪些性质。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>机器学习究竟在学什么？从样本、假设到经验风险最小化</title><link>https://zwjcode.cn/blog/ml-what-is-learning</link><guid isPermaLink="true">https://zwjcode.cn/blog/ml-what-is-learning</guid><description>从“写规则”和“从数据学习”的区别出发，拆解样本、特征、模型、损失函数与训练循环，并手算一次参数更新。</description><pubDate>Tue, 18 Aug 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;如果要判断一封邮件是不是垃圾邮件，传统程序会先写规则：标题含“中奖”就加分，正文含大量链接再加分，超过阈值便拦截。&lt;/p&gt;
&lt;p&gt;问题是，发送者会换词、改写句子，规则很快失效。机器学习换了一种思路：不直接写出所有判断规则，而是给出许多“邮件及其答案”，让程序从样本中找到一套能推广到新邮件的计算规则。&lt;/p&gt;
&lt;p&gt;今天真正要回答的问题是：&lt;strong&gt;机器学习中的“学习”究竟发生在哪里？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;01 从写规则到选择函数&lt;/h2&gt;
&lt;p&gt;一个监督学习任务通常有三样东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;输入（Input）&lt;/strong&gt; $x$：模型能看到的信息，例如邮件的词频。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;标签（Label）&lt;/strong&gt; $y$：希望模型给出的答案，例如“正常邮件”或“垃圾邮件”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模型（Model）&lt;/strong&gt; $f_\theta$：把输入映射为预测结果的函数，其中 $\theta$ 是可调整参数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;训练过程可以压缩成一条数据流：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;样本 (x, y)
    │
    ▼
模型 fθ(x) ──► 预测值 ŷ
    │              │
    └──── 标签 y ──┘
                   ▼
              损失 L(ŷ, y)
                   │
                   ▼
              计算梯度 ∇θL
                   │
                   ▼
              更新参数 θ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以，“学习”并不是模型凭空产生知识，而是：&lt;strong&gt;在一组候选函数中，根据数据不断调整参数，找到损失较小的那个函数。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这里第一次出现两个重要术语：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hypothesis（假设）&lt;/strong&gt;：模型可以表示的某个具体函数。例如 $y=2x+1$ 是线性模型族中的一个假设。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hypothesis Space（假设空间）&lt;/strong&gt;：模型所有可能参数对应的函数集合。线性回归的假设空间包含所有 $wx+b$。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;模型结构决定“能找哪些函数”，训练算法决定“怎样在这些函数中寻找”。&lt;/p&gt;
&lt;h2&gt;02 数据怎样进入模型？&lt;/h2&gt;
&lt;p&gt;假设我们用房屋面积和房龄预测价格。一个样本可表示为：&lt;/p&gt;
&lt;p&gt;$$
x=[80, 5],\qquad y=160
$$&lt;/p&gt;
&lt;p&gt;$x$ 有两个特征：80 平方米、房龄 5 年；$y$ 是价格，单位可以是万元。&lt;/p&gt;
&lt;p&gt;把 $n$ 个样本放在一起：&lt;/p&gt;
&lt;p&gt;$$
X\in\mathbb{R}^{n\times d},\qquad y\in\mathbb{R}^{n}
$$&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$n$：样本数量。&lt;/li&gt;
&lt;li&gt;$d$：每个样本的特征数量。&lt;/li&gt;
&lt;li&gt;$X$ 的第 $i$ 行是第 $i$ 个样本。&lt;/li&gt;
&lt;li&gt;$y_i$ 是第 $i$ 个样本的标签。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;若一次送入 32 套房屋、每套有 2 个特征，则：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Input:
X.shape = [32, 2]

Parameters:
w.shape = [2]
b.shape = []

Output:
y_hat.shape = [32]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最简单的线性模型是：&lt;/p&gt;
&lt;p&gt;$$
\hat y_i=w^\top x_i+b
$$&lt;/p&gt;
&lt;p&gt;$w$ 决定每个特征对预测的影响，$b$ 是不依赖输入的基础偏移，$\hat y_i$ 是模型预测。&lt;/p&gt;
&lt;h2&gt;03 为什么需要损失函数？&lt;/h2&gt;
&lt;p&gt;模型输出预测后，必须有一个数字回答“错得有多严重”。这个函数叫 &lt;strong&gt;Loss Function（损失函数）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;回归任务常用平方误差：&lt;/p&gt;
&lt;p&gt;$$
L(\hat y_i,y_i)=(\hat y_i-y_i)^2
$$&lt;/p&gt;
&lt;p&gt;它的输入是一个预测值和一个真实值，输出是非负标量。预测完全正确时损失为 0；误差越大，平方后的惩罚增长越快。&lt;/p&gt;
&lt;p&gt;但一个样本损失小，不代表模型整体可靠。训练时通常最小化所有训练样本的平均损失：&lt;/p&gt;
&lt;p&gt;$$
\hat R(\theta)=\frac{1}{n}\sum_{i=1}^{n}L(f_\theta(x_i),y_i)
$$&lt;/p&gt;
&lt;p&gt;这叫 &lt;strong&gt;Empirical Risk（经验风险）&lt;/strong&gt;：在已经观察到的有限样本上测得的平均错误。&lt;/p&gt;
&lt;p&gt;公式中的变量分别是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$\theta$：模型全部可训练参数；在线性模型中就是 $w$ 和 $b$。&lt;/li&gt;
&lt;li&gt;$f_\theta(x_i)$：第 $i$ 个样本的预测。&lt;/li&gt;
&lt;li&gt;$L$：单个样本的损失函数。&lt;/li&gt;
&lt;li&gt;$n$：训练样本数量。&lt;/li&gt;
&lt;li&gt;$\hat R(\theta)$：一个标量，表示当前参数在训练集上的平均损失。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;训练目标写成：&lt;/p&gt;
&lt;p&gt;$$
\theta^*=\arg\min_\theta\hat R(\theta)
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;arg min&lt;/strong&gt; 返回的不是最小损失值，而是“让损失最小的那组参数”。这就是 &lt;strong&gt;Empirical Risk Minimization（经验风险最小化，ERM）&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;04 手算一次真正的“学习”&lt;/h2&gt;
&lt;p&gt;先去掉偏置，只看一个参数：&lt;/p&gt;
&lt;p&gt;$$
\hat y=wx
$$&lt;/p&gt;
&lt;p&gt;给定一个样本：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;x = 2
y = 5
w = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一步，前向计算：&lt;/p&gt;
&lt;p&gt;$$
\hat y=wx=1\times2=2
$$&lt;/p&gt;
&lt;p&gt;第二步，计算平方损失：&lt;/p&gt;
&lt;p&gt;$$
L=(\hat y-y)^2=(2-5)^2=9
$$&lt;/p&gt;
&lt;p&gt;第三步，求损失对参数 $w$ 的梯度：&lt;/p&gt;
&lt;p&gt;$$
\frac{\partial L}{\partial w}
=2(wx-y)x
=2(2-5)\times2
=-12
$$&lt;/p&gt;
&lt;p&gt;梯度为负，表示略微增大 $w$ 会让损失下降。设学习率 $\eta=0.1$：&lt;/p&gt;
&lt;p&gt;$$
w_{new}=w-\eta\frac{\partial L}{\partial w}
=1-0.1\times(-12)=2.2
$$&lt;/p&gt;
&lt;p&gt;更新后：&lt;/p&gt;
&lt;p&gt;$$
\hat y_{new}=2.2\times2=4.4,\qquad L_{new}=(4.4-5)^2=0.36
$$&lt;/p&gt;
&lt;p&gt;一次更新就让损失从 9 降到 0.36。模型“学到”的内容，正是参数从 1 变成了 2.2。&lt;/p&gt;
&lt;p&gt;此时 $w_{new}=1-1\times(-12)=13$，预测变成 26，损失变成 441。梯度方向虽然正确，但步子太大，越过了最低点。学习率控制每次参数更新的幅度。&lt;/p&gt;
&lt;h2&gt;05 从公式落到代码&lt;/h2&gt;
&lt;h3&gt;不依赖框架的关键逻辑&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;x, y = 2.0, 5.0
w = 1.0
learning_rate = 0.1

for step in range(5):
    y_hat = w * x
    loss = (y_hat - y) ** 2
    grad_w = 2 * (y_hat - y) * x
    w = w - learning_rate * grad_w
    print(step, round(w, 4), round(loss, 4))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码没有隐藏步骤：前向计算得到预测，损失衡量错误，导数给出局部变化方向，最后更新参数。&lt;/p&gt;
&lt;h3&gt;PyTorch 怎样表达同一个过程？&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch

X = torch.tensor([[1.0], [2.0], [3.0]])  # [batch_size=3, num_features=1]
y = torch.tensor([[2.0], [4.0], [6.0]])  # [3, 1]

model = torch.nn.Linear(in_features=1, out_features=1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)

for step in range(200):
    y_hat = model(X)                       # [3, 1]
    loss = torch.mean((y_hat - y) ** 2)   # 标量

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个关键 API 分别做什么：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;optimizer.zero_grad()&lt;/code&gt;：清空上一次迭代留下的梯度。PyTorch 默认会累加梯度。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loss.backward()&lt;/code&gt;：沿计算图反向应用链式法则，把梯度写入每个参数的 &lt;code&gt;.grad&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;optimizer.step()&lt;/code&gt;：根据梯度和学习率更新参数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;训练阶段需要计算梯度；推理阶段只需要：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with torch.no_grad():
    prediction = model(torch.tensor([[4.0]]))  # [1, 1]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;06 经验风险最小，为什么还不够？&lt;/h2&gt;
&lt;p&gt;真正想要的不是记住训练样本，而是在未来数据上仍然准确。这种能力叫 &lt;strong&gt;Generalization（泛化）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;现实中至少有四类风险：&lt;/p&gt;
&lt;p&gt;| 风险 | 表现 | 原因 |
| --- | --- | --- |
| 模型太简单 | 训练损失也很高 | 假设空间表达能力不足 |
| 模型太复杂 | 训练损失低，新数据误差高 | 把噪声也当成规律 |
| 数据不代表未来 | 离线表现好，上线失效 | 训练分布与实际分布不同 |
| 标签或特征有问题 | 损失下降但目标错误 | 数据定义偏离真实任务 |&lt;/p&gt;
&lt;p&gt;因此，训练损失只是证据，不是最终答案。下一篇会建立一套基本实验制度：训练集用于学习参数，验证集用于做选择，测试集只负责最后验收。&lt;/p&gt;
&lt;h2&gt;07 项目中最常见的错误&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把 loss 当成准确率。&lt;/strong&gt; 损失是优化目标，准确率是评价指标；两者相关但不等价。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;忽略张量形状。&lt;/strong&gt; &lt;code&gt;[batch]&lt;/code&gt; 和 &lt;code&gt;[batch, 1]&lt;/code&gt; 可能触发广播，代码能运行却计算了错误结果。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只看最后一次训练损失。&lt;/strong&gt; 应同时记录训练曲线、验证指标和随机种子。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;学习率只凭感觉。&lt;/strong&gt; 过大会震荡或发散，过小会训练缓慢；先观察损失是否稳定下降。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认为模型自动理解现实含义。&lt;/strong&gt; 模型只优化你提供的损失，不会替你判断目标定义是否合理。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;08 它什么时候会失败？&lt;/h2&gt;
&lt;p&gt;经验风险最小化依赖一个隐含前提：训练样本能代表未来数据。如果训练邮件全部来自同一家公司，而上线后面对不同语言和用户群体，即使训练损失接近 0，也可能失败。&lt;/p&gt;
&lt;p&gt;此外，多个参数都能获得很低的训练损失时，ERM 本身不会告诉我们哪个更能泛化。正则化、模型结构中的归纳偏置、更多数据和可靠验证，都是后续要引入的约束。&lt;/p&gt;
&lt;h2&gt;09 今天真正需要记住什么？&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;机器学习的“学习”是根据数据调整参数，而不是凭空理解世界。&lt;/li&gt;
&lt;li&gt;模型结构定义假设空间，损失函数定义“什么叫错”，优化算法负责寻找低损失参数。&lt;/li&gt;
&lt;li&gt;经验风险是训练样本上的平均损失，ERM 就是寻找使它较小的参数。&lt;/li&gt;
&lt;li&gt;梯度指出参数的局部上升方向，梯度下降沿反方向更新。&lt;/li&gt;
&lt;li&gt;训练损失低不等于能泛化到新数据。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;10 思考题与小练习&lt;/h2&gt;
&lt;p&gt;令 $x=3,y=7,w=1,\eta=0.05$，使用 $L=(wx-y)^2$。先计算预测、损失、梯度，再得到新参数。答案：$\hat y=3$，$L=16$，梯度为 $-24$，$w_{new}=2.2$。&lt;/p&gt;
&lt;p&gt;直接答案是损失函数与评价方案。模型会忠实地优化目标，因此目标写错比优化器选错更危险。&lt;/p&gt;
&lt;p&gt;把纯 Python 示例中的学习率依次改成 &lt;code&gt;0.01&lt;/code&gt;、&lt;code&gt;0.1&lt;/code&gt;、&lt;code&gt;0.5&lt;/code&gt;，记录损失。解释为什么“下降方向正确”仍不保证每一步都下降。&lt;/p&gt;
&lt;h3&gt;相关工作&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.cs.cmu.edu/~tom/mlbook.html&quot;&gt;Tom M. Mitchell: Machine Learning&lt;/a&gt;：经典教材，从任务、经验和性能度量定义学习问题。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://link.springer.com/book/10.1007/978-1-4757-3264-1&quot;&gt;Vapnik: The Nature of Statistical Learning Theory&lt;/a&gt;：统计学习理论与经验风险最小化的重要来源。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytorch.org/docs/stable/autograd.html&quot;&gt;PyTorch Autograd&lt;/a&gt;：自动微分和计算图的官方说明。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.pytorch.org/docs/stable/optim.html&quot;&gt;PyTorch Optimizers&lt;/a&gt;：&lt;code&gt;zero_grad&lt;/code&gt;、&lt;code&gt;backward&lt;/code&gt; 后的参数更新接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;11 下一篇预告&lt;/h2&gt;
&lt;p&gt;如果同一批数据既用于训练，又用于判断模型好不好，模型很容易“既当运动员又当裁判”。下一篇将解释训练集、验证集和测试集各自负责什么，并用一个只有 10 个样本的例子看清数据泄漏为什么会制造虚假的高分。&lt;/p&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>从零理解等变网络与不变网络</title><link>https://zwjcode.cn/blog/equivariant-invariant-networks</link><guid isPermaLink="true">https://zwjcode.cn/blog/equivariant-invariant-networks</guid><description>用旋转图形和通俗例子理解不变性、等变性、群与常见网络，并学会判断什么时候该用哪一种。</description><pubDate>Sun, 16 Aug 2026 16:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;
import EquivariancePlayground from &apos;@/components/blog/EquivariancePlayground.astro&apos;&lt;/p&gt;
&lt;p&gt;一张猫的照片旋转 90°，它仍然是猫；但照片中猫头朝向的箭头，也应该跟着旋转 90°。&lt;/p&gt;
&lt;p&gt;这两种需求分别对应：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不变（Invariant）&lt;/strong&gt;：输入变了，输出保持不变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;等变（Equivariant）&lt;/strong&gt;：输入怎么变，输出就有规律地跟着变。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;等变网络并不是一种特定模型，而是一类把“对称规律”写进网络结构的设计思想。&lt;/p&gt;
&lt;h2&gt;先从一个直观例子开始&lt;/h2&gt;
&lt;p&gt;假设输入是一张图片，我们把它旋转 90°。&lt;/p&gt;
&lt;p&gt;如果任务是判断“图中是不是猫”，理想输出仍然是“猫”。这是&lt;strong&gt;旋转不变&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果任务是标出猫头朝向，那么原本指向右侧的箭头也应旋转 90°。这是&lt;strong&gt;旋转等变&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;| 任务 | 输入旋转后，输出应该怎样变化？ | 需要的性质 |
| --- | --- | --- |
| 图像分类 | 类别不变 | 不变 |
| 关键点定位 | 关键点一起旋转 | 等变 |
| 分子能量预测 | 整体旋转不影响能量 | 不变 |
| 分子受力预测 | 力的方向一起旋转 | 等变 |&lt;/p&gt;
&lt;p&gt;拖动角度滑块，再切换“不变”和“等变”。你会发现：不变输出只关心“它是什么”，等变输出还保留了“它朝哪里”。&lt;/p&gt;
&lt;h2&gt;两个公式就够了&lt;/h2&gt;
&lt;p&gt;用 $x$ 表示输入，用 $g$ 表示一种变换，例如旋转、平移或重新排列节点，用 $f$ 表示网络。&lt;/p&gt;
&lt;h3&gt;不变性&lt;/h3&gt;
&lt;p&gt;$$
f(gx)=f(x)
$$&lt;/p&gt;
&lt;p&gt;先变换输入再送入网络，结果与原输入相同。网络忽略了这类变化。&lt;/p&gt;
&lt;h3&gt;等变性&lt;/h3&gt;
&lt;p&gt;$$
f(gx)=g f(x)
$$&lt;/p&gt;
&lt;p&gt;先变换输入再计算，等于先计算再对输出做同样的变换。网络没有忽略变化，而是让变化以可预测的方式传递下去。&lt;/p&gt;
&lt;h2&gt;“群”到底是什么？&lt;/h2&gt;
&lt;p&gt;阅读等变网络资料时，经常会看到“群（Group）”。它听起来抽象，其实可以先把它理解为：&lt;strong&gt;一套允许使用、并且能够相互组合的变换规则&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;例如，平面旋转可以连续做两次；旋转 30° 后再旋转 60°，等于旋转 90°。每次旋转也都能找到反向操作。这套旋转规则就是一个群。&lt;/p&gt;
&lt;p&gt;常见的变换包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;平移：图片向左或向右移动。&lt;/li&gt;
&lt;li&gt;旋转：图像、点云或分子整体转动。&lt;/li&gt;
&lt;li&gt;反射：像照镜子一样翻转。&lt;/li&gt;
&lt;li&gt;置换：改变集合或图节点的输入顺序。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，“对某个群等变”可以通俗地理解为：网络遵守这一套变换规则。&lt;/p&gt;
&lt;h2&gt;常见网络里早已有等变性&lt;/h2&gt;
&lt;h3&gt;CNN：对平移近似等变&lt;/h3&gt;
&lt;p&gt;卷积核在图片各处共享参数。同一个边缘无论出现在左边还是右边，都能触发相似的特征。因此，在忽略边界、步长等影响时，卷积对平移具有等变性。&lt;/p&gt;
&lt;p&gt;当最后使用 Global Pooling，把整张特征图汇总为一个向量时，位置信息被压缩，输出就更接近平移不变。可以把它看成一条常见路线：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;前面用等变层保留结构，最后用不变操作完成分类。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;GNN：对节点排列等变&lt;/h3&gt;
&lt;p&gt;同一张图的节点编号可以任意改变，但图的连接关系没有改变。消息传递层会让节点特征随编号一起重新排列，因此是置换等变的；把所有节点特征求和或求平均后，得到的整图表示则是置换不变的。&lt;/p&gt;
&lt;h3&gt;E(3) 等变网络：处理三维几何&lt;/h3&gt;
&lt;p&gt;分子和点云生活在三维空间。把整个分子平移或旋转，不应改变它的能量；但每个原子的受力方向应该同步旋转。E(3) 等变网络正是为了遵守这些三维几何规律。&lt;/p&gt;
&lt;h2&gt;为什么不只做数据增强？&lt;/h2&gt;
&lt;p&gt;数据增强会向模型展示许多旋转、平移后的样本，让模型“尝试学会”规律；等变结构则直接规定模型“必须遵守”规律。&lt;/p&gt;
&lt;p&gt;这通常带来三个好处：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;更省数据&lt;/strong&gt;：不必把每个方向都反复展示给模型。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;更稳定&lt;/strong&gt;：模型对未见过的姿态也更容易保持一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;更符合物理规律&lt;/strong&gt;：在分子、材料、机器人等任务中尤其重要。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;代价也很现实：等变层通常更复杂，计算量更大，而且选错对称性会限制模型。例如数字 6 旋转 180° 可能变成 9，此时强行要求旋转不变就是错误的。&lt;/p&gt;
&lt;h2&gt;怎么判断该用哪一种？&lt;/h2&gt;
&lt;p&gt;先问两个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;哪些输入变化不应改变任务含义？&lt;/li&gt;
&lt;li&gt;输出是标量、类别，还是带方向和位置的量？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果输出是类别、能量、分数，通常需要不变性；如果输出是坐标、方向、速度、力，通常需要等变性。实际模型常常两者都用：中间层保持等变，最终读出层根据任务产生不变或等变输出。&lt;/p&gt;
&lt;h2&gt;三道自测题&lt;/h2&gt;
&lt;h2&gt;延伸阅读与相关工作&lt;/h2&gt;
&lt;p&gt;建议按下面的顺序阅读：先建立整体认识，再进入二维卷积、集合与三维几何。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2205.07362&quot;&gt;What is an Equivariant Neural Network?&lt;/a&gt;：面向初学者的概念综述，适合作为本文之后的第一篇英文材料。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2104.13478&quot;&gt;Geometric Deep Learning: Grids, Groups, Graphs, Geodesics, and Gauges&lt;/a&gt;：从统一视角解释 CNN、GNN 与等变网络，是系统学习的总览。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1602.07576&quot;&gt;Group Equivariant Convolutional Networks&lt;/a&gt;：G-CNN 的代表性工作，把普通卷积推广到旋转、反射等变换群。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1911.08251&quot;&gt;General E(2)-Equivariant Steerable CNNs&lt;/a&gt;：进一步讨论二维旋转、反射等变卷积，理论内容更深入。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1703.06114&quot;&gt;Deep Sets&lt;/a&gt;：理解集合上的置换不变与置换等变，也是学习 GNN 的重要基础。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/1802.08219&quot;&gt;Tensor Field Networks&lt;/a&gt;：较早系统处理三维旋转、平移等变的神经网络工作。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2006.10503&quot;&gt;SE(3)-Transformer&lt;/a&gt;：把三维旋转和平移等变性引入 Attention。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/abs/2102.09844&quot;&gt;E(n) Equivariant Graph Neural Networks&lt;/a&gt;：结构相对直观，适合从 GNN 进入分子与三维动力学任务。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;准备动手实践时，可以从 &lt;a href=&quot;https://docs.e3nn.org/en/latest/&quot;&gt;e3nn 官方文档&lt;/a&gt; 开始。它提供了 Irreducible Representations、Convolution、Transformer 和等变性测试等教程。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;不变性：输入发生特定变换，输出不变。&lt;/li&gt;
&lt;li&gt;等变性：输入发生特定变换，输出按相同规则变化。&lt;/li&gt;
&lt;li&gt;群：一套可以组合和逆转的变换规则。&lt;/li&gt;
&lt;li&gt;设计网络前，应先从任务中寻找真正存在的对称性。&lt;/li&gt;
&lt;li&gt;常见做法是“中间层等变，最终输出按任务选择不变或等变”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理解这些直觉后，再学习 Group Convolution、Steerable CNN、SE(3)/E(3) Equivariant GNN，会容易很多：它们解决的核心问题都一样——让模型尊重数据本来就拥有的对称规律。&lt;/p&gt;</content:encoded><h:img src="/_astro/thumbnail.DQ1sSqh2.png"/><enclosure url="/_astro/thumbnail.DQ1sSqh2.png"/></item><item><title>提升专注力的实用方法</title><link>https://zwjcode.cn/blog/improve-concentration</link><guid isPermaLink="true">https://zwjcode.cn/blog/improve-concentration</guid><description>正念练习、认知训练和健康的生活方式，有助于改善注意力与专注状态。</description><pubDate>Sat, 10 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import { Aside } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;当你试图集中注意力时，思绪却不断游离，外界干扰也很容易打断当前任务。随着年龄增长，大脑过滤无关刺激的能力可能会下降，因此保持专注会变得更困难。&lt;/p&gt;
&lt;h2&gt;哪些因素会影响专注？&lt;/h2&gt;
&lt;p&gt;与长期使用后逐渐变慢的计算机类似，大脑也会受到多种生理因素影响，例如炎症、血管损伤、异常蛋白质积累和自然发生的脑容量变化。&lt;/p&gt;
&lt;p&gt;以下因素同样可能影响专注力：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;潜在健康问题。&lt;/strong&gt; 抑郁或睡眠障碍（如 Sleep Apnea）可能降低专注能力。视力或听力下降也会占用额外的认知资源。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;药物副作用。&lt;/strong&gt; 一些药物，特别是 Anticholinergics，可能降低信息处理速度，影响清晰思考。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;过量饮酒。&lt;/strong&gt; 酒精会干扰思考和睡眠，进而影响注意力。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;信息过载。&lt;/strong&gt; 电视、计算机、消息和邮件不断输入信息，会增加大脑筛选信息的负担，使人更容易分心。&lt;/p&gt;
&lt;h2&gt;保持专注的策略&lt;/h2&gt;
&lt;p&gt;可以从以下几个方面改善注意力：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mindfulness。&lt;/strong&gt; 正念强调把注意力放在当下。每天安静坐几分钟，闭上眼睛，关注呼吸、周围声音和身体感受，可以训练注意力回到当前时刻。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cognitive Training。&lt;/strong&gt; 认知训练游戏通常用于改善反应速度和注意力。训练重点并不是提高游戏成绩，而是逐步提高持续注意的难度，并把这种能力迁移到日常任务中。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;更健康的生活方式。&lt;/strong&gt; 睡眠和运动与认知能力，特别是注意力密切相关。运动能够促进新的神经连接、缓解压力并改善睡眠；充足睡眠则有助于降低压力激素水平，并清理可能损伤大脑的代谢产物。&lt;/p&gt;
&lt;p&gt;可以把每晚 7 至 8 小时睡眠和每周约 150 分钟有氧运动（如快走）作为参考目标。&lt;/p&gt;
&lt;p&gt;其他有助于专注的健康措施包括：采用支持脑健康的 Mediterranean Diet、及时处理潜在健康问题，以及在医生指导下调整可能影响注意力的药物。&lt;/p&gt;
&lt;p&gt;年龄变化无法控制，但生活方式可以主动调整，而这些改变可能帮助我们保持更好的专注状态。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;内容参考：&lt;a href=&quot;https://www.health.harvard.edu/mind-and-mood/tips-to-improve-concentration&quot;&gt;Harvard Health Publishing&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="/_astro/thumbnail.1GZ294Dz.jpg"/><enclosure url="/_astro/thumbnail.1GZ294Dz.jpg"/></item><item><title>在 Astro 中使用 MDX</title><link>https://zwjcode.cn/blog/using-mdx</link><guid isPermaLink="true">https://zwjcode.cn/blog/using-mdx</guid><description>了解如何在 Astro 中使用 MDX</description><pubDate>Sun, 01 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本主题已经安装并配置了 &lt;a href=&quot;https://docs.astro.build/en/guides/integrations-guide/mdx/&quot;&gt;@astrojs/mdx&lt;/a&gt; 集成，相关配置位于 &lt;code&gt;astro.config.ts&lt;/code&gt;。如果不需要 MDX，可以从配置文件中移除该集成。&lt;/p&gt;
&lt;h2&gt;为什么使用 MDX？&lt;/h2&gt;
&lt;p&gt;MDX 是 Markdown 的扩展格式，支持嵌入 JavaScript 和 JSX。借助它，可以&lt;a href=&quot;https://docs.astro.build/en/guides/markdown-content/#mdx-features&quot;&gt;在 Markdown 内容中组合 JavaScript 与 UI 组件&lt;/a&gt;，实现交互式图表、提示框等内容。&lt;/p&gt;
&lt;p&gt;如果已有使用 MDX 编写的内容，这项集成可以简化迁移到 Astro 的过程。&lt;/p&gt;
&lt;h2&gt;示例&lt;/h2&gt;
&lt;p&gt;下面展示如何在 MDX 中导入并使用 UI 组件。
在浏览器中打开页面后，可以看到一个可点击的按钮。&lt;/p&gt;
&lt;p&gt;import { Button } from &apos;astro-pure/user&apos;&lt;/p&gt;
&lt;p&gt;点击我&lt;/p&gt;
&lt;h2&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://mdxjs.com/docs/what-is-mdx&quot;&gt;MDX 语法文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/markdown-content/#markdown-and-mdx-pages&quot;&gt;Astro 使用文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意：&lt;/strong&gt; 创建交互式组件仍然需要使用 &lt;a href=&quot;https://docs.astro.build/en/reference/directives-reference/#client-directives&quot;&gt;Client Directives&lt;/a&gt;。否则，MDX 中的组件默认会被渲染为不包含 JavaScript 的静态 HTML。&lt;/li&gt;
&lt;/ul&gt;</content:encoded><h:img src="undefined"/><enclosure url="undefined"/></item><item><title>3D Rendering 是什么？3D 可视化完整指南</title><link>https://zwjcode.cn/blog/3d-rendering</link><guid isPermaLink="true">https://zwjcode.cn/blog/3d-rendering</guid><description>3D 图像可以呈现电影级视觉效果，也能帮助规划建筑、产品和城市空间。本文介绍 3D Rendering 的基本原理与常见用途。</description><pubDate>Sun, 09 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;3D Rendering 已经广泛存在于我们的生活中。从动作电影、汽车广告，到建筑方案和产品设计预览，3D 可视化的应用越来越普遍，也越来越逼真。&lt;/p&gt;
&lt;p&gt;本文从图像生成所依赖的计算方法讲起，介绍制作高质量 Computer Graphics（CG）内容所需的艺术技巧，以及 3D Rendering 的典型应用场景。&lt;/p&gt;
&lt;h2&gt;什么是 3D Rendering？&lt;/h2&gt;
&lt;p&gt;简单来说，3D Rendering 是使用计算机将数字三维场景生成二维图像的过程。&lt;/p&gt;
&lt;p&gt;生成图像需要特定的方法、软件和硬件。因此，3D Rendering 并不是单一工具，而是一套构建最终图像的完整流程。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://zwjcode.cn/_astro/nikola-arsov-still-life-interior-design-vray-3ds-max-05-930px.DoY3_oVo_alYGQ.webp&quot; alt=&quot;室内设计 3D Rendering 示例&quot;&gt;&lt;/p&gt;
&lt;h2&gt;3D Rendering 的类型&lt;/h2&gt;
&lt;p&gt;Rendering 图像可以分为写实和非写实两类。&lt;/p&gt;
&lt;p&gt;写实图像可以是接近摄影效果的建筑室内图、家具等产品设计图，或汽车可视化图。非写实图像则可以是轮廓示意图、卡通风格画面，或具有传统 2D 视觉效果的作品。从技术角度看，任何可以被描述和建模的内容都能够被可视化。&lt;/p&gt;
&lt;h2&gt;3D Rendering 有哪些用途？&lt;/h2&gt;
&lt;p&gt;3D Rendering 是建筑、产品设计、广告、电子游戏，以及电影、电视和动画视觉特效中的关键技术。&lt;/p&gt;
&lt;p&gt;在设计和建筑领域，Rendering 可以清晰地传达创意。设计师能够在实际建造或生产前评估方案、测试材质、开展研究，并把设计放入真实环境中观察效果。&lt;/p&gt;
&lt;p&gt;在媒体与娱乐行业，3D Rendering 是制作叙事镜头和动画的重要基础。无论是动画电影、历史剧，还是包含爆炸、未来飞船、奇异场景和外星生物的动作镜头，都离不开这类技术。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://zwjcode.cn/_astro/thanos-dd-single-image-004a.DUX4VGf-_1A3bTN.webp&quot; alt=&quot;角色视觉效果示例&quot;&gt;&lt;/p&gt;
&lt;p&gt;近年来，Computer Graphics 的发展逐渐替代了许多传统制作方式。例如，Visual Effects 可以减少高风险实拍，让特技演员无需在真实撞车等场景中承担不必要的危险。&lt;/p&gt;
&lt;p&gt;在广告行业，大量汽车广告都使用 CG 制作。在建筑行业，实体比例模型等传统表达方式也逐渐被 Photo-realistic 图像取代，使人们能够在建设前更准确地了解最终效果。&lt;/p&gt;
&lt;p&gt;更快的制作流程、更低的成本和更高的质量要求推动了技术演进。随着硬件性能持续提升，CG 的普及成为自然结果。&lt;/p&gt;
&lt;h2&gt;3D Rendering 图像如何生成？&lt;/h2&gt;
&lt;p&gt;计算机生成图像和动画通常会使用两类具有不同特性的引擎：Render Engine 与 Game Engine。Render Engine 常使用 Ray Tracing，Game Engine 通常使用 Rasterization；部分现代引擎也会混合使用这两种技术。&lt;/p&gt;</content:encoded><h:img src="/_astro/thumbnail.DzZDiYKA.jpg"/><enclosure url="/_astro/thumbnail.DzZDiYKA.jpg"/></item><item><title>技术如何改变音乐世界</title><link>https://zwjcode.cn/blog/music-journey</link><guid isPermaLink="true">https://zwjcode.cn/blog/music-journey</guid><description>音乐的发展由创造力、节奏与技术共同推动。</description><pubDate>Sat, 30 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;音乐的发展由创造力、节奏与技术共同推动。从 Acoustic Instruments 到今天的数字时代，技术不断改变音乐的创作、传播和体验方式。本文将回顾重要历史节点、Digital Revolution 和新兴技术，观察技术如何重塑音乐世界。&lt;/p&gt;
&lt;h2&gt;历史视角&lt;/h2&gt;
&lt;p&gt;音乐与技术的结合已经持续数百年。19 世纪末，Thomas Edison 发明 Phonograph，使音乐第一次能够被记录和重放。此后的 Electric Guitar、Synthesizer 等技术又改变了音乐创作方式，为新的流派和声音打开空间。&lt;/p&gt;
&lt;p&gt;这些技术突破不仅改变了当时的音乐面貌，也为音乐与技术的持续融合奠定基础。艺术家通过新工具探索不同的声音和风格，使现代音乐产业更加多元。回顾这段历史，可以清楚看到每一次创新都在改变音乐的创作、消费和体验方式。&lt;/p&gt;
&lt;h2&gt;Digital Revolution&lt;/h2&gt;
&lt;p&gt;Digital Revolution 深刻改变了音乐的消费、分发和制作。从 CD、Vinyl 等实体介质转向 MP3 和 Streaming Services 后，更多人可以低成本接触音乐。Streaming 让发现和收听音乐更加方便，同时也挑战了传统收入模式。&lt;/p&gt;
&lt;h2&gt;音乐消费与分发中的技术&lt;/h2&gt;
&lt;p&gt;Streaming Services 已成为音乐消费的重要入口，传统唱片商店则逐渐式微。在线音乐重塑了分发渠道，让艺术家可以触达全球听众，但也带来了创作者收益分配等问题。&lt;/p&gt;
&lt;h2&gt;音乐制作与创作&lt;/h2&gt;
&lt;p&gt;Digital Audio Workstations（DAWs）、Software Instruments 和电子制作技术降低了音乐创作门槛。艺术家可以便捷地实验声音、远程协作并独立完成制作。技术打破了传统资源限制，让更多不同的声音进入不断扩展的音乐世界。&lt;/p&gt;</content:encoded><h:img src="/_astro/thumbnail.Cx18cRmB.jpg"/><enclosure url="/_astro/thumbnail.Cx18cRmB.jpg"/></item><item><title>Markdown 语法支持（导航页）</title><link>https://zwjcode.cn/blog/markdown</link><guid isPermaLink="true">https://zwjcode.cn/blog/markdown</guid><description>Markdown 是一种轻量级标记语言。</description><pubDate>Wed, 26 Jul 2023 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这篇文章的完整中文版已经整理完成，请阅读：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/blog/markdown-zh/&quot;&gt;查看 Markdown 语法支持完整版&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;文章涵盖标题、粗体与斜体、链接、代码块、数学公式、图片、列表、引用、HTML 元素等常见语法，并保留 Shiki、KaTeX 等专业名词。&lt;/p&gt;</content:encoded><h:img src="/_astro/thumbnail.HAXFr_hw.jpg"/><enclosure url="/_astro/thumbnail.HAXFr_hw.jpg"/></item><item><title>Markdown 语法支持</title><link>https://zwjcode.cn/blog/markdown-zh</link><guid isPermaLink="true">https://zwjcode.cn/blog/markdown-zh</guid><description>Markdown 是一种轻量级的「标记语言」。</description><pubDate>Wed, 26 Jul 2023 08:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;基本语法&lt;/h2&gt;
&lt;p&gt;Markdown 是一种轻量级且易于使用的语法，用于为您的写作设计风格。&lt;/p&gt;
&lt;h3&gt;标题&lt;/h3&gt;
&lt;p&gt;文章内容较多时，可以用标题分段：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# 标题 1

## 标题 2

## 大标题

### 小标题
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;标题预览会打乱文章的结构，所以在此不展示。&lt;/p&gt;
&lt;h3&gt;粗斜体&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;_斜体文本_

**粗体文本**

**_粗斜体文本_**
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;&lt;em&gt;斜体文本&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;粗体文本&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;粗斜体文本&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;链接&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;文字链接 [链接名称](http://链接网址)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;文字链接 &lt;a href=&quot;http://%E9%93%BE%E6%8E%A5%E7%BD%91%E5%9D%80&quot;&gt;链接名称&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;行内代码&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;这是一条 `单行代码`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;这是一条 &lt;code&gt;行内代码&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;代码块&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;```js
// calculate fibonacci
function fibonacci(n) {
  if (n &amp;#x3C;= 1) return 1
  return fibonacci(n - 1) + fibonacci(n - 2)
}
```
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// calculate fibonacci
function fibonacci(n) {
  if (n &amp;#x3C;= 1) return 1
  return fibonacci(n - 1) + fibonacci(n - 2)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前使用 shiki 作为代码高亮插件，支持的语言请参考 &lt;a href=&quot;https://shiki.matsu.io/languages.html&quot;&gt;shiki / languages&lt;/a&gt;。&lt;/p&gt;
&lt;h3&gt;行内公式&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;这是一条行内公式 $e^{i\pi} + 1 = 0$
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;这是一条行内公式 $e^{i\pi} + 1 = 0$&lt;/p&gt;
&lt;h3&gt;公式块&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;$$
\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x) e^{-2\pi i x \xi} \, dx
$$
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;$$
\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x) e^{-2\pi i x \xi} , dx
$$&lt;/p&gt;
&lt;p&gt;当前使用 KaTeX 作为数学公式插件，支持的语法请参考 &lt;a href=&quot;https://katex.org/docs/supported.html&quot;&gt;KaTeX Supported Functions&lt;/a&gt;。&lt;/p&gt;
&lt;h4&gt;图片&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;![CWorld](/images/links/cworld.webp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://zwjcode.cn/images/links/cworld.webp&quot; alt=&quot;CWorld&quot;&gt;&lt;/p&gt;
&lt;h4&gt;删除线&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;~~删除线~~
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;~~删除线~~&lt;/p&gt;
&lt;h3&gt;列表&lt;/h3&gt;
&lt;p&gt;普通无序列表&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;- 1
- 2
- 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1&lt;/li&gt;
&lt;li&gt;2&lt;/li&gt;
&lt;li&gt;3&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;普通有序列表&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;1. GPT-4
2. Claude Opus
3. LLaMa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;GPT-4&lt;/li&gt;
&lt;li&gt;Claude Opus&lt;/li&gt;
&lt;li&gt;LLaMa&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;列表里可以继续嵌套语法&lt;/p&gt;
&lt;h3&gt;引用&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;&gt; 枪响，雷鸣，剑起。繁花血景。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;枪响，雷鸣，剑起。繁花血景。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;引用里也可以继续嵌套语法。&lt;/p&gt;
&lt;h3&gt;换行&lt;/h3&gt;
&lt;p&gt;markdown 分段落是需要空一行的。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;如果不空行
就会在一段

第一段

第二段
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;如果不空行
就会在一段&lt;/p&gt;
&lt;p&gt;第一段&lt;/p&gt;
&lt;p&gt;第二段&lt;/p&gt;
&lt;h3&gt;分隔符&lt;/h3&gt;
&lt;p&gt;如果你有写分割线的习惯，可以新起一行输入三个减号&lt;code&gt;---&lt;/code&gt; 或者星号 &lt;code&gt;***&lt;/code&gt;。当前后都有段落时，请空出一行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;高级技巧&lt;/h2&gt;
&lt;h3&gt;行内 HTML 元素&lt;/h3&gt;
&lt;p&gt;目前只支持部分段内 HTML 元素效果，包括 &lt;code&gt;&amp;#x3C;kdb&gt; &amp;#x3C;b&gt; &amp;#x3C;i&gt; &amp;#x3C;em&gt; &amp;#x3C;sup&gt; &amp;#x3C;sub&gt; &amp;#x3C;br&gt;&lt;/code&gt; ，如&lt;/p&gt;
&lt;h4&gt;键位显示&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;使用 &amp;#x3C;kbd&gt;Ctrl&amp;#x3C;/kbd&gt; + &amp;#x3C;kbd&gt;Alt&amp;#x3C;/kbd&gt; + &amp;#x3C;kbd&gt;Del&amp;#x3C;/kbd&gt; 重启电脑
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;使用 Ctrl + Alt + Del 重启电脑&lt;/p&gt;
&lt;h4&gt;粗斜体&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;&amp;#x3C;b&gt; Markdown 在此处同样适用，如 _加粗_ &amp;#x3C;/b&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt; Markdown 在此处同样适用，如 &lt;em&gt;加粗&lt;/em&gt; &lt;/p&gt;
&lt;h3&gt;其他 HTML 写法&lt;/h3&gt;
&lt;h4&gt;折叠块&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;&amp;#x3C;details&gt;&amp;#x3C;summary&gt;点击展开&amp;#x3C;/summary&gt;它被隐藏了&amp;#x3C;/details&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;h3&gt;表格&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;| 表头1 | 表头2 |
| ----- | ----- |
| 内容1 | 内容2 |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;| 表头1 | 表头2 |
| ----- | ----- |
| 内容1 | 内容2 |&lt;/p&gt;
&lt;h3&gt;注释&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;在引用的地方使用 [^注释] 来添加注释。

然后在文档的结尾，添加注释的内容（会默认于文章结尾渲染之）。

[^注释]: 这里是注释的内容
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;在引用的地方使用 &lt;a href=&quot;%E8%BF%99%E9%87%8C%E6%98%AF%E6%B3%A8%E9%87%8A%E7%9A%84%E5%86%85%E5%AE%B9&quot;&gt;^注释&lt;/a&gt; 来添加注释。&lt;/p&gt;
&lt;p&gt;然后在文档的结尾，添加注释的内容（会默认于文章结尾渲染之）。&lt;/p&gt;
&lt;h3&gt;To-Do 列表&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;- [ ] 未完成的任务
- [x] 已完成的任务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 未完成的任务&lt;/li&gt;
&lt;li&gt;[x] 已完成的任务&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;符号转义&lt;/h3&gt;
&lt;p&gt;如果你的描述中需要用到 markdown 的符号，比如 _ # * 等，但又不想它被转义，这时候可以在这些符号前加反斜杠，如 &lt;code&gt;\_&lt;/code&gt; &lt;code&gt;\#&lt;/code&gt; &lt;code&gt;\*&lt;/code&gt; 进行避免。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;\_不想这里的文本变斜体\_

\*\*不想这里的文本被加粗\*\*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;预览：&lt;/p&gt;
&lt;p&gt;_不想这里的文本变斜体_&lt;/p&gt;
&lt;p&gt;**不想这里的文本被加粗**&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;内嵌 Astro 组件&lt;/h2&gt;
&lt;p&gt;See &lt;a href=&quot;/docs/integrations/components&quot;&gt;User Components&lt;/a&gt; and &lt;a href=&quot;/docs/integrations/advanced&quot;&gt;Advanced Components&lt;/a&gt; for details.&lt;/p&gt;</content:encoded><h:img src="/_astro/thumbnail.HAXFr_hw.jpg"/><enclosure url="/_astro/thumbnail.HAXFr_hw.jpg"/></item></channel></rss>