圖神經網絡12-分子指紋GCN:Neural FPs

1 Neural FPs論文簡介

論文:Convolutional Networks on Graphs for Learning Molecular Fingerprints 圖卷積網絡用於學習分子指紋
鏈接:http://arxiv.org/pdf/1509.09292.pdf
作者:David Duvenaud†, Dougal Maclaurin†, Jorge Aguilera-Iparraguirre (哈佛大學)
來源:NIPS 2015
代碼:https://github.com/HIPS/neural-fingerprint

本文引入了直接在圖上運行的卷積神經網絡,這些網絡允許端到端學習和預測,輸入是任意大小和形狀的Graph。 我們介紹的架構可以作爲基於圓形指紋的標準分子特徵提取方法,通過實驗表明這些基於Graph數據提取的特徵更具解釋性,並且在各種任務上具有更好的預測性能。

2 論文動機

最新材料設計方面的工作使用神經網絡在數據樣本中歸納總結,然後來預測新型分子的屬性或者性質。這類任務的一個難點是,對預測變量的輸入
分子的大小和形狀是任意的,沒有固定的。當前,大多數機器學習框架只能
處理固定大小的輸入,一般情況是使用現成的指紋軟件,計算固定尺寸的特徵向量,並將這些特徵用作全連接的輸入深度神經網絡或其他傳統的機器學習方法。在訓練期間,將分子指紋載體視爲固定的不變的。

在本文中,我們用一個輸入爲原始分子圖的可微神經網絡代替了該堆棧的底層——計算分子指紋向量的函數。在這個圖中,頂點代表單個原子,邊代表鍵。這個網絡的底層是卷積的,因爲相同的局部過濾器被應用到每個原子和它的鄰居。經過幾個這樣的層之後,一個全局池步驟結合了分子中所有原子的特徵。

這些神經圖指紋比固定指紋有幾個優點:

  • 預測能力強:通過實驗比較可以發現,我們的模型比傳統的指紋向量能夠提供更好的預測能力。

  • 模型簡潔:爲了對所有可能的子結構進行編碼,傳統的指紋向量必須維度非常高。而我們的模型只需要對相關特徵進行編碼,模型的維度相對而言低得多,這降低了下游的計算量。

  • 可解釋性:傳統的指紋向量對每個片段fragment 進行不同的編碼,片段之間沒有相似的概念。即:相似的片段不一定有相似的編碼;相似的編碼也不一定代表了相似的片段。

論文的模型中,每個特徵都可以由相似但是不同的分子片段激活,因此相似的片段具有相似的特徵,相似的特徵也代表了相似的片段。這使得特徵的representation 更具有意義。

3 分子指紋(Molecular Fingerprint)

在比較兩個化合物之間的相似性時遇到的最重要問題之一是任務的複雜性,這取決於分子表徵的複雜性。 爲了使分子的比較在計算上更容易,需要一定程度的簡化或抽象。分子指紋就是一種分子的抽象表徵,它將分子轉化(編碼)爲一系列比特串(即比特向量,bit vector .),然後可以很容易地在分子之間進行比較。典型的流程是將提取分子的結構特徵、然後哈希(Hashing)生成比特向量。


比較分子是很難的,比較比特串卻很容易,分子之間的比較必須以可量化的方式進行。分子指紋上的每個比特位對應於一種分子片段(Figure 2),假設相似的分子之間必然有許多公共的片段,那麼具有相似指紋的分子具有很大的概率在2D結構上也是相似的。


有十多種方法可以評估兩個向量之間的相似性(參見:Fingerprints-Screening and Similarity.),最常見的是歐幾里德距離。但對於分子指紋,行業標準是Tanimoto係數,它由兩個指紋中設置爲1的公共位數除以兩個指紋之間設置爲1的總位數組成。這意味着Tanimoto係數總是具有介於1和0之間的值,而不管指紋的長度如何,這導致指紋隨着指紋變長而變得鬆散。這種損失還意味着具有給定Tanimoto係數的兩個指紋實際上將如何相似地將極大地取決於所使用的指紋的類型,這使得不可能選擇用於確定兩個指紋是相似還是不相似的通用截止標準。然而,通過數據融合策略將分子指紋與其他相似係數相結合,可以提高分子指紋的性能[1]。表1列出了幾個與指紋一起使用的相似性和距離度量。

其中,給定兩個化合物A和B的指紋,m等於指紋中存在的總位數,a、b分別等於A、B中比特值爲1的位數,c等於A和B中公共的比特值爲1的位數。

4 圓形指紋算法

分子指紋的最新技術是擴展連接性圓形指紋 extended-connectivity circular fingerprints:ECFP 。ECFP 是對Morgan 算法的改進,旨在以無關於原子標記順序atom-relabling的方式來識別分子中存在哪些亞結構。

