"""
=============================================================================
第 7 讲 · 代码 1：指令微调（instruction tuning）——FLAN / T0 在迷你世界里
=============================================================================

对应讲义：lectures/lecture07_instruction_tuning_alignment.md 第 1、2 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
    演示 1：base model 为什么"不听话"？
            把 5 类任务写成指令（"where does kari live? answer:"），base model 会怎样续写？
            同时用两种评测协议打分：rank classification（只在合法候选里挑，测"知识在不在"）
            和贪心生成 + exact match（测"会不会按要求直接回答"）。
    演示 2：有监督指令微调（SFT）教会了什么？
            在 30 个"训练人物"和新生成的评论上，用 5 个任务簇 × 4 个模板做 300 步 SFT。
            看三件事：格式学会了没有；对**没出现在 SFT 数据里**的 30 个人，知识取不取得出来；
            原来会的东西（"kari lives in" 这样的叙述句补全）有没有被忘掉——以及混入预训练数据（ptx）能不能减轻遗忘。
    演示 3：FLAN 式的"留出整个任务簇"：在 4 个簇上微调，测第 5 个簇（zero-shot）。
    演示 4：T0 式的"换一种说法"：只用 1 个模板 vs 用 4 个模板微调，测 2 个没见过的说法。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_instruction_tuning.py      # 第一次约 70 秒（训练并缓存 9 个微调模型）；之后约 20 秒

需要 PyTorch、numpy。只读地导入 code/lecture01/ 的 fm_world.py、tiny_gpt.py、console.py 和本目录的 align_tools.py。
微调模型缓存在本目录的 checkpoints/（代码 05 会读取 sft_all_s0）；第 1 讲的 base model 只读、不修改。
"""

import random
import time
from collections import Counter

import numpy as np
import torch

from align_tools import (CANDIDATES, CLUSTER_NAMES, CLUSTERS, TEMPLATES, evaluate_items, eval_items,
                         greedy_answers, instr_prompt, instruction_model, load_base, make_item, parse_answer,
                         split_people)
from console import pad, print_header, show
from tiny_gpt import bits_per_char


# =============================================================================
# 工具
# =============================================================================
def native_items(world, cluster, people, n_reviews=120, seed=123):
    """
    用 base model **最熟悉的格式**（预训练语料里本来就有的写法）问同样的问题——这是"不微调"能做到的参照。

    【返回】list[(prompt, answer)]；food / staff 在语料里没有对应的格式，返回 None
    """
    if cluster == "city":
        return [(f"\n{p.name} lives in", p.city) for p in people]
    if cluster == "job":
        return [(f"\nq: what does {p.name} do? a:", p.job) for p in people]
    if cluster == "rating":
        rng = random.Random(seed)
        items = []
        for _ in range(n_reviews):
            q, a = make_item("rating", "{r}", rng)
            items.append((f"\nreview: {q} rating:", a))
        return items
    return None


def native_em(model, tok, items):
    """原生格式下贪心生成的 exact match。"""
    answers = [parse_answer(g) for g in greedy_answers(model, tok, [p for p, _ in items])]
    return float(np.mean([a == g for a, g in zip(answers, [g for _, g in items])]))


def majority_rate(items):
    """
    多数类基线：永远猜评测集里最常见的答案。

    【Python 小知识】collections.Counter 数每个元素出现几次；.most_common(1) 返回 [(最常见的元素, 次数)]。
    """
    counts = Counter(a for _, a in items)
    return counts.most_common(1)[0][1] / len(items)


