Blood-Brain Barrier Penetration Prediction

By Eldar · Published August 20, 2026

Binary classification model predicting BBB penetration from molecular SMILES using physicochemical descriptors, fingerprints, and machine learning with scaffold-based train/test split.

  • drug-discovery
  • molecular-properties
  • classification
  • rdkit
  • cheminformatics
16 cells3 experiments5 views0 forks

Inside this notebook

# Blood–Brain Barrier Penetration from SMILES **Task.** Binary classification: given a molecule's SMILES string, predict whether it penetrates the blood–brain barrier (`p_np` = 1) or not (`p_np` = 0). **Data.** [MoleculeNet BBBP](https://arxiv.org/abs/1703.00564) — 2,053 raw rows (`num, name, p_np, smiles`) pulled from the DeepChem S3 bucket, with the HuggingFace mirror `scikit-fingerprints/MoleculeNet_BBBP` as fallback. The label is heavily skewed (**≈76% penetrating / 24% non-penetrating**), so accuracy alone is misleading — AUROC and F1 are the headline metrics. Roughly 14 SMILES fail RDKit sanitization and are dropped, which is why published work reports ~2,039 usable molecules. **Protocol.** Clean → canonicalize → dedupe → **Bemis–Murcko scaffold split (80/10/10)** → define a *single* shared eval harness → **then** branch and train. Scaffold splitting means the test set contains molecular frameworks the model has never seen, which is far harder (and far more honest) than a random split. **Two modelling arms, forked in parallel:** | Branch | Approach | |---|---| | **A** | Fine-tune [`DeepChem/ChemBERTa-10M-MLM`](https://huggingface.co/DeepChem/ChemBERTa-10M-MLM) — a small RoBERTa SMILES encoder (hidden 384, 3 layers, vocab 600) — via `AutoModelForSequenceClassification` with class-weighted loss | | **B** | RDKit **Morgan/ECFP4** fingerprints (radius 2, 2048 bits) → **XGBoost** (`scale_pos_weight`) plus a regularized **logistic-regression** reference | Both arms are…

# === Setup: dependencies, seeds, versions ===
import subprocess, sys, importlib, os

# This sandbox ships PYTHONPATH="/pkg/:/root/" and HOME=/root, but /root is
# unreadable as uid 1000 -> pip's environment scan dies with PermissionError.
# Sanitize the env for any pip subprocess.
PIP_ENV = {**os.environ, "HOME": "/home/user", "PYTHONPATH": "/pkg"}

def ensure(pkg, import_name=None):
    """Install `pkg` only if it isn't importable. Returns True if installed."""
    name = import_name or pkg.split("==")[0].replace("-", "_")
    try:
        importlib.import_module(name)
        return False
    except ImportError:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg], env=PIP_ENV)
        importlib.invalidate_caches()
        return True
…
Pinned environment
  python         3.11.13
  numpy          2.1.2
  pandas         2.2.3
  rdkit          2026.03.5
  scikit-learn   1.5.2
  xgboost        3.1.3
  torch          2.7.1+cu128
  transformers   5.13.1
  plotly         5.24.1

device = cuda   |   seed = 42
# === Load MoleculeNet BBBP ===
S3_URL = "https://deepchemdata.s3.us-west-1.amazonaws.com/datasets/BBBP.csv"
HF_MIRROR = "scikit-fingerprints/MoleculeNet_BBBP"

source_used = None
try:
    raw = pd.read_csv(S3_URL)
    source_used = f"DeepChem S3 ({S3_URL})"
except Exception as e:
    print(f"S3 fetch failed ({type(e).__name__}: {e}) — falling back to HF mirror")
    from datasets import load_dataset
    raw = load_dataset(HF_MIRROR, split="train").to_pandas()
    raw = raw.rename(columns={"SMILES": "smiles", "label": "p_np"})
    source_used = f"HuggingFace mirror ({HF_MIRROR})"