ECFP 通過對前一層鄰域的特徵進行拼接,然後採用一個固定的哈希函數來抽取當前層的特徵。哈希函數的結果視爲整數索引,然後對頂點 feature vector 在索引對應位置處填寫 1 。

不考慮hash 衝突,則指紋向量的每個索引都代表一個特定的亞結構。索引表示的亞結構的範圍取決於網絡深度,因此網絡的層數也被稱爲指紋的“半徑”。
ECFP 類似於卷積網絡,因爲它們都在局部採用了相同的操作,並且在全局池化中聚合信息。
ECFP 的計算框架如下圖所示:

首先通過分子結構構建分子圖,其中頂點表示原子、邊表示化學鍵。在每一層,信息在鄰域之間流動。圖的每個頂點在一個固定的指紋向量中佔據一個bit。其中這只是一個簡單的示意圖,實際上每一層都可以寫入指紋向量。


圓形指紋算法:

(1) 輸入:

  • 分子結構
  • 半徑參數R
  • 指紋向量長度S

(2) 輸出:

  • 指紋向量f

(3) 算法步驟:

  • 初始化指紋向量:f \longleftarrow 0_{s}
  • 遍歷每個原子a,獲取每個原子的特徵:r_{a} \longleftarrow g(a)
  • 遍歷每一層。對於第L層,迭代步驟爲:

遍歷分子中的每個原子a,對於每個原子a計算:
<1>獲取頂點a的領域原子的特徵:r_{1},...,r_{N}=neighbors(a)
<2>拼接頂點a及其領域原子特徵:
v \longleftarrow [r_{a},r_{1},...,r_{N}]
<3>執行哈希函數得到頂點a的當前特徵:
r_{a} \longleftarrow hash(v)
<4>執行索引函數:i\longleftarrow mod(r_{a},S)
<5>登記索引:f_{i} \longleftarrow1

(4)最後返回分子指紋向量(0-1):f

5 分子指紋GCN算法

分子指紋GCN算法選擇類似於現有ECFP 的神經網絡架構:

哈希操作Hashing:在ECFP 算法中,每一層採用哈希操作的目的是爲了組合關於每個原子及其鄰域子結構的信息。

本文利用一層神經網絡代替哈希運算。當分子的局部結構發生微小的變化時(神經網絡是可微的,因此也是平滑的),這種平滑函數可以得到相似的激活值。

索引操作Indexing:在 ECFP 算法中,每一層採用索引操作的目的是將每個原子的特徵向量組合成整個分子指紋。每個原子在其特徵向量的哈希值確定的索引處,將指紋向量的單個比特位設置爲1,每個原子對應一個1 。這種操作類似於池化,它可以將任意大小的Graph 轉換爲固定大小的向量。

這種索引操作的一個缺點是:當分子圖比較小而指紋長度很大時,最終得到的指紋向量非常稀疏。然後論文使用softmax 操作視作索引操作的一個可導的近似。本質上這是要求將每個原子劃分到一組類別的某個類別中。所有原子的這些類別向量的總和得到最終的指紋向量。其操作也類似於卷積神經網絡中的池化操作。

規範化Canonicalization:無論原子的鄰域原子的順序如何變化,圓形指紋是不變的。實現這種不變性的一種方式是:在算法過程中,根據相鄰原子的特徵和鍵特徵對相鄰原子進行排序。論文裏嘗試了這種排序方案,還對局部鄰域的所有可能排列應用了局部特徵變換。另外,一種替代方案是應用排序不變函數permutation-invariant, 如求和。爲了簡單和可擴展性,論文裏選擇直接求和。

GCN網絡指紋算法:

(1) 輸入:

  • 分子結構molecule
  • 半徑參數R
  • 隱藏參數:H_{1}^{1}...H_{R}^{5}
  • 輸出層參數:W_{1}...W_{R}

對不同的鍵數量,採用不同的隱層參數H(最多五個鍵)

(2) 輸出:(實數)指紋向量

(3) 算法步驟:

  • 初始化指紋向量:f \longleftarrow 0_{s}
  • 遍歷每個原子a,獲取每個原子的特徵:r_{a} \longleftarrow g(a)
  • 遍歷每一層。對於第L層,迭代步驟爲:

遍歷分子中的每個原子a,對於每個原子a計算:
<1>獲取頂點a的領域原子的特徵:r_{1},...,r_{N}=neighbors(a)
<2>池化頂點a及其領域r_{i}的原子的特徵:v \longleftarrow r_{a}+\sum_{i=1}^{N} r_{i}
<3>執行哈希函數 i \longleftarrow softmax(r_{a}W_{L})
<4> 登記索引:f \longleftarrow f+i

(4) 返回向量f

