فهرست مقاله
در ارزیابی یک دستهبندِ عیب، عدد بزرگی مثل accuracy میتواند توجه را از کلاس کمنمونه دور کند. پیش از انتخاب معیار پذیرش، باید بدانیم هر عدد چه چیزی را میشمارد و با کلاسِ بدون نمونه چه میکند. در این درس سه نام healthy، bearing و winding صرفاً برچسبهای ساختگیِ سالم، یاتاقان و سیمپیچاند. هیچ حسگر، موتور واقعی یا مدل آموزشدیدهای در آزمایش نداریم؛ هدف، آزمودن محاسبه و انتخاب آگاهانهٔ معیار است.
۱. مسئلهٔ تکبرچسبی و فضای کلاس ثابت

برای هر مشاهده دقیقاً یک برچسب مرجع و یک پیشبینی داریم. ترتیب فهرست labels را قبل از شمارش تعیین میکنیم و تمام برچسبهای مشاهدهشده باید داخل آن باشند. این محدودیت اجازه نمیدهد یک پیشبینی ناشناخته بیصدا از ارزیابی حذف شود. دادهٔ بدون برچسب، امتناع از پاسخ یا برچسبهای همزمانِ چندگانه در قرارداد فعلی پشتیبانی نمیشوند؛ افزودنشان نیازمند تعریف معیار تازه است.
کلاسهای اعلامشده حتی اگر در این نمونه دیده نشوند، باقی میمانند. ثابتماندن این فهرست برای مقایسهٔ گزارشها مهم است: اگر هر بار فقط نامهای حاضر در داده را انتخاب کنیم، مخرج میانگین macro میتواند تغییر کند. نبود نمونهٔ یک کلاس، شاهدی دربارهٔ توانایی دستهبند در آن کلاس نیست. همراه هر معیار باید تعداد مشاهدات و سیاست برخورد با چنین کلاسهایی را هم ببینیم.
واحد ماتریس، تعداد مشاهده است؛ precision و recall و F1 بیبعدند. این نسبتها احتمالِ کالیبرهشدهٔ خرابی یا اطمینانِ پیشبینیِ یک نمونه نیستند. در مثال هشتتایی، چهار مرجع سالم، دو مرجع یاتاقان و دو مرجع سیمپیچ داریم. پیشبینیها را خودمان از قبل نوشتهایم؛ پس عبارت «نتیجهٔ مدل» برای این خروجی، ادعای درستی نخواهد بود.
۲. جهت ماتریس خطا و شمارش مستقل هر کلاس

طبق قرارداد ماتریس خطای scikit-learn، سطر را مرجع و ستون را پیشبینی میگیریم. خانهٔ سطر سالم و ستون یاتاقان، تعداد نمونههای سالمی است که یاتاقان پیشبینی شدهاند. محورهای جابهجا همان تفسیر را ندارند. ماتریس خام مثال، سطرهای [۳،۱،۰]، [۱،۱،۰] و [۱،۰،۱] دارد و جمع همهٔ خانهها هشت است.
برای کلاس سالم، TP برابر سه است؛ دو مشاهدهٔ غیرسالم هم سالم پیشبینی شدهاند، پس FP دو است. یک سالم با نام دیگری آمده، بنابراین FN یک است. precision برابر ۳/۵ و recall برابر ۳/۴ میشود. F1 را از ۲TP تقسیم بر ۲TP+FP+FN میسازیم؛ در این کلاس ۶/۹ یا ۲/۳ است. support نیز چهار، یعنی تعداد مرجعِ سالم است.
مرجع precision و recall تعریف این نسبتها و میانگینها را ارائه میکند. macro، میانگینِ بدون وزنِ معیار هر کلاس است؛ macro-F1 میانگین سه F1 خواهد بود و نباید آن را از میانگین precision و میانگین recall دوباره محاسبه کرد. micro ابتدا TP و FP و FN همهٔ کلاسها را جمع میکند و سپس نسبت را میسازد.
۳. کد کامل و معیار تعریفنشده

