SearchLibrium 0.0.261__tar.gz → 0.0.263__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.261/src/SearchLibrium.egg-info → searchlibrium-0.0.263}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/Halton.py +8 -4
  4. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/MixedLogit.py +291 -27
  5. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/destination_distances.py +34 -11
  6. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/latent_class.py +21 -4
  7. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/mixed_logit.py +10 -1
  8. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/multinomial_logit.py +4 -1
  9. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/ordered_logit.py +17 -1
  10. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/test_lc_de.py +1 -0
  11. searchlibrium-0.0.263/src/SearchLibrium/version.txt +1 -0
  12. {searchlibrium-0.0.261 → searchlibrium-0.0.263/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  13. searchlibrium-0.0.261/src/SearchLibrium/version.txt +0 -1
  14. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/LICENSE +0 -0
  15. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/MANIFEST.in +0 -0
  16. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/README.md +0 -0
  17. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/setup.cfg +0 -0
  18. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/BEHier.py +0 -0
  19. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  20. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  21. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/RandomP.py +0 -0
  22. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  23. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  24. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/__init__.py +0 -0
  25. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/__main__.py +0 -0
  26. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/_choice_model.py +0 -0
  27. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/_device.py +0 -0
  28. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/abm.py +0 -0
  29. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/accessibility.py +0 -0
  30. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/banditsa.py +0 -0
  31. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/boxcox_functions.py +0 -0
  32. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/call_meta.py +0 -0
  33. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/constraints_builder.py +0 -0
  34. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/coupled_solver.py +0 -0
  35. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  36. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/data/TravelMode.csv +0 -0
  37. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/data/electricity.csv +0 -0
  38. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/harmony.py +0 -0
  39. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/hspbil.py +0 -0
  40. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/jax_utils.py +0 -0
  41. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/larch_compat.py +0 -0
  42. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/larch_models.py +0 -0
  43. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/latent_spec.py +0 -0
  44. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/logistic_regression.py +0 -0
  45. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/main.py +0 -0
  46. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/main_debug.py +0 -0
  47. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/mdcev.py +0 -0
  48. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/mh_choice_estimator.py +0 -0
  49. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/misc.py +0 -0
  50. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/mixed_nested.py +0 -0
  51. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/mixedrrm.py +0 -0
  52. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/multinomial_nested.py +0 -0
  53. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/multinomial_probit.py +0 -0
  54. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/numba_engine.py +0 -0
  55. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/numba_mxl.py +0 -0
  56. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/numba_rrm.py +0 -0
  57. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  58. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/predict.py +0 -0
  59. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/regularization.py +0 -0
  60. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/rrm.py +0 -0
  61. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/sample_data.py +0 -0
  62. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/sapbil.py +0 -0
  63. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/search.py +0 -0
  64. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/selection_models.py +0 -0
  65. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/setup.py +0 -0
  66. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/siman.py +0 -0
  67. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/skims.py +0 -0
  68. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/sparseea_agds.py +0 -0
  69. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/test_mario_searches.py +0 -0
  70. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  71. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/threshold.py +0 -0
  72. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  73. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  74. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  75. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  76. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/requires.txt +0 -0
  77. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  78. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_destination_distances.py +0 -0
  79. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_jax_mask_equivalence.py +0 -0
  80. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_larch_integration.py +0 -0
  81. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_lc_smart_init.py +0 -0
  82. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_mh_choice_estimator.py +0 -0
  83. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_mxl_random_starts.py +0 -0
  84. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/tests/test_per_alternative_random.py +0 -0
  85. {searchlibrium-0.0.261 → searchlibrium-0.0.263}/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.261
3
+ Version: 0.0.263
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
5
  Author: Zeke Ahern, Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Maintainer-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -132,7 +132,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
132
132
  realpython = "SearchLibrium.__main__:main"
133
133
 
134
134
  [tool.bumpver]
135
- current_version = "0.0.261"
135
+ current_version = "0.0.263"
136
136
  version_pattern = "MAJOR.MINOR.PATCH"
137
137
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
138
138
  commit = true
@@ -56,12 +56,14 @@ class Halton:
56
56
  Set use_sobol=True to use scrambled Sobol sequences instead.
57
57
  """
58
58
 
59
- def __init__(self, primes=None, drop=100, shuffled=False, antithetic=False, use_sobol=False):
59
+ def __init__(self, primes=None, drop=100, shuffled=False,
60
+ antithetic=False, use_sobol=False, seed=None):
60
61
  self.primes = primes
61
62
  self.drop = drop
62
63
  self.shuffled = shuffled
63
64
  self.antithetic = antithetic
64
65
  self.use_sobol = use_sobol
66
+ self.seed = seed
65
67
 
66
68
  # Default primes for Halton sequence
67
69
  if self.primes is None:
@@ -81,7 +83,9 @@ class Halton:
81
83
  if self.use_sobol:
82
84
  # Use Sobol sequence (newer method)
83
85
  base = n_draws // 2 if self.antithetic else n_draws
84
- draws = _sobol_generate(sample_size, base, n_vars, shuffled=self.shuffled)
86
+ draws = _sobol_generate(
87
+ sample_size, base, n_vars, shuffled=self.shuffled,
88
+ seed=self.seed)
85
89
  if self.antithetic:
86
90
  draws = np.concatenate([draws, 1.0 - draws], axis=2)
87
91
  else:
@@ -110,7 +114,7 @@ class Halton:
110
114
  return draws
111
115
 
112
116
 
113
- def _sobol_generate(sample_size, n_draws, n_vars, shuffled=False):
117
+ def _sobol_generate(sample_size, n_draws, n_vars, shuffled=False, seed=None):
114
118
  """Generate scrambled Sobol (0,1) draws.
115
119
 
116
120
  Returns
@@ -123,7 +127,7 @@ def _sobol_generate(sample_size, n_draws, n_vars, shuffled=False):
123
127
  p2 = 1
124
128
  while p2 < total:
125
129
  p2 <<= 1
126
- sobol = Sobol(d=n_vars, scramble=True)
130
+ sobol = Sobol(d=n_vars, scramble=True, seed=seed)
127
131
  flat = sobol.random(p2)[:total] # (total, n_vars)
128
132
  draws = flat.reshape(sample_size, n_draws, n_vars).transpose(0, 2, 1)
129
133
  else:
@@ -122,10 +122,19 @@ def _panel_weighted_shares(prob_np, panel_info):
122
122
  return ind, np.mean(ind, axis=0)
123
123
 
124
124
  class MixedLogit(DiscreteChoiceModel):
125
- def __init__(self, halton_opts=None, distributions=['n', 'ln', 'nln', 't', 'tn', 'u'], _jax=None):
125
+ def __init__(self, halton_opts=None, distributions=['n', 'ln', 'nln', 't', 'tn', 'u'], _jax=None,
126
+ save_prob_full=None):
126
127
  super().__init__(_jax)
127
128
  self.descr = "MXL"
128
129
  self.halton_opts = halton_opts
130
+ # MEMORY: prob_full is the full (N, P, J, R) probability tensor built in
131
+ # post-process for the summary/plots. For a 13k-case/20-alt/1000-draw
132
+ # model that is ~2 GB, and it is never read during estimation, so it is
133
+ # now opt-in. Pass save_prob_full=True (or set
134
+ # SEARCHLIBRIUM_SAVE_PROB_FULL=1) to retain the previous behaviour.
135
+ if save_prob_full is None:
136
+ save_prob_full = bool(os.environ.get("SEARCHLIBRIUM_SAVE_PROB_FULL", ""))
137
+ self.save_prob_full = bool(save_prob_full)
129
138
  self.draws_generator = Draws(k=len(distributions), halton_opts=halton_opts, rvdist=distributions)
130
139
  self.random_parameters = RandomParameters(distributions or [])
131
140
  self.softmax_r = True
@@ -225,7 +234,8 @@ class MixedLogit(DiscreteChoiceModel):
225
234
  randvars=None, panels=None, base_alt=None, fit_intercept=False,
226
235
  init_coeff=None, maxiter=1000, correlated_vars=None,
227
236
  n_draws=1000, halton=True, minimise_func=None,
228
- batch_size=None, halton_opts=None, ftol=1e-12,
237
+ batch_size=None, person_batch_size=None, halton_opts=None,
238
+ draw_seed=0, ftol=1e-12,
229
239
  gtol=1e-6, return_hess=True, return_grad=True, method="slsqp",
230
240
  save_fitted_params=True, mnl_init=True,
231
241
  de_init=False, de_popsize=4, de_maxiter=3, de_tol=0.5,
@@ -301,7 +311,15 @@ class MixedLogit(DiscreteChoiceModel):
301
311
  self.jac = self.return_grad # scipy optimize parameter
302
312
  self.n_draws = n_draws
303
313
  self.batch_size = min(n_draws, batch_size) if batch_size is not None else n_draws
304
- self.halton_opts = halton_opts # Store halton options for draw generation
314
+ if person_batch_size is None:
315
+ self.person_batch_size = None
316
+ else:
317
+ self.person_batch_size = max(1, int(person_batch_size))
318
+ self._person_batch_active = False
319
+ self.draw_seed = int(draw_seed)
320
+ self.halton_opts = dict(halton_opts or {})
321
+ if self.person_batch_size is not None:
322
+ self.halton_opts.setdefault("seed", self.draw_seed)
305
323
 
306
324
  # CRITICAL FIX: Recreate draws_generator with proper halton_opts AND rvdist
307
325
  # The __init__ creates it with halton_opts=None, but setup() gets the actual options
@@ -309,7 +327,8 @@ class MixedLogit(DiscreteChoiceModel):
309
327
  # AND to pass the correct random variable distributions (e.g., 'ln' for lognormal)
310
328
  # IMPORTANT: Order rvdist by varnames order, not dictionary order
311
329
  rvdist = [randvars[var] for var in varnames if var in randvars] if randvars else []
312
- self.draws_generator = Draws(k=len(randvars or {}), halton_opts=halton_opts, rvdist=rvdist)
330
+ self.draws_generator = Draws(
331
+ k=len(randvars or {}), halton_opts=self.halton_opts, rvdist=rvdist)
313
332
 
314
333
  self.randvarsdict = randvars # random variables not transformed
315
334
 
@@ -812,7 +831,16 @@ class MixedLogit(DiscreteChoiceModel):
812
831
  def fit(self):
813
832
  # {
814
833
  # Generate draws:
815
- draws, drawstrans = self.generate_draws(self.N, self.n_draws, self.halton)
834
+ streaming_people = (
835
+ self.person_batch_size is not None
836
+ and self.person_batch_size < self.N
837
+ )
838
+ if streaming_people:
839
+ # Person chunks regenerate deterministic draws during each
840
+ # objective evaluation, avoiding an (N, Kr, R) draw bank.
841
+ draws, drawstrans = None, None
842
+ else:
843
+ draws, drawstrans = self.generate_draws(self.N, self.n_draws, self.halton)
816
844
  self.draws, self.drawstrans = draws, drawstrans # Record generated values
817
845
  # Explicit caller-supplied starts are respected verbatim (no ASC
818
846
  # share seeding); the MNL block below may still overwrite
@@ -829,9 +857,15 @@ class MixedLogit(DiscreteChoiceModel):
829
857
  from numba_engine import effective_engine as _mx_eff_eng
830
858
  except ImportError:
831
859
  from .numba_engine import effective_engine as _mx_eff_eng
832
- use_numba = (_mx_eff_eng(self) == 'numba')
860
+ # Numba is the default CPU engine. JAX must be selected
861
+ # explicitly; unsupported specifications fall back to SciPy.
862
+ use_numba = (_mx_eff_eng(self) != 'jax')
833
863
  except Exception:
834
- use_numba = (getattr(self, 'engine', None) == 'numba')
864
+ use_numba = (getattr(self, 'engine', None) != 'jax')
865
+ if streaming_people:
866
+ # The streaming path regenerates person-chunk draws and is not
867
+ # compatible with the current full-array Numba adapter.
868
+ use_numba = False
835
869
 
836
870
  # 2x Kftrans - mean and lambda, 3x Krtrans - mean, s.d., lambda
837
871
  # Kchol, Kbw - relate to random variables, non-transformed
@@ -1052,11 +1086,12 @@ class MixedLogit(DiscreteChoiceModel):
1052
1086
  if _v: print(f"[MXL] Differential evolution initialization failed: {e}")
1053
1087
 
1054
1088
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
1055
- if dev.using_gpu: # {
1089
+ if dev.using_gpu and draws is not None: # {
1056
1090
  self.X, self.y = dev.convert_array_gpu(self.X), dev.convert_array_gpu(self.y)
1057
1091
  self.panel_info = dev.convert_array_gpu(self.panel_info)
1058
1092
  draws = dev.convert_array_gpu(draws)
1059
- drawstrans = dev.convert_array_gpu(drawstrans)
1093
+ if drawstrans is not None:
1094
+ drawstrans = dev.convert_array_gpu(drawstrans)
1060
1095
  self.weights = dev.convert_array_gpu(self.weights) if self.weights is not None else None
1061
1096
  self.avail = dev.convert_array_gpu(self.avail) if self.avail is not None else None
1062
1097
  # }
@@ -1104,7 +1139,8 @@ class MixedLogit(DiscreteChoiceModel):
1104
1139
  # SOLVE OPTIMISATION PROBLEM - COMPUTATIONALLY TIME CONSUMING!
1105
1140
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1106
1141
  # ---- JAX fast path ----
1107
- if (getattr(self, '_jax', False) and not use_numba
1142
+ if (getattr(self, '_jax', False) and not streaming_people
1143
+ and not use_numba
1108
1144
  and self.minimise_func is None):
1109
1145
  jax_result = self.optimize_jax(betas, draws, drawstrans)
1110
1146
  if jax_result is not None:
@@ -1173,7 +1209,8 @@ class MixedLogit(DiscreteChoiceModel):
1173
1209
  self.choice_pred_prob = np.mean(p, axis=3)
1174
1210
  self.ind_pred_prob, self.pred_prob = _panel_weighted_shares(
1175
1211
  self.choice_pred_prob, self.panel_info)
1176
- self.prob_full = p
1212
+ if self.save_prob_full:
1213
+ self.prob_full = p
1177
1214
  self._refresh_corr_from_varlist()
1178
1215
  self.post_process(jax_result, self.Xnames, self.N)
1179
1216
  return
@@ -1234,6 +1271,16 @@ class MixedLogit(DiscreteChoiceModel):
1234
1271
  except Exception:
1235
1272
  pass
1236
1273
 
1274
+ if draws is None:
1275
+ # Estimation stayed streamed; materialize full draws only for the
1276
+ # final fitted-parameter and prediction summaries.
1277
+ draws, drawstrans = self.generate_draws(
1278
+ self.N, self.n_draws, self.halton)
1279
+ self.draws, self.drawstrans = draws, drawstrans
1280
+ self.p = self.compute_probabilities(
1281
+ result['x'], self.X, self.panel_info, draws, drawstrans,
1282
+ self.avail, self.var_list, self.chol_mat)
1283
+
1237
1284
  if self.save_fitted_params:
1238
1285
  self.compute_fitted_params(self.y, self.p, self.panel_info, self.Br)
1239
1286
 
@@ -1251,7 +1298,8 @@ class MixedLogit(DiscreteChoiceModel):
1251
1298
  self.ind_pred_prob, self.pred_prob = _panel_weighted_shares(
1252
1299
  self.choice_pred_prob, self.panel_info)
1253
1300
 
1254
- self.prob_full = p
1301
+ if self.save_prob_full:
1302
+ self.prob_full = p
1255
1303
  # }
1256
1304
  self._refresh_corr_from_varlist()
1257
1305
  self.post_process(result, self.Xnames, self.N)
@@ -1413,7 +1461,8 @@ class MixedLogit(DiscreteChoiceModel):
1413
1461
 
1414
1462
  sim_p = jnp.mean(pch, axis=1) # (N,)
1415
1463
  sim_p = jnp.clip(sim_p, 1e-300, None)
1416
- ll = -jnp.sum(jnp.log(sim_p))
1464
+ valid_person = jnp.any(panel_info_jax > 0, axis=1)
1465
+ ll = -jnp.sum(jnp.where(valid_person, jnp.log(sim_p), 0.0))
1417
1466
  # Tiny L2 penalty — makes the Hessian diagonally dominant so it
1418
1467
  # never becomes singular, even with near-constant variables or
1419
1468
  # simulation noise in the mixed-logit probability integration.
@@ -1499,7 +1548,11 @@ class MixedLogit(DiscreteChoiceModel):
1499
1548
  # process; during a heterogeneous GA search that is one retained model
1500
1549
  # per distinct parameter-count combination.
1501
1550
  # ------------------------------------------------------------------
1502
- _JIT_CACHE_MAXSIZE = 16
1551
+ # MEMORY: cache size is in *live models*, each of which can pin its X/y
1552
+ # arrays and a compiled XLA executable. The old default of 16 was tuned for
1553
+ # speed; 4 keeps most of the reuse benefit while capping the worst case.
1554
+ # Override with SEARCHLIBRIUM_JIT_CACHE_MAXSIZE (0 disables the cache).
1555
+ _JIT_CACHE_MAXSIZE = max(0, int(os.environ.get("SEARCHLIBRIUM_JIT_CACHE_MAXSIZE", "4")))
1503
1556
  _mxl_jit_cache: "OrderedDict[tuple, tuple]" = OrderedDict()
1504
1557
 
1505
1558
  @staticmethod
@@ -1562,6 +1615,17 @@ class MixedLogit(DiscreteChoiceModel):
1562
1615
  pass
1563
1616
  gc.collect()
1564
1617
 
1618
+ def release_memory(self):
1619
+ """Free per-fit caches and large post-process tensors this model holds.
1620
+
1621
+ Safe to call between metaheuristic candidates: it clears the process-
1622
+ wide JIT/XLA cache and drops ``prob_full`` unless it was explicitly
1623
+ saved. Fitted coefficients, standard errors and covariance are kept.
1624
+ """
1625
+ if not getattr(self, "save_prob_full", False):
1626
+ self.prob_full = None
1627
+ type(self)._clear_jit_cache()
1628
+
1565
1629
  def _run_jaxopt(self, betas, X_jax, y_jax, pi_jax, draws_jax,
1566
1630
  _fn, _cache_key):
1567
1631
  """JAX likelihood + ``jaxopt.LBFGS`` solver (experiment).
@@ -1640,6 +1704,125 @@ class MixedLogit(DiscreteChoiceModel):
1640
1704
  print(f"[JAX MXL optimizer] jaxopt run failed ({_e}).")
1641
1705
  return None
1642
1706
 
1707
+ def _optimize_jax_streamed(self, betas):
1708
+ """Fit plain MXL with bounded person chunks and deterministic draws."""
1709
+ try:
1710
+ import os
1711
+ import jax
1712
+ import jax.numpy as jnp
1713
+ import numpy as _np
1714
+ from scipy.optimize import minimize as sp_min
1715
+
1716
+ chunk_size = int(self.person_batch_size)
1717
+ X_np = _np.asarray(self.X)
1718
+ y_np = _np.asarray(self.y)
1719
+ if y_np.ndim > 3:
1720
+ y_np = y_np[..., 0]
1721
+ pi_np = _np.asarray(self.panel_info)
1722
+ N, P, J, _ = X_np.shape
1723
+ Kf, Kr = int(self.Kf), int(self.Kr)
1724
+ Kchol, Kbw = int(self.Kchol), int(self.Kbw)
1725
+ fxidx = jnp.asarray(self.fxidx, dtype=bool)
1726
+ rvidx = jnp.asarray(self.rvidx, dtype=bool)
1727
+ rvdist_names = [d for d in self.rvdist if d is not False]
1728
+ extra = self._jax_negloglik_extra_kwargs()
1729
+
1730
+ def _pad(value, shape):
1731
+ result = _np.zeros(shape, dtype=_np.float64)
1732
+ result[tuple(slice(0, n) for n in value.shape)] = value
1733
+ return result
1734
+
1735
+ def _chunk_inputs(chunk_index):
1736
+ start = chunk_index * chunk_size
1737
+ end = min(start + chunk_size, N)
1738
+ n = end - start
1739
+ X_chunk = _pad(X_np[start:end], (chunk_size, P, J, X_np.shape[3]))
1740
+ y_chunk = _pad(y_np[start:end], (chunk_size, P, J))
1741
+ pi_chunk = _pad(pi_np[start:end], (chunk_size, P))
1742
+
1743
+ state = _np.random.get_state()
1744
+ try:
1745
+ _np.random.seed(self.draw_seed + chunk_index)
1746
+ if self.halton and hasattr(self.draws_generator, "halton"):
1747
+ self.draws_generator.halton.seed = (
1748
+ self.draw_seed + chunk_index)
1749
+ draws_chunk, _ = self.generate_draws(
1750
+ n, self.n_draws, self.halton)
1751
+ finally:
1752
+ _np.random.set_state(state)
1753
+ draws_chunk = _pad(
1754
+ _np.asarray(draws_chunk),
1755
+ (chunk_size, int(self.Kr), self.n_draws))
1756
+ return (
1757
+ jnp.asarray(X_chunk),
1758
+ jnp.asarray(y_chunk),
1759
+ jnp.asarray(pi_chunk),
1760
+ jnp.asarray(draws_chunk),
1761
+ )
1762
+
1763
+ def _chunk_objective(b, X_j, y_j, pi_j, draws_j):
1764
+ return self._jax_mxl_negloglik(
1765
+ b, X_j, y_j, pi_j, draws_j,
1766
+ fxidx, rvidx, Kf, Kr, Kchol, Kbw,
1767
+ rvdist_names, int(self.correlationLength),
1768
+ **extra)
1769
+
1770
+ compiled = jax.jit(jax.value_and_grad(_chunk_objective))
1771
+ chunk_args = [
1772
+ _chunk_inputs(i)
1773
+ for i in range((N + chunk_size - 1) // chunk_size)
1774
+ ]
1775
+ valid_counts = [
1776
+ min(chunk_size, N - i * chunk_size)
1777
+ for i in range(len(chunk_args))
1778
+ ]
1779
+
1780
+ def _objective(betas_np):
1781
+ b = jnp.asarray(betas_np, dtype=jnp.float64)
1782
+ value = 0.0
1783
+ gradient = jnp.zeros_like(b)
1784
+ for args, valid in zip(chunk_args, valid_counts):
1785
+ chunk_value, chunk_gradient = compiled(b, *args)
1786
+ # Padded people contribute zero because their panel mask
1787
+ # is zero; valid is retained for clarity and future
1788
+ # weighted-panel support.
1789
+ del valid
1790
+ value = value + chunk_value
1791
+ gradient = gradient + chunk_gradient
1792
+ value, gradient = jax.block_until_ready((value, gradient))
1793
+ return float(value), _np.asarray(gradient, dtype=_np.float64)
1794
+
1795
+ options = {'maxiter': self.maxiter, 'disp': False}
1796
+ if self.method in ('bfgs', 'l-bfgs-b'):
1797
+ options['gtol'] = self.gtol
1798
+ options['ftol'] = self.ftol
1799
+ else:
1800
+ options['ftol'] = self.ftol
1801
+ result = sp_min(
1802
+ _objective, betas, jac=True, method=self.method,
1803
+ options=options)
1804
+
1805
+ if self.return_hess:
1806
+ def _hessian_objective(b):
1807
+ value = 0.0
1808
+ for args in chunk_args:
1809
+ value = value + _chunk_objective(b, *args)
1810
+ return value
1811
+
1812
+ hessian_fn = jax.jit(jax.hessian(_hessian_objective))
1813
+ hessian = _np.asarray(
1814
+ hessian_fn(jnp.asarray(result['x'], dtype=jnp.float64)),
1815
+ dtype=float)
1816
+ try:
1817
+ result['hess_inv'] = _np.linalg.inv(hessian)
1818
+ except _np.linalg.LinAlgError:
1819
+ result['hess_inv'] = _np.linalg.pinv(hessian)
1820
+ return result
1821
+ except Exception as exc:
1822
+ if not _SL_QUIET:
1823
+ print(f"[JAX streamed MXL] falling back to scipy: {exc}")
1824
+ return None
1825
+
1643
1826
  def optimize_jax(self, betas, draws, drawstrans):
1644
1827
  """JAX-accelerated optimisation for the Mixed Logit model.
1645
1828
 
@@ -1655,6 +1838,10 @@ class MixedLogit(DiscreteChoiceModel):
1655
1838
  print("[JAX MXL optimizer] heterogeneity detected; falling back to scipy.")
1656
1839
  return None
1657
1840
 
1841
+ if (self.person_batch_size is not None
1842
+ and self.person_batch_size < self.N):
1843
+ return self._optimize_jax_streamed(betas)
1844
+
1658
1845
  try:
1659
1846
  import os
1660
1847
  os.environ.setdefault("JAX_ENABLE_X64", "True")
@@ -1914,12 +2101,88 @@ class MixedLogit(DiscreteChoiceModel):
1914
2101
  print(f"[JAX MXL masked] falling back: {e}")
1915
2102
  return None
1916
2103
 
2104
+ def _person_batch_slice(self, value, start, end):
2105
+ """Slice an observation-level array while preserving scalar settings."""
2106
+ if value is None:
2107
+ return None
2108
+ try:
2109
+ if np.ndim(value) > 0 and np.shape(value)[0] == self.N:
2110
+ return value[start:end]
2111
+ except (TypeError, IndexError):
2112
+ pass
2113
+ return value
2114
+
2115
+ def _stream_person_batches(self, betas, X, y, panel_info, draws,
2116
+ drawstrans, weights, avail, batch_size):
2117
+ """Evaluate the objective in bounded person chunks.
2118
+
2119
+ When ``draws`` is absent, each chunk receives deterministic random
2120
+ draws derived from ``draw_seed`` and its chunk index. This keeps every
2121
+ optimizer evaluation reproducible without retaining all draws.
2122
+ """
2123
+ original_n = self.N
2124
+ total_value = 0.0
2125
+ total_gradient = None
2126
+ has_gradient = bool(self.return_grad)
2127
+ try:
2128
+ self._person_batch_active = True
2129
+ for chunk_index, start in enumerate(
2130
+ range(0, original_n, self.person_batch_size)):
2131
+ end = min(start + self.person_batch_size, original_n)
2132
+ self.N = end - start
2133
+ chunk_X = X[start:end]
2134
+ chunk_y = y[start:end]
2135
+ chunk_panel = panel_info[start:end]
2136
+ chunk_weights = self._person_batch_slice(weights, start, end)
2137
+ chunk_avail = self._person_batch_slice(avail, start, end)
2138
+
2139
+ if draws is None:
2140
+ rng_state = np.random.get_state()
2141
+ try:
2142
+ np.random.seed(self.draw_seed + chunk_index)
2143
+ if self.halton and hasattr(self.draws_generator, "halton"):
2144
+ self.draws_generator.halton.seed = (
2145
+ self.draw_seed + chunk_index)
2146
+ chunk_draws, chunk_drawstrans = self.generate_draws(
2147
+ self.N, self.n_draws, self.halton)
2148
+ finally:
2149
+ np.random.set_state(rng_state)
2150
+ else:
2151
+ chunk_draws = draws[start:end]
2152
+ chunk_drawstrans = (
2153
+ drawstrans[start:end] if drawstrans is not None
2154
+ else drawstrans
2155
+ )
2156
+
2157
+ result = self.get_loglik_gradient(
2158
+ betas, chunk_X, chunk_y, chunk_panel,
2159
+ chunk_draws, chunk_drawstrans, chunk_weights,
2160
+ chunk_avail, batch_size)
2161
+ total_value += float(result[0])
2162
+ if has_gradient and len(result) > 1:
2163
+ if total_gradient is None:
2164
+ total_gradient = np.zeros_like(result[1], dtype=float)
2165
+ total_gradient += np.asarray(result[1], dtype=float)
2166
+ finally:
2167
+ self.N = original_n
2168
+ self._person_batch_active = False
2169
+
2170
+ if has_gradient:
2171
+ return total_value, total_gradient
2172
+ return (total_value,)
2173
+
1917
2174
  def get_loglik_gradient(self, betas, X, y, panel_info, draws,
1918
2175
  drawstrans, weights, avail, batch_size):
1919
2176
  # {
1920
2177
  """ Fixed and random parameters are handled separately to
1921
2178
  speed up the estimation and the results are concatenated.
1922
2179
  """
2180
+ if (not self._person_batch_active
2181
+ and self.person_batch_size is not None
2182
+ and self.person_batch_size < self.N):
2183
+ return self._stream_person_batches(
2184
+ betas, X, y, panel_info, draws, drawstrans,
2185
+ weights, avail, batch_size)
1923
2186
  # Segregating initial values to fixed betas (Bf),
1924
2187
  # random beta means (Br_b)
1925
2188
  # for both non-transformed and transformed variables
@@ -1999,11 +2262,10 @@ class MixedLogit(DiscreteChoiceModel):
1999
2262
 
2000
2263
  # _, gr_b, gr_w, pch = np.zeros((N, self.Kf)), np.zeros((N, self.Kr)), np.zeros((N, self.Kr)), [] # Batch data
2001
2264
 
2002
- _ = np.zeros((N, self.Kf)) # CONCEPTUAL ERROR. WHY CREATE IF NO NAME?
2003
2265
  gr_b = np.zeros((N, self.Kr))
2004
2266
  gr_w = np.zeros((N, self.Kr))
2005
2267
  g_all = np.zeros((N, len(betas)))
2006
- pch = [] # Batch data
2268
+ lik_sum = np.zeros(N, dtype=float)
2007
2269
  for batch in range(n_batches):
2008
2270
  # {
2009
2271
  a = batch * batch_size
@@ -2025,6 +2287,8 @@ class MixedLogit(DiscreteChoiceModel):
2025
2287
 
2026
2288
  # Thresholds to avoid divide by zero warnings
2027
2289
  pch_batch = truncate_lower(pch_batch, min_comp_val)
2290
+ draw_count = b - a
2291
+ lik_sum += np.sum(pch_batch, axis=1)
2028
2292
 
2029
2293
  # Observed probability minus predicted probability
2030
2294
  ymp = y - p # (N, P, J, R)
@@ -2263,12 +2527,10 @@ class MixedLogit(DiscreteChoiceModel):
2263
2527
  g, pch_batch = dev.to_cpu(g), dev.to_cpu(pch_batch)
2264
2528
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2265
2529
 
2266
- pch.append(pch_batch)
2267
- g_all += g
2530
+ g_all += g * draw_count
2268
2531
  # }
2269
2532
 
2270
- pch = np.concatenate(pch, axis=-1)
2271
- lik = pch.mean(axis=1) # (N,)
2533
+ lik = lik_sum / float(self.n_draws) # (N,)
2272
2534
  loglik = np.log(lik)
2273
2535
  if weights is not None: loglik = loglik * weights
2274
2536
  loglik = loglik.sum()
@@ -2296,7 +2558,7 @@ class MixedLogit(DiscreteChoiceModel):
2296
2558
  g *= weights[:, 0][:, None] # Use only the first column and convert to column vector
2297
2559
  # }
2298
2560
 
2299
- g = np.sum(g, axis=0) / n_batches # (K, )
2561
+ g = np.sum(g_all, axis=0) / float(self.n_draws) # (K, )
2300
2562
  g = g - self.regularize_l1_grad(betas)
2301
2563
  # L2 ridge penalty gradient: the objective subtracts
2302
2564
  # reg_penalty * sum(betas^2) from the log-likelihood, so its
@@ -2645,14 +2907,10 @@ class MixedLogit(DiscreteChoiceModel):
2645
2907
 
2646
2908
  # INITIALISE
2647
2909
  XBf = np.zeros((self.N, self.P, self.J))
2648
- XBr = np.zeros((self.N, self.P, self.J, self.batch_size)) # NOT USED?
2649
- V = np.zeros((self.N, self.P, self.J, self.batch_size)) # NOT USED?
2650
2910
 
2651
2911
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2652
2912
  if dev.using_gpu: # {
2653
2913
  XBf = dev.convert_array_gpu(XBf)
2654
- XBr = dev.convert_array_gpu(XBr)
2655
- V = dev.convert_array_gpu(V)
2656
2914
  # }
2657
2915
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2658
2916
 
@@ -2692,10 +2950,16 @@ class MixedLogit(DiscreteChoiceModel):
2692
2950
  Br = self.draws_generator.apply_distribution(Br, self.rvdist)
2693
2951
  self.Br = Br # save Br to use later
2694
2952
  Xr = X[:, :, :, self.rvidx]
2695
- XBr = dev.cust_einsum('npjk,nkr -> npjr', Xr, Br).astype(float) # (N, P, J, R)
2696
- V = XBf[:, :, :, None] + XBr # Add an extra dimension to XBf and then add XBr
2953
+ # Build the utility directly. Keeping a separate XBr and a
2954
+ # preallocated V needlessly retains two (N, P, J, R) work arrays.
2955
+ V = dev.cust_einsum('npjk,nkr -> npjr', Xr, Br).astype(float)
2956
+ V += XBf[:, :, :, None]
2697
2957
  else:
2698
2958
  self.Br = Br_b[None, :, None]
2959
+ V = np.broadcast_to(
2960
+ XBf[:, :, :, None],
2961
+ (self.N, self.P, self.J, draws.shape[-1]),
2962
+ ).copy()
2699
2963
 
2700
2964
  # transformations for variables with fixed coeffs
2701
2965
  if self.Kftrans != 0:
@@ -167,6 +167,7 @@ def build_case_alt_distances(
167
167
  dest_lat_col: str = "dest_lat",
168
168
  dest_lon_col: str = "dest_lon",
169
169
  offset_col: str | None = "distance_offset_km",
170
+ member_zones_col: str | None = "member_suburbs",
170
171
  ) -> pd.DataFrame:
171
172
  """Build larch-style per-(case, alternative) distances.
172
173
 
@@ -204,7 +205,12 @@ def build_case_alt_distances(
204
205
  raise KeyError(f"build_case_alt_distances: alternatives missing columns {missing}")
205
206
 
206
207
  c = cases[req_cases].copy()
207
- a = alternatives[req_alts + ([offset_col] if offset_col and offset_col in alternatives.columns else [])].copy()
208
+ extra_alt_cols = []
209
+ if offset_col and offset_col in alternatives.columns:
210
+ extra_alt_cols.append(offset_col)
211
+ if member_zones_col and member_zones_col in alternatives.columns:
212
+ extra_alt_cols.append(member_zones_col)
213
+ a = alternatives[req_alts + extra_alt_cols].copy()
208
214
  c["_oz"] = c[orig_zone_col].astype(str).str.strip().str.upper()
209
215
  a["_dz"] = a[dest_zone_col].astype(str).str.strip().str.upper()
210
216
  if offset_col and offset_col in a.columns:
@@ -225,24 +231,41 @@ def build_case_alt_distances(
225
231
  # Skim index lookup (zone label -> matrix row).
226
232
  skim_lookup: dict[str, int] = {}
227
233
  if skim_zone_ids is not None and skim_dist_mat is not None:
228
- skim_lookup = {z: i for i, z in enumerate(skim_zone_ids)}
234
+ skim_lookup = {str(z).strip().upper(): i
235
+ for i, z in enumerate(skim_zone_ids)}
229
236
  o_idx = np.array([skim_lookup.get(z, -1) for z in c["_oz"].to_numpy()], dtype=int)
230
237
  d_idx = np.array([skim_lookup.get(z, -1) for z in a["_dz"].to_numpy()], dtype=int)
231
238
 
232
239
  skim_d = np.full((n_c, n_a), np.nan, dtype=float)
233
240
  skim_t = np.full((n_c, n_a), np.nan, dtype=float)
234
- if skim_dist_mat is not None and (o_idx >= 0).any() and (d_idx >= 0).any():
241
+ if skim_dist_mat is not None and (o_idx >= 0).any():
235
242
  sd = np.asarray(skim_dist_mat, dtype=float)
236
243
  st = np.asarray(skim_time_mat, dtype=float) if skim_time_mat is not None else None
237
244
  ok_c = np.where(o_idx >= 0)[0]
238
- ok_a = np.where(d_idx >= 0)[0]
239
- sub = sd[np.ix_(o_idx[ok_c], d_idx[ok_a])]
240
- usable = (sub > 0.0) & (sub < float(max_km))
241
- block = np.where(usable, sub, np.nan)
242
- skim_d[np.ix_(ok_c, ok_a)] = block
243
- if st is not None and st.shape == sd.shape:
244
- sub_t = st[np.ix_(o_idx[ok_c], d_idx[ok_a])]
245
- skim_t[np.ix_(ok_c, ok_a)] = np.where(usable, sub_t, np.nan)
245
+ ok_a = np.arange(n_a) if member_zones_col and member_zones_col in a.columns \
246
+ else np.where(d_idx >= 0)[0]
247
+ if member_zones_col and member_zones_col in a.columns:
248
+ for ci in ok_c:
249
+ for ai in ok_a:
250
+ members = [z.strip().upper() for z in str(
251
+ a.iloc[ai][member_zones_col] or "").split(",") if z.strip()]
252
+ member_idx = [skim_lookup[z] for z in members if z in skim_lookup]
253
+ if not member_idx:
254
+ member_idx = [d_idx[ai]]
255
+ values = sd[o_idx[ci], member_idx]
256
+ valid = np.isfinite(values) & (values > 0.0) & (values < float(max_km))
257
+ if valid.any():
258
+ pick = int(np.flatnonzero(valid)[np.argmin(values[valid])])
259
+ skim_d[ci, ai] = values[valid][pick]
260
+ if st is not None and st.shape == sd.shape:
261
+ skim_t[ci, ai] = st[o_idx[ci], member_idx[np.flatnonzero(valid)[pick]]]
262
+ else:
263
+ sub = sd[np.ix_(o_idx[ok_c], d_idx[ok_a])]
264
+ usable = (sub > 0.0) & (sub < float(max_km))
265
+ skim_d[np.ix_(ok_c, ok_a)] = np.where(usable, sub, np.nan)
266
+ if st is not None and st.shape == sd.shape:
267
+ sub_t = st[np.ix_(o_idx[ok_c], d_idx[ok_a])]
268
+ skim_t[np.ix_(ok_c, ok_a)] = np.where(usable, sub_t, np.nan)
246
269
 
247
270
  # Haversine fallback from case-specific origin coords (broadcast).
248
271
  with np.errstate(invalid="ignore"):
@@ -1780,6 +1780,7 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
1780
1780
  member_params_spec=None,
1781
1781
  class_params_spec=None,
1782
1782
  panels=None,
1783
+ retain_all=False,
1783
1784
  ident_penalty_kwargs=None,
1784
1785
  **kwargs,
1785
1786
  ):
@@ -1797,8 +1798,17 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
1797
1798
  Variables used in the class membership equation.
1798
1799
  member_params_spec : list or array, optional
1799
1800
  Per-class membership variable specification.
1801
+ retain_all : bool
1802
+ MEMORY: when False (default) only the best model is kept alive and
1803
+ the returned list contains just that model; every intermediate
1804
+ class-count model is released as the search advances. Each model
1805
+ otherwise pins its X/y/draws design, JAX device copies and cached
1806
+ JIT objectives, so retaining all of them across a GA search leaks
1807
+ hundreds of MB per candidate. Set True to recover the previous
1808
+ behaviour (all models returned and stored on
1809
+ ``best_model.search_results``).
1800
1810
  """
1801
- fitted_models = []
1811
+ fitted_models = [] if retain_all else None
1802
1812
  best_model = None
1803
1813
  _best_nsig = float('inf')
1804
1814
  prev_model = None
@@ -1833,7 +1843,8 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
1833
1843
  de_seed=de_seed,
1834
1844
  )
1835
1845
  model.get_loglik_null()
1836
- fitted_models.append(model)
1846
+ if fitted_models is not None:
1847
+ fitted_models.append(model)
1837
1848
 
1838
1849
  # Pareto-style significance prioritisation:
1839
1850
  # 1) prefer fewer insignificant variable groups,
@@ -1858,8 +1869,14 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
1858
1869
  _best_nsig = nsig
1859
1870
  prev_model = model
1860
1871
 
1861
- best_model.search_results = fitted_models
1862
- return best_model, fitted_models
1872
+ if fitted_models is not None:
1873
+ best_model.search_results = fitted_models
1874
+ return best_model, fitted_models
1875
+ # Default path: keep only the best model alive. Drop the warm-start
1876
+ # chain's trailing reference so intermediate models can be collected.
1877
+ best_model.search_results = None
1878
+ prev_model = None
1879
+ return best_model, [best_model]
1863
1880
 
1864
1881
  # ------------------------------------------------------------------
1865
1882
  # Internal helpers for numerical standard errors
@@ -54,6 +54,7 @@ in a panel or longitudinal dataset.
54
54
  ''' LIBRARIES '''
55
55
  ''' ---------------------------------------------------------- '''
56
56
  import itertools
57
+ import os
57
58
  import numpy as np
58
59
  import scipy.stats as ss
59
60
  from scipy.optimize import minimize
@@ -273,6 +274,8 @@ class MixedLogit(DiscreteChoiceModel):
273
274
  self.rvdist = None # Distributions for randvar
274
275
  self.correlated_vars, self.halton_opts = None, None
275
276
  self.batch_size, self.minimise_func = None, None
277
+ if not hasattr(self, "save_prob_full"):
278
+ self.save_prob_full = bool(os.environ.get("SEARCHLIBRIUM_SAVE_PROB_FULL", ""))
276
279
  # }
277
280
 
278
281
  ''' -------------------------------------------------------------------- '''
@@ -781,11 +784,17 @@ class MixedLogit(DiscreteChoiceModel):
781
784
  self.ind_pred_prob, self.pred_prob = _panel_weighted_shares(
782
785
  self.choice_pred_prob, self.panel_info)
783
786
 
784
- self.prob_full = p
787
+ if self.save_prob_full:
788
+ self.prob_full = p
785
789
  # }
786
790
  self.post_process(result, self.Xnames, self.N)
787
791
  # }
788
792
 
793
+ def release_memory(self):
794
+ """Drop prob_full if not explicitly saved. Safe between GA candidates."""
795
+ if not getattr(self, "save_prob_full", False):
796
+ self.prob_full = None
797
+
789
798
  ''' ---------------------------------------------------------- '''
790
799
  ''' Function. This code segments the betas array into different
791
800
  parts and stores these parts in var_list under keys from
@@ -3,6 +3,7 @@ IMPLEMENTATION: MULTINOMIAL AND CONDITIONAL LOGIT MODEL
3
3
  """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
4
4
  from sklearn.preprocessing import RobustScaler
5
5
  from addicty import Dict
6
+ import os
6
7
 
7
8
  """
8
9
  BACKGROUND - MULTINOMIAL LOGIT
@@ -1019,7 +1020,9 @@ class MultinomialLogit(DiscreteChoiceModel):
1019
1020
  # owning model instead of capturing ``self``, so an entry never pins a
1020
1021
  # dead model (and its X/y arrays) for the life of the process.
1021
1022
  # ------------------------------------------------------------------
1022
- _JIT_CACHE_MAXSIZE = 16
1023
+ _JIT_CACHE_MAXSIZE = max(
1024
+ 0, int(os.environ.get("SEARCHLIBRIUM_MNL_JIT_CACHE_MAXSIZE", "4"))
1025
+ )
1023
1026
  _jit_cache: "OrderedDict[tuple, tuple]" = OrderedDict()
1024
1027
 
1025
1028
  @classmethod
@@ -69,6 +69,8 @@ from scipy import stats
69
69
  from scipy.optimize import minimize
70
70
 
71
71
  import inspect
72
+ import os
73
+ from collections import OrderedDict
72
74
 
73
75
  minval = 1E-30
74
76
 
@@ -2481,7 +2483,15 @@ class SA_ORDLOG():
2481
2483
  self.mod = OrderedLogit(X=X, y=y, J=J, distr='logit', start=None, fit_intercept=True)
2482
2484
 
2483
2485
  # SA specific parameters
2484
- self.archive = {} # Define a dictionary
2486
+ # MEMORY: the archive memoises spec -> loglik so repeated specs skip
2487
+ # the expensive refit. It used to be an unbounded dict, so a long run
2488
+ # retained one entry per distinct predictor mask (2**K in the limit).
2489
+ # It is now an LRU-bounded OrderedDict; evicting an entry only costs a
2490
+ # future refit, never correctness. Set
2491
+ # SEARCHLIBRIUM_ORDLOG_ARCHIVE_MAX=0 to restore unbounded behaviour.
2492
+ self.archive = OrderedDict()
2493
+ self.archive_maxsize = int(
2494
+ os.environ.get("SEARCHLIBRIUM_ORDLOG_ARCHIVE_MAX", "50000"))
2485
2495
  self.tI = 100
2486
2496
  self.tF = 0.001
2487
2497
  self.t = self.tI
@@ -2544,6 +2554,12 @@ class SA_ORDLOG():
2544
2554
  print("Solution already seen")
2545
2555
  candidate_score = self.archive[key]
2546
2556
 
2557
+ # LRU touch + bounded eviction (see archive_maxsize above).
2558
+ self.archive.move_to_end(key)
2559
+ _amax = getattr(self, "archive_maxsize", 0)
2560
+ while _amax and len(self.archive) > _amax:
2561
+ self.archive.popitem(last=False)
2562
+
2547
2563
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2548
2564
 
2549
2565
  if accept_change(self.current_score, candidate_score, self.t):
@@ -231,6 +231,7 @@ def run_class_search(
231
231
  max_classes=max_classes,
232
232
  criterion="bic",
233
233
  warm_start=True,
234
+ retain_all=True,
234
235
  de_init=de_init,
235
236
  de_popsize=de_popsize,
236
237
  de_maxiter=de_maxiter,
@@ -0,0 +1 @@
1
+ 0.0.263
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.261
3
+ Version: 0.0.263
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
5
  Author: Zeke Ahern, Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Maintainer-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -1 +0,0 @@
1
- 0.0.261
File without changes