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

الوحدة 2 — Autograd: الرسم الديناميكي وحساب التدرّج

في الدورة السابقة كتبنا الانتشار الخلفي بأيدينا لنفهم من أين تأتي صيغته. في هذه الدورة سنترك الحساب لآلة اسمها Autograd، لكنّنا لن نعتبرها صندوقًا أسود: نعرف ما تفعله، ولماذا، ومتى تكسر بصمت.

requires_grad: العلَم الذي يشغّل التسجيل

يبدأ كلّ شيء بمُتغيّر أوّليّ يُفعَّل عليه العلَم:

import torch

w = torch.tensor(3.0, requires_grad=True)
x = torch.tensor(2.0) # لا نطلب تدرّجًا هنا
y = w * x + 1 # y مسجَّل تلقائيًا
print(y.grad_fn) # <AddBackward0>

بمجرّد أن يُشارك تنسور مطلوب التدرّج في عملية، تُنتَج عقدة في رسم حسابي داخلي تحتفظ بمرجع الدالّة العكسية. عندما نطلب لاحقًا التدرّج، ستنعكس هذه العقد من y إلى w مرّةً واحدة لتحسب y/w\partial y / \partial w.

backward: مسار عكسي في تمريرة واحدة

الاستدعاء الشهير y.backward() يُطلق الرحلة العكسية على الرسم الذي بُني في الرحلة الأمامية. يُخزَّن التدرّج في السمة .grad للأوراق فقط — أي التنسورات التي أنشأناها بأنفسنا مع requires_grad=True، لا التنسورات الوسيطة:

w = torch.tensor(3.0, requires_grad=True)
y = w ** 2 + 2 * w + 1 # y = (w+1)^2
y.backward()
print(w.grad) # 2*w + 2 = 8.0

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

الرسم الديناميكي: يُبنى في كلّ خطوة من جديد

نقطة الفارق الجوهرية بين PyTorch وTensorFlow في وقت مبكّر: الرسم هنا ديناميكي. لا يوجد إعلان مسبق للبنية؛ الرسم يُبنى أثناء تنفيذ الرحلة الأمامية، ويُهدَم بعد الرحلة العكسية.

def anti_forward(x, profondeur):
for _ in range(profondeur):
x = torch.tanh(x) # طول الرسم يعتمد على profondeur
return x

هذا الأسلوب يسمح بشرط if أو بحلقة for طولها متغيّر داخل الرحلة الأمامية، ما يُبسّط الشبكات المتسلسلة والمعقّدة بشكل ملحوظ. مقابل ذلك يدفع PyTorch ثمن بناء الرسم مرّةً في كلّ خطوة، وهو ثمن معقول جدًّا في التدريب، ويُخفَّف بـtorch.compile كما سنرى في الوحدة السابعة.

التدرّجات تتراكم — وهي ميزة، ليست علّة

ثاني مصدر للحيرة عند القدوم من TensorFlow: w.grad يجمع التدرّجات من كلّ استدعاء عكسي، لا يستبدلها.

w = torch.tensor(1.0, requires_grad=True)

(w ** 2).backward()
print(w.grad) # 2.0

(w ** 3).backward()
print(w.grad) # 2.0 + 3.0 = 5.0: تراكم!

المسوّغ ليس اعتباطيًا: هذا التصميم يجعل تراكم التدرّجات عبر عدّة حزم صغيرة (لمحاكاة حزمة كبيرة على GPU محدود الذاكرة) طبيعيًّا وبلا تحايل. لكنّه يعني أنّ كلّ حلقة تدريب صحيحة تبدأ باستدعاء optimiseur.zero_grad()، وإلّا يُخلَط تدرّج الحزمة الحالية بتدرّج الحزمة السابقة، والنتيجة صعود متسارع نحو NaN دون خطأ ظاهر.

for lot_x, lot_y in charg_tr:
optimiseur.zero_grad() # أوّل ما تفعله في كلّ تكرار
sortie = modele(lot_x)
perte = critere(sortie, lot_y)
perte.backward()
optimiseur.step()

torch.no_grad: نطفئ الرسم عندما لا نحتاج تدرّجًا

الرسم الحسابي يُستهلك ذاكرة. أثناء التقييم على مجموعة التحقّق أو أثناء الاستدلال في الإنتاج، لا نحتاج إلى تدرّج. تفعيل الرسم عندئذ إسراف واضح: ذاكرة ضائعة، وحساب ضائع. الحلّ سياق torch.no_grad:

modele.eval()
with torch.no_grad():
for lot_x, lot_y in charg_val:
sorties = modele(lot_x)
# لا رسم، لا سمة grad_fn، ذاكرة أقل بكثير
exactitude = (sorties.argmax(1) == lot_y).float().mean()

