NanoGPT Tutorial

来自WHY42

接触大模型已经大半年了,一直以来,都很想深入了解一下大模型神秘面纱的背后,究竟是何种面孔。 但通常跟大模型实现挂钩的还有两个让人望而生畏的东西:显卡和数据。 经常听说A100,V100,3090,4090,以及动辄几十GB甚至PB的数据,这些都不是每个人都能够承受的起的。

对于没有机器学习经验的开发者而言,去了解模型的训练细节其实是挺难的一件事。 幸好,通过nanoGPT项目, 我们每一个人都可以很容易在自己的笔记本上就能体会训练和运行GPT。

作为一个没有任何机器学习经验的小白,笔者花了几个小时就通过nanoGPT成功训练了几个没什么用但很有意思的模型, 因此把整个过程分享给大家,期望对您有所帮助。

我们的目标是,不需要掌握机器学习的知识,也不需要强悍的电脑, 就在自己的普通笔记本上,掌握训练和使用nanoGPT的方法。

本机环境准备

本文操作在MacBook Air(macOS 13.5.1,2020版,M1芯片)和OptiPlex 7080(Linux Mint 21.2)上测试验证, 无需本地显卡。 您也可以在其他的硬件和操作系统上运行, 或者使用在线的环境如Colab(后文会有介绍)等。

安装Miniconda 和Python

Conda是机器学习常用的一个软件环境,可以用来管理多个Python版本运行时。 但其本身过于臃肿,我们可以使用轻量级的Miniconda来管理本机的Python环境。 在MacOS下,可以通过以下脚本安装[1]

$ mkdir -p ~/miniconda3
$ curl https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-arm64.sh -o ~/miniconda3/miniconda.sh
$ bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3
$ rm -rf ~/miniconda3/miniconda.sh

安装完成后,可以使用conda命令来管理机器学习的Python环境了。 默认安装完成后,系统会自动创建一个Python3.11的环境:

$ python --version
Python 3.11.5
$ whereis python
python: /Users/riguz/miniconda3/bin/python

安装PyTorch

由于nanoGPT是依赖PyTorch的,因此首先需要安装它。 您可以使用pip或者conda来安装。 在Mac上运行时,建议使用nightly版本而不是稳定版, 因为nightly版本会集成Mac自带GPU的支持,可以使用GPU来加速。

$ conda install pytorch-nightly::pytorch torchvision torchaudio -c pytorch-nightly

下载nanoGPT

nanoGPT依赖于一些Python的软件包,在运行之前应该安装这些依赖项。 这里直接通过pip进行安装即可, 为加快下载速度,我们选择使用国内的清华大学pip源:

$ pip install torch numpy transformers datasets tiktoken wandb tqdm -i https://pypi.tuna.tsinghua.edu.cn/simple

接下来,将nanoGPT克隆到本地(注意这里检出的是笔者的fork而不是原版[2]):

$ git clone https://github.com/drriguz/nanoGPT.git

运行“莎士比亚”例子

为了测试nanoGPT是否能正确工作,我们可以运行它自带的莎士比亚剧本生成的例子。 该例子通过一个两三万行的莎士比亚剧本作为训练语料,该语料长这样:

First Citizen:
Before we proceed any further, hear me speak.

All:
Speak, speak.

First Citizen:
You are all resolved rather to die than to famish?

All:
Resolved. resolved.
...

运行这个例子分为三步:

  1. 准备数据,通过data/shakespeare_char/prepare.py脚本将语料加载到本地,并将数据切分为训练集和验证集,供训练使用。可以理解为,训练集是用来训练模型的,而验证集用来对模型的效果进行评测。
  2. 通过train.py进行训练
  3. 训练完成后,通过sample.py推理并输出生成结果

准备数据非常简单,直接运行prepare.py即可。 该脚本会将数据拆分成两部分,一部分用来进行训练(90%)、剩余的部分用来做验证(10%), 并将原文进行简单的编码,穷举所有字符并制作一个映射表通过序号进行映射,最后生成包含这些编码序号的文件train.bin和val.bin:

$ cd nanoGPT
$ python data/shakespeare_char/prepare.py
length of dataset in characters: 1,115,394
all the unique characters:
 !$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
