"""
=============================================================================
第 3 讲 · 代码 4：同一份数据、同样的算力，四种架构 / 目标各学会了什么、能做什么
=============================================================================

对应讲义：lectures/lecture03_architectures_objectives.md 第 4–6 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
把四种"架构 + 目标"放在同一条起跑线上：同样的数据（迷你互联网里的 1,897 条评论）、同样的宽度和层数
（d = 64，2 层，4 个头）、同样的序列长度和 batch（32 × 32 个 token）、同样的训练步数（2500 步）、同样的超参数：

    GPT   ：decoder-only + 因果 LM（每个位置预测下一个词）
    prefix：decoder-only + prefix LM（前缀双向，之后因果；只在前缀之后算损失）
    BERT  ：encoder-only + masked LM（15%，80/10/10）
    T5    ：encoder-decoder（编码器 2 层 + 解码器 2 层）+ span corruption（15%）

    演示 1：学习曲线——训练过程中，每个模型用**它自己的接口**预测评分（GPT 接着写、BERT 填 [M]、T5 填 <0>），
            但用同一种打分协议：在那个空位上只比较 good / ok / bad。准确率随步数怎样变化？谁先学会？
    演示 2：能做什么、不能做什么——预测评分、"根据右边的上下文填形容词"（三种做法）、从零生成整条评论。
    演示 3：线性探针——不看各自的"接口"，只看表示：冻结模型，用很少的标签读出"食物好不好"
            （测试用的形容词在标注数据里从没出现过，和第 1 讲代码 03 演示 2 的设定 B 相同）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_compare_objectives.py          # 第一次约 80 秒（训练 4 个模型并缓存）；之后约 5 秒

需要 PyTorch、numpy。训练好的模型缓存在本目录的 checkpoints/（l3_gpt.pt 等），可以随时删掉，
下次运行会重新训练出**完全相同**的模型。
"""

import os
import random
import sys

import numpy as np
import torch

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

from arch_tools import (Corpus, WordTokenizer, clm_batch, count_params, few_label_accuracy, full_mask,   # noqa: E402
                        hidden_at, load_or_train, make_encdec_model, make_masked_model, mlm_batch,
                        next_logits, prefix_batch, sample_lines_any, score_ids, t5_batch, t5_fill,
                        t5_next_logits, t5_span_logprob, train_objective)
from console import pad, print_header                                                           # noqa: E402
from fm_world import FOOD_NEG, FOOD_POS, build_world, check_line, make_review                    # noqa: E402
from tiny_gpt import GPTConfig                                                                  # noqa: E402

T, BATCH, STEPS, LR = 32, 32, 2500, 3e-3
MODEL_SEED, DATA_SEED = 0, 1
CURVE_EVERY = 500
KINDS = ["gpt", "prefix", "bert", "t5"]
NAMES = {"gpt": "GPT（因果 LM）", "prefix": "prefix LM", "bert": "BERT（masked LM）", "t5": "T5（span corruption）"}


# =============================================================================
# 建模型、训练（或读缓存）
# =============================================================================
def make_cfg(tok):
    """四个模型共用的形状。T5 的编码器、解码器各自 2 层（所以参数约是其他模型的 2 倍，计算量大致相同）。"""
    return GPTConfig(vocab_size=tok.vocab_size, block_size=T, n_layer=2, n_head=4, d_model=64)


def batch_fn(kind, corpus):
    """每种模型怎样取一个 batch（arch_tools 里的四个函数）。"""
    if kind == "gpt":
        return lambda B, g: clm_batch(corpus, B, T, g)
    if kind == "prefix":
        return lambda B, g: prefix_batch(corpus, B, T, g)
    if kind == "bert":
        return lambda B, g: mlm_batch(corpus, B, T, g)
    return lambda B, g: t5_batch(corpus, B, g)


