"""
=============================================================================
第 6 讲 · 代码 2：探针、微调，以及微调到底改了什么
=============================================================================

对应讲义：lectures/lecture06_adaptation.md 第 2.3、3.3 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
代码 01 的主表里，几乎所有方法的分布外成绩都和同分布一样好。这个文件看两种**不那么顺利**的情况：

    演示 1：微调会"扭曲"好的特征吗？（Kumar et al. 2022，LP-FT）
            任务：评论里的食物好不好（二分类，第 1 讲代码 03 演示 2 的任务）。分布外测试用的食物形容词
            在标注数据里从没出现过。比较三种做法：
                LP    ：线性探针——冻结模型，只训练一个线性分类头
                FT    ：全量微调——分类头随机初始化，和整个模型一起训练
                LP-FT ：先做线性探针，用它初始化分类头，再全量微调
            同时量一下"特征变了多少"：微调前后，同一条评论在读出位置的表示差了多远。
    演示 2：少样本微调是在"暴露知识"还是在"背答案"？
            任务：第 1 讲 §4.2 得 0.00 的人物问答新格式 "question: where does X live? answer:"。
            15 个人的标签，测试另外的人——答案只能来自预训练时学到的知识。
            看训练过程中：训练集准确率、没见过的人（同类 / 只在叙述句里出现过的人）、没训练过的问题（问职业）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_probe_vs_finetune.py          # 约 70 秒

需要 PyTorch、numpy。依赖本目录的 adapt_lab.py，以及 code/lecture01 的 fm_world.py、tiny_gpt.py、console.py。

【PyTorch 小知识】
    - nn.Linear(64, 2)：一个 64 维 → 2 维的线性层（权重 2×64 + 偏置 2），这里当分类头用。
    - model(x, return_hidden=True)：TinyGPT 同时返回 logits 和最后一层表示（第 1 讲 tiny_gpt.py）。
"""

import copy
import os
import random
import sys
import time

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))
from adapt_lab import (FMT_CITY, FMT_JOB, NL, TOK, choice_accuracy, fit_softmax_probe, greedy_answers,  # noqa: E402
                       items_for, last_hidden, load_base, make_adapted, split_people, train_on_pairs,
                       train_pairs_for)
from console import pad, print_header  # noqa: E402
from fm_world import CITIES, FOOD_NEG, FOOD_POS, JOBS, make_review, qa_prompt  # noqa: E402


# =============================================================================
# 演示 1：LP、FT、LP-FT
# =============================================================================
def polarity_items(n, rng, adjs):
    """
    食物好不好的样本：[(提示, 0/1)]。

    【思路】提示接到 " rating: " 为止——第 1 讲代码 03 发现读这里的表示最好（模型正要说出评分）。
            标签是"食物好不好"，不是评分本身：评分还受服务影响，所以这个任务不能简单地照抄评分。
    【参数】n：条数；rng：random.Random；adjs：(正面形容词列表, 负面形容词列表)
    【返回】list[(str, int)]
    """
    out = []
    for _ in range(n):
        text, _, food_pos, _ = make_review(rng, food_adjs=adjs)
        out.append((f"\n{text} rating: ", int(food_pos)))
    return out


def encode_prompts(items):
    """
    把提示右侧补齐成一个 batch。

    【参数】items：list[(提示, 标签)]
    【返回】(x, last, y)：x 是 (n, T) 的字符编号；last 是每条提示最后一个字符的位置（读表示的地方）；y 是标签
    【边界】补齐用 "\n"；因果注意力保证 last 位置的表示不受后面补齐字符的影响。
    """
    seqs = [TOK.encode(p) for p, _ in items]
    T = max(len(s) for s in seqs)
    x = torch.full((len(seqs), T), NL, dtype=torch.long)
    for i, s in enumerate(seqs):
        x[i, :len(s)] = torch.tensor(s)
    last = torch.tensor([len(s) - 1 for s in seqs])
    y = torch.tensor([lab for _, lab in items])
    return x, last, y


