0701-資料處理

二十三歲的有德發表於2021-04-27

0701-資料處理

pytorch完整教程目錄:https://www.cnblogs.com/nickchen121/p/14662511.html

一、概述

在機器學習中,尤其是在深度學習中,需要耗費大量的精力去處理資料,並且資料的處理對訓練神經網路來說也是很重要的,良好的資料不僅會加速模型的訓練,也可以提高模型的效率。

為此,torch 提供了幾個高效便捷的工具,以便使用者更方便的對資料做處理,同時也可以並行化加速資料載入。

二、載入自定義資料集

在 torch 中,可以載入自定義資料集,在這個過程中,需要自定義資料集物件,資料集物件將被抽象為 Dataset 類,也就是說實現自定義的資料集需要繼承 Dataset,同時也需要實現兩個 Python 魔法方法:

  • __getiter__:返回一條資料或一個樣本。obj[index] 等價於 obj.__getitem__(index)
  • __len__:返回樣本的數量。len(obj) 等價於 obj.__len__()

在這裡我們以 Kaggle 經典挑戰賽“Dogs vs. Cat”的資料為例,詳細講解如何處理資料。其中該資料是一個分類問題的資料,判斷一張圖片是狗還是貓,它的所有圖片都放在一個資料夾下,並可以根據檔名的字首是狗還是貓。需要圖片資料的可以加我微信:chenyoudea

import os

imgs = os.listdir('./img/dogcat')  # 獲取./img/dogcat下的所有圖片檔案
for img in imgs:
    print(img)
dog.12497.jpg
cat.12484.jpg
cat.12485.jpg
dog.12496.jpg
cat.12487.jpg
cat.12486.jpg
dog.12498.jpg
dog.12499.jpg
import os
import torch as t
import numpy as np
from PIL import Image
from torch.utils import data


class DogCat(data.Dataset):
    def __init__(self, root):
        imgs = os.listdir(root)
        # 所有圖片的絕對路徑
        # 這裡不實際載入圖片,只是指定路徑
        # 當呼叫__getitem__時才會真正讀圖片
        self.imgs = [os.path.join(root, img) for img in imgs]

    def __getitem__(self, index):
        img_path = self.imgs[index]
        # dog->1, cat->0
        label = 1 if 'dog' in img_path.split(
            '/')[-1] else 0  # 通過對圖片檔名字首的判斷給圖片增加標籤
        pil_img = Image.open(img_path)  # 開啟圖片
        array = np.asarray(pil_img)  # 把圖片轉為 ndarray 資料
        data = t.from_numpy(array)  # 把圖片轉為 Tensor 資料
        return data, label


dataset = DogCat('./img/dogcat/')
# img, label = dataset[0]  # 相當於呼叫 dataset.__getitem__(0)
for img, label in dataset:
    print(img.size(), img.float().mean(), label)
torch.Size([375, 499, 3]) tensor(150.5080) 1
torch.Size([500, 497, 3]) tensor(106.4915) 0
torch.Size([499, 379, 3]) tensor(171.8085) 0
torch.Size([375, 499, 3]) tensor(116.8139) 1
torch.Size([374, 499, 3]) tensor(115.5177) 0
torch.Size([236, 289, 3]) tensor(130.3004) 0
torch.Size([377, 499, 3]) tensor(151.7174) 1
torch.Size([400, 300, 3]) tensor(128.1550) 1


/Applications/anaconda3/lib/python3.6/site-packages/ipykernel_launcher.py:23: UserWarning: The given NumPy array is not writeable, and PyTorch does not support non-writeable tensors. This means you can write to the underlying (supposedly non-writeable) NumPy array using the tensor. You may want to copy the array to protect its data or make it writeable before converting it to a tensor. This type of warning will be suppressed for the rest of this program. (Triggered internally at  ../torch/csrc/utils/tensor_numpy.cpp:143.)

上述所示的 /Applications/anaconda3/lib…… 的錯誤,是因為圖片是 git 上拿下來的,沒有修改許可權,我懶得修改了,自己有空把它修改下,反正沒啥影響。

對於我們自定義的資料集,我們已經學會了如何通過程式碼定義這樣的資料集,但是這樣的資料並不適合使用,因為它們有兩個這樣的問題:

  1. 每張圖片的大小不一樣,這對於需要取 batch 訓練的神經網路來說並不友好
  2. 返回樣本的數值較大,沒有歸一化到 [-1,1]

