"""
=============================================================================
第 3 讲 · 代码 2：打开第 1 讲的黑盒——tiny_gpt 的每一块在做什么
=============================================================================

对应讲义：lectures/lecture03_architectures_objectives.md 第 2、3 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
第 1 讲把 base model（2 层、64 维、109,056 个参数的 decoder-only Transformer）当作黑盒：
"输入一串字符 → 输出下一个字符的概率分布"。这个文件把它一块一块拆开：

    演示 1：参数都在哪里？按公式逐项数出 109,056，并和 GPT-3 的"12·L·d²"对照。
    演示 2：不调用模型，**只用它的权重矩阵**手工做一遍前向传播（嵌入 → 2 个块 → LayerNorm → 输出层），
            打印每一步的形状，最后和 TinyGPT 的输出逐位比较。
    演示 3：注意力在看哪里？因果掩码让"未来"的权重严格为 0；最后一个位置的每个头分别看向哪些字符。
    演示 4：为什么注意力分数要除以 √d？维度越大，点积越大，softmax 越"尖"——梯度消失。
    演示 5：拆掉一块会怎样？把某一层的注意力 / MLP / 位置嵌入"关掉"，看验证集 bit/字符和事实补全怎样变化；
            以及：把因果掩码换成双向掩码，模型会不会"作弊"？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_inside_tiny_gpt.py          # 约 5 秒（读取第 1 讲缓存的 base model）

需要 PyTorch。只读地导入第 1 讲的 fm_world.py、tiny_gpt.py、console.py 和本目录的 arch_tools.py。
如果第 1 讲的缓存不存在，load_or_train_base_model 会按第 1 讲的默认配置训练一个（约 1 分钟），
得到的模型和第 1 讲完全相同。
"""

import math
import os
import sys

import torch
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))

from arch_tools import MaskedTransformer, causal_mask, full_mask           # noqa: E402
from console import pad, print_header, show                                # noqa: E402
from fm_world import ALPHABET, build_world                                 # noqa: E402
from tiny_gpt import CharTokenizer, count_parameters, load_or_train_base_model   # noqa: E402


