老万故事会

从0开始学chatGPT(二):纯手工打造罗大佑马尔可夫链

本文是我的 chatGPT 学习心得《从0开始学chatGPT》系列第二篇。建议从第一篇开始阅读:

~~~~

上回书咱们说过,chatGPT 背后的大语言模型本质上是一个条件概率计算器:根据前面 n 个记号(token),求下一个记号有哪些可能,概率分别有多大。

学过大学概率课的朋友可能已经发觉了,这个技术一点也不新,不就是一百多年前的马尔可夫链(Markov chain)吗?

没错,这正是马尔可夫。

Image

安德烈⋅马尔可夫(Andrey Markov)乃罗刹国人。他生于咸丰七年,天庭饱满,面如冠玉,眉若双山,目似流星。其英姿飒爽,犹如天庭之神明,举手投足,发散不凡之气质。

老马爷天资聪颖,勤学上进,不但是数学大牛,还是文学青年。这一天,他在朗诵普希金诗歌时注意到每个音节出现的频率与前一个音节有很强的相关。他发现这种关系可以通过概率模型来表示,遂于光绪三十二年发表了一篇论文加以描述。后人便把这类后续状态按一定概率取决于过去状态的随机过程称为马尔可夫链。

说白了,这马尔可夫链就是统计相邻字词的频率。只要把字词间的相关性都掌握了,鹦鹉学舌说出来的就像是人话了。比如说两人见面,张三开口刚说出来一个“你”,接下来大概率是“好”,也可能是“吃”(“你吃过了?”),基本不可能是“嫖”,除非他不想活了。给机器看大量的文本,它就可以统计出“你”字后面跟上不同字的概率分别有多大。这就叫一阶马尔可夫链,因为它只考虑了当前字和前一个字之间的关系。

但人说话不光是相邻的字有相关性,隔得很远的字之间也可能相关。比如“郭德纲爱吃,尤其爱吃他嫂子于谦夫人的豆腐”这句话中,“郭德纲”和“豆腐”中间隔了崇山峻岭,却不能阻隔它们的强相关。因为开头是“郭德纲”,结尾必须是“豆腐”,其它都不成。

可以想象,用一阶马尔可夫链生成文本过于粗糙了,结果基本不知所云。为了提升效果,人们又发展出高阶马尔可夫链。比如根据前两个字猜下一个字就叫二阶马尔可夫链。chatGPT 可以根据前 4095 个记号猜下一个记号,所以是一个 4095 阶的马尔可夫链。

可不可以把 chatGPT 的模型理解为一张大表,每次它需要产生下一个记号的时候,就到表里面去查:给定当前记号串,下一个记号有哪些可能,概率分别是多少?

从概念上说可以这么理解,但实际实现的时候是不可能真的存这么一张表的。首先,没有那么大的空间来装这张表。其次,训练时用的语料虽然是天量,也不可能涵盖所有的组合。chatGPT 在工作时必然会遇到它在训练时从没见过的记号串,查表扑了个空怎么办?

神经网络可以很好地解决这两个问题。它不是真的试着去存一张完整的概率表,而是在训练时不断调整自己的参数,让自己的行为更接近这样一张理想的表。训练完成后,给它一串记号它就能算出一个后续记号的概率,这就相当于查表了。而且,哪怕给它的记号序列是它没见过的,它也能凭经验算出一个结果。用术语说这叫“泛化”(generalization),相当于对类似的输入做了脑补。

Image

即便是训练时没见过《大学自习室》这首歌,AI 也是会推算出一组概率的。

~~~~

为了更深刻地理解语言模型的原理,我们不妨自己动手,编程实现一个处理中文的马尔可夫链。至于我们的语料,不需要太多,这一刻咱的目的只是为学习。

我是罗大佑的歌迷(见拙文《我将生命付给了你》),收集了他的 104 首歌词可以用做语料。通过统计这些歌词里面相邻两个汉字的频度,就可以建立起罗大佑歌词的一阶马尔可夫模型。

Image

因为数据量很小,用 Python 在单机上就可以处理,瞬间就跑完。然后我们就可以用随机游走来产生罗大佑风格的歌词。当然,这个模型实在是太低阶,数据量也太小,大家不要对结果有太高的期望。

为方便大家试验,我把代码开源在 https://github.com/zhanyong-wan/lyrics-master 。项目的中文全名叫“神经网络大佑风格打油诗歌生成器”,简称“络打油”。(虽然这个马尔可夫链模型没有用到神经网络,以后会用到的。)

我们来看如何生成一阶马尔可夫频度表。

假定歌词已经存在一个字符串列表里了(每个元素是一行歌词),我们可以上这个 Python 函数:

def BuildBigramFrequencyMap(lines):    map = defaultdict(lambda: defaultdict(lambda: 0))    for line in lines:        ch0 = ""        ch1 = ""        for ch1 in line:            map[ch0][ch1] += 1            ch0 = ch1        map[ch1][""] += 1    return map

区区十行,就构造出了一张表:要是 x 和 y 是两个字符,map[x][y] 就是语料里出现 xy 这个序列的次数。我们用空字符串 "" 代表句子的开头和结尾,所以 map[""][y] 就是 y 出现在句子开头的次数,map[x][""] 就是 x 出现在句子结尾的次数。