@torch.no_grad()
def predict_rating(model, kind, tok, review_text):
    """
    用模型**自己的接口**预测一条评论的评分：四个模型用**同一种协议**——在"评分这个空"上取 logits，
    只在 good / ok / bad 三个候选里取最大的。

    【思路】GPT：提示 "...rating:"，看下一个词的 logits；prefix：同样的提示，整段当前缀；
            BERT：在评分的位置放 [M]（后面还有句号和换行），看 [M] 处的 logits；
            T5：在评分的位置放哨兵 <0>，解码器写到 "[S] <0>" 时，看下一个 token 的 logits。
    【返回】"good" / "ok" / "bad"
    【边界】T5 的"自由生成"（让解码器一直写到下一个哨兵）是另一回事：它还要求模型知道片段在哪里结束。
            本讲最初的版本误用了自由生成的整段完全匹配来给 T5 打分，得到全 0（见 README"关于实验结果"）；
            自由生成的结果现在单独用 t5_free_rating 统计。
    """
    cands = ["good", "ok", "bad"]
    left = tok.encode("\n" + review_text + " rating:")
    right = tok.encode(".\n")
    if kind == "t5":
        logits = t5_next_logits(model, tok, left[1:] + [tok.sentinel_ids[0]] + right)   # T5 的输入不需要开头的换行
    elif kind == "bert":
        ids = left + [tok.mask_id] + right
        logits = model(torch.tensor([ids]), full_mask(len(ids)))[0, len(left)]
    else:
        logits = next_logits(model, left, "causal" if kind == "gpt" else "prefix")
    return max(cands, key=lambda c: logits[tok.stoi[c]].item())


def t5_free_rating(model, tok, review_text):
    """
    T5 的"自由生成"：让解码器从 "[S] <0>" 开始一直写，写到下一个哨兵（或换行）为止，返回写出的 token 列表。

    【思路】这是 span corruption 模型被直接拿来"生成"时的样子：它不仅要写对内容，还要自己写出结束哨兵 <1>。
    """
    left = tok.encode("\n" + review_text + " rating:")
    return [tok.vocab[i] for i in t5_fill(model, tok, left[1:] + [tok.sentinel_ids[0]] + tok.encode(".\n"))]


def make_eval_reviews(n, seed):
    """n 条新评论（不一定没在训练集里出现过：评论只有 4096 种组合），返回 (文本, 评分, 食物好不好)。"""
    rng = random.Random(seed)
    out = []
    for _ in range(n):
        text, rating, food_pos, _ = make_review(rng)
        out.append((text, rating, food_pos))
    return out


def train_or_load(kind, tok, corpus):
    """
    训练一个模型（或读缓存）。训练过程中每 CURVE_EVERY 步用 100 条评论测一次"预测评分"的准确率（学习曲线）。

    【返回】(model, info)；info["curve"] 是 list[(step, 准确率)]
    """
    cfg = make_cfg(tok)
    meta = dict(script="04", kind=kind, cfg=vars(cfg), T=T, batch=BATCH, steps=STEPS, lr=LR,
                model_seed=MODEL_SEED, data_seed=DATA_SEED, data="fm_world 默认世界的 review 行",
                eval_protocol="三个候选里取 argmax（T5 另记自由生成）", version=2)
    curve_reviews = make_eval_reviews(100, seed=21)

    def build():
        maker = make_encdec_model if kind == "t5" else make_masked_model
        return {"model": maker(cfg, seed=MODEL_SEED)}

    def train(modules):
        curve, curve_free = [], []

        def track(step, model):
            acc = sum(predict_rating(model, kind, tok, t) == r for t, r, _ in curve_reviews) / len(curve_reviews)
            curve.append((step, acc))
            if kind == "t5":          # 另记：自由生成时，恰好写出 "评分词" 然后结束的比例（旧的、更苛刻的协议）
                free = sum(t5_free_rating(model, tok, t) == [r] for t, r, _ in curve_reviews) / len(curve_reviews)
                curve_free.append((step, free))

        hist = train_objective(modules["model"], batch_fn(kind, corpus), steps=STEPS, batch_size=BATCH, lr=LR,
                               seed=DATA_SEED, callback=track, callback_every=CURVE_EVERY)
        return dict(curve=curve, curve_free=curve_free, history=hist)

    modules, info = load_or_train(f"l3_{kind}", meta, build, train)
    return modules["model"], info


