"""
=============================================================================
第 1 讲 · 代码 5：分词器实验 —— 词表大小、多语言、数字、欠训练 token
=============================================================================

对应讲义：lectures/lecture01_overview_tokenization.md 第 6、11、12 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
有了 04 号文件的 BPE 分词器，我们可以做实验来回答讲义里提出的几个问题：

    实验 1：词表越大，压缩率越高吗？会一直涨下去吗？
    实验 2：分词器的训练语料对"哪种语言更省 token"有多大影响？
    实验 3：数字、大小写、前导空格是怎么被切分的？为什么模型算术会出错？
    实验 4：什么是"欠训练 token"（glitch token 的成因）？
    实验 5：词表大小对模型参数量有什么影响？
    实验 6（可选，需要 tiktoken）：看看真实的 GPT 分词器，并验证我们的实现与 GPT-2 完全一致。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 05_tokenizer_experiments.py

依赖：04_bpe_tokenizer.py（同一目录）。
可选：pip install tiktoken（实验 6 需要；没装会自动跳过）。
      首次使用 tiktoken 时，它需要联网下载 GPT 分词器的词表文件。
"""

# =============================================================================
# 第 0 部分：导入 04 号文件
# =============================================================================
# 【Python 小知识】
#   平时导入模块用 import xxx，但 "04_bpe_tokenizer" 以数字开头，
#   不符合 Python 变量名规则，不能写成 import 04_bpe_tokenizer（会语法错误）。
#   importlib.import_module("04_bpe_tokenizer") 用字符串指定模块名，就可以绕过这个限制。
#   它会在"当前脚本所在目录"里找到 04_bpe_tokenizer.py 并导入。
import importlib
from collections import Counter

bpe = importlib.import_module("04_bpe_tokenizer")

# 把 04 号文件中要用的东西取出来，起个短名字
BPETokenizer = bpe.BPETokenizer
train_bpe_from_text = bpe.train_bpe_from_text
pretty_token = bpe.pretty_token
print_header = bpe.print_header
END_OF_TEXT = bpe.END_OF_TEXT
CORPUS_PATH = bpe.CORPUS_PATH

try:
    import tiktoken
    HAS_TIKTOKEN = True
except ImportError:
    tiktoken = None
    HAS_TIKTOKEN = False


def load_docs():
    """
    读取语料，分成英文文档、中文文档两组。

    【返回】
        (english_docs, chinese_docs)，都是 list[str]
    """
    raw = CORPUS_PATH.read_text(encoding="utf-8")
    docs = [d.strip() for d in raw.split(END_OF_TEXT) if d.strip()]
    english = [d for d in docs if not bpe.is_chinese(d)]
    chinese = [d for d in docs if bpe.is_chinese(d)]
    return english, chinese


def compression(tok, text):
    """压缩率 = 字节数 / token 数。text 不能为空。"""
    return len(text.encode("utf-8")) / len(tok.encode(text))


def train_tokenizer(docs, vocab_size):
    """
    在一组文档上训练 BPE 分词器（文档之间用 <|endoftext|> 分隔）。

    【参数】
        docs       : list[str]
        vocab_size : int，>= 257（256 字节 + 1 个特殊 token）
    【返回】
        BPETokenizer
    """
    text = END_OF_TEXT.join(docs)
    vocab, merges = train_bpe_from_text(text, vocab_size, [END_OF_TEXT])
    return BPETokenizer(vocab, merges, [END_OF_TEXT])


# =============================================================================
# 实验 1：词表大小 vs 压缩率
# =============================================================================
def experiment_vocab_size():
    """
    【思路】
        用同一份训练语料，训练不同词表大小的分词器，在"没见过的"测试文本上测压缩率。
        预期：词表变大 → 压缩率上升，但上升越来越慢（边际收益递减）。

    【边界】
        我们的语料只有约 17 KB，能学到的合并规则有限。
        词表设得太大时，训练会因"没有相邻对可合并"而提前结束（会打印提示），
        此时实际词表比设定值小。真实语料（几十 GB）不会遇到这个问题。
    """
    print_header("实验 1：词表大小 vs 压缩率")
    english, chinese = load_docs()
    train_docs = english[:-1] + chinese[:-1]
    test_en, test_zh = english[-1], chinese[-1]
    train_text = "\n".join(train_docs)

    print(f"{'设定词表':>8}{'实际词表':>8}{'训练集':>8}{'测试·英':>8}{'测试·中':>8}")
    for vs in [257, 300, 400, 600, 1000, 1500, 2000]:
        tok = train_tokenizer(train_docs, vs)
        print(f"{vs:>10}{len(tok.vocab):>10}{compression(tok, train_text):>10.2f}"
              f"{compression(tok, test_en):>10.2f}{compression(tok, test_zh):>10.2f}")
    print("""
观察：
  - 词表 257（只有字节 + 1 个特殊 token）时压缩率恰好是 1.00。
  - 前几百个合并带来的提升最大（最常见的片段先被合并），之后越来越慢。
  - 训练集的压缩率一直在涨，但测试集更早"饱和"：
    词表后期学到的是只在训练集里出现的罕见片段（类似过拟合）。
  - 真实分词器（GPT-2 约 5 万、GPT-4 约 10 万、GPT-4o 约 20 万）是在 TB 级语料上训练的，
    英文压缩率约 4 字节/token。""")


