Reading preview — {{NFIG}} figures placed at their first mention in the text. +The submission DOCX keeps figures under the legend sections at the end, per journal convention.
+{{BODY}} +diff --git a/.gitignore b/.gitignore
index 0359025..8ac0516 100644
--- a/.gitignore
+++ b/.gitignore
@@ -169,3 +169,4 @@ scripts/build_natcomms_docx.js
scripts/build_imrad_md.py
scripts/apa_out.json
scripts/figures/
+scripts/check_refs.py
diff --git a/data/processed/binding_sites.json b/data/processed/binding_sites.json
index 9561ec2..2457397 100644
--- a/data/processed/binding_sites.json
+++ b/data/processed/binding_sites.json
@@ -22,25 +22,25 @@
"uniprot": "O14842"
},
{
+ "uniprot": "O15552",
"biasdb_name": "FFA2 receptor",
- "center_x": 121.3982,
- "center_y": 114.1356,
- "center_z": 113.9273,
- "cocrystal_ligand_n_atoms": null,
- "cocrystal_ligand_resname": null,
- "confidence": "ok",
+ "pdb_id": "8T3S",
+ "structure_source": "pdb",
"method": "consensus",
- "notes": "p2rank top + fpocket top agreement = 2.19 \u00c5",
- "pdb_id": "9K1D",
- "pocket_prediction_agrees": true,
- "pocket_prediction_distance_angstroms": 2.188294288573317,
- "pocket_score_fpocket": 0.385,
- "pocket_score_p2rank": 1.89,
+ "center_x": 96.4309,
+ "center_y": 96.1629,
+ "center_z": 138.8659,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "O15552"
+ "cocrystal_ligand_resname": null,
+ "cocrystal_ligand_n_atoms": null,
+ "pocket_prediction_distance_angstroms": 4.706148663426533,
+ "pocket_prediction_agrees": true,
+ "pocket_score_p2rank": 16.99,
+ "pocket_score_fpocket": 0.01,
+ "confidence": "ok",
+ "notes": "p2rank top + fpocket top agreement = 4.71 \u00c5"
},
{
"biasdb_name": "\u03b22-adrenoceptor",
@@ -253,25 +253,25 @@
"uniprot": "P20309"
},
{
+ "uniprot": "P21452",
"biasdb_name": "NK2 receptor",
- "center_x": 135.1467,
- "center_y": 154.615,
- "center_z": 106.6592,
- "cocrystal_ligand_n_atoms": null,
- "cocrystal_ligand_resname": null,
- "confidence": "ok",
- "method": "consensus",
- "notes": "p2rank top + fpocket top agreement = 1.54 \u00c5",
"pdb_id": "7XWO",
- "pocket_prediction_agrees": true,
- "pocket_prediction_distance_angstroms": 1.5371655525782293,
- "pocket_score_fpocket": 0.697,
- "pocket_score_p2rank": 34.42,
+ "structure_source": "pdb",
+ "method": "primary_only",
+ "center_x": 114.1459,
+ "center_y": 103.232,
+ "center_z": 143.3739,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "P21452"
+ "cocrystal_ligand_resname": null,
+ "cocrystal_ligand_n_atoms": null,
+ "pocket_prediction_distance_angstroms": 9.265273875342334,
+ "pocket_prediction_agrees": false,
+ "pocket_score_p2rank": 35.31,
+ "pocket_score_fpocket": 0.024,
+ "confidence": "low_confidence",
+ "notes": "p2rank top + fpocket top agreement = 9.27 \u00c5"
},
{
"biasdb_name": "S1P1 receptor",
@@ -295,25 +295,25 @@
"uniprot": "P21453"
},
{
+ "uniprot": "P21462",
"biasdb_name": "FPR1",
- "center_x": 129.6976,
- "center_y": 85.7293,
- "center_z": 119.9248,
- "cocrystal_ligand_n_atoms": null,
- "cocrystal_ligand_resname": null,
- "confidence": "ok",
- "method": "consensus",
- "notes": "p2rank top + fpocket top agreement = 0.91 \u00c5",
- "pdb_id": "7VFX",
- "pocket_prediction_agrees": true,
- "pocket_prediction_distance_angstroms": 0.9125193969244265,
- "pocket_score_fpocket": 0.507,
- "pocket_score_p2rank": 6.55,
+ "pdb_id": "7EUO",
+ "structure_source": "pdb",
+ "method": "primary_only",
+ "center_x": 108.4012,
+ "center_y": 115.0815,
+ "center_z": 76.1135,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "P21462"
+ "cocrystal_ligand_resname": null,
+ "cocrystal_ligand_n_atoms": null,
+ "pocket_prediction_distance_angstroms": 10.812407348402004,
+ "pocket_prediction_agrees": false,
+ "pocket_score_p2rank": 24.05,
+ "pocket_score_fpocket": 0.002,
+ "confidence": "low_confidence",
+ "notes": "p2rank top + fpocket top agreement = 10.81 \u00c5"
},
{
"biasdb_name": "CB1 receptor",
@@ -547,25 +547,25 @@
"uniprot": "P29275"
},
{
+ "uniprot": "P30518",
"biasdb_name": "V2 receptor",
- "center_x": 98.2838,
- "center_y": 112.9913,
- "center_z": 127.3186,
- "cocrystal_ligand_n_atoms": null,
- "cocrystal_ligand_resname": null,
- "confidence": "low_confidence",
+ "pdb_id": "7DW9",
+ "structure_source": "pdb",
"method": "primary_only",
- "notes": "p2rank top + fpocket top agreement = 13.55 \u00c5",
- "pdb_id": "7KH0",
- "pocket_prediction_agrees": false,
- "pocket_prediction_distance_angstroms": 13.545152622333719,
- "pocket_score_fpocket": 0.084,
- "pocket_score_p2rank": 1.53,
+ "center_x": 49.3278,
+ "center_y": 70.0202,
+ "center_z": 72.9365,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "P30518"
+ "cocrystal_ligand_resname": null,
+ "cocrystal_ligand_n_atoms": null,
+ "pocket_prediction_distance_angstroms": 7.609059392513593,
+ "pocket_prediction_agrees": false,
+ "pocket_score_p2rank": 16.23,
+ "pocket_score_fpocket": 0.144,
+ "confidence": "low_confidence",
+ "notes": "p2rank top + fpocket top agreement = 7.61 \u00c5"
},
{
"biasdb_name": "A1 receptor",
@@ -820,25 +820,25 @@
"uniprot": "P41143"
},
{
+ "uniprot": "P41145",
"biasdb_name": "\u03ba receptor",
- "center_x": 154.11657097226097,
- "center_y": 141.57423800513857,
- "center_z": 122.21078582037063,
- "cocrystal_ligand_n_atoms": 42,
- "cocrystal_ligand_resname": "Q6Q",
- "confidence": "ok",
+ "pdb_id": "8DZP",
+ "structure_source": "pdb",
"method": "cocrystal_ligand",
- "notes": "box centered on bounded Q6Q (42 heavy atoms)",
- "pdb_id": "7UL2",
- "pocket_prediction_agrees": null,
- "pocket_prediction_distance_angstroms": null,
- "pocket_score_fpocket": null,
- "pocket_score_p2rank": null,
+ "center_x": 129.84212838449787,
+ "center_y": 120.11083910542149,
+ "center_z": 93.83938672465663,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "P41145"
+ "cocrystal_ligand_resname": "U99",
+ "cocrystal_ligand_n_atoms": 31,
+ "pocket_prediction_distance_angstroms": null,
+ "pocket_prediction_agrees": null,
+ "pocket_score_p2rank": null,
+ "pocket_score_fpocket": null,
+ "confidence": "ok",
+ "notes": "box centered on bounded U99 (31 heavy atoms)"
},
{
"biasdb_name": "NOP receptor",
@@ -904,25 +904,25 @@
"uniprot": "P41595"
},
{
+ "uniprot": "P41597",
"biasdb_name": "CCR2",
- "center_x": 3.083374999463558,
- "center_y": -6.391812473535538,
- "center_z": -23.6166250705719,
- "cocrystal_ligand_n_atoms": 16,
- "cocrystal_ligand_resname": "TYS",
- "confidence": "ok",
- "method": "cocrystal_ligand",
- "notes": "box centered on bounded TYS (16 heavy atoms)",
- "pdb_id": "7P8X",
- "pocket_prediction_agrees": null,
- "pocket_prediction_distance_angstroms": null,
- "pocket_score_fpocket": null,
- "pocket_score_p2rank": null,
+ "pdb_id": "7XA3",
+ "structure_source": "pdb",
+ "method": "consensus",
+ "center_x": 113.6946,
+ "center_y": 138.318,
+ "center_z": 130.1826,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "P41597"
+ "cocrystal_ligand_resname": null,
+ "cocrystal_ligand_n_atoms": null,
+ "pocket_prediction_distance_angstroms": 4.824759347532267,
+ "pocket_prediction_agrees": true,
+ "pocket_score_p2rank": 23.91,
+ "pocket_score_fpocket": 0.427,
+ "confidence": "ok",
+ "notes": "p2rank top + fpocket top agreement = 4.82 \u00c5"
},
{
"biasdb_name": "MC3 receptor",
@@ -988,25 +988,25 @@
"uniprot": "P49682"
},
{
+ "uniprot": "P51681",
"biasdb_name": "CCR5",
- "center_x": -3.751437494531274,
- "center_y": -11.872749924659729,
- "center_z": -18.457124888896942,
- "cocrystal_ligand_n_atoms": 16,
- "cocrystal_ligand_resname": "TYS",
- "confidence": "ok",
- "method": "cocrystal_ligand",
- "notes": "box centered on bounded TYS (16 heavy atoms)",
- "pdb_id": "5YY4",
- "pocket_prediction_agrees": null,
- "pocket_prediction_distance_angstroms": null,
- "pocket_score_fpocket": null,
- "pocket_score_p2rank": null,
+ "pdb_id": "7O7F",
+ "structure_source": "pdb",
+ "method": "primary_only",
+ "center_x": 192.7681,
+ "center_y": 179.8396,
+ "center_z": 230.1375,
"size_x": 22.0,
"size_y": 22.0,
"size_z": 22.0,
- "structure_source": "pdb",
- "uniprot": "P51681"
+ "cocrystal_ligand_resname": null,
+ "cocrystal_ligand_n_atoms": null,
+ "pocket_prediction_distance_angstroms": 13.56752954970882,
+ "pocket_prediction_agrees": false,
+ "pocket_score_p2rank": 26.63,
+ "pocket_score_fpocket": 0.002,
+ "confidence": "low_confidence",
+ "notes": "p2rank top + fpocket top agreement = 13.57 \u00c5"
},
{
"biasdb_name": "PAR2",
@@ -1298,5 +1298,14 @@
"primary_only": 2
},
"receptors": 61
- }
+ },
+ "repaired_receptors": [
+ "O15552",
+ "P21452",
+ "P21462",
+ "P30518",
+ "P41145",
+ "P41597",
+ "P51681"
+ ]
}
\ No newline at end of file
diff --git a/scripts/ablation_by_block.py b/scripts/ablation_by_block.py
new file mode 100644
index 0000000..06cde2c
--- /dev/null
+++ b/scripts/ablation_by_block.py
@@ -0,0 +1,119 @@
+"""Block-wise ablation: separate ligand 3D shape from receptor-derived features.
+
+`reviewer_extras.ablation_drop_structural` drops one combined "structural"
+block, but that block mixes three different information sources: descriptors of
+the docked *ligand's* 3D shape (which need no receptor), pair-level docking
+energetics, and receptor-contact fingerprints. A ligand is a structure too, so
+the combined Delta cannot answer "does the receptor help?" — only "does the
+whole docking arm help?". This runs each block separately.
+
+Run:
+ PYTHONPATH=src python scripts/ablation_by_block.py
+"""
+from __future__ import annotations
+
+import json as _json
+import warnings
+from pathlib import Path
+
+import joblib
+import numpy as np
+import pandas as pd
+from sklearn.metrics import f1_score
+from sklearn.model_selection import GroupKFold, StratifiedKFold
+
+from cancerag.ml.preprocessing import get_X_y_groups, build_full_pipeline
+from cancerag.ml.model_training import MODEL_FACTORIES, _combined_weight
+
+warnings.filterwarnings("ignore")
+
+POSE_3D = ("Asphericity", "Eccentricity", "InertialShapeFactor", "NPR1", "NPR2",
+ "PMI1", "PMI2", "PMI3", "RadiusOfGyration", "SpherocityIndex",
+ "pose_3d_missing")
+# needs the receptor to compute at all
+RECEPTOR_DEPENDENT = ("vina_", "ifp_", "n_residues_contacted", "n_total_contacts",
+ "n_poses", "ifp_missing", "ifp_no_contacts")
+
+SEEDS = (42, 7, 13)
+FOLDS = 3
+
+
+def cols_matching(X, prefixes):
+ return [c for c in X.columns
+ if any(c.startswith(p) or c == p for p in prefixes)]
+
+
+def main() -> None:
+ df = pd.read_parquet("data/processed/ml_ready_dataset.parquet")
+ le = joblib.load("data/processed/ml_preprocessed/label_encoder.joblib")
+ X, y, sw, le, _ = get_X_y_groups(df, label_encoder=le)
+ n_classes = len(le.classes_)
+ winner = _json.loads(
+ Path("data/processed/ml_models/selection_decision.json").read_text())["chosen"]
+ factory = MODEL_FACTORIES[winner]
+
+ pose = cols_matching(X, POSE_3D)
+ recep = cols_matching(X, RECEPTOR_DEPENDENT)
+ print(f"model={winner} total={X.shape[1]} "
+ f"ligand-3D-shape={len(pose)} receptor-dependent={len(recep)}")
+
+ # The published ablation ran StratifiedKFold while the manuscript described
+ # it as scaffold-grouped. Stratified is the optimistic regime this paper
+ # argues against, so the headline Delta must be reported on the grouped
+ # splits as well -- especially receptor-grouped, which is the only split
+ # that asks whether receptor features help on an unseen receptor.
+ variants = {
+ "full": X,
+ "drop_receptor_dependent": X.drop(columns=recep), # keeps ligand 3D shape
+ "drop_ligand_3d_shape": X.drop(columns=pose), # keeps receptor features
+ "drop_both": X.drop(columns=recep + pose),
+ }
+
+ scaf = pd.factorize(df["murcko_scaffold"])[0] if "murcko_scaffold" in df.columns \
+ else pd.factorize(df["scaffold"])[0]
+ recep = pd.factorize(df["receptor_uniprot"])[0]
+ SPLITS = {
+ "stratified": lambda Xv, seed: StratifiedKFold(
+ n_splits=FOLDS, shuffle=True, random_state=seed).split(Xv, y),
+ "scaffold_grouped": lambda Xv, seed: GroupKFold(n_splits=FOLDS).split(Xv, y, scaf),
+ "receptor_grouped": lambda Xv, seed: GroupKFold(n_splits=FOLDS).split(Xv, y, recep),
+ }
+
+ rows = []
+ for split_name, splitter in SPLITS.items():
+ for name, Xv in variants.items():
+ for seed in SEEDS:
+ f1s = []
+ for tr, te in splitter(Xv, seed):
+ model = factory(n_classes=n_classes, random_state=seed)
+ pipe = build_full_pipeline(model)
+ last = pipe.steps[-1][0]
+ pipe.fit(Xv.iloc[tr], y[tr],
+ **{f"{last}__sample_weight": _combined_weight(y[tr], sw[tr])})
+ f1s.append(float(f1_score(y[te], pipe.predict(Xv.iloc[te]),
+ average="macro", zero_division=0)))
+ rows.append({"split": split_name, "variant": name, "seed": seed,
+ "n_features": int(Xv.shape[1]),
+ "macro_f1": float(np.mean(f1s))})
+ print(f" [{split_name}] done")
+
+ out = pd.DataFrame(rows)
+ outdir = Path("data/processed/ml_models/extras"); outdir.mkdir(parents=True, exist_ok=True)
+ out.to_csv(outdir / "ablation_by_block.csv", index=False)
+
+ print("\n=== mean macro-F1 over 3 seeds x 3 folds ===")
+ for split_name in SPLITS:
+ sub = out[out.split == split_name]
+ base = sub[sub.variant == "full"].macro_f1
+ print(f"\n--- {split_name} ---")
+ for name in variants:
+ m = sub[sub.variant == name].macro_f1
+ d = m.mean() - base.mean()
+ # seed spread of the baseline, for judging whether Delta is signal
+ print(f" {name:<26} {m.mean():.4f} +/- {m.std():.4f}"
+ f" (Delta vs full: {d:+.4f})")
+ print(f" [baseline seed spread: +/-{base.std():.4f}]")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_preview_html.py b/scripts/build_preview_html.py
new file mode 100644
index 0000000..a773bb2
--- /dev/null
+++ b/scripts/build_preview_html.py
@@ -0,0 +1,191 @@
+"""Build a self-contained HTML reading preview with figures placed inline.
+
+The DOCX builder embeds figures only under the figure-legend sections at the
+end, which is the Nature submission convention but makes the manuscript hard to
+read: a Results paragraph cites "Fig. 3" and the reader has to scroll past the
+Methods to see it. The markdown carries no image tags at all, so a plain
+markdown preview shows no figures whatever.
+
+This renderer places each figure at its first in-text mention in Results, keeps
+the legend text with it, and inlines every image as a data URI so the file works
+with no network and no sibling asset directory.
+
+Run:
+ PYTHONPATH=src python scripts/build_preview_html.py (?:(?!{mistune.html(rest)}" if rest else ""
+ tags = "".join(f"" for u in imgs)
+ return (f"
Figure legends")
+ head, tail = (html[:cut], html[cut:]) if cut != -1 else (html, "")
+ head = re.sub(r"<(p|h2|h3)>(?:(?!\1>).)*?\1>", place, head, flags=re.S)
+
+ # legend sections: show the image under each legend as well
+ def legend_img(match: re.Match) -> str:
+ para = match.group(0)
+ mm = re.search(r"Figure (\d+) \|", para) or re.search(r"Figure (\d+)", para)
+ if not mm:
+ return para
+ num = int(mm.group(1))
+ tags = "".join(f"
" for u in
+ (data_uri(f) for f in files.get(num, [])) if u)
+ return para + (f"