# =============================================================================
# 演示 1：学习曲线
# =============================================================================
@torch.no_grad()
def own_objective_accuracy(model, kind, corpus):
    """
    每个模型在**它自己的训练目标**上的准确率（新取的 256 段，teacher forcing：答案的前缀照原文给）。

    【返回】(总体准确率, 哨兵上的准确率或 None, 内容 token 上的准确率)
    【思路】GPT / prefix：下一个词猜对的比例（prefix 只算前缀之后）；BERT：被选中位置猜对的比例；
            T5：解码器目标里猜对的比例，分开统计哨兵（<0> <1> … 按顺序出现，几乎不用猜）和真正被挖掉的词。
    """
    g = torch.Generator().manual_seed(77)
    *inputs, y = batch_fn(kind, corpus)(256, g)
    pred = model(*inputs).argmax(-1)
    keep = y != -100
    correct = (pred == y) & keep
    total = correct.sum().item() / keep.sum().item()
    if kind != "t5":
        return total, None, total
    sent = keep & (y >= corpus.tok.sentinel_ids[0])
    content = keep & ~sent
    return total, correct[sent].float().mean().item(), correct[content].float().mean().item()


def demo_curves(models, infos, corpus):
    print_header("演示 1：学习曲线 —— 各自用自己的方式预测评分，准确率随训练步数的变化")
    steps = [s for s, _ in infos["gpt"]["curve"]]
    print(pad("模型", 26) + pad("参数量", 10, ">") + "".join(pad(f"{s} 步", 9, ">") for s in steps))
    for kind in KINDS:
        accs = dict(infos[kind]["curve"])
        print(pad(NAMES[kind], 26) + pad(f"{count_params(models[kind]):,}", 10, ">")
              + "".join(pad(f"{accs[s]:.2f}", 9, ">") for s in steps))
    free = dict(infos["t5"]["curve_free"])
    print(pad("  （T5 自由生成：恰好写出评分词就结束）", 36) + "".join(pad(f"{free[s]:.2f}", 9, ">") for s in steps))
    t = [f"{NAMES[k]} {infos[k]['train_seconds']:.0f} 秒" for k in KINDS if not infos[k]["from_cache"]]
    if t:
        print("（训练用时：" + "；".join(t) + "）")
    print("（100 条评论；三种评分约各占 1/4、1/2、1/4，所以\"永远猜 ok\"约 0.5，三选一瞎猜约 0.33。）")
    print("\n训练结束时，每个模型在**自己的训练目标**上的准确率（新取的 256 段）：")
    for kind in KINDS:
        total, sent, content = own_objective_accuracy(models[kind], kind, corpus)
        what = {"gpt": "预测下一个词", "prefix": "预测前缀之后的下一个词", "bert": "猜出被遮住的词",
                "t5": "写出目标序列"}[kind]
        extra = f"（哨兵 {sent:.2f}，被挖掉的词 {content:.2f}）" if sent is not None else ""
        print(f"  {pad(NAMES[kind], 24)} {pad(what, 24)} {total:.2f}{extra}")
    print("""
观察 Observations：
  · GPT 和 prefix LM 在第 500 步就已经全对：GPT 的每个位置都是一道"预测下一个词"的题
    （代码 03 演示 3：每 1000 个位置 1000 个训练信号），而"评分由两个形容词决定"恰好是从左往右读就能学到的规律。
    prefix LM 的训练信号少一半，在这个任务上也足够快。
  · BERT 先在"按先验猜"的水平停了一段（500 步 0.47，正好等于这 100 条评论里 ok 的比例，也就是"永远猜 ok"的水平），
    1000 步 0.73，1500 步才全对。
    masked LM 每 1000 个位置只有约 140 个训练信号，而且大多数被遮住的词靠附近的语法就能猜对
    （"the ___ was" 后面是食物名），真正需要"看远处内容"的信号更少——所以它要多花几倍的步数。
  · T5（和其他模型用同一种协议：在 <0> 处只比较 good / ok / bad）从第 500 步起一直是 0.47，
    也就是"永远猜 ok"：在三个评分词里它总是选最常见的 ok，没学会"评分由两个形容词决定"。
    它在自己的目标上有进步（哨兵 0.99，被挖掉的词 0.66；BERT 猜被遮住的词是 0.77），
    但"在输入里找到 <0>、再根据 <0> 周围的内容决定写什么"，在这个算力下还没学会。
  · 表里 T5 下面那一行是它的"自由生成"：让解码器一直写到下一个哨兵，整段正好是评分词才算对——始终是 0。
    它最常写出的片段是 "ok ."，把后面本来就在的句号也写了进去（演示 2）。这是"片段边界"的问题，不是"知不知道评分"的问题；
    本讲最初的版本就是只用这一行给 T5 打分，把"还没学会规则"夸大成了"什么都没学会"（见 README）。
  · 这是一个**机制**层面的观察：在同样的（很小的）算力下，训练信号越多、任务越"直接"的目标学得越快。
    它不说明 BERT / T5 最终学不会，也不说明它们在大规模上更差——T5 论文（表 2）在大得多的算力下发现，
    encoder-decoder + 去噪目标在**微调之后**的下游任务上最好。但它解释了 ELECTRA 为什么要"让每个位置都有信号"
    （代码 05），以及 decoder-only 为什么在大规模预训练里如此高效。""")


