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

۱. امتیاز عیب را از تصمیم و احتمال جدا کنیم

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

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

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

در آزمون ماتریس خطا و معیارهای کلاس، ورودیِ محاسبه خودِ برچسب پیش‌بینی‌شده بود. اینجا هنوز آستانه انتخاب نکرده‌ایم؛ پس accuracy، precision یا هزینهٔ تصمیم از خروجی به دست نمی‌آید. ممکن است یک رتبه‌بندی برای نیاز خاصی مناسب نباشد، حتی اگر این محاسبه درست اجرا شود. معیار باید پاسخِ پرسش مشخص باشد، نه نام دیگری برای پذیرش محصول.

۲. رتبهٔ متوسط، تساوی و جهت امتیاز

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

تعداد ردیف‌های مثبت را P و منفی را N می‌گیریم. امتیازها صعودی مرتب می‌شوند و رتبه‌ها از یک آغاز می‌شوند. به امتیازهای مساوی، میانگین رتبه‌های اشغال‌شده را می‌دهیم؛ اگر دو عضو جایگاه دوم و سوم داشته باشند، رتبهٔ هردو دو‌ونیم است. مرتب‌سازیِ اتفاقیِ اعضای مساوی نباید یکی از دو کلاس را برنده کند.

مجموع رتبه‌های مثبت را R می‌نامیم. عبارت (R - P*(P+1)/2)/(P*N)، امتیاز این درس است. کد برای جلوگیری از نیمهٔ شناور، دو برابرِ رتبهٔ متوسط و مجموع را با عدد صحیح نگه می‌دارد. کم‌کردنِ سهمِ رتبه‌های خودِ مثبت‌ها، شمارِ تقدمِ آن‌ها نسبت به منفی‌ها را باقی می‌گذارد؛ جفتِ برابر، نصف امتیازِ تقدم می‌گیرد. این رابطه را برای دادهٔ خودمان با شمارش مستقیم جفت‌ها بازبینی می‌کنیم.

مثال اصلی برچسب‌های صفر، یک، صفر، یک و امتیازهای یک، دو، دو، سه دارد. رتبه‌ها یک، دو‌ونیم، دو‌ونیم و چهارند. مجموع رتبهٔ دو مثبت شش‌ونیم است؛ با کم‌کردن سه و تقسیم بر چهار، هفت‌هشتم یا ۰٫۸۷۵ می‌گیریم. تساویِ امتیاز دو، یک جفتِ مثبت و منفی دارد؛ آن را برد یا باخت کامل نمی‌شماریم.

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

۳. کد کامل با قرارداد محدود و صریح

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

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

"""Bounded binary ROC-AUC: exact rank sums and separate audit oracles."""
import itertools
import json
import math
import platform
from fractions import Fraction


def auc_rank(labels, scores):
    if type(labels) is not tuple or type(scores) is not tuple:
        raise TypeError("labels and scores must be actual tuples")
    if not 2 <= len(labels) <= 64 or len(scores) != len(labels):
        raise ValueError("equal lengths in 2..64 required")
    if any(type(y) is not int or y not in (0, 1) for y in labels):
        raise ValueError("labels must be exact integer 0 or 1")
    for score in scores:
        if type(score) not in (int, float):
            raise TypeError("score must be exact int or float, not bool")
        if not -1000 <= score <= 1000 or not math.isfinite(score):
            raise ValueError("finite score in [-1000,1000] required")
    positives = sum(labels)
    negatives = len(labels) - positives
    if not positives or not negatives:
        raise ValueError("both reference classes must be present")
    ordered = sorted(zip(scores, labels))
    twice_positive_rank_sum = 0
    start = 0
    while start < len(ordered):
        stop = start + 1
        while stop < len(ordered) and ordered[stop][0] == ordered[start][0]:
            stop += 1
        # One-based ranks start+1 through stop: twice their mean is their sum.
        twice_mean_rank = start + 1 + stop
        twice_positive_rank_sum += twice_mean_rank * sum(
            label for _, label in ordered[start:stop]
        )
        start = stop
    twice_u = twice_positive_rank_sum - positives * (positives + 1)
    return Fraction(twice_u, 2 * positives * negatives)


def pairwise_reference(labels, scores):
    # Independent enumeration: no sorting, ranks or call to auc_rank.
    positive_scores = [scores[i] for i in range(len(labels)) if labels[i] == 1]
    negative_scores = [scores[i] for i in range(len(labels)) if labels[i] == 0]
    twice_credit = sum(
        2 if positive > negative else 1 if positive == negative else 0
        for positive in positive_scores for negative in negative_scores
    )
    return Fraction(twice_credit, 2 * len(positive_scores) * len(negative_scores))


