在訓練神經網路過程中,需要用到很多工具,其中最重要的三部分是:資料、視覺化和GPU加速。本章主要介紹Pytorch在這幾方面的工具模組,合理使用這些工具能夠極大地提高編碼效率。
1.1 資料處理
在解決深度學習問題的過程中,往往需要花費大量的精力去處理資料,包括影像、文字、語音或其它二進位制資料等。資料的處理對訓練神經網路來說十分重要,良好的資料處理不僅會加速模型訓練,更會提高模型效果。考慮到這點,PyTorch提供了幾個高效便捷的工具,以便使用者進行資料處理或增強等操作,同時可透過並行化加速資料載入。
1.1.1 資料載入
在PyTorch中,資料載入可透過自定義的資料集物件。資料集物件被抽象為Dataset
類,實現自定義的資料集需要繼承Dataset,並實現兩個Python方法:
__getitem__
:返回一條資料,或一個樣本。obj[index]
等價於obj.__getitem__(index)
__len__
:返回樣本的數量。len(obj)
等價於obj.__len__()
這裡我們以Kaggle經典挑戰賽"Dogs vs. Cat"的資料為例,來詳細講解如何處理資料。"Dogs vs. Cats"是一個分類問題,判斷一張圖片是狗還是貓,其所有圖片都存放在一個資料夾下,根據檔名的字首判斷是狗還是貓。
# 這條命令用於關閉終端輸出中的顏色顯示,使輸出變成無色的純文字顯示。
%env LS_COLORS = None
# 以無顏色的方式檢視 data/dogcat/ 目錄的結構,會以 ASCII 字元顯示,不會出現顏色
!tree --charset ascii data/dogcat/
# 結果
env: LS_COLORS=None
data/dogcat/
|-- cat.12484.jpg
|-- cat.12485.jpg
|-- cat.12486.jpg
|-- cat.12487.jpg
|-- dog.12496.jpg
|-- dog.12497.jpg
|-- dog.12498.jpg
`-- dog.12499.jpg
0 directories, 8 files
import torch as t
from torch.utils import data
import os
from PIL import Image
import numpy as np
class DogCat(data.Dataset):
def __init__(self, root):
imgs = os.listdir(root)
# 這裡不實際載入圖片,只是指定路徑,當呼叫__getitem__時才會真正讀圖片
# 將資料夾路徑 root 和每一個檔名 img 連線起來,生成檔案的完整路徑。
self.imgs = [os.path.join(root, img) for img in imgs]
def __getitem__(self, index):
img_path = self.imgs[index]
# 將路徑按斜槓 '/' 分割成各個部分,生成一個列表。取列表的最後一個元素,即檔名部分。
# 判斷檔名中是否包含 'dog',若包含則標籤 label 設為 1(表示狗),否則為0(貓)
label = 1 if 'dog' in img_path.split('/')[-1] else 0
# 使用 PIL 庫開啟圖片檔案,將其載入為 PIL Image 物件
pil_img = Image.open(img_path)
# 將 PIL Image 物件轉換為 numpy 陣列
array = np.asarray(pil_img)
# 將 numpy 陣列轉換為 PyTorch 張量
data = t.from_numpy(array)
return data, label
def __len__(self):
return len(self.imgs)
dataset = DogCat('./data/dogcat')
# 使用 __getitem__ 方法獲取資料集中的第一張圖片及其標籤
img, label = dataset[0] # 相當於呼叫dataset.__grtitem__(0)
for img, label in dataset:
print(img.size(), img.float().mean(), label)
# 結果
torch.Size([375, 499, 3]) tensor(116.8187) 1
torch.Size([499, 379, 3]) tensor(171.8088) 0
torch.Size([236, 289, 3]) tensor(130.3022) 0
torch.Size([377, 499, 3]) tensor(151.7141) 1
torch.Size([374, 499, 3]) tensor(115.5157) 0
透過上面的程式碼,我們學習瞭如何自定義自己的資料集,並可以依次獲取。但這裡返回的資料不適合實際使用,因其具有如下兩方面問題:
- 返回樣本的形狀不一,因每張圖片的大小不一樣,這對於需要取batch訓練的神經網路來說很不友好
- 返回樣本的數值較大,未歸一化至[-1, 1]
針對上述問題,PyTorch提供了torchvision。它是一個視覺工具包,提供了很多視覺影像處理的工具,其中transforms
模組提供了對PIL Image
物件和Tensor
物件的常用操作。
對PIL Image的操作包括:
Scale
:調整圖片尺寸,長寬比保持不變CenterCrop
、RandomCrop
、RandomResizedCrop
: 裁剪圖片Pad
:填充ToTensor
:將PIL Image物件轉成Tensor,會自動將[0, 255]歸一化至[0, 1]
對Tensor的操作包括:
- Normalize:標準化,即減均值,除以標準差
- ToPILImage:將Tensor轉為PIL Image物件
如果要對圖片進行多個操作,可透過Compose
函式將這些操作拼接起來,類似於nn.Sequential
。注意,這些操作定義後是以函式的形式存在,真正使用時需呼叫它的__call__
方法,這點類似於nn.Module
。例如要將圖片調整為224×224,首先應構建這個操作trans = Resize((224, 224))
,然後呼叫trans(img)
。下面我們就用transforms的這些操作來最佳化上面實現的dataset。
import os
from PIL import Image
import numpy as np
from torchvision import transforms as T
transform = T.Compose([
T.Resize(224), # 縮放圖片(Image),保持長寬比不變,最短邊為224畫素
T.CenterCrop(224), # 從圖片中間切出224*224的圖片
T.ToTensor(), # 將圖片(Image)轉成Tensor,歸一化至[0, 1]
T.Normalize(mean=[.5, .5, .5], std=[.5, .5, .5]) # 標準化至[-1, 1],規定均值和標準差
])
class DogCat(data.Dataset):
def __init__(self, root, transforms=None):
imgs = os.listdir(root)
self.imgs = [os.path.join(root, img) for img in imgs]
self.transforms=transforms
def __getitem__(self, index):
img_path = self.imgs[index]
label = 0 if 'dog' in img_path.split('/')[-1] else 1
data = Image.open(img_path)
if self.transforms:
data = self.transforms(data)
return data, label
def __len__(self):
return len(self.imgs)
dataset = DogCat('./data/dogcat/', transforms=transform)
img, label = dataset[0]
for img, label in dataset:
print(img.size(), label)
除了上述操作之外,transforms還可透過Lambda
封裝自定義的轉換策略。例如想對PIL Image進行隨機旋轉,則可寫成這樣trans=T.Lambda(lambda img: img.rotate(random()*360))
。
torchvision已經預先實現了常用的Dataset,包括前面使用過的CIFAR-10,以及ImageNet、COCO、MNIST、LSUN等資料集,可透過諸如torchvision.datasets.CIFAR10
來呼叫。在這裡介紹一個會經常使用到的Dataset——ImageFolder
,它的實現和上述的DogCat
很相似。ImageFolder
假設所有的檔案按資料夾儲存,每個資料夾下儲存同一個類別的圖片,資料夾名為類名,其建構函式如下:
ImageFolder(root, transform=None, target_transform=None, loader=default_loader)
它主要有四個引數:
root
:在root指定的路徑下尋找圖片transform
:對PIL Image進行的轉換操作,transform的輸入是使用loader讀取圖片的返回物件target_transform
:對label的轉換loader
:給定路徑後如何讀取圖片,預設讀取為RGB格式的PIL Image物件
label是按照資料夾名順序排序後存成字典,即{類名:類序號(從0開始)},一般來說最好直接將資料夾命名為從0開始的數字,這樣會和ImageFolder實際的label一致,如果不是這種命名規範,建議看看self.class_to_idx
屬性以瞭解label和資料夾名的對映關係。
!tree --charset ASCII data/dogcat_2/
data/dogcat_2/
|-- cat
| |-- cat.12484.jpg
| |-- cat.12485.jpg
| |-- cat.12486.jpg
| `-- cat.12487.jpg
`-- dog
|-- dog.12496.jpg
|-- dog.12497.jpg
|-- dog.12498.jpg
`-- dog.12499.jpg
2 directories, 8 files
from torchvision.datasets import ImageFolder
dataset = ImageFolder('data/dogcat_2/')
# cat資料夾的圖片對應label 0,dog對應1
dataset.class_to_idx
# {'cat': 0, 'dog': 1}
# 所有圖片的路徑和對應的label
dataset.imgs
[('data/dogcat_2/cat/cat.12484.jpg', 0),
('data/dogcat_2/cat/cat.12485.jpg', 0),
('data/dogcat_2/dog/dog.12498.jpg', 1),
('data/dogcat_2/dog/dog.12499.jpg', 1)]
# 沒有任何的transform,所以返回的還是PIL Image物件
dataset[0][1] # 第一維是第幾張圖,第二維為1返回label
dataset[0][0] # 為0返回圖片資料
# 加上transform
normalize = T.Normalize(mean=[0.4, 0.4, 0.4], std=[0.2, 0.2, 0.2])
transform = T.Compose([
T.RandomResizedCrop(224),
T.RandomHorizontalFlip(),
T.ToTensor(),
normalize,
])
dataset = ImageFolder('data/dogcat_2/', transform=transform)
# 深度學習中圖片資料一般儲存成CxHxW,即通道數x圖片高x圖片寬
dataset[0][0].size()
# torch.Size([3, 224, 224])
# 張量格式的影像轉換為PIL影像格式
to_img = T.ToPILImage()
# 0.2和0.4是標準差和均值的近似,將轉換後的影像處理,這樣就可以用於顯示
to_img(dataset[0][0]*0.2+0.4)
Dataset
只負責資料的抽象,一次呼叫__getitem__
只返回一個樣本。前面提到過,在訓練神經網路時,最好是對一個batch的資料進行操作,同時還需要對資料進行shuffle和並行加速等。對此,PyTorch提供了DataLoader
幫助我們實現這些功能。
DataLoader的函式定義如下: DataLoader(dataset, batch_size=1, shuffle=False, sampler=None, num_workers=0, collate_fn=default_collate, pin_memory=False, drop_last=False)
- dataset:載入的資料集(Dataset物件)
- batch_size:batch size
- shuffle::是否將資料打亂
- sampler: 樣本抽樣,後續會詳細介紹
- num_workers:使用多程序載入的程序數,0代表不使用多程序
- collate_fn: 如何將多個樣本資料拼接成一個batch,一般使用預設的拼接方式即可
- pin_memory:是否將資料儲存在pin memory區,pin memory中的資料轉到GPU會快一些
- drop_last:dataset中的資料個數可能不是batch_size的整數倍,drop_last為True會將多出來不足一個batch的資料丟棄
from torch.utils.data import DataLoader
dataloader = DataLoader(dataset, batch_size=3, shuffle=True, num_workers=0, drop_last=False)
# 將 dataloader 轉換為一個迭代器,使其可以逐批獲取資料。每次訪問後自動指向下一個元素
dataiter = iter(dataloader)
# 從 dataiter 迭代器中獲取下一個批次的資料。
imgs, labels = next(dataiter)
imgs.size() # batch_size, channel, height, width
# torch.Size([3, 3, 224, 224])
dataloader是一個可迭代的物件,意味著我們可以像使用迭代器一樣使用它,例如:
for batch_datas, batch_labels in dataloader:
train()
或
dataiter = iter(dataloader)
batch_datas, batch_labesl = next(dataiter)
在資料處理中,有時會出現某個樣本無法讀取等問題,比如某張圖片損壞。這時在__getitem__
函式中將出現異常,此時最好的解決方案即是將出錯的樣本剔除。如果實在是遇到這種情況無法處理,則可以返回None物件,然後在Dataloader
中實現自定義的collate_fn
,將空物件過濾掉。但要注意,在這種情況下dataloader返回的batch數目會少於batch_size。
class NewDogCat(DogCat): # 繼承前面實現的DogCat資料集
def __getitem__(self, index):
try:
# 呼叫父類的獲取函式,即 DogCat.__getitem__(self, index)
return super(NewDogCat,self).__getitem__(index)
except:
return None, None
from torch.utils.data.dataloader import default_collate # 匯入預設的拼接方式
def my_collate_fn(batch):
'''
batch中每個元素形如(data, label)
'''
# 過濾掉批次資料 batch 中第一個元素為 None 的樣本,只保留第一個元素不為 None 的樣本。
batch = list(filter(lambda x:x[0] is not None, batch))
# 如果 batch 為空,則直接返回一個空的張量
if len(batch) == 0: return t.Tensor()
return default_collate(batch) # 用預設方式拼接過濾後的batch資料
dataset = NewDogCat('data/dogcat_wrong/', transforms=transform)
dataset[5]
# 結果
(tensor([[[ 3.0000, 3.0000, 3.0000, ..., 2.5490, 2.6471, 2.7059],
[ 3.0000, 3.0000, 3.0000, ..., 2.5686, 2.6471, 2.7059],
[ 2.9804, 3.0000, 3.0000, ..., 2.5686, 2.6471, 2.6863],
...,
[-0.9020, -0.8627, -0.8235, ..., 0.0392, 0.2745, 0.4314],
[-0.9216, -0.8824, -0.8235, ..., 0.0588, 0.2745, 0.4314],
[-0.9412, -0.9020, -0.8431, ..., 0.0980, 0.3137, 0.4510]],
[[ 3.0000, 2.9608, 2.8824, ..., 2.5294, 2.6275, 2.6863],
[ 3.0000, 2.9804, 2.9216, ..., 2.5098, 2.6275, 2.6863],
[ 3.0000, 2.9804, 2.9608, ..., 2.5098, 2.6078, 2.6667],
...,
dataloader = DataLoader(dataset, 2, collate_fn=my_collate_fn, num_workers=1,shuffle=True)
for batch_datas, batch_labels in dataloader:
print(batch_datas.size(),batch_labels.size())
# 結果
torch.Size([2, 3, 224, 224]) torch.Size([2])
torch.Size([1, 3, 224, 224]) torch.Size([1])
torch.Size([2, 3, 224, 224]) torch.Size([2])
torch.Size([2, 3, 224, 224]) torch.Size([2])
torch.Size([1, 3, 224, 224]) torch.Size([1])
來看一下上述batch_size的大小。其中第2個的batch_size為1,這是因為有一張圖片損壞,導致其無法正常返回。而最後1個的batch_size也為1,這是因為共有9張(包括損壞的檔案)圖片,無法整除2(batch_size),因此最後一個batch的資料會少於batch_szie,可透過指定drop_last=True
來丟棄最後一個不足batch_size的batch。
對於諸如樣本損壞或資料集載入異常等情況,還可以透過其它方式解決。例如但凡遇到異常情況,就隨機取一張圖片代替:
class NewDogCat(DogCat):
def __getitem__(self, index):
try:
return super(NewDogCat, self).__getitem__(index)
except:
new_index = random.randint(0, len(self)-1)
return self[new_index]
相比較丟棄異常圖片而言,這種做法會更好一些,因為它能保證每個batch的數目仍是batch_size。但在大多數情況下,最好的方式還是對資料進行徹底清洗。
DataLoader封裝了Python的標準庫multiprocessing
,使其能夠實現多程序加速。在此提幾點關於Dataset和DataLoader使用方面的建議:
- 高負載的操作放在
__getitem__
中,如載入圖片等。 - dataset中應儘量只包含只讀物件,避免修改任何可變物件,利用多執行緒進行操作
以下是一個不好的例子:
class BadDataset(Dataset):
def __init__(self):
self.datas = range(100)
self.num = 0 # 取資料的次數
def __getitem__(self, index):
self.num += 1
return self.datas[index]
在 Dataset
中使用 Transforms
是執行緒安全的,因為只在每次資料載入時應用變換,不會更改原始資料,也不會線上程之間共享。
使用Python multiprocessing
庫的另一個問題是,在使用多程序時,如果主程式異常終止(比如用Ctrl+C強行退出),相應的資料載入程序可能無法正常退出。這時你可能會發現程式已經退出了,但GPU視訊記憶體和記憶體依舊被佔用著,或透過top
、ps aux
依舊能夠看到已經退出的程式,這時就需要手動強行殺掉程序。建議使用如下命令:
ps x | grep <cmdline> | awk '{print $1}' | xargs kill
pgrep -u root|xargs kill -9 # 殺掉所有程序,注意檢查是否有其他程序在執行
ps x
:獲取當前使用者的所有程序grep <cmdline>
:找到已經停止的PyTorch程式的程序,例如你是透過python train.py啟動的,那你就需要寫grep 'python train.py'
awk '{print $1}'
:獲取程序的pidxargs kill
:殺掉程序,根據需要可能要寫成xargs kill -9
強制殺掉程序
PyTorch中還單獨提供了一個sampler
模組,用來對資料進行取樣。常用的有隨機取樣器:RandomSampler
,當dataloader的shuffle
引數為True時,系統會自動呼叫這個取樣器,實現打亂資料。預設的是採用SequentialSampler
,它會按順序一個一個進行取樣。這裡介紹另外一個很有用的取樣方法: WeightedRandomSampler
,它會根據每個樣本的權重選取資料,在樣本比例不均衡的問題中,可用它來進行重取樣。
構建WeightedRandomSampler
時需提供兩個引數:每個樣本的權重weights
、共選取的樣本總數num_samples
,以及一個可選引數replacement
。權重越大的樣本被選中的機率越大,待選取的樣本數目一般小於全部的樣本數目。replacement
用於指定是否可以重複選取某一個樣本,預設為True,即允許在一個epoch中重複取樣某一個資料。如果設為False,則當某一類的樣本被全部選取完,但其樣本數目仍未達到num_samples時,sampler將不會再從該類中選擇資料,此時可能導致weights
引數失效。
dataset = DogCat('data/dogcat/', transforms=transform)
# 狗的圖片被取出的機率是貓的機率的兩倍
# 兩類圖片被取出的機率與weights的絕對大小無關,只和比值有關
weights = [2 if label == 1 else 1 for data, label in dataset]
weights
# [1, 2, 2, 1, 2, 1, 1, 2]
from torch.utils.data.sampler import WeightedRandomSampler
sampler = WeightedRandomSampler(weights,\
num_samples=9,\
replacement=True)
dataloader = DataLoader(dataset,
batch_size=3,
sampler=sampler)
for datas, labels in dataloader:
print(labels.tolist())
[1, 0, 1]
[0, 0, 1]
[1, 0, 1]
可見貓狗樣本比例約為1:2,另外一共只有8個樣本,但是卻返回了9個,說明肯定有被重複返回的,這就是replacement引數的作用,下面將replacement設為False試試。
sampler = WeightedRandomSampler(weights, 8, replacement=False)
dataloader = DataLoader(dataset, batch_size=4, sampler=sampler)
for datas, labels in dataloader:
print(labels.tolist())
[0, 1, 1, 1]
[1, 0, 0, 0]
在這種情況下,num_samples等於dataset的樣本總數,為了不重複選取,sampler會將每個樣本都返回,這樣就失去weight引數的意義了。
從上面的例子可見sampler在樣本取樣中的作用:如果指定了sampler,shuffle將不再生效,並且sampler.num_samples會覆蓋dataset的實際大小,即一個epoch返回的圖片總數取決於sampler.num_samples
。
1.2 計算機視覺工具包:torchvision
torchvision主要包含三部分:
-
- models:提供深度學習中各種經典網路的網路結構以及預訓練好的模型,包括
AlexNet
、VGG系列、ResNet系列、Inception系列等。 - datasets: 提供常用的資料集載入,設計上都是繼承
torhc.utils.data.Dataset
,主要包括MNIST
、CIFAR10/100
、ImageNet
、COCO
等。 - transforms:提供常用的資料預處理操作,主要包括對Tensor以及PIL Image物件的操作。
- models:提供深度學習中各種經典網路的網路結構以及預訓練好的模型,包括
from torchvision import models
from torch import nn
# 載入預訓練好的模型,如果不存在會進行下載
# 預訓練好的模型儲存在 ~/.torch/models/下面
resnet34 = models.squeezenet1_1(pretrained=True, num_classes=1000)
# 修改最後的全連線層為10分類問題(預設是ImageNet上的1000分類)
resnet34.fc=nn.Linear(512, 10)
from torchvision import datasets
# 指定資料集路徑為data,如果資料集不存在則進行下載
# 透過train=False獲取測試集
dataset = datasets.MNIST('data/', download=True, train=False, transform=transform)
Transforms中涵蓋了大部分對Tensor和PIL Image的常用處理,這些已在上文提到,這裡就不再詳細介紹。需要注意的是轉換分為兩步,第一步:構建轉換操作,例如transf = transforms.Normalize(mean=x, std=y)
,第二步:執行轉換操作,例如output = transf(input)
。另外還可將多個處理操作用Compose拼接起來,形成一個處理轉換流程。
torchvision還提供了兩個常用的函式。一個是make_grid
,它能將多張圖片拼接成一個網格中;另一個是save_img
,它能將Tensor儲存成圖片。
dataloader = DataLoader(dataset, shuffle=True, batch_size=16)
from torchvision.utils import make_grid, save_image
dataiter = iter(dataloader)
img = make_grid(next(dataiter)[0], 4) # 拼成4*4網格圖片,且會轉成3通道
to_img(img)
save_image(img, 'a.png')
Image.open('a.png')
1.3 視覺化工具
在訓練神經網路時,我們希望能更直觀地瞭解訓練情況,包括損失曲線、輸入圖片、輸出圖片、卷積核的引數分佈等資訊。這些資訊能幫助我們更好地監督網路的訓練過程,併為引數最佳化提供方向和依據。最簡單的辦法就是列印輸出,但其只能列印數值資訊,不夠直觀,同時無法檢視分佈、圖片、聲音等。在本節,我們將介紹兩個深度學習中常用的視覺化工具:Tensorboard和Visdom。
1.4 使用GPU加速:cuda
這部分內容在前面介紹Tensor、Module時大都提到過,這裡將做一個總結,並深入介紹相關應用。
在PyTorch中以下資料結構分為CPU和GPU兩個版本:
-
- Tensor
- nn.Module(包括常用的layer、loss function,以及容器Sequential等)
它們都帶有一個.cuda
方法,呼叫此方法即可將其轉為對應的GPU物件。注意,tensor.cuda
會返回一個新物件,這個新物件的資料已轉移至GPU,而之前的tensor還在原來的裝置上(CPU)。而module.cuda
則會將所有的資料都遷移至GPU,並返回自己。所以module = module.cuda()
和module.cuda()
所起的作用一致。
另外這裡需要專門提一下,大部分的損失函式也都屬於nn.Moudle
,但在使用GPU時,很多時候我們都忘記使用它的.cuda
方法,這在大多數情況下不會報錯,因為損失函式本身沒有可學習的引數(learnable parameters)。但在某些情況下會出現問題,為了保險起見同時也為了程式碼更規範,應記得呼叫criterion.cuda
。下面舉例說明。
# 交叉熵損失函式,帶權重
criterion = t.nn.CrossEntropyLoss(weight=t.Tensor([1, 3]))
input = t.randn(4, 2).cuda()
target = t.Tensor([1, 0, 0, 1]).long().cuda()
# 下面這行會報錯,因weight未被轉移至GPU
# loss = criterion(input, target)
# 這行則不會報錯
criterion.cuda()
loss = criterion(input, target)
criterion._buffers
# 結果
OrderedDict([('weight',
1
3
[torch.cuda.FloatTensor of size 2 (GPU 0)])])
而除了呼叫物件的.cuda
方法之外,還可以使用torch.cuda.device
,來指定預設使用哪一塊GPU,或使用torch.set_default_tensor_type
使程式預設使用GPU,不需要手動呼叫cuda。
# 如果未指定使用哪塊GPU,預設使用GPU 0
x = t.cuda.FloatTensor(2, 3)
# x.get_device() == 0
y = t.FloatTensor(2, 3).cuda()
# y.get_device() == 0
# 指定預設使用GPU 1
with t.cuda.device(1):
# 在GPU 1上構建tensor
a = t.cuda.FloatTensor(2, 3)
# 將tensor轉移至GPU 1
b = t.FloatTensor(2, 3).cuda()
c = a + b
z = x + y
# 手動指定使用GPU 0
d = t.randn(2, 3).cuda(0)
t.set_default_tensor_type('torch.cuda.FloatTensor') # 指定預設tensor的型別為GPU上的FloatTensor
a = t.ones(2, 3)
a.is_cuda # ture