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

الوحدة 1 — التنسورات والمتغيّرات ورسوم الحساب

شرحت الدورة 07 ما تحسبه الشبكة العصبية. وتشرح هذه الدورة بأيّ شيء تُبنى تلك الشبكة. ويبدأ كلّ شيء بكائنَين يسهل الخلط بينهما، مع أنّ دوريهما متعاكسان تمامًا.

التنسور لا يتغيّر أبدًا، والمتغيّر مُصمَّم لذلك

كائن tf.Tensor غير قابل للتعديل. فأيّ عملية تُطبَّق عليه تُنتج تنسورًا جديدًا ولا تُبدّل الأصل.

import tensorflow as tf

x = tf.constant([[1.0, 2.0], [3.0, 4.0]])
y = x * 2 # تنسور جديد، وx يبقى سليمًا
print(x.shape) # (2, 2)
print(x.dtype) # <dtype: 'float32'>

أمّا tf.Variable فهو قابل للتعديل، وهذا بالضبط ما يجعله الحامل الطبيعي لأوزان النموذج: يجب أن يستطيع التدريب أن يكتب فوقها آلاف المرّات دون إنشاء كائن جديد في كلّ مرّة.

w = tf.Variable([[0.5, -0.2]])
w.assign([[0.7, -0.1]]) # يستبدل المحتوى
w.assign_sub([[0.01, 0.01]]) # يطرح في المكان

القاعدة الواجب حفظها: البيانات تنسورات، والمعاملات المتعلَّمة متغيّرات. فإسناد ساذج من نوع w = w - 0.01 * gradient على متغيّر يستبدله بتنسور عادي، ويُخرجه بصمت من قائمة المعاملات القابلة للتدريب في النموذج. ويستمرّ التدريب دون أيّ خطأ، لكنّ هذا الوزن لا يتحرّك بعد ذلك أبدًا.

ثلاث خصائص تحكم كلّ ما تبقى

يُوصَف التنسور بـشكله، ونوعه، وجهازه.

الخاصّةما تحملهالخطأ النموذجي
shapeالأبعاد، مع None لأيّ حجم حرّنسيان بُعد الحزمة في المقدّمة
dtypefloat32 افتراضيًا، وfloat16 في الدقّة المختلطةخلط int32 وfloat32 في عملية واحدة
deviceالمعالج المركزي أو المسرّعنسخ ضمنية تلتهم زمن الحساب

بُعد المقدّمة هو في الغالب بُعد الحزمة. فحزمة من 32 صورة بأبعاد 224 على 224 بيكسل وثلاث قنوات لها الشكل (32, 224, 224, 3). وحين يعرض Keras (None, 224, 224, 3)، فإنّ None تعني «أيّ حجم للحزمة»، وهو ما يسمح بالتدريب بحزم من 32 والتوقّع على صورة واحدة.

تحويل النوع لا يجري تلقائيًا

يرفض TensorFlow جمع int32 مع float32. وعلى خلاف NumPy لا يُرقّي الأنواع في صمت: بل يرفع استثناءً. وهذا اختيار مقصود، لأنّ الترقية الضمنية على مسرّع تكلّف كثيرًا ولا تُلاحَظ إلّا نادرًا. استعمل tf.cast(x, tf.float32) صراحةً.

التنفيذ الفوري في مقابل النمط الرسومي

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

أمّا النمط الرسومي فهو النظام الآخر. يُحلّل TensorFlow جسم الدالّة، ويبني منها تمثيلًا على هيئة رسم من العمليّات، ثمّ يُحسّنه: دمج العمليّات المتجاورة، وإسقاط الحسابات التي لا فائدة منها، والموازاة. النتيجة أسرع، والأهمّ أنّها قابلة للتصدير — إذ يُسلسَل الرسم ويُنشَر في الإنتاج دون مفسّر بايثون، وهو موضوع الوحدة 10.

الانتقال من نظام إلى الآخر يكفيه مُزخرِف واحد:

@tf.function
def step(x, w):
return tf.matmul(x, w)

ما يفعله tf.function حقًّا

هنا النقطة التي تسكت عنها معظم الشروح، وتفسّر أغلب السلوكيّات المُحيّرة.

