SearchLibrium 0.0.151__tar.gz → 0.0.155__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 (64) hide show
  1. {searchlibrium-0.0.151/src/SearchLibrium.egg-info → searchlibrium-0.0.155}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/MixedLogit.py +84 -94
  4. searchlibrium-0.0.155/src/SearchLibrium/MixedLogitGSE.py +249 -0
  5. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/__init__.py +2 -0
  6. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/constraints_builder.py +73 -0
  7. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/harmony.py +22 -2
  8. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/search.py +227 -36
  9. searchlibrium-0.0.155/src/SearchLibrium/version.txt +1 -0
  10. {searchlibrium-0.0.151 → searchlibrium-0.0.155/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  11. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium.egg-info/SOURCES.txt +6 -1
  12. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium.egg-info/top_level.txt +1 -0
  13. searchlibrium-0.0.155/src/data_generator/demonstrate_generation.py +85 -0
  14. searchlibrium-0.0.155/src/data_generator/fit_recovery.py +465 -0
  15. searchlibrium-0.0.155/src/data_generator/latent_class_gen.py +237 -0
  16. searchlibrium-0.0.155/src/data_generator/print_synthetic_data.py +122 -0
  17. searchlibrium-0.0.151/src/SearchLibrium/version.txt +0 -1
  18. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/MANIFEST.in +0 -0
  19. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/README.md +0 -0
  20. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/setup.cfg +0 -0
  21. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/BEHier.py +0 -0
  22. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/Halton.py +0 -0
  23. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  24. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/RandomP.py +0 -0
  25. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  26. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  27. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/__main__.py +0 -0
  28. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/_choice_model.py +0 -0
  29. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/_device.py +0 -0
  30. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/banditsa.py +0 -0
  31. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/bhhh/minimize.py +0 -0
  32. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/boxcox_functions.py +0 -0
  33. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/call_meta.py +0 -0
  34. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  35. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/data/TravelMode.csv +0 -0
  36. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/data/electricity.csv +0 -0
  37. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/hspbil.py +0 -0
  38. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/latent_class.py +0 -0
  39. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/main.py +0 -0
  40. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/main_debug.py +0 -0
  41. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/mdcev.py +0 -0
  42. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/misc.py +0 -0
  43. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/mixed_logit.py +0 -0
  44. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/mixed_nested.py +0 -0
  45. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/mixedrrm.py +0 -0
  46. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/multinomial_logit.py +0 -0
  47. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/multinomial_nested.py +0 -0
  48. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/multinomial_probit.py +0 -0
  49. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/ordered_logit.py +0 -0
  50. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  51. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/predict.py +0 -0
  52. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/rrm.py +0 -0
  53. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/sample_data.py +0 -0
  54. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/sapbil.py +0 -0
  55. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/selection_models.py +0 -0
  56. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/setup.py +0 -0
  57. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/siman.py +0 -0
  58. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/test_lc_de.py +0 -0
  59. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/test_mario_searches.py +0 -0
  60. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  61. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium/threshold.py +0 -0
  62. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  63. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  64. {searchlibrium-0.0.151 → searchlibrium-0.0.155}/src/SearchLibrium.egg-info/requires.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.151
3
+ Version: 0.0.155
4
4
  Summary: A Python package for econometric models driven by search
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -82,7 +82,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
82
82
  realpython = "SearchLibrium.__main__:main"
83
83
 
84
84
  [tool.bumpver]
85
- current_version = "0.0.151"
85
+ current_version = "0.0.155"
86
86
  version_pattern = "MAJOR.MINOR.PATCH"
87
87
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
88
88
  commit = true
@@ -134,14 +134,13 @@ class MixedLogit(DiscreteChoiceModel):
134
134
  def setup(self, X, y, varnames=None, alts=None, isvars=None, transvars=None,
135
135
  transformation="boxcox", ids=None, weights=None, avail=None,
136
136
  randvars=None, panels=None, base_alt=None, fit_intercept=False,
137
- init_coeff=None, maxiter=2000, correlated_vars=None,
137
+ init_coeff=None, maxiter=1000, correlated_vars=None,
138
138
  n_draws=1000, halton=True, minimise_func=None,
139
- batch_size=None, halton_opts=None, ftol=1e-6,
139
+ batch_size=None, halton_opts=None, ftol=1e-12,
140
140
  gtol=1e-6, return_hess=True, return_grad=True, method="slsqp",
141
141
  save_fitted_params=True, mnl_init=True,
142
142
  de_init=False, de_popsize=4, de_maxiter=3, de_tol=0.5,
143
- de_polish=False, l1_penalty=0.1, l2_penalty=0.5,
144
- sd_penalty=0.001):
143
+ de_polish=False, l1_penalty=0.0):
145
144
  # {
146
145
  self.fit_intercept = fit_intercept
147
146
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -187,8 +186,6 @@ class MixedLogit(DiscreteChoiceModel):
187
186
  self.total_fun_eval = 0
188
187
  self.method = method.lower() if hasattr(method, 'lower') else method
189
188
  self.l1_penalty = float(l1_penalty)
