---
title: "Apache Parquet-based Mass Spectrometry Data Backend"
output:
BiocStyle::html_document:
toc_float: true
vignette: >
%\VignetteIndexEntry{Apache Parquet-based Mass Spectrometry Data Backend}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
%\VignettePackage{MsBackendParquet}
---
```{r style, echo = FALSE, results = "asis", message = FALSE}
BiocStyle::markdown()
```
**Package**: `r Biocpkg("MsBackendParquet")`
**Authors**: `r packageDescription("MsBackendParquet")[["Author"]] `
**Compiled**: `r date()`
```{r setup, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(BiocStyle)
```
# Introduction
The `r Biocpkg("MsBackendParquet")` package provides a backend for
`r Biocpkg("Spectra")` objects that stores mass spectrometry (MS) data
in an on-disk dataset built on
[Apache Parquet](https://parquet.apache.org/).
Datasets are *written* with the
[Apache Arrow](https://arrow.apache.org/docs/r) R bindings and *queried*
with [DuckDB](https://duckdb.org/), keeping the spectra metadata
together with the `mz`/`intensity` peak values in a columnar format that
supports compression, dictionary encoding and Hive-style partitioning.
A dataset is a directory identified by an `mzStack.json`
manifest, holding one or more *runs*. A run is one source of spectra
and owns a contiguous block of the dataset's spectrum ids.
A run is either
`native` (converted from a raw MS data file, with peaks stored alongside
the metadata) or `mzpeak`, an index over external
[HUPO-PSI mzPeak](https://github.com/HUPO-PSI/mzPeak) archives that are
read where they are and never modified (see
*Querying collections of mzPeak archives* below). The manifest records
which kind each run is, so the two never have to be told apart by
inspecting the directory.
To keep access fast, the (small) non-peak spectra variables are held in
a bounded in-memory index while the peak data is read from disk on
demand. As a result, metadata filters cost about as much as with an
in-memory backend, while the bulk peak storage stays out of core.
The backend extends `MsBackendCached` from `r Biocpkg("Spectra")` so it
benefits from caching of user-defined spectra variables while keeping
the on-disk dataset read-only.
## Relation to other Spectra backends
`MsBackendMzR` from `r Biocpkg("Spectra")` keeps the spectra metadata in
memory and reads peaks from the raw mzML files on demand: nothing has to be
converted, but every peak access parses the files again.
`r Biocpkg("MsBackendSql")` stores spectra in an SQL database, which suits
data shared from a database server. `r Biocpkg("SpectraStash")` saves and
restores whole `Spectra` objects in interoperable formats such as HDF5. It
is for exchanging an object, not for querying it on disk.
`MsBackendParquet` converts once, like `MsBackendSql`, and then queries a
database engine, DuckDB. But its storage is plain Parquet files that need
no server and that other tools, such as Python, DuckDB or Spark, read
directly. It can also index mzPeak archives where they are, without
converting them.
# Installation
The package is installed from Bioconductor:
```{r install, eval = FALSE}
if (!requireNamespace("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install("MsBackendParquet")
```
Reading raw mzML files needs the `r Biocpkg("mzR")` package, which
`BiocManager::install("MsBackendParquet", dependencies = TRUE)` installs
too. Datasets made from mzPeak archives or from in-memory data do not
need it.
# Creating a Parquet dataset
The examples below use a DDA data file of a pesticide mix, provided by
the `r Biocpkg("MsDataHub")` package. The file is downloaded once and
cached locally.
```{r data-file, message = FALSE}
library(Spectra)
library(MsBackendParquet)
library(MsDataHub)
fl <- MsDataHub::PestMix1_DDA.mzML()
```
`MsDataHub` returns the file under its cache name, and the id of a run is
taken from the name of the file it was imported from. Copying the file to
a named path first therefore gives the dataset readable run ids:
```{r name-file}
fl_named <- file.path(tempdir(), "PestMix1_DDA.mzML")
invisible(file.copy(fl, fl_named, overwrite = TRUE))
```
`mzMLToParquet()` converts raw MS data files into a Parquet dataset. It
validates the input files, writes the dataset and returns an initialised
backend, ready to be wrapped in a `Spectra` object:
```{r mzml-to-parquet}
path <- tempfile()
be <- mzMLToParquet(fl_named, path = path, verbose = FALSE)
sps <- Spectra(be)
sps
```
Each input file becomes its own run, written to
`/spectra/run_id=/`, so a filter that selects whole files
prunes at the directory level:
```{r run-dirs}
list.files(file.path(path, "spectra"))
```
`mzMLToParquet()` imports MS data via `MsBackendMzR()` and writes the
spectra metadata together with the `mz`/`intensity` values as Parquet
`list` columns.
A `Spectra` object that is already loaded, with any backend, can instead be
moved onto Parquet with `setBackend()`. The destination is given as `path`:
```{r set-backend}
sps_mem <- Spectra(fl_named, source = MsBackendMzR())
sps_pq <- setBackend(sps_mem, MsBackendParquet(), path = tempfile())
sps_pq
```
# Accessing data
`spectraData()`, `peaksData()`, `mz()`, `intensity()` and related
methods work the same way as for any other `MsBackend`. The spectra
variables of the dataset are:
```{r spectra-variables}
spectraVariables(sps)
```
Values are returned as usual, with the peak data read from disk on
demand:
```{r access}
spectraData(sps, c("msLevel", "rtime", "precursorMz"))
head(peaksData(sps)[[1L]])
head(tic(sps))
```
# Filtering
Filter methods are translated into DuckDB SQL predicates that are pushed
down to the Parquet scan. DuckDB prunes whole row groups using their
min/max statistics, and skips whole partitions for Hive-partitioned
datasets, so a range or set filter reads only the data it needs. When
the filter column is held in the in-memory metadata index (see *The
in-memory metadata cache* below), the predicate is evaluated in memory
instead.
Selecting spectra by MS level and by retention time, which is given in
seconds:
```{r filter-basic}
filterMsLevel(sps, 2L)
filterRt(sps, c(200, 300))
```
Filters return a `Spectra` object and can be chained. Each call narrows
the predicate rather than reading data, so the combination is answered
by a single scan:
```{r filter-chained}
sps |>
filterRt(c(200, 300)) |>
filterMsLevel(2L) |>
length()
```
MS2 spectra can be selected by precursor m/z, either over a range or
against a set of values with a mass tolerance in ppm:
```{r filter-precursor}
filterPrecursorMzRange(sps, c(278, 279))
ms2 <- filterPrecursorMzValues(sps, 278.093, ppm = 30)
ms2
range(rtime(ms2))
```
`filterPrecursorMzValues()` accepts several values at once, which become
one OR-ed predicate and therefore one scan rather than one scan per
value:
```{r filter-precursor-values}
filterPrecursorMzValues(sps, c(278.093, 124.086), tolerance = 0.01)
```
None of the filters above read peak data. They are answered from the
spectra variables, and the resulting predicate is carried into any later
peak access.
# Working with peaks
Peak data is read when it is asked for, and only for the spectra that
survived the filters. The nine MS2 spectra selected above share one
precursor, so their fragments can be compared directly:
```{r peaks}
lengths(ms2)
pk <- peaksData(ms2)[[which.max(lengths(ms2))]]
head(pk[order(-pk[, "intensity"]), ])
```
`containsMz()` from `r Biocpkg("Spectra")` reports which of those
spectra contain a peak at a given m/z:
```{r contains-mz}
which(containsMz(ms2, mz = 164.054, tolerance = 0.01))
```
Because the peaks are stored columnwise, DuckDB reads only the `mz` and
`intensity` columns, and only the rows belonging to the selected
spectra.
# Controlling the dataset layout
`createMsBackendParquetDataset()` writes a dataset without opening it
and exposes the layout options: the reader backend, chunk size,
compression codec, Parquet row-group size, and Hive partitioning.
```{r create-ms-backend}
path_part <- tempfile()
createMsBackendParquetDataset(path = path_part, x = fl,
partitioning = "msLevel",
compression = "zstd")
be_part <- backendInitialize(MsBackendParquet(), path = path_part)
length(be_part)
```
`partitioning` adds a further Hive level *below* the run.
The partition directories carry mzPeak column names (see *How the
dataset interfaces with mzPeak's model* below):
```{r partition-dirs}
list.files(list.files(file.path(path_part, "spectra"), full.names = TRUE))
```
The same function writes an existing `DataFrame` of spectra data when it
is passed as `data` instead of `x`.
# Querying collections of mzPeak archives
[mzPeak](https://github.com/HUPO-PSI/mzPeak) is the HUPO-PSI successor to
mzML. An mzPeak *archive* holds **one MS run** as a folder (or an
uncompressed ZIP) of Parquet files plus a JSON manifest naming them. That
structure is well suited to distributing and archiving individual runs,
but inefficient for queries spanning thousands of runs.
Answering such a query would require opening and parsing each archive and
its manifest.
`MsBackendParquet` bridges the two. `createMzPeakDataset()` registers a
set of archives as one dataset. The package ships two small archives for
this purpose:
```{r create-mzpeak-dataset}
archives <- system.file("extdata", c("QC01.mzpeak", "QC02.mzpeak"),
package = "MsBackendParquet")
qc_path <- tempfile()
createMzPeakDataset(archives[1L], path = qc_path, verbose = FALSE)
```
Further archives join the same dataset later. Only the index grows:
```{r add-mzpeak-archives}
addMzPeakArchives(qc_path, archives[2L], verbose = FALSE)
be_qc <- backendInitialize(MsBackendParquet(), path = qc_path)
sps_qc <- Spectra(be_qc)
sps_qc
table(basename(dataOrigin(sps_qc)))
```
With two runs in the dataset, `filterDataOrigin()` selects one of them:
```{r filter-data-origin}
filterDataOrigin(sps_qc, grep("QC02", dataOrigin(sps_qc), value = TRUE)[1L])
```
The archives are neither modified nor copied. Ingest reads each
archive's manifest, its spectrum metadata table and its scan, precursor
and selected-ion side tables, and writes a small derived index next to
them, around 3% of the size of the archives. It never reads a byte of
signal data, so the cost scales with the number of spectra rather than
the size of the data. Peaks are then read from the archives themselves,
on demand. Pass `link = "copy"` to place a copy of each archive inside
the dataset directory instead of referencing it in place.
Because the archives stay exactly as they were, they remain readable by
every other mzPeak tool, and exporting a run back out of the dataset is a
directory copy rather than a conversion.
## How the dataset interfaces with mzPeak's model
Several aspects of the data are translated or transformed on input, so it
is worth being precise about what changes.
- **Units and names.** mzPeak stores retention time as `time` in *minutes*;
`Spectra` expects `rtime` in *seconds*. The conversion, and every other
column rename and re-encoding (`scan_polarity` ±1, `spectrum_representation`
as a CURIE, the isolation window as a target plus offsets, and so on),
happens in a single SQL view. The same view serves a natively converted
dataset: its `spectra/` files are written with mzPeak's column names and
encodings too, so both kinds are directly comparable and either can be
exported back to an archive mechanically. `spectrum_index` is always the
dataset's unique 0-based key; a spectrum's position within its source file
(`scanIndex`, which restarts per file) is kept in `scan_index`. The package
adds only a handful of reserved columns (`spectrum_id_`, `acquisition_num_`,
the `n_*` counts).
- **Flattening.** mzPeak keeps scans, precursors and selected ions in
separate tables linked back to the spectrum by a foreign key. The index
takes the first of each, and records how many there were in `n_scans`,
`n_selected_ions` and `n_precursors`. The archive still holds the full
detail, but a spectrum with several scans is summarised in the index.
- **Profile and centroid.** mzPeak can hold both representations of the same
spectrum, in separate files. Choose with `representation`, which defaults
to `"auto"`:
```{r mzpeak-repr}
be_centroid <- backendInitialize(MsBackendParquet(), path = qc_path,
representation = "centroid")
be_centroid
```
- **Differing schemas.** Two perfectly valid archives often have different
columns, because writers are encouraged to promote whichever parameters
they have into columns. They still combine into one queryable dataset;
columns missing from an archive read as `NA`.
Archives that use mzPeak's *chunked* signal layout are rejected with a clear
message; only the *point* layout is supported so far.
## Searching for peaks across runs
Filters on spectra variables work as they do for any other dataset.
Finding spectra that *contain a peak* at a given m/z is different: it has
to look at the signal, and mzPeak stores signal ordered by spectrum,
which means a filter on m/z cannot skip anything, because every block of
rows spans the whole mass range.
`filterContainsMz()` runs that search inside the storage engine rather
than pulling peaks into R:
```{r filter-contains-mz}
hits <- filterContainsMz(be_qc, c(201, 10101), ppm = 20)
length(hits)
basename(dataOrigin(hits))
```
`filterContainsMz()` works on the backend, and the result is wrapped in a
`Spectra` object like any other backend:
```{r filter-contains-mz-spectra}
Spectra(hits)
```
For datasets where this is a common query, build an m/z-ordered copy of
the signal. Sorting is what makes the block statistics useful, so the
same search then reads a handful of blocks instead of everything:
```{r build-projection}
buildProjection(qc_path, verbose = FALSE)
length(filterContainsMz(be_qc, 201, ppm = 20))
```
On a 5-million-point test dataset this made `filterContainsMz()` around
eight times faster. A projection is a cache: it is a derived copy of the
signal and so costs roughly as much disk again, it can be dropped with
`dropProjection()`, and results are identical with or without it. Only
the performance changes. Build it per run, for the runs you query often.
```{r drop-projection}
dropProjection(qc_path, verbose = FALSE)
```
# Sample metadata
An experiment records factors about the *sample* a run came from, such as
subject, timepoint, treatment group or injection order, that are not in
the raw data. `runData()` stores them in the dataset, one row per run,
keyed by `run_id`.
```{r run-data}
runData(be_qc) <- data.frame(run_id = c("QC01", "QC02"),
subject = c("S1", "S2"),
timepoint = c(0L, 6L))
runVariables(be_qc)
```
The columns then behave like any other spectra variable:
```{r run-data-access}
spectraData(be_qc, c("rtime", "subject", "timepoint"))
```
and `filterSampleData()` selects on them:
```{r filter-sample-data}
Spectra(filterSampleData(be_qc, timepoint == 6))
```
The sample metadata is held once per run, not copied onto every spectrum.
Each run owns a
*contiguous block* of `spectrum_id_`, so `timepoint == 6` is exactly a
range of ids, and `filterSampleData()` resolves it against a table of a
few thousand rows rather than reading a column of a few million. The
resulting predicate is a range on the column the files are ordered by,
which is the case Parquet's row-group statistics prune best, and it is
carried into any later `peaksData()`.
Keeping it out of the per-spectrum data also makes it correctable. Sample
metadata is the one thing that reliably changes after ingest. Rewriting it
touches a few kilobytes and leaves the signal, and any projections built
over it, untouched.
Runs with no row get `NA`, so metadata covering only some runs is fine. To
go further, and link samples to spectra in an experiment-wide object, pass
the same table to `r Biocpkg("MsExperiment")` and link the two on
`run_id`:
```{r link-sample-data, message = FALSE}
library(MsExperiment)
samples <- S4Vectors::DataFrame(run_id = c("QC01", "QC02"),
subject = c("S1", "S2"),
timepoint = c(0L, 6L))
mse <- MsExperiment(sampleData = samples, spectra = Spectra(be_qc))
mse <- linkSampleData(mse, with = "sampleData.run_id = spectra.run_id")
mse
## The spectra of the second sample.
spectra(mse[2])
```
# The in-memory metadata cache
The non-peak spectra variables are typically small compared to the peak
data, so `MsBackendParquet` keeps them in a bounded, read-through
in-memory index. The cache is held at the package level, keyed by
dataset path and shared by every backend over that dataset, so it is
independent of the individual, serialisable backend objects. It is
warmed on `backendInitialize()` and dropped automatically whenever the
dataset on disk is (re-)written. It is entirely separate from the
user-modified variables tracked by `MsBackendCached`, which always take
precedence.
The cache is controlled through three options, shown here with their
defaults:
```{r options}
## Enable ("auto", the default) or disable ("off") the metadata cache.
options(MsBackendParquet.cacheMetadata = "auto")
## Columns held by default (the ones the optimised filters need).
options(MsBackendParquet.cacheColumns =
c("msLevel", "rtime", "precursorMz", "dataOrigin"))
## Budget in cells (rows x columns); 5e7 is roughly 400 MB of doubles.
## Requests over budget quietly fall back to reading from disk.
options(MsBackendParquet.cacheMaxCells = 5e7)
```
Two further options tune the underlying DuckDB session.
`MsBackendParquet.threads` sets how many threads DuckDB uses, and Arrow
too. When it is unset, both use at most two threads, and one when the
registered `r Biocpkg("BiocParallel")` backend is serial, so the package
does not take every core of a shared machine. DuckDB reads the option when
the package first opens its connection, and Arrow when the package is
loaded, so set it before either.
`MsBackendParquet.memoryLimit` maps to DuckDB's `memory_limit`, and is
left at DuckDB's default when unset.
# Streaming large mzML files
For very large mzML files, the default conversion path goes through a
`Spectra` object built with `MsBackendMzR()`, which materialises a
whole chunk of peaks in memory before writing it to Parquet. The
`engine = "mzr"` option bypasses `Spectra` entirely and streams
batches of spectra directly from `mzR::openMSfile()` into Apache
Arrow's incremental `ParquetFileWriter`:
```{r mzml-to-parquet-streaming}
be_stream <- mzMLToParquet(fl, path = tempfile(),
engine = "mzr", batch_size = 1000L,
verbose = FALSE)
length(be_stream)
```
Memory use stays bounded by `batch_size * mean(peaks per spectrum)`,
regardless of total file size. The streaming engine processes files
serially and requires the `mzR` package.
# Performance
Storing the peaks as columnar `list` values lets DuckDB skip
whole row groups when a query touches only part of the data, while
projection and predicate push-down keep both the columns and rows read
to a minimum. Combined with the in-memory metadata index, this makes
metadata filters roughly as fast as an in-memory backend while the peak
storage stays on disk.
Across a suite of seven queries over 380,100 spectra (50 mzML files),
the Parquet backend is the fastest of the on-disk `Spectra` backends on
5 of 7 queries (medians in ms, lower is better):
| query | MsBackendParquet | MsBackendMzR | MsBackendHdf5Peaks | MsBackendOfflineSql |
|---|---|---|---|---|
| `filterRt()`, wide range | **12.24** | 15.44 | 24.05 | 380.19 |
| `filterRt()`, narrow range | 11.23 | **5.33** | 14.76 | 348.17 |
| `filterPrecursorMzValues()`, ppm | **14.09** | 21.66 | 28.66 | 336.33 |
| `filterRt()` and `filterPrecursorMzValues()` | **12.56** | 17.25 | 27.75 | 389.22 |
| `peaksData()` of MS1 spectra | **314.82** | 1510 | 1440 | 919.04 |
| `tic()` of MS1 spectra | **506.79** | 1390 | 1490 | 890.82 |
| `spectraData()` of MS1 spectra | 26.09 | **17.27** | 22.30 | 718.91 |
The benchmark scripts live in
[`inst/benchmarks/`](https://github.com/computational-metabolomics/MsBackendParquet/tree/devel/inst/benchmarks);
see `inst/benchmarks/performance.Rmd` for the full numbers, the
configuration sweeps over the tunable options, and the design
considerations behind them.
# Saving and reloading the backend
Because `MsBackendParquet` objects only contain a file system path,
they are fully serialisable and can be sent to parallel workers
without re-opening connections:
```{r save-load}
rds <- tempfile(fileext = ".rds")
saveRDS(be, rds)
be2 <- readRDS(rds)
identical(spectraNames(be), spectraNames(be2))
```
# Session information
```{r si}
sessionInfo()
```