"""
=============================================================================
第 5 讲 · 代码 4：多个不完美的提示怎样合起来用？知识存进去了，为什么取不出来？
=============================================================================

对应讲义：lectures/lecture05_emergence_icl.md 第 6 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
第 1 讲 §4.2 发现：同一个知识，换一种问法，准确率从 0.93 掉到 0.00。这一讲讨论两种应对和一个更深的问题：

    演示 1：AMA 式的提示集成（Arora et al. 2022, Ask Me Anything）。
            对第 1 讲的 base model（只读加载，不重新训练），同一个问题用 6 种不完美的提示去问，
            再把 6 个答案合起来：简单多数投票 vs 用**弱监督**（不看任何正确答案，只看提示之间谁和谁一致）
            估计每个提示的可靠程度、再加权投票。
    演示 2：反转诅咒（reversal curse，Berglund et al. 2023）。
            重新训练一个小模型，让它只在一个方向上见过事实（"kari is the keeper of the red gate."），
            再从反方向问（"the keeper of the red gate is ___"）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_prompt_ensembles_and_reversal.py      # 第一次约 30 秒（训练演示 2 的小模型并缓存）；之后约 15 秒

需要 PyTorch。依赖本目录的 seq_tasks.py，以及 code/lecture01 的 fm_world.py、tiny_gpt.py、console.py
（第 1 讲的 base model 从 code/lecture01/checkpoints/base_model.pt 只读加载）。

【Python 小知识】
    - sorted(all_names, key=lambda c: -scores[c])：按分数从高到低排序（key 是"排序时比较什么"）；
      .index(n) + 1 就是正确名字的名次。
    - math.fsum：精确的浮点数求和，结果不受相加顺序和 Python 版本影响。
    - 条件表达式 a if 条件 else b：一行写完的 if / else。
"""

import math
import os
import random
import sys
from collections import Counter

import torch

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))
from console import pad, print_header  # noqa: E402
from fm_world import ALPHABET, CITIES, JOBS, build_world, make_names, qa_prompt  # noqa: E402
from seq_tasks import GPTConfig, config_dict, load_or_train, new_model  # noqa: E402
from tiny_gpt import (CharTokenizer, completion_logprob, generate, load_or_train_base_model,  # noqa: E402
                      train_lm)

TOK = CharTokenizer(ALPHABET)


# =============================================================================
# 演示 1：AMA 式的提示集成
# =============================================================================
def answer(model, prompt, candidates):
    """
    贪心续写到句号或换行为止，取第一个词；如果它是一个合法的候选答案（8 个城市 / 8 个职业之一）就返回它，
    否则返回 None（弃权）。

    【思路】AMA 里每个提示链最后都要把模型的自由文本"映射"回标签空间；这里的映射最简单：取第一个词，
            不在候选里就弃权。取第一个词是因为有的句式后面还有话（"kari moved to varno years ago."、
            "kari is a baker from varno."）。
    """
    out = generate(model, TOK, prompt, max_new_chars=16, temperature=0, stop=".\n").replace(".", " ").split()
    return out[0] if out and out[0] in candidates else None


