"""
=============================================================================
第 3 讲 · 代码 1：分词（tokenization）——字符、词、BPE 与 unigram
=============================================================================

对应讲义：lectures/lecture03_architectures_objectives.md 第 1 节

-----------------------------------------------------------------------------
【这个文件要回答的问题 What this file answers】
-----------------------------------------------------------------------------
语言模型是"token 序列上的概率分布"。可是原始数据是一个字符串——谁来决定"token"是什么？
这就是分词器（tokenizer）的工作。第 1 讲为了简单，直接把每个字符当成一个 token。这个文件回答：

    演示 1：为什么不能简单地"按空格切"？在迷你互联网上比较三种粒度（字符 / 空格切分 / 词 + 标点）
            各需要多大的词表、序列有多长、遇到没见过的词怎么办。
    演示 2：BPE（byte pair encoding）：先逐步复现 CS324 课程笔记里的小例子（the car / the cat / the rat），
            再在迷你互联网上训练：合并次数 ↔ 词表大小 ↔ 序列长度；学到的前几个合并是什么；
            数字为什么被切得不一致；没见过的名字怎样被拆成碎片。
    演示 3：unigram 分词（SentencePiece 用的模型）：复现笔记里 "ababc" 的似然 4/9；
            同一个字符串有多种切法，每种切法有一个概率，Viterbi 选概率最大的那种。
    演示 4：第 1 讲为什么用字符？代价是什么：序列长度、注意力的 n² 开销、上下文能装下几行、嵌入表的大小。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 01_tokenization.py          # 约 2 秒

只用 Python 标准库（不需要 PyTorch）；只读地导入第 1 讲的 fm_world.py、console.py。
在 Python 3.9 和 3.13 下输出逐字相同。
"""

import math
import os
import re
import sys
from fractions import Fraction

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, build_world             # noqa: E402

# GPT-2 风格的"预分词"（pre-tokenization）：先按规则把一行切成小块，BPE 只在块的内部合并。
# 规则：空格贴在后面那个词的开头（" lives"），字母一块、数字一块、标点一块。
# 【Python 小知识】正则里 | 表示"或"；" ?" 表示"可以有一个空格，也可以没有"；
#                [a-z]+ 是一个或多个小写字母；[^a-z0-9\s]+ 是一个或多个"既不是字母数字、也不是空白"的字符。
PRETOKEN_RE = re.compile(r" ?[a-z]+| ?[0-9]+| ?[^a-z0-9\s]+|\s+")
WORD_RE = re.compile(r"[a-z]+|[0-9]+|[^a-z0-9\s]")       # "词 + 标点"：去掉空格，标点单独成词
K_SHOW = 300      # 演示 2、3、4 展示分词结果时用前多少条 BPE 合并规则（词表 = 44 + 300 = 344）


def vis(tok):
    """把 token 里的空格显示成 ␣，方便看清"空格贴在哪个词上"。"""
    return tok.replace(" ", "␣").replace("\n", "⏎")


