ODIN TANK · MLP

旋钮打分机

把坦克网络想成一台有 23,698 个旋钮的打分机器。数字从左流到右,错了就从右往回追责任,再把旋钮拧一点点。

仪器待命

开始之后,跑到的函数会在对应代码上亮起来

01它在干什么

喂进 37 个数字:敌人在哪、炮弹多远、自己还剩多少血。吐出 18 个分数,每个分数是一种动作想做的程度。选最高的,或者按分数当概率抽签。

37

输入

128

ReLU

128

ReLU

18

动作

旋钮形状个数
W1128 × 374,736
b1128128
W2128 × 12816,384
b2128128
W318 × 1282,304
b31818
合计一个扁平数组23,698

第二层 128×128 就占了 16384 个。He 初始化让前面两层的权重大约是 N(0, 2/扇入),输出层再乘 0.1,免得刚出生就固执。

tank/train.odin · roll_one每一步的调用
observe(&g, me, s.obs[:])
forward(j.p, s.obs[:], &c)
a := sample_from(c.p[:], &r)
s.teacher = i8(teacher_action(&g, me))
game_step(&g, Action(a), oa)
rew := cfg.r_hit * f32(g.ev_hits[me]) - cfg.r_hit * f32(g.ev_hits[1 - me])
if pol.kind == .Net_Greedy {return Action(argmax(c.p[:]))}
case .Teacher: return teacher_action(g, me)

亮的是 Odin 原文件里这一拍真正对应的那一行。浏览器里跑的是同一套算法。训练比眼睛快,所以大约每半秒才移到下一处。

02一个神经元

跟小学的加权平均一样:每个输入乘一根旋钮,加起来,再加偏置。负数被 ReLU 掐成 0,正数原样通过。不够兴奋,就不说话。

tank/nn.odin · forward_cachedReLU
s := p[OFF_B1 + j]
row := w1[j * N_IN:]
for i in 0 ..< N_IN {s += row[i] * c.x[i]}
c.h1[j] = s if s > 0 else 0

正在接通这台网络…

03前向传播

同一批旋钮,换一种战场,分数就换。下面三张是课本情景,不是某一帧的真实观测。训练一旦开始,这些分数会跟着旋钮一起变。

tank/nn.odin · forward_cached前向
for j in 0 ..< N_H {
s := p[OFF_B1 + j]
for i in 0 ..< N_IN {s += row[i] * c.x[i]}
c.h1[j] = s if s > 0 else 0
}
for j in 0 ..< N_H {
s := p[OFF_B2 + j]
for i in 0 ..< N_H {s += row[i] * c.h1[i]}
c.h2[j] = s if s > 0 else 0
}
c.z[k] = s

04分数变成概率

18 个分数可以是 3.2、−1、0.5。softmax 先减去最大的那个,再做指数,让它们加起来等于 1。负数也不会把机器弄炸。

tank/nn.odin · forward_cachedsoftmax
m := c.z[0]
for k in 1 ..< N_OUT {m = max(m, c.z[k])}
for k in 0 ..< N_OUT {sum += math.exp(c.z[k] - m)}
lse := m + math.ln(sum)
c.logp[k] = c.z[k] - lse
c.p[k] = math.exp(c.logp[k])
tank/nn.odin · argmax选最高分
argmax :: proc(v: []$T) -> int {
b := 0
for i in 1 ..< len(v) {if v[i] > v[b] {b = i}}
return b
}
tank/nn.odin · sample_from按概率抽签
u := T(rng_f64(r))
acc := T(0)
for i in 0 ..< len(pr) {
acc += pr[i]
if u < acc {return i}
}

两个分数的小例子。老师说:该开火。

左转 27% · 开火 73% · 损失 −log(开火) = 0.313
反向到两个分数:左转 0.269,开火 -0.269

开火的梯度是负的,所以损失下降时它的分数会升高。这就是「老师说该开火,就把开火的概率往上调」。

05怎么学习:调旋钮

三步循环。打分看出错多少,反向传播问谁的责任大,Adam 记得上次往哪拧,这一次就拧得更稳。梯度范数太大时先裁剪,防止一下拧坏。

旋钮在转

大的四颗是这一批责任最大的,会跟着 Adam 拧过去。正的权重顺时针,负的逆时针。小的是从 23,698 根里均匀抽出来的,也会轻轻动。

1 · loss_grad

打分

还没拧过。损失会在第一次更新之后出现在这里。

2 · backward

找责任

反向传播从 18 个动作的误差,穿过没被关掉的 ReLU,回到每一根旋钮。

3 · adam_step

拧一下

Adam 用两份记忆:方向的平均,和步子的大小。b1 = 0.9,b2 = 0.999。

