adesomics.io.metadata

Sample metadata for RNA-seq experiments.

  1"""Sample metadata for RNA-seq experiments."""
  2
  3
  4import re
  5from dataclasses import dataclass, field
  6
  7import pandas as pd
  8
  9# columns that describe the study as a whole, not individual samples
 10_STUDY_LEVEL = [
 11    "Organism", "strain", "substrain", "BioProject", "SRA Study",
 12    "Instrument", "Platform", "Assay Type", "LibraryLayout",
 13    "LibrarySelection", "LibrarySource", "Center Name", "culture_type",
 14]
 15
 16# bookkeeping columns with no biological meaning
 17_DROP = [
 18    "Bytes", "Bases", "AvgSpotLen", "Consent", "ReleaseDate", "create_date",
 19    "version", "DATASTORE filetype", "DATASTORE provider", "DATASTORE region",
 20]
 21
 22_ID_CANDIDATES = ["Run", "run_accession", "sample_id"]
 23
 24
 25# columns holding a free-text sample description (pysradb / GEO style)
 26_TITLE_CANDIDATES = ["sample_title", "title", "Sample Name", "sample_name"]
 27
 28# marks a structured SRA Run Selector export
 29_SRA_MARKERS = {"Run", "BioProject", "Assay Type"}
 30
 31
 32def _slug(text) -> str:
 33    """'Acetoacetate/LiCl(10mM)' -> 'Acetoacetate_LiCl_10mM'"""
 34    return re.sub(r"[^0-9A-Za-z]+", "_", str(text)).strip("_")
 35
 36
 37def _strip_replicate(title) -> str:
 38    """'del atoC, M9, LiCl, rep1' -> 'del_atoC_M9_LiCl'"""
 39    without = re.sub(r"[,;]?\s*rep(licate)?\s*\.?\s*\d+\s*$", "", str(title),
 40                     flags=re.I)
 41    return _slug(without)
 42
 43
 44# columns that are never experimental factors, even at low cardinality
 45_NON_FACTOR = {
 46    "Assay Type", "LibrarySelection", "LibrarySource", "Platform",
 47    "LibraryLayout", "Sample Name", "Library Name", "Experiment", "BioSample",
 48    "source_name", "strain",
 49}
 50
 51# preferred factor columns, tried in this order
 52_FACTOR_PRIORITY = ["genotype", "media", "supplement", "treatment",
 53                    "condition_", "time", "temperature", "carbon_source"]
 54
 55
 56def _detect_factors(df):
 57    """Pick columns that vary but are not per-sample identifiers.
 58
 59    A factor has more than one level but fewer than one level per sample,
 60    and is not on the non-factor blocklist.
 61    """
 62    n = len(df)
 63    usable = [
 64        c for c in df.columns
 65        if c not in _NON_FACTOR and 1 < df[c].nunique() < n
 66    ]
 67    # keep known factor names first, then anything else usable
 68    ordered = [c for c in _FACTOR_PRIORITY if c in usable]
 69    ordered += [c for c in usable if c not in ordered]
 70
 71    if not ordered:
 72        raise ValueError(
 73            "Could not detect any experimental factor. Pass factors=(...) "
 74            f"explicitly. Columns available: {list(df.columns)}"
 75        )
 76    return tuple(ordered)
 77
 78
 79def _pick(columns, candidates, what):
 80    for c in candidates:
 81        if c in columns:
 82            return c
 83    raise ValueError(
 84        f"No column for {what}; expected one of {candidates}, have {list(columns)}"
 85    )
 86
 87
 88@dataclass(repr=False, eq=False)
 89class Metadata:
 90    """Per-sample annotation plus study-level constants.
 91
 92    table: index = run IDs, columns = experimental factors
 93    study: values identical across all samples (organism, platform, ...)
 94    """
 95
 96    table: pd.DataFrame
 97    study: dict = field(default_factory=dict)
 98    group_col: str = "condition"
 99
