# In Google Colab, install SMSD Pro first; elsewhere this cell does nothing.
import importlib.util, subprocess, sys
if importlib.util.find_spec("smsd") is None:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "smsd==8.1.0", "rdkit<2027"], check=True)
Screening a library end to end¶
This notebook uses SMSD 8.1.0 to screen a small library five ways: a fingerprint screen held in memory, a saved packed index, a streamed screen of a SMILES file, an exact substructure screen and an MCS-similarity screen. It then clusters the index and saves the hits to an SD file. The library is built inside the notebook: 14 benzodiazepines, 7 common drugs and 512 generated trisubstituted benzenes. The files it writes go to a temporary directory that the last cell removes.
NumPy is needed only for the packed index and the clustering cells, and pandas only to print one table; cells that need a missing package say so and carry on. Select a kernel with SMSD 8.1.0 or later installed. This notebook has no upload cell: to run it in Colab, first copy the setup cell of feature_search.ipynb into a new first cell and run it, as the setup section of that notebook describes.
import heapq
import itertools
import json
import shutil
import tempfile
from pathlib import Path
import smsd
def version_tuple(text):
"""The first three numeric parts of a version string."""
parts = []
for piece in text.split(".")[:3]:
digits = "".join(itertools.takewhile(str.isdigit, piece))
parts.append(int(digits or 0))
return tuple(parts + [0] * (3 - len(parts)))
if version_tuple(smsd.__version__) < (8, 1, 0):
raise RuntimeError("This notebook needs SMSD 8.1.0 or later")
print("Using SMSD", smsd.__version__)
try:
import numpy
except ImportError:
numpy = None
print("NumPy is not installed: the packed-index and clustering cells are "
"skipped. Install smsd[numpy] to run them.")
try:
import pandas
except ImportError:
pandas = None
print("pandas is not installed: the MCS table is printed as plain text.")
THREADS = 2 # 0 uses every core that OMP_NUM_THREADS allows
workdir = Path(tempfile.mkdtemp(prefix="smsd-screening-"))
Using SMSD 8.1.0
# The shared example library: named drugs, then generated benzenes.
NAMED = {
"diazepam": "CN1C(=O)CN=C(c2ccccc2)c2cc(Cl)ccc21",
"nordazepam": "O=C1CN=C(c2ccccc2)c2cc(Cl)ccc2N1",
"temazepam": "CN1C(=O)C(O)N=C(c2ccccc2)c2cc(Cl)ccc21",
"oxazepam": "OC1N=C(c2ccccc2)c2cc(Cl)ccc2NC1=O",
"lorazepam": "OC1N=C(c2ccccc2Cl)c2cc(Cl)ccc2NC1=O",
"clonazepam": "O=C1CN=C(c2ccccc2Cl)c2cc([N+](=O)[O-])ccc2N1",
"nitrazepam": "O=C1CN=C(c2ccccc2)c2cc([N+](=O)[O-])ccc2N1",
"flunitrazepam": "CN1C(=O)CN=C(c2ccccc2F)c2cc([N+](=O)[O-])ccc21",
"bromazepam": "O=C1CN=C(c2ccccn2)c2cc(Br)ccc2N1",
"tetrazepam": "CN1C(=O)CN=C(C2=CCCCC2)c2cc(Cl)ccc21",
"alprazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1)=NC2",
"triazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1Cl)=NC2",
"midazolam": "Cc1ncc2n1-c1ccc(Cl)cc1C(c1ccccc1F)=NC2",
"clobazam": "CN1C(=O)CC(=O)N(c2ccccc2)c2cc(Cl)ccc21",
"aspirin": "CC(=O)Oc1ccccc1C(=O)O",
"salicylic acid": "OC(=O)c1ccccc1O",
"paracetamol": "CC(=O)Nc1ccc(O)cc1",
"ibuprofen": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
"naproxen": "COc1ccc2cc(ccc2c1)C(C)C(=O)O",
"caffeine": "CN1C=NC2=C1C(=O)N(C(=O)N2C)C",
"phenol": "Oc1ccccc1",
}
GROUPS = ["C", "CC", "OC", "N", "O", "F", "Cl", "C(=O)O"]
library = list(NAMED.items()) + [
(f"GEN-{number:03d}", f"c1c({a})cc({b})cc1{c}")
for number, (a, b, c) in enumerate(itertools.product(GROUPS, repeat=3))
]
names = [name for name, _ in library]
smiles = [text for _, text in library]
assert len(library) == 533
graphs = []
for name, text in library:
graph = smsd.parse_smiles(text)
graph.name = name
graph.properties = {"SOURCE": "named" if name in NAMED else "generated"}
graphs.append(graph)
sdf_path = workdir / "library.sdf"
smi_path = workdir / "library.smi"
smsd.write_sdf(graphs, sdf_path)
smi_path.write_text("".join(f"{text} {name}\n" for name, text in library),
encoding="utf-8")
print("Wrote", len(graphs), "molecules to library.sdf and library.smi")
Wrote 533 molecules to library.sdf and library.smi
# Malformed SD records come back as empty graphs, so keep graphs with atoms.
records = [graph for graph in smsd.read_sdf(sdf_path) if graph.n > 0]
sources = [graph.properties["SOURCE"] for graph in records]
def smiles_column(path):
"""Yield the first column of a SMILES file, one line at a time."""
with open(path, encoding="utf-8") as stream:
for line in stream:
if line.strip():
yield line.split()[0]
file_smiles = list(smiles_column(smi_path))
print("SD records with atoms:", len(records),
"| SMILES lines:", len(file_smiles))
print("Named:", sources.count("named"),
"| generated:", sources.count("generated"))
assert [graph.name for graph in records] == names
assert file_smiles == smiles
SD records with atoms: 533 | SMILES lines: 533 Named: 21 | generated: 512
Similarity screens¶
A TargetCorpus keeps the parsed library in memory. prewarm() computes an
ECFP radius 2, 2048-bit fingerprint for every target, and screen returns the
indices of the targets whose Tanimoto score is at least the threshold, in
library order. A screen run before prewarm(), or after targets are added,
returns an empty list rather than raising.
The packed index stores the same kind of fingerprints in a file that a later session can reopen. The file holds bits only, so a small JSON sidecar keeps the fingerprint specification and the names. The streamed screen reads the SMILES file one chunk at a time. All three give the same hits here.
DIAZEPAM = NAMED["diazepam"]
ALPRAZOLAM = NAMED["alprazolam"]
corpus = smsd.TargetCorpus.from_smiles(records)
assert corpus.screen(DIAZEPAM, 0.5, num_threads=THREADS) == []
print("Before prewarm(): no hits, and no error")
corpus.prewarm(num_threads=THREADS)
similar = corpus.screen(DIAZEPAM, 0.5, num_threads=THREADS)
print("Diazepam, at least 0.5:", [names[i] for i in similar])
per_query = corpus.screen_many([DIAZEPAM, ALPRAZOLAM], 0.5,
num_threads=THREADS)
print("Alprazolam, at least 0.5:", [names[i] for i in per_query[1]])
assert per_query[0] == similar
Before prewarm(): no hits, and no error Diazepam, at least 0.5: ['diazepam', 'nordazepam', 'temazepam', 'tetrazepam', 'clobazam'] Alprazolam, at least 0.5: ['alprazolam', 'triazolam', 'midazolam']
index_path = workdir / "library.fp"
if numpy is None:
print("Skipped: the packed index needs NumPy.")
else:
options = smsd.FingerprintOptions.ecfp(radius=2, size=2048)
smsd.FingerprintCorpus.from_smiles(index_path, records, options=options,
num_threads=THREADS).close()
(workdir / "library.json").write_text(json.dumps(
{"family": "ecfp", "radius": 2, "size": 2048, "names": names}),
encoding="utf-8")
# A later session: rebuild the options from the sidecar, then reopen.
spec = json.loads((workdir / "library.json").read_text(encoding="utf-8"))
query_options = smsd.FingerprintOptions.ecfp(radius=spec["radius"],
size=spec["size"])
query = smsd.fingerprint(DIAZEPAM, options=query_options)
with smsd.FingerprintCorpus(index_path) as index:
packed_hits = list(index.similarities(query, threshold=0.5))
top_five = heapq.nlargest(5, index.similarities(query),
key=lambda hit: (hit[1], -hit[0]))
print("Index:", len(index), "rows of", index.fp_size, "bits")
print("At least 0.5:",
[(spec["names"][row], round(score, 3)) for row, score in packed_hits])
print("Five most similar:")
for row, score in top_five:
print(f" {spec['names'][row]:<12} {score:.3f}")
assert [row for row, _ in packed_hits] == similar
Index: 533 rows of 2048 bits
At least 0.5: [('diazepam', 1.0), ('nordazepam', 0.528), ('temazepam', 0.582), ('tetrazepam', 0.542), ('clobazam', 0.5)]
Five most similar:
diazepam 1.000
temazepam 0.582
tetrazepam 0.542
nordazepam 0.528
clobazam 0.500
streamed = list(smsd.stream_screen_smiles(
DIAZEPAM, smiles_column(smi_path), 0.5, chunk_size=100,
num_threads=THREADS))
print("Streamed, at least 0.5:", [names[i] for i in streamed])
assert streamed == similar
Streamed, at least 0.5: ['diazepam', 'nordazepam', 'temazepam', 'tetrazepam', 'clobazam']
Substructure and MCS-similarity screens¶
Substructure.match_corpus rules out targets that cannot contain the query
before matching, with a proof, and searches every other target exactly. Each
decision says whether the query was found, proved absent or left unknown, for
example by a search that stopped at its time limit. The default FLEXIBLE
aromaticity lets an aromatic query bond match a single or double bond, so
tetrazepam, with a cyclohexene ring in place of the phenyl ring, contains the
core; STRICT aromaticity drops it.
screen_targets computes a fast upper bound on the atom-count MCS Tanimoto,
common / (query atoms + target atoms - common), and never drops a target that
can reach the threshold under the same chemistry options. The exact MCS then
runs on the survivors only.
CORE = "O=C1CN=C(c2ccccc2)c2ccccc2N1" # benzodiazepinone core
core = smsd.Substructure.find(CORE)
strict_options = smsd.Substructure.Options()
strict_options.chemistry = smsd.ChemOptions().with_aromaticity_mode(
smsd.AromaticityMode.STRICT)
searches = (("Default chemistry", core),
("STRICT aromaticity", core.with_options(strict_options)))
for label, search in searches:
decisions = search.match_corpus(corpus, num_threads=THREADS)
found = [names[d.target_index] for d in decisions if d.threshold_reached]
ruled_out = sum(d.result is None for d in decisions)
unknown = sum(not d.threshold_reached and not d.proven_below_threshold
for d in decisions)
print(f"{label}: {len(found)} contain the core, {ruled_out} ruled out "
f"before matching, {unknown} unknown")
print(" ", found)
if label == "Default chemistry":
with_core = [d.target_index for d in decisions if d.threshold_reached]
Default chemistry: 9 contain the core, 523 ruled out before matching, 0 unknown ['diazepam', 'nordazepam', 'temazepam', 'oxazepam', 'lorazepam', 'clonazepam', 'nitrazepam', 'flunitrazepam', 'tetrazepam'] STRICT aromaticity: 8 contain the core, 523 ruled out before matching, 0 unknown ['diazepam', 'nordazepam', 'temazepam', 'oxazepam', 'lorazepam', 'clonazepam', 'nitrazepam', 'flunitrazepam']
query_graph = smsd.parse_smiles(DIAZEPAM)
candidates = smsd.screen_targets(query_graph, corpus, 0.8,
num_threads=THREADS)
results = smsd.MCS.find(query_graph).with_timeout(1000).match_many(
[records[i] for i in candidates], num_threads=THREADS)
rows = []
for index, result in zip(candidates, results):
common = result.best.atom_count if result.found else 0
score = common / (query_graph.n + records[index].n - common)
rows.append({"name": names[index], "common atoms": common,
"MCS Tanimoto": round(score, 3), "optimal": result.optimal,
"kept": result.optimal and score >= 0.8})
print("The bound kept", len(candidates), "of", len(records), "targets")
if pandas is not None:
print(pandas.DataFrame(rows).to_string(index=False))
else:
for row in rows:
print(row)
mcs_hits = [row["name"] for row in rows if row["kept"]]
print("At least 0.8 after the exact MCS:", mcs_hits)
The bound kept 9 of 533 targets
name common atoms MCS Tanimoto optimal kept
diazepam 20 1.000 True True
nordazepam 19 0.950 True True
temazepam 20 0.952 True True
oxazepam 19 0.905 True True
lorazepam 19 0.864 True True
clonazepam 18 0.750 True False
tetrazepam 20 1.000 True True
alprazolam 19 0.826 True True
clobazam 12 0.414 True False
At least 0.8 after the exact MCS: ['diazepam', 'nordazepam', 'temazepam', 'oxazepam', 'lorazepam', 'tetrazepam', 'alprazolam']
if numpy is None:
print("Skipped: clustering needs NumPy.")
else:
with smsd.FingerprintCorpus(index_path) as index:
clusters = smsd.butina(index, threshold=0.6)
diverse = smsd.maxmin(index, 5)
largest = max(clusters, key=len)
print("Butina clusters at 0.6:", len(clusters))
print("Largest cluster:", len(largest), "members, centre",
names[largest[0]])
print(" first members:", [names[row] for row in largest[:6]])
print("MaxMin selection of 5:", [names[row] for row in diverse])
Butina clusters at 0.6: 53 Largest cluster: 48 members, centre GEN-031 first members: ['GEN-031', 'GEN-007', 'GEN-047', 'GEN-055', 'GEN-056', 'GEN-059'] MaxMin selection of 5: ['diazepam', 'GEN-148', 'caffeine', 'GEN-175', 'paracetamol']
# TargetCorpus.screen gives indices; batch_similarity gives the same scores.
scored = smsd.batch_similarity(DIAZEPAM, [smiles[i] for i in similar])
hit_graphs = []
for position, score in scored:
index = similar[position]
graph = smsd.parse_smiles(smiles[index])
graph.name = names[index]
graph.properties = {"SOURCE": sources[index],
"TANIMOTO": f"{score:.3f}",
"HAS_CORE": "yes" if index in with_core else "no"}
hit_graphs.append(graph)
smsd.write_sdf(hit_graphs, workdir / "hits.sdf")
for graph in smsd.read_sdf(workdir / "hits.sdf"):
print(graph.name, dict(graph.properties))
diazepam {'HAS_CORE': 'yes', 'SOURCE': 'named', 'TANIMOTO': '1.000'}
temazepam {'HAS_CORE': 'yes', 'SOURCE': 'named', 'TANIMOTO': '0.582'}
tetrazepam {'HAS_CORE': 'yes', 'SOURCE': 'named', 'TANIMOTO': '0.542'}
nordazepam {'HAS_CORE': 'yes', 'SOURCE': 'named', 'TANIMOTO': '0.528'}
clobazam {'HAS_CORE': 'no', 'SOURCE': 'named', 'TANIMOTO': '0.500'}
shutil.rmtree(workdir)
print("Done")
Done
The fingerprint screens return the same hits whichever route runs them, and the substructure decisions carry a proof for every target ruled out before matching. The screening chapter explains each route, its options and its limits, and the packed index chapter covers building, reopening and searching a saved index.