# =============================================================================
# 演示 1：参数都在哪里
# =============================================================================
def demo_param_count(model):
    print_header("演示 1：按公式数参数 —— 109,056 个参数都在哪里")
    cfg = model.cfg
    V, T, d, L = cfg.vocab_size, cfg.block_size, cfg.d_model, cfg.n_layer
    parts = [
        ("字符嵌入 tok_emb", "V·d", V * d),
        ("位置嵌入 pos_emb", "T·d", T * d),
        ("每个块：ln1（γ 和 β）", "2d", 2 * d),
        ("每个块：注意力 qkv（W 和 b）", "d·3d + 3d", d * 3 * d + 3 * d),
        ("每个块：注意力输出 proj", "d·d + d", d * d + d),
        ("每个块：ln2", "2d", 2 * d),
        ("每个块：MLP 第一层（升到 4d）", "d·4d + 4d", d * 4 * d + 4 * d),
        ("每个块：MLP 第二层（降回 d）", "4d·d + d", 4 * d * d + d),
        ("最后的 ln_f", "2d", 2 * d),
        ("输出层 head（与 tok_emb 共享）", "0", 0),
    ]
    print(f"V = {V}（字符数），T = {T}（上下文长度），d = {d}（宽度），L = {L}（层数），4 个头\n")
    print(pad("部分", 34) + pad("公式", 14) + pad("参数个数", 10, ">"))
    for name, formula, n in parts:
        print(pad(name, 34) + pad(formula, 14) + pad(f"{n:,}", 10, ">"))
    per_block = sum(n for name, _, n in parts if name.startswith("每个块"))
    total = V * d + T * d + L * per_block + 2 * d
    print(f"\n每个块合计 12d² + 13d = {per_block:,}；{L} 个块 = {L * per_block:,}")
    print(f"总计 = V·d + T·d + L·(12d² + 13d) + 2d = {total:,}；PyTorch 数出来的 = {count_parameters(model):,}")
    assert total == count_parameters(model)
    mlp = d * 4 * d + 4 * d + 4 * d * d + d
    print(f"每个块里 MLP 占 {mlp / per_block:.0%}，注意力（qkv + proj）占 {(d * 3 * d + 3 * d + d * d + d) / per_block:.0%}；"
          f"两张嵌入表占全模型的 {(V * d + T * d) / total:.1%}")
    print(f"如果不共享输出层，要多 V·d = {V * d:,} 个参数（全模型的 {V * d / total:.1%}）")

    # GPT-3：d = 12288，L = 96，V = 50257，T = 2048（数字引自 CS324 课程笔记与 GPT-3 论文）
    d3, L3, V3, T3 = 12288, 96, 50257, 2048
    blocks3 = 12 * L3 * d3 * d3
    emb3 = V3 * d3 + T3 * d3
    print(f"\n对照 GPT-3（d = {d3:,}，L = {L3}，V = {V3:,}，T = {T3:,}）：")
    print(f"  12·L·d² = {blocks3:,} ≈ {blocks3 / 1e9:.1f} B；嵌入表 (V + T)·d = {emb3:,} ≈ {emb3 / 1e9:.2f} B；"
          f"合计 ≈ {(blocks3 + emb3) / 1e9:.1f} B（论文报告 175 B）")
    print(f"  嵌入表占 {emb3 / (blocks3 + emb3):.2%}；而在我们的小模型里占 {(V * d + T * d) / total:.1%}")
    print("""
观察 Observations：
  · 一个 Transformer 块的参数几乎全在 4 个矩阵里：W_qkv（d×3d）、W_proj（d×d）、MLP 的两层（d×4d 和 4d×d），
    合计 12d²；偏置和 LayerNorm 只有 13d 个。所以"参数量 ≈ 12·L·d²"是估算 Transformer 大小的标准公式，
    用它算 GPT-3 得到约 1739 亿，加上嵌入表约 1746 亿，和论文的 1750 亿只差一点点。
  · MLP 占每个块参数的 2/3，注意力只占 1/3。"注意力"是 Transformer 的招牌，但大部分参数（以及大部分计算）在 MLP 里
    ——第 12 讲会看到，很多"事实知识"被认为储存在 MLP 里。
  · 规模越小，嵌入表的占比越大：我们的模型里两张嵌入表占 8.2%，GPT-3 里只占约 0.37%。
    所以小模型上的结论（例如"嵌入表很贵"）不一定能推广到大模型——这正是"机制 vs 量级"的一个例子。
  · 输出层和字符嵌入共享同一个矩阵（weight tying，Press & Wolf 2017；GPT-2 也这样做）：输入时"字符 → 向量"，
    输出时用同一组向量和最后的表示做点积，"哪个字符的向量和表示最像，下一个就最可能是它"。""")


# =============================================================================
# 演示 2：手工前向传播
# =============================================================================
def layer_norm(x, w, b, eps=1e-5):
    """
    LayerNorm：把每个位置的向量减去自己的均值、除以自己的标准差，再乘以 γ（w）、加上 β（b）。

    【参数】x：(..., d)；w、b：(d,)
    【返回】与 x 同形状
    【思路】它只在"一个位置的 d 个数"之间做归一化，不跨位置、不跨样本（BatchNorm 才跨样本）。
            作用：不管残差流里的数值涨到多大，送进注意力 / MLP 的输入始终是"标准大小"，训练更稳定。
    【PyTorch 小知识】var(unbiased=False) 是除以 d 的方差（nn.LayerNorm 用的就是这个）。
    """
    mu = x.mean(-1, keepdim=True)
    var = x.var(-1, unbiased=False, keepdim=True)
    return (x - mu) / torch.sqrt(var + eps) * w + b


def gelu(x):
    """GELU 激活函数（精确版）：x · Φ(x)，Φ 是标准正态分布的累积分布函数。nn.GELU() 的默认形式。"""
    return 0.5 * x * (1.0 + torch.erf(x / math.sqrt(2.0)))


