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.
Files changed (80) hide show
  1. {searchlibrium-0.0.243/src/SearchLibrium.egg-info → searchlibrium-0.0.244}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/__init__.py +19 -0
  4. searchlibrium-0.0.244/src/SearchLibrium/mh_choice_estimator.py +665 -0
  5. searchlibrium-0.0.244/src/SearchLibrium/version.txt +1 -0
  6. {searchlibrium-0.0.243 → searchlibrium-0.0.244/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  7. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
  8. searchlibrium-0.0.243/src/SearchLibrium/version.txt +0 -1
  9. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/LICENSE +0 -0
  10. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/MANIFEST.in +0 -0
  11. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/README.md +0 -0
  12. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/setup.cfg +0 -0
  13. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/BEHier.py +0 -0
  14. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/Halton.py +0 -0
  15. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/MixedLogit.py +0 -0
  16. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  17. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  18. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/RandomP.py +0 -0
  19. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  20. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  21. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/__main__.py +0 -0
  22. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/_choice_model.py +0 -0
  23. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/_device.py +0 -0
  24. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/abm.py +0 -0
  25. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/accessibility.py +0 -0
  26. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/banditsa.py +0 -0
  27. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/boxcox_functions.py +0 -0
  28. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/call_meta.py +0 -0
  29. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/constraints_builder.py +0 -0
  30. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  31. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/data/TravelMode.csv +0 -0
  32. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/data/electricity.csv +0 -0
  33. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/harmony.py +0 -0
  34. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/hspbil.py +0 -0
  35. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/jax_utils.py +0 -0
  36. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/larch_compat.py +0 -0
  37. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/larch_models.py +0 -0
  38. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/latent_class.py +0 -0
  39. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/latent_spec.py +0 -0
  40. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/logistic_regression.py +0 -0
  41. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/main.py +0 -0
  42. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/main_debug.py +0 -0
  43. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mdcev.py +0 -0
  44. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/misc.py +0 -0
  45. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mixed_logit.py +0 -0
  46. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mixed_nested.py +0 -0
  47. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/mixedrrm.py +0 -0
  48. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/multinomial_logit.py +0 -0
  49. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/multinomial_nested.py +0 -0
  50. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/multinomial_probit.py +0 -0
  51. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/numba_engine.py +0 -0
  52. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/numba_mxl.py +0 -0
  53. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/numba_rrm.py +0 -0
  54. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/ordered_logit.py +0 -0
  55. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  56. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/predict.py +0 -0
  57. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/regularization.py +0 -0
  58. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/rrm.py +0 -0
  59. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/sample_data.py +0 -0
  60. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/sapbil.py +0 -0
  61. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/search.py +0 -0
  62. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/selection_models.py +0 -0
  63. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/setup.py +0 -0
  64. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/siman.py +0 -0
  65. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/skims.py +0 -0
  66. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/sparseea_agds.py +0 -0
  67. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_lc_de.py +0 -0
  68. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_mario_searches.py +0 -0
  69. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  70. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/threshold.py +0 -0
  71. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  72. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  73. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  74. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/requires.txt +0 -0
  75. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  76. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_jax_mask_equivalence.py +0 -0
  77. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_larch_integration.py +0 -0
  78. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_lc_smart_init.py +0 -0
  79. {searchlibrium-0.0.243 → searchlibrium-0.0.244}/tests/test_per_alternative_random.py +0 -0
  80. {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.243
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.243"
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.243
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