"""The cohort abstraction: one interface, a SPARK and an SSC backend behind it.
Every analysis is written once against :class:`Cohort` and the harmonised
:class:`CohortMatrix` it yields, so the reference fit, the stability checks, and the SSC
replication run on either cohort without change (plan section 10). Each backend maps its
raw tables onto the shared schema; the SPARK-only timing fields (age at diagnosis, era)
are exposed as an optional capability that the SSC backend need not provide.
The shared helpers here resolve a table's source CSV through the ``dscat`` catalogue and
read only the columns a stage needs, so a backend never loads a whole file into memory.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, runtime_checkable
import pandas as pd
from dscat import queries
from dscat.index import Catalogue
from dscat.paths import index_path
from analysis import config
from analysis.paths import find_repo_root
INDEX_COLUMN = "subject_sp_id"
[docs]
@dataclass
class CohortMatrix:
"""A harmonised proband-by-feature matrix with its covariates and provenance.
Attributes
----------
features : pandas.DataFrame
Proband-by-feature matrix, indexed by proband id, holding only the requested
clustered features (covariates excluded).
covariates : pandas.DataFrame
Proband-by-covariate matrix on the same index, holding the structural-model
covariates.
dataset : str
Cohort the matrix was built from.
version : str
Dataset version the matrix was built from.
"""
features: pd.DataFrame
covariates: pd.DataFrame
dataset: str
version: str
@property
def feature_names(self) -> list[str]:
"""Return the feature column names."""
return list(self.features.columns)
@property
def n_probands(self) -> int:
"""Return the number of probands (rows)."""
return int(len(self.features))
[docs]
@runtime_checkable
class Cohort(Protocol):
"""The contract every cohort backend satisfies.
A backend integrates its instruments into one harmonised, complete-case
proband-by-column frame (covariates and the shared feature schema), and declares
whether it can provide the SPARK-only diagnosis-timing fields used for the
stratification axes (plan sections 5 and 7).
"""
dataset: str
version: str
[docs]
def integrate(self) -> pd.DataFrame:
"""Return the harmonised, complete-case proband-by-column frame."""
...
[docs]
def supports_timing(self) -> bool:
"""Return whether the backend can provide diagnosis-timing fields."""
...
[docs]
def open_catalogue(root: Path) -> Catalogue:
"""Open the ``dscat`` catalogue at the repository root."""
return Catalogue.open(index_path(root))
[docs]
def source_csv(
cat: Catalogue, root: Path, dataset: str, version: str, table: str, role: str = ""
) -> Path:
"""Return the absolute path to a table's backing CSV.
Parameters
----------
cat : Catalogue
Open catalogue.
root : Path
Repository root, used to resolve the catalogue's repo-relative path.
dataset, version, table : str
The table to locate.
role : str, default ""
Family role for cohorts that split a measure across role folders (SSC). SPARK
tables use the empty role.
Returns
-------
Path
Absolute path to the CSV.
Raises
------
FileNotFoundError
When no source row matches ``table`` and ``role``.
"""
for row in queries.feature_sources(cat, dataset, version, table):
if (row["role"] or "") == role:
return root / row["file_path"]
raise FileNotFoundError(f"no source CSV for {dataset}/{version} {table!r} role={role!r}")
[docs]
def csv_columns(path: Path) -> list[str]:
"""Return a CSV's column names without reading its rows."""
return list(pd.read_csv(path, nrows=0).columns)
[docs]
def read_columns(path: Path, columns: Sequence[str]) -> pd.DataFrame:
"""Read only the requested columns of a CSV, skipping any that are absent.
Parameters
----------
path : Path
CSV to read.
columns : sequence of str
Columns to keep; columns not present in the file are ignored.
Returns
-------
pandas.DataFrame
The requested columns that exist in the file.
"""
wanted = set(columns)
return pd.read_csv(path, usecols=lambda c: c in wanted)
[docs]
def build_matrix(
integrated: pd.DataFrame,
feature_names: Sequence[str],
dataset: str,
version: str,
covariates: Sequence[str] = config.COVARIATES,
) -> CohortMatrix:
"""Split an integrated frame into a feature matrix and a covariate matrix.
Parameters
----------
integrated : pandas.DataFrame
The backend's harmonised, complete-case frame.
feature_names : sequence of str
The clustered features to keep.
dataset, version : str
Provenance recorded on the matrix.
covariates : sequence of str, optional
Covariate columns to split out. Defaults to the structural-model covariates.
Returns
-------
CohortMatrix
The feature and covariate matrices on a shared index.
Raises
------
KeyError
When a requested feature or covariate is absent from ``integrated``.
"""
missing_features = [f for f in feature_names if f not in integrated.columns]
if missing_features:
raise KeyError(f"{len(missing_features)} requested features absent: {missing_features[:8]}")
missing_covariates = [c for c in covariates if c not in integrated.columns]
if missing_covariates:
raise KeyError(f"covariates absent: {missing_covariates}")
return CohortMatrix(
features=integrated.loc[:, list(feature_names)].copy(),
covariates=integrated.loc[:, list(covariates)].copy(),
dataset=dataset,
version=version,
)
[docs]
def get_cohort(
dataset: str, version: str, root: Path | None = None, *, as_of: str | None = None
) -> Cohort:
"""Return the backend for a dataset.
Parameters
----------
dataset : str
``"spark"`` or ``"ssc"``.
version : str
Dataset version.
root : Path, optional
Repository root. Defaults to the discovered root.
as_of : str, optional
A records cutoff passed to the SPARK backend (for example ``"2022-12-12"``); it
restricts the cohort to the probands present at that freeze. Ignored by cohorts that
do not carry the timing fields the cutoff needs (the SSC).
Returns
-------
Cohort
The backend.
Raises
------
ValueError
When ``dataset`` is not a known cohort.
"""
root = root or find_repo_root()
if dataset == "spark":
from analysis.cohort.spark import SparkCohort
return SparkCohort(root, version, as_of=as_of)
if dataset == "ssc":
from analysis.cohort.ssc import SscCohort
return SscCohort(root, version)
raise ValueError(f"unknown cohort {dataset!r}")