OSCR

Interpretable deep survival analysis of Alzheimer's disease via metabolic genetic variants.

Code ↔ Paper

5 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 5 matches
  1. [1] § 3 Results › 3.1 5-Fold cross-validation and performance evaluation ↔ visualize.py, lines 600–665 · score 0.61 · Feedforward Neural Network, Fold Cross Validation, Linear Regression, Standard Deviation, Std, LR
  2. [2] § 3 Results › 3.1 5-Fold cross-validation and performance evaluation ↔ visualize.py, lines 600–665 · score 0.56 · fold cross validation, linear regression, standard deviation, training, FFN, models
  3. [3] § 2 Methods › 2.1 Data collection and preprocessing ↔ visualize.py, lines 491–557 · score 0.54 · T2D, diabetes, dyslipidemia, chromosome, position, Variant
  4. [4] § 3 Results › 3.2 Feature importance analysis using SHAP ↔ visualize.py, lines 691–762 · score 0.52 · APOE E3, APOE E2, APOE E4, MMSE, model
  5. [5] § 2 Methods › 2.3 Evaluating and comparing model with baseline models ↔ visualize.py, lines 45–57 · score 0.51 · Weibull AFT, linear model, concordance, score, event

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 · 888 lines · 28 KB · MIT · 5 matches

  1. # %%
  2. import os
  3. from dataclasses import dataclass
  4. from itertools import combinations
  5. from pathlib import Path
  6. import matplotlib.pyplot as plt
  7. import numpy as np
  8. import pandas as pd
  9. import seaborn as sns
  10. import torch
  11. from captum.attr import IntegratedGradients
  12. from ignite.handlers import Checkpoint
  13. from lifelines import KaplanMeierFitter, WeibullAFTFitter
  14. from model import DeepWeibullModel
  15. from scipy.stats import chi2_contingency, fisher_exact
  16. from seaborn import catplot, boxplot
  17. from sklearn.cluster import (
  18. BisectingKMeans,
  19. )
  20. from sklearn.decomposition import PCA
  21. from torchsurv.loss.weibull import log_hazard, survival_function
  22. from torchsurv.metrics.cindex import ConcordanceIndex
  23. from torchsurv.metrics.brier_score import BrierScore
  24. from torchsurv.stats.kaplan_meier import KaplanMeierEstimator
  25. from lifelines.statistics import logrank_test
  26. from shap import DeepExplainer
  27. import shap
  28. MMSE_COLUMN_NAME = "MMSE"
  29. TARGET_COLUMN_NAME = "Diagnose"
  30. AGE_COLUMN_NAME = "Age"
  31. SEX_COLUMN_NAME = "Sex"
  32. APOE_COLUMN_NAME = "APOE"
  33. FOLD_COLUMN_NAME = "Fold"
  34. CLUSTER_COLUMN_NAME = "Cluster"
  35. BASE_AGE = 55
  36. EPOCHS = 10_000
  37. plt.rcParams["font.family"] = "DejaVu Serif"
  38. plt.rcParams["font.serif"] = ["Book"]
  39. plt.rcParams["mathtext.fontset"] = "cm"
  40. def get_linear_aft_result(df_train, df_test) -> tuple[pd.DataFrame, float, float]:
  41. weibull_aft = WeibullAFTFitter()
  42. weibull_aft.fit(
  43. df_train, duration_col=AGE_COLUMN_NAME, event_col=TARGET_COLUMN_NAME
  44. )
  45. linear_model_c_index_test = weibull_aft.score(
  46. df_test, scoring_method="concordance_index"
  47. )
  48. return (
  49. weibull_aft.summary,
  50. weibull_aft.concordance_index_,
  51. linear_model_c_index_test,
  52. )
  53. def get_fnn_result(df_train, df_test, deep_weibull_model):
  54. features_train_df = df_train.drop(columns=[TARGET_COLUMN_NAME, AGE_COLUMN_NAME])
  55. features_train = torch.tensor(features_train_df.values, dtype=torch.float32)
  56. log_scale_train = deep_weibull_model.forward(features_train)
  57. log_shape_train = deep_weibull_model.log_shape().repeat(log_scale_train.size(0), 1)
  58. log_params_train = torch.cat((log_scale_train, log_shape_train), dim=-1)
  59. durations_train = torch.tensor(
  60. df_train[AGE_COLUMN_NAME].values, dtype=torch.float32
  61. )
  62. events_train = torch.tensor(df_train[TARGET_COLUMN_NAME].values, dtype=torch.bool)
  63. log_hz = log_hazard(log_params_train, durations_train)
  64. c_index = ConcordanceIndex()
  65. c_index_train = c_index(log_hz, events_train, durations_train)
  66. surv_train = survival_function(log_params_train, durations_train)
  67. brier_score = BrierScore()
  68. brier_train = brier_score(surv_train, events_train, durations_train)
  69. brier_score_train = brier_score.integral()
  70. log_scale_test = deep_weibull_model.forward(
  71. torch.tensor(
  72. df_test.drop(columns=[TARGET_COLUMN_NAME, AGE_COLUMN_NAME]).values,
  73. dtype=torch.float32,
  74. )
  75. )
  76. log_shape_test = deep_weibull_model.log_shape().repeat(log_scale_test.size(0), 1)
  77. log_params_test = torch.cat((log_scale_test, log_shape_test), dim=-1)
  78. durations_test = torch.tensor(df_test[AGE_COLUMN_NAME].values, dtype=torch.float32)
  79. events_test = torch.tensor(df_test[TARGET_COLUMN_NAME].values, dtype=torch.bool)
  80. log_hz_test = log_hazard(log_params_test, durations_test)
  81. c_index_test = c_index(log_hz_test, events_test, durations_test)
  82. surv_test = survival_function(log_params_test, durations_test)
  83. brier_score_test = brier_score(surv_test, events_test, durations_test)
  84. brier_score_test = brier_score.integral()
  85. return c_index_train, c_index_test, brier_score_train, brier_score_test
  86. def get_IG(model: DeepWeibullModel, features: pd.DataFrame) -> pd.DataFrame:
  87. features = features.drop(
  88. columns=[TARGET_COLUMN_NAME, AGE_COLUMN_NAME, FOLD_COLUMN_NAME]
  89. )
  90. column_names = features.columns.tolist()
  91. features = torch.tensor(features.values, dtype=torch.float32)
  92. ig = IntegratedGradients(model)
  93. base_line = torch.zeros((1, features.shape[1]), dtype=torch.float32)
  94. # base_line = features.mean(dim=0, keepdim=True)
  95. attributions = ig.attribute(
  96. features, baselines=base_line.repeat([features.shape[0], 1]), n_steps=50
  97. )
  98. attributions_df = pd.DataFrame(
  99. attributions.cpu().detach().numpy(), columns=column_names
  100. )
  101. return attributions_df
  102. def get_shap(
  103. model: DeepWeibullModel, features: pd.DataFrame, seed: int, background_size=900
  104. ) -> pd.DataFrame:
  105. column_names = features.columns.tolist()
  106. features = torch.tensor(features.values, dtype=torch.float32)
  107. rng = np.random.default_rng(seed)
  108. background_samples = rng.choice(
  109. features.shape[0], size=background_size, replace=False
  110. )
  111. explainer = DeepExplainer(
  112. model, features[background_samples]
  113. ) # Use a subset as background
  114. shap_values = explainer.shap_values(features)
  115. shap_values = np.array(shap_values.squeeze(-1))
  116. attributions_df = pd.DataFrame(shap_values, columns=column_names)
  117. plt.figure(figsize=(10, 6))
  118. shap.summary_plot(
  119. shap_values,
  120. features.cpu().numpy(),
  121. feature_names=column_names,
  122. )
  123. plt.xlim(-0.5, 0.5)
  124. plt.tight_layout()
  125. plt.savefig("fig_img/shap_beeswarm.pdf", dpi=300, bbox_inches="tight")
  126. return attributions_df
  127. def get_shap_specific_interaction(
  128. model,
  129. features: pd.DataFrame,
  130. feature_x: str,
  131. feature_color: str,
  132. seed: int,
  133. background_size=900,
  134. ) -> pd.DataFrame:
  135. column_names = features.columns.tolist()
  136. features_tensor = torch.tensor(features.values, dtype=torch.float32)
  137. rng = np.random.default_rng(seed)
  138. background_samples = rng.choice(
  139. features_tensor.shape[0], size=background_size, replace=False
  140. )
  141. explainer = shap.DeepExplainer(model, features_tensor[background_samples])
  142. shap_values = explainer.shap_values(features_tensor)
  143. if isinstance(shap_values, list):
  144. shap_values = shap_values[0]
  145. if len(shap_values.shape) > 2:
  146. shap_values = np.array(shap_values.squeeze(-1))
  147. attributions_df = pd.DataFrame(shap_values, columns=column_names)
  148. plt.figure(figsize=(8, 5))
  149. shap.dependence_plot(
  150. feature_x,
  151. shap_values,
  152. features_tensor.cpu().numpy(),
  153. feature_names=column_names,
  154. interaction_index=feature_color,
  155. show=False,
  156. x_jitter=0.1,
  157. alpha=0.5,
  158. )
  159. plt.tight_layout()
  160. plt.savefig(
  161. f"fig_img/shap_dependence_{feature_x}_vs_{feature_color}.pdf",
  162. dpi=300,
  163. bbox_inches="tight",
  164. )
  165. plt.close()
  166. return attributions_df
  167. def get_mult_shap_specific_interaction(
  168. model,
  169. features: pd.DataFrame,
  170. feature_xs: list[str],
  171. feature_colors: list[str],
  172. seed: int,
  173. background_size=900,
  174. ) -> pd.DataFrame:
  175. column_names = features.columns.tolist()
  176. features_tensor = torch.tensor(features.values, dtype=torch.float32)
  177. rng = np.random.default_rng(seed)
  178. background_samples = rng.choice(
  179. features_tensor.shape[0], size=background_size, replace=False
  180. )
  181. explainer = shap.DeepExplainer(model, features_tensor[background_samples])
  182. shap_values = explainer.shap_values(features_tensor)
  183. if isinstance(shap_values, list):
  184. shap_values = shap_values[0]
  185. if len(shap_values.shape) > 2:
  186. shap_values = np.array(shap_values.squeeze(-1))
  187. attributions_df = pd.DataFrame(shap_values, columns=column_names)
  188. fig, axes = plt.subplots(
  189. len(feature_xs),
  190. len(feature_colors),
  191. figsize=(5 * len(feature_colors), 4 * len(feature_xs)),
  192. )
  193. for i, feature_x in enumerate(feature_xs):
  194. for j, feature_color in enumerate(feature_colors):
  195. shap.dependence_plot(
  196. feature_x,
  197. shap_values,
  198. features_tensor.cpu().numpy(),
  199. feature_names=column_names,
  200. interaction_index=feature_color,
  201. show=False,
  202. x_jitter=0.1,
  203. alpha=0.5,
  204. ax=axes[i, j],
  205. )
  206. plt.tight_layout()
  207. plt.savefig(
  208. f"fig_img/shap_inter.pdf",
  209. dpi=300,
  210. bbox_inches="tight",
  211. )
  212. plt.close()
  213. return attributions_df
  214. def cluster_attributions(features_ig: pd.DataFrame, n_clusters=5, seed=42):
  215. clusterer = BisectingKMeans(
  216. n_clusters=n_clusters,
  217. random_state=seed,
  218. n_init=1,
  219. max_iter=10_000_000,
  220. )
  221. clusterer.fit(features_ig)
  222. features_ig[CLUSTER_COLUMN_NAME] = clusterer.labels_
  223. return features_ig
  224. def plot_pca(
  225. ax,
  226. attributions_df: pd.DataFrame,
  227. palette: dict,
  228. x_lim=[-1.25, 1.25],
  229. y_lim=[-0.25, 0.25],
  230. ):
  231. pca = PCA(n_components=2)
  232. attributions_pca = pca.fit_transform(attributions_df.drop(columns="Cluster"))
  233. scatter_ax = sns.scatterplot(
  234. x=attributions_pca[:, 0],
  235. y=attributions_pca[:, 1],
  236. hue=attributions_df["Cluster"],
  237. palette=palette,
  238. alpha=0.6,
  239. s=60,
  240. ax=ax,
  241. )
  242. scatter_ax.set_xlim(x_lim)
  243. scatter_ax.set_ylim(y_lim)
  244. handles, labels = scatter_ax.get_legend_handles_labels()
  245. order = sorted(range(len(labels)), key=lambda k: labels[k])
  246. scatter_ax.legend(
  247. [handles[idx] for idx in order],
  248. [labels[idx] for idx in order],
  249. title="Cluster",
  250. loc="upper left",
  251. )
  252. return scatter_ax
  253. def median_time_km(clusters, durations, events):
  254. aucs = {}
  255. for cluster in clusters.unique():
  256. mask = clusters == cluster
  257. if events[mask].sum() == 0:
  258. aucs[cluster] = 1.0 * (durations[mask].max() - BASE_AGE)
  259. continue
  260. km = KaplanMeierEstimator()
  261. km_fitter = KaplanMeierFitter()
  262. km_fitter.fit(
  263. durations[mask],
  264. event_observed=events[mask],
  265. )
  266. aucs[cluster] = km_fitter.median_survival_time_
  267. sorted_aucs = dict(sorted(aucs.items(), key=lambda item: item[1], reverse=True))
  268. return sorted_aucs
  269. def plot_km(
  270. ax,
  271. clusters: pd.Series | None,
  272. durations: np.ndarray,
  273. events: np.ndarray,
  274. colors: list,
  275. cluster_list=None,
  276. total=False,
  277. total_color="black",
  278. ):
  279. if clusters is None:
  280. clusters = pd.Series(["All"] * len(durations))
  281. for cluster, color in zip(cluster_list or clusters.unique(), colors):
  282. mask = clusters == cluster
  283. count = mask.sum()
  284. if events[mask].sum() == 0:
  285. ax.plot(
  286. durations[mask] + BASE_AGE,
  287. np.ones_like(durations[mask]),
  288. linestyle=":",
  289. label=f"{cluster} (n={count}) - No events",
  290. color=color,
  291. )
  292. else:
  293. km = KaplanMeierEstimator()
  294. km(
  295. torch.tensor(events[mask], dtype=torch.bool),
  296. torch.tensor(durations[mask], dtype=torch.float32),
  297. )
  298. times = (
  299. km.time.cpu().numpy() + BASE_AGE
  300. if hasattr(km.time, "cpu")
  301. else km.time.numpy() + BASE_AGE
  302. )
  303. surv = (
  304. km.km_est.cpu().numpy()
  305. if hasattr(km.km_est, "cpu")
  306. else km.km_est.numpy()
  307. )
  308. ax.step(
  309. times, surv, where="post", label=f"{cluster} (n={count})", color=color
  310. )
  311. if cluster_list and len(cluster_list) == 2:
  312. mask0 = clusters == cluster_list[0]
  313. mask1 = clusters == cluster_list[1]
  314. logrank_test_result = logrank_test(
  315. df_train[AGE_COLUMN_NAME][mask0],
  316. df_train[AGE_COLUMN_NAME][mask1],
  317. event_observed_A=df_train[TARGET_COLUMN_NAME][mask0],
  318. event_observed_B=df_train[TARGET_COLUMN_NAME][mask1],
  319. )
  320. p_value = logrank_test_result.p_value
  321. ax.plot([], [], " ", label=f"p-value = {p_value:.3g}")
  322. if total:
  323. km = KaplanMeierEstimator()
  324. km(
  325. torch.tensor(events, dtype=torch.bool),
  326. torch.tensor(durations, dtype=torch.float32),
  327. )
  328. times = (
  329. km.time.cpu().numpy() + BASE_AGE
  330. if hasattr(km.time, "cpu")
  331. else km.time.numpy() + BASE_AGE
  332. )
  333. surv = (
  334. km.km_est.cpu().numpy() if hasattr(km.km_est, "cpu") else km.km_est.numpy()
  335. )
  336. count = len(durations)
  337. ax.step(
  338. times, surv, where="post", label=f"Total (n={count})", color=total_color
  339. )
  340. if clusters.nunique() > 0:
  341. handles, labels = ax.get_legend_handles_labels()
  342. order = sorted(range(len(labels)), key=lambda k: labels[k])
  343. if len(labels) > 1:
  344. ax.legend(
  345. [handles[idx] for idx in order],
  346. [labels[idx] for idx in order],
  347. title="Cluster",
  348. loc="lower left",
  349. )
  350. else:
  351. legend = ax.get_legend()
  352. if legend:
  353. legend.remove()
  354. return ax
  355. def make_crosstab(df: pd.DataFrame) -> pd.DataFrame:
  356. df_long = df.melt(
  357. id_vars=["Cluster"],
  358. value_vars=df.drop(columns=["Cluster"]).columns.to_list(),
  359. var_name="feature",
  360. value_name="value",
  361. )
  362. counts_df = pd.crosstab(
  363. index=[df_long["Cluster"], df_long["feature"]], columns=df_long["value"]
  364. )
  365. total_counts_df = pd.crosstab(index=[df_long["feature"]], columns=df_long["value"])
  366. total_counts_df.columns = ["0", "1"]
  367. counts_df.columns = ["0", "1"]
  368. counts_df = counts_df[["1", "0"]]
  369. total_counts_df = total_counts_df[["1", "0"]]
  370. comparison_clusters = list(
  371. combinations(["Total"] + df["Cluster"].unique().tolist(), 2)
  372. )
  373. results = []
  374. for feature in df.drop(columns=["Cluster"]).columns:
  375. for cluster2 in df["Cluster"].unique().tolist():
  376. cluster1 = "Total"
  377. try:
  378. if cluster1 == "Total":
  379. base_counts = total_counts_df.loc[(feature)]
  380. else:
  381. base_counts = counts_df.loc[(cluster1, feature)]
  382. if cluster2 == "Total":
  383. comp_counts = total_counts_df.loc[(feature)]
  384. else:
  385. comp_counts = counts_df.loc[(cluster2, feature)]
  386. contingency_table = pd.DataFrame([base_counts, comp_counts])
  387. contingency_table.index = [
  388. f"Cluster {cluster1}",
  389. f"Cluster {cluster2}",
  390. ]
  391. chi2, p_value, dof, expected = chi2_contingency(contingency_table)
  392. mat = contingency_table.values.astype(float)
  393. odds_ratio = (
  394. (mat[0, 1] * mat[1, 0]) / (mat[0, 0] * mat[1, 1])
  395. if mat[0, 0] * mat[1, 1] != 0.0
  396. else np.nan
  397. )
  398. if odds_ratio is np.nan or odds_ratio == 0:
  399. mat = contingency_table.values.astype(float) + 0.5
  400. odds_ratio = (mat[0, 1] * mat[1, 0]) / (mat[0, 0] * mat[1, 1])
  401. results.append(
  402. {
  403. "feature": feature,
  404. "comparison": f"{cluster1} vs {cluster2}",
  405. "chi2_statistic": chi2,
  406. "p_value": p_value,
  407. "odds_ratio": odds_ratio,
  408. }
  409. )
  410. except:
  411. results.append(
  412. {
  413. "feature": feature,
  414. "comparison": f"{cluster1} vs {cluster2}",
  415. "chi2_statistic": np.nan,
  416. "p_value": np.nan,
  417. "odds_ratio": np.nan,
  418. }
  419. )
  420. return pd.DataFrame(results)
  421. def latex_crosstab(significance_df, gene_csv_path="raw/gene.csv"):
  422. gene_df = pd.read_csv(gene_csv_path)
  423. significance_df["Chromosome"] = significance_df["feature"].apply(
  424. lambda x: x.split(":")[0] if ":" in x else 0
  425. )
  426. significance_df["Position"] = significance_df["feature"].apply(
  427. lambda x: x.split(":")[1][:-1] if ":" in x else 0
  428. )
  429. significance_df["Genotype"] = significance_df["feature"].apply(
  430. lambda x: x.split(":")[1][-1] if ":" in x else 0
  431. )
  432. significance_df["Chromosome"] = significance_df["Chromosome"].astype(int)
  433. significance_df["Position"] = significance_df["Position"].astype(int)
  434. chrpos_rsID_dict = gene_df.set_index(["Chromosome", "Position"])["rsID"].to_dict()
  435. significance_df["rsID"] = significance_df.apply(
  436. lambda row: chrpos_rsID_dict.get((row["Chromosome"], row["Position"]), ""),
  437. axis=1,
  438. )
  439. significance_df = significance_df.merge(
  440. gene_df[["rsID", "Disease Type", "Ref", "Alt", "Gene"]], on="rsID", how="left"
  441. )
  442. significance_df = significance_df.sort_values(
  443. by=["comparison", "Disease Type", "Gene", "odds_ratio"],
  444. ascending=[True, True, True, False],
  445. )
  446. significance_df["Variant"] = significance_df["Ref"] + ">" + significance_df["Alt"]
  447. gene_filter = significance_df["feature"].str.startswith("E")
  448. significance_df.loc[gene_filter, "Variant"] = significance_df.loc[
  449. gene_filter, "feature"
  450. ]
  451. significance_df.loc[gene_filter, "Disease Type"] = "Dyslipidemia"
  452. significance_df.loc[gene_filter, "Gene"] = "APOE"
  453. significance_df.loc[gene_filter, "rsID"] = "rs429358, rs7412"
  454. significance_df["comparison"] = significance_df["comparison"].str.replace(
  455. "Reference vs ", "Ref. vs. "
  456. )
  457. disease_abbreviation = {"Dyslipidemia": "DL", "Type 2 Diabetes": "T2D"}
  458. significance_df["Disease Type"] = significance_df["Disease Type"].replace(
  459. disease_abbreviation
  460. )
  461. significance_df["rsID"] = significance_df["rsID"].str.replace("rs", "")
  462. gene_research_df = pd.read_csv("raw/gene_research.csv")
  463. gene_research_df["rsID"] = gene_research_df["rsID"].str.replace("rs", "")
  464. significance_df = significance_df.merge(
  465. gene_research_df[["rsID", "Risk Genotype"]], on="rsID", how="left"
  466. )
  467. e2f = significance_df["Variant"] == "E2"
  468. significance_df.loc[e2f, "Genotype"] = "TT"
  469. e3f = significance_df["Variant"] == "E3"
  470. significance_df.loc[e3f, "Genotype"] = "TC"
  471. e4f = significance_df["Variant"] == "E4"
  472. significance_df.loc[e4f, "Genotype"] = "CC"
  473. significance_df.loc[e2f, "Risk Genotype"] = "CC"
  474. significance_df.loc[e3f, "Risk Genotype"] = "CC"
  475. significance_df.loc[e4f, "Risk Genotype"] = "CC"
  476. return significance_df
  477. os.chdir("/workspace")
  478. num_folds = 5
  479. DATA_PATH = Path("data")
  480. DATA_PATH.mkdir(exist_ok=True, parents=True)
  481. df_total = pd.read_csv(DATA_PATH / "feature_fold0.csv")
  482. df_total[AGE_COLUMN_NAME] = pd.read_csv(DATA_PATH / "duration_fold0.csv")[
  483. AGE_COLUMN_NAME
  484. ]
  485. df_total[TARGET_COLUMN_NAME] = pd.read_csv(DATA_PATH / "event_fold0.csv")[
  486. TARGET_COLUMN_NAME
  487. ]
  488. df_total[FOLD_COLUMN_NAME] = 0
  489. for fold_idx in range(1, num_folds):
  490. df_fold = pd.read_csv(DATA_PATH / f"feature_fold{fold_idx}.csv")
  491. df_fold[AGE_COLUMN_NAME] = pd.read_csv(DATA_PATH / f"duration_fold{fold_idx}.csv")[
  492. AGE_COLUMN_NAME
  493. ]
  494. df_fold[TARGET_COLUMN_NAME] = pd.read_csv(DATA_PATH / f"event_fold{fold_idx}.csv")[
  495. TARGET_COLUMN_NAME
  496. ]
  497. df_fold[FOLD_COLUMN_NAME] = fold_idx
  498. df_total = pd.concat([df_total, df_fold], ignore_index=True)
  499. num_features = df_total.shape[1] - 3
  500. ckpts = []
  501. for fold_idx in range(num_folds):
  502. ckpt_dir = Path(f"ckpts/train/fold{fold_idx}")
  503. files = sorted([p for p in ckpt_dir.iterdir() if p.is_file()])
  504. if not files:
  505. raise FileNotFoundError(f"No files found in {ckpt_dir}")
  506. ckpt_path = files[0]
  507. ckpts.append(ckpt_path)
  508. # %%
  509. total_metrics = pd.DataFrame(
  510. columns=[
  511. "Fold",
  512. "FFN Train",
  513. "FFN Test",
  514. "LR Train",
  515. "LR Test",
  516. ]
  517. )
  518. for fold in range(num_folds):
  519. print(f"Fold {fold}")
  520. # Load the model state dict from the checkpoint
  521. deep_weibull_model = DeepWeibullModel(input_dim=num_features, aging_process=True)
  522. deep_weibull_model.load_state_dict(
  523. torch.load(ckpts[fold], map_location="cpu")["model"]
  524. )
  525. df_train = df_total[df_total[FOLD_COLUMN_NAME] != fold]
  526. df_test = df_total[df_total[FOLD_COLUMN_NAME] == fold]
  527. metric = get_fnn_result(
  528. df_train.drop(columns=[FOLD_COLUMN_NAME]),
  529. df_test.drop(columns=[FOLD_COLUMN_NAME]),
  530. deep_weibull_model,
  531. )
  532. c_index_fnn_train, c_index_fnn_test, _, _ = metric
  533. c_index_dict = {}
  534. c_index_dict["Fold"] = fold
  535. c_index_dict["FFN Train"] = c_index_fnn_train.item()
  536. c_index_dict["FFN Test"] = c_index_fnn_test.item()
  537. df_train_for_linear = df_train.drop(columns=[FOLD_COLUMN_NAME])
  538. df_train_for_linear[MMSE_COLUMN_NAME] = (
  539. df_train[MMSE_COLUMN_NAME].mean() / df_train[MMSE_COLUMN_NAME].std()
  540. )
  541. df_test_for_linear = df_test.drop(columns=[FOLD_COLUMN_NAME])
  542. df_test_for_linear[MMSE_COLUMN_NAME] = (
  543. df_test[MMSE_COLUMN_NAME].mean() / df_test[MMSE_COLUMN_NAME].std()
  544. )
  545. _, l_cindex_train, l_cindex_test = get_linear_aft_result(
  546. df_train_for_linear,
  547. df_test_for_linear,
  548. )
  549. c_index_dict["LR Train"] = l_cindex_train
  550. c_index_dict["LR Test"] = l_cindex_test
  551. total_metrics = pd.concat(
  552. [total_metrics, pd.DataFrame([c_index_dict])], ignore_index=True
  553. )
  554. mean_dict = {"Fold": "Mean"}
  555. std_dict = {"Fold": "Std"}
  556. for col in total_metrics.columns.drop("Fold"):
  557. mean_dict[col] = total_metrics[col].mean()
  558. std_dict[col] = total_metrics[col].std()
  559. total_metrics = pd.concat(
  560. [total_metrics, pd.DataFrame([mean_dict]), pd.DataFrame([std_dict])],
  561. ignore_index=True,
  562. )
  563. total_metrics.to_latex(
  564. "tables/c_index.tex",
  565. index=False,
  566. float_format="%.4f",
  567. caption="Concordance index(C-index) from 5-Fold Cross-Validation, LR: Linear Regression, FFN: Feedforward Neural Network, Std: Standard Deviation.",
  568. label="tab:c_index",
  569. position="htbp",
  570. )
  571. # %%
  572. gene_df = pd.read_csv("raw/gene.csv")
  573. chrpos_rsID = gene_df.set_index(["Chromosome", "Position"])["rsID"].to_dict()
  574. rename_map = {}
  575. used_names = set()
  576. for col in df_total.columns:
  577. if col == FOLD_COLUMN_NAME:
  578. continue
  579. new_name = col
  580. if ":" in col:
  581. try:
  582. chrom, rest = col.split(":", 1)
  583. chrom_i = int(chrom)
  584. pos = int(rest[:-1]) # drop allele letter at end
  585. rs = chrpos_rsID.get((chrom_i, pos))
  586. if isinstance(rs, str) and rs.strip():
  587. new_name = rs
  588. rename_map[col] = new_name + rest[-1]
  589. except Exception:
  590. new_name = col
  591. rename_map["E3"] = "APOE E3"
  592. rename_map["E4"] = "APOE E4"
  593. rename_map["E2"] = "APOE E2"
  594. # %%
  595. lr_summary, _, _ = get_linear_aft_result(
  596. df_total.drop(columns=[FOLD_COLUMN_NAME]), df_total.drop(columns=[FOLD_COLUMN_NAME])
  597. )
  598. lr_summary = (
  599. lr_summary.drop([("rho_", "Intercept")])
  600. .drop([("lambda_", "Intercept")])
  601. .reset_index()
  602. .drop(columns=["param"])
  603. )
  604. rename_map["E2"] = "APOE E2"
  605. rename_map["E3"] = "APOE E3"
  606. rename_map["E4"] = "APOE E4"
  607. rename_map["MMSE"] = "MMSE"
  608. rename_map["Female"] = "Female"
  609. lr_summary["covariate"] = lr_summary["covariate"].map(rename_map)
  610. lr_summary["abs_coef"] = lr_summary["coef"].abs()
  611. lr_summary = lr_summary[lr_summary["p"] < 0.05]
  612. lr_summary = lr_summary.sort_values(by=["p"], ascending=True)
  613. fig, ax = plt.subplots(figsize=(8, 4))
  614. lr_summary = lr_summary[:15]
  615. errors = [
  616. lr_summary["coef"] - lr_summary["coef lower 95%"],
  617. lr_summary["coef upper 95%"] - lr_summary["coef"],
  618. ]
  619. colors = ["red" if c < 0 else "royalblue" for c in lr_summary["coef"].values]
  620. ax.bar(
  621. x=lr_summary["covariate"],
  622. height=lr_summary["abs_coef"],
  623. color=colors,
  624. )
  625. ax.axhline(0, ls="--", color="gray", linewidth=2, zorder=0)
  626. ax.set_xticklabels(lr_summary["covariate"], rotation=90, ha="right")
  627. ax.set_ylabel("|Coefficient|")
  628. fig.savefig("fig_img/linear_aft_coef.pdf", dpi=300, bbox_inches="tight")
  629. # %%
  630. total_shap = pd.DataFrame()
  631. full_chpt = Path("ckpts/train/fold-1/checkpoint_-1.7854.pt")
  632. deep_weibull_model_full = DeepWeibullModel(
  633. input_dim=df_total.shape[1] - 3, aging_process=True
  634. )
  635. deep_weibull_model_full.load_state_dict(
  636. torch.load(full_chpt, map_location="cpu")["model"]
  637. )
  638. df_shap = df_total.rename(columns=rename_map)
  639. attributions_df = get_shap(
  640. model=deep_weibull_model_full,
  641. features=df_shap.drop(
  642. columns=[TARGET_COLUMN_NAME, AGE_COLUMN_NAME, FOLD_COLUMN_NAME]
  643. ),
  644. seed=42,
  645. )
  646. # %%
  647. top_features_shap = attributions_df.abs().mean().sort_values(ascending=False)
  648. top_n = 15
  649. fig, ax = plt.subplots(figsize=(8, 4))
  650. top15_features_shap = top_features_shap[:top_n]
  651. colors = [
  652. "red" if v < 0 else "royalblue"
  653. for v in attributions_df.rename(columns=rename_map)[top15_features_shap.index]
  654. .mean()
  655. .values
  656. ]
  657. ax.bar(
  658. x=top15_features_shap.index,
  659. height=top15_features_shap.values,
  660. # color=colors,
  661. )
  662. ax.set_xticklabels(top15_features_shap.index, rotation=90, ha="right")
  663. ax.set_ylabel("Mean(|SHAP value|)")
  664. fig.savefig("fig_img/shap_top15.pdf", dpi=300, bbox_inches="tight")
  665. # %%
  666. feature_counts = [10, 20, 30, 40]
  667. all_results = []
  668. df_total_for_subset = df_total.rename(columns=rename_map)
  669. for n in feature_counts:
  670. ckpt_dir = Path(f"ckpts/top{n}_train/fold-1")
  671. files = sorted([p for p in ckpt_dir.iterdir() if p.is_file()])
  672. if not files:
  673. raise FileNotFoundError(f"No checkpoint files found in {ckpt_dir}")
  674. chpt_path = files[0]
  675. top_features_shap_names = top_features_shap.index.tolist()[:n]
  676. model_n = DeepWeibullModel(input_dim=n, aging_process=True)
  677. model_n.load_state_dict(torch.load(chpt_path, map_location="cpu")["model"])
  678. df_subset = df_total_for_subset.drop(columns=[FOLD_COLUMN_NAME])[
  679. top_features_shap_names + [AGE_COLUMN_NAME, TARGET_COLUMN_NAME]
  680. ]
  681. c_index_train_n, c_index_test_n, brier_train_n, brier_test_n = get_fnn_result(
  682. df_subset, df_subset, model_n
  683. )
  684. all_results.append(
  685. {
  686. "Number of Features": n,
  687. "C-index": c_index_train_n.item(),
  688. "Brier": brier_train_n.item(),
  689. }
  690. )
  691. apoe_ckpt = Path("ckpts/top_apoe_train/fold-1/checkpoint_-1.8456.pt")
  692. model_apoe = DeepWeibullModel(input_dim=5, aging_process=True)
  693. model_apoe.load_state_dict(torch.load(apoe_ckpt, map_location="cpu")["model"])
  694. df_apoe = df_total.drop(columns=[FOLD_COLUMN_NAME])[
  695. ["E4", "MMSE", "E2", "E3", "Female", AGE_COLUMN_NAME, TARGET_COLUMN_NAME]
  696. ]
  697. (
  698. c_index_fnn_train_apoe,
  699. c_index_fnn_test_apoe,
  700. brier_fnn_train_apoe,
  701. brier_fnn_test_apoe,
  702. ) = get_fnn_result(df_apoe, df_apoe, model_apoe)
  703. all_results.append(
  704. {
  705. "Number of Features": 5,
  706. "C-index": c_index_fnn_train_apoe.item(),
  707. "Brier": brier_fnn_train_apoe.item(),
  708. }
  709. )
  710. full_chpt = Path("ckpts/train/fold-1/checkpoint_-1.7854.pt")
  711. deep_weibull_model_full = DeepWeibullModel(
  712. input_dim=df_total.shape[1] - 3, aging_process=True
  713. )
  714. deep_weibull_model_full.load_state_dict(
  715. torch.load(full_chpt, map_location="cpu")["model"]
  716. )
  717. (
  718. c_index_fnn_train_full,
  719. c_index_fnn_test_full,
  720. brier_fnn_train_full,
  721. brier_fnn_test_full,
  722. ) = get_fnn_result(
  723. df_total.drop(columns=[FOLD_COLUMN_NAME]),
  724. df_total.drop(columns=[FOLD_COLUMN_NAME]),
  725. deep_weibull_model_full,
  726. )
  727. all_results.append(
  728. {
  729. "Number of Features": df_total.shape[1] - 3,
  730. "C-index": c_index_fnn_train_full.item(),
  731. "Brier": brier_fnn_train_full.item(),
  732. }
  733. )
  734. all_cindex = pd.DataFrame(all_results).sort_values(by="Number of Features")
  735. fig, ax = plt.subplots(figsize=(6, 4))
  736. sns.lineplot(
  737. data=all_cindex,
  738. x="Number of Features",
  739. y="C-index",
  740. marker="o",
  741. ax=ax,
  742. )
  743. ax.set_xticks(all_cindex["Number of Features"])
  744. ax.set_ylim(0.65, 0.75)
  745. fig.savefig("fig_img/cindex_by_num_features.pdf", dpi=300, bbox_inches="tight")
  746. # %%
  747. fig, ax = plt.subplots(figsize=(6, 4))
  748. sns.lineplot(
  749. data=all_cindex.rename(columns={"Brier": "Integrated Brier Score"}),
  750. x="Number of Features",
  751. y="Integrated Brier Score",
  752. marker="o",
  753. ax=ax,
  754. )
  755. ax.set_xticks(all_cindex["Number of Features"])
  756. fig.savefig("fig_img/brier_by_num_features.pdf", dpi=300, bbox_inches="tight")
  757. # %%
  758. get_mult_shap_specific_interaction(
  759. model=deep_weibull_model_full,
  760. features=df_shap.drop(
  761. columns=[TARGET_COLUMN_NAME, AGE_COLUMN_NAME, FOLD_COLUMN_NAME]
  762. ),
  763. feature_xs=[
  764. "rs17145738T",
  765. "rs17145738G",
  766. "rs1558902A",
  767. "rs7007797G",
  768. "rs662799G",
  769. "rs9939609A",
  770. "rs708272A",
  771. ],
  772. feature_colors=["APOE E4", "APOE E3", "APOE E2"],
  773. seed=42,
  774. background_size=900,
  775. )
  776. # %%

