同一个提示词,每次答案都不同
一句话总结
模型给出的不是答案,而是一组 logits,答案是在把 logits 转成概率再抽取一个的过程中确定的。温度、top-k、top-p 是在抽取之前修改这个概率分布的三个旋钮。
为什么需要它
第一次接上模型,会出现两件怪事。一件是同一个提示词输入两次,答案却不同;另一件是把答案生成得长一点,会一直重复同样的话直到结束。
两者都出在最后一步。Transformer 块所做的事,到给出一个与词表一样大的实数列表为止。这个列表叫 logits,它不是概率,而是没有归一化的分数。从中挑出实际要用的令牌(token),是模型之外的规则,规则是什么,同一个模型就会说出完全不同的话。
最简单的规则是选最大的 logit。叫 greedy。结果总是相同,便于测试,但生成得长了,容易陷入回到同一句话的循环。The Curious Case of Neural Text Degeneration 整理的正是这种现象——只沿着概率最高的路走,就会得到与人写的文章不相像、明显重复的文字。
于是要抽取。按概率的比例挑一个,重复就会减少。但这次问题出在尾部。词表有几万个时,概率是 0.00001 的令牌就有几万个,这整条尾部的总和不可忽视。哪怕只有一次从中被抽中,句子就会当场崩溃。三个旋钮全都是处理这种张力——重复与崩溃之间——的工具。
温度的要点在于除的位置
温度 T 是在输入 softmax 之前去除 logits 的值。不是先求出概率再去动它。
scaled = [value / T for value in logits]
top = max(scaled) # 빼는 것도 장식이 아니다
weights = [math.exp(value - top) for value in scaled]
probs = [w / sum(weights) for w in weights]
除法是怎么起作用的,用间隔来看就容易懂。假设第一名和第二名的 logit 之差是 1.1。T 为 0.25 时,这个差拉开到 4.4,取指数之后,第二名的份额缩小到第一名的 1% 左右。T 为 4 时,差缩小到 0.275,两者几乎一样。T 小则变尖,大则变平。T 为 1 就是原样使用 logits。
减去最大值的位置也不只是习惯。把 T 设成 0.01,logit 5.2 就变成 520,math.exp(520) 会直接溢出。意思是温度会制造出很大的输入。减去最大值之后,指数的自变量降到 0 以下,不会再有溢出的地方,而且是从同样的值里减去同一个数,概率不会改变。
有一点要钉死。不存在 T 为 0 的 softmax。除法做不了。“温度设为 0 就是 greedy”这句话,是把 T 趋向 0 时的极限简略说出来了,实际实现会在那个位置改用 greedy。负数更糟——排名会颠倒,最不可能的令牌成了第一名。
想用一个数字来看分散程度,就用熵。以 2 为底的熵,在概率集中于一处时接近 0,均匀分散到 n 个时是 log2(n)。提高温度,这个值会变大。
top-k 和 top-p 是截断尾部的两种方法
top-k 很简单。只保留概率最大的 k 个,其余丢掉,再把剩下的重新归一化。平局怎么打破,必须事先确定。本实验钉死为编号小的一方保留。
固定 k 的弱点是不看分布的形状。在很有把握的位置,只要第一名就够了,却还是保留 k 个;在岔路口,k 个又不够。
top-p,又名 nucleus sampling 不按个数,而是按质量来截断。把概率按降序排开并计算累积和,一直保留到累积和第一次达到 p 的那一项为止。这里有一个容易搞混的地方。如果把那一项去掉,剩下的总和就达不到 p。所以是“到越过的那一项为止”,而不是“到越过之前为止”。这样保留的个数会随分布自动变化——在尖的位置是一个,在平的位置是好几个。
两者一起用时,顺序会改变结果。先作用温度,分布本身就变了,所以即使 p 相同,保留的个数也不同。先做 top-k,幸存下来的会被重新归一化,概率变大,用变大的值计算累积和,top-p 就会更早停下。所以用什么顺序应用,是必须确定下来并写进文档的值。Hugging Face 的生成策略文档也是把这些旋钮当作一组配置来处理,而不是分开的。
同一个种子给出同一个序列
分布都改好之后,抽取一个。逆累积分布是标准的方法——抽一个大于等于 0、小于 1 的实数 u,按编号顺序累加概率,挑选第一次超过 u 的位置。概率恰好为 0 的位置不会增加累积和,所以绝对不会被抽中。意思是被截掉的令牌不会复活。
这里引出一个重要的性质。把提供 u 的随机数生成器的种子固定,同样的输入就会得到同样的序列。是随机的,却可以复现。要重现故障,必须有这个。
在现场相遇的样子
第一,无法复现“偶尔会出现奇怪答案”的反馈。因为没有留下种子。每个请求都把种子和温度、k、p 写进日志,就能把那一个请求原样重现。
第二,想提高温度获得多样性,却丢了准确率。在分类或抽取这类答案唯一的任务里,没有提高温度的理由。旋钮要按任务分别设定。
第三,同样的配置,换了库结果就不同。是应用顺序不同,或者平局处理不同,或者是否包含达到 p 的那一项不同。这三点在文档里都很少写明。
第四,缓存莫名其妙地命中。只把提示词当作键,而漏掉了温度、k、p、种子,配置不同的请求就会拿到同样的答案。
第五,评测分数抖动,抓不住回归。评测时关掉抽取、固定为 greedy 会更好。因为想测的是模型的变化,而不是随机数的变化。
实际工作中真正重要的事
- 配置不是一个值,而是一组。温度、k、p、种子以及应用顺序是一套,其中只要有一个不同,就是另一个系统。
- greedy 不是温度 0。把它看作另一条规则,在代码里也分成不同的分支,会更安全。
- 判定要靠分布。光凭抽中的一个结果,无法知道实现对不对。要对照概率向量,或者固定种子对照编号序列。
- 被截掉的令牌不能复活。这一点一旦破裂,用安全装置拦下的令牌,就会以很低的概率漏出来。
下一项实验要做什么
一步步扩展 /root/work/tf-sample/sample.py。只使用标准库 math 和 random——这个 Pod 的系统 Python 里没有 numpy,只在 /opt/onnx-lab/bin/python 里才有。也不调用模型。自己写出一组 logits,之后的全部亲手来做。
从用温度相除之后的稳定 softmax 开始,做出熵、top-k、top-p、按规定顺序把四者连起来的函数、逆累积分布抽取、greedy 与温度扫描。最后用数字留下:用同一个种子抽两次,是否得到同样的序列;把温度调低的一侧是否与 greedy 相同。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的 logits 和不同的温度检验函数,并与它另外计算的概率向量对照。它不会判定随机抽取本身——只看固定了种子的编号序列和概率向量。这样,正确的实现就不会偶然失败,错误的实现也不会偶然通过。