Thom Benjamin Volker
The singular value decomposition of a
Note that
The singular value decomposition of a matrix can be obtained on a column by column basis, using the following program.
svd_R <- function(X, n.dim, n.iter, tol = 1e-08, verbose = FALSE) {
N <- nrow(X)
P <- ncol(X)
u <- matrix(rnorm(N*n.dim), ncol = n.dim)
v <- matrix(rnorm(P*n.dim), nrow = n.dim)
s <- rep(0, n.dim)
X_res <- X
warn <- NULL;
for (d in 1:n.dim) {
converged <- FALSE;
iter <- 0
if (d == 2) X_res <- X - u[,1:(d-1), drop = FALSE] %*% s[1] %*% t(v[,1:(d-1), drop = FALSE])
if (d > 2) X_res <- X - u[,1:(d-1), drop = FALSE] %*% diag(s[1:(d-1)]) %*% t(v[,1:(d-1), drop = FALSE])
u_new <- u_old <- u[,d]
v_new <- v_old <- v[,d]
while (!converged) {
iter <- iter + 1
if (verbose) cat("\r", "Dimension: ", d, " | Iteration: ", iter)
u_new <- X_res %*% v_new
u_new <- u_new / sqrt(sum(u_new^2))
v_new <- t(X_res) %*% u_new
v_new <- v_new / sqrt(sum(v_new^2))
if (sum(sqrt((v_new - v_old)^2)) < tol) {
converged <- TRUE
} else if (iter == n.iter) {
warn <- "The algorithm has not converged, but the maximum number of iterations is reached."
} else {
u_old <- u_new
v_old <- v_new
}
}
u[,d] <- u_new
v[,d] <- v_new
s[d] <- t(u[,d]) %*% X_res %*% v[,d]
}
cat("\n")
if (!is.null(warn)) warning(warn)
return(list(u = u, v = v, s = s))
}
That is, for every dimension of the composition, we proceed iteratively.
We first calculate the first left singular vector
set.seed(24)
N <- 100
P <- 50
S <- diag(P)
S[S==0] <- 0.5
X <- rnorm(N*P) |> matrix(N) %*% chol(S)
t <- Sys.time()
SVD_base <- svd(X)
Sys.time() - tTime difference of 0.00176692 secs
t <- Sys.time()
SVD_R <- svd_R(X, P, 2000, 1e-05, FALSE)Sys.time() - tTime difference of 0.281373 secs
diag(cor(SVD_base$u, SVD_R$u)) [1] -1 1 1 -1 -1 -1 1 1 -1 1 -1 -1 -1 1 1 1 1 -1 1 1 -1 1 -1 1 -1
[26] 1 -1 -1 1 -1 1 -1 -1 1 1 1 -1 1 1 -1 1 1 -1 -1 -1 -1 1 1 -1 1
So, we now have an algorithm that can obtain the singular value
decomposition of a matrix that actually works, but that is substantially
slower than the native R implementation. This is not surprising, as
the native R implementation is based on the LAPACK library, which is
written in Fortran. However, we can still improve our algorithm by
using the Rcpp package to write the algorithm in C++, which is much
faster than R. We can then use the Rcpp package to compile the C++
code and use it in R.
The C++ code for the singular value decomposition is shown below. Note
that we use the Rcpp package to write the code, which allows us to use
R objects in C++ and vice versa. We also use the RcppArmadillo
package, which allows us to use the Armadillo library in C++.
Despite some practical differences, the code is pretty similar to the
R code.
library(Rcpp)
svd_script <- '
#include <Rcpp.h>
using namespace Rcpp;
// [[Rcpp::depends(RcppProgress)]]
// [[Rcpp::depends(RcppArmadillo)]]
#include <progress.hpp>
#include <progress_bar.hpp>
#define RCPP_ARMADILLO_RETURN_ANYVEC_AS_VECTOR
// [[Rcpp::export]]
List svd_cpp(arma::mat X, arma::mat u, arma::mat v, arma::vec s, int n_dim, int n_iter, double tol, bool verbose) {
arma::mat X_res = X;
Progress p(n_dim, verbose);
bool warn = false;
for (int d = 0; d < n_dim; d++) {
if (d == 1) X_res = X - u.cols(0, d-1) * s(0) * v.cols(0, d-1).t();
if (d > 1) X_res = X - u.cols(0, d-1) * diagmat(s.subvec(0, d-1)) * v.cols(0, d-1).t();
p.increment();
arma::vec u_old = u.col(d);
arma::vec u_new = u.col(d);
arma::vec v_old = v.col(d);
arma::vec v_new = v.col(d);
bool converged = false;
int iter = 0;
while (!converged) {
iter++;
u_new = X_res * v_new;
u_new = u_new / sqrt(sum(u_new % u_new));
v_new = X_res.t() * u_new;
v_new = v_new / sqrt(sum(v_new % v_new));
if (sum(sqrt(pow(v_new - v_old, 2))) < tol) {
converged = true;
} else if (iter == n_iter) {
warn = true;
} else {
u_old = u_new;
v_old = v_new;
}
}
u.col(d) = u_new;
v.col(d) = v_new;
s(d) = dot(u.col(d), X_res * v.col(d));
}
if (verbose) Rcpp::Rcout << "\\n";
if (warn) {
Rcpp::warning("The algorithm has not converged, but the maximum number of iterations is reached.");
}
return Rcpp::List::create(Rcpp::Named("u") = u, Rcpp::Named("v") = v, Rcpp::Named("s") = s);
}
'
cppFunction(svd_script, depends = c("RcppArmadillo", "RcppProgress"))Warning: No function found for Rcpp::export attribute at file42444e852cdf.cpp:8
svdcpp <- function(X, n.dim = ncol(X), n.iter = 2000, tol = 1e-08, verbose = FALSE) {
N <- nrow(X)
P <- ncol(X)
u <- matrix(rnorm(N*n.dim), ncol = n.dim)
v <- matrix(rnorm(P*n.dim), ncol = n.dim)
s <- rep(0, n.dim)
svd_cpp(X, u, v, s, n.dim, n.iter, tol, verbose)
}The function again yields correct results.
SVD_Cpp <- svdcpp(X, P, 2000, 1e-05, FALSE)
diag(cor(SVD_base$u, SVD_Cpp$u)) [1] -1 -1 1 1 -1 1 1 1 1 1 1 1 1 1 1 1 1 -1 1 -1 1 1 -1 -1 1
[26] -1 1 1 -1 -1 1 -1 1 1 1 -1 1 -1 -1 1 -1 1 1 1 -1 1 -1 -1 -1 -1
Also all singular values are as they’re supposed to be.
cor(cbind(SVD_Cpp$s, SVD_R$s, SVD_base$d)) [,1] [,2] [,3]
[1,] 1 1 1
[2,] 1 1 1
[3,] 1 1 1
However, we’re still much slower than the native R implementation,
which probably means that our algorithm is simply quite slow.
bench::mark(
base = svd(X),
R = svd_R(X, P, 2000, 1e-05, FALSE),
Cpp = svdcpp(X, P, 2000, 1e-05, FALSE),
check = FALSE
)Warning: Some expressions had a GC in every iteration; so filtering is
disabled.
# A tibble: 3 × 6
expression min median `itr/sec` mem_alloc `gc/sec`
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl>
1 base 958.2µs 1.03ms 922. 279KB 9.98
2 R 400.1ms 438.16ms 2.28 465MB 45.6
3 Cpp 84.9ms 91.75ms 11.1 229KB 0
Hence, we are able to program a singular value decomposition manually,
but in terms of computational efficiency, I’m unsure whether we can do
much better than the base R implementation.