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

جریانها را به ترتیب منفی دو، منفی یک، صفر، یک و دو میلیآمپر و ولتاژها را منفی ۳۳، منفی ۱۶، ۳، ۲۴ و ۴۷ میلیولت تعریف میکنیم. ترتیب زوجها اهمیت دارد؛ هر جریان باید با ولتاژ متناظر خودش بماند. جریان منفی در این مثال صرفاً انتخاب جهت مرجع است. بازهٔ اعداد، مشخصات یک حسگر یا محدودهٔ مجاز یک قطعه نیست.
مدل تألیفی voltage_mv = R*current_ma + offset_mv دو پارامتر دارد. نسبت میلیولت به میلیآمپر برابر ولت به آمپر است؛ بنابراین واحد شیب اهم و واحد عرض از مبدأ میلیولت خواهد بود. تفسیر شیب به عنوان مقاومت، به همین مدل وابسته است. وجود عرض از مبدأ به معنی اثبات خطای یک دستگاه واقعی نیست؛ اینجا آن را بخشی از مسئلهٔ آموزشی قرار دادهایم.
تابع فقط فهرست یا تاپل دقیق، با تعداد برابر و حداقل دو عضو میپذیرد. هر عضو باید دقیقاً int یا float پایتون و پس از تبدیل متناهی باشد. مقدار بولی، رشتهٔ عددی، عدد مختلط، NaN، بینهایت و عدد صحیحی که تبدیلش سرریز میکند رد میشوند. این قرارداد عمداً محدود است؛ پشتیبانی از آرایه، دادهٔ گمشده یا وزن نمونهها نیازمند تصمیم و آزمون جداست.
۲. جواب مستقل و معنای باقیمانده

ماتریس طراحی دو ستون دارد: جریان و ستون یکها. برازش کمترین مربعات، مجموع مربع فاصلهٔ داده از مدل را کمینه میکند؛ این تعریف در راهنمای مدل خطی NIST توضیح داده شده است. مسئلهٔ اینجا بدون وزن است و عرض از مبدأ آزاد دارد. نتیجه را با یک برازش دوم از همان کتابخانه مرجعسازی نمیکنیم، چون اشتباه مشترک ممکن است در هر دو تکرار شود.
برای این اعداد، جمع جریان صفر، مجموع مربع جریانها ۱۰ و مجموع حاصلضرب جریان در ولتاژ ۲۰۰ است. پس شیب مرجع ۲۰ اهم و عرض از مبدأ، یعنی میانگین ولتاژها، ۵ میلیولت میشود. پیشبینیهای دستی منفی ۳۵، منفی ۱۵، ۵، ۲۵ و ۴۵ میلیولت هستند. با تعریف «داده منهای پیشبینی»، باقیماندهها به ترتیب ۲، منفی ۱، منفی ۲، منفی ۱ و ۲ میلیولتاند.
جمع باقیماندهها صفر است، اما مجموع مربع آنها ۱۴ میلیولت مربع میشود. بنابراین خنثی شدن علامتها شاهد خطای صفر نیست. حاصلضرب داخلی جریان و باقیمانده نیز صفر میشود. این دو رابطه در همین برازش با ستون ثابت و جریان مستقل، کنترل مکملاند. حتی میتوان دید تغییر شیب به ۲۱، مجموع مربع را به ۲۴، و تغییر آفست به ۶ آن را به ۱۹ میرساند. دو آزمون اخیر فقط همین دو تغییر را بررسی میکنند.
۳. کد کامل و تلورانس دارای واحد

