"""
=============================================================================
第 1 讲 · 代码 06：提示一个 base 模型 —— 它只是在续写文档
=============================================================================

对应讲义：lectures/lecture01_history_of_lms.md §8.3–§8.5、§10（历史给提示工程的直觉）

【这个脚本要回答的问题】
    05 号脚本训练出来的迷你 GPT 是一个 **base 模型**：它没有经过任何指令微调，只会续写。
    这个脚本用各种方式"问"它，看提示的哪些细节决定了它的行为：

    1. 同一个问题，放在不同"文档"的开头（光秃秃的问题 / 题目列表 / 问答页），它会怎样续写？（演示 1）
    2. 问答模板的细节（有没有页眉、大小写、换一个同义词）影响有多大？（演示 2）
    3. 模型不认识的格式，给几个示例（few-shot）能不能救回来？什么情况下救不回来？（演示 3）
    4. 分类任务：触发词（"rating :"）换掉之后会怎样？示例能不能教会它一个新的触发词？（演示 4）

【怎么读输出】
    每道知识题只有一个正确答案（一个词）。"第一个词就答对"指模型续写的第一个 token 就是答案；
    "8 个词内答出来"指贪心续写的前 8 个 token 里出现了答案（模型可能先写 "a :" 再写答案）。

【运行】python3 06_prompt_formats.py     （几秒钟；需要迷你 GPT 的缓存，没有的话会先训练约 50 秒）
【依赖】PyTorch；import 同目录的 tiny_gpt.py、pretrain_world.py、console.py。
"""

import random

import torch

from console import pct, pct_ci, print_header, print_observations, print_table
from pretrain_world import fact_probes, make_review, question_text, review_probes
from tiny_gpt import generate, get_model, next_token_probs, score_options, top_next

# 百科句式（完形填空）：每种关系一个
CLOZE = {"capital": "the capital of {c} is", "language": "the main language of {c} is",
         "export": "the main export of {c} is", "leader": "the leader of {c} is"}


def fill(template, p):
    """把模板里的 {q}（问题）和 {cloze}（百科句式）换成某道知识题的内容。"""
    return template.format(q=question_text(p.country, p.relation), cloze=CLOZE[p.relation].format(c=p.country))


# ---------------------------------------------------------------------------
@torch.no_grad()
def demo1_framing(model, tok, world):
    """演示 1：同一个问题放进不同"文档"里。"""
    print_header("演示 1：同一个问题，放在不同'文档'的开头——base 模型在猜自己正在写哪种文档")
    probes = fact_probes(world)
    framings = [
        ("光秃秃的问题", "<doc> {q}"),
        ("题目列表", "<doc> quiz : {q}"),
        ("问答页（只有页眉）", "<doc> faq : {q}"),
        ("问答页 + 'q :'", "<doc> faq : q : {q}"),
        ("问答页 + 'q :' + 'a :'", "<doc> faq : q : {q} a :"),
    ]
    a_id, what_id, who_id = tok.stoi["a"], tok.stoi["what"], tok.stoi["who"]
    rows, res = [], {}
    for name, t in framings:
        r = [name]
        for seen in [True, False]:
            sub = [p for p in probes if p.seen == seen]
            p_a = p_q = 0.0
            ok = 0
            for p in sub:
                prompt = fill(t, p)
                probs = next_token_probs(model, tok, prompt)
                p_a += probs[a_id].item()
                p_q += probs[what_id].item() + probs[who_id].item()
                ok += p.answer in generate(model, tok, prompt, 8, stop=("<doc>",)).split()
            res[(name, seen)] = ok / len(sub)
            r += [f"{p_a / len(sub):.2f}", f"{p_q / len(sub):.2f}", pct(ok, len(sub))]
        rows.append(r)
    print("P(a) = 下一个词是 'a'（要开始答题）的平均概率；P(题) = 下一个词是 'what' / 'who'（要接着出下一道题）的平均概率")
    print_table(["提示", "见过·P(a)", "见过·P(题)", "见过·答出", "百科·P(a)", "百科·P(题)", "百科·答出"], rows)
    c = world.held[0]
    for name, t in framings[:3]:
        prompt = t.format(q=question_text(c.name, "capital"))
        print(f"  {prompt!r} → {generate(model, tok, prompt, 12, stop=('<doc>',))!r}")
    full, header = framings[4][0], framings[2][0]
    print_observations([
        "光秃秃的一个问题，在训练语料里既可能是问答页里的问题（后面跟答案），也可能是题目列表里的一道题（后面跟下一道题）。"
        "模型在两种续写之间分配概率——它在猜**自己正在写哪种文档**。",
        f"有意思的是这个猜测取决于问题的**内容**：问答页里见过的国家，光秃秃的问题有 {pct(res[('光秃秃的问题', True)], 1)} 被答出来；"
        f"只在百科出现过的国家（它们的问题只出现在题目列表里）是 {pct(res[('光秃秃的问题', False)], 1)}——模型把它们当成了题目列表。",
        f"加上 'quiz :' 页眉，它一定接着出题。只加 'faq :' 页眉，见过的国家 {pct(res[(header, True)], 1)} 被答出来，"
        f"只在百科的国家仍只有 {pct(res[(header, False)], 1)}；再加一个 'q :'，P(a) 就接近 1——"
        f"这个小标记比页眉更能说明'这里是问答'。把格式写全（q : … a :），两类国家分别是 "
        f"{pct(res[(full, True)], 1)} 和 {pct(res[(full, False)], 1)}。",
        "GPT-3 之前的 base 模型都是这样：你问它一个问题，它可能接着写五个问题。提示工程最早的技巧就是"
        "**把提示写成'答案会自然出现在后面'的那种文档的开头**——这也是后来指令微调（第 3 讲）要解决的问题。",
    ])