三、利用 torchvision 工具處理資料集

為了解決上一節的遺留的問題,torch 提供了 torchvision,它是一個視覺工具包,提供了很多視覺影像處理的工具,其中 transform 模組提供了對 PIL Image 物件和 Tensor 物件的常用操作。如果想更詳細的瞭解這個工具,可以去去檢視官方文件:https://github.com/pytorch/vision/

對 PIL Image 的常見操作如下:

  • Resize:調整圖片尺寸
  • CenterCrop、RandomCrop、RandomSizedCrop:裁剪圖片
  • Pad:填充
  • ToTensor:把 PIL Image 物件轉成 Tensor,會自動將 [0,255] 歸一化為 [0,1]

對 Tensor 的常見操作如下:

  • Normalize:標準化,即減均值,除以標準差
  • ToPILImage:將 Tensor 轉為 PIL Image 物件

如果需要對圖片進行多個操作,可以通過 Compose 把這些操作拼接起來,類似於 nn.Sequential。需要注意的是,這些操作定義後是以物件的形式存在,真正使用時需要呼叫它的 __call__ 方法,類似於 nn.Module

例如,如果要把圖片調整為 224*224,首先構建操作 trans = Scale((224,224)),然後呼叫 trans(img)。接下來我們就用 transform 的這些操作來優化上面實現的 dataset。

import os
from PIL import Image
import numpy as np
from torchvision import transforms as T

