"""
=============================================================================
第 9 讲 · 代码 3：预训练 → 微调 —— 预训练到底带来多少收益？
=============================================================================

对应讲义：lectures/lecture09_pretraining.md 第 6、7 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
"预训练 + 微调"（pretrain, then fine-tune）是 2018 年以后 NLP 的标准做法：
先在大量无标签文本上预训练（02 号文件），再在少量标注数据上继续训练（微调）完成具体任务。
这里用第 8 讲的影评情感分类，比较五种做法在不同标注数据量下的表现：

    逻辑回归            第 8 讲最好的基线（单个词 + 相邻两词）
    从零训练            和迷你 GPT 结构完全相同，但参数随机初始化，只用标注数据训练
    GPT 微调            加载预训练的迷你 GPT，接一个分类头，所有参数一起微调
    BERT 微调           加载预训练的迷你 BERT，接一个分类头，所有参数一起微调
    GPT 冻结 + 线性头   预训练的迷你 GPT 一个参数都不动，只训练最后的分类头（"线性探针"）

    演示 1：标注数据从 50 条到 2000 条，五种做法的测试准确率
    演示 2：标注数据里**没出现过**的形容词，模型还能判断吗？
    演示 3：只用影评的标签微调，用在餐厅点评上（第 8 讲 05 号文件的分布偏移）

需要 02 号文件保存的 checkpoints/clm.pt、mlm.pt（找不到时会先自动预训练，约 6~10 分钟）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 03_finetune.py           # 完整运行（要训练约 50 个分类器，约 10~20 分钟）
    python 03_finetune.py --quick   # 冒烟测试（几十秒）

【关于结果】编写讲义时这个文件只运行了 --quick 冒烟测试，没有完整运行。
    "预期会看到"描述的是应该出现的趋势，具体数字以你运行的结果为准。
"""

import os

os.environ.setdefault("OMP_NUM_THREADS", "1")
import importlib
import math
import sys
import time

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

import lm_utils as U
from toy_reviews import NEGATIVE, POSITIVE, Vocab, make_dataset

base = importlib.import_module("01_baselines_and_metrics")       # 第 8 讲的逻辑回归（lm_utils 已把第 8 讲加进搜索路径）
torch.set_num_threads(1)


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 70)
    print(f"  {title}")
    print("=" * 70)


# =============================================================================
# 第 1 部分：分类器 = 预训练的主体 + 一个分类头
# =============================================================================
class Classifier(nn.Module):
    """
    把一个 Transformer（预训练好的或随机初始化的）变成分类器。

    【构造参数】
        body : lm_utils.new_model 得到的 TransformerLM
        pool : "last" = 取最后一个词的隐藏状态（适合解码器：只有最后一个位置"读过"整条评论）
               "mean" = 对所有真实词的隐藏状态取平均（适合编码器：每个位置都看过整条评论）

    【思路】（讲义第 6.1 节）
        预训练模型的最后一层隐藏状态是"上下文相关的表示"。分类只需要在它上面加一个线性层
        （GPT 原论文、BERT 原论文都这样做）。微调时可以只训练这个线性层（冻结主体），
        也可以让所有参数一起训练（全量微调）。

    【边界】输入不加 </s>：解码器预训练时，最后一个词（句号）的隐藏状态被训练成"预测接下来是什么"，
        它最好地概括了整条评论；而 </s> 之后从来没有需要预测的东西。
    """

    def __init__(self, body, pool):
        super().__init__()
        self.body, self.pool = body, pool
        self.head = nn.Linear(body.tok_emb.weight.shape[1], 2)

    def forward(self, X):
        pad = X != 0
        _, H = self.body(X, pad_mask=pad)                          # (B, T, D)
        if self.pool == "last":
            h = H[torch.arange(X.shape[0]), pad.sum(1) - 1]
        else:
            h = (H * pad.unsqueeze(-1)).sum(1) / pad.sum(1, keepdim=True)
        return self.head(h)


def encode(data, vocab):
    """[(词列表, 标签)] → (X: (N, T), y: (N,))；每条前面加 <s>，后面不加 </s>。"""
    return U.pad_batch([vocab.encode(w, add_eos=False) for w, _ in data]), torch.tensor([y for _, y in data])


