1 · loss_grad
打分
还没拧过。损失会在第一次更新之后出现在这里。
ODIN TANK · MLP
把坦克网络想成一台有 23,698 个旋钮的打分机器。数字从左流到右,错了就从右往回追责任,再把旋钮拧一点点。
仪器待命
开始之后,跑到的函数会在对应代码上亮起来
喂进 37 个数字:敌人在哪、炮弹多远、自己还剩多少血。吐出 18 个分数,每个分数是一种动作想做的程度。选最高的,或者按分数当概率抽签。
37
输入
128
ReLU
128
ReLU
18
动作
| 旋钮 | 形状 | 个数 |
|---|---|---|
| W1 | 128 × 37 | 4,736 |
| b1 | 128 | 128 |
| W2 | 128 × 128 | 16,384 |
| b2 | 128 | 128 |
| W3 | 18 × 128 | 2,304 |
| b3 | 18 | 18 |
| 合计 | 一个扁平数组 | 23,698 |
第二层 128×128 就占了 16384 个。He 初始化让前面两层的权重大约是 N(0, 2/扇入),输出层再乘 0.1,免得刚出生就固执。
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 原文件里这一拍真正对应的那一行。浏览器里跑的是同一套算法。训练比眼睛快,所以大约每半秒才移到下一处。
跟小学的加权平均一样:每个输入乘一根旋钮,加起来,再加偏置。负数被 ReLU 掐成 0,正数原样通过。不够兴奋,就不说话。
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
正在接通这台网络…
同一批旋钮,换一种战场,分数就换。下面三张是课本情景,不是某一帧的真实观测。训练一旦开始,这些分数会跟着旋钮一起变。
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
18 个分数可以是 3.2、−1、0.5。softmax 先减去最大的那个,再做指数,让它们加起来等于 1。负数也不会把机器弄炸。
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] - lsec.p[k] = math.exp(c.logp[k])
argmax :: proc(v: []$T) -> int {b := 0for i in 1 ..< len(v) {if v[i] > v[b] {b = i}}return b}
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
开火的梯度是负的,所以损失下降时它的分数会升高。这就是「老师说该开火,就把开火的概率往上调」。
三步循环。打分看出错多少,反向传播问谁的责任大,Adam 记得上次往哪拧,这一次就拧得更稳。梯度范数太大时先裁剪,防止一下拧坏。
旋钮在转
大的四颗是这一批责任最大的,会跟着 Adam 拧过去。正的权重顺时针,负的逆时针。小的是从 23,698 根里均匀抽出来的,也会轻轻动。
1 · loss_grad
还没拧过。损失会在第一次更新之后出现在这里。
2 · backward
反向传播从 18 个动作的误差,穿过没被关掉的 ReLU,回到每一根旋钮。
3 · adam_step
Adam 用两份记忆:方向的平均,和步子的大小。b1 = 0.9,b2 = 0.999。
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
grow[i] += d * c.h2[i]dh2[i] += row[i] * dif c.h2[j] <= 0 {continue} // ReLU 导数grow[i] += d * c.h1[i]if c.h1[j] <= 0 {continue}grow[i] += d * c.x[i]
if max_norm > 0 && norm > max_norm {scale = max_norm / norm}a.m[i] = a.b1 * a.m[i] + (1 - a.b1) * ga.v[i] = a.b2 * a.v[i] + (1 - a.b2) * g * gmh := a.m[i] / bc1vh := a.v[i] / bc2p[i] -= a.lr * mh / (math.sqrt(vh) + a.eps)
开始拧之后,这里显示这一批概率被拧动了多少。
老师说「这时该开火」,网络就把开火的概率往上调。代码里动作设成 −1,λ = 1,熵奖励 = 0,剩下的就是交叉熵。
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)}
case .Teacher:return teacher_action(g, me)
按概率抽一个动作。这一小段的回报减去这批的平均,就是优势 adv。优势大于 0 就多做,小于 0 就少做。
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 越大,损失越讨厌「把概率压成一条」。
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)}}
模仿模式把这两项锁死,跟代码里的行为克隆一致。
缩小到 2 个输入、1 个神经元。目标是 0:这颗神经元代表「该开火」,可是冷却还没好,不该兴奋。一步步看前向、反向、再拧。
固定矩形掩体、18 个组合动作、37 维观测,网络和 Adam 跟 Odin 里是同一套。模仿时两边都听老师的,网络只负责猜;试错时左边交给网络抽签。单局最多 1200 步。
o[0] = a.x / ARENA_Wo[4] = dist / ARENA_DIAGo[15] = a.hp / MAX_HPo[17] = 1 if line_of_sight(g, me) else 0o[18] = 1 if aim_on_target(g, me) else 0o[28] = threat
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_moveturn = dodge_turn}fire := los && abs(aim_err) <= tol && a.cooldown == 0return make_action(move, turn, fire)
apply_action(g, 0, a0)apply_action(g, 1, a1)b.x += b.vx / SUBif 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}
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 工程里。
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)