# =============================================================================
# 演示 1：base model 不听话
# =============================================================================
def demo_base(world, tok, base, test_people):
    """演示 1：base model 对指令的续写，以及两种评测协议下的分数；返回每个簇的评测结果（演示 3 当基线用）。"""
    print_header("演示 1：把任务写成指令，base model 会怎样续写？（两种评测协议）")
    examples = [instr_prompt(f"where does {test_people[0].name} live?"),
                instr_prompt(f"what does {test_people[0].name} do?"),
                instr_prompt("rate this review: the soup was tasty but the host was rude."),
                instr_prompt("the cake was stale and the cook was rude. was the cake good?")]
    print("贪心续写（⏎ 表示换行）：")
    for p, g in zip(examples, greedy_answers(base, tok, examples, max_new=30)):
        print(f"  {show(p[1:]):<72} → {show(g)}")

    print(f"\n评测：每个簇 4 个指令模板；city / job 问 {len(test_people)} 个 test 人物，评论类问 30 条新评论 × 4 个模板。")
    print("rc = rank classification（在合法候选里挑对数概率最大的）；em = 贪心生成后 exact match；"
          "合法 = 生成的答案在合法答案集合里\n")
    head = (pad("任务簇", 12) + pad("候选数", 7, ">") + pad("机会", 7, ">") + pad("多数类", 8, ">")
            + pad("base rc", 9, ">") + pad("base em", 9, ">") + pad("合法", 7, ">") + pad("原生格式 em", 13, ">"))
    print(head)
    rows = {}
    for c in CLUSTERS:
        items = eval_items(c, TEMPLATES[c][0], test_people)
        r = evaluate_items(base, tok, items, CANDIDATES[c])
        nat = native_items(world, c, test_people)
        nat_s = f"{native_em(base, tok, nat):.2f}" if nat else "—"
        rows[c] = r
        print(pad(CLUSTER_NAMES[c], 12) + pad(len(CANDIDATES[c]), 7, ">") + pad(f"{1 / len(CANDIDATES[c]):.2f}", 7, ">")
              + pad(f"{majority_rate(items):.2f}", 8, ">") + pad(f"{r['rc']:.2f}", 9, ">") + pad(f"{r['em']:.2f}", 9, ">")
              + pad(f"{r['valid']:.2f}", 7, ">") + pad(nat_s, 13, ">"))
    print("（原生格式：city 用 \"kari lives in\"，job 用预训练里的问答格式 \"q: what does kari do? a:\"，"
          "rating 用 \"review: … rating:\"；food / staff 在语料里没有现成的问法）")
    wrong = Counter(rows["city"]["answers"]).most_common(2)
    print(f"base 对\"问城市\"指令最常见的两个续写：{wrong}")
    print("""
观察 Observations：
  · base model 几乎从不"回答"：生成的东西几乎都不是合法答案（合法率 0.00–0.03），它把 "answer:" 后面当成
    一条新评论接着写——它在**续写文档**，而不是**完成任务**。exact match 因此接近 0。
  · rank classification（把候选答案摆出来让它挑）只在"评论打分"上明显高于多数类（0.66 vs 0.50）；
    问城市 0.25 和多数类 0.23 差不多，问职业、食物、店员都在机会水平。用指令的写法问，连"挑答案"都挑不对。
  · 可是用 base 最熟悉的原生格式问，城市 0.93、职业 0.63、评分 1.00——同一个模型、同一批知识，
    换一种问法就从"答不出"变成"大多答对"。这是第 1 讲 §4.2 的提示敏感性，也是指令微调要解决的问题：
    让模型对**各种写法的指令**都按要求回答。
  · 两种协议各看到一半：rc 只问"正确答案在候选里排第几"，em 还要求模型自己把答案写出来。
    后面所有模型都用同一个 evaluate_items 打这两种分（第 3 讲 §5 的教训：比较必须用同一个评测协议）。""")
    return rows


