"""
=============================================================================
第 6 讲 · 代码 4：适配的代价——灾难性遗忘；以及用继续预训练适配一个新领域
=============================================================================

对应讲义：lectures/lecture06_adaptation.md 第 3.4、5.3 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
    演示 1：灾难性遗忘（catastrophic forgetting）。在代码 01 的评论新任务上（k = 64）训练 400 步，
            一边训练一边量"原来的本事"还剩多少：第 1 讲验证集的 bit/字符、人物事实、原格式的评论打分、问答。
            比较全量微调、只调最后一个块、BitFit、adapter、LoRA，以及"全量微调 + 回放（replay）"：
            每一步同时在一小批原来的预训练文本上做语言建模。
    演示 2：领域适配。造一个 base model 从没见过的新"领域"——诊所记录，里面有 8 个新造的药名：
                note: kari took zorvex for the rash. the rash got better.
            哪种药治哪种症状是这个领域的"知识"（10% 的记录是噪声）。比较：
                base model（不适配）、从零训练（只用领域数据）、继续预训练（DAPT，从 base model 出发）、
                继续预训练 + 回放 1/3 原来的数据。
            量：领域文本的 bit/字符、领域任务（"the rash got" 后面是 better 还是 worse）、原来的本事还剩多少。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_forgetting_and_domain.py      # 第一次约 70 秒（训练并缓存演示 2 的 3 个模型）；之后约 35 秒

需要 PyTorch、numpy。依赖本目录的 adapt_lab.py，以及 code/lecture01 的 fm_world.py、tiny_gpt.py、console.py。
演示 2 训练的模型缓存在本目录的 checkpoints/ 里；第 1 讲的 base model 只读。
"""

import copy
import os
import random
import sys
import time

import numpy as np
import torch
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 (TOK, VERDICT_WORDS, bpc_with, cached_model, choice_accuracy, encode_pairs,  # noqa: E402
                       load_base, make_adapted, verdict_items)
from console import pad, print_header  # noqa: E402
from fm_world import CITIES, FOOD_NEG, FOOD_POS, JOBS, make_review, qa_prompt  # noqa: E402
from tiny_gpt import BASE_CONFIG, bits_per_char, get_batch, lr_at, make_model, train_lm  # noqa: E402

# 代码 01 演示 2 在开发集上为每种方法选出的学习率（照抄过来，保证两个文件用同样的设定）
LR_FROM_01 = {"full": 3e-4, "top": 1e-3, "bitfit": 1e-2, "adapter4": 1e-2, "lora1": 3e-3, "lora8": 1e-3}


def capability_panel(world):
    """
    "原来的本事"：base model 在预训练里学会的 4 件事，每件一个评测集（受限选择）。

    【返回】dict：名字 → (items, candidates)
    """
    rng = random.Random(5)
    reviews = []
    for _ in range(150):
        text, rating, _, _ = make_review(rng)
        reviews.append((f"\n{text} rating:", rating))
    qa = [p for p in world.people if p.name in world.qa_names]
    return {
        "人物事实": ([(f"\n{p.name} lives in", p.city) for p in world.people], CITIES),
        "原格式问答": ([("\n" + qa_prompt(p.name, "job"), p.job) for p in qa], JOBS),
        "原格式打分": (reviews, ["good", "ok", "bad"]),
    }


# =============================================================================
# 演示 1：灾难性遗忘
# =============================================================================
def train_with_tracking(ad, pairs, steps, lr, seed, track, every=50, replay=None):
    """
    和 adapt_lab.train_on_pairs 相同的训练（只在答案上算损失，batch 16，恒定学习率），
    另外两点：每 every 步调用 track(step) 记录；replay 不为 None 时，每一步再加一项
    "在一小批预训练文本上做语言建模"的损失（回放 / rehearsal）。

    【参数】replay：None，或 (data, batch_size)：data 是第 1 讲训练语料的字符编号
    【FM 联系】回放是对抗遗忘最朴素也最常用的办法：适配时不断"温习"一点原来的数据。
            继续预训练大模型时也这样做（Ibrahim et al. 2024：学习率重新预热 + 回放，能追上用全部数据重训）。
    """
    x, y, mask = encode_pairs(pairs)
    gen = torch.Generator().manual_seed(seed)
    opt = torch.optim.AdamW(ad.params, lr=lr, weight_decay=0.0)
    for step in range(steps + 1):
        if step % every == 0:
            ad.model.eval()
            track(step)
            ad.model.train()
        if step == steps:
            break
        bi = torch.randint(0, len(pairs), (16,), generator=gen)
        logits = ad.fwd(x[bi])
        per = F.cross_entropy(logits.reshape(-1, logits.size(-1)), y[bi].reshape(-1), reduction="none")
        loss = (per * mask[bi].reshape(-1)).sum() / mask[bi].sum()
        if replay is not None:
            data, bs = replay
            xr, yr = get_batch(data, bs, 64, gen)
            lr_logits = ad.fwd(xr)
            loss = loss + F.cross_entropy(lr_logits.reshape(-1, lr_logits.size(-1)), yr.reshape(-1))
        opt.zero_grad()
        loss.backward()
        opt.step()
    ad.model.eval()


