SONUB
ZH English 한국어 日本語 中文 हिन्दी বাংলা اردو Español Português Français Deutsch Русский العربية Bahasa Indonesia Tiếng Việt ไทย Türkçe

如何开启通知

此浏览器或网址无法使用通知。请使用支持通知的浏览器,通过安全连接访问SONUB。

如果通知被屏蔽,请在浏览器设置中允许通知。

  1. 打开地址栏旁的站点设置或浏览器的站点设置,将此网站的通知设为允许。
  2. 在设备设置中允许浏览器发送通知,并检查勿扰模式或专注模式。
  3. 返回SONUB并刷新页面。通知开启后,铃铛将不再显示。

在iPhone或iPad上,将SONUB添加到主屏幕,从主屏幕打开后允许通知。

登录 退出登录 显示主题 默认主题 深色主题 粉色主题 聊天 1:1 聊天 群聊 论坛 公告 新闻区 建议区 自我介绍 主菜单

现在,让我们打造属于你自己的人工智能吧。

从自己的文本学习 。用 tokenizer.py 合并最常相邻的字符对,并确认编码 → 解码能还原原文。

一排词语积木的最后是一个空格。空格上方有三块候选积木,各带一根高低不同的柱子;其中一块被选中,接到这一排的末尾,一个箭头又绕回去,再选下一个。
4

BPE分词器

从单字符开始,统计相邻对并合并最常见的一对。编码时按学习顺序重放规则。

不合并,未知字符用编号0。这是字符级教学版,不是完整级实现。和规则一起保存,后不能随意换词表。

为什么要分词——计算机只能用数字计算

只会加法和乘法。"cat" 这样的文字无法计算,必须把文字变成整数编号的。做这件事。

  • :文字 → 编号列表。用默认资料学到的分词器中,"cat" → [9, 7, 23]。
  • decode:编号列表 → 文字。[9, 7, 23] → "cat"。

需要编号的地方有三处。

  • :用编号取出表中的一行(第5阶段)。
  • :为词表中的每个编号给出分数。编号的个数就是可以选择的下一个的个数。
  • :看正确编号的(第6阶段)。

编号本身没有含义。不能保证 9 号和 10 号是相似的字。含义是经过训练后在该编号的嵌入向量里形成的。

按什么单位切分——字符、单词、子词

决定词元单位有三种方法。

  • 字符单位:词表小(英文只有几十个),几乎没有未知字符。但文字会变长,模型一次能看的内容变少,从字符拼出词义的工作全要模型来做。
  • 单词单位:文字变短,但词表膨胀到几十万个,也无法处理词典里没有的词(新名字、错别字、变形)。
  • 子词单位( 等):常见片段作为一个词元,罕见词分成多个片段。词表大小和文字长度比较平衡,如今大多数都用它。

用自己的资料训练分词器,原因也在这里。通用分词器主要用英文学习,会把其他语言或特殊领域的文字切得很碎。例如 " 150k" 用招聘文字学到的分词器是 3 个词元,用通用分词器是 6 个词元。词元越少,同样的长度能放进更多内容,训练也更快。

BPE 算法——合并最常相邻的一对

BPE(Byte Pair Encoding)从字符开始,反复把最常相邻出现的两个片段合成一个新词元。

  • 初始词表是资料中出现的每个字符。
  • 统计整个资料中所有相邻的两个词元对。
  • 把出现最多的对作为新词元加入词表,并把资料中的这一对全部换成新词元。
  • 重复到设定的次数(settings.json 的 merges),或者没有出现 2 次以上的对为止。

下面的实验用 "low lower lowest" 合并四次。原本 16 个词元的文字每合并一次就变短,最后成为 6 个词元。注意看 l+o → lo、lo+w → low,前面合成的词元成为下一次合并的材料,逐渐形成更长的片段。像第三次合并 " "+"low" 那样,空格也会粘到片段上。因为 不事先按空格切分。实际的通常先按空格和字节切分(预分词,pre-tokenizer),然后只在其中合并。

动手验证 lab/bpe_trace.py
from tokenizer import BPE, merge