# =============================================================================
# 演示 2：SFT 教会了什么、忘掉了什么
# =============================================================================
def demo_sft(world, tok, base, train_people, test_people, base_rows):
    """演示 2：两个种子的 SFT + 一个混入预训练数据的 SFT；格式、知识（train / test 人物）与遗忘。"""
    print_header("演示 2：在 5 个簇 × 4 个模板上做 SFT（300 步）——格式、知识与遗忘")
    ptx_text = torch.tensor(tok.encode(world.train_text()), dtype=torch.long)
    t0 = time.time()
    models = {}
    for name, kw in [("sft_all_s0", dict(seed=0)), ("sft_all_s1", dict(seed=1)),
                     ("sft_all_ptx", dict(seed=0, ptx_text=ptx_text, ptx_coef=1.0))]:
        models[name], _ = instruction_model(name, base, tok, CLUSTERS, train_people, **kw)
    print(f"（3 个微调模型就绪，用时 {time.time() - t0:.1f} 秒；SFT 数据只用 {len(train_people)} 个 train 人物 + 新生成的评论）\n")

    labels = {"sft_all_s0": "SFT（种子 0）", "sft_all_s1": "SFT（种子 1）", "sft_all_ptx": "SFT + ptx（种子 0）"}
    print(pad("任务簇", 12) + pad("base em", 9, ">") + "".join(pad(labels[n] + " rc/em/合法", 34, ">") for n in models))
    results = {}
    for c in CLUSTERS:
        items = eval_items(c, TEMPLATES[c][0], test_people)
        line = pad(CLUSTER_NAMES[c], 12) + pad(f"{base_rows[c]['em']:.2f}", 9, ">")
        for n, m in models.items():
            r = evaluate_items(m, tok, items, CANDIDATES[c])
            results[(n, c)] = r
            line += pad(f"{r['rc']:.2f} / {r['em']:.2f} / {r['valid']:.2f}", 34, ">")
        print(line)

    print("\n同一个问题，问 SFT 数据里**出现过**的 train 人物 vs **没出现过**的 test 人物（em，4 个模板）：")
    print(pad("", 22) + pad("city·train", 12, ">") + pad("city·test", 11, ">") + pad("job·train", 11, ">") + pad("job·test", 10, ">"))
    for n, m in models.items():
        vals = []
        for c in ("city", "job"):
            for ppl in (train_people, test_people):
                vals.append(evaluate_items(m, tok, eval_items(c, TEMPLATES[c][0], ppl), CANDIDATES[c])["em"])
        print(pad(labels[n], 22) + pad(f"{vals[0]:.2f}", 12, ">") + pad(f"{vals[1]:.2f}", 11, ">")
              + pad(f"{vals[2]:.2f}", 11, ">") + pad(f"{vals[3]:.2f}", 10, ">"))

    print("\n对齐税（alignment tax）：微调之后，base model 原来会的事还会吗？")
    val = torch.tensor(tok.encode(world.val_text()), dtype=torch.long)
    city_all = native_items(world, "city", world.people)
    rating_native = native_items(world, "rating", None)
    print(pad("", 22) + pad("\"X lives in\" 60 人", 20, ">") + pad("\"review: … rating:\"", 21, ">") + pad("验证集 bit/字符", 16, ">"))
    for label, m in [("base model", base)] + [(labels[n], m) for n, m in models.items()]:
        print(pad(label, 22) + pad(f"{native_em(m, tok, city_all):.2f}", 20, ">")
              + pad(f"{native_em(m, tok, rating_native):.2f}", 21, ">") + pad(f"{bits_per_char(m, val):.3f}", 16, ">"))
    print("""
观察 Observations：
  · 格式几乎立刻学会了：300 步之后合法率从 ≈ 0 升到 0.89–1.00，模型现在会"按要求只说一个答案"。
    评论打分 1.00；食物 / 店员好吗 0.76–0.85（base 从没见过这两个标签，这是在 SFT 里新学的）。
  · 知识没有被完整地"解锁"。对 SFT 数据里出现过的 train 人物，问城市、问职业几乎全对（0.97–1.00）；
    对没出现过的 test 人物，城市只有 0.46–0.53、职业 0.34–0.39——而 base 用原生格式能答对 0.93 / 0.63。
    微调在很大程度上是在**记住** SFT 数据里的具体事实，而不是学会"用预训练的知识回答任何人的问题"。
    两个种子的差别（城市 0.46 vs 0.47）给出了噪声的大小，远小于 train / test 的差距。
  · 对齐税：纯 SFT 之后，"X lives in" 从 0.93 掉到 0.42–0.58，原生的评论打分从 1.00 掉到 0.23–0.31，
    验证集 bit/字符从 0.409 变成 1.07–1.11——为了新格式改写参数，模型忘掉了旧的用法。
    每一步混入预训练数据（ptx，和 InstructGPT 的 PPO-ptx 是同一个想法）几乎把它们全部拉回（0.87、1.00、0.414），
    而且指令任务不降反升（城市 0.53、店员 0.85）：复习旧数据也保住了"人名 → 城市"这条通路。
  · 这和 LIMA 的"表面对齐假说"（知识几乎全来自预训练，对齐只教格式）在这个小模型上**只对了一半**：
    格式确实是一学就会的，但知识没有自动跟上——第 5 讲 §6.2 说过，小模型的知识和"问法"绑在一起。""")
    return models


