这是25年春CS336的课堂笔记和作业,课程网站为Stanford CS336 | Language Modeling from Scratch,课程视频可在哔哩哔哩上观看:斯坦福CS336:大模型从0到1

此课程内容涵盖分词、模型架构、系统优化、数据处理和模型对齐等方面,通过从零开始构建语言模型,深入理解NLP和AI的核心技术。

我的作业备份仓库:zlh123123/CS336_spring2025: CS336的作业与课程笔记

Tokenization

什么是分词(Tokenization)

分词是将**字符串(文本)转换为令牌(tokens,通常是整数索引)**的过程,以便语言模型处理。反过来,也需要将令牌解码回字符串。分词器(Tokenizer)需要实现以下两个方法:

  • encode:将字符串编码为整数序列(tokens)。
  • decode:将整数序列解码回字符串。
1
2
3
string = "Hello, 🌍! 你好!"

indices = [15496, 11, 995, 0]

分词评估指标

  • 词汇表大小(Vocabulary Size)

    指分词器能够生成的所有可能 token 的数量,通常表示为整数索引的范围。

  • 压缩率(Compression Ratio)

    压缩率衡量每个 token 平均代表的字节数,计算公式为:

    Compression Ratio=num_bytesnum_tokens\text{Compression Ratio} = \frac{\text{num\_bytes}}{\text{num\_tokens}}

    其中num_bytes表示输入字符串的 UTF-8 编码字节数;num_tokens表示分词后生成的 token 数量。

  • 序列长度(Sequence Length)

    分词后生成的 token 序列的长度,即 len(indices)。它直接影响语言模型的计算成本和性能。

分词策略

基于字符的分词(Character-based Tokenization)

顾名思义就是将每个 Unicode 字符转换为对应的代码点(整数),例如:

1
"Hello, 🌍!" → [72, 101, 108, 108, 111, 44, 32, 127757, 33]

这种分词方法简单直接,适用于任何 Unicode 文本;同时解码和编码过程明确且可逆。

但是:

  • 词汇表过大:Unicode 字符约有 150,000 个,导致词汇表巨大,模型学习效率低。
  • 稀有字符问题:如 🌍 等字符使用频率低,浪费词汇表空间。
  • 压缩率低:每个字符一个 token,序列长度长,影响模型效率(尤其是 Transformer 的上下文长度受限)。

基于字节的分词(Byte-based Tokenization)

将字符串编码为 UTF-8 字节序列,每个字节是一个整数(0-255)。例如:

1
2
3
4
assert bytes("a", encoding="utf-8") == b"a"

# UTF-8 编码可能包含多个字节,尤其是对于非 ASCII 字符(如 🌍)
assert bytes("🌍", encoding="utf-8") == b"\xf0\x9f\x8c\x8d"

这种分词方式显然词汇表很小,毕竟只有256种可能。但是压缩率差,因为一个字符可能对应多个byte,压缩率几乎为1,导致 token 序列过长(注意力机制的二次方复杂度,token过长效率下降)。

UTF-8、ASCII 和 Unicode字符编码

  • ASCII码:被设计用于仅英文字符的编码标准,包括大小写英文字母、10个数字、标点与空格/制表符这种控制字符。通常使用8位二进制来表示字符,例如字母 ‘A’ 的 ASCII 码是 65(二进制:01000001)。
  • Unicode:旨在为世界上几乎所有语言的字符分配一个唯一的编码(码点),目前定义了超过 14 万个字符,涵盖多种语言、符号、表情等。码点通常表示为 U+XXXX,例如汉字“中”的 Unicode 码点是 U+4E2D。这种方法解决了 ASCII 的局限性,支持多语言文本。
  • UTF-8:其中一种将 Unicode 码点编码为字节序列的变长编码方案,用于在计算机中存储或传输 Unicode 字符。其可以兼容ASCII码(ASCII 字符0-127在 UTF-8 中直接用 1 字节表示,与 ASCII 编码相同);此外还根据字符的 Unicode 码点进行变长编码,比如中文用3 byte,表情符号用4 byte。

基于单词的分词(Word-based Tokenization)

使用正则表达式(如 \w+|.)将文本分割为单词或标点。例如:

1
"I'll say supercalifragilisticexpialidocious!" → ["I'll", "say", "supercalifragilisticexpialidocious", "!"]

这种分词更符合人类语言的语义单位(单词),压缩率也能比较高,它面临的问题就是:

  • 词汇表过大:单词数量可能非常多(尤其是罕见单词)。
  • 稀有单词问题:新词或未见过的词需要用特殊 token(如 <UNK>)表示,影响模型性能。
  • 词汇表大小不固定:依赖训练数据,难以控制。

字节对编码(Byte Pair Encoding, BPE)

字节对编码(Byte Pair Encoding, BPE)是一种数据压缩算法,最初用于压缩文本,后来被适配到NLP中,BPE 的核心思想是:

  • 字节级别开始,将最常见的**相邻字节对(byte pairs)**合并为新的 token,逐步构建一个词汇表。
  • 通过合并规则,将常见的字符序列表示为单个 token,减少 token 序列长度,提高压缩率,同时保持适中的词汇表大小。

BPE 的核心是通过统计语料中的字节对频率,迭代合并最常见的字节对,生成新的 token。它分为两个阶段:

  1. 训练阶段:根据语料统计字节对频率,生成词汇表(vocab)和合并规则(merges)。
  2. 编码/解码阶段:使用训练好的词汇表和合并规则,将输入字符串编码为 token 序列,或将 token 序列解码回字符串。

