祝贺北理工/六子棋遐想

2009/05/17 | 18:43 | 分类:学习随感 | 标签: | 1,219次阅读

  时隔半年再闻喜讯,在西班牙潘普洛纳市刚刚结束的第14届ICGA国际计算机(棋类)奥林匹克大赛中,北京理工大学软件学院2006级本科生崔皓、李亮、林思然、王锐坚开发的六子棋程序“中国深度”一举夺得金牌。北理工其他几支日本将棋、幻影围棋等队伍也分获多枚银牌和铜牌。
  从2006年北理工学生初涉全国计算机博弈比赛,到2007年获得六子棋全国亚军,再到2008年的国际亚军以及这一次的国际冠军,北理工已经在计算机博弈领域有所积淀,初步形成了一个优势项目。我前文所期望的“再创辉煌”、“为国争光”,已经为年轻的力量所实现。我代表已经毕业的北理工第一代计算机博弈选手再次向你们表示衷心的祝贺!
  诚然,棋类博弈只是人工智能中的一个子领域。在对手不断改进、黑马不断涌现的情况下,保持自己程序的常胜地位是难上加难。对非职业选手来说,夺得国际冠军来之不易。他们在这一过程中获得的,不只是精通某类问题的特定解法,而是借助对算法的研究培养了一种偏数学、偏逻辑的共性思维能力。对计算机、软件专业的学生来说,这比一块奖牌要实在得多。


 
  六子棋发明人吴毅成教授在其论文《A New Family of k-in-a-row Games》中定义了K子棋:Connect(m, n, k, p, q),其中棋盘大小为m×n,先手方第一步下q个棋子,以后双方每步各下p个棋子,先将k子连成一线者为胜利。他在此基础上说明了前人已经证明过的五子棋[Connect(15, 15, 5, 1, 1)]的不公平性以及六子棋[Connect(19, 19, 6, 2, 1)]的公平性和复杂度(衡量可玩性)。我认为他提出用这个q来化解先手优势是较为创新的,尽管此前的棋类实践中早已有贴目、让子之类的规则,但专门拿出这个q,在公平性的形式化证明中还是很有一般意义的。
  K子棋还可能有什么创新的思路呢?容易想到的改变棋盘的形状和维度,很早就有这类实践或产品了,比如3-D Tic-Tac-Toe。或者是增加游戏人数(参数u),三个人在纸上下五子棋想必很多人都尝试过。另外,由那个q也容易联想到,可以将p、q合并为一个函数p(t)或p(u, t),代表第t手,或第u个玩家的第t手可以下几个棋子。类似的想法还有k(u)等。
  2007年我在和吴双研究六子棋时,就有一个想法:K子棋的各项参数能否突破自然数的限制?如果这些参数取负数、分数乃至无理数、复数,是不是可以发明一类新的游戏?即使不能得到一种现实中可玩的游戏,是否可以在计算机上模拟呢(例如Magic Cube 4D)?如果不能模拟,那么是不是可以作为一种纯抽象的数学游戏或思维游戏呢?如果把这种东西作为游戏太耗神,那么能否用作对其它领域事物的一种建模,为其它问题的描述或解决提供方法呢?想想看,指数函数的自变量是自然数时可以直观地表示连乘,但它扩展到实数域、复数域后,用于复分析,成为了重要的数学工具。整数维度可以描述物理空间,但分数维度在解释分形理论时同样有实际意义。分数维空间中的无理数个玩家进行的复数子棋,或许真的是某种现实事物的数学模型。有兴趣的朋友来研究一下吧!

祝贺北理工/六子棋遐想
我与吴毅成教授

对几项带有随机算法思想的工作的讨论

2009/03/28 | 11:38 | 分类:计算机科学与编程 | 标签: | 831次阅读

