** Код, использованный в этой статье, доступен здесь на моем GitHub.
Проблема
Процесс разработки и выпуска нового лекарственного препарата - это грандиозный подвиг. Часто первым шагом на пути к открытию лекарств является тестирование сотен тысяч лекарств-кандидатов in vitro с использованием высокопроизводительного скрининга - автоматизированных процессов, которые позволяют проводить простые фармакологические эксперименты параллельно с целевым лекарством. Как и следовало ожидать, этот процесс смехотворно дорог, требует много времени и ресурсов. Поэтому вычислительные подходы, позволяющие проводить скрининг кандидатов в лекарственные препараты in silico, очень востребованы; Такой тип системы может сократить количество необходимых физических экспериментов на порядки, резко ускорив открытие лекарств и снизив затраты. Эти типы подходов уже существуют, и вы можете увидеть мой любимый пример этой концепции в этой статье, опубликованной в Cell ранее в этом году.
В этой статье я собираюсь продемонстрировать, как мы можем создать очень простой скрипт Python и Torch, который принимает молекулу в качестве входных данных (представленную строкой SMILES) и классифицирует ее как нерастворимую, малорастворимую или растворимую в воды через глубокую нейронную сеть, обученную на наборе данных Harvard AqSolDB, который содержит список из 10000 соединений и их различных свойств. Как и следовало ожидать, растворимость - чрезвычайно важная характеристика потенциального лекарства. Лекарство должно быть способным распространяться в целевой (ые) области (ах) тела, и он не может этого сделать, если при пероральном приеме остается в основном в виде твердого вещества. Более того, было бы совершенно катастрофически, если бы соединение выскочило из раствора при внутривенном введении пациенту. Вы можете узнать больше о важности растворимости лекарств здесь.
Как мы вообще подходим к этому с помощью глубокого обучения?
Большинство нейронных сетей обычно принимают входные данные фиксированной размерности или, по крайней мере, входные данные, которые имеют фиксированные ограничения на их размерность. Например, сверточная нейронная сеть, обученная на изображениях размером 256x256 пикселей, не может даже обработать изображение размером 255x257 пикселей исключительно с архитектурной точки зрения (конечно, изображение можно обрезать, изменить размер или преобразовать таким образом, чтобы оно могло использоваться как вход). Другие сети, такие как рекуррентные нейронные сети (RNN), могут принимать входные данные произвольной длины, но требуют, чтобы все измерения, кроме длины, были фиксированными. Например, RNN, обученная на данных на уровне символов, может принимать последовательности размерности (Nx26), где N - длина последовательности, а 26 - вектор горячих признаков для каждой буквы алфавита. По сути, гибкость входных данных нейронной сети варьируется от модели к модели, но почти всегда существуют ограничения на входное измерение.
С другой стороны, молекулы сильно различаются по структуре. Даже молекулы с одинаковым числом и типом атомов, такие как октан (C8H20), можно расположить 18 различными способами. Другими словами, состав, длина и связность могут варьироваться между молекулами. Какую нейронную архитектуру мы можем использовать для обработки такого рода диких данных?
Во-первых, это помогает думать о молекулах как о неориентированных графах. Все молекулы могут быть представлены набором узлов и ребер. Тогда каждый узел в таком графе будет иметь набор функций, таких как элемент, который представляет узел, стереохимическая информация об этом атоме, состояние гибридизации, местоположение в физическом пространстве и т. Д. Итак, для начала давайте напишем некоторый код Python, который примет строку SMILES и преобразует ее в структуру графа NetworkX с помощью Pysmiles. Это позволит нам, по крайней мере, обрабатывать данные значимым образом.
import numpy as np from pysmiles import read_smiles G = read_smiles("CN(C)C(=N)N=C(N)N", explicit_hydrogen=True) print(G.nodes(data=’element’)) print(G.edges)
Это так просто - теперь у нас есть граф NetworkX, G, который содержит структуру нашей молекулы (в данном случае метформина). Результат этого фрагмента:
[(0, ‘C’), (1, ‘N’), (2, ‘C’), (3, ‘C’), (4, ‘N’), (5, ‘N’), (6, ‘C’), (7, ‘N’), (8, ‘N’), (9, ‘H’), (10, ‘H’), (11, ‘H’), (12, ‘H’), (13, ‘H’), (14, ‘H’), (15, ‘H’), (16, ‘H’), (17, ‘H’), (18, ‘H’), (19, ‘H’)] [(0, 1), (0, 9), (0, 10), (0, 11), (1, 2), (1, 3), (2, 12), (2, 13), (2, 14), (3, 4), (3, 5), (4, 15), (5, 6), (6, 7), (6, 8), (7, 16), (7, 17), (8, 18), (8, 19)]
Обратите внимание, как каждый узел от 0 до 19 имеет соответствующий элемент и как каждое ребро описывается упорядоченной парой (родительский, дочерний).
Веселая часть
Пришло время заняться глубоким обучением. Оказывается, существует множество исследований, посвященных обработке графов с помощью глубоких нейронных сетей, поскольку эта проблема имеет широкое применение. В этом проекте мы будем использовать сверточную сеть графов (GCN). GCN очень элегантно обрабатывают произвольную и разнообразную структуру графов. По сути, каждый узел на нашем графике имеет собственный вектор одномерных признаков фиксированной размерности. На каждом этапе обработки в нейронной сети узлы обмениваются информацией друг с другом, отправляя преобразованную версию своего вектора признаков своим соседним узлам. Он также обрабатывает свою собственную информацию, отправляя преобразованный вектор признаков самому себе. Преобразование, применяемое к вектору признаков, может быть представлено как обучаемая матрица весов, которую мы можем обучать с помощью градиентного спуска. Каждый узел берет все преобразованные векторы признаков от своих соседей и объединяет их с помощью операции сохранения размерности, такой как поэлементное среднее, максимальный пул или что-то еще. В нашем частном случае операция сохранения размерности немного сложна, но очень красива. Предлагаю прочитать эту отличную статью о GCN. Наконец, применяется функция активации нелинейности, такая как RELU, Tanh, Sigmoid или что-то еще. Наконец, мы можем использовать эти обработанные функции на каждом узле, чтобы вычислить что-то о свойствах каждого узла или, возможно, графа в целом. Например, если мы пытаемся предсказать возраст пользователя в социальной сети, мы могли бы использовать регрессию для функций в каждом узле, чтобы сделать прогноз. В нашем случае мы хотим провести классификацию графов, т.е. мы хотим классифицировать граф в целом как растворимый, нерастворимый или слабо растворимый. Для этого мы можем выполнить одну последнюю агрегацию с сохранением размерности всех узлов в графе и запустить классификационную сеть на этом последнем объекте.
Код
Мы будем использовать Python и PyTorch Geometric для обучения нашей модели. Pytorch Geometric построен на основе PyTorch и реализует множество графических сетевых слоев и алгоритмов, которые значительно упростят нам этот процесс. Во-первых, давайте уберем все наши операторы импорта, чтобы они не загромождали остальную часть нашего кода:
import numpy as np import random import matplotlib.pyplot as plt from pysmiles import read_smiles import pandas as pd import logging from tqdm import tqdm import torch from torch.nn import Sequential as Seq, Linear, ReLU, CrossEntropyLoss import torch.nn.functional as F from torch_geometric.nn import MessagePassing, GCNConv from torch_geometric.utils import remove_self_loops, add_self_loops, degree from torch_geometric.data import Data logging.getLogger(‘pysmiles’).setLevel(logging.CRITICAL) # Anything higher than warning
Загрузка данных
Во-первых, давайте загрузим наш набор данных с помощью Pandas и отформатируем его в структуру данных, которую использует PyTorch Geometric.
df = pd.read_csv(‘dataset.csv’) #read dataset X_smiles = list(df[‘SMILES’]) #get smiles strings Y = np.asarray(df[‘Solubility’]) #get solubility values #list of all elements in the dataset, which I've precomputed elements = [‘K’, ‘Y’, ‘V’, ‘Sm’, ‘Dy’, ‘In’, ‘Lu’, ‘Hg’, ‘Co’, ‘Mg’, ‘Cu’, ‘Rh’, ‘Hf’, ‘O’, ‘As’, ‘Ge’, ‘Au’, ‘Mo’, ‘Br’, ‘Ce’, ‘Zr’, ‘Ag’, ‘Ba’, ’N’, ‘Cr’, ‘Sr’, ‘Fe’, ‘Gd’, ‘I’, ‘Al’, ‘B’, ‘Se’, ‘Pr’, ‘Te’, ‘Cd’, ‘Pd’, ‘Si’, ‘Zn’, ‘Pb’, ‘Sn’, ‘Cl’, ‘Mn’, ‘Cs’, ‘Na’, ‘S’, ‘Ti’, ‘Ni’, ‘Ru’, ‘Ca’, ‘Nd’, ‘W’, ‘H’, ‘Li’, ‘Sb’, ‘Bi’, ‘La’, ‘Pt’, ‘Nb’, ‘P’, ‘F’, ‘C’] #convert element to a one-hot vector of dimension len(elements) def element_to_onehot(element): out = [] for i in range(0, len(element)): v = np.zeros(len(elements)) v[elements.index(element[i])] = 1.0 out.append(v) return np.asarray(out)
Выше мы извлекли строки SMILES и информацию о растворимости из файла .csv, определили список всех элементов в строках и определили функцию с именем element_to_onehot. Эта функция преобразует строку элементов («C», «Ru» и т. Д.) В 61-мерный одномерный вектор, который будет стартовыми характеристиками наших узлов.
def val_to_class(val): if val < -3.65: #insoluble return [1, 0, 0] elif val < -1.69: #slightly soluble return [0, 1, 0] else: #soluble return [0, 0, 1]
Далее мы определяем критерии растворимости в этой функции. По сути, если насыщенный раствор может содержать не более 10 ^ -3,65 моль / л, мы считаем это растворенное вещество нерастворимым. Если он не растворим, но может удерживать в растворе не более 10-1,69 моль / л, мы считаем его малорастворимым. В противном случае результат очень растворим. Конечно, это не лучшее определение растворимости, и я просто сделал это так, чтобы набор данных разделился на четные трети. Однако это подойдет для нашего примера.
#process SMILES strings into graphs nodes = [] edge_index = [] for smiles in tqdm(X_smiles): try: G = read_smiles(smiles, explicit_hydrogen=True) feature = element_to_onehot(np.asarray(G.nodes(data=’element’))[:, 1]) edges = np.asarray(G.edges) index = np.asarray([edges[:,0], edges[:,1]]) nodes.append(feature) edge_index.append(index) except: pass
Здесь мы преобразуем строки SMILES в графики, как делали раньше, и сохраняем их в списках. У нас есть блок try / except, чтобы игнорировать любые ошибки при обработке строки SMILES. TQDM используется как индикатор выполнения.
#Generate Data objects data = list() #process graphs into torch_geometric Data objects for i in tqdm(range(0, len(nodes))): x = torch.tensor(nodes[i], dtype=torch.float) edges = torch.tensor(edge_index[i], dtype=torch.long) y = torch.tensor([val_to_class(Y[i])], dtype=torch.float) data.append(Data(x=x,edge_index=edges, y=y)) random.shuffle(data) train = data[:int(len(data)*0.8)] #train set test = data[int(len(data)*0.8):] #val set train = data
Затем мы генерируем объекты PyTorch Geometric Data (). Для этого просто требуется список характеристик узла (x) и индекс ребра (ребра). Мы также добавляем нашу метку данных (y) к объекту в целях обучения. Наконец, мы разделяем наш набор данных на обучающие и проверочные наборы с разделением 80:20.
Определение сети
Наконец-то мы готовы определить нашу сверточную сеть на основе графов. Мы настроили его так же, как и любую другую сеть PyTorch, поэтому я вставлю блок кода ниже, и мы пройдемся по частям кода, которые являются уникальными и важными.
class Net(torch.nn.Module): def __init__(self): super(Net, self).__init__() self.conv1 = GCNConv(61, 32) self.conv2 = GCNConv(32, 32) self.conv3 = GCNConv(32, 32) self.conv4 = GCNConv(32, 32) self.lin1 = Linear(32, 16) self.lin2 = Linear(16, 3) def forward(self, data): x, edge_index= data.x, data.edge_index x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, p=0.25, training=self.training) x = self.conv2(x, edge_index) x = F.relu(x) x = F.dropout(x, p=0.25, training=self.training) x = self.conv3(x, edge_index) x = F.relu(x) x = F.dropout(x, p=0.25, training=self.training) x = self.conv4(x, edge_index) x = F.relu(x) x = torch.sum(x, dim=0) x = self.lin1(x) x = F.relu(x) x = self.lin2(x) return x
Во-первых, мы используем слои GCNConv, которые принимают входное и выходное измерения. Обратите внимание, как наш первый слой принимает входной размер 61, что является нашим горячим измерением, и выводит размер 32. После этого слоя у нас будет набор узлов, каждый из которых имеет вектор признаков размером 32. Перед нашим первым На линейном слое я суммирую значения всех характеристик узла поэлементно, чтобы получить один 32-мерный вектор, по которому мы будем выполнять классификацию через обычную полносвязную нейронную сеть. Обратите внимание, что мы не применяем SoftMax или какую-либо нормализацию к окончательному результату размера 3 (представляющему наши 3 класса) - это потому, что функция потерь, которую мы используем позже, сделает это автоматически.
Обучение
Пришло время приступить к обучению модели. Давайте сначала инициализируем нашу среду:
#set up device and create model device = torch.device(‘cuda’ if torch.cuda.is_available() else ‘cpu’) #use CUDA if available model = Net().to(device) #create network and send to the device memory optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) #use Adam optimizer CSE = CrossEntropyLoss()
Здесь мы определяем устройство, которое используем, объявляем нашу модель и переносим ее в память устройства, объявляем наш оптимизатор (Адам) и объявляем нашу функцию потерь (кросс-энтропия).
Теперь мы определим наш основной цикл обучения:
#train model model.train() #set model to training mode for epoch in range(32): #run for epochs of training sum_loss = 0 #used to compute average loss in an epoch num_correct = 0 random.shuffle(train) #shuffle the training data each epoch for d in tqdm(train): #go over each training point data = d.to(device) #send data to device optimizer.zero_grad() #zero gradients out = model(data) #evaluate data point if torch.argmax(out) == torch.argmax(data.y): #if prediction is correct, increment counter for accuracy calculation num_correct += 1 loss = CSE(torch.reshape(out, [1, 3]), torch.reshape(torch.argmax(data.y),[1])) #compute loss sum_loss += float(loss) #add loss value to aggregate loss loss.backward() #compute gradients optimizer.step() #apply optimization print(‘Epoch: {:03d}, Average loss: {:.5f}, Accuracy: {:.5f}’.format(epoch, sum_loss/len(train), num_correct/len(train)))
На самом деле это ничем не отличается от обычного цикла обучения PyTorch, поэтому не нужно много говорить об этом фрагменте кода. После 32 эпох у нас есть модель, точность которой составляет около 43% по нашему набору проверки. По сравнению со случайным угадыванием (точность 33%) мы видим повышение точности примерно на 10%. Конечно, это не очень хорошо, но растворимость - невероятно сложная проблема, и удивительно, что мы видим какое-либо повышение производительности при такой упрощенной настройке.
Тестируем это
Давайте немного поиграем с моделью, чтобы увидеть, чему она научилась.
Во-первых, давайте скармливаем ему строку улыбки для декана (C10H22), углеводорода, который в основном нерастворим в воде.
>>>evaluate_smiles(‘CCCCCCCCCC’) insoluble
Здорово! Теперь давайте добавим функциональные группы карбоновых кислот к концам нашей молекулы и посмотрим, как это влияет на растворимость.
>>>evaluate_smiles(‘C(=O)(O)CCCCCCCCC(=O)(O)’) slightly soluble
Действительно, молекула мало растворима в воде. Замечательно, похоже, модель работает на этот случай!
Хорошо, давай попробуем что-нибудь посложнее. Давайте дадим ему структуру УЛЫБКИ аспирина:
>>>evaluate_smiles(‘O=C(C1=C(OC(C)=O)C=CC=C1)O’) soluble
Я очень на это надеюсь! Аспирин лучше растворим, иначе я последние несколько лет принимал плацебо от головных болей ...
Однако модель не идеальна… она сообщает, что бензол «слабо растворим» в воде, что, конечно же, неверно:
>>>evaluate_smiles(‘c1ccccc1’) slightly soluble
Когда мы добавляем к нашему бензолу пару трет-бутильных групп, модель сообщает, что он полностью нерастворим, и это здорово!
>>>evaluate_smiles('CC(C)(C)c1ccc(C(C)(C)C)cc1') insoluble
Хорошо, для последнего теста давайте попробуем протеин-инсулин и посмотрим, как он с ним справится ...
>>>evaluate_smiles(‘CCC(C)C1C(=O)NC2CSSCC(C(=O)NC(CSSCC(C(=O)NCC(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(C(=O)NC(CSSCC(NC(=O)C(NC(=O)C(NC(=O)C(NC(=O)C(NC(=O)C(NC(=O)C(NC(=O)C(NC(=O)C(NC2=O)CO)CC(C)C)CC3=CC=C(C=C3)O)CCC(=O)N)CC(C)C)CCC(=O)O)CC(=O)N)CC4=CC=C(C=C4)O)C(=O)NC(CC(=O)N)C(=O)O)C(=O)NCC(=O)NC(CCC(=O)O)C(=O)NC(CCCNC(=N)N)C(=O)NCC(=O)NC(CC5=CC=CC=C5)C(=O)NC(CC6=CC=CC=C6)C(=O)NC(CC7=CC=C(C=C7)O)C(=O)NC(C(C)O)C(=O)N8CCCC8C(=O)NC(CCCCN)C(=O)NC(C)C(=O)O)C(C)C)CC(C)C)CC9=CC=C(C=C9)O)CC(C)C)C)CCC(=O)O)C(C)C)CC(C)C)CC2=CN=CN2)CO)NC(=O)C(CC(C)C)NC(=O)C(CC2=CN=CN2)NC(=O)C(CCC(=O)N)NC(=O)C(CC(=O)N)NC(=O)C(C(C)C)NC(=O)C(CC2=CC=CC=C2)N)C(=O)NC(C(=O)NC(C(=O)N1)CO)C(C)O)NC(=O)C(CCC(=O)N)NC(=O)C(CCC(=O)O)NC(=O)C(C(C)C)NC(=O)C(C(C)CC)NC(=O)CN’) insoluble
Хм, это же неожиданно, не правда ли? Мы можем вводить инсулин прямо в наши вены. На самом деле инсулин нерастворим в чистой воде, но легко растворяется в плазме крови. Это отличный пример ограничений такой модели. Соединения, считающиеся растворимыми / нерастворимыми в воде, могут вести себя иначе в физиологической среде! Кроме того, такие белки, как инсулин, образуют особую трехмерную структуру, которую эта модель явно не принимает во внимание. Таким образом, для любых больших молекул эта модель, вероятно, будет очень неточной.
Наконец, давайте попробуем суперрастворимое органическое соединение, витамин С:
>>>evaluate_smiles(‘OC([C@H](O1)[C@H](CO)O)=C(O)C1=O’) soluble
Фух! Было бы неловко, если бы он ошибся.
Заворачивать
Используя простую сверточную сеть графов в PyTorch Geometric, мы смогли создать систему классификации, которая могла бы взять произвольное графическое представление молекулы и предсказать ее растворимость с точностью на 10% лучше, чем случайное предположение. Не очень хорошо, но потрясающе, учитывая сложность задачи! Мы также немного повеселились, играя с разными молекулами в нашем программном обеспечении.