OSCR

Preliminary testing of a prespecified liability architecture for autism: theory-guided pathogenetic triad models outperform strength-matched alternatives.

Code ↔ Paper

12 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 12 matches
  1. [1] § Materials and methods › Analytic overview ↔ scripts/_04_pt_triadindex.py, lines 615–724 · score 0.82 · outer training, log loss, model fitting, logistic regression, leakage free, stratified
  2. [2] § Materials and methods › Constructing the triplet multiverse space ↔ scripts/_05_pt_multiverse.py, lines 4–91 · score 0.82 · ridge logistic regression, repeated domains, distinct domains, full predictor, domain restriction, multiverse
  3. [3] § Materials and methods › Analytic overview ↔ scripts/_05_pt_multiverse.py, lines 511–571 · score 0.79 · outer training, log loss, leakage free, model fitting, stratified, tuned
  4. [4] § Results › Does a prespecified PT operationalization show out-of-sample separation? › Primary PT model in triad space ↔ scripts/_03_pt_primary_classifier.py, lines 970–1078 · score 0.69 · bootstrap envelope, ROC curve, Triad space, decision boundary, LR, clustering
  5. [5] § Materials and methods › Constructing the triplet multiverse space › Matched paired comparisons of PT vs non-PT triplets ↔ scripts/_05_pt_multiverse.py, lines 4–91 · score 0.67 · comparator triplets, PT triplet, univariate AUC, nearest, profile, strength
  6. [6] § Materials and methods › Data processing and univariate characterization ↔ scripts/_02_pt_preprocessing.py, lines 4–70 · score 0.65 · right skewed, log transformed, oriented, strength, univariate, variables
  7. [7] § Results › Does a prespecified PT operationalization show out-of-sample separation? › TriadIndex: collapsing triad space onto a single axis ↔ scripts/_03_pt_primary_classifier.py, lines 970–1078 · score 0.60 · calibration curve, ROC curve, decision boundary, sensitivity, triad, space
  8. [8] § Materials and methods › High-dimensional landscape ↔ scripts/_06_pt_kitchen_sink.py, lines 4–77 · score 0.59 · exhaustive enumeration, 2–3, PT models, landscape, predictor
  9. [9] § Materials and methods › The Triadindex as a continuous PT risk score ↔ scripts/_04_pt_triadindex.py, lines 1290–1431 · score 0.59 · TriadIndex, equal weight, raw, risk, TI, axis
  10. [10] § Results › Does a prespecified PT operationalization show out-of-sample separation? › Permutation test and unsupervised clustering ↔ scripts/_03_pt_primary_classifier.py, lines 1294–1345 · score 0.53 · ROC curve, triad space, unsupervised, clustering, BA, model
  11. [11] § Materials and methods › Measures › Non-PT predictors ↔ scripts/_02_pt_preprocessing.py, lines 4–70 · score 0.52 · domain assignments, preprocessing, MEG, pipelines, variables, NB
  12. [12] § Results › Does a prespecified PT operationalization show out-of-sample separation? › TriadIndex: collapsing triad space onto a single axis ↔ scripts/_04_pt_triadindex.py, lines 899–1031 · score 0.52 · calibration curve, ROC curve, sensitivity, BA, TriadIndex, AUC

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,435 lines · 49 KB · no license · 3 matches

  1. # Copyright (C) 2025 Darko Sarovic
  2. # SPDX-License-Identifier: AGPL-3.0-or-later only
  3. #!/usr/bin/env python3
  4. # -*- coding: utf-8 -*-
  5. """
  6. 04_pt_triadindex.py
  7. ===================
  8. Purpose
  9. ----------------------------------------------------------------------------------------
  10. TriadIndex (TI) construction and leakage-free 1-SE nested-CV evaluation (ridge logistic)
  11. AUTOMATIC MULTI-MODE RUN:
  12. - specified weights (default 2:1:1)
  13. - equal weights (1:1:1)
  14. - Exploratory (not implemented here because of small sample): 3D-derived weights (|beta| from 3-predictor logistic), computed leakage-free within CV
  15. Conceptual separation (important for Methods):
  16. - Panel A (TI distribution) uses full-sample standardization of TI_raw -> TI_z to define a stable TI axis.
  17. This is descriptive only.
  18. - Predictive performance uses leakage-free nested CV:
  19. outer LOOCV + inner 5-fold CV to tune C by minimizing log loss.
  20. All standardization and TI construction are performed within training partitions only.
  21. Outputs: <OUTPUT_ROOT>/04_TriadIndex/
  22. - ti_models_summary.csv
  23. - ti_weight_info.csv
  24. - For each model:
  25. ti_predictions_<model>.csv
  26. ti_calibration_<model>.csv
  27. ti_C_diagnostics_<model>.csv
  28. ti_outer_fold_summary_<model>.csv
  29. - Figures:
  30. TriadIndex_TI_Calibration_ROC_combined_<model>.png/.pdf/.eps
  31. """
  32. from __future__ import annotations
  33. import argparse
  34. import math
  35. from dataclasses import dataclass
  36. from pathlib import Path
  37. from typing import Dict, List, Optional, Tuple
  38. import numpy as np
  39. import pandas as pd
  40. import matplotlib.pyplot as plt
  41. from matplotlib.gridspec import GridSpec
  42. from sklearn.linear_model import LogisticRegression
  43. from sklearn.metrics import roc_auc_score, roc_curve, brier_score_loss, log_loss
  44. from sklearn.model_selection import StratifiedKFold
  45. from _01_pt_utils import set_global_random_seed, RANDOM_SEED
  46. # -----------------------------------------------------------------------------
  47. # Defaults
  48. # -----------------------------------------------------------------------------
  49. DEFAULT_OUTPUT_ROOT = Path("/User/Desktop/PT_project_folder/Output_folder")
  50. DEFAULT_INPUT_XLSX = DEFAULT_OUTPUT_ROOT / "derived_data" / "triad_data_logfixed.xlsx"
  51. DIAG_COL = "Diagnosis" # 0 = non-autism, 1 = autism
  52. AUTISM_CODE = 1
  53. CONTROL_CODE = 0
  54. AP_CANDS = ["AQ", "AQ_total", "AP_AQ"]
  55. CC_CANDS = ["WMIQ", "CC_WMIQ"]
  56. NB_CANDS = ["SD1", "NB_SD1"]
  57. ID_CANDS = ["ID", "Id", "Participant", "ParticipantID", "Subject", "SubjectID", "Case", "CaseID"]
  58. # Match your primary classifier grid (10^-4 ... 10^4, 17 values)
  59. C_GRID = np.logspace(-4, 4, 17)
  60. # -----------------------------------------------------------------------------
  61. # Styling
  62. # -----------------------------------------------------------------------------
  63. def set_plot_style() -> None:
  64. plt.rcParams["font.family"] = "serif"
  65. plt.rcParams["font.serif"] = ["Times New Roman"]
  66. plt.rcParams["axes.grid"] = True
  67. plt.rcParams["grid.color"] = "0.82"
  68. plt.rcParams["grid.linewidth"] = 1.0
  69. plt.rcParams["grid.linestyle"] = "-"
  70. plt.rcParams["axes.edgecolor"] = "0.7"
  71. plt.rcParams["axes.linewidth"] = 1.0
  72. plt.rcParams["savefig.dpi"] = 300
  73. plt.rcParams["figure.dpi"] = 150
  74. # -----------------------------------------------------------------------------
  75. # Helpers
  76. # -----------------------------------------------------------------------------
  77. # -------------------------
  78. # Positive-class convention
  79. # -------------------------
  80. POS_LABEL = AUTISM_CODE # autism/case must be coded as 1
  81. def proba_of_label(model: LogisticRegression, X: np.ndarray, label: int = POS_LABEL) -> np.ndarray:
  82. """Return P(y==label) robustly even if class ordering differs."""
  83. probs = model.predict_proba(X)
  84. classes = list(model.classes_)
  85. if label not in classes:
  86. raise ValueError(f"Label {label} not in model.classes_={classes}")
  87. return probs[:, classes.index(label)]
  88. def centered_decision_score(model: LogisticRegression, X: np.ndarray) -> np.ndarray:
  89. """Centered decision score: decision_function - intercept = X @ coef (binary)."""
  90. s = np.asarray(model.decision_function(X), dtype=float).reshape(-1)
  91. try:
  92. intercept = float(np.asarray(model.intercept_).reshape(-1)[0])
  93. except Exception:
  94. intercept = 0.0
  95. return s - intercept
  96. def sigmoid(z: float) -> float:
  97. return float(1.0 / (1.0 + math.exp(-float(z))))
  98. def best_ba_point(y_true: np.ndarray, scores: np.ndarray) -> Tuple[float, float, float, float]:
  99. """
  100. Maximize BA over thresholds on `scores`.
  101. Returns: (best_thr, best_BA, FPR_at_best, TPR_at_best)
  102. """
  103. y_true = y_true.astype(int)
  104. scores = scores.astype(float)
  105. thr_unique = np.unique(scores)
  106. best_thr = float(thr_unique[0])
  107. best_ba = -np.inf
  108. best_fpr = float("nan")
  109. best_tpr = float("nan")
  110. for thr in thr_unique:
  111. pred = (scores >= thr).astype(int)
  112. tp = np.sum((pred == 1) & (y_true == 1))
  113. tn = np.sum((pred == 0) & (y_true == 0))
  114. fp = np.sum((pred == 1) & (y_true == 0))
  115. fn = np.sum((pred == 0) & (y_true == 1))
  116. if (tp + fn) == 0 or (tn + fp) == 0:
  117. continue
  118. sens = tp / (tp + fn)
  119. spec = tn / (tn + fp)
  120. ba = 0.5 * (sens + spec)
  121. if ba > best_ba:
  122. best_ba = float(ba)
  123. best_thr = float(thr)
  124. best_fpr = float(1.0 - spec)
  125. best_tpr = float(sens)
  126. return best_thr, float(best_ba), float(best_fpr), float(best_tpr)
  127. def score_to_prob_threshold(thr_score: float, intercept: float) -> float:
  128. """Probability at the decision threshold (intercept + centered_score)."""
  129. return sigmoid(float(intercept) + float(thr_score))
  130. def score_to_ti_threshold(thr_score: float, beta: float) -> float:
  131. """Map centered-score threshold to TI threshold: thr_score = beta * TI."""
  132. if not np.isfinite(beta) or np.isclose(beta, 0.0):
  133. return float("nan")
  134. return float(thr_score / beta)
  135. def ensure_dir(p: Path) -> None:
  136. p.mkdir(parents=True, exist_ok=True)
  137. def find_first_present(df: pd.DataFrame, candidates: List[str]) -> str:
  138. for c in candidates:
  139. if c in df.columns:
  140. return c
  141. raise KeyError(f"None of these columns were found: {candidates}")
  142. def find_optional_id_col(df: pd.DataFrame) -> Optional[str]:
  143. for c in ID_CANDS:
  144. if c in df.columns:
  145. return c
  146. return None
  147. def _mean_sd(x: np.ndarray) -> Tuple[float, float]:
  148. mu = float(np.nanmean(x))
  149. sd = float(np.nanstd(x, ddof=0))
  150. if (not np.isfinite(sd)) or sd <= 0:
  151. sd = 1.0
  152. return mu, sd
  153. def z_from_train(x: np.ndarray, mu: float, sd: float) -> np.ndarray:
  154. return (x - mu) / sd
  155. def best_ba_threshold_from_probs(y_true: np.ndarray, y_prob: np.ndarray) -> Tuple[float, float]:
  156. fpr, tpr, thr = roc_curve(y_true, y_prob)
  157. ba = 0.5 * (tpr + (1 - fpr))
  158. k = int(np.argmax(ba))
  159. return float(thr[k]), float(ba[k])
  160. def prob_to_ti_threshold(thr_prob: float, intercept: float, beta: float) -> float:
  161. if thr_prob <= 0.0 or thr_prob >= 1.0:
  162. raise ValueError("thr_prob must be in (0,1).")
  163. if np.isclose(beta, 0.0):
  164. return float("nan")
  165. logit = math.log(thr_prob / (1.0 - thr_prob))
  166. return float((logit - intercept) / beta)
  167. def calibration_table_quantile(y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 5) -> pd.DataFrame:
  168. df = pd.DataFrame({"y": y_true.astype(int), "p": y_prob.astype(float)}).sort_values("p").reset_index(drop=True)
  169. qs = np.linspace(0, 1, n_bins + 1)
  170. edges = df["p"].quantile(qs).to_numpy()
  171. for i in range(1, len(edges)):
  172. if edges[i] <= edges[i - 1]:
  173. edges[i] = edges[i - 1] + 1e-8
  174. rows = []
  175. for b in range(n_bins):
  176. lo, hi = float(edges[b]), float(edges[b + 1])
  177. if b == n_bins - 1:
  178. m = (df["p"] >= lo) & (df["p"] <= hi)
  179. else:
  180. m = (df["p"] >= lo) & (df["p"] < hi)
  181. sub = df.loc[m]
  182. rows.append(
  183. {
  184. "bin": b + 1,
  185. "lower": lo,
  186. "upper": hi,
  187. "mean_prob": float(sub["p"].mean()) if len(sub) else float("nan"),
  188. "obs_rate": float(sub["y"].mean()) if len(sub) else float("nan"),
  189. "n": int(len(sub)),
  190. }
  191. )
  192. return pd.DataFrame(rows)
  193. def calibration_table_fixed_edges(y_true: np.ndarray, y_prob: np.ndarray, bin_edges: List[float]) -> pd.DataFrame:
  194. df = pd.DataFrame({"y": y_true.astype(int), "p": y_prob.astype(float)})
  195. edges = np.asarray(bin_edges, dtype=float)
  196. if edges.ndim != 1 or edges.size < 3:
  197. raise ValueError("bin_edges must be a 1D list/array with at least 3 values (>=2 bins).")
  198. # Ensure [0,1] coverage and strict monotonicity
  199. edges[0] = min(edges[0], 0.0)
  200. edges[-1] = max(edges[-1], 1.0)
  201. for i in range(1, len(edges)):
  202. if edges[i] <= edges[i - 1]:
  203. edges[i] = edges[i - 1] + 1e-8
  204. df["bin"] = pd.cut(df["p"], bins=edges, include_lowest=True, right=True)
  205. rows = []
  206. for interval, g in df.groupby("bin", observed=True):
  207. if g.shape[0] == 0:
  208. continue
  209. rows.append(
  210. {
  211. "bin": str(interval),
  212. "lower": float(interval.left),
  213. "upper": float(interval.right),
  214. "mean_prob": float(g["p"].mean()),
  215. "obs_rate": float(g["y"].mean()),
  216. "n": int(g.shape[0]),
  217. }
  218. )
  219. return pd.DataFrame(rows).sort_values("mean_prob").reset_index(drop=True)
  220. def bootstrap_auc_ci(
  221. y_true: np.ndarray,
  222. y_score: np.ndarray,
  223. n_boot: int = 2000,
  224. seed: int = 12345,
  225. alpha: float = 0.05,
  226. ) -> Tuple[float, float, int]:
  227. """
  228. Percentile bootstrap CI for ROC AUC on paired (y_true, y_score).
  229. Skips samples with only one class.
  230. """
  231. y_true = np.asarray(y_true).astype(int)
  232. y_score = np.asarray(y_score).astype(float)
  233. n = y_true.shape[0]
  234. if n_boot <= 0:
  235. return float("nan"), float("nan"), 0
  236. rng = np.random.default_rng(int(seed))
  237. aucs: List[float] = []
  238. for _ in range(int(n_boot)):
  239. idx = rng.integers(0, n, size=n)
  240. ys = y_true[idx]
  241. if np.unique(ys).size < 2:
  242. continue
  243. aucs.append(float(roc_auc_score(ys, y_score[idx])))
  244. if len(aucs) == 0:
  245. return float("nan"), float("nan"), 0
  246. lo, hi = np.quantile(np.asarray(aucs, float), [alpha / 2.0, 1.0 - alpha / 2.0])
  247. return float(lo), float(hi), int(len(aucs))
  248. def bootstrap_brier_ci(
  249. y_true: np.ndarray,
  250. y_prob: np.ndarray,
  251. n_boot: int = 2000,
  252. seed: int = 12345,
  253. alpha: float = 0.05,
  254. ) -> Tuple[float, float, int]:
  255. """
  256. Percentile bootstrap CI for Brier score on paired (y_true, y_prob).
  257. Returns (ci_low, ci_high, n_valid_boot).
  258. """
  259. y_true = np.asarray(y_true).astype(int)
  260. y_prob = np.asarray(y_prob).astype(float)
  261. n = y_true.shape[0]
  262. if n_boot <= 0:
  263. return float("nan"), float("nan"), 0
  264. rng = np.random.default_rng(int(seed))
  265. vals = []
  266. for _ in range(int(n_boot)):
  267. idx = rng.integers(0, n, size=n)
  268. vals.append(float(brier_score_loss(y_true[idx], y_prob[idx])))
  269. lo, hi = np.quantile(np.asarray(vals, float), [alpha / 2.0, 1.0 - alpha / 2.0])
  270. return float(lo), float(hi), int(len(vals))
  271. def bootstrap_ba_ci(
  272. y_true: np.ndarray,
  273. y_score: np.ndarray,
  274. n_boot: int = 2000,
  275. seed: int = 12345,
  276. alpha: float = 0.05,
  277. ) -> Tuple[float, float, int]:
  278. """
  279. Percentile bootstrap CI for best BA from thresholds on paired (y_true, y_score).
  280. Skips samples with only one class.
  281. """
  282. y_true = np.asarray(y_true).astype(int)
  283. y_score = np.asarray(y_score).astype(float)
  284. n = y_true.shape[0]
  285. if n_boot <= 0:
  286. return float("nan"), float("nan"), 0
  287. rng = np.random.default_rng(int(seed))
  288. vals: List[float] = []
  289. for _ in range(int(n_boot)):
  290. idx = rng.integers(0, n, size=n)
  291. ys = y_true[idx]
  292. if np.unique(ys).size < 2:
  293. continue
  294. _, ba, _, _ = best_ba_point(ys, y_score[idx])
  295. vals.append(float(ba))
  296. if len(vals) == 0:
  297. return float("nan"), float("nan"), 0
  298. lo, hi = np.quantile(np.asarray(vals, float), [alpha / 2.0, 1.0 - alpha / 2.0])
  299. return float(lo), float(hi), int(len(vals))
  300. def save_figure_triplet(fig: plt.Figure, out_base: Path) -> None:
  301. out_base.parent.mkdir(parents=True, exist_ok=True)
  302. fig.savefig(out_base.with_suffix(".png"), bbox_inches="tight")
  303. fig.savefig(out_base.with_suffix(".pdf"), bbox_inches="tight")
  304. fig.savefig(out_base.with_suffix(".eps"), bbox_inches="tight")
  305. def normalize_weights(w_ap: float, w_cc: float, w_nb: float, method: str = "l1") -> Tuple[float, float, float]:
  306. w = np.array([w_ap, w_cc, w_nb], dtype=float)
  307. if method == "none":
  308. return float(w[0]), float(w[1]), float(w[2])
  309. if method == "sum":
  310. s = float(np.sum(w))
  311. if np.isclose(s, 0.0):
  312. raise ValueError("Sum of weights is zero; cannot normalize by sum.")
  313. return float(w[0] / s), float(w[1] / s), float(w[2] / s)
  314. s = float(np.sum(np.abs(w)))
  315. if np.isclose(s, 0.0):
  316. raise ValueError("All weights are zero; provide nonzero weights.")
  317. return float(w[0] / s), float(w[1] / s), float(w[2] / s)
  318. def format_model_tag(tag: str) -> str:
  319. return tag.replace(" ", "_").replace(":", "").replace("/", "_").replace("|", "_")
  320. def safe_kfold_k(y: np.ndarray, desired_k: int) -> int:
  321. """Choose a safe K for label-independent KFold in small, imbalanced samples.
  322. Logistic regression requires both classes in each *training* fold. With KFold, rare edge cases
  323. can produce an inner split whose training side has only one class if the minority class is tiny.
  324. We cap K by the per-class counts to reduce that risk, while still using label-independent splits.
  325. """
  326. y = y.astype(int)
  327. n0 = int(np.sum(y == 0))
  328. n1 = int(np.sum(y == 1))
  329. if n0 == 0 or n1 == 0:
  330. raise ValueError("Cannot do CV: only one class present.")
  331. k = min(int(desired_k), n0, n1, len(y))
  332. return max(2, k)
  333. def choose_C_one_se_smallest(
  334. Cs: np.ndarray,
  335. mean_losses: np.ndarray,
  336. se_losses: np.ndarray,
  337. ) -> float:
  338. """1-SE rule for a minimization metric (log loss): pick smallest C within 1 SE of the best mean."""
  339. Cs = np.asarray(Cs, dtype=float)
  340. mean_losses = np.asarray(mean_losses, dtype=float)
  341. se_losses = np.asarray(se_losses, dtype=float)
  342. finite = np.isfinite(mean_losses) & np.isfinite(se_losses) & np.isfinite(Cs)
  343. if not np.any(finite):
  344. return float(Cs[0])
  345. best_idx = int(np.nanargmin(np.where(finite, mean_losses, np.nan)))
  346. best_mean = float(mean_losses[best_idx])
  347. best_se = float(se_losses[best_idx])
  348. threshold = best_mean + best_se
  349. eligible = finite & (mean_losses <= threshold + 1e-12)
  350. if not np.any(eligible):
  351. return float(Cs[best_idx])
  352. return float(np.min(Cs[eligible]))
  353. def ridge_logistic(C: float) -> LogisticRegression:
  354. return LogisticRegression(
  355. penalty="l2",
  356. C=float(C),
  357. solver="lbfgs",
  358. max_iter=6000,
  359. )
  360. # -----------------------------------------------------------------------------
  361. # Data construction
  362. # -----------------------------------------------------------------------------
  363. @dataclass(frozen=True)
  364. class TriadData:
  365. df: pd.DataFrame
  366. ap_var: str
  367. cc_var: str
  368. nb_var: str
  369. def build_triad_fullsample(df: pd.DataFrame) -> TriadData:
  370. ap_var = find_first_present(df, AP_CANDS)
  371. cc_var = find_first_present(df, CC_CANDS)
  372. nb_var = find_first_present(df, NB_CANDS)
  373. id_col = find_optional_id_col(df)
  374. cols = [DIAG_COL, ap_var, cc_var, nb_var]
  375. if id_col is not None:
  376. cols = [id_col] + cols
  377. tri = df[cols].copy().dropna(subset=[DIAG_COL, ap_var, cc_var, nb_var]).reset_index(drop=True)
  378. if id_col is not None:
  379. tri = tri.rename(columns={id_col: "participant_id"})
  380. else:
  381. tri["participant_id"] = np.arange(tri.shape[0], dtype=int)
  382. tri = tri.rename(columns={ap_var: "AP_raw", cc_var: "CC_raw", nb_var: "NB_raw"})
  383. # Full-sample z's for descriptive TI axis (Panel A only)
  384. ap = tri["AP_raw"].to_numpy(float)
  385. cc = tri["CC_raw"].to_numpy(float)
  386. nb = tri["NB_raw"].to_numpy(float)
  387. ap_mu, ap_sd = _mean_sd(ap)
  388. cc_mu, cc_sd = _mean_sd(cc)
  389. nb_mu, nb_sd = _mean_sd(nb)
  390. tri["z_AP"] = z_from_train(ap, ap_mu, ap_sd)
  391. tri["z_CC_risk"] = -z_from_train(cc, cc_mu, cc_sd)
  392. tri["z_NB_risk"] = -z_from_train(nb, nb_mu, nb_sd)
  393. return TriadData(df=tri, ap_var=ap_var, cc_var=cc_var, nb_var=nb_var)
  394. # -----------------------------------------------------------------------------
  395. # TI construction (leakage-free) utilities
  396. # -----------------------------------------------------------------------------
  397. def compute_ti_from_train(
  398. ap: np.ndarray,
  399. cc: np.ndarray,
  400. nb: np.ndarray,
  401. train_idx: np.ndarray,
  402. apply_idx: np.ndarray,
  403. w_ap: float,
  404. w_cc: float,
  405. w_nb: float,
  406. ) -> Tuple[np.ndarray, float, float]:
  407. """
  408. Compute TI_z for apply_idx using parameters learned from train_idx only:
  409. - z-score AP/CC/NB using train means/SDs
  410. - risk-orient CC and NB by sign reversal
  411. - compute TI_raw = w_ap*z_AP + w_cc*z_CC_risk + w_nb*z_NB_risk
  412. - standardize TI_raw using train TI_raw mean/SD to yield TI_z
  413. Returns
  414. -------
  415. ti_apply : array of TI_z for apply_idx
  416. ti_mu, ti_sd : mean and SD of TI_raw in train set (used for standardization)
  417. """
  418. ap_tr = ap[train_idx]
  419. cc_tr = cc[train_idx]
  420. nb_tr = nb[train_idx]
  421. ap_mu, ap_sd = _mean_sd(ap_tr)
  422. cc_mu, cc_sd = _mean_sd(cc_tr)
  423. nb_mu, nb_sd = _mean_sd(nb_tr)
  424. # z for train (needed to compute TI_raw train stats)
  425. z_ap_tr = z_from_train(ap_tr, ap_mu, ap_sd)
  426. z_cc_tr = z_from_train(cc_tr, cc_mu, cc_sd)
  427. z_nb_tr = z_from_train(nb_tr, nb_mu, nb_sd)
  428. z_cc_risk_tr = -z_cc_tr
  429. z_nb_risk_tr = -z_nb_tr
  430. ti_raw_tr = w_ap * z_ap_tr + w_cc * z_cc_risk_tr + w_nb * z_nb_risk_tr
  431. ti_mu, ti_sd = _mean_sd(ti_raw_tr)
  432. # apply
  433. ap_ap = ap[apply_idx]
  434. cc_ap = cc[apply_idx]
  435. nb_ap = nb[apply_idx]
  436. z_ap = z_from_train(ap_ap, ap_mu, ap_sd)
  437. z_cc = z_from_train(cc_ap, cc_mu, cc_sd)
  438. z_nb = z_from_train(nb_ap, nb_mu, nb_sd)
  439. ti_raw = w_ap * z_ap + w_cc * (-z_cc) + w_nb * (-z_nb)
  440. ti_z = z_from_train(ti_raw, ti_mu, ti_sd)
  441. return ti_z.astype(float), float(ti_mu), float(ti_sd)
  442. def derive_weights_from_3d_logistic_trainonly(
  443. ap: np.ndarray,
  444. cc: np.ndarray,
  445. nb: np.ndarray,
  446. y: np.ndarray,
  447. train_idx: np.ndarray,
  448. ) -> Tuple[float, float, float, float, float, float]:
  449. """
  450. Derive positive L1-normalized weights from a 3-predictor logistic model fit
  451. on standardized risk-oriented components within train_idx only.
  452. Returns: (w_ap, w_cc, w_nb, beta_ap, beta_cc, beta_nb)
  453. """
  454. y_tr = y[train_idx].astype(int)
  455. ap_tr = ap[train_idx]
  456. cc_tr = cc[train_idx]
  457. nb_tr = nb[train_idx]
  458. ap_mu, ap_sd = _mean_sd(ap_tr)
  459. cc_mu, cc_sd = _mean_sd(cc_tr)
  460. nb_mu, nb_sd = _mean_sd(nb_tr)
  461. z_ap = z_from_train(ap_tr, ap_mu, ap_sd)
  462. z_cc_risk = -z_from_train(cc_tr, cc_mu, cc_sd)
  463. z_nb_risk = -z_from_train(nb_tr, nb_mu, nb_sd)
  464. X = np.column_stack([z_ap, z_cc_risk, z_nb_risk]).astype(float)
  465. # Use (near-)unpenalized as a descriptive 3D fit; fall back if it fails.
  466. try:
  467. m = LogisticRegression(penalty=None, solver="lbfgs", max_iter=10000)
  468. m.fit(X, y_tr)
  469. except Exception:
  470. m = LogisticRegression(penalty="l2", C=1e6, solver="lbfgs", max_iter=10000)
  471. m.fit(X, y_tr)
  472. beta = m.coef_[0].astype(float)
  473. s = float(np.sum(np.abs(beta)))
  474. if np.isclose(s, 0.0):
  475. w = np.array([1.0, 1.0, 1.0], float) / 3.0
  476. else:
  477. w = np.abs(beta) / s
  478. return float(w[0]), float(w[1]), float(w[2]), float(beta[0]), float(beta[1]), float(beta[2])
  479. # -----------------------------------------------------------------------------
  480. # Nested tuning: inner CV chooses C by minimizing log loss
  481. # -----------------------------------------------------------------------------
  482. def tune_C_inner_cv_for_ti(
  483. ap: np.ndarray,
  484. cc: np.ndarray,
  485. nb: np.ndarray,
  486. y: np.ndarray,
  487. outer_train_idx: np.ndarray,
  488. w_mode: str,
  489. fixed_w: Tuple[float, float, float],
  490. inner_k_desired: int,
  491. rng_seed: int,
  492. ) -> Tuple[float, pd.DataFrame]:
  493. """Tune C on outer_train_idx using inner CV + 1-SE rule (smallest C within 1 SE of the best mean log loss).
  494. Inner splitting is stratified (KFold with shuffle). For each inner split:
  495. - TI is constructed using inner-train only (leakage-free)
  496. - ridge logistic is fit on TI(inner-train)
  497. - log loss is evaluated on TI(inner-val)
  498. w_mode:
  499. - "fixed": use fixed_w for all inner splits
  500. - "derived": derive weights from a 3D logistic model fit on inner-train only (leakage-free)
  501. Returns:
  502. best_C (float): selected by the 1-SE rule
  503. diagnostics (DataFrame): per-C mean/se log loss and folds used
  504. """
  505. outer_idx = np.asarray(outer_train_idx, dtype=int)
  506. y_outer = y[outer_idx].astype(int)
  507. k_inner = safe_kfold_k(y_outer, inner_k_desired)
  508. kf = StratifiedKFold(n_splits=k_inner, shuffle=True, random_state=int(rng_seed))
  509. rows = []
  510. mean_losses = []
  511. se_losses = []
  512. for C in C_GRID:
  513. fold_losses = []
  514. folds_used = 0
  515. for fold_id, (tr_pos, va_pos) in enumerate(kf.split(np.zeros(len(outer_idx)), y_outer), start=1):
  516. inner_tr_idx = outer_idx[tr_pos]
  517. inner_va_idx = outer_idx[va_pos]
  518. y_tr = y[inner_tr_idx].astype(int)
  519. y_va = y[inner_va_idx].astype(int)
  520. # Guard: training must contain both classes for logistic regression
  521. if np.unique(y_tr).size < 2:
  522. continue
  523. # Weights for TI (fixed or derived on inner-train only)
  524. if w_mode == "derived":
  525. w_ap, w_cc, w_nb, *_ = derive_weights_from_3d_logistic_trainonly(
  526. ap=ap, cc=cc, nb=nb, y=y, train_idx=inner_tr_idx
  527. )
  528. else:
  529. w_ap, w_cc, w_nb = fixed_w
  530. # Leakage-free TI construction: stats learned on inner-train only
  531. ti_tr, _, _ = compute_ti_from_train(
  532. ap=ap, cc=cc, nb=nb,
  533. train_idx=inner_tr_idx, apply_idx=inner_tr_idx,
  534. w_ap=w_ap, w_cc=w_cc, w_nb=w_nb
  535. )
  536. ti_va, _, _ = compute_ti_from_train(
  537. ap=ap, cc=cc, nb=nb,
  538. train_idx=inner_tr_idx, apply_idx=inner_va_idx,
  539. w_ap=w_ap, w_cc=w_cc, w_nb=w_nb
  540. )
  541. X_tr = ti_tr.reshape(-1, 1)
  542. X_va = ti_va.reshape(-1, 1)
  543. m = ridge_logistic(float(C))
  544. m.fit(X_tr, y_tr)
  545. p = proba_of_label(m, X_va, POS_LABEL)
  546. fold_losses.append(float(log_loss(y_va, p, labels=[0, 1])))
  547. folds_used += 1
  548. if folds_used == 0:
  549. mean_ll = float("nan")
  550. se_ll = float("nan")
  551. else:
  552. mean_ll = float(np.mean(fold_losses))
  553. se_ll = float(np.std(fold_losses, ddof=1) / np.sqrt(folds_used)) if folds_used >= 2 else 0.0
  554. rows.append({
  555. "C": float(C),
  556. "k_inner": int(k_inner),
  557. "folds_used": int(folds_used),
  558. "mean_logloss": mean_ll,
  559. "se_logloss": se_ll,
  560. "w_mode": str(w_mode),
  561. })
  562. mean_losses.append(mean_ll)
  563. se_losses.append(se_ll)
  564. df = pd.DataFrame(rows)
  565. if np.all(np.isnan(np.asarray(mean_losses, float))):
  566. raise RuntimeError("Inner CV failed: all candidate C values produced zero usable folds (single-class training folds).")
  567. best_C = choose_C_one_se_smallest(
  568. np.asarray(C_GRID, float),
  569. np.asarray(mean_losses, float),
  570. np.asarray(se_losses, float),
  571. )
  572. return float(best_C), df
  573. def loocv_ti_probs_nested_ridge(
  574. triad: pd.DataFrame,
  575. w_mode: str,
  576. fixed_w: Tuple[float, float, float],
  577. inner_k: int,
  578. random_seed: int,
  579. ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, pd.DataFrame]:
  580. """
  581. Outer LOOCV:
  582. - within each outer fold: tune C via inner CV (log loss)
  583. - construct TI using outer-train only
  584. - fit ridge logistic with tuned C on TI_train
  585. - predict held-out
  586. Returns:
  587. ti_cv (held-out foldwise TI_z),
  588. prob_cv (held-out probabilities),
  589. fold_df (per-fold diagnostics: C, and (if derived) weights, betas)
  590. """
  591. y = triad[DIAG_COL].to_numpy(int)
  592. ap = triad["AP_raw"].to_numpy(float)
  593. cc = triad["CC_raw"].to_numpy(float)
  594. nb = triad["NB_raw"].to_numpy(float)
  595. n = len(y)
  596. ti_cv = np.full(n, np.nan, float)
  597. prob_cv = np.full(n, np.nan, float)
  598. score_cv = np.full(n, np.nan, float)
  599. fold_rows = []
  600. for i in range(n):
  601. outer_train_idx = np.array([j for j in range(n) if j != i], dtype=int)
  602. # Tune C (inner CV) using leakage-free TI construction within inner splits
  603. best_C, diag = tune_C_inner_cv_for_ti(
  604. ap=ap, cc=cc, nb=nb, y=y,
  605. outer_train_idx=outer_train_idx,
  606. w_mode=w_mode,
  607. fixed_w=fixed_w,
  608. inner_k_desired=inner_k,
  609. rng_seed=random_seed + 1000 + i,
  610. )
  611. # For derived mode: derive final weights on full outer training only (leakage-free w.r.t. held-out)
  612. if w_mode == "derived":
  613. w_ap, w_cc, w_nb, b_ap, b_cc, b_nb = derive_weights_from_3d_logistic_trainonly(
  614. ap=ap, cc=cc, nb=nb, y=y, train_idx=outer_train_idx
  615. )
  616. else:
  617. w_ap, w_cc, w_nb = fixed_w
  618. b_ap = b_cc = b_nb = float("nan")
  619. # Compute outer-train TI and held-out TI using outer-train stats only
  620. ti_tr, _, _ = compute_ti_from_train(
  621. ap=ap, cc=cc, nb=nb,
  622. train_idx=outer_train_idx, apply_idx=outer_train_idx,
  623. w_ap=w_ap, w_cc=w_cc, w_nb=w_nb
  624. )
  625. ti_te, _, _ = compute_ti_from_train(
  626. ap=ap, cc=cc, nb=nb,
  627. train_idx=outer_train_idx, apply_idx=np.array([i], dtype=int),
  628. w_ap=w_ap, w_cc=w_cc, w_nb=w_nb
  629. )
  630. Xtr = ti_tr.reshape(-1, 1)
  631. ytr = y[outer_train_idx].astype(int)
  632. m = ridge_logistic(best_C)
  633. m.fit(Xtr, ytr)
  634. p_te = float(proba_of_label(m, ti_te.reshape(1, 1), POS_LABEL)[0])
  635. s_te = float(centered_decision_score(m, ti_te.reshape(1, 1))[0])
  636. ti_cv[i] = float(ti_te[0])
  637. prob_cv[i] = p_te
  638. score_cv[i] = s_te
  639. fold_rows.append({
  640. "heldout_index": int(i),
  641. "heldout_id": str(triad.loc[i, "participant_id"]),
  642. "heldout_y": int(y[i]),
  643. "C_best": float(best_C),
  644. "inner_k": int(safe_kfold_k(y[outer_train_idx], inner_k)),
  645. "w_mode": str(w_mode),
  646. "w_ap": float(w_ap),
  647. "w_cc": float(w_cc),
  648. "w_nb": float(w_nb),
  649. "beta_ap_3d": float(b_ap),
  650. "beta_cc_3d": float(b_cc),
  651. "beta_nb_3d": float(b_nb),
  652. })
  653. # Store per-fold inner-CV diagnostics (one file per model; append with fold index)
  654. diag2 = diag.copy()
  655. # Safe column insertion: avoid crashing if the diagnostics already include these fields.
  656. for _loc, _col, _val in [
  657. (0, "heldout_index", int(i)),
  658. (1, "C_best_fold", float(best_C)),
  659. (2, "w_mode", str(w_mode)),
  660. ]:
  661. if _col in diag2.columns:
  662. diag2[_col] = _val
  663. else:
  664. diag2.insert(_loc, _col, _val)
  665. # attach later by caller
  666. fold_df = pd.DataFrame(fold_rows)
  667. if np.any(~np.isfinite(prob_cv)) or np.any(~np.isfinite(ti_cv)) or np.any(~np.isfinite(score_cv)):
  668. raise RuntimeError("Non-finite LOOCV results encountered; check data and model stability.")
  669. return ti_cv, prob_cv, score_cv, fold_df
  670. # -----------------------------------------------------------------------------
  671. # In-sample ridge (with 5-fold tuning of C) on full-sample TI axis (descriptive)
  672. # -----------------------------------------------------------------------------
  673. def tune_C_on_fixed_feature(
  674. X: np.ndarray,
  675. y: np.ndarray,
  676. desired_k: int,
  677. seed: int,
  678. ) -> Tuple[float, pd.DataFrame]:
  679. """Tune C on a fixed feature matrix using stratified KFold and a 1-SE rule (smallest C).
  680. This is used for descriptive/full-sample models (not the leakage-free nested LOOCV outputs).
  681. """
  682. y = y.astype(int)
  683. k = safe_kfold_k(y, desired_k)
  684. kf = StratifiedKFold(n_splits=k, shuffle=True, random_state=int(seed))
  685. rows = []
  686. mean_losses = []
  687. se_losses = []
  688. for C in C_GRID:
  689. losses = []
  690. folds_used = 0
  691. for tr, va in kf.split(X, y):
  692. y_tr = y[tr]
  693. if np.unique(y_tr).size < 2:
  694. continue
  695. m = ridge_logistic(C)
  696. m.fit(X[tr], y_tr)
  697. p = m.predict_proba(X[va])[:, 1]
  698. losses.append(float(log_loss(y[va], p, labels=[0, 1])))
  699. folds_used += 1
  700. if folds_used == 0:
  701. mean_ll = np.nan
  702. se_ll = np.nan
  703. else:
  704. mean_ll = float(np.mean(losses))
  705. if folds_used >= 2:
  706. se_ll = float(np.std(losses, ddof=1) / np.sqrt(folds_used))
  707. else:
  708. se_ll = 0.0
  709. rows.append({
  710. "C": float(C),
  711. "k": int(k),
  712. "folds_used": int(folds_used),
  713. "mean_logloss": float(mean_ll) if np.isfinite(mean_ll) else np.nan,
  714. "se_logloss": float(se_ll) if np.isfinite(se_ll) else np.nan,
  715. })
  716. mean_losses.append(mean_ll)
  717. se_losses.append(se_ll)
  718. df = pd.DataFrame(rows)
  719. best_C = choose_C_one_se_smallest(np.asarray(C_GRID, float), np.asarray(mean_losses, float), np.asarray(se_losses, float))
  720. return float(best_C), df
  721. def make_joint_figure(
  722. ti_full: np.ndarray,
  723. y: np.ndarray,
  724. score_in: np.ndarray,
  725. score_cv: np.ndarray,
  726. cal_df: pd.DataFrame,
  727. auc_in: float,
  728. auc_cv: float,
  729. thr_ti_for_plot: float,
  730. title_tag: str,
  731. use_boxplot: bool,
  732. jitter_seed: int = 12345,
  733. ) -> plt.Figure:
  734. set_plot_style()
  735. fig = plt.figure(figsize=(8, 9))
  736. gs = GridSpec(2, 2, width_ratios=[0.6, 1.0], height_ratios=[1.0, 1.0], figure=fig)
  737. ax_ti = fig.add_subplot(gs[:, 0])
  738. ax_roc = fig.add_subplot(gs[0, 1])
  739. ax_cal = fig.add_subplot(gs[1, 1])
  740. diag = y.astype(int)
  741. TI = np.asarray(ti_full, float)
  742. mask_ctrl = diag == CONTROL_CODE
  743. mask_aut = diag == AUTISM_CODE
  744. x_ctrl_pos, x_aut_pos = 0.4, 0.6
  745. if use_boxplot:
  746. bp = ax_ti.boxplot(
  747. [TI[mask_ctrl], TI[mask_aut]],
  748. positions=[x_ctrl_pos, x_aut_pos],
  749. widths=0.075,
  750. patch_artist=True,
  751. showfliers=False,
  752. whis=1.5,
  753. manage_ticks=False
  754. )
  755. for k in bp:
  756. for art in bp[k]:
  757. try:
  758. art.set_zorder(1)
  759. except Exception:
  760. pass
  761. for box in bp["boxes"]:
  762. box.set_facecolor("white")
  763. box.set_edgecolor("0.35")
  764. box.set_linewidth(1.0)
  765. box.set_alpha(0.35)
  766. for whisk in bp["whiskers"]:
  767. whisk.set_color("0.35")
  768. whisk.set_linewidth(1.0)
  769. whisk.set_alpha(0.35)
  770. for cap in bp["caps"]:
  771. cap.set_color("0.35")
  772. cap.set_linewidth(1.0)
  773. cap.set_alpha(0.35)
  774. for med in bp["medians"]:
  775. med.set_color("0.20")
  776. med.set_linewidth(1.2)
  777. med.set_alpha(0.55)
  778. med.set_zorder(1)
  779. rng = np.random.default_rng(jitter_seed)
  780. x_ctrl = x_ctrl_pos + rng.normal(scale=0.015, size=int(mask_ctrl.sum()))
  781. x_aut = x_aut_pos + rng.normal(scale=0.015, size=int(mask_aut.sum()))
  782. ax_ti.scatter(x_ctrl, TI[mask_ctrl], marker="o", s=90, alpha=0.7, edgecolors="none", zorder=3)
  783. ax_ti.scatter(x_aut, TI[mask_aut], marker="x", s=90, alpha=0.7, zorder=3)
  784. ax_ti.axhline(thr_ti_for_plot, color="black", linestyle="--", linewidth=1.5,
  785. label="BA-optimal cutoff (descriptive)")
  786. ax_ti.set_xlim(0.3, 0.7)
  787. ax_ti.set_xticks([x_ctrl_pos, x_aut_pos])
  788. ax_ti.set_xticklabels(["Non-autism", "Autism"], fontsize=15)
  789. ax_ti.set_xlabel("Diagnosis", fontsize=15)
  790. ax_ti.xaxis.grid(False)
  791. ax_ti.yaxis.grid(True, linestyle="-", color="0.9", linewidth=1, alpha=0.6, zorder=1)
  792. ax_ti.set_ylabel("TriadIndex (standardized)", fontsize=15)
  793. ax_ti.set_title("A. TriadIndex by diagnosis", fontweight="bold", fontsize=18)
  794. ax_ti.set_ylim(-2, 3.5)
  795. ax_ti.legend(loc="lower right", fontsize=9.4, framealpha=0.9)
  796. fpr_in, tpr_in, _ = roc_curve(y, score_in)
  797. fpr_cv, tpr_cv, _ = roc_curve(y, score_cv)
  798. ax_roc.plot(fpr_cv, tpr_cv, color="k", linewidth=1.6, label=f"LOOCV (AUC = {auc_cv:.3f})")
  799. ax_roc.plot(fpr_in, tpr_in, color="k", linewidth=1.6, linestyle="--", label=f"In-sample (AUC = {auc_in:.3f})")
  800. ax_roc.plot([0, 1], [0, 1], "k:", linewidth=1)
  801. ax_roc.set_xlim(-0.001, 1.001)
  802. ax_roc.set_ylim(-0.001, 1.001)
  803. ax_roc.set_xticks([0.0, 0.2, 0.4, 0.6, 0.8, 1.0])
  804. ax_roc.set_xlabel("False positive rate (1 - specificity)", fontsize=15)
  805. ax_roc.set_ylabel("True positive rate (sensitivity)", fontsize=15)
  806. ax_roc.set_title("B. ROC curve", fontweight="bold", fontsize=18)
  807. ax_roc.grid(True, linestyle="-", color="0.9", linewidth=1, alpha=0.6)
  808. ax_roc.set_aspect("equal", adjustable="box")
  809. ax_roc.legend(loc="lower right", fontsize=12, framealpha=0.9)
  810. mean_pred = cal_df["mean_prob"].to_numpy(float)
  811. obs_rate = cal_df["obs_rate"].to_numpy(float)
  812. n_bin = cal_df["n"].to_numpy(int)
  813. ok = np.isfinite(mean_pred) & np.isfinite(obs_rate)
  814. mean_pred, obs_rate, n_bin = mean_pred[ok], obs_rate[ok], n_bin[ok]
  815. ax_cal.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="0.7")
  816. ax_cal.plot(mean_pred, obs_rate, marker="o", linestyle="-", linewidth=1.2, markersize=8)
  817. for x, yv, n in zip(mean_pred, obs_rate, n_bin):
  818. ax_cal.text(x, yv + 0.03, f"{int(n)}", ha="center", va="bottom", fontsize=8)
  819. ax_cal.set_xlim(0.0, 1.0)
  820. ax_cal.set_ylim(0.0, 1.0)
  821. ax_cal.set_xticks([0, 0.2, 0.4, 0.6, 0.8, 1])
  822. ax_cal.set_title("C. Calibration curve", fontweight="bold", fontsize=18)
  823. ax_cal.set_xlabel("Mean predicted probability of autism", fontsize=15)
  824. ax_cal.set_ylabel("Observed proportion of autism", fontsize=15)
  825. ax_cal.grid(True, linestyle="-", color="0.9", linewidth=1, alpha=0.6)
  826. ax_cal.set_aspect("equal", adjustable="box")
  827. fig.suptitle(title_tag, fontsize=1) # keep layout stable; no visible suptitle
  828. fig.tight_layout()
  829. return fig
  830. # -----------------------------------------------------------------------------
  831. # Model runner
  832. # -----------------------------------------------------------------------------
  833. @dataclass
  834. class TIModelResult:
  835. model_tag: str
  836. w_mode: str
  837. w_ap: float
  838. w_cc: float
  839. w_nb: float
  840. C_in: float
  841. C_median_outer: float
  842. auc_in: float
  843. auc_cv: float
  844. auc_cv_ci_low: float
  845. auc_cv_ci_high: float
  846. auc_cv_ci_nboot: int
  847. brier_in: float
  848. brier_cv: float
  849. brier_cv_ci_low: float
  850. brier_cv_ci_high: float
  851. brier_cv_ci_nboot: int
  852. ba_in: float
  853. ba_cv: float
  854. ba_cv_ci_low: float
  855. ba_cv_ci_high: float
  856. ba_cv_ci_nboot: int
  857. thr_prob_in: float
  858. thr_prob_cv: float
  859. thr_ti_in: float
  860. thr_ti_cv_for_plot: float
  861. def run_one_ti_model(
  862. triad: pd.DataFrame,
  863. model_tag: str,
  864. w_mode: str,
  865. w_ap_in: float,
  866. w_cc_in: float,
  867. w_nb_in: float,
  868. normalization: str,
  869. n_cal_bins: int,
  870. inner_k: int,
  871. n_boot_auc: int,
  872. out_dir: Path,
  873. fig_dir: Path,
  874. boxplot: bool,
  875. random_seed: int,
  876. ) -> TIModelResult:
  877. y = triad[DIAG_COL].to_numpy(int)
  878. ap = triad["AP_raw"].to_numpy(float)
  879. cc = triad["CC_raw"].to_numpy(float)
  880. nb = triad["NB_raw"].to_numpy(float)
  881. # Fixed weights (for specified/equal). For derived mode, these are just for the descriptive axis.
  882. w_ap, w_cc, w_nb = normalize_weights(w_ap_in, w_cc_in, w_nb_in, method=normalization)
  883. # -------------------------
  884. # Full-sample TI axis (descriptive Panel A)
  885. # -------------------------
  886. z_ap = triad["z_AP"].to_numpy(float)
  887. z_cc = triad["z_CC_risk"].to_numpy(float)
  888. z_nb = triad["z_NB_risk"].to_numpy(float)
  889. ti_raw_full = w_ap * z_ap + w_cc * z_cc + w_nb * z_nb
  890. mu_full, sd_full = _mean_sd(ti_raw_full)
  891. ti_full = z_from_train(ti_raw_full, mu_full, sd_full).astype(float)
  892. # -------------------------
  893. # In-sample ridge on full-sample TI axis (C tuned by 5-fold CV)
  894. # -------------------------
  895. X_in = ti_full.reshape(-1, 1)
  896. C_in, C_diag = tune_C_on_fixed_feature(X_in, y, desired_k=5, seed=random_seed + 42)
  897. m_in = ridge_logistic(C_in)
  898. m_in.fit(X_in, y)
  899. prob_in = proba_of_label(m_in, X_in, POS_LABEL)
  900. score_in = centered_decision_score(m_in, X_in)
  901. auc_in = float(roc_auc_score(y, score_in)) # Route 1 AUC
  902. auc_in_prob = float(roc_auc_score(y, prob_in)) # optional diagnostic
  903. brier_in = float(brier_score_loss(y, prob_in))
  904. thr_score_in, ba_in, _, _ = best_ba_point(y, score_in) # Route 1 BA
  905. intercept_in = float(m_in.intercept_[0])
  906. beta_in = float(m_in.coef_[0, 0])
  907. thr_prob_in = score_to_prob_threshold(thr_score_in, intercept_in) # descriptive
  908. thr_ti_in = score_to_ti_threshold(thr_score_in, beta_in)
  909. intercept_in = float(m_in.intercept_[0])
  910. beta_in = float(m_in.coef_[0, 0])
  911. thr_ti_in = prob_to_ti_threshold(thr_prob_in, intercept_in, beta_in)
  912. # Save in-sample C tuning diagnostics (optional but helpful)
  913. C_diag.to_csv(out_dir / f"ti_C_in_sample_{format_model_tag(model_tag)}.csv", index=False)
  914. # -------------------------
  915. # LOOCV nested ridge (leakage-free)
  916. # -------------------------
  917. ti_cv, prob_cv, score_cv, fold_df = loocv_ti_probs_nested_ridge(
  918. triad=triad,
  919. w_mode=w_mode,
  920. fixed_w=(w_ap, w_cc, w_nb),
  921. inner_k=inner_k,
  922. random_seed=random_seed,
  923. )
  924. auc_cv = float(roc_auc_score(y, score_cv)) # Route 1 AUC
  925. auc_cv_prob = float(roc_auc_score(y, prob_cv)) # optional diagnostic
  926. auc_cv_ci_low, auc_cv_ci_high, auc_cv_ci_nboot = bootstrap_auc_ci(
  927. y_true=y,
  928. y_score=score_cv,
  929. n_boot=int(n_boot_auc),
  930. seed=int(random_seed) + 777,
  931. alpha=0.05,
  932. )
  933. brier_cv = float(brier_score_loss(y, prob_cv))
  934. thr_score_cv, ba_cv, _, _ = best_ba_point(y, score_cv) # Route 1 BA
  935. thr_prob_cv = score_to_prob_threshold(thr_score_cv, intercept_in) # descriptive
  936. brier_cv_ci_low, brier_cv_ci_high, brier_cv_ci_nboot = bootstrap_brier_ci(
  937. y_true=y,
  938. y_prob=prob_cv,
  939. n_boot=int(n_boot_auc),
  940. seed=int(random_seed) + 888,
  941. alpha=0.05,
  942. )
  943. ba_cv_ci_low, ba_cv_ci_high, ba_cv_ci_nboot = bootstrap_ba_ci(
  944. y_true=y,
  945. y_score=score_cv,
  946. n_boot=int(n_boot_auc),
  947. seed=int(random_seed) + 999,
  948. alpha=0.05,
  949. )
  950. # Map LOOCV best-BA probability threshold onto the *full-sample TI axis* for Panel A display
  951. thr_ti_cv_for_plot = score_to_ti_threshold(thr_score_cv, beta_in)
  952. # Calibration (LOOCV)
  953. cal_df = calibration_table_fixed_edges(y, prob_cv, bin_edges=[0.0, 0.25, 0.5, 0.75, 1.0])
  954. # Save per-model predictions
  955. pred_df = triad.copy()
  956. pred_df["TI_fullsample_axis"] = ti_full
  957. pred_df["TI_LOOCV_foldwise"] = ti_cv
  958. pred_df["prob_in_sample"] = prob_in
  959. pred_df["prob_LOOCV"] = prob_cv
  960. pred_df["score_in_sample_centered"] = score_in
  961. pred_df["score_LOOCV_centered"] = score_cv
  962. # Attach per-subject C (the C chosen for the fold where that subject was held out)
  963. # (one row per subject; fold_df is indexed by heldout_index)
  964. pred_df = pred_df.merge(
  965. fold_df[["heldout_index", "C_best"]].rename(columns={"heldout_index": "row_index"}),
  966. left_index=True, right_on="row_index", how="left"
  967. ).drop(columns=["row_index"])
  968. pred_df.to_csv(out_dir / f"ti_predictions_{format_model_tag(model_tag)}.csv", index=False)
  969. # Save calibration
  970. cal_df.to_csv(out_dir / f"ti_calibration_{format_model_tag(model_tag)}.csv", index=False)
  971. # Save outer-fold summary (C, and for derived also fold weights/betas)
  972. fold_df.to_csv(out_dir / f"ti_outer_fold_summary_{format_model_tag(model_tag)}.csv", index=False)
  973. # Summary of per-fold C
  974. C_median_outer = float(np.median(fold_df["C_best"].to_numpy(float)))
  975. cdiag_out = fold_df[["heldout_index", "heldout_id", "heldout_y", "C_best", "inner_k", "w_mode", "w_ap", "w_cc", "w_nb"]].copy()
  976. cdiag_out.to_csv(out_dir / f"ti_C_diagnostics_{format_model_tag(model_tag)}.csv", index=False)
  977. # Figure
  978. fig = make_joint_figure(
  979. ti_full=ti_full,
  980. y=y,
  981. score_in=score_in,
  982. score_cv=score_cv,
  983. cal_df=cal_df,
  984. auc_in=auc_in,
  985. auc_cv=auc_cv,
  986. thr_ti_for_plot=thr_ti_cv_for_plot,
  987. title_tag=model_tag,
  988. use_boxplot=boxplot,
  989. jitter_seed=12345,
  990. )
  991. fig_base = fig_dir / f"TriadIndex_TI_Calibration_ROC_combined_{format_model_tag(model_tag)}"
  992. save_figure_triplet(fig, fig_base)
  993. plt.close(fig)
  994. return TIModelResult(
  995. model_tag=model_tag,
  996. w_mode=w_mode,
  997. w_ap=float(w_ap), w_cc=float(w_cc), w_nb=float(w_nb),
  998. C_in=float(C_in),
  999. C_median_outer=float(C_median_outer),
  1000. auc_in=float(auc_in), auc_cv=float(auc_cv),
  1001. auc_cv_ci_low=float(auc_cv_ci_low),
  1002. auc_cv_ci_high=float(auc_cv_ci_high),
  1003. auc_cv_ci_nboot=int(auc_cv_ci_nboot),
  1004. brier_in=float(brier_in), brier_cv=float(brier_cv),
  1005. brier_cv_ci_low=float(brier_cv_ci_low),
  1006. brier_cv_ci_high=float(brier_cv_ci_high),
  1007. brier_cv_ci_nboot=int(brier_cv_ci_nboot),
  1008. ba_in=float(ba_in), ba_cv=float(ba_cv),
  1009. ba_cv_ci_low=float(ba_cv_ci_low),
  1010. ba_cv_ci_high=float(ba_cv_ci_high),
  1011. ba_cv_ci_nboot=int(ba_cv_ci_nboot),
  1012. thr_prob_in=float(thr_prob_in), thr_prob_cv=float(thr_prob_cv),
  1013. thr_ti_in=float(thr_ti_in),
  1014. thr_ti_cv_for_plot=float(thr_ti_cv_for_plot),
  1015. )
  1016. # -----------------------------------------------------------------------------
  1017. # CLI
  1018. # -----------------------------------------------------------------------------
  1019. def parse_args() -> argparse.Namespace:
  1020. p = argparse.ArgumentParser(description="Script 04 (1-SE nested-CV) — TriadIndex evaluation with nested LOOCV + inner C tuning (ridge).")
  1021. p.add_argument("--input-xlsx", type=str, default=str(DEFAULT_INPUT_XLSX))
  1022. p.add_argument("--output-root", type=str, default=str(DEFAULT_OUTPUT_ROOT))
  1023. p.add_argument("--random-seed", type=int, default=RANDOM_SEED)
  1024. p.add_argument("--n-boot-auc", type=int, default=2000,
  1025. help="Bootstrap replicates for LOOCV metric 95% CIs (AUC, Brier, BA); 0 disables.")
  1026. # User-specified weights (main model)
  1027. p.add_argument("--w-ap", type=float, default=2.0)
  1028. p.add_argument("--w-cc", type=float, default=1.0)
  1029. p.add_argument("--w-nb", type=float, default=1.0)
  1030. p.add_argument("--weight-normalization", type=str, choices=["l1", "sum", "none"], default="l1")
  1031. # Which models to run
  1032. p.add_argument("--run-specified", action="store_false", help="Run specified-weight TI (default ON if none selected).")
  1033. p.add_argument("--run-equal", action="store_false", help="Run equal-weight TI (1:1:1).")
  1034. p.add_argument("--run-derived", action="store_false", help="Run 3D-derived-weight TI (exploratory).")
  1035. # CV / calibration / plots
  1036. p.add_argument("--inner-k", type=int, default=5, help="Inner folds for C tuning (safe-adjusted if class counts are small).")
  1037. p.add_argument("--n-cal-bins", type=int, default=4)
  1038. p.add_argument("--boxplot", dest="boxplot", action="store_true", help="Show boxplots behind points (default: ON).")
  1039. p.add_argument("--no-boxplot", dest="boxplot", action="store_false", help="Disable boxplots.")
  1040. p.set_defaults(boxplot=True)
  1041. return p.parse_args()
  1042. # -----------------------------------------------------------------------------
  1043. # Main
  1044. # -----------------------------------------------------------------------------
  1045. def main() -> None:
  1046. args = parse_args()
  1047. # If user didn’t specify any model flags, run all three by default.
  1048. if not (args.run_specified or args.run_equal or args.run_derived):
  1049. args.run_specified = True
  1050. args.run_equal = True
  1051. args.run_derived = True
  1052. output_root = Path(args.output_root)
  1053. out_dir = output_root / "04_TriadIndex"
  1054. fig_dir = out_dir / "figures"
  1055. ensure_dir(fig_dir)
  1056. input_path = Path(args.input_xlsx)
  1057. if not input_path.exists():
  1058. raise FileNotFoundError(f"Input file not found: {input_path}")
  1059. df = pd.read_excel(input_path, sheet_name=0)
  1060. triad_data = build_triad_fullsample(df)
  1061. triad = triad_data.df.copy()
  1062. ensure_dir(out_dir)
  1063. # Full-sample derived weights (for reporting / descriptive axis only)
  1064. ap = triad["AP_raw"].to_numpy(float)
  1065. cc = triad["CC_raw"].to_numpy(float)
  1066. nb = triad["NB_raw"].to_numpy(float)
  1067. y = triad[DIAG_COL].to_numpy(int)
  1068. full_idx = np.arange(len(y), dtype=int)
  1069. w_ap_der, w_cc_der, w_nb_der, b_ap, b_cc, b_nb = derive_weights_from_3d_logistic_trainonly(
  1070. ap=ap, cc=cc, nb=nb, y=y, train_idx=full_idx
  1071. )
  1072. # Save weight info (always)
  1073. weight_info = pd.DataFrame([
  1074. {
  1075. "type": "specified_input",
  1076. "w_ap_input": float(args.w_ap), "w_cc_input": float(args.w_cc), "w_nb_input": float(args.w_nb),
  1077. "normalization": str(args.weight_normalization),
  1078. },
  1079. {
  1080. "type": "equal_weights",
  1081. "w_ap_input": 1.0, "w_cc_input": 1.0, "w_nb_input": 1.0,
  1082. "normalization": "l1",
  1083. },
  1084. {
  1085. "type": "derived_from_fullsample_3d_logistic_trainonly",
  1086. "beta_AP": float(b_ap),
  1087. "beta_CC_risk": float(b_cc),
  1088. "beta_NB_risk": float(b_nb),
  1089. "w_AP_l1": float(w_ap_der),
  1090. "w_CC_l1": float(w_cc_der),
  1091. "w_NB_l1": float(w_nb_der),
  1092. "note": "These full-sample derived weights are for descriptive reporting; LOOCV uses train-only derived weights per fold.",
  1093. }
  1094. ])
  1095. weight_info.to_csv(out_dir / "ti_weight_info.csv", index=False)
  1096. results: List[TIModelResult] = []
  1097. if args.run_specified:
  1098. results.append(
  1099. run_one_ti_model(
  1100. triad=triad,
  1101. model_tag=f"TI specified ({args.w_ap:g}:{args.w_cc:g}:{args.w_nb:g})",
  1102. w_mode="fixed",
  1103. w_ap_in=float(args.w_ap),
  1104. w_cc_in=float(args.w_cc),
  1105. w_nb_in=float(args.w_nb),
  1106. normalization=str(args.weight_normalization),
  1107. n_cal_bins=int(args.n_cal_bins),
  1108. inner_k=int(args.inner_k),
  1109. out_dir=out_dir,
  1110. fig_dir=fig_dir,
  1111. boxplot=bool(args.boxplot),
  1112. n_boot_auc=int(args.n_boot_auc),
  1113. random_seed=int(args.random_seed),
  1114. )
  1115. )
  1116. if args.run_equal:
  1117. results.append(
  1118. run_one_ti_model(
  1119. triad=triad,
  1120. model_tag="TI equal (1:1:1)",
  1121. w_mode="fixed",
  1122. w_ap_in=1.0,
  1123. w_cc_in=1.0,
  1124. w_nb_in=1.0,
  1125. normalization="l1",
  1126. n_cal_bins=int(args.n_cal_bins),
  1127. inner_k=int(args.inner_k),
  1128. out_dir=out_dir,
  1129. fig_dir=fig_dir,
  1130. boxplot=bool(args.boxplot),
  1131. n_boot_auc=int(args.n_boot_auc),
  1132. random_seed=int(args.random_seed),
  1133. )
  1134. )
  1135. if args.run_derived:
  1136. # Use full-sample derived weights for the descriptive axis/in-sample part,
  1137. # but LOOCV will derive weights leakage-free per fold and inner split (w_mode="derived").
  1138. results.append(
  1139. run_one_ti_model(
  1140. triad=triad,
  1141. model_tag="TI derived (3D logistic |beta|, L1-normalized)",
  1142. w_mode="derived",
  1143. w_ap_in=float(w_ap_der),
  1144. w_cc_in=float(w_cc_der),
  1145. w_nb_in=float(w_nb_der),
  1146. normalization="none",
  1147. n_cal_bins=int(args.n_cal_bins),
  1148. inner_k=int(args.inner_k),
  1149. out_dir=out_dir,
  1150. fig_dir=fig_dir,
  1151. boxplot=bool(args.boxplot),
  1152. n_boot_auc=int(args.n_boot_auc),
  1153. random_seed=int(args.random_seed),
  1154. )
  1155. )
  1156. # Summary table
  1157. summary_df = pd.DataFrame([r.__dict__ for r in results])
  1158. summary_df.insert(0, "ap_var", triad_data.ap_var)
  1159. summary_df.insert(1, "cc_var", triad_data.cc_var)
  1160. summary_df.insert(2, "nb_var", triad_data.nb_var)
  1161. summary_df.insert(3, "n", int(triad.shape[0]))
  1162. summary_df.to_csv(out_dir / "ti_models_summary.csv", index=False)
  1163. print("------------------------------------------------------------")
  1164. print("TriadIndex multi-model analysis complete")
  1165. print(f"Output directory: {out_dir}")
  1166. print("Models run:")
  1167. for r in results:
  1168. print(
  1169. f" - {r.model_tag}: LOOCV AUC={r.auc_cv:.3f}, LOOCV Brier={r.brier_cv:.3f}, "
  1170. f"LOOCV BA(best)={r.ba_cv:.3f}, C_median_outer={r.C_median_outer:.5g}"
  1171. )
  1172. print("------------------------------------------------------------")
  1173. if __name__ == "__main__":
  1174. main()