# =============================================================================
# 实验 2：训练语料的语言构成
# =============================================================================
def experiment_language_mix():
    """
    【思路】
        训练两个同样大小的分词器：A 只用英文语料，B 用英文 + 中文语料。
        比较它们在英文和中文测试文本上的 token 数。

    【大模型联系】
        同样的意思，用某种语言表达需要更多 token，就意味着：
          - API 按 token 计费时更贵；
          - 上下文窗口里能放下的内容更少；
          - 生成同样的内容更慢。
        这就是"分词器带来的语言不公平"（Petrov et al., 2023）。
    """
    print_header("实验 2：分词器训练语料的语言构成")
    english, chinese = load_docs()
    test_en, test_zh = english[-1], chinese[-1]

    tok_en = train_tokenizer(english[:-1], 600)
    tok_mix = train_tokenizer(english[:-1] + chinese[:-1], 600)

    print(f"{'分词器':<16}{'英文测试 token 数':>16}{'中文测试 token 数':>16}")
    for name, tok in [("A：只用英文训练", tok_en), ("B：英文+中文训练", tok_mix)]:
        print(f"{name:<16}{len(tok.encode(test_en)):>16}{len(tok.encode(test_zh)):>16}")
    print(f"\n（参考：中文测试文本共 {len(test_zh)} 个汉字/标点，{len(test_zh.encode())} 字节）")
    print("""
观察：
  - 只用英文训练的分词器 A 从没见过汉字，只能把每个汉字拆成 3 个字节 token。
  - 加入中文后，B 在中文上的 token 数明显下降，而英文上只付出了很小的代价。
  → 分词器的训练数据决定了它"偏爱"哪种语言。多语言模型必须在分词器训练语料里
    给各语言足够的比例（这也是 Qwen、GLM 等中文友好模型词表更大的原因之一）。""")


# =============================================================================
# 实验 3：数字、大小写、前导空格
# =============================================================================
def experiment_quirks():
    """
    【思路】
        用我们训练的分词器看看几类"怪现象"。如果装了 tiktoken，也用 GPT-2 看一下。
    """
    print_header("实验 3：数字、大小写、前导空格")
    english, chinese = load_docs()
    tok = train_tokenizer(english + chinese, 1000)

    words = ["hello", " hello", "Hello", " Hello", "HELLO"]
    print("  (a) 同一个词的不同形式（我们的分词器，词表 1000）：")
    for w in words:
        print(f"      {w!r:10} → {tok.token_strings(w)}")

    print("\n  (b) 数字（我们的分词器）：")
    for n in ["7", "12", "27", "100", "2026", "12345"]:
        print(f"      {n!r:8} → {tok.token_strings(n)}")
    print("      我们的语料里数字很少，所以大部分数字只能按单个数字切分。")

    if HAS_TIKTOKEN:
        gpt2 = tiktoken.get_encoding("gpt2")
        cl100k = tiktoken.get_encoding("cl100k_base")
        print("\n  (c) 真实分词器怎么切数字（tiktoken）：")
        print(f"      {'数字':<10}{'GPT-2':<28}{'GPT-4 (cl100k_base)':<28}")
        for n in ["1234", "12345", "123456", "1000000", "2026"]:
            g = [gpt2.decode_single_token_bytes(i).decode() for i in gpt2.encode(n)]
            c = [cl100k.decode_single_token_bytes(i).decode() for i in cl100k.encode(n)]
            print(f"      {n:<10}{str(g):<28}{str(c):<28}")
        print("""      GPT-2 的切法取决于训练语料里哪些数字片段常见（'2026' → '20'+'26'，'1000000' → '1'+'000000'），
      同一个数位在不同数字里可能落在不同 token 的不同位置，这让"对齐数位做加法"变得困难。
      GPT-4 的正则把数字限制为最多 3 位一组（从左往右切），规律性更强；
      Llama 1/2 则干脆把每个数字单独切开。""")

        print("\n  (d) GPT-2 中同一个词的不同形式：")
        for w in words:
            ids = gpt2.encode(w)
            print(f"      {w!r:10} → ids {ids}")
        print("      'hello' 和 ' hello' 是完全不同的 token；'HELLO' 甚至被拆成了 3 个 token。")
    else:
        print("\n  （未安装 tiktoken，跳过 GPT-2 对比。安装：pip install tiktoken）")


