SONUB
ZH English 한국어 日本語 中文 हिन्दी বাংলা اردو Español Português Français Deutsch Русский العربية Bahasa Indonesia Tiếng Việt ไทย Türkçe

如何开启通知

此浏览器或网址无法使用通知。请使用支持通知的浏览器,通过安全连接访问SONUB。

如果通知被屏蔽,请在浏览器设置中允许通知。

  1. 打开地址栏旁的站点设置或浏览器的站点设置,将此网站的通知设为允许。
  2. 在设备设置中允许浏览器发送通知,并检查勿扰模式或专注模式。
  3. 返回SONUB并刷新页面。通知开启后,铃铛将不再显示。

在iPhone或iPad上,将SONUB添加到主屏幕,从主屏幕打开后允许通知。

登录 退出登录 显示主题 默认主题 深色主题 粉色主题 聊天 1:1 聊天 群聊 论坛 公告 新闻区 建议区 自我介绍 主菜单

现在,让我们打造属于你自己的人工智能吧。

、更新,并从检查点继续。运行 train.py,比较训练前后的。

一排词语积木的最后是一个空格。空格上方有三块候选积木,各带一根高低不同的柱子;其中一块被选中,接到这一排的末尾,一个箭头又绕回去,再选下一个。
6

训练与检查点

输入[a,b]对应目标[b,c]。是正确的,低不保证事实正确。

选择窗口、清、前后向计算、限制梯度、更新。每次完整更新保存、设置、、步数与随机状态。仅检查固定的首个验证窗口。

训练目标——错开一格的答案

把资料转成,就成了一长串编号。从中切出 13 个词元的片段,前 12 个作为输入,错开一格的后 12 个作为答案。

  • 词元是 [a, b, c, d] 时,输入 [a, b, c] 的答案是 [b, c, d]。
  • 位置 1 只看 a 预测 b,位置 2 看 a、b 预测 c,位置 3 看 a、b、c 预测 d。
  • 多亏因果,一次 forward 同时解 12 道题。

不需要人来标注答案,文字本身就成了题目和答案。所以网上的文字可以直接作为训练资料(自监督学习)。要准确,必须懂语法、、格式和事实,所以这个简单的目标能引出整个语言能力。

损失——交叉熵(正确概率的负对数)

设每个位置上给正确词元的概率为 p,该位置的损失就是 −log(p)。片段的损失是 12 个位置损失的平均。

  • p = 1 时损失为 0:对答案很确定。
  • p = 0.5 时是 0.693,p = 0.1 时是 2.303,p = 0.01 时是 4.605:给正确答案的概率越小,损失增长得越陡。
  • 给错误答案大概率不会直接受罚。概率总和为 1,错误答案拿走得多,正确答案的 p 就变小,损失变大。

初始损失可以事先知道。训练前的模型给词表 40 个词元几乎相同的概率(1/40),所以损失是 −log(1/40) = ln 40 ≈ 3.689。train.py 最先输出的 validation 3.7568 和这个值几乎相同,就说明模型正确初始化了。词表有 50,257 个的 ,初始损失约 10.8。对损失取 exp 的值叫困惑度(perplexity),像 exp(3.689) = 40 这样,可以理解为"正在多少个选项中犹豫"。

词表有几万个的模型,损失大致这样解读:10 以上是随机水平,5~7 在学语法,3~5 在学格式,2~3 在学含义,降到 2 以下就要怀疑在死记()。出发点随词表大小不同,所以在 中看从出发点 3.69 下降了多少。

动手验证 lab/loss.py
import math

for p in [1.0, 0.9, 0.5, 0.1, 0.01]:
    print('p =', p, ' loss =', round(0.0 - math.log(p), 4))
for vocab in [40, 50257]:
    print('guess evenly among', vocab, '->', round(math.log(vocab), 4))
cd ~/xslm
mkdir -p lab
micro lab/loss.py
python3 -m lab.loss

运行结果

p = 1.0  loss = 0.0
p = 0.9  loss = 0.1054
p = 0.5  loss = 0.6931
p = 0.1  loss = 2.3026
p = 0.01  loss = 4.6052
guess evenly among 40 -> 3.6889
guess evenly among 50257 -> 10.8249

梯度下降与学习率——朝梯度反方向一点点走

梯度是"把这个数字稍微调大,损失会增加多少"。要减少损失,就把数字朝梯度的反方向移动。

  • 新值 = 旧值 − × 梯度

