FastAI实践第一部分:数据准备与预处理
[TOC]
在上手深度学习项目的时候,最开始的数据处理总是一件很让人头疼的事。下面的笔记,就是针对这些痛点而整理的,涵盖文件处理、图像的加载和操作、张量的各种运算以及HuggingFace模型的加载等方面,它们表面上和模型训练没有直接关系,但是在项目初期,熟练掌握这些函数用法可以极大地提高写代码的效率!
文件处理
文件检索
以后的代码例子遵循的原则是:基本都从FastAI课程和书本上的代码片段中截取,直接对着代码讲解用法。在少数我想要拓展一些知识点的时候,我才会自己去动手编一些其他的例子。下面的代码片段,就属于FastAI书上第四章。
path = untar_data(URLs.MNIST_SAMPLE)
filename_list = path.ls()
threes = (path/'train'/'3').ls().sorted()
在面对新数据集的时候,文件级别的操作是必不可少的!很显然,上面这段代码的第一行干的事情是:获取MNIST_SAMPLE数据集的网址,然后将它下载到本地,并且解压缩。根据代码猜测意思,可以知道返回的path对象告诉了我们数据文件具体解压到了哪个文件夹。一般来说,解压之后的数据(作为文件夹的形式存在)会保存到与代码文件同级的文件夹里。
拓展:TAR文件格式
tar文件时Tape Archive的缩写,其实跟压缩包的本质是一样的,就是一个文件里塞进去了好几个文件或文件夹。一般来说,tar文件并没有对里面的原始文件进行压缩,如果压缩的话,就变成了TGZ文件,后缀名就会变成
.tar.gz。
第二行则是把当前目录下的所有文件和文件夹的名字列举出来,并存储到一个列表中。
拓展:ls命令的含义
非常简单!ls时list files的缩写,顾名思义,列出当前目录下的文件。
拓展:fastai体系下的列表叫做L。可以参考:
一般来说,如果你没有用到过fastai库,调用ls()方法只会返回一个普通的Python列表,不能直接使用.sorted()来获取排序后的版本。导入fastai库之后我们可以这么做,是因为fastai对Path类进行了进一步的封装,给它凭空添加了许多功能,让我们能够方便地直接使用。技术细节是使用了fastcore中的patch修饰符,不过这个和深度学习关系不大,后面会详细讲述用法。
Path的功能非常强大,比如在下面的代码中,你可以对一个文件使用name属性获取文件名并且对它进行操作:
fname = (path/"images").ls()[0]
fname_as_string = fname.name # 'beagle_115.jpg'
result = re.findall(r'(.+)_\d+.jpg$', fname.name)
# result is: ['beagle']
# 正则表达式相关内容:https://www.regexone.com/
也可以凭空创造一个新路径,然后调用mkdir()方法来让这个文件夹真正地被创建出来。我在这里使用了exist_ok参数,是为了遵循代码的可重复运行原则:因为大部分代码都会在Jupyter Notebook里面运行,我希望一个代码块被运行多次之后,不会产生什么灾难性的后果或者报错。这里exist_ok=True,意味着如果这个路径存在,再次调用mkdir()就不会引发文件系统的任何操作,也不会引发报错。
destination_folder.mkdir(exist_ok=True)
除了刚才利用Pathlib库获取文件名,我们也可以用os.path.basename方法。
os.path.basename('/home/user/data/image001.JPEG')
# 'image001.JPEG'
还有一个快速获取文件名(不带扩展名)的方法,那就是直接调用某个Path的stem属性。当然,这是针对一个文件的路径而言的。如果Path对象对应的是一个文件夹的路径,比如说Path('SROIE2019/train/box),那么调用stem就会返回box,也就是所在文件夹的名称。
train/'box'/fname # Path('SROIE2019/train/box/X51005442376.txt')
(train/'box'/fname).stem # 'X51005442376'
拓展:用path都能干什么?
其实这个神奇的工具根本不是fastai自己开发出来的东西,而是python的一个内置库!我们刚才正在操作的path,其实就是一个pathlib的Path对象。接下来延伸出来的东西其实跟深度学习的关系不大,直接把拓展链接放在这里就好了:pathlib — 面向对象的文件系统路径 — Python 3.12.4 文档
上面讲的文件处理相关内容其实都用到了Path对象。这样写的逻辑也很好理解,因为Python使用普通方法操作文件十分简单,根本就不怎么需要提及。不过,用open()打开文件读取内容时,还是有些细节可以注意,如可以使用f.read().splitlines()代替f.read().split('\n')。
文件匹配
我们想快速查找当前目录下的所有JPEG格式的文件,可以用Python的标准库glob检索:
from glob import glob
files = glob(str(path/'**/*.JPEG'), recursive=True)
这个函数会返回一个列表,里面包含所有符合格式的文件名。在recursive=True的时候,使用**会匹配任意层级的文件夹,也就是突破了搜索深度限制。
图像处理
如果项目涉及到计算机视觉,或者与图片相关的预测任务,那么学会如何正确地处理图片实在是太重要了。我们从最基本的搜索、下载、显示图像等操作开始,然后再到最后复杂的图像变换。
下载、验证、缩放和显示图像
如果想快速获得一批数据,就需要从网络上搜索图像然后下载,这在之前做起来非常简单,但是随着时间推移,许多搜索的API都已经失效了。我们本来介绍的搜索图片的两个方法,都已经失效。幸好我暑假的时候已经度过了需要从网上下载图片运行模型的阶段,只算是看看热闹,搜索功能真的用不了对我也影响不大。下面讲的是在已知图像、文件网址的情况下,如何把它们下载下来。
下载图像法一:download_images
前提:from fastbook import *
results = search_images_ddg('grizzly bear') # 这行代码已经不能运行了
download_images(destination_folder, urls=results)
值得注意的是,search_images理论上返回一个装有许多网址的列表,然后我们再用download_images去把这些图片保存到本地。作为替代的,还有一个download_url函数,第一个参数是网址,第二个参数是保存位置,更多用法见[External data |
fastai](https://fastai.github.io/fastai-docs/data.external#download_url)。 |
拓展:
有趣的是,虽然两个函数的名字相像,但是所属的模块完全不同。
download_url属于fastdownload.core,而download_images属于fastai.vision.utils。
下载图像法二:download_url
我有将近一年时间没有碰fastai。重新开始复习课程内容的时候,我发现官方的代码改了。当然,正如上文的描述,这段代码里的搜索图片的部分也有问题,估计无法运行。不过仍然可以感受一下,在已经得知网址和要保存的位置时,与其去查urlretrieve的文档,不如直接无脑写这个函数。
from fastdownload import download_url
dest = 'bird.jpg'
download_url(urls[0], dest, show_progress=False)
from fastai.vision.all import *
im = Image.open(dest)
im.to_thumb(256,256)
这种方法看上去可靠一些。先获得图片的url,然后再下载保存到本地,比上一段模糊的代码更加实用。
如果这些代码想要缩短,也是可以的:
download_url(search_images('forest photos', max_images=1)[0], 'forest.jpg', show_progress=False)
Image.open('forest.jpg').to_thumb(256,256)
下载文件的通用方法:fastcore.net.urlsave
我最近又找到了这个新的下载网络文件的函数,事实上,上面提到的download_url就是套的这个urlsave的壳,额外加了一个进度条的功能罢了!具体使用方法是提供网址和保存位置就好了,比较简单。
url = 'https://github.com/lerocha/chinook-database/raw/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite'
path = Path('chinook.sqlite')
if not path.exists(): urlsave(url, path)
# 如果需要解压下载的zip文件,可以参考下面的格式
shutil.unpack_archive('data/tiny-imagenet-200.zip', 'data')
快速解码图像:fastcore.all.urlread+torchvision.io.decode_image,read_image
def download_image(url):
imgb = fc.urlread(url, decode=False)
return torchvision.io.decode_image(tensor(list(imgb), dtype=torch.uint8)).float()/255.
有时候,我们希望直接把图片转换成对应的矩阵,常规的方法是用PIL打开然后再转换为张量,但是这样做会比较慢,不如使用torchvision自带的模块来进行加速!需要注意的是一些细节:
fc.urlread(url, decode=False)返回的是一个bytes对象。而bytes的本质就是一个装满了0-255整数的列表,所以我们可以直接用list进行类型转换!- 我们必须要记得使用
dtype=torch.uint8,因为8位的无符号整数对应0-255。而新建的tensor默认类型是int64,所以如果不加这个额外的参数,就会出现类型不匹配的RuntimeError。
我们读取的imgb是一个没有解码的一维张量,所以要用decode_image解码。对于已经存储在本地的图片文件,我们可以直接调用torchvision.io.read_image(path)来直接获得三维的图片张量。
img = read_image(x, mode=ImageReadMode.RGB)/255
我们附带上mode=RGB是为了处理Tiny ImageNet数据集中的例外情况,把一小部分灰度图片转换成三个通道的正常图片。
验证图像:verify_images
验证图像完整性。怎么个验证法呢?就是看哪些图片无法被打开。在下面的代码段里,我们通过一个递归的函数找到当前文件夹下所有图片文件的路径,然后使用verify_images函数依次进行验证,返回那些损坏的文件路径,然后我们调用Path.unlink删除这些文件。
fns = get_image_files(path)
failed = verify_images(fns)
failed.map(Path.unlink)
拓展:L
可以想象到failed是一个列表,里面包含着所有损坏的图像文件。可是这又不是普通的列表,而是fastai的一个L对象!它与普通的python列表相比,又增加了许多高级功能。这里的map是把函数当参数传进来,对列表里的每一个元素进行新的处理之后,再返回新的列表。后面的args和kwargs也会原封不动的传给函数。
更多信息,可以参考Foundation – fastcore
补充知识:Path.unlink
就是删除对应的文件或者快捷方式。
下面来到了一个重要的环节,就是图片已经乖乖的躺到了文件系统里之后,我们如何看到这些图片。
显示图像法一:PIL.Image
这是最基本的方法,我们使用Python Image Library打开一张图片,然后Jupyter Notebook就会自动识别并且把图片正确显示在屏幕上。
im3 = Image.open(im3_path) # im3_path是图像的路径,即一个fastai Path对象。
im3 # 直接在笔记本中显示这个图像
显示图像法二:PILImage
PILImage.create('bird.jpg')
没错,两种方法都能使用!但注意,这个PILImage类并不是归属于PIL的,而是来自于fastai.vision.core。只需要在所有代码的前面运行from fastai.vision.all import *,就可以把PIL等用到的类的名称全部导入。
显示图像法三(张量演示)
介绍一个冷门的。
我已经知道一个黑白图像的像素点的值——有一种可视化的方法:
im3_t = tensor(im3)
df = pd.DataFrame(im3_t[4:15,4:22])
df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')
对于刚才已经用正常方法打开、可以显示的im3,
- 先转换为tensor,再转换为dataframe
- 然后使用最后一行代码,设置background_gradient(‘Greys’)即可。
显示图像法三(张量演示二)
mpl.rcParams['image.cmap'] = 'gray'
plt.imshow(some_2d_array)
这样也是可以的。
作为补充,
mpl.rcParams['figure.dpi'] = 70
也是调整显示图像方式的做法。
显示图像法四(张量转图片)
如果我们有一个用张量表示的图像,如何再把它显示出来呢?
答案是,使用fastai内置的show_image(some_tensor)即可!
show_image(three_tensors[1]);
显示图像法五(缩略图)
im.to_thumb(160)
这样就可以显示缩略图了,不会把图片占满整个屏幕!
分析图像大小
假如我们拿到了一堆图像,我们先观察一下这些图像的大小都遵循什么分布,可以用到下面的代码:
from fastcore.parallel import *
def f(o): return PILImage.create(o).size
sizes = parallel(f, files, n_workers=8)
pd.Series(sizes).value_counts()
批量缩放图像
我们希望把大图像变成小图像来交给模型处理,来获得更快的训练速度。
trn_path = Path('sml')
resize_images(path/'train_images', dest=trn_path, max_size=256, recurse=True)
TorchVision的图像操作
其实在大部分情况下,torchvision可以平替PIL。我们使用resize改变图片的大小,然后用F.interpolate插值,变成一个和原来图片大小相同,但是更加模糊的图片版本。
import torchvision.transforms.functional as TF
def tfmx(x, erase=True):
x = TF.resize(x, (32,32))[None]
x = F.interpolate(x, scale_factor=2)
if erase: x = rand_erase(x)
return x[0]
Presizing增强
Presizing(加压):
- Resize images to relatively “large” dimensions—that is, dimensions significantly larger than the target training dimensions.
- Compose all of the common augmentation operations (including a resize to the final target size) into one, and perform the combined operation on the GPU only once at the end of processing, rather than performing the operations individually and interpolating multiple times.
- To implement this process in fastai, you use Resize as an item transform with a large size, and RandomResizedCrop as a batch transform with a smaller size.
这个Image Presizing是什么意思呢?在图像增强的过程中,我们先对图像进行缩放,在进行旋转、裁剪、改变亮度和对比度之类的增强操作。它带来的问题就是旋转之后的图片就会出现一些“空”的区域,要么用黑色素填充,要么通过一种算法来“补充”上这些像素。总之,使用传统的方法增强图片,会导致图片的质量下降,机器能够学到的东西减少。Presizing就是用来解决这种问题的。
其秘密在于这里:
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75)
显然,最开始把它重新裁剪成460像素大小,是远大于我们即将要裁剪的224像素大小的!这样能够在保证有足够空间和质量的情况下完成图片的增强。然后接下来的aug_transforms则有fastai的特色,把多种增强方式叠加在一起,同时处理。
图像变形方法集锦
在使用item_tfms的时候,Resize的第一个参数是图像的大小,但还可以填入第二个参数:缩放的方法。
item_tfms对应transforms,我们下载的图片都是不同大小的,所以需要压缩到同样大小。我们先用Resize(128)来简单地将每张图片裁剪到128x128像素,注意,有损失!
到这里不妨拓展一下在fastai中图片处理的几种不同方式:
dls = bears.dataloaders(path)
dls.valid.show_batch(max_n=4, nrows=1)
这样我们可以使用show_batch展示一定数量的图片。
- 使用
item_tfms=Resize(128, ResizeMethod.Squish)可以不裁剪图片的边缘,而是把整张图片拉伸为128x128的大小。或者,后一个参数直接写method='squish'也可以。 item_tfms=Resize(128, ResizeMethod.Pad, pad_mode='zeros')可以不裁剪或者拉伸图片,而是按照比例缩放之后在周围加入黑色,填满128x128的大小。item_tfms=RandomResizedCrop(128, min_scale=0.3)则是顾名思义就行了,相较于前两种处理方式是一种更科学的方法。-
item_tfms=Resize(128), batch_tfms=aug_transforms(mult=2)则是在前一步的基础上加入了数据增强!数据增强可不是刚刚的改变大小那么简单了,而是改变了图片的亮度、饱和度、倾斜度等等,能够更多地增强机器的泛化能力。 - 补充:
batch_tfms=aug_transforms(size=224, min_scale=0.75)采用了Presizing的用法,更加先进。
张量处理
显示格式
torch.set_printoptions(precision=2, linewidth=125, sci_mode=False)
np.set_printoptions(precision=2, lienwidth=140)
前两个参数比较容易理解,第三个参数是为了禁用科学计数法,这样就不会有9.9e-1或1.00e+0的数值出现了。
更新:加入了针对numpy数组的类似格式化方式。
取值约束和softmax数值稳定性
def relu(x): return x.clamp_min(0.)
没错,我们可以用一个很花哨的方式来实现relu函数。这个clamp()既可以直接提供min和max参数,也可以使用clamp_min()和clamp_max的形式。
def log_softmax(x): return x - x.logsumexp(-1,keepdim=True)
在计算交叉熵的时候我们对经过softmax处理之后的激活值取对数。为了防止数值溢出,我们可以直接实现log_softmax,经过数学表达式的一些转换,写成logsumexp()的形式。这个函数的含义就是“log of the sum of the exponentials”。
如果我们更懒的话,可以直接使用torch提供的log_softmax函数,用法是F.log_softmax(data, dim)。
外面再套一层:F.nll_loss(F.log_softmax(pred, -1), y_train)就会得到交叉熵了!
当然,作为补充,F.cross_entropy(pred, y_train)做的也就是这些。
除了硬性要求,我们还可以使用浮点数除法fmod函数来隐晦地实现约束激活值范围的功能:
x = torch.tensor([3.5, -2.7, 5.0, -8.3])
print(x.fmod(2))
fmod函数会返回浮点数除法剩下的余数,结果便是tensor([ 1.5000, -0.7000, 1.0000, -0.3000])。
变形
three_tensors = [tensor(Image.open(o)) for o in list_of_filenames_containing_threes]
show_image(three_tensors[1]) # 其中three_tensors是一个列表,每一项都是用张量表示的图片。
stacked_threes = torch.stack(three_tensors).float() # 只需要增加一个函数就可以转换数据类型
# 参考文章:
# https://python-code.dev/articles/317124631
# https://hatchjs.com/torch-view-vs-reshape/
train_x = torch.cat([stacked_threes, stacked_sevens]).view(-1, 28*28)
number_of_dimensions = stacked_threes.ndim # 等同于 len(stacked_threes.shape)
type_of_tensor = tns.type() # 如 'torch.LongTensor'
- 记住stack的用法,把一堆形状相同的二位张量组成的列表,通过
torch.stack函数“堆”成了一个三维张量。 - 可以观察一下
cat函数和stack的不同。stack要求输入列表中的每个元素形状都是完全相同的,会增加一个维度,而在这种情况下显然不能够用stack。我们好像是在把两个长度不同的向量拼接起来,这里是按行拼接,和接下来要讨论的mean类似,它也有一个dim参数默认为零。如果设置dim=1就是按列拼接,如此类推。 view干的事情好像是和reshape一样的!但是推荐优先用view而不是reshape。前者更快,尤其是对于大型的张量;另外一点的区别就是reshape会产生一个新的张量副本,但是view只是给你一种看张量的新视角,不会占用内存或者时间来进行拷贝,因此更快。然而view也不是所有情况下都能使用:它要求张量的数据分布在一个连续的空间上,如果原来的张量使用transpose,permute,或者切片过,那么数据的连续性就被破坏,无法再使用view了。因此,我们可以先通过x.contiguous()来创建一个连续的副本(如果原来的数据是连续的,那么不会有任何影响)再来调用x.view(shape)。下面是一个产生错误的示范:
x = torch.randn(2, 3, 4)
y = x.transpose(0, 1) # Now y is non-contiguous
y.view(-1) # This will fail with an error
降维
def mse(output, targ): return (output[:,0]-targ).pow(2).mean()
在这个例子中,output是一个形状(50000, 1)的张量。那么我们采用output[:,0]就可以实现快速降维。
取值
def logsumexp(x):
m = x.max(-1)[0]
return m + (x-m[:,None]).exp().sum(-1).log()
这里详细解释一下max的用法细节。x是一个二维的张量,那么我们按照最后一个维度取最值之后,为什么还要加一个[0]索引呢?
因为对高维张量使用max之后,生成的结果其实是这样的:
torch.return_types.max(
values=tensor([0.10, 0.14, 0.21, ..., 0.14, 0.11, 0.14], grad_fn=<MaxBackward0>),
indices=tensor([3, 9, 3, ..., 9, 9, 9]))
其中,我们可以用[0]索引真正的包含最大值的张量,用[1]来查询能够取到这些最大值的索引。
因此m = x.max(-1)[0]的作用就是获得tensor([0.10, 0.14, 0.21, ..., 0.14, 0.11, 0.14]。
整合
def to_device(x, device=def_device):
if isinstance(x, torch.Tensor): return x.to(device)
if isinstance(x, Mapping): return {k:v.to(device) for k,v in x.items()}
return type(x)(to_device(o, device) for o in x)
def collate_device(b): return to_device(default_collate(b))
在整合张量过程中的一个常见函数就是to_device。它基本上做的就是:
- 输入张量:直接转换成
mps或cuda方便硬件加速! - 字典:很显然是值对应的张量,用
{k:v.to(device) for k,v in x.items()}即可。 - 其他情况:估计是一些包含张量的列表或迭代器,那么我直接用
type(x)来获得对应类型的构造函数,并且迭代元素加以转换。
求均值、总和、unsqueeze、softmax等涉及到维度的问题
# 参考文章:
# https://machinelearningknowledge.ai/complete-tutorial-for-torch-mean-to-find-tensor-mean-in-pytorch/
# https://machinelearningknowledge.ai/complete-tutorial-for-torch-sum-to-sum-tensor-elements-in-pytorch/
mean3 = stacked_threes.mean(0)
mean的用法需要单独说明。简单的解释:第0个维度是行,第1个维度是列。我在这里填入0,意思就是说我就想知道这个张量中平均的一行长得是什么样子的。对应这个例子,虽然这个张量是三维的,但是我们就把后面的两维“压缩”当成一个维度就好了,第一行,挤着一张图片,第二行还是挤着一张图片,以此类推,平均的一行就是:一张平均的图片“3”!
注:一种思维方式。面临三维以及更高维度的
mean()或者sum()函数的时候,怎么判断什么行、列和其他更高维度的变化情况?正确思考方式的核心在于:一次只考虑两个维度。不管有多少个维度,第一个维度总是行,那么如果dim=0的话,你也就知道怎么办了(求平均的行)。如果硬观察的话,也可以总结到一些似是而非的规律,比如原本张量的形状是
(3,4),dim=0处理之后形状就变成了(1,4),shape所对应的dim索引处的大小全部变成了1。但根本不推荐使用这种方法。那假如你面前是一个三维向量,让你求
tensor.mean(2)即最后一个维度上取平均,怎么办?很简单,你把最后两个维度当成一个装在列表里的”表格“或者”饼“来看。那么整个张量就变成了一系列的饼排列在一起,然后我们再去深入地看每个饼本身。因为我们要求的平均值和第零维度(最开始的”行“)没有任何关系,所以mean(2)就相当于在”饼“里求平均值,那是这个二维饼的哪一个维度呢?当然是”列“维度了!我们把”饼“挤压成了一个个二维的列。没错,这看上去确实很奇怪,因为整个张量现在看起来是细长的。在
keepdim=True的情况下,它确实就会别扭地这样待着!看下面的代码:t = torch.arange(60).reshape(3,4,5) :: # 当然形状是(3,4,5)了 tensor([[[ 0, 1, 2, 3, 4], [ 5, 6, 7, 8, 9], [10, 11, 12, 13, 14], [15, 16, 17, 18, 19]], [[20, 21, 22, 23, 24], [25, 26, 27, 28, 29], [30, 31, 32, 33, 34], [35, 36, 37, 38, 39]], [[40, 41, 42, 43, 44], [45, 46, 47, 48, 49], [50, 51, 52, 53, 54], [55, 56, 57, 58, 59]]]) :: t.sum(dim=2, keepdim=True) :: tensor([[[ 10], [ 35], [ 60], [ 85]], [[110], [135], [160], [185]], [[210], [235], [260], [285]]]) ::和我们想的一模一样!那如果不加
keepdim,torch又会这样处理:竖直的长条真讨厌,你的最后一个维度不是长度变成1了吗?那要你还有什么用?直接去掉,所有数值压缩成饼的第零维(也就是一行)去!然后我们就得到了如下结果:tensor([[ 10, 35, 60, 85], [110, 135, 160, 185], [210, 235, 260, 285]])
- 而如果不加任何参数,torch就会默认我们想要求所有维度的平均值,也就是
(0,1,2)。我们可以想象一下求得平均值的过程:先压缩得到“平均图像”,然后只剩下了两个维度,还是按照老办法处理,压缩成平均的一行,再压缩成平均的一列,最后只剩下一个数,当然这个数在这个情况下没有什么意义。 - 还有一个参数
keepdim=False,就是询问是否保持求得平均值的维度和原张量相同。这里没有必要再平白增加一个维度,所以没有明确指出keepdim=True。


- 如果维度是负数,那就像列表索引一样从后面的维度往前数。如果使用元组,那就是同时合并多个维度,元组里的数字的顺序可以任意调换。在
(a-b).abs().mean((-1,-2))中,如果a、b都是28x28的张量,我们最后想要得到两张图片的平均差异,是一个零维的数字,只需要用mean()就行了,但是有可能给我们的a和b不是简单的28x28张量,也许是一个三维的张量,如(3000, 28, 28)!那么我们就只需要对后两维度进行压缩,所以就有了这一行代码。
dist_3_abs = (image_of_3 - mean3).abs().mean() # image_of_3, mean_3 全是张量
dist_3_sqr = ((image_of_3 - mean3)**2).mean().sqrt() # 可以很方便进行数学运算。
value = tsr.item() # 如果张量中只包含一个数,可以使用item()来方便地获取。
train_y = tensor([1]*len(threes) + [0]*len(sevens)).unsqueeze(1)
unsqueeze需要说一下。代码示例放在下面,本质上就是在对应的位置上插入一个多余的维度。比如说本来train_y是一个长度为n的向量,使用了unsqueeze之后,它的形状就变成了(n,1)的矩阵。或者为了好记:dim=1就是列向量只有一列,dim=0就是行向量只有一行。
# 参考文章 https://python-code.dev/articles/332257550
# Create a vector (1D tensor)
x = torch.tensor([1, 2, 3])
y = torch.unsqueeze(x, dim=0)
print(y.shape) # Output: torch.Size([1, 3])
z = torch.unsqueeze(x, dim=1)
print(z.shape) # Output: torch.Size([3, 1])
其实unsqueeze还有一种替代的用法:
t = tensor([1, 2, 3])
t[None, :] # tensor([[1, 2, 3]])
t[:, None]
# tensor([[1],
# [2],
# [3]])
- 下面来看看softmax的用法。
activations = torch.randn((6,2))*2
# 我们认为这是一个有六张图片的数据集,其中两列分别代表这张图片是“3”或“7”的概率。
activatons
::
tensor([[-2.7469, 1.2929],
[-3.3264, -0.6674],
[-1.2777, 1.2582],
[ 1.1777, 2.5570],
[-1.6045, -3.3076],
[ 2.2148, -2.0914]])
::
softmax_acts = torch.softmax(acts, dim=1)
# 可以看出每张图片被判定为“3”或“7”的概率相加之后等于1。
softmax_acts
::
tensor([[0.0173, 0.9827],
[0.0654, 0.9346],
[0.0734, 0.9266],
[0.2011, 0.7989],
[0.8459, 0.1541],
[0.9867, 0.0133]])
::
与求均值、求和的函数不同,softmax要求必须提供dim参数,否则就会报错。那么这里的维度和我们之前理解的仍然一样,设定哪一个维度进行softmax操作,最终那个维度所有的值之和总会等于1。大致意思也就是说:
tensor.softmax(dim=x).sum(dim=x) = [1, 1, 1...]
但是在使用的时候,不能想“我要把每一行的和都变成1,所以用dim=0”。原理不是这样,实际上应该用dim=1,为什么?因为dim参数规定的本质是:
“every slice along dim will sum to 1”
“Specifying
dim=1ensures that the softmax operation normalizes scores within each row (across columns)”
这可能仍然有些不好理解。那就这样想吧:把softmax函数改成mean或sum函数,dim=1,我要求平均的一列长成什么样子!那就需要把整个表格压缩成一个细长的列,而根据刚才推导出来的规律,这一个“平均”的列应该全部由“1”组成。然后再把它展开到原来的样子,“1.000”被拉伸成了一行中多个总和为“1”的小数,就知道softmax是如何得来的了。
索引
softmax_activations = torch.softmax(torch.randn((6,2))*2, dim=1)
::
tensor([[0.6025, 0.3975],
[0.5021, 0.4979],
[0.1332, 0.8668],
[0.9966, 0.0034],
[0.5959, 0.4041],
[0.3661, 0.6339]])
::
targ = tensor([0,1,0,1,1,0])
idx = range(6)
softmax_activations[idx, targ]
::
tensor([0.6025, 0.4979, 0.1332, 0.0034, 0.4041, 0.3661])
::
# 等同于:-F.nll_loss(softmax_activations, targ, reduction='none')
# 实际应用中,F不应当前面加负号。nll代表negative log likelihood,但是并不是真的对数据取对数!
In PyTorch, nll_loss assumes that you already took the log of the softmax, so it doesn’t do the logarithm for you. PyTorch has a function called log_softmax that combines log and softmax in a fast and accurate way. nll_loss is designed to be used after log_softmax.
没错,张量支持双列表索引。
拷贝
copied_data = data.clone()
使用clone()方法即可。
广播(拓展expand_as(), storage(), stride())
正常来讲,广播这件事情比较直观,不需要细讲。这里补充的是比较复杂的广播(就是代码看上去比较难以理解的那种)。为什么要学习写这种拗口的代码呢?因为广播运算实在是太快了,可读性下降被带来的速度提升补偿了。
理论探究
第一件要拓展的,就是广播背后的数据变形可以用expand_as获取。
c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
t = c.expand_as(m)
t
# tensor([[10., 20., 30.],
# [10., 20., 30.],
# [10., 20., 30.]])
同时,我们可以用t.storage()来获得张量t所占用的存储空间,然后t.stride()进一步检查。
t.storage()
# 10.0
# 20.0
# 30.0
# [torch.storage.TypedStorage(dtype=torch.float32, device=cpu) of size 3]
t.stride()
# (0, 1)
好像我在运行这行代码的时候,还会蹦出一个Deprecation警告,说是以后要统一用UntypedStorage。不过这无关紧要。作为一个完全陌生的方法,我这里进一步解释一下stride()在做什么。对于一个正常的张量,stride()描述的是张量的指针从一个维度移动到另一个维度,中间隔开的元素数量。举个例子,
x = torch.tensor([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
x.stride() # (5, 1)
想象一个指针从这个二维张量的某个位置从上向下移动(沿着第0个维度),则会直接走过五个元素的位置。然而,我们此处的张量t非常特殊,真正的元素只有三个:10、20、30,纵向上移动,根本不会在内存上移动位置,所以stride是0,而横向的就是1。
实际例子
以上讲的都是一些理论上的拔高,在广播之前,我们还经常做的一件事情就是升维。除了使用unsqueeze()和维度中加入None索引之外,还有更花哨的用法:
c[None].shape,c[...,None].shape
如果我们要把:放在切片的最后面,那么可以省略。因此c[None]等价于c[None, :],也就是unsqueeze(0)。
如果我们把...放在None的前面,那么和:放在None的前面效果是一样的。
下面开始比较难的广播实操部分。
第一个比较容易迷惑的点:
c = tensor([10.,20,30])
c[None,:] * c[:,None]
c[:,None] * c[None,:]
# 都是
# tensor([[100., 200., 300.],
# [200., 400., 600.],
# [300., 600., 900.]])
注意,这样的操作和矩阵乘法一点关系都没有,在PyTorch中,矩阵乘法用@运算符。
第二个点:
def matmul(a,b):
(ar,ac),(br,bc) = a.shape,b.shape
c = torch.zeros(ar, bc)
for i in range(ar):
# c[i,j] = (a[i,:] * b[:,j]).sum() # previous version
c[i] = (a[i,:,None] * b).sum(dim=0) # broadcast version
return c
这是一个实现矩阵乘法的函数,我们通过广播可以把计算过程从三层循环化简成两层循环,但是通过更加高级的广播方法(就是这一种),可以把所需要的循环层数变成1个!解释一下细节:a[i,:,None]实际上返回的是一个二维张量,可以理解成a[i, :]先得到一个正常的一维张量,然后再通过None增加一个维度,这样就得到一个竖着的列向量(简单的话来说,就是把a矩阵对应的行抽了出来,然后反转90度)。再让这个向量和b相乘,再在0维度上求和,就可以得到最终矩阵的一整行的值!
Einsum
这个东西比较复杂,我还是没有特别弄熟它的具体原理,不过我可以把我所需要的代码例子都粘贴在这里。
如果之后想要深入研究,可以参考大模型,还有这篇博客:https://ajcr.net/Basic-guide-to-einsum/
主要规则:
- Repeating letters between input arrays means that values along those axes will be multiplied together.
- Omitting a letter from the output means that values along that axis will be summed.
mr = torch.einsum('ik,kj->ikj', m1, m2)
这里,mr[i][k][j]=m1[i][k]*m2[k][j],因此我们把对应的字符串换成ik,kj=ij就对应了矩阵乘法(会把k维度求和消掉)。这和torch的矩阵乘法的速度一样快——说明矩阵乘法很可能与einsum是通过同一个原理实现的!
MeanShift
在MeanShift算法的某一步骤中,我们有5个点,需要要计算每个点到其余1500个点到欧几里得距离。x中保存这五个点到坐标,X保存剩下1500个点到坐标,形状分别为(5,2)和(1500,2)。那么怎么通过广播来计算这些距离呢?
我之前说这个东西不太好理解,因为涉及到升维,不过现在看来,也没有什么特别难的。每个点计算和其他点坐标的差值,然后5个点都这样,那么就会自然地产生一个三维的张量,形状是(5, 1500, 2),而想产生这样的张量,就需要我们把x“竖起来”,变成(5,1,2)都形状然后被X减。然后乘方,sum再sqrt就可以解决了。
(X-x.unsqueeze(1)).shape
# torch.Size([5, 1500, 2])