【案例】新零售评价决策系统 · P-Tuning 软模板

别再猜哪句话问得好——把模板交给梯度自己学:六个伪 token 的拼接、掩码重算,以及与 PET 逐行对照的迁移清单。

30″30 秒看懂 P-Tuning

前两页里,那位只会填空的老师傅一直在做我们写好的题:「这是一条 __ 评论:……」。题干是人写的——人觉得这么问最顺口。

问题来了:人觉得顺口,不等于模型觉得顺口。把「这是一条」换成「以下属于」,准确率可能差好几个点,而你事先根本不知道哪句更好,只能一句句试。P-Tuning 的想法是:别猜了,让模型自己把题干学出来。

做法是在句子最前面插几个空白占位符——词表里那些从来没被用过的 [unused1] ~ [unused6]。它们没有字面意思,就是六个可以被梯度自由塑形的向量。训练开始后,模型会把「怎么问这道题最容易答对」写进这六个向量里。

图① 30 秒看懂:模板从人写的字换成模型自己学的向量
图① 30 秒看懂:模板从人写的字换成模型自己学的向量
比喻里的角色对应的技术概念它到底干了什么
人写的题干硬模板 hard templatePET 的做法:一句自然语言,每个字都有字面意义
六个空白占位符[unused1] ~ [unused6]预训练里几乎没被更新过的 token,等于一块干净画布
模型自己学出的题干软模板 soft prompt六个 embedding 向量,没有字面意思,只有数值
占位符的个数p_embedding_num本讲取 6;调大容量更足但更难训,还更占序列长度
答题的空格[MASK]和 PET 完全一样,位置固定在 [CLS] 之后
对答案的词典Verbalizer和 PET 共用同一份,一个字都不用改
⛔ 这一页的铁律 P-Tuning 换掉的只有模板的形态——从人写的字变成模型学的向量。损失函数、训练循环、评估器、Verbalizer、存盘策略,与 PET 完全一致,一行都不用改。所以这一页的重点全在「怎么把伪 token 拼进序列」这一件事上。

01概念

软模板是什么、它为什么能学出来、和 PET 怎么选

硬模板的三个真实麻烦

  • 模板敏感。 同一份数据、同一个模型,换一句措辞,指标能差好几个点。这个差异没有规律可循,只能穷举。
  • 类别抽象时写不出来。 「水果 / 酒店」这种有直白名字的类别好办,遇到「意图 A / 意图 B」这种业务内部编号,硬模板根本没法组句——你要模型在 mask 位置填什么字?
  • 换语言、换领域要重写。 模板是自然语言,跨语言完全不能复用。

软模板怎么学出来

BERT 的第一层是 embedding 层,它把每个 token id 查成一个 768 维向量。这张查找表也是参数,一样会被梯度更新。

P-Tuning 利用的就是这一点:在输入序列最前面插入 6 个 token id,这 6 个 id 对应的 embedding 向量随着训练一起被优化。因为选的是 [unused1]~[unused6] 这种预训练阶段几乎没出现过的 token,它们的向量近乎「未被写过的空白」,梯度可以自由塑形,不必先把原有语义抹掉。

为什么不用「的」「了」这种高频字 高频字的 embedding 已经承载了明确语义,被整个模型的其他部分依赖着。挪用它当伪 token,梯度要先跟原有语义打一架,收敛更慢也更不稳,还可能连累模型在别处的表现。
unused_token_probe.py —— 词表空位与序列长度预算可直接运行
# -*- 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 + PETBERT + P-Tuning
模板形态人工写的自然语言6 个可学习的伪 token
新增可训练参数6 个 embedding 向量(6 × 768 = 4608 个数)
模板敏感性高,换一句话指标就抖低,梯度自己找
需要的额外文件prompt.txtverbalizer.txt只要 verbalizer.txt
mask 位置随模板长度变化,要跟着算固定p_embedding_num + 1
可解释性模板能读,出问题看得懂向量不可读,只能看指标
适合场景类别语义直白、能想出好模板模板不好写、类别抽象

注意「新增可训练参数」这一行:P-Tuning 确实新增了参数,所以严格讲它不像 PET 那样「零新增」。但 4608 个数相对于 BERT 的一亿参数可以忽略不计,而且本讲的实现里 BERT 全部参数依然参与训练——伪 token 的 embedding 只是搭了个便车,不是唯一被更新的东西。

别把它和「只训练 prompt」混为一谈 有一类做法(Prompt Tuning)会冻结整个底座、只训那几个 prompt 向量,那才是真正的参数高效微调。本讲的 P-Tuning 实现没有冻结底座,显存和训练时间与 PET 基本一样。冻结底座的思路要到下一个案例的 LoRA 才登场。

怎么选

工程上的实际建议:先写硬模板跑一版做基线,因为它可解释、好排查。指标不理想且怀疑是模板不够好时,再换 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 的下标可以写成一个与正文无关的常数。

splice_demo.py —— 四步拼接的复刻与断言可直接运行
# -*- 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],不是就说明拼接写错了。

