玩了下transformer
最近因为产品上的一个想法,决定尝试下能否用transformer来实现。 于是专门抽出了块时间仔细的写写代码玩了一下。其实产品上想法的验证是一方面,最主要还是GPT之类的火了这么久,之前虽然简单地了解过背后的原理技术,心里总是想找个时间写代码跑跑训练小模型试试,几个原因叠加在一起吧,最近也是难得抽出来一大块时间,仔细地跑了一下。
俗话说得好,纸上得来终觉浅,绝知此事要躬行。只有自己真的尝试去实现了,才能更深刻的把握其中的原理细节,搞清楚为什么要这样设计,发现当中的细微精妙之处,才能更好的去扩展应用和改进。 我这段时间不停的写代码调试训练的过程中,大脑中不断地不自主的索引到这句话,感触颇深。
另一个方面,自从毕业以后,游戏和图形图像相关的技术搞得比较多一点,NLP之类的就基本没怎么搞过了,很多新技术都没有具体的去了解过,也是趁着这次机会详细的又深入学习研究了一遍。
整个过程差不多花了半个多月的时间,超出了我当初打算投入的时间不少,大部分都花在了研究一些技术的细节上面,需要阅读很多资料文章甚至论文才能彻底搞明白。不过回头评估一下,我其实是很开心的,并没有觉得时间被浪费掉,而是很强的获得感。
好了,下面进入正题。
既然是测试评估,那就要写一个程序来玩玩,什么样的程序呢? 因为transformer最早是被用来解决机器翻译问题的,所以我用来试水的代码也是一个翻译的程序,比如把汉语翻译成英语,这是很正常的想法,我也确实这么干了,使用大量的英语汉语对照翻译的句子来对这个模型进行训练,翻译的效果看起来还不错,可以看后面的几个测试输出。
然后,很自然的,我顺着就想,能否直接把汉语现在的白话文翻译成古代的文言文呢?这个也挺有趣的。
于是说干就干, 直接找了一堆古文和现代文对照翻译的语料库。 主要是一堆《史记》《汉书》之类的史书。都是类似下面这样的句子,有古文和现代的对照翻译。
古文 |
现代文 |
子能以燕伐齐,则寡人举国委子。 |
假如您能以燕国现有的力量讨伐齐国,那么,我愿把整个国家托付给您。 |
祸与福同,刑与德双。 |
灾祸和幸福同在,刑罚和赏赐相成。 |
其后诸侯共击楚,大破之,杀其将唐眛。 |
在此之后,各诸侯国联合攻打楚国,大败楚军,杀死了楚国大将唐眛。 |
秦将章邯破杀项梁也,沛公与项羽引而东。 |
这时秦将章邯打败项梁的军队,杀死项梁,沛公与项羽率军东归。 |
禹曰: 予娶涂山,癸甲,生启予不子,以故能成水土功。 |
对这种人我决不听之任之。 禹说: 我娶涂山氏的女儿时,只经四天婚期就又去治水了,我的孩子启从生下来我未曾抚育过,所以才能使平治水土的工作取得成功。 |
整本史记,古文和现代文的对照大致有三万行左右,我用的数据并不是很精准,比如上面的对照翻译中,最后一个现代文,第一句“对这种人我决不听之任之。”显然是错误的,应该删除掉,但是整个训练的语料是在是太大,全部去手动改不现实,毕竟是玩玩,凑合着用也不太影响。
不过既然都写代码训练了,处理加载语料的时候顺手统计打印了一下史记里面每个字出现的次数,下面这个表是前一百个频率最高的字,按降序排列,每个字后面跟的数字是总共出现的次数。
1 |
, |
55459 |
。 |
29482 |
之 |
13308 |
|
11163 |
王 |
8118 |
不 |
7612 |
以 |
7443 |
为 |
7338 |
9 |
子 |
6561 |
而 |
6411 |
曰 |
6005 |
其 |
5470 |
: |
5309 |
于 |
5083 |
人 |
4705 |
公 |
4670 |
17 |
也 |
4505 |
者 |
4191 |
、 |
3778 |
年 |
3335 |
有 |
3202 |
十 |
3149 |
大 |
3136 |
秦 |
3062 |
25 |
下 |
2813 |
侯 |
2788 |
与 |
2642 |
天 |
2453 |
使 |
2426 |
将 |
2397 |
乃 |
2376 |
是 |
2324 |
33 |
后 |
2323 |
君 |
2284 |
齐 |
2255 |
太 |
2214 |
上 |
2203 |
臣 |
2189 |
三 |
2132 |
国 |
2106 |
41 |
所 |
2057 |
楚 |
2052 |
二 |
2026 |
相 |
2025 |
立 |
2010 |
至 |
1985 |
军 |
1979 |
无 |
1908 |
49 |
得 |
1897 |
兵 |
1888 |
中 |
1880 |
; |
1879 |
故 |
1792 |
赵 |
1744 |
诸 |
1685 |
夫 |
1632 |
57 |
? |
1589 |
言 |
1578 |
帝 |
1576 |
自 |
1574 |
事 |
1521 |
欲 |
1514 |
一 |
1440 |
可 |
1423 |
65 |
汉 |
1421 |
五 |
1406 |
皆 |
1403 |
卒 |
1399 |
行 |
1382 |
余 |
1335 |
阳 |
1324 |
此 |
1313 |
73 |
则 |
1309 |
见 |
1281 |
矣 |
1279 |
今 |
1273 |
出 |
1268 |
死 |
1268 |
时 |
1258 |
入 |
1256 |
81 |
能 |
1229 |
从 |
1213 |
东 |
1205 |
何 |
1191 |
如 |
1190 |
生 |
1181 |
魏 |
1177 |
闻 |
1176 |
89 |
四 |
1161 |
及 |
1144 |
地 |
1126 |
令 |
1120 |
周 |
1112 |
杀 |
1100 |
百 |
1091 |
长 |
1078 |
之字出现的频率高是很正常的,但我没有想到王字出现的频率居然这么高。 整体来看,王侯将相出现的频率跟之乎者也差不多了。也难怪一代目会感叹说,二十四史写的都是帝王将相。
至于代码实现上面,我写了好几个不同的翻译模型实现,一个是用lstm,另一个是lstm加attention机制,还有一个是gru+attention,最后一个是transformer的实现。
使用transformer模型的代码总共有五六百行,我就不贴出来了,毕竟没几个人愿意看。就是一个经典的seq2seq模型,或者说encoder decoder程序,搞过机器翻译的应该很容易理解,自己动手写也不难。
先看看单纯lstm的输出,我只使用了一层并没有堆叠,训练数据量很少只有一万多行,而且很快就训练完了。
下面是它的一些测试输出,,最后的数字是前面整个句子的字数。
这句话如果用古文来说,应该是什么意思呢? 20
侯如君,是言也?
——————————-
我要骑着马去外边。 9
行无欲辱。
——————————-
这个东西其实一点意思都没有。 14
若兵皆也,无后。
——————————-
你不要说话。 6
比听。
实际上单纯的lstm效果很差,它似乎简单的理解了一些语义,翻译有一点沾边的信息,但是还差的很远,不算像样的句子。
然而lstm加上attention之后的效果有一个非常明显的提升,下面的例子可以看到句子明显靠谱可用了。
这句话如果用古文来说,应该是什么意思呢?20
即诚用古文法,何也乎?
——————————-
我要骑着马去外边。 9
引兵车党。
——————————-
这个东西其实一点意思都没有。 14
者诚相恐都不听。
——————————-
你不要说话。 6
论默
所以说,attention机制非常的重要,它能统一的关联整个上下文。
根据我的测试,lstm感觉比gru废话要多一些,可能是因为它多了一个存储单元的缘故。理论上效果应该比gru要好,但是我测试的结果发现其实差不了多少,可能是训练的数据量不够多到足以拉开差距吧。
我只用了史记里面一部分的数据来训练,主要是因为lstm很难训练,它的时间复杂度会随着序列长度增加而线性的增加,所以我把token序列的长度限制在24,这样就裁剪掉了一半多的数据,另一个原因是我发现用CPU训练的速度比GPU还快,应该是因为他的序列是有线性的依赖关系,无法并行加速。如果使用大量的数据的话,效果可能会好不少。
关于lstm就说这么多吧,下面该我们讨论的主角transformer登场了 。
transformer模型我用的embedding大小是256,token序列长度是36,堆了两层的encoder decoder,总共的参数是9128201,差不多是一千万个左右的样子。输入数据使用[史记,汉书,后汉书,三国志,魏书,晋书,北史,南史,周书,北齐书,宋书,南齐书,梁书,陈书,隋书,旧唐书,新唐书,宋史,元史,明史]等史书,把里面序列长度大于36的对照组剔除,一共有360671对句子,古文加现代文的token数量加起来大致也在一千万左右,对于模型来说数据量还是有点偏小,不过大致差不多能凑合了。
在我的mac m2的mps设备上跑了几个晚上,几个测试的大致输出如下
这句话如果用古文来说,应该是什么意思呢? 20
greedy 若以古文意,何宜言?
beam [‘若‘, ‘苟‘, ‘如‘, ‘夫‘, ‘诚‘]
苟用古文言,何宜意?
苟用古文意,何宜言?
苟用古文言,何宜言?
——————————-
我要骑着马去外边。 9
greedy 我骑出边外边外,我骑诮马乘边外,吾往往外。
beam [‘我‘, ‘吾‘, ‘臣‘, ‘朕‘, ‘边‘]
我骑出边外边外,我骑诮马乘边外。
我骑出边外边外,我骑诮马乘边外,吾往外。
我骑出边外边外,我骑诮马乘边外,吾往去。
——————————-
你说,这个东西难不难呢? 12
greedy 汝不难,难乎?
beam [‘汝‘, ‘尔‘, ‘卿‘, ‘公‘, ‘是‘]
汝不言难,难难乎?
汝不言难,难难乎?
汝不言难,难遽言是,难难乎?
——————————-
这个东西其实一点意思都没有。 14
greedy 其无他意实者,皆东西无他意。
beam [‘其‘, ‘皆‘, ‘是‘, ‘东‘, ‘不‘]
东西皆无他意实意者。
东西皆无他意实,不果。
东西皆无他意实,不果也。
——————————-
你不要说话。 6
greedy 汝勿言,毋言。毋言。
beam [‘汝’, ‘毋’, ‘卿’, ‘尔’, ‘勿’]
汝勿言,勿言。
汝勿言,毋言。
汝勿言,毋言。毋言。
——————————-
我要睡觉了。 6
greedy 吾觉寐,我欲寐寐矣。
beam [‘吾‘, ‘我‘, ‘臣‘, ‘朕‘, ‘予‘]
吾觉寐,我欲寐矣。
吾觉寐,我欲寐寐。
吾觉寐,我欲寐寐矣。
——————————-
时间过的很快。 7
greedy 寻过之日久,甚。
beam [‘寻‘, ‘久‘, ‘旋‘, ‘亟‘, ‘已‘]
寻过之。
寻过期之。
寻过之日久。
——————————-
我昨天经过一个村庄,那里的人都很友善热情,我在那里吃了一顿饭。 31
greedy 昨日分顿庐,昨有所饮食,昨有所具十囚。
beam [‘昨’, ‘臣’, ‘其’, ‘我’, ‘今’]
昨一饭之在藩庄昨,有善者,有所具。
昨一饭之在藩庄昨,有善具者,有所具。
昨一饭之在藩庄昨,有善具者,有所具者。
第一句话是输入,最后的数字是整个输入的字数,下边是输出的翻译。
实际上Transformer的解码器是一个字一个字输出的,一开始给它一个开始的标记,他输出第一个字,然后再把这个开始标记加上输出的第一个字当成输入,继续迭代,它再输出第二个字,再把这三个当成输入,然后它会输出第三个字。如此往复循环,直到输出整个句子或者达到最大长度限制。
实际上输出的也并不是一个单独的字,而是所有字的每一个可能性概率,这就牵扯到一个选择的算法。通常有两种策略,一个是greedy贪心算法,也就是说每次都选择输出概率最大的那个字,最终组成一个句子。上面的输出前面写着greedy的就是这个算法。
另一种策略叫做beam search。他大致原理是选择若干个比如说前五个概率最大的字,然后每一个作为输入再生成下一个输出。如果直接展开搜索树不加限制的话很容易形成指数爆炸,因为第一次是五个,第二次就是25,然后第三次就是125了。句子很长的话很容易就形成一个天文数字,所以需要进行一定的剪枝操作,在前五个的输出生成两个字的25个输出中选择连续概率最高的前五个,作为下一次输入,然后对于三个字再选择连续概率最高的前五个,这样不断的迭代下去,最终会得到概率最高的前五个句子的输出,这个算法对于树枝每次都进行裁剪,形成一个五个的束,所以叫Beam search。因为他的概率选择是基于整个句子的,一般来说效果要比单纯的greedy策略好一些。
输出里边前面写着beam的就是这个算法实现。对于第一个字,我把前五个最大概率的字都打印出来了,就是后面中括号里面的。为了便于阅读最终的句子只打印出了前三个。
可以看到,已经大致能翻译出来不错的结果,比如对于第一句,“这句话如果用古文来说,应该是什么意思呢?”他的翻译效果看起来还不错。而且对于beam给出的几个最大概率的字,用每一个看起来都是一个可行的选择。’若用古文言’, ‘苟用古文言’, ‘如用古文言’, ‘夫用古文言’, ‘诚用古文言’,都是毫无违和感的。
但对于其他的一些句子,效果并不是特别好。特别是最后那句很长的句子他并没有真的完全get到整体的语义。
其实稍微思考一下就能发现改进空间还是非常大的。比如进行中文分词。因为我是直接把单独的汉字嵌入做词向量的,这样就会把很多词语给硬生生的拆分开。比如“东西”这个词,翻译成古文可能用“物”字来对应。但是如果是字符级别的直接训练,Transformer模型是很难很建立一一对应关系的,当然足够大的数据,有东西和物两个对照翻译的话是可以输出相应的效果。但是我的训练数据集显然偏小,而且这样大量的词语一一对应的情况非常少,因为东和西两个字作为指代方向在古文中大量的出现,所以训练的结果输出中很难把这两个字作为一个词当成整体来看待。
于是我尝试简单的进行一下分词试试,很快就发现这并不可行。汉字大致有几千个,但要是词的话至少也有好几万。,这样会直接导致embedding层扩大一二十倍。本来参数都有几百万了,训练数据输入都偏小,无法使得模型很好的拟合。做了分词之后一下子就飙到了好几千万,这在我本地电脑上训练成本实在是太高了,内存消耗和时间消耗都增大了一个量级。我甚至都产生了要买一块儿4090显卡的冲动,24g的显存加上70tflops左右的算力应该能玩儿的比较舒服一点。
还有一个优化方式是对大量的词语古今对照做成一张表,对模型进行预训练,这样他就能更清楚的理解和翻译了,这张表很可能包含几万甚至十几万的对照翻译。我搜了下,好像有标注好的语料库,但并不是特别好找,简单找了一下后就放弃了,毕竟我只是玩玩。
另一个优化方式是,对encoder部分进行单独的训练,因为我们有大量的汉语现代文的语料,通过mask之类的模型就可以把编码器部分训练的足够的好,然后再用古文和现代文的对照训练最终的翻译模型,这样的效果应该会好很多。
正当我准备单独训练encoder的时候,突然意识到一件事情,那就是BERT。这不正是我准备要训练的东西吗?
BERT是Google几年前发布的一个训练好的大模型,他并不是使用完整的transformer,而是仅仅使用了encoder的部分,这可是一个使用大量的数据和机器训练出来的一个经历过实践验证的大模型,我直接在他上面加个decoder就好了,这简直就是站在巨人的肩膀上,实在是太爽了!
实际上利用训练好的大模型对特殊的使用场景进行二次训练是非常正常的一个行为,甚至有个专门的术语叫做fine tuning,我们一般翻译成微调。
事实证明我过于乐观了,bert跟今天这些动辄几百亿参数的大模型比起来虽然相形见绌,但他好歹也是个大模型,在我本地机器上跑实在是太力不从心了。
bert训练好的汉语模型的并不是太多,我找到了一个最小的模型,堆了12层编码器,词向量的维度是768,输入序列有128个token。我按照这个数据实现的decoder一下子就有了一两千万的参数量,这还是把层数砍到只有两层的情况下。
我一开始直接把bert的输出连接到decoder上,输入了一本史记进行训练,结果直接导致内存飙满,操作系统不停的进行内存和硬盘的swap,不到一个小时就写了一两个tb的数据量,我只能停止训练下去了,再这么搞下去直接就把ssd写废了。
于是我想到了一个简单的优化策略,既然训练数据是固定的,那么bert的编码输出也是固定的,我直接预先把这些现代文的句子用bert输出后写到文件里,然后用这些输出单独的训练解码器,这样训练过程中就不用bert参与了,可以节省不少内存。
事实上这些数据量特别大,我只把史记,汉书和后汉书转换了一下,整个过程花了几个小时,pickle之后在磁盘上占了134g的大小。
加载训练过程同样发现实在是太耗内存,经过尝试,我只使用了汉书的数据,裁剪过长的句子后只剩下3万个句子左右,才能全部加载到内存而且不进行大量swap。
下边是bert+decoder训练后的输出,beam search我懒得再单独实现一次了,所以下面看到有一个None
这句话如果用古文来说,应该是什么意思呢?20
greedy何以古文?
None
——————————-
我要骑着马去外边。 9
greedy我当乘马。
None
——————————-
你说,这个东西难不难呢? 12
greedy君何用?
None
——————————-
这个东西其实一点意思都没有。 14
greedy其来无意。
None
——————————-
你不要说话。 6
greedy君无言。
None
——————————-
我要睡觉了。 6
greedy臣敞寐矣。
None
——————————-
时间过的很快。 7
greedy日月骛速。
None
——————————-
今天和明天的区别。 9
greedy今、明之异也。
None
——————————-
写一段话记录一下整个事情的经过。 16
greedy令言举事。
None
——————————-
我昨天经过一个村庄,那里的人都很友善热情,我在那里吃了一顿饭。 31
greedy臣今日过邑,皆有友善。
None
bert的效果看起来更有潜力一些,毕竟我只用了很少的数据,要是把大量的史书加载进来效果应该会更好,可惜我本地的电脑也只能到这个程度了,不过要是花费大量的时间慢慢优化应该也可以,我毕竟只是玩玩儿,实在是不愿意投入更多的时间精力了。
真的要想玩儿的非常开心,感觉至少要专业的a100或者h100了,然而这些卡买一块儿都至少要一二十万,更不要说英伟达对我们禁售了。
整个过程大致就是这样吧,我花了大量的时间精力进去,整个过程当然玩儿的还是很开心的。虽然最后对于计算力的限制有点儿郁闷,但我觉得这应该不仅仅是我自己遇到的问题,即便国外那些大厂像openai或者facebook之类的同样有算力的焦虑,他们的模型更大对算力的需求也更大,毕竟参数越多效果越好,facebook最近发布的llama3有700亿个参数,在将近5万块儿h100上面进行训练,花了640万gpu小时,这些算力看起来是非常恐怖的,而且这远不是结束,整个业界还在谋划着更大的模型,更强的算力。
最后讨论一下transformer模型的能达到的效果极限。本来是放在这篇文章里一起写的,后来发现太长,于是拆分了出去,成为了另外一篇单独发布了。
P.S. 另外附上transformer训练的英文翻译成中文的程序输出,我堆了两层,序列长度是16,一共有八百多万个参数,我使用了五万多行中英文对照翻译的语料进行训练,相对于参数来说训练数据实在是太少,并不能充分拟合。
英文直接按空格分词,也没有做stemming等操作,因为最后一个标点跟挨着的词组合在一起导致词汇表大量增加,我直接把结尾的标点给删掉了,这可能会导致比如?号之类的疑问句不太好识别。毕竟是玩玩,我太懒,不想优化的太细致了。
然而翻译的效果看起来比文言文翻译要好不少,我猜应该是因为测试数据都很短且中英文的语法对应比较简单。另外汉语本身有大量的一字多义造成的映射困难,每个字和词拆分的可能性太多。下面是一些测试的输出:
i love sleeping 15
我爱睡觉。
——————————-
i love sleep 12
我爱睡觉。
——————————-
who are you 11
你是谁。
——————————-
you are stupid 14
你很笨蛋。
——————————-
it seems bad 12
看起来很糟糕。
——————————-
it seems not bad 16
看起来不错。
——————————-
what is the difference between seem and seems 45
“看来说,哪多点是点。”
——————————-
he goes to school 17
他上学。
——————————-
are you serious 15
你是认真的。
——————————-
are you hungry 14
你饿了。
——————————-
i am feeling good 17
我觉得好看。
——————————-
i am fine 9
我很好.
——————————-
call me when you get home 25
你什么时候给我回家。
——————————-
please let me know if you need my help 38
怎么办想都亲就了。
——————————-
what is your name 17
你叫什么名字。
——————————-
i want go back 14
我想回去。
——————————-
time to go 10
时间到了。
——————————-