Predicting Chemical Reaction Yield Using Recurrent Neural Networks on SMILES Strings

Chemical Informatics Fundamentals

The evolution of artificial intelligence in chemistry involves several stages. Early efforts focused on digitizing chemical knowledge into databases using various representation methods. Mid-stage approaches relied on manual feature engineering to encode existing data, followed by traditional machine learning algorithms for prediction tasks. The current era is defined by the widespread adoption of deep learning networks, which has spurred the development of novel molecular fingerprinting techniques.

Molecular Representation: SMILES

SMILES (Simplified Molecular Input Line Entry System) is a standard notation used in chemoinformatics to represent chemical structures as strings of ASCII characters. Proposed by Weininger et al., this system encodes atoms, bonds, charges, and structural features like rings or side chains according to specific syntax rules:

  • Atoms are represented by their chemical symbols.
  • Bonds are denoted by specific characters (= for double, # for triple).
  • Special groups or ions use square brackets, e.g., [Cu+2] represents a copper ion with a +2 charge.

Molecular Fingerprints

A molecular fingerprint is a fixed-length bit vector that acts as a unique identifier for a molecule's structural characteristics. Each bit posiiton corresponds to a specific chemical substructure. For instance, a 2-bit vector might indicate the presence of a methyl group at index 0 and a benzene ring at index 1. A vector [0, 1] implies the molecule contains a benzene ring but lacks a methyl group.

Tooling: RDKit

RDKit is an open-source cheminformatics library supporting Windows, macOS, and Linux. It facilitates integration with Python, Java, and C, serving as a primary tool for processing molecular data in computational chemistry workflows.

Modeling Methodologies

Machine learning tasks generally fall into classification (discrete outputs) or regression (continuous outputs). In earlier experiments involving reaction data, Random Forest models were employed, combining multiple decision trees trained on random subsets of data.

Deep Learning, a subset of machine learning, automates feature extraction through neural networks, eliminating the need for laborious hand-crafted features. Since SMILES sequences resemble language, Natural Language Processing (NLP) techniques are applicable here.

Recurrent Neural Networks (RNN)

RNNs are designed for sequential data. They process inputs step-by-step, maintaining a hidden state that carries information from previous steps. The mathematical update rule typically follows:

h_n = σ(W_hh * h_{n-1} + W_hx * x_n + b)
y_n = Softmax(V * h_n + c)

The hidden state allows the network to "read" past sequence context to inform future predictions. However, standard RNNs face limitations regarding long-term dependency due to vanishing gradients and poor parallelization capabilities during training, as computations must occur sequentially.

Implementation Details

Imports and Dependencies

import re
import time
import pandas as pd
from typing import List, Tuple
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader, Subset

Model Architecture

class SequenceYieldNetwork(nn.Module):
    def __init__(self, vocab_size, feature_dim, hidden_units, out_units, layers, dropout_rate, device):
        super(SequenceYieldNetwork, self).__init__()
        self.embedding_layer = nn.Embedding(vocab_size, feature_dim)
        self.recurrent_cell = nn.RNN(
            input_size=feature_dim,
            hidden_size=hidden_units,
            num_layers=layers,
            batch_first=True,
            dropout=dropout_rate,
            bidirectional=True
        )
        self.output_projection = nn.Sequential(
            nn.Linear(2 * layers * hidden_units, out_units),
            nn.Sigmoid(),
            nn.Linear(out_units, 1),
            nn.Sigmoid()
        )

    def forward(self, x):
        embedded = self.embedding_layer(x)
        _, final_states = self.recurrent_cell(embedded)
        states_transposed = final_states.transpose(0, 1)
        flattened = states_transposed.reshape(states_transposed.size(0), -1)
        prediction = self.output_projection(flattened).squeeze(-1)
        return prediction

Tokenization Logic

class MoleculeSeqTokenizer():
    def __init__(self, pad_symbol, pattern, vocab_path, max_seq_len):
        self.pad_token = pad_symbol
        self.regex_pattern = pattern
        self.vocab_path = vocab_path
        self.max_length = max_seq_len

        with open(self.vocab_path, 'r') as f:
            lines = [line.strip('\n') for line in f.readlines()]
        
        self.token_map = {token: idx for idx, token in enumerate(lines)}

    def _apply_regex(self, smiles_list):
        compiled_re = re.compile(f"({self.regex_pattern}|.)")
        processed_tokens = []
        for smi in smiles_list:
            match = compiled_re.findall(smi)
            if len(match) > self.max_length:
                match = match[:self.max_length]
            processed_tokens.append(match)
        return processed_tokens

    def tokenize(self, smiles_list):
        raw_tokens = self._apply_regex(smiles_list)
        tagged_tokens = [["<CLS>"] + t + ["<SEP>"] for t in raw_tokens]
        padded_tokens = self._pad_sequences(tagged_tokens, self.pad_token)
        indexed_tokens = self._map_to_indices(padded_tokens)
        return indexed_tokens

    def _pad_sequences(self, seqs, pad_val):
        max_len = max(len(seq) for seq in seqs)
        return [seq + [pad_val] * (max_len - len(seq)) for seq in seqs]

    def _map_to_indices(self, tokens):
        indices_list = []
        for token_group in tokens:
            current_idx = []
            for token in token_group:
                if token in self.token_map:
                    current_idx.append(self.token_map[token])
                else:
                    new_id = max(self.token_map.values()) + 1
                    self.token_map[token] = new_id
                    current_idx.append(new_id)
            indices_list.append(current_idx)
        return indices_list

Data Loading Pipeline

def load_reaction_dataset(file_path, is_training=True):
    df = pd.read_csv(file_path)
    
    r1 = df["Reactant1"].tolist()
    r2 = df["Reactant2"].tolist()
    prod = df["Product"].tolist()
    addi = df["Additive"].tolist()
    solv = df["Solvent"].tolist()
    
    yields = df["Yield"].tolist() if is_training else [0] * len(r1)
    
    combined_input = []
    for react_a, react_b, product, additive, solvent in zip(r1, r2, prod, addi, solv):
        connection_string = ".".join([react_a, react_b])
        full_string = ">".join([connection_string, product])
        combined_input.append(full_string)
    
    paired_data = list(zip(combined_input, yields))
    return paired_data

class ReactionDataSubset(Dataset):
    def __init__(self, dataset_items: List[Tuple[List[str], float]]):
        self.records = dataset_items

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

    def __getitem__(self, idx):
        return self.records[idx]

def collate_fn(batch):
    regex_pattern = r"[[^\\]]+]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p|\\(|\\)|\\.|=|#|-|\\+|\\\\\\|/|:|~|@|\\?|>|\\*|\\$|[0-9]{2}|[0-9]"
    tokenizer = MoleculeSeqTokenizer("<PAD>", regex_pattern, "../vocab_full.txt", max_length=300)
    
    smi_batch = []
    yield_batch = []
    
    for item in batch:
        smi_batch.append(item[0])
        yield_batch.append(item[1])
    
    token_tensors = torch.tensor(tokenizer.tokenize(smi_batch)[1])
    target_tensor = torch.tensor(yield_batch)
    
    return token_tensors, target_tensor

Training Procedure

def execute_training_loop():
    subset_size = 10
    vocab_dimension = 294
    input_dim = 300
    hidden_dim = 512
    output_dim = 512
    layers = 10
    drop_prob = 0.2
    clip_value = 1
    epochs = 100
    lr = 0.0001
    
    start_time = time.time()
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
    train_data = load_reaction_dataset("../dataset/round1_train_data.csv")
    main_dataset = ReactionDataSubset(train_data)
    train_loader = DataLoader(main_dataset, batch_size=128, shuffle=True, collate_fn=collate_fn)
    
    model = SequenceYieldNetwork(vocab_dimension, input_dim, hidden_dim, output_dim, layers, drop_prob, device).to(device)
    model.train()
    
    optimizer = optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.L1Loss()
    best_error = 10
    
    for epoch in range(epochs):
        total_loss = 0
        for src, targets in train_loader:
            src, targets = src.to(device), targets.to(device)
            optimizer.zero_grad()
            preds = model(src)
            loss = loss_fn(preds, targets)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), clip_value)
            optimizer.step()
            total_loss += loss.item()
        
        avg_loss = total_loss / len(train_loader)
        print(f'Epoch: {epoch+1:02} | Train Loss: {avg_loss:.3f}')
        
        if avg_loss < best_error:
            torch.save(model.state_dict(), '../model/RNN.pth')
    
    elapsed_mins = (time.time() - start_time) / 60
    print(f"Total execution time: {elapsed_mins:.2f} minutes")