# =============================================================================
# 演示 1：为什么不能按空格切
# =============================================================================
def demo_granularity(world):
    print_header("演示 1：三种粒度 —— 字符、空格切分、词 + 标点")
    print("CS324 笔记里的反例（按空格切）：")
    for s in ["我今天去了商店。", "father-in-law and mother-in-law", "don't"]:
        print(f"  {s!r:<36} → {s.split(' ')}")

    lines = world.train_lines
    val = world.val_lines
    n_chars = sum(len(l) + 1 for l in lines)                 # +1：每行末尾的 "\n" 也是一个 token
    space_tok = [t for l in lines for t in l.split(" ")]
    word_tok = [t for l in lines for t in WORD_RE.findall(l)]
    rows = [
        ("字符（第 1 讲）", len(ALPHABET), n_chars, set(ALPHABET), [c for l in val for c in l]),
        ("按空格切", len(set(space_tok)) + 1, len(space_tok) + len(lines), set(space_tok),
         [t for l in val for t in l.split(" ")]),
        ("词 + 标点", len(set(word_tok)) + 1, len(word_tok) + len(lines), set(word_tok),
         [t for l in val for t in WORD_RE.findall(l)]),
    ]
    print(f"\n迷你互联网训练集 {len(lines):,} 行。词表大小、序列长度，以及验证集里\"训练时没见过的 token\"（OOV）的比例：\n")
    print(pad("粒度", 18) + pad("词表大小", 10, ">") + pad("token 总数", 12, ">") + pad("每行 token", 12, ">")
          + pad("验证集 OOV", 12, ">"))
    for name, vocab, n_tok, seen, val_toks in rows:
        oov = sum(t not in seen for t in val_toks) / len(val_toks)
        print(pad(name, 18) + pad(f"{vocab:,}", 10, ">") + pad(f"{n_tok:,}", 12, ">")
              + pad(f"{n_tok / len(lines):.1f}", 12, ">") + pad(f"{oov:.2%}", 12, ">"))
    seen_space = set(space_tok)
    oov_tokens = [t for l in val for t in l.split(" ") if t not in seen_space]
    oov_examples = sorted(set(oov_tokens))[:6]
    n_math = sum("+" in t for t in oov_tokens)
    print(f"\n按空格切时，验证集里没见过的 token 共 {len(oov_tokens)} 个，其中 {n_math} 个是整道加法题。举例：{oov_examples}")
    print("（\"词表大小\"里的 +1 是行尾的换行符；字符级的词表是第 1 讲固定的 44 个字符。）")
    new_line = "zubek lives in varno."
    print(f"\n一个训练集里没有的名字：{new_line!r}")
    print(f"  词级分词 → {['[UNK]' if t not in set(word_tok) else t for t in WORD_RE.findall(new_line)]}")
    print(f"  字符级   → {list(new_line)[:8]} …（任何字符串都能表示）")
    print("""
观察 Observations：
  · 按空格切，标点粘在词上："varno." 和 "varno" 是两个不同的 token；一道加法题 "11+21=32." 整个是一个 token。
    结果词表是"词 + 标点"的 5 倍多，验证集里约 3% 的 token 训练时从没见过（78 个全是整道的加法题）——
    模型对它们一无所知。真实语言更糟：中文不用空格，德语有很长的复合词，英语有 don't、father-in-law。
  · 任何"词级"分词器都要面对**开放词表**问题：新名字、新数字、拼写错误都会变成 [UNK]。
    字符级永远不会遇到没见过的 token，代价是序列长了好几倍（每行约 38 个 token，而词 + 标点约 11 个）。
  · 子词（subword）分词器（BPE、unigram）就是在这两个极端之间找平衡：常见的词是一个 token，
    罕见的词拆成几块，最坏情况退回到字符（或字节），所以没有 OOV。""")


# =============================================================================
# 演示 2：BPE
# =============================================================================
def count_pairs(words):
    """
    统计相邻 token 对出现的次数（按"块"的出现次数加权）。

    【参数】words：list[[symbols, count]]，symbols 是一个块当前的 token 列表
    【返回】dict：(a, b) -> 次数。dict 按"第一次出现"的顺序排列（Python 3.7 起 dict 保持插入顺序），
            所以出现次数相同时，选最早出现的那一对——这让结果完全确定，也复现了课程笔记的选择。
    """
    pairs = {}
    for syms, cnt in words:
        for a, b in zip(syms, syms[1:]):
            pairs[(a, b)] = pairs.get((a, b), 0) + cnt
    return pairs


def merge_pair(syms, pair):
    """在一个 token 列表里，从左到右把所有相邻的 (a, b) 合并成 a+b。"""
    out, i = [], 0
    while i < len(syms):
        if i + 1 < len(syms) and (syms[i], syms[i + 1]) == pair:
            out.append(syms[i] + syms[i + 1])
            i += 2
        else:
            out.append(syms[i])
            i += 1
    return out


