Home▸Articles▸Deep Learning

Chemical Language Models for Drug Discovery

How BERT-style transformers pre-trained on millions of SMILES strings learn rich molecular representations that outperform fingerprints on low-data property prediction tasks. Covers ChemBERTa, MolBERT, tokenization strategies, masked SMILES pre-training, and Hugging Face fine-tuning workflows.

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

The transformer revolution in NLP — starting with BERT and GPT — showed that models pre-trained on vast amounts of text with a simple self-supervised objective learn representations that transfer powerfully to downstream tasks with very little labelled data. The same idea applies to molecules. SMILES strings are a linear encoding of molecular structure, and a transformer pre-trained to reconstruct masked SMILES tokens learns to capture chemical semantics: atoms, bonds, ring systems, functional groups, and even physicochemical trends.

Chemical language models (ChemLMs) are particularly valuable in drug discovery because labelled data is scarce and expensive. A model pre-trained on 77 million ChEMBL or ZINC SMILES can be fine-tuned on 500 labelled activity measurements and outperform Morgan fingerprint models trained on the same 500 points.

Why SMILES as a Language?

SMILES (Simplified Molecular-Input Line-Entry System) encodes a molecular graph as a string of characters. From a language modelling perspective:

A transformer can learn both: it learns which token sequences are grammatically valid (SMILES syntax) through masked token prediction, and it implicitly captures chemical semantics through the co-occurrence patterns of functional groups in the training corpus.

Key Pre-trained Models

Model Architecture Pre-training Data Pre-training Objective HuggingFace
ChemBERTa RoBERTa-small 77M ZINC SMILES Masked Language Model (MLM) seyonec/ChemBERTa-zinc-base-v1
ChemBERTa-2 RoBERTa-medium 77M ZINC + MTR MLM + Multi-Task Regression seyonec/ChemBERTa-zinc-base-v2
MolBERT BERT-base 1.6M ChEMBL SMILES MLM + Property prediction jasonwew98/molbert
Chemformer BART (seq2seq) 100M ZINC + USPTO SMILES reconstruction MolecularAI/chemformer-base-10M
MolT5 T5-small/base C4 + ChEMBL SMILES Text↔SMILES translation laituan245/molt5-small

Tokenization Strategies

SMILES tokenization is non-trivial. Naive character-level tokenization treats "Br" as two tokens "B"+"r", which is chemically wrong. The main strategies are:

import re

def atom_wise_tokenize(smiles: str) -> list[str]:
    """
    Tokenize a SMILES string at atom boundaries.
    Handles multi-character atoms (Br, Cl, Si, etc.) and
    bracket atoms ([NH], [C@@H], etc.) as single tokens.
    """
    PATTERN = r"(\[[^\]]+\]|Br|Cl|Si|Se|Na|Ca|Mg|[A-Z][a-z]?|%\d{2}|[bcnops]|[=#\-\+\\\/\.\(\)@%\d])"
    tokens = re.findall(PATTERN, smiles)
    return tokens


# Example
smi = "CC(=O)Oc1ccccc1C(=O)O"
tokens = atom_wise_tokenize(smi)
print("SMILES:", smi)
print("Tokens:", tokens)
# Output: ['C', 'C', '(', '=', 'O', ')', 'O', 'c', '1', 'c', 'c', ...]

Loading ChemBERTa with HuggingFace

from transformers import AutoTokenizer, AutoModel
import torch
import numpy as np

# Load pre-trained ChemBERTa
MODEL_NAME = "seyonec/ChemBERTa-zinc-base-v1"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModel.from_pretrained(MODEL_NAME)
model.eval()

def smiles_to_embedding(smiles: str, pooling: str = "cls") -> np.ndarray:
    """
    Encode a SMILES string into a fixed-size embedding vector using ChemBERTa.

    Args:
        smiles:  Input SMILES string
        pooling: 'cls' uses the [CLS] token embedding;
                 'mean' uses mean over all token embeddings

    Returns:
        numpy array of shape (hidden_size,)  — 384 for ChemBERTa-zinc-base-v1
    """
    inputs = tokenizer(smiles, return_tensors="pt", truncation=True, max_length=128)

    with torch.no_grad():
        outputs = model(**inputs)

    hidden_states = outputs.last_hidden_state  # shape: (1, seq_len, hidden_size)

    if pooling == "cls":
        embedding = hidden_states[:, 0, :]   # [CLS] token
    else:
        # Mean pool over non-padding tokens
        attention_mask = inputs["attention_mask"]  # shape: (1, seq_len)
        mask_expanded = attention_mask.unsqueeze(-1).float()
        embedding = (hidden_states * mask_expanded).sum(dim=1) / mask_expanded.sum(dim=1)

    return embedding.squeeze().numpy()


