Home▸Articles▸Bioactivity

Multi-Target Drug Design

How to design compounds that intentionally modulate multiple biological targets (polypharmacology). Covers curating ChEMBL multi-target activity data, training multi-task GNNs, computing selectivity fingerprints, multi-output XGBoost, and Pareto-optimal compound prioritisation for desired target activity profiles.

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

Polypharmacology — the deliberate design of compounds that engage multiple targets simultaneously — offers therapeutic advantages over single-target drugs for complex diseases such as cancer, neurodegeneration, and infection. A compound might need to inhibit kinase A while avoiding kinase B (selectivity), or intentionally inhibit both kinase A and protease C to overcome resistance mechanisms. ML is well-suited to learning these multi-target profiles from large ChEMBL bioactivity datasets.

This guide covers the full practical pipeline: curating clean multi-target data from ChEMBL, training single-task baselines, multi-task GNNs, and selectivity fingerprints, and prioritising compound sets using Pareto optimisation of the activity profile vector.

Data Curation from ChEMBL

import pandas as pd
import numpy as np
from chembl_webresource_client.new_client import new_client

def fetch_multitarget_data(
    target_chembl_ids: list[str],
    standard_types: list[str] = ["IC50", "Ki", "Kd"],
    pchembl_min: float = 5.0,  # pIC50 ≥ 5 means IC50 ≤ 10 μM
    n_per_target: int = 5000,
) -> pd.DataFrame:
    """
    Fetch bioactivity data for multiple targets from ChEMBL.

    Args:
        target_chembl_ids: List of ChEMBL target IDs (e.g. ['CHEMBL279', 'CHEMBL340'])
        standard_types: Measurement types to include
        pchembl_min: Minimum pCHEMBL value (excludes very weak binders)
        n_per_target: Max compounds per target

    Returns:
        Combined DataFrame with columns: smiles, target_id, pchembl_value, label
    """
    activity = new_client.activity
    records = []
    for target_id in target_chembl_ids:
        results = activity.filter(
            target_chembl_id=target_id,
            standard_type__in=standard_types,
            pchembl_value__gte=pchembl_min,
        ).only(
            "molecule_chembl_id", "canonical_smiles",
            "standard_type", "pchembl_value", "target_chembl_id"
        )[:n_per_target]

        for r in results:
            if r.get("canonical_smiles") and r.get("pchembl_value"):
                records.append({
                    "smiles": r["canonical_smiles"],
                    "target_id": r["target_chembl_id"],
                    "standard_type": r["standard_type"],
                    "pchembl_value": float(r["pchembl_value"]),
                    "label": 1 if float(r["pchembl_value"]) >= pchembl_min else 0,
                })
    return pd.DataFrame(records)


def build_multitarget_matrix(
    df: pd.DataFrame,
    targets: list[str],
    active_threshold: float = 6.5,
) -> pd.DataFrame:
    """
    Pivot the long-format data into a compound × target activity matrix.

    Values are pCHEMBL mediated by median; NaN for missing data.
    Binary activity labels computed at active_threshold.

    Returns:
        DataFrame indexed by SMILES with columns for each target's pchembl + binary label.
    """
    # Aggregate: take median pCHEMBL per (smiles, target) pair
    agg = df.groupby(["smiles", "target_id"])["pchembl_value"].median().reset_index()
    matrix = agg.pivot(index="smiles", columns="target_id", values="pchembl_value")
    matrix.columns.name = None
    # Add binary columns
    for t in targets:
        if t in matrix.columns:
            matrix[f"{t}_active"] = (matrix[t] >= active_threshold).astype(int)
    return matrix.reset_index()

Multi-Task GNN Training

A multi-task GNN shares a common molecular representation encoder across all targets, using separate output heads per target. Shared representations improve generalisation for data-sparse targets (a form of transfer learning between pharmacologically related targets).

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.data import Data, Batch
from torch_geometric.nn import GCNConv, global_mean_pool
from typing import Optional