# =============================================================================
# 实验 4：欠训练 token
# =============================================================================
def experiment_undertrained_tokens():
    """
    【思路】
        BPE 的中间产物（比如为了得到 ' laughed' 而先合并出的 ' laug'）会留在词表里，
        但编码训练语料时，它们可能几乎不会作为"最终 token"出现
        （因为它们总是被进一步合并成更长的 token）。
        语言模型训练时，一个 token 的嵌入向量只有在它出现时才会被更新；
        出现得极少的 token，其嵌入几乎停留在随机初始化的状态。

    【大模型联系】
        著名的 " SolidGoldMagikarp"：它是 GPT-2/GPT-3 词表里的一个 token（GPT-2 中 id 为 43453），
        据分析来自分词器训练语料中频繁出现的 Reddit 用户名；
        但语言模型的训练数据经过了不同的清洗，这个 token 几乎没出现过。
        结果 GPT-3 看到它时会产生各种离奇的行为（被称为 glitch token）。
        根本原因：**分词器的训练数据和模型的训练数据不一致**。
    """
    print_header("实验 4：欠训练 token（glitch token 的成因）")
    english, chinese = load_docs()
    docs = english + chinese
    tok = train_tokenizer(docs, 1000)

    # 编码全部训练语料，统计每个 token 作为"最终 token"出现了多少次
    usage = Counter()
    for d in docs:
        usage.update(tok.encode(d))

    merged_ids = [i for i in tok.vocab if i > 256]      # 合并产生的 token（跳过字节和特殊 token）
    never = [i for i in merged_ids if usage[i] == 0]
    rare = [i for i in merged_ids if 0 < usage[i] <= 2]
    print(f"  词表中合并产生的 token 共 {len(merged_ids)} 个")
    print(f"  在编码后的训练语料中从未出现：{len(never)} 个，例如 "
          f"{', '.join(pretty_token(tok.vocab[i]) for i in never[:10])}")
    print(f"  只出现 1~2 次：{len(rare)} 个，例如 "
          f"{', '.join(pretty_token(tok.vocab[i]) for i in rare[:10])}")
    print("""
  这些 token 在词表里占了位置（模型要为它们分配嵌入向量和输出层参数），
  却几乎得不到训练信号。在真实的大模型里，如果分词器和模型的训练数据分布不同，
  这个问题会更严重。""")


# =============================================================================
# 实验 5：词表大小与模型参数量
# =============================================================================
def experiment_embedding_params():
    """
    【思路】
        模型有两处参数与词表大小 V 成正比：
          - 输入嵌入矩阵（embedding）：V × d
          - 输出层（语言模型头，LM head）：d × V
        很多小模型会让两者共享参数（weight tying），那就只算一份。

    【例子】
        GPT-2 small：V = 50,257，d = 768，总参数约 1.24 亿（共享嵌入）。
        嵌入矩阵 = 50,257 × 768 ≈ 3,860 万，约占总参数的 31%。
    """
    print_header("实验 5：词表大小对参数量的影响")
    V, d, total = 50257, 768, 124_000_000
    emb = V * d
    print(f"  GPT-2 small：嵌入矩阵 {V:,} × {d} = {emb:,} ≈ 总参数 1.24 亿的 {emb / total:.0%}")

    print(f"\n  一个 d = 512 的小模型（非嵌入参数假设为 2,000 万），不同词表下嵌入参数占比：")
    non_emb = 20_000_000
    print(f"  {'词表大小 V':>12}{'嵌入参数（共享）':>18}{'占总参数':>10}")
    for V in [10_000, 32_000, 50_257, 128_000, 256_000]:
        emb = V * 512
        print(f"  {V:>12,}{emb:>18,}{emb / (emb + non_emb):>10.0%}")
    print("""
  对小模型来说，大词表会让大部分参数都花在嵌入上；
  对几百亿参数的大模型，嵌入的占比就很小了。
  这也是为什么大模型能"负担得起"更大的词表（Llama 3 用了 128K，Gemma 用了 256K）。
  CS336 作业 1 在 TinyStories 上用的词表是 10K，在 OpenWebText 上用的是 32K。""")


