Files
SirRoboGarage/BNNBot_garage/analysis/backtest_alternatives.py
SirStone 1ed7797cb6 feat(ModularBot): 6 guns, pattern matcher, melee modules, adversarial bots
- New guns: guess-factor (GF histogram), pattern-matcher (movement tape replay)
- New modules: minimum-risk melee movement, spinning melee radar
- New test bots: PatternMover, RandomMover, WaveSurfer
- Fixed: FeedbackEvent now carries actualX/actualY for proper GF learning
- Fixed: TM gun warmup gating + directional residuals
- Fixed: circular gun integrated formula + multi-bin omega cache
- Fixed: oscillator wall-bounce lockout
- Fixed: phantom meteor perpendicular body orientation
- 6/6 battle wins across all enemy types
2026-09-20 00:59:53 +02:00

653 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Alternative learning methods backtest — binary input, online learning, no gradients.
Stdlib only: csv, math, random, collections.
Baselines: linear extrapolation MAE≈12.24, WiSARD K=12 MAE≈9.93
"""
import csv
import math
import random
import os
import collections
CSV_PATH = os.path.join(os.path.dirname(__file__), "../data/target_battle_1_decimal.csv")
OUT_PATH = os.path.join(os.path.dirname(__file__), "alternatives_results.txt")
POWER_LEVELS = [0.10, 0.42, 0.74, 1.07, 1.39, 1.71, 2.03, 2.36, 2.68, 3.00]
POWER_STRS = ["p0.10","p0.42","p0.74","p1.07","p1.39","p1.71","p2.03","p2.36","p2.68","p3.00"]
MAX_DIST = 1414.0
HIT_THRESH = 18.0 # px
REP_POWER = 1.07
REP_PS = "p1.07"
# ── bit encoding (matches backtest_wisard.py) ─────────────────────────────────
def _to_gray(v):
return v ^ (v >> 1)
def int_to_bits(value, nbits):
g = _to_gray(int(value))
return [(g >> (nbits - 1 - i)) & 1 for i in range(nbits)]
FIELD_DEFS = [
("bearing_sin", 8), ("bearing_cos", 8), ("distance", 7), ("velocity", 5),
("heading_sin", 8), ("heading_cos", 8), ("enemy_x", 7), ("enemy_y", 7),
("enemy_energy", 11),
]
BITS_PER_FRAME = sum(b for _, b in FIELD_DEFS) # 69
def encode_frame(row, prefix):
bits = []
for fname, nbits in FIELD_DEFS:
bits += int_to_bits(row[prefix + fname], nbits)
return bits
def build_input(row):
bits = []
for i in range(4):
bits += encode_frame(row, f"f{i}_")
return bits # 276 bits
# ── geometry helpers ──────────────────────────────────────────────────────────
def bullet_speed(power): return 20.0 - 3.0 * power
def flight_ticks(dist_enc, power): return dist_enc / 99.0 * MAX_DIST / bullet_speed(power)
def euclid(ax, ay, bx, by): return math.sqrt((ax - bx)**2 + (ay - by)**2)
def predict_linear(row, t):
x0, y0 = row["f0_enemy_x"], row["f0_enemy_y"]
x1, y1 = row["f1_enemy_x"], row["f1_enemy_y"]
return x0 + (x0 - x1) * t, y0 + (y0 - y1) * t
def stats(errors, label=None):
if not errors: return {}
n = len(errors)
mae = sum(errors) / n
rmse = math.sqrt(sum(e*e for e in errors) / n)
med = sorted(errors)[n // 2]
hit = sum(1 for e in errors if e <= HIT_THRESH) / n * 100
return {"mae": mae, "rmse": rmse, "median": med, "hit": hit, "n": n}
# ── CSV loader ────────────────────────────────────────────────────────────────
def load_csv():
with open(CSV_PATH) as f:
rows = []
for row in csv.DictReader(f):
try:
rows.append({k: float(v) for k, v in row.items()})
except ValueError:
pass
return rows
# ── Generic online backtest harness ──────────────────────────────────────────
def run_method(rows, predict_fn, learn_fn):
"""
predict_fn(bits) -> (cx, cy) correction on top of linear
learn_fn(bits, rx, ry) update on true residual
Returns: (all_errors_by_ps, first50_p107, last50_p107)
"""
errors = {ps: [] for ps in POWER_STRS}
first50, last50 = [], []
n = len(rows)
for i, row in enumerate(rows):
bits = build_input(row)
cx, cy = predict_fn(bits)
for ps, power in zip(POWER_STRS, POWER_LEVELS):
t = flight_ticks(row["f0_distance"], power)
lx, ly = predict_linear(row, t)
e = euclid(lx + cx, ly + cy, row[f"{ps}_enemy_x"], row[f"{ps}_enemy_y"])
errors[ps].append(e)
if ps == REP_PS:
if i < 50: first50.append(e)
if i >= n - 50: last50.append(e)
# learn residual at representative power
t_r = flight_ticks(row["f0_distance"], REP_POWER)
lx_r, ly_r = predict_linear(row, t_r)
cx2, cy2 = predict_fn(bits) # use same prediction (already called above)
rx = row[f"{REP_PS}_enemy_x"] - (lx_r + cx)
ry = row[f"{REP_PS}_enemy_y"] - (ly_r + cy)
learn_fn(bits, rx, ry)
return errors, first50, last50
# ════════════════════════════════════════════════════════════════════════════════
# 1. Echo State Network (Reservoir Computing)
# Fixed sparse random reservoir, delta-rule readout only.
# ════════════════════════════════════════════════════════════════════════════════
class EchoStateNet:
def __init__(self, n_in=276, n_res=512, sparsity=0.1, seed=7):
rng = random.Random(seed)
self.n_res = n_res
# sparse binary reservoir weights: ±1 with prob sparsity, else 0
self.W_res = [[0.0]*n_res for _ in range(n_res)]
for i in range(n_res):
for j in range(n_res):
if rng.random() < sparsity:
self.W_res[i][j] = 1.0 if rng.random() < 0.5 else -1.0
# scale spectral radius to ~0.9 (approx: divide by expected density*n)
scale = sparsity * n_res * 0.9
if scale > 0:
for i in range(n_res):
for j in range(n_res):
self.W_res[i][j] /= scale
# input projection: random binary ±1
self.W_in = []
for i in range(n_res):
col = rng.randint(0, n_in - 1) # random input index
sign = 1 if rng.random() < 0.5 else -1
self.W_in.append((col, sign))
# reservoir state
self.state = [0.0] * n_res
# readout weights (x and y)
self.w_x = [0.0] * n_res
self.w_y = [0.0] * n_res
self.lr = 0.01
def _step(self, bits):
new_state = [0.0] * self.n_res
for i in range(self.n_res):
col, sign = self.W_in[i]
s = sign * bits[col]
for j in range(self.n_res):
s += self.W_res[i][j] * self.state[j]
new_state[i] = math.tanh(s)
self.state = new_state
def predict(self, bits):
self._step(bits)
cx = sum(self.w_x[i] * self.state[i] for i in range(self.n_res))
cy = sum(self.w_y[i] * self.state[i] for i in range(self.n_res))
return cx, cy
def learn(self, bits, rx, ry):
# delta rule on readout (reservoir already stepped in predict)
for i in range(self.n_res):
self.w_x[i] += self.lr * rx * self.state[i]
self.w_y[i] += self.lr * ry * self.state[i]
# ════════════════════════════════════════════════════════════════════════════════
# 2. Kanerva Sparse Distributed Memory (SDM)
# Hard addresses = random 276-bit patterns. Read/write by Hamming proximity.
# ════════════════════════════════════════════════════════════════════════════════
class KanervaSDM:
def __init__(self, n_addr=1000, n_bits=276, radius=90, seed=13):
rng = random.Random(seed)
self.n_bits = n_bits
self.radius = radius
# random hard addresses as integers (stored as bit lists for fast Hamming)
self.hard_addr = []
for _ in range(n_addr):
addr = [rng.randint(0, 1) for _ in range(n_bits)]
self.hard_addr.append(addr)
# storage cells: sum_x, sum_y, count
self.cells = [[0.0, 0.0, 0] for _ in range(n_addr)]
def _hamming(self, bits, addr):
return sum(b != a for b, a in zip(bits, addr))
def _near(self, bits):
return [i for i, addr in enumerate(self.hard_addr)
if self._hamming(bits, addr) <= self.radius]
def predict(self, bits):
near = self._near(bits)
hits = [(self.cells[i][0], self.cells[i][1], self.cells[i][2])
for i in near if self.cells[i][2] > 0]
if not hits:
return 0.0, 0.0
cx = sum(sx/c for sx, sy, c in hits) / len(hits)
cy = sum(sy/c for sx, sy, c in hits) / len(hits)
return cx, cy
def learn(self, bits, rx, ry):
for i in self._near(bits):
self.cells[i][0] += rx
self.cells[i][1] += ry
self.cells[i][2] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 3. N-gram Markov on discretized patterns
# Hash 4 chunks of bits → pattern ID, build pattern→correction table.
# ════════════════════════════════════════════════════════════════════════════════
class NGramMarkov:
def __init__(self, chunk_size=69, n_chunks=4):
self.chunk_size = chunk_size
self.n_chunks = n_chunks
# table: pattern_key -> [sum_x, sum_y, count]
self.table = {}
def _key(self, bits):
chunks = []
for c in range(self.n_chunks):
start = c * self.chunk_size
end = min(start + self.chunk_size, len(bits))
val = 0
for b in bits[start:end]:
val = (val << 1) | b
chunks.append(val)
return tuple(chunks)
def predict(self, bits):
key = self._key(bits)
e = self.table.get(key)
if e is None or e[2] == 0:
return 0.0, 0.0
return e[0] / e[2], e[1] / e[2]
def learn(self, bits, rx, ry):
key = self._key(bits)
if key not in self.table:
self.table[key] = [0.0, 0.0, 0]
self.table[key][0] += rx
self.table[key][1] += ry
self.table[key][2] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 4. Bloom Filter Predictor
# Multiple hash functions → slots, store/average corrections.
# ════════════════════════════════════════════════════════════════════════════════
class BloomPredictor:
def __init__(self, n_slots=4096, n_hashes=8, seed=99):
rng = random.Random(seed)
self.n_slots = n_slots
self.n_hashes = n_hashes
# random hash seeds
self.seeds = [rng.randint(1, 2**31) for _ in range(n_hashes)]
self.slots_x = [0.0] * n_slots
self.slots_y = [0.0] * n_slots
self.counts = [0] * n_slots
def _hash(self, bits, seed):
h = seed
for i, b in enumerate(bits):
if b:
h ^= (i * 2654435761 + seed) & 0xFFFFFFFF
return h % self.n_slots
def _addrs(self, bits):
return [self._hash(bits, s) for s in self.seeds]
def predict(self, bits):
addrs = self._addrs(bits)
hits = [(self.slots_x[a], self.slots_y[a], self.counts[a])
for a in addrs if self.counts[a] > 0]
if not hits:
return 0.0, 0.0
cx = sum(sx/c for sx, sy, c in hits) / len(hits)
cy = sum(sy/c for sx, sy, c in hits) / len(hits)
return cx, cy
def learn(self, bits, rx, ry):
for a in self._addrs(bits):
self.slots_x[a] += rx
self.slots_y[a] += ry
self.counts[a] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 5. Hyperdimensional Computing (HDC)
# Random projection to 10000-dim binary HV, bundled class prototypes.
# Discretize correction into 8 angle classes × 4 magnitude classes = 32 classes.
# ════════════════════════════════════════════════════════════════════════════════
class HDC:
def __init__(self, n_in=276, n_hd=10000, n_classes=32, seed=17):
rng = random.Random(seed)
self.n_hd = n_hd
self.n_classes = n_classes
# random projection matrix: for each HD dim, pick a random input bit index
self.proj = [rng.randint(0, n_in - 1) for _ in range(n_hd)]
# random XOR masks per input position to break locality
self.masks = [rng.getrandbits(n_hd) for _ in range(n_in)]
# class prototypes: sum of HVs (as int for fast popcount via XOR)
self.proto_sum = [0] * (n_hd * n_classes) # flattened int sums
self.proto_count = [0] * n_classes
# store accumulated corrections per class
self.class_cx = [0.0] * n_classes
self.class_cy = [0.0] * n_classes
def _encode(self, bits):
# Build HD vector as integer (1 bit per position)
# Use random projection: hv[i] = bits[proj[i]] XOR random noise
hv = 0
for i, idx in enumerate(self.proj):
if bits[idx]:
hv |= (1 << i)
return hv
def _hamming_hv(self, hv_a, hv_b):
# Hamming distance between two n_hd-bit integers
return bin(hv_a ^ hv_b).count('1')
def _discretize(self, rx, ry):
# 8 angle classes × 4 magnitude classes
angle = math.atan2(ry, rx) # -pi..pi
angle_cls = int((angle + math.pi) / (2 * math.pi) * 8) % 8
mag = math.sqrt(rx*rx + ry*ry)
mag_cls = min(int(mag / 20), 3) # 0-19, 20-39, 40-59, 60+
return angle_cls * 4 + mag_cls
def predict(self, bits):
hv = self._encode(bits)
best_cls = -1
best_dist = self.n_hd + 1
for c in range(self.n_classes):
if self.proto_count[c] == 0:
continue
# proto is stored as sum; threshold at count/2 to binarize
count = self.proto_count[c]
# approx distance: count bits where sum > count/2 differs from hv
# ponytail: O(n_hd) loop; acceptable for 10k bits and ~100 samples
dist = 0
base = c * self.n_hd
for i in range(self.n_hd):
proto_bit = 1 if self.proto_sum[base + i] > count / 2 else 0
hv_bit = (hv >> i) & 1
if proto_bit != hv_bit:
dist += 1
if dist < best_dist:
best_dist = dist
best_cls = c
if best_cls < 0 or self.proto_count[best_cls] == 0:
return 0.0, 0.0
c = self.proto_count[best_cls]
return self.class_cx[best_cls] / c, self.class_cy[best_cls] / c
def learn(self, bits, rx, ry):
hv = self._encode(bits)
cls = self._discretize(rx, ry)
self.proto_count[cls] += 1
self.class_cx[cls] += rx
self.class_cy[cls] += ry
base = cls * self.n_hd
for i in range(self.n_hd):
self.proto_sum[base + i] += (hv >> i) & 1
# ════════════════════════════════════════════════════════════════════════════════
# 6. Random Subspace Ensemble
# Multiple WiSARD-lite predictors, each seeing random 50-bit subset.
# ════════════════════════════════════════════════════════════════════════════════
class RandomSubspaceEnsemble:
def __init__(self, n_estimators=20, subset_size=50, k=5, n_bits=276, seed=31):
rng = random.Random(seed)
self.estimators = []
for _ in range(n_estimators):
indices = random.sample(range(n_bits), subset_size)
tables = {} # addr -> [sum_x, sum_y, count]
self.estimators.append((indices, tables, k))
def _addresses(self, bits, indices, k):
addrs = []
for start in range(0, len(indices), k):
chunk = indices[start:start+k]
addr = 0
for idx in chunk:
addr = (addr << 1) | bits[idx]
addrs.append((start // k, addr))
return addrs
def predict(self, bits):
cx_sum = cy_sum = weight = 0.0
for indices, tables, k in self.estimators:
addrs = self._addresses(bits, indices, k)
est_cx = est_cy = 0.0
hit = 0
for node, addr in addrs:
key = (node, addr)
e = tables.get(key)
if e and e[2] > 0:
est_cx += e[0] / e[2]
est_cy += e[1] / e[2]
hit += 1
if hit > 0:
cx_sum += est_cx / hit
cy_sum += est_cy / hit
weight += 1
if weight == 0:
return 0.0, 0.0
return cx_sum / weight, cy_sum / weight
def learn(self, bits, rx, ry):
for indices, tables, k in self.estimators:
for node, addr in self._addresses(bits, indices, k):
key = (node, addr)
if key not in tables:
tables[key] = [0.0, 0.0, 0]
tables[key][0] += rx
tables[key][1] += ry
tables[key][2] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 7. WiSARD + Eligibility Traces (temporal credit assignment)
# WiSARD stores eligibility traces per LUT entry.
# At learn time, also update recently-visited entries with decayed credit.
# ════════════════════════════════════════════════════════════════════════════════
class WiSARDEligibility:
"""WiSARD with eligibility trace — past addresses get partial credit."""
def __init__(self, n_bits=276, k=12, trace_len=5, decay=0.7, seed=42):
self.k = k
n_pad = math.ceil(n_bits / k) * k
self.n_nodes = n_pad // k
rng = random.Random(seed)
indices = list(range(n_bits)) + [0] * (n_pad - n_bits)
self.perm = indices[:]
rng.shuffle(self.perm)
self.tables = [{} for _ in range(self.n_nodes)]
self.trace_len = trace_len
self.decay = decay
# history: deque of (addrs_list, weight)
self.history = collections.deque()
def _addresses(self, bits):
addrs = []
for node in range(self.n_nodes):
addr = 0
for bit_idx in range(self.k):
perm_idx = node * self.k + bit_idx
b = bits[self.perm[perm_idx]] if perm_idx < len(bits) else 0
addr = (addr << 1) | b
addrs.append(addr)
return addrs
def predict(self, bits):
addrs = self._addresses(bits)
cx_sum = cy_sum = 0.0
count = 0
for node, addr in enumerate(addrs):
e = self.tables[node].get(addr)
if e and e[2] > 0:
cx_sum += e[0] / e[2]
cy_sum += e[1] / e[2]
count += 1
return (cx_sum / count, cy_sum / count) if count else (0.0, 0.0)
def learn(self, bits, rx, ry):
addrs = self._addresses(bits)
# current step with full credit
self.history.appendleft((addrs, 1.0))
if len(self.history) > self.trace_len:
self.history.pop()
# update all traces
for past_addrs, weight in self.history:
for node, addr in enumerate(past_addrs):
e = self.tables[node].get(addr)
if e is None:
self.tables[node][addr] = [rx * weight, ry * weight, 1]
else:
e[0] += rx * weight
e[1] += ry * weight
e[2] += 1
# decay weights for next round
self.history = collections.deque(
[(a, w * self.decay) for a, w in self.history]
)
# ════════════════════════════════════════════════════════════════════════════════
# Main
# ════════════════════════════════════════════════════════════════════════════════
def fmt_row(name, s_all, s_first, s_last, p1_mae):
mae = s_all.get("mae", float("nan"))
hit = s_all.get("hit", float("nan"))
f50 = s_first.get("mae", float("nan")) if s_first else float("nan")
l50 = s_last.get("mae", float("nan")) if s_last else float("nan")
delta = mae - p1_mae
return (f"{name:<34} {mae:>7.2f} {hit:>7.1f}% {f50:>8.2f} {l50:>8.2f} {delta:>+8.2f}")
def run_and_report(rows, name, predict_fn, learn_fn):
print(f" running {name}...")
errors, first50, last50 = run_method(rows, predict_fn, learn_fn)
s_all = stats(errors[REP_PS])
s_f = stats(first50)
s_l = stats(last50)
return errors, s_all, s_f, s_l
def main():
rows = load_csv()
lines = []
w = lines.append
w("=" * 80)
w("ALTERNATIVE LEARNING METHODS BACKTEST")
w(f"Dataset: {len(rows)} rows | Rep power: {REP_PS} | Hit threshold: {HIT_THRESH}px")
w("Baselines: linear MAE≈12.24 | WiSARD K=12 MAE≈9.93")
w("=" * 80)
# ── baseline: pure linear ──────────────────────────────────────────────────
lin_errs = []
for row in rows:
t = flight_ticks(row["f0_distance"], REP_POWER)
lx, ly = predict_linear(row, t)
lin_errs.append(euclid(lx, ly, row[f"{REP_PS}_enemy_x"], row[f"{REP_PS}_enemy_y"]))
p1_mae = stats(lin_errs)["mae"]
p1_hit = stats(lin_errs)["hit"]
p1_f50 = stats(lin_errs[:50])["mae"]
p1_l50 = stats(lin_errs[-50:])["mae"]
w(f"\n{'Method':<34} {'MAE':>7} {'Hit%':>8} {'F50 MAE':>8} {'L50 MAE':>8} {'vs Lin':>8}")
w("-" * 80)
w(f"{'Linear (baseline)':<34} {p1_mae:>7.2f} {p1_hit:>7.1f}% {p1_f50:>8.2f} {p1_l50:>8.2f} {'---':>8}")
w(f"{'WiSARD K=12 (reference)':<34} {'9.93':>7} {'~55%':>8} {'---':>8} {'---':>8} {'-2.31':>8}")
results = []
# ── 1. Echo State Network ─────────────────────────────────────────────────
esn = EchoStateNet(n_in=276, n_res=512, sparsity=0.05, seed=7)
_last_state = [None]
def esn_predict(bits):
cx, cy = esn.predict(bits)
return cx, cy
def esn_learn(bits, rx, ry):
esn.learn(bits, rx, ry)
e, sa, sf, sl = run_and_report(rows, "1. Echo State Network", esn_predict, esn_learn)
results.append(("1. Echo State Net (res=512)", sa, sf, sl))
w(fmt_row("1. Echo State Net (res=512)", sa, sf, sl, p1_mae))
# ── 2. Kanerva SDM ───────────────────────────────────────────────────────
sdm = KanervaSDM(n_addr=2000, n_bits=276, radius=100, seed=13)
e, sa, sf, sl = run_and_report(rows, "2. Kanerva SDM", sdm.predict, sdm.learn)
results.append(("2. Kanerva SDM (addr=2000)", sa, sf, sl))
w(fmt_row("2. Kanerva SDM (addr=2000)", sa, sf, sl, p1_mae))
# ── 3. N-gram Markov ─────────────────────────────────────────────────────
ng = NGramMarkov(chunk_size=69, n_chunks=4)
e, sa, sf, sl = run_and_report(rows, "3. N-gram Markov", ng.predict, ng.learn)
results.append(("3. N-gram Markov (4×69 chunks)", sa, sf, sl))
w(fmt_row("3. N-gram Markov (4×69 chunks)", sa, sf, sl, p1_mae))
# ── 4. Bloom Filter Predictor ─────────────────────────────────────────────
bloom = BloomPredictor(n_slots=8192, n_hashes=12, seed=99)
e, sa, sf, sl = run_and_report(rows, "4. Bloom Filter", bloom.predict, bloom.learn)
results.append(("4. Bloom Filter (8192 slots)", sa, sf, sl))
w(fmt_row("4. Bloom Filter (8192 slots)", sa, sf, sl, p1_mae))
# ── 5. HDC ───────────────────────────────────────────────────────────────
print(" running 5. HDC (slow, n_hd=2000)...")
hdc = HDC(n_in=276, n_hd=2000, n_classes=32, seed=17)
hdc_errors = {ps: [] for ps in POWER_STRS}
hdc_first50, hdc_last50 = [], []
n = len(rows)
for i, row in enumerate(rows):
bits = build_input(row)
cx, cy = hdc.predict(bits)
for ps, power in zip(POWER_STRS, POWER_LEVELS):
t = flight_ticks(row["f0_distance"], power)
lx, ly = predict_linear(row, t)
e_val = euclid(lx + cx, ly + cy, row[f"{ps}_enemy_x"], row[f"{ps}_enemy_y"])
hdc_errors[ps].append(e_val)
if ps == REP_PS:
if i < 50: hdc_first50.append(e_val)
if i >= n - 50: hdc_last50.append(e_val)
t_r = flight_ticks(row["f0_distance"], REP_POWER)
lx_r, ly_r = predict_linear(row, t_r)
rx = row[f"{REP_PS}_enemy_x"] - (lx_r + cx)
ry = row[f"{REP_PS}_enemy_y"] - (ly_r + cy)
hdc.learn(bits, rx, ry)
sa = stats(hdc_errors[REP_PS])
sf = stats(hdc_first50)
sl = stats(hdc_last50)
results.append(("5. HDC (n_hd=2000, 32 cls)", sa, sf, sl))
w(fmt_row("5. HDC (n_hd=2000, 32 cls)", sa, sf, sl, p1_mae))
# ── 6. Random Subspace Ensemble ───────────────────────────────────────────
rse = RandomSubspaceEnsemble(n_estimators=30, subset_size=50, k=5, n_bits=276, seed=31)
e, sa, sf, sl = run_and_report(rows, "6. Random Subspace Ensemble", rse.predict, rse.learn)
results.append(("6. RandSubspace (30×50bits)", sa, sf, sl))
w(fmt_row("6. RandSubspace (30×50bits)", sa, sf, sl, p1_mae))
# ── 7. WiSARD + Eligibility Traces ───────────────────────────────────────
wet = WiSARDEligibility(n_bits=276, k=12, trace_len=5, decay=0.7, seed=42)
e, sa, sf, sl = run_and_report(rows, "7. WiSARD+Eligibility", wet.predict, wet.learn)
results.append(("7. WiSARD+Elig (k=12,tr=5)", sa, sf, sl))
w(fmt_row("7. WiSARD+Elig (k=12,tr=5)", sa, sf, sl, p1_mae))
# ── Summary ───────────────────────────────────────────────────────────────
w("\n" + "=" * 80)
w("RANKING by MAE (lower is better, rep power p1.07)")
w("=" * 80)
ranked = sorted(results, key=lambda x: x[1].get("mae", 9999))
w(f"\n{'Rank':<5} {'Method':<34} {'MAE':>7} {'Hit%':>8} {'F50':>8} {'L50':>8}")
w("-" * 70)
for rank, (name, sa, sf, sl) in enumerate(ranked, 1):
mae = sa.get("mae", float("nan"))
hit = sa.get("hit", float("nan"))
f50 = sf.get("mae", float("nan")) if sf else float("nan")
l50 = sl.get("mae", float("nan")) if sl else float("nan")
w(f"{rank:<5} {name:<34} {mae:>7.2f} {hit:>7.1f}% {f50:>8.2f} {l50:>8.2f}")
w(f"\n Linear baseline: MAE={p1_mae:.2f} Hit={p1_hit:.1f}%")
w(f" WiSARD K=12 ref: MAE=9.93 Hit=~55%")
w("\nNotes:")
w(" F50/L50 = MAE on first/last 50 samples (learning speed proxy).")
w(" Hit% = fraction within 18px (Robocode bullet half-width).")
w(" All methods: online, binary input, no gradients, no supervised labels.")
w(" Learning signal: residual correction after linear extrapolation at p1.07.")
report = "\n".join(lines)
print("\n" + report)
with open(OUT_PATH, "w") as f:
f.write(report + "\n")
print(f"\n[saved to {OUT_PATH}]")
if __name__ == "__main__":
main()