« February 2023 | Main | April 2023 »

March 30, 2023

短 ID hash 映射的问题

今天解决了一个问题,觉得很有趣。本来想发到推特,发现一两句话说不清。所以写一篇 blog 记录一下。

我们正在开发的游戏中,会用一个 id 来表示一个游戏对象到底是什么。比如,“铁片” 是 1 ,“煤” 是 2 ,“采矿机” 是 3 …… 这样,在运行时,C 代码可以根据对象的类型方便的查询对象的属性。而对象的属性则是用 Lua 配置好,在运行期不变的。例如每燃烧一个单位的“煤”,可以产生 100KJ 的热量;一箱“铁片”有 100 个。

为了在 C 和 Lua 间快速共享这些配置数据,我还专门写了一个 cache 模块

问题出在 ID 的持久化上。因为游戏中的物品种类并不是特别多,出于时间以及空间性能的考量,我把 ID 设计为 16bits 。64K 种物品种类的上限看起来足够了。但 ID 的分配却比较麻烦。

最简单的做法是,在程序运行时,用一个自增 ID 分配给每个物品类型。但这会导致 ID 极不稳定,考虑到 ID 需要持久化,在开发过程中,持久化的存档也变得很不稳定。所以我选择了用物品的名称以及一些内在的参数(例如版本号)一起计算 hash 值当作 ID 使用。

但 16bits 的 ID 显然会发生 hash 冲突,所以最终还是结合了一个自增算法为每种物品赋予 ID 。这个方法在很长时间相安无事。大多数小版本更新都没有破坏老版本的存档,偶尔的版本更迭导致存档失效也是可以接受的。我们开发人力有限,暂时也没有把游戏版本升级同时升级存档数据这个机制的开发优先级列得很高。

但最近开发迭代周期变短,游戏可以玩的时长也越来越长。即使是开发测试,游戏存档失效后需要从头玩起,也变得越来越影响开发了。所以今天我们又重新审视这个问题。导致存档失效大大部分原因都在于新的版本(因为新增或删除修改了老物品)重新排列了物品类别的 ID ,导致旧存档中持久化的 ID 相关信息过期。

我和同事讨论了几种最容易想到的解决方案:

  1. 改成人工指定 ID 。这个方案更为传统,我参于的很多游戏都是这么做的。所谓由策划配表,由人来保证尽量不要变更过去的映射表。

  2. 把 ID 位数增长,变为 32bits 甚至 64/128 bits 。只用 hash 来生成 ID 。

  3. 把存档数据升级机制纳入日程。加载老存档时,先预处理一遍数据,修正过期的 ID 。

方案一快速被否决了。我们项目没有全职策划。唯一一个挂策划名头的同事还要兼任 UI 制作、测试、项目管理等多项工作。而且我们的游戏类型决定了这个表会相当庞大,很多部分也不只是策划一人来决定的,具体实现的程序也会经常增加或删除其中的类型。人力维护一个稳定的映射关系不现实。

方案二的问题在于,从长期看,完全避免 hash 冲突可能需要 128bits ID 。这可能造成较大的性能浪费。而较短的 ID ,例如 32bits ,依然需要一个中心机构来记录历史上我们曾经使用的 ID 。这个中心机构维护起来并不轻松。尤其在我们现在的快速迭代阶段也不显示。

方案三看起来只是把未来要做的事情提前了。但做起来并不容易。现在的存档缺少不少元信息用来做新旧版本过度。这些信息或许以后还是要补上的,工作量却不小。我还是倾向于等版本再稳定一点时考虑具体方案。即便是像 Factorio Stellaris 这样已经迭代了好多年,单局游戏时间很长,有着存档升级机制的游戏。它们也没有做到可以从任意老版本的存档升级到最新版本。


最后的解决方案出人意料的简单。

我们在存档中额外放了一份 ID 映射表:记录每个 ID 具体是什么东西,也就是用来 hash 生成 ID 的那些元素:名称、成分、版本号等等。