class MultiTaskGNN(nn.Module):
    """
    Multi-task Graph Neural Network for polypharmacology.

    Arguments:
        in_node_features: Atom feature dimensionality
        hidden_dim: Hidden GNN layer dimension
        n_layers: Number of GCN layers
        targets: List of target names (one output head per target)
        dropout: Dropout rate
    """
    def __init__(
        self,
        in_node_features: int = 74,
        hidden_dim: int = 256,
        n_layers: int = 3,
        targets: list[str] = ["target_1", "target_2"],
        dropout: float = 0.2,
    ):
        super().__init__()
        self.targets = targets
        self.dropout = dropout

        # Shared encoder: GCN layers
        self.convs = nn.ModuleList()
        self.bns = nn.ModuleList()
        in_dim = in_node_features
        for _ in range(n_layers):
            self.convs.append(GCNConv(in_dim, hidden_dim))
            self.bns.append(nn.BatchNorm1d(hidden_dim))
            in_dim = hidden_dim

        # Per-target output heads (regression: predict pCHEMBL)
        self.heads = nn.ModuleDict({
            t: nn.Sequential(
                nn.Linear(hidden_dim, 128),
                nn.ReLU(),
                nn.Dropout(dropout),
                nn.Linear(128, 1),
            )
            for t in targets
        })

    def encode(self, x, edge_index, batch):
        for conv, bn in zip(self.convs, self.bns):
            x = conv(x, edge_index)
            x = bn(x)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)
        return global_mean_pool(x, batch)  # (batch_size, hidden_dim)

    def forward(
        self,
        x: torch.Tensor,
        edge_index: torch.Tensor,
        batch: torch.Tensor,
    ) -> dict[str, torch.Tensor]:
        h = self.encode(x, edge_index, batch)
        return {t: self.heads[t](h).squeeze(-1) for t in self.targets}


def multitask_loss(
    predictions: dict[str, torch.Tensor],
    labels: dict[str, torch.Tensor],
    masks: dict[str, torch.Tensor],
    task_weights: Optional[dict[str, float]] = None,
) -> torch.Tensor:
    """
    Compute masked multi-task MSE loss. Missing labels (NaN) are masked out.

    Args:
        predictions: {target: tensor of shape (batch,)}
        labels: {target: tensor of shape (batch,)} — may contain NaN
        masks: {target: bool tensor, True where label is available}
        task_weights: Optional per-task loss weights (default uniform)

    Returns:
        Scalar total loss
    """
    total_loss = 0.0
    n_tasks_with_data = 0
    for target in predictions:
        mask = masks[target]
        if mask.sum() == 0:
            continue
        pred = predictions[target][mask]
        true = labels[target][mask]
        task_loss = F.mse_loss(pred, true)
        weight = task_weights[target] if task_weights else 1.0
        total_loss = total_loss + weight * task_loss
        n_tasks_with_data += 1
    return total_loss / max(n_tasks_with_data, 1)

Selectivity Fingerprints

A selectivity fingerprint encodes the predicted activity profile of a molecule across a panel of targets. It can be used as a feature for clustering compounds by their polypharmacology profile, or as a constraint in generative design ("design compounds that look like this selectivity pattern").

import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

