انتقل إلى المحتوى الرئيسي

الوحدة 4 — Dataset وDataLoader: تغذية التدريب

النموذج الذي بنيناه في الوحدة الثالثة يحتاج شيئًا واحدًا ليتعلّم: بيانات. لكن كيف تصل تلك البيانات إليه يُقرِّر أكثر ممّا تظنّ من سرعة التدريب ومن صوابه. هذه الوحدة تُبنى على مفهومين: Dataset يعرف كيف يقرأ مثالًا واحدًا، وDataLoader يعرف كيف يُقدّمه في حزم مُوازِية وجاهزة للنموذج.

Dataset: بروتوكول من طريقتين

كلّ صنف يرث من torch.utils.data.Dataset يُعرِّف طريقتين:

from torch.utils.data import Dataset

class MonJeu(Dataset):
def __init__(self, ...):
...

def __len__(self) -> int:
return N # عدد الأمثلة

def __getitem__(self, idx: int):
# يعود لكل استدعاء بمثال جاهز للنموذج
return x, y

هذا كلّ ما يطلبه PyTorch. لا مصفوفة عملاقة تُحمَّل في الذاكرة، ولا تكرار في الطبعات المسبقة: DataLoader يستدعي __getitem__ عند الحاجة، وأنت حرّ في القراءة من القرص، من قاعدة بيانات، من واجهة برمجية بعيدة، أو من الذاكرة إذا كانت البيانات صغيرة.

Dataset لـFashion-MNIST من ملفّ CSV

سنبني مثالاً عمليًّا حيث كلّ صفّ من CSV يحتوي على صنف و784 قيمة بكسل:

import csv
import torch
from torch.utils.data import Dataset

class JeuMode(Dataset):
"""Fashion-MNIST من CSV بسيط: colonne 0 = صنف، 1..784 = بكسل."""

def __init__(self, chemin_csv: str, transformation=None):
self.lignes = []
with open(chemin_csv, encoding="utf-8") as f:
lecteur = csv.reader(f)
next(lecteur) # تخطّي رأس الأعمدة
for ligne in lecteur:
self.lignes.append(ligne)
self.transformation = transformation

def __len__(self):
return len(self.lignes)

def __getitem__(self, idx):
ligne = self.lignes[idx]
y = int(ligne[0])
x = torch.tensor(
[int(v) for v in ligne[1:]], dtype=torch.float32
).view(1, 28, 28)
if self.transformation is not None:
x = self.transformation(x)
return x, y

نُلاحظ ثلاث نقاط. أوّلاً، لا نُحمِّل الصور بل نقرأها عند الطلب، والذاكرة تبقى منخفضة. ثانيًا، transformation يُحقن من الخارج، لأنّ نفس Dataset قد يخدم التدريب والتقييم بمعالجات مختلفة (وسنعود إلى ذلك). ثالثًا، الشكل الناتج (1, 28, 28) — قناة رمادية واحدة — يوافق تمامًا ما ينتظره النموذج.

DataLoader: التوزيع في حزم مُوازِية

DataLoader يُحوّل Dataset إلى تدفّق حزم:

from torch.utils.data import DataLoader

jeu_tr = JeuMode("fashion_train.csv", transformation=augmentations)
jeu_val = JeuMode("fashion_val.csv", transformation=preparations)

charg_tr = DataLoader(
jeu_tr,
batch_size=64,
shuffle=True,
num_workers=4,
pin_memory=True,
drop_last=True,
)
charg_val = DataLoader(
jeu_val,
batch_size=256,
shuffle=False,
num_workers=2,
pin_memory=True,
)

كلّ وسيط له دور محدَّد يُشرَح في السطور التالية.

أهمّ الوسائط، وما يفعله كلّ منها

الوسيطما يفعلهملاحظة
batch_sizeحجم الحزمةيُوازن السرعة والذاكرة
shuffle=Trueيخلط الفهارس في كلّ حقبةلا يُفعَّل أبدًا على التحقّق
num_workers=kيُشغّل k عمليات لقراءة البيانات0 يقرأ في العملية الرئيسية
pin_memory=Trueيُثبّت الحزمة في ذاكرة قابلة للنقل السريع إلى GPUذو أثر عند وجود GPU
drop_last=Trueيُهمل الحزمة الأخيرة الناقصةمفيد مع BatchNorm
collate_fn=fيُخصّص طريقة تجميع العناصر في حزمةلطول متغيّر

num_workers هو الوسيط الأكثر أثرًا على السرعة. صفرٌ يعني أنّ القراءة والحساب في العملية نفسها، فيبقى GPU جائعًا. رفعه إلى 4 أو 8 يُوازِي القراءة، لكن قيمة كبيرة جدًّا تُدهور الأداء لأنّ إدارة العمليات نفسها تصير مكلفة. القاعدة العملية: ابدأ بعدد النوى المتاحة، ثم قِس.

pin_memory وnon_blocking: زوج للنقل الفعّال

عند pin_memory=True، تُحضَّر الحزم في منطقة ذاكرة يُمكن لبطاقة GPU قراءتها مباشرةً دون نسخ وسيط. لتفعيل الفائدة كاملةً، يجب أن يُطلَب النقل مع non_blocking=True:

for lot_x, lot_y in charg_tr:
lot_x = lot_x.to("cuda", non_blocking=True)
lot_y = lot_y.to("cuda", non_blocking=True)
...

بذلك يبدأ نقل الحزمة القادمة في نفس الوقت الذي يحسب فيه GPU الحزمة الحالية. من دون pin_memory يُهمَل non_blocking بصمت. نعود إلى قياس هذا الأثر في الوحدة السابعة.

