1use std::{ptr, ptr::NonNull};
2
3use cudf::Exec;
4
5use crate::{
6 device::{DeviceBuffer, DeviceTensorMut, MatrixLayout, OwnedMatrix},
7 dlpack::DlpackTensor,
8 error::{Result, check_cuvs, from_cudf, invalid_input, invalid_state, require_nonzero},
9 resources::CuvsResources,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PcaSolver {
14 CovarianceDivideAndConquer,
15 CovarianceJacobi,
16}
17
18impl PcaSolver {
19 fn raw(self) -> cuvs_sys::cuvsPcaSolver {
20 match self {
21 Self::CovarianceDivideAndConquer => cuvs_sys::cuvsPcaSolver::CUVS_PCA_COV_EIG_DQ,
22 Self::CovarianceJacobi => cuvs_sys::cuvsPcaSolver::CUVS_PCA_COV_EIG_JACOBI,
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
28pub struct PcaParams {
29 pub n_components: usize,
30 pub copy: bool,
31 pub whiten: bool,
32 pub solver: PcaSolver,
33 pub tolerance: f32,
34 pub iterations: i32,
35 pub flip_signs_based_on_u: bool,
36}
37
38impl PcaParams {
39 pub fn new(n_components: usize) -> Self {
40 Self {
41 n_components,
42 copy: true,
43 whiten: false,
44 solver: PcaSolver::CovarianceDivideAndConquer,
45 tolerance: 1e-7,
46 iterations: 15,
47 flip_signs_based_on_u: false,
48 }
49 }
50}
51
52#[derive(Debug)]
54pub struct PcaOutputBuffers {
55 pub transformed: DeviceBuffer<f32>,
56 pub components: DeviceBuffer<f32>,
57 pub explained_variance: DeviceBuffer<f32>,
58 pub explained_variance_ratio: DeviceBuffer<f32>,
59 pub singular_values: DeviceBuffer<f32>,
60 pub mean: DeviceBuffer<f32>,
61 pub noise_variance: DeviceBuffer<f32>,
62}
63
64impl PcaOutputBuffers {
65 pub fn new(resources: &CuvsResources, rows: usize, columns: usize, n_components: usize) -> Result<Self> {
66 let operation = "PcaOutputBuffers::new";
67 require_nonzero(rows, operation, "rows")?;
68 require_nonzero(columns, operation, "columns")?;
69 require_nonzero(n_components, operation, "n_components")?;
70 let transformed_len = rows
71 .checked_mul(n_components)
72 .ok_or_else(|| invalid_input(operation, "transformed output length overflowed usize"))?;
73 let components_len = n_components
74 .checked_mul(columns)
75 .ok_or_else(|| invalid_input(operation, "components output length overflowed usize"))?;
76 Ok(Self {
77 transformed: DeviceBuffer::new(resources, transformed_len)?,
78 components: DeviceBuffer::new(resources, components_len)?,
79 explained_variance: DeviceBuffer::new(resources, n_components)?,
80 explained_variance_ratio: DeviceBuffer::new(resources, n_components)?,
81 singular_values: DeviceBuffer::new(resources, n_components)?,
82 mean: DeviceBuffer::new(resources, columns)?,
83 noise_variance: DeviceBuffer::new(resources, 1)?,
84 })
85 }
86
87 pub fn into_transformed_columns(self, fit: PcaFitTransform) -> Result<Vec<cudf::Series>> {
90 let operation = "PcaOutputBuffers::into_transformed_columns";
91 validate_output_lengths(&self, fit.rows, fit.columns, fit.n_components, operation)?;
92 if !self.transformed.native_input_validated() {
93 return Err(invalid_state(
94 operation,
95 "transformed PCA output has not passed post-native finite validation",
96 ));
97 }
98 let expected = fit
99 .rows
100 .checked_mul(fit.n_components)
101 .ok_or_else(|| invalid_input(operation, "transformed output length overflowed usize"))?;
102 if self.transformed.len() != expected {
103 return Err(invalid_input(
104 operation,
105 format!("transformed output contains {} elements, expected {expected}", self.transformed.len()),
106 ));
107 }
108
109 let flat =
110 cudf::Series::from_column(self.transformed.into_column()?).map_err(|error| from_cudf(operation, error))?;
111 (0..fit.n_components)
112 .map(|component| {
113 flat.on(Exec::RETAINED)
114 .slice(component * fit.rows, fit.rows)
115 .map_err(|error| from_cudf(operation, error))
116 })
117 .collect()
118 }
119
120 pub fn into_transformed_dataframe(self, fit: PcaFitTransform) -> Result<cudf::DataFrame> {
123 let operation = "PcaOutputBuffers::into_transformed_dataframe";
124 let columns = self.into_transformed_columns(fit)?;
125 cudf::DataFrame::from_series(
126 columns.into_iter().enumerate().map(|(component, column)| (format!("component_{component}"), column)),
127 )
128 .map_err(|error| from_cudf(operation, error))
129 }
130
131 fn validate_native_outputs(&mut self, operation: &'static str) -> Result<()> {
132 self.transformed.validate_native_input(operation)?;
133 self.components.validate_native_input(operation)?;
134 self.explained_variance.validate_native_input(operation)?;
135 self.explained_variance_ratio.validate_native_input(operation)?;
136 self.singular_values.validate_native_input(operation)?;
137 self.mean.validate_native_input(operation)?;
138 self.noise_variance.validate_native_input(operation)?;
139 Ok(())
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct PcaFitTransform {
145 rows: usize,
146 columns: usize,
147 n_components: usize,
148}
149
150impl PcaFitTransform {
151 pub fn rows(&self) -> usize {
152 self.rows
153 }
154
155 pub fn columns(&self) -> usize {
156 self.columns
157 }
158
159 pub fn n_components(&self) -> usize {
160 self.n_components
161 }
162}
163
164struct ParamsHandle {
165 raw: Option<NonNull<cuvs_sys::cuvsPcaParams>>,
166}
167
168impl ParamsHandle {
169 fn new(params: &PcaParams, operation: &'static str) -> Result<Self> {
170 validate_params(params, operation)?;
171 let mut raw = ptr::null_mut();
172 unsafe { check_cuvs(cuvs_sys::cuvsPcaParamsCreate(&mut raw), operation)? };
173 let raw = NonNull::new(raw).ok_or_else(|| invalid_state(operation, "cuVS returned null PCA params"))?;
174 unsafe {
175 let native = raw.as_ptr();
176 (*native).n_components = params.n_components as i32;
177 (*native).copy = params.copy;
178 (*native).whiten = params.whiten;
179 (*native).algorithm = params.solver.raw();
180 (*native).tol = params.tolerance;
181 (*native).n_iterations = params.iterations;
182 }
183 Ok(Self { raw: Some(raw) })
184 }
185
186 fn as_ptr(&self, operation: &'static str) -> Result<cuvs_sys::cuvsPcaParams_t> {
187 self.raw.map(NonNull::as_ptr).ok_or_else(|| invalid_state(operation, "PCA params have been closed"))
188 }
189
190 fn close(&mut self, operation: &'static str) -> Result<()> {
191 let Some(raw) = self.raw.take() else {
192 return Ok(());
193 };
194 let result = unsafe { check_cuvs(cuvs_sys::cuvsPcaParamsDestroy(raw.as_ptr()), operation) };
195 if result.is_err() {
196 self.raw = Some(raw);
197 }
198 result
199 }
200}
201
202impl Drop for ParamsHandle {
203 fn drop(&mut self) {
204 if let Some(raw) = self.raw.take() {
205 unsafe {
206 let _ = cuvs_sys::cuvsPcaParamsDestroy(raw.as_ptr());
207 }
208 }
209 }
210}
211
212pub(crate) fn fit_transform_into(
213 resources: &CuvsResources,
214 input: &mut DeviceTensorMut<'_, f32>,
215 params: &PcaParams,
216 outputs: &mut PcaOutputBuffers,
217) -> Result<PcaFitTransform> {
218 let operation = "pca::fit_transform_into";
219 resources.validate_exact_binding(operation)?;
220 if input.binding_id() != resources.binding_id() {
221 return Err(invalid_input(operation, "PCA input and cuVS resources must use the same exact runtime binding")
222 .with_context("tensor_binding_id", input.binding_id())
223 .with_context("resource_binding_id", resources.binding_id()));
224 }
225 if input.device() != resources.device().ordinal() as i32 {
226 return Err(invalid_input(operation, "PCA input and cuVS resources must use the same CUDA device")
227 .with_context("tensor_device", input.device())
228 .with_context("resource_device", resources.device().ordinal()));
229 }
230 if matrix_layout_mut(input, operation)? != MatrixLayout::ColumnMajor {
231 return Err(invalid_input(operation, "PCA input must be a contiguous mutable column-major matrix"));
232 }
233 validate_params(params, operation)?;
234 let rows = input.rows()?;
235 let columns = input.columns()?;
236 validate_input_dimensions(rows, columns, params.n_components, operation)?;
237 validate_output_lengths(outputs, rows, columns, params.n_components, operation)?;
238
239 let transformed_strides = [1, rows];
240 let components_strides = [1, params.n_components];
241 let PcaOutputBuffers {
242 transformed,
243 components,
244 explained_variance,
245 explained_variance_ratio,
246 singular_values,
247 mean,
248 noise_variance,
249 } = outputs;
250 let mut transformed_view =
251 transformed.as_tensor_mut(resources, &[rows, params.n_components], Some(&transformed_strides), 0)?;
252 let mut components_view =
253 components.as_tensor_mut(resources, &[params.n_components, columns], Some(&components_strides), 0)?;
254 let mut explained_variance_view = explained_variance.as_tensor_mut(resources, &[params.n_components], None, 0)?;
255 let mut explained_variance_ratio_view =
256 explained_variance_ratio.as_tensor_mut(resources, &[params.n_components], None, 0)?;
257 let mut singular_values_view = singular_values.as_tensor_mut(resources, &[params.n_components], None, 0)?;
258 let mut mean_view = mean.as_tensor_mut(resources, &[columns], None, 0)?;
259 let mut noise_variance_view = noise_variance.as_tensor_mut(resources, &[1], None, 0)?;
260
261 resources.with_native(operation, |native| {
262 let mut params_handle = ParamsHandle::new(params, operation)?;
263 let mut input_tensor = DlpackTensor::from_mut(input);
264 let mut transformed_tensor = DlpackTensor::from_mut(&mut transformed_view);
265 let mut components_tensor = DlpackTensor::from_mut(&mut components_view);
266 let mut explained_variance_tensor = DlpackTensor::from_mut(&mut explained_variance_view);
267 let mut explained_variance_ratio_tensor = DlpackTensor::from_mut(&mut explained_variance_ratio_view);
268 let mut singular_values_tensor = DlpackTensor::from_mut(&mut singular_values_view);
269 let mut mean_tensor = DlpackTensor::from_mut(&mut mean_view);
270 let mut noise_variance_tensor = DlpackTensor::from_mut(&mut noise_variance_view);
271 let call_result = unsafe {
272 check_cuvs(
273 cuvs_sys::cuvsPcaFitTransform(
274 native,
275 params_handle.as_ptr(operation)?,
276 input_tensor.as_mut_ptr(),
277 transformed_tensor.as_mut_ptr(),
278 components_tensor.as_mut_ptr(),
279 explained_variance_tensor.as_mut_ptr(),
280 explained_variance_ratio_tensor.as_mut_ptr(),
281 singular_values_tensor.as_mut_ptr(),
282 mean_tensor.as_mut_ptr(),
283 noise_variance_tensor.as_mut_ptr(),
284 params.flip_signs_based_on_u,
285 ),
286 operation,
287 )
288 };
289 match (call_result, params_handle.close(operation)) {
290 (Err(error), _) => Err(error),
291 (Ok(()), close) => close,
292 }
293 })?;
294 outputs.validate_native_outputs(operation)?;
295
296 Ok(PcaFitTransform { rows, columns, n_components: params.n_components })
297}
298
299fn validate_input_dimensions(rows: usize, columns: usize, n_components: usize, operation: &'static str) -> Result<()> {
300 if rows < 2 {
301 return Err(invalid_input(operation, format!("PCA input must contain at least two rows, found {rows}"))
302 .with_context("rows", rows)
303 .with_context("minimum_rows", 2));
304 }
305 if columns < 2 {
306 return Err(invalid_input(
307 operation,
308 format!("PCA input must contain at least two feature columns, found {columns}"),
309 )
310 .with_context("feature_columns", columns)
311 .with_context("minimum_feature_columns", 2));
312 }
313 if n_components > columns {
314 return Err(invalid_input(
315 operation,
316 format!("n_components ({n_components}) must be <= input columns ({columns})"),
317 ));
318 }
319 Ok(())
320}
321
322pub fn fit_transform_owned(
326 resources: &CuvsResources,
327 input: &mut OwnedMatrix<f32>,
328 params: &PcaParams,
329 outputs: &mut PcaOutputBuffers,
330) -> Result<PcaFitTransform> {
331 let operation = "pca::fit_transform_owned";
332 if !input.is_input_validated() {
333 return Err(invalid_input(operation, "PCA input must carry finite-input validation"));
334 }
335 if input.layout() != MatrixLayout::ColumnMajor {
336 return Err(invalid_input(operation, "PCA owned input must be a contiguous column-major matrix"));
337 }
338 validate_params(params, operation)?;
339 validate_input_dimensions(input.rows(), input.columns(), params.n_components, operation)?;
340 validate_output_lengths(outputs, input.rows(), input.columns(), params.n_components, operation)?;
341 let mut tensor = input.as_tensor_mut(resources)?;
342 fit_transform_into(resources, &mut tensor, params, outputs).map_err(|error| error.with_operation(operation))
343}
344
345fn validate_params(params: &PcaParams, operation: &'static str) -> Result<()> {
346 require_nonzero(params.n_components, operation, "n_components")?;
347 if params.n_components > i32::MAX as usize {
348 return Err(invalid_input(operation, "n_components exceeds the cuVS i32 parameter range"));
349 }
350 if !params.tolerance.is_finite() || params.tolerance < 0.0 {
351 return Err(invalid_input(operation, "PCA tolerance must be finite and non-negative"));
352 }
353 if params.iterations <= 0 {
354 return Err(invalid_input(operation, "PCA iterations must be greater than zero"));
355 }
356 Ok(())
357}
358
359fn validate_output_lengths(
360 outputs: &PcaOutputBuffers,
361 rows: usize,
362 columns: usize,
363 n_components: usize,
364 operation: &'static str,
365) -> Result<()> {
366 let transformed = rows
367 .checked_mul(n_components)
368 .ok_or_else(|| invalid_input(operation, "transformed output length overflowed usize"))?;
369 let components = n_components
370 .checked_mul(columns)
371 .ok_or_else(|| invalid_input(operation, "components output length overflowed usize"))?;
372 let actual = [
373 ("transformed", outputs.transformed.len(), transformed),
374 ("components", outputs.components.len(), components),
375 ("explained_variance", outputs.explained_variance.len(), n_components),
376 ("explained_variance_ratio", outputs.explained_variance_ratio.len(), n_components),
377 ("singular_values", outputs.singular_values.len(), n_components),
378 ("mean", outputs.mean.len(), columns),
379 ("noise_variance", outputs.noise_variance.len(), 1),
380 ];
381 for (name, actual, expected) in actual {
382 if actual != expected {
383 return Err(invalid_input(operation, format!("{name} contains {actual} elements, expected {expected}")));
384 }
385 }
386 Ok(())
387}
388
389fn matrix_layout_mut(matrix: &DeviceTensorMut<'_, f32>, operation: &'static str) -> Result<MatrixLayout> {
390 let rows = matrix.rows()? as i64;
391 let columns = matrix.columns()? as i64;
392 match matrix.strides() {
393 None => Ok(MatrixLayout::RowMajor),
394 Some([row_stride, column_stride]) if *row_stride == columns && *column_stride == 1 => {
395 Ok(MatrixLayout::RowMajor)
396 },
397 Some([row_stride, column_stride]) if *row_stride == 1 && *column_stride == rows => {
398 Ok(MatrixLayout::ColumnMajor)
399 },
400 _ => Err(invalid_input(operation, "PCA matrix must be contiguous row-major or column-major")),
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn input_dimensions_reject_a_single_row_before_native_pca() {
410 let error = validate_input_dimensions(1, 2, 1, "test").expect_err("PCA requires at least two rows");
411
412 assert_eq!(error.kind(), crate::ErrorKind::InvalidInput);
413 assert!(error.message().contains("at least two rows"));
414 assert_eq!(error.context_value("rows"), Some("1"));
415 }
416
417 #[test]
418 fn input_dimensions_accept_the_native_boundary() {
419 validate_input_dimensions(2, 2, 2, "test").expect("two rows and two columns satisfy the native contract");
420 }
421}