十五、PyTorch常用工具模組

鹤比纷恆红發表於2024-10-30

  在訓練神經網路過程中,需要用到很多工具,其中最重要的三部分是:資料、視覺化和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:調整圖片尺寸,長寬比保持不變
  • CenterCropRandomCropRandomResizedCrop: 裁剪圖片
  • 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使用方面的建議:

  1. 高負載的操作放在__getitem__中,如載入圖片等。
  2. 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視訊記憶體和記憶體依舊被佔用著,或透過topps 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}':獲取程序的pid
  • xargs 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,主要包括MNISTCIFAR10/100ImageNetCOCO等。
    • transforms:提供常用的資料預處理操作,主要包括對Tensor以及PIL Image物件的操作。
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)

十五、PyTorch常用工具模組

save_image(img, 'a.png')
Image.open('a.png')

十五、PyTorch常用工具模組

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

相關文章