Case study: sparse factors in S&P 500 returns

This vignette works through a complete, non-trivial application of msPCA to financial return data. It illustrates the practical difference between the two notions of non-redundancy that the package supports —orthogonal loadings and uncorrelated principal components— and shows that the choice materially changes the factors you recover.

Every msPCA result below reproduces from data shipped with the package: no downloads, no accounts, no external files. The fitting chunks are marked eval = FALSE only to keep the vignette quick to build (the full sparsity grid takes several minutes). Note that the benchmark method,
nsprcomp needs a data matrix rather than a correlation matrix.

The data

The dataset snp500 is the market-deflated correlation matrix of daily log-returns for p = 423 S&P 500 constituents with complete price histories from January 2010 to December 2019 (n = 2,515 trading days).

library("msPCA")
data(snp500)

dim(snp500)
#> [1] 423 423
round(snp500[1:4, 1:4], 3)
#>           A   AAPL   ABT   ACGL
#> A     0.433  0.004 0.031 -0.027
#> AAPL  0.004  0.716 0.012 -0.048
#> ABT   0.031  0.012 0.614  0.010
#> ACGL -0.027 -0.048 0.010  0.631

The matrix was constructed from the S&P 500 daily update dataset on Kaggle, released under CC0 1.0. Typically, stock returns are dominated by a “market factor” that absorbs a disproportionate share of total variance. To expose cross-sectional structure (sector and style effects) rather than market-wide movements, we projected out the leading eigenvector \(v_1\) of the empirical correlation matrix \(\Sigma\), so \(\texttt{snp500} = P^\top \Sigma P\) with \(P = I - v_1 v_1^\top\). The matrix is rank \(p - 1=422\):

ev <- eigen(snp500, symmetric = TRUE, only.values = TRUE)$values
sum(ev > 1e-8)          # 422: the market direction has been removed
#> [1] 422

The full data processing script is provided in data-raw/snp500.R in the package repository, and ?snp500 documents the format.

Sparse factor extraction

We extract r = 4 sparse factors, each allowed to load on at most k stocks, varying k from 5 to 35 in steps of 5 and running the analysis under both constraint types. The code below produces the results for the orthogonality constraint (feasibilityConstraintType = 0); setting feasibilityConstraintType = 1 gives the zero pairwise correlation results.

ks_grid <- seq(5, 35, by = 5)

results <- lapply(ks_grid, function(k) {
  set.seed(42)
  res <- mspca(snp500, r = 4, ks = rep(k, 4), verbose = FALSE,
               maxIter = 100, feasibilityConstraintType = 0)
  data.frame(
    k      = k,
    fve    = fraction_variance_explained(snp500, res$x_best),
    orth   = feasibility_violation_off(snp500, res$x_best, 0),
    pwcorr = feasibility_violation_off(snp500, res$x_best, 1)
  )
})
results_df <- do.call(rbind, results)

We also run nsprcomp::nsprcomp() (Sigg 2019) at the same budgets as a reference. Note that nsprcomp() requires a data matrix rather than a covariance matrix, so it needs the deflated returns XR <- X %*% P rather than snp500. That matrix is 2,515 x 423 and is not shipped with the package; data-raw/snp500.R documents how to rebuild it from the raw prices. We include a pre-computed comparison instead here, so the numbers below can be inspected and re-plotted without a rerun:

res_grid <- read.csv(system.file("vignette-data", "snp_varyingk_results.csv",
                                 package = "msPCA"))

ks  <- sort(unique(res_grid$k))
by_constraint <- function(cn) {
  sub <- res_grid[res_grid$constraint == cn, ]
  sub[match(ks, sub$k), c("fve", "orth_violation")]
}

tab <- cbind(k = ks,
             by_constraint("orthogonality"),
             by_constraint("zero-correlation"),
             by_constraint("nsprcomp"))

knitr::kable(
  tab, digits = 4, row.names = FALSE,
  col.names = c("k", "FVE (msPCA - orth)", "orth viol (msPCA - orth)", "FVE (msPCA - zero-corr)",
                "orth viol (msPCA - zero-corr)", "FVE (nsprcomp)", "orth viol (nsprcomp)"),
  caption = paste("FVE and orthogonality violation across the sparsity grid.",
                  "Every violation column reports the orthogonality violation,",
                  "including for the fits run under the zero-correlation constraint.")
)
FVE and orthogonality violation across the sparsity grid. Every violation column reports the orthogonality violation, including for the fits run under the zero-correlation constraint.
k FVE (msPCA - orth) orth viol (msPCA - orth) FVE (msPCA - zero-corr) orth viol (msPCA - zero-corr) FVE (nsprcomp) orth viol (nsprcomp)
5 0.0347 0.0155 0.0250 0.2331 0.0417 0.0000
10 0.0639 0.0001 0.0383 0.3327 0.0660 0.0000
15 0.0758 0.0001 0.0493 0.3470 0.0832 0.0000
20 0.0930 0.0001 0.0596 0.2998 0.0986 0.0000
25 0.0953 0.0001 0.0658 0.2780 0.1083 0.0148
30 0.1113 0.0001 0.0783 0.4033 0.1025 0.1168
35 0.1142 0.0001 0.0795 0.3180 0.1051 0.2349

