2017年8月23日 · 8分钟阅读
在预训练模型微调过程中,灾难性遗忘始终是一个经典难题:你费尽心思在特定任务上把模型调整到位,结果回过头来,它却忘了之前学到的通用语言能力。听起来有些讽刺,对吧?但实际上,这个问题并非那么神秘。核心解决方案其实相当直接:利用原始模型先对一批样本进行预测,然后将这些“旧知识”与微调数据混合起来共同训练。这种方法被称为“伪排练”。
先来了解一下背景。当你依次优化两个学习任务,并且用第一个任务的权重初始化第二个任务时,灾难性遗忘就会出现。学术界一直在探索那些对初始化不敏感的优化算法——理想情况下,优化器应当足够强大,无论权重如何初始化,都能找到给定问题的最优解。然而现实远非如此。换句话说,如果你连续优化两个问题,遗忘实际上是一个必然会发生的现象。
多任务学习在spaCy v2.0.0a10中的实践
为了帮助用户避开这个陷阱,spaCy v2.0 alpha模型采用了一种“分合结合”的策略:多任务卷积神经网络(CNN)加上每个任务专用的局部CNN。这样一来,每个任务可以独立更新,而不会污染共享组件。
对于spaCy用户而言,灾难性遗忘近期变得尤为突出——因为v2版本的词性标注、命名实体识别、句法依存和句子分割模型全都共享同一个卷积神经网络生成的输入表示。好处是模型体积大幅降低(最新版本仅18MB,而之前的线性模型接近1GB),坏处是任何微调都可能破坏全局性能。
好在多任务输入表示可以通过doc.tensor属性开放给文本分类、语义相似度等下游任务。但共享权重也埋下了一个隐患:假设你有一批短命令需要解析,而默认的spaCy模型在这些输入上表现欠佳——例如,它总是把第一个单词“search”分析成名词,而实际上它应该是动词。你手头有大量示例表明句首是祈使动词,于是你决定用这些数据更新模型。
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp(u"search for pictures of playful rodents")
spacy.displacy.serve(doc)
解析结果不正确。你可以仅用一个标签(比如把第一个单词标注为VBP)来修正模型。只需要向nlp.update()传递一个Doc对象和一个GoldParse对象即可:
from spacy.gold import GoldParse
new_tags = [None] * len(doc)
new_tags[0] = "VBP"
gold = GoldParse(doc, tags=new_tags)
nlp.update(doc, gold, update_shared=True)
None表示那些位置没有监督信号,梯度为零。没有提供依存或实体标签,对应的模型权重就不会更新。然而,所有模型共享同一个输入表示,因此一旦输入表示被更新,所有模型都可能受到影响。为解决这个问题,spaCy v2.0.0a10引入了update_shared标志,默认值为False。
如果只在这个例子上反复更新几次,模型很快就能正确标注它。但仅凭一个例子,模型无法判断你想要的泛化程度——是把所有单词都标注成VBP?还是所有句子的第一个单词?抑或是所有出现“search”的地方?你需要向模型提供更多关于目标解空间的信息,否则学习问题过于自由,结果很可能不是你所期望的。
超越“遗忘”这个隐喻
我们通常说“模型忘记了之前学的东西”。这个比喻虽然直观,但仔细想想有些误导:整体多任务模型一开始“知道”如何为多种英文书面体裁标注实体和做依存解析。然后你专注做了一点修正,结果模型丢掉了更通用的能力。这听起来像AI很笨、很脆弱。但到这一步,隐喻已经帮不上忙了——我们需要更精确地理解背后发生了什么。
当调用nlp.update()时,模型根据当前权重产生分析结果,然后为每个子任务计算梯度,并通过反向传播更新权重。本质上,你是在调整权重,直到分析结果的误差梯度接近零。任何能使损失变为零的权重组合都是稳定的。但稳定并不意味着是你想要的那种稳定。
正则化对嵌入层依然有效
嵌入表定义了一个向量空间,参数值的变化与解的变化之间存在线性关系。在这种情况下,对参数值与初始值之间的偏差施加L2范数惩罚是合理的——它编码了对“不要改动太多”的偏好。但在深层神经网络中,模型权重与预测行为之间的关系往往是非线性的,甚至可能完全混乱。我们真正关心的是输出,而不是参数值。因此,理想的做法是直接对输出行为施加约束,而不是去猜测参数应该长什么样。
伪排练:简单而有效的解法
综合来看,解决灾难性遗忘的思路其实很直接:当你微调模型时,你希望得到一个既在新数据上正确、又尽可能“像”原始模型输出的解。既然原始模型的输出可以无限生成,那么只需要把原始输出和新数据混合起来一起训练即可。这不是新点子,但非常管用。
下面是一个伪排练的Python示例:
revision_data = []
# 将初始模型应用于原始示例。需要试验找到合适的修订文本数量,
# 过滤掉一些数据也可能有帮助。
for doc in nlp.pipe(revision_texts):
tags = [w.tag_ for w in doc]
heads = [w.head.i for w in doc]
deps = [w.dep_ for w in doc]
entities = [(e.start_char, e.end_char, e.label_) for e in doc.ents]
revision_data.append((doc, GoldParse(doc, tags=tags, heads=heads, deps=deps, entities=entities)))
# 现在将先前的行为打乱混入新的微调数据中,一起更新。
# 可以对微调示例做上采样(比如包含5个副本),这样在比例不变的情况下用更多样化的修订数据。
n_epoch = 5
batch_size = 32
for i in range(n_epoch):
examples = revision_data + fine_tune_data
losses = {}
random.shuffle(examples)
for batch in partition_all(batch_size, examples):
docs, golds = zip(*batch)
nlp.update(docs, golds, losses=losses)
关键细节在于:混入的“修订练习”不能由当前正在优化的权重生成。你必须让生成修订材料的模型保持静态,否则模型可能收敛到平凡解(比如直接复制当前输出)。如果数据是流式的,内存里需要保存两个模型副本。更省事的方法是:预先解析一批文本,用这些标注来稳定后续的微调过程。
目前,spaCy将教师模型提供的分析与金标准数据同等对待——例如,原始预测“80%置信度标签是NN”被强制转换为“100%置信度标签是NN”。这还不够理想。更好的做法是使用教师模型返回的分布做监督(比如对数损失),让模型学习“软”目标。
总结
在计算机视觉和自然语言处理中,预训练模型已经非常普遍。图像、视频、文本、音频这类输入都具有丰富的内部结构,可以从海量数据中学习跨任务泛化的能力。微调能够针对性地进行优化,但也会引发灾难性遗忘:你找到了在特定数据上表现出色的解,却丧失了泛化能力。有人建议用正则化惩罚来抑制参数变化,但这种方法只约束了参数空间,而我们真正需要的是输出空间上的约束。伪排练直接做到了这一点:用初始模型预测一批样本,把它们混入微调数据中一起训练,相当于告诉模型“除了新任务,你还得跟以前表现得一样好”。就这么简单。
