تقسيمات تكشف الحقيقة
اكتشف الصور شبه المتطابقة التي تتسرب بين التدريب والتحقق، وقسّم البيانات وفق الوحدة التي ستكون جديدة عند النشر.
إذا حقق النموذج نتيجة 95% في التحقق و60% في بيئة الإنتاج، فعادةً ما يكون التقسيم مضللًا. فقد وُضعت صور شبه متطابقة — مثل إطارات فيديو متجاورة أو صور الكاميرا نفسها في اليوم نفسه — في كلا الجزأين، فاختبر التحقق قدرة النموذج على التذكر بدلًا من التعميم. ستكتشف هذا التسرّب بعينه في مجموعة بيانات حقيقية، ثم تعالجه بتقسيم المجموعات.
عملي
ثلاثة تقسيمات، وثلاث مهام#
| التقسيم | من يتعلم منه | متى تطّلع عليه |
|---|---|---|
| التدريب | النموذج: تُضبط الأوزان هنا | كل حقبة تدريب |
| التحقق | أنت: المعلمات الفائقة، وعدد الحقب، وحجم النموذج، والحدود | بعد كل تجربة |
| الاختبار | لا أحد: فهو يعرض الرقم النهائي | مرة واحدة، بعد اتخاذ كل القرارات |
كل قرار تتخذه بالاطلاع على أحد الأقسام يُسرّب جزءًا صغيرًا منه إلى النموذج. هذه هي وظيفة مجموعة التحقق. إذا ضبطت الإعدادات باستخدام مجموعة الاختبار، فلن يعود رقمها تقديرًا نزيهًا — فقد أفرطت في التكيّف مع اختياراتك. في Ultralytics، يحسب model.val(split="test") نتائج قسم الاختبار المحدد في ملف YAML لمجموعة البيانات.
اكتشف التسرّب في مجموعة بيانات حقيقية#
سُمّيت صور أقراص الدواء Frame_0.jpg وFrame_4.jpg وFrame_8.jpg — وهي إطارات من مقطع فيديو. هذه إشارة تحذير: الإطارات المتجاورة متطابقة تقريبًا، ويؤدي التقسيم العشوائي إلى وضعها في كلا القسمين. تحقّق من ذلك بمقارنة بصمات رمادية مصغّرة لكل صورة تحقق مع كل صورة تدريب:
from pathlib import Path
import numpy as np
from PIL import Image
from ultralytics.data.utils import check_det_dataset
data = check_det_dataset("medical-pills.yaml")
def thumbnail(path):
"""A 32x18 grayscale fingerprint: enough to spot near-identical frames."""
return np.asarray(Image.open(path).convert("L").resize((32, 18)), dtype=float)
train = {p.name: thumbnail(p) for p in sorted(Path(data["train"]).glob("*.jpg"))}
pairs = []
for p in sorted(Path(data["val"]).glob("*.jpg")):
v = thumbnail(p)
name, diff = min(((n, np.abs(v - t).mean()) for n, t in train.items()), key=lambda x: x[1])
pairs.append((diff, p.name, name))
pairs.sort()
print(f"{sum(d < 5 for d, _, _ in pairs)} of {len(pairs)} val images have a near-twin in train (difference < 5)")
for diff, val_name, train_name in pairs[:3]:
print(f" {val_name} ~ {train_name} difference {diff:.1f}")17 of 23 val images have a near-twin in train (difference < 5)
Frame_344.jpg ~ Frame_56.jpg difference 0.3
Frame_28.jpg ~ Frame_312.jpg difference 0.5
Frame_24.jpg ~ Frame_308.jpg difference 1.8يعني فرق قدره 0.3 على مقياس من 0 إلى 255 أن الصورتين متطابقتان عمليًا. افتح val/Frame_24.jpg وtrain/Frame_308.jpg جنبًا إلى جنب، وسترى عبوات الأقراص نفسها من الزاوية نفسها. من بين صور التحقق البالغ عددها 23، توجد صورة مطابقة في التدريب لـ17 صورة، لذا فإن درجة التحقق هنا تقيس في معظمها مدى تذكّر النموذج لصور سبق أن رآها. لا بأس بذلك لمجموعة بيانات تجريبية سريعة — لكنه بالضبط ما يجب ألا تطلقه.

