[stanford-cs336] Assignment-1 Chapter-2

Reference https://github.com/stanford cs336/assignment1 basics/tree/maingithub.com/stanford cs336/assignment1 basics/tree/maingithub.com/stanford cs336/assignment1 basics/tree/main

Reference

https://github.com/stanford-cs336/assignment1-basics/tree/maingithub.com/stanford-cs336/assignment1-basics/tree/maingithub.com/stanford-cs336/assignment1-basics/tree/main

Setup

下载并安装环境

git clone https://github.com/stanford-cs336/assignment1-basics.git
cd assignment1-basics
uv self update # optional
uv sync # 如果没安装uv,就去看我的uv文章
source .venv/bin/activate

先测试一下,应该是全部fail

uv run pytest

下载数据

  • TinyStories [Eldan and Li, 2023]
  • OpenWebText [Gokaslan et al., 2019]

新建一个download_data.sh文件,把下面的代码复制进去

#!/bin/bash

# Create data directory and enter it
mkdir -p data
cd data

# Download TinyStories dataset
wget https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-train.txt
wget https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-valid.txt

# Download and extract OWT sample dataset
wget https://huggingface.co/datasets/stanford-cs336/owt-sample/resolve/main/owt_train.txt.gz
gunzip -f owt_train.txt.gz

wget https://huggingface.co/datasets/stanford-cs336/owt-sample/resolve/main/owt_valid.txt.gz
gunzip -f owt_valid.txt.gz

# Go back to previous directory
cd ..

然后

chmod +x download_data.sh # 改成可以执行的文件
./download_data.sh # 运行它

第二章 BPE分词器

2.1 Unicode standard

a. chr(0) returns '\x00' (其实代表的字符是null)

chr()会返回integer(unicode十进制编码)所对应的字符,比如chr(100)会返回d

ord('牛') 会返回字符的Unicode 编码。但用string.encode('utf-8')会返回bytes

ord() 和 .encode('utf-8')的区别

添加图片注释,不超过 140 字(可选)

ord会返回unicode编码,string.encode('utf-8')会返回utf8编码的三个bytes对应的数字。

bytes([raw byte value])会返回这个单独byte对应的字符。

bytes([0]) # []放的是raw byte value, 返回0所对应的十六进制bytes

注意bytes([255])和chr(255)返回的完全不一样。255在bytes里是ff, 在unicode里如果用utf-8编码,可以用两个bytes表示:

添加图片注释,不超过 140 字(可选)

建一个vocab dict

vocab = {i:bytes([i]) for i in range(256)}

添加图片注释,不超过 140 字(可选)

b. chr(0) is invisible when printed but its repr() shows it as the escape sequence '\x00'

Control character(控制字符) 是一种在 Unicode 或 ASCII 编码中用于控制文本的显示、格式或传输行为,而不是表示可见字符的特殊字符。

添加图片注释,不超过 140 字(可选)

c.

"this is a test" + a + "string"  # return this is a test\x00string
print("this is a test" + chr(0) + "string") # return print("this is a test" + chr(0) + "string")

这个行为像是class里的repr和str

class MyClass:
    def __repr__(self):
        return "repr output"

    def __str__(self):
        return "str output"

b = MyClass()
b # return repr output
print(b) # return str output

2.2 Unicode encodings

a) UTF-8 is preferred for tokenizer training because it’s compact for common characters (like ASCII), avoids surrogate complexities of UTF-16, and allows modeling with a fixed 256-token byte vocabulary. It also aligns with web standards and real-world data encoding.

下面是一个不同UTF之间的比较:

添加图片注释,不超过 140 字(可选)

b.

decode_utf8_bytes_to_str_wrong("€".encode("utf-8"))  

This function is incorrect because it decodes each byte individually rather than decoding the full multibyte UTF-8 sequence (e.g. '€' is encoded as three bytes: 0xE2 0x82 0xAC), which leads to a UnicodeDecodeError or incorrect characters.

