SearchLibrium 0.0.262__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.262/src/SearchLibrium.egg-info → searchlibrium-0.0.263}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/Halton.py +8 -4
  4. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/MixedLogit.py +261 -23
  5. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/destination_distances.py +34 -11
  6. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/multinomial_logit.py +4 -1
  7. searchlibrium-0.0.263/src/SearchLibrium/version.txt +1 -0
  8. {searchlibrium-0.0.262 → searchlibrium-0.0.263/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  9. searchlibrium-0.0.262/src/SearchLibrium/version.txt +0 -1
  10. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/LICENSE +0 -0
  11. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/MANIFEST.in +0 -0
  12. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/README.md +0 -0
  13. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/setup.cfg +0 -0
  14. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/BEHier.py +0 -0
  15. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  16. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  17. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/RandomP.py +0 -0
  18. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  19. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  20. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/__init__.py +0 -0
  21. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/__main__.py +0 -0
  22. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/_choice_model.py +0 -0
  23. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/_device.py +0 -0
  24. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/abm.py +0 -0
  25. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/accessibility.py +0 -0
  26. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/banditsa.py +0 -0
  27. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/boxcox_functions.py +0 -0
  28. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/call_meta.py +0 -0
  29. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/constraints_builder.py +0 -0
  30. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/coupled_solver.py +0 -0
  31. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  32. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/data/TravelMode.csv +0 -0
  33. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/data/electricity.csv +0 -0
  34. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/harmony.py +0 -0
  35. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/hspbil.py +0 -0
  36. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/jax_utils.py +0 -0
  37. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/larch_compat.py +0 -0
  38. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/larch_models.py +0 -0
  39. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/latent_class.py +0 -0
  40. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/latent_spec.py +0 -0
  41. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/logistic_regression.py +0 -0
  42. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/main.py +0 -0
  43. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/main_debug.py +0 -0
  44. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/mdcev.py +0 -0
  45. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/mh_choice_estimator.py +0 -0
  46. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/misc.py +0 -0
  47. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/mixed_logit.py +0 -0
  48. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/mixed_nested.py +0 -0
  49. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/mixedrrm.py +0 -0
  50. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/multinomial_nested.py +0 -0
  51. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/multinomial_probit.py +0 -0
  52. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/numba_engine.py +0 -0
  53. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/numba_mxl.py +0 -0
  54. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/numba_rrm.py +0 -0
  55. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/ordered_logit.py +0 -0
  56. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  57. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/predict.py +0 -0
  58. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/regularization.py +0 -0
  59. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/rrm.py +0 -0
  60. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/sample_data.py +0 -0
  61. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/sapbil.py +0 -0
  62. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/search.py +0 -0
  63. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/selection_models.py +0 -0
  64. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/setup.py +0 -0
  65. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/siman.py +0 -0
  66. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/skims.py +0 -0
  67. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/sparseea_agds.py +0 -0
  68. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/test_lc_de.py +0 -0
  69. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/test_mario_searches.py +0 -0
  70. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  71. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/threshold.py +0 -0
  72. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  73. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  74. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  75. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  76. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/requires.txt +0 -0
  77. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  78. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_destination_distances.py +0 -0
  79. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_jax_mask_equivalence.py +0 -0
  80. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_larch_integration.py +0 -0
  81. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_lc_smart_init.py +0 -0
  82. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_mh_choice_estimator.py +0 -0
  83. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_mxl_random_starts.py +0 -0
  84. {searchlibrium-0.0.262 → searchlibrium-0.0.263}/tests/test_per_alternative_random.py +0 -0
  85. {searchlibrium-0.0.262 → 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.262
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.262"
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:
@@ -234,7 +234,8 @@ class MixedLogit(DiscreteChoiceModel):
234
234
  randvars=None, panels=None, base_alt=None, fit_intercept=False,
235
235
  init_coeff=None, maxiter=1000, correlated_vars=None,
236
236
  n_draws=1000, halton=True, minimise_func=None,
237
- 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,
238
239
  gtol=1e-6, return_hess=True, return_grad=True, method="slsqp",
239
240
  save_fitted_params=True, mnl_init=True,
240
241
  de_init=False, de_popsize=4, de_maxiter=3, de_tol=0.5,
@@ -310,7 +311,15 @@ class MixedLogit(DiscreteChoiceModel):
310
311
  self.jac = self.return_grad # scipy optimize parameter
311
312
  self.n_draws = n_draws
312
313
  self.batch_size = min(n_draws, batch_size) if batch_size is not None else n_draws
313
- 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)
314
323
 
315
324
  # CRITICAL FIX: Recreate draws_generator with proper halton_opts AND rvdist
316
325
  # The __init__ creates it with halton_opts=None, but setup() gets the actual options
@@ -318,7 +327,8 @@ class MixedLogit(DiscreteChoiceModel):
318
327
  # AND to pass the correct random variable distributions (e.g., 'ln' for lognormal)
319
328
  # IMPORTANT: Order rvdist by varnames order, not dictionary order
320
329
  rvdist = [randvars[var] for var in varnames if var in randvars] if randvars else []
321
- 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)
322
332
 