visualize.py at commit b63c5e5, under MIT · at the source

Overview

  1. College of Pharmacy, Chungnam National University, Daejeon, 34134, Republic of Korea
  2. Department of Bio-AI convergence, Chungnam National University, Daejeon, 34134, Republic of Korea
  3. Institute of Drug Research and Development, Chungnam National University, Daejeon, 34134, Republic of Korea
  4. Department of Computer Science and Engineering, Chungnam National University, Daejeon, 34134, Republic of Korea
Institutions: Chungnam National University (South Korea)
Journal: Bioinformatics (Oxford, England), volume 42, issue 6, article btag213
Dates: received 10 December 2025; accepted 22 April 2026; published online 30 April 2026; in print June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1093/bioinformatics/btag213 · PMID 42063212 · PMCID PMC13224968 · OpenAlex W7159677252
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: genetics / omics (modality), human (organism), Alzheimer's / dementia (population), clinical / translational (subfield)
Methods: Machine learning, Statistics
MeSH: Alzheimer Disease*, Deep Learning*, Genetic Predisposition to Disease, Humans, Polymorphism, Single Nucleotide, Survival Analysis (* major topic)
Journal subjects: Genome Analysis
Topic: Genetic Associations and Epidemiology (Genetics, Biochemistry, Genetics and Molecular Biology), according to OpenAlex
Funding: Chungnam National University; Institute of Information & Communications Technology Planning Evaluation (IITP); Korea government (MSIT) (RS-2022–00155857, RS-2023–00278597, RS-2022-NR070856); Artificial Intelligence Convergence Innovation Human Resources Development (Chungnam National University); National Research Foundation of Korea (NRF); Senior Health Convergence Research Center based on Life Cycle (Chungnam National University)]; Basic Science Research Program through the National Research Foundation of Korea (NRF); Ministry of Education (RS-2025–25397599); Korea Environmental Industry & Technology Institute (KEITI); Core Technology Development Project for Environmental Diseases Prevention and Management; Korea Ministry of Environment (MOE) (RS-2021-KE001333); Korea Machine Learning Ledger Orchestration for Drug Discovery Project (K-MELLODDY); Ministry of Health & Welfare and Ministry of Science and ICT (RS-2024–00460694); Korea Institute of Toxicology (KIT) (2710008763, KK-2401–01); Korea Health Technology R&D Project through the Korea Health Industry Development Institute (KHIDI); Ministry of Health & Welfare (RS-2024–00336984, RS-2025–02306055); Ministry of Trade; Energy (MOTIE); Korea Institute for Advancement of Technology (KIAT) (RS-2024–00434342, 26212MFDS008); Ministry of Food and Drug Safety (MFDS)
Citations: not cited yet (Europe PMC); 42 references in the paper