100    def __post_init__(self):
101        self.table = self.table.copy()
102        if self.table.index.duplicated().any():
103            dupes = self.table.index[self.table.index.duplicated()].unique()
104            raise ValueError(f"Duplicate sample IDs: {list(dupes)}")
105        if self.group_col not in self.table.columns:
106            raise ValueError(
107                f"Column '{self.group_col}' missing; have {list(self.table.columns)}"
108            )
109
110    # --- constructors ----------------------------------------------------
111
112    @classmethod
113    def from_sra_run_table(
114        cls,
115        path,
116        factors=None,
117        id_col=None,
118        assay=None,
119    ) -> "Metadata":
120        """Read SraRunTable.csv from the NCBI SRA Run Selector.
121
122        factors
123            Columns that define a condition. If None, they are detected
124            automatically: every non-constant, non-ID column that looks like
125            an experimental factor.
126        assay
127            Keep only rows whose 'Assay Type' matches (e.g. 'RNA-Seq'), for
128            run tables that mix RNA-Seq and ChIP-Seq.
129        """
130        df = pd.read_csv(path, sep=None, engine="python")
131
132        if assay is not None and "Assay Type" in df.columns:
133            before = len(df)
134            df = df[df["Assay Type"] == assay]
135            print(f"[Metadata] assay filter '{assay}': kept {len(df)}/{before} runs")
136            if df.empty:
137                raise ValueError(
138                    f"No rows with Assay Type == '{assay}'; "
139                    f"available: {sorted(pd.read_csv(path)['Assay Type'].unique())}"
140                )
141
142        id_col = id_col or next(
143            (c for c in _ID_CANDIDATES if c in df.columns), None
144        )
145        if id_col is None:
146            raise ValueError(
147                f"No run-ID column found; expected one of {_ID_CANDIDATES}"
148            )
149        df = df.set_index(id_col).drop(columns=_DROP, errors="ignore")
150
151        # split off values that are constant across the whole study
152        study = {}
153        for col in list(df.columns):
154            if col in _STUDY_LEVEL and df[col].nunique(dropna=False) == 1:
155                study[col] = df[col].iloc[0]
156                df = df.drop(columns=col)
157
158        # normalise the biological factors that we recognise
159        if "genotype" not in df.columns and "source_name" in df.columns:
160            df["genotype"] = (
161                df["source_name"]
162                .str.replace("MG1655", "", regex=False)
163                .str.strip()
164                .replace("", "wt")
165            )
166        if "genotype" in df.columns:
167            df["genotype"] = df["genotype"].map(_slug)
168        if "supplement" in df.columns:
169            df["supplement"] = df["supplement"].replace({"--": "none"}).map(_slug)
170        if "media" in df.columns:
171            df["media"] = df["media"].map(_slug)
172
173        if factors is None:
174            factors = _detect_factors(df)
175            print(f"[Metadata] detected factors: {list(factors)}")
176
177        missing = [f for f in factors if f not in df.columns]
178        if missing:
179            raise ValueError(
180                f"Factors {missing} not in table; have {list(df.columns)}"
181            )
182
183        df["condition"] = df[list(factors)].astype(str).agg("_".join, axis=1)
184        return cls(df, study=study)
185
186    @classmethod
187    def from_groups(cls, samples, groups, name="condition") -> "Metadata":
188        """Manual fallback: one group label per sample."""
189        if isinstance(groups, dict):
190            groups = [groups[s] for s in samples]
191        if len(groups) != len(samples):
192            raise ValueError(f"{len(groups)} groups for {len(samples)} samples")
193        return cls(
194            pd.DataFrame({name: list(groups)}, index=list(samples)),
195            group_col=name,
196        )
197
198    # --- access ----------------------------------------------------------
199
200    @property
201    def groups(self) -> pd.Series:
202        return self.table[self.group_col]
203
204    @property
205    def samples(self) -> list:
206        return list(self.table.index)
207
208    @property
209    def organism(self) -> str | None:
210        return self.study.get("Organism")
211
212    def factor_levels(self) -> dict:
213        """{'genotype': ['wt', 'del_atoC', ...], 'media': [...]}
214
215        Skips constants and ID-like columns (one distinct value per sample).
216        """
217        return {
218            c: sorted(self.table[c].unique())
219            for c in self.table.columns
220            if c != self.group_col and 1 < self.table[c].nunique() < len(self.table)
221        }
222
223    def replicates(self) -> pd.Series:
224        return self.groups.value_counts()
225
226    def align(self, counts: pd.DataFrame) -> "Metadata":
227        """Reorder rows to match count-matrix columns; tolerates BAM paths."""
228        ids = [
229            m.group() if (m := re.search(r"[SED]RR\d+", str(c))) else str(c)
230            for c in counts.columns
231        ]
232        missing = set(ids) - set(self.table.index)
233        if missing:
234            raise ValueError(f"Not in metadata: {sorted(missing)}")
235
236        sub = self.table.loc[ids].copy()
237        sub.index = counts.columns
238        return Metadata(sub, study=self.study, group_col=self.group_col)
239
240    def subset_samples(self, samples) -> "Metadata":
241        """Keep the given sample IDs, in the given order."""
242        missing = set(samples) - set(self.table.index)
243        if missing:
244            raise ValueError(f"Not in metadata: {sorted(missing)}")
245        return Metadata(self.table.loc[list(samples)], study=self.study,
246                        group_col=self.group_col)
247
248    def subset(self, keep=None, **criteria) -> "Metadata":
249        """subset(keep=[...]) by group, or subset(media='M9') by factor."""
250        mask = pd.Series(True, index=self.table.index)
251        if keep is not None:
252            mask &= self.groups.isin(keep)
253        for col, want in criteria.items():
254            want = want if isinstance(want, (list, tuple, set)) else [want]
255            mask &= self.table[col].isin(want)
256        return Metadata(self.table[mask], study=self.study,
257                        group_col=self.group_col)
258
259    def __len__(self):
260        return len(self.table)
261
262    def __repr__(self):
263        org = self.organism or "unknown organism"
264        return (f"<Metadata: {len(self)} samples, "
265                f"{self.groups.nunique()} groups, {org}>")
@dataclass(repr=False, eq=False)
class Metadata:
 89@dataclass(repr=False, eq=False)
 90class Metadata:
 91    """Per-sample annotation plus study-level constants.
 92
 93    table: index = run IDs, columns = experimental factors
 94    study: values identical across all samples (organism, platform, ...)
 95    """
 96
 97    table: pd.DataFrame
 98    study: dict = field(default_factory=dict)
 99    group_col: str = "condition"
