cca_zoo.model_selection¶
Cross-validated hyperparameter search and significance testing for multiview models.
GridSearchCV ¶
GridSearchCV(
estimator: BaseEstimator,
param_grid: dict[str, list[Any]]
| list[dict[str, list[Any]]],
*,
cv: int | Any = 5,
scoring: str | None = None,
n_jobs: int | None = None,
refit: bool = True,
verbose: int = 0,
pre_dispatch: str | int = "2*n_jobs",
error_score: float = np.nan,
return_train_score: bool = False,
)
Bases: _BaseMultiviewSearchCV
Exhaustive grid search with cross-validation for multiview CCA models.
A thin multiview adapter around
:class:sklearn.model_selection.GridSearchCV: views are horizontally
stacked into one array via :class:MultiviewWrapper, and the actual
search (candidate generation, parallel fold evaluation, cv_results_,
refitting, ...) is entirely sklearn's.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
BaseEstimator
|
A multiview CCA estimator (e.g.
:class: |
required |
param_grid
|
dict[str, list[Any]] | list[dict[str, list[Any]]]
|
Dictionary or list of dictionaries with parameter names as keys and lists of parameter settings as values. |
required |
cv
|
int | Any
|
Number of cross-validation folds or a cross-validation splitter. Default is 5. |
5
|
scoring
|
str | None
|
Scoring strategy. When |
None
|
n_jobs
|
int | None
|
Number of jobs to run in parallel. Default is |
None
|
refit
|
bool
|
Whether to refit the best estimator on the full dataset.
Default is |
True
|
verbose
|
int
|
Verbosity level. Default is 0. |
0
|
pre_dispatch
|
str | int
|
Controls the number of jobs dispatched during
parallel execution, forwarded to sklearn's |
'2*n_jobs'
|
error_score
|
float
|
Value to assign to the score if fitting a candidate
raises an exception, forwarded to sklearn's |
nan
|
return_train_score
|
bool
|
If |
False
|
Examples:
>>> import numpy as np
>>> from cca_zoo.linear import CCA
>>> from cca_zoo.model_selection import GridSearchCV
>>> rng = np.random.default_rng(0)
>>> X1 = rng.standard_normal((50, 5))
>>> X2 = rng.standard_normal((50, 4))
>>> gs = GridSearchCV(
... CCA(), param_grid={"latent_dimensions": [1, 2]}, cv=2
... )
>>> gs = gs.fit([X1, X2])
A per-view estimator parameter (e.g. :class:~cca_zoo.linear.rCCA's
ridge c) can be searched independently per view with a
name__<view index> suffix in param_grid:
>>> from cca_zoo.linear import rCCA
>>> gs = GridSearchCV(
... rCCA(), param_grid={"c__0": [0.0, 0.1], "c__1": [0.0, 0.5]}, cv=2
... )
>>> gs = gs.fit([X1, X2])
>>> sorted(gs.best_params_.items())
[('c__0', 0.0), ('c__1', 0.5)]
Source code in cca_zoo/model_selection/_search.py
fit ¶
Run grid search with cross-validation on multiview data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
views
|
list[ArrayLike]
|
List of arrays, each of shape (n_samples, n_features_i). All arrays must have the same number of rows. |
required |
y
|
None
|
Ignored. |
None
|
**fit_params
|
Any
|
Additional keyword arguments forwarded to the
estimator's |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
self |
GridSearchCV
|
Fitted grid search object. |
Source code in cca_zoo/model_selection/_search.py
RandomizedSearchCV ¶
RandomizedSearchCV(
estimator: BaseEstimator,
param_distributions: dict[str, Any]
| list[dict[str, Any]],
*,
n_iter: int = 10,
cv: int | Any = 5,
scoring: str | None = None,
n_jobs: int | None = None,
refit: bool = True,
verbose: int = 0,
random_state: int | Any = None,
pre_dispatch: str | int = "2*n_jobs",
error_score: float = np.nan,
return_train_score: bool = False,
)
Bases: _BaseMultiviewSearchCV
Randomized search with cross-validation for multiview CCA models.
Samples n_iter parameter settings from param_distributions
instead of exhaustively trying every combination in a grid -- useful
when a hyperparameter (e.g. c) is continuous, or when the grid is
too large to search exhaustively. A thin multiview adapter around
:class:sklearn.model_selection.RandomizedSearchCV, following the same
:class:MultiviewWrapper pattern as :class:GridSearchCV.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
BaseEstimator
|
A multiview CCA estimator (e.g.
:class: |
required |
param_distributions
|
dict[str, Any] | list[dict[str, Any]]
|
Dictionary (or list of dictionaries) with
parameter names as keys and either a list of values to sample
from, or a distribution (anything with a |
required |
n_iter
|
int
|
Number of parameter settings sampled. Default is 10. |
10
|
cv
|
int | Any
|
Number of cross-validation folds or a cross-validation splitter. Default is 5. |
5
|
scoring
|
str | None
|
Scoring strategy. When |
None
|
n_jobs
|
int | None
|
Number of jobs to run in parallel. Default is |
None
|
refit
|
bool
|
Whether to refit the best estimator on the full dataset.
Default is |
True
|
verbose
|
int
|
Verbosity level. Default is 0. |
0
|
random_state
|
int | Any
|
Controls the randomness of the parameter sampling. |
None
|
pre_dispatch
|
str | int
|
Controls the number of jobs dispatched during
parallel execution, forwarded to sklearn's |
'2*n_jobs'
|
error_score
|
float
|
Value to assign to the score if fitting a candidate
raises an exception, forwarded to sklearn's |
nan
|
return_train_score
|
bool
|
If |
False
|
Examples:
>>> import numpy as np
>>> from scipy.stats import loguniform
>>> from cca_zoo.linear import rCCA
>>> from cca_zoo.model_selection import RandomizedSearchCV
>>> rng = np.random.default_rng(0)
>>> X1 = rng.standard_normal((50, 5))
>>> X2 = rng.standard_normal((50, 4))
>>> rs = RandomizedSearchCV(
... rCCA(),
... param_distributions={"c": loguniform(1e-3, 1.0)},
... n_iter=5,
... cv=2,
... random_state=0,
... )
>>> rs = rs.fit([X1, X2])
Per-view distributions use the same name__<view index> suffix
as :class:GridSearchCV:
>>> rs = RandomizedSearchCV(
... rCCA(),
... param_distributions={
... "c__0": loguniform(1e-3, 1.0),
... "c__1": loguniform(1e-3, 1.0),
... },
... n_iter=5,
... cv=2,
... random_state=0,
... )
>>> rs = rs.fit([X1, X2])
>>> sorted(rs.best_params_.keys())
['c__0', 'c__1']
Source code in cca_zoo/model_selection/_search.py
fit ¶
Run randomized search with cross-validation on multiview data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
views
|
list[ArrayLike]
|
List of arrays, each of shape (n_samples, n_features_i). All arrays must have the same number of rows. |
required |
y
|
None
|
Ignored. |
None
|
**fit_params
|
Any
|
Additional keyword arguments forwarded to the
estimator's |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
self |
RandomizedSearchCV
|
Fitted randomized search object. |
Source code in cca_zoo/model_selection/_search.py
HalvingGridSearchCV ¶
HalvingGridSearchCV(
estimator: BaseEstimator,
param_grid: dict[str, list[Any]]
| list[dict[str, list[Any]]],
*,
factor: int | float = 3,
resource: str = "n_samples",
max_resources: int | str = "auto",
min_resources: int | str = "exhaust",
aggressive_elimination: bool = False,
cv: int | Any = 5,
scoring: str | None = None,
refit: bool = True,
error_score: float = np.nan,
return_train_score: bool = True,
random_state: int | Any = None,
n_jobs: int | None = None,
verbose: int = 0,
)
Bases: _BaseMultiviewSearchCV
Successive-halving grid search with cross-validation for multiview CCA models.
Like :class:GridSearchCV, but candidates are evaluated on a growing
subset of the training samples across rounds: most candidates are
eliminated early on a small subset, and only the survivors are evaluated
on progressively larger subsets, which is usually much cheaper than an
exhaustive :class:GridSearchCV when the grid is large. A thin multiview
adapter around :class:sklearn.model_selection.HalvingGridSearchCV,
following the same :class:MultiviewWrapper pattern as
:class:GridSearchCV; the "resource" being grown across rounds is a row
count of the (already view-concatenated) training array, so the
successive-halving mechanics need no multiview-specific handling.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
BaseEstimator
|
A multiview CCA estimator (e.g.
:class: |
required |
param_grid
|
dict[str, list[Any]] | list[dict[str, list[Any]]]
|
Dictionary or list of dictionaries with parameter names as keys and lists of parameter settings as values. |
required |
factor
|
int | float
|
The proportion of candidates eliminated (and resources multiplied by) at each round. Default is 3. |
3
|
resource
|
str
|
The resource grown between rounds, forwarded to sklearn's
|
'n_samples'
|
max_resources
|
int | str
|
The maximum amount of resource a candidate is
allowed to use, forwarded to sklearn's |
'auto'
|
min_resources
|
int | str
|
The minimum amount of resource a candidate is
allowed to use, forwarded to sklearn's |
'exhaust'
|
aggressive_elimination
|
bool
|
Whether to eliminate candidates at the same
rate even before there are enough resources to grow, forwarded
to sklearn's |
False
|
cv
|
int | Any
|
Number of cross-validation folds or a cross-validation splitter. Default is 5. |
5
|
scoring
|
str | None
|
Scoring strategy. When |
None
|
refit
|
bool
|
Whether to refit the best estimator on the full dataset.
Default is |
True
|
error_score
|
float
|
Value to assign to the score if fitting a candidate
raises an exception, forwarded to sklearn's
|
nan
|
return_train_score
|
bool
|
If |
True
|
random_state
|
int | Any
|
Controls the pseudo-random subsampling of the training set that determines the candidates' resources at each round. |
None
|
n_jobs
|
int | None
|
Number of jobs to run in parallel. Default is |
None
|
verbose
|
int
|
Verbosity level. Default is 0. |
0
|
Examples:
>>> import numpy as np
>>> from cca_zoo.linear import CCA
>>> from cca_zoo.model_selection import HalvingGridSearchCV
>>> rng = np.random.default_rng(0)
>>> X1 = rng.standard_normal((50, 5))
>>> X2 = rng.standard_normal((50, 4))
>>> hgs = HalvingGridSearchCV(
... CCA(), param_grid={"latent_dimensions": [1, 2]}, cv=2
... )
>>> hgs = hgs.fit([X1, X2])
Per-view parameters use the same name__<view index> suffix as
:class:GridSearchCV:
>>> from cca_zoo.linear import rCCA
>>> hgs = HalvingGridSearchCV(
... rCCA(),
... param_grid={"c__0": [0.0, 0.1], "c__1": [0.0, 0.5]},
... cv=2,
... random_state=0,
... )
>>> hgs = hgs.fit([X1, X2])
>>> sorted(hgs.best_params_.items())
[('c__0', 0.1), ('c__1', 0.0)]
Source code in cca_zoo/model_selection/_search.py
fit ¶
Run successive-halving grid search with cross-validation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
views
|
list[ArrayLike]
|
List of arrays, each of shape (n_samples, n_features_i). All arrays must have the same number of rows. |
required |
y
|
None
|
Ignored. |
None
|
**fit_params
|
Any
|
Additional keyword arguments forwarded to the
estimator's |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
self |
HalvingGridSearchCV
|
Fitted search object. |
Source code in cca_zoo/model_selection/_search.py
HalvingRandomSearchCV ¶
HalvingRandomSearchCV(
estimator: BaseEstimator,
param_distributions: dict[str, Any]
| list[dict[str, Any]],
*,
n_candidates: int | str = "exhaust",
factor: int | float = 3,
resource: str = "n_samples",
max_resources: int | str = "auto",
min_resources: int | str = "smallest",
aggressive_elimination: bool = False,
cv: int | Any = 5,
scoring: str | None = None,
refit: bool = True,
error_score: float = np.nan,
return_train_score: bool = True,
random_state: int | Any = None,
n_jobs: int | None = None,
verbose: int = 0,
)
Bases: _BaseMultiviewSearchCV
Successive-halving randomized search with cross-validation for multiview models.
Combines :class:RandomizedSearchCV's sampling of param_distributions
with :class:HalvingGridSearchCV's successive-halving elimination: most
sampled candidates are eliminated early on a small subset of the training
samples, and only the survivors are evaluated on progressively larger
subsets. A thin multiview adapter around
:class:sklearn.model_selection.HalvingRandomSearchCV, following the
same :class:MultiviewWrapper pattern as :class:RandomizedSearchCV.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
BaseEstimator
|
A multiview CCA estimator (e.g.
:class: |
required |
param_distributions
|
dict[str, Any] | list[dict[str, Any]]
|
Dictionary (or list of dictionaries) with
parameter names as keys and either a list of values to sample
from, or a distribution (anything with a |
required |
n_candidates
|
int | str
|
The number of candidate parameters to sample,
forwarded to sklearn's |
'exhaust'
|
factor
|
int | float
|
The proportion of candidates eliminated (and resources multiplied by) at each round. Default is 3. |
3
|
resource
|
str
|
The resource grown between rounds, forwarded to sklearn's
|
'n_samples'
|
max_resources
|
int | str
|
The maximum amount of resource a candidate is
allowed to use, forwarded to sklearn's |
'auto'
|
min_resources
|
int | str
|
The minimum amount of resource a candidate is
allowed to use, forwarded to sklearn's |
'smallest'
|
aggressive_elimination
|
bool
|
Whether to eliminate candidates at the same
rate even before there are enough resources to grow, forwarded
to sklearn's |
False
|
cv
|
int | Any
|
Number of cross-validation folds or a cross-validation splitter. Default is 5. |
5
|
scoring
|
str | None
|
Scoring strategy. When |
None
|
refit
|
bool
|
Whether to refit the best estimator on the full dataset.
Default is |
True
|
error_score
|
float
|
Value to assign to the score if fitting a candidate
raises an exception, forwarded to sklearn's
|
nan
|
return_train_score
|
bool
|
If |
True
|
random_state
|
int | Any
|
Controls both the randomness of the parameter sampling and the pseudo-random subsampling of the training set. |
None
|
n_jobs
|
int | None
|
Number of jobs to run in parallel. Default is |
None
|
verbose
|
int
|
Verbosity level. Default is 0. |
0
|
Examples:
>>> import numpy as np
>>> from scipy.stats import loguniform
>>> from cca_zoo.linear import rCCA
>>> from cca_zoo.model_selection import HalvingRandomSearchCV
>>> rng = np.random.default_rng(0)
>>> X1 = rng.standard_normal((50, 5))
>>> X2 = rng.standard_normal((50, 4))
>>> hrs = HalvingRandomSearchCV(
... rCCA(),
... param_distributions={"c": loguniform(1e-3, 1.0)},
... cv=2,
... random_state=0,
... )
>>> hrs = hrs.fit([X1, X2])
Per-view distributions use the same name__<view index> suffix
as :class:RandomizedSearchCV:
>>> hrs = HalvingRandomSearchCV(
... rCCA(),
... param_distributions={
... "c__0": loguniform(1e-3, 1.0),
... "c__1": loguniform(1e-3, 1.0),
... },
... cv=2,
... random_state=0,
... )
>>> hrs = hrs.fit([X1, X2])
>>> sorted(hrs.best_params_.keys())
['c__0', 'c__1']
Source code in cca_zoo/model_selection/_search.py
fit ¶
Run successive-halving randomized search with cross-validation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
views
|
list[ArrayLike]
|
List of arrays, each of shape (n_samples, n_features_i). All arrays must have the same number of rows. |
required |
y
|
None
|
Ignored. |
None
|
**fit_params
|
Any
|
Additional keyword arguments forwarded to the
estimator's |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
self |
HalvingRandomSearchCV
|
Fitted search object. |
Source code in cca_zoo/model_selection/_search.py
MultiviewWrapper ¶
Bases: BaseEstimator
Adapt a multiview estimator to sklearn's single-X estimator API.
Sklearn's model-selection tools (GridSearchCV, cross_val_score,
Pipeline, ...) require an estimator whose fit/score accept
(X, y) with X a single 2-D array, so they can index and split rows
into folds. This wrapper concatenates the views' feature axes into one
array on the way in and splits them back into views on the way out, so
any sklearn tool that only ever sees the concatenated array can be used
unmodified with a cca_zoo multiview estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
BaseEstimator
|
A multiview CCA estimator (e.g. :class: |
required |
split_indices
|
list[int]
|
Number of features in each view, in order. Used to split the concatenated array back into views. |
required |
Examples:
>>> import numpy as np
>>> from sklearn.model_selection import cross_val_score
>>> from cca_zoo.linear import CCA
>>> from cca_zoo.model_selection import MultiviewWrapper
>>> rng = np.random.default_rng(0)
>>> X1, X2 = rng.standard_normal((50, 5)), rng.standard_normal((50, 4))
>>> wrapper = MultiviewWrapper(CCA(), split_indices=[5, 4])
>>> scores = cross_val_score(wrapper, np.hstack([X1, X2]), cv=3)
Per-view hyperparameters (a per-view CCA model accepts a scalar,
broadcast to every view, or an explicit list with one value per view --
e.g. KCCA(c=[0.01, 0.1])) can be searched independently per view
through set_params using a name__<view index> suffix, e.g.
estimator__c__0. This is mainly useful for grid/randomized search: a
param grid of {"c__0": [0.01, 0.1], "c__1": [0.1, 1.0]} makes
sklearn's ParameterGrid search the Cartesian product of the two
views' values. Indices left unset keep the estimator's current value for
that view (broadcast if it was a scalar).
Source code in cca_zoo/model_selection/_search.py
set_params ¶
Set parameters, honouring per-view name__<view index> overrides.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**params
|
Any
|
Parameter names/values. A key of the form
|
{}
|
Source code in cca_zoo/model_selection/_search.py
fit ¶
Fit the wrapped estimator on the concatenated multiview data.
Source code in cca_zoo/model_selection/_search.py
score ¶
Mean canonical correlation over all latent dimensions.
transform ¶
Transform and re-concatenate, so the wrapper composes with Pipeline.
permutation_test_significance ¶
permutation_test_significance(
estimator: BaseEstimator,
views: list[ArrayLike],
n_permutations: int = 1000,
random_state: int | Generator | None = None,
n_jobs: int | None = None,
) -> PermutationTestResult
Permutation test for canonical correlation and feature-loading significance.
Fits a clone of estimator on views, then repeatedly refits a
fresh clone on data where every view except the first has had its rows
independently shuffled -- destroying the true cross-view
correspondence while preserving each view's own covariance structure --
to build a null distribution.
Canonical-correlation significance (p_values_) compares each
dimension's observed correlation directly to its permuted
counterparts: since both the observed and permuted fits rank
dimensions by correlation strength, the d-th dimension of a permuted
fit is already the right null comparison for the d-th observed
dimension, with no realignment needed.
Feature-loading significance (loading_p_values_) is subtler: a
permuted refit is not guaranteed to recover canonical variates in the
same order or with the same sign as the observed fit, since
permutation can induce an arbitrary rotation or reflection of
near-tied dimensions (Xia et al., 2018, Nat. Commun.; McIntosh &
Lobaugh, 2004, NeuroImage). Each permutation's loadings are
therefore realigned to the observed loadings via
:func:procrustes_rotation (fit jointly across all views' stacked
loadings, since the rotation ambiguity is shared across views) before
being compared feature-by-feature and dimension-by-dimension.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
BaseEstimator
|
An unfitted multiview CCA/PLS estimator implementing
the :class: |
required |
views
|
list[ArrayLike]
|
List of arrays, each of shape (n_samples, n_features_i). |
required |
n_permutations
|
int
|
Number of permutations to draw. Default 1000. |
1000
|
random_state
|
int | Generator | None
|
Seed or |
None
|
n_jobs
|
int | None
|
Number of permutations to fit in parallel (forwarded to
:class: |
None
|
Returns:
| Type | Description |
|---|---|
PermutationTestResult
|
PermutationTestResult with the observed and null statistics. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fewer than 2 views are provided, or
|
Examples:
>>> import numpy as np
>>> from cca_zoo.linear import CCA
>>> from cca_zoo.model_selection import permutation_test_significance
>>> rng = np.random.default_rng(0)
>>> z = rng.standard_normal((40, 1))
>>> X1 = z @ rng.standard_normal((1, 5)) + 0.1 * rng.standard_normal((40, 5))
>>> X2 = z @ rng.standard_normal((1, 4)) + 0.1 * rng.standard_normal((40, 4))
>>> result = permutation_test_significance(
... CCA(latent_dimensions=1), [X1, X2], n_permutations=49, random_state=0
... )
>>> result.p_values_.shape
(1,)
Source code in cca_zoo/model_selection/_significance.py
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
PermutationTestResult
dataclass
¶
PermutationTestResult(
correlations_: ndarray,
null_correlations_: ndarray,
p_values_: ndarray,
loadings_: list[ndarray],
null_loadings_: list[ndarray],
loading_p_values_: list[ndarray],
)
Result of :func:permutation_test_significance.
Attributes:
| Name | Type | Description |
|---|---|---|
correlations_ |
ndarray
|
Observed per-dimension average pairwise canonical correlations, shape (k,). |
null_correlations_ |
ndarray
|
Per-dimension correlations from each permutation, shape (n_permutations, k). |
p_values_ |
ndarray
|
Per-dimension permutation p-value for the canonical correlations, shape (k,). |
loadings_ |
list[ndarray]
|
Observed factor loadings, one array of shape
(n_features_i, k) per view (see
:meth: |
null_loadings_ |
list[ndarray]
|
Permuted factor loadings, realigned to
|
loading_p_values_ |
list[ndarray]
|
Per-feature, per-dimension permutation p-value for the factor loadings, one array of shape (n_features_i, k) per view. |
procrustes_rotation ¶
Solve the orthogonal Procrustes problem aligning target to reference.
Finds the orthogonal matrix R minimising ||reference - target @
R|| (Frobenius norm), via the classical SVD solution (Schönemann,
1966): writing the SVD of target.T @ reference as U @ S @ Vt,
the optimum is R = U @ Vt. R is a general orthogonal matrix, not
restricted to a proper (determinant +1) rotation, so it also captures
axis reflections (sign flips) -- both are needed when matching
permutation- or bootstrap-resampled canonical variates back to a
reference fit, since resampling can induce either (Xia et al., 2018,
Nat. Commun.; McIntosh & Lobaugh, 2004, NeuroImage).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
reference
|
ndarray
|
Array of shape (n, k). |
required |
target
|
ndarray
|
Array of shape (n, k), matched row-for-row with
|
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
Orthogonal matrix of shape (k, k) such that |
ndarray
|
optimally aligned to |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> import numpy as np
>>> from cca_zoo.model_selection import procrustes_rotation
>>> rng = np.random.default_rng(0)
>>> reference = rng.standard_normal((20, 3))
>>> true_rotation, _ = np.linalg.qr(rng.standard_normal((3, 3)))
>>> target = reference @ true_rotation.T
>>> recovered = procrustes_rotation(reference, target)
>>> np.allclose(target @ recovered, reference, atol=1e-8)
True