SearchLibrium 0.0.253__tar.gz → 0.0.260__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {searchlibrium-0.0.253/src/SearchLibrium.egg-info → searchlibrium-0.0.260}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/pyproject.toml +2 -2
  3. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/harmony.py +44 -24
  4. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/hspbil.py +7 -1
  5. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/mh_choice_estimator.py +45 -7
  6. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/search.py +108 -1
  7. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/sparseea_agds.py +84 -45
  8. searchlibrium-0.0.260/src/SearchLibrium/version.txt +1 -0
  9. {searchlibrium-0.0.253 → searchlibrium-0.0.260/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  10. searchlibrium-0.0.253/src/SearchLibrium/version.txt +0 -1
  11. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/LICENSE +0 -0
  12. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/MANIFEST.in +0 -0
  13. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/README.md +0 -0
  14. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/setup.cfg +0 -0
  15. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/BEHier.py +0 -0
  16. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/Halton.py +0 -0
  17. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/MixedLogit.py +0 -0
  18. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  19. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  20. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/RandomP.py +0 -0
  21. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  22. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  23. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/__init__.py +0 -0
  24. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/__main__.py +0 -0
  25. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/_choice_model.py +0 -0
  26. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/_device.py +0 -0
  27. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/abm.py +0 -0
  28. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/accessibility.py +0 -0
  29. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/banditsa.py +0 -0
  30. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/boxcox_functions.py +0 -0
  31. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/call_meta.py +0 -0
  32. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/constraints_builder.py +0 -0
  33. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/coupled_solver.py +0 -0
  34. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  35. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/data/TravelMode.csv +0 -0
  36. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/data/electricity.csv +0 -0
  37. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/destination_distances.py +0 -0
  38. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/jax_utils.py +0 -0
  39. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/larch_compat.py +0 -0
  40. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/larch_models.py +0 -0
  41. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/latent_class.py +0 -0
  42. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/latent_spec.py +0 -0
  43. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/logistic_regression.py +0 -0
  44. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/main.py +0 -0
  45. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/main_debug.py +0 -0
  46. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/mdcev.py +0 -0
  47. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/misc.py +0 -0
  48. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/mixed_logit.py +0 -0
  49. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/mixed_nested.py +0 -0
  50. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/mixedrrm.py +0 -0
  51. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/multinomial_logit.py +0 -0
  52. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/multinomial_nested.py +0 -0
  53. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/multinomial_probit.py +0 -0
  54. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/numba_engine.py +0 -0
  55. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/numba_mxl.py +0 -0
  56. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/numba_rrm.py +0 -0
  57. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/ordered_logit.py +0 -0
  58. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  59. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/predict.py +0 -0
  60. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/regularization.py +0 -0
  61. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/rrm.py +0 -0
  62. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/sample_data.py +0 -0
  63. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/sapbil.py +0 -0
  64. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/selection_models.py +0 -0
  65. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/setup.py +0 -0
  66. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/siman.py +0 -0
  67. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/skims.py +0 -0
  68. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/test_lc_de.py +0 -0
  69. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/test_mario_searches.py +0 -0
  70. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  71. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/threshold.py +0 -0
  72. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  73. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  74. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  75. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  76. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium.egg-info/requires.txt +0 -0
  77. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  78. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_destination_distances.py +0 -0
  79. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_jax_mask_equivalence.py +0 -0
  80. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_larch_integration.py +0 -0
  81. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_lc_smart_init.py +0 -0
  82. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_mh_choice_estimator.py +0 -0
  83. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_mxl_random_starts.py +0 -0
  84. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_per_alternative_random.py +0 -0
  85. {searchlibrium-0.0.253 → searchlibrium-0.0.260}/tests/test_zero_inflated_ordered_probit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.253
3
+ Version: 0.0.260
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -129,7 +129,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
129
129
  realpython = "SearchLibrium.__main__:main"
130
130
 
131
131
  [tool.bumpver]
132
- current_version = "0.0.253"
132
+ current_version = "0.0.260"
133
133
  version_pattern = "MAJOR.MINOR.PATCH"
134
134
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
135
135
  commit = true
@@ -137,4 +137,4 @@ tag = true
137
137
  push = false
138
138
 
139
139
  [tool.bumpver.file_patterns]
140
- "src/SearchLibrium/version.txt" = ["{version}"]
140
+ "src/SearchLibrium/version.txt" = ["{version}"]
@@ -237,34 +237,49 @@ class HarmonySearch(Search):
237
237
  def initialize_memory(self, nb_sols):
238
238
  """
239
239
  Initialize harmony memory and opposite harmony memory with unique random solutions.
240
+
241
+ De-duplicates by model SPECIFICATION (``get_unique_spec``), not by
242
+ objective value (``get_unique``). Criterion 0 is usually BIC (a
243
+ continuous float), but many specs still share an objective value —
244
+ and where the objective is a coarse count (e.g. an ``nsig``-style
245
+ criterion) ``get_unique(mem, 0)`` collapses the whole memory to a
246
+ handful of entries, so the loop burns its entire attempt budget
247
+ without ever reaching ``nb_sols``. Only NEW opposite solutions are
248
+ folded in each pass (the old ``mem += opp_mem`` re-merged and
249
+ re-sorted the whole accumulated ``opp_mem`` every iteration — O(n^2)
250
+ work and needless model fits).
240
251
  """
241
- mem, opp_mem = [], [] # Initialize memory and opposite memory
242
- for counter in range(30000):
252
+ mem = []
253
+ seen = set() # spec keys already represented in mem
254
+ max_attempts = 30000 # safety ceiling on outer generation attempts
255
+
256
+ def _admit(cand):
257
+ """True if ``cand`` is converged, within BOUND, and a new spec."""
258
+ if cand is None or not abs(cand.obj(0)) < BOUND:
259
+ return False
260
+ k = spec_key(cand)
261
+ if k in seen:
262
+ return False
263
+ seen.add(k)
264
+ mem.append(cand)
265
+ return True
266
+
267
+ attempts = 0
268
+ while len(mem) < nb_sols and attempts < max_attempts:
269
+ attempts += 1
243
270
  sol = self.generate_solution() # Generate a solution
244
271
  sol, converged = self.evaluate_solution(sol)
245
272
 
246
- if converged:
247
- mem.append(sol) # Add solution to harmony memory
248
-
249
- # Generate opposite solution only if the original solution is valid
250
- if sol is not None:
273
+ if converged and _admit(sol):
274
+ # Generate one opposite solution and fold it in directly
251
275
  opp_sol = self.create_opposite_solution(sol)
252
-
253
276
  if opp_sol is not None: # Validate opposite solution before evaluating
254
- opp_sol, converged = self.evaluate_solution(opp_sol)
255
- if converged:
256
- opp_mem.append(opp_sol) # Add opposite solution to opposite memory
257
-
258
- mem += opp_mem # Combine memory and opposite memory
259
- mem = get_unique(mem, 0) # Keep unique solutions only
260
-
261
- # Filter memory to remove poor solutions based on the first objective
262
- mem = [sol for sol in mem if abs(sol.obj(0)) < BOUND] # Call obj(0) correctly
263
-
264
- if len(mem) >= nb_sols:
265
- return mem[:nb_sols] # Return the initialized memory
277
+ opp_sol, o_converged = self.evaluate_solution(opp_sol)
278
+ if o_converged:
279
+ _admit(opp_sol)
266
280
 
267
- return mem # Return memory after exhausting attempts
281
+ # Keep the best nb_sols by objective (get_unique_spec sorts by obj(0))
282
+ return get_unique_spec(mem, 0)[:nb_sols]
268
283
 
269
284
  ''' ---------------------------------------------------------- '''
270
285
  ''' Function. Build new solution using Harmony Memory '''
@@ -1237,7 +1252,7 @@ class HarmonySearch(Search):
1237
1252
  # }
1238
1253
 
1239
1254
  ''' ---------------------------------------------------------- '''
1240
- ''' Function. Discard non-convergent solutions '''
1255
+ ''' Function. Discard non-convergent solutions '''
1241
1256
  ''' ---------------------------------------------------------- '''
1242
1257
  def screen_solutions(self, solutions):
1243
1258
  # {
@@ -1435,7 +1450,11 @@ class HarmonySearch(Search):
1435
1450
  # OPTIONAL: CREATE max(max_mem - existing, 0) NEW SOLUTIONS?
1436
1451
 
1437
1452
  init_memory = generated_memory + existing_memory # Aggregate solution lists
1438
- unique_memory = get_unique(init_memory, 0) # Remove duplicate solutions if present
1453
+ # De-duplicate by SPECIFICATION, not objective value: get_unique(init_memory, 0)
1454
+ # drops every candidate sharing an obj(0) value with one already kept, which
1455
+ # silently shrinks the memory below max_mem (most acute when criterion 0 is
1456
+ # a coarse value like an nsig-style count rather than BIC).
1457
+ unique_memory = get_unique_spec(init_memory, 0) # Remove duplicate specifications
1439
1458
  for sol in unique_memory:
1440
1459
  sol.data['is_initial_sol'] = True # Set solution status
1441
1460
 
@@ -1804,7 +1823,8 @@ class HarmonySearch(Search):
1804
1823
  existing_memory = self.screen_solutions(existing_sols)
1805
1824
  generated_memory = self.initialize_memory(self.max_mem)
1806
1825
  init_memory = generated_memory + existing_memory
1807
- unique_memory = get_unique(init_memory, 0)
1826
+ # De-duplicate by SPECIFICATION, not objective value (see note above).
1827
+ unique_memory = get_unique_spec(init_memory, 0)
1808
1828
  for sol in unique_memory:
1809
1829
  sol.data['is_initial_sol'] = True
1810
1830
 
@@ -21,9 +21,11 @@ import time as _time
21
21
  try:
22
22
  from .harmony import HarmonySearch
23
23
  from .sapbil import ProbabilityMatrix
24
+ from .search import get_unique_spec
24
25
  except ImportError:
25
26
  from harmony import HarmonySearch
26
27
  from sapbil import ProbabilityMatrix
28
+ from search import get_unique_spec
27
29
 
28
30
  logger = logging.getLogger(__name__)
29
31
 
@@ -339,7 +341,11 @@ class HSPBIL(HarmonySearch):
339
341
  existing_memory = self.screen_solutions(existing_sols)
340
342
  generated_memory = self.initialize_memory(self.max_mem)
341
343
  init_memory = generated_memory + existing_memory
342
- unique_memory = get_unique(init_memory, 0)
344
+ # De-duplicate by SPECIFICATION, not objective value: get_unique(init_memory, 0)
345
+ # drops every candidate sharing an obj(0) value with one already kept, which
346
+ # silently shrinks the memory below max_mem (most acute when criterion 0 is
347
+ # a coarse value like an nsig-style count rather than BIC).
348
+ unique_memory = get_unique_spec(init_memory, 0)
343
349
  for sol in unique_memory:
344
350
  sol.data['is_initial_sol'] = True
345
351
 
@@ -481,6 +481,13 @@ class BlockRandomWalkProposal:
481
481
  # ---------------------------------------------------------------------------
482
482
 
483
483
 
484
+ def _env_int(name: str, default: int) -> int:
485
+ try:
486
+ return int(str(os.environ.get(name, "")).strip())
487
+ except (TypeError, ValueError):
488
+ return default
489
+
490
+
484
491
  class HHTSCompetingPrior:
485
492
  """Dirichlet-smoothed HHTS shares blended with current utilities."""
486
493
 
@@ -496,6 +503,8 @@ class HHTSCompetingPrior:
496
503
  max_alpha: float = 0.95,
497
504
  min_temperature: float = 0.15,
498
505
  max_temperature: float = 8.0,
506
+ max_history: int | None = None,
507
+ record_history: bool = True,
499
508
  ) -> None:
500
509
  raw = shares or {}
501
510
  if raw and all(not isinstance(value, Mapping) for value in raw.values()):
@@ -512,8 +521,13 @@ class HHTSCompetingPrior:
512
521
  # adapt_to_denominator_ess runs once per CHOICE CASE per Gibbs
513
522
  # iteration, so an unbounded list here grew by n_cases x outer
514
523
  # iterations (millions of dicts on the 13k-case datasets). Only the
515
- # most recent entry is ever read, so keep a bounded window.
516
- self.history_maxlen: int = 512
524
+ # most recent entry is ever read, so keep a bounded window
525
+ # (SL_HHTS_MAX_HISTORY, default 4096; 0 disables recording).
526
+ if max_history is None:
527
+ max_history = _env_int("SL_HHTS_MAX_HISTORY", 4096)
528
+ self.max_history = max(0, int(max_history))
529
+ self.record_history = bool(record_history) and self.max_history > 0
530
+ self.history_maxlen: int = self.max_history or 1
517
531
  self.history: deque = deque(maxlen=self.history_maxlen)
518
532
 
519
533
  @classmethod
@@ -578,7 +592,8 @@ class HHTSCompetingPrior:
578
592
  self.alpha = float(np.clip(self.alpha + self.adaptation_rate * error, self.min_alpha, self.max_alpha))