vocab size: 65
train has 1,003,854 tokens
val has 111,540 tokens

数据准备完成后,可以开始真正的训练过程。由于MacBook上没有支持CUDA的显卡,只能使用CPU训练(注意--device=cpu参数):

# 如果在不支持CUDA的设备上运行会报错:
# $ python train.py config/train_shakespeare_char.py
# raise AssertionError("Torch not compiled with CUDA enabled")
python train.py config/train_shakespeare_char.py --device=cpu --compile=False --eval_iters=20 --log_interval=1 --block_size=64 --batch_size=12 --n_layer=4 --n_head=4 --n_embd=128 --max_iters=2000 --lr_decay_iters=2000 --dropout=0.0

...
step 2000: train loss 1.7640, val loss 1.8925
saving checkpoint to out-shakespeare-char
iter 2000: loss 1.6982, time 306.45ms, mfu 0.05%

随着一次又一次的迭代,train loss(训练损失)会逐渐减少,这说明训练起到效果了。 整个训练过程需要大概1分钟的时间。 训练结束后,就可以运行sample.py来查看效果了:

$ python sample.py --out_dir=out-shakespeare-char --device=cpu
Overriding: out_dir = out-shakespeare-char
Overriding: device = cpu
number of parameters: 0.80M
Loading meta from data/shakespeare_char/meta.pkl...

I by doth what letterd fain flowarrman,
Lotheefuly daught shouss blate thou his though'd that opt--
Hammine than you, not neme your down way.

ELANUS:
I would and murser wormen that more?
...

该脚本会运行模型并生成10次数据。 从形式上来看,生成的结果是令人满意的,至少看起来像那么回事。

至此,我们已经成功训练了一个nanoGPT模型,是不是很简单! 理论上如果通过GPU训练会更快一些,但是在MacBook上测试通过mps(--device=mps) 训练的时间与CPU几乎相当。

训练一个中文的nanoGPT模型

从莎士比亚的例子可以看出,nanoGPT训练的过程实际上十分简单,只需要一段文本,就可以从中学习到一些知识。 本质上,这是个数学游戏而已,模型学习的是“概率”,即文字与文字之间的关系。 比如,当我们说“今天天气真”的时候,你会不自觉就联想到“真好”,“真不错”,“真晴朗”等等。

nanoGPT也是一样,训练的过程实际上是在从一堆知识中学习这些规律, 从而可以预测下一个词的概率。 对于模型来说,所有的内容不过是个概率问题而已; 但是对于我们使用者而言, 就好像这个模型真的“理解”了一样。

上面莎士比亚的例子中, 给模型学习了莎士比亚的剧本,模型看起来就“学会”了写这样的内容。 那么,如果我们用一些中文的内容, 模型能够学会中文么?

学习一下启蒙读物《三字经》?

看起来nanoGPT学习能力还不错, 但是大多数评论都说中文比英文更难, 为了降低难度,不欺负nanoGPT, 我们先让它学习一下中国三岁小孩的启蒙教材《三字经》吧。

这里我们仿照莎士比亚剧本的例子的做法, 把训练数据换成三字经:

人之初
性本善
性相近
...

我们期望nanoGPT学习了这些数据之后, 起码能生成像每句话三个字的“三字经”。 使用同样的参数训练一下试试:

$ python data/sanzijing_char/prepare.py
$ python train.py config/train_sanzijing_char.py --device=cpu --compile=False --eval_iters=20 --log_interval=1 --block_size=64 --batch_size=12 --n_layer=4 --n_head=4 --n_embd=128 --max_iters=2000 --lr_decay_iters=2000 --dropout=0.0

...
iter 1998: loss 0.0437, time 23.68ms, mfu 0.06%
iter 1999: loss 0.0535, time 23.63ms, mfu 0.06%
step 2000: train loss 0.0445, val loss 8.9908
iter 2000: loss 0.0524, time 331.35ms, mfu 0.05%

经过两千次迭代后,训练完成,整个过程也是非常快。 接下来运行一下看看效果:

$ python sample.py --out_dir=out-sanzijing-char --device=cpu
...

宇文周
与高齐
迨至隋
一土宇
不再传
失统绪
唐高祖
...

