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

الوحدة 3 — nn.Module: بناء النموذج وتنظيمه

بعد أن رأينا التنسور في الوحدة الأولى والاشتقاق التلقائي في الثانية، ننتقل إلى الطبقة التي تنظّم كلّ شيء: nn.Module. هذه هي البنية التي يعتمدها PyTorch لكلّ شبكة، من الانحدار اللوجستي إلى شبكة ResNet كاملة. سنبني في هذه الوحدة أوّل مُصنِّف حقيقيّ لصور Fashion-MNIST.

لماذا نحتاج بنية بدل قائمة دوالّ

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

  • جمع كلّ الأوزان لتحديثها معًا في كلّ خطوة تدريب.
  • نقلها بين الأجهزة بسطر واحد model.to("cuda").
  • حفظها وتحميلها ذات النظام في ملفّ واحد.
  • التبديل بين وضعَي التدريب والاستدلال — سنراه في الوحدة الخامسة.

nn.Module يُقدّم كلّ ذلك آليًّا بشرط أن تُصرّح بطبقاتك الفرعية كسمَات على الصنف.

القالب الأدنى: __init__ وforward

كلّ صنف يرث من nn.Module يُعرِّف طريقتين:

import torch
import torch.nn as nn

class MLP(nn.Module):
def __init__(self, n_entrees: int, n_classes: int, largeur: int = 128):
super().__init__() # مطلوب دومًا
self.fc1 = nn.Linear(n_entrees, largeur)
self.fc2 = nn.Linear(largeur, largeur)
self.fc3 = nn.Linear(largeur, n_classes)

def forward(self, x: torch.Tensor) -> torch.Tensor:
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
return self.fc3(x) # نتائج خام، بلا سوفت‌ماكس

نقطتان لا تُخفَقان: نداء super().__init__() قبل أيّ شيء آخر، وتعريف كلّ طبقة كسمَة self.xxx. النداء الفعلي على النموذج (modele(x)) هو الذي يُشغّل forward، مضافةً إليه ميكانيك خطّاف التسجيل. لا تُنادِ forward مباشرةً، فذلك يعطّل خطّافات PyTorch ويُنتج أخطاءً دقيقة.

اكتشاف الأوزان تلقائيًا

بمجرّد التصريح، تُصبح كلّ الأوزان مرئية عبر parameters():

modele = MLP(n_entrees=28 * 28, n_classes=10)
for nom, p in modele.named_parameters():
print(nom, tuple(p.shape))
# fc1.weight (128, 784)
# fc1.bias (128,)
# fc2.weight (128, 128)
# fc2.bias (128,)
# fc3.weight (10, 128)
# fc3.bias (10,)

هذه اللائحة هي التي يستقبلها المُحسِّن في الوحدة السادسة: optim.Adam(modele.parameters(), lr=1e-3). لولا التسجيل التلقائي، لكان علينا تمريرها يدويًا. وأيّ تنسور نُنشئه دون تغليف في nn.Parameter لن يظهر هنا، ولن يتحرّك أثناء التدريب. هذا خطأ شائع عند بناء طبقات مخصّصة: self.w = torch.randn(10) لا يُسجَّل، بينما self.w = nn.Parameter(torch.randn(10)) نعم.

state_dict: الأوزان في قاموس بايثون

state_dict() تُعيد قاموسًا يربط أسماء الأوزان بتنسوراتها:

etat = modele.state_dict()
print(list(etat.keys())[:3])
# ['fc1.weight', 'fc1.bias', 'fc2.weight']

torch.save(etat, "modele_v1.pt")

# لاحقًا:
autre = MLP(n_entrees=784, n_classes=10)
autre.load_state_dict(torch.load("modele_v1.pt"))

هذا القاموس هو الوحدة الأساسية للحفظ في PyTorch. يحتوي الأوزان فقط، لا معمارية النموذج، ولذلك يجب امتلاك تعريف الصنف قبل تحميله. سنعود إلى هذا القرار في الوحدة الثامنة عند نقاط الحفظ الكاملة، وفي العاشرة عند التصدير المستقلّ.

nn.Sequential: القالب المضغوط للحالات البسيطة

عندما لا يحمل النموذج غير سلسلة طبقات، يوجد قالب أقصر:

modele_seq = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, 10),
)

هذا يكافئ الصنف السابق تقريبًا، مع فارقين. الأوّل تجميلي: أسماء الأوزان تُصبح 0.weight، 2.weight، 4.weight، وهي أقلّ وضوحًا في السجلّات. والثاني بنيوي: nn.Sequential لا يقبل أيّ تفرّع، ولا شرط if، ولا مسار جانبيّ. متى احتجت أن تُغيّر مسار البيانات بشكل مشروط، ارجع إلى الصنف المخصّص.

متى أختار الواحد على الآخر

الحالةالخيار المستحسن
سلسلة طبقات بلا تفرّعnn.Sequential
اتّصال متبقٍّ أو مسار جانبيّصنف يرث من nn.Module
مُدخَلات متعدّدة (صورة + نصّ)صنف مخصّص، بواجهة forward(img, txt)
بلوك يُعاد استعمالهصنف تُغلّفه في nn.Sequential أعلى

