学习笔记
Task3:Transformer建模SMILES进行反应产率预测
前期学习回顾:在Task2,我们使用RNN建模SMILES,且发现了RNN在处理这类问题的局限性。因此,本节我们学习Transformer这种更加强大和高效的序列建模算法。
一、基本知识点-Transformer
(以下内容为简洁版,详细内容见手册:数据鲸 (linklearner.com))
1、RNN的局限性
- RNN在处理长序列时,由于信息逐渐被遗忘,导致长程依赖问题难以捕捉。
- 卷积神经网络(CNN)在处理长文本时,受限于固定的上下文窗口,需要多层卷积操作。
2、 Transformer的优势
- 通过注意力机制,Transformer能够全局地建模序列依赖,不受序列长度的限制。
- 并行计算能力,提高了计算效率。
3、Transformer的基本架构
- 嵌入层:将token转换为向量表示,并加入位置编码,以保留序列中单词的位置信息。
- 自注意力层:通过查询(Query)、键(Key)、值(Value)的机制,计算上下文单词的权重得分,聚合上下文信息。
- 前馈层:通常是一个线性层,有助于提升模型性能。
- 残差连接:通过直连通道连接子层的输入和输出,避免梯度消失问题。
- 层归一化:对每一层的输出进行归一化,提高模型的稳定性和收敛速度。
4、Transformer的应用
- 使用Transformer的Encoder部分作为编码器,将SMILES字符串编码为向量表示。
- 将编码后的向量通过线性层,预测化学反应的产率。
二、实践原始代码解析
1、导入相关的库和包
import pandas as pd # 导入pandas库,用于数据处理和分析
from torch.utils.data import Dataset, DataLoader, Subset # 从torch库中导入数据集、数据加载器和子集类
from typing import List, Tuple # 导入List和Tuple类型注解
import re # 导入正则表达式库
import torch # 导入PyTorch库,用于深度学习模型的构建和训练
import torch.nn as nn # 导入PyTorch神经网络模块
import time # 导入时间库,用于计时等操作
import torch.optim as optim # 导入PyTorch优化器模块
2、 定义相关函数
# 定义一个SMILES_tokenizer类,用于处理SMILES字符串并将其转换为数字序列
class Smiles_tokenizer():
def __init__(self, pad_token, regex, vocab_file, max_length):
self.pad_token = pad_token # 填充符号,用于在序列长度不足时进行填充
self.regex = regex # 正则表达式,用于匹配SMILES字符串中的字符
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"|" # 构造正则表达式字符串,用于匹配SMILES字符串中的字符
regex_string += r".)" # 添加任意字符匹配规则
prog = re.compile(regex_string) # 编译正则表达式
tokenised = [] # 创建一个空列表,用于存储分词后的结果
for smi in smiles:
tokens = prog.findall(smi) # 使用正则表达式匹配SMILES字符串中的字符
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) # 调用_regex_match方法对输入的SMILES字符串进行分词
# 添加表示开始和结束的特殊标记:<CLS>和<SEP>
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 = [] # 创建一个空列表,用于存储数字索引序列
new_vocab = [] # 创建一个空列表,用于存储新的词汇表中的字符
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:
new_vocab.append(i) # 如果字符不在词汇表中,则将其添加到新的词汇表中
self.vocab_dic[i] = max(self.vocab_dic.values()) + 1 # 为新字符分配一个新的索引
tokens_idx.append(self.vocab_dic[i]) # 将新字符的索引添加到数字索引序列中
idx_list.append(tokens_idx) # 将当前分词结果的数字索引序列添加到总的数字索引序列列表中
with open("../new_vocab_list.txt", "a") as f:
for i in new_vocab:
f.write(i)
f.write("\n")
return idx_list # 返回数字索引序列列表
def _save_vocab(self, vocab_path):
with open(vocab_path, "w") as f:
for i in self.vocab_dic.keys():
f.write(i)
f.write("\n")
print("update new vocab!") # 打印更新词汇表的信息
3、数据处理
def read_data(file_path, train=True):
df = pd.read_csv(file_path) # 使用pandas读取CSV文件
reactant1 = df["Reactant1"].tolist() # 将"Reactant1"列转换为列表
reactant2 = df["Reactant2"].tolist() # 将"Reactant2"列转换为列表
product = df["Product"].tolist() # 将"Product"列转换为列表
additive = df["Additive"].tolist() # 将"Additive"列转换为列表
solvent = df["Solvent"].tolist() # 将"Solvent"列转换为列表
if train:
react_yield = df["Yield"].tolist() # 如果train为True,则获取"Yield"列并转换为列表
else:
react_yield = [0 for i in range(len(reactant1))] # 如果train为False,则创建一个全为0的列表,长度与reactant1相同
# 将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]) # 拼接reactant1和reactant2
input_info = ">".join([input_info, prod]) # 拼接上product
input_data_list.append(input_info) # 添加到input_data_list中
output = [(react, y) for react, y in zip(input_data_list, react_yield)] # 创建输出列表,包含输入数据和反应产量
return output # 返回输出列表
4、定义数据集的类
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] # 根据索引返回数据集中的元素
5、 定义批处理函数
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", 300)
smi_list = []
yield_list = []
for i in batch:
smi_list.append(i[0]) # 提取输入数据中的SMILES字符串
yield_list.append(i[1]) # 提取输入数据中的产量
tokenizer_batch = torch.tensor(tokenizer.tokenize(smi_list)[1]) # 对SMILES字符串进行分词并转换为张量
yield_list = torch.tensor(yield_list) # 将产量列表转换为张量
return tokenizer_batch, yield_list # 返回分词后的张量和产量张量
6、定义模型的类
class TransformerEncoderModel(nn.Module):
def __init__(self, input_dim, d_model, num_heads, fnn_dim, num_layers, dropout):
super().__init__()
self.embedding = nn.Embedding(input_dim, d_model) # 定义嵌入层,将输入维度映射到d_model维空间
self.layerNorm = nn.LayerNorm(d_model) # 定义层归一化
self.encoder_layer = nn.TransformerEncoderLayer(d_model=d_model,
nhead=num_heads,
dim_feedforward=fnn_dim,
dropout=dropout,
batch_first=True,
norm_first=True) # 定义Transformer编码器层
self.transformer_encoder = nn.TransformerEncoder(self.encoder_layer,
num_layers=num_layers,
norm=self.layerNorm) # 定义Transformer编码器
self.dropout = nn.Dropout(dropout) # 定义Dropout层
self.lc = nn.Sequential(nn.Linear(d_model, 256), # 定义一个线性层序列,用于最后的预测
nn.Sigmoid(),
nn.Linear(256, 96),
nn.Sigmoid(),
nn.Linear(96, 1))
def forward(self, src):
embedded = self.dropout(self.embedding(src)) # 对输入进行嵌入并应用Dropout
outputs = self.transformer_encoder(embedded) # 通过Transformer编码器得到输出
z = outputs[:,0,:] # 取第一个位置的输出作为特征表示
outputs = self.lc(z) # 通过线性层序列得到最终的预测结果
return outputs.squeeze(-1) # 压缩最后一个维度并返回结果
7、定义优化器学习率
def adjust_learning_rate(optimizer, epoch, start_lr):
"""Sets the learning rate to the initial LR decayed by 10 every 30 epochs"""
# 计算当前学习率,初始学习率乘以一个衰减因子,每30个epoch衰减一次
lr = start_lr * (0.1 ** (epoch // 3))
# 遍历优化器中的参数组
for param_group in optimizer.param_groups:
# 更新每个参数组的学习率为计算出的新学习率
param_group['lr'] = lr
8、训练
def train():
# 超参数设置
N = 10 # 训练集大小,可以设置为数据集大小的一定比例,如 int(len(dataset) * 0.1)
INPUT_DIM = 292 # 输入维度,即源数据的长度
D_MODEL = 512 # Transformer模型的隐藏层大小
NUM_HEADS = 4 # Transformer模型的多头注意力机制的头数
FNN_DIM = 1024 # Transformer模型中的前馈神经网络的隐藏层大小
NUM_LAYERS = 4 # Transformer模型的层数
DROPOUT = 0.2 # Dropout概率
CLIP = 1 # 梯度裁剪值
N_EPOCHS = 40 # 训练轮数
LR = 1e-4 # 学习率
start_time = time.time() # 开始计时
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 选择设备(GPU或CPU)
# device = 'cpu'
data = read_data("../dataset/round1_train_data.csv") # 读取训练数据
dataset = ReactionDataset(data) # 创建数据集对象
subset_indices = list(range(N)) # 获取训练集索引
subset_dataset = Subset(dataset, subset_indices) # 创建子集数据集
train_loader = DataLoader(dataset, batch_size=128, shuffle=True, collate_fn=collate_fn) # 创建数据加载器
model = TransformerEncoderModel(INPUT_DIM, D_MODEL, NUM_HEADS, FNN_DIM, NUM_LAYERS, DROPOUT) # 创建Transformer模型
model = model.to(device) # 将模型移动到指定设备
model.train() # 设置模型为训练模式
optimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=0.01) # 创建优化器
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=10) # 创建学习率调整器
criterion = nn.MSELoss() # 创建损失函数
best_valid_loss = 10 # 初始化最佳验证损失
for epoch in range(N_EPOCHS): # 遍历每个训练轮次
epoch_loss = 0 # 初始化当前轮次的损失
# adjust_learning_rate(optimizer, epoch, LR) # 动态调整学习率(注释掉的部分表示未使用动态调整学习率的功能)
for i, (src, y) in enumerate(train_loader): # 遍历数据加载器中的每个批次
src, y = src.to(device), y.to(device) # 将数据移动到指定设备
optimizer.zero_grad() # 清空梯度
output = model(src) # 前向传播计算输出
loss = criterion(output, y) # 计算损失
loss.backward() # 反向传播计算梯度
torch.nn.utils.clip_grad_norm_(model.parameters(), CLIP) # 梯度裁剪
optimizer.step() # 更新模型参数
epoch_loss += loss.detach().item() # 累加损失
if i % 50 == 0: # 每50个批次打印一次训练损失
print(f'Step: {i} | Train Loss: {epoch_loss:.4f}')
loss_in_a_epoch = epoch_loss / len(train_loader) # 计算当前轮次的平均损失
scheduler.step(loss_in_a_epoch) # 根据平均损失调整学习率
print(f'Epoch: {epoch+1:02} | Train Loss: {loss_in_a_epoch:.3f}') # 打印当前轮次的训练损失
if loss_in_a_epoch < best_valid_loss: # 如果当前轮次的损失小于最佳验证损失
best_valid_loss = loss_in_a_epoch # 更新最佳验证损失
# 在训练循环结束后保存模型
torch.save(model.state_dict(), '../model/transformer.pth')
end_time = time.time() # 结束计时
# 计算并打印运行时间
elapsed_time_minute = (end_time - start_time)/60
print(f"Total running time: {elapsed_time_minute:.2f} minutes")
if __name__ == '__main__':
train()
注意:此处代码出现点小问题: loss_in_a_epoch在被赋值前调用,
修改方案如下: scheduler.step(loss_in_a_epoch)放置在loss_in_a_epoch后,见上述图片。
9、预测并生成提交文件
# 定义一个函数,用于预测并生成提交文件
def predicit_and_make_submit_file(model_file, output_file):
# 设置输入维度为292(源序列长度)
INPUT_DIM = 292
# 设置模型的隐藏层维度为512
D_MODEL = 512
# 设置多头注意力机制的头数为4
NUM_HEADS = 4
# 设置前馈神经网络的维度为1024
FNN_DIM = 1024
# 设置Transformer编码器的层数为4
NUM_LAYERS = 4
# 设置dropout率为0.2
DROPOUT = 0.2
# 判断是否有可用的GPU,如果有则使用GPU,否则使用CPU
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 读取测试数据
test_data = read_data("../dataset/round1_test_data.csv", train=False)
# 创建ReactionDataset实例
test_dataset = ReactionDataset(test_data)
# 创建DataLoader实例,用于批量加载测试数据
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, collate_fn=collate_fn)
# 创建TransformerEncoderModel实例
model = TransformerEncoderModel(INPUT_DIM, D_MODEL, NUM_HEADS, FNN_DIM, NUM_LAYERS, DROPOUT).to(device)
# 加载最佳模型参数
model.load_state_dict(torch.load(model_file))
# 初始化输出列表
output_list = []
# 遍历测试数据
for i, (src, y) in enumerate(test_loader):
# 将数据移动到设备上(GPU或CPU)
src = src.to(device)
# 不计算梯度,仅进行前向传播
with torch.no_grad():
# 通过模型进行预测
output = model(src)
# 将预测结果添加到输出列表中
output_list += output.detach().tolist()
# 初始化答案字符串列表,包含表头
ans_str_lst = ['rxnid,Yield']
# 遍历输出列表,生成答案字符串
for idx,y in enumerate(output_list):
ans_str_lst.append(f'test{idx+1},{y:.4f}')
# 将答案字符串写入输出文件
with open(output_file,'w') as fw:
fw.writelines('\n'.join(ans_str_lst))
# 调用函数,传入模型文件和输出文件路径
predicit_and_make_submit_file("../model/transformer.pth", "../output/result.txt")
10、代码整体说明
使用 Transformer 模型的机器学习任务的管道,该模型根据化合物的 SMILES 表示来预测反应产率。首先,它从类开始,该类根据正则表达式模式标记 SMILES 字符串,处理填充,并使用词汇文件将标记映射到索引。如果遇到新的标记,标记器会添加特殊标记并动态更新词汇表。
并且定义Smiles_tokenizer函数处理 CSV 文件中的反应数据,提取反应物、产物、添加剂、溶剂和产量。它将反应物和产物信息组合成一个字符串格式,适用于模型输入。然后,数据存储在类中,该类是一个自定义 PyTorch,提供对数据的索引访问。自定义函数用于对 SMILES 字符串进行批处理和标记,将它们和产量转换为用于训练的张量。
通过模型的核心封装在类中,该类包括嵌入层、Transformer 编码器层和前馈网络。此体系结构专为回归任务而设计,可预测每个输入序列的单个产量值。该函数管理训练过程,初始化模型、优化器和调度器,并根据验证性能处理训练循环,并进行损失计算和模型保存。
最后,通过函数负责经过训练的模型对测试数据集进行预测。它加载测试数据,处理测试数据,预测产量,并将结果以指定格式写入输出文件。这种全面的设置有效地集成了数据处理、模型训练和结果生成,用于化学反应产量预测任务。
三、测试结果(原始代码)
通过手册以及代码的运行,得到原始代码的运行结果。通过提交文件,得到评价分数0.1270 。此结果相比于Task2的RNN网络模型要进步一大截,但是结果不太理想,还需要继续改进。可以结合下面的优化方案进行改进优化。
四、优化方案
1、调整epoch:epoch越大,训练得越久,一般而言模型得性能会更好。但是也有可能会出现过拟合现象。
2、调整模型大小:也即中间向量的维度、模型得层数、注意力头的个数。一般而言,模型越大学习能力越强,但是同样的也有可能出现过拟合。
3、数据:对数据做清洗,调整数据分布,做数据增广。对于SMILES一个可行的增广思路是:将一个SMILES换一种写法。
4、采用学习率调度策略:在训练模型的过程中,我们发现往往约到后面,需要更小的学习率。例如下图:学习到后面,我们需要收敛的局部最小值点的两边都比较“窄”,如果现在学习率太大,那么在梯度下降的时候,就有可能翻过局部最小点了。因此需要调整学习率变小。在Pytorch中已经定义好了一些常用的学习率调度方法,需要的学习者可以自己从官网上查看如何使用。
5、集成学习:训练多个不同初始化或架构的模型,并使用集成方法(如投票或平均)来产生最终翻译。这可以减少单一模型的过拟合风险,提高翻译的稳定性。
(备注:手册内容)
五、尝试上分方案
...
六、总结
目前已经学习完三个阶段的内容学习,从基础的机器学习到后来的深度学习中的RNN学习再到transform的训练。可以说整个学习流程是逐步深入的,也通过此次的活动学习,也学到了一些数据处理的步骤及其方法。但是也存在一些不足之处,就是目前在原有的基础上进行自我改进,出现一点的不足,目前还在创新,希望后期可以有所进步,然后在进行补充说明。
备注:
task1笔记:http://t.csdnimg.cn/OLl28
task2笔记:http://t.csdnimg.cn/2UN8m