ECFP 圓形指紋可以解釋爲具有較大隨機權重的神經網絡指紋算法的特殊情況。在較大的輸入權重情況下,tanh激活函數接近階躍函數。而級聯的階躍函數類似於哈希函數。
在較大的輸入權重情況下,softmax 函數接近一個one-hot 的 argmax 操作,這類似於索引操作。

6 實驗部分

6.1 隨機權重

分子指紋的一個用途是計算分子之間的距離。這裏我們檢查基於 ECFP 的分子距離是否類似於基於隨機的神經網絡指紋的分子距離。

我們選擇指紋向量的長度爲 2048,並使用Jaccard 相似度來計算兩個分子的指紋向量之間的距離:


我們的數據集爲溶解度數據集,下圖爲使用圓形指紋和神經網絡指紋的成對距離散點圖,其相關係數爲:r=0.823
圖中每個點代表:相同的一對分子,採用圓形指紋計算到的分子距離、採用神經網絡指紋計算得到的分子距離,其中神經網絡指紋模型採用大的隨機權重。

距離爲1.0 代表兩個分子的圓形指紋沒有任何重疊;距離爲0.0 代表兩個分子的圓形指紋完全重疊。



我們將圓形指紋、隨機神經網絡指紋接入一個線性迴歸層,從而比較二者的預測性能。

圓形指紋、大的隨機權重的隨機神經網絡指紋,二者的曲線都有類似的軌跡。這表明:通過大的隨機權重初始化的隨機神經網絡指紋和圓形指紋類似。
較小隨機權重初始化的隨機神經網絡指紋,其曲線與前兩者不同,並且性能更好。
即使是未經訓練的神經網絡,神經網絡激活值的平滑性也能夠有助於模型的泛化。


6.2 可解釋性

圓形指紋向量的特徵(即某一組bit 的組合)只能夠通過單層的單個片段激活(偶然發生的哈希碰撞除外),神經網絡指紋向量的特徵可以通過相同結構的不同變種來激活,從而更加簡潔和可解釋。

爲證明神經網絡指紋是可接受的,我們展示了激活指紋向量中每個特徵對應的亞結構類別。

溶解性特徵:我們將神經網絡指紋模型作爲預訓溶解度的線性模型的輸入來一起訓練。下圖展示了對應的片段(藍色),這些片段可以最大程度的激活神經網絡指紋向量中最有預測能力的特徵。

上半圖:激活的指紋向量的特徵與溶解性具有正向的預測關係,這些特徵大多數被包含親水性R-OH 基團(溶解度的標準指標)的片段激活。
下半圖:激活的指紋向量的特徵與溶解性具有負向的預測關係(即:不溶解性),這些特徵大多數被非極性的重複環結構激活。



毒性特徵:我們用相同的架構來預測分子毒性。下圖展示了對應的片段(紅色),這些片段可以最大程度的激活神經網絡指紋向量中最有預測能力的特徵。

上半圖:激活的指紋向量的特徵與毒性具有正向的預測關係,這些特徵大多數被包含芳環相連的硫原子基團的片段激活。
下半圖:激活的指紋向量的特徵與毒性具有正向的預測關係,這些特徵大多數被稠合的芳環(也被稱作多環芳烴,一種著名的致癌物)激活。


6.3 模型比較

數據集:論文在多個數據集上比較圓形指紋和神經網絡指紋的性能:

  • 溶解度數據集:包含 1144 個分子,及其溶解度標記。
  • 藥物功效數據集:包含 10000 個分子,及其對惡行瘧原蟲(一種引發瘧疾的寄生蟲)的功效。
  • 有機光伏效率數據集:哈佛清潔能源項目使用昂貴的 DFT 模擬來估算有機分子的光伏效率,我們從該數據集中使用 20000 個分子作爲數據集。

論文中的 pipeline 將每個分子編碼的 SMILES 字符串作爲輸入,然後使用 RDKit 將其轉換爲Graph 。我們也使用 RDKit 生成的擴展圓形指紋作爲 baseline 。這個過程中,氫原子被隱式處理。

ECFP 和神經網絡中用到的特徵包括:

  • 原子特徵:原子元素類型的 one-hot、原子的度degree、連接氫原子的數量、隱含價implicit valence、極性指示aromaticity indicator。
  • 鍵特徵:是否單鍵、是否雙鍵、是否三鍵、是否芳族鍵、鍵是否共軛、鍵是否爲環的一部分。

結果如下圖所示。可以看到在所有實驗中,神經網絡指紋均達到或者超過圓形指紋的性能,並且使用神經網絡層的方式(neural net )超過了線性層的方式(linear layer)。


7 核心代碼

import autograd.numpy as np

from util import memoize, WeightsParser
from rdkit_utils import smiles_to_fps


