"""
=============================================================================
第 1 讲 · 代码 2：预训练一个"迷你基础模型"，再用提示（prompt）让它做各种任务
=============================================================================

对应讲义：lectures/lecture01_intro_foundation_models.md 第 3、4 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
基础模型的配方只有两步：**在大量无标签数据上做自监督预训练，再适配（adapt）到各种下游任务**。
GPT-3 最让人吃惊的是：适配可以简单到只写一个**提示（prompt）**，不用改任何参数。
这个文件在迷你互联网上把这个配方完整地走一遍：

    演示 1：预训练数据长什么样——没有标签，只有文本；"预测下一个字符"怎样从文本里自动造出训练样本。
    演示 2：预训练 base model（2 层、64 维、约 11 万参数，单线程 CPU 约 1 分钟），
            每 500 步停下来，用提示测试 7 种"能力"：它们在训练中**出现的时间各不相同**，
            有的到最后也没出现。同时和代码 01 里最好的 n-gram 比较 bit/字符（按体裁拆开）。
    演示 3：一个模型、很多任务——同一个模型、同一组参数，换个提示就换了一个任务。
    演示 4：提示怎么写很重要——同一个知识，用训练时见过的格式问、用没见过的格式问、
            在提示里先给几个例子（few-shot），结果差别很大。
    演示 5：温度与生成质量——和代码 01 的 n-gram 用同样的指标比：格式合法率、内容为真率、照抄率。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_pretrain_and_prompt.py          # 约 1.5 分钟（其中预训练约 1 分钟）

需要 PyTorch（CPU 即可）。依赖本目录的 fm_world.py、tiny_gpt.py、ngram_lm.py、console.py。
运行结束时把训练好的模型保存到 checkpoints/base_model.pt，代码 03 和后面几讲会直接读取它。
"""

import math
import random
import time

import torch

from console import pad, print_header, show
from fm_world import ALPHABET, build_world, check_line, make_review, qa_prompt
from ngram_lm import CharNGramLM
from tiny_gpt import (CharTokenizer, bits_per_char, completion_logprob, count_parameters, generate, get_batch,
                      load_or_train_base_model, sample_lines)

NGRAM_N = 14     # 代码 01 演示 3 里验证集上最好的 n-gram 阶数


# =============================================================================
# 演示 1：预训练数据——没有标签，只有文本
# =============================================================================
def demo_pretraining_data(world, tok):
    print_header("演示 1：预训练数据 —— 没有标签，只有文本")
    text = world.train_text()
    print(f"迷你互联网：{len(world.train_lines):,} 行，{len(text):,} 个字符；字符级词表 {tok.vocab_size} 个字符。")
    line = world.train_lines[2]
    print(f"\n一行文本：{line!r}")
    print(f"编码成整数：{tok.encode(line)[:24]} ……")
    data = torch.tensor(tok.encode(text), dtype=torch.long)
    x, y = get_batch(data, batch_size=1, block_size=24, generator=torch.Generator().manual_seed(0))
    xs, ys = tok.decode(x[0]), tok.decode(y[0])
    print("\n从语料里随机切一段（长 24），输入 x 和目标 y 只差一位：")
    print("  x = " + " ".join(show(c) if c != " " else "␣" for c in xs))
    print("  y = " + " ".join(show(c) if c != " " else "␣" for c in ys))
    print("""
观察 Observations：
  · 这一段文本同时提供了 24 道"填空题"：看到 x 的前 1 个字符猜 y 的第 1 个，看到前 2 个猜第 2 个……
    答案就在文本里，**不需要任何人标注**。这就是"自监督（self-supervised）"——监督信号来自数据本身。
  · 因为不需要标注，数据量的上限就是"能找到多少文本"。GPT-3 用了约 3000 亿个 token；
    今天的开放模型用到 15 万亿以上。基础模型的"规模"首先建立在这一点上。
  · 窗口常常跨过行边界（上面的 ⏎）：模型要学会"一行结束了，下一行可能是任何体裁"。""")


# =============================================================================
# 演示 2：预训练，并在训练中途测试能力
# =============================================================================
def greedy_answer(model, tok, prompt):
    """贪心生成到句号或换行为止，去掉首尾空白，作为模型的"答案"。"""
    return generate(model, tok, prompt, max_new_chars=10, temperature=0, stop=".\n").strip()