عند الاستدعاء الأوّل يُنفِّذ tf.function جسم بايثون ليرى ما استُدعي من عمليّات TensorFlow، ويستنبط منها الرسم. هذه هي مرحلة التتبّع. وفي الاستدعاءات التالية، لم يعد جسم بايثون يُنفَّذ: بل يعمل الرسم وحده.

تنجم عن هذا ثلاث نتائج مباشرة:

@tf.function
def chatty(x):
print("تتبّع بايثون") # مرّة واحدة فقط، عند التتبّع
tf.print("تنفيذ رسم الحساب") # في كلّ استدعاء
return x * 2

chatty(tf.constant(1.0)) # يعرض السطرين
chatty(tf.constant(2.0)) # يعرض الثاني فقط

أوّلًا، print البايثوني لا يظهر إلّا في التتبّع، بينما يصير tf.print عمليّة داخل الرسم وتُنفَّذ دائمًا. ثانيًا، عدّاد بايثوني يُزاد داخل الجسم يظلّ متجمّدًا على قيمة لحظة التتبّع. ثالثًا، حلقة for بايثونية على عدد ثابت من التكرارات تُفرَد داخل الرسم، وهو ما قد يُنتج رسمًا ضخمًا؛ في المقابل تبقى tf.while_loop حلقة فعلية.

المُنزَلَق الثاني هو إعادة التتبّع. يبني TensorFlow رسمًا لكلّ توقيع مُدخَل. فاستدعاء الدالّة بأشكال تتغيّر في كلّ مرّة يُطلق تتبّعًا في كلّ استدعاء، ويصير الشغل أبطأ من التنفيذ الفوري.

@tf.function(input_signature=[tf.TensorSpec([None, 10], tf.float32)])
def stable(x):
return tf.reduce_sum(x, axis=1)

تثبيت توقيع مع None على بُعد الحزمة يفرض رسمًا وحيدًا صالحًا لجميع أحجام الحزمة.

GradientTape يُسجّل ليتمكّن من الاشتقاق

النمط الرسومي وحده لا يكفي لحساب المشتقّات: لا بدّ من معرفة العمليّات التي طُبِّقت وبأيّ ترتيب. وهذا دور tf.GradientTape الذي يُسجّل العمليّات المتعلّقة بالمتغيّرات المُراقَبة.

w = tf.Variable(3.0)

with tf.GradientTape() as tape:
loss = w ** 2

gradient = tape.gradient(loss, w) # 2 * w = 6.0

يُستنفَد الشريط عند أوّل استدعاء للدالّة gradient لتحرير الذاكرة. ولأخذ مشتقَّتَين من الشريط نفسه لا بدّ من persistent=True، مع حذفه صراحةً بعد الاستعمال. أمّا tf.Variable فتُراقَب تلقائيًا، بينما يحتاج التنسور العادي إلى tape.watch(x) صريح.

هذه هي الميكانيكية التي يستعملها model.fit تحت الغطاء، والتي نستأنفها يدويًا في الوحدة 4 حين تعجز الحلقة القياسية.

في الخلاصة

  • التنسور غير قابل للتعديل، والمتغيّر قابل له: البيانات تنسورات، والأوزان المتعلَّمة متغيّرات، وإسناد قيمة جديدة بالطريقة البايثونية إلى متغيّر يُخرجه من المعاملات القابلة للتدريب.
  • الشكل والنوع والجهاز تصف التنسور؛ وبُعد المقدّمة هو بُعد الحزمة، وNone فيه تعني «حجم حزمة حرّ».
  • tf.function يُتتبَّع جسم بايثون مرّة واحدة ليستنبط منه رسمًا، ثمّ لا يُنفَّذ الجسم بعدها: الآثار الجانبية البايثونية لا تحصل إلّا عند التتبّع، وتوقيع مُدخَل غير مستقرّ يُطلق تتبّعًا باهظًا.
  • tf.GradientTape يُسجّل العمليّات ليُتيح الاشتقاق؛ يُستهلَك عند أوّل استدعاء ما لم يُعلَن دائمًا.

الوحدة التالية: الواجهة التتابعية، التي تبني نموذجًا كاملًا في أسطر قليلة دون أن نلمس شريطًا واحدًا.