"""
=============================================================================
第 6 讲 · 代码 1：同一个任务、同一个打分函数，把适配方法排成一排
=============================================================================

对应讲义：lectures/lecture06_adaptation.md 第 2.1、4.9、7 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
任务：同一条评论，换一个 base model 从没见过的格式，答案也换成语料里从没出现过的三个词：

        语料里的样子：  review: the soup was tasty but the host was rude. rating: ok.
        新任务：        opinion: the soup was tasty but the host was rude. verdict: mixed.
                        （good → loved，ok → mixed，bad → hated）

评分规则（两个形容词决定好 / 一般 / 差）base model 早就会了（第 1 讲：用原格式问是 1.00）。
适配只需要教它"新格式 + 新标签词"。这正是 W22 课程笔记说的第一种差异：格式（formatting）。

    演示 1：起点。不做任何训练，base model 在新格式上得几分？用两种打分协议（受限选择 vs 自由生成）
            各看一遍，并检查"在提示里放一个示例（few-shot）"在 96 个字符的上下文里放不放得下。
    演示 2：选学习率。每种方法在 3 个学习率里选一个——只看开发集（dev），不看测试集。
    演示 3：主表。9 种方法 × 标签数 k = 4 / 16 / 64，每格 2 个种子：
            同分布测试（形容词在训练例子里出现过的那一半）、分布外测试（食物形容词全是另一半，标注数据里从没出现），
            可训练参数个数、适配后的模型在第 1 讲验证集上的 bit/字符（"还剩多少原来的本事"）。
    演示 4：打开"不及格"的方法看它到底写了什么（受限选择 vs 自由生成）。
    演示 5：成本账：每个任务要存多少参数、训练时要多少内存、推理时多不多花计算。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_adaptation_spectrum.py          # 约 85 秒（单线程 CPU；一共训练 72 个小模型）

需要 PyTorch、numpy。依赖本目录的 adapt_lab.py，以及 code/lecture01 的 fm_world.py、tiny_gpt.py、console.py
（第 1 讲的 base model 只读加载）。

【Python 小知识】
    - collections.Counter(列表)：数每个元素出现了几次；.most_common(n) 给出最常见的 n 个。
    - dict.items() 按插入顺序遍历（Python 3.7 起保证），所以表格的行序和 METHODS 的写法一致。
"""

import os
import random
import sys
import time
from collections import Counter

import numpy as np
import torch

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 (TOK, VERDICT_WORDS, bpc_with, choice_accuracy, fit_softmax_probe, greedy_answers,  # noqa: E402
                       last_hidden, load_base, make_adapted, train_on_pairs, verdict_items)
from console import pad, print_header  # noqa: E402
from fm_world import FOOD_NEG, FOOD_POS, make_review  # noqa: E402

STEPS = 150            # 每种方法同样的训练步数（同样的"训练预算"）
KS = [4, 16, 64]       # 标签数
SEEDS = [0, 1]         # 每格 2 个种子：抽到哪 k 个样本、插件怎样初始化
# 每种方法的学习率候选：全量微调通常用比预训练（3e-3）小的学习率；只训练很少参数的方法通常用大得多的学习率
METHODS = {
    "full": [3e-4, 1e-3, 3e-3],
    "top": [3e-4, 1e-3, 3e-3],
    "bitfit": [1e-3, 3e-3, 1e-2],
    "adapter4": [1e-3, 3e-3, 1e-2],
    "lora1": [1e-3, 3e-3, 1e-2],
    "lora8": [1e-3, 3e-3, 1e-2],
    "prefix8": [1e-2, 3e-2, 1e-1],
    "prompt8": [1e-2, 3e-2, 1e-1],
}
LABELS = {
    "probe": "线性探针（冻结，读 verdict: 处）",
    "full": "全量微调 full FT",
    "top": "只调最后一个块 top block",
    "bitfit": "BitFit（只调偏置）",
    "adapter4": "adapter（瓶颈 4）",
    "lora1": "LoRA r = 1",
    "lora8": "LoRA r = 8",
    "prefix8": "prefix tuning（8 个）",
    "prompt8": "prompt tuning（8 个）",
}