def make_eval_sets(world):
    """
    构造 7 个评测集。每一项是 (提示, 标准答案)；模型的贪心输出和标准答案**完全一致**才算对（exact match）。

    【返回】dict：任务名 -> list[(prompt, answer)]
    【思路】
        - 事实补全：用叙述句的开头当提示，"kari lives in" → "varno."（所有人；这些句子训练时见过）
        - 问答（见过格式）：有问答文本的那 30 人，用问答格式问城市（训练时见过一模一样的问答）
        - 问答（没见过格式）：另外 30 人，他们的事实**只**以叙述句出现过；问城市、问职业
        - 加法（见过的题）：训练语料里出现过的 200 道加法题（检查"背"住了没有）
        - 加法（没见过的题）：world.test_pairs 里的 200 道（检查"学会"了没有）
        - 评论打分：150 条新生成的评论，提示到 "rating:" 为止
    """
    sets = {}
    sets["事实补全"] = [(f"\n{p.name} lives in", f"{p.city}.") for p in world.people]
    qa_seen = [p for p in world.people if p.name in world.qa_names]
    qa_unseen = [p for p in world.people if p.name not in world.qa_names]
    sets["问答·见过格式·城市"] = [("\n" + qa_prompt(p.name, "city"), f"{p.city}.") for p in qa_seen]
    sets["问答·没见过·城市"] = [("\n" + qa_prompt(p.name, "city"), f"{p.city}.") for p in qa_unseen]
    sets["问答·没见过·职业"] = [("\n" + qa_prompt(p.name, "job"), f"{p.job}.") for p in qa_unseen]
    seen_math = sorted({line for line in world.train_lines if world.line_genres[line] == "math"})
    random.Random(3).shuffle(seen_math)
    sets["加法·见过的题"] = [("\n" + line.split("=")[0] + "=", line.split("=")[1]) for line in seen_math[:200]]
    sets["加法·没见过的题"] = [(f"\n{a}+{b}=", f"{a + b}.") for a, b in world.test_pairs[:200]]
    rng = random.Random(5)
    reviews = []
    for _ in range(150):
        text, rating, _, _ = make_review(rng)
        reviews.append((f"\n{text} rating:", f"{rating}."))
    sets["评论打分"] = reviews
    return sets


def accuracy(model, tok, items):
    """exact-match 准确率：贪心答案与标准答案完全一致的比例。"""
    return sum(greedy_answer(model, tok, p) == a for p, a in items) / len(items)


def line_bits(model, tok, line):
    """
    base model 给一行文本（含行尾 "\\n"）编码要多少 bit（总数，不是平均）。

    【思路】log p(line + "\\n" | "\\n")，换成以 2 为底再取负。提示 "\\n" 表示"一行的开头"，
            和 n-gram 的"每行开头补 \\n"对齐，两者比较才公平。
    """
    return -completion_logprob(model, tok, "\n", line + "\n") / math.log(2)


