Home▸Articles▸Screening

Fragment-Based Drug Discovery

How to build, filter, and computationally screen fragment libraries to identify starting points for drug design. Covers the Rule of Three, fragment efficiency metrics, computational fragment docking, and fragment-growing and fragment-linking strategies with practical Python/RDKit examples.

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

Fragment-based drug discovery (FBDD) starts from very small molecules (fragments, MW < 300 Da) that bind weakly to the target but occupy a well-defined sub-pocket. These fragments are then grown, linked, or merged into drug-like leads. FBDD has delivered approved drugs including Venetoclax (ABT-199) and Vemurafenib, demonstrating that weak-binding, small starting points can be optimised into potent inhibitors.

The key insight is that fragments have a higher "hit rate" in structural screening (X-ray or SPR) than drug-like molecules because they need to satisfy fewer constraints simultaneously. The trade-off is that binding is weak (Kd values in the μM–mM range), requiring sensitive biophysical techniques for detection.

The Rule of Three

Analogous to Lipinski's Rule of Five for drugs, the Rule of Three (Ro3) was proposed by Astex Therapeutics to define fragment-like chemical space:

PropertyRule of ThreeRule of Five (drug-like)
Molecular weight≤ 300 Da≤ 500 Da
cLogP≤ 3≤ 5
H-bond donors≤ 3≤ 5
H-bond acceptors≤ 3≤ 10
Rotatable bonds≤ 3≤ 10
PSA≤ 60 Ų≤ 140 Ų
from rdkit import Chem
from rdkit.Chem import Descriptors, Lipinski, rdMolDescriptors
from dataclasses import dataclass

@dataclass
class Ro3Result:
    smiles: str
    mw: float
    clogp: float
    hbd: int
    hba: int
    rotatable_bonds: int
    tpsa: float
    passes: bool
    violations: list[str]


def ro3_filter(smiles: str) -> Ro3Result:
    """
    Apply Astex Rule of Three fragment filter to a SMILES string.

    Returns Ro3Result with pass/fail and list of violated rules.
    """
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return Ro3Result(smiles, 0, 0, 0, 0, 0, 0, False, ["Invalid SMILES"])

    mw = Descriptors.ExactMolWt(mol)
    clogp = Descriptors.MolLogP(mol)
    hbd = Lipinski.NumHDonors(mol)
    hba = Lipinski.NumHAcceptors(mol)
    rot = rdMolDescriptors.CalcNumRotatableBonds(mol)
    tpsa = rdMolDescriptors.CalcTPSA(mol)

    violations = []
    if mw > 300:
        violations.append(f"MW={mw:.1f} > 300")
    if clogp > 3:
        violations.append(f"cLogP={clogp:.2f} > 3")
    if hbd > 3:
        violations.append(f"HBD={hbd} > 3")
    if hba > 3:
        violations.append(f"HBA={hba} > 3")
    if rot > 3:
        violations.append(f"RotBonds={rot} > 3")
    if tpsa > 60:
        violations.append(f"TPSA={tpsa:.1f} > 60 Ų")

    return Ro3Result(smiles, mw, clogp, hbd, hba, rot, tpsa, len(violations) == 0, violations)


def build_fragment_library(
    smiles_list: list[str],
    max_violations: int = 0,
    min_heavy_atoms: int = 7,
) -> list[str]:
    """
    Filter a SMILES list to fragment-like compounds.

    Args:
        smiles_list: List of SMILES strings
        max_violations: Allowed Ro3 violations (0 = strict Ro3, 1 = lenient)
        min_heavy_atoms: Minimum heavy atom count (avoid too-small fragments)

    Returns:
        Filtered list of SMILES passing fragment criteria
    """
    fragments = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is None:
            continue
        if mol.GetNumHeavyAtoms() < min_heavy_atoms:
            continue
        result = ro3_filter(smi)
        if len(result.violations) <= max_violations:
            fragments.append(smi)
    return fragments

Fragment Efficiency Metrics

Because fragments bind weakly in absolute terms, potency must be normalised by molecular size or lipophilicity. The two key metrics are:

import numpy as np
from rdkit import Chem
from rdkit.Chem import Descriptors

def ligand_efficiency(pki: float, heavy_atom_count: int) -> float:
    """
    Ligand Efficiency (LE) = ΔG / #HA.
    ΔG ≈ -1.37 × pKi at 25°C (kcal/mol).

    Args:
        pki: Experimental or predicted pKi (= -log10(Ki in M))
        heavy_atom_count: Number of non-hydrogen atoms

    Returns:
        LE in kcal/mol per heavy atom
    """
    if heavy_atom_count == 0:
        return 0.0
    delta_g = -1.37 * pki  # kcal/mol
    return abs(delta_g) / heavy_atom_count