في مشاريع حقيقية، كثيرًا ما يمزج المهندسون: بلوكات صغيرة بـnn.Sequential، مُدمَجة في نموذج رئيسي بصنف. لا يوجد حكم قطعيّ، لكن قاعدة عملية جيّدة: إن ظهر if في المسار، ارجع إلى الصنف.

أوّل نموذج على Fashion-MNIST

نُطبّق كلّ ما سبق ببناء الشبكة التي ستُصاحبنا حتى الوحدة الثامنة:

import torch
import torch.nn as nn

class ClassifieurMode(nn.Module):
"""مُصنِّف بسيط لصور Fashion-MNIST بـ10 أصناف."""

def __init__(self, largeur: int = 256, abandon: float = 0.2):
super().__init__()
self.aplatir = nn.Flatten()
self.bloc = nn.Sequential(
nn.Linear(28 * 28, largeur),
nn.ReLU(inplace=False),
nn.Dropout(abandon),
nn.Linear(largeur, largeur),
nn.ReLU(inplace=False),
nn.Dropout(abandon),
)
self.tete = nn.Linear(largeur, 10)

def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.aplatir(x) # (N, 1, 28, 28) -> (N, 784)
x = self.bloc(x)
return self.tete(x) # (N, 10) نتائج خام

لا تنشيط في الطبقة الأخيرة، كما شرحت الدورة السابقة: CrossEntropyLoss تنتظر النتائج الخام وتُطبِّق log_softmax بشكل مستقرّ عدديًا. إضافة Softmax هنا خطأ صامت يُضاعف تطبيق الدالّة ويُشوِّه التدرّجات.

تهيئة الأوزان: PyTorch يفعل الصواب افتراضيًا

الطبقات الشائعة (nn.Linear، nn.Conv2d) تأتي بتهيئة معقولة: تهيئة Kaiming موائمة لـReLU. في غالب مشاريعك لن تحتاج التدخّل. حين تحتاج تخصيصًا، اكتب دالّة صغيرة وطبّقها بـapply:

def initialiser(module: nn.Module):
if isinstance(module, nn.Linear):
nn.init.kaiming_normal_(module.weight, nonlinearity="relu")
if module.bias is not None:
nn.init.zeros_(module.bias)

modele = ClassifieurMode()
modele.apply(initialiser)

هذه العادة الدفاعية تفيد خصوصًا عندما تُعدّل نموذجًا مُدرَّبًا مسبقًا (الوحدة التاسعة)، أو حين تختبر تجارب تحتاج بذورًا صارمة.

نقل النموذج بين الأجهزة

سمة عمليّة لا تُقدَّر إلّا حين تُفقَد: to تنقل جميع الأوزان والحواف دفعةً واحدة:

appareil = "cuda" if torch.cuda.is_available() else "cpu"
modele = ClassifieurMode().to(appareil)

for lot_x, lot_y in charg_tr:
lot_x, lot_y = lot_x.to(appareil), lot_y.to(appareil)
sortie = modele(lot_x)

لاحظ أنّ البيانات كذلك تنتقل إلى الجهاز نفسه. عملية على تنسورين في جهازين مختلفين ترفع خطأً واضحًا. تفاصيل النقل ونفقاته الحقيقية في الوحدة السابعة.

طبقة مسجَّلة، وليست دالّةً منفصلة

حين تحتاج طبقة تُعيد استعمالها في forward، عرِّفها في __init__ واستدعِها في forward. تعريف nn.Linear(10, 10) داخل forward يُنشئ طبقة جديدة بأوزان عشوائية في كلّ استدعاء، فلا تتعلّم شيئًا مع بقاء الحلقة صامتة. الأثر: خسارة عالية عالقة، والنموذج يبدو معطوبًا دون تفسير.

اقرأ نموذجك مطبوعًا

print(modele) يعرض هيكل الشبكة كلَّه بأسماء الطبقات وأشكالها. اجعل هذه العادة أوّل خطوة بعد تعريف الصنف: تكتشف طبقةً منسيّة، أو Dropout مضاعفًا، في ثوانٍ. وسنستعمل هذه العادة في كلّ مشروع لاحقًا.

في الخلاصة

  • nn.Module يُغلّف الأوزان والسلوك؛ صرّح كلّ طبقة كسمَة self.xxx، ولا تنسَ super().__init__().
  • parameters() وstate_dict() تُتيحان تحديث الأوزان جماعيًا وحفظها؛ التنسور خارج nn.Parameter لا يُسجَّل ولا يتعلّم.
  • nn.Sequential مناسب للسلاسل البسيطة؛ الصنف المُخصَّص لازم متى ظهر تفرّع أو شرط في المسار.
  • لا تُضِف تنشيطًا نهائيًا قبل خسارة تنتظر النتائج الخام؛ ولا تُنشئ طبقةً داخل forward، فذلك خطأ صامت خطير.

الوحدة التالية: Dataset وDataLoader، الجسر بين البيانات على القرص ونموذجنا القابل للتدريب.