def train_bpe(chunk_counts, n_merges=None, on_merge=None):
    """
    训练 BPE：从单个字符出发，反复把"出现次数最多的相邻一对"合并成一个新 token。

    【思路】(Sennrich et al. 2016)
        1. 每个块（pre-token，例如 " lives"）先拆成字符；
        2. 统计所有相邻对的出现次数，选最多的那一对，在所有块里合并它，记下这条"合并规则"；
        3. 重复，直到合并了 n_merges 次（或者再也没有可以合并的对）。
        学到的是一张有序的合并规则表；词表 = 初始字符 + 每条规则产生的新 token。
    【参数】chunk_counts：dict 块 -> 出现次数；n_merges：最多合并几次（None = 合并到不能再合并）；
            on_merge：可选回调 on_merge(k, words)，每次合并之后调用（用来记录序列长度的变化）
    【返回】merges：list[(a, b)]，按学到的先后顺序
    【边界】块不能跨越——BPE 只在预分词切出的块内部合并（所以 " the" 和 "." 永远不会合成一个 token）。
    """
    words = [[list(ch), cnt] for ch, cnt in chunk_counts.items()]
    merges = []
    while n_merges is None or len(merges) < n_merges:
        pairs = count_pairs(words)
        if not pairs:
            break
        best = max(pairs.values())
        pair = next(p for p, c in pairs.items() if c == best)      # 并列时取最早出现的
        merges.append(pair)
        for w in words:
            w[0] = merge_pair(w[0], pair)
        if on_merge is not None:
            on_merge(len(merges), words)
    return merges


def bpe_encode_chunk(chunk, ranks):
    """
    用学好的合并规则给一个块分词：反复找"排名最靠前"（最早学到）的那一对来合并。

    【参数】chunk：字符串；ranks：dict (a, b) -> 这条规则的序号
    【返回】token 列表
    【边界】没见过的字符串也能分词：最坏情况就是一个字符一个 token（不会有 OOV）。
    """
    syms = list(chunk)
    while len(syms) > 1:
        cands = [(ranks[p], p) for p in zip(syms, syms[1:]) if p in ranks]
        if not cands:
            break
        syms = merge_pair(syms, min(cands)[1])
    return syms


def bpe_encode(line, ranks):
    """一整行：先预分词，再对每个块用 BPE。"""
    return [t for ch in PRETOKEN_RE.findall(line) for t in bpe_encode_chunk(ch, ranks)]


