2. Scoring#
What this block is. A product goes in, a number comes out. The sampler only ever sees that number; it never knows whether it came from a docking run, a lookup table or a line of Python. You describe the scorer with a small config object, and the sampler builds (and, in parallel runs, rebuilds) the evaluator from it.
Core#
The quickest scorer is a table of precomputed values keyed by product name. The bundled dataset ships one — docking scores for all 500 k thrombin amides:
from TACTICS.thompson_sampling import LookupEvaluatorConfig
evaluator = LookupEvaluatorConfig(
ref_filename=str(data / "product_scores.parquet"), # .csv or .parquet
compound_col="Product_Code", # default
score_col="Scores", # default
)
Two decisions travel with every scorer:
Direction.
mode="minimize"for docking scores and free energies (lower is better);mode="maximize"for probabilities, similarities, read counts. It is set on the strategy — viaget_preset(mode=...)orTopTwoConfig(mode=...)— because it is the search that needs to know.What “missing” means. A product the table does not contain scores
NaNand is skipped. For sparse DEL-style tables where absence means not a binder, setdefault_score=0.0so absence is a real observation.
What it produces: an *EvaluatorConfig that you pass as
evaluator_config= in Block 3.
Build on it#
Your own Python function#
Most real screens score with code you already have. Wrap any callable that
takes an RDKit Mol and returns a float:
from rdkit.Chem import Descriptors, QED
def drug_likeness(mol) -> float:
"""Any callable taking an RDKit Mol and returning a float will do."""
return QED.qed(mol) - 0.01 * max(0.0, Descriptors.MolWt(mol) - 500)
from TACTICS.thompson_sampling import CustomEvaluatorConfig
config = get_preset(
synthesis_pipeline=pipeline,
evaluator_config=CustomEvaluatorConfig(scoring_function=drug_likeness),
mode="maximize",
num_iterations=10,
batch_size=20,
)
sampler = ThompsonSampler.from_config(config)
sampler.warm_up(num_warmup_trials=config.num_warmup_trials)
results = sampler.search(num_cycles=config.num_ts_iterations)
sampler.close()
print(results.sort("score", descending=True).head(3))
Scores are cached by canonical SMILES; an exception inside your function
yields NaN for that product and the run continues. Two rules:
Define the function at module level (not a lambda, not inside another function). With
processes > 1each worker rebuilds the evaluator from the config, which means pickling a reference to your function.Expensive setup (loading a model, reading a receptor) belongs in a module-level cache, not in the function body — it runs once per product.
The other built-in scorers#
Config |
Scores |
Cost |
Needs |
|---|---|---|---|
value from a CSV/Parquet table, by product name |
instant |
the table |
|
value from a |
instant |
the |
|
your function |
yours |
a picklable callable |
|
Morgan-fingerprint Tanimoto to a query SMILES |
fast |
a query SMILES |
|
molecular weight (a smoke test) |
fast |
nothing |
|
positive-class probability from a pickled scikit-learn model on a Morgan FP |
fast |
the |
|
shape + colour overlay to a 3D query |
slow |
|
|
FRED docking score into an |
slow |
|
The two lookup scorers key on the product name and skip synthesis
altogether, so SMILES in the results reads FAIL for them; the others
need the structure and populate it. For the slow ones, see 4. Scale.
Using an evaluator without a config#
Every config has a class behind it (LookupEvaluatorConfig →
LookupEvaluator) that
takes the same keys as a dict. Construct one directly when driving the
sampler by hand (see 3. Search) or when
you just want to score a molecule:
from TACTICS import FPEvaluator
from rdkit import Chem
scorer = FPEvaluator({"query_smiles": "CC(=O)Nc1ccc(O)cc1"})
scorer.evaluate(Chem.MolFromSmiles("CC(=O)Nc1ccc(OC)cc1")) # 0.593
Gotchas
Direction lives on the strategy, not the evaluator. A docking table with the default
mode="maximize"will happily find you the worst binders.Lookup tables are keyed by exact product name —
<acid>_<amine>built from the reagent-file names. Rename a reagent and every score for it goes missing (→NaN, skipped; ordefault_score).A lambda or nested function fails with
processes > 1. Module level only.``FPEvaluatorConfig`` has no radius/bits knobs — it is fixed at radius 2, 2048 bits. Write a
CustomEvaluatorConfigfor anything else.Passing a key the config does not have is silently ignored for evaluator configs (they are permissive); check the field list in the reference.
Reference#
Scoring — every config and evaluator class with its keys.
Interactive: marimo edit tutorials/custom_evaluator_tester.py lets you
paste a scoring function and run it against a live search.
Next: 3. Search — put the library and the scorer together and run.