Skip to content

Commit d014d6a

Browse files
authored
Merge pull request #4 from helix-agh/code-review
fix: reward scaling, PPO clipping, ELA memory cap, and eval metrics
2 parents 824844a + c93efb8 commit d014d6a

8 files changed

Lines changed: 97 additions & 35 deletions

File tree

agents/rl_das/agent.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -194,17 +194,16 @@ def learn(self, k_epoch: int, bootstrap_value: float = 0.0) -> dict[str, float]:
194194
)
195195
actor_loss = -torch.min(surr1, surr2).mean()
196196

197-
# Value clipping (like PPO v2) from the 2nd epoch onward
198-
if epoch_idx > 0:
199-
values_clipped = old_values_t + torch.clamp(
200-
values - old_values_t, -self.eps_clip, self.eps_clip
201-
)
202-
critic_loss = torch.max(
203-
(values - returns_t.detach()) ** 2,
204-
(values_clipped - returns_t.detach()) ** 2,
205-
).mean()
206-
else:
207-
critic_loss = (values - returns_t.detach()).pow(2).mean()
197+
# Value clipping applied from the first inner epoch. Skipping it
198+
# on epoch 0 allowed an unconstrained large update on the first step,
199+
# breaking the PPO v2 guarantee that value changes stay within eps_clip.
200+
values_clipped = old_values_t + torch.clamp(
201+
values - old_values_t, -self.eps_clip, self.eps_clip
202+
)
203+
critic_loss = torch.max(
204+
(values - returns_t.detach()) ** 2,
205+
(values_clipped - returns_t.detach()) ** 2,
206+
).mean()
208207

209208
loss = actor_loss + 0.5 * critic_loss - 0.01 * entropy
210209

agents/rl_das/env.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,13 @@ def __init__(
242242
self._best_history: list[list[np.ndarray]] = [[] for _ in range(self.n_opt)]
243243
self._worst_history: list[list[np.ndarray]] = [[] for _ in range(self.n_opt)]
244244

245+
@property
246+
def problem_ids(self) -> list[str]:
247+
# Public accessor — callers should not reach into _problem_ids directly
248+
# because it is filtered (dimension-matched) and may differ from the
249+
# original list passed to the constructor.
250+
return self._problem_ids
251+
245252
# ------------------------------------------------------------------
246253
# Gymnasium interface
247254
# ------------------------------------------------------------------

agents/rl_das/network.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ def __init__(self, dim: int) -> None:
3232
nn.Linear(dim, 64),
3333
nn.ReLU(),
3434
nn.Linear(64, 1),
35-
nn.ReLU(),
35+
# No second ReLU: movement vectors are signed displacements.
36+
# Clamping to >= 0 discards direction — the network cannot tell
37+
# whether the optimizer stepped left or right in search space.
3638
)
3739

3840
def forward(self, x: torch.Tensor) -> torch.Tensor:
@@ -94,4 +96,7 @@ def __init__(self, dim: int, n_opt: int) -> None:
9496
self.head = nn.Linear(16, 1)
9597

9698
def forward(self, obs: torch.Tensor) -> torch.Tensor:
99+
# Mirror Actor's NaN guard: a NaN value estimate flows into advantages
100+
# and silently zeroes all gradients via backward(), corrupting the update.
101+
obs = torch.nan_to_num(obs, nan=0.0, posinf=1.0, neginf=-1.0)
97102
return self.head(self.backbone(obs)).squeeze(-1) # (batch,)

