"""
=============================================================================
第 1 讲 · 01：Scaling law、"涌现"（emergence）与 chain-of-thought
=============================================================================

对应讲义：lectures/lecture01_overview.md 第 2 节（scaling laws）、第 3 节（emergent abilities 与 CoT）

-----------------------------------------------------------------------------
【这个文件回答三个问题】
-----------------------------------------------------------------------------
演示 1  Scaling law 长什么样？为什么"每 10 倍算力换来的进步越来越少"？
        我们用一条已知的"真实规律" L(C) = L∞ + A·(C/C0)^(−α) 生成 6 个带噪声的"实验点"，
        然后像研究者一样去拟合它、外推它。你会看到：
          · 在 log-log 坐标里，"可下降的那部分 loss" 是一条直线（power law）；
          · 忽略 irreducible loss L∞，外推会过于乐观；
          · 每多 10 倍算力，loss 的绝对下降越来越小 —— 这就是"pretraining 进入收益递减"的直觉。

演示 2  "涌现"是真的突然出现，还是度量方式造成的错觉？
        用 toy_llm 里的模拟模型（100M → 1T 参数），同时测三种指标：
          · per-step accuracy（每一步算对的比例）—— 随规模平滑上升；
          · 2 步题的 exact match；
          · 10 步题的 exact match —— 看起来像在某个规模"突然"学会了。
        而 10 步题的 exact match ≈ (per-step accuracy)^10。指标的非线性制造了"突变"的样子
        （Schaeffer et al. 2023, "Are Emergent Abilities of LLMs a Mirage?"）。

演示 3  为什么 chain-of-thought 有用？
        比较 direct answer（直接报答案）和 CoT（写出每一步）在 1~8 步题上的准确率：
        direct 模式超过"一次前向传播能做的串行步数"就崩溃；CoT 只随步数平滑下降。
        CoT 多花了 token —— 它本身就是一种 test-time compute。

运行时间：不到 1 秒（只用标准库）。
"""

import math
import random

from toy_llm import ToyLLM, fmt_size, make_problem, mean, pad, bar


# =============================================================================
# 演示 1：scaling law 的拟合与外推
# =============================================================================
# "真实规律"的参数（只有生成数据时用到；拟合时假装不知道）。
# L∞ = 1.69 借用了 Chinchilla 论文（Hoffmann et al. 2022）拟合出的 irreducible loss 数值，
# 其余参数是为了演示而设的。
TRUE_L_INF = 1.69
TRUE_A = 1.8
TRUE_ALPHA = 0.155
C0 = 1e18          # 参照算力（FLOPs）


def true_loss(compute):
    """
    "真实"的 scaling law：L(C) = L∞ + A·(C/C0)^(−α)。

    【思路】这是 Kaplan et al. 2020、Hoffmann et al. 2022 用的函数形式：
        · A·(C/C0)^(−α)：可以靠扩大规模消除的部分（reducible loss），随算力按幂律（power law）下降；
        · L∞：再大的模型也消除不了的部分（irreducible loss），可以理解为文本本身的"不可预测性"（熵）。
    【参数】compute：训练算力（FLOPs，> 0）
    【返回】float（loss，单位是 nats/token）
    """
    return TRUE_L_INF + TRUE_A * (compute / C0) ** (-TRUE_ALPHA)


def fit_line(xs, ys):
    """
    最小二乘拟合直线 y = a + b·x，返回 (a, b)。

    【思路】高中学过的"线性回归"公式：
        b = Σ(x − x̄)(y − ȳ) / Σ(x − x̄)²，   a = ȳ − b·x̄
    【参数】xs、ys：等长的数字列表，至少 2 个点，且 xs 不能全相同
    【返回】(截距 a, 斜率 b)
    【边界】xs 全相同时分母为 0，抛出 ValueError（竖直的直线不能写成 y = a + bx）。
    """
    if len(xs) != len(ys) or len(xs) < 2:
        raise ValueError("需要至少 2 个点，且 xs、ys 等长")
    mx, my = mean(xs), mean(ys)
    sxx = math.fsum((x - mx) ** 2 for x in xs)
    if sxx == 0:
        raise ValueError("xs 全相同，无法拟合")
    sxy = math.fsum((x - mx) * (y - my) for x, y in zip(xs, ys))
    b = sxy / sxx
    return my - b * mx, b


