EthnoHack 2026 · Track 2

The shortlists

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.

  1. 01A missed hit costs nothing visible
  2. 02A wasted synthesis costs weeks of bench time
  3. 03So the metric is precision at the head of the list
  4. 04And the result is a hypothesis for a bench, never a treatment claim

EthnoHack 2026, Track 2

Plant structures scored, per pathogen97,709
Top-scoring candidate pool500
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.

  1. 01Join activity to provenance on the InChIKey skeleton
  2. 02One model per pathogen, not one model for bacteria
  3. 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

  1. Escherichia coli

    43,099 measured · 874 from plants

    Gram-negative

  2. 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

  1. ChEMBL

    CC BY-SA 3.0

    measured activity, one row per assay result

  2. LOTUS

    CC0

    which plant a compound came from, with a DOI

  3. GBIF backbone

    CC BY-NC 4.0

    a second read on which organisms are plants

  4. The labelled table

    joined on the InChIKey skeleton

    one row per compound, per pathogen

Considered and turned down

  1. NPASS

    a copy of ChEMBL, and no licence published

  2. COCONUT, ChEBI, WFO, taxtree

    hosted wrappers whose certificate does not verify here

  3. Wikidata SPARQL

    the plant query times out on the public endpoint

  4. 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.

  1. 0199.6% of MIC rows are whole-organism, so a per-protein model has nothing to train on
  2. 02No MIC row carries a pChEMBL value, so the usual quality filter empties the dataset in silence
  3. 0321.6% of rows are `>` or `>=`: tested, inactive, and most of the negative class
  4. 0437 distinct unit strings, several of which are not concentrations at all
  5. 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.

  1. 01Load the raw MIC rows for the organism
  2. 02Drop rows ChEMBL itself flags as suspect
  3. 03Drop rows whose comment says no measurement was made
  4. 04Drop rows measured against an isolated protein
  5. 05Drop rows with no structure
  6. 06Drop rows with no usable positive numeric value
  7. 07Drop rows whose structure does not parse in RDKit
  8. 08Convert every value to micromolar, through molecular weight where the unit is a mass
  9. 09Refuse the unit strings that are not concentrations
  10. 10Deduplicate by InChIKey skeleton, collapsing salts, stereoisomers and tautomers
  11. 11Collapse each compound's repeats to one label, by median
  12. 12Label against the threshold and write the table

notebooks/02_curation.ipynb

Every cut, counted

Staphylococcus aureus

Raw MIC rows160,956

−6,435

Rows that survive154,521

−96,330

Compounds after dedup58,191

49.2% active at 32 µM

−56,767

Of those, from a plant1,424

Escherichia coli

Raw MIC rows90,099

−5,072

Rows that survive85,027

−41,928

Compounds after dedup43,099

34.5% active at 32 µM

−42,225

Of those, from a plant874

Mycobacterium tuberculosis

Raw MIC rows47,619

−1,918

Rows that survive45,701

−22,794

Compounds after dedup22,907

54.8% active at 32 µM

−22,216

Of those, from a plant691

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.

  1. 01Morgan fingerprint, radius 2, 2048 bits
  2. 02Fourteen physicochemical descriptors, from logP to fraction of sp3 carbon
  3. 03Hybrid: the two side by side
  4. 04A Murcko scaffold per compound, which the next beat splits on
  5. 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

Hybrid2,062

fingerprint and descriptors side by side

Morgan fingerprint2,048

radius 2, hashed to bits

Descriptors14

weight, logP, TPSA, donors, acceptors, and nine more

And the one each model was finally fitted on

  1. Staphylococcus aureus

    hybrid

  2. Escherichia coli

    hybrid

  3. 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.

  1. 01Group every compound by its Murcko scaffold
  2. 02Hold whole scaffolds out into validation and test
  3. 03Remove every plant-derived compound from training
  4. 04Measure what the split held back, against a random split
  5. 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

S. aureus, split at random0.757
E. coli, split at random0.773
M. tuberculosis, split at random0.786
S. aureus, split on scaffolds0.000
E. coli, split on scaffolds0.000
M. tuberculosis, split on scaffolds0.000

How far the held-out chemistry really sits

Staphylococcus aureus

Gram-positive