@torch.no_grad()
def manual_forward(model, idx, verbose=True):
    """
    只用模型的权重矩阵，手工做一遍前向传播（不调用任何 nn.Module 的 forward）。

    【参数】model：TinyGPT（只读它的参数）；idx：一维 LongTensor，长度 T
    【返回】logits：(T, V)
    【思路】逐行对应 tiny_gpt.py 里 TinyGPT.forward → Block.forward → CausalSelfAttention.forward。
    """
    P = dict(model.named_parameters())
    T = len(idx)
    d = model.cfg.d_model
    H = model.cfg.n_head
    dh = d // H

    def say(msg):
        if verbose:
            print("  " + msg)

    x = P["tok_emb.weight"][idx] + P["pos_emb.weight"][:T]
    say(f"x = 字符嵌入[idx] + 位置嵌入[0:T]                     形状 {tuple(x.shape)}   (T, d)")
    for l in range(model.cfg.n_layer):
        pre = f"blocks.{l}."
        h = layer_norm(x, P[pre + "ln1.weight"], P[pre + "ln1.bias"])
        qkv = h @ P[pre + "attn.qkv.weight"].T + P[pre + "attn.qkv.bias"]
        q, k, v = qkv.split(d, dim=1)
        q = q.view(T, H, dh).transpose(0, 1)                   # (H, T, dh)
        k = k.view(T, H, dh).transpose(0, 1)
        v = v.view(T, H, dh).transpose(0, 1)
        scores = q @ k.transpose(1, 2) / math.sqrt(dh)         # (H, T, T)
        scores = scores.masked_fill(~causal_mask(T), float("-inf"))
        att = torch.softmax(scores, dim=-1)
        y = (att @ v).transpose(0, 1).reshape(T, d)            # 把 H 个头拼回 d 维
        attn_out = y @ P[pre + "attn.proj.weight"].T + P[pre + "attn.proj.bias"]
        x = x + attn_out
        h2 = layer_norm(x, P[pre + "ln2.weight"], P[pre + "ln2.bias"])
        m = gelu(h2 @ P[pre + "mlp.0.weight"].T + P[pre + "mlp.0.bias"])
        mlp_out = m @ P[pre + "mlp.2.weight"].T + P[pre + "mlp.2.bias"]
        x = x + mlp_out
        if l == 0:
            say(f"第 {l} 层：h = LayerNorm(x)                             形状 {tuple(h.shape)}")
            say(f"        q, k, v = h·W_qkv 再拆成 {H} 个头               形状 {tuple(q.shape)} (H, T, d/H)")
            say(f"        分数 = q·kᵀ / √{dh}，上三角填 −∞，softmax      形状 {tuple(att.shape)} (H, T, T)")
            say(f"        各头加权平均 v，拼回、乘 W_proj             形状 {tuple(attn_out.shape)}")
            say(f"        x = x + 注意力输出（残差）")
            say(f"        MLP：LayerNorm → 升到 {4 * d} 维 → GELU → 降回 {d} 维  中间形状 {tuple(m.shape)}")
            say(f"        x = x + MLP 输出（残差）                      形状 {tuple(x.shape)}")
        else:
            say(f"第 {l} 层：同上                                        形状 {tuple(x.shape)}")
    hf = layer_norm(x, P["ln_f.weight"], P["ln_f.bias"])
    logits = hf @ P["tok_emb.weight"].T                        # 输出层 = 字符嵌入矩阵的转置（weight tying）
    say(f"最后：LayerNorm，再乘以字符嵌入矩阵的转置（共享权重）       形状 {tuple(logits.shape)}   (T, V)")
    return logits