def make_data():
    """
    造数据：标注池（候选训练样本）、开发集、同分布测试集、分布外测试集。

    【思路】8 个正面、8 个负面食物形容词各分成两半。标注池、开发集、同分布测试只用前一半；
            分布外测试只用后一半——测试评论里的食物形容词在任何标注数据里都没出现过（第 1 讲代码 03 的设定 B）。
            要做对分布外测试，只能靠预训练时学到的"这个形容词是好是坏"。
    【返回】dict：pool / dev / test_id / test_ood，每个都是 list[(提示, 标签词)]
    """
    rng = random.Random(11)
    seen = (FOOD_POS[:4], FOOD_NEG[:4])
    unseen = (FOOD_POS[4:], FOOD_NEG[4:])
    return dict(pool=verdict_items(400, rng, seen), dev=verdict_items(200, rng, seen),
                test_id=verdict_items(300, rng, seen), test_ood=verdict_items(300, rng, unseen))


def to_pairs(items):
    """
    [(提示, 标签词)] → 训练用的 [(提示, " 标签词.")]。

    【思路】答案前面一个空格、后面一个句号，和语料里 "rating: ok." 的写法一致。
    """
    return [(p, f" {a}.") for p, a in items]


def sample_k(pool, k, seed):
    """
    从标注池里无放回地抽 k 个样本。

    【思路】每个 (k, seed) 一个独立的 numpy 生成器：同一个 (k, seed) 对所有方法抽到**同样**的样本（比较才公平），
            改动别处的随机调用也不会影响这里。代码 03、04 用同样的公式抽样，所以数字可以对照。
    【参数】pool：标注池；k：要几个；seed：种子
    【返回】list，长度 k
    【边界】k 不能超过 len(pool)（400）。
    【numpy 小知识】rng.permutation(n) 返回 0..n−1 的一个随机排列，取前 k 个就是无放回抽样。
    """
    rng = np.random.default_rng(1000 + 10 * k + seed)
    return [pool[i] for i in rng.permutation(len(pool))[:k]]


# =============================================================================
# 演示 1：起点
# =============================================================================
def demo_start(base, data):
    print_header("演示 1：起点 —— base model 不做任何训练，在新格式上能得几分？")
    test = data["test_id"]
    counts = Counter(a for _, a in test)
    majority = counts.most_common(1)[0][1] / len(test)
    acc_choice = choice_accuracy(base, test, VERDICT_WORDS)
    outs = greedy_answers(base, [p for p, _ in test])
    acc_free = float(np.mean([o == f" {a}." for o, (_, a) in zip(outs, test)]))
    # 参照：用语料里的原格式和原标签词问同样的评论（这就是"知识在不在模型里"的上限参照）
    rng = random.Random(11)
    orig = []
    for _ in range(300):
        text, rating, _, _ = make_review(rng)
        orig.append((f"\n{text} rating:", rating))
    acc_orig = choice_accuracy(base, orig, ["good", "ok", "bad"])
    print(f"测试集 {len(test)} 条；三个标签的比例：" + "，".join(f"{w} {counts[w] / len(test):.2f}" for w in VERDICT_WORDS))
    rows = [("瞎猜（3 选 1）", f"{1 / 3:.3f}"), ("多数类（永远答 mixed）", f"{majority:.3f}"),
            ("base model，受限选择（3 个标签词里挑概率最大的）", f"{acc_choice:.3f}"),
            ("base model，自由生成 + 完全匹配", f"{acc_free:.3f}"),
            ("参照：原格式 rating: + 原标签词 good/ok/bad，受限选择", f"{acc_orig:.3f}")]
    for name, v in rows:
        print("  " + pad(name, 56) + pad(v, 8, ">"))
    top = Counter(outs).most_common(4)
    print("\n自由生成时 base model 最常写出的续写：" + "，".join(f"{o!r} × {n}" for o, n in top))
    one_demo = test[0][0] + f" {test[0][1]}." + test[1][0]
    print(f"\n上下文长度：一条新格式的评论（含答案）约 {len(test[0][0]) + 7} 个字符；"
          f"放一个示例再接上问题要 {len(one_demo)} 个字符，超过了 base model 的上下文长度 96。")
    print("""
观察 Observations：
  · 知识在模型里：用它熟悉的原格式问，评分全对。但新格式 + 新标签词下，受限选择比瞎猜还低，
    自由生成时它根本不写这三个标签词，而是写出城市名（brena）和像是原来评分词的碎片（ook、goo）——
    和第 1 讲 §4.2 "question: … answer:" 得 0.00 是同一回事。
  · 两种协议的数字不同，但结论相同：不训练就不行。本讲后面所有方法都用**同一个**打分函数（受限选择），
    只在演示 4 里拿自由生成做对照。
  · few-shot 在这里不是选项：96 个字符连一个示例都放不下。GPT-3 的 2048 个 token 也有同样的上限，
    只是大得多（W22 笔记：上下文长度限制了 in-context learning 能用的示例数）。""")