def roc_area_reference(labels, scores):
    # Independently count every threshold group; exact trapezoid area.
    positives = sum(labels)
    negatives = len(labels) - positives
    previous_x = previous_y = Fraction(0)
    area = Fraction(0)
    for threshold in sorted(set(scores), reverse=True):
        tp = sum(y == 1 and s >= threshold for y, s in zip(labels, scores))
        fp = sum(y == 0 and s >= threshold for y, s in zip(labels, scores))
        x, y = Fraction(fp, negatives), Fraction(tp, positives)
        area += (x - previous_x) * (y + previous_y) / 2
        previous_x, previous_y = x, y
    return area


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


def run_checks():
    labels, scores = (0, 1, 0, 1), (1, 2, 2, 3)
    checks = {}
    checks["literal_hand_auc_seven_eighths"] = auc_rank(labels, scores) == Fraction(7, 8)
    checks["all_tied_scores_half_credit"] = auc_rank(labels, (2, 2, 2, 2)) == Fraction(1, 2)
    checks["perfect_and_reversed_rankings"] = (
        auc_rank((0, 0, 1, 1), (0, 1, 2, 3)) == 1
        and auc_rank((0, 0, 1, 1), (3, 2, 1, 0)) == 0
    )
    pair_ok = roc_ok = True
    cases = 0
    for size in range(2, 5):
        for ys in itertools.product((0, 1), repeat=size):
            if not 0 < sum(ys) < size:
                continue
            for ss in itertools.product((-1, 0, 1), repeat=size):
                result = auc_rank(ys, ss)
                pair_ok = pair_ok and result == pairwise_reference(ys, ss)
                roc_ok = roc_ok and result == roc_area_reference(ys, ss)
                cases += 1
    checks["independent_pairwise_1314_cases_one_group"] = pair_ok and cases == 1314
    checks["independent_roc_trapezoids_1314_cases_one_group"] = roc_ok and cases == 1314
    permutations = ((3, 0, 2, 1), (1, 3, 0, 2), (2, 1, 3, 0))
    checks["paired_row_permutation_preserves_auc"] = all(
        auc_rank(tuple(labels[i] for i in order), tuple(scores[i] for i in order))
        == Fraction(7, 8) for order in permutations
    )
    checks["chosen_positive_affine_score_change_preserves_order"] = (
        auc_rank(labels, tuple(2 * s + 10 for s in scores)) == Fraction(7, 8)
    )
    checks["score_direction_and_label_complements_include_ties"] = (
        auc_rank(labels, tuple(-s for s in scores)) == Fraction(1, 8)
        and auc_rank(tuple(1 - y for y in labels), scores) == Fraction(1, 8)
    )
    checks["repeating_all_rows_preserves_auc"] = auc_rank(labels * 3, scores * 3) == Fraction(7, 8)
    checks["exact_float_ties_not_isclose_and_signed_zero"] = (
        auc_rank((0, 1), (1.0, math.nextafter(1.0, math.inf))) == 1
        and auc_rank((0, 1), (-0.0, 0.0)) == Fraction(1, 2)
    )
    checks["both_classes_required_not_zero_substitution"] = all(
        rejected(auc_rank, ys, (0,) * len(ys)) for ys in ((0, 0), (1, 1))
    )
    checks["container_lengths_and_label_contract_errors"] = all([
        rejected(auc_rank, list(labels), scores), rejected(auc_rank, labels, list(scores)),
        rejected(auc_rank, (), ()), rejected(auc_rank, (0,), (1,)),
        rejected(auc_rank, labels, (1, 2)), rejected(auc_rank, (0, 1) * 33, (0, 1) * 33),
        rejected(auc_rank, (False, 1), (0, 1)), rejected(auc_rank, (0, 2), (0, 1)),
        rejected(auc_rank, (0.0, 1), (0, 1)), rejected(auc_rank, ("0", 1), (0, 1)),
    ])
    class FloatSubclass(float):
        pass
    checks["finite_score_type_and_range_contract_errors"] = all(
        rejected(auc_rank, (0, 1), (0, value))
        for value in (True, "1", 1j, FloatSubclass(1), math.nan, math.inf,
                      -math.inf, 1001, -1001, 10 ** 500)
    )
    before = labels, scores
    boundary = auc_rank((0, 1) * 32, (-1000, 1000) * 32)
    checks["inclusive_bounds_64_rows_and_original_inputs_preserved"] = (
        boundary == 1 and (labels, scores) == before and auc_rank(labels, scores) == Fraction(7, 8)
    )
    assert all(type(v) is bool and v for v in checks.values())
    return {
        "python": platform.python_version(), "dependencies": "standard library only",
        "positive_class": 1, "score_direction": "larger score favors reference class 1",
        "tie_policy": "exact equality of accepted Python numbers; one-half pair credit",
        "hand_labels": labels, "hand_scores": scores,
        "hand_auc_fraction": str(auc_rank(labels, scores)),
        "hand_auc_decimal": float(auc_rank(labels, scores)),
        "hand_pairwise_twice_credit": 7, "hand_pair_count": 4,
        "hand_roc_points_fpr_tpr": [[0, 0], [0, 0.5], [0.5, 1], [1, 1]],
        "reversed_score_auc_fraction": str(auc_rank(labels, tuple(-s for s in scores))),
        "oracle_cases_inside_each_of_two_groups": cases,
        "checks": checks, "unique_check_groups": len(checks), "all_passed": True,
        "limits": ["Unweighted full binary AUC only, not multiclass or partial AUC",
                   "Synthetic scores; no training, confidence interval, calibration or threshold selection",
                   "Exact rank arithmetic does not make reference labels or population generalization valid"],
    }


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

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

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

