Part III: Discovery Through Data and Models
Chapter 28: Multimodal Scientific AI

28.2 Molecule-Text and Protein-Text Models

"Give me a SMILES string and I will give you a paragraph. Give me a paragraph and I will give you a molecule. Give me both and I will give you a drug candidate."

A Sequence-to-Sequence Model With Ambitions

Prerequisites

In the previous section you learned how InfoNCE loss aligns representations from two modalities into a shared embedding space. This section applies that machinery to specific scientific modality pairs: molecules with text and proteins with text. You should be comfortable with the contrastive alignment pipeline, projection heads, and retrieval metrics from Section 28.1. Familiarity with Simplified Molecular-Input Line-Entry System (SMILES) notation for molecular structures (introduced in Chapter 3) and protein sequence representation from Chapter 27 will help you follow the domain-specific examples.

The Big Picture

A chemist describes a molecule in words: "a selective COX-2 inhibitor with a sulfonamide group and low hepatotoxicity risk." A biologist describes a protein: "a serine/threonine kinase that phosphorylates MAPK in response to growth factor signaling." These descriptions encode expert knowledge that complements structural representations. Molecule-text models like MolT5 and MoleculeSTM learn bidirectional mappings between molecular structures (SMILES, graphs) and natural language. Protein-text models like ProteinChat and ESM3 do the same for protein sequences and structures. Together, these models enable a new paradigm: describe what you want in words and retrieve (or generate) the molecule or protein that matches. This section covers the architectures, training procedures, and hands-on pipelines for both modality pairs.

Before diving into each model, Figure 28.2 illustrates how text serves as a universal bridge modality. Separate models align molecules and proteins to text independently, and the overlapping text embedding space creates emergent cross-modal connections between molecules and proteins without requiring direct molecule-protein training pairs.

Molecules (SMILES, 2D graphs) Proteins (sequence, 3D structure) Text (natural language) Retrieve / Generate Predict / Design MolT5 MoleculeSTM ProteinChat ESM3 emergent cross-modal
Figure 28.2: Text as a universal bridge modality. Molecule-text models (MolT5, MoleculeSTM) and protein-text models (ProteinChat, ESM3) each align their modality to text independently. The shared text embedding space creates emergent cross-modal connections (purple dashed line) that enable molecule-protein matching without direct paired training data.

1. MolT5: Bidirectional Molecule-Text Translation

What if you could type "a selective COX-2 inhibitor with low hepatotoxicity risk" into a search bar and receive, in return, the SMILES string of a molecule that matches that description?

Without models like these, a medicinal chemist searching for "a selective COX-2 inhibitor with low hepatotoxicity risk" would have to translate that intent into a precise substructure query, manually filtering thousands of hits; any molecule described differently in the database would be invisible. Molecule-text models remove that translation step entirely.

MolT5 (Edwards et al., 2022) makes this possible by treating molecule-text translation as a sequence-to-sequence problem. Built on the T5 (Text-to-Text Transfer Transformer) architecture, it handles two tasks: molecule captioning (SMILES to text) and text-conditional molecule generation (text to SMILES). The authors pretrain the model on a large corpus of unlabeled SMILES strings and scientific text, then fine-tune it on paired molecule-text data from the CheBI-20 dataset (33,010 molecule-description pairs derived from the Chemical Entities of Biological Interest ontology). (As of 2024, BioT5 and BioT5+ extend the MolT5 paradigm by jointly pretraining on molecules, proteins, and biomedical text, achieving stronger captioning and generation scores on CheBI-20 and additional protein-related benchmarks.)

Molecule-text translation converts a machine-readable molecular representation (such as a SMILES string) into a natural language description of that molecule's structure, properties, or biological activity, and vice versa. It matters because it bridges two communities: computational chemists who work with structural formulas and bench scientists or clinicians who think in terms of drug classes, mechanisms, and side effects. An encoder-decoder transformer ingests one representation as a token sequence and autoregressively generates the other. Use molecule-text translation to search chemical databases with plain-language queries, to generate human-interpretable explanations of screening hits, or to produce candidate structures from verbal specifications. Use direct molecular property prediction (quantitative structure-activity relationship (QSAR) models) instead when you need precise numeric properties like binding affinity or solubility and already have the molecular structure in hand.

The architecture is a T5 encoder-decoder where the input is either a SMILES string (for captioning) or a text description (for generation), and the output is the other modality. No special molecular tokenizer is required; MolT5 treats SMILES characters as text tokens, though specialized tokenizers (like those splitting on chemical bonds) can improve performance. At decoding time, MolT5 uses beam search (a breadth-limited tree search that keeps the top-k most probable partial sequences at each step, trading off speed for output quality) to produce fluent captions or valid SMILES. In short: by treating SMILES and English as two dialects of the same language, a single sequence-to-sequence model can translate freely between how machines encode molecules and how scientists talk about them.

from transformers import T5ForConditionalGeneration, T5Tokenizer
import torch


