الوحدة 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 مرّةً واحدة لتحسب .
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()) # لا نريد تحديث المولّد من هنا