def demo_bpe(world):
    print_header("演示 2：BPE —— 反复合并最常见的相邻一对")

    # (a) 课程笔记的小例子：三个"句子"，空格单独成块（不参与合并）
    corpus = ["the car", "the cat", "the rat"]
    counts = {}
    for s in corpus:
        for ch in re.findall(r"[a-z]+| ", s):
            counts[ch] = counts.get(ch, 0) + 1
    print("(a) CS324 笔记的例子：语料 = " + " / ".join(corpus))
    state = {}

    def show_step(k, words):
        state[k] = [" ".join(vis(t) for t in w[0]) for w in words]

    merges = train_bpe(counts, n_merges=3, on_merge=show_step)
    for k, (a, b) in enumerate(merges, 1):
        print(f"  第 {k} 次合并：{a} + {b} → {a + b:<5}   块现在是：{' | '.join(state[k])}")
    vocab = sorted({c for s in corpus for c in s if c != " "}) + [a + b for a, b in merges]
    ranks = {p: i for i, p in enumerate(merges)}
    print(f"  词表：{vocab}")
    print(f"  给新文本 \"the ox\" 分词：{[vis(t) for t in bpe_encode_chunk('the', ranks)] + ['␣'] + bpe_encode_chunk('ox', ranks)}")

    # (b) 在迷你互联网上训练
    lines = world.train_lines
    chunk_counts = {}
    for l in lines:
        for ch in PRETOKEN_RE.findall(l):
            chunk_counts[ch] = chunk_counts.get(ch, 0) + 1
    n_lines = len(lines)
    n_chars = sum(len(l) + 1 for l in lines)
    record = {0: sum(len(ch) * c for ch, c in chunk_counts.items())}

    def log_len(k, words):
        record[k] = sum(len(w[0]) * w[1] for w in words)

    merges = train_bpe(chunk_counts, on_merge=log_len)
    print(f"\n(b) 在迷你互联网上训练：{len(chunk_counts)} 种不同的块；一直合并到不能再合并，共 {len(merges)} 次。")
    print("  前 12 次合并（␣ = 空格）：")
    for k, (a, b) in enumerate(merges[:12], 1):
        print(f"    {k:>2}. {vis(a)} + {vis(b)} → {vis(a + b)}")
    print("\n" + pad("合并次数 K", 12) + pad("词表大小", 10, ">") + pad("token 总数", 13, ">")
          + pad("字符 / token", 14, ">") + pad("每行 token", 12, ">") + pad("96 个 token 装几行", 20, ">"))
    for k in [0, 10, 30, 60, 100, 200, 300, len(merges)]:
        n_tok = record[k] + n_lines                           # + 每行一个换行符
        per_line = n_tok / n_lines
        print(pad(f"{k}", 12) + pad(f"{len(ALPHABET) + k}", 10, ">") + pad(f"{n_tok:,}", 13, ">")
              + pad(f"{n_chars / n_tok:.2f}", 14, ">") + pad(f"{per_line:.1f}", 12, ">")
              + pad(f"{96 / per_line:.1f}", 20, ">"))

    K = K_SHOW
    ranks = {p: i for i, p in enumerate(merges[:K])}
    print(f"\n  用前 K = {K} 条规则分词（每个 token 用 | 隔开）：")
    examples = [lines[4], lines[2], "37+45=82.", "95+42=137.", "zubek lives in varno.", "kari livs in varno."]
    for s in examples:
        print(f"    {s:<62} → |{'|'.join(vis(t) for t in bpe_encode(s, ranks))}|")
    pieces = {}
    for n in range(10, 199):
        k = len(bpe_encode_chunk(str(n), ranks))
        pieces[k] = pieces.get(k, 0) + 1
    print(f"\n  数字 10–198（加法题里会出现的所有数）在 K = {K} 时：" +
          "、".join(f"{v} 个切成 {k} 块" for k, v in sorted(pieces.items())) + "。")
    ex = [f"{n}→{'|'.join(bpe_encode_chunk(str(n), ranks))}" for n in (45, 82, 95, 107, 137, 142, 198)]
    print("  例如：" + "  ".join(ex))
    full = {p: i for i, p in enumerate(merges)}
    split_full = [n for n in range(10, 199) if len(bpe_encode_chunk(str(n), full)) > 1]
    shown = "  ".join(f"{n}→{'|'.join(bpe_encode_chunk(str(n), full))}" for n in split_full)
    print(f"  合并到底时仍被切开的数（它们从没在训练语料里出现过）：{shown}")
    print("""
观察 Observations：
  · 笔记的例子逐步复现：th（3 次）→ the（3 次）→ ca（2 次），词表 [a, c, e, h, r, t, th, the, ca]；
    新文本 "the ox" 里 the 是一个 token，ox 没见过，就退回到单个字符——BPE 没有 OOV。
    （这个例子里空格单独成块、不参与合并，所以 the 不会和后面的空格合在一起。）
  · 迷你互联网上，最先学到的是最高频的片段：he → the → ␣w → ␣the → ␣was……（评论和故事里 the、was 最多）。
    合并越多，词表越大，序列越短：K = 300 时每行约 12 个 token；合并到底约 11 个，是字符级的 1/3.4。
    合并到底时每个块都变成一个 token——因为迷你互联网是模板生成的，只有 410 种块；
    真实文本的块是长尾的，GPT-2 / GPT-3 在约 5 万个 token 时停下，罕见的词依然被拆成几块。
  · K = 300 时，高频词（␣quila、␣polite、␣rating）已经是一个 token，而名字（每人只出现在几十行里）还是碎片：
    zu|ku|n、k|ar|i。**分词器把整块的 token 留给高频的东西**，低频的东西模型要从碎片里拼出来。
  · **数字被切得不一致**：K = 300 时，95 是一个 token，45 被切成 4|5；107 切成 10|7，137 切成 13|7，198 切成 1|9|8。
    同一个数位在不同的数里落在不同的 token 位置上，模型做加法时对齐数位就更难——这被认为是 GPT-3 这类
    BPE 模型算术不稳定的原因之一；后来的很多模型（PaLM、LLaMA 等）把数字强制拆成单个数位。
  · 合并到底以后，训练语料里出现过的数都是一个 token，没出现过的 195、196、198 却被切成两块：
    分词结果依赖于分词器的训练语料，"见过"和"没见过"的东西连表示方式都不一样。
  · 拼错的 livs 被拆成 ␣li|v|s，和正确的 ␣lives（一个 token）没有任何共同的 token——
    分词对拼写错误很敏感，一个字母之差，模型看到的 token 序列就完全不同。字符级模型没有这个问题。""")
    return merges


# =============================================================================
# 演示 3：unigram 分词
# =============================================================================
def segmentations(s, vocab):
    """
    枚举字符串 s 用 vocab 里的 token 拼出来的所有切法。

    【返回】list[list[str]]，按字典序排序
    【边界】切法的个数随长度指数增长，只适合很短的字符串（这里最多十来个字符）。
    """
    if not s:
        return [[]]
    out = []
    for end in range(1, len(s) + 1):
        head = s[:end]
        if head in vocab:
            out += [[head] + rest for rest in segmentations(s[end:], vocab)]
    return sorted(out)


