Sample Metadata (SDRF)
Overview
SDRF-Proteomics is the community format for
describing what each run and each label in an experiment actually is: the sample, its conditions,
the instrument, the labelling. msmu treats an SDRF as the source of truth for sample
metadata, in two explicit steps:
attach_sdrf()stores the whole table atmdata.uns["sdrf"], unchanged.apply_sdrf_to_obs()projects the columns you want onto each modality's.obs.
The split exists because an SDRF row does not correspond to one observation. Its rows span both
axes — comment[label] addresses the obs axis (channels) and comment[data file] the var axis
(runs and fractions) — so a 144-row SDRF of 12 fractions × 12 channels cannot be reduced to 12
sample rows without discarding the fraction axis. Keeping the original in .uns, which is carried
through split_tmt() and collapse_obs(), means the full table stays available no matter how the
container is reshaped, while .obs holds only what is well-defined per observation.
Metadata that is not in SDRF form — a plain DataFrame, csv, tsv, or parquet — is still supported
through add_meta().
attach_sdrf()
mdata = mm.read_sage(
identification_file="data/sage_tmt/sage/results.sage.tsv",
quantification_file="data/sage_tmt/sage/tmt.tsv",
label="tmt",
)
mdata = mm.pp.attach_sdrf(
mdata,
"data/sage_tmt/meta.sdrf.tsv", # path, URL, or DataFrame
validate=True, # optional, default True: validate with sdrf-pipelines
skip_ontology=True, # optional, default True: skip ontology term lookups
)
mdata.uns["sdrf"].shape # (144, 17)
The SDRF may be a path, a URL, or a DataFrame. Paths and URLs are read with mm.read_sdrf(), and
the table is validated with sdrf-pipelines unless you
pass validate=False; ontology term lookups are skipped by default (skip_ontology=True). To read
an SDRF into a DataFrame without attaching it, call mm.read_sdrf(sdrf_file, validate_sdrf=True)
directly.
.obs is untouched by this step, and uns["sdrf"] is never rewritten by the steps that follow.
apply_sdrf_to_obs()
Projection reduces the SDRF to the obs axis by a match key and copies across the columns that are a function of that key:
mdata = mm.pp.apply_sdrf_to_obs(mdata)
WARNING - apply_sdrf_to_obs: psm columns not projectable under 'comment[label]';
kept only in uns['sdrf']: ['assay name', 'comment[data file]', 'comment[fraction identifier]']
mdata["psm"].obs.head(2)
source name characteristics[organism] ... factor value[condition] factor value[time point]
TMT126 t0h Homo sapiens ... t0 t0h
TMT127 t1h Homo sapiens ... t1 t1h
All three arguments are optional, and the call above passes none of them:
-
on(optional, defaultNone) — the match key.Noneresolves it per modality, from that modality's ownuns["label"]:comment[label]where the label istmt(obs are channels), andcomment[data file]otherwise (obs are runs). Aftersplit_tmt()the composite[comment[label], set_key]is used for the whole container instead. File names are matched ignoring the extension, since readers store bare stems (QExHF03751) while an SDRF carriesQExHF03751.mzML. Pass a column name to override, or a list of columns to match a composite key.Only the reader's
psmmodality carriesuns["label"], so after summarising a TMT experiment the derivedpeptide/proteinmodalities fall back tocomment[data file]even though their obs are still channels. Project before summarising, or passon="comment[label]"explicitly. -columns(optional, defaultNone) — which columns to project.Noneprojects every projectable column; pass a name or list to project a subset. -set_index(optional, defaultNone) —Nonekeeps the existingobs.index; pass a column to rename samples by it (see below).
Projectable columns only. A column that takes more than one value within a key group cannot be
represented per observation. Above, each channel spans 12 fractions, so comment[data file] and
comment[fraction identifier] are skipped with a warning and stay in uns["sdrf"]. Naming such a
column explicitly in columns=[...] raises instead of skipping, so .obs never silently collapses
SDRF data.
Naming samples with set_index
Straight from a reader, samples are named by whatever the instrument produced — TMT channels or
run file names. set_index renames them to the SDRF's own sample identifier, so every plot,
obs slice and DE result speaks in the experiment's terms rather than the acquisition's:
mdata = mm.pp.apply_sdrf_to_obs(
mdata,
on=None, # optional, default None: match key auto-picked per modality
# (comment[label] for TMT, comment[data file] otherwise)
columns=["source name", "factor value[condition]"], # optional, default None: project every projectable column
set_index="source name", # optional, default None: keep obs.index unchanged
)
mdata["psm"].obs
source name factor value[condition]
t0h t0h t0
t1h t1h t1
t2h t2h t2
t6h t6h t6
The projected columns land on every modality's obs and on the MuData-level mdata.obs, the
index column stays available as a column too, and uns["sdrf"] keeps the untouched original —
including the columns that were not projectable.
Two constraints on set_index, both raised rather than worked around:
- The
set_indexcolumn must be among the projected columns — that is, listed incolumns, or covered by the defaultcolumns=None, or already present inobsfrom an earlier call.columns=["factor value[condition]"], set_index="source name"raises, becausesource namewas never projected. - Its values must be unique across obs, since they become the index.
mm.pp.apply_sdrf_to_obs(mdata, set_index="characteristics[organism]")
# ValueError: set_index 'characteristics[organism]' is not unique across psm.obs; cannot index.
mm.pp.apply_sdrf_to_obs(mdata, set_index="comment[data file]")
# ValueError: set_index 'comment[data file]' is neither a projectable SDRF column nor an obs column
On a multi-plex experiment, project and rename after split_tmt(), not before. The split
builds its channel_set obs names from the current obs names and starts the new modality with an
empty obs, so renaming first both discards the projected columns and produces names
(t0h_set1) that no longer match the SDRF's comment[label]. Keep the channel labels through
the split:
mdata = mm.pp.attach_sdrf(mdata, "meta.sdrf.tsv")
mdata = mm.pp.split_tmt(mdata) # obs: TMT126_set1, ...
mdata = mm.pp.apply_sdrf_to_obs(mdata, set_index="source name") # obs: the SDRF's sample names
The projected columns are also merged onto the MuData-level mdata.obs, where functions such as
correct_batch_effect() read them.
TMT: channels, plexes and split_tmt()
Readers emit TMT channels in the SDRF spelling — TMT126, TMT127N, … — so obs.index lines up
with comment[label] without a translation table.
For a multi-plex experiment, split_tmt() splits each plex into its own set of samples. With an
SDRF attached it derives the file → set map itself:
mdata = mm.pp.split_tmt(
mdata,
map=None, # optional, default None: derive the file -> set map from uns["sdrf"]
set_key="comment[sample preparation batch]", # optional, default shown: SDRF column naming each file's set
)
mdata = mm.pp.apply_sdrf_to_obs(mdata) # obs are now "TMT126_set1", ...
Which columns it reads, by default:
| side | column | note |
|---|---|---|
| SDRF | comment[data file] |
fixed; matched to the data file each plex was acquired in |
| SDRF | comment[sample preparation batch] |
the set/plex name — override with set_key |
| MuData | psm.var["filename"] |
what the SDRF file names are matched against, extension stripped |
SDRF has no dedicated column for the TMT set, which is why the batch column stands in for it.
split_tmt() errors if the column is absent, or if one data file maps to two sets. Any column
that is constant per data file may be named instead:
mdata = mm.pp.split_tmt(mdata, set_key="factor value[plex]")
split_tmt() records the key it used, so the following apply_sdrf_to_obs() knows to match on the
composite [comment[label], set_key] that the post-split channel_set obs index encodes — no
manual on is needed.
Passing map explicitly skips the SDRF entirely — a dict, Series, or two-column DataFrame
of file name → set:
mdata = mm.pp.split_tmt(mdata, map={"run_A": "set1", "run_B": "set2"}) # SDRF not consulted
add_meta() — metadata without an SDRF
Not every experiment has an SDRF. add_meta() is the generic path: it takes a DataFrame you
already have in memory, or a csv, tsv, parquet, or sdrf file (path or URL), and joins it
onto obs by one metadata key and one obs key.
import pandas as pd
meta = pd.DataFrame(
{
"channel": ["TMT126", "TMT127", "TMT128", "TMT129", "TMT130", "TMT131"],
"condition": ["ctrl", "ctrl", "ctrl", "treat", "treat", "treat"],
"replicate": [1, 2, 3, 1, 2, 3],
}
)
mdata = mm.pp.add_meta(
mdata,
meta, # DataFrame, path, or URL
format=None, # optional, default None: for files, inferred from the extension
metadata_on="channel", # optional, default None: metadata column to match on (default: its index)
obs_columns=None, # optional, default None: obs column to match on (default: the obs index)
validate_sdrf=True, # optional, default True: only applies to SDRF input
skip_ontology=True, # optional, default True: skip ontology term lookups
)
mdata["psm"].obs
channel condition replicate
TMT126 TMT126 ctrl 1
TMT127 TMT127 ctrl 2
TMT128 TMT128 ctrl 3
TMT129 TMT129 treat 1
TMT130 TMT130 treat 2
TMT131 TMT131 treat 3
Every column of the table lands on obs (and on the MuData-level mdata.obs). Matching is exact
and there is no projectability rule — it is a plain join, so the table must already be one row per
observation. Observations with no matching row are left as NaN rather than raising, so check
obs after joining a table you are unsure of. For a file input, format
is inferred from the extension (.sdrf.tsv / .sdrf → SDRF, otherwise .csv / .tsv /
.parquet) and only needs passing when the name does not say.
Use attach_sdrf() + apply_sdrf_to_obs() instead when you do have an SDRF: those keep the
original table on the container, understand its two axes, and refuse to collapse a column that is
not well-defined per observation.