MOFA+ Multi-Omics Factor Analysis
Overview
MOFA+ (Multi-Omics Factor Analysis v2) is an unsupervised statistical framework that jointly decomposes multiple omics datasets into a small set of latent factors. Each factor captures an independent source of variation (e.g., cell cycle, a disease phenotype, a technical batch) and is associated with feature weights (loadings) that reveal which genes, peaks, or proteins drive it. The Python package mofapy2 produces an HDF5 model file compatible with downstream analysis in both Python and R. MOFA+ extends the original MOFA to support multi-group settings where samples belong to distinct cohorts or conditions.
When to Use
- Integrating two or more omics layers from the same set of cells or samples (e.g., scRNA-seq + scATAC-seq, RNA + proteomics, methylation + RNA)
- Identifying shared and view-specific sources of variation across omics modalities without supervised labels
- Comparing how latent factors differ between patient groups, treatment conditions, or time points in a multi-group analysis
- Reducing multi-omics dimensionality before clustering, trajectory inference, or survival modeling
- Discovering which genomic features (genes, peaks, proteins) drive each factor via sparse loadings
- Annotating latent factors by correlating factor scores with sample metadata (age, stage, treatment response)
- Use scVI / MultiVI (scverse) instead when you need deep generative batch correction across modalities with explicit latent space inference and VAE architecture
- Use LIGER instead when your primary goal is integrating datasets across technologies (e.g., snRNA-seq + snATAC-seq) with shared and dataset-specific factors via iNMF
Prerequisites
- Python packages:
mofapy2>=0.7,anndata>=0.10,numpy>=1.24,pandas>=2.0,scipy>=1.11,matplotlib>=3.7,seaborn>=0.13,muon(optional, for MuData integration) - Data requirements: One AnnData object per omics view (cells/samples x features). All views must share the same obs (cell/sample) index. Missing samples per group are supported.
- Environment: Python 3.9+; trained model is saved as HDF5 and can be analyzed in R via the
MOFA2Bioconductor package
pip install mofapy2 anndata muon matplotlib seaborn
Quick Start
import numpy as np
import pandas as pd
import anndata as ad
from mofapy2.run.entry_point import entry_point
# Simulate two omics views, 200 cells, 500 RNA genes, 300 ATAC peaks
np.random.seed(42)
n_cells, n_rna, n_atac = 200, 500, 300
adata_rna = ad.AnnData(np.abs(np.random.randn(n_cells, n_rna)),
obs=pd.DataFrame(index=[f"cell_{i}" for i in range(n_cells)]))
adata_atac = ad.AnnData(np.abs(np.random.randn(n_cells, n_atac)),
obs=adata_rna.obs.copy())
ent = entry_point()
ent.set_data_options(scale_groups=False, scale_views=True)
ent.set_data_matrix([[adata_rna.X, adata_atac.X]],
likelihoods=["gaussian", "gaussian"],
views_names=["RNA", "ATAC"],
groups_names=["all_cells"],
samples_names=[list(adata_rna.obs_names)])
ent.set_model_options(factors=10)
ent.set_train_options(iter=500, convergence_mode="fast", seed=42)
ent.build()
ent.run()
ent.save("mofa_model.hdf5")
print("Model saved to mofa_model.hdf5")
Workflow
Step 1: Load and Prepare Multi-Omics Data
Each omics layer is represented as an AnnData object. Align cell indices across modalities, log-normalize RNA counts, and binarize or normalize ATAC/methylation data as appropriate.
import numpy as np
import pandas as pd
import anndata as ad
import scipy.sparse as sp
# --- RNA-seq: 200 cells x 2000 highly variable genes ---
np.random.seed(42)
n_cells = 200
cell_ids = [f"cell_{i:03d}" for i in range(n_cells)]
# Simulate log-normalized counts (in practice: load from h5ad after Scanpy preprocessing)
rna_counts = np.abs(np.random.randn(n_cells, 2000) * 2)
adata_rna = ad.AnnData(
X=rna_counts,
obs=pd.DataFrame(
{"condition": ["A"] * 100 + ["B"] * 100,
"patient": [f"P{i % 10}" for i in range(n_cells)]},
index=cell_ids
),
var=pd.DataFrame(index=[f"Gene_{i}" for i in range(2000)])
)
# --- ATAC-seq: same cells x 1000 peaks ---
atac_matrix = (np.random.rand(n_cells, 1000) > 0.8).astype(float)
adata_atac = ad.AnnData(
X=atac_matrix,
obs=adata_rna.obs.copy(),
var=pd.DataFrame(index=[f"Peak_{i}" for i in range(1000)])
)
# Confirm alignment
assert list(adata_rna.obs_names) == list(adata_atac.obs_names), "Cell indices must match"
print(f"RNA: {adata_rna.shape}, ATAC: {adata_atac.shape}")
print(f"Conditions: {adata_rna.obs['condition'].value_counts().to_dict()}")
Step 2: Create the MOFA+ Model Object
Instantiate the entry_point and register all data views. Views are provided as a list-of-lists: data[groups][views]. Assign meaningful view and group names for interpretability.
from mofapy2.run.entry_point import entry_point
ent = entry_point()
# Configure data options before setting data
ent.set_data_options(
scale_groups=False, # Do not rescale variance between groups
scale_views=True, # Rescale each view to unit variance (recommended when views differ in scale)
)
# Provide data as list-of-lists: [groups][views]
# Single group → wrap each view in a list
ent.set_data_matrix(
data=[[adata_rna.X, adata_atac.X]], # outer list = groups, inner = views
likelihoods=["gaussian", "bernoulli"], # gaussian for continuous, bernoulli for binary ATAC
views_names=["RNA", "ATAC"],
groups_names=["all_cells"],
samples_names=[list(adata_rna.obs_names)] # one list per group
)
print("Data registered. Views: RNA, ATAC | Groups: all_cells")
Step 3: Set Model and Training Options
Configure the number of factors and training hyperparameters. More factors capture finer variation but increase computation and risk overfitting; convergence_mode="medium" balances speed and accuracy.
# Model options
ent.set_model_options(
factors=15, # Number of latent factors (start with 15; prune inactive ones automatically)
spikeslab_weights=True, # Sparse weight prior (ARD+spike-slab); recommended for feature selection
ard_factors=True, # Automatic relevance determination per factor per view
ard_weights=True, # ARD per feature weight; enables pruning of irrelevant features
)
# Training options
ent.set_train_options(
iter=1000, # Maximum EM iterations
convergence_mode="medium", # "fast" (<1000 iter), "medium" (default), "slow" (>5000 iter)
startELBO=1, # Start computing ELBO from iteration 1
freqELBO=5, # Compute ELBO every 5 iterations
dropR2=0.01, # Drop factors explaining < 1% variance (set to None to disable)
seed=42,
verbose=False
)
print("Model and training options set")
Step 4: Build and Train the Model
Build the internal data structures, then run variational inference. Training produces a fitted model where each factor's weights and scores are optimized to maximize the evidence lower bound (ELBO).
# Build internal model structure
ent.build()
# Run training (EM algorithm with variational Bayes updates)
ent.run()
# Save trained model to HDF5 — required for downstream analysis
output_path = "mofa_model.hdf5"
ent.save(output_path, overwrite=True)
print(f"Model trained and saved to {output_path}")
Step 5: Load Trained Model and Inspect Variance Explained
Load the HDF5 model and inspect how much variance each factor explains per view. Factors explaining less than ~1-2% total variance are typically noise.
import h5py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
def load_mofa_r2(model_path):
"""Extract variance explained (R2) per factor per view from MOFA+ H