对几项带有随机算法思想的工作的讨论
(节选自上学期本人组内讨论课作业)

  在某些类型的数值计算问题中,我们常使用随机算法,如数值概率算法(得到近似解)、拉斯维加斯算法(不一定有解,如果有解一定正确)、蒙特卡罗算法(一定有解,但解不一定正确)、舍伍德算法(一定有解,解一定正确)。这些随机算法的原理基于概率模型,用简单的随机因子代替精确但复杂度高的最优化求解。对于数值概率算法,通过增加计算次数逼近精确解;对于后三类算法,通过增加计算次数提高得到正确解的概率,总体上都可以得到比确定性算法更低的平均时间复杂度。数值概率算法牺牲了解的精确性(Accuracy),但在很多场合下精确性不是首要要求;后三类算法有失去正确性(Correctness)的风险,但由于有办法将失败化为小概率事件,故在实际应用中利远大于弊。
  在计算机系统,特别是分布式系统的各类问题中,活性(Liveness)与安全性(Safety)往往是评价某种解决方案的关键因素,这类似于数值计算问题中的正确性,通常是不能含糊的,有必要做形式化证明。但这在实际中并不是绝对的。在本学期的讨论课上我们了解了不少工作,其中引入了概率模型,应用类似于随机算法的思想,牺牲某些情况下系统内部绝对的活性与安全性来换取性能的大幅提升,同时引入相关的机制将潜在的失败可控化,不影响系统对外的活性与安全性。这些工作包括:
  Exterminator:一种能动态修复C、C++程序堆内存错误的运行时库。基于统计规律,在多次运行时重新安排内存布局来定位错误,对错误位置分配额外的内存来降低再次出错的概率,使得原本有内存缺陷的程序可对外表现正常,同时便于开发人员快速定位错误。
  MetaTM/TxLinux:一类基于事务的内存管理模型。用允许并发访问临界区的事务管理算法代替严格的锁保护机制。同时检测临界区冲突,在发生冲突时回滚重试。它减少了系统同步操作中由严格的锁带来的性能瓶颈。
  Zyzzyva:一种拜占庭容错协议。一般情况下乐观地采纳主服务器对请求处理顺序的判断,省去了三阶段提交(3PC)协议。同时在客户端检测服务器副本可能的不一致,在发现问题时通知其更正。这种方案减少了3PC协议带来的开销。

● 我得到的一些启发
  这些工作中的随机算法思想
  Exterminator的做法带有数值概率算法的思想。设想一类能够精确地对运行时程序内存进行分析、预测和修复的机制,它应该覆盖所有可能的执行路径,并对有关变量的值域、边界条件等做细致分析,其复杂度有可能是指数级的。但Exterminator仅分析了特定的,也是实际工作中典型的执行路径,通过多次运行随机安排内存布局的实例来找出可以正常运行的实例,并利用内存布局的信息计算每一块内存分配可能引发错误的概率,复杂度下降到了多项式级。在一些基本假设下,作者给出了Exterminator对内存操作问题位置判定正确性概率的公式。Exterminator这样的设计满足了作者所谓 “automatically correcting memory errors with high probability”的需求。作为一种运行时的、治标不治本的纠错机制,使用随机算法很好地平衡了其功能和性能。它所找到的问题位置对机器来说精确度并不高,修补方案也很粗糙,但其分析结果在程序员来看足以发现错误原因并在代码中修正了,具备可用性(尽管目前版本输出信息的可读性不高,但容易改进)。
  MetaTM/TxLinux和Zyzzyva对原有机制的简化没有破坏它们的活性,但弱化了原有机制的安全性,从这个角度看,它们类似于蒙特卡罗算法。不同的是,数值计算可以通过多次运行来提高解的置信度,而系统算法是有副作用的,不能通过多次运行的方法提高其安全性。MetaTM/TxLinux 和Zyzzyva均采用额外的监督机制来发现错误,在情况不合预期时及时告知算法进程撤销错误操作,重新运行算法或者转而使用原有的安全机制运行。这样,作为一个整体,系统的安全性重新得到了保证。MetaTM/TxLinux的作者没有给出他们这种乐观的并发执行性能的理论证明,但通过在修改之后的 Linux内核上运行多个实际负载,用实验数据说明了事务内存的开销小于传统的锁机制。Zyzzyva的作者同样以实验数据说明为主。可以预测,其假设失败的概率与分布式系统中节点本身失效的概率相关。
  完备性(Completeness)与可用性(Availability)的权衡
  上面已经提到,Exterminator的算法肯定不能准确覆盖和修正所有内存错误,但作为一个辅助工具,对用户来说可用性是足够的。对它来说可用性的约束弱于完备性。在MetaTM/TxLinux中,监督机制的加入从理论上保证了系统的安全性,但测试表明对一类频繁更新文件系统同一结点的负载,事务内存的乐观估计失效,临界区冲突激增,系统可用性下降。在这种情况下可以认为可用性的约束强于理论上的完备性。可见,在有些时候系统的完备性与可用性的约束强度不能一概而论,需要针对具体上下文来分析。
  面向方面程序设计(Aspect Oriented Programming)的思想
  MetaTM/TxLinux对临界区的控制相比严格的锁要简单不少,但之所以能够对应用保证安全性,是因为它改变了对环境的假设,拓宽了系统边界,加入了外部的监督机制。Zyzzyva中客户端对服务器的监督和反馈也类似。这种通过适当修改外界条件假设,加入外部机制以解决在原有系统闭包内不能或难以解决的问题的方法是值得我们学习和应用的。
  更进一步地看,将监督反馈机制、垃圾回收机制,乃至活性、安全性这样的抽象性质独立出来,作为一种系统“切面”机制,体现了一定的面向方面的程序设计思想。这类方法或许在GOS将来的设计中有积极意义。

