Home▸Articles▸Foundations

Scaffold Analysis and Lead Optimization

How to analyse chemical series using Bemis–Murcko scaffolds, matched molecular pairs, and activity cliffs. Covers scaffold hopping strategies, SAR table construction, and Python pipelines for lead optimization decision-making.

mysimulator teamUpdated January 2026≈ 10 min read▶ Open the simulation

Once a hit compound is confirmed in a biochemical assay, the medicinal chemistry team moves into lead optimization — systematically modifying the structure to improve potency, selectivity, solubility, metabolic stability, and safety. Computational analysis of chemical series using scaffold frameworks, structure-activity relationships (SAR), and matched molecular pair (MMP) analysis provides the data-driven backbone for these decisions.

This guide covers the key computational tools for scaffold analysis, how to build SAR tables and activity cliff maps, how to perform MMP analysis to quantify the effect of individual substituent changes, and how to use these insights for scaffold hopping.

Bemis–Murcko Scaffolds

The Bemis–Murcko scaffold decomposition, introduced in 1996, is the standard framework for classifying drug-like molecules into structural families. It reduces a molecule to its:

The scaffold is the ring systems plus linkers, stripped of all side chains. Two molecules with the same scaffold belong to the same chemical series even if their peripheral substitution patterns differ dramatically.

Level Definition Use Case
Bemis–Murcko Scaffold Ring systems + linkers, no substituents Chemical series grouping
Generic Scaffold All atoms replaced by C/N, bonds by single Shape-topology clustering
Murcko Framework Ring atoms only (no linkers) Scaffold hopping targets
Carbon Skeleton All heteroatoms replaced by C Carbon framework diversity

Computing Scaffolds with RDKit

from rdkit import Chem
from rdkit.Chem.Scaffolds import MurckoScaffold
import pandas as pd
from collections import Counter

def get_murcko_scaffold(smiles: str, generic: bool = False) -> str:
    """
    Return the Bemis-Murcko scaffold SMILES for a molecule.

    Args:
        smiles:  Input SMILES
        generic: If True, return the generic scaffold (all C, single bonds)

    Returns:
        Scaffold SMILES string, or empty string if molecule is invalid.
    """
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return ""
    if generic:
        scaffold = MurckoScaffold.MakeScaffoldGeneric(
            MurckoScaffold.GetScaffoldForMol(mol)
        )
    else:
        scaffold = MurckoScaffold.GetScaffoldForMol(mol)
    return Chem.MolToSmiles(scaffold)


# Example: analyse scaffold distribution in a ChEMBL activity set
df = pd.read_csv("chembl_activities.csv")  # columns: smiles, pIC50
df["scaffold"] = df["smiles"].apply(get_murcko_scaffold)

# Count scaffold frequency
scaffold_counts = Counter(df["scaffold"])
top_scaffolds = scaffold_counts.most_common(10)
print("Top 10 scaffolds by compound count:")
for scaffold, count in top_scaffolds:
    print(f"  {scaffold}: {count} compounds")

# Average potency per scaffold
scaffold_sar = df.groupby("scaffold")["pIC50"].agg(["mean", "std", "count"])
scaffold_sar = scaffold_sar[scaffold_sar["count"] >= 5].sort_values("mean", ascending=False)
print(scaffold_sar.head(10))

Activity Cliffs

An activity cliff is a pair of structurally similar compounds with a large difference in biological activity (typically ≥2 log units in pIC50). Activity cliffs are simultaneously the most informative and most dangerous data points in a chemical series:

The standard metric is the Structure-Activity Landscape Index (SALI):

SALI(i,j) = |A_i - A_j| / (1 - sim(i,j))

Where A is activity value and sim is Tanimoto similarity. High SALI scores identify the steepest activity cliffs.

from rdkit import Chem, DataStructs
from rdkit.Chem import AllChem
import numpy as np
import pandas as pd