agents/rl_das/trainer.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,24 @@ def train(
102102
"""
103103
Path(save_dir).mkdir(parents=True, exist_ok=True)
104104
log: list[dict] = []
105-
n_train = len(train_env._problem_ids)
105+
n_train = len(train_env.problem_ids)
106106

107107
for epoch in range(1, n_epochs + 1):
108-
epoch_rewards = []
108+
epoch_rewards: list[float] = []
109+
epoch_diagnostics: list[dict] = []
109110
epoch_start = time.time()
110111

111112
for _ in range(n_train):
112113
ep = _run_episode(train_env, agent, deterministic=False)
113114
epoch_rewards.append(ep["total_reward"])
114115

115-
agent.learn(k_epoch)
116+
# bootstrap_value=0.0 is correct: this env only terminates naturally
117+
# (terminated=True, truncated always False), so the last done=True flag
118+
# already zeroes future returns — no critic bootstrap is needed.
119+
diag = agent.learn(k_epoch, bootstrap_value=0.0)
116120
agent.rollout.clear()
121+
if diag:
122+
epoch_diagnostics.append(diag)
117123

118124
mean_train_reward = float(np.mean(epoch_rewards))
119125
entry: dict = {
@@ -122,9 +128,20 @@ def train(
122128
"elapsed_s": round(time.time() - epoch_start, 2),
123129
}
124130

131+
# Log per-epoch PPO diagnostics so training instability is visible
132+
# (e.g. actor_loss explosion, entropy collapse) without manual debugging.
133+
if epoch_diagnostics:
134+
entry["actor_loss"] = float(
135+
np.mean([d["actor_loss"] for d in epoch_diagnostics])
136+
)
137+
entry["critic_loss"] = float(
138+
np.mean([d["critic_loss"] for d in epoch_diagnostics])
139+
)
140+
entry["entropy"] = float(np.mean([d["entropy"] for d in epoch_diagnostics]))
141+
125142
if epoch % eval_interval == 0:
126143
test_results = evaluate(
127-
test_env, agent, n_episodes=len(test_env._problem_ids)
144+
test_env, agent, n_episodes=len(test_env.problem_ids)
128145
)
129146
entry["mean_test_reward"] = float(
130147
np.mean([r["total_reward"] for r in test_results])
@@ -137,12 +154,16 @@ def train(
137154
f" train_r={mean_train_reward:.4f}"
138155
f" test_r={entry['mean_test_reward']:.4f}"
139156
f" test_best_y={entry['mean_test_best_y']:.4e}"
157+
f" actor_loss={entry.get('actor_loss', float('nan')):.4f}"
158+
f" entropy={entry.get('entropy', float('nan')):.4f}"
140159
f" ({entry['elapsed_s']:.1f}s)"
141160
)
142161
else:
143162
print(
144163
f"Epoch {epoch:4d}/{n_epochs}"
145164
f" train_r={mean_train_reward:.4f}"
165+
f" actor_loss={entry.get('actor_loss', float('nan')):.4f}"
166+
f" entropy={entry.get('entropy', float('nan')):.4f}"
146167
f" ({entry['elapsed_s']:.1f}s)"
147168
)
148169

@@ -186,7 +207,7 @@ def evaluate(
186207
List of dicts with keys: problem_id, total_reward, best_y, n_fe.
187208
"""
188209
if n_episodes is None:
189-
n_episodes = len(env._problem_ids)
210+
n_episodes = len(env.problem_ids)
190211

191212
results = []
192213
for _ in range(n_episodes):

das/env/das_env.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import gymnasium as gym
1717
from gymnasium import spaces
1818

19-
from das.env.observation import compute_observation, observation_dim
19+
from das.env.observation import compute_observation, observation_dim, MAX_HISTORY_SAMPLE
2020
from das.env.reward import compute_reward
2121
from das.optimizers.base import get_checkpoints
2222

@@ -251,14 +251,27 @@ def _update_episode_state(self, result: dict, prev_best_y: float):
251251
if worst_y > self._worst_y:
252252
self._worst_y = worst_y
253253

254-
# Set initial range on first step
254+
# Set initial range on first step.
255+
# When worst_so_far_y is absent the default is -inf, which collapses
256+
# scale to 1e-5 and inflates every subsequent reward by 1e5. Instead,
257+
# derive scale from the magnitude of the initial best fitness.
255258
if self._initial_range[0] == float("inf"):
256-
self._initial_range = (new_best_y, max(worst_y, new_best_y + 1e-5))
259+
safe_worst = (
260+
worst_y
261+
if np.isfinite(worst_y)
262+
else new_best_y + max(abs(new_best_y), 1.0)
263+
)
264+
self._initial_range = (new_best_y, max(safe_worst, new_best_y + 1e-5))
257265

258-
# Stagnation counter
266+
# Stagnation counter — prefer the FE delta from the result dict so that
267+
# stagnation accumulates correctly even when y_history is not returned.
259268
x_hist: np.ndarray | None = result.get("x_history")
260269
y_hist: np.ndarray | None = result.get("y_history")
261-
n_fe_step = len(y_hist) if y_hist is not None else 0
270+
n_fe_reported = result.get("n_function_evaluations")
271+
if n_fe_reported is not None:
272+
n_fe_step = max(0, n_fe_reported - self._n_fe)
273+
else:
274+
n_fe_step = len(y_hist) if y_hist is not None else 0
262275

263276
if new_best_y >= prev_best_y:
264277
self._stagnation_count += n_fe_step
@@ -267,20 +280,23 @@ def _update_episode_state(self, result: dict, prev_best_y: float):
267280

268281
self._n_fe = result.get("n_function_evaluations", self._n_fe + n_fe_step)
269282

270-
# Accumulate population history for ELA
283+
# Accumulate population history for ELA, capped at MAX_HISTORY_SAMPLE rows.
284+
# Without the cap, large budgets (e.g. 40-dim × 10 000 FE) accumulate
285+
# hundreds of thousands of rows — GBs of RAM for a single episode.
271286
if x_hist is not None and len(x_hist) > 0:
272287
self._x_history = (
273-
x_hist
288+
x_hist[-MAX_HISTORY_SAMPLE:]
274289
if self._x_history is None
275-
else np.concatenate([self._x_history, x_hist])
290+
else np.concatenate([self._x_history, x_hist])[-MAX_HISTORY_SAMPLE:]
276291
)
277292
self._y_history = (
278-
y_hist
293+
y_hist[-MAX_HISTORY_SAMPLE:]
279294
if self._y_history is None
280-
else np.concatenate([self._y_history, y_hist])
295+
else np.concatenate([self._y_history, y_hist])[-MAX_HISTORY_SAMPLE:]
281296
)
282297

283298
def _build_observation(self) -> np.ndarray:
299+
284300
return compute_observation(
285301
x_history=self._x_history,
286302
y_history=self._y_history,

das/env/observation.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,8 @@ def compute_action_history_features(
124124
last_idx = choices_history[-1]
125125
last_action[last_idx] = 1.0
126126

127-
counts = np.array(
128-
[choices_history.count(j) for j in range(n_actions)], dtype=np.float32
129-
)
127+
# O(n) instead of O(n_actions * n_steps) from calling list.count in a loop.
128+
counts = np.bincount(choices_history, minlength=n_actions).astype(np.float32)
130129
frequencies = counts / len(choices_history)
131130

132131
run = 0

das/env/reward.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def reward_sparse(new_best_y, old_best_y, initial_range, is_final=False):
4444
def reward_binary(new_best_y, old_best_y, initial_range, is_final=False):
4545
"""Binary: 1 if improvement >= 0.1%, else 0 (original r4)."""
4646
if old_best_y == float("inf"):
47-
return float(np.log(initial_range[1] - initial_range[0] + 1e-10))
47+
return 0.0
4848
ratio = _improvement_ratio(new_best_y, old_best_y, initial_range)
4949
return 1.0 if ratio >= 1e-3 else 0.0
5050

das/training/rldas.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ def run_rl_das(args) -> None:
2222

2323
suite = IOHSuite()
2424

25-
if args.k_epoch is None:
26-
args.k_epoch = max(1, int(0.3 * args.n_checkpoints))
25+
# Local variable — avoid mutating args so the caller's namespace stays predictable.
26+
k_epoch = (
27+
args.k_epoch
28+
if args.k_epoch is not None
29+
else max(1, int(0.3 * args.n_checkpoints))
30+
)
2731

2832
env_kwargs = dict(
2933
suite=suite,
@@ -45,15 +49,15 @@ def run_rl_das(args) -> None:
4549
print(
4650
f"RL-DAS | dim={args.dim} | portfolio={args.portfolio}"
4751
f" | obs_dim={train_env.observation_space.shape[0]}"
48-
f" | k_epoch={args.k_epoch}"
52+
f" | k_epoch={k_epoch}"
4953
)
5054

5155
train(
5256
train_env=train_env,
5357
test_env=test_env,
5458
agent=agent,
5559
n_epochs=args.n_epochs,
56-
k_epoch=args.k_epoch,
60+
k_epoch=k_epoch,
5761
eval_interval=args.eval_interval,
5862
save_interval=args.save_interval,
5963
save_dir="models",
@@ -62,6 +66,17 @@ def run_rl_das(args) -> None:
6266

6367
if args.eval:
6468
print("\nRunning final evaluation on test set …")
69+
70+
# Fresh env so _problem_idx starts at 0. test_env accumulated increments
71+
# from periodic evaluations inside train() and would start from a rotated
72+
# offset rather than problem 0, making results hard to reproduce.
73+
eval_env = RLDASEnv(problem_ids=test_ids, **env_kwargs)
74+
n_problems = len(test_ids)
75+
test_results = evaluate(eval_env, agent, n_episodes=n_problems)
76+
77+
# Create the output directory before writing — write_jsonl does not
78+
# create parent directories and would raise FileNotFoundError otherwise.
79+
os.makedirs("results", exist_ok=True)
6580
n_problems = len(test_env._problem_ids)
6681
test_results = evaluate(test_env, agent, n_episodes=n_problems)
6782
mean_best_y = float(np.mean([r["best_y"] for r in test_results]))

0 commit comments

Comments
 (0)