برنامه با Python 3.14.5 و NumPy 2.4.6 اجرا شده است. فایل را با نام least_squares_residuals.py ذخیره کنید. مستندات نسخهٔ ۲٫۴ تابع lstsq قرارداد حل و رتبهٔ عددی را شرح میدهد. گزینهٔ rcond=None آستانهٔ نسبی تعیین رتبه را با دقت ماشین و ابعاد مرتبط میکند؛ رتبهٔ دو بهتنهایی تضمین خوششرط بودن هر مسئلهای نیست. تابع ما رتبهٔ کمتر از دو را رد میکند.
باقیمانده و مجموع مربع را از خروجی ضرایب دوباره محاسبه میکنیم. مقایسهٔ ضرایب، پیشبینیها و مجموع مربع هر کدام حد پذیرش خود را دارند؛ مقدار عددی ۱۰ به توان منفی ده در واحد همان کمیت به کار میرود. برای مجموع مربع در واحد ولت مربع، حد ۱۰ به توان منفی چهارده انتخاب شده است. اینها حدود آموزشی همین دادهاند، نه دقت ابزار یا تضمین عمومی الگوریتم.
در قرارداد assert_allclose، حد مجاز از تلورانس مطلق و سهم نسبی ساخته میشود. اینجا سهم نسبی صفر و پذیرش NaN غیرفعال است. پس از تبدیل دو طرف به float64، مقایسهٔ شکل و نوع داده سختگیرانه است؛ اختلاف نوع دادهٔ ورودی اولیه با این مقایسه تشخیص داده نمیشود. آزمون عمدی، ضریب غلط و شکل ناسازگار را رد میکند. ورودی نامتناهی نیز پیش از فراخوانی حلگر رد میشود؛ کنترل آن را به رفتار نسخهٔ کتابخانه واگذار نمیکنیم.
"""Unit tests for an authored voltage/current least-squares fixture; no instruments."""
import json
import math
import platform
from unittest.mock import patch
import numpy as np
def finite_vector(values):
if type(values) not in (list, tuple):
raise TypeError('Exact list or tuple required')
converted = []
for value in values:
if type(value) not in (int, float):
raise TypeError('Exact int or float required; bool is excluded')
try:
number = float(value)
except OverflowError as error:
raise ValueError('Value cannot be represented as finite float64') from error
if not math.isfinite(number):
raise ValueError('Only finite numbers are accepted')
converted.append(number)
return np.array(converted, dtype=np.float64)
def fit_current_voltage(current_ma, voltage_mv):
current = finite_vector(current_ma)
voltage = finite_vector(voltage_mv)
if len(current) != len(voltage) or len(current) < 2:
raise ValueError('Equal-length vectors with at least two readings required')
design = np.column_stack((current, np.ones(len(current), dtype=np.float64)))
coefficient, reported_sse, rank, singular_values = np.linalg.lstsq(design, voltage, rcond=None)
if int(rank) != 2:
raise ValueError('Two independent design columns required to separate slope and offset')
prediction = design @ coefficient
residual = voltage - prediction # millivolts, recomputed independently of reported_sse.
sse = float(residual @ residual) # squared millivolts, not millivolts.
if not (np.isfinite(coefficient).all() and np.isfinite(prediction).all()
and np.isfinite(residual).all() and math.isfinite(sse)):
raise ValueError('Computed result is not finite')
return {'coefficient': coefficient, 'prediction': prediction, 'residual': residual,
'sse_mv2': sse, 'reported_sse': reported_sse, 'rank': int(rank),
'singular_values': singular_values, 'design': design}
def close(actual, expected, atol):
# No relative tolerance, NaN agreement or scalar broadcasting is allowed here.
np.testing.assert_allclose(np.array(actual, dtype=np.float64),
np.array(expected, dtype=np.float64),
rtol=0.0, atol=atol, equal_nan=False, strict=True)
return True
def rejects(call, exception):
try:
call()
except exception:
return True
return False
def run_example():
current_ma = [-2, -1, 0, 1, 2]
voltage_mv = [-33, -16, 3, 24, 47]
original_inputs = (list(current_ma), list(voltage_mv))
fitted = fit_current_voltage(current_ma, voltage_mv)
residual = fitted['residual']
# Independent hand reference: x sums to zero, sum(x*x)=10, sum(x*y)=200,
# and mean(y)=5, so the full-rank line is 20*x+5.
expected_prediction = [-35, -15, 5, 25, 45]
expected_residual = [2, -1, -2, -1, 2]
current_si = np.array(current_ma, dtype=np.float64) / 1000.0
voltage_si = np.array(voltage_mv, dtype=np.float64) / 1000.0
design_si = np.column_stack((current_si, np.ones(5, dtype=np.float64)))
coefficient_si = np.linalg.lstsq(design_si, voltage_si, rcond=None)[0]
residual_si = voltage_si - design_si @ coefficient_si
rank_one_design = np.column_stack((np.ones(3), np.ones(3)))
rank_one_y = np.array([24.0, 25.0, 26.0])
rank_one_coefficient, rank_one_reported, rank_one_rank, _ = np.linalg.lstsq(
rank_one_design, rank_one_y, rcond=None)
rank_one_residual = rank_one_y - rank_one_design @ rank_one_coefficient
rank_one_sse = float(rank_one_residual @ rank_one_residual)
square_design = np.array([[0.0, 1.0], [1.0, 1.0]])
square_y = np.array([5.0, 25.0])
square_coefficient, square_reported, square_rank, _ = np.linalg.lstsq(
square_design, square_y, rcond=None)
square_residual = square_y - square_design @ square_coefficient
square_sse = float(square_residual @ square_residual)
altered_sse = []
for candidate in ([21.0, 5.0], [20.0, 6.0]):
difference = np.array(voltage_mv) - fitted['design'] @ np.array(candidate)
altered_sse.append(float(difference @ difference))
with patch.object(np.linalg, 'lstsq', wraps=np.linalg.lstsq) as solve_probe:
invalid_numeric_rejected = all(
rejects(lambda value=value: fit_current_voltage([value, 1], [0, 1]), ValueError)
for value in (math.nan, math.inf, -math.inf))
invalid_rejected_before_solver = solve_probe.call_count == 0
checks = {
'coefficients_match_independent_20_ohm_5_mv_reference': close(fitted['coefficient'], [20.0, 5.0], 1e-10),
'all_five_predictions_match_hand_reference': close(fitted['prediction'], expected_prediction, 1e-10),
'all_five_residuals_match_hand_reference': close(residual, expected_residual, 1e-10),
'squared_residual_sum_is_14_square_millivolts': close(fitted['sse_mv2'], 14.0, 1e-10),
'residual_is_orthogonal_to_constant_column': close(float(np.sum(residual)), 0.0, 1e-10),
'residual_is_orthogonal_to_current_column': close(float(np.dot(current_ma, residual)), 0.0, 1e-10),
'reported_sse_and_full_rank_match_independent_recomputation': fitted['rank'] == 2
and fitted['reported_sse'].shape == (1,) and close(fitted['reported_sse'], [14.0], 1e-10),
'si_unit_conversion_preserves_resistance_and_converts_offset_sse': close(coefficient_si, [20.0, 0.005], 1e-10)
and close(float(residual_si @ residual_si), 14e-6, 1e-14),
'rank_deficient_empty_return_can_have_nonzero_sse': int(rank_one_rank) == 1
and rank_one_reported.size == 0 and close(rank_one_sse, 2.0, 1e-10),
'public_fit_rejects_unidentifiable_slope_and_offset': rejects(lambda: fit_current_voltage([1, 1, 1], [24, 25, 26]), ValueError),
'square_full_rank_also_returns_empty_residuals_with_zero_sse': int(square_rank) == 2
and square_reported.size == 0 and close(square_sse, 0.0, 1e-10),
'shape_container_and_minimum_count_contracts_enforced': all(
rejects(call, exception) for call, exception in (
(lambda: fit_current_voltage([], []), ValueError),
(lambda: fit_current_voltage([0], [5]), ValueError),
(lambda: fit_current_voltage([0, 1], [5]), ValueError),
(lambda: fit_current_voltage(np.array([0, 1]), [5, 25]), TypeError))),
'bool_string_and_complex_scalars_are_rejected': all(
rejects(lambda value=value: fit_current_voltage([0, 1], [value, 25]), TypeError)
for value in (True, '5', 5 + 0j)),
'nan_and_infinities_rejected_before_linalg': invalid_numeric_rejected and invalid_rejected_before_solver,
'unrepresentable_large_integer_rejected': rejects(lambda: fit_current_voltage([0, 10**400], [5, 25]), ValueError),
'explicit_tolerance_rejects_wrong_value_and_shape': rejects(lambda: close([20.001, 5.0], [20.0, 5.0], 1e-10), AssertionError)
and rejects(lambda: close([0.0, 0.0], 0.0, 1e-10), AssertionError),
'inputs_unchanged_and_float64_design_shape_explicit': (current_ma, voltage_mv) == original_inputs
and fitted['design'].shape == (5, 2) and fitted['design'].dtype == np.dtype('float64'),
'two_authored_alternative_coefficients_increase_sse': close(altered_sse, [24.0, 19.0], 1e-10)
and all(value > fitted['sse_mv2'] for value in altered_sse),
}
assert len(checks) == 18 and all(type(value) is bool for value in checks.values())
assert all(checks.values()), checks
return {'python': platform.python_version(), 'numpy': np.__version__,
'scope': 'authored_finite_current_voltage_fixture_no_measurements',
'fixture': {'current_ma': current_ma, 'voltage_mv': voltage_mv,
'independent_prediction_mv': expected_prediction,
'independent_residual_mv': expected_residual},
'fitted': {'resistance_ohm': float(fitted['coefficient'][0]),
'offset_mv': float(fitted['coefficient'][1]),
'prediction_mv': fitted['prediction'].tolist(),
'residual_mv': residual.tolist(), 'sse_mv2': fitted['sse_mv2'],
'rank': fitted['rank'], 'reported_sse_mv2': fitted['reported_sse'].tolist(),
'residual_sum_mv': float(np.sum(residual)),
'current_residual_dot_ma_mv': float(np.dot(current_ma, residual))},
'si_conversion': {'resistance_ohm': float(coefficient_si[0]),
'offset_v': float(coefficient_si[1]),
'sse_v2': float(residual_si @ residual_si)},
'rank_deficient_probe': {'rank': int(rank_one_rank),
'reported_residuals': rank_one_reported.tolist(),
'recomputed_sse_mv2': rank_one_sse},
'square_probe': {'rank': int(square_rank), 'reported_residuals': square_reported.tolist(),
'recomputed_sse_mv2': square_sse},
'alternative_sse_mv2': altered_sse,
'absolute_tolerances': {'coefficient_ohm_or_mv': 1e-10,
'prediction_residual_mv': 1e-10,
'sse_mv2': 1e-10, 'orthogonality_mv_or_ma_mv': 1e-10,
'si_coefficient_ohm_or_v': 1e-10, 'sse_v2': 1e-14},
'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), indent=2))
۴. آرایهٔ residuals تهی چه چیزی نمیگوید؟

خروجی اجرای واقعی شامل ۱۸ بررسی نامدار موفق است. شیب شناور حدود ۱۹٫۹۹۹۹۹۹۹۹۹۹۹۹۹۹۶ اهم و مجموع مربع حدود ۱۴٫۰۰۰۰۰۰۰۰۰۰۰۰۰۱۸ میلیولت مربع شد. جمع باقیمانده حدود منفی ۷٫۱ ضربدر ده به توان منفی پانزده میلیولت است. تفاوت رقمهای انتهایی با مرجع صحیح، دلیل استفاده از تلورانس است؛ این اعداد برای محیط دیگر الزاماً یکسان چاپ نمیشوند.
دو حالت مستقل، تفسیر آرایهٔ تهی را روشن میکنند. با سه جریان برابر یک و ولتاژهای ۲۴، ۲۵ و ۲۶، رتبه یک میشود و نمیتوان شیب را جدا از آفست شناسایی کرد. آرایهٔ residuals تهی است، ولی محاسبهٔ مستقیم، مجموع مربع ۲ را میدهد. با دو زوج صفر و ۵، و یک و ۲۵، ماتریس مربع و دارای رتبهٔ کامل است؛ همان آرایه باز تهی است، ولی مجموع مربع مستقیم در حد گرد کردن صفر میشود.
علت هر دو رفتار در بخش خروجی residuals آمده است: کمبود رتبه یا تعداد سطر کمتر یا مساوی ستون، آرایهٔ تهی میدهد. پس اندازهٔ آن معیار صفر بودن خطا نیست. گزارش کامل زیر هم خروجی کتابخانه و هم محاسبهٔ دوباره را ثبت میکند.
{
"python": "3.14.5",
"numpy": "2.4.6",
"scope": "authored_finite_current_voltage_fixture_no_measurements",
"fixture": {
"current_ma": [
-2,
-1,
0,
1,
2
],
"voltage_mv": [
-33,
-16,
3,
24,
47
],
"independent_prediction_mv": [
-35,
-15,
5,
25,
45
],
"independent_residual_mv": [
2,
-1,
-2,
-1,
2
]
},
"fitted": {
"resistance_ohm": 19.999999999999996,
"offset_mv": 5.000000000000002,
"prediction_mv": [
-34.99999999999999,
-14.999999999999995,
5.000000000000002,
25.0,
44.99999999999999
],
"residual_mv": [
1.999999999999993,
-1.0000000000000053,
-2.0000000000000018,
-1.0,
2.000000000000007
],
"sse_mv2": 14.000000000000018,
"rank": 2,
"reported_sse_mv2": [
14.000000000000046
],
"residual_sum_mv": -7.105427357601002e-15,
"current_residual_dot_ma_mv": 3.375077994860476e-14
},
"si_conversion": {
"resistance_ohm": 20.0,
"offset_v": 0.005000000000000002,
"sse_v2": 1.3999999999999971e-05
},
"rank_deficient_probe": {
"rank": 1,
"reported_residuals": [],
"recomputed_sse_mv2": 2.0
},
"square_probe": {
"rank": 2,
"reported_residuals": [],
"recomputed_sse_mv2": 1.9721522630525295e-29
},
"alternative_sse_mv2": [
24.0,
19.0
],
"absolute_tolerances": {
"coefficient_ohm_or_mv": 1e-10,
"prediction_residual_mv": 1e-10,
"sse_mv2": 1e-10,
"orthogonality_mv_or_ma_mv": 1e-10,
"si_coefficient_ohm_or_v": 1e-10,
"sse_v2": 1e-14
},
"checks": {
"coefficients_match_independent_20_ohm_5_mv_reference": true,
"all_five_predictions_match_hand_reference": true,
"all_five_residuals_match_hand_reference": true,
"squared_residual_sum_is_14_square_millivolts": true,
"residual_is_orthogonal_to_constant_column": true,
"residual_is_orthogonal_to_current_column": true,
"reported_sse_and_full_rank_match_independent_recomputation": true,
"si_unit_conversion_preserves_resistance_and_converts_offset_sse": true,
"rank_deficient_empty_return_can_have_nonzero_sse": true,
"public_fit_rejects_unidentifiable_slope_and_offset": true,
"square_full_rank_also_returns_empty_residuals_with_zero_sse": true,
"shape_container_and_minimum_count_contracts_enforced": true,
"bool_string_and_complex_scalars_are_rejected": true,
"nan_and_infinities_rejected_before_linalg": true,
"unrepresentable_large_integer_rejected": true,
"explicit_tolerance_rejects_wrong_value_and_shape": true,
"inputs_unchanged_and_float64_design_shape_explicit": true,
"two_authored_alternative_coefficients_increase_sse": true
}
}
۵. چهار تمرین با پاسخ و مرز اعتبار آزمون

تمرین اول: با تبدیل همهٔ جریانها به آمپر و ولتاژها به ولت چه چیزی تغییر میکند؟ پاسخ: شیب ۲۰ اهم حفظ میشود، آفست ۰٫۰۰۵ ولت و مجموع مربع ۰٫۰۰۰۰۱۴ ولت مربع خواهد بود. مربع ضریب تبدیل ولتاژ وارد مجموع مربع میشود. برنامه هر سه را میآزماید؛ تبدیل فقط یک محور، ضرایب دیگری با واحد دیگری میسازد.
تمرین دوم: آیا جمع صفر باقیمانده یعنی پیشبینی کامل؟ پاسخ: خیر؛ بردار مرجع این مقاله جمع صفر و مجموع مربع ۱۴ دارد. اندازه و الگوی باقیمانده را باید جدا دید. این مثال هیچ توزیع احتمالی برای نویز فرض نکرده و فاصلهٔ اطمینان یا عدم قطعیت مقاومت محاسبه نمیکند.
تمرین سوم: آیا سه بار اعمال جریان یک میلیآمپر برای تعیین دو پارامتر کافی است؟ پاسخ: تعداد نمونه بهتنهایی کافی نیست. دو ستون ماتریس این مثال وابستهاند و فقط ترکیب شیب و آفست تعیین میشود. تکرار نمونه، تنوع جریان ایجاد نمیکند؛ تابع عمومی ما چنین دادهای را به عنوان برازش دو پارامتری معتبر نمیپذیرد.
تمرین چهارم: آیا ۱۸ بررسی موفق، کالیبراسیون مدار را تأیید میکند؟ پاسخ: خیر. این بررسیها قرارداد و محاسبهٔ همین نمونه را کنترل میکنند. خطای ابزار، تغییر دما، غیرخطی بودن، دادهٔ پرت و رفتار خارج از بازه بررسی نشدهاند. برای تمرین مکمل، آزمون مستقل گرادیان مدل خطی و اعتبارسنجی عددی مدار RC دو پرسش متفاوت دربارهٔ مشتق و حل معادله را دنبال میکنند. این آموزش پایه با منابع بررسیشده در ۲۴ مهر ۱۴۰۵ تهیه شده؛ تصاویر مفهومیاند و نتیجهٔ آزمایش فیزیکی را نشان نمیدهند.
منابع و مطالعهٔ بیشتر
- NumPy2.4 lstsq؛ کمترین مربعات، رتبه و آرایهٔ residuals
- NumPy2.4 assert_allclose؛ تلورانس، NaN و مقایسهٔ سختگیرانه
- NIST e-Handbook؛ تعریف مدل خطی و کمترین مربعات و محدودیت تعمیم
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

