Skip to content

Commit e12e711

Browse files
committed
fix: reward scaling, PPO clipping, ELA memory cap, and eval metrics
1 parent 48c1f5e commit e12e711

9 files changed

Lines changed: 165 additions & 58 deletions

File tree

agents/rl_das/agent.py

Lines changed: 13 additions & 12 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

@@ -243,7 +242,9 @@ def save(self, path: str) -> None:
243242
@classmethod
244243
def load(cls, path: str, dim: int, n_opt: int, **kwargs) -> "PPOAgent":
245244
agent = cls(dim, n_opt, **kwargs)
246-
ckpt = torch.load(path, map_location=agent.device)
245+
# weights_only=True prevents arbitrary code execution via pickle when loading
246+
# a checkpoint from an untrusted source, and silences FutureWarning in PyTorch 2.0+.
247+
ckpt = torch.load(path, map_location=agent.device, weights_only=True)
247248
agent.actor.load_state_dict(ckpt["actor"])
248249
agent.critic.load_state_dict(ckpt["critic"])
249250
if "optimizer" in ckpt:

agents/rl_das/env.py

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

188+
@property
189+
def problem_ids(self) -> list[str]:
190+
# Public accessor — callers should not reach into _problem_ids directly
191+
# because it is filtered (dimension-matched) and may differ from the
192+
# original list passed to the constructor.
193+
return self._problem_ids
194+
188195
# ------------------------------------------------------------------
189196
# Gymnasium interface
190197
# ------------------------------------------------------------------

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: 22 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-
diagnostics = 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,16 @@ 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(np.mean([d["actor_loss"] for d in epoch_diagnostics]))
135+
entry["critic_loss"] = float(np.mean([d["critic_loss"] for d in epoch_diagnostics]))
136+
entry["entropy"] = float(np.mean([d["entropy"] for d in epoch_diagnostics]))
137+
125138
if epoch % eval_interval == 0:
126139
test_results = evaluate(
127-
test_env, agent, n_episodes=len(test_env._problem_ids)
140+
test_env, agent, n_episodes=len(test_env.problem_ids)
128141
)
129142
entry["mean_test_reward"] = float(
130143
np.mean([r["total_reward"] for r in test_results])
@@ -137,12 +150,16 @@ def train(
137150
f" train_r={mean_train_reward:.4f}"
138151
f" test_r={entry['mean_test_reward']:.4f}"
139152
f" test_best_y={entry['mean_test_best_y']:.4e}"
153+
f" actor_loss={entry.get('actor_loss', float('nan')):.4f}"
154+
f" entropy={entry.get('entropy', float('nan')):.4f}"
140155
f" ({entry['elapsed_s']:.1f}s)"
141156
)
142157
else:
143158
print(
144159
f"Epoch {epoch:4d}/{n_epochs}"
145160
f" train_r={mean_train_reward:.4f}"
161+
f" actor_loss={entry.get('actor_loss', float('nan')):.4f}"
162+
f" entropy={entry.get('entropy', float('nan')):.4f}"
146163
f" ({entry['elapsed_s']:.1f}s)"
147164
)
148165

@@ -186,7 +203,7 @@ def evaluate(
186203
List of dicts with keys: problem_id, total_reward, best_y, n_fe.
187204
"""
188205
if n_episodes is None:
189-
n_episodes = len(env._problem_ids)
206+
n_episodes = len(env.problem_ids)
190207

191208
results = []
192209
for _ in range(n_episodes):

das/env/bbob_splits.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
ALL_DIMS = [2, 3, 5, 10, 20, 40]
88
ALL_FUNCTIONS = set(range(1, 25))
99
INSTANCE_IDS = [1, 2, 3, 4, 5, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80]
10-
EASY_TRAIN_FUNCTIONS = {4, *range(6, 15), 18, 19, 20, 22, 23, 24}
10+
EASY_TRAIN_FUNCTIONS = {1, 2, 3, 4, *range(6, 15), 18, 19, 20, 22, 23, 24} # Czy tutaj nie powinny być też funkcje 1,2,3?
1111

1212

1313
def build_problem_ids(
@@ -22,7 +22,7 @@ def build_problem_ids(
2222
]
2323

2424

25-
def get_train_test_split(mode: str, dims: list[int]) -> tuple[list[str], list[str]]:
25+
def get_train_test_split(mode: str, dims: list[int], seed: int = 0) -> tuple[list[str], list[str]]:
2626
"""Return (train_ids, test_ids) for the given split mode and dimensions.
2727
2828
Modes:
@@ -42,7 +42,7 @@ def get_train_test_split(mode: str, dims: list[int]) -> tuple[list[str], list[st
4242
)
4343
# random 2/3 – 1/3 split
4444
all_ids = build_problem_ids(ALL_FUNCTIONS, dims)
45-
rng = np.random.default_rng()
45+
rng = np.random.default_rng(seed)
4646
rng.shuffle(all_ids)
4747
split = 2 * len(all_ids) // 3
4848
return all_ids[:split], all_ids[split:]

das/env/das_env.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,21 @@
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 (
20+
compute_observation,
21+
observation_dim,
22+
compute_ela_features,
23+
MAX_HISTORY_SAMPLE,
24+
ELA_DIM,
25+
)
2026
from das.env.reward import compute_reward
2127
from das.optimizers.base import get_checkpoints
2228

29+
# Recompute ELA every ~500 new population samples. pflacco runs regression,
30+
# nearest-neighbour search, and IC calculations on every call — running it
31+
# every step would dominate wall-clock time for long training runs.
32+
_ELA_RECOMPUTE_THRESHOLD = MAX_HISTORY_SAMPLE // 5
33+
2334

2435
class DASEnv(gym.Env):
2536
"""DAS environment.
@@ -96,6 +107,11 @@ def __init__(
96107
self._stagnation_count = 0
97108
self._choices_history: list[int] = []
98109

110+
# ELA features are expensive; cache the last computed vector and refresh
111+
# lazily once _ELA_RECOMPUTE_THRESHOLD new samples have arrived.
112+
self._ela_cache: np.ndarray = np.zeros(ELA_DIM, dtype=np.float32)
113+
self._ela_cache_len: int = 0
114+
99115
# ------------------------------------------------------------------ #
100116
# Gymnasium interface #
101117
# ------------------------------------------------------------------ #
@@ -125,6 +141,8 @@ def reset(self, seed=None, options=None):
125141
self._initial_range = (float("inf"), -np.inf)
126142
self._stagnation_count = 0
127143
self._choices_history = []
144+
self._ela_cache = np.zeros(ELA_DIM, dtype=np.float32)
145+
self._ela_cache_len = 0
128146

129147
obs = self._build_observation()
130148
info = {"problem_id": problem_id, "dimension": dim}
@@ -228,14 +246,25 @@ def _update_episode_state(self, result: dict, prev_best_y: float):
228246
if worst_y > self._worst_y:
229247
self._worst_y = worst_y
230248

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

235-
# Stagnation counter
259+
# Stagnation counter — prefer the FE delta from the result dict so that
260+
# stagnation accumulates correctly even when y_history is not returned.
236261
x_hist: np.ndarray | None = result.get("x_history")
237262
y_hist: np.ndarray | None = result.get("y_history")
238-
n_fe_step = len(y_hist) if y_hist is not None else 0
263+
n_fe_reported = result.get("n_function_evaluations")
264+
if n_fe_reported is not None:
265+
n_fe_step = max(0, n_fe_reported - self._n_fe)
266+
else:
267+
n_fe_step = len(y_hist) if y_hist is not None else 0
239268

240269
if new_best_y >= prev_best_y:
241270
self._stagnation_count += n_fe_step
@@ -244,20 +273,30 @@ def _update_episode_state(self, result: dict, prev_best_y: float):
244273

245274
self._n_fe = result.get("n_function_evaluations", self._n_fe + n_fe_step)
246275

247-
# Accumulate population history for ELA
276+
# Accumulate population history for ELA, capped at MAX_HISTORY_SAMPLE rows.
277+
# Without the cap, large budgets (e.g. 40-dim × 10 000 FE) accumulate
278+
# hundreds of thousands of rows — GBs of RAM for a single episode.
248279
if x_hist is not None and len(x_hist) > 0:
249280
self._x_history = (
250-
x_hist
281+
x_hist[-MAX_HISTORY_SAMPLE:]
251282
if self._x_history is None
252-
else np.concatenate([self._x_history, x_hist])
283+
else np.concatenate([self._x_history, x_hist])[-MAX_HISTORY_SAMPLE:]
253284
)
254285
self._y_history = (
255-
y_hist
286+
y_hist[-MAX_HISTORY_SAMPLE:]
256287
if self._y_history is None
257-
else np.concatenate([self._y_history, y_hist])
288+
else np.concatenate([self._y_history, y_hist])[-MAX_HISTORY_SAMPLE:]
258289
)
259290

260291
def _build_observation(self) -> np.ndarray:
292+
# Recompute ELA only when enough new samples have arrived.
293+
# _ela_cache starts as zeros (correct before 50 samples) and is reset
294+
# each episode, so stale features from a previous episode never leak in.
295+
current_len = len(self._x_history) if self._x_history is not None else 0
296+
if current_len >= 50 and current_len - self._ela_cache_len >= _ELA_RECOMPUTE_THRESHOLD:
297+
self._ela_cache = compute_ela_features(self._x_history, self._y_history)
298+
self._ela_cache_len = current_len
299+
261300
return compute_observation(
262301
x_history=self._x_history,
263302
y_history=self._y_history,
@@ -268,4 +307,5 @@ def _build_observation(self) -> np.ndarray:
268307
max_fe=max(self._max_fe, 1),
269308
stagnation_count=self._stagnation_count,
270309
ndim_problem=self._problem.dimension if self._problem is not None else 1,
310+
ela=self._ela_cache,
271311
)

das/env/observation.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@ def compute_ela_features(x: np.ndarray, y: np.ndarray) -> np.ndarray:
5858
with warnings.catch_warnings():
5959
warnings.simplefilter("ignore")
6060

61-
_, unique_idx = np.unique(x, axis=0, return_index=True)
62-
unique_idx = np.sort(unique_idx)
63-
x = x[unique_idx][-MAX_HISTORY_SAMPLE:]
64-
y = y[unique_idx][-MAX_HISTORY_SAMPLE:]
61+
# Slice to the most-recent samples first; deduplication is done below
62+
# in normalised space where it is actually meaningful — raw-space
63+
# np.unique missed points that become identical after normalisation and
64+
# was therefore doing redundant work without full correctness guarantees.
65+
x = x[-MAX_HISTORY_SAMPLE:]
66+
y = y[-MAX_HISTORY_SAMPLE:]
6567

6668
x_norm_arr = (x - x.mean()) / (x.std() + 1e-8)
6769
y_norm_arr = (y - y.mean()) / (y.std() + 1e-8)
@@ -96,8 +98,14 @@ def compute_ela_features(x: np.ndarray, y: np.ndarray) -> np.ndarray:
9698
)
9799
}
98100

99-
all_feats = {**meta, **nbc, **disp, **ic, **ela_distr}
100-
return np.array([all_feats[k] for k in ELA_FEATURE_KEYS], dtype=np.float32)
101+
# pflacco may return an incomplete dict for degenerate or edge-case
102+
# inputs that slipped past the variance guard above. Fall back to
103+
# zeros rather than crashing training with a KeyError mid-run.
104+
try:
105+
all_feats = {**meta, **nbc, **disp, **ic, **ela_distr}
106+
return np.array([all_feats[k] for k in ELA_FEATURE_KEYS], dtype=np.float32)
107+
except (KeyError, ValueError):
108+
return np.zeros(ELA_DIM, dtype=np.float32)
101109

102110

103111
def compute_action_history_features(
@@ -124,9 +132,8 @@ def compute_action_history_features(
124132
last_idx = choices_history[-1]
125133
last_action[last_idx] = 1.0
126134

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

132139
run = 0
@@ -165,12 +172,16 @@ def compute_observation(
165172
max_fe: int,
166173
stagnation_count: int,
167174
ndim_problem: int,
175+
ela: np.ndarray | None = None,
168176
) -> np.ndarray:
169177
"""Assemble the full observation vector from its components."""
170-
if x_history is not None and y_history is not None and len(x_history) >= 50:
171-
ela = compute_ela_features(x_history, y_history)
172-
else:
173-
ela = np.zeros(ELA_DIM, dtype=np.float32)
178+
# Accept a pre-computed ELA vector so the caller can cache it across steps
179+
# and avoid running pflacco on every observation build (pflacco is expensive).
180+
if ela is None:
181+
if x_history is not None and y_history is not None and len(x_history) >= 50:
182+
ela = compute_ela_features(x_history, y_history)
183+
else:
184+
ela = np.zeros(ELA_DIM, dtype=np.float32)
174185

175186
action_hist = compute_action_history_features(
176187
choices_history, n_actions, n_checkpoints, ndim_problem

das/env/reward.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ def _improvement_ratio(
1818
def reward_log_scaled(new_best_y, old_best_y, initial_range, is_final=False):
1919
"""Log-scaled incremental improvement (original r1)."""
2020
if old_best_y == float("inf"):
21-
return float(np.log(initial_range[1] - initial_range[0] + 1e-10))
21+
return 0.0
2222
ratio = _improvement_ratio(new_best_y, old_best_y, initial_range)
2323
return float(np.log(np.clip(ratio, 0.0, 1.0) + 1e-5))
2424

2525

2626
def reward_linear(new_best_y, old_best_y, initial_range, is_final=False):
2727
"""Linear improvement clipped to [0, 1] (original r2)."""
2828
if old_best_y == float("inf"):
29-
return float(np.log(initial_range[1] - initial_range[0] + 1e-10))
29+
# No prior best on the first step — returning log(scale) here would
30+
# produce a value outside [0, 1] and break the linear contract.
31+
return 0.0
3032
return float(
3133
np.clip(_improvement_ratio(new_best_y, old_best_y, initial_range), 0.0, 1.0)
3234
)
@@ -35,7 +37,7 @@ def reward_linear(new_best_y, old_best_y, initial_range, is_final=False):
3537
def reward_sparse(new_best_y, old_best_y, initial_range, is_final=False):
3638
"""Sparse: only reward at the final checkpoint (original r3)."""
3739
if old_best_y == float("inf") or not is_final:
38-
return float(np.log(initial_range[1] - initial_range[0] + 1e-10))
40+
return 0.0
3941
total_improvement = initial_range[0] - new_best_y
4042
scale = initial_range[1] - initial_range[0]
4143
return float(np.log(total_improvement / (scale + 1e-10) + 1e-5))
@@ -44,7 +46,7 @@ def reward_sparse(new_best_y, old_best_y, initial_range, is_final=False):
4446
def reward_binary(new_best_y, old_best_y, initial_range, is_final=False):
4547
"""Binary: 1 if improvement >= 0.1%, else 0 (original r4)."""
4648
if old_best_y == float("inf"):
47-
return float(np.log(initial_range[1] - initial_range[0] + 1e-10))
49+
return 0.0
4850
ratio = _improvement_ratio(new_best_y, old_best_y, initial_range)
4951
return 1.0 if ratio >= 1e-3 else 0.0
5052

0 commit comments

Comments
 (0)