190
- self.l2_penalty = float(l2_penalty)
191
- self.sd_penalty = float(sd_penalty)
192
189
  self.jac = self.return_grad # scipy optimize parameter
193
190
  self.n_draws = n_draws
194
191
  self.batch_size = min(n_draws, batch_size) if batch_size is not None else n_draws
@@ -387,7 +384,9 @@ class MixedLogit(DiscreteChoiceModel):
387
384
  # 2x Kftrans - mean and lambda, 3x Krtrans - mean, s.d., lambda
388
385
  # Kchol, Kbw - relate to random variables, non-transformed
389
386
  # Kchol - cholesky matrix, Kbw the s.d. for random vars
390
- n_coeff = self.Kf + self.Kr + self.Kchol + self.Kbw + 2 * self.Kftrans + 3 * self.Krtrans
387
+ n_coeff = (self.Kf + self.Kr + self.Kchol + self.Kbw
388
+ + 2 * self.Kftrans + 3 * self.Krtrans
389
+ + self._n_coeff_extra())
391
390
 
392
391
  # Initalise coefficients using a multinomial logit model
393
392
  if self.mnl_init and self.init_coeff is None:
@@ -411,30 +410,38 @@ class MixedLogit(DiscreteChoiceModel):
411
410
  fit_intercept=False)
412
411
  mnl.fit()
413
412
 
414
- # mnl estimates -> mxl needs to add stdev to random variables
415
- self.init_coeff = mnl.coeff_est
416
-
417
- lower = self.Kf + 2 * self.Kftrans + self.Kr
418
- upper = lower + self.Krtrans
419
-
420
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
421
- # ERROR HANDLING (FIX) - WHEN LESS COEFFICIENTS THAN "kf + kr"
422
- if lower > len(self.init_coeff):
423
- # {
424
- additional_elements_needed = lower - len(self.init_coeff)
425
- extra = np.full(additional_elements_needed, 0.1)
426
- self.init_coeff = np.concatenate((self.init_coeff, extra))
427
- # }
428
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
429
-
430
- arr = self.init_coeff[:lower]
431
- # Use simple initialization matching searchlogit for better convergence
413
+ # Build init_coeff from MNL estimates, mapping each variable
414
+ # to its correct MXL slot (Bf for fixed, Br_b for random).
415
+ bf_init = np.zeros(self.Kf)
416
+ br_b_init = np.zeros(self.Kr)
417
+ bf_pos = br_pos = 0
418
+ for ii in range(len(self.varnames)):
419
+ if not mnl.fxidx[ii]:
420
+ continue
421
+ mnl_idx = int(np.sum(mnl.fxidx[:ii]))
422
+ mnl_val = mnl.coeff_est[mnl_idx] if mnl_idx < len(mnl.coeff_est) else 0.1
423
+ if self.fxidx[ii]:
424
+ bf_init[bf_pos] = mnl_val
425
+ bf_pos += 1
426
+ elif self.rvidx[ii]:
427
+ br_b_init[br_pos] = mnl_val
428
+ br_pos += 1
429
+
430
+ mnl_Kf = int(np.sum(mnl.fxidx))
431
+ mnl_Kftrans = int(np.sum(mnl.fxtransidx))
432
+ bftrans_b = mnl.coeff_est[mnl_Kf:mnl_Kf + mnl_Kftrans] if mnl_Kftrans > 0 else np.array([])
433
+ bftrans_l = mnl.coeff_est[mnl_Kf + mnl_Kftrans:mnl_Kf + 2 * mnl_Kftrans] if mnl_Kftrans > 0 else np.array([])
434
+
435
+ arr = np.concatenate([bf_init, br_b_init] + list(self._init_pad_arrays()) + [bftrans_b, bftrans_l])
432
436
  rep = np.repeat(0.1, self.Kchol + self.Kbw)
433
- self.init_coeff = np.concatenate((arr, rep, self.init_coeff[lower:upper],))
437
+ self.init_coeff = np.concatenate([arr, rep])
434
438
 
435
439
  if self.Krtrans:
436
- rep = np.repeat(0.1, self.Krtrans)
437
- self.init_coeff = np.concatenate((self.init_coeff, rep, self.init_coeff[-self.Krtrans:]))
440
+ self.init_coeff = np.concatenate([
441
+ self.init_coeff,
442
+ np.repeat(0.1, self.Krtrans),
443
+ np.repeat(0.1, self.Krtrans),
444
+ ])
438
445
  # }
439
446
 
440
447
  betas = np.repeat(0.1, n_coeff) if self.init_coeff is None else self.init_coeff
@@ -454,7 +461,8 @@ class MixedLogit(DiscreteChoiceModel):
454
461
  "flmbda": (lmda_bound, self.Kftrans),
455
462
  "br_trans_b": (any_bound, self.Krtrans),
456
463
  "br_trans_w": (any_bound, self.Krtrans),
457
- "rlmbda": (lmda_bound, self.Krtrans)
464
+ "rlmbda": (lmda_bound, self.Krtrans),
465
+ **self._bound_extra(),
458
466
  }
