SearchLibrium 0.0.243__tar.gz → 0.0.244__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.243/src/SearchLibrium.egg-info → searchlibrium-0.0.244}/PKG-INFO +1 -1
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/pyproject.toml +1 -1
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/__init__.py +19 -0
- searchlibrium-0.0.244/src/SearchLibrium/mh_choice_estimator.py +665 -0
- searchlibrium-0.0.244/src/SearchLibrium/version.txt +1 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
- searchlibrium-0.0.243/src/SearchLibrium/version.txt +0 -1
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/LICENSE +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/MANIFEST.in +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/README.md +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/setup.cfg +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/BEHier.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/Halton.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/MixedLogit.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/MixedLogitGSE.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/RandomP.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/Two_Level_Nest.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/__main__.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/_choice_model.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/_device.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/abm.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/accessibility.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/banditsa.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/boxcox_functions.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/call_meta.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/constraints_builder.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/data/TravelMode.csv +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/data/electricity.csv +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/harmony.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/hspbil.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/jax_utils.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/larch_compat.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/larch_models.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/latent_class.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/latent_spec.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/logistic_regression.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/main.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/main_debug.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mdcev.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/misc.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mixed_logit.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mixed_nested.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mixedrrm.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/multinomial_logit.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/multinomial_nested.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/multinomial_probit.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/numba_engine.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/numba_mxl.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/numba_rrm.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/ordered_logit.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/predict.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/regularization.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/rrm.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/sample_data.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/sapbil.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/search.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/selection_models.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/setup.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/siman.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/skims.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/sparseea_agds.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_lc_de.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_mario_searches.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/threshold.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/requires.txt +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/top_level.txt +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_jax_mask_equivalence.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_larch_integration.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_lc_smart_init.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_per_alternative_random.py +0 -0
- {searchlibrium-0.0.243 → searchlibrium-0.0.244}/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.244
|
|
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.244"
|
|
133
133
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
134
134
|
commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
|
|
135
135
|
commit = true
|
|
@@ -161,6 +161,13 @@ try:
|
|
|
161
161
|
from .skims import omx_to_dataframe, read_omx_skims, prepare_skims_from_omx, merge_skim_component
|
|
162
162
|
from .accessibility import gravity_accessibility, compute_accessibility, logsum_accessibility
|
|
163
163
|
from .abm import available_stages, stage_search_config
|
|
164
|
+
from .mh_choice_estimator import (
|
|
165
|
+
ChoiceEstimate, ChoiceEstimateResult, ChoiceSetFrame,
|
|
166
|
+
GaussianRandomWalkProposal,
|
|
167
|
+
BlockRandomWalkProposal, HHTSCompetingPrior,
|
|
168
|
+
chain_diagnostics, effective_sample_size, split_rhat, r_hat,
|
|
169
|
+
estimate_dest_choice, estimate_stage_choice,
|
|
170
|
+
)
|
|
164
171
|
except Exception:
|
|
165
172
|
try:
|
|
166
173
|
from multinomial_logit import MultinomialLogit
|
|
@@ -196,6 +203,13 @@ except Exception:
|
|
|
196
203
|
from skims import omx_to_dataframe, read_omx_skims, prepare_skims_from_omx, merge_skim_component
|
|
197
204
|
from accessibility import gravity_accessibility, compute_accessibility, logsum_accessibility
|
|
198
205
|
from abm import available_stages, stage_search_config
|
|
206
|
+
from mh_choice_estimator import (
|
|
207
|
+
ChoiceEstimate, ChoiceEstimateResult, ChoiceSetFrame,
|
|
208
|
+
GaussianRandomWalkProposal,
|
|
209
|
+
BlockRandomWalkProposal, HHTSCompetingPrior,
|
|
210
|
+
chain_diagnostics, effective_sample_size, split_rhat, r_hat,
|
|
211
|
+
estimate_dest_choice, estimate_stage_choice,
|
|
212
|
+
)
|
|
199
213
|
except Exception as e:
|
|
200
214
|
warnings.warn(f"SearchLibrium: some modules failed to import: {e}")
|
|
201
215
|
|
|
@@ -217,6 +231,11 @@ __all__ = [
|
|
|
217
231
|
"align_classes", "propose_class_params_spec", "MDCEVFitResult", "MDCEVModel", "MultinomialProbit",
|
|
218
232
|
"MixedLogitGSE", "RandomParameters", "ConstraintBuilder", "create_constraints",
|
|
219
233
|
"Parameters", "misc",
|
|
234
|
+
"ChoiceEstimate", "ChoiceEstimateResult", "ChoiceSetFrame",
|
|
235
|
+
"GaussianRandomWalkProposal",
|
|
236
|
+
"BlockRandomWalkProposal", "HHTSCompetingPrior",
|
|
237
|
+
"chain_diagnostics", "effective_sample_size", "split_rhat", "r_hat",
|
|
238
|
+
"estimate_dest_choice", "estimate_stage_choice",
|
|
220
239
|
"SAPBIL", "ProbabilityMatrix", "HSPBIL", "SparseEAAGDS", "BanditSA", "PerturbationBandit",
|
|
221
240
|
"call_search", "call_siman", "call_harmony", "call_harmony_pbil", "call_sapbil",
|
|
222
241
|
"call_banditsa", "call_parsa", "call_parcopsa", "call_agds", "estimate_ctrl",
|
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Adaptive sampled-alternative estimators for destination choice.
|
|
2
|
+
|
|
3
|
+
The module accepts a long choice-set frame. Each case owns its alternatives and
|
|
4
|
+
likelihood denominators are evaluated only within that case. A dense universal
|
|
5
|
+
zone-by-case denominator is never built.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Iterable, Mapping, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from scipy.special import logsumexp
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Diagnostics
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def effective_sample_size(values: np.ndarray) -> float:
|
|
28
|
+
"""Return the initial-positive-sequence autocorrelation ESS."""
|
|
29
|
+
x = np.asarray(values, dtype=float)
|
|
30
|
+
if x.ndim != 1:
|
|
31
|
+
raise ValueError("ESS expects a one-dimensional chain")
|
|
32
|
+
n = len(x)
|
|
33
|
+
if n < 3 or not np.isfinite(x).all() or np.var(x) <= 1e-15:
|
|
34
|
+
return float(n)
|
|
35
|
+
centered = x - x.mean()
|
|
36
|
+
variance = float(np.dot(centered, centered) / n)
|
|
37
|
+
if variance <= 1e-15:
|
|
38
|
+
return float(n)
|
|
39
|
+
ac_sum = 0.0
|
|
40
|
+
for lag in range(1, n - 1):
|
|
41
|
+
rho = float(np.dot(centered[:-lag], centered[lag:]) / ((n - lag) * variance))
|
|
42
|
+
if rho < 0.0:
|
|
43
|
+
break
|
|
44
|
+
ac_sum += rho
|
|
45
|
+
if ac_sum > n:
|
|
46
|
+
break
|
|
47
|
+
return float(np.clip(n / (1.0 + 2.0 * ac_sum), 1.0, n))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def split_rhat(chains: np.ndarray) -> np.ndarray:
|
|
51
|
+
"""Compute split R-hat for ``(chains, draws, parameters)`` samples."""
|
|
52
|
+
x = np.asarray(chains, dtype=float)
|
|
53
|
+
if x.ndim == 2:
|
|
54
|
+
x = x[None, ...]
|
|
55
|
+
if x.ndim != 3:
|
|
56
|
+
raise ValueError("R-hat expects (chains, draws, parameters)")
|
|
57
|
+
n_chain, n_draw, n_param = x.shape
|
|
58
|
+
if n_chain < 2 or n_draw < 4:
|
|
59
|
+
return np.full(n_param, np.nan)
|
|
60
|
+
half = n_draw // 2
|
|
61
|
+
split = np.concatenate((x[:, :half, :], x[:, -half:, :]), axis=0)
|
|
62
|
+
n_split, n_each, _ = split.shape
|
|
63
|
+
means = split.mean(axis=1)
|
|
64
|
+
variances = split.var(axis=1, ddof=1)
|
|
65
|
+
between = n_each * means.var(axis=0, ddof=1)
|
|
66
|
+
within = variances.mean(axis=0)
|
|
67
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
68
|
+
result = np.sqrt((within * (n_each - 1.0) / n_each + between / n_each) / within)
|
|
69
|
+
return np.where(np.isfinite(result), result, np.nan)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def r_hat(chains: np.ndarray) -> np.ndarray:
|
|
73
|
+
"""Compatibility alias for :func:`split_rhat`."""
|
|
74
|
+
return split_rhat(chains)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def chain_diagnostics(samples: np.ndarray, chains: np.ndarray | None = None) -> dict:
|
|
78
|
+
"""Return ESS and, when supplied, split R-hat diagnostics."""
|
|
79
|
+
x = np.asarray(samples, dtype=float)
|
|
80
|
+
if x.ndim == 1:
|
|
81
|
+
x = x[:, None]
|
|
82
|
+
ess = np.array([effective_sample_size(x[:, j]) for j in range(x.shape[1])])
|
|
83
|
+
result = {"ess": ess, "ess_min": float(np.min(ess)) if len(ess) else 0.0}
|
|
84
|
+
if chains is not None:
|
|
85
|
+
values = split_rhat(chains)
|
|
86
|
+
result["r_hat"] = values
|
|
87
|
+
result["r_hat_max"] = float(np.nanmax(values)) if np.isfinite(values).any() else float("nan")
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Explicit adaptive random-walk proposals
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GaussianRandomWalkProposal:
|
|
97
|
+
"""Haario adaptive Gaussian random walk with symmetric Hastings ratio."""
|
|
98
|
+
|
|
99
|
+
symmetric = True
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
dimension: int,
|
|
104
|
+
scale: float | None = None,
|
|
105
|
+
eps: float = 1e-6,
|
|
106
|
+
burn_in: int = 100,
|
|
107
|
+
initial_cov: np.ndarray | None = None,
|
|
108
|
+
seed: int | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.dimension = int(dimension)
|
|
111
|
+
if self.dimension < 1:
|
|
112
|
+
raise ValueError("dimension must be positive")
|
|
113
|
+
self.scale = float(scale) if scale is not None else 2.38 ** 2 / self.dimension
|
|
114
|
+
self.eps = float(eps)
|
|
115
|
+
self.burn_in = max(0, int(burn_in))
|
|
116
|
+
self.rng = np.random.default_rng(seed)
|
|
117
|
+
self.n_observed = 0
|
|
118
|
+
self.mean = np.zeros(self.dimension, dtype=float)
|
|
119
|
+
self.m2 = np.zeros((self.dimension, self.dimension), dtype=float)
|
|
120
|
+
self.initial_cov = (
|
|
121
|
+
np.asarray(initial_cov, dtype=float).copy()
|
|
122
|
+
if initial_cov is not None else np.eye(self.dimension, dtype=float)
|
|
123
|
+
)
|
|
124
|
+
if self.initial_cov.shape != (self.dimension, self.dimension):
|
|
125
|
+
raise ValueError("initial_cov has the wrong shape")
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def covariance(self) -> np.ndarray:
|
|
129
|
+
if self.n_observed < 2 or self.n_observed <= self.burn_in:
|
|
130
|
+
cov = self.initial_cov
|
|
131
|
+
else:
|
|
132
|
+
cov = self.m2 / max(self.n_observed - self.burn_in - 1, 1)
|
|
133
|
+
cov = 0.5 * np.asarray(cov, dtype=float) + 0.5 * np.asarray(cov, dtype=float).T
|
|
134
|
+
return self.scale * (cov + self.eps * np.eye(self.dimension))
|
|
135
|
+
|
|
136
|
+
def propose(self, current: np.ndarray, rng: np.random.Generator | None = None) -> np.ndarray:
|
|
137
|
+
current = np.asarray(current, dtype=float)
|
|
138
|
+
if current.shape != (self.dimension,):
|
|
139
|
+
raise ValueError("current has the wrong shape")
|
|
140
|
+
generator = rng or self.rng
|
|
141
|
+
try:
|
|
142
|
+
step = generator.multivariate_normal(np.zeros(self.dimension), self.covariance)
|
|
143
|
+
except (ValueError, np.linalg.LinAlgError):
|
|
144
|
+
step = generator.normal(size=self.dimension) * np.sqrt(np.maximum(np.diag(self.covariance), self.eps))
|
|
145
|
+
return current + step
|
|
146
|
+
|
|
147
|
+
def update(self, value: np.ndarray) -> None:
|
|
148
|
+
"""Update recursive moments only after the burn-in period."""
|
|
149
|
+
value = np.asarray(value, dtype=float)
|
|
150
|
+
if value.shape != (self.dimension,):
|
|
151
|
+
raise ValueError("value has the wrong shape")
|
|
152
|
+
self.n_observed += 1
|
|
153
|
+
if self.n_observed <= self.burn_in:
|
|
154
|
+
return
|
|
155
|
+
count = self.n_observed - self.burn_in
|
|
156
|
+
delta = value - self.mean
|
|
157
|
+
self.mean += delta / count
|
|
158
|
+
self.m2 += np.outer(delta, value - self.mean)
|
|
159
|
+
|
|
160
|
+
observe = update
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class BlockRandomWalkProposal:
|
|
164
|
+
"""Independent adaptive Gaussian random walks for named parameter blocks."""
|
|
165
|
+
|
|
166
|
+
symmetric = True
|
|
167
|
+
|
|
168
|
+
def __init__(self, blocks: Mapping[str, Sequence[int]], dimension: int | None = None, **kwargs) -> None:
|
|
169
|
+
self.blocks = {str(name): np.asarray(indices, dtype=int) for name, indices in blocks.items()}
|
|
170
|
+
if not self.blocks or any(not len(indices) for indices in self.blocks.values()):
|
|
171
|
+
raise ValueError("blocks must contain non-empty index sequences")
|
|
172
|
+
inferred = max(int(indices.max()) for indices in self.blocks.values()) + 1
|
|
173
|
+
self.dimension = int(dimension if dimension is not None else inferred)
|
|
174
|
+
self.proposals = {
|
|
175
|
+
name: GaussianRandomWalkProposal(len(indices), **kwargs)
|
|
176
|
+
for name, indices in self.blocks.items()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def propose(self, current: np.ndarray, rng: np.random.Generator | None = None) -> np.ndarray:
|
|
180
|
+
out = np.asarray(current, dtype=float).copy()
|
|
181
|
+
for name, indices in self.blocks.items():
|
|
182
|
+
out[indices] = self.proposals[name].propose(out[indices], rng=rng)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
def update(self, value: np.ndarray) -> None:
|
|
186
|
+
value = np.asarray(value, dtype=float)
|
|
187
|
+
for name, indices in self.blocks.items():
|
|
188
|
+
self.proposals[name].update(value[indices])
|
|
189
|
+
|
|
190
|
+
observe = update
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# HHTS competing prior
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class HHTSCompetingPrior:
|
|
199
|
+
"""Dirichlet-smoothed HHTS shares blended with current utilities."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
shares: Mapping[object, Mapping[object, float]] | Mapping[object, float] | None = None,
|
|
204
|
+
smoothing: float = 0.5,
|
|
205
|
+
alpha: float = 0.7,
|
|
206
|
+
temperature: float = 1.0,
|
|
207
|
+
target_ess: float = 0.35,
|
|
208
|
+
adaptation_rate: float = 0.15,
|
|
209
|
+
min_alpha: float = 0.05,
|
|
210
|
+
max_alpha: float = 0.95,
|
|
211
|
+
min_temperature: float = 0.15,
|
|
212
|
+
max_temperature: float = 8.0,
|
|
213
|
+
) -> None:
|
|
214
|
+
raw = shares or {}
|
|
215
|
+
if raw and all(not isinstance(value, Mapping) for value in raw.values()):
|
|
216
|
+
raw = {"default": raw}
|
|
217
|
+
self.shares = {str(key): {zone: float(value) for zone, value in values.items()}
|
|
218
|
+
for key, values in raw.items()}
|
|
219
|
+
self.smoothing = max(float(smoothing), 1e-12)
|
|
220
|
+
self.alpha = float(np.clip(alpha, min_alpha, max_alpha))
|
|
221
|
+
self.temperature = float(np.clip(temperature, min_temperature, max_temperature))
|
|
222
|
+
self.target_ess = float(target_ess)
|
|
223
|
+
self.adaptation_rate = float(adaptation_rate)
|
|
224
|
+
self.min_alpha, self.max_alpha = float(min_alpha), float(max_alpha)
|
|
225
|
+
self.min_temperature, self.max_temperature = float(min_temperature), float(max_temperature)
|
|
226
|
+
self.history: list[dict] = []
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_hhts(
|
|
230
|
+
cls,
|
|
231
|
+
hhts: pd.DataFrame,
|
|
232
|
+
segment: str | None = None,
|
|
233
|
+
segment_col: str = "segment",
|
|
234
|
+
zone_col: str = "DTAZ",
|
|
235
|
+
weight_col: str | None = "weight",
|
|
236
|
+
**kwargs,
|
|
237
|
+
) -> "HHTSCompetingPrior":
|
|
238
|
+
if hhts is None or len(hhts) == 0:
|
|
239
|
+
return cls(**kwargs)
|
|
240
|
+
frame = hhts.copy()
|
|
241
|
+
if segment is not None and segment_col in frame.columns:
|
|
242
|
+
frame = frame[frame[segment_col].astype(str).str.lower() == str(segment).lower()]
|
|
243
|
+
if zone_col not in frame.columns:
|
|
244
|
+
raise KeyError(f"HHTS frame is missing '{zone_col}'")
|
|
245
|
+
frame[zone_col] = pd.to_numeric(frame[zone_col], errors="coerce")
|
|
246
|
+
frame = frame.dropna(subset=[zone_col])
|
|
247
|
+
frame[zone_col] = frame[zone_col].astype(int)
|
|
248
|
+
count = (pd.to_numeric(frame[weight_col], errors="coerce").fillna(1.0).clip(lower=0.0)
|
|
249
|
+
if weight_col and weight_col in frame.columns else pd.Series(1.0, index=frame.index))
|
|
250
|
+
grouped = frame.assign(_w=count).groupby(zone_col, observed=True)['_w'].sum()
|
|
251
|
+
return cls({str(segment or "default"): grouped.to_dict()}, **kwargs)
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def from_frame(cls, frame: pd.DataFrame, alt_col: str = "alt_id",
|
|
255
|
+
choice_col: str = "chosen", **kwargs) -> "HHTSCompetingPrior":
|
|
256
|
+
chosen = frame.loc[frame[choice_col].astype(bool), alt_col]
|
|
257
|
+
counts = chosen.value_counts().to_dict()
|
|
258
|
+
return cls({"default": counts}, **kwargs)
|
|
259
|
+
|
|
260
|
+
def probabilities(self, zones: Sequence[object], utilities: np.ndarray | None = None,
|
|
261
|
+
segment: str | None = None, adapt: bool = True) -> np.ndarray:
|
|
262
|
+
zones = list(zones)
|
|
263
|
+
if not zones:
|
|
264
|
+
raise ValueError("zones must not be empty")
|
|
265
|
+
counts = self.shares.get(str(segment or "default"), self.shares.get("default", {}))
|
|
266
|
+
count_vec = np.array([max(float(counts.get(zone, counts.get(str(zone), 0.0))), 0.0)
|
|
267
|
+
for zone in zones], dtype=float)
|
|
268
|
+
empirical = (count_vec + self.smoothing) / (count_vec.sum() + self.smoothing * len(zones))
|
|
269
|
+
if utilities is None:
|
|
270
|
+
q = empirical
|
|
271
|
+
else:
|
|
272
|
+
utility = np.nan_to_num(np.asarray(utilities, dtype=float), nan=0.0, posinf=50.0, neginf=-50.0)
|
|
273
|
+
scaled = utility / self.temperature
|
|
274
|
+
utility_prob = np.exp(scaled - logsumexp(scaled))
|
|
275
|
+
q = self.alpha * empirical + (1.0 - self.alpha) * utility_prob
|
|
276
|
+
q = np.maximum(q, np.finfo(float).tiny)
|
|
277
|
+
q /= q.sum()
|
|
278
|
+
if adapt:
|
|
279
|
+
self.adapt_to_denominator_ess(q)
|
|
280
|
+
return q
|
|
281
|
+
|
|
282
|
+
def adapt_to_denominator_ess(self, probabilities: np.ndarray) -> float:
|
|
283
|
+
q = np.asarray(probabilities, dtype=float)
|
|
284
|
+
ess = float(1.0 / np.sum(np.square(q)))
|
|
285
|
+
target = self.target_ess * len(q) if 0.0 < self.target_ess <= 1.0 else self.target_ess
|
|
286
|
+
error = float(np.clip((target - ess) / max(target, 1e-12), -1.0, 1.0))
|
|
287
|
+
self.alpha = float(np.clip(self.alpha + self.adaptation_rate * error, self.min_alpha, self.max_alpha))
|
|
288
|
+
self.temperature = float(np.clip(self.temperature * np.exp(self.adaptation_rate * error),
|
|
289
|
+
self.min_temperature, self.max_temperature))
|
|
290
|
+
self.history.append({"denominator_ess": ess, "alpha": self.alpha, "temperature": self.temperature})
|
|
291
|
+
return ess
|
|
292
|
+
|
|
293
|
+
def sample_choice_sets(self, observations: pd.DataFrame, zone_ids: Sequence[object],
|
|
294
|
+
sample_size: int, segment: str | None = None,
|
|
295
|
+
chosen_col: str = "DTAZ", case_col: str = "ptour_id",
|
|
296
|
+
utility_col: str | None = None,
|
|
297
|
+
rng: np.random.Generator | None = None) -> pd.DataFrame:
|
|
298
|
+
"""Sample each case and emit McFadden corrections from the actual q."""
|
|
299
|
+
if int(sample_size) < 2:
|
|
300
|
+
raise ValueError("sample_size must be at least two")
|
|
301
|
+
generator = rng or np.random.default_rng()
|
|
302
|
+
rows = []
|
|
303
|
+
for _, observation in observations.iterrows():
|
|
304
|
+
chosen = observation[chosen_col]
|
|
305
|
+
candidates = list(zone_ids)
|
|
306
|
+
if chosen not in candidates:
|
|
307
|
+
candidates.append(chosen)
|
|
308
|
+
utility = None if utility_col is None else np.full(len(candidates), float(observation[utility_col]))
|
|
309
|
+
q = self.probabilities(candidates, utility, segment=segment)
|
|
310
|
+
chosen_idx = candidates.index(chosen)
|
|
311
|
+
remaining = [index for index in range(len(candidates)) if index != chosen_idx]
|
|
312
|
+
take = min(int(sample_size) - 1, len(remaining))
|
|
313
|
+
picked = generator.choice(remaining, size=take, replace=False, p=q[remaining] / q[remaining].sum()) if take else []
|
|
314
|
+
selected = [chosen_idx, *[int(index) for index in np.asarray(picked).ravel()]]
|
|
315
|
+
for index in selected:
|
|
316
|
+
rows.append({case_col: observation[case_col], "alt_zone": candidates[index],
|
|
317
|
+
"sampling_prob": float(q[index]),
|
|
318
|
+
"log_correction": float(-np.log(q[index]))})
|
|
319
|
+
result = pd.DataFrame(rows)
|
|
320
|
+
result.attrs["universal_denominator"] = False
|
|
321
|
+
result.attrs["proposal"] = "hhts_utility_mixture"
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Long-format likelihood and estimators
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass
|
|
331
|
+
class ChoiceSetFrame:
|
|
332
|
+
x: tuple[np.ndarray, ...]
|
|
333
|
+
chosen: tuple[int, ...]
|
|
334
|
+
offsets: tuple[np.ndarray, ...]
|
|
335
|
+
case_ids: tuple[object, ...]
|
|
336
|
+
alt_ids: tuple[np.ndarray, ...]
|
|
337
|
+
feature_names: tuple[str, ...]
|
|
338
|
+
weights: tuple[float, ...]
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def n_cases(self) -> int:
|
|
342
|
+
return len(self.x)
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def dimension(self) -> int:
|
|
346
|
+
return int(self.x[0].shape[1]) if self.x else 0
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def max_alternatives(self) -> int:
|
|
350
|
+
return max((len(values) for values in self.x), default=0)
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def from_long(cls, data: pd.DataFrame, feature_cols: Sequence[str],
|
|
354
|
+
case_col: str = "case_id", alt_col: str = "alt_id",
|
|
355
|
+
chosen_col: str = "chosen", offset_col: str = "log_correction",
|
|
356
|
+
q_col: str | None = None, weight_col: str | None = None) -> "ChoiceSetFrame":
|
|
357
|
+
if not isinstance(data, pd.DataFrame) or data.empty:
|
|
358
|
+
raise ValueError("a non-empty long-format DataFrame is required")
|
|
359
|
+
if bool(data.attrs.get("universal_denominator", False)):
|
|
360
|
+
raise AssertionError("universal denominator frames are forbidden")
|
|
361
|
+
missing = [column for column in [case_col, alt_col, *feature_cols] if column not in data.columns]
|
|
362
|
+
if missing:
|
|
363
|
+
raise KeyError(f"choice-set frame is missing columns: {missing}")
|
|
364
|
+
if chosen_col not in data.columns:
|
|
365
|
+
raise KeyError(f"choice-set frame needs '{chosen_col}'")
|
|
366
|
+
frame = data.copy()
|
|
367
|
+
if offset_col not in frame.columns and q_col is None:
|
|
368
|
+
frame[offset_col] = 0.0
|
|
369
|
+
elif offset_col not in frame.columns:
|
|
370
|
+
q = pd.to_numeric(frame[q_col], errors="coerce")
|
|
371
|
+
if (q <= 0).any() or not np.isfinite(q).all():
|
|
372
|
+
raise ValueError("sampling probabilities must be finite and positive")
|
|
373
|
+
frame[offset_col] = -np.log(q)
|
|
374
|
+
frame[offset_col] = pd.to_numeric(frame[offset_col], errors="coerce").fillna(0.0)
|
|
375
|
+
frame[chosen_col] = frame[chosen_col].astype(bool)
|
|
376
|
+
xs, chosen, offsets, ids, alts, weights = [], [], [], [], [], []
|
|
377
|
+
for case_id, group in frame.groupby(case_col, sort=False, observed=True):
|
|
378
|
+
group = group.reset_index(drop=True)
|
|
379
|
+
selected = group[chosen_col].to_numpy()
|
|
380
|
+
if selected.sum() != 1:
|
|
381
|
+
raise ValueError(f"case {case_id!r} must have exactly one chosen alternative")
|
|
382
|
+
if len(group) < 2:
|
|
383
|
+
raise ValueError(f"case {case_id!r} has fewer than two alternatives")
|
|
384
|
+
values = group.loc[:, feature_cols].apply(pd.to_numeric, errors="coerce").to_numpy(float)
|
|
385
|
+
if not np.isfinite(values).all():
|
|
386
|
+
raise ValueError(f"non-finite utility feature in case {case_id!r}")
|
|
387
|
+
xs.append(values)
|
|
388
|
+
chosen.append(int(np.flatnonzero(selected)[0]))
|
|
389
|
+
offsets.append(group[offset_col].to_numpy(float))
|
|
390
|
+
ids.append(case_id)
|
|
391
|
+
alts.append(group[alt_col].to_numpy(copy=True))
|
|
392
|
+
weights.append(float(pd.to_numeric(group[weight_col], errors="coerce").fillna(1.0).iloc[0])
|
|
393
|
+
if weight_col and weight_col in group.columns else 1.0)
|
|
394
|
+
return cls(tuple(xs), tuple(chosen), tuple(offsets), tuple(ids), tuple(alts),
|
|
395
|
+
tuple(str(column) for column in feature_cols), tuple(weights))
|
|
396
|
+
|
|
397
|
+
def loglike_and_gradient(self, beta: np.ndarray, indices: Iterable[int] | None = None):
|
|
398
|
+
beta = np.asarray(beta, dtype=float)
|
|
399
|
+
selected = range(self.n_cases) if indices is None else list(indices)
|
|
400
|
+
ll = 0.0
|
|
401
|
+
gradient = np.zeros(self.dimension, dtype=float)
|
|
402
|
+
for index in selected:
|
|
403
|
+
utility = self.x[index] @ beta + self.offsets[index]
|
|
404
|
+
denominator = logsumexp(utility)
|
|
405
|
+
probability = np.exp(utility - denominator)
|
|
406
|
+
weight = self.weights[index]
|
|
407
|
+
ll += weight * float(utility[self.chosen[index]] - denominator)
|
|
408
|
+
gradient += weight * (self.x[index][self.chosen[index]] - probability @ self.x[index])
|
|
409
|
+
return float(ll), gradient
|
|
410
|
+
|
|
411
|
+
def loglike(self, beta: np.ndarray) -> float:
|
|
412
|
+
return self.loglike_and_gradient(beta)[0]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass
|
|
416
|
+
class ChoiceEstimate:
|
|
417
|
+
method: str
|
|
418
|
+
feature_names: tuple[str, ...]
|
|
419
|
+
coef: np.ndarray
|
|
420
|
+
std_err: np.ndarray
|
|
421
|
+
loglike: float
|
|
422
|
+
acceptance_rate: float = float("nan")
|
|
423
|
+
diagnostics: dict = field(default_factory=dict)
|
|
424
|
+
metadata: dict = field(default_factory=dict)
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def coef_dict(self) -> dict[str, float]:
|
|
428
|
+
return {name: float(value) for name, value in zip(self.feature_names, self.coef)}
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def std_err_dict(self) -> dict[str, float]:
|
|
432
|
+
return {name: float(value) for name, value in zip(self.feature_names, self.std_err)}
|
|
433
|
+
|
|
434
|
+
def as_dict(self) -> dict:
|
|
435
|
+
return {"method": self.method, "coef": self.coef_dict,
|
|
436
|
+
"std_err": self.std_err_dict, "loglike": float(self.loglike),
|
|
437
|
+
"acceptance_rate": float(self.acceptance_rate),
|
|
438
|
+
"diagnostics": self.diagnostics, "metadata": self.metadata}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
ChoiceEstimateResult = ChoiceEstimate
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _normal_logprior(beta: np.ndarray, scale: float) -> float:
|
|
445
|
+
return float(-0.5 * np.sum(np.square(beta / max(float(scale), 1e-12))))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _opg_se(frame: ChoiceSetFrame, beta: np.ndarray) -> np.ndarray:
|
|
449
|
+
scores = [frame.loglike_and_gradient(beta, [index])[1] for index in range(frame.n_cases)]
|
|
450
|
+
if not scores:
|
|
451
|
+
return np.full(len(beta), np.nan)
|
|
452
|
+
information = np.asarray(scores).T @ np.asarray(scores)
|
|
453
|
+
covariance = np.linalg.pinv(information + 1e-8 * np.eye(len(beta)))
|
|
454
|
+
return np.sqrt(np.clip(np.diag(covariance), 0.0, None))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _mh_estimate(frame: ChoiceSetFrame, seed: int, draws: int, burn_in: int,
|
|
458
|
+
initial: np.ndarray | None, prior_scale: float, proposal=None) -> ChoiceEstimate:
|
|
459
|
+
rng = np.random.default_rng(seed)
|
|
460
|
+
beta = np.zeros(frame.dimension) if initial is None else np.asarray(initial, dtype=float).copy()
|
|
461
|
+
if beta.shape != (frame.dimension,):
|
|
462
|
+
raise ValueError("initial has the wrong shape")
|
|
463
|
+
prop = proposal or GaussianRandomWalkProposal(frame.dimension, burn_in=burn_in, seed=seed + 1)
|
|
464
|
+
current = frame.loglike(beta) + _normal_logprior(beta, prior_scale)
|
|
465
|
+
retained, accepted = [], 0
|
|
466
|
+
total = max(0, int(burn_in)) + max(1, int(draws))
|
|
467
|
+
for iteration in range(total):
|
|
468
|
+
candidate = prop.propose(beta, rng=rng)
|
|
469
|
+
candidate_score = frame.loglike(candidate) + _normal_logprior(candidate, prior_scale)
|
|
470
|
+
if np.log(rng.random()) < min(0.0, candidate_score - current):
|
|
471
|
+
beta, current = candidate, candidate_score
|
|
472
|
+
accepted += 1
|
|
473
|
+
prop.update(beta)
|
|
474
|
+
if iteration >= burn_in:
|
|
475
|
+
retained.append(beta.copy())
|
|
476
|
+
samples = np.asarray(retained, dtype=float)
|
|
477
|
+
std_err = samples.std(axis=0, ddof=1) if len(samples) > 1 else _opg_se(frame, beta)
|
|
478
|
+
diagnostics = chain_diagnostics(samples)
|
|
479
|
+
diagnostics["proposal_symmetric"] = bool(getattr(prop, "symmetric", False))
|
|
480
|
+
diagnostics["burn_in"] = int(burn_in)
|
|
481
|
+
return ChoiceEstimate("mh", frame.feature_names, beta, std_err, frame.loglike(beta),
|
|
482
|
+
accepted / max(total, 1), diagnostics,
|
|
483
|
+
{"n_cases": frame.n_cases, "max_alternatives": frame.max_alternatives})
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _refresh_frame(frame: ChoiceSetFrame, beta: np.ndarray, prior: HHTSCompetingPrior,
|
|
487
|
+
sample_size: int, rng: np.random.Generator) -> ChoiceSetFrame:
|
|
488
|
+
rows = []
|
|
489
|
+
for index in range(frame.n_cases):
|
|
490
|
+
alternatives = list(frame.alt_ids[index])
|
|
491
|
+
probability = prior.probabilities(alternatives, frame.x[index] @ beta, adapt=True)
|
|
492
|
+
chosen_index = frame.chosen[index]
|
|
493
|
+
n = min(max(2, int(sample_size)), len(alternatives))
|
|
494
|
+
rest = [value for value in range(len(alternatives)) if value != chosen_index]
|
|
495
|
+
take = min(n - 1, len(rest))
|
|
496
|
+
picked = rng.choice(rest, size=take, replace=False,
|
|
497
|
+
p=probability[rest] / probability[rest].sum()) if take else []
|
|
498
|
+
selected = [chosen_index, *[int(value) for value in np.asarray(picked).ravel()]]
|
|
499
|
+
inclusion = 1.0 - np.power(1.0 - probability, n)
|
|
500
|
+
rows.append((frame.x[index][selected], frame.alt_ids[index][selected],
|
|
501
|
+
-np.log(np.maximum(inclusion[selected], np.finfo(float).tiny)),
|
|
502
|
+
frame.case_ids[index], frame.weights[index]))
|
|
503
|
+
return ChoiceSetFrame(tuple(row[0] for row in rows), tuple(0 for _ in rows),
|
|
504
|
+
tuple(row[2] for row in rows), tuple(row[3] for row in rows),
|
|
505
|
+
tuple(row[1] for row in rows), frame.feature_names,
|
|
506
|
+
tuple(row[4] for row in rows))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _gibbs_estimate(frame: ChoiceSetFrame, seed: int, draws: int, burn_in: int,
|
|
510
|
+
initial: np.ndarray | None, prior: HHTSCompetingPrior,
|
|
511
|
+
set_size: int, inner_steps: int, prior_scale: float) -> ChoiceEstimate:
|
|
512
|
+
rng = np.random.default_rng(seed)
|
|
513
|
+
beta = np.zeros(frame.dimension) if initial is None else np.asarray(initial, dtype=float).copy()
|
|
514
|
+
proposal = GaussianRandomWalkProposal(frame.dimension, burn_in=max(10, burn_in // 2), seed=seed + 1)
|
|
515
|
+
retained, accepted, ess_history = [], 0, []
|
|
516
|
+
current_frame = frame
|
|
517
|
+
total = max(0, int(burn_in)) + max(1, int(draws))
|
|
518
|
+
for iteration in range(total):
|
|
519
|
+
current_frame = _refresh_frame(frame, beta, prior, set_size, rng)
|
|
520
|
+
if prior.history:
|
|
521
|
+
ess_history.append(prior.history[-1]["denominator_ess"])
|
|
522
|
+
current = current_frame.loglike(beta) + _normal_logprior(beta, prior_scale)
|
|
523
|
+
for _ in range(max(1, int(inner_steps))):
|
|
524
|
+
candidate = proposal.propose(beta, rng=rng)
|
|
525
|
+
score = current_frame.loglike(candidate) + _normal_logprior(candidate, prior_scale)
|
|
526
|
+
if np.log(rng.random()) < min(0.0, score - current):
|
|
527
|
+
beta, current = candidate, score
|
|
528
|
+
accepted += 1
|
|
529
|
+
proposal.update(beta)
|
|
530
|
+
if iteration >= burn_in:
|
|
531
|
+
retained.append(beta.copy())
|
|
532
|
+
samples = np.asarray(retained, dtype=float)
|
|
533
|
+
std_err = samples.std(axis=0, ddof=1) if len(samples) > 1 else _opg_se(current_frame, beta)
|
|
534
|
+
diagnostics = chain_diagnostics(samples)
|
|
535
|
+
diagnostics.update({"set_ess": ess_history, "prior_alpha": prior.alpha,
|
|
536
|
+
"prior_temperature": prior.temperature})
|
|
537
|
+
return ChoiceEstimate("gibbs", frame.feature_names, beta, std_err, current_frame.loglike(beta),
|
|
538
|
+
accepted / max(total * max(1, int(inner_steps)), 1), diagnostics,
|
|
539
|
+
{"n_cases": frame.n_cases, "max_alternatives": frame.max_alternatives,
|
|
540
|
+
"inner_steps": int(inner_steps), "mc_correction": "actual q inclusion probability"})
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _case_subsample(frame: ChoiceSetFrame, indices: np.ndarray, k: int,
|
|
544
|
+
rng: np.random.Generator) -> ChoiceSetFrame:
|
|
545
|
+
x, chosen, offsets, ids, alts, weights = [], [], [], [], [], []
|
|
546
|
+
for index in indices:
|
|
547
|
+
available = np.arange(len(frame.x[index]))
|
|
548
|
+
if len(available) > k:
|
|
549
|
+
other = available[available != frame.chosen[index]]
|
|
550
|
+
selected = np.asarray([frame.chosen[index], *rng.choice(other, size=k - 1, replace=False)])
|
|
551
|
+
else:
|
|
552
|
+
selected = available
|
|
553
|
+
x.append(frame.x[index][selected])
|
|
554
|
+
chosen.append(0 if frame.chosen[index] in selected else int(frame.chosen[index]))
|
|
555
|
+
offsets.append(frame.offsets[index][selected])
|
|
556
|
+
ids.append(frame.case_ids[index])
|
|
557
|
+
alts.append(frame.alt_ids[index][selected])
|
|
558
|
+
weights.append(frame.weights[index])
|
|
559
|
+
return ChoiceSetFrame(tuple(x), tuple(chosen), tuple(offsets), tuple(ids), tuple(alts),
|
|
560
|
+
frame.feature_names, tuple(weights))
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _sgd_estimate(frame: ChoiceSetFrame, seed: int, iterations: int, batch_size: int,
|
|
564
|
+
learning_rate: float, initial: np.ndarray | None, k_min: int,
|
|
565
|
+
k_max: int, target_noise: float) -> ChoiceEstimate:
|
|
566
|
+
rng = np.random.default_rng(seed)
|
|
567
|
+
beta = np.zeros(frame.dimension) if initial is None else np.asarray(initial, dtype=float).copy()
|
|
568
|
+
moment = np.zeros_like(beta)
|
|
569
|
+
velocity = np.zeros_like(beta)
|
|
570
|
+
beta1, beta2 = 0.9, 0.999
|
|
571
|
+
k = min(max(2, int(k_min)), max(2, frame.max_alternatives))
|
|
572
|
+
noise_history = []
|
|
573
|
+
for step in range(1, max(1, int(iterations)) + 1):
|
|
574
|
+
batch = rng.choice(frame.n_cases, size=min(int(batch_size), frame.n_cases), replace=False)
|
|
575
|
+
first = _case_subsample(frame, batch, k, rng)
|
|
576
|
+
second = _case_subsample(frame, rng.choice(batch, size=len(batch), replace=True), k, rng)
|
|
577
|
+
g1 = first.loglike_and_gradient(beta)[1] / max(first.n_cases, 1)
|
|
578
|
+
g2 = second.loglike_and_gradient(beta)[1] / max(second.n_cases, 1)
|
|
579
|
+
gradient = 0.5 * (g1 + g2)
|
|
580
|
+
noise = float(np.linalg.norm(g1 - g2) / (np.linalg.norm(g1 + g2) + 1e-12))
|
|
581
|
+
noise_history.append(noise)
|
|
582
|
+
if noise > target_noise and k < k_max:
|
|
583
|
+
k = min(k_max, k + 2)
|
|
584
|
+
elif noise < target_noise * 0.25 and k > k_min:
|
|
585
|
+
k = max(k_min, k - 1)
|
|
586
|
+
moment = beta1 * moment + (1.0 - beta1) * gradient
|
|
587
|
+
velocity = beta2 * velocity + (1.0 - beta2) * np.square(gradient)
|
|
588
|
+
m_hat = moment / (1.0 - beta1 ** step)
|
|
589
|
+
v_hat = velocity / (1.0 - beta2 ** step)
|
|
590
|
+
beta += learning_rate * m_hat / (np.sqrt(v_hat) + 1e-8)
|
|
591
|
+
std_err = _opg_se(frame, beta)
|
|
592
|
+
diagnostics = {"K_final": int(k), "K_bounds": (int(k_min), int(k_max)),
|
|
593
|
+
"gradient_noise": noise_history, "opg_information": "case score outer product"}
|
|
594
|
+
return ChoiceEstimate("sgd", frame.feature_names, beta, std_err, frame.loglike(beta),
|
|
595
|
+
float("nan"), diagnostics,
|
|
596
|
+
{"n_cases": frame.n_cases, "max_alternatives": frame.max_alternatives,
|
|
597
|
+
"optimizer": "Adam"})
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# Public dispatch
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def estimate_dest_choice(data: pd.DataFrame, method: str | None = None,
|
|
606
|
+
feature_cols: Sequence[str] | None = None,
|
|
607
|
+
prior: HHTSCompetingPrior | None = None,
|
|
608
|
+
seed: int = 42, **kwargs) -> ChoiceEstimate:
|
|
609
|
+
"""Estimate a sampled-alternative destination model.
|
|
610
|
+
|
|
611
|
+
``method`` is ``mh``, ``gibbs``, or ``sgd``; when omitted the stage
|
|
612
|
+
environment variable is used. ``log_correction`` is included as an
|
|
613
|
+
alternative-specific McFadden/Manski-Lerman offset.
|
|
614
|
+
"""
|
|
615
|
+
selected = str(method or os.environ.get("STAGE5_DEST_ESTIMATOR", "mh")).strip().lower()
|
|
616
|
+
if selected not in {"mh", "gibbs", "sgd"}:
|
|
617
|
+
raise ValueError("method must be one of {'mh', 'gibbs', 'sgd'}")
|
|
618
|
+
if feature_cols is None:
|
|
619
|
+
preferred = [column for column in
|
|
620
|
+
("log_DIST_1", "DIST", "size_term", "logsum", "log_CRASH_1")
|
|
621
|
+
if column in data.columns]
|
|
622
|
+
excluded = {kwargs.get("case_col", "case_id"), kwargs.get("alt_col", "alt_id"),
|
|
623
|
+
kwargs.get("chosen_col", "chosen"), kwargs.get("offset_col", "log_correction"),
|
|
624
|
+
kwargs.get("q_col"), kwargs.get("weight_col"), "DTAZ", "segment"}
|
|
625
|
+
inferred = [column for column in data.columns
|
|
626
|
+
if column not in excluded and pd.api.types.is_numeric_dtype(data[column])]
|
|
627
|
+
feature_cols = preferred or inferred
|
|
628
|
+
feature_cols = list(feature_cols)
|
|
629
|
+
if not feature_cols:
|
|
630
|
+
raise ValueError("at least one utility feature column is required")
|
|
631
|
+
frame = ChoiceSetFrame.from_long(data, feature_cols=feature_cols,
|
|
632
|
+
case_col=kwargs.get("case_col", "case_id"),
|
|
633
|
+
alt_col=kwargs.get("alt_col", "alt_id"),
|
|
634
|
+
chosen_col=kwargs.get("chosen_col", "chosen"),
|
|
635
|
+
offset_col=kwargs.get("offset_col", "log_correction"),
|
|
636
|
+
q_col=kwargs.get("q_col"), weight_col=kwargs.get("weight_col"))
|
|
637
|
+
common = {"seed": seed, "initial": kwargs.get("initial"),
|
|
638
|
+
"prior_scale": kwargs.get("prior_scale", 5.0)}
|
|
639
|
+
if selected == "mh":
|
|
640
|
+
return _mh_estimate(frame, draws=kwargs.get("draws", 1000), burn_in=kwargs.get("burn_in", 500),
|
|
641
|
+
proposal=kwargs.get("proposal"), **common)
|
|
642
|
+
if selected == "gibbs":
|
|
643
|
+
return _gibbs_estimate(frame, prior=prior or HHTSCompetingPrior(),
|
|
644
|
+
set_size=kwargs.get("set_size", frame.max_alternatives),
|
|
645
|
+
inner_steps=kwargs.get("inner_steps", 2),
|
|
646
|
+
draws=kwargs.get("draws", 500), burn_in=kwargs.get("burn_in", 250), **common)
|
|
647
|
+
return _sgd_estimate(frame, iterations=kwargs.get("iterations", 500),
|
|
648
|
+
batch_size=kwargs.get("batch_size", 64),
|
|
649
|
+
learning_rate=kwargs.get("learning_rate", 0.01),
|
|
650
|
+
k_min=kwargs.get("k_min", 8), k_max=kwargs.get("k_max", 64),
|
|
651
|
+
target_noise=kwargs.get("target_noise", 0.35),
|
|
652
|
+
**{key: value for key, value in common.items() if key != "prior_scale"})
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def estimate_stage_choice(*args, **kwargs) -> ChoiceEstimate:
|
|
656
|
+
"""Stage-neutral alias for :func:`estimate_dest_choice`."""
|
|
657
|
+
return estimate_dest_choice(*args, **kwargs)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
__all__ = [
|
|
661
|
+
"ChoiceEstimate", "ChoiceEstimateResult", "ChoiceSetFrame",
|
|
662
|
+
"GaussianRandomWalkProposal", "BlockRandomWalkProposal",
|
|
663
|
+
"HHTSCompetingPrior", "chain_diagnostics", "effective_sample_size",
|
|
664
|
+
"split_rhat", "r_hat", "estimate_dest_choice", "estimate_stage_choice",
|
|
665
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.0.244
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SearchLibrium
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.244
|
|
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>
|
|
@@ -31,6 +31,7 @@ src/SearchLibrium/logistic_regression.py
|
|
|
31
31
|
src/SearchLibrium/main.py
|
|
32
32
|
src/SearchLibrium/main_debug.py
|
|
33
33
|
src/SearchLibrium/mdcev.py
|
|
34
|
+
src/SearchLibrium/mh_choice_estimator.py
|
|
34
35
|
src/SearchLibrium/misc.py
|
|
35
36
|
src/SearchLibrium/mixed_logit.py
|
|
36
37
|
src/SearchLibrium/mixed_nested.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.0.243
|
|
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
|
|
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.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_sapbil_vs_banditsa.py
RENAMED
|
File without changes
|
|
File without changes
|
{searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/zero_inflated_ordered_probit.py
RENAMED
|
File without changes
|
{searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/dependency_links.txt
RENAMED
|
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
|