def compute_tanimoto_matrix(smiles_list: list, radius: int = 2, n_bits: int = 2048) -> np.ndarray:
    """Compute pairwise Tanimoto similarity matrix."""
    fps = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is not None:
            fps.append(AllChem.GetMorganFingerprintAsBitVect(mol, radius, nBits=n_bits))
        else:
            fps.append(None)

    n = len(fps)
    matrix = np.zeros((n, n))
    for i in range(n):
        for j in range(i + 1, n):
            if fps[i] is not None and fps[j] is not None:
                sim = DataStructs.TanimotoSimilarity(fps[i], fps[j])
                matrix[i, j] = sim
                matrix[j, i] = sim
    np.fill_diagonal(matrix, 1.0)
    return matrix


def find_activity_cliffs(
    df: pd.DataFrame,
    smiles_col: str = "smiles",
    activity_col: str = "pIC50",
    sim_threshold: float = 0.6,
    activity_threshold: float = 2.0
) -> pd.DataFrame:
    """
    Find activity cliff pairs: Tanimoto >= sim_threshold but
    |pIC50 difference| >= activity_threshold.
    """
    smiles = df[smiles_col].tolist()
    activities = df[activity_col].values
    sim_matrix = compute_tanimoto_matrix(smiles)

    cliffs = []
    n = len(smiles)
    for i in range(n):
        for j in range(i + 1, n):
            sim = sim_matrix[i, j]
            act_diff = abs(activities[i] - activities[j])
            if sim >= sim_threshold and act_diff >= activity_threshold:
                sali = act_diff / (1 - sim + 1e-9)
                cliffs.append({
                    "smiles_1": smiles[i],
                    "smiles_2": smiles[j],
                    "activity_1": activities[i],
                    "activity_2": activities[j],
                    "tanimoto": round(sim, 3),
                    "activity_diff": round(act_diff, 2),
                    "SALI": round(sali, 2),
                })
    return pd.DataFrame(cliffs).sort_values("SALI", ascending=False)

Matched Molecular Pair Analysis

Matched Molecular Pair (MMP) analysis transforms the question "how does this substituent change affect activity?" into a statistical answer. An MMP is defined as two compounds that differ by exactly one structural transformation — the replacement of one fragment (context) with another (transformation). By collecting hundreds or thousands of such pairs from a dataset, you build a data-driven transformation dictionary.

Core Concepts

Common insights from MMP analysis in drug discovery:

Transformation Typical Δ logD Typical Δ pIC50 Common Purpose
H → F −0.1 to −0.3 −0.1 to +0.5 Metabolic stability, bioisosterism
CH3 → CF3 +0.5 to +1.0 variable Lipophilicity tuning
Ph → Py −0.6 to −1.0 variable Solubility, CYP inhibition reduction
COOH → tetrazole −0.8 to −1.5 ~0 Metabolic stability bioisostere
NH → O (amide → ester) +0.3 to +0.8 −0.5 to −1.5 Probe permeability/potency trade-off

MMP Analysis with mmpdb

"""
mmpdb is the standard open-source tool for MMP analysis.
Install: pip install mmpdb

Workflow:
1. Fragment a SMILES dataset into MMP pairs
2. Load an activity CSV
3. Query property changes for specific transformations
"""
import subprocess
import pandas as pd

# Step 1: fragment the compound set
subprocess.run([
    "mmpdb", "fragment",
    "compounds.smi",         # tab-separated: SMILES  ID
    "-o", "fragments.frag"
], check=True)

# Step 2: index the fragments into an MMP database
subprocess.run([
    "mmpdb", "index",
    "fragments.frag",
    "-o", "compounds.mmpdb"
], check=True)

# Step 3: load activity data
# CSV with columns: ID, pIC50, logD, clearance
subprocess.run([
    "mmpdb", "loadprops",
    "-i", "activities.csv",
    "compounds.mmpdb"
], check=True)