0.461 median plant compound0.680 median test compound

Escherichia coli

Gram-negative

0.437 median plant compound0.677 median test compound

Mycobacterium tuberculosis

Acid-fast

0.373 median plant compound0.677 median test compound

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.

  1. 01Fit three feature sets by two model families by three seeds, untuned
  2. 02Tune the winning combination with Optuna, thirty trials against validation AUROC
  3. 03Weight the classes rather than resample, so the base rate stays readable
  4. 04Calibrate on validation with the forest frozen, so calibration cannot leak
  5. 05Pick the operating threshold by rule: most precision at recall 0.40 or better
  6. 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

  1. Staphylococcus aureus

    700 trees · leaf 1 · 0.109 of columns per split

    operating threshold 0.905, precision 88% at recall 1.9%

  2. Escherichia coli

    700 trees · leaf 1 · 0.085 of columns per split

    operating threshold 0.844, precision 100% at recall 10.1%

  3. 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.

  1. 01AUROC at all three stations, each with a bootstrap interval
  2. 02Precision at the top of the ranking, against the base rate of the same set
  3. 03AUPRC as well as AUROC, because the class balance shifts between the three
  4. 04The applicability domain, derived from the most distant set we measured on
  5. 05A calibration check across the prevalence shift, which it does not survive

notebooks/05_results.ipynb

Staphylococcus aureus

0.8800.8630.719
validationscaffold testplant holdout

plant holdout 95% interval 0.689–0.749

Escherichia coli

0.8820.9030.682
validationscaffold testplant holdout

plant holdout 95% interval 0.609–0.750

Mycobacterium tuberculosis

0.8460.8150.629
validationscaffold testplant holdout

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.

  1. 01Build the screening set: plant structures never measured against these three
  2. 02Featurise it with the same feature set the winning model was fitted on
  3. 03Score every compound with the calibrated model
  4. 04Take the top-scoring candidate pool
  5. 05Sample the shortlist for distance, by farthest-point sampling on fingerprints
  6. 06Carry each candidate's distance to the training set on its row
  7. 07Split in two tiers: in domain is the shortlist, outside it is exploratory
  8. 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

Plant structures scored97,709

−97,209

Top-scoring candidate pool500

−450

Shortlist, in domain50

−35

Exploratory, outside the domain15

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.

  1. 01What replicates cleanly is random above scaffold, on all three pathogens
  2. 02Regularisation does not close the plant gap: the sweep falls monotonically
  3. 03Tuning for AUPRC instead of AUROC changed nothing at equal budget
  4. 04Calibration does not transport across the prevalence shift, and is reported anyway
  5. 05The top-ranked candidate is a near-copy of a training compound on all three plates
  6. 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

no effect-0.130 S. aureus, n=762, NPASS-0.073 S. aureus, n=823, ChEMBL+0.004 E. coli, n=486, ChEMBL-0.097 M. tuberculosis, n=400, ChEMBL

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

  1. The top-ranked candidate is a near-copy

    0.986 / 0.982 / 0.976

    nearest training compound, per pathogen

  2. Candidates above 0.90

    2 / 1 / 1

    of fifty, per pathogen

  3. No source plant on record

    12 / 8 / 3

    of fifty, per pathogen

  4. 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.

  1. 01Each stage records its own entry in runs.jsonl and LEDGER.md
  2. 02Settings are environment variables with defaults, so a change is a sensitivity analysis
  3. 03Dependencies are pinned to the versions every number was produced with
  4. 04Curation and splits were re-run from the raw downloads on a second machine
  5. 05This interface is a static site that reads the committed results and retrains nothing
  6. 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

  1. Curation

    eab26d57be

    threshold, censoring, dedup, collapse rule

  2. Splits

    26572ff90a

    group key, fractions, seed

  3. Model

    a0881a0953

    feature set, params, calibration, threshold rule

  4. 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

  1. Licences

    ChEMBL CC BY-SA 3.0 · LOTUS CC0 · GBIF CC BY-NC 4.0

    carried on both pages, beside the data they cover

  2. Generative AI

    pipeline, interface, parts of the analysis

    every number and claim is the team's own responsibility

  3. Traditional knowledge

    published literature only

    where it sits behind a record, it belongs to the community that holds it

  4. 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.