虽然我们在 16bits 内无法避免 hash 冲突,但我们可以延迟生成 id 的时机。也就是说,程序在构造每种物品时,并不为它们生成 ID 。而是在加载存档时,根据这张表,动态绑定 ID 。如果存档中有 “铁片” 这种物品,标记为 42 ,那么在加载存档后,内存中的“铁片”这种物品的属性表中才赋予它 42 这个 id 。

而存档中不存在,但系统中已经创建出来的物品类型,再按 hash 算法生成独有的 id 。

March 23, 2023

继续学习神经网络

这段时间忙游戏项目之外,继续花了几天学习神经网络。

上一篇 blog 发布以后,收到了图灵的朋友的 email 。然后,我收到了一大堆(九本)图灵出版的关于人工智能的中文书。通常出版社的同学给我邮寄新书,或多或少是希望我能写点什么。我不想刻意帮人宣传新书,但倒是不介意推荐一些我自己读过的不少的东西。

这次收到的书太多,当然没有全部读完。但书的内容相关性都很强(差不多一个主题)。去年的时候,我读了《如何阅读一本书》,里面提到读书的四个层次。第四个层次就是就一个主题同时读多本书是非常有意义的。这次,我也颇有收获。

在这堆书中,关于神经网络的入门。除了上一篇 blog 中提到的那本免费英文书(已经有中文版《深入浅出神经网络与深度学习》了),我还推荐日本人写的《深度学习入门》。我觉得日本的科普入门书都讲的特别细致,并配了大量图解。果然这一本补充了许多我在阅读前本书中忽略的细节。尤其是误差反向传播算法,我觉得读了之后脉络更清楚一些。btw, 我在 twitter 上推荐这本书时(并没有写出书名,只有一张照片),还收到了作者斋藤康毅的回复。世界真是小啊 :)

在实作环节、我给自己定的小目标是实践一下卷积网络。但几本书的实践代码都不再像之前那个简单的全连接网络那样有非常简单的实现了。我还是打算完全自己从零写(不借助任何包括数学向量计算在内的库)。这次没有太多可以参考的代码,所以难度更大。倒不是说代码会特别复杂,而是万一哪个细节出了 bug ,没有参照物很难追溯。需要把原理弄得再清楚一点,才能在编写代码的时候少犯错,犯了错也容易触发自己写代码的直觉快速定位 bug 。

我首先发现的是自己之前对一些基本概念理解的不够。导致老版本有好些错误的抽象。想在已有的抽象上扩展出一个卷积网络很困难。

比如、全连接层之间每个连接上都有一个影响信号传播的权重、还有一个偏置。从计算角度看,其实对信号做了一次仿射变换。我觉得这个权重和偏置看起来是一个整体。但实际它们应该是两个东西。

如果只看全连接层的一个神经元,权重配置在它上游的神经元过来的连接上;而偏置是放在自己身上的。偏置更像是另外一个独立的常量信号源。这样拆解开,可以更好的理解误差反向传播:权重和偏置的影响是分开传播的。

激活函数也是一样:不同的场合,我们需要不同的激活函数。sigmoid 、relu 、tanh 各有适用的场合。它们会反向传播误差,但没有需要调整的参数。

我在实现卷积网络之前,先花了点时间把旧代码重写。重写的版本也没有做太好的抽象,只是恰恰能用。因为我觉得随着学习的进展,随时可能再改动。没必要太早引入过多的复杂度。

关于卷积网络。其实和生物学上神经网络的结构没有太大关系。它只是利用信号处理中已经成熟的技术,通过对图像卷积运算,提取出 2D 图像上的特征。这样,神经网络的输入参数会更合理(而不是一个个孤立的像素)。可以理解为从更高维上提取出图像的信息,交给神经网络分类。

但卷积层的信号传播以及利用误差反向传播去调整参数这部分的原理和神经网络是差不多的。所以我们还是把它归到神经网络中去。对于实现、正向推理就是图形学中常见的图像卷积运算;这次我需要的是求得正确的误差反向传播算法。