def compute_selectivity_fingerprint(
    activity_vector: np.ndarray,
    target_names: list[str],
    active_threshold: float = 6.5,
) -> dict:
    """
    Convert a raw pCHEMBL activity vector into a selectivity fingerprint.

    Args:
        activity_vector: pCHEMBL predicted values for each target (NaN = unknown)
        target_names: Target names corresponding to each position
        active_threshold: pCHEMBL threshold for binary active/inactive

    Returns:
        Dict with binary fingerprint, selectivity ratio, and top active targets
    """
    binary = (activity_vector >= active_threshold).astype(int)
    active_mask = ~np.isnan(activity_vector) & binary.astype(bool)

    n_active = int(binary[~np.isnan(activity_vector)].sum())
    n_measured = int(~np.isnan(activity_vector)).sum()

    # Selectivity ratio: 1 means perfectly selective (1 of N targets),
    # n_measured means pan-active across all measured targets
    active_targets = [target_names[i] for i in np.where(active_mask)[0]]

    # Gini-style selectivity coefficient (Bento et al.)
    valid = activity_vector[~np.isnan(activity_vector)]
    if len(valid) > 1:
        sorted_vals = np.sort(valid) / valid.sum() if valid.sum() > 0 else valid
        n = len(sorted_vals)
        gini = (2 * np.sum((np.arange(1, n+1)) * sorted_vals) - (n + 1)) / n
    else:
        gini = float("nan")

    return {
        "binary_fp": binary.tolist(),
        "n_active": n_active,
        "n_measured": n_measured,
        "selectivity_ratio": round(n_active / n_measured, 3) if n_measured > 0 else None,
        "gini_selectivity": round(float(gini), 3) if not np.isnan(gini) else None,
        "active_targets": active_targets,
    }


def selectivity_profile_distance(
    fp1: np.ndarray,
    fp2: np.ndarray,
) -> float:
    """Cosine distance between two selectivity fingerprints."""
    sim = cosine_similarity(fp1.reshape(1, -1), fp2.reshape(1, -1))[0, 0]
    return 1.0 - float(sim)

Pareto Optimisation for Target Profile Selection

import numpy as np
from typing import Optional

def is_pareto_dominated(point: np.ndarray, candidates: np.ndarray) -> bool:
    """
    Check if a point is Pareto dominated by any candidate.
    Assumes maximisation of all objectives.

    A point is dominated if there exists another candidate that is
    better in at least one objective and at least as good in all others.
    """
    for candidate in candidates:
        if np.all(candidate >= point) and np.any(candidate > point):
            return True
    return False


def pareto_front(
    objective_matrix: np.ndarray,
    target_names: Optional[list[str]] = None,
) -> np.ndarray:
    """
    Compute the Pareto front (non-dominated set) for multi-objective optimisation.

    Args:
        objective_matrix: Shape (n_compounds, n_objectives).
                          Each column is an objective to maximise.
        target_names: Optional names for objectives

    Returns:
        Boolean mask of shape (n_compounds,) marking Pareto-optimal compounds.
    """
    n = len(objective_matrix)
    is_pareto = np.ones(n, dtype=bool)
    for i in range(n):
        if not is_pareto[i]:
            continue
        others = objective_matrix[is_pareto]
        if is_pareto_dominated(objective_matrix[i], others):
            is_pareto[i] = False
    return is_pareto


# Example: select compounds that are active on targets A and B
# but NOT active on off-target C (selectivity constraint)
def design_selectivity_filter(
    predicted_pchembl: np.ndarray,    # shape (n_compounds, n_targets)
    target_names: list[str],
    desired_targets: list[str],
    avoid_targets: list[str],
    desired_threshold: float = 6.5,   # pCHEMBL >= 6.5 = active
    avoid_threshold: float = 6.0,     # pCHEMBL < 6.0 = selective/inactive
) -> np.ndarray:
    """
    Filter compounds by desired multi-target activity profile.

    Args:
        predicted_pchembl: (n_compounds, n_targets) pCHEMBL predictions
        target_names: Names of all targets
        desired_targets: Must be active on these targets
        avoid_targets: Must be inactive on these targets

    Returns:
        Boolean mask of passing compounds.
    """
    n_cmpds = predicted_pchembl.shape[0]
    mask = np.ones(n_cmpds, dtype=bool)

    for target in desired_targets:
        col = target_names.index(target)
        mask &= (predicted_pchembl[:, col] >= desired_threshold)

    for target in avoid_targets:
        col = target_names.index(target)
        mask &= (predicted_pchembl[:, col] < avoid_threshold)

    return mask

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)