Skip to content

Commit 098be6f

Browse files
authored
Add GPs (#632)
* Use pyproject.toml. Remove unnecesary files. Improve CI workflows * name change * stringify python version * Take version from a file. Install optional deps from local as well * Changelog.md -> CHANGELOG.md * add pydata-sphinx-theme to dependencies * try to fix test workflow * lint * Add 'bash -l {0}' to tests * [no ci] update contributing.md * Plan GPs * Start implementation * Start background term * first POC * update uplanning * Custom priors * more progress * Write HSGP as a transformation * [no ci] first model that runs with bambi * [no ci] update example * [no ci] predictions prototype working * Add GP example with bambi * Small tweak to the example * Allow more covariance functions and general names * Handle HSGP > 1D * [no ci] docs, comments and ideas * example changes * Store HSGP instance in the Bambi HSGP term * add default priors for HSGP * [no ci] create test script. I'm just writing ideas for now * [no ci] rerun with new version of formulae * [no ci] add hsgp component to idata when computing predictions. Reproduce some examples * [no ci] hsgp by categories * [no ci] latest ideas * [no ci] update to new API of hsgp * [no ci] reconciliate shapes for by-group case * [no ci] make hsgp more flexible * [no ci] Compute 'L' for the most general case * [no ci] hsgp is working again after tweaks to allow for groups * [no ci] 'by' seems to be working! * [no ci] different covariance function instances by group * [no ci] hsgp by group seems to be working but prediction problems * Predictions by group working * It seems the implementation is done * lint * Add support for the 'scale' argument * [no ci] scale works more intuitively now * [no ci] scale and iso available? * Many examples using HSGP with 2D data * [no ci] add HSGP in the model summary and clean notebooks a litle * [no ci] docs and more examples * Add text to the 2-dimensional notebook * [no ci] add text to examples * [no ci] add final version of examples * [no ci] add how bambi works notebook * [no ci] remove gp_notes.ipynb * [no ci] add first set of tests * [no ci] transpose coords of hsgp contribution * [no ci] add more deep tests * [no ci] rerun notebooks * [no ci] fix import in test * [no ci] add optional namespace to the model * [no ci] extra_namespace is clearer than namespace * [no ci] revisit notebooks * Switch to PyMC dev version * Small tweak to test * Remove problematic type hints * linter
1 parent ce7b060 commit 098be6f

32 files changed

+6855
-147
lines changed

bambi/__init__.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,18 @@
1111
from .version import __version__
1212

1313

14-
__all__ = ["Model", "Prior", "Family", "Likelihood", "Link", "PyMCModel", "Formula"]
14+
__all__ = [
15+
"Model",
16+
"Prior",
17+
"Family",
18+
"Likelihood",
19+
"Link",
20+
"PyMCModel",
21+
"Formula",
22+
"clear_data_home",
23+
"load_data",
24+
"math",
25+
]
1526

1627
_log = logging.getLogger("bambi")
1728

bambi/backend/model_components.py

+26-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from pytensor import tensor as pt
44

5-
from bambi.backend.terms import CommonTerm, GroupSpecificTerm, InterceptTerm, ResponseTerm
5+
from bambi.backend.terms import CommonTerm, GroupSpecificTerm, HSGPTerm, InterceptTerm, ResponseTerm
66
from bambi.backend.utils import get_distribution_from_prior
77
from bambi.families.multivariate import MultivariateFamily
88
from bambi.families.univariate import Categorical
@@ -35,12 +35,16 @@ def __init__(self, component):
3535
self.output = 0
3636
self.has_intercept = self.component.intercept_term is not None
3737
self.design_matrix_without_intercept = None
38+
self.terms = {}
3839

3940
def build(self, pymc_backend, bmb_model):
41+
# Coordinates for the response are added first
42+
self.add_response_coords(pymc_backend, bmb_model)
4043
with pymc_backend.model:
4144
self.build_intercept(bmb_model)
4245
self.build_offsets()
4346
self.build_common_terms(pymc_backend, bmb_model)
47+
self.build_hsgp_terms(pymc_backend, bmb_model)
4448
self.build_group_specific_terms(pymc_backend, bmb_model)
4549

4650
def build_intercept(self, bmb_model):
@@ -96,11 +100,25 @@ def build_common_terms(self, pymc_backend, bmb_model):
96100
# Add term to linear predictor
97101
self.output += pt.dot(data, coefs)
98102

103+
def build_hsgp_terms(self, pymc_backend, bmb_model):
104+
"""Add HSGP (Hilbert-Space Gaussian Process approximation) terms to the PyMC model.
105+
106+
The linear predictor 'X @ b + Z @ u' can be augmented with non-parametric HSGP terms
107+
'f(x)'. This creates the 'f(x)' and adds it ``self.output``.
108+
"""
109+
for term in self.component.hsgp_terms.values():
110+
hsgp_term = HSGPTerm(term)
111+
for name, values in hsgp_term.coords.items():
112+
if name not in pymc_backend.model.coords:
113+
pymc_backend.model.add_coords({name: values})
114+
self.output += hsgp_term.build(bmb_model)
115+
99116
def build_group_specific_terms(self, pymc_backend, bmb_model):
100117
"""Add group-specific (random or varying) terms to the PyMC model.
101118
102119
We have linear predictors of the form 'X @ b + Z @ u'.
103-
This creates the 'u' parameter vector in PyMC, computes `Z @ u`, and adds it to ``self.mu``.
120+
This creates the 'u' parameter vector in PyMC, computes `Z @ u`, and adds it to
121+
``self.output``.
104122
"""
105123
for term in self.component.group_specific_terms.values():
106124
group_specific_term = GroupSpecificTerm(term, bmb_model.noncentered)
@@ -128,16 +146,17 @@ def build_response(self, pymc_backend, bmb_model):
128146
# Extract the response term from the Bambi family
129147
response_term = bmb_model.response_component.response_term
130148

131-
# Add coordinates to the PyMC model. They're used if it is a distributional model.
149+
# Create and build the response term
150+
response_term = ResponseTerm(response_term, bmb_model.family)
151+
response_term.build(pymc_backend, bmb_model)
152+
153+
def add_response_coords(self, pymc_backend, bmb_model):
154+
response_term = bmb_model.response_component.response_term
132155
response_name = get_aliased_name(response_term)
133156
dim_name = f"{response_name}_obs"
134157
dim_value = np.arange(response_term.shape[0])
135158
pymc_backend.model.add_coords({dim_name: dim_value})
136159

137-
# Create and build the response term
138-
response_term = ResponseTerm(response_term, bmb_model.family)
139-
response_term.build(pymc_backend, bmb_model)
140-
141160

142161
# # NOTE: Here for historical reasons, not supposed to work now at least for now
143162
# def add_lkj(backend, terms, eta=1):

bambi/backend/pymc.py

+4
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ def _run_laplace(self, draws, omit_offsets, include_mean):
347347
idata = self._clean_results(idata, omit_offsets, include_mean)
348348
return idata
349349

350+
@property
351+
def response_component(self):
352+
return self.components[self.spec.response_name]
353+
350354
@property
351355
def constant_components(self):
352356
return {k: v for k, v in self.components.items() if isinstance(v, ConstantComponent)}

bambi/backend/terms.py

+206-25
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import inspect
2+
13
import numpy as np
24
import pymc as pm
3-
45
import pytensor.tensor as pt
56

67
from bambi.backend.utils import (
78
has_hyperprior,
89
get_distribution_from_prior,
910
get_distribution_from_likelihood,
11+
get_linkinv,
12+
GP_KERNELS,
1013
)
1114
from bambi.families.multivariate import MultivariateFamily
1215
from bambi.families.univariate import Categorical
@@ -236,8 +239,7 @@ def build(self, pymc_backend, bmb_model):
236239
kwargs[name] = component.output
237240

238241
# Distributional parameters. A link funciton is used.
239-
response_aliased_name = get_aliased_name(self.term)
240-
dims = [response_aliased_name + "_obs"]
242+
dims = (f"{self.name}_obs",)
241243
for name, component in pymc_backend.distributional_components.items():
242244
bmb_component = bmb_model.components[name]
243245
if bmb_component.response_term: # The response is added later
@@ -247,7 +249,7 @@ def build(self, pymc_backend, bmb_model):
247249
)
248250
linkinv = get_linkinv(self.family.link[name], pymc_backend.INVLINKS)
249251
kwargs[name] = pm.Deterministic(
250-
f"{response_aliased_name}_{aliased_name}", linkinv(component.output), dims=dims
252+
f"{self.name}_{aliased_name}", linkinv(component.output), dims=dims
251253
)
252254

