首页 Transformers快速入门(三):用PyTorch加载和处理数据
文章
取消

Transformers快速入门(三):用PyTorch加载和处理数据

Transformers库建立在PyTorch框架之上,需要通过PyTorch来加载和处理数据、定义优化器、定义和调整模型参数,甚至直接加载模型等等。本章将介绍用PyTorch加载和处理数据。

我一直都说,人类相比于动物的一大区别就是人类善于使用工具,所谓君子生非异也,善假于物也。而人类创造出各种工具,是为了方便我们,而不是折磨我们。会有人说PyTorch好难,但是不用PyTorch,深度学习会更难!所以,让我们快速进入PyTorch这一工具的学习吧!

PyTorch张量运算和自动微分

PyTorch由Facebook人工智能研究院于2017年推出,具有强大的GPU加速张量计算功能,并且能够自动进行微分计算,从而可以使用基于梯度的方法对模型参数进行优化,大部分研究人员、公司机构、数据比赛都使用PyTorch。

张量创建

在深度学习领域你会经常看到张量(Tensor)的表述,张量是深度学习的基础,所以谷歌会把他的深度学习框架叫做TensorFlow。深度学习中的张量可以理解成数组,类似numpy的array。例如:

  • 单个数字就是0维张量,称为标量(scalar);
  • 1维张量称为向量(vector);
  • 2 维张量称为矩阵(matrix);
  • 再多点维度就统一称作张量了。

高等代数中学习过矩阵运算,就是最基本的张量运算。

在用Transformers时最常见的是二维和三维张量。二维张量一般是权重矩阵$W$等,三维张量一般是原数据处理成$batchsize \times 序列长度 \times 模型维度$。在描述张量维度时,或者创建多维张量时,你会经常看到$W\in\mathbb{R}^{d_m \times d_k \times d_h}$这种类似表述,用几行几列这样的方式去理解的话,相当不直观。

分享一个自创的框框理论,$d_m \times d_k \times d_h$代表最大一个框包着$m$个框、再下一层有$k$个,最里层有$h$个。第零维$m$个框:

\[\begin{array}{c} m个 \\ [\overbrace{[...],[...],...,[...]}] \end{array}\]

第一维$k$个框:

\[\begin{array}{} k个 \\ [[\overbrace{[...],...,[...]}],...,] \end{array}\]

第二维$h$个框:

\[\begin{array}{} h个 \\ [[[\overbrace{[...],...,[...]}],...],...] \end{array}\]

值得注意的是,这个维度并不是$1 \times d_m \times d_k \times d_h$喔,因为最外面必需有个大框包起来,不然不漏了吗~

PyTorch提供了多种方式来创建张量,以创建一个$2 \times 3$的矩阵为例:

1
2
3
4
5
6
7
8
9
10
11
import torch
# empty作用就是初始化一块内存放着,里面数据不重要,根本不会用
t = torch.empty(2, 3)
# 随机初始化张量,范围是[0,1)
t = torch.rand(2, 3)
# 随机初始化张量,服从标准正态分布
t = torch.randn(2, 3)
# 全0矩阵,其中的0是长整型,也可以换成torch.double、torch.float64
t = torch.zeros(2, 3, dtype=torch.long)
# 同理有全1矩阵
t = torch.ones(2, 3, dtype=torch.long)

上面比较常用的是全0和全1,对判断真假很有用。也可以从一个张量创造维度相同的张量,like一下:

1
2
3
4
5
6
import torch
t = torch.empty(2, 3)
x = torch.rand_like(a)
x = torch.randn_like(a)
x = torch.zeros_like(a)
x = torch.ones_like(a)

也可以通过基于已有的数组创建张量:

1
2
3
4
5
6
7
# 从列表
_list = [[1.0, 3.8, 2.1], [8.6, 4.0, 2.4]]
t = torch.tensor(_list)
# 从ndarray
import numpy as np
array = np.array([[1.0, 3.8, 2.1], [8.6, 4.0, 2.4]])
t = torch.from_numpy(array)

这样创建的张量默认在CPU,将其调入GPU有如下方式:

1
2
3
t = torch.empty(2, 3).cuda()
t = torch.empty(2, 3, device="cuda")
t = torch.empty(2, 3).to("cuda")

默认是使用当前第0张卡,指定用第1张卡:

1
2
3
t = torch.empty(2, 3).cuda(1)
t = torch.empty(2, 3, device="cuda:1")
t = torch.empty(2, 3).to("cuda:1")

对应的可以调入CPU

1
2
3
t = torch.empty(2, 3).cpu()
t = torch.empty(2, 3, device="cpu")
t = torch.empty(2, 3).to("cpu")

