Model Selection¶
Every cca_zoo model is a sklearn.base.BaseEstimator, but its fit/transform/score
take a list[ArrayLike] of per-view arrays rather than a single 2-D X. That's the one
thing that stops sklearn's own model-selection tools (GridSearchCV, cross_val_score,
Pipeline, ...) from working with it directly — they need to slice X by row to build
folds, and can't do that across a Python list of differently-shaped arrays.
cca_zoo.model_selection closes that gap. Its searches and cross-validation functions
are sklearn's, taking a list of views: they stack the views into one array for sklearn and
split them back for the model, so folds, scores and results behave exactly as in sklearn.
sklearn's Pipeline needs no adapter; cca_zoo.preprocessing.PerViewTransformer applies a
transformer to each view within it.
GridSearchCV¶
GridSearchCV finds the hyperparameters that maximise the average canonical correlation on
held-out folds, using sklearn's cross-validation machinery under the hood.
from cca_zoo.model_selection import GridSearchCV
from cca_zoo.linear import RidgeCCA
param_grid = {"shrinkage": [0.001, 0.01, 0.1, 1.0]}
gs = GridSearchCV(RidgeCCA(n_components=2), param_grid=param_grid, cv=5)
gs.fit([X1, X2])
print("Best shrinkage:", gs.best_params_["shrinkage"])
print("Best CV score:", gs.best_score_)
# Use the refitted best model directly
best_model = gs.best_estimator_
z1, z2 = best_model.transform([X1, X2])
Per-view parameters¶
Many CCA models accept per-view parameters as a scalar (broadcast to all views) or an
explicit list, e.g. KCCA(shrinkage=[0.01, 0.1]). To search each view's value independently,
suffix the parameter name with __<view index>; sklearn then searches the full Cartesian
product across views:
from cca_zoo.model_selection import GridSearchCV
from cca_zoo.nonparametric import KCCA
param_grid = {
"shrinkage__0": [0.01, 0.1, 1.0],
"shrinkage__1": [0.001, 0.01],
} # all 6 combinations
gs = GridSearchCV(
KCCA(n_components=2, kernel="rbf", gamma=0.01), param_grid=param_grid, cv=5
)
gs.fit([X1, X2])
print(gs.best_params_) # e.g. {"shrinkage__0": 0.1, "shrinkage__1": 0.01}
print(gs.best_estimator_.shrinkage) # [0.1, 0.01]
An index you don't mention in the grid keeps the estimator's current value for that view,
so param_grid={"shrinkage__0": [...]} alone only tunes view 0, leaving view 1 fixed.
Accessing results¶
GridSearchCV exposes the standard sklearn attributes:
import pandas as pd
# Full CV results table
df = pd.DataFrame(gs.cv_results_)
print(
df[
[
"param_shrinkage__0",
"param_shrinkage__1",
"mean_test_score",
"std_test_score",
]
].sort_values("mean_test_score")
)
# Best parameters and score
print(gs.best_params_)
print(gs.best_score_)
# Best estimator (already refitted on the full training set)
best = gs.best_estimator_
Custom refit rules¶
As in sklearn, refit also takes a callable that receives cv_results_ and returns the index of
the candidate to refit. When CV scores are noisy, the top mean score tends to favour complex
candidates, and a common remedy is the one-standard-error rule (Breiman's CART, glmnet's
lambda.1se): take the simplest candidate whose mean score is within one standard error of the
best. sklearn's Balance model complexity and cross-validated
score
example shows the pattern; for MARSCCA's nprune (smaller is simpler):
import numpy as np
from cca_zoo.gam import MARSCCA
from cca_zoo.model_selection import GridSearchCV
def one_standard_error(cv_results):
mean = cv_results["mean_test_score"]
se = cv_results["std_test_score"] / np.sqrt(5) # 5 CV splits
best = np.argmax(mean)
eligible = np.flatnonzero(mean >= mean[best] - se[best])
return eligible[np.argmin(cv_results["param_nprune"][eligible])]
gs = GridSearchCV(
MARSCCA(degree=2, nk=40),
{"nprune": [2, 4, 8, 12, 16, 24, 32, 48, 80]},
cv=5,
refit=one_standard_error,
).fit([X1, X2])
cv_results_ carries the same parameter names you passed in the grid (param_nprune, not the
internal param_estimator__nprune). The successive-halving searches choose their final
candidate themselves and, like sklearn's, take only refit=True/False.
RandomizedSearchCV¶
For a continuous hyperparameter like shrinkage, sampling a distribution is usually more efficient
than searching a fixed grid. RandomizedSearchCV mirrors
sklearn.model_selection.RandomizedSearchCV: pass a distribution (anything with an rvs
method, e.g. scipy.stats.loguniform) instead of a list of values, and it draws n_iter
random parameter settings rather than trying every grid point.
from scipy.stats import loguniform
from cca_zoo.model_selection import RandomizedSearchCV
from cca_zoo.linear import RidgeCCA
rs = RandomizedSearchCV(
RidgeCCA(n_components=2),
param_distributions={"shrinkage": loguniform(1e-4, 1.0)},
n_iter=20,
cv=5,
random_state=0,
)
rs.fit([X1, X2])
print("Best shrinkage:", rs.best_params_["shrinkage"])
HalvingGridSearchCV and HalvingRandomSearchCV are sklearn's successive-halving searches
in the same way. Each search class extends its sklearn namesake, so it takes exactly that
class's parameters and follows it as sklearn changes; only fit, transform and score
take a list of views, and parameter names are the model's own.
OptunaSearchCV¶
With the optuna extra (pip install 'cca-zoo[optuna]'), OptunaSearchCV is
optuna_integration.OptunaSearchCV on a list of views, with Optuna's samplers, pruners and
parameters:
from optuna.distributions import FloatDistribution
from cca_zoo.model_selection import OptunaSearchCV
search = OptunaSearchCV(
RidgeCCA(n_components=2),
{"shrinkage__0": FloatDistribution(0, 1), "shrinkage__1": FloatDistribution(0, 1)},
n_trials=50,
cv=5,
).fit([X1, X2])
print(search.best_params_)
Optuna's own records, such as search.study_.trials_dataframe(), show the parameters with an
internal estimator__ prefix.
Cross-validation¶
cross_val_score, cross_validate, cross_val_predict, learning_curve and
validation_curve are sklearn's functions taking a list of views; every other argument is
passed to sklearn. validation_curve accepts per-view names such as "shrinkage__0".
cross_val_predict returns each view's out-of-fold scores, from which out-of-sample
canonical correlations follow. Each fold's model fixes its own signs, so compare views
within a component rather than scores across folds.
from cca_zoo.linear import CCA, RidgeCCA
from cca_zoo.metrics import pairwise_correlations
from cca_zoo.model_selection import (
cross_val_predict,
cross_val_score,
cross_validate,
validation_curve,
)
scores = cross_val_score(CCA(n_components=2), [X1, X2], cv=5)
results = cross_validate(CCA(n_components=2), [X1, X2], cv=5, return_train_score=True)
train, test = validation_curve(
RidgeCCA(), [X1, X2], "shrinkage__0", [0.0, 0.1, 1.0], cv=5
)
out_of_fold = pairwise_correlations(
cross_val_predict(CCA(n_components=2), [X1, X2], cv=5)
)
Pipelines¶
A Pipeline of PerViewTransformer steps and a model takes a list of views, so preprocessing
is refitted within each fold. Per-view names reach through it as usual:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from cca_zoo.linear import RidgeCCA
from cca_zoo.model_selection import GridSearchCV
from cca_zoo.preprocessing import PerViewTransformer
pipeline = Pipeline(
[("scale", PerViewTransformer(StandardScaler())), ("cca", RidgeCCA())]
)
gs = GridSearchCV(pipeline, {"cca__shrinkage__0": [0.0, 0.1, 1.0]}, cv=5).fit([X1, X2])
Custom scoring¶
The default score is the model's score, the mean canonical correlation. Any search or
cross-validation function takes a callable scoring(estimator, views) instead, or a dict
of them, called with the fitted model and the held-out views:
from cca_zoo.metrics import pairwise_correlations
def first_correlation(estimator, views):
return pairwise_correlations(estimator.transform(views))[0, 1, 0]
scores = cross_val_score(CCA(n_components=2), [X1, X2], cv=5, scoring=first_correlation)
Full example: tuning kernel CCA¶
from cca_zoo.datasets import make_joint_data
from cca_zoo.model_selection import GridSearchCV
from cca_zoo.nonparametric import KCCA
# Simulate data
views = make_joint_data(
n_samples=200,
n_features=[30, 30],
n_components=2,
signal_to_noise=2.0,
random_state=0,
)
# Grid search over kernel and regularisation
param_grid = {
"kernel": ["rbf", "poly"],
"shrinkage": [0.01, 0.1, 1.0],
"gamma": [0.01, 0.1],
}
gs = GridSearchCV(
KCCA(n_components=2),
param_grid=param_grid,
cv=5,
)
gs.fit(views)
print("Best params:", gs.best_params_)
print("Best score: ", gs.best_score_)
Tips¶
scoreis the mean canonical correlation across alln_components, averaged over all pairwise view combinations.- Cross-validation is done on the full set of views passed to
fit; train/test splits are row-wise (same rows held out across all views). - For sparse CCA methods, tune
alpha,l1_boundorspanjust like any other hyperparameter. - When the grid is large, prefer
RandomizedSearchCV, or a coarse-to-fineGridSearchCV: search a coarse grid first, then refine around the best value.
Assessing significance¶
permutation_test_significance answers two different questions: is a canonical
correlation stronger than you'd expect by chance, and which individual features
reliably drive it?
from cca_zoo.linear import CCA
from cca_zoo.model_selection import permutation_test_significance
result = permutation_test_significance(
CCA(n_components=2), [X1, X2], n_permutations=1000, random_state=0
)
print("Canonical correlations:", result.correlations)
print("p-values (per dimension):", result.p_values)
# Per-feature, per-dimension p-values for view 1's loadings
print(result.loading_p_values[0])
It works by refitting the model many times on data where every view except the first
has had its rows independently shuffled, breaking the true cross-view relationship while
keeping each view's own covariance structure intact. p_values compares each
dimension's true correlation directly against its shuffled counterparts. For the
loadings, a shuffled refit isn't guaranteed to recover components in the same order or
sign as the true fit — permutation can rotate or reflect near-tied dimensions — so each
permutation's loadings are first realigned to the true fit (scipy.linalg.orthogonal_procrustes)
before being compared feature-by-feature. This follows the resampling-based significance
testing approach used in the neuroimaging CCA/PLS literature (Xia et al. 2018; McIntosh &
Lobaugh 2004).
Only the first dimension's p-value is a valid test. A later dimension is compared with the shuffled fits' dimension of the same rank, but those were fitted without first removing the earlier dimensions' signal; Winkler et al. (2020) test later dimensions step-down, residualising each earlier one first.
n_permutations trades off precision against runtime (each permutation refits the model
from scratch); pass n_jobs to parallelise across permutations.