"""
=============================================================================
第 5 讲 · 代码 1：能力是"突然出现"的吗？——涌现、评价指标与"海市蜃楼"之争
=============================================================================

对应讲义：lectures/lecture05_emergence_icl.md 第 1、2 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
Wei et al. 2022 把"小模型上没有、大模型上才有"的能力叫做**涌现能力（emergent abilities）**：
画成"准确率 vs 模型规模"，曲线先贴着 0 走，到某个规模突然往上跳。
Schaeffer et al. 2023 反驳说：很多这样的"跳变"是**评价指标**造成的——如果一个答案有 L 个 token，
要求每个都对（exact match），那么整题准确率 ≈ p^L，p 是单个 token 的准确率；p 平稳上升，p^L 却像突然出现。

    演示 1：纯数学。假设单个 token 的交叉熵随规模按幂律平稳下降，exact match 在不同答案长度下长什么样？
    演示 2：真实的小模型。在一个"查密码本"的翻译任务上训练 6 个大小不同的模型（约 1.5 千到 5.5 万参数），
            同一批模型、同一批输出，用不同的指标看：逐 token 准确率、正确 token 的概率、
            不同长度答案的 exact match；再看"测试集只有 100 道题"会怎样（分辨率问题）。
    演示 3："海市蜃楼"之后还剩什么？一个学多位数加法的模型，在训练过程中**连逐 token 的损失都会突然下降**——
            这种跳变不是指标造成的。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_emergence_metrics.py      # 第一次约 1–1.5 分钟（训练 7 个小模型并缓存到 checkpoints/）；之后约 10 秒

需要 PyTorch。依赖本目录的 seq_tasks.py，以及 code/lecture01 的 tiny_gpt.py、console.py（只读导入）。

【Python / PyTorch 小知识】
    - f"{x:.2e}"：科学计数法，例如 0.000267 打印成 2.67e-04（"2.67 × 10 的 −4 次方"）。概率小到 1e-8 时，
      普通的 f"{x:.4f}" 只会打印 0.0000，看不出差别。
    - (out == truth)：两个形状相同的张量逐元素比较，得到一个布尔张量；.float().mean() 是"对了的比例"，
      .all(dim=1) 是"这一行是不是全对"，.prod(dim=1) 对 0/1 浮点数也是"全对才为 1"。
"""

import math
import os
import random
import sys

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 seq_tasks import (ADD_ALPHABET, CharTokenizer, GPTConfig, addition_answers, addition_batch,  # noqa: E402
                       addition_block_size, addition_prompts, config_dict, encode_batch, greedy_fill, load_or_train,
                       new_model, random_pairs, target_logprobs, train_masked_lm)


# =============================================================================
# 演示 1：纯数学——p^L
# =============================================================================
def per_token_loss(n_params):
    """
    一个假想的模型族：单个 token 的交叉熵（nats）随参数量按幂律下降。

    【思路】L(N) = 1.2 · (N / 10^5)^(−0.35)。这只是一个"看起来像 scaling law"的例子（第 4 讲），
            数字是为了演示随手选的，不对应任何真实模型。Schaeffer et al. 2023 用的是同一种形式。
    """
    return 1.2 * (n_params / 1e5) ** (-0.35)