def demo_manual_forward(model, tok, world):
    print_header("演示 2：只用权重矩阵，手工做一遍前向传播")
    p = world.people[0]
    prompt = f"\n{p.name} lives in "
    idx = torch.tensor(tok.encode(prompt))
    print(f"提示：{show(prompt)!r}（T = {len(idx)} 个字符）；{p.name} 住在 {p.city}\n")
    logits = manual_forward(model, idx)
    with torch.no_grad():
        ref = model(idx[None])[0]
    diff = (logits - ref).abs().max().item()
    print(f"\n手工结果与 TinyGPT 的输出：最大绝对差 {diff:.1e}（logits 本身的量级约 {ref.abs().max().item():.0f}）")
    probs = torch.softmax(logits[-1], dim=-1)
    top = torch.topk(probs, 5)
    print("最后一个位置（\"in \" 后面）下一个字符的前 5 名："
          + "  ".join(f"{show(tok.alphabet[i])!r} {pr:.3f}" for pr, i in zip(top.values.tolist(), top.indices.tolist())))

    # 残差流：每一层往 x 上"加"了多少
    P = dict(model.named_parameters())
    T = len(idx)
    with torch.no_grad():
        x = P["tok_emb.weight"][idx] + P["pos_emb.weight"][:T]
        norms = [("嵌入", x[-1].norm().item())]
        for l, block in enumerate(model.blocks):
            a = block.attn(block.ln1(x[None]))[0]
            norms.append((f"第 {l} 层注意力的输出", a[-1].norm().item()))
            x = x + a
            m = block.mlp(block.ln2(x[None]))[0]
            norms.append((f"第 {l} 层 MLP 的输出", m[-1].norm().item()))
            x = x + m
        norms.append(("最终残差流 x", x[-1].norm().item()))
    print("\n残差流（最后一个位置）：每个组件写进去的向量有多长（L2 范数）：")
    print("  " + "   ".join(f"{name} {n:.1f}" for name, n in norms))
    print("""
观察 Observations：
  · 手工计算和模型的输出只差浮点舍入误差（10⁻⁵ 量级，而 logits 本身是十几的量级）：base model 里**没有任何别的东西**——
    两张嵌入表、两个块（每块 = LayerNorm → 注意力 → 残差相加 → LayerNorm → MLP → 残差相加）、
    最后一个 LayerNorm、一次矩阵乘法。GPT-3 的结构和它基本相同，只是 96 层、12288 维（另外它交替使用稠密和稀疏的注意力）。
  · 形状的变化值得记住：整个过程中，每个位置始终是一个 d = 64 维的向量（"残差流"，residual stream）；
    只有注意力内部出现 (H, T, T) 的权重矩阵——这是唯一让不同位置交换信息的地方；
    MLP 只在每个位置内部计算（d → 4d → d），不同位置之间不交流。
  · 残差连接（x = x + f(x)）意味着每个组件只是往残差流上"加一笔"：嵌入先写下"这是哪个字符、在哪个位置"，
    注意力把别的位置的信息搬过来，MLP 在原地加工。最后的输出层读的是所有笔画的总和。
    在这个位置上，最后一层 MLP 写进去的那一笔最长（4.8，其他都在 1.3–1.5）——"说出哪个城市"的决定主要在那里成形。
    这个"共享的残差流"的视角是第 12 讲（机制可解释性）的出发点。
  · 模型很有把握：下一个字符是城市名的首字母。它"知道" base 住在 telmi——这个知识只能存在于这些权重矩阵里。""")