# =============================================================================
# 演示 2：选学习率（只看开发集）
# =============================================================================
def demo_pick_lr(base, world, data):
    print_header("演示 2：选学习率 —— k = 16、种子 0，在开发集（200 条，不是测试集）上比较 3 个学习率")
    train = to_pairs(sample_k(data["pool"], 16, 0))
    best = {}
    print(pad("方法", 30) + "".join(pad(f"候选 {i + 1}", 18, ">") for i in range(3)) + pad("选中", 10, ">"))
    for method, lrs in METHODS.items():
        accs = []
        for lr in lrs:
            ad = make_adapted(base, method, seed=0, world=world)
            train_on_pairs(ad, train, STEPS, lr, seed=0)
            accs.append(choice_accuracy(ad.fwd, data["dev"], VERDICT_WORDS))
        best[method] = lrs[int(np.argmax(accs))]       # 并列时取较小的学习率（argmax 取第一个）
        print(pad(LABELS[method], 30) + "".join(pad(f"lr {lr:g}: {a:.3f}", 18, ">") for lr, a in zip(lrs, accs))
              + pad(f"{best[method]:g}", 10, ">"))
    print("""
观察 Observations：
  · 只训练很少参数的方法（BitFit、LoRA、adapter、prefix / prompt）选中的学习率比全量微调大几倍到几百倍——
    这和论文里的经验一致（LoRA、prompt tuning 的学习率都远大于全量微调）。
  · 学习率只用开发集选，测试集在主表之前一次都没看过；选不好学习率是"某方法不行"最常见的假象之一。""")
    return best


# =============================================================================
# 演示 3：主表
# =============================================================================
def probe_accuracy(base, train_items, test_items):
    """
    线性探针：冻结 base model，读提示最后一个位置（"verdict:" 的冒号处）的表示，训练一个 3 类的 softmax 回归。

    【思路】这是"探针"这一行唯一要训练的东西：64 × 3 的权重 + 3 个偏置 = 195 个参数。
            它和其他方法用同一个测试集，但打分方式天然不同（它不"说话"，直接输出类别）——
            这是唯一的例外，所以它的数字要和"受限选择"放在一起看时心里有数。
    【参数】base：TinyGPT；train_items、test_items：list[(提示, 标签词)]
    【返回】测试准确率（float）
    【边界】k = 4 时训练集里可能缺某个类别，探针几乎不会预测它（fit_softmax_probe 的边界）。
    """
    X = last_hidden(base, [p for p, _ in train_items])
    y = torch.tensor([VERDICT_WORDS.index(a) for _, a in train_items])
    predict = fit_softmax_probe(X, y, 3)
    pred = predict(last_hidden(base, [p for p, _ in test_items]))
    gold = torch.tensor([VERDICT_WORDS.index(a) for _, a in test_items])
    return float((pred == gold).float().mean())