text = 'low lower lowest'
tok = BPE.train(text, 4)
ids = [tok.chars[c] for c in text]
show = lambda ids: '|'.join(tok.vocab[i] for i in ids)
print('start:', show(ids), len(ids))
for (a, b), new_id in tok.merges:
    ids = merge(ids, (a, b), new_id)
    print(repr(tok.vocab[a]), '+', repr(tok.vocab[b]), '->', show(ids), len(ids))

在 ~/xslm 中建立 lab 文件夹,在同名文件中亲手输入后运行。 -m lab.名称 会把(~/xslm)加入 ,所以能直接导入前面阶段写好的 tokenizer.py、engine.py、model.py。如果用 python3 lab/名称.py 运行,只会在 lab 文件夹里查找,会出现 。

cd ~/xslm
mkdir -p lab
micro lab/bpe_trace.py
python3 -m lab.bpe_trace

这是用默认资料和设置运行的结果。改过资料或设置,数字就会不同。

start: l|o|w| |l|o|w|e|r| |l|o|w|e|s|t 16
'l' + 'o' -> lo|w| |lo|w|e|r| |lo|w|e|s|t 13
'lo' + 'w' -> low| |low|e|r| |low|e|s|t 10
' ' + 'low' -> low| low|e|r| low|e|s|t 8
' low' + 'e' -> low| lowe|r| lowe|s|t 6

词表与编号——tokenizer.json 里有什么

训练好的分词器保存两样东西。

  • vocab:按编号顺序排列的词元列表。列表中的位置就是编号。
  • merges:的列表。一条规则是 [[前编号, 后编号], 新编号]。

编号这样分配。

  • 0 号永远是 �。代替词表中没有的字符。
  • 从 1 号开始是训练资料中出现的字符排序后的顺序。默认资料有换行、空格、句号、冒号、大写 A、Q 和 21 个小写字母,共 27 个字符,占 1~27 号。
  • 之后按合并顺序接上。默认资料的第一次合并是 ":"+" " → ": " (28 号),第二次是 "Q"+": " → "Q: " (29 号),第三次是 "A"+": " → "A: " (30 号)。

所以词表数 = 1 + 字符种类 + 合并数,默认资料是 1 + 27 + 12 = 40。因为排序后编号,用同样的资料重新训练,总会得到同样的编号。

编码——按学习顺序重新应用规则

把新文字变成编号时,先给每个字符查编号(未知字符为 0),再按学习顺序逐条应用保存的合并规则。

顺序重要,因为后面的规则会用前面规则生成的词元作材料。生成 "Q: " 的规则(Q + ": ")要求 ": " 已经先生成才能匹配。顺序一换,得到的词元就和训练时不同。

merge 从左向右扫描列表,不重叠地合并。两格与该对相符就放入新编号并跳过两格,否则原样移过一格。所以在 "aaa" 中合并 a+a 会得到 [aa, a],不会得到 [a, aa]。

decode 只是按每个编号取出 vocab 中的片段拼起来。片段都是由原字符拼成的,所以 encode 后再 decode 能准确回到原文()。只有未知字符会变成 � 回不来。

边界处也有陷阱。训练资料中 "A:" 后面总是跟空格,所以 "A: " 合成了一个词元。如果以不带空格的 "A:" 结尾,就会变成 "A" 和 ":" 两个词元,而这两个在训练资料中从未单独出现过。生成提示词要和训练格式一样,连空格都相同,以 "A: " 结尾。

不合并换行——生成停下的位置

tokenizer.py 的 train 在统计对时,如果两个片段连起来的文字含有换行,就不计数。所以换行永远单独作为一个词元(1 号)。

原因是 generate.py 抽到换行词元就停止生成。训练资料中答案以换行结束,所以模型学会写完答案就输出换行。如果像 ".\n" 那样句号和换行合成一个词元,换行就藏在别的词元里,找不到停下的位置。

真正的语言模型用表示结束的(EOS)做这件事。XSLM 把换行当作 EOS 使用。

未知字符——0 号 � 与字节级 BPE

XSLM 的分词器只认识训练资料中出现过的字符。默认资料里没有大写 C,没有韩文,也没有表情符号,所以下面实验中 Cat 的 C、안녕、😀 全部变成 0 号。0 号只留下"未知字符"的意思,原字符消失了,所以 decode 后得到 �。