print(f"source      : {source_used}")
print(f"shape       : {raw.shape}")
print(f"columns     : {list(raw.columns)}")
…
source      : DeepChem S3 (https://deepchemdata.s3.us-west-1.amazonaws.com/datasets/BBBP.csv)
shape       : (2050, 4)
columns     : ['num', 'name', 'p_np', 'smiles']

dtypes:
num        int64
name      object
p_np       int64
smiles    object

class balance (raw):
  non-penetrant (0)      483    23.6%
  penetrant (1)         1567    76.4%
  imbalance ratio       3.24 : 1

unique canonical-input SMILES strings: 2050 of 2050
# === Clean: RDKit sanitize -> canonicalize -> dedupe (BEFORE any splitting) ===
from rdkit import Chem

audit = []          # row-by-row record of what we drop and why
n0 = len(raw)
audit.append(("raw rows from source", n0, ""))

df = raw.copy()
df["mol"] = df["smiles"].apply(Chem.MolFromSmiles)

bad_mask = df["mol"].isna()
bad_smiles = df.loc[bad_mask, ["num", "name", "smiles"]]
df = df.loc[~bad_mask].copy()
audit.append(("dropped: RDKit could not sanitize", -int(bad_mask.sum()), "invalid valence / unparseable"))

# canonical (isomeric) SMILES — the string both branches will actually consume
df["canonical_smiles"] = df["mol"].apply(lambda m: Chem.MolToSmiles(m, isomericSmiles=True))

…
step    n                          note
                             raw rows from source 2050                              
                dropped: RDKit could not sanitize  -11 invalid valence / unparseable
dropped: duplicate SMILES with CONFLICTING labels  -20       10 ambiguous structures
          dropped: duplicate SMILES, label agreed  -54         kept first occurrence
                dropped: fewer than 3 heavy atoms   -1      not a drug-like molecule
                            FINAL c…
# === Physicochemical descriptors (interpretable CNS-permeability axes) ===
from rdkit.Chem import Descriptors, Crippen, QED, rdMolDescriptors as rdmd

DESCRIPTORS = {
    "MolWt":     Descriptors.MolWt,
    "cLogP":     Crippen.MolLogP,
    "TPSA":      Descriptors.TPSA,
    "HBD":       Descriptors.NumHDonors,
    "HBA":       Descriptors.NumHAcceptors,
    "RotB":      Descriptors.NumRotatableBonds,
    "AromRings": rdmd.CalcNumAromaticRings,
    "HeavyAtoms": lambda m: m.GetNumHeavyAtoms(),
    "FracCSP3":  rdmd.CalcFractionCSP3,
    "QED":       QED.qed,
    "MolMR":     Crippen.MolMR,
    "FormalCharge": lambda m: Chem.GetFormalCharge(m),
}

…
computed 12 descriptors for 1964 molecules in 4.8s

Descriptor means by class, ranked by rank-biserial effect size:
  descriptor   mean_pen  mean_non  effect_size_rb    p_value
        TPSA      54.16     128.4         -0.6665 7.966e-105
         HBD      1.059     3.211         -0.6219  1.626e-98
         HBA      3.624      7.28         -0.5643  2.006e-77
         QED     0.6743    0.4542          0.5177  5.627e-64
       MolWt      317.7     441.3          -0.418  2.433e-42
       cLogP…
# === EDA: class balance, descriptor separation, correlation, CNS window ===
KEY = ["TPSA", "HBD", "HBA", "QED", "MolWt", "cLogP"]

fig = make_subplots(
    rows=3, cols=3,
    specs=[[{"type": "domain"}, {"type": "xy"}, {"type": "xy"}],
           [{"type": "xy"}, {"type": "xy"}, {"type": "xy"}],
           [{"type": "xy"}, {"type": "heatmap", "colspan": 2}, None]],
    subplot_titles=("Class balance", *[f"<b>{k}</b>" for k in KEY],
                    "TPSA vs cLogP density", "Descriptor correlation (Spearman)"),
    vertical_spacing=0.11, horizontal_spacing=0.09,
)

# (1) donut
vc = df["label_str"].value_counts()
fig.add_trace(go.Pie(labels=vc.index, values=vc.values, hole=0.58,
                     marker=dict(colors=[PALETTE[l] for l in vc.index],
                                 line=dict(color="white", width=2)),
…
# === 3D chemical space: ECFP4 PCA and the physicochemical CNS box ===
from rdkit.Chem import rdFingerprintGenerator
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

FP_RADIUS, FP_BITS = 2, 2048
mfpgen = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS)

def ecfp_matrix(mols):
    return np.array([mfpgen.GetFingerprintAsNumPy(m) for m in mols], dtype=np.uint8)

t0 = time.time()
FP_ALL = ecfp_matrix(df["mol"].tolist())
print(f"ECFP{2*FP_RADIUS} bit matrix {FP_ALL.shape} in {time.time()-t0:.1f}s "
      f"| mean bits set = {FP_ALL.sum(1).mean():.1f} | density = {FP_ALL.mean():.3%}")

pca = PCA(n_components=3, random_state=SEED).fit(FP_ALL.astype(np.float32))
P3 = pca.transform(FP_ALL.astype(np.float32))
…
ECFP4 bit matrix (1964, 2048) in 0.1s | mean bits set = 43.0 | density = 2.099%
PCA-3 explained variance: 5.1%, 3.0%, 2.7% (total 10.9%)
Blood-Brain Barrier Penetration Prediction | Clusy