"""
=============================================================================
第 2 讲 · 代码 3：重复数据——同样的算力，把数据看多少遍？
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
去重（代码 02）的一个理由是"重复的数据会伤害模型"。真的吗？伤害有多大？什么时候不伤害？
这个文件用第 1 讲的 tiny GPT（2 层、64 维、约 11 万参数），在**完全相同的算力**下
（640 步 × 32 条 × 96 个字符 ≈ 197 万个字符），只改训练数据：

    演示 1：同样看 197 万个字符，这些字符来自多少不同的数据？
            E = 1（全部是新数据，每行只看一遍）、E = 16、E = 128、E = 1024（一小份数据反复看 1024 遍）。
            比较：验证集 bit/字符（泛化）、训练集 bit/字符（记忆）、加法题——训练里见过的 vs 从没见过的、
            以及采样出来的文本有多少是训练原句（背诵）。
            对照 T5 论文的表 9 和 Muennighoff et al. (2023)：重复几遍几乎免费，重复上千遍才明显变差。
    演示 2：Hernandez et al. (2022) 式的实验——90% 新数据 + 10% 的 token 来自一小撮（40 行）
            反复出现的数据。验证集变差了吗？比"干脆少训练 10%"更差吗？模型背下了那 40 行吗？

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_repeated_data.py          # 第一次约 100 秒（训练 7 个模型，各约 11–14 秒）；之后读缓存，约 25 秒

需要 PyTorch。依赖本目录的 lm_lab.py 和第 1 讲的 fm_world.py、tiny_gpt.py、console.py（只读导入）。
训练好的模型缓存在 code/lecture02/checkpoints/repeat_*.pt，删掉它们会重新训练出完全相同的模型。
"""

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 fm_world import ALPHABET, addition_line, check_line, review_line  # noqa: E402
from lm_lab import WORLD, encode_lines, math_accuracy, natural_lines, train_or_load  # noqa: E402
from tiny_gpt import CharTokenizer, bits_per_char, sample_lines  # noqa: E402

STEPS = 640
TOTAL_CHARS = STEPS * 32 * 96        # 每个模型在训练中"看到"的字符数（步数 × batch × 上下文长度）
EPOCHS = [1, 16, 128, 1024]


def make_pool():
    """
    生成一个"自然数据池"：从第 1 讲的世界里新鲜生成的行，总字符数刚好超过 TOTAL_CHARS。

    【返回】list[str]。E = 1 的模型用整个池子（每行大约只看一遍），E = k 的模型只用池子的前 1/k
    """
    rng = random.Random(0)
    pool, n = [], 0
    while n < TOTAL_CHARS:
        line = natural_lines(1, rng)[0]
        pool.append(line)
        n += len(line) + 1
    return pool