def demo_main_table(base, world, data, best, val):
    print_header(f"演示 3：主表 —— 同一个任务、同一个打分函数、同样 {STEPS} 步；每格是 {len(SEEDS)} 个种子的平均")
    results = {}
    seed_rows = {}
    ads_k64 = {}
    t0 = time.time()
    for method in ["probe"] + list(METHODS):
        for k in KS:
            rows = []
            for seed in SEEDS:
                train = sample_k(data["pool"], k, seed)
                if method == "probe":
                    rows.append((probe_accuracy(base, train, data["test_id"]),
                                 probe_accuracy(base, train, data["test_ood"]), float("nan")))
                    continue
                ad = make_adapted(base, method, seed=seed, world=world)
                train_on_pairs(ad, to_pairs(train), STEPS, best[method], seed=seed)
                bpc = bpc_with(ad.fwd, val) if k == KS[-1] else float("nan")
                rows.append((choice_accuracy(ad.fwd, data["test_id"], VERDICT_WORDS),
                             choice_accuracy(ad.fwd, data["test_ood"], VERDICT_WORDS), bpc))
                if k == KS[-1] and seed == SEEDS[0]:
                    ads_k64[method] = ad
            results[(method, k)] = np.mean(rows, axis=0)
            seed_rows[(method, k)] = rows
    n_base = sum(p.numel() for p in base.parameters())
    n_train = {m: make_adapted(base, m, seed=0, world=world).n_trainable for m in METHODS}
    n_train["probe"] = 3 * 64 + 3
    print(f"（这一步训练了 {len(METHODS) * len(KS) * len(SEEDS)} 个模型，用时 {time.time() - t0:.0f} 秒；计时每次运行略有不同）\n")
    head = pad("方法", 34) + pad("可训练参数", 12, ">") + pad("占比", 8, ">") \
        + "".join(pad(f"同分布 k={k}", 13, ">") for k in KS) + "".join(pad(f"分布外 k={k}", 13, ">") for k in KS) \
        + pad("验证 bpc", 10, ">")
    print(head)
    base_id = choice_accuracy(base, data["test_id"], VERDICT_WORDS)
    base_ood = choice_accuracy(base, data["test_ood"], VERDICT_WORDS)
    base_bpc = bpc_with(base, val)
    print(pad("不训练（base model）", 34) + pad(0, 12, ">") + pad("0", 8, ">")
          + "".join(pad(f"{base_id:.3f}", 13, ">") for _ in KS) + "".join(pad(f"{base_ood:.3f}", 13, ">") for _ in KS)
          + pad(f"{base_bpc:.3f}", 10, ">"))
    for method in ["probe"] + list(METHODS):
        n = n_train[method]
        bpc = results[(method, KS[-1])][2]
        print(pad(LABELS[method], 34) + pad(f"{n:,}", 12, ">") + pad(f"{n / n_base:.2%}", 8, ">")
              + "".join(pad(f"{results[(method, k)][0]:.3f}", 13, ">") for k in KS)
              + "".join(pad(f"{results[(method, k)][1]:.3f}", 13, ">") for k in KS)
              + pad("—" if np.isnan(bpc) else f"{bpc:.3f}", 10, ">"))
    print(f"\n（base model 共 {n_base:,} 个参数。\"验证 bpc\"：k = {KS[-1]} 适配后、适配器开着，在第 1 讲验证集上的 bit/字符，"
          f"base model 是 {base_bpc:.3f}；探针不改模型，所以是 —。prompt / prefix 的软提示也被接在了每个窗口前面。）")
    gaps = sorted(((abs(seed_rows[key][0][0] - seed_rows[key][1][0]), key) for key in seed_rows), reverse=True)
    (g1, (m1, k1)), (g2, (m2, k2)) = gaps[0], gaps[1]
    print(f"（噪声：同一格两个种子的同分布成绩平均相差 {np.mean([g for g, _ in gaps]):.3f}；差得最多的是 "
          f"{LABELS[m1]} k = {k1}（{seed_rows[(m1, k1)][0][0]:.3f} vs {seed_rows[(m1, k1)][1][0]:.3f}）和 "
          f"{LABELS[m2]} k = {k2}（{seed_rows[(m2, k2)][0][0]:.3f} vs {seed_rows[(m2, k2)][1][0]:.3f}）。）")
    print("""
观察 Observations：
  · 没有一种方法在所有列上都赢。k = 4 时最好的是"只调最后一个块"（0.92），其次 LoRA r = 8；全量微调到 k = 16 才追上来；
    k = 64 时全量微调、最后一个块、LoRA r = 8 都是满分，LoRA r = 1 接近满分，BitFit 停在八成，探针八成多。
    这个任务的难点在**输出端**：要让模型说出三个它从没说过的词。能直接改输出附近权重的方法学得最快。
  · 分布外（形容词在标注数据里从没出现）和同分布的数字几乎一样：这些方法都在**复用**预训练学到的
    "形容词好坏"，而不是从 k 个例子里重新学——这正是"预训练 + 适配"的价值。
  · prompt tuning 在这个 11 万参数的模型上基本没学会；prefix tuning 在 150 步内只学到一半多（多训练几步会好得多，见练习）。
    Lester et al. 2021 的结论正是：prompt tuning 只有在模型足够大时才追得上全量微调；我们的模型比他们最小的 T5 还小几百倍。
  · adapter 在 k = 64 时反而比 k = 16 低，是种子在作怪（见上面的噪声说明）：每格只有 2 个种子，差 0.1 以内的比较不要当真。
  · 最后一列提醒：适配后的模型如果被直接拿去做原来的事，全都变差了（bpc 从 0.41 往上涨）——灾难性遗忘，代码 04 细看。
    参数少 ≠ 改动小：BitFit 只动 1,472 个偏置，却把验证 bpc 推到 3 以上，因为偏置作用在每一个位置上。""")
    return results, ads_k64


