Probabilistic CCA¶
The cca_zoo.probabilistic module provides a Bayesian treatment of CCA, with three inference
backends:
GFA— closed-form coordinate-ascent variational Bayes with per-view automatic relevance determination (ARD). No extra dependencies; always available.ProbabilisticCCA— full MCMC sampling via NumPyro. Requires the[probabilistic]extra.VariationalBayesCCA— black-box variational inference with ARD shared across views, via NumPyro. Requires the[probabilistic]extra.
Background¶
Classical CCA finds a single point estimate of the canonical weights. Probabilistic CCA
(Bach & Jordan 2005) instead defines a generative model with explicit priors, enabling
uncertainty quantification over the weights. ProbabilisticCCA and VariationalBayesCCA share
this base generative model:
where:
- \(\mathbf{z}\) is the \(k\)-dimensional shared latent variable
- \(W_i\) is the view-specific loading matrix with a Normal prior
- \(\Psi_i\) is a full noise covariance per view, with an LKJ prior on its correlation and log-Normal priors on its scales
The full \(\Psi_i\) is what makes this CCA rather than factor analysis. With diagonal noise the latent has to explain the correlations within each view too, so a strong factor private to one view captures it; with a full \(\Psi_i\) each view's own structure is noise, and the latent models only what the views share. At the maximum likelihood each view's posterior mean spans its canonical variates (Bach & Jordan 2005), and both classes start inference there. The latent is integrated out of the likelihood, so the posterior draws hold loadings and noise, not \(z\).
They share this model and the same posterior-mean projection formula for transform; they
differ only in how the posterior is approximated. GFA modifies the model itself (per-view ARD,
per-view scalar noise, so view-specific structure is taken up by private latent dimensions) —
see its own section below.
Scoring: correlation vs likelihood¶
All three classes implement score (average pairwise correlation) for consistency with every
other model in cca_zoo — this is what GridSearchCV optimizes by default. But a correlation
between projections isn't the statistically natural fit criterion for a probabilistic model.
All three also implement log_likelihood, the marginal log-likelihood of the data with the
shared latent variable integrated out:
where \(\mathbf{x}\) is the concatenation of every view's centred features for one sample, \(W\)
stacks every view's loading matrix, and \(\Psi\) is the block-diagonal noise covariance.
This is evaluated jointly across the concatenated views rather than per view: because every view
shares the same \(z\), marginalising it induces cross-view covariance that a per-view likelihood
would silently ignore. Use log_likelihood to compare n_components choices, or to compare
any of the three classes against each other on the same data — larger (less negative) is better.
GFA: per-view ARD, no extra dependencies¶
GFA (Group Factor Analysis; Klami, Virtanen, Leppäaho & Kaski 2015) is ported directly from the
reference R package CCAGFA — the update equations are
transliterated from that source, not re-derived. It fits a single shared latent variable \(z\),
but gives each view its own ARD precision \(\alpha_{i,k}\) per latent dimension, rather than
tying every view to the same precision like VariationalBayesCCA:
"Shared" vs. "private" dimensions are therefore emergent, not a fixed split of \(z\) into
blocks: a dimension ends up shared if its \(\alpha_{i,k}\) stays small in several views at once,
and private to view \(i\) if it shrinks toward zero loadings in every other view. This is the
actual mechanism behind "Bayesian CCA" that distinguishes it from VariationalBayesCCA's single
shared ARD parameter per dimension. It also uses a different noise model: \(\tau_i\) is a single
scalar precision per view (homoscedastic), not a full covariance.
Inference is closed-form coordinate-ascent variational Bayes — fully conjugate, so there's no
need for NumPyro/JAX at all. GFA works with just the base cca-zoo install.
from cca_zoo.probabilistic import GFA
# n_components is an upper bound; drop_k=True (default) prunes it
model = GFA(n_components=5, random_state=0)
model.fit([X1, X2])
print(model.n_components_) # <= 5: how many components survived pruning
print(model.ard_precision_) # (n_views, n_components_) posterior mean alpha
ard_precision_[i, k] large means "dimension k is shrunk away in view i" — a component with a
small value in one view and a huge one in every other view is private to that view; a component
with small values everywhere is shared.
Convergence is a best-effort heuristic, not a guarantee
Fitting stops once a proxy (relative change in \(z\)) stays small for 1000 consecutive
iterations, rather than the R package's full variational lower bound (which is provably
monotonic, and so immune to this). Checking against a run with early stopping disabled
caught this proxy staying below tolerance for 700+ iterations in the middle of a slow
pruning process before rising again — a patience window makes this less likely, but can't
rule it out. A fit that reaches max_iter (default 10000) without meeting the criterion
raises a ConvergenceWarning; if n_components_ looks larger than you'd expect, raise
max_iter rather than assuming the result is final.
GFA.transform and GFA.weights_ behave identically to the other two classes; n_iter_ reports
how many iterations were actually run.
ProbabilisticCCA: full MCMC¶
Inference is performed using the No-U-Turn Sampler (NUTS) via NumPyro, giving samples from the exact posterior (up to MCMC error). This is the more accurate option, but full-batch NUTS scales poorly with \(n\).
from cca_zoo.probabilistic import ProbabilisticCCA
model = ProbabilisticCCA(
n_components=2,
center=True,
n_warmup=500,
n_posterior_samples=1000,
random_state=0,
)
model.fit([X1, X2])
After fitting, model.weights_ holds the posterior mean loading matrices, and
model.posterior_samples_ holds the full set of MCMC draws.
Rotational symmetry
This model has an exact symmetry — \(z \to zR\), \(W_i \to W_i R\) for any orthogonal \(R\)
shared across views leaves the likelihood unchanged — and different NUTS draws can settle
on different rotations along that ridge. Averaging un-aligned draws would then be biased
toward zero (draws pointing along different rotations partially cancel), so fit aligns
every draw's loadings to a common reference via generalized Procrustes analysis before
computing weights_ or storing posterior_samples_. On a synthetic check, this raised a rotation-invariant coherence
ratio (||mean(W)||² vs the mean of ||W||² across draws — 1.0 if every draw agrees on a
rotation) from 0.81 to 0.99.
Transform (posterior mean prediction)¶
The latent representation is computed via the analytical posterior mean, using the posterior mean loadings and noise covariances:
z = model.posterior_mean([X1, X2]) # shape (n_samples, n_components)
z_from_x1 = model.posterior_mean([X1, None]) # conditioning on view 1 alone
transform returns one array per view, as for every model in cca_zoo: each view's posterior
mean given that view alone, the formula above with a single term in each sum. It weights
features by their noise precision, so noisy features, and variation the view does not share,
count for less than in the raw projection \(X_i W_i\). Correlations, score and predict then work as they do elsewhere.
VariationalBayesCCA: variational inference with automatic relevance determination¶
VariationalBayesCCA fits the same model, extended with a hierarchical automatic relevance
determination (ARD) prior shared across views:
Because \(\alpha_k\) ties every view's \(k\)-th loading column together, a shared latent dimension
is only retained if some view actually uses it — irrelevant dimensions get shrunk toward zero in
every view at once. The posterior mean of \(\alpha_k\) (model.ard_precision_) is a direct
usefulness score per dimension: large values mean "shrunk away, safe to drop". This gives
automatic latent-dimensionality selection, as an alternative to sweeping n_components with
GridSearchCV.
Inference uses mean-field stochastic variational inference (SVI) rather than the closed-form
conjugate updates in Wang (2007)'s original derivation — SVI reuses the same NumPyro generative
model as ProbabilisticCCA and extends to non-conjugate variants unmodified, at the cost of the
mean-field independence assumption between latent variables. It is substantially cheaper than
full NUTS.
from cca_zoo.probabilistic import VariationalBayesCCA
# n_components is an upper bound here — set it generously and let ARD prune it
model = VariationalBayesCCA(
n_components=5,
n_iter=2000,
learning_rate=1e-2,
random_state=0,
)
model.fit([X1, X2])
print(model.ard_precision_) # one score per dimension; large = pruned
model.transform and model.weights_ behave identically to ProbabilisticCCA. model.losses_
holds the ELBO trace across SVI steps, useful for checking convergence.
Full example¶
from cca_zoo.datasets import make_joint_data
from cca_zoo.probabilistic import GFA, ProbabilisticCCA, VariationalBayesCCA
# Simulate correlated views
views = make_joint_data(
n_samples=100,
n_features=[10, 10],
n_components=2,
signal_to_noise=3.0,
random_state=0,
)
# Fit with GFA (no extra dependencies), requesting more dimensions than
# needed to see per-view ARD prune the unsupported ones
gfa_model = GFA(n_components=4, random_state=42)
gfa_model.fit(views)
print("GFA n_components_ after pruning:", gfa_model.n_components_)
print("Per-view relevance:", gfa_model.ard_precision_)
# Fit with MCMC (reduce warmup/samples for speed in examples)
mcmc_model = ProbabilisticCCA(
n_components=2,
n_warmup=200,
n_posterior_samples=500,
random_state=42,
)
mcmc_model.fit(views)
print("Posterior mean weights shape:", mcmc_model.weights_[0].shape) # (10, 2)
# Fit with variational inference, requesting more dimensions than needed
# to see ARD prune the unsupported ones
vb_model = VariationalBayesCCA(
n_components=4,
n_iter=2000,
random_state=42,
)
vb_model.fit(views)
print("ARD relevance per dimension:", vb_model.ard_precision_)
z = vb_model.posterior_mean(views)
print("Latent shape:", z.shape) # (100, 4)
Tips¶
- Choosing a backend. Start with
GFA— no extra install, and per-view ARD is the more informative dimensionality signal if you care about shared-vs-private structure. PreferVariationalBayesCCAwhen you specifically want ARD shared across views (fewer, more conservative retained dimensions) or need NumPyro's ecosystem. PreferProbabilisticCCAwhen you need the most accurate posterior (e.g. for final reported credible intervals) and \(n\) is small enough for MCMC to be practical. - Warmup vs samples (MCMC). NUTS requires a warm-up phase to adapt the step size. A typical
setting is
n_warmup=500, n_posterior_samples=1000. For exploration,n_warmup=100, n_posterior_samples=200is enough. - n_iter vs learning_rate (VB). SVI runs all
n_itersteps, with no stopping rule. Checkmodel.losses_— if it hasn't plateaued, increasen_iter. If it's noisy or diverging, lowerlearning_rate. - max_iter vs tol (GFA). Convergence-based early stopping is a best-effort heuristic (see the
warning above) — if
n_components_looks too large, raisemax_iterrather than loweringtolfurther. - Small datasets. Probabilistic CCA is most useful when \(n\) is small enough that uncertainty in the weights is meaningful (rough guide: \(n < 500\) for MCMC; VB scales further).
- Feature scaling. Center and scale your views before fitting (
center=Trueis the default). The priors on \(W_i\) assume unit-scale inputs. - Convergence diagnostics.
numpyro.diagnostics.summary(model.posterior_samples_, group_by_chain=False)gives the split R-hat and effective sample size of every site. The fitted model keeps the draws, not the sampler, which would hold the training data. - Comparing models. Use
model.log_likelihood(held_out_views)rather thanmodel.score(...)when the question is "which model/n_components fits this data better" — it's the statistically proper Bayesian criterion, unlike the correlation-basedscoreevery model shares forGridSearchCVconsistency.