#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use scirs2_core::ndarray::{Array1, Array2, Axis};
use scirs2_core::random::rngs::StdRng;
use scirs2_core::random::{rng as make_rng, RngExt, SeedableRng};
use scirs2_linalg::compat::ArrayLinalgExt;
use sklears_core::{
error::{Result, SklearsError},
traits::{Fit, Transform, Untrained},
types::Float,
};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EMAlgorithm {
pub max_iter: usize,
pub tol: Float,
}
impl Default for EMAlgorithm {
fn default() -> Self {
Self {
max_iter: 1000,
tol: 1e-4,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FactorAnalysisConfig {
pub n_components: usize,
pub em: EMAlgorithm,
pub random_state: Option<u64>,
}
impl Default for FactorAnalysisConfig {
fn default() -> Self {
Self {
n_components: 2,
em: EMAlgorithm::default(),
random_state: None,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TrainedFactorAnalysis {
pub loadings: Array2<Float>,
pub noise_variance: Array1<Float>,
pub mean: Array1<Float>,
pub n_samples_fit: usize,
pub log_likelihood: Float,
pub n_iter: usize,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FactorAnalysis<State = Untrained> {
pub config: FactorAnalysisConfig,
state: State,
}
impl FactorAnalysis<Untrained> {
pub fn new(n_components: usize) -> Self {
Self {
config: FactorAnalysisConfig {
n_components,
..FactorAnalysisConfig::default()
},
state: Untrained,
}
}
pub fn em(mut self, em: EMAlgorithm) -> Self {
self.config.em = em;
self
}
pub fn random_state(mut self, seed: u64) -> Self {
self.config.random_state = Some(seed);
self
}
}
impl Fit<Array2<Float>, ()> for FactorAnalysis<Untrained> {
type Fitted = FactorAnalysis<TrainedFactorAnalysis>;
fn fit(self, x: &Array2<Float>, _y: &()) -> Result<Self::Fitted> {
let (n_samples, n_features) = x.dim();
let q = self.config.n_components;
if q == 0 {
return Err(SklearsError::InvalidInput(
"n_components must be > 0".to_string(),
));
}
if q >= n_features {
return Err(SklearsError::InvalidInput(format!(
"n_components ({}) must be less than n_features ({})",
q, n_features
)));
}
if n_samples < 2 {
return Err(SklearsError::InvalidInput(
"At least 2 samples are required for factor analysis".to_string(),
));
}
let n = n_samples as Float;
let mean = x.mean_axis(Axis(0)).ok_or_else(|| {
SklearsError::NumericalError("Cannot compute mean of empty array".to_string())
})?;
let x_c = x - &mean.clone().insert_axis(Axis(0));
let s_full: Array2<Float> = x_c.t().dot(&x_c) / n;
let s_diag: Array1<Float> = {
let mut d = Array1::zeros(n_features);
for j in 0..n_features {
d[j] = s_full[[j, j]];
}
d
};
let mut rng: StdRng = match self.config.random_state {
Some(seed) => StdRng::seed_from_u64(seed),
None => StdRng::from_rng(&mut make_rng()),
};
let mut w: Array2<Float> =
Array2::from_shape_fn((n_features, q), |_| (rng.random::<Float>() - 0.5) * 0.01);
let psi_min: Float = 1e-6;
let mut psi: Array1<Float> = s_diag.mapv(|v| v.max(psi_min));
let mut log_likelihood_prev: Float = Float::NEG_INFINITY;
let mut n_iter = 0usize;
for iter in 0..self.config.em.max_iter {
n_iter = iter + 1;
let psi_inv: Array1<Float> = psi.mapv(|v| 1.0 / v);
let wt_psi_inv: Array2<Float> = {
let mut out = Array2::zeros((q, n_features));
for j in 0..n_features {
for i in 0..q {
out[[i, j]] = w[[j, i]] * psi_inv[j];
}
}
out
};
let mut m_mat: Array2<Float> = wt_psi_inv.dot(&w);
for i in 0..q {
m_mat[[i, i]] += 1.0;
}
let m_inv = m_mat.inv().map_err(|e| {
SklearsError::NumericalError(format!(
"Factor precision matrix M is singular: {}",
e
))
})?;
let ez: Array2<Float> = m_inv.dot(&wt_psi_inv.dot(&x_c.t()));
let ez_ezt: Array2<Float> = ez.dot(&ez.t());
let mut ezz: Array2<Float> = &m_inv * n + &ez_ezt;
for i in 0..q {
ezz[[i, i]] += psi_min;
}
let xct_ez: Array2<Float> = x_c.t().dot(&ez.t()); let ezz_inv = ezz.inv().map_err(|e| {
SklearsError::NumericalError(format!(
"E[z zᵀ] matrix is singular during M-step: {}",
e
))
})?;
let w_new: Array2<Float> = xct_ez.dot(&ezz_inv);
let w_new_ez: Array2<Float> = w_new.dot(&ez); let mut psi_new: Array1<Float> = Array1::zeros(n_features);
for j in 0..n_features {
let row = w_new_ez.row(j);
let col = x_c.column(j);
let corr: Float = row.dot(&col) / n;
psi_new[j] = (s_diag[j] - corr).max(psi_min);
}
let log_l = compute_log_likelihood_mdl(
n, n_features, &w_new, &psi_new, &psi_inv, &m_mat, &m_inv, &s_full,
);
w = w_new;
psi = psi_new;
if (log_l - log_likelihood_prev).abs() < self.config.em.tol {
log_likelihood_prev = log_l;
break;
}
log_likelihood_prev = log_l;
}
Ok(FactorAnalysis {
config: self.config,
state: TrainedFactorAnalysis {
loadings: w,
noise_variance: psi,
mean,
n_samples_fit: n_samples,
log_likelihood: log_likelihood_prev,
n_iter,
},
})
}
}
impl Transform<Array2<Float>, Array2<Float>> for FactorAnalysis<TrainedFactorAnalysis> {
fn transform(&self, x: &Array2<Float>) -> Result<Array2<Float>> {
let (n_samples, n_features) = x.dim();
let s = &self.state;
if n_features != s.mean.len() {
return Err(SklearsError::FeatureMismatch {
expected: s.mean.len(),
actual: n_features,
});
}
let q = s.loadings.ncols();
let psi_inv: Array1<Float> = s.noise_variance.mapv(|v| 1.0 / v);
let wt_psi_inv: Array2<Float> = {
let mut out = Array2::zeros((q, n_features));
for j in 0..n_features {
for i in 0..q {
out[[i, j]] = s.loadings[[j, i]] * psi_inv[j];
}
}
out
};
let mut m_mat: Array2<Float> = wt_psi_inv.dot(&s.loadings);
for i in 0..q {
m_mat[[i, i]] += 1.0;
}
let m_inv = m_mat.inv().map_err(|e| {
SklearsError::NumericalError(format!("Factor precision matrix is singular: {}", e))
})?;
let mean_row = s.mean.clone().insert_axis(Axis(0));
let x_c = x - &mean_row;
let scores_t: Array2<Float> = m_inv.dot(&wt_psi_inv.dot(&x_c.t()));
let mut scores = Array2::zeros((n_samples, q));
for n_idx in 0..n_samples {
for qi in 0..q {
scores[[n_idx, qi]] = scores_t[[qi, n_idx]];
}
}
Ok(scores)
}
}
impl FactorAnalysis<TrainedFactorAnalysis> {
pub fn loadings(&self) -> &Array2<Float> {
&self.state.loadings
}
pub fn noise_variance(&self) -> &Array1<Float> {
&self.state.noise_variance
}
pub fn mean(&self) -> &Array1<Float> {
&self.state.mean
}
pub fn log_likelihood(&self) -> Float {
self.state.log_likelihood
}
pub fn n_iter(&self) -> usize {
self.state.n_iter
}
pub fn loadings_plot_data(&self, feature_names: Option<Vec<String>>) -> LoadingsPlotData {
let (n_features, n_components) = self.state.loadings.dim();
let features = feature_names
.unwrap_or_else(|| (0..n_features).map(|i| format!("feature_{}", i)).collect());
let components: Vec<String> = (1..=n_components).map(|i| format!("Factor{}", i)).collect();
LoadingsPlotData {
loadings: self.state.loadings.clone(),
feature_names: features,
component_names: components,
}
}
pub fn communalities(&self) -> Array1<Float> {
let w = &self.state.loadings;
let psi = &self.state.noise_variance;
let n_features = w.nrows();
let mut comm = Array1::zeros(n_features);
for j in 0..n_features {
let h2: Float = w.row(j).mapv(|v| v * v).sum();
comm[j] = h2 / (h2 + psi[j]);
}
comm
}
pub fn sample(&self, n_samples: usize, random_state: Option<u64>) -> Result<Array2<Float>> {
let s = &self.state;
let (n_features, q) = s.loadings.dim();
let mut rng: StdRng = match random_state {
Some(seed) => StdRng::seed_from_u64(seed),
None => StdRng::from_rng(&mut make_rng()),
};
let factors: Array2<Float> =
Array2::from_shape_fn((n_samples, q), |_| sample_normal(&mut rng));
let noise: Array2<Float> = Array2::from_shape_fn((n_samples, n_features), |(_i, j)| {
sample_normal(&mut rng) * s.noise_variance[j].sqrt()
});
let loadings_t = s.loadings.t().to_owned(); let x_latent: Array2<Float> = factors.dot(&loadings_t); let mean_row = s.mean.clone().insert_axis(Axis(0));
Ok(x_latent + &mean_row + &noise)
}
pub fn aic(&self) -> Float {
let n_params = self.n_free_params() as Float;
-2.0 * self.state.log_likelihood + 2.0 * n_params
}
pub fn bic(&self) -> Float {
let n = self.state.n_samples_fit as Float;
let n_params = self.n_free_params() as Float;
-2.0 * self.state.log_likelihood + n.ln() * n_params
}
pub fn n_free_params(&self) -> usize {
let (p, q) = self.state.loadings.dim();
let rotation_removal = q.saturating_sub(1) * q / 2;
p * q + p - rotation_removal
}
pub fn covariance(&self) -> Array2<Float> {
compute_sigma(&self.state.loadings, &self.state.noise_variance)
}
}
pub struct ModelComparisonResult {
pub aic_scores: Vec<Float>,
pub bic_scores: Vec<Float>,
pub best_aic_idx: usize,
pub best_bic_idx: usize,
}
pub fn select_n_components(
x: &Array2<Float>,
component_range: std::ops::Range<usize>,
random_state: Option<u64>,
) -> Result<ModelComparisonResult> {
if component_range.is_empty() {
return Err(SklearsError::InvalidInput(
"component_range must be non-empty".to_string(),
));
}
let mut aic_scores = Vec::new();
let mut bic_scores = Vec::new();
for q in component_range {
let model = FactorAnalysis::new(q);
let model = if let Some(seed) = random_state {
model.random_state(seed)
} else {
model
};
let trained = model.fit(x, &())?;
aic_scores.push(trained.aic());
bic_scores.push(trained.bic());
}
let best_aic_idx = aic_scores
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
let best_bic_idx = bic_scores
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
Ok(ModelComparisonResult {
aic_scores,
bic_scores,
best_aic_idx,
best_bic_idx,
})
}
#[derive(Debug, Clone)]
pub struct LoadingsPlotData {
pub loadings: Array2<Float>,
pub feature_names: Vec<String>,
pub component_names: Vec<String>,
}
fn compute_sigma(w: &Array2<Float>, psi: &Array1<Float>) -> Array2<Float> {
let mut sigma: Array2<Float> = w.dot(&w.t());
let n_features = psi.len();
for j in 0..n_features {
sigma[[j, j]] += psi[j];
}
sigma
}
#[allow(clippy::too_many_arguments)]
fn compute_log_likelihood_mdl(
n: Float,
p: usize,
_w: &Array2<Float>,
psi: &Array1<Float>,
psi_inv: &Array1<Float>,
m_mat: &Array2<Float>,
m_inv: &Array2<Float>,
s_full: &Array2<Float>,
) -> Float {
use std::f64::consts::PI;
let q = m_mat.nrows();
let log_psi_sum: Float = psi.iter().map(|&v| v.max(1e-300).ln()).sum();
let log_det_m = log_det_spd(m_mat, q);
if !log_det_m.is_finite() {
return Float::NEG_INFINITY;
}
let log_det_sigma = log_psi_sum + log_det_m;
let trace_psi_inv_s: Float = (0..p).map(|j| psi_inv[j] * s_full[[j, j]]).sum();
let w = _w;
let p_dim = psi_inv.len();
let a: Array2<Float> = {
let mut out = Array2::zeros((q, p_dim));
for j in 0..p_dim {
for i in 0..q {
out[[i, j]] = w[[j, i]] * psi_inv[j];
}
}
out
};
let b: Array2<Float> = a.dot(s_full).dot(&a.t());
let trace_correction: Float = (0..q)
.map(|i| (0..q).map(|j| m_inv[[i, j]] * b[[j, i]]).sum::<Float>())
.sum();
let trace_sigma_inv_s = trace_psi_inv_s - trace_correction;
-0.5 * n * ((p as Float) * (2.0 * PI as Float).ln() + log_det_sigma + trace_sigma_inv_s)
}
fn log_det_spd(m: &Array2<Float>, q: usize) -> Float {
if q == 0 {
return 0.0;
}
if q == 1 {
return m[[0, 0]].abs().ln();
}
leibniz_log_det(m, q)
}
fn leibniz_log_det(m: &Array2<Float>, q: usize) -> Float {
if q <= 4 {
let det = small_det(m, q);
if det <= 0.0 {
Float::NEG_INFINITY
} else {
det.ln()
}
} else {
lu_log_det(m, q)
}
}
fn small_det(m: &Array2<Float>, q: usize) -> Float {
match q {
1 => m[[0, 0]],
2 => m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]],
3 => {
m[[0, 0]] * (m[[1, 1]] * m[[2, 2]] - m[[1, 2]] * m[[2, 1]])
- m[[0, 1]] * (m[[1, 0]] * m[[2, 2]] - m[[1, 2]] * m[[2, 0]])
+ m[[0, 2]] * (m[[1, 0]] * m[[2, 1]] - m[[1, 1]] * m[[2, 0]])
}
4 => {
let mut det = 0.0_f64;
for col in 0..4_usize {
let mut sub = Array2::zeros((3, 3));
for r in 1..4_usize {
let mut sc = 0usize;
for c in 0..4_usize {
if c != col {
sub[[r - 1, sc]] = m[[r, c]];
sc += 1;
}
}
}
let cofactor = if col % 2 == 0 { 1.0 } else { -1.0 };
det += cofactor * m[[0, col]] * small_det(&sub, 3);
}
det
}
_ => lu_log_det(m, q),
}
}
fn lu_log_det(m: &Array2<Float>, q: usize) -> Float {
let mut a = m.to_owned();
let mut log_det = 0.0_f64;
let mut sign = 1.0_f64;
for k in 0..q {
let pivot_row = (k..q)
.max_by(|&r1, &r2| {
a[[r1, k]]
.abs()
.partial_cmp(&a[[r2, k]].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(k);
if pivot_row != k {
for c in 0..q {
a.swap([k, c], [pivot_row, c]);
}
sign = -sign;
}
let pivot = a[[k, k]];
if pivot.abs() < 1e-300 {
return Float::NEG_INFINITY;
}
log_det += pivot.abs().ln();
if pivot < 0.0 {
sign = -sign;
}
for r in (k + 1)..q {
let factor = a[[r, k]] / pivot;
for c in k..q {
let val = a[[k, c]] * factor;
a[[r, c]] -= val;
}
}
}
if sign < 0.0 {
Float::NEG_INFINITY
} else {
log_det
}
}
fn sample_normal(rng: &mut StdRng) -> Float {
let u1: Float = rng.random::<Float>().max(1e-10);
let u2: Float = rng.random::<Float>();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI as Float * u2).cos()
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
fn make_test_data() -> Array2<Float> {
array![
[1.2, 2.1, 3.3, 0.5],
[0.8, 1.9, 2.7, 0.3],
[1.5, 2.4, 3.9, 0.6],
[0.6, 1.6, 2.2, 0.2],
[1.1, 2.0, 3.1, 0.4],
[1.3, 2.2, 3.5, 0.55],
[0.9, 1.8, 2.7, 0.35],
[1.4, 2.3, 3.7, 0.58],
[0.7, 1.7, 2.4, 0.25],
[1.0, 2.05, 3.05, 0.45],
]
}
#[test]
fn test_factor_analysis_fit_transform() {
let x = make_test_data();
let fa = FactorAnalysis::new(2).random_state(42);
let trained = fa
.fit(&x, &())
.expect("FactorAnalysis::fit should succeed on well-formed data");
assert_eq!(trained.loadings().dim(), (4, 2));
assert_eq!(trained.noise_variance().len(), 4);
assert!(trained.noise_variance().iter().all(|&v| v > 0.0));
let scores = trained
.transform(&x)
.expect("transform should succeed on fitted model");
assert_eq!(scores.dim(), (10, 2));
}
#[test]
fn test_factor_analysis_sampling() {
let x = make_test_data();
let fa = FactorAnalysis::new(2).random_state(0);
let trained = fa
.fit(&x, &())
.expect("FactorAnalysis::fit should succeed on well-formed data");
let samples = trained
.sample(5, Some(1))
.expect("sample should succeed on fitted model");
assert_eq!(samples.dim(), (5, 4));
}
#[test]
fn test_aic_bic_ordering() {
let x = make_test_data();
let fa1 = FactorAnalysis::new(1).random_state(7);
let fa2 = FactorAnalysis::new(2).random_state(7);
let m1 = fa1
.fit(&x, &())
.expect("FactorAnalysis::fit with 1 component should succeed");
let m2 = fa2
.fit(&x, &())
.expect("FactorAnalysis::fit with 2 components should succeed");
assert!(m2.n_free_params() > m1.n_free_params());
assert!(m1.log_likelihood().is_finite());
assert!(m2.log_likelihood().is_finite());
}
#[test]
fn test_loadings_plot_data() {
let x = make_test_data();
let trained = FactorAnalysis::new(2)
.random_state(42)
.fit(&x, &())
.expect("FactorAnalysis::fit should succeed on well-formed data");
let plot =
trained.loadings_plot_data(Some(vec!["a".into(), "b".into(), "c".into(), "d".into()]));
assert_eq!(plot.feature_names.len(), 4);
assert_eq!(plot.component_names.len(), 2);
}
#[test]
fn test_select_n_components() {
let x = make_test_data();
let result = select_n_components(&x, 1..3, Some(42))
.expect("select_n_components should succeed on well-formed data");
assert_eq!(result.aic_scores.len(), 2);
assert_eq!(result.bic_scores.len(), 2);
}
#[test]
fn test_communalities_range() {
let x = make_test_data();
let trained = FactorAnalysis::new(2)
.random_state(42)
.fit(&x, &())
.expect("FactorAnalysis::fit should succeed on well-formed data");
let comm = trained.communalities();
for c in comm.iter() {
assert!(
*c > 0.0 && *c <= 1.0 + 1e-9,
"communality out of range: {}",
c
);
}
}
#[test]
fn test_invalid_n_components() {
let x = make_test_data();
let fa = FactorAnalysis::new(4);
assert!(fa.fit(&x, &()).is_err());
}
#[test]
fn test_covariance_positive_diagonal() {
let x = make_test_data();
let trained = FactorAnalysis::new(2)
.random_state(42)
.fit(&x, &())
.expect("FactorAnalysis::fit should succeed on well-formed data");
let cov = trained.covariance();
for j in 0..4 {
assert!(cov[[j, j]] > 0.0);
}
}
#[test]
fn test_log_likelihood_numerical() {
let w_test = Array2::from_shape_vec((2, 1), vec![1.0_f64, 1.0_f64])
.expect("shape (2,1) matches 2-element vector");
let psi_test = Array1::from_vec(vec![0.5_f64, 0.5_f64]);
let m_mat = Array2::from_shape_vec((1, 1), vec![3.0_f64])
.expect("shape (1,1) matches 1-element vector");
let m_inv = Array2::from_shape_vec((1, 1), vec![1.0 / 3.0])
.expect("shape (1,1) matches 1-element vector");
let psi_inv = Array1::from_vec(vec![2.0_f64, 2.0_f64]);
let s_full = Array2::eye(2);
let n = 10.0_f64;
let p = 2;
let log_l =
compute_log_likelihood_mdl(n, p, &w_test, &psi_test, &psi_inv, &m_mat, &m_inv, &s_full);
let expected_log_det_sigma = 0.5_f64.ln() * 2.0 + 3.0_f64.ln();
let expected_trace = 4.0 - 8.0 / 3.0;
let expected = -5.0
* (2.0 * (2.0 * std::f64::consts::PI).ln() + expected_log_det_sigma + expected_trace);
assert!(
(log_l - expected).abs() < 1e-6,
"log-likelihood mismatch: got {}, expected {}",
log_l,
expected
);
}
#[test]
fn test_pipeline_fit_transform_then_transform_new_data() {
use crate::modular_framework::{
AlgorithmCapabilities, DecompositionAlgorithm, DecompositionComponents,
DecompositionParams, DecompositionPipeline, StandardizationStep,
};
use scirs2_core::ndarray::{Array1, Array2};
use sklears_core::types::Float;
use std::any::Any;
#[derive(Debug, Clone)]
struct IdentityAlg {
fitted: bool,
n_feats: usize,
}
impl IdentityAlg {
fn new() -> Self {
Self {
fitted: false,
n_feats: 0,
}
}
}
impl DecompositionAlgorithm for IdentityAlg {
fn name(&self) -> &str {
"identity"
}
fn description(&self) -> &str {
"pass-through"
}
fn capabilities(&self) -> AlgorithmCapabilities {
AlgorithmCapabilities::default()
}
fn validate_params(&self, _: &DecompositionParams) -> sklears_core::error::Result<()> {
Ok(())
}
fn fit(
&mut self,
data: &Array2<Float>,
_: &DecompositionParams,
) -> sklears_core::error::Result<()> {
self.fitted = true;
self.n_feats = data.ncols();
Ok(())
}
fn transform(
&self,
data: &Array2<Float>,
) -> sklears_core::error::Result<Array2<Float>> {
Ok(data.clone())
}
fn get_components(&self) -> sklears_core::error::Result<DecompositionComponents> {
Ok(DecompositionComponents {
components: Some(Array2::eye(self.n_feats)),
eigenvalues: Some(Array1::ones(self.n_feats)),
..Default::default()
})
}
fn is_fitted(&self) -> bool {
self.fitted
}
fn clone_algorithm(&self) -> Box<dyn DecompositionAlgorithm> {
Box::new(self.clone())
}
fn as_any(&self) -> &dyn Any {
self
}
}
let mut pipeline = DecompositionPipeline::new(Box::new(IdentityAlg::new()))
.add_preprocessing(Box::new(StandardizationStep::new()));
let params = DecompositionParams::default();
let x_train = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
.expect("shape (4,2) matches 8-element vector");
let result = pipeline
.fit_transform(&x_train, ¶ms)
.expect("pipeline fit_transform should succeed on well-formed data");
assert_eq!(
result
.components
.components
.expect("identity algorithm returns eye components")
.shape(),
[2, 2]
);
let x_test = Array2::from_shape_vec((2, 2), vec![2.0, 3.0, 4.0, 5.0])
.expect("shape (2,2) matches 4-element vector");
let transformed = pipeline
.transform(&x_test)
.expect("pipeline transform should succeed on fitted pipeline");
assert_eq!(transformed.dim(), (2, 2));
}
}