# Step 4: query for all transformations affecting pIC50
result = subprocess.run([
    "mmpdb", "transform",
    "--smiles", "c1ccc(NC(=O)c2ccccc2)cc1",  # query compound SMILES
    "--property", "pIC50",
    "compounds.mmpdb"
], capture_output=True, text=True, check=True)

print(result.stdout[:2000])  # First few predicted transformations

Implementing Simple MMP Detection in Python

from rdkit import Chem
from rdkit.Chem import rdMMPA  # Matched Molecular Pair Analysis module
import pandas as pd

def extract_mmps(smiles_list: list, ids: list) -> pd.DataFrame:
    """
    Use RDKit's rdMMPA to fragment molecules and identify MMP pairs.
    Returns a dataframe of (id, smiles, core, r_group) fragments.
    """
    records = []
    for smi, mol_id in zip(smiles_list, ids):
        mol = Chem.MolFromSmiles(smi)
        if mol is None:
            continue
        # FragmentMol returns (core, r_group) pairs
        frags = rdMMPA.FragmentMol(mol, maxCuts=1, resultsAsMols=False)
        for core_smi, rgroup_smi in frags:
            records.append({
                "id": mol_id,
                "smiles": smi,
                "core": core_smi,
                "rgroup": rgroup_smi,
            })
    return pd.DataFrame(records)


# Find pairs with the same core but different R-groups
def find_mmp_pairs(df: pd.DataFrame) -> pd.DataFrame:
    """Group by core and return all pairs sharing a scaffold context."""
    pairs = []
    for core, group in df.groupby("core"):
        if len(group) < 2:
            continue
        rows = group.reset_index(drop=True)
        for i in range(len(rows)):
            for j in range(i + 1, len(rows)):
                if rows.loc[i, "rgroup"] != rows.loc[j, "rgroup"]:
                    pairs.append({
                        "core": core,
                        "id_1": rows.loc[i, "id"],
                        "id_2": rows.loc[j, "id"],
                        "smiles_1": rows.loc[i, "smiles"],
                        "smiles_2": rows.loc[j, "smiles"],
                        "rgroup_1": rows.loc[i, "rgroup"],
                        "rgroup_2": rows.loc[j, "rgroup"],
                    })
    return pd.DataFrame(pairs)

Scaffold Hopping

Scaffold hopping is the replacement of the core ring system of a compound while retaining its binding pharmacophore. Reasons to hop scaffolds include avoiding competitor IP, improving aqueous solubility, reducing CYP inhibition from the original scaffold, or escaping metabolic soft spots within the rings.

Strategies for Scaffold Hopping

Similarity-Based Scaffold Hop Search

from rdkit import Chem, DataStructs
from rdkit.Chem import AllChem
from rdkit.Chem.Scaffolds import MurckoScaffold
import pandas as pd

def scaffold_hop_candidates(
    query_smiles: str,
    library_df: pd.DataFrame,
    smiles_col: str = "smiles",
    top_k: int = 20,
    fp_sim_min: float = 0.3,
    fp_sim_max: float = 0.6,
) -> pd.DataFrame:
    """
    Find library compounds that are moderately similar to the query
    (same pharmacophore region) but have a different Murcko scaffold.

    Low-to-moderate Tanimoto + different scaffold = scaffold hop candidate.
    """
    query_mol = Chem.MolFromSmiles(query_smiles)
    query_fp = AllChem.GetMorganFingerprintAsBitVect(query_mol, 2, 2048)
    query_scaffold = MurckoScaffold.MurckoScaffoldSmiles(mol=query_mol)

    results = []
    for _, row in library_df.iterrows():
        mol = Chem.MolFromSmiles(row[smiles_col])
        if mol is None:
            continue
        fp = AllChem.GetMorganFingerprintAsBitVect(mol, 2, 2048)
        sim = DataStructs.TanimotoSimilarity(query_fp, fp)
        if not (fp_sim_min <= sim <= fp_sim_max):
            continue
        scaf = MurckoScaffold.MurckoScaffoldSmiles(mol=mol)
        if scaf == query_scaffold:
            continue  # Same scaffold — not a hop
        results.append({**row.to_dict(), "tanimoto": round(sim, 3), "scaffold": scaf})

    return pd.DataFrame(results).sort_values("tanimoto", ascending=False).head(top_k)