def demo_unigram(world, merges):
    print_header("演示 3：unigram 分词 —— 每种切法都有一个概率，取概率最大的那种")
    tokens = ["ab", "ab", "c"]
    p = {"ab": Fraction(2, 3), "c": Fraction(1, 3)}
    like = p["ab"] * p["ab"] * p["c"]
    print("(a) 笔记的例子：训练数据 \"ababc\"，切成 ab | ab | c（区间 (1,2)、(3,4)、(5,5)），词表 {ab, c}")
    print(f"    用这次切分里的频率当概率：p(ab) = {p['ab']}，p(c) = {p['c']}")
    print(f"    似然 = {' × '.join(str(p[t]) for t in tokens)} = {like} ≈ {float(like):.3f}"
          f"（【注意】课程笔记里写成了 4/9，是一个算术笔误）")
    # 【Python 小知识】fractions.Fraction 做精确的分数运算：Fraction(2, 3) * Fraction(1, 3) == Fraction(2, 9)，
    # 不会有浮点误差，适合核对这种手算的例子。

    q = {"ab": Fraction(1, 2), "c": Fraction(1, 4), "a": Fraction(1, 8), "b": Fraction(1, 8)}
    print("\n    如果词表是 {a, b, c, ab}，概率 p(ab) = 1/2、p(c) = 1/4、p(a) = p(b) = 1/8，\"ababc\" 有 4 种切法：")
    for seg in segmentations("ababc", q):
        pr = Fraction(1)
        for t in seg:
            pr *= q[t]
        print(f"      {pad(' | '.join(seg), 16)} 概率 {str(pr):>7} = {float(pr):.5f}")
    print("    Viterbi（动态规划）直接找到概率最大的切法 ab | ab | c，不用枚举所有切法。")

    # (b) 在迷你互联网上：用 BPE 前 K 条规则产生的 token 当词表，用它们在语料里的频率当 unigram 概率
    K = K_SHOW
    ranks = {pr: i for i, pr in enumerate(merges[:K])}
    freq = {}
    for l in world.train_lines:
        for t in bpe_encode(l, ranks):
            freq[t] = freq.get(t, 0) + 1
    for c in ALPHABET:
        freq.setdefault(c, 1)            # 每个字符至少给 1 次，保证任何字符串都能切
    total = sum(freq.values())
    logp = {t: math.log(c / total) for t, c in freq.items()}
    print(f"\n(b) 迷你互联网：词表 = BPE 前 {K} 条规则分词后实际用到的 {len(freq)} 个 token，"
          f"概率 = 它们在分词后的语料里的频率（相当于做了一轮 unigram 的概率估计）。")
    for word in [" zukun", " kari", " varno", " livs"]:
        segs = segmentations(word, logp)
        scored = sorted(((math.fsum(logp[t] for t in sg), sg) for sg in segs), key=lambda x: (-x[0], x[1]))
        z = math.fsum(math.exp(s) for s, _ in scored)
        bpe = bpe_encode_chunk(word, ranks)
        top = "  ".join(f"{'|'.join(vis(t) for t in sg)} ({math.exp(s) / z:.3f})" for s, sg in scored[:3])
        print(f"  {pad(vis(word), 8)} BPE：{pad('|'.join(vis(t) for t in bpe), 12)} "
              f"unigram 概率最高的 3 种切法（在所有切法里归一化）：{top}")
    print("""
观察 Observations：
  · unigram 模型（Kudo 2018，SentencePiece 的默认算法之一，T5 用的就是它）把分词本身当成一个概率模型：
    一个字符串的每种切法都有概率 = 各个 token 概率的乘积；分词时取概率最大的切法（Viterbi）。
    训练时从一个很大的候选词表出发，用 EM 估计概率，再删掉"删了以后似然下降最少"的那部分 token，反复进行。
  · BPE 是"按学到的顺序贪心地合并"，unigram 是"在所有切法里挑概率最大的"。多数词两者一样（zukun、kari、varno）。
    拼错的 livs 不一样：BPE 按规则先合出 ␣li（它是合成 ␣lives 的中间一步），而在分词后的语料里 ␣li 从不单独出现，
    不在 unigram 的词表里，unigram 只能切成 ␣|l|i|v|s。对没见过的字符串，两种算法可以给出不同的切法。
  · unigram 还给出了**其他切法的概率**：␣zukun 的头两种切法概率约是 2 : 1。训练时可以按概率随机采样切法
    （subword regularization），让模型见到同一个词的不同切法，对分词的偶然性更鲁棒。""")