# Batch encoding
def encode_smiles_batch(smiles_list: list[str], batch_size: int = 64) -> np.ndarray:
    """Encode a list of SMILES into a 2D embedding matrix."""
    all_embeddings = []
    for i in range(0, len(smiles_list), batch_size):
        batch = smiles_list[i:i + batch_size]
        inputs = tokenizer(
            batch,
            return_tensors="pt",
            truncation=True,
            padding=True,
            max_length=128
        )
        with torch.no_grad():
            outputs = model(**inputs)
        # CLS token embeddings
        embeddings = outputs.last_hidden_state[:, 0, :].numpy()
        all_embeddings.append(embeddings)
    return np.vstack(all_embeddings)

Fine-Tuning for Property Prediction

Fine-tuning a pre-trained ChemBERTa on a labelled property dataset follows the same workflow as fine-tuning BERT on text classification, but uses SMILES as input instead of sentences.

import torch
import torch.nn as nn
from transformers import AutoTokenizer, AutoModel, get_linear_schedule_with_warmup
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import numpy as np
from sklearn.metrics import roc_auc_score

class MoleculeDataset(Dataset):
    """PyTorch dataset for SMILES + property prediction."""

    def __init__(self, smiles: list, labels: list, tokenizer, max_length: int = 128):
        self.smiles = smiles
        self.labels = torch.tensor(labels, dtype=torch.float32)
        self.tokenizer = tokenizer
        self.max_length = max_length

    def __len__(self):
        return len(self.smiles)

    def __getitem__(self, idx):
        encoding = self.tokenizer(
            self.smiles[idx],
            max_length=self.max_length,
            padding="max_length",
            truncation=True,
            return_tensors="pt",
        )
        return {
            "input_ids": encoding["input_ids"].squeeze(0),
            "attention_mask": encoding["attention_mask"].squeeze(0),
            "label": self.labels[idx],
        }


class ChemBERTaClassifier(nn.Module):
    """Binary classifier head on top of ChemBERTa."""

    def __init__(self, model_name: str, dropout: float = 0.1):
        super().__init__()
        self.encoder = AutoModel.from_pretrained(model_name)
        hidden_size = self.encoder.config.hidden_size
        self.classifier = nn.Sequential(
            nn.Dropout(dropout),
            nn.Linear(hidden_size, 256),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(256, 1),
        )

    def forward(self, input_ids, attention_mask):
        outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
        cls_embedding = outputs.last_hidden_state[:, 0, :]  # [CLS] token
        logit = self.classifier(cls_embedding).squeeze(-1)
        return logit


def train_one_epoch(model, loader, optimizer, scheduler, device):
    model.train()
    total_loss = 0
    criterion = nn.BCEWithLogitsLoss()
    for batch in loader:
        input_ids = batch["input_ids"].to(device)
        attention_mask = batch["attention_mask"].to(device)
        labels = batch["label"].to(device)

        optimizer.zero_grad()
        logits = model(input_ids, attention_mask)
        loss = criterion(logits, labels)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        scheduler.step()
        total_loss += loss.item()
    return total_loss / len(loader)


# --- Training Script ---
MODEL_NAME = "seyonec/ChemBERTa-zinc-base-v1"
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
EPOCHS = 10
LR = 2e-5
BATCH_SIZE = 32

df = pd.read_csv("activity_dataset.csv")   # columns: smiles, label (0/1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

# Scaffold split (use scaffold_split from scaffold-analysis guide)
from sklearn.model_selection import train_test_split
train_df, test_df = train_test_split(df, test_size=0.2, random_state=42)

train_ds = MoleculeDataset(train_df["smiles"].tolist(), train_df["label"].tolist(), tokenizer)
test_ds  = MoleculeDataset(test_df["smiles"].tolist(), test_df["label"].tolist(), tokenizer)
train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True)
test_loader  = DataLoader(test_ds, batch_size=BATCH_SIZE)