def make_prompt_views(world, rng):
    """
    为 "住在哪里（city）" 和 "做什么（job）" 两个问题各准备 6 种提示（AMA 论文里叫不同的 prompt / view）。

    【思路】
        1–2：叙述句的两种开头（训练里最常见的格式）
        3  ：问答格式 zero-shot
        4–5：问答格式 2-shot，两组不同的示例（示例来自"有问答文本"的那 30 人，并且不包括被问的人）
        6  ：训练里从没见过的格式 "question: ... answer:"
    【返回】dict：kind → list[(提示名, 函数 person → 提示字符串)]
    """
    qa_people = [p for p in world.people if p.name in world.qa_names]
    demo_sets = [rng.sample(qa_people, 4), rng.sample(qa_people, 4)]

    def few_shot(kind, demos, person):
        chosen = [d for d in demos if d.name != person.name][:2]
        lines = "".join(f"\n{qa_prompt(d.name, kind)} {d.city if kind == 'city' else d.job}." for d in chosen)
        return lines + "\n" + qa_prompt(person.name, kind)

    views = {
        "city": [("叙述：X lives in", lambda p: f"\n{p.name} lives in"),
                 ("叙述：X moved to", lambda p: f"\n{p.name} moved to"),
                 ("问答 zero-shot", lambda p: "\n" + qa_prompt(p.name, "city")),
                 ("问答 2-shot（第 1 组示例）", lambda p: few_shot("city", demo_sets[0], p)),
                 ("问答 2-shot（第 2 组示例）", lambda p: few_shot("city", demo_sets[1], p)),
                 ("没见过的格式 question/answer", lambda p: f"\nquestion: where does {p.name} live? answer:")],
        "job": [("叙述：X works as a", lambda p: f"\n{p.name} works as a"),
                ("叙述：X is a", lambda p: f"\n{p.name} is a"),
                ("问答 zero-shot", lambda p: "\n" + qa_prompt(p.name, "job")),
                ("问答 2-shot（第 1 组示例）", lambda p: few_shot("job", demo_sets[0], p)),
                ("问答 2-shot（第 2 组示例）", lambda p: few_shot("job", demo_sets[1], p)),
                ("没见过的格式 question/answer", lambda p: f"\nquestion: what does {p.name} do? answer:")],
    }
    return views


def weighted_vote(votes, weights, candidates):
    """
    加权投票：每个提示的票乘上它的权重，得分最高的候选胜出；所有提示都弃权时返回 None。

    【参数】votes：一个问题上各提示的答案（None 表示弃权）；weights：各提示的权重；candidates：候选答案列表
    【边界】并列时取 candidates 里排在前面的那个（确定性）。
    """
    scores = {c: 0.0 for c in candidates}
    any_vote = False
    for v, w in zip(votes, weights):
        if v is not None:
            scores[v] += w
            any_vote = True
    if not any_vote:
        return None
    return max(candidates, key=lambda c: scores[c])


def estimate_accuracies(all_votes, candidates, iters=30):
    """
    弱监督：**不看任何正确答案**，只从"提示之间的一致程度"估计每个提示的准确率。

    【思路】这是 Dawid & Skene（1979）的"单硬币"模型——众包标注、Snorkel、AMA 用的都是同一类想法：
            假设每个提示 j 以概率 a_j 答对，答错时在其余 C − 1 个候选里随便选一个，且不同提示的错误相互独立。
            EM 算法交替做两步：
              E 步：给定 a_j，算每个问题"真实答案是 c"的后验 ∝ Π_j [a_j if 票 = c else (1 − a_j)/(C − 1)]；
              M 步：给定后验，a_j = 提示 j 的票"是真实答案"的期望比例。
            一个提示如果经常和其他提示的共识一致，就被估计为可靠；一个总是自说自话的提示，权重就低。
    【参数】all_votes：list（每个问题）of list（每个提示）的答案或 None；candidates：候选列表
    【返回】list[float]：每个提示的估计准确率（只在它没弃权的问题上算）
    【边界】独立性假设在这里并不成立（两个 2-shot 提示的错误高度相关），AMA 论文用了能建模提示之间依赖的
            标签模型；这里用最简单的版本，讲义 §6.1 会讨论这个差别。
    """
    n_prompts, n_cls = len(all_votes[0]), len(candidates)
    acc = [0.7] * n_prompts
    for _ in range(iters):
        posts = []
        for votes in all_votes:
            logs = []
            for c in candidates:
                s = 0.0
                for v, a in zip(votes, acc):
                    if v is not None:
                        s += math.log(a) if v == c else math.log((1 - a) / (n_cls - 1))
                logs.append(s)
            m = max(logs)
            w = [math.exp(x - m) for x in logs]
            z = math.fsum(w)
            posts.append({c: wi / z for c, wi in zip(candidates, w)})
        for j in range(n_prompts):
            got = [post[votes[j]] for votes, post in zip(all_votes, posts) if votes[j] is not None]
            a = math.fsum(got) / len(got) if got else 1.0 / n_cls
            acc[j] = min(0.99, max(0.01, a))
    return acc


