斯坦福大学 | CS336 | 从零开始构建语言模型 | Spring 2025 | 笔记 | Lecture 1: Overview and Tokenization
前言
最近频繁刷到斯坦福的 CS336 从零开始构建语言模型这门课程,应该是在暗示我需要开始学习了🤗,当初的 CS231n 学一半就放弃了,希望这门课能撑到结束。本篇文章记录课程第一讲:概述和分词,记录下个人学习笔记,仅供自己参考😄
website:https://stanford-cs336.github.io/spring2025
video:https://www.youtube.com/playlist?list=PLoROMvodv4rOY23Y0BoGoBGgQ1zmU_MT_
materials:https://github.com/stanford-cs336/spring2025-lectures
course material:https://stanford-cs336.github.io/spring2025-lectures/?trace=var/traces/lecture_01.json
1. Course Introduction
CS336 这门课程讲的是什么?
语言模型是现代自然语言处理(NLP)应用的基石,它开创了一种新的范式,即用一个通用系统处理一系列下游任务。随着人工智能(AI)、机器学习(ML)和 NLP 领域的不断发展,深入理解语言模型对科学家和工程师而言都至关重要。本课程通过引导学生亲手构建语言模型,提供系统化的学习路径。借鉴操作系统课程从零创建操作系统的教学模式,我们将带领学生完整经历语言模型创建的全流程:涵盖预训练阶段的数据采集与清洗、Transformer 模型的构建、模型训练以及部署前的评估
前置条件
- 精通 Python
- 大多数课程作业将使用 Python 编写。与其他大多数人工智能课程不同,本课程将极少提供辅助支持。你需要编写的代码量至少比其他课程多出一个数量级。因此,精通 Python 和软件工程至关重要
- 具有深度学习和系统优化经验
- 课程的一大重点是让语言模型在多台机器的 GPU 上快速高效地运行。我们希望学生能够熟练掌握 PyTorch,并了解内存层次结构等基础系统概念
- 大学微积分,线性代数(例如 MATH 51,CME 100)
- 你应该能够轻松理解矩阵/向量表示法及其运算
- 基础概率论和统计(例如 CS 109)
- 你应该掌握概率论的基础知识,包括高斯分布、均值、标准差等概念
- 机器学习(例如 CS221,CS229,CS230,CS124,CS224N)
- 你应该熟悉机器学习和深度学习的基础知识
请注意,由于课程内容侧重于实践,请预留充足的学习时间
2. Overview
整个课程分为以下五个部分:

下面我们将依次介绍每个部分,并且讲解我们将涵盖的内容以及作业会涉及什么,最后做个总结
2.1 Basics Unit
Basics 基础单元的目标就是让完整流程的基础版本正常运行,在这里,你将学习分词(tokenization)、模型架构和训练过程
tokenizer 分词器是一种能在字符串和整数序列(tokens)之间进行转换的东西

简单来说,你可以把这些整数看作是对应于将字符串分解成一个个小片段(token),然后将每一个小片段映射到一个整数
这样做的想法是,你的整数序列就是输入到实际模型中的内容,而这个模型必须是固定维度的,在本次课程中,我们将讨论字节对编码(Byte-Pair Encoding, BPE)分词器 [Sennrich+ 2015],它相对简单,并且目前仍在被使用
关于 “无分词器”,目前也有一系列很有前景的方法 [Xue+ 2021][Yu+ 2023][Pagnoni+ 2024][Deiseroth+ 2024],这些方法直接处理原始字节,而不进行分词,通过开发一种特定的架构来直接接收原始字节
OK,一旦你将序列或字符串分词成整数序列后,就可以在这类序列上定义一个模型架构,我们从原始的 Transformer 模型开始 [Vaswani+ 2017]

Transformer 基本上是所有前沿模型的核心骨干,上图展示的是它的架构,我们在这里就不深入细节了,它有一个注意力机制部分,然后还有一个带有归一化的 MLP 层
自 2017 年以来其实发生了很多变化,关于 Transformer 有很多小改进,例如:
- 激活函数:ReLU,SwiGLU [Shazeer 2020]
- 位置编码:sinusoidal,RoPE [Su+ 2021]
- 归一化:LayerNorm,RMSNorm [Ba+ 2016][Zhang+ 2019]
- 归一化位置:pre-norm vs post-norm [Xiong+ 2020]
- MLP:dense,mixture of experts [Shazeer+ 2017]
- Attention:full,sliding window,linear [Jiang+ 2023][Katharopoulos+ 2020]
- Lower-dimensional attention:group-query attention (GQA),multi-head latent attention (MLA) [Ainslie+ 2023][DeepSeek-AI+ 2024]
- 状态空间模型:Hyena [Poli+ 2023]
OK,一旦你定义好了你的架构后就需要进行训练了,需要我们设计且关注以下几个部分:
- 优化器 Optimizer (e.g., AdamW, Muon, SOAP) [Kingma+ 2014][Loshchilov+ 2017][Keller 2024][Vyas+ 2024]
- 学习率策略 Learning rate schedule (e.g., cosine, WSD) [Loshchilov+ 2016][Hu+ 2024]
- 批大小 Batch size (e…g, critical batch size) [McCandlish+ 2018]
- 正则化 Regularization (e.g., dropout, weight decay)
- 超参数 Hyperparameters (number of heads, hidden dimension):grid search
这里面有很多细节,这些细节非常重要,因为如果不关注某些细节的话,那么在一个精心调优的架构和一个普通的 Transformer 之间很容易就会产生数量级的差异,
作业 1
- BPE 分词器实现
- Transformer 模型、交叉熵损失函数、AdamW 优化器以及训练过程实现
- TinyStories 和 OpenWebText 数据集训练
- 榜单:最小化 OpenWebText perplexity [去年榜单]
在作业 1 中要求实现 BPE 分词器,还需要实现 Transformer 模型、交叉熵损失函数、AdamW 优化器以及整个训练过程,并使用提供的数据集进行训练、验证以及测试
以上就是基础部分
2.2 Systems Unit
基础讲完之后,你应该有能力训练一个 Transformer 模型了,我们还需要什么呢?系统部分主要探讨如何进一步优化它,如何最大限度地利用硬件性能,为此我们需要仔细研究一下硬件以及如何利用它
所以,kernels(核函数)、parallelism(并行化)以及 inference(推理)是本单元的三个组成部分
在开始谈论 kernels 之前,我们先来简单了解一下 GPU 是长什么样的

