fix: exp18 — fix circular exploit in parallel training (window=200, min_lap=12s)

Exp 17 post-mortem: efficiency gate window=30 steps only covers ~40% of a
3.5s exploit circle at 22fps, giving partial-arc efficiency ~0.77 (gate fires
at 0.15). Car earned positive reward while circling, outweighing the -10
lap penalty. Performance peaked at 80k then collapsed.

Exp 18 fixes:
- window_size 30→200: covers 2+ full exploit circles, driving efficiency→0
- min_lap_time 5s→12s: genuine laps are 13-16s (gentrack) and 27-29s (mountain);
  anything under 12s is an exploit and terminates immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-28 09:00:42 -04:00
parent b504b89b2a
commit 7fdfbacaee
2 changed files with 203 additions and 1 deletions

View File

@ -5,7 +5,8 @@ Each corresponds to an entry in docs/TEST_HISTORY.md.
| Script | Experiment | Key change |
|---|---|---|
| exp17_parallel_450k.py | Exp 17 | Parallel DummyVecEnv, 450k steps, v6 reward, HOST=localhost |
| exp18_parallel_450k_v2.py | Exp 18 | Exp 17 + exploit fix: window=200, min_lap_time=12s |
| exp17_parallel_450k.py | Exp 17 | Parallel DummyVecEnv, 450k steps, v6 reward — circular exploit dominated |
| mountain_v5.py | Exp 5 | v5 reward + throttle_min=0.5, direct model.learn() |
| mountain_continue.py | Exp 4 | Continued Exp3 training |
| mountain_high_throttle.py | Exp 3 | throttle_min=0.5, old v4 reward |

View File