def demo_ama():
    print_header("演示 1：同一个问题，6 种不完美的提示——单个提示 vs 多数投票 vs 弱监督加权投票（第 1 讲的 base model）")
    world = build_world()
    model, _ = load_or_train_base_model(world, TOK)
    views = make_prompt_views(world, random.Random(11))
    qa_set = set(world.qa_names)
    for kind, candidates in (("city", CITIES), ("job", JOBS)):
        truth = [p.city if kind == "city" else p.job for p in world.people]
        all_votes = [[answer(model, make(p), candidates) for _, make in views[kind]] for p in world.people]
        est = estimate_accuracies(all_votes, candidates)
        print(f"\n问题：{'住在哪个城市' if kind == 'city' else '做什么职业'}（60 人：其中 30 人的问答文本出现在语料里，另外 30 人没有）")
        print(pad("提示", 32) + pad("准确率", 8, ">") + pad("有问答的人", 11, ">") + pad("没问答的人", 11, ">")
              + pad("弃权率", 8, ">") + pad("弱监督估计的准确率", 20, ">"))
        groups = [[i for i, p in enumerate(world.people) if p.name in qa_set],
                  [i for i, p in enumerate(world.people) if p.name not in qa_set]]

        def acc_of(preds, idx):
            return sum(preds[i] == truth[i] for i in idx) / len(idx)

        everyone = list(range(len(world.people)))
        for j, (name, _) in enumerate(views[kind]):
            preds = [v[j] for v in all_votes]
            abstain = sum(v is None for v in preds) / len(preds)
            print(pad(name, 32) + pad(f"{acc_of(preds, everyone):.2f}", 8, ">") + pad(f"{acc_of(preds, groups[0]):.2f}", 11, ">")
                  + pad(f"{acc_of(preds, groups[1]):.2f}", 11, ">") + pad(f"{abstain:.2f}", 8, ">") + pad(f"{est[j]:.2f}", 20, ">"))
        majority = [weighted_vote(v, [1.0] * len(v), candidates) for v in all_votes]
        log_odds = [math.log(a * (len(candidates) - 1) / (1 - a)) for a in est]
        weighted = [weighted_vote(v, log_odds, candidates) for v in all_votes]
        for name, preds in (("多数投票（6 个提示一人一票）", majority), ("弱监督加权投票（不看答案）", weighted)):
            print(pad(name, 32) + pad(f"{acc_of(preds, everyone):.2f}", 8, ">") + pad(f"{acc_of(preds, groups[0]):.2f}", 11, ">")
                  + pad(f"{acc_of(preds, groups[1]):.2f}", 11, ">"))
        print("    （加权投票的权重 = log(a·(C−1)/(1−a))，a 是弱监督估计的准确率：" + "、".join(f"{w:.1f}" for w in log_odds) + "）")
        print("  两个提示\"错成同一个答案\"的比例（60 人中；对角线 = 这个提示自己答错的比例；只列前 5 个提示）：")
        print("    " + pad("", 30) + "".join(pad(f"#{j + 1}", 7, ">") for j in range(5)))
        for i in range(5):
            cells = []
            for j in range(5):
                same_wrong = sum(v[i] is not None and v[i] == v[j] and v[i] != truth[n]
                                 for n, v in enumerate(all_votes)) if i != j else \
                    sum(v[i] != truth[n] for n, v in enumerate(all_votes))
                cells.append(pad(f"{same_wrong / len(all_votes):.2f}", 7, ">"))
            print("    " + pad(f"#{i + 1} {views[kind][i][0]}", 30) + "".join(cells))
    print("""
观察 Observations：
  · 先看单个提示。模型最熟悉的叙述句格式最好（问城市 0.93、0.92）；问答格式 zero-shot 0.72——对问答文本出现在
    语料里的 30 人是 0.97，对另外 30 人只有 0.47（第 1 讲 §4.2 的数字）；训练里没见过的 "question / answer"
    格式全部弃权（模型根本不输出城市名）。AMA 的第一个发现在这里同样成立：**把问题改写成模型在预训练里
    最常见的格式，比在原格式上苦苦调提示更有效**（AMA 发现开放式问答格式在 The Pile 里比"只输出 True/False"
    之类的限制性格式常见得多）。
  · 2-shot 示例对"没问答的人"有帮助（问城市 0.47 → 0.60 / 0.53），对"有问答的人"反而有害（0.97 → 0.83 / 0.87）：
    示例不是越多越好，它们也会把模型带偏。
  · 再看集成。多数投票问城市 0.80、问职业 0.68，**都不如最好的单个提示**（0.93、0.73）；弱监督加权投票也一样
    （0.80、0.70）。原因在最后那张"错成同一个答案"的表里：三个问答提示经常错成同一个城市（两两之间 0.13–0.23），
    而叙述句和问答提示很少一起错（0.03–0.05）。三个问答提示是一个"投票集团"，一起把两个叙述句提示压了下去。
  · 弱监督的估计也被这个集团骗了：它假设各提示的错误相互独立，于是把"彼此最一致"的问答提示估计得最可靠——
    问城市时两个 2-shot 提示被估计为 0.90、0.88（真实 0.72、0.70）；问职业时问答 zero-shot 和第 1 组 2-shot
    被估计为 0.91、0.92（真实 0.63、0.67），而真正最准的叙述句 "works as a"（0.73）只被估计为 0.68。
    它唯一做对的，是把全部弃权的那个提示的权重设为 0。
  · 这正是 AMA 论文强调的一点：不同提示的准确率不同，而且**错误之间有依赖**；AMA 用的标签模型会估计这种依赖，
    简单的多数投票和"错误独立"的模型都不行。集成有用的前提是提示足够**多样**——同一个模板换几组示例，
    得到的不是几个独立的意见，而是同一个意见说了几遍。""")


