SearchLibrium 0.0.119__tar.gz → 0.0.121__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.119 → searchlibrium-0.0.121}/PKG-INFO +1 -1
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/pyproject.toml +1 -1
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/MixedLogit.py +4 -1
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/__init__.py +4 -2
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/call_meta.py +53 -1
- searchlibrium-0.0.121/src/SearchLibrium/hspbil.py +299 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/latent_class.py +233 -26
- searchlibrium-0.0.121/src/SearchLibrium/version.txt +1 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/PKG-INFO +1 -1
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/SOURCES.txt +1 -0
- searchlibrium-0.0.119/src/SearchLibrium/version.txt +0 -1
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/README.md +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/setup.cfg +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/Halton.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/RandomP.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/Two_Level_Nest.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/__main__.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/_choice_model.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/_device.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/banditsa.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/bhhh/minimize.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/boxcox_functions.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/constraints_builder.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/harmony.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/main.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/main_debug.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/mdcev.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/misc.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/mixed_logit.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/mixed_nested.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/mixedrrm.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/multinomial_logit.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/multinomial_nested.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/multinomial_probit.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/ordered_logit.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/rrm.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/sapbil.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/search.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/selection_models.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/setup.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/siman.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/test_lc_de.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/test_mario_searches.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium/threshold.py +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/requires.txt +0 -0
- {searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/top_level.txt +0 -0
|
@@ -68,7 +68,7 @@ Homepage = "https://github.com/zahern/HypothesisX"
|
|
|
68
68
|
realpython = "SearchLibrium.__main__:main"
|
|
69
69
|
|
|
70
70
|
[tool.bumpver]
|
|
71
|
-
current_version = "0.0.
|
|
71
|
+
current_version = "0.0.121"
|
|
72
72
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
73
73
|
commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
|
|
74
74
|
commit = true
|
|
@@ -140,7 +140,7 @@ class MixedLogit(DiscreteChoiceModel):
|
|
|
140
140
|
gtol=1e-6, return_hess=True, return_grad=True, method="slsqp",
|
|
141
141
|
save_fitted_params=True, mnl_init=True,
|
|
142
142
|
de_init=False, de_popsize=4, de_maxiter=3, de_tol=0.5,
|
|
143
|
-
de_polish=False):
|
|
143
|
+
de_polish=False, l1_penalty=0.0):
|
|
144
144
|
# {
|
|
145
145
|
self.fit_intercept = fit_intercept
|
|
146
146
|
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
@@ -185,6 +185,7 @@ class MixedLogit(DiscreteChoiceModel):
|
|
|
185
185
|
self.de_polish = de_polish
|
|
186
186
|
self.total_fun_eval = 0
|
|
187
187
|
self.method = method.lower() if hasattr(method, 'lower') else method
|
|
188
|
+
self.l1_penalty = float(l1_penalty)
|
|
188
189
|
self.jac = self.return_grad # scipy optimize parameter
|
|
189
190
|
self.n_draws = n_draws
|
|
190
191
|
self.batch_size = min(n_draws, batch_size) if batch_size is not None else n_draws
|
|
@@ -1033,6 +1034,7 @@ class MixedLogit(DiscreteChoiceModel):
|
|
|
1033
1034
|
|
|
1034
1035
|
penalty = self.regularize_loglik(betas)
|
|
1035
1036
|
loglik = loglik - penalty
|
|
1037
|
+
loglik = loglik - self.regularize_l1_loglik(betas)
|
|
1036
1038
|
|
|
1037
1039
|
self.total_fun_eval += 1
|
|
1038
1040
|
|
|
@@ -1054,6 +1056,7 @@ class MixedLogit(DiscreteChoiceModel):
|
|
|
1054
1056
|
# }
|
|
1055
1057
|
|
|
1056
1058
|
g = np.sum(g, axis=0) / n_batches # (K, )
|
|
1059
|
+
g = g - self.regularize_l1_grad(betas)
|
|
1057
1060
|
self.gtol_res = np.linalg.norm(g, ord=np.inf)
|
|
1058
1061
|
|
|
1059
1062
|
result = (-loglik,) # Create a tuple
|
|
@@ -111,7 +111,8 @@ try:
|
|
|
111
111
|
|
|
112
112
|
from . import misc
|
|
113
113
|
from .sapbil import SAPBIL, ProbabilityMatrix
|
|
114
|
-
from .
|
|
114
|
+
from .hspbil import HSPBIL
|
|
115
|
+
from .call_meta import call_harmony, call_harmony_pbil, call_siman, call_parsa, call_search, call_sapbil, estimate_ctrl
|
|
115
116
|
|
|
116
117
|
except ImportError as e:
|
|
117
118
|
from _choice_model import DiscreteChoiceModel
|
|
@@ -130,7 +131,8 @@ except ImportError as e:
|
|
|
130
131
|
from constraints_builder import ConstraintBuilder, create_constraints
|
|
131
132
|
from search import Parameters
|
|
132
133
|
from sapbil import SAPBIL, ProbabilityMatrix
|
|
133
|
-
from
|
|
134
|
+
from hspbil import HSPBIL
|
|
135
|
+
from call_meta import call_siman, call_harmony, call_harmony_pbil, call_search, call_sapbil, estimate_ctrl
|
|
134
136
|
try:
|
|
135
137
|
from .main import print_ascii_art_logo
|
|
136
138
|
except Exception:
|
|
@@ -24,12 +24,14 @@ try:
|
|
|
24
24
|
from banditsa import*
|
|
25
25
|
from threshold import*
|
|
26
26
|
from sapbil import SAPBIL, ProbabilityMatrix
|
|
27
|
+
from hspbil import HSPBIL
|
|
27
28
|
except ImportError:
|
|
28
29
|
from .harmony import*
|
|
29
30
|
from .siman import*
|
|
30
31
|
from .banditsa import*
|
|
31
32
|
from .threshold import*
|
|
32
33
|
from .sapbil import SAPBIL, ProbabilityMatrix
|
|
34
|
+
from .hspbil import HSPBIL
|
|
33
35
|
|
|
34
36
|
import numpy as np
|
|
35
37
|
|
|
@@ -447,6 +449,54 @@ def call_harmony(parameters, init_sol=None, ctrl=None, **kwargs):
|
|
|
447
449
|
return best
|
|
448
450
|
|
|
449
451
|
|
|
452
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
453
|
+
# Harmony Search + PBIL
|
|
454
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def call_harmony_pbil(parameters, init_sol=None, ctrl=None, **kwargs):
|
|
457
|
+
"""Run Harmony Search with PBIL-guided pitch adjustment.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
parameters : Parameters
|
|
462
|
+
Problem definition.
|
|
463
|
+
init_sol : Solution, optional
|
|
464
|
+
Warm-start solution (passed as existing memory).
|
|
465
|
+
ctrl : tuple, optional
|
|
466
|
+
``(max_mem, maxiter, max_harm, min_harm, max_pitch, min_pitch)``.
|
|
467
|
+
If omitted the values are estimated from the problem size.
|
|
468
|
+
**kwargs
|
|
469
|
+
``id_num`` — run identifier.
|
|
470
|
+
|
|
471
|
+
Returns
|
|
472
|
+
-------
|
|
473
|
+
Solution
|
|
474
|
+
Best solution in the final harmony memory.
|
|
475
|
+
"""
|
|
476
|
+
if ctrl is None:
|
|
477
|
+
ctrl = kwargs.pop('ctrl', None)
|
|
478
|
+
|
|
479
|
+
id_num = kwargs.pop('id_num', None)
|
|
480
|
+
|
|
481
|
+
if ctrl is None:
|
|
482
|
+
ctrl = estimate_ctrl(parameters, algorithm='hs')
|
|
483
|
+
print(f"[HS+PBIL] Auto-estimated hyperparameters (problem complexity "
|
|
484
|
+
f"= {_problem_size(parameters)['complexity']}):")
|
|
485
|
+
else:
|
|
486
|
+
print(f"[HS+PBIL] Using provided hyperparameters:")
|
|
487
|
+
|
|
488
|
+
print(_describe_ctrl(ctrl, 'hs'))
|
|
489
|
+
print()
|
|
490
|
+
|
|
491
|
+
solver = HSPBIL(parameters, init_sol, ctrl, idnum=id_num, **kwargs)
|
|
492
|
+
existing = [init_sol] if init_sol is not None else None
|
|
493
|
+
solver.run_search(existing_sols=existing)
|
|
494
|
+
solver.close_files()
|
|
495
|
+
best = solver.return_best()
|
|
496
|
+
_print_dashboard(solver, best, algorithm='HS+PBIL')
|
|
497
|
+
return best
|
|
498
|
+
|
|
499
|
+
|
|
450
500
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
451
501
|
# Unified entry point
|
|
452
502
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -496,10 +546,12 @@ def call_search(parameters, init_sol=None, algorithm='sa', ctrl=None, **kwargs):
|
|
|
496
546
|
return call_banditsa(parameters, init_sol=init_sol, ctrl=ctrl, **kwargs)
|
|
497
547
|
elif algorithm in ('hs', 'harmony', 'harmony_search'):
|
|
498
548
|
return call_harmony(parameters, init_sol=init_sol, ctrl=ctrl, **kwargs)
|
|
549
|
+
elif algorithm in ('hspbil', 'harmony_pbil', 'hs_pbil', 'hs+pbil'):
|
|
550
|
+
return call_harmony_pbil(parameters, init_sol=init_sol, ctrl=ctrl, **kwargs)
|
|
499
551
|
else:
|
|
500
552
|
raise ValueError(
|
|
501
553
|
f"Unknown algorithm '{algorithm}'. "
|
|
502
|
-
f"Choose 'sa', 'sapbil', 'banditsa', or '
|
|
554
|
+
f"Choose 'sa', 'sapbil', 'banditsa', 'hs', or 'hspbil'."
|
|
503
555
|
)
|
|
504
556
|
|
|
505
557
|
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""HS+PBIL: Harmony Search coupled with Population-Based Incremental Learning.
|
|
2
|
+
|
|
3
|
+
Reference:
|
|
4
|
+
Taco-Morales, M.F. (2026). SA + PBIL: Coupling Simulated Annealing with
|
|
5
|
+
Population-Based Incremental Learning for Discrete Choice Model Specification
|
|
6
|
+
Search. QUT Master's Thesis, May 2026.
|
|
7
|
+
|
|
8
|
+
This module extends the PBIL probability-guided perturbation mechanism from
|
|
9
|
+
SAPBIL to the Harmony Search algorithm. Instead of uniform-random
|
|
10
|
+
perturbations during pitch adjustment, the PBIL probability matrix guides
|
|
11
|
+
add/remove decisions for variables, increasing the probability of choosing
|
|
12
|
+
directions that led to significant parameters in previously accepted solutions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import logging
|
|
17
|
+
import numpy as np
|
|
18
|
+
import time as _time
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from .harmony import HarmonySearch
|
|
22
|
+
from .sapbil import ProbabilityMatrix
|
|
23
|
+
except ImportError:
|
|
24
|
+
from harmony import HarmonySearch
|
|
25
|
+
from sapbil import ProbabilityMatrix
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# pylint: disable=too-many-ancestors,attribute-defined-outside-init
|
|
31
|
+
class HSPBIL(HarmonySearch):
|
|
32
|
+
"""Harmony Search with PBIL-guided pitch adjustment.
|
|
33
|
+
|
|
34
|
+
Overrides the standard uniform-random pitch perturbations with
|
|
35
|
+
probability-weighted decisions that learn from accepted solutions.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, param, init_sol, ctrl, idnum=0, **kwargs):
|
|
39
|
+
super().__init__(param, init_sol, ctrl, idnum=idnum, **kwargs)
|
|
40
|
+
|
|
41
|
+
varnames = list(param.asvarnames or [])
|
|
42
|
+
distributions = list(param.distr or ["n", "ln", "tn", "u", "t"])
|
|
43
|
+
self.prob_matrix = ProbabilityMatrix(varnames, distributions)
|
|
44
|
+
|
|
45
|
+
self._ps_asvars = set(getattr(param, "ps_asvars", []) or [])
|
|
46
|
+
self._ps_randvars = set(
|
|
47
|
+
(getattr(param, "ps_randvars", {}) or {}).keys()
|
|
48
|
+
)
|
|
49
|
+
self._ps_bcvars = set(getattr(param, "ps_bcvars", []) or [])
|
|
50
|
+
self._ps_corvars = set(getattr(param, "ps_corvars", []) or [])
|
|
51
|
+
|
|
52
|
+
self._pbil_updates = 0
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Output files (override to use "hspbil_" prefix)
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def create_output_files(self, param, **kwargs):
|
|
59
|
+
run_id = kwargs.get('idnum', 0)
|
|
60
|
+
ts = _time.strftime("%Y%m%d_%H%M%S")
|
|
61
|
+
run_name = f"hspbil_{run_id}_{ts}" if run_id else f"hspbil_{ts}"
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
self.results_file = open(f"{run_name}_results.txt", "w")
|
|
65
|
+
except Exception:
|
|
66
|
+
self.results_file = open(f"hspbil_results.txt", "w")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
self.progress_file = open(f"{run_name}_progress.txt", "w")
|
|
70
|
+
except Exception:
|
|
71
|
+
self.progress_file = open(f"hspbil_progress.txt", "w")
|
|
72
|
+
|
|
73
|
+
print("SearchLibrium - HS+PBIL Run", file=self.results_file)
|
|
74
|
+
print(f"Run ID: {run_id}", file=self.results_file)
|
|
75
|
+
print(f"Start time: {_time.strftime('%Y-%m-%d %H:%M:%S')}", file=self.results_file)
|
|
76
|
+
print("-" * 72, file=self.results_file)
|
|
77
|
+
self.results_file.flush()
|
|
78
|
+
|
|
79
|
+
print("iteration,score", file=self.progress_file)
|
|
80
|
+
self.progress_file.flush()
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# PBIL significance helpers (same as SAPBIL)
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _build_significance_map(self, sol):
|
|
87
|
+
model = sol.get("model")
|
|
88
|
+
if model is None:
|
|
89
|
+
return {}
|
|
90
|
+
try:
|
|
91
|
+
pvalues = np.array(model.pvalues)
|
|
92
|
+
coeff_names = list(model.coeff_names) if model.coeff_names is not None else []
|
|
93
|
+
p_thresh = getattr(self.param, "p_val", 0.05)
|
|
94
|
+
return {
|
|
95
|
+
name: bool(pv <= p_thresh)
|
|
96
|
+
for name, pv in zip(coeff_names, pvalues)
|
|
97
|
+
}
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
logger.debug("HS+PBIL: could not build significance map: %s", exc)
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
def _update_probability_matrix(self, sol):
|
|
103
|
+
pm = self.prob_matrix
|
|
104
|
+
|
|
105
|
+
sig = self._build_significance_map(sol)
|
|
106
|
+
|
|
107
|
+
asvars = set(sol.get("asvars", []) or [])
|
|
108
|
+
randvars = dict(sol.get("randvars", {}) or {})
|
|
109
|
+
bcvars = set(sol.get("bcvars", []) or [])
|
|
110
|
+
corvars = set(sol.get("corvars", []) or [])
|
|
111
|
+
|
|
112
|
+
t = 1.0
|
|
113
|
+
tI = 1.0
|
|
114
|
+
|
|
115
|
+
for var in pm.varnames:
|
|
116
|
+
if var in self._ps_asvars:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
in_model = var in asvars
|
|
120
|
+
is_random = var in randvars
|
|
121
|
+
is_bc = var in bcvars
|
|
122
|
+
is_corr = var in corvars
|
|
123
|
+
|
|
124
|
+
if in_model:
|
|
125
|
+
mean_sig = sig.get(var, False)
|
|
126
|
+
sd_sig = sig.get(f"sd.{var}", False)
|
|
127
|
+
if mean_sig or (is_random and sd_sig):
|
|
128
|
+
incl_ind = 1.0
|
|
129
|
+
elif (not mean_sig) and (not is_random or (not mean_sig and not sd_sig)):
|
|
130
|
+
incl_ind = 0.0
|
|
131
|
+
else:
|
|
132
|
+
incl_ind = 0.5
|
|
133
|
+
else:
|
|
134
|
+
incl_ind = 0.0
|
|
135
|
+
|
|
136
|
+
pm.update_inclusion(var, incl_ind, t, tI)
|
|
137
|
+
|
|
138
|
+
if not in_model:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if var not in self._ps_randvars:
|
|
142
|
+
if is_random:
|
|
143
|
+
rand_ind = 1.0 if sig.get(f"sd.{var}", False) else 0.0
|
|
144
|
+
else:
|
|
145
|
+
rand_ind = 0.0
|
|
146
|
+
pm.update_random(var, rand_ind, t, tI)
|
|
147
|
+
|
|
148
|
+
if not is_random:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if var not in self._ps_randvars:
|
|
152
|
+
current_distr = randvars[var]
|
|
153
|
+
mean_sig_d = sig.get(var, False)
|
|
154
|
+
sd_sig_d = sig.get(f"sd.{var}", False)
|
|
155
|
+
distr_ind = 1.0 if (mean_sig_d and sd_sig_d) else 0.0
|
|
156
|
+
pm.update_distribution(var, current_distr, distr_ind, t, tI)
|
|
157
|
+
|
|
158
|
+
if not is_bc and var not in self._ps_corvars:
|
|
159
|
+
if is_corr:
|
|
160
|
+
chol_sig = any(
|
|
161
|
+
(
|
|
162
|
+
sig.get(f"chol.{var}.{v2}", False)
|
|
163
|
+
or sig.get(f"chol.{v2}.{var}", False)
|
|
164
|
+
)
|
|
165
|
+
for v2 in corvars
|
|
166
|
+
if v2 != var
|
|
167
|
+
)
|
|
168
|
+
corr_ind = 1.0 if chol_sig else 0.0
|
|
169
|
+
else:
|
|
170
|
+
corr_ind = 0.0
|
|
171
|
+
pm.update_correlation(var, corr_ind, t, tI)
|
|
172
|
+
|
|
173
|
+
if not is_corr and var not in self._ps_bcvars:
|
|
174
|
+
if is_bc:
|
|
175
|
+
bc_ind = 1.0 if sig.get(f"lambda.{var}", False) else 0.0
|
|
176
|
+
else:
|
|
177
|
+
bc_ind = 0.0
|
|
178
|
+
pm.update_boxcox(var, bc_ind, t, tI)
|
|
179
|
+
|
|
180
|
+
self._pbil_updates += 1
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# PBIL-guided inclusion perturbation
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _pbil_inclusion(self, sol):
|
|
187
|
+
"""Apply one PBIL-guided inclusion add/remove step."""
|
|
188
|
+
pm = self.prob_matrix
|
|
189
|
+
in_vars = [v for v in sol.get("asvars", []) if v in pm.p_inclusion]
|
|
190
|
+
out_vars = [v for v in pm.varnames if v not in sol.get("asvars", [])]
|
|
191
|
+
|
|
192
|
+
p_bar_in = float(np.mean([pm.p_inclusion[v] for v in in_vars])) if in_vars else 0.5
|
|
193
|
+
p_bar_out = float(np.mean([pm.p_inclusion[v] for v in out_vars])) if out_vars else 0.5
|
|
194
|
+
|
|
195
|
+
denom = p_bar_out + (1.0 - p_bar_in)
|
|
196
|
+
p_add = p_bar_out / denom if denom > 1e-12 else 0.5
|
|
197
|
+
|
|
198
|
+
if np.random.random() < p_add and out_vars:
|
|
199
|
+
probs = np.array([pm.p_inclusion[v] for v in out_vars], dtype=float)
|
|
200
|
+
probs = probs / (probs.sum() + 1e-12)
|
|
201
|
+
var = np.random.choice(out_vars, p=probs)
|
|
202
|
+
return self.add_asvar(var, sol)
|
|
203
|
+
elif in_vars:
|
|
204
|
+
non_ps = [v for v in in_vars if v not in self._ps_asvars]
|
|
205
|
+
if not non_ps or len(in_vars) <= 1:
|
|
206
|
+
return sol
|
|
207
|
+
probs = np.array([1.0 - pm.p_inclusion[v] for v in non_ps], dtype=float)
|
|
208
|
+
if probs.sum() < 1e-12:
|
|
209
|
+
probs = np.ones(len(non_ps), dtype=float)
|
|
210
|
+
probs = probs / probs.sum()
|
|
211
|
+
var = np.random.choice(non_ps, p=probs)
|
|
212
|
+
return self.remove_asvar(var, sol)
|
|
213
|
+
return sol
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# PBIL override of key perturbation methods from SearchBase
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def perturb_asfeature(self, sol):
|
|
220
|
+
if sol['asvars'] is None or len(sol['asvars']) == 0:
|
|
221
|
+
return self.perturb_add_asfeature(sol)
|
|
222
|
+
if np.random.random() < 0.5:
|
|
223
|
+
return self._pbil_inclusion(sol)
|
|
224
|
+
return super().perturb_asfeature(sol)
|
|
225
|
+
|
|
226
|
+
def perturb_isfeature(self, sol):
|
|
227
|
+
return super().perturb_isfeature(sol)
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Override improvise to track iteration counter for PBIL logging
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def improvise(self):
|
|
234
|
+
best, current = [], []
|
|
235
|
+
for iter in range(self.maxiter):
|
|
236
|
+
sine_iter = max(0, np.sign(math.sin(iter)))
|
|
237
|
+
self.harm_rate = (self.min_harm + ((self.max_harm - self.min_harm) / self.maxiter) * iter) * sine_iter
|
|
238
|
+
self.pitch = (self.min_pitch + ((self.max_pitch - self.min_pitch) / self.maxiter) * iter) * sine_iter
|
|
239
|
+
|
|
240
|
+
new_sol = self.build_solution(self.memory, self.harm_rate)
|
|
241
|
+
curr_sol, converged = self.pitch_adjustment(new_sol, self.pitch)
|
|
242
|
+
if converged:
|
|
243
|
+
self.insert_solution(curr_sol)
|
|
244
|
+
try:
|
|
245
|
+
self._update_probability_matrix(curr_sol)
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
all_val, obj_val = self.log_convergence(self.memory)
|
|
250
|
+
if self.generate_plots:
|
|
251
|
+
self.plot_results(self.memory, all_val, obj_val)
|
|
252
|
+
|
|
253
|
+
def run_search(self, existing_sols=None):
|
|
254
|
+
import time as _time
|
|
255
|
+
|
|
256
|
+
self.start = _time.time()
|
|
257
|
+
|
|
258
|
+
from .search import get_unique as _get_unique
|
|
259
|
+
try:
|
|
260
|
+
from .search import get_unique
|
|
261
|
+
except ImportError:
|
|
262
|
+
from search import get_unique
|
|
263
|
+
|
|
264
|
+
self.create_output_files(self.param, idnum=getattr(self, 'idnum', 0))
|
|
265
|
+
|
|
266
|
+
existing_memory = self.screen_solutions(existing_sols)
|
|
267
|
+
generated_memory = self.initialize_memory(self.max_mem)
|
|
268
|
+
init_memory = generated_memory + existing_memory
|
|
269
|
+
unique_memory = get_unique(init_memory, 0)
|
|
270
|
+
for sol in unique_memory:
|
|
271
|
+
sol.data['is_initial_sol'] = True
|
|
272
|
+
|
|
273
|
+
memory_sorted = self.sort_memory(unique_memory)
|
|
274
|
+
memory = memory_sorted[: self.max_mem]
|
|
275
|
+
self.memory = memory.copy()
|
|
276
|
+
|
|
277
|
+
if self.memory:
|
|
278
|
+
self.best_sol = self.copy_solution(self.sort_memory(self.memory)[0])
|
|
279
|
+
|
|
280
|
+
print(f"HS+PBIL[{getattr(self, 'idnum', 0)}] Memory initialised. "
|
|
281
|
+
f"Starting improvisation ({self.maxiter} iterations) ...")
|
|
282
|
+
self.improvise()
|
|
283
|
+
|
|
284
|
+
improved = self.sort_memory(self.memory.copy())
|
|
285
|
+
if improved:
|
|
286
|
+
self.best_sol = self.copy_solution(improved[0])
|
|
287
|
+
|
|
288
|
+
print(f"HS+PBIL[{getattr(self, 'idnum', 0)}]. Search complete")
|
|
289
|
+
logger.info("Search ended at: {}".format(str(_time.ctime())))
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
pm_summary = self.prob_matrix.summary()
|
|
293
|
+
print("\nHS+PBIL -- final probability matrix:", file=self.results_file)
|
|
294
|
+
for row in pm_summary:
|
|
295
|
+
print(f" {row}", file=self.results_file)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
return improved
|
|
@@ -41,6 +41,8 @@ class LatentClassMixedLogit:
|
|
|
41
41
|
n_init=1,
|
|
42
42
|
optimise_membership=True,
|
|
43
43
|
membership_maxiter=50,
|
|
44
|
+
l1_penalty=0.0,
|
|
45
|
+
l2_penalty=0.0,
|
|
44
46
|
):
|
|
45
47
|
self.n_classes = int(n_classes)
|
|
46
48
|
self.maxiter = int(maxiter)
|
|
@@ -50,6 +52,8 @@ class LatentClassMixedLogit:
|
|
|
50
52
|
self.n_init = max(1, int(n_init))
|
|
51
53
|
self.optimise_membership = bool(optimise_membership)
|
|
52
54
|
self.membership_maxiter = int(membership_maxiter)
|
|
55
|
+
self.l1_penalty = float(l1_penalty)
|
|
56
|
+
self.l2_penalty = float(l2_penalty)
|
|
53
57
|
self.descr = "LC-MXL"
|
|
54
58
|
self.coeff_est = None
|
|
55
59
|
self.coeff_names = None
|
|
@@ -87,7 +91,7 @@ class LatentClassMixedLogit:
|
|
|
87
91
|
|
|
88
92
|
def setup(self, X, y, varnames, ids, alts, avail=None, fit_intercept=False,
|
|
89
93
|
membership_vars=None, member_params_spec=None,
|
|
90
|
-
class_params_spec=None):
|
|
94
|
+
class_params_spec=None, l1_penalty=None, l2_penalty=None):
|
|
91
95
|
X = np.asarray(X, dtype=float)
|
|
92
96
|
y = np.asarray(y, dtype=float)
|
|
93
97
|
ids = np.asarray(ids)
|
|
@@ -108,6 +112,10 @@ class LatentClassMixedLogit:
|
|
|
108
112
|
self.J = len(self.alts)
|
|
109
113
|
self.K = X.shape[1]
|
|
110
114
|
self.varnames = varnames
|
|
115
|
+
if l1_penalty is not None:
|
|
116
|
+
self.l1_penalty = float(l1_penalty)
|
|
117
|
+
if l2_penalty is not None:
|
|
118
|
+
self.l2_penalty = float(l2_penalty)
|
|
111
119
|
|
|
112
120
|
order = np.lexsort((alts, ids))
|
|
113
121
|
X = X[order]
|
|
@@ -129,6 +137,23 @@ class LatentClassMixedLogit:
|
|
|
129
137
|
# ── Class-specific specification ────────────────────────────────────
|
|
130
138
|
self._class_specs = None
|
|
131
139
|
self._Ks = None # K per class
|
|
140
|
+
|
|
141
|
+
_member_util_exclude = set()
|
|
142
|
+
if membership_vars is not None:
|
|
143
|
+
for v in membership_vars:
|
|
144
|
+
if v in varnames:
|
|
145
|
+
_member_util_exclude.add(varnames.index(v))
|
|
146
|
+
elif member_params_spec is not None:
|
|
147
|
+
if hasattr(member_params_spec, 'shape'):
|
|
148
|
+
raw = member_params_spec.ravel() if member_params_spec.ndim > 1 else member_params_spec
|
|
149
|
+
for v in raw:
|
|
150
|
+
if isinstance(v, str) and v != '_inter' and v in varnames:
|
|
151
|
+
_member_util_exclude.add(varnames.index(v))
|
|
152
|
+
else:
|
|
153
|
+
for v in member_params_spec:
|
|
154
|
+
if isinstance(v, str) and v != '_inter' and v in varnames:
|
|
155
|
+
_member_util_exclude.add(varnames.index(v))
|
|
156
|
+
|
|
132
157
|
if class_params_spec is not None:
|
|
133
158
|
self._class_specs = []
|
|
134
159
|
self._Ks = []
|
|
@@ -149,9 +174,15 @@ class LatentClassMixedLogit:
|
|
|
149
174
|
self._Ks = np.array(self._Ks, dtype=int)
|
|
150
175
|
self.K_tot = int(self._Ks.sum())
|
|
151
176
|
else:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
177
|
+
if _member_util_exclude:
|
|
178
|
+
utility_idx = [i for i in range(self.K) if i not in _member_util_exclude]
|
|
179
|
+
self._Ks = np.full(self.n_classes, len(utility_idx), dtype=int)
|
|
180
|
+
self._class_specs = [np.array(utility_idx, dtype=int)] * self.n_classes
|
|
181
|
+
self.K_tot = self.n_classes * len(utility_idx)
|
|
182
|
+
else:
|
|
183
|
+
self._Ks = np.full(self.n_classes, self.K, dtype=int)
|
|
184
|
+
self._class_specs = [np.arange(self.K, dtype=int)] * self.n_classes
|
|
185
|
+
self.K_tot = self.n_classes * self.K
|
|
155
186
|
|
|
156
187
|
# ── Membership data ────────────────────────────────────────────────
|
|
157
188
|
self._has_membership = False
|
|
@@ -219,6 +250,29 @@ class LatentClassMixedLogit:
|
|
|
219
250
|
else:
|
|
220
251
|
self.X_memb_backend = None
|
|
221
252
|
|
|
253
|
+
def _regularize_l2_betas(self, betas):
|
|
254
|
+
if isinstance(betas, list):
|
|
255
|
+
return self.l2_penalty * sum(float(np.sum(np.square(np.asarray(b)))) for b in betas)
|
|
256
|
+
return self.l2_penalty * float(np.sum(np.square(np.asarray(betas))))
|
|
257
|
+
|
|
258
|
+
def _regularize_l1_betas(self, betas):
|
|
259
|
+
if isinstance(betas, list):
|
|
260
|
+
return self.l1_penalty * sum(float(np.sum(np.abs(np.asarray(b)))) for b in betas)
|
|
261
|
+
return self.l1_penalty * float(np.sum(np.abs(np.asarray(betas))))
|
|
262
|
+
|
|
263
|
+
def _regularize_l2_gammas(self, gammas):
|
|
264
|
+
if gammas is None or self.l2_penalty == 0:
|
|
265
|
+
return 0.0
|
|
266
|
+
return self.l2_penalty * float(np.sum(np.square(np.asarray(gammas))))
|
|
267
|
+
|
|
268
|
+
def _regularize_l1_gammas(self, gammas):
|
|
269
|
+
if gammas is None or self.l1_penalty == 0:
|
|
270
|
+
return 0.0
|
|
271
|
+
return self.l1_penalty * float(np.sum(np.abs(np.asarray(gammas))))
|
|
272
|
+
|
|
273
|
+
def _regularize_l1_grad(self, beta):
|
|
274
|
+
return self.l1_penalty * np.sign(np.asarray(beta, dtype=float))
|
|
275
|
+
|
|
222
276
|
@staticmethod
|
|
223
277
|
def _normalize_class_probs(class_probs):
|
|
224
278
|
class_probs = np.clip(np.asarray(class_probs, dtype=float), 1e-12, None)
|
|
@@ -271,8 +325,8 @@ class LatentClassMixedLogit:
|
|
|
271
325
|
"""
|
|
272
326
|
C = self.n_classes
|
|
273
327
|
Km = self.K_membership
|
|
274
|
-
|
|
275
|
-
priors = self._compute_membership_priors(
|
|
328
|
+
gammas_arr = gammas.reshape(C - 1, Km)
|
|
329
|
+
priors = self._compute_membership_priors(gammas_arr)
|
|
276
330
|
log_priors = np.log(np.clip(priors, 1e-300, None))
|
|
277
331
|
ll = np.sum(weights * log_priors)
|
|
278
332
|
|
|
@@ -281,7 +335,22 @@ class LatentClassMixedLogit:
|
|
|
281
335
|
for c in range(C - 1):
|
|
282
336
|
res_c = residuals[:, c]
|
|
283
337
|
grad[c] = self.X_membership.T @ res_c
|
|
284
|
-
|
|
338
|
+
|
|
339
|
+
l2 = self.l2_penalty
|
|
340
|
+
l1 = self.l1_penalty
|
|
341
|
+
if l2 > 0:
|
|
342
|
+
ll -= l2 * np.sum(np.square(gammas_arr))
|
|
343
|
+
if l1 > 0:
|
|
344
|
+
ll -= l1 * np.sum(np.abs(gammas_arr))
|
|
345
|
+
|
|
346
|
+
neg_obj = -ll
|
|
347
|
+
neg_grad = -grad
|
|
348
|
+
if l2 > 0:
|
|
349
|
+
neg_grad += 2.0 * l2 * gammas_arr
|
|
350
|
+
if l1 > 0:
|
|
351
|
+
neg_grad += l1 * np.sign(gammas_arr)
|
|
352
|
+
|
|
353
|
+
return neg_obj, neg_grad.flatten()
|
|
285
354
|
|
|
286
355
|
def _membership_m_step(self, gamma0, weights):
|
|
287
356
|
"""M-step for membership (gamma) parameters.
|
|
@@ -361,6 +430,84 @@ class LatentClassMixedLogit:
|
|
|
361
430
|
chosen_prob = np.clip((probs * self.y[:, None, :]).sum(axis=2), 1e-300, None)
|
|
362
431
|
return np.log(chosen_prob), probs
|
|
363
432
|
|
|
433
|
+
def _build_jax_full_objective(self):
|
|
434
|
+
cache_key = "_jax_full_obj"
|
|
435
|
+
if hasattr(self, cache_key):
|
|
436
|
+
return getattr(self, cache_key)
|
|
437
|
+
|
|
438
|
+
if not self._jax_enabled or len(set(self._Ks)) != 1:
|
|
439
|
+
setattr(self, cache_key, None)
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
jnp = self.jnp
|
|
443
|
+
C = self.n_classes
|
|
444
|
+
K = int(self._Ks[0])
|
|
445
|
+
n_phi = C - 1
|
|
446
|
+
avail = self.avail_backend
|
|
447
|
+
y_b = self.y_backend
|
|
448
|
+
X_full = self.X_backend
|
|
449
|
+
util_spec = self._class_specs[0] if self._class_specs is not None else None
|
|
450
|
+
if util_spec is not None and len(util_spec) < X_full.shape[2]:
|
|
451
|
+
X_b = X_full[:, :, util_spec]
|
|
452
|
+
else:
|
|
453
|
+
X_b = X_full
|
|
454
|
+
N = int(X_b.shape[0])
|
|
455
|
+
has_memb = bool(self._has_membership and self.optimise_membership
|
|
456
|
+
and self.K_membership > 0)
|
|
457
|
+
Km = self.K_membership if has_memb else 0
|
|
458
|
+
X_memb = self.X_memb_backend if has_memb else None
|
|
459
|
+
l2 = self.l2_penalty
|
|
460
|
+
l1 = self.l1_penalty
|
|
461
|
+
|
|
462
|
+
def _negloglik_flat(params):
|
|
463
|
+
phi = params[:n_phi]
|
|
464
|
+
beta_flat = params[n_phi:n_phi + C * K]
|
|
465
|
+
betas = beta_flat.reshape(C, K)
|
|
466
|
+
|
|
467
|
+
utilities = jnp.einsum("ck,njk->ncj", betas, X_b)
|
|
468
|
+
utilities = jnp.where(avail[:, None, :] > 0, utilities, -1e10)
|
|
469
|
+
utilities = utilities - jnp.max(utilities, axis=2, keepdims=True)
|
|
470
|
+
exp_u = jnp.exp(utilities) * avail[:, None, :]
|
|
471
|
+
denom = jnp.clip(exp_u.sum(axis=2, keepdims=True), 1e-300)
|
|
472
|
+
probs = exp_u / denom
|
|
473
|
+
chosen = jnp.clip((probs * y_b[:, None, :]).sum(axis=2), 1e-300)
|
|
474
|
+
log_chosen = jnp.log(chosen)
|
|
475
|
+
|
|
476
|
+
if has_memb:
|
|
477
|
+
gammas = params[n_phi + C * K:].reshape(C - 1, Km)
|
|
478
|
+
logits = jnp.zeros((N, C))
|
|
479
|
+
for c in range(C - 1):
|
|
480
|
+
logits = logits.at[:, c].set(X_memb @ gammas[c])
|
|
481
|
+
logits = logits - jnp.max(logits, axis=1, keepdims=True)
|
|
482
|
+
exp_logits = jnp.exp(logits)
|
|
483
|
+
priors = exp_logits / jnp.clip(jnp.sum(exp_logits, axis=1, keepdims=True), 1e-300, None)
|
|
484
|
+
log_prior = jnp.log(jnp.clip(priors, 1e-300))
|
|
485
|
+
else:
|
|
486
|
+
phi_full = jnp.concatenate([phi, jnp.zeros(1)])
|
|
487
|
+
log_priors_raw = phi_full - self.jax_logsumexp(phi_full)
|
|
488
|
+
log_prior = jnp.broadcast_to(log_priors_raw[None, :], (N, C))
|
|
489
|
+
|
|
490
|
+
log_joint = log_chosen + log_prior
|
|
491
|
+
log_marg = self.jax_logsumexp(log_joint, axis=1)
|
|
492
|
+
ll = jnp.sum(log_marg)
|
|
493
|
+
ll -= l2 * jnp.sum(jnp.square(betas))
|
|
494
|
+
ll -= l1 * jnp.sum(jnp.abs(betas))
|
|
495
|
+
if has_memb:
|
|
496
|
+
ll -= l2 * jnp.sum(jnp.square(gammas))
|
|
497
|
+
ll -= l1 * jnp.sum(jnp.abs(gammas))
|
|
498
|
+
return -ll
|
|
499
|
+
|
|
500
|
+
obj, grad_fn = None, None
|
|
501
|
+
try:
|
|
502
|
+
obj = self.jit(_negloglik_flat)
|
|
503
|
+
grad_fn = self.jit(self.value_and_grad(_negloglik_flat))
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
cached = (obj, grad_fn)
|
|
508
|
+
setattr(self, cache_key, cached)
|
|
509
|
+
return cached
|
|
510
|
+
|
|
364
511
|
def _build_jax_weighted_objective(self, X_c=None, class_idx=None):
|
|
365
512
|
if X_c is None:
|
|
366
513
|
cache_key = "_full"
|
|
@@ -419,7 +566,10 @@ class LatentClassMixedLogit:
|
|
|
419
566
|
|
|
420
567
|
def objective(beta):
|
|
421
568
|
value, grad = grad_fn(self.jnp.asarray(beta), weights_backend)
|
|
422
|
-
|
|
569
|
+
l2 = self.l2_penalty * float(self.jnp.sum(self.jnp.square(beta)))
|
|
570
|
+
l1 = self.l1_penalty * float(self.jnp.sum(self.jnp.abs(beta)))
|
|
571
|
+
grad_l1 = self._regularize_l1_grad(np.asarray(beta))
|
|
572
|
+
return float(value) + l2 + l1, np.asarray(grad, dtype=float) + 2.0 * self.l2_penalty * np.asarray(beta) + grad_l1
|
|
423
573
|
else:
|
|
424
574
|
def objective(beta):
|
|
425
575
|
utilities = np.einsum("njk,k->nj", X_c, beta)
|
|
@@ -431,7 +581,9 @@ class LatentClassMixedLogit:
|
|
|
431
581
|
loglik = np.sum(weights * np.log(chosen_prob))
|
|
432
582
|
diff = (self.y - probs) * weights[:, None]
|
|
433
583
|
grad = np.einsum("nj,njk->k", diff, X_c)
|
|
434
|
-
|
|
584
|
+
l2 = self.l2_penalty * float(np.sum(beta * beta))
|
|
585
|
+
l1 = self.l1_penalty * float(np.sum(np.abs(beta)))
|
|
586
|
+
return -loglik + l2 + l1, -grad + 2.0 * self.l2_penalty * beta + self._regularize_l1_grad(beta)
|
|
435
587
|
|
|
436
588
|
result = minimize(
|
|
437
589
|
objective,
|
|
@@ -469,11 +621,18 @@ class LatentClassMixedLogit:
|
|
|
469
621
|
y_b = self.y_backend
|
|
470
622
|
av_b = self.avail_backend
|
|
471
623
|
C = self.n_classes
|
|
624
|
+
K_util = int(self._Ks[0])
|
|
625
|
+
util_spec = self._class_specs[0] if self._class_specs is not None else None
|
|
626
|
+
|
|
627
|
+
if util_spec is not None and len(util_spec) < self.K:
|
|
628
|
+
X_util = X_b[:, :, util_spec]
|
|
629
|
+
else:
|
|
630
|
+
X_util = X_b
|
|
472
631
|
|
|
473
632
|
@self.jit
|
|
474
633
|
def _jax_negll(betas_flat):
|
|
475
|
-
betas = betas_flat.reshape(C,
|
|
476
|
-
utilities = jnp.einsum("ck,njk->ncj", betas,
|
|
634
|
+
betas = betas_flat.reshape(C, K_util)
|
|
635
|
+
utilities = jnp.einsum("ck,njk->ncj", betas, X_util)
|
|
477
636
|
utilities = jnp.where(av_b[:, None, :] > 0, utilities, -1e10)
|
|
478
637
|
utilities = utilities - jnp.max(utilities, axis=2, keepdims=True)
|
|
479
638
|
exp_u = jnp.exp(utilities) * av_b[:, None, :]
|
|
@@ -484,7 +643,10 @@ class LatentClassMixedLogit:
|
|
|
484
643
|
log_prior = jnp.log(jnp.full(C, 1.0 / C))
|
|
485
644
|
log_joint = log_chosen + log_prior[None, :]
|
|
486
645
|
log_marg = self.jax_logsumexp(log_joint, axis=1)
|
|
487
|
-
|
|
646
|
+
ll = jnp.sum(log_marg)
|
|
647
|
+
l2 = self.l2_penalty * jnp.sum(jnp.square(betas))
|
|
648
|
+
l1 = self.l1_penalty * jnp.sum(jnp.abs(betas))
|
|
649
|
+
return -(ll - l2 - l1)
|
|
488
650
|
|
|
489
651
|
def _obj(betas_np):
|
|
490
652
|
return float(_jax_negll(jnp.array(betas_np, dtype=jnp.float64)))
|
|
@@ -500,7 +662,10 @@ class LatentClassMixedLogit:
|
|
|
500
662
|
log_prior = np.log(np.full(self.n_classes, 1.0 / self.n_classes))
|
|
501
663
|
log_joint = log_choice + log_prior[None, :]
|
|
502
664
|
log_marg = logsumexp(log_joint, axis=1)
|
|
503
|
-
|
|
665
|
+
ll = float(log_marg.sum())
|
|
666
|
+
ll -= self._regularize_l2_betas(betas)
|
|
667
|
+
ll -= self._regularize_l1_betas(betas)
|
|
668
|
+
return -ll
|
|
504
669
|
|
|
505
670
|
K_flat = sum(self._Ks)
|
|
506
671
|
print(
|
|
@@ -561,6 +726,10 @@ class LatentClassMixedLogit:
|
|
|
561
726
|
log_denom = logsumexp(log_joint, axis=1, keepdims=True)
|
|
562
727
|
posterior = np.exp(log_joint - log_denom)
|
|
563
728
|
loglik = float(log_denom.sum())
|
|
729
|
+
loglik -= self._regularize_l2_betas(betas)
|
|
730
|
+
loglik -= self._regularize_l1_betas(betas)
|
|
731
|
+
loglik -= self._regularize_l2_gammas(gammas)
|
|
732
|
+
loglik -= self._regularize_l1_gammas(gammas)
|
|
564
733
|
|
|
565
734
|
new_class_probs = self._normalize_class_probs(posterior.mean(axis=0))
|
|
566
735
|
|
|
@@ -585,7 +754,12 @@ class LatentClassMixedLogit:
|
|
|
585
754
|
priors = np.broadcast_to(class_probs[None, :], (self.N, self.n_classes))
|
|
586
755
|
|
|
587
756
|
log_joint = log_choice + np.log(np.clip(priors, 1e-300, None))
|
|
588
|
-
|
|
757
|
+
ll = float(logsumexp(log_joint, axis=1).sum())
|
|
758
|
+
ll -= self._regularize_l2_betas(betas)
|
|
759
|
+
ll -= self._regularize_l1_betas(betas)
|
|
760
|
+
ll -= self._regularize_l2_gammas(gammas)
|
|
761
|
+
ll -= self._regularize_l1_gammas(gammas)
|
|
762
|
+
return ll
|
|
589
763
|
|
|
590
764
|
def _fit_em_once(self, rng, betas0=None, class_probs0=None, gammas0=None):
|
|
591
765
|
betas = self._make_initial_betas(rng, betas0=betas0)
|
|
@@ -607,6 +781,10 @@ class LatentClassMixedLogit:
|
|
|
607
781
|
log_denom = logsumexp(log_joint, axis=1, keepdims=True)
|
|
608
782
|
posterior = np.exp(log_joint - log_denom)
|
|
609
783
|
loglik = float(log_denom.sum())
|
|
784
|
+
loglik -= self._regularize_l2_betas(betas)
|
|
785
|
+
loglik -= self._regularize_l1_betas(betas)
|
|
786
|
+
loglik -= self._regularize_l2_gammas(gammas)
|
|
787
|
+
loglik -= self._regularize_l1_gammas(gammas)
|
|
610
788
|
|
|
611
789
|
class_probs = self._normalize_class_probs(posterior.mean(axis=0))
|
|
612
790
|
for c in range(self.n_classes):
|
|
@@ -882,15 +1060,32 @@ class LatentClassMixedLogit:
|
|
|
882
1060
|
if maxiter is None:
|
|
883
1061
|
maxiter = max(100, 100 * len(params0))
|
|
884
1062
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1063
|
+
jax_obj = self._build_jax_full_objective()
|
|
1064
|
+
if jax_obj is not None:
|
|
1065
|
+
_jax_func, _jax_grad = jax_obj
|
|
1066
|
+
|
|
1067
|
+
def _neg_ll_jax(params):
|
|
1068
|
+
b = self.jnp.asarray(params, dtype=self.jnp.float64)
|
|
1069
|
+
v, g = _jax_grad(b)
|
|
1070
|
+
return float(v), np.asarray(g, dtype=float)
|
|
1071
|
+
|
|
1072
|
+
result = minimize(
|
|
1073
|
+
_neg_ll_jax,
|
|
1074
|
+
params0,
|
|
1075
|
+
method="L-BFGS-B",
|
|
1076
|
+
jac=True,
|
|
1077
|
+
options={"maxiter": maxiter, "ftol": self.tol},
|
|
1078
|
+
)
|
|
1079
|
+
else:
|
|
1080
|
+
def _neg_ll(params):
|
|
1081
|
+
return -self._full_loglik(params)
|
|
1082
|
+
|
|
1083
|
+
result = minimize(
|
|
1084
|
+
_neg_ll,
|
|
1085
|
+
params0,
|
|
1086
|
+
method="L-BFGS-B",
|
|
1087
|
+
options={"maxiter": maxiter, "ftol": self.tol},
|
|
1088
|
+
)
|
|
894
1089
|
|
|
895
1090
|
params = result.x
|
|
896
1091
|
offset = C - 1
|
|
@@ -970,7 +1165,7 @@ class LatentClassMixedLogit:
|
|
|
970
1165
|
else:
|
|
971
1166
|
base_beta = self.class_betas[source_idx]
|
|
972
1167
|
rng = np.random.default_rng(self.random_state + next_classes)
|
|
973
|
-
jitter = rng.normal(scale=jitter_scale, size=
|
|
1168
|
+
jitter = rng.normal(scale=jitter_scale, size=base_beta.shape[0])
|
|
974
1169
|
new_betas = np.vstack([self.class_betas, base_beta + jitter])
|
|
975
1170
|
|
|
976
1171
|
new_probs = np.empty(next_classes, dtype=float)
|
|
@@ -1105,7 +1300,10 @@ class LatentClassMixedLogit:
|
|
|
1105
1300
|
)
|
|
1106
1301
|
)
|
|
1107
1302
|
log_joint = log_chosen + np.log(np.clip(priors, 1e-300, None))
|
|
1108
|
-
|
|
1303
|
+
ll = float(logsumexp(log_joint, axis=1).sum())
|
|
1304
|
+
ll -= self._regularize_l2_betas(betas)
|
|
1305
|
+
ll -= self._regularize_l1_betas(betas)
|
|
1306
|
+
return ll
|
|
1109
1307
|
|
|
1110
1308
|
def _numerical_hessian(self, params: np.ndarray, eps: float = 1e-4) -> np.ndarray:
|
|
1111
1309
|
"""Numerical Hessian of the log-likelihood via central finite differences.
|
|
@@ -1423,8 +1621,17 @@ class LatentClassMixedLogit:
|
|
|
1423
1621
|
print(f" class_{idx}: {share:.6f}")
|
|
1424
1622
|
print(" Coefficients:")
|
|
1425
1623
|
for c_idx, beta in enumerate(self.class_betas, start=1):
|
|
1426
|
-
|
|
1624
|
+
idx = self._class_specs[c_idx - 1] if self._class_specs is not None else range(len(beta))
|
|
1625
|
+
for k, value in zip(idx, beta):
|
|
1626
|
+
name = self.varnames[k]
|
|
1427
1627
|
print(f" class_{c_idx}_{name}: {value:.6f}")
|
|
1628
|
+
if self.class_gammas is not None and self._has_membership and self.optimise_membership:
|
|
1629
|
+
gammas_arr = np.asarray(self.class_gammas)
|
|
1630
|
+
print(" Membership Parameters:")
|
|
1631
|
+
for c in range(self.n_classes - 1):
|
|
1632
|
+
for k in range(gammas_arr.shape[1]):
|
|
1633
|
+
label = f"gamma_{c + 1}_{self.membership_vars[k]}"
|
|
1634
|
+
print(f" {label}: {gammas_arr[c, k]:.6f}")
|
|
1428
1635
|
return
|
|
1429
1636
|
|
|
1430
1637
|
params = stats["params"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.0.121
|
|
@@ -15,6 +15,7 @@ src/SearchLibrium/boxcox_functions.py
|
|
|
15
15
|
src/SearchLibrium/call_meta.py
|
|
16
16
|
src/SearchLibrium/constraints_builder.py
|
|
17
17
|
src/SearchLibrium/harmony.py
|
|
18
|
+
src/SearchLibrium/hspbil.py
|
|
18
19
|
src/SearchLibrium/latent_class.py
|
|
19
20
|
src/SearchLibrium/main.py
|
|
20
21
|
src/SearchLibrium/main_debug.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.0.119
|
|
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.119 → searchlibrium-0.0.121}/src/SearchLibrium/test_sapbil_vs_banditsa.py
RENAMED
|
File without changes
|
|
File without changes
|
{searchlibrium-0.0.119 → searchlibrium-0.0.121}/src/SearchLibrium.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|