collate_fn: عندما تختلف أطوال العناصر

القيمة الافتراضية لـcollate_fn تُكدِّس التنسورات من نفس الشكل. عندما تتفاوت الأطوال — وهو الحال في اللغة الطبيعية — تحتاج دالّة مخصّصة:

def rassembler_lot_variable(lot):
x = [element[0] for element in lot]
y = torch.tensor([element[1] for element in lot], dtype=torch.long)
x_pad = torch.nn.utils.rnn.pad_sequence(x, batch_first=True)
return x_pad, y

على Fashion-MNIST لا نحتاجها لأنّ الصور بنفس القياس، لكنّها تفيدك عند الانتقال إلى دورات النصوص والتسلسلات لاحقًا.

التقسيم إلى تدريب، تحقّق، اختبار

نُقسّم قبل أيّ معالجة، كما شرحت دورات سابقة، ولذلك السبب نفسه: نمنع التسرّب. في PyTorch الطريقة الأنظف هي random_split:

from torch.utils.data import random_split

jeu_complet = JeuMode("fashion_all.csv", transformation=None)
n = len(jeu_complet)
n_tr = int(0.7 * n)
n_val = int(0.15 * n)
n_test = n - n_tr - n_val
jeu_tr, jeu_val, jeu_test = random_split(
jeu_complet,
[n_tr, n_val, n_test],
generator=torch.Generator().manual_seed(42),
)

البذرة تُثبّت التقسيم عبر التجارب، وهذا شرط لأيّ مقارنة عادلة بين نموذجين. من دونها، فروق دقيقة في التقسيم قد تُموِّه فروقًا حقيقية في النموذج، أو تُنتج فروقًا كاذبة.

فخّ تسرّب المعالجة القبلية

إليك الخطأ الأشيع في هذه الوحدة، وهو مطابق تمامًا لخطأ StandardScaler المعتاد على البيانات الجدولية:

# ❌ خطأ: حساب الإحصائيات على البيانات كلّها قبل التقسيم
moyenne = images_toutes.mean()
ecart = images_toutes.std()

def normaliser(x):
return (x - moyenne) / ecart

هنا تنسرّب معلومات التحقّق والاختبار إلى المعالجة، فتبدو أرقام التقييم أفضل ممّا هي عليه فعلاً. الحلّ:

# ✅ صحيح: حساب الإحصائيات على التدريب وحده
moyenne = jeu_tr_tensor.mean()
ecart = jeu_tr_tensor.std()

هذه القاعدة تنطبق على كلّ ما يتعلّم من البيانات: التطبيع، المُسوّي، مفردات الترميز، جدول التوزيع في ترميز الأصناف. كلّها تُقاس على التدريب، ثم تُطبَّق كما هي على التحقّق والاختبار.

Fashion-MNIST جاهز عبر torchvision

في مشروع الدورة الحقيقيّ سنستخدم النسخة الجاهزة من torchvision، لكنّ فهم الآلية اليدوية لا غنى عنه في مشاريعك الشخصية:

from torchvision import datasets, transforms

pipeline_tr = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.286], std=[0.353]), # قِيَم Fashion-MNIST
])

jeu_tr = datasets.FashionMNIST(
root="./donnees", train=True, download=True, transform=pipeline_tr
)

نلاحظ نفس المبدأ: تحويلات التدريب تحوي التقليب والقلب، وتحويلات التحقّق تقتصر على التحويل والتطبيع. الاختلاف بين المعالجتين مقصود وضروري، والخلط بينهما يُدخل ضجيجًا في التقييم.

تسرّب حسابيّ عابر للتقسيم

حساب متوسط الصور على المجموعة الكاملة ثم استخدامه في تطبيع الجميع يبدو بريئًا، وهو من الأخطاء الأصعب اكتشافًا. المتوسطات المذكورة أعلاه (0.286 و0.353) قيم Fashion-MNIST المنشورة، محسوبة على مجموعة التدريب الرسمية وحدها. اعتمد هذا النهج دائمًا: حَقيبةُ حساب مغلقة على التدريب.

كم عاملاً وأيّ حجم حزمة؟

لا توجد إجابة نظرية. جرّب num_workers في 0، 2، 4، 8 وسجّل ثوانيَ الحقبة لكلّ إعداد. غالبًا ما تجد قيمة مثلى دون النوى المتاحة. أمّا حجم الحزمة فمقيَّد بالذاكرة: خذ أكبر قيمة تعمل، ثم اضبط معدّل التعلّم معها في الوحدة السادسة. تكبير الحزمة يستدعي تكبير معدّل التعلّم في معظم الأحيان.

في الخلاصة

  • Dataset يُعرَّف بـ__len__ و__getitem__؛ يقرأ مثالاً واحدًا عند الطلب، ويقبل تحويلاً حقنيًّا مختلفًا للتدريب والتقييم.
  • DataLoader يُغذّي الحزم مع خلط للتدريب فقط، وعمّال لموازاة القراءة، وpin_memory مع non_blocking لنقل GPU سريع.
  • لا خلطَ على التحقّق، ولا حساب إحصائيات على البيانات كلّها: الإحصائيات تُقاس على التدريب وتُطبَّق كما هي على الباقي.
  • على البيانات القياسية، torchvision.datasets يوفّر ضمانات التقسيم؛ خذ منه المتوسّطات المنشورة بدل حسابها بنفسك.

الوحدة التالية: نجمع كلّ ما سبق في حلقة تدريب كاملة، مع تشخيص لحظيّ للتقدّم ولفرط التخصيص.