s17 预训练范式 — exercise.py 练习指南
练习目标
通过动手实现 BERT 分类头、MLM 遮盖策略和多义词嵌入对比,建立对预训练-微调范式的实际理解。完成后你将能够:
- 在任意预训练 BERT 上添加分类头并计算损失
- 理解并实现 MLM 的 80%-10%-10% 遮盖策略
- 理解 BERT 上下文嵌入如何解决多义词问题
预备知识
- BERT 架构:Encoder-only,双向自注意力,输入三种嵌入的和(Token + Segment + Position)
- BERT 的特殊 token:
[CLS]=101,[SEP]=102,[MASK]=103,[PAD]=0 - MLM 遮盖策略:选中 15% 的 token → 80% 替换为
[MASK],10% 替换为随机 token,10% 保持不变 - 为什么不全用
[MASK]:防止预训练-微调不匹配(微调时输入中没有[MASK]token)
任务清单
练习 1:为 BERT 构建分类头并计算损失
目标:补全 BertForClassification 类,在预训练 BERT 之上添加分类头。
核心思路:BERT 输出的 [CLS] token 的隐藏向量聚合了整个句子的信息。分类头的任务是将这个向量映射为类别 logits。
BERT 前向流程:
输入文本 → Tokenizer → input_ids (batch, seq_len)
→ BERT → last_hidden_state (batch, seq_len, hidden_size)
→ 取 [CLS] = last_hidden_state[:, 0, :] → (batch, hidden_size)
→ 分类头 Linear(hidden_size, num_labels) → logits (batch, num_labels)
→ CrossEntropyLoss(logits, labels) → 标量损失TODO 步骤:
步骤 A — 创建分类头:
class BertForClassification(nn.Module):
def __init__(self, bert_backbone, num_labels):
self.bert = bert_backbone
hidden_size = self.bert.config.hidden_size # 通常 768
self.classifier = nn.Linear(hidden_size, num_labels) # ← TODO步骤 B — 提取 [CLS] 向量:
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
# outputs.last_hidden_state: (batch, seq_len, hidden_size)
cls_embedding = outputs.last_hidden_state[:, 0, :] # ← TODO: 第一个 token[:, 0, :] 的含义:取所有 batch、第 0 个 token(即 [CLS])、所有 hidden_size 维度。
步骤 C — 分类与损失计算:
logits = self.classifier(cls_embedding) # ← TODO: (batch, num_labels)
if labels is not None:
loss = F.cross_entropy(logits, labels) # ← TODO
return loss, logits为什么用 [CLS] 而不是平均所有 token? [CLS] 是一个特殊的聚合 token,它在预训练期间(NSP 任务)被训练来编码整个输入序列的语义。Attention 机制让 [CLS] 能够从所有其他 token 中收集信息。
预期理解:[CLS]向量 → Linear(hidden, num_labels) → CrossEntropy。这就是 BERT 微调的标准范式。
[练习1] BERT 分类头结构已定义
核心步骤: [CLS]向量 → Linear(hidden, num_labels) → CrossEntropy练习 2:实现 MLM 的随机遮盖
目标:补全 random_mask_tokens() 函数,实现 BERT 预训练中使用的 MLM 遮盖策略。
遮盖策略总结:
- 随机选中 15% 的 token(不包括特殊 token)
- 选中的 token 中:
- 80% 替换为
[MASK]— 让模型学会从上下文推断 - 10% 替换为随机 token — 防止模型"看到 [MASK] 就无脑复制"
- 10% 保持原样 — 让模型学会"这个位置可能不需要改变"
- 80% 替换为
- 特殊 token(
[CLS],[SEP],[PAD])永不被遮盖
TODO 步骤:
def random_mask_tokens(input_ids, vocab_size, mask_token_id,
special_tokens=None, mask_prob=0.15):
labels = torch.full_like(input_ids, -100) # -100 在 CrossEntropyLoss 中被忽略
masked_input_ids = input_ids.clone()
# 步骤 1: 确定哪些位置需要被遮盖
prob_matrix = torch.rand(input_ids.shape) # (batch, seq_len) 的 [0,1) 随机数
# 需要遮盖的位置 = 随机概率 < mask_prob 且 不是特殊 token
mask_positions = (prob_matrix < mask_prob)
for sp in special_tokens:
mask_positions = mask_positions & (input_ids != sp)
# 步骤 2: 对每个被选中的位置,按 80-10-10 策略替换
for i in range(input_ids.size(0)):
for j in range(input_ids.size(1)):
if mask_positions[i, j]:
labels[i, j] = input_ids[i, j] # 记录真实 token
rand_val = torch.rand(1).item()
if rand_val < 0.8: # 80% → [MASK]
masked_input_ids[i, j] = mask_token_id
elif rand_val < 0.9: # 10% → 随机 token
masked_input_ids[i, j] = torch.randint(
0, vocab_size, (1,)
).item()
# else: 10% 保持原样(不修改)
return masked_input_ids, labels关键设计:labels 中非遮盖位置的值为 -100,这是 PyTorch CrossEntropyLoss 的特殊约定——当 ignore_index=-100 时,对应位置的损失不参与计算。这样模型只在被遮盖的位置上计算损失,与 BERT 预训练的做法一致。
预期输出:
[练习2] MLM 遮盖测试:
原始: [101, 123, 456, 789, 102, 0, 0]
遮盖后: [101, 103, 456, 789, 102, 0, 0] (示例)
标签: [-100, 123, -100, -100, -100, -100, -100]
注意: 101([CLS]), 102([SEP]), 0([PAD]) 不应被遮盖练习 3:比较多义词在不同上下文中的 BERT 嵌入
目标:理解 BERT 的上下文嵌入如何解决 Word2Vec 等静态嵌入的多义词问题。
核心概念:Word2Vec/GloVe 等静态嵌入给每个词一个固定的向量——"苹果"只有一个向量表示,无论它出现在"吃苹果"还是"苹果公司"的上下文中。BERT 的不同在于:同一个词的嵌入会随上下文动态变化。
TODO 步骤:
def compare_polysemous_embeddings(sentence_pairs, get_bert_embedding):
similarities = []
for sent1, sent2 in sentence_pairs:
emb1 = get_bert_embedding(sent1) # 获取句子 1 中目标词的 BERT 嵌入
emb2 = get_bert_embedding(sent2) # 获取句子 2 中目标词的 BERT 嵌入
sim = F.cosine_similarity(emb1.unsqueeze(0), emb2.unsqueeze(0)).item()
similarities.append(sim)
return similarities预期现象:
- "苹果很好吃"中的"苹果" vs "苹果发布了新手机"中的"苹果" → 余弦相似度低(不同语义)
- "苹果很好吃"中的"苹果" vs "红富士苹果很甜"中的"苹果" → 余弦相似度高(相同语义)
- "苹果很好吃"中的"苹果" vs "香蕉很好吃"中的"香蕉" → 可能比前一对的相似度更高或更低,取决于模型是否学到了"苹果和香蕉都是水果"这种类别知识
这为什么重要? 多义词处理是 NLP 的核心挑战之一。BERT 的上下文嵌入让下游任务(情感分析、问答等)能区分"bank(银行)"和"bank(河岸)"——这对理解句子含义至关重要。
[练习3] 多义词嵌入对比(需要实际的 BERT 模型才能测试)
预期: '苹果很好吃'中的'苹果' ≠ '苹果发布了新手机'中的'苹果'三个练习的关系
| 练习 | 对应概念 | BERT 预训练/微调中的位置 |
|---|---|---|
| 练习 1: 分类头 | BERT 微调 | 在预训练模型顶部加任务相关层 |
| 练习 2: MLM 遮盖 | BERT 预训练 | 预训练阶段的核心训练目标 |
| 练习 3: 上下文嵌入 | BERT 表示能力 | BERT 的独特优势 |
检查要点
运行 python exercise.py,确认:
- [ ] 练习 1 分类头结构正确(Linear + CrossEntropy)
- [ ] 练习 2 特殊 token 不被遮盖,遮盖位置按 80-10-10 策略处理
- [ ] 练习 3 理解同一词在不同上下文中的向量差异
完成练习后,返回 demo.py 观察这些概念在完整 pipeline 中的应用——BERT 分类微调(练习 1 的概念)、MLM 演示(练习 2 的概念)和上下文嵌入对比(练习 3 的概念)。
完整代码
# -*- coding: utf-8 -*-
"""
s17 预训练范式 — 练习题
==============================================
请补全以下 TODO 部分,完成后运行验证。
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import random
# ============================================================
# 练习 1:为 BERT 构建分类头并计算损失
# ============================================================
class BertForClassification(nn.Module):
"""
TODO: 构建 BERT 分类模型
在预训练 BERT 之上添加分类头
"""
def __init__(self, bert_backbone: nn.Module, num_labels: int):
"""
参数:
bert_backbone: 预训练的 BERT 模型(不含分类头)
num_labels: 分类类别数
"""
super().__init__()
self.bert = bert_backbone
hidden_size = self.bert.config.hidden_size # BERT 的隐藏维度,通常 768
# TODO: 创建一个线性层作为分类头
# 输入维度: hidden_size (取 [CLS] token 的向量)
# 输出维度: num_labels
# ===== 你的代码在这里 =====
self.classifier = None # 替换为 nn.Linear(hidden_size, num_labels)
# ==========================
def forward(self, input_ids, attention_mask, labels=None):
"""
前向传播:提取 [CLS] 向量 → 分类头 → 计算损失
参数:
input_ids: token 索引 (batch, seq_len)
attention_mask: 注意力掩码 (batch, seq_len)
labels: 真实标签 (batch,), 可选
返回:
如果 labels 不为 None: (loss, logits)
否则: logits
"""
# 获取 BERT 的输出
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
# outputs.last_hidden_state: (batch, seq_len, hidden_size)
# TODO: 取出 [CLS] token 的向量(第一个 token)
# ===== 你的代码在这里 =====
cls_embedding = None # outputs.last_hidden_state[:, 0, :]
# ==========================
# TODO: 通过分类头得到 logits
# ===== 你的代码在这里 =====
logits = None # self.classifier(cls_embedding)
# ==========================
loss = None
if labels is not None:
# TODO: 用 CrossEntropyLoss 计算损失
# ===== 你的代码在这里 =====
pass # loss = F.cross_entropy(logits, labels)
# ==========================
return loss, logits
return logits
print("[练习1] BERT 分类头结构已定义(需要实际的 bert_backbone 才能测试)")
print(" 核心步骤: [CLS]向量 → Linear(hidden, num_labels) → CrossEntropy")
# ============================================================
# 练习 2:实现简单的 MLM:随机遮盖 15% 的 token
# ============================================================
def random_mask_tokens(
input_ids: torch.Tensor, # (batch, seq_len) — token id 序列
vocab_size: int, # 词汇表大小
mask_token_id: int, # [MASK] token 的 id
special_tokens: set = None, # 特殊 token 集合,不应被遮盖
mask_prob: float = 0.15, # 遮盖比例
) -> tuple:
"""
TODO: 实现 MLM 的随机遮盖
模仿 BERT 的策略:
- 15% 的 token 被选中
- 选中的 token 中: 80% 替换为 [MASK], 10% 替换为随机 token, 10% 保持原样
- 特殊 token ([CLS], [SEP], [PAD]) 不应被遮盖
参数:
input_ids: 原始 token 序列
vocab_size: 词汇表大小
mask_token_id: [MASK] token 的 id
special_tokens: 特殊 token id 集合
mask_prob: 被选中遮盖的概率
返回:
masked_input_ids: 遮盖后的 token 序列
labels: 真实标签,非遮盖位置为 -100(在 CrossEntropyLoss 中被忽略)
"""
if special_tokens is None:
special_tokens = set()
batch_size, seq_len = input_ids.shape
# 创建标签:默认全部为 -100(损失计算时忽略)
labels = torch.full_like(input_ids, -100)
# TODO: 实现遮盖逻辑
# 步骤:
# 1. 创建概率矩阵,形状 (batch, seq_len),值为随机均匀分布
# 2. 找出需要遮盖的位置:
# - 随机概率 < mask_prob
# - 不是特殊 token
# 3. 对这些位置:
# - 生成 [0, 1) 随机数
# - < 0.8: 替换为 [MASK]
# - < 0.9: 替换为随机 token id
# - >= 0.9: 保持原样
# 4. labels 中,被选中的位置填入原始 token id
# ===== 你的代码在这里 =====
masked_input_ids = input_ids.clone()
# 概率矩阵
prob_matrix = torch.rand(input_ids.shape)
# 找出需要遮盖的 mask 位置
# ...
# ==========================
return masked_input_ids, labels
# 测试 MLM 遮盖
test_ids = torch.tensor([[101, 123, 456, 789, 102, 0, 0]]) # [CLS]=101, [SEP]=102, [PAD]=0
mask_token = 103
special = {101, 102, 0}
try:
masked_ids, labels = random_mask_tokens(test_ids, 10000, mask_token, special, mask_prob=1.0)
print(f"\n[练习2] MLM 遮盖测试:")
print(f" 原始: {test_ids.tolist()[0]}")
print(f" 遮盖后: {masked_ids.tolist()[0]}")
print(f" 标签: {labels.tolist()[0]}")
print(f" 注意: 101([CLS]), 102([SEP]), 0([PAD]) 不应被遮盖")
except Exception as e:
print(f"\n[练习2] 未完成实现: {e}")
# ============================================================
# 练习 3:比较多义词在不同上下文中的 BERT 嵌入
# ============================================================
def compare_polysemous_embeddings(
sentence_pairs: list, # [(句1, 句2), ...] 每组包含同一个多义词在不同上下文中的句子
get_bert_embedding, # 函数: sentence → embedding vector
) -> list:
"""
TODO: 比较多义词在不同上下文中的 BERT 嵌入
参数:
sentence_pairs: 句子对列表
get_bert_embedding: 获取 BERT 嵌入的函数
返回:
similarities: 每组句对的余弦相似度列表
"""
# TODO: 实现
# 步骤:
# 1. 对每组句对,调用 get_bert_embedding 获取每个句子的嵌入
# 2. 计算余弦相似度 F.cosine_similarity(v1, v2, dim=0)
# 3. 比较同义词在不同上下文中和不同词在相同上下文中的相似度
# ===== 你的代码在这里 =====
similarities = []
# ==========================
return similarities
# 模拟测试
print(f"\n[练习3] 多义词嵌入对比(需要实际的 BERT 模型才能测试)")
print(" 预期: '苹果很好吃'中的'苹果' ≠ '苹果发布了新手机'中的'苹果'")
print(" BERT 的上下文嵌入让同一词在不同语境中有不同的向量表示")
print("\n所有练习测试完成!请对比 demo.py 查看参考实现。")
print("""
提示:
- BERT 分类: [CLS] token 聚合了全句信息 → Linear → softmax
- MLM 遮盖: 15% 选中的 token 中 80%→[MASK], 10%→随机, 10%→不变
- 上下文嵌入: BERT 给同一词在不同上下文中不同的向量
""")