Abstract

Background: Alzheimer’s disease (AD) is a progressive neurodegenerative disease. Traditional models for estimating AD onset cannot capture nonlinear interactions (epistasis) among the numerous genetic variables that contribute to AD risk.

Methods: We developed a feedforward neural network (FFN)–Weibull survival model to predict AD onset using large-scale single-nucleotide polymorphism (SNP) data. We integrated an XAI technique, Shapley additive explanations (SHAP), to address the black-box nature of deep learning, interpret model predictions, and quantify the contribution of each genetic factor to AD.

Results: The FFN model achieved a mean concordance index of 0.647, demonstrating an approximately 3.6% improvement over the traditional linear baseline (0.625). The FFN-SHAP model validated established findings, identifying APOE E4 as a primary AD risk factor. APOE E2 strongly protected against AD. Metabolic-disorder-related SNPs had conflicting effects, suggesting gene–environment interactions influence AD onset.

Conclusions: By effectively bypassing the combinatorial explosion of interaction terms, the predictive power of an FFN combined with XAI provides a robust methodological tool for identifying the genetic basis of complex diseases, even in cohorts with limited sample sizes. Our model generated novel testable hypotheses regarding the intricate roles of gene–gene and gene–environment interactions in AD pathogenesis.

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 5 matches between paragraphs and lines of code.