def fit_pure_power_law(computes, losses):
    """
    拟合"纯幂律" L = K·C^(−α)（**没有** irreducible loss）。

    【思路】两边取 log10：log L = log K − α·log C，是一条直线 → 用 fit_line 拟合即可。
        这是很多人画 log-log 图时下意识做的事。
    【返回】一个函数 predict(C) → 预测的 loss
    【边界】如果真实规律有 L∞ > 0，纯幂律会把"趋近 L∞"误读成"继续按直线下降"，外推时过于乐观。
    """
    a, b = fit_line([math.log10(c) for c in computes], [math.log10(l) for l in losses])
    return lambda c: 10 ** (a + b * math.log10(c))


def fit_power_law_with_floor(computes, losses, step=0.005):
    """
    拟合带 irreducible loss 的幂律 L = L∞ + K·C^(−α)。

    【思路】有三个未知数，不能直接取 log 变成直线。但如果 L∞ 已知，L − L∞ = K·C^(−α) 就又是纯幂律了。
        所以用最笨也最稳的办法：在 [0, min(L)) 上**逐个尝试** L∞（网格搜索，grid search），
        每个 L∞ 都拟合一次直线，留下预测误差（平方和）最小的那个。
    【参数】computes、losses：数据；step：L∞ 的网格间距
    【返回】(predict 函数, 拟合出的 L∞, 拟合出的 α)
    【边界】
        · L∞ 必须小于所有观测到的 loss（否则 log(L − L∞) 没有定义），所以网格的上限是 min(L) − step。
        · 只有几个点、又有噪声时，L∞ 和 α 会"此消彼长"（一个偏大另一个就偏小也能拟合得很好），
          所以拟合出的 L∞ 可能和真实值差不少 —— 这是 scaling law 研究里真实存在的困难。
    """
    log_c = [math.log10(c) for c in computes]
    best = None
    n_grid = int((min(losses) - step) / step)
    for i in range(n_grid + 1):
        floor = i * step
        a, b = fit_line(log_c, [math.log10(l - floor) for l in losses])
        sse = math.fsum((floor + 10 ** (a + b * x) - l) ** 2 for x, l in zip(log_c, losses))
        if best is None or sse < best[0]:
            best = (sse, floor, a, b)
    _, floor, a, b = best
    return (lambda c: floor + 10 ** (a + b * math.log10(c))), floor, -b


