【案例】新零售评价决策系统 · P-Tuning 软模板
别再猜哪句话问得好——把模板交给梯度自己学:六个伪 token 的拼接、掩码重算,以及与 PET 逐行对照的迁移清单。
30″30 秒看懂 P-Tuning
前两页里,那位只会填空的老师傅一直在做我们写好的题:「这是一条 __ 评论:……」。题干是人写的——人觉得这么问最顺口。
问题来了:人觉得顺口,不等于模型觉得顺口。把「这是一条」换成「以下属于」,准确率可能差好几个点,而你事先根本不知道哪句更好,只能一句句试。P-Tuning 的想法是:别猜了,让模型自己把题干学出来。
做法是在句子最前面插几个空白占位符——词表里那些从来没被用过的 [unused1] ~ [unused6]。它们没有字面意思,就是六个可以被梯度自由塑形的向量。训练开始后,模型会把「怎么问这道题最容易答对」写进这六个向量里。

| 比喻里的角色 | 对应的技术概念 | 它到底干了什么 |
|---|---|---|
| 人写的题干 | 硬模板 hard template | PET 的做法:一句自然语言,每个字都有字面意义 |
| 六个空白占位符 | [unused1] ~ [unused6] | 预训练里几乎没被更新过的 token,等于一块干净画布 |
| 模型自己学出的题干 | 软模板 soft prompt | 六个 embedding 向量,没有字面意思,只有数值 |
| 占位符的个数 | p_embedding_num | 本讲取 6;调大容量更足但更难训,还更占序列长度 |
| 答题的空格 | [MASK] | 和 PET 完全一样,位置固定在 [CLS] 之后 |
| 对答案的词典 | Verbalizer | 和 PET 共用同一份,一个字都不用改 |
01概念
软模板是什么、它为什么能学出来、和 PET 怎么选
硬模板的三个真实麻烦
- 模板敏感。 同一份数据、同一个模型,换一句措辞,指标能差好几个点。这个差异没有规律可循,只能穷举。
- 类别抽象时写不出来。 「水果 / 酒店」这种有直白名字的类别好办,遇到「意图 A / 意图 B」这种业务内部编号,硬模板根本没法组句——你要模型在 mask 位置填什么字?
- 换语言、换领域要重写。 模板是自然语言,跨语言完全不能复用。
软模板怎么学出来
BERT 的第一层是 embedding 层,它把每个 token id 查成一个 768 维向量。这张查找表也是参数,一样会被梯度更新。
P-Tuning 利用的就是这一点:在输入序列最前面插入 6 个 token id,这 6 个 id 对应的 embedding 向量随着训练一起被优化。因为选的是 [unused1]~[unused6] 这种预训练阶段几乎没出现过的 token,它们的向量近乎「未被写过的空白」,梯度可以自由塑形,不必先把原有语义抹掉。
# -*- coding:utf-8 -*-
"""为什么偏偏挑 [unused1] 这种 token 当伪 token。
bert-base-chinese 的词表里留了一批 [unusedN] 占位符,
预训练阶段它们从未在真实语料里出现过,embedding 基本没被更新过——
相当于一块干净的画布。用「的」「了」这种高频字当伪 token,
梯度要先把它原有的语义抹掉,收敛更慢也更不稳。
"""
def unused_ids(n=6, base=1):
"""中文 BERT 词表里 [unused1] 的 id 就是 1,往后连号。"""
return [base + i for i in range(n)]
if __name__ == '__main__':
print('[unused1..6] 的 token id:', unused_ids(6))
assert unused_ids(6) == [1, 2, 3, 4, 5, 6]
# 词表结构:0=[PAD], 1..99=[unusedN], 100=[UNK], 101=[CLS], 102=[SEP], 103=[MASK]
special = {0: '[PAD]', 100: '[UNK]', 101: '[CLS]', 102: '[SEP]', 103: '[MASK]'}
print('\nid token')
for i in sorted(special):
print('%-4d %s' % (i, special[i]))
print('1-99 [unused1] ~ [unused99] <- 可挪用的空位一共 99 个')
# 越界检查:p_embedding_num 不能超过 99
for p_num in (6, 32, 99):
ids = unused_ids(p_num)
assert max(ids) <= 99, 'p_embedding_num 最多 99'
print('p_embedding_num=%-3d 占用 id %d..%d' % (p_num, ids[0], ids[-1]))
try:
p_num = 120
ids = unused_ids(p_num)
assert max(ids) <= 99
except AssertionError:
print('\np_embedding_num=120 会撞上 [UNK] 及之后的真实 token —— 被断言拦住')
# 伪 token 占的是序列长度预算,不是免费的
max_seq_len, max_label_len = 512, 2
for p_num in (6, 32, 99):
usable = max_seq_len - p_num - max_label_len - 2
print('p_embedding_num=%-3d 正文还剩 %d 个 token' % (p_num, usable))
assert 512 - 99 - 2 - 2 == 409
print('\n用满 99 个伪 token,正文可用长度从 502 掉到 409 —— 长文本任务要算这笔账')
和 PET 的对照
| 对比项 | BERT + PET | BERT + P-Tuning |
|---|---|---|
| 模板形态 | 人工写的自然语言 | 6 个可学习的伪 token |
| 新增可训练参数 | 无 | 6 个 embedding 向量(6 × 768 = 4608 个数) |
| 模板敏感性 | 高,换一句话指标就抖 | 低,梯度自己找 |
| 需要的额外文件 | prompt.txt 与 verbalizer.txt | 只要 verbalizer.txt |
| mask 位置 | 随模板长度变化,要跟着算 | 固定在 p_embedding_num + 1 起 |
| 可解释性 | 模板能读,出问题看得懂 | 向量不可读,只能看指标 |
| 适合场景 | 类别语义直白、能想出好模板 | 模板不好写、类别抽象 |
注意「新增可训练参数」这一行:P-Tuning 确实新增了参数,所以严格讲它不像 PET 那样「零新增」。但 4608 个数相对于 BERT 的一亿参数可以忽略不计,而且本讲的实现里 BERT 全部参数依然参与训练——伪 token 的 embedding 只是搭了个便车,不是唯一被更新的东西。
怎么选
工程上的实际建议:先写硬模板跑一版做基线,因为它可解释、好排查。指标不理想且怀疑是模板不够好时,再换 P-Tuning 对照。两条路的数据文件、训练代码、评估口径完全一致,切换成本极低——这本身就是把两套方案做成同一个项目结构的价值。
02原理:软提示是怎么拼进去的
四步拼接,每一步都有一个必须成立的性质
P-Tuning 和 PET 的代码差异,集中在编码函数里的四行。别的地方一个字都没变。这四行做的事是:把 6 个伪 token 插到序列最前面,把 2 个 mask 插到 [CLS] 之后,然后把长度和掩码都重算一遍。

