1616import gymnasium as gym
1717from 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+ )
2026from das .env .reward import compute_reward
2127from 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
2435class 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 )
0 commit comments