理解链式法则的话,自己推导不是难事。我在自己推导了一遍后,为避免错误,又 google 了不少相关的文章。我觉得写的比较好的是微软研究院的这一篇:《卷积的反向传播原理》。至于池化层的误差反向传播更简单,也可以参考:Forward and Backward propagation of Max Pooling Layer in Convolutional Neural Networks

简单说来,对于 mnist 手写数字识别来说。我们期待用卷积层找到这些图片中的不同特征。横竖笔划、交叉、弯折等等。卷积有很好的平移不变性,只要找到了某个特征,就无所谓特征出现在图像的哪个位置。最后用这些特征作为信号源去判断具体数字是什么,这样比基于像素点的判断要高效。

但用怎样的滤波器(卷积核)找什么图像特征我们事先并不知道。循环卷积层的工作就是从随机数开始去寻找怎样的卷积核更合适。

ps. 说起为什么要从随机数(而不是零)初始化学习的起点。这几天我在书中找到了答案:如果我们要训练的那些参数一开始都是一致的,它们就很难分出彼此。因为本质上神经网络的同一层中的神经元都是等价的。一开始并没有规定哪个神经元负责什么工作。是训练让它们偏向不同的职责。如果一开始初始值都一样的话,就训练路径就很难分开了。如果是多层网络,我们还需要调整不同层次的随机初始值的分布参数才能获得更好的效果。

从零编写卷积层很容易犯错误。我并没有指望一次做对。所以单独写了一段测试代码测试卷积运算的误差反向传播算法。即求解问题:如果我有一张图片以及通过某个过滤器进行图像卷积运算得到的目标图案,如何反过来求得过滤器是什么。通过这段测试,找出了这些代码中的几处小 bug 。如果没有这个测试步骤,恐怕直接把新写的卷积层并入神经网络,错误都无从查起。

最终的卷积神经网络效果很好。把之前 95% 左右的正确率提升到了接近 99% 。根据从书中了解到的知识,下一步改进一些学习过程的细节,应该还能进一步提高到 99% 以上。


不过,加入卷积层后,训练成本也大幅度增加了。之前那个全连接的前馈神经网络,训练一次只需要一两分钟。而现在需要 20 分钟左右。学习过程如果有快速的数据反馈自然是效果最好。半个小时恐怕是让人心烦的极限了。

目前我的实现中采用的 mini batch 方法,也就是一个周期训练一小批数据,这一小批每组数据的训练都是完全独立的。一批完成后才累积结构迭代到要训练的模型中去。这天然就是可以拆分到不同的线程中去的。如果花一点功夫,在 skynet 或 ltask 下实现一个并行版本的话,我想还能把速度提升一个数量级。不过这似乎对学习神经网络本身意义不大。毕竟把运算移到 gpu 中收益更大。

March 08, 2023

学习神经网络的一点笔记

最近 AI 很火。我从读书的时候就对 AI 有兴趣,90 年代的时候读过一本关于神经网络的书。但当时没有能力完全理解。2000 年初刚毕业那会儿想搞明白点,又研究过一段时间。了解了很多相关知识,感觉自己弄明白了,但却没有真正动手实践过。

现在,所有人都看得到,AI 已经变成了一个可以真正帮助我们提高生产力的工具了。我觉得有必要好好学习一下。我并不期望几篇文章就可以搞清楚,那样只会让自己好像懂了,和人聊天估计够用,想理解它还是得自己动手。真正写几段代码,才有触碰的感觉,明白别人到底在解决什么问题。

一开始,我是从维基百科的 Machine learning 开始读的。顺着看了一整天,了解了近年发展的脉络。好多词条,乱七八糟的笔记记了一大堆,感觉快消化不了了。疑问也积累了很多,觉得看下去效率会越来越低。不如换一条路。