swgoo/dementia_xai_sa

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: b63c5e5391cf9927db947aa4af0f4a5991e9e29f, 24 April 2026
Languages: Python (2)
Size: 15 files, 2 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: license file, environment (requirements.txt)
Not found: README, CITATION.cff, tests, continuous integration, documentation
Tools: pandas (2 files), PyTorch (2 files), Matplotlib (1 file), NumPy (1 file), scikit-learn (1 file), SciPy (1 file), seaborn (1 file), SHAP (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
3 files

Code availability

The source codes for the proposed FFN-Weibull model and SHAP analysis are provided at git repository (https://github.com/swgoo/dementia_xai_sa). The dataset and trained model weights cannot be shared due to Institutional Review Board (IRB) and genetic privacy restrictions.

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

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;
  • 2 scripts, each with its path and the digest of its content;
  • 5 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.

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, issue, pages, dates, 5 authors, 6 MeSH terms, 20 funders, 32 references.

Cite

This paper

Goo, S., Lee, S., Chae, J.-W., Jung, S., & Yun, H.-y. (2026). Interpretable deep survival analysis of Alzheimer's disease via metabolic genetic variants. Bioinformatics (Oxford, England), 42(6), btag213. https://doi.org/10.1093/bioinformatics/btag213

BibTeX

@article{goo2026interpretable,
author = {Goo, Sungwoo and Lee, Soyoung and Chae, Jung-Woo and Jung, Sangkeun and Yun, Hwi-yeol},
title = {{Interpretable deep survival analysis of Alzheimer's disease via metabolic genetic variants}},
journal = {Bioinformatics (Oxford, England)},
year = {2026},
month = jun,
volume = {42},
number = {6},
pages = {btag213},
publisher = {Oxford University Press},
issn = {1367-4803},
doi = {10.1093/bioinformatics/btag213},
url = {https://doi.org/10.1093/bioinformatics/btag213},
pmid = {42063212},
pmcid = {PMC13224968}
}

RIS

TY - JOUR
AU - Goo, Sungwoo
AU - Lee, Soyoung
AU - Chae, Jung-Woo
AU - Jung, Sangkeun
AU - Yun, Hwi-yeol
TI - Interpretable deep survival analysis of Alzheimer's disease via metabolic genetic variants
T2 - Bioinformatics (Oxford, England)
J2 - Bioinformatics
PY - 2026
DA - 2026/06/01
VL - 42
IS - 6
SP - btag213
SN - 1367-4803
PB - Oxford University Press
DO - 10.1093/bioinformatics/btag213
UR - https://doi.org/10.1093/bioinformatics/btag213
LA - en
ER -

CSL-JSON

{
"id": "10.1093/bioinformatics/btag213",
"type": "article-journal",
"title": "Interpretable deep survival analysis of Alzheimer's disease via metabolic genetic variants",
"container-title": "Bioinformatics (Oxford, England)",
"author": [
{
"family": "Goo",
"given": "Sungwoo"
},
{
"family": "Lee",
"given": "Soyoung"
},
{
"family": "Chae",
"given": "Jung-Woo"
},
{
"family": "Jung",
"given": "Sangkeun"
},
{
"family": "Yun",
"given": "Hwi-yeol"
}
],
"container-title-short": "Bioinformatics",
"volume": "42",
"issue": "6",
"page": "btag213",
"DOI": "10.1093/bioinformatics/btag213",
"PMID": "42063212",
"PMCID": "PMC13224968",
"ISSN": "1367-4803",
"publisher": "Oxford University Press",
"URL": "https://doi.org/10.1093/bioinformatics/btag213",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
1
]
]
}
}

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-48613-0 [code]
An snRNA-seq aging clock for the fruit fly head sheds light on sex-biased aging.
Journal: Scientific reports
In common: SHAP, PyTorch, seaborn, 5 other tools, genetics / omics, 1 reference
[2] doi:10.1038/s41467-026-76837-1 [code]
Drug screen and machine learning predict neuroprotective agents in a preclinical human model of childhood dementia.
Journal: Nature communications
In common: SHAP, PyTorch, seaborn, 5 other tools, Alzheimer's / dementia
[3] doi:10.1038/s43856-026-01606-6 [code]
Validation of remote multimodal AI screening for Parkinson disease across diverse settings.
Journal: Communications medicine
In common: SHAP, PyTorch, seaborn, 5 other tools, clinical / translational
[4] doi:10.1038/s41467-026-71555-0 [code]
A deep representation learning model to predict response to vagus nerve stimulation.
Journal: Nature communications
In common: SHAP, PyTorch, seaborn, 5 other tools, clinical / translational
[5] doi:10.1186/s13059-026-04125-8 [code]
MLMarker: a machine learning framework for tissue inference and biomarker discovery.
Journal: Genome biology
In common: SHAP, PyTorch, seaborn, 5 other tools, genetics / omics
[6] doi:10.1016/j.isci.2026.116825 [code]
Social hierarchy shapes behavioral and transcriptional responses to chronic stress and ketamine in male mice.
Journal: iScience
In common: SHAP, PyTorch, seaborn, 5 other tools, genetics / omics
[7] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: SHAP, PyTorch, seaborn, 5 other tools
[8] doi:10.1371/journal.pone.0345854 [code]
Shedding light on neural learning to rank models for anticancer drug prioritization.
Journal: PloS one
In common: SHAP, PyTorch, seaborn, 5 other tools
[9] doi:10.1371/journal.pcbi.1014615 [code]
Toward reliable machine learning models for neural circuit inference: A diagnostic study of CNNs on spike trains.
Journal: PLoS computational biology
In common: SHAP, PyTorch, seaborn, 5 other tools
[10] doi:10.1093/nargab/lqag050 [code]
TSProm: deep learning framework to predict tissue-specific regulatory logic.
Journal: NAR genomics and bioinformatics
In common: SHAP, PyTorch, seaborn, 5 other tools

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.