transform = T.Compose([
    T.Resize(224),  # 縮放圖片,保持長寬比不變,最短邊為 224 畫素
    T.CenterCrop(224),  # 從圖片中間切出 224*224 的圖片
    T.ToTensor(),  # 把圖片轉成 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('./img/dogcat/', transforms=transform)
img, label = dataset[0]
for img, label in dataset:
    print(img.size(), label)
torch.Size([3, 224, 224]) 0
torch.Size([3, 224, 224]) 1
torch.Size([3, 224, 224]) 1
torch.Size([3, 224, 224]) 0
torch.Size([3, 224, 224]) 1
torch.Size([3, 224, 224]) 1
torch.Size([3, 224, 224]) 0
torch.Size([3, 224, 224]) 0

從上述程式碼可以看到 transforms 的強大,除了這些,transforms 還可以通過 Lambda 封裝自定義的轉換策略。

例如,如果相對 PIL Image 進行隨機旋轉,則可以寫成 trans = T.Lambda(lambda img: img.rotate(random()*360))

上面我們說到了如何載入自定義的資料集,對於很多研究者來說,只是想試驗自己的演算法有沒有問題,如果自己去獲取資料,再加上深度學習對資料量的要求,那是非常困難的。

為此 torchvision 預先實現了常用的 Dataset,包括 CIFAR-10、ImageNet、COCO、MNIST、LSUN 等資料集,可以通過呼叫 torchvision.datasets 下相應的物件來呼叫相關的資料集,具體的使用方法可以檢視官方文件:https://pytorch.org/vision/stable/datasets.html

四、ImageFolder 的使用——處理資料集

本節介紹一個我們經常會用到的一個 Dataset——ImageFolder,它的實現和上述 DogCat類 的功能類似,主要是對圖片進行處理。

ImageFoder 假設所有的檔案按資料夾儲存,每個資料夾下儲存同一個類別的圖片,資料夾名為類名,它的建構函式如下所示:ImageFolder(root, transform=None, target_transform=None, loader=default_loader)

它主要有以下四個引數:

  • root:在 root 指定的路徑下尋找圖片
  • transform:對 PIL Image進行轉換操作,transform 的輸入是使用 loader 讀取圖片的返回物件
  • target_transform:對 label 的轉換
  • loader:指定載入圖片的函式,預設操作是讀取為 PIL Image 物件

label 是按照資料夾名字順序排序後存成字典的,即 {類名:類序號(從 0 開始)},一般來說最好直接將檔案命名為從 0 開始的數字,這樣回合 ImageFolder 實際的 label 一致。

from torchvision.datasets import ImageFolder

dataset = ImageFolder('./img/dogcat_2')

# cat 資料夾的圖片對應 label 0,dog 對應 1
dataset.class_to_idx
{'cat': 0, 'dog': 1}
# 所有圖片的路徑和對應的 label
dataset.imgs
[('./img/dogcat_2/cat/cat.12484.jpg', 0),
 ('./img/dogcat_2/cat/cat.12485.jpg', 0),
 ('./img/dogcat_2/cat/cat.12486.jpg', 0),
 ('./img/dogcat_2/cat/cat.12487.jpg', 0),
 ('./img/dogcat_2/dog/dog.12496.jpg', 1),
 ('./img/dogcat_2/dog/dog.12497.jpg', 1),
 ('./img/dogcat_2/dog/dog.12498.jpg', 1),
 ('./img/dogcat_2/dog/dog.12499.jpg', 1)]
dataset[0][1]  # 第一維是第幾張圖,第二維為 1 返回 label
0
# 沒有任何的 transform,多以返回的還是 PIL Image 物件
dataset[0][0]  # 為 0 返回圖片資料,返回的 Image 物件如下圖所示

# 加上 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('img/dogcat_2', transform=transform)
dataset[0][0].size()  # 深度學習圖片資料一般儲存成 C*H*W,即 通道數*圖片高*圖片寬
torch.Size([3, 224, 224])
to_img = T.ToPILImage()
# 0.2 和 0.4 是標準差和均值的近似
to_img(dataset[0][0]*0.2+0.4) # 程式輸出如下圖所示

五、DataLoader 的使用——批載入資料

Dataset 只負責抽象資料,並且一次呼叫 __getitem__ 只返回一個樣本。

在訓練神經網路的時候,是對一個 batch 的資料進行操作,同時還需要對資料進行 shuffle 和並行加速等,為此,torch 提供了 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)

dataiter = iter(dataloader)  # dataloader是一個可迭代物件,通過 iter 把 dataloader 變成一個迭代器
imgs, labels = next(dataiter)
imgs.size()  # batch_size,channel,height,weight
torch.Size([3, 3, 224, 224])

dataloader 是一個可迭代的物件,因此可以像使用迭代器一樣使用它。迭代器如果你忘記了是啥,可以看這篇文章:迭代器

# 迭代器的兩種使用方法
# 第一種直接獲取所有資料,資料量大不建議使用
for batch_datas, batch_labels in dataloader:
    train()

# 第二種只生成一個迭代器,用一個取一個資料
dataiter = iter(dataloader)
imgs, labels = next(dataiter)

六、處理損壞圖片

class NewDogCat(DogCat):
    def __getitem__(self, index):
        try:
            # 呼叫父類的獲取函式,相當於 DogCat.__getitem__(self,index)
            return super(NewDogCat, self).__getitem__(index)
        except:
            return None, None  # 獲取異常的物件返回 None


from torch.utils.data.dataloader import default_collate  # 匯入預設的拼接方式


def my_collate_fn(batch):
    """
    batch 中每個元素形如(data,label)
    """
    batch = list(filter(lambda x: x[0] is not None, batch))  # 過濾為 None 的資料
    return default_collate(batch)  # 用預設方式拼接過濾後的 batch 資料


dataset = NewDogCat('img/dogcat_wrong/', transforms=transform)
dataset[6]
(None, None)
dataloader = DataLoader(dataset, 2, collate_fn=my_collate_fn, num_workers=0)
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([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])
torch.Size([1, 3, 224, 224]) torch.Size([1])

通過檢視上面的列印結果,可以看到第 4 個 batch_size 為 1,這是因為其中有一張圖片損壞,而最後一個 batch_size 也是 1,是因為總共有 9 張圖片,無法整除 2,因此最後一個 batch 的資料會少於 batch_size,可以通過指定 drop_last=True 丟棄最後一個樣本數目不足 batch_size 的 batch。

除了上述所說的方法,對於損壞或資料集載入異常等情況,還可以通過其他方法解決,例如遇到異常圖片,就可以隨機選擇另外一張圖片代替,則 batch_size 就不會小於規定的 batch_size。

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]

上述所說的方法看起來很好,但是如果我們換個角度去想,我為什麼要讓資料夾裡面有一張異常的圖片呢?因此為了防止圖片異常,更應該對資料進行徹底清洗。

