الوحدة 6 — الضبط الآليّ للمعاملات الفائقة
انتهت الوحدة السابقة بسكربت scikit-learn ينتج نموذجًا مقبولًا. اختيار n_estimators=300 وmax_depth=10 بديهيّ، لكنّ الاحتمال أن يكون هناك خيار أفضل قائم. هنا يدخل الضبط الآليّ للمعاملات الفائقة (Automatic Model Tuning, أو AMT): بدل تجربة يدويّة تستغرق أيّامًا، نُشغّل عشرات المهامّ بالتوازي وندع SageMaker يقود البحث.
لماذا لا يكفي البحث الشبكيّ
الأسلوب المتعارف عليه في الدروس هو البحث الشبكيّ (grid search): نجرّب كلّ تركيبة من قائمة محدّدة. مع خمسة معاملات لكلّ منها خمس قيم، ذلك 3125 تدريبًا. لخيطنا الأحمر بأربع دقائق تدريب على ml.m5.xlarge ذلك عمليًّا 208 ساعات ≈ 48 دولارًا فقط على العتاد.
يقترح SageMaker استراتيجيّتين أكفأ:
- Random Search: تركيبات عشوائيّة داخل مجالات محدّدة. أفضل من الشبكيّ بكثير عندما يكون بعض المعاملات لا تأثير له فعليًّا.
- Bayesian Search (الافتراضيّ والموصى به): يبني نموذجًا للتوقّع للمقياس، ويختار التركيبة التالية التي يتوقّع أن تحسّن النتيجة أكثر. غالبًا يصل إلى نتيجة مقاربة للأفضل في 10٪ من عدد التجارب.
تعريف مجالات البحث
المفتاح الأوّل: أن نعرف نوع كلّ معامل. صحيح، أم مستمرّ، أم فئويّ. لسكربت RandomForest من الوحدة السابقة:
from sagemaker.tuner import (
HyperparameterTuner,
IntegerParameter,
ContinuousParameter,
CategoricalParameter,
)
hyperparameter_ranges = {
"n-estimators": IntegerParameter(100, 800),
"max-depth": IntegerParameter(3, 20),
"min-samples-split": IntegerParameter(2, 20),
"min-samples-leaf": IntegerParameter(1, 10),
}
القاعدة العمليّة: مجالات واسعة نسبيًّا حتّى لا نُقصي مسبقًا قيمة قد تكون أفضل. لكن لا مجالات لا معنى لها: max-depth=1 يجعل الأشجار عاجزة، فلا فائدة من تجربتها.
المقياس الهدف: العمود الفقريّ للبحث
يعمل الضبط الآليّ على مقياس واحد، يُلتقط من سجلّ السكربت بتعبير منتظم:
objective_metric_name = "validation:f1"
metric_definitions = [
{"Name": "validation:f1", "Regex": "validation:f1=([0-9\\.]+)"},
]
قواعد الاختيار:
- المقياس الهدف يعكس ما يهمّ العمل: F1 على الفئة الأقلّية للتسرّب، وليس دقّة عامّة قد تُخفي فقر النموذج.
- اقرأه من مجموعة التحقّق (validation)، لا من مجموعة التدريب. وإلّا فالضبط سيختار النموذج الأكثر إفراطًا.
- تجنّب المقاييس المكوّنة (F1_macro مثلًا) إذا كنت لا تعرف بالضبط كيف تُحسب: ما تظنّ أنّه تحسين قد يكون تدهورًا لأصناف مهمّة.
تشغيل التجربة
tuner = HyperparameterTuner(
estimator=sk_estimator,
objective_metric_name=objective_metric_name,
objective_type="Maximize",
hyperparameter_ranges=hyperparameter_ranges,
metric_definitions=metric_definitions,
max_jobs=30, # مجموع المهامّ
max_parallel_jobs=3, # كم من مهمّة تعمل في نفس الوقت
strategy="Bayesian", # افتراضيّ
early_stopping_type="Auto",
)
tuner.fit({
"train": TrainingInput(f"s3://{bucket}/churn/processed/v1/", content_type="application/x-parquet"),
"validation": TrainingInput(f"s3://{bucket}/churn/processed/v1/", content_type="application/x-parquet"),
})
قرار حاسم: max_parallel_jobs أصغر من max_jobs. البحث البيزيّ يتعلّم من كلّ مهمّة منتهية، فتشغيل الكلّ بالتوازي (30 = 30) يقتل هذا التعلّم ويحوّل البيزيّ إلى بحث عشوائيّ. القيمة العمليّة الحسنة: بين 3 و5.
الإيقاف المبكّر: توفير حرّ
early_stopping_type="Auto" يمنح SageMaker السلطة لإيقاف المهامّ التي يُتوقّع بوضوح أن تعطي أسوأ من الأفضل الحاليّ. الاختبار يجري على المقياس الهدف نفسه، ويقلّل الفاتورة الإجماليّة بنحو 40٪ في المتوسّط دون أن يفقد المهمّة الأفضل.
الشرط الوحيد: يجب أن يُطبع المقياس مرّات عدّة أثناء التدريب، لا مرّة واحدة في النهاية. تعديل بسيط على train.py:
for i in range(1, args.n_estimators + 1, 20):
partial = RandomForestClassifier(n_estimators=i, ...).fit(train_df, y_train)
score = f1_score(y_valid, partial.predict(valid_df))
print(f"[step={i}] validation:f1={score:.4f}")
قراءة النتائج
بعد اكتمال التجربة، tuner.analytics() يعيد DataFrame بكلّ المهامّ ومعاملاتها ومقاييسها:
df = tuner.analytics().dataframe()
df = df.sort_values("FinalObjectiveValue", ascending=False)
df[["TrainingJobName", "FinalObjectiveValue", "max-depth", "n-estimators"]].head(10)
اطّلع دائمًا على أفضل عشر مهامّ، لا على المهمّة الأولى فقط. إن كانت المهامّ العشر متشابهة في المقياس ومختلفة كثيرًا في المعاملات، فذلك مؤشّر إلى أنّ المعاملات في هذا النطاق لا تُحدث فرقًا، وأنّ تحسين النموذج يستلزم شيئًا آخر (ميزات جديدة، خوارزميّة مختلفة).
يستخرج المُقدِّر الأفضل مباشرةً:
best_estimator = tuner.best_estimator()
print(best_estimator.hyperparameters())
الفخّ: تكرار التجربة نفسها بلا تعلّم
الخطأ الكلاسيكيّ: تشغيل تجربة ضبط، مشاهدة النتائج، تغيير المجالات، إعادة التشغيل، بلا حفظ أيّ شيء. بعد أسبوع لا يتذكّر أحد لماذا اختار هذه المجالات.
اجعل ما يلي إلزاميًّا:
- سجّل اسم كلّ تجربة ضبط ومعاملاتها في ملفّ (أو في MLflow) قبل التشغيل.
- علّق أفضل نتيجة والفرضيّة التي دُفعت بها (مثلًا: «توقّعت أنّ
max_depthكبير سيتفوّق، لكنّه لم يفعل»). - التجربة اللاحقة تنطلق من هذه الملاحظة، لا من الصفر.
max_jobs=30 على ml.m5.xlarge لتدريب مدّته 4 دقائق يعطي 2 ساعة إجماليّة ≈ 0.46 دولار. لكن مع ml.g4dn.xlarge (شبكة عصبيّة) يصير الحساب 2 × 0.736 ≈ 1.5 دولار. max_jobs=200 على GPU يتحوّل إلى فاتورة ثلاث خانات. ابدأ صغيرًا لتُقدّر الكلفة قبل التوسّع.
الخلاصة
- البحث البيزيّ يصل إلى نتائج مقاربة للأفضل بـ10٪ من التجارب مقارنةً بالبحث الشبكيّ.
- عرّف مجالات واسعة لكن معقولة، وأشمل نوع كلّ معامل (صحيح، مستمرّ، فئويّ).
max_parallel_jobsصغير نسبيًّا (3-5) يحفظ فاعليّة التعلّم البيزيّ.- الإيقاف المبكّر يوفّر نحو 40٪ من الفاتورة، شريطة أن يُطبع المقياس مرّات أثناء التدريب.
الوحدة التالية: نأخذ النموذج الأفضل ونحوّله إلى نقطة نهاية HTTP، ثمّ إلى نسخة بلا خوادم.