الوحدة 1 — تنسورات PyTorch والتوافق مع NumPy
بعد دورة أساسيات التعلّم العميق، تبدأ هذه الدورة من الطابوقة الأولى في PyTorch: التنسور. سنبني في هذه الوحدة تنسورات لصور Fashion-MNIST، سنعبر بها بين NumPy وPyTorch دون نسخ غير مقصود، وسنكتشف الحفرة التي تبتلع أكثر ما تبتلع المبتدئين: العمليات في المكان مع الاشتقاق التلقائي.
ما هو التنسور، ولماذا يختلف عن مصفوفة NumPy
torch.Tensor مصفوفة متعدّدة الأبعاد كـnumpy.ndarray، مع فارقين
حاسمين. الأوّل: التنسور يعرف جهازه — وحدة معالجة مركزية أو مسرِّع
GPU — ويحمل بياناته في ذاكرة هذا الجهاز. والثاني: كلّ تنسور قد يكون
عقدةً في رسم حسابي يتذكّر العمليات التي أنتجته لأجل الاشتقاق
التلقائي. سيأتي هذا الجانب في الوحدة الثانية؛ أمّا هنا فسنركّز على
الميكانيك وحده.
import torch
# صورة Fashion-MNIST مبسّطة، 28 في 28 بكسل بقيم عشرية.
x = torch.zeros(28, 28)
print(x.shape) # torch.Size([28, 28])
print(x.dtype) # torch.float32
print(x.device) # cpu
ثلاث خصائص تصف أيّ تنسور: الشكل shape والنو ع dtype والجهاز
device. الشكل يفرض ما يُسمَح به من عمليات؛ والنوع يحدّد الدقّة والاستهلاك
في الذاكرة؛ والجهاز يقرّر أين يجري الحساب. ثلاثتها معًا تُحدّد ما إذا
كانت العملية القادمة ستنجح.
بناء التنسورات: طرق قليلة تكفي في التسعين بالمئة من الحالات
خمس دوالّ تكفي عادةً لبناء ما تحتاجه في مشروع تدريب كامل. ننصح بحفظها عن ظهر قلب:
| الدالّة | ما تفعله | ملاحظة |
|---|---|---|
torch.tensor(data) | تنسور من قائمة أو مصفوفة NumPy، مع نسخ | تُستنتَج dtype من المُدخَل |
torch.zeros(shape) | تنسور من أصفار | اذكر dtype صراحةً إن اختلف عن float32 |
torch.ones(shape) | تنسور من الآحاد | مفيد للتغطية الأولى في بعض الاختبارات |
torch.arange(a, b, step) | متتالية عددية، كـrange | تُنتج int64 افتراضيًا |
torch.randn(shape) | توزيع طبيعي معياري | لبذر التجارب حدّد torch.manual_seed(42) |
يقبل معظم هذه الدوالّ الوسيطين dtype= وdevice=. اكتبهما عندما يفرض
السياق ذلك، لا تعتمد على الافتراض:
y = torch.zeros(64, 1, 28, 28, dtype=torch.float32, device="cpu")
الشكل هنا مألوف: حجم الدفعة، القنوات، الارتفاع، العرض. سنعتمد هذا
الترتيب في كامل الدورة لأنّ nn.Conv2d وسائر طبقات الرؤية تنتظره.
النوع مسألة قرارات، لا اعتباط
النوع الافتراضي في PyTorch هو torch.float32، وهو حلّ وسط مقبول: دقّة
كافية لتدريب النماذج، مع نصف استهلاك float64. ثلاثة أنواع تلتقي بها
في هذه الدورة:
torch.float32للأوزان والتنسورات العشرية عمومًا.torch.float16أوtorch.bfloat16في الوحدة السابعة عند تفعيل الدقّة المختلطة.torch.long(وهوint64) للأهداف في التصنيف المتعدّد الأصناف.
خلط الأنواع في عملية واحدة يوقف التنفيذ بخطأ صريح، خلافًا لسلوك NumPy
الذي يُحوّل ضمنيًا. حوّل صراحةً بـ.to(torch.float32) أو بـ.long()
عند الحاجة:
etiquettes = torch.tensor([9, 0, 3, 5]).long() # مطلوب لخسارة التصنيف
pixels = torch.randint(0, 256, (28, 28)).float() / 255.0
العروض ضدّ النسخ: القاعدة التي تحسم قصص كثيرة
بعض العمليات تنشئ تنسورًا جديدًا يشارك الذاكرة نفسها مع الأصل: هذا
عرض view. وبعضها الآخر ينسخ الذاكرة فعليًا. الفرق ضخم على
الأداء، وقد يكون أيضًا ضخمًا على الصواب:
a = torch.arange(12).view(3, 4) # عرض، بلا نسخ
b = a.t() # عرض مُبدَّل
c = a.reshape(2, 6) # عرض إن أمكن، وإلا نسخة صامتة
d = a.clone() # نسخة صريحة، مستقلّة تمامًا
a[0, 0] = 999
print(b[0, 0]) # 999: تأثّر بالعرض
print(d[0, 0]) # 0: نسخة مستقلّة
عندما يكون ترتيب البايتات غير متجاور — نتيجة permute أو transpose
— لا تعمل view وترفع خطأً. الحلّ إمّا استدعاء contiguous() قبلها،
أو استخدام reshape التي تُدبّر أمرها. اختر view عند الحاجة إلى
ضمان غياب النسخ، وreshape حين لا تهمّك التفاصيل.
البثّ: نفس قواعد NumPy تقريبًا
البثّ broadcasting يسمح بجمع تنسورات مختلفة الأشكال إذا كانت أبعادها
متوافقة عند الترصيف من اليمين إلى اليسار: إمّا متساوية، وإمّا أحدها
يساوي 1. على صور Fashion-MNIST، هذه الوسيلة الأشيع لإزالة المتوسّط
عن كلّ قناة:
lot = torch.randn(64, 1, 28, 28) # (N, C, H, W)
moyenne = lot.mean(dim=(0, 2, 3), keepdim=True) # (1, 1, 1, 1) في هذه الحالة
lot_centre = lot - moyenne # يعمل بالبثّ
قاعدة عملية: احرص دومًا على keepdim=True في العمليات التجميعية عندما
تنوي إعادة استخدام الناتج في البثّ. النسيان يُعطي شكلاً مضغوطًا ثم
يفشل الطرح بشكل غير مفهوم.
الجسر مع NumPy: مشاركة ذاكرة صامتة
from_numpy و.numpy() يشاركان الذاكرة بين المصفوفة والتنسور،
لا ينسخانها. هذه ميزة أداء ممتازة، وفخّ للمُبتدئ:
import numpy as np
x_np = np.zeros((3, 3), dtype=np.float32)
x_pt = torch.from_numpy(x_np)
x_np[0, 0] = 42.0
print(x_pt[0, 0]) # 42.0: تأثّرت لأنّ الذاكرة مشتركة
المشاركة مقتصرة على تنسورات وحدة المعالجة المركزية، ولا تعمل مع الأنواع
غير المدعومة (مثلًا float16 عبر NumPy القديم). عند الشكّ استخدم
torch.tensor(x_np) الذي ينسخ بشكل صريح، وبذلك تعزل الحالتين تمامًا.
العمليات في المكان والاشتقاق التلقائي: الفخّ الذي سيتكرّر
بعض دوالّ PyTorch تنتهي بشرطة سفلية، كـadd_ وmul_ وzero_،
وهذه تُعدّل التنسور في مكانه بدل إنتاج نسخة. توفّر ذاكرة ولا شكّ،
لكنّها تكسر الاشتقاق التلقائي في الوحدة القادمة. حين يعرف التنسور أنّه
يحتاج تدرّجًا (requires_grad=True)، فإنّ تعديله في المكان يُتلف الرسم
الحسابي:
w = torch.randn(4, requires_grad=True)
w = w + 0.1 # عملية جديدة تُنتج تنسورًا جديدًا: سليمة
# w += 0.1 # عملية في المكان: قد ترفع خطأً وقت الاستدعاء العكسي
لا يعني ذلك أنّ العمليات في المكان محرّمة، بل يعني أنّ استعمالها في
أثناء التدريب يطلب حذرًا. الاستثناء المقبول والمشهور هو .zero_()
على التدرّجات نفسها، سنُبرّره في الوحدة الثانية.
كلّ نقل بين وحدة المعالجة المركزية وGPU يمرّ عبر ناقل ذي عرض حزمة محدود مقارنة بذاكرة الجهاز نفسه. تنسور صغير يُنقل مرّةً واحدة لا يبدو مكلفًا، لكن حلقة تنقل كلّ دفعة ذهابًا وإيابًا يمكن أن تُهدر أكثر من نصف الوقت في النقل لا في الحساب. القاعدة: أعِد التنسورات إلى وحدة المعالجة المركزية للطباعة والقياس فقط، وابقِ الحساب على GPU لأطول قدر ممكن. سترى في الوحدة السابعة كيف نقيس هذا النقل.
التنسور الأوّل من مشروع الدورة
نُطبّق ما سبق ببناء دفعة بسيطة من Fashion-MNIST بأسلوب يُشبه ما سنراه في الوحدة الرابعة:
import torch
torch.manual_seed(42)
# صور 28x28 بقيم من 0 إلى 255، ثم تسوية إلى [0, 1]
brut = torch.randint(0, 256, (64, 1, 28, 28), dtype=torch.uint8)
images = brut.float() / 255.0
# أهداف من 10 أصناف
cibles = torch.randint(0, 10, (64,)).long()
print(images.shape, images.dtype, images.device) # torch.Size([64, 1, 28, 28]) torch.float32 cpu
print(cibles.shape, cibles.dtype) # torch.Size([64]) torch.int64
هذه الأشكال تحديدًا هي ما سيقبله النموذج الذي نبنيه في الوحدة الثالثة،
ولذلك اعتمدنا (N, C, H, W) منذ الآن. الاتّساق منذ البداية يُوفّر ساعات
من التصحيح لاحقًا.
قبل أيّ تدريب حقيقي، جرّب بناء تنسور مُدخل صغير وتشغيله في نموذج فارغ لرؤية الأشكال في كلّ طبقة. إن اختلّ شكل مبكرًا اكتشفته في ثوانٍ. سنُعمّم هذه الفكرة في الوحدة التاسعة تحت اسم «الإفراط في تعلّم عيّنة صغيرة»، وهي ذاتها التي ورَدت في الدورة السابقة.
في الخلاصة
- تنسور PyTorch يتميّز عن مصفوفة NumPy بـالجهاز والرسم الحسابي؛
ثلاث خصائص تصفه دومًا:
shapeوdtypeوdevice. - التمييز بين العرض والنسخة حاسم للأداء والصواب؛
viewتشترط ذاكرة متجاورة، وreshapeتُدبّر أمرها بنسخة صامتة عند الحاجة. from_numpyيشارك الذاكرة ولا ينسخها؛ استخدمtorch.tensorعند الرغبة في العزل التامّ بين المصفوفة والتنسور.- العمليات في المكان (
add_،mul_، …) تُوفّر ذاكرةً لكنّها تكسر الاشتقاق التلقائي؛ تجنّبها على تنسورات مُتّبعة إلّا في الحالات المشروحة.
الوحدة التالية: Autograd، الآلة التي تجعل من عصبون واحد إلى شبكة عميقة قابلاً للتعلّم دون كتابة سطر واحد من صيغ التدرّج يدويًا.