100
101    def __post_init__(self):
102        self.table = self.table.copy()
103        if self.table.index.duplicated().any():
104            dupes = self.table.index[self.table.index.duplicated()].unique()
105            raise ValueError(f"Duplicate sample IDs: {list(dupes)}")
106        if self.group_col not in self.table.columns:
107            raise ValueError(
108                f"Column '{self.group_col}' missing; have {list(self.table.columns)}"
109            )
110
111    # --- constructors ----------------------------------------------------
112
113    @classmethod
114    def from_sra_run_table(
115        cls,
116        path,
117        factors=None,
118        id_col=None,
119        assay=None,
120    ) -> "Metadata":
121        """Read SraRunTable.csv from the NCBI SRA Run Selector.
122
123        factors
124            Columns that define a condition. If None, they are detected
125            automatically: every non-constant, non-ID column that looks like
126            an experimental factor.
127        assay
128            Keep only rows whose 'Assay Type' matches (e.g. 'RNA-Seq'), for
129            run tables that mix RNA-Seq and ChIP-Seq.
130        """
131        df = pd.read_csv(path, sep=None, engine="python")
132
133        if assay is not None and "Assay Type" in df.columns:
134            before = len(df)
135            df = df[df["Assay Type"] == assay]
136            print(f"[Metadata] assay filter '{assay}': kept {len(df)}/{before} runs")
137            if df.empty:
138                raise ValueError(
139                    f"No rows with Assay Type == '{assay}'; "
140                    f"available: {sorted(pd.read_csv(path)['Assay Type'].unique())}"
141                )
142
143        id_col = id_col or next(
144            (c for c in _ID_CANDIDATES if c in df.columns), None
145        )
146        if id_col is None:
147            raise ValueError(
148                f"No run-ID column found; expected one of {_ID_CANDIDATES}"
149            )
150        df = df.set_index(id_col).drop(columns=_DROP, errors="ignore")
151
152        # split off values that are constant across the whole study
153        study = {}
154        for col in list(df.columns):
155            if col in _STUDY_LEVEL and df[col].nunique(dropna=False) == 1:
156                study[col] = df[col].iloc[0]
157                df = df.drop(columns=col)
158
159        # normalise the biological factors that we recognise
160        if "genotype" not in df.columns and "source_name" in df.columns:
161            df["genotype"] = (
162                df["source_name"]
163                .str.replace("MG1655", "", regex=False)
164                .str.strip()
165                .replace("", "wt")
166            )
167        if "genotype" in df.columns:
168            df["genotype"] = df["genotype"].map(_slug)
169        if "supplement" in df.columns:
170            df["supplement"] = df["supplement"].replace({"--": "none"}).map(_slug)
171        if "media" in df.columns:
172            df["media"] = df["media"].map(_slug)
173
174        if factors is None:
175            factors = _detect_factors(df)
176            print(f"[Metadata] detected factors: {list(factors)}")
177
178        missing = [f for f in factors if f not in df.columns]
179        if missing:
180            raise ValueError(
181                f"Factors {missing} not in table; have {list(df.columns)}"
182            )
183
184        df["condition"] = df[list(factors)].astype(str).agg("_".join, axis=1)
185        return cls(df, study=study)
186
187    @classmethod
188    def from_groups(cls, samples, groups, name="condition") -> "Metadata":
189        """Manual fallback: one group label per sample."""
190        if isinstance(groups, dict):
191            groups = [groups[s] for s in samples]
192        if len(groups) != len(samples):
193            raise ValueError(f"{len(groups)} groups for {len(samples)} samples")
194        return cls(
195            pd.DataFrame({name: list(groups)}, index=list(samples)),
196            group_col=name,
197        )
198
199    # --- access ----------------------------------------------------------
200
201    @property
202    def groups(self) -> pd.Series:
203        return self.table[self.group_col]
204
205    @property
206    def samples(self) -> list:
207        return list(self.table.index)
208
209    @property
210    def organism(self) -> str | None:
211        return self.study.get("Organism")
212
213    def factor_levels(self) -> dict:
214        """{'genotype': ['wt', 'del_atoC', ...], 'media': [...]}
215
216        Skips constants and ID-like columns (one distinct value per sample).
217        """
218        return {
219            c: sorted(self.table[c].unique())
220            for c in self.table.columns
221            if c != self.group_col and 1 < self.table[c].nunique() < len(self.table)
222        }
223
224    def replicates(self) -> pd.Series:
225        return self.groups.value_counts()
226
227    def align(self, counts: pd.DataFrame) -> "Metadata":
228        """Reorder rows to match count-matrix columns; tolerates BAM paths."""
229        ids = [
230            m.group() if (m := re.search(r"[SED]RR\d+", str(c))) else str(c)
231            for c in counts.columns
232        ]
233        missing = set(ids) - set(self.table.index)
234        if missing:
235            raise ValueError(f"Not in metadata: {sorted(missing)}")
236
237        sub = self.table.loc[ids].copy()
238        sub.index = counts.columns
239        return Metadata(sub, study=self.study, group_col=self.group_col)
240
241    def subset_samples(self, samples) -> "Metadata":
242        """Keep the given sample IDs, in the given order."""
243        missing = set(samples) - set(self.table.index)
244        if missing:
245            raise ValueError(f"Not in metadata: {sorted(missing)}")
246        return Metadata(self.table.loc[list(samples)], study=self.study,
247                        group_col=self.group_col)
248
249    def subset(self, keep=None, **criteria) -> "Metadata":
250        """subset(keep=[...]) by group, or subset(media='M9') by factor."""
251        mask = pd.Series(True, index=self.table.index)
252        if keep is not None:
253            mask &= self.groups.isin(keep)
254        for col, want in criteria.items():
255            want = want if isinstance(want, (list, tuple, set)) else [want]
256            mask &= self.table[col].isin(want)
257        return Metadata(self.table[mask], study=self.study,
258                        group_col=self.group_col)
259
260    def __len__(self):
261        return len(self.table)
262
263    def __repr__(self):
264        org = self.organism or "unknown organism"
265        return (f"<Metadata: {len(self)} samples, "
266                f"{self.groups.nunique()} groups, {org}>")

