最近在学习模型训练,实际上在大模型训练上,我并没有深厚的背景,通过视频课程和b站上的一些分享,开始入门。
由于我非神经网络这些相关的专业,所以想把自己学习的过程和经验总结记录下来,一方面自己可以巩固总结知识,另一方面,对于“外行”如何入手,把这些经验给到有需要的人。
这篇文章用较容易理解的方式,去开始去训练一个小模型。我们通过一个项目来直接进入模型训练,了解GPT模型的核心逻辑,这个是github上的项目地址:https://github.com/karpathy/nanoGPT.git
这个项目是很简洁、高效的,是学习 Transformer 和 GPT 原理的最佳实践项目之一。代码量极少,相比于大型工业级框架,nanoGPT的代码量非常少,几百行核心代码,去除了复杂的工程封装,保留了 GPT 模型最核心的逻辑。正在学习大语言模型(LLM),这是一个非常好的切入点,能让你看到模型背后的代码是如何运作的。
项目给了三个运行方式,对于侧重点也是有所不同。
train_shakespeare_char
从随机参数开始,没有加载现成 GPT-2,用的是字符级数据表示,所以它只是“从零训练一个小模型”。
train_gpt2
把模型结构设成 GPT-2 风格,然后从零开始训练。它不是加载 OpenAI 已经训好的GPT-2,而是自己重新训一个。
所以它叫:复现 GPT-2,从零训练 GPT-2 风格模型,不是微调。
finetune_shakespeare 是微调,先把现成 GPT-2 XL 的参数加载进来,再拿 Shakespeare 数据继续训练。符合“微调”的定义。
如果目标是:
学训练流程 -> 看 train_shakespeare_char
学怎么从零训大语言模型 -> 看 train_gpt2
学怎么在现成模型上继续训练 -> 看 finetune_shakespeare
我这次要学习训练流程,可以直接在cpu上运行,我会根据自己实践和理解,分为几个部分来总结,让大家也有个对模型逻辑性、结构性的认识。
一、从目录理解项目
可以把这个 nanoGPT 项目先粗分成 5 块:
——data/:准备训练数据
——config/:配置文件,不同训练/评估场景的参数模板
——根目录几个 .py:真正执行训练、采样、建模、配置解析、性能测试
——assets/:文档图片资源,一般前端静态资源文件,图片、音频、视频、字体
——.ipynb:实验/分析笔记,不是主流程
——train.py:主训练脚本。这是项目最重要的入口。
它负责:读取 train.bin / val.bin、按 batch 取数据、初始化模型、训练循环、评估 loss、保存 checkpoint,你可以把它理解成,整个训练流程的总控。
——model.py:GPT 模型定义。
它负责定义:token embedding、position embedding、Transformer block、self-attention、MLP
最后的输出层、forward 逻辑
从预训练 GPT-2 加载参数、你可以把它理解成:“模型长什么样”
——sample.py:推理 / 采样脚本。
它负责:加载你训练好的 checkpoint、或直接加载 GPT-2、输入一个 prompt、让模型继续生成文本
比如训练完 Shakespeare 后,用它生成新台词。
你可以把它理解成:拿训练好的模型出来说话,开始使用模型
——configurator.py:简易配置加载器。
它负责:读取 config/*.py、读取命令行里的 --batch_size=32 这种参数、覆盖 train.py 或 sample.py 里的默认值
你可以把它理解成:“把配置文件和命令行参数注入到脚本里”
——bench.py:性能测试脚本。
它不是正式训练入口,而是用来测:一轮前向/反向大概多快、GPU 利用情况如何、数据读取和模型计算速度如何,你可以把它理解成:“压测 / 跑分脚本”。
如果你是刚入门,建议按这个顺序看这几个文件:
data/shakespeare_char/prepare.py
config/train_shakespeare_char.py
train.py
sample.py
model.py
我们第一篇先看如何准备数据,data/这个文件夹
二、准备数据/数据预处理 (data文件夹)
data文件夹下有几个文件,在训练之前先跑 prepare.py文件,它在做的是数据预处理,还没有训练神经网络。
把人能读的英文文本,预处理成模型能高效读取的数字数据。模型本质上不会直接理解英文字符,它只能处理数字。
所以要先做这件事:读入原始文本 input.txt、统计里面出现过哪些字符、给每个字符分配一个编号、把整篇文本从“字符串”变成“数字串”、保存成训练时方便高速读取的格式、这一步可以叫数据预处理。
1. 读入原始文本
读入原始剧本文本,就是把 input.txt 整个读进来,得到一个超长字符串,比如概念上像这样:
"First Citizen:\nBefore we proceed any further, hear me speak.\n..."
注意这里不是“按句子”读,也不是“按单词”读,而是整个文件当成一个长字符串。
如果你把它拆成一个个字符,就会变成:
F, i, r, s, t, 空格, C, i, t, i, z, e, n, :, 换行, B, e, f, o, r, e, ...
这就是“字符序列”。
2. 建立“字符 -> 整数”的对照
模型不能直接吃 F、i、空格、\n,所以先要做编号。
假设文本里只出现这几个字符:
a, b, c, 空格
那就可能编号成:
' ' -> 0
'a' -> 1
'b' -> 2
'c' -> 3
在你的代码里:
stoi = string to integer,字符转数字
itos = integer to string,数字转字符
也就是:
stoi['a'] = 1
itos[1] = 'a'
为什么要两张表?
训练前:把文本转成数字,用 stoi
训练后:把模型输出的数字转回字符,用itos
为什么是 65 个字符?
因为这份 tiny Shakespeare 文本里,总共只出现了 65 种不同字符。
比如包括:
大写字母 A-Z
小写字母 a-z
空格
换行
标点符号如 , . : ; ? !
少量别的符号
代码里也打印了这个结果:
length of dataset in characters: 1,115,393
all the unique characters:
!$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
vocab size: 65
train has 1,003,853 tokens
val has 111,540 tokens
这里的 vocab size: 65,在这个字符级任务里,其实就是:这个模型只需要学会处理 65 种可能出现的字符。
把整本书变成数字串
比如原文是:
"hi!"
假设编号是:
'!' -> 0
'h' -> 1
'i' -> 2
那编码后就变成:
[1, 2, 0]
这个过程就是:encode("hi!") -> [1, 2, 0]
大模型处理文本的底层逻辑,这个过程想象成把人类语言翻译成机器密码,更偏向encoding(编码阶段),文本 到token ID(离散整数)Encoding=Tokenization+Numericalization (转ID)+Special Tokens (特殊标记)
(1)Tokenization(分词):切分
“把长句子切成小块”
这是第一步。模型不能一口吃成个胖子,它需要把连续的文本流切分成有意义的单元(Token)。
动作:根据规则(如空格、标点或 BPE 算法)将字符串切开。
输入:原始字符串
"Hi!"输出:字符串列表
["Hi", "!"]关键点:这一步还没有变成数字,只是把文本打散了。对于中文,可能是
["你", "好"];对于英文生僻词,可能是["Un", "believable"]。
(2)Numericalization(数值化):查表转 ID
“给每个小块发身份证号”
计算机只认识数字。分好词后,我们需要去模型的词汇表(Vocabulary)里查找每个 Token 对应的唯一编号。
动作:在字典中查找索引。
输入:字符串列表
["Hi", "!"]输出:整数列表
[101, 502](假设 "Hi" 的 ID 是 101,"!" 是 502)关键点:这就是你之前看到的
[1, 2, 0]的来源。这一步将语义符号变成了数学符号。
(3)Special Tokens(特殊标记):添加控制指令
模型不仅需要内容,还需要知道“哪里是开头”、“哪里是结尾”或者“这是两个句子中的哪一个”。这就好比写信要有“尊敬的”和“此致敬礼”。
动作:在数字序列的首尾或中间插入特定的控制 ID。
输入:整数列表
[101, 502]输出:
100可能是[CLS](开始标记,告诉模型:从这里开始读)102可能是[SEP](结束标记,告诉模型:这句话读完了)
关键点:如果不加这些,模型可能不知道句子在哪里结束,或者无法区分这是用户说的话还是 AI 说的话(在对话模型中尤为重要)。
| 步骤 | 名称 | 数据形态变化 | 说明 |
| 原始输入 | Text | "Hi!" | 人类看的文本 |
| 第一步 | Tokenization | ["Hi", "!"] | 切分成 Token |
| 第二步 | Numericalization | [50257, 30] | 查表变成 ID |
| 第三步 | Special Tokens | [101, 50257, 30, 102] | 加上 [CLS] 和 [SEP] |
| 最终结果 | Encoding | tensor([101, 50257, 30, 102]) | 变成张量送入 GPU 计算 |
所以,Encoding 是为了让模型能“读懂并处理”文本,而 Tokenization 只是其中负责“切分”的一环。
整本 input.txt 也是同理,只不过会变成一个非常长的整数列表,像这样:
[18, 47, 56, 57, 58, 1, 15, 47, 58, 47, 62,43, 52, 10, 0, ...]
这里每个数字对应一个字符。注意这里说“token”,在这个项目的 shakespeare_char 数据集中,token 就是一个字符。
所以:
一个字母 = 一个 token
一个空格 = 一个 token
一个换行 = 一个 token
一个标点 = 一个 token
这和很多大模型常见的“token 是词片段”不一样。
字符级切法是:
'T' 'o' ' ' 'b' 'e',所以一共 5 个 token。
但很多大模型不是这么切的,很多大模型会把文本切成“词片段”而不是单字符。比如:playing
可能不是 7 个字符 token,而可能被切成:play + ing,这就叫 subword token,也就是“词片段 token”。
所以同一段文本:在 shakespeare_char 里,token 是字符
在 GPT 这类常见 tokenizer 里,token 往往是词片段
有两层概念:
文本层面:你看到的是英文句子
模型层面:模型看到的是 token 序列,再进一步是 token 对应的整数 id
而你这个项目里刚好采用的是最简单的一种:字符级 tokenization 也就是: 先把文本拆成一个个字符,再给每个字符编号。
一个非常直观的对比
文本:
Hello, world!
如果按字符级:
H | e | l | l | o | , | 空格 | w | o | r | l | d | !
这就是 13 个 token。
如果按词片段级,可能会像这样:
Hello | , | world | !
那就只有 4 个 token。
3. 切成训练集/ 验证集
意思是:
前 90% 文本作为训练集 train_data
后 10% 文本作为验证集 val_data
为什么要这样?
因为你不能只看模型“背会了训练内容没有”,还要看它对没参与训练的数据表现怎么样。
验证集就是用来检查模型有没有真正学到规律,而不是死记硬背。
你可以粗略理解成:
train.bin:拿来学习
val.bin:拿来考试
4. 要保存成 train.bin / val.bin
这是很多新手最容易卡住的点。可能会想:既然已经有 input.txt,训练时直接读文本不行吗?
理论上可以,但效率差。因为训练会反复做下面的事情:
从数据里截取一小段连续内容
把字符转成数字,送进模型
如果每次训练一个 batch 都重新从文本解析字符,会很慢。
所以预处理脚本提前把它变成纯数字,并直接保存到二进制文件里:
train_ids.tofile(...)
val_ids.tofile(...)
这样后面训练时可以直接高速读取整数,不用反复做字符串处理。
5. meta.pkl 的作用
train.bin / val.bin 里只存了一串数字,比如:
[18, 47, 56, 57, ...]
但如果没有那张“对照表”,你就不知道:
18 是哪个字符?
47 是哪个字符?
所以还要额外保存 meta.pkl,.pkl就是Python 专用的数据或模型存档文件(二进制),里面有:
vocab_size
stoi
itos
这三个可以理解成:“这个字符级数据集的说明书 + 字典”。
vocab_size,意思是:总共有多少种不同的字符。
比如你的 Shakespeare 数据里,出现过:
大写字母、小写字母、空格、换行、标点
把所有不重复字符收集起来,一共有 65 个,所以:vocab_size = 65
它告诉模型:你最终要预测的候选字符,总共就这 65 种。
stoi
stoi = string to integer
意思是:把字符映射成整数编号的字典。
例如可能像这样:
stoi = {
' ': 0,
'!': 1,
'A': 10,
'a': 35,
}
作用是:把原始文本编码成数字,模型训练前先把字符转成 token id
比如:
stoi['a']# 可能得到 35
itos
itos = integer to string
意思是:把整数编号映射回字符的字典。
例如:
itos = {
0: ' ',
1: '!',
10: 'A',
35: 'a',
}
作用是:把模型输出的数字还原回字符,生成文本时把 token id 解码成人能读的内容
比如:
itos[35]# 得到 'a'
三者之间的关系
你可以把它们看成一套完整配套工具:
vocab_size:字典里总共有多少个字符
stoi:查“这个字符编号是多少”
itos:查“这个编号对应什么字符”
这相当于把“字典”也一起存起来了。这样以后你才能:把新文本编码成数字,把模型生成的数字再解码回字符。
6. 总结:简单理解数据预处理流程
原始 Shakespeare 文本就像一本英文书,模型看不懂字母。
于是你先做了一套“机器版翻译”:把每个字符编上号,把整本书翻译成数字,把数字存好,训练时直接喂数字给模型,训练完再把模型吐出的数字翻译回字符。所以这个预处理脚本本质上是在做:把“人类可读文本”变成“神经网络可训练数据”。
总结
prepare.py 做的不是“让模型理解莎士比亚”,而是先把莎士比亚文本变成一种统一、紧凑、可快速读取的数字格式,这样后面的 train.py 才能高效地训练“预测下一个字符”的模型。
三、后面的 train.py 如何用这些文件
后面的 train.py 会用内存映射(memmap)直接读这些文件,批量取连续片段。意思是训练时不会一次性把全部数据都完整塞进内存,而是像“按需读取”。你可以想象 train.bin 是一条很长很长的数字带子:
[18, 47, 56, 57, 58, 1, 15, 47, 58, 47, 62,...]
训练时,模型不会每次看全书,而是随机抽一小段,例如长度是 8:
输入 x:
[18, 47, 56, 57, 58, 1, 15, 47]
目标 y:
[47, 56, 57, 58, 1, 15, 47, 58]
可以把 x 和 y 理解成:
x:题目
y:标准答案
在语言模型里更准确一点:
x:模型当前能看到的输入序列
y:这个输入序列对应的“下一个 token”真实答案
也就是说:模型看到前面的字符,去预测“下一个字符”是什么。这是语言模型最核心的训练方式。
block_size 决定“每条样本有多长”,batch_size 决定“一次并行喂多少条样本”,block_size 的意思更像是:模型最多能看多长的上下文。不是说:每次必须刚好输入这么长。
[47] 可以预测下一个token
[47, 58] 可以预测下一个 token
[18, 47, 56, 57] 也可以预测下一个 token
只要长度不超过 block_size,原则上都可以。
为什么训练时总是切成 block_size,因为训练时要走 GPU 批量计算,固定长度最方便。
比如代码里会取:batch_size 条样本,每条样本长度都是 block_size,这样张量形状整齐,计算高效。所以训练代码里看到的是:
x.shape = (batch_size, block_size)
y.shape = (batch_size, block_size)
这是工程实现上的方便,不是概念上只能这么做。
为什么训练必须用连续片段,因为训练目标是让模型学会真实文本中的规律,比如:哪些字符常常连在一起、哪些单词后面常出现什么、句子通常怎么延续、这些规律都建立在邻接关系上,也就是“谁跟在谁后面”。如果你把上下文打乱了,模型就学不到真正的语言结构。即是连续切片,而非随机散点。
好啦,我们这次总结了一下数据集这块内容,下篇继续讲配置项,训练模型都需要配置什么内容。