看出问题了么?模型生成的的确”像“是三字经,但这根本就是原封不动的三字经! 也就是说,模型并没有“生成”我们想要的内容, 更像是在“背诵”我们训练时的语料。

在机器学习中,这种现象叫做“过拟合(overfitting)”,当模型尝试“记住”训练数据而非从训练数据中学习规律时,就可能发生过拟合。

通常,出现这种问题是由于数据集过小导致的。我们的三字经总共就三百多条数据(机器学习几百条的数据量一般认为是很少,几千到几万可以算中等[3]),由于数据量太少而导致过拟合。 过拟合时,从训练的损失上也可以看出端倪,train loss不断收敛,但是跟val loss差别很大:

step 2000: train loss 0.0445, val loss 8.9908

这样训练出来的数据,缺乏泛化能力。也就是说, 对于训练中出现的数据能够给出结果, 但是如果是没有出现过的,就不能得到结果了。 为了验证这个结果, 我们可以修改sample.py运行时的start参数,该参数用来控制模型根据什么开头进行续写:

$ python sample.py --out_dir=out-sanzijing-char --device=cpu --start="养不"
...
养不教
父之过
教不严
...
$ python sample.py --out_dir=out-sanzijing-char --device=cpu --start="养要"
...
养要
记其事
五子者

可以看出,如果是出现过的字,模型能够准确“背出”内容;但对于没有见到过开头,就不知道怎么回答了。

不听不听,和尚念经

从前面的例子看出,大模型的学习方式跟人类还不一样。我们学习时,期望内容越少越容易记住; 但大模型缺与之相反:内容越多反而越容易学会。

由于三字经实在是内容太少,通过这几百条数据无法训练成一个会写三字经的模型,还会出现过拟合。 如果使用更多的数据来训练,比方说来个几万几十万条,是不是就可以解决这个问题呢?

但现在问题来了, 有什么题材可以比较容易找到很多的数据呢?

经过一点调查,笔者发现佛经是个不错的选择: 一方面佛经很啰嗦,动不动就几十卷; 一方面又很晦涩,就是看也很难看懂,可能甚至比看莎士比亚的英文剧本更难理解。

于是,我们拿《大般若波罗蜜多经》这部佛教中最长的佛经来训练一下试试。 该训练数据共五万多行,合计约五百七十万字。足够大了!

$ python data/boluojing_char/prepare.py
$ python train.py config/train_boluojing_char.py --device=cpu --compile=False --eval_iters=20 --log_interval=1 --block_size=64 --batch_size=12 --n_layer=4 --n_head=4 --n_embd=128 --max_iters=2000 --lr_decay_iters=2000 --dropout=0.0
...
step 2000: train loss 1.5015, val loss 3.3029

这次,训练完成后,train loss和val loss就比较接近了。 然后我们运行测试一下效果:

$ python sample.py --out_dir=out-boluojing-char --device=cpu
...
    “不也,善现。”
  “即四念住、四正断、四神足、五根、五力、七等觉支、八圣道支无二分故。”
  “世尊,预流果无生不二。”
  “善现,一切智智无二言定,无二为方便、无生为方便、无所得、无生为方便,回向一切智智,修习五眼、六神通。”
  “世尊,云何以五眼、六神通无二为方便、无生为方便、无所得为方便,回向一切智智、道相智,修习六神通,修习八圣道支?”
  “庆喜,云何菩萨摩诃萨行无忘失法无二无二为方便、无生为方便、无所得为方便,回向一切智智智,修习佛十力、四无所畏、四无碍解、大慈、大悲、大喜、大舍、十八佛不共法、一切法。”
  “庆喜,以四无所畏、四无碍解、大慈、大悲、大喜、大舍、十八佛不共法性空与外空、内空、空、大空、胜义空、有为空、无为空、毕竟空、无际空、散空、无变异空、本性空、自相空、共相空、一切法空、不可得空、无性空、自性空、无性自性空清净,外空乃至无性自性自性空清净故法界清净。何以故?若一切智智清净,若法界乃至不思议界清净,若道相智、一切相智清净,无二、无二分、无别、无断故。
  “善现,一切智智清净故一切相智清净,一切相智清净故无性空清净。何以故?若一切智智清净,若无