def demo_scaling_law(rng):
    print("=" * 78)
    print("演示 1：scaling law —— 拟合 6 个实验点，再外推到更大的算力")
    print("=" * 78)
    computes = [10 ** e for e in range(18, 24)]          # 1e18 ~ 1e23 FLOPs，每次 ×10
    # 每个"实验点"带 ±0.4% 左右的测量噪声（真实实验里，不同随机种子训练出的 loss 也会有这种波动）
    losses = [true_loss(c) * (1 + rng.gauss(0, 0.004)) for c in computes]

    pure = fit_pure_power_law(computes, losses)
    floor_fit, l_inf_hat, alpha_hat = fit_power_law_with_floor(computes, losses)
    print(f"真实规律：L∞ = {TRUE_L_INF}，α = {TRUE_ALPHA}；"
          f"带 L∞ 的拟合得到：L∞ = {l_inf_hat:.3f}，α = {alpha_hat:.3f}\n")

    print(pad("算力 C (FLOPs)", 16) + pad("真实 loss", 12, "right") + pad("观测值", 10, "right")
          + pad("纯幂律拟合", 14, "right") + pad("带 L∞ 拟合", 14, "right") + "   比上一行下降")
    prev = None
    for e in range(18, 30):
        c = 10 ** e
        truth = true_loss(c)
        observed = f"{losses[e - 18]:.3f}" if e < 24 else "（外推）"
        drop = "" if prev is None else f"{prev - truth:.3f}"
        print(pad(f"1e{e}", 16) + pad(f"{truth:.3f}", 12, "right") + pad(observed, 10, "right")
              + pad(f"{pure(c):.3f}", 14, "right") + pad(f"{floor_fit(c):.3f}", 14, "right")
              + "   " + drop)
        prev = truth

    # 稳健性检查：换几组随机噪声重新"做实验"，看拟合出的 L∞ 和 α 有多稳定。
    print("\n换 8 组随机噪声重做实验，带 L∞ 的拟合每次得到的 (L∞, α)：")
    for noise in (0.004, 0.015):
        fits = []
        for _ in range(8):
            noisy = [true_loss(c) * (1 + rng.gauss(0, noise)) for c in computes]
            _, l_hat, a_hat = fit_power_law_with_floor(computes, noisy)
            fits.append((l_hat, a_hat))
        l_values = [f[0] for f in fits]
        a_values = [f[1] for f in fits]
        print(f"  噪声 ±{noise:.1%}：L∞ 在 {min(l_values):.3f} ~ {max(l_values):.3f} 之间，"
              f"α 在 {min(a_values):.3f} ~ {max(a_values):.3f} 之间")

    print("\n观察：")
    print("  · 纯幂律在观测范围内就有系统性的偏差：两头低估、中间高估（残差有规律，不是随机的）。")
    print("    数据在 log-log 图上其实是一条微微弯曲的线，这点弯曲很容易被当成噪声忽略掉。")
    print("  · 外推到 1e29 时，纯幂律预测的 loss 远低于真实值：它假设 loss 会一直按直线降下去，")
    print("    看不到 L∞ 这个下限。带 L∞ 的拟合这次几乎完全正确。")
    print("  · 但稳健性检查显示：噪声变大时，L∞ 和 α 会一起漂移（L∞ 估大时 α 也估大，两者此消彼长）。")
    print("    只靠几个点估计\"天花板在哪里\"，本身就很不确定。")
    print("  · 最右一列：每多 10 倍算力，真实 loss 的下降越来越少。power law 在 log-log 图上是直线，")
    print("    在普通坐标上就是\"收益递减\"。这是\"只靠扩大预训练越来越贵\"的直觉来源。\n")


# =============================================================================
# 演示 2："涌现"可能是度量方式造成的
# =============================================================================
def evaluate(model, problems, rng, n_samples, temperature=1.0):
    """
    在一组题上评估模型：返回 (per-step accuracy, exact match)。

    【思路】每道题采样 n_samples 次：
        · per-step accuracy：所有样本的所有步骤里，"就它自己的输入而言算对了"的比例（Sample.local_ok）。
          这是一个"细粒度、连续"的指标，类似 token-level 的 log-likelihood。
        · exact match：最终答案完全正确的样本比例。这是"全或无"的指标，类似真实评测里的 accuracy。
    【参数】model：ToyLLM；problems：题目列表；rng；n_samples：每题采样次数（≥ 1）；temperature
    【返回】(float, float)
    【边界】problems 为空时 mean 会抛出 ValueError。
    """
    step_ok, exact = [], []
    for p in problems:
        for _ in range(n_samples):
            s = model.generate(p, rng, temperature)
            step_ok.extend(s.local_ok)          # 【Python 小知识】list.extend(另一个序列)：把元素逐个追加进来
            exact.append(s.correct)             # True/False 求平均时当作 1/0
    return mean(step_ok), mean(exact)


