| |
| """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: |
| |
| 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) |
|
|
| |
| 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() |
|
|