کد استاندارد پایتون با نسخهٔ 3.14.5 اجرا شده است؛ scikit-learn در این آزمایش اجرا نشده و مرجع تعریفهاست. ورودی فقط list یا tuple محدود با رشتههای دقیق، غیرخالی و بدون فاصلهٔ ابتدا و انتهاست. فضای برچسب تکراری، مشاهدهٔ ناشناخته، طول ناهماهنگ و دادهٔ خالی رد میشود. bool و NaN نیز برچسب رشتهای معتبر نیستند و خودکار تبدیل نمیشوند.
"""Original synthetic single-label multiclass acceptance-metric lesson."""
from fractions import Fraction
import itertools
import json
import math
import platform
def _labels(value, name):
if type(value) not in (list, tuple):
raise TypeError(name + ' must be a finite list or tuple')
result = tuple(value)
if any(type(x) is not str or not x or x.strip() != x for x in result):
raise TypeError(name + ' entries must be exact nonempty trimmed str')
return result
def classification_report(actual, predicted, labels):
"""Rows actual, columns predicted; all labels included, zero division -> 0."""
universe = _labels(labels, 'labels')
if not universe or len(set(universe)) != len(universe):
raise ValueError('labels must be nonempty and unique')
actual, predicted = _labels(actual, 'actual'), _labels(predicted, 'predicted')
if not actual or len(actual) != len(predicted):
raise ValueError('nonempty aligned observations required')
index = {name: i for i, name in enumerate(universe)}
if any(x not in index for x in actual + predicted):
raise ValueError('observation outside explicit label universe')
matrix = [[0 for _ in universe] for _ in universe]
for truth, guess in zip(actual, predicted):
matrix[index[truth]][index[guess]] += 1
per_class = []
for i, label in enumerate(universe):
tp = matrix[i][i]
support = sum(matrix[i])
predicted_count = sum(row[i] for row in matrix)
fp, fn = predicted_count - tp, support - tp
pden, rden, fden = tp + fp, tp + fn, 2 * tp + fp + fn
per_class.append({'label': label, 'support': support, 'tp': tp, 'fp': fp, 'fn': fn,
'precision': tp / pden if pden else 0.0,
'recall': tp / rden if rden else 0.0,
'f1': 2 * tp / fden if fden else 0.0,
'undefined': {'precision': pden == 0, 'recall': rden == 0, 'f1': fden == 0}})
macro = {key: math.fsum(row[key] for row in per_class) / len(universe)
for key in ('precision', 'recall', 'f1')}
tp, fp, fn = (sum(row[key] for row in per_class) for key in ('tp', 'fp', 'fn'))
micro = {'precision': tp / (tp + fp), 'recall': tp / (tp + fn),
'f1': 2 * tp / (2 * tp + fp + fn)}
return {'labels': list(universe), 'n': len(actual), 'matrix': matrix,
'per_class': per_class, 'macro': macro, 'micro': micro,
'accuracy': sum(matrix[i][i] for i in range(len(universe))) / len(actual)}
def _direct_fraction_reference(actual, predicted, labels):
"""Independent one-versus-rest scans, no matrix or production helper."""
answer = []
for label in labels:
tp = sum(a == label and p == label for a, p in zip(actual, predicted))
fp = sum(a != label and p == label for a, p in zip(actual, predicted))
fn = sum(a == label and p != label for a, p in zip(actual, predicted))
answer.append(tuple(Fraction(num, den) if den else Fraction(0)
for num, den in ((tp, tp + fp), (tp, tp + fn), (2 * tp, 2 * tp + fp + fn))))
macro = tuple(sum(row[i] for row in answer) / len(labels) for i in range(3))
accuracy = Fraction(sum(a == p for a, p in zip(actual, predicted)), len(actual))
return answer, macro, accuracy
def _same_metrics(left, right):
return all(math.isclose(left[avg][key], right[avg][key], rel_tol=1e-14, abs_tol=1e-15)
for avg in ('macro', 'micro') for key in ('precision', 'recall', 'f1'))
def _raises(expected, function, *args):
try:
function(*args)
except expected:
return True
return False
def run_example():
labels = ['healthy', 'bearing', 'winding']
actual = ['healthy'] * 4 + ['bearing'] * 2 + ['winding'] * 2
predicted = ['healthy', 'healthy', 'healthy', 'bearing', 'bearing', 'healthy', 'healthy', 'winding']
report = classification_report(actual, predicted, labels)
original_inputs = (actual[:], predicted[:], labels[:])
direct, macro, accuracy = _direct_fraction_reference(actual, predicted, labels)
absent = classification_report(['healthy'], ['healthy'], labels)
no_prediction = classification_report(['healthy', 'bearing'], ['healthy', 'healthy'], labels)
reordered = classification_report(actual, predicted, list(reversed(labels)))
reordered_rows = {row['label']: row for row in reordered['per_class']}
permuted = classification_report(actual[::-1], predicted[::-1], labels)
repeated = classification_report(actual * 3, predicted * 3, labels)
swapped = classification_report(predicted, actual, labels)
left = classification_report(actual[:4], predicted[:4], labels)
right = classification_report(actual[4:], predicted[4:], labels)
exhaustive_results = []
for size in (1, 2, 3):
sequences = list(itertools.product(labels, repeat=size))
for truth in sequences:
for guess in sequences:
result = classification_report(truth, guess, labels)
rows, expected_macro, expected_accuracy = _direct_fraction_reference(truth, guess, labels)
matches = all(math.isclose(result['per_class'][i][key], float(rows[i][j]), abs_tol=1e-15)
for i in range(3) for j, key in enumerate(('precision', 'recall', 'f1')))
matches = matches and all(math.isclose(result['macro'][key], float(expected_macro[j]), abs_tol=1e-15)
for j, key in enumerate(('precision', 'recall', 'f1')))
exhaustive_results.append(matches and all(result['micro'][k] == float(expected_accuracy)
for k in ('precision', 'recall', 'f1')))
imbalanced = classification_report(['healthy'] * 90 + ['bearing'] * 10, ['healthy'] * 100,
['healthy', 'bearing'])
accuracy_rule = imbalanced['accuracy'] >= 0.85
recall_rule = imbalanced['per_class'][1]['recall'] >= 0.80
invalid_universes = [[], ['healthy', 'healthy'], [''], [True], [float('nan')], 'healthy']
invalid_observations = [([True], ['healthy']), ([1], ['healthy']),
([float('nan')], ['healthy']), (['healthy'], [None]),
('healthy', ['healthy']), ([' healthy'], ['healthy'])]
checks = {
'hand_rows_actual_columns_predicted': report['matrix'] == [[3, 1, 0], [1, 1, 0], [1, 0, 1]],
'hand_healthy_counts_and_ratios': report['per_class'][0]['tp'] == 3 and report['per_class'][0]['fp'] == 2
and report['per_class'][0]['fn'] == 1 and report['per_class'][0]['precision'] == 3 / 5
and report['per_class'][0]['recall'] == 3 / 4 and report['per_class'][0]['f1'] == 2 / 3,
'hand_macro_is_mean_of_class_metrics': math.isclose(report['macro']['precision'], 7 / 10)
and math.isclose(report['macro']['recall'], 7 / 12) and math.isclose(report['macro']['f1'], 11 / 18),
'micro_equals_accuracy_only_under_our_single_label_full_universe': report['accuracy'] == 5 / 8
and all(v == 5 / 8 for v in report['micro'].values()),
'absent_class_kept_with_undefined_flags_and_zero_policy': absent['per_class'][2]['support'] == 0
and absent['per_class'][2]['undefined'] == {'precision': True, 'recall': True, 'f1': True}
and absent['macro'] == {'precision': 1 / 3, 'recall': 1 / 3, 'f1': 1 / 3},
'unsupported_prediction_precision_and_observed_recall_distinguished': no_prediction['per_class'][1]['undefined']
== {'precision': True, 'recall': False, 'f1': False}
and no_prediction['per_class'][1]['precision'] == no_prediction['per_class'][1]['recall'] == 0,
'label_order_changes_axes_not_named_class_metrics': all(reordered_rows[r['label']] == r for r in report['per_class'])
and _same_metrics(reordered, report),
'observation_order_invariance': permuted == report,
'repeat_dataset_scales_counts_not_ratios': _same_metrics(repeated, report)
and repeated['matrix'] == [[3 * n for n in row] for row in report['matrix']],
'swap_actual_prediction_transposes_matrix_and_exchanges_ratios': swapped['matrix'] == [list(x) for x in zip(*report['matrix'])]
and all(swapped['per_class'][i]['precision'] == report['per_class'][i]['recall']
and swapped['per_class'][i]['recall'] == report['per_class'][i]['precision'] for i in range(3)),
'chunk_counts_add_but_macros_are_not_pooled_by_simple_mean': report['matrix']
== [[left['matrix'][i][j] + right['matrix'][i][j] for j in range(3)] for i in range(3)]
and not math.isclose((left['macro']['f1'] + right['macro']['f1']) / 2, report['macro']['f1']),
'independent_direct_fraction_reference_819_cases': len(exhaustive_results) == 819 and all(exhaustive_results),
'synthetic_imbalance_acceptance_rules_disagree': accuracy_rule is True and recall_rule is False
and imbalanced['accuracy'] == 0.9 and imbalanced['per_class'][1]['recall'] == 0,
'empty_observations_rejected': _raises(ValueError, classification_report, [], [], labels),
'unequal_observation_lengths_rejected': _raises(ValueError, classification_report, ['healthy'], [], labels),
'invalid_label_universes_rejected': all(_raises((TypeError, ValueError), classification_report,
['healthy'], ['healthy'], bad) for bad in invalid_universes),
'unknown_or_invalid_observation_labels_rejected': _raises(ValueError, classification_report, ['other'], ['healthy'], labels)
and all(_raises((TypeError, ValueError), classification_report, a, p, labels) for a, p in invalid_observations),
'inputs_unchanged_and_hand_reference_exact_fractions': (actual, predicted, labels) == original_inputs
and direct == [(Fraction(3, 5), Fraction(3, 4), Fraction(2, 3)),
(Fraction(1, 2), Fraction(1, 2), Fraction(1, 2)),
(Fraction(1), Fraction(1, 2), Fraction(2, 3))]
and macro == (Fraction(7, 10), Fraction(7, 12), Fraction(11, 18)) and accuracy == Fraction(5, 8),
}
assert len(checks) == 18 and all(type(v) is bool and v for v in checks.values())
return {'runtime': {'python': platform.python_version(), 'libraries': ['stdlib only; scikit-learn not executed']},
'contract': {'task': 'synthetic single-label multiclass, all labels mandatory', 'axes': 'rows actual, columns predicted',
'zero_division': 'return0 and record undefined flag; retain absent classes in macro',
'units': 'counts observations; scores dimensionless, not probabilities'},
'metrics': {'hand_report': report, 'hand_macro_exact': [str(x) for x in macro],
'independent_reference_cases': len(exhaustive_results),
'imbalance': {'n': 100, 'accuracy': imbalanced['accuracy'], 'bearing_recall': imbalanced['per_class'][1]['recall'],
'illustrative_accuracy_at_least_0_85': accuracy_rule,
'illustrative_bearing_recall_at_least_0_80': recall_rule}},
'checks': checks,
'limitations': ['No trained model, real faults, sensor data, confidence interval, cost/risk optimum or deployment approval.',
'One label per observation, no sample weights, multilabel, abstentions or excluded actual/predicted labels.',
'Zero-filled undefined scores are policy values, not observed evidence; class universe affects macro.',
'Acceptance thresholds are arbitrary teaching rules fixed for this synthetic fixture, not engineering safety requirements.']}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
اگر هیچ پیشبینیِ یک کلاس وجود نداشته باشد، مخرج precision آن صفر است. اگر هیچ مرجع آن کلاس نباشد، recall تعریف نشده است. سیاست ما برای خروجی عددی، صفر قراردادی است و همزمان پرچم undefined را ذخیره میکنیم. این صفر معادل اندازهگیریِ عملکرد صفر نیست؛ پرچم، علت عدد را حفظ میکند. کلاس بدون مرجع ولی دارای پیشبینی، با کلاس کاملاً غایب نیز یکسان نیست.
کد تمام کلاسهای اعلامشده را در macro نگه میدارد؛ حذف نسبتهای تعریفنشده یا جایگزینی یک بهجای صفر، عدد دیگری میسازد. در نمونهٔ تکمشاهدهای که تنها سالم درست است، با همین سه کلاس، macro هر معیار یکسوم میشود؛ accuracy یک است. این تفاوت تناقض نیست: یکی میانگینِ کلاسهای ثابت و دیگری نسبتِ مشاهدههای درست است.
۴. جواب دستی، مرجع کسری و تصمیم پذیرش