323
333
  self.randvarsdict = randvars # random variables not transformed
324
334
 
@@ -821,7 +831,16 @@ class MixedLogit(DiscreteChoiceModel):
821
831
  def fit(self):
822
832
  # {
823
833
  # Generate draws:
824
- 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)
825
844
  self.draws, self.drawstrans = draws, drawstrans # Record generated values
826
845
  # Explicit caller-supplied starts are respected verbatim (no ASC
827
846
  # share seeding); the MNL block below may still overwrite
@@ -838,9 +857,15 @@ class MixedLogit(DiscreteChoiceModel):
838
857
  from numba_engine import effective_engine as _mx_eff_eng
839
858
  except ImportError:
840
859
  from .numba_engine import effective_engine as _mx_eff_eng
841
- 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')
842
863
  except Exception:
843
- 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
844
869
 
845
870
  # 2x Kftrans - mean and lambda, 3x Krtrans - mean, s.d., lambda
846
871
  # Kchol, Kbw - relate to random variables, non-transformed
@@ -1061,11 +1086,12 @@ class MixedLogit(DiscreteChoiceModel):
1061
1086
  if _v: print(f"[MXL] Differential evolution initialization failed: {e}")
1062
1087
 
1063
1088
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
1064
- if dev.using_gpu: # {
1089
+ if dev.using_gpu and draws is not None: # {
1065
1090
  self.X, self.y = dev.convert_array_gpu(self.X), dev.convert_array_gpu(self.y)
1066
1091
  self.panel_info = dev.convert_array_gpu(self.panel_info)
1067
1092
  draws = dev.convert_array_gpu(draws)
1068
- drawstrans = dev.convert_array_gpu(drawstrans)
1093
+ if drawstrans is not None:
1094
+ drawstrans = dev.convert_array_gpu(drawstrans)
1069
1095
  self.weights = dev.convert_array_gpu(self.weights) if self.weights is not None else None
1070
1096
  self.avail = dev.convert_array_gpu(self.avail) if self.avail is not None else None
1071
1097
  # }
@@ -1113,7 +1139,8 @@ class MixedLogit(DiscreteChoiceModel):
1113
1139
  # SOLVE OPTIMISATION PROBLEM - COMPUTATIONALLY TIME CONSUMING!
1114
1140
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1115
1141
  # ---- JAX fast path ----
1116
- if (getattr(self, '_jax', False) and not use_numba
1142
+ if (getattr(self, '_jax', False) and not streaming_people
1143
+ and not use_numba
1117
1144
  and self.minimise_func is None):
1118
1145
  jax_result = self.optimize_jax(betas, draws, drawstrans)
1119
1146
  if jax_result is not None:
@@ -1244,6 +1271,16 @@ class MixedLogit(DiscreteChoiceModel):
1244
1271
  except Exception:
1245
1272
  pass
1246
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
+
1247
1284
  if self.save_fitted_params:
1248
1285
  self.compute_fitted_params(self.y, self.p, self.panel_info, self.Br)
1249
1286
 
@@ -1424,7 +1461,8 @@ class MixedLogit(DiscreteChoiceModel):
1424
1461
 
1425
1462
  sim_p = jnp.mean(pch, axis=1) # (N,)
1426
1463
  sim_p = jnp.clip(sim_p, 1e-300, None)
1427
- 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))
1428
1466
  # Tiny L2 penalty — makes the Hessian diagonally dominant so it
1429
1467
  # never becomes singular, even with near-constant variables or
1430
1468
  # simulation noise in the mixed-logit probability integration.
@@ -1666,6 +1704,125 @@ class MixedLogit(DiscreteChoiceModel):
1666
1704
  print(f"[JAX MXL optimizer] jaxopt run failed ({_e}).")
1667
1705
  return None
1668
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
+
1669
1826
  def optimize_jax(self, betas, draws, drawstrans):
1670
1827
  """JAX-accelerated optimisation for the Mixed Logit model.
1671
1828
 
@@ -1681,6 +1838,10 @@ class MixedLogit(DiscreteChoiceModel):
1681
1838
  print("[JAX MXL optimizer] heterogeneity detected; falling back to scipy.")
1682
1839
  return None
1683
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
+
1684
1845
  try:
1685
1846
  import os
1686
1847
  os.environ.setdefault("JAX_ENABLE_X64", "True")
@@ -1940,12 +2101,88 @@ class MixedLogit(DiscreteChoiceModel):
1940
2101
  print(f"[JAX MXL masked] falling back: {e}")
1941
2102
  return None
1942
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
+
1943
2174
  def get_loglik_gradient(self, betas, X, y, panel_info, draws,
1944
2175
  drawstrans, weights, avail, batch_size):
1945
2176
  # {
1946
2177
  """ Fixed and random parameters are handled separately to