@torch.no_grad()
def correct(model, data, vocab, batch_size=256):
    """返回 0/1 数组：每条数据是否分类正确。"""
    model.eval()
    out = []
    for start in range(0, len(data), batch_size):
        X, y = encode(data[start:start + batch_size], vocab)
        out.append((model(X).argmax(-1) == y).numpy())
    model.train()
    return np.concatenate(out).astype(float)


def finetune(model, train, dev, vocab, lr, freeze_body=False, batch_size=16, min_steps=200, max_epochs=40, seed=0):
    """
    微调一个分类器，用验证集做早停，返回验证准确率最高的那一轮的模型。

    【参数】
        model       : Classifier
        train, dev  : 标注数据（dev 用来早停）
        lr          : 学习率（预训练的主体通常用比从零训练更小的学习率，以免"冲掉"学到的东西）
        freeze_body : True = 只训练分类头
        batch_size  : 批大小
        min_steps   : 至少训练多少步（数据很少时多训练几轮）
        max_epochs  : 最多训练几轮
        seed        : 数据顺序的随机种子

    【返回】(model, 最好的验证准确率)
    """
    if freeze_body:
        for p in model.body.parameters():
            p.requires_grad = False
    params = [p for p in model.parameters() if p.requires_grad]
    opt = torch.optim.AdamW(params, lr=lr, weight_decay=0.01)
    epochs = min(max_epochs, max(3, math.ceil(min_steps * batch_size / len(train))))
    rng = np.random.default_rng(seed)
    best, best_state = -1.0, None
    for _ in range(epochs):
        order = rng.permutation(len(train))
        for start in range(0, len(train), batch_size):
            X, y = encode([train[i] for i in order[start:start + batch_size]], vocab)
            loss = F.cross_entropy(model(X), y)
            opt.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(params, 1.0)
            opt.step()
        acc = correct(model, dev, vocab).mean()
        if acc > best:
            best, best_state = acc, {k: v.clone() for k, v in model.state_dict().items()}
    model.load_state_dict(best_state)
    return model, best


def make_classifier(method, vocab, pretrained, seed):
    """
    按方法名创建分类器，返回 (分类器, 学习率, 是否冻结主体)。

    【参数】pretrained：dict，"clm"/"mlm" → 预训练好的模型（每次深拷贝一份再微调，互不影响）
    """
    import copy
    torch.manual_seed(seed)
    if method == "从零训练":
        return Classifier(U.new_model(vocab, causal=True, seed=seed), "last"), 1e-3, False
    if method == "GPT 微调":
        return Classifier(copy.deepcopy(pretrained["clm"]), "last"), 5e-4, False
    if method == "BERT 微调":
        return Classifier(copy.deepcopy(pretrained["mlm"]), "mean"), 5e-4, False
    if method == "GPT 冻结 + 线性头":
        return Classifier(copy.deepcopy(pretrained["clm"]), "last"), 3e-3, True
    raise ValueError(method)


def run_method(method, train, dev, tests, vocab, pretrained, seed):
    """
    训练并评估一种方法。

    【参数】tests：dict，名字 → 测试数据
    【返回】dict，名字 → 0/1 正确与否的数组
    """
    if method == "逻辑回归":
        predict = base.train_lr(train, n_max=2)
        return {k: (predict([w for w, _ in d]) == np.array([y for _, y in d])).astype(float) for k, d in tests.items()}
    model, lr, freeze = make_classifier(method, vocab, pretrained, seed)
    model, _ = finetune(model, train, dev, vocab, lr, freeze_body=freeze, seed=seed)
    return {k: correct(model, d, vocab) for k, d in tests.items()}


def unseen_adjective_mask(train, test):
    """标出测试集中"含有至少一个训练集里从没出现过的形容词"的评论。"""
    seen = {w for words, _ in train for w in words}
    adjs = set(POSITIVE) | set(NEGATIVE)
    return np.array([any(w in adjs and w not in seen for w in words) for words, _ in test])


