use crate::Float;
use crate::error::{CoreError, Result};
use crate::linalg::decomp::SvdDecomposition;
use crate::tensor::Tensor;
pub fn unfold<T: Float>(x: &Tensor<T>, mode: usize) -> Result<Tensor<T>> {
let ndim = x.ndim();
if mode >= ndim {
return Err(CoreError::AxisOutOfBounds { axis: mode, ndim });
}
let shape = x.shape();
let rows = shape[mode];
let cols: usize = shape
.iter()
.enumerate()
.filter(|&(i, _)| i != mode)
.map(|(_, &d)| d)
.product();
if cols == 0 || rows == 0 {
return Tensor::from_vec(vec![], vec![rows, cols]);
}
let mut result = vec![T::zero(); rows * cols];
let other_dims: Vec<usize> = (0..ndim).filter(|&i| i != mode).collect();
let _other_shape: Vec<usize> = other_dims.iter().map(|&i| shape[i]).collect();
let numel = x.numel();
let strides = x.strides();
let mut multi_idx = vec![0usize; ndim];
for flat in 0..numel {
let mut rem = flat;
for d in 0..ndim {
multi_idx[d] = rem / strides[d];
rem %= strides[d];
}
let row = multi_idx[mode];
let mut col = 0;
let mut col_stride = 1;
for &d in other_dims.iter().rev() {
col += multi_idx[d] * col_stride;
col_stride *= shape[d];
}
result[row * cols + col] = *x.get(&multi_idx)?;
}
Tensor::from_vec(result, vec![rows, cols])
}
fn fold<T: Float>(mat: &Tensor<T>, shape: &[usize], mode: usize) -> Result<Tensor<T>> {
let ndim = shape.len();
if mode >= ndim {
return Err(CoreError::AxisOutOfBounds { axis: mode, ndim });
}
let numel: usize = shape.iter().product();
let mut result = vec![T::zero(); numel];
let result_strides = compute_strides(shape);
let rows = shape[mode];
let cols: usize = shape
.iter()
.enumerate()
.filter(|&(i, _)| i != mode)
.map(|(_, &d)| d)
.product();
let other_dims: Vec<usize> = (0..ndim).filter(|&i| i != mode).collect();
let mut multi_idx = vec![0usize; ndim];
for (flat, result_val) in result.iter_mut().enumerate() {
let mut rem = flat;
for d in 0..ndim {
multi_idx[d] = rem / result_strides[d];
rem %= result_strides[d];
}
let row = multi_idx[mode];
let mut col = 0;
let mut col_stride = 1;
for &d in other_dims.iter().rev() {
col += multi_idx[d] * col_stride;
col_stride *= shape[d];
}
if row < rows && col < cols {
*result_val = mat.as_slice()[row * cols + col];
}
}
Tensor::from_vec(result, shape.to_vec())
}
fn compute_strides(shape: &[usize]) -> Vec<usize> {
let mut strides = vec![1usize; shape.len()];
for i in (0..shape.len()).rev().skip(1) {
strides[i] = strides[i + 1] * shape[i + 1];
}
strides
}
pub fn mode_n_product<T: Float>(x: &Tensor<T>, a: &Tensor<T>, mode: usize) -> Result<Tensor<T>> {
if a.ndim() != 2 {
return Err(CoreError::InvalidArgument {
reason: "mode_n_product: matrix must be 2-D",
});
}
if mode >= x.ndim() {
return Err(CoreError::AxisOutOfBounds {
axis: mode,
ndim: x.ndim(),
});
}
if a.shape()[1] != x.shape()[mode] {
return Err(CoreError::DimensionMismatch {
expected: vec![a.shape()[0], x.shape()[mode]],
got: a.shape().to_vec(),
});
}
let x_unf = unfold(x, mode)?;
let y_unf = a.matmul(&x_unf)?;
let mut new_shape = x.shape().to_vec();
new_shape[mode] = a.shape()[0];
fold(&y_unf, &new_shape, mode)
}
pub fn khatri_rao<T: Float>(a: &Tensor<T>, b: &Tensor<T>) -> Result<Tensor<T>> {
if a.ndim() != 2 || b.ndim() != 2 {
return Err(CoreError::InvalidArgument {
reason: "khatri_rao: both arguments must be 2-D",
});
}
if a.shape()[1] != b.shape()[1] {
return Err(CoreError::DimensionMismatch {
expected: vec![a.shape()[0], a.shape()[1]],
got: b.shape().to_vec(),
});
}
let ia = a.shape()[0];
let jb = b.shape()[0];
let r = a.shape()[1];
let rows = ia * jb;
let mut data = vec![T::zero(); rows * r];
let a_data = a.as_slice();
let b_data = b.as_slice();
for col in 0..r {
for i in 0..ia {
for j in 0..jb {
data[(i * jb + j) * r + col] = a_data[i * r + col] * b_data[j * r + col];
}
}
}
Tensor::from_vec(data, vec![rows, r])
}
fn hadamard<T: Float>(a: &Tensor<T>, b: &Tensor<T>) -> Result<Tensor<T>> {
if a.shape() != b.shape() {
return Err(CoreError::DimensionMismatch {
expected: a.shape().to_vec(),
got: b.shape().to_vec(),
});
}
let data: Vec<T> = a
.as_slice()
.iter()
.zip(b.as_slice().iter())
.map(|(&x, &y)| x * y)
.collect();
Tensor::from_vec(data, a.shape().to_vec())
}
fn ata<T: Float>(a: &Tensor<T>) -> Result<Tensor<T>> {
let at = a.transpose()?;
at.matmul(a)
}
fn pinv_solve<T: Float>(a: &Tensor<T>, b: &Tensor<T>) -> Result<Tensor<T>> {
let svd = SvdDecomposition::decompose(a)?;
let u = svd.u(); let vt = svd.vt(); let s = svd.singular_values();
let _m = a.shape()[0];
let n = a.shape()[1];
let p = b.shape()[1];
let ut = u.transpose()?;
let utb = ut.matmul(b)?;
let k = s.len(); let tol = T::epsilon() * T::from_f64(100.0) * s[0];
let mut sinv_utb = vec![T::zero(); n * p];
for i in 0..k {
if s[i] > tol {
let inv_si = T::one() / s[i];
for j in 0..p {
sinv_utb[i * p + j] = inv_si * utb.as_slice()[i * p + j];
}
}
}
let sinv_utb_tensor = Tensor::from_vec(sinv_utb, vec![n, p])?;
let v = vt.transpose()?; v.matmul(&sinv_utb_tensor)
}
#[cfg_attr(
feature = "serde-support",
derive(serde::Serialize, serde::Deserialize)
)]
#[derive(Debug, Clone)]
pub struct CpDecomposition<T: Float> {
weights: Vec<T>,
factors: Vec<Tensor<T>>,
}
#[allow(clippy::many_single_char_names)]
impl<T: Float> CpDecomposition<T> {
pub fn decompose(x: &Tensor<T>, rank: usize, max_iter: usize, tol: T) -> Result<Self> {
let ndim = x.ndim();
if ndim < 2 {
return Err(CoreError::InvalidArgument {
reason: "CP decomposition requires at least a 2-D tensor",
});
}
if rank == 0 {
return Err(CoreError::InvalidArgument {
reason: "CP decomposition rank must be positive",
});
}
let shape = x.shape().to_vec();
let mut factors: Vec<Tensor<T>> = Vec::with_capacity(ndim);
for (mode, &dim) in shape.iter().enumerate() {
let x_n = unfold(x, mode)?;
let r_use = rank.min(dim);
if let Ok(svd) = SvdDecomposition::decompose(&x_n) {
let u = svd.u(); let u_data = u.as_slice();
let u_cols = u.shape()[1];
let mut data = vec![T::zero(); dim * rank];
for i in 0..dim {
for r in 0..r_use {
data[i * rank + r] = u_data[i * u_cols + r];
}
for r in r_use..rank {
data[i * rank + r] = T::from_f64(
(i + 1) as f64 * 0.01
+ (r + 1) as f64 * 0.007
+ (mode + 1) as f64 * 0.003,
);
}
}
factors.push(Tensor::from_vec(data, vec![dim, rank])?);
} else {
let mut data = vec![T::zero(); dim * rank];
for i in 0..dim {
for r in 0..rank {
let val = T::from_f64(
((i + 1) as f64).sqrt() / (dim as f64)
+ ((r * 7 + 3) as f64).sin() * 0.5,
);
data[i * rank + r] = val;
}
}
factors.push(Tensor::from_vec(data, vec![dim, rank])?);
}
}
let mut prev_error = T::from_f64(f64::MAX);
for _iter in 0..max_iter {
for n in 0..ndim {
let mut v_mat = all_ones_matrix(rank)?;
let mut kr: Option<Tensor<T>> = None;
for k in (0..ndim).rev() {
if k == n {
continue;
}
let fk = &factors[k];
let ftf = ata(fk)?;
v_mat = hadamard(&v_mat, &ftf)?;
kr = Some(match kr {
None => fk.clone(),
Some(prev) => khatri_rao(fk, &prev)?,
});
}
let kr = kr.ok_or(CoreError::InvalidArgument {
reason: "CP decomposition: need at least 2 modes",
})?;
let x_n = unfold(x, n)?;
let x_kr = x_n.matmul(&kr)?; let new_factor =
pinv_solve(&v_mat.transpose()?, &x_kr.transpose()?)?.transpose()?;
factors[n] = new_factor;
}
let decomp = CpDecomposition {
weights: vec![T::one(); rank],
factors: factors.clone(),
};
let recon = decomp.reconstruct()?;
let mut error = T::zero();
for (&a, &b) in x.as_slice().iter().zip(recon.as_slice().iter()) {
let d = a - b;
error += d * d;
}
error = error.sqrt();
if (prev_error - error).abs() < tol {
break;
}
prev_error = error;
}
let weights = extract_weights(&mut factors, &shape, rank)?;
Ok(CpDecomposition { weights, factors })
}
pub fn reconstruct(&self) -> Result<Tensor<T>> {
if self.factors.is_empty() {
return Err(CoreError::InvalidArgument {
reason: "CP decomposition has no factors",
});
}
let rank = self.weights.len();
let shape: Vec<usize> = self.factors.iter().map(|f| f.shape()[0]).collect();
let numel: usize = shape.iter().product();
let ndim = shape.len();
let strides = compute_strides(&shape);
let mut data = vec![T::zero(); numel];
for r in 0..rank {
let w = self.weights[r];
let mut multi_idx = vec![0usize; ndim];
for (flat, data_val) in data.iter_mut().enumerate() {
let mut rem = flat;
for d in 0..ndim {
multi_idx[d] = rem / strides[d];
rem %= strides[d];
}
let mut val = w;
for (n, &idx) in multi_idx.iter().enumerate() {
val *= self.factors[n].as_slice()[idx * rank + r];
}
*data_val += val;
}
}
Tensor::from_vec(data, shape)
}
pub fn factors(&self) -> &[Tensor<T>] {
&self.factors
}
pub fn weights(&self) -> &[T] {
&self.weights
}
}
fn all_ones_matrix<T: Float>(r: usize) -> Result<Tensor<T>> {
Tensor::from_vec(vec![T::one(); r * r], vec![r, r])
}
fn extract_weights<T: Float>(
factors: &mut [Tensor<T>],
shape: &[usize],
rank: usize,
) -> Result<Vec<T>> {
let ndim = factors.len();
let mut weights = vec![T::one(); rank];
for n in 0..ndim {
let dim = shape[n];
let f_data = factors[n].as_slice();
let mut new_data = vec![T::zero(); dim * rank];
for r in 0..rank {
let mut col_norm = T::zero();
for i in 0..dim {
col_norm += f_data[i * rank + r] * f_data[i * rank + r];
}
col_norm = col_norm.sqrt();
if col_norm > T::epsilon() {
for i in 0..dim {
new_data[i * rank + r] = f_data[i * rank + r] / col_norm;
}
weights[r] *= col_norm;
} else {
for i in 0..dim {
new_data[i * rank + r] = f_data[i * rank + r];
}
}
}
factors[n] = Tensor::from_vec(new_data, vec![dim, rank])?;
}
Ok(weights)
}
#[cfg_attr(
feature = "serde-support",
derive(serde::Serialize, serde::Deserialize)
)]
#[derive(Debug, Clone)]
pub struct TuckerDecomposition<T: Float> {
core: Tensor<T>,
factors: Vec<Tensor<T>>,
}
#[allow(clippy::many_single_char_names)]
impl<T: Float> TuckerDecomposition<T> {
pub fn decompose(x: &Tensor<T>, ranks: &[usize]) -> Result<Self> {
let ndim = x.ndim();
if ndim < 2 {
return Err(CoreError::InvalidArgument {
reason: "Tucker decomposition requires at least a 2-D tensor",
});
}
if ranks.len() != ndim {
return Err(CoreError::DimensionMismatch {
expected: x.shape().to_vec(),
got: ranks.to_vec(),
});
}
let shape = x.shape();
for (n, &r) in ranks.iter().enumerate() {
if r == 0 || r > shape[n] {
return Err(CoreError::InvalidArgument {
reason: "Tucker rank must be in [1, dim_n] for each mode",
});
}
}
let mut factors: Vec<Tensor<T>> = Vec::with_capacity(ndim);
for n in 0..ndim {
let x_n = unfold(x, n)?; let svd = SvdDecomposition::decompose(&x_n)?;
let u_full = svd.u();
let dim_n = shape[n];
let rn = ranks[n];
let mut u_data = vec![T::zero(); dim_n * rn];
for i in 0..dim_n {
for j in 0..rn {
u_data[i * rn + j] = u_full.as_slice()[i * dim_n + j];
}
}
factors.push(Tensor::from_vec(u_data, vec![dim_n, rn])?);
}
let mut core = x.clone();
for (n, factor) in factors.iter().enumerate() {
let ut = factor.transpose()?; core = mode_n_product(&core, &ut, n)?;
}
Ok(TuckerDecomposition { core, factors })
}
pub fn reconstruct(&self) -> Result<Tensor<T>> {
let mut result = self.core.clone();
for (n, factor) in self.factors.iter().enumerate() {
result = mode_n_product(&result, factor, n)?;
}
Ok(result)
}
pub fn core(&self) -> &Tensor<T> {
&self.core
}
pub fn factors(&self) -> &[Tensor<T>] {
&self.factors
}
}
#[cfg_attr(
feature = "serde-support",
derive(serde::Serialize, serde::Deserialize)
)]
#[derive(Debug, Clone)]
pub struct NtfDecomposition<T: Float> {
weights: Vec<T>,
factors: Vec<Tensor<T>>,
}
#[allow(clippy::many_single_char_names)]
impl<T: Float> NtfDecomposition<T> {
pub fn decompose(x: &Tensor<T>, rank: usize, max_iter: usize, tol: T) -> Result<Self> {
let ndim = x.ndim();
if ndim < 2 {
return Err(CoreError::InvalidArgument {
reason: "NTF decomposition requires at least a 2-D tensor",
});
}
if rank == 0 {
return Err(CoreError::InvalidArgument {
reason: "NTF decomposition rank must be positive",
});
}
for &val in x.as_slice() {
if val < T::zero() {
return Err(CoreError::InvalidArgument {
reason: "NTF decomposition requires non-negative tensor",
});
}
}
let shape = x.shape().to_vec();
let mut factors: Vec<Tensor<T>> = Vec::with_capacity(ndim);
for (mode, &dim) in shape.iter().enumerate() {
let mut data = vec![T::zero(); dim * rank];
for i in 0..dim {
for r in 0..rank {
data[i * rank + r] = T::from_f64(
1.0 + (i as f64 * 0.1) + (r as f64 * 0.05) + (mode as f64 * 0.01),
);
}
}
factors.push(Tensor::from_vec(data, vec![dim, rank])?);
}
let eps = T::from_f64(1e-12);
let mut prev_error = T::from_f64(f64::MAX);
for _iter in 0..max_iter {
for n in 0..ndim {
let x_n = unfold(x, n)?;
let mut kr: Option<Tensor<T>> = None;
for (k, fk) in factors.iter().enumerate().rev() {
if k == n {
continue;
}
kr = Some(match kr {
None => fk.clone(),
Some(prev) => khatri_rao(fk, &prev)?,
});
}
let kr = kr.ok_or(CoreError::InvalidArgument {
reason: "NTF: need at least 2 modes",
})?;
let numerator = x_n.matmul(&kr)?;
let mut v_mat = all_ones_matrix(rank)?;
for (k, fk) in factors.iter().enumerate() {
if k == n {
continue;
}
let ftf = ata(fk)?;
v_mat = hadamard(&v_mat, &ftf)?;
}
let denominator = factors[n].matmul(&v_mat)?;
let dim_n = shape[n];
let f_data = factors[n].as_slice();
let num_data = numerator.as_slice();
let den_data = denominator.as_slice();
let mut new_data = vec![T::zero(); dim_n * rank];
for idx in 0..dim_n * rank {
new_data[idx] = f_data[idx] * num_data[idx] / (den_data[idx] + eps);
}
factors[n] = Tensor::from_vec(new_data, vec![dim_n, rank])?;
}
let decomp = NtfDecomposition {
weights: vec![T::one(); rank],
factors: factors.clone(),
};
let recon = decomp.reconstruct()?;
let mut error = T::zero();
for (&a, &b) in x.as_slice().iter().zip(recon.as_slice().iter()) {
let d = a - b;
error += d * d;
}
error = error.sqrt();
if (prev_error - error).abs() < tol {
break;
}
prev_error = error;
}
let weights = extract_weights(&mut factors, &shape, rank)?;
Ok(NtfDecomposition { weights, factors })
}
pub fn reconstruct(&self) -> Result<Tensor<T>> {
if self.factors.is_empty() {
return Err(CoreError::InvalidArgument {
reason: "NTF decomposition has no factors",
});
}
let rank = self.weights.len();
let shape: Vec<usize> = self.factors.iter().map(|f| f.shape()[0]).collect();
let numel: usize = shape.iter().product();
let ndim = shape.len();
let strides = compute_strides(&shape);
let mut data = vec![T::zero(); numel];
for r in 0..rank {
let w = self.weights[r];
let mut multi_idx = vec![0usize; ndim];
for (flat, data_val) in data.iter_mut().enumerate() {
let mut rem = flat;
for d in 0..ndim {
multi_idx[d] = rem / strides[d];
rem %= strides[d];
}
let mut val = w;
for (n, &idx) in multi_idx.iter().enumerate() {
val *= self.factors[n].as_slice()[idx * rank + r];
}
*data_val += val;
}
}
Tensor::from_vec(data, shape)
}
pub fn factors(&self) -> &[Tensor<T>] {
&self.factors
}
pub fn weights(&self) -> &[T] {
&self.weights
}
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::many_single_char_names)]
mod tests {
use super::*;
fn approx_eq_tensor(a: &Tensor<f64>, b: &Tensor<f64>, tol: f64) -> bool {
if a.shape() != b.shape() {
return false;
}
a.as_slice()
.iter()
.zip(b.as_slice().iter())
.all(|(&x, &y)| (x - y).abs() < tol)
}
fn reconstruction_error(orig: &Tensor<f64>, recon: &Tensor<f64>) -> f64 {
orig.as_slice()
.iter()
.zip(recon.as_slice().iter())
.map(|(&a, &b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt()
}
fn tensor_norm(x: &Tensor<f64>) -> f64 {
x.as_slice().iter().map(|&v| v * v).sum::<f64>().sqrt()
}
#[test]
fn test_cp_rank1_exact() {
let a = [1.0, 2.0];
let b = [3.0, 4.0, 5.0];
let c = [6.0, 7.0];
let mut data = vec![0.0; 2 * 3 * 2];
for i in 0..2 {
for j in 0..3 {
for k in 0..2 {
data[i * 6 + j * 2 + k] = a[i] * b[j] * c[k];
}
}
}
let x = Tensor::from_vec(data, vec![2, 3, 2]).unwrap();
let cp = CpDecomposition::decompose(&x, 1, 100, 1e-10).unwrap();
let recon = cp.reconstruct().unwrap();
let err = reconstruction_error(&x, &recon);
let norm = tensor_norm(&x);
assert!(
err / norm < 1e-6,
"rank-1 CP reconstruction error too large: {err}"
);
}
#[test]
fn test_cp_reduces_error() {
let data: Vec<f64> = (0..24).map(|i| (f64::from(i) + 1.0) * 0.5).collect();
let x = Tensor::from_vec(data, vec![2, 3, 4]).unwrap();
let cp = CpDecomposition::decompose(&x, 3, 50, 1e-12).unwrap();
let recon = cp.reconstruct().unwrap();
let err = reconstruction_error(&x, &recon);
let norm = tensor_norm(&x);
assert!(
err / norm < 0.5,
"CP reconstruction error too large: {err}/{norm}"
);
}
#[test]
fn test_tucker_full_ranks_lossless() {
let data: Vec<f64> = (0..24).map(|i| f64::from(i) + 1.0).collect();
let x = Tensor::from_vec(data, vec![2, 3, 4]).unwrap();
let tucker = TuckerDecomposition::decompose(&x, &[2, 3, 4]).unwrap();
let recon = tucker.reconstruct().unwrap();
let err = reconstruction_error(&x, &recon);
assert!(err < 1e-8, "Tucker full-rank reconstruction error: {err}");
}
#[test]
fn test_tucker_reduced_ranks() {
let data: Vec<f64> = (0..24).map(|i| f64::from(i) + 1.0).collect();
let x = Tensor::from_vec(data, vec![2, 3, 4]).unwrap();
let tucker = TuckerDecomposition::decompose(&x, &[2, 2, 3]).unwrap();
let recon = tucker.reconstruct().unwrap();
assert_eq!(tucker.core().shape(), &[2, 2, 3]);
let err = reconstruction_error(&x, &recon);
let norm = tensor_norm(&x);
assert!(
err / norm < 0.5,
"Tucker reduced-rank error too large: {err}/{norm}"
);
}
#[test]
fn test_ntf_nonnegative() {
let data: Vec<f64> = (0..12).map(|i| f64::from(i) + 1.0).collect();
let x = Tensor::from_vec(data, vec![2, 3, 2]).unwrap();
let ntf = NtfDecomposition::decompose(&x, 2, 100, 1e-10).unwrap();
let recon = ntf.reconstruct().unwrap();
for &v in recon.as_slice() {
assert!(v >= 0.0, "NTF reconstruction has negative value: {v}");
}
for factor in ntf.factors() {
for &v in factor.as_slice() {
assert!(v >= 0.0, "NTF factor has negative value: {v}");
}
}
for &w in ntf.weights() {
assert!(w >= 0.0, "NTF weight is negative: {w}");
}
}
#[test]
fn test_unfold_fold_identity() {
let data: Vec<f64> = (0..24).map(f64::from).collect();
let x = Tensor::from_vec(data, vec![2, 3, 4]).unwrap();
for mode in 0..3 {
let unfolded = unfold(&x, mode).unwrap();
let expected_rows = x.shape()[mode];
let expected_cols: usize = x
.shape()
.iter()
.enumerate()
.filter(|&(i, _)| i != mode)
.map(|(_, &d)| d)
.product();
assert_eq!(
unfolded.shape(),
&[expected_rows, expected_cols],
"unfolded shape mismatch for mode {mode}"
);
let refolded = fold(&unfolded, x.shape(), mode).unwrap();
assert!(
approx_eq_tensor(&x, &refolded, 1e-12),
"unfold/fold round-trip failed for mode {mode}"
);
}
}
#[test]
fn test_khatri_rao_shape() {
let a = Tensor::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![3, 2]).unwrap();
let b = Tensor::from_vec(vec![7.0, 8.0, 9.0, 10.0], vec![2, 2]).unwrap();
let kr = khatri_rao(&a, &b).unwrap();
assert_eq!(kr.shape(), &[6, 2]);
let c = Tensor::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]).unwrap();
assert!(khatri_rao(&a, &c).is_err());
}
#[test]
fn test_tucker_rank_too_large() {
let data: Vec<f64> = (0..12).map(f64::from).collect();
let x = Tensor::from_vec(data, vec![2, 3, 2]).unwrap();
let result = TuckerDecomposition::decompose(&x, &[2, 5, 2]);
assert!(result.is_err());
}
#[test]
fn test_mode_n_product_identity() {
let data: Vec<f64> = (0..24).map(|i| f64::from(i) + 1.0).collect();
let x = Tensor::from_vec(data, vec![2, 3, 4]).unwrap();
for mode in 0..3 {
let dim = x.shape()[mode];
let eye = Tensor::<f64>::eye(dim);
let result = mode_n_product(&x, &eye, mode).unwrap();
assert!(
approx_eq_tensor(&x, &result, 1e-10),
"mode-{mode} product with identity failed"
);
}
}
#[test]
fn test_cp_zero_iterations() {
let data: Vec<f64> = (0..12).map(|i| f64::from(i) + 1.0).collect();
let x = Tensor::from_vec(data, vec![2, 3, 2]).unwrap();
let cp = CpDecomposition::decompose(&x, 2, 0, 1e-10).unwrap();
assert_eq!(cp.factors().len(), 3);
assert_eq!(cp.weights().len(), 2);
assert_eq!(cp.factors()[0].shape(), &[2, 2]);
assert_eq!(cp.factors()[1].shape(), &[3, 2]);
assert_eq!(cp.factors()[2].shape(), &[2, 2]);
}
}