def demo_forgetting(world, base, val, train_data):
    print_header("演示 1：灾难性遗忘 —— 在评论新任务上训练 400 步（k = 64），原来的本事还剩多少？")
    rng = random.Random(11)
    seen = (FOOD_POS[:4], FOOD_NEG[:4])
    pool = verdict_items(400, rng, seen)
    dev = verdict_items(200, rng, seen)[:100]
    idx = np.random.default_rng(1000 + 10 * 64 + 0).permutation(len(pool))[:64]     # 和代码 01 相同的 k = 64 样本
    pairs = [(pool[i][0], f" {pool[i][1]}.") for i in idx]
    panel = capability_panel(world)
    base_scores = {"验证 bpc": bpc_with(base, val)}
    base_scores.update({n: choice_accuracy(base, *v) for n, v in panel.items()})
    configs = [("full", None), ("full", "replay"), ("top", None), ("bitfit", None), ("adapter4", None),
               ("lora1", None), ("lora8", None)]
    labels = {"full": "全量微调", "top": "只调最后一个块", "bitfit": "BitFit", "adapter4": "adapter（4）",
              "lora1": "LoRA r = 1", "lora8": "LoRA r = 8"}
    STEPS = 400
    records = {}
    for method, extra in configs:
        ad = make_adapted(base, method, seed=0, world=world)
        rec = []

        def track(step, _ad=ad, _rec=rec):
            row = {"step": step, "任务": choice_accuracy(_ad.fwd, dev, VERDICT_WORDS), "验证 bpc": bpc_with(_ad.fwd, val)}
            row.update({n: choice_accuracy(_ad.fwd, *v) for n, v in panel.items()})
            _rec.append(row)

        replay = (train_data, 16) if extra == "replay" else None
        train_with_tracking(ad, pairs, STEPS, LR_FROM_01[method], seed=0, track=track, replay=replay)
        records[(method, extra)] = rec
    cols = ["任务", "验证 bpc", "人物事实", "原格式问答", "原格式打分"]
    print("（任务：开发集 100 条；其余是 base model 在预训练里学会的事。学习率沿用代码 01 演示 2 选出的值。）\n")
    print(pad("", 30) + pad("第一次任务 ≥ 0.95", 18, ">") + "".join(pad(c, 12, ">") for c in cols))
    print(pad("base model（不训练）", 30) + pad("—", 18, ">") + pad("—", 12, ">")
          + "".join(pad(f"{base_scores[c]:.3f}", 12, ">") for c in cols[1:]))
    for (method, extra), rec in records.items():
        label = labels[method] + ("（+ 回放）" if extra else "")
        hit = next((r for r in rec if r["任务"] >= 0.95), None)
        for tag, r in [("达标时", hit), (f"第 {STEPS} 步", rec[-1])]:
            if r is None:
                print(pad(f"{label}·{tag}", 30) + pad("没有达到", 18, ">"))
                continue
            print(pad(f"{label}·{tag}", 30) + pad(f"第 {r['step']} 步", 18, ">")
                  + "".join(pad(f"{r[c]:.3f}", 12, ">") for c in cols))
    print("""
观察 Observations：
  · 只在新任务上训练，原来的本事会掉：验证 bpc 上涨，人物事实、原格式的问答和打分都在变差。
    遗忘在任务刚达标时就已经发生了（"达标时"一行），之后再多训练几百步，变化反而不大。
    模型从没被要求"别忘了"，梯度只管新任务——这就是灾难性遗忘。
  · 参数高效 ≠ 不遗忘。插件开着的时候，BitFit、adapter 改动的是每个位置都要经过的东西（偏置、每层的输出），
    遗忘比全量微调还重得多；LoRA 在这里遗忘最轻（r = 8 的人物事实 0.867），但也不是零。真正的保护来自**可以拔掉**：PEFT 的插件一拔，base model 原封不动
    （prompt / prefix tuning、LoRA 不合并、adapter 都如此）；全量微调只能靠另存一份原模型。
  · 回放几乎消除了遗忘：每一步同时在一小批原来的文本上做语言建模，任务照样学会，原来的能力基本保住。
    代价是训练时要一直带着原来的数据（和多一倍的计算）。""")


