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

الوحدة 5 — حلقة التدريب والتقييم المكتوبة يدويًا

جمعنا حتى الآن كلّ القطع: التنسور، Autograd، nn.Module، DataLoader. هذه الوحدة تُركّبها في حلقة تدريب كاملة على Fashion-MNIST، ثم تُضيف حلقة تقييم مقابلة، وتُظهر كيف نُلاحظ فرط التخصيص لحظةً بلحظة. سيصبح هذا القالب هيكل كلّ التدريبات القادمة في الدورة.

البنية الأدنى للحلقة

قبل الغوص في التفاصيل، إليك هيكل حلقة تدريب صحيح لحقبة واحدة:

modele.train()                             # ننتقل إلى وضع التدريب
for lot_x, lot_y in charg_tr:
lot_x, lot_y = lot_x.to(appareil), lot_y.to(appareil)
optimiseur.zero_grad() # ننظّف التدرّجات المتراكمة
sorties = modele(lot_x) # الرحلة الأمامية
perte = critere(sorties, lot_y) # حساب الخسارة
perte.backward() # الرحلة العكسية
optimiseur.step() # تحديث الأوزان

خمسة استدعاءات في ترتيب لا يتغيّر: zero_grad أوّلاً، ثم الأمامي، ثم الخسارة، ثم backward، ثم step. أيّ خلل في هذا الترتيب — نسيان zero_grad، أو step قبل backward، أو الأمامي بعد الخسارة — يُنتج تدريبًا معطوبًا يستمرّ دون رسالة خطأ.

train مقابل eval: التحوّل الذي لا يرفع خطأً

model.train() وmodel.eval() لا يُشغّلان أو يوقفان التدريب: هما يُبدّلان سلوك بعض الطبقات. تحديدًا Dropout وBatchNorm — وسائر طبقات التطبيع — لهما وضعان مختلفان:

الطبقةفي train()في eval()
Dropoutيُلغي عصبونات عشوائيًايُمرّر كلّ شيء
BatchNormيحسب إحصائيات الحزمة الحاليةيستعمل المتوسّطات الجارية
Linear، Conv2dلا فرقلا فرق

نسيان eval() قبل التقييم لا يرفع خطأً: الشبكة تُنتج توقّعات، لكنّها تعتمد على تركيبة الحزمة الحالية، وتُفعِّل Dropout الذي يُدهور الاستدلال. الأثر: مقاييس تحقّق مضطربة، أسوأ من التدريب دون سبب واضح. هذه هي العلّة الأشيع في هذه الدورة.

حلقة تقييم صحيحة

الحلقة المقابلة للتقييم تلتزم ثلاث قواعد: eval()، torch.no_grad، عدم استدعاء optimiseur.step:

def evaluer(modele, charg, critere, appareil):
modele.eval()
perte_totale = 0.0
justes = 0
n = 0
with torch.no_grad():
for lot_x, lot_y in charg:
lot_x, lot_y = lot_x.to(appareil), lot_y.to(appareil)
sorties = modele(lot_x)
perte_totale += critere(sorties, lot_y).item() * lot_x.size(0)
justes += (sorties.argmax(dim=1) == lot_y).sum().item()
n += lot_x.size(0)
return perte_totale / n, justes / n

نُحسِن ضرب الخسارة في حجم الحزمة قبل التجميع لتفادي انحياز آخر حزمة ناقصة. الفارق طفيف في الأداء لكنّه دقيق في التقييمات المتقاربة.

التدريب الكامل على Fashion-MNIST

نجمع القِطَع في مثال قابل للتنفيذ:

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

appareil = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(42)

# 1. البيانات
prep = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.286], std=[0.353]),
])
jeu_tr = datasets.FashionMNIST("./donnees", train=True, download=True, transform=prep)
jeu_val = datasets.FashionMNIST("./donnees", train=False, download=True, transform=prep)
charg_tr = DataLoader(jeu_tr, batch_size=128, shuffle=True, num_workers=2, pin_memory=True)
charg_val = DataLoader(jeu_val, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)

# 2. النموذج (من الوحدة 3)
class ClassifieurMode(nn.Module):
def __init__(self, largeur=256, abandon=0.2):
super().__init__()
self.bloc = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, largeur), nn.ReLU(), nn.Dropout(abandon),
nn.Linear(largeur, largeur), nn.ReLU(), nn.Dropout(abandon),
nn.Linear(largeur, 10),
)
def forward(self, x):
return self.bloc(x)

modele = ClassifieurMode().to(appareil)
critere = nn.CrossEntropyLoss()
optimiseur = torch.optim.AdamW(modele.parameters(), lr=1e-3, weight_decay=1e-4)

# 3. الحلقة الرئيسية
historique = {"tr_perte": [], "val_perte": [], "val_exact": []}

for epoque in range(1, 21):
modele.train()
perte_tr = 0.0
n_tr = 0
for lot_x, lot_y in charg_tr:
lot_x = lot_x.to(appareil, non_blocking=True)
lot_y = lot_y.to(appareil, non_blocking=True)
optimiseur.zero_grad()
sorties = modele(lot_x)
perte = critere(sorties, lot_y)
perte.backward()
optimiseur.step()
perte_tr += perte.item() * lot_x.size(0)
n_tr += lot_x.size(0)