def load_molt5(model_name: str = "laituan245/molt5-base") -> tuple:
    """Load MolT5 model and tokenizer from Hugging Face.

    Available sizes: molt5-small (77M), molt5-base (250M),
    molt5-large (780M).
    """
    tokenizer = T5Tokenizer.from_pretrained(model_name)
    model = T5ForConditionalGeneration.from_pretrained(model_name)
    model.eval()
    return model, tokenizer


def smiles_to_caption(smiles: str, model, tokenizer,
                      max_length: int = 256) -> str:
    """Generate a text description for a SMILES string.

    MolT5 molecule captioning: SMILES in, English out.
    """
    input_ids = tokenizer(
        smiles, return_tensors="pt", max_length=512,
        truncation=True,
    ).input_ids

    with torch.no_grad():
        output_ids = model.generate(
            input_ids,
            max_length=max_length,
            num_beams=5,          # beam search for fluency
            early_stopping=True,
        )
    return tokenizer.decode(output_ids[0], skip_special_tokens=True)


def caption_to_smiles(description: str, model, tokenizer,
                      num_candidates: int = 10) -> list[str]:
    """Generate SMILES candidates from a text description.

    Returns multiple candidates ranked by beam score.
    """
    input_ids = tokenizer(
        description, return_tensors="pt", max_length=512,
        truncation=True,
    ).input_ids

    with torch.no_grad():
        output_ids = model.generate(
            input_ids,
            max_length=256,
            num_beams=num_candidates,
            num_return_sequences=num_candidates,
            early_stopping=True,
        )
    candidates = [
        tokenizer.decode(ids, skip_special_tokens=True)
        for ids in output_ids
    ]
    return candidates


# Example usage
model, tokenizer = load_molt5("laituan245/molt5-base")

# Molecule captioning
aspirin_smiles = "CC(=O)Oc1ccccc1C(=O)O"
caption = smiles_to_caption(aspirin_smiles, model, tokenizer)
print(f"Caption: {caption}")
# Output: "The molecule is a member of the class of benzoic acids
#          that is salicylic acid in which the hydrogen of the
#          hydroxy group has been replaced by an acetyl group."

# Text-conditional generation
target_desc = "a nonsteroidal anti-inflammatory drug with analgesic properties"
candidates = caption_to_smiles(target_desc, model, tokenizer,
                                num_candidates=5)
for i, smi in enumerate(candidates):
    print(f"  Candidate {i+1}: {smi}")
MolT5 molecule captioning and text-conditional generation. The captioning direction (SMILES to text) uses beam search for fluent descriptions. The generation direction (text to SMILES) returns multiple candidates that can be filtered for chemical validity using RDKit.

A critical step after text-conditional generation is validity filtering. Not every SMILES string generated by MolT5 represents a valid molecule. RDKit provides the filter:

from rdkit import Chem
from rdkit.Chem import Descriptors, Draw


def filter_valid_molecules(smiles_list: list[str]) -> list[dict]:
    """Filter generated SMILES for chemical validity.

    Returns valid molecules with basic property annotations.
    """
    valid = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is not None:
            valid.append({
                "smiles": Chem.MolToSmiles(mol),  # canonicalize
                "molecular_weight": Descriptors.MolWt(mol),
                "logp": Descriptors.MolLogP(mol),
                "num_h_donors": Descriptors.NumHDonors(mol),
                "num_h_acceptors": Descriptors.NumHAcceptors(mol),
                "valid": True,
            })
    return valid


# Filter candidates from MolT5 generation
valid_mols = filter_valid_molecules(candidates)
print(f"Valid: {len(valid_mols)}/{len(candidates)}")
for mol_info in valid_mols:
    print(f"  {mol_info['smiles']}  MW={mol_info['molecular_weight']:.1f}")
RDKit validity filtering for MolT5-generated SMILES candidates. Canonicalization via MolToSmiles normalizes the SMILES representation, and Lipinski descriptors provide a quick drug-likeness check.
Practical Example: Drug Repurposing via Text Query

Consider a hypothetical scenario: A researcher investigating COVID-19 treatments queries MolT5 with "a protease inhibitor that binds to the main protease of SARS-CoV-2 with a molecular weight under 500 daltons." MolT5 generates 20 SMILES candidates. After RDKit filtering, suppose 14 are chemically valid. Cross-referencing with PubChem might reveal that 3 of these match known protease inhibitors (nirmatrelvir analogs), while 2 represent novel scaffolds not in any public database. The novel scaffolds would become candidates for molecular dynamics simulation (see Chapter 43) and experimental validation.

2. MoleculeSTM: Contrastive Molecule-Text Alignment