def demo_math():
    print_header("演示 1：纯数学——单个 token 平稳变好，整个答案却像\"突然学会\"")
    print("假设：单个 token 的交叉熵 L(N) = 1.2·(N/1e5)^(−0.35)，单个 token 答对的概率 p = exp(−L)。")
    print("如果各 token 的对错相互独立：L 个 token 全对的概率 = p^L；编辑距离（错几个 token）的期望 = L·(1 − p)。\n")
    print(pad("参数量 N", 10) + pad("交叉熵/token", 14, ">") + pad("p", 8, ">") + pad("全对 L=1", 10, ">")
          + pad("全对 L=5", 10, ">") + pad("全对 L=20", 11, ">") + pad("错几个(L=20)", 14, ">"))
    for e in range(5, 12):
        n = 10.0 ** e
        loss = per_token_loss(n)
        p = math.exp(-loss)
        print(pad(f"1e{e}", 10) + pad(f"{loss:.3f}", 14, ">") + pad(f"{p:.3f}", 8, ">") + pad(f"{p:.3f}", 10, ">")
              + pad(f"{p ** 5:.3f}", 10, ">") + pad(f"{p ** 20:.4f}", 11, ">") + pad(f"{20 * (1 - p):.2f}", 14, ">"))
    print("""
观察 Observations：
  · 每增大 10 倍参数，交叉熵稳定地乘以同一个因子（10^−0.35 ≈ 0.45）——这是一条完全"平滑、可预测"的曲线。
  · 单个 token 的准确率 p 从 0.30 平稳升到 0.99；答案只有 1 个 token 时，"整题准确率"就是 p，同样平滑。
  · 答案有 20 个 token 时，整题全对的概率在 1e5–1e7 都小于 0.01（画在图上就是"贴着 0"），
    1e8 时 0.118、1e9 时 0.385——看起来像"在 1e8 左右突然出现"。而"错几个 token"这个指标从 13.98 平稳降到 0.19。
  · 同一个模型族、同样平稳的进步，换一个指标，就从"可以预测"变成了"涌现"。这是 Schaeffer et al. 2023 的核心论证。""")


# =============================================================================
# 演示 2：真实的小模型——"查密码本"翻译
# =============================================================================
CONSONANTS = "bdfgklmnprstvz"
VOWELS = "aeiou"
CODES = "0123456789ABCDEF"
CODEBOOK_ALPHABET = "\n :" + CONSONANTS + VOWELS + CODES
N_WORDS = 900          # 词典里有 900 个"词"
SENT_LEN = 8           # 每句 8 个词 → 答案 8 个 token
ZIPF = 1.0             # 词频 ∝ 1 / 排名^1（像真实语言一样，少数词很常见、大多数词很少见）
CB_SIZES = [(1, 8), (1, 16), (1, 24), (1, 32), (1, 48), (1, 64)]   # (层数, 宽度)
CB_STEPS = 1500
CB_BATCH = 64
CB_TOK = CharTokenizer(CODEBOOK_ALPHABET)


def make_codebook(seed=0):
    """
    造一本"密码本"：900 个三字母的词（辅音 + 元音 + 辅音），每个词对应 16 个符号里随机的一个。

    【思路】这是 BIG-Bench 里"音标转写""查表翻译"一类任务的缩小版：答案的每个 token 都要查一次表。
            每个词的对应关系是随机的，没有规律可学，只能**一个一个记住**——模型越大，记住的越多；
            常见词先被记住，罕见词后被记住。所以"单个 token 答对的概率"会随规模**逐渐**上升。
    【返回】(words, code)：words 是按词频从高到低排好的词列表；code 是 dict 词 → 符号
    【边界】三字母词一共 14 × 5 × 14 = 980 种，N_WORDS 不能超过 980。
    """
    rng = random.Random(seed)
    all_words = sorted({c1 + v + c2 for c1 in CONSONANTS for v in VOWELS for c2 in CONSONANTS})
    rng.shuffle(all_words)
    words = all_words[:N_WORDS]
    return words, {w: rng.choice(CODES) for w in words}


WORDS, CODE = make_codebook()
WORD_WEIGHTS = torch.tensor([1.0 / (r + 1) ** ZIPF for r in range(N_WORDS)])


def codebook_texts(n, generator):
    """
    随机生成 n 个句子：(提示, 答案) 两个列表。提示 = "\\n" + 8 个词 + ":"，答案 = 8 个符号。

    【PyTorch 小知识】torch.multinomial(weights, k, replacement=True) 按权重有放回地抽 k 个下标。
    """
    idx = torch.multinomial(WORD_WEIGHTS, n * SENT_LEN, replacement=True, generator=generator).view(n, SENT_LEN)
    prompts, answers = [], []
    for row in idx.tolist():
        ws = [WORDS[i] for i in row]
        prompts.append("\n" + " ".join(ws) + ":")
        answers.append("".join(CODE[w] for w in ws))
    return prompts, answers