学习率(learning_rate)是一次移动多少。下面的实验从 0 出发,走 20 步寻找 f(w) = (w − 3)² 的最低点 w = 3。

  • 0.01:太谨慎,只走到 1.0 附近。训练很慢。
  • 0.1:走到 2.97,几乎到达。
  • 0.5:对这个函数,一步就正好到 3。
  • 1.1:跳得太大,反复越过谷底到对面,最后飞到 −112(发散)。

真实模型有几千到几十亿个数字,无法知道谷底的形状,所以学习率靠实验决定。XSLM 的 0.03 是让小模型不乱跳的谨慎值。改学习率会改变 settings.json,所以要用新检查点开始。

动手验证 lab/descent.py
# f(w) = (w - 3) ** 2 is smallest at w = 3. Its gradient is 2 * (w - 3).
for lr in [0.01, 0.1, 0.5, 1.1]:
    w = 0.0
    for step in range(20):
        w -= lr * 2 * (w - 3)
    print('lr', lr, '-> w =', round(w, 4))
cd ~/xslm
mkdir -p lab
micro lab/descent.py
python3 -m lab.descent

运行结果

lr 0.01 -> w = 0.9972
lr 0.1 -> w = 2.9654
lr 0.5 -> w = 3.0
lr 1.1 -> w = -112.0128

训练循环一圈——六步

train.py 的一次是这个顺序。

  • 选片段:在训练词元序列中随机选起点,切出 13 个词元。
  • 清梯度:把所有的 grad 设为 0。梯度用 += 累加,上一次循环的值留下就会混进来。本次片段中没出现的词元的行不在中,backward 不会碰它们,所以不在这里清掉,旧梯度会一直留着。
  • :model.loss 完成 forward 和损失计算。
  • :用 loss.backward() 求出全部 1,248 个的梯度。
  • 更新:限制梯度大小后,用 p.data −= 学习率 × 梯度 × 倍率 移动所有数字。
  • 保存与记录:保存检查点,输出 step 和 loss。

训练按设定次数转这一圈。开始前和结束后,用验证资料的第一个片段测损失,看在没用于训练的文字上是否也有进步。

XSLM 不按资料过了几遍()计数,而是按步数(step)计数。片段是随机抽的,有些部分被抽到多次,有些少一些。大模型通常也按 step 计数。

梯度裁剪——一次别走太远

有时某个片段的梯度会突然变得非常大。直接更新,数字会一下子飞得很远,毁掉之前的训练(梯度爆炸)。所以 train.py 在更新前测量整个梯度的长度。

  • 长度 = √(所有梯度平方之和)
  • 倍率 = min(1, 1 ÷ 长度)
  • 长度在 1 以下时倍率为 1,原样使用;大于 1 时,把所有梯度按同一比例缩小,使长度正好为 1。

关键是方向不变,只缩小大小。在 中由 clip_grad_norm_ 做同样的事(通常最大长度 1.0)。分母中的 1e-8 是为了在梯度全为 0 时不除以 0 而加的小数。

优化器——SGD 与 AdamW

用梯度修改数字的规则叫。

  • SGD(XSLM):新值 = 旧值 − 学习率 × 梯度。只看当前梯度,不记忆任何东西。所以不需要在检查点里保存优化器状态。
  • :记住过去梯度的走势(速度),方向一致就走得更快,摇摆时就少动。
  • Adam、AdamW:为每个数字记住梯度的平均和大小的平均,自动为每个数字定出合适的步长。AdamW 再加上一点点缩小数字、防止过大的权重衰减(weight decay)。这是训练的标准,学习率通常在 0.0003 左右。

Adam 每个参数要多记两个数字,所以要继续训练,检查点里也要保存这个状态(optimizer state)。因此 PyTorch 检查点里通常除了模型权重还有优化器状态。训练中的也是只放权重时的好几倍。

批——多个片段的平均梯度

XSLM 一次用一个片段(批大小 1)求梯度。每个片段内容不同,梯度波动大,所以损失也忽高忽低。

大模型一次计算多个片段,使用平均梯度。例如 16 个长 256 词元的片段(批大小 16),一次更新就看 4,096 个词元。

  • 取平均能减少波动,更新方向更稳定。
  • 同时计算多个片段,所以批变大,时间几乎不增加。
  • 但内存也相应增加。内存不够()时就减小批或片段长度。

内存不足时,用梯度累积(gradient accumulation):用小批多次 backward 收集梯度,再更新一次。XSLM 的 backward 开始时会把图中的梯度清为 0,所以不能直接用这个方法。

过拟合与欠拟合——同时看训练损失和验证损失