$ python sample.py --out_dir=out-boluojing-char --device=cpu --start="如是我闻:一时,佛在舍卫国祇树给孤独园,与"
...
如是我闻:一时,佛在舍卫国祇树给孤独园,与善男子、善女人圣过生人、无为智不染著,或不?”
  “善现,是善男子、善女人等,诸有情毕竟中,不获福聚无量无边有,无数无增语是菩萨摩诃萨不见余菩提,亦不见无所有,亦不见有唯有唯有有所得故人中,法界、法界、意识界,亦不见有见可得、不见识界及身触、意触为缘所生诸受乃至身触为缘所生诸受无染有性故,名为名四念住、四正断乃至十后、中际谓无所有性。
  “善现,如是,善现,如见诸佛如来、应、正等觉、正等觉亦无相,无所有不见。何以故?眼界性空法界、无愿故。色界等无所有故。眼识界及眼触为缘所生诸受无我亦无所有故,受、想、行、识;声、香、味、触、法处无所有故,当知四念住亦无所有;声、香、味、触、法处故,当知作意,当知作意;无明无所有,当知无所有故,善现,当知是为菩萨摩诃萨不作者,亦无所有;法亦无所有故,当知般若波罗蜜多,当知般若波罗蜜多亦无所有。
  “善现,菩萨摩诃萨若善,亦无所有故,当知般若波罗蜜多亦无所有,水、火、风、空、识界无所有故,菩萨摩诃萨行般若波罗蜜多亦无所有;一切菩萨摩诃萨所行般若波罗蜜多,净戒乃至般若波罗蜜多亦无所有;水、火、风、空、识界无所有不可得,声、香、味、触、法处菩萨摩诃萨无所
...

与之前的三字经不同,这次生成的“佛经”不仅看起来像那么回事, 其中很多句子都是训练的原文没有的,是nanoGPT编出来的。

但是,对于生成的结果的好坏,我们难以评判。 也许这些句子是毫无意义的组合而已,但是对于普通人,由于缺乏相关的佛学知识而难以判断。 对于这些生成的看不懂的句子, 也不知道是自己不理解,还是模型生成的不正确。

这样训练出来的模型大概率只能用来唬人了,可以用它去考考别人了!

续写《水浒传》

如果把佛经换一下,换成小说,我们就很容易可以判断出生成的结果如何了。 以《水浒传》为例,全文共计四千多行,九十多万字,我们拿它来训练一下:

$ python data/shuixu_char/prepare.py
$ python train.py config/train_shuixu_char.py --device=cpu --compile=False --eval_iters=20 --log_interval=1 --block_size=64 --batch_size=12 --n_layer=4 --n_head=4 --n_embd=128 --max_iters=2000 --lr_decay_iters=2000 --dropout=0.0
...
step 2000: train loss 4.2661, val loss 4.8593

训练2000步后,train loss下降到4.2661,相对于之前的训练来看这个数值有些偏高。 先不管这些,现在模型学习了《水浒传》,能否写出类似的小说段落呢? 一直好奇如果武松来押送生辰纲会是什么效果,现在可以来生成一下试试了:

$ python sample.py --out_dir=out-shuixu-char --device=cpu --start="武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下"
...
武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣来,一发算钱。”竟抢先要下山来,便道:“这个去说。”阮小二道:“你们相见他。”那妇人只见了一个头,就那里飞入房里,那里取出来酒来。李逵又去看时,那大汉庄上马上见他。背后坐在武松歇下。晁盖道:“你既是叔叔在县里,何不敢归。”只听得茶讨了。王婆娘道:“你且不是不得。”晁盖道:“小人便是这厮们。”店府道:“我自和道:“却有百姓知,有几个教小人说。”
且说当日是两个个都是火的门外里有。二人一日从人中间,出身前来到几个村坊库,自上把手来的,又会了船只一个,便走!李应也把武松,那汉,却是两个去的人出。李逵大听得山寨,便叫道:“小人莫不曾头,且不知!我也不可要来,你知去借了。”,只见了一十五句,早要行不得。”石秀叫道:“押司宋江,不知闲便拜让,他做甚为父。”王伦答道:“兀自去甚事!”酒保道:“小人都是个是这话。”山边山寨,将过过一路。四十数日戴宗,便向前面,下墙边一个不在房。李逵道:“你必是我。”王婆道:“这个是这句话,却不得。”原来到这那婆子里说道:“你却是不要说,昨夜不须!便是五阮哥哥哥娘的?”宋江道:“好好来去走,怎生要走有个。”吃了的也,不要走,只见得衣服却不要寻。杨志便禀道:“你不肯起甚么?”正说道:“嫂嫂两个土兵
...

