如何把我们写的原始文章,变成能够用来训练 BERT"下一句预测 (NSP)"任务的(句子A,句子B,真/假标签)格式。
在WikiText-2数据集中,每行代表一个段落,其中在任意标点符号及其前面的词元之间插入空格。保留至少有两句话的段落。为了简单起见,我们仅使用句号作为分隔符来拆分句子。我们将更复杂的句子拆分技术的讨论留在本节末尾的练习中。
数据集代码:
这部分看不明白其实没啥严重的影响,知道是在处理数据就行了,最关键的还是模型的构建和训练的过程
预测句子是否相邻:
python
import os
import random
from d2l import torch as d2l
# ==========================================
# 1. 定义要下载的数据集 (WikiText-2)
# ==========================================
# WikiText-2 是一个非常经典的用于语言模型训练的维基百科英文数据集。
# @save 只是 d2l 教程里用于把代码保存到内部包的修饰符。
#@save
d2l.DATA_HUB['wikitext-2'] = (
'https://s3.amazonaws.com/research.metamind.io/wikitext/'
'wikitext-2-v1.zip', '3c914d17d80b1459be871a5039ac23e752a53cbe')
# ==========================================
# 2. 读取并清洗文本文件
# ==========================================
#@save
def _read_wiki(data_dir):
file_name = os.path.join(data_dir, 'wiki.train.tokens')
with open(file_name, 'r') as f:
lines = f.readlines()
"""
读出来的 lines 变量确实就是:
['第一句话的内容...\n', '第二句话的内容...\n']
"""
# 【重点:数据结构转换】
# WikiText 数据集的特点是:段落之间用换行符隔开,句子之间用 ' . ' 隔开。
# 这一步做的事情是:
# 1. strip().lower():去掉首尾空格,全部转成小写字母
# 2. split(' . '):按句号把一段话切分成多个句子
# 3. if len(...) >= 2:丢弃那些只有一句话的段落(因为没法做"下一句"预测啊!)
# 最终的 paragraphs 是一个【嵌套列表(两层)】:
# [
# ['句子1', '句子2', '句子3'], <- 这是一个段落
# ['句子4', '句子5'], <- 这是另一个段落
# ]
"""
line.strip() 发挥了极其关键的作用:它的主要任务就是把字符串首尾的空白字符(包括空格、制表符 \t,以及换行符 \n)统统切掉
"""
paragraphs = [line.strip().lower().split(' . ')
for line in lines if len(line.split(' . ')) >= 2]
# 随机打乱所有段落的顺序,防止模型死记硬背文章的总体顺序
random.shuffle(paragraphs)
"""
为什么要这么做?(极其关键的细节)
为什么只打乱段落,不打乱句子?
因为我们后续要做"下一句预测 (NSP)"任务!如果在这一步把段落里的句子也洗乱了,那原本相邻的"真下一句"就被拆散了,我们就再也造不出 is_next = True 的标准训练数据了。必须保证段落内部的句子原汁原味!
为什么要打乱段落?
为了防止模型"死记硬背"。如果你总是按维基百科原文的固定顺序(从第一段、第二段一直喂到最后一段)喂给机器,机器可能会学到一些没用的"伪规律"(比如:这篇文章的段落总是按时间顺序写的)。打乱段落顺序,能让机器在训练时每次抓取到的段落都是随机分布的,从而更加专注于学习句子与句子之间的通用逻辑,而不是去背诵某篇特定文章的结构。
random.shuffle(paragraphs)
这行代码的作用是随机打乱外层列表(也就是段落)的顺序,但绝对不会打乱段落内部句子的顺序。
paragraphs = [
# 第 1 段
['我是第一段的第1句', '我是第一段的第2句'],
# 第 2 段
['我是第二段的第1句', '我是第二段的第2句'],
# 第 3 段
['我是第三段的第1句', '我是第三段的第2句']
]->>>
paragraphs = [
# 第 3 段跑到了最前面
['我是第三段的第1句', '我是第三段的第2句'],
# 第 1 段跑到了中间
['我是第一段的第1句', '我是第一段的第2句'],
# 第 2 段跑到了最后
['我是第二段的第1句', '我是第二段的第2句']
]
"""
return paragraphs
# ==========================================
# 3. 构造 50% 真 / 50% 假的上下句组合
# ==========================================
# 这个函数完美还原了咱们刚刚讨论过的 NSP 任务"人工毒打"模型的机制!
#@save
def _get_next_sentence(sentence, next_sentence, paragraphs):
# 掷个硬币,产生一个 0 到 1 之间的随机小数
if random.random() < 0.5:
# 50% 的概率:老老实实保留原本真实的下一句话
is_next = True
else:
# 50% 的概率:强行制造假的拼凑句
# paragraphs是嵌套列表。
# 第一个 random.choice(paragraphs) 随机挑了一个段落。
# 第二个 random.choice(...) 从那个挑出的段落里,随机挑了一句话。
next_sentence = random.choice(random.choice(paragraphs))
is_next = False
# 返回:[句子A, 句子B, 它们到底是不是真的一对(True/False)]
return sentence, next_sentence, is_next
# ==========================================
# 4. 把一个段落加工成完整的 NSP 训练样本
# ==========================================
#@save
def _get_nsp_data_from_paragraph(paragraph, paragraphs, vocab, max_len):
nsp_data_from_paragraph = []
# 遍历这个段落里的每一句话(除了最后一句,因为它没有下一句了)
for i in range(len(paragraph) - 1):
# 拿到当前的句子(A)和原本相邻的下一句(B)
# 扔给上面的函数去"掷硬币",得到加工后的 句子A、句子B 和 真假标签
tokens_a, tokens_b, is_next = _get_next_sentence(
paragraph[i], paragraph[i + 1], paragraphs)
# 【检查句子长度】
# BERT 模型的输入长度是有限制的 (比如 max_len=512)。
# 别忘了我们要手动塞入 3 个特殊字符:[<cls>, 句子A, <sep>, 句子B, <sep>]
# 如果句子A 和 句子B 加上这 3 个特殊字符超长了,这个样本就直接丢弃 (continue)
if len(tokens_a) + len(tokens_b) + 3 > max_len:
continue
# 【组装成 BERT 认识的输入格式】
# d2l.get_tokens_and_segments 这个辅助函数做的事情是:
# 1. 组装 tokens: ['<cls>', '我', '吃饭', '<sep>', '去', '睡觉', '<sep>']
# 2. 组装 segments (号码牌): [0, 0, 0, 0, 1, 1, 1]
tokens, segments = d2l.get_tokens_and_segments(tokens_a, tokens_b)
# 把准备好的 (词列表, 片段号码牌, 真假标签) 打包塞进最终的列表里
nsp_data_from_paragraph.append((tokens, segments, is_next))
return nsp_data_from_paragraph
例子:


通过这两个函数的配合,本来老老实实的一本维基百科,就被加工成了无数对这样的数据:
("i love apples", "they are very sweet", True)
("i love apples", "the sky is blue", False)
_get_nsp_data_from_paragraph例子:


生成遮蔽语言模型任务的数据
python
import random
# ==========================================
# 1. 核心掩蔽逻辑 (经典的 80-10-10 法则)
# ==========================================
#@save
def _replace_mlm_tokens(tokens, candidate_pred_positions, num_mlm_preds, vocab):
# 1. 先把原来完整的句子拷贝一份,我们将在这个副本上进行"破坏"(掩蔽)
mlm_input_tokens = [token for token in tokens]
# 用来记录最终被挖掉的词的位置,以及它的正确答案
pred_positions_and_labels = []
# 2. 打乱候选位置,这样我们就能做到"随机"挑选 15% 的词
random.shuffle(candidate_pred_positions)
# 3. 开始遍历随机位置,一个一个去"破坏"
for mlm_pred_position in candidate_pred_positions:
# 如果已经破坏了足够数量的词(达到了 15% 的配额),就赶紧停手
if len(pred_positions_and_labels) >= num_mlm_preds:
break
masked_token = None
# 【💡 BERT 最经典的 80-10-10 掩蔽策略】
# 掷一个 0~1 的随机数
if random.random() < 0.8:
# 80% 的概率:规规矩矩地把这个词替换成 "<mask>" 符号
# 例如: "my dog is hairy" -> "my dog is <mask>"
masked_token = '<mask>'
else:
# 剩下 20% 的情况里面,再对半开 (也就是各占总体的 10%)
if random.random() < 0.5:
# 10% 的概率:什么都不做,保持原词不变!
# 例如: "my dog is hairy" -> "my dog is hairy"
# (目的:让模型不要过于依赖 <mask > 符号才去预测,要随时核对每个词对不对)
masked_token = tokens[mlm_pred_position]
else:
# 10% 的概率:用词典里的一个随机词去替换它!
# 例如: "my dog is hairy" -> "my dog is apple"
# (目的:故意制造噪音,逼迫模型不仅看当前词,必须结合上下文才能发现错误)
masked_token = random.choice(vocab.idx_to_token)
# 执行替换动作
mlm_input_tokens[mlm_pred_position] = masked_token
# 把被选中的位置,以及它【原来的正确词】记录在小本本上,留着以后对答案
pred_positions_and_labels.append(
(mlm_pred_position, tokens[mlm_pred_position]))
return mlm_input_tokens, pred_positions_and_labels
# ==========================================
# 2. 从一整句话中生成 MLM 训练样本
# ==========================================
#@save
def _get_mlm_data_from_tokens(tokens, vocab):
candidate_pred_positions = []
# 1. 寻找所有"可以被挖空"的候选位置
# tokens 此时是一个包含了特殊符号的列表,比如 ['<cls>', '我', '爱', '中', '国', '<sep>']
for i, token in enumerate(tokens):
# 像 <cls> 和 <sep> 这种自带特殊使命的结构性符号,绝对不能拿去做完形填空!
if token in ['<cls>', '<sep>']:
continue
# 把普通词的位置加进候选名单
candidate_pred_positions.append(i)
# 2. 算出 15% 到底是几个词?
# len(tokens) * 0.15 算出数值,round 四舍五入,max(1, ...) 保证至少挖掉 1 个词
num_mlm_preds = max(1, round(len(tokens) * 0.15))
# 3. 呼叫上面的流水线,去执行 80-10-10 的破坏任务
mlm_input_tokens, pred_positions_and_labels = _replace_mlm_tokens(
tokens, candidate_pred_positions, num_mlm_preds, vocab)
# 4. 把刚刚记录的小本本按照"位置"从小到大排个序
# 为什么要排?因为 Transformer 预测的时候是有序输出的,排好序对答案更方便
pred_positions_and_labels = sorted(pred_positions_and_labels,
key=lambda x: x[0])
# 5. 把排序好的(位置)和(正确答案标签)拆分成两个干净的列表
pred_positions = [v[0] for v in pred_positions_and_labels]
mlm_pred_labels = [v[1] for v in pred_positions_and_labels]
"""
# [v[0] for v in ...] 完全等价于下面这段代码:
pred_positions = []
for v in pred_positions_and_labels: # v 依次是 (2, 'is'), (5, 'red')...
pred_positions.append(v[0]) # v[0] 就是 2, 5...
"""
# 6. 最终返回前,把所有人类看的字符串,通过 vocab 字典全部转换成机器懂的整数索引!
return vocab[mlm_input_tokens], pred_positions, vocab[mlm_pred_labels]