# =============================================================================
# 演示 2：能做什么、不能做什么
# =============================================================================
@torch.no_grad()
def fill_adjective(model, kind, tok, text, rating):
    """
    "根据右边的上下文填形容词"：把评论里的食物形容词挖掉，让模型填回来。

    【返回】dict：做法 -> 填出的词（字符串）
        GPT / prefix "从左往右写"：只给 "review: the soup was"，让它接着写——看不到右边
        GPT "给候选打分"：把 16 个食物形容词逐个填进去，比较整行的概率——能用上右边，但要算 16 遍
        BERT "填 [M]"：在空位放 [M]，一次前向传播，取最大的那个词
        T5 "填 <0>，第一步取最大"：在空位放哨兵，解码器写到 "[S] <0>" 时取最大的那个词（和 BERT 的做法对应）
        T5 "给 16 个候选打分"：log p(<0> 处是这个词、然后结束)（和 GPT 的打分对应）
        T5 "自由生成"：让解码器一直写到下一个哨兵，整段必须正好是一个形容词
    """
    words = text.split(" ")
    left = tok.encode("\n" + " ".join(words[:4]))                     # \n review : the soup was
    right = tok.encode(" ".join(words[5:]) + f" rating: {rating}.\n")
    out = {}
    if kind in ("gpt", "prefix"):
        logits = next_logits(model, left, "causal" if kind == "gpt" else "prefix")
        out["从左往右写"] = tok.vocab[int(logits[:tok.n_regular].argmax())]
    if kind == "gpt":
        scores = {a: score_ids(model, left + [tok.stoi[a]] + right) for a in FOOD_POS + FOOD_NEG}
        out["给 16 个候选打分"] = max(scores, key=scores.get)
    if kind == "bert":
        ids = left + [tok.mask_id] + right
        logits = model(torch.tensor([ids]), full_mask(len(ids)))[0, len(left)]
        out["填 [M]"] = tok.vocab[int(logits[:tok.n_regular].argmax())]
    if kind == "t5":
        src = left[1:] + [tok.sentinel_ids[0]] + right
        logits = t5_next_logits(model, tok, src)
        out["填 <0>，第一步取最大"] = tok.vocab[int(logits[:tok.n_regular].argmax())]
        scores = {a: t5_span_logprob(model, tok, src, [tok.stoi[a]]) for a in FOOD_POS + FOOD_NEG}
        out["给 16 个候选打分"] = max(scores, key=scores.get)
        out["自由生成（写到下一个哨兵）"] = tok.decode(t5_fill(model, tok, src))
    return out


