虽然PyTorch实战上手较快,但是由于之前并没有系统的学习PyTorch,对于其使用逻辑也不是很清楚。所以本人在日常阅读论文源码过程中,发现可以读懂别人编写的模型,但是自己亲手搭建神经网络的时候却无从下手(基础不足)。故想要通过学习PyTorch框架了解其工作机制,进而形成自己的知识体系后便于学习其他更厉害的(当然一切目标是发论文!冲冲冲!!!)。
这一系列文章均以 ABSA(细粒度情感分析)中的 AEN模型 为基础进行PyTorch的学习。
AEN具体模型结构可参考Attentional Encoder Network for Targeted Sentiment Classificatio 论文解读
模型学习分为五大模块:数据、模型、损失函数、优化器、迭代训练(前四个)
首先来学习PyTorch中的数据读取机制
torch.utils.data.DataLoader()
:构建一个可迭代的数据装载器,在训练的时候,每一个for循环,每一次iteration,就是从DataLoader中获取一个batch_size大小的数据的。
我们首先在train.py
(训练代码)中找到数据读取模块,如下:
# 构建ABSADataset实例
self.trainset = ABSADataset(opt.dataset_file['train'], tokenizer)
self.testset = ABSADataset(opt.dataset_file['test'], tokenizer)
......
......
......
train_data_loader = DataLoader(dataset=self.trainset, batch_size=self.opt.batch_size, shuffle=True)
test_data_loader = DataLoader(dataset=self.testset, batch_size=self.opt.batch_size, shuffle=False)
val_data_loader = DataLoader(dataset=self.valset, batch_size=self.opt.batch_size, shuffle=False)
接下来进入到 DataLoder.py
源码中查看 DataLoader 类:
DataLoader(dataset: Dataset[T_co],
batch_size: Optional[int],
num_workers: int,
pin_memory: bool,
drop_last: bool,
timeout: float,
sampler: Sampler,
prefetch_factor: int,
_iterator : Optional['_BaseDataLoaderIter'],
__initialized = False)
DataLoader 的参数很多,通常主要用以下5个:
三个小概念:(1)Epoch:所有训练样本都已输入到模型中,是一个Epoch;(2)Iteration:一批样本输入到模型中,称为一个Iteration;(3)Batch_size:一批样本的大小,决定一个Epoch有多少个Iteration。
torch.utils.data.Dataset()
:Dataset抽象类,所有自定义的Dataset都需要继承它,并且必须复写__getitem__()
类方法
从创建的 ABSADataset 类中进入到Dataset.py
源码:
class Dataset(Generic[T_co]):
functions: Dict[str, Callable] = {}
def __getitem__(self, index) -> T_co:
raise NotImplementedError
def __add__(self, other: 'Dataset[T_co]') -> 'ConcatDataset[T_co]':
return ConcatDataset([self, other])
__getitem__()
方法是Dataset的核心,作用是接收一个索引,返回一个样本。参数里面接收index,我们需要编写究竟如何根据这个索引去读取数据部分。
关于数据读取的三个问题:
(1)读哪些数据?
(2)从哪里读数据?
(3)如何读数据?
有关部分代码截取
# 构建ABSADataset实例
self.trainset = ABSADataset(opt.dataset_file['train'], tokenizer)
self.testset = ABSADataset(opt.dataset_file['test'], tokenizer)
......
......
......
# 构建DataLoader
# 接收的参数是上面的trainset(即ABSADataset),还跟了一个batch_size
# 有了一个batch的样本数量,有了样本总数,就能得到总共有多少个batch
# DataLoader做的事情:我们指定了batch_size(假设指定为10,总共有100个训练样本,计算出批数是10,那么DataLoader将样本分成10批顺序打乱的数据)
train_data_loader = DataLoader(dataset=self.trainset, batch_size=self.opt.batch_size, shuffle=True)
test_data_loader = DataLoader(dataset=self.testset, batch_size=self.opt.batch_size, shuffle=False)
val_data_loader = DataLoader(dataset=self.valset, batch_size=self.opt.batch_size, shuffle=False)
......
......
......
# 位于主函数中
dataset_files = {
'twitter': {
'train': './datasets/acl-14-short-data/train.raw',
'test': './datasets/acl-14-short-data/test.raw'
},
'restaurant': {
'train': './datasets/semeval14/Restaurants_Train.xml.seg',
'test': './datasets/semeval14/Restaurants_Test_Gold.xml.seg'
},
'laptop': {
'train': './datasets/semeval14/Laptops_Train.xml.seg',
'test': './datasets/semeval14/Laptops_Test_Gold.xml.seg'
}
}
首先是数据路径dataset_files
部分,即训练集和测试集的位置,我们的模型将会从这里读入数据,然后是tokenizer
的数据预处理部分(这块的预处理部分比较简单),主要来看一下所创建 ABSADataset 实例后面的 DataLoader。
我们从self.trainset = ABSADataset(opt.dataset_file['train'], tokenizer)
开始看起,核心是 ABSADataset,这个是自己实现的一个类,继承了抽象类Dataset,并重写了__getitem__()
方法,这个类的目的就是传入数据路径和预处理部分,然后返回数据:
class ABSADataset(Dataset):
def __init__(self, fname, tokenizer):
"""
:param fname: 数据集所在路径
:param tokenizer: 数据预处理
"""
fin = open(fname, 'r', encoding='utf-8', newline='\n', errors='ignore')
lines = fin.readlines()
fin.close()
fin = open(fname+'.graph', 'rb')
idx2graph = pickle.load(fin)
fin.close()
all_data = [] # 这里!! 下面就是对数据集进行了预处理,然后返回最下面大括号里面的一系列东西(一个列表)
for i in range(0, len(lines), 3):
text_left, _, text_right = [s.lower().strip() for s in lines[i].partition("$T$")]
aspect = lines[i + 1].lower().strip()
polarity = lines[i + 2].strip() # 看数据集文件理解!
text_indices = tokenizer.text_to_sequence(text_left + " " + aspect + " " + text_right)
context_indices = tokenizer.text_to_sequence(text_left + " " + text_right)
left_indices = tokenizer.text_to_sequence(text_left)
left_with_aspect_indices = tokenizer.text_to_sequence(text_left + " " + aspect)
right_indices = tokenizer.text_to_sequence(text_right, reverse=True)
right_with_aspect_indices = tokenizer.text_to_sequence(aspect + " " + text_right, reverse=True)
aspect_indices = tokenizer.text_to_sequence(aspect)
left_len = np.sum(left_indices != 0)
aspect_len = np.sum(aspect_indices != 0)
aspect_boundary = np.asarray([left_len, left_len + aspect_len - 1], dtype=np.int64)
polarity = int(polarity) + 1
text_len = np.sum(text_indices != 0)
concat_bert_indices = tokenizer.text_to_sequence('[CLS] ' + text_left + " " + aspect + " " + text_right + ' [SEP] ' + aspect + " [SEP]")
concat_segments_indices = [0] * (text_len + 2) + [1] * (aspect_len + 1)
concat_segments_indices = pad_and_truncate(concat_segments_indices, tokenizer.max_seq_len)
text_bert_indices = tokenizer.text_to_sequence("[CLS] " + text_left + " " + aspect + " "
+ text_right + " [SEP]")
aspect_bert_indices = tokenizer.text_to_sequence("[CLS] " + aspect + " [SEP]")
dependency_graph = np.pad(idx2graph[i], ((0, tokenizer.max_seq_len-idx2graph[i].shape[0]),
(0, tokenizer.max_seq_len-idx2graph[i].shape[0])),
'constant')
data1 = {
'concat_bert_indices': concat_bert_indices,
'concat_segments_indices': concat_segments_indices,
'text_bert_indices': text_bert_indices,
'aspect_bert_indices': aspect_bert_indices,
'text_indices': text_indices,
'context_indices': context_indices,
'left_indices': left_indices,
'left_with_aspect_indices': left_with_aspect_indices,
'right_indices': right_indices,
'right_with_aspect_indices': right_with_aspect_indices,
'aspect_indices': aspect_indices,
'aspect_boundary': aspect_boundary,
'dependency_graph': dependency_graph,
'polarity': polarity,
}
all_data.append(data1)
self.data2 = all_data # 又调用了all_data,找到all_data看做了什么事情
def __getitem__(self, index):
return self.data2[index]
# 只要给定了index,就可以通过这句代码获取样本
# data是ABSADataset这个类的成员,再往上找
# 告诉机器一共有多少个样本数据。 返回总的样本个数
def __len__(self):
return len(self.data2)
我们可以看到__getitem__()
函数中的data[index]
,只要给定了这个index,就可以通过这句代码获取样本。
【源代码中的data有点多,理解有困难,下面解释的时候将all_data.append(data)
这句及以上的data改称为 data1,以下改称为 data2】
那这个data[index]
做了什么事情呢?我们 ctrl+左键 看看这个data2
,跑到了初始化__init__
部分中的self.data2 = all_data
,再继续看all_data
,又all_data.append(data1)
。上面代码中,初始化了一个all_data=[]
,在找到数据路径之后读取数据并将其预处理,预处理的结果data被一条一条封装成字典形式(看代码中data部分)存入all_data
,最终形成了一个list,list的每个元素是dict,格式为 [{样本1}, {样本2}, {样本3}, …, {样本n}]。所以,data2
拿到了这个list,有了这个list,然后又给了data2
一个index,那么取数据就容易了。data2[index]
就可以取出某个{样本i}。
接下来看代码train_data_loader = DataLoader(dataset=self.trainset, batch_size=self.opt.batch_size, shuffle=True)
,DataLoader类接收(1)参数 ABSADataset
,我们知道其返回一个样本(dict);后面又跟了一个(2)batch_size
,这个就是说一个batch里有多少个样本,如果有了一个batch的样本数量和样本总数,那么就能得到总共有多少个batch;(3)shuffle
,就是将数据打乱一下。
接下来我们看看具体使用过程中,每一批数据是如何获得的?
# 训练部分核心
# 两层循环,外循环表示迭代Epoch(全部训练样本喂入模型一次);
# 内循环表示的批次的循环,每一个Epoch中,都是一批一批的喂入
for i_epoch in range(self.opt.num_epoch):
logger.info('>' * 100)
logger.info('epoch: {}'.format(i_epoch))
n_correct, n_total, loss_total = 0, 0, 0
# switch model to training mode
self.model.train()
# 数据读取具体使用的核心
# 利用enumerate进行迭代 获取一批批的数据,然后训练模型。这样所有批次的数据都喂入模型,完后了一次epoch
for i_batch, batch in enumerate(train_data_loader):
global_step += 1
# clear gradient accumulators
optimizer.zero_grad()
# forward
inputs = [batch[col].to(self.opt.device) for col in self.opt.inputs_cols]
outputs = self.model(inputs)
targets = batch['polarity'].to(self.opt.device)
# compute loss
loss = criterion(outputs, targets)
# backward
loss.backward()
# update weights
optimizer.step()
数据读取的核心for i_batch, batch in enumerate(train_data_loader)
,我们debug一下看看这个函数是如何得到数据的?
点击下面的stepinto步入这个函数里面,看看调用DataLoader中的哪个方法:
可以看到,程序跳转到了DataLoader的__iter__(self)
方法,发现它是一个判断,在说是用单进程还是用多进程读取机制进行处理,与读取数据无关,所以使用stepover进行下一步。
明天再写吧
读哪些数据?
根据 Sampler(DataLoader源码中)输出的 index 决定。
从哪里读数据?
根据Dataset中设置的数据路径读取数据。
如何读数据?
Dataset的 __getitem__()
方法,可以帮助我们获取一个样本。
代码示例(以下代码均选自细粒度情感分析的AEN模型):
# 构建ABSADataset实例
self.trainset = ABSADataset(opt.dataset_file['train'], tokenizer)
self.testset = ABSADataset(opt.dataset_file['test'], tokenizer)
# 构建DataLoader
# 接收的参数是上面的trainset(即ABSADataset),还跟了一个batch_size
# 有了一个batch的样本数量,有了样本总数,就能得到总共有多少个batch
# DataLoader做的事情:我们指定了batch_size(假设指定为10,总共有100个训练样本,计算出批数是10,那么DataLoader将样本分成10批顺序打乱的数据)
train_data_loader = DataLoader(dataset=self.trainset, batch_size=self.opt.batch_size, shuffle=True)
test_data_loader = DataLoader(dataset=self.testset, batch_size=self.opt.batch_size, shuffle=False)
val_data_loader = DataLoader(dataset=self.valset, batch_size=self.opt.batch_size, shuffle=False)
# 训练部分核心
# 有两层循环,外循环表示迭代Epoch(全部训练样本喂入模型一次);
# 内循环表示的批次的循环,每一个Epoch中,都是一批一批的喂入
for i_epoch in range(self.opt.num_epoch):
logger.info('>' * 100)
logger.info('epoch: {}'.format(i_epoch))
n_correct, n_total, loss_total = 0, 0, 0
# switch model to training mode
self.model.train()
# 数据读取具体使用的核心
# 利用enumerate进行迭代 获取一批批的数据,然后训练模型。这样所有批次的数据都喂入模型,完后了一次epoch
for i_batch, batch in enumerate(train_data_loader):
global_step += 1
# clear gradient accumulators
optimizer.zero_grad()
# forward
inputs = [batch[col].to(self.opt.device) for col in self.opt.inputs_cols]
outputs = self.model(inputs)
targets = batch['polarity'].to(self.opt.device)
# compute loss
loss = criterion(outputs, targets)
# backward
loss.backward()
# update weights
optimizer.step()
class ABSADataset(Dataset):
'''
Dataset抽象类,所有自定义的Dataset都需要继承它,必须复写__getitem__()这个类方法。
这个类的目的就是 传入数据的路径 ,还有 预处理部分
'''
def __init__(self, fname, tokenizer):
"""
:param fname: 数据集所在路径
:param tokenizer: 数据预处理
"""
fin = open(fname, 'r', encoding='utf-8', newline='\n', errors='ignore')
lines = fin.readlines()
fin.close()
fin = open(fname+'.graph', 'rb')
idx2graph = pickle.load(fin)
fin.close()
all_data = [] # 这里!! 下面就是对数据集进行了预处理,然后返回最下面大括号里面的一系列东西(一个列表)
for i in range(0, len(lines), 3):
text_left, _, text_right = [s.lower().strip() for s in lines[i].partition("$T$")]
aspect = lines[i + 1].lower().strip()
polarity = lines[i + 2].strip() # 看数据集文件理解!
text_indices = tokenizer.text_to_sequence(text_left + " " + aspect + " " + text_right)
context_indices = tokenizer.text_to_sequence(text_left + " " + text_right)
left_indices = tokenizer.text_to_sequence(text_left)
left_with_aspect_indices = tokenizer.text_to_sequence(text_left + " " + aspect)
right_indices = tokenizer.text_to_sequence(text_right, reverse=True)
right_with_aspect_indices = tokenizer.text_to_sequence(aspect + " " + text_right, reverse=True)
aspect_indices = tokenizer.text_to_sequence(aspect)
left_len = np.sum(left_indices != 0)
aspect_len = np.sum(aspect_indices != 0)
aspect_boundary = np.asarray([left_len, left_len + aspect_len - 1], dtype=np.int64)
polarity = int(polarity) + 1 # 极性变成0,1,2??
text_len = np.sum(text_indices != 0)
concat_bert_indices = tokenizer.text_to_sequence('[CLS] ' + text_left + " " + aspect + " " + text_right + ' [SEP] ' + aspect + " [SEP]")
concat_segments_indices = [0] * (text_len + 2) + [1] * (aspect_len + 1)
concat_segments_indices = pad_and_truncate(concat_segments_indices, tokenizer.max_seq_len)
text_bert_indices = tokenizer.text_to_sequence("[CLS] " + text_left + " " + aspect + " "
+ text_right + " [SEP]")
aspect_bert_indices = tokenizer.text_to_sequence("[CLS] " + aspect + " [SEP]")
dependency_graph = np.pad(idx2graph[i], ((0, tokenizer.max_seq_len-idx2graph[i].shape[0]),
(0, tokenizer.max_seq_len-idx2graph[i].shape[0])),
'constant')
data = {
'concat_bert_indices': concat_bert_indices,
'concat_segments_indices': concat_segments_indices,
'text_bert_indices': text_bert_indices,
'aspect_bert_indices': aspect_bert_indices,
'text_indices': text_indices,
'context_indices': context_indices,
'left_indices': left_indices,
'left_with_aspect_indices': left_with_aspect_indices,
'right_indices': right_indices,
'right_with_aspect_indices': right_with_aspect_indices,
'aspect_indices': aspect_indices,
'aspect_boundary': aspect_boundary,
'dependency_graph': dependency_graph,
'polarity': polarity,
}
all_data.append(data)
self.data = all_data # 又调用了all_data,找到all_data看做了什么事情
'''
__getitem__()方法 是Dataset的核心,作用:接收一个索引,返回一个样本
参数里面接受index,然后我们需要编写 如何根据这个索引去读取我们的数据部分。
'''
def __getitem__(self, index):
return self.data[index]
# 只要给定了index,就可以通过这句代码获取样本
# data是ABSADataset这个类的成员,再往上找
def __len__(self):
return len(self.data)
关于第一条,自己可以尝试debug一下,会很清晰。
DataLoader 读取数据过程如下:
总结:
DataLoader的作用就是构建一个数据装载器,根据我们提供的batch_size的大小,将数据样本分成一个个batch去训练模型,而这个分的过程中需要把数据取到,这就要借助Dataset中的__getitem__()
方法。
自己利用PyTorch编写代码时,首先应该自己写一个 XXDataset
类,这个类要继承Dataset类并且实现里面的__getitem__()
,在这里面告诉机器怎样读取数据。还有__len__()
方法,这个方法是告诉机器一共有多少个样本数据,实际编写就是返回总的样本个数即可,可参考以上代码。
之后,机器就可以根据 Dataset 去硬盘中读取数据,接下来就是用 DataLoder 构建一个可迭代的数据装载器,传入如何读取数据的机制Dataset,传入batch_size,就可以返回一批批的数据。这个 DataLoader 是在模型训练的时候使用。