跟着LAP啥都学 跟着LAP啥都学

第 2 章 文本 → 词元 → 向量

      +

      本章导读:把一段原始文本加工成能直接输入注意力模块的张量。

      学完本章你将能够:

      1. 解释为什么必须嵌入,说出它与 word2vec 的三条本质区别;

      2. 比较三种分词粒度各自的代价,说明工业界选择子词级的原因;

      3. 实现朴素分词器与词汇表,并复现它在未登录词上的失败;

      4. 说明 BPE 的合并循环,推导 GPT-2 词表 50257 的构成;

      5. 用滑动窗口生成训练标签,分析 stride / drop_last 的权衡;

      6. 理解嵌入层的查表本质与随机初始化的含义;

      7. 补上位置嵌入,理解 GPT-2 为什么选择可学习的绝对位置嵌入;

      8. 掌握全章数据的形状链:文本 → ID → [batch, token, dim]。

      产出与代码:chapters/chapter02_text_data/(include/simple_tokenizer.h、 include/bpe_tokenizer.h、include/torchtext_gpt2_bpe.h、src/main.cpp)。

      2.1 LLM 只处理数字

      大语言模型不认识文字。

      神经网络只对数字张量做运算,无法对字符串求梯度——就像无法对「你好」两个字做乘法。文本必须先变成数字,训练和推理才有出发点。

      所以在谈注意力、谈训练之前,得先解决一个基础问题:怎么把文字变成数字,而且是携带语义信息的数字。这道工序叫嵌入(embedding):把离散对象(单词、图像,甚至整个文档)映射到连续向量空间中的点。

      这串数字不能随意指定。语义相近的词,向量距离也近:「猫」和「狗」在空间中彼此接近,「猫」和「微分方程」相距很远。距离与方向都携带语义,后续的数学运算才有意义。

      维度指的是向量的长度。维度越高,能表达的语义细节越多,计算开销也越大——这是性能与效率的典型权衡。参考规模:GPT-2 最小版用 768 维,GPT-3 用 12288 维。嵌入不限于词——句子、文档都有对应方案(RAG 检索使用的就是文档嵌入),但类 GPT 模型逐词生成,本教程只关心词嵌入。

      word2vec 与 LLM 嵌入的区别

      提到词嵌入,绕不开 word2vec——2013 年 Google 的 Mikolov 团队提出的模型。它的核心思想,一句话就能说清:经常出现在相似上下文里的词,语义大概率相似。

      这个思路是正确的,但 LLM 的嵌入和 word2vec 的嵌入在机制上有三条本质区别:

      word2vec 大语言模型

      嵌入来源

      独立模型专门训练后产出,供人使用

      模型自己的第一层,随模型一同训练

      是否随训练更新

      否——训练完成后固定

      是——可训练参数,反向传播每步都会更新

      认不认上下文

      「苹果」在水果和手机语境里是同一个向量

      进入注意力后结合上下文,同一个词在不同句子中有不同的表示

      概括来说:word2vec 是先训练好词向量再使用,LLM 的嵌入矩阵本身就是模型参数、随训练更新。这条区别会在 2.6 节落到代码——届时会看到嵌入权重如何随机初始化、如何参与训练。

      全章流水线

      本章的任务是把下面这条流水线从头走通:输入一段文本,输出可送入注意力模块的向量张量:

      Diagram

      在进入流水线之前,先回答一个前置问题:文本应该按什么单位切分。

      2.2 分词粒度的选择

      嵌入解决了「词变数字」,但跳过了一步:变成数字之前,先要把文本切分成词元。切分粒度直接决定模型的能力上限——切得太粗,遇到未登录词就无法处理;切得太细,序列过长,计算代价高。三种粒度对比如下:

      粒度 例:"The quick brown fox" 代价

      字符级

      ["T","h","e"," ",…​]

      序列极长,模型需要更多计算步数才能学到「词」

      单词级

      ["The","quick","brown","fox"]

      词表规模失控;遇到未登录词即失败(OOV)

      子词级(BPE)

      常见词保留整词,生僻词拆成小块

      两者折中——GPT-2 的选择,词表固定 50257

      结论是明确的:现代 LLM 均采用子词级分词。字符级主要用于教学演示,单词级问题过多,子词级是工业界的通用方案。

      分词也不是可以忽略的预处理:它划定模型的能力边界,而主流 API 按词元计费,切分方式直接决定成本。

      要理解 BPE 的价值,最直接的办法是先实现「没有 BPE」的版本,观察它的问题。下一节就这么做。

      2.3 朴素分词器及其局限

      朴素分词器不含任何分词算法,只靠正则表达式切词。它实现简单,但遇到词表外的词就会失败。

      正则切词:四步迭代

      分词正则很难一次写对,实践中需要逐步迭代,每一步解决前一步的一个具体问题:

      步 正则 遗留问题

      ①

      (\s) 只按空白切

      标点仍与单词相连(Hello,、world.)

      ②

      ([,.]|\s) 把标点切开

      列表里混入空白字符项和空串

      ③

      过滤 item.strip() 非空项

      干净了,但只认逗号和句号

      ④

      ([,.:;?_!"()']|--|\s) 覆盖引号/冒号/双破折号

      ✅ 最终版

      最终效果(也是 C++ 实现的验收样例):

      "Hello, world. Is this-- a test?"
      → ['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']

      C++ 用 std::sregex_iterator 等价实现 Python 的 re.split,定义在 include/simple_tokenizer.h:

      // Python re.split("(pattern)", text) 的等价:分隔符之间的文本和分隔符本身都保留
      inline std::vector<std::string> re_split_capture(const std::string& text,
                                                       const std::string& pattern) {
          std::vector<std::string> parts;
          std::regex re(pattern);
          std::sregex_iterator it(text.begin(), text.end(), re), end;
          size_t pos = 0;
          for (; it != end; ++it) {
              const std::smatch& m = *it;
              parts.push_back(text.substr(pos, m.position() - pos));  // 分隔符前
              parts.push_back(m.str(0));                              // 分隔符本身
              pos = m.position() + m.length();
          }
          parts.push_back(text.substr(pos));  // 尾部
          return parts;
      }
      
      // 分割后过滤空串与纯空白:[t.strip() for t in result if t.strip()]
      inline std::vector<std::string> split_tokens(const std::string& text,
                                                   const std::string& pattern);

      这里有两个设计取舍:不要把文本统一转小写——保留大写有助于模型区分专有名词、理解句子结构;空白字符单独编码还是移除?移除省内存省计算,但会丢掉缩进信息——Python 代码缩进即语法,处理代码类文本不能这么干。本教程语料是小说,没有这个顾虑,暂时移除以简化输出,BPE 方案会以 Ġ 的形式把空白信息恢复回来(见 2.4)。

      词汇表的构建

      分词产出的是字符串,模型需要数字,因此还要构建词汇表(vocabulary),流程固定:

      std::set<std::string> unique(all_words.begin(), all_words.end());  // 排序+去重
      // 1130 个唯一词元,enumerate 编号 → vocab('!'=0, '"'=1, ... 'Her'=49, 'Hermia'=50)

      对 The Verdict 全文分词得到 4690 个词元;去重后有 1130 个唯一词元,逐个编号。语料是一篇 20,479 字符的短篇小说,位于 data/the-verdict.txt。SimpleTokenizerV1 负责 encode/decode:

      class SimpleTokenizerV1 {
      public:
          explicit SimpleTokenizerV1(const std::unordered_map<std::string, int>& vocab);
          std::vector<int> encode(const std::string& text) const;  // 分词 → 查表 → ID
          std::string decode(const std::vector<int>& ids) const;   // 反查 → 拼接 → 去标点前空格
      private:
          std::unordered_map<std::string, int> str_to_int_;
          std::unordered_map<int, std::string> int_to_str_;        // 逆向词汇表
      };

      特殊词元与失败示例

      还剩两个问题:词表中没有的词怎么处理?两段独立文本拼接时,边界如何标记?标准做法是引入特殊词元:未知词统一替换为 <|unk|>,文本边界用 <|endoftext|> 标记——预训练用它拼接多个文档,模型由此知道两段文本虽相连但内容无关。SimpleTokenizerV2 加入这两个词元后,词表规模从 1130 增加到 1132:

      text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace."
      [V2] encode(text) = [1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, ...]
                             ^Hello 被替换成 <|unk|>          ^<|endoftext|> 完整保留
      [V2] unk_id = 1131, eot_id = 1130

      更完整的特殊词元家族还包括 [BOS](序列开始)、[EOS](序列结束)、[PAD](填充)。GPT-2 的实际做法是三项都不使用:

      1. 不用 <|unk|>——BPE 把生僻词拆成子词,从源头避免未登录词(见 2.4);

      2. 不用 [BOS]/[PAD]——一个 <|endoftext|> 兼任结束符和填充符(HF 官方 GPT-2 分词器配置里,unk/bos/eos 三个字段全部指向它——一个词元承担三种职责);

      3. 填充位不计损失——批量训练时,填充位置用掩码让模型不关注(第 7 章的 -100 掩码会落实这个机制)。

      最后看失败示例:V1 对 "Hello, do you like tea?" 直接抛异常——"Hello" 在这篇小说里从未出现,词表中查不到。一部短篇小说构建的词表,处理不了一句日常问候。这是小语料加词级切分的主要缺陷,也是引入 BPE 的直接动机。

      2.4 字节对编码(BPE)

      BPE(Byte Pair Encoding,字节对编码)的解法很直接:词表里没有的词,拆成词表里有的子词。 任何生僻词、自造词、错别字,最终都能拆成子词甚至单个字符——模型总能查到,未登录词问题从根源上解决。

      BPE 的起源

      BPE 最初并不是为 NLP 设计的。它 1994 年问世时的本职是数据压缩——把文本中反复出现的字节对换成一个新符号,文本随之变短。2016 年 Sennrich 等人把它引入神经机器翻译来处理生词问题,随后 GPT-2 将它用作官方分词器,自此成为行业标准。

      BPE 的核心循环分三步:

      ① 把所有单个字符(a、b、…)加入词汇表
      ② 扫一遍语料,找出最常一起出现的相邻字符对,合并成一个新 token
      ③ 重复②——把刚合并出的 token 继续参与下一轮合并,直到凑够预算

      高频组合逐步合并成新词元,低频组合保持零散:常见词最终合并成整词,生僻词停在较小的子词。这就是 BPE 的全部原理。

      GPT-2 词表:50257 的构成

      GPT-2 词表的规模由三个数字相加得到:

      256 + 50000 + 1 = 50257

      • 256:全部字节。这是基础——任何语言、任何 emoji 本质上都是字节流,256 个字节保证任何输入都能表示;

      • 50000:合并规则产出的词元数量;

      • 1:特殊词元 <|endoftext|>,ID 为 50256,是全词表中最大的 ID。

      配套数据是两份文件(位于 data/,由 scripts/download-data.sh 下载),二者完全定义了这个分词器:

      文件 内容

      encoder.json

      token → id 映射(等价 HF vocab.json)

      vocab.bpe

      合并规则,每行 a b,行号即优先级(等价 HF merges.txt)

      Ġ:字节到可打印字符的映射

      打开词表文件,会看到大量 Ġ 开头的词元,例如 Ġworld。这不是乱码,而是编码设计的结果。

      GPT-2 的 token 基于字节,但字节里有不少不可打印字符(空格、换行),直接存词表无法阅读。于是有一个 bytes_to_unicode 映射步骤,把不可打印字节搬进「可打印字符」的安全区——空格映射为 Ġ。这个设计的实际效果是:空格保留在 token 内部,Ġworld(带前导空格)和 world(不带)是两个不同的词元。回到 2.3 节「要不要移除空白」的问题,答案在这里:BPE 用 Ġ 完整保留了空格信息。

      Python 生态使用 tiktoken(OpenAI 官方实现,基于 Rust,速度快);本教程的 C++ 版从零实现,核心流程分五步:

      // 流程:文本 UTF-8 -> bytes -> bytes_to_unicode 映射为可打印字符
      //       -> 正则切分成"单词"(含前导空格) -> 单词内贪心 merge -> 查表得 token id
      class BpeTokenizer {
      public:
          BpeTokenizer(const std::string& encoder_json_path, const std::string& vocab_bpe_path);
          std::vector<int> encode(const std::string& text,
                                  const std::vector<std::string>& allowed_special = {}) const;
          std::string decode(const std::vector<int>& ids) const;
          size_t vocab_size() const { return encoder_.size(); }
          int token_id(const std::string& token) const;   // 不存在返回 -1
      private:
          std::unordered_map<std::string, int> encoder_;               // token -> id
          std::map<std::pair<std::string, std::string>, int> merges_;  // (a,b) -> rank
          std::array<std::string, 256> byte_to_char_;                  // 字节 → 可打印字符
          ...
      };

      一个常见的坑:编码 <|endoftext|> 这类特殊词元时,必须显式传入 allowed_special,声明这些词元不要按普通文本拆分——tiktoken 与本实现遵循同一约定。漏传时,特殊词元会被拆成碎片,不报错但结果错误——这类错误比直接崩溃更难排查:

      std::vector<int> encode(const std::string& text,
                              const std::vector<std::string>& allowed_special = {}) const;
      // 调用:tokenizer.encode(text, {"<|endoftext|>"})

      交叉验证与确定性输出

      如何证明手写版实现正确?仓库接入了 torchtext 官方的 GPT2BPEEncoder(include/torchtext_gpt2_bpe.h + third_party/torchtext/,依赖 libre2-dev)。main.cpp 取一段不含特殊词元的文本,对手写版与官方版做逐 ID 对比,结果完全一致。这是实现正确性的直接证据。

      ./scripts/run.sh chapter02_text_data
      词汇表大小 vocab_size = 50257
      token_id('<|endoftext|>') = 50256        token_id('Hello') = 15496
      
      encode("Hello, do you like tea? <|endoftext|> In the sunlit terraces
              of someunknownPlace.")
            = [15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, ...]
      
      decode(integers) = "Hello, do you like tea?  In the sunlit terraces of someunknownPlace."

      输出里有两个细节:someunknownPlace 这样的未登录词没有变成 <|unk|>,而是被拆成 some/unknown/Place 三个子词——子词拆分保证任何词都能编码;220 是孤立的空格词元(Ġ),紧挨在 <|endoftext|> 之前。

      同一份语料,两种分词,两个数字:正则简易分词 4690 个词元(词表 1130),BPE 5145 个词元(词表 50257)。BPE 会拆分子词,词元数更多,换来的是词表固定、无未登录词。

      2.5 滑动窗口生成训练标签

      第 1 章提到,LLM 预训练是自监督任务,不需要人工标注。这一节说明标签是如何自动生成的。

      手法只有一句话:把输入右移一位,就是标签。

      语料 A B C D E,取 A B C 做输入,那么 B C D 就是标签——每个位置上,模型要预测的「下一个词」就在原文里。标签直接取自语料本身,无需任何人工标注。

      具体实现是滑动窗口:在词元序列上开一个长 max_length 的窗口,窗口内的 L 个词元做输入,右移一位的 L 个做目标;然后窗口向前滑动 stride 位,再切一份。src/main.cpp 里的 GPTDataLoader 把「数据集切分 + 批组装」合在一个类里(等价 PyTorch 生态的 Dataset + DataLoader 组合):

      // 对应 GPTDatasetV1: for i in range(0, len - max_length, stride)
      for (int i = 0; i + max_length < (int)token_ids.size(); i += stride) {
          input_rows_.push_back({ids.begin() + i,          ids.begin() + i + max_length});
          target_rows_.push_back({ids.begin() + i + 1,     ids.begin() + i + max_length + 1});
      }
      // drop_last: 不足一个 batch 的尾部丢弃;shuffle: 按 seed 打乱样本顺序

      实测(先跳过前 50 个词元,max_length=4):

      BPE 分词后词元总数 len(enc_text) = 5145   ← 编码后跳过前 50 个词元
      
      x = [290, 4920, 2241, 287]
      y =       [4920, 2241, 287, 257]        ← y 就是 x 右移一位
      
      -- 下一单词预测任务(文本形式)--
        and                        ---->  established
        and established            ---->  himself
        and established himself    ---->  in
        and established himself in ---->  a
      
      -- DataLoader(batch_size=1, max_length=4, stride=1) --
      first_batch inputs  = [[290, 4920, 2241, 287]]
      first_batch targets = [[4920, 2241, 287, 257]]
      second_batch inputs = [[4920, 2241, 287, 257]]   ← 与 first_batch 左移一位

      第二批相对第一批整体左移一位——这就是「滑动」的含义。四个超参各控制一件事,各自的权衡如下:

      超参 含义 权衡

      stride

      批次间输入的位移量

      stride = 1:样本最多但高度重叠;stride = max_length:完全不重叠——既充分利用数据(不跳过任何词),又避免过多重叠增加过拟合风险。程序演示 2.8 即用此配置(见 2.9 节输出)

      max_length

      每个输入块的词元数

      本教程演示用 4,实际训练通常不小于 256

      drop_last

      丢弃不足 batch_size 的最后一批

      真实目的是防止训练期间损失剧增——最后一批如果特别小,这一批的损失会异常大,影响整轮平均值

      batch_size

      每批样本数

      小批次省内存但更新噪声大;大批次稳定,吃显存

      调试数据流水线时的实操建议:先打印几个 batch 肉眼检查——x 和 y 是否恰好相差一位?第二个 batch 是否是第一个的滑动延续?数据管道的问题多数出在形状对不上,肉眼检查比断点调试更快。

      2.6 词元嵌入:可训练的查表层

      「嵌入」配上「向量空间」「语义表示」等术语,显得抽象。拆开看,实现很朴素:一张权重矩阵,加一次查表操作。

      训练开始前,这张表必须用随机值初始化——这是学习的起点:

      const int vocab_size = 50257, output_dim = 256, max_length = 4;
      auto token_embedding_layer = torch::nn::Embedding(vocab_size, output_dim);
      auto pos_embedding_layer   = torch::nn::Embedding(max_length, output_dim);

      权重矩阵形状 = vocab_size 行 × output_dim 列:每行对应词表中一个词元。对 ID=3 做前向计算,得到的向量与权重矩阵第 4 行完全相同——

      嵌入层本质是一次查表操作,不是矩阵乘法:根据词元 ID 从权重矩阵中检索出相应的行。但它又可以视为更高效的独热编码实现——把词元 ID 转成独热向量再乘这张矩阵,效果恰好等于「挑出对应那一行」(其他位的 0 把其余行全部消去)。数学上一模一样,工程上不存那些 0、不算那些乘法。正因为它等价于一个网络层,它才是可训练的——反向传播能顺着这条等价关系更新这张表。这是它和「拿现成 word2vec 向量」的根本区别:这张表是模型的一部分,随训练一起更新。模型的「语义理解」起点是一张随机数的表;语义来自训练过程,而不是初始化。

      再算一笔参数账:这张表 50257 × 768 ≈ 3860 万个参数,是 GPT-2 全模型中最大的单项之一——但前向计算时它只做检索、不做乘法。统计参数量时不要遗漏它。

      2.7 位置嵌入

      词元嵌入就位后,还缺一块关键拼图——没有它,模型无法区分「狗咬人」和「人咬狗」。

      问题出在自注意力机制本身:它不感知位置。原因藏在查表机制里:同一个词元 ID,无论出现在句首还是句尾,查出的向量完全相同;注意力计算又只依据向量内容打分——「狗咬人」里「狗」的向量和「人咬狗」里「狗」的向量相同,注意力给出的分数也相同。对注意力来说,这两句话不可区分。

      词序信息就此丢失,而语言恰恰依赖顺序表意。补救方法很直接:给位置单独做一套嵌入,「词是什么」和「词在哪」两套信息逐元素相加:

      策略 做法 特点

      绝对位置嵌入

      与序列中的特定位置绑定:第 1 个词元有特定嵌入,第 2 个有另一个

      简单直接

      相对位置嵌入

      关注词元之间的相对位置或距离,学的是「距离」关系

      能更好地泛化到训练中从未见过的长度

      OpenAI 的 GPT 系列采用可学习的绝对位置嵌入;原始 Transformer 用的则是不可训练的正弦函数编码(形式类似信号处理中的波形)。实现上它就是又一张查询表:max_length(位置数)× output_dim。GPT-2 上下文最长 1024 个词元,这张表就是 1024 × 768。

      为什么放弃正弦函数、改用可学习的查表?正弦编码不占参数,理论上可外推到 训练中没见过的长度,但每个位置的向量是公式写死的。可学习的版本反过来: 模型自己学出每个位置的表示,实现与词元嵌入同构,代价是位置数锁死在 max_length,超长只能截断。GPT-2 发布时(2019 年)相对位置方案 已有探索但尚未成熟,可学习的绝对位置是当时工程上最直接的选择。几年后 旋转位置编码(RoPE)普及,新模型才普遍转向。

      auto token_embeddings = token_embedding_layer->forward(inputs);   // [8, 4, 256]
      auto pos_embeddings   = pos_embedding_layer->forward(
                                  torch::arange(max_length, torch::kLong)); // [4, 256]
      auto input_embeddings = token_embeddings + pos_embeddings;   // 广播相加 → [8, 4, 256]

      写成公式——两类嵌入逐元素相加,形状不变(广播把 [4, 256] 复制到批次内每个样本上):

      \[X = E_{\mathrm{tok}}[\,\mathrm{ids}\,] + E_{\mathrm{pos}}[\,0{:}T\,], \qquad X \in \mathbb{R}^{T \times d}\]
      Diagram

      相加的结果中,词元嵌入承载「这个词是什么」,位置嵌入注入「它在第几个位置」,X 同时带着两种信息进入第 3 章的注意力:

      02 embedding sum
      Figure 1. 公式图像:词元嵌入 + 位置嵌入 = 输入嵌入(T=6、d=8 的示意样本)

      两个容易写错的细节:① pos_embedding_layer 的第一维是位置数(代码中的 max_length),不是 vocab_size——位置索引上限为 1024,文本超长必须截断;② 位置嵌入与词元嵌入的维度必须相同(都是 output_dim),否则无法相加。

      2.8 全章形状变化总览

      从字符串到「能喂进注意力」的张量,每一步的形状都在这张表里,调试数据流水线时对着它逐行核对:

      阶段 数据形态 具体数值

      ① 原始文本

      字符串

      The Verdict,20,479 字符

      ② 分词

      词元列表

      简易正则 4690 / BPE 5145

      ③ 词元 ID

      整数序列

      词表 1130(+2 = 1132)或 BPE 50,257

      ④ 批次采样

      张量 x, y

      x=[8,4],y 为 x 右移一位

      ⑤ 词元嵌入

      张量

      [8, 4, 256]

      ⑥ 位置嵌入

      张量

      [4, 256]

      ⑦ 输入嵌入

      张量

      [8, 4, 256] ← ⑤+⑥ 广播相加

      通用公式:input_embeddings.shape = [batch_size, num_tokens, output_dim]。 本章产出的 [batch, token, dim] 张量,就是第 3 章自注意力机制的直接输入。

      2.9 编译运行与预期输出

      ./scripts/run.sh chapter02_text_data

      输出按小节顺序分节打印(2.3 → 2.4 → 2.5 → 2.6 → 2.8;编号为程序内的演示序号,与本章小节号略有错位)。需要预先跑过 ./scripts/download-data.sh(data/ 下有 the-verdict.txt、encoder.json、vocab.bpe)。核对以下确定性输出:

      Total number of character: 20479
      词元数量 len(preprocessed) = 4690        ← 朴素分词
      词汇表大小 vocab_size = 1130             ← V1
      更新后的词汇表大小 = 1132                ← V2(+<|endoftext|> +<|unk|>)
      2.5: vocab_size = 50257;  encode = [15496, 11, 466, ...]
      2.6: len(enc_text) = 5145;  x = [290, 4920, 2241, 287]
      2.8: input_embeddings.shape = [8, 4, 256]
      与手写版交叉验证 ...  结果一致: yes

      本章小结

      • 大语言模型只处理数字:神经网络只对数字张量运算,嵌入负责把词变成有语义的向量;它与 word2vec 的本质区别在于嵌入层是模型的一部分、随训练更新、能结合上下文。

      • 分词三粒度:字符级序列过长、单词级词表失控且有未登录词问题、子词级是工业方案——切分方式划定模型能力边界,也是 API 成本的计价单位。

      • 朴素分词器(正则 + 查表 + 特殊词元)遇到未登录词即失败;BPE 用「字节全覆盖 + 高频合并」把生词拆成子词,50257 = 256 字节 + 50000 合并 + 1 特殊词元。

      • 滑动窗口把自监督落到代码:标签 = 输入右移一位,无需人工标注;stride / max_length / drop_last / batch_size 四个超参各有权衡。

      • 嵌入层本质是可训练矩阵的查表操作(等价于独热编码接全连接),随机初始化、随训练更新。

      • 自注意力不感知位置——位置嵌入补上词序信息,使模型能区分「狗咬人」与「人咬狗」;GPT-2 用可学习的绝对位置嵌入,与词元嵌入广播相加。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 能说出 LLM 嵌入与 word2vec 的三条本质区别

      2. 朴素分词 4690 / 词表 1130 → 1132,与正文一致

      3. BPE 词表 50257 的构成能推导:256 字节 + 50000 合并 + 1 特殊词元; <|endoftext|> = 50256(最大 ID)

      4. someunknownPlace 被拆成子词而非 <|unk|>;能解释 Ġ 是什么、 为什么空格要保留在词元里

      5. allowed_special 不传会出什么事,能解释为什么这类错误难以排查

      6. 手写 BPE 与 torchtext 官方版交叉验证 结果一致: yes

      7. x 与 y 相差一位;second_batch 是 first_batch 左移一位

      8. 能说出 stride/max_length/drop_last/batch_size 四个超参各自的权衡

      9. 能解释「嵌入层是查表不是乘法」以及它与独热编码的等价关系

      10. 能说出注意力为什么不感知位置、GPT-2 的位置嵌入怎么补

      11. input_embeddings.shape = [8, 4, 256]

      习题

      1. 把 max_length 从 4 改成 8(改 src/main.cpp 里 demo_dataloader 的 context_size/max_length 并重编译),观察 x/y 与 batch 形状的变化。

      2. 用 BpeTokenizer 查 token_id(" world")(注意前导空格是 Ġ), 解释为什么带空格和不带空格的 world 是两个不同的词元。

      3. 用 BPE 对未知单词 "Akwirw ier" 分词,打印所有 词元 ID,逐个 decode 复现子词映射,再整体 decode 验证还原。

      4. 把 max_length=2, stride=2 与 max_length=8, stride=2 各跑一遍,观察数据加载器行为差异。

      5. 打开 vocab.bpe,找到对应你自己名字英文拼写的合并规则链—— 从字节到整词,经过了几次合并?

      拓展阅读