记第一次数模竞赛
记第一次数模竞赛
Justin Wu
(一)
Jason和Kerry是我的两个队友。Jason涉猎广泛,有着很好的理论基础;Kerry实践能力颇强,算法与coding能力也不差。虽然我们没有一个人参加过建模比赛,但仍对这支队伍充满信心。
比赛前两天,我们开了一次长会,建模的分工成为了会议的焦点。别人的分工,往往有建模手、论文手、代码手等,而我们并无秩序,或言羁绊。队伍的分工确实明确了每个人的方向,然而会导致这支队伍失去了团结一致的信念,颇有趁早干完歇班的颓废感。我们每个人都有独立的灵魂,独立的思想,更有独立完成整个建模的能力。把分工留给机会,是我们的选择。
9月4日晚6点,比赛开始。按照规划,我们每个人需要独立阅读题目,在晚上9点开会讨论选题。我吃完饭打开电脑已经是7点,三组问题的PDF早已发到了我的微信。
开始分析。A题读完,丑陋的数据给了我当头一棒。难以想象的空间绘图,奇怪的坐标数据,还有最让数院人失去美感的——没有任何的对称性。想到去年的A题,龙头坐标犹有螺线的对称,犹能用极坐标建模,而今年的题目只有不断移动的视野线和烟雾弹。总之,参数结构的丑陋使我大失胃口。
我怀揣希望看向B题,然而空虚的物理基础使我寸步难行。反射折射还能勉强理解,衍射的定量计算直接揭开了我高中物理的伤疤。一个在高考前都要背一遍双缝干涉的公式的人,是无论如何也没有信心在B题与参加过物理竞赛的同学battle的。Kerry在三人群转发着多衍射的知乎资料,我艰难的翻看着,满屏的公式把我推向C题。
看到C题,我觉察到我并不能胜任这个题目对统计学背景的要求。大一下遗憾未能选到数理统计课程,于是对统计的认知从毕业开始便停止了进步。打开数据表,一列列的生物学的统计信息用肉眼完全看不懂规律,它们留给我的只有按行看数据吃瓜的乐趣了。
三题读完,心如死灰。一点可做的希望都没有。这时我才意识到赛前对参加建模大赛的知识估计有多低。丑陋的结构,陌生的物理,未知的统计,像三座大山把我压在工位。
我翻看着三组问题,逐渐冷静下来。Kerry支持做A组题,Jason也赞成。我们对物理和统计学的认识在B和C组题上确实显得捉襟见肘,似乎A题是唯一的选择。
我们打算做A题。
A题讲述的是若干烟雾弹在三维空间遮蔽导弹视野的故事,总共有5问,导弹数量和烟雾弹数量递增,模型也逐渐复杂。导弹的移动已被固定为匀速直线运动,直指离真目标不远的假目标;无人机飞行方向、烟雾弹投放时间和引爆时间决定了烟雾弹能遮蔽导弹视野的时间。
我不久前认为这个模型参数结构复杂,在建模时发现为两个问题:一个是目标圆柱体的视野锥不好刻画,一个是15个烟雾弹遮蔽时间的重叠交错很难算清楚。
Kerry和我开始激烈讨论起优化的核心算法。抽象地说,是在高维的参数空间里优化一个复杂的函数。碍于18年里培养的的数学严谨的思想,我否定了Kerry提出的把真假目标化为同一目标以进行简化的想法,迟缓地赞同了Kerry提出的用若干个点来代替目标圆柱体的估计想法。我总是天真地幻想着结果的最优性,甚至幻想它最优性的理论证明。我们利用视野z锥截面的恒凸性,发现只要在圆柱上取若干点,使得包围他们的凸包几乎是圆柱即可,于是我们选定在圆柱的上下底的两圆环上均匀取点,用他们被遮挡的统一性来代替整个圆柱的被遮挡性。
我开始谋划第五题的算法,并不打算挨个做起。这是因为,在我眼里,数学竞赛中很少见到循序渐进的题目,多小问只是多几个没有包含关系的问题罢了。像23年CMO的第5题,两小问的证明也可以是独立的,不少人恰拿到了第二问的分数。于是我只想通过解决第5题的代码,降低要求来解决前4个问题。
我最先想到可以把烟雾弹的1s间隔与无人机的移动限制统一表示为每一个烟雾弹的可行域。换言之,我把无人机的速度、烟雾弹的投放和爆炸时间这样复杂的参数简化为描绘烟雾弹的空间分布位置限制。这其实是用另一套变量体系表达旧变量体系。大一上旁听的ML让我想起KKT条件,于是限制的边界又可以通过KKT条件化为等式。如此这般,一个个限制的不等式就通过KKT化为等式,只不过同一架无人机上的可行域有些关系。
想到这里,我都快跳起来了。一个复杂的建模问题,被我一时的妙想解决掉了。彼时我还沉浸在北大title带来的信心中,坚信想到这些的同学必然是少的,坚信数模比赛的难度一定低的。然而我自己得意中的一点思考便发现了问题:被优化函数的梯度不能求。
被优化函数其实就是遮蔽时间。固定其他变量,仅仅调整一个无人机的飞行角度,遮蔽时间都会面临巨大的改变。无人机的飞行角度直接影响3颗烟雾弹的炸点,不同的遮蔽轨迹又可能和另外12颗不变的遮蔽轨迹有了交集,总之写出由40个变量表示的显式表达几乎是不可能的。
梯度不能求,在我当时的眼里简直是灭顶之灾。ML绝大多数的算法都依赖被优化函数的梯度,KKT也不例外。而被优化函数除了连续的性质,一无所有。数学人眼里,没有了导数,就好像失去了一切。此时已是晚11点,自习的位置开始赶人了。我独自走回宿舍,希望一切困难于梦中烟消云散。
(二)
第二天醒来,我继续明确了梯度的重要性,希望能够表示出一个可以求梯度的优化函数。最开始想的就是torch的autograd。虽然Pytorch并不能给到一个具体的导数公式,但是它的反向传播还是能支撑很多函数用于写正向传播的。问题又来到了数值求解:
如何用可被torch反向求导的函数,求出一个精确的总遮蔽时间?
不用定积分,就算给我了40个完整的参数,我也不能在数学上写出一个表达式来计算遮蔽时间。这个想法在我和Jason的共同努力下依旧失败,遂放弃了。理由也很简单,如果真的抛弃了积分,意味着我需要考虑每个烟雾弹的进视野和出视野的时间,考虑多个烟雾弹之间的重复遮挡影响,考虑多个导弹之间的重复遮挡影响等等。这些数字就算能挨个算出来(其实是没有显式表达的),然而我们甚至不知道那些数字是可能出现的,哪些是不可能发生的。
所以问题的解决还是要依靠积分,对时间的积分。怎么写呢?遮蔽时间其实就是一个很朴素的积分:从t = 0积分到导弹撞击目标时间,被积函数是“t时刻被遮蔽的导弹数”。判断一个导弹是否在此时被遮蔽太简单了:求所有烟雾弹位置,验证视野线是否都被遮蔽即可。
这个函数怎么能求梯度呢?只能说是对每个时间点求梯度再积分罢了。可是这样的计算量会非常大,最后的梯度也有着误差。对数学人来说,又是可恶的误差,我不能接受的误差。
那怎么办呢,还是回去看看不积分的做法吧。
我开始思考遮蔽时间这个函数在梯度上出现的问题。我不假思索的认为,不能求梯度的原因有两个:一个是烟雾弹突然进入导弹视野时,并不能给出连续可微的函数;另一个是我们很难刻画多个视野线同时被遮住的“且”条件。
然而这些问题在我后续反思时看来,都是不必要的。ReLU都能被torch反向传播,一个端点处的不可微又能怎样?分段可微,徒增笑尔。但在当时,我急于给出一个几乎是初等的遮蔽公式,并未考虑到不初等的函数也可以是几乎处处可微的。我的下一步就是找到这样的初等函数。
众所周知,Sigmoid函数是很好的初等激活函数,提升温度后也可以作为一个0-1的软分隔函数,这在训练分类器时很常用。于是我在整个空间里定义了一个初等的标量场,用于刻画此处的视野情况。15个烟雾弹,每时刻都会在空间里生成一个标量场,这个场的定义依靠到该烟雾弹中心的距离r,很自然的,可以定义成sigmoid(10 - r),这样在r = 10的位置,0很快变成1,形成了空间里的分隔球。于是我把15个这样的场叠加,再与1求min避免烟雾弹重合,于是空间有了对烟雾弹的软分割,有了连续可微函数。
烟雾爆炸后自然有用于判断遮蔽的场函数,那么爆炸未发生的时候呢?我还需要给这个场函数加上一些关于时间的变量。我痛恨if和else决定的函数,还是希望能用初等的东西来构造。我看着sigmoid一半取0一半取1的样子,创新地提出用sigmoid的乘积来刻画是否开启这个场函数。也就是说,如果想要一个函数在t \in [a, b]时才取想要的函数值,其他时候取0,只需给你的函数乘上sigmoid(t - a) sigmoid(b - t)即可。后来查阅资料才知道,这已经是被发明过的技巧了,sigmoid(t - a) sigmoid(b - t)也被成为窗函数。
那怎么利用这个视野场来刻画遮蔽情况呢,也很简单,取检查某一条视野线上的点在这个场上的取值的最大值便是了,如果这条视野线上的点的值都很小,说明根本没有经过烟雾弹,反之则不然。
很好很好,我有了一个很棒的初等函数刻画某一点的烟雾浓度,我马上动手开始写建模。我写了满满两页的数学公式,并用随机梯度下降(SGD)写了一个demo用于训练。等待模型训练过程中,我开始查看队友的进度。
Kerry在独立验证着自己的想法。他希望能从第一问稳稳做起,循序渐进,看起来已经完成第一问了。第一问并不是优化问题,而是简单的数值计算,也就是说,只需要写一个遮蔽时间的判断即可。Jason在推理论,试图用数学来找到一些模型上的简化。我多么希望他能够算出一个初等的,哪怕复杂一点的函数去刻画遮蔽时间啊。
此时已经是第二天的中午,我不知道能不能在今天找到我心仪的算法,也不知道我们三人能不能完成这次比赛。
训练跑完了,然而所有loop的遮蔽时间一直是零。我不是梯度下降了吗?怎么一点变化都没有?我马上动手可视化,发现无人机移动方向在训练前后根本没变。
这是什么原因。我很快意识到是因为sigmoid函数在阶梯之外的梯度异常小,即按指数收敛到零。然而整个空间的坐标和速度数值都比较大:导数可能只有0.1,但是坐标是以万计的。原来是学习率的问题!我把lr = 0.01立刻改成了lr = 10。
新一轮的训练终于出现了正值的遮蔽时间。可是遮蔽时间的数值太小,小到我用膝盖都可以随便构造。我反复地进行一轮轮的训练,可是结果始终不如意。用运气碰结果的想法又是我不能接受的。我反复调整着超参数,一次训练就要10分钟,可我根本总结不出合适的学习率。此时已经是晚上23点,我收拾东西准备回宿舍。
走在路上,我陷入了沉思:这个参数空间这么大,能取到好的遮蔽时间的点那么少,模型训练最困难的就是初始的部分了,因为我们对这个参数空间一无所知。如果能在训练的时候给初始的部分更多的信息,让他能迅速依赖较大的梯度走到极值点附近,后续的训练其实会轻松很多。
对呀!sigmoid函数在10之外的梯度那么小,我何不把这段梯度放大呢?也就是说,我可以将梯度均匀分摊在5到20之间,这样初始的训练就会有更多的信息!想到这里,为初始训练提供更多信息的idea如雨后春笋冒出:增大烟雾半径,修改sigmoid参数,修改学习率等等。
我立刻拿起书包跑向经院楼,一个能通宵自习的地方。一坐下便打开电脑,制定着一步步的训练方案。队友早已经睡去,我在一个朋友的陪伴下,在深夜与模型搏斗。巧合的是,座位旁边还有其他三个做数模的对手,而我孤军奋战。
时间来到凌晨2点,我不断调整着烟雾浓度函数。之前设想的低温sigmoid仍然具有很小的梯度,于是我换成了Gauss分布;Gauss分布也没有很好的提升,我又修改了学习率和遮蔽半径。按Kerry的话说,这就是在炼丹。
可我不是太上老君,没有好的炼丹炉——GPU,每次训练都花费我大量的时间。冷静分析后,我认为对于微弱的梯度来说,速度的学习率可能要到100,而时间的学习率可能只需要1。疲倦的身体不能支撑我继续做实验修改学习率,我带着完美的idea跑到了经院楼,背着遗憾走回了宿舍。
(三)
尽管第三天凌晨3点才睡,但是为了新学期抢课,我还是需要10点起床。Jason因事刚到学校,于是我们到智华楼预约了玻璃房第一次展开面对面讨论,各自汇报进度。Kerry已经解决了前两题的算法和代码,给出了精细的数值解。第一问只是计算数值,第二问需要的初参数可以从第一问直接拿取。我在我第五题原创的“逐步训练”算法中无法自拔,花了大概半个小时跟他们讲清楚了我所有的动机和想法,如前两篇陈述的那样。
Kerry抱怨道:“我第三题做的也不好,现在是在用遗传算法,但是最开始输入的族群太关键了,如果随机去选的话效果很差。”
“噢你直接放弃了梯度啊?完全靠数值去做?”我很疑惑,因为我并未接触过仅靠数值分布来优化的算法。
“是啊,我现在就是手动去写初始族群。Jason,你有时间帮我算算呗,找个差不多的方案就可以,让遗传算法再去优化。”
Jason淡定地说:“那你可以在正式训练前直接先训好一批比较棒的族群啊。”
“对呀!我们的痛点不就是在参数空间中优秀遮蔽方案的稀疏性吗!如果直接按遮蔽时间作为优化函数,那必然很难做到梯度下降,我们可以考虑其他的易优化的函数去做!”我激动得差点跳起来。
我之前的算法不也是一样的想法吗?在我的算法里,使用高学习率,高遮蔽半径以及缓sigmoid阶梯,都是想找到另一个与原遮蔽时间定义不同的函数,使得这个新函数容易被优化。而我的思想还是被禁锢在遮蔽的定义附近,不如更直接一些——
“那直接考虑爆炸中心到视野线距离的平方损失就可以了啊。”Kerry补充道。
眼前的烟雾散去了。距离平方损失预训练+遗传算法,多么伟大的想法。想到预训练会让初始参数非常好的分散在视野线附近,想到在这样优秀的预训练数据下遗传算法将会爆发多么大的威力,还需要优化什么?变异率?交配率?还是训练速度?这些推进远不如我们三人在这几分钟的讨论关键。
于是我们的训练被分为了两步,第一步是用新的loss函数——烟雾弹爆炸位置到视野线的距离的平方损失之和——来优化随机生成的参数向量,直到loss接近于0;第二步是利用预训练好的向量作为父本,使用遗传算法精确计算遮蔽时间,进行筛选、交配和变异。
我庆幸我们不是按照代码手、建模手、论文手分工。我们的算法并不是靠一个人的灵机一动想到的。准确地说,是我先想到给最初的训练“松弛”一下函数,Jason准确提出预训练的想法,Kerry最终用代码实现。我在第一篇提到的无分工策略,在这里得到了很好的应用。
“我马上写一个预训练代码。”Kerry放弃了之前手搓的预训练数据。
“我来读一下你的前两问。”我也放弃了我那个失败的软间隔算法,决定在我们新讨论出的新算法上ALL IN。
“那我先去写论文好了。”Jason去用数学公式建模了。
这朵乌云最终只在我们头上盘旋了2天。
离开智华楼,我们前往了光华咖啡,坐在角落的沙发里写代码。Kerry在前两问的贡献已经让他足够熟悉遗传算法的流程,于是他去写第三问的代码。我则在他前两问的算法里做一些优化和可视化。
我躺在沙发上,手里捏着一杯冰美式,看向远处一桌玩德扑的同学。他们应该是娱乐局,对话里充满了乐趣。有人想几句话想勾引朋友下注,又有人不断发出fold的声音,突然一声all in,举座皆惊。不久,一位女士爽朗地笑了,大抵是赢走了ALL IN玩家的所有筹码。
晚饭时间,我们三人完全沉浸在代码中。Jason实在扛不住,就近找了家校外餐厅吃饭,我和Kerry打算继续咖啡代餐。
“第四问做出来了!”Kerry喊道。“我第三问还是用的自己写的预训练数据,第四问加上我们的预训练之后,效果好很多!”
我凑过身去,看到Kerry把距离平方损失几乎为0的参数点放到了遗传算法的初始族群,遮蔽时间很快收敛到一个较大的值。
“这太棒了,快push到仓库里我看看。”我惊呼道。
拉取仓库后,发现代码结构还是过于简朴了。两个文件,一个是pretrain.py,一个是P4.py,居然需要先跑pretrain,手动粘贴过去参数后才能开始正式训练。我马上修改成import pretrain的形式,修改了一些算法,这样P4基本也算是大功告成了。
第三天晚上9:30,光华咖啡打烊了,我带着他们前往了第二天晚上我工作的经院楼。
“哎我说,咱今晚有这么好的idea,要不通宵写完吧。”我兴奋提议。“我跟你讲我以前做的所有的project都是一个通宵结尾的。”
“我……没问题吧,都可以。”Jason说。
“你俩没问题,我当然没问题。”Kerry附和道。
于是三人,两车,走在北大校园里,走向他们最后奋斗的战场。
(四)
最后的时刻到了。到论文提交只有22小时,然而我们刚刚找到合适的算法,论文只有三四页。Jason在写论文,而我和Kerry更像是两个人的流水线,他负责跑通,我负责优化。
每次优化都要阅读大量的代码,仔细调整超参数。
我想到了几个不错的策略。首先第一个是向量化。一般来说,同样对一组(batch)数据进行计算或者优化,使用for循环的时间复杂度往往是o(n)的。然而如果把这一组参数张量看作新的张量,成组成组的优化,那么torch包或者numpy包就会自动发动他们在编译层面的“超能力”,使得复杂度下降。这样能够大大节省训练时间。
其次是精英保留策略。这是遗传算法里经典的优化方法,把一些特别好的染色体保留,不参与遗传和变异,这样避免迭代后的最佳结果反而更差。
最后是动态调参策略。在随机梯度下降算法中,常常有自动调整学习率的算法,例如Adam。同样在遗传算法中还会有动态调整变异率的算法。当遮蔽时间长时间没有提升时,我们会自动提高变异率,使得产生更多样的子代。我跟Kerry的形容是这样的:当所有子代在一个极大值点蜷缩不动时,需要一个“炸弹”把他们炸开,去探索其他的极大值点。这枚炸弹就是提高的变异率。
可是这样的算法得出的遮蔽时间还是不够长,我还是担心遗漏了一些可以取到更大遮蔽时间的参数点。仔细思考后,我认为问题出现在预训练。预训练当然得到了很好的初始参数,但是仅仅依靠距离平方损失优化的初始点只能得到很单调的父代,不具有多样性,于是我开始尝试为预训练的输出设置多样化处理。
多样化处理是什么意思呢?我们的预训练20次,只能得到几种非常相近的参数向量,如果训练1000次,那得到的向量又非常多,不适合作为父代。于是我考虑了一种贪心算法:我预训练出1000个向量,每次添加到20元集合,并删掉一个,使得新的20个向量更具有多样性。怎么刻画多样性呢?去考察这20个向量的各个分量的方差之和即可。如此一来,每次添加新向量,都可以增加预训练集的多样性,以此类推,最后保留下的向量集合一定是很丰富的。而预训练是很快的,大概一秒就可以训练出若干个参数向量,我只需一个个尝试贪心即可。
这当然是一个很伟大的想法,如果再给我一天,我一定可以优化得更好。然而在我尝试了几次后,由于时间紧张,不能尝试下去,遂放弃。
Kerry最先完成了P5的代码,而我还在优化P4,于是Kerry转去做P1的更好的可视化。抬头看时间,已经到了第四天。昏暗的走廊,明亮的桌台,队友不时传来的讨论声。激烈跳动的心脏提醒我的疲惫,眼前浮动的高亮代码刺激着我的神经。我一次次去卫生间洗脸,去隔壁的售货柜买零食。另一个数模小队已经在11点被我们熬走,大厅空旷,只听见我们的交谈声;不作声时,便是机械的键盘声。我也有几次类似的做project的经历,一次是给我自己写个人博客作为生日礼物,在成年生日的ddl下做到早上5点;一次是用godot写游戏,在第二天晚提交的ddl下做到早上10点。也许是前两天身体疲惫了,也许是年龄大了,这次到了凌晨2点我便残破不堪。我吃力地push掉最后一个commit,输入一串激动人心的提交信息:
git commit -m ‘BEST!BEST!P5.py!!!!’
我今天的工作结束了,此时时间定格在2:48。
我跑到Jason和Kerry的中间,搭着他们的肩膀上看他们工作。Jason把建模写好了,Kerry把可视化做到了第三题。临走前,Jason最后把P5的训练放到了云服务器上,离开了经院楼。
路上,Jason看着被遮挡一半的月亮问:“这里什么时候安了一盏这么亮的灯啊?”
我和Kerry笑道:“这是月亮啊。”看来Jason也不清醒了。
醒来还是第四天,不敢多睡,早上十点半,我们又来到了经院楼。
“P5训出了35s的好成绩!”Jason打开服务器,找到日志。“但是怎么没有记录最值时的参数?你没有加吗?”
我猛地想起来,修改昨晚的P5代码时,我居然忘记在训练最后打印取最值时的参数。跑了一晚上的代码,最终只能给一个数字,不能用于提交xlsx文件,痛心疾首!Jason无奈,修改后继续跑P5的训练,我和Kerry继续完善可视化。
为了数模,我从宿舍带来了七夕送的巧克力,一整盒费列罗全部用来支持比赛。Kerry也从宿舍带来了库存的面包,再配上售货柜里的零食,我们一整天没去食堂吃饭。饮水机和卫生间离我们只有十几米的距离,经院楼坐落于未名湖东北侧,也少有人来打扰。
下午3点到7点,我似乎失去了记忆。那4个小时,只记得优化、写latex还有可视化这些关键词。人们在神经紧张的时候总是会忘记一些事情,试图让自己的心情更融洽,然而对过度紧张的事情总会记忆犹新。于是我还记得:
最后1小时。
我已经做不了什么了,Kerry在把训练最佳结果的向量填到xlsx表,Jason在努力写论文,由于没有调好论文的共同写作模式,我只能提前下载好提交论文的客户端等待。
“这个怎么总是报错啊,”Kerry急了,他在写代码将参数向量输入到xlsx表,“你快帮我算算这个爆炸时间减去投放时间。”
我不敢多问,虽然不知道为什么要算这个,我还是打开了手机的计算器。
“等……等下,我填好这个,你就给我报数。”Kerry盯着电脑,对我说。
“13.25,14.16……”我笨拙地敲着计算器,眼睛一会看向电脑,一会看向手机。
“这个代码怎么输入到论文里啊。”Jason那边也出现了问题,“好丑啊。”Jason试图把代码放到论文里,在尝试怎么能让latex忽略掉作为注释的“#”……
一团糟的半个多小时过去了,最后15分钟。
Jason导出论文pdf,Kerry压缩出支撑材料,我准备开始提交。这时Jason突然看见论文的引用文献出现了编译错误。
我立马安慰道:“你先改,我把错误这版先交上,保证有论文可交。”
Jason马上打开电脑修改、编译,可是这时候他电脑闪退了。
“怎么这个时候闪退啊。”Jason也开始着急,试了几次后开始重启电脑。
电脑重启成功了,Jason还在改着细节,还剩5分钟。
“Kerry咱的xlsx文件放哪里了?”
“你没放到paper里吗?”Kerry惊呼。
Kerry马上把xlsx文件放到压缩包,可是时间只有两分钟了。我立刻打开电脑,拉取最新仓库,打开文件资源管理器,解压原文件,找到xlsx文件放到其中,再压缩,挪到客户端。电脑右下角显示19:59。
我上传支撑文件,颤抖着点击“生成MD5码”,这是19:59:39。
我又从微信下载Jason传上去的新论文,颤抖着点击“生成MD5码”,这是19:59:52。
我呆滞住,过了几秒钟,20:00显示在我的电脑上。
“我靠刚才你提交的时候只差8秒。”Jason说。
“没有吧,他前面提交了一次,还差21秒。”Kerry补充道。
一切都结束了。





