[控场AI]
· 7 分钟阅读· 3,719 字

把代码编译成神经网络:能学价格,学不会规则

把代码编译成神经网络:能学价格,学不会规则

Handwired 把 JS 手动翻译成可微网络,缩放量可学、决策量冻结。

Handwired 是一个把普通 JavaScript 手动翻译成神经网络的项目,发布两天即获 1313 颗星,但 Hacker News 上无人真正上手,最高赞评论只问「这东西的意义是什么」。作者用一个五条规则的运费函数做实验:翻译后为 12 层、50 操作、81 权重,在 1600 万输入上零误差,但速度比原函数慢 175 到 275 倍。反向传播显示,缩放答案的数值(价格、每公斤费率)能拿到干净梯度,而决定分支的阈值梯度恒为零。训练实验中价格常量被精确学到,免运费门槛却纹丝不动,网络只能扭曲价格来掩盖看不见的规则。这揭示其真正价值:把代码数值分成可拟合的缩放量和被冻结的决策量,让业务规则天然免疫梯度。

Cheng Lou 的新项目 Handwired 在 GitHub 上发布后,不到两天就收获了 1313 颗星,推特宣布贴获得超过 1 万点赞。项目口号很吸引人:「把你的普通 JavaScript 代码变成神经网络」——无需训练、输出与 JS 完全一致、且完全可微分。

但 Hacker News 的反应截然相反。最热门的提交只有 9 分,最高赞评论来自用户 skyzoidbroczky,只有一句话:「这东西的意义是什么?」另外两条评论也表示没看懂它的用途。整个讨论串里没有一个人说自己真正上手试过。

于是我拿自己写的一个函数做了实验。

Handwired 项目发布截图

名字就是使用说明:手动翻译,而非自动转换

Handwired 的核心思路可以用几行代码概括。一个比较运算变成一对 ReLU, 一个 if 同时计算两个分支,再用一个 0 或 1 的比特把它们混合起来。堆叠足够多的这种结构,你的程序就变成了一摞加权求和与 ReLU 乘积——一个权重由你手写、而非训练得来的神经网络。

官方文档说得很直白:「没有工具能把现有 JavaScript 源码转换成这种程序。」你必须手动翻译,写在一次性的构建代码里,把操作逐个追加到一个 Circuit 对象上。Handwired 这个名字正来源于此。

区间和网格也是翻译的一部分。README 里的例子把 (a > 4) ? 10 : 12 改写成 relu(a - 4) - relu(a - 5),并注明这只对整数 a 成立。实际 API 要求你为每个输入声明取值范围和网格粒度(比如整数分、整数克),然后它会构建出对你声明范围内每个取值都精确成立的比较。

ReLU(Rectified Linear Unit,修正线性单元)是神经网络中最常用的激活函数,定义为 $\mathrm{ReLU}(x) = \max(0, x)$。它把负数截断为零、保留正数原值,因此是一个分段线性函数。Handwired 用 ReLU 来表达比较并非偶然:ReLU(a - c) - ReLU(a - c - 1) 这类组合可以构造出阶梯状的指示函数,在整数网格上精确地返回 1 或 0,从而模拟布尔判断。由于 ReLU 的线性区处处可导且导数恒为 1,它对「缩放类」数值能传出干净的梯度;但在折点处导数不连续,这也正是 Handwired 中阈值处梯度为零的根本原因。

一个五条规则的运费函数

我写了一个整数分版本的运费计算函数,包含五条规则:会员且满 5000 分免运费、满 10000 分免运费、500 克以内收 499、2000 克以内收 899、超过 2000 克则每多 1 公斤加收 300。

翻译过程中唯一让我停下来思考的是 Math.ceil。这类程序不能循环,也不能除以运行时才确定的数,所以「超过 2 公斤后有几个整公斤」变成了八次比较,把各自的比特加起来。文档管这叫「阈值计数」。

最终产物:12 层、50 个操作、81 个权重。其中 12 次比较占了 24 个操作(每次两个),因为整数分和整数克让每次比较都能采用最廉价的精确形式。

我用最笨的方法验证了它:遍历 0 到 200 美元的每 25 分、0 到 10 公斤的每一克、会员与非会员两种状态,总共 16,021,602 个输入。与原始函数零误差,覆盖它可能返回的全部 11 种价格。

有个细节出乎意料。当我手工折叠那些 if 分支后(轻包裹必然不到 2 公斤,嵌套 select 可以塌缩成一个求和),同一个函数缩减到 7 层、37 个活跃操作,在 1600 万输入上答案完全一致。在 Handwired 里你就是编译器,你的代数化简会直接体现为网络深度。

速度并不好看。在忙于其他任务的 M1 Max 上,编译后的程序处理 1600 万次调用花了 10 到 17 秒,而原始函数只要 60 毫秒,差距约 175 到 275 倍。但这其实不是项目的卖点——README 的速度声明针对的是 GPU 上的批量求值,比如它的 JSON 搜索 demo 一次处理 10 万份文档。我没跑 GPU 路径。