def codebook_batch(batch_size, generator):
    """
    一批训练句子 → (x, y, mask)，交给 seq_tasks.train_masked_lm。

    【思路】序列 = 提示 + 答案；y 是 x 往后挪一位；mask 只在 8 个答案符号的位置上为 1——
            输入的 8 个词是随机抽的，预测它们没有意义。
    【参数】batch_size：几句；generator：torch.Generator（决定抽哪些词）
    【返回】三个 (batch_size, 40) 的张量（mask 是浮点数）
    """
    prompts, answers = codebook_texts(batch_size, generator)
    ids = encode_batch(CB_TOK, [p + a for p, a in zip(prompts, answers)])
    mask = torch.zeros(batch_size, ids.shape[1] - 1)
    mask[:, len(prompts[0]) - 1:] = 1.0      # y 的位置 j 预测原序列第 j+1 个字符；答案从提示之后开始
    return ids[:, :-1], ids[:, 1:], mask


def get_codebook_model(n_layer, d_model):
    """
    训练（或读取缓存的）一个查密码本的模型。

    【思路】上下文长度 = 提示长度 + 答案长度 − 1（最后一个答案符号只当预测目标）；
            宽度 < 32 时用 2 个注意力头，否则 4 个（每个头至少 4 维）。所有模型训练同样的步数、同样的 batch，
            所以"更大的模型"同时意味着"更多的训练算力"（算力 ∝ 参数量 × token 数，第 4 讲）。
    【参数】n_layer, d_model：模型的层数和宽度
    【返回】(model, info)；info["n_params"] 是参数量
    【边界】第一次运行要训练（每个几秒到十几秒），之后读 checkpoints/ 里的缓存。
    """
    block = len(codebook_texts(1, torch.Generator().manual_seed(0))[0][0]) + SENT_LEN - 1
    cfg = GPTConfig(vocab_size=len(CODEBOOK_ALPHABET), block_size=block, n_layer=n_layer,
                    n_head=4 if d_model >= 32 else 2, d_model=d_model)
    meta = dict(config=config_dict(cfg), task="codebook", n_words=N_WORDS, sent_len=SENT_LEN, zipf=ZIPF,
                steps=CB_STEPS, batch_size=CB_BATCH, lr=3e-3, model_seed=0, data_seed=1)

    def build_and_train():
        model = new_model(cfg, seed=0)
        train_masked_lm(model, lambda g: codebook_batch(CB_BATCH, g), steps=CB_STEPS, seed=1)
        return model, {}

    model, info, _ = load_or_train(f"codebook_L{n_layer}d{d_model}", meta, build_and_train)
    return model, info


def codebook_metrics(model, n=2000):
    """
    同一个模型、同一批测试句子，算出几种指标。

    【返回】dict：
        tok_acc   : 逐 token 准确率（贪心输出的 8 个符号里，对了几个）
        tok_prob  : 正确符号的平均概率（teacher forcing）
        em        : {L: 前 L 个符号全对的比例}，L = 1, 2, 4, 8
        em8_100   : 只用前 100 个句子时，8 个符号全对的比例（模拟"小测试集"）
        seq_prob  : 按 T = 1 采样时，8 个符号全对的概率的平均值 = 期望的 exact match（一个连续的量）
    """
    prompts, answers = codebook_texts(n, torch.Generator().manual_seed(99))
    p_ids, a_ids = encode_batch(CB_TOK, prompts), encode_batch(CB_TOK, answers)
    out = greedy_fill(model, p_ids, SENT_LEN)
    correct = (out == a_ids).float()                       # (n, 8)：每个位置对不对
    logp = target_logprobs(model, p_ids, a_ids)            # (n, 8)：teacher forcing 下的对数概率
    return dict(tok_acc=correct.mean().item(), tok_prob=logp.exp().mean().item(),
                em={L: correct[:, :L].prod(dim=1).mean().item() for L in (1, 2, 4, 8)},
                em8_100=correct[:100].prod(dim=1).mean().item(),
                seq_prob=logp.sum(dim=1).exp().mean().item())