# =============================================================================
# 演示 3：FLAN 式留出整个任务簇
# =============================================================================
def demo_heldout_clusters(tok, base, train_people, test_people, base_rows, sft_all):
    """演示 3：留一法——每次留出一个任务簇，在另外 4 个上微调，zero-shot 测留出的那个。"""
    print_header("演示 3：FLAN 式评测——在 4 个簇上微调，zero-shot 测第 5 个簇")
    t0 = time.time()
    print(pad("留出的簇", 12) + pad("base rc/em/合法", 22, ">") + pad("没见过该簇 rc/em/合法", 26, ">")
          + pad("见过该簇 em", 12, ">") + "   没见过该簇时最常见的答案")
    for held in CLUSTERS:
        others = [c for c in CLUSTERS if c != held]
        m, _ = instruction_model(f"sft_loo_{held}", base, tok, others, train_people, seed=0)
        items = eval_items(held, TEMPLATES[held][0], test_people)
        r = evaluate_items(m, tok, items, CANDIDATES[held])
        seen = evaluate_items(sft_all, tok, items, CANDIDATES[held])
        b = base_rows[held]
        common = ", ".join(f"{a}×{n}" for a, n in Counter(r["answers"]).most_common(3))
        print(pad(CLUSTER_NAMES[held], 12) + pad(f"{b['rc']:.2f} / {b['em']:.2f} / {b['valid']:.2f}", 22, ">")
              + pad(f"{r['rc']:.2f} / {r['em']:.2f} / {r['valid']:.2f}", 26, ">") + pad(f"{seen['em']:.2f}", 12, ">")
              + "   " + common)
    print(f"（5 个留一法模型就绪，含评测用时 {time.time() - t0:.1f} 秒）")
    # 诊断：留出"食物好吗"的模型，它的 yes / no 到底在回答什么？和"店员好不好"的真实标签比一比
    for held, other in (("food", "staff"), ("staff", "food")):
        m, _ = instruction_model(f"sft_loo_{held}", base, tok, [c for c in CLUSTERS if c != held], train_people, seed=0)
        items = eval_items(held, TEMPLATES[held][0], test_people)
        answers = evaluate_items(m, tok, items, CANDIDATES[held])["answers"]
        other_labels = [a for _, a in eval_items(other, TEMPLATES[held][0], test_people)]   # 同一批评论、另一个属性的标签
        agree = np.mean([x == y for x, y in zip(answers, other_labels)])
        print(f"留出「{CLUSTER_NAMES[held]}」的模型：答案和「{CLUSTER_NAMES[other]}」的真实标签一致的比例 {agree:.2f}")
    print("""
观察 Observations：
  · 留出"问城市"时，模型几乎从不说城市名：最常见的答案是 singer、farmer（职业）和 no；留出"问职业"时，
    它回答 yes 和城市名。它学会的是"人名问题 → 用训练里见过的那一类词回答"，没有读懂 "live" 和 "job" 的区别。
    rc（只在合法候选里挑）和 base 差不多（城市 0.24 vs 0.25，职业 0.20 vs 0.15）：微调没有带来新的知识。
  · 留出"评论打分"时，模型全部回答 yes / no（合法率 0.00），rc 也从 base 的 0.66 降到 0.53：
    训练里"评论 + 问句"总是配 yes / no，这个表面规律压过了一切。
  · 留出"食物好吗"或"店员好吗"时，另一个 yes / no 簇教会了答案格式（合法率 1.00），但准确率正好是 0.50——
    机会水平。表格下面两行揭示了原因：它的答案和**另一个属性**的标签完全一致（1.00）——问食物，它回答的是店员；
    问店员，它回答的是食物。格式迁移了，"读指令里问的是哪个名词"一点也没有迁移。
  · 这和 FLAN 论文在 80 亿参数及以下的模型上看到的现象一致：指令微调**伤害**了没见过的任务。
    作者的解释是小模型的容量被训练任务占满；在这个 11 万参数的模型上还要加一条：
    它的预训练语料里从来没有"指令"这种文体，它没有可以迁移的"理解指令"的能力，只能抓表面线索。""")