阈值计数(threshold counting)在这里指用一组并行的比较把「有多少个整数单位超过了某个基准」表达出来。因为这类程序不能循环、也不允许除以运行时才确定的数,Math.ceil((w - 2000) / 1000) 无法直接翻译。做法是对每个可能的整公斤起点做一次比较,把结果(0 或 1 的比特)相加,得到超出的整公斤数。这种展开的代价是网络宽度与深度随范围线性增长,但换来了完全无环、纯前馈的结构——也正因为如此,它才能被表示为一叠加权求和与 ReLU。

梯度去了哪里:缩放量有梯度,决策量没有

「完全可微分」是宣传重点之一。我对一笔 30 美元、3.5 公斤、运费 14.99 美元的订单跑了反向传播:

  • 运费对每公斤费率的偏导 = 2,正好对应超重的两个整公斤。
  • 运费对中等档价格的偏导 = 1。
  • 运费对所有阈值的偏导 = 0。50 美元会员线、100 美元线、500 克、2 公斤,全是零。

凡是缩放答案的数值都拿到了干净的梯度,凡是决定走哪个分支的数值什么都拿不到。文档对此也毫不遮掩:比较操作回传的是阶跃函数的导数,除了比一个网格步长还窄的缝隙之外处处为零。

反向传播(backpropagation)是神经网络计算梯度的标准算法:沿计算图从输出向输入逐层应用链式法则,得到损失对每个权重的偏导。可微性的前提是每一步运算在求值点附近有良好定义的导数。比较操作(如 a > c)在数值上等价于阶跃函数,其导数除在跳变点外处处为零、在跳变点处未定义。因此在这种网络里,梯度能否流回某个参数,取决于该参数是参与连续缩放(乘、加)还是参与离散决策(比较、取整)。这解释了实验中每公斤费率得到梯度 2、而 100 美元门槛始终为 0 的现象。

教它一份新价目表:学会了价格,学不会规则

我用程序自己的梯度加普通 Adam 优化器,在 2000 笔随机订单上训练它的孪生网络。

第一次实验:只改价格(549、949、每公斤 350)。到第 100 步,网络精确学到了 549、949 和 350,2000 笔订单零错误。这个结果相当惊人:没有任何东西告诉它哪个常量是每公斤费率,梯度自己找到了。

第二次实验:价格不变,但免运费门槛从 100 美元改成 75 美元。 阈值纹丝不动,600 步里一直停在 100 美元,梯度始终为零。训练只好做了它唯一能做的事:把基础价格往下拖(499 变成 434.63,899 变成 792.88),把每公斤费率微调到 313.87,以此软化那些本应免运费订单的误差。均方误差几乎没动,从 245,000 到 243,380。在它仍然收费的 769 笔订单里,一笔都没算对——和训练前错的是同一批。

它学会了一份更差的价目表,来掩盖一条它看不见的规则。

这正是项目的 issue #1 想要解决的问题。作者估计出每个决策点在其翻转位置附近的梯度。在他们的双栏布局测试中,普通反向传播把断点留在 500,而他们的方法把它推到 699.9(目标是 720),能够拟合所有样本。目前这个方案还在一个 fork 分支上。

那么意义究竟是什么

回到那条 HN 评论。我的回答是:把代码翻译成网络,等于把它的数值分成两堆。缩放类数值(价格、利润率、尺寸、弹簧系数)变成可以从样本中拟合的权重;决策类数值(阈值、断点)保持精确、冻结不动。

第二堆既是限制也是特性。一条你不想被梯度掰弯的规则,它就真的弯不了,而它周围的常量仍然可以调。项目里的 learned gallery demo 正是这个思路:一个布局程序内嵌小型网络,网络系数用程序自身的梯度训练。

我遇到的限制:必须手动翻译,区间和网格得自己声明,一切都是 32 位浮点(对整数分够用,对浮点金额有风险),仓库的测试需要 Bun,所以我没跑。

留一个问题:你自己代码里的哪些数值愿意交给梯度,哪些绝不让它碰?

Adam(Adaptive Moment Estimation)是一种常用的随机梯度下降变体,维护梯度的一阶矩和二阶矩的滑动平均,为每个参数自适应地调整步长。它被广泛用于训练深度网络,但前提是目标函数对参数可微。在 Handwired 的实验里,Adam 对价格常量(549、949、350)能迅速收敛到精确值,而面对阈值的零梯度只能停滞——这不是优化器的缺陷,而是可微结构的边界:优化器只能沿着梯度存在的方向移动参数。这一对比恰好凸显了「缩放类数值」与「决策类数值」在可训练性上的本质分野。

#Handwired#神经网络#代码编译#可微分编程#梯度下降#JavaScript#Cheng Lou
分享:

相关推荐