def demo_codebook():
    print_header("演示 2：同一批模型、同一批输出，换一个指标——\"查密码本\"翻译（每句 8 个词 → 8 个符号）")
    prompts, answers = codebook_texts(2, torch.Generator().manual_seed(5))
    for p, a in zip(prompts, answers):
        print(f"  {p.strip()} {a}")
    print(f"\n词典 {N_WORDS} 个词，词频按 Zipf 分布；每个模型训练 {CB_STEPS} 步 × {CB_BATCH} 句。测试：2000 个新句子。\n")
    print(pad("模型", 8) + pad("参数量", 9, ">") + pad("逐token准确率", 14, ">") + pad("正确token概率", 14, ">")
          + pad("全对 L=1", 10, ">") + pad("L=2", 7, ">") + pad("L=4", 7, ">") + pad("L=8", 7, ">")
          + pad("(逐token)^8", 13, ">"))
    rows = []
    for n_layer, d_model in CB_SIZES:
        model, info = get_codebook_model(n_layer, d_model)
        m = codebook_metrics(model)
        rows.append((n_layer, d_model, info["n_params"], m))
        print(pad(f"{n_layer}L·{d_model}", 8) + pad(f"{info['n_params']:,}", 9, ">") + pad(f"{m['tok_acc']:.3f}", 14, ">")
              + pad(f"{m['tok_prob']:.3f}", 14, ">") + pad(f"{m['em'][1]:.3f}", 10, ">") + pad(f"{m['em'][2]:.3f}", 7, ">")
              + pad(f"{m['em'][4]:.3f}", 7, ">") + pad(f"{m['em'][8]:.3f}", 7, ">") + pad(f"{m['tok_acc'] ** 8:.3f}", 13, ">"))
    print("\n分辨率：同样是\"8 个符号全对\"，用 100 个句子测、用 2000 个句子测、直接算概率：")
    print(pad("模型", 8) + pad("100 句上的全对率", 18, ">") + pad("2000 句上的全对率", 19, ">")
          + pad("T=1 采样时全对的概率（期望）", 30, ">"))
    for n_layer, d_model, _, m in rows:
        print(pad(f"{n_layer}L·{d_model}", 8) + pad(f"{m['em8_100']:.2f}", 18, ">") + pad(f"{m['em'][8]:.4f}", 19, ">")
              + pad(f"{m['seq_prob']:.2e}", 30, ">"))
    print("""
观察 Observations：
  · 逐 token 准确率随模型变大而上升：0.190、0.219、0.512、0.699、0.885、1.000。前两个模型只记住了最常见的
    几个词（Zipf 分布下，排名靠前的词占了很大比例），之后每大一档就多记住一批。
  · "8 个符号全对"：0.000、0.000、0.007、0.065、0.373、0.999——按参数量排开，前三个点贴着 0，后面"突然"冲上去，
    看起来就是 Wei et al. 意义上的"涌现"。可是这一列几乎就等于最后一列"逐 token 准确率的 8 次方"
    （0.005 vs 0.007、0.057 vs 0.065、0.375 vs 0.373）：这里的"跳变"几乎完全是 p^L 造成的。
  · 答案越长，曲线越陡：第 3 个模型（9,144 参数）在 L = 1 时已经有 0.511，L = 8 时只有 0.007。
  · 分辨率：用 100 句测，前三个模型都是 0.00——"这个能力不存在"；用 2000 句测，第三个模型是 0.0070；
    直接算"按 T = 1 采样时 8 个符号全对的概率"，6 个模型依次是 7.3e-09、2.8e-07、2.7e-04、1.5e-02、0.18、0.95——
    在对数尺度上一路上升，没有哪一步是从"没有"变成"有"。Schaeffer et al. 的第二个因素（小模型区间的分辨率不够）
    在这里看得很清楚：测试集只有 100 题时，任何低于 1% 的能力都会被报告成 0。
  · 结论要说完整：这不是说"exact match 是错的指标"——用户关心的往往就是整句全对。它说的是：**能力看起来是否
    "突然出现"，取决于你用什么指标看**；想预测更大的模型会怎样，应该看连续的、逐 token 的指标。""")


# =============================================================================
# 演示 3：真正的跳变——加法模型在训练中途"顿悟"
# =============================================================================
ADD_TOK = CharTokenizer(ADD_ALPHABET)
ADD_DIGITS = 4
ADD_SHAPE = dict(n_layer=2, n_head=4, d_model=96)
ADD_STEPS = 1500
ADD_TOKENS_PER_STEP = 1024


