每次输入最近上下文 并添加一个词元 。temperature 为0选最高分,正数调整随机概率 ;top_k 为0保留所有候选。
seed帮助重现 实验。遇换行 或长度上限停止,只返回新生成文字。保持训练 时Q:/A:格式。
生成循环——逐个接上词元(自回归)
generate 按设定的词元数重复这个顺序。
从目前为止的词元中只取最后 context(12)个,放入 forward。
返回的分数列表 中只用最后一个位置的分数 [-1]。那就是下一个词元的分数。
用设定的方法(贪心 、温度、top_k)选出一个词元。
把选出的词元接到 ids 后面。下一圈会看到包含这个词元的文字。
选出的词元是换行就停止。
把自己的输出再作为输入,所以叫自回归 (autoregressive)生成。选过的词元不会撤回,前面出错,后面就会跟着这个错误走。
生成只读取权重 。但 XSLM 的 forward 和训练时做同样的 Value 计算,连用不上的计算图 也会生成。PyTorch 在 torch.no_grad() 中生成,不建图,并用 model.eval() 关闭 dropout 。
贪心选择——temperature 0
temperature 设为 0,就总是选分数最高的词元(greedy, argmax )。相同输入总是得到相同回答。
优点是可预测。它如实展示模型 认为什么最合理,适合用来确认训练是否有效。缺点是容易重复同一个短语。像 "is warn is warn is warn" 那样一旦进入循环,每次都是同一个词元最高,出不来。所以对话中会混入一点随机性。
温度——让概率分布变尖或变平
temperature(T)在 softmax 之前把分数除以 T。概率与 exp(分数 ÷ T) 成正比。
T < 1:分数差距变大,分布变尖。高分词元被抽中得更频繁。
T = 1:就是模型给出的概率。
T > 1:分数差距变小,分布变平。低分词元也常被抽中,结果变得离谱。
下面的实验对分数 warm 2.0、hot 1.0、blue 0.2、run −1.0 改变温度。warm 的概率在 T 0.5 时是 0.858,1.0 时是 0.632,2.0 时是 0.447;run 从 0.002 升到 0.1。
generate.py 也做同样的计算。从候选分数中减去最大值(数值稳定),除以 T 后取 exp,把这个值作为权重交给 random.choices。权重总和不必为 1,choices 会按比例抽取。通常多用 0.7~0.9,0.1~0.5 偏保守,1.0 以上容易变得离谱。
问问人工智能
动手验证 lab/temperature.py
import math
logits = {'warm': 2.0, 'hot': 1.0, 'blue': 0.2, 'run': -1.0}
for t in [0.5, 1.0, 2.0]:
weights = {w: math.exp(s / t) for w, s in logits.items()}
total = sum(weights.values())
print('T =', t, {w: round(v / total, 3) for w, v in weights.items()})
print('top_k=2 keeps', sorted(logits, key=logits.get, reverse=True)[:2])
cd ~/xslm
mkdir -p lab
micro lab/temperature.py
python3 -m lab.temperature
运行结果
T = 0.5 {'warm': 0.858, 'hot': 0.116, 'blue': 0.023, 'run': 0.002}
T = 1.0 {'warm': 0.632, 'hot': 0.232, 'blue': 0.104, 'run': 0.031}
T = 2.0 {'warm': 0.447, 'hot': 0.271, 'blue': 0.182, 'run': 0.1}
top_k=2 keeps ['warm', 'hot']
top-k 与 top-p——减少候选
即使用了温度,非常低的词元合起来也可能有相当大的概率。词表 有 5 万个时,每个虽小,加起来也不能忽视。所以抽取前先减少候选。
top-k:只保留分数前 k 个,其余丢掉。XSLM 的 top_k 8 在词表 40 个中只留 8 个候选。为 0 则不减少。实验中的 top_k=2 只留下 warm、hot。
top-p(nucleus):按概率从高到低相加,只保留到总和达到 p(例如 0.9)为止。模型有把握时候选只有几个,犹豫时候选变多,会随情况变化。XSLM 没有,但真实服务中常用。
词表有几万个的模型常用 top_k 40~60。(温度 0.7, top_k 30)求准确,(0.9, 50)求平衡,(1.1, 100)求创意,是常见的组合。在词表 40 个的 XSLM 中,8 左右是相近的比例。
问问人工智能
seed——复现随机选择
temperature 大于 0 时,random.choices 随机抽取词元。generate 每次调用 都用 random.Random(seed) 新建生成器,所以检查点、提示词 、温度、top_k、seed 都相同,总会得到相同的文字。
下面的实验按权重 5:3:2 抽取 a、b、c 共 12 次。seed 7 两次都得到相同的一行,seed 8 得到不同的一行。权重大的 a 出现最多,但顺序由 seed 决定。
seed 是复现结果的编号,不是保证好回答的编号。比较设置时固定 seed、只改一项;想看结果是不是运气,就换几个 seed 试试。
动手验证 lab/seed.py
import random
for seed in [7, 7, 8]:
rng = random.Random(seed)
print(seed, ''.join(rng.choices('abc', weights=[5, 3, 2])[0] for _ in range(12)))
cd ~/xslm
mkdir -p lab
micro lab/seed.py
python3 -m lab.seed
运行结果
7 aababaabaaaa
7 aababaabaaaa
8 acabaacabaaa
停止条件——换行与最大词元数
生成在两种情况下停止。
抽到换行词元时:训练资料中一行答案以换行结束,所以这表示模型判断"答案结束了"。换行不放进结果。
达到请求的词元数(默认 24,最多 64)时:即使模型没能结束,也防止它无休止地写下去。
结果中去掉提示词,只把新生成的词元 decode 后返回。第一个词元就抽到换行,就会得到空回答。
真实模型在 EOS 特殊词元或设定的停止标记(例如 [/ANSWER])处停止,并截掉后面的部分。每生成一个词元就立刻发到屏幕的流式 输出也很常见。
提示词格式——与训练的形状一样,连空格都相同
模型只擅长接续训练资料中见过的形状后面的文字。所以生成时放入的文字(提示词)必须和训练格式一模一样。XSLM 的默认提示词是 "Q: hi" 的下一行放 "A: "。
一个空格也很重要。训练资料中的 "A: " 是一个词元(30 号),去掉末尾空格写成 "A:",就变成 "A"(5 号)和 ":"(4 号)两个词元。这两个词元这样出现的情况在训练资料中一次也没有,所以模型在从未见过的情况下开始回答。在桌面上用学习率 0.1 训练 3,000 次的模型询问 sun,以 "A: " 结尾时会给出 "te sun is warn is warn…" 这样和训练句子相似的文字,以 "A:" 结尾时只给出 "ore" 这样的碎片。
格式较长时,连答案的开头也放进去。例如在 [ANSWER] 后面放上 "总结:" 和 "-",模型就会直接接着写总结列表。
回答奇怪时——原因与实验
按常见顺序看小模型回答奇怪的原因。
训练不足:默认 20 次是用来确认的。损失 在 3.7 附近,模型几乎还什么都不懂。
资料不足:12 对只能模仿格式,无法回答没见过的问题。
格式不一致:提示词和训练格式不同,或混入了未知字符(�)。
上下文不足:超过 context 12 个词元的前面部分看不到。
采样 设置:出现重复就把温度、top_k 稍微调高,离谱就调低。
提高随机性解决不了训练不足。值得做的实验是这样:用新检查点把学习率改成 0.1,把 --steps 分成每次几百次来训练,用 temperature 0 问同一个问题,记录回答怎样变化。上面看到的 "te sun is warn…" 是开始模仿训练句子 "the sun is warm."、但还没背下来的阶段。
动手输入并实验
写generate.py,用同一检查点逐个改变温度、top_k和seed。
cd ~/xslm
micro generate.py
python3 generate.py --temperature 0 --top-k 1
python3 generate.py --temperature 0.8 --top-k 8 --seed 7
python3 generate.py --temperature 1.2 --top-k 0 --seed 8
在终端中练习
generate.py 需要亲手编写的完整源码
恢复结构和数字,截取上下文并反复采样。
import argparse
import json
import math
import random
from pathlib import Path
from model import GPT
from tokenizer import BPE
def load():
saved = json.loads(Path('checkpoints/model.json').read_text())
cfg = saved['settings']
tokenizer = BPE(**saved['tokenizer'])
model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
model.load(saved['weights'])
return model, tokenizer, cfg
def generate(model, tokenizer, prompt, tokens=24, temperature=0.8, top_k=8, seed=7):
if not prompt or tokens < 1 or temperature < 0 or not math.isfinite(temperature) or top_k < 0:
raise ValueError('invalid sampling parameters')
ids, result, rng = tokenizer.encode(prompt), [], random.Random(seed)
for _ in range(tokens):
logits = model.forward(ids[-model.context:])[-1]
ranked = sorted(range(len(logits)), key=lambda i: logits[i].data, reverse=True)
if temperature == 0:
token = ranked[0]
else:
candidates = ranked[:top_k] if top_k else ranked
peak = logits[candidates[0]].data
weights = [math.exp((logits[i].data - peak) / temperature) for i in candidates]
token = rng.choices(candidates, weights=weights)[0]
ids.append(token)
if tokenizer.vocab[token] == '\n':
break
result.append(token)
return tokenizer.decode(result)
if __name__ == '__main__':
model, tokenizer, cfg = load()
parser = argparse.ArgumentParser()
parser.add_argument('--prompt', default='Q: hi\nA: ')
parser.add_argument('--tokens', type=int, default=cfg['tokens'])
parser.add_argument('--temperature', type=float, default=0.8)
parser.add_argument('--top-k', type=int, default=8)
parser.add_argument('--seed', type=int, default=cfg['seed'])
args = parser.parse_args()
assert 1 <= args.tokens <= cfg['max_tokens']
print(generate(model, tokenizer, args.prompt, args.tokens,
args.temperature, args.top_k, args.seed))
逐行拆解代码
generate.py load——用检查点恢复模型
def load():
saved = json.loads(Path('checkpoints/model.json').read_text())
cfg = saved['settings']
tokenizer = BPE(**saved['tokenizer'])
model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
model.load(saved['weights'])
return model, tokenizer, cfg
从一个 model.json 连设置和分词器 都读出来,所以即使 settings.json 或 tokenizer.json 之后改变,也能建出和训练时相同的模型。建立同样大小的 GPT (初始值马上会被覆盖),再用 load 放入训练好的数字。
generate.py 输入检查与准备
def generate(model, tokenizer, prompt, tokens=24, temperature=0.8, top_k=8, seed=7):
if not prompt or tokens < 1 or temperature < 0 or not math.isfinite(temperature) or top_k < 0:
raise ValueError('invalid sampling parameters')
ids, result, rng = tokenizer.encode(prompt), [], random.Random(seed)
空提示词、小于 1 的词元数、负数/无穷大/NaN 的温度、负数 top_k,都用 ValueError 拒绝。math.isfinite 遇到 inf、nan 时为假。把提示词变成编号,再建立收集新词元的 result 和只用于这次生成的随机数生成器 rng。
generate.py 求下一个词元的分数
for _ in range(tokens):
logits = model.forward(ids[-model.context:])[-1]
ranked = sorted(range(len(logits)), key=lambda i: logits[i].data, reverse=True)
用 ids[-model.context:] 只放入最后 12 个词元,只用 forward 结果的最后一行 [-1]。ranked 是把词表编号按分数从高到低排序的列表,ranked[0] 是最高的词元。
generate.py 选出一个词元
if temperature == 0:
token = ranked[0]
else:
candidates = ranked[:top_k] if top_k else ranked
peak = logits[candidates[0]].data
weights = [math.exp((logits[i].data - peak) / temperature) for i in candidates]
token = rng.choices(candidates, weights=weights)[0]
temperature 为 0 就选最高的词元。否则保留 top_k 个(为 0 则全部)作为候选,减去最大分数 peak,再除以 temperature 取 exp,以此为权重,用 rng.choices 抽一个。choices 返回列表,所以用 [0] 取出。
generate.py 接上、停止、返回
ids.append(token)
if tokenizer.vocab[token] == '\n':
break
result.append(token)
return tokenizer.decode(result)
把选出的词元接到 ids 上,放进下一圈的输入。这个词元是换行就不放进结果并停止,否则收集到 result。循环 结束后只把 result decode 后返回,所以结果中不包含提示词。
generate.py 命令行运行
if __name__ == '__main__':
model, tokenizer, cfg = load()
parser = argparse.ArgumentParser()
parser.add_argument('--prompt', default='Q: hi\nA: ')
parser.add_argument('--tokens', type=int, default=cfg['tokens'])
parser.add_argument('--temperature', type=float, default=0.8)
parser.add_argument('--top-k', type=int, default=8)
parser.add_argument('--seed', type=int, default=cfg['seed'])
args = parser.parse_args()
assert 1 <= args.tokens <= cfg['max_tokens']
print(generate(model, tokenizer, args.prompt, args.tokens,
args.temperature, args.top_k, args.seed))
直接运行时读取检查点,接收 --prompt、--tokens、--temperature、--top-k、--seed 选项。argparse 把 --top-k 变成 args.top_k(- 变成 _)。用 assert 确认词元数在 1~max_tokens(64) 之间后,输出生成的文字。在 shell 中要给出含换行的提示词,就像 --prompt "$(printf 'Q: cat\nA: ')" 这样用 printf。
检查理解
相同输入、权重和设置应复现。首个词元为换行时可能得到空答案。
遇到问题时 小模型短训后乱码正常。先检查格式、未知字和损失,增加随机性不能替代训练。
常见问题
为什么同一个问题每次回答都一样?
因为 seed 相同。generate 每次请求都用同一个 seed 新建生成器。想看不同的回答,就改 --seed,或在 Runner 请求 JSON 中把 "seed" 设成别的整数。temperature 为 0 时,不管 seed 是多少总是相同。
温度调高会变聪明吗?
不会。温度只改变从已算好的概率中选择的方法。模型给正确答案的概率很低,温度怎么调正确答案也不会常出现。聪明程度由训练(资料、规模、次数)决定。
出现了空回答。
这是第一个词元就抽到换行而立刻停止的情况。训练资料中 "A: " 后面从不会直接换行,所以是训练不足或提示词格式不同时才会发生。检查提示词是否以 "A: " 结尾,再多训练,或换 seed、temperature 试试。