SearchLibrium 0.0.84__tar.gz → 0.0.86__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 (46) hide show
  1. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/__init__.py +11 -2
  4. searchlibrium-0.0.86/src/SearchLibrium/mdcev.py +484 -0
  5. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/siman.py +4 -7
  6. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/threshold.py +1 -1
  7. searchlibrium-0.0.86/src/SearchLibrium/version.txt +1 -0
  8. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/PKG-INFO +1 -1
  9. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
  10. searchlibrium-0.0.84/src/SearchLibrium/version.txt +0 -1
  11. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/README.md +0 -0
  12. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/setup.cfg +0 -0
  13. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/Halton.py +0 -0
  14. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/MixedLogit.py +0 -0
  15. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  16. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/RandomP.py +0 -0
  17. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  18. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  19. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/__main__.py +0 -0
  20. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/_choice_model.py +0 -0
  21. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/_device.py +0 -0
  22. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/bhhh/minimize.py +0 -0
  23. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/boxcox_functions.py +0 -0
  24. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/call_meta.py +0 -0
  25. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/constraints_builder.py +0 -0
  26. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/harmony.py +0 -0
  27. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/latent_class.py +0 -0
  28. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/main.py +0 -0
  29. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/main_debug.py +0 -0
  30. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/misc.py +0 -0
  31. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/mixed_logit.py +0 -0
  32. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/mixed_nested.py +0 -0
  33. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/mixedrrm.py +0 -0
  34. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/multinomial_logit.py +0 -0
  35. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/multinomial_nested.py +0 -0
  36. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/multinomial_probit.py +0 -0
  37. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/ordered_logit.py +0 -0
  38. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  39. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/rrm.py +0 -0
  40. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/search.py +0 -0
  41. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/selection_models.py +0 -0
  42. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/setup.py +0 -0
  43. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  44. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  45. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/requires.txt +0 -0
  46. {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.84
3
+ Version: 0.0.86
4
4
  Summary: A Python package for econometric models driven by search
5
5
  Author: Alexander Paz Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -59,7 +59,7 @@ Homepage = "https://github.com/zahern/HypothesisX"
59
59
  realpython = "SearchLibrium.__main__:main"
60
60
 
61
61
  [tool.bumpver]
62
- current_version = "0.0.84"
62
+ current_version = "0.0.86"
63
63
  version_pattern = "MAJOR.MINOR.PATCH"
64
64
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
65
65
  commit = true
@@ -55,7 +55,13 @@ def new_features():
55
55
  """)
56
56
 
57
57
  def get_version_from_pkg_info():
58
- """Reads the version from the PKG-INFO file."""
58
+ """Reads the installed package version via importlib.metadata."""
59
+ try:
60
+ from importlib.metadata import version as _pkg_version
61
+ return _pkg_version("SearchLibrium")
62
+ except Exception:
63
+ pass
64
+ # Fallback: read from egg-info PKG-INFO (editable installs)
59
65
  pkg_info_path = os.path.join(os.path.dirname(__file__), "../SearchLibrium.egg-info/PKG-INFO")
60
66
  try:
61
67
  with open(pkg_info_path, "r") as f:
@@ -63,7 +69,8 @@ def get_version_from_pkg_info():
63
69
  if line.startswith("Version:"):
64
70
  return line.split(":")[1].strip()
65
71
  except FileNotFoundError:
66
- return "0.0.32"
72
+ pass
73
+ return "unknown"
67
74
 
68
75
  __version__ = get_version_from_pkg_info()
69
76
 
@@ -88,6 +95,7 @@ try:
88
95
  from .ordered_logit import OrderedLogit, OrderedLogitLong
89
96
  from .selection_models import BinaryProbit, HeckmanTwoStep
90
97
  from .latent_class import LatentClassMixedLogit
98
+ from .mdcev import MDCEVFitResult, MDCEVModel
91
99
  from .multinomial_probit import MultinomialProbit
92
100
  from .RandomP import RandomParameters
93
101
  from .constraints_builder import ConstraintBuilder, create_constraints
@@ -106,6 +114,7 @@ except ImportError as e:
106
114
  from ordered_logit import OrderedLogit, OrderedLogitLong
107
115
  from selection_models import BinaryProbit, HeckmanTwoStep
108
116
  from latent_class import LatentClassMixedLogit
117
+ from mdcev import MDCEVFitResult, MDCEVModel
109
118
  from multinomial_probit import MultinomialProbit
110
119
  from RandomP import RandomParameters
111
120
  from constraints_builder import ConstraintBuilder, create_constraints
@@ -0,0 +1,484 @@
1
+ """MDCEV budget-allocation prototype for SearchLibrium.
2
+
3
+ This module implements a compact translated-utility MDCEV-style allocator for
4
+ continuous budget splits such as daily time-use or discretionary activity
5
+ budgets. The implementation is forecasting-oriented: it provides a stable
6
+ fitting heuristic from observed allocations together with an analytical
7
+ budget-allocation solver based on the translated utility first-order
8
+ conditions.
9
+
10
+ The class is intended as a practical bridge between the current scalar budget
11
+ models and a fuller MDCEV pipeline. It includes both a stable heuristic fit
12
+ and a JAX-accelerated quasi-MLE refinement with exact automatic differentiation.
13
+
14
+ JAX is used for:
15
+ - Bisection solver: JIT-compiled + vmapped over observations (fast predict/simulate)
16
+ - fit_mle gradients: exact autodiff via jax.grad instead of scipy FD approximations
17
+
18
+ A pure-numpy fallback is provided when JAX is not installed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os as _os
24
+ from dataclasses import dataclass
25
+ from typing import Iterable, Optional
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ from scipy.optimize import minimize
30
+
31
+ # ── JAX optional import ──────────────────────────────────────────────────────
32
+ _os.environ.setdefault("JAX_PLATFORMS", "cpu")
33
+ try:
34
+ import jax
35
+ import jax.numpy as jnp
36
+ from jax import jit as _jit, vmap as _vmap
37
+ jax.config.update("jax_enable_x64", True)
38
+ _JAX_AVAILABLE = True
39
+ except Exception:
40
+ _JAX_AVAILABLE = False
41
+
42
+
43
+ def _as_2d_float(array_like) -> np.ndarray:
44
+ arr = np.asarray(array_like, dtype=float)
45
+ if arr.ndim == 1:
46
+ arr = arr.reshape(1, -1)
47
+ if arr.ndim != 2:
48
+ raise ValueError("Expected a 2D array of allocations")
49
+ return np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
50
+
51
+
52
+ @dataclass
53
+ class MDCEVFitResult:
54
+ labels: list[str]
55
+ baseline_utility: np.ndarray
56
+ alpha: np.ndarray
57
+ gamma: np.ndarray
58
+ participation_rate: np.ndarray
59
+ mean_allocation: np.ndarray
60
+ mean_budget: float
61
+
62
+
63
+ # ── JAX bisection kernel (module-level, JIT-compiled once) ───────────────────
64
+ if _JAX_AVAILABLE:
65
+ def _jax_bisect_single(budget, util_idx, alpha, gamma):
66
+ """Translated-utility KKT bisection for one observation.
67
+
68
+ Fully JAX-jittable and differentiable: uses ``jax.lax.scan`` for both
69
+ the initial bracket search and the bisection, so gradients flow through
70
+ the solver and ``fit_mle`` can use exact autodiff.
71
+ """
72
+ _tol = 1e-9
73
+ weights = jnp.exp(jnp.clip(util_idx, -40.0, 40.0))
74
+
75
+ def _alloc_sum(lam):
76
+ power = 1.0 / jnp.clip(1.0 - alpha, _tol, None)
77
+ raw = jnp.power(weights / jnp.maximum(lam, _tol), power) - gamma
78
+ return jnp.maximum(raw, 0.0).sum()
79
+
80
+ # Find upper bracket: keep doubling hi until alloc_sum(hi) <= budget.
81
+ # scan (max 60 steps) is differentiable; once condition is false hi stays fixed.
82
+ init_hi = jnp.maximum(jnp.max(weights), 1.0)
83
+
84
+ def _double_step(hi, _):
85
+ return jnp.where(_alloc_sum(hi) > budget, hi * 2.0, hi), None
86
+
87
+ hi, _ = jax.lax.scan(_double_step, init_hi, None, length=60)
88
+
89
+ # 80-step bisection
90
+ def _bisect_step(carry, _):
91
+ lo, hi = carry
92
+ mid = 0.5 * (lo + hi)
93
+ go_lo = _alloc_sum(mid) > budget
94
+ return (jnp.where(go_lo, mid, lo), jnp.where(go_lo, hi, mid)), None
95
+
96
+ (_, hi_f), _ = jax.lax.scan(_bisect_step, (_tol, hi), None, length=80)
97
+
98
+ power = 1.0 / jnp.clip(1.0 - alpha, _tol, None)
99
+ raw = jnp.power(weights / jnp.maximum(hi_f, _tol), power) - gamma
100
+ allocation = jnp.maximum(raw, 0.0)
101
+ total = allocation.sum()
102
+ allocation = jnp.where(total > _tol, allocation * (budget / total), allocation)
103
+ # zero-budget guard
104
+ return jnp.where(budget <= _tol, jnp.zeros_like(allocation), allocation)
105
+
106
+ # Batch version: vmap over (budget, util_idx); alpha and gamma are shared
107
+ _jax_bisect_batch = _jit(_vmap(_jax_bisect_single, in_axes=(0, 0, None, None)))
108
+
109
+
110
+ class MDCEVModel:
111
+ """Translated-utility MDCEV-style allocator.
112
+
113
+ Parameters are learned from observed budget shares using stable moment-based
114
+ heuristics (``fit``), then optionally refined via quasi-MLE with JAX
115
+ automatic differentiation (``fit_mle``). Predictions use a JAX-jitted and
116
+ vmapped bisection solver when JAX is available, with a pure-numpy fallback.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ outside_good: Optional[int] = 0,
122
+ alpha_floor: float = 0.05,
123
+ alpha_cap: float = 0.95,
124
+ gamma_floor: float = 1e-3,
125
+ tol: float = 1e-9,
126
+ ):
127
+ self.outside_good = outside_good
128
+ self.alpha_floor = alpha_floor
129
+ self.alpha_cap = alpha_cap
130
+ self.gamma_floor = gamma_floor
131
+ self.tol = tol
132
+
133
+ self.labels_: list[str] | None = None
134
+ self.baseline_utility_: np.ndarray | None = None
135
+ self.alpha_: np.ndarray | None = None
136
+ self.gamma_: np.ndarray | None = None
137
+ self.fit_result_: MDCEVFitResult | None = None
138
+
139
+ def fit(self, allocations, labels: Optional[Iterable[str]] = None):
140
+ """Estimate baseline utility and satiation terms from observed allocations.
141
+
142
+ Parameters
143
+ ----------
144
+ allocations:
145
+ Matrix of observed budgets split across alternatives. Rows are
146
+ observations and columns are alternatives.
147
+ labels:
148
+ Optional alternative labels.
149
+ """
150
+ y = _as_2d_float(allocations)
151
+ n_obs, n_alt = y.shape
152
+ budgets = y.sum(axis=1)
153
+ if np.any(budgets < self.tol):
154
+ raise ValueError("Each observation must have a positive total budget")
155
+
156
+ labels_list = list(labels) if labels is not None else [f"alt_{i}" for i in range(n_alt)]
157
+ if len(labels_list) != n_alt:
158
+ raise ValueError("labels length must match number of alternatives")
159
+
160
+ positive = y > self.tol
161
+ participation = positive.mean(axis=0)
162
+ mean_allocation = y.mean(axis=0)
163
+ share = y.sum(axis=0) / np.clip(y.sum(), self.tol, None)
164
+
165
+ if self.outside_good is not None and 0 <= self.outside_good < n_alt:
166
+ ref_share = max(float(share[self.outside_good]), self.tol)
167
+ baseline = np.log(np.clip(share, self.tol, None)) - np.log(ref_share)
168
+ baseline[self.outside_good] = 0.0
169
+ else:
170
+ baseline = np.log(np.clip(share, self.tol, None))
171
+ baseline = baseline - baseline.mean()
172
+
173
+ gamma = np.full(n_alt, self.gamma_floor, dtype=float)
174
+ alpha = np.full(n_alt, 0.5, dtype=float)
175
+
176
+ for idx in range(n_alt):
177
+ pos_vals = y[positive[:, idx], idx]
178
+ if pos_vals.size == 0:
179
+ gamma[idx] = max(np.median(budgets) * 0.05, self.gamma_floor)
180
+ alpha[idx] = self.alpha_floor
181
+ baseline[idx] = min(baseline[idx], -8.0)
182
+ continue
183
+
184
+ median_pos = float(np.median(pos_vals))
185
+ mean_pos = float(np.mean(pos_vals))
186
+ std_pos = float(np.std(pos_vals))
187
+ cv_pos = std_pos / max(mean_pos, self.tol)
188
+
189
+ gamma[idx] = max(median_pos * max(1.0 - participation[idx], 0.1), self.gamma_floor)
190
+ raw_alpha = 0.2 + 0.6 * participation[idx] / (1.0 + cv_pos)
191
+ alpha[idx] = float(np.clip(raw_alpha, self.alpha_floor, self.alpha_cap))
192
+
193
+ if self.outside_good is not None and 0 <= self.outside_good < n_alt:
194
+ gamma[self.outside_good] = self.gamma_floor
195
+ alpha[self.outside_good] = max(alpha[self.outside_good], 0.8)
196
+
197
+ self.labels_ = labels_list
198
+ self.baseline_utility_ = baseline
199
+ self.alpha_ = alpha
200
+ self.gamma_ = gamma
201
+ self.fit_result_ = MDCEVFitResult(
202
+ labels=labels_list,
203
+ baseline_utility=baseline.copy(),
204
+ alpha=alpha.copy(),
205
+ gamma=gamma.copy(),
206
+ participation_rate=participation.copy(),
207
+ mean_allocation=mean_allocation.copy(),
208
+ mean_budget=float(np.mean(budgets)),
209
+ )
210
+ return self
211
+
212
+ def fit_mle(
213
+ self,
214
+ allocations,
215
+ labels: Optional[Iterable[str]] = None,
216
+ maxiter: int = 400,
217
+ l2_penalty: float = 1e-4,
218
+ ):
219
+ """Likelihood-based parameter refinement.
220
+
221
+ Uses JAX automatic differentiation for exact analytic gradients when
222
+ JAX is available, replacing the slow scipy finite-difference
223
+ approximation. Falls back to scipy FD when JAX is not installed.
224
+ """
225
+ self.fit(allocations, labels=labels)
226
+
227
+ y = _as_2d_float(allocations)
228
+ budgets_np = y.sum(axis=1)
229
+ n_alt = y.shape[1]
230
+ alpha_floor = self.alpha_floor
231
+ alpha_cap = self.alpha_cap
232
+ gamma_floor = self.gamma_floor
233
+ tol = self.tol
234
+ og = self.outside_good
235
+ has_og = og is not None and 0 <= og < n_alt
236
+ free_base_idx = [i for i in range(n_alt) if not has_og or i != og]
237
+
238
+ def _pack_np(base, alpha, gamma, sigma):
239
+ p = []
240
+ p.extend(np.asarray(base)[free_base_idx].tolist())
241
+ p.extend(np.log(np.clip(
242
+ (np.asarray(alpha) - alpha_floor) / np.clip(alpha_cap - np.asarray(alpha), tol, None),
243
+ tol, None)).tolist())
244
+ p.extend(np.log(np.clip(np.asarray(gamma), gamma_floor, None)).tolist())
245
+ p.append(np.log(max(float(sigma), 1e-3)))
246
+ return np.asarray(p, dtype=float)
247
+
248
+ theta0 = _pack_np(self.baseline_utility_, self.alpha_, self.gamma_, sigma=0.5)
249
+
250
+ if _JAX_AVAILABLE:
251
+ # ── JAX path: exact gradients via autodiff ────────────────────
252
+ y_jax = jnp.array(y)
253
+ B_jax = jnp.array(budgets_np)
254
+ free_idx_jax = jnp.array(free_base_idx)
255
+
256
+ def _unpack_jax(theta):
257
+ o = 0
258
+ n_free = len(free_base_idx)
259
+ free_base = theta[o:o + n_free]
260
+ o += n_free
261
+ base = jnp.zeros(n_alt).at[free_idx_jax].set(free_base)
262
+
263
+ alpha_raw = theta[o:o + n_alt]
264
+ o += n_alt
265
+ alpha = alpha_floor + (alpha_cap - alpha_floor) * jax.nn.sigmoid(alpha_raw)
266
+
267
+ gamma_raw = theta[o:o + n_alt]
268
+ o += n_alt
269
+ gamma = jnp.maximum(jnp.exp(gamma_raw), gamma_floor)
270
+
271
+ sigma = jnp.maximum(jnp.exp(theta[o]), 1e-3)
272
+ return base, alpha, gamma, sigma
273
+
274
+ @_jit
275
+ def _neg_loglike_jax(theta):
276
+ base, alpha, gamma, sigma = _unpack_jax(theta)
277
+ util_matrix = jnp.broadcast_to(base[None, :], (len(B_jax), n_alt))
278
+ preds = _jax_bisect_batch(B_jax, util_matrix, alpha, gamma)
279
+ log_y = jnp.log(jnp.clip(y_jax, tol, None))
280
+ log_p = jnp.log(jnp.clip(preds, tol, None))
281
+ resid = log_y - log_p
282
+ ll = -0.5 * resid.size * jnp.log(2.0 * jnp.pi * sigma * sigma)
283
+ ll -= 0.5 * jnp.sum((resid / sigma) ** 2)
284
+ ll -= l2_penalty * jnp.sum(theta * theta)
285
+ return -ll
286
+
287
+ val_and_grad = _jit(jax.value_and_grad(_neg_loglike_jax))
288
+
289
+ def _scipy_obj(theta_np):
290
+ val, grad = val_and_grad(jnp.array(theta_np, dtype=jnp.float64))
291
+ return float(val), np.asarray(grad, dtype=np.float64)
292
+
293
+ res = minimize(
294
+ _scipy_obj,
295
+ theta0,
296
+ method="L-BFGS-B",
297
+ jac=True,
298
+ options={"maxiter": int(maxiter), "ftol": 1e-9},
299
+ )
300
+ base_j, alpha_j, gamma_j, sigma_j = _unpack_jax(jnp.array(res.x))
301
+ self.baseline_utility_ = np.asarray(base_j)
302
+ self.alpha_ = np.asarray(alpha_j)
303
+ self.gamma_ = np.asarray(gamma_j)
304
+ self.noise_sigma_ = float(sigma_j)
305
+
306
+ else:
307
+ # ── numpy / scipy FD fallback (original behaviour) ────────────
308
+ def _unpack_np(theta):
309
+ theta = np.asarray(theta, dtype=float)
310
+ o = 0
311
+ base = self.baseline_utility_.copy()
312
+ for idx in free_base_idx:
313
+ base[idx] = theta[o]
314
+ o += 1
315
+ if has_og:
316
+ base[og] = 0.0
317
+ alpha_raw = theta[o:o + n_alt]; o += n_alt
318
+ alpha = alpha_floor + (alpha_cap - alpha_floor) / (1.0 + np.exp(-alpha_raw))
319
+ gamma_raw = theta[o:o + n_alt]; o += n_alt
320
+ gamma = np.maximum(np.exp(gamma_raw), gamma_floor)
321
+ sigma = max(np.exp(theta[o]), 1e-3)
322
+ return base, alpha, gamma, sigma
323
+
324
+ def _neg_loglike_np(theta):
325
+ base, alpha, gamma, sigma = _unpack_np(theta)
326
+ old_b, old_a, old_g = self.baseline_utility_, self.alpha_, self.gamma_
327
+ self.baseline_utility_, self.alpha_, self.gamma_ = base, alpha, gamma
328
+ try:
329
+ mu = np.zeros_like(y)
330
+ for i, b in enumerate(budgets_np):
331
+ mu[i] = self._solve_budget(float(b), base)
332
+ finally:
333
+ self.baseline_utility_, self.alpha_, self.gamma_ = old_b, old_a, old_g
334
+ log_y = np.log(np.clip(y, tol, None))
335
+ log_mu = np.log(np.clip(mu, tol, None))
336
+ resid = log_y - log_mu
337
+ ll = -0.5 * resid.size * np.log(2.0 * np.pi * sigma * sigma)
338
+ ll -= 0.5 * np.sum((resid / sigma) ** 2)
339
+ ll -= l2_penalty * np.sum(theta * theta)
340
+ return -float(ll)
341
+
342
+ res = minimize(
343
+ _neg_loglike_np,
344
+ theta0,
345
+ method="L-BFGS-B",
346
+ options={"maxiter": int(maxiter), "ftol": 1e-9},
347
+ )
348
+ base, alpha, gamma, sigma = _unpack_np(res.x)
349
+ self.baseline_utility_ = base
350
+ self.alpha_ = alpha
351
+ self.gamma_ = gamma
352
+ self.noise_sigma_ = float(sigma)
353
+
354
+ self.mle_success_ = bool(res.success)
355
+ self.mle_message_ = str(res.message)
356
+ return self
357
+
358
+ def summary(self) -> pd.DataFrame:
359
+ if self.fit_result_ is None:
360
+ raise RuntimeError("Model must be fit before calling summary()")
361
+ result = self.fit_result_
362
+ return pd.DataFrame(
363
+ {
364
+ "alternative": result.labels,
365
+ "baseline_utility": result.baseline_utility,
366
+ "alpha": result.alpha,
367
+ "gamma": result.gamma,
368
+ "participation_rate": result.participation_rate,
369
+ "mean_allocation": result.mean_allocation,
370
+ }
371
+ )
372
+
373
+ def predict(self, budgets, utility_shift=None) -> np.ndarray:
374
+ """Predict deterministic budget allocations for one or more budgets.
375
+
376
+ Parameters
377
+ ----------
378
+ budgets:
379
+ Scalar or vector of total budgets.
380
+ utility_shift:
381
+ Optional additive utility adjustment. Can be shape ``(J,)`` or
382
+ ``(N, J)``.
383
+ """
384
+ self._check_fitted()
385
+ budgets_arr = np.asarray(budgets, dtype=float).reshape(-1)
386
+ shifts = self._prepare_utility_shift(utility_shift, len(budgets_arr))
387
+
388
+ if _JAX_AVAILABLE:
389
+ util_matrix = jnp.array(self.baseline_utility_[None, :] + shifts)
390
+ preds = _jax_bisect_batch(
391
+ jnp.array(budgets_arr),
392
+ util_matrix,
393
+ jnp.array(self.alpha_),
394
+ jnp.array(self.gamma_),
395
+ )
396
+ return np.asarray(preds)
397
+
398
+ predictions = np.zeros((len(budgets_arr), len(self.baseline_utility_)), dtype=float)
399
+ for row_idx, budget in enumerate(budgets_arr):
400
+ predictions[row_idx] = self._solve_budget(budget, self.baseline_utility_ + shifts[row_idx])
401
+ return predictions
402
+
403
+ def simulate(self, budgets, utility_shift=None, n_draws: int = 100, random_state: Optional[int] = None) -> np.ndarray:
404
+ """Simulate stochastic budget allocations with Gumbel utility shocks."""
405
+ self._check_fitted()
406
+ budgets_arr = np.asarray(budgets, dtype=float).reshape(-1)
407
+ shifts = self._prepare_utility_shift(utility_shift, len(budgets_arr))
408
+ rng = np.random.default_rng(random_state)
409
+
410
+ sims = np.zeros((n_draws, len(budgets_arr), len(self.baseline_utility_)), dtype=float)
411
+
412
+ if _JAX_AVAILABLE:
413
+ B_jax = jnp.array(budgets_arr)
414
+ alpha_jax = jnp.array(self.alpha_)
415
+ gamma_jax = jnp.array(self.gamma_)
416
+ for draw_idx in range(n_draws):
417
+ shocks = rng.gumbel(loc=0.0, scale=1.0, size=shifts.shape)
418
+ util_matrix = jnp.array(self.baseline_utility_[None, :] + shifts + shocks)
419
+ sims[draw_idx] = np.asarray(_jax_bisect_batch(B_jax, util_matrix, alpha_jax, gamma_jax))
420
+ return sims
421
+
422
+ for draw_idx in range(n_draws):
423
+ shocks = rng.gumbel(loc=0.0, scale=1.0, size=shifts.shape)
424
+ for row_idx, budget in enumerate(budgets_arr):
425
+ sims[draw_idx, row_idx] = self._solve_budget(
426
+ budget,
427
+ self.baseline_utility_ + shifts[row_idx] + shocks[row_idx],
428
+ )
429
+ return sims
430
+
431
+ def _prepare_utility_shift(self, utility_shift, n_rows: int) -> np.ndarray:
432
+ n_alt = len(self.baseline_utility_)
433
+ if utility_shift is None:
434
+ return np.zeros((n_rows, n_alt), dtype=float)
435
+
436
+ shift_arr = np.asarray(utility_shift, dtype=float)
437
+ if shift_arr.ndim == 1:
438
+ if shift_arr.shape[0] != n_alt:
439
+ raise ValueError("utility_shift has the wrong number of alternatives")
440
+ return np.repeat(shift_arr.reshape(1, -1), n_rows, axis=0)
441
+ if shift_arr.shape != (n_rows, n_alt):
442
+ raise ValueError("utility_shift must have shape (J,) or (N, J)")
443
+ return shift_arr
444
+
445
+ def _solve_budget(self, budget: float, utility_index: np.ndarray) -> np.ndarray:
446
+ """Pure-numpy bisection fallback — used when JAX is not available."""
447
+ if budget <= self.tol:
448
+ return np.zeros(len(self.baseline_utility_), dtype=float)
449
+
450
+ weights = np.exp(np.clip(utility_index, -40.0, 40.0))
451
+
452
+ def alloc_for_lambda(lam: float) -> np.ndarray:
453
+ lam = max(lam, self.tol)
454
+ power = 1.0 / np.clip(1.0 - self.alpha_, self.tol, None)
455
+ raw = np.power(weights / lam, power) - self.gamma_
456
+ return np.maximum(raw, 0.0)
457
+
458
+ lo = self.tol
459
+ hi = max(np.max(weights), 1.0)
460
+ while alloc_for_lambda(hi).sum() > budget:
461
+ hi *= 2.0
462
+
463
+ for _ in range(80):
464
+ mid = 0.5 * (lo + hi)
465
+ if alloc_for_lambda(mid).sum() > budget:
466
+ lo = mid
467
+ else:
468
+ hi = mid
469
+
470
+ allocation = alloc_for_lambda(hi)
471
+ total = allocation.sum()
472
+ if total > self.tol:
473
+ allocation *= budget / total
474
+ elif self.outside_good is not None and 0 <= self.outside_good < len(allocation):
475
+ allocation[self.outside_good] = budget
476
+
477
+ residual = budget - allocation.sum()
478
+ if self.outside_good is not None and 0 <= self.outside_good < len(allocation) and residual > self.tol:
479
+ allocation[self.outside_good] += residual
480
+ return allocation
481
+
482
+ def _check_fitted(self):
483
+ if self.fit_result_ is None or self.baseline_utility_ is None:
484
+ raise RuntimeError("Model must be fit before prediction")
@@ -616,7 +616,7 @@ class SA(Search):
616
616
  def copy_solution(self, sol):
617
617
  # {
618
618
  logging.info('normal copy')
619
- copy_sol = sol.copy()
619
+ copy_sol = copy.deepcopy(sol) # Deep copy to prevent shared mutable state (lists, dicts inside solution)
620
620
  return copy_sol
621
621
  # }
622
622
 
@@ -998,15 +998,12 @@ class SA(Search):
998
998
  print("Initialized best_sol with the first solution")
999
999
  return
1000
1000
 
1001
- self.no_impr = 0
1002
1001
  if self.nb_crit == 1:
1003
1002
  # {
1004
1003
  if is_better(sol.obj(0), self.best_sol.obj(0), self.param.sign_crit(0)):
1005
1004
  self.best_sol = self.copy_solution(sol)
1006
-
1007
- print('new best')
1008
-
1009
1005
  self.no_impr = 0
1006
+ print('new best')
1010
1007
  # }
1011
1008
  else:
1012
1009
  self.archive = self.update_archive(sol)
@@ -1087,7 +1084,7 @@ class SA(Search):
1087
1084
  # {
1088
1085
  print("ARCHIVE STATIC. RESTORE A NON DOMINATED SOLUTION.")
1089
1086
  choice = np.random.randint(len(self.archive))
1090
- self.current_sol = self.archive[choice]
1087
+ self.current_sol = self.copy_solution(self.archive[choice]) # Deep copy to avoid aliasing archive entry
1091
1088
  self.no_impr = 0
1092
1089
  # }
1093
1090
  # }
@@ -1102,7 +1099,7 @@ class SA(Search):
1102
1099
  # {
1103
1100
  if overall_best_solution is None or \
1104
1101
  is_better(self.best_sol.obj(0), overall_best_solution.obj(0), self.param.sign_crit(0)):
1105
- overall_best_solution = self.best_sol # Update overall best solution
1102
+ overall_best_solution = self.copy_solution(self.best_sol) # Update overall best solution (deep copy to prevent overwriting)
1106
1103
  elif overall_best_solution is not None and \
1107
1104
  is_worse(self.best_sol.obj(0), overall_best_solution.obj(0), self.param.sign_crit(0)):
1108
1105
  self.update_best(overall_best_solution) # Revise best solution of current SA solver
@@ -448,7 +448,7 @@ class TA(Search):
448
448
  # {
449
449
  if overall_best_solution is None or \
450
450
  is_better(self.best_sol.obj(0), overall_best_solution.obj(0), self.param.sign_crit(0)):
451
- overall_best_solution = self.best_sol # Update overall best solution
451
+ overall_best_solution = self.copy_solution(self.best_sol) # Update overall best solution (deep copy to prevent overwriting)
452
452
  elif overall_best_solution is not None and \
453
453
  is_worse(self.best_sol.obj(0), overall_best_solution.obj(0), self.param.sign_crit(0)):
454
454
  self.update_best(overall_best_solution) # Revise best solution of current TA solver
@@ -0,0 +1 @@
1
+ 0.0.86
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.84
3
+ Version: 0.0.86
4
4
  Summary: A Python package for econometric models driven by search
5
5
  Author: Alexander Paz Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -17,6 +17,7 @@ src/SearchLibrium/harmony.py
17
17
  src/SearchLibrium/latent_class.py
18
18
  src/SearchLibrium/main.py
19
19
  src/SearchLibrium/main_debug.py
20
+ src/SearchLibrium/mdcev.py
20
21
  src/SearchLibrium/misc.py
21
22
  src/SearchLibrium/mixed_logit.py
22
23
  src/SearchLibrium/mixed_nested.py
@@ -1 +0,0 @@
1
- 0.0.84
File without changes
File without changes