● 我认为这些工作的不足
  应用于数值计算的随机算法的平均复杂度和性能都是可以借助概率论证明的,但应用于系统问题的随机算法只能对理想负载计算其性能,在实际利用中负载的特性对系统的性能可能有显著的影响。MetaTM/TxLinux的事务内存的在某些负载下的临界区冲突激增的问题就是一例。作者叙述了这一现象并推测了原因,但没有给出这类问题共性的、内在的原因。如果能够进一步分析这类问题的本质,较为明确地找出不同内存管理模型的适用条件,则可能更具学术和实用意义。如果在此基础上引入一些启发式算法,根据负载的特性调整执行策略,有可能达到更好的效果。
  MetaTM/TxLinux文章以及会议演示文稿均用相当的篇幅阐述其实验方法和评测结果,作者选择的几个测试用例基本覆盖了Linux内核几大模块的主要原语,但这只是在静态层面上的。内存管理可能遇到的诸多问题只有在不同类型的原语以不同序列组合的动态运行过程中才能充分暴露。因此,如何提高测试用例的说服力是一个问题。

● 总结
  我从上述工作中学习到的算法以及其中蕴含的随机算法等思想可以说是一类技术,在初始了解到其巧妙之处后,我曾设想在其它一些经典的系统问题中如何应用这一工具,例如在共识问题(Consensus)中如何快速取得一致。但我觉得有一条原则需要注意,不能为了使用工具而使用工具,要以应用的需求为出发点,合理地分析应用对系统性质的需求,选择最适合的技术方案。这几项带有随机算法思想的工作之所以比较成功,我认为就是因为作者对问题需求把握得好,适时地放弃一些次要目标,另辟蹊径来换取首要问题的高效解决。

基于BP神经网络的鼠标手势识别实验程序

2008/02/13 | 10:08 | 分类:Windows开发 | 标签: | 1,769次阅读

程序与全文下载:
http://www.linjian.cn/files/dotNet/GestureStudy.rar

http://files.linjian.org/dotNet/GestureStudy.rar

程序基于Mat Buckland在《游戏编程中的人工智能》一书中例子的实现。

 

●问题陈述
  本程序基于BP神经网络完成了对用户使用鼠标输入的特定手势的识别,并且可以学习用户创建的新手势。程序中已经存储的手势是Palm系列掌上电脑中手写识别专用的Graffiti字体的数字0~9,这种字体的特点是每个字由一个连续笔划构成,适合用一个连贯的鼠标手势表示:

 Picture1.png

图1 Graffiti字体的数字

●程序概述
  本程序针对鼠标手势的存储、识别和学习功能,需要实现以下要素:
一、鼠标手势的表示
  由于每个鼠标手势仅由一个连续笔划构成,因此可将这一笔划分解为一系列连续的向量。不同的手势对应不同的向量组合。为计算方便,所有向量可以归一化为单位长度向量,如图2示例:

 Picture1.png

图2 鼠标手势向量示例

  分别以向右、向下为x轴、y轴正方向,则图2所示笔画的向量组合为(0,1)-(0,1)-(0,1)-(0,1)-(1,0)-(1,0)-(1,0)。