把前面造好的长短不一的"考题",通过"填充(Padding)"变成统一的长度,然后把它们全部打包进 PyTorch 的标准 Dataset 和 DataLoader 中,形成一条可以源源不断给模型喂数据的自动化流水线。

python
import torch
from d2l import torch as d2l
# ==========================================
# 1. 统一试卷长度 (Padding 填充操作)
# ==========================================
# 为什么要有这一步?因为深度学习模型(特别是显卡计算)要求每个批次里的数据矩阵必须是对齐的(形状一样大)。
# 句子有长有短,所以我们需要用一个无意义的字符 '<pad>' 把短句补齐到规定的 max_len。
#@save
def _pad_bert_inputs(examples, max_len, vocab):
# 算出每个句子最多允许挖几个空 (最大长度的 15%)
max_num_mlm_preds = round(max_len * 0.15)
# 准备 7 个空列表,用来装最终对齐好的张量(Tensor)
all_token_ids, all_segments, valid_lens = [], [], []
all_pred_positions, all_mlm_weights, all_mlm_labels = [], [], []
nsp_labels = []
for (token_ids, pred_positions, mlm_pred_label_ids, segments, is_next) in examples:
# 1. 补齐 Tokens (词元)
# 用 vocab['<pad>'] (也就是0) 补在句子末尾,直到凑够 max_len
all_token_ids.append(torch.tensor(token_ids + [vocab['<pad>']] * (
max_len - len(token_ids)), dtype=torch.long))
# 2. 补齐 Segments (片段号码牌)
# 补上的位置用 0 填充
all_segments.append(torch.tensor(segments + [0] * (
max_len - len(segments)), dtype=torch.long))
# 3. 记录真实的句子长度 (valid_lens)
# 告诉模型:别看我总长是 max_len,其实真正有用的词只有前面 len(token_ids) 个!
valid_lens.append(torch.tensor(len(token_ids), dtype=torch.float32))
# 4. 补齐 MLM 预测位置
# 如果一句话太短,没挖够 max_num_mlm_preds 个坑,也得用 0 补齐位置
all_pred_positions.append(torch.tensor(pred_positions + [0] * (
max_num_mlm_preds - len(pred_positions)), dtype=torch.long))
# 5. 【极其关键!】制作 MLM 权重 (Weights)
# 模型在做完形填空时,会给所有的坑位(包括真正挖掉的词,和上面用0补齐的凑数坑位)做预测。
# 这里权重为 1.0 代表"这是真题,算误差";权重为 0.0 代表"这是凑数的,不算误差"。
all_mlm_weights.append(
torch.tensor([1.0] * len(mlm_pred_label_ids) + [0.0] * (
max_num_mlm_preds - len(pred_positions)),
dtype=torch.float32))
# 6. 补齐 MLM 的真实答案标签
all_mlm_labels.append(torch.tensor(mlm_pred_label_ids + [0] * (
max_num_mlm_preds - len(mlm_pred_label_ids)), dtype=torch.long))
# 7. 记录这道题的 NSP 真假标签
nsp_labels.append(torch.tensor(is_next, dtype=torch.long))
return (all_token_ids, all_segments, valid_lens, all_pred_positions,
all_mlm_weights, all_mlm_labels, nsp_labels)
# ==========================================
# 2. 定义 PyTorch 标准数据集类
# ==========================================
#@save
class _WikiTextDataset(torch.utils.data.Dataset):
def __init__(self, paragraphs, max_len):
# 1. 把自然语言句子分词成单词列表
paragraphs = [d2l.tokenize(paragraph, token='word') for paragraph in paragraphs]
sentences = [sentence for paragraph in paragraphs for sentence in paragraph]
# 2. 构建词典 (Vocab)
# 规定词频小于 5 的词当作生僻词扔掉,并强制保留 4 个特殊字符。
self.vocab = d2l.Vocab(sentences, min_freq=5, reserved_tokens=[
'<pad>', '<mask>', '<cls>', '<sep>'])
# 3. 生产流水线启动!
examples = []
for paragraph in paragraphs:
# 步骤A:生成"下一句预测(NSP)"的数据对 (一半真一半假)
examples.extend(_get_nsp_data_from_paragraph(
paragraph, paragraphs, self.vocab, max_len))
# 步骤B:在上面 NSP 数据的基础上,去执行"完形填空(MLM)"的 80-10-10 挖词破坏
examples = [(_get_mlm_data_from_tokens(tokens, self.vocab) + (segments, is_next))
for tokens, segments, is_next in examples]
# 步骤C:执行 Padding 补齐,把所有长短不一的数据变成一样大的整齐矩阵
(self.all_token_ids, self.all_segments, self.valid_lens,
self.all_pred_positions, self.all_mlm_weights,
self.all_mlm_labels, self.nsp_labels) = _pad_bert_inputs(
examples, max_len, self.vocab)
# Dataset 的标准接口:告诉 PyTorch 怎么取第 idx 个样本
def __getitem__(self, idx):
return (self.all_token_ids[idx], self.all_segments[idx],
self.valid_lens[idx], self.all_pred_positions[idx],
self.all_mlm_weights[idx], self.all_mlm_labels[idx],
self.nsp_labels[idx])
# Dataset 的标准接口:告诉 PyTorch 这个数据集一共有多少条数据
def __len__(self):
return len(self.all_token_ids)
# ==========================================
# 3. 最终的对外调用接口
# ==========================================
#@save
def load_data_wiki(batch_size, max_len):
"""加载WikiText-2数据集 (一键傻瓜式加载)"""
num_workers = d2l.get_dataloader_workers() # 获取多线程读取的进程数
data_dir = d2l.download_extract('wikitext-2', 'wikitext-2') # 下载解压
paragraphs = _read_wiki(data_dir) # 读取清洗
train_set = _WikiTextDataset(paragraphs, max_len) # 丢进上面的类里加工
# 包装成 DataLoader,方便后续在 for 循环中按批次(batch_size)抓取数据
train_iter = torch.utils.data.DataLoader(train_set, batch_size,
shuffle=True, num_workers=num_workers)
return train_iter, train_set.vocab
# ==========================================
# 4. 测试大融合:打印一波批次数据看看!
# ==========================================
# 设定每一批抓取 512 道题,每道题(包含两句话)最大长度为 64
batch_size, max_len = 512, 64
train_iter, vocab = load_data_wiki(batch_size, max_len)
# 从数据流中抓取第一个批次 (里面整整齐齐地包含了 7 个张量矩阵)
for (tokens_X, segments_X, valid_lens_x, pred_positions_X, mlm_weights_X,
mlm_Y, nsp_y) in train_iter:
print("输入的词元序列形状:", tokens_X.shape) # (512, 64)
print("输入的句子标识形状:", segments_X.shape) # (512, 64)
print("有效长度形状:", valid_lens_x.shape) # (512,)
print("需要预测的位置形状:", pred_positions_X.shape) # (512, 10) 因为 64 * 0.15 = 9.6,约等于 10
print("MLM权重形状:", mlm_weights_X.shape) # (512, 10)
print("MLM正确答案形状:", mlm_Y.shape) # (512, 10)
print("NSP正确答案形状:", nsp_y.shape) # (512,)
break # 验证成功,跳出循环!
整体流程:
1.paragraphs -> 2. 造出真假相间的上下句对 (NSP任务) -> 3. 挖掉15%的词 (MLM任务) -> 4. 用 <pad> 补齐所有句子 (Padding) -> 5. 封装进 DataLoader 等待送入模型