def features(model, x, last):
    """
    读出位置（每条提示的最后一个字符）的最后一层表示，形状 (n, 64)。

    【边界】不在 no_grad 里——微调时要让梯度流过它；只评测时请在外面套 torch.no_grad()。
    【PyTorch 小知识】h[torch.arange(n), last]：对每一行 i 取第 last[i] 个位置（"高级索引"）。
    """
    _, h = model(x, return_hidden=True)
    return h[torch.arange(len(last)), last]


def fit_linear_head(Fz, y, l2=1e-2):
    """
    线性探针：在冻结的特征上用 L-BFGS 解二分类的 softmax 回归。

    【参数】Fz：(k, 64) 的特征；y：(k,) 的 0/1 标签；l2：正则强度
    【返回】(W, b)：W 是 (2, 64)，b 是 (2,)；LP-FT 直接用它们初始化分类头
    【边界】k 很小、数据线性可分时，没有 L2 正则的解会跑到无穷大；有了正则就有唯一解。
    """
    W = torch.zeros(2, Fz.shape[1], dtype=torch.float64, requires_grad=True)
    b = torch.zeros(2, dtype=torch.float64, requires_grad=True)
    opt = torch.optim.LBFGS([W, b], max_iter=200, line_search_fn="strong_wolfe")
    Fd = Fz.double()

    def closure():
        opt.zero_grad()
        loss = F.cross_entropy(Fd @ W.t() + b, y) + l2 * (W ** 2).sum()
        loss.backward()
        return loss

    opt.step(closure)
    return W.detach().float(), b.detach().float()


def finetune_classifier(base, x, last, y, head_init, lr, steps, seed):
    """
    全量微调：模型 + 一个 64 → 2 的分类头一起训练（交叉熵，batch 32，AdamW）。

    【参数】head_init：None 表示随机初始化分类头（普通 FT）；(W, b) 表示用线性探针的结果初始化（LP-FT）
    【返回】(model, head)
    【FM 联系】Kumar et al. 的分析：分类头是随机的时候，最初几步的梯度很大且方向"乱"，
            底层特征会为了迁就这个乱头而被改动——只在训练数据覆盖到的方向上被改动；
            分布外的输入落在没被一起调整的方向上，于是和新的分类头"对不上"。先探针、再微调，
            一开始的头就已经是好的，特征只需要小改。
    """
    gen = torch.Generator().manual_seed(seed)
    model = copy.deepcopy(base)
    for p in model.parameters():
        p.requires_grad_(True)
    head = nn.Linear(64, 2)
    with torch.no_grad():
        if head_init is None:
            head.weight.copy_(torch.randn(2, 64, generator=gen) * 0.02)
            head.bias.zero_()
        else:
            head.weight.copy_(head_init[0])
            head.bias.copy_(head_init[1])
    opt = torch.optim.AdamW(list(model.parameters()) + list(head.parameters()), lr=lr, weight_decay=0.0)
    model.train()
    for _ in range(steps):
        bi = torch.randint(0, len(y), (min(len(y), 32),), generator=gen)
        loss = F.cross_entropy(head(features(model, x[bi], last[bi])), y[bi])
        opt.zero_grad()
        loss.backward()
        opt.step()
    model.eval()
    return model, head