之后的许多分词器不从字符开始,而是从 字节开始(字节级 BPE)。

  • UTF-8 用 1~4 个字节表示一个字符。如实验结果所示,a 是 1 字节,안 是 3 字节,😀 是 4 字节。
  • 字节只有 256 种,所以用 256 个初始词表就能写出世界上所有文字,不会出现未知字符。
  • 但没见过的字符会被切成多个字节词元,变得很长。

XSLM 为了易读做成了字符级。解决办法是把要用的字符放进训练资料。

动手验证 lab/unknown.py
import json
from pathlib import Path
from tokenizer import BPE

tok = BPE(**json.loads(Path('data/tokenizer.json').read_text()))
for text in ['cat', 'Cat', '안녕', '😀']:
    print(text, tok.encode(text), list(text.encode('utf-8')))
cd ~/xslm
mkdir -p lab
micro lab/unknown.py
python3 -m lab.unknown

运行结果

cat [9, 7, 23] [99, 97, 116]
Cat [0, 7, 23] [67, 97, 116]
안녕 [0, 0] [236, 149, 136, 235, 133, 149]
😀 [0] [240, 159, 152, 128]

特殊词元——开始、结束、填充、未知

真正的分词器把文字中不会出现的约定用词元放在词表前面。通常在 0~3 号放这四个。

  • [PAD]:把长度不同的文字作为一批(batch)计算时,填充短文字的后面。
  • [UNK]:词表中没有的片段。和 XSLM 的 0 号 � 相同。
  • [BOS]:表示文字的开始。
  • [EOS]:表示文字的结束。生成遇到这个词元就停止。

资料格式中的标记([QUESTION]、[ANSWER] 等)也可以注册为特殊词元,避免被切开。对话模型使用区分用户和助手轮次的特殊词元。

XSLM 只把其中的 [UNK] 作为 0 号。一次只训练一个片段,不需要填充;换行承担结束标记;Q:、A: 由 BPE 自己合成一个词元("Q: "、"A: ")。

决定词表大小——合并数改变了什么

词表大小(vocab_size)是词元种类的数量,编号从 0 到 vocab_size − 1。增加合并,两件事会朝相反方向变化。

  • 文字变短。下面的实验中,合并 0 次时训练资料是 309 个词元,12 次时是 218 个,48 次时是 138 个。上下文 12 格内能放进更多文字。
  • 模型变大。嵌入表(token)和输出层(head)的行数等于词表数,所以词表从 40 变成 76 时,两张表的数字各自从 320 个增加到 608 个。

太大的话罕见词元变多,每个词元能学习的例子不够。大资料通常用 16,000~32,000 的词表,非常小的资料(10KB 以下)用 500~1,000。大致估算是"资料字符数 ÷ 100~500"。

合并可能比设定的次数更早停止。因为没有出现 2 次以上的对,合并也没有好处。资料小时这很常见,是正常的。

重新训练分词器后,编号的含义会变,所以模型也要从头重新训练。分词器和模型的词表数不同,嵌入表的行数就对不上,无法读取检查点。

动手验证 lab/token_count.py
from pathlib import Path
from tokenizer import BPE

text = Path('data/train.txt').read_text(encoding='utf-8')
for count in [0, 6, 12, 24, 48]:
    tok = BPE.train(text, count)
    print('merges', count, 'vocab', len(tok.vocab), 'tokens', len(tok.encode(text)))
cd ~/xslm
mkdir -p lab
micro lab/token_count.py
python3 -m lab.token_count

运行结果

merges 0 vocab 28 tokens 309
merges 6 vocab 34 tokens 242
merges 12 vocab 40 tokens 218
merges 24 vocab 52 tokens 186
merges 48 vocab 76 tokens 138

动手输入并实验

输入并运行tokenizer.py,查看。追踪merge中匹配时前进两位、否则一位的过程。

cd ~/xslm
micro tokenizer.py
python3 tokenizer.py
python3 -m json.tool data/tokenizer.json
在终端中练习
tokenizer.py需要亲手编写的完整源码

merge合并一对;train学习有序规则;encode重放;decode拼接还原。

import json
from collections import Counter
from pathlib import Path