253255
# Take the inverse link function that maps from linear predictor to the parent of likelihood
@@ -256,13 +258,14 @@ def build(self, pymc_backend, bmb_model):
256258
# Add parent parameter and observed data. We don't need to pass dims.
257259
kwargs[parent] = linkinv(nu)
258260
kwargs["observed"] = data
261+
kwargs["dims"] = dims
259262

260263
# Build the response distribution
261-
dist = self.build_response_distribution(kwargs)
264+
dist = self.build_response_distribution(kwargs, pymc_backend)
262265

263266
return dist
264267

265-
def build_response_distribution(self, kwargs):
268+
def build_response_distribution(self, kwargs, pymc_backend):
266269
# Get likelihood distribution
267270
distribution = get_distribution_from_likelihood(self.family.likelihood)
268271

@@ -271,6 +274,7 @@ def build_response_distribution(self, kwargs):
271274
if hasattr(self.family, "transform_backend_kwargs"):
272275
kwargs = self.family.transform_backend_kwargs(kwargs)
273276

277+
kwargs = self.robustify_dims(pymc_backend, kwargs)
274278
return distribution(self.name, **kwargs)
275279

276280
@property
@@ -279,26 +283,203 @@ def name(self):
279283
return self.term.alias
280284
return self.term.name
281285

