kulprit 0.2.0__tar.gz → 0.3.0__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.
- {kulprit-0.2.0 → kulprit-0.3.0}/PKG-INFO +2 -3
- {kulprit-0.2.0 → kulprit-0.3.0}/kulprit/__init__.py +2 -2
- {kulprit-0.2.0 → kulprit-0.3.0}/kulprit/plots/plots.py +36 -51
- kulprit-0.3.0/kulprit/projection/arviz_io.py +44 -0
- kulprit-0.3.0/kulprit/projection/pymc_io.py +87 -0
- kulprit-0.3.0/kulprit/projection/search_strategies.py +188 -0
- kulprit-0.3.0/kulprit/projection/solver.py +63 -0
- kulprit-0.3.0/kulprit/projector.py +415 -0
- {kulprit-0.2.0 → kulprit-0.3.0}/pyproject.toml +1 -3
- kulprit-0.2.0/kulprit/data/__init__.py +0 -1
- kulprit-0.2.0/kulprit/data/submodel.py +0 -33
- kulprit-0.2.0/kulprit/projection/projector.py +0 -273
- kulprit-0.2.0/kulprit/projection/solver.py +0 -250
- kulprit-0.2.0/kulprit/reference.py +0 -302
- kulprit-0.2.0/kulprit/search/__init__.py +0 -12
- kulprit-0.2.0/kulprit/search/forward.py +0 -139
- kulprit-0.2.0/kulprit/search/l1.py +0 -141
- kulprit-0.2.0/kulprit/search/searcher.py +0 -76
- {kulprit-0.2.0 → kulprit-0.3.0}/README.md +0 -0
- {kulprit-0.2.0 → kulprit-0.3.0}/kulprit/datasets.py +0 -0
- {kulprit-0.2.0 → kulprit-0.3.0}/kulprit/plots/__init__.py +0 -0
- {kulprit-0.2.0 → kulprit-0.3.0}/kulprit/projection/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: kulprit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Kullback-Leibler projections for Bayesian model selection.
|
|
5
5
|
Maintainer-email: Osvaldo Martin <aloctavodia@gmail.com>, Tomás Capretto <tomicapretto@gmail.com>
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -18,7 +18,6 @@ Requires-Dist: arviz>=0.17.1
|
|
|
18
18
|
Requires-Dist: bambi>=0.12.0
|
|
19
19
|
Requires-Dist: scikit-learn>=1.0.2
|
|
20
20
|
Requires-Dist: numba>=0.56.0
|
|
21
|
-
Requires-Dist: preliz>=0.4.1
|
|
22
21
|
Project-URL: source, https://github.com/bambinos/kulprit
|
|
23
22
|
Project-URL: tracker, https://github.com/bambinos/kulprit/issues
|
|
24
23
|
|
|
@@ -4,11 +4,11 @@ Kulprit.
|
|
|
4
4
|
Kullback-Leibler projections for Bayesian model selection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from kulprit.
|
|
7
|
+
from kulprit.projector import ProjectionPredictive
|
|
8
8
|
from kulprit.datasets import *
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
__version__ = "0.
|
|
11
|
+
__version__ = "0.3.0"
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
__all__ = ["ProjectionPredictive"]
|
|
@@ -4,7 +4,9 @@ import matplotlib.pyplot as plt
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def plot_compare(
|
|
7
|
+
def plot_compare(
|
|
8
|
+
elpd_info, label_terms=None, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
9
|
+
):
|
|
8
10
|
"""
|
|
9
11
|
Plot model comparison.
|
|
10
12
|
|
|
@@ -14,6 +16,8 @@ def plot_compare(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
|
14
16
|
Dataframe containing the comparison data. Should have columns
|
|
15
17
|
`elpd_loo` and `elpd_diff` containing the ELPD values and the
|
|
16
18
|
differences to the reference model.
|
|
19
|
+
label_terms : list
|
|
20
|
+
List of the labels for the submodels.
|
|
17
21
|
legend : bool
|
|
18
22
|
Flag for plotting the legend, default True.
|
|
19
23
|
title : bool
|
|
@@ -22,29 +26,26 @@ def plot_compare(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
|
22
26
|
Figure size. If None it will be defined automatically.
|
|
23
27
|
plot_kwargs : dict
|
|
24
28
|
"""
|
|
25
|
-
|
|
26
29
|
if plot_kwargs is None:
|
|
27
30
|
plot_kwargs = {}
|
|
28
31
|
|
|
29
32
|
if figsize is None:
|
|
30
|
-
figsize = (
|
|
33
|
+
figsize = (10, 4)
|
|
31
34
|
|
|
32
35
|
figsize, ax_labelsize, _, xt_labelsize, linewidth, _ = _scale_fig_size(figsize, None, 1, 1)
|
|
33
36
|
|
|
34
|
-
xticks_pos
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
xticks_labels[0] = labels[0]
|
|
40
|
-
xticks_labels[2::2] = labels[1:]
|
|
37
|
+
xticks_pos = np.linspace(0, 1, len(elpd_info) - 1)
|
|
38
|
+
xticks_num_labels = [value[0] for value in elpd_info[1:]]
|
|
39
|
+
xticks_name_labels = [f"\n\n{term}" for term in label_terms]
|
|
40
|
+
elpd_loo = [value[1] for value in elpd_info]
|
|
41
|
+
elpd_se = [value[2] for value in elpd_info]
|
|
41
42
|
|
|
42
43
|
fig, axes = plt.subplots(1, figsize=figsize)
|
|
43
44
|
|
|
44
45
|
axes.errorbar(
|
|
45
|
-
y=
|
|
46
|
-
x=xticks_pos
|
|
47
|
-
yerr=
|
|
46
|
+
y=elpd_loo[1:],
|
|
47
|
+
x=xticks_pos,
|
|
48
|
+
yerr=elpd_se[1:],
|
|
48
49
|
label="Submodels",
|
|
49
50
|
color=plot_kwargs.get("color_eldp", "k"),
|
|
50
51
|
fmt=plot_kwargs.get("marker_eldp", "o"),
|
|
@@ -55,7 +56,7 @@ def plot_compare(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
|
55
56
|
)
|
|
56
57
|
|
|
57
58
|
axes.axhline(
|
|
58
|
-
|
|
59
|
+
elpd_loo[0],
|
|
59
60
|
ls=plot_kwargs.get("ls_reference", "--"),
|
|
60
61
|
color=plot_kwargs.get("color_ls_reference", "grey"),
|
|
61
62
|
lw=linewidth,
|
|
@@ -63,16 +64,16 @@ def plot_compare(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
|
63
64
|
)
|
|
64
65
|
|
|
65
66
|
axes.fill_between(
|
|
66
|
-
[-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
[-0.15, 1.15],
|
|
68
|
+
elpd_loo[0] + elpd_se[0],
|
|
69
|
+
elpd_loo[0] - elpd_se[0],
|
|
69
70
|
alpha=0.1,
|
|
70
71
|
color=plot_kwargs.get("color_ls_reference", "grey"),
|
|
71
72
|
)
|
|
72
73
|
|
|
73
74
|
if legend:
|
|
74
75
|
fig.legend(
|
|
75
|
-
bbox_to_anchor=(0.9, 0.
|
|
76
|
+
bbox_to_anchor=(0.9, 0.3),
|
|
76
77
|
loc="lower right",
|
|
77
78
|
ncol=1,
|
|
78
79
|
fontsize=ax_labelsize * 0.6,
|
|
@@ -84,15 +85,18 @@ def plot_compare(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
|
84
85
|
fontsize=ax_labelsize * 0.6,
|
|
85
86
|
)
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
xticks_pos,
|
|
88
|
+
sec0 = axes.secondary_xaxis(location=0)
|
|
89
|
+
sec0.set_xticks(xticks_pos, xticks_num_labels)
|
|
90
|
+
sec0.tick_params("x", length=0, labelsize=xt_labelsize * 0.6)
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
sec1 = axes.secondary_xaxis(location=0)
|
|
93
|
+
sec1.set_xticks(xticks_pos, xticks_name_labels, rotation=plot_kwargs.get("xlabel_rotation", 0))
|
|
94
|
+
sec1.tick_params("x", length=0, labelsize=xt_labelsize * 0.6)
|
|
95
|
+
sec1.set_xlabel("Submodels", fontsize=ax_labelsize * 0.6)
|
|
96
|
+
|
|
97
|
+
axes.set_xticks([])
|
|
92
98
|
axes.set_ylabel("ELPD", fontsize=ax_labelsize * 0.6)
|
|
93
|
-
axes.
|
|
94
|
-
axes.set_xticklabels(xticks_labels)
|
|
95
|
-
axes.set_xlim(-1 + step, 0 - step)
|
|
99
|
+
axes.set_xlim(-0.1, 1.1)
|
|
96
100
|
axes.tick_params(labelsize=xt_labelsize * 0.6)
|
|
97
101
|
|
|
98
102
|
return axes
|
|
@@ -100,47 +104,35 @@ def plot_compare(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None
|
|
|
100
104
|
|
|
101
105
|
def plot_densities(
|
|
102
106
|
model,
|
|
103
|
-
path,
|
|
104
107
|
idata,
|
|
108
|
+
submodels,
|
|
105
109
|
var_names=None,
|
|
106
|
-
submodels=None,
|
|
107
110
|
include_reference=True,
|
|
108
|
-
labels="
|
|
111
|
+
labels="size",
|
|
109
112
|
kind="density",
|
|
110
113
|
figsize=None,
|
|
111
114
|
plot_kwargs=None,
|
|
112
115
|
):
|
|
113
116
|
"""Compare the projected posterior densities of the submodels"""
|
|
114
|
-
|
|
115
117
|
if plot_kwargs is None:
|
|
116
118
|
plot_kwargs = {}
|
|
117
|
-
plot_kwargs.setdefault("figsize", figsize)
|
|
118
119
|
|
|
119
120
|
if kind not in ["density", "forest"]:
|
|
120
121
|
raise ValueError("kind must be one of 'density' or 'forest'")
|
|
121
122
|
|
|
122
123
|
# set default variable names to the reference model terms
|
|
123
124
|
if not var_names:
|
|
124
|
-
var_names =
|
|
125
|
-
set(model.components[model.family.likelihood.parent].common_terms.keys())
|
|
126
|
-
- set([model.response_component.term.name])
|
|
127
|
-
)
|
|
125
|
+
var_names = [fvar.name for fvar in model.backend.model.free_RVs]
|
|
128
126
|
|
|
129
127
|
if include_reference:
|
|
130
128
|
data = [idata]
|
|
131
129
|
l_labels = ["Reference"]
|
|
132
|
-
var_names.append(f"~{model.family.likelihood.parent}")
|
|
133
130
|
else:
|
|
134
131
|
data = []
|
|
135
132
|
l_labels = []
|
|
136
133
|
|
|
137
|
-
if submodels is None:
|
|
138
|
-
submodels = path.values()
|
|
139
|
-
else:
|
|
140
|
-
submodels = [path[key] for key in submodels]
|
|
141
|
-
|
|
142
134
|
if labels == "formula":
|
|
143
|
-
l_labels.extend([submodel.
|
|
135
|
+
l_labels.extend([",".join(submodel.term_names) for submodel in submodels])
|
|
144
136
|
else:
|
|
145
137
|
l_labels.extend([submodel.size for submodel in submodels])
|
|
146
138
|
|
|
@@ -149,6 +141,7 @@ def plot_densities(
|
|
|
149
141
|
if kind == "density":
|
|
150
142
|
plot_kwargs.setdefault("outline", False)
|
|
151
143
|
plot_kwargs.setdefault("shade", 0.4)
|
|
144
|
+
plot_kwargs.setdefault("figsize", figsize)
|
|
152
145
|
|
|
153
146
|
axes = plot_density(
|
|
154
147
|
data=data,
|
|
@@ -159,6 +152,8 @@ def plot_densities(
|
|
|
159
152
|
|
|
160
153
|
elif kind == "forest":
|
|
161
154
|
plot_kwargs.setdefault("combined", True)
|
|
155
|
+
plot_kwargs.setdefault("figsize", (10, 2 + len(var_names) ** 0.5))
|
|
156
|
+
|
|
162
157
|
axes = plot_forest(
|
|
163
158
|
data=data,
|
|
164
159
|
model_names=l_labels,
|
|
@@ -167,13 +162,3 @@ def plot_densities(
|
|
|
167
162
|
)
|
|
168
163
|
|
|
169
164
|
return axes
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def align_yaxis(axes, v_1, ax2, v_2):
|
|
173
|
-
"""adjust ax2 ylimit so that v2 in ax2 is aligned to v1 in axes"""
|
|
174
|
-
_, y_1 = axes.transData.transform((0, v_1))
|
|
175
|
-
_, y_2 = ax2.transData.transform((0, v_2))
|
|
176
|
-
inv = ax2.transData.inverted()
|
|
177
|
-
_, d_y = inv.transform((0, 0)) - inv.transform((0, y_1 - y_2))
|
|
178
|
-
miny, maxy = ax2.get_ylim()
|
|
179
|
-
ax2.set_ylim(miny + d_y, maxy + d_y)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Functions to interact with InferenceData objects"""
|
|
2
|
+
import warnings
|
|
3
|
+
from arviz import convert_to_dataset, extract, loo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_observed_data(idata, response_name):
|
|
7
|
+
"""Extract the observed data from the reference model."""
|
|
8
|
+
observed_data = {response_name: idata.observed_data.get(response_name).to_dict().get("data")}
|
|
9
|
+
return convert_to_dataset(observed_data), idata.observed_data.get(response_name).values
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_pps(model, idata):
|
|
13
|
+
"""Compute posterior predictive samples from the reference model."""
|
|
14
|
+
if "posterior_predictive" not in idata.groups():
|
|
15
|
+
model.predict(idata, kind="response", inplace=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_pps(idata, response_name, num_samples):
|
|
19
|
+
"""Extract samples posterior predictive samples from the reference model."""
|
|
20
|
+
pps = extract(
|
|
21
|
+
idata,
|
|
22
|
+
group="posterior_predictive",
|
|
23
|
+
var_names=[response_name],
|
|
24
|
+
num_samples=num_samples,
|
|
25
|
+
rng=1,
|
|
26
|
+
).values.T
|
|
27
|
+
return pps
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compute_loo(submodel=None, idata=None):
|
|
31
|
+
"""Compute the PSIS-LOO-CV for a submodel or InferenceData object."""
|
|
32
|
+
with warnings.catch_warnings():
|
|
33
|
+
warnings.filterwarnings(
|
|
34
|
+
"ignore", message="Estimated shape parameter of Pareto distribution"
|
|
35
|
+
)
|
|
36
|
+
if submodel is not None:
|
|
37
|
+
elpd = loo(submodel.idata)
|
|
38
|
+
submodel.elpd_loo = elpd.elpd_loo
|
|
39
|
+
submodel.elpd_se = elpd.se
|
|
40
|
+
|
|
41
|
+
if idata is not None:
|
|
42
|
+
return loo(idata)
|
|
43
|
+
|
|
44
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Functions to interact with PyMC models"""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from pymc import do, compute_log_likelihood
|
|
5
|
+
from pymc.util import is_transformed_name, get_untransformed_name
|
|
6
|
+
from pymc.pytensorf import join_nonshared_inputs
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
# Try to import compile_pymc as compile for older versions of pymc
|
|
10
|
+
from pymc.pytensorf import compile_pymc as compile_
|
|
11
|
+
except ImportError:
|
|
12
|
+
# Fallback to importing compile for newer versions of pymc
|
|
13
|
+
from pymc.pytensorf import compile as compile_
|
|
14
|
+
|
|
15
|
+
from pytensor import function
|
|
16
|
+
from pytensor.tensor import matrix
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compile_mllk(model):
|
|
20
|
+
"""
|
|
21
|
+
Compile the log-likelihood function for the model to be able to condition on both
|
|
22
|
+
data and parameters.
|
|
23
|
+
"""
|
|
24
|
+
obs_rvs = model.observed_RVs[0]
|
|
25
|
+
old_y_value = model.rvs_to_values[obs_rvs]
|
|
26
|
+
new_y_value = obs_rvs.type()
|
|
27
|
+
model.rvs_to_values[obs_rvs] = new_y_value
|
|
28
|
+
|
|
29
|
+
vars_ = model.value_vars
|
|
30
|
+
initial_point = model.initial_point()
|
|
31
|
+
|
|
32
|
+
[logp], raveled_inp = join_nonshared_inputs(
|
|
33
|
+
point=initial_point, outputs=[model.datalogp], inputs=vars_
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
rv_logp_fn = compile_([raveled_inp, new_y_value], logp)
|
|
37
|
+
rv_logp_fn.trust_input = True
|
|
38
|
+
|
|
39
|
+
def fmodel(params, obs):
|
|
40
|
+
return -rv_logp_fn(params, obs)
|
|
41
|
+
|
|
42
|
+
return fmodel, old_y_value, obs_rvs
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compute_new_model(model, ref_var_info, all_terms, term_names):
|
|
46
|
+
"""
|
|
47
|
+
Compute a new model by excluding the terms not in term_names.
|
|
48
|
+
"""
|
|
49
|
+
# get all the terms not in term_names
|
|
50
|
+
exclude_terms = {term: 0 for term in set(all_terms) - set(term_names)}
|
|
51
|
+
|
|
52
|
+
for term in exclude_terms.keys():
|
|
53
|
+
shape = ref_var_info[term][0]
|
|
54
|
+
exclude_terms[term] = np.zeros(shape)
|
|
55
|
+
|
|
56
|
+
return do(model, exclude_terms)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compute_llk(idata, model):
|
|
60
|
+
"""Compute log-likelihood for the submodel."""
|
|
61
|
+
return compute_log_likelihood(idata, model=model, progressbar=False, extend_inferencedata=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_model_information(model):
|
|
65
|
+
"""
|
|
66
|
+
Get the size and transformation of each variable in a PyMC model.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
var_info = {}
|
|
70
|
+
initial_point = model.initial_point()
|
|
71
|
+
for v_var in model.value_vars:
|
|
72
|
+
name = v_var.name
|
|
73
|
+
if is_transformed_name(name):
|
|
74
|
+
name = get_untransformed_name(name)
|
|
75
|
+
x_var = matrix(f"{name}_transformed")
|
|
76
|
+
z_var = model.rvs_to_transforms[model.values_to_rvs[v_var]].backward(x_var)
|
|
77
|
+
transformation = function(inputs=[x_var], outputs=z_var)
|
|
78
|
+
else:
|
|
79
|
+
transformation = None
|
|
80
|
+
|
|
81
|
+
var_info[name] = (
|
|
82
|
+
initial_point[v_var.name].shape,
|
|
83
|
+
initial_point[v_var.name].size,
|
|
84
|
+
transformation,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return var_info
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""This module contains the search strategies"""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from sklearn.linear_model import lasso_path
|
|
5
|
+
from kulprit.projection.arviz_io import compute_loo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def user_path(_project, path):
|
|
9
|
+
submodels = []
|
|
10
|
+
for term_names in path:
|
|
11
|
+
submodel = _project(term_names)
|
|
12
|
+
compute_loo(submodel=submodel)
|
|
13
|
+
submodels.append(submodel)
|
|
14
|
+
return submodels
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def forward_search(_project, ref_terms, max_terms, elpd_ref, early_stop):
|
|
18
|
+
"""Method for performing forward search.
|
|
19
|
+
|
|
20
|
+
Parameters:
|
|
21
|
+
----------
|
|
22
|
+
_project : function
|
|
23
|
+
A function that takes a list of term names and returns a submodel object.
|
|
24
|
+
ref_terms : list
|
|
25
|
+
A list of all terms in the model.
|
|
26
|
+
max_terms : int
|
|
27
|
+
The maximum number of terms to include in the submodel.
|
|
28
|
+
elpd_ref : float
|
|
29
|
+
The expected log pointwise predictive density of the reference model.
|
|
30
|
+
early_stop : str
|
|
31
|
+
The early stopping criterion. Either "mean" or "se".
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
-------
|
|
35
|
+
List: A list of submodels, each containing the terms of the submodel and its ELPD.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# initial intercept-only subset
|
|
39
|
+
submodel_size = 0
|
|
40
|
+
term_names = []
|
|
41
|
+
submodel = _project(term_names)
|
|
42
|
+
compute_loo(submodel=submodel)
|
|
43
|
+
submodels = [submodel]
|
|
44
|
+
|
|
45
|
+
while submodel_size < max_terms:
|
|
46
|
+
# increment submodel size
|
|
47
|
+
submodel_size += 1
|
|
48
|
+
|
|
49
|
+
# get list of candidate submodels, project onto them, and compute
|
|
50
|
+
# their distances
|
|
51
|
+
candidates = _get_candidates(submodel.term_names, ref_terms)
|
|
52
|
+
projections = [_project(candidate) for candidate in candidates]
|
|
53
|
+
|
|
54
|
+
# identify the best candidate by loss (equivalent to KL min)
|
|
55
|
+
submodel = min(projections, key=lambda projection: projection.loss)
|
|
56
|
+
|
|
57
|
+
# compute loo for the best candidate and update inplace
|
|
58
|
+
compute_loo(submodel=submodel)
|
|
59
|
+
|
|
60
|
+
if _early_stopping(submodel, elpd_ref, early_stop):
|
|
61
|
+
submodels.append(submodel)
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
# add best candidate to the list of selected submodels
|
|
65
|
+
submodels.append(submodel)
|
|
66
|
+
|
|
67
|
+
return submodels
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def l1_search(_project, model, ref_terms, max_terms, elpd_ref, early_stop):
|
|
71
|
+
"""Method for performing l1 search.
|
|
72
|
+
|
|
73
|
+
Parameters:
|
|
74
|
+
----------
|
|
75
|
+
_project : function
|
|
76
|
+
A function that takes a list of term names and returns a submodel object.
|
|
77
|
+
model : object
|
|
78
|
+
A Bambi model.
|
|
79
|
+
ref_terms : list
|
|
80
|
+
A list of all terms in the model.
|
|
81
|
+
max_terms : int
|
|
82
|
+
The maximum number of terms to include in the submodel.
|
|
83
|
+
elpd_ref : float
|
|
84
|
+
The expected log pointwise predictive density of the reference model.
|
|
85
|
+
early_stop : str
|
|
86
|
+
The early stopping criterion. Either "mean" or "se".
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
-------
|
|
90
|
+
List: A list of submodels, each containing the terms of the submodel and its ELPD.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
d_component = model.distributional_components[model.family.likelihood.parent]
|
|
94
|
+
X = np.column_stack([d_component.design.common[term] for term in ref_terms])
|
|
95
|
+
# XXX we need to make this more general # pylint: disable=fixme
|
|
96
|
+
mean_param_name = list(model.family.link.keys())[0]
|
|
97
|
+
eta = model.family.link[mean_param_name].link(
|
|
98
|
+
model.components[model.family.likelihood.parent].design.response.design_matrix
|
|
99
|
+
)
|
|
100
|
+
# compute L1 path in the latent space
|
|
101
|
+
_, coef_path, *_ = lasso_path(X, eta)
|
|
102
|
+
cov_order = _first_non_zero_idx(coef_path)
|
|
103
|
+
|
|
104
|
+
# sort the covariates according to their L1 ordering
|
|
105
|
+
cov_lasso = dict(sorted(cov_order.items(), key=lambda item: item[1]))
|
|
106
|
+
sorted_covs = [ref_terms[k] for k in cov_lasso]
|
|
107
|
+
|
|
108
|
+
submodel_size = 0
|
|
109
|
+
submodels = []
|
|
110
|
+
|
|
111
|
+
while submodel_size < max_terms + 1:
|
|
112
|
+
term_names = sorted_covs[:submodel_size]
|
|
113
|
+
submodel = _project(term_names)
|
|
114
|
+
|
|
115
|
+
# compute loo for the best candidate and update inplace
|
|
116
|
+
compute_loo(submodel=submodel)
|
|
117
|
+
|
|
118
|
+
if _early_stopping(submodel, elpd_ref, early_stop):
|
|
119
|
+
submodels.append(submodel)
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
# add best candidate to the list of selected submodels
|
|
123
|
+
submodels.append(submodel)
|
|
124
|
+
|
|
125
|
+
submodel_size += 1
|
|
126
|
+
return submodels
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _get_candidates(prev_subset, ref_terms):
|
|
130
|
+
"""Method for extracting a list of all candidate submodels.
|
|
131
|
+
|
|
132
|
+
Parameters:
|
|
133
|
+
----------
|
|
134
|
+
prev_subset : list
|
|
135
|
+
The terms of the previous submodel.
|
|
136
|
+
ref_terms : list
|
|
137
|
+
The terms of the reference model.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
-------
|
|
141
|
+
List: A list of lists, each containing the terms of all candidate submodels
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
candidate_additions = list(set(ref_terms).difference(prev_subset))
|
|
145
|
+
candidates = [prev_subset + [addition] for addition in candidate_additions]
|
|
146
|
+
return candidates
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _first_non_zero_idx(arr):
|
|
150
|
+
"""Find the index of the first non-zero element in each row of a matrix.
|
|
151
|
+
|
|
152
|
+
Parameters:
|
|
153
|
+
----------
|
|
154
|
+
arr : np.ndarray
|
|
155
|
+
A matrix.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
-------
|
|
159
|
+
dict: Dictionary keyed by the row number where each value is the index of the first
|
|
160
|
+
non-zero element in that row.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
# initialise dictionary of indices
|
|
164
|
+
idx_dict = {}
|
|
165
|
+
|
|
166
|
+
# loop through each row and find first non-zero element
|
|
167
|
+
for i, j in zip(*np.where(arr != 0)):
|
|
168
|
+
if i in idx_dict:
|
|
169
|
+
continue
|
|
170
|
+
idx_dict[i] = j
|
|
171
|
+
|
|
172
|
+
# identify which keys are missing and set their values to infinity
|
|
173
|
+
if len(idx_dict) < arr.shape[0]:
|
|
174
|
+
missing_keys = set(range(arr.shape[0])) - set(idx_dict.keys())
|
|
175
|
+
for key in missing_keys:
|
|
176
|
+
idx_dict[key] = np.inf
|
|
177
|
+
|
|
178
|
+
return idx_dict
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _early_stopping(submodel, elpd_ref, early_stop):
|
|
182
|
+
if early_stop == "mean":
|
|
183
|
+
if elpd_ref.elpd_loo - submodel.elpd_loo <= 4:
|
|
184
|
+
return True
|
|
185
|
+
elif early_stop == "se":
|
|
186
|
+
if submodel.elpd_loo + submodel.elpd_se >= elpd_ref.elpd_loo:
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""optimization module."""
|
|
2
|
+
from arviz import from_dict
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.optimize import minimize
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def solve(model_log_likelihood, pps, initial_guess, var_info, tolerance):
|
|
8
|
+
"""The primary projection method in the procedure.
|
|
9
|
+
|
|
10
|
+
Parameters:
|
|
11
|
+
----------
|
|
12
|
+
model_log_likelihood: Callable
|
|
13
|
+
The log-likelihood function of the model
|
|
14
|
+
pps: array
|
|
15
|
+
The predictions of the reference model
|
|
16
|
+
initial_guess: array
|
|
17
|
+
The initial guess for the optimization
|
|
18
|
+
var_info: dict
|
|
19
|
+
The dictionary containing information about the size and transformation of the variables
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
-------
|
|
23
|
+
new_idata: arviz.InferenceData
|
|
24
|
+
loss: float
|
|
25
|
+
"""
|
|
26
|
+
num_samples = len(pps)
|
|
27
|
+
posterior_array = np.zeros((num_samples, len(initial_guess)))
|
|
28
|
+
posterior_dict = {}
|
|
29
|
+
objectives = []
|
|
30
|
+
|
|
31
|
+
opt = minimize(
|
|
32
|
+
model_log_likelihood,
|
|
33
|
+
args=(pps[0]),
|
|
34
|
+
x0=initial_guess,
|
|
35
|
+
)
|
|
36
|
+
initial_guess = opt.x
|
|
37
|
+
|
|
38
|
+
for idx, obs in enumerate(pps):
|
|
39
|
+
opt = minimize(
|
|
40
|
+
model_log_likelihood,
|
|
41
|
+
args=(obs),
|
|
42
|
+
x0=initial_guess,
|
|
43
|
+
tol=tolerance,
|
|
44
|
+
method="SLSQP",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
posterior_array[idx] = opt.x
|
|
48
|
+
objectives.append(opt.fun)
|
|
49
|
+
initial_guess = opt.x
|
|
50
|
+
|
|
51
|
+
size = 0
|
|
52
|
+
for key, values in var_info.items():
|
|
53
|
+
shape, new_size, transformation = values
|
|
54
|
+
posterior_dict[key] = posterior_array[:, size : size + new_size].reshape(
|
|
55
|
+
1, num_samples, *shape
|
|
56
|
+
)
|
|
57
|
+
if transformation is not None:
|
|
58
|
+
posterior_dict[key] = transformation(posterior_dict[key])
|
|
59
|
+
size += new_size
|
|
60
|
+
|
|
61
|
+
new_idata = from_dict(posterior=posterior_dict)
|
|
62
|
+
loss = np.mean(objectives)
|
|
63
|
+
return new_idata, loss
|