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

الوحدة 4 — الطبقات المخصّصة والاشتقاق من Model

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

طبقة واحدة وثلاث دوال

كلّ طبقة مخصّصة ترث keras.layers.Layer وتنقسم على ثلاث دوال ذات أدوار منفصلة تمامًا.

from tensorflow import keras
import tensorflow as tf

class DenseWithScale(keras.layers.Layer):
def __init__(self, units, **kwargs):
super().__init__(**kwargs)
self.units = units # وسائط فائقة فقط

def build(self, input_shape):
self.kernel = self.add_weight(
shape=(input_shape[-1], self.units),
initializer="glorot_uniform",
trainable=True,
name="kernel",
)
self.scale = self.add_weight(
shape=(), initializer="ones", trainable=True, name="scale",
)

def call(self, inputs):
return tf.matmul(inputs, self.kernel) * self.scale

تتلقّى __init__ الوسائط الفائقة ولا شيء غيرها. وتُنشئ build الأوزان، وتُستدعى مرّة واحدة عند أوّل مرور بيانات. وتصف call الحساب.

سبب وجود build بسيط: في لحظة __init__ يكون بُعد المدخل مجهولًا. أمّا build فتتلقّاه عبر input_shape، وهذا ما يسمح بكتابة Dense(64) دون تحديد عدد المداخل الوارد. إنشاء الأوزان داخل __init__ يعمل إن رمّزت البُعد ثابتًا في المصدر، لكنّك تخسر هذه المرونة وتصير الطبقة غير قابلة للاستعمال في مكان آخر.

add_weight لا tf.Variable

tf.Variable مُصرَّح مباشرةً داخل الطبقة لا يُتابَع بصورة موثوقة: قد لا يظهر في layer.trainable_weights، ولا يُحفَظ، ولا يتلقّى تدرّجًا. أمّا add_weight فيُسجّله لدى Keras. عرض الخلل هنا هو وزن لا يتحرّك أبدًا، دون أيّ رسالة.

لا بدّ من تمرير طور التدريب

بعض الطبقات تتصرّف تصرّفًا مختلفًا بحسب ما إذا كنّا نُدرّب أو نتوقّع. لا بدّ أن تتلقّى المعلومة وأن تُمرّرها.

class RegularizedBlock(keras.layers.Layer):
def __init__(self, units, rate=0.3, **kwargs):
super().__init__(**kwargs)
self.dense = keras.layers.Dense(units, activation="relu")
self.dropout = keras.layers.Dropout(rate)

def call(self, inputs, training=None):
h = self.dense(inputs)
return self.dropout(h, training=training)

إسقاط training=training عند استدعاء Dropout يُنتج علّةً مُرعبة: يبقى الإسقاط فعّالًا أثناء التقييم والتوقّع. تصير نقاط المصادقة مضطربة ومتشائمة بصورة منهجية، وتتغيّر التوقّعات من استدعاء إلى آخر، ولا شيء يُشير إلى السبب. والمُنزَلَق نفسه يُصيب BatchNormalization التي يجب ألّا تُحدَّث إحصائيّاتها خارج التدريب.

التسلسل من أجل إعادة التحميل

حفظ نموذج يحوي طبقةً مخصّصة لا يكفي: لا بدّ أن يعرف Keras كيف يُعيد بناء تلك الطبقة عند إعادة التحميل.

    def get_config(self):
config = super().get_config()
config.update({"units": self.units, "rate": self.rate})
return config

تُعيد get_config وسائط __init__ على شكل قاموس. بدونها يفشل keras.models.load_model بخطأ كائن مجهول. ومعها، ومع مُزخرِف التسجيل، تصير إعادة التحميل شفّافة:

@keras.saving.register_keras_serializable(package="inskillml")
class RegularizedBlock(keras.layers.Layer):
...

تفصيل إداريّ، لكنّه هو الذي يقرّر ما إذا كان نموذجك قابلًا للنشر أم لا — وهو موضوع الوحدة 10.

اشتقاق Model حين يكون التدفّق ديناميكيًا

اشتقاق keras.Model يُنقل تعريف المعمارية إلى شفرة بايثون آمرة.

class AdaptiveClassifier(keras.Model):
def __init__(self, num_classes, **kwargs):
super().__init__(**kwargs)
self.trunk = keras.layers.Dense(128, activation="relu")
self.head = keras.layers.Dense(num_classes, activation="softmax")

def call(self, inputs, training=None):
h = self.trunk(inputs)
if training:
h = tf.nn.dropout(h, rate=0.2)
return self.head(h)

المكسب هو الحرّية الكاملة: شروط، وحلقات يعتمد عدد تكراراتها على البيانات، واستدعاءات تعاودية. أمّا الكلفة فحقيقية وكثيرًا ما تُستهان بها. الرسم لا يوجد قبل أوّل دفعة بيانات، ومن ثمّ يبقى model.summary() صامتًا إلى ذلك الحين، ولا تظهر أخطاء الأشكال إلّا عند التنفيذ، ولا يُسلسَل النموذج بالبساطة نفسها.

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

إعادة تعريف train_step بدل الحلقة كاملة

هنا أنفع نقطة في الوحدة. حين لا تناسب الحلقة القياسية، ردّ الفعل الشائع هو كتابة حلقة كاملة مع GradientTape، وفقدان الاستدعاءات وأشرطة التقدّم والتدريب الموزَّع والسجلّ التاريخي في الوقت نفسه. وهناك نقطة دخول أوفر بكثير.

class CustomLossModel(keras.Model):
def train_step(self, data):
x, y = data

with tf.GradientTape() as tape:
y_pred = self(x, training=True)
loss = self.compute_loss(x=x, y=y, y_pred=y_pred)

gradients = tape.gradient(loss, self.trainable_variables)
self.optimizer.apply_gradients(zip(gradients, self.trainable_variables))

for metric in self.metrics:
if metric.name != "loss":
metric.update_state(y, y_pred)
return {m.name: m.result() for m in self.metrics}

تتلقّى train_step دفعةً وتُعيد قاموسًا من المقاييس. وكلّ بقيّة بنية Keras تستمرّ في العمل: fit، واستدعاءات الوحدة 6، وTensorBoard الوحدة 7، وتوزيع الوحدة 9. هذا هو المستوى الصحيح للتدخّل حين نُريد قصّ تدرّجات، أو تدريبًا خصوميًا، أو تراكم تدرّجات على عدّة دفعات، أو خسارةً تعتمد على شيء غير الزوج مدخل-مخرج وحده.

training=True عند استدعاء self(x, ...) ليس اختياريًا: هو ما يُفعِّل الإسقاط وتحديث إحصائيّات التطبيع.

في الخلاصة

  • تفصل الطبقة المخصّصة الوسائط الفائقة في __init__، والأوزان في build، والحساب في call؛ وتتلقّى build شكل المدخل، وهذا ما يجعل الطبقة قابلة لإعادة الاستعمال.
  • تُنشَأ الأوزان بـ**add_weight** لا بـtf.Variable، وإلّا هربت من متابعة Keras ولم تتدرّب أبدًا.
  • تُمرَّر training=training إلى الطبقات الفرعية؛ ونسيان ذلك يترك الإسقاط فعّالًا عند التوقّع، بنقاط متشائمة ودون أيّ رسالة خطأ.
  • لتعديل التدريب تُعاد كتابة train_step بدل كتابة حلقة كاملة: فنحفظ fit والاستدعاءات وTensorBoard والتوزيع.

الوحدة التالية: خطوط tf.data، لأنّ نموذجًا سريعًا تُغذّيه ببطء يبقى بطيئًا.