Semblance / core /parse.py
yueyvettehao's picture
Upload full app (app.py + core/agents/mcp_server/ui/assets/examples/...)
fb2ae51 verified
Raw
History Blame Contribute Delete
5.09 kB
"""Parse GSEA tables → canonical `GseaResult`.
Mapping-driven, not name-trusting: a column *mapping* (canonical field -> source column)
decides what becomes what. The default mapping is for clusterProfiler GSEA output.
The leading_edge trap: clusterProfiler emits a column literally named `leading_edge` that holds
a stats string ("tags=77%, list=28%, signal=77%") — NOT genes. The genes live in
`core_enrichment` (slash-separated). The default mapping therefore reads genes from
`core_enrichment`, exactly as the canonical schema intends.
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import Optional
import pandas as pd
from core.schema import GseaResult, PathwayRow
# Canonical field -> source column (clusterProfiler GSEA). pval/size/leading_edge optional.
CLUSTERPROFILER_MAPPING: dict[str, str] = {
"pathway": "Description",
"nes": "NES",
"padj": "p.adjust",
"pval": "pvalue",
"size": "setSize",
"leading_edge": "core_enrichment", # genes — NOT the literal `leading_edge` column
}
REQUIRED_FIELDS = ("pathway", "nes", "padj")
def default_mapping() -> dict[str, str]:
"""The clusterProfiler GSEA column mapping (a copy, safe to mutate)."""
return dict(CLUSTERPROFILER_MAPPING)
def read_table(path: str | Path, sep: Optional[str] = None) -> pd.DataFrame:
"""Read csv/tsv/xlsx into a DataFrame, dispatching on file extension."""
path = Path(path)
suffix = path.suffix.lower()
if suffix in (".xlsx", ".xls"):
return pd.read_excel(path)
if sep is None:
sep = "\t" if suffix in (".tsv", ".txt") else ","
return pd.read_csv(path, sep=sep)
def _split_genes(value) -> list[str]:
"""Split a clusterProfiler `core_enrichment` cell ('IFIT1/STAT1/...') into symbols."""
if value is None or (isinstance(value, float) and math.isnan(value)):
return []
return [g.strip() for g in str(value).split("/") if g.strip()]
def _to_float(value) -> Optional[float]:
try:
f = float(value)
except (TypeError, ValueError):
return None
# Reject NaN AND ±inf: R/clusterProfiler can serialize infinities as 'Inf', and an infinite
# NES would poison the |NES|-weighted centroid (→ NaN vector → scipy.linkage crash).
return f if math.isfinite(f) else None
def _to_int(value) -> Optional[int]:
f = _to_float(value)
return None if f is None else int(round(f))
def _infer_collection(pathways: list[str]) -> Optional[str]:
"""Infer the MSigDB collection from a shared pathway-name prefix (e.g. HALLMARK_)."""
prefixes = {p.split("_", 1)[0] for p in pathways if "_" in p}
return prefixes.pop() if len(prefixes) == 1 else None
def apply_mapping(
df: pd.DataFrame,
mapping: Optional[dict[str, str]] = None,
name: str = "result",
collection: Optional[str] = None,
) -> GseaResult:
"""Transform a DataFrame into a canonical `GseaResult` via a column mapping.
Deterministic: code applies the mapping; nothing here rewrites data rows. Rows with a
non-numeric/missing NES or padj are dropped (warned by the engine downstream).
"""
mapping = mapping or default_mapping()
for field in REQUIRED_FIELDS:
col = mapping.get(field)
if col is None or col not in df.columns:
raise ValueError(
f"required field '{field}' maps to column '{col}', which is absent. "
f"available columns: {list(df.columns)}"
)
has_pval = mapping.get("pval") in df.columns
has_size = mapping.get("size") in df.columns
has_le = mapping.get("leading_edge") in df.columns
rows: list[PathwayRow] = []
for _, r in df.iterrows():
nes = _to_float(r[mapping["nes"]])
padj = _to_float(r[mapping["padj"]])
pathway = r[mapping["pathway"]]
if nes is None or padj is None or pd.isna(pathway):
continue # un-parseable / blank row
rows.append(PathwayRow(
pathway=str(pathway).strip(),
nes=nes,
padj=padj,
pval=_to_float(r[mapping["pval"]]) if has_pval else None,
size=_to_int(r[mapping["size"]]) if has_size else None,
leading_edge=_split_genes(r[mapping["leading_edge"]]) if has_le else [],
))
if collection is None:
collection = _infer_collection([row.pathway for row in rows])
return GseaResult(name=name, collection=collection, rows=rows)
def parse_gsea_file(
path: str | Path,
mapping: Optional[dict[str, str]] = None,
name: Optional[str] = None,
collection: Optional[str] = None,
sep: Optional[str] = None,
) -> GseaResult:
"""Read a GSEA file and normalize it to a `GseaResult` (default = clusterProfiler mapping)."""
path = Path(path)
name = name or path.stem
return apply_mapping(read_table(path, sep=sep), mapping=mapping, name=name, collection=collection)
def to_canonical_dict(result: GseaResult) -> dict:
"""JSON-serializable canonical dict, ready to ship over the MCP boundary."""
return result.model_dump()