Introduction to WOVEN: Multi-Omics Integration for Incomplete Patient Data

Overview

Multi-omics studies increasingly combine several assay platforms on the same patients – for example RNA-seq, DNA methylation, and proteomics run on the same cohort. Each platform produces an assay block: the complete set of features measured by that platform for a given subject (all genes for RNA-seq, all CpG sites for methylation, and so on). In practice, not every subject has every block. A blood draw can fail, an imaging visit can be missed, a sample can be too degraded to run on a given platform, and the result is that a subject is missing an entire block at once, not a handful of scattered values within it. This is different from ordinary feature-level missingness (a few dropped genes here and there), which most tools already handle; block-level missingness removes a whole modality for that subject in one go.

Methods built for fully-paired data, such as mixOmics::DIABLO (Singh et al. 2019), require every subject to have every block: a patient missing even one platform is dropped before the analysis begins. This has a real cost. If the patients who end up missing a block are not a random subset (for example, sicker patients who could not complete a full imaging workup), then the subjects who survive this filtering are a biased sample of the cohort, not a representative one, and any downstream comparison inherits that bias.

WOVEN (Weighted Omics View Embedding via Nystrom) is a supervised multi-omics integration method that avoids this intersection requirement. It fits a shared latent space using only the anchor subjects – those observed in every block – and then places every other subject into that same space using whatever blocks they do have, via a linear form of the Nystrom method. The Nystrom method was originally developed to solve integral equations numerically, by approximating them using a small representative set of points. In spectral and kernel methods it has since been repurposed as an out-of-sample extension: a way to place a new point into an already-fitted low-dimensional embedding without recomputing the whole eigendecomposition, by reusing the mapping learned from a reference subset of points (Bengio et al. 2003). WOVEN’s anchor subjects play that reference-subset role – the projection matrices learned from them are applied directly to whatever features a block-missing subject has, with no feature-level imputation of the missing block.

Installation

if (!requireNamespace("BiocManager", quietly = TRUE))
    install.packages("BiocManager")
BiocManager::install("woven")

Preparing Your Data

Input format

woven() takes a named list of matrices — one per omics platform — where:

  • Rows are subjects (same order across all matrices)
  • Columns are features (genes, CpGs, proteins, metabolites, etc.)
  • Subjects missing an entire modality block have that row set to NA
# Correct format: row = subject, col = feature
# Subjects missing a platform have that entire row set to NA
X_list <- list(
    RNA   = rna_matrix,    # n x p_rna,  missing rows = all NA
    Methyl = methyl_matrix, # n x p_meth, missing rows = all NA
    Prot  = prot_matrix    # n x p_prot, missing rows = all NA
)

Pre-processing recommendations

WOVEN expects pre-processed, approximately continuous data. Apply standard platform-specific normalization before passing data to woven():

Platform Recommended pre-processing
RNA-seq (bulk) log1p(CPM) then scale() per gene
DNA methylation M-values (log2(beta/(1-beta))), then scale()
Proteomics (LC-MS) log2(intensity), median-center per sample
Metabolomics log2 transform, then scale()
Microbiome (16S) CLR transform (e.g. compositions::clr())

Raw counts, raw beta values, or raw intensities will produce poorly scaled projection matrices. When in doubt, scale() each modality matrix so all features have mean 0 and SD 1.

# Example: log1p + scale for RNA-seq
X_rna_scaled <- scale(log1p(raw_counts))

# Then set block-missing rows to NA (do NOT impute)
X_rna_scaled[subjects_missing_rna, ] <- NA

Quick Start

Simulate a three-modality dataset

library(woven)
set.seed(42)

n       <- 150   # total subjects
K       <- 3     # latent dimensions to learn
n_groups <- 3

# True group labels (e.g. CN, MCI, Dementia)
Y <- factor(rep(c("CN", "MCI", "Dementia"), each = n / n_groups),
            levels = c("CN", "MCI", "Dementia"))

# Simulate three pre-processed modality matrices with group signal
make_modality <- function(p, Y, signal = 3) {
    X <- matrix(rnorm(length(Y) * p), length(Y), p)
    for (g in levels(Y))
        X[Y == g, seq_len(8)] <- X[Y == g, seq_len(8)] + signal
    colnames(X) <- paste0("Feature_", seq_len(p))
    X
}