# =============================================================================
# 演示
# =============================================================================
def main(quick=False):
    vocab = Vocab()
    pretrained = {k: U.load_or_pretrain(k, vocab, quick=quick) for k in ["clm", "mlm"]}
    for m in pretrained.values():
        m.train()
    sizes, seeds = ([50], [0]) if quick else ([50, 100, 200, 500, 2000], [0, 1])
    pool = make_dataset(2000, seed=60)                       # 标注数据池：每次从里面取前 n 条（按种子打乱）
    dev = make_dataset(200 if quick else 500, seed=61)
    test = make_dataset(300 if quick else 2000, seed=62)
    rest_test = make_dataset(300 if quick else 2000, seed=63, domain="restaurant")
    methods = ["逻辑回归", "从零训练", "GPT 微调", "BERT 微调", "GPT 冻结 + 线性头"]

    # ---------------- 演示 1、2 ----------------
    print_header("演示 1：标注数据的多少 vs 测试准确率（每格是 " + f"{len(seeds)} 个随机种子的平均）")
    t0 = time.perf_counter()
    results = {}           # (方法, n) → list[dict]
    for n in sizes:
        for seed in seeds:
            order = np.random.default_rng(seed).permutation(len(pool))
            train = [pool[i] for i in order[:n]]
            for method in methods:
                results.setdefault((method, n), []).append(
                    (run_method(method, train, dev, {"test": test, "rest": rest_test}, vocab, pretrained, seed),
                     unseen_adjective_mask(train, test)))
        print(f"  （n = {n} 完成，累计用时 {time.perf_counter() - t0:.0f} 秒）", flush=True)
    print(f"\n    {'标注条数':<16s}" + "".join(f"{n:>9d}" for n in sizes))
    for method in methods:
        print(f"    {method:<14s}" + "".join(f"{np.mean([r['test'].mean() for r, _ in results[(method, n)]]):>9.1%}"
                                         for n in sizes))
    print("""
  预期会看到（编写讲义时只做了冒烟测试，具体数字以你运行的结果为准）：
    - 标注数据少的时候，预训练过的模型（GPT 微调、BERT 微调）明显好于从零训练的同一个模型，
      往往也好于逻辑回归：预训练已经让它知道了每个形容词的褒贬、not 的作用（02 号文件演示 3、4）。
    - 标注数据越多，差距越小 —— 数据足够多时，从零训练也能学会这些。预训练的价值在"数据少"时最大。
    - 从零训练的 Transformer 在数据很少时可能还不如逻辑回归：参数多、容易过拟合（第 8 讲演示 6）。
    - 冻结主体、只训练线性头，在数据很少时可能和全量微调差不多甚至更好（要训练的参数少、不容易过拟合），
      数据多时通常不如全量微调。""")

    print_header("演示 2：标注数据里没出现过的形容词")
    n_small = sizes[0] if quick else 100
    print(f"\n    用 {n_small} 条标注数据训练。测试集中，含有\"训练集里从没出现过的形容词\"的评论 vs 其余评论：")
    print(f"    {'方法':<16s}{'含没见过的形容词':>14s}{'其余':>8s}")
    for method in methods:
        accs_u, accs_s = [], []
        for r, mask in results[(method, n_small)]:
            if mask.any():
                accs_u.append(r["test"][mask].mean())
            if (~mask).any():
                accs_s.append(r["test"][~mask].mean())
        print(f"    {method:<14s}{np.mean(accs_u):>16.1%}{np.mean(accs_s):>10.1%}")
    print("""
  预期会看到：逻辑回归和从零训练的模型，对没见过的形容词毫无办法（这个词的权重是 0 或随机的）；
    预训练过的模型在预训练时见过这些词出现在什么样的上下文里，知道它和哪些已知的词相似，
    所以在这部分评论上的优势应该最明显。这是预训练最直接的价值：**把无标签文本里的知识带给下游任务**。""")

    # ---------------- 演示 3 ----------------
    print_header("演示 3：只用影评的标签微调，用在餐厅点评上")
    n_mid = sizes[-1] if quick else 500
    print(f"\n    用 {n_mid} 条影评训练：")
    print(f"    {'方法':<16s}{'影评':>8s}{'餐厅点评':>10s}")
    for method in methods:
        rs = results[(method, n_mid)]
        print(f"    {method:<14s}{np.mean([r['test'].mean() for r, _ in rs]):>10.1%}"
              f"{np.mean([r['rest'].mean() for r, _ in rs]):>10.1%}")
    print("""
  预期会看到：第 8 讲的逻辑回归换到餐厅点评上明显变差（它不认识 delicious、rude）。
    预训练语料里有餐厅点评（**没有标签**），预训练过的模型知道 delicious 和 great 用法相似，
    所以应该掉得少很多 —— 在目标领域的无标签文本上预训练，是应对分布偏移的常用办法
    （Gururangan 等 2020："Don't Stop Pretraining"）。""")


if __name__ == "__main__":
    main(quick="--quick" in sys.argv)