然后我开始读 Neural Networks and Deep Learning 这本书。读了两章后,我想,手写数字识别看起来是一个非常好的实践手段,不如自己写一遍最快。有这么一个基础,后面可以继续修修补补,沿着前人的轨迹走一下,可以更好的理解为什么。

现在的条件远超 20 年前,训练这么一个简单的前馈神经网络估计在我的机器只要不到一分钟,可以很快得到反馈。有很多现成的代码可以比对,出了 bug 容易查。练习需要的数据也是唾手可得:THE MNIST DATABASE of handwritten digits 。虽然神经网络到底怎么工作的,人类恐怕还无法弄清楚每个神经元传递的信号的意义。这会导致程序出了 bug 很容易被掩盖起来,但有了数据库里的数据,我们还是可以从结果的正确率推测自己有没有实现对。所以用这个手写数字识别这个课题来学习是非常好的。

我所理解的神经网络,就是模拟大脑的工作方式,用非常简单的神经单元,相互连接起来,通过传递非常简单的信号,最后可以从网络的一端输入信息,另一端得到预期的结果。用程序模拟现实事物本来就是我的爱好,游戏就是干这个的。所以写这么一个程序让我感到非常快乐。

我们的信念是:模拟神经网络,尤其是实现单个神经元的代码肯定是异常简单的。起作用的不是单个神经元多么奇妙,能思考问题;而起作用的是网络结构本身。如果神给定了一个合适的网络结构,我们从一端输入若干信号,经由若干连接(神经元之间的突触),发送给众多神经元。一个神经元会被很多突触连接到多个神经元,每个通过突触的信号被突触上的权重所修正累积在一起,在接收的神经元内再由此神经元内的阈值修正一次,把该信号传播下去。(也可以把阈值看成连接到这个神经元的一个常量信号)最后,一定能得到正确的结果。如果结果不正确,并不是方法有问题,只是网络结构不对。

所谓人工神经网络,只要得到了正确的网络结构,用它来解决问题是非常简单的:模拟信号传播的过程,把信号通一遍就好了。我的代码的最初版本就是用已有的正确的程序训练出网络,把数据导出,然后导入到我的代码中验证一下。这样,我只用实现最简单的部分,可以快速检验实现的对不对(避免后面加入更复杂的部分时,难以定位 bug )。

最简单的神经网络就是把所有神经元相互全连接。这显然不是生物上神经网络的结构,但它是最简单的。因为,如果神经元是一组组聚集在一起,相互间只有部分连接的话,无非是把全连接中的部分连接权重设为 0 而已。

但这也是真正的困难所在:到底该怎样得到一个正确解决问题的网络结构。也就是所谓的训练。如果我们有无限的时间,固然可以穷举所有的可能性(每个连接上的权重),但穷举所需的时间一定超过了宇宙的寿命。而神经元之间的连接数量也远超了人为去每个设定的能力范畴。我们必须用某种启发式方法减少这个寻找正确网络结果的时间,同时还需要简化数据规模,比如卷积神经网络把神经元分组分层,就极大的减少了连接数量。

作为学习,我想先从一个最简单的前馈神经网络开始。也就是根据问题:从 28x28 = 784 个像素的灰度图中识别出手写的数字 0 - 9 。输入是 784 个灰度信号,输出则是 0 - 9 十个独立信号。其中每个信号表示结果是或不是某一个具体的数字。固然,10 个独立信号有很大的冗余。理论上 4 个信号就够了(可以表示 0-15 个状态)。但是,如果采用 4 个信号的输出,相当于除了让网络识别出数字外,还需要理解数字的二进制表示。这肯定需要更复杂的网络结构,势必大大增加训练出网络的难度。

中间模拟神经网络的部分,采用书上建议的 30 个神经元。采用无环结构,让它们一边和 784 个输入单元全连接,另一端和 10 个输出单元全连接。所谓的训练工作就是反复尝试找到每个连接上的权重,以及每个神经单位过滤信号用的阈值。一共 784 * 30 + 30 + 30 * 10 + 10 个需要测算的参数。