GPU 基本上是由很多用于执行浮点运算的小单元(Streaming Multiprocessor,SM,流式多处理器)构成的一个巨大阵列。另外需要注意的一点是,GPU 芯片有片外内存(如 global memory),它们位于芯片外部,此外还有一些其它类型的内存比如 L2 缓存、L1 缓存等
现在的问题是计算必须在片上内存进行,而你的数据可能在别的地方,你如何才能有效地组织计算从而实现最高的效率呢?

一个简单的类比是,想象一下内存 Memory 是你可以用来存储数据、模型参数的地方,就像一个仓库,而你的电脑就像一个工厂,我们会发现一个主要的瓶颈就是数据移动的成本,所以我们要做的是如何组织计算。即使是矩阵乘法也要通过最小化数据移动来最大化 GPU 的利用率,有很多技术例如 kernel fusion、tiling 等等可以让你做到这一点,我们会在本单元深入探讨所有细节,以及如何实现和利用它们
我们使用 Triton 来编写 kernel,还有一些其它的方式例如 CUDA/CUTLASS/ThunderKittens 也可以用来实现 kernel,但它们具有不同程度的复杂性,因此我们决定使用由 OpenAI 开发的一种流行工具 Triton 来构建 kernel

我们将编写一些用于单个 GPU 的 kernel,但现在一般来说,你可能会进行一些大型运算,需要数万甚至更多的 GPU。在上图中有 8 个 GPU,它们连接到了一些 CPU 节点,并且通过 NVSwitch 直接相连。现在道理也是一样的,唯一的问题是 GPU 之间的数据移动更慢了,所以我们需要弄清楚如何放置模型参数、激活值和梯度,并将它们放在 GPU 上进行计算,同时最大限度地减少数据移动量,我们将探索不同类型的技术比如数据并行、张量并行等等
最后是推理(Inference)部分,它的任务是给定提示词并提供训练好的模型来生成 token。事实上,它也对很多其他事情非常有用,不只是在你和模型聊天时会用到推理,你需要它用于强化学习的测试时间计算,这在最近非常流行
即使是评估模型,你也需要进行推理,所以我们会花点时间来聊聊推理。实际上,如果你从全球角度看,花在推理上的成本正在超越用于训练模型的成本,因为训练虽然非常密集但最终是一次性成本,而推理成本随每次使用而增加,使用你模型的人越多,你就越需要推理更高效

如上图所示,在推理中有两个阶段,预填充(Prefill)阶段和解码(Decode)阶段,预填充是你接收提示词,然后通过模型运行它得到一些激活值,而解码则是逐个进行自回归来生成 token。在预填充阶段所有的 token 都已经给出,所以你可以一次性处理所有内容,这正是你在训练时所看到的情形,而真正让推理过程变得特殊和困难的,正是后面的自回归解码方式,你需要一次生成一个 token,因此我们很难充分利用所有的 GPU,并且它还会受到内存的限制,因为你一直在移动数据
在这个单元我们还将讨论几种加速模型、提高推理速度的方法,包括:
- 使用成本更低的模型(通过模型剪枝、量化、蒸馏)
- 推测解码:使用更便宜的 “draft” 模型生成多个 token,然后使用完整模型并行评分
- 系统优化:KV caching、batching
作业 2
[Github from 2024][PDF from 2024]
- 在 Triton 中实现融合 RMSNorm kernel
- 实现分布式数据并行训练
- 实现优化器状态分片
- 对上述实现进行基准测试和性能分析
2.3 Scaling laws Unit
第三单元是 scaling laws,这单元的目标是 在小规模的训练上进行实验,然后在大规模的训练上预测超参数和损失。那我们为什么要了解这个呢,这里有一个根本问题,如果我给你一个 FLOPs 预算,你知道应该使用什么尺寸大小的模型吗?
如果你使用更大的模型,意味着你可以用更少的数据进行训练,那这里的最佳平衡点是什么呢?通过 OpenAI 和 DeepMind 的一系列论文 [Kaplan+ 2020][Hoffmann+ 2022],这个问题已经被广泛研究并找到了答案