def demo_lpft(base):
    print_header("演示 1：线性探针（LP）、全量微调（FT）、先探针再微调（LP-FT）—— 同分布 vs 分布外")
    rng = random.Random(7)
    seen, unseen = (FOOD_POS[:4], FOOD_NEG[:4]), (FOOD_POS[4:], FOOD_NEG[4:])
    pool = polarity_items(600, rng, seen)
    test = {"同分布": polarity_items(400, rng, seen), "分布外": polarity_items(400, rng, unseen)}
    enc = {name: encode_prompts(items) for name, items in test.items()}
    with torch.no_grad():
        f0 = {name: features(base, x, last) for name, (x, last, _) in enc.items()}

    def evaluate(model, head):
        """返回 {测试集: 准确率} 和 {测试集: 特征相对变化 ‖f − f0‖ / ‖f0‖ 的平均}。"""
        acc, dist = {}, {}
        with torch.no_grad():
            for name, (x, last, y) in enc.items():
                f = features(model, x, last)
                acc[name] = float(((f @ head[0].t() + head[1]).argmax(1) == y).float().mean())
                dist[name] = float(((f - f0[name]).norm(dim=1) / f0[name].norm(dim=1)).mean())
        return acc, dist

    configs = [(16, 1e-4), (64, 1e-4), (256, 1e-4), (256, 1e-3)]
    print("每种设定 3 个种子（抽到哪 k 条评论），微调 150 步、batch 32。")
    print(pad("k", 5) + pad("微调 lr", 9) + "".join(pad(f"{m} {t}", 15, ">") for m in ["LP", "FT", "LP-FT"]
                                                  for t in ["同分布", "分布外"])
          + pad("特征变化 FT", 14, ">") + pad("特征变化 LP-FT", 16, ">"))
    for k, lr in configs:
        res = {m: [] for m in ["LP", "FT", "LP-FT"]}
        dists = {m: [] for m in ["FT", "LP-FT"]}
        for seed in range(3):
            idx = np.random.default_rng(seed).permutation(len(pool))[:k]
            x, last, y = encode_prompts([pool[i] for i in idx])
            with torch.no_grad():
                W, b = fit_linear_head(features(base, x, last), y)
            res["LP"].append(evaluate(base, (W, b))[0])
            for name, init in [("FT", None), ("LP-FT", (W, b))]:
                model, head = finetune_classifier(base, x, last, y, init, lr, 150, seed)
                acc, dist = evaluate(model, (head.weight.detach(), head.bias.detach()))
                res[name].append(acc)
                dists[name].append(dist)
        row = pad(k, 5) + pad(f"{lr:g}", 9)
        for m in ["LP", "FT", "LP-FT"]:
            for t in ["同分布", "分布外"]:
                row += pad(f"{np.mean([a[t] for a in res[m]]):.3f}", 15, ">")
        for m, w in [("FT", 14), ("LP-FT", 16)]:
            row += pad(f"{np.mean([d['同分布'] for d in dists[m]]):.2f} / {np.mean([d['分布外'] for d in dists[m]]):.2f}", w, ">")
        print(row)
    print("""
（"特征变化"：微调前后，同一条评论在 "rating: " 处的表示变了多少，‖f − f0‖ / ‖f0‖，写成"同分布 / 分布外"。）

观察 Observations：
  · Kumar et al. 的现象在迷你世界里出现了：标签够多（k = 64、256）时，全量微调在同分布上和探针一样好或更好，
    在分布外（没见过的形容词）上却明显更差。先探针再微调（LP-FT）两边都最好。
  · 特征变化一栏给出了原因的一部分：随机初始化的分类头让 FT 把表示改得面目全非（相对变化 > 1），
    LP-FT 只改了它的三分之一到四成。学习率调大（最后一行），两种微调都改得更多，FT 的分布外成绩掉得更厉害。
  · k = 16 时探针本身就已经很好，FT 在两边都输给它——标签很少时，"只训练一个头"本身就是一种保护。
  · 这里的"分布偏移"是温和的（同一种评论、换一批形容词）；论文在 10 个真实的分布偏移数据集上，
    平均 FT 同分布高 2%、分布外低 7%；LP-FT 比 FT 同分布高 1%、分布外高 10%。""")


