برای آزمودنِ تعمیمِ یک مدل به دستگاه تازه، جدا بودنِ ردیف‌های آموزش و آزمون کافی نیست؛ ممکن است نمونه‌های همان دستگاه در هر دو طرف باشند. این درس، کنترلِ قابل‌اجرایی برای هویت گروه‌ها می‌سازد تا بتوان آن را در بازبینی یا خطِ آزمون پروژه به کار برد. فقط شناسه‌های ساختگی و تعداد نمونه‌ها داریم؛ ویژگی، برچسب خرابی، مدل و امتیاز پیش‌بینی در آزمایش وجود ندارند.

۱. گروه دستگاه را پیش از تقسیم تعریف کنیم

شناسهٔ دستگاه با شناسهٔ نمونه فرق دارد
تصویر مفهومی: شناسهٔ دستگاه با شناسهٔ نمونه فرق دارد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

شش دستگاهِ فرضی A تا F به‌ترتیب چهار، سه، سه، دو، دو و یک نمونه دارند؛ جمع، پانزده است. هر نمونه یک شناسهٔ یکتا مانند A0 و یک شناسهٔ گروه مانند A دارد. اولی هویت ردیف است و دومی می‌گوید کدام ردیف‌ها از همان دستگاه فرضی آمده‌اند. نام‌ها واقعی نیستند و اطلاعاتی دربارهٔ مشتری، تجهیزات یا فرکانس دریافت نداریم.

راهنمای اعتبارسنجی گروهی scikit-learn توضیح می‌دهد که نمونه‌های هم‌گروه می‌توانند وابسته باشند؛ برای پرسشِ دستگاهِ ندیده، گروه آزمون نباید در آموزشِ همان تقسیم نماینده داشته باشد. تعریف گروه به مسئله وابسته است. اگر یک دستگاه دو نام متفاوت داشته باشد، کنترل رشته‌ها هویت فیزیکیِ مشترک را کشف نمی‌کند؛ منشأ درست باید پیش از این مرحله ثبت شود.

در شاهدِ عمداً نامناسب، ردیف‌های زوج و فردِ فهرست را دو طرف می‌گذاریم. شناسهٔ نمونه‌ها مشترک نیست، ولی گروه‌های A،B،C،D،E در هر دو طرف حاضرند. این فقط یک تقسیمِ قطعیِ متناوب است، نه شبیه‌سازیِ تقسیم تصادفی یا اثباتِ اندازهٔ خطای ارزیابی. هدف نشان دادنِ ناکافی بودنِ کنترل یکتایی ردیف برای شرطِ جدایی دستگاه است.

این پرسش با آیندهٔ همان دستگاه فرق دارد. آزمون حالتِ پیش‌پردازشِ آموزش بررسی می‌کرد پارامتر تبدیل از کجا آمده است؛ جدا بودن گروه‌ها آن مسئولیت را حذف نمی‌کند. تقسیم حاضر ترتیب زمانی، افق برچسب یا فاصلهٔ زمانی را کنترل نمی‌کند. برای دستگاه تازه و زمان آینده ممکن است هر دو نوع مرز لازم باشند.

۲. قرارداد قطعی تخصیص و تساوی بار

تخصیص قطعی گروه‌ها با شمار نمونه و قاعدهٔ تساوی
تصویر مفهومی: تخصیص قطعی گروه‌ها با شمار نمونه و قاعدهٔ تساوی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تعداد بخش‌ها باید int دقیقِ دو تا چهار باشد. ورودی tuple با یک تا ۶۴ رکورد است و هر رکورد tuple دو عضویِ شناسهٔ نمونه و گروه است. هر نام رشتهٔ واقعی با یک حرف بزرگ آغاز می‌شود و تا شانزده نویسه از حروف بزرگ لاتین، رقم، خط تیره یا زیرخط دارد. نامِ گروه و شناسهٔ نمونه تبدیل یا نرمال‌سازی نمی‌شوند.

شناسهٔ نمونه تکراری رد می‌شود، حتی اگر گروهش متفاوت نوشته شده باشد. تعداد گروه‌ها از تعداد بخش‌ها کمتر نیست و حداکثر هشت است. بنابراین هر گروه حداقل یک نمونه دارد و تخصیصِ اولیه می‌تواند همهٔ بخش‌ها را غیرخالی کند. این کران‌ها برای آزمایش کوچک‌اند؛ محدودیت عمومیِ ارزیابی گروهی یا مشخصات دادهٔ واقعی نیستند. ورودی خالی نیز در این قرارداد خطاست.