While MolT5 treats molecule-text translation as a sequence-to-sequence problem, MoleculeSTM (Liu et al., 2023) takes the contrastive alignment approach from Section 28.1. It trains a molecular encoder (graph neural network (GNN) operating on 2D molecular graphs) and a text encoder (SciBERT, where SciBERT is a BERT language model pretrained on 1.14 million scientific papers from Semantic Scholar) with InfoNCE loss on 281,000 molecule-description pairs from PubChem. The result is a shared embedding space where molecules and their descriptions are neighbors. (As of 2024, newer contrastive molecule-text models such as MolFM and 3D-MoLM extend this approach to incorporate 3D conformer geometry alongside 2D graphs and text, improving retrieval on structure-sensitive queries.)

MoleculeSTM's advantage over MolT5 is retrieval. Instead of generating a SMILES string from scratch (which may be invalid), MoleculeSTM retrieves the closest molecules from an existing database. Given a text query, it encodes the text, then finds the nearest molecular embeddings in the shared space. Because every result comes from the database, retrieval ensures that all returned structures are known, catalogued molecules (though their relevance to the query still depends on the quality of the learned embeddings).

Common Misconception

A common misconception is that contrastive alignment (as in MoleculeSTM) teaches the model to "understand" molecular chemistry the way a chemist does. In reality, contrastive models learn statistical co-occurrence patterns between molecular substructures and textual phrases; they match representations in embedding space without any internal model of reaction mechanisms, 3D binding geometry, or thermodynamic stability. High cosine similarity between a molecule and a text description means "these appeared together in training data," not "this molecule truly has this property," so retrieval results always require independent experimental or computational validation.

import torch
import torch.nn.functional as F
import numpy as np


class MoleculeSTMRetriever:
    """Text-to-molecule retrieval using MoleculeSTM embeddings.

    Assumes pre-computed molecular embeddings stored in a matrix.
    Text queries are encoded on-the-fly and compared to the
    molecular index via cosine similarity.
    """

    def __init__(self, mol_embeddings: np.ndarray,
                 mol_smiles: list[str],
                 text_encoder, text_tokenizer):
        """
        Parameters
        ----------
        mol_embeddings : np.ndarray
            Pre-computed molecular embeddings, shape (N, D).
        mol_smiles : list[str]
            SMILES strings corresponding to each row.
        text_encoder : nn.Module
            Text encoder from the aligned MoleculeSTM model.
        text_tokenizer : tokenizer
            Tokenizer for the text encoder.
        """
        # Normalize for cosine similarity
        norms = np.linalg.norm(mol_embeddings, axis=1, keepdims=True)
        self.mol_index = mol_embeddings / norms
        self.smiles = mol_smiles
        self.text_encoder = text_encoder
        self.tokenizer = text_tokenizer

    def encode_query(self, text: str) -> np.ndarray:
        """Encode a text query to the shared embedding space."""
        inputs = self.tokenizer(
            text, return_tensors="pt", max_length=256,
            truncation=True, padding=True,
        )
        with torch.no_grad():
            hidden = self.text_encoder(**inputs).last_hidden_state
            pooled = hidden[:, 0, :]  # CLS token (first token, used as the sequence-level representation)
        z = F.normalize(pooled, dim=-1)
        return z.cpu().numpy()

    def retrieve(self, query: str, top_k: int = 10) -> list[dict]:
        """Retrieve top-k molecules matching a text description.

        Returns SMILES, similarity scores, and ranks.
        """
        z_query = self.encode_query(query)  # (1, D)
        similarities = (z_query @ self.mol_index.T).squeeze()

        top_indices = np.argsort(-similarities)[:top_k]

        results = []
        for rank, idx in enumerate(top_indices):
            results.append({
                "rank": rank + 1,
                "smiles": self.smiles[idx],
                "similarity": float(similarities[idx]),
            })
        return results


# Example: retrieve kinase inhibitors by text query
# retriever = MoleculeSTMRetriever(mol_embs, smiles_db,
#                                   text_enc, text_tok)
# hits = retriever.retrieve(
#     "a selective EGFR kinase inhibitor with quinazoline scaffold",
#     top_k=5,
# )
# for hit in hits:
#     print(f"  #{hit['rank']} sim={hit['similarity']:.3f} {hit['smiles']}")
Text-to-molecule retrieval using MoleculeSTM's aligned embedding space. Pre-computed molecular embeddings serve as the search index; text queries are encoded on-the-fly and matched via cosine similarity over the pre-normalized index.

MoleculeSTM also supports text-guided molecular editing: given a starting molecule and a text instruction ("make it more water-soluble"), the model adjusts the molecular embedding in the direction indicated by the text and retrieves the nearest molecule to the adjusted vector. This is analogous to the arithmetic in word2vec (king - man + woman = queen) but operating across modalities.

Mental Model