X_rna   <- make_modality(300, Y, signal = 3)
X_meth  <- make_modality(120, Y, signal = 4)
X_prot  <- make_modality(40,  Y, signal = 5)

Induce block-level missingness

set.seed(7)
V        <- 3
miss_mask <- matrix(runif(n * V) < 0.35, n, V)

# Every subject must retain at least one modality
for (i in which(rowSums(miss_mask) == V))
    miss_mask[i, sample(V, 1)] <- FALSE

# Apply missingness: entire row -> NA for missing modalities
X_rna[ miss_mask[, 1], ] <- NA
X_meth[miss_mask[, 2], ] <- NA
X_prot[miss_mask[, 3], ] <- NA

n_anchors <- sum(rowSums(miss_mask) == 0)
cat(sprintf(
    "Anchors (all 3 views): %d / %d (%.0f%%)\n",
    n_anchors, n, 100 * n_anchors / n
))
#> Anchors (all 3 views): 47 / 150 (31%)

Fit WOVEN

Pass a named list so modality labels appear in all plots automatically. anchor_idx is optional – WOVEN detects anchors from the NA pattern.

fit <- woven(
    X_list  = list(RNA = X_rna, Methylation = X_meth, Proteomics = X_prot),
    Y       = Y,
    K       = K,
    lambdas = 0.01,   # graph Laplacian regularization (robust to this)
    gamma_y = 5.0,    # label supervision strength (any value > 0 works)
    k_nn    = 10L     # k-NN graph for Laplacian
)

fit   # prints summary + guided next steps
#> WOVEN fit
#>   Modalities : 3    Subjects: 150    Dimensions: 3
#>   Anchors    : 47 (31%)    Scored: 150 (100%)
#>   Solver     : mcca_dual (closed-form, globally optimal)
#>   gamma_y    : 5.00    lambda: 0.01/0.01/0.01    k_nn: 10
#>   Singular values: 5.719, 4.845, 1.999
#>   Classes    : CN, MCI, Dementia
#>   Modalities : RNA, Methylation, Proteomics
#> 
#>   -- Next steps --
#>   plot(fit, labels = Y)                         # latent space scatter
#>   woven_plot_vip(fit, modality = "RNA")        # top features by VIP
#>   woven_plot_loadings(fit, dim = 1)             # loadings per modality
#>   woven_plot_variance(fit)                      # variance per dimension
#>   woven_metrics(fit, Y)                         # silhouette, NMI, ESS
#>   woven_predict(fit, X_list_new)                # predict on new data

Exploring Results

Latent space scatter

plot() shows all scored subjects colored by group. Solid points are anchors; faded points are block-missing subjects projected from their available views. Ellipses show the 68% confidence region per group (anchors only).

plot(fit, labels = Y)
WOVEN latent space. All subjects are scored regardless of missingness.

WOVEN latent space. All subjects are scored regardless of missingness.

VIP scores: which features matter most?

Variable Importance in Projection (VIP) scores rank features by their contribution to the shared latent space across all K dimensions. VIP > 1 indicates above-average importance.

woven_plot_vip(fit, modality = "RNA", n_top = 15)
Top RNA features by VIP score.

Top RNA features by VIP score.

Plot VIP for a different modality:

woven_plot_vip(fit, modality = "Methylation", n_top = 15)
Top methylation features by VIP score.

Top methylation features by VIP score.

Feature loadings: direction of effect

Loadings show which features push subjects in the positive vs. negative direction along a given latent dimension. Equivalent to plotLoadings() in DIABLO.

woven_plot_loadings(fit, dim = 1L, n_top = 10)
Top feature loadings for latent dimension 1 across all three modalities.

Top feature loadings for latent dimension 1 across all three modalities.

Variance explained: choosing K

Use this to verify that your chosen K captures most of the shared signal. If the last dimension still explains substantial variance, increase K.

woven_plot_variance(fit)
Proportion of shared variance explained per latent dimension.

Proportion of shared variance explained per latent dimension.

Quantitative metrics

woven_metrics() computes silhouette score, Davies-Bouldin index, NMI, and effective sample size (ESS) retention directly from the fit object.

woven_metrics(fit, Y)
#>     Silhouette Davies-Bouldin            NMI            ESS 
#>     0.02735447     4.49789867     0.08722509     1.00000000

ESS = 1.00 means every subject with at least one observed modality was scored. Compare this to DIABLO, which can only score subjects present in all modalities.