改了 p_embedding_num 就必须重新生成数据 mask 的下标跟着伪 token 个数走。把 6 改成 8 而复用了旧的缓存特征,mask_positions 还是 [7, 8],而真正的 [MASK] 已经挪到了 [9, 10]。损失算在了正文的字上,不报错,指标断崖。datasetsmap() 默认带缓存,这个坑很容易踩到。

attention_mask 必须重算

tokenizer 返回的 attention_mask 是针对原始序列的。拼进 6 个伪 token 和 2 个 mask 之后,长度变了、内容也变了。沿用旧的那一份,最直接的后果是:模型把伪 token 当成 padding 忽略掉,软提示等于没加——训练照跑,指标和不加提示一个样。

attention_mask_demo.py —— 沿用旧掩码的错误对照可直接运行
# -*- 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占用正文还剩
66 + 2 mask + CLS + SEP502 个 token
3232 + 2 + 2476 个 token
99(词表空位上限)99 + 2 + 2409 个 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 也能把拼接逻辑验证清楚。

① tokenizer 编码正文拿到原始 input_ids
② 摘尾 + 截断腾出 9 个位置
③ 四段拼接伪token+CLS+mask+正文
④ 补回 SEP/PAD结尾收口
⑤ 算 mask 下标公式,不是搜索
⑥ 重算掩码attention_mask
splice_demo.py —— 纯标准库复刻四步拼接可直接运行
# -*- 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_idsmask_positions,并逐条断言:前 6 个是伪 token、第 7 个是 [CLS]、第 8-9 个是 [MASK]这三条断言就是这一页的全部技术内容。

接上真实的分词器之后,就是项目里的编码函数:

data_preprocess.py —— P-Tuning 的样本编码核心逻辑
# -*- 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])
对照着 PET 版看,差异一目了然 上一个案例的 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
训练 / 评估 / 映射 / 推理完全一致

复用的那份标签映射长这样,十个类别、水果一类挂了五个子标签:

verbalizer.txt —— 与 PET 版共用的同一份映射表
电脑	电脑
水果	水果,苹果,香蕉,榴莲,西瓜
平板	平板
衣服	衣服
酒店	酒店
洗浴	洗浴
书籍	书籍
蒙牛	蒙牛
手机	手机
电器	电器
图③ 同一份数据上 PET 与 P-Tuning 的逐项对照
图③ 同一份数据上 PET 与 P-Tuning 的逐项对照

配置

ptune_config.py —— 只比 PET 版多一个结构超参配置
# 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

data_loader.py —— 与 PET 版的唯一差别在 partial 的参数核心逻辑
# -*- 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 都没换。

训练脚本:对照着看更有价值

train.py —— 与 PET 版逐行对照,训练循环零改动核心逻辑
# -*- 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 和配置类。

这不是巧合,是设计出来的结果:编码函数的输出字段名、字段形状、语义全部对齐,下游就感知不到上游换了实现。工程上想让两个方案能公平对照,这是前提——否则指标差异里混着实现差异,比不出东西来

推理

inference.py —— p_embedding_num 必须与训练一致核心逻辑
# -*- 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,并把逐类指标一起贴出来。
  • 多个随机种子取均值与标准差。小数据集上单次结果的抖动可能比两个方案的差距还大——只跑一次就下结论是最常见的自欺。
本讲没有实跑训练 这两条路线的代码都完整给出并通过了语法检查,但没有在 GPU 上实跑,因此不提供任何指标数字。要对照就按上面五条自己跑一遍——别人给的数字换一份数据就不成立,自己那份才算数。

05骨架模板

四个 TODO 加一套开训前自检

skeleton_ptune.py —— P-Tuning 骨架,填 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训练循环本体不要动,动了就没法公平对照
第 4 步最容易漏 datasetsmap() 会按「函数字节码 + 参数」做缓存指纹。改了拼接逻辑通常能触发重算,但只改一个数字参数时不一定。稳妥做法是换一个缓存目录,或显式传 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 却复用了旧缓存。 datasetsmap() 带缓存,特征还是旧的,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=6max_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 之后,除了改配置还必须做什么?

重新生成数据。 datasetsmap() 带缓存,只改一个数字参数不一定触发重算,旧特征里的 mask_positions 还是老值,与实际 [MASK] 位置错位。换缓存目录或传 load_from_cache_file=False

要公平对比 PET 与 P-Tuning,需要控制哪些变量?

同一份 train/dev 切分、同一份 verbalizer、同样的 epochs/batch/lr/随机种子、同一种平均方式的指标(并附逐类指标),并且跑多个随机种子取均值与标准差。小数据集上单次结果的抖动可能比两个方案的真实差距还大,只跑一次就下结论不可信。

术语表

术语英文原形含义
软模板 / 软提示soft prompt由梯度学出来的提示向量,没有字面意义,不对应词表里任何词
硬模板hard template人写的自然语言模板,每个字都有字面意义,可读可排查
伪 tokenpseudo 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 cachedatasets.map() 的结果缓存;改了编码参数要清掉,否则用的还是旧特征