tank/nn.odin · loss_grad打分
H := T(0)
for k in 0 ..< N_OUT {H -= c.p[k] * c.logp[k]}
for k in 0 ..< N_OUT {dz[k] = 0}
return L
tank/nn.odin · backward找责任
grow[i] += d * c.h2[i]
dh2[i] += row[i] * d
if c.h2[j] <= 0 {continue} // ReLU 导数
grow[i] += d * c.h1[i]
if c.h1[j] <= 0 {continue}
grow[i] += d * c.x[i]
tank/nn.odin · adam_step拧一下
if max_norm > 0 && norm > max_norm {scale = max_norm / norm}
a.m[i] = a.b1 * a.m[i] + (1 - a.b1) * g
a.v[i] = a.b2 * a.v[i] + (1 - a.b2) * g * g
mh := a.m[i] / bc1
vh := a.v[i] / bc2
p[i] -= a.lr * mh / (math.sqrt(vh) + a.eps)

开始拧之后,这里显示这一批概率被拧动了多少。

06两种学法,合成一个损失

模仿老师

老师说「这时该开火」,网络就把开火的概率往上调。代码里动作设成 −1,λ = 1,熵奖励 = 0,剩下的就是交叉熵。

tank/nn.odin · loss_grad模仿
if lt.teacher >= 0 && lt.lam != 0 {
L -= lt.lam * f64(c.logp[lt.teacher])
for k in 0 ..< N_OUT {dz[k] += T(lt.lam) * c.p[k]}
dz[lt.teacher] -= T(lt.lam)
}
tank/train.odin · choose_action场上听老师
case .Teacher:
return teacher_action(g, me)

自己试错

按概率抽一个动作。这一小段的回报减去这批的平均,就是优势 adv。优势大于 0 就多做,小于 0 就少做。

tank/nn.odin · loss_grad试错
if lt.action >= 0 {
L -= lt.adv * f64(c.logp[lt.action])
for k in 0 ..< N_OUT {dz[k] += T(lt.adv) * c.p[k]}
dz[lt.action] -= T(lt.adv)
}

别太早固执

熵奖励鼓励它多试试别的动作。ent 越大,损失越讨厌「把概率压成一条」。

tank/nn.odin · loss_grad熵
if lt.ent != 0 {
L -= lt.ent * f64(H)
for k in 0 ..< N_OUT {
dz[k] += T(lt.ent) * c.p[k] * (c.logp[k] + H)
}
}

模仿模式把这两项锁死,跟代码里的行为克隆一致。

07手算一次

缩小到 2 个输入、1 个神经元。目标是 0:这颗神经元代表「该开火」,可是冷却还没好,不该兴奋。一步步看前向、反向、再拧。

  1. …
  2. …
  3. …
  4. …
  5. …

08现场训练

固定矩形掩体、18 个组合动作、37 维观测,网络和 Adam 跟 Odin 里是同一套。模仿时两边都听老师的,网络只负责猜;试错时左边交给网络抽签。单局最多 1200 步。

tank/obs.odin · observe37 个数
o[0] = a.x / ARENA_W
o[4] = dist / ARENA_DIAG
o[15] = a.hp / MAX_HP
o[17] = 1 if line_of_sight(g, me) else 0
o[18] = 1 if aim_on_target(g, me) else 0
o[28] = threat
tank/teacher.odin · teacher_action老师
los := line_of_sight(g, me)
if !los {target_x, target_y = navigation_target(g, me)}
if turn_err > turn_tol {turn = .Right} else if turn_err < -turn_tol {turn = .Left}
if dodge_move, dodge_turn, threatened := dodge_action(g, me); threatened {
move = dodge_move
turn = dodge_turn
}
fire := los && abs(aim_err) <= tol && a.cooldown == 0
return make_action(move, turn, fire)
tank/game.odin · game_step走一步
apply_action(g, 0, a0)
apply_action(g, 1, a1)
b.x += b.vx / SUB
if dx * dx + dy * dy < rr * rr {t.hp -= BULLET_DAMAGE}
if d0 || d1 {
g.done = true
} else if g.step >= MAX_STEPS {
g.done = true
}
tank/train.odin · roll_one这一步的奖罚
rew := cfg.r_hit * f32(g.ev_hits[me]) - cfg.r_hit * f32(g.ev_hits[1 - me])
if g.ev_fired[me] {rew -= cfg.r_ammo}
if t.bumped {rew -= cfg.r_bump}
if t.idle {rew -= cfg.r_idle}
if g.winner == i32(me) {rew += 1} else if g.winner == i32(1 - me) {rew -= 1}

我方血

100

步

0

敌方血

100

旋钮是随机的。输出层被缩小了 10 倍,所以一开始几乎在乱猜。

原程序把这 23,698 个 f32 写成带魔数 TNK1 的文件。这里写进浏览器本地,只留在这台设备上。随机三角形掩体和 raylib 窗口仍在 Odin 工程里。

tank/nn.odin · net_save记住
MAGIC :: u32(0x314B4E54) // "TNK1"
hdr := [4]u32{MAGIC, N_IN, N_H, N_OUT}
libc.fwrite(&hdr, 4, 4, f)
libc.fwrite(raw_data(p), 4, uint(len(p)), f)