张量运算

张量的加减乘除、拆拼换调、特殊函数,都能在PyTorch找到快速方法。

加减乘除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
x = torch.rand(2, 3)
y = torch.rand(2, 3)
# 等价于x + y
z = torch.add(x, y)
# torch没有减方法,但是可以x - y
# 矩阵点乘,multiplication,Hadamard积,等价于x * y
z = torch.mul(x, y)
# 矩阵叉乘,矩阵乘法,matrix multiplication,等价于x @ y
z = torch.mm(x, y)
# 会报错,因为两者的维度不能做叉乘,需要如下转置
z = torch.mm(x, y.T)
# 三维对应矩阵乘法,batch matrix multiplication
x = torch.rand(2, 3, 4)
y = torch.rand(2, 4, 3)
z = torch.bmm(x, y)
# 更普遍的矩阵叉乘
z = torch.matmul(x, y)
# 除法不常用,但也可以x / y

广播机制

前面我们都是假设参与运算的两个张量形状相同,但是PyTorch同样可以处理不相同形状的张量。

1
2
3
x = torch.ones(2, 3, 4)
y = torch.ones(1, 3, 4)
z = x + y

PyTorch会使得最外面的框维度相同,做法是复制,如上例的$y$复制一份变成$2 \times 3 \times 4$,然后以此类推使得前面的框框都相同,最后可以做相同维度运算。再来个更极端的例子:

1
2
3
4
5
import torch
x = torch.ones(2, 1, 3, 4)
y = torch.ones(5, 4, 3)
z = torch.matmul(x, y)
print(z)

这么乱都能乘?耶斯。

  1. 首先来看,不乱的是最后两位的$3 \times 4和4 \times 3$,刚好能做叉乘,好,所以结果的最后两位是$3 \times 3$。
  2. 再看前面的维度,$y$少了框,先补最外面$y$变成$2 \times 5 \times 4 \times 3$。
  3. 这时$x$第二维的$1$少了,复制成$2 \times 5 \times 3 \times 4$,这样就可以乘了。

聪明的你要问,如果$x$第二维是$3$,复制不成$5$啊,那怎么办?怎么办?难办就别办了!答案就是会报错。

拆拼换调

这些方法几乎是最常用的,跟着我好好理解一遍哦。首先是拼接cat方法:

1
2
3
x = torch.tensor([[1, 2, 3], [ 4,  5,  6]], dtype=torch.double)
y = torch.tensor([[7, 8, 9], [10, 11, 12]], dtype=torch.double)
z = torch.cat((x, y), dim=0)

看到dim=0了吗,根据框框理论,这是把第0维的几个框框拼起来,得到:

1
2
3
4
tensor([[ 1.,  2.,  3.],
     [ 4.,  5.,  6.],
     [ 7.,  8.,  9.],
     [10., 11., 12.]], dtype=torch.float64)

dim=1,则是把第一个框框里的拼起来,得到:

1
2
tensor([[ 1.,  2.,  3.,  7.,  8.,  9.],
     [ 4.,  5.,  6., 10., 11., 12.]], dtype=torch.float64)

拆分就用索引与切片,操作如同list

1
2
3
4
5
6
7
# 取第0维第1个框里的第2位,注意第X是从0开始
t = torch.randn(3, 4)
x = t[1, 2]
# 取第0维所有框里的第2位
x = t[:, 2]
# 取第0维所有框里的第2、3、4位赋值为100
t[:, 2:4] = 100

转换有多种操作,如下:

  • view将张量转换为新的形状,需要保证总的元素个数不变。

    1
    2
    3
    4
    5
    6
    
    # x.shape为6
    t = torch.arange(6)
    # 2×3
    x = t.view(2, 3)
    # -1会自动计算,如下例是3×2
    x = t.view(-1, 2)
    
  • transpose交换张量中的两个维度,参数为相应的维度。

    1
    2
    3
    4
    5
    
    # 2×3的张量
    t = torch.tensor([[1, 2, 3], [4, 5, 6]])
    # 调换成3×2
    x = t.transpose(0, 1)
    # 这就是矩阵转置,同x.t()或者x.T
    
  • permute可以直接设置新的维度排列方式,而transpose每次只能交换两个维度。

    1
    2
    3
    4
    
    # 1×2×3的张量
    t = torch.tensor([[[1, 2, 3], [4, 5, 6]]])
    # 换成3×1×2
    x = t.permute(2, 0, 1)
    
  • reshape,与view功能几乎一致。区别在于进行view的张量必须是连续的,可以调用.is_contiguous()来判断张量是否连续;如果非连续,需要先通过.contiguous()函数将其变为连续的,再view。但reshape一步到位。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    t = torch.tensor([[[1, 2, 3], [4, 5, 6]]])
    t = t.permute(2, 0, 1)
    # 这样会报错,因为x.shape是3×1×2,不连续
    x = t.view(-1)
    # contiguous
    x = t.contiguous()
    x = x.view(-1)
    # 直接用reshape
    x = t.reshape(-1)
    