579
593
  self.temperature = float(np.clip(self.temperature * np.exp(self.adaptation_rate * error),
580
594
  self.min_temperature, self.max_temperature))
581
- self.history.append({"denominator_ess": ess, "alpha": self.alpha, "temperature": self.temperature})
595
+ if self.record_history:
596
+ self.history.append({"denominator_ess": ess, "alpha": self.alpha, "temperature": self.temperature})
582
597
  return ess
583
598
 
584
599
  def sample_choice_sets(self, observations: pd.DataFrame, zone_ids: Sequence[object],
@@ -1265,17 +1280,29 @@ def _weighted_frame(frame: "ChoiceSetFrame",
1265
1280
  frame.feature_names, tuple(float(w) for w in np.asarray(weights).ravel()))
1266
1281
 
1267
1282
 
1268
- def _fit_membership_gammas(case_X, resp, l2: float = 1.0, maxiter: int = 200):
1283
+ def _fit_membership_gammas(case_X, resp, l2: float = 1.0, maxiter: int = 200,
1284
+ standardize: bool = True):
1269
1285
  """Weighted multinomial-logit M-step for the class-membership equation.
1270
1286
 
1271
1287
  ``gammas`` has shape (C-1, K+1) with class 0 as the reference and an
1272
1288
  intercept column. Rows are weighted by the EM responsibilities (soft
1273
1289
  labels), so the step maximises the expected membership log-likelihood;
1274
1290
  slopes carry an L2 penalty, intercepts do not.
1291
+
1292
+ If ``standardize=True`` (default), covariates are standardized to mean=0,
1293
+ std=1 before fitting, and coefficients are transformed back to the original
1294
+ scale for the returned ``gammas``.
1275
1295
  """
1276
1296
  from scipy.optimize import minimize
1277
1297
  N = np.asarray(case_X).shape[0]
1278
- Xa = np.column_stack([np.ones(N), np.asarray(case_X, dtype=float)])
1298
+ X = np.asarray(case_X, dtype=float)
1299
+ if standardize:
1300
+ X_mean = X.mean(axis=0)
1301
+ X_std = X.std(axis=0, ddof=0)
1302
+ X_std = np.where(X_std > 1e-12, X_std, 1.0)
1303
+ Xa = np.column_stack([np.ones(N), (X - X_mean) / X_std])
1304
+ else:
1305
+ Xa = np.column_stack([np.ones(N), X])
1279
1306
  C = np.asarray(resp).shape[1]
1280
1307
  Ka = Xa.shape[1]
1281
1308
  if C < 2:
@@ -1296,7 +1323,18 @@ def _fit_membership_gammas(case_X, resp, l2: float = 1.0, maxiter: int = 200):
1296
1323
 
1297
1324
  res = minimize(nll, g0.ravel(), method="L-BFGS-B",
1298
1325
  options={"maxiter": int(maxiter)})
1299
- return res.x.reshape(C - 1, Ka)
1326
+ G = res.x.reshape(C - 1, Ka)
1327
+ # Transform back to original scale if standardized
1328
+ if standardize:
1329
+ # G has shape (C-1, Ka) where Ka = n_features + 1 (intercept)
1330
+ # G[:, 0] are intercepts, G[:, 1:] are slopes on standardized features
1331
+ G_out = G.copy()
1332
+ G_out[:, 1:] = G[:, 1:] / X_std
1333
+ # Intercept adjustment: intercept = intercept - sum(mean_k * slope_k / std_k)
1334
+ # For each class (row), compute dot product of (mean/std) with slopes
1335
+ G_out[:, 0] = G[:, 0] - np.sum((X_mean / X_std) * G[:, 1:], axis=1)
1336
+ return G_out
1337
+ return G
1300
1338
 
1301
1339
 
1302
1340
  def _fit_frame_mnl(frame: "ChoiceSetFrame", x0, *, prior_scale: float = 5.0,
@@ -1530,7 +1568,7 @@ def estimate_dest_choice_lc(
1530
1568
  prior_scale=prior_scale, bounds=bounds)[0]
1531
1569
  for c in range(n_classes)]
1532
1570
  if case_X is not None:
1533
- gammas = _fit_membership_gammas(case_X, p, l2=membership_l2)
1571
+ gammas = _fit_membership_gammas(case_X, p, l2=membership_l2, standardize=True)
1534
1572
  shares = new_shares
1535
1573
  if share_floor and float(share_floor) > 0:
1536
1574
  _coll = int(np.argmin(shares))
@@ -60,6 +60,7 @@ from collections import UserDict
60
60
 
61
61
  from enum import Enum
62
62
  import copy
63
+ import itertools
63
64
  import numpy as np
64
65
  import pandas as pd
65
66
  import random
@@ -215,6 +216,16 @@ def process_criterions(criterions):
215
216
  # }
216
217
 
217
218
 
219
+ def _search_env_int(name, default):
220
+ ''' Read an integer tuning knob from the environment, falling back to
221
+ `default` when it is unset or not a valid integer. '''
222
+ import os
223
+ try:
224
+ return int(str(os.environ.get(name, '')).strip())
225
+ except (TypeError, ValueError):
226
+ return default
227
+
228
+
218
229
  def count_insig_groups(coeff_names, pvalues, p_val=0.05):
219
230
  """Count variable *groups* where *every* coefficient fails the p-value
220
231
  threshold. A group is the set of coefficients belonging to the same
@@ -525,6 +536,64 @@ def get_unique_tuple(solutions):
525
536
  return unique_solutions
526
537
  # }
527
538
 
539
+
540
+ def spec_key(sol):
541
+ ''' ---------------------------------------------------------- '''
542
+ ''' Hashable key identifying a solution's model *specification*. '''
543
+ ''' '''
544
+ ''' Used to de-duplicate populations by spec instead of by objective '''
545
+ ''' value. ``get_unique`` de-dupes on ``obj(key)``, which collapses '''
546
+ ''' the population whenever that objective takes only a handful of '''
547
+ ''' distinct values (an integer count such as ``nsig``, or tied '''
548
+ ''' BICs) — the initialisation loops then spin their whole attempt '''
549
+ ''' budget without ever filling the memory/population target. '''
550
+ ''' ---------------------------------------------------------- '''
551
+ try:
552
+ _get = sol.get if hasattr(sol, 'get') else (
553
+ lambda k, d=None: getattr(sol, k, d))
554
+
555
+ def _norm(v):
556
+ try:
557
+ if isinstance(v, np.ndarray):
558
+ v = v.tolist()
559
+ except Exception:
560
+ pass
561
+ if isinstance(v, dict):
562
+ return tuple(sorted((str(k), str(x)) for k, x in v.items()))
563
+ if isinstance(v, (list, tuple, set, frozenset)):
564
+ return tuple(sorted(str(x) for x in v))
565
+ return str(v)
566
+
567
+ return tuple((k, _norm(_get(k))) for k in (
568
+ 'model_n', 'asvars', 'isvars', 'randvars', 'corvars',
569
+ 'bcvars', 'transvars', 'trans_asvars', 'asc_ind',
570
+ 'class_params_spec', 'member_params_spec'))
571
+ except Exception:
572
+ try:
573
+ return ('__tuple__', tuple(sol.values()))
574
+ except Exception:
575
+ return ('__repr__', repr(sol))
576
+
577
+
578
+ def get_unique_spec(solutions, key=0):
579
+ ''' ---------------------------------------------------------- '''
580
+ ''' Like ``get_unique`` but de-duplicates by specification, not '''
581
+ ''' objective. Keeps ``get_unique``'s ordering (sorted by objective '''
582
+ ''' ``key``) so callers see the same ranking with by-spec '''
583
+ ''' duplicates removed. '''
584
+ ''' ---------------------------------------------------------- '''
585
+ if not solutions:
586
+ return []
587
+ ordered = sorted(solutions, key=lambda sol: sol.obj(key))
588
+ seen, out = set(), []
589
+ for sol in ordered:
590
+ k = spec_key(sol)
591
+ if k not in seen:
592
+ seen.add(k)
593
+ out.append(sol)
594
+ return out
595
+
596
+
528
597
  ''' ---------------------------------------------------------- '''
529
598
  ''' FUNCTION. CREATE TEST DATASET '''
530
599
  ''' ---------------------------------------------------------- '''
@@ -1497,6 +1566,18 @@ class Search():
1497
1566
  self.explored_specs = set() # Set of hashes for all explored specifications (evaluated or not)
1498
1567
  self.cache_hits = 0 # Count of how many times a solution was found in the cache
1499
1568
 
1569
+ # Cache growth bound (SEARCHLIBRIUM_CACHE_LIMIT, default 250000).
1570
+ # evaluated_solutions / explored_specs / _banlist are keyed by a
1571
+ # 64-char signature and grow by one entry per evaluated individual.
1572
+ # Over a long evolutionary search (tens of thousands of fits) that is
1573
+ # tens of MB of dead weight, and a specification-search job can then
1574
+ # trip a cgroup memory limit that the live population never came
1575
+ # close to. Cap them and drop the oldest entries under pressure.
1576
+ # Both caches are pure speed/heuristic aids: evicting an entry costs a
1577
+ # refit (or one retry of an already-failed spec), never correctness.
1578
+ self._cache_limit = _search_env_int('SEARCHLIBRIUM_CACHE_LIMIT', 250000)
1579
+ self._n_explored = 0 # monotonic count; survives cache trimming
1580
+
1500
1581
  self.last_printed_solution = None # Track the last printed solution
1501
1582
  self.best_solution = None # T
1502
1583
 
@@ -4801,12 +4882,37 @@ class Search():
4801
4882
  ''' and cache the result if it converged. '''
4802
4883
  ''' ------------------------------------------------------------------ '''
4803
4884
 
4885
+ def _trim_search_caches(self):
4886
+ ''' Drop the oldest entries from the specification caches once they
4887
+ exceed `self._cache_limit`.
4888
+
4889
+ `evaluated_solutions` is insertion-ordered, so the front is the
4890
+ oldest. `explored_specs` and `_banlist` are unordered sets whose
4891
+ only role is bookkeeping / avoiding a retry of a known-bad spec, so
4892
+ they are cleared wholesale when oversized. `self._n_explored` keeps
4893
+ the true running total for the exploration summary. '''
4894
+ limit = getattr(self, '_cache_limit', 0)
4895
+ if not limit:
4896
+ return
4897
+ if len(self.evaluated_solutions) > limit:
4898
+ # free ~25% in one pass rather than trimming on every single insert
4899
+ for _sig in list(itertools.islice(self.evaluated_solutions,
4900
+ len(self.evaluated_solutions) - limit // 4)):
4901
+ self.evaluated_solutions.pop(_sig, None)
4902
+ logging.debug('trimmed evaluated_solutions to %d (limit %d)',
4903
+ len(self.evaluated_solutions), limit)
4904
+ if len(self.explored_specs) > limit:
4905
+ self.explored_specs.clear()
4906
+ if len(self._banlist) > limit:
4907
+ self._banlist.clear()
4908
+
4804
4909
  def evaluate_solution(self, sol, track_best=True):
4805
4910
 
4806
4911
  sig = self.setup_signature(sol)
4807
4912
 
4808
4913
  if sig not in self.explored_specs:
4809
4914
  self.explored_specs.add(sig)
4915
+ self._n_explored += 1
4810
4916
 
4811
4917
  if sig in self.evaluated_solutions:
4812
4918
  self.cache_hits += 1
@@ -4819,6 +4925,7 @@ class Search():
4819
4925
 
4820
4926
  if converged:
4821
4927
  self.evaluated_solutions[sig] = [sol.obj(i) for i in range(self.nb_crit)]
4928
+ self._trim_search_caches()
4822
4929
 
4823
4930
  return sol, converged
4824
4931
 
@@ -5066,7 +5173,7 @@ class Search():
5066
5173
  served from cache instead of re-fitting the model. """
5067
5174
 
5068
5175
  sep = '═' * 60
5069
- total = len(self.explored_specs)
5176
+ total = getattr(self, '_n_explored', len(self.explored_specs))
5070
5177
  hits = self.cache_hits
5071
5178
  hit_pct = (hits / total * 100) if total else 0.0
5072
5179
 
@@ -37,7 +37,9 @@ object and call ``run()`` (which dispatches to ``run_search``).
37
37
 
38
38
  import copy
39
39
  import datetime
40
+ import gc as _gc
40
41
  import logging
42
+ import os
41
43
 
42
44
  import numpy as np
43
45
 
@@ -48,61 +50,89 @@ except ImportError: # running as a top-level module
48
50
 
49
51
  logger = logging.getLogger(__name__)
50
52
 
53
+ # ``spec_key`` / ``get_unique_spec`` (de-duplicate a population by model
54
+ # SPECIFICATION rather than by objective value) now live in search.py so every
55
+ # algorithm shares them. The originals were defined here; _agds_spec_key is kept
56
+ # as an alias for backwards compatibility.
57
+ _agds_spec_key = spec_key
58
+
51
59
 
52
60
  # ---------------------------------------------------------------------- #
53
- # Specification-based de-duplication #
61
+ # Memory reclamation #
54
62
  # ---------------------------------------------------------------------- #
55
- def _agds_spec_key(sol):
56
- """Hashable key identifying a solution's model *specification*.
57
-
58
- Used to de-duplicate populations by spec instead of by objective value.
59
- (The old ``get_unique(mem, 0)`` dropped every solution sharing an
60
- objective value; with ``nsig`` as crit 0 that collapses the whole
61
- population to a handful of distinct integers and stalls the search.)
62
- """
63
+ # A long AGDS run re-fits one model per evaluated individual - tens of
64
+ # thousands over a 500-generation search. Each fit materialises a full
65
+ # case x alternative design frame (and, under a JAX backend, a freshly
66
+ # compiled executable), so the transient peak per fit is orders of magnitude
67
+ # larger than the live population. Those objects *are* released, but glibc
68
+ # retains the freed spans in its arenas, so RSS ratchets steadily upward and a
69
+ # process holding only a few hundred MB of live data still walks into a
70
+ # cgroup memory limit and gets OOM-killed.
71
+ #
72
+ # Calling collect() + malloc_trim() once per generation returns those spans to
73
+ # the OS so RSS tracks the live set instead of the high-water mark. Every
74
+ # CLEAR_JAX_EVERY generations (and every force=True release) the JAX
75
+ # compilation cache is dropped as well: one executable per distinct spec is
76
+ # retained there beyond the reach of gc/malloc_trim (+24.3 MB/fit measured
77
+ # Oct-2026 on a 1016-trip x 233-destination larch workload, identical across
78
+ # three runs, OOM-killing two 128 GB jobs in the refit that follows the
79
+ # search). Recompile cost is bounded: only re-visited specs recompile, and
80
+ # an evolutionary search rarely revisits.
81
+ #
82
+ # SEARCHLIBRIUM_RECLAIM_EVERY=<n> reclaim every <n> generations (default 1)
83
+ # SEARCHLIBRIUM_RECLAIM=0 disable entirely
84
+ # SEARCHLIBRIUM_CLEAR_JAX=1 also drop JAX's cache on EVERY call
85
+ # SEARCHLIBRIUM_CLEAR_JAX_EVERY=<n>
86
+ # ... otherwise drop it every <n>
87
+ # generations (default 5) and on every
88
+ # force=True release
89
+
90
+ def _env_int(name, default):
63
91
  try:
64
- _get = sol.get if hasattr(sol, 'get') else (
65
- lambda k, d=None: getattr(sol, k, d))
92
+ return int(str(os.environ.get(name, "")).strip())
93
+ except (TypeError, ValueError):
94
+ return default
66
95
 
67
- def _norm(v):
68
- try:
69
- if isinstance(v, np.ndarray):
70
- v = v.tolist()
71
- except Exception:
72
- pass
73
- if isinstance(v, dict):
74
- return tuple(sorted((str(k), str(x)) for k, x in v.items()))
75
- if isinstance(v, (list, tuple, set, frozenset)):
76
- return tuple(sorted(str(x) for x in v))
77
- return str(v)
78
-
79
- return tuple((k, _norm(_get(k))) for k in (
80
- 'model_n', 'asvars', 'isvars', 'randvars', 'corvars',
81
- 'bcvars', 'transvars', 'trans_asvars', 'asc_ind',
82
- 'class_params_spec', 'member_params_spec'))
96
+
97
+ RECLAIM_EVERY = max(1, _env_int("SEARCHLIBRIUM_RECLAIM_EVERY", 1))
98
+ RECLAIM_ENABLED = str(os.environ.get("SEARCHLIBRIUM_RECLAIM", "1")).strip().lower() \
99
+ not in {"0", "false", "no", "off"}
100
+ CLEAR_JAX = str(os.environ.get("SEARCHLIBRIUM_CLEAR_JAX", "0")).strip().lower() \
101
+ in {"1", "true", "yes", "on"}
102
+ CLEAR_JAX_EVERY = max(1, _env_int("SEARCHLIBRIUM_CLEAR_JAX_EVERY", 5))
103
+
104
+
105
+ def _malloc_trim():
106
+ """Return free glibc heap arenas to the OS. No-op on non-glibc platforms."""
107
+ try:
108
+ import ctypes
109
+ libc = ctypes.CDLL("libc.so.6")
110
+ malloc_trim = libc.malloc_trim
111
+ malloc_trim.argtypes = [ctypes.c_size_t]
112
+ malloc_trim.restype = ctypes.c_int
113
+ malloc_trim(0)
83
114
  except Exception:
84
- try:
85
- return ('__tuple__', tuple(sol.values()))
86
- except Exception:
87
- return ('__repr__', repr(sol))
115
+ pass
88
116
 
89
117
 
90
- def get_unique_spec(solutions, key=0):
91
- """Like ``get_unique`` but de-duplicates by specification, not objective.
118
+ def reclaim_memory(force=False, clear_jax=False):
119
+ """Collect search-local garbage and hand freed heap back to the OS.
92
120
 
93
- Keeps ``get_unique``'s ordering (sorted by objective ``key``) so callers
94
- see the same ranking with duplicates-by-spec removed.
121
+ ``force=True`` releases (one-off phase transitions) always also drop
122
+ JAX's compiled-executable cache; in-loop callers pass ``clear_jax=True``
123
+ every ``CLEAR_JAX_EVERY`` generations; ``SEARCHLIBRIUM_CLEAR_JAX=1``
124
+ clears on every call.
95
125
  """
96
- if not solutions:
97
- return []
98
- ordered = sorted(solutions, key=lambda sol: sol.obj(key))
99
- seen, out = set(), []
100
- for sol in ordered:
101
- k = _agds_spec_key(sol)
102
- if k not in seen:
103
- seen.add(k)
104
- out.append(sol)
105
- return out
126
+ if not (force or RECLAIM_ENABLED):
127
+ return
128
+ _gc.collect()
129
+ if clear_jax or force or CLEAR_JAX:
130
+ try:
131
+ import jax
132
+ jax.clear_caches()
133
+ except Exception:
134
+ pass
135
+ _malloc_trim()
106
136
 
107
137
 
108
138
  # ---------------------------------------------------------------------- #
@@ -485,6 +515,15 @@ class SparseEAAGDS(Search):
485
515
  pf.flush()
486
516
  except Exception:
487
517
  pass
518
+
519
+ # Return the per-generation fit garbage to the OS. Without
520
+ # this RSS follows the high-water mark of the transient fit
521
+ # buffers and a long search is OOM-killed despite a small live
522
+ # set. Every CLEAR_JAX_EVERY generations also drop JAX's
523
+ # global compilation cache (see reclaim_memory).
524
+ if (gen + 1) % RECLAIM_EVERY == 0:
525
+ reclaim_memory(
526
+ clear_jax=((gen + 1) % (RECLAIM_EVERY * CLEAR_JAX_EVERY) == 0))
488
527
  finally:
489
528
  pf.close()
490
529
 
@@ -0,0 +1 @@
1
+ 0.0.260
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.253
3
+ Version: 0.0.260
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -1 +0,0 @@
1
- 0.0.253
File without changes