四步拼接
| 步骤 | 代码在做什么 | 为什么必须这么做 |
|---|---|---|
| ① | input_ids = input_ids[:-1] | 摘掉末尾的 [SEP],拼完再补回去;不摘的话 [SEP] 会卡在正文中间 |
| ② | 按 max_seq_len - mask 数 - 伪 token 数 - 1 截断 | 给待插入的内容腾位置,否则拼完超长,尾部被 truncation 砍掉 |
| ③ | p_token_ids + ids[:1] + mask_ids + ids[1:] | 伪 token 在最前,然后 [CLS],然后 mask,然后正文 |
| ④ | 补回 [SEP] 或 [PAD],重算 attention_mask | 序列变了,掩码必须跟着变 |
顺序值得留意:伪 token 排在 [CLS] 前面。这和直觉不太一样——按 BERT 的惯例 [CLS] 该是第一个。但这里 [CLS] 的池化输出根本没被用到(损失只看 mask 位置),它的位置就不再关键;把伪 token 放最前,好处是 mask 的下标可以写成一个与正文无关的常数。
# -*- coding:utf-8 -*-
"""软提示到底是怎么拼进去的:纯标准库复刻四步拼接,可直接运行。
P-Tuning 的全部结构改动就这四步。写错的典型后果是
mask_positions 与实际 [MASK] 的位置对不上,损失算在了正文的字上。
"""
CLS, SEP, PAD, MASK = 101, 102, 0, 103
UNUSED_BASE = 1 # [unused1] 在中文 BERT 词表里的 id 就是 1
def build_input_ids(content_ids, p_num=6, mask_len=2, max_seq_len=24):
"""复刻 data_preprocess.convert_example 里的拼接逻辑。
参数:
content_ids: tokenizer 编码后的完整 input_ids,形如 [CLS] ... [SEP]
"""
p_token_ids = [UNUSED_BASE + i for i in range(p_num)] # [1..6]
mask_ids = [MASK] * mask_len
start_mask_position = 1 # 伪 token 插在 [CLS] 之后
# ① 先把最后一个 token 摘掉(通常是 [SEP]),待会儿补回来
tmp = content_ids[:-1]
# ② 给伪 token、mask 和末尾的 [SEP] 腾出位置
tmp = tmp[:max_seq_len - len(mask_ids) - len(p_token_ids) - 1]
# ③ 真正的拼接:伪 token + [CLS] + mask + 正文
tmp = p_token_ids + tmp[:1] + mask_ids + tmp[start_mask_position:]
# ④ 补回结尾:原句里已有 [SEP] 就补 pad,否则补 [SEP]
if SEP in tmp:
input_ids = tmp + [PAD]
else:
input_ids = tmp + [SEP]
mask_positions = [len(p_token_ids) + start_mask_position + i for i in range(mask_len)]
attention_mask = [1 if t > 0 else 0 for t in input_ids]
return input_ids, mask_positions, attention_mask
if __name__ == '__main__':
# 模拟一句 10 个字的评论:[CLS] + 10 个字 + [SEP]
content = [CLS] + [3000 + i for i in range(10)] + [SEP]
ids, pos, att = build_input_ids(content, p_num=6, mask_len=2, max_seq_len=24)
print('拼接后的 input_ids:', ids)
print('长度:', len(ids))
print('mask_positions :', pos)
# 断言一:前 6 个位置必须是伪 token
assert ids[:6] == [1, 2, 3, 4, 5, 6], '伪 token 必须排在最前面'
# 断言二:第 7 个位置是 [CLS]
assert ids[6] == CLS
# 断言三:mask 紧跟在 [CLS] 之后,落在下标 7、8
assert ids[7] == MASK and ids[8] == MASK
assert pos == [7, 8], 'mask_positions 由公式算出,不是数出来的'
print('拼接顺序校验通过:[unused1-6] [CLS] [MASK][MASK] 正文 …')
# 断言四:mask 位置与伪 token 个数直接挂钩,改一个另一个跟着变
for p_num in (2, 4, 8):
_, pos_n, _ = build_input_ids(content, p_num=p_num, mask_len=2, max_seq_len=32)
assert pos_n == [p_num + 1, p_num + 2]
print(' p_embedding_num=%d -> mask_positions=%s' % (p_num, pos_n))
print('结论:改 p_embedding_num 必须同步重新生成数据,缓存的旧特征会算错位置')
# 断言五:attention_mask 必须重算。原 tokenizer 给的那份长度和内容都不对了
print('attention_mask :', att)
assert len(att) == len(ids)
assert att[:9] == [1] * 9, '伪 token 与 mask 都要被看见'
assert att[-1] == 0 or ids[-1] != PAD
print('全部断言通过')
mask 位置变成了常数
PET 里 mask 的下标取决于模板有多长,模板一改就得重算。P-Tuning 里它是一个公式:
mask_positions = [p_embedding_num + 1 + i for i in range(max_label_len)]
本讲的配置下就是固定的 [7, 8]——6 个伪 token 占 0~5,[CLS] 占 6,两个 mask 占 7 和 8。这个性质让调试变得简单:打印任意一条样本,第 7、8 个位置必须是 [MASK],不是就说明拼接写错了。
[7, 8],而真正的 [MASK] 已经挪到了 [9, 10]。损失算在了正文的字上,不报错,指标断崖。datasets 的 map() 默认带缓存,这个坑很容易踩到。
attention_mask 必须重算
tokenizer 返回的 attention_mask 是针对原始序列的。拼进 6 个伪 token 和 2 个 mask 之后,长度变了、内容也变了。沿用旧的那一份,最直接的后果是:模型把伪 token 当成 padding 忽略掉,软提示等于没加——训练照跑,指标和不加提示一个样。
# -*- coding:utf-8 -*-
"""为什么 attention_mask 必须重算:纯标准库对照实验,可直接运行。
tokenizer 给出的 attention_mask 是针对「原始序列」的。
拼进 6 个伪 token 和 2 个 mask 之后,序列长度和内容都变了,
直接沿用那一份,模型会把伪 token 当成 padding 忽略掉——软提示等于没加。
"""
CLS, SEP, PAD, MASK = 101, 102, 0, 103
def recompute(input_ids):
"""真实代码里的 np.where(np.array(ids) > 0, 1, 0)。"""
return [1 if t > 0 else 0 for t in input_ids]
if __name__ == '__main__':
# tokenizer 对一句 5 个字的评论、padding 到 12 的输出
orig_ids = [CLS, 3001, 3002, 3003, 3004, 3005, SEP] + [PAD] * 5
orig_att = [1] * 7 + [0] * 5
print('原始 input_ids :', orig_ids)
print('原始 attention_mask:', orig_att)
assert len(orig_ids) == len(orig_att) == 12
# 拼接后的序列:6 个伪 token + [CLS] + 2 个 mask + 正文 + [SEP]
new_ids = [1, 2, 3, 4, 5, 6, CLS, MASK, MASK, 3001, 3002, 3003, 3004, 3005, SEP, PAD]
print('\n拼接后 input_ids :', new_ids)
print('长度从 %d 变成 %d' % (len(orig_ids), len(new_ids)))
# 错误做法:沿用旧的 attention_mask
wrong = orig_att + [0] * (len(new_ids) - len(orig_att))
print('\n沿用旧 mask(错误):', wrong)
ignored = [new_ids[i] for i in range(len(new_ids)) if wrong[i] == 0 and new_ids[i] != PAD]
print('被错误忽略的 token :', ignored)
assert 3004 in ignored and 3005 in ignored, '正文尾部被当成 padding 丢掉了'
assert SEP in ignored
# 正确做法:按新的 input_ids 重算
right = recompute(new_ids)
print('\n重算的 mask(正确):', right)
assert len(right) == len(new_ids)
assert right[:6] == [1] * 6, '伪 token 必须可见,否则软提示白拼'
assert right[-1] == 0, 'padding 必须被屏蔽'
assert sum(right) == len(new_ids) - 1
print('可见 token 数:', sum(right), '/', len(new_ids))
# 这个写法有个前提:pad_token_id 必须是 0
print('\n前提:pad_token_id == 0,才能用 “>0” 判可见性')
print('若换一个 pad_token_id 不为 0 的分词器,这行必须改成与 pad_token_id 比较')
print('全部断言通过')
重算的写法是 np.where(np.array(input_ids) > 0, 1, 0)。它成立的前提是 pad_token_id == 0,中文 BERT 恰好满足。换一个 pad id 不为 0 的分词器,这一行必须改成与 pad_token_id 比较,否则 padding 会被当成可见 token。
序列长度预算
伪 token 占的是真金白银的序列长度。max_seq_len=512 时:
p_embedding_num | 占用 | 正文还剩 |
|---|---|---|
| 6 | 6 + 2 mask + CLS + SEP | 502 个 token |
| 32 | 32 + 2 + 2 | 476 个 token |
| 99(词表空位上限) | 99 + 2 + 2 | 409 个 token |
评论类短文本无所谓,长文档任务要算这笔账。另外 [unusedN] 在中文 BERT 词表里一共只有 99 个(id 1~99),p_embedding_num 超过 99 就会撞上 [UNK] 及之后的真实 token——那些 token 有语义,挪用它们就退回到「高频字当伪 token」的老问题上了。
其余部分为什么能原样复用
把 P-Tuning 的 train.py 和 PET 的 train.py 并排看,会发现除了 import 的模块名不同,训练循环一行没改。原因在于两者交给损失函数的东西形状完全一致:
logits:(batch, seq_len, 21128)—— 一样。mask_positions:(batch, max_label_len)—— 一样,只是数值从「随模板变」变成了常数。mask_labels:(batch, max_label_len)—— 一样,Verbalizer 也是同一份。
这就是把接口对齐的价值:换掉一个模块,上下游不受影响。工程上想让两条技术路线可以随时对照,就得在设计数据结构时先把这件事想清楚。
03最小代码:把伪 token 拼进一条样本
不加载模型,只验证拼出来的序列对不对
P-Tuning 的全部结构改动就在编码函数里。验证它不需要模型,只需要一个分词器——甚至连分词器都不必,用假的 token id 也能把拼接逻辑验证清楚。
# -*- coding:utf-8 -*-
"""软提示到底是怎么拼进去的:纯标准库复刻四步拼接,可直接运行。
P-Tuning 的全部结构改动就这四步。写错的典型后果是
mask_positions 与实际 [MASK] 的位置对不上,损失算在了正文的字上。
"""
CLS, SEP, PAD, MASK = 101, 102, 0, 103
UNUSED_BASE = 1 # [unused1] 在中文 BERT 词表里的 id 就是 1
def build_input_ids(content_ids, p_num=6, mask_len=2, max_seq_len=24):
"""复刻 data_preprocess.convert_example 里的拼接逻辑。
参数:
content_ids: tokenizer 编码后的完整 input_ids,形如 [CLS] ... [SEP]
"""
p_token_ids = [UNUSED_BASE + i for i in range(p_num)] # [1..6]
mask_ids = [MASK] * mask_len
start_mask_position = 1 # 伪 token 插在 [CLS] 之后
# ① 先把最后一个 token 摘掉(通常是 [SEP]),待会儿补回来
tmp = content_ids[:-1]
# ② 给伪 token、mask 和末尾的 [SEP] 腾出位置
tmp = tmp[:max_seq_len - len(mask_ids) - len(p_token_ids) - 1]
# ③ 真正的拼接:伪 token + [CLS] + mask + 正文
tmp = p_token_ids + tmp[:1] + mask_ids + tmp[start_mask_position:]
# ④ 补回结尾:原句里已有 [SEP] 就补 pad,否则补 [SEP]
if SEP in tmp:
input_ids = tmp + [PAD]
else:
input_ids = tmp + [SEP]
mask_positions = [len(p_token_ids) + start_mask_position + i for i in range(mask_len)]
attention_mask = [1 if t > 0 else 0 for t in input_ids]
return input_ids, mask_positions, attention_mask
if __name__ == '__main__':
# 模拟一句 10 个字的评论:[CLS] + 10 个字 + [SEP]
content = [CLS] + [3000 + i for i in range(10)] + [SEP]
ids, pos, att = build_input_ids(content, p_num=6, mask_len=2, max_seq_len=24)
print('拼接后的 input_ids:', ids)
print('长度:', len(ids))
print('mask_positions :', pos)
# 断言一:前 6 个位置必须是伪 token
assert ids[:6] == [1, 2, 3, 4, 5, 6], '伪 token 必须排在最前面'
# 断言二:第 7 个位置是 [CLS]
assert ids[6] == CLS
# 断言三:mask 紧跟在 [CLS] 之后,落在下标 7、8
assert ids[7] == MASK and ids[8] == MASK
assert pos == [7, 8], 'mask_positions 由公式算出,不是数出来的'
print('拼接顺序校验通过:[unused1-6] [CLS] [MASK][MASK] 正文 …')
# 断言四:mask 位置与伪 token 个数直接挂钩,改一个另一个跟着变
for p_num in (2, 4, 8):
_, pos_n, _ = build_input_ids(content, p_num=p_num, mask_len=2, max_seq_len=32)
assert pos_n == [p_num + 1, p_num + 2]
print(' p_embedding_num=%d -> mask_positions=%s' % (p_num, pos_n))
print('结论:改 p_embedding_num 必须同步重新生成数据,缓存的旧特征会算错位置')
# 断言五:attention_mask 必须重算。原 tokenizer 给的那份长度和内容都不对了
print('attention_mask :', att)
assert len(att) == len(ids)
assert att[:9] == [1] * 9, '伪 token 与 mask 都要被看见'
assert att[-1] == 0 or ids[-1] != PAD
print('全部断言通过')
跑完它,控制台会打出拼接后的 input_ids 和 mask_positions,并逐条断言:前 6 个是伪 token、第 7 个是 [CLS]、第 8-9 个是 [MASK]。这三条断言就是这一页的全部技术内容。
接上真实的分词器之后,就是项目里的编码函数:
# -*- coding:utf-8 -*-
"""P-Tuning 的样本编码:把「标签\t正文」变成模型能吃的张量。
和 PET 版的唯一差别在 convert_example 内部——
PET 是「填模板」,这里是「在句首插伪 token」。外层接口完全一致,
所以 data_loader、train、inference 几乎可以原样复用。
"""
import numpy as np
def convert_example(examples: dict,
tokenizer,
max_seq_len: int,
max_label_len: int,
p_embedding_num=6,
train_mode=True,
return_tensor=False) -> dict:
"""
参数:
examples: {'text': ['体育 世界杯开赛了', ...]},train_mode=False 时没有前面的标签列
p_embedding_num: 伪 token 个数,必须与训练/推理两侧一致
返回:
input_ids / token_type_ids / attention_mask / mask_positions /
mask_labels(仅训练)
"""
tokenized_output = {
'input_ids': [],
'token_type_ids': [],
'attention_mask': [],
'mask_positions': [],
'mask_labels': [],
}
for i, example in enumerate(examples['text']):
try:
if train_mode:
label, content = example.strip().split('\t', 1)
else:
content, label = example.strip(), None
except ValueError:
print('[跳过] 第 %d 条格式不对(缺少 \\t): %s' % (i, example[:30]))
continue
encoded_inputs = tokenizer(
text=content,
truncation=True,
max_length=max_seq_len,
padding='max_length')
input_ids = encoded_inputs['input_ids']
# ① 摘掉末尾 token(一般是 [SEP]),拼完再补回去
input_ids = input_ids[:-1]
# ② 给伪 token、mask、[SEP] 腾位置
input_ids = input_ids[:max_seq_len - len(input_ids) * 0 - max_label_len - p_embedding_num - 1]
p_token_ids = tokenizer.convert_tokens_to_ids(
['[unused%s]' % (i + 1) for i in range(p_embedding_num)])
mask_ids = [tokenizer.mask_token_id] * max_label_len
# ③ 伪 token + [CLS] + [MASK]*n + 正文
input_ids = p_token_ids + input_ids[:1] + mask_ids + input_ids[1:]
# ④ 补回结尾
if tokenizer.sep_token_id in input_ids:
input_ids = input_ids + [tokenizer.pad_token_id]
else:
input_ids = input_ids + [tokenizer.sep_token_id]
# mask 位置由公式算出,不要去序列里 index() 查找:
# 正文里若本来就含 [MASK] 字面,index() 会找错
mask_positions = [len(p_token_ids) + 1 + i for i in range(max_label_len)]
token_type_ids = [0] * len(input_ids)
# attention_mask 必须按新序列重算,沿用 tokenizer 那份会把伪 token 当 padding
attention_mask = np.where(np.array(input_ids) > 0, 1, 0).tolist()
tokenized_output['input_ids'].append(input_ids)
tokenized_output['token_type_ids'].append(token_type_ids)
tokenized_output['attention_mask'].append(attention_mask)
tokenized_output['mask_positions'].append(mask_positions)
if train_mode:
mask_labels = tokenizer(text=label)['input_ids'][1:-1] # 去掉 [CLS] [SEP]
mask_labels = mask_labels[:max_label_len]
mask_labels += [tokenizer.pad_token_id] * (max_label_len - len(mask_labels))
tokenized_output['mask_labels'].append(mask_labels)
for k, v in tokenized_output.items():
if not v:
continue
if return_tensor:
import torch
tokenized_output[k] = torch.LongTensor(v)
else:
tokenized_output[k] = np.array(v)
return {k: v for k, v in tokenized_output.items() if len(v)}
if __name__ == '__main__':
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained('./bert-base-chinese')
out = convert_example(
{'text': ['电脑\t这个笔记本电脑散热做得不错,风扇噪音也小']},
tokenizer, max_seq_len=32, max_label_len=2, p_embedding_num=6)
print('input_ids :', out['input_ids'][0][:14], '...')
print('前 14 个token:', tokenizer.convert_ids_to_tokens(out['input_ids'][0][:14]))
print('mask 位置 :', out['mask_positions'][0])
print('标签 ids :', out['mask_labels'][0])
data_preprocess.py 里有 hard_template 参数,这里没有;上一个里 mask 下标由模板解析给出,这里由公式算出。除此之外两个函数的签名、返回字段、字段形状完全相同——这是它们能共用同一套 DataLoader 和训练循环的原因。
04完整案例:同一份数据跑第二条路线
改两个文件、删一个文件,评价分流器就从 PET 切到了 P-Tuning
项目结构的差异
把 P-Tuning 版和 PET 版的目录并排放,差异只有三处:
| 文件 | PET 版 | P-Tuning 版 |
|---|---|---|
data/prompt.txt | 必需 | 删掉,模板不再是文字 |
data/verbalizer.txt | 必需 | 原样复用 |
data/train.txt / dev.txt | 必需 | 原样复用 |
| 配置类 | 有 prompt_file | 换成 p_embedding_num |
hard_template.py | 有 | 删掉 |
| 编码函数 | 套模板 | 插伪 token |
| 训练 / 评估 / 映射 / 推理 | — | 完全一致 |
复用的那份标签映射长这样,十个类别、水果一类挂了五个子标签:
电脑 电脑
水果 水果,苹果,香蕉,榴莲,西瓜
平板 平板
衣服 衣服
酒店 酒店
洗浴 洗浴
书籍 书籍
蒙牛 蒙牛
手机 手机
电器 电器