459
467
  bnds = [[bound[1][0]] * bound[1][1] for bound in bound_dict.items() if bound[1][1] > 0]
460
468
  bnds = list(itertools.chain.from_iterable(bnds))
@@ -553,7 +561,8 @@ class MixedLogit(DiscreteChoiceModel):
553
561
  "flmbda": (lmda_bound, self.Kftrans),
554
562
  "br_trans_b": (any_bound, self.Krtrans),
555
563
  "br_trans_w": (any_bound, self.Krtrans),
556
- "rlmbda": (lmda_bound, self.Krtrans)
564
+ "rlmbda": (lmda_bound, self.Krtrans),
565
+ **self._bound_extra(),
557
566
  }
558
567
 
559
568
  # This code makes a specific number of copies of each range
@@ -570,10 +579,13 @@ class MixedLogit(DiscreteChoiceModel):
570
579
  if getattr(self, '_jax', False):
571
580
  jax_result = self.optimize_jax(betas, draws, drawstrans)
572
581
  if jax_result is not None:
573
- beta_segment_names = ["Bf", "Br_b", "chol", "Br_w", "Bftrans",
574
- "flmbda", "Brtrans_b", "Brtrans_w", "rlmda"]
575
- iterations = [self.Kf, self.Kr, self.Kchol, self.Kbw, self.Kftrans,
576
- self.Kftrans, self.Krtrans, self.Krtrans, self.Krtrans]
582
+ extra_names, extra_counts = self._beta_segment_extra()
583
+ beta_segment_names = (["Bf", "Br_b"] + list(extra_names)
584
+ + ["chol", "Br_w", "Bftrans",
585
+ "flmbda", "Brtrans_b", "Brtrans_w", "rlmda"])
586
+ iterations = ([self.Kf, self.Kr] + list(extra_counts)
587
+ + [self.Kchol, self.Kbw, self.Kftrans,
588
+ self.Kftrans, self.Krtrans, self.Krtrans, self.Krtrans])
577
589
  self.var_list = self.split_betas(jax_result['x'], iterations, beta_segment_names)
578
590
  self.chol_mat = self.construct_chol_mat(
579
591
  self.var_list['chol'], self.var_list['Br_w'], self.var_list['Brtrans_w'])
@@ -603,64 +615,11 @@ class MixedLogit(DiscreteChoiceModel):
603
615
  args = (self.X, self.y, self.panel_info, draws, drawstrans, self.weights, self.avail, self.batch_size)
604
616
  bounds = bnds if self.method == "L-BFGS-B" else None
605
617
  options = {'gtol': self.gtol, 'maxiter': self.maxiter, 'disp': False}