DataLoader 為了實現多程式加速,它封裝了 Python 的標準庫 multiprocessing,因此在 Dataset 和 DataLoader 使用時有以下兩個建議:

  1. 高負載的操作放在 __getitem__中,如載入圖片等
  2. dataset 中應該儘量只包含只讀物件,避免修改任何可變物件

第一點是因為多程式會並行地呼叫 __getitem__ 函式,把負載高的放在 __getitem__ 函式中能夠實現並行加速。

第二點是因為 dataloader 使用多程式載入,如果在 Dataset 中使用了可變物件,可能會有意想不到的衝突。在多執行緒/多程式中,修改一個可變物件需要加鎖,但是 dataloader 的設計讓它很難加鎖,因此最好避免在 dataset 中修改可變物件。

下面就是一個不好的例子,在多程式中處理的 self.num 可能和預期不符,這種問題不會報錯,所以很難發現。如果真的一定要修改可變物件,可以使用 Python 標準庫 Queue 中的相關資料結構。

class BadDataset(data.Dataset):
    def __init__(self):
        self.datas = range(10)
        self.num = 0  # 取資料的次數

    def __getitem__(self, index):
        self.num += 1
        return self.datas[index]

使用 Python 的 multiprocessing 庫的另一個問題就是,在使用多程式時,如果主程式異常終止,相應的資料載入程式可能無法正常退出。這個時候你可能會發現程式已經退出了,但是 GPU 視訊記憶體和記憶體仍然被佔用著,這個時候就需要手動強行終止程式。

七、資料取樣

torch 中還單獨提供了一個 sampler 模組,用來進行資料取樣。常用的有隨機取樣器 RandomSampler,當 dataloader 的 shuffle 引數為 True 時,系統就會自動呼叫這個取樣器,進而打亂資料。

預設的取樣器是 SequentialSampler,它會按順序一個一個進行取樣。

在這裡介紹另外一個很有用的取樣方法 WeightedRandomSampler,它會根據每個樣本的權重選取資料,在樣本比例不均衡的問題中,可以用它進行重取樣。

構建 WeightedRandomSampler 時需要提供3個引數:

  • 每個樣本的權重weights
  • 共選取的樣本總數 num_samples
  • 可選引數 replacement,指定是否可以重複選取一個樣本,預設為 True,也就是說允許一個 epoch 中重複取樣一個資料。如果設定為 False,則當某一類樣本被全部選取結束後,它的樣本還沒有達到 num_samples 時,sampler 將不會再從該類中選擇資料,此時可能會導致 weights 引數失效

注:權重越大的樣本被選中的概率越大,待選取的樣本數目一般小於全部的樣本數目。

dataset = DogCat('./img/dogcat/', transforms=transform)
# 狗的圖片被取出的概率是貓的概率的兩倍
# 兩類圖片被取出的概率和 weights 的絕對大小無關,只和比值有關,例如這裡的比值為 2:1
weights = [2 if label == 1 else 1 for data, label in dataset]
weights
[1, 2, 2, 1, 2, 2, 1, 1]
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, 1, 1]
[1, 0, 0]
[1, 0, 1]

從上面可以看到貓狗樣本的比例約為 1:2,另外一共只有 8 個樣本,卻返回了 9 個,說明有樣本被重複返回,這就是 replacement 引數的左右,下面我們把 replacement 設為 False。

# 如果 weights 設定為 100:1,則 貓 的被選中的概率幾乎為 0
weights = [100 if label == 1 else 1 for data, label in dataset]

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, 1, 1]
[1, 1, 1]
[1, 1, 1]
sampler = WeightedRandomSampler(weights, 8, replacement=False)
dataloader = DataLoader(dataset, batch_size=4, sampler=sampler)
for datas, labels in dataloader:
    print(labels.tolist())
[1, 1, 1, 1]
[0, 0, 0, 0]

從上面的程式碼可以看到,num_samples 等於 dataset 的樣本總數,為了不重複選取,sampler 會把每個樣本都返回,這樣就失去了 weight 引數的意義。

從上面的例子可以看出 sampler 在樣本取樣中的作用:如果指定了 sampler,shuffle 將不會再生效,並且 sampler.num_samples 會覆蓋 dataset 的實際大小,也就是一個 epoch 返回的圖片總數取決於 sampler.num_samples。