Home▸Articles▸Screening

DNA-Encoded Library (DEL) Analysis with ML

DNA-encoded libraries enable affinity selection of billions of compounds in a single experiment, with compound identity decoded by next-generation sequencing. The resulting high-dimensional count data requires statistical normalisation, enrichment scoring, synthon-level SAR deconvolution, and ML models trained on DEL data to generalise beyond the enumerated library space to off-DNA compounds.

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

DEL technology uses split-and-pool combinatorial synthesis where each compound is covalently tagged with a unique DNA barcode encoding the building blocks used. Libraries routinely contain 108–1012 compounds. After affinity selection against an immobilised target, surviving compounds are identified by Illumina sequencing of the DNA tags. The readout is compound counts, not activities — statistical enrichment over negative control determines hits.

The key ML opportunity: train a model on DEL count data to predict activity for "off-DNA" compounds (the DNA tag is removed before testing). This vastly expands the actionable hit space beyond the subset of DEL compounds that can be practically resynthesised.

DEL Data Processing and Enrichment Scoring

import numpy as np
import pandas as pd
from scipy import stats


def compute_enrichment_score(
    selection_counts: np.ndarray,
    control_counts: np.ndarray,
    pseudocount: float = 0.5,
    normalise_to_million: bool = True,
) -> np.ndarray:
    """
    Compute enrichment score for each compound in a DEL selection.

    Enrichment = log2((CPM_selection + ε) / (CPM_control + ε))

    Args:
        selection_counts: Raw read counts per compound in target selection
        control_counts: Raw read counts per compound in no-target control
        pseudocount: Added before division to handle zero counts
        normalise_to_million: Whether to normalise to counts-per-million (CPM)

    Returns:
        Log2 enrichment scores for each compound.
    """
    sel = selection_counts.astype(float)
    ctrl = control_counts.astype(float)

    if normalise_to_million:
        sel = sel / sel.sum() * 1e6
        ctrl = ctrl / ctrl.sum() * 1e6

    enrichment = np.log2((sel + pseudocount) / (ctrl + pseudocount))
    return enrichment


def flag_del_hits(
    enrichment_scores: np.ndarray,
    compound_ids: list[str],
    zscore_threshold: float = 3.0,
    min_selection_count: int = 5,
    selection_counts: np.ndarray | None = None,
) -> pd.DataFrame:
    """
    Identify statistically enriched DEL hits using Z-score thresholding.

    Args:
        enrichment_scores: log2 enrichment scores
        compound_ids: Compound identifiers (barcode decoded)
        zscore_threshold: Number of SDs above mean for hit calling
        min_selection_count: Minimum raw selection reads to be considered
        selection_counts: Raw counts for minimum count filter

    Returns:
        DataFrame of hit compounds with enrichment and Z-score.
    """
    mean_e = enrichment_scores.mean()
    std_e = enrichment_scores.std()
    zscores = (enrichment_scores - mean_e) / (std_e + 1e-9)

    df = pd.DataFrame({
        "compound_id": compound_ids,
        "enrichment_log2": enrichment_scores,
        "zscore": zscores,
    })

    if selection_counts is not None:
        df["selection_count"] = selection_counts
        df = df[df["selection_count"] >= min_selection_count]

    hits = df[df["zscore"] >= zscore_threshold].sort_values("enrichment_log2", ascending=False)
    return hits.reset_index(drop=True)

Synthon-Level SAR Analysis

