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.
Files changed (79) hide show
  1. {searchlibrium-0.0.239/src/SearchLibrium.egg-info → searchlibrium-0.0.241}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/__init__.py +6 -1
  4. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/latent_class.py +134 -18
  5. searchlibrium-0.0.241/src/SearchLibrium/latent_spec.py +856 -0
  6. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/multinomial_logit.py +51 -22
  7. searchlibrium-0.0.241/src/SearchLibrium/version.txt +1 -0
  8. {searchlibrium-0.0.239 → searchlibrium-0.0.241/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  9. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
  10. searchlibrium-0.0.239/src/SearchLibrium/version.txt +0 -1
  11. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/LICENSE +0 -0
  12. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/MANIFEST.in +0 -0
  13. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/README.md +0 -0
  14. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/setup.cfg +0 -0
  15. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/BEHier.py +0 -0
  16. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/Halton.py +0 -0
  17. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/MixedLogit.py +0 -0
  18. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  19. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  20. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/RandomP.py +0 -0
  21. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  22. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  23. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/__main__.py +0 -0
  24. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/_choice_model.py +0 -0
  25. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/_device.py +0 -0
  26. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/abm.py +0 -0
  27. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/accessibility.py +0 -0
  28. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/banditsa.py +0 -0
  29. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/boxcox_functions.py +0 -0
  30. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/call_meta.py +0 -0
  31. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/constraints_builder.py +0 -0
  32. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  33. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/data/TravelMode.csv +0 -0
  34. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/data/electricity.csv +0 -0
  35. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/harmony.py +0 -0
  36. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/hspbil.py +0 -0
  37. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/jax_utils.py +0 -0
  38. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/larch_compat.py +0 -0
  39. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/larch_models.py +0 -0
  40. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/logistic_regression.py +0 -0
  41. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/main.py +0 -0
  42. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/main_debug.py +0 -0
  43. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mdcev.py +0 -0
  44. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/misc.py +0 -0
  45. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mixed_logit.py +0 -0
  46. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mixed_nested.py +0 -0
  47. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/mixedrrm.py +0 -0
  48. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/multinomial_nested.py +0 -0
  49. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/multinomial_probit.py +0 -0
  50. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/numba_engine.py +0 -0
  51. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/numba_mxl.py +0 -0
  52. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/numba_rrm.py +0 -0
  53. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/ordered_logit.py +0 -0
  54. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  55. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/predict.py +0 -0
  56. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/regularization.py +0 -0
  57. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/rrm.py +0 -0
  58. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/sample_data.py +0 -0
  59. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/sapbil.py +0 -0
  60. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/search.py +0 -0
  61. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/selection_models.py +0 -0
  62. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/setup.py +0 -0
  63. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/siman.py +0 -0
  64. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/skims.py +0 -0
  65. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/sparseea_agds.py +0 -0
  66. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/test_lc_de.py +0 -0
  67. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/test_mario_searches.py +0 -0
  68. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  69. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/threshold.py +0 -0
  70. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  71. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  72. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  73. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/requires.txt +0 -0
  74. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  75. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_jax_mask_equivalence.py +0 -0
  76. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_larch_integration.py +0 -0
  77. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_lc_smart_init.py +0 -0
  78. {searchlibrium-0.0.239 → searchlibrium-0.0.241}/tests/test_per_alternative_random.py +0 -0
  79. {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.239
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.239"
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", "MDCEVFitResult", "MDCEVModel", "MultinomialProbit",
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
- return result.x.reshape(C - 1, Km)
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 or self._panelled:
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
- log_chosen = jnp.log(chosen)
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((N, C))
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, :], (N, C))
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
- score = getattr(model, criterion)
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
- cov = None
1898
+ cov_id = None
1787
1899
  try:
1788
- eigvals = np.linalg.eigvalsh(info)
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 = info + ridge * np.eye(len(params))
1794
- cov = np.linalg.inv(info_reg)
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
- cov = np.linalg.inv(info)
1909
+ cov_id = np.linalg.inv(info_id)
1798
1910
  except np.linalg.LinAlgError:
1799
- cov = np.linalg.pinv(info)
1911
+ cov_id = np.linalg.pinv(info_id)
1800
1912
  se_method = "hessian (pinv fallback)"
1801
1913
 
1802
- if cov is None:
1803
- cov = np.linalg.pinv(info)
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