def repeated_corpus(pool, E, seed=5):
    """
    取池子的前 1/E 行作为"唯一数据"，重复 E 遍、打乱，得到训练语料（总量和整个池子差不多）。

    【参数】pool：make_pool() 的结果；E：重复次数（epoch 数）；seed：打乱用的随机种子
    【返回】(unique, lines)：唯一数据，和重复 E 遍、打乱后的训练行
    【思路】总字符数不变 = 算力不变；变的只是"这些字符里有多少是不同的"。
    """
    unique = pool[:max(1, len(pool) // E)]
    lines = unique * E
    random.Random(seed).shuffle(lines)
    return unique, lines


def sample_stats(model, tok, train_set, n=200, seed=0):
    """
    从模型采样 n 行（T = 1），统计：多少是训练原句（背诵）、有多少种不同的行（多样性）。

    【参数】train_set：训练数据里所有行组成的集合
    【返回】(训练原句的比例, 不同行的个数, 样本列表)
    """
    samples = sample_lines(model, tok, n, temperature=1.0, generator=torch.Generator().manual_seed(seed))
    return sum(s in train_set for s in samples) / n, len(set(samples)), samples


def genre_bpc(model, tok, lines):
    """按体裁（用 check_line 判断）分别算 bit/字符。【返回】dict：体裁 -> bpc"""
    groups = {}
    for l in lines:
        groups.setdefault(check_line(WORLD, l)[0], []).append(l)
    return {g: bits_per_char(model, encode_lines(tok, ls)) for g, ls in sorted(groups.items())}


# =============================================================================
# 演示 1：同样的算力，重复多少遍
# =============================================================================
def demo_epochs(tok, pool, val):
    """
    演示 1：同样的训练步数，唯一数据重复 E = 1、16、128、1024 遍（外加一个换数据顺序的噪声参照）。

    【参数】tok：CharTokenizer；pool：make_pool() 的结果；val：新鲜的验证行
    【返回】models：dict，E -> (模型, 唯一数据)，给演示 2 用（E = 1 的模型当基线）
    """
    print_header(f"演示 1：同样的算力（{STEPS} 步 ≈ {TOTAL_CHARS:,} 个字符），这些字符来自多少不同的数据？")
    val_data = encode_lines(tok, val)
    held_out = WORLD.test_pairs[:100]           # 留出的加法题：任何模型都没见过
    rows = []
    models = {}
    # 最后一个是"噪声参照"：和 E = 1 用完全相同的数据，只换 batch 的抽样顺序（data_seed = 2）。
    # 它告诉我们：两个模型之间多大的差别只是训练过程的随机性，不能算作"数据的影响"。
    for E, data_seed in [(E, 1) for E in EPOCHS] + [(1, 2)]:
        unique, lines = repeated_corpus(pool, E)
        name = f"repeat_E{E}" if data_seed == 1 else f"repeat_E{E}_seed{data_seed}"
        model, info = train_or_load(name, tok, lines, STEPS, data_seed=data_seed)
        if data_seed == 1:
            models[E] = (model, unique)
        uniq_set = set(unique)
        train_bpc = bits_per_char(model, encode_lines(tok, unique[:2000]))
        val_bpc = bits_per_char(model, val_data)
        seen_math = []
        for l in unique:
            if "+" in l and l not in seen_math:
                seen_math.append(l)
        seen_pairs = [tuple(int(x) for x in l[:-1].split("=")[0].split("+")) for l in seen_math[:100]]
        copy_rate, n_distinct, samples = sample_stats(model, tok, uniq_set)
        base_rate = sum(l in uniq_set for l in val) / len(val)
        true_rate = sum(check_line(WORLD, x)[2] for x in samples) / len(samples)
        label = str(E) if data_seed == 1 else f"{E}（换数据顺序）"
        rows.append(dict(E=label, chars=sum(len(l) + 1 for l in unique), lines=len(unique), train=train_bpc, val=val_bpc,
                         true=true_rate,
                         seen=math_accuracy(model, tok, seen_pairs), n_seen=len(seen_pairs),
                         held=math_accuracy(model, tok, held_out), copy=copy_rate, base=base_rate,
                         distinct=n_distinct, genres=genre_bpc(model, tok, val)))
    W = 20
    print(pad("重复次数 E", W) + pad("唯一数据", 16, ">") + pad("训练 bpc", 10, ">") + pad("验证 bpc", 10, ">")
          + pad("差距", 8, ">") + pad("加法·见过", 16, ">") + pad("加法·没见过", 12, ">"))
    for r in rows:
        print(pad(r["E"], W) + pad(f"{r['chars']:,} 字符", 16, ">") + pad(f"{r['train']:.3f}", 10, ">")
              + pad(f"{r['val']:.3f}", 10, ">") + pad(f"{r['val'] - r['train']:+.3f}", 8, ">")
              + pad(f"{r['seen']:.2f}（{r['n_seen']} 题）", 16, ">") + pad(f"{r['held']:.2f}", 12, ">"))
    print("（加法·见过：唯一数据里出现过的加法题，最多取 100 道；加法·没见过：100 道留出的题，任何模型都没见过）")
    print("\n按体裁拆开的验证 bpc：")
    genres = list(rows[0]["genres"])
    print(pad("重复次数 E", W) + "".join(pad(g, 9, ">") for g in genres))
    for r in rows:
        print(pad(r["E"], W) + "".join(pad(f"{r['genres'][g]:.3f}", 9, ">") for g in genres))
    print("\n采样 200 行（T = 1）：")
    print(pad("重复次数 E", W) + pad("格式合法且为真", 16, ">") + pad("是训练原句", 12, ">") + pad("基准", 8, ">")
          + pad("不同的行", 10, ">"))
    for r in rows:
        print(pad(r["E"], W) + pad(f"{r['true']:.0%}", 16, ">") + pad(f"{r['copy']:.0%}", 12, ">")
              + pad(f"{r['base']:.0%}", 8, ">") + pad(r["distinct"], 10, ">"))
    print("（基准 = 新鲜生成的验证行里，恰好也出现在这个模型训练数据里的比例。模板世界的句子种类有限，"
          "\n  所以训练数据很大时，几乎任何一句合法的话都\"在训练数据里\"——E = 1 的基准因此接近 100%）")
    _, _, samples = sample_stats(models[1024][0], tok, set(models[1024][1]), n=6, seed=1)
    print("\nE = 1024 的模型随手采样 6 行：" + " | ".join(samples))
    print("""
观察 Observations：
  · 前两行几乎一样：只用 1/16 的数据、每行看 16 遍（12 万个字符），验证 bpc 0.568，和全部用新数据完全相同。
    （同样的数据、只换 batch 的抽样顺序重训一次，得到 0.579——差别在 0.01 左右的，都只是训练过程的随机性。）
    这和 T5 论文的表 9（C4 截短到 2^29 个 token、重复 64 遍，GLUE 82.87 vs 完整数据 83.28）、
    Muennighoff et al. (2023)（最多 4 个 epoch 的重复和全新数据几乎没有区别）一致：**少量重复几乎免费**。
  · 重复 128 遍开始变差（验证 0.621，训练和验证之间拉开 0.133 的差距）。重复 1024 遍（唯一数据只剩
    2,184 个字符），模型几乎背下了全部训练数据（训练 bpc 0.221），验证 bpc 却变成 1.772——是 E = 1 的三倍。
    它"见过"的 8 道加法题答对 75%，没见过的题全错：背住了答案，没学会加法。
    T5 表 9 里重复 1024 遍、4096 遍时，GLUE 也降到了 79.55、76.34。
  · 按体裁看：重复伤害最大的是需要记住**具体事实**的人物介绍和问答（E = 128 时问答从 1.013 升到 1.697），
    评论和故事几乎不受影响——它们只需要规则，而规则在 1.5 万个字符里已经学得到。
    唯一数据少到一定程度，有些人的事实根本不在里面：重复不能创造信息。
  · 采样：E = 1024 的模型生成的 200 行里 42% 是训练原句（对新鲜数据来说这个比例只有 7%），
    只有 142 种不同的行——它在背诵。前几行"是训练原句"的比例反而很低，是因为 640 步的小模型生成的句子
    大多有错（格式合法且为真只有 4–10%）；模板世界里一句正确的话几乎必然"在训练数据里"（基准 95%），
    所以这一列要和基准对照着看。""")
    return models


# =============================================================================
# 演示 2：一小撮数据重复很多遍（Hernandez et al. 式）
# =============================================================================
def demo_small_repeated_subset(tok, pool, val, clean_model):
    """
    演示 2：90% 新数据 + 10% 来自 40 行重复子集；对照组只用那 90% 新数据、训练 90% 的步数。

    【参数】tok、pool、val：同演示 1；clean_model：E = 1 的模型
    【返回】无（只打印）
    """
    print_header("演示 2：90% 新数据 + 10% 的 token 来自 40 行反复出现的数据（Hernandez et al. 2022 式）")
    rng = random.Random(1234)
    test = set(WORLD.test_pairs)
    subset = []
    while len(subset) < 20:          # 20 道加法题 + 20 条评论：都是"高信息量"、不容易碰巧生成出来的行
        a, b = rng.randrange(10, 100), rng.randrange(10, 100)
        line = addition_line(a, b)
        if (a, b) not in test and line not in subset:
            subset.append(line)
    while len(subset) < 40:
        line = review_line(rng)
        if line not in subset:
            subset.append(line)
    sub_chars = sum(len(l) + 1 for l in subset)
    keep, n = [], 0
    for line in pool:                # 前 90% 的字符用新数据
        if n >= 0.9 * TOTAL_CHARS:
            break
        keep.append(line)
        n += len(line) + 1
    reps = round(0.1 * TOTAL_CHARS / sub_chars)
    lines = keep + subset * reps
    random.Random(5).shuffle(lines)
    model, info = train_or_load("repeat_subset10", tok, lines, STEPS)
    # 对照：干脆不要那 10%——只用那 90% 的新数据，训练 90% 的步数（算力也只用 90%）。
    # 如果"重复子集"的模型和它一样好，说明重复的那 10% 只是被浪费了；如果更差，说明重复数据有额外的伤害。
    short_steps = int(STEPS * 0.9)
    control, _ = train_or_load("repeat_fresh90", tok, keep, short_steps)
    share = reps * sub_chars / sum(len(l) + 1 for l in lines)
    print(f"重复子集：{len(subset)} 行（{sub_chars:,} 个字符），每行出现 {reps} 次，占训练字符的 {share:.1%}。\n")
    val_data = encode_lines(tok, val)
    sub_set = set(subset)
    sub_pairs = [tuple(int(x) for x in l[:-1].split("=")[0].split("+")) for l in subset[:20]]
    print(pad("模型", 38) + pad("验证 bpc", 10, ">") + pad("子集 bpc", 10, ">") + pad("子集加法题", 12, ">")
          + pad("样本里是子集原句", 18, ">"))
    trio = ((f"E = 1：100% 新数据，{STEPS} 步", clean_model), (f"90% 新数据 + 10% 重复子集，{STEPS} 步", model),
            (f"对照：只用那 90% 新数据，{short_steps} 步", control))
    for name, m in trio:
        samples = sample_lines(m, tok, 200, temperature=1.0, generator=torch.Generator().manual_seed(0))
        rate = sum(s in sub_set for s in samples) / len(samples)
        print(pad(name, 38) + pad(f"{bits_per_char(m, val_data):.3f}", 10, ">")
              + pad(f"{bits_per_char(m, encode_lines(tok, subset * 3)):.3f}", 10, ">")
              + pad(f"{math_accuracy(m, tok, sub_pairs):.2f}", 12, ">") + pad(f"{rate:.1%}", 18, ">"))
    print("\n按体裁的验证 bpc：")
    gs = [genre_bpc(m, tok, val) for _, m in trio]
    print(pad("模型", 38) + "".join(pad(g, 9, ">") for g in gs[0]))
    for (name, _), g in zip(trio, gs):
        print(pad(name, 38) + "".join(pad(f"{g[k]:.3f}", 9, ">") for k in gs[0]))
    print("""
观察 Observations：
  · 10% 的 token 来自 40 行、每行重复 131 次：验证 bpc 0.574，和 100% 新数据的 0.568 相差不到训练噪声（约 0.01）。
    对照组——干脆不要这 10%，只用那 90% 的新数据、训练 90% 的步数——是 0.604，反而更差。
    所以在这个规模上，重复的那 10% 不但没有"额外伤害"，还起到了和新数据差不多的作用。
  · 原因看"子集 bpc"：模型在这 40 行上是 0.586，只比从没见过它们的 E = 1 模型（0.631）低一点——
    远没有背下来（对比：E = 1024 的模型在自己的训练数据上是 0.221）。子集里的加法题一道也答不对，
    采样里也一次都没出现。131 次重复、只占 10% 的训练信号，对这个 11 万参数、只训练 640 步的模型来说不够背下来。
  · Hernandez et al. (2022) 看到的伤害发生在另一个区间：重复的数据**被背下来**、而且背它占用了模型
    可观的一部分容量。他们发现伤害最大的时候，正是重复数据的训练损失接近 0 的时候；那时把 0.1% 的数据
    重复 100 次（占 10% 的 token），一个 8 亿参数的模型会退化到 4 亿参数模型的水平。
    我们的玩具停在"还没背下来"的一侧，看到的是"重复数据 ≈ 新数据"（讲义练习里有一道题让你往另一侧推）。
  · 演示 1 的 E = 1024 是另一个极端：全部数据都在重复、而且被背下来了——那里伤害巨大。
    合起来看：重复数据的伤害取决于重复了多少遍、占了多大比例、模型有没有把它背下来，而不是"有重复就有害"。""")


if __name__ == "__main__":
    tok = CharTokenizer(ALPHABET)
    pool = make_pool()
    val = natural_lines(2000, random.Random(99))     # 新鲜的验证数据：和训练数据同分布，但单独生成
    models = demo_epochs(tok, pool, val)
    demo_small_repeated_subset(tok, pool, val, models[1][0])
