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

الوحدة 4 — التحقّق من التكافؤ العدديّ

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

لماذا لا يكون التكافؤ تامًّا أبدًا

جمع النقاط العائمة ليس تجميعيًّا: (a + b) + c ليست بالضبط a + (b + c) في float32. ONNX Runtime قد يُنفّذ العمليّات بترتيب مختلف قليلًا عن PyTorch (بسبب الفنّ المتوازي، دمج العمليّات...)، فينتج عن ذلك فوارق صغيرة جدًّا، لكنّها ليست صفرًا. القاعدة الذهبيّة: لا تُقارن بـ==، بل بتحمّل رقميّ.

numpy.allclose(a, b, rtol=1e-4, atol=1e-5) هي الأداة الصحيحة. تقول: النتيجتان متكافئتان إذا كان الفارق المطلق أصغر من atol أو الفارق النسبيّ أصغر من rtol. القيم أعلاه معقولة لأغلب نماذج الرؤية في float32. لنماذج اللغة الكبيرة، rtol=1e-3 أكثر واقعيّة.

أوّل فحص: عيّنة واحدة

import numpy as np
import torch
import onnxruntime as ort

modele_torch.eval()
session = ort.InferenceSession("resnet18.onnx",
providers=["CPUExecutionProvider"])

entree = torch.randn(1, 3, 224, 224)
with torch.no_grad():
sortie_torch = modele_torch(entree).numpy()
sortie_onnx = session.run(None, {"image": entree.numpy()})[0]

fait = np.allclose(sortie_torch, sortie_onnx, rtol=1e-4, atol=1e-5)
print(f"تكافؤ سريع : {fait} | أقصى فارق : {np.abs(sortie_torch - sortie_onnx).max():.2e}")

هذا يعطي إشارة أوّليّة. إذا فشل، توقّف هنا: هناك مشكلة بنيويّة (طبقة مفقودة، eval() منسيّ، محاور مقلوبة).

الفحص الحقيقيّ: توزيع إدخالات متنوّعة

عيّنة واحدة يمكن أن تُخفي أخطاء تحدث فقط على مجالات معيّنة من القيم. الاختبار الجدّي يُشغّل النموذج على مئات الإدخالات الممثِّلة للتوزيع الحقيقيّ، ويُحصي الفوارق:

def compare(modele_torch, session, n=200):
modele_torch.eval()
fs = []
for _ in range(n):
x = torch.randn(1, 3, 224, 224)
with torch.no_grad():
yt = modele_torch(x).numpy()
yo = session.run(None, {"image": x.numpy()})[0]
fs.append(np.abs(yt - yo).max())
fs = np.array(fs)
print(f"n={n} | متوسط={fs.mean():.2e} | p50={np.median(fs):.2e} "
f"| p95={np.quantile(fs, 0.95):.2e} | أقصى={fs.max():.2e}")

compare(modele_torch, session, n=500)

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

استعمل إدخالات حقيقيّة عندما تكون متاحة. torch.randn ينتج قيمًا مركّزة قد لا تُشبه ما يراه النموذج فعليًّا. عيّنة من مجموعة التحقّق أدقّ:

for x, _ in charg_val:      # DataLoader على مجموعة تحقّق حقيقيّة
yt = modele_torch(x).numpy()
yo = session.run(None, {"image": x.numpy()})[0]
# جمع الفوارق ...

معيار المهمّة، لا معيار المخرجات وحده

الفارق في اللوجيتات (logits) قد يكون صغيرًا جدًّا، لكنّ الصنف المتوقَّع بعد argmax قد يتغيّر في حالة حدّيّة. المعيار الحقيقيّ هو معدّل الاتّفاق على المهمّة:

# على مصنّف
accord = 0
total = 0
for x, _ in charg_val:
ct = modele_torch(x).argmax(1)
co = session.run(None, {"image": x.numpy()})[0].argmax(1)
accord += (ct.numpy() == co).sum()
total += len(x)
print(f"اتّفاق على الصنف : {accord/total:.4%}")

نسبة اتّفاق أقلّ من 99.9% على مصنّف تعني وجود مشكلة. على نموذج توليد، المعيار قد يكون BLEU أو مسافة سلسلة، لكنّ المبدأ ذاته: قِس ما يهمّ فعلًا، لا فقط الأرقام الوسطى.