降维与升维:很多时候,深度学习模型无法输入向量、矩阵,只能输入张量,所以要用.unsqueeze()把一二维的升维成三维以上张量,反之.squeeze()降维。squeeze就是挤压、压榨的意思,让张量变弱了,框框都压没了,很形象吧!

1
2
3
4
5
6
7
8
t = torch.tensor([1, 2, 3, 4])
# 最外面套个框框
x = torch.unsqueeze(t, dim=0)
# 或者
x = t.unsqueeze(dim=0)
# 思考下dim=1是什么样子?
# squeeze会挤掉所有为1的维度,比如3×1×2就会变成3×2
x = t.squeeze()

特殊函数

PyTorch提供了许多内置函数,只需要点一下,方法就可以出来,例如常用的:

  • 包括均值.mean()、方差.std()、平方根.sqrt()、对数.log()等等常见的数学运算,以平均值为例。

    1
    2
    3
    4
    5
    6
    7
    8
    
    t = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.double)
    # 默认情况下计算所有元素的平均值
    m = t.mean()
    # 拿掉最外的框框,里面第0维的框框对应位置平均,这里就是对列平均
    m = t.mean(dim=0)
    # dim=1呢?
    # torch.mean()是相同效果
    m = torch.mean(t)
    
  • 三角函数.sin().cos().tan()等。

  • 激活函数,一般不在这里直接用,而是用torch.nn里的激活函数,但也放出来吧。

    1
    2
    
    a = torch.sigmoid(t)
    a = torch.tanh(t)
    
  • 对角线。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    # 如果输入是一个向量,返回这个向量为对角线元素的2D方阵;如果是方阵,返回其对角线的1D张量
    t = torch.randn(3)
    d = torch.diag(t)
    # 如果不是方阵呢?试试看
    # 返回方阵的迹
    t = torch.arange(1, 10).view(3, 3)
    d = torch.trace(t)
    # 返回矩阵下三角,其他置0
    d = torch.tril(t)
    # 创建对角线为1,其他为0的方阵
    t = torch.eye(3)
    

自动微分

Pytorch提供自动计算梯度的功能,只需要执行.backward()

1
2
3
4
5
x = torch.tensor([2.], requires_grad=True)
y = torch.tensor([3.], requires_grad=True)
z = (x + y) * (y - 2)
z.backward()
print(x.grad, y.grad)

很容易手工求解$\frac{\text{d}z}{\text{d}x} = y-2,\frac{\text{d}z}{\text{d}y} = x + 2y - 2$,当$x=2,y=3$时,$\frac{\text{d}z}{\text{d}x}=1$和$\frac{\text{d}z}{\text{d}y}=6$,与代码计算结果一致。

PyTorch加载和处理数据

讲到这,相信你已经大致掌握了PyTorch怎么创建和运算张量,让我们马上进入模型前的最后一步——数据的加载和处理。

Pytorch提供了Dataset/IterableDataset,和DataLoader和用于处理数据,它们既可以加载Pytorch预置的数据集,也可以加载自定义数据。其中Dataset/IterableDataset负责存储样本以及它们对应的标签;数据加载类DataLoader负责迭代地访问数据集中的样本。

为什么用这些方法,而不直接创建Tensor喂给模型?个人使用中有这些感受:

  1. 符合End2End的理念,代码可以干净整洁、易于理解和上手;
  2. 能规范储存样本和标签,读取和处理时也很方便;
  3. 能快速调用其内置的方法,如分batch、打乱等等;
  4. 对于数据量超大的情况,能够以迭代器的方式处理。

下面进行这些组件的介绍。

Dataset

Dataset类的本质是索引-样本,这样我们就可以方便地通过dataset[idx]来访问指定索引的样本。

Dataset必须重写__getitem__()函数来指定获取样本的方式,因为源码中这个方法指定了NotImplementedError,不实现就报错。一般还会实现__len__()用于返回数据集的大小。例如将一个存有股票的csv文件转为Dataset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from torch.utils.data import Dataset, DataLoader
import pandas as pd

class MyDataset(Dataset):
    def __init__(self, file_name):
        self.df = pd.read_csv(file_name)

    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):
        code, date, open, close, high, low, is_rise= self.df.iloc[idx, :].values
        return {
            "stock": [open, close, high, low],
            "label": is_rise,
        }