如果你听说过 Chinchilla optimal 这个术语,这就是它所指的,其基本思想是对于每一种计算预算(FLOPs 的数量),你可以改变模型的参数量然后衡量该模型的表现有多好。因此,对于每一种计算量级,你都能找到最优的参数数量,然后你可以拟合一条曲线进行外推,如上图所示,结果会发现这些最优的参数量把它们画出来时,实际上是惊人的线性关系!
这就形成了一个实际上非常简单但非常有用的经验法则:那就是如果你有一个大小为 N 的特定模型,如果乘以 20 基本上就是你应该用来训练的 token 数量(D),也就是 D ∗ = 20 N ∗ D^* = 20N^* D∗=20N∗,比如说你有一个 14 亿参数的模型,那么你就应该用 280 亿的 token 进行训练
但请注意,这条经验法则还没考虑到推理成本,所以这里存在一些局限性,尽管如此但它对于模型开发而言仍然非常有用
作业 3
[Github from 2024][PDF from 2024]
- 提交 “训练任务”(在 FLOPs 预算范围内)并收集数据
- 对数据点进行缩放定律拟合
- 提交更大规模训练的超参数预测
在作业 3 中官方定义了一个所谓的训练 API,你可以在其中用一套特定的超参数进行查询,你需要指定模型架构、batch 大小等等,然后这个 API 会返回你决策所产生的损失
你的任务就是,现在有一个 FLOPs 预算,你需要尝试弄清楚如何训练一系列模型,然后收集数据并对收集到的数据进行缩放定律拟合,最后你需要提交你预测的超参数选择方案,包括用于更大规模训练的模型大小、输入 batch 等等
2.4 Data Unit
接下来我们来讲讲数据,那到目前为止,大家应该已经了解了缩放定律,知道了系统优化,有 Transformer 的实现,基本上所有东西都准备好了。
但其实数据是一个非常关键的要素,它在某种程度上起到了区分作用,这里要提出的问题是:我们究竟想要让模型做什么?因为模型的功能完全(或者说大部分)是由数据决定的。如果用多语言数据训练,它就会具备多语言能力;用代码训练,它就会具备代码能力;这是一件很自然的事情

通常来说,数据集是很多不同部分的集合,上图展示的是 pile 数据集的组成,包括 Academic、Internet 等各种来源。在数据部分,我们将开始讨论数据集的评估(evaluation),也就是说如果给定一个模型,你如何评估它的好坏呢?我们将讨论困惑度(perplexity)、各种衡量指标以及像 MMLU 这样的标准化测试
如果你有一些模型能够生成遵循指令的回复,你又该如何来评估这些模型呢?还有关于在测试时是否可以进行模型集成(ensemble)或使用 Chain of Ada 等方法呢,它们又如何影响你的评估的呢?最后我们可以讨论整个系统的评估,而不仅仅是语言模型本身的评估,因为如今语言模型经常被整合到一些智能体系统或类似的东西中去了
那么在确定了评估方法之后,让我们来看看数据筛选,这可能是大家没有意识到的一个关键点。我们可能经常听人说:“我们在互联网上训练模型”,这说不通,数据不会凭空从天上掉下来,好像互联网就在那儿可以直接导入到你的模型里一样,数据总是需要想办法主动获取的