Building a SAR Table

A SAR (Structure-Activity Relationship) table organizes compounds in a chemical series to visualize the effect of each substitution position on activity. Automating SAR table generation from ChEMBL data is a common workflow at the start of a project.

import pandas as pd
from rdkit import Chem
from rdkit.Chem import AllChem
from rdkit.Chem.Scaffolds import MurckoScaffold

def build_sar_table(df: pd.DataFrame, scaffold_smi: str) -> pd.DataFrame:
    """
    For a given scaffold SMILES, extract all compounds in df that share it
    and compute their R-group substituents at each attachment point.

    Returns a dataframe with columns:
        smiles, pIC50, scaffold, rgroup_summary
    """
    target_scaffold = Chem.MolToSmiles(Chem.MolFromSmiles(scaffold_smi))
    rows = []
    for _, row in df.iterrows():
        mol = Chem.MolFromSmiles(row["smiles"])
        if mol is None:
            continue
        scaf_smi = Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(mol))
        if scaf_smi != target_scaffold:
            continue
        rows.append(row)

    sar_df = pd.DataFrame(rows)
    sar_df = sar_df.sort_values("pIC50", ascending=False)
    return sar_df


# Example usage
df = pd.read_csv("series_activities.csv")  # smiles, pIC50, compound_id
core = "c1ccc2ncccc2c1"                    # quinoline scaffold SMILES
sar = build_sar_table(df, core)
print(sar[["compound_id", "smiles", "pIC50"]].to_string(index=False))

Activity Cliff Awareness in ML Models

Activity cliffs are the main failure mode for fingerprint-based ML models. When the training set contains a cliff pair and the model assigns them similar latent representations, predictions for cliff-related compounds will be systematically wrong. Mitigation strategies include:

from rdkit import Chem
from rdkit.Chem.Scaffolds import MurckoScaffold
from sklearn.model_selection import GroupShuffleSplit
import pandas as pd
import numpy as np

def scaffold_split(
    df: pd.DataFrame,
    test_size: float = 0.2,
    random_state: int = 42
) -> tuple:
    """
    Perform scaffold-based train/test split.
    Compounds with the same Murcko scaffold always go to the same split.
    """
    scaffolds = []
    for smi in df["smiles"]:
        mol = Chem.MolFromSmiles(smi)
        if mol is None:
            scaffolds.append("")
        else:
            scaffolds.append(
                Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(mol))
            )

    groups = pd.Categorical(scaffolds).codes
    gss = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=random_state)
    train_idx, test_idx = next(gss.split(df, groups=groups))
    return df.iloc[train_idx], df.iloc[test_idx]


df = pd.read_csv("activities.csv")
train_df, test_df = scaffold_split(df)
print(f"Train: {len(train_df)} compounds | Test: {len(test_df)} compounds")

# Verify no scaffold overlap
train_scaffolds = set(train_df["smiles"].apply(
    lambda s: Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(Chem.MolFromSmiles(s)))
    if Chem.MolFromSmiles(s) else ""
))
test_scaffolds = set(test_df["smiles"].apply(
    lambda s: Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(Chem.MolFromSmiles(s)))
    if Chem.MolFromSmiles(s) else ""
))
overlap = train_scaffolds & test_scaffolds
print(f"Scaffold overlap: {len(overlap)} scaffolds")  # Should be 0

Key Takeaways

Try it live

Explore a related model hands-on: open Molecule Screening Engine — Similarity Search Live and change its parameters while it runs. Everything stays in your browser.

▶ Open Molecule Screening Engine — Similarity Search Live simulation

What did you find?

Add reproduction steps (optional)