--- title: PCA on sparse data without densifying output: rmarkdown::html_vignette: toc: yes toc_depth: 2 css: albers.css includes: in_header: albers-header.html params: family: lapis preset: homage resource_files: - albers.css - albers.js - albers-header.html - fonts vignette: | %\VignetteIndexEntry{PCA on sparse data without densifying} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup-opts, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE, fig.width = 7, fig.height = 4.1, fig.align = "center", out.width = "92%", dpi = 100 ) ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` ```{r helpers, include = FALSE} # Shared plotting helper. Kept out of the reader's view: the reader sees # the API call and the picture, never the plotting boilerplate. ec_blue <- "#2166ac"; ec_grey <- "grey78" # A computed slice (blue) drawn on top of the full dense spectrum (grey). plot_spectrum <- function(all_vals, computed, ylab = "value", main = NULL) { s <- sort(all_vals, decreasing = TRUE) k <- length(computed) op <- par(mar = c(4, 4.6, if (is.null(main)) 1 else 2.4, 1)); on.exit(par(op)) plot(seq_along(s), s, pch = 19, cex = 0.4, col = ec_grey, bty = "n", xlab = "rank (largest to smallest)", ylab = ylab, main = main) points(seq_len(k), sort(computed, decreasing = TRUE), pch = 19, cex = 1.2, col = ec_blue) legend("topright", c("full spectrum (dense reference)", "computed by eigencore"), pch = 19, pt.cex = c(0.7, 1.2), col = c(ec_grey, ec_blue), bty = "n", cex = 0.9) } ``` Principal component analysis starts with centering: subtract each column's mean before finding directions of maximum variance. For a sparse matrix, that one subtraction is the problem. `A - 1 %*% t(col_means)` fills in every zero with a small nonzero number, so the matrix that started sparse becomes dense before you've computed a single eigenvector. eigencore solves the *centered* (and optionally scaled) problem as an operator, so the subtraction never happens as a stored matrix. This vignette builds one sparse dataset, centers and scales it, and computes a certified partial SVD without ever forming the dense copy. ```{r setup} library(eigencore) library(Matrix) ``` ## Build a sparse dataset with a low-rank signal Simulate the kind of matrix behind collaborative filtering and topic models: sparse interaction counts between 2,000 users and 500 items, built from five latent factors so a handful of singular vectors should recover most of the structure. ```{r simulate-signal} set.seed(1) m <- 2000L; n <- 500L; k_true <- 5L U <- qr.Q(qr(matrix(rnorm(m * k_true), m, k_true))) V <- qr.Q(qr(matrix(rnorm(n * k_true), n, k_true))) signal <- U %*% diag(seq(8, 4, length.out = k_true)) %*% t(V) ``` Only 4% of user-item pairs are observed, and the observations are noisy: ```{r simulate-sparsify} mask <- rsparsematrix(m, n, density = 0.04) noise <- rsparsematrix(m, n, density = 0.01, rand.x = function(k) rnorm(k, sd = 0.05)) A <- as((signal * (mask != 0)) + noise, "dgCMatrix") dim(A) ``` ```{r simulate-memory, echo = FALSE} dense_mb <- as.numeric(object.size(matrix(0, m, n))) / 1024^2 sparse_mb <- as.numeric(object.size(A)) / 1024^2 ``` At 2,000 x 500 this is a small matrix, but the storage gap is already visible: a dense copy would need `r sprintf("%.1f", dense_mb)` MB, while the sparse representation needs `r sprintf("%.2f", sparse_mb)` MB — about `r round(dense_mb / sparse_mb)` times less. ## Center without densifying `center()` wraps `A` in an operator that subtracts column means on the fly, computing the means directly from the sparse matrix rather than requiring you to supply them. ```{r center} Ac <- center(A, columns = TRUE) Ac ``` Pass the centered operator straight to `svd_partial()`, exactly as you would the original matrix. ```{r first-svd} fit <- svd_partial(Ac, rank = 5, target = largest()) fit ``` ```{r first-scree, echo = FALSE, fig.cap = "The five largest singular values of the centered matrix (blue) against the full singular spectrum (grey).", fig.alt = "Scatter plot of all 500 singular values sorted descending in grey, with the top five highlighted in blue."} dense_centered <- scale(as.matrix(A), center = TRUE, scale = FALSE) all_sv <- svd(dense_centered, nu = 0, nv = 0)$d plot_spectrum(all_sv, fit$d, ylab = "singular value") ``` The five requested singular values occupy the leading edge of the spectrum, although the fifth-to-sixth gap is modest after masking and noise. This example checks the non-densifying computation rather than claiming reliable recovery of the five simulated factors. ## Read the certificate on a composed operator `fit$certificate$passed` is `FALSE` here, but look at why before treating that as a problem: ```{r composed-certificate} fit$certificate$max_backward_error fit$certificate$norm_bound_type fit$certificate$scale_is_estimate ``` The backward error is tiny — far below the `1e-8` tolerance. What's withheld is the *scale*: the current centered sparse operator does not propagate an exact Frobenius norm, so eigencore reports a stochastic (Hutchinson) estimate and refuses to mark the certificate `passed` while that estimate is the only evidence. This is the certificate bookkeeping `vignette("certificates")` covers in detail. It is a current metadata limitation, not a mathematical requirement of centering; supplying exact norm metadata gives a deterministic certificate scale. ## Does it match what you'd get by densifying? The whole point of `center()` is that it should be numerically indistinguishable from centering the dense matrix directly. Check it: ```{r verify-dense} max(abs(sort(fit$d, decreasing = TRUE) - sort(all_sv[1:5], decreasing = TRUE))) ``` The two agree to machine precision. `dense_centered` above was built only to draw the comparison plot and run this check — the eigencore computation itself never materialized it. ## Scale columns too A common next step in PCA preprocessing is scaling each column to unit sample variance, turning a covariance-matrix PCA into a correlation-matrix one. Compute the sample variances from the sparse matrix and pass the inverse standard deviations to `scale_cols()`, composed with the centering operator you already have: ```{r scale-cols} col_var <- (Matrix::colSums(A^2) - m * Matrix::colMeans(A)^2) / (m - 1) w <- 1 / sqrt(col_var) Acs <- scale_cols(Ac, w) fit_scaled <- svd_partial(Acs, rank = 5, target = largest()) fit_scaled$d ``` ```{r validate-scale-cols, include = FALSE} stopifnot( all(is.finite(col_var)), all(col_var > 0), max(abs(col_var * w^2 - 1)) < 1e-12 ) ``` `center()` and `scale_cols()` compose freely because both return the same `eigencore_operator` type that every solver accepts — there's no separate "scaled matrix" object to keep track of. ## What did the planner actually do? Every solve is preceded by a plan, and you can inspect it directly instead of trusting a black box: ```{r plan} plan <- plan_solver(svd_problem(Acs), rank = 5, target = largest()) plan$method plan$reasons ``` Centering and scaling remain non-densifying, but they are not currently one fully fused native operator. The centered sparse apply is native; the scaling layer composes through an R-level operator apply. The solver therefore drives the result through a matrix-free Golub-Kahan callback cycle rather than a fully block-native kernel — the `reasons` make that boundary explicit. `plan_solver()` takes the same problem/rank/target arguments as the solve, so you can inspect the intended primary route before paying for the computation; the result diagnostics record any fallback that actually occurs. ## Going fully matrix-free Sometimes there's no matrix at all — just a function that knows how to apply `A` and its adjoint. `linear_operator()` wraps arbitrary callbacks the same way `center()` wraps a sparse matrix. ```{r matrix-free} op <- linear_operator( dim = dim(A), apply = function(X, alpha = 1, beta = 0, Y = NULL) { # Sparse %*% dense returns an S4 dgeMatrix; as.matrix() keeps the # callback's return type consistent with what the native solver expects. Z <- alpha * as.matrix(A %*% X) if (is.null(Y) || beta == 0) Z else Z + beta * Y }, apply_adjoint = function(X, alpha = 1, beta = 0, Y = NULL) { Z <- alpha * as.matrix(crossprod(A, X)) if (is.null(Y) || beta == 0) Z else Z + beta * Y }, name = "matrix-free A" ) fit_mf <- svd_partial(op, rank = 5, target = largest()) fit_mf$certificate$norm_bound_type ``` Without any norm metadata, this is another stochastic-estimate certificate. Supply the exact Frobenius norm — cheap to compute once for a sparse matrix — and the certificate stops withholding `passed`: ```{r matrix-free-hint} op_hinted <- linear_operator( dim = dim(A), apply = op$apply, apply_adjoint = op$apply_adjoint, name = "matrix-free A (exact norm)", metadata = list(frobenius_norm = norm(A, type = "F")) ) fit_hinted <- svd_partial(op_hinted, rank = 5, target = largest()) fit_hinted$certificate$norm_bound_type fit_hinted$certificate$passed ``` Whether you reach for `center()`/`scale_cols()` or hand-write a `linear_operator()`, the certificate always tells you which kind of evidence backed the result. ## Why this matters at scale The 2,000 x 500 example above keeps this vignette fast to build, but the memory argument only gets sharper as the matrix grows. A production recommender with 5 million users and 200,000 items would need roughly 8 TB to store as a dense double matrix. At densities from 0.01% to 0.05%, the sparse value and row-index arrays alone require roughly 1.2 to 6 GB, before format overhead. Densifying to center it would defeat the point of using a sparse format. ## Where to go next - `vignette("certificates")` — the deep dive on `passed`, `scale_is_estimate`, and what to do about a withheld certificate. - `vignette("eigencore")` — the general get-started workflow: operators, problems, plans, and solves for both eigenproblems and SVD. - `vignette("generalized-eigenproblems")` — problems of the form `A v = lambda B v`, including whitened and metric-weighted variants. - `?linear_operator` and `?compose` document the full operator algebra, including `crossprod_operator()` for building `A^* A` when an eigenproblem is a more natural fit than an SVD.