def batch_normalize(activations):
    mbmean = np.mean(activations, axis=0, keepdims=True)
    return (activations - mbmean) / (np.std(activations, axis=0, keepdims=True) + 1)

def relu(X):
    "Rectified linear activation function."
    return X * (X > 0)

def sigmoid(x):
    return 0.5*(np.tanh(x) + 1)

def mean_squared_error(predictions, targets):
    return np.mean((predictions - targets)**2, axis=0)

def categorical_nll(predictions, targets):
    return -np.mean(predictions * targets)

def binary_classification_nll(predictions, targets):
    """Predictions is a real number, whose sigmoid is the probability that
     the target is 1."""
    pred_probs = sigmoid(predictions)
    label_probabilities = pred_probs * targets + (1 - pred_probs) * (1 - targets)
    return -np.mean(np.log(label_probabilities))

def build_standard_net(layer_sizes, normalize, L2_reg, L1_reg=0.0, activation_function=relu,
                       nll_func=mean_squared_error):
    """Just a plain old neural net, nothing to do with molecules.
    layer sizes includes the input size."""
    layer_sizes = layer_sizes + [1]

    parser = WeightsParser()
    for i, shape in enumerate(zip(layer_sizes[:-1], layer_sizes[1:])):
        parser.add_weights(('weights', i), shape)
        parser.add_weights(('biases', i), (1, shape[1]))

    def predictions(W_vect, X):
        cur_units = X
        for layer in range(len(layer_sizes) - 1):
            cur_W = parser.get(W_vect, ('weights', layer))
            cur_B = parser.get(W_vect, ('biases', layer))
            cur_units = np.dot(cur_units, cur_W) + cur_B
            if layer < len(layer_sizes) - 2:
                if normalize:
                    cur_units = batch_normalize(cur_units)
                cur_units = activation_function(cur_units)
        return cur_units[:, 0]

    def loss(w, X, targets):
        assert len(w) > 0
        log_prior = -L2_reg * np.dot(w, w) / len(w) - L1_reg * np.mean(np.abs(w))
        preds = predictions(w, X)
        return nll_func(preds, targets) - log_prior

    return loss, predictions, parser


def build_fingerprint_deep_net(net_params, fingerprint_func, fp_parser, fp_l2_penalty):
    """Composes a fingerprint function with signature (smiles, weights, params)
     with a fully-connected neural network."""
    net_loss_fun, net_pred_fun, net_parser = build_standard_net(**net_params)

    combined_parser = WeightsParser()
    combined_parser.add_weights('fingerprint weights', (len(fp_parser),))
    combined_parser.add_weights('net weights', (len(net_parser),))

    def unpack_weights(weights):
        fingerprint_weights = combined_parser.get(weights, 'fingerprint weights')
        net_weights         = combined_parser.get(weights, 'net weights')
        return fingerprint_weights, net_weights

    def loss_fun(weights, smiles, targets):
        fingerprint_weights, net_weights = unpack_weights(weights)
        fingerprints = fingerprint_func(fingerprint_weights, smiles)
        net_loss = net_loss_fun(net_weights, fingerprints, targets)
        if len(fingerprint_weights) > 0 and fp_l2_penalty > 0:
            return net_loss + fp_l2_penalty * np.mean(fingerprint_weights**2)
        else:
            return net_loss

    def pred_fun(weights, smiles):
        fingerprint_weights, net_weights = unpack_weights(weights)
        fingerprints = fingerprint_func(fingerprint_weights, smiles)
        return net_pred_fun(net_weights, fingerprints)

    return loss_fun, pred_fun, combined_parser


def build_morgan_fingerprint_fun(fp_length=512, fp_radius=4):

    def fingerprints_from_smiles(weights, smiles):
        # Morgan fingerprints don't use weights.
        return fingerprints_from_smiles_tuple(tuple(smiles))

    @memoize # This wrapper function exists because tuples can be hashed, but arrays can't.
    def fingerprints_from_smiles_tuple(smiles_tuple):
        return smiles_to_fps(smiles_tuple, fp_length, fp_radius)

    return fingerprints_from_smiles

def build_morgan_deep_net(fp_length, fp_depth, net_params):
    empty_parser = WeightsParser()
    morgan_fp_func = build_morgan_fingerprint_fun(fp_length, fp_depth)
    return build_fingerprint_deep_net(net_params, morgan_fp_func, empty_parser, 0)

def build_mean_predictor(loss_func):
    parser = WeightsParser()
    parser.add_weights('mean', (1,))
    def loss_fun(weights, smiles, targets):
        mean = parser.get(weights, 'mean')
        return loss_func(np.full(targets.shape, mean), targets)
    def pred_fun(weights, smiles):
        mean = parser.get(weights, 'mean')
        return np.full((len(smiles),), mean)
    return loss_fun, pred_fun, parser

8 參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章