def lipophilic_efficiency(pic50: float, smiles: str) -> float:
    """
    Lipophilic Ligand Efficiency (LLE) = pIC50 - cLogP.
    Higher is better; drug-like threshold is LLE > 5.
    """
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return float("nan")
    clogp = Descriptors.MolLogP(mol)
    return pic50 - clogp


def efficiency_profile(smiles: str, pki: float) -> dict:
    """
    Compute all fragment efficiency metrics for a compound.
    """
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return {}
    n_ha = mol.GetNumHeavyAtoms()
    mw = Descriptors.ExactMolWt(mol)
    clogp = Descriptors.MolLogP(mol)
    le = ligand_efficiency(pki, n_ha)
    lle = pki - clogp
    # Binding efficiency index (BEI) = pKi / MW (kDa)
    bei = pki / (mw / 1000.0)
    # Surface efficiency index (SEI) = pKi / PSA (1/100 Ų)
    from rdkit.Chem import rdMolDescriptors
    tpsa = rdMolDescriptors.CalcTPSA(mol)
    sei = pki / (tpsa / 100.0) if tpsa > 0 else 0.0

    return {
        "smiles": smiles,
        "mw": round(mw, 2),
        "clogp": round(clogp, 2),
        "n_heavy_atoms": n_ha,
        "pki": pki,
        "le": round(le, 3),
        "lle": round(lle, 2),
        "bei": round(bei, 2),
        "sei": round(sei, 2),
    }

Computational Fragment Docking

Fragment docking uses the same algorithms as standard molecular docking (see the Molecular Docking guide) but with adapted scoring to account for weak binding and smaller ligands. Key tools include AutoDock Vina, Glide HTVS, and SMINA. Fragment-specific considerations:

import subprocess
import os
from pathlib import Path

def dock_fragment_vina(
    receptor_pdbqt: str,
    fragment_pdbqt: str,
    output_dir: str,
    center: tuple[float, float, float],
    box_size: tuple[float, float, float] = (25.0, 25.0, 25.0),
    exhaustiveness: int = 16,
    num_modes: int = 20,
) -> list[dict]:
    """
    Dock a fragment using Vina with a larger box for wider site exploration.

    Args:
        receptor_pdbqt: Path to receptor .pdbqt
        fragment_pdbqt: Path to fragment .pdbqt
        output_dir: Directory for output files
        center: (cx, cy, cz) box center (Angstroms)
        box_size: (sx, sy, sz) box dimensions — larger for fragments
        exhaustiveness: Vina exhaustiveness (higher for fragments)
        num_modes: Number of poses to save

    Returns:
        List of dicts with pose_rank and score (kcal/mol)
    """
    os.makedirs(output_dir, exist_ok=True)
    output_path = os.path.join(output_dir, "out.pdbqt")
    log_path = os.path.join(output_dir, "vina.log")

    cmd = [
        "vina",
        "--receptor", receptor_pdbqt,
        "--ligand", fragment_pdbqt,
        "--out", output_path,
        "--log", log_path,
        "--center_x", str(center[0]),
        "--center_y", str(center[1]),
        "--center_z", str(center[2]),
        "--size_x", str(box_size[0]),
        "--size_y", str(box_size[1]),
        "--size_z", str(box_size[2]),
        "--exhaustiveness", str(exhaustiveness),
        "--num_modes", str(num_modes),
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)

    # Parse scores from log
    poses = []
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) >= 2:
            try:
                rank = int(parts[0])
                score = float(parts[1])
                poses.append({"rank": rank, "score": score})
            except ValueError:
                pass
    return poses

Fragment Growing Strategy

Once a fragment hit is confirmed in a crystal structure, the next step is fragment growing: adding atoms or small groups to the fragment while keeping the core binding mode intact. Growing is guided by exit vectors (bonds pointing into unexplored protein sub-pockets) identified from the crystal structure.

from rdkit import Chem
from rdkit.Chem import AllChem, draw
from rdkit.Chem.Scaffolds import MurckoScaffold

