学习目标
跑通baseline,学习了解RNN
进一步掌握学习调参过程
背景知识
SMILES —— 最流行的将分子表示为序列类型数据的方法
SMILES,提出者Weininger et al,全称是Simplified Molecular Input Line Entry System,是一种将化学分子用ASCII字符表示的方法,在化学信息学领域有着举足轻重的作用。当前对于分子和化学式的储存形式,几乎都是由SMILES(或者它的一些手足兄弟)完成的。使用非常广泛的分子/反应数据库,例如ZINC,ChemBL,USPTO等,都是采用这种形式存储。SMILES将化学分子中涉及的原子、键、电荷等信息,用对应的ASCII字符表示;环、侧链等化学结构信息,用特定的书写规范表达。以此,几乎所有的分子都可以用特定的SMILES表示,且SMILES的表示还算比较直观。
表1:一些常见的化学结构用SMILES表示。
表2:化学反应也可以用SMILES表示,用“>>”连接产物即可。
表3:一些常见分子的SMILES例子
在SMILES中,原子由他们的化学符号表示,=表示双键、#表示三键、[]里面的内容表示侧基或者特殊原子(例如[Cu+2]表示带电+2电荷的Cu离子)。通过SMLIES,就可以把分子表示为序列类型的数据了。
深度学习
深度学习可以归为机器学习的一个子集,主要通过神经网络学习数据的特征和分布。深度学习的一个重要进化是不再需要繁琐的特征工程,让神经网络自己从里面学习特征。
SMILES是一种以ASCII组成的序列,可以被理解为一种“化学语言”。既然是一种语言,那么很自然地想到了可以使用NLP中的方法对SMILES进行建模。
使用RNN对SMILES建模是早期的一个主要方法。RNN(Recurrent Neural Network)是处理序列数据的一把好手。RNN的网络每层除了会有自己的输出以外,还会输出一个隐向量到下一层。
图5 RNN的架构示意图
通过隐向量的不断传递,序列后面的部分就通过“阅读”隐向量,获取前面序列的信息,从而提升学习能力。
但是RNN也有缺点:如果序列太长,那么两个相距比较远的字符之间的联系需要通过多个隐藏向量。这就像人和人之间传话一样,传递的人多了,很容易导致信息的损失或者扭曲。因此,它对长序列的记忆能力较弱。
同时,RNN需要一层一层地传递,所以并行能力差,同时也比较容易出现梯度消失或梯度爆炸问题。
具体代码剖析
定义RNN模型
# 定义RNN模型
class RNNModel(nn.Module):
def __init__(self, num_embed, input_size, hidden_size, output_size, num_layers, dropout, device):
super(RNNModel, self).__init__()
self.embed = nn.Embedding(num_embed, input_size)
self.rnn = nn.RNN(input_size, hidden_size, num_layers=num_layers,
batch_first=True, dropout=dropout, bidirectional=True)
self.fc = nn.Sequential(nn.Linear(2 * num_layers * hidden_size, output_size),
nn.Sigmoid(),
nn.Linear(output_size, 1),
nn.Sigmoid())
def forward(self, x):
# x : [bs, seq_len]
x = self.embed(x)
# x : [bs, seq_len, input_size]
_, hn = self.rnn(x) # hn : [2*num_layers, bs, h_dim]
hn = hn.transpose(0,1)
z = hn.reshape(hn.shape[0], -1) # z shape: [bs, 2*num_layers*h_dim]
output = self.fc(z).squeeze(-1) # output shape: [bs, 1]
return output
RNNModel类定义了一个RNN模型,它包含以下几个组件:
-
embed:一个嵌入层,用于将输入序列表示为高维空间。
-
rnn:一个RNN层,用于处理序列数据。它包含一个RNN模块,用于计算每个时间步的隐藏状态。
-
fc:一个全连接层,用于将RNN层的输出表示为输出空间。它包含一个线性层、一个Sigmoid层和一个线性层。
RNNModel类还提供了一个forward方法,用于将输入序列x经过嵌入层、RNN层和全连接层后,得到输出z和最终输出output。
定义数据处理函数和tokenizer
# import matplotlib.pyplot as plt
## 数据处理部分
# tokenizer,鉴于SMILES的特性,这里需要自己定义tokenizer和vocab
# 这里直接将smiles str按字符拆分,并替换为词汇表中的序号
class Smiles_tokenizer():
def __init__(self, pad_token, regex, vocab_file, max_length):
self.pad_token = pad_token
self.regex = regex
self.vocab_file = vocab_file
self.max_length = max_length
with open(self.vocab_file, "r") as f:
lines = f.readlines()
lines = [line.strip("\n") for line in lines]
vocab_dic = {}
for index, token in enumerate(lines):
vocab_dic[token] = index
self.vocab_dic = vocab_dic
def _regex_match(self, smiles):
regex_string = r"(" + self.regex + r"|"
regex_string += r".)"
prog = re.compile(regex_string)
tokenised = []
for smi in smiles:
tokens = prog.findall(smi)
if len(tokens) > self.max_length:
tokens = tokens[:self.max_length]
tokenised.append(tokens) # 返回一个所有的字符串列表
return tokenised
def tokenize(self, smiles):
tokens = self._regex_match(smiles)
# 添加上表示开始和结束的token:<cls>, <end>
tokens = [["<CLS>"] + token + ["<SEP>"] for token in tokens]
tokens = self._pad_seqs(tokens, self.pad_token)
token_idx = self._pad_token_to_idx(tokens)
return tokens, token_idx
def _pad_seqs(self, seqs, pad_token):
pad_length = max([len(seq) for seq in seqs])
padded = [seq + ([pad_token] * (pad_length - len(seq))) for seq in seqs]
return padded
def _pad_token_to_idx(self, tokens):
idx_list = []
for token in tokens:
tokens_idx = []
for i in token:
if i in self.vocab_dic.keys():
tokens_idx.append(self.vocab_dic[i])
else:
self.vocab_dic[i] = max(self.vocab_dic.values()) + 1
tokens_idx.append(self.vocab_dic[i])
idx_list.append(tokens_idx)
return idx_list
# 读数据并处理
def read_data(file_path, train=True):
df = pd.read_csv(file_path)
reactant1 = df["Reactant1"].tolist()
reactant2 = df["Reactant2"].tolist()
product = df["Product"].tolist()
additive = df["Additive"].tolist()
solvent = df["Solvent"].tolist()
if train:
react_yield = df["Yield"].tolist()
else:
react_yield = [0 for i in range(len(reactant1))]
# 将reactant拼到一起,之间用.分开。product也拼到一起,用>分开
input_data_list = []
for react1, react2, prod, addi, sol in zip(reactant1, reactant2, product, additive, solvent):
input_info = ".".join([react1, react2])
input_info = ">".join([input_info, prod])
input_data_list.append(input_info)
output = [(react, y) for react, y in zip(input_data_list, react_yield)]
# 下面的代码将reactant\additive\solvent拼到一起,之间用.分开。product也拼到一起,用>分开
'''
input_data_list = []
for react1, react2, prod, addi, sol in zip(reactant1, reactant2, product, additive, solvent):
input_info = ".".join([react1, react2, addi, sol])
input_info = ">".join([input_info, prod])
input_data_list.append(input_info)
output = [(react, y) for react, y in zip(input_data_list, react_yield)]
'''
# # 统计seq length,序列的长度是一个重要的参考,可以使用下面的代码统计查看以下序列长度的分布
# seq_length = [len(i[0]) for i in output]
# seq_length_400 = [len(i[0]) for i in output if len(i[0])>200]
# print(len(seq_length_400) / len(seq_length))
# seq_length.sort(reverse=True)
# plt.plot(range(len(seq_length)), seq_length)
# plt.title("templates frequence")
# plt.show()
return output
class ReactionDataset(Dataset):
def __init__(self, data: List[Tuple[List[str], float]]):
self.data = data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
def collate_fn(batch):
REGEX = r"\[[^\]]+]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p|\(|\)|\.|=|#|-|\+|\\\\|\/|:|~|@|\?|>|\*|\$|\%[0-9]{2}|[0-9]"
tokenizer = Smiles_tokenizer("<PAD>", REGEX, "../vocab_full.txt", max_length=300)
smi_list = []
yield_list = []
for i in batch:
smi_list.append(i[0])
yield_list.append(i[1])
tokenizer_batch = torch.tensor(tokenizer.tokenize(smi_list)[1])
yield_list = torch.tensor(yield_list)
return tokenizer_batch, yield_list
首先读取训练数据,并存储在read_data函数中。读取的数据是SMILES字符串和对应的反应物和产物的产率。使用Subset类从数据集中随机选取N个样本,构成一个子集。创建一个DataLoader类,用于将数据集分成小批量,并随机打乱顺序。使用collate_fn函数将SMILES字符串和目标值进行合并,以便在GPU上运行模型。创建一个RNN模型,包括embedding层、RNN层和输出层。嵌入层用于将SMILES字符串编码为固定长度的向量,以便在RNN层中进行处理。RNN层用于对SMILES字符串进行处理,并生成输出向量。输出层用于输出每个样本的输出向量。模型还包括一些超参数,如dropout率和CLIP值。将模型初始化为训练模式,以便在训练过程中更新模型参数。设置损失函数为L1损失函数。创建Adam优化器,用于更新模型参数。在训练循环中:每次迭代将输入数据src和目标值y转换为设备变量,以便在GPU上运行模型。计算模型的输出和损失函数,并将损失反向传播到模型参数。使用clip方法限制梯度的L2范数。更新模型参数。保存模型状态。
调参尝试
初次使用默认数据进行运行时得到的结果是0.0724,感觉中规中矩,但是运行了超级久,差不多3.6h;然后把Dropout的值改成了0.4,得到的结果有所上升,达到了0.1036,可能是减少了过拟合;最后我将HIDDEN_SIZE = 550,NUM_LAYERS = 15,Dropout = 0.5,得到的结果为0.0869,略有上升。
从第二次到第三次尝试变化Dropout的值增大了,增加了正则化的强度;同时增加层数(从10到15)和隐藏层大小(从512到550),进而增加了模型的容量,如果数据集足够大且包含足够复杂的模式,那么增加模型容量可能有助于模型更好地拟合数据,从而提高性能。当然也可能是训练过程的随机性导致的这次结果的上升,因为上升的幅度并不明显。