@ -0,0 +1,201 @@
"""
Exp 18: Parallel DummyVecEnv 450k steps, fixed efficiency gate (v2).
Problem with Exp 17: circular exploit dominated entire run.
Root cause: v6 efficiency gate window=30 steps (~1.4s at 22fps).
Exploit circles complete in ~77 steps (3.5s). In a 30-step window the car
covers only ~40% of one circle partial arc efficiency 0.77, well above
the 0.15 threshold. Gate never fired. Car earned positive reward while
circling, outweighing the lap-termination penalty.
Fixes:
1. window_size: 30 200 steps (~9s). Now covers 2+ full exploit circles,
driving efficiency 0 and reliably triggering the gate.
2. min_lap_time: 5.0s 12.0s. Genuine generated_track laps are 13-16s;
genuine mountain laps are 27-29s. Anything under 12s is an exploit.
Episode terminates immediately with large penalty on any sub-12s lap.
Everything else identical to Exp 17.
Setup TWO sim instances required:
Sim 1: donkey_sim.exe on port 9091 generated_track
Sim 2: separate copy of donkey_sim.exe on port 9093 mountain_track
"""
import sys, os, time
sys.path.insert(0, '/home/paulh/projects/donkeycar-rl-autoresearch/agent')
from multitrack_runner import log, StuckTerminationWrapper
from donkeycar_sb3_runner import ThrottleClampWrapper
from reward_wrapper import SpeedRewardWrapper
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecTransposeImage
import gymnasium as gym
import numpy as np
HOST = 'localhost'
THROTTLE_MIN = 0.2
LR = 0.000725
TOTAL_STEPS = 450_000
CHECKPOINT_EVERY = 20_000
SAVE_DIR = '/home/paulh/projects/donkeycar-rl-autoresearch/agent/models/exp18-parallel-450k-v2'
os.makedirs(SAVE_DIR, exist_ok=True)
# Exploit fixes: larger window catches full circles; higher min_lap_time
# kills sub-genuine laps before they can contribute positive reward.
EFFICIENCY_WINDOW = 200 # was 30 — now covers 2+ exploit circles at 22fps
MIN_LAP_TIME = 12.0 # was 5.0 — genuine laps: gentrack 13-16s, mountain 27-29s
def make_env(track_id, port):
def _init():
raw = gym.make(track_id, conf={'host': HOST, 'port': port})
env = ThrottleClampWrapper(raw, throttle_min=THROTTLE_MIN)
env = StuckTerminationWrapper(env, stuck_steps=40, min_displacement=0.5)
env = SpeedRewardWrapper(env, window_size=EFFICIENCY_WINDOW, min_lap_time=MIN_LAP_TIME)
return env
return _init
log('=' * 60)
log('Exp 18: Parallel DummyVecEnv — 450k steps (exploit fix v2)')
log(f' Sim 1: {HOST}:9091 → generated_track')
log(f' Sim 2: {HOST}:9093 → mountain_track')
log(f' throttle_min={THROTTLE_MIN}, lr={LR}, total={TOTAL_STEPS:,}')
log(f' Reward: v6 + exploit fix (window={EFFICIENCY_WINDOW}, min_lap={MIN_LAP_TIME}s)')
log(f' Stuck termination: 40 steps (~2.5s)')
log(f' Checkpoints: every {CHECKPOINT_EVERY:,} steps')
log('=' * 60)
log('Creating DummyVecEnv with two tracks...')
env = DummyVecEnv([
make_env('donkey-generated-track-v0', 9091),
make_env('donkey-mountain-track-v0', 9093),
])
env = VecTransposeImage(env)
log(f' VecEnv num_envs={env.num_envs}, obs={env.observation_space.shape}')
model = PPO('CnnPolicy', env, learning_rate=LR, verbose=1, device='cpu')
log('PPO created. Starting training...')
best_reward = float('-inf')
steps_done = 0
while steps_done < TOTAL_STEPS:
seg_steps = min(CHECKPOINT_EVERY, TOTAL_STEPS - steps_done)
model.learn(total_timesteps=seg_steps, reset_num_timesteps=False)
steps_done += seg_steps
ckpt = os.path.join(SAVE_DIR, f'checkpoint_{steps_done:07d}')
model.save(ckpt)
model.save(os.path.join(SAVE_DIR, 'model'))
log(f'[{steps_done:,}/{TOTAL_STEPS:,}] Checkpoint saved: {ckpt}.zip')
# Eval on both training tracks using the existing DummyVecEnv connections
try:
obs = env.reset()
ep_rewards = np.zeros(env.num_envs)
ep_steps = np.zeros(env.num_envs)
done_mask = np.zeros(env.num_envs, dtype=bool)
for _ in range(2000):
action, _ = model.predict(obs, deterministic=True)
obs, rewards, dones, infos = env.step(action)
for i in range(env.num_envs):
if not done_mask[i]:
ep_rewards[i] += rewards[i]
ep_steps[i] += 1
if dones[i]:
done_mask[i] = True
if done_mask.all():
break
status0 = '' if ep_steps[0] >= 2000 else f'❌@{int(ep_steps[0])}'
status1 = '' if ep_steps[1] >= 2000 else f'❌@{int(ep_steps[1])}'
log(f' Eval: gen_track={ep_rewards[0]:.1f}r/{int(ep_steps[0])}s {status0} '
f'mountain={ep_rewards[1]:.1f}r/{int(ep_steps[1])}s {status1}')
total_reward = ep_rewards.sum()
if total_reward > best_reward:
best_reward = total_reward
model.save(os.path.join(SAVE_DIR, 'best_model'))
log(f' ⭐ NEW BEST: {best_reward:.1f} combined reward')
except Exception as e:
log(f' Eval error: {e}')
import traceback; traceback.print_exc()
model.save(os.path.join(SAVE_DIR, 'model'))
log(f'\nTraining complete. Best combined reward: {best_reward:.1f}')
env.close()
time.sleep(5)
# --- Final eval on all 4 tracks (sequential, port 9091) ---
log('\n' + '=' * 60)
log('FINAL EVALUATION: best_model on 4 tracks (3 sets each)')
log('=' * 60)
EVAL_TRACKS = [
('donkey-generated-track-v0', 'generated_track'),
('donkey-mountain-track-v0', 'mountain_track'),
('donkey-minimonaco-track-v0', 'mini_monaco'),
('donkey-generated-roads-v0', 'generated_road'),
]
EVAL_PORT = 9091
EVAL_SETS = 3
EVAL_MAX_STEPS = 2000
best_model_path = os.path.join(SAVE_DIR, 'best_model.zip')
results_by_track = {}
for track_id, track_name in EVAL_TRACKS:
log(f'\n--- {track_name} ---')
steps_list = []
for s in range(1, EVAL_SETS + 1):
try:
raw = gym.make(track_id, conf={'host': HOST, 'port': EVAL_PORT})
inner = ThrottleClampWrapper(raw, throttle_min=THROTTLE_MIN)
inner = StuckTerminationWrapper(inner, stuck_steps=40, min_displacement=0.5)
inner = SpeedRewardWrapper(inner)
eval_env = VecTransposeImage(DummyVecEnv([lambda e=inner: e]))
eval_model = PPO.load(best_model_path, env=eval_env, device='cpu')
obs = eval_env.reset()
total_r, total_s, done = 0.0, 0, False
while not done and total_s < EVAL_MAX_STEPS:
action, _ = eval_model.predict(obs, deterministic=True)
result = eval_env.step(action)
if len(result) == 4:
obs, r, d, info = result
done = bool(d[0])
else:
obs, r, t, tr, info = result
done = bool(t[0] or tr[0])
total_r += float(r[0])
total_s += 1
status = '' if total_s >= EVAL_MAX_STEPS else f'❌@{total_s}'
log(f' Set {s}: {total_r:.1f}r / {total_s}s {status}')
steps_list.append(total_s)
eval_env.close()
time.sleep(3)
except Exception as e:
log(f' Set {s}: ERROR — {e}')
steps_list.append(0)
time.sleep(3)
mean_steps = np.mean(steps_list) if steps_list else 0
results_by_track[track_name] = steps_list
log(f' Mean: {mean_steps:.0f} steps')
log('\n' + '=' * 60)
log('SUMMARY')
log('=' * 60)
for track_name, steps_list in results_by_track.items():
steps_str = '/'.join(str(s) for s in steps_list)
mean = np.mean(steps_list)
verdict = '' if mean >= 1500 else '⚠️' if mean >= 500 else ''
log(f' {verdict} {track_name:20s}: {steps_str} mean={mean:.0f}')
log(f'\n=== Exp 17 COMPLETE ===')