容易看出,这生成的结果完全不通、乱七八糟,前言不搭后语。 接下来,我们来尝试优化一下效果。

尝试提升迭代次数

由于模型通过梯度下降的方式不断迭代来训练最优参数, 那么迭代次数越多,应该下降越多,模型参数越优。 我们试试提高训练的步数,从2000步提高到20000,让其损失进一步收敛:

$ python train.py config/train_shuixu_char.py --device=mps --compile=False --eval_iters=20 --log_interval=1 --block_size=64 --batch_size=12 --n_layer=4 --n_head=4 --n_embd=128 --max_iters=20000 --lr_decay_iters=20000 --dropout=0.0
...
step 20000: train loss 3.1365, val loss 4.5919

经过20000步训练之后,损失降低了一些,再来试试效果:

$ python sample.py --out_dir=out-shuixu-char --device=cpu --start="武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下"
...
武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣来,一发算钱。”竟抢先要下一刀来,和那妇人说道:“我只怕你武头陀直吃了去,不去取笑!”那妇人道:“你猜个不说,我自在在家里,只是如今来却和你好也,也吃他口骂我!我若是你去倒来,你也不还我!”那妇人便道:“郓哥与你说话。小人是个干娘,你却不知来,如何不来欺负他!”武松再笑起来劝他。武松把两口刀来筛了,说道:“我只道是这个罪人,老爷少也不也。”那妇人道:“一个真人真个是谁?”武松道:“三个酒肉吃了一杯,也热了,便去换些香味去里面。”武松道:“这个人姓甚么?”那妇人答道:“主人,我家道个不曾有一个。前日,买了酒果儿送酒归来与我吃了,却暗地下来?”那妇人道:“你这个道童,你来,对我说了。听我叫道:‘我和你押司说么?”李逵道:“也须是。好教他作事,我不得你,须每这些酒食与你吃了些枪棒,提条好去。”婆子道:“你若是他,不怕他,且我如何不叫他有些和他?却怎地戏弄我?”那汉喝道:“你是甚么官人便来。我写一封书,却要使人去店里卖炊饼出卖与你?”武松道:“恁地时,怎地?”那卖话正卿吃得动,只见那和尚入来,便叫道:“大郎已不要吃酒,快去取来,小人便问人家里取笑话。”那妇人便道:“叔叔恁地说谎。”那妇人道:“这个兄弟,又没酒吃,这几日

这个效果很难说好,也很难衡量它与之前的生成结果的好坏,但是直观感觉就是都很烂。 看起来,我们的训练遇到瓶颈了。

提升模型参数并使用GPU训练

由于我们之前在训练时指定了使用较小的模型参数以便减少计算量(例如n_layer=4 --n_head=4 --n_embd=128), 对于神经网络而言,通常层数越多模型越复杂。 如果把模型参数提升一下,是否能够得到效果上的提升呢?

nanoGPT示例中给出了一个“baby GPT”参数值,相比于现有的参数稍微有些变化,比如层数从4层提高到了6层:

# baby GPT model :)
n_layer = 6
n_head = 6
n_embd = 384
dropout = 0.2

我们尝试使用这些参数再来训练一次。 但是这一些参数的提高,带来的是计算量的急剧增加。 使用CPU训练时, 这个过程需要数小时才能完成。

为了更快能够验证结果, 我们需要使用GPU来进行训练。 我们没有GPU怎么办?不要慌,有很多免费的GPU训练平台可以使用,例如,Google的Colab就是之一。