606
-
607
- # ── SD penalty wrapper: regularise SD params away from exactly zero ──
608
- # When an SD parameter hits zero, the gradient vanishes and BFGS gets stuck.
609
- # A small L2 penalty that activates below a threshold keeps SDs bounded
610
- # away from zero without affecting the well-identified estimates.
611
- if self.sd_penalty > 0 and self.Kbw > 0:
612
- # SD param indices: end of beta vector, before Kftrans and Krtrans
613
- sd_start = self.Kf + self.Kr + self.Kchol
614
- sd_end = sd_start + self.Kbw
615
- _orig_obj = self.get_loglik_gradient
616
-
617
- def _penalised_obj(betas, *obj_args):
618
- f, g = _orig_obj(betas, *obj_args)
619
- for i in range(sd_start, sd_end):
620
- if i >= len(betas):
621
- break
622
- sd_val = betas[i]
623
- if sd_val < 0.05:
624
- p = self.sd_penalty * (sd_val - 0.05) ** 2
625
- f += p
626
- if g is not None and len(g) > i:
627
- g[i] += 2.0 * self.sd_penalty * (sd_val - 0.05)
628
- return f, g
629
-
630
- obj_fn = _penalised_obj
631
- else:
632
- obj_fn = self.get_loglik_gradient
633
-
634
- result = minimise_func(obj_fn, betas, jac=self.jac, method=self.method,
618
+ result = minimise_func(self.get_loglik_gradient, betas, jac=self.jac, method=self.method,
635
619
  args=args, tol=self.ftol, bounds=bounds, options=options)
636
- print(f"[MXL] Primary minimization ({self.method}): success={result.get('success', None)}, fun={result.get('fun', float('nan')):.6g}, nit={result.get('nit', '?')}")
620
+ print(f"[MXL] Minimization completed: success={result.get('success', None)}, fun={result.get('fun', float('nan')):.6g}, nit={result.get('nit', '?')}")
637
621
  if 'x' in result:
638
- print(f"[MXL] Primary betas first_values={np.asarray(result['x'])[:min(8, len(result['x']))]!r}")
639
-
640
- # ── BFGS polish: after non-BFGS methods (SLSQP, Powell, etc.),
641
- # run a quick BFGS to get the inverse Hessian for standard errors.
642
- if (self.method not in ('bfgs', 'l-bfgs-b')
643
- and 'x' in result
644
- and result.get('success', False)):
645
- try:
646
- polish_opts = {'gtol': self.gtol * 10, 'maxiter': min(self.maxiter // 5, 200), 'disp': False}
647
- polish_bnds = bnds # re-use same bounds
648
- # Use original objective (no SD penalty) for clean Hessian
649
- polish_result = minimise_func(
650
- self.get_loglik_gradient,
651
- np.asarray(result['x']).copy(),
652
- jac=True, method='L-BFGS-B',
653
- args=args, tol=self.ftol,
654
- bounds=polish_bnds, options=polish_opts,
655
- )
656
- if polish_result.get('fun', float('inf')) < result.get('fun', float('inf')) + 0.1:
657
- print(f"[MXL] BFGS polish: success={polish_result.get('success')}, "
658
- f"fun={polish_result.get('fun', float('nan')):.6g}")
659
- result = polish_result
660
- else:
661
- print(f"[MXL] BFGS polish skipped (worse objective)")
662
- except Exception as e:
663
- print(f"[MXL] BFGS polish failed: {e}")
622
+ print(f"[MXL] Minimization final betas first_values={np.asarray(result['x'])[:min(8, len(result['x']))]!r}")
664
623
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
665
624
 
666
625
  if hasattr(self, 'method') and self.method == "L-BFGS-B": # {
@@ -720,7 +679,7 @@ class MixedLogit(DiscreteChoiceModel):
720
679
  @staticmethod
721
680
  def _jax_mxl_negloglik(betas, X_jax, y_jax, panel_info_jax, draws_jax,
722
681
  fxidx, rvidx, Kf, Kr, Kchol, Kbw, rvdist_names,
723
- correlationLength):
682
+ correlationLength, **kwargs):
724
683
  """JAX simulation-based log-likelihood for Mixed Logit (standard case).
725
684
 
726
685
  Handles fixed and random (normally/lognormally distributed) parameters.
@@ -849,12 +808,14 @@ class MixedLogit(DiscreteChoiceModel):
849
808
  _N = X_jax.shape[0]
850
809
  _P = X_jax.shape[1] if X_jax.ndim > 1 else 1
851
810
  _J = X_jax.shape[2] if X_jax.ndim > 2 else 1
852
- _cache_key = (_N, _P, _J, Kf, Kr, Kchol, Kbw)
811
+ _cache_key = (_N, _P, _J, Kf, Kr, Kchol, Kbw) + self._jax_cache_key_extra()
853
812
  _compiled = self._mxl_jit_cache.get(_cache_key)
854
813
  if _compiled is None:
814
+ _extra = self._jax_negloglik_extra_kwargs()
855
815
  _fn = lambda b, _X, _y, _pi, _dr: self._jax_mxl_negloglik(
856
816
  b, _X, _y, _pi, _dr,
857
- fxidx, rvidx, Kf, Kr, Kchol, Kbw, rvdist_names, correlationLength)
817
+ fxidx, rvidx, Kf, Kr, Kchol, Kbw, rvdist_names, correlationLength,
818
+ **_extra)
858
819
  _compiled = jax.jit(jax.value_and_grad(_fn))
859
820
  self._mxl_jit_cache[_cache_key] = _compiled
860
821
  # ────────────────────────────────────────────────────────
@@ -1668,4 +1629,33 @@ class MixedLogit(DiscreteChoiceModel):
1668
1629
  pch2_sd_test[i, :] = np.std(pch2[batch_slice, :], axis=0)
1669
1630
  # }
1670
1631
  self.pch2_res, self.pch2_sd_test = pch2_res, pch2_sd_test
1671
- # }
1632
+ # }
1633
+
1634
+ # ── Extension hooks for subclasses (e.g. MixedLogitGSE) ────────
1635
+ def _n_coeff_extra(self) -> int:
1636
+ """Extra parameter count beyond base MXL params."""
1637
+ return 0
1638
+
1639
+ def _bound_extra(self) -> dict:
1640
+ """Extra bound dictionary entries for subclasses."""
1641
+ return {}
1642
+
1643
+ def _beta_segment_extra(self):
1644
+ """Returns (extra_names, extra_counts) for split_betas."""
1645
+ return [], []
1646
+
1647
+ def _augment_br(self, Br, var_list):
1648
+ """Modify random coefficients. Subclasses may add gradient terms."""
1649
+ return Br
1650
+
1651
+ def _jax_negloglik_extra_kwargs(self):
1652
+ """Extra keyword arguments forwarded to JAX negloglik fn."""
1653
+ return {}
1654
+
1655
+ def _jax_cache_key_extra(self):
1656
+ """Extra tuple elements for the JAX compilation cache key."""
1657
+ return ()
1658
+
1659
+ def _init_pad_arrays(self):
1660
+ """Extra arrays to insert between Br_b and Bftrans in init_coeff."""
1661
+ return []
@@ -0,0 +1,249 @@
1
+ """MixedLogitGSE — Gradient Score Enhanced Mixed Logit.
2
+
3
+ Extends MixedLogit with gradient-latent loadings.
4
+
5
+ beta_nk = mu_k + (gamma_k + tau_k * z_nk) * g_nk + sigma_k * eta_nk
6
+
7
+ Parameter layout (no transforms, no correlation):
8
+ [Br_b(Kr) | gamma(Kr) | gamma_w(Kr) | Br_w(Kr)] (12 or 16 params)
9
+
10
+ Usage:
11
+ mxl = MixedLogitGSE()
12
+ mxl.setup(..., gradient_scores=g_avg, random_gamma=False)
13
+ mxl.fit()
14
+
15
+ Larch equivalence (kept in sync with test_runs_tours stage-5 mode model):
16
+ The model β_nk = μ_k + γ_k·g_nk + σ_k·η_nk decomposes term-by-term into a
17
+ plain Larch mixed logit, since β_nk multiplies x_k in the utility:
18
+ μ_k·x_k → ordinary fixed utility term P('c_k') * X('x_k')
19
+ γ_k·(g_nk·x_k) → systematic gradient interaction P('g_k') * X('x_k * g')
20
+ σ_k·(η_nk·x_k) → Normal random coefficient mixtures.append(Normal('c_k','s_k'))
21
+ random_gamma=True adds τ_k·z_nk·g_nk, i.e. promoting the γ_k loadings to
22
+ random coefficients too (Normal mixtures on the g_k params in the Larch build).
23
+ See stage5_destination_choice._estimate_mixed_logit for the mirrored build.
24
+ """
25
+
26
+ import numpy as np
27
+ import jax.numpy as jnp
28
+ import jax
29
+
30
+ from SearchLibrium.MixedLogit import MixedLogit
31
+
32
+ try:
33
+ from ._device import device as dev
34
+ except ImportError:
35
+ from _device import device as dev
36
+
37
+
38
+ class MixedLogitGSE(MixedLogit):
39
+ """Mixed Logit with Gradient Score Enhanced latent loadings."""
40
+
41
+ def __init__(self, *args, **kwargs):
42
+ super().__init__(*args, **kwargs)
43
+ self.Kgrad = 0
44
+ self.Kgrad_w = 0
45
+ self.gamma_draws = None
46
+
47
+ def setup(self, *args, gradient_scores=None, random_gamma=False, **kwargs):
48
+ self.random_gamma = bool(random_gamma)
49
+ if gradient_scores is not None:
50
+ # Standardise each gradient column to mean 0 / std 1 (O(1)) so the
51
+ # γ loadings are on a common scale and can't numerically overflow.
52
+ # Centering matters: an uncentred score folds a spurious γ·mean/std
53
+ # shift into μ; centring keeps μ interpretable as the population mean.
54
+ # A constant (zero-variance) column would divide by ~0 and blow up to
55
+ # ~1e8 — guard it by leaving such columns at 0 (they carry no gradient
56
+ # information anyway).
57
+ g = np.asarray(gradient_scores, dtype=float)
58
+ gmu = g.mean(axis=0, keepdims=True)
59
+ gsd = g.std(axis=0, keepdims=True)
60
+ gsd_safe = np.where(gsd < 1e-8, 1.0, gsd)
61
+ self.gradient_scores = (g - gmu) / gsd_safe
62
+ n_const = int(np.sum(gsd < 1e-8))
63
+ print(f"[MixedLogitGSE] Gradient loadings enabled (Kgrad={len(kwargs.get('randvars', {}))}, "
64
+ f"random_gamma={self.random_gamma}, "
65
+ f"raw std range=[{float(gsd.min()):.2f}, {float(gsd.max()):.2f}] -> "
66
+ f"centred+standardised"
67
+ + (f", {n_const} constant column(s) zeroed" if n_const else "") + ")")
68
+ else:
69
+ self.gradient_scores = None
70
+ super().setup(*args, **kwargs)
71
+
72
+ # ── Hook overrides ─────────────────────────────────────────────
73
+
74
+ def _n_coeff_extra(self) -> int:
75
+ return self.Kgrad + self.Kgrad_w
76
+
77
+ def _bound_extra(self) -> dict:
78
+ inf = float("inf")
79
+ return {"grad": ((-inf, inf), self.Kgrad),
80
+ "grad_w": ((0, inf), self.Kgrad_w)}
81
+
82
+ def _beta_segment_extra(self):
83
+ return (["grad", "grad_w"], [self.Kgrad, self.Kgrad_w])
84
+
85
+ def _jax_cache_key_extra(self):
86
+ return (self.Kgrad, self.Kgrad_w)
87
+
88
+ def _init_pad_arrays(self):
89
+ pads = []
90
+ if self.Kgrad > 0:
91
+ pads.append(np.repeat(0.1, self.Kgrad))
92
+ if self.Kgrad_w > 0:
93
+ pads.append(np.repeat(0.1, self.Kgrad_w))
94
+ return pads
95
+
96
+ def _jax_negloglik_extra_kwargs(self):
97
+ gd = (jnp.array(self.gamma_draws) if self.Kgrad_w > 0
98
+ and self.gamma_draws is not None else None)
99
+ gs = (jnp.array(self.gradient_scores) if self.gradient_scores is not None
100
+ else None)
101
+ return dict(Kgrad=self.Kgrad, Kgrad_w=self.Kgrad_w,
102
+ gradient_scores=gs, gamma_draws=gd)
103
+
104
+ # ── Fit override ───────────────────────────────────────────────
105
+
106
+ def fit(self):
107
+ if self.gradient_scores is not None:
108
+ self.Kgrad = self.Kr
109
+ self.Kgrad_w = self.Kr if self.random_gamma else 0
110
+ if self.Kgrad_w > 0:
111
+ # Seed the random-γ (τ·z) draws so a fit is reproducible across
112
+ # runs — unseeded np.random.randn made every fit non-deterministic
113
+ # (and non-comparable in a search). Reuse the model's own seed
114
+ # when set, mixing in Kgrad so it differs from the η draws' seed.
115
+ _seed = getattr(self, "seed", None)
116
+ rng = np.random.default_rng(None if _seed is None
117
+ else int(_seed) + 10007 * self.Kgrad)
118
+ self.gamma_draws = rng.standard_normal((self.N, self.Kgrad))
119
+ # Force JAX-only (scipy fallback doesn't handle gradient loadings)
120
+ self._jax = True
121
+ super().fit()
122
+
123
+ # ── JAX loglik with GSE terms ──────────────────────────────────
124
+
125
+ @staticmethod
126
+ def _jax_mxl_negloglik(betas, X_jax, y_jax, panel_info_jax, draws_jax,
127
+ fxidx, rvidx, Kf, Kr, Kchol, Kbw, rvdist_names,
128
+ correlationLength,
129
+ Kgrad=0, Kgrad_w=0,
130
+ gradient_scores=None, gamma_draws=None,
131
+ **kwargs):
132
+ """GSE loglik: β_nk = μ_k + γ_k·g_nk + σ_k·η_nk."""
133
+ # Split
134
+ Bf = betas[:Kf]
135
+ Br_b = betas[Kf:Kf + Kr]
136
+ gamma_mu = betas[Kf + Kr:Kf + Kr + Kgrad] if Kgrad > 0 else jnp.array([])
137
+ gamma_w = (betas[Kf + Kr + Kgrad:Kf + Kr + Kgrad + Kgrad_w]
138
+ if Kgrad_w > 0 else jnp.array([]))
139
+ offset = Kf + Kr + Kgrad + Kgrad_w
140
+ chol_v = betas[offset:offset + Kchol]
141
+ Br_w = betas[offset + Kchol:offset + Kchol + Kbw]
142
+
143
+ # Cholesky
144
+ chol_mat = jnp.zeros((Kr, Kr))
145
+ idx = 0
146
+ for r in range(correlationLength):
147
+ for c in range(r + 1):
148
+ chol_mat = chol_mat.at[r, c].set(chol_v[idx]); idx += 1
149
+ for k in range(Kbw):
150
+ chol_mat = chol_mat.at[correlationLength + k,
151
+ correlationLength + k].set(jnp.abs(Br_w[k]))
152
+
153
+ # Random coefficients
154
+ N = X_jax.shape[0]
155
+ Br = Br_b[:, None] + jnp.einsum("kl,nlr->nkr", chol_mat, draws_jax[:, :Kr, :])
156
+
157
+ # GSE gradient-latent term (standardised scores, O(1))
158
+ if Kgrad > 0 and gradient_scores is not None:
159
+ if Kgrad_w > 0 and gamma_draws is not None:
160
+ gamma_rnd = gamma_mu[None, :] + gamma_w[None, :] * gamma_draws
161
+ else:
162
+ gamma_rnd = gamma_mu[None, :]
163
+ Br = Br + gamma_rnd[:, :, None] * gradient_scores[:, :, None]
164
+
165
+ # Distribution transforms
166
+ for k, dist in enumerate(rvdist_names):
167
+ if dist == "ln":
168
+ Br = Br.at[:, k, :].set(jnp.exp(Br[:, k, :]))
169
+ elif dist == "tn":
170
+ Br = Br.at[:, k, :].set(jnp.abs(Br[:, k, :]))
171
+ elif dist == "u":
172
+ Br = Br.at[:, k, :].set(Br_b[k] + Br_w[k] * (draws_jax[:, k, :] - 0.5))
173
+
174
+ # Utility
175
+ Xr = X_jax[:, :, :, rvidx]
176
+ if Kf > 0:
177
+ Xf = X_jax[:, :, :, fxidx]
178
+ V = (jnp.einsum("npjk,k->npj", Xf, Bf)[:, :, :, None]
179
+ + jnp.einsum("npjk,nkr->npjr", Xr, Br))
180
+ else:
181
+ V = jnp.einsum("npjk,nkr->npjr", Xr, Br)
182
+
183
+ V = V - jnp.max(V, axis=2, keepdims=True)
184
+ eV = jnp.exp(V)
185
+ p = eV / jnp.sum(eV, axis=2, keepdims=True)
186
+
187
+ pch = jnp.sum(y_jax[:, :, :, None] * p, axis=2)
188
+ pch = jnp.prod(pch, axis=1)
189
+ pch = jnp.clip(pch, 1e-300, None)
190
+ sim_p = jnp.mean(pch, axis=1)
191
+ sim_p = jnp.clip(sim_p, 1e-300, None)
192
+ return -jnp.sum(jnp.log(sim_p))
193
+
194
+ # ── Scipy compute_probabilities override ────────────────────────
195
+
196
+ def compute_probabilities(self, betas, X, panel_info, draws, drawstrans,
197
+ avail, var_list, chol_mat):
198
+ """GSE override: add gradient-latent term to Br."""
199
+ vals = list(var_list.values())
200
+ Bf = vals[0]
201
+ Br_b = vals[1]
202
+ # GSE segments sit between Br_b and chol/Br_w
203
+ has_gse = len(vals) > 9
204
+ gamma = vals[2] if has_gse else np.array([])
205
+ gamma_w = vals[3] if has_gse else np.array([])
206
+ chol_v = vals[4] if has_gse else vals[2]
207
+ Br_w = vals[5] if has_gse else vals[3]
208
+ Bftrans = vals[6] if has_gse else vals[4] if len(vals) > 4 else np.array([])
209
+ flmbda = vals[7] if has_gse else vals[5] if len(vals) > 5 else np.array([])
210
+
211
+ if dev.using_gpu:
212
+ Bf = dev.convert_array_gpu(Bf)
213
+ Br_b = dev.convert_array_gpu(Br_b)
214
+ Br_w = dev.convert_array_gpu(Br_w)
215
+ if len(Bftrans) > 0: Bftrans = dev.convert_array_gpu(Bftrans)
216
+ if len(flmbda) > 0: flmbda = dev.convert_array_gpu(flmbda)
217
+ if len(gamma) > 0:
218
+ gamma = dev.convert_array_gpu(gamma)
219
+ gamma_w = dev.convert_array_gpu(gamma_w)
220
+
221
+ XBf = np.zeros((self.N, self.P, self.J))
222
+ if dev.using_gpu: XBf = dev.convert_array_gpu(XBf)
223
+
224
+ if self.Kf != 0:
225
+ Xf = X[:, :, :, self.fxidx]
226
+ XBf = dev.cust_einsum("npjk,k -> npj", Xf, Bf).astype(float)
227
+
228
+ XBr = np.zeros((self.N, self.P, self.J, draws.shape[2]))
229
+ if self.Kr != 0:
230
+ tmp = dev.np.matmul(chol_mat[:self.Kr, :self.Kr], draws)
231
+ Br = Br_b[None, :, None] + tmp
232
+
233
+ if self.Kgrad > 0 and self.gradient_scores is not None:
234
+ if (self.Kgrad_w > 0 and self.gamma_draws is not None
235
+ and len(gamma_w) > 0):
236
+ g_rnd = gamma[None, :] + gamma_w[None, :] * self.gamma_draws
237
+ else:
238
+ g_rnd = gamma[None, :] if len(gamma) > 0 else np.zeros((1, self.Kr))
239
+ Br = Br + g_rnd[:, :, None] * self.gradient_scores[:, :, None]
240
+
241
+ Br = self.draws_generator.apply_distribution(Br, self.rvdist)
242
+ self.Br = Br
243
+ Xr = X[:, :, :, self.rvidx].astype(float)
244
+ XBr = dev.cust_einsum("npjk,nkr -> npjr", Xr, Br)
245
+
246
+ V = XBf[:, :, :, None] + XBr if self.Kf != 0 else XBr
247
+ eV = np.exp(np.clip(V, -700, 700))
248
+ probs = eV / np.clip(eV.sum(axis=2, keepdims=True), 1e-300, None)
249
+ return probs
@@ -106,6 +106,7 @@ try:
106
106
  from .latent_class import LatentClassMixedLogit
107
107
  from .mdcev import MDCEVFitResult, MDCEVModel
108
108
  from .multinomial_probit import MultinomialProbit
109
+ from .MixedLogitGSE import MixedLogitGSE
109
110
  from .RandomP import RandomParameters
110
111
  from .constraints_builder import ConstraintBuilder, create_constraints
111
112
  from .search import Parameters
@@ -131,6 +132,7 @@ except ImportError as e:
131
132
  from latent_class import LatentClassMixedLogit
132
133
  from mdcev import MDCEVFitResult, MDCEVModel
133
134
  from multinomial_probit import MultinomialProbit
135
+ from MixedLogitGSE import MixedLogitGSE
134
136
  from RandomP import RandomParameters
135
137
  from constraints_builder import ConstraintBuilder, create_constraints
136
138
  from search import Parameters
@@ -135,6 +135,79 @@ class ConstraintBuilder:
135
135
  self._print(f"Mutually exclusive group: {variables}")
136
136
  return self
137
137
 
138
+ # ========================
139
+ # Alternative-Specific Variable Constraints
140
+ # ========================
141
+
142
+ def asvar_alt_spec(self, variable: str, alts: List[str]) -> 'ConstraintBuilder':
143
+ """
144
+ Break alternative-specific variables into subsets so they don't all
145
+ apply uniformly to the model. For each variable, creates
146
+ alternative-specific dummy columns only for the given *alts*, while
147
+ keeping the original variable for the remaining alternatives as a
148
+ generic coefficient. The search will then select among these
149
+ alt-specific columns as part of the variable search.
150
+
151
+ Parameters
152
+ ----------
153
+ variable : str
154
+ Base variable name (e.g. 'TIME', 'COST').
155
+ alts : list of str
156
+ Alternatives for which alt-specific coefficients should be
157
+ created (e.g. ['Car', 'Train']). Other alternatives share a
158
+ single generic coefficient.
159
+
160
+ Returns
161
+ -------
162
+ ConstraintBuilder
163
+ Self for method chaining
164
+
165
+ Example
166
+ -------
167
+ >>> constraints.asvar_alt_spec('TIME', ['Car', 'Train'])
168
+ >>> constraints.asvar_alt_spec('COST', ['Bus', 'Car'])
169
+
170
+ This creates ``TIME_Car``, ``TIME_Train``, ``COST_Bus``, ``COST_Car``
171
+ in the active variable pool while keeping ``TIME`` and ``COST`` as
172
+ generic columns for the remaining alternatives.
173
+ """
174
+ if 'asvar_alt_spec' not in self._constraints:
175
+ self._constraints['asvar_alt_spec'] = {}
176
+ self._constraints['asvar_alt_spec'][variable] = list(alts)
177
+ self._print(f"Alt-specific subset: '{variable}' → {alts}")
178
+ return self
179
+
180
+ def incompatible_specs(self, *alt_var_names: str) -> 'ConstraintBuilder':
181
+ """
182
+ Declare that certain alternative-specific variable names (e.g.,
183
+ ``TIME_Car`` and ``COST_Car``) are incompatible and must not appear
184
+ together in the same model. Each call creates one exclusion group.
185
+
186
+ This is applied **after** the alternative-specific dummies are
187
+ created, so you can declare constraints on the resulting column
188
+ names.
189
+
190
+ Parameters
191
+ ----------
192
+ *alt_var_names : str
193
+ Alt-specific column names forming a mutually exclusive group.
194
+
195
+ Returns
196
+ -------
197
+ ConstraintBuilder
198
+ Self for method chaining
199
+
200
+ Example
201
+ -------
202
+ >>> constraints.incompatible_specs('TIME_Car', 'COST_Car')
203
+ >>> constraints.incompatible_specs('FARE_Train', 'TIME_Train')
204
+ """
205
+ if 'incompatible_specs' not in self._constraints:
206
+ self._constraints['incompatible_specs'] = []
207
+ self._constraints['incompatible_specs'].append(list(alt_var_names))
208
+ self._print(f"Incompatible alt-specific group: {alt_var_names}")
209
+ return self
210
+
138
211
  def min_behavioral(self, min_count: int, *variables: str) -> 'ConstraintBuilder':
139
212
  """
140
213
  Require at least ``min_count`` variables from a behavioural pool
@@ -949,10 +949,11 @@ class HarmonySearch(Search):
949
949
  """Identify groups of variables that measure similar constructs
950
950
  and can be substituted for one another."""
951
951
  groups = []
952
- # Group 1: destination size measures
952
+ # Group 1: destination size measures (now includes per-type ACDC)
953
953
  size_measures = [v for v in asvars
954
954
  if v in {'ln_dest_trips', 'osm_destination_pull',
955
- 'acdc_aggl', 'osm_poi_intensity'}]
955
+ 'acdc_aggl', 'osm_poi_intensity'}
956
+ or v.startswith('acdc_aggl_')]
956
957
  for i in range(len(size_measures)):
