SearchLibrium 0.0.239__tar.gz → 0.0.241__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.239/src/SearchLibrium.egg-info → searchlibrium-0.0.241}/PKG-INFO +1 -1
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/pyproject.toml +1 -1
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/__init__.py +6 -1
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/latent_class.py +134 -18
- searchlibrium-0.0.241/src/SearchLibrium/latent_spec.py +856 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/multinomial_logit.py +51 -22
- searchlibrium-0.0.241/src/SearchLibrium/version.txt +1 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
- searchlibrium-0.0.239/src/SearchLibrium/version.txt +0 -1
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/LICENSE +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/MANIFEST.in +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/README.md +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/setup.cfg +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/BEHier.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/Halton.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/MixedLogit.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/MixedLogitGSE.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/RandomP.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/Two_Level_Nest.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/__main__.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/_choice_model.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/_device.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/abm.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/accessibility.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/banditsa.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/boxcox_functions.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/call_meta.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/constraints_builder.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/data/TravelMode.csv +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/data/electricity.csv +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/harmony.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/hspbil.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/jax_utils.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/larch_compat.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/larch_models.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/logistic_regression.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/main.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/main_debug.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mdcev.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/misc.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mixed_logit.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mixed_nested.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mixedrrm.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/multinomial_nested.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/multinomial_probit.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/numba_engine.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/numba_mxl.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/numba_rrm.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/ordered_logit.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/predict.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/regularization.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/rrm.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/sample_data.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/sapbil.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/search.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/selection_models.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/setup.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/siman.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/skims.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/sparseea_agds.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/test_lc_de.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/test_mario_searches.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/threshold.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/requires.txt +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/top_level.txt +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_jax_mask_equivalence.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_larch_integration.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_lc_smart_init.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_per_alternative_random.py +0 -0
- {searchlibrium-0.0.239 → searchlibrium-0.0.241}/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.241
|
|
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.241"
|
|
133
133
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
134
134
|
commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
|
|
135
135
|
commit = true
|
|
@@ -140,6 +140,8 @@ try:
|
|
|
140
140
|
from .selection_models import BinaryProbit, HeckmanTwoStep
|
|
141
141
|
from .zero_inflated_ordered_probit import ZeroInflatedOrderedProbit, ZeroInflatedProbit
|
|
142
142
|
from .latent_class import LatentClassMixedLogit
|
|
143
|
+
from .latent_spec import (vip_table, near_unidentified_report,
|
|
144
|
+
align_classes, propose_class_params_spec)
|
|
143
145
|
from .mdcev import MDCEVFitResult, MDCEVModel
|
|
144
146
|
from .multinomial_probit import MultinomialProbit
|
|
145
147
|
from .MixedLogitGSE import MixedLogitGSE
|
|
@@ -173,6 +175,8 @@ except Exception:
|
|
|
173
175
|
from selection_models import BinaryProbit, HeckmanTwoStep
|
|
174
176
|
from zero_inflated_ordered_probit import ZeroInflatedOrderedProbit, ZeroInflatedProbit
|
|
175
177
|
from latent_class import LatentClassMixedLogit
|
|
178
|
+
from latent_spec import (vip_table, near_unidentified_report,
|
|
179
|
+
align_classes, propose_class_params_spec)
|
|
176
180
|
from mdcev import MDCEVFitResult, MDCEVModel
|
|
177
181
|
from multinomial_probit import MultinomialProbit
|
|
178
182
|
from MixedLogitGSE import MixedLogitGSE
|
|
@@ -209,7 +213,8 @@ __all__ = [
|
|
|
209
213
|
"RandomRegret", "MixedRandomRegret", "OrderedLogit", "OrderedLogitLong", "ExplodedLogit", "MixedExplodedLogit",
|
|
210
214
|
"BinaryProbit", "HeckmanTwoStep",
|
|
211
215
|
"ZeroInflatedOrderedProbit", "ZeroInflatedProbit",
|
|
212
|
-
"LatentClassMixedLogit", "
|
|
216
|
+
"LatentClassMixedLogit", "vip_table", "near_unidentified_report",
|
|
217
|
+
"align_classes", "propose_class_params_spec", "MDCEVFitResult", "MDCEVModel", "MultinomialProbit",
|
|
213
218
|
"MixedLogitGSE", "RandomParameters", "ConstraintBuilder", "create_constraints",
|
|
214
219
|
"Parameters", "misc",
|
|
215
220
|
"SAPBIL", "ProbabilityMatrix", "HSPBIL", "SparseEAAGDS", "BanditSA", "PerturbationBandit",
|
|
@@ -57,6 +57,9 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
57
57
|
smart_jitter=0.5,
|
|
58
58
|
min_share=0.05,
|
|
59
59
|
sort_classes=True,
|
|
60
|
+
gamma_max_abs=None,
|
|
61
|
+
share_floor=0.0,
|
|
62
|
+
membership_standardize=False,
|
|
60
63
|
):
|
|
61
64
|
self.n_classes = int(n_classes)
|
|
62
65
|
self.maxiter = int(maxiter)
|
|
@@ -80,6 +83,22 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
80
83
|
self.smart_jitter = float(smart_jitter)
|
|
81
84
|
self.min_share = float(min_share)
|
|
82
85
|
self.sort_classes = bool(sort_classes)
|
|
86
|
+
# ── membership identification guardrails (all opt-in) ──────────
|
|
87
|
+
# gamma_max_abs : box bound |gamma| <= B on membership coefficients in
|
|
88
|
+
# the L-BFGS-B M-step. Stops one covariate from saturating the
|
|
89
|
+
# membership softmax and collapsing a class. None = unbounded.
|
|
90
|
+
# share_floor : minimum allowed implied class share; after each
|
|
91
|
+
# membership M-step, gammas shrink toward 0 until every class
|
|
92
|
+
# clears the floor. 0.0 = off.
|
|
93
|
+
# membership_standardize : z-score X_membership at setup so bounds and
|
|
94
|
+
# penalties are scale-free (fitted gammas then live in standardized
|
|
95
|
+
# units; means/stds are kept in _memb_standardize_).
|
|
96
|
+
self.gamma_max_abs = (None if gamma_max_abs is None
|
|
97
|
+
else float(gamma_max_abs))
|
|
98
|
+
self.share_floor = float(share_floor)
|
|
99
|
+
self.membership_standardize = bool(membership_standardize)
|
|
100
|
+
self._memb_standardize_ = None
|
|
101
|
+
self._memb_floor_shrinks_ = 0
|
|
83
102
|
# Diagnostics populated by fit()/fit_direct().
|
|
84
103
|
self.starts_ = []
|
|
85
104
|
self.sort_perm_ = None
|
|
@@ -299,6 +318,21 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
299
318
|
self.K_membership = 0
|
|
300
319
|
self.X_membership = None
|
|
301
320
|
|
|
321
|
+
# ── Optional membership standardisation (scale-free bounds) ──────
|
|
322
|
+
# Applied here so the L-BFGS-B box, the L1/L2 penalties and the VIP
|
|
323
|
+
# swing diagnostics all operate in comparable units.
|
|
324
|
+
if (getattr(self, 'membership_standardize', False)
|
|
325
|
+
and self.X_membership is not None and self.K_membership > 0):
|
|
326
|
+
_mu = np.nanmean(self.X_membership, axis=0)
|
|
327
|
+
_sd = np.nanstd(self.X_membership, axis=0)
|
|
328
|
+
_sd = np.where(np.isfinite(_sd) & (_sd > 0), _sd, 1.0)
|
|
329
|
+
self.X_membership = ((self.X_membership - _mu) / _sd).astype(float)
|
|
330
|
+
self._memb_standardize_ = {
|
|
331
|
+
'mean': np.asarray(_mu, dtype=float),
|
|
332
|
+
'std': np.asarray(_sd, dtype=float),
|
|
333
|
+
'vars': list(self.membership_vars or []),
|
|
334
|
+
}
|
|
335
|
+
|
|
302
336
|
self._prepare_backend_arrays()
|
|
303
337
|
self._prepare_membership_backend()
|
|
304
338
|
return self
|
|
@@ -439,14 +473,60 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
439
473
|
if gamma0.ndim > 1:
|
|
440
474
|
gamma0 = gamma0.ravel()
|
|
441
475
|
|
|
476
|
+
# Opt-in box bound: stops one covariate from saturating the membership
|
|
477
|
+
# softmax (the class-collapse pathology). None = unbounded (legacy).
|
|
478
|
+
_B = getattr(self, 'gamma_max_abs', None)
|
|
479
|
+
_bounds = None
|
|
480
|
+
if _B is not None:
|
|
481
|
+
try:
|
|
482
|
+
_b = float(_B)
|
|
483
|
+
if np.isfinite(_b) and _b > 0:
|
|
484
|
+
_bounds = [(-_b, _b)] * int(np.asarray(gamma0).size)
|
|
485
|
+
except Exception:
|
|
486
|
+
_bounds = None
|
|
487
|
+
|
|
442
488
|
result = minimize(
|
|
443
489
|
lambda g: self._membership_loglik_and_grad(g, weights),
|
|
444
490
|
gamma0,
|
|
445
491
|
method="L-BFGS-B",
|
|
446
492
|
jac=True,
|
|
493
|
+
bounds=_bounds,
|
|
447
494
|
options={"maxiter": self.membership_maxiter},
|
|
448
495
|
)
|
|
449
|
-
|
|
496
|
+
_g = result.x.reshape(C - 1, Km)
|
|
497
|
+
return self._project_gammas_to_share_floor(_g)
|
|
498
|
+
|
|
499
|
+
def _project_gammas_to_share_floor(self, gammas):
|
|
500
|
+
"""Shrink membership coefficients until every implied class clears
|
|
501
|
+
``share_floor`` (or give up after 25 halvings and return as-is).
|
|
502
|
+
|
|
503
|
+
A saturated membership equation assigns ~everyone by one covariate;
|
|
504
|
+
pulling gammas toward 0 moves priors back toward uniform, which
|
|
505
|
+
re-inflates the emptied class and keeps its utility parameters
|
|
506
|
+
identified. Only active when ``share_floor > 0``.
|
|
507
|
+
"""
|
|
508
|
+
_floor = float(getattr(self, 'share_floor', 0.0) or 0.0)
|
|
509
|
+
if (not _floor > 0 or not self._has_membership
|
|
510
|
+
or self.X_membership is None or self.K_membership <= 0):
|
|
511
|
+
return gammas
|
|
512
|
+
_g = np.asarray(gammas, dtype=float)
|
|
513
|
+
for _ in range(25):
|
|
514
|
+
try:
|
|
515
|
+
_priors = self._compute_membership_priors(_g)
|
|
516
|
+
except Exception:
|
|
517
|
+
break
|
|
518
|
+
try:
|
|
519
|
+
_shares = np.asarray(_priors, dtype=float).mean(axis=0)
|
|
520
|
+
except Exception:
|
|
521
|
+
break
|
|
522
|
+
if bool(np.all(np.isfinite(_shares))) and bool(np.all(_shares >= _floor)):
|
|
523
|
+
break
|
|
524
|
+
_g = 0.5 * _g
|
|
525
|
+
try:
|
|
526
|
+
self._memb_floor_shrinks_ = int(getattr(self, '_memb_floor_shrinks_', 0)) + 1
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
return _g
|
|
450
530
|
|
|
451
531
|
def _make_initial_gammas(self, rng, gammas0=None):
|
|
452
532
|
"""Initialise membership coefficients."""
|
|
@@ -507,7 +587,7 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
507
587
|
if hasattr(self, cache_key):
|
|
508
588
|
return getattr(self, cache_key)
|
|
509
589
|
|
|
510
|
-
if not self._jax_enabled or len(set(self._Ks)) != 1
|
|
590
|
+
if not self._jax_enabled or len(set(self._Ks)) != 1:
|
|
511
591
|
setattr(self, cache_key, None)
|
|
512
592
|
return None
|
|
513
593
|
|
|
@@ -531,6 +611,13 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
531
611
|
l2 = self.l2_penalty
|
|
532
612
|
l1 = self.l1_penalty
|
|
533
613
|
|
|
614
|
+
# Panel aggregation: the class marginalisation is per decision-maker, so
|
|
615
|
+
# per-task log-choice is segment-summed into M = n_panels person totals.
|
|
616
|
+
panelled = bool(self._panelled)
|
|
617
|
+
M = int(self.n_panels)
|
|
618
|
+
panel_idx_b = self.jnp.asarray(self.panel_idx) if panelled else None
|
|
619
|
+
seg_sum = self.jax.ops.segment_sum
|
|
620
|
+
|
|
534
621
|
def _negloglik_flat(params):
|
|
535
622
|
phi = params[:n_phi]
|
|
536
623
|
beta_flat = params[n_phi:n_phi + C * K]
|
|
@@ -543,11 +630,15 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
543
630
|
denom = jnp.clip(exp_u.sum(axis=2, keepdims=True), 1e-300)
|
|
544
631
|
probs = exp_u / denom
|
|
545
632
|
chosen = jnp.clip((probs * y_b[:, None, :]).sum(axis=2), 1e-300)
|
|
546
|
-
|
|
633
|
+
log_chosen_task = jnp.log(chosen)
|
|
634
|
+
if panelled:
|
|
635
|
+
log_chosen = seg_sum(log_chosen_task, panel_idx_b, num_segments=M)
|
|
636
|
+
else:
|
|
637
|
+
log_chosen = log_chosen_task
|
|
547
638
|
|
|
548
639
|
if has_memb:
|
|
549
640
|
gammas = params[n_phi + C * K:].reshape(C - 1, Km)
|
|
550
|
-
logits = jnp.zeros((
|
|
641
|
+
logits = jnp.zeros((M, C))
|
|
551
642
|
for c in range(C - 1):
|
|
552
643
|
logits = logits.at[:, c].set(X_memb @ gammas[c])
|
|
553
644
|
logits = logits - jnp.max(logits, axis=1, keepdims=True)
|
|
@@ -557,7 +648,7 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
557
648
|
else:
|
|
558
649
|
phi_full = jnp.concatenate([phi, jnp.zeros(1)])
|
|
559
650
|
log_priors_raw = phi_full - self.jax_logsumexp(phi_full)
|
|
560
|
-
log_prior = jnp.broadcast_to(log_priors_raw[None, :], (
|
|
651
|
+
log_prior = jnp.broadcast_to(log_priors_raw[None, :], (M, C))
|
|
561
652
|
|
|
562
653
|
log_joint = log_chosen + log_prior
|
|
563
654
|
log_marg = self.jax_logsumexp(log_joint, axis=1)
|
|
@@ -1482,6 +1573,7 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
1482
1573
|
member_params_spec=None,
|
|
1483
1574
|
class_params_spec=None,
|
|
1484
1575
|
panels=None,
|
|
1576
|
+
ident_penalty_kwargs=None,
|
|
1485
1577
|
**kwargs,
|
|
1486
1578
|
):
|
|
1487
1579
|
"""Search over number of latent classes, optionally using DE warm-start.
|
|
@@ -1539,10 +1631,19 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
1539
1631
|
# Pareto-style significance prioritisation:
|
|
1540
1632
|
# 1) prefer fewer insignificant variable groups,
|
|
1541
1633
|
# 2) among models with equal nsig, prefer lower criterion (e.g. BIC).
|
|
1634
|
+
# criterion="penalized_bic" adds the latent_spec identification
|
|
1635
|
+
# penalty (collapse / saturation / weak-identification) to BIC so
|
|
1636
|
+
# degenerate specs lose even when their raw likelihood is good.
|
|
1542
1637
|
from .search import count_insig_groups
|
|
1543
1638
|
nsig = count_insig_groups(model.coeff_names, model.pvalues,
|
|
1544
1639
|
p_val=kwargs.get('p_val', 0.05))
|
|
1545
|
-
|
|
1640
|
+
if criterion == "penalized_bic":
|
|
1641
|
+
from .latent_spec import score_spec as _score_spec
|
|
1642
|
+
_sc = _score_spec(model,
|
|
1643
|
+
**(ident_penalty_kwargs or {}))
|
|
1644
|
+
score = _sc["penalized_bic"]
|
|
1645
|
+
else:
|
|
1646
|
+
score = getattr(model, criterion)
|
|
1546
1647
|
if (best_model is None
|
|
1547
1648
|
or nsig < _best_nsig
|
|
1548
1649
|
or (nsig == _best_nsig and score < getattr(best_model, criterion))):
|
|
@@ -1595,6 +1696,9 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
1595
1696
|
ll = float(logsumexp(log_joint, axis=1).sum())
|
|
1596
1697
|
ll -= self._regularize_l2_betas(betas)
|
|
1597
1698
|
ll -= self._regularize_l1_betas(betas)
|
|
1699
|
+
if has_gamma:
|
|
1700
|
+
ll -= self._regularize_l2_gammas(gammas)
|
|
1701
|
+
ll -= self._regularize_l1_gammas(gammas)
|
|
1598
1702
|
return ll
|
|
1599
1703
|
|
|
1600
1704
|
def _autograd_hessian(self, params: np.ndarray) -> np.ndarray | None:
|
|
@@ -1612,10 +1716,6 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
1612
1716
|
if len(set(self._Ks)) != 1:
|
|
1613
1717
|
print("[LC] Autograd Hessian requires all classes to share the same variable set.")
|
|
1614
1718
|
return None
|
|
1615
|
-
if self._panelled:
|
|
1616
|
-
# The JIT objective marginalises per task, not per person; use the
|
|
1617
|
-
# panel-aware finite-difference Hessian instead.
|
|
1618
|
-
return None
|
|
1619
1719
|
|
|
1620
1720
|
cache_key = "_cached_autograd_hessian_fn"
|
|
1621
1721
|
if hasattr(self, cache_key):
|
|
@@ -1782,25 +1882,41 @@ class LatentClassMixedLogit(DiscreteChoiceModel):
|
|
|
1782
1882
|
info = -H_num # observed info = -hessian(loglik)
|
|
1783
1883
|
se_method = "numerical-hessian (finite differences)"
|
|
1784
1884
|
|
|
1885
|
+
# When a membership equation is active the class-share logits (phi) do
|
|
1886
|
+
# not enter the likelihood — they are exactly unidentified, which makes
|
|
1887
|
+
# the observed-information matrix exactly singular in those directions
|
|
1888
|
+
# (an artefact, not a genuinely ill-conditioned model). Invert only the
|
|
1889
|
+
# identified block; phi rows/cols are reinstated with zero covariance.
|
|
1890
|
+
P = len(params)
|
|
1891
|
+
if has_gamma and n_phi > 0:
|
|
1892
|
+
keep = np.arange(n_phi, P)
|
|
1893
|
+
else:
|
|
1894
|
+
keep = np.arange(P)
|
|
1895
|
+
info_id = info[np.ix_(keep, keep)]
|
|
1896
|
+
|
|
1785
1897
|
cond_number = np.nan
|
|
1786
|
-
|
|
1898
|
+
cov_id = None
|
|
1787
1899
|
try:
|
|
1788
|
-
eigvals = np.linalg.eigvalsh(
|
|
1900
|
+
eigvals = np.linalg.eigvalsh(info_id)
|
|
1789
1901
|
cond_number = float(eigvals.max() / max(eigvals.min(), 1e-300))
|
|
1790
1902
|
|
|
1791
1903
|
if eigvals.min() < 1e-8 * eigvals.max():
|
|
1792
1904
|
ridge = 1e-6 * eigvals.max()
|
|
1793
|
-
info_reg =
|
|
1794
|
-
|
|
1905
|
+
info_reg = info_id + ridge * np.eye(len(keep))
|
|
1906
|
+
cov_id = np.linalg.inv(info_reg)
|
|
1795
1907
|
se_method = "hessian (ridge-regularised)"
|
|
1796
1908
|
else:
|
|
1797
|
-
|
|
1909
|
+
cov_id = np.linalg.inv(info_id)
|
|
1798
1910
|
except np.linalg.LinAlgError:
|
|
1799
|
-
|
|
1911
|
+
cov_id = np.linalg.pinv(info_id)
|
|
1800
1912
|
se_method = "hessian (pinv fallback)"
|
|
1801
1913
|
|
|
1802
|
-
if
|
|
1803
|
-
|
|
1914
|
+
if cov_id is None:
|
|
1915
|
+
cov_id = np.linalg.pinv(info_id)
|
|
1916
|
+
|
|
1917
|
+
# Re-embed the identified covariance into the full parameter space.
|
|
1918
|
+
cov = np.zeros((P, P))
|
|
1919
|
+
cov[np.ix_(keep, keep)] = cov_id
|
|
1804
1920
|
|
|
1805
1921
|
se = np.sqrt(np.clip(np.diag(cov), 0.0, None))
|
|
1806
1922
|
|