def get_addition_run():
    """
    训练（或读取缓存的）一个直接写答案的 4 位数加法模型，每 100 步记录一次各种指标。

    【返回】(model, info)；info["log"] = list[dict(step, bits, tok_prob, digit_acc, em)]
    """
    cfg = GPTConfig(vocab_size=len(ADD_ALPHABET), block_size=addition_block_size(ADD_DIGITS, "direct"), **ADD_SHAPE)
    bsz = ADD_TOKENS_PER_STEP // addition_block_size(ADD_DIGITS, "direct")
    meta = dict(config=config_dict(cfg), task="addition_dynamics", n_digits=ADD_DIGITS, steps=ADD_STEPS,
                batch_size=bsz, lr=3e-3, model_seed=0, data_seed=1)

    def build_and_train():
        model = new_model(cfg, seed=0)
        a, b = random_pairs(500, ADD_DIGITS, torch.Generator().manual_seed(7))
        prompts = addition_prompts(ADD_TOK, a, b, ADD_DIGITS, "direct")
        truth = encode_batch(ADD_TOK, addition_answers(a, b, ADD_DIGITS))
        log = []

        def callback(step, m):
            logp = target_logprobs(m, prompts, truth)
            out = greedy_fill(m, prompts, ADD_DIGITS + 1)
            log.append(dict(step=step, bits=(-logp.mean() / math.log(2)).item(), tok_prob=logp.exp().mean().item(),
                            digit_acc=(out == truth).float().mean().item(),
                            em=(out == truth).all(dim=1).float().mean().item()))

        train_masked_lm(model, lambda g: addition_batch(ADD_TOK, ADD_DIGITS, "direct", bsz, g), steps=ADD_STEPS,
                        seed=1, callback=callback, callback_every=100)
        return model, dict(log=log)

    model, info, _ = load_or_train("addition_dynamics_L2d96", meta, build_and_train)
    return model, info


def demo_real_jump():
    print_header("演示 3：海市蜃楼之后还剩什么？——4 位数加法（直接写答案），训练过程中每 100 步记录一次")
    _, info = get_addition_run()
    print(f"模型：2 层、96 维（{info['n_params']:,} 参数）；500 道新题；答案 5 个 token（含最高位的进位）。\n")
    print(pad("step", 6) + pad("bit/token", 11, ">") + pad("正确token概率", 14, ">") + pad("逐位准确率", 12, ">")
          + pad("整题准确率", 12, ">"))
    for r in info["log"]:
        print(pad(r["step"], 6) + pad(f"{r['bits']:.3f}", 11, ">") + pad(f"{r['tok_prob']:.3f}", 14, ">")
              + pad(f"{r['digit_acc']:.3f}", 12, ">") + pad(f"{r['em']:.3f}", 12, ">"))
    print("""
观察 Observations：
  · 前 300 步：bit/token 从 4.143 降到 1.899，正确 token 的平均概率从 0.059 升到 0.419，整题准确率却一直≈0
    （第 300 步 0.002）。模型在这段时间已经学会了一部分（逐位准确率从 0.021 升到 0.491），整题指标完全看不见。
  · 300–800 步：正确 token 的平均概率从 0.419 升到 0.943，bit/token 从 1.899 降到 0.127（降了 15 倍；
    而 100–300 步只从 2.502 降到 1.899）。整题准确率在这段时间从 0.002 冲到 0.888。
  · 两件事叠在一起：(1) 逐 token 的指标本身就有"慢—快"两个阶段——模型在第 300 步前后才真正学会"进位"，
    这一段加速不是指标造成的；(2) 整题准确率把这个加速再放大一次（大约是 p^5，各位数的错误还会集中在同一批
    难题上）。Schaeffer et al. 的论证解释了 (2)，解释不了 (1)。
  · (1) 这类"平台期之后突然学会"的现象在算法类任务上很常见（grokking、induction head 的形成，第 12 讲），
    而且**什么时候发生带有偶然性**：代码 03 里，同样配置的直接写答案模型，换一个随机种子，一个在第 1000 步
    前后学会、一个 1500 步都没学会。Wei et al. 和 Du et al. 2024 主张"有些涌现是真的"，指的就是这类现象。""")


if __name__ == "__main__":
    demo_math()
    demo_codebook()
    demo_real_jump()
