Files
foxhunt/docs/plans/2026-03-03-hyperopt-improvements-design.md
jgrusewski a0a2a1d736 docs: add hyperopt improvements design and implementation plan
A+B approach: smooth penalties + position limits + CUDA cleanup +
search space reduction (45D→25D) + TPE optimizer replacing PSO.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:17:23 +01:00

145 lines
6.4 KiB
Markdown
Raw 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.
# DQN Hyperopt Improvements Design
## Problem Statement
The current DQN hyperparameter optimization has three critical failures:
1. **"Do Nothing" convergence (57% of trials)**: 8/14 trials converge to objective=44.86 (penalty value), producing 0 trades. PSO with 20 particles in 45D space can't explore effectively. Cliff penalties (-10.0 entropy, 1000.0 completion) give zero gradient signal.
2. **Catastrophic leverage (29% of trials)**: 4/14 trials produce >100% max drawdown. `max_position_absolute` range [4.0, 8.0] allows 8x leverage on $100K. No drawdown circuit breaker during backtest evaluation.
3. **Memory growth (844-1435 MB/trial)**: No explicit CUDA cache clearing between trials. `drop()` + 100ms sleep is insufficient for Candle's CUDA allocator.
## Approach: A+B (Surgical Fixes + Bayesian Optimizer)
### Part A: Objective Function & Environment Fixes
#### A1. Smooth Penalties (replace cliffs)
**Current**: Binary cliff penalties destroy PSO gradient signal.
```
entropy < 0.5 → -10.0 (cliff)
entropy >= 0.5 → 0.0 (cliff)
completion < min_epochs → 1000.0 (dominates everything)
```
**Proposed**: Smooth, gradient-friendly penalties.
```
diversity_penalty = -5.0 * max(0, 1.0 - entropy/1.0)² # Quadratic, smooth at boundary
completion_penalty = 50.0 * max(0, 1.0 - epochs/min_epochs) # Linear scale, not 1000x
```
**Files**: `crates/ml/src/hyperopt/adapters/dqn.rs``calculate_diversity_penalty()` (line 1840), `calculate_completion_penalty()` (line 2198)
#### A2. Position Limit Enforcement
**Current**: `max_position_absolute` range [4.0, 8.0] with no drawdown guard.
**Proposed**:
- Narrow search range to [1.0, 4.0] (max 4x leverage)
- Add drawdown circuit breaker in backtest: if drawdown > 20%, force-close all positions and halt trading for remainder of episode
- Wire into `execute_action_internal` in portfolio_tracker.rs
**Files**: `crates/ml/src/hyperopt/adapters/dqn.rs` (search bounds line 476), `crates/ml/src/dqn/portfolio_tracker.rs` (execute_action_internal line 232)
#### A3. CUDA Memory Cleanup
**Current**: `drop()` + 100ms sleep. No CUDA synchronize or cache clear.
**Proposed**: After dropping trainer/metrics, call CUDA synchronize via Candle's backend, then force a GC pass. Add memory budget check — if usage exceeds 80% of available, trigger aggressive cleanup.
**Files**: `crates/ml/src/hyperopt/adapters/dqn.rs` (cleanup block line 3076)
#### A4. Search Space Reduction (45D → ~25D)
Fix 20 parameters to validated defaults. Keep the parameters that genuinely affect trading performance in the search space.
**Keep in search (25D)**:
- Base: learning_rate, batch_size, gamma, buffer_size, hold_penalty_weight, max_position_absolute, huber_delta, entropy_coefficient, transaction_cost_multiplier (9)
- PER: per_alpha, per_beta_start (2)
- Rainbow: v_min, v_max, noisy_sigma_init, dueling_hidden_dim, n_steps, num_atoms (6)
- Risk: kelly_fractional, kelly_max_fraction, volatility_window (3)
- Training: weight_decay, curiosity_weight, tau, hidden_dim_base (4)
- Exploration: noisy_epsilon_floor (1)
**Fix to defaults (20D)**:
- minimum_profit_factor → 1.5 (midpoint)
- kelly_min_trades → 20 (validated)
- ensemble_size → 5, beta_variance → 0.5, beta_disagreement → 0.5, beta_entropy → 0.2, variance_cap → 1.0 (ensemble defaults)
- warmup_ratio → 0.0 (hyperopt trials too short)
- td_error_clamp_max → 10.0, batch_diversity_cooldown → 50.0 (validated)
- lr_decay_type → 0 (constant for short trials)
- sharpe_weight → 0.0 (already in objective)
- gae_lambda → 0.95 (standard)
- noisy_sigma_initial → 0.5, noisy_sigma_final → 0.3 (validated)
- norm_type → 1 (RMSNorm), activation_type → 1 (LeakyReLU)
- num_quantiles → 64, qr_kappa → 1.0 (QR-DQN defaults)
- count_bonus_coefficient → 0.1 (validated)
**Files**: `crates/ml/src/hyperopt/adapters/dqn.rs``continuous_bounds()` (line 469), `from_continuous()` (line 549)
### Part B: Replace PSO with Bayesian Optimization (TPE)
#### B1. TPE Optimizer Implementation
Replace `ArgminOptimizer` (PSO) with a Tree-Parzen Estimator:
1. **Surrogate model**: Maintain two kernel density estimates (KDEs) — one for parameters from "good" trials (top γ=25%), one for "bad" trials
2. **Acquisition function**: Expected Improvement (EI) = l(x)/g(x) where l(x) is the good KDE and g(x) is the bad KDE
3. **Sampling**: Draw candidates from l(x), score by EI, pick best
4. **Initial exploration**: Keep LHS (Latin Hypercube Sampling) for first 5 trials
The `egobox` crate (already in the workspace) provides EGO (GP-based BO) as an alternative, but TPE scales better to 25D. We'll implement a lightweight TPE using the `statrs` crate for KDE.
**Architecture**:
```
ArgminOptimizer (PSO, 20 particles, 50 iters/restart)
↓ replace with
TpeOptimizer {
n_initial: 5, // LHS warmup
max_trials: 30, // Same budget
gamma: 0.25, // Top 25% = "good"
n_candidates: 100, // EI candidates per trial
bandwidth: "silverman" // KDE bandwidth selection
}
```
**Files**:
- New: `crates/ml/src/hyperopt/tpe.rs` (~300 lines)
- Modify: `crates/ml/src/hyperopt/optimizer.rs` (add TPE variant)
- Modify: `crates/ml/src/hyperopt/mod.rs` (export)
- Modify: `crates/ml/src/hyperopt/campaign.rs` (wire TPE)
- Modify CLI: `bin/fxt/src/commands/train/hyperopt.rs` (--optimizer=tpe flag)
#### B2. Trial History & Warm-Starting
TPE benefits from trial history across runs. Add JSON-based trial persistence:
- Save all (params, objective) pairs to `hyperopt_dir/trial_history.json`
- On restart, load history to seed TPE's KDE
- This makes interrupted runs resume intelligently instead of starting from scratch
**Files**: `crates/ml/src/hyperopt/tpe.rs` (persistence methods)
## Non-Goals
- Changing the reward function or backtest environment (separate concern)
- Multi-GPU support (single L40S is the target)
- Changing the DQN architecture itself
- Modifying the FactoredAction space
## Expected Impact
| Metric | Current (PSO 45D) | Target (TPE 25D + fixes) |
|--------|-------------------|--------------------------|
| "Do nothing" rate | 57% (8/14) | <15% |
| Catastrophic DD rate | 29% (4/14) | <5% |
| Memory per trial | 844-1435 MB | <200 MB delta |
| Best Sharpe found | 1.73 (1 trial) | >2.0 consistently |
| Trials to find signal | ~7 | ~3-5 |
## Testing Strategy
- Unit tests for TPE KDE, EI acquisition, smooth penalties
- Integration test: 5-trial micro-hyperopt on synthetic data
- Regression: ensure existing PSO path still works (--optimizer=pso flag)