SearchLibrium 0.0.254__tar.gz → 0.0.261__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.254/src/SearchLibrium.egg-info → searchlibrium-0.0.261}/PKG-INFO +3 -3
  2. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/pyproject.toml +6 -3
  3. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/harmony.py +1 -1
  4. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/mh_choice_estimator.py +45 -7
  5. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/search.py +50 -1
  6. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/sparseea_agds.py +89 -0
  7. searchlibrium-0.0.261/src/SearchLibrium/version.txt +1 -0
  8. {searchlibrium-0.0.254 → searchlibrium-0.0.261/src/SearchLibrium.egg-info}/PKG-INFO +3 -3
  9. searchlibrium-0.0.254/src/SearchLibrium/version.txt +0 -1
  10. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/LICENSE +0 -0
  11. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/MANIFEST.in +0 -0
  12. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/README.md +0 -0
  13. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/setup.cfg +0 -0
  14. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/BEHier.py +0 -0
  15. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/Halton.py +0 -0
  16. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/MixedLogit.py +0 -0
  17. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  18. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  19. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/RandomP.py +0 -0
  20. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  21. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  22. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/__init__.py +0 -0
  23. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/__main__.py +0 -0
  24. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/_choice_model.py +0 -0
  25. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/_device.py +0 -0
  26. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/abm.py +0 -0
  27. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/accessibility.py +0 -0
  28. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/banditsa.py +0 -0
  29. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/boxcox_functions.py +0 -0
  30. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/call_meta.py +0 -0
  31. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/constraints_builder.py +0 -0
  32. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/coupled_solver.py +0 -0
  33. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  34. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/data/TravelMode.csv +0 -0
  35. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/data/electricity.csv +0 -0
  36. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/destination_distances.py +0 -0
  37. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/hspbil.py +0 -0
  38. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/jax_utils.py +0 -0
  39. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/larch_compat.py +0 -0
  40. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/larch_models.py +0 -0
  41. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/latent_class.py +0 -0
  42. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/latent_spec.py +0 -0
  43. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/logistic_regression.py +0 -0
  44. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/main.py +0 -0
  45. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/main_debug.py +0 -0
  46. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/mdcev.py +0 -0
  47. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/misc.py +0 -0
  48. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/mixed_logit.py +0 -0
  49. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/mixed_nested.py +0 -0
  50. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/mixedrrm.py +0 -0
  51. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/multinomial_logit.py +0 -0
  52. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/multinomial_nested.py +0 -0
  53. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/multinomial_probit.py +0 -0
  54. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/numba_engine.py +0 -0
  55. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/numba_mxl.py +0 -0
  56. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/numba_rrm.py +0 -0
  57. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/ordered_logit.py +0 -0
  58. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  59. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/predict.py +0 -0
  60. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/regularization.py +0 -0
  61. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/rrm.py +0 -0
  62. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/sample_data.py +0 -0
  63. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/sapbil.py +0 -0
  64. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/selection_models.py +0 -0
  65. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/setup.py +0 -0
  66. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/siman.py +0 -0
  67. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/skims.py +0 -0
  68. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/test_lc_de.py +0 -0
  69. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/test_mario_searches.py +0 -0
  70. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  71. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/threshold.py +0 -0
  72. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  73. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  74. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  75. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  76. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium.egg-info/requires.txt +0 -0
  77. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  78. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_destination_distances.py +0 -0
  79. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_jax_mask_equivalence.py +0 -0
  80. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_larch_integration.py +0 -0
  81. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_lc_smart_init.py +0 -0
  82. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_mh_choice_estimator.py +0 -0
  83. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_mxl_random_starts.py +0 -0
  84. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_per_alternative_random.py +0 -0
  85. {searchlibrium-0.0.254 → searchlibrium-0.0.261}/tests/test_zero_inflated_ordered_probit.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.254
3
+ Version: 0.0.261
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
- Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
- Author-email: Zeke Ahern <z.ahern@qut.edu.au>
5
+ Author: Zeke Ahern, Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
+ Maintainer-email: Zeke Ahern <z.ahern@qut.edu.au>
7
7
  License-Expression: MIT
8
8
  Project-URL: Homepage, https://pypi.org/project/SearchLibrium/
9
9
  Project-URL: Repository, https://github.com/zahern/HypothesisX
@@ -25,12 +25,15 @@ requires-python = ">=3.9"
25
25
  license = "MIT"
26
26
  license-files = ["LICENSE"]
27
27
  authors = [
28
- { name = "Zeke Ahern", email = "z.ahern@qut.edu.au" },
28
+ { name = "Zeke Ahern" },
29
29
  { name = "Mario Fernando Taco Morales" },
30
30
  { name = "Alexander Paz" },
31
31
  { name = "Prithvi Beeramole" },
32
32
  { name = "Robert Burdett" },
33
33
  ]