# ---------------------------------------------------------------------------
@torch.no_grad()
def demo2_templates(model, tok, world):
    """演示 2：模板细节的敏感性。"""
    print_header("演示 2：模板里的一个小改动——问答模板的敏感性")
    probes = fact_probes(world)
    templates = [
        ("训练时的格式", "<doc> faq : q : {q} a :"),
        ("去掉页眉 'faq :'", "<doc> q : {q} a :"),
        ("再去掉 '<doc>'", "q : {q} a :"),
        ("q / a 改成大写 Q / A", "<doc> faq : Q : {q} A :"),
        ("q / a 改成 question / answer", "<doc> faq : question : {q} answer :"),
        ("改用百科的句式（完形填空）", "<doc> article : {cloze}"),
    ]
    rows, res = [], {}
    for name, t in templates:
        unk = tok.unknown_words(fill(t, probes[0]))
        r = [name, ", ".join(sorted(set(unk))) if unk else "-"]
        for seen in [True, False]:
            sub = [p for p in probes if p.seen == seen]
            ok = sum(top_next(model, tok, fill(t, p), 1)[0][0] == p.answer for p in sub)
            res[(name, seen)] = (ok, len(sub))
            r.append(pct_ci(ok, len(sub)))
        rows.append(r)
    print("指标：模型续写的第一个词就是答案的比例（64 道问答见过的题 / 32 道只在百科出现的题）")
    print_table(["模板", "模型词表里没有的词", "问答见过的国家", "只在百科的国家"], rows)
    print_observations([
        "去掉页眉、去掉 <doc> 几乎没有影响：'q : … ? a :' 这个结构本身已经足够说明'这里要答题'。",
        "只是把 q / a 换成大写，或者换成意思完全相同的 question / answer，准确率就从接近 100% 掉到 0："
        "这些词不在模型的词表里（变成了 <unk>），模型认不出这个结构了。"
        "真实的大模型词表大得多、见过的格式多得多，不会这么极端，但'格式上的小改动带来大波动'在真实模型上同样存在："
        "Sclar et al.（2024）报告，只改分隔符、大小写、空格这类'无关紧要'的格式，LLaMA-2-13B 的准确率最多能差 76 个百分点。",
        "换成百科的句式也全对：同一个知识可以用不同的'文档格式'取出来，只要这种格式在训练语料里出现过。",
    ])


# ---------------------------------------------------------------------------
@torch.no_grad()
def demo3_few_shot(model, tok, world):
    """演示 3：示例能不能救回模型不认识的格式。"""
    print_header("演示 3：模型不认识的格式，给几个示例（few-shot）能不能救回来？")
    probes = fact_probes(world)
    formats = [
        ("Q / A（大写）", "<doc> faq :", "Q : {q} A :", " {a} ."),
        ("question / answer", "<doc> faq :", "question : {q} answer :", " {a} ."),
        ("问题后面直接跟答案（没有 a :）", "<doc>", "{q}", " {a} ."),
    ]
    ks = [0, 1, 2, 4]
    rows, res, plen = [], {}, {}
    example_prompt = None
    for name, head, q_tpl, a_tpl in formats:
        r = [name]
        for k in ks:
            rng = random.Random(100 + k)
            ok = 0
            for p in probes:
                pool = [x for x in probes if x.seen and x.country != p.country]
                demos = rng.sample(pool, k)
                demo_text = " ".join(q_tpl.format(q=question_text(d.country, d.relation)) + a_tpl.format(a=d.answer)
                                     for d in demos)
                prompt = " ".join(x for x in [head, demo_text, q_tpl.format(q=question_text(p.country, p.relation))] if x)
                ok += top_next(model, tok, prompt, 1)[0][0] == p.answer
                if name.startswith("question"):
                    plen.setdefault(k, []).append(len(tok.encode(prompt)))
                if k == 2 and name.startswith("question") and example_prompt is None:
                    example_prompt = prompt
            res[(name, k)] = ok
            r.append(pct(ok, len(probes)))
        rows.append(r)
    print(f"示例从别的国家（问答见过的）里随机抽；每个格式 {len(probes)} 道题。指标：第一个词就是答案")
    print_table(["格式"] + [f"{k} 个示例" for k in ks], rows)
    print(f"\n  2 个示例的提示长这样：{example_prompt!r}")
    print(f"  模型实际看到的 token：{tok.decode(tok.encode(example_prompt))!r}")
    print_observations([
        f"question / answer 格式：0 个示例时 {pct(res[('question / answer', 0)], len(probes))}，"
        f"2 个示例 {pct(res[('question / answer', 2)], len(probes))}，4 个示例 {pct(res[('question / answer', 4)], len(probes))}。"
        "模型并不认识 question、answer 这两个词（它看到的是 <unk>），但示例让它看出了结构：'<unk> : 问题 ? <unk> : 答案 .'"
        "——示例告诉它**这是一个问答页，答案该写在哪里**。这就是 GPT-3 论文'少样本学习'的核心。",
        "前两行的数字完全一样，因为 Q / A 和 question / answer 在模型眼里都变成了同一串 '<unk> :'。",
        f"示例越多越好，但收益递减：从 0 到 2 个提升最大。示例占的是上下文窗口：question / answer 格式下，"
        f"4 个示例的提示平均 {sum(plen[4]) / len(plen[4]):.0f} 个 token，有 {sum(x > model.cfg.block_size for x in plen[4])}"
        f"/{len(plen[4])} 个超过了这个模型 {model.cfg.block_size} 个 token 的窗口，最前面的部分（页眉、第一个示例的开头）被截掉了。",
        f"可'问题后面直接跟答案'这种格式，示例给到 4 个也是 {pct(res[(formats[2][0], 4)], len(probes))}：在训练语料里，"
        "'?' 后面从来不会直接出现答案（总是 'a :' 或下一道题），这个习惯强到几个示例扳不过来。"
        "小模型尤其如此——Wei et al.（2023）发现，大模型才能用上下文里的示例'推翻'预训练形成的先验。",
    ])


