BREAKING CHANGES: - Removed orphaned dqn.rs monolithic trainer (4,975 lines) - Removed orphaned dqn_ensemble.rs module (816 lines) - Removed orphaned tft.rs and tft_complete_int8_integration_test.rs - TFT trainer split into modular directory structure DQN Module Refactoring: - Split trainers/dqn.rs into modular structure (config.rs, statistics.rs, trainer.rs) - Fixed hyperopt 39D search space (continuous params only) - Boolean flags (use_dueling, use_double_dqn, use_per, use_noisy_nets) are now FIXED architectural decisions - use_distributional defaults to false (Candle BUG #36 - scatter_add gradient issues) Clean Module Structure: - ml/src/trainers/dqn/ directory with proper mod.rs exports - ml/src/trainers/tft/ directory with config.rs, types.rs, model.rs, trainer.rs, tests.rs - All P0 features validated: TD-error clamping, batch diversity, LR scheduler, priority staleness Documentation: - Added comprehensive docs in docs/codebase-cleanup/ - ADR-001 for DQN refactoring decisions - Rainbow DQN component matrix and quick reference guides Build Status: Compiles with zero errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
452 lines
14 KiB
Markdown
452 lines
14 KiB
Markdown
# Agent 20: Reward Normalization Analysis Report
|
||
|
||
**Date**: 2025-11-27
|
||
**Task**: Review reward normalization for potential overfitting issues
|
||
**Status**: ✅ COMPLETE - Analysis Finished
|
||
|
||
---
|
||
|
||
## Executive Summary
|
||
|
||
**VERDICT**: The reward normalization system is **well-designed** with appropriate parameters. However, there are **two potential concerns** related to the effective window size and normalization algorithm that warrant attention.
|
||
|
||
### Key Findings
|
||
|
||
1. ✅ **EMA Algorithm**: Correctly chosen over Welford's for non-stationary markets
|
||
2. ⚠️ **Window Size**: 100-step effective window may be too short for regime detection
|
||
3. ✅ **Normalization Range**: Proper clipping at ±3.0σ preserves economic signal
|
||
4. ⚠️ **Overfitting Risk**: EMA parameters (α=0.01, β=0.01) are fixed, not hyperparameter-tuned
|
||
|
||
---
|
||
|
||
## 1. RewardNormalizer Implementation Analysis
|
||
|
||
### Architecture (Lines 14-165 in `/ml/src/dqn/reward.rs`)
|
||
|
||
```rust
|
||
pub struct RewardNormalizer {
|
||
mean: f64, // Running mean via EMA
|
||
variance: f64, // Running variance via EMA
|
||
alpha: 0.01, // Mean decay rate (100-step window)
|
||
beta: 0.01, // Variance decay rate (100-step window)
|
||
initialized: bool,
|
||
epsilon: 1e-8, // Numerical stability
|
||
}
|
||
```
|
||
|
||
### Algorithm: Exponential Moving Average (EMA)
|
||
|
||
**Update Rule** (Lines 101-114):
|
||
```rust
|
||
if !self.initialized {
|
||
self.mean = value;
|
||
self.variance = 1.0;
|
||
self.initialized = true;
|
||
} else {
|
||
// EMA update for mean
|
||
self.mean = self.alpha * value + (1.0 - self.alpha) * self.mean;
|
||
|
||
// EMA update for variance
|
||
let diff = value - self.mean;
|
||
self.variance = self.beta * diff.powi(2) + (1.0 - self.beta) * self.variance;
|
||
}
|
||
```
|
||
|
||
**Normalization** (Lines 121-135):
|
||
```rust
|
||
let std = self.variance.sqrt();
|
||
if std < self.epsilon {
|
||
return value;
|
||
}
|
||
(value - self.mean) / std
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Parameter Analysis
|
||
|
||
### 2.1 Window Size: α = β = 0.01 → Effective Window ≈ 100 Steps
|
||
|
||
**Effective Window Calculation**:
|
||
- For decay rate α, effective window = 1/α
|
||
- α = 0.01 → **100 steps**
|
||
|
||
**Decay Profile** (Lines 95-98):
|
||
```
|
||
After 100 steps: weight = (1-0.01)^100 = 0.366 (63.4% decay)
|
||
After 200 steps: weight = (1-0.01)^200 = 0.134 (86.6% decay)
|
||
After 500 steps: weight = (1-0.01)^500 = 0.007 (99.3% decay)
|
||
```
|
||
|
||
**Assessment**:
|
||
- ✅ **Reactive**: Adapts quickly to changing reward distributions
|
||
- ⚠️ **Too Short?**: May overfit to recent 100 episodes, missing longer-term trends
|
||
- ⚠️ **Regime Detection**: Trading regimes can last 500+ steps (hours/days)
|
||
|
||
**Recommendation**:
|
||
```rust
|
||
// Consider longer windows for regime stability
|
||
alpha: 0.001, // 1000-step window (more stable)
|
||
beta: 0.005, // 200-step window (variance adapts faster)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Normalization Range & Clipping
|
||
|
||
### 3.1 Clipping at ±3.0σ (Line 557)
|
||
|
||
```rust
|
||
let normalized_reward = if let Some(normalizer) = &mut self.normalizer {
|
||
let norm = normalizer.normalize(final_reward_f64);
|
||
normalizer.update(final_reward_f64); // Update AFTER normalization
|
||
norm.clamp(-3.0, 3.0) // Clip to ±3σ
|
||
} else {
|
||
final_reward_f64
|
||
};
|
||
```
|
||
|
||
**Assessment**:
|
||
- ✅ **Appropriate Range**: ±3σ covers 99.7% of normal distribution
|
||
- ✅ **Preserves Signal**: Wide enough to not distort economic information
|
||
- ✅ **Prevents Explosion**: Bounds extreme outliers (e.g., rare large P&L events)
|
||
|
||
**Expected Q-Values** (Line 556):
|
||
```
|
||
Q* = r / (1 - γ)
|
||
≈ 3.0 / (1 - 0.9626)
|
||
≈ 80
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Overfitting Risk Assessment
|
||
|
||
### 4.1 Fixed vs. Tunable Parameters
|
||
|
||
**Current Implementation**:
|
||
```rust
|
||
alpha: 0.01, // FIXED (hardcoded)
|
||
beta: 0.01, // FIXED (hardcoded)
|
||
```
|
||
|
||
**Concern**: These are **not exposed as hyperparameters** in the reward config.
|
||
|
||
**Evidence from Codebase**:
|
||
- `RewardConfig` (Lines 167-213) does NOT include `alpha`/`beta` parameters
|
||
- Hyperopt tuning (checked via `ml/src/hyperopt/`) does NOT optimize EMA decay rates
|
||
- All trials use the same fixed 100-step window
|
||
|
||
### 4.2 Potential Overfitting Scenarios
|
||
|
||
#### Scenario A: Too Short Window (α = 0.01)
|
||
```
|
||
Market regime: Volatile (500 steps) → Calm (500 steps) → Volatile (500 steps)
|
||
EMA behavior: Quickly forgets old volatility, normalizes to recent 100 steps
|
||
Result: Rewards appear stable in volatile regime → agent underestimates risk
|
||
```
|
||
|
||
#### Scenario B: Too Long Window (α = 0.001)
|
||
```
|
||
Market regime: Calm (1000 steps) → Flash crash (10 steps) → Recovery (50 steps)
|
||
EMA behavior: Slow to react, uses stale calm-regime statistics
|
||
Result: Crash rewards seem extreme → agent avoids trading during recovery
|
||
```
|
||
|
||
**Current Risk**: **MODERATE**
|
||
- 100-step window is a reasonable middle ground
|
||
- But lack of hyperparameter tuning means we don't know if it's optimal
|
||
|
||
---
|
||
|
||
## 5. Normalization Correctness (BUG #41 Fix)
|
||
|
||
### 5.1 Why EMA Over Welford's Algorithm
|
||
|
||
**Documentation** (Lines 25-29):
|
||
> Welford's algorithm gives equal weight to all historical samples, causing mean drift in non-stationary markets. When the market produces losses, the mean drifts negative and stays there, inverting reward signs. EMA gives exponentially decaying weight to old samples, adapting to regime changes.
|
||
|
||
**Assessment**:
|
||
- ✅ **Correct Choice**: EMA is appropriate for non-stationary trading environments
|
||
- ✅ **Prevents Mean Drift**: Old regimes decay exponentially, not linearly
|
||
- ✅ **BUG #41 Fix**: Addresses the Welford's algorithm issue mentioned in prior audit reports
|
||
|
||
---
|
||
|
||
## 6. Integration with Reward Function
|
||
|
||
### 6.1 Update Order (CRITICAL FIX, Lines 544-552)
|
||
|
||
```rust
|
||
// CRITICAL: Normalize BEFORE update to avoid zeroing first reward
|
||
let norm = normalizer.normalize(final_reward_f64); // Uses PREVIOUS stats
|
||
normalizer.update(final_reward_f64); // Updates for NEXT call
|
||
```
|
||
|
||
**Comment** (Lines 545-548):
|
||
> Bug: If we update(x) then normalize(x), first call returns 0:
|
||
> update(x): mean = x, variance = 1.0
|
||
> normalize(x): returns (x - x) / 1.0 = 0
|
||
> Solution: normalize(x) using PREVIOUS stats, THEN update with new value
|
||
|
||
**Assessment**:
|
||
- ✅ **Order Correct**: Prevents first-reward zero bug
|
||
- ✅ **Proper Implementation**: Update happens AFTER normalization
|
||
|
||
---
|
||
|
||
## 7. Testing Coverage
|
||
|
||
### 7.1 Existing Tests
|
||
|
||
**Found in Codebase**:
|
||
- `ml/tests/dqn_reward_normalization_test.rs` (11 unit tests)
|
||
- `ml/tests/dqn_reward_calculation_test.rs` (12 tests)
|
||
|
||
**Coverage**:
|
||
- ✅ BUY/SELL symmetry
|
||
- ✅ Zero P&L normalization
|
||
- ✅ Negative P&L handling
|
||
- ✅ Large P&L normalization
|
||
|
||
### 7.2 Missing Tests (from TEST_COVERAGE_GAPS_AUDIT.md)
|
||
|
||
❌ **RewardNormalizer Update During Training**:
|
||
- No test verifies `normalizer.update()` is called after each reward
|
||
- **Risk**: Normalizer may use stale epoch-1 statistics for all epochs
|
||
|
||
❌ **Normalized Reward Range (-1.0 to 1.0)**:
|
||
- No test verifies rewards stay within expected bounds
|
||
- **Risk**: Rewards may explode if normalization fails
|
||
|
||
---
|
||
|
||
## 8. Recommendations
|
||
|
||
### Priority 1: Add Window Size Hyperparameter
|
||
|
||
**Current**:
|
||
```rust
|
||
impl RewardNormalizer {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
alpha: 0.01, // FIXED
|
||
beta: 0.01, // FIXED
|
||
// ...
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Proposed**:
|
||
```rust
|
||
pub struct RewardConfig {
|
||
// ... existing fields ...
|
||
|
||
/// EMA decay rate for mean (default: 0.01 = 100-step window)
|
||
pub reward_normalizer_alpha: Decimal,
|
||
/// EMA decay rate for variance (default: 0.01 = 100-step window)
|
||
pub reward_normalizer_beta: Decimal,
|
||
}
|
||
|
||
impl Default for RewardConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
// ... existing defaults ...
|
||
reward_normalizer_alpha: Decimal::try_from(0.01).unwrap(),
|
||
reward_normalizer_beta: Decimal::try_from(0.01).unwrap(),
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Hyperopt Search Space**:
|
||
```python
|
||
# Add to hyperopt configuration
|
||
"reward_normalizer_alpha": hp.loguniform("rn_alpha", -7, -2), # 0.001 to 0.1
|
||
"reward_normalizer_beta": hp.loguniform("rn_beta", -7, -2), # 0.001 to 0.1
|
||
```
|
||
|
||
### Priority 2: Test Normalization Update During Training
|
||
|
||
**Proposed Test** (add to `ml/tests/dqn_reward_normalization_test.rs`):
|
||
```rust
|
||
#[test]
|
||
fn test_normalizer_updates_during_training() {
|
||
let config = RewardConfig::default();
|
||
let mut reward_fn = RewardFunction::new(config);
|
||
|
||
// Verify initial state
|
||
assert_eq!(reward_fn.normalizer.as_ref().unwrap().count(), 0);
|
||
|
||
// Run 100 reward calculations
|
||
for i in 0..100 {
|
||
let action = FactoredAction::new(/* ... */);
|
||
let current_state = create_test_state(/* ... */);
|
||
let next_state = create_test_state(/* ... */);
|
||
|
||
reward_fn.calculate_reward(action, ¤t_state, &next_state, &[])?;
|
||
}
|
||
|
||
// Verify normalizer updated 100 times
|
||
assert_eq!(reward_fn.normalizer.as_ref().unwrap().count(), 100);
|
||
|
||
// Verify mean/variance changed from initialization
|
||
let (mean, std) = reward_fn.normalizer.as_ref().unwrap().get_stats();
|
||
assert_ne!(mean, 0.0);
|
||
assert_ne!(std, 1.0);
|
||
}
|
||
```
|
||
|
||
### Priority 3: Add Regime-Aware Window Sizing
|
||
|
||
**Proposed Enhancement**:
|
||
```rust
|
||
pub struct RewardNormalizer {
|
||
mean: f64,
|
||
variance: f64,
|
||
alpha: f64,
|
||
beta: f64,
|
||
|
||
// NEW: Adaptive window sizing
|
||
regime_detector: Option<RegimeDetector>,
|
||
alpha_fast: f64, // 0.01 (100-step) for volatile regimes
|
||
alpha_slow: f64, // 0.001 (1000-step) for stable regimes
|
||
}
|
||
|
||
impl RewardNormalizer {
|
||
pub fn update(&mut self, value: f64) {
|
||
// Detect regime change
|
||
let alpha = if let Some(detector) = &self.regime_detector {
|
||
if detector.is_volatile() {
|
||
self.alpha_fast // React quickly to volatility
|
||
} else {
|
||
self.alpha_slow // Use longer window for stability
|
||
}
|
||
} else {
|
||
self.alpha // Default fixed window
|
||
};
|
||
|
||
// EMA update with adaptive window
|
||
self.mean = alpha * value + (1.0 - alpha) * self.mean;
|
||
// ... variance update ...
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Comparison with Alternative Approaches
|
||
|
||
### 9.1 Welford's Algorithm (Original Implementation)
|
||
|
||
**Pros**:
|
||
- ✅ Exact online mean/variance calculation
|
||
- ✅ No hyperparameters to tune
|
||
|
||
**Cons**:
|
||
- ❌ Equal weight to all samples → mean drift in non-stationary markets (BUG #41)
|
||
- ❌ Cannot adapt to regime changes
|
||
|
||
**Verdict**: ❌ **Not suitable for trading** (already replaced with EMA)
|
||
|
||
### 9.2 Batch Normalization (Per-Episode)
|
||
|
||
**Pros**:
|
||
- ✅ No window size parameter
|
||
- ✅ Normalizes each episode independently
|
||
|
||
**Cons**:
|
||
- ❌ Cannot normalize online (needs full episode)
|
||
- ❌ Breaks temporal correlation across episodes
|
||
|
||
**Verdict**: ❌ **Not suitable for online RL**
|
||
|
||
### 9.3 Quantile Normalization
|
||
|
||
**Pros**:
|
||
- ✅ Robust to outliers
|
||
- ✅ No assumption of normal distribution
|
||
|
||
**Cons**:
|
||
- ❌ Requires storing large buffer of rewards
|
||
- ❌ More complex to implement
|
||
|
||
**Verdict**: ⚠️ **Worth exploring** for extreme market events
|
||
|
||
---
|
||
|
||
## 10. Final Assessment
|
||
|
||
### Current Parameters
|
||
|
||
| Parameter | Value | Effective Window | Assessment |
|
||
|-----------|-------|------------------|------------|
|
||
| `alpha` (mean decay) | 0.01 | 100 steps | ⚠️ May be too reactive |
|
||
| `beta` (variance decay) | 0.01 | 100 steps | ⚠️ May be too reactive |
|
||
| Clipping range | ±3.0σ | 99.7% coverage | ✅ Appropriate |
|
||
| Algorithm | EMA | O(1) memory | ✅ Correct choice |
|
||
|
||
### Risk Analysis
|
||
|
||
| Risk Type | Severity | Likelihood | Mitigation |
|
||
|-----------|----------|------------|------------|
|
||
| **Too short window** | MEDIUM | MEDIUM | Expose as hyperparameter, test 500-1000 step windows |
|
||
| **Reward hacking** | LOW | LOW | Clipping at ±3σ prevents extreme values |
|
||
| **Gradient issues** | LOW | LOW | Normalization to ~N(0,1) stabilizes gradients |
|
||
| **Overfitting to recent data** | MEDIUM | MEDIUM | Use regime-aware adaptive windows |
|
||
|
||
### Does Normalization Preserve Reward Structure?
|
||
|
||
**Yes**, with caveats:
|
||
|
||
1. ✅ **Relative Ordering**: Normalization preserves `r1 > r2 > r3` ordering
|
||
2. ✅ **Economic Signal**: ±3σ clipping is wide enough to not distort information
|
||
3. ⚠️ **Magnitude Information**: Raw reward magnitude is lost (by design)
|
||
4. ⚠️ **Temporal Correlation**: 100-step window may smooth out short-term patterns
|
||
|
||
---
|
||
|
||
## 11. Summary & Action Items
|
||
|
||
### What's Working Well
|
||
|
||
1. ✅ **EMA Algorithm**: Correct choice for non-stationary markets (BUG #41 fix)
|
||
2. ✅ **Update Order**: Normalize BEFORE update (prevents first-reward zero bug)
|
||
3. ✅ **Clipping Range**: ±3σ is appropriate for preserving signal
|
||
4. ✅ **Numerical Stability**: epsilon=1e-8 prevents division by zero
|
||
|
||
### What Needs Attention
|
||
|
||
1. ⚠️ **Window Size**: 100 steps may be too short for regime detection
|
||
2. ⚠️ **Fixed Parameters**: α/β not exposed as hyperparameters for tuning
|
||
3. ⚠️ **Missing Tests**: No integration test for normalizer update during training
|
||
|
||
### Recommended Actions
|
||
|
||
| Action | Priority | Effort | Impact |
|
||
|--------|----------|--------|--------|
|
||
| Add α/β as hyperparameters | HIGH | LOW | Enables hyperopt tuning |
|
||
| Test 500-1000 step windows | HIGH | MEDIUM | Reduces overfitting risk |
|
||
| Add normalizer update test | MEDIUM | LOW | Catches regression bugs |
|
||
| Implement regime-aware windows | LOW | HIGH | Advanced optimization |
|
||
|
||
---
|
||
|
||
## Appendix: Effective Window Size Cheat Sheet
|
||
|
||
| Alpha (α) | Effective Window | Use Case |
|
||
|-----------|------------------|----------|
|
||
| 0.05 | 20 steps | Very reactive (intraday trading) |
|
||
| 0.01 | **100 steps** | **Current default** (balanced) |
|
||
| 0.005 | 200 steps | Moderate stability |
|
||
| 0.001 | 1000 steps | Stable regimes (multi-day) |
|
||
| 0.0001 | 10000 steps | Very stable (trend-following) |
|
||
|
||
**Recommendation**: Test α ∈ {0.001, 0.005, 0.01} via hyperopt to find optimal balance.
|
||
|
||
---
|
||
|
||
**Agent 20 Analysis Complete**
|
||
**Next Steps**: Coordinate with Agent 21 (Hyperparameter Tuner) to add α/β to search space.
|