نصف الدقّة: مصيدة معلومة

عندما تُصدَّر نماذج بـfloat16 لتسريعها على GPU، الفارق يتضخّم فورًا. atol=1e-5 تصير غير واقعيّة؛ atol=1e-2 أقرب إلى الواقع. الأخطر: بعض الطبقات (LayerNorm، Softmax على قيم كبيرة) تُنتج Inf أو NaN في float16 بينما تعمل جيّدًا في float32.

القاعدة: صدِّر أوّلًا في float32، افحص، ثمّ حوِّل إلى float16 بأداة مخصّصة كـonnxconverter_common.float16.convert_float_to_float16، وأعد الفحص. لا تقفز مباشرةً إلى half() قبل التصدير: قد تتلقّى مفاجآت في الاستدلال بصمت.

أدوات التحقّق المُدمَجة في ONNX

مكتبة onnx تقدّم فحصًا بنيويًّا مستقلًّا عن الاستدلال:

import onnx

modele = onnx.load("resnet18.onnx")

# 1. فحص بنيويّ (سيرفع استثناء إن كان الرسم غير صالح)
onnx.checker.check_model(modele)

# 2. استنتاج أشكال التنسورات الوسيطة
modele_avec_formes = onnx.shape_inference.infer_shapes(modele)
for tenseur in modele_avec_formes.graph.value_info:
forme = [d.dim_value or d.dim_param for d in tenseur.type.tensor_type.shape.dim]
print(f"{tenseur.name:30s} {forme}")

check_model يكشف الرسوم غير المتّسقة (إدخالات لا تُستهلك، أنواع متعارضة، opset غير معلن). shape_inference مفيد لتشخيص أشكال غير متوقّعة قبل التنفيذ.

سيناريو حقيقيّ: خطّ CI

في مشروع جدّي، لا تعتمد على الذاكرة البشريّة. اجعل التحقّق آليًّا: كلّ تعديل على النموذج يُشغِّل سكربتًا يُصدِّر إلى ONNX، ثمّ يُقارن على 200 عيّنة من مجموعة تحقّق مُعرَّفة، ويرفض الدمج إذا:

  • أقصى فارق مطلق يتجاوز عتبة معلومة
  • اتّفاق الصنف ينزل تحت 99.9%
  • زمن الاستدلال يزيد أكثر من 20% دون سبب

هذا الخطّ يُنتج ثقة عمليّة: أيّ تراجع يظهر عند المطوّر، لا عند العميل في الإنتاج.

الحالة الحديّة الأكثر إخفاءً

النموذج يعمل بشكل مثاليّ في اختباراتك، لكنّه ينهار في الإنتاج على صور من مصدر جديد. السبب المرجّح: المعالجة القبلية ليست هي نفسها. PyTorch تُطبِّق Normalize(mean, std) بعد ToTensor، وONNX Runtime يستقبل تنسورًا خامًّا. إن كنت تنسى تطبيق Normalize نفسه على مدخل ONNX، فالمقارنة تظهر تكافؤًا مثاليًّا في اختبارك (تُقدّم البيانات المعالَجة سلفًا للاثنين)، لكنّ الإنتاج الذي يعتمد فقط على ONNX يستقبل بيانات خاطئة السلّم. احفظ خطّ المعالجة القبلية مع النموذج أو أدمجه في الرسم.

الخلاصة

  • التكافؤ العدديّ ليس تامًّا أبدًا؛ استعمل np.allclose بتحمّل مناسب (1e-5 لـfloat32، 1e-2 لـfloat16).
  • لا تقتصر على عيّنة واحدة؛ اختبر على مئات الإدخالات الحقيقيّة واعرض النسب المئويّة.
  • المعيار الحقيقيّ هو اتّفاق المهمّة (الصنف، BLEU...)، لا الفوارق الخام على المخرجات.
  • استعمل onnx.checker.check_model وshape_inference كفحوصات بنيويّة مستقلّة.

الوحدة التالية: كيف يُحسّن ONNX Runtime الرسم داخليًّا، وكيف نتحكّم في مستويات التحسين.