# 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)
Fingerprints and similarity¶
This notebook uses SMSD 8.1.0 to compute each fingerprint family for a few common drugs, compare fingerprints with every binary and count coefficient, configure a fingerprint with stereo, scaffold and fragment switches, and rank a series of benzodiazepines by similarity. Each value the notebook prints is checked by an assertion in the same cell. NumPy and pandas are optional: without them, the matrix cell uses plain lists and the setup cell reports what is missing.
Setup¶
Select a kernel with SMSD 8.1.0 or later installed
(python -m pip install "smsd==8.1.0"); NumPy and pandas are used when
present. The notebook downloads nothing and writes no files.
To run it in Colab, copy the setup cell of feature_search.ipynb to the top of this notebook and run it first. That cell installs a trusted SMSD 8.1.0 core wheel that you upload, after checking its name and platform tags.
# Check the SMSD version and look for the optional packages.
import re
import smsd
print("SMSD", smsd.__version__)
_installed = tuple(int(part) for part in re.findall(r"\d+", smsd.__version__)[:3])
if _installed < (8, 1, 0):
raise RuntimeError("Use SMSD 8.1.0 or later in this notebook's kernel")
try:
import numpy as np
except ImportError:
np = None
print("NumPy is not installed; the matrix cell uses plain lists.")
try:
import pandas as pd
except ImportError:
pd = None
print("pandas is not installed; the matrix cell prints plain rows.")
SMSD 8.1.0
# Every fingerprint family on aspirin. Each kind of fingerprint() gives the
# bits of its fixed function, and the classic FingerprintOptions give them again.
aspirin = "CC(=O)Oc1ccccc1C(=O)O" # aspirin
graph = smsd.parse_smiles(aspirin)
fixed = {
"ecfp": smsd.circular_fingerprint(graph, radius=2, fp_size=2048),
"fcfp": smsd.circular_fingerprint(graph, radius=2, fp_size=2048, mode="fcfp"),
"path": smsd.path_fingerprint(graph, path_length=7, fp_size=2048),
"mcs": smsd.mcs_fingerprint(graph, path_length=7, fp_size=2048),
"torsion": smsd.topological_torsion(graph, fp_size=2048),
}
classic = {
"ecfp": smsd.FingerprintOptions.ecfp(),
"fcfp": smsd.FingerprintOptions.fcfp(),
"path": smsd.FingerprintOptions.path(),
"torsion": smsd.FingerprintOptions.torsion(),
}
for kind, bits in fixed.items():
assert smsd.fingerprint(aspirin, kind=kind) == bits
if kind in classic:
assert smsd.fingerprint(aspirin, options=classic[kind]) == bits
print(f"{kind:8} {len(bits):3d} set bits, first five {bits[:5]}")
maccs_bits = smsd.fingerprint(aspirin, options=smsd.FingerprintOptions.maccs())
print(f"{'maccs':8} {len(maccs_bits):3d} set bits")
assert [len(bits) for bits in fixed.values()] == [36, 29, 29, 127, 16]
assert len(maccs_bits) == 21
# The default kind differs between entry points.
default = smsd.fingerprint(aspirin)
from_smiles = smsd.fingerprint_from_smiles(aspirin)
print("fingerprint() default equals kind='mcs':", default == fixed["mcs"])
print("fingerprint_from_smiles() default equals ECFP4:", from_smiles == fixed["ecfp"])
assert default == fixed["mcs"] and from_smiles == fixed["ecfp"]
ecfp 36 set bits, first five [23, 44, 61, 101, 172] fcfp 29 set bits, first five [6, 17, 180, 274, 352] path 29 set bits, first five [23, 25, 145, 147, 149] mcs 127 set bits, first five [9, 12, 38, 80, 91] torsion 16 set bits, first five [735, 740, 783, 825, 1081] maccs 21 set bits fingerprint() default equals kind='mcs': True fingerprint_from_smiles() default equals ECFP4: True
# Count fingerprints: positions equal the binary positions; path counts are 1.
hexane = smsd.parse_smiles("CCCCCC") # hexane
counts = smsd.circular_fingerprint_counts(hexane, radius=2, fp_size=2048)
print("ECFP4 counts of hexane:", counts)
assert counts[0] == (592, 2) and counts[-1] == (1861, 4)
assert [position for position, _ in counts] == smsd.circular_fingerprint(hexane)
counted = smsd.FingerprintOptions("ecfp", counts=True)
assert smsd.fingerprint("CCCCCC", options=counted) == counts
path_counts = smsd.fingerprint("CCCCCC", options=smsd.FingerprintOptions("path", counts=True))
print("path counts of hexane:", path_counts)
assert all(count == 1 for _, count in path_counts)
ECFP4 counts of hexane: [(592, 2), (906, 2), (1015, 2), (1147, 2), (1512, 2), (1702, 2), (1828, 2), (1861, 4)] path counts of hexane: [(145, 1), (273, 1), (277, 1), (401, 1), (405, 1), (533, 1)]
# Every binary and count coefficient for aspirin against salicylic acid.
ecfp4 = smsd.FingerprintOptions.ecfp(radius=2, size=2048)
a = smsd.fingerprint("CC(=O)Oc1ccccc1C(=O)O", options=ecfp4) # aspirin
b = smsd.fingerprint("OC(=O)c1ccccc1O", options=ecfp4) # salicylic acid
binary = {
"tanimoto_coefficient": smsd.tanimoto_coefficient(a, b),
"dice": smsd.dice(a, b, fp_size=2048),
"cosine": smsd.cosine(a, b, fp_size=2048),
"overlap_coefficient": smsd.overlap_coefficient(a, b),
"soergel": smsd.soergel(a, b, fp_size=2048),
}
for name, score in binary.items():
print(f"{name:27} {score:.3f}")
assert [round(score, 3) for score in binary.values()] == [0.185, 0.312, 0.315, 0.357, 0.815]
counted = smsd.FingerprintOptions("ecfp", counts=True)
ca = smsd.fingerprint("CC(=O)Oc1ccccc1C(=O)O", options=counted) # aspirin
cb = smsd.fingerprint("OC(=O)c1ccccc1O", options=counted) # salicylic acid
by_count = {
"count_tanimoto_coefficient": smsd.count_tanimoto_coefficient(ca, cb),
"count_dice": smsd.count_dice(ca, cb),
"count_cosine": smsd.count_cosine(ca, cb),
"count_overlap_coefficient": smsd.count_overlap_coefficient(ca, cb),
}
for name, score in by_count.items():
print(f"{name:27} {score:.3f}")
assert [round(score, 3) for score in by_count.values()] == [0.211, 0.348, 0.462, 0.4]
# dice, cosine and soergel read fp_size: pass the real width.
wide = smsd.FingerprintOptions.ecfp(radius=2, size=4096)
wa = smsd.fingerprint("CC(=O)Oc1ccccc1C(=O)O", options=wide) # aspirin
wb = smsd.fingerprint("OC(=O)c1ccccc1O", options=wide) # salicylic acid
right, wrong = smsd.dice(wa, wb, fp_size=4096), smsd.dice(wa, wb)
print(f"4096-bit dice: {right:.3f} with fp_size=4096, {wrong:.3f} with the default")
assert round(right, 3) == 0.312 and round(wrong, 3) == 0.333
tanimoto_coefficient 0.185 dice 0.312 cosine 0.315 overlap_coefficient 0.357 soergel 0.815 count_tanimoto_coefficient 0.211 count_dice 0.348 count_cosine 0.462 count_overlap_coefficient 0.400 4096-bit dice: 0.312 with fp_size=4096, 0.333 with the default
# What each coefficient returns for empty fingerprints.
rules = [
("tanimoto_coefficient", smsd.tanimoto_coefficient, [1], 1.0, 0.0),
("overlap_coefficient", smsd.overlap_coefficient, [1], 1.0, 0.0),
("dice", smsd.dice, [1], 0.0, 0.0),
("cosine", smsd.cosine, [1], 0.0, 0.0),
("soergel", smsd.soergel, [1], 0.0, 1.0),
("count_tanimoto_coefficient", smsd.count_tanimoto_coefficient, [(1, 1)], 0.0, 0.0),
("count_overlap_coefficient", smsd.count_overlap_coefficient, [(1, 1)], 0.0, 0.0),
("count_dice", smsd.count_dice, [(1, 1)], 0.0, 0.0),
("count_cosine", smsd.count_cosine, [(1, 1)], 0.0, 0.0),
]
print(f"{'coefficient':27} both empty one empty")
for name, metric, one, both_empty, one_empty in rules:
print(f"{name:27} {metric([], []):10.1f} {metric([], one):10.1f}")
assert metric([], []) == both_empty and metric([], one) == one_empty
coefficient both empty one empty tanimoto_coefficient 1.0 0.0 overlap_coefficient 1.0 0.0 dice 0.0 0.0 cosine 0.0 0.0 soergel 0.0 1.0 count_tanimoto_coefficient 0.0 0.0 count_overlap_coefficient 0.0 0.0 count_dice 0.0 0.0 count_cosine 0.0 0.0
# FingerprintOptions switches: stereo, scaffold and fragments; MACCS key numbers.
classic = smsd.FingerprintOptions()
stereo = smsd.FingerprintOptions(cip_atoms=True)
first, second = "C[C@H](N)C(=O)O", "C[C@@H](N)C(=O)O" # alanine enantiomers
same = smsd.fingerprint(first, options=classic) == smsd.fingerprint(second, options=classic)
apart = smsd.fingerprint(first, options=stereo) != smsd.fingerprint(second, options=stereo)
print("classic ECFP4 separates the enantiomers:", not same)
print("cip_atoms separates them:", apart)
assert same and apart
murcko = smsd.FingerprintOptions(scaffold="murcko")
framework = smsd.fingerprint("Cc1ccccc1", options=murcko) # toluene
benzene = smsd.fingerprint("c1ccccc1", options=classic) # benzene
print("Murcko framework of toluene equals benzene:", framework == benzene)
assert framework == benzene
salt = "CC(=O)Oc1ccccc1C(=O)O.[Na+].[Cl-]" # aspirin with sodium chloride
parent = smsd.fingerprint("CC(=O)Oc1ccccc1C(=O)O", options=classic) # aspirin
largest = smsd.fingerprint(salt, options=smsd.FingerprintOptions(fragments="largest"))
print("largest fragment of the salt equals aspirin:", largest == parent)
assert largest == parent and smsd.fingerprint(salt, options=classic) != parent
maccs = smsd.fingerprint("CC(=O)Oc1ccccc1C(=O)O", options=smsd.FingerprintOptions.maccs())
key_numbers = [position + 1 for position in maccs] # key k is position k - 1
print("MACCS keys set for aspirin:", key_numbers)
assert len(key_numbers) == 21 and key_numbers[:3] == [89, 113, 123]
classic ECFP4 separates the enantiomers: False cip_atoms separates them: True Murcko framework of toluene equals benzene: True largest fragment of the salt equals aspirin: True MACCS keys set for aspirin: [89, 113, 123, 126, 127, 136, 139, 140, 143, 144, 146, 150, 152, 154, 157, 159, 160, 162, 163, 164, 165]
# SMSD has no Tversky function; it is a few lines over the set positions.
def tversky(query, target, alpha=0.9, beta=0.1):
q, t = set(query), set(target)
common = len(q & t)
denominator = common + alpha * len(q - t) + beta * len(t - q)
return 1.0 if denominator == 0 else common / denominator
ecfp4 = smsd.FingerprintOptions.ecfp()
phenol = smsd.fingerprint("Oc1ccccc1", options=ecfp4) # phenol
paracetamol = smsd.fingerprint("CC(=O)Nc1ccc(O)cc1", options=ecfp4) # paracetamol
forward, reverse = tversky(phenol, paracetamol), tversky(paracetamol, phenol)
print(f"phenol in paracetamol: {forward:.3f}")
print(f"paracetamol in phenol: {reverse:.3f}")
assert round(forward, 3) == 0.533 and round(reverse, 3) == 0.348
assert abs(tversky(phenol, paracetamol, 1, 1) - smsd.tanimoto_coefficient(phenol, paracetamol)) < 1e-12
dice = smsd.dice(phenol, paracetamol, fp_size=2048)
assert abs(tversky(phenol, paracetamol, 0.5, 0.5) - dice) < 1e-12
phenol in paracetamol: 0.533 paracetamol in phenol: 0.348
# An ECFP4 Tanimoto matrix of the benzodiazepines.
benzodiazepines = {
"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",
}
names, smiles = list(benzodiazepines), list(benzodiazepines.values())
ecfp4 = smsd.FingerprintOptions.ecfp(radius=2, size=2048)
fps = smsd.fingerprints(smiles, options=ecfp4)
by_coefficient = [[smsd.tanimoto_coefficient(p, q) for q in fps] for p in fps]
if np is not None:
A = np.zeros((len(fps), 2048), dtype=np.int32)
for row, positions in enumerate(fps):
A[row, positions] = 1
common = A @ A.T
sizes = A.sum(axis=1)
matrix = common / (sizes[:, None] + sizes[None, :] - common)
assert np.allclose(matrix, by_coefficient)
rows = matrix.round(2).tolist()
else:
rows = [[round(score, 2) for score in row] for row in by_coefficient]
labels = [name[:4] for name in names] # short column labels
assert len(set(labels)) == len(names)
if pd is not None:
print(pd.DataFrame(rows, index=names, columns=labels).to_string())
else:
for name, row in zip(names, rows):
print(f"{name:14}", row)
# The nearest neighbour of each molecule, other than itself.
nearest = {}
for i, name in enumerate(names):
j = max((k for k in range(len(names)) if k != i), key=lambda k: by_coefficient[i][k])
nearest[name] = (names[j], round(by_coefficient[i][j], 3))
print(f"{name:14} nearest {names[j]:14} {by_coefficient[i][j]:.3f}")
assert all(by_coefficient[i][i] == 1.0 for i in range(len(names)))
assert nearest["diazepam"] == ("temazepam", 0.582)
assert nearest["oxazepam"] == ("lorazepam", 0.755)
assert nearest["alprazolam"] == ("triazolam", 0.76)
diaz nord tema oxaz lora clon nitr flun brom tetr alpr tria mida clob diazepam 1.00 0.53 0.58 0.38 0.28 0.24 0.31 0.40 0.25 0.54 0.35 0.26 0.25 0.50 nordazepam 0.53 1.00 0.38 0.56 0.43 0.41 0.51 0.19 0.52 0.28 0.34 0.25 0.24 0.28 temazepam 0.58 0.38 1.00 0.55 0.41 0.17 0.21 0.22 0.19 0.34 0.33 0.25 0.24 0.38 oxazepam 0.38 0.56 0.55 1.00 0.76 0.22 0.27 0.13 0.31 0.21 0.33 0.24 0.23 0.25 lorazepam 0.28 0.43 0.41 0.76 1.00 0.32 0.20 0.14 0.30 0.21 0.24 0.35 0.24 0.22 clonazepam 0.24 0.41 0.17 0.22 0.32 1.00 0.75 0.46 0.36 0.16 0.13 0.22 0.14 0.15 nitrazepam 0.31 0.51 0.21 0.27 0.20 0.75 1.00 0.45 0.38 0.14 0.17 0.11 0.10 0.15 flunitrazepam 0.40 0.19 0.22 0.13 0.14 0.46 0.45 1.00 0.20 0.29 0.12 0.13 0.21 0.24 bromazepam 0.25 0.52 0.19 0.31 0.30 0.36 0.38 0.20 1.00 0.16 0.15 0.14 0.14 0.15 tetrazepam 0.54 0.28 0.34 0.21 0.21 0.16 0.14 0.29 0.16 1.00 0.21 0.21 0.20 0.38 alprazolam 0.35 0.34 0.33 0.33 0.24 0.13 0.17 0.12 0.15 0.21 1.00 0.76 0.51 0.22 triazolam 0.26 0.25 0.25 0.24 0.35 0.22 0.11 0.13 0.14 0.21 0.76 1.00 0.54 0.20 midazolam 0.25 0.24 0.24 0.23 0.24 0.14 0.10 0.21 0.14 0.20 0.51 0.54 1.00 0.19 clobazam 0.50 0.28 0.38 0.25 0.22 0.15 0.15 0.24 0.15 0.38 0.22 0.20 0.19 1.00 diazepam nearest temazepam 0.582 nordazepam nearest oxazepam 0.558 temazepam nearest diazepam 0.582 oxazepam nearest lorazepam 0.755 lorazepam nearest oxazepam 0.755 clonazepam nearest nitrazepam 0.750 nitrazepam nearest clonazepam 0.750 flunitrazepam nearest clonazepam 0.465 bromazepam nearest nordazepam 0.519 tetrazepam nearest diazepam 0.542 alprazolam nearest triazolam 0.760 triazolam nearest alprazolam 0.760 midazolam nearest triazolam 0.541 clobazam nearest diazepam 0.500
# Rank the other benzodiazepines against diazepam by ECFP Tanimoto.
query = benzodiazepines["diazepam"]
targets = [name for name in names if name != "diazepam"]
ranking = smsd.batch_similarity(query, [benzodiazepines[name] for name in targets],
fp_size=2048, radius=2)
for index, score in ranking[:5]:
print(f"{score:.3f} {targets[index]}")
top = [(targets[index], round(score, 3)) for index, score in ranking[:5]]
assert top == [("temazepam", 0.582), ("tetrazepam", 0.542), ("nordazepam", 0.528),
("clobazam", 0.5), ("flunitrazepam", 0.397)]
q = smsd.fingerprint(query, options=ecfp4)
scores = [smsd.tanimoto_coefficient(q, smsd.fingerprint(benzodiazepines[name], options=ecfp4))
for name in targets]
assert ranking == sorted(enumerate(scores), key=lambda pair: -pair[1])
# batch_similarity takes the radius and width; radius 3 gives ECFP6.
ecfp6 = smsd.batch_similarity(query, [benzodiazepines[name] for name in targets],
fp_size=2048, radius=3)
print("ECFP6 nearest neighbour:", targets[ecfp6[0][0]], f"{ecfp6[0][1]:.3f}")
assert targets[ecfp6[0][0]] == "temazepam" and round(ecfp6[0][1], 3) == 0.476
0.582 temazepam 0.542 tetrazepam 0.528 nordazepam 0.500 clobazam 0.397 flunitrazepam ECFP6 nearest neighbour: temazepam 0.476
# Hexadecimal and binary-string encodings of a binary fingerprint.
phenol = smsd.fingerprint("Oc1ccccc1", kind="ecfp") # phenol
text = smsd.to_hex(phenol, fp_size=2048)
print("hex characters:", len(text), "| round trip:", smsd.from_hex(text) == phenol)
assert len(text) == 512 and smsd.from_hex(text) == phenol
# 64-bit words in ascending order, most significant digit first.
print("to_hex([0], 64): ", smsd.to_hex([0], fp_size=64))
print("to_hex([63], 64):", smsd.to_hex([63], fp_size=64))
assert smsd.to_hex([0], fp_size=64) == "0000000000000001"
assert smsd.to_hex([63], fp_size=64) == "8000000000000000"
bits = smsd.to_binary_string([0, 3], fp_size=8) # character i is bit i
print("to_binary_string([0, 3], 8):", bits)
assert bits == "10010000"
hex characters: 512 | round trip: True to_hex([0], 64): 0000000000000001 to_hex([63], 64): 8000000000000000 to_binary_string([0, 3], 8): 10010000
Where next¶
Fingerprints made with different families, radii, path lengths, widths or switches cannot be compared, and nothing in a fingerprint records how it was made, so keep one specification per comparison. The fingerprints chapter of the user guide covers every function and option shown here, with the defaults. Screening a library and the packed fingerprint index apply these fingerprints to larger libraries, and the Python feature adapters page turns them into NumPy, SciPy, pandas, Polars, Arrow and PyTorch data.