caithmac commited on
Commit
b405a6e
·
verified ·
1 Parent(s): d8f631e

Add paired GRASP/A1 Colab comparison with shared audited splits

Browse files
README.md CHANGED
@@ -15,7 +15,7 @@ tags:
15
 
16
  **GRASP-A1** is the GRASP molecular graph Transformer trained without absolute atom-position embeddings. It retains shortest-path relative attention, radius-0 atom-environment tokens, and the replaced-token-detection objective. The encoder has **93,091,584 parameters**, 12 layers, hidden size 768, and 12 attention heads.
17
 
18
- This private research release provides the completed **50,000-update Step2 encoder** by default and the corresponding **Step1 encoder** under `step1/`. Step1 saw **1,538,242,560 molecule presentations** from ZINC20; this is a presentation budget, not a count of certified unique molecules. Step2 used the audited 511,897-molecule ChEMBL 36 set with 642 assay tasks.
19
 
20
  ## Use
21
 
@@ -23,10 +23,9 @@ Install Python 3.10–3.13 and the [GRASP code repository](https://github.com/ca
23
 
24
  ```bash
25
  python -m pip install "git+https://github.com/caithmac/GRASP.git@7843e72606dd208517cd8cf86ee07b3bb965cc79"
26
- hf auth login
27
  ```
28
 
29
- Authenticate with an account permitted to read this private repository, then:
30
 
31
  ```python
32
  from grasp import GRASPEncoder
@@ -66,6 +65,12 @@ python -m grasp predict --model runs/a1_my_task \
66
 
67
  Use `--task binary` for binary classification. `--method frozen` trains the readout and head with a fixed encoder; `--method lora` provides LoRA adaptation. Use a separate output directory per endpoint. To compare against published GRASP, repeat the same split, preprocessing, method and seed with `--model caithmac/GRASP`.
68
 
 
 
 
 
 
 
69
  ## Model details and provenance
70
 
71
  | Property | Value |
 
15
 
16
  **GRASP-A1** is the GRASP molecular graph Transformer trained without absolute atom-position embeddings. It retains shortest-path relative attention, radius-0 atom-environment tokens, and the replaced-token-detection objective. The encoder has **93,091,584 parameters**, 12 layers, hidden size 768, and 12 attention heads.
17
 
18
+ This research release provides the completed **50,000-update Step2 encoder** by default and the corresponding **Step1 encoder** under `step1/`. Step1 saw **1,538,242,560 molecule presentations** from ZINC20; this is a presentation budget, not a count of certified unique molecules. Step2 used the audited 511,897-molecule ChEMBL 36 set with 642 assay tasks.
19
 
20
  ## Use
21
 
 
23
 
24
  ```bash
25
  python -m pip install "git+https://github.com/caithmac/GRASP.git@7843e72606dd208517cd8cf86ee07b3bb965cc79"
 
26
  ```
27
 
28
+ The repository is public and requires no authentication:
29
 
30
  ```python
31
  from grasp import GRASPEncoder
 
65
 
66
  Use `--task binary` for binary classification. `--method frozen` trains the readout and head with a fixed encoder; `--method lora` provides LoRA adaptation. Use a separate output directory per endpoint. To compare against published GRASP, repeat the same split, preprocessing, method and seed with `--model caithmac/GRASP`.
67
 
68
+ ## Compare A1 and original GRASP in Colab
69
+
70
+ [Open the comparison notebook in Colab](https://colab.research.google.com/drive/15G8jCYj2RmRiJLUtjoZqi_VHvzw89GIF), or [download its self-contained source](notebooks/01_compare_GRASP_A1.ipynb) and use **Colab → File → Upload notebook**. Select a GPU and run cells in order. Public access requires no token; if access is restricted later, the notebook supports the Colab secret `HF_TOKEN` or a hidden login prompt.
71
+
72
+ The notebook tests ESOL, FreeSolv and BBBP with shared scaffold splits, matched initial heads and training settings, and pinned checkpoint revisions. It exports paired metrics, per-molecule predictions, split hashes and data-exclusion audits. Defaults use one training seed; set `SEEDS = [0, 1, 2]` for repeated training on the same splits. This compares the released checkpoints and does not isolate the positional-embedding change or reproduce paper scores. See [notebook validation](notebooks/verification.json) for the real-data split audits and tiny CPU workflow checks; full Colab GPU benchmarks have not been run as part of notebook creation.
73
+
74
  ## Model details and provenance
75
 
76
  | Property | Value |
SHA256SUMS CHANGED
@@ -1,8 +1,11 @@
1
  5b2636c65d69d21303a60eb7f2e9dad76b6b1ba78e27202079184a27b5789b62 config.json
2
  6318960741814b0ae14a6769b0e129451ae7f3bec6ecbf0764fbb519407bc6a4 LICENSE
3
  7f355e252de37e3a4affbb3ca2aabe79a92c9e893bbee01c9f0fd171b3e41e92 model.safetensors
 
 
 
4
  ac8a653d18e0fdfad95a2edf74b2c813d820d86bcf03527d88029bef8b04cd21 NOTICE
5
- 53dae9e0d17f514ee38eae7f605715285a2fea1f4e644cdfcb638f4e7f86bd6f README.md
6
  adcb0cb97d071880ee1e540d20434c8138dfdd63f9db654f5eef4c8584d38108 requirements-tested.txt
7
  784e6a4c9f2e22dbbc7f02d3bd151fe6de96e065aa50c3df21f238e9addb2948 step1/config.json
8
  e1fbbfb34f65b3a30072f2df482a600024b4749d131e542b0d0a3c23604334f9 step1/model.safetensors
 
1
  5b2636c65d69d21303a60eb7f2e9dad76b6b1ba78e27202079184a27b5789b62 config.json
2
  6318960741814b0ae14a6769b0e129451ae7f3bec6ecbf0764fbb519407bc6a4 LICENSE
3
  7f355e252de37e3a4affbb3ca2aabe79a92c9e893bbee01c9f0fd171b3e41e92 model.safetensors
4
+ cc71af7c0c1572a0718cbd5276e2a76cc769ca72fb46f02447cfd383d28fd6dd notebooks/01_compare_GRASP_A1.ipynb
5
+ 5bff4850d4b7da22944c50ac1a741c6b1ca8738347ec5a55ec3bc9140d3aaaae notebooks/comparison_helpers.py
6
+ b92ff6a337427a514b3e7c2f6334617bcb864f976e52216c88c54f6335b9fa7e notebooks/verification.json
7
  ac8a653d18e0fdfad95a2edf74b2c813d820d86bcf03527d88029bef8b04cd21 NOTICE
8
+ 55ace9b45470a55e33a332e6c6d4625979199e8a760bf8882dc659c488b6570b README.md
9
  adcb0cb97d071880ee1e540d20434c8138dfdd63f9db654f5eef4c8584d38108 requirements-tested.txt
10
  784e6a4c9f2e22dbbc7f02d3bd151fe6de96e065aa50c3df21f238e9addb2948 step1/config.json
11
  e1fbbfb34f65b3a30072f2df482a600024b4749d131e542b0d0a3c23604334f9 step1/model.safetensors
notebooks/01_compare_GRASP_A1.ipynb ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Compare A1 with the released GRASP on the same splits\n",
8
+ "\n",
9
+ "Three small experimental tasks: **ESOL** (solubility regression), **FreeSolv** (hydration-free-energy regression), and **BBBP** (binary classification). Choose a GPU under **Runtime \u2192 Change runtime type**, then run cells in order. If A1 requires private access, use a Hugging Face read token permitted to read `caithmac/GRASP-A1`.\n",
10
+ "\n",
11
+ "Both models receive the **same saved scaffold train/validation/test CSVs**, canonical SMILES, training seed, initial task head, optimizer settings and learned layer-mixture/atom-attention readout. Test labels are used only for evaluation after training. Defaults perform one-seed full fine-tuning; change `SEEDS` to `[0, 1, 2]` for repeated training on the same splits, or select `frozen` for a faster screen.\n",
12
+ "\n",
13
+ "**Interpretation:** this compares the two released Step2 checkpoints. The public GRASP and A1 have additional training-history differences, so the result cannot isolate removal of absolute positions. It does not reproduce paper splits/scores, certify absence of pretraining overlap, or establish a general improvement. Neither encoder represents stereochemical assignments.\n"
14
+ ],
15
+ "id": "a1-comparison-00"
16
+ },
17
+ {
18
+ "cell_type": "markdown",
19
+ "metadata": {},
20
+ "source": [
21
+ "## 1. Install the pinned GRASP code\n",
22
+ "\n",
23
+ "Use a fresh Colab session. The existing CUDA PyTorch build is retained when it satisfies GRASP's requirements. If installation replaces an already imported scientific package, restart the session and rerun from here. No DeepChem installation is required.\n"
24
+ ],
25
+ "id": "a1-comparison-01"
26
+ },
27
+ {
28
+ "cell_type": "code",
29
+ "metadata": {},
30
+ "source": [
31
+ "import os, sys, subprocess, importlib.metadata\n",
32
+ "os.environ.setdefault(\"CUBLAS_WORKSPACE_CONFIG\", \":4096:8\")\n",
33
+ "CODE_REVISION = \"7843e72606dd208517cd8cf86ee07b3bb965cc79\"\n",
34
+ "watched = {\"numpy\": \"numpy\", \"scipy\": \"scipy\", \"rdkit\": \"rdkit\", \"sklearn\": \"scikit-learn\"}\n",
35
+ "loaded = {module: importlib.metadata.version(package) for module, package in watched.items() if module in sys.modules}\n",
36
+ "subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\",\n",
37
+ " f\"git+https://github.com/caithmac/GRASP.git@{CODE_REVISION}\",\n",
38
+ " \"rdkit==2025.3.6\", \"scipy==1.15.3\", \"scikit-learn==1.7.2\"], check=True)\n",
39
+ "changed = [module for module, version in loaded.items() if importlib.metadata.version(watched[module]) != version]\n",
40
+ "if changed:\n",
41
+ " raise RuntimeError(f\"Restart the Colab session and rerun from installation; imported packages changed: {changed}\")\n",
42
+ "print(\"GRASP installation ready\")\n"
43
+ ],
44
+ "execution_count": null,
45
+ "outputs": [],
46
+ "id": "a1-comparison-02"
47
+ },
48
+ {
49
+ "cell_type": "markdown",
50
+ "metadata": {},
51
+ "source": [
52
+ "## 2. Settings\n",
53
+ "\n",
54
+ "Set these **before** looking at test results. Use a new `RUN_ROOT` when changing settings. Completed fits can be skipped safely when rerunning the same experiment; interrupted fits restart that individual fit. Each saved predictor includes its encoder and can occupy about 370 MB. For persistence, set `RUN_ROOT` to a directory on already mounted Drive storage. The final compact ZIP excludes predictor weights and raw datasets.\n"
55
+ ],
56
+ "id": "a1-comparison-03"
57
+ },
58
+ {
59
+ "cell_type": "code",
60
+ "metadata": {},
61
+ "source": [
62
+ "from pathlib import Path\n",
63
+ "TASKS = [\"esol\", \"freesolv\", \"bbbp\"]\n",
64
+ "METHOD = \"full\" # \"full\", \"lora\", or \"frozen\"; identical for both models\n",
65
+ "SEEDS = [0] # training seeds; use [0, 1, 2] for repeats\n",
66
+ "SPLIT_SEED = 20261015 # fixed independently of training seeds\n",
67
+ "EPOCHS, PATIENCE, BATCH_SIZE = 25, 6, 16\n",
68
+ "LEARNING_RATE = {\"full\": 1e-5, \"lora\": 5e-5, \"frozen\": 1e-3}[METHOD]\n",
69
+ "RUN_ROOT = Path(\"/content/grasp_a1_comparison\")\n",
70
+ "MODELS = {\n",
71
+ " \"GRASP\": {\"repo\": \"caithmac/GRASP\", \"revision\": \"4804c0a558307c944da5e8de1d182b4b4c994dae\"},\n",
72
+ " \"A1\": {\"repo\": \"caithmac/GRASP-A1\", \"revision\": \"d8f631e096f1d2dfd281962a21389142cde437ee\"},\n",
73
+ "}\n",
74
+ "assert SEEDS and len(SEEDS) == len(set(SEEDS))\n",
75
+ "assert METHOD in (\"full\", \"lora\", \"frozen\")\n",
76
+ "RUN_ROOT.mkdir(parents=True, exist_ok=True)\n"
77
+ ],
78
+ "execution_count": null,
79
+ "outputs": [],
80
+ "id": "a1-comparison-04"
81
+ },
82
+ {
83
+ "cell_type": "markdown",
84
+ "metadata": {},
85
+ "source": [
86
+ "## 3. Confirm checkpoint access\n",
87
+ "\n",
88
+ "Public checkpoints or an already authenticated session need no extra login. For private A1 access, add `HF_TOKEN` in Colab's **Secrets** panel and allow this notebook access, or enter a read token in the hidden prompt. The token is not embedded in the notebook or exported results. Do not share an executed notebook containing credentials you added yourself.\n"
89
+ ],
90
+ "id": "a1-comparison-05"
91
+ },
92
+ {
93
+ "cell_type": "code",
94
+ "metadata": {},
95
+ "source": [
96
+ "from getpass import getpass\n",
97
+ "from huggingface_hub import login, HfApi, snapshot_download\n",
98
+ "from huggingface_hub.errors import HfHubHTTPError\n",
99
+ "try:\n",
100
+ " HfApi().model_info(MODELS[\"A1\"][\"repo\"], revision=MODELS[\"A1\"][\"revision\"])\n",
101
+ "except HfHubHTTPError as exc:\n",
102
+ " if exc.response is None or exc.response.status_code not in (401, 403, 404):\n",
103
+ " raise\n",
104
+ " token = None\n",
105
+ " try:\n",
106
+ " from google.colab import userdata\n",
107
+ " token = userdata.get(\"HF_TOKEN\")\n",
108
+ " except Exception:\n",
109
+ " pass\n",
110
+ " if not token:\n",
111
+ " token = getpass(\"Hugging Face read token with access to private A1: \")\n",
112
+ " login(token=token, add_to_git_credential=False)\n",
113
+ " del token\n",
114
+ " HfApi().model_info(MODELS[\"A1\"][\"repo\"], revision=MODELS[\"A1\"][\"revision\"])\n",
115
+ "print(\"A1 checkpoint access confirmed\")\n"
116
+ ],
117
+ "execution_count": null,
118
+ "outputs": [],
119
+ "id": "a1-comparison-06"
120
+ },
121
+ {
122
+ "cell_type": "markdown",
123
+ "metadata": {},
124
+ "source": [
125
+ "## 4. Load the shared protocol and pin model files\n",
126
+ "\n",
127
+ "Encoder construction is isolated from the task-head RNG stream. Otherwise the different position-table sizes would give different randomly initialized heads even with the same seed. The notebook checks both the initial readout hash and the post-initialization RNG state.\n"
128
+ ],
129
+ "id": "a1-comparison-07"
130
+ },
131
+ {
132
+ "cell_type": "code",
133
+ "metadata": {},
134
+ "source": [
135
+ "#@title Shared protocol helpers (run once)\n",
136
+ "\"\"\"Shared, auditable protocol embedded in the self-contained Colab notebook.\"\"\"\n",
137
+ "import gc\n",
138
+ "import hashlib\n",
139
+ "import io\n",
140
+ "import json\n",
141
+ "from pathlib import Path\n",
142
+ "\n",
143
+ "import numpy as np\n",
144
+ "import pandas as pd\n",
145
+ "import requests\n",
146
+ "import torch\n",
147
+ "from rdkit import Chem\n",
148
+ "from rdkit.Chem.Scaffolds import MurckoScaffold\n",
149
+ "from sklearn.metrics import average_precision_score, mean_absolute_error, mean_squared_error, r2_score, roc_auc_score\n",
150
+ "\n",
151
+ "DATASETS = {\n",
152
+ " \"esol\": dict(filename=\"delaney-processed.csv\", target=\"measured log solubility in mols per litre\", task=\"regression\",\n",
153
+ " sha256=\"8c06a76f0c6487d29ab0f903e6a7a7139f189ab3c1178f159c8be8964602f189\"),\n",
154
+ " \"freesolv\": dict(filename=\"freesolv.csv.gz\", target=\"y\", task=\"regression\",\n",
155
+ " sha256=\"008e9a8a9e018c6b77eb00df7153d0bf2e35a1d663f18f301de522fe3f8711d7\"),\n",
156
+ " \"bbbp\": dict(filename=\"BBBP.csv\", target=\"p_np\", task=\"binary\",\n",
157
+ " sha256=\"d07a38487aeac5cee5508413e468043ef3097451d2a112701c2d60be9ec6b662\"),\n",
158
+ "}\n",
159
+ "PROTOCOL_VERSION = \"grasp-a1-paired-colab-20261015-v1\"\n",
160
+ "\n",
161
+ "\n",
162
+ "def sha256(path):\n",
163
+ " digest = hashlib.sha256()\n",
164
+ " with Path(path).open(\"rb\") as stream:\n",
165
+ " for block in iter(lambda: stream.read(1024 * 1024), b\"\"):\n",
166
+ " digest.update(block)\n",
167
+ " return digest.hexdigest()\n",
168
+ "\n",
169
+ "\n",
170
+ "def write_json(path, value):\n",
171
+ " Path(path).parent.mkdir(parents=True, exist_ok=True)\n",
172
+ " Path(path).write_text(json.dumps(value, indent=2, allow_nan=False) + \"\\n\")\n",
173
+ "\n",
174
+ "\n",
175
+ "def prepare_task(name, cache_dir, output_dir, vocabulary):\n",
176
+ " from mole.training.data.datasets import MolDataset\n",
177
+ " spec = DATASETS[name]\n",
178
+ " cache_dir, output_dir = Path(cache_dir), Path(output_dir)\n",
179
+ " cache_dir.mkdir(parents=True, exist_ok=True)\n",
180
+ " output_dir.mkdir(parents=True, exist_ok=True)\n",
181
+ " raw = cache_dir / spec[\"filename\"]\n",
182
+ " url = \"https://deepchemdata.s3.us-west-1.amazonaws.com/datasets/\" + spec[\"filename\"]\n",
183
+ " if not raw.is_file():\n",
184
+ " response = requests.get(url, timeout=90)\n",
185
+ " response.raise_for_status()\n",
186
+ " raw.write_bytes(response.content)\n",
187
+ " assert sha256(raw) == spec[\"sha256\"], f\"Source changed for {name}; review it before updating its checksum.\"\n",
188
+ " frame = pd.read_csv(io.BytesIO(raw.read_bytes()), compression=\"gzip\" if raw.suffix == \".gz\" else None)\n",
189
+ " accepted, audit = [], []\n",
190
+ " for source_row, row in frame.iterrows():\n",
191
+ " reason = \"\"\n",
192
+ " try:\n",
193
+ " target = float(row[spec[\"target\"]])\n",
194
+ " if not np.isfinite(target):\n",
195
+ " raise ValueError(\"nonfinite_target\")\n",
196
+ " if spec[\"task\"] == \"binary\" and target not in (0, 1):\n",
197
+ " raise ValueError(\"nonbinary_target\")\n",
198
+ " mol = Chem.MolFromSmiles(str(row[\"smiles\"]))\n",
199
+ " if mol is None or not mol.GetNumAtoms():\n",
200
+ " raise ValueError(\"unparsable_or_empty\")\n",
201
+ " if mol.GetNumAtoms() > 511:\n",
202
+ " raise ValueError(\"above_loader_atom_limit\")\n",
203
+ " smiles = Chem.MolToSmiles(mol, isomericSmiles=True)\n",
204
+ " # Check the exact deployed featurizer once, before either model receives a split.\n",
205
+ " MolDataset(pd.Series([smiles]), vocabulary, cls_token=True)[0]\n",
206
+ " connectivity = Chem.MolToSmiles(mol, isomericSmiles=False)\n",
207
+ " scaffold = Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(mol), isomericSmiles=False) or \"<acyclic>\"\n",
208
+ " accepted.append(dict(source_row=int(source_row), smiles=smiles, target=target,\n",
209
+ " connectivity=connectivity, scaffold=scaffold))\n",
210
+ " except Exception as exc:\n",
211
+ " reason = f\"{type(exc).__name__}: {exc}\"\n",
212
+ " audit.append(dict(source_row=int(source_row), original_smiles=str(row[\"smiles\"]), excluded=bool(reason), reason=reason))\n",
213
+ " pd.DataFrame(audit).to_csv(output_dir / \"data_audit.csv\", index=False)\n",
214
+ " result = pd.DataFrame(accepted)\n",
215
+ " assert len(result), f\"No usable rows for {name}\"\n",
216
+ " result.to_csv(output_dir / \"eligible_rows.csv\", index=False)\n",
217
+ " write_json(output_dir / \"data_manifest.json\", dict(dataset=name, url=url, source_sha256=spec[\"sha256\"],\n",
218
+ " source_rows=len(frame), eligible_rows=len(result), excluded_rows=len(frame) - len(result),\n",
219
+ " policy=\"Canonical isomeric SMILES; no charge/salt/tautomer repair; retain duplicates within one scaffold partition.\"))\n",
220
+ " return result\n",
221
+ "\n",
222
+ "\n",
223
+ "def scaffold_split(frame, seed):\n",
224
+ " groups = [group.index.to_list() for _, group in frame.groupby(\"scaffold\", sort=True)]\n",
225
+ " np.random.default_rng(seed).shuffle(groups)\n",
226
+ " groups.sort(key=len, reverse=True)\n",
227
+ " partitions = {\"train\": [], \"valid\": [], \"test\": []}\n",
228
+ " for group in groups:\n",
229
+ " if len(partitions[\"train\"]) + len(group) <= 0.8 * len(frame):\n",
230
+ " partitions[\"train\"].extend(group)\n",
231
+ " elif len(partitions[\"valid\"]) + len(group) <= 0.1 * len(frame):\n",
232
+ " partitions[\"valid\"].extend(group)\n",
233
+ " else:\n",
234
+ " partitions[\"test\"].extend(group)\n",
235
+ " result = {key: frame.loc[sorted(rows)].reset_index(drop=True) for key, rows in partitions.items()}\n",
236
+ " assert sum(map(len, result.values())) == len(frame)\n",
237
+ " assert all(len(part) for part in result.values()), \"Too few scaffold groups to create three nonempty splits.\"\n",
238
+ " for column in (\"source_row\", \"connectivity\", \"scaffold\"):\n",
239
+ " sets = [set(part[column]) for part in result.values()]\n",
240
+ " assert not (sets[0] & sets[1] or sets[0] & sets[2] or sets[1] & sets[2]), f\"Split leakage: {column}\"\n",
241
+ " return result\n",
242
+ "\n",
243
+ "\n",
244
+ "def save_splits(frame, task, directory, seed):\n",
245
+ " directory = Path(directory)\n",
246
+ " directory.mkdir(parents=True, exist_ok=True)\n",
247
+ " parts = scaffold_split(frame, seed)\n",
248
+ " if task == \"binary\":\n",
249
+ " assert all(set(part.target) == {0, 1} for part in parts.values()), \"A binary partition lacks one class; do not silently resplit.\"\n",
250
+ " csv_bytes = {split: part.to_csv(index=False).encode(\"utf-8\") for split, part in parts.items()}\n",
251
+ " for split, data in csv_bytes.items():\n",
252
+ " path = directory / f\"{split}.csv\"\n",
253
+ " assert not path.is_file() or path.read_bytes() == data, \"Existing splits differ: choose a new RUN_ROOT.\"\n",
254
+ " for split, data in csv_bytes.items():\n",
255
+ " (directory / f\"{split}.csv\").write_bytes(data)\n",
256
+ " manifest = dict(split_seed=seed, policy=\"Shared scaffold-grouped 80/10/10 target proportions; all acyclic molecules form one group.\",\n",
257
+ " counts={key: len(part) for key, part in parts.items()},\n",
258
+ " sha256={key: sha256(directory / f\"{key}.csv\") for key in parts},\n",
259
+ " disjoint_source_rows=True, disjoint_connectivity=True, disjoint_scaffolds=True)\n",
260
+ " write_json(directory / \"split_manifest.json\", manifest)\n",
261
+ " return manifest\n",
262
+ "\n",
263
+ "\n",
264
+ "def enable_matched_initialization():\n",
265
+ " import grasp.encoder as encoder_module\n",
266
+ " import grasp.predictor as predictor_module\n",
267
+ " from mole.training.models.mole import AtomEnvEmbeddings\n",
268
+ " class MatchedEmbeddings(AtomEnvEmbeddings):\n",
269
+ " def __init__(self, config):\n",
270
+ " # Loading a different encoder architecture must not shift task-head or loader RNG.\n",
271
+ " with torch.random.fork_rng(devices=[]):\n",
272
+ " super().__init__(config)\n",
273
+ " encoder_module.AtomEnvEmbeddings = predictor_module.AtomEnvEmbeddings = MatchedEmbeddings\n",
274
+ "\n",
275
+ "\n",
276
+ "def head_fingerprint(model_dir, seed, method):\n",
277
+ " from grasp import GRASPEncoder, GRASPPredictor\n",
278
+ " torch.manual_seed(seed)\n",
279
+ " encoder = GRASPEncoder.from_pretrained(model_dir, device=\"cpu\")\n",
280
+ " predictor = GRASPPredictor.from_encoder(encoder, task=\"regression\", method=method)\n",
281
+ " digest = hashlib.sha256()\n",
282
+ " for key, value in sorted(predictor.model.state_dict().items()):\n",
283
+ " if not key.startswith(\"encoder.\"):\n",
284
+ " digest.update(key.encode())\n",
285
+ " digest.update(value.detach().cpu().contiguous().numpy().tobytes())\n",
286
+ " state = hashlib.sha256(torch.get_rng_state().numpy().tobytes()).hexdigest()\n",
287
+ " del predictor, encoder\n",
288
+ " gc.collect()\n",
289
+ " return dict(readout_sha256=digest.hexdigest(), post_initialization_rng_sha256=state)\n",
290
+ "\n",
291
+ "\n",
292
+ "def train_one(model_dir, split_dir, output_dir, *, task, method, seed, epochs, patience, batch_size, learning_rate, device, identity):\n",
293
+ " from grasp.train import fit\n",
294
+ " split_dir, output_dir = Path(split_dir), Path(output_dir)\n",
295
+ " split_hashes = {key: sha256(split_dir / f\"{key}.csv\") for key in (\"train\", \"valid\", \"test\")}\n",
296
+ " assert split_hashes == json.loads((split_dir / \"split_manifest.json\").read_text())[\"sha256\"], \"Shared split files changed.\"\n",
297
+ " plan = dict(protocol=PROTOCOL_VERSION, identity=identity, split_sha256=split_hashes, task=task, method=method,\n",
298
+ " seed=seed, epochs=epochs, patience=patience, batch_size=batch_size, learning_rate=learning_rate, device=device)\n",
299
+ " plan_file = output_dir / \"run_plan.json\"\n",
300
+ " if plan_file.is_file():\n",
301
+ " assert json.loads(plan_file.read_text()) == plan, \"Settings/splits changed: choose a new RUN_ROOT.\"\n",
302
+ " else:\n",
303
+ " write_json(plan_file, plan)\n",
304
+ " complete = output_dir / \"TRAINING_COMPLETE.json\"\n",
305
+ " if complete.is_file():\n",
306
+ " assert (output_dir / \"predictor.safetensors\").is_file()\n",
307
+ " return json.loads(complete.read_text())\n",
308
+ " # Test labels are never supplied to fitting or early stopping.\n",
309
+ " outcome = fit(model_source=model_dir, train_csv=split_dir / \"train.csv\", valid_csv=split_dir / \"valid.csv\",\n",
310
+ " output_dir=output_dir, task=task, method=method, seed=seed, epochs=epochs, patience=patience,\n",
311
+ " batch_size=batch_size, learning_rate=learning_rate, device=device)\n",
312
+ " write_json(complete, outcome)\n",
313
+ " gc.collect()\n",
314
+ " if torch.cuda.is_available():\n",
315
+ " torch.cuda.empty_cache()\n",
316
+ " return outcome\n",
317
+ "\n",
318
+ "\n",
319
+ "def score_predictions(target, prediction, task):\n",
320
+ " target, prediction = np.asarray(target), np.asarray(prediction)\n",
321
+ " assert target.shape == prediction.shape and np.isfinite(prediction).all()\n",
322
+ " if task == \"regression\":\n",
323
+ " return dict(mae=float(mean_absolute_error(target, prediction)), rmse=float(np.sqrt(mean_squared_error(target, prediction))),\n",
324
+ " r2=float(r2_score(target, prediction)))\n",
325
+ " assert set(target) == {0, 1} and ((prediction >= 0) & (prediction <= 1)).all()\n",
326
+ " return dict(auroc=float(roc_auc_score(target, prediction)), auprc=float(average_precision_score(target, prediction)))\n",
327
+ "\n",
328
+ "\n",
329
+ "def paired_table(results):\n",
330
+ " pairs = []\n",
331
+ " for (dataset, seed), group in results.groupby([\"dataset\", \"seed\"]):\n",
332
+ " assert set(group.model) == {\"GRASP\", \"A1\"} and len(group) == 2\n",
333
+ " group = group.set_index(\"model\")\n",
334
+ " metric = \"mae\" if group.loc[\"A1\", \"task\"] == \"regression\" else \"auroc\"\n",
335
+ " baseline, a1 = float(group.loc[\"GRASP\", metric]), float(group.loc[\"A1\", metric])\n",
336
+ " delta = a1 - baseline\n",
337
+ " pairs.append(dict(dataset=dataset, seed=int(seed), metric=metric, GRASP=baseline, A1=a1,\n",
338
+ " delta_A1_minus_GRASP=delta, direction=\"lower is better\" if metric == \"mae\" else \"higher is better\",\n",
339
+ " A1_better=bool(delta < 0 if metric == \"mae\" else delta > 0)))\n",
340
+ " return pd.DataFrame(pairs)\n"
341
+ ],
342
+ "execution_count": null,
343
+ "outputs": [],
344
+ "id": "a1-comparison-08"
345
+ },
346
+ {
347
+ "cell_type": "code",
348
+ "metadata": {},
349
+ "source": [
350
+ "import importlib.metadata, platform\n",
351
+ "from rdkit import RDLogger\n",
352
+ "from grasp.encoder import read_vocab\n",
353
+ "from IPython.display import display\n",
354
+ "RDLogger.DisableLog(\"rdApp.warning\")\n",
355
+ "DEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
356
+ "torch.set_num_threads(2)\n",
357
+ "enable_matched_initialization()\n",
358
+ "MODEL_DIRS, registry = {}, {}\n",
359
+ "for name, model in MODELS.items():\n",
360
+ " folder = Path(snapshot_download(model[\"repo\"], revision=model[\"revision\"],\n",
361
+ " allow_patterns=[\"model.safetensors\", \"config.json\", \"vocab.json\"]))\n",
362
+ " MODEL_DIRS[name] = folder\n",
363
+ " config = json.loads((folder / \"config.json\").read_text())\n",
364
+ " assert config[\"architecture\"][\"position_biased_input\"] == (name == \"GRASP\")\n",
365
+ " registry[name] = {**model, \"files_sha256\": {file: sha256(folder / file)\n",
366
+ " for file in (\"model.safetensors\", \"config.json\", \"vocab.json\")}}\n",
367
+ "vocabulary = read_vocab(MODEL_DIRS[\"GRASP\"] / \"vocab.json\")\n",
368
+ "assert vocabulary == read_vocab(MODEL_DIRS[\"A1\"] / \"vocab.json\")\n",
369
+ "runtime = {\"python\": platform.python_version(), \"device\": DEVICE,\n",
370
+ " \"gpu\": torch.cuda.get_device_name(0) if DEVICE == \"cuda\" else None,\n",
371
+ " \"bf16\": DEVICE == \"cuda\" and torch.cuda.is_bf16_supported(),\n",
372
+ " \"packages\": {p: importlib.metadata.version(p) for p in\n",
373
+ " (\"torch\", \"rdkit\", \"numpy\", \"scipy\", \"scikit-learn\", \"grasp-molecular\")}}\n",
374
+ "print(runtime)\n",
375
+ "if DEVICE == \"cpu\":\n",
376
+ " print(\"CPU selected: full fine-tuning can be slow; a Colab GPU is recommended.\")\n",
377
+ "write_json(RUN_ROOT / \"models.json\", registry)\n",
378
+ "write_json(RUN_ROOT / \"runtime.json\", runtime)\n"
379
+ ],
380
+ "execution_count": null,
381
+ "outputs": [],
382
+ "id": "a1-comparison-09"
383
+ },
384
+ {
385
+ "cell_type": "markdown",
386
+ "metadata": {},
387
+ "source": [
388
+ "## 5. Prepare data and create splits once\n",
389
+ "\n",
390
+ "Sources and checksums are fixed. Parsing, missing labels, unsupported size and featurizer failures are audited jointly **before** splitting; excluded rows are listed in `data_audit.csv`. No molecule is repaired or truncated. Duplicates are retained within one partition. Canonical isomeric SMILES are shared by both models; salts, charges and tautomers are not standardized away.\n",
391
+ "\n",
392
+ "The 80/10/10 **target** proportions use scaffold groups; all acyclic structures stay together. Actual counts can differ from these proportions. The notebook checks row, connectivity and scaffold disjointness. These are newly generated comparison splits, not an official test-set modification or a reproduction of DeepChem's default split for every dataset.\n"
393
+ ],
394
+ "id": "a1-comparison-10"
395
+ },
396
+ {
397
+ "cell_type": "code",
398
+ "metadata": {},
399
+ "source": [
400
+ "assert set(TASKS) <= set(DATASETS) and len(TASKS) == len(set(TASKS))\n",
401
+ "split_manifests, overview = {}, []\n",
402
+ "for dataset in TASKS:\n",
403
+ " directory = RUN_ROOT / \"splits\" / dataset\n",
404
+ " frame = prepare_task(dataset, RUN_ROOT / \"raw\", directory, vocabulary)\n",
405
+ " split_manifests[dataset] = save_splits(frame, DATASETS[dataset][\"task\"], directory, SPLIT_SEED)\n",
406
+ " audit = json.loads((directory / \"data_manifest.json\").read_text())\n",
407
+ " overview.append({\"dataset\": dataset, \"task\": DATASETS[dataset][\"task\"],\n",
408
+ " \"excluded\": audit[\"excluded_rows\"], **split_manifests[dataset][\"counts\"]})\n",
409
+ "display(pd.DataFrame(overview))\n"
410
+ ],
411
+ "execution_count": null,
412
+ "outputs": [],
413
+ "id": "a1-comparison-11"
414
+ },
415
+ {
416
+ "cell_type": "code",
417
+ "metadata": {},
418
+ "source": [
419
+ "head_checks = {}\n",
420
+ "for seed in SEEDS:\n",
421
+ " checks = {name: head_fingerprint(folder, seed, METHOD) for name, folder in MODEL_DIRS.items()}\n",
422
+ " assert checks[\"GRASP\"] == checks[\"A1\"], \"Initial head/RNG mismatch: comparison blocked.\"\n",
423
+ " head_checks[str(seed)] = checks\n",
424
+ "write_json(RUN_ROOT / \"head_initialization_checks.json\", head_checks)\n",
425
+ "experiment = dict(protocol=PROTOCOL_VERSION, code_revision=CODE_REVISION, models=registry,\n",
426
+ " tasks=TASKS, method=METHOD, seeds=SEEDS, split_seed=SPLIT_SEED,\n",
427
+ " epochs=EPOCHS, patience=PATIENCE, batch_size=BATCH_SIZE, learning_rate=LEARNING_RATE,\n",
428
+ " splits=split_manifests, runtime=runtime)\n",
429
+ "experiment_file = RUN_ROOT / \"experiment.json\"\n",
430
+ "if experiment_file.is_file():\n",
431
+ " assert json.loads(experiment_file.read_text()) == experiment, \"Experiment changed: use a new RUN_ROOT.\"\n",
432
+ "else:\n",
433
+ " write_json(experiment_file, experiment)\n",
434
+ "print(\"Same splits, checkpoint files, initial heads and post-initialization RNG verified.\")\n"
435
+ ],
436
+ "execution_count": null,
437
+ "outputs": [],
438
+ "id": "a1-comparison-12"
439
+ },
440
+ {
441
+ "cell_type": "markdown",
442
+ "metadata": {},
443
+ "source": [
444
+ "## 6. Train both models on the same files\n",
445
+ "\n",
446
+ "The learned readout mixes layers 4/8/10/12 and uses atom-attention pooling. Target scaling comes only from training labels. Validation controls early stopping separately for each model under the same rule; **no test predictions are used here**. The fitting runner saves a predictor per endpoint, model and seed.\n"
447
+ ],
448
+ "id": "a1-comparison-13"
449
+ },
450
+ {
451
+ "cell_type": "code",
452
+ "metadata": {},
453
+ "source": [
454
+ "training_rows = []\n",
455
+ "for dataset in TASKS:\n",
456
+ " split_dir = RUN_ROOT / \"splits\" / dataset\n",
457
+ " for seed in SEEDS:\n",
458
+ " for model_name in (\"GRASP\", \"A1\"):\n",
459
+ " output = RUN_ROOT / \"runs\" / dataset / f\"seed_{seed}\" / model_name\n",
460
+ " print(f\"TRAIN {dataset} seed={seed} {model_name}\", flush=True)\n",
461
+ " outcome = train_one(MODEL_DIRS[model_name], split_dir, output,\n",
462
+ " task=DATASETS[dataset][\"task\"], method=METHOD, seed=seed,\n",
463
+ " epochs=EPOCHS, patience=PATIENCE, batch_size=BATCH_SIZE,\n",
464
+ " learning_rate=LEARNING_RATE, device=DEVICE,\n",
465
+ " identity={\"checkpoint\": registry[model_name], \"runtime\": runtime,\n",
466
+ " \"initialization\": head_checks[str(seed)][model_name]})\n",
467
+ " training_rows.append({\"dataset\": dataset, \"seed\": seed, \"model\": model_name,\n",
468
+ " \"best_epoch\": outcome[\"best_epoch\"], \"validation_score\": outcome[\"validation_score\"]})\n",
469
+ "pd.DataFrame(training_rows).to_csv(RUN_ROOT / \"training.csv\", index=False)\n"
470
+ ],
471
+ "execution_count": null,
472
+ "outputs": [],
473
+ "id": "a1-comparison-14"
474
+ },
475
+ {
476
+ "cell_type": "markdown",
477
+ "metadata": {},
478
+ "source": [
479
+ "## 7. Evaluate and compare\n",
480
+ "\n",
481
+ "MAE/RMSE are in the target's original units; lower is better. BBBP AUROC/AUPRC use predicted class-1 probabilities; higher is better. Each paired delta is **A1 minus GRASP**. Do not average incompatible metrics across tasks. With one seed, sample standard deviation is undefined. Repeated training seeds use the same held-out structures and do not quantify split or pretraining uncertainty.\n"
482
+ ],
483
+ "id": "a1-comparison-15"
484
+ },
485
+ {
486
+ "cell_type": "code",
487
+ "metadata": {},
488
+ "source": [
489
+ "from grasp import GRASPPredictor\n",
490
+ "results, predictions = [], []\n",
491
+ "for dataset in TASKS:\n",
492
+ " split_dir = RUN_ROOT / \"splits\" / dataset\n",
493
+ " test = pd.read_csv(split_dir / \"test.csv\")\n",
494
+ " assert sha256(split_dir / \"test.csv\") == split_manifests[dataset][\"sha256\"][\"test\"]\n",
495
+ " for seed in SEEDS:\n",
496
+ " for model_name in (\"GRASP\", \"A1\"):\n",
497
+ " predictor = GRASPPredictor.from_pretrained(RUN_ROOT / \"runs\" / dataset / f\"seed_{seed}\" / model_name, device=DEVICE)\n",
498
+ " pred = predictor.predict(test.smiles.tolist(), batch_size=BATCH_SIZE)\n",
499
+ " metrics = score_predictions(test.target.to_numpy(), pred, DATASETS[dataset][\"task\"])\n",
500
+ " results.append({\"dataset\": dataset, \"seed\": seed, \"model\": model_name,\n",
501
+ " \"task\": DATASETS[dataset][\"task\"], \"test_rows\": len(test), **metrics})\n",
502
+ " block = test[[\"source_row\", \"smiles\", \"target\"]].copy()\n",
503
+ " block[\"prediction\"], block[\"dataset\"], block[\"seed\"], block[\"model\"] = pred, dataset, seed, model_name\n",
504
+ " predictions.append(block)\n",
505
+ " del predictor\n",
506
+ " gc.collect()\n",
507
+ " if DEVICE == \"cuda\": torch.cuda.empty_cache()\n",
508
+ "results = pd.DataFrame(results)\n",
509
+ "paired = paired_table(results)\n",
510
+ "summary = paired.groupby([\"dataset\", \"metric\", \"direction\"])[[\"GRASP\", \"A1\", \"delta_A1_minus_GRASP\"]].agg([\"mean\", \"std\", \"count\"])\n",
511
+ "results.to_csv(RUN_ROOT / \"results.csv\", index=False)\n",
512
+ "paired.to_csv(RUN_ROOT / \"paired_comparison.csv\", index=False)\n",
513
+ "summary.to_csv(RUN_ROOT / \"summary.csv\")\n",
514
+ "pd.concat(predictions, ignore_index=True).to_csv(RUN_ROOT / \"test_predictions.csv\", index=False)\n",
515
+ "display(paired)\n",
516
+ "display(summary)\n"
517
+ ],
518
+ "execution_count": null,
519
+ "outputs": [],
520
+ "id": "a1-comparison-16"
521
+ },
522
+ {
523
+ "cell_type": "markdown",
524
+ "metadata": {},
525
+ "source": [
526
+ "## 8. Download the evidence\n",
527
+ "\n",
528
+ "The compact ZIP contains scores, predictions, shared splits, exclusions, runtime settings, checkpoint hashes and head checks. It excludes raw data and model/predictor weights. Keep the `runs/` directory separately if you need the trained predictors after Colab disconnects. Test data must remain untouched for an honest comparison; new experiments selected after viewing these scores need a new held-out evaluation.\n"
529
+ ],
530
+ "id": "a1-comparison-17"
531
+ },
532
+ {
533
+ "cell_type": "code",
534
+ "metadata": {},
535
+ "source": [
536
+ "import zipfile\n",
537
+ "archive = RUN_ROOT / \"grasp_a1_comparison_results.zip\"\n",
538
+ "with zipfile.ZipFile(archive, \"w\", compression=zipfile.ZIP_DEFLATED) as bundle:\n",
539
+ " for path in sorted(RUN_ROOT.rglob(\"*\")):\n",
540
+ " if path.is_file() and path.suffix in (\".csv\", \".json\") and \"raw\" not in path.relative_to(RUN_ROOT).parts:\n",
541
+ " bundle.write(path, path.relative_to(RUN_ROOT).as_posix())\n",
542
+ "print(f\"Saved {archive}\")\n",
543
+ "try:\n",
544
+ " from google.colab import files\n",
545
+ "except ImportError:\n",
546
+ " pass\n",
547
+ "else:\n",
548
+ " files.download(str(archive))\n"
549
+ ],
550
+ "execution_count": null,
551
+ "outputs": [],
552
+ "id": "a1-comparison-18"
553
+ },
554
+ {
555
+ "cell_type": "markdown",
556
+ "metadata": {},
557
+ "source": [
558
+ "## Sources and scope\n",
559
+ "\n",
560
+ "Dataset URLs/targets follow the primary DeepChem loaders: [ESOL](https://github.com/deepchem/deepchem/blob/master/deepchem/molnet/load_function/delaney_datasets.py), [FreeSolv](https://github.com/deepchem/deepchem/blob/master/deepchem/molnet/load_function/freesolv_dataset.py), [BBBP](https://github.com/deepchem/deepchem/blob/master/deepchem/molnet/load_function/bbbp_datasets.py). This notebook deliberately applies one disclosed scaffold-split recipe to all three, rather than claiming to reproduce each loader's defaults. Data use remains subject to the source datasets' terms.\n",
561
+ "\n",
562
+ "Model weights: [GRASP](https://huggingface.co/caithmac/GRASP), [A1](https://huggingface.co/caithmac/GRASP-A1). Code and model terms are CC BY-NC 4.0 with the DeBERTa implementation's separate MIT attribution. See the model cards for training data and limitations. This notebook has no embedded tokens and is saved without outputs.\n"
563
+ ],
564
+ "id": "a1-comparison-19"
565
+ }
566
+ ],
567
+ "metadata": {
568
+ "accelerator": "GPU",
569
+ "colab": {
570
+ "provenance": [],
571
+ "toc_visible": true
572
+ },
573
+ "kernelspec": {
574
+ "display_name": "Python 3",
575
+ "language": "python",
576
+ "name": "python3"
577
+ },
578
+ "language_info": {
579
+ "name": "python"
580
+ },
581
+ "protocol_helper_sha256": "5bff4850d4b7da22944c50ac1a741c6b1ca8738347ec5a55ec3bc9140d3aaaae"
582
+ },
583
+ "nbformat": 4,
584
+ "nbformat_minor": 5
585
+ }
notebooks/comparison_helpers.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared, auditable protocol embedded in the self-contained Colab notebook."""
2
+ import gc
3
+ import hashlib
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import requests
11
+ import torch
12
+ from rdkit import Chem
13
+ from rdkit.Chem.Scaffolds import MurckoScaffold
14
+ from sklearn.metrics import average_precision_score, mean_absolute_error, mean_squared_error, r2_score, roc_auc_score
15
+
16
+ DATASETS = {
17
+ "esol": dict(filename="delaney-processed.csv", target="measured log solubility in mols per litre", task="regression",
18
+ sha256="8c06a76f0c6487d29ab0f903e6a7a7139f189ab3c1178f159c8be8964602f189"),
19
+ "freesolv": dict(filename="freesolv.csv.gz", target="y", task="regression",
20
+ sha256="008e9a8a9e018c6b77eb00df7153d0bf2e35a1d663f18f301de522fe3f8711d7"),
21
+ "bbbp": dict(filename="BBBP.csv", target="p_np", task="binary",
22
+ sha256="d07a38487aeac5cee5508413e468043ef3097451d2a112701c2d60be9ec6b662"),
23
+ }
24
+ PROTOCOL_VERSION = "grasp-a1-paired-colab-20261015-v1"
25
+
26
+
27
+ def sha256(path):
28
+ digest = hashlib.sha256()
29
+ with Path(path).open("rb") as stream:
30
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
31
+ digest.update(block)
32
+ return digest.hexdigest()
33
+
34
+
35
+ def write_json(path, value):
36
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
37
+ Path(path).write_text(json.dumps(value, indent=2, allow_nan=False) + "\n")
38
+
39
+
40
+ def prepare_task(name, cache_dir, output_dir, vocabulary):
41
+ from mole.training.data.datasets import MolDataset
42
+ spec = DATASETS[name]
43
+ cache_dir, output_dir = Path(cache_dir), Path(output_dir)
44
+ cache_dir.mkdir(parents=True, exist_ok=True)
45
+ output_dir.mkdir(parents=True, exist_ok=True)
46
+ raw = cache_dir / spec["filename"]
47
+ url = "https://deepchemdata.s3.us-west-1.amazonaws.com/datasets/" + spec["filename"]
48
+ if not raw.is_file():
49
+ response = requests.get(url, timeout=90)
50
+ response.raise_for_status()
51
+ raw.write_bytes(response.content)
52
+ assert sha256(raw) == spec["sha256"], f"Source changed for {name}; review it before updating its checksum."
53
+ frame = pd.read_csv(io.BytesIO(raw.read_bytes()), compression="gzip" if raw.suffix == ".gz" else None)
54
+ accepted, audit = [], []
55
+ for source_row, row in frame.iterrows():
56
+ reason = ""
57
+ try:
58
+ target = float(row[spec["target"]])
59
+ if not np.isfinite(target):
60
+ raise ValueError("nonfinite_target")
61
+ if spec["task"] == "binary" and target not in (0, 1):
62
+ raise ValueError("nonbinary_target")
63
+ mol = Chem.MolFromSmiles(str(row["smiles"]))
64
+ if mol is None or not mol.GetNumAtoms():
65
+ raise ValueError("unparsable_or_empty")
66
+ if mol.GetNumAtoms() > 511:
67
+ raise ValueError("above_loader_atom_limit")
68
+ smiles = Chem.MolToSmiles(mol, isomericSmiles=True)
69
+ # Check the exact deployed featurizer once, before either model receives a split.
70
+ MolDataset(pd.Series([smiles]), vocabulary, cls_token=True)[0]
71
+ connectivity = Chem.MolToSmiles(mol, isomericSmiles=False)
72
+ scaffold = Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(mol), isomericSmiles=False) or "<acyclic>"
73
+ accepted.append(dict(source_row=int(source_row), smiles=smiles, target=target,
74
+ connectivity=connectivity, scaffold=scaffold))
75
+ except Exception as exc:
76
+ reason = f"{type(exc).__name__}: {exc}"
77
+ audit.append(dict(source_row=int(source_row), original_smiles=str(row["smiles"]), excluded=bool(reason), reason=reason))
78
+ pd.DataFrame(audit).to_csv(output_dir / "data_audit.csv", index=False)
79
+ result = pd.DataFrame(accepted)
80
+ assert len(result), f"No usable rows for {name}"
81
+ result.to_csv(output_dir / "eligible_rows.csv", index=False)
82
+ write_json(output_dir / "data_manifest.json", dict(dataset=name, url=url, source_sha256=spec["sha256"],
83
+ source_rows=len(frame), eligible_rows=len(result), excluded_rows=len(frame) - len(result),
84
+ policy="Canonical isomeric SMILES; no charge/salt/tautomer repair; retain duplicates within one scaffold partition."))
85
+ return result
86
+
87
+
88
+ def scaffold_split(frame, seed):
89
+ groups = [group.index.to_list() for _, group in frame.groupby("scaffold", sort=True)]
90
+ np.random.default_rng(seed).shuffle(groups)
91
+ groups.sort(key=len, reverse=True)
92
+ partitions = {"train": [], "valid": [], "test": []}
93
+ for group in groups:
94
+ if len(partitions["train"]) + len(group) <= 0.8 * len(frame):
95
+ partitions["train"].extend(group)
96
+ elif len(partitions["valid"]) + len(group) <= 0.1 * len(frame):
97
+ partitions["valid"].extend(group)
98
+ else:
99
+ partitions["test"].extend(group)
100
+ result = {key: frame.loc[sorted(rows)].reset_index(drop=True) for key, rows in partitions.items()}
101
+ assert sum(map(len, result.values())) == len(frame)
102
+ assert all(len(part) for part in result.values()), "Too few scaffold groups to create three nonempty splits."
103
+ for column in ("source_row", "connectivity", "scaffold"):
104
+ sets = [set(part[column]) for part in result.values()]
105
+ assert not (sets[0] & sets[1] or sets[0] & sets[2] or sets[1] & sets[2]), f"Split leakage: {column}"
106
+ return result
107
+
108
+
109
+ def save_splits(frame, task, directory, seed):
110
+ directory = Path(directory)
111
+ directory.mkdir(parents=True, exist_ok=True)
112
+ parts = scaffold_split(frame, seed)
113
+ if task == "binary":
114
+ assert all(set(part.target) == {0, 1} for part in parts.values()), "A binary partition lacks one class; do not silently resplit."
115
+ csv_bytes = {split: part.to_csv(index=False).encode("utf-8") for split, part in parts.items()}
116
+ for split, data in csv_bytes.items():
117
+ path = directory / f"{split}.csv"
118
+ assert not path.is_file() or path.read_bytes() == data, "Existing splits differ: choose a new RUN_ROOT."
119
+ for split, data in csv_bytes.items():
120
+ (directory / f"{split}.csv").write_bytes(data)
121
+ manifest = dict(split_seed=seed, policy="Shared scaffold-grouped 80/10/10 target proportions; all acyclic molecules form one group.",
122
+ counts={key: len(part) for key, part in parts.items()},
123
+ sha256={key: sha256(directory / f"{key}.csv") for key in parts},
124
+ disjoint_source_rows=True, disjoint_connectivity=True, disjoint_scaffolds=True)
125
+ write_json(directory / "split_manifest.json", manifest)
126
+ return manifest
127
+
128
+
129
+ def enable_matched_initialization():
130
+ import grasp.encoder as encoder_module
131
+ import grasp.predictor as predictor_module
132
+ from mole.training.models.mole import AtomEnvEmbeddings
133
+ class MatchedEmbeddings(AtomEnvEmbeddings):
134
+ def __init__(self, config):
135
+ # Loading a different encoder architecture must not shift task-head or loader RNG.
136
+ with torch.random.fork_rng(devices=[]):
137
+ super().__init__(config)
138
+ encoder_module.AtomEnvEmbeddings = predictor_module.AtomEnvEmbeddings = MatchedEmbeddings
139
+
140
+
141
+ def head_fingerprint(model_dir, seed, method):
142
+ from grasp import GRASPEncoder, GRASPPredictor
143
+ torch.manual_seed(seed)
144
+ encoder = GRASPEncoder.from_pretrained(model_dir, device="cpu")
145
+ predictor = GRASPPredictor.from_encoder(encoder, task="regression", method=method)
146
+ digest = hashlib.sha256()
147
+ for key, value in sorted(predictor.model.state_dict().items()):
148
+ if not key.startswith("encoder."):
149
+ digest.update(key.encode())
150
+ digest.update(value.detach().cpu().contiguous().numpy().tobytes())
151
+ state = hashlib.sha256(torch.get_rng_state().numpy().tobytes()).hexdigest()
152
+ del predictor, encoder
153
+ gc.collect()
154
+ return dict(readout_sha256=digest.hexdigest(), post_initialization_rng_sha256=state)
155
+
156
+
157
+ def train_one(model_dir, split_dir, output_dir, *, task, method, seed, epochs, patience, batch_size, learning_rate, device, identity):
158
+ from grasp.train import fit
159
+ split_dir, output_dir = Path(split_dir), Path(output_dir)
160
+ split_hashes = {key: sha256(split_dir / f"{key}.csv") for key in ("train", "valid", "test")}
161
+ assert split_hashes == json.loads((split_dir / "split_manifest.json").read_text())["sha256"], "Shared split files changed."
162
+ plan = dict(protocol=PROTOCOL_VERSION, identity=identity, split_sha256=split_hashes, task=task, method=method,
163
+ seed=seed, epochs=epochs, patience=patience, batch_size=batch_size, learning_rate=learning_rate, device=device)
164
+ plan_file = output_dir / "run_plan.json"
165
+ if plan_file.is_file():
166
+ assert json.loads(plan_file.read_text()) == plan, "Settings/splits changed: choose a new RUN_ROOT."
167
+ else:
168
+ write_json(plan_file, plan)
169
+ complete = output_dir / "TRAINING_COMPLETE.json"
170
+ if complete.is_file():
171
+ assert (output_dir / "predictor.safetensors").is_file()
172
+ return json.loads(complete.read_text())
173
+ # Test labels are never supplied to fitting or early stopping.
174
+ outcome = fit(model_source=model_dir, train_csv=split_dir / "train.csv", valid_csv=split_dir / "valid.csv",
175
+ output_dir=output_dir, task=task, method=method, seed=seed, epochs=epochs, patience=patience,
176
+ batch_size=batch_size, learning_rate=learning_rate, device=device)
177
+ write_json(complete, outcome)
178
+ gc.collect()
179
+ if torch.cuda.is_available():
180
+ torch.cuda.empty_cache()
181
+ return outcome
182
+
183
+
184
+ def score_predictions(target, prediction, task):
185
+ target, prediction = np.asarray(target), np.asarray(prediction)
186
+ assert target.shape == prediction.shape and np.isfinite(prediction).all()
187
+ if task == "regression":
188
+ return dict(mae=float(mean_absolute_error(target, prediction)), rmse=float(np.sqrt(mean_squared_error(target, prediction))),
189
+ r2=float(r2_score(target, prediction)))
190
+ assert set(target) == {0, 1} and ((prediction >= 0) & (prediction <= 1)).all()
191
+ return dict(auroc=float(roc_auc_score(target, prediction)), auprc=float(average_precision_score(target, prediction)))
192
+
193
+
194
+ def paired_table(results):
195
+ pairs = []
196
+ for (dataset, seed), group in results.groupby(["dataset", "seed"]):
197
+ assert set(group.model) == {"GRASP", "A1"} and len(group) == 2
198
+ group = group.set_index("model")
199
+ metric = "mae" if group.loc["A1", "task"] == "regression" else "auroc"
200
+ baseline, a1 = float(group.loc["GRASP", metric]), float(group.loc["A1", metric])
201
+ delta = a1 - baseline
202
+ pairs.append(dict(dataset=dataset, seed=int(seed), metric=metric, GRASP=baseline, A1=a1,
203
+ delta_A1_minus_GRASP=delta, direction="lower is better" if metric == "mae" else "higher is better",
204
+ A1_better=bool(delta < 0 if metric == "mae" else delta > 0)))
205
+ return pd.DataFrame(pairs)
notebooks/verification.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "datasets": {
3
+ "esol": {
4
+ "eligible_rows": 1128,
5
+ "exclusions": 0,
6
+ "split_counts": {
7
+ "train": 902,
8
+ "valid": 112,
9
+ "test": 114
10
+ },
11
+ "deterministic": true,
12
+ "disjoint_scaffolds_and_connectivity": true
13
+ },
14
+ "freesolv": {
15
+ "eligible_rows": 642,
16
+ "exclusions": 0,
17
+ "split_counts": {
18
+ "train": 513,
19
+ "valid": 64,
20
+ "test": 65
21
+ },
22
+ "deterministic": true,
23
+ "disjoint_scaffolds_and_connectivity": true
24
+ },
25
+ "bbbp": {
26
+ "eligible_rows": 2039,
27
+ "exclusions": 11,
28
+ "split_counts": {
29
+ "train": 1631,
30
+ "valid": 203,
31
+ "test": 205
32
+ },
33
+ "deterministic": true,
34
+ "disjoint_scaffolds_and_connectivity": true
35
+ }
36
+ },
37
+ "status": "passed",
38
+ "notebook_cells_compiled": true,
39
+ "paired_full_adaptation_smoke_fits": 4,
40
+ "initial_heads_and_rng_identical": true,
41
+ "resume_skip_and_mismatch_rejection": true,
42
+ "compact_archive": true,
43
+ "scope": "Real full-dataset split audits plus tiny CPU notebook fits; no Colab GPU or full benchmark run",
44
+ "auth_public_private_and_network_error_checks": true,
45
+ "notebook_sha256": "cc71af7c0c1572a0718cbd5276e2a76cc769ca72fb46f02447cfd383d28fd6dd"
46
+ }