Think of text-guided molecular editing like adjusting a recipe by describing the change you want rather than rewriting the ingredient list. Imagine you have a soup recipe (the source molecule's embedding) and you tell the chef "make it spicier" (the text instruction). The chef does not start from scratch; instead, the chef shifts the recipe in the direction of "spicy" by a controlled amount (the alpha parameter), then looks through a cookbook of known recipes (the molecular database) to find the closest match to the adjusted version. The result is always a real, tested recipe, not a random invention. The interpolation weight alpha controls how far toward "spicy" you move: too little and the soup barely changes; too much and you lose the original flavor entirely. This is precisely how MoleculeSTM's embedding arithmetic works: interpolate in vector space, then snap to the nearest known molecule.

def text_guided_edit(retriever: MoleculeSTMRetriever,
                     source_smiles: str,
                     edit_instruction: str,
                     alpha: float = 0.5,
                     top_k: int = 5) -> list[dict]:
    """Edit a molecule by shifting its embedding toward a text direction.

    Parameters
    ----------
    source_smiles : str
        Starting molecule SMILES.
    edit_instruction : str
        Natural language edit (e.g., "increase water solubility").
    alpha : float
        Interpolation weight: 0 = keep original, 1 = full text direction.
    """
    # Encode source molecule
    z_mol = retriever.mol_index[
        retriever.smiles.index(source_smiles)
    ]  # (D,)

    # Encode the edit instruction
    z_text = retriever.encode_query(edit_instruction).squeeze()  # (D,)

    # Interpolate: shift molecule embedding toward text
    z_edited = (1 - alpha) * z_mol + alpha * z_text
    z_edited = z_edited / np.linalg.norm(z_edited)  # re-normalize

    # Retrieve nearest molecules to the edited vector
    similarities = z_edited @ retriever.mol_index.T
    top_indices = np.argsort(-similarities)[:top_k]

    results = []
    for rank, idx in enumerate(top_indices):
        results.append({
            "rank": rank + 1,
            "smiles": retriever.smiles[idx],
            "similarity": float(similarities[idx]),
        })
    return results
Text-guided molecular editing via embedding interpolation. The source molecule's vector is shifted toward the edit instruction's vector by a factor of alpha, then the nearest real molecules to the edited vector are retrieved, producing chemically valid edits without generative SMILES decoding.
Key Insight: Retrieval vs. Generation

MolT5 (generation) and MoleculeSTM (retrieval) represent two complementary strategies. Generation can produce novel molecules not in any database, but many generated SMILES are invalid or unsynthesizable. Retrieval always returns real molecules, but cannot find anything outside the database. In practice, combine both: use MoleculeSTM to find the closest known molecules, then use MolT5 to generate novel variants inspired by the retrieval results. This two-stage approach (retrieve-then-generate) mirrors how human chemists work: start from known scaffolds, then modify.

3. ProteinChat: Conversational Protein Understanding

The molecule-text models above let researchers describe and retrieve small molecules in natural language; the same principle extends naturally to proteins, where free-text descriptions of function and mechanism are equally central to how biologists think.

ProteinChat (Guo et al., 2023) adapts the visual instruction tuning paradigm (LLaVA-style) to proteins. It aligns a protein structure encoder with a large language model (LLM) so that a user can ask natural language questions about a protein's 3D structure and receive informed answers.

The Three-Part Architecture

Protein encoder. ESM-2 or a 3D structure encoder (e.g., GVP-GNN, where GVP-GNN is a Geometric Vector Perceptron graph neural network that operates directly on 3D atomic coordinates and edge orientations) produces per-residue embeddings from the protein's sequence or coordinates.

Projection layer. A linear or multilayer perceptron (MLP) projection maps the protein embeddings into the LLM's input space, analogous to the visual projection in LLaVA.

Language model. A pretrained LLM (Vicuna, LLaMA) generates answers conditioned on both the projected protein embeddings and the user's question text. (As of 2024, successors such as ProteinGPT and InstructProtein have extended this paradigm with larger backbone LLMs, richer instruction-tuning datasets, and support for multi-turn dialogue about protein function and design.)

import torch
import torch.nn as nn
from transformers import AutoModel, AutoModelForCausalLM, AutoTokenizer


class ProteinChatModel(nn.Module):
    """Simplified ProteinChat architecture.

    Aligns a protein encoder with an LLM via a projection layer
    for conversational protein understanding.
    """

    def __init__(
        self,
        protein_encoder_name: str = "facebook/esm2_t33_650M_UR50D",
        llm_name: str = "meta-llama/Llama-2-7b-chat-hf",
        projection_dim: int = 4096,
    ):
        super().__init__()
        self.protein_encoder = AutoModel.from_pretrained(
            protein_encoder_name
        )
        self.llm = AutoModelForCausalLM.from_pretrained(llm_name)
        self.llm_tokenizer = AutoTokenizer.from_pretrained(llm_name)

        # Freeze both backbone models
        for p in self.protein_encoder.parameters():
            p.requires_grad = False
        for p in self.llm.parameters():
            p.requires_grad = False

        # Trainable projection: protein space -> LLM input space
        esm_dim = self.protein_encoder.config.hidden_size
        self.protein_projection = nn.Sequential(
            nn.Linear(esm_dim, projection_dim),
            nn.GELU(),
            nn.Linear(projection_dim, self.llm.config.hidden_size),
        )

    def encode_protein(self, protein_ids: torch.Tensor,
                       attention_mask: torch.Tensor) -> torch.Tensor:
        """Encode protein sequence and project to LLM space.

        Returns per-residue embeddings in the LLM's input dimension.
        """
        with torch.no_grad():
            esm_output = self.protein_encoder(
                input_ids=protein_ids,
                attention_mask=attention_mask,
            )
        # Pool: mean over residue positions
        hidden = esm_output.last_hidden_state
        pooled = (hidden * attention_mask.unsqueeze(-1)).sum(dim=1)
        pooled = pooled / attention_mask.sum(dim=1, keepdim=True)
        # Project to LLM space
        return self.protein_projection(pooled)  # (batch, llm_dim)

    def answer_question(self, protein_ids, protein_mask,
                        question: str,
                        max_new_tokens: int = 256) -> str:
        """Answer a question about a protein.

        The protein embedding is prepended to the question tokens
        as a "visual prefix" in the LLM input.
        """
        # Encode protein
        protein_emb = self.encode_protein(protein_ids, protein_mask)
        # Shape: (1, llm_dim) -> (1, 1, llm_dim) as a prefix token
        prefix = protein_emb.unsqueeze(1)

        # Tokenize the question
        q_tokens = self.llm_tokenizer(
            question, return_tensors="pt"
        )
        q_embeds = self.llm.get_input_embeddings()(
            q_tokens.input_ids
        )

        # Concatenate: [protein_prefix, question_tokens]
        inputs_embeds = torch.cat([prefix, q_embeds], dim=1)

        with torch.no_grad():
            output = self.llm.generate(
                inputs_embeds=inputs_embeds,
                max_new_tokens=max_new_tokens,
            )
        return self.llm_tokenizer.decode(
            output[0], skip_special_tokens=True
        )
Simplified ProteinChat architecture showing the three-component design: ESM-2 protein encoder, trainable MLP projection layer, and frozen LLM decoder. The protein embedding is prepended to the question tokens as a prefix, enabling the LLM to condition its answer on structural information.

4. ESM3: Multimodal Protein Foundation Model

ESM3 (Hayes et al., 2024) represents the state of the art (circa 2024) in protein multimodal AI. Unlike ProteinChat, which bolts a protein encoder onto an existing LLM, ESM3 is natively multimodal: it jointly processes three tracks of protein information (sequence, structure, and function) within a single transformer architecture. Each track has its own tokenizer and embedding layer, but the transformer layers attend across all three tracks simultaneously.

ESM3 trains on 2.78 billion protein sequences (UniRef, where UniRef is the UniProt Reference Clusters database that groups protein sequences by similarity to reduce redundancy), 236 million predicted structures (ESMFold), and functional annotations (InterPro, where InterPro is a database that classifies protein sequences into families and predicts functional domains and sites), using a masked language modeling objective that randomly masks tokens from any combination of tracks. At inference, you provide any subset of inputs (sequence only, structure only, sequence plus partial function) and ESM3 generates the missing tracks, serving as a unified interface for protein understanding, design, and function prediction.

Checkpoint

So far: ESM3 jointly tokenizes three protein tracks (sequence, structure, function), trains a single transformer on 2.78 billion sequences with masked prediction across all tracks, and at inference fills in whichever tracks you leave blank, unifying protein understanding, folding, and design in one model.

# ESM3 usage via the esm Python package (EvolutionaryScale)
# pip install esm

from esm.models.esm3 import ESM3
from esm.sdk.api import (
    ESMProtein, GenerationConfig, SamplingConfig
)


def protein_from_sequence(sequence: str) -> dict:
    """Use ESM3 to predict structure and function from sequence.

    Returns structure tokens and function annotations
    for a given amino acid sequence.
    """
    # Load the open-weight ESM3 model
    model = ESM3.from_pretrained("esm3_sm_open_v1")

    # Create a protein object with sequence only
    protein = ESMProtein(sequence=sequence)

    # Generate structure (predict folding from sequence)
    structure_config = GenerationConfig(
        track="structure",
        num_steps=8,           # iterative refinement steps
        temperature=0.7,
    )
    protein_with_structure = model.generate(
        protein, structure_config
    )

    # Generate function annotations
    function_config = GenerationConfig(
        track="function",
        num_steps=8,
        temperature=0.5,
    )
    protein_full = model.generate(
        protein_with_structure, function_config
    )

    return {
        "sequence": protein_full.sequence,
        "structure_tokens": protein_full.coordinates,
        "function_annotations": protein_full.function_annotations,
    }


def design_protein_for_function(function_description: str,
                                 length: int = 100) -> dict:
    """Design a protein sequence for a target function.

    Conditional generation: specify a desired function and let
    ESM3 generate a sequence predicted to realize it.
    """
    model = ESM3.from_pretrained("esm3_sm_open_v1")

    # Create protein with only function specified
    protein = ESMProtein(
        function_annotations=[function_description],
    )

    # Generate sequence conditioned on function
    seq_config = GenerationConfig(
        track="sequence",
        num_steps=16,
        temperature=0.8,
    )
    designed = model.generate(protein, seq_config)

    return {
        "designed_sequence": designed.sequence,
        "confidence": designed.confidence_scores,
    }
ESM3 multimodal protein operations: predicting structure and function from a sequence input, and inverse design that generates a novel sequence from a function specification. The iterative generation with configurable temperature allows trading off diversity against confidence.

Research Frontier

Chai-1 (Chai Discovery, 2024) extends multimodal protein modeling to the prediction of molecular interactions, including protein-protein, protein-ligand, protein-nucleic acid, and protein-small molecule complexes within a single unified architecture. Unlike ESM3, which focuses on single-chain sequence-structure-function generation, Chai-1 treats the full biomolecular assembly as input and predicts 3D complex structures with accuracy competitive with AlphaFold-Multimer. The model was released with open weights and a permissive license for drug discovery applications. This direction points toward foundation models that reason jointly about molecules and their binding partners, closing the gap between the molecule-text and protein-text paradigms discussed in this section and enabling end-to-end virtual screening pipelines that predict not just "what molecule fits this description" but "how tightly does this molecule bind this protein target."

5. Cross-Referencing Molecular and Protein Embeddings

With separate models now aligning molecules to text and proteins to text, a natural next question arises: can we connect molecules and proteins to each other through the text modality they share? As illustrated in Figure 28.2, the shared text embedding space enables exactly this kind of emergent cross-modal matching.

Molecules interact with proteins: drugs bind to target proteins, metabolites serve as enzyme substrates, and signaling molecules activate receptors. If you have aligned molecule-text embeddings (from MoleculeSTM) and aligned protein-text embeddings (from ProteinChat or ESM3 plus a text projection), you can cross-reference through the shared text modality. A molecule's text description and a protein's function annotation, when both are nearby in text embedding space, suggest a potential interaction. This works because textual co-occurrence (for example, a drug and its target protein frequently appearing in the same sentences) correlates with biological interaction, even though cosine similarity in text space is only a rough proxy for binding affinity or functional relevance. Figure 28.2.1 illustrates cross-modal molecule-protein matching via shared text embedding space.

Cross-modal molecule-protein matching via shared text embedding space
Figure 28.2.1: Cross-modal molecule-protein matching via a shared text embedding space, where text descriptions serve as a universal bridge connecting independently aligned molecule and protein representations.
import numpy as np
from dataclasses import dataclass


@dataclass
class CrossModalMatcher:
    """Match molecules to proteins via shared text embeddings.

    Uses the text modality as a bridge: molecules aligned to text
    via MoleculeSTM, proteins aligned to text via ProteinChat.
    The overlapping text space enables cross-modal matching.
    """
    mol_text_embeddings: np.ndarray   # (N_mol, D)
    protein_text_embeddings: np.ndarray  # (N_prot, D)
    mol_smiles: list[str]
    protein_ids: list[str]

    def __post_init__(self):
        # Normalize for cosine similarity
        self.mol_normed = self.mol_text_embeddings / np.linalg.norm(
            self.mol_text_embeddings, axis=1, keepdims=True
        )
        self.prot_normed = self.protein_text_embeddings / np.linalg.norm(
            self.protein_text_embeddings, axis=1, keepdims=True
        )

    def find_targets(self, smiles: str,
                     top_k: int = 5) -> list[dict]:
        """Find proteins likely to interact with a molecule.

        Uses text-space similarity as a proxy for binding affinity.
        """
        mol_idx = self.mol_smiles.index(smiles)
        z_mol = self.mol_normed[mol_idx]  # (D,)

        # Cross-modal similarity
        scores = z_mol @ self.prot_normed.T
        top_indices = np.argsort(-scores)[:top_k]

        return [
            {
                "protein_id": self.protein_ids[idx],
                "similarity": float(scores[idx]),
                "rank": rank + 1,
            }
            for rank, idx in enumerate(top_indices)
        ]

    def find_ligands(self, protein_id: str,
                     top_k: int = 5) -> list[dict]:
        """Find molecules likely to bind a protein target."""
        prot_idx = self.protein_ids.index(protein_id)
        z_prot = self.prot_normed[prot_idx]

        scores = z_prot @ self.mol_normed.T
        top_indices = np.argsort(-scores)[:top_k]

        return [
            {
                "smiles": self.mol_smiles[idx],
                "similarity": float(scores[idx]),
                "rank": rank + 1,
            }
            for rank, idx in enumerate(top_indices)
        ]
Cross-modal molecule-protein matching via shared text embeddings. The find_targets method locates candidate protein targets for a given molecule, while find_ligands retrieves candidate small-molecule binders for a given protein, both using text-space cosine similarity as a proxy.
Key Insight: Text as a Universal Bridge

The cross-referencing strategy above exploits a fundamental property of text: it can describe anything. By aligning each scientific modality to text independently, you get emergent cross-modal connections for free. A molecule described as "inhibits EGFR tyrosine kinase activity" and a protein annotated as "epidermal growth factor receptor, tyrosine kinase domain" will have similar text embeddings even if no molecule-protein pairs appeared in the training data. This is the scientific analog of ImageBind's insight that binding all modalities to one anchor creates emergent zero-shot alignment between non-anchor pairs. Text is the natural anchor for scientific AI because scientists describe everything in text.

Library Shortcut: Hugging Face for Pretrained Multimodal Models

The from-scratch implementations above total approximately 200 lines. Using Hugging Face's ecosystem, loading and running inference with these models compresses to about 10 lines per model:

# MolT5: 3 lines to load, 2 to run inference
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained("laituan245/molt5-base")
model = AutoModelForSeq2SeqLM.from_pretrained("laituan245/molt5-base")
out = model.generate(tok("CC(=O)Oc1ccccc1C(=O)O", return_tensors="pt").input_ids)
print(tok.decode(out[0], skip_special_tokens=True))

# ESM-2 protein embeddings: 3 lines
from transformers import EsmModel, EsmTokenizer
esm_tok = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
esm_model = EsmModel.from_pretrained("facebook/esm2_t33_650M_UR50D")
# Then: esm_model(**esm_tok(sequence, return_tensors="pt"))
Hugging Face shorthand for MolT5 molecule captioning and ESM-2 protein embedding extraction, reducing each model to three lines for loading and two for inference.

The Hugging Face Hub hosts pretrained weights for MolT5 (all sizes), ESM-2, ChemBERTa, SciBERT, and dozens of other scientific models. Line count reduction: 20x compared to training from scratch. Use the from-scratch code for custom architectures or research extensions; use Hugging Face for production inference pipelines.

Fun Note: The Molecule That Described Itself

MolT5's training set includes molecules from the CheBI ontology, where descriptions are written by human curators. When you feed MolT5 a molecule and ask it to generate a description, then feed that description back and ask for a molecule, you sometimes get a different molecule. This "round-trip" inconsistency is not a bug; it reflects the many-to-many nature of the molecule-text relationship. Multiple molecules match the description "a benzodiazepine derivative with anxiolytic properties," and multiple descriptions apply to any given benzodiazepine. Perfect round-trip consistency would require a one-to-one mapping that does not exist in chemistry.

Try It: Round-Trip Molecule-Text Consistency Check

Test how well MolT5 preserves molecular identity through a caption-then-generate round trip, using only a laptop with Python and free libraries.

Step 1. Install dependencies: pip install transformers torch rdkit-pypi. No GPU is required; the molt5-small model (77M parameters) runs on CPU in under 10 seconds per inference.

Step 2. Pick five well-known drug molecules and collect their canonical SMILES from PubChem (e.g., aspirin: CC(=O)Oc1ccccc1C(=O)O, ibuprofen: CC(C)Cc1ccc(cc1)C(C)C(=O)O, caffeine: Cn1c(=O)c2c(ncn2C)n(C)c1=O).

Step 3. For each molecule, run the forward pass (SMILES to caption) using smiles_to_caption from the code above, then run the reverse pass (caption to SMILES) using caption_to_smiles with num_candidates=5.

Step 4. For each round-trip candidate, compute the Tanimoto similarity, where Tanimoto similarity is the ratio of shared features to total features between two molecular fingerprints, ranging from 0 (no overlap) to 1 (identical) to the original molecule using RDKit Morgan fingerprints (circular fingerprints that encode each atom's neighborhood out to a given radius as a fixed-length bit vector): DataStructs.TanimotoSimilarity(AllChem.GetMorganFingerprintAsBitVect(mol_original, 2, 2048), AllChem.GetMorganFingerprintAsBitVect(mol_candidate, 2, 2048)).

Step 5. Tabulate results: for each input molecule, record the generated caption, the top candidate SMILES, its Tanimoto similarity, and whether the candidate is the same molecule (Tanimoto = 1.0). You should observe that simple molecules (aspirin) round-trip more reliably than complex ones, and that the generated captions capture functional group information even when the exact structure drifts.

Exercise 28.2.1

You have a MoleculeSTM retriever with a database of 10,000 molecules and their pre-normalized embeddings (dimension 256). A user submits the text query "a beta-lactam antibiotic with broad-spectrum activity." After encoding the query, you obtain a 256-dimensional text vector. The top-3 cosine similarities returned are 0.87, 0.84, and 0.61. You then run text-guided editing on the top-1 hit with alpha = 0.3 toward the instruction "improve oral bioavailability." What is the mathematical expression for the edited embedding vector before re-normalization, and why must you re-normalize it before retrieval?

Hint

The edited vector is z_edited = (1 - alpha) * z_mol + alpha * z_text, which is 0.7 * z_mol + 0.3 * z_text. A linear combination of two unit vectors is not itself a unit vector (unless the vectors are identical), so the cosine similarities computed against the database would be scaled incorrectly without dividing by the norm.

Step-Through: Text-Guided Molecular Editing Arithmetic

Trace through the embedding interpolation with concrete 4-dimensional vectors. Suppose the source molecule has normalized embedding z_mol = [0.5, 0.5, 0.5, 0.5] and the text instruction "increase solubility" encodes to z_text = [0.1, 0.9, 0.2, 0.3] (already normalized to unit length ~0.975, close enough for illustration). With alpha = 0.4:

Step 1. Compute the interpolated vector: z_edited = 0.6 * [0.5, 0.5, 0.5, 0.5] + 0.4 * [0.1, 0.9, 0.2, 0.3] = [0.34, 0.66, 0.38, 0.42].

Step 2. Compute the norm: ||z_edited|| = sqrt(0.34^2 + 0.66^2 + 0.38^2 + 0.42^2) = sqrt(0.1156 + 0.4356 + 0.1444 + 0.1764) = sqrt(0.872) = 0.9338.

Step 3. Re-normalize: z_edited_norm = [0.364, 0.707, 0.407, 0.450].

Step 4. Compare to database. If the three database molecules have normalized embeddings A = [0.4, 0.7, 0.3, 0.5], B = [0.8, 0.1, 0.5, 0.3], C = [0.3, 0.6, 0.5, 0.5], the cosine similarities are: dot(z, A) = 0.145 + 0.495 + 0.122 + 0.225 = 0.987; dot(z, B) = 0.291 + 0.071 + 0.204 + 0.135 = 0.701; dot(z, C) = 0.109 + 0.424 + 0.204 + 0.225 = 0.962. Molecule A is retrieved as the top hit, reflecting the shift toward the "solubility" text direction while staying close to the original molecule.

Real-World Application: Drug Discovery at Insilico Medicine

Insilico Medicine used multimodal molecule-text models as one component of its generative chemistry platform to design ISM001-055, an anti-fibrotic drug candidate that entered Phase II clinical trials in 2023. The pipeline combined text-conditioned molecular generation (describing desired pharmacological profiles in natural language) with structure-based filtering and traditional medicinal chemistry optimization, reportedly reducing the hit-to-lead optimization cycle from years to months. ISM001-055 is frequently cited as among the first AI-designed drugs to reach human clinical trials, suggesting that the retrieve-then-generate paradigm described in this section can contribute to real therapeutic pipelines, though the relative contribution of each AI component remains difficult to isolate from the overall drug design effort.

Lab: MolT5 Caption Quality vs. Molecular Complexity

Goal: Measure how MolT5's captioning accuracy degrades as molecular complexity increases, using Bilingual Evaluation Understudy (BLEU) score against reference descriptions from the CheBI-20 test set.

Tools needed: Python with transformers, rdkit-pypi, and nltk (for BLEU). No GPU required; use laituan245/molt5-small for fast CPU inference.

Procedure (20 minutes): (1) Download the CheBI-20 test split (~3,300 pairs) from the MolT5 repository on GitHub. (2) For each molecule, compute molecular weight and number of rotatable bonds using RDKit. (3) Run MolT5 captioning on 200 randomly sampled molecules. (4) Compute sentence-level BLEU-4 between generated and reference captions.

What to vary: Bin molecules by molecular weight (under 200, 200 to 400, 400 to 600, over 600 daltons) and compare average BLEU scores across bins. Also try beam widths of 1, 5, and 10 and observe the effect on caption quality.

What to observe: You should find that BLEU drops noticeably for molecules above 400 daltons and that beam search width 5 provides diminishing returns beyond width 10. Record cases where the generated caption is factually correct but uses different phrasing than the reference (a known limitation of BLEU as a metric for scientific text).

Exercises

  1. Conceptual. MolT5 is a generative model (encoder-decoder) while MoleculeSTM is a contrastive model (dual encoder). Explain under what discovery scenarios each architecture is preferable. Consider: (a) searching an existing molecular database for leads, (b) designing entirely novel molecules, (c) explaining a molecule's properties to a non-expert.
  2. Coding. Using MolT5 from Hugging Face, implement a round-trip consistency test: generate a caption from a SMILES string, then generate SMILES from that caption. Measure the Tanimoto similarity (using RDKit Morgan fingerprints) between the original and round-trip molecules across 100 molecules from the CheBI-20 test set. What is the average Tanimoto similarity? How does it vary with molecule complexity (molecular weight)?
  3. Analysis. The cross-referencing strategy in Listing 28.7 uses text as a bridge between molecules and proteins. Identify three failure cases where this bridge would produce false positives (high similarity but no real interaction). For each case, propose a filter or additional data source that could eliminate the false positive.