34
+ maintainers = [
35
+ { name = "Zeke Ahern", email = "z.ahern@qut.edu.au" },
36
+ ]
34
37
  keywords = [
35
38
  "econometrics",
36
39
  "discrete choice",
@@ -129,7 +132,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
129
132
  realpython = "SearchLibrium.__main__:main"
130
133
 
131
134
  [tool.bumpver]
132
- current_version = "0.0.254"
135
+ current_version = "0.0.261"
133
136
  version_pattern = "MAJOR.MINOR.PATCH"
134
137
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
135
138
  commit = true
@@ -137,4 +140,4 @@ tag = true
137
140
  push = false
138
141
 
139
142
  [tool.bumpver.file_patterns]
140
- "src/SearchLibrium/version.txt" = ["{version}"]
143
+ "src/SearchLibrium/version.txt" = ["{version}"]
@@ -1252,7 +1252,7 @@ class HarmonySearch(Search):
1252
1252
  # }
1253
1253
 
1254
1254
  ''' ---------------------------------------------------------- '''
1255
- ''' Function. Discard non-convergent solutions '''
1255
+ ''' Function. Discard non-convergent solutions '''
1256
1256
  ''' ---------------------------------------------------------- '''
1257
1257
  def screen_solutions(self, solutions):
1258
1258
  # {
@@ -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
@@ -1555,6 +1566,18 @@ class Search():
1555
1566
  self.explored_specs = set() # Set of hashes for all explored specifications (evaluated or not)
1556
1567
  self.cache_hits = 0 # Count of how many times a solution was found in the cache
1557
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
+
1558
1581
  self.last_printed_solution = None # Track the last printed solution
1559
1582
  self.best_solution = None # T
1560
1583
 
@@ -4859,12 +4882,37 @@ class Search():
4859
4882
  ''' and cache the result if it converged. '''
4860
4883
  ''' ------------------------------------------------------------------ '''
4861
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
+
4862
4909
  def evaluate_solution(self, sol, track_best=True):
4863
4910
 
4864
4911
  sig = self.setup_signature(sol)
4865
4912
 
4866
4913
  if sig not in self.explored_specs:
4867
4914
  self.explored_specs.add(sig)
4915
+ self._n_explored += 1
4868
4916
 
4869
4917
  if sig in self.evaluated_solutions:
4870
4918
  self.cache_hits += 1
@@ -4877,6 +4925,7 @@ class Search():
4877
4925
 
4878
4926
  if converged:
4879
4927
  self.evaluated_solutions[sig] = [sol.obj(i) for i in range(self.nb_crit)]
4928
+ self._trim_search_caches()
4880
4929
 
4881
4930
  return sol, converged
4882
4931
 
@@ -5124,7 +5173,7 @@ class Search():
5124
5173
  served from cache instead of re-fitting the model. """
5125
5174
 
5126
5175
  sep = '═' * 60
5127
- total = len(self.explored_specs)
5176
+ total = getattr(self, '_n_explored', len(self.explored_specs))
5128
5177
  hits = self.cache_hits
5129
5178
  hit_pct = (hits / total * 100) if total else 0.0
5130
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
 
@@ -55,6 +57,84 @@ logger = logging.getLogger(__name__)
55
57
  _agds_spec_key = spec_key
56
58
 
57
59
 
60
+ # ---------------------------------------------------------------------- #
61
+ # Memory reclamation #
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):
91
+ try:
92
+ return int(str(os.environ.get(name, "")).strip())
93
+ except (TypeError, ValueError):
94
+ return default
95
+
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)
114
+ except Exception:
115
+ pass
116
+
117
+
118
+ def reclaim_memory(force=False, clear_jax=False):
119
+ """Collect search-local garbage and hand freed heap back to the OS.
120
+
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.
125
+ """
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()
136
+
137
+
58
138
  # ---------------------------------------------------------------------- #
59
139
  # NSGA-III reference-point helpers (numpy, self-contained) #
60
140
  # ---------------------------------------------------------------------- #
@@ -435,6 +515,15 @@ class SparseEAAGDS(Search):
435
515
  pf.flush()
436
516
  except Exception:
437
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))
438
527
  finally:
439
528
  pf.close()
440
529
 
@@ -0,0 +1 @@
1
+ 0.0.261
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.254
3
+ Version: 0.0.261
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
- Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
- Author-email: Zeke Ahern <z.ahern@qut.edu.au>
5
+ Author: Zeke Ahern, Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
+ Maintainer-email: Zeke Ahern <z.ahern@qut.edu.au>
7
7
  License-Expression: MIT
8
8
  Project-URL: Homepage, https://pypi.org/project/SearchLibrium/
9
9
  Project-URL: Repository, https://github.com/zahern/HypothesisX
@@ -1 +0,0 @@
1
- 0.0.254
File without changes