二、定义网络输入输出
  使用BP神经网络,需要定义一组一维的输入向量与输出向量。将一个手势的笔划向量依次连接,x坐标、y坐标依次出现,即可构成该手势的输入向量。对于可以识别N种手势的神经网络,可以使用长度为N,第M位为1、其余各位均为0的向量作为第M种手势的标准输出向量。
  上例对应的输入向量为(0,1,0,1,0,1,0,1,1,0,1,0,1,0),输出向量形式为(0,…,0,1,0,…,0)。
三、训练神经网络
  对于程序中已存储的手势,需要训练神经网络,使得网络对每个输入向量都能够产生误差在允许范围之内的输出向量。BP神经网络训练使用反向传播过程,即对每个输入向量重复以下步骤:
  (1)将它送入网络,计算网络的输出o。
  (2)计算输出o与目标输出t之间的误差。
  (3)调整输出层权重,为每个隐藏层重复步骤(4)和(5)。
  (4)计算隐藏层误差。
  (5)调整隐藏层权重。
四、鼠标手势的记录
  用户使用鼠标,在程序界面指定区域按下左键画出一个手势,程序定时记录鼠标坐标,构成一个点集(图3所示蓝点)。使用一定的算法对点集进行处理,取出比笔画的向量数目多一个的代表点(图3所示红圈),相邻两个代表点求差得到的向量经归一化即可得到笔画的向量组合。
取代表点的方法采用了光滑化算法,即对分布不均的原始记录点找到其中跨度最小的两个点,用它们的中点取代它们。重复执行,直到点的数目符合向量数目要求。

 Picture1.png

图3 鼠标手势记录示例

五、鼠标手势的识别
  用户的输入不可能与定义的标准输入完全一致。将待测试的手势对应的输入向量输入训练过的神经网络后,网络的输出与定义的第M种手势的标准输出向量越接近,说明输入手势越接近第M种手势的标准输入。评判“接近”的简单标准是找出输出向量中最大(最接近1)的元素,认为输入最有可能是该位为1的输出对应的手势。
六、鼠标手势的学习
  学习过程是将用户新输入的一个手势构建输入向量,加入原有的输入向量集;对输出向量的长度加一,设置其对应的输出向量,然后将网络重新训练一遍。