القاعدة: كلّ تقييم يجري داخل torch.no_grad. النسيان لا يُنتج نتائج خاطئة، لكنه يُهدر ذاكرةً بحجم ما تحتاجه الرحلة العكسية، ويجعل حزمة التحقّق الكبيرة تنفجر بخطأ نفاد الذاكرة في اللحظة الأسوأ. لاحظ أنّنا استعملنا model.eval() كذلك: سنعود إلى الفرق في الوحدة الخامسة.

detach: نفصل من الرسم بلا نسخ

في بعض الأحيان نحتاج قيمةَ تنسورٍ مسجَّل دون أن يستمرّ التسجيل. كأن نطبع خسارة، أو نُحصي عدد الأخطاء، أو نحفظ في سجلّ:

perte_tr = 0.0
for lot_x, lot_y in charg_tr:
optimiseur.zero_grad()
perte = critere(modele(lot_x), lot_y)
perte.backward()
optimiseur.step()
perte_tr += perte.item() # .item() على قيمة سُلَّمية
# perte.detach() لو أردنا الاحتفاظ بالتنسور دون رسم

.item() تُخرج قيمة قياسية بايثونية بلا رسم، و.detach() تُخرج تنسورًا يشارك الذاكرة نفسها لكنّه خارج الرسم. النتيجة العملية: اجمعوا الخسائر بالأولى، وحوّلوا التنسورات إلى NumPy بالثانية عند الحاجة. من دونهما، يبقى الرسم متعلّقًا بالخسارة المتراكمة فيتضخّم عبر الحقب حتى ينفد المُتَاح.

غادرنا في هذا التنسور، لكن لا نريد تحديث المصدر

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

z = generateur(bruit)                 # يريد التدرّج
w = discriminateur(z.detach()) # لا نريد تحديث المولّد من هنا

بدون detach هنا، يتلقّى المولّد تدرّجًا من خسارة المُميِّز، والنتيجة أن نموذجين يتنازعان على وزن واحد. detach تفصل الجذر عن الفرع.

الرسم الديناميكي ضدّ الرسم الساكن: مقارنة صريحة

نقطةديناميكي (PyTorch)ساكن (TensorFlow v1)
متى يُبنى الرسمفي كلّ رحلة أماميةمرّةً قبل التدريب
بايثون في forwardمسموح: if، for، printمقيّد: يجب أن يُترجَم
تصحيح الأخطاءمباشر: كأنّ لا رسمصعب: الأخطاء وقت التنفيذ
التصدير للإنتاجيمرّ بـTorchScript أو ONNXمُتاح مباشرة
السرعة الخاممتقاربة الآن مع torch.compileتاريخيًا أسرع

TensorFlow 2 تبنّى الرسم الديناميكي كذلك، مع tf.function للتحويل الاختياري (رأينا هذا في دورة 08). العالمان تقاربا كثيرًا، والفارق اليوم أسلوب أكثر منه بنية.

عدة رحلات عكسية على الرسم نفسه

y.backward() تحذف الرسم بعد الاستعمال لتوفير الذاكرة. استدعاء y.backward() مرّةً ثانية يرفع خطأً واضحًا: «Trying to backward through the graph a second time». إن احتجت فعلاً إلى ذلك، مرّر retain_graph=True في الاستدعاء الأوّل. لكنّ الحالات المشروعة نادرة، وغالبًا ما يخفي هذا الخطأ حلقة تدريب بها تراكم غير مقصود.

اكتب ثم اقرأ ما كتبت

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

في الخلاصة

  • الرسم يُبنى ديناميكيًا أثناء الرحلة الأمامية بمجرّد تدخّل تنسور عليه requires_grad=True؛ ثم يُهدَم بعد backward.
  • التدرّجات تتراكم في .grad؛ لذلك تبدأ كلّ حلقة تدريب صحيحة بـoptimiseur.zero_grad()، وإلّا اختلطت الحزم واستشرى NaN.
  • كلّ تقييم داخل torch.no_grad: يوفّر ذاكرةً وحسابًا، ولا يُغيّر النتيجة الرقمية.
  • detach تفصل تنسورًا من الرسم دون نسخ ذاكرة؛ استعملها للطباعة والإحصاء وللحالات التي تُشغّل فرعًا دون تحديث جذره.

الوحدة التالية: نُغلّف كلّ ما رأيناه في nn.Module، البنية التي تُنظّم النموذج وتُهيّئه للحفظ والتحميل والنقل بين الأجهزة.