282-
283-
def get_linkinv(link, invlinks):
284-
"""Get the inverse of the link function as needed by PyMC
286+
def robustify_dims(self, pymc_backend, kwargs):
287+
# It's possible the observed for the response is multidimensional, but there's a single
288+
# linear predictor because the family is not multivariate.
289+
# In this case, we add extra dimensions to avoid having shape mismatch between the data
290+
# and the shape implied by the `dims` we pass.
291+
dims, data = kwargs["dims"], kwargs["observed"]
292+
dims_n = len(dims)
293+
ndim_diff = data.ndim - dims_n
294+
295+
# TO DO: Test with multinomial regression, shouldn't be added?
296+
if ndim_diff > 0:
297+
for i in range(ndim_diff):
298+
axis = dims_n + i
299+
name = f"{self.name}_extra_dim_{i}"
300+
values = np.arange(np.size(data, axis=axis))
301+
pymc_backend.model.add_coords({name: values})
302+
dims = dims + (name,)
303+
kwargs["dims"] = dims
304+
return kwargs
305+
306+
307+
class HSGPTerm:
308+
"""A term that is compiled to a HSGP term in PyMC
309+
310+
This instance contains information of a bambi.HSGPTerm and knows how to build the distributions
311+
in PyMC that represent the HSGP latent approximation.
285312
286313
Parameters
287314
----------
288-
link : bmb.Link
289-
A link function object. It may contain the linkinv function that the backend uses.
290-
invlinks : dict
291-
Keys are names of link functions. Values are the built-in link functions.
292-
293-
Returns
294-
-------
295-
callable
296-
The link function
315+
term : bambi.terms.HSGPTerm
316+
An object representing a Bambi hsgp term.
297317
"""
298-
# If the name is in the backend, get it from there
299-
if link.name in invlinks:
300-
invlink = invlinks[link.name]
301-
# If not, use whatever is in `linkinv_backend`
302-
else:
303-
invlink = link.linkinv_backend
304-
return invlink
318+
319+
def __init__(self, term):
320+
self.term = term
321+
self.coords = self.term.coords.copy()
322+
323+
# Coordinates for the variable
324+
if not self.term.iso and self.term.shape[1] > 1:
325+
self.coords[f"{self.name}_var"] = np.arange(self.term.shape[1])
326+
327+
if self.coords and self.term.alias:
328+
self.coords[f"{self.term.alias}_weights_dim"] = self.coords.pop(
329+
f"{self.term.name}_weights_dim"
330+
)
331+
if self.term.by_levels is not None:
332+
self.coords[f"{self.term.alias}_by"] = self.coords.pop(f"{self.term.name}_by")
333+
334+
def build(self, bmb_model):
335+
# Get the name of the term
336+
label = self.name
337+
338+
# Get the covariance functions (it's possibly more than one)
339+
covariance_functions = self.get_covariance_functions()
340+
341+
# Get dimension name for the response
342+
response_name = get_aliased_name(bmb_model.response_component.response_term)
343+
344+
# Prepare dims
345+
coeff_dims = (f"{label}_weights_dim",)
346+
contribution_dims = (f"{response_name}_obs",)
347+
348+
# Data may be scaled so the maximum Euclidean distance between two points is 1
349+
if self.term.scale_predictors:
350+
data = self.term.data_centered / self.term.maximum_distance
351+
else:
352+
data = self.term.data_centered
353+
354+
# Build HSGP and store it in the term.
355+
if self.term.by_levels is not None:
356+
flatten_coeffs = True
357+
coeff_dims = coeff_dims + (f"{label}_by",)
358+
phi_list, sqrt_psd_list = [], []
359+
self.term.hsgp = {}
360+
for i, level in enumerate(self.term.by_levels):
361+
cov_func = covariance_functions[i]
362+
hsgp = pm.gp.HSGP(
363+
m=list(self.term.m), # Doesn't change by group
364+
L=list(self.term.L[i]), # 1d array is not a Sequence
365+
drop_first=self.term.drop_first,
366+
cov_func=cov_func,
367+
)
368+
# Notice we pass all the values, for all the groups.
369+
# Then we only keep the ones for the corresponding group.
370+
phi, sqrt_psd = hsgp.prior_linearized(data)
371+
phi = phi.eval()
372+
phi[self.term.by != i] = 0
373+
sqrt_psd_list.append(sqrt_psd)
374+
phi_list.append(phi)
375+
376+
# Store it for later usage
377+
self.term.hsgp[level] = hsgp
378+
379+
phi = np.hstack(phi_list)
380+
sqrt_psd = pt.stack(sqrt_psd_list, axis=1)
381+
else:
382+
flatten_coeffs = False
383+
(cov_func,) = covariance_functions
384+
self.term.hsgp = pm.gp.HSGP(
385+
m=list(self.term.m),
386+
L=list(self.term.L[0]),
387+
drop_first=self.term.drop_first,
388+
cov_func=cov_func,
389+
)
390+
# Get prior components
391+
phi, sqrt_psd = self.term.hsgp.prior_linearized(data)
392+
phi = phi.eval()
393+
394+
# Build weights coefficient
395+
if self.term.centered:
396+
coeffs = pm.Normal(f"{label}_weights", sigma=sqrt_psd, dims=coeff_dims)
397+
else:
398+
coeffs_raw = pm.Normal(f"{label}_weights_raw", dims=coeff_dims)
399+
coeffs = pm.Deterministic(f"{label}_weights", coeffs_raw * sqrt_psd, dims=coeff_dims)
400+
401+
# Build deterministic for the HSGP contribution
402+
if flatten_coeffs:
403+
coeffs = coeffs.T.flatten() # Equivalent to .flatten("F")
404+
output = pm.Deterministic(label, phi @ coeffs, dims=contribution_dims)
405+
return output
406+
407+
def get_covariance_functions(self):
408+
"""Construct and return the covariance function
409+
410+
This method uses the name of the covariance function to retrieve a callable that
411+
returns a GP kernel and the name of its parameters. Then it looks for values for the
412+
parameters in the dictionary of priors of the term (building PyMC distributions as needed)
413+
and finally it determines the value of 'input_dim' if that is required by the callable
414+
that produces the covariance function. If that is the case, 'input_dim' is set to the
415+
dimensionality of the GP component -- the number of columns in the data.
416+
417+
Returns
418+
-------
419+
Sequence[pm.gp.Covariance]
420+
A covariance function that can be used with a GP in PyMC
421+
"""
422+
423+
# Get the callable that creates the function
424+
cov_dict = GP_KERNELS[self.term.cov]
425+
create_covariance_function = cov_dict["fn"]
426+
param_names = cov_dict["params"]
427+
params = {}
428+
429+
# Set dimensions and behavior for priors that are actually fixed (floats or ints)
430+
if self.term.by_levels is not None and not self.term.share_cov:
431+
dims = (f"{self.name}_by",)
432+
recycle = True
433+
else:
434+
dims = None
435+
recycle = False
436+
437+
# Build priors and parameters
438+
for param_name in param_names:
439+
prior = self.term.prior[param_name]
440+
param_dims = dims
441+
if isinstance(prior, Prior):
442+
distribution = get_distribution_from_prior(prior)
443+
# varying lengthscale parameter
444+
if param_name == "ell" and not self.term.iso and self.term.shape[1] > 1:
445+
if param_dims is not None:
446+
param_dims = (f"{self.name}_var",) + param_dims
447+
else:
448+
param_dims = (f"{self.name}_var",)
449+
value = distribution(f"{self.name}_{param_name}", **prior.args, dims=param_dims)
450+
else:
451+
# If it's not a distribution, but a scalar...
452+
if recycle:
453+
value = (prior,) * self.term.groups_n
454+
else:
455+
value = prior
456+
params[param_name] = value
457+
458+
if "input_dim" in list(inspect.signature(create_covariance_function).parameters):
459+
if self.term.groups_n > 1 and not self.term.share_cov:
460+
params["input_dim"] = np.repeat(self.term.shape[1], self.term.groups_n)
461+
else:
462+
params["input_dim"] = self.term.shape[1]
463+
464+
if self.term.groups_n == 1 or self.term.share_cov:
465+
covariance_function = create_covariance_function(**params)
466+
output = [covariance_function] * self.term.groups_n
467+
else:
468+
output = []
469+
for i, _ in enumerate(self.term.by_levels):
470+
params_level = {}
471+
for key, value in params.items():
472+
if value[..., i].ndim == 0 and isinstance(value, np.ndarray):
473+
entry = value[..., i].item()
474+
else:
475+
entry = value[..., i]
476+
params_level[key] = entry
477+
covariance_function = create_covariance_function(**params_level)
478+
output.append(covariance_function)
479+
return output
480+
481+
@property
482+
def name(self):
483+
if self.term.alias:
484+
return self.term.alias
485+
return self.term.name

0 commit comments

Comments
 (0)