同时看两个损失,就能知道模型的状态。

  • 两个都下降:学得很好。
  • 只有训练损失下降,上升:过拟合。背下了训练资料,对没见过的文字反而更差。
  • 两个都降不下来:。模型太小、训练不足,或学习率不合适。

我们用 XSLM 实际测了一下(桌面 ,默认资料)。默认学习率 0.03 训练 1,500 次,验证损失从 3.76 降到 3.23。学习率 0.1 训练 3,000 次,训练损失在某些片段降到 1.3,但验证损失升到 4.42;0.3 时升到 5.28。背下 12 对训练句子很容易,但资料不够多,学不到能用于没见过的 3 对的规则。

减少过拟合最可靠的方法是增加资料,也会用缩小模型、、提前停止训练(使用验证损失最低的检查点)。欠拟合就把模型调大或多训练。

检查点——保存什么,为什么

checkpoints/model.json 包含停下后能准确继续训练所需的一切。

  • weights:9 个的 1,248 个数字。
  • step:到目前为止的更新次数。
  • random:随机数生成器的状态。继续训练时,片段顺序无缝衔接。
  • settings、:模型的大小和编号规则。generate.py 和 runner.py 只读这些就能重建模型。
  • data_hash:训练和验证资料的 指纹。用来判断资料是否变化。

所以分两次各训练 3 次的结果,和一次训练 6 次的结果,每个数字都一样。可以亲自验证:把运行两次 --steps 3 得到的 model.json 改名保存,再和重新运行一次 --steps 6 的结果比较。

保存是安全的。先完整写到 model.tmp,再用 改名。改名是一瞬间完成的,所以写到一半按 或断电,model.json 也只会是之前的完整版或新的完整版之一。不会留下写了一半的坏文件。

PyTorch 用 torch.save 把权重、优化器状态、step 和设置保存为(.pt)。XSLM 每一步都保存,但大模型保存也要花时间,所以每几百步并保存一次。

可复现性——用 seed 再次得到相同结果

计算机的随机数是从 seed(种子编号)按固定规则生成的数列。seed 相同,总会得到相同的数列。XSLM 用 seed 7 决定两处。

  • 权重的初始值(model.py 的 random.Random(seed))。
  • 训练片段的顺序(train.py 的 rng)。

所以资料和设置相同,谁在哪里运行都会走相同的训练过程。只改一个学习率来比较,其余的随机性必须相同,所以是实验的基础。改了 seed,就从不同的初始值出发,结果也会不同。依赖一个幸运 seed 得出的结论难以相信,所以重要的比较要换几个 seed 重复。

不过 Python 或的数学不同,小数点末位可能不同,很长的训练中数字可能会逐渐拉开差距。

读训练记录——数字告诉你什么

train.py 会输出这样的行。

  • parameters: 1248 resume: 0 —— 参数数量和继续开始的步数。第一次是 0。
  • validation: 3.7568 —— 训练前的验证损失。应该接近 (词表数)。
  • step: 1 loss: 3.6875 —— 每一步该片段的训练损失。
  • validation: … —— 训练后的验证损失。
  • saved: checkpoints/model.json —— 保存的位置。

每一步的 loss 因片段不同而起伏。不要只看一个数字,要看多步的走势。默认 20 次只是在 3.7 附近略有变动,几百到几千次才会明显下降。

=True 表示立刻把输出送到屏幕。没有它,Python 会把输出攒起来一次显示,看起来像训练卡住了,用 写入文件时也会记录得晚。

训练不顺时——按症状检查

用症状缩小原因范围。

  • 损失在 3.7 附近几乎不动:训练次数少。增加 --steps,或者用新检查点稍微调大学习率试试。
  • 损失大幅跳动或只升不降:学习率太大。调小后重新开始。
  • : math domain error 或 nan:概率变成 0 而计算了 log(0),或者数字发散了。降低学习率,检查是否有。
  • : changed settings/tokenizer 或 changed :检查点和当前设置、资料不同。把移走,重新开始。
  • AssertionError: add more text:训练或验证词元少于 13 个。在 raw.txt 中加行。
  • 太慢:减小 width、context 会变快,但需要新检查点。先把 --steps 分小段运行。
  • 训练好了但 的回答没变:Runner 只在启动时读一次。关掉再启动。

动手输入并实验

编写train.py,短训后再运行五步,查看resume和真实loss。速度因设备而异。

cd ~/xslm
micro train.py
python3 train.py --steps 20
python3 train.py --steps 5
在终端中练习
train.py需要亲手编写的完整源码

