Skip to main content

dspk_ops/linalg/
subspace.rs

1//! The leading `k` principal components of `L` rows of `D` features, streamed to the device in
2//! batches ([`RowSource`]): PCA of more rows than fit in device memory at once (MountainSort 5's
3//! snippets, its subdivision features).
4//!
5//! The rows are centred on their mean (one pass). Then, after scikit-learn's solver choice
6//! (MountainSort 5 `pca_solver.py`):
7//!
8//! - **`D ≤ exact_cap`** (8000): the covariance `C = Σ (x − μ)(x − μ)ᵀ` is formed on the device
9//!   (one product per batch of at most [`GRAM_ROWS`] rows, summed). Its leading eigenvectors are
10//!   exact: all of them by Jacobi ([`fn@super::symmetric_eigen`]) when `k + oversamples ≥ D / 2`,
11//!   otherwise by subspace iteration on `C` until the leading values change by less than
12//!   `tolerance` (relative), then Rayleigh–Ritz.
13//! - **Few rows** (`L ≤ dual_max_rows`, `L < D`, off by default): exact through the `L × L` Gram
14//!   matrix `G = X_c X_cᵀ` instead: its eigenvectors `U` (host, `f64`) give the components
15//!   `V = X_cᵀ U Σ⁻¹` and the variances `λ / (L − 1)`. One product each way and one small
16//!   eigendecomposition: much cheaper than iterating a subspace when `k` is close to `L`
17//!   (MountainSort 5's classifiers, ~`3 · mask channels` components of a few hundred snippets).
18//! - **`D > exact_cap`**: `C` is not formed (`D²` values); subspace iteration applies `C` as
19//!   `Σ_b X_bᵀ (X_b Q)` over the batches, for scikit-learn's randomized count of iterations (7
20//!   when `k < 0.1 · min(L, D)`, else 4) from a seeded Gaussian start of `k + oversamples` columns,
21//!   then Rayleigh–Ritz. Approximate, as scikit-learn's randomized solver; the random stream
22//!   differs from NumPy's, so the components are not bitwise scikit-learn's.
23//!
24//! Orthonormalisation is by the eigendecomposition of the Gram matrix `YᵀY` (`k + oversamples`
25//! square; on the host up to [`HOST_EIGEN_MAX`], else on the device): directions whose singular
26//! value is below `√ε` of the largest are set to zero instead of being amplified (rank-deficient
27//! data, e.g. masked snippets). `k` is capped at
28//! `min(k, L, D)` as scikit-learn caps it. Signs: each component's largest `|value|` is positive
29//! (scikit-learn ≥ 1.5 `svd_flip`, `u_based_decision=False`).
30
31use std::ops::Range;
32
33use cubecl::prelude::*;
34use cubecl::server::Handle;
35use dspk_core::compute::LaunchGeometry;
36
37use super::eigen::{symmetric_eigen, EigenOptions, SymmetricEigen};
38use super::tridiagonal::symmetric_eigen_cpu;
39use super::kernels::{add_assign_kernel, centre_columns_kernel, gather_rows_kernel};
40use super::matmul::{matmul, MatrixView};
41use crate::core::buffer;
42
43/// Matrices up to this size are eigendecomposed on the host ([`symmetric_eigen_cpu`], `f64`): the
44/// device Jacobi solver's launches cost more than the arithmetic there.
45pub const HOST_EIGEN_MAX: usize = 512;
46
47/// Rows per partial product of the covariance (each sum adds at most this many terms in `f32`).
48pub const GRAM_ROWS: usize = 4096;
49
50/// Rows of `dim` features, handed to the device a batch at a time.
51pub trait RowSource {
52    fn rows(&self) -> usize;
53    fn dim(&self) -> usize;
54    /// Rows `range` as a new row-major `[range.len(), dim]` `f32` device buffer.
55    fn batch(&self, client: &Client, range: Range<usize>) -> Handle;
56}
57
58/// Rows already on the host, row-major `[rows, dim]`.
59#[derive(Debug, Clone, Copy)]
60pub struct HostRows<'a> {
61    pub data: &'a [f32],
62    pub dim: usize,
63}
64
65impl RowSource for HostRows<'_> {
66    fn rows(&self) -> usize {
67        if self.dim == 0 { 0 } else { self.data.len() / self.dim }
68    }
69    fn dim(&self) -> usize {
70        self.dim
71    }
72    fn batch(&self, client: &Client, range: Range<usize>) -> Handle {
73        buffer::upload(client, &self.data[range.start * self.dim..range.end * self.dim])
74    }
75}
76
77/// Some rows (`rows`, by index) of a host matrix `[_, dim]`, gathered a batch at a time: no copy of
78/// the subset is made up front.
79#[derive(Debug, Clone, Copy)]
80pub struct IndexedRows<'a> {
81    pub data: &'a [f32],
82    pub dim: usize,
83    pub rows: &'a [usize],
84}
85
86impl RowSource for IndexedRows<'_> {
87    fn rows(&self) -> usize {
88        self.rows.len()
89    }
90    fn dim(&self) -> usize {
91        self.dim
92    }
93    fn batch(&self, client: &Client, range: Range<usize>) -> Handle {
94        let d = self.dim;
95        let mut out = Vec::with_capacity(range.len() * d);
96        for &i in &self.rows[range] {
97            out.extend_from_slice(&self.data[i * d..(i + 1) * d]);
98        }
99        buffer::upload(client, &out)
100    }
101}
102
103/// Some rows (`rows`, by index) of a `[_, dim]` `f32` matrix already in device memory (`data`, `len`
104/// values), gathered on the device a batch at a time: nothing crosses to the device but the indices.
105#[derive(Debug, Clone, Copy)]
106pub struct DeviceRows<'a> {
107    pub data: &'a Handle,
108    pub len: usize,
109    pub dim: usize,
110    pub rows: &'a [u32],
111}
112
113impl RowSource for DeviceRows<'_> {
114    fn rows(&self) -> usize {
115        self.rows.len()
116    }
117    fn dim(&self) -> usize {
118        self.dim
119    }
120    fn batch(&self, client: &Client, range: Range<usize>) -> Handle {
121        let b = range.len();
122        let total = b * self.dim;
123        let out = buffer::empty::<f32>(client, total.max(1));
124        if total > 0 {
125            let geom = LaunchGeometry::elementwise(client, total);
126            // SAFETY: `data` holds `len` values; the indices are below `len / dim`
127            unsafe {
128                gather_rows_kernel::launch::<f32>(
129                    client,
130                    geom.cube_count,
131                    geom.cube_dim,
132                    BufferArg::from_raw_parts(self.data.clone(), self.len),
133                    BufferArg::from_raw_parts(buffer::upload(client, &self.rows[range]), b),
134                    BufferArg::from_raw_parts(out.clone(), total),
135                    self.dim as u32,
136                    total as u32,
137                );
138            }
139        }
140        out
141    }
142}
143
144/// Settings of [`TopComponents::fit`].
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct TopComponentsOptions {
147    /// Above this many features the covariance is not formed (scikit-learn's randomized solver).
148    pub exact_cap: usize,
149    /// Extra columns of the iterated subspace (scikit-learn's `n_oversamples`).
150    pub oversamples: usize,
151    /// Iterations above `exact_cap`; `None`: scikit-learn's (module docs).
152    pub randomized_iterations: Option<usize>,
153    /// Relative change of the leading values that ends the exact iteration.
154    pub tolerance: f64,
155    pub max_iterations: usize,
156    pub seed: u64,
157    /// Device elements per batch of rows.
158    pub batch_elements: usize,
159    /// At most this many rows (fewer than the features, and `rows · features ≤ batch_elements`)
160    /// are decomposed exactly through their `rows × rows` Gram matrix (module docs); 0: never.
161    pub dual_max_rows: usize,
162}
163
164impl Default for TopComponentsOptions {
165    fn default() -> Self {
166        Self {
167            exact_cap: 8000,
168            oversamples: 10,
169            randomized_iterations: None,
170            tolerance: 1e-6,
171            max_iterations: 300,
172            seed: 0,
173            batch_elements: 1 << 25,
174            dual_max_rows: 0,
175        }
176    }
177}
178
179/// Fitted components (module docs).
180#[derive(Debug, Clone)]
181pub struct TopComponents {
182    pub dim: usize,
183    pub k: usize,
184    /// Column means.
185    pub mean: Vec<f32>,
186    /// `[k, dim]`, row `c` the `c`-th component.
187    pub components: Vec<f32>,
188    /// Variance along each component (`/ (L − 1)`).
189    pub variances: Vec<f64>,
190    /// Subspace iterations run (0: direct eigendecomposition).
191    pub iterations: usize,
192    mean_h: Handle,
193    /// `[dim, k]` (components as columns) on the device.
194    v_h: Handle,
195}
196
197/// Seeded standard normal draws (splitmix64, Box–Muller).
198struct Normal(u64);
199
200impl Normal {
201    fn uniform(&mut self) -> f64 {
202        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
203        let mut z = self.0;
204        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
205        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
206        (((z ^ (z >> 31)) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
207    }
208    fn next(&mut self) -> f64 {
209        let (u, v) = (self.uniform(), self.uniform());
210        (-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * v).cos()
211    }
212}
213
214/// How `C` is applied.
215enum Operator<'a, S: RowSource> {
216    /// `[d, d]` on the device.
217    Formed(Handle),
218    Implicit(&'a S),
219}
220
221impl TopComponents {
222    /// The leading `k` components of `src` (module docs).
223    ///
224    /// # Panics
225    ///
226    /// If `src` has no rows or no features.
227    pub fn fit<S: RowSource>(client: &Client, src: &S, k: usize, opts: &TopComponentsOptions) -> Self {
228        let (l, d) = (src.rows(), src.dim());
229        assert!(l > 0 && d > 0, "PCA of {l} rows of {d} features");
230        let k = k.min(l).min(d);
231        let batch_rows = (opts.batch_elements / d).clamp(1, GRAM_ROWS);
232
233        // Column means
234        let mut sums = vec![0.0f64; d];
235        let ones = buffer::filled::<f32>(client, batch_rows, 1.0);
236        let row = buffer::empty::<f32>(client, d);
237        for r in batches(l, batch_rows) {
238            let b = r.len();
239            let x = src.batch(client, r);
240            matmul::<f32>(client, &MatrixView::row_major(&ones, batch_rows, 1, b), &MatrixView::row_major(&x, b * d, b, d), &row, d);
241            for (s, v) in sums.iter_mut().zip(buffer::download::<f32>(client, row.clone())) {
242                *s += v as f64;
243            }
244        }
245        let mean: Vec<f32> = sums.iter().map(|s| (s / l as f64) as f32).collect();
246        let mean_h = buffer::upload(client, &mean);
247        let denom = (l.max(2) - 1) as f64;
248        if l <= opts.dual_max_rows && l < d && l * d <= opts.batch_elements {
249            return Self::fit_dual(client, src, k, mean, mean_h, denom);
250        }
251
252        let r = (k + opts.oversamples).min(d);
253        let exact = d <= opts.exact_cap;
254        let operator = if exact {
255            let c = buffer::zeros::<f32>(client, d * d);
256            let part = buffer::empty::<f32>(client, d * d);
257            for rr in batches(l, batch_rows) {
258                let b = rr.len();
259                let x = centred(client, src, rr, &mean_h);
260                let xv = MatrixView::row_major(&x, b * d, b, d);
261                matmul::<f32>(client, &xv.transposed(), &xv, &part, d * d);
262                add_assign(client, &c, &part, d * d);
263            }
264            if 2 * r >= d {
265                let eig = eigen_of(client, &c, d);
266                let vectors: Vec<f64> = (0..d * k).map(|e| eig.vectors[(e / k) * d + e % k]).collect();
267                let values = eig.values[..k].iter().map(|v| v.max(0.0) / denom).collect();
268                return Self::finish(client, d, k, mean, mean_h, vectors, values, 0);
269            }
270            Operator::Formed(c)
271        } else {
272            Operator::Implicit(src)
273        };
274        let iterations = if exact {
275            opts.max_iterations
276        } else {
277            opts.randomized_iterations.unwrap_or(if (k as f64) < 0.1 * l.min(d) as f64 { 7 } else { 4 })
278        };
279
280        let mut rng = Normal(opts.seed);
281        let start: Vec<f32> = (0..d * r).map(|_| rng.next() as f32).collect();
282        let (mut q, _) = orthonormalise(client, &buffer::upload(client, &start), d, r);
283        let mut previous: Option<Vec<f64>> = None;
284        let mut ran = 0;
285        for _ in 0..iterations {
286            ran += 1;
287            let y = apply(client, &operator, &q, d, r, l, batch_rows, &mean_h);
288            let (next, sigmas) = orthonormalise(client, &y, d, r);
289            q = next;
290            if exact {
291                let lead = &sigmas[..k];
292                if let Some(p) = &previous {
293                    let change = lead.iter().zip(p).map(|(a, b)| (a - b).abs() / a.abs().max(f64::MIN_POSITIVE)).fold(0.0, f64::max);
294                    if change < opts.tolerance {
295                        break;
296                    }
297                }
298                previous = Some(lead.to_vec());
299            }
300        }
301        // Rayleigh–Ritz: B = Qᵀ C Q, V = Q U
302        let y = apply(client, &operator, &q, d, r, l, batch_rows, &mean_h);
303        let bmat = buffer::empty::<f32>(client, r * r);
304        let qv = MatrixView::row_major(&q, d * r, d, r);
305        matmul::<f32>(client, &qv.transposed(), &MatrixView::row_major(&y, d * r, d, r), &bmat, r * r);
306        // Symmetrise: rounding leaves B slightly asymmetric
307        let mut bh = buffer::download::<f32>(client, bmat);
308        for i in 0..r {
309            for j in 0..i {
310                let m = 0.5 * (bh[i * r + j] + bh[j * r + i]);
311                bh[i * r + j] = m;
312                bh[j * r + i] = m;
313            }
314        }
315        let eig = if r <= HOST_EIGEN_MAX {
316            symmetric_eigen_cpu(&bh.iter().map(|&v| v as f64).collect::<Vec<_>>(), r)
317        } else {
318            symmetric_eigen::<f32>(client, &buffer::upload(client, &bh), r, EigenOptions::default())
319        };
320        let u: Vec<f32> = (0..r * k).map(|e| eig.vectors[(e / k) * r + e % k] as f32).collect();
321        let v = buffer::empty::<f32>(client, d * k);
322        matmul::<f32>(client, &qv, &MatrixView::row_major(&buffer::upload(client, &u), r * k, r, k), &v, d * k);
323        let vectors: Vec<f64> = buffer::download::<f32>(client, v).into_iter().map(f64::from).collect();
324        let values = eig.values[..k].iter().map(|v| v.max(0.0) / denom).collect();
325        Self::finish(client, d, k, mean, mean_h, vectors, values, ran)
326    }
327
328    /// Exact components of few rows through their Gram matrix (module docs).
329    fn fit_dual<S: RowSource>(client: &Client, src: &S, k: usize, mean: Vec<f32>, mean_h: Handle, denom: f64) -> Self {
330        let (l, d) = (src.rows(), src.dim());
331        let x = centred(client, src, 0..l, &mean_h);
332        let xv = MatrixView::row_major(&x, l * d, l, d);
333        let g = buffer::empty::<f32>(client, l * l);
334        matmul::<f32>(client, &xv, &xv.transposed(), &g, l * l);
335        let host: Vec<f64> = buffer::download::<f32>(client, g).into_iter().take(l * l).map(f64::from).collect();
336        // Rounding leaves the device product slightly asymmetric
337        let sym: Vec<f64> = (0..l * l).map(|e| 0.5 * (host[e] + host[(e % l) * l + e / l])).collect();
338        let eig = symmetric_eigen_cpu(&sym, l);
339        // U Σ⁻¹ for the leading k; directions below √ε of the largest are left at zero, as
340        // `orthonormalise` does (rank-deficient rows, e.g. masked snippets)
341        let top = eig.values.first().copied().unwrap_or(0.0).max(0.0);
342        let floor = top * f32::EPSILON as f64;
343        let mut w = vec![0.0f32; l * k];
344        for c in 0..k {
345            let lambda = eig.values[c];
346            if lambda > floor && lambda > 0.0 {
347                let s = 1.0 / lambda.sqrt();
348                for i in 0..l {
349                    w[i * k + c] = (eig.vectors[i * l + c] * s) as f32;
350                }
351            }
352        }
353        let v = buffer::empty::<f32>(client, d * k);
354        matmul::<f32>(client, &xv.transposed(), &MatrixView::row_major(&buffer::upload(client, &w), l * k, l, k), &v, d * k);
355        let vectors: Vec<f64> = buffer::download::<f32>(client, v).into_iter().map(f64::from).collect();
356        let values = eig.values[..k].iter().map(|v| v.max(0.0) / denom).collect();
357        Self::finish(client, d, k, mean, mean_h, vectors, values, 0)
358    }
359
360    /// Sign convention and device copies; `vectors` is `[d, k]` (components as columns).
361    #[allow(clippy::too_many_arguments)]
362    fn finish(client: &Client, d: usize, k: usize, mean: Vec<f32>, mean_h: Handle, vectors: Vec<f64>, variances: Vec<f64>, iterations: usize) -> Self {
363        let mut components = vec![0.0f32; k * d];
364        for c in 0..k {
365            let big = (0..d).map(|t| vectors[t * k + c]).fold(0.0f64, |m, v| if v.abs() > m.abs() { v } else { m });
366            let sign = if big < 0.0 { -1.0 } else { 1.0 };
367            for t in 0..d {
368                components[c * d + t] = (sign * vectors[t * k + c]) as f32;
369            }
370        }
371        let columns: Vec<f32> = (0..d * k).map(|e| components[(e % k) * d + e / k]).collect();
372        Self { dim: d, k, mean, components, variances, iterations, mean_h, v_h: buffer::upload(client, &columns) }
373    }
374
375    /// `(x − μ) · Vᵀ` of every row of `src`, `[rows, k]` on the host.
376    ///
377    /// # Panics
378    ///
379    /// If `src` has another number of features.
380    pub fn transform<S: RowSource>(&self, client: &Client, src: &S, batch_elements: usize) -> Vec<f32> {
381        let (l, d, k) = (src.rows(), self.dim, self.k);
382        assert_eq!(src.dim(), d, "rows of {} features, fitted on {d}", src.dim());
383        let batch_rows = (batch_elements / d.max(k)).max(1);
384        let mut out = Vec::with_capacity(l * k);
385        let f = buffer::empty::<f32>(client, batch_rows * k);
386        for r in batches(l, batch_rows) {
387            let b = r.len();
388            let x = centred(client, src, r, &self.mean_h);
389            matmul::<f32>(client, &MatrixView::row_major(&x, b * d, b, d), &MatrixView::row_major(&self.v_h, d * k, d, k), &f, batch_rows * k);
390            out.extend(buffer::download_prefix::<f32>(client, f.clone(), b * k));
391        }
392        out
393    }
394}
395
396/// Eigendecomposition of the symmetric `[n, n]` device matrix `m`: on the host up to
397/// [`HOST_EIGEN_MAX`], else on the device.
398fn eigen_of(client: &Client, m: &Handle, n: usize) -> SymmetricEigen {
399    if n <= HOST_EIGEN_MAX {
400        let host: Vec<f64> = buffer::download::<f32>(client, m.clone()).into_iter().take(n * n).map(f64::from).collect();
401        // Rounding leaves the device product slightly asymmetric
402        let sym: Vec<f64> = (0..n * n).map(|e| 0.5 * (host[e] + host[(e % n) * n + e / n])).collect();
403        symmetric_eigen_cpu(&sym, n)
404    } else {
405        symmetric_eigen::<f32>(client, m, n, EigenOptions::default())
406    }
407}
408
409fn batches(l: usize, rows: usize) -> impl Iterator<Item = Range<usize>> {
410    (0..l.div_ceil(rows)).map(move |i| i * rows..((i + 1) * rows).min(l))
411}
412
413/// Rows `r` of `src`, centred, on the device.
414fn centred<S: RowSource>(client: &Client, src: &S, r: Range<usize>, mean: &Handle) -> Handle {
415    let (b, d) = (r.len(), src.dim());
416    let x = src.batch(client, r);
417    let geom = LaunchGeometry::elementwise(client, b * d);
418    // SAFETY: `x` holds `b · d` values, `mean` `d`
419    unsafe {
420        centre_columns_kernel::launch::<f32>(
421            client,
422            geom.cube_count,
423            geom.cube_dim,
424            BufferArg::from_raw_parts(x.clone(), b * d),
425            BufferArg::from_raw_parts(mean.clone(), d),
426            d as u32,
427            (b * d) as u32,
428        );
429    }
430    x
431}
432
433fn add_assign(client: &Client, acc: &Handle, x: &Handle, n: usize) {
434    let geom = LaunchGeometry::elementwise(client, n);
435    // SAFETY: both hold `n` values
436    unsafe {
437        add_assign_kernel::launch::<f32>(
438            client,
439            geom.cube_count,
440            geom.cube_dim,
441            BufferArg::from_raw_parts(acc.clone(), n),
442            BufferArg::from_raw_parts(x.clone(), n),
443            n as u32,
444        );
445    }
446}
447
448/// `C · Q` (`[d, r]`).
449#[allow(clippy::too_many_arguments)]
450fn apply<S: RowSource>(client: &Client, op: &Operator<'_, S>, q: &Handle, d: usize, r: usize, l: usize, batch_rows: usize, mean: &Handle) -> Handle {
451    let qv = MatrixView::row_major(q, d * r, d, r);
452    let y = buffer::empty::<f32>(client, d * r);
453    match op {
454        Operator::Formed(c) => matmul::<f32>(client, &MatrixView::row_major(c, d * d, d, d), &qv, &y, d * r),
455        Operator::Implicit(src) => {
456            let acc = buffer::zeros::<f32>(client, d * r);
457            let z = buffer::empty::<f32>(client, batch_rows * r);
458            for rr in batches(l, batch_rows) {
459                let b = rr.len();
460                let x = centred(client, *src, rr, mean);
461                let xv = MatrixView::row_major(&x, b * d, b, d);
462                matmul::<f32>(client, &xv, &qv, &z, batch_rows * r);
463                matmul::<f32>(client, &xv.transposed(), &MatrixView::row_major(&z, b * r, b, r), &y, d * r);
464                add_assign(client, &acc, &y, d * r);
465            }
466            return acc;
467        }
468    }
469    y
470}
471
472/// Orthonormal columns spanning `y` (`[d, r]`), and the singular values of `y` (descending), by
473/// the eigendecomposition of `yᵀy` (module docs).
474fn orthonormalise(client: &Client, y: &Handle, d: usize, r: usize) -> (Handle, Vec<f64>) {
475    let yv = MatrixView::row_major(y, d * r, d, r);
476    let g = buffer::empty::<f32>(client, r * r);
477    matmul::<f32>(client, &yv.transposed(), &yv, &g, r * r);
478    let eig = eigen_of(client, &g, r);
479    let top = eig.values.first().copied().unwrap_or(0.0).max(0.0);
480    let floor = top * f32::EPSILON as f64;
481    let mut t = vec![0.0f32; r * r];
482    for c in 0..r {
483        let lambda = eig.values[c];
484        if lambda > floor && lambda > 0.0 {
485            let s = 1.0 / lambda.sqrt();
486            for i in 0..r {
487                t[i * r + c] = (eig.vectors[i * r + c] * s) as f32;
488            }
489        }
490    }
491    let q = buffer::empty::<f32>(client, d * r);
492    matmul::<f32>(client, &yv, &MatrixView::row_major(&buffer::upload(client, &t), r * r, r, r), &q, d * r);
493    (q, eig.values.iter().map(|v| v.max(0.0).sqrt()).collect())
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::linalg::symmetric_eigen_host;
500
501    /// Rows with a known spectrum: `L` rows of `D` features, variance decaying along random axes.
502    fn data(l: usize, d: usize, seed: u64) -> Vec<f32> {
503        let mut rng = Normal(seed);
504        let scales: Vec<f64> = (0..d).map(|i| 10.0 * 0.8f64.powi(i as i32) + 0.01).collect();
505        // A random rotation would be costly; mixing two coordinates keeps the axes non-trivial
506        (0..l)
507            .flat_map(|_| {
508                let z: Vec<f64> = scales.iter().map(|s| s * rng.next()).collect();
509                (0..d).map(move |j| (z[j] + 0.5 * z[(j + 1) % d] + 3.0) as f32).collect::<Vec<_>>()
510            })
511            .collect()
512    }
513
514    /// Host covariance, its leading eigenvalues (device Jacobi on the host's `f64` matrix).
515    fn reference(client: &Client, x: &[f32], l: usize, d: usize, k: usize) -> Vec<f64> {
516        let mean: Vec<f64> = (0..d).map(|j| (0..l).map(|i| x[i * d + j] as f64).sum::<f64>() / l as f64).collect();
517        let mut c = vec![0.0f64; d * d];
518        for i in 0..l {
519            for a in 0..d {
520                for b in 0..d {
521                    c[a * d + b] += (x[i * d + a] as f64 - mean[a]) * (x[i * d + b] as f64 - mean[b]);
522                }
523            }
524        }
525        c.iter_mut().for_each(|v| *v /= (l - 1) as f64);
526        symmetric_eigen_host::<f32>(client, &c, d, EigenOptions::default()).values[..k].to_vec()
527    }
528
529    fn check(client: &Client, opts: TopComponentsOptions, iterate: bool) {
530        let (l, d, k) = (3000usize, 64usize, 5usize);
531        let x = data(l, d, 1);
532        let src = HostRows { data: &x, dim: d };
533        let fit = TopComponents::fit(client, &src, k, &opts);
534        assert_eq!(fit.iterations > 0, iterate, "{opts:?}");
535        let want = reference(client, &x, l, d, k);
536        for (g, w) in fit.variances.iter().zip(&want) {
537            assert!((g - w).abs() < 2e-3 * w, "variances {:?} vs {want:?}", fit.variances);
538        }
539        // Orthonormal components, features with those variances
540        for a in 0..k {
541            for b in 0..k {
542                let dot: f32 = (0..d).map(|t| fit.components[a * d + t] * fit.components[b * d + t]).sum();
543                assert!((dot - if a == b { 1.0 } else { 0.0 }).abs() < 1e-3, "<{a},{b}> = {dot}");
544            }
545        }
546        let f = fit.transform(client, &src, 1 << 12);
547        assert_eq!(f.len(), l * k);
548        let var0 = (0..l).map(|i| (f[i * k] as f64).powi(2)).sum::<f64>() / (l - 1) as f64;
549        assert!((var0 - want[0]).abs() < 2e-3 * want[0], "{var0} vs {}", want[0]);
550    }
551
552    fn all_paths(client: &Client) {
553        // Small batches: the sums run over many batches
554        let base = TopComponentsOptions { batch_elements: 64 * 100, ..Default::default() };
555        // Direct (k + oversamples ≥ D / 2)
556        check(client, TopComponentsOptions { oversamples: 30, ..base }, false);
557        // Exact subspace iteration (oversamples small against D)
558        check(client, TopComponentsOptions { oversamples: 3, ..base }, true);
559        // Implicit ("randomized"), more iterations than scikit-learn's to reach the test tolerance
560        check(client, TopComponentsOptions { exact_cap: 10, oversamples: 10, randomized_iterations: Some(12), ..base }, true);
561    }
562    runtime_test!(test_top_components_match_the_covariance, all_paths);
563
564    /// Fewer rows than features: the Gram-matrix path gives the covariance's leading variances and
565    /// orthonormal components, without iterating, with `k` close to `L`.
566    fn dual_path(client: &Client) {
567        let (l, d, k) = (120usize, 300usize, 100usize);
568        let x = data(l, d, 2);
569        let src = HostRows { data: &x, dim: d };
570        let opts = TopComponentsOptions { dual_max_rows: 4096, ..Default::default() };
571        let fit = TopComponents::fit(client, &src, k, &opts);
572        assert_eq!((fit.iterations, fit.k), (0, k));
573        let want = reference(client, &x, l, d, 10);
574        for (g, w) in fit.variances.iter().zip(&want) {
575            assert!((g - w).abs() < 2e-3 * w, "variances {:?} vs {want:?}", &fit.variances[..10]);
576        }
577        for a in 0..10 {
578            for b in 0..10 {
579                let dot: f32 = (0..d).map(|t| fit.components[a * d + t] * fit.components[b * d + t]).sum();
580                assert!((dot - if a == b { 1.0 } else { 0.0 }).abs() < 1e-3, "<{a},{b}> = {dot}");
581            }
582        }
583    }
584    runtime_test!(test_top_components_dual_path, dual_path);
585}