c. Example: b'\xc3\x28' . This is an invalid UTF-8 sequence because 0xC3 indicates the start of a 2-byte character, but 0x28 is not a valid continuation byte (it doesn't start with 10xxxxxx).

连续的byte需要二进制以10为开头

添加图片注释,不超过 140 字(可选)

以10为开头的有8,9,A,B,这些是有效的连续字节

添加图片注释,不超过 140 字(可选)

Unicode

bit就是二进制里的最小单位,全称是 binary digit(二进制数字)。

一个byte是8个bit

8 bit (8个0或1的数) 可以表达256 个值,因为2^8=256

添加图片注释,不超过 140 字(可选)

每个字符在 Unicode 表里都有一个唯一编号(code point),例如:

 'A' → U+0041 → 十进制 65
 '你' → U+4F60 → 十进制 20320

但仅靠这个整数编号,计算机并不知道怎么把它“存成字节”或“传给别人”,还需要一套编码方式把它转换为具体的字节序列,比如 UTF-8、UTF-16、UTF-32。

相比string,bytes of letters占用的内存更少。

添加图片注释,不超过 140 字(可选)

十六进制

ord('你')       # 20320
hex(ord('你'))  # '0x4f60'
# 对应 Unicode 标准写法:U+4F60

十六进制是以 16 为基数的数制,用 0–9 和 A–F 来表示数字

添加图片注释,不超过 140 字(可选)

两个十六进制数字正好等于一个byte

十六进制格式,以 0x 开头

hex(10)       # 输出 '0xA' 或 '0xa'
hex(65)       # 输出 '0x41'
hex(20320)    # 输出 '0x4f60'

0x 是 Python(和大多数编程语言)中表示“十六进制数”的前缀

U+是Unicode 标准的官方写法

\x 是 “十六进制转义符”,在 Python 字符串或字节串中用来表示一个字节(8 位),后面必须紧跟 两位十六进制数。格式:\xHH

b'\x41'    # 表示 ASCII 字母 'A',因为 0x41 = 65
b'\xe4'    # 表示 UTF-8 编码中一个字节(汉字的一部分)

一些例子:

0x4F60

4F60 = 4×16³ + F×16² + 6×16¹ + 0×16⁰
     = 4×4096 + 15×256 + 6×16 + 0
     = 16384 + 3840 + 96 + 0
     = 20320

0xFFFF

15×16e3+15×16e2+15×16e1+15=65535

0xFFFF 是 Basic Multilingual Plane(基本多文种平面,BMP)中的最后一个码点。 它常被视为普通 Unicode 字符的最大值。

十六进制转十进制

int('0x4f60', 16)  # 输出 20320
int('BD', 16)    # 输出 189

十六进制范围:

添加图片注释,不超过 140 字(可选)

UTF-8

UTF-8中的8指的是:

每个编码单元(code unit)是 8 位(bit),也就是 1 字节(byte)

添加图片注释,不超过 140 字(可选)

ASCII

128 characters: letters (A–Z, a–z), digits (0–9), punctuation, and control characters.

Each character is represented using 1 byte, 比如'A' → 0x41

前 128个 Unicode codepoints (U+0000 to U+007F) are identical to ASCII

所以 characters like 'h', 'e', 'l', 'o', etc., are 1 byte in both ASCII and UTF-8.

但对于'€' (U+20AC) needs 3 bytes in UTF-8: 0xE2 0x82 0xAC 如果每个单独decode会报错,需要一起。

UTF-8字节规则:

添加图片注释,不超过 140 字(可选)

2.5a Train BPE

a. BPE Tokenizer Training

要求

  • vocab 从256 个byte value开始
  • Pre-tokenization: 不用string.split(" "), 而用gpt2的pre-tokenizer,下面是一个执行的例子:
import regex as re

PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""

# 会把每个词放在一个list里占内存
re.findall(PAT, "some text that i'll pre-tokenize") 

# 用re.finditer
for m in re.finditer(PAT, text):
   word = m.group(0)

用regex package (不是import re哦,regex在re的基础上多了更多功能)

  • 当遇到多个字符对(token pair)并列为最高频率时,选“字典序更大的”那一对
max([("A", "B"), ("A", "C"), ("B", "ZZ"), ("BA", "A")])
  • special token

  • vocab里initialize的时候除了256个bytes,还需要加入特殊token,比如<|endoftext|>,然后有它们自己的token_id

  • Parallel pre-tokenization

  • 时间的瓶颈就是pre-tokenization step, 可以用pretokenization_example.py的function来chunk text

  • pre-tokenization前把special token移除

  • re.split "|".join(special_token) , 小心用re.escape

  • 用test_train_bpe_special_tokens来测试

  • 优化merging这一步

  • 每次merge,都要算每个pair的count,这其实会计算冗余,可以把pair count存在一个cache上,只更新那些和merged pair overlap的

  • 用cProfile 或者scalene来找到时间瓶颈在哪里

  • 厉害的同学可以考虑用C++ (cppyy) 或者 Rust (PyO3)来写

  • Function 里:

  • input args: input_path (text_path), vocab_size (int that defines max vocab size), special_tokens (list of strings)

  • return: vocab (dict {token_id, bytes}, merges (list of tuples of bytes)

答案

在cs336_basics下面建一个bpe.py

import regex as re
from collections import defaultdict
from tqdm.contrib.concurrent import process_map

PAT = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

def read_text(input_path):
    with open(input_path, "r", encoding="utf-8") as f:
        text = f.read()
    return text

def split_by_special(text, special_tokens, drop_special=True):
    if not special_tokens:
        return [text]

    # Sort by descending length to prioritize longer tokens (e.g., "<|endoftext|><|endoftext|>" before "<|endoftext|>")
    special_tokens = sorted(special_tokens, key=len, reverse=True)

    pattern = "|".join(re.escape(tok) for tok in special_tokens)
    if not drop_special: pattern = f"({pattern})"

    pattern = re.compile(pattern)
    chunks = pattern.split(text)
    return [c for c in chunks if c]  # remove empty strings

def word2bytes(word):
    "Convert word string to tuple of bytes"
    a = list(word.encode('utf-8'))
    return tuple(bytes([i]) for i in a)

def count_word(text):
    "Split text into word bytes using GPT2 pattern and count word bytes frequency."
    word_cnt = defaultdict(int)
    for m in PAT.finditer(text):
        word = m.group(0)
        word_bytes = word2bytes(word)
        if len(word_bytes)>=2:
            word_cnt[word_bytes]+=1
    return word_cnt

def merge_dicts(dicts):
    merged = defaultdict(int)
    for d in dicts:
        for k, v in d.items():
            merged[k] += v
    return merged

def count_pair(word_cnt):
    pair_cnt = defaultdict(int)
    for word_bytes,cnt in word_cnt.items():
        for pair in zip(word_bytes[:-1],word_bytes[1:]):
            pair_cnt[pair]+=cnt
    return pair_cnt

def get_max_pair(pair_cnt):
    max_pair, _ = max(pair_cnt.items(), key=lambda x: (x[1], x[0]))  # lexicographic tie-breaker
    return max_pair

def get_basic_vocab(special_tokens):
    vocab={token:bytes([token]) for token in range(256)}

    for i,token in enumerate(special_tokens):
        token_id = 256+i
        vocab[token_id] = token.encode("utf-8")
    return vocab

def apply_merge(word_bytes,merge):
    merged = merge[0]+merge[1]
    i = 0
    new_word_bytes = []
    while i < len(word_bytes):
        # Check for match
        if i < len(word_bytes) - 1 and word_bytes[i] == merge[0] and word_bytes[i+1] == merge[1]:
            new_word_bytes.append(merged)
            i += 2
        else:
            new_word_bytes.append(word_bytes[i])
            i += 1
    return tuple(new_word_bytes)

def update_cnt(word_cnt,pair_cnt, merge_pair):

    new_word_cnt = defaultdict(int)
    new_pair_cnt = defaultdict(int, pair_cnt) # copy with defaultdict

    for word_bytes,cnt in word_cnt.items():

        #----------for word cnt ---------------

        old_pairs = list(zip(word_bytes[:-1], word_bytes[1:]))

        # Keep the original count if the merge not appear in the key
        if merge_pair not in old_pairs:
            new_word_cnt[word_bytes]+=cnt
            continue

        # Use updated key if merge appear
        new_word = apply_merge(word_bytes,merge_pair)
        new_word_cnt[new_word]+=cnt

        #--------for pair cnt ----------------

        # Decrease all old pair counts
        for pair in old_pairs:
            new_pair_cnt[pair]-=cnt
            if new_pair_cnt[pair] ==0:
                del new_pair_cnt[pair]

        # Count new pairs in the new word
        new_pairs = list(zip(new_word[:-1], new_word[1:]))
        for p in new_pairs:
            new_pair_cnt[p] += cnt

    return new_word_cnt,new_pair_cnt

def train_bpe(input_path,vocab_size,special_tokens):

    text = read_text(input_path)
    chunks = split_by_special(text,special_tokens)

    # Only parallelize if chunk count is big enough
    if len(chunks) < 4: word_dicts = list(map(count_word, chunks))
    else: word_dicts = process_map(count_word, chunks, chunksize=1)

    word_cnt = merge_dicts(word_dicts)
    pair_cnt = count_pair(word_cnt)

    vocab = get_basic_vocab(special_tokens)
    base_vocab_size = len(vocab)
    n_merges=vocab_size-base_vocab_size

    merges = []
    for i in range(n_merges):
        max_pair = get_max_pair(pair_cnt)
        vocab[base_vocab_size+i] = max_pair[0]+max_pair[1]
        merges.append(max_pair)
        word_cnt, pair_cnt = update_cnt(word_cnt,pair_cnt,max_pair)
    return vocab, merges

adapters.py里

from cs336_basics.bpe import *

def run_train_bpe(
    input_path: str | os.PathLike,
    vocab_size: int,
    special_tokens: list[str],
    **kwargs,
) -> tuple[dict[int, bytes], list[tuple[bytes, bytes]]]:

    vocab, merges = train_bpe(
        input_path=input_path,
        vocab_size=vocab_size,
        special_tokens=special_tokens,
    )
    # vocab, merges = train_bpe(input_path, vocab_size, special_tokens)

    return vocab, merges

测试

uv run pytest -k test_train_bpe_special_tokens
uv run pytest -k test_train_bpe

PASS

colab notebook:

2.5b TinyStories

要求

  • 在tinystories里训练BPE tokenizer

Tiny Stories Overview

  • 用<|endoftext|> 作为special token
  • 时间应小于2分钟
  • max vocab size=10,000
  • 保存vocab和merges

代码

建一个run.py或者.ipynb

from cs336_basics.bpe import *

vocab, merges = train_bpe(
        input_path='data/TinyStoriesV2-GPT4-valid.txt',
        vocab_size=10_000,
        special_tokens=["<|endoftext|>","<|endoftext|><|endoftext|>"],
    )

整个过程持续1分50s。 另外version2 (heap+lazy update, 文章最下面的)也差不多这个时长 (所以这个优化不work)

保存vocab 和merges,另外load 的func。这里用了YAML,方便查看。

import yaml

def save_tokenizer_yaml(vocab, merges, fname):
    "Save vocab and merges to a YAML file with UTF-8 decoding for readability."
    # Convert bytes → string for readability
    vocab_serializable = {
        k: v.decode("utf-8", errors="replace") if isinstance(v, bytes) else v
        for k, v in vocab.items()
    }
    merges_serializable = [
        (a.decode("utf-8", errors="replace"), b.decode("utf-8", errors="replace"))
        for a, b in merges
    ]
    
    with open(fname, "w", encoding="utf-8") as f:
        yaml.dump(
            {"vocab": vocab_serializable, "merges": merges_serializable},
            f,
            allow_unicode=True,
            sort_keys=False
        )

def load_tokenizer_yaml(fname):
    "Load vocab and merges from a YAML file, converting strings back to bytes."
    with open(fname, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
    
    vocab_loaded = {
        int(k): v.encode("utf-8") if isinstance(v, str) else v
        for k, v in data["vocab"].items()
    }
    merges_loaded = [
        (a.encode("utf-8"), b.encode("utf-8")) for a, b in data["merges"]
    ]
    return vocab_loaded, merges_loaded

save和load

save_tokenizer_yaml(vocab,merges,'tokenizer_owt_valid.yaml')

# to load 
# vocab,merges=load_tokenizer_yaml('tokenizer_owt_valid.yaml')

时间和memory各是多少?

profile run.py,查看bottleneck

profile的教程

uv add scalene
uv run scalene run.py

打开profile.html

添加图片注释,不超过 140 字(可选)

memory

占用的总memory是左下的浅绿加深绿,198M加一个什么数。然后右上的max:114M指的是在某个时段内存占用的最大值。

time

点击时间column,按照时间排序

添加图片注释,不超过 140 字(可选)

哪个部分take了最长的时间?

时间take最长的是parallel processing chunk (GPT2 & count word)

这里可以把chunksize改到100可以更快(8.49s-->6.5s 在test train bpe里)

排名第二的是list(zip(word...)),这里可以优化的部分是可以用:

  • pairwise from itertools代替list(zip(...)),速度稍快,没差太多
  • 另外是先scan一边,用continue,pair_cnt因为是copy,只更新那些和merge_pair相关的count
from itertools import pairwise

def update_cnt_fast(word_cnt, pair_cnt, merge_pair):
    a, b = merge_pair
    new_word_cnt = defaultdict(int)
    new_pair_cnt = defaultdict(int, pair_cnt)  # copy

    for wbytes, cnt in word_cnt.items():
        # cheap presence check (no list/zip)
        has = False
        i, n = 0, len(wbytes) - 1
        while i < n:
            if wbytes[i] == a and wbytes[i+1] == b:
                has = True
                break
            i += 1
        if not has:
            new_word_cnt[wbytes] += cnt
            continue

        # decrement old pairs (iterator, no list)
        for p in pairwise(wbytes):
            v = new_pair_cnt[p] - cnt
            if v: new_pair_cnt[p] = v
            else: new_pair_cnt.pop(p, None)

        # merge & add new pairs
        new_w = apply_merge(wbytes, merge_pair)
        new_word_cnt[new_w] += cnt
        for p in pairwise(new_w):
            new_pair_cnt[p] += cnt

    return new_word_cnt, new_pair_cnt

4.27s 在test train bpe里

57.5s 在tinystories里

还有一个方法是只update merge相关的:

Changed pairs per occurrence at index i (merging a,b -> m):

  • Decrement: (L, a), (a, b), (b, R)
  • Increment: (L, m), (m, R)
def find_merge_positions(wbytes, a, b):
    # left-to-right, non-overlapping (typical BPE behavior)
    pos = []
    i, n = 0, len(wbytes)-1
    while i < n:
        if wbytes[i] == a and wbytes[i+1] == b:
            pos.append(i)
            i += 2
        else:
            i += 1
    return pos

def apply_merge_inline(wbytes, a, b, new_tok):
    out = []
    i, n = 0, len(wbytes)
    while i < n:
        if i+1 < n and wbytes[i] == a and wbytes[i+1] == b:
            out.append(new_tok)
            i += 2
        else:
            out.append(wbytes[i])
            i += 1
    return tuple(out)

def update_cnt(word_cnt, pair_cnt, merge_pair):
    a, b = merge_pair
    m = a+b
    new_word_cnt = defaultdict(int)
    new_pair_cnt = defaultdict(int, pair_cnt)

    for wbytes, cnt in word_cnt.items():
        pos = find_merge_positions(wbytes, a, b)
        if not pos:
            new_word_cnt[wbytes] += cnt
            continue

        # For each merge site, touch only neighbors
        for i in pos:
            L = wbytes[i-1] if i-1 >= 0 else None
            R = wbytes[i+2] if i+2 < len(wbytes) else None

            # decrement old local pairs
            if L is not None:
                p = (L, a)
                v = new_pair_cnt[p] - cnt
                if v: new_pair_cnt[p] = v
                else: new_pair_cnt.pop(p, None)

            p = (a, b)
            v = new_pair_cnt[p] - cnt
            if v: new_pair_cnt[p] = v
            else: new_pair_cnt.pop(p, None)

            if R is not None:
                p = (b, R)
                v = new_pair_cnt[p] - cnt
                if v: new_pair_cnt[p] = v
                else: new_pair_cnt.pop(p, None)

            # increment new local pairs
            if L is not None:
                new_pair_cnt[(L, m)] += cnt
            if R is not None:
                new_pair_cnt[(m, R)] += cnt

        # build merged word once
        new_w = apply_merge_inline(wbytes, a, b, m)
        new_word_cnt[new_w] += cnt

    return new_word_cnt, new_pair_cnt

4.44s 在test train bpe里 (上面那个似乎快一点)

1min 在tinystories里 (同上)

vocab里最长的token是哪个?

sorted(vocab.items(), key=lambda x: len(x[1]), reverse=True)

添加图片注释,不超过 140 字(可选)

accomplishment,比较make sense

2.5c OpenWebText

要求

  • max vocab size: 32,000
  • 保存vocab和merges

代码

这里我们就训练一下valid set (downscale一下,以学习为主)

vocab, merges = train_bpe(
        input_path='data/owt_valid.txt',
        vocab_size=32_000,
        special_tokens=["<|endoftext|>","<|endoftext|><|endoftext|>"],
    )

save_tokenizer_yaml(vocab,merges,'tokenizer_tinystories.yaml')

# to load 
# vocab,merges=load_tokenizer_yaml('tokenizer_tinystories.yaml')

用了optimize的代码,训练时长:还在跑

最大vocab是?make sense吗?

和tinystories比如何?

2.6 BPE Tokenizer

要求

用class写,encoder,decoder

答案

from typing import Iterator, Iterable
import json

def split_to_words(text):
    "Split text into words."
    return PAT.findall(text)

def apply_merges(word_bytes, merges_set, vocab_to_id):
    word_bytes = list(word_bytes)
    
    while True:
        min_token_id = float('inf')
        best_pair_idx = -1
        merged = None

        for i in range(len(word_bytes) - 1):
            pair = (word_bytes[i], word_bytes[i + 1])
            if pair in merges_set:
                combined = pair[0] + pair[1]
                token_id = vocab_to_id.get(combined)
                if token_id is not None and token_id < min_token_id:
                    min_token_id = token_id
                    best_pair_idx = i
                    merged = combined

        if best_pair_idx == -1:
            break

        # Apply best merge
        word_bytes = (
            word_bytes[:best_pair_idx]
            + [merged]
            + word_bytes[best_pair_idx + 2:]
        )

    return tuple(word_bytes)

def encode_merged(text,merges,vocab_to_id):
    word_list = split_to_words(text)
    tokens=[]
    for word in word_list:
        word_bytes=word2bytes(word)
        merged_word_bytes = apply_merges(word_bytes,merges,vocab_to_id)
        tokens.extend(vocab_to_id[i] for i in merged_word_bytes)
    return tokens

# Can merge the above functions into below class

class Tokenizer:
    def __init__(self, vocab, merges, special_tokens=None):
        self.vocab = vocab
        self.merges = set(merges)
        self.special_tokens = special_tokens if special_tokens else []
        self.special_tokens_bytes = [i.encode('utf-8') for i in self.special_tokens]
        

        self.vocab_to_id={v:k for k,v in vocab.items()}

        # Ensure special tokens are in the vocabulary
        for token_bytes in self.special_tokens_bytes:
            if token_bytes not in self.vocab_to_id:
                # Add to vocab if not already present
                new_id = len(self.vocab)
                self.vocab[new_id] = token_bytes
                self.vocab_to_id[token_bytes] = new_id

    @classmethod
    def from_files(cls, vocab_filepath, merges_filepath, special_tokens=None):
        # Load vocab (assumed to be a JSON file: {token_id: byte_string})
        with open(vocab_filepath, 'r', encoding='utf-8') as vf:
            vocab_data = json.load(vf)
            # Optional: convert keys to int if stored as strings
            vocab = {int(k): bytes(v, 'latin1') if isinstance(v, str) else bytes(v) 
                     for k, v in vocab_data.items()}

        # Load merges (assumed to be a list of pairs like: "a b")
        with open(merges_filepath, 'r', encoding='utf-8') as mf:
            lines = mf.readlines()
            # Optional: skip headers like "#version: 0.2"
            merge_pairs = [tuple(line.strip().split()) for line in lines if not line.startswith('#') and line.strip()]
            # Convert to byte-pairs
            merges = [(a.encode('utf-8'), b.encode('utf-8')) for a, b in merge_pairs]

        return cls(vocab=vocab, merges=merges, special_tokens=special_tokens)
    
    def encode(self, text: str) -> list[int]:
        chunks = split_by_special(text, self.special_tokens, drop_special=False)
        tokens = []
        for chunk in chunks:
            if self.special_tokens and chunk in self.special_tokens:
                tokens.append(self.vocab_to_id[chunk.encode('utf-8')])
            else:
                tokens.extend(encode_merged(chunk, self.merges, self.vocab_to_id))
        return tokens

    def encode_iterable(self, iterable: Iterable[str]) -> Iterator[int]:
        """
        Given an iterable of strings (e.g., a Python file handle), return a generator that lazily yields token IDs. 
        This is required for memory-efficient tokenization of large files that we cannot directly load into memory.
        """
        for chunk in iterable:
            yield from self.encode(chunk)

    def decode(self, ids: list[int]) -> str:
        "Decode a sequence of token IDs into text."
        return b''.join([self.vocab[t] for t in ids]).decode('utf-8',errors='replace')

adapters.py里

def get_tokenizer(
    vocab: dict[int, bytes],
    merges: list[tuple[bytes, bytes]],
    special_tokens: list[str] | None = None,
) -> Any:
    return Tokenizer(vocab=vocab, merges=merges, special_tokens=special_tokens)

测试

uv run pytest tests/test_tokenizer.py

PASS

2.7 Experiments

2.5 optimize: Heap + lazy update

可以用heapq最大堆的方法来optimize,配合lazy update

import regex as re
import heapq
from collections import defaultdict
from tqdm.contrib.concurrent import process_map

PAT = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

def read_text(input_path):
    with open(input_path, "r", encoding="utf-8") as f:
        return f.read()

def split_by_special(text, special_tokens, drop_special=True):
    if not special_tokens:
        return [text]
    special_tokens = sorted(special_tokens, key=len, reverse=True)
    pattern = "|".join(re.escape(tok) for tok in special_tokens)
    if not drop_special:
        pattern = f"({pattern})"
    return [c for c in re.compile(pattern).split(text) if c]

def word2bytes(word: str):
    # tuple of 1-byte bytes objects; consistent with your original representation
    return tuple(bytes([b]) for b in word.encode("utf-8"))

def count_word(text):
    word_cnt = defaultdict(int)
    for m in PAT.finditer(text):
        w = m.group(0)
        wb = word2bytes(w)
        if len(wb) >= 2:
            word_cnt[wb] += 1
    return word_cnt

def merge_dicts(dicts):
    merged = defaultdict(int)
    for d in dicts:
        for k, v in d.items():
            merged[k] += v
    return merged

def get_basic_vocab(special_tokens):
    vocab = {i: bytes([i]) for i in range(256)}
    for i, tok in enumerate(special_tokens):
        vocab[256 + i] = tok.encode("utf-8")
    return vocab

def apply_merge(word_bytes, merge):
    merged = merge[0] + merge[1]
    i = 0
    out = []
    L = len(word_bytes)
    while i < L:
        if i + 1 < L and word_bytes[i] == merge[0] and word_bytes[i+1] == merge[1]:
            out.append(merged)
            i += 2
        else:
            out.append(word_bytes[i])
            i += 1
    return tuple(out)

# ---------- HEAP + LAZY HELPERS ----------

def _desc_bytes_key(b: bytes):
    # For descending lexicographic order on bytes: invert length & each byte.
    return (-len(b), tuple(-x for x in b))

def _desc_pair_key(pair):
    # pair is (bytes, bytes). Compare first, then second, both descending.
    return (_desc_bytes_key(pair[0]), _desc_bytes_key(pair[1]))

def _heap_push(heap, pair, freq):
    # Store (-freq, desc_key, pair). Min-heap => max by freq then lexicographically descending.
    heapq.heappush(heap, (-freq, _desc_pair_key(pair), pair))

def _heap_pop_best(heap, pair_freq):
    # Pop until top matches current freq (lazy invalidation).
    while True:
        negf, _, pair = heapq.heappop(heap)
        f = -negf
        if pair_freq.get(pair, 0) == f and f > 0:
            return pair, f
        # else stale; keep popping

# ---------- BUILD INITIAL INDICES ----------

def _build_word_tables(word_cnt):
    """
    Turn {word_tuple -> count} into:
      - words: list of word tuples (bytes tokens)
      - wcnt:  list of counts aligned with words
      - wid_of: mapping for quick lookup if needed
    """
    words, wcnt = [], []
    for wb, c in word_cnt.items():
        words.append(wb)
        wcnt.append(c)
    return words, wcnt

def _count_pairs_and_occ(words, wcnt):
    pair_freq = defaultdict(int)    # pair -> total weighted count
    pair_occ  = defaultdict(set)    # pair -> set of word_ids containing it
    for wid, (wb, c) in enumerate(zip(words, wcnt)):
        if len(wb) < 2: 
            continue
        pairs = list(zip(wb[:-1], wb[1:]))
        for p in pairs:
            pair_freq[p] += c
        for p in set(pairs):        # occ just needs membership
            pair_occ[p].add(wid)
    return pair_freq, pair_occ

# ---------- FAST TRAIN LOOP ----------

def train_bpe_fast(input_path, vocab_size, special_tokens=()):
    text = read_text(input_path)
    chunks = split_by_special(text, special_tokens)
    # parallelize the first pass when appropriate
    word_dicts = list(map(count_word, chunks)) if len(chunks) < 4 else process_map(count_word, chunks, chunksize=1)
    word_cnt = merge_dicts(word_dicts)

    # Tables
    words, wcnt = _build_word_tables(word_cnt)
    pair_freq, pair_occ = _count_pairs_and_occ(words, wcnt)

    # Heap (lazy)
    heap = []
    for p, f in pair_freq.items():
        _heap_push(heap, p, f)

    # Vocab like yours
    vocab = get_basic_vocab(special_tokens)
    base_vocab_size = len(vocab)
    n_merges = max(0, vocab_size - base_vocab_size)
    merges = []

    # Train
    for step in range(n_merges):
        if not heap:
            break  # no more pairs
        best_pair, _ = _heap_pop_best(heap, pair_freq)
        merges.append(best_pair)

        # Register new token id
        new_tid = base_vocab_size + step
        vocab[new_tid] = best_pair[0] + best_pair[1]

        affected = list(pair_occ.get(best_pair, ()))
        if not affected:
            # nothing to merge anymore; continue to next
            continue

        # For each affected word, update in-place
        for wid in affected:
            old = words[wid]
            if len(old) < 2:
                continue
            c = wcnt[wid]

            # ---- remove old pairs from global counts & occ ----
            old_pairs = list(zip(old[:-1], old[1:]))
            # freq decrements (weighted)
            for q in old_pairs:
                fq = pair_freq.get(q, 0)
                if fq:
                    fq -= c
                    if fq <= 0:
                        pair_freq.pop(q, None)
                    else:
                        pair_freq[q] = fq
                    # push new (maybe smaller) freq; stale entries are lazily discarded later
                    if fq > 0:
                        _heap_push(heap, q, fq)

            # occ removal (set-based, just membership)
            for q in set(old_pairs):
                occ = pair_occ.get(q)
                if occ is not None:
                    occ.discard(wid)
                    if not occ:
                        pair_occ.pop(q, None)

            # ---- apply merge on this word ----
            new = apply_merge(old, best_pair)
            words[wid] = new

            # ---- add new pairs to global counts & occ ----
            if len(new) >= 2:
                new_pairs = list(zip(new[:-1], new[1:]))
                for q in new_pairs:
                    fq = pair_freq.get(q, 0) + c
                    pair_freq[q] = fq
                    _heap_push(heap, q, fq)
                for q in set(new_pairs):
                    pair_occ.setdefault(q, set()).add(wid)

        # After processing, the merged pair won't exist anymore in those words,
        # so its occ set is now either empty or only for non-updated words
        if best_pair in pair_occ and not pair_occ[best_pair]:
            pair_occ.pop(best_pair, None)

    return vocab, merges

to be continued..