Find the best compounds in a combinatorial library without enumerating all of them.
TACTICS (Thompson Sampling-Assisted Chemical Targeting and Iterative Compound Selection) uses Thompson Sampling to search ultra-large combinatorial libraries, scoring only a small fraction of the products while still recovering most of the top hits. A typical run evaluates 1–2% of the library and recovers ~85–95% of the true top-100.
📖 Documentation · 🧱 Guides: Library → Scoring → Search → Scale → Inspect → Visualise · 🧪 Tutorials · 📋 Changelog
TACTICS fits if you have:
- A combinatorial library defined by a reaction plus lists of reagents (2 or 3 components), and
- A scoring function that is too slow to run on every product — docking, shape similarity, or an ML model — or a big table of precomputed scores.
If your library is small enough to score exhaustively, you don't need TACTICS.
pip install chem-tacticsRequires Python 3.11+.
Optional extras and development install
pip install chem-tactics[viz] # matplotlib/altair plotting (TS_Benchmarks, diagnostic_plots)
pip install chem-tactics[tutorials] # interactive marimo notebooks (includes viz)
pip install chem-tactics[test] # test dependencies
# development install
git clone https://github.com/aakankschit/TACTICS.git
cd TACTICS
pip install -e ".[test]"Screen the bundled thrombin library (130 acids × 3,844 amines) against its
precomputed docking scores — this runs as-is after pip install chem-tactics:
from TACTICS import ThompsonSampler, get_preset
from TACTICS.library_enumeration import SynthesisPipeline, ReactionConfig, ReactionDef
from TACTICS.thompson_sampling import LookupEvaluatorConfig
data = files("TACTICS.data.thrombin") # bundled example: 130 acids x 3844 amines
# 1. Describe the library: one reaction, one reagent file per component
pipeline = SynthesisPipeline(ReactionConfig(
reactions=[ReactionDef(
reaction_smarts="[#6:1](=[O:2])[OH].[#7X3;H1,H2;!$(N[!#6]);!$(N[#6]=[O]):3]"
">>[#6:1](=[O:2])[#7:3]",
step_index=0,
)],
reagent_file_list=[str(data / "acids.smi"), str(data / "coupled_aa_sub.smi")],
))
# 2. Describe how a product is scored (here: a precomputed docking table)
evaluator = LookupEvaluatorConfig(ref_filename=str(data / "product_scores.parquet"))
# 3. Take the tuned preset, run, and read the results
config = get_preset(
synthesis_pipeline=pipeline,
evaluator_config=evaluator,
mode="minimize", # docking scores: lower is better
num_iterations=20, # cycles; 1000+ for a real screen
batch_size=50, # compounds per cycle
)
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").head(5))results is a Polars DataFrame of every product that was evaluated, with
columns score, SMILES and Name.
With
LookupEvaluatorandDBEvaluator, TACTICS skips molecule generation entirely and scores by product code, soSMILESreadsFAILand products are identified byName(the underscore-joined reagent names). Evaluators that need a molecule — docking, ROCS, fingerprints — populateSMILES.
Docking and ROCS take seconds per molecule, so evaluation dominates runtime.
Set processes to the number of cores you have — each worker builds its own
docking session, so OpenEye evaluators parallelize correctly:
from TACTICS.thompson_sampling.core.evaluator_config import FredEvaluatorConfig
config = get_preset(
synthesis_pipeline=pipeline,
evaluator_config=FredEvaluatorConfig(design_unit_file="receptor.oedu"),
mode="minimize", # docking scores: lower is better
batch_size=100,
)
config.processes = 32 # or os.cpu_count()
sampler = ThompsonSampler.from_config(config)Leave
processes=1forLookupEvaluatorandDBEvaluator. Their lookups are faster than the cost of shipping work to a worker, so parallelism makes those runs slower.
On macOS/Windows, guard your entry point with
if __name__ == "__main__":— the defaultspawnstart method re-imports your script in every worker. On a cluster, make sure the.oedureceptor is readable from every node (automatic on NFS/GPFS).
Scoring functions (evaluator_config):
| Evaluator | Use it for | Speed |
|---|---|---|
LookupEvaluatorConfig |
A CSV/Parquet table of precomputed scores | instant |
CustomEvaluatorConfig |
Any Python function Mol -> float |
yours |
DBEvaluatorConfig |
Scores in a SQLite database | instant |
FPEvaluatorConfig |
Fingerprint similarity to a reference ligand | fast |
ROCSEvaluatorConfig |
3D shape/colour overlay (OpenEye) | slow |
FredEvaluatorConfig |
Docking into a receptor (OpenEye) | slow |
MLClassifierEvaluatorConfig |
A trained scikit-learn model | varies |
Presets (get_preset(name, ...)):
| Preset | What it does |
|---|---|
recommended (default) |
Top-Two Thompson Sampling — best overall |
recommended_rws |
Roulette wheel with criticality-aware thermal cycling |
baseline |
Balanced warmup + greedy, for measuring what the search adds |
Start with recommended. If you are benchmarking, run recommended and
recommended_rws and take the better result — they favour different library
structures.
Building a config by hand instead of using a preset
Presets are just ThompsonSamplingConfig objects, so you can construct one
directly to control the strategy, warmup and search budget:
from TACTICS.thompson_sampling import ThompsonSamplingConfig, ThompsonSampler
from TACTICS.thompson_sampling.strategies.config import TopTwoConfig
from TACTICS.thompson_sampling.warmup.config import EnhancedWarmupConfig
config = ThompsonSamplingConfig(
synthesis_pipeline=pipeline,
evaluator_config=evaluator,
strategy_config=TopTwoConfig(mode="maximize", beta=0.5),
warmup_config=EnhancedWarmupConfig(),
num_warmup_trials=3,
num_ts_iterations=1000,
batch_size=100,
processes=1,
seed=42,
)
sampler = ThompsonSampler.from_config(config)Configs are Pydantic models, so invalid values raise ValidationError at
construction rather than failing mid-run. Other selection strategies —
GreedyConfig, RouletteWheelConfig, UCBConfig, EpsilonGreedyConfig,
BayesUCBConfig — are available for baselines and comparisons.
See the Search guide and the reference for the full reference.
Interactive marimo notebooks in tutorials/ (they default
to a bundled thrombin dataset, so they run with no setup):
marimo run tutorials/thompson_sampling_tutorial.py # compare strategies
marimo edit tutorials/library_enumeration_tutorial.py # build a library| Tutorial | What it covers |
|---|---|
thompson_sampling_tutorial.py |
Compare strategies and warmups; recovery charts |
library_enumeration_tutorial.py |
SynthesisPipeline: single-step, multi-step, alternative SMARTS |
reaction_config_builder.py |
Build and validate a ReactionConfig interactively |
custom_evaluator_tester.py |
Paste a scoring function and run it |
diagnostic_benchmark_plots.py |
Mechanism plots over the diagnostic benchmark output |
interactive_sar_explorer.py |
Hover-to-structure SAR explorer (needs data/scores) |
manuscript_plots_ROCS.py |
Manuscript figures, ROCS libraries |
manuscript_plots_docking.py |
Manuscript figures, docking libraries |
manuscript_sar_plots.py |
Manuscript figures, reagent score landscapes |
- Guides (one per building block): https://aakankschit.github.io/TACTICS/guides/01_library.html
- API reference: https://aakankschit.github.io/TACTICS/reference/
- Theory: https://aakankschit.github.io/TACTICS/theory/
- Runnable scripts: see
examples/ - Build docs locally:
cd docs && make html
Fork, branch, add tests for new behaviour, make sure pytest tests/ passes,
then open a pull request.
@software{tactics,
title={TACTICS: Thompson Sampling-Assisted Chemical Targeting and
Iterative Compound Selection for Drug Discovery},
author={Aakankschit Nandkeolyar},
year={2025},
url={https://github.com/aakankschit/TACTICS}
}Open an issue on GitHub, or contact anandkeo@uci.edu.
Licensed under the MIT License — see LICENSE.
This work is based on previous work by Patrick Walters. This project is a collaboration between the University of California Irvine, Leiden University and Groningen University.
