feat(SNNBot): replace bin-based aimer with exemplar kernel regression
Eliminates aim jitter by switching from 360-bin WTA to weighted circular mean over stored exemplars. Continuous float output, no bins, no hysteresis, no saturation. K=128 ring buffer, Hamming similarity with quadratic weighting.
This commit is contained in:
@@ -46,7 +46,6 @@ const
|
||||
# ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning
|
||||
FIRE_POWER = 1.0 # fixed firing power; bullet speed = 20 - 3*FIRE_POWER
|
||||
BULLET_SPEED = 20.0 - 3.0 * FIRE_POWER
|
||||
HYSTERESIS = 1 # minimum score advantage to switch bins
|
||||
|
||||
# ── SNN types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -216,7 +215,6 @@ type
|
||||
velSpeed: float64 # speed (units/tick) from last scan delta
|
||||
lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE
|
||||
lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE
|
||||
lastWinBin: int # last winning bin for hysteresis
|
||||
|
||||
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -280,8 +278,7 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
|
||||
|
||||
method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
||||
initSNN(bot.snn)
|
||||
bot.res = initAimer()
|
||||
bot.lastWinBin = -1
|
||||
bot.res = initBinaryAimer()
|
||||
|
||||
# ── Reservoir helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -341,20 +338,19 @@ method run*(bot: SNNBot) =
|
||||
let relBearing = normalizeRelativeAngle(bot.enemyBearing - gunDir)
|
||||
bot.lastRelBearing = relBearing
|
||||
when USE_RESERVOIR:
|
||||
let binInput = toBinaryInput(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
|
||||
let winBin = bot.res.forward(binInput)
|
||||
# ponytail: hysteresis=1 prevents bin flickering; raise if still jittery, lower if gun lags
|
||||
let activeBin =
|
||||
if bot.lastWinBin >= 0 and bot.res.scores[bot.lastWinBin] + HYSTERESIS >= bot.res.scores[winBin]:
|
||||
bot.lastWinBin # stick with current bin
|
||||
else:
|
||||
winBin # switch to new winner
|
||||
bot.lastWinBin = activeBin
|
||||
let aimAngle = bot.res.interpolatedAngle(activeBin)
|
||||
let binInput = toBinaryInput(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
|
||||
let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel
|
||||
bot.lastBinInput = binInput
|
||||
bot.targetAngle = gunDir + aimAngle
|
||||
bot.lastDecideGunDir = gunDir
|
||||
echo "RES tick=" & $bot.tick & " bin=" & $activeBin & " aim=" & formatFloat(aimAngle, ffDecimal, 1)
|
||||
if aimRaw < 0.0:
|
||||
# cold start: use raw bearing directly
|
||||
bot.targetAngle = bot.enemyBearing
|
||||
echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(bot.enemyBearing, ffDecimal, 1)
|
||||
else:
|
||||
# aimRaw is absolute angle [0,360); convert to -180..180 relative then add gunDir
|
||||
let aimRel = normalizeRelativeAngle(aimRaw - 180.0) # exemplars stored as 0-360, relative bearing is -180..180
|
||||
bot.targetAngle = gunDir + aimRel
|
||||
echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1) & " rel=" & formatFloat(aimRel, ffDecimal, 1)
|
||||
else:
|
||||
let inputs = encodeInputFull(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
|
||||
# Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks
|
||||
@@ -400,23 +396,16 @@ method run*(bot: SNNBot) =
|
||||
let absBearing = directionTo(myX, myY, futureX, futureY)
|
||||
let targetRel = normalizeRelativeAngle(absBearing - bot.lastDecideGunDir)
|
||||
when USE_RESERVOIR:
|
||||
let winBin = block:
|
||||
var best = 0; var bestS = -1
|
||||
for k in 0 ..< N_BINS:
|
||||
if bot.res.scores[k] > bestS: bestS = bot.res.scores[k]; best = k
|
||||
best
|
||||
let aimAngle = bot.res.interpolatedAngle(winBin)
|
||||
bot.res.learn(bot.lastBinInput, targetRel)
|
||||
let correctBin = angleToBin(targetRel)
|
||||
let readoutPopCorrect = block:
|
||||
var c = 0
|
||||
for w in 0 ..< WORDS_IN: c += popcount(bot.res.readout[correctBin][w]).int
|
||||
c
|
||||
echo "RES tick=" & $bot.tick & " bin=" & $winBin &
|
||||
" aim=" & formatFloat(aimAngle, ffDecimal, 1) &
|
||||
" target=" & formatFloat(targetRel, ffDecimal, 1) &
|
||||
" err=" & formatFloat(abs(normalizeRelativeAngle(aimAngle - targetRel)), ffDecimal, 1) &
|
||||
" readout_pop_correct=" & $readoutPopCorrect
|
||||
# correctAngle must be [0,360): targetRel is -180..180, shift it
|
||||
var correctAngle = targetRel + 180.0
|
||||
let aimRaw = bot.res.forward(bot.lastBinInput)
|
||||
bot.res.learn(bot.lastBinInput, correctAngle)
|
||||
let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0
|
||||
echo "RES tick=" & $bot.tick &
|
||||
" aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") &
|
||||
" correct=" & formatFloat(correctAngle, ffDecimal, 1) &
|
||||
" err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") &
|
||||
" exemplars=" & $bot.res.count
|
||||
else:
|
||||
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
|
||||
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
|
||||
|
||||
@@ -4,83 +4,52 @@ import std/[bitops, math]
|
||||
|
||||
const
|
||||
INPUT_BITS* = 80
|
||||
N_BINS* = 360
|
||||
BIN_WIDTH* = 1.0
|
||||
WORDS_IN* = 2 # 80 bits → 2 × uint64 (128 bits, only 80 used)
|
||||
MAX_K* = 128
|
||||
MIN_SIM* = 3
|
||||
|
||||
type
|
||||
BitVec80* = array[WORDS_IN, uint64]
|
||||
|
||||
BinaryAimer* = object
|
||||
readout*: array[N_BINS, BitVec80] # 360 bins × 80-bit weight vectors
|
||||
scores*: array[N_BINS, int]
|
||||
Exemplar* = object
|
||||
pattern*: BitVec80
|
||||
angle*: float # degrees 0-360
|
||||
active*: bool
|
||||
|
||||
proc initAimer*(): BinaryAimer =
|
||||
# All readout weights start at zero — no bin preferred
|
||||
BinaryAimer* = object
|
||||
exemplars*: array[MAX_K, Exemplar]
|
||||
count*: int
|
||||
nextSlot*: int
|
||||
|
||||
proc initBinaryAimer*(): BinaryAimer =
|
||||
result = BinaryAimer()
|
||||
|
||||
proc forward*(a: var BinaryAimer, input: BitVec80): int =
|
||||
## Score each bin via popcount(input AND weights), return best bin
|
||||
var bestBin = 0
|
||||
var bestScore = -1
|
||||
for k in 0 ..< N_BINS:
|
||||
var score = 0
|
||||
proc forward*(aimer: var BinaryAimer, input: BitVec80): float =
|
||||
## Kernel regression: weighted circular mean over active exemplars.
|
||||
## Returns angle in [0,360) degrees, or -1.0 sentinel when cold (no weight).
|
||||
var sinSum = 0.0
|
||||
var cosSum = 0.0
|
||||
var totalW = 0.0
|
||||
for i in 0 ..< MAX_K:
|
||||
if not aimer.exemplars[i].active: continue
|
||||
var sim = 0
|
||||
for w in 0 ..< WORDS_IN:
|
||||
score += popcount(input[w] and a.readout[k][w]).int
|
||||
a.scores[k] = score
|
||||
if score > bestScore:
|
||||
bestScore = score
|
||||
bestBin = k
|
||||
result = bestBin
|
||||
sim += popcount(input[w] and aimer.exemplars[i].pattern[w]).int
|
||||
let excess = sim - MIN_SIM
|
||||
if excess <= 0: continue
|
||||
let weight = float(excess * excess)
|
||||
let rad = degToRad(aimer.exemplars[i].angle)
|
||||
sinSum += weight * sin(rad)
|
||||
cosSum += weight * cos(rad)
|
||||
totalW += weight
|
||||
if totalW == 0.0:
|
||||
return -1.0
|
||||
var angle = radToDeg(arctan2(sinSum, cosSum))
|
||||
if angle < 0.0: angle += 360.0
|
||||
result = angle
|
||||
|
||||
proc angleToBin*(angle: float): int =
|
||||
var a = angle
|
||||
while a < -180.0: a += 360.0
|
||||
while a >= 180.0: a -= 360.0
|
||||
result = clamp(int((a + 180.0) / BIN_WIDTH), 0, N_BINS - 1)
|
||||
|
||||
proc binToAngle*(bin: int): float =
|
||||
result = -180.0 + (float(bin) + 0.5) * BIN_WIDTH # bin center
|
||||
|
||||
proc interpolatedAngle*(a: BinaryAimer, winnerBin: int): float =
|
||||
## Weighted centroid of winner + neighbors for sub-bin precision
|
||||
let left = (winnerBin - 1 + N_BINS) mod N_BINS
|
||||
let right = (winnerBin + 1) mod N_BINS
|
||||
let sW = float(max(a.scores[winnerBin], 1))
|
||||
let sL = float(max(a.scores[left], 0))
|
||||
let sR = float(max(a.scores[right], 0))
|
||||
let total = sW + sL + sR
|
||||
let aW = binToAngle(winnerBin)
|
||||
let aL = binToAngle(left)
|
||||
let aR = binToAngle(right)
|
||||
# Circular mean
|
||||
let sinAvg = (sW * sin(degToRad(aW)) + sL * sin(degToRad(aL)) + sR * sin(degToRad(aR))) / total
|
||||
let cosAvg = (sW * cos(degToRad(aW)) + sL * cos(degToRad(aL)) + sR * cos(degToRad(aR))) / total
|
||||
result = radToDeg(arctan2(sinAvg, cosAvg))
|
||||
|
||||
proc learn*(a: var BinaryAimer, input: BitVec80, correctAngle: float) =
|
||||
## WTA Hebbian: replace correct bin, OR neighbors for generalization, punish worst wrong bin (>2° away)
|
||||
let correctBin = angleToBin(correctAngle)
|
||||
|
||||
# Replace correct bin with input (prevents bit accumulation saturation)
|
||||
for w in 0 ..< WORDS_IN:
|
||||
a.readout[correctBin][w] = input[w]
|
||||
|
||||
# OR neighbors ±1, ±2 for generalization (not correct bin itself)
|
||||
for offset in [-2, -1, 1, 2]:
|
||||
let bin = (correctBin + offset + N_BINS) mod N_BINS
|
||||
for w in 0 ..< WORDS_IN:
|
||||
a.readout[bin][w] = a.readout[bin][w] or input[w]
|
||||
|
||||
# Punish highest-scoring wrong bin (must be >2 bins away from correct)
|
||||
var worstBin = -1
|
||||
var worstScore = -1
|
||||
for k in 0 ..< N_BINS:
|
||||
let dist = min(abs(k - correctBin), N_BINS - abs(k - correctBin)) # circular distance
|
||||
if dist > 2 and a.scores[k] > worstScore:
|
||||
worstScore = a.scores[k]
|
||||
worstBin = k
|
||||
|
||||
if worstBin >= 0:
|
||||
for w in 0 ..< WORDS_IN:
|
||||
a.readout[worstBin][w] = a.readout[worstBin][w] and (not input[w])
|
||||
proc learn*(aimer: var BinaryAimer, input: BitVec80, correctAngle: float) =
|
||||
## Ring-buffer store: write exemplar at nextSlot, advance mod MAX_K.
|
||||
aimer.exemplars[aimer.nextSlot] = Exemplar(pattern: input, angle: correctAngle, active: true)
|
||||
aimer.nextSlot = (aimer.nextSlot + 1) mod MAX_K
|
||||
if aimer.count < MAX_K: inc aimer.count
|
||||
|
||||
Reference in New Issue
Block a user