Per-sample annotation plus study-level constants.

table: index = run IDs, columns = experimental factors study: values identical across all samples (organism, platform, ...)

Metadata( table: pandas.core.frame.DataFrame, study: dict = <factory>, group_col: str = 'condition')
table: pandas.core.frame.DataFrame
study: dict
group_col: str = 'condition'
@classmethod
def from_sra_run_table( cls, path, factors=None, id_col=None, assay=None) -> Metadata:
113    @classmethod
114    def from_sra_run_table(
115        cls,
116        path,
117        factors=None,
118        id_col=None,
119        assay=None,
120    ) -> "Metadata":
121        """Read SraRunTable.csv from the NCBI SRA Run Selector.
122
123        factors
124            Columns that define a condition. If None, they are detected
125            automatically: every non-constant, non-ID column that looks like
126            an experimental factor.
127        assay
128            Keep only rows whose 'Assay Type' matches (e.g. 'RNA-Seq'), for
129            run tables that mix RNA-Seq and ChIP-Seq.
130        """
131        df = pd.read_csv(path, sep=None, engine="python")
132
133        if assay is not None and "Assay Type" in df.columns:
134            before = len(df)
135            df = df[df["Assay Type"] == assay]
136            print(f"[Metadata] assay filter '{assay}': kept {len(df)}/{before} runs")
137            if df.empty:
138                raise ValueError(
139                    f"No rows with Assay Type == '{assay}'; "
140                    f"available: {sorted(pd.read_csv(path)['Assay Type'].unique())}"
141                )
142
143        id_col = id_col or next(
144            (c for c in _ID_CANDIDATES if c in df.columns), None
145        )
146        if id_col is None:
147            raise ValueError(
148                f"No run-ID column found; expected one of {_ID_CANDIDATES}"
149            )
150        df = df.set_index(id_col).drop(columns=_DROP, errors="ignore")
151
152        # split off values that are constant across the whole study
153        study = {}
154        for col in list(df.columns):
155            if col in _STUDY_LEVEL and df[col].nunique(dropna=False) == 1:
156                study[col] = df[col].iloc[0]
157                df = df.drop(columns=col)
158
159        # normalise the biological factors that we recognise
160        if "genotype" not in df.columns and "source_name" in df.columns:
161            df["genotype"] = (
162                df["source_name"]
163                .str.replace("MG1655", "", regex=False)
164                .str.strip()
165                .replace("", "wt")
166            )
167        if "genotype" in df.columns:
168            df["genotype"] = df["genotype"].map(_slug)
169        if "supplement" in df.columns:
170            df["supplement"] = df["supplement"].replace({"--": "none"}).map(_slug)
171        if "media" in df.columns:
172            df["media"] = df["media"].map(_slug)
173
174        if factors is None:
175            factors = _detect_factors(df)
176            print(f"[Metadata] detected factors: {list(factors)}")
177
178        missing = [f for f in factors if f not in df.columns]
179        if missing:
180            raise ValueError(
181                f"Factors {missing} not in table; have {list(df.columns)}"
182            )
183
184        df["condition"] = df[list(factors)].astype(str).agg("_".join, axis=1)
185        return cls(df, study=study)

