用代码探索文本中的 n-gram 频率分布:以《时间机器》为例

在自然语言处理(NLP)中,理解文本的统计规律是构建语言模型、文本生成等任务的基础。今天我们通过一段代码,以经典小说《时间机器》为语料,探索单字(unigram)、双字(bigram)和三字(trigram)序列的频率分布规律,看看这些语言单元在文本中是如何出现的。

什么是 n-gram?

在开始代码解析前,先明确几个核心概念:

  • Token:文本的基本单位,可以是单词、字符或子词。本文中我们以单词为 token。
  • Corpus(语料库):大量文本的集合,这里我们使用《时间机器》全文作为语料。
  • n-gram:由 n 个连续 token 组成的序列。
    • unigram(一元语法):单个 token(如 "the"、"time")
    • bigram(二元语法):两个连续 token(如 "the time"、"time machine")
    • trigram(三元语法):三个连续 token(如 "the time machine")

n-gram 的频率分布能反映语言的统计特性:比如某些词组合出现的概率远高于随机组合,这是语言规律性的体现。

代码汇总

import random
import torch
import re
from d2l import torch as d2l


d2l.DATA_HUB['time_machine']=(d2l.DATA_URL+'timemachine.txt','090b5e7e70c295757f55df93cb0a180b9691891a')

def read_time_machine():
    with open(d2l.download('time_machine'),'r') as f:
        lines=f.readlines()
    return [re.sub('[^A-Za-z]+',' ',line).strip().lower() for line in lines]

#一元语法
tokens=d2l.tokenize(read_time_machine())
corpus=[token for line in tokens for token in line]
vocab=d2l.Vocab(corpus)
# print(vocab.token_freqs[:10])

freqs=[freq for token,freq in vocab.token_freqs]
d2l.plot(freqs,xlabel='token x',ylabel='frequency:n(x)',xscale='log',yscale='log')


#二元语法
bigram_tokens=[' '.join(pair) for pair in zip(corpus[:-1],corpus[1:])]
bigram_vocab=d2l.Vocab(bigram_tokens)
# print(bigram_vocab.token_freqs[:10])

#三元语法
trigram_tokens=[' '.join(triple) for triple in zip(corpus[:-2],corpus[1:-1],corpus[2:])]
trigram_vocab=d2l.Vocab(trigram_tokens)
# print(trigram_vocab.token_freqs[:10])

#在图中观察
bigram_freqs=[freq for token,freq in bigram_vocab.token_freqs]
trigram_freqs=[freq for token,freq in trigram_vocab.token_freqs]
d2l.plot([freqs,bigram_freqs,trigram_freqs],
         xlabel='token:x',ylabel='frequency:n(x)',
         xscale='log',yscale='log',
         legend=['unigram','bigram','trigram'])

代码解析:从数据获取到可视化

下面我们逐段解析代码,看看如何从原始文本中提取 n-gram 并分析其频率。

1. 库导入与数据集配置

import random
import torch
import re
from d2l import torch as d2l

# 配置《时间机器》数据集的下载地址和校验信息
d2l.DATA_HUB['time_machine'] = (
    d2l.DATA_URL + 'timemachine.txt',
    '090b5e7e70c295757f55df93cb0a180b9691891a'
)
  • 库说明
    • re:用于文本正则化处理(清洗文本)。
    • d2l:深度学习框架 Dive into Deep Learning(《动手学深度学习》)的工具库,提供了数据下载、可视化等便捷功能。
    • torch:PyTorch 库,虽然本文未直接使用其核心功能,但 d2l 的部分工具依赖它。
  • 数据集配置d2l.DATA_HUB 用于注册数据集信息,确保后续能自动下载《时间机器》的文本文件(timemachine.txt)。

2. 文本读取与预处理

def read_time_machine():
    # 下载并打开文本文件
    with open(d2l.download('time_machine'), 'r') as f:
        lines = f.readlines()
    # 文本清洗:保留字母,其他字符替换为空格,转为小写并去除首尾空格
    return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]

这段函数的作用是读取并预处理文本,关键步骤:

  • d2l.download('time_machine'):自动下载并返回文本文件的本地路径。
  • 正则表达式 re.sub('[^A-Za-z]+', ' ', line):将所有非字母字符(如数字、标点、换行符)替换为空格,避免这些符号干扰词频统计。
  • strip().lower():去除每行首尾的空格,并将所有字母转为小写(统一大小写,避免 "The" 和 "the" 被视为两个不同的词)。