资料检查变更。加os.replace保留完整检查点;SGD没有动量状态。

import argparse
import hashlib
import json
import math
import os
import random
from pathlib import Path
from model import GPT
from tokenizer import BPE


def main():
    cfg = json.loads(Path('settings.json').read_text())
    parser = argparse.ArgumentParser()
    parser.add_argument('--steps', type=int, default=cfg['steps'])
    args = parser.parse_args()
    assert args.steps > 0, '--steps must be positive'
    token_state = json.loads(Path('data/tokenizer.json').read_text())
    tokenizer = BPE(**token_state)
    train_text = Path('data/train.txt').read_text(encoding='utf-8')
    val_text = Path('data/val.txt').read_text(encoding='utf-8')
    fingerprint = hashlib.sha256((train_text + '\0' + val_text).encode()).hexdigest()
    train, val = tokenizer.encode(train_text), tokenizer.encode(val_text)
    model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
    rng, step = random.Random(cfg['seed']), 0
    checkpoint = Path('checkpoints/model.json')
    if checkpoint.exists():
        saved = json.loads(checkpoint.read_text())
        assert saved['settings'] == cfg and saved['tokenizer'] == token_state, 'changed settings/tokenizer: start a new checkpoint'
        assert saved['data_hash'] == fingerprint, 'changed data: start a new checkpoint'
        model.load(saved['weights'])
        step = saved['step']
        rng.setstate((saved['random'][0], tuple(saved['random'][1]), saved['random'][2]))
    n = model.context
    assert len(train) > n and len(val) > n, 'add more text'
    print('parameters:', len(model.parameters), 'resume:', step, flush=True)
    print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
    checkpoint.parent.mkdir(exist_ok=True)
    for _ in range(args.steps):
        start = rng.randrange(len(train) - n)
        window = train[start:start + n + 1]
        for p in model.parameters:
            p.grad = 0
        loss = model.loss(window[:-1], window[1:])
        loss.backward()
        size = math.sqrt(sum(p.grad * p.grad for p in model.parameters))
        scale = min(1.0, 1.0 / (size + 1e-8))
        for p in model.parameters:
            p.data -= cfg['learning_rate'] * p.grad * scale
        step += 1
        state = {'settings': cfg, 'tokenizer': token_state, 'data_hash': fingerprint,
                 'weights': model.state(), 'step': step, 'random': rng.getstate()}
        temporary = checkpoint.with_suffix('.tmp')
        temporary.write_text(json.dumps(state, ensure_ascii=False), encoding='utf-8')
        os.replace(temporary, checkpoint)
        print('step:', step, 'loss:', round(loss.data, 4), flush=True)
    print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
    print('saved:', checkpoint, flush=True)


if __name__ == '__main__':
    main()

逐行拆解代码

train.py 准备——设置、分词器、资料

def main():
    cfg = json.loads(Path('settings.json').read_text())
    parser = argparse.ArgumentParser()
    parser.add_argument('--steps', type=int, default=cfg['steps'])
    args = parser.parse_args()
    assert args.steps > 0, '--steps must be positive'
    token_state = json.loads(Path('data/tokenizer.json').read_text())
    tokenizer = BPE(**token_state)
    train_text = Path('data/train.txt').read_text(encoding='utf-8')
    val_text = Path('data/val.txt').read_text(encoding='utf-8')
    fingerprint = hashlib.sha256((train_text + '\0' + val_text).encode()).hexdigest()
    train, val = tokenizer.encode(train_text), tokenizer.encode(val_text)
    model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])

读取 settings.json,用 argparse 接收 --steps 选项(没有就用设置中的 steps)。用 tokenizer.json 重建 ,读取训练和验证文字。fingerprint 是把两段文字用 \0 连接后生成的 SHA-256 指纹。文字哪怕只改一个字,也会得到完全不同的值。把两段文字转成词元编号,按词表数建立 。

train.py 继续训练

    rng, step = random.Random(cfg['seed']), 0
    checkpoint = Path('checkpoints/model.json')
    if checkpoint.exists():
        saved = json.loads(checkpoint.read_text())
        assert saved['settings'] == cfg and saved['tokenizer'] == token_state, 'changed settings/tokenizer: start a new checkpoint'
        assert saved['data_hash'] == fingerprint, 'changed data: start a new checkpoint'
        model.load(saved['weights'])
        step = saved['step']
        rng.setstate((saved['random'][0], tuple(saved['random'][1]), saved['random'][2]))