Read SraRunTable.csv from the NCBI SRA Run Selector.

factors Columns that define a condition. If None, they are detected automatically: every non-constant, non-ID column that looks like an experimental factor. assay Keep only rows whose 'Assay Type' matches (e.g. 'RNA-Seq'), for run tables that mix RNA-Seq and ChIP-Seq.

@classmethod
def from_groups(cls, samples, groups, name='condition') -> Metadata:
187    @classmethod
188    def from_groups(cls, samples, groups, name="condition") -> "Metadata":
189        """Manual fallback: one group label per sample."""
190        if isinstance(groups, dict):
191            groups = [groups[s] for s in samples]
192        if len(groups) != len(samples):
193            raise ValueError(f"{len(groups)} groups for {len(samples)} samples")
194        return cls(
195            pd.DataFrame({name: list(groups)}, index=list(samples)),
196            group_col=name,
197        )

Manual fallback: one group label per sample.

groups: pandas.core.series.Series
201    @property
202    def groups(self) -> pd.Series:
203        return self.table[self.group_col]
samples: list
205    @property
206    def samples(self) -> list:
207        return list(self.table.index)
organism: str | None
209    @property
210    def organism(self) -> str | None:
211        return self.study.get("Organism")
def factor_levels(self) -> dict:
213    def factor_levels(self) -> dict:
214        """{'genotype': ['wt', 'del_atoC', ...], 'media': [...]}
215
216        Skips constants and ID-like columns (one distinct value per sample).
217        """
218        return {
219            c: sorted(self.table[c].unique())
220            for c in self.table.columns
221            if c != self.group_col and 1 < self.table[c].nunique() < len(self.table)
222        }