def demo_abilities(models, tok, world):
    print_header("演示 2：能做什么、不能做什么")
    reviews = make_eval_reviews(200, seed=31)
    rows = []
    t5_first = {}            # T5 在形容词空位上"第一步取最大"写出的词，统计一下都是什么
    for kind in KINDS:
        m = models[kind]
        rating = sum(predict_rating(m, kind, tok, t) == r for t, r, _ in reviews) / len(reviews)
        fills = {}
        for text, r, food_pos in reviews:
            for method, word in fill_adjective(m, kind, tok, text, r).items():
                if kind == "t5" and method.startswith("填 <0>"):
                    t5_first[word] = t5_first.get(word, 0) + 1
                ok, valid = fills.get(method, (0, 0))
                is_valid = word in FOOD_POS + FOOD_NEG
                fills[method] = (ok + (is_valid and (word in FOOD_POS) == food_pos), valid + is_valid)
        if kind == "t5":
            gen = "—"
        else:
            g = torch.Generator().manual_seed(5)
            lines = sample_lines_any(m, tok, 200, {"gpt": "causal", "prefix": "prefix", "bert": "mlm"}[kind],
                                     1.0, g)
            res = [check_line(world, l) for l in lines]
            gen = f"{sum(r[1] for r in res) / len(res):.2f} / {sum(r[2] for r in res) / len(res):.2f}"
            if kind == "bert":
                bert_samples = lines[:3]
        rows.append((kind, rating, fills, gen))
    print("200 条评论。\"填形容词\"：挖掉食物形容词，右边的 \"and/but + 服务 + 评分\" 决定了它应该是正面还是负面；"
          "\n记\"填对\"= 填出一个食物形容词且正负和右边一致（瞎猜约 0.5）。\"生成\"：从零采样 200 行，格式合法率 / 内容为真率。\n")
    print(pad("模型", 24) + pad("预测评分", 10, ">") + pad("  填形容词：做法 → 填对 / 填出形容词", 50)
          + pad("生成：合法 / 为真", 18, ">"))
    for kind, rating, fills, gen in rows:
        methods = list(fills.items())
        first = True
        for method, (ok, valid) in methods:
            cell = f"  {method} → {ok / len(reviews):.2f} / {valid / len(reviews):.2f}"
            if first:
                print(pad(NAMES[kind], 24) + pad(f"{rating:.2f}", 10, ">") + pad(cell, 50) + pad(gen, 18, ">"))
                first = False
            else:
                print(pad("", 34) + pad(cell, 50))
    print("\nBERT 用\"末尾放 [M]、猜出来再放一个 [M]\"的办法从零生成的前 3 行：")
    for line in bert_samples:
        print("  " + line)
    frees = [" ".join(t5_free_rating(models["t5"], tok, t)) for t, _, _ in reviews]
    counts = {}
    for f in frees:
        counts[f] = counts.get(f, 0) + 1
    top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
    exact = sum(f == r for f, (_, r, _) in zip(frees, reviews)) / len(reviews)
    print("\nT5 在评分的空位上\"自由生成\"（从 [S] <0> 开始一直写到下一个哨兵）：最常见的写法 "
          + "；".join(f"\"{f}\" {c} 次" for f, c in top)
          + f"。恰好写出正确的评分词然后结束的比例 {exact:.2f}。")
    top_first = sorted(t5_first.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
    print("T5 在形容词空位上第一步最可能的词：" + "；".join(f"{w} {c} 次" for w, c in top_first) + "（共 200 条）。")
    print("""
观察 Observations：
  · **GPT 从左往右写，用不上右边的信息**：写到 "the soup was" 时，它还没看到后面的 "and the host was kind. rating: good"，
    只能按先验挑一个形容词，正负对的比例约等于瞎猜。prefix LM 也一样——生成的部分是因果的。
  · 但 GPT 并不是"不知道"：把 16 个候选逐个填进去、比较整行的概率，它几乎全对。整行的概率里包含了
    "这个形容词和后面的评分配不配"——右边的信息通过"打分"间接用上了，代价是 16 次前向传播。
    （这也是第 1 讲用 completion_logprob 做多项选择的办法；GPT-3 论文做多项选择题也是这样。）
  · **BERT 一次前向传播就能填对**：[M] 同时看到左边和右边。这是双向编码器的长处——
    分类、抽取、填空这类"输入已经完整给出"的任务，正是 BERT 时代的主战场。
  · **BERT 不会写**：没有"下一个词"的概念，用"末尾放 [M]"硬凑出来的文字几乎全不合法——
    写出来的东西词序混乱、缺词少字（上面的例子）。它的训练目标里根本没有"从左往右接着写"这回事：
    [M] 只是"这里少了一个词"，左边和右边对它来说是对称的。
  · T5 在这个算力下还在"按先验猜"：评分 0.45（这 200 条里 ok 的比例，即"永远猜 ok"），
    填形容词时第一步最可能的词是 the（连词性都不对），给 16 个候选打分也只有 0.46——和瞎猜一样。
  · T5 的自由生成暴露了另一个问题：片段的边界没对上。它最常写出的片段是 "ok ."——把后面本来就在的句号
    也当成了被挖掉的内容（偶尔还接着写 "rating"）；其次是从 "review : the" 开始重新写一条评论。
    span corruption 训练的是"把挖掉的片段写回来"，"片段从哪里开始、到哪里结束"本身也要学。在大规模上，T5 的长处恰恰是两者兼得：编码器双向地读，解码器写出任意长度的片段；
    但要它从零写一整段（"生成"一栏的"—"），需要换一种输入格式或继续训练（例如后来专门做的 "LM-adapted" 版 T5）。""")


# =============================================================================
# 演示 3：线性探针
# =============================================================================
@torch.no_grad()
def review_features(model, kind, tok, texts, readout="mean"):
    """
    每条评论（不含评分）的表示。

    【参数】readout：
        "mean" = 最后一层在评论各位置上的平均（mean pooling）；"last" = 最后一个位置（句号）的表示；
        "adj"  = 食物形容词所在位置的表示（只用于 T5 的编码器）；
        "dec"  = T5 解码器写到 "[S] <0>" 时的状态，输入是 "评论 + rating: <0>."（相当于 GPT 读 "rating:" 处）
    【思路】评论的 token 数都相同（14 个，含开头的换行），所以可以整批算。GPT 用因果掩码（它被训练成这样用），
            prefix LM 把整条评论当前缀（双向），BERT 用双向掩码，T5 用编码器的输出（输入里没有补齐位置，
            每个位置都看得到整条评论）。T5 多给两种读法，确认它的结果不是"读错了地方"造成的。
    【返回】(n, 64) numpy 数组
    """
    ids = [tok.encode("\n" + t) for t in texts]
    x = torch.tensor(ids)
    if kind == "t5" and readout == "dec":
        src = torch.tensor([tok.encode(t + " rating:") + [tok.sentinel_ids[0]] + tok.encode(".\n") for t in texts])
        vis = torch.ones_like(src, dtype=torch.bool)
        enc = model.encode(src, vis)
        tgt = torch.tensor([[tok.sep_id, tok.sentinel_ids[0]]] * len(texts))
        h = model.tok_emb(tgt) + model.dec_pos(torch.arange(2))
        for block in model.dec_blocks:
            h = block(h, enc, vis)
        return model.dec_ln(h)[:, -1].double().numpy()
    if kind == "t5":
        h = model.encode(x[:, 1:], torch.ones_like(x[:, 1:], dtype=torch.bool))
        if readout == "adj":
            return h[:, 5].double().numpy()           # review : the 食物 was 形容词 → 第 5 个（从 0 数）
    else:
        mask = {"gpt": "causal", "prefix": "full", "bert": "full"}[kind]
        h = torch.stack([hidden_at(model, row, mask) for row in ids])
    feats = h.mean(1) if readout == "mean" else h[:, -1]
    return feats.double().numpy()


def demo_probe(models, tok):
    print_header("演示 3：线性探针 —— 冻结模型，用很少的标签读出\"食物好不好\"（测试形容词从没在标注数据里出现过）")
    rng = random.Random(7)
    seen = (FOOD_POS[:4], FOOD_NEG[:4])
    unseen = (FOOD_POS[4:], FOOD_NEG[4:])
    pool, test = [], []
    for _ in range(600):
        text, _, fp, _ = make_review(rng, food_adjs=seen)
        pool.append((text, float(fp)))
    for _ in range(400):
        text, _, fp, _ = make_review(rng, food_adjs=unseen)
        test.append((text, float(fp)))
    yp = np.array([l for _, l in pool])
    yt = np.array([l for _, l in test])
    ks = [8, 32, 128]
    print(f"标注池 {len(pool)} 条（食物形容词只用 {seen[0] + seen[1]}），"
          f"测试 {len(test)} 条（只用 {unseen[0] + unseen[1]}）；每个 k 重复 10 次。\n")
    print(pad("模型", 24) + pad("读哪里", 20) + "".join(pad(f"k = {k}", 10, ">") for k in ks))
    rows = [(k, "mean") for k in KINDS] + [("gpt", "last"), ("t5", "adj"), ("t5", "dec")]
    for kind, readout in rows:
        Xp = review_features(models[kind], kind, tok, [t for t, _ in pool], readout)
        Xt = review_features(models[kind], kind, tok, [t for t, _ in test], readout)
        accs = [few_label_accuracy(Xp, yp, Xt, yt, k, reps=10, seed=k) for k in ks]
        where = {"mean": "各位置平均", "last": "最后一个位置", "adj": "编码器：形容词处", "dec": "解码器：[S] <0> 处"}[readout]
        print(pad(NAMES[kind], 24) + pad(where, 20) + "".join(pad(f"{a:.3f}", 10, ">") for a in accs))
    print("""
观察 Observations：
  · 探针只能利用"表示里已经有的东西"：测试评论里的形容词在标注数据里一次都没出现，
    要判断它们的正负，表示本身就得编码"这个形容词是正面还是负面"——这只能来自预训练。
  · 结果和演示 1 一致：学会了"形容词决定评分"的三个模型，表示里都带着形容词的正负（k = 128 时 0.85–1.00）；
    还没学会的 T5 换了三种读法（编码器各位置平均、编码器在形容词处、解码器在 [S] <0> 处）都在 0.45–0.56 之间——
    不是读错了地方，它的表示里确实还没有形容词的正负。**能不能把知识带到标签之外，取决于预训练时学到了什么。**
  · 表示要"怎么读"也很重要。GPT 的因果表示取平均时，评论前半段的位置还没看到形容词，平均会把信息冲淡（约 0.84）；
    读最后一个位置（它看到了整条评论）就好得多——和第 1 讲代码 03 演示 2 的发现相同。
    双向的模型（prefix LM 把整条评论当前缀、BERT）每个位置都看到了整条评论，取平均就很自然；
    在这里 prefix LM 的表示最好读（k = 32 时已接近 1.0）。
  · BERT 论文的消融（表 5）把"双向"换成"从左到右"（LTR）后，下游任务全面下降（例如 MRPC 86.7 → 77.5，
    SQuAD 88.5 → 77.8）：在"输入已经完整给出、要对它做判断"的任务上，双向表示有天然的优势。""")


if __name__ == "__main__":
    world = build_world()
    tok = WordTokenizer(world.train_lines)
    review_lines = [l for l in world.train_lines if world.line_genres[l] == "review"]
    corpus = Corpus(review_lines, tok)
    print(f"训练数据：迷你互联网训练集里的 {len(review_lines):,} 条评论，共 {len(corpus.ids_list):,} 个词级 token；"
          f"词表 {tok.vocab_size} 个 token。")
    models, infos = {}, {}
    for kind in KINDS:
        models[kind], infos[kind] = train_or_load(kind, tok, corpus)
    demo_curves(models, infos, corpus)
    demo_abilities(models, tok, world)
    demo_probe(models, tok)