# =============================================================================
# 演示 4：打开"不及格"的方法看看
# =============================================================================
def demo_raw_outputs(data, ads_k64):
    print_header("演示 4：受限选择 vs 自由生成 —— k = 64、种子 0 的模型，在同分布测试集上到底写了什么？")
    test = data["test_id"]
    print(pad("方法", 34) + pad("受限选择", 10, ">") + pad("自由生成", 10, ">") + "    自由生成最常见的 3 种续写")
    for method, ad in ads_k64.items():
        outs = greedy_answers(ad.fwd, [p for p, _ in test])
        free = float(np.mean([o == f" {a}." for o, (_, a) in zip(outs, test)]))
        choice = choice_accuracy(ad.fwd, test, VERDICT_WORDS)
        top = "，".join(f"{o!r}×{n}" for o, n in Counter(outs).most_common(3))
        print(pad(LABELS[method], 34) + pad(f"{choice:.3f}", 10, ">") + pad(f"{free:.3f}", 10, ">") + "    " + top)
    print("""
观察 Observations：
  · 除了 prompt tuning，训练过的方法都学会了"写一个标签词再加句号"，两种协议的分数几乎一样——格式已经学会了，
    分数的高低反映的是"标签对不对"。
  · prompt tuning 连格式都没学会：自由生成写出 'thely.'、'the toste' 这样的碎片，完全匹配是 0；
    受限选择的 0.5 只是"在三个词里总得挑一个"的产物，恰好接近多数类的比例。
    一个方法得了瞎猜或多数类的分数时，先看原始输出再下结论（第 3 讲 T5 的教训）。""")


# =============================================================================
# 演示 5：成本账
# =============================================================================
def demo_costs(base, world):
    print_header("演示 5：成本账 —— 每个任务存多少、训练时占多少内存、推理时多花多少")
    n_base = sum(p.numel() for p in base.parameters())
    print(pad("方法", 34) + pad("每个任务额外存", 16, ">") + pad("训练内存（权重+梯度+Adam）", 28, ">") + "    推理时")
    notes = {
        "full": "每个任务一整份模型",
        "top": "每个任务一份最后一层",
        "bitfit": "换一组偏置，计算量不变",
        "adapter4": "每层多两个小矩阵乘法（不能合并）",
        "lora1": "合并进 W 后零额外计算",
        "lora8": "合并进 W 后零额外计算",
        "prefix8": "注意力多看 8 个键值",
        "prompt8": "序列变长 8 个位置（占用上下文）",
    }
    for method in METHODS:
        n = make_adapted(base, method, seed=0, world=world).n_trainable
        # fp32：模型权重 4 字节/个；要训练的参数再加梯度 4 字节 + Adam 的两个动量各 4 字节。
        # 插件类方法（adapter、LoRA、prefix、prompt）的参数是**新加的**，权重本身也要多存 4 字节/个。
        extra = 4 * n if method.startswith(("adapter", "lora", "prefix", "prompt")) else 0
        mem = 4 * n_base + extra + 12 * n
        print(pad(LABELS[method], 34) + pad(f"{4 * n / 1024:.1f} KB", 16, ">") + pad(f"{mem / 1024:.0f} KB", 28, ">")
              + "    " + notes[method])
    print(f"""
（只算参数相关的内存，不算激活值；fp32，每个数 4 字节。base model 本身 {4 * n_base / 1024:.0f} KB。）

观察 Observations：
  · 在这个玩具上数字都很小，但**比例**和真实模型一样：全量微调每个任务要存一整份模型、训练时要 4 倍于模型的内存
    （权重 + 梯度 + Adam 的两份状态）；参数高效的方法每个任务只存几 KB，训练内存几乎就是"模型本身"。
  · LoRA 论文给的真实数字（GPT-3 175B）：检查点从 350 GB 降到 35 MB，训练显存从 1.2 TB 降到 350 GB。
  · 推理成本的差别在于插件能不能"合并"：LoRA 可以，adapter 不行，prompt / prefix 会占用上下文。""")


if __name__ == "__main__":
    T0 = time.time()
    world, base = load_base()
    data = make_data()
    val = torch.tensor(TOK.encode(world.val_text()), dtype=torch.long)
    demo_start(base, data)
    best = demo_pick_lr(base, world, data)
    results, ads_k64 = demo_main_table(base, world, data, best, val)
    demo_raw_outputs(data, ads_k64)
    demo_costs(base, world)
    print(f"\n（总用时约 {time.time() - T0:.0f} 秒；计时每次运行略有不同）")
