Skip to content

Commit b48fb19

Browse files
wadekargclaude
andcommitted
Fix convergence bugs in REINFORCE and Q-Learning
REINFORCE: - Replace broken global-baseline approach with per-episode return normalization: G̃_t = (G_t − μ) / (σ + ε). The old baseline used the running average of G_0 for ALL timesteps t, causing massive negative advantages at late steps (G_{T-1}≈1, baseline≈300 → −299) and a zero gradient at t=0 on the first episode. - Lower default lr 0.005 → 0.002 (research: >0.003 often diverges) - Lower default gamma 0.97 → 0.99 (0.97 too myopic for 500-step horizon) Q-Learning: - Add alpha (learning rate) decay per episode alongside epsilon decay. Constant alpha prevents Q-values from ever stabilizing — they keep jittering regardless of how many episodes run. - Raise initial alpha default 0.2 → 0.5 (decay brings it to 0.1 min) - Raise epsilonMin default 0.01 → 0.05 (0.01 stops exploration too early) - Tighten epsilonDecay default 0.998 → 0.995 (faster initial exploration) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7559f23 commit b48fb19

3 files changed

Lines changed: 48 additions & 19 deletions

File tree

src/algorithms/classicCartpole/discretizedQLearning.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,39 @@ import { argmax, randInt } from '../../utils/math'
88
* Q-Learning over discretized classic CartPole state space.
99
* Bins the 4 continuous state variables (x, xDot, theta, thetaDot)
1010
* into discrete buckets. Default: 6×6×12×12 = 5,184 states.
11+
*
12+
* Both α and ε decay per episode — constant α prevents Q-values from stabilizing.
1113
*/
1214
export class DiscretizedQLearningAgent implements Agent<ClassicCartPoleState, ClassicCartPoleAction> {
1315
private qTable: Map<string, number[]> = new Map()
14-
private alpha: number
16+
private alpha: number // initial learning rate
1517
private gamma: number
16-
private epsilon: number
18+
private epsilon: number // initial exploration rate
1719
private epsilonDecay: number
1820
private epsilonMin: number
1921
private currentEpsilon: number
22+
private currentAlpha: number // decayed learning rate
23+
private readonly alphaDecay = 0.999
24+
private readonly alphaMin = 0.1
2025
private episodeCount = 0
2126
private discretizationConfig: ClassicDiscretizationConfig
2227
private numActions = 2
2328

2429
constructor(
25-
alpha = 0.1,
30+
alpha = 0.5,
2631
gamma = 0.99,
27-
epsilon = 0.1,
32+
epsilon = 1.0,
2833
discretizationConfig: ClassicDiscretizationConfig = DEFAULT_CLASSIC_DISCRETIZATION,
2934
epsilonDecay = 0.995,
30-
epsilonMin = 0.01,
35+
epsilonMin = 0.05,
3136
) {
3237
this.alpha = alpha
3338
this.gamma = gamma
3439
this.epsilon = epsilon
3540
this.epsilonDecay = epsilonDecay
3641
this.epsilonMin = epsilonMin
3742
this.currentEpsilon = epsilon
43+
this.currentAlpha = alpha
3844
this.discretizationConfig = discretizationConfig
3945
}
4046

@@ -60,14 +66,20 @@ export class DiscretizedQLearningAgent implements Agent<ClassicCartPoleState, Cl
6066
const nextQ = this.getQ(nextKey)
6167

6268
const target = done ? reward : reward + this.gamma * Math.max(...nextQ)
63-
q[action] += this.alpha * (target - q[action])
69+
q[action] += this.currentAlpha * (target - q[action])
6470

6571
if (done) {
6672
this.episodeCount++
73+
// Decay ε per episode
6774
this.currentEpsilon = Math.max(
6875
this.epsilonMin,
6976
this.epsilon * Math.pow(this.epsilonDecay, this.episodeCount),
7077
)
78+
// Decay α per episode — constant α prevents convergence
79+
this.currentAlpha = Math.max(
80+
this.alphaMin,
81+
this.alpha * Math.pow(this.alphaDecay, this.episodeCount),
82+
)
7183
}
7284
}
7385

@@ -87,6 +99,7 @@ export class DiscretizedQLearningAgent implements Agent<ClassicCartPoleState, Cl
8799
this.qTable.clear()
88100
this.episodeCount = 0
89101
this.currentEpsilon = this.epsilon
102+
this.currentAlpha = this.alpha
90103
}
91104