فایل با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. scikit-learn در آزمایش اجرا نشده و مرجعِ تعریف‌هاست. چهارده گروه نام‌دار ثبت شده‌اند؛ بعضی گروه‌ها چند شاهدِ مرزی دارند. تعداد ورودی‌های یک حلقه را به تعداد گروه‌های مستقل اضافه نمی‌کنیم و موفقیت آزمون‌های محدود را اثباتِ تمام امتیازهای ممکن نمی‌نامیم.

۴. مرجع جفتی و سطح ذوزنقه‌ای ROC

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

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

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

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

{
  "python": "3.14.5",
  "dependencies": "standard library only",
  "positive_class": 1,
  "score_direction": "larger score favors reference class 1",
  "tie_policy": "exact equality of accepted Python numbers; one-half pair credit",
  "hand_labels": [
    0,
    1,
    0,
    1
  ],
  "hand_scores": [
    1,
    2,
    2,
    3
  ],
  "hand_auc_fraction": "7/8",
  "hand_auc_decimal": 0.875,
  "hand_pairwise_twice_credit": 7,
  "hand_pair_count": 4,
  "hand_roc_points_fpr_tpr": [
    [
      0,
      0
    ],
    [
      0,
      0.5
    ],
    [
      0.5,
      1
    ],
    [
      1,
      1
    ]
  ],
  "reversed_score_auc_fraction": "1/8",
  "oracle_cases_inside_each_of_two_groups": 1314,
  "checks": {
    "literal_hand_auc_seven_eighths": true,
    "all_tied_scores_half_credit": true,
    "perfect_and_reversed_rankings": true,
    "independent_pairwise_1314_cases_one_group": true,
    "independent_roc_trapezoids_1314_cases_one_group": true,
    "paired_row_permutation_preserves_auc": true,
    "chosen_positive_affine_score_change_preserves_order": true,
    "score_direction_and_label_complements_include_ties": true,
    "repeating_all_rows_preserves_auc": true,
    "exact_float_ties_not_isclose_and_signed_zero": true,
    "both_classes_required_not_zero_substitution": true,
    "container_lengths_and_label_contract_errors": true,
    "finite_score_type_and_range_contract_errors": true,
    "inclusive_bounds_64_rows_and_original_inputs_preserved": true
  },
  "unique_check_groups": 14,
  "all_passed": true,
  "limits": [
    "Unweighted full binary AUC only, not multiclass or partial AUC",
    "Synthetic scores; no training, confidence interval, calibration or threshold selection",
    "Exact rank arithmetic does not make reference labels or population generalization valid"
  ]
}

برای طول‌های دو تا چهار، تمام برچسب‌های دارای دو کلاس و تمام امتیازهای منفی یک، صفر و یک بررسی شده‌اند: ۱۳۱۴ حالت در گروهِ مرجع جفتی و همان ۱۳۱۴ حالت در گروهِ ذوزنقه‌ای. جابه‌جاییِ مشترک ردیف‌ها، تکرارِ کل داده، تغییر جهت امتیاز و یک تبدیلِ افزایشیِ مشخص نیز بررسی شده‌اند. تبدیلِ شناور دلخواه ممکن است مقدارهای متفاوت را یکی کند؛ از شاهدِ صحیحِ کوچک، ناورداییِ بیتیِ همگانی نتیجه نمی‌گیریم.

۵. چهار پرسش و مرز پذیرش سامانه

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

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

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

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

پرسش چهارم: آیا ۰٫۸۷۵ آستانهٔ استقرار را تعیین می‌کند؟ پاسخ: خیر؛ هزینهٔ خطا، نرخ رخداد و نقطهٔ کار تعیین نشده‌اند. این عدد حتی کالیبراسیون احتمال را اندازه نمی‌گیرد. آزمون مرز پیش‌پردازش آموزش نیز یادآور می‌شود محاسبهٔ درست، استقلال داده را ثابت نمی‌کند. منابع در ۲۶ مهر ۱۴۰۵ با مستندات scikit-learn 1.9.1 و Python 3.14.8 خوانده شدند؛ تاریخ انتشار اصلی صفحات مشخص نیست و این مطلب آموزش پایه است.

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

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