def demo_emergence(rng):
    print("=" * 78)
    print("演示 2：同一批模型，三种指标 —— \"涌现\"是不是度量方式造成的？")
    print("=" * 78)
    short = [make_problem(rng, 2, pid=i) for i in range(200)]     # 200 道 2 步题
    long = [make_problem(rng, 10, pid=i) for i in range(200)]     # 200 道 10 步题
    sizes = [10 ** (8 + 0.5 * i) for i in range(9)]                # 100M, 316M, 1B, ..., 1T

    print(pad("参数量", 8) + pad("per-step", 10, "right") + pad("EM(2步)", 10, "right")
          + pad("EM(10步)", 10, "right") + pad("per-step^10", 13, "right")
          + "   EM(10步) 的线性坐标条形图")
    for size in sizes:
        model = ToyLLM(size)
        step_acc, _ = evaluate(model, long, rng, n_samples=5)
        _, em_short = evaluate(model, short, rng, n_samples=5)
        _, em_long = evaluate(model, long, rng, n_samples=5)
        print(pad(fmt_size(size), 8) + pad(f"{step_acc:.3f}", 10, "right")
              + pad(f"{em_short:.3f}", 10, "right") + pad(f"{em_long:.3f}", 10, "right")
              + pad(f"{step_acc ** 10:.3f}", 13, "right") + "   " + bar(em_long, 30))

    print("\n观察：")
    print("  · per-step accuracy 随参数量的对数平稳上升，没有任何\"突变\"。")
    print("  · 10 步题的 exact match 在小模型上几乎为 0，过了某个规模才\"突然\"起来（看条形图）。")
    print("    但它基本就是 per-step^10（对比两列）：一道题要 10 步全对才算对，")
    print("    把一条平滑的曲线做了 10 次方，就变成了一条\"先贴地、再陡升\"的曲线。")
    print("  · 2 步题的 exact match 起步早得多、也平缓得多。同一个能力，换个任务长度，\"涌现点\"就变了。")
    print("  · 结论：\"涌现\"既可能是模型真的变了，也可能是指标的非线性造成的。但对使用者来说，")
    print("    能不能把 10 步全做对才是关心的事 —— 这也是长程任务（long-horizon）这么难的原因。\n")


# =============================================================================
# 演示 3：chain-of-thought 与串行计算
# =============================================================================
def demo_chain_of_thought(rng):
    print("=" * 78)
    print("演示 3：direct answer vs chain-of-thought —— 步数越多，差距越大")
    print("=" * 78)
    sizes = (1e9, 1e11)
    for size in sizes:
        print(f"  {fmt_size(size)} 模型在 direct 模式下一次前向传播能做 "
              f"{ToyLLM(size, cot=False).depth_capacity()} 步")
    print()
    header = pad("步数 k", 8)
    for size in sizes:
        header += pad(f"{fmt_size(size)} direct", 13, "right") + pad(f"{fmt_size(size)} CoT", 11, "right")
    print(header + pad("CoT/direct 的 token 数", 24, "right"))

    for k in range(1, 9):
        problems = [make_problem(rng, k, pid=i) for i in range(150)]
        row = pad(str(k), 8)
        for size in sizes:
            _, em_direct = evaluate(ToyLLM(size, cot=False), problems, rng, n_samples=4)
            _, em_cot = evaluate(ToyLLM(size, cot=True), problems, rng, n_samples=4)
            row += pad(f"{em_direct:.3f}", 13, "right") + pad(f"{em_cot:.3f}", 11, "right")
        tokens_cot = ToyLLM(1e9, cot=True).n_tokens(problems[0])
        tokens_direct = ToyLLM(1e9, cot=False).n_tokens(problems[0])
        print(row + pad(f"{tokens_cot}/{tokens_direct}", 24, "right"))

    print("\n观察：")
    print("  · 步数不超过 direct 模式的串行上限时，direct 和 CoT 差不多（本玩具模型里两者每一步的难度相同）。")
    print("  · 超过上限以后，每多一步就多一个\"几乎只能靠猜\"的步骤，direct 模式迅速下降、最后接近 0；")
    print("    CoT 只随步数平滑下降。")
    print("    CoT 把\"必须在一次前向传播里完成的串行计算\"摊到了很多个 token 上。")
    print("  · 大模型的串行上限更高，所以 direct 模式在更多步数上还能用；但只要步数足够多，CoT 总会胜出。")
    print("  · 代价是 token：k 步题 CoT 要多处理约 10k 个 token。CoT 本身就是一种 test-time compute。")
    print("  · 本玩具模型没有刻画原论文的另一个现象：小模型写出的推理链\"通顺但不合逻辑\"，")
    print("    CoT 反而会让它们变差（Wei et al. 2022）。讲义第 3.2 节会讨论。\n")


def main():
    # 每个演示用自己的随机数生成器：修改一个演示不会改变其他演示的数字。
    demo_scaling_law(random.Random(1))
    demo_emergence(random.Random(2))
    demo_chain_of_thought(random.Random(3))


if __name__ == "__main__":
    main()