def merge(ids, pair, new_id):
    result, i = [], 0
    while i < len(ids):
        if tuple(ids[i:i + 2]) == tuple(pair):
            result.append(new_id)
            i += 2
        else:
            result.append(ids[i])
            i += 1
    return result


class BPE:
    def __init__(self, vocab, merges):
        self.vocab, self.merges = vocab, merges
        self.chars = {c: i for i, c in enumerate(vocab) if len(c) == 1}

    @classmethod
    def train(cls, text, count):
        vocab = ['\ufffd'] + sorted(set(text) - {'\ufffd'})
        ids = [vocab.index(c) for c in text]
        merges = []
        for _ in range(count):
            pairs = Counter((a, b) for a, b in zip(ids, ids[1:])
                            if '\n' not in vocab[a] + vocab[b])
            if not pairs:
                break
            pair, frequency = pairs.most_common(1)[0]
            if frequency < 2:
                break
            new_id = len(vocab)
            vocab.append(vocab[pair[0]] + vocab[pair[1]])
            merges.append([list(pair), new_id])
            ids = merge(ids, pair, new_id)
        return cls(vocab, merges)

    def encode(self, text):
        ids = [self.chars.get(c, 0) for c in text]
        for pair, new_id in self.merges:
            ids = merge(ids, pair, new_id)
        return ids

    def decode(self, ids):
        return ''.join(self.vocab[i] for i in ids)

    def state(self):
        return {'vocab': self.vocab, 'merges': self.merges}


if __name__ == '__main__':
    settings = json.loads(Path('settings.json').read_text())
    text = Path('data/train.txt').read_text(encoding='utf-8')
    tokenizer = BPE.train(text, settings['merges'])
    Path('data/tokenizer.json').write_text(
        json.dumps(tokenizer.state(), ensure_ascii=False), encoding='utf-8')
    assert tokenizer.decode(tokenizer.encode(text)) == text
    print('vocab:', len(tokenizer.vocab), 'merges:', len(tokenizer.merges))
    print('roundtrip: OK', tokenizer.encode('Q: hi\nA: '))

逐行拆解代码

tokenizer.py merge——不重叠地合并一对

def merge(ids, pair, new_id):
    result, i = [], 0
    while i < len(ids):
        if tuple(ids[i:i + 2]) == tuple(pair):
            result.append(new_id)
            i += 2
        else:
            result.append(ids[i])
            i += 1
    return result

用 i 从左向右扫描 ids 列表。ids[i:i + 2] 是当前格和下一格两个(到末尾时是一个)。如果它等于 pair,就把一个 new_id 放进结果,i 加 2,跳过两格;否则把当前格原样放入,i 加 1。转成 tuple 再比较,是因为从 JSON 读出的规则是列表(列表和元组用 == 比较不相等)。列表只过一遍,所以耗时与长度成正比。

tokenizer.py 用保存的状态创建分词器

class BPE:
    def __init__(self, vocab, merges):
        self.vocab, self.merges = vocab, merges
        self.chars = {c: i for i, c in enumerate(vocab) if len(c) == 1}

BPE 对象只有 vocab 和 merges 两样。所以可以把从 tokenizer.json 读出的展开为关键字参数,原样传给 BPE(…) 来重建,train.py、generate.py 就是这样用的。chars 是只收集单字符词元的"字符 → 编号"查找表。在 encode 的第一步用来快速查每个字符的编号。

tokenizer.py train——学习合并规则

    @classmethod
    def train(cls, text, count):
        vocab = ['\ufffd'] + sorted(set(text) - {'\ufffd'})
        ids = [vocab.index(c) for c in text]
        merges = []
        for _ in range(count):
            pairs = Counter((a, b) for a, b in zip(ids, ids[1:])
                            if '\n' not in vocab[a] + vocab[b])
            if not pairs:
                break
            pair, frequency = pairs.most_common(1)[0]
            if frequency < 2:
                break
            new_id = len(vocab)
            vocab.append(vocab[pair[0]] + vocab[pair[1]])
            merges.append([list(pair), new_id])
            ids = merge(ids, pair, new_id)
        return cls(vocab, merges)