قاعدهٔ تألیفی چنین است: گروه‌ها را بر اساس تعداد نمونه از بزرگ به کوچک می‌چینیم؛ در تعداد برابر، نامِ گروه ترتیب می‌دهد. هر گروهِ کامل وارد بخشی می‌شود که کمترین تعداد نمونهٔ فعلی را دارد؛ در تساوی بار، شمارهٔ کوچک‌ترِ بخش برنده است. گروه شکسته نمی‌شود. «بار» فقط تعداد نمونه است و زمان آموزش، حافظه یا دشواری پیش‌بینی را اندازه نمی‌گیرد.

قرارداد GroupKFold در مستندات 1.9.1، جدایی گروه‌ها و یک بار حضور هر گروه در آزمون را بیان می‌کند. تابعِ ما پیاده‌سازیِ کامل یا همسانِ API آن نیست؛ سیاست نام‌ها، کران‌ها و تساوی‌ها را خودمان تعیین کرده‌ایم. scikit-learn اجرا نشده و برابر بودنِ دقیقِ شمارهٔ بخش‌ها با خروجی آن کتابخانه ادعا نمی‌شود.

۳. کد کامل و پوشش train/test بدون اشتراک گروه

پوشش کامل نمونه‌ها و جدایی گروه آموزش و آزمون
تصویر مفهومی: پوشش کامل نمونه‌ها و جدایی گروه آموزش و آزمون؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل grouped_sensor_folds.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. plan_folds شمار هر گروه را می‌سازد و تخصیصِ گروه و نمونه را در tuple نگه می‌دارد. train_test_ids فقط برای طرحِ معتبرِ همان سازنده طراحی شده است؛ برنامه اصالتِ یک FoldPlan دست‌ساز و دست‌کاری‌شده را بررسی نمی‌کند. شمارهٔ بخشِ کنارگذاشته‌شده نوع و حدِ روشن دارد.

"""Finite, deterministic grouped partitions; not sklearn API parity or model fit."""
from collections import Counter
from dataclasses import dataclass
from itertools import product
import json
import platform
import re


@dataclass(frozen=True)
class FoldPlan:
    group_to_fold: tuple
    sample_to_fold: tuple
    sample_loads: tuple


def plan_folds(records, n_folds):
    if type(n_folds) is not int or not 2 <= n_folds <= 4:
        raise ValueError("actual integer fold count 2..4 required")
    if type(records) is not tuple or not 1 <= len(records) <= 64:
        raise ValueError("tuple with 1..64 sample records required")
    ids, groups = [], []
    for record in records:
        if type(record) is not tuple or len(record) != 2:
            raise ValueError("each record must be (sample_id, group_id) tuple")
        for name in record:
            if type(name) is not str or re.fullmatch(r"[A-Z][A-Z0-9_-]{0,15}", name) is None:
                raise ValueError("bounded ASCII sample and group names required")
        ids.append(record[0])
        groups.append(record[1])
    if len(set(ids)) != len(ids):
        raise ValueError("sample ids must be unique")
    counts = Counter(groups)
    if not n_folds <= len(counts) <= 8:
        raise ValueError("need at least one distinct group per fold, at most eight groups")
    loads, assignments = [0] * n_folds, {}
    for group in sorted(counts, key=lambda name: (-counts[name], name)):
        fold = min(range(n_folds), key=lambda index: (loads[index], index))
        assignments[group] = fold
        loads[fold] += counts[group]
    return FoldPlan(tuple(sorted(assignments.items())),
                    tuple((sample, assignments[group]) for sample, group in records), tuple(loads))


def train_test_ids(plan, held_out):
    """Trusted own plan_folds result; preserving original sample order."""
    if type(held_out) is not int or not 0 <= held_out < len(plan.sample_loads):
        raise ValueError("held-out fold index out of range")
    train = tuple(sample for sample, fold in plan.sample_to_fold if fold != held_out)
    test = tuple(sample for sample, fold in plan.sample_to_fold if fold == held_out)
    return train, test


def exact_best_max_load(counts, n_folds):
    """Independent trusted small-count fixture oracle: enumerate every assignment."""
    best, visited = None, 0
    for assignment in product(range(n_folds), repeat=len(counts)):
        visited += 1
        if set(assignment) != set(range(n_folds)):
            continue
        # Independent indexed filter/sum, not greedy running loads or sort/tie rule.
        loads = tuple(sum(counts[index] for index in range(len(counts))
                          if assignment[index] == fold) for fold in range(n_folds))
        objective = max(loads)
        best = objective if best is None else min(best, objective)
    return best, visited


