Datawhale AI 夏令营---第三期(催化反应产率预测)

学习笔记

Task3:Transformer建模SMILES进行反应产率预测

前期学习回顾:在Task2,我们使用RNN建模SMILES,且发现了RNN在处理这类问题的局限性。因此,本节我们学习Transformer这种更加强大和高效的序列建模算法。

一、基本知识点-Transformer

(以下内容为简洁版,详细内容见手册:数据鲸 (linklearner.com)

 1、RNN的局限性

  • RNN在处理长序列时,由于信息逐渐被遗忘,导致长程依赖问题难以捕捉。
  • 卷积神经网络(CNN)在处理长文本时,受限于固定的上下文窗口,需要多层卷积操作。

2、 Transformer的优势

  • 通过注意力机制,Transformer能够全局地建模序列依赖,不受序列长度的限制。
  • 并行计算能力,提高了计算效率。

3、Transformer的基本架构

  1. 嵌入层:将token转换为向量表示,并加入位置编码,以保留序列中单词的位置信息。
  2. 自注意力层:通过查询(Query)、键(Key)、值(Value)的机制,计算上下文单词的权重得分,聚合上下文信息。
  3. 前馈层:通常是一个线性层,有助于提升模型性能。
  4. 残差连接:通过直连通道连接子层的输入和输出,避免梯度消失问题。
  5. 层归一化:对每一层的输出进行归一化,提高模型的稳定性和收敛速度。

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小萌新加速中

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值