perte_tr /= n_tr
perte_val, exact_val = evaluer(modele, charg_val, critere, appareil)
historique["tr_perte"].append(perte_tr)
historique["val_perte"].append(perte_val)
historique["val_exact"].append(exact_val)

print(f"حقبة {epoque:2d} | تدريب {perte_tr:.4f} | تحقّق {perte_val:.4f} | دقّة {exact_val:.3f}")

هذا القالب سنُثريه في الوحدات القادمة، لكنّ عظامه هي عظام كلّ تدريب بـPyTorch.

قراءة السجلّات: علامات التقدّم وعلامات المرض

مع كلّ حقبة نُطبع ثلاثة أرقام: خسارة التدريب، خسارة التحقّق، دقّة التحقّق. علامات صحّية:

  • خسارة التدريب تنخفض بانتظام: الشبكة تتعلّم شيئًا.
  • خسارة التحقّق تنخفض تقريبًا كذلك، مع تأخّر خفيف: التعميم يعمل.
  • دقّة التحقّق ترتفع بشكل موازٍ.

علامات مرَضية تُوجب التدخّل:

  • خسارة تدريب تنخفض وخسارة تحقّق ترتفع: هذا فرط التخصيص المدرسي. الحلّ ينتظرنا في الوحدة السادسة (تنظيم، معدّل تعلّم) والتاسعة (تعلّم بالنقل).
  • خسارتان تبقيان مستويتين: معدّل التعلّم ربّما بعيد جدًّا، والوحدة السادسة تُقدّم أدوات تشخيصه.
  • NaN مفاجئ: غالبًا نسيان zero_grad، أو معدّل تعلّم مبالغ فيه، أو خسارة سيئة الاختيار (سوفت‌ماكس مضاعف في المخرج).

تشخيص عمليّ قبل الإطلاق الكبير

قبل تشغيل عشرين حقبةً على البيانات كلّها، طبّق «إفراط في تعلّم عيّنة صغيرة»: خذ عشر أو مئة صورة، عطّل التنظيم، وشغّل الحلقة على هذه العيّنة نفسها لعدد كافٍ من الحقب. إن لم تصل الخسارة إلى صفر، هناك عطب في الكود لا في البيانات ولا في المعمارية. هذه الحيلة، الوارِدة في دورة أساسيات التعلّم العميق، توفّر ساعات من التصحيح على تجارب كاملة.

petit = torch.utils.data.Subset(jeu_tr, indices=range(50))
charg_p = DataLoader(petit, batch_size=10, shuffle=True)
# ... شغّل 200 حقبةً، انتظر خسارة قريبة من الصفر

criterion.reduction وحسابات الخسارة الجمعية

افتراضيًا، CrossEntropyLoss تُعيد متوسّط خسارة الحزمة (reduction="mean"). حين نضربها في حجم الحزمة قبل الجمع نحصل على مجموع الحزم، وعند القسمة على العدد الكلّي نحصل على متوسّط دقيق. البديل تمرير reduction="sum" والقسمة في النهاية على العدد. الطريقتان صحيحتان؛ الخلط بينهما (متوسّط الحزمة + قسمة على عدد الحزم لا العدد الكلّي) يُنتج قيمًا خاطئة قليلاً حين تختلف أحجام الحزم.

ماذا نُسجّل، ومتى

قاعدة عملية: خسارة كلّ حقبة، لا كلّ خطوة. الطباعة في كلّ حزمة تُنتج سجلّات ضخمة قليلة الفائدة. للتشخيص العميق سنستعمل TensorBoard في الوحدة القادمة من دورة 08 وسنعود إليه في المسار الاحترافي. أمّا هنا، فقاموس بايثون بسيط historique يكفي، ويُرسَم لاحقًا في matplotlib.

no_grad كذلك لا يكفي بلا eval

torch.no_grad() يُوقف تسجيل التدرّج. لا يُعدِّل سلوك Dropout ولا BatchNorm. كلّ منهما مسؤوليّة منفصلة. الحلقة الصحيحة تحمل الاثنين: modele.eval() و with torch.no_grad():. غيابهما معًا لا يُسبّب خطأً واضحًا، لكنّه يُحوِّل رقم تحقّق موثوق إلى رقم عشوائي.

اطبع أوّلًا، حسِّن ثانيًا

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

في الخلاصة

  • خمسة استدعاءات، بترتيب واحد لا يتغيّر: zero_grad، الأمامي، الخسارة، backward، step. أيّ نقص يُنتج تدريبًا معطوبًا صامتًا.
  • train() وeval() يُبدّلان سلوك Dropout وBatchNorm؛ no_grad شيء آخر، وكلاهما لازم للتقييم السليم.
  • الإفراط في تعلّم عيّنة صغيرة قبل الإطلاق الكبير هو أسرع اختبار صحّة، وقد وردت الفكرة في دورة أساسيات التعلّم العميق.
  • قراءة خسارتَي التدريب والتحقّق تكشف فرط التخصيص وNaN ومعدّل التعلّم غير المناسب — والوحدة القادمة تُقدّم أدوات علاجها.

الوحدة التالية: المُحسِّنات وجدولة معدّل التعلّم، أهمّ رافعتَين على جودة التدريب.