الوحدة 3 — التكميم بعد التدريب
النموذج المحوَّل من الوحدة 2 يزن 14 مِيغا، وميزانيّتنا 8. الحلّ الأوّل والأرخص هو التكميم بعد التدريب (Post-Training Quantization أو PTQ): تقليص دقّة الأوزان والتفعيلات من float32 إلى int8 أو float16، دون إعادة تدريب. النموذج نفسه لا يتغيّر تعليميًّا، لكنّ تمثيله الرقميّ يُختصر إلى ربعه، فيصغر الملفّ، ويسرع الاستدلال على العتاد الذي يعرف الحسابات على أعداد صحيحة (ما يشمل تقريبًا كلّ الهواتف الحديثة).
الأنواع الثلاثة للتكميم بعد التدريب
TFLite يعرض ثلاث استراتيجيّات تُختار حسب المفاضلة بين الحجم والزمن والدقّة والجهد.
تكميم المدى الديناميكيّ (Dynamic Range Quantization). الأوزان تُخزَّن بـint8، لكنّ التفعيلات تبقى بـfloat32 وتُحوَّل إلى int8 أثناء الاستدلال ثمّ ترجع إلى float32. الحجم يُختصر إلى الربع تقريبًا. الزمن يُحسَّن قليلًا على CPU (ضعف تقريبًا). لا حاجة لأيّ بيانات إضافيّة. هذا هو الحلّ الافتراضيّ: يعمل دومًا وبأقلّ جهد.
التكميم الكامل بـint8 مع جَمْع تمثيليّ (Full Integer Quantization). الأوزان والتفعيلات كلّها بـint8. يتطلّب هذا جَمْعًا تمثيليّا (representative dataset) من مئة إلى خمسمئة صورة يُشغَّل النموذج عليها لقياس مدى قيم كلّ تنسور، فتُحسَب معاملات التكميم لكلّ طبقة. الحجم إلى الربع، والزمن إلى النصف على CPU، إلى الثلث على معالج EdgeTPU أو NNAPI. يعمل على المسرِّعات العتاديّة التي لا تقبل إلاّ حسابات صحيحة. الجهد أكبر بقليل.
تكميم float16. الأوزان تُخزَّن بـfloat16، والتفعيلات تبقى بـfloat32. الحجم إلى النصف، لا الربع. الزمن يتحسَّن على GPU لأنّ GPU الهاتف يعرف float16 عتاديًّا. الدقّة شبه محفوظة. مفيد حين نستهدف GPU خصوصًا، وحين نخشى فقدان دقّة int8.
الاستراتيجيّة الأولى: تكميم المدى الديناميكيّ
نُطبِّق أبسط استراتيجيّة على النموذج نفسه:
import tensorflow as tf
model = tf.keras.models.load_model("plantvillage_mobilenetv2.keras")
converter = tf.lite.TFLiteConverter.from_keras_model(model)
# التكميم الديناميكيّ : الأوزان int8، التفعيلات float32
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_dynamic = converter.convert()
with open("plantvillage_dynamic.tflite", "wb") as f:
f.write(tflite_dynamic)
print(f"الحجم بعد التكميم الديناميكيّ : {len(tflite_dynamic) / 1024 / 1024:.2f} Mo")
النت يجة العمليّة على MobileNetV2: من 14 مِيغا إلى نحو 3.6 مِيغا. الدقّة لا تكاد تتحرَّك (خسارة أقلّ من نقطة مئويّة على PlantVillage). لكن الزمن على CPU لا ينخفض كثيرًا، وعلى GPU لا يتحسَّن أبدًا (GPU لا يستفيد من int8 هنا لأنّ التفعيلات تبقى float32).
الاستراتيجيّة الثانية: التكميم الكامل بـint8
هنا نحتاج جَمْعًا تمثيليّا. الفكرة: نُشغِّل النموذج على مئة صورة على الأقلّ من توزيعة الإنتاج (لا صور اصطناعيّة، ولا صور من مجموعة مختلفة تمامًا)، ونقيس المدى الحقيقيّ لقيم كلّ تفعيل.
import numpy as np
import tensorflow as tf
# 1) جَمْع تمثيليّ : 200 صورة من مجموعة التحقّق
# مُهمّ : من التوزيعة نفسها، لا صور اصطناعيّة
def representative_dataset():
calibration_ds = tf.keras.utils.image_dataset_from_directory(
"plantvillage/val",
image_size=(224, 224),
batch_size=1,
shuffle=True,
).take(200)
for image_batch, _ in calibration_ds:
# التطبيع المستعمَل في التدريب : [-1, 1]
image = tf.cast(image_batch, tf.float32)
image = (image / 127.5) - 1.0
yield [image]
# 2) تجهيز المحوّل للتكميم الكامل بـint8
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
# 3) فرض int8 صريحًا : كلّ العوامل يجب أن تُكمَّم، وإلاّ يُلقى خطأ
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8 # تدخل الصورة بـint8 مباشرة
converter.inference_output_type = tf.int8
tflite_int8 = converter.convert()
with open("plantvillage_int8.tflite", "wb") as f:
f.write(tflite_int8)
print(f"الحجم بعد التكميم الكامل int8 : {len(tflite_int8) / 1024 / 1024:.2f} Mo")
النتيجة على MobileNetV2: نحو 3.5 مِيغا (يشبه المدى الديناميكيّ)، لكن الزمن على CPU ينخفض بحوالي النصف، وعلى NNAPI (المسرِّع العتاديّ لـAndroid) إلى الثلث. هذا هو الخيار المستهدَف لنموذجنا.
الخيار inference_input_type = tf.int8 مهمّ: يقبل النموذج صورة بـint8 مباشرة، فلا حاجة لتحويلها إلى float32 قبل الاستدلال. هذا يوفّر معالجة مسبقة على الهاتف، ويبسِّط الكود.
إن أعطيت جَمْعًا تمثيليّا مشوَّهًا (مئة صورة سوداء، أو صور من صنف واحد فقط)، فمعاملات التكميم ستُبنى على مدى ضيّق، وأيّ صورة مختلفة أثناء الاستدلال ستُشبَّع أو تُصفَّى. الجَمْع التمثيليّ يجب أن يمثّل توزيعة الإنتاج الحقيقيّة، لا أن يُبسِّطها. مئتا صورة موزَّعة على الأصناف الـ38 أفضل من ألف صورة كلّها للصنف الأوّل.
الاستراتيجيّة الثالثة: float16
مفيدة عند استهداف GPU خصوصًا. تطبيقها الأبسط:
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.target_spec.supported_types = [tf.float16]
tflite_fp16 = converter.convert()
print(f"الحجم float16 : {len(tflite_fp16) / 1024 / 1024:.2f} Mo")
النتيجة: 7 مِيغا (نصف الحجم الأصليّ). الدقّة محفوظة تمامًا. الزمن على GPU يُحسَّن بمقدار ملحوظ، لكنّه على CPU الأساسيّ لا يتحرّك أو يسوء (CPU يعرف float32 أفضل من float16).
قياس الأثر: جدول مقارنة
قبل الاختيار، نُشغِّل الاستدلال على مجموعة التحقّق (نحو 4000 صورة) بكلّ نسخة، ونقيس ثلاث كمّيّات: الحجم، والزمن الوسيط لكلّ استدلال على CPU، والدقّة.
def evaluer_tflite(path_tflite, val_dataset, quantized=False):
interpreter = tf.lite.Interpreter(model_path=path_tflite)
interpreter.allocate_tensors()
inp = interpreter.get_input_details()[0]
out = interpreter.get_output_details()[0]
correct = 0
total = 0
import time
latences = []
for image, label in val_dataset:
# المعالجة المسبقة تختلف حسب نوع الإدخال
if quantized:
x = tf.cast(image, tf.int8).numpy()
else:
x = ((tf.cast(image, tf.float32) / 127.5) - 1.0).numpy()
t0 = time.perf_counter()
interpreter.set_tensor(inp["index"], x)
interpreter.invoke()
y = interpreter.get_tensor(out["index"])
latences.append((time.perf_counter() - t0) * 1000)
pred = int(y.argmax(axis=-1)[0])
correct += int(pred == int(label.numpy()[0]))
total += 1
return {
"accuracy": correct / total,
"median_ms": float(np.median(latences)),
}
الأرقام النموذجيّة على MobileNetV2 وPlantVillage، على حاسوب مكتبيّ ذي CPU متوسّط، تكون قريبة من:
| الاستراتيجيّة | الحجم | زمن CPU وسيط | الدقّة |
|---|---|---|---|
| خام (float32) | 14.0 Mo | 62 ms | 0.945 |
| المدى الديناميكيّ | 3.6 Mo | 48 ms | 0.943 |
| int8 كامل | 3.5 Mo | 31 ms | 0.936 |
| float16 | 7.0 Mo | 60 ms | 0.945 |
الاختيار لنا: int8 الكامل. يخسر تسعة أعشار النقطة المئويّة، لكنّه يدخل الميزانيّة، ويشتغل على NNAPI، وسنسترجع جزءًا من الدقّة في الوحدة 4 بـQAT إن لزم.
الخلاصة
- ثلاث استراتيجيّات، ثلاث مفاضلات: المدى الديناميكيّ (أبسط، لا يحتاج بيانات، ربح متواضع على CPU)؛ int8 الكامل (أقوى ربح، يفتح المسرِّعات، يتطلّب جَمْعًا تمثيليّا)؛ float16 (مفيد للـGPU، حجم إلى النصف فقط).
- الجَمْع التمثيليّ يشبه بيانات الإنتاج: مئتا صورة من التوزيعة الحقيقيّة، موزَّعة على الأصناف. جَمْع مشوَّه = تكميم مشوَّه.
- قِس على مجموعة التحقّق قبل الاختيار: الحجم والزمن والدقّة معًا، لا واحدًا منها. الاختيار قرار مفاضلة، لا قرار تقنيّ.
- الخسارة المتوقّعة بـint8 في نطاق نقطة مئويّة: إن تجاوزت ثلاث نقاط، ثمّة مشكلة في الجَمْع التمثيليّ أو معماريّة تعاني حساسيّة استثنائيّة، والحلّ في الوحدة 4 (QAT).
الوحدة التالية تنتقل إلى التدريب الواعي بالتكميم حين لا يكفي PTQ ونحتاج إلى استرداد نقاط الدقّة الضائعة.