●程序架构
  本程序使用Visual Studio 2005(C#语言)开发,基于.Net Framework 2.0运行。
一、关键数据结构
  程序包含的类如下:

Picture1.png
图4 程序类图

  (1)Neuron类(神经元):记录一个神经元的输入数目、各个输入的权重、偏差值等数据。
  (2)NeuronLayer类(神经网络层):记录一个神经网络层的神经元数目和各个神经元对象。
  (3)NeuralNet类(神经网络):记录一个BP神经网络的各个神经网络层以及隐藏层数、学习率、训练与否等信息;提供训练、更新等方法。
  (4)GestureData类(鼠标手势):记录所有鼠标手势的名称、笔划向量组合、对应输出等信息;提供追加新鼠标手势等方法。
二、程序界面操作说明

Picture1.png
图5 程序界面

  (1)程序运行后,首先需要训练神经网络。用户可以设置网络的一些参数,包括学习率、可允许的偏差门限、隐藏层数等。此外可以选择开启带动量的算法或带噪声的算法,并设置动量分数和噪声最大值。单击“Train Network”按钮开始训练网络,界面左下方显示当前训练的迭代数和偏差。
  (2)训练之后,在绘图区域按下鼠标左键画出一个连续的手势,程序会将代表点标注在手势上,并在界面左下方给出通过神经网络运算得到的最佳匹配输出值与对应手势的名称,用卡通图标显示最佳匹配输出值的大小。
  (3)单击“Learn New Gesture”按钮,程序进入学习状态。此时用户可以输入一个新的手势,并在弹出的对话框中为之命名。程序随即重新训练神经网络。
  (4)单击“Clear Pad”按钮清空绘图区域。

●参考文献
[1] Mat Buckland.游戏编程中的人工智能,北京:清华大学出版社,2006.
[2] 朱大奇,史慧.人工神经网络原理及应用,北京:科学出版社,2006.

随机数不随机——一个有趣的错误

2007/12/25 | 10:02 | 分类:计算机科学与编程 | 标签: | 924次阅读

  今天在编程过程中犯了一个有趣的错误:用C#写了一个BP神经网络,在训练的过程中发现其收敛速度极其地慢。仔细检查与训练相关的函数、参数并单步调试,没有发现什么有问题的地方。最终审查每一个外围函数,终于找到了问题所在——
 
  初始化一个神经网络时需要一系列随机的权值,这就需要生成一系列-1~1的随机数。我原先是这样写的:
 
        static public double RandomClamped()
        {
            Random ran = new Random();
            double a = ran.NextDouble();
            double b = ran.NextDouble();
            return a - b;
        }

 
  它的错误在于每次运行时都通过当前时钟来重置随机种子。当程序高速运行时,有可能连续几次的随机种子一样,导致连续生成几个相同
的数,影响了神经网络的性能。正确的写法可以是:
 
        static private Random ran = new Random();
        static public double RandomClamped()
        {
            double a = ran.NextDouble();
            double b = ran.NextDouble();
            return a - b;
        }

 
  这样程序的每个实例使用同一个随机种子,生成的数列便是随机分布了。网络训练收敛速度大为加快。
 
  有趣的是,在对原程序调试过程中我并没有很快地发现这个问题,因为我将包括随机数在内的很多网络参数输出到一个TextBox控件中,并
在每次输出时都Update窗口的显示,这样便降低了程序的运行速度,使得连续两个随机数的种子(时钟)不一致,生成的随机数列看上去还是随机的,问题便被悄悄地掩盖了。

浅议Fibonacci(斐波纳契)数列求解

2006/05/05 | 20:18 | 分类:数学类文档 | 标签: | 1,813次阅读

浅议Fibonacci(斐波纳契)数列求解

  Fibonacci数列:

浅议Fibonacci(斐波纳契)数列求解

  描述了动物繁殖数量、植物花序变化等自然规律。作为一个经典的数学问题,Fibonacci数列常作为例子出现在程序设计、数据结构与算法等多个相关学科中。

  下面简单地分析一下常见的Fibonacci数列求解算法。

  1、递归法。大多数教材在讲解递归算法时总喜欢以Fibonacci数列为例,这是因为我们可以直观地从定义公式的第三行看出Fibonacci数列的递归性。其C++实现如下:

unsigned long Fib(int n)
{
    if (n <= 1) {
        return n;
    } else {
        return Fib(n - 1) + Fib(n - 2);
    }
}

  递归算法与定义公式十分吻合,容易理解,但计算过程存在大量重复的运算,时间复杂度达到了O(2^n),使用的内存空间也随着函数调用栈的增长而增长。这显然不适于实用的程序。

  2、表驱动的递归法。这里不提纯粹的表驱动法,因为对于项数未知的Fibonacci数列开启大片的空间来换取时间未免不值得且不负责。我们只是为了消除递归法中大量重复的运算,可以将已经计算过的中间值存入一个表,已备后续使用:

#define MAX_LOG 20
static unsigned long Logs[MAX_LOG] = {0};
unsigned long Fib(int n)
{
    if (n <= 1) {
        return n;
    } else if (n < MAX_LOG && Logs[n] != 0) {
        return Logs[n];
    } else {
        Logs[n] = Fib(n - 1) + Fib(n - 2);
        return Logs[n];
    }
}

  当n小于保存的表长时,由于每个中间值只计算一次,时间复杂度降为O(n)。但随着n的增大,要想维持O(n)的时间复杂度,就必须扩大保存的表长,这就造成了存储空间的浪费。

  3、迭代法。求Fibonacci数列第n项时虽然要用到前面两项的值,但它们仅作为临时计算的中间值,不作为结果输出,因此无保留的必要,完全可以转化成迭代法求解:

unsigned long Fib(int n)
{
    int i;
    unsigned long a = 0, b = 1, c;
    if (n <= 1) {
        return n;
    } else {
        for (i = 2; i <= n; i++) {
            c = a + b;
            a = b;
            b = c;
        }
        return c;
    }
}

  迭代法的时间复杂度为O(n),使用的内存空间也不会动态上涨。个人认为Fibonacci数列更适宜作为迭代法而非递归法的典例出现在教材上。

  下面给出两种数学性较强的算法。考虑到表达的简洁性,用Matlab实现:

  4、转移矩阵法。此方法通常见于线性代数中的Markov过程示例。Fibonacci数列第n项与第n-1项可以通过转移矩阵的n-1次迭代求出:

浅议Fibonacci(斐波纳契)数列求解

  代码如下:

function y = Fib(n)
    first = [1; 0];
    trans = [1 1; 1 0];
    last = trans ^ (n - 1) * first;
    y = last(1, 1);
end

  此算法的时间复杂度相当于计算矩阵乘方的时间复杂度。在计算2阶矩阵n次方时,如果直接按矩阵乘法定义式展开,不加特别优化,其时间复杂度为O(n)。

  5、通项公式法。Fibonacci数列的通项公式如下(证明略):

浅议Fibonacci(斐波纳契)数列求解

  利用通项公式可以得到Fibonacci数列的任何项:

function y = Fib(n)
    sr5 = sqrt(5);
    y = uint32((((1 + sr5) / 2) ^ n - ((1 - sr5) / 2) ^ n) / sr5);
end

  该方法的时间复杂度貌似为O(1),但我们还应该考虑乘方运算的时间消耗。不加特别优化时,用乘法实现n次乘方的时间复杂度为O(n)。考虑到浮点数计算效率和精度问题,此方法在计算机实现时不如转移矩阵法。

  下面再给出两种对计算机实现有特别意义,但同时有一定局限性的实现方法(只是实现方法,不能称为新的算法)。其中使用了一些C++编程技巧,对使用其它语言实现也有一定的参考价值:

  6、模板元编程法。通常我们在C++中使用模板,仅限于类模板与函数模板。事实上C++支持模板元编程,理论上可以在编译时执行任何计算(甚至包含选择、循环、递归等结构)。代码如下:

#define Fib(N) FibT<N>::Val
template<int n> struct FibT
{
    enum
    {
        Val = FibT<n - 1>::Val + FibT<n - 2>::Val
    };
};
template<> struct FibT<0>
{
    enum
    {
        Val = 0
    };
};
template<> struct FibT<1>
{
    enum
    {
        Val = 1
    };
};

  我们用一个结构体作为模板的载体,用一个枚举值保存运算结果。其中第一个模板为基本递归过程(使用递归算法是为了说明的简便,完全可以用其它算法替代,以加速编译过程),后两个模板为n=0、1时的模板特化。通过#define语句将模板调用简写成类似函数调用的方式。程序在编译时运算所需的 Fibonacci数列项,将结果作为常量嵌入编译好的程序。运行时直接使用结果,时间复杂度真正地变成了O(1)。但这一方法最大的局限就是只能对常量嵌入,程序中出现诸如计算Fib(i++)的情况则无能为力。尽管如此,这比在代码中手工计算并写入所需的值要直观准确,比通过纯粹的表驱动法“空间换时间”要方便快捷。

  7、函数对象法。此方法主要用于C++ STL编程的通用算法方面,其执行行为也有别于以上其它方法:

class Fib
{
public:
    Fib() : a(0), b(1), n(0)
    {
    }
    unsigned long operator()()
    {
        if (n <= 1) {
            n++;
            return n - 1;
        } else {
            int c;
            c = a + b;
            a = b;
            b = c;
            return c;
        }
    }
private:
    int a, b, n;
};

  这个函数类对象的行为可以理解为一个“Fibonacci数列发生器”,其测试性调用如下,程序将依次打印

void test(int i)
{
    Fib fib;
    do {
        cout << fib() << endl;
    } while (i--);
}

  函数对象具有与函数指针类似的行为,同时又能保存自身的一些属性,因此常用于STL通用算法编程。但针对单个的Fibonacci数列项求值,灵活性不如一般的方法。

  希望读者能够从上面的算法分析中举一反三,有所领悟。

参考资料:
  1、Bruce Eckel,Thinking In C++ Volume 2: Practical Programming,机械工业出版社,2006
  2、William J. Collins,Data Structures and the Standard Template Library,机械工业出版社,2003
  3、Knott's Surrey University,The Home page for Fibonacci Numbers and the Golden Section,http://www.mcs.surrey.ac.uk/Personal/R.Knott/Fibonacci/

页面存档: 上页 1 2 下页