在Colab中,新建一个记事本,并将运行环境设置为GPU("代码执行程序“->"更改运行时类型")。 Google对于免费用户提供了Telsa T4的GPU选项,其显存为16G, 已经是个很不错的GPU了。


Colab默认就支持Python环境,因此对于我们的训练来说,省去了安装Python的烦恼,开箱即用。不过,我们先确认一下GPU是否真的能用:

!nvidia-smi
import torch
torch.cuda.is_available()

注意这是一段shell和python混排的命令,以“!”开头的为shell命令,其他为python脚本。 如果配置正确,就可以检测到T4 GPU,并且torch.cuda.is_available()返回结果为True。

Tue Dec 12 05:09:25 2023       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 525.105.17   Driver Version: 525.105.17   CUDA Version: 12.0     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   42C    P8    11W /  70W |      3MiB / 15360MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+---------------

接下来,我们可以从Github导入已经写好的代码并开始训练,这个过程跟在本地用CPU训练类似,仅运行参数稍有差别(不需要指定--device=cpu):

%%shell
git clone https://github.com/drriguz/nanoGPT.git

# see: https://github.com/pytorch/pytorch/issues/107960
ldconfig /usr/lib64-nvidia
cd /content/nanoGPT
git pull
pip install torch numpy transformers datasets tiktoken wandb tqdm --use-deprecated=legacy-resolver

python data/shuixu_char/prepare.py
python train.py config/train_shuixu_char.py

使用GPU后,训练在数分钟即可完成,最终train loss下降到0.6535,但val loss仍旧高达5.5836, 说明模型仍然还是存在过拟合的问题。

step 5000: train loss 0.6535, val loss 5.5836
iter 5000: loss 1.3843, time 13585.75ms, mfu 3.12%


且先看一下效果如何:

!python sample.py --out_dir=out-shuixu-char --start="武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下"
...
武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下认得,却问道:“客官人去来与我吃酒?”主人道:“小人不要客人,不敢问恕。”武松道:“不是吃些酒肉吃,且罢,如何却来到这里?”武松道:“你待怎地?”那人舀一桶吃两桶酒,又荡四分酒来。武松问道:“端的只人家,再来!客官要斟酒来?”那人道:“客官,你休说,我身三碗酒。”武松道:“客官不是,我不要吃,我吃三碗来,倒不卖?”那汉道:“你是甚么鸟人家,我不还。”那汉道:“我我店里有‘不要酒,你再来也是。”武松道:“我自也不信时,不要吃酒,你兀自买酒吃荤,都吃了些吃了,再来对我说道:‘这碗酒得酒吃,你们又吃了便来。’我哥哥哥道:”我自去外面坐了。武松拿起来,插了,望武松道:“小人不早起来,你且请酒来。”武松道:“好大哥,休要吃早吃。”酒家道:“我这早晚来,不要吃了!”酒家道:“你且和哥休要吃!”仆家道:“大哥,你休要我便罢,我自去打火。”武松道:“你且来说武松时,这酒再来吃。”酒保道:“你莫请我去。”酒家又筛酒,一发碗,一盘子相劝酒。武松道:“我自吃不醉,不要,醉不醉倒,吃得吃了不得!你若不醉,我还你不来,老爷又不知道你,我却不要说信,就还你。”那妇人道:“你饶你这酒钱,你这等甚是好意!我与你些钱!
...

尝试改善模型效果

前面做了一些优化工作来提升模型效果,但不是很明显,老实说只能感觉都很烂,但要比谁更烂,说不上来。这些结果都有很明显的问题, 例如,从生成的文本中很容易找到很不通顺的地方,完全没有什么逻辑可言。

现在,我们来想办法优化一下效果。 从上面的GPU训练情况来看,模型仍然有过拟合的表现:train loss收敛但val loss很高。 通过对训练日志进行分析,可以将train loss和val loss可视化绘制出来,得到一张这样的图:


从图上可以很明显看出,val loss在500次迭代后不降反升,这是很明显的过拟合情况了。 为了改善模型效果,现在有一些简单的办法可以考虑:

  • 使用更多的语料。除了《水浒传》,还有一些风格类似,故事情节有关联的小说,例如《荡寇志》等也一并收集起来进行训练
  • 优化分词算法。目前为止仍然采取的按照字符分词的方式,对于中文而言可能存在问题,一个句子的基本组成单位是“词”,而不是“字”

选取了《荡寇志》、《残水浒》、《金瓶梅》等其他水浒周边的一些小说,训练数据从2.8Mb提高到了15.3Mb,大概提高了5倍。 同时,我们使用GPT2的词表来进行编解码:

enc = tiktoken.get_encoding("gpt2")
train_ids = enc.encode_ordinary(train_data)
val_ids = enc.encode_ordinary(val_data)

然后使用新数据集,重新训练5000步:

step 5000: train loss 1.9272, val loss 1.9829
saving checkpoint to out-shuixu-full
iter 5000: loss 2.1698, time 17303.27ms, mfu 2.70%

这一次训练后,train loss和val loss收敛得比较好:

再来看一下效果上有什么变化:

!python sample.py --out_dir=out-shuixu-full --start="武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下"
...
武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下坐。正走近李俊来,见有人问,时迁走到店里,那人叫道:“贼猛,你有甚人走的?”智深骂道:“张顺!我怎地走间?”李应道:“你走了得!”李俊道:“且慢慢走,谁知他是本州捕呢。”李应道:“你这厮道是谁?”李俊道:“小人是好汉,这小人是个姓名,你不是俺的便是,快走。”李俊大怒,把李俊引入寨,骑一匹马来报拜。智深把李俊放了,拈弓在箭上,准备天锡。李俊两个家骑马,李俊一齐都点马,全在后面。那数路戴宗、李俊众人只顾马,带领兵报进城。宋江在公孙胜边,把八百名骑
---------------
武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下山去。
   那日宰柳衙内走到那里,只见篇簪圈,一个小小小节,一个郎穿着一匹红虎袍,拿了两条袖,一顶绿紫绣袍:素文儿,纱银袍[皮僧][交与西门庆]着酒,整顿大红红钗银钉,一个白带裤子,用托来,系叶锦牛儿筛,来到蜡多少虫儿,如何做出来?就是一般没廉耻的。这春梅见他走在房里,只得把做些胎笼拶包放在地面前。然后边月娘如意儿上,就问:“娘来,你甚么不是。”于是上香来拿着张胜,解与玳安,把西门庆那边有几银子,摆在旁边。蕙莲道:“这
...

很容易看到两个变化:

  • 语料对模型的影响——这生成似乎是盗版的水浒了,第二段的风格不忍直视
  • 仔细体会可以感觉到,折回生成的内容“通顺”了一些。

进一步调整模型参数,增加到8层并将block_size调整到512后进行训练:

batch_size = 16
block_size = 512
n_layer = 8
n_head = 8
n_embd = 768
dropout = 0.2

再来训练5000步并生成结果:

step 5250: train loss 1.8338, val loss 1.9226

...
武松道:“不要问,只管用瓢舀来吃酒。贩枣的客人那里,买三五斤枣 来,一发算钱。”竟抢先要下来探望着来。
   太师借了李师师,听了,只见李师师师,回到太师府里。蔡京回到燕青,走近前,走出一步,只见燕青道:“这个山阳,却是我两个手挝,你如何来得?”只见童子长挺枪,只见丫鬟长枪,口里挺枪枪,提着朴刀,指着朴刀,仰天灵咬,一阵无血。又力挺,直取脚,把手拨开,骂道:“贼王抬举,便杀过来!”燕青把红枪呼唤,一刀一枪,从马后搠将来。燕青只一箭,飞起双斧,拨开八十合的黑汉,把那枣男儿挺枪拨强架起,打透了马。又是锦娘,三

这一次,经过5250步后train loss降低到1.8338,看起来不错;但是现在生成的结果已经无法直视了:p,感觉还不如上一次的结果。 由此可见调模型结果是一件很复杂的事情, 尤其对于笔者这种新手小白来说就好像是开盲盒一样,不知道接下来应该怎么试了。 由于精力有限,对于编造水浒传的测试就只进行到这里罢。

fine-tune一个Chuck Norris Facts生成器

除了从零开始训练外,nanoGPT还支持使用预训练的模型进行微调。 由于它本身是用英文语料训练的,要想微调有效果,只能使用英文的数据集了。 Chuck Norris是李小龙时代的人物(他们合拍过一部著名的电影《猛龙过江》),关于他有很多奇奇怪怪的“事实”[4], 例如:“Chuck Norris不需要呼吸空气,他让空气呼吸他”,“Chuck Norris告诉过一次谎,从此所有事实都成真”。 我们将训练nanoGPT,让它也能够编造一些Chuck Norris的“事实”。

准备数据

不同于从零开始训练,微调(fine tuning)是在已经训练好的模型基础上使用少量数据集、少量算力的情况下达到期望效果的一种方式。 首先,我们通过chucknorris.io收集大概1500条数据,用一个简单的脚本即可:

for i in range(0, 1500):
    joke = requests.get('https://api.chucknorris.io/jokes/random').json()
    print(joke['value'])
    with open('input.txt', 'a') as f:
        f.write(joke['value'] + '\n')

微调nanoGPT

微调nanoGPT与普通训练差别不大,仍然是将数据集拆分, 然后执行训练脚本即可。 由于是在预训练的模型上进行微调,我们需要选择一个底座模型作为基础。 由于T4 GPU显存有限,我们这里最高只能选择到gpt2-mediu, 更高的模型(gpt2-large, gpt2-xl)可能就微调不了了:

init_from = 'gpt2-medium'

微调时,所需的迭代次数也可以少很多,比如20步:

batch_size = 1
gradient_accumulation_steps = 32
max_iters = 20

然后像之前的训练一样执行train.py脚本训练即可:

!python train.py config/finetune_chuck_norris.py
...
step 20: train loss 2.4478, val loss 3.3339
iter 20: loss 2.2979, time 11276.48ms, mfu 2.80%

整个过程一两分钟就可以完成。训练完成后,train loss收敛到2.4478,但是val loss仍稍高, 这个问题也有其他人遇到[5], 但是生成的效果还可以,语法通顺,还有一些挺有意思的段子, 截取了一些觉得不错的:

!python sample.py --out_dir=out-chuck-norris --start="Chuck Norris tried PHP and"

Chuck Norris tried PHP and doesn't know what the hell it does?
Llama Fucker is dead
Chuck Norris tried PHP and he got fired.
Chuck Norris tried PHP and escaped to the outside world.
Chuck Norris saved a programmer when ____ was being written in Fortran.
Chuck Norris saved a programmer when he spotted a bug in his code 
Chuck Norris saved a programmer when  his employees were about to write a program. It was  the last thing a human would ever do.)

