SearchLibrium 0.0.241__tar.gz → 0.0.243__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 (79) hide show
  1. {searchlibrium-0.0.241/src/SearchLibrium.egg-info → searchlibrium-0.0.243}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/MixedLogit.py +12 -3
  4. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/MixedLogitGSE.py +13 -1
  5. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/_choice_model.py +69 -5
  6. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/latent_class.py +211 -4
  7. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/logistic_regression.py +1 -1
  8. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mixed_nested.py +19 -7
  9. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mixedrrm.py +11 -1
  10. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/multinomial_logit.py +1 -1
  11. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/multinomial_nested.py +47 -35
  12. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/multinomial_probit.py +1 -1
  13. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/numba_engine.py +82 -0
  14. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/ordered_logit.py +150 -83
  15. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/rrm.py +11 -1
  16. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/search.py +17 -4
  17. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/selection_models.py +2 -2
  18. searchlibrium-0.0.243/src/SearchLibrium/version.txt +1 -0
  19. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/zero_inflated_ordered_probit.py +1 -1
  20. {searchlibrium-0.0.241 → searchlibrium-0.0.243/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  21. searchlibrium-0.0.241/src/SearchLibrium/version.txt +0 -1
  22. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/LICENSE +0 -0
  23. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/MANIFEST.in +0 -0
  24. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/README.md +0 -0
  25. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/setup.cfg +0 -0
  26. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/BEHier.py +0 -0
  27. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/Halton.py +0 -0
  28. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  29. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/RandomP.py +0 -0
  30. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  31. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  32. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/__init__.py +0 -0
  33. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/__main__.py +0 -0
  34. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/_device.py +0 -0
  35. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/abm.py +0 -0
  36. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/accessibility.py +0 -0
  37. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/banditsa.py +0 -0
  38. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/boxcox_functions.py +0 -0
  39. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/call_meta.py +0 -0
  40. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/constraints_builder.py +0 -0
  41. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  42. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/data/TravelMode.csv +0 -0
  43. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/data/electricity.csv +0 -0
  44. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/harmony.py +0 -0
  45. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/hspbil.py +0 -0
  46. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/jax_utils.py +0 -0
  47. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/larch_compat.py +0 -0
  48. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/larch_models.py +0 -0
  49. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/latent_spec.py +0 -0
  50. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/main.py +0 -0
  51. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/main_debug.py +0 -0
  52. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mdcev.py +0 -0
  53. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/misc.py +0 -0
  54. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mixed_logit.py +0 -0
  55. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/numba_mxl.py +0 -0
  56. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/numba_rrm.py +0 -0
  57. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  58. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/predict.py +0 -0
  59. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/regularization.py +0 -0
  60. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/sample_data.py +0 -0
  61. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/sapbil.py +0 -0
  62. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/setup.py +0 -0
  63. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/siman.py +0 -0
  64. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/skims.py +0 -0
  65. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/sparseea_agds.py +0 -0
  66. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/test_lc_de.py +0 -0
  67. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/test_mario_searches.py +0 -0
  68. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  69. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/threshold.py +0 -0
  70. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  71. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  72. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  73. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/requires.txt +0 -0
  74. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  75. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_jax_mask_equivalence.py +0 -0
  76. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_larch_integration.py +0 -0
  77. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_lc_smart_init.py +0 -0
  78. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_per_alternative_random.py +0 -0
  79. {searchlibrium-0.0.241 → searchlibrium-0.0.243}/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.241
3
+ Version: 0.0.243
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -129,7 +129,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
129
129
  realpython = "SearchLibrium.__main__:main"
130
130
 
131
131
  [tool.bumpver]
132
- current_version = "0.0.241"
132
+ current_version = "0.0.243"
133
133
  version_pattern = "MAJOR.MINOR.PATCH"
134
134
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
135
135
  commit = true
@@ -63,7 +63,7 @@ def _panel_weighted_shares(prob_np, panel_info):
63
63
  return ind, np.mean(ind, axis=0)
64
64
 
65
65
  class MixedLogit(DiscreteChoiceModel):
66
- def __init__(self, halton_opts=None, distributions=['n', 'ln', 'nln', 't', 'tn', 'u'], _jax=True):
66
+ def __init__(self, halton_opts=None, distributions=['n', 'ln', 'nln', 't', 'tn', 'u'], _jax=None):
67
67
  super().__init__(_jax)
68
68
  self.descr = "MXL"
69
69
  self.halton_opts = halton_opts
@@ -713,8 +713,17 @@ class MixedLogit(DiscreteChoiceModel):
713
713
 
714
714
  # Optional numba engine (first-class; the T4 minimise_func patch is
715
715
  # the equivalent for older installs). Resolved here so the JAX fast
716
- # path below is skipped when numba is selected.
717
- use_numba = (getattr(self, 'engine', None) == 'numba')
716
+ # path below is skipped when numba is selected. An explicit
717
+ # model.engine wins; otherwise the global default (SL_ENGINE env var
718
+ # or numba_engine.set_default_engine) applies.
719
+ try:
720
+ try:
721
+ from numba_engine import effective_engine as _mx_eff_eng
722
+ except ImportError:
723
+ from .numba_engine import effective_engine as _mx_eff_eng
724
+ use_numba = (_mx_eff_eng(self) == 'numba')
725
+ except Exception:
726
+ use_numba = (getattr(self, 'engine', None) == 'numba')
718
727
 
719
728
  # 2x Kftrans - mean and lambda, 3x Krtrans - mean, s.d., lambda
720
729
  # Kchol, Kbw - relate to random variables, non-transformed
@@ -116,7 +116,19 @@ class MixedLogitGSE(MixedLogit):
116
116
  rng = np.random.default_rng(None if _seed is None
117
117
  else int(_seed) + 10007 * self.Kgrad)
118
118
  self.gamma_draws = rng.standard_normal((self.N, self.Kgrad))
119
- # Force JAX-only (scipy fallback doesn't handle gradient loadings)
119
+ # Force JAX-only (scipy fallback doesn't handle gradient loadings,
120
+ # and there is no numba port for the gradient-latent terms yet).
121
+ # If numba is the global default, say so instead of silently using JAX.
122
+ try:
123
+ try:
124
+ from numba_engine import numba_as_default as _gse_nad
125
+ except ImportError:
126
+ from .numba_engine import numba_as_default as _gse_nad
127
+ if bool(_gse_nad()):
128
+ print("[MixedLogitGSE] gradient loadings require JAX; ignoring "
129
+ "the global numba default for this fit.")
130
+ except Exception:
131
+ pass
120
132
  self._jax = True
121
133
  super().fit()
122
134
 
@@ -67,7 +67,12 @@ from time import time
67
67
  import numpy as np
68
68
  import pandas as pd
69
69
  import scipy.stats as ss
70
- import jax.numpy as jnp
70
+ try:
71
+ import jax.numpy as jnp
72
+ JAX_AVAILABLE = True
73
+ except ImportError: # pragma: no cover - jax is required; guard is import robustness only
74
+ jnp = None
75
+ JAX_AVAILABLE = False
71
76
 
72
77
 
73
78
 
@@ -87,8 +92,65 @@ except ImportError:
87
92
  # A dictionary to manage backends
88
93
  BACKENDS = {
89
94
  "numpy": np,
90
- "jax": jnp,
91
95
  }
96
+ if JAX_AVAILABLE:
97
+ BACKENDS["jax"] = jnp
98
+
99
+ _JAX_MISSING_WARNED = False
100
+
101
+
102
+ def _warn_jax_missing():
103
+ """Warn once that JAX was requested but is not installed."""
104
+ global _JAX_MISSING_WARNED
105
+ if not _JAX_MISSING_WARNED:
106
+ _JAX_MISSING_WARNED = True
107
+ warnings.warn(
108
+ "JAX was requested but could not be imported; falling back to "
109
+ "the numpy/scipy backend. JAX is a required dependency — check "
110
+ "your installation.",
111
+ ImportWarning, stacklevel=3,
112
+ )
113
+
114
+
115
+ def _numba_is_global_default():
116
+ """True when the process-wide default engine is numba (SL_ENGINE/env)."""
117
+ try:
118
+ try:
119
+ from .numba_engine import numba_as_default as _nad
120
+ except ImportError:
121
+ try:
122
+ from numba_engine import numba_as_default as _nad
123
+ except ImportError:
124
+ _nad = None
125
+ if _nad is not None:
126
+ return bool(_nad())
127
+ except Exception:
128
+ pass
129
+ try:
130
+ import os as _os
131
+ return str(_os.environ.get('SL_ENGINE', '')).strip().lower() == 'numba'
132
+ except Exception:
133
+ return False
134
+
135
+
136
+ def resolve_jax_backend(requested=None):
137
+ """Resolve the array backend for ``_jax=<requested>``.
138
+
139
+ Returns ``(use_jax, backend_module)``. ``None`` (the default) follows
140
+ the global default engine: ``False``/numpy when numba is the global
141
+ default (``SL_ENGINE=numba`` or ``numba_engine.set_default_engine``),
142
+ ``True``/jax.numpy otherwise (legacy JAX-first behaviour). Explicit
143
+ ``True``/``False`` is honoured verbatim. When JAX is requested but not
144
+ installed this degrades to ``(False, numpy)`` with a one-time warning
145
+ instead of raising.
146
+ """
147
+ if requested is None:
148
+ requested = not _numba_is_global_default()
149
+ if requested and JAX_AVAILABLE:
150
+ return True, jnp
151
+ if requested:
152
+ _warn_jax_missing()
153
+ return False, np
92
154
 
93
155
  # Utility function to select the backend
94
156
  def get_backend(jax=True):
@@ -159,8 +221,11 @@ class DiscreteChoiceModel(ABC):
159
221
  ''' ---------------------------------------------------------- '''
160
222
  ''' Function '''
161
223
  ''' ---------------------------------------------------------- '''
162
- def __init__(self, jax = True):
224
+ def __init__(self, jax = None):
163
225
  # {
226
+ # jax=None follows the global default engine (JAX-first unless
227
+ # SL_ENGINE=numba / set_default_engine('numba')); explicit
228
+ # True/False is honoured verbatim.
164
229
 
165
230
  self.reset_attributes()
166
231
  self.fit_intercept = False
@@ -202,8 +267,7 @@ class DiscreteChoiceModel(ABC):
202
267
  self.X_original, self.y_original = [], []
203
268
  self.weights, self.avail = [], []
204
269
  self.init_coeff = []
205
- self._jax = jax
206
- self.backend = get_backend(jax)
270
+ self._jax, self.backend = resolve_jax_backend(jax)
207
271
  self.descr = ""
208
272
  # }
209
273
 
@@ -32,6 +32,103 @@ def _sig_stars(pv: float) -> str:
32
32
  return ""
33
33
 
34
34
 
35
+ # ---------------------------------------------------------------------------
36
+ # Numba engine: fused weighted-MNL (value, grad) for the EM M-steps.
37
+ # ---------------------------------------------------------------------------
38
+ # The numpy path allocates several (N, J)/(N, J, K) temporaries per objective
39
+ # evaluation; the numba kernel fuses the whole case loop into one pass with
40
+ # O(J*K) stack temporaries. Numerics mirror the numpy branch exactly
41
+ # (max-subtraction, 1e-300 clipping, L1/L2/sd penalties). Compiled once and
42
+ # cached at module level; all dtypes are float64.
43
+
44
+ _NB_MNL_OBJ = {"fn": None}
45
+
46
+
47
+ def _numba_weighted_mnl():
48
+ """Return the cached njit weighted-MNL objective, or raise."""
49
+ if _NB_MNL_OBJ["fn"] is not None:
50
+ return _NB_MNL_OBJ["fn"]
51
+ try:
52
+ import numba as _nb
53
+ except Exception as exc:
54
+ raise RuntimeError(
55
+ "engine='numba' requested but numba is not importable. "
56
+ "Install numba or use engine='auto'/'jax'/'numpy'.") from exc
57
+
58
+ @_nb.njit(cache=True)
59
+ def _obj(beta, Xc, y, w, avail, l2, l1, sd_pen, sd_k):
60
+ n_obs = Xc.shape[0]
61
+ n_alts = Xc.shape[1]
62
+ n_par = Xc.shape[2]
63
+ ll = 0.0
64
+ grad = np.zeros(n_par, dtype=np.float64)
65
+ U = np.empty(n_alts, dtype=np.float64)
66
+ E = np.empty(n_alts, dtype=np.float64)
67
+ for n in range(n_obs):
68
+ wn = w[n]
69
+ umax = -1e300
70
+ for j in range(n_alts):
71
+ if avail[n, j] > 0.0:
72
+ uj = 0.0
73
+ for k in range(n_par):
74
+ uj += Xc[n, j, k] * beta[k]
75
+ U[j] = uj
76
+ if uj > umax:
77
+ umax = uj
78
+ else:
79
+ U[j] = -1e300
80
+ s = 0.0
81
+ for j in range(n_alts):
82
+ if avail[n, j] > 0.0:
83
+ ej = np.exp(U[j] - umax)
84
+ E[j] = ej
85
+ s += ej
86
+ else:
87
+ E[j] = 0.0
88
+ if s <= 0.0:
89
+ continue # no available alternative: skip (numpy path yields p=0)
90
+ for j in range(n_alts):
91
+ if avail[n, j] <= 0.0:
92
+ continue
93
+ p = E[j] / s
94
+ yij = y[n, j]
95
+ if yij > 0.0:
96
+ cp = p if p > 1e-300 else 1e-300
97
+ ll += wn * np.log(cp)
98
+ d = (yij - p) * wn
99
+ if d != 0.0:
100
+ for k in range(n_par):
101
+ grad[k] += d * Xc[n, j, k]
102
+ s2 = 0.0
103
+ s1 = 0.0
104
+ for k in range(n_par):
105
+ s2 += beta[k] * beta[k]
106
+ ak = beta[k] if beta[k] >= 0.0 else -beta[k]
107
+ s1 += ak
108
+ pen = l2 * s2 + l1 * s1
109
+ if sd_pen > 0.0 and sd_k > 0 and sd_k <= n_par:
110
+ sds = 0.0
111
+ for k in range(n_par - sd_k, n_par):
112
+ sds += beta[k] * beta[k]
113
+ pen += sd_pen * sds
114
+ value = -ll + pen
115
+ for k in range(n_par):
116
+ b = beta[k]
117
+ g = -grad[k] + 2.0 * l2 * b
118
+ if b > 0.0:
119
+ g += l1
120
+ elif b < 0.0:
121
+ g -= l1
122
+ grad[k] = g
123
+ if sd_pen > 0.0 and sd_k > 0 and sd_k <= n_par:
124
+ for k in range(n_par - sd_k, n_par):
125
+ grad[k] += 2.0 * sd_pen * beta[k]
126
+ return value, grad
127
+
128
+ _NB_MNL_OBJ["fn"] = _obj
129
+ return _obj
130
+
131
+
35
132
  class LatentClassMixedLogit(DiscreteChoiceModel):
36
133
  """Fast latent-class discrete choice model with optional JAX acceleration.
37
134
 
@@ -47,7 +144,7 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
47
144
  class_maxiter=50,
48
145
  tol=1e-6,
49
146
  random_state=0,
50
- _jax=True,
147
+ _jax=None,
51
148
  n_init=1,
52
149
  optimise_membership=True,
53
150
  membership_maxiter=50,
@@ -60,6 +157,7 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
60
157
  gamma_max_abs=None,
61
158
  share_floor=0.0,
62
159
  membership_standardize=False,
160
+ engine="auto",
63
161
  ):
64
162
  self.n_classes = int(n_classes)
65
163
  self.maxiter = int(maxiter)
@@ -99,6 +197,17 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
99
197
  self.membership_standardize = bool(membership_standardize)
100
198
  self._memb_standardize_ = None
101
199
  self._memb_floor_shrinks_ = 0
200
+ # ── compute engine for the EM weighted M-steps ───────────────
201
+ # "auto" (default): JAX when available, else numpy/scipy.
202
+ # "jax" / "numba" / "numpy": force that backend (numba falls back
203
+ # with a warning when not importable). search() forwards **kwargs,
204
+ # so engine="numba" works there too.
205
+ eng = str(engine or "auto").strip().lower()
206
+ if eng not in ("auto", "jax", "numba", "numpy"):
207
+ warnings.warn(f"Unknown engine={engine!r}; using 'auto'.")
208
+ eng = "auto"
209
+ self.engine = eng
210
+ self._backend = None # resolved lazily by _resolve_backend()
102
211
  # Diagnostics populated by fit()/fit_direct().
103
212
  self.starts_ = []
104
213
  self.sort_perm_ = None
@@ -124,9 +233,24 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
124
233
  self.total_iter = 0
125
234
  self.num_params = None
126
235
  self.search_results = None
127
- self._jax = bool(_jax)
128
236
  self._jax_enabled = False
129
237
 
238
+ if _jax is None:
239
+ # Follow the global default engine (SL_ENGINE=numba ->
240
+ # numpy/numba paths, no JAX); otherwise legacy JAX-first.
241
+ try:
242
+ from ._choice_model import resolve_jax_backend as _rjb
243
+ except ImportError:
244
+ try:
245
+ from _choice_model import resolve_jax_backend as _rjb
246
+ except ImportError:
247
+ _rjb = None
248
+ if _rjb is not None:
249
+ _jax = bool(_rjb(None)[0])
250
+ else:
251
+ _jax = True
252
+ self._jax = bool(_jax)
253
+
130
254
  if self._jax:
131
255
  try:
132
256
  import jax
@@ -703,6 +827,63 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
703
827
  setattr(self, cache_grad, self.jit(self.value_and_grad(objective)))
704
828
  return objective
705
829
 
830
+ def _numba_global_default(self) -> bool:
831
+ """True when the process-wide default engine is numba."""
832
+ try:
833
+ from .numba_engine import numba_as_default as _nad
834
+ except ImportError:
835
+ try:
836
+ from numba_engine import numba_as_default as _nad
837
+ except ImportError:
838
+ _nad = None
839
+ if _nad is not None:
840
+ try:
841
+ return bool(_nad())
842
+ except Exception:
843
+ return False
844
+ try:
845
+ import os as _os
846
+ return str(_os.environ.get('SL_ENGINE', '')).strip().lower() == 'numba'
847
+ except Exception:
848
+ return False
849
+
850
+ def _resolve_backend(self) -> str:
851
+ """Resolve the effective compute backend for EM M-steps.
852
+
853
+ Returns one of "jax" | "numba" | "numpy". Result is cached on
854
+ ``self._backend``; explicit ``engine="jax"/"numba"`` falls back with a
855
+ warning when the backend is unavailable.
856
+ """
857
+ if self._backend in ("jax", "numba", "numpy"):
858
+ return self._backend
859
+ eng = str(getattr(self, "engine", "auto") or "auto").lower()
860
+ if eng == "numba":
861
+ try:
862
+ import numba # noqa: F401
863
+ self._backend = "numba"
864
+ return self._backend
865
+ except Exception:
866
+ warnings.warn("engine='numba' requested but numba is not "
867
+ "importable; falling back.")
868
+ elif eng == "jax":
869
+ if self._jax_enabled:
870
+ self._backend = "jax"
871
+ return self._backend
872
+ warnings.warn("engine='jax' requested but JAX is unavailable; "
873
+ "falling back.")
874
+ if eng == "numpy":
875
+ self._backend = "numpy"
876
+ else: # "auto" or any fallback above
877
+ if self._numba_global_default():
878
+ try:
879
+ import numba # noqa: F401
880
+ self._backend = "numba"
881
+ return self._backend
882
+ except Exception:
883
+ pass
884
+ self._backend = "jax" if self._jax_enabled else "numpy"
885
+ return self._backend
886
+
706
887
  def _weighted_m_step(self, beta0, weights, class_idx=None):
707
888
  """Weighted M-step for a single class's betas."""
708
889
  weights = np.asarray(weights, dtype=float)
@@ -712,9 +893,35 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
712
893
  else:
713
894
  X_c = self.X
714
895
 
715
- use_jax = self._jax_enabled
896
+ use_jax = self._resolve_backend() == "jax"
897
+ use_numba = self._resolve_backend() == "numba"
898
+
899
+ if use_numba:
900
+ # Fused njit objective: one pass per evaluation, no (N,J,K)
901
+ # temporaries. Arrays are made contiguous once per M-step (not per
902
+ # iteration) since L-BFGS-B calls `objective` hundreds of times.
903
+ _Xc = np.ascontiguousarray(X_c, dtype=np.float64)
904
+ _y = np.ascontiguousarray(self.y, dtype=np.float64)
905
+ _w = np.ascontiguousarray(weights, dtype=np.float64)
906
+ _a = np.ascontiguousarray(self.avail, dtype=np.float64)
907
+ _sd_pen, _sd_k = 0.0, 0
908
+ if hasattr(self, 'sd_penalty') and self.sd_penalty > 0:
909
+ _kbw = getattr(self, '_Kbw', getattr(self, 'Kr', 0))
910
+ try:
911
+ _kbw = int(_kbw)
912
+ except Exception:
913
+ _kbw = 0
914
+ if _kbw > 0 and int(np.asarray(beta0).size) >= _kbw:
915
+ _sd_pen, _sd_k = float(self.sd_penalty), _kbw
916
+ _fn = _numba_weighted_mnl()
716
917
 
717
- if use_jax:
918
+ def objective(beta):
919
+ _bb = np.ascontiguousarray(np.asarray(beta, dtype=float))
920
+ _v, _g = _fn(_bb, _Xc, _y, _w, _a,
921
+ float(self.l2_penalty), float(self.l1_penalty),
922
+ _sd_pen, _sd_k)
923
+ return float(_v), np.asarray(_g, dtype=float)
924
+ elif use_jax:
718
925
  self._build_jax_weighted_objective(
719
926
  X_c if class_idx is not None else None,
720
927
  class_idx=class_idx
@@ -41,7 +41,7 @@ from .multinomial_logit import MultinomialLogit
41
41
  class LogisticRegression(MultinomialLogit):
42
42
  """Binomial logistic regression fitted by SearchLibrium's MNL estimator."""
43
43
 
44
- def __init__(self, _jax: bool = True, base_alt: int = 0, **kwargs):
44
+ def __init__(self, _jax=None, base_alt: int = 0, **kwargs):
45
45
  super().__init__(_jax=_jax)
46
46
  self.base_alt = int(base_alt) # reference alternative (0 = no event)
47
47
  self._varnames: Optional[list] = None
@@ -13,9 +13,8 @@ class MixedNested(MixedLogit, NestedLogit):
13
13
  Mixed Nested Logit Model.
14
14
  """
15
15
 
16
- def __init__(self, _jax=True):
16
+ def __init__(self, _jax=None):
17
17
  MixedLogit.__init__(self, _jax=_jax)
18
- self._jax = _jax
19
18
  self._set_backend(_jax)
20
19
  self.descr = "Mixed Nested Logit"
21
20
  self.nests = {}
@@ -25,12 +24,25 @@ class MixedNested(MixedLogit, NestedLogit):
25
24
  self.base_param_count = 0
26
25
 
27
26
  def _set_backend(self, use_jax):
27
+ if use_jax is None:
28
+ # Follow the global default engine (numba -> numpy backend).
29
+ try:
30
+ from ._choice_model import resolve_jax_backend
31
+ except ImportError:
32
+ from _choice_model import resolve_jax_backend
33
+ use_jax, _be = resolve_jax_backend(None)
34
+ self._jax = bool(use_jax)
28
35
  if use_jax:
29
- import jax
30
-
31
- jax.config.update("jax_enable_x64", True)
32
- import jax.numpy as jnp
33
-
36
+ try:
37
+ import jax
38
+ jax.config.update("jax_enable_x64", True)
39
+ import jax.numpy as jnp
40
+ except ImportError:
41
+ import numpy as _np_fallback
42
+ self._jax = False
43
+ self.np = _np_fallback
44
+ self.backend = _np_fallback
45
+ return
34
46
  self.np = jnp
35
47
  self.backend = jnp
36
48
  else:
@@ -413,8 +413,18 @@ class MixedRandomRegret(RandomRegret, MixedLogit):
413
413
  # RandomRegret.fit directly (NOT fit_jax: its fallback invokes
414
414
  # self.fit(), which would recurse back here via the MRO).
415
415
  return RandomRegret.fit(self)
416
+ # Engine resolution: explicit model.engine wins, otherwise the global
417
+ # default (SL_ENGINE env var or numba_engine.set_default_engine).
416
418
  try:
417
- if getattr(self, 'engine', None) == 'numba':
419
+ try:
420
+ from numba_engine import effective_engine as _mrrm_eff_eng
421
+ except ImportError:
422
+ from .numba_engine import effective_engine as _mrrm_eff_eng
423
+ _mrrm_use_numba = (_mrrm_eff_eng(self) == 'numba')
424
+ except Exception:
425
+ _mrrm_use_numba = (getattr(self, 'engine', None) == 'numba')
426
+ try:
427
+ if _mrrm_use_numba:
418
428
  try:
419
429
  from numba_rrm import fit_mrrm_numba
420
430
  except ImportError:
@@ -184,7 +184,7 @@ class MultinomialLogit(DiscreteChoiceModel):
184
184
  ''' --------------------------------- '''
185
185
  ''' Function. Constructor '''
186
186
  ''' --------------------------------- '''
187
- def __init__(self, _jax = True): # {
187
+ def __init__(self, _jax = None): # {
188
188
  super(MultinomialLogit, self).__init__(_jax) # Base class initialisations
189
189
  self.descr = "MNL"
190
190
  # }
@@ -12,30 +12,39 @@ class NestedLogit(MultinomialLogit):
12
12
  Handles nested structure of alternatives.
13
13
  """
14
14
 
15
- def __init__(self, _jax = True):
15
+ def __init__(self, _jax = None):
16
16
  super(NestedLogit, self).__init__(_jax)
17
17
  self.descr = "Nested Logit"
18
18
  self.robust = False
19
19
  self.robust_corr = None
20
20
 
21
- # Dynamically set the backend once during initialization
22
- if _jax:
23
- import jax
24
- jax.config.update("jax_enable_x64", True) #
25
- import jax.numpy as jnp
26
- from jax import grad, jacfwd, jit, lax, vmap
27
- from scipy.optimize import minimize
28
- self.np = jnp # Assign JAX's NumPy-like module
29
- self.jaxgrad = grad
30
- self.vmap = vmap
31
- self.lax = lax
32
- self.jacfwd = jacfwd
33
- self.jit = jit
34
- from jaxopt import ScipyMinimize
35
- from scipy.stats import norm
36
- self.jaxoptmin = ScipyMinimize
37
- self.minimize = minimize
38
- else:
21
+ # Dynamically set the backend once during initialization.
22
+ # self._jax is already resolved by the base class (None follows the
23
+ # global default engine: numba -> numpy backend, no JAX import).
24
+ if self._jax:
25
+ try:
26
+ import jax
27
+ jax.config.update("jax_enable_x64", True) #
28
+ import jax.numpy as jnp
29
+ from jax import grad, jacfwd, jit, lax, vmap
30
+ from scipy.optimize import minimize
31
+ self.np = jnp # Assign JAX's NumPy-like module
32
+ self.jaxgrad = grad
33
+ self.vmap = vmap
34
+ self.lax = lax
35
+ self.jacfwd = jacfwd
36
+ self.jit = jit
37
+ from jaxopt import ScipyMinimize
38
+ from scipy.stats import norm
39
+ self.jaxoptmin = ScipyMinimize
40
+ self.minimize = minimize
41
+ except ImportError:
42
+ import warnings as _warnings
43
+ _warnings.warn(
44
+ "JAX backend requested but jax/jaxopt is not importable; "
45
+ "falling back to the numpy backend.")
46
+ self._jax = False
47
+ if not self._jax:
39
48
  import numpy as np
40
49
  from scipy.optimize import minimize
41
50
  self.np = np # Assign standard NumPy
@@ -63,22 +72,25 @@ class NestedLogit(MultinomialLogit):
63
72
  self.__dict__.update(state)
64
73
  # Re-import the right modules based on _jax flag
65
74
  if getattr(self, "_jax", False):
66
- import jax
67
- jax.config.update("jax_enable_x64", True)
68
- import jax.numpy as jnp
69
- from jax import grad, jacfwd, jit, lax, vmap
70
- from jaxopt import ScipyMinimize
71
- from scipy.optimize import minimize
72
-
73
- self.np = jnp
74
- self.jaxgrad = grad
75
- self.vmap = vmap
76
- self.lax = lax
77
- self.jacfwd = jacfwd
78
- self.jit = jit
79
- self.jaxoptmin = ScipyMinimize
80
- self.minimize = minimize
81
- else:
75
+ try:
76
+ import jax
77
+ jax.config.update("jax_enable_x64", True)
78
+ import jax.numpy as jnp
79
+ from jax import grad, jacfwd, jit, lax, vmap
80
+ from jaxopt import ScipyMinimize
81
+ from scipy.optimize import minimize
82
+
83
+ self.np = jnp
84
+ self.jaxgrad = grad
85
+ self.vmap = vmap
86
+ self.lax = lax
87
+ self.jacfwd = jacfwd
88
+ self.jit = jit
89
+ self.jaxoptmin = ScipyMinimize
90
+ self.minimize = minimize
91
+ except ImportError:
92
+ self._jax = False
93
+ if not getattr(self, "_jax", False):
82
94
  import numpy as np
83
95
  from scipy.optimize import minimize
84
96
 
@@ -25,7 +25,7 @@ class MultinomialProbit(MultinomialLogit):
25
25
  softmax with a probit-style probability construction based on normal CDFs.
26
26
  """
27
27
 
28
- def __init__(self, _jax=True):
28
+ def __init__(self, _jax=None):
29
29
  super(MultinomialProbit, self).__init__(_jax)
30
30
  self.descr = "Multinomial Probit (pairwise normal approximation)"
31
31