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.
- {searchlibrium-0.0.241/src/SearchLibrium.egg-info → searchlibrium-0.0.243}/PKG-INFO +1 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/pyproject.toml +1 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/MixedLogit.py +12 -3
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/MixedLogitGSE.py +13 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/_choice_model.py +69 -5
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/latent_class.py +211 -4
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/logistic_regression.py +1 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mixed_nested.py +19 -7
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mixedrrm.py +11 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/multinomial_logit.py +1 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/multinomial_nested.py +47 -35
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/multinomial_probit.py +1 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/numba_engine.py +82 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/ordered_logit.py +150 -83
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/rrm.py +11 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/search.py +17 -4
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/selection_models.py +2 -2
- searchlibrium-0.0.243/src/SearchLibrium/version.txt +1 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/zero_inflated_ordered_probit.py +1 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
- searchlibrium-0.0.241/src/SearchLibrium/version.txt +0 -1
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/LICENSE +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/MANIFEST.in +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/README.md +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/setup.cfg +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/BEHier.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/Halton.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/RandomP.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/Two_Level_Nest.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/__init__.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/__main__.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/_device.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/abm.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/accessibility.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/banditsa.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/boxcox_functions.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/call_meta.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/constraints_builder.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/data/TravelMode.csv +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/data/electricity.csv +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/harmony.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/hspbil.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/jax_utils.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/larch_compat.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/larch_models.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/latent_spec.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/main.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/main_debug.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mdcev.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/misc.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/mixed_logit.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/numba_mxl.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/numba_rrm.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/predict.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/regularization.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/sample_data.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/sapbil.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/setup.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/siman.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/skims.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/sparseea_agds.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/test_lc_de.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/test_mario_searches.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium/threshold.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/requires.txt +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/src/SearchLibrium.egg-info/top_level.txt +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_jax_mask_equivalence.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_larch_integration.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_lc_smart_init.py +0 -0
- {searchlibrium-0.0.241 → searchlibrium-0.0.243}/tests/test_per_alternative_random.py +0 -0
- {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.
|
|
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.
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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=
|
|
28
|
+
def __init__(self, _jax=None):
|
|
29
29
|
super(MultinomialProbit, self).__init__(_jax)
|
|
30
30
|
self.descr = "Multinomial Probit (pairwise normal approximation)"
|
|
31
31
|
|