def demo_pretrain(world, tok):
    print_header("演示 2：预训练 base model，每 500 步测一次能力（单线程 CPU 约 1 分钟）")
    eval_sets = make_eval_sets(world)
    val = torch.tensor(tok.encode(world.val_text()), dtype=torch.long)
    rows = []
    eval_seconds = [0.0]    # 【Python 小知识】用一个单元素列表，内层函数才能修改它（直接给外层变量赋值会报错）
    t0 = time.time()

    def track(step, model):
        # 训练过程中被 train_lm 调用：只"读"模型，不改参数
        t = time.time()
        row = dict(step=step, bpc=bits_per_char(model, val))
        for name, items in eval_sets.items():
            row[name] = accuracy(model, tok, items)
        rows.append(row)
        eval_seconds[0] += time.time() - t
        print(f"  step {step:5d} 已测完（累计 {time.time() - t0:5.1f} 秒）")

    model, info = load_or_train_base_model(world, tok, retrain=True, callback=track, callback_every=500)
    print(f"\nbase model：{count_parameters(model):,} 个参数；预训练约 {info['train_seconds'] - eval_seconds[0]:.0f} 秒"
          f" + 中途评测约 {eval_seconds[0]:.0f} 秒（计时每次运行略有不同）")

    names = list(eval_sets)
    print("\n" + pad("step", 6) + pad("验证 bit/字符", 13, ">") + "".join(pad(n, 19, ">") for n in names))
    for r in rows:
        print(pad(r["step"], 6) + pad(f"{r['bpc']:.3f}", 13, ">") + "".join(pad(f"{r[n]:.2f}", 19, ">") for n in names))
    print("（每个评测集的大小：" + "，".join(f"{n} {len(eval_sets[n])}" for n in names) + "）")

    # 和最好的 n-gram 按体裁比较
    ngram = CharNGramLM(NGRAM_N, ALPHABET, k=0.01).fit(world.train_text())
    print(f"\n按体裁拆开的验证集 bit/字符：base model vs 代码 01 里最好的 n-gram（n = {NGRAM_N}）")
    print(pad("体裁", 10) + pad("字符数", 8, ">") + pad(f"{NGRAM_N}-gram", 10, ">") + pad("base model", 12, ">"))
    tot_g = tot_n = tot_c = 0.0
    for genre in ["bio", "qa", "math", "review", "story"]:
        lines = [l for l in world.val_lines if world.line_genres[l] == genre]
        chars = sum(len(l) + 1 for l in lines)
        g_bits = sum(line_bits(model, tok, l) for l in lines)
        n_bits = sum(ngram.bits_per_char(l + "\n") * (len(l) + 1) for l in lines)
        tot_g, tot_n, tot_c = tot_g + g_bits, tot_n + n_bits, tot_c + chars
        print(pad(genre, 10) + pad(chars, 8, ">") + pad(f"{n_bits / chars:.3f}", 10, ">") + pad(f"{g_bits / chars:.3f}", 12, ">"))
    print(pad("合计", 10) + pad(int(tot_c), 8, ">") + pad(f"{tot_n / tot_c:.3f}", 10, ">") + pad(f"{tot_g / tot_c:.3f}", 12, ">"))
    print("""
观察 Observations：
  · 训练目标从头到尾只有一个："预测下一个字符"。我们从来没有告诉模型"这是一道问答题"或"请做加法"。
    但随着训练进行，一个个"能力"先后出现：评论打分在 500 步时就接近满分；人物事实（事实补全、
    见过格式的问答）要到 1500–2500 步才大幅上升；把事实迁移到"没见过问答的人"身上，到最后也只做到一半；
    两位数加法到训练结束也基本没出现。能力不是一起长出来的。
  · 为什么评论打分最早？它只需要一条对所有评论都适用的**规则**（两个形容词的正负 → 评分），
    1900 多条评论都在为同一条规则提供证据。人物事实则是 60 个人、每人两条互不相关的**具体事实**，
    每条事实只有几十行文本作证，要背下来需要更多步。
  · "问答·见过格式"（0.97）vs "问答·没见过"（城市 0.47、职业 0.27）：模型能把叙述句里学到的事实
    用到从没见过的问答上，但只做到一部分。问职业为什么比问城市差？一个直觉的猜测是方向：城市在 5 个模板里
    都出现在名字**后面**，职业有一个模板出现在名字**前面**（"the baker kari lives in ..."）。
    但把那个模板改成职业在后面、重新训练（讲义练习 6），职业只从 0.27 升到 0.33，差距仍在——方向最多解释一小部分。
    （真实 LLM 的 "reversal curse"——学了"A 是 B"不等于学会"B 是 A"——是相关的现象；第 5 讲再谈。）
  · 加法：见过的题 0.12、没见过的题 0.09，几乎一样低。模型既没"背住"（每道题在语料里平均只出现约 1 次），
    也没"学会"进位。GPT-3 论文里，两位数加法对小模型接近 0，对 1750 亿参数的模型接近 100%——
    有些能力要到足够大的规模才出现（第 4、5 讲）。
  · 按体裁看 bit/字符：base model 在需要"长距离信息"或"举一反三"的地方明显胜过 n-gram——
    人物事实（名字在 14 个字符之前）、评论评分（形容词在 30 个字符之前），差距最大的是加法：
    新题对 n-gram 是没见过的上下文，对神经网络只是"见过的数字的新组合"。
    故事体裁两者打平：它的规律全在几个字符之内，查表就够了。""")
    return model, rows