上图展示的是从 Common Crawl 里随机抽取的样本,可以看到这些可能并不是完全符合要求的数据,所以说网络上很多内容都是垃圾,如果我们不加筛选的加入到我们的数据集中,很有可能会造成污染
你可以抓取互联网、书籍、论文、Github 等来源的数据,但实际上在使用时我们还需要进行大量的处理,还有关于你可以用什么数据进行训练的法律问题,我们后面也会谈到
如今,许多前沿模型实际上不得不购买数据,因为互联网上那些可以公开获取的数据太局限。而且需要记住一点,这些抓取来的数据它其实并不是文本,它可能是 HTML、PDF 甚至代码,所以必须有一个明确的过程来处理这些数据,将它们转化为文本
在讲完了数据筛选后我们要讲的是如何将 HTML 转换为文本,这是一个有损的过程,所以关键在于你如何既能保留内容和部分结构又不仅仅是简单地进行 HTML 过滤,这个非常重要,不仅是为了获得高质量的数据,也是为了移除有害内容,通常,人们会训练分类器来完成这项工作。此外,数据去重也是一个重要步骤,我们到时候会详细讲解
作业 4
[Github from 2024][PDF from 2024]
- 将 Common Crawl HTML 数据转换为文本数据
- 训练分类器区分优质内容和有害内容
- 使用 MinHash 进行重复数据删除
第四次作业全部都围绕数据展开,我们会提供原始的 Common Crawl 数据存储文件,然后你们将训练分类器、进行数据去重,之后会有一个排行榜,在这个排行榜上,你们需要在给定的 token 预算下,努力最小化困惑度(perplexity)
2.5 Alignment Unit
到目前为止我们有了数据,构建好了 kernels,也完成了训练,现在就可以真正开始训练模型了。但在此时,我们得到的是一个能够预测下一个 token 的模型,它被称作一个基础模型(base model)
我们可以认为这个基础模型拥有巨大的原始潜力,但它需要以某种方式进行对齐或修改,而对齐(alignment)是一个使其变得有用的过程
对齐涵盖了很多不同的方面,主要包括:
- 让语言模型遵循指令
- 指定生成内容的风格(格式、长度、语气等)
- 融合安全元素(例如拒绝回答有害问题)
通常来说,对齐有两个阶段:监督微调和从反馈中学习,监督微调的目标非常简单,你收集一组用户-助手对(也就是提示-响应对)然后进行监督学习,这里的想法是基础模型本身就具备了那种原始潜力,所以只需要在少量示例上对其进行微调就足够了,当然示例越多结果越好,但也有论文 [Zhou+ 2023] 表明即使一千个示例也已足够从一个好的基础模型中获得遵循指令的能力
所以这部分其实非常简单,而且它与预训练没有太大区别,因为它只是给定文本,然后你只需最大化文本的概率,所以从算法角度来看,对齐的第二部分会更有趣一些
完成 SFT 阶段后你也能得到一个不错的模型,那么该如何继续改进呢?当然你可以获取更多的 SFT 数据,但那样可能会非常昂贵,因为必须要找人标注数据,那有没有更合适的方法呢
因此,第二阶段从反馈中学习的目标是你可以利用更轻量的标注形式并让算法承担更多的工作。你可以用来学习的一种数据类型是偏好数据(preference data),也就是说,你让模型为一个给定的提示生成多个响应比如 A 或 B,让用户来评价是 A 更好还是 B 更好
另一种监督方式是使用验证器(verifiers),在某些领域,你很幸运能有 formal verifier 比如数学或代码验证器,或者你可以使用 learned verifier 训练一个实际的语言模型来评价响应
这就是强化学习的领域,最早开发并应用指令调优模型的算法是 PPO(Proximal Policy Optimization)[Schulman+ 2017][Ouyang+ 2022],也叫近端策略优化。结果表明,如果你只有偏好数据,有一个简单得多的算法叫 DPO(Direct Policy Optimization)[Rafailov+ 2023],效果非常好。
但总的来说,如果你想从验证器数据中学习,那并不是偏好数据,所以你必须完全采用强化学习。还有一种方法我们会在课上讲,叫做 GRPO(Group Relative Preference Optimization)[Shao+ 2024],它由 DeepSeek 提出,通过移除价值函数简化了 PPO 并使其更高效,其效果看起来相当不错
作业 5
[Github from 2024][PDF from 2024]
- 实现监督微调
- 实现 DPO
- 实现 GRPO
2.6 Summary
OK,这里就对五个单元要讲的内容做个回顾:
- 数据处理:避免将宝贵的计算资源浪费在无效/无关数据上
- 分词:直接操作原始字节虽优雅,但在如今的模型架构中计算效率低下
- 模型架构:为降低内存或浮点运算量而进行的诸多改进(例如共享 KV cache、滑动窗口注意力机制)
- 训练:我们只需一个 epoch 就能搞定!
- 缩放定律:在较小的模型上使用更少的计算资源进行超参数调优
- 对齐:若将模型更精细地调整至目标使用场景,则需采用更小的基础模型
值得一提的是,我们目前正处于计算受限的状态,至少在本课程中是这样的,也就是说,我们有很多数据但计算资源却不多,因此我们需要设计决策来最大限度地利用硬件性能
举例来说,在数据处理时我们会相当激进地进行过滤,因为我们不想把宝贵的计算资源浪费在对相关数据进行更好地分词上。比如构建一个直接处理字节的模型,这非常好,但对于目前的模型架构来说,它的计算效率非常低下,所以我们不得不进行分词以此作为一种提升模型架构效率的手段,此外还有很多设计决策,它们本质上都是出于对训练效率的考虑
我们现在做的大部分工作都只跑一个 epoch,我们只是需要看到更多数据,而不是花费大量时间在某个数据点上。scaling laws 完全是为了效率,我们用更少的计算资源来确定超参数,而对齐方式可能有点不同,但它与效率的关联在于,如果你能把资源投入到对齐上,那么你实际上就不那么需要小型基础模型了
所有大致有两种路径,如果你的应用场景比较窄,你或许可以使用更小的模型,你对其进行对齐或微调就能取得不错的效果;但如果你的应用场景非常广泛,那么可能就没有替代方案来训练一个大模型了,这就是如今的情况,至少对于那些顶尖实验室而言,他们正越来越受到数据的限制
计算能力虽然很重要,但设计上的决策确实会改变。比如,深度学习只用数据跑一个 epoch,这真的有点说不通,如果计算能力更强,为什么我们不跑更多 epoch 或者做些更聪明的事情呢?也许会有不同的模型架构出现,你要知道 transformer 之所以出现,很大程度上是出于对计算效率的考虑,所以这仍然是值得我们思考的事情,这是关于效率问题的讨论
3. Tokenization
OK,现在我们将正式进入第一个单元的学习,Andrej Karpathy 有一个关于分词的非常好的视频 [video],大家感兴趣的可以看看
3.1 Introduction
我们之前谈到过,分词(Tokenization)本质上就是获取通常表示为 Unicode 字符串的原始文本并将其转化为一组整数的过程,其中每个整数代表一个 token。因此我们需要一个程序,能够将字符串编码成 token,再将它们解码回字符串。词汇表大小(vocabulary size)就是一个 token 值的数量,也就是整数范围的数量
3.2 Tokenization examples
下面就举个例子来说明分词器是如何工作的
这里有一个很棒的网站 Tiktokenizer,它能让你查看不同分词器