# =============================================================================
# 演示 2：领域适配——继续预训练
# =============================================================================
DRUGS = ["zorvex", "melatrin", "kavolin", "dupraxa", "fendil", "sorbane", "tirmex", "olvadin"]
SYMPTOMS = ["cough", "fever", "rash", "itch"]
_rng0 = random.Random(3)
TREATS = {d: sorted(_rng0.sample(SYMPTOMS, 2)) for d in DRUGS}    # 每种药治两种症状：这个领域的"知识"
DAPT_STEPS = 600


def clinic_notes(world, n, seed, noise=0.1):
    """
    新领域的文本：诊所记录。病人是迷你世界里的人，药名是新造的词；结果由"药治不治这个症状"决定，
    另有 noise 比例的记录结果相反（真实数据也有噪声）。

    【返回】list[str]
    """
    rng = random.Random(seed)
    out = []
    for _ in range(n):
        p, d, s = rng.choice(world.people), rng.choice(DRUGS), rng.choice(SYMPTOMS)
        ok = (s in TREATS[d]) != (rng.random() < noise)
        out.append(f"note: {p.name} took {d} for the {s}. the {s} got {'better' if ok else 'worse'}.")
    return out


def clinic_task(world, n=200, seed=7):
    """
    领域任务：给出药和症状，"the rash got" 后面是 better 还是 worse。

    【思路】答案用"真值"（这种药治不治这种症状），不含训练语料里 10% 的噪声：
            模型要学的是规律，而不是背下某几条记录。2 选 1，瞎猜 0.5。
    【返回】list[(提示, "better" / "worse")]
    """
    rng = random.Random(seed)
    out = []
    for _ in range(n):
        p, d, s = rng.choice(world.people), rng.choice(DRUGS), rng.choice(SYMPTOMS)
        out.append((f"\nnote: {p.name} took {d} for the {s}. the {s} got", "better" if s in TREATS[d] else "worse"))
    return out


def train_domain_model(name, init, text, lr, world, task, meta_extra):
    """
    在一段文本上做语言建模训练（第 1 讲的 train_lm：AdamW + warmup + cosine，batch 32），每 100 步记一次领域任务准确率。

    【参数】init："base"（从 base model 的拷贝出发 = 继续预训练）或 "scratch"（随机初始化，和 base model 同样的形状）
    【返回】(model, curve)：curve 是 [(step, 任务准确率)]
    """
    meta = dict(name=name, init=init, steps=DAPT_STEPS, lr=lr, batch_size=32, data_seed=1, model_seed=0,
                n_chars=len(text), **meta_extra)

    def build():
        if init == "base":
            _, b = load_base()
            model = copy.deepcopy(b)
            for p in model.parameters():
                p.requires_grad_(True)
        else:
            model = make_model(BASE_CONFIG, seed=0)
        curve = []
        data = torch.tensor(TOK.encode(text), dtype=torch.long)
        train_lm(model, data, steps=DAPT_STEPS, batch_size=32, lr=lr, seed=1,
                 callback=lambda step, m: curve.append((step, choice_accuracy(m, task, ["better", "worse"]))),
                 callback_every=100)
        return model, dict(curve=curve)

    model, info, _ = cached_model(name, meta, build)
    return model, info["curve"]