@classmethod 是不需要对象、用 BPE.train(…) ,并创建对象返回的函数。

  • 初始词表把 \ufffd(�)放在 0 号,再把文字中出现的字符用 sorted 排序后接上。因为排序,编号总是相同。
  • ids 是给每个字符查到编号的列表(vocab.index)。
  • 每次用 Counter 统计相邻的对 (a, b)。zip(ids, ids[1:]) 把相邻的两格配成对。两个片段连起来的文字含有换行就不计数。
  • most_common(1) 给出最多的对和次数。次数相同时,先统计到的对,也就是文字中先出现的对被选中。
  • 没有对,或者最多的对也只出现 1 次,就停止。
  • 新编号是 (vocab),即词表的下一个编号。把两个片段连起来的文字加入词表,记录规则 [[a, b], 新编号],把 ids 换成合并后的列表,准备下一次循环。

tokenizer.py encode——把文字变成编号

    def encode(self, text):
        ids = [self.chars.get(c, 0) for c in text]
        for pair, new_id in self.merges:
            ids = merge(ids, pair, new_id)
        return ids

chars.get(c, 0) 查字符的编号,没有就给 0(�)。然后按学习顺序逐条 merge。规则有多少条就重新扫描列表多少次,所以规则多、文字长时会变慢。实际的库用规则的优先级表合并得更快。

tokenizer.py decode、state——编号变文字,保存状态

    def decode(self, ids):
        return ''.join(self.vocab[i] for i in ids)

    def state(self):
        return {'vocab': self.vocab, 'merges': self.merges}

decode 按每个编号取出 vocab 中的片段,用 ''.join 拼起来。state 返回可以用 json 保存的字典。规则用列表而不是元组记录,所以存成 JSON 再读回来,形状也一样。

tokenizer.py 运行部分——训练、保存、检查

if __name__ == '__main__':
    settings = json.loads(Path('settings.json').read_text())
    text = Path('data/train.txt').read_text(encoding='utf-8')
    tokenizer = BPE.train(text, settings['merges'])
    Path('data/tokenizer.json').write_text(
        json.dumps(tokenizer.state(), ensure_ascii=False), encoding='utf-8')
    assert tokenizer.decode(tokenizer.encode(text)) == text
    print('vocab:', len(tokenizer.vocab), 'merges:', len(tokenizer.merges))
    print('roundtrip: OK', tokenizer.encode('Q: hi\nA: '))

用 settings.json 的 merges 和 /train.txt 训练,用 ensure_ascii=False 保存到 tokenizer.json。没有这个选项,中文会被存成像 \uc548 这样的转义。 把整个训练资料 encode → decode,检查是否和原文完全相同。最后一行显示生成提示词 "Q: hi" 下一行 "A: " 会变成什么编号。默认资料是 [29, 13, 14, 1, 30],即 "Q: "、h、i、换行、"A: "。

检查理解

roundtrip: OK说明原文完整复原。修改合并次数比较词元数量;未知字变为�。

遇到问题时

无重复对时零合并也正确。资料或词表改变后,应重新训练并旧模型。

常见问题

为什么不用分词器库而要自己做?

实际开发中使用 tokenizers 或 tiktoken 这样的库。它们用 等编写,处理大资料也很快。XSLM 为了亲眼看到合并规则如何产生、按什么顺序应用,用 60 行自己实现。库保存的 tokenizer.json 里也有同样的 vocab 和 merges。

roundtrip 是 OK,但我的问题变成了 �。

roundtrip 只用训练资料检查。问题里有训练资料中没有的字符(大写字母、汉字、符号),就会变成 0 号。把含有这些字符的行加到 raw.txt,然后 prepare → tokenizer → 用新检查点重新训练。

一个词元就是一个词吗?

不是。词元是 BPE 常见的片段,可能像 "is " 那样是词加空格,也可能像 " ca" 那样是词的一部分。真实模型中一个英文单词平均也就一个多一点的词元,其他语言有时切得更碎。所以费用和上下文长度不按词而按词元计算。

学习指南

人工智能

智能体工具

Vibe 编程

AI 应用

AI证书

AI课堂

IT 基础

聊天

论坛

创作

社区

更多

术语词典

选一个你不认识的词吧,可以看到简单的解释和相关术语。

还没有匹配的术语。

选择一个术语即可查看解释。