# ---------------------------------------------------------------------------
@torch.no_grad()
def demo4_triggers(model, tok):
    """演示 4：分类任务的触发词。"""
    print_header("演示 4：分类任务的'触发词'——把 rating 换成 sentiment 会怎样？")
    probes = review_probes()
    variants = [
        ("训练时的格式", "<doc> reviews : review : {x} . rating :", 0, "rating"),
        ("去掉 'reviews : review :'", "<doc> {x} . rating :", 0, "rating"),
        ("rating 换成 sentiment（词表里没有）", "<doc> reviews : review : {x} . sentiment :", 0, "sentiment"),
        ("sentiment + 2 个示例", "<doc> reviews : {demos} review : {x} . sentiment :", 2, "sentiment"),
        ("sentiment + 4 个示例", "<doc> reviews : {demos} review : {x} . sentiment :", 4, "sentiment"),
    ]
    rows, res = [], {}
    for name, t, k, key in variants:
        rng = random.Random(7)
        cnt = {}
        for text, label, kind in probes:
            demos = []
            for _ in range(k):
                dt, dl, _ = make_review(rng)
                demos.append(f"review : {dt} . {key} : {dl} .")
            prompt = t.format(x=text, demos=" ".join(demos))
            pred = score_options(model, tok, prompt, ["positive", "negative"])[2]
            ok, n = cnt.get(kind, (0, 0))
            cnt[kind] = (ok + (pred == label), n + 1)
        tot = sum(ok for ok, _ in cnt.values())
        res[name] = tot / len(probes)
        res[(name, "but")] = cnt["but"][0] / cnt["but"][1]
        rows.append([name] + [pct(*cnt[kd]) for kd in ["simple", "but", "not"]] + [pct_ci(tot, len(probes))])
    print(f"{len(probes)} 条新评论；比较 positive 和 negative 作为下一个词的概率")
    print_table(["提示", "简单句", "but 句", "not 句", "总体 [95% CI]"], rows)
    print_observations([
        "'rating :' 就是这个任务的触发词：它在预训练语料里总是引出 positive / negative。有它，零样本就是 100%；"
        "连 'reviews : review :' 都不需要。",
        f"把它换成 sentiment（模型没见过的词），准确率掉到 {pct(res[variants[2][0]], 1)}，接近随机：模型不知道 '<unk> :' 后面该写什么。",
        f"给 2 个、4 个用 sentiment 写的示例，总体准确率回到 {pct(res[variants[3][0]], 1)}、{pct(res[variants[4][0]], 1)}："
        "示例告诉了它'这个新的键后面写 positive / negative'，简单句又全对了。"
        f"但 but 句只有 {pct(res[(variants[3][0], 'but')], 1)}、{pct(res[(variants[4][0], 'but')], 1)}——"
        "示例救回了**格式**，却没有完全救回 'rating :' 时的那种判断。用模型熟悉的触发词，比教它一个新的触发词可靠得多。",
        "总结这一讲的提示工程直觉：**base 模型的提示 = 一份它熟悉的文档的开头**。格式、触发词、示例的作用，"
        "都是让'你想要的回答'成为这份文档最自然的续写。第 2 讲会系统地研究示例的选择、顺序和校准。",
    ])


def main():
    torch.set_num_threads(1)
    model, tok, info = get_model("base")
    world = info["world"]
    demo1_framing(model, tok, world)
    demo2_templates(model, tok, world)
    demo3_few_shot(model, tok, world)
    demo4_triggers(model, tok)


if __name__ == "__main__":
    main()