در مثال اصلی، پنج پیشبینی از هشت درستاند؛ accuracy برابر ۵/۸ است. در مسئلهٔ تکبرچسبی با پوشش تمام کلاسها، micro precision و recall و F1 نیز همین مقدارند. macro precision برابر ۷/۱۰، macro recall برابر ۷/۱۲ و macro-F1 برابر ۱۱/۱۸ میشود. نمایش اعشاری ممکن است رقم پایانی اضافه داشته باشد؛ مرجع کسریِ جدا، جواب دقیقِ این شمارشهای کوچک را نگه میدارد.
{
"runtime": {
"python": "3.14.5",
"libraries": [
"stdlib only; scikit-learn not executed"
]
},
"contract": {
"task": "synthetic single-label multiclass, all labels mandatory",
"axes": "rows actual, columns predicted",
"zero_division": "return0 and record undefined flag; retain absent classes in macro",
"units": "counts observations; scores dimensionless, not probabilities"
},
"metrics": {
"hand_report": {
"labels": [
"healthy",
"bearing",
"winding"
],
"n": 8,
"matrix": [
[
3,
1,
0
],
[
1,
1,
0
],
[
1,
0,
1
]
],
"per_class": [
{
"label": "healthy",
"support": 4,
"tp": 3,
"fp": 2,
"fn": 1,
"precision": 0.6,
"recall": 0.75,
"f1": 0.6666666666666666,
"undefined": {
"precision": false,
"recall": false,
"f1": false
}
},
{
"label": "bearing",
"support": 2,
"tp": 1,
"fp": 1,
"fn": 1,
"precision": 0.5,
"recall": 0.5,
"f1": 0.5,
"undefined": {
"precision": false,
"recall": false,
"f1": false
}
},
{
"label": "winding",
"support": 2,
"tp": 1,
"fp": 0,
"fn": 1,
"precision": 1.0,
"recall": 0.5,
"f1": 0.6666666666666666,
"undefined": {
"precision": false,
"recall": false,
"f1": false
}
}
],
"macro": {
"precision": 0.7000000000000001,
"recall": 0.5833333333333334,
"f1": 0.611111111111111
},
"micro": {
"precision": 0.625,
"recall": 0.625,
"f1": 0.625
},
"accuracy": 0.625
},
"hand_macro_exact": [
"7/10",
"7/12",
"11/18"
],
"independent_reference_cases": 819,
"imbalance": {
"n": 100,
"accuracy": 0.9,
"bearing_recall": 0.0,
"illustrative_accuracy_at_least_0_85": true,
"illustrative_bearing_recall_at_least_0_80": false
}
},
"checks": {
"hand_rows_actual_columns_predicted": true,
"hand_healthy_counts_and_ratios": true,
"hand_macro_is_mean_of_class_metrics": true,
"micro_equals_accuracy_only_under_our_single_label_full_universe": true,
"absent_class_kept_with_undefined_flags_and_zero_policy": true,
"unsupported_prediction_precision_and_observed_recall_distinguished": true,
"label_order_changes_axes_not_named_class_metrics": true,
"observation_order_invariance": true,
"repeat_dataset_scales_counts_not_ratios": true,
"swap_actual_prediction_transposes_matrix_and_exchanges_ratios": true,
"chunk_counts_add_but_macros_are_not_pooled_by_simple_mean": true,
"independent_direct_fraction_reference_819_cases": true,
"synthetic_imbalance_acceptance_rules_disagree": true,
"empty_observations_rejected": true,
"unequal_observation_lengths_rejected": true,
"invalid_label_universes_rejected": true,
"unknown_or_invalid_observation_labels_rejected": true,
"inputs_unchanged_and_hand_reference_exact_fractions": true
},
"limitations": [
"No trained model, real faults, sensor data, confidence interval, cost/risk optimum or deployment approval.",
"One label per observation, no sample weights, multilabel, abstentions or excluded actual/predicted labels.",
"Zero-filled undefined scores are policy values, not observed evidence; class universe affects macro.",
"Acceptance thresholds are arbitrary teaching rules fixed for this synthetic fixture, not engineering safety requirements."
]
}
مرجع مستقل برای هر کلاس، جفتهای مرجع و پیشبینی را مستقیم مرور میکند و از ماتریس یا تابع اصلی استفاده نمیکند. نسبتهای آن با Fraction محاسبه میشوند. برای دنبالههای طول یک تا سه روی سه کلاس، ۸۱۹ جفت دنباله بررسی شده است؛ همهٔ این موارد یک گروه از هجده گروه نامدارند. جابهجایی ترتیب مشاهدهها، تغییر ترتیب کلاسها و تکرار داده نیز با ویژگیهای مناسب آزموده شدهاند.
جمعکردن ماتریس بستههای داده درست است، اما میانگین سادهٔ macro-F1 بستهها لزوماً macro-F1 دادهٔ تجمیعی نیست. برای گزارش نهایی، شمارشها را تجمیع و نسبتها را دوباره محاسبه میکنیم. با عوضکردن جای مرجع و پیشبینی، ماتریس ترانهاده و precision و recall هر کلاس جابهجا میشوند؛ این آزمون میتواند اشتباه جهت محور را آشکار کند، ولی کیفیت مرجعگذاری انسانی را تأیید نمیکند.
نمونهٔ دوم صد برچسب ساختگی دارد: نود سالم و ده یاتاقان، با پیشبینی سالم برای همه. accuracy نود درصد و recall یاتاقان صفر است. شرط آموزشیِ «accuracy دستکم ۰٫۸۵» میگذرد و شرطِ «recall یاتاقان دستکم ۰٫۸۰» رد میشود. آستانهها دلخواهِ همین تمریناند؛ معیار ایمنی یا اجازهٔ استقرار صنعتی نیستند. انتخاب واقعی به هزینهٔ خطا، دادهٔ نماینده و نیازِ مسئله وابسته است.
آزمون IoU و Dice ماسک تصویر نمونهٔ دیگری از لزوم تعریف معیار است؛ اینجا مشاهده، برچسب کامل یک نمونه است، نه پیکسل. آزمون گرادیان مدل خطی نیز صحت یک محاسبه را از ارزیابی کاربرد جدا میکند. منابع در ۲۴ مهر ۱۴۰۵، با مستندات scikit-learn نسخهٔ 1.9.1 خوانده شدند؛ تاریخ اصلی انتشار صفحات مشخص نیست و این درس خبر پژوهشی تازه نیست.
۵. چهار پرسش با پاسخ کامل