# =============================================================================
# 演示 2：暴露知识，还是背答案？
# =============================================================================
def demo_expose_or_memorize(world, base):
    print_header("演示 2：少样本微调是在暴露知识，还是在背答案？—— 人物问答的新格式，k = 15，3 种划分")
    STEPS = 300
    configs = [("full", 3e-4), ("full", 1e-3), ("bitfit", 3e-3), ("bitfit", 1e-2), ("lora1", 3e-3), ("lora1", 1e-2)]
    names = {"full": "全量微调", "bitfit": "BitFit", "lora1": "LoRA r = 1"}
    # 参照线：base model 用它熟悉的格式能取出多少知识
    refs = {"同类新人": [], "叙述句里的人": [], "同类·问职业": []}
    base_new = {"同类新人": [], "叙述句里的人": [], "同类·问职业": []}
    probe = {"同类新人": [], "叙述句里的人": []}
    curves = {c: [] for c in configs}
    finals = {c: [] for c in configs}
    keep = {}
    for seed in range(3):
        pool, id_test, ood = split_people(world, seed)
        evals = {
            "训练集": (items_for(pool, "city", FMT_CITY), CITIES),
            "同类新人": (items_for(id_test, "city", FMT_CITY), CITIES),
            "叙述句里的人": (items_for(ood, "city", FMT_CITY), CITIES),
            "同类·问职业": (items_for(id_test, "job", FMT_JOB), JOBS),
        }
        refs["同类新人"].append(choice_accuracy(base, [("\n" + qa_prompt(p.name, "city"), p.city) for p in id_test], CITIES))
        refs["叙述句里的人"].append(choice_accuracy(base, [(f"\n{p.name} lives in", p.city) for p in ood], CITIES))
        refs["同类·问职业"].append(choice_accuracy(base, [("\n" + qa_prompt(p.name, "job"), p.job) for p in id_test], JOBS))
        for key in base_new:
            base_new[key].append(choice_accuracy(base, *evals[key]))
        # 线性探针：冻结模型，在新格式提示最后一个位置的表示上做 8 选 1
        X = last_hidden(base, [p for p, _ in evals["训练集"][0]])
        y = torch.tensor([CITIES.index(p.city) for p in pool])
        predict = fit_softmax_probe(X, y, len(CITIES))
        for key in probe:
            items = evals[key][0]
            pred = predict(last_hidden(base, [p for p, _ in items]))
            probe[key].append(float(np.mean([CITIES[j] == a for j, (_, a) in zip(pred.tolist(), items)])))
        for method, lr in configs:
            ad = make_adapted(base, method, seed=seed)
            curve = []

            def track(step, a, _curve=curve, _evals=evals):
                _curve.append([choice_accuracy(a.fwd, *_evals[key]) for key in _evals])

            train_on_pairs(ad, train_pairs_for(pool), STEPS, lr, seed=seed, callback=track, every=50)
            curves[(method, lr)].append(curve)
            finals[(method, lr)].append(curve[-1])
            if seed == 0 and lr in (1e-3, 1e-2):
                keep[method] = (ad, id_test)
    keys = ["训练集", "同类新人", "叙述句里的人", "同类·问职业"]
    print("""三组测试（都是训练里没有的人或问题）：
  同类新人     ：有问答文本、但不在这 15 个训练例子里的 15 人（"同分布"）
  叙述句里的人 ：只在叙述句里出现过的 30 人（"人群偏移"；第 1 讲 §4.2 的那 30 人）
  同类·问职业 ：同类新人，但问的是职业（"任务偏移"：训练只教了问城市）
每格是 3 种划分的平均；受限选择，8 选 1，瞎猜 0.125。
""")
    print(pad("", 40) + "".join(pad(key, 14, ">") for key in keys))
    print(pad("参照：base model 用熟悉的格式问", 40) + pad("—", 14, ">")
          + "".join(pad(f"{np.mean(refs[key]):.3f}", 14, ">") for key in keys[1:]))
    print(pad("不训练：base model，新格式", 40) + pad("—", 14, ">")
          + "".join(pad(f"{np.mean(base_new[key]):.3f}", 14, ">") for key in keys[1:]))
    print(pad("线性探针（冻结，读新格式提示末尾）", 40) + pad("—", 14, ">")
          + "".join(pad(f"{np.mean(probe[key]):.3f}", 14, ">") for key in keys[1:3]) + pad("不适用", 14, ">"))
    for method, lr in configs:
        f = np.mean(finals[(method, lr)], axis=0)
        print(pad(f"{names[method]}（lr {lr:g}，{STEPS} 步后）", 40) + "".join(pad(f"{v:.3f}", 14, ">") for v in f))
    print("\n训练过程（3 种划分的平均）：训练集 / 同类新人 / 叙述句里的人 / 同类·问职业")
    steps_shown = [0, 50, 100, 200, 300]
    print(pad("", 26) + "".join(pad(f"第 {s} 步", 26, ">") for s in steps_shown))
    for method, lr in configs:
        c = np.mean(curves[(method, lr)], axis=0)
        cells = []
        for s in steps_shown:
            v = c[s // 50]
            cells.append(pad(" / ".join(f"{x:.2f}" for x in v), 26, ">"))
        print(pad(f"{names[method]} lr {lr:g}", 26) + "".join(cells))
    print("\n自由生成：问职业（训练只教过问城市），种子 0 的前 5 个同类新人：")
    for method, (ad, id_test) in keep.items():
        prompts = [FMT_JOB.format(name=p.name) for p in id_test[:5]]
        outs = greedy_answers(ad.fwd, prompts)
        print(f"  {pad(names[method], 12)}" + "  ".join(f"{p.name}({p.job})→{o.strip()!r}" for p, o in zip(id_test[:5], outs)))
    print("""
观察 Observations：
  · 所有方法几十步之内就把 15 个训练例子全部答对了（训练集 1.00）。区别在没见过的人身上：
    全量微调在两种学习率下都停在 0.2–0.3，和不训练差不多甚至更差；BitFit 和 LoRA r = 1 明显更高。
    全量微调有 10 万个参数可以用来**背下这 15 个名字对应的城市**；BitFit 只有 1,472 个偏置，背不下，
    只能学一个对所有人都适用的变换——也就是"把新格式接到模型原有的知识上"。
  · 但没有一种方法接近参照线（base model 用熟悉的格式问，同类新人 0.98）：在这个 2 层的小模型里，
    "新格式 → 已有的知识"这条路很难从 15 个例子里学出来。只在叙述句里出现过的人更难（参照 0.93，适配后最多一半）——
    知识在模型里，但适配**不能**取出模型本身取不出的东西（第 5 讲 §6.2：知识的存储与提取）。
  · 问职业（从没训练过的问题）是最严格的检验，三种方法都没通过（0.07–0.29，瞎猜 0.125）。自由生成一栏说明了原因：
    问职业，它们几乎都答城市名——学会的是"answer: 后面写一个城市"，而不是"answer: 后面写问题问的那个属性"。
    训练数据里只有一种问题，模型就只学一种问题；想要"跟着问题走"，训练数据里就得有多种问题（第 7 讲的指令微调）。
  · 冻结模型的线性探针接近瞎猜：新格式提示末尾的表示里没有"这个人住在哪"的信息（模型在那个位置不知道该回忆城市），
    而且 15 个标签连 8 个城市都覆盖不全。BitFit 的假设（Zaken et al.）"微调主要是在暴露预训练学到的知识"，
    在这里的意思是：**限制可训练的参数，逼它去暴露，而不是去背**。""")


if __name__ == "__main__":
    T0 = time.time()
    world, base = load_base()
    demo_lpft(base)
    demo_expose_or_memorize(world, base)
    print(f"\n（总用时约 {time.time() - T0:.0f} 秒；计时每次运行略有不同）")
