from __future__ import annotations import argparse import re from pathlib import Path import pandas as pd FULL_COLUMNS = [ "RID", "PTID", "label", "VISCODE", "EXAMDATE", "DX_bl", "DX", "AGE", "PTGENDER", "PTEDUCAT", "PTETHCAT", "PTRACCAT", "PTMARRY", "APOE4", "FDG", "PIB", "AV45", "ABETA", "TAU", "PTAU", "CDRSB", "ADAS11", "ADAS13", "ADASQ4", "MMSE", "RAVLT_immediate", "RAVLT_learning", "RAVLT_forgetting", "RAVLT_perc_forgetting", "LDELTOTAL", "DIGITSCOR", "TRABSCOR", "FAQ", "MOCA", "Years_bl", ] CONVERSION_COLUMNS = [ "PTID", "label", "DX.1", "mPACCdigit", "mPACCtrailsB", "Ventricles(心室)", "Hippocampus(海马)", "WholeBrain(全脑)", "Entorhinal(内嗅觉)", "Fusiform(梭形)", "MidTemp(中点温度)", "ICV", ] def normalize_subject_id(value: object) -> str: if pd.isna(value): return "" text = str(value).strip().upper() text = text.replace("_S_", "S").replace("_", "") return re.sub(r"[^A-Z0-9]", "", text) def clean_numeric(value: object) -> float | pd.NA: if pd.isna(value): return pd.NA text = str(value).strip() if not text: return pd.NA if text.startswith(">"): text = text[1:] try: return float(text) except ValueError: return pd.NA def read_xlsx(path: Path, columns: list[str]) -> pd.DataFrame: df = pd.read_excel(path) available = [col for col in columns if col in df.columns] if "PTID" not in available: raise ValueError(f"{path} does not contain PTID.") df = df[available].copy() df["subject_id_norm"] = df["PTID"].map(normalize_subject_id) df = df[df["subject_id_norm"] != ""].copy() df = df.drop_duplicates("subject_id_norm", keep="first") return df def add_numeric_clean_columns(df: pd.DataFrame) -> pd.DataFrame: for col in ["FDG", "PIB", "AV45", "ABETA", "TAU", "PTAU"]: if col in df.columns: df[f"{col}_num"] = df[col].map(clean_numeric) return df def write_split_manifests(enriched: pd.DataFrame, out_dir: Path) -> None: out_dir.mkdir(parents=True, exist_ok=True) for split_name, split_df in enriched.groupby("split", sort=False): split_df.to_csv(out_dir / f"{split_name}_clinical.csv", index=False) def summarize(enriched: pd.DataFrame, out_md: Path, out_csv: Path) -> None: rows = [] total = len(enriched) for col in [ "clinical_label", "dx", "dx_bl", "conversion_label", "age", "sex", "apoe4", "mmse", "cdrsb", "adas11", "adas13", "faq", "moca", "fdg_adni", "av45", "abeta_num", "tau_num", "ptau_num", "hippocampus", "wholebrain", ]: if col not in enriched.columns: continue non_missing = int(enriched[col].notna().sum()) rows.append({"field": col, "non_missing": non_missing, "coverage": non_missing / total}) report = pd.DataFrame(rows) report.to_csv(out_csv, index=False) label_counts = {} for col in ["clinical_label", "dx", "dx_bl", "conversion_label"]: if col in enriched.columns: label_counts[col] = enriched[col].dropna().astype(str).value_counts().to_dict() lines = [ "# ADNI Metadata Match Report", "", f"- PET/SUVR samples: {total}", f"- Matched clinical rows: {int(enriched['clinical_label'].notna().sum())}", "", "## Field Coverage", "", "| field | non_missing | coverage |", "|---|---:|---:|", ] for row in rows: lines.append(f"| {row['field']} | {row['non_missing']} | {row['coverage']:.3f} |") lines.extend([ "", "## Label Counts", "", ]) for col, counts in label_counts.items(): lines.append(f"### {col}") lines.append("") for key, value in counts.items(): lines.append(f"- {key}: {value}") lines.append("") out_md.write_text("\n".join(lines), encoding="utf-8") def main() -> None: parser = argparse.ArgumentParser(description="Match ADNI Excel metadata to the PET/SUVR manifest.") parser.add_argument("--manifest", type=Path, default=Path("data/metadata/splits/pet_fdg_manifest_with_split.csv")) parser.add_argument("--full-xlsx", type=Path, default=Path("data/ADNIbase1416_info.xlsx")) parser.add_argument("--conversion-xlsx", type=Path, default=Path("data/adni_1203s_info_fix.xlsx")) parser.add_argument("--out", type=Path, default=Path("data/metadata/adni_matched_clinical.csv")) parser.add_argument("--split-out-dir", type=Path, default=Path("data/metadata/splits")) parser.add_argument("--report-md", type=Path, default=Path("data/metadata/adni_match_report.md")) parser.add_argument("--report-csv", type=Path, default=Path("data/metadata/adni_match_report.csv")) args = parser.parse_args() manifest = pd.read_csv(args.manifest) manifest["subject_id_norm"] = manifest["subject_id"].map(normalize_subject_id) full = read_xlsx(args.full_xlsx, FULL_COLUMNS) full = add_numeric_clean_columns(full) full = full.rename( columns={ "label": "clinical_label", "DX": "dx", "DX_bl": "dx_bl", "AGE": "age", "PTGENDER": "sex", "PTEDUCAT": "education", "PTETHCAT": "ethnicity", "PTRACCAT": "race", "PTMARRY": "marital_status", "APOE4": "apoe4", "FDG": "fdg_adni", "PIB": "pib", "AV45": "av45", "ABETA": "abeta", "TAU": "tau", "PTAU": "ptau", "ABETA_num": "abeta_num", "TAU_num": "tau_num", "PTAU_num": "ptau_num", "CDRSB": "cdrsb", "ADAS11": "adas11", "ADAS13": "adas13", "ADASQ4": "adasq4", "MMSE": "mmse", "RAVLT_immediate": "ravlt_immediate", "RAVLT_learning": "ravlt_learning", "RAVLT_forgetting": "ravlt_forgetting", "RAVLT_perc_forgetting": "ravlt_perc_forgetting", "LDELTOTAL": "ldeltotal", "DIGITSCOR": "digitscor", "TRABSCOR": "trabscor", "FAQ": "faq", "MOCA": "moca", "Years_bl": "years_bl", } ) conversion = read_xlsx(args.conversion_xlsx, CONVERSION_COLUMNS) conversion = conversion.rename( columns={ "label": "conversion_label", "DX.1": "dx_followup", "mPACCdigit": "mpacc_digit", "mPACCtrailsB": "mpacc_trailsb", "Ventricles(心室)": "ventricles", "Hippocampus(海马)": "hippocampus", "WholeBrain(全脑)": "wholebrain", "Entorhinal(内嗅觉)": "entorhinal", "Fusiform(梭形)": "fusiform", "MidTemp(中点温度)": "midtemp", } ) conversion = conversion.drop(columns=["PTID"], errors="ignore") enriched = manifest.merge(full.drop(columns=["PTID"], errors="ignore"), on="subject_id_norm", how="left") enriched = enriched.merge(conversion, on="subject_id_norm", how="left") enriched = enriched.drop(columns=["subject_id_norm"]) args.out.parent.mkdir(parents=True, exist_ok=True) enriched.to_csv(args.out, index=False) write_split_manifests(enriched, args.split_out_dir) summarize(enriched, args.report_md, args.report_csv) print(f"wrote={args.out}") print(f"wrote_splits={args.split_out_dir}/*_clinical.csv") print(f"wrote_report={args.report_md}") print(f"samples={len(enriched)} matched={int(enriched['clinical_label'].notna().sum())}") if __name__ == "__main__": main()