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

الوحدة 10 — TorchScript وONNX والنشر للخدمة

بعد تسع وحدات من التدريب، نصل إلى السؤال العملي: كيف نُخرج النموذج من بايثون إلى بيئة إنتاج مستقلّة؟ في هذه الوحدة سنُصدّر شبكتنا بـTorchScript وONNX، سنتحقّق من مطابقتها العدديّة، ثم سنُغلّفها في خدمة HTTP بسيطة. نُلمح إلى دورَتي 37 (ONNX) و40 (FastAPI-ML) اللتين تُعمّقان الموضوع.

لماذا لا نستطيع نشر ملفّ .pt مباشرةً

state_dict الذي حفظناه في الوحدة الثامنة يحمل الأوزان فقط. إعادة البناء تتطلّب استيراد صنف nn.Module، أي وجود بايثون وشيفرتك في بيئة الإنتاج. هذا ثقيل: نسخة بايثون، PyTorch كامل بعدّة غيغابايت، وتعقيد إدارة الاعتمادات. النشر الحديث يفضّل:

  • تشغيل بلا بايثون: خادم مكتوب بـC++ أو Rust يقرأ ملفًّا مستقلاًّا.
  • تشغيل عبر أُطر أخرى: JavaScript، Java، C#.
  • تحسينات وقت التشغيل: تحسين الرسم، تجميع للمعالج المستهدف.

TorchScript وONNX يُوفّران هاتين القدرتين، بمقاربتين مختلفتين.

TorchScript: صيغة PyTorch الأصلية

TorchScript تحويل النموذج إلى تمثيل داخليّ في PyTorch نفسه. الملفّ الناتج قابل للتحميل بمكتبة libtorch (C++) دون أيّ بايثون. طريقتان للحصول عليه: trace وscript.

torch.jit.trace: عرض ثم تسجيل

يمرّر trace مدخلاً وهميًّا في النموذج، يُسجّل العمليات المُنفَّذة، ثم يُنتج رسمًا:

import torch
from torchvision import models

modele = models.resnet18()
modele.load_state_dict(torch.load("meilleur.pt")["modele"])
modele.eval() # مهمّ: يُعطّل Dropout وBatchNorm

exemple = torch.randn(1, 3, 224, 224)
trace = torch.jit.trace(modele, exemple)
trace.save("modele_trace.pt")

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

torch.jit.script: قراءة بايثون وترجمة

script يقرأ شيفرة forward بايثونيًّا ويُترجمها إلى TorchScript:

modele_script = torch.jit.script(modele)
modele_script.save("modele_script.pt")

يحتفظ بالتفرّعات المشروطة والحلقات، لكنّه أقلّ تسامحًا: بعض العمليات البايثونية غير مدعومة، وتظهر رسائل خطأ أثناء الترجمة.

trace أم script: قاعدة عملية

السياقخيار مستحسن
نموذج تلافيفيّ تقليديّ (ResNet، EfficientNet)trace
نموذج فيه if أو حلقة تعتمد على المدخلscript
نموذج بشرط على trainingتحقّق يدويًّا
مسارات متعدّدة (متعدّد الرؤوس)script

قاعدة أكثر عملية: جرِّب trace أوّلاً، وارجع إلى script إن ظهر شرط لم يُحفظ. script يستدعي كتابة تلميحات النوع (type hints) في forward، ما قد يفرض تعديلات على النموذج.

تحقّق عدديّ إجباريّ بعد التصدير

لا تُصدِّر ثم تنشر ثم تختبر. رتّب دومًا تحقّقًا فوريًّا:

# نفس المُدخَل، نفس البذور
exemple = torch.randn(4, 3, 224, 224)
modele.eval()
with torch.no_grad():
reference = modele(exemple)
trace_out = trace(exemple)

erreur_max = (reference - trace_out).abs().max().item()
print(f"أقصى فارق: {erreur_max:.2e}")
assert erreur_max < 1e-5, "التتبع مُعطَل"

