Rhaister / scripts /data_prep /compute_pdex.py
Shreshth Gandhi
Import Rhaister main branch from GitHub source
5a72781
Raw
History Blame Contribute Delete
5.87 kB
#!/usr/bin/env python
"""Run pdex differential expression from h5ad files.
For each h5ad file, computes per-gene fold change, p-value, and FDR by
comparing each treatment condition to a control using the pdex library
(Mann-Whitney U test). Results are stratified by a grouping column
(e.g. cell_line for Tahoe, donor for Parse) so that each comparison uses
only cells from the same batch/group.
All dataset-specific parameters are read from a YAML config file.
Usage:
# Tahoe: per-plate, stratified by cell_line, DMSO control
python scripts/data_prep/compute_pdex.py configs/tahoe.yaml
# Parse: per-cell_type, stratified by donor, PBS control
python scripts/data_prep/compute_pdex.py configs/parse.yaml
# Process specific files only
python scripts/data_prep/compute_pdex.py configs/tahoe.yaml \
--files plate1_full_filtered.h5ad.gz plate2_full_filtered.h5ad.gz
Dependencies: anndata, polars, pdex
"""
import argparse
import gc
import glob
import time
from pathlib import Path
import anndata as ad
import polars as pl
import yaml
from pdex import pdex
def process_group(adata_subset, groupby, reference, threads, group_label, file_label):
"""Run pdex on a subset of cells (one group within one file)."""
n_groups = adata_subset.obs[groupby].nunique()
if n_groups < 2 or reference not in adata_subset.obs[groupby].values:
return None, f"skip ({adata_subset.n_obs} cells, {n_groups} groups, no control)"
t0 = time.time()
result = pdex(
adata_subset,
groupby=groupby,
mode="ref",
reference=reference,
is_log1p=False,
threads=threads,
)
dt = time.time() - t0
return result, f"ok ({len(result)} rows, {dt:.0f}s)"
def process_file(h5ad_path, cfg, global_cfg, output_dir):
"""Process one h5ad file: stratify by group_col, run pdex per group."""
file_stem = Path(h5ad_path).name.split(".h5ad")[0]
file_out = output_dir / f"{file_stem}_pdex.parquet"
if file_out.exists():
print(f" {file_stem}: already done, skipping")
return file_out
print(f"\n{'=' * 60}")
print(f"Processing {file_stem}")
print(f"{'=' * 60}")
t0 = time.time()
adata = ad.read_h5ad(h5ad_path)
print(f" Loaded {adata.n_obs:,} cells in {time.time() - t0:.0f}s")
group_col = global_cfg["group_col"]
groupby = global_cfg["treatment_col"]
reference = global_cfg["control"]
threads = cfg.get("threads", 32)
file_id_col = global_cfg.get("file_id_col")
groups = sorted(adata.obs[group_col].unique())
print(f" {len(groups)} groups in {group_col}")
all_results = []
for i, grp in enumerate(groups):
grp_out = output_dir / f"{file_stem}_{group_col}_{grp}.parquet"
if grp_out.exists():
print(f" [{i + 1}/{len(groups)}] {grp}: cached")
all_results.append(pl.read_parquet(grp_out))
continue
subset = adata[adata.obs[group_col] == grp].copy()
result, status = process_group(subset, groupby, reference, threads, grp, file_stem)
print(f" [{i + 1}/{len(groups)}] {grp}: {status}")
if result is not None:
# Tag with metadata
extra_cols = [pl.lit(grp).alias(group_col)]
if file_id_col:
extra_cols.append(pl.lit(file_stem).alias(file_id_col))
result = result.with_columns(extra_cols)
result.write_parquet(grp_out)
all_results.append(result)
del subset
gc.collect()
del adata
gc.collect()
if all_results:
combined = pl.concat(all_results)
combined.write_parquet(file_out)
print(f" {file_stem}: {len(combined):,} rows -> {file_out.name} ({time.time() - t0:.0f}s)")
else:
print(f" {file_stem}: no results")
return file_out if all_results else None
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("config", type=str, help="YAML config file (e.g. configs/tahoe.yaml)")
parser.add_argument("--output_dir", type=str, default=None, help="Override output directory from config")
parser.add_argument("--files", nargs="+", default=None, help="Process only these h5ad files (basenames)")
args = parser.parse_args()
with open(args.config) as f:
global_cfg = yaml.safe_load(f)
cfg = global_cfg.get("pdex", {})
output_dir = Path(args.output_dir or cfg["output_dir"])
output_dir.mkdir(parents=True, exist_ok=True)
h5ad_dir = Path(global_cfg["h5ad_dir"])
pattern = global_cfg.get("h5ad_pattern", "*.h5ad*")
h5ad_files = sorted(glob.glob(str(h5ad_dir / pattern)))
if args.files:
requested = set(args.files)
h5ad_files = [f for f in h5ad_files if Path(f).name in requested]
print(f"Dataset: {global_cfg['dataset']}")
print(f"h5ad files: {len(h5ad_files)} in {h5ad_dir}")
print(f"Group col: {global_cfg['group_col']}")
print(f"Treatment col: {global_cfg['treatment_col']}")
print(f"Control: {global_cfg['control']}")
print(f"Output: {output_dir}")
grand_t0 = time.time()
result_files = []
for h5ad_path in h5ad_files:
result = process_file(h5ad_path, cfg, global_cfg, output_dir)
if result:
result_files.append(result)
# Combine all per-file parquets
if result_files:
all_data = pl.concat([pl.read_parquet(f) for f in result_files])
combined_name = f"all_{global_cfg['dataset']}_pdex.parquet"
combined_path = output_dir / combined_name
all_data.write_parquet(combined_path)
print(f"\nAll files combined: {len(all_data):,} rows -> {combined_path}")
print(f"\nTotal time: {time.time() - grand_t0:.0f}s")
if __name__ == "__main__":
main()