{'genotype': ['wt', 'del_atoC', ...], 'media': [...]}

Skips constants and ID-like columns (one distinct value per sample).

def replicates(self) -> pandas.core.series.Series:
224    def replicates(self) -> pd.Series:
225        return self.groups.value_counts()
def align( self, counts: pandas.core.frame.DataFrame) -> Metadata:
227    def align(self, counts: pd.DataFrame) -> "Metadata":
228        """Reorder rows to match count-matrix columns; tolerates BAM paths."""
229        ids = [
230            m.group() if (m := re.search(r"[SED]RR\d+", str(c))) else str(c)
231            for c in counts.columns
232        ]
233        missing = set(ids) - set(self.table.index)
234        if missing:
235            raise ValueError(f"Not in metadata: {sorted(missing)}")
236
237        sub = self.table.loc[ids].copy()
238        sub.index = counts.columns
239        return Metadata(sub, study=self.study, group_col=self.group_col)

Reorder rows to match count-matrix columns; tolerates BAM paths.

def subset_samples(self, samples) -> Metadata:
241    def subset_samples(self, samples) -> "Metadata":
242        """Keep the given sample IDs, in the given order."""
243        missing = set(samples) - set(self.table.index)
244        if missing:
245            raise ValueError(f"Not in metadata: {sorted(missing)}")
246        return Metadata(self.table.loc[list(samples)], study=self.study,
247                        group_col=self.group_col)

Keep the given sample IDs, in the given order.

def subset(self, keep=None, **criteria) -> Metadata:
249    def subset(self, keep=None, **criteria) -> "Metadata":
250        """subset(keep=[...]) by group, or subset(media='M9') by factor."""
251        mask = pd.Series(True, index=self.table.index)
252        if keep is not None:
253            mask &= self.groups.isin(keep)
254        for col, want in criteria.items():
255            want = want if isinstance(want, (list, tuple, set)) else [want]
256            mask &= self.table[col].isin(want)
257        return Metadata(self.table[mask], study=self.study,
258                        group_col=self.group_col)

subset(keep=[...]) by group, or subset(media='M9') by factor.