ds = MyDataset("stock.csv")

IterableDataset

当数据量超级大,或者访问远程服务器产生的数据,不可能一把梭到内存里,所以用IterableDataset迭代地访问数据集。必须重写__iter__()函数,用于返回一个样本迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
class MyIterableDataset(IterableDataset):
    def __init__(self, start, end):
        super(MyIterableDataset).__init__()
        assert end > start
        self.start = start
        self.end = end

    def __iter__(self):
        return iter(range(self.start, self.end))

ds = MyIterableDataset(start=3, end=7)
print(list(DataLoader(ds, num_workers=0)))

如果要配合DistributedDataParallel进行多进程分布式训练,num_workers=0就可以了。如果非要多个workers,例如num_workers=2,会出问题,由于每个workers都获取到了单独的数据集拷贝,因此会重复访问每一个样本。需要定义每一个workers应该获取哪些数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from torch.utils.data import get_worker_info

def worker_init_fn(worker_id):
    worker_info = get_worker_info()
    # 把数据复制给worker_info
    dataset = worker_info.dataset
    overall_start = dataset.start
    overall_end = dataset.end
    # 根据workers数分割数据
    per_worker = int(math.ceil((overall_end - overall_start) / float(worker_info.num_workers)))
    worker_id = worker_info.id
    dataset.start = overall_start + worker_id * per_worker
    dataset.end = min(dataset.start + per_worker, overall_end)

print(list(DataLoader(ds, num_workers=2, worker_init_fn=worker_init_fn)))

DataLoaders

在加载数据后,需要将数据集切分为很多batches,然后按batch将样本喂给模型,并且循环这一过程,每一个完整遍历所有样本的循环称为一个epochDataLoader类专门负责处理这些操作。紧接着IterableDataset的例子:

1
2
3
train_dataloader = DataLoader(ds, batch_size=2)
for i in train_dataloader:
    print(i)

DataLoader中还有几个值得探讨的参数,samplercollate_fn

sampler

sampler用于控制数据的顺序。对于IterableDataset,数据本身是一个个按顺序来的,所以无法使用,会报错。对于Dataset,可以调整,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from torch.utils.data import Dataset, DataLoader, SequentialSampler, RandomSampler

class MyDataset(Dataset):
    def __init__(self):
        self.examples = list(range(10))
        self.labels = list(range(10))

    def __len__(self):
        return len(self.examples)

    def __getitem__(self, idx):
        return {
            "example": self.examples[idx],
            "label": self.labels[idx],
        }

ds = MyDataset()
train_sampler = RandomSampler(ds)
test_sampler = SequentialSampler(ds)

train_dataloader = DataLoader(ds, batch_size=2, sampler=train_sampler)
print(list(train_dataloader))
test_dataloader = DataLoader(ds, batch_size=2, sampler=test_sampler)
print(list(test_dataloader))

一般在训练时需要打乱数据,而测试时不用。上例输出train_dataloader已经被打乱了,而test_dataloader则没有。当然,也可以直接在DataLoader中指定参数shuffle=True来打乱,但总归这样自由一些。

collate_fn

批处理函数collate_fn负责对每一个采样出的 batch 中的样本进行处理。默认的 collate_fn 会进行如下操作:

  • 添加一个新维度作为batch维;
  • 自动将NumPy数组和Python数值转换为PyTorch张量;
  • 保留原始的数据结构,例如输入是字典的话,它会输出batch后的字典。

例如上例的test_dataloader

  • {'example': 0, 'label': 0}, {'example': 1, 'label': 1}

变成了2个batch和tensor格式的:

  • {'example': tensor([0, 1]), 'label': tensor([0, 1])}

我们也可以传入手工编写的collate_fn函数以对数据进行自定义处理。例如给每个样本乘以10:

1
2
3
4
5
6
7
8
9
10
11
12
13
def collote_fn(batch_samples):
    batch_example, batch_label = [], []
    for sample in batch_samples:
        batch_example.append(sample["example"] * 10)
        batch_label.append(sample["label"])
    return {
        "batch_inputs": batch_example,
        "labels": batch_label
    }

ds = MyDataset()
train_dataloader = DataLoader(ds, batch_size=2, collate_fn=collote_fn)
print(list(train_dataloader))

collote_fn输入的是每个batch。遍历batch里的字典,处理成你想要的格式就可以了。

小结

本章我们熟悉了PyTorch的数据处理,终于搞定数据了!下一章我们进入模型的加载和修改。

本文由作者按照 CC BY 4.0 进行授权

Transformers快速入门(二):用Tokenizer从零开始训练词表

Transformers快速入门(四):结合Transformers和PyTorch修改模型