def demo_domain(world, base, val):
    print_header(f"演示 2：领域适配 —— 一个从没见过的领域（诊所记录、8 个新药名），继续预训练 vs 从零训练（各 {DAPT_STEPS} 步）")
    notes = clinic_notes(world, 3000, seed=1)
    dom_val = torch.tensor(TOK.encode("\n" + "\n".join(clinic_notes(world, 400, seed=99)) + "\n"), dtype=torch.long)
    task = clinic_task(world)
    print("领域文本的样子：")
    for line in notes[:3]:
        print("    " + line)
    print("哪种药治哪种症状（领域知识）：" + "；".join(f"{d}: {'/'.join(s)}" for d, s in TREATS.items()))
    # 新词在 base model 眼里有多"陌生"：逐字符的 bit
    for word, ctx in [("zorvex", "\nnote: kari took "), ("fever", "\nnote: kari took zorvex for the "),
                      ("the ", "\nnote: kari took zorvex for ")]:
        x, y, m = encode_pairs([(ctx, word)])
        with torch.no_grad():
            lp = F.log_softmax(base(x), -1).gather(-1, y[..., None])[..., 0]
        bits = -(lp * m).sum().item() / np.log(2)
        print(f"base model 在 {ctx[1:]!r} 之后给 {word!r}（{len(word)} 个字符）编码要 {bits:.1f} bit，"
              f"平均每字符 {bits / len(word):.2f} bit")
    text = "\n" + "\n".join(notes) + "\n"
    general = random.Random(5).sample(world.train_lines, len(notes) // 2)
    mixed = notes + general
    random.Random(6).shuffle(mixed)
    mixed_text = "\n" + "\n".join(mixed) + "\n"
    t0 = time.time()
    models = {
        "从零训练（只用领域数据）": train_domain_model("domain_scratch", "scratch", text, 3e-3, world, task, {}),
        "继续预训练 DAPT": train_domain_model("domain_dapt", "base", text, 1e-3, world, task, {}),
        "DAPT + 回放 1/3 原来的数据": train_domain_model("domain_dapt_replay", "base", mixed_text, 1e-3, world, task,
                                                    dict(replay_lines=len(general))),
    }
    print(f"（训练或读取缓存用时 {time.time() - t0:.0f} 秒）\n")
    panel = capability_panel(world)
    cols = ["领域 bpc", "领域任务", "通用 bpc", "人物事实", "原格式问答", "原格式打分"]
    print(pad("模型", 30) + "".join(pad(c, 12, ">") for c in cols))
    rows = [("base model（不适配）", base)] + [(n, m) for n, (m, _) in models.items()]
    for name, m in rows:
        vals = [bits_per_char(m, dom_val), choice_accuracy(m, task, ["better", "worse"]), bits_per_char(m, val)]
        vals += [choice_accuracy(m, *panel[k]) for k in ["人物事实", "原格式问答", "原格式打分"]]
        print(pad(name, 30) + "".join(pad(f"{v:.3f}", 12, ">") for v in vals))
    print("\n领域任务准确率随训练步数的变化（每 100 步）：")
    print(pad("", 30) + "".join(pad(f"第 {s} 步", 10, ">") for s, _ in models["继续预训练 DAPT"][1]))
    for name, (_, curve) in models.items():
        print(pad(name, 30) + "".join(pad(f"{a:.3f}", 10, ">") for _, a in curve))
    print(f"""
（领域 bpc：400 条新的诊所记录；领域任务：200 题，瞎猜 0.5；通用 bpc：第 1 讲验证集；均匀分布是 {np.log2(44):.2f} bit/字符。）

观察 Observations：
  · base model 对新领域不只是"不会"，而是**自信地错**：领域文本上 7 bit/字符以上，比均匀分布（5.46）还差——
    新药名、新症状名每个字符要 9–12 bit；连它认识的 "the "，在这个陌生的上下文里也要近 4 bit/字符。
    这就是 W22 笔记说的"主题偏移（topic shift）"：不只是生词，整个句式都是陌生的。
  · 同样 {DAPT_STEPS} 步，继续预训练（DAPT）的领域 bpc 比从零训练更低，领域任务也学得快得多：
    从零训练的模型要先学会拼写、句子结构、"把前面的症状抄过来"，DAPT 直接继承了这些。
    Gururangan et al. 2020 在 RoBERTa 上看到的正是这一点：在领域文本上继续预训练，8 个任务全部变好或持平。
  · 代价是遗忘：只用领域数据继续预训练，通用 bpc 和人物事实都垮了。混入 1/3 原来的数据（回放），
    领域上几乎一样好，通用 bpc、人物事实、原格式打分基本保住，只有原格式问答掉到一半（一个可能的原因：回放的数据是
    从语料里随机抽的一小部分，问答行在里面不多）。真实的继续预训练（例如给模型补充新语言、新知识）几乎都要回放。
  · 从零训练的模型完全没有原来的本事（人物事实≈瞎猜）。PubMedGPT / BioMedLM 选的是"从零训练一个领域模型"——
    但它用了 3000 亿个 token；在数据少的时候，从通用模型出发要划算得多。""")


if __name__ == "__main__":
    T0 = time.time()
    world, base = load_base()
    val = torch.tensor(TOK.encode(world.val_text()), dtype=torch.long)
    train_data = torch.tensor(TOK.encode(world.train_text()), dtype=torch.long)
    demo_forgetting(world, base, val, train_data)
    demo_domain(world, base, val)
    print(f"\n（总用时约 {time.time() - T0:.0f} 秒；计时每次运行略有不同）")
