This is a fork from PMBio's MuDataSeurat
Please refer to the original repo for more details
I find MuDataSeurat to be the most compatible tool for converting SeuratObject to H5AD/H5MU format. It does not require python runtime as well.
The original repository activity seems quite low, and unfortunately, the bugs have not been fixed promptly. Since I use MuDataSeruat quite often, I've decided to create my own fork and make my own version. I will do my best to ensure that it remains compatible with the latest pull requests from the original repository.
-
Compatible with Seurat v5
-
Tested and works with
anndata(>=0.8), andanndata-rs(>=0.2.0). -
Export all missing reductions, such as
UMAP,tSNEetc. -
Fixed stack overflow issue because of obs column containing NAs
- skip columns with all NA value
- fixed string array with NA
-
Add one new keyword arguments to
WriteH5ADandWriteH5MU:sparse.type: storecsc_matrixorcsr_matrixinanndata/mudatacompression: compression type, "gzip", or "none"
-
Correctness fixes
.h5mufiles are now readable bymudata:obsm,varm,obsp,varp,obsmapandvarmapare always written.- Categorical (factor) columns keep their labels when a category is unused.
NAin string columns is stored as a missing value (ascategorical, since anndata has no nullable-string encoding) instead of the text"NaN".WriteH5ADnow errors on an unknownassayinstead of silently writing a different one.- Reductions whose names contain non-alphanumeric characters (
umap_harmony,pca_uncorrected,mrVI_umap, ...) are no longer dropped fromobsm. Seurat strips those characters when it derives a reduction key (umap_harmony->umapharmony_), which previously made such reductions fail the assay-matching check and be skipped silently. A reduction that is still not matched now raises a warning instead of disappearing. scale.datais no longer exported, and thescale.dataargument is deprecated (still accepted, ignored, and warned about only whenTRUE).ScaleData()runs on the variable features only while AnnData requiresXto span every feature, so exporting it meant padding the dense scaled matrix out withNaNton_features / n_variable_featurestimes its size — commonly 10-15x, and the largest allocation a write ever made. The resultingXwas mostlyNaN, which is not something a reader can compute on. Recompute scaling after reading instead.
-
Much faster on large objects. On a 500,000 cell object (2,000 features, 150M nonzeros, 28 metadata columns):
before after ReadH5AD54 s 9 s WriteH5AD23 s 23 s ( compression = "gzip", the default)WriteH5AD23 s 3 s ( compression = "none")- Reading a
csr_matrixno longer converts it to CSC and then transposes it back: AnnData's CSR buffers already describe the matrix in the orientation Seurat wants, so it is built from them directly. nCount_*/nFeature_*are no longer recomputed from the matrix when the storedobsalready contains them (they were recomputed and then immediately overwritten). They are still computed when the file omits them.obsandvarare read once per file instead of once per consumer (ReadH5ADused to read/obsfour times).- New
compressionargument onWriteH5AD/WriteH5MU:"gzip"(default, unchanged behaviour),"none", or a gzip level from 0 to 9. Compression, not disk I/O, dominates write time;"none"is several times faster in exchange for a roughly 3x larger file.
- Reading a
-
Disk-backed (BPCells) matrices, both directions.
ReadH5AD/ReadH5MUcan leave the count matrices on disk instead of loading them (backend = "bpcells"), andWriteH5AD/WriteH5MUstream such an object back out a block of cells at a time. Neither direction ever holds the whole matrix in memory.ConvertToSeuratBPCells()andConvertToSeuratInMemory()move an object you already have between the two. See Working with BPCells (on-disk matrices). -
Reading now always produces a Seurat v5 assay (
Assay5), and several metadata bugs are fixed along with it:- Feature metadata on a v5 assay used to be dropped entirely on write. Since
CreateSeuratObjecthas returned v5 assays by default since Seurat 5, this meant/varwas written empty for almost everyone. /varcolumns were duplicated on read (attached once when the assay was built, thencbinded on again).- Writing an integer column containing
NAs recursed until the stack overflowed.FindVariableFeaturesproduces such a column (vf_vst_counts_rank), so this was easy to hit. - Logical columns are stored as a real two-value boolean. hdf5r's default
three-value enum (
FALSE/TRUE/NA) is read asuint8byh5py, which madehighly_variablecome out as an integer and made columns withNAs unreadable byanndataoutright.
- Feature metadata on a v5 assay used to be dropped entirely on write. Since
Please install the main branch
remotes::install_github("zqfang/MuDataSeurat")MuDataSeurat exports 2 layers: counts and data. When both are present,
counts becomes layers['counts'] and data becomes X; when only one is
present it becomes X.
scale.data is not exported. ScaleData() runs on the variable features
only, while AnnData requires X to span every feature, so writing it meant
padding the dense scaled matrix out to the full var axis with NaN — many times
the size of the real data, and mostly NaN once written. Recompute it after
reading instead (ScaleData() in Seurat, sc.pp.scale in scanpy).
You need JoinLayers for each modality first with seurat v5.
library(MuDataSeurat)
DefaultAssay(seu) = "RNA"
seu = JoinLayers(seu) # critical for seurat v5
## write unimodal h5ad
WriteH5AD(seu, "export.h5ad", assay="RNA", overwrite=TRUE)
## write multimodal h5mu
WriteH5MU(seu, "export.h5mu", overwrite=TRUE)seu <- ReadH5AD("export.h5ad")
seu <- ReadH5MU("export.h5mu")Both return a Seurat v5 object: matrices become v5 layers (counts, data,
scale.data), and /var becomes the assay's feature metadata, reachable with
seu[["RNA"]][[]]. For files too large to fit in memory, see
Working with BPCells.
BPCells keeps count matrices on disk and streams them, so an object larger than memory can still be worked with. Both directions are supported: reading can leave the matrices on disk, and writing can stream them back out.
BPCells is an optional dependency; install it separately:
remotes::install_github("bnprks/BPCells/r")Pass backend = "bpcells" to keep X, layers and raw on disk:
# matrices stay in the .h5ad; nothing is copied
seu <- ReadH5AD("big.h5ad", backend = "bpcells")
# or convert them once into BPCells' own format
seu <- ReadH5AD("big.h5ad", backend = "bpcells", bpcells.dir = "big_bpcells")
seu <- ReadH5MU("big.h5mu", backend = "bpcells", bpcells.dir = "big_bpcells")The two forms trade off differently:
bpcells.dir |
cost to open | afterwards |
|---|---|---|
NULL (default) |
nothing is copied | every pass re-reads the HDF5; the object breaks if the source file moves or is deleted |
| a directory | one pass over the data, plus disk | much faster to compute on, and the object survivessaveRDS and the source file going away |
Reading a 120,000 cell file (2,000 features, 391 MB uncompressed .h5ad):
| backend | peak memory | time |
|---|---|---|
"memory" (default) |
858 MB | 2.3 s |
"bpcells", no dir |
145 MB | 2.2 s |
"bpcells" + dir |
149 MB | 1.9 s |
Only the count matrices are affected. obsm, varm and obsp — reductions and
graphs — are always read into memory, since Seurat needs real matrices for them
and they are far smaller.
With .h5mu, each modality converts into its own subdirectory
(big_bpcells/RNA/X, big_bpcells/ADT/X, ...) carrying that modality's own
feature names.
WriteH5AD/WriteH5MU accept such objects directly and stream them out a block
of cells at a time, rather than pulling the whole matrix into memory first. This
works whether the object came from ReadH5AD(..., backend = "bpcells") or was
built with BPCells directly:
library(BPCells)
library(MuDataSeurat)
# One-time conversion of the counts to BPCells' on-disk format
mat <- open_matrix_anndata_hdf5("big.h5ad") # or open_matrix_10x_hdf5(), etc.
mat <- convert_matrix_type(mat, "uint32_t") # raw counts only -- see below
mat <- write_matrix_dir(mat, dir = "big_bpcells")
seu <- CreateSeuratObject(counts = open_matrix_dir("big_bpcells"))
seu <- NormalizeData(seu)
WriteH5AD(seu, "export.h5ad")
WriteH5MU(seu, "export.h5mu")convert_matrix_type(mat, "uint32_t") matters more than it looks: BPCells'
bitpacking compresses integers far better than floats, and skipping it can leave
the on-disk directory several times larger than it needs to be (5.4x on a small
test matrix). Use it only for raw counts — converting a normalized matrix would
truncate it to integers. Note that this package always writes matrix values as
float64, so counts read back out of an .h5ad arrive as doubles even when they
are whole numbers; BPCells will warn about poor compression if you skip the
conversion.
Normalized layers work too. NormalizeData on a disk-backed assay leaves an
unevaluated operation tree rather than a matrix, and the transform is applied as
each block is pulled, so nothing is materialised in full.
On a 120,000 cell object (2,000 features, 63.6M nonzeros), writing the same data from a BPCells-backed assay versus an in-memory one:
| peak memory | time | |
|---|---|---|
| BPCells (streamed) | 213 MB | 3.0 s |
in-memorydgCMatrix |
946 MB | 1.7 s |
The output files are byte-for-byte identical apart from the indptr integer
width. Streaming trades some speed for a bounded memory ceiling.
Two constraints to be aware of:
sparse.typemust be"csr_matrix"(the default). Acsc_matrixis grouped by feature, which is the opposite of the order a disk-backed matrix streams in; producing one would mean rewriting the whole matrix withBPCells::transpose_storage_order()first, so it is refused rather than done silently.- You cannot write over the file a matrix reads from. A BPCells matrix keeps
reading from its source for as long as the object is alive, so
WriteH5AD(seu, "big.h5ad")whereseu's counts are backed bybig.h5adis refused — it would destroy the data being read. Write elsewhere.
The two can be chained, so a large file can be re-encoded without ever holding its matrix in memory:
seu <- ReadH5AD("big.h5ad", backend = "bpcells")
WriteH5MU(seu, "big.h5mu") # streamed straight back outTwo helpers move an existing Seurat object between memory and disk, leaving everything else — feature metadata, variable features, reductions, graphs — untouched:
# in-memory -> disk-backed
seu <- ConvertToSeuratBPCells(seu, dir = "seu_bpcells")
# disk-backed -> in-memory
seu <- ConvertToSeuratInMemory(seu)ConvertToSeuratBPCells writes each layer to <dir>/<assay>/<layer>, so the
object becomes self-contained and survives saveRDS. It also works on an object
read with backend = "bpcells" and no bpcells.dir, which is how you detach
such an object from the .h5ad it is still reading from.
Two defaults worth knowing:
-
scale.datastays in memory. It is dense, usually covers only the variable features, and gains little from being moved. Passlayers = "scale.data"to convert it anyway. -
No type casting happens unless you ask. Casting raw counts to
"uint32_t"compresses much better, but the same cast would truncate a normalized layer, so name the layers it applies to:seu <- ConvertToSeuratBPCells(seu, dir = "seu_bpcells", type = c(counts = "uint32_t"))
Passing a bare
type = "uint32_t"applies to every layer; if any in-memory layer does not hold non-negative integers, the call is refused rather than silently corrupting it.
ConvertToSeuratInMemory materializes the matrices, so check dim(seu) first —
that is the point of the call, but it does mean the object has to fit.