97,709 plant compounds in. 150 out, each with its distance from everything we checked.
Twelve stages, in the order they were built. Scroll and each one draws itself from the run that produced it.
- 00A hundred thousand plant compounds, and a bench that can make fifty.
- 01Learn from the compounds that have been measured. Score the ones that have not.
- 02Three sources, three licences, four scripts, and no API key anywhere.
- 03We measured the download before trusting it, and it changed the project.
- 04Twelve cuts, one configuration, and a negative class we had to keep rather than drop.
- 05Three descriptions of the same molecule, so that the choice between them is measured.
- 06Split the chemistry, not the rows, then hold every plant compound out of training.
- 07Eighteen cheap fits per pathogen, then tune only the winner and freeze it.
- 08The same model read three ways, and only the hardest reading answers our question.
- 09Score every untested plant structure, then cut fifty that are not copies of each other.
- 10The result we set out to find did not replicate, so it ships as a negative.
- 11Every number traces to a configuration, including the ones on this page.
A hundred thousand plant compounds, and a bench that can make fifty.
Almost no plant compound has ever been put in front of a bacterium, so the useful output is a list someone can afford to synthesise.
- 01A missed hit costs nothing visible
- 02A wasted synthesis costs weeks of bench time
- 03So the metric is precision at the head of the list
- 04And the result is a hypothesis for a bench, never a treatment claim
EthnoHack 2026, Track 2
- Shortlist, in domain
- 50
- Exploratory, outside it
- 15
- Scored, pooled, not carried
- 435
The bright sliver is the whole pool, at true scale. What reaches a chemist is 0.051% of the screen, which is why precision at the head of the ranking is the only metric that buys anything here.
What we chose, and what we turned down
Rank fifty compounds per pathogen
instead of Score the whole plant chemical space and publish the table
A ranking nobody can act on is a file, not a result.
Optimise precision at the top of the list
instead of Optimise AUROC over the full set
AUROC averages over a region a chemist will never reach.
Learn from the compounds that have been measured. Score the ones that have not.
ChEMBL carries measured antibacterial activity, LOTUS carries which plant a natural product came from, and they join on the structure itself.
- 01Join activity to provenance on the InChIKey skeleton
- 02One model per pathogen, not one model for bacteria
- 03Three pathogens, three different cell envelopes
docs/proposal-plant-antibacterials.md
- Measured against this pathogen
- 58,191
- ChEMBL MIC, mostly synthetic chemistry
- Measured and from a plant
- 1,424
- the only place the screen can be checked
- Plant structures, never measured
- 97,709
- LOTUS, with a plant and a reference each
Drawn for Staphylococcus aureus. The model learns on the left block and is pointed at the right one; everything we know about how well that works comes from the narrow band where they meet.
The same join, on the other two
Escherichia coli
43,099 measured · 874 from plants
Gram-negative
Mycobacterium tuberculosis
22,907 measured · 691 from plants
Acid-fast
What we chose, and what we turned down
Three per-pathogen models
instead of One general antibacterial model
Pooling the labels hides the envelope difference the three pathogens were chosen to expose.
S. aureus, E. coli, M. tuberculosis
instead of P. aeruginosa, B. subtilis
A second Gram-negative adds no envelope, and a lab strain is not a clinical pathogen.
Single compounds
instead of Synergy between the components of an extract
No labelled set of plant compound pairs exists at this scale to supervise it.
Three sources, three licences, four scripts, and no API key anywhere.
Everything the pipeline reads is open and fetched by a script in the repository.
scripts/fetch_*.py
What the pipeline reads
ChEMBL
CC BY-SA 3.0
measured activity, one row per assay result
LOTUS
CC0
which plant a compound came from, with a DOI
GBIF backbone
CC BY-NC 4.0
a second read on which organisms are plants
The labelled table
joined on the InChIKey skeleton
one row per compound, per pathogen
Considered and turned down
NPASS
a copy of ChEMBL, and no licence published
COCONUT, ChEBI, WFO, taxtree
hosted wrappers whose certificate does not verify here
Wikidata SPARQL
the plant query times out on the public endpoint
DrugBank, KEGG, NAPRALERT
registration, restricted download, subscription
Four scripts, all resumable, about half an hour of downloading, and nothing committed to the repository. The licences travel with the output on both pages.
What we chose, and what we turned down
LOTUS `_frozen_metadata`
instead of The smaller `_frozen` export
It carries only InChIKeys, and a hash cannot be turned back into a structure to featurise.
ChEMBL alone for the labels
instead of ChEMBL and NPASS as two sources
NPASS is a copy: 40 of 40 sampled compounds were already in ChEMBL, 39 of 40 to three significant figures, and it publishes no licence.
Open data only
instead of DrugBank, KEGG, NAPRALERT
Registration, restricted bulk download, subscription. The repository is public, so anyone has to be able to get it.
- Writes
- data/raw/chembl_mic_*.parquet · data/raw/lotus/ · data/raw/gbif_organisms.parquet
- Code
- docs/data-sources.md
We measured the download before trusting it, and it changed the project.
This notebook cleans nothing, and one of its findings killed the original plan: the MIC data is measured against the whole bacterium, not a purified protein.
- 0199.6% of MIC rows are whole-organism, so a per-protein model has nothing to train on
- 02No MIC row carries a pChEMBL value, so the usual quality filter empties the dataset in silence
- 0321.6% of rows are `>` or `>=`: tested, inactive, and most of the negative class
- 0437 distinct unit strings, several of which are not concentrations at all
- 0568.6% of µg/mL values sit at or below 32, which is publication bias rather than chemistry
notebooks/01_data.ipynb
What the MIC rows are measured against
- Whole organism
- 99.6%
- A single protein
- 0.4%
How the values are reported
- An exact value
- 78.4%
- Censored: tested, inactive
- 21.6%
Where those values fall
- At or below 32 µg/mL
- 68.6%
- Above it
- 31.4%
And the units they are reported in
37 distinct unit strings in the download
Measured on 3 Nov from the real download. Notebook 01 writes figures rather than ledger metrics, so these counts are not read out of results/.
What we chose, and what we turned down
A whole-organism growth-inhibition readout
instead of One model per bacterial protein, grouped by mechanism
Only 35 single-protein targets carry any MIC at all, median 8 compounds. The per-target framing survives as one model per bacterium.
Cluster the chemistry and split the clusters
instead of Cluster protein sequences at 90% identity
With no protein in the pipeline there is no sequence to cluster. Split clusters, not rows, is what carries over.
- Writes
- data/processed/01_activities.parquet · 01_plant_occurrences.parquet · 01_plant_structures.parquet
- Code
- notebooks/pipeline/data.py
Twelve cuts, one configuration, and a negative class we had to keep rather than drop.
Every cleaning decision is a key in one CONFIG dict and a counted row in an audit table, so each cut can be argued with.
- 01Load the raw MIC rows for the organism
- 02Drop rows ChEMBL itself flags as suspect
- 03Drop rows whose comment says no measurement was made
- 04Drop rows measured against an isolated protein
- 05Drop rows with no structure
- 06Drop rows with no usable positive numeric value
- 07Drop rows whose structure does not parse in RDKit
- 08Convert every value to micromolar, through molecular weight where the unit is a mass
- 09Refuse the unit strings that are not concentrations
- 10Deduplicate by InChIKey skeleton, collapsing salts, stereoisomers and tautomers
- 11Collapse each compound's repeats to one label, by median
- 12Label against the threshold and write the table
notebooks/02_curation.ipynb
Every cut, counted
Staphylococcus aureus
−6,435
−96,330
49.2% active at 32 µM
−56,767
Escherichia coli
−5,072
−41,928
34.5% active at 32 µM
−42,225
Mycobacterium tuberculosis
−1,918
−22,794
54.8% active at 32 µM
−22,216
The units, now refused
37 unit strings · 18 refused: per disk, per area, a percentage, a velocity
Quoted from the run’s own configuration: censoring, > and >= at or above threshold are negatives, < and <= at or below are positives; repeats, median of a compound's measurements; key, inchikey_skeleton.
What we chose, and what we turned down
Keep `>` and `>=` rows as negatives
instead of Drop them as unusable
They are most of the negative class; dropping them leaves a model that cannot recognise inactivity.
The median of a compound's measurements
instead of Active wins over inactive
Around 27,000 compounds carry more than one measurement, and best-of lets one optimistic paper overrule many negative ones.
32 µM as the primary threshold
instead of A single stricter line at 10 µM
32 is the end of a doubling dilution series. 10 µM is reported alongside as a sensitivity check.
- Writes
- data/processed/02_labelled_<organism>.parquet
- Code
- notebooks/pipeline/curation.py, labels.py
Three descriptions of the same molecule, so that the choice between them is measured.
A fingerprint says which substructures are present; descriptors say how the whole molecule behaves.
- 01Morgan fingerprint, radius 2, 2048 bits
- 02Fourteen physicochemical descriptors, from logP to fraction of sp3 carbon
- 03Hybrid: the two side by side
- 04A Murcko scaffold per compound, which the next beat splits on
- 05Fingerprints stored packed, unpacked only through one checked helper
notebooks/03_features.ipynb
One compound, and the ring skeleton the next beat splits on
XLGPTVJCHRTFPZ
reported in Marchantia chenopoda
Murcko scaffold
the grouping key of the split
Three feature sets, by width
fingerprint and descriptors side by side
radius 2, hashed to bits
weight, logP, TPSA, donors, acceptors, and nine more
And the one each model was finally fitted on
Staphylococcus aureus
hybrid
Escherichia coli
hybrid
Mycobacterium tuberculosis
morgan
What we chose, and what we turned down
Build all three and let a cheap screen pick
instead of Pick the hybrid set on principle and tune it
It did not come out the same for all three pathogens.
Tree ensembles on 2,062 columns
instead of A learned molecular representation
Hackathon budget, no GPU. A pretrained encoder is the first thing to try next.
- Writes
- data/processed/03_features_<organism>.npz
- Code
- notebooks/pipeline/features.py, featureset.py
Split the chemistry, not the rows, then hold every plant compound out of training.
A random split of this data measures memorisation, so whole ring skeletons are held out and plant compounds kept out of training altogether.
- 01Group every compound by its Murcko scaffold
- 02Hold whole scaffolds out into validation and test
- 03Remove every plant-derived compound from training
- 04Measure what the split held back, against a random split
- 05Measure how far the held-out chemistry really sits
notebooks/03_features.ipynb
Where the compounds went
Staphylococcus aureus
Gram-positive
- Train
- 39,736
- Validation
- 8,515
- Test
- 8,516
- Plant holdout
- 1,424
Escherichia coli
Gram-negative
- Train
- 29,552
- Validation
- 6,337
- Test
- 6,336
- Plant holdout
- 874
Mycobacterium tuberculosis
Acid-fast
- Train
- 15,542
- Validation
- 3,337
- Test
- 3,337
- Plant holdout
- 691
Share of held-out compounds whose scaffold was already in training
How far the held-out chemistry really sits
Staphylococcus aureus
Gram-positive
Escherichia coli
Gram-negative
Mycobacterium tuberculosis
Acid-fast
Tanimoto to the nearest compound the model trained on, where 1.000 is an identical fingerprint. Holding out a ring system does not hold out the analogue beside it, which is why the test column of the next beat stays high.
What we chose, and what we turned down
Scaffold groups as the split key
instead of A random split
Three quarters of randomly held-out compounds share a scaffold with training.
Report nearest-neighbour distance as well as scaffold overlap
instead of Report scaffold overlap alone
Zero overlap is necessary and nowhere near sufficient: removing a ring system does not remove the analogue beside it.
A plant holdout on top of the scaffold split
instead of The plant-family split from the proposal
The family split did not replicate. Beat 10 is the measurement.
- Writes
- data/processed/03_splits_<organism>.parquet
- Code
- notebooks/pipeline/splits.py
Eighteen cheap fits per pathogen, then tune only the winner and freeze it.
Tuning everything would spend the budget deciding what a cheap experiment answers.
- 01Fit three feature sets by two model families by three seeds, untuned
- 02Tune the winning combination with Optuna, thirty trials against validation AUROC
- 03Weight the classes rather than resample, so the base rate stays readable
- 04Calibrate on validation with the forest frozen, so calibration cannot leak
- 05Pick the operating threshold by rule: most precision at recall 0.40 or better
- 06Score once on the scaffold test set and once on the plant holdout
notebooks/04_models.ipynb
Eighteen untuned fits per pathogen, and the combination that won
Staphylococcus aureus
Gram-positive
18 fits, untuned · 3 seeds of the combination that won: hybrid, random forest
Escherichia coli
Gram-negative
18 fits, untuned · 3 seeds of the combination that won: hybrid, random forest
Mycobacterium tuberculosis
Acid-fast
18 fits, untuned · 3 seeds of the combination that won: morgan, random forest
Then the winner is tuned, frozen and calibrated
30 Optuna trials per pathogen, against validation AUROC
- Sampler
- TPE
- not a grid search
- Calibration
- Platt
- on validation, forest frozen
- Threshold rule
- P|R≥0.40
- most precision at recall 0.40 or better
- Bootstrap
- 2,000
- stratified resamples per interval
What the search returned
Staphylococcus aureus
700 trees · leaf 1 · 0.109 of columns per split
operating threshold 0.905, precision 88% at recall 1.9%
Escherichia coli
700 trees · leaf 1 · 0.085 of columns per split
operating threshold 0.844, precision 100% at recall 10.1%
Mycobacterium tuberculosis
600 trees · leaf 2 · 0.060 of columns per split
operating threshold 0.861, precision 90% at recall 5.5%
What we chose, and what we turned down
Random forest, on all three pathogens
instead of XGBoost
Gradient boosting was in the screen on equal terms and did not come out ahead.
Tune on validation AUROC
instead of Tune on AUPRC
Both were tried at equal budget. AUPRC gave one inconsistent win in three.
CPU
instead of The Colab Pro GPU we had reserved
Tree ensembles do not use it; the whole pipeline runs on a laptop in about an hour.
- Writes
- data/processed/04_models_<organism>.pkl · 04_scores_<organism>_<set>.parquet
- Code
- notebooks/pipeline/models.py
The same model read three ways, and only the hardest reading answers our question.
On validation it looks strong, on held-out ring skeletons it holds up, and on plant compounds held out of training it falls away.
- 01AUROC at all three stations, each with a bootstrap interval
- 02Precision at the top of the ranking, against the base rate of the same set
- 03AUPRC as well as AUROC, because the class balance shifts between the three
- 04The applicability domain, derived from the most distant set we measured on
- 05A calibration check across the prevalence shift, which it does not survive
notebooks/05_results.ipynb
Staphylococcus aureus
plant holdout 95% interval 0.689–0.749
Escherichia coli
plant holdout 95% interval 0.609–0.750
Mycobacterium tuberculosis
plant holdout 95% interval 0.582–0.677
AUROC: the chance the model ranks a random active compound above a random inactive one, where 0.500 is a coin flip. No scaffold appears on both sides of the middle station, and the fall to the right is what a plant screen actually asks of this model.
The cutoff the hardest reading produced
- Domain cutoff
- 0.328
- the 25th percentile of the plant holdout, one line applied to all three pathogens
What we chose, and what we turned down
Publish all three readings and the fall between them
instead of Lead with the validation or the scaffold number
That fall is the most useful thing we measured.
A domain cutoff derived from the plant holdout
instead of A round number, or one chosen to keep the shortlist full
Past the most distant set we measured on, we have no evidence at all.
- Code
- notebooks/pipeline/models.py
Score every untested plant structure, then cut fifty that are not copies of each other.
The top fifty by score alone would be fifty variations on one molecule.
- 01Build the screening set: plant structures never measured against these three
- 02Featurise it with the same feature set the winning model was fitted on
- 03Score every compound with the calibrated model
- 04Take the top-scoring candidate pool
- 05Sample the shortlist for distance, by farthest-point sampling on fingerprints
- 06Carry each candidate's distance to the training set on its row
- 07Split in two tiers: in domain is the shortlist, outside it is exploratory
- 08Attach source plants, families, pathway and a reference DOI from LOTUS
notebooks/05_results.ipynb
From the screening set to two tiers, on a log track
−97,209
−450
−35
The result, read as the plate it came from
Staphylococcus aureus
median score 0.760, median distance 0.415, 9 biosynthetic pathways
Escherichia coli
median score 0.577, median distance 0.368, 8 biosynthetic pathways
Mycobacterium tuberculosis
median score 0.826, median distance 0.388, 7 biosynthetic pathways
One well per candidate. The liquid rises to that compound’s distance from known chemistry and clouds with its predicted score; the last fifteen are struck through because they fall outside the domain, and their scores are not evidence of activity.
What we chose, and what we turned down
Diversity sampling inside a scored pool
instead of The top fifty by score
Near-duplicates of one hit are one hypothesis tested fifty times.
Publish out-of-domain candidates in their own tier
instead of Filter them out silently
A filtered file hides the model's limit. A second tier states it.
- Writes
- results/shortlist_<organism>.csv · results/exploratory_<organism>.csv · results/exploratory_<organism>.README.txt
The result we set out to find did not replicate, so it ships as a negative.
The proposal's contribution was that holding out whole plant families is harder than holding out scaffolds; four runs later the direction is usually right and the size never settles.
- 01What replicates cleanly is random above scaffold, on all three pathogens
- 02Regularisation does not close the plant gap: the sweep falls monotonically
- 03Tuning for AUPRC instead of AUROC changed nothing at equal budget
- 04Calibration does not transport across the prevalence shift, and is reported anyway
- 05The top-ranked candidate is a near-copy of a training compound on all three plates
- 06Plant provenance is incomplete, and the per-compound SHAP column is numerically impossible
scripts/run_split_comparison.py
Four runs of the family split, as the gap each one measured
Left of the line, holding out plant families was harder than holding out scaffolds; right of it, easier. Across the four runs the repeat-to-repeat spread is wider than the effect, so it ships as a negative result rather than as the contribution.
And the defects we know are in the shortlist
The top-ranked candidate is a near-copy
0.986 / 0.982 / 0.976
nearest training compound, per pathogen
Candidates above 0.90
2 / 1 / 1
of fifty, per pathogen
No source plant on record
12 / 8 / 3
of fifty, per pathogen
Per-compound SHAP withheld
10^24
magnitudes a probability output cannot produce
What we chose, and what we turned down
Report the family split as a secondary analysis
instead of Report the first run as the finding
Two of the three estimates come from test sets of 103 and 80 compounds. One run that size is a number, not an effect.
List the shortlist's defects on the product page
instead of Fix what we could and ship the rest quietly
A reader who finds a near-duplicate at rank 1 unaided has reason to distrust everything else.
- Writes
- results/LEDGER.md · results/runs.jsonl
Every number traces to a configuration, including the ones on this page.
Each stage appends its own ledger entry: a hash of the configuration, the configuration itself, and every metric it produced.
- 01Each stage records its own entry in runs.jsonl and LEDGER.md
- 02Settings are environment variables with defaults, so a change is a sensitivity analysis
- 03Dependencies are pinned to the versions every number was produced with
- 04Curation and splits were re-run from the raw downloads on a second machine
- 05This interface is a static site that reads the committed results and retrains nothing
- 06The licences, the AI disclosure and the traditional-knowledge statement ship with it
results/, web/
Four stages, each stamped with the configuration that produced it
Curation
eab26d57be
threshold, censoring, dedup, collapse rule
Splits
26572ff90a
group key, fractions, seed
Model
a0881a0953
feature set, params, calibration, threshold rule
Shortlist
3b80425ba2
pool, size, diversity, domain cutoff
What stands behind every number on both pages
- Metrics reproduced
- 57
- curation and splits, on a second machine
- Data fingerprint
- 68ecd9738653
- the table the winning model was fitted on
- Retrained here
- none
- this page reads committed files only
And the obligations that ship with it
Licences
ChEMBL CC BY-SA 3.0 · LOTUS CC0 · GBIF CC BY-NC 4.0
carried on both pages, beside the data they cover
Generative AI
pipeline, interface, parts of the analysis
every number and claim is the team's own responsibility
Traditional knowledge
published literature only
where it sits behind a record, it belongs to the community that holds it
No health claim
a hypothesis for a bench
no efficacy, no dose, no recommendation, anywhere
- Code
- notebooks/pipeline/ledger.py, web/lib/data.ts
That is the whole pipeline.
The shortlists it produced are on the other page, read as the plate they came from: one well per compound, the liquid at its distance from known chemistry, the line across every well where our evidence stops.