def fixture(group_counts):
    return tuple((chr(65 + group) + str(sample), chr(65 + group))
                 for group, count in enumerate(group_counts) for sample in range(count))


def rejects(function, *args):
    try:
        function(*args)
    except ValueError:
        return True
    return False


def main():
    counts = (4, 3, 3, 2, 2, 1)
    records = fixture(counts)
    result = plan_folds(records, 3)
    hand = (("A", 0), ("B", 1), ("C", 2), ("D", 1), ("E", 2), ("F", 0))
    checks = {"hand_group_assignments_and_loads": result.group_to_fold == hand and result.sample_loads == (5, 5, 5)}
    lookup = dict(records)
    tests = []
    disjoint = True
    for held_out in range(3):
        train, test = train_test_ids(result, held_out)
        tests.extend(test)
        disjoint = disjoint and not (set(train) & set(test))
        disjoint = disjoint and not ({lookup[sample] for sample in train} & {lookup[sample] for sample in test})
        disjoint = disjoint and set(train) | set(test) == set(lookup)
    checks["every_split_disjoint_sample_ids_and_group_ids"] = bool(disjoint)
    checks["all_samples_tested_exactly_once_across_folds"] = Counter(tests) == Counter(lookup.keys())
    checks["record_reordering_preserves_group_mapping"] = plan_folds(records[::-1], 3).group_to_fold == hand
    equal = plan_folds(fixture((2, 2, 2, 2)), 2)
    checks["equal_size_and_load_ties_have_declared_deterministic_rule"] = (
        equal.group_to_fold == (("A", 0), ("B", 1), ("C", 0), ("D", 1)))
    best, balanced_visited = exact_best_max_load(counts, 3)
    checks["balanced_fixture_matches_exhaustive_729_assignments"] = best == max(result.sample_loads) == 5 and balanced_visited == 729
    counter_counts = (8, 7, 6, 5, 4)
    counter = plan_folds(fixture(counter_counts), 2)
    optimum, counter_visited = exact_best_max_load(counter_counts, 2)
    checks["greedy_counterexample_seventeen_versus_optimum_fifteen"] = (
        counter.sample_loads == (17, 13) and optimum == 15 and counter_visited == 32)
    ordinary_train, ordinary_test = records[::2], records[1::2]
    overlap = sorted(set(group for _, group in ordinary_train) & set(group for _, group in ordinary_test))
    checks["record_level_alternation_can_split_device_identity"] = overlap == ["A", "B", "C", "D", "E"]
    checks["fold_types_bounds_and_too_few_groups_rejected"] = all(
        rejects(plan_folds, records, value) for value in (True, 2.0, 1, 0, 5, "3")) and rejects(plan_folds, fixture((2,)), 2)
    checks["record_container_shape_names_and_duplicate_ids_rejected"] = all(
        rejects(plan_folds, bad, 2) for bad in (
            [], (), (("A0",),), (("A0", "A", "X"),), (["A0", "A"],),
            ((True, "A"), ("B0", "B")), (("a0", "A"), ("B0", "B")),
            (("A0", "A"), ("A0", "B"))))
    checks["record_and_group_count_bounds_rejected"] = (
        rejects(plan_folds, fixture((33, 32)), 2) and rejects(plan_folds, fixture((1,) * 9), 2))
    maximum = plan_folds(fixture((8,) * 8), 4)
    checks["closed_maximum_domain_keeps_nonempty_folds"] = maximum.sample_loads == (16, 16, 16, 16)
    checks["held_out_index_type_and_bounds_rejected"] = all(
        rejects(train_test_ids, result, value) for value in (True, -1, 3, 1.0))
    checks["input_preserved_and_result_contains_immutable_values"] = (
        records == fixture(counts) and type(result.group_to_fold) is tuple
        and all(type(pair) is tuple for pair in result.sample_to_fold))
    checks = {key: bool(value) for key, value in checks.items()}
    if not all(checks.values()):
        raise RuntimeError(checks)
    return {"python_version": platform.python_version(), "dependencies": "standard library; sklearn not executed",
            "data_origin": "original synthetic sample IDs and device groups; no features, labels or model fit",
            "group_sample_counts": dict(zip("ABCDEF", counts)),
            "hand_group_to_fold": result.group_to_fold, "test_fold_sample_loads": result.sample_loads,
            "record_level_alternating_split_shared_groups": overlap,
            "balanced_oracle": {"assignments_visited_in_one_group": balanced_visited, "optimal_max_load": best},
            "greedy_counterexample": {"group_counts": counter_counts, "greedy_loads": counter.sample_loads,
                                      "optimal_max_load": optimum, "assignments_visited_in_one_group": counter_visited},
            "checks": checks, "unique_check_groups": len(checks)}