قِيَم من رتبة 10610^{-6} عاديّة (ضجيج الفاصلة العائمة). قيمة أكبر تعني أنّ التتبّع ضاعت فيه شروط، والنموذج المصدَّر ليس هو نفسه.

ONNX: صيغة مفتوحة عابرة للأُطر

ONNX (Open Neural Network Exchange) تعريف رسم عصبيّ مستقلّ عن الإطار. نموذج مصدَّر بـONNX من PyTorch يُقرأ في:

  • ONNX Runtime: خادم استدلال بـC++ متعدّد المنصّات.
  • TensorRT (NVIDIA): تحسين ذو نُقل عالية للـGPU.
  • CoreML (Apple): تشغيل على iPhone وMac.
  • TensorFlow.js: تشغيل في المتصفّح.

التصدير في PyTorch:

import torch
from torchvision import models

modele = models.resnet18()
modele.load_state_dict(torch.load("meilleur.pt")["modele"])
modele.eval()

exemple = torch.randn(1, 3, 224, 224)

torch.onnx.export(
modele,
exemple,
"modele.onnx",
input_names=["image"],
output_names=["logits"],
dynamic_axes={
"image": {0: "batch"}, # حجم حزمة مرن
"logits": {0: "batch"},
},
opset_version=17,
)

dynamic_axes مهمّ: بدونه، الملفّ المُنتَج يقبل فقط حجم الحزمة المُستعمَل في التصدير. مع dynamic_axes نستطيع إرسال صورة واحدة أو مئة عبر الملفّ نفسه.

تحقّق ONNX: نفس مبدأ TorchScript

import numpy as np
import onnxruntime as ort

session = ort.InferenceSession("modele.onnx")
entree_np = exemple.numpy()
sortie_ort = session.run(["logits"], {"image": entree_np})[0]

erreur = np.abs(reference.numpy() - sortie_ort).max()
print(f"أقصى فارق ONNX: {erreur:.2e}")

القيم المتوقّعة تحت 10410^{-4} عادةً؛ فارق أكبر يستدعي التحقيق. قد يكون السبب opset_version منخفضًا لا يدعم عملية استعملتها الشبكة، أو عملية غير مدعومة تُترجَم بشكل مقارِب لا دقيق.

opset_version: مسألة توافق

كلّ إصدار من ONNX يُضيف عمليات جديدة. opset_version=17 تدعمه معظم بيئات الاستدلال منذ 2023، وهو قيمة افتراضية آمنة. إصدارات أدنى تفقد بعض العمليات الحديثة؛ إصدارات أعلى قد لا تفهمها أدوات النشر القديمة. اختبر في البيئة المستهدفة قبل الالتزام بإصدار.

خدمة توقّعات بسيطة بـFastAPI

نُغلّف النموذج المصدَّر في خدمة HTTP بسيطة:

# fichier: service.py
import io
import torch
import onnxruntime as ort
from fastapi import FastAPI, UploadFile
from PIL import Image
from torchvision import transforms

app = FastAPI()
session = ort.InferenceSession("modele.onnx")

prep = transforms.Compose([
transforms.Resize(224),
transforms.CenterCrop(224),
transforms.Grayscale(num_output_channels=3),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
),
])

CLASSES = [
"قميص عادي", "بنطلون", "بلوزة", "فستان", "معطف",
"صندل", "قميص", "حذاء رياضي", "حقيبة", "حذاء بكاحل",
]

@app.post("/predire")
async def predire(fichier: UploadFile):
image = Image.open(io.BytesIO(await fichier.read()))
tenseur = prep(image).unsqueeze(0).numpy()
logits = session.run(["logits"], {"image": tenseur})[0]
idx = int(logits.argmax(axis=1)[0])
return {"classe": CLASSES[idx], "score": float(logits[0, idx])}

نُشغّلها بـuvicorn service:app --port 8000. الطلب:

curl -X POST -F "fichier=@robe.jpg" http://localhost:8000/predire
# {"classe":"فستان","score":6.42}

قواعد النشر تفاصيلها في دورة 40 (FastAPI-ML). المهمّ هنا: الخدمة لا تحتاج بايثون-PyTorch، لأنّ onnxruntime وحده كافٍ. الحاوية تصير أخفّ بعدّة غيغابايت.

المعالجة القبلية جزء من العقد

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

حلاّن. الأول: دَمْج المعالجة في النموذج قبل التصدير:

class NModeleAvecPrep(nn.Module):
def __init__(self, base):
super().__init__()
self.base = base
self.mean = nn.Parameter(torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1), requires_grad=False)
self.std = nn.Parameter(torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1), requires_grad=False)

def forward(self, x):
# x تنسور بكسلات في [0, 1]
x = (x - self.mean) / self.std
return self.base(x)

modele_complet = NModeleAvecPrep(modele)

الثاني: توثيق العقد بوضوح في وثائق الخدمة، وتنفيذه في المستهلك. الحلّ الأول أكثر أمانًا لأنّه يمنع أيّ ضلال.

ذكر سريع: كمّنة النموذج

الكمّنة (quantization) تحويل الأوزان من float32 إلى int8 مثلاً، ما يقلّص الحجم أربع مرّات ويُسرّع الاستدلال على المعالجات المركزية. PyTorch يوفّر ثلاثة أساليب: ديناميكيّ، ساكن، مدركًا للكمّنة أثناء التدريب. الديناميكيّ أبسط:

modele_kmm = torch.quantization.quantize_dynamic(
modele, {nn.Linear}, dtype=torch.qint8
)

مناسب للنماذج المُهيمَنة بـLinear (المحوّلات مثلاً). أثره على شبكات الرؤية أقلّ. تفاصيل الكمّنة في دورة 36 (TFLite) ودورة 37 (ONNX).

ضع النموذج في eval قبل التصدير

Dropout وBatchNorm يتصرّفان بشكل مختلف بين التدريب والاستدلال. إن صدَّرت وأنت في وضع train()، فإنّ الملفّ الناتج يُدرج Dropout في الرحلة الأمامية أثناء الاستدلال — وهو خطأ صامت مدمّر. دائمًا modele.eval() قبل torch.jit.trace أو torch.onnx.export.

اختبر النموذج المصدَّر على بيئة الاستدلال المستهدفة

النموذج الذي يعمل مثاليًّا في بايثون قد يفشل عند نقله إلى C++ أو JavaScript. الأسباب: عمليات غير مدعومة في بيئة الاستدلال، تحويلات عدديّة دقيقة، تباين في تحسينات المُترجم. اختبار عمليّ في البيئة المستهدفة — ليس في بايثون على الجهاز نفسه — يكشف المشاكل قبل النشر. عشر عيّنات مقارنة كافية غالبًا.

في الخلاصة

  • TorchScript يُنتج ملفًّا مستقلاًّا يعمل بلا بايثون؛ trace لمسار واحد بسيط، وscript لتفرّعات ومسارات متعدّدة.
  • ONNX صيغة مفتوحة عابرة للأُطر، تُستهلك في onnxruntime وسواه؛ dynamic_axes ضروريّ لحزم مرنة.
  • تحقّق عدديّ إجباريّ بعد كلّ تصدير: قارن النموذج المصدَّر بالأصل على نفس المدخل، وأوقف النشر إن تجاوز الفارق حدًّا معقولاً.
  • المعالجة القبلية جزء من العقد: ادمجها في النموذج قبل التصدير، أو وثّقها بدقّة وأدخلها في المستهلك.

هذه هي نهاية المسار التعليميّ. تنتقل الآن إلى الوحدة 11: مراجعة شاملة للدورة، مع مقدّمة لاختبار الأربعين سؤالاً والشهادة.