# =============================================================================
# 演示 2：反转诅咒
# =============================================================================
ROLES = ["keeper", "maker", "finder", "guard", "painter", "mender", "seller", "owner"]
THINGS = ["red gate", "old bell", "blue boat", "tall tower", "small lamp", "green door", "stone well", "silver key"]
REV_REPEATS = 10
REV_STEPS = 1500


def make_reversal_world(seed=0):
    """
    60 个虚构的人，每人一个独一无二的身份（"the keeper of the red gate"）。分成三组，每组 20 人：
        F（只见过正向）：  "kari is the keeper of the red gate."
        R（只见过反向）：  "the keeper of the red gate is kari."
        Both（两个方向都见过）
    每句话重复 REV_REPEATS 次，打乱顺序拼成训练语料。

    【返回】(groups, text)：groups 是 dict 组名 → list[(名字, 角色, 物件)]；text 是训练语料
    【FM 联系】这就是 Berglund et al. 2023 实验 1 的缩小版（他们用 GPT-3 和 LLaMA 微调虚构名人的描述）。
            Both 组的作用：让模型知道"两种句式都存在"，排除"只是没见过反向句式"这个解释。
    """
    rng = random.Random(seed)
    names = make_names(60, rng)
    rng.shuffle(names)
    combos = [(r, t) for r in ROLES for t in THINGS]
    rng.shuffle(combos)
    people = [(n,) + combos[i] for i, n in enumerate(names)]
    groups = {"F": people[:20], "R": people[20:40], "Both": people[40:]}
    lines = []
    for g, members in groups.items():
        for n, r, t in members:
            if g in ("F", "Both"):
                lines += [f"{n} is the {r} of the {t}."] * REV_REPEATS
            if g in ("R", "Both"):
                lines += [f"the {r} of the {t} is {n}."] * REV_REPEATS
    rng.shuffle(lines)
    return groups, "\n" + "\n".join(lines) + "\n"


def get_reversal_model(text):
    """
    在反转实验的语料上从零训练一个小模型（和第 1 讲 base model 同样的形状、同样的 train_lm），并缓存。

    【思路】这里用第 1 讲的 train_lm（随机切窗口的普通预训练），而不是 seq_tasks 的掩码训练：
            事实是作为"文本"被学进去的，没有"问题"和"答案"之分。
    """
    cfg = GPTConfig(vocab_size=len(ALPHABET), block_size=64, n_layer=2, n_head=4, d_model=64)
    meta = dict(config=config_dict(cfg), task="reversal", repeats=REV_REPEATS, steps=REV_STEPS, batch_size=32,
                lr=3e-3, model_seed=0, data_seed=1, n_chars=len(text))

    def build_and_train():
        model = new_model(cfg, seed=0)
        train_lm(model, torch.tensor(TOK.encode(text), dtype=torch.long), steps=REV_STEPS, batch_size=32,
                 lr=3e-3, seed=1)
        return model, {}

    model, _, _ = load_or_train("reversal", meta, build_and_train)
    return model


