الوحدة 10 — SavedModel والنشر مع TensorFlow Serving
النموذج الذي لا يُقدَّم لم يُنتج أيّ قيمة. تغطّي هذه الوحدة القطعة الأخيرة: إخراج النموذج من الكرّاسة وجعله قابلًا للاستعلام، دون تكرار الخطأ الذي يُفشل نصف عمليّات وضع النماذج في الإنتاج.
صيغتان لاستعمالين
يُقدّم Keras صيغتَي إخراج، والخلط بينهما يكلّف وقتًا.
| الصيغة | الأمر | تحتوي على | الاستعمال |
|---|---|---|---|
.keras | modele.save("m.keras") | المعمارية والأوزان وحالة المُحسِّن | استئناف التدريب |
| SavedModel | modele.export("m/") | رسم محسَّن وتوقيعات | الخدمة في الإنتاج |
صيغة .keras أرشيف مخصّص لبايثون: يُتيح إعادة تحميل النموذ ج ومتابعة تدريب توقّف. أمّا SavedModel فدليل يحتوي رسمًا مُسلسَلًا، دون تبعيّة لبايثون: هذا ما يعرف قراءتَه كلٌّ من TensorFlow Serving وTensorFlow Lite ووقتُ تشغيل C++.
modele.save("modeles/classifieur.keras") # لاستئناف التدريب
modele.export("modeles/servi/1") # للإنتاج
الرقم 1 في النهاية ليس زخرفة: بل هو رقم الإصدار، وTensorFlow Serving يشترطه.
المعالجة المسبقة يجب أن تعيش داخل النموذج
هذه أهمّ نقطة في الوحدة. في الوحدة 8، كانت الصور تمرّ عبر preprocess_input قبل دخول الشبكة. فإن بقيت هذه الخطوة في سكربت تدريبك البايثوني، فهي غير موجودة في SavedModel المُصدَّر. تستقبل الخدمةُ عندئذٍ بكسلات خامًا وتُطبّق شبكةً تتوقّع قيمًا مُطبَّعة.
النتيجة هي أسوأ الحالات: لا خطأ، ولا تنبيه، وتنبّؤات متدهورة بأسلوب يصعب ربطُه بسببه. هذه هي الفجوة بين التدريب والخدمة، وتظهر دائمًا تقريبًا في المعالجة المسبقة.
الحلّ يقوم على دمج المعالجة المسبقة على شكل طبقات من النموذج:
from tensorflow import keras
from tensorflow.keras import layers
entree = keras.Input(shape=(None, None, 3), dtype="uint8", name="image")
x = layers.Resizing(224, 224)(entree)
x = layers.Rescaling(1.0 / 127.5, offset=-1.0)(x)
x = base(x, training=False)
x = layers.GlobalAveragePooling2D()(x)
sortie = layers.Dense(nb_classes, activation="softmax")(x)
modele_servi = keras.Model(entree, sortie)
modele_servi.export("modeles/servi/1")
يقبل النموذج الآن صورًا بأيّ حجم بأعداد صحيحة غير موقّعة، ويتكفّل بنفسه بإعادة القياس والتطبيع. لم يعد على العميل أن يعرف شيئًا عن الاصطلاحات الداخلية. وتتّبع طبقات Normalization وTextVectorization وStringLookup المنطقَ نفسه للبيانات الجدولية والنصّية: تتكيّف مع البيانات بـadapt أثناء التدريب، ثمّ تحمل إحصائيّاتها في التصدير.
كلّ مرّة يوجد فيها تحويل داخل سكربت التدريب لكنّه غائب عن المصنوع المُصدَّر، يُصبح انحرافًا. الاختبار الحاسم في جملة واحدة: هل يقبل SavedModel تحديدًا ما سيرسله العميل؟ إن كان الجواب يفترض خطوةً سابقة، فتلك الخطوة يجب أن تلتحق بالنموذج أو تُوسَم بإصدار معه.
افحص قبل النشر
يُفحص SavedModel دون كتابة سطر واحد من بايثون، ويأخذ هذا الفحص عشر ثوانٍ.
saved_model_cli show --dir modeles/servi/1 --tag_set serve \
--signature_def serving_default
المُخرَج يصف المدخلات والمخرجات المتوقَّعة:
inputs['image'] tensor_info:
dtype: DT_UINT8
shape: (-1, -1, -1, 3)
outputs['output_0'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
-1 يقابل الأبعاد الحرّة، ومنها بُعد الحزمة. فحصان: نوع المدخل يجب أن يوافق ما سيُنتجه العميل، وشكل المخرج يجب أن يوافق عدد الأصناف. shape: (-1, 1) حيث كنت تتوقّع عشرة أصناف يُشير إلى رأس بأبعاد سيّئة، ومن الأفضل اكتشاف ذلك هنا لا بعد النشر.
خدمة النموذج
يُشغَّل TensorFlow Serving ف ي حاوية ويكشف واجهتَين.
docker run -p 8501:8501 \
--mount type=bind,source=$(pwd)/modeles/servi,target=/models/classifieur \
-e MODEL_NAME=classifieur \
tensorflow/serving
الدليل المركَّب هو الأب للإصدارات لا الإصدار نفسه. يبحث Serving فيه عن الدلائل الفرعيّة الرقميّة ويُحمّل الأعلى رقمًا.
يُستعلَم بعدها عبر HTTP:
curl -X POST http://localhost:8501/v1/models/classifieur:predict \
-d '{"instances": [[[[12, 34, 56], [78, 90, 12]]]]}'
الواجهة REST على المنفذ 8501 عمليّة وسهلة القراءة. أمّا واجهة gRPC على المنفذ 8500 فأسرع بكثير، لأنّها تتفادى ترميز التنسورات إلى JSON — وهو مكلف حين تصير المدخلات ضخمة. ولخدمة صور بمرور كثيف، ليست gRPC ترفًا بل ضرورة.
إدارة الإصدارات مجّانية، فاستعملها
بنية الدلائل تكفي لإدارة الإصدارات:
modeles/servi/
1/ saved_model.pb variables/
2/ saved_model.pb variables/
إيداع دليل 2 يكفي: يكتشفه Serving، ويُحمّله، ويُحوِّل حركة الاستعلام إليه، ويُفرغ السابق، بلا انقطاع. والعودة إلى الوراء تتمّ بإزالة الدليل المعطوب.
وسياسة الخدمة تسمح بأبعد من ذلك، بالإبقاء على إصدارَين نشطَين في الآن نفسه لمقارنة إجاباتهما على حركة حقيقيّة قبل التحويل. هذا هو أساس النشر التدريجي، الذي تعالجه وحدة وضع النماذج في الإنتاج من الدورة 20.
تحقّق بعد النشر لا قبله فقط
نقطة تحقّق أخيرة تُجنّبك المفاجآت السيّئة:
import numpy as np, requests
lot = images_test[:8]
attendu = modele_servi.predict(lot)
reponse = requests.post(
"http://localhost:8501/v1/models/classifieur:predict",
json={"instances": lot.tolist()},
).json()
obtenu = np.array(reponse["predictions"])
print("الفارق الأقصى:", np.abs(attendu - obtenu).max())
يجب أن يبقى الفارق على مستوى الضجيج العددي، من رتبة . فارق أكبر يكشف انحراف معالجة مسبقة، أو إصدارًا مُحمَّلًا مغايرًا للمنتظَر، أو تحويل نوع ضاع في الطريق. هذا الاختبار يقع في عشرة أسطر ويكشف تقريبًا كلّ أخطاء التقديم.