def enumerate_exit_vectors(smiles: str) -> list[int]:
    """
    Enumerate candidate exit vectors (attachment points) on a fragment.
    Exit vectors are atoms bonded to H that point outward from the ring system.

    Returns list of heavy atom indices suitable for growing.
    """
    mol = Chem.MolFromSmiles(smiles)
    mol = Chem.AddHs(mol)
    AllChem.EmbedMolecule(mol, AllChem.ETKDGv3())
    mol = Chem.RemoveHs(mol)

    exit_vectors = []
    for atom in mol.GetAtoms():
        # Sp3 or ring atom with at least one implicit/explicit H
        if atom.GetTotalNumHs() > 0 and atom.GetAtomicNum() > 1:
            exit_vectors.append(atom.GetIdx())
    return exit_vectors


def grow_fragment(
    fragment_smiles: str,
    building_block_smiles: str,
    attachment_idx: int,
) -> list[str]:
    """
    Grow a fragment by attaching a building block at the specified atom index.
    Uses RDKit's cross-coupling reaction template approach.

    Returns list of product SMILES.
    """
    # Convert attachment point to a marked amine for coupling
    fragment_mol = Chem.MolFromSmiles(fragment_smiles)
    bb_mol = Chem.MolFromSmiles(building_block_smiles)
    if fragment_mol is None or bb_mol is None:
        return []

    # Amide coupling reaction SMARTS
    reaction_smarts = "[C:1](=O)[OH:2].[NH2:3][C:4]>>[C:1](=O)[NH:3][C:4]"
    rxn = AllChem.ReactionFromSmarts(reaction_smarts)
    products = rxn.RunReactants((fragment_mol, bb_mol))
    result = []
    seen = set()
    for prod_set in products:
        for prod in prod_set:
            try:
                smi = Chem.MolToSmiles(prod)
                if smi not in seen:
                    seen.add(smi)
                    result.append(smi)
            except Exception:
                continue
    return result

Fragment Linking

When two fragments bind simultaneously to adjacent sub-pockets, they can be connected by a linker. Fragment linking is geometrically constrained: the linker must be short enough that the fragments don't have to move from their bound positions, and the exit vectors must be roughly co-linear with the linker geometry.

from rdkit import Chem
from rdkit.Chem import AllChem
import numpy as np

def estimate_linking_geometry(
    frag1_exit_coords: np.ndarray,
    frag2_exit_coords: np.ndarray,
) -> dict:
    """
    Estimate minimum linker length and geometry for linking two fragments.

    Args:
        frag1_exit_coords: 3D coordinates of exit atom in fragment 1
        frag2_exit_coords: 3D coordinates of exit atom in fragment 2

    Returns:
        Dict with distance, suggested linker atom count, and geometry type
    """
    distance = np.linalg.norm(frag1_exit_coords - frag2_exit_coords)

    # Approximate: each C-C bond adds ~1.5 Å, each C=C ~1.33 Å
    # Rule of thumb: linker_atoms ≈ round(distance / 1.5) - 1
    linker_atoms = max(1, round(distance / 1.5) - 1)

    if distance < 4.0:
        geometry_type = "direct_bond_or_1-atom_linker"
    elif distance < 7.0:
        geometry_type = "short_chain_2-3_atoms"
    elif distance < 10.0:
        geometry_type = "medium_chain_4-6_atoms"
    else:
        geometry_type = "long_linker_or_fragment_merge"

    return {
        "exit_vector_distance_A": round(distance, 2),
        "suggested_linker_atoms": linker_atoms,
        "geometry_type": geometry_type,
        "note": "Rigid linkers (phenyl, alkyne) preferred for <6 Å; flexible for >8 Å"
    }


# Practical linker options by distance range
LINKER_LIBRARY = {
    "1-atom": ["C", "O", "N", "S"],
    "2-atom": ["CC", "CO", "CN", "OC"],
    "3-atom": ["CCC", "C=CC", "COC", "CNC", "C1CC1"],
    "4-atom": ["CCCC", "CC=CC", "c1ccc1", "CCOC"],
    "5-atom": ["c1cccc1", "CCCCC", "CC(C)CC"],
}

Practical FBDD Workflow Summary

StageGoalComputational Tool
Library designBuild 1,000–2,000 fragment libraryZINC, Enamine REAL, Ro3 filter
Primary screenDocking / virtual screen to identify site bindersVina, Glide, SMINA with LE-rescoring
Hit confirmationStructural confirmation of binding modeX-ray, SPR, NMR (experimental)
Fragment growingExtend into unexplored sub-pocketsFragmenstein, STRIFE, RDKit grow
Fragment mergingCombine overlapping fragmentsFBDD-ML, manual medicinal chemistry
Fragment linkingConnect fragments on adjacent pocketsLINK, DeLinker, geometry analysis
Lead optimisationOptimise for potency, selectivity, ADMETFull docking pipeline + ADMET models

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)