This vignette explains each processing step, its settings and
recorded outputs, with examples of running steps individually.
vignette("MRManalyzeR", package = "MRManalyzeR") covers the
usual YAML route.
QC, or biological samples if
the design is balanced across batches) are comparable between batches.
correctBatch() fails without enough reference injections
per batch, and warns when the batch variable is confounded with a study
factor.The workflow falls into four stages, preceded by a validation pass
that checks the workbook and the study design before any data is
processed. Everything from stage 1 onwards is carried in a single
struct::DatasetExperiment: the assay matrix, the sample
metadata and the feature metadata kept aligned in one object. Therefore
every matrix-processing step takes that object and returns it, and
nothing has to be handed a matching metadata table a second time. The
readers at the start return a plain matrix, and the statistics and
plotting functions at the end take the object and return a table or a
plot; it is the stage-2 steps that pass it along unchanged in shape.
| Stage | Question it answers | Functions |
|---|---|---|
| 0. Validation | Is the input usable, and the design sound? | mrmValidateInput(),
mrmValidateDesign() |
| 1. Data parse | What did the instrument report? | readTargetLynx(), readSkyline(),
assembleDataset(), loadConfig(),
loadDataset(), combineDatasets() |
| 2. Peak-matrix processing | How should the reported measurements be processed? | filterBlanks(), correctBatch(),
normaliseMatrix(), adjustConcentration(),
imputeMissing(), subsetDataset(),
processDataset() |
| 3. QC check | Can these numbers be trusted? | addCVMetrics(), summariseFeatures(),
mrmRunPCA(), plotDrift(),
plotQQ(), mrmPlotPCA(),
plotLoadings() |
| 4. Stats | What do these numbers mean? | runStats(), summariseGroups(),
plotBoxplot(), plotVolcano(),
mrmPlotHeatmap(), plotGroupHeatmap(),
plotCorrelation() |
project_config.yml workbook.xlsx
| |
| mrmValidateInput() [0]
| mrmValidateDesign()
| |
| readTargetLynx() / readSkyline() [1]
| |
| wide sample x compound matrix
| |
| assembleDataset()
| |
| v
| DatasetExperiment
| |
| filterBlanks -> filterFeatures -> [2]
| filterSamples -> correctBatch ->
+--------> normaliseMatrix -> adjustConcentration
-> imputeMissing
|
v
processed DatasetExperiment
| | |
[3] QC check | | | [4] Stats
addCVMetrics | | | summariseGroups
mrmRunPCA | | | runStats
summariseFeatures | | |
| | |
tables + plots | | |tables + plots
v v v
data_quality .xlsx .RDS stats_report
_report.html .html
|
_stats.xlsx
Where a figure draws a computed result, it draws the one that was
written to the spreadsheet: runStats() and
addCVMetrics() compute once, and plotVolcano()
and plotCorrelation() take those tables as their input. The
per-compound plots work differently – plotBoxplot(),
plotQQ() and the heatmaps summarise the matrix themselves
as they draw. They agree with the sheet because both start from the same
matrix, so a figure and a sheet can only disagree if they were given
different objects (a filled matrix on one side and measured
on the other).
Each plotting function returns a standard ggplot that
can be customised and saved. The reports call exactly the same
functions, so what you get interactively is what the report shows.
MRManalyzeR expects one .xlsx workbook holding the
instrument export plus the two curation sheets that describe it:
| Sheet | One row per | Purpose |
|---|---|---|
lcms_data (or skyline_data) |
peak | the instrument export itself |
feature_metadata |
compound | which compounds to report, and their annotation |
sample_metadata |
injection | which injections to include, and their design |
Keeping the curation next to the data is what makes a run reproducible: the decisions about which compounds are reportable and which injections belong to the study are recorded in the workbook, not in a script.
Two practical rules govern whether a run will work at all:
feature_metadata$Processing_name has to match the compound
names in the acquisition method, and sample_metadata$Name
has to match the injection names in the data sheet – including trailing
spaces, which are the usual culprit.Include = "NO" rather than
being removed; a compound that should not be reported gets
Report = "NO". Re-injections are the common case, and
marking them keeps the workbook a faithful record of the run.xlsx <- system.file("extdata", "example_data.xlsx",
package = "MRManalyzeR")
fdata <- openxlsx::read.xlsx(xlsx, sheet = "feature_metadata")
meta <- openxlsx::read.xlsx(xlsx, sheet = "sample_metadata")
c(injections = nrow(meta), compounds = nrow(fdata))#> injections compounds
#> 79 164
The bundled workbook is a subset of the oxylipin LC-MS/MS data of Kolmert et al. (2018) (see References): 79 injections of mouse bronchoalveolar lavage over a calibration ladder, extraction blanks, pooled QC injections and 45 study samples.
DT::datatable(as.data.frame(table(Sample_type = meta$Sample_type)),
rownames = FALSE, filter = "top",
options = list(pageLength = 5, scrollX = TRUE))Because the panel is known in advance, feature_metadata
can carry real biology alongside the reporting decision, for instance
the enzymatic pathway a compound comes from, its precursor PUFA, its
retention time. Those columns serve a functional role: they colour the
PCA loadings, group the heatmap rows and define the metabolite ratios,
and they travel with the data as variable_meta all the way
to the output.
TargetLynx does not export a matrix. Its “complete summary”
(Edit > Copy > Complete Summary, pasted straight into
the lcms_data sheet) is a stacked table – one
block per compound, each block its own little table of injections, with
the compound name in a header row between blocks. It is tedious to
inspect visually and does not parse into R with existing tools, which is
the first problem the package solves.
readTargetLynx() parses those blocks, pivots them into a
wide sample x compound matrix of the requested
datatype column, and (with snr) masks any peak
whose signal-to-noise falls below the threshold:
#> [1] 82 155
Columns come back as the raw TargetLynx compound identifiers
(Processing_name). feature_metadata maps those
to the canonical Compound name used everywhere downstream,
which is how an instrument method can be renamed without touching the
analysis.
Three shapes have now appeared – 79 injections and 164 compounds in the metadata, a parsed matrix of 82 x 155 – and they describe different populations at different stages:
feature_metadata lists the whole curated
panel, 164 compounds, including everything acquired but not
reported. Eighteen carry Report = "YES".sample_metadata lists 79 injections.
The export holds 82: three were run but never entered
in the metadata, and an injection the metadata does not describe cannot
be analysed, so they are dropped on assembly.Assembly applies the reporting decision and the name matching, and nothing else:
#> [1] 79 14
Fourteen compounds of the eighteen reported, and 79 injections rather
than the 57 flagged Include = "YES":
assembleDataset() keeps every injection the
metadata describes, and Include is applied later,
by processDataset() or by subsetDataset().
The four missing compounds are worth understanding, because they are
a trap in the manual route only. assembleDataset() matches
matrix columns against the Compound
column, not Processing_name:
rep18 <- fdata[fdata$Report == "YES", c("Processing_name", "Compound")]
rep18[rep18$Processing_name != rep18$Compound, ]#> Processing_name Compound
#> 13 12,13-DiHOME (erythro) erythro-12,13-DiHOME
#> 14 12,13-DiHOME (threo) threo-12,13-DiHOME
#> 68 13-oxo-ODE 13-KODE
#> 72 LTB4 (12-epi-LTB4) LTB4
Exactly those four differ, and none of them appears in this export
under either name, so here they are simply absent. But the mechanism is
general: a matrix whose columns are raw Processing_name
identifiers loses every compound whose two names differ, silently,
because the column no longer matches a Compound value. The
YAML route never hits this – processDataset() renames
Processing_name to Compound before assembling.
Calling assembleDataset() yourself means doing that rename
yourself, or passing a matrix that already carries canonical names.
readSkyline() returns the same contract from a Skyline
molecule x sample sheet, with signal_filter = "LOD" or
"LOQ" in place of the S/N mask.
Output from any other software can be supplied as a plain
sample-by-analyte matrix, which enters the workflow at
assembleDataset() and is processed identically from there
on. Readers for further vendor formats are planned for a later version;
suitable test data is being sought, since a reader written without real
exports to check it against is not one worth relying on.
The example above is a single chromatographic batch, injected from one reconstitution volume, with no per-sample amount to normalise to so normalisation, concentration adjustment and batch correction would all be incompatible. The package therefore bundles a second, entirely simulated workbook: a Skyline-layout export of 20 analytes across three enzymatic pathways, 54 injections split over two chromatographic batches, with a per-sample protein amount and a different reconstitution volume per batch.
The study design is balanced across the batches, so the batch effect and the treatment effect are not confounded.
sxlsx <- system.file("extdata", "example_synthetic.xlsx",
package = "MRManalyzeR")
sfeat <- openxlsx::read.xlsx(sxlsx, sheet = "feature_metadata")
smeta <- openxlsx::read.xlsx(sxlsx, sheet = "sample_metadata")
table(smeta$Chrom_Batch, smeta$Sample_type)#>
#> Blank QC Sample
#> B1 2 5 20
#> B2 2 5 20
assembleDataset() aligns the matrix with its feature and
sample metadata and returns a struct::DatasetExperiment.
Every step from here on takes and returns that object,
so the metadata travels with the data and no step has to be handed a
matching sample table again.
sm <- readSkyline(sxlsx, "skyline_data", sfeat, signal_filter = "LOD")
de <- assembleDataset(sm, sfeat, smeta)
de#> A "DatasetExperiment" object
#> ----------------------------
#> name:
#> description:
#> data: 54 rows x 20 columns
#> sample_meta: 54 rows x 16 columns
#> variable_meta: 20 rows x 9 columns
This works directly because the simulated panel uses one name for
each compound – Processing_name and Compound
are identical for all 20 – so the distinction drawn above never arises.
On a real workbook, check that before handing a reader’s output straight
to assembleDataset().
filterBlanks() finds the extraction blanks via
sample_meta and sets any value below
mean(blanks) * blank_filter to NA. A factor of
3 is a common choice – a peak has to be at least three times the blank
level to be treated as real.
It needs blank values to work from, which makes it worth seeing on data that has them. Two compounds, three samples and two extraction blanks:
bl <- assembleDataset(
data.frame(well_above = c(100, 120, 110, 10, 12),
near_blank = c(30, 28, 26, 25, 24),
row.names = c("S1", "S2", "S3", "B1", "B2")),
data.frame(Compound = c("well_above", "near_blank"), Report = "YES"),
data.frame(Name = c("S1", "S2", "S3", "B1", "B2"),
Sample_type = c("Sample", "Sample", "Sample", "Blank", "Blank")))
before <- as.data.frame(bl$data)
bl <- filterBlanks(bl, blank_filter = 3, blank_head = "Sample_type",
blank_name = "Blank")
cbind(before, after = as.data.frame(bl$data))#> well_above near_blank after.well_above after.near_blank
#> B1 10 25 NA NA
#> B2 12 24 NA NA
#> S1 100 30 100 NA
#> S2 120 28 120 NA
#> S3 110 26 110 NA
well_above has blanks at 10 and 12, so the threshold is
33 and all three samples clear it. near_blank has blanks at
25 and 24, so the threshold is 73.5 and nothing in the compound survives
– which is the point of the step. Both are recorded:
#> blank_threshold n_masked_blank
#> well_above 33.0 2
#> near_blank 73.5 5
n_masked_blank counts every cell masked, the blank
injections themselves included: they sit below their own threshold by
construction.
On the simulated workbook the step has nothing to do, and it is worth seeing why rather than running it and reading the number as an effect:
blank_rows <- rownames(de$data)[
as.data.frame(de$sample_meta)$Sample_type == "Blank"]
c(missing_total = sum(is.na(de$data)),
missing_in_blanks = sum(is.na(as.data.frame(de$data)[blank_rows, ])),
blank_cells = length(blank_rows) * ncol(de$data))#> missing_total missing_in_blanks blank_cells
#> 154 80 80
Every blank cell is already NA:
readSkyline(signal_filter = "LOD") masked the blanks along
with everything else below the detection limit, which is what you would
hope for in a clean extraction. filterBlanks() therefore
has no blank values to average and leaves the matrix alone, and the
missing values counted here are the LOD mask’s work, not the blank
filter’s.
Detection filters remove poorly measured compounds and injections
before imputation: filterFeatures() drops compounds,
filterSamples() drops injections that missed most of the
panel. Both are off by default.
What matters is which samples the detection fraction is computed over. Take a compound present throughout one treatment group and absent from the others:
demo <- assembleDataset(
data.frame(everywhere = c(10, 12, 11, 9, 10, 11),
group_specific = c(NA, NA, NA, 20, 22, 21),
sparse = c(5, NA, NA, NA, NA, 6),
row.names = paste0("S", 1:6)),
# assembleDataset() keeps only rows flagged Report == "YES"
data.frame(Compound = c("everywhere", "group_specific", "sparse"),
Report = "YES"),
data.frame(Name = paste0("S", 1:6),
Sample_type = rep("Sample", 6),
Treatment = rep(c("PBS", "HDM"), each = 3)))
across <- filterFeatures(demo, min_frac = 0.6, method = "across")
within <- filterFeatures(demo, min_frac = 0.6, method = "within",
group_col = "Treatment")
list(across = colnames(across$data), within = colnames(within$data))#> $across
#> [1] "everywhere"
#>
#> $within
#> [1] "everywhere" "group_specific"
"across" pools all six samples, so
group_specific scores 3/6 = 0.5, falls short of 0.6 and is
deleted because it is group-specific. Within-group filtering retains
compounds consistently detected in at least one group:
"within" scores each treatment separately,
group_specific is 3/3 in HDM and survives, while
sparse still goes.
The threshold matters as much as the method here. At
min_frac = 0.5 the same compound scores exactly 0.5 under
"across" and is kept, since the test is “at least”, so the
two methods agree and nothing is demonstrated. A group-specific compound
is only ever at risk once the pooled threshold is set above its group’s
share of the samples.
A third mode, "QC", scores against the QC injections –
whether a compound is detected consistently in the QCs, which
is a prerequisite for trusting its precision rather than a measure of
it. It falls back to "within" with a warning when a run has
no QCs.
Small groups quantise the threshold: with n = 3 a
fraction can only be 0, 1/3, 2/3 or 1, so every min_frac
between 1/3 and 2/3 behaves identically.
QC and blank injections never count towards "within" or
"across". They stay in the dataset – the blank filter and
correctBatch() still need them – but a compound seen only
in the QCs cannot keep itself alive. The fraction each feature was
judged on is kept as detected_frac.
On the working dataset, where readSkyline() has already
masked values below the LOD:
filterSamples() then drops injections by missing-value
fraction. Run it after filterFeatures(): judged
against a feature list that still contains everything the instrument
barely saw, every injection looks poor because the denominator is padded
with noise. Only study samples are removed – a QC over the threshold is
reported but kept, since dropping one can leave a batch with nothing for
correctBatch() to anchor to.
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 0.0000 0.0000 0.0500 0.1426 0.1000 1.0000
Both run once over the whole dataset rather than per batch: a
detection fraction is a property of the study, and filtering per batch
would leave each batch with a different set of columns. In a YAML config
they are the filter_features: and
filter_samples: blocks, applied after the blank filter and
before imputation – imputation fills the very cells these measure.
Where an acquisition spans several days or columns, response can
shift between batches. correctBatch() scales each batch so
its per-feature median matches the grand median across the reference
samples. It runs straight after the filters, on the instrument signal,
and before normalisation, concentration adjustment and imputation. A
missing value counts as zero: a compound missing from most of a batch’s
QCs is described as such, thus impacting the median calculation,
avoiding it being based on few injections where it was found. Where more
than half of values for a compound are missing, the median is zero, and
correction is not applied for this compound only. The failing batches
are printed and kept in bc_failed_batches. To correct only
part of the panel, pass feature_col – a
feature_metadata column of YES /
NO.
batch <- stats::setNames(de$sample_meta$Chrom_Batch,
rownames(as.data.frame(de$data)))
de_uncorr <- de
de <- correctBatch(de, qc_label = "QC",
factor_name = "Sample_type",
batch_head = "Chrom_Batch")
de_bc <- deThe correction equalises the medians of the reference injections, so that is where to look at it. Taking the most completely measured analyte, and counting a missing value as zero exactly as the reference-median calculation does:
qc <- as.data.frame(de_uncorr$sample_meta)$Sample_type == "QC"
j <- which.min(colSums(is.na(as.data.frame(de_uncorr$data))))
zeroed <- function(v) median(replace(v, is.na(v), 0))
rbind(
before = tapply(as.data.frame(de_uncorr$data)[qc, j], batch[qc], zeroed),
after = tapply(as.data.frame(de_bc$data)[qc, j], batch[qc], zeroed))#> B1 B2
#> before 1662.70 837.20
#> after 1243.85 1243.85
The two QC medians land on the same value, which is the whole of what the step promises. The same table over all injections does not come out equal, and should not be read as a failure:
rbind(
before = tapply(as.data.frame(de_uncorr$data)[[j]], batch, zeroed),
after = tapply(as.data.frame(de_bc$data)[[j]], batch, zeroed))#> B1 B2
#> before 1670.40 837.20
#> after 1249.61 1243.85
Two limitations follow from that, and both matter when reading a corrected dataset:
plotDrift() is where that shows up, and pmp’s
spline-based QC-RSC is the tool for it.Counting a missing value as zero applies only to the reference median – it is what stops a batch whose QCs mostly missed a compound from claiming a high reference. The missing measurements themselves stay missing, and are still missing after correction.
A compound whose reference median is zero in any batch is left as measured in all of them rather than corrected in some. In this dataset the two analytes simulated near their detection limit are below it in more than half of the batch-2 QCs, which puts that median at zero. They are listed here, and they still carry the batch difference in the PCA further down:
vm <- as.data.frame(de_bc$variable_meta)
vm[nzchar(vm$bc_failed_batches), c("Compound", "bc_failed_batches")]#> Compound bc_failed_batches
#> Analyte_07 Analyte_07 B2
#> Analyte_16 Analyte_16 B2
Use pooled QC injections as the reference
(qc_label = "QC") whenever the design has enough of them in
every batch; falling back to "Sample" assumes the batches
are biologically comparable.
normaliseMatrix() divides each sample by a known
per-sample amount – protein, tissue weight, or the volume of biofluid
extracted – so that concentrations are expressed per unit of starting
material:
#> Analyte_01 Analyte_02 Analyte_03
#> SYN_001 NA NA NA
#> SYN_002 2.344 14.980 45.445
#> SYN_003 6.766 7.592 33.770
Before the arithmetic, a word on units, because they change under
your feet through this stage. The simulated workbook reports a
simulated response, not a calibrated concentration –
the generator plants signals, it does not model a calibration curve – so
nothing below should be read as ng/mL. On a real panel the chain runs:
the reported datatype (area, response or a concentration
from the vendor’s curve), divided by the per-sample amount at
normaliseMatrix() (so, per ug protein here), then rescaled
by adjustConcentration(), which does not change the unit
but does change what the number refers to – the amount in the original
sample rather than in the vial. Whatever that chain ends at is what
every axis label and every units: entry downstream should
say.
A calibration curve returns the concentration in the vial the
instrument sampled, and it does so based on the calibrant vial: the
curve was built at the calibrant’s internal-standard concentration, and
it assumes the sample carries the same.
adjustConcentration() corrects that assumption (based on
the same volume of calibration and study sample being injected into the
instrument):
\[ \text{conc} \times \frac{\text{cal vol}}{\text{sample vol}} \times \frac{\text{sample IS}}{\text{cal IS}} \]
This uses the volumes of internal standard added as a proxy for the amounts of internal standard present, which holds only if the calibrant and the samples were spiked from the same stock at the same concentration. Where they were not, the ratio has to be built from amounts instead.
Enable it only when the correction has not already been applied upstream. Vendor software is often configured to do this at the point of quantification, and running it again here would apply the same factor twice – which is invisible in the output, since the result is a plausible number either way.
Ultimately leading to:
\[ \frac{\text{cal vol}}{\text{sample vol}} \times \frac{\text{sample IS}}{\text{cal IS}} \;=\; \frac{\text{sample IS} / \text{sample vol}}{\text{cal IS} / \text{cal vol}} \;=\; \frac{[\text{IS}] \text{ in the sample vial}} {[\text{IS}] \text{ in the calibrant vial}} \]
So the correction is the ratio of internal-standard concentrations. More vial volume per unit of IS means the IS is more dilute in your sample than in the calibrant; a smaller IS peak inflates the analyte/IS response ratio, the curve reads high, and this factor – below 1 – scales it back down.
Note what is not being corrected here. Reconstituting in more solvent dilutes the analyte and the internal standard equally, so the response ratio does not move and neither does the curve’s answer.
Setting starting_vol_col adds a second step, back to the
concentration in the original, undried sample:
\[ \text{conc}_{\text{original}} = \text{conc}_{\text{vial}} \times \frac{\text{sample vol}}{\text{starting vol}} \]
de <- adjustConcentration(de,
sample_vol_col = "sample_volume_uL",
cal_vol_col = "cal_vol_uL",
sample_IS_col = "sample_IS_vol_uL",
cal_IS_col = "cal_IS_vol_uL",
starting_vol_col = "starting_vol_uL")
round(as.data.frame(de$data)[1:3, 1:3], 5)#> Analyte_01 Analyte_02 Analyte_03
#> SYN_001 NA NA NA
#> SYN_002 0.58607 3.74509 11.36127
#> SYN_003 1.69152 1.89799 8.44240
imputeMissing() fills the remaining NAs in
every injection that is not a blank – biological samples and QCs alike –
with values below that compound’s lowest measurement, treating a
non-detect as lying under the detection floor rather than at zero. Only
the blanks are exempt, so frac_imputed below is a fraction
of samples and QCs. The ceiling is scalar times
the minimum – 0.2 here – and by default every cell gets its own random
draw between 0 and that ceiling, from a normal centred mid-range. A
single constant would give every non-detect the same value, which
shrinks a compound’s variance and ties its ranks;
method = "constant" does that if it is wanted. The draws
use a fixed seed, so a rerun is identical. Blanks are left
untouched.
The PCA pre-processing (mrmRunPCA(impute = )) offers the
same random fill ("gaussian", its default) alongside
"frac_min", "min", "half_min" and
"none", and missing-value filters that drop features or
samples above a missingness threshold instead of imputing them. For
targeted panels a compound missing in most samples is usually better
dropped than modelled.
Imputation comes last, after batch correction, normalisation and
concentration adjustment. In a YAML-driven run it fills the exported
matrix and the PCAs; the tests and plots use the measured values unless
use_imputed: True is set. With replace_MVs
off, the PCAs impute for themselves.
de <- imputeMissing(de, scalar = 0.2, blank_head = "Sample_type",
blank_name = "Blank")
sum(is.na(de$data))#> [1] 80
Imputation is recorded in variable_meta: the ceiling
(impute_max), how many cells it filled
(n_imputed) and what share of the compound’s non-blank
injections that is (frac_imputed).
Results for heavily imputed compounds are more sensitive to the imputation method. A high value does not by itself make a difference an artefact – a compound genuinely absent from one treatment carries one too – so read it next to the detection counts per group rather than on its own:
vm <- as.data.frame(de$variable_meta)
head(vm[order(-vm$frac_imputed),
c("impute_max", "n_imputed", "frac_imputed")])#> impute_max n_imputed frac_imputed
#> Analyte_07 2.13364437 19 0.38
#> Analyte_16 0.06665625 19 0.38
#> Analyte_01 0.11721461 2 0.04
#> Analyte_02 0.15344484 2 0.04
#> Analyte_03 1.01602752 2 0.04
#> Analyte_04 0.01385953 2 0.04
processDataset() composes stages 1 and 2 – read,
assemble, filter, batch-correct, normalise, adjust, then impute – driven
by the same metadata sheets:
out <- processDataset(
sfeat, smeta,
xlsx_path = sxlsx,
data_source = "skyline",
data_tab_names = "skyline_data",
signal_filter = "LOD",
blank_filter = 3,
normalize = "protein_ug",
adjust_conc = TRUE,
starting_vol_col = "starting_vol_uL",
replace_MVs = 0.2,
batch_correction = TRUE,
bc_qc_label = "QC",
bc_header = "Chrom_Batch",
blank_head = "Sample_type")
dim(out[[1]]$data)#> [1] 54 20
out[[2]] holds the features dropped along the way with
the reason recorded – the same table the data-quality report surfaces as
“compounds excluded”, and the first place to look when a compound you
expected is missing from the output.
The shape matches what the manual walkthrough produced, but the two
are not the same calculation, and matching dimensions would not show
that they were. The call above leaves filter_features and
filter_samples at their defaults – off – where the
walkthrough applied the detection filter, and
processDataset() imputes per processing batch where
imputeMissing() was called once over everything. Align the
arguments if you want the two routes to agree cell for cell.
out$measured is the same dataset before imputation, gaps
included – what the reports and statistics use in a YAML-driven run
unless use_imputed: True is set. out[[1]] has
its gaps filled everywhere except in the blanks, which are left as they
were:
#> [1] 154
#> [1] 74
The rest of this vignette carries on from the run just made, using the measured copy – the one that still has its gaps:
#> [1] 54 20
#> [1] 154
That matters for everything that follows. CVs, group summaries and
tests are meant to be read off measured values; computed on a filled
matrix they would report invented numbers as observations, and a
compound that was a non-detect in half of one group would show a full
complement of samples. The PCA is the exception, and it handles itself:
mrmRunPCA() fills internally for the decomposition, because
a PCA cannot take a gap.
The same dataset also ships ready-made as
system.file("extdata", "example_synthetic.RDS", package = "MRManalyzeR"),
for examples that need a processed object without doing the processing.
Note that the shipped copy is the filled one –
out[[1]], not out$measured – so load it for
convenience, not for a demonstration of measured values.
addCVMetrics() writes the per-compound quality metrics
into variable_meta, matched to the feature they describe,
so annotation and quality live in the same table.
runMRManalyzeR() applies it to every run.
syn <- addCVMetrics(syn, qc_label = "QC")
cv <- as.data.frame(syn$variable_meta)[
, c("Compound", "Enzymatic_pathway", "CV_QC", "CV_sample",
"CV_sample_vs_QC")]
DT::datatable(cv[order(cv$CV_QC), ], rownames = FALSE, filter = "top",
options = list(pageLength = 10, scrollX = TRUE))CV_QC is the coefficient of
variation across the pooled QC injections. Every QC is the same
material, so this is technical variability.
CV_sample is the same statistic
across the biological samples, so it carries the
biological spread plus that same technical
noise.
CV_sample_vs_QC is
CV_sample / CV_QC, a descriptive comparison of the two
dispersions above. A large value says the samples vary much more than
the QCs, which is what you want to see in a compound you intend to
analyse; a value near 1 says the spread across the study is no wider
than the repeatability of the method.
Two cautions. It is a ratio of coefficients of variation, not of variances, and not a decomposition into biological and technical parts: a CV carries its own mean, so the comparison is only as meaningful as the similarity of the QC and sample means. And it is not the dispersion ratio of Broadhurst et al. (2018), which is defined on standard deviations and in the other direction, \(100 \times SD_{\mathrm{QC}} / SD_{\mathrm{samples}}\), with its commonly quoted thresholds attached to that definition. The two are not reciprocals of each other unless the means coincide, so do not carry a D-ratio threshold across to this column. Compute the published statistic explicitly if that is the one you need.
plotDrift() puts one compound’s response against
acquisition order, coloured by sample type so the pooled QCs can be
tracked through the sequence. A trend, a step or a widening spread in
the QCs is instrumental, not biological.
Q-Q plots help assess distributional assumptions within comparison groups. The parametric tests assume approximate normality within each group – or of the model residuals – not of the pooled measurements, and pooling two treatments that genuinely differ can bend the line while each group on its own is well behaved. So assess it per group:
qq_grp <- function(level)
plotQQ(subsetDataset(syn, conditions = list(Treatment = level,
Sample_type = "Sample")),
"Analyte_02", transform = "log2") +
ggplot2::labs(title = level)
cowplot::plot_grid(qq_grp("PBS"), qq_grp("HDM"), nrow = 1)Targeted panels are frequently right-skewed, which is why the report
renders a tab per transform rather than picking one. The displayed
transformation does not change the statistical tests:
runStats() works on the values it is handed, so analysing
on a transformed scale means transforming the matrix.
mrmRunPCA() applies the missing-value filtering,
imputation, transform, centring and scaling, and returns the fit
together with a readable trace of what it did. mrmPlotPCA()
draws the scores.
Throughout the package, transform = "log2" means
log2(x + 1) – in the PCA and in both heatmaps. The
pseudocount keeps zeros and near-zeros finite, but it is an absolute
offset on a relative scale: on areas in the thousands it is negligible,
while on values already near 1 it compresses the low end noticeably.
Changing the units of the matrix therefore changes what this transform
does to it, which is worth knowing before comparing a normalised run
with an unnormalised one.
Blanks are dropped first. They are diagnostics rather than
measurements, and leaving them in gives them a say in the components: a
blank sits far from every real sample, so it can dominate the variance
the PCA is trying to describe. mrmRunPCA() does not filter
them for you, so the subset is explicit.
syn_bio <- subsetDataset(syn, conditions = list(Sample_type = c("Sample", "QC")))
pca <- mrmRunPCA(syn_bio, impute = "gaussian", impute_frac = 0.2,
transform = "log2", feat_na_max = 0.6,
sample_na_max = 0.6)
pca$steps#> [1] "Feature missing filter (max 60% NA): dropped 0 feature(s)."
#> [2] "Sample missing filter (max 60% NA): dropped 0 sample(s)."
#> [3] "Imputed NAs with random values between 0 and 0.2 x the per-feature minimum (normal, mean at half that ceiling, SD a sixth of it; seed 42)."
#> [4] "log2(x + 1) transform."
#> [5] "Mean-centred per feature."
#> [6] "Autoscaled (divide by per-feature SD)."
Colour scores by sample type, batch or injection order to investigate
sources of variation. The report draws one plot per column listed under
qc_pca.color_by.
Colouring by chromatographic batch is the sharpest of these: if the batches separate, batch-associated variation is driving the component and warrants investigation. It does not by itself make that variation analytical – a design where one batch holds more of one treatment, or where batches were run on material collected at different times, separates for reasons that are partly biological. The validation step warns when batch is confounded with a study factor for exactly this reason, and that warning is what tells the two readings apart.
This dataset had a batch shift planted in it, and the design is
balanced, so here the separation really is the artefact – the same PCA
before and after correctBatch():
keep <- list(Sample_type = c("Sample", "QC"))
before <- subsetDataset(de_uncorr, conditions = keep)
after <- subsetDataset(de_bc, conditions = keep)
pca_before <- mrmRunPCA(before, impute = "gaussian", impute_frac = 0.2,
transform = "log2")
pca_after <- mrmRunPCA(after, impute = "gaussian", impute_frac = 0.2,
transform = "log2")
cowplot::plot_grid(
mrmPlotPCA(pca_before, before, colour_by = "Chrom_Batch") +
ggplot2::labs(title = "Before batch correction"),
mrmPlotPCA(pca_after, after, colour_by = "Chrom_Batch") +
ggplot2::labs(title = "After batch correction"),
nrow = 1)The scores say whether samples separate;
plotLoadings() says which compounds drove it.
Colouring by the enzymatic pathway is what turns a ranked list into
something interpretable – the palette is shared with the heatmap
annotation, so a class is the same colour in every figure.
subsetDataset() slices by any sample_meta
condition, which is how the study samples are separated from the QCs and
blanks before testing:
samples <- subsetDataset(syn,
conditions = list(Sample_type = "Sample"))
table(samples$sample_meta$Treatment, samples$sample_meta$Type)#>
#> KO WT
#> HDM 10 10
#> PBS 10 10
summariseGroups() is the table the box and bar plots are
drawn from, and it is written to the summary sheet of the
stats workbook. Both SD and SE are returned: SD describes the spread of
the animals, SE how well the mean is pinned down, and the choice belongs
to the reader.
runStats() runs the comparisons, correlations, linear
models and metabolite ratios described by the stats_report:
block of the YAML and returns tidy tables. The method is named
explicitly per comparison: welch (the default),
student or wilcoxon where a factor has two
levels, anova or kruskal where it has three or
more, the latter carrying Tukey and pairwise-Wilcoxon post-hoc
tests.
Three things to fix before reading any of its output:
runStats() – here the measured, normalised values,
untransformed. Nothing is logged on the way in.levels = c("PBS", "HDM")
makes PBS group 1 and HDM group 2, and log2FC is
log2(mean_HDM / mean_PBS): positive means higher in
HDM.adjust_family: one per comparison and method for the tests,
one per correlation analysis, subset and method, one per linear-model
term. A global test and its post-hoc results are therefore adjusted
separately – and the post-hoc p-values arrive already adjusted within
the feature, by Tukey for anova and by Holm for
kruskal, so p_adj on those rows is a second,
across-features correction of an already-corrected value.Naming the test in the YAML, before the data is seen, keeps that choice visible and prevents a more convenient result being selected afterwards.
params <- list(
sig_threshold = 0.05,
comparisons = list(enabled = TRUE, entries = list(
list(name = "PBS_vs_HDM",
method = "welch",
compare = list(factor = "Treatment",
levels = c("PBS", "HDM"))))),
ion_ratios = list(enabled = TRUE, ratios = list(
list(num = "Analyte_02", den = "Analyte_05",
label = "A02/A05"))))
st <- runStats(samples, params)Because the effects in this dataset were planted, the recovered fold changes can be checked against the truth:
tt <- st$stats[st$stats$method == "welch",
c("feature", "n_g1", "n_g2", "log2FC", "p_value", "p_adj")]
truth <- as.data.frame(syn$variable_meta)[, c("Compound",
"Simulated_log2FC")]
chk <- merge(tt, truth, by.x = "feature", by.y = "Compound")
chk <- chk[order(-abs(chk$Simulated_log2FC), chk$p_value), ]
chk$log2FC <- round(chk$log2FC, 3)
chk$Simulated_log2FC <- round(chk$Simulated_log2FC, 3)
chk$p_value <- signif(chk$p_value, 3)
chk$p_adj <- signif(chk$p_adj, 3)
DT::datatable(chk, rownames = FALSE, filter = "top",
options = list(pageLength = 8, scrollX = TRUE))n_g1 and n_g2 are the non-missing counts
the test actually used, which is why they are worth carrying: a fold
change computed on three animals is not the same evidence as one
computed on twenty.
Read the recovery directly. The five analytes with a non-zero
Simulated_log2FC carry the largest recovered effects and
the smallest p-values, in the right direction, and four of them clear
FDR 0.05. Analyte_18 – the smallest planted effect, at
-0.737 – does not, which is the expected outcome rather than a failure:
an effect that size, in this number of samples, is not reliably
recoverable at this threshold. The other fifteen scatter around
zero.
type = "bar" draws the group means and error bars from
summariseGroups(), so the figure and the
summary sheet cannot drift apart:
plotBoxplot(samples, "Analyte_02", "Treatment", type = "bar",
error_bar = "SE", value_label = "response / ug protein")Volcano plots show fold changes against statistical significance. The
x axis is log2(HDM / PBS), so compounds to the right are
higher in HDM.
The inference above used FDR-adjusted p-values, so the plot should
too – use_adjusted = TRUE draws the same threshold the
table was read against:
Whether a small fold change matters depends on the analyte and the question, not on the plot.
Where the comparisons test one compound at a time, the heatmap shows the whole panel at once – which is how you see that a treatment moved an entire pathway rather than a few compounds that happened to clear a threshold.
mrmPlotHeatmap(samples,
color_samples_by = "Treatment",
group_features_by = "Enzymatic_pathway",
transform = "log2",
title = "All analytes")mrmPlotHeatmap() never averages: every injection gets
its own line – rows by default, or columns with
orientation = "samples_x". Choose the heatmap layout
according to sample count and readability.
plotGroupHeatmap() is the same figure – same annotation
bars, same palette, same blocks and gaps – with the hue
set by the group mean, so group patterns are easier to see. It still
draws one cell per sample, so it does not reduce the number of columns;
what it changes is what the colour says. The opacity of
each sample’s cell encodes how that sample sits relative to its group’s
deviation from the overall centre.
plotGroupHeatmap(samples,
color_samples_by = "Treatment",
group_features_by = "Enzymatic_pathway",
transform = "log2",
title = "All analytes, group means")Opacity is measured against the mean of all samples, not against the group’s own members, so it is not “has the largest value”: a solid cell is one aligned with the direction its group deviated in, a faded one is a sample sitting against that direction. Read it as alignment, not as influence, uncertainty or agreement – it is a visual aid, not a statistic, and there is no scale bar for it.
A report renders one style or the other, never both
– heatmap: style: takes samples,
group_means, or auto.
Feature correlations identify compounds that vary together. Shared treatment responses, batch effects and normalisation can all produce them, so a block along the diagonal is a starting point for a hypothesis about shared pathway chemistry rather than evidence of it – correlating within a treatment group as well as across the pooled samples is the quickest way to see which it is.
cp <- list(correlations = list(enabled = TRUE, entries = list(
list(name = "pairs", methods = "spearman",
subsets = list(list())))))
plotCorrelation(runStats(samples, cp), samples,
group_by = "Enzymatic_pathway")Metabolite ratios describe relative abundances and can support hypotheses about pathway regulation (these are ratios between metabolites, not the qualifier-to-quantifier transition ratios). A product-to-precursor ratio cancels shared variation in how much substrate was present, which makes it a more stable quantity than either compound alone.
It is not a measure of enzyme activity or of flux. A concentration
ratio at one timepoint is the net result of synthesis, degradation,
transport and compartmentation; inferring a rate from it needs kinetic
information this assay does not carry. runStats() returns
the per-sample ratios with a group test attached, so they reach the
ion_ratios sheet as well as the report.
Broadhurst D. et al. (2018). Guidelines and considerations for the use of system suitability and quality control samples in mass spectrometry assays applied in untargeted clinical metabolomic studies. Metabolomics. https://doi.org/10.1007/s11306-018-1367-3 – the dispersion ratio discussed in the QC section.
Kolmert J. et al. (2018). Prominent release of lipoxygenase generated mediators in a murine house dust mite-induced asthma model. Prostaglandins & Other Lipid Mediators. https://doi.org/10.1016/j.prostaglandins.2018.05.005 – source of the bundled example data.
Lloyd G.R., Jankevics A., Weber R.J.M. (2020). struct: an
R/Bioconductor-based framework for standardized metabolomics data
analysis. Bioinformatics. https://doi.org/10.1093/bioinformatics/btaa1031 – the
DatasetExperiment container used throughout.
Wilkinson M.D. et al. (2016). The FAIR Guiding Principles for scientific data management and stewardship. Scientific Data 3, 160018. https://doi.org/10.1038/sdata.2016.18 – the reproducibility principles the configuration-driven workflow follows.
The script that generates the simulated workbook, including every
effect planted in it, ships with the package at
system.file("script", "make_example_synthetic.R", package = "MRManalyzeR"),
with its provenance recorded in
system.file("script", "README.md", package = "MRManalyzeR").
#> 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] ggplot2_4.0.3 tidyr_1.3.2 dplyr_1.2.1
#> [4] structToolbox_1.25.0 MRManalyzeR_0.99.7 struct_1.25.0
#> [7] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] tidyselect_1.2.1 farver_2.1.2
#> [3] S7_0.2.2 fastmap_1.2.0
#> [5] janitor_2.2.1 digest_0.6.39
#> [7] timechange_0.4.0 lifecycle_1.0.5
#> [9] magrittr_2.0.5 compiler_4.6.1
#> [11] rlang_1.3.0 sass_0.4.10
#> [13] tools_4.6.1 yaml_2.3.12
#> [15] knitr_1.52 S4Arrays_1.13.2
#> [17] labeling_0.4.3 htmlwidgets_1.6.4
#> [19] sp_2.2-4 DelayedArray_0.39.8
#> [21] RColorBrewer_1.1-3 abind_1.4-8
#> [23] withr_3.0.3 purrr_1.2.2
#> [25] BiocGenerics_0.59.12 sys_3.4.3
#> [27] grid_4.6.1 stats4_4.6.1
#> [29] scales_1.4.0 SummarizedExperiment_1.43.0
#> [31] cli_3.6.6 rmarkdown_2.32
#> [33] generics_0.1.4 otel_0.2.0
#> [35] cachem_1.1.0 stringr_1.6.0
#> [37] ggthemes_7.0.0 BiocManager_1.30.27
#> [39] XVector_0.53.0 matrixStats_1.5.0
#> [41] vctrs_0.7.3 Matrix_1.7-6
#> [43] jsonlite_2.0.0 IRanges_2.47.5
#> [45] S4Vectors_0.51.10 ggrepel_0.9.8
#> [47] maketools_1.3.2 crosstalk_1.2.2
#> [49] ggnewscale_0.5.2 jquerylib_0.1.4
#> [51] pls_2.9-0 glue_1.8.1
#> [53] cowplot_1.2.0 DT_0.34.0
#> [55] lubridate_1.9.5 stringi_1.8.9
#> [57] gtable_0.3.6 GenomicRanges_1.65.4
#> [59] tibble_3.3.1 pillar_1.11.1
#> [61] htmltools_0.5.9 Seqinfo_1.3.2
#> [63] R6_2.6.1 httr2_1.3.0
#> [65] evaluate_1.0.5 lattice_0.23-1
#> [67] Biobase_2.73.2 openxlsx_4.2.9
#> [69] pheatmap_1.0.13 snakecase_0.11.1
#> [71] bslib_0.12.0 Rcpp_1.1.2
#> [73] zip_3.0.2 gridExtra_2.3.1
#> [75] SparseArray_1.13.4 xfun_0.61
#> [77] MatrixGenerics_1.25.0 buildtools_1.0.0
#> [79] pkgconfig_2.0.3