训练过程是这样的:假设输入为the cat in the hat

  • Step 1:把文字拆成最小的字节

    将输入的文字转成 UTF-8 字节(每个字节是个 0-255 的数字),例如the cat in the hat被转换为:

    1
    [116, 104, 101, 32, 99, 97, 116, 32, 105, 110, 32, 116, 104, 101, 32, 104, 97, 116]
  • Step 2:找最常见的组合

    BPE 会数一数,哪些两个字节老是挨着出现。比如,“t”和“h”(116, 104)出现了两次(“the”和“the”里各一次)。因此它决定把“t”和“h”粘在一起,变成一个新块,编号是 256(因为 0-255 已经被字节用完了),然后把所有“t h”替换成 256。现在句子变成:

    1
    2
    # 256 表示“th”
    [256, 101, 32, 99, 97, 116, 32, 105, 110, 32, 256, 101, 32, 104, 97, 116]
  • Step 3:再找下一个常见组合

    现在发现“th”和“e”(256, 101)出现了两次,把“th”和“e”粘成一个新块,编号 257(表示“the”)。替换后,句子变成:

    1
    2
    # 257 表示“the”
    [257, 32, 99, 97, 116, 32, 105, 110, 32, 257, 32, 104, 97, 116]
  • Step 4:还在GO

    再找常见组合,比如“a”和“t”(97, 116)出现两次(“cat”和“hat”里),粘成新块,编号 258(表示“at”)。替换后,句子变成:

    1
    2
    # 258 表示“at”
    [257, 32, 99, 258, 32, 105, 110, 32, 257, 32, 104, 258]
  • Step 5:STOP条件

    可以设定一个次数(比如合并 3 次),或者直到词汇表大小合适(比如 500 个块)就停下来。

    最终得到:

    • 词汇表:记录每个编号对应的块,比如 {116: b't', 104: b'h', 256: b'th', 257: b'the', 258: b'at'}
    • 合并规则:记录哪些块粘在一起,比如 {(116, 104): 256, (256, 101): 257, (97, 116): 258}

对于编码,假设输入为the quick brown fox,按步骤操作:

  • 先转成字节

    1
    [116, 104, 101, 32, 113, 117, 105, 99, 107, 32, 98, 114, 111, 119, 110, 32, 102, 111, 120]
  • 按合并规则替换:

    1. “t h” → 256(th):[256, 101, 32, 113, 117, 105, 99, 107, …]
    2. “th e” → 257(the):[257, 32, 113, 117, 105, 99, 107, …]
    3. 没有“a t”组合,停止。
  • 最终

    1
    [257, 32, 113, 117, 105, 99, 107, 32, 98, 114, 111, 119, 110, 32, 102, 111, 120]

BPE能够实现:

  • 动态词汇表:通过训练自动生成词汇表,适应不同语料(如英语、中文、代码等);可以通过 num_merges 控制词汇表大小。
  • 高压缩率:常见序列被合并为单一 token,减少序列长度。
  • 未见字符或词可以通过字节级别表示,不会产生<UNK> token。

Assignment 1 (Part 2)

CS336_spring2025/assignment1-basics/cs336_basics/Part2

Pytorch基本构件与资源管理

内存管理

**张量(tensors)**是深度学习的基本构建块,用于存储参数、梯度、优化器状态和数据。张量的创建方式多种多样:

1
2
3
4
x = torch.tensor([[1, 2, 3], [4, 5, 6]])  # @inspect x
x = torch.zeros(4, 8) # 4x8 全0矩阵
x = torch.ones(4, 8) # 4x8 全1矩阵
x = torch.randn(4, 8) # 4x8 满足正态分布

张量拥有不同数据类型(float32、float16、bfloat16、FP8),不同数据类型也决定了这个张量占多少空间。

  • float32(fp32、单精度)

    img

    作为创建张量时的默认数据类型。一个fp32变量占用32bit空间,即4字节。

    此外还有float64(fp64,双精度),但在深度学习中并不常用

  • float16(fp16,半精度)

img

内存占用相比fp32减半。fp16相比fp32就不适合表示特别大或者特别小的数(上溢或下溢)。

  • bfloat16

    img

    float16的进化版,在fp16的基础上扩大了数的表示范围,减小了数值的表示精度(但这个对深度学习来说影响不大)。

  • fp8

    更加粗略的数据类型,包括E4M3和E5M2两种,分别对应不同的范围和精度。

在日常的训练过程中,我们常会在模型不同层、不同存储内容中采用不同的数据类型,即混合精度训练(mixed precision training)

计算管理

张量在默认情况下保存在CPU内存中,可以通过下面的指令将张量转移至GPU内存中:

1
2
3
4
5
x = torch.zeros(32, 32)
y = x.to("cuda:0")

# 或者直接在GPU中创建
z = torch.zeros(32, 32, device="cuda:0")

Pytorch中的张量实际上以数组的形式存储,如果知道该元素在张量中的坐标,就能知道其在数组中的位置。

在矩阵乘法中,张量的用法是这样的:

1
2
3
4
x = torch.ones(16, 32)
w = torch.ones(32, 2)
y = x @ w
assert y.size() == torch.Size([16, 2])

LLM基本架构

最原汁原味的transformer架构可见机器学习与深度学习基础 | 好急好急的Hexo博客,本节讨论的内容主要是transformer中的各个组件的变体及当前最新的改进版transformer。