if __name__ == "__main__":
    print(json.dumps(main(), ensure_ascii=False, indent=2, allow_nan=False))

برای هر بخش، نمونه‌های همان بخش آزمون و بقیه آموزش‌اند. ترتیبِ نمونه‌ها در هر طرف، ترتیبِ اولیهٔ ورودی را حفظ می‌کند. در هر تقسیم، اشتراکِ شناسهٔ نمونه و اشتراکِ شناسهٔ گروه باید تهی باشند؛ اجتماع دو طرف باید تمام نمونه‌ها را پوشش دهد. در طول سه تقسیم نیز هر نمونه دقیقاً یک بار در آزمون می‌آید؛ هیچ رکوردی حذف یا تکثیر نمی‌شود.

تغییر ترتیبِ ردیف‌ها، شمار گروه‌ها و نگاشتِ گروه به بخش را عوض نمی‌کند، چون تساوی‌ها با نام و شماره شکسته می‌شوند. اما ترتیبِ فهرست شناسه‌های هر طرف، طبق قرارداد تابع با ورودی تغییر می‌کند. این دو نوع تکرارپذیری را یکی نمی‌گیریم. برای نگهداریِ آزمایش، نگاشت و هویت ردیف‌ها را ثبت کنید؛ تنها نوشتن «سه بخش» برای بازسازی کافی نیست.

خروجی شاملِ عدد صحیح، رشته و tuple است و انتسابِ معمول به فیلدهای dataclass محدود شده است؛ از آن مرز امنیتی نمی‌سازیم. همچنین دادهٔ ویژگی یا تبدیلِ آموختنی وجود ندارد که صحتش با این آزمون تأیید شود. اگر مسیر واقعی استانداردسازی یا انتخاب ویژگی دارد، باید در هر تقسیم فقط روی آموزش برازش شود؛ حفظ گروه جای این کنترل را نمی‌گیرد.

۴. مرجع همهٔ تخصیص‌ها و شاهد توازن ناموفق

جست‌وجوی همهٔ تخصیص‌ها در برابر انتخاب حریصانه
تصویر مفهومی: جست‌وجوی همهٔ تخصیص‌ها در برابر انتخاب حریصانه؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

در نمونهٔ اصلی، A وارد بخش صفر، B وارد یک و C وارد دو می‌شود. D با دو عضو به یک، E با دو عضو به دو و F با یک عضو به صفر می‌روند. در پایان هر بخش پنج نمونه دارد. این پاسخ را دستی در کد ثبت کرده‌ایم؛ جمعِ پانزده و سه بخش نیز نشان می‌دهد بیشینهٔ بار نمی‌تواند کمتر از پنج باشد.

مرجع مستقل برای تعدادهای کوچک، همهٔ تخصیص‌های گروه‌ها به بخش‌ها را می‌سازد، حالت‌های دارای بخش خالی را کنار می‌گذارد و بیشترین بار را کمینه می‌کند. مرتب‌سازیِ حریصانه، نگاشت یا جمع‌های جاریِ مسیر اصلی برای ساخت جوابِ مرجع فراخوانی نمی‌شوند. برای شش گروه و سه بخش، ۷۲۹ تخصیص بررسی می‌شود؛ این‌ها یک گروه بررسی‌اند و شمار آزمایش‌ها را به ۷۲۹ افزایش نمی‌دهند.

نمونهٔ دوم، پنج گروه با تعدادهای هشت، هفت، شش، پنج و چهار و دو بخش دارد. روش ما بارهای هفده و سیزده می‌سازد. اما می‌توان هشت و هفت را یک طرف و شش، پنج و چهار را طرف دیگر گذاشت؛ هر دو پانزده می‌شوند. مرجع با بررسیِ ۳۲ تخصیص همین بهترین بیشینه را تأیید می‌کند. پس روشِ قطعی و معقول، تضمینِ بهترین توازن عمومی نیست.