预处理后,文本中只剩下小写字母和空格(用于分隔单词)。

3. 一元语法(unigram)分析

# 分词:将文本按空格拆分为token列表(单词列表)
tokens = d2l.tokenize(read_time_machine())
# 构建语料库:将所有句子的token合并成一个长列表
corpus = [token for line in tokens for token in line]
# 构建词汇表:统计每个token的出现频率
vocab = d2l.Vocab(corpus)
# 打印出现频率最高的10个token(可选)
# print(vocab.token_freqs[:10])

分词(d2l.tokenize:将预处理后的文本(每行是一个字符串)按空格拆分为单词列表,例如 "the time machine" 会变成["the", "time", "machine"]

语料库(corpus:将所有行的单词列表扁平化为一个大列表,方便后续统计全局词频。

词汇表(d2l.Vocab:自动统计每个单词在语料库中出现的次数(vocab.token_freqs 是一个按频率降序排列的列表,每个元素是 (token, 频率) 元组)。

例如,vocab.token_freqs[:10] 可能会输出 [('the', 2261), ('and', 1225), ('of', 1155), ...],说明 "the" 是《时间机器》中出现次数最多的单词。

如果输出的是

统计的是字母频率,请将d2l版本升级,实际上输出的应该是单词频率:

4. 绘制一元语法频率分布图

# 提取所有token的频率
freqs = [freq for token, freq in vocab.token_freqs]
# 绘制频率分布(双对数坐标)
d2l.plot(freqs, xlabel='token x', ylabel='frequency:n(x)', xscale='log', yscale='log')

画出来的效果大概这个样子:

如果画成了这个样子:

请将d2l的版本适当升级,此时d2l的tokenize的方法是有问题的,没有按照一元语法来进行

频率提取:从 vocab.token_freqs 中提取所有单词的频率,得到一个频率列表。

可视化d2l.plot 绘制频率分布图,x 轴是单词的排名(按频率从高到低),y 轴是频率。使用双对数坐标(xscale='log'yscale='log')是因为词频分布通常符合齐夫定律(Zipf's law):第 k 个最常见的词的频率与 k 成反比,在双对数坐标下表现为近似直线

5. 二元语法(bigram)与三元语法(trigram)分析

# 二元语法:生成相邻的词对(如"the time"、"time machine")
bigram_tokens = [' '.join(pair) for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = d2l.Vocab(bigram_tokens)
# print(bigram_vocab.token_freqs[:10])  # 打印高频二元组

# 三元语法:生成相邻的三字组(如"the time machine")
trigram_tokens = [' '.join(triple) for triple in zip(corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
# print(trigram_vocab.token_freqs[:10])  # 打印高频三元组

二元语法生成zip(corpus[:-1], corpus[1:]) 生成相邻的单词对(例如 corpus 是 [a, b, c, d],则结果是 (a,b), (b,c), (c,d)),再用 ' '.join(pair) 拼接成字符串(如 "a b")作为二元语法的 token。

三元语法生成:类似地,zip(corpus[:-2], corpus[1:-1], corpus[2:]) 生成相邻的三个单词((a,b,c), (b,c,d)),拼接后作为三元语法的 token。

词汇表构建:与一元语法类似,通过 d2l.Vocab 统计二元组和三元组的出现频率。

二元语法的输出部分:

实际上还有八个组合,由于直接截下来太小了所以我就只展示了两个

三元语法的输出部分:

实际上还有八个组合,由于直接截下来太小了所以我就只展示了两个

6. 对比不同 n-gram 的频率分布

# 提取二元组和三元组的频率
bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]

# 绘制三种n-gram的频率分布对比图
d2l.plot([freqs, bigram_freqs, trigram_freqs],
         xlabel='token:x', ylabel='frequency:n(x)',
         xscale='log', yscale='log',
         legend=['unigram', 'bigram', 'trigram'])

这部分代码将一元、二元、三元语法的频率分布绘制在同一张图上,方便对比:

  • x 轴:n-gram 按频率的排名(从高到低)。
  • y 轴:n-gram 的出现频率。
  • 双对数坐标下,我们可以观察到:随着 n 的增大(从 1 到 3),n-gram 的整体频率降低(因为更长的序列出现的概率更低),但曲线的斜率变化反映了语言的规律性 —— 并非完全随机。

结果解读:从图中能看到什么?

运行代码后,我们会得到两张图:

1)一元语法频率图

呈现典型的齐夫定律分布,高频词(如 "the"、"and")与低频词的数量差距极大,双对数坐标下近似一条直线。

2)n-gram 对比图

  • 一元语法的频率最高(单个词更容易重复出现)。
  • 二元语法和三元语法的频率整体低于一元语法,且三元语法的频率最低。
  • 曲线的斜率差异反映了不同长度序列的分布规律:越长的序列,频率随排名下降得越快,说明特定长序列的重复出现更依赖语言的固有结构(而非随机)。

总结

这段代码通过简单的步骤,展示了文本分析中 n-gram 频率统计的核心流程:

  1. 文本预处理(清洗、标准化)→ 2. 分词与语料库构建 → 3. n-gram 生成 → 4. 频率统计与可视化。

通过这个例子,我们不仅理解了 n-gram 的概念,还直观感受到了自然语言的统计特性 —— 这些特性是构建语言模型(如预测下一个词)的基础。在实际应用中,n-gram 模型常被用于机器翻译、文本纠错、语音识别等任务中。

词表vocab

在自然语言处理(NLP)中,文本数据由一个个词语、字符或子词(统称为 “词元”,Token)组成,而模型通常只能处理数字。词表(Vocab)的核心作用就是建立 “词元” 与 “数字索引” 之间的映射关系,将文本转换为模型可理解的数字形式,同时记录词元的出现频率等信息。

 代码汇总

def count_corpus(tokens):
    if len(tokens)==0 or isinstance(tokens[0],list):
        tokens=[token for line in tokens for token in line]
    return collections.Counter(tokens)

class Vocab:
    def __init__(self,tokens=None,min_freq=0,reserved_tokens=None):
        if tokens is None:
            tokens=[]
        if reserved_tokens is None:
            reserved_tokens=[]
        #按出现排律排序
        counter=count_corpus(tokens)
        #_token_freqs统计词频基础,筛选词元(结合 min_freq):
        self._token_freqs=sorted(counter.items(),key=lambda x:x[1],reverse=True)

        self.idx_to_token=['<unk>'] + reserved_tokens
        #token_to_idx是建立 "token(词元)" 到 "索引(index)" 的映射关系,实现从文本符号到数字的转换的字典。
        self.token_to_idx={token:idx for idx,token in enumerate(self.idx_to_token)}
        for token,freq in self._token_freqs:
            if freq<min_freq:
                break
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                self.token_to_idx[token]=len(self.idx_to_token)-1
    def __len__(self):
        return len(self.idx_to_token)

    def __getitem__(self,tokens):
        if not isinstance(tokens,(list,tuple)):
            return self.token_to_idx.get(tokens,self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self,indices):
        if not isinstance(indices,(list,tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]


    #装饰器的语法 把方法当做属性
    @property
    def unk(self):
        return 0

    @property
    def token_freqs(self):
        return self._token_freqs

vocab=Vocab(tokens)
print()
print(list(vocab.token_to_idx.items())[:10])
for i in [0,10]:
    print('文本:',tokens[i])
    print('索引:',vocab[tokens[i]])

    代码逐部分解析

    1. 辅助函数:count_corpus(tokens)—— 统计词频

    def count_corpus(tokens):
        if len(tokens)==0 or isinstance(tokens[0],list):
            tokens=[token for line in tokens for token in line]
        return collections.Counter(tokens)
    
    • 功能:统计输入词元列表中每个词元的出现次数。
    • 细节
      • 输入tokens可以是单层列表(如["我", "爱", "NLP"]),也可以是嵌套列表(如[["我", "爱"], ["NLP", "学习"]]),函数会先将嵌套列表展平为单层列表;
      • collections.Counter统计词频,返回一个字典(键为词元,值为出现次数)
    2. Vocab类的初始化:构建词表核心映射

    Vocab类的__init__方法是核心,负责构建词表的基础映射关系,步骤如下

    对init函数参数说明

    tokens:输入的词元列表(可以是单层或嵌套列表)

    min_freq:最低词频阈值,低于该值的词元会被过滤;

    reserved_tokens:预留词元(如<pad>填充符、<bos>句首符等),会强制加入词表。

    核心逻辑

    1 先用count_corpus统计词频,再按词频从高到低排序(确保高频词索引更小,节省计算资源)

    2 初始化词表时,默认加入<unk>(未登录词标记,用于处理不在词表中的词元)和预留词元

    3 遍历排序后的词元,只将词频≥min_freq且未在词表中的词元加入,同时更新 “词元 - 索引” 映射。

    3. 核心方法:编码与解码

    词表的核心作用是 “词元→索引” 和 “索引→词元” 的转换,这两个功能由以下方法实现:

    (1)__getitem__(tokens)—— 词元转索引(编码)
    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]
    
    • 功能:将输入的词元(单个或列表)转换为对应的索引。
    • 细节
      • 若输入是单个词元,直接返回其索引;若词元不在词表中,返回<unk>的索引(默认 0);
      • 若输入是词元列表,递归处理每个词元,返回索引列表。
    (2)to_tokens(indices)—— 索引转词元(解码)
    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]
    
    • 功能:将输入的索引(单个或列表)转换为对应的词元。
    • 细节:与__getitem__对应,支持单个索引或索引列表的解码。

    4. 其他实用功能

    • __len__:返回词表大小(即词元总数);
    • @property unk:返回<unk>的索引(固定为 0),方便调用;
    • @property token_freqs:返回按词频排序的词元 - 频率列表,可用于分析数据分布(如高频词是什么)。

    代码使用示例

    假设输入的tokens是一段文本的词元列表(如[["我", "爱", "Python"], ["Python", "很", "好用"]]),初始化词表后:

    vocab = Vocab(tokens, min_freq=1)  # 保留所有出现过的词元
    
    • 可以通过vocab.token_to_idx查看 “词元 - 索引” 映射(前 10 项);
    • vocab[tokens[i]]将第i个句子的词元转换为索引;
    • vocab.to_tokens(indices)将索引转回词元。

    这段代码的核心功能

    这段代码实现了一个轻量但完整的Vocab类,主要包含以下功能:

    1. 统计输入文本中所有词元的出现频率;
    2. 基于词频筛选词元,生成 “词元 - 索引” 双向映射;
    3. 支持将词元转换为索引(编码)、将索引转换为词元(解码);
    4. 处理未登录词(未在词表中出现的词元)。

    总结

    这段代码实现了一个简洁但实用的词表工具,核心价值在于:

    1. 自动化处理词元统计与映射构建,无需手动维护索引;
    2. 支持低频词过滤和预留词元,灵活适配不同场景;
    3. 提供直观的编码 / 解码接口,方便将文本与数字索引互转。

    在实际 NLP 任务中(如文本分类、机器翻译等),这样的词表是连接原始文本与模型输入的重要桥梁,也是更复杂工具(如 Tokenizer)的基础组件。

    以下是对这段代码的部分知识的补充,如果这些知识都会则可以跳过

    下面是介绍enumerate的部分,会enumerate的可以自行跳过

    enumerate

    在 Python 中,enumerate() 是一个内置函数,它的核心作用是遍历可迭代对象(iterable)时,同时返回元素的索引和值。它可以操作的核心数据类型是所有可迭代对象(iterable),具体包括:

    1. 基本可迭代类型

    • 列表(list)

      for idx, val in enumerate(["a", "b", "c"]):
          print(idx, val)  # 输出 (0, 'a'), (1, 'b'), (2, 'c')
      
    • 元组(tuple)

      for idx, val in enumerate(("x", "y", "z")):
          print(idx, val)  # 输出 (0, 'x'), (1, 'y'), (2, 'z')
      
    • 字符串(str)(字符串是字符的可迭代序列):

      for idx, char in enumerate("hello"):
          print(idx, char)  # 输出 (0, 'h'), (1, 'e'), (2, 'l'), (3, 'l'), (4, 'o')
      
    • 集合(set)
      注意:集合是无序的,因此索引仅代表遍历顺序,不反映元素在集合中的实际位置。

      for idx, val in enumerate({1, 2, 3}):
          print(idx, val)  # 可能输出 (0, 1), (1, 2), (2, 3)(顺序不固定)
      
    • 字典(dict)
      直接遍历字典时,enumerate 会作用于字典的键(key);若需遍历值或键值对,需结合 values() 或 items()

      d = {"name": "Alice", "age": 30}
      # 遍历键
      for idx, key in enumerate(d):
          print(idx, key)  # 输出 (0, 'name'), (1, 'age')(Python 3.7+ 按插入顺序)
      # 遍历值
      for idx, val in enumerate(d.values()):
          print(idx, val)  # 输出 (0, 'Alice'), (1, 30)
      
    • 2. 其他可迭代对象

    • 生成器(generator)

      gen = (x*2 for x in range(3))  # 生成器:0, 2, 4
      for idx, val in enumerate(gen):
          print(idx, val)  # 输出 (0, 0), (1, 2), (2, 4)
      
    • 总结

      enumerate() 可操作的本质是所有实现了迭代协议(即有 __iter__() 方法)的对象,包括上述的列表、元组、字符串、集合、字典、生成器、range 等。它的核心价值是简化 “获取索引 + 元素” 的操作,避免手动定义计数器变量(如 i = 0; for val in iterable: ...; i += 1)。

    下面是介绍字典推导式的部分,掌握字典推导式的可以跳过

    字典推导式

    self.token_to_idx = {token: idx for idx, token in enumerate(self.idx_to_token)}
    

    这是 Python 字典推导式(Dictionary Comprehension) 语法,核心作用是:
    基于 self.idx_to_token(索引到词元的列表),反向构建 self.token_to_idx(词元到索引的字典)。

    各部分含义:

    • enumerate(self.idx_to_token)
      enumerate 是 Python 内置函数,遍历 self.idx_to_token 列表时,同时返回索引 idx 和对应的词元 token(例如列表 ["<unk>", "我", "爱"],遍历后会生成 (0, "<unk>"), (1, "我"), (2, "爱") )。

    • {token: idx for idx, token in ...}
      字典推导式语法,遍历 enumerate 的结果,将 token 作为字典的键idx 作为对应的值,最终构建出 {词元: 索引} 的映射关系。

    功能举例:

    假设 self.idx_to_token = ["<unk>", "我", "爱", "自然语言"],执行代码后:

    self.token_to_idx = {
        "<unk>": 0,
        "我": 1,
        "爱": 2,
        "自然语言": 3
    }
    

    后续可通过 self.token_to_idx["我"] 快速查询词元 "我" 对应的索引 1,方便文本转数字索引的操作。


    等价代码(用普通循环实现)

    字典推导式是 Python 的语法糖,等价逻辑可用 for 循环手动实现,代码如下:

    self.token_to_idx = {}
    for idx, token in enumerate(self.idx_to_token):
        self.token_to_idx[token] = idx
    

    逻辑对比:

    • 字典推导式:一行代码简洁完成映射构建,适合 Python 风格的简洁写法;
    • 普通循环:显式遍历 enumerate 结果,逐步赋值构建字典,逻辑更直观,适合理解推导式语法。

    在词汇表(Vocab)中的作用

    在自然语言处理的词汇表类中:

    • self.idx_to_token 是索引→词元的列表(按索引顺序存储词元);
    • self.token_to_idx 是词元→索引的字典(快速查询词元对应的索引)。

    两者配合实现双向映射

    • 已知索引查词元 → 用 self.idx_to_token[idx]
    • 已知词元查索引 → 用 self.token_to_idx[token]

    应用场景扩展

    类似的映射构建逻辑,还可用于以下场景:

    • 字符编码映射:构建字符到 ASCII 码的映射(如 {"A": 65, "B": 66} );
    • 标签映射:分类任务中,构建类别名称到数字标签的映射(如 {"猫": 0, "狗": 1} );
    • 配置解析:将配置项名称映射到配置值的索引(如 {"host": 0, "port": 1} )。

    下面是嵌套列表的列表推导式的解释,如果会可以跳过

    列表推导式

    下面是一段嵌套列表的列表推导式

    tokens = [token for line in tokens for token in line]

    等价于这个代码:

    new_tokens = []
    for line in tokens:      # 外层循环:遍历外层列表的每个元素(称为line)
        for token in line:   # 内层循环:遍历每个line中的元素(称为token)
            new_tokens.append(token)  # 将每个token添加到新列表中
    tokens = new_tokens

      序列数据迭代器

      在深度学习中,处理序列数据(如文本、时间序列)是非常常见的任务。无论是训练循环神经网络(RNN)、Transformer,还是构建语言模型,都需要将原始序列数据转换为模型可接收的输入格式 —— 即固定长度的批量样本。

      为什么需要序列数据迭代器?

      在处理长序列时,我们面临两个核心问题:

      1. 长度不固定:原始序列(如一本书的文本)通常很长,无法直接作为模型输入(模型需要固定长度的输入);
      2. 批量处理:为了高效训练,需要将数据分成多个批次(batch),利用并行计算加速训练。

      序列数据迭代器的作用就是解决这两个问题:将长序列切分成固定长度(num_steps)的样本,并按批次(batch_size)组织数据,同时生成对应的标签(通常是下一个时间步的元素)。

      核心概念说明

      在开始解析代码前,先明确几个关键参数:

      • corpus:原始序列数据(如词语列表、时间序列值),是一个一维列表 / 数组;
      • batch_size:每个批次包含的样本数量;
      • num_steps:每个样本的长度(即模型一次处理的时间步数量);
      • 样本与标签:对于序列数据,通常用前num_steps个元素作为输入(X),后num_steps个元素(偏移 1 位)作为标签(Y),即Y[i] = X[i+1]

      一、随机采样迭代器(seq_data_iter_random

      随机采样的核心是打破原始序列的连续性,通过随机选择样本起始位置,让批次内的样本来自序列的不同部分。这种方式能增加数据的随机性,减少模型对序列顺序的依赖,有助于避免过拟合。

      代码解析

      def seq_data_iter_random(corpus, batch_size, num_steps):
          # 步骤1:随机偏移起始位置,避免总是从序列开头开始
          corpus = corpus[random.randint(0, num_steps - 1):]
          
          # 步骤2:计算可生成的子序列数量(每个子序列长度为num_steps)
          # 减1是因为Y是X的下一个元素,需保证X的最后一个元素有对应的Y
          num_subseqs = (len(corpus) - 1) // num_steps
          
          # 步骤3:生成所有子序列的起始索引(间隔为num_steps)
          initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
          
          # 步骤4:打乱起始索引,实现随机采样
          random.shuffle(initial_indices)
          
          # 辅助函数:从pos开始取长度为num_steps的子序列
          def data(pos):
              return corpus[pos:pos + num_steps]
          
          # 步骤5:按批次生成样本
          num_batches = num_subseqs // batch_size  # 总批次数
          for i in range(0, batch_size * num_batches, batch_size):
              # 取当前批次的起始索引
              initial_indices_per_batch = initial_indices[i:i + batch_size]
              # 生成X(输入)和Y(标签,X偏移1位)
              X = [data(j) for j in initial_indices_per_batch]
              Y = [data(j + 1) for j in initial_indices_per_batch]
              yield torch.tensor(X), torch.tensor(Y)
      

      关键逻辑拆解

      以示例my_seq = list(range(35))(即[0,1,2,...,34])、batch_size=2num_steps=5为例:

      1. 随机偏移:假设random.randint(0,4)返回 2,则corpus被切分为[2,3,...,34](长度 33);

      2. 计算子序列数量num_subseqs = (33-1)//5 = 32//5 = 6(即能生成 6 个长度为 5 的子序列);

      3. 起始索引initial_indices = [0,5,10,15,20,25](每个索引间隔 5,对应 6 个子序列的起点);

      4. 打乱索引:假设打乱后为[10,25,0,20,5,15]

      5. 生成批次num_batches=6//2=3(共 3 批),每批 2 个样本:

        • 第 1 批:索引[10,25] → X=[corpus[10:15], corpus[25:30]]Y=[corpus[11:16], corpus[26:31]]
        • 第 2 批:索引[0,20] → X=[corpus[0:5], corpus[20:25]]Y=[corpus[1:6], corpus[21:26]]
        • 第 3 批:索引[5,15] → X=[corpus[5:10], corpus[15:20]]Y=[corpus[6:11], corpus[16:21]]

      输出示例

      X: tensor([[ 0,  1,  2,  3,  4],
              [20, 21, 22, 23, 24]]) 
      Y: tensor([[ 1,  2,  3,  4,  5],
              [21, 22, 23, 24, 25]])
      X: tensor([[25, 26, 27, 28, 29],
              [15, 16, 17, 18, 19]]) 
      Y: tensor([[26, 27, 28, 29, 30],
              [16, 17, 18, 19, 20]])
      X: tensor([[ 5,  6,  7,  8,  9],
              [10, 11, 12, 13, 14]]) 
      Y: tensor([[ 6,  7,  8,  9, 10],
              [11, 12, 13, 14, 15]])

      可以看到,样本来自序列的不同位置,彼此之间没有连续关系,随机性较强。

      二、顺序采样迭代器(seq_data_iter_sequential)

      顺序采样的核心是保留原始序列的连续性,将序列按顺序切分成连续的块,每个批次的样本来自相邻的序列片段。这种方式适合需要保持时序连贯性的场景(如评估语言模型时生成连续文本)。

      代码解析

      def seq_data_iter_sequential(corpus, batch_size, num_steps):
          # 步骤1:随机偏移(轻微打乱,避免每次都从同一位置开始)
          offset = random.randint(0, num_steps)
          
          # 步骤2:计算有效长度(确保能被batch_size整除,均匀分配给每个批次)
          # 减1是为了保证Y的有效性,offset+1是Y的起始位置
          num_tokens = ((len(corpus) - 1 - offset) // batch_size) * batch_size
          
          # 步骤3:生成X和Y的原始序列(X从offset开始,Y从offset+1开始)
          Xs = torch.tensor(corpus[offset:offset + num_tokens])
          Ys = torch.tensor(corpus[offset + 1:offset + 1 + num_tokens])
          
          # 步骤4:重塑为(batch_size, 总长度//batch_size)
          # 每行是一个连续的子序列,共batch_size行
          Xs, Ys = Xs.reshape((batch_size, -1)), Ys.reshape((batch_size, -1))
          
          # 步骤5:按顺序切分每个批次的样本(长度为num_steps)
          num_batches = Xs.shape[1] // num_steps  # 总批次数
          for i in range(0, num_steps * num_batches, num_steps):
              # 按列取num_steps长度的子序列(保持每行连续性)
              X = Xs[:, i:i + num_steps]
              Y = Ys[:, i:i + num_steps]
              yield X, Y
      

      关键逻辑拆解

      同样以my_seq = [0,1,...,34]batch_size=2num_steps=5为例:

      1. 随机偏移:假设offset=2,则Xs从索引 2 开始,Ys从索引 3 开始;

      2. 计算有效长度num_tokens = ((35-1-2)//2)*2 = (32//2)*2 = 32(即XsYs长度均为 32);

      3. 重塑序列Xs被 reshape 为(2, 16)(2 行 16 列),Ys同样为(2, 16)。此时:

        • Xs[0] = [2,3,...,17](第 1 行,16 个元素);
        • Xs[1] = [18,19,...,33](第 2 行,16 个元素);
      4. 生成批次num_batches = 16//5 = 3(共 3 批),每批取 5 列:

        • 第 1 批:i=0 → X = Xs[:,0:5]Y = Ys[:,0:5]
        • 第 2 批:i=5 → X = Xs[:,5:10]Y = Ys[:,5:10]
        • 第 3 批:i=10 → X = Xs[:,10:15]Y = Ys[:,10:15]

      输出

      X: tensor([[ 0,  1,  2,  3,  4],
              [17, 18, 19, 20, 21]]) 
      Y: tensor([[ 1,  2,  3,  4,  5],
              [18, 19, 20, 21, 22]])
      X: tensor([[ 5,  6,  7,  8,  9],
              [22, 23, 24, 25, 26]]) 
      Y: tensor([[ 6,  7,  8,  9, 10],
              [23, 24, 25, 26, 27]])
      X: tensor([[10, 11, 12, 13, 14],
              [27, 28, 29, 30, 31]]) 
      Y: tensor([[11, 12, 13, 14, 15],
              [28, 29, 30, 31, 32]])
      
      

      可以看到,同一行的样本是连续的(如第 1 行从[2-6][7-11]),保持了原始序列的时序关系。

      两种采样方式的核心差异

      维度随机采样(seq_data_iter_random顺序采样(seq_data_iter_sequential
      序列连续性样本来自序列不同位置,无连续性样本来自连续片段,保持时序关系
      数据随机性高(打乱起始索引)低(仅初始偏移随机)
      适用场景模型训练(减少过拟合)模型评估 / 生成(保持上下文连贯)
      计算效率需打乱索引,略低直接切分,效率高

      总结与应用

      序列数据迭代器是处理时序任务的基础组件,本文介绍的两种采样方式各有侧重:

      • 随机采样通过打破序列连续性增加数据多样性,适合训练阶段,帮助模型学习全局模式;
      • 顺序采样保留时序关系,适合评估或生成场景(如生成连贯文本)。

      在实际应用中,这两种迭代器常结合使用:训练时用随机采样,评估时用顺序采样。此外,根据任务需求,还可以扩展出更复杂的采样策略(如滑动窗口采样、分层采样等)。

      掌握序列数据的预处理逻辑,能帮助我们更深入理解模型输入的由来,为后续模型调优打下基础。

      以下是对序列预测任务XY序列设置的解释,如果掌握可以跳过

      在序列预测任务中,将 X 设为序列 j 而 Y 设为序列 j+1

      在序列预测任务中,将 X 设为序列 j 而 Y 设为序列 j+1(即 X 是当前位置的元素,Y 是下一个位置的元素),是一种构建 “输入 - 目标” 监督信号的核心思路,其意义主要体现在以下 3 个方面:

      1. 模拟 “预测下一个元素” 的核心任务

      序列预测的本质是基于历史信息推测未来。例如:

      • 在语言模型中,已知 “今天天气”(X),需要预测下一个词 “很”(Y);
      • 在时间序列预测中,已知前 7 天的温度(X),需要预测第 8 天的温度(Y)。

      通过 X=j, Y=j+1 的设计,模型会学习 “给定当前序列,如何输出下一个元素” 的规律,直接对齐序列预测的核心目标。

      2. 构建监督学习的训练信号

      机器学习模型(尤其是深度学习模型)需要明确的 “输入 - 标签” 对来训练。
      X=j 作为模型的输入特征,Y=j+1 作为监督标签,通过计算模型对 Y 的预测结果与真实 j+1 的差异(如交叉熵损失),可以反向更新模型参数,让模型逐渐学会 “从 X 推断 Y” 的映射关系。

      3. 适配序列模型的结构设计

      无论是 RNN、LSTM 还是 Transformer,序列模型的核心能力是捕捉序列中的依赖关系(如上下文关联)。
      X=j, Y=j+1 的设计,本质是让模型学习 “当前元素与下一个元素的条件概率分布 P(j+1 | j)”,而复杂序列的概率分布可以通过链式法则分解为多个相邻元素的条件概率乘积(如 P(a,b,c) = P(a)·P(b|a)·P(c|b))。因此,这种 “相邻对” 的设计能帮模型逐步构建对整个序列的理解。

      总结

      X=j, Y=j+1 是序列预测任务中将原始序列转化为可训练数据的标准方式,其核心意义是:
      通过构建 “当前 - 下一个” 的监督对,让模型学习序列的连续性规律,最终具备 “根据已有序列预测后续内容” 的能力。

      下面是介绍requires_grad和requires_grad_的区别,掌握的可以自行跳过

      requires_gradrequires_grad_的区别

      在 PyTorch 中,requires_gradrequires_grad_都与自动求导相关,但它们有着明显的区别,主要体现在功能用途和使用方式上:

      requires_grad

      • 功能requires_grad张量(Tensor)的一个属性,用于标识该张量在计算过程中是否需要计算梯度。当一个张量的requires_grad属性设置为True时,后续基于该张量的计算操作都会被记录在计算图中,以便在反向传播过程中计算梯度。
      • 示例
      import torch
      
      # 创建一个requires_grad为False的张量
      x = torch.tensor([1.0, 2.0, 3.0], requires_grad=False) 
      y = x * 2
      
      # 由于x的requires_grad为False,y的requires_grad也为False
      print(y.requires_grad)  
      
      # 创建一个requires_grad为True的张量
      a = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) 
      b = a * 2
      
      # 由于a的requires_grad为True,b的requires_grad也为True
      print(b.requires_grad) 
      

      在上述代码中,x创建时requires_gradFalse,由x得到的y也不会记录梯度信息;而a创建时requires_gradTrue,由a得到的b就会记录梯度信息。

      requires_grad_

      • 功能requires_grad_是一个方法,它的作用是就地(in-place)改变张量的requires_grad属性。也就是说,它可以修改一个已经存在的张量的requires_grad属性值。
      • 示例
      import torch
      
      # 创建一个张量
      c = torch.tensor([1.0, 2.0, 3.0]) 
      print(c.requires_grad) 
      
      # 使用requires_grad_方法将c的requires_grad属性设置为True
      c.requires_grad_(True) 
      print(c.requires_grad) 
      

      代码先创建了一个普通张量c,此时它的requires_gradFalse ,然后通过c.requires_grad_(True)将其requires_grad属性就地修改为True

      二者区别总结

      • 数据类型和使用方式requires_grad是张量的属性,在创建张量时可以指定,也可以后续通过访问属性来查看其值requires_grad_是方法,只能用于已创建的张量,且是就地修改其requires_grad属性。
      • 应用场景:当你创建新张量并希望明确其是否参与梯度计算时,可直接在创建时设置requires_grad属性;当你已经有了一个张量,后续想要改变它是否计算梯度的状态,就可以使用requires_grad_方法。

      Logo

      北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

      更多推荐