957
958
  for j in range(i + 1, len(size_measures)):
958
959
  groups.append((size_measures[i], size_measures[j]))
@@ -972,6 +973,25 @@ class HarmonySearch(Search):
972
973
  for j in range(i + 1, len(osm_measures)):
973
974
  groups.append((osm_measures[i], osm_measures[j]))
974
975
 
976
+ # Group 4: Per-type ACDC agglomeration terms (substitutable by OSM type)
977
+ per_type_aggl = [v for v in asvars if v.startswith('acdc_aggl_')]
978
+ for i in range(len(per_type_aggl)):
979
+ for j in range(i + 1, len(per_type_aggl)):
980
+ groups.append((per_type_aggl[i], per_type_aggl[j]))
981
+
982
+ # Group 5: Per-type ACDC competition terms (substitutable by OSM type)
983
+ per_type_comp = [v for v in asvars if v.startswith('acdc_comp_')]
984
+ for i in range(len(per_type_comp)):
985
+ for j in range(i + 1, len(per_type_comp)):
986
+ groups.append((per_type_comp[i], per_type_comp[j]))
987
+
988
+ # Group 6: Cross-substitution — agglomeration <-> competition for same type
989
+ for t in ['retail', 'food', 'leisure', 'parks', 'transit', 'cycleway', 'culture']:
990
+ aggl_var = f'acdc_aggl_{t}'
991
+ comp_var = f'acdc_comp_{t}'
992
+ if aggl_var in asvars and comp_var in asvars:
993
+ groups.append((aggl_var, comp_var))
994
+
975
995
  return groups
976
996
 
977
997
  def _get_forced_vars(self):