# =============================================================================
# 演示 3：注意力在看哪里
# =============================================================================
def demo_attention(model, tok, world):
    print_header("演示 3：注意力在看哪里 —— 因果掩码与各个头的分工")
    p = world.people[0]
    prompt = f"\nq: where does {p.name} live? a:"
    idx = torch.tensor([tok.encode(prompt)])
    for block in model.blocks:
        block.attn.record = True
    with torch.no_grad():
        model(idx)
    atts = [block.attn.last_attention[0] for block in model.blocks]       # 每层 (H, T, T)
    for block in model.blocks:
        block.attn.record = False
        block.attn.last_attention = None
    T = idx.shape[1]
    future = sum(a.triu(diagonal=1).sum().item() for a in atts)
    print(f"提示：{show(prompt)!r}（T = {T}）")
    print(f"所有层、所有头里，\"看向未来\"（上三角）的注意力权重之和 = {future}（因果掩码：严格为 0）\n")

    name_pos = list(range(len("\nq: where does "), len("\nq: where does ") + len(p.name)))
    chars = [show(c) for c in prompt]
    print("最后一个位置（\":\"，模型接下来要说出城市）的每个头：注意力最大的 3 个位置，以及落在名字上的权重之和")
    print(pad("层.头", 7) + pad("最关注的 3 个位置（字符 @ 下标：权重）", 54) + pad("名字上的权重", 12, ">"))
    best = None
    for l, a in enumerate(atts):
        for hd in range(a.shape[0]):
            row = a[hd, -1]
            top = torch.topk(row, 3)
            desc = "  ".join(f"{chars[i]!r}@{i}:{w:.2f}" for w, i in zip(top.values.tolist(), top.indices.tolist()))
            on_name = row[name_pos].sum().item()
            if best is None or on_name > best[0]:
                best = (on_name, l, hd)
            print(pad(f"{l}.{hd}", 7) + pad(desc, 54) + pad(f"{on_name:.2f}", 12, ">"))

    _, l, hd = best
    a = atts[l][hd]
    shades = " .:-=+*#%@"
    print(f"\n名字上权重最大的头是 {l}.{hd}。它的注意力矩阵（行 = 当前位置，列 = 被看的位置；字符越"
          f"\"重\"权重越大；空白 = 0 或很小）：")
    print("      " + "".join(c if c not in "⏎" else "⏎" for c in chars))
    for i in range(T):
        line = "".join(shades[min(9, int(a[i, j].item() * 10))] if j <= i else " " for j in range(T))
        print(f"  {chars[i]:>2}  {line}")
    print("""
观察 Observations：
  · 因果掩码让"看向未来"的权重**严格为 0**：softmax 之前把这些分数设成 −∞，exp(−∞) = 0。
    这保证了训练时位置 t 预测第 t+1 个字符不会偷看答案；也是"一次前向传播就得到所有位置的预测"的前提。
  · 不同的头分工不同。第 0 层的 4 个头都很"散"（最大的权重不到 0.2），主要看附近的标点和字符；
    第 1 层里，头 1.0 把约 0.39 的权重放在名字上（主要是名字的最后一个字母），要回答"住在哪里"，必须知道问的是谁；
    头 1.1 则几乎只看自己（0.58）。这就是**多头**的意义——每个头只有 16 维，各自学一种"查找规则"，
    结果拼起来（原论文的话：在不同的表示子空间里同时关注不同位置）。演示 5 会看到，关掉第 1 层的注意力，
    事实补全几乎归零。
  · 热力图的对角线以上全是空白（因果掩码）；很多行的权重集中在少数几个位置上：注意力是一种"软"的查表
    （CS324 笔记的说法：soft lookup table），查询 q 和每个键 k 比相似度，按相似度把值 v 加权平均。
  · 第 12 讲会更系统地读注意力模式（例如"归纳头"induction head），并说明只看注意力权重不足以解释模型。""")