配置
# coding:utf-8
"""P-Tuning 路线的配置。与 PET 路线只差两处,其余照搬。
差别一:多了 p_embedding_num —— 伪 token 的个数。
差别二:没有 prompt_file —— 模板不再是人写的文字,没有文件可读。
"""
import os
import torch
class ProjectConfig(object):
def __init__(self):
self.device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
self.root = os.environ.get(
'PTUNE_PROJECT_ROOT',
os.path.dirname(os.path.abspath(__file__)),
)
self.pre_model = os.path.join(self.root, 'bert-base-chinese')
# 只要三个数据文件:train / dev / verbalizer。模板没有文件。
self.train_path = os.path.join(self.root, 'data', 'train.txt')
self.dev_path = os.path.join(self.root, 'data', 'dev.txt')
self.verbalizer = os.path.join(self.root, 'data', 'verbalizer.txt')
# 伪 token 个数:拼在句首的 [unused1] ~ [unused6]。
# 调大 -> 模板容量更大但更难训、也更占序列长度;调小 -> 表达力不够。
# 这是 P-Tuning 唯一真正需要调的结构超参数。
self.p_embedding_num = 6
self.max_seq_len = 512
self.batch_size = 8
self.learning_rate = 5e-5
self.weight_decay = 0
self.warmup_ratio = 0.06
self.max_label_len = 2
self.epochs = 10
self.logging_steps = 5
self.valid_steps = 20
self.save_dir = os.path.join(self.root, 'checkpoints')
if __name__ == '__main__':
pc = ProjectConfig()
print('伪 token 个数:', pc.p_embedding_num)
print('伪 token 字面:', ['[unused%d]' % (i + 1) for i in range(pc.p_embedding_num)])
print('mask 个数 :', pc.max_label_len)
# 这两个数决定了正文能用多长:总长 - 伪 token - mask - [CLS] - [SEP]
print('正文可用长度 :', pc.max_seq_len - pc.p_embedding_num - pc.max_label_len - 2)
p_embedding_num 是这条路线上唯一真正需要调的结构超参数。它的取舍:
| 取值 | 效果 | 代价 |
|---|---|---|
| 偏小(2~4) | 参数少、收敛快 | 模板容量不足,复杂任务学不动 |
| 本讲取 6 | 十分类够用 | — |
| 偏大(16~32) | 表达力更强 | 更难训、更占序列长度,小数据集上容易过拟合 |
| 超过 99 | — | 词表空位用尽,会撞上真实 token |
小数据集上不建议一上来就调大。6 个向量、每个 768 维,对 63 条训练样本来说已经不算少了。
编码与 DataLoader
# -*- coding:utf-8 -*-
"""P-Tuning 的 DataLoader。与 PET 版几乎一致,只是 partial 里换了参数。"""
from functools import partial
from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import AutoTokenizer, default_data_collator
from data_preprocess import convert_example
from ptune_config import ProjectConfig
pc = ProjectConfig()
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
def get_data():
dataset = load_dataset('text', data_files={'train': pc.train_path,
'dev': pc.dev_path})
# 注意这里没有 hard_template:模板不再是文字,而是词表里的 6 个伪 token
new_func = partial(convert_example,
tokenizer=tokenizer,
max_seq_len=pc.max_seq_len,
max_label_len=pc.max_label_len,
p_embedding_num=pc.p_embedding_num)
dataset = dataset.map(new_func, batched=True)
train_dataset = dataset['train']
dev_dataset = dataset['dev']
train_dataloader = DataLoader(train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=pc.batch_size)
dev_dataloader = DataLoader(dev_dataset,
collate_fn=default_data_collator,
batch_size=pc.batch_size)
return train_dataloader, dev_dataloader
if __name__ == '__main__':
train_dataloader, dev_dataloader = get_data()
print('train batch 数:', len(train_dataloader))
print('dev batch 数:', len(dev_dataloader))
for batch in train_dataloader:
print({k: tuple(v.shape) for k, v in batch.items()})
break
partial() 里少了 hard_template,多了 p_embedding_num。别的一模一样,连 default_data_collator 都没换。
训练脚本:对照着看更有价值
# -*- coding:utf-8 -*-
"""P-Tuning 训练主脚本。
⚠️ 与 PET 的 train.py 对照着看:训练循环、优化器、调度器、存盘逻辑
一行都没改。变的只有 import 的 data_loader 和 config——
这就是 P-Tuning 工程上最大的优点:换模板方式不用重写训练代码。
"""
import os
import time
import torch
from tqdm import tqdm
from transformers import AutoModelForMaskedLM, AutoTokenizer, get_scheduler
from data_loader import get_data
from ptune_config import ProjectConfig
from utils.common_utils import convert_logits_to_ids, mlm_loss
from utils.metirc_utils import ClassEvaluator
from utils.verbalizer import Verbalizer
pc = ProjectConfig()
def evaluate_model(model, metric, data_loader, tokenizer, verbalizer):
model.eval()
metric.reset()
with torch.no_grad():
for batch in tqdm(data_loader, desc='eval'):
logits = model(input_ids=batch['input_ids'].to(pc.device),
token_type_ids=batch['token_type_ids'].to(pc.device),
attention_mask=batch['attention_mask'].to(pc.device)).logits
mask_labels = batch['mask_labels'].numpy().tolist()
for i in range(len(mask_labels)):
while tokenizer.pad_token_id in mask_labels[i]:
mask_labels[i].remove(tokenizer.pad_token_id)
mask_labels = [''.join(tokenizer.convert_ids_to_tokens(t)) for t in mask_labels]
predictions = convert_logits_to_ids(
logits, batch['mask_positions']).cpu().numpy().tolist()
predictions = verbalizer.batch_find_main_label(predictions)
predictions = [ele['label'] for ele in predictions]
metric.add_batch(pred_batch=predictions, gold_batch=mask_labels)
eval_metric = metric.compute()
model.train()
return (eval_metric['accuracy'], eval_metric['precision'],
eval_metric['recall'], eval_metric['f1'], eval_metric['class_metrics'])
def model2train():
model = AutoModelForMaskedLM.from_pretrained(pc.pre_model)
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
verbalizer = Verbalizer(verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=pc.max_label_len)
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped_parameters = [
{'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
'weight_decay': pc.weight_decay},
{'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
'weight_decay': 0.0},
]
optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=pc.learning_rate)
model.to(pc.device)
train_dataloader, dev_dataloader = get_data()
num_update_steps_per_epoch = len(train_dataloader)
max_train_steps = pc.epochs * num_update_steps_per_epoch
warm_steps = int(pc.warmup_ratio * max_train_steps)
lr_scheduler = get_scheduler(name='linear',
optimizer=optimizer,
num_warmup_steps=warm_steps,
num_training_steps=max_train_steps)
loss_list = []
tic_train = time.time()
metric = ClassEvaluator()
criterion = torch.nn.CrossEntropyLoss()
global_step, best_f1 = 0, 0
for epoch in range(pc.epochs):
for batch in tqdm(train_dataloader, desc='epoch %d' % epoch):
logits = model(input_ids=batch['input_ids'].to(pc.device),
token_type_ids=batch['token_type_ids'].to(pc.device),
attention_mask=batch['attention_mask'].to(pc.device)).logits
mask_labels = batch['mask_labels'].numpy().tolist()
sub_labels = verbalizer.batch_find_sub_labels(mask_labels)
sub_labels = [ele['token_ids'] for ele in sub_labels]
loss = mlm_loss(logits,
batch['mask_positions'].to(pc.device),
sub_labels,
criterion,
pc.device,
1.0)
loss_list.append(float(loss.cpu().detach()))
global_step += 1
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
if global_step % pc.logging_steps == 0:
time_diff = time.time() - tic_train
print('global step %d, epoch: %d, loss: %.5f, speed: %.2f step/s'
% (global_step, epoch, sum(loss_list) / len(loss_list),
pc.logging_steps / time_diff))
tic_train = time.time()
if global_step % pc.valid_steps == 0:
acc, precision, recall, f1, class_metrics = evaluate_model(
model, metric, dev_dataloader, tokenizer, verbalizer)
print('Evaluation precision: %.5f, recall: %.5f, F1: %.5f'
% (precision, recall, f1))
if f1 > best_f1:
print('best F1 updated: %.5f --> %.5f' % (best_f1, f1))
best_f1 = f1
cur_save_dir = os.path.join(pc.save_dir, 'model_best')
os.makedirs(cur_save_dir, exist_ok=True)
model.save_pretrained(cur_save_dir)
tokenizer.save_pretrained(cur_save_dir)
tic_train = time.time()
print('训练结束,最好 F1: %.5f' % best_f1)
if __name__ == '__main__':
model2train()
把这个文件和上一页的 train.py 并排打开,从 for epoch 那一行往下看到结尾——一个字都没变。改的只有顶部的 import 和配置类。
这不是巧合,是设计出来的结果:编码函数的输出字段名、字段形状、语义全部对齐,下游就感知不到上游换了实现。工程上想让两个方案能公平对照,这是前提——否则指标差异里混着实现差异,比不出东西来。
推理
# -*- coding:utf-8 -*-
"""P-Tuning 推理。
⚠️ 最要命的一条:p_embedding_num 必须与训练时一模一样。
训练用 6、推理用 8,不会报错,只会让 mask 位置整体后移两格,
模型在正文的字上取答案,准确率断崖式下降却查不出原因。
"""
import os
import time
from typing import List
import torch
from transformers import AutoModelForMaskedLM, AutoTokenizer
from data_preprocess import convert_example
from ptune_config import ProjectConfig
from utils.common_utils import convert_logits_to_ids
from utils.verbalizer import Verbalizer
pc = ProjectConfig()
model_path = os.path.join(pc.save_dir, 'model_best')
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForMaskedLM.from_pretrained(model_path)
model.to(pc.device).eval()
verbalizer = Verbalizer(verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=pc.max_label_len)
def inference(contents: List[str]):
with torch.no_grad():
start_time = time.time()
tokenized_output = convert_example(
{'text': contents},
tokenizer,
max_seq_len=128,
max_label_len=pc.max_label_len,
p_embedding_num=pc.p_embedding_num, # 必须和训练一致
train_mode=False,
return_tensor=True,
)
logits = model(input_ids=tokenized_output['input_ids'].to(pc.device),
token_type_ids=tokenized_output['token_type_ids'].to(pc.device),
attention_mask=tokenized_output['attention_mask'].to(pc.device)).logits
predictions = convert_logits_to_ids(
logits, tokenized_output['mask_positions']).cpu().numpy().tolist()
predictions = verbalizer.batch_find_main_label(predictions, hard_mapping=False)
predictions = [ele['label'] for ele in predictions]
print('用时 %.3fs' % (time.time() - start_time))
return predictions
if __name__ == '__main__':
contents = [
'天台很好看,躺在躺椅上很悠闲,适合一家出行,下次有机会肯定还会再来的',
'这个笔记本的键盘手感一般,但屏幕素质不错,办公够用了',
'榴莲闻着臭吃着香,这次买的很新鲜,果肉厚实',
]
for content, label in zip(contents, inference(contents)):
print('%s -> %s' % (label, content[:22]))
p_embedding_num 训练用 6、推理写成 8,不会报错。mask 位置整体后移两格,模型在正文的字上取答案,准确率崩掉而日志里干干净净。把它写进配置类、两边都从配置读,是唯一可靠的防法。
两条路线怎么比
比对要控制变量,否则结论不可信。可控的做法:
- 同一份 train/dev 切分,不要各自随机切。
- 同一份 verbalizer,子标签一个不差。
- 同样的 epochs、batch、lr、随机种子。
- 报同一种平均方式的 F1,并把逐类指标一起贴出来。
- 跑多个随机种子取均值与标准差。小数据集上单次结果的抖动可能比两个方案的差距还大——只跑一次就下结论是最常见的自欺。
05骨架模板
四个 TODO 加一套开训前自检
# -*- coding:utf-8 -*-
"""P-Tuning 骨架模板:换任务时只改这四处。
拿它当起点比拿 train.py 快,因为「一定要改的」已经被 TODO 标出来了。
"""
import os
# TODO-1: 底座。必须是带 [unusedN] 占位符的词表,否则换一种挪用方案
PRE_MODEL = os.environ.get('PTUNE_PRE_MODEL', './bert-base-chinese')
# TODO-2: 伪 token 个数。改了它 => 必须重新生成数据、重新训练、推理同步改
P_EMBEDDING_NUM = 6
# TODO-3: 标签最长几个字。标签有三字词就要调到 3,否则被截断
MAX_LABEL_LEN = 2
# TODO-4: verbalizer.txt 路径。主标签 \t 子标签1,子标签2,...
VERBALIZER = './data/verbalizer.txt'
MAX_SEQ_LEN = 512
def check_config(label_texts):
"""开训前的四项自检,任何一项不过就别开始跑。"""
problems = []
# ① 标签长度够不够
too_long = [t for t in label_texts if len(t) > MAX_LABEL_LEN]
if too_long:
problems.append('这些标签会被截断: %s(把 MAX_LABEL_LEN 调到 %d)'
% (too_long, max(len(t) for t in label_texts)))
# ② 截断后有没有撞车:「笔记本」和「笔记」截到 2 字都是「笔记」
truncated = {}
for t in label_texts:
truncated.setdefault(t[:MAX_LABEL_LEN], []).append(t)
clashes = {k: v for k, v in truncated.items() if len(v) > 1}
if clashes:
problems.append('截断后标签撞车: %s' % clashes)
# ③ 伪 token 数量不能超过词表里的空位
if P_EMBEDDING_NUM > 99:
problems.append('P_EMBEDDING_NUM=%d 超过 99 个 [unused] 空位' % P_EMBEDDING_NUM)
# ④ 正文还剩多少位置
usable = MAX_SEQ_LEN - P_EMBEDDING_NUM - MAX_LABEL_LEN - 2
if usable < 64:
problems.append('正文只剩 %d 个 token,长评论会被截没' % usable)
return problems, usable
if __name__ == '__main__':
labels = ['水果', '书籍', '平板', '衣服', '酒店', '洗浴', '母婴', '电器', '笔记本', '蒙牛']
problems, usable = check_config(labels)
print('正文可用长度:', usable)
if problems:
print('自检未通过:')
for p in problems:
print(' -', p)
else:
print('自检通过,可以开训')
| TODO | 填什么 | 填错的后果 |
|---|---|---|
| TODO-1 底座 | 带 [unusedN] 空位的模型 | 词表没有空位就得挪用真实 token,退回老问题 |
| TODO-2 伪 token 数 | 通常 4~8 起步 | 改了必须重新生成数据、重训、推理同步改 |
| TODO-3 标签长度 | 最长类别名的字数 | 短了静默截断,还可能截断撞车 |
| TODO-4 映射表 | verbalizer.txt 路径 | 子标签给太少,模型填的同义词映射不回去 |
骨架里的 check_config() 把四项自检串起来:标签超长、截断撞车、伪 token 越界、正文可用长度不足。跑一次几毫秒,能挡掉的是几小时的无效训练。
从 PET 迁过来的清单
| 步骤 | 动作 | 检查点 |
|---|---|---|
| 1 | 删掉 prompt.txt 与模板类 | 确认没有别处还在 import 它 |
| 2 | 配置里加 p_embedding_num | 训练与推理读的是同一个配置类 |
| 3 | 改写编码函数的拼接段 | 打印一条样本,第 7、8 位必须是 [MASK] |
| 4 | 清掉 datasets 的旧缓存 | 否则用的还是 PET 版的特征 |
| 5 | 训练脚本只改 import | 训练循环本体不要动,动了就没法公平对照 |
datasets 的 map() 会按「函数字节码 + 参数」做缓存指纹。改了拼接逻辑通常能触发重算,但只改一个数字参数时不一定。稳妥做法是换一个缓存目录,或显式传 load_from_cache_file=False。
06易错点汇总
按「拼接 / 掩码 / 配置一致性 / 认知」四类归并
⚠️ 一、拼接
- 忘了先摘掉末尾的
[SEP]。 直接在原序列前面插伪 token,[SEP]会卡在正文中间,模型把它当成句子边界,正文后半段的注意力被切断。 - 截断时没给伪 token 腾位置。 拼之前必须按
max_seq_len - mask 数 - 伪 token 数 - 1截断。不腾位置,拼完超长,尾部被砍。 - 把 mask 插在伪 token 前面。 顺序必须是「伪 token →
[CLS]→[MASK]→ 正文」。顺序一变,mask_positions的公式就不对了,而公式是写死的常数,不会跟着变。 - 用
index(mask_token_id)找 mask 位置。 和 PET 一样的老坑:正文里出现「[MASK]」字面时会找错。P-Tuning 里 mask 位置是常数公式,更没有理由去搜索。 - 忘了补回结尾。 摘掉的
[SEP]要补回来;序列里已经有[SEP]时补[PAD]。少这一步,序列长度对不齐,堆 batch 时报错。
⚠️ 二、attention_mask
- 沿用 tokenizer 给的那一份掩码。 最严重的一条:伪 token 被当成 padding 忽略掉,软提示等于没加。训练照跑、损失照降,指标和不加提示一模一样,查不出原因。序列变了,掩码必须按新的
input_ids重算。 > 0判据用在pad_token_id != 0的分词器上。 中文 BERT 的 pad id 恰好是 0 才能这么写。换底座要改成与pad_token_id比较,否则 padding 被当成可见 token。- 只重算了
attention_mask,忘了token_type_ids。 长度对不上会直接报错——这个是好坑,至少会炸。
⚠️ 三、配置一致性
- 训练与推理的
p_embedding_num不一致。 这一页最致命的坑。训练 6、推理 8,mask 位置整体后移两格,模型在正文的字上取答案。不报错,指标断崖。两边必须从同一个配置类读。 - 改了
p_embedding_num却复用了旧缓存。datasets的map()带缓存,特征还是旧的,mask_positions 与实际位置错位。换缓存目录或传load_from_cache_file=False。 p_embedding_num超过 99。 中文 BERT 只有[unused1]~[unused99]这 99 个空位,超了会撞上[UNK]及之后的真实 token,等于挪用有语义的词。- 忘了伪 token 也吃序列长度。 用满 99 个,正文可用长度从 502 掉到 409。长文本任务要把这笔账算进
max_seq_len。
⚠️ 四、认知层面
- 以为 P-Tuning 只训练那几个向量、底座是冻结的。 本讲的实现没有冻结底座,BERT 全部参数照样更新,显存和训练时间与 PET 基本一样。冻结底座只训 prompt 的做法是另一件事(Prompt Tuning),参数高效微调要到 LoRA 那一讲才登场。
- 以为伪 token 学出来的向量能翻译成人话。 它们不对应词表里任何一个词,也没有可读的语义。出了问题只能看指标和样本,不能像硬模板那样直接读出来——这是换来「不用猜措辞」的代价。
- 用「无脑调大
p_embedding_num」来提指标。 6 个 768 维向量对 63 条训练样本已经不算少。调大更容易过拟合,还挤占正文长度。 - 跑一次就下「P-Tuning 比 PET 好」的结论。 小数据集上单次结果的抖动可能比两个方案的真实差距还大。要固定切分、固定种子、跑多个种子取均值与标准差,再报结论。
07自测题
点击题目展开答案;这 9 题过了,两条路线的差异就吃透了
P-Tuning 相对 PET 换掉了什么?没换什么?
换掉的只有模板的形态:从人写的自然语言,变成几个可学习的伪 token 向量。没换的是:损失函数、训练循环、Verbalizer、评估器、存盘策略——全部一致,代码一行不用改。
为什么挑 [unused1] 这类 token 当伪 token,而不是「的」「了」?
[unusedN] 在预训练语料里几乎没出现过,embedding 近乎未被写过的空白,梯度可以自由塑形。高频字的向量已承载明确语义并被模型其他部分依赖,挪用它要先把原语义抹掉,收敛更慢更不稳,还可能连累别处表现。
P-Tuning 是「参数高效微调」吗?
本讲的实现不是。它确实新增了 6 × 768 = 4608 个可训练参数,但没有冻结底座,BERT 全部参数照样更新,显存和训练时间与 PET 相当。冻结底座只训 prompt 向量的做法是 Prompt Tuning;真正的参数高效微调要到 LoRA 才登场。
写出四步拼接的顺序,并说明第一步为什么要摘掉末尾 token。
① 摘掉末尾的 [SEP];② 按 max_seq_len - mask 数 - 伪 token 数 - 1 截断腾位置;③ 拼成「伪 token + [CLS] + [MASK]×n + 正文」;④ 补回 [SEP] 或 [PAD],重算掩码。不摘尾,[SEP] 会卡在正文中间,模型把它当句子边界,切断后半段注意力。
p_embedding_num=6、max_label_len=2 时,mask_positions 是多少?为什么它是常数?
固定是 [7, 8]。6 个伪 token 占 0~5,[CLS] 占 6,两个 mask 占 7、8。之所以是常数,因为伪 token 排在最前面、mask 紧跟 [CLS],位置与正文长度无关。公式是 p_embedding_num + 1 + i。
沿用 tokenizer 原来的 attention_mask 会发生什么?
伪 token 会被当成 padding 屏蔽掉,软提示等于没加。训练照跑、损失照降,最终指标和不加提示一样。而且长度也对不上,正文尾部和 [SEP] 可能一起被错误屏蔽。必须按新的 input_ids 重算。
训练用 p_embedding_num=6、推理写成 8,会怎样?
不会报错。 mask 位置整体后移两格([7,8] → [9,10]),模型在正文的字上取答案,准确率崩掉而日志里什么异常都没有。防法是两边都从同一个配置类读这个值。
改了 p_embedding_num 之后,除了改配置还必须做什么?
重新生成数据。 datasets 的 map() 带缓存,只改一个数字参数不一定触发重算,旧特征里的 mask_positions 还是老值,与实际 [MASK] 位置错位。换缓存目录或传 load_from_cache_file=False。
要公平对比 PET 与 P-Tuning,需要控制哪些变量?
同一份 train/dev 切分、同一份 verbalizer、同样的 epochs/batch/lr/随机种子、同一种平均方式的指标(并附逐类指标),并且跑多个随机种子取均值与标准差。小数据集上单次结果的抖动可能比两个方案的真实差距还大,只跑一次就下结论不可信。
附术语表
| 术语 | 英文原形 | 含义 |
|---|---|---|
| 软模板 / 软提示 | soft prompt | 由梯度学出来的提示向量,没有字面意义,不对应词表里任何词 |
| 硬模板 | hard template | 人写的自然语言模板,每个字都有字面意义,可读可排查 |
| 伪 token | pseudo token | 被挪用来承载软提示的占位 token,本讲用 [unused1]~[unused6] |
| 伪 token 个数 | p_embedding_num | 这条路线唯一的结构超参数;改了必须重新生成数据并同步推理配置 |
| 词表空位 | [unusedN] | 中文 BERT 词表里 id 1~99 的未使用占位符,共 99 个 |
| 嵌入层 | embedding layer | 把 token id 查成向量的查找表;它本身也是参数,会被梯度更新 |
| 注意力掩码 | attention_mask | 标记哪些位置可见;序列被改写后必须重算,否则伪 token 被当成 padding |
| 提示微调 | Prompt Tuning | 冻结底座、只训 prompt 向量的做法;与本讲不冻结底座的实现不是一回事 |
| 特征缓存 | dataset cache | datasets.map() 的结果缓存;改了编码参数要清掉,否则用的还是旧特征 |