المشتقات التلقائية
درس 2 من 8
2المشتقات التلقائية
الصورة الكبيرةفي درس المشتقة حسبت الميل بمعادلة كتبتها بيدك. مع ملايين الأوزان هذا مستحيل. الاشتقاق التلقائي يسجّل كل عملية أثناء الحساب الأمامي، ثم يمشي على السجل للخلف بقاعدة السلسلة ويعطيك ميل كل وزن.
ماذا تشاهد في الرسم: الحساب يمرّ للأمام فيحفظ كل قيمة وسيطة، ثم يعود للخلف من الخسارة إلى w. في طريق العودة تُضرب المشتقات المحلية: 2 × (−3) × 1 × 3، فيخرج الميل −18 دون أن تكتب أي معادلة.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
كل حساب يجريه النموذج يُسجَّل مثل وصفة: «ضربت هذا في ذاك ثم جمعت». حين تريد أن تعرف أي مقدار يجب تعديله لتحسين النتيجة، يقرأ PyTorch الوصفة من الخلف إلى الأمام ويحسب أثر كل مقدار تلقائيًا. هكذا لا تحسب المشتقات بيدك أبدًا.
requires_grad=True يجعل الموتر يُتتبّع في رسم حسابي computational graph. loss.backward() يطبّق قاعدة السلسلة من الخسارة إلى الأوراق ويخزّن الميل في .grad. الميل يتراكم، فتصفّره بـzero_grad() كل خطوة، وtorch.no_grad() يوقف التتبّع في التقييم.
قبل أن تبدأ
📉المشتقة والميل
حسبت ميلًا بمعادلة كتبتها بيدك، وتعرف أن إشارته تقول في أي اتجاه تحرّك الوزن.
🧊الموتر
تعرف أن الموتر يمكن أن يتذكّر العمليات التي صنعته، وهذا بالضبط ما يستخدمه الاشتقاق التلقائي.
الفكرة الجوهرية
مع ملايين الأوزان لا يمكنك كتابة صيغة الميل بيدك. في هذا الدرس تحسب ميلًا واحدًا بقاعدة السلسلة، ثم تترك PyTorch يحسبه، ثم ترى لماذا تتراكم الميول، ثم متى يتوقّف التتبّع.
1قاعدة السلسلة بيدك
الحساب: y = w·x + 1، ثم loss = (y − 10)². كل خطوة أمامية لها مشتقة محلية بسيطة. الميل النهائي هو حاصل ضربها من الآخر إلى الأول.
w, x, target = 2.0, 3.0, 10.0
# forward, keeping every intermediate value
y = w * x + 1
diff = y - target
loss = diff ** 2
# backward, chain rule from the end to the start
dloss_ddiff = 2 * diff
ddiff_dy = 1.0
dy_dw = x
dloss_dw = dloss_ddiff * ddiff_dy * dy_dw
print(f"loss={loss} dloss/dw={dloss_dw}")
كل خطوة في الاتجاه الأمامي لها مشتقة محلية بسيطة. الميل النهائي هو حاصل ضربها كلها من النهاية للبداية: 2 × (7 − 10) × 1 × 3 = −18. الإشارة السالبة تقول: زِد w لتقلّ الخسارة، وهذا منطقي لأن y = 7 أقل من الهدف 10.
هذا كل ما يفعله الانتشار الخلفي: يضرب المشتقات المحلية على الطريق بين الخسارة والوزن. لا يكتب صيغة، ولا يحرّك الوزن ليقيس التغيّر.
2الرسم الحسابي وrequires_grad
عندما تضع requires_grad=True على وزن، تبدأ PyTorch بتسجيل كل عملية تلمسه. كل ناتج يحمل grad_fn يشير إلى العملية التي صنعته. انظر إلى الرسم: هذه أسماء grad_fn للنواتج الثلاثة، وتتبّع السلسلة من الخسارة يوصلك إلى الوزن.
هذا الكود يحتاج PyTorch على جهازك، فالناتج المعروض مسجَّل من تشغيل حقيقي. ثبّته بـpip install torch وشغّله بنفسك.
import torch
w = torch.tensor(2.0, requires_grad=True)
x = torch.tensor(3.0)
y = w * x + 1
loss = (y - 10) ** 2
loss.backward()
print("y =", y.item(), "loss =", loss.item())
print("dloss/dw =", w.grad.item())
print("by hand =", 2 * (y.item() - 10) * x.item())
requires_grad=True يقول «تتبّع كل ما يحدث لهذا الموتر». وloss.backward() يمشي للخلف ويضع الميل في w.grad. نفس الرقم −18، بلا أي معادلة كتبتها.
الأوزان داخل nn.Module عليها requires_grad تلقائيًا، والبيانات لا تحتاجه. والرسم يُبنى من جديد في كل تشغيل، لذلك تعمل الشروط والحلقات في كودك بلا مشكلة.
3الميول تتراكم
backward لا يستبدل .grad، بل يجمع عليه. هذا مفيد لتجميع دفعات صغيرة، ومدمّر إن نسيت التصفير. انظر إلى الرسم: نفس الخسارة ثلاث مرات، والميل يكبر في كل مرة.
نفس الخسارة ثلاث مرات بنفس الوزن. الميل الحقيقي في كل مرة هو −8.
import torch
w = torch.tensor(1.0, requires_grad=True)
for step in range(3):
loss = (w * 2 - 4) ** 2
loss.backward()
print(f"step {step}: grad = {w.grad.item()}")
w.grad.zero_()
loss = (w * 2 - 4) ** 2
loss.backward()
print("after zero_:", w.grad.item())
−8 ثم −16 ثم −24: كل backward أضاف −8 إلى ما سبق. لو كانت هذه حلقة تدريب، لكان كل تحديث مبنيًا على مجموع ميول كل الخطوات السابقة، فتنطلق الأوزان بعيدًا. بعد zero_() يعود الميل −8 الصحيح.
zero_grad بين backward وstep. عندها يرى step ميولًا صفرية، فلا تتحرّك الأوزان أبدًا. الترتيب الصحيح: zero_grad، ثم backward، ثم step.4إيقاف التتبّع: no_grad وdetach
في التقييم والتنبّؤ لا تحتاج ميلًا. torch.no_grad() يوقف بناء الرسم، فيوفّر ذاكرة ووقتًا كثيرين.
import torch
w = torch.tensor([1.0, -2.0], requires_grad=True)
x = torch.tensor([3.0, 4.0])
y = (w * x).sum()
print(type(y.grad_fn).__name__)
print(y.requires_grad)
with torch.no_grad():
z = (w * x).sum()
print(z.requires_grad)
print(w.detach().numpy())
y يعرف أنه جاء من عملية جمع، ويحتاج ميلًا. داخل torch.no_grad() لا يُبنى أي رسم، فـz موتر عادي. وdetach() يعطيك نسخة منفصلة عن الرسم، وهي الطريقة الصحيحة لتحويل وزن إلى NumPy لطباعته أو رسمه.
كيف يعمل، خطوة بخطوة: كيف يحسب PyTorch الميل بنفسه
تضع requires_grad=True على الوزن، فيبدأ PyTorch بتسجيل كل عملية تمسّه.
أثناء الحساب الأمامي يحمل كل ناتج grad_fn يشير إلى العملية التي صنعته، فيتكوّن الرسم الحسابي.
عند loss.backward() يمشي على الرسم من الخسارة للخلف ويضرب المشتقات المحلية بقاعدة السلسلة.
يضيف الميل إلى .grad لكل وزن ثم يحرّر الرسم، ولذلك تصفّر .grad قبل الخطوة التالية.
مثال محلول
قبل PyTorch، احسب ميلًا واحدًا بقاعدة السلسلة بيدك. الحساب: y = w·x + 1 ثم loss = (y − 10)². نريد dloss/dw.
total += loss بدل total += loss.item(). الأول يحتفظ بالرسم الحسابي لكل دفعة في الذاكرة، فتنفد الذاكرة بعد مئات الخطوات دون أن تعرف السبب.
1e-4 واحسب فرق الخسارة مقسومًا على 1e-4. إن قارب ما يعطيه backward، فالكود صحيح. PyTorch يوفّر هذا جاهزًا في torch.autograd.gradcheck.
جرّب بلا كود
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| الاشتقاق التلقائي يكتب معادلة المشتقة ثم يحسبها. | لا يكتب أي معادلة، ولا يحرّك الأوزان قليلًا ليقيس الفرق. يطبّق قاعدة السلسلة آليًا على العمليات التي نُفّذت فعلًا. |
| backward يستبدل الميل القديم بالجديد. | يجمع عليه. نفس الخسارة ثلاث مرات أعطت −8 ثم −16 ثم −24، ولا يعود −8 إلا بعد التصفير. |
| الشروط والحلقات في الكود تكسر الاشتقاق التلقائي. | الرسم يُبنى من جديد في كل تشغيل من العمليات التي نُفّذت، فيعمل مع الشروط والحلقات دون مشكلة. |
جرّب بنفسك
loss = (3 * w - 6) ** 2 عند w = 1. ما w.grad بعد loss.backward()؟
قاعدة السلسلة: 2 × (3·1 − 6) × 3 = 2 × (−3) × 3 = −18. المشتقة الخارجية 2(·)، والداخلية معامل w وهو 3.
شغّلت loss.backward() مرتين على نفس الخسارة فظهر خطأ «Trying to backward through the graph a second time». لماذا؟
بعد backward تُحذف القيم الوسيطة المحفوظة. في التدريب العادي هذا ما تريده لأنك تبني رسمًا جديدًا في كل خطوة. إن احتجت فعلًا لاستدعاءين، استخدم retain_graph=True، لكنه نادرًا ما يكون الحل الصحيح.
بطاقات مراجعة سريعة
requires_gradnn.Module مفعّلة تلقائيًا، والبيانات لا تحتاجه.backwardrequires_grad ويضيفه إلى خاصية .grad، ثم يحرّر الرسم الحسابي.backward يجمع على .grad ولا يستبدله. هذا مفيد لتجميع دفعات صغيرة، ومدمّر إن نسيت التصفير في كل خطوة.تحقق من فهمك
لماذا نلفّ حلقة التقييم بـtorch.no_grad() رغم أن النتيجة صحيحة بدونها؟
لأن بدونه يبني PyTorch رسمًا حسابيًا لكل دفعة تقييم ويحتفظ بالقيم الوسيطة استعدادًا لـbackward لن يحدث أبدًا. هذا يضاعف استهلاك الذاكرة ويبطئ الحساب. النتيجة نفسها صحيحة، لكنك قد لا تستطيع تقييم دفعة كبيرة كانت ستتّسع بدونه.
لماذا تستخدم w.detach().numpy() بدل w.numpy() لتحويل وزن إلى NumPy؟
لأن الوزن مرتبط بالرسم الحسابي ويتطلّب ميلًا. detach() يعطي نسخة مقطوعة عن الرسم، وهي الطريقة الصحيحة للطباعة أو الرسم.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.