训练的方法是最基本的监督学习。就是用已知结果的信号去跑网络,告诉它这样一张图片是数字几。这样让网络知道人所需要的结果偏好。MINIST 数据集中有 5 万组预扫描好的图片,并标注了结果数字。一开始输入图片后,结果肯定是乱来的。但是我们可以根据错误的结果和正确的结果比对,来调整网络 。这里可以采用一种叫 stochastic gradient descent (SGD) 的方法。有点像调收音机,感觉频道不对,就稍微转一下旋钮。再听一下信号好不好,如果好了,方向就对了,如果差了就反着转一下。这个过程叫 backpropagation ,根据结果误差来修正源头,把误差反向传播回去。但是,对于神经元来说,每个神经元对应的源头有很多个,我们很难确定该转哪个源头的旋钮,该转多少。

为了更好的调整这些权重值,我们需要对信号做一些处理。因为每个神经元接收到的信号都是很多来源叠加起来的,所以,不应该让某些信号过于特殊。不然它的一点点变化就会极大的影响输出,掩盖掉另一些信号的调整影响。我们可以用一个函数,把所谓输出信号的范围从整个实数范围全部放到 [0,1] 之间,这样,对于每个神经元来说,它的每个输入看起来都差不多了,可以按一致的规则去调整。

其实,这个对信号做变形的函数具体是什么不重要,重要的是这个函数连续可微,它把输入信号都规范方便调整了。书上推荐的 sigmoid 函数之所以用得多,还在于对它求导非常简单。后面这点非常重要。

我在一开始觉得 backpropagation 的细节很难理解。后来看了另一篇文章 How Does Backpropagation in a Neural Network Work? 就明白了。原来,现在用的方法并不是人们一开始都知道的。现在我们用的从结果的差异反推来源的 delta ,公式非常简单,但数学原理并不简单。感谢 Andrew Ng 的公式 delta_0 = w . delta_1 . f'(z) ,我们只需要对正向信号传播时,每个神经元上得到的值求导,乘上该神经元输出端的神经元上的 delta 和输出权重就可以得到它的 delta 。总 delta 就是所有输出侧用该公式计算出的 delta 累积量。

我写程序的过程中,一共出过两个 bug。其中一个就是在 backpropagation 过程的,主要还是理解不到位引起的手误。花了两个小时重新读书、理解、再审查代码才找出来。

我将代码提交到了 github ,有兴趣的同学可以参考。https://github.com/cloudwu/neuralnet


说到另一个 bug 。一开始我的程序可以正确运行,后来我尝试调大了一些神经元的个数,比如从 30 改到 40 ,质量却急剧下降。这让我难以理解。花了 2 个多小时排查(其实整个程序连同读书的时间,一共也就花了 10 个小时),才发现问题并不是出在神经网络的实现上。

我在书中读到,初始化神经网络时可以用正态分布的随机值初始化,这样可以减少训练时间。暂时我没有去探究这样做为什么有效,或是有什么更好的方法。只是动手实现了一下。因为我没有使用任何第三方库,而 C 标准库中的随机数是均匀分布的。所以我随手 google 了一个公式,把均匀分布的随机数转换为正态分布:取两个均匀分布的随机数 r1, r2 ,然后计算 float x = sqrtf( -2.0 * logf ( r1 ) ) * cosf ( 2.0 * PI * r2 ); 。每次用一个随机数,记下另外一个,留给下一次迭代就可以了。

我没有怀疑这个公式有什么问题。但我疏忽了当输入的 r1 为 0 时,log(r1) 会是无穷大。而无穷大会污染神经网络,导致经过几次 backpropagation 后,很多权重都变成了 NaN 。


这次的实践只能说是一个开始。不过已经占用了很多工作时间。我们的游戏还在制作中,我得先放一放,等下周再试试深入一点的知识。