92105
setParams(alpha: number, gamma: number, epsilon: number): void {

src/algorithms/classicCartpole/reinforce.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import type { ClassicCartPoleState, ClassicCartPoleAction } from '../../environm
77
* Policy: π(a|s) = softmax(W · φ(s))
88
* Features φ(s): [1, x/2.4, v/3.0, θ/0.21, ω/3.5, (θ/0.21)², (ω/3.5)²] (7 features)
99
* Weights: W is 2×7 matrix (2 actions × 7 features)
10+
*
11+
* Return normalization (per-episode):
12+
* G̃_t = (G_t − mean(G)) / (std(G) + ε)
13+
* This is critical: using the same global baseline for all timesteps creates high variance
14+
* because G_t naturally decreases across the episode (G_T-1 ≈ 1, G_0 ≈ T).
15+
* Per-episode normalization removes this structural bias.
1016
*/
1117

1218
interface Transition {
@@ -46,10 +52,12 @@ export class ReinforceAgent implements Agent<ClassicCartPoleState, ClassicCartPo
4652
private lr: number
4753
private gamma: number
4854
private trajectory: Transition[] = []
49-
private baselineReturn = 0
55+
56+
// Display-only baseline: running average of episode returns (not used for learning)
57+
private displayBaseline = 0
5058
private episodeCount = 0
5159

52-
constructor(lr = 0.01, gamma = 0.99) {
60+
constructor(lr = 0.002, gamma = 0.99) {
5361
this.lr = lr
5462
this.gamma = gamma
5563
this.weights = Array.from({ length: NUM_ACTIONS }, () =>
@@ -76,29 +84,37 @@ export class ReinforceAgent implements Agent<ClassicCartPoleState, ClassicCartPo
7684
const T = this.trajectory.length
7785
const returns: number[] = new Array(T)
7886

87+
// Reward-to-go: G_t = r_t + γ·r_{t+1} + γ²·r_{t+2} + …
7988
let G = 0
8089
for (let t = T - 1; t >= 0; t--) {
8190
G = this.trajectory[t].reward + this.gamma * G
8291
returns[t] = G
8392
}
8493

94+
// Per-episode return normalization: G̃_t = (G_t − μ) / (σ + ε)
95+
// Fixes the high-variance problem caused by G_t naturally shrinking across the episode.
96+
const meanG = returns.reduce((a, b) => a + b, 0) / T
97+
const varG = returns.reduce((a, v) => a + (v - meanG) ** 2, 0) / T
98+
const stdG = Math.sqrt(varG + 1e-8)
99+
100+
// Update display baseline (running average of total episode return — for UI only)
85101
this.episodeCount++
86-
const episodeReturn = returns[0]
87-
this.baselineReturn += (episodeReturn - this.baselineReturn) / this.episodeCount
102+
this.displayBaseline += (returns[0] - this.displayBaseline) / this.episodeCount
88103

89104
for (let t = 0; t < T; t++) {
90105
const { state: s, action: a } = this.trajectory[t]
91-
const advantage = returns[t] - this.baselineReturn
106+
const normalizedReturn = (returns[t] - meanG) / stdG
92107

93108
const phi = features(s)
94109
const logits = this.weights.map((w) =>
95110
w.reduce((sum, wi, i) => sum + wi * phi[i], 0),
96111
)
97112
const probs = softmax(logits)
98113

114+
// Policy gradient: ∇W = lr · G̃_t · (I(j==a) − π(j|s)) · φ(s)
99115
for (let j = 0; j < NUM_ACTIONS; j++) {
100116
const indicator = j === a ? 1 : 0
101-
const gradScale = (indicator - probs[j]) * advantage
117+
const gradScale = (indicator - probs[j]) * normalizedReturn
102118
for (let f = 0; f < NUM_FEATURES; f++) {
103119
this.weights[j][f] += this.lr * gradScale * phi[f]
104120
}
@@ -110,15 +126,15 @@ export class ReinforceAgent implements Agent<ClassicCartPoleState, ClassicCartPo
110126

111127
getValues(): Record<string, number[]> {
112128
const flat = this.weights.flat()
113-
return { weights: flat, baseline: [this.baselineReturn] }
129+
return { weights: flat, baseline: [this.displayBaseline] }
114130
}
115131

116132
reset(): void {
117133
this.weights = Array.from({ length: NUM_ACTIONS }, () =>
118134
new Array(NUM_FEATURES).fill(0),
119135
)
120136
this.trajectory = []
121-
this.baselineReturn = 0
137+
this.displayBaseline = 0
122138
this.episodeCount = 0
123139
}
124140

src/components/classicCartpole/ClassicCartPolePage.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ const ALGO_CATEGORY: Record<AlgorithmType, string> = {
2424

2525
export function ClassicCartPolePage() {
2626
const [algorithmType, setAlgorithmType] = useState<AlgorithmType>('discretized-q')
27-
const [alpha, setAlpha] = useState(0.2)
27+
const [alpha, setAlpha] = useState(0.5)
2828
const [gamma, setGamma] = useState(0.99)
29-
const [gammaRF, setGammaRF] = useState(0.97)
29+
const [gammaRF, setGammaRF] = useState(0.99)
3030
const [epsilon, setEpsilon] = useState(1.0)
31-
const [lr, setLr] = useState(0.005)
31+
const [lr, setLr] = useState(0.002)
3232
const [bins, setBins] = useState(6)
33-
const [epsilonDecay, setEpsilonDecay] = useState(0.998)
34-
const [epsilonMin, setEpsilonMin] = useState(0.01)
33+
const [epsilonDecay, setEpsilonDecay] = useState(0.995)
34+
const [epsilonMin, setEpsilonMin] = useState(0.05)
3535
const [showIntro, setShowIntro] = useState(true)
3636
const [maxSteps, setMaxSteps] = useState(100000)
3737
const [envSeed, setEnvSeed] = useState(0)

0 commit comments

Comments
 (0)