它其中一个功能就是展示给你一个整数列表,这就是分词器的输出结果,它还会很好地展示原始字符串是如何分解成一段段的,有几点需要注意:
首先,空格也是 token 的一部分,所以不像传统的自然语言处理,空格直接就消失了,这里所有东西都会被考虑进去。这些操作(分词)应该是可逆的,而且不知道什么原因,空格通常会出现在 token 前面:

需要注意,hello 和 hello 是完全不同的两个 token,例如,在上图中,hello 代表的 token 是 24912,而 hello 代表的 token 是 40617
现在的问题是:空格放在 token 前面而不是后面,这是有意为之,还是仅仅是预处理过程的产物?
所以在接下来要讲的 BP 过程中,实际上你需要先进行预分词,然后再对每个部分进行分词。预分词器确实会把空格放在前面,所以这是算法内部构建的,你可以把它放在末尾,但放在开头可能更有道理

如果我们输入数字,你会发现这些数字被切分成了不同的部分,有趣的点是它是从左到右的,所以它肯定不是按千位分组或者其他任何语义上的方式
大家可以自己尝试下,感受一下这些现有的分词器是怎样的
假设现在有一个字符串 Hello, 🌍! 你好!,如果我们使用 GPT-2 分词器,我们会得到对应的索引,代码如下:
def tokenization_examples():
text("Here's the GPT-2 tokenizer from OpenAI (tiktoken) in action.")
tokenizer = get_gpt2_tokenizer()
string = "Hello, 🌍! 你好!" # @inspect string
text("Check that encode() and decode() roundtrip:")
indices = tokenizer.encode(string) # @inspect indices
reconstructed_string = tokenizer.decode(indices) # @inspect reconstructed_string
assert string == reconstructed_string
compression_ratio = get_compression_ratio(string, indices) # @inspect compression_ratio
这段代码将字符串映射到索引,然后解码回得到字符串,这这是一个健全性检查,确保你能够来回转换,执行后输出如下:

另外一个有趣的事情是压缩率(compression ratio)也就是看字节数除以 token 数。那么一个 token 代表多少字节呢?这里的答案是 1.6,意味着每个 token 代表 1.6 字节的数据
上面提到的是一个 GPT 分词器,下面我们将依次介绍几种分词方法
3.3 Character-based tokenization
假设你想实现分词,最简单的方法可能就是基于字符(character-based)的分词。一个 Unicode 字符串就是一连串的 Unicode 字符,并且每个字符都可以转换成一个整数,称为码位(code point)[Code point]
def character_tokenizer():
text("## Character-based tokenization")
text("A Unicode string is a sequence of Unicode characters.")
text("Each character can be converted into a code point (integer) via `ord`.")
assert ord("a") == 97
assert ord("🌍") == 127757
text("It can be converted back via `chr`.")
assert chr(97) == "a"
assert chr(127757) == "🌍"
例如 a 对应 97,世界表情符号 🌍 对应 127757,同理还可以逆向转换变回去。所以你可以定义一个分词器,它只是将每个字符映射到一个码位:
def character_tokenizer():
text("Now let's build a `Tokenizer` and make sure it round-trips:")
tokenizer = CharacterTokenizer()
string = "Hello, 🌍! 你好!" # @inspect string
indices = tokenizer.encode(string) # @inspect indices
reconstructed_string = tokenizer.decode(indices) # @inspect reconstructed_string
assert string == reconstructed_string

那这样做有什么问题呢?首先词汇表非常庞大,其次许多字符相当罕见(例如 🌍),这导致词汇表利用效率低下
3.4 Byte-based tokenization
基于字符的分词是一种非常朴素的方法,此外,你可以采用基于字节(byte-based)的分词方法。Unicode 字符串可以用字节序列来表示,因为每个字符串都可以转换成字节,有些字符比如 a 已经是一个字节了,而有些字符最多会占用四个字节,这里使用的是 Unicode 的 UTF-8 编码
def byte_tokenizer():
text("## Byte-based tokenization")
text("Unicode strings can be represented as a sequence of bytes, which can be represented by integers between 0 and 255.")
text("The most common Unicode encoding is "), link(title="UTF-8", url="https://en.wikipedia.org/wiki/UTF-8")
text("Some Unicode characters are represented by one byte:")
assert bytes("a", encoding="utf-8") == b"a"
text("Others take multiple bytes:")
assert bytes("🌍", encoding="utf-8") == b"\xf0\x9f\x8c\x8d"

