4. Scale#

What this block is. The search loop spends almost all its time in the scorer. When the scorer is slow — docking, shape overlay, a model that takes a second per molecule — this block spreads that cost over worker processes, or skips the synthesis step by handing the sampler pre-enumerated products. Nothing else about the run changes.

Core#

Two fields on the config control parallel scoring:

from TACTICS import ThompsonSampler, get_preset
from TACTICS.thompson_sampling import FPEvaluatorConfig

config = get_preset(
    synthesis_pipeline=pipeline,
    # similarity to a query needs the product structure, so every score
    # costs a synthesis + fingerprint: worth parallelising
    evaluator_config=FPEvaluatorConfig(query_smiles="CC(=O)Nc1ccc(O)cc1"),
    mode="maximize",
    num_iterations=6,
    batch_size=40,
)
config.processes = 2            # worker processes
config.min_cpds_per_core = 10   # evaluate once 2 x 10 compounds have accumulated

sampler = ThompsonSampler.from_config(config)   # workers rebuild the evaluator from its config
sampler.warm_up(num_warmup_trials=2)
results = sampler.search(num_cycles=config.num_ts_iterations)
sampler.close()                 # shuts the worker pool down
  • processes — worker processes for evaluation. Selection, posterior updates and synthesis bookkeeping stay in the parent.

  • min_cpds_per_core — the sampler accumulates processes × min_cpds_per_core products before dispatching a batch to the pool (and flushes whatever is left on the last cycle). Bigger batches, fewer round-trips; the default 10 is fine.

How workers get their scorer. Evaluators such as FRED and ROCS hold C++ objects that cannot be pickled, so the evaluator itself never crosses the process boundary. Each worker is started once with the config and builds its own evaluator from it. That is why ThompsonSampler.from_config is the parallel-safe path: it hands the config through. It is also why a CustomEvaluatorConfig function must be importable by name (module level).

When it pays. Only when a single score costs more than the ~ms it takes to pickle a reagent tuple and collect the result. For LookupEvaluator and DBEvaluator the sampler skips synthesis entirely and the lookup is microseconds; processes > 1 there is slower, and the sampler logs a warning saying so.

What it produces: the same results DataFrame as Block 3. close() now matters — it shuts the pool down.

Build on it#

Pre-enumerated products#

If the library has already been enumerated (Block 1’s enumerate_librarywrite_enumerated_library(..., format="csv"), or another tool), give the sampler the file and it will look products up instead of running the reaction:

config.product_library_file = "library.csv"   # columns: Product_Code, SMILES

The lookup is by product name; a miss falls back to synthesis, so a partial file is fine. This removes the RDKit reaction cost from every evaluation — worth it for structure-based scorers on large libraries, irrelevant for lookup scorers (which never synthesise anyway).

Budgeting a run#

  • sampler.search(num_cycles, max_evaluations=N) stops after N scored products even if cycles remain — the natural knob when the budget is a number of docking runs.

  • Warmup is separate and not capped: Enhanced warmup costs max(component sizes) × num_warmup_trials evaluations before the search starts. On 3,844 amines × 5 trials that is ~19 k docking runs; drop num_warmup_trials to 2–3 for expensive scorers, or use Balanced warmup (sum(sizes) × K) when one component is very large.

  • batch_size × num_ts_iterations is the search budget. With processes = 32 and min_cpds_per_core = 10, a batch_size of 320 or more keeps every worker busy each cycle.

On a cluster#

  • Set config.processes to the cores you were allocated (e.g. from SLURM_CPUS_PER_TASK); the pool is a plain multiprocessing pool.

  • config.hide_progress = True keeps tqdm out of the job log; get_preset(..., output_dir=...) or config.log_filename writes the run log to a file.

  • Write results yourself at the end (results.write_parquet(...)); the sampler holds them in memory only.

  • OpenEye licences are checked per process — make sure workers can see OE_LICENSE.

  • Put the run in if __name__ == "__main__":. With the spawn start method (macOS, Windows) each worker re-imports the script; an unguarded script starts workers recursively.

Gotchas

  • Direct construction and processes > 1: call sampler.set_evaluator(evaluator, evaluator_config=cfg) — without the config, workers have nothing to rebuild from, and an unpicklable evaluator raises a TypeError that says so.

  • A lambda scoring function fails with processes > 1 even though it works with processes = 1.

  • Parallelism does not change the search. Batches are dispatched in evaluation order and posteriors update once per batch, so seed still reproduces the run — but posteriors update less often than with batch_size = 1, which is the trade you make for throughput.

  • processes counts evaluation workers; RDKit reaction enumeration for enumerate_library has its own n_jobs.

Reference#

ScaleParallelEvaluator; the processes, min_cpds_per_core and product_library_file fields of ThompsonSamplingConfig; set_evaluator().

Next: 5. Inspect — what the search learned.