if __name__ == '__main__':
    execute_training_loop()

Inference and Submission

import torch
from torch.utils.data import DataLoader
from dataset import ReactionDataSubset, collate_fn
from model import SequenceYieldNetwork
from data_utils import load_reaction_dataset

def generate_submission_results(model_checkpoint, output_dest):
    params = {
        'embed_size': 294, 'input_size': 300, 'hidden_size': 512,
        'output_size': 512, 'layers': 10, 'dropout': 0.2
    }
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
    test_records = load_reaction_dataset("../dataset/round1_test_data.csv", train=False)
    test_dataset = ReactionDataSubset(test_records)
    test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, collate_fn=collate_fn)

    network = SequenceYieldNetwork(**params).to(device)
    network.load_state_dict(torch.load(model_checkpoint))
    network.eval()
    
    results = []
    with torch.no_grad():
        for src, _ in test_loader:
            src = src.to(device)
            pred = network(src)
            results.extend(pred.detach().tolist())
    
    submission_lines = ['rxnid,Yield']
    for i, val in enumerate(results):
        submission_lines.append(f'test{i+1},{val:.4f}')
    
    with open(output_dest, 'w') as f:
        f.write('\n'.join(submission_lines))

    print("Submission generation complete.")

generate_submission_results("../model/RNN.pth", "../output/RNN_submit.txt")

Performance Evaluation

Empirical testing was conducted on local server hardware. The initial run required approximately two hours of processing time. Comparative analysis against baseline tree-based ensemble models indicated that the RNN architecture achieved lower predictive accuracy in this specific context. This suggests that while RNNs are theoretically capable of handling sequential molecular representations, they may not be the optimal choice for this particular task without further architectural adjustments or hyperparameter optimization.

Tags: cheminformatics deep-learning rnn pytorch smiles

Posted on Sat, 11 Jul 2026 16:37:55 +0000 by jdpatrick