当然还有其它编码方式,但 UTF-8 是最常见的、也是最灵活的一种。将字符串转换成字节后,所有的索引值现在都会在 0~255 之间,因为根据定义,一个字节总共有 256 个可能的值,所以你的词汇表非常小
从某些方面来讲,我真希望字节编码能行得通,因为这是最优雅的方式,但它的问题在于长序列,那基于字节的编码处理大块序列有什么问题呢?
我们可以看到字节编码的压缩比是 1,也就是每个 token 是 1 个字节,压缩比为 1 是非常糟糕的一件事情,因为你的序列会非常长,而注意力机制在序列长度 N 上的复杂度是 O(N^2),所以在效率方面会很糟糕
3.5 Word-based tokenization
既然基于字节的分词也行不通,那么现在你可能会想到的是有没有更加灵活的处理方式,比如,我们能不能给每个 token 分配一个字符或一个字节,有些 token 可以代表很多字节,有些 token 可以代表很少字节
所以一种实现方式是基于词(word-based)的分词,这实际上在 NLP 中是非常经典的,假设有一个字符串:
def word_tokenizer():
text("## Word-based tokenization")
text("Another approach (closer to what was done classically in NLP) is to split strings into words.")
string = "I'll say supercalifragilisticexpialidocious!"
segments = regex.findall(r"\w+|.", string) # @inspect segments
你可以把它分成一个片段序列:
segments = ['I', "'", 'll', ' ', 'say', ' ', 'supercalifragilisticexpialidocious', '!']
同时你可以把这些切分好的片段序列称为 token,你只需要用一个正则表达式就行了
# https://github.com/openai/tiktoken/blob/main/tiktoken_ext/openai_public.py#L23
GPT2_TOKENIZER_REGEX = \
r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
def word_tokenizer():
text("## Word-based tokenization")
text("Another approach (closer to what was done classically in NLP) is to split strings into words.")
string = "I'll say supercalifragilisticexpialidocious!"
segments = regex.findall(r"\w+|.", string) # @inspect segments
text("This regular expression keeps all alphanumeric characters together (words).")
text("Here is a fancier version:")
pattern = GPT2_TOKENIZER_REGEX # @inspect pattern
segments = regex.findall(pattern, string) # @inspect segments
上面展示的是 GPT-2 用来预分词的另一个正则表达式,它只是把你的字符串分割成一系列片段,然后你要做的是给每一个片段分配一个整数,整个过程就结束了
那这有什么问题呢?核心的问题在于你的词汇表有点无上限,你不知道它到底有多大,因为对于一个新的输入,你可能会得到一个你以前从未见过的片段,另外许多词汇较为罕见,模型也无法充分学习它们,所以这种基于词的方法实际上是个非常麻烦的事
它其实捕捉到了正确的关于形容词性的直觉,但它不是我们在这里真正想要的
3.6 Byte Pair Encoding (BPE)
在这里我们终于要谈论 BPE 编码,也就是字节对编码了,[Wikipedia],实际上这是一个非常古老的算法,由 Philip Gage 在 94 年为数据压缩而开发 [article]
而它首次被引入到 NLP 领域是用于机器翻译 [Sennrich+ 2015],在此之前,无论是机器翻译还是其它 NLP 任务,基本上都使用的是基于词(word-based)的分词方法
我们可以使用这个源自 94 年的出色算法实现分词,且不必处理未知词汇(unks)或任何类似的问题,GPT-2 就是使用 BPE 分词器训练的 [Radford+ 2019]
BPE 的基本思想是:不预先设定好如何分割的概念,我们在原始文本上训练分词器。可以说这是一种直觉,因此,很自然地,对于跨越多个字符的常见序列我们会尝试表示为一个 token,而罕见的序列则会表示为多个 token
这里有个小细节,为了效率 GPT-2 的论文中使用了基于词的分词器作为一种预处理方法将其分解成片段,然后在每个片段上运行 BPE 算法,这也是你在这门课里将会做的事情
BPE 算法实际上非常简单,我们首先将字符串转换成字节序列,这点我们在讨论字节分词时已经做过了,现在我们将重复地、连续地合并最常见的相邻 token 对。所以,直觉是如果一个 token 对频繁出现,那么我们就把它压缩为一个 token
3.7 Training and Using the tokenizer
那我们来看看 BPE 这个算法是什么样子的:
def train_bpe(string: str, num_merges: int) -> BPETokenizerParams: # @inspect string, @inspect num_merges
text("Start with the list of bytes of `string`.")
indices = list(map(int, string.encode("utf-8"))) # @inspect indices
merges: dict[tuple[int, int], int] = {} # index1, index2 => merged index
vocab: dict[int, bytes] = {x: bytes([x]) for x in range(256)} # index -> bytes
for i in range(num_merges):
text("Count the number of occurrences of each pair of tokens")
counts = defaultdict(int)
for index1, index2 in zip(indices, indices[1:]): # For each adjacent pair
counts[(index1, index2)] += 1 # @inspect counts
text("Find the most common pair.")
pair = max(counts, key=counts.get) # @inspect pair
index1, index2 = pair
text("Merge that pair.")
new_index = 256 + i # @inspect new_index
merges[pair] = new_index # @inspect merges
vocab[new_index] = vocab[index1] + vocab[index2] # @inspect vocab
indices = merge(indices, pair, new_index) # @inspect indices
return BPETokenizerParams(vocab=vocab, merges=merges)
我们将会用 cat 和 hat 作为例子,然后会把这个转换成一串整数,这些就是字节:

然后我们要记住我们合并了哪些字节,merges 是两个整数的映射,这两个整数可以代表字节或其它已有的 token。vocab 用来表示索引到字节的映射。
接着我们要讲 BP 算法了,它非常简单,下面带大家过一遍代码:(from ChatGPT)
for i in range(num_merges):
text("Count the number of occurrences of each pair of tokens")
counts = defaultdict(int)
for index1, index2 in zip(indices, indices[1:]): # For each adjacent pair
counts[(index1, index2)] += 1 # @inspect counts
text("Find the most common pair.")
pair = max(counts, key=counts.get) # @inspect pair
index1, index2 = pair
text("Merge that pair.")
new_index = 256 + i # @inspect new_index
merges[pair] = new_index # @inspect merges
vocab[new_index] = vocab[index1] + vocab[index2] # @inspect vocab
indices = merge(indices, pair, new_index) # @inspect indices
BPE 的训练过程是个 贪心压缩 过程,在当前序列(这里是 indices,即 UTF-8 字节序列)上,统计所有相邻二元组(bigrams)出现频次 → 挑选频次最高的那一对 → 把序列里所有不重叠出现的该对同时合并成一个新 token → 重复 num_merges 轮。每完成一轮,就把词汇表 vocab 和合并规则表 merges 扩充一条
下面我们来逐行解析:
for i in range(num_merges):
counts = defaultdict(int)
for index1, index2 in zip(indices, indices[1:]): # For each adjacent pair
counts[(index1, index2)] += 1

zip(indices, indices[1:])枚举序列里的 所有相邻对,例如[a, b, a]会产生[a, b]和[b, a]counts统计每个二元组出现的次数,注意 是按当前轮的 token 序列统计,前几轮已经合并的 token 都将当成单个 token 参与下一轮统计
pair = max(counts, key=counts.get)
index1, index2 = pair

- 选出 频次最高 的二元组
pair(BPE 的核心贪心步骤) - 并列最高如何打破?
- python 里
max(counts, key=counts.get)若遇到并列,则返回 字典里先出现的那个键。而counts的键的首次出现顺序取决于 从左到右扫描序列时首次遇见该 pair 的位置,所以我们在这里返回的 pair 是(116, 104) - 这意味着:并列时会倾向于 更靠左先出现 的 pair,保证了结果的可复现性
- python 里
new_index = 256 + i
merges[pair] = new_index
vocab[new_index] = vocab[index1] + vocab[index2]

- 初始词表用的是 0~255 的原始字节;从
256开始往后分配新 token 的 id,确保 不与原始字节冲突 merges[pair] = new_index记录把(index1, index2)合并成new_index这条 规则- 👉 以后 编码 新文本时,会按训练得到的 合并次序 去反复应用这些规则
vocab[new_index] = vocab[index1] + vocab[index2]:vocab存 token id → 对应的字节串。新 token 的字节串就是两个旧 token 字节串的拼接。这样在 解码 时,只需把 token 的字节串拼回来、再按 UFT-8 解码,就能还原原文本
indices = merge(indices, pair, new_index)

- 把 当前序列 中 所有不重叠 的
pair替换为new_index,得到下一轮使用的新序列 - 下一轮在新序列上再计算新的最频
pair,继续合并
其中的 merge 实现(非重叠、从左到右)如下:
def merge(indices: list[int], pair: tuple[int, int], new_index: int) -> list[int]: # @inspect indices, @inspect pair, @inspect new_index
"""Return `indices`, but with all instances of `pair` replaced with `new_index`."""
new_indices = [] # @inspect new_indices
i = 0 # @inspect i
while i < len(indices):
if i + 1 < len(indices) and indices[i] == pair[0] and indices[i + 1] == pair[1]:
new_indices.append(new_index)
i += 2
else:
new_indices.append(indices[i])
i += 1
return new_indices
以 the cat in the hat 为例,我们来看下 3 轮统计后的变化,初始 indices = [116,104,101,32,99,97,116,32,105,110,32,116,104,101,32,104,97,116]
第 1 轮
相邻对序列(从左到右):
(116,104) (104,101) (101,32) (32,99) (99,97) (97,116) (116,32)
(32,105) (105,110) (110,32) (32,116) (116,104) (104,101)
(101,32) (32,104) (104,97) (97,116)
频次统计:
- 2 次:
(116,104) = th,(104,101) = he,(101,32) = e,(97,116) = at - 1 次:
(32,99),(99,97),(116,32),(32,105),(105,110),(110,32),(32,116),(32,104),(104,97)
有并列的最大频次(都是 2 次),按谁先在序列中出现,最先出现的是 (116,104) 即 th
- 选中的 pair:
(116,104)→ 新 id 256 - 按不重叠合并后,序列变为:
[256, 101, 32, 99, 97, 116, 32, 105, 110, 32, 256, 101, 32, 104, 97, 116]
- 词表含义更新:
vocab[256] = b"th"
第 2 轮
在新序列上数相邻对:
- 2 次:
(256,104) = the,(101,32) = e,(97,116) = at - 1 次:
(32,99),(99,97),(116,32),(32,105),(105,110),(110,32),(32,256),(32,104),(104,97)
最大频次又有并列(2 次),按先出现优先,最先出现的是 (256,101) 即 the
- 选中的 pair:
(256,101)→ 新 id 257 - 合并后序列:
[257, 32, 99, 97, 116, 32, 105, 110, 32, 257, 32, 104, 97, 116]
- 词表含义更新:
vocab[257] = vocab[256] + b"e" = b"the"
第 3 轮
再次统计:
- 2 次:
(257,32) = the,(97,116) = at - 1 次:
(32,99),(99,97),(116,32),(32,105),(105,110),(110,32),(32,257),(32,104),(104,97)
并列最大(2 次),最先出现的是 (257,32) 即 the (注意有空格)
- 选中的 pair:
(257,32)→ 新 id 258 - 合并后序列:
[258, 99, 97, 116, 32, 105, 110, 32, 258, 104, 97, 116]
- 词表含义更新:
vocab[258] = vocab[257] + b" " = b"the "
现在我们完成了 BPE 分词器的训练,接下来我们来试试这个分词器:
def bpe_tokenizer():
text("## Training the tokenizer")
string = "the cat in the hat" # @inspect string
params = train_bpe(string, num_merges=3)
text("## Using the tokenizer")
text("Now, given a new text, we can encode it.")
tokenizer = BPETokenizer(params)
string = "the quick brown fox" # @inspect string
indices = tokenizer.encode(string) # @inspect indices
reconstructed_string = tokenizer.decode(indices) # @inspect reconstructed_string
assert string == reconstructed_string