# =============================================================================
# 演示 4：为什么要除以 √d
# =============================================================================
def demo_sqrt_d(model, tok, world):
    print_header("演示 4：为什么注意力分数要除以 √d")
    g = torch.Generator().manual_seed(4)
    print("随机向量 q、k 的每个分量独立地服从 N(0, 1)（原论文脚注的假设）；一行有 96 个键，各取 2000 行：\n")
    print(pad("每头维度 d_k", 14) + pad("q·k 的标准差", 14, ">") + pad("√d_k", 8, ">")
          + pad("不缩放：最大权重", 18, ">") + pad("除以 √d_k：最大权重", 22, ">") + pad("不缩放：梯度 ≈ 0 的行", 22, ">"))
    for dk in [4, 16, 64, 256, 1024]:
        q = torch.randn(2000, 1, dk, generator=g)
        k = torch.randn(2000, 96, dk, generator=g)
        s = (q * k).sum(-1)                                     # (2000, 96)
        raw = torch.softmax(s, dim=-1)
        scaled = torch.softmax(s / math.sqrt(dk), dim=-1)
        # softmax 的雅可比矩阵 J = diag(p) − p pᵀ；最大权重接近 1 时 J ≈ 0，梯度几乎传不回去
        sat = (raw.max(dim=-1).values > 0.99).float().mean().item()
        print(pad(f"{dk}", 14) + pad(f"{s.std().item():.1f}", 14, ">") + pad(f"{math.sqrt(dk):.1f}", 8, ">")
              + pad(f"{raw.max(-1).values.mean().item():.3f}", 18, ">")
              + pad(f"{scaled.max(-1).values.mean().item():.3f}", 22, ">") + pad(f"{sat:.0%}", 22, ">"))

    # 训练好的 base model：每层 q·k / √d_h 的实际大小
    p = world.people[0]
    idx = torch.tensor([tok.encode(f"\n{p.name} lives in {p.city}.\nreview: the soup was tasty and the host was kind.")])
    with torch.no_grad():
        x = model.tok_emb(idx) + model.pos_emb(torch.arange(idx.shape[1]))
        print("\n训练好的 base model（每头 d_h = 16）在一段文本上：")
        for l, block in enumerate(model.blocks):
            h = block.ln1(x)
            q, k, _ = block.attn.qkv(h).split(64, dim=2)
            T = idx.shape[1]
            q = q.view(1, T, 4, 16).transpose(1, 2)
            k = k.view(1, T, 4, 16).transpose(1, 2)
            sc = (q @ k.transpose(-2, -1) / 4.0)[0]
            sc = sc.masked_fill(~causal_mask(T), float("nan"))
            vals = sc[~torch.isnan(sc)]
            att = torch.softmax(torch.nan_to_num(sc, nan=float("-inf")), dim=-1)
            ent = -(att * torch.log(att.clamp_min(1e-12))).sum(-1).mean().item()
            print(f"  第 {l} 层：缩放后分数的标准差 {vals.std().item():.2f}，"
                  f"注意力分布的平均熵 {ent:.2f} nat（同样长度的均匀分布约 {math.log((T + 1) / 2):.2f}）")
            x = block(x)
    print("""
观察 Observations：
  · 两个 d_k 维随机向量的点积，标准差约等于 √d_k（表里 2.0、4.0、8.0、16.0、32.0）：维度每大 4 倍，分数就大 2 倍。
  · 不缩放时，d_k 越大 softmax 越"尖"：d_k = 64 时已有 18% 的行最大权重超过 0.99，d_k = 1024 时平均最大权重 0.94、
    约 7 成的行超过 0.99——这些行的 softmax 梯度（diag(p) − ppᵀ）几乎为 0，注意力"卡死"在随机选中的一个位置上，学不动。
    除以 √d_k 之后，分数的尺度和维度无关，各种 d_k 下的分布都一样"温和"。
  · 这就是原论文的理由：缩放的目的是让训练开始时的注意力分布不要太尖（而不是为了最终的表达能力——
    训练好的模型可以自己把分数调大，表里训练好的 base model 的注意力就比均匀分布集中得多）。
  · GPT-3 的每头维度是 128（12288 / 96），不缩放的话分数的标准差约 11，softmax 一开始就几乎是 one-hot。""")


# =============================================================================
# 演示 5：拆掉一块会怎样
# =============================================================================
@torch.no_grad()
def masked_bpc(mt, data, kind="causal"):
    """和 tiny_gpt.bits_per_char 一样（不重叠的 96 字符窗口），但用 MaskedTransformer 和指定的掩码。"""
    T = mt.cfg.block_size
    mask = causal_mask(T) if kind == "causal" else full_mask(T)
    total, count = 0.0, 0
    for i in range(0, len(data) - T - 1, T):
        x = data[i:i + T][None]
        y = data[i + 1:i + T + 1][None]
        logits = mt(x, mask)
        total += F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1), reduction="sum").item()
        count += T
    return total / count / math.log(2)


@torch.no_grad()
def fact_accuracy(mt, tok, world):
    """事实补全（第 1 讲代码 02 的评测）："\\n{名字} lives in" 之后贪心地写到句号，和 " {城市}." 比较。"""
    ok = 0
    for p in world.people:
        ids = tok.encode(f"\n{p.name} lives in")
        out = ""
        for _ in range(10):
            nxt = int(mt(torch.tensor([ids]), causal_mask(len(ids)))[0, -1].argmax())
            ids.append(nxt)
            out += tok.alphabet[nxt]
            if tok.alphabet[nxt] in ".\n":
                break
        ok += out == f" {p.city}."
    return ok / len(world.people)


def ablated(model, what):
    """
    复制一份 base model 装进 MaskedTransformer，再把某个组件"关掉"。

    【参数】what：None（不改）/ "pos"（位置嵌入置 0）/ ("attn", l) / ("mlp", l)（第 l 层的输出投影置 0，
            这一层就只剩残差连接："什么都不加"）
    【返回】MaskedTransformer
    【边界】只在副本上修改，不碰原模型，更不碰第 1 讲的缓存文件。
    """
    mt = MaskedTransformer(model.cfg)
    mt.load_state_dict(model.state_dict())
    mt.eval()
    with torch.no_grad():
        if what == "pos":
            mt.pos_emb.weight.zero_()
        elif what is not None:
            kind, l = what
            lin = mt.blocks[l].attn.proj if kind == "attn" else mt.blocks[l].mlp[2]
            lin.weight.zero_()
            lin.bias.zero_()
    return mt