# =============================================================================
# 演示 3：一个模型、很多任务
# =============================================================================
def demo_one_model_many_tasks(model, tok, world):
    print_header("演示 3：一个模型、很多任务 —— 同一组参数，换个提示就换了一个任务")
    p_seen = next(p for p in world.people if p.name in world.qa_names)
    p_unseen = next(p for p in world.people if p.name not in world.qa_names)
    a, b = world.test_pairs[0]
    rv_text, rv_rating, _, _ = make_review(random.Random(42))
    prompts = [
        ("知识：事实补全", f"\n{p_seen.name} lives in", f"{p_seen.city}."),
        ("知识：问答", "\n" + qa_prompt(p_seen.name, "city"), f"{p_seen.city}."),
        ("知识：没见过问答的人", "\n" + qa_prompt(p_unseen.name, "job"), f"{p_unseen.job}."),
        ("计算：新的加法题", f"\n{a}+{b}=", f"{a + b}."),
        ("分类：评论打分", f"\n{rv_text} rating:", f"{rv_rating}."),
        ("续写：故事", "\nthe pilot finished the work. then", None),
    ]
    for task, prompt, answer in prompts:
        out = generate(model, tok, prompt, max_new_chars=40, temperature=0, stop="\n").rstrip("\n")
        mark = "" if answer is None else ("  ✓" if out.strip() == answer else f"  ✗（应为 {answer}）")
        print(f"  {pad(task, 22)}{show(prompt)[1:]}【{out}】{mark}")
    print("""
观察 Observations：
  · 6 个"任务"用的是**同一个模型、同一组参数**，区别只在提示。这就是 Bommasani 等人说的
    **同质化（homogenization）**：以前每个任务训练一个模型，现在一个模型通过不同的提示（或少量微调）
    服务所有任务。好处是一次预训练、处处受益；坏处是这个模型的缺陷也会出现在每一个任务里（代码 03 演示 3）。
  · 模型能做什么，完全取决于预训练数据里有什么：它会"给评论打分"，是因为迷你互联网里有带评分的评论；
    它会"问答"，是因为有问答网站。GPT-3 能做翻译、问答、写代码，也是因为互联网上有大量这样的文本。
    "能力从数据里来"——这是第 2 讲（数据）的主题。""")


# =============================================================================
# 演示 4：提示怎么写很重要
# =============================================================================
def demo_prompt_formats(model, tok, world):
    print_header("演示 4：同一个知识，换一种问法 —— 30 个只在叙述句里出现过的人，问他们住在哪个城市")
    unseen = [p for p in world.people if p.name not in world.qa_names]
    seen = [p for p in world.people if p.name in world.qa_names]

    def few_shot_prefix(k, rng):
        demos = rng.sample(seen, k)
        return "".join(f"\n{qa_prompt(d.name, 'city')} {d.city}." for d in demos)

    formats = [
        ("叙述句补全（训练里最常见）", lambda p, r: f"\n{p.name} lives in"),
        ("问答格式，zero-shot", lambda p, r: "\n" + qa_prompt(p.name, "city")),
        ("问答格式，2-shot", lambda p, r: few_shot_prefix(2, r) + "\n" + qa_prompt(p.name, "city")),
        ("问答格式，4-shot", lambda p, r: few_shot_prefix(4, r) + "\n" + qa_prompt(p.name, "city")),
        ("没见过的问法 question/answer", lambda p, r: f"\nquestion: where does {p.name} live? answer:"),
        ("没见过的问法，2-shot", lambda p, r: "".join(
            f"\nquestion: where does {d.name} live? answer: {d.city}." for d in r.sample(seen, 2))
            + f"\nquestion: where does {p.name} live? answer:"),
    ]
    print(pad("提示格式", 34) + pad("准确率", 8, ">") + "    第一个人的提示 → 模型输出")
    for name, make in formats:
        rng = random.Random(7)
        prompts = [make(p, rng) for p in unseen]
        outs = [greedy_answer(model, tok, pr) for pr in prompts]
        acc = sum(o == f"{p.city}." for o, p in zip(outs, unseen)) / len(unseen)
        example = show(prompts[0])[1:]
        example = ("…" + example[-46:]) if len(example) > 47 else example
        print(pad(name, 34) + pad(f"{acc:.2f}", 8, ">") + f"    {example} → 【{outs[0]}】")
    print("""
观察 Observations：
  · 第一行说明知识**基本都在模型里**：用它最熟悉的叙述句去问，30 个人里答对了 28 个。
  · 但同一个知识，换成问答格式只能取出一半左右；换成训练时从没见过的 "question / answer" 格式，
    一个也取不出来——模型甚至不知道该输出一个城市名（它接上了评论里的词）。
  · 在提示里先给几个例子（few-shot / in-context learning）：对**见过的**问答格式有帮助（0.47 → 0.60），
    对**没见过的**格式一点帮助都没有。也就是说，这个 11 万参数的模型不会"从提示里的例子学新格式"，
    只会"认出它见过的格式"。GPT-3 论文的发现正是：few-shot 带来的提升随模型变大而变大，
    小模型几乎不会 in-context learning——这是"涌现能力"的一个典型例子（第 5 讲）。
  · 结论：评价一个基础模型"会不会"某件事，结论会随提示的写法而变。这既是 prompt engineering 存在的原因，
    也是评测基础模型的一大难点（第 1 讲 §4.4）。""")