def synthon_sar_table(
    del_data: pd.DataFrame,
    enrichment_col: str = "enrichment_log2",
    synthon_cols: list[str] = None,
    min_observations: int = 5,
) -> dict[str, pd.DataFrame]:
    """
    Compute per-synthon mean enrichment to identify productive building blocks.

    In a tri-synthon library (3 cycles of split-and-pool), each compound is
    A–linker–B–linker–C. Averaging enrichment over all compounds containing
    synthon A_i reveals its individual contribution.

    Args:
        del_data: DataFrame with enrichment scores and synthon ID columns
        enrichment_col: Column name for enrichment values
        synthon_cols: List of column names for each synthon cycle (e.g. ['bb1', 'bb2', 'bb3'])
        min_observations: Minimum compound count per synthon to report

    Returns:
        Dict mapping synthon_col → ranked DataFrame of synthon SAR.
    """
    if synthon_cols is None:
        synthon_cols = [c for c in del_data.columns if c.startswith("bb") or c.startswith("synthon")]

    results = {}
    for col in synthon_cols:
        synthon_summary = (
            del_data.groupby(col)[enrichment_col]
            .agg(
                mean_enrichment="mean",
                median_enrichment="median",
                n_compounds="count",
                max_enrichment="max",
            )
            .reset_index()
            .rename(columns={col: "synthon_id"})
            .query(f"n_compounds >= {min_observations}")
            .sort_values("mean_enrichment", ascending=False)
        )
        results[col] = synthon_summary

    return results

Off-DNA Activity Prediction

import numpy as np
from rdkit import Chem
from rdkit.Chem import AllChem
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline


def build_del_activity_model(
    smiles_list: list[str],
    enrichment_scores: list[float],
    binary_threshold: float = 3.0,
    task: str = "regression",
) -> dict:
    """
    Train a model on DEL enrichment data for off-DNA activity prediction.

    Critical consideration: DEL data is noisy count data, not IC50.
    Binarise at a Z-score threshold for classification, or use log2
    enrichment directly for regression.

    Args:
        smiles_list: SMILES for each DEL compound (synthon-reconstructed)
        enrichment_scores: log2 enrichment scores
        binary_threshold: Z-score/enrichment cutoff for positives (regression only)
        task: 'regression' (predict enrichment) or 'classification' (hit/no-hit)

    Returns:
        Dict with model and validation metrics.
    """
    fps, valid_scores = [], []
    for smi, score in zip(smiles_list, enrichment_scores):
        mol = Chem.MolFromSmiles(smi)
        if mol is not None:
            fp = list(AllChem.GetMorganFingerprintAsBitVect(mol, 2, 2048))
            fps.append(fp)
            valid_scores.append(score)

    X = np.array(fps)
    y = np.array(valid_scores)

    if task == "classification":
        from sklearn.ensemble import RandomForestClassifier
        from sklearn.metrics import roc_auc_score
        y_binary = (y >= binary_threshold).astype(int)
        model = Pipeline([
            ("clf", RandomForestClassifier(n_estimators=500, class_weight="balanced", random_state=42, n_jobs=-1))
        ])
        cv_scores = cross_val_score(model, X, y_binary, cv=5, scoring="roc_auc", n_jobs=-1)
        model.fit(X, y_binary)
        return {
            "model": model, "task": "classification",
            "cv_roc_auc": round(cv_scores.mean(), 3),
            "cv_roc_auc_std": round(cv_scores.std(), 3),
            "positive_rate": round(y_binary.mean(), 3),
        }
    else:
        model = Pipeline([
            ("scaler", StandardScaler()),
            ("gbr", GradientBoostingRegressor(n_estimators=400, max_depth=4, learning_rate=0.05, subsample=0.8, random_state=42)),
        ])
        cv_scores = cross_val_score(model, X, y, cv=5, scoring="neg_mean_absolute_error", n_jobs=-1)
        model.fit(X, y)
        return {
            "model": model, "task": "regression",
            "cv_mae_log2_enrichment": round(-cv_scores.mean(), 3),
            "description": "Predict log2 enrichment for off-DNA compounds",
        }

DEL Technology Comparison

Library TypeSize RangeTypical CyclesOutputKey Limitation
3-cycle split-and-pool DEL107–10103 (A×B×C)Sequencing count matrixRequires linker-compatible chemistry
Encoded self-assembling chemical (ESAC)108–10122 (pairing)Fragment pair enrichmentFragment pairs, not covalent compounds
YoctoReactor DEL (Vipergen)108–1093Solution-phase selectionComplex implementation
DEL-ML (ML-augmented)1012+Virtual + DEL-trained modelPredicted enrichment scoresModel generalisation to novel scaffolds

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)