Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Extract all data and model results from the mixed-effects notebook | |
| and save them as self-contained files in ./data/ for the Streamlit dashboard. | |
| Run once on the cluster, then the entire Stroke_Dashboard/ directory can be | |
| moved to any machine. | |
| """ | |
| import warnings, json, pickle | |
| from pathlib import Path | |
| from itertools import combinations | |
| import numpy as np | |
| import pandas as pd | |
| from scipy.special import expit | |
| import statsmodels.formula.api as smf | |
| OUT = Path(__file__).parent / "data" | |
| OUT.mkdir(exist_ok=True) | |
| # ββ 1. Discover CSVs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| RUNS_ROOT = Path("/home/rbielski/stroke_cleaned/ARC_ATLAS_Combined/ARC_ATLAS_Train_v3/runs") | |
| RUN_DIRS = sorted(RUNS_ROOT.glob("*/test_eval")) | |
| assert RUN_DIRS, f"No test_eval directories found under {RUNS_ROOT}" | |
| def latest_csv_any(pattern, img_path_hint=None): | |
| matches = sorted(path for run in RUN_DIRS for path in run.glob(pattern)) | |
| if not matches: | |
| return None | |
| if img_path_hint is None: | |
| return matches[-1] | |
| hinted = [] | |
| for path in matches: | |
| try: | |
| probe = pd.read_csv(path, usecols=["img_path"], nrows=5) | |
| except Exception: | |
| continue | |
| if probe["img_path"].astype(str).str.contains(img_path_hint, regex=False).any(): | |
| hinted.append(path) | |
| return hinted[-1] if hinted else matches[-1] | |
| VARIANT_GLOBS = [ | |
| {"variant": "hires", "cohort": "hires", "role": "natural", | |
| "glob": "test_hires_metrics_with_manifest_*.csv"}, | |
| {"variant": "lower_resolution", "cohort": "lores", "role": "holdout", | |
| "glob": "test_lores_metrics_with_manifest_*.csv", "img_path_hint": "/test_lores/"}, | |
| ] | |
| for family, prefix in [ | |
| ("crude", "crude"), | |
| ("thick_slice", "thickslices"), | |
| ("inplane_coarsening", "inplane"), | |
| ("reduced_snr", "reducedsnr"), | |
| ("rigid_jitter", "rigidjitter"), | |
| ]: | |
| for level in range(1, 6): | |
| VARIANT_GLOBS.append({ | |
| "variant": f"{family}_v{level}", | |
| "cohort": "hires", | |
| "role": "degraded", | |
| "glob": f"test_{prefix}_v{level}_metrics_with_manifest_*.csv", | |
| }) | |
| FILE_SPECS = [] | |
| for entry in VARIANT_GLOBS: | |
| path = latest_csv_any(entry["glob"], entry.get("img_path_hint")) | |
| if path is None: | |
| print(f"[warn] no CSV for variant '{entry['variant']}'; skipping.") | |
| continue | |
| FILE_SPECS.append({ | |
| "variant": entry["variant"], "cohort": entry["cohort"], | |
| "role": entry["role"], "path": str(path), | |
| }) | |
| # ββ 2. Load & merge ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| REQUIRED = ["key", "soft_dice", "img_path", "mf_fwhm_mm", "mf_hi_freq_energy", "mf_lap_var"] | |
| dfs = {} | |
| for spec in FILE_SPECS: | |
| df = pd.read_csv(spec["path"]) | |
| if "dataset_tag" not in df.columns: | |
| df["dataset_tag"] = spec["cohort"] | |
| df["cohort"] = spec["cohort"] | |
| df["variant"] = spec["variant"] | |
| df["role"] = spec["role"] | |
| dfs[spec["variant"]] = df | |
| all_df = pd.concat(dfs.values(), ignore_index=True) | |
| # Image-level quality metrics | |
| QM_DIR = Path("/home/rbielski/stroke_cleaned/ARC_ATLAS_Combined/ARC_ATLAS_Train_v3/Image_Quality_Metrics") | |
| qm_csvs = sorted(QM_DIR.glob("image_quality_metrics_all_variants_*.csv")) | |
| if qm_csvs: | |
| qm = pd.read_csv(qm_csvs[-1]) | |
| QM_MAP = {"test_hires": "hires", "test_lores": "lower_resolution"} | |
| for fi, fo in [("Crude","crude"),("ThickSlices","thick_slice"), | |
| ("InPlaneCoarse","inplane_coarsening"),("ReducedSNR","reduced_snr"), | |
| ("RigidJitter","rigid_jitter")]: | |
| for lv in range(1,6): | |
| QM_MAP[f"{fi}_v{lv}"] = f"{fo}_v{lv}" | |
| qm["variant"] = qm["variant"].map(QM_MAP).fillna(qm["variant"]) | |
| qm["key"] = qm["key"].astype(str) | |
| qm = qm.rename(columns={"fwhm_mm":"mf_fwhm_mm","hi_freq_energy":"mf_hi_freq_energy","lap_var":"mf_lap_var"}) | |
| qm = qm[["key","variant","mf_fwhm_mm","mf_hi_freq_energy","mf_lap_var"]] | |
| for c in ["mf_fwhm_mm","mf_hi_freq_energy","mf_lap_var"]: | |
| if c in all_df.columns: | |
| all_df = all_df.drop(columns=[c]) | |
| all_df = all_df.merge(qm, on=["key","variant"], how="left") | |
| # Lesion volume | |
| MANIFEST = Path("/home/rbielski/stroke_cleaned/ARC_ATLAS_Combined/ARC_ATLAS_Train_v4/data/splits/50_25_25/meta/_resolution_manifest_v2.csv") | |
| if MANIFEST.exists(): | |
| mf = pd.read_csv(MANIFEST)[["key","mask_ml_clean"]] | |
| mf["lesion_mm3"] = mf["mask_ml_clean"] * 1000 | |
| mf = mf[["key","lesion_mm3"]] | |
| if "lesion_mm3" in all_df.columns: | |
| all_df = all_df.drop(columns=["lesion_mm3"]) | |
| all_df = all_df.merge(mf, on="key", how="left") | |
| # ββ 3. Build design matrix (same logic as notebook) ββββββββββββββββββββββββββ | |
| DEGRADED_VARIANTS = [s["variant"] for s in FILE_SPECS if s["role"] in ("natural","degraded")] | |
| HOLDOUT_VARIANTS = [s["variant"] for s in FILE_SPECS if s["role"] == "holdout"] | |
| OUTCOME = "soft_dice" | |
| GROUP_COL = "mf_key" if ("mf_key" in all_df.columns and all_df["mf_key"].notna().all()) else "key" | |
| MODEL_QUALITY_COLS = ["mf_fwhm_mm","mf_lap_var"] | |
| ALL_QUALITY_COLS = ["mf_fwhm_mm","mf_hi_freq_energy","mf_lap_var"] | |
| needed = [OUTCOME,"cohort","variant","role","dataset_tag","key",GROUP_COL,"img_path","lesion_mm3"] + ALL_QUALITY_COLS | |
| if "mf_bin" in all_df.columns: | |
| needed.append("mf_bin") | |
| work_df = all_df[[c for c in needed if c in all_df.columns]].copy() | |
| for col in [OUTCOME] + ALL_QUALITY_COLS: | |
| work_df[col] = pd.to_numeric(work_df[col], errors="coerce") | |
| work_df[GROUP_COL] = work_df[GROUP_COL].astype(str) | |
| work_df["key"] = work_df["key"].astype(str) | |
| if "lesion_mm3" in work_df.columns: | |
| work_df["log_lesion_mm3"] = np.log1p(work_df["lesion_mm3"].clip(lower=0)) | |
| else: | |
| work_df["log_lesion_mm3"] = np.nan | |
| # Misalign flag | |
| variant_hint = work_df["variant"].astype(str).str.lower().str.contains("jitter|rigid|misalign|shift", regex=True) | |
| if "img_path" in work_df.columns: | |
| img_hint = work_df["img_path"].astype(str).str.lower().str.contains("jitter|rigid|misalign|shift", regex=True) | |
| else: | |
| img_hint = pd.Series(False, index=work_df.index) | |
| work_df["misalign"] = (variant_hint | img_hint).astype(int) | |
| train_df = work_df[work_df["variant"].isin(DEGRADED_VARIANTS)].copy() | |
| holdout_df = work_df[work_df["variant"].isin(HOLDOUT_VARIANTS)].copy() | |
| train_df = train_df.dropna(subset=[OUTCOME, GROUP_COL] + MODEL_QUALITY_COLS).copy() | |
| holdout_df = holdout_df.dropna(subset=[OUTCOME] + MODEL_QUALITY_COLS).copy() | |
| # Standardise | |
| scaler = {} | |
| for col in MODEL_QUALITY_COLS: | |
| mu = float(train_df[col].mean()) | |
| sd = float(train_df[col].std(ddof=0)) | |
| if not np.isfinite(sd) or sd < 1e-12: | |
| sd = 1.0 | |
| scaler[col] = {"mean": mu, "std": sd} | |
| zcol = f"z_{col}" | |
| train_df[zcol] = (train_df[col] - mu) / sd | |
| holdout_df[zcol] = (holdout_df[col] - mu) / sd | |
| les_mu = float(train_df["log_lesion_mm3"].mean()) | |
| les_sd = float(train_df["log_lesion_mm3"].std(ddof=0)) or 1.0 | |
| scaler["log_lesion_mm3"] = {"mean": les_mu, "std": les_sd} | |
| train_df["z_log_lesion_mm3"] = (train_df["log_lesion_mm3"] - les_mu) / les_sd | |
| holdout_df["z_log_lesion_mm3"] = (holdout_df["log_lesion_mm3"] - les_mu) / les_sd | |
| # Logit-transform | |
| eps = 1e-6 | |
| for df in (train_df, holdout_df): | |
| clipped = np.clip(df[OUTCOME].astype(float).values, eps, 1 - eps) | |
| df["logit_dice"] = np.log(clipped / (1 - clipped)) | |
| # Family column | |
| def _variant_family(variant): | |
| text = str(variant) | |
| if text in {"hires","lower_resolution"}: return text | |
| for prefix, family in [("crude_v","crude"),("thick_slice_v","thick_slice"), | |
| ("inplane_coarsening_v","inplane_coarsening"), | |
| ("reduced_snr_v","reduced_snr"),("rigid_jitter_v","rigid_jitter")]: | |
| if text.startswith(prefix): return family | |
| return text | |
| train_df["family"] = train_df["variant"].map(_variant_family) | |
| holdout_df["family"] = holdout_df["variant"].map(_variant_family) | |
| # ββ 4. Fit all 7 models βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| compare_df = train_df.copy() | |
| def fit_mixedlm(formula, data): | |
| model = smf.mixedlm(formula=formula, data=data, groups=data[GROUP_COL], re_formula="1") | |
| for method in ["bfgs","cg","powell","nm","lbfgs"]: | |
| try: | |
| with warnings.catch_warnings(record=True): | |
| warnings.simplefilter("always") | |
| result = model.fit(reml=False, method=method, maxiter=2000, disp=False) | |
| if result.converged: | |
| return result, method | |
| except Exception: | |
| pass | |
| raise RuntimeError(f"No optimizer converged for: {formula}") | |
| FAMILY_LABELS = { | |
| "hires": "Natural High-Quality", | |
| "crude": "Crude Downsample", | |
| "thick_slice": "Thick Slices", | |
| "inplane_coarsening": "In-Plane Coarse", | |
| "reduced_snr": "Reduced SNR", | |
| "rigid_jitter": "Rigid Jitter", | |
| "lower_resolution": "Natural Low-Quality", | |
| } | |
| model_specs = [ | |
| {"model":"FWHM only", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm", | |
| "role":"Unadjusted continuous blur model", | |
| "group":"Building up","has_holdout":True, | |
| "has_lapvar":False,"has_lesion":False,"has_misalign":False}, | |
| {"model":"FWHM + lesion", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm + z_log_lesion_mm3", | |
| "role":"Blur adjusted for lesion volume", | |
| "group":"Building up","has_holdout":True, | |
| "has_lapvar":False,"has_lesion":True,"has_misalign":False}, | |
| {"model":"FWHM + lesion + misalign", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm + z_log_lesion_mm3 + misalign", | |
| "role":"Blur plus lesion volume, with misalignment flag", | |
| "group":"Building up","has_holdout":True, | |
| "has_lapvar":False,"has_lesion":True,"has_misalign":True}, | |
| {"model":"FWHM + LapVar", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm + z_mf_lap_var", | |
| "role":"Add LapVar to FWHM without lesion volume", | |
| "group":"LapVar models","has_holdout":True, | |
| "has_lapvar":True,"has_lesion":False,"has_misalign":False}, | |
| {"model":"FWHM + LapVar + lesion", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm + z_mf_lap_var + z_log_lesion_mm3", | |
| "role":"Continuous quality model with lesion volume", | |
| "group":"LapVar models","has_holdout":True, | |
| "has_lapvar":True,"has_lesion":True,"has_misalign":False}, | |
| {"model":"FWHM + LapVar + lesion + misalign", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm + z_mf_lap_var + z_log_lesion_mm3 + misalign", | |
| "role":"Continuous quality model with lesion volume and misalignment", | |
| "group":"LapVar models","has_holdout":True, | |
| "has_lapvar":True,"has_lesion":True,"has_misalign":True}, | |
| {"model":"FWHM x LapVar + lesion + misalign", | |
| "formula":"logit_dice ~ z_mf_fwhm_mm * z_mf_lap_var + z_log_lesion_mm3 + misalign", | |
| "role":"Full interaction model: blur by edge-energy plus lesion and misalignment", | |
| "group":"Interaction","has_holdout":True, | |
| "has_lapvar":True,"has_lesion":True,"has_misalign":True}, | |
| ] | |
| compare_results = {} | |
| model_summaries = [] | |
| for spec in model_specs: | |
| print(f"Fitting: {spec['model']}...") | |
| result, method = fit_mixedlm(spec["formula"], compare_df) | |
| compare_results[spec["model"]] = result | |
| fe = result.fe_params | |
| ci = result.conf_int() | |
| ci.columns = ["ci_low","ci_high"] | |
| pvals = result.pvalues | |
| group_var = float(result.cov_re.iloc[0,0]) | |
| resid_var = float(result.scale) | |
| icc = group_var / (group_var + resid_var) | |
| # R-squared (Nakagawa) | |
| fe_vals = np.array(result.model.exog @ result.fe_params, dtype=float) | |
| var_fixed = float(np.var(fe_vals)) | |
| var_total = var_fixed + group_var + resid_var | |
| r2_marginal = var_fixed / var_total | |
| r2_conditional = (var_fixed + group_var) / var_total | |
| # Coefficient table | |
| coef_rows = [] | |
| for term in fe.index: | |
| coef_rows.append({ | |
| "term": term, | |
| "coef": float(fe[term]), | |
| "ci_low": float(ci.loc[term, "ci_low"]), | |
| "ci_high": float(ci.loc[term, "ci_high"]), | |
| "p_value": float(pvals.get(term, np.nan)), | |
| "is_intercept": term == "Intercept", | |
| "is_interaction": ":" in term, | |
| }) | |
| # Holdout predictions | |
| holdout_metrics = {} | |
| if spec["has_holdout"]: | |
| ho = holdout_df.copy() | |
| try: | |
| ho["pred_logit"] = result.predict(ho) | |
| ho["pred_dice"] = expit(ho["pred_logit"]) | |
| obs = ho[OUTCOME].to_numpy(float) | |
| pred = ho["pred_dice"].to_numpy(float) | |
| res = obs - pred | |
| holdout_metrics = { | |
| "MAE": float(np.mean(np.abs(res))), | |
| "RMSE": float(np.sqrt(np.mean(res**2))), | |
| "r": float(np.corrcoef(obs, pred)[0,1]), | |
| "Bias": float(np.mean(res)), | |
| } | |
| except Exception as e: | |
| print(f" Holdout prediction failed for {spec['model']}: {e}") | |
| # Fitted values for diagnostics | |
| fitted_logit = result.fittedvalues | |
| residuals = result.resid | |
| # Random effects | |
| re_dict = result.random_effects # {group_label: Series} | |
| re_vals = {str(k): float(v.iloc[0]) for k, v in re_dict.items()} | |
| model_summaries.append({ | |
| "model": spec["model"], | |
| "formula": spec["formula"], | |
| "role": spec["role"], | |
| "group": spec["group"], | |
| "has_holdout": spec["has_holdout"], | |
| "has_lapvar": spec.get("has_lapvar", False), | |
| "has_lesion": spec.get("has_lesion", False), | |
| "has_misalign": spec.get("has_misalign", False), | |
| "optimizer": method, | |
| "AIC": float(result.aic), | |
| "BIC": float(result.bic), | |
| "logLik": float(result.llf), | |
| "ICC": icc, | |
| "group_var": group_var, | |
| "resid_var": resid_var, | |
| "R2_marginal": r2_marginal, | |
| "R2_conditional": r2_conditional, | |
| "n_fixed": len(fe) - 1, | |
| "coefficients": coef_rows, | |
| "holdout_metrics": holdout_metrics, | |
| "random_effects": re_vals, | |
| "fitted_logit": fitted_logit.tolist(), | |
| "residuals": residuals.tolist(), | |
| }) | |
| print(f" AIC={result.aic:.1f} ICC={icc:.3f} R2m={r2_marginal:.3f} R2c={r2_conditional:.3f}") | |
| # ββ 5. Holdout predictions per model βββββββββββββββββββββββββββββββββββββββββ | |
| holdout_preds = {} | |
| for spec in model_specs: | |
| if not spec["has_holdout"]: | |
| continue | |
| result = compare_results[spec["model"]] | |
| ho = holdout_df.copy() | |
| try: | |
| ho["pred_logit"] = result.predict(ho) | |
| ho["pred_dice"] = expit(ho["pred_logit"]) | |
| holdout_preds[spec["model"]] = ho[["key","variant","soft_dice","pred_dice","pred_logit"]].copy() | |
| except Exception: | |
| pass | |
| # ββ 6. Misalignment contrasts ββββββββββββββββββββββββββββββββββββββββββββββ | |
| misalign_pairs = [ | |
| ("FWHM + lesion", "FWHM + lesion + misalign"), | |
| ("FWHM + LapVar + lesion", "FWHM + LapVar + lesion + misalign"), | |
| ] | |
| misalignment_contrasts = [] | |
| ms_lookup = {m["model"]: m for m in model_summaries} | |
| for without_name, with_name in misalign_pairs: | |
| if without_name not in ms_lookup or with_name not in ms_lookup: | |
| continue | |
| wo = ms_lookup[without_name] | |
| wi = ms_lookup[with_name] | |
| misalignment_contrasts.append({ | |
| "comparison": f"{with_name} vs {without_name}", | |
| "without_model": without_name, | |
| "with_model": with_name, | |
| "delta_AIC": wi["AIC"] - wo["AIC"], | |
| "delta_BIC": wi["BIC"] - wo["BIC"], | |
| "delta_holdout_MAE": (wi["holdout_metrics"].get("MAE", float("nan")) | |
| - wo["holdout_metrics"].get("MAE", float("nan"))), | |
| "delta_R2_marginal": wi["R2_marginal"] - wo["R2_marginal"], | |
| "delta_R2_conditional": wi["R2_conditional"] - wo["R2_conditional"], | |
| "delta_ICC": wi["ICC"] - wo["ICC"], | |
| }) | |
| # ββ 7. Save everything ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DataFrames | |
| train_df.to_csv(OUT / "train_df.csv", index=False) | |
| holdout_df.to_csv(OUT / "holdout_df.csv", index=False) | |
| all_df_export = all_df.copy() | |
| # Add family column to all_df for dashboard use | |
| all_df_export["family"] = all_df_export["variant"].map(_variant_family) | |
| all_df_export.to_csv(OUT / "all_df.csv", index=False) | |
| # Per-variant summary | |
| variant_order = [v for v in DEGRADED_VARIANTS + HOLDOUT_VARIANTS if v in set(all_df["variant"].unique())] | |
| summary_rows = [] | |
| for v in variant_order: | |
| source = holdout_df if v in HOLDOUT_VARIANTS else train_df | |
| arr = source[source["variant"] == v]["soft_dice"].dropna().values | |
| if len(arr) == 0: | |
| continue | |
| summary_rows.append({ | |
| "variant": v, "family": _variant_family(v), | |
| "label": FAMILY_LABELS.get(_variant_family(v), v), | |
| "role": "Holdout" if v in HOLDOUT_VARIANTS else "Training", | |
| "n": len(arr), | |
| "median_dice": float(np.median(arr)), | |
| "mean_dice": float(np.mean(arr)), | |
| "std_dice": float(np.std(arr)), | |
| "q25": float(np.percentile(arr, 25)), | |
| "q75": float(np.percentile(arr, 75)), | |
| "pct_zero": float(100.0 * np.mean(arr == 0)), | |
| }) | |
| pd.DataFrame(summary_rows).to_csv(OUT / "variant_summary.csv", index=False) | |
| # Model results (JSON-serializable) | |
| with open(OUT / "model_summaries.json", "w") as f: | |
| json.dump(model_summaries, f, indent=2) | |
| # Holdout predictions | |
| for name, df in holdout_preds.items(): | |
| safe = name.replace(" ", "_").replace("Γ", "x") | |
| df.to_csv(OUT / f"holdout_pred_{safe}.csv", index=False) | |
| # Misalignment contrasts | |
| with open(OUT / "misalignment_contrasts.json", "w") as f: | |
| json.dump(misalignment_contrasts, f, indent=2) | |
| # Scaler info | |
| with open(OUT / "scaler.json", "w") as f: | |
| json.dump(scaler, f, indent=2) | |
| # Metadata | |
| meta = { | |
| "GROUP_COL": GROUP_COL, | |
| "OUTCOME": OUTCOME, | |
| "DEGRADED_VARIANTS": DEGRADED_VARIANTS, | |
| "HOLDOUT_VARIANTS": HOLDOUT_VARIANTS, | |
| "FAMILY_LABELS": FAMILY_LABELS, | |
| "MODEL_QUALITY_COLS": MODEL_QUALITY_COLS, | |
| "ALL_QUALITY_COLS": ALL_QUALITY_COLS, | |
| "model_names": [s["model"] for s in model_specs], | |
| } | |
| with open(OUT / "meta.json", "w") as f: | |
| json.dump(meta, f, indent=2) | |
| print(f"\nAll data saved to {OUT.resolve()}") | |
| print("Files:", sorted(p.name for p in OUT.iterdir())) | |