rng 是选择片段的随机数生成器,step 是更新次数。有检查点就读取,用 assert 确认设置、分词器、资料指纹与现在相同,然后恢复权重、step 和随机数状态。 把元组存成列表,所以交给 setstate 之前要把中间部分转回 tuple。

train.py 开始前的确认

    n = model.context
    assert len(train) > n and len(val) > n, 'add more text'
    print('parameters:', len(model.parameters), 'resume:', step, flush=True)
    print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
    checkpoint.parent.mkdir(exist_ok=True)

n 是上下文长度(12)。训练和验证词元必须多于 n,才能切出 n + 1 个词元的片段。输出参数数量和继续开始的步数,测量训练前的验证损失。val[:n] 是输入,val[1:n + 1] 是错开一格的答案。没有 checkpoints 文件夹就创建。

train.py 切出随机片段

    for _ in range(args.steps):
        start = rng.randrange(len(train) - n)
        window = train[start:start + n + 1]

randrange((train) − n) 抽出 0 以上、小于(长度 − n)的起点。从那里切出 n + 1 个,作为 window。下一行中 window[:-1] 是 12 个输入,window[1:] 是 12 个答案。

train.py 清梯度、前向传播、反向传播

        for p in model.parameters:
            p.grad = 0
        loss = model.loss(window[:-1], window[1:])
        loss.backward()

把所有参数的 grad 设为 0,用 model.loss 计算损失,再用 backward 填入梯度。backward 只清图中的节点,所以本次片段没用到的词元嵌入行的旧梯度由这个循环来清。

train.py 梯度裁剪与 SGD 更新

        size = math.sqrt(sum(p.grad * p.grad for p in model.parameters))
        scale = min(1.0, 1.0 / (size + 1e-8))
        for p in model.parameters:
            p.data -= cfg['learning_rate'] * p.grad * scale
        step += 1

size 是把 1,248 个梯度看成一个时的长度。scale 在长度超过 1 时为 1/长度,否则为 1。从所有参数的 data 中减去 学习率 × 梯度 × scale。因为直接修改 data,这个计算不会记录在图中。最后 step 加 1。

train.py 安全保存并记录

        state = {'settings': cfg, 'tokenizer': token_state, 'data_hash': fingerprint,
                 'weights': model.state(), 'step': step, 'random': rng.getstate()}
        temporary = checkpoint.with_suffix('.tmp')
        temporary.write_text(json.dumps(state, ensure_ascii=False), encoding='utf-8')
        os.replace(temporary, checkpoint)
        print('step:', step, 'loss:', round(loss.data, 4), flush=True)

state 装着继续训练所需的一切。全部写到 model.tmp 后,用 os.replace 一次替换 model.json。然后输出这一步的损失。每一步都保存,所以无论何时停下,都会保留到最后完成的那一步。

train.py 最后的验证

    print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
    print('saved:', checkpoint, flush=True)

用训练完的权重,在与开始时相同的验证片段上重新测量损失。和开始时的值比较,就能知道在没用于训练的文字上是否也有进步。只有一个片段,只能当作大致的信号。

检查理解

确认model.json与连续步数。不同随机窗口的loss可能上升。

遇到问题时

Ctrl+C保留最近完成的更新。数据或设置变化时先导出并改名旧检查点,再重新训练。

常见问题

loss 忽高忽低,训练坏了吗?

没有。每一步用不同的一个片段测损失,片段容易就低,难就高。请看多步的走势和开始、结束时的验证损失。批变大,波动就会减小。

要训练多少次?

没有固定答案,由验证损失决定。验证损失不再下降或开始上升,就是该停的时候。在桌面上测试,默认学习率 0.03 到 1,500 次为止验证损失都在下降,学习率 0.1 训练 3,000 次反而上升了。中每一步都慢,请像 --steps 50 这样分段运行并观察。

没有 GPU 也能训练吗?

能。训练只是加法和乘法的反复,任何 都能做。GPU 只是同时做几千个相同的计算,所以快。XSLM 用虚拟 CPU,大模型通常用 GPU(Apple 上是 ),做同样原理的训练。

训练好的模型在使用中会自己继续学习吗?

不会。训练只在运行 train.py 时发生。Runner 回答时只读取权重,不会修改。看起来能记住对话内容的服务,是把重新放进里。

学习指南

人工智能

智能体工具

Vibe 编程

AI 应用

AI证书

AI课堂

IT 基础

聊天

论坛

创作

社区

更多

术语词典

选一个你不认识的词吧,可以看到简单的解释和相关术语。

还没有匹配的术语。

选择一个术语即可查看解释。