2025, Oct 21 06:17
Как сохранить порядок и маппинг SMILES при ReplaceSubstructs в RDKit
Почему RDKit ReplaceSubstructs меняет порядок атомов и маппинг в SMILES, и как этого избежать. Пошаговое объяснение и простой способ на regex с примерами кода.
Когда вы пытаетесь «сшить» два SMILES с помощью ReplaceSubstructs() из RDKit, легко наткнуться на побочный эффект: замена вроде бы «срабатывает», но порядок атомов и маппинг оказываются перемешаны. Если последующая визуализация опирается на точное текстовое расположение или индексы атомов, это уже проблема. Ниже — точное объяснение происходящего и практичный способ сохранить изначальную раскладку групп для отображения.
Постановка задачи
Цель — заменить атом углерода в одном SMILES вторым фрагментом SMILES через ReplaceSubstructs(). На практике, независимо от выбранного варианта из возвращаемого списка, RDKit переупорядочивает атомы, и индексы выглядят перемешанными. Ниже минимальный пример на RDKit, который демонстрирует это поведение.
from rdkit import Chem
def fuse_smiles(base_smi, insert_smi, choice_idx=1):
    base_mol = Chem.MolFromSmiles(base_smi)
    frag_mol = Chem.MolFromSmiles(insert_smi)
    if base_mol is None or frag_mol is None:
        raise ValueError("One or both SMILES strings are invalid.")
    tagged_base = add_atom_map_tags(base_mol)
    result_mol = Chem.ReplaceSubstructs(
        tagged_base,
        Chem.MolFromSmarts("[CH3]"),
        frag_mol
    )[choice_idx]
    result_smi = Chem.MolToSmiles(result_mol)
    print(result_smi)
def add_atom_map_tags(m):
    n_atoms = m.GetNumAtoms()
    for aidx in range(n_atoms):
        m.GetAtomWithIdx(aidx).SetProp(
            'molAtomMapNumber', str(m.GetAtomWithIdx(aidx).GetIdx())
        )
    return m
if __name__ == "__main__":
    base = "CC(C)(C)Cl"
    insert = "CN(C)C"
    fuse_smiles(base, insert)
Изменяя индекс варианта, вы получите примерно такие строки:
CN(C)C[C:1]([CH3:2])([CH3:3])[Cl:4] для варианта 0
CN(C)C[C:1]([CH3:0])([CH3:3])[Cl:4] для варианта 1
CN(C)C[C:1]([CH3:0])([CH3:2])[Cl:4] для варианта 2
Однако желаемое отображение сохраняет исходную группировку и порядок, как они были набраны:
CN(C)C[C:1]([CH3:2])([CH3:3])[Cl:4] для варианта 0
[CH3:0][C:1](CN(C)C)([CH3:3])[Cl:4] для варианта 1
[CH3:0][C:1]([CH3:2])(CN(C)C)[Cl:4] для варианта 2
Что происходит
RDKit действительно заменяет ожидаемые подструктуры. Но на выходе видно, что он внутренне меняет или переупорядочивает атомы, приводя молекулу к форме, которую считает корректной или канонической. Для задач хемоинформатики это нормально, но если нужно сохранить точный текстовый порядок и разветвления для визуального вывода, где важны индексы и позиции, это мешает.
Практичное решение: подстановка на уровне строки с регулярными выражениями
Если приоритет — корректное отображение и нужно удержать исходную раскладку текста SMILES, относитесь к SMILES как к строке и подставляйте нужные токены, а не полагайтесь на RDKit для сохранения вашего порядка ветвлений. Идея в том, чтобы найти все вхождения [CH3:number] и каждое из них заменить вторым фрагментом, выпуская отдельные варианты для каждой потенциальной позиции замены — при этом остальной текст остаётся неизменным.
import re
template_str = "[CH3:0][C:1]([CH3:2])([CH3:3])[Cl:4]"
hits = re.findall(r'\[CH3:\d+\]', template_str)
print('hits:', hits)
variants = []
for token in hits:
    replaced_str = template_str.replace(token, 'CN(C)C')
    variants.append(replaced_str)
    print('variant:', replaced_str)
choice_idx = 0
print(choice_idx, variants[choice_idx])
Это даёт:
hits: ['[CH3:0]', '[CH3:2]', '[CH3:3]']
variant: CN(C)C[C:1]([CH3:2])([CH3:3])[Cl:4]
variant: [CH3:0][C:1](CN(C)C)([CH3:3])[Cl:4]
variant: [CH3:0][C:1]([CH3:2])(CN(C)C)[Cl:4]
0 CN(C)C[C:1]([CH3:2])([CH3:3])[Cl:4]
Чтобы переиспользовать это в вашем приложении, оберните логику в небольшую утилиту:
import re
def substitute_tokens(src, token_pattern, payload):
    found = re.findall(token_pattern, src)
    out = []
    for token in found:
        out.append(src.replace(token, payload))
    return out
# --- usage ---
source = "[CH3:0][C:1]([CH3:2])([CH3:3])[Cl:4]"
token_re = r'\[CH3:\d+\]'
payload = 'CN(C)C'
alts = substitute_tokens(source, token_re, payload)
for choice_idx in range(3):
    print(choice_idx, alts[choice_idx])
Вывод:
0 CN(C)C[C:1]([CH3:2])([CH3:3])[Cl:4]
1 [CH3:0][C:1](CN(C)C)([CH3:3])[Cl:4]
2 [CH3:0][C:1]([CH3:2])(CN(C)C)[Cl:4]
Почему это важно
В продуктах, где SMILES-текст напрямую используется для визуализации, сохранение точного порядка и ветвлений бывает не менее важно, чем химическая корректность. Если рендерер или логика разметки зависят от позиции или подписи атомов «как написано», не стоит позволять тулкиту менять их за кулисами. Подстановка на уровне строки даёт возможность удержать исходную раскладку и при этом предлагать несколько вариантов замены.
Итоги
Если нужны «химически осмысленные» правки, ReplaceSubstructs() справляется, но может менять порядок атомов. Если важна исходная верстка SMILES для отображения и контроль над тем, какой именно [CH3:number] заменяется, используйте строчный подход с регулярными выражениями и генерируйте нужные варианты. Следите за тем, что реально производит ваш код, простыми отладочными print, и убедитесь, что скрипт корректно отформатирован и структурирован — так вы сможете доверять просматриваемым результатам.