_04_pt_triadindex.py, no license · at the source

Overview

Authors: Darko Sarovic1,2,3,4,5
  1. Institute of Clinical Sciences, University of Gothenburg, Gothenburg, Sweden
  2. Department of Radiology, Harvard Medical School, Massachusetts General Hospital, Boston, MA, United States
  3. Athinoula A. Martinos Center for Biomedical Imaging, Department of Radiology, Massachusetts General Hospital, Charlestown, MA, United States
  4. Paediatric Health Professions and Paediatric Radiology, Sahlgrenska University Hospital, Gothenburg, Sweden
  5. Department of Women’s and Children’s Health, Uppsala University, Uppsala, Sweden
Journal: Frontiers in psychiatry, volume 17, article 1837909
Dates: received 24 March 2026; accepted 19 May 2026; published online 6 July 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.3389/fpsyt.2026.1837909 · PMID 42519192 · PMCID PMC13381483 · OpenAlex W7167459730
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: structural MRI / diffusion (modality), autism (population)
Methods: Statistics, Machine learning, Preprocessing, Physiology & signal measures
Keywords: autism, autistic traits, heart rate variability (HRV), liability architecture, multilevel framework, multiverse analyses, predictive modeling, pathogenetic triad
Topic: Autism Spectrum Disorder Research (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 26 references in the paper

Abstract

Background: Neuropsychiatric conditions are heterogeneous and mechanistically diverse, yet predictive modeling studies commonly aggregate features without evaluating prespecified liability architectures. The Pathogenetic Triad (PT) is a multilevel framework proposing that diagnostic outcomes reflect the joint configuration of a trait-related domain, cognitive capacity (CC), and neuropathological burden (NB). In autism, the trait-related domain corresponds to autistic personality (AP). We evaluated this framework using out-of-sample prediction as a structured empirical test of architectural coherence rather than as a purely data-driven exercise in classification.

Methods: We analyzed a case–comparison cohort (n = 42, 21 autistic) with dense multimodal characterization, including behavioral phenotyping, psychometric measures, autonomic physiology, structural MRI morphometry, and magnetoencephalographic indices. AP was indexed by the Autism-Spectrum Quotient, CC by Wechsler’s scales of intelligence, and NB by heart-rate variability as a proxy indicator. This multimodal structure enabled direct comparison of theory-constrained PT models with strength-matched, domain-restricted atheoretical combinations. Predictive discrimination was evaluated using leakage-free nested cross-validation with permutation inference.

Results: Across multiverse specifications, low-dimensional PT models ranked among the strongest models of comparable size and achieved discrimination broadly comparable to higher-dimensional models within this dataset. Matched comparisons indicated that including all three PT domains was associated with systematic advantages relative to alternatives with similar univariate input strength, consistent with complementary configurational information relating to case status.

Conclusions: These findings provide preliminary evidence supporting the Pathogenetic Triad as a multilevel architecture of autism liability. Although based on a small and demographically restricted cohort, this densely characterized dataset permitted explicit comparison of theory-guided and atheoretical model spaces. The results illustrate how prespecified multilevel frameworks can be operationalized and empirically evaluated in neuropsychiatric samples.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 12 matches between paragraphs and lines of code.

OSF sg8ad

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Languages: Python (7)
Size: 12 files, 7 scripts
Software Heritage: not checked
Found in: “Data availability statement”
Holds: environment (docs/requirements.txt), documentation
Not found: README, license file, CITATION.cff, tests, continuous integration
Tools: NumPy (7 files), pandas (6 files), scikit-learn (6 files), Matplotlib (5 files), SciPy (3 files), seaborn (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
7 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 7 scripts, each with its path and the digest of its content;
  • 12 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability statement

The datasets presented in this article are not readily available because of ethical/consent constraints but are available from the corresponding author on reasonable request. To enable computational reproducibility, analysis scripts and a fully synthetic dataset are available at https://doi.org/10.17605/OSF.IO/SG8AD. Requests to access the datasets should be directed to DS, .

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 1 author, 8 keywords, 4 funders, 20 references.

Cite

This paper

Sarovic, D. (2026). Preliminary testing of a prespecified liability architecture for autism: theory-guided pathogenetic triad models outperform strength-matched alternatives. Frontiers in psychiatry, 17, 1837909. https://doi.org/10.3389/fpsyt.2026.1837909

BibTeX

@article{sarovic2026preliminary,
author = {Sarovic, Darko},
title = {{Preliminary testing of a prespecified liability architecture for autism: theory-guided pathogenetic triad models outperform strength-matched alternatives}},
journal = {Frontiers in psychiatry},
year = {2026},
month = jul,
volume = {17},
pages = {1837909},
publisher = {Frontiers Media SA},
issn = {1664-0640},
doi = {10.3389/fpsyt.2026.1837909},
url = {https://doi.org/10.3389/fpsyt.2026.1837909},
pmid = {42519192},
pmcid = {PMC13381483}
}

RIS

TY - JOUR
AU - Sarovic, Darko
TI - Preliminary testing of a prespecified liability architecture for autism: theory-guided pathogenetic triad models outperform strength-matched alternatives
T2 - Frontiers in psychiatry
J2 - Front Psychiatry
PY - 2026
DA - 2026/07/06
VL - 17
SP - 1837909
SN - 1664-0640
PB - Frontiers Media SA
DO - 10.3389/fpsyt.2026.1837909
UR - https://doi.org/10.3389/fpsyt.2026.1837909
LA - en
ER -

CSL-JSON

{
"id": "10.3389/fpsyt.2026.1837909",
"type": "article-journal",
"title": "Preliminary testing of a prespecified liability architecture for autism: theory-guided pathogenetic triad models outperform strength-matched alternatives",
"container-title": "Frontiers in psychiatry",
"author": [
{
"family": "Sarovic",
"given": "Darko"
}
],
"container-title-short": "Front Psychiatry",
"volume": "17",
"page": "1837909",
"DOI": "10.3389/fpsyt.2026.1837909",
"PMID": "42519192",
"PMCID": "PMC13381483",
"ISSN": "1664-0640",
"publisher": "Frontiers Media SA",
"URL": "https://doi.org/10.3389/fpsyt.2026.1837909",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
6
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1038/s41598-026-55163-y [code]
Autism spectrum disorder identification using machine learning models on MRI data.
Journal: Scientific reports
In common: seaborn, scikit-learn, pandas, 3 other tools, autism, structural MRI / diffusion
[2] doi:10.1007/s44192-026-00521-5 [code]
Associations between anterior hypothalamic subunits and ADHD and autistic traits revealed by deep learning MRI segmentation.
Journal: Discover mental health
In common: scikit-learn, SciPy, Matplotlib, 1 other tool, autism, structural MRI / diffusion, 1 reference
[3] doi:10.1038/s41467-026-76675-1 [code]
Long-read proteogenomic atlas of human neuronal differentiation reveals isoform diversity informing neurodevelopmental risk mechanisms.
Journal: Nature communications
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[4] doi:10.3389/fgene.2026.1799530 [code]
Sex-dependent prediction of autism.
Journal: Frontiers in genetics
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[5] doi:10.1016/j.celrep.2026.117590 [code]
Impaired behavioral inhibition in Fmr1 KO mice is linked to disrupted visual cortex theta oscillations.
Journal: Cell reports
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[6] doi:10.1038/s41586-026-10679-1 [code]
Cortical development dynamics across autism spectrum disorder mouse models.
Journal: Nature
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[7] doi:10.1162/imag.a.1220 [code]
Brain functional network connectivity interpolation characterizes the neuropsychiatric continuum and heterogeneity.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[8] doi:10.1038/s42003-026-10094-2 [code]
E/I imbalance and internal noise cause weak neural representations and face recognition challenges in ASD.
Journal: Communications biology
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[9] doi:10.1002/advs.202519479 [code]
Diminished Signal-to-Noise Ratio Disrupts Somatosensory Population Encoding and Drives Tactile Hyposensitivity in the Fmr1&lt;sup&gt;-/y&lt;/sup&gt; Autism Model.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: seaborn, scikit-learn, pandas, 3 other tools, autism
[10] doi:10.1002/hbm.70496 [code]
Transdiagnostic Profiles of BOLD Signal Variability in Autism and Schizophrenia Spectrum Disorders: Associations With Cognition and Functioning.
Journal: Human brain mapping
In common: seaborn, scikit-learn, pandas, 3 other tools, autism

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.