# =============================================================================
# 演示 4：T0 式换说法
# =============================================================================
def demo_phrasings(tok, base, train_people, test_people, sft_all):
    """演示 4：1 个模板 vs 4 个模板微调，在 2 个没见过的说法上测。"""
    print_header("演示 4：T0 式评测——只用 1 个模板 vs 用 4 个模板微调，测 2 个没见过的说法")
    m1, _ = instruction_model("sft_1tmpl", base, tok, CLUSTERS, train_people, n_templates=1, seed=0)
    print("没见过的说法，例如：" + "；".join(TEMPLATES[c][1][0] for c in ("city", "rating", "food")))
    print("\n" + pad("任务簇", 12) + pad("1 个模板：见过的说法 em", 24, ">") + pad("没见过的说法 em / 合法", 24, ">")
          + pad("4 个模板：见过的 em", 21, ">") + pad("没见过的 em / 合法", 21, ">"))
    gaps = {1: [], 4: []}
    for c in CLUSTERS:
        cells = []
        for m, n_t in ((m1, 1), (sft_all, 4)):
            seen = evaluate_items(m, tok, eval_items(c, TEMPLATES[c][0][:n_t], test_people), CANDIDATES[c])
            unseen = evaluate_items(m, tok, eval_items(c, TEMPLATES[c][1], test_people), CANDIDATES[c])
            gaps[n_t].append(unseen["em"])
            cells += [f"{seen['em']:.2f}", f"{unseen['em']:.2f} / {unseen['valid']:.2f}"]
        print(pad(CLUSTER_NAMES[c], 12) + pad(cells[0], 24, ">") + pad(cells[1], 24, ">")
              + pad(cells[2], 21, ">") + pad(cells[3], 21, ">"))
    print(f"\n没见过的说法上的 em，5 个簇平均：1 个模板 {np.mean(gaps[1]):.2f}（最低 {min(gaps[1]):.2f}），"
          f"4 个模板 {np.mean(gaps[4]):.2f}（最低 {min(gaps[4]):.2f}）")
    print("""
观察 Observations：
  · 换一种说法，准确率大幅下降，而且常常是"答错了类型"（合法率掉下来）：例如 "{r} rating?" 以问号结尾，
    4 个模板的模型就按"评论 + 问句 → yes / no"的套路回答（评论打分在没见过的说法上 0.25，合法率 0.25）。
    模型认的是表面形状，不是指令的意思。
  · 4 个模板 vs 1 个模板：没见过的说法上平均 0.40 vs 0.30，但不是每个簇都变好——食物、店员、城市变好，
    评论打分和职业反而变差，最差的簇也没有改善（0.08 vs 0.10）。T0 论文的结论是"提示越多，没见过的提示上
    中位数越高、波动越小"，而且也只在 11 个数据集中的 8 个（中位数）和 7 个（波动）上成立；
    在这个小模型上只看得到"平均变好"这个微弱的影子。
  · 另一个代价：同样 300 步，4 个模板的模型在见过的说法上略差（城市 0.46 vs 0.53，食物 0.78 vs 0.93）——
    每个模板分到的训练样本少了。多样性和"把一种说法学透"之间有取舍。""")


def main():
    """依次运行本文件的各个演示。"""
    t0 = time.time()
    world, tok, base = load_base()
    train_people, test_people = split_people(world)
    base_rows = demo_base(world, tok, base, test_people)
    models = demo_sft(world, tok, base, train_people, test_people, base_rows)
    demo_heldout_clusters(tok, base, train_people, test_people, base_rows, models["sft_all_s0"])
    demo_phrasings(tok, base, train_people, test_people, models["sft_all_s0"])
    print(f"\n总用时 {time.time() - t0:.1f} 秒（计时每次运行略有不同）")


if __name__ == "__main__":
    main()
