學習Dataset類的來龍去脈,使用干凈的代碼結構,同時最大限度地減少在訓練期間管理大量數據的麻煩。
神經網絡訓練在數據管理上可能很難做到“大規模”。
PyTorch 最近已經出現在我的圈子里,盡管對Keras和TensorFlow感到滿意,但我還是不得不嘗試一下。令人驚訝的是,我發現它非常令人耳目一新,非常討人喜歡,尤其是PyTorch 提供了一個Pythonic API、一個更為固執己見的編程模式和一組很好的內置實用程序函數。我特別喜歡的一項功能是能夠輕松地創建一個自定義的Dataset
對象,然后可以與內置的DataLoader
一起在訓練模型時提供數據。
在本文中,我將從頭開始研究PyTorchDataset
對象,其目的是創建一個用於處理文本文件的數據集,以及探索如何為特定任務優化管道。我們首先通過一個簡單示例來了解Dataset
實用程序的基礎知識,然后逐步完成實際任務。具體地說,我們想創建一個管道,從The Elder Scrolls(TES)系列中獲取名稱,這些名稱的種族和性別屬性作為一個one-hot張量。你可以在我的網站上找到這個數據集。
Dataset類的基礎知識
Pythorch允許您自由地對“Dataset”類執行任何操作,只要您重寫兩個子類函數:
-返回數據集大小的函數,以及
-函數的函數從給定索引的數據集中返回一個樣本。
數據集的大小有時可能是灰色區域,但它等於整個數據集中的樣本數。因此,如果數據集中有10000個單詞(或數據點、圖像、句子等),則函數“uuLen_uUu”應該返回10000個。
PyTorch使您可以自由地對Dataset
類執行任何操作,只要您重寫改類中的兩個函數即可:
__len__
函數:返回數據集大小__getitem__
函數:返回對應索引的數據集中的樣本
數據集的大小有時難以確定,但它等於整個數據集中的樣本數量。因此,如果您的數據集中有10,000個樣本(數據點,圖像,句子等),則__len__
函數應返回10,000。
一個簡單示例
首先,創建一個從1到1000所有數字的Dataset
來模擬一個簡單的數據集。我們將其適當地命名為NumbersDataset
。
from torch.utils.data import Dataset
class NumbersDataset(Dataset):
def __init__(self):
self.samples = list(range(1, 1001))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
return self.samples[idx]
if __name__ == '__main__':
dataset = NumbersDataset()
print(len(dataset))
print(dataset[100])
print(dataset[122:361])
很簡單,對吧?首先,當我們初始化NumbersDataset
時,我們立即創建一個名為samples
的列表,該列表將存儲1到1000之間的所有數字。列表的名稱是任意的,因此請隨意使用您喜歡的名稱。需要重寫的函數是不用我說明的(我希望!),並且對在構造函數中創建的列表進行操作。如果運行該python文件,將看到1000、101和122到361之間的值,它們分別指的是數據集的長度,數據集中索引為100的數據以及索引為121到361之間的數據集切片。
擴展數據集
讓我們擴展此數據集,以便它可以存儲low
和high
之間的所有整數。
from torch.utils.data import Dataset
class NumbersDataset(Dataset):
def __init__(self, low, high):
self.samples = list(range(low, high))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
return self.samples[idx]
if __name__ == '__main__':
dataset = NumbersDataset(2821, 8295)
print(len(dataset))
print(dataset[100])
print(dataset[122:361])
運行上面代碼應在控制台打印5474、2921和2943到3181之間的數字。通過編寫構造函數,我們現在可以將數據集的low
和high
設置為我們的想要的內容。這個簡單的更改顯示了我們可以從PyTorch的Dataset
類獲得的各種好處。例如,我們可以生成多個不同的數據集並使用這些值,而不必像在NumPy中那樣,考慮編寫新的類或創建許多難以理解的矩陣。
從文件讀取數據
讓我們來進一步擴展Dataset
類的功能。PyTorch與Python標准庫的接口設計得非常優美,這意味着您不必擔心集成功能。在這里,我們將
- 創建一個全新的使用Python I/O和一些靜態文件的
Dataset
類 - 收集TES角色名稱(我的網站上有可用的數據集),這些角色名稱分為種族文件夾和性別文件,以填充
samples
列表 - 通過在
samples
列表中存儲一個元組而不只是名稱本身來跟蹤每個名稱的種族和性別。
TES名稱數據集具有以下目錄結構:
.
|-- Altmer/
| |-- Female
| `-- Male
|-- Argonian/
| |-- Female
| `-- Male
... (truncated for brevity)(為了簡潔,這里進行省略)
`-- Redguard/
|-- Female
`-- Male
每個文件都包含用換行符分隔的TES名稱,因此我們必須逐行讀取每個文件,以捕獲每個種族和性別的所有字符名稱。
import os
from torch.utils.data import Dataset
class TESNamesDataset(Dataset):
def __init__(self, data_root):
self.samples = []
for race in os.listdir(data_root):
race_folder = os.path.join(data_root, race)
for gender in os.listdir(race_folder):
gender_filepath = os.path.join(race_folder, gender)
with open(gender_filepath, 'r') as gender_file:
for name in gender_file.read().splitlines():
self.samples.append((race, gender, name))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
return self.samples[idx]
if __name__ == '__main__':
dataset = TESNamesDataset('/home/syafiq/Data/tes-names/')
print(len(dataset))
print(dataset[420])
我們來看一下代碼:首先創建一個空的samples
列表,然后遍歷每個種族(race)文件夾和性別文件並讀取每個文件中的名稱來填充該列表。然后將種族,性別和名稱存儲在元組中,並將其添加到samples
列表中。運行該文件應打印19491和('Bosmer', 'Female', 'Gluineth')
(每台計算機的輸出可能不太一樣)。讓我們看一下將數據集的一個batch的樣子:
# 將main函數改成下面這樣:
if __name__ == '__main__':
dataset = TESNamesDataset('/home/syafiq/Data/tes-names/')
print(dataset[10:60])
正如您所想的,它的工作原理與列表完全相同。對本節內容進行總結,我們剛剛將標准的Python I/O 引入了PyTorch數據集中,並且我們不需要任何其他特殊的包裝器或幫助器,只需要單純的Python代碼。實際上,我們還可以包括NumPy或Pandas之類的其他庫,並且通過一些巧妙的操作,使它們在PyTorch中發揮良好的作用。讓我們現在來看看在訓練時如何有效地遍歷數據集。
用DataLoader加載數據
盡管Dataset
類是創建數據集的一種不錯的方法,但似乎在訓練時,我們將需要對數據集的samples
列表進行索引或切片。這並不比我們對列表或NumPy矩陣進行操作更簡單。PyTorch並沒有沿這條路走,而是提供了另一個實用工具類DataLoader
。DataLoader
充當Dataset
對象的數據饋送器(feeder)。如果您熟悉的話,這個對象跟Keras中的flow
數據生成器函數很類似。DataLoader
需要一個Dataset
對象(它延伸任何子類)和其他一些可選參數(參數都列在PyTorch的DataLoader文檔中)。在這些參數中,我們可以選擇對數據進行打亂,確定batch的大小和並行加載數據的線程(job)數量。這是TESNamesDataset
在循環中進行調用的一個簡單示例。
# 將main函數改成下面這樣:
if __name__ == '__main__':
from torch.utils.data import DataLoader
dataset = TESNamesDataset('/home/syafiq/Data/tes-names/')
dataloader = DataLoader(dataset, batch_size=50, shuffle=True, num_workers=2)
for i, batch in enumerate(dataloader):
print(i, batch)
當您看到大量的batch被打印出來時,您可能會注意到每個batch都是三元組的列表:第一個元組包含種族,下一個元組包含性別,最后一個元祖包含名稱。
等等,那不是我們之前對數據集進行切片時的樣子!這里到底發生了什么?好吧,事實證明,DataLoader
以系統的方式加載數據,以便我們垂直而非水平來堆疊數據。這對於一個batch的張量(tensor)流動特別有用,因為張量垂直堆疊(即在第一維上)構成batch。此外,DataLoader
還會為對數據進行重新排列,因此在發送(feed)數據時無需重新排列矩陣或跟蹤索引。
張量(tensor)和其他類型
為了進一步探索不同類型的數據在DataLoader
中是如何加載的,我們將更新我們先前模擬的數字數據集,以產生兩對張量數據:數據集中每個數字的后4個數字的張量,以及加入一些隨機噪音的張量。為了拋出DataLoader
的曲線球,我們還希望返回數字本身,而不是張量類型,是作為Python字符串返回。__getitem__
函數將在一個元組中返回三個異構數據項。
from torch.utils.data import Dataset
import torch
class NumbersDataset(Dataset):
def __init__(self, low, high):
self.samples = list(range(low, high))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
n = self.samples[idx]
successors = torch.arange(4).float() + n + 1
noisy = torch.randn(4) + successors
return n, successors, noisy
if __name__ == '__main__':
from torch.utils.data import DataLoader
dataset = NumbersDataset(100, 120)
dataloader = DataLoader(dataset, batch_size=10, shuffle=True)
print(next(iter(dataloader)))
請注意,我們沒有更改數據集的構造函數,而是修改了__getitem__
函數。對於PyTorch數據集來說,比較好的做法是,因為該數據集將隨着樣本越來越多而進行縮放,因此我們不想在Dataset
對象運行時,在內存中存儲太多張量類型的數據。取而代之的是,當我們遍歷樣本列表時,我們將希望它是張量類型,以犧牲一些速度來節省內存。在以下各節中,我將解釋它的用處。
觀察上面的輸出,盡管我們新的__getitem__
函數返回了一個巨大的字符串和張量元組,但是DataLoader
能夠識別數據並進行相應的堆疊。字符串化后的數字形成元組,其大小與創建DataLoader
時配置的batch大小的相同。對於兩個張量,DataLoader
將它們垂直堆疊成一個大小為10x4
的張量。這是因為我們將batch大小配置為10,並且在__getitem__
函數返回兩個大小為4的張量。
通常來說,DataLoader
嘗試將一批一維張量堆疊為二維張量,將一批二維張量堆疊為三維張量,依此類推。在這一點上,我懇請您注意到這對其他機器學習庫中的傳統數據處理產生了翻天覆地的影響,以及這個做法是多么優雅。太不可思議了!如果您不同意我的觀點,那么至少您現在知道有這樣的一種方法。
完成TES數據集的代碼
讓我們回到TES數據集。似乎初始化函數的代碼有點不優雅(至少對於我而言,確實應該有一種使代碼看起來更好的方法。請記住我說過的,PyTorch API是像python的(Pythonic)嗎?數據集中的工具函數,甚至對內部函數進行初始化。為清理TES數據集的代碼,我們將更新TESNamesDataset
的代碼來實現以下目的:
- 更新構造函數以包含字符集
- 創建一個內部函數來初始化數據集
- 創建一個將標量轉換為獨熱(one-hot)張量的工具函數
- 創建一個工具函數,該函數將樣本數據轉換為種族,性別和名稱的三個獨熱(one-hot)張量的集合。
為了使工具函數正常工作,我們將借助scikit-learn
庫對數值(即種族,性別和名稱數據)進行編碼。具體來說,我們將需要LabelEncoder
類。我們對代碼進行大量的更新,我將在接下來的幾小節中解釋這些修改的代碼。
import os
from sklearn.preprocessing import LabelEncoder
from torch.utils.data import Dataset
import torch
class TESNamesDataset(Dataset):
def __init__(self, data_root, charset):
self.data_root = data_root
self.charset = charset
self.samples = []
self.race_codec = LabelEncoder()
self.gender_codec = LabelEncoder()
self.char_codec = LabelEncoder()
self._init_dataset()
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
race, gender, name = self.samples[idx]
return self.one_hot_sample(race, gender, name)
def _init_dataset(self):
races = set()
genders = set()
for race in os.listdir(self.data_root):
race_folder = os.path.join(self.data_root, race)
races.add(race)
for gender in os.listdir(race_folder):
gender_filepath = os.path.join(race_folder, gender)
genders.add(gender)
with open(gender_filepath, 'r') as gender_file:
for name in gender_file.read().splitlines():
self.samples.append((race, gender, name))
self.race_codec.fit(list(races))
self.gender_codec.fit(list(genders))
self.char_codec.fit(list(self.charset))
def to_one_hot(self, codec, values):
value_idxs = codec.transform(values)
return torch.eye(len(codec.classes_))[value_idxs]
def one_hot_sample(self, race, gender, name):
t_race = self.to_one_hot(self.race_codec, [race])
t_gender = self.to_one_hot(self.gender_codec, [gender])
t_name = self.to_one_hot(self.char_codec, list(name))
return t_race, t_gender, t_name
if __name__ == '__main__':
import string
data_root = '/home/syafiq/Data/tes-names/'
charset = string.ascii_letters + "-' "
dataset = TESNamesDataset(data_root, charset)
print(len(dataset))
print(dataset[420])
修改的構造函數初始化
構造函數這里有很多變化,所以讓我們一點一點地來解釋它。您可能已經注意到構造函數中沒有任何文件處理邏輯。我們已將此邏輯移至_init_dataset
函數中,並清理了構造函數。此外,我們添加了一些編碼器,來將原始字符串轉換為整數並返回。samples
列表也是一個空列表,我們將在_init_dataset
函數中填充該列表。構造函數還接受一個新的參數charset
。顧名思義,它只是一個字符串,可以將char_codec
轉換為整數。
已增強了文件處理功能,該功能可以在我們遍歷文件夾時捕獲種族和性別的唯一標簽。如果您沒有結構良好的數據集,這將很有用;例如,如果Argonians擁有一個與性別無關的名稱,我們將擁有一個名為“Unknown”的文件,並將其放入性別集合中,而不管其他種族是否存在“Unknown”性別。所有名稱存儲完畢后,我們將在由種族,性別和名稱構成數據集來初始化編碼器。
工具函數
我們添加了兩個工具函數:to_one_hot
和one_hot_sample
。to_one_hot
使用數據集的內部編碼器將數值列表轉換為整數列表,然后再調用看似不適當的torch.eye
函數。實際上,這是一種巧妙的技巧,可以將整數列表快速轉換為一個向量。torch.eye
函數創建一個任意大小的單位矩陣,其對角線上的值為1。如果對矩陣行進行索引,則將在該索引處獲得值為1的行向量,這是獨熱向量的定義!
因為我們需要將三個數據轉換為張量,所以我們將在對應數據的每個編碼器上調用to_one_hot
函數。one_hot_sample
將單個樣本數據轉換為張量元組。種族和性別被轉換為二維張量,這實際上是擴展的行向量。該向量也被轉換為二維張量,但該二維向量包含該名稱的每個字符每個獨熱向量。
__getitem__
調用
最后,__getitem__
函數的代碼已更新為僅在one_hot_sample
給定種族,性別和名稱的情況下調用該函數。注意,我們不需要在samples
列表中預先准備張量,而是僅在調用__getitem__
函數(即DataLoader
加載數據流時)時形成張量。當您在訓練期間有成千上萬的樣本要加載時,這使數據集具有很好的可伸縮性。
您可以想象如何在計算機視覺訓練場景中使用該數據集。數據集將具有文件名列表和圖像目錄的路徑,從而讓__getitem__
函數僅讀取圖像文件並將它們及時轉換為張量來進行訓練。通過提供適當數量的工作線程,DataLoader
可以並行處理多個圖像文件,可以使其運行得更快。PyTorch數據加載教程有更詳細的圖像數據集,加載器,和互補數據集。這些都是由torchvision
庫進行封裝的(它經常隨着PyTorch一起安裝)。torchvision
用於計算機視覺,使得圖像處理管道(例如增白,歸一化,隨機移位等)很容易構建。
回到原文。數據集已經構建好了,看來我們已准備好使用它進行訓練……
……但我們還沒有
如果我們嘗試使用DataLoader
來加載batch大小大於1的數據,則會遇到錯誤:
您可能已經看到過這種情況,但現實是,文本數據的不同樣本之間很少有相同的長度。結果,DataLoader
嘗試批量處理多個不同長度的名稱張量,這在張量格式中是不可能的,因為在NumPy數組中也是如此。為了說明此問題,請考慮以下情況:當我們將“ John”和“ Steven”之類的名稱堆疊在一起形成一個單一的獨熱矩陣時。'John'轉換為大小4xC
的二維張量,'Steven'轉換為大小6xC
二維張量,其中C是字符集的長度。DataLoader
嘗試將這些名稱堆疊為大小2x?xC
三維張量(DataLoader
認為堆積大小為1x4xC
和1x6xC
)。由於第二維不匹配,DataLoader
拋出錯誤,導致它無法繼續運行。
可能的解決方案
為了解決這個問題,這里有兩種方法,每種方法都各有利弊。
- 將批處理(batch)大小設置為1,這樣您就永遠不會遇到錯誤。如果批處理大小為1,則單個張量不會與(可能)不同長度的其他任何張量堆疊在一起。但是,這種方法在進行訓練時會受到影響,因為神經網絡在單批次(batch)的梯度下降時收斂將非常慢。另一方面,當批次大小不重要時,這對於快速測試時,數據加載或沙盒測試很有用。
- 通過使用空字符填充或截斷名稱來獲得固定的長度。截短長的名稱或用空字符來填充短的名稱可以使所有名稱格式正確,並具有相同的輸出張量大小,從而可以進行批處理。不利的一面是,根據任務的不同,空字符可能是有害的,因為它不能代表原始數據。
由於本文的目的,我將選擇第二個方法,您只需對整體數據管道進行很少的更改即可實現此目的。請注意,這也適用於任何長度不同的字符數據(盡管有多種填充數據的方法,請參見NumPy和PyTorch中的選項部分)。在我的例子中,我選擇用零來填充名稱,因此我更新了構造函數和_init_dataset
函數:
...
def __init__(self, data_root, charset, length):
self.data_root = data_root
self.charset = charset + '\0'
self.length = length
...
with open(gender_filepath, 'r') as gender_file:
for name in gender_file.read().splitlines():
if len(name) < self.length:
name += '\0' * (self.length - len(name))
else:
name = name[:self.length-1] + '\0'
self.samples.append((race, gender, name))
...
首先,我在構造函數引入一個新的參數,該參數將所有傳入名稱字符固定為length
值。我還將\0
字符添加到字符集中,用於填充短的名稱。接下來,數據集初始化邏輯已更新。缺少長度的名稱僅用\0
填充,直到滿足長度的要求為止。超過固定長度的名稱將被截斷,最后一個字符將被替換為\0
。替換是可選的,這取決於具體的任務。
而且,如果您現在嘗試加載此數據集,您應該獲得跟您當初所期望的數據:正確的批(batch)大小格式的張量。下圖顯示了批大小為2的張量,但請注意有三個張量:
- 堆疊種族張量,獨熱編碼形式表示該張量是十個種族中的某一個種族
- 堆疊性別張量,獨熱編碼形式表示數據集中存在兩種性別中的某一種性別
- 堆疊名稱張量,最后一個維度應該是
charset
的長度,第二個維度是名稱長度(固定大小后),第一個維度是批(batch)大小。
數據拆分實用程序
所有這些功能都內置在PyTorch中,真是太棒了。現在可能出現的問題是,如何制作驗證甚至測試集,以及如何在不擾亂代碼庫並盡可能保持DRY的情況下執行驗證或測試。測試集的一種方法是為訓練數據和測試數據提供不同的data_root
,並在運行時保留兩個數據集變量(另外還有兩個數據加載器),尤其是在訓練后立即進行測試的情況下。
如果您想從訓練集中創建驗證集,那么可以使用PyTorch數據實用程序中的random_split
函數輕松處理這一問題。random_split
函數接受一個數據集和一個划分子集大小的列表,該函數隨機拆分數據,以生成更小的Dataset
對象,這些對象可立即與DataLoader
一起使用。這里有一個例子。
通過使用內置函數輕松拆分自定義PyTorch數據集來創建驗證集。
事實上,您可以在任意間隔進行拆分,這對於折疊交叉驗證集非常有用。我對這個方法唯一的不滿是你不能定義百分比分割,這很煩人。至少子數據集的大小從一開始就明確定義了。另外,請注意,每個數據集都需要單獨的DataLoader
,這絕對比在循環中管理兩個隨機排序的數據集和索引更干凈。
結束語
希望本文能使您了解PyTorch中Dataset
和DataLoader
實用程序的功能。與干凈的Pythonic API結合使用,它可以使編碼變得更加輕松愉快,同時提供一種有效的數據處理方式。我認為PyTorch開發的易用性根深蒂固於他們的開發理念,並且在我的工作中使用PyTorch之后,我從此不再回頭使用Keras和TensorFlow。我不得不說我確實錯過了Keras模型隨附的進度條和fit
/predict
API,但這是一個小小的挫折,因為最新的帶TensorBoard接口的PyTorch帶回了熟悉的工作環境。盡管如此,目前,PyTorch是我將來的深度學習項目的首選。
我鼓勵以這種方式構建自己的數據集,因為它消除了我以前管理數據時遇到的許多凌亂的編程習慣。在復雜情況下,Dataset
是一個救命稻草。我記得必須管理屬於一個樣本的數據,但該數據必須來自三個不同的MATLAB矩陣文件,並且需要正確切片,規范化和轉置。如果沒有Dataset
和DataLoader
組合,我不知如何進行管理,特別是因為數據量巨大,而且沒有簡便的方法將所有數據組合成NumPy矩陣且不會導致計算機崩潰。
最后,查看PyTorch數據實用程序文檔頁面 ,其中包含其他類別和功能,這是一個很小但有價值的實用程序庫。您可以在我的GitHub上找到TES數據集的代碼,在該代碼中,我創建了與數據集同步的PyTorch中的LSTM名稱預測變量。讓我知道這篇文章是有用的還是不清楚的,以及您將來是否希望獲得更多此類內容。
原文鏈接:https://towardsdatascience.com/building-efficient-custom-datasets-in-pytorch-2563b946fd9f
歡迎關注磐創AI博客站:
http://panchuang.net/
sklearn機器學習中文官方文檔:
http://sklearn123.com/
歡迎關注磐創博客資源匯總站:
http://docs.panchuang.net/