feat(SNNBot): add STDP learner and integration test (#153, #155)

- Implement reward-modulated STDP: spike timing, eligibility traces,
  reward-gated weight updates (w += η * reward * trace), clamp [-2,2]
- Add integration smoke test vs SittingDuck (compile + battle completion)
- Add tests/config.nims and nimble test task
This commit is contained in:
2026-09-13 10:31:07 +02:00
parent c038128c2f
commit 2bfa8eb6a5
2 changed files with 49 additions and 8 deletions
+3
View File
@@ -10,3 +10,6 @@ bin = @["SNNBot"]
requires "nim >= 2.0.0"
requires "robocode_tankroyale_botapi >= 1.0.7"
# radar_lock is vendored in-tree (common_libs/) and wired via config.nims --path
task test, "Run integration tests":
exec "nim c -r --path:../common_libs tests/test_basic_battle.nim"
+46 -8
View File
@@ -23,21 +23,33 @@ const
THRESH = 1.0 # LIF spike threshold
MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees)
AIM_TOL = 2.0 # arrive tolerance (degrees)
STDP_WIN = 20 # STDP timing window (ticks)
ETA = 0.01 # learning rate
# ponytail: global learning rate, no per-synapse adaptation; add when performance plateaus
ELG_DECAY = 0.95 # eligibility trace decay per tick
W_CLAMP = 2.0 # weight magnitude clamp
# ── SNN types ─────────────────────────────────────────────────────────────────
type
SNN = object
wih: array[N_IN * N_HID, float] # 36×6 input→hidden weights
who: array[N_HID, float] # 6×1 hidden→output weights
vHid: array[N_HID, float] # hidden membrane potentials
vOut: float # output membrane potential (readout)
wih: array[N_IN * N_HID, float] # 36×6 input→hidden weights
who: array[N_HID, float] # 6×1 hidden→output weights
vHid: array[N_HID, float] # hidden membrane potentials
vOut: float # output membrane potential (readout)
lastSpikeIn: array[N_IN, int] # tick of last input spike (-1 = never)
lastSpikeHid: array[N_HID, int] # tick of last hidden spike (-1 = never)
eligibility: array[N_IN * N_HID, float] # per-synapse eligibility traces
tick: int # internal tick counter for spike timing
proc initSNN(snn: var SNN) =
# ponytail: uniform random init, fine until STDP training added in #155
randomize()
for w in snn.wih.mitems: w = rand(1.0) - 0.5
for w in snn.who.mitems: w = rand(1.0) - 0.5
for t in snn.lastSpikeIn.mitems: t = -STDP_WIN - 1
for t in snn.lastSpikeHid.mitems: t = -STDP_WIN - 1
for e in snn.eligibility.mitems: e = 0.0
snn.tick = 0
proc encodeInput(bearing: float): array[N_IN, float] =
## Population-code bearing into N_IN neurons.
@@ -53,7 +65,17 @@ proc encodeInput(bearing: float): array[N_IN, float] =
proc forward(snn: var SNN, inputs: array[N_IN, float]): float =
## One SNN tick. Returns target angle in degrees (-180..+180).
# Hidden layer: LIF update
inc snn.tick
# Decay eligibility traces each tick
for e in snn.eligibility.mitems: e *= ELG_DECAY
# Record input spikes
for i in 0 ..< N_IN:
if inputs[i] > 0.0:
snn.lastSpikeIn[i] = snn.tick
# Hidden layer: LIF update + STDP trace
var spikes: array[N_HID, float]
for h in 0 ..< N_HID:
var wsum = 0.0
@@ -63,8 +85,20 @@ proc forward(snn: var SNN, inputs: array[N_IN, float]): float =
if snn.vHid[h] >= THRESH:
spikes[h] = 1.0
snn.vHid[h] = 0.0
snn.lastSpikeHid[h] = snn.tick
# STDP: post fires → check recent pre-spikes (potentiation)
for i in 0 ..< N_IN:
let dt = snn.tick - snn.lastSpikeIn[i]
if dt >= 0 and dt <= STDP_WIN:
snn.eligibility[i * N_HID + h] += exp(-float(dt) / float(STDP_WIN))
else:
spikes[h] = 0.0
# STDP: pre fires after post → depression for synapses where post spiked recently
for i in 0 ..< N_IN:
if inputs[i] > 0.0:
let dt = snn.tick - snn.lastSpikeHid[h]
if dt >= 0 and dt <= STDP_WIN:
snn.eligibility[i * N_HID + h] -= exp(-float(dt) / float(STDP_WIN))
# Output layer: membrane readout (no threshold)
var osum = 0.0
@@ -72,8 +106,7 @@ proc forward(snn: var SNN, inputs: array[N_IN, float]): float =
osum += spikes[h] * snn.who[h]
snn.vOut = LEAK * snn.vOut + osum
# Scale vOut to [-180, +180]. vOut is unbounded so clamp after tanh-like squash.
# ponytail: simple linear scale; replace with proper output neuron tuning in #155
# Scale vOut to [-180, +180]
result = tanh(snn.vOut) * 180.0
# ── Bot state machine ─────────────────────────────────────────────────────────
@@ -274,6 +307,11 @@ method run*(bot: SNNBot) =
of EVALUATE:
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
let reward = 1.0 / (1.0 + err)
# R-STDP weight update: w += η * R * e; then clamp and decay traces
for idx in 0 ..< N_IN * N_HID:
bot.snn.wih[idx] += ETA * reward * bot.snn.eligibility[idx]
bot.snn.wih[idx] = bot.snn.wih[idx].clamp(-W_CLAMP, W_CLAMP)
bot.snn.eligibility[idx] *= ELG_DECAY
echo "tick=" & $bot.tick & " error=" & $err & "° reward=" & $reward
bot.phase = DECIDE