1947
2178
  speed up the estimation and the results are concatenated.
1948
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)
1949
2186
  # Segregating initial values to fixed betas (Bf),
1950
2187
  # random beta means (Br_b)
1951
2188
  # for both non-transformed and transformed variables
@@ -2025,11 +2262,10 @@ class MixedLogit(DiscreteChoiceModel):
2025
2262
 
2026
2263
  # _, gr_b, gr_w, pch = np.zeros((N, self.Kf)), np.zeros((N, self.Kr)), np.zeros((N, self.Kr)), [] # Batch data
2027
2264
 
2028
- _ = np.zeros((N, self.Kf)) # CONCEPTUAL ERROR. WHY CREATE IF NO NAME?
2029
2265
  gr_b = np.zeros((N, self.Kr))
2030
2266
  gr_w = np.zeros((N, self.Kr))
2031
2267
  g_all = np.zeros((N, len(betas)))
2032
- pch = [] # Batch data
2268
+ lik_sum = np.zeros(N, dtype=float)
2033
2269
  for batch in range(n_batches):
2034
2270
  # {
2035
2271
  a = batch * batch_size
@@ -2051,6 +2287,8 @@ class MixedLogit(DiscreteChoiceModel):
2051
2287
 
2052
2288
  # Thresholds to avoid divide by zero warnings
2053
2289
  pch_batch = truncate_lower(pch_batch, min_comp_val)
2290
+ draw_count = b - a
2291
+ lik_sum += np.sum(pch_batch, axis=1)
2054
2292
 
2055
2293
  # Observed probability minus predicted probability
2056
2294
  ymp = y - p # (N, P, J, R)
@@ -2289,12 +2527,10 @@ class MixedLogit(DiscreteChoiceModel):
2289
2527
  g, pch_batch = dev.to_cpu(g), dev.to_cpu(pch_batch)
2290
2528
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2291
2529
 
2292
- pch.append(pch_batch)
2293
- g_all += g
2530
+ g_all += g * draw_count
2294
2531
  # }
2295
2532
 
2296
- pch = np.concatenate(pch, axis=-1)
2297
- lik = pch.mean(axis=1) # (N,)
2533
+ lik = lik_sum / float(self.n_draws) # (N,)
2298
2534
  loglik = np.log(lik)
2299
2535
  if weights is not None: loglik = loglik * weights
2300
2536
  loglik = loglik.sum()
@@ -2322,7 +2558,7 @@ class MixedLogit(DiscreteChoiceModel):
2322
2558
  g *= weights[:, 0][:, None] # Use only the first column and convert to column vector
2323
2559
  # }
2324
2560
 
2325
- g = np.sum(g, axis=0) / n_batches # (K, )
2561
+ g = np.sum(g_all, axis=0) / float(self.n_draws) # (K, )
2326
2562
  g = g - self.regularize_l1_grad(betas)
2327
2563
  # L2 ridge penalty gradient: the objective subtracts
2328
2564
  # reg_penalty * sum(betas^2) from the log-likelihood, so its
@@ -2671,14 +2907,10 @@ class MixedLogit(DiscreteChoiceModel):
2671
2907
 
2672
2908
  # INITIALISE
2673
2909
  XBf = np.zeros((self.N, self.P, self.J))
2674
- XBr = np.zeros((self.N, self.P, self.J, self.batch_size)) # NOT USED?
2675
- V = np.zeros((self.N, self.P, self.J, self.batch_size)) # NOT USED?
2676
2910
 
2677
2911
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2678
2912
  if dev.using_gpu: # {
2679
2913
  XBf = dev.convert_array_gpu(XBf)
2680
- XBr = dev.convert_array_gpu(XBr)
2681
- V = dev.convert_array_gpu(V)
2682
2914
  # }
2683
2915
  # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
2684
2916
 
@@ -2718,10 +2950,16 @@ class MixedLogit(DiscreteChoiceModel):
2718
2950
  Br = self.draws_generator.apply_distribution(Br, self.rvdist)
2719
2951
  self.Br = Br # save Br to use later
2720
2952
  Xr = X[:, :, :, self.rvidx]
2721
- XBr = dev.cust_einsum('npjk,nkr -> npjr', Xr, Br).astype(float) # (N, P, J, R)
2722
- 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]
2723
2957
  else:
2724
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()
2725
2963
 
2726
2964
  # transformations for variables with fixed coeffs
2727
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"):
@@ -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
@@ -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.262
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.262
File without changes