# Novel Generalization Techniques Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Close the in-sample/OOS generalization gap (Sharpe +0.57 IS → -1.30 OOS) by implementing 5 novel techniques that force the model to learn regime-invariant features instead of memorizing price sequences. **Architecture:** All techniques integrate into the existing training loop without changing the core DQN architecture. Techniques 1 and 4 modify Rust-side training config per epoch. Technique 2 adds a shuffle flag to episode starts. Technique 3 computes cross-validation action similarity at epoch boundary. Technique 5 repurposes the existing curiosity forward model's prediction error as a CQL-like Q-penalty in the loss kernel. **Tech Stack:** Rust, CUDA (cudarc), existing fused CUDA kernels, existing curiosity forward model --- ### Task 1: Domain Randomization — Per-Epoch Simulation Parameter Jitter **Files:** - Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1007-1058` (ExperienceCollectorConfig construction) - Modify: `crates/ml/src/trainers/dqn/config.rs` (add `enable_domain_randomization` flag) - Modify: `config/training/dqn-localdev.toml`, `config/training/dqn-production.toml` The ExperienceCollectorConfig is constructed fresh each epoch at training_loop.rs:1007. Currently all values are deterministic. Add per-epoch randomization of spread, tx_cost, fill probabilities, and initial capital. - [ ] **Step 1: Add config flag** In `config.rs`, after `enable_circuit_breaker` (~line 1407), add: ```rust pub enable_domain_randomization: bool, ``` Default in `conservative()`: `true`. - [ ] **Step 2: Add randomization to ExperienceCollectorConfig construction** In `training_loop.rs`, at the ExperienceCollectorConfig construction (~line 1007), wrap the fixed values with randomization when enabled: ```rust use rand::Rng; let mut epoch_rng = rand::rng(); let config = ExperienceCollectorConfig { // ... existing fields ... tx_cost_multiplier: if self.hyperparams.enable_domain_randomization { epoch_rng.random_range(0.5..2.5) } else { self.hyperparams.transaction_cost_multiplier as f32 }, fill_median_spread: if self.hyperparams.enable_domain_randomization { self.hyperparams.avg_spread as f32 * epoch_rng.random_range(0.5..3.0) } else { self.hyperparams.avg_spread as f32 }, fill_ioc_fill_prob: if self.hyperparams.enable_domain_randomization { epoch_rng.random_range(0.65..0.95) } else { self.hyperparams.fill_ioc_fill_prob as f32 }, fill_limit_fill_min: if self.hyperparams.enable_domain_randomization { epoch_rng.random_range(0.15..0.45) } else { self.hyperparams.fill_limit_fill_min as f32 }, fill_limit_fill_max: if self.hyperparams.enable_domain_randomization { epoch_rng.random_range(0.60..0.95) } else { self.hyperparams.fill_limit_fill_max as f32 }, spread_cost: if self.hyperparams.enable_domain_randomization { (self.hyperparams.tick_size * self.hyperparams.contract_multiplier * epoch_rng.random_range(0.3..0.8)) as f32 } else { (self.hyperparams.tick_size * self.hyperparams.contract_multiplier * self.hyperparams.fill_spread_cost_frac) as f32 }, // ... rest unchanged ... }; ``` - [ ] **Step 3: Add episode start jitter** In `training_loop.rs` at line 935-937, add ±25% stride jitter: ```rust let mut episode_starts: Vec = (0..n_episodes) .map(|i| { let base = (i * stride).rem_euclid(usable_bars); if self.hyperparams.enable_domain_randomization { let jitter = epoch_rng.random_range(-(stride/4)..(stride/4)); (base + jitter).rem_euclid(usable_bars) } else { base } }) .collect(); ``` - [ ] **Step 4: Add variable episode length** Before the `timesteps` assignment at line 931, randomize: ```rust let timesteps = if self.hyperparams.enable_domain_randomization { let base = self.hyperparams.gpu_timesteps_per_episode.min(1000) as i32; let jitter = epoch_rng.random_range(-(base/4)..(base/4)); (base + jitter).max(50) // minimum 50 timesteps } else { self.hyperparams.gpu_timesteps_per_episode.min(1000) as i32 }; ``` - [ ] **Step 5: Add to TOML configs** Add to `[advanced]` section in dqn-localdev.toml and dqn-production.toml: ```toml enable_domain_randomization = true ``` - [ ] **Step 6: Compile + smoke test** ```bash SQLX_OFFLINE=true cargo check -p ml FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests::training_stability::test_production_training_stability --ignored --nocapture ``` - [ ] **Step 7: Commit** ```bash git add crates/ml/src/trainers/dqn/trainer/training_loop.rs crates/ml/src/trainers/dqn/config.rs config/training/*.toml git commit -m "feat: domain randomization — per-epoch jitter on spread, tx_cost, fills, episode starts" ``` --- ### Task 2: Periodic Shrink-and-Perturb (already exists, needs scheduling) **Files:** - Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs:308-347` (phase boundary S&P) - Modify: `crates/ml/src/trainers/dqn/config.rs` (add `shrink_perturb_interval`) Shrink-and-perturb already works (`fused.shrink_and_perturb(alpha, sigma)`) but only runs at phase boundaries. Need periodic execution every N epochs. - [ ] **Step 1: Add config field** In `config.rs`, add: ```rust pub shrink_perturb_interval: usize, // 0=disabled, 20=every 20 epochs pub shrink_perturb_alpha: f64, // 0.85 = keep 85%, reinit 15% pub shrink_perturb_sigma: f64, // 0.01 = noise scale ``` Defaults in `conservative()`: `shrink_perturb_interval: 20, shrink_perturb_alpha: 0.85, shrink_perturb_sigma: 0.01`. - [ ] **Step 2: Add periodic S&P to training loop** In `training_loop.rs`, in the main epoch loop (after experience collection, before training steps), add: ```rust if self.hyperparams.shrink_perturb_interval > 0 && epoch > 0 && epoch % self.hyperparams.shrink_perturb_interval == 0 { if let Some(ref mut fused) = self.fused_ctx { let alpha = self.hyperparams.shrink_perturb_alpha as f32; let sigma = self.hyperparams.shrink_perturb_sigma as f32; match fused.shrink_and_perturb(alpha, sigma) { Ok(()) => info!(epoch, alpha, sigma, "Periodic shrink-and-perturb applied"), Err(e) => warn!(epoch, "Shrink-and-perturb failed (non-fatal): {e}"), } } } ``` - [ ] **Step 3: Add to TOMLs + profile** dqn-localdev.toml and dqn-production.toml `[advanced]`: ```toml shrink_perturb_interval = 20 shrink_perturb_alpha = 0.85 shrink_perturb_sigma = 0.01 ``` Wire through `training_profile.rs` `apply_to()` if fields are added to `AdvancedSection`. - [ ] **Step 4: Compile + test** ```bash SQLX_OFFLINE=true cargo check -p ml FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests --ignored --nocapture ``` - [ ] **Step 5: Commit** ```bash git commit -m "feat: periodic shrink-and-perturb every N epochs (default 20)" ``` --- ### Task 3: Adversarial Regime Injection **Files:** - Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (epoch-level regime detection + injection) - Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (ExperienceCollectorConfig fields) - Modify: `crates/ml/src/trainers/dqn/config.rs` (add `adversarial_injection_threshold`) When the model achieves low drawdown (<10%) for 3+ consecutive epochs, inject adversarial conditions: 3x spread, 2x tx_cost, 0.5x fill probability. This forces the model to handle worst-case scenarios. - [ ] **Step 1: Add config + tracking** In `config.rs`: ```rust pub adversarial_injection_threshold: f64, // DD threshold (0.10 = 10%) pub adversarial_injection_consecutive: usize, // consecutive good epochs before trigger (3) ``` In `trainer/mod.rs`, add tracking field: ```rust pub(crate) consecutive_low_dd_epochs: usize, ``` - [ ] **Step 2: Implement detection + injection** In `training_loop.rs`, after epoch metrics are computed (after the Sharpe/MaxDD/PF log), add: ```rust // Adversarial regime injection: when model is doing too well, make it harder let max_dd_fraction = monitor.max_drawdown_pct / 100.0; if max_dd_fraction < self.hyperparams.adversarial_injection_threshold { self.consecutive_low_dd_epochs += 1; } else { self.consecutive_low_dd_epochs = 0; } let adversarial_active = self.consecutive_low_dd_epochs >= self.hyperparams.adversarial_injection_consecutive; ``` Then in the ExperienceCollectorConfig construction for the NEXT epoch, check `adversarial_active`: ```rust let adversarial_mult = if adversarial_active { 3.0_f32 } else { 1.0 }; // Apply to spread_cost, tx_cost_multiplier, etc. spread_cost: base_spread * adversarial_mult, tx_cost_multiplier: base_tx * (if adversarial_active { 2.0 } else { 1.0 }), fill_ioc_fill_prob: if adversarial_active { 0.50 } else { base_fill }, ``` - [ ] **Step 3: Log when adversarial mode activates** ```rust if adversarial_active { info!( epoch, consecutive = self.consecutive_low_dd_epochs, "ADVERSARIAL REGIME INJECTED: 3x spread, 2x tx_cost, 0.5x fill prob" ); } ``` - [ ] **Step 4: Compile + test + commit** --- ### Task 4: Anti-Correlation Reward Penalty **Files:** - Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (epoch boundary action comparison) - Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (reward_penalty_multiplier field) - Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (apply penalty to reward) At epoch end, compare the action distribution (from GPU monitoring) between training and validation. If cosine similarity > 0.8, the model is doing the same thing on both — it hasn't learned to differentiate. Penalize next epoch's rewards. - [ ] **Step 1: Compute cosine similarity at epoch boundary** In `training_loop.rs`, after validation loss computation, add: ```rust // Anti-correlation: cosine similarity between train and val action distributions let train_actions: Vec = monitor.action_counts.iter().map(|&c| c as f64).collect(); let val_actions: Vec = /* from validation pass action counts */; let dot: f64 = train_actions.iter().zip(&val_actions).map(|(a, b)| a * b).sum(); let mag_t: f64 = train_actions.iter().map(|a| a * a).sum::().sqrt(); let mag_v: f64 = val_actions.iter().map(|a| a * a).sum::().sqrt(); let cosine_sim = if mag_t > 0.0 && mag_v > 0.0 { dot / (mag_t * mag_v) } else { 0.0 }; let anti_corr_penalty = if cosine_sim > 0.8 { 0.8_f32 // scale rewards by 0.8 next epoch (20% penalty) } else { 1.0_f32 }; ``` - [ ] **Step 2: Pass penalty to experience collector** Add `reward_scale_override: f32` to `ExperienceCollectorConfig`. Default 1.0. In the experience kernel, multiply reward by this scale factor: ```cuda reward *= reward_scale_override; ``` - [ ] **Step 3: Compile + test + commit** --- ### Task 5: Curiosity Prediction Error as Q-Penalty (Inverse Model) **Files:** - Modify: `crates/ml/src/trainers/dqn/fused_training.rs` (wire curiosity error into loss) - Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (compute curiosity error per batch) - Modify: `crates/ml/src/cuda_pipeline/mse_loss_kernel.cu` or `c51_loss_kernel.cu` (add penalty term) - Modify: `crates/ml/src/trainers/dqn/config.rs` (add `curiosity_q_penalty_weight`) The curiosity forward model (state+action → next_state) already exists and trains. Its prediction error measures how "novel" a state-action pair is. High prediction error = the model has never seen this situation = Q-values should be conservative (lower). Wire the curiosity prediction error as an ADDITIVE penalty to the Q-target in the Bellman equation: ``` target_Q = reward + gamma * (1-done) * target_Q_next - curiosity_penalty * prediction_error ``` This makes the Q-function conservative on novel state-action pairs (same principle as CQL, but data-driven instead of action-space-driven). - [ ] **Step 1: Add config field** ```rust pub curiosity_q_penalty_weight: f64, // 0.0=disabled, 0.5=moderate, 1.0=strong ``` Default: `0.5`. - [ ] **Step 2: Compute per-sample curiosity error in fused training step** In `fused_training.rs`, after the forward pass and before the loss kernel, launch the curiosity forward model on the current batch to get per-sample prediction errors. Store in a `CudaSlice` of size `[batch_size]`. The curiosity model is already initialized in the fused context (`self.curiosity_trainer`). Call: ```rust let curiosity_errors = self.curiosity_trainer.compute_batch_errors( &batch_states, &batch_actions, &batch_next_states, batch_size )?; ``` - [ ] **Step 3: Pass curiosity errors to loss kernel** Add `const float* curiosity_errors` parameter to `mse_loss_batched` and `c51_loss_batched`. In the Bellman target computation (MSE kernel line 334): ```cuda float curiosity_penalty = curiosity_errors[sample_id] * curiosity_q_penalty_weight; float target_q = reward + gamma * (1.0f - done) * target_eq - curiosity_penalty; ``` The penalty subtracts from the Q-target: high prediction error → lower target → model learns to be conservative on novel states. - [ ] **Step 4: Wire through Rust launch code** Add `curiosity_errors_buf` to the kernel launch in `gpu_dqn_trainer.rs`. Pass the prediction error buffer from step 2. - [ ] **Step 5: Add to TOML configs** ```toml [advanced] curiosity_q_penalty_weight = 0.5 ``` - [ ] **Step 6: Compile + full smoke test suite** ```bash SQLX_OFFLINE=true cargo check -p ml FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests --ignored --nocapture ``` All 11 smoke tests must pass. - [ ] **Step 7: Commit** ```bash git commit -m "feat: curiosity prediction error as Q-penalty (inverse model generalization)" ``` --- ## Verification Plan After all 5 techniques are implemented: 1. **Local 3-epoch smoke test** — all 11 pass, zero NaN 2. **Local 200-epoch training** — compare MaxDD and Sharpe trajectory to pre-fix baseline 3. **H100 hyperopt** — 20 trials × 8 epochs, compare OOS Sharpe distribution to baseline (-1.30) 4. **Key metric**: OOS Sharpe should be closer to 0 (ideally > -0.5). Omega should be more consistent with total return (no contradiction). ## Expected Impact | Technique | Mechanism | Expected Gap Reduction | |-----------|-----------|----------------------| | Domain randomization | Prevents memorizing fixed sim params | 40-60% | | Shrink-and-perturb | Kills memorized weights periodically | 15-25% | | Adversarial injection | Forces worst-case survival | 10-20% | | Anti-correlation penalty | Punishes regime-specific behavior | 10-15% | | Curiosity Q-penalty | Conservative on novel states | 20-30% | Combined expected impact: 50-80% generalization gap reduction. --- ### Task 6: Regime-Adversarial Training (Gradient Reversal) **Files:** - Create: `crates/ml/src/cuda_pipeline/regime_discriminator_kernel.cu` - Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (add discriminator head) - Modify: `crates/ml/src/trainers/dqn/fused_training.rs` (gradient reversal) Train a small classifier head on the DQN's hidden features that predicts which time window (fold) the data comes from. Apply GRADIENT REVERSAL from the discriminator to the feature extractor — the DQN learns features that the discriminator CANNOT use to identify the regime. This is Domain Adversarial Neural Networks (DANN) adapted for temporal domains. - [ ] **Step 1: Add discriminator kernel** — small MLP [hidden_dim → 64 → num_folds] - [ ] **Step 2: Wire gradient reversal** — during backward pass, negate the discriminator's gradient before it flows into the shared trunk - [ ] **Step 3: Add fold ID to experience buffer** — each experience carries which time window it came from - [ ] **Step 4: Compile + test + commit** --- ### Task 7: Counterfactual Experience Augmentation **Files:** - Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (dual action evaluation) - Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (double output buffers) For every trade taken, also compute the counterfactual: what would have happened with the mirror action (Long→Short, Short→Long). Store both with inverted reward signs. This doubles the effective data diversity. - [ ] **Step 1: In experience kernel**, after computing reward for the taken action, compute reward for the mirror action - [ ] **Step 2: Write both** to output buffers (2x output size) - [ ] **Step 3: Counterfactual reward** = -1 × actual reward (inverted signal) - [ ] **Step 4: Compile + test + commit** --- ## Execution Order (All 7 Techniques) 1. Domain randomization (Task 1) — Rust only, highest impact 2. Shrink-and-perturb scheduling (Task 2) — Rust only, already implemented 3. Adversarial regime injection (Task 3) — Rust only 4. Anti-correlation penalty (Task 4) — minor CUDA change 5. Curiosity Q-penalty (Task 5) — repurpose existing model 6. Counterfactual augmentation (Task 7) — CUDA kernel change 7. Regime-adversarial training (Task 6) — new network head Tasks 1-3 can be implemented in a single session (Rust only). Tasks 4-5 require CUDA kernel changes. Tasks 6-7 are the most ambitious and should come after validating 1-5. --- ### Task 8: Hindsight Regime Labeling — Feature Importance Filtering **Files:** - Create: `crates/ml/src/trainers/dqn/feature_importance.rs` - Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (epoch boundary analysis) - Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (feature masking) - Modify: `crates/ml/src/trainers/dqn/config.rs` (add feature_mask config) After each epoch, compute gradient × activation for each feature to identify which features actually predicted profitable trades. Mask the bottom 50% of features (set to 0) in the next epoch. Iterative: the model discovers which features carry signal and ignores noise. - [ ] **Step 1: Compute per-feature importance** from gradient × activation at epoch boundary - [ ] **Step 2: Rank features** by importance, compute a binary mask (top-K survive) - [ ] **Step 3: Apply mask** in the experience kernel (zero out masked features) - [ ] **Step 4: Iterate** — recompute importance each epoch, mask adapts - [ ] **Step 5: Compile + test + commit** ## Updated Execution Order (All 8 Techniques) 1. Domain randomization (Task 1) — Rust only, highest impact 2. CQL alpha boost (from researcher plan) — config change, 2 hours 3. Shrink-and-perturb scheduling (Task 2) — Rust only, already implemented 4. Adversarial regime injection (Task 3) — Rust only 5. Anti-correlation penalty (Task 4) — minor CUDA change 6. Curiosity Q-penalty (Task 5) — repurpose existing model 7. Counterfactual augmentation (Task 7) — CUDA kernel change 8. Hindsight feature filtering (Task 8) — feature masking 9. Regime-adversarial training (Task 6) — new network head (most ambitious) Phase 1 (today): Tasks 1-4 (Rust only, immediate impact) Phase 2 (next session): Tasks 5-8 (CUDA changes, deeper integration) Phase 3 (after validation): Task 6 (new network architecture)