# =============================================================================
# 实验 6（可选）：真实分词器 + 验证我们的实现
# =============================================================================
def recover_merges_from_ranks(mergeable_ranks):
    """
    从 tiktoken 的"token → rank"表中恢复出 BPE 合并规则列表。

    【思路】
        tiktoken 只存了每个 token（字节串）的 rank（= 它的 id，也等于它被合并出来的顺序），
        没有直接存"它是由哪两个 token 合并来的"。
        恢复方法：对 rank 为 r 的 token，只使用 rank < r 的合并规则对它的字节做 BPE，
        最后一定恰好剩下两个部分 (a, b) —— 这就是生成它的那一次合并。

    【参数】
        mergeable_ranks : dict[bytes, int]
    【返回】
        list[tuple[bytes, bytes]]，按 rank 排序的合并规则。
    """
    merges = []
    for token, rank in sorted(mergeable_ranks.items(), key=lambda kv: kv[1]):
        if len(token) == 1:
            continue                           # 单字节 token 不是合并出来的
        parts = [bytes([b]) for b in token]
        while True:
            best = None
            for i in range(len(parts) - 1):
                r = mergeable_ranks.get(parts[i] + parts[i + 1])
                if r is not None and r < rank and (best is None or r < best[0]):
                    best = (r, i)
            if best is None:
                break
            i = best[1]
            parts = parts[:i] + [parts[i] + parts[i + 1]] + parts[i + 2:]
        if len(parts) != 2:
            raise ValueError(f"无法恢复 token {token!r} 的合并规则")
        merges.append((parts[0], parts[1]))
    return merges


def experiment_real_tokenizers():
    """
    【思路】
        (a) 比较 GPT-2 / GPT-4 / GPT-4o 分词器在英文和"意思相同的中文"上的 token 数。
        (b) 把 GPT-2 的词表和合并规则装进我们自己写的 BPETokenizer，
            看编码结果是否与 tiktoken（OpenAI 官方实现）完全一致。
            如果一致，就说明我们的预分词、合并顺序、特殊 token 处理全都是对的。
    """
    print_header("实验 6（可选）：真实的 GPT 分词器")
    if not HAS_TIKTOKEN:
        print("  未安装 tiktoken，跳过。安装方法：pip install tiktoken")
        return

    en = "Once upon a time, there was a little girl named Lily. She loved to play outside in the park with her friends."
    zh = "从前，有一个叫莉莉的小女孩。她喜欢和朋友们一起在公园里玩耍。"
    print("  (a) 同一个意思，英文 vs 中文需要多少 token：")
    print(f"      {'分词器':<14}{'词表大小':>10}{'英文 token':>10}{'中文 token':>10}{'中/英':>8}")
    for name, label in [("gpt2", "GPT-2"), ("cl100k_base", "GPT-4"), ("o200k_base", "GPT-4o")]:
        enc = tiktoken.get_encoding(name)
        ne, nz = len(enc.encode(en)), len(enc.encode(zh))
        print(f"      {label:<14}{enc.n_vocab:>10,}{ne:>10}{nz:>10}{nz / ne:>8.2f}")
    print("      → 词表越新越大，对中文越友好；GPT-2 表达同样的意思，中文要多花不少 token。")

    print("\n  (b) 把 GPT-2 的合并规则装进我们的 BPETokenizer，与官方实现对比：")
    gpt2 = tiktoken.get_encoding("gpt2")
    ranks = gpt2._mergeable_ranks          # 下划线开头：tiktoken 的内部属性，将来版本可能变化
    vocab = {r: b for b, r in ranks.items()}
    merges = recover_merges_from_ranks(ranks)
    ours = BPETokenizer(vocab, merges, special_tokens=[END_OF_TEXT])
    print(f"      恢复出 {len(merges):,} 条合并规则（GPT-2 词表 = 256 + 50,000 + 1 个特殊 token）")
    tests = [CORPUS_PATH.read_text(encoding="utf-8"), "Hello, 🌍! 你好!", "DON'T don't 12345   x\n\n\ny"]
    for t in tests:
        a = ours.encode(t)
        b = gpt2.encode(t, allowed_special="all")
        label = (t[:20] + "...") if len(t) > 20 else t
        print(f"      {label!r:28} 我们 {len(a):>5} 个 token，tiktoken {len(b):>5} 个，完全一致：{a == b}")
    sgm = gpt2.encode(" SolidGoldMagikarp")
    print(f"\n      彩蛋：' SolidGoldMagikarp' 在 GPT-2 中是 {len(sgm)} 个 token：{sgm}")


def main():
    experiment_vocab_size()
    experiment_language_mix()
    experiment_quirks()
    experiment_undertrained_tokens()
    experiment_embedding_params()
    experiment_real_tokenizers()
    print("\n全部实验运行完毕。")


if __name__ == "__main__":
    main()