پرسش اول: در کلاس سالمِ مثال، precision و recall چندند؟ پاسخ: با TP سه، FP دو و FN یک، precision برابر ۳/۵ و recall برابر ۳/۴ است؛ عوضکردن این دو نسبت معنای خطا را تغییر میدهد.
پرسش دوم: macro-F1 را از دو میانگین دیگر بسازیم؟ پاسخ: خیر؛ F1 هر کلاس را جدا محاسبه و میانگین میگیریم. جواب این داده ۱۱/۱۸ است، نه لزوماً میانگین هارمونیک macro precision و macro recall.
پرسش سوم: کلاس کاملاً غایب را حذف کنیم؟ پاسخ: در این قرارداد خیر؛ با support صفر، نسبتهای تعریفنشده، صفر قراردادی و پرچم روشن باقی میماند. تغییر این سیاست باید صریح باشد و مقایسهٔ گزارشها را تحت تأثیر قرار میدهد.
پرسش چهارم: نود درصد accuracy یعنی ده عیب یاتاقان پیدا شدهاند؟ پاسخ: خیر؛ در نمونهٔ نامتوازن ما هیچکدام پیدا نشده و recall آن کلاس صفر است. پذیرش با یک عدد کلی باید با نیازِ مشخصِ هر کلاس و محدودیت داده بررسی شود.
منابع و مطالعهٔ بیشتر
- scikit-learn؛ جهت سطر و ستون confusion_matrix
- scikit-learn؛ precision، recall، macro، micro و zero_division
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

