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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: kulprit
3
- Version: 0.2.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.reference import ProjectionPredictive
7
+ from kulprit.projector import ProjectionPredictive
8
8
  from kulprit.datasets import *
9
9
 
10
10
 
11
- __version__ = "0.2.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(cmp_df, legend=True, title=True, figsize=None, plot_kwargs=None):
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 = (len(cmp_df) - 1, 10)
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, step = np.linspace(0, -1, ((cmp_df.shape[0]) * 2) - 2, retstep=True)
35
- xticks_pos[1::2] = xticks_pos[1::2] - step * 1.5
36
-
37
- labels = cmp_df.index.values[1:]
38
- xticks_labels = [""] * len(xticks_pos)
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=cmp_df["elpd_loo"][1:],
46
- x=xticks_pos[::2],
47
- yerr=cmp_df.se[1:],
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
- cmp_df["elpd_loo"].iloc[0],
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
- [-2, 1],
67
- cmp_df["elpd_loo"].iloc[0] + cmp_df["se"].iloc[0],
68
- cmp_df["elpd_loo"].iloc[0] - cmp_df["se"].iloc[0],
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.1),
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
- # remove double ticks
88
- xticks_pos, xticks_labels = xticks_pos[::2], xticks_labels[::2]
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
- # set axes
91
- axes.set_xticks(xticks_pos)
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.set_xlabel("Submodel size", fontsize=ax_labelsize * 0.6)
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="formula",
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 = list(
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.model.formula for submodel in submodels])
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