这里就可以体现出微调的好处了。由于有大量数据训练的底座模型,我们不再需要在微调数据中教给大模型基本的语法知识, 只需要提供一些我们期望的内容来改变生成结果的风格。 这样,生成出来的结果不仅语法通顺,效果上也符合我们的预期。

总结

我们通过nanoGPT和Colab, 一分钱没花就体验了一把训练大模型的感觉, 体验真棒!

GPT2使用无监督和大量语料来训练语言模型的方式[6], 让我们无需对数据进行分类标注, 只需要简单把文字整到一起就能进行训练出一个”理解“语言的模型, 让人叹为观止。 可以说, GPT2为现在的大模型奠定了基础。

nanoGPT的作者是前特斯拉AI总监,Andrej Karpathy,也是李飞飞的学生。 nanoGPT,是他2年前MinGPT的升级版。 不得不说,感谢作者开源了这么有意思的项目, 让每一个人都可以很容易在自己的笔记本上体验训练GPT的感觉。

nanoGPT已经发布有一段时间了,这期间各种各样的大模型风起云涌,例如GPT-4、Llama、百川等等, 但nanoGPT仍然是笔者尝试过的学习语言模型的最简单的项目。 对于非AI模型算法的开发者而言, 通过它了解一些大模型的底层原理, 也会对使用大模型起到帮助作用。

当然,nanoGPT的功能也是有局限的, 它其实并不是一个真正的GPT,因为它不具备对话的功能, 只能进行续写补全。 但这并不影响它成为一个值得去玩的项目, 除了本文的玩法外, 网上还有一些其他的玩法也很有意思,比如训练诗词生成器、生成鲁迅风格的内容等。

文中所有的代码都可在Github仓库中找到, 文中还使用了殆知阁古代文献数据集中水浒传和佛经相关的训练数据。 受限于笔者的水平,文中错误之处在所难免,欢迎读者不吝赐教。