亲手实现温度、top-k 与 top-p
目标
从一组 logits 到挑出下一个令牌(token),把分布经过的每一个环节都只用标准库做出来。用温度相除之后的稳定 softmax、熵、top-k、top-p、按规定顺序把四者连起来的函数、逆累积分布抽取,直到 greedy 和温度扫描。最后把用同一个种子抽两次是否得到同样的序列、温度调低的一侧是否与 greedy 相同,留成记录。
为什么重要
Transformer 块所做的事,到给出一个与词表一样大的实数列表为止。实际要用的令牌由模型之外的规则来挑,同一个模型每次说得不同的原因,和每次重复同样话的原因,都在那里。
本实验不调用模型。这个 Pod 里没有 transformers 也没有 torch,numpy 只在 /opt/onnx-lab/bin/python 里,所以在系统 Python 中 import numpy 是不行的。取而代之的是自己写出一组 logits,之后的部分亲手来做。所以这里出现的数字,全都是在你写的 logits 上测出来的。
难的是细节。温度除的位置是在 softmax 之前还是之后、为什么要减去最大值、top-k 的平局怎么打破、top-p 是否包含达到 p 的那一项、温度、top-p、top-k 按什么顺序作用。只要有一处不同,同样的配置就成了另一个系统。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的 logits 和不同的温度检验函数,并与评分器另外计算的概率向量对照。它不判定随机抽取本身——只看固定了种子的编号序列和概率向量。
步骤
- 在 /root/work/tf-sample/sample.py 中创建
VOCAB、LOGITS以及softmax_t(logits, temperature)。先把 logits 除以温度,再减去最大值,然后做 softmax。 - 增加
entropy_bits(probs),用以 2 为底的熵来测量分布的分散程度。概率为 0 的位置按 0 计。 - 增加
top_k_filter(probs, k),只保留概率最大的 k 个,其余恰好设为0.0,再重新归一化。平局时编号小的一方保留。 - 增加
top_p_filter(probs, p),保留到降序累积和第一次达到 p 的那一项为止。如果去掉那一项,和就达不到 p。 - 创建
filtered_probs(logits, temperature, top_k=None, top_p=None),按 温度 → softmax → top-k → top-p 的顺序连起来。为None则跳过那一步。 - 创建
sample_index(probs, u)和sample_sequence(logits, temperature, top_k, top_p, n, seed)。用逆累积分布抽取,并从random.Random(seed)调用 n 次random(),使同一个种子得到同样的序列。 - 创建
greedy(logits)和sweep(logits, temps, p)。greedy是最大 logit 的编号(平局时取小的编号),sweep对每个温度测量(온도, 엔트로피, top-p 가 남기는 개수)(占位符依次为温度、熵、top-p 保留的个数)。 - 用规定的配置测量,并把结果记录到 /root/work/tf-sample/sample_report.json 和 /root/work/tf-sample/sample_report.md 中。
参考
- 执行契约:评分器会把
/root/work/tf-sample/sample.py当作 Python 模块导入,直接使用VOCAB、LOGITS、softmax_t、entropy_bits、top_k_filter、top_p_filter、filtered_probs、sample_index、sample_sequence、greedy、sweep。它不会作为脚本运行,所以可以没有if __name__ == "__main__"。 VOCAB是不少于 12 个互不相同的短字符串的列表。内容自由决定。LOGITS是与VOCAB长度相同的实数列表,且值必须互不相同。它不是概率,而是没有归一化的分数。第一名与第二名之差必须在 0.5 以上,最大值与最小值之差必须在 3.0 以上,这样改变温度时才能看到分布在动。softmax_t(logits, temperature)先把 logits 除以temperature,从除过的值中减去最大值,取指数后归一化。不减去最大值的话,温度小时math.exp会溢出。temperature小于等于 0 时要抛异常。entropy_bits(probs)是-sum(p * log2(p))。p为 0 的位置跳过。均匀分散到 n 个时得到log2(n)。top_k_filter(probs, k)把丢弃的位置恰好设为0.0,只对剩下的归一化,使和为 1。平局时编号小的一方保留。k大于等于列表长度时什么都不丢,只做归一化。k小于 1 时要抛异常。top_p_filter(probs, p)把概率按降序(平局时编号小的在前)排开并计算累积和,包含累积和大于等于 p 的第一项之后停下。其余恰好是0.0,对剩下的归一化。p必须大于 0 且小于等于 1,否则要抛异常。filtered_probs的顺序是 温度 → softmax → top-k → top-p。这个顺序就是本实验的契约,顺序一变,留下的令牌就不同。sample_index(probs, u)按编号顺序累加概率,返回第一次超过u的位置,也就是u < 누적합(韩文,意为“u < 累积和”)第一次为真的位置。u大于等于 0 且小于 1。绝对不返回概率为0.0的位置。sample_sequence(logits, temperature, top_k, top_p, n, seed)只构造一次分布,调用 n 次random.Random(seed)的random(),返回 n 个编号的列表。种子相同,结果也必须相同。sweep(logits, temps, p)对temps中的每个温度,返回(온도, 엔트로피, top-p 가 남기는 개수)(占位符依次为温度、熵、top-p 保留的个数)三元组的列表。个数是经过top_p_filter之后不为 0 的位置数。- 第 8 步的配置是固定的。种子 20260917,抽取次数 24,
top_k为 5,top_p为 0.9。热的一侧温度 1.0,冷的一侧温度 0.2,都从同一个种子抽取。 - 第 8 步的
sweep温度是 0.25、0.5、1.0、2.0、4.0,此时的p是 0.9。nucleus是在温度 1.0 下把 p 换成 0.5、0.8、0.9、0.95 测得的[p, 남은 개수](占位符依次为 p 与剩余个数),topk_mass是在温度 1.0 的原始概率中,对 k 取 1、3、5、10 测得的前 k 个所占的总和[k, 합](占位符依次为 k 与总和)。 sample_report.json的键:vocab_size、greedy_index、greedy_token、top_prob_t1、entropy_t1、sweep、nucleus、topk_mass、seed、draw_count、hot_draws、cold_draws、cold_is_greedy、hot_distinct、cold_distinct、repeat_matches。sample_report.md用## 무엇을 쟀나、## 온도가 분포를 어떻게 바꾸나、## top-k 와 top-p 가 남기는 것、## 같은 시드는 같은 수열을 준다(韩文,依次意为“测量了什么”“温度如何改变分布”“top-k 和 top-p 留下的内容”“同样的种子给出同样的序列”)四节来写。- 这个 Pod 没有互联网。
pip install无法使用,transformers、torch 也没有。numpy 只在/opt/onnx-lab/bin/python里,所以在系统 Python 中import numpy是不行的。math和random就足够了。 - 官方文档:nucleus sampling 原论文 · top-k 原论文 · Hugging Face — Generation strategies · Python — math
- 常见错误:对概率作用温度、省略减去最大值而在低温下溢出、截断之后忘记归一化、在 top-p 中把越过 p 的第一项去掉、不确定平局规则、把 top-p 放在 top-k 之前应用、每次抽取都重新构造分布。
用温度相除后再做 softmax
在 /root/work/tf-sample/sample.py 中放入 VOCAB(不少于 12 个互不相同的短字符串)和 LOGITS(长度相同,值互不相同,第一名与第二名之差 0.5 以上,最大值与最小值之差 3.0 以上),并创建 softmax_t(logits, temperature)。把 logits 除以 temperature 之后,减去最大值,取指数并归一化。temperature 小于等于 0 时要抛异常。
要点在于除的位置。如果先求出概率再作用温度,会得到完全不同的结果。减去最大值在温度小时显出作用——除以 0.01,logit 5.2 就变成 520,math.exp(520) 会直接溢出。因为是从同样的值里减去同一个数,概率不会改变。温度 0 做不了除法,所以抛 ValueError 更好——greedy 不是“温度 0”,而是另一条规则。
用比特来测量分散程度
增加 entropy_bits(probs)。是以 2 为底的熵 -sum(p * log2(p))。概率为 0 的位置按 0 计。均匀分散到 n 个时得到 log2(n)。
math.log2(0) 会抛异常。所以 0 的位置必须跳过,这不是取巧,而是正确的处理——因为 p * log2(p) 在 p 趋向 0 时的极限是 0。提高温度并测量这个值,可以看到它变大。这里是把分布有多分散概括成一个数字的地方。
只保留前 k 个
增加 top_k_filter(probs, k)。只保留概率最大的 k 个,其余恰好设为 0.0,再把剩下的归一化使和为 1。平局时编号小的一方保留。k 大于等于列表长度时什么都不丢,只做归一化,k 小于 1 时要抛异常。
把编号按概率降序排序,把前 k 个放进集合,剩下的只要看这个集合即可。把排序键设为 (-확률, 번호)(韩文,意为“(-概率, 编号)”),平局规则也能写进一行。忘了归一化,和就不是 1,那么后面抽取时会偏向最后一个位置。不能把丢弃的位置设成很小的值——它会以很低的概率复活。
到累积和达到 p 的那一项为止
增加 top_p_filter(probs, p)。把概率按降序(平局时编号小的在前)排开并计算累积和,包含累积和大于等于 p 的第一项之后停下。其余恰好是 0.0,对剩下的归一化。p 小于等于 0 或大于 1 时要抛异常。
包含还是去掉,就是这一步的全部。如果把让累积和越过 p 的那一项去掉,剩下的总和就达不到 p——那样“保留 p 这么多的质量”这句话本身就不成立。累积和达到 p 的那一刻,把那个编号已经放进去之后再停下就行。无论 p 多小,至少会留下一个。
按规定的顺序连起四者
创建 filtered_probs(logits, temperature, top_k=None, top_p=None)。按 温度 → softmax → top-k → top-p 的顺序连起来,为 None 的环节跳过。
顺序会改变结果。先作用温度,分布本身就变了,所以即使 p 相同,保留的个数也不同;先做 top-k,幸存下来的会被重新归一化而使概率变大,所以 top-p 会更早停下。函数四行就够——把前面做的三个函数按这个顺序调用就行。别忘了判断 top_k 或 top_p 是否为 None 的条件。
同一个种子得到同一个序列
创建 sample_index(probs, u) 和 sample_sequence(logits, temperature, top_k, top_p, n, seed)。sample_index 按编号顺序累加概率,返回第一次超过 u 的位置,sample_sequence 只构造一次分布,调用 n 次 random.Random(seed) 的 random(),返回 n 个编号。
就是 u < 누적합(韩文,意为“u < 累积和”)第一次为真的位置。概率为 0.0 的位置不会增加累积和,所以绝对不会被抽中——意思是被截掉的令牌不会复活,这就是这个结构的安全装置。random.Random(seed) 只在函数里创建一次。每次抽取都创建新的生成器,就会得到 n 个同样的编号。分布也只在循环之外构造一次。
greedy 与温度扫描
创建 greedy(logits) 和 sweep(logits, temps, p)。greedy 是最大 logit 的编号,平局时取小的编号。sweep 对 temps 中的每个温度返回 (온도, 엔트로피, top-p 가 남기는 개수)(占位符依次为温度、熵、top-p 保留的个数)三元组的列表。
greedy 甚至不需要求概率——因为 softmax 不改变顺序。如果搞不清 max 在平局时给出哪一个,就自己扫描,只在更大时才更新。这样就会留下小的编号。sweep 的个数是经过 top_p_filter 之后不为 0 的位置数。请亲眼看看,温度越高,熵越大,保留的个数也越多。
留下拧动旋钮的结果
用固定的配置来测量。种子 20260917,抽取次数 24,top_k 为 5,top_p 为 0.9,热的一侧温度 1.0,冷的一侧温度 0.2。把结果以 vocab_size、greedy_index、greedy_token、top_prob_t1、entropy_t1、sweep、nucleus、topk_mass、seed、draw_count、hot_draws、cold_draws、cold_is_greedy、hot_distinct、cold_distinct、repeat_matches 写入 /root/work/tf-sample/sample_report.json,并在 /root/work/tf-sample/sample_report.md 中用 ## 무엇을 쟀나(韩文,意为“测量了什么”)、## 온도가 분포를 어떻게 바꾸나(韩文,意为“温度如何改变分布”)、## top-k 와 top-p 가 남기는 것(韩文,意为“top-k 和 top-p 留下的内容”)、## 같은 시드는 같은 수열을 준다(韩文,意为“同样的种子给出同样的序列”)四节写下。
数字不要手写,要用实际运行你的代码得到的值来填。sweep 是温度 0.25、0.5、1.0、2.0、4.0,p 是 0.9。nucleus 是在温度 1.0 下把 p 换成 0.5、0.8、0.9、0.95 测得的 [p, 남은 개수](占位符依次为 p 与剩余个数),topk_mass 是在温度 1.0 的原始概率中,对 k 取 1、3、5、10 测得的前 k 个所占的总和 [k, 합](占位符依次为 k 与总和)——不是截断后再归一化的值,而是截断之前的总和。cold_is_greedy 是冷的一侧的序列是否全部与 greedy(LOGITS) 相同,repeat_matches 是用同一个种子再抽一次时是否得到同样的序列。根据你写的 logits,cold_is_greedy 也可能是假——那就如实写下,并在正文中解释为什么。