def demo_ablation(model, tok, world):
    print_header("演示 5：拆掉一块会怎样 —— 消融（ablation）与\"换掉因果掩码\"")
    val = torch.tensor(tok.encode(world.val_text()), dtype=torch.long)
    base = ablated(model, None)
    x = torch.tensor([tok.encode(world.val_text()[:96])])
    with torch.no_grad():
        same = torch.equal(base(x, causal_mask(96)), model(x))
    print(f"MaskedTransformer 装入 base model 的权重、用因果掩码：输出与 TinyGPT 逐位相同 {same}\n")
    rows = [("完整的 base model", None, "causal"),
            ("位置嵌入置 0", "pos", "causal"),
            ("关掉第 0 层注意力", ("attn", 0), "causal"),
            ("关掉第 1 层注意力", ("attn", 1), "causal"),
            ("关掉第 0 层 MLP", ("mlp", 0), "causal"),
            ("关掉第 1 层 MLP", ("mlp", 1), "causal"),
            ("因果掩码 → 双向掩码（能看到答案）", None, "full")]
    print(pad("模型", 36) + pad("验证集 bit/字符", 16, ">") + pad("事实补全", 10, ">"))
    print(pad("（参照：均匀分布，什么都没学）", 36) + pad(f"{math.log2(len(tok.alphabet)):.3f}", 16, ">") + pad("0.00", 10, ">"))
    for name, what, kind in rows:
        mt = ablated(model, what)
        bpc = masked_bpc(mt, val, kind)
        if kind == "causal":
            fact = f"{fact_accuracy(mt, tok, world):.2f}"
        else:
            fact = "—"
        print(pad(name, 36) + pad(f"{bpc:.3f}", 16, ">") + pad(fact, 10, ">"))
    print("""
观察 Observations：
  · 没有位置嵌入，注意力就不知道"谁在前、谁在后"：自注意力本身对顺序不敏感（把输入打乱，每个位置的输出只是跟着换位置），
    顺序信息主要从位置嵌入来。关掉它，bit/字符从 0.409 变成 5.022，接近什么都没学的 5.459，事实补全归零。
  · 在这个只有 2 层的模型里，**每一块都不可缺少**：关掉第 0 层的注意力或 MLP，结果比均匀分布还差（6.799、5.499）——
    后面的层是在"第 0 层已经加工过的残差流"上学的，输入的分布一变，它们就失灵了。
    影响最小的是第 1 层注意力：bit/字符只升到 1.348（大部分"拼单词、接模板"的能力还在），事实补全却从 0.93 掉到 0.05——
    演示 3 里把名字搬到当前位置的头 1.0 就在这一层，没有它，模型"想不起"谁住在哪里。（在几十层的大模型里，单独去掉中间某一层的影响
    常常小得多——层与层之间有冗余；小模型没有这种余量。）第 12 讲会用更精细的方法定位知识。
  · 最后一行：把因果掩码换成双向掩码，每个位置都能"看到"它要预测的下一个字符，按理说可以作弊——
    但 bit/字符变成 7.528，比均匀分布还差得多。模型从没学过怎样利用右边的信息，多出来的注意力目标
    把它学到的模式全打乱了。**掩码不是一个可以随便拨动的开关：架构（掩码）和训练目标是一起学进权重里的。**
    把一个训练好的因果模型改成双向模型，需要继续训练来"适应"（Wang et al. 2022 的 non-causal adaptation，讲义 §5）。""")


if __name__ == "__main__":
    world = build_world()
    tok = CharTokenizer(ALPHABET)
    model, info = load_or_train_base_model(world, tok)
    model.eval()
    demo_param_count(model)
    demo_manual_forward(model, tok, world)
    demo_attention(model, tok, world)
    demo_sqrt_d(model, tok, world)
    demo_ablation(model, tok, world)
