Structure-Activity Relationship Wizard. A rapid-turnaround study of whether the descriptor target a molecular foundation model is pretrained against determines which downstream endpoints it serves best.
CheMeleon is a directed message-passing neural network (D-MPNN, Chemprop) pretrained self-supervised to regress a block of precomputed molecular descriptors from the molecular graph. SARizard trains several "flavors" of that foundation, each varying only the descriptor block it learns to predict (osmordred and PCA-compressed variants of it, RDKit descriptors, fingerprints, pharmacophores, 3D descriptors, and learned-model targets such as minimol embeddings and surrogate ADME predictions). Every flavor is pretrained separately on one shared corpus, never concatenated, then finetuned on real ADMET benchmark endpoints and evaluated. The result is a fit-to-purpose catalog: which foundational pretraining target lends itself to which endpoint and endpoint family.
- One shared corpus.
corpus/corpus_full.parquet: 944,296 molecules, the whole cleaned 1M-molecule PubChem set from the CheMeleon Training Data record that survives salt stripping and canonicalization. Every flavor computes its target on these same molecules so the report-card columns are apples-to-apples. A 250K subset (corpus/corpus_250k.parquet) was built first and used only for early screening; every result on record was rerun on the full corpus, and the 250K flavor-sweep artifacts are archived underarchive/flavor_sweep_250k/. The 250K subset is still what the pipeline builds by default, so full-corpus runs exportCORPUS_FILE=corpus/corpus_full.parquet CORPUS_N=1000000(see Running the full experiment). - Pretrain per flavor. Each flavor pretrains a D-MPNN to regress its descriptor
block under one fixed regime (shared corpus, capped epochs, shared LR schedule, gradient
clipping, masked-pretext target dropout). Continuous targets are prescaled with the
chemeleon_baselinerecipe the ablation triage picked and train with MSE; binary fingerprint targets skip scaling and train with BCE. Target blocks at or under 30 dims (jazzyat 6,surrogate_admeat 25) pretrain with the masked-pretext dropout off, which is a hard invariant rather than a tunable (seeAGENTS.md). - Convert. The pretrained message-passing block is exported to the checkpoint
format
openadmet-modelsconsumes viafrom_foundation. - Finetune per endpoint. For every flavor, each ADMET benchmark endpoint is
finetuned with
openadmet anvil, initialized from that flavor's foundation. 24 finetuning recipes across 8 source datasets become 32 endpoint-columns on the card. Each flavor is pretrained once at seed 42 and finetuned at 5 replicate seeds, under three backbone protocols:frozen(mpnn_lr = 0),reduced(mpnn_lr = ffn_lr/10), andunlocked(mpnn_lr = ffn_lr). Each protocol is compared against a stock-CheMeleon baseline finetuned the same way; thereducedbaseline was later deepened to 20 seeds, which is the allocation Dunnett's test wants. - Evaluate and compare. Three artifacts. A pair of report-card heatmaps (endpoints by flavors): the R-squared card leads with the stock-CheMeleon baseline column, divided from the flavor block by a heavy rule, and the MAE %-change card colors each flavor's change against that baseline, white where Dunnett's test does not separate the two. Each card is written as a PNG and as an equivalent HTML table. Endpoint rows are bracketed by source dataset, and the three ChEMBL-derived sources are named for the assay they measure (ChEMBL CLint, ChEMBL CYP, and, for the single hERG row, ChEMBL) rather than for a release number. A pair of summary boxplots then unpacks each card's AVERAGE row into one box per column (endpoint-to-endpoint spread as the box, seed spread of the average as the error bar), ordered best to worst left to right. Finally a meta-model stacks the per-flavor predictions per endpoint to test whether an ensemble of foundations beats the best single one.
| Flavor | Pretraining target | dim | Loss | Target source |
|---|---|---|---|---|
| osmordred | osmordred descriptors | 3585 | MSE | direct compute (isolated env) |
| rdkit2d | RDKit 2D descriptors | 200 | MSE | direct compute |
| erg | ERG pharmacophore | 315 | MSE | direct compute |
| ecfp | ECFP bits | 2048 | BCE | direct compute |
| atompair | Atom-pair bits | 2048 | BCE | direct compute |
| pubchem | PubChem keys | 881 | BCE | direct compute |
| usrcat | USRCAT (3D) | 60 | MSE | direct compute (conformers) |
| whim | WHIM (3D) | 114 | MSE | direct compute (conformers) |
| e3fp | E3FP bits (3D) | 1024 | BCE | direct compute (conformers) |
| jazzy | hydration energy + H-bond strengths | 6 | MSE | direct compute (isolated env) |
| minimol | minimol embedding | 512 | MSE | learned model (isolated env) |
| surrogate_adme | 25 ADME predictions | 25 | MSE | released dataset (native corpus) |
| osmordred_pca80 | osmordred, PCA to 80% explained variance | 70 | MSE | derived from osmordred |
| osmordred_pca90 | osmordred, PCA to 90% explained variance | 147 | MSE | derived from osmordred |
| osmordred_pca95 | osmordred, PCA to 95% explained variance | 237 | MSE | derived from osmordred |
Binary fingerprint targets are deterministic from the input graph, so they are a
leaky and likely weak pretext; their position on the report card is itself a result.
The 3D flavors require conformer generation (RDKit ETKDG + MMFF94) and their targets are
not bit-reproducible across runs (conformers are stochastic). The learned-model flavors generate their target by running the source model over the shared
corpus. The three PCA flavors have no calculator of their own: they compress the
full-recipe-prescaled osmordred matrix, with the PCA fit on the train rows only, so they
test whether the 3585-dim block's width or its content is what the foundation is learning.
surrogate_adme is the exception to the shared corpus: the Novartis work ships a 273K-row
prediction CSV (CC BY 4.0) with 25 ADME values per molecule, so this flavor uses those
molecules and labels directly as its own pretraining corpus rather than predicting over the
shared corpus (see sarizard/pretraining/features/surrogate_target.py). Its report-card
column is therefore a different-corpus reference arm rather than an apples-to-apples
comparison.
Each calculator writes a plain cache/targets/<flavor>/target.npy in the flavor's own
environment, then a pack step in the main environment converts it to the chunked
cache/targets/<flavor>/target.zarr the trainer reads. This keeps zarr (and its Python
3.11+ requirement) out of the old, conflicting learned-model environments.
FINDINGS.md is the source of record; wiki/ holds the per-flavor and per-endpoint detail.
The sweep on record covers all 15 flavors under all three finetune protocols at 5 finetune
seeds each, against a stock-CheMeleon baseline per protocol (20 seeds under reduced), plus
the prescaling triage, the external-foundation comparison, the osmordred_surrogate
chemical-space control, and the PXR external-test rerun. In short:
- The descriptor target matters, but less than the finetune protocol. Under frozen and
reduced, the learned-model flavors (
surrogate_adme,minimol) andrdkit2dclear the stock baseline significantly and the binary fingerprints fall well below it. Under unlocked, almost everything collapses back to or below stock, so the pretraining target shows through only while the backbone is held still or nearly still. - Specialization is real but narrow. The per-endpoint winner changes across families, but
no flavor beats stock across a whole family the way the fit-to-purpose premise predicts, and
the one clean specialization signal (
rdkit2don PXR) does not survive a fixed external hold-out. - Stacking beats any single foundation. The LGBM meta-model wins 21 of 32 endpoint-columns, mean delta R-squared +0.082 over the best single flavor per endpoint. This is the frozen protocol; the per-protocol meta-models are wired but were never launched.
- Pretraining corpus size is not the lever. Four external foundations spanning 1M to 10M molecules all lose to stock CheMeleon under every protocol, significantly, and the three Molpile sizes do not order monotonically.
- Control depth changed the reading. Deepening the reduced-protocol stock baseline from 5 seeds to 20 moved seven flavors rather than three above stock, so part of what read as "the target barely matters" was a thin baseline rather than the foundations.
All Python code lives in the importable sarizard/ package; the repo root holds inputs,
recipes, and regenerable artifacts.
sarizard/is the package (pip install -e .); run modules from the repo root withpython -m sarizard.<sub>.<module>:sarizard/corpus/prepares the shared SMILES corpus from the original CheMeleon corpus.sarizard/pretraining/vendors and adaptshow-to-train-your-chemeleon: per-flavor target calculators infeatures/, the pretrain config,prescaling.py(the toggleable descriptor preprocessing and its ablation registry), andconvert_checkpoint.py.sarizard/configs/is the per-flavor recipe generator.sarizard/analysis/holds the report cards, the AVERAGE-row summary boxplots, the prescaling and LR reports, the side-study reports, and the meta-model;sarizard/analysis/paths.pyis the single source of truth for on-disk locations.
configs/_baseline/holds the committed stock-CheMeleon recipe templates; generated per-flavor recipes land inconfigs/<flavor>__s<seed>/(gitignored).corpus/,data/hold inputs;cache/,foundations/,results/,plots/hold regenerable artifacts (all gitignored;data/is committed, seedata/README.md).slurm/holds the sbatch job-array scripts for parallel pretraining and finetuning, plus therun_*.shdrivers that submit them; shared runtime configuration lives inslurm/env.sh.archive/holds runs moved aside rather than overwritten: the 250K flavor sweep, the invalid pre-gradient-clipping ablation triage, and the full-corpus ablation artifacts parked there so the 250K corpus-size check could reuse the same paths.wiki/is an Obsidian vault with detailed per-flavor and per-endpoint write-ups and figures.
The project targets Python 3.11+ and uses conda for environments (see AGENTS.md).
The fastest path on a cluster is setup.sh, which builds every environment from envs/*.yml,
installs SARizard into each (with --no-deps so the isolated envs' pinned stacks are left
intact), runs the test suite in the main env, and prints okay if everything passed:
bash setup.sh # all envs, then test
bash setup.sh main osmordred # restrict to specific envs (envs/<name>.yml basenames)
FORCE=1 bash setup.sh # recreate envs that already exist
SKIP_OSMORDRED_BUILD=1 bash setup.sh # skip the slow osmordred source buildTo set up only the main environment by hand:
conda env create -f envs/main.yml
conda activate sarizard
pip install -e ../openadmet-models
pip install -e .
envs/main.yml deliberately does not pin a PyTorch build, since the right one depends on the
node's CUDA. Install it after creating the env, or the conda-forge CPU-only build gets picked up
and pretraining silently runs on CPU:
conda install -n sarizard pytorch pytorch-cuda=<ver> -c pytorch -c nvidiaThree target generators with conflicting stacks have isolated environments
(envs/osmordred.yml, envs/jazzy.yml, envs/minimol.yml). Each writes a plain target.npy to cache/ that the main
environment packs to zarr, so their dependencies never reach this training env. osmordred has
no package release and is built from source into its env with envs/build_osmordred.sh;
minimol needs the PyG extension wheels declared in its env file's find-links.
surrogate_adme runs in the main environment; it reads the Novartis released CSV directly
and requires no isolated env.
Finetuning and analysis run in a separate openadmet environment (envs/openadmet.yml, built
by setup.sh), not the main env: they drive the openadmet-models CLI, whose stack (pandas 2.x,
torch 2.7, Python 3.12) conflicts with the main training env (pandas 3.x, torch 2.12, Python
3.11). Point OPENADMET_ENV at it (default openadmet).
Build the environments first (bash setup.sh, see Setup), then:
# surrogate_adme needs the Novartis released CSV; the pipeline defaults to
# cache/surrogate/protacdb2.0_zinc_chembl_dataset.csv (see wiki/surrogate_adme.md to download).
# Only export SURROGATE_CSV to point somewhere else:
# export SURROGATE_CSV=/elsewhere/protacdb2.0_zinc_chembl_dataset.csv
# the full corpus every recorded result ran on; slurm/env.sh still defaults to the 250K
# screening subset, so export both to reproduce the sweep as run
export CORPUS_FILE=corpus/corpus_full.parquet CORPUS_N=1000000
# submit the full pipeline and walk away
bash slurm/run_all.sh
# how the recorded sweep was actually run: one foundation per flavor pretrained at seed 42,
# then five finetune replicates off it (this path skips corpus/target/split/pretrain)
FOUNDATION_SEED=42 FLAVOR_SEEDS="1 2 3 4 5" bash slurm/run_all.shrun_all.sh generates the per-(flavor, seed) finetuning configs, then runs six stages in order
(corpus preparation, target computation, split, pretraining, finetuning, analysis). The
pre-finetune stages go out as a SLURM dependency chain; the finetune stage then runs in batches
through submit_batched.sh, which submits a batch, waits, reruns any casualties, and only then
submits the next, so the driver blocks for hours and wants a persistent shell (an interactive
allocation or nohup). Results land in results/ and plots/ when the final job finishes.
Every stage is resumable: existing outputs are skipped, so re-running with more seeds fills in
only the new ones. FLAVOR_SEEDS alone pretrains a separate foundation per seed, tagged
<flavor>__s<seed>; with FOUNDATION_SEED set, the seeds become finetune replicates off that
one foundation, which is how the sweep on record was run. The report card and meta-model average
the seeds back to one column per flavor. See slurm/README.md for the per-stage scripts and how
to resubmit after a partial failure.
After the sweep, bash slurm/run_lr_experiments.sh repeats the finetuning from the same
foundations with the MPNN backbone partially unfrozen (reduced) or fully unfrozen
(unlocked) and compares both against the frozen sweep in plots/lr_ranking_r2.csv.
slurm/run_stock_baseline.sh finetunes the released stock-CheMeleon checkpoint on the same
endpoints under the same recipes; that is the baseline column every card is measured against, so
it has to exist before the analysis is meaningful.
Three further drivers cover the side studies, all on the same endpoints:
slurm/run_external_foundations.sh finetunes four foreign CheMeleon-format checkpoints
(molpile_1M, molpile_5M, molpile_10M, expansion_gen), varying the pretraining dataset and
its size instead of the descriptor block; slurm/run_pxr_ext.sh retests the PXR specialization
signal against the two OpenADMET PXR-challenge phases as fixed external hold-outs, instead of the
sweep's internal Butina split; and slurm/run_osmordred_surrogate.sh computes the osmordred
target on surrogate_adme's corpus, separating that flavor's chemical space from its target.
Before the flavor sweep, decide how continuous descriptor targets are preprocessed. The
triage drives one representative flavor (osmordred) through every prescaling recipe in
sarizard/pretraining/prescaling.py with the backbone, corpus, and regime held fixed, then compares
downstream endpoint performance so the difference is the prescaling alone:
conda env create -f envs/osmordred.yml # the triage flavor's target environment
conda activate sarizard-osmordred && bash envs/build_osmordred.sh && conda deactivate
bash slurm/run_ablations.shRead plots/prescaling_ranking_r2.csv and the ablation report card to pick the production
recipe, then bake it into the core workflow before running the flavor sweep. Outcome:
chemeleon_baseline won under the frozen protocol on the full corpus, narrowly over
order_fix. That recipe is what split.py already produces, so nothing needed baking in. The
same triage on the 250K corpus ranks the recipes differently, which is itself a result: corpus
size shapes which prescaling wins, so a screening-scale triage does not transfer.
The analysis modules run from the repo root against the tidy metrics CSVs, so a figure can be
re-rendered without resubmitting anything. sarizard.analysis.evaluate needs the openadmet
env (it runs predictions) and the 600 dpi report cards need more memory than a login shell
allows, so both go through slurm/analyze.sbatch. The summary boxplots read only the metrics
CSV and depend on nothing beyond pandas, numpy, and matplotlib, so they run inline in either
env. For example, the reduced-protocol AVERAGE-row summaries for the three card sets:
python -m sarizard.analysis.average_summary --lr-mode reduced \
--metrics-csv results/lr_metrics.csv --baseline-flavor chemeleon_stock_reduced \
--exclude-recipe cyp1a2_st expansionrx_logd_st_rand
python -m sarizard.analysis.average_summary --lr-mode reduced \
--metrics-csv results/ablation_metrics.csv --baseline-flavor chemeleon_stock_reduced \
--ablations --prefix ablation_average_summary \
--exclude-recipe cyp1a2_st expansionrx_logd_st_rand
python -m sarizard.analysis.average_summary --lr-mode reduced \
--metrics-csv results/external_metrics.csv --baseline-flavor chemeleon_stock_reduced \
--columns molpile_1M molpile_5M molpile_10M expansion_gen \
--out-dir plots/external_foundations \
--exclude-recipe cyp1a2_st expansionrx_logd_st_randThe scikit-fingerprints flavors (ecfp, rdkit2d, erg, atompair, pubchem, usrcat,
whim, e3fp) and surrogate_adme compute in the main sarizard env; only osmordred,
jazzy, and minimol have isolated envs. Compute the target in the flavor's env
(get_flavor(name).env), pack and train in the main env. Example with ecfp (main env
throughout) at the default seed 42:
conda activate sarizard
# --corpus is explicit: the flag defaults to the 250K screening subset, not the full corpus
python -m sarizard.pretraining.features.compute_target --flavor ecfp \
--corpus corpus/corpus_full.parquet
python -m sarizard.pretraining.features.pack_target --flavor ecfp
# split (seed-independent), then pretrain the foundation at seed 42. The SMILES parquet must be
# the same one the target was computed on, or split.py fails on the row-count mismatch
cd sarizard/pretraining
python split.py --input-zarr ../../cache/targets/ecfp/target.zarr \
--input-smiles ../../corpus/corpus_full.parquet --outdir ../../cache/splits/ecfp \
--flavor ecfp --force
python train.py --flavor ecfp --input-dir ../../cache/splits/ecfp \
--output-dir runs/ecfp__s42 --foundation-name ecfp__s42_mp.pt --seed 42
cd ../..
# generate the seeded recipes and finetune one endpoint
python -m sarizard.configs.generate --flavors ecfp
openadmet anvil --recipe-path configs/ecfp__s42/cyp_mt.yaml --output-dir results/ecfp__s42/cyp_mt/An isolated flavor differs only in the compute step: conda activate sarizard-osmordred
(after bash envs/build_osmordred.sh) for compute_target --flavor osmordred, then switch
back to sarizard for pack, split, train, and finetune.
Datasets and the pretraining corpus are not redistributed; regenerate or obtain them and place them as described before training.