Predicting New Subjects

# Simulate 20 new subjects with partial missingness
set.seed(99)
X_new <- list(
    RNA         = make_modality(300, Y[1:20], signal = 3),
    Methylation = make_modality(120, Y[1:20], signal = 4),
    Proteomics  = make_modality(40,  Y[1:20], signal = 5)
)
# Some new subjects are missing a modality
X_new$RNA[1:5, ]        <- NA
X_new$Proteomics[6:10, ] <- NA

pred <- woven_predict(fit, X_new)
head(pred[, 1:3])
#>   predicted_class confidence      p_CN
#> 1             MCI  0.7543819 0.1195400
#> 2             MCI  0.5249843 0.1749973
#> 3              CN  0.7015899 0.7015899
#> 4        Dementia  0.4288839 0.2685680
#> 5        Dementia  0.6187182 0.2533136
#> 6        Dementia  0.3840129 0.2787230

predicted_class returns original label names (“CN”, “MCI”, “Dementia”), not integer codes.

Comparing WOVEN and DIABLO: Effective Sample Size

The key advantage of WOVEN over mixOmics::DIABLO is effective sample size (ESS). DIABLO requires every subject to have every modality; WOVEN scores all subjects with at least one observed view.

n_anchors <- length(anchorIdx(fit))    # what DIABLO would use
n_woven   <- sum(!is.na(Z(fit)[, 1]))  # what WOVEN scores

cat(sprintf("DIABLO ESS: %d / %d (%.0f%%)\n",
            n_anchors, n, round(100 * n_anchors / n)))
#> DIABLO ESS: 47 / 150 (31%)
cat(sprintf("WOVEN  ESS: %d / %d (%.0f%%)\n",
            n_woven,   n, round(100 * n_woven   / n)))
#> WOVEN  ESS: 150 / 150 (100%)

In real cohorts, the subjects DIABLO discards are often the sickest patients (those who miss a blood draw or imaging scan due to disease severity), introducing systematic bias in comparative effectiveness estimates.

Accessing Raw Results

The fit is an S4 object (class ). Accessor functions expose the internal quantities most useful for custom downstream analyses; see for the complete slot list if you need something an accessor does not cover.

dim(Z(fit))          # n x K consensus latent scores for ALL subjects
#> [1] 150   3
names(WList(fit))    # one projection matrix per modality
#> [1] "RNA"         "Methylation" "Proteomics"
dim(WList(fit)$RNA)  # p_rna x K
#> [1] 300   3

# Row names on Z come from row names of your input matrices
# (set rownames on X_list matrices to get named rows in Z(fit))

Z(fit) can be passed directly to survival analysis, clustering, or any downstream model as a low-dimensional representation of the full cohort.

Session Info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] woven_0.99.3   rmarkdown_2.32
#> 
#> loaded via a namespace (and not attached):
#>  [1] Matrix_1.7-6          gtable_0.3.6          jsonlite_2.0.0       
#>  [4] dplyr_1.2.1           compiler_4.6.1        tidyselect_1.2.1     
#>  [7] parallel_4.6.1        cluster_2.1.8.3       jquerylib_0.1.4      
#> [10] scales_1.4.0          BiocParallel_1.47.0   yaml_2.3.12          
#> [13] fastmap_1.2.0         lattice_0.23-1        ggplot2_4.0.3        
#> [16] R6_2.6.1              labeling_0.4.3        generics_0.1.4       
#> [19] knitr_1.52            tibble_3.3.1          MatrixGenerics_1.25.0
#> [22] maketools_1.3.2       pillar_1.11.1         bslib_0.12.0         
#> [25] RColorBrewer_1.1-3    rlang_1.3.0           cachem_1.1.0         
#> [28] RANN_2.6.3            xfun_0.61             sass_0.4.10          
#> [31] sys_3.4.3             S7_0.2.2              otel_0.2.0           
#> [34] cli_3.6.6             withr_3.0.3           magrittr_2.0.5       
#> [37] digest_0.6.39         grid_4.6.1            lifecycle_1.0.5      
#> [40] vctrs_0.7.3           evaluate_1.0.5        glue_1.8.1           
#> [43] farver_2.1.2          codetools_0.2-20      buildtools_1.0.0     
#> [46] pkgconfig_2.0.3       matrixStats_1.5.0     tools_4.6.1          
#> [49] htmltools_0.5.9