Day 28 資料載入
引言
Day 27 我們認識了 autograd,理解神經網路怎麼自動算出梯度。但要在自己的資料上訓練模型,光有自動微分還不夠:我們得先把資料讀進來、做預處理、切成小批次送進模型。當資料量小的時候,把整份資料塞進記憶體直接用也沒問題;一旦資料筆數到達數萬甚至數十萬、每筆資料又是高解析度的影像時,就得改用 PyTorch 提供的標準化機制:Dataset 與 DataLoader。
這兩者屬於 torch.utils.data 模組,是訓練模型時最常打交道的一組工具。Dataset 負責「怎麼從磁碟或記憶體拿到單筆資料」,DataLoader 則負責「怎麼把單筆資料組成訓練用的批次」。兩者分工清楚,學會之後不管是處理 MNIST、CIFAR-10 還是公司內部的資料集,都能套用同樣的模式。今天會用三個小節,分別說明 Dataset、transforms 與 DataLoader 的用法。
Dataset:定義「如何取得單筆資料」
Dataset 是一個抽象類別,規定只要實作兩個方法,就能讓資料被 PyTorch 的訓練流程正確使用:__len__() 回傳資料集的總筆數,__getitem__(idx) 根據索引回傳第 idx 筆資料與對應的標籤。
我們先做一個最簡單的範例:用 5 筆假的特徵與標籤,示範如何把串列包裝成 Dataset。
import torch
from torch.utils.data import Dataset
class ToyDataset(Dataset):
def __init__(self):
# 5 筆特徵,每筆有 2 個數值
self.features = torch.tensor(
[[1.0, 2.0],
[3.0, 4.0],
[5.0, 6.0],
[7.0, 8.0],
[9.0, 10.0]]
)
# 對應的標籤(0 或 1)
self.labels = torch.tensor([0, 1, 0, 1, 0])
def __len__(self):
return len(self.features)
def __getitem__(self, idx):
return self.features[idx], self.labels[idx]
dataset = ToyDataset()
print(len(dataset)) # 輸出:5
print(dataset[0]) # 輸出:(tensor([1., 2.]), tensor(0))
print(dataset[2]) # 輸出:(tensor([5., 6.]), tensor(0))
dataset[0] 直接用索引取值,這是因為 __getitem__ 的行為和 Python 的串列一樣;這個小細節讓我們可以很直覺地檢查資料內容,也可以搭配 for 迴圈走訪每一筆。
實務上,__getitem__ 才是真正「讀資料」的地方。常見的做法是在這裡從硬碟讀取單張影像(例如用 PIL 或 OpenCV),或是從大型 HDF5、Parquet 檔案中取出單筆。這樣做的好處是:訓練過程一次只需要一個 batch 的資料,可以大幅節省記憶體。
transforms:把原始資料轉成模型能吃的格式
影像資料在送進模型前通常需要預處理。最常見的幾個步驟是:調整尺寸、轉成張量、把像素值從 0–255 縮放到 0–1、正規化到特定範圍。PyTorch 把這些常用操作整理在 torchvision.transforms 模組,並用 Compose 串接成一條預處理管線。
下面示範如何把一張 32×32 的彩色影像,先調整成 28×28,再轉成張量與正規化。
import torch
from torchvision import transforms
# 建立預處理管線
transform = transforms.Compose([
transforms.Resize((28, 28)), # 縮放到 28x28
transforms.ToTensor(), # PIL Image / ndarray -> Tensor,並縮放到 [0, 1]
transforms.Normalize( # 用均值與標準差做正規化
mean=[0.5, 0.5, 0.5], # 三個通道
std=[0.5, 0.5, 0.5]
),
])
# 假設有一張 PIL Image 影像
from PIL import Image
img = Image.new("RGB", (32, 32), color=(127, 127, 127))
x = transform(img)
print(x.shape) # 輸出:torch.Size([3, 28, 28])
Resize 接受目標尺寸,ToTensor 會把像素從整數範圍 0–255 轉成 0–1 的浮點數,並把維度從 HWC(高、寬、通道)改成 CHW(通道、高、寬),這是 PyTorch 期待的順序。Normalize 用給定的平均值與標準差做 Z-score 正規化,把像素再縮到接近 −1 到 1 的範圍。許多預訓練模型(如 ResNet)有官方建議的 mean/std,最好沿用。
transforms 的設計是「給 PIL Image 或 Tensor,輸出 Tensor」。當我們把 transform 物件傳給 Dataset 時,可以在 __getitem__ 裡套用:
class ImageDataset(Dataset):
def __init__(self, image_paths, labels, transform=None):
self.image_paths = image_paths
self.labels = labels
self.transform = transform
def __len__(self):
return len(self.image_paths)
def __getitem__(self, idx):
img = Image.open(self.image_paths[idx]).convert("RGB")
if self.transform is not None:
img = self.transform(img)
return img, self.labels[idx]
這段程式示範裡的 __getitem__ 開啟硬碟上的影像檔、用 transform 做預處理、再回傳張量與標籤。如果之後想加資料增強(Data Augmentation),只要在 Compose 裡加 RandomHorizontalFlip() 這類方法即可,概念一致。
DataLoader:把資料組成批次送進模型
Dataset 解決了「怎麼拿單筆資料」,DataLoader 則負責把多筆資料組合成訓練用的批次(batch)。它還會幫我們處理 shuffle、多執行緒讀取、把多筆張量沿著第一個維度堆疊等瑣事。
延續前面的 ToyDataset,用 DataLoader 把它包起來:
from torch.utils.data import DataLoader
dataset = ToyDataset()
loader = DataLoader(dataset, batch_size=2, shuffle=True)
for batch_features, batch_labels in loader:
print("特徵批次:", batch_features)
print("標籤批次:", batch_labels)
print("---")
# 輸出(順序會因 shuffle 而不同):
# 特徵批次:tensor([[3., 4.], [1., 2.]])
# 標籤批次:tensor([1, 0])
# ---
# 特徵批次:tensor([[5., 6.], [9., 10.]])
# 標籤批次:tensor([0, 0])
# ---
# 特徵批次:tensor([[7., 8.]])
# 標籤批次:tensor([1])
batch_size=2 表示每個 batch 包含 2 筆資料;shuffle=True 表示每個 epoch 開始前會把資料順序打亂,這對訓練的穩定性很有幫助。最後一批可能湊不到 batch_size 筆(例如上例 5 筆資料、batch_size=2 時,最後一批只有 1 筆),這是正常的。
在 GPU 上訓練時,DataLoader 還能配合 num_workers 用多個子行程同時讀資料,縮短 GPU 等待時間。常見的設定是 num_workers=2 或 num_workers=4,不過 Windows 在多行程讀取上有時候會遇到相容性問題,如果出現錯誤可以先設回 num_workers=0,再用其他方式加速。
loader = DataLoader(
dataset,
batch_size=64,
shuffle=True,
num_workers=2, # 用 2 個子行程讀資料
pin_memory=True, # 若用 CUDA,啟用可加速 CPU->GPU 傳輸
)
pin_memory=True 會把張量放在「鎖定」的記憶體區域,加快之後搬到 GPU 的速度;不過只在 CUDA 環境有效,CPU 訓練時設成 False 也沒關係。把 Dataset、transforms 與 DataLoader 串起來,就是訓練深度學習模型的標準資料管線。
把三層串起來:完整的小範例
為了把觀念釐清,下面把 Dataset、transforms 與 DataLoader 一次串起來。雖然這個範例還是用合成資料,但只要把 __getitem__ 換成讀取硬碟影像的程式碼,就能直接套用到真實任務上。
import torch
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
class NumberDataset(Dataset):
def __init__(self, n_samples=100, transform=None):
self.features = torch.randn(n_samples, 3, 32, 32) # 假裝是 3x32x32 影像
self.labels = torch.randint(0, 10, (n_samples,))
self.transform = transform
def __len__(self):
return len(self.features)
def __getitem__(self, idx):
x = self.features[idx]
if self.transform is not None:
x = self.transform(x)
return x, self.labels[idx]
transform = transforms.Compose([
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
])
dataset = NumberDataset(transform=transform)
loader = DataLoader(dataset, batch_size=8, shuffle=True)
for batch_x, batch_y in loader:
print(batch_x.shape, batch_y.shape)
break
# 輸出:torch.Size([8, 3, 32, 32]) torch.Size([8])
這個範例說明了一個重點:DataLoader 會自動把同一批次的「資料張量」沿著第一個維度疊起來,把「標籤張量」也沿著第一個維度疊起來,我們不必自己寫 stack 程式碼。如果資料格式比較特殊(例如每筆資料長度不同的文字),可以額外傳入 collate_fn 自訂疊法,這部分在 Day 35 處理文字資料時會再提到。
結語
今天把訓練流程的「資料這一端」整理了一遍:Dataset 定義如何取得單筆資料與標籤,transforms.Compose 把預處理與資料增強串成一條管線,DataLoader 則把資料組成批次並交給模型。掌握這三層分工之後,不管資料是存在記憶體、硬碟上的影像、還是大型資料庫,都能用同樣的模式包進訓練流程。
明天,我們會把神經網路這一端補上。PyTorch 用 torch.nn.Module 把模型結構與參數統一管理,搭配 nn.Linear 與激活函式,就能搭出第一個全連接層構成的網路,並用前向傳播得到預測值。
留言
張貼留言