def demo_reversal():
    print_header("演示 2：反转诅咒——学过 \"A 是 B\"，能回答 \"B 是谁\" 吗？")
    groups, text = make_reversal_world()
    model = get_reversal_model(text)
    all_names = [p[0] for g in groups.values() for p in g]
    print(f"训练语料：{text.count(chr(10)) - 1} 行（每句重复 {REV_REPEATS} 次）。例句：")
    n, r, t = groups["Both"][0]
    print(f"    正向：{n} is the {r} of the {t}.\n    反向：the {r} of the {t} is {n}.\n")
    print(pad("组", 6) + pad("训练里见过的方向", 18) + pad("正向问：名字→身份", 20, ">") + pad("反向问：身份→名字", 20, ">")
          + pad("正确名字的平均排名（共 60 个）", 32, ">"))
    seen_dir = {"F": "只有正向", "R": "只有反向", "Both": "两个方向"}
    examples = []
    for g, members in groups.items():
        fwd = rev = 0
        ranks = []
        for n, r, t in members:
            fwd += generate(model, TOK, f"\n{n} is the", max_new_chars=30, temperature=0, stop="\n").strip() == f"{r} of the {t}."
            out = generate(model, TOK, f"\nthe {r} of the {t} is", max_new_chars=12, temperature=0, stop="\n").strip()
            rev += out == f"{n}."
            if g == "F" and len(examples) < 3:
                examples.append((f"the {r} of the {t} is", out, n))
            # Berglund et al. 的"似然"测试：把 60 个名字都接在反向提示后面，看正确名字的 log p 排第几
            scores = {c: completion_logprob(model, TOK, f"\nthe {r} of the {t} is ", c + ".") for c in all_names}
            ranks.append(sorted(all_names, key=lambda c: -scores[c]).index(n) + 1)
        print(pad(g, 6) + pad(seen_dir[g], 18) + pad(f"{fwd / len(members):.2f}", 20, ">")
              + pad(f"{rev / len(members):.2f}", 20, ">") + pad(f"{sum(ranks) / len(ranks):.1f}", 32, ">"))
    print("\nF 组（只见过正向）被反向提问时，模型写出的名字：")
    for prompt, out, true in examples:
        print(f"    {prompt} → {out:<10}（正确答案 {true}）")
    print("""
观察 Observations：
  · 同一方向：F 组问"名字→身份"、R 组问"身份→名字"、Both 组两个方向，全部 1.00——事实都背下来了。
  · 反方向：F 组问"身份→名字"、R 组问"名字→身份"，全部 0.00。Both 组的存在说明模型认识两种句式，
    问题不在"没见过这种格式"，而在**这一个事实**只以一个方向出现过。这就是 Berglund et al. 2023 的反转诅咒。
  · 似然测试更说明问题：F 组反向提问时，把 60 个名字都接在后面比较 log p，正确名字的平均排名是 50.8——
    比瞎猜（30.5）还差。模型偏爱那 40 个"在 is 后面出现过"的名字（R 组和 Both 组），F 组的名字从没出现在
    这个位置上，于是全都排在最后。正确答案没有得到哪怕一点额外的概率——和 Berglund et al. 在 GPT-3 上
    做的似然检验结论相同。
  · 为什么？"预测下一个 token"的训练，每一步只是在调整"看到前文之后，下一个 token 是什么"。
    "kari is the keeper of the red gate." 教会模型的是"kari is the → keeper of the red gate"，
    而不是"the keeper of the red gate is → kari"：后者的前文在训练里从没出现过。
    第 1 讲 §3.3 的跨格式问答（城市 0.47、职业 0.27）是同一件事的温和版本：知识**存进去了**，
    但只能沿着训练时的"路径"**取出来**（讲义 §6.2–6.3）。""")


if __name__ == "__main__":
    demo_ama()
    demo_reversal()