model = ChemBERTaClassifier(MODEL_NAME).to(DEVICE)
optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.01)
scheduler = get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps=100,
    num_training_steps=len(train_loader) * EPOCHS,
)

for epoch in range(1, EPOCHS + 1):
    train_loss = train_one_epoch(model, train_loader, optimizer, scheduler, DEVICE)
    print(f"Epoch {epoch}: train loss = {train_loss:.4f}")

# Evaluate
model.eval()
all_preds, all_labels = [], []
with torch.no_grad():
    for batch in test_loader:
        logits = model(batch["input_ids"].to(DEVICE), batch["attention_mask"].to(DEVICE))
        all_preds.extend(torch.sigmoid(logits).cpu().numpy())
        all_labels.extend(batch["label"].numpy())

auc = roc_auc_score(all_labels, all_preds)
print(f"Test ROC-AUC: {auc:.4f}")

Comparing ChemBERTa vs. Fingerprint Baselines

Method BBBP AUC HIV AUC BACE AUC Tox21 (mean AUC)
Morgan ECFP4 + RF 0.886 0.776 0.861 0.760
Morgan ECFP4 + XGB 0.893 0.795 0.873 0.771
ChemBERTa (fine-tuned) 0.905 0.812 0.886 0.788
MolBERT (fine-tuned) 0.911 0.819 0.892 0.796
AttentiveFP (GNN) 0.908 0.827 0.863 0.812

ChemLMs are most advantageous when: (1) training sets are very small (<1000 compounds), (2) scaffold diversity is high, and (3) the property is governed by global molecular features rather than local substructures. For larger datasets (>10,000 compounds), GNNs (AttentiveFP, MPNN) often match or exceed ChemLM performance.

Using ChemBERTa Embeddings as Frozen Features

Even without fine-tuning, ChemBERTa CLS embeddings are powerful molecular features. They can be used as drop-in replacements for Morgan fingerprints in any downstream model:

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold

# Load pre-computed ChemBERTa embeddings (save with encode_smiles_batch)
df = pd.read_csv("dataset.csv")   # smiles, label
X = encode_smiles_batch(df["smiles"].tolist())   # shape: (n, 384)
y = df["label"].values

# 5-fold CV with frozen ChemBERTa + RF
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
auc_scores = []
for train_idx, test_idx in cv.split(X, y):
    clf = RandomForestClassifier(n_estimators=300, n_jobs=-1, random_state=42)
    clf.fit(X[train_idx], y[train_idx])
    preds = clf.predict_proba(X[test_idx])[:, 1]
    auc_scores.append(roc_auc_score(y[test_idx], preds))

print(f"ChemBERTa + RF: AUC = {np.mean(auc_scores):.3f} ± {np.std(auc_scores):.3f}")

Multi-Task Fine-Tuning

Multi-task fine-tuning is particularly powerful for chemical language models when you have several related tasks (e.g., ADMET endpoints) with overlapping training compounds. A shared ChemBERTa encoder with task-specific heads can leverage data across all tasks simultaneously:

class MultiTaskChemBERTa(nn.Module):
    """
    Shared ChemBERTa encoder with multiple prediction heads.
    Each head can be regression or classification.
    """

    def __init__(self, model_name: str, task_names: list[str], dropout: float = 0.1):
        super().__init__()
        self.encoder = AutoModel.from_pretrained(model_name)
        hidden_size = self.encoder.config.hidden_size
        self.dropout = nn.Dropout(dropout)
        self.heads = nn.ModuleDict({
            task: nn.Linear(hidden_size, 1)
            for task in task_names
        })

    def forward(self, input_ids, attention_mask, task: str = None):
        outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
        cls_emb = self.dropout(outputs.last_hidden_state[:, 0, :])
        if task is not None:
            return self.heads[task](cls_emb).squeeze(-1)
        return {task: self.heads[task](cls_emb).squeeze(-1) for task in self.heads}


# Example: ADMET multitask model
tasks = ["solubility", "permeability", "metabolic_stability", "herg_inhibition"]
model = MultiTaskChemBERTa("seyonec/ChemBERTa-zinc-base-v1", tasks)

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)