{
  "python_version": "3.14.5",
  "dependencies": "standard library; sklearn not executed",
  "data_origin": "original synthetic sample IDs and device groups; no features, labels or model fit",
  "group_sample_counts": {
    "A": 4,
    "B": 3,
    "C": 3,
    "D": 2,
    "E": 2,
    "F": 1
  },
  "hand_group_to_fold": [
    [
      "A",
      0
    ],
    [
      "B",
      1
    ],
    [
      "C",
      2
    ],
    [
      "D",
      1
    ],
    [
      "E",
      2
    ],
    [
      "F",
      0
    ]
  ],
  "test_fold_sample_loads": [
    5,
    5,
    5
  ],
  "record_level_alternating_split_shared_groups": [
    "A",
    "B",
    "C",
    "D",
    "E"
  ],
  "balanced_oracle": {
    "assignments_visited_in_one_group": 729,
    "optimal_max_load": 5
  },
  "greedy_counterexample": {
    "group_counts": [
      8,
      7,
      6,
      5,
      4
    ],
    "greedy_loads": [
      17,
      13
    ],
    "optimal_max_load": 15,
    "assignments_visited_in_one_group": 32
  },
  "checks": {
    "hand_group_assignments_and_loads": true,
    "every_split_disjoint_sample_ids_and_group_ids": true,
    "all_samples_tested_exactly_once_across_folds": true,
    "record_reordering_preserves_group_mapping": true,
    "equal_size_and_load_ties_have_declared_deterministic_rule": true,
    "balanced_fixture_matches_exhaustive_729_assignments": true,
    "greedy_counterexample_seventeen_versus_optimum_fifteen": true,
    "record_level_alternation_can_split_device_identity": true,
    "fold_types_bounds_and_too_few_groups_rejected": true,
    "record_container_shape_names_and_duplicate_ids_rejected": true,
    "record_and_group_count_bounds_rejected": true,
    "closed_maximum_domain_keeps_nonempty_folds": true,
    "held_out_index_type_and_bounds_rejected": true,
    "input_preserved_and_result_contains_immutable_values": true
  },
  "unique_check_groups": 14
}

چهارده گروه بررسی واقعی گذشتند: جواب دستی، پوشش و جدایی، تساوی‌ها، جابه‌جایی ورودی، دو شاهدِ مرجع مستقل، تقسیمِ متناوبِ نامناسب، انواع و کران‌ها و حفظ داده. توازنِ شمار نمونه هم تضمینِ توازن کلاس یا شرایط کاری نیست؛ برچسبی نداریم که آن نسبت‌ها را کنترل کنیم. برای سنجشِ مدل باید پس از انتخاب پروتکل، خودِ آموزش و معیار نیز جدا اجرا شوند.

۵. چهار پرسش با پاسخ کامل

توازن تعداد نمونه تضمین بهترین تقسیم نیست
تصویر مفهومی: توازن تعداد نمونه تضمین بهترین تقسیم نیست؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: بخش صفرِ مثال اصلی چه گروه‌هایی دارد؟ پاسخ: A و F، با چهار و یک نمونه؛ پنج نمونه آزمون و ده نمونه آموزش می‌شوند. گروه‌های این دو طرف مشترک نیستند. در تقسیم بعدی نقشِ گروه‌ها عوض می‌شود، ولی هر گروه در همان تقسیم همچنان کامل می‌ماند.

پرسش دوم: چرا اشتراکِ تهیِ شناسهٔ نمونه کافی نیست؟ پاسخ: ردیف‌های متفاوتِ یک دستگاه می‌توانند ویژگی‌های وابسته داشته باشند. تقسیمِ متناوبِ شاهد، هیچ شناسهٔ نمونهٔ مشترکی ندارد، اما پنج گروه را میان دو طرف پخش می‌کند. آزمون فقط هویتِ قراردادی را می‌سنجد و وابستگی آماری را اندازه نمی‌گیرد.

پرسش سوم: آیا بار هفده و سیزده، بهترین پاسخِ ممکن است؟ پاسخ: خیر؛ هشت‌به‌علاوهٔ هفت و شش‌به‌علاوهٔ پنج‌به‌علاوهٔ چهار، پانزده و پانزده می‌دهند. این ضدنمونه دلیل روشنِ پرهیز از ادعای بهینگیِ عمومی است؛ تقسیم حریصانه همچنان شرطِ جدایی گروه را حفظ کرده است.

پرسش چهارم: آیا چهارده بررسی، تعمیمِ مدل به دستگاه تازه را تأیید می‌کند؟ پاسخ: خیر؛ هیچ مدل یا برچسبی اجرا نشده است. بستهٔ آزمایش تکرارپذیر باید داده، نگاشت و ارزیابی واقعی را هم داشته باشد. منابع در ۲۵ مهر ۱۴۰۵ خوانده شدند؛ آموزش پایه است و از آن کیفیت محصول یا نتیجهٔ کاربرد ایمنی استنتاج نمی‌کنیم.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۵ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.