# =============================================================================
# 演示 5：温度与生成质量
# =============================================================================
def quality(world, lines):
    """和代码 01 演示 4 相同的指标：格式合法、其中为真、照抄、新的合法且为真、人物事实为真。"""
    train_set = set(world.train_lines)
    checks = [check_line(world, l) for l in lines]
    n = len(lines)
    formed = [c for c in checks if c[1]]
    facts = [t for g, f, t in checks if f and g in ("bio", "qa")]
    return dict(formed=len(formed) / n, true=sum(t for _, _, t in formed) / max(1, len(formed)),
                copied=sum(l in train_set for l in lines) / n,
                novel_true=sum(1 for l, (_, f, t) in zip(lines, checks) if f and t and l not in train_set) / n,
                facts=sum(facts) / max(1, len(facts)), n_facts=len(facts))


def demo_temperature(model, tok, world):
    print_header("演示 5：温度与生成质量（每个温度采样 300 行，从\"一行的开头\"开始）")
    print(pad("温度", 6) + pad("格式合法", 10, ">") + pad("其中内容为真", 14, ">") + pad("照抄训练集原句", 16, ">")
          + pad("新的、合法且为真", 18, ">") + pad("人物事实为真", 16, ">"))
    examples = {}
    for T in [0.5, 0.8, 1.0, 1.5, 2.0]:
        lines = sample_lines(model, tok, 300, temperature=T, generator=torch.Generator().manual_seed(int(T * 10)))
        q = quality(world, lines)
        examples[T] = lines[:3]
        print(pad(T, 6) + pad(f"{q['formed']:.0%}", 10, ">") + pad(f"{q['true']:.0%}", 14, ">")
              + pad(f"{q['copied']:.0%}", 16, ">") + pad(f"{q['novel_true']:.0%}", 18, ">")
              + pad(f"{q['facts']:.0%}（{q['n_facts']} 行）", 16, ">"))
    greedy = sample_lines(model, tok, 1, temperature=0)[0]
    print(f"\n温度 0（贪心）从一行的开头只能生成同一行：{greedy!r}")
    print("\n样例（每个温度的前 3 行）：")
    for T in [0.5, 1.0, 2.0]:
        for e in examples[T]:
            print(f"  T = {T:<4} {show(e)[:90]}")
    print("""
观察 Observations：
  · 对照代码 01 演示 4 的 n-gram（n = 14、T = 1：格式合法 49%、新的合法且为真 2%）：
    base model 在 T = 1 时 91% 的行格式合法，19% 是训练集里**没有的、合法且为真的新句子**
    （新的评论组合、新的加法……）。它学到的是**规律**，不只是原句。
  · "照抄"的比例看起来很高（42–58%），但要小心解读：故事体裁一共只有 8 个职业 × 2 个代词 × 4 个模板
    = 64 种合法的句子，全都在训练集里，所以任何合法的故事都必然是"原句"。照抄率高不一定是背诵。
  · 温度是"多样性 vs 正确性"的旋钮：T 越低，越保守、越正确，但也越单调（T = 0 永远生成同一行）；
    T 越高，越多样，但错误越多，T = 1.5 时一半以上的行格式不合法，T = 2 时基本是乱码。
  · "人物事实为真"一列（括号里是这样的行数，样本不多，只看趋势）：格式完美的人物介绍里也有假的——
    把人安到了错的城市，而且温度越高越多。这就是 LLM 的**幻觉（hallucination）**在迷你世界里的样子：
    模型会用和真话一模一样的语气说假话。""")


if __name__ == "__main__":
    world = build_world()
    tok = CharTokenizer(ALPHABET)
    demo_pretraining_data(world, tok)
    model, rows = demo_pretrain(world, tok)
    demo_one_model_many_tasks(model, tok, world)
    demo_prompt_formats(model, tok, world)
    demo_temperature(model, tok, world)