Comparing the two constraint types

The figures below report the fraction of variance explained (FVE), the orthogonality violation, and the uncorrelatedness violation as a function of k, for msPCA under each constraint type, against nsprcomp::nsprcomp() at the same sparsity budgets.

Left: fraction of variance explained vs. sparsity budget k. Center: orthogonality violation vs. k. Right: uncorrelatedness violation vs. k. Results are shown for msPCA with orthogonality constraints (blue, solid), msPCA with zero pairwise correlation constraints (green, dashed), and nsprcomp::nsprcomp() (orange, dotted). All methods use r = 4 components.

On this dataset, nsprcomp::nsprcomp() returns exactly orthogonal loading vectors for small-to-moderate sparsity budgets (k <= 20), reflecting the effectiveness of the deflation procedure when component supports can easily be disjoint. Beyond that orthogonality breaks down, and does so steeply: the violation is 0.015 at k = 25, 0.12 at k = 30 and 0.23 at k = 35.

By contrast, msPCA with orthogonality constraints holds the violation at or below 1e-4 — the default feasibility tolerance — at every budget from k = 10 upward, because the penalty on constraint violation is explicitly tightened throughout the algorithm. In terms of FVE the two methods are comparable, with a small edge for nsprcomp::nsprcomp() up to k = 25. At k = 30 and k = 35 — precisely the budgets where nsprcomp::nsprcomp() gives up orthogonality — msPCA overtakes it, reaching an FVE of 0.111 and 0.114 against 0.102 and 0.105 while keeping the violation below tolerance.

Neither nsprcomp::nsprcomp() nor orthogonality-constrained msPCA yields uncorrelated PCs here. To obtain uncorrelated PCs we run msPCA with pairwise correlation constraints instead, which yields PCs with near-zero pairwise correlation that are not mutually orthogonal. On this dataset, requiring zero pairwise correlation rather than orthogonality is possible only at the expense of a substantially lower FVE.

The two constraints correspond to different feasibility definitions and lead to meaningfully different factor compositions. msPCA lets the user choose and enforce whichever is relevant to their use case, with predictable behavior across the full range of sparsity levels.

Interpreting the sparse components

Fixing k = 10, each factor loads on 10 stocks out of 423, making it possible to associate each component with an economic theme. Sector labels below follow the Global Industry Classification Standard [GICS; MSCI and S&P Dow Jones Indices (2023)].

set.seed(42)
res_orth <- mspca(snp500, r = 4, ks = rep(10, 4), verbose = FALSE,
                  maxIter = 100, feasibilityConstraintType = 0)
set.seed(42)
res_corr <- mspca(snp500, r = 4, ks = rep(10, 4), verbose = FALSE,
                  maxIter = 100, feasibilityConstraintType = 1)

print(res_orth)
print(res_corr)

summary() on either fit reports the violations under the constraint that fit enforced, and labels them as such:

summary(res_orth)
summary(res_corr)

Loadings of the 4 PCs (sparsity k = 10) returned by msPCA with orthogonality (left) or zero-correlation (right) constraints.

Under orthogonality constraints

The four PCs concentrate entirely within the utility and REIT sectors, with no cross-sector loadings. Each PC has 10 nonzeros by construction, but a few are numerically negligible (below 1e-4); the economically meaningful names are:

The orthogonality constraint therefore fragments the market into three disjoint utility clusters and one diversified REIT basket, with each PC loading exclusively on one sector. The four components carry comparable weight, explaining 2.13%, 1.62%, 1.52% and 1.21% of total variance respectively.

Under zero-correlation constraints

The components are more sector-diverse, and their supports overlap rather than partitioning the universe — Consolidated Edison (ED) appears in all four. Variance is also far more concentrated in the leading component: 2.28%, 0.64%, 0.63% and 0.45%, against a much flatter profile under orthogonality.

Signs are arbitrary up to a global flip within each component; what matters is the contrast between the positively and negatively loaded groups.

Takeaway

Orthogonality and zero pairwise correlation are not interchangeable. On strongly correlated data they recover qualitatively different factor structures. On this stock return data, orthogonality returns disjoint supports and spends all four components inside two sectors: three separate utility clusters and one REIT basket. Zero pairwise correlation, on the other hand, returns overlapping supports and recovers four distinct themes instead —utilities, REITs, packaged food and household staples, and casino operators— each expressed as a contrast between positively and negatively loaded groups. We also observe a difference in the distribution of variance explained by each PC: under zero correlation the leading component carries most of the explained variance (2.28% against 0.45–0.64% for the others), whereas orthogonality spreads it more evenly (2.13% down to 1.21%), and total FVE is markedly lower with zero-correlation constraints at every sparsity budget.

References

MSCI, and S&P Dow Jones Indices. 2023. The Global Industry Classification Standard (GICS). Https://www.msci.com/our-solutions/indexes/gics.
Sigg, Christian D. 2019. nsprcomp: Non-Negative and Sparse PCA. https://CRAN.R-project.org/package=nsprcomp.