يوجد الفخ نفسه في أي أداة. يذكر الوصف الخاص بمربع الحوار هذا أنه يعيد تعيين كل صورة عشوائيًا، لذا فهو مثالي عندما تكون الصور لقطات مستقلة — لكنه يسبب تسرّبًا عندما تكون إطارات فيديو. أجرِ أولًا فحصًا للصور شبه المتطابقة، واحتفظ بنسبة للاختبار أكبر من 0% إذا أردت رقمًا نهائيًا لم يُضبط بناءً عليه.
قسّم حسب المجموعة#
القاعدة: يجب أن تكون الأقسام مستقلة على مستوى الوحدة التي ستكون جديدة عند النشر.
| البيانات | التقسيم حسب |
|---|---|
| الفيديو | مقطع أو جلسة تسجيل |
| أسطول كاميرات ثابت | الكاميرا أو الموقع |
| المشاهد التي تتغير بمرور الوقت | اليوم أو الأسبوع |
| البيانات الطبية أو الرياضية أو بيانات الأشخاص | المريض أو الرياضي أو الشخص المعني |
| دفعات الصور | حدث الالتقاط |
غالبًا ما تحمل أسماء الملفات مفتاح المجموعة. ينشئ هذا المثال 900 صورة من 6 كاميرات ويقارن بين الاستراتيجيتين:
import random
from collections import defaultdict
# File names carry the unit that must never straddle splits: camera_day_frame.jpg
images = [f"cam{c:02d}_day{d}_{f:04d}.jpg" for c in range(1, 7) for d in range(1, 4) for f in range(50)]
# WRONG: shuffle individual frames, so near-identical neighbors land in both splits
random.seed(0)
shuffled = random.sample(images, len(images))
train, val = shuffled[: int(0.8 * len(images))], shuffled[int(0.8 * len(images)) :]
shared = {i.split("_")[0] for i in train} & {i.split("_")[0] for i in val}
print(f"per-image split: {len(shared)} of 6 cameras appear in both train and val")
# RIGHT: shuffle whole cameras, then expand them back into images
groups = defaultdict(list)
for image in images:
groups[image.split("_")[0]].append(image)
cameras = random.sample(sorted(groups), len(groups))
train = [i for cam in cameras[:5] for i in groups[cam]]
val = [i for cam in cameras[5:] for i in groups[cam]]
print(f"per-camera split: train {len(train)} images, val {len(val)} images, val cameras {cameras[5:]}")per-image split: 6 of 6 cameras appear in both train and val
per-camera split: train 750 images, val 150 images, val cameras ['cam05']عند التقسيم لكل صورة، لا يرى التحقق كاميرا جديدة مطلقًا. أما عند التقسيم لكل كاميرا، فتقدّر الدرجة ما سيحدث مع كاميرا لم يرها النموذج من قبل — وهذا هو الرقم الذي تحتاج إليه.
مواطن الخلل#
العَرَض: أداء التحقق أفضل بكثير من أول اختبار ميداني. الحل: اعثر على مفتاح المجموعة — وغالبًا ما يكون مخفيًا في أسماء الملفات أو بيانات التسجيل الوصفية — وقسّم البيانات بناءً عليه، ثم شغّل فحص التكرارات شبه المتطابقة للتأكد.
العَرَض: فئة نادرة بلا AP، أو قيمة AP مستندة إلى حالتين فقط. الحل: بعد التقسيم بحسب المجموعات، احسب عدد كل فئة في كل جزء، وبدّل مجموعات كاملة حتى تظهر كل فئة بنسب متقاربة. تؤتمت sklearn.model_selection.StratifiedGroupKFold هذه العملية.
العَرَض: ترتفع قيمة الاختبار تدريجيًا عبر التجارب. الحل: جمّد مجموعة الاختبار، واستخرجها من ظروف شبيهة بالإنتاج، وأبلغ عن نتائجها مرة واحدة لكل تجربة مكتملة. في مجموعات البيانات الصغيرة، احسب المتوسط عبر عدة تقسيمات باستخدام التحقق المتقاطع بـ k طيات بدلًا من إعادة استخدام تقسيم واحد.
يتناول تقسيم مجموعة البيانات على نحو صحيح النسب وفرض كل ذلك على مجموعة بيانات كاملة.
عرض الحل
فحص تسرّب يعمل على أي قائمة بأسماء الملفات، إذا زُوّد بدالة تستخرج مفتاح المجموعة:
def leaked_groups(splits, key):
"""Return every group key that appears in more than one split."""
owner, leaks = {}, set()
for split, files in splits.items():
for name in files:
group = key(name)
if owner.setdefault(group, split) != split:
leaks.add(group)
return sorted(leaks)
# استبدل هذه القوائم بأسماء ملفاتك، مثلاً [p.name for p in Path("images/train").glob("*.jpg")]
splits = {
"train": ["cam01_day1_0001.jpg", "cam01_day1_0002.jpg", "cam02_day1_0001.jpg"],
"val": ["cam03_day2_0001.jpg", "cam01_day1_0003.jpg"],
"test": ["cam04_day3_0001.jpg"],
}
print(leaked_groups(splits, key=lambda name: name.split("_")[0])) # التجميع حسب الكاميرا['cam01']يظهر cam01 في كل من التدريب والتحقق، لذا يسرّب هذا التقسيم البيانات. انقل كل صورة cam01 إلى قسم واحد، وأعِد التشغيل حتى يطبع []. أما في مجموعة أقراص الدواء، حيث تعود الإطارات من الفيديو الواحد إلى المنظر نفسه، فلا يوجد مفتاح مجموعة واضح في أسماء الملفات: إما أن تسجل مقاطع فيديو تحقق جديدة لعبوات مختلفة، أو أن تجمع الصور حسب بصمة التشابه الشديد وتخصص كل مجموعة كاملة لقسم واحد.