题目概述
本来不想写这篇笔记,因为自己的模型效果并不理想。权当是一次记录吧。
这次的任务是使用RNN模型(循环神经网络)进行深度学习。
具体思路和上一次的机器学习其实很类似,不过这次的思路显然比上次要复杂一些,我主要通过关键函数——训练函数 train 来理清整个代码的逻辑。
train函数源码如下
def train():
## super param
N = 10 #int / int(len(dataset) * 1) # 或者你可以设置为数据集大小的一定比例,如 int(len(dataset) * 0.1)
NUM_EMBED = 294 # nn.Embedding()
INPUT_SIZE = 300 # src length
HIDDEN_SIZE = 512
OUTPUT_SIZE =512
NUM_LAYERS = 10
DROPOUT = 0.2
CLIP = 1 # CLIP value
N_EPOCHS = 100
LR = 0.0001
start_time = time.time() # 开始计时
device = torch.device('cuda' if torch.cuda.is_available() else '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 = RNNModel(NUM_EMBED, INPUT_SIZE, HIDDEN_SIZE, OUTPUT_SIZE, NUM_LAYERS, DROPOUT, device).to(device)
model.train()
optimizer = optim.Adam(model.parameters(), lr=LR)
# criterion = nn.MSELoss() # MSE
criterion = nn.L1Loss() # MAE
best_loss = 10
for epoch in range(N_EPOCHS):
epoch_loss = 0
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.item()
loss_in_a_epoch = epoch_loss / len(train_loader)
print(f'Epoch: {epoch+1:02} | Train Loss: {loss_in_a_epoch:.3f}')
if loss_in_a_epoch < best_loss:
# 在训练循环结束后保存模型
torch.save(model.state_dict(), '../model/RNN.pth')
end_time = time.time() # 结束计时
# 计算并打印运行时间
elapsed_time_minute = (end_time - start_time)/60
print(f"Total running time: {elapsed_time_minute:.2f} minutes")
- 首先是一系列超参数的设置,这也是优化的一大方向。
- 之后是设置计时器、选择设备等等准备工作。
- 下面调用 read_data 函数读取 csv 文件的数据。
- 调用 ReactionDataset 实现创建数据集对象,包含数据的读取方法和索引访问方法。
- 使用 Subset 选择数据集的一个子集 subset_dataset。
- 使用 DataLoader 创建训练数据加载器 train_loader,设置批大小为128,并使用 collate_fn 进行批处理。
- 实例化 RNNModel 模型并传入参数,选择使用 Adam 优化器、L1损失函数(MAE)。
- 开始训练并输出每个训练轮次的相关信息,具体的操作包括:将输入和目标移到设备上、清零优化器的梯度、前向传播,计算模型输出、计算损失、反向传播、使用梯度裁剪防止梯度爆炸、优化器更新模型参数、累加当前批次的损失到 epoch_loss。
同样还涉及到一些工具函数:
- read_data:数据读取和处理
def read_data(file_path, train=True): # 从CSV文件中读取反应物、生成物、添加剂、溶剂和产率信息 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
- ReactionDataset:定义数据集类
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]
- collate_fn:批处理函数,用于批处理数据,将SMILES字符串转换为token索引,并返回token索引和产率。
def collate_fn(batch): # 正则表达式,匹配SMILES字符串中的各种化学符号 REGEX = r"\[[^\]]+]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p|\(|\)|\.|=|#|-|\+|\\\\|\/|:|~|@|\?|>|\*|\$|\%[0-9]{2}|[0-9]" # 将SMILES字符串转化为模型可以接受的索引序列 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]) # 对SMILES字符串进行tokenize并转换为张量 tokenizer_batch = torch.tensor(tokenizer.tokenize(smi_list)[1]) # 将产率列表转换为张量 yield_list = torch.tensor(yield_list) return tokenizer_batch, yield_list
- Smiles_tokenizer:将SMILES字符串转化为模型可以接受的索引序列。
class Smiles_tokenizer(): # 初始化实例变量。读取词汇表文件,将每个符号与其对应的索引存储在字典 vocab_dic 中 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 # 使用正则表达式匹配SMILES字符串中的化学符号,将每个符号添加到 tokenised 列表中 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 # 调用 _regex_match 方法获取 tokenized 的 SMILES 字符串;在每个序列的开头和结尾分别添加特 殊标记 <CLS> 和 <SEP>;调用 _pad_seqs 方法将序列填充到统一长度;调用 _pad_token_to_idx 方法将序列中的符号转化为对应的索引 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 # 计算序列中最长的长度,将每个序列填充到最长长度,使用 pad_token 进行填充 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
- 我自己定义的生成token的代码
import re import pandas as pd # 定义正则表达式用于分词 REGEX = r"\[[^\]]+]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p|\(|\)|\.|=|#|-|\+|\\\\|\/|:|~|@|\?|>|\*|\$|\%[0-9]{2}|[0-9]" # 分词函数 def tokenize(smiles, regex): regex_string = r"(" + regex + r"|.)" prog = re.compile(regex_string) tokenised = [] for smi in smiles: tokens = prog.findall(smi) tokenised.extend(tokens) # 收集所有的 token return tokenised # 从 CSV 文件读取数据 file_path = "../dataset/round1_train_data.csv" 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() # 将所有 SMILES 字符串收集在一起 all_smiles = reactant1 + reactant2 + product + additive + solvent # 对所有 SMILES 字符串进行分词 all_tokens = tokenize(all_smiles, REGEX) # 创建词汇表 vocab = sorted(set(all_tokens)) # 添加特殊标记 vocab = ["<PAD>", "<CLS>", "<SEP>"] + vocab # 将词汇表保存到文件 vocab_file = "../vocab_full.txt" with open(vocab_file, "w") as f: for token in vocab: f.write(token + "\n")
主要思路
emm,很遗憾,我在人工智能课程学习的是 CNN(卷积神经网络),没有学过RNN……
一、调参
观察给出的初始参数,结合以往使用 CNN 的经验,我觉得网络层数和每层的神经元数量都已经很高,但是学习率偏低,存在过拟合的风险;同时批次大小太低,导致训练时间过长。目前最终调整的参数:学习率LR=0.001,批次大小N=64, DROPOUT=0.5。
当然,目前参数表现很差,后续再继续调整。
二、模型优化
目前 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
后续考虑更换激活函数,现在使用的是 Sigmoid 激活函数,以及添加一些丢弃层、归一化等操作。
三、模型替换
目前没有这个打算,毕竟赛题要求使用 RNN,而且我目前知道的除了 RNN 就是 CNN,可能表现不如 RNN。
总结
最终得分有些低,只有0.1左右,就不贴出来丢人了……
深度学习分数比机器学习低的原因是什么呢?可能自己的模型和参数不太合适吧,也可能是训练数据量太少。看看 Transformer 的表现吧。