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.
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/PKG-INFO +1 -1
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/pyproject.toml +1 -1
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/__init__.py +11 -2
- searchlibrium-0.0.86/src/SearchLibrium/mdcev.py +484 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/siman.py +4 -7
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/threshold.py +1 -1
- searchlibrium-0.0.86/src/SearchLibrium/version.txt +1 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/PKG-INFO +1 -1
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
- searchlibrium-0.0.84/src/SearchLibrium/version.txt +0 -1
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/README.md +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/setup.cfg +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/Halton.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/MixedLogit.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/RandomP.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/Two_Level_Nest.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/__main__.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/_choice_model.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/_device.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/bhhh/minimize.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/boxcox_functions.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/call_meta.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/constraints_builder.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/harmony.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/latent_class.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/main.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/main_debug.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/misc.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/mixed_logit.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/mixed_nested.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/mixedrrm.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/multinomial_logit.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/multinomial_nested.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/multinomial_probit.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/ordered_logit.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/rrm.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/search.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/selection_models.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium/setup.py +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/requires.txt +0 -0
- {searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 +0,0 @@
|
|
|
1
|
-
0.0.84
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{searchlibrium-0.0.84 → searchlibrium-0.0.86}/src/SearchLibrium.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|