# =============================================================================
# 演示 4：字符级的代价
# =============================================================================
def demo_costs(world, merges):
    print_header("演示 4：第 1 讲为什么用字符？代价是什么")
    lines = world.train_lines
    n_lines = len(lines)
    rows = []
    for name, k in [("字符（第 1 讲）", 0), (f"BPE K = {K_SHOW}", K_SHOW), ("BPE 合并到底", len(merges))]:
        ranks = {p: i for i, p in enumerate(merges[:k])}
        n_tok = sum(len(bpe_encode(l, ranks)) + 1 for l in lines)
        rows.append((name, len(ALPHABET) + k, n_tok / n_lines))
    print(pad("分词方式", 18) + pad("词表 V", 8, ">") + pad("每行 token n", 14, ">") + pad("注意力 n²/行", 14, ">")
          + pad("嵌入表 V×64", 14, ">") + pad("96 个 token 装几行", 20, ">"))
    for name, V, n in rows:
        print(pad(name, 18) + pad(f"{V}", 8, ">") + pad(f"{n:.1f}", 14, ">") + pad(f"{n * n:,.0f}", 14, ">")
              + pad(f"{V * 64:,}", 14, ">") + pad(f"{96 / n:.1f}", 20, ">"))
    ratio = rows[0][2] / rows[1][2]
    print(f"\n同一行文本，字符级的 token 数是 BPE（K = {K_SHOW}）的 {ratio:.1f} 倍，注意力矩阵的元素个数是它的 {ratio ** 2:.1f} 倍。")
    gpt3_emb = 50257 * 12288
    print(f"对照：GPT-3 的词表 50,257 个 BPE token、宽度 12,288，嵌入表 {gpt3_emb:,} 个参数，"
          f"占 1750 亿的 {gpt3_emb / 175e9:.2%}。")
    print(f"第 1 讲 base model：字符嵌入 44 × 64 = {44 * 64:,} 个参数（占 109,056 的 {44 * 64 / 109056:.2%}），"
          f"位置嵌入 96 × 64 = {96 * 64:,} 个（{96 * 64 / 109056:.2%}）。")
    print("""
观察 Observations：
  · 字符级的好处：词表极小（44），不用训练分词器，永远没有 OOV，模型能直接看到每个字母（拼写、数字的每一位）。
  · 代价一：**序列长**。同一行文本，字符级的 token 数约是 BPE（K = 300）的 3 倍；自注意力的计算量随 n² 增长，
    每行的注意力开销约是 10 倍；生成一行也要多走 3 倍的步数。
  · 代价二：**上下文装得少**。base model 的上下文是 96 个字符，只装得下约 2.6 行；换成 BPE，同样 96 个位置
    能装下约 8 行。上下文学习（第 5 讲）要在上下文里放示例，这一点很关键。
  · 代价三：**大部分预测很琐碎**。字符级模型的多数位置是在"把单词拼完"，真正有信息量的预测
    （住在哪个城市、评分是什么）只占一小部分位置。所以第 1 讲用 bit/字符而不是 token 级困惑度——
    不同分词方式的 token 级困惑度不能直接比较（第 1 讲练习 3）。
  · 嵌入表的大小在真实模型里不是问题：GPT-3 的 5 万词表只占参数的约 0.35%。我们的小模型里，
    字符嵌入和位置嵌入合起来也只占约 8%。
  · 真实系统的选择是折中：GPT-2 / GPT-3 用字节级 BPE（先把文本变成 UTF-8 字节，任何语言、任何符号都能表示），
    T5 用 SentencePiece。第 1 讲选字符，是因为它最简单、最透明；对一个只有 26 万字符的迷你世界，
    序列长的代价也承受得起。""")


if __name__ == "__main__":
    world = build_world()
    demo_granularity(world)
    merges = demo_bpe(world)
    demo_unigram(world, merges)
    demo_costs(world, merges)