你可能已经注意到了:我们这里得到的是次数,不是概率。没关系,有了次数,概率就好算了。

接下来就是见证奇迹的时刻了!

    bi_freq_map = BuildBigramFrequencyMap(lines)    lyrics = args.subject    ch = GetChar(lyrics, -1)    for _ in range(NUM_CHARS_PER_SONG):        freq_map = bi_freq_map[ch]        ch = WeightedSample(freq_map)        if ch:            lyrics += ch        else:            lyrics += "\n"    print(lyrics)

这段代码:

  1. 扫描歌词建立相邻两字的频度表 bi_freq_map。

  2. 用从命令行接收到的参数 args.subject 做歌词的开始,依次产生 NUM_CHARS_PER_SONG 个字符添加到歌词后面。

  3. 打印生成的歌词。

它用到几个辅助函数:

# 返回指定下标处的字符。要是下标非法,返回空串。def GetChar(text, index):    try:        return text[index]    except IndexError:        return ""

还有

# 按频度表的权重随机挑一个字。def WeightedSample1(freq_map):    total_weight = sum(freq_map.values())    # random() 产生 [0, 1) 之间的随机数。    r = random.random() * total_weight    start = 0    for x, weight in freq_map.items():        if start <= r and r < start + weight:            return x        start += weight    return ""

跑一下这个程序,下面是部分结果。

以“落叶”为题:

落叶冬季节里手儿照亮我年给我晕眩的身做就飞来掌握。。。

以“初恋”为题:

初恋情的心有没想问一首先去爱是谁又是我青春水纹挥洒我们正开你我们要不能不能使你亲爱和你的你的路程度回忆。。。

以“岁月不堪回首“为题:

岁月不堪回首鹿港的诺言随着你再会记得匆匆匆匆匆数年问题我的传统的心话语母亲的话你的吹动真的耳中的回答是忧郁的会爱人的。。。

总结:一阶罗大佑有一些只鳞片爪的词句勉强狗屁通,大部分狗屁不通。

下一步就是把一阶代码略做扩充,升级为二阶马尔可夫链。

这是频度表构造代码:

def BuildTrigramFrequencyMap(lines):    map = defaultdict(        lambda: defaultdict(lambda: defaultdict(lambda: 0))    )    for line in lines:        ch0 = ""        ch1 = ""        ch2 = ""        for ch2 in line:            map[ch0][ch1][ch2] += 1            ch0 = ch1            ch1 = ch2        map[ch1][ch2][""] += 1        map[ch2][""][""] += 1    return map

这是歌词生成代码:

    tri_freq_map = BuildTrigramFrequencyMap(lines)    lyrics = args.subject    ch0 = GetChar(lyrics, -2)    ch1 = GetChar(lyrics, -1)    for _ in range(NUM_CHARS_PER_SONG):        freq_map = tri_freq_map[ch0][ch1]        if len(freq_map) <= 1:            freq_map = bi_freq_map[ch1]        ch2 = WeightedSample(freq_map)        if ch2:            lyrics += ch2        else:            lyrics += "\n"        ch0 = ch1        ch1 = ch2    print(lyrics)

与此类似,升级到三阶马尔可夫链也不难。为节省篇幅,三阶的代码就不晒了。有兴趣的朋友可以自己写或者去 github 看络打油的全部源码。

下面是三阶罗大佑交的作业。

以“落叶”为题:

落叶纷纷飞出了百六合路我心田敞开口的太平洋惊醒那支持一支舞台的幸福将会记得我吗黄色的脸庞了黑发黄河也许还算早谢你胸膛才会属一意浓妆。。。

以“初恋”为题:

初恋情已奔出江南来像是个生命的无情与牺牲静悄溜走在碧辉煌的夜晚还安康情到深处人情犹豫妈祖先的矛盾的自己一张嘴酒入海刷掉了千百个来自天地遥远的归宿。。。

以“岁月不堪回首”为题:

岁月不堪回首的庭姑娘你漫不在乎转变成钥匙儿在注视的泪痕与蝉声都叫着和魔法七十八手儿要和那历史奇情的我们彼此抓不到来没告诉你付诸于也见真深锁链知了千年即将来懂大同小妹。。。

虽然还是不知所云,总体比一阶稍稍通顺了一点。那句“酒入海,刷掉了千百个来自天地遥远的归宿”甚至有点惊艳了我。

~~~~

上面的算法在生成歌词时都严格遵守从语料中习得的概率。假比在罗大佑的歌词中“姑娘”后面出现了五次“你”,三次“我”,还有两次“别”,那么打油哥在写歌时碰上“姑娘”选下一个字的时候就会有 50% 的概率选“你”,30% 的概率选“我”,20% 的概率选“别”。

下一次,我们会介绍如何给络打油添加行为控制参数,让它偏离原始数据中的概率,达到写歌更谨慎或者更狂放的效果。这个练习将帮助我们理解 openAI API 中相应参数的意义,用好 API。

I’ll be back.

~~~~~~~~~~

猜你会喜欢:

~~~~~~~~~~

关注老万故事会公众号:

本公众号不开赞赏不放广告。如果喜欢这篇文章,欢迎点赞、在看、转发。谢谢大家🙏