我们的字符串是 "the quick brown fox",我们将它编码成一个索引序列,然后使用我们训练好的 BP 分词器进行解码,让我们来具体看看其中的 encode 和 decode 过程:
class BPETokenizer(Tokenizer):
"""BPE tokenizer given a set of merges and a vocabulary."""
def __init__(self, params: BPETokenizerParams):
self.params = params
def encode(self, string: str) -> list[int]:
indices = list(map(int, string.encode("utf-8"))) # @inspect indices
# Note: this is a very slow implementation
for pair, new_index in self.params.merges.items(): # @inspect pair, @inspect new_index
indices = merge(indices, pair, new_index)
return indices
def decode(self, indices: list[int]) -> str:
bytes_list = list(map(self.params.vocab.get, indices)) # @inspect bytes_list
string = b"".join(bytes_list).decode("utf-8") # @inspect string
return string
encode 在做什么
def encode(self, string: str) -> list[int]:
indices = list(map(int, string.encode("utf-8")))
# Note: this is a very slow implementation
for pair, new_index in self.params.merges.items():
indices = merge(indices, pair, new_index)
return indices

- 1. 把字符串转成 UTF-8 字节
- 2. 按训练得到的合并顺序逐条应用 merge 规则
- 每条规则用
merge函数在 当前整段序列上 进行一次 不重叠替换
- 每条规则用
decode 在做什么(为什么保证可逆)

- 1. 查词表把 token 还原成字节串
- 在训练时构造了词表
vocab,现在只要通过vocab.get(id)就能拿回每个 token 的 原始字节串
- 在训练时构造了词表
- 2. 把所有字节串拼起来再按 UFT-8 解码
代码非常简单,正因为它简单,所以也非常低效。例如,编码过程会循环处理合并操作,你应该只遍历那些重要的合并操作,还有一些附加功能,比如预分词前的特殊 token,所以你的作业基本上要以此为出发点,你的目标是要提高实现速度
3.8 Summary
OK,我们来看看关于分词器的总结
分词器在字符串和整数序列之间进行映射,我们看了基于字符的分词器、基于字节的分词器、基于词的分词器,由于各种原因,它们远非最优。
BPE 是一个诞生于 1994 年的非常古老的算法,但它至今仍被证明是一种有效的启发式方法,重点是它会观察你的语料库统计数据,从而针对关于如何最好地自适应分配词汇以表示字符序列的问题来做出更明智的决策
OK,以上就是本次讲座的全部内容了
下次我们将深入探讨 PyTorch 的细节,并讲解基础模块同时关注资源管理
结语
第一讲我们首先对本次课程做了一个概述,课程总共包括 basics、systems、scaling laws、data 以及 alignment 五个单元,基础单元要学习的是分词、模型架构和训练过程,在这个单元我们目标是跑通一个基础版的完整流程。系统单元主要探讨模型的优化,如何最大限度地利用硬件性能,包括 kernel、parallelism 以及 inference 三个组成部分;缩放定律单元的目标是在小规模训练上进行实验,然后在大规模的训练上预测超参数和损失;数据单元我们主要聚焦于数据评估、数据筛选以及数据的去重;最后对齐单元涉及到强化学习的一些知识,包括 PPO、DPO、GRPO 等算法的讲解。
接着我们讲解了 Tokenization 分词,分词本质上就是将原始字符串转换为一组整数,而每一个整数就是一个 token。分词的方法有很多,我们了解了基于字符的、基于字节的、基于词的,但它们总存在一些问题,无法满足我们的需求。而 BPE 这个古老算法给了我们启发,BPE 算法非常简单,首先将字符串转换成字节序列,接着重复、连续地合并最常见的相邻 token 对,把它压缩为一个 token。其好处是在保证能覆盖任意输入的前提下,把高频片段压缩为单个 token,从而减少序列长度、提高模型效率、降低词表稀疏性,同时避免 OOV(Out-Of-Vocabulary) 问题。
整个讲解非常通俗易懂,大家感兴趣的可以看看
下一讲我们将深入探讨 PyTorch 的细节,并讲解基础模型同时关注资源管理,敬请期待🤗
参考
更多推荐



所有评论(0)