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

۱. ضریب فیلتر و قرارداد زمان‌های ورودی

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

برای نمونهٔ آموزشی فیلتر نمایی، نسبت بی‌واحد x = dt / tau را تعریف می‌کنیم؛ dt فاصلهٔ نمونه‌ها و tau ثابت زمانی است و هر دو باید با یک واحد زمانی بیان شوند. ضریب به‌روزرسانی برابر alpha = 1 - exp(-x) است. اگر ورودی در هر گام ثابت فرض شود، می‌توان وضعیت را با y_new = y + alpha * (u - y) به‌روز کرد. این فرض، تغییرات درون گام یا رفتار واقعی حسگر را مدل نمی‌کند.

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

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

۲. چرا کم کردن از یک می‌تواند نتیجه را صفر کند؟

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

وقتی x بسیار کوچک است، مقدار exp(-x) به یک نزدیک می‌شود. اگر این مقدار در نمایش محدود به یک گرد شود، کم کردن آن از یک، صفر می‌دهد؛ اختلاف اصلی دیگر در مقدار میانی موجود نیست. در این وضعیت، صرفاً چاپ رقم‌های بیشتر چیزی را بازیابی نمی‌کند. برابر بودن دو فرمول روی کاغذ، تضمین برابری مسیرهای محاسباتی آن‌ها با دقت محدود نیست.

در راهنمای math پایتون، تابع expm1 برای محاسبهٔ تفاضل نمایی نزدیک صفر معرفی شده است. عبارت پایدارتر ما alpha = -math.expm1(-x) است. برای محاسبهٔ نسبت از روی ضریب، از x = -math.log1p(-alpha) استفاده می‌کنیم، به شرطی که ضریب از صفر تا کمتر از یک باشد. این بازنویسی، تضمین دقت نامحدود در همهٔ ورودی‌ها نیست.

به عنوان کنترل زبانی، مستندات MATLAB برای expm1 نیز کاربرد آن در ورودی کوچک را شرح می‌دهد. در این مقاله MATLAB اجرا نشده است و خروجی پایتون را نتیجهٔ آزمون MATLAB معرفی نمی‌کنیم. موضوع، انتخاب تابع عددی مناسب و طراحی آزمون است؛ نام مشترک توابع، یکسان بودن جزئیات همهٔ اجراها و سخت‌افزارها را ثابت نمی‌کند.

۳. کد کامل با مرجع Decimal و سیزده بررسی

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

کد زیر فقط به کتابخانهٔ استاندارد نیاز دارد و با پایتون ۳٫۱۴٫۵ اجرا شده است. فایل را با نام expm1_filter.py ذخیره و دستور python expm1_filter.py را اجرا کنید. شش نسبت از بسیار کوچک تا بیست بررسی می‌شود. محاسبهٔ مرجع با دقت ۸۰ رقم انجام و با دقت ۱۰۰ رقم دوباره کنترل می‌شود؛ توافق تبدیل‌شده به float، یک کنترل عملی برای همین ورودی‌هاست، نه اثبات نمادین.

"""Authored floating-point stress test of a first-order filter coefficient."""
from decimal import Decimal, localcontext
import json
import math
import platform


def coefficient(ratio):
    x = float(ratio)
    if not math.isfinite(x) or x < 0:
        raise ValueError("dt/tau must be finite and nonnegative")
    return -math.expm1(-x)


def inverse_ratio(alpha):
    a = float(alpha)
    if not math.isfinite(a) or not 0 <= a < 1:
        raise ValueError("inverse requires 0 <= alpha < 1")
    return -math.log1p(-a)


def decimal_reference(x, precision=80):
    with localcontext() as context:
        context.prec = precision
        # Preserve the actual binary input; do not silently change it to a decimal literal.
        value = Decimal.from_float(x)
        return Decimal(1) - (-value).exp()


def relative_error(value, reference):
    with localcontext() as context:
        context.prec = 80
        return float(abs(Decimal.from_float(value) - reference) / abs(reference))


def rejects(call):
    try:
        call()
    except ValueError:
        return True
    return False


def run_example():
    ratios = [1e-17, 1e-12, 1e-6, 0.1, 1.0, 20.0]
    rows = []
    for x in ratios:
        ref = decimal_reference(x)
        naive, stable = 1.0 - math.exp(-x), coefficient(x)
        rows.append({"dt_over_tau": x, "naive": naive, "stable": stable,
                     "naive_relative_error": relative_error(naive, ref),
                     "stable_relative_error": relative_error(stable, ref)})
    tiny = rows[0]
    reference_float = float(decimal_reference(ratios[0]))
    checks = {
        "tiny_naive_value_collapses_to_zero_here": tiny["naive"] == 0.0,
        "tiny_stable_value_stays_positive": tiny["stable"] > 0,
        "stable_relative_errors_below_2e_15_for_fixture": all(r["stable_relative_error"] < 2e-15 for r in rows),
        "reference_precision_crosscheck": all(float(decimal_reference(x, 80)) == float(decimal_reference(x, 100)) for x in ratios),
        "zero_ratio_gives_zero": coefficient(0) == 0,
        "coefficient_in_unit_interval": all(0 <= r["stable"] <= 1 for r in rows),
        "coefficient_increases_for_fixture": all(a["stable"] < b["stable"] for a, b in zip(rows, rows[1:])),
        "tiny_inverse_roundtrip": math.isclose(inverse_ratio(tiny["stable"]), ratios[0], rel_tol=2e-15, abs_tol=0),
        "overlarge_absolute_tolerance_hides_bug": math.isclose(tiny["naive"], reference_float, rel_tol=1e-12, abs_tol=1e-8),
        "relative_only_check_detects_bug": not math.isclose(tiny["naive"], reference_float, rel_tol=1e-12, abs_tol=0),
        "state_addition_can_still_stagnate": 1.0 + tiny["stable"] * (2.0 - 1.0) == 1.0,
        "invalid_ratio_rejected": all(rejects(lambda x=x: coefficient(x)) for x in (-1, math.nan, math.inf)),
        "saturated_inverse_rejected": coefficient(1000) == 1 and rejects(lambda: inverse_ratio(coefficient(1000))),
    }
    assert all(checks.values()), checks
    return {"python": platform.python_version(), "synthetic_numerical_stress_test": True,
            "reference_decimal_precision": [80, 100], "ratios_dimensionless": True,
            "rows": rows, "tiny_roundtrip_ratio": inverse_ratio(tiny["stable"]), "checks": checks}


if __name__ == "__main__":
    print(json.dumps(run_example(), indent=2))

طبق راهنمای Decimal، from_float مقدار واقعی ورودی دودویی را به Decimal منتقل می‌کند. بنابراین مرجع ما همان عددی را بررسی می‌کند که تابع دریافت کرده است؛ تبدیل از رشتهٔ اعشاری می‌توانست مسئلهٔ اندکی متفاوت بسازد. سپس خطای نسبی را در فضای Decimal حساب می‌کنیم. برای مقدار مرجع صفر، تقسیم نسبی تعریف نمی‌کنیم و شرط صفر را جدا می‌سنجیم.

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

۴. خروجی اجرا و دام تلورانس مطلق بزرگ

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

برای نسبت 1e-17، روش ساده در این اجرا صفر و روش پایدار 1e-17 داده است. خطای نسبی روش ساده یک، یعنی صددرصد است؛ مقدار کوچک خطای مطلق نباید ما را گمراه کند. برای نسبت 1e-12، خطای نسبی روش ساده حدود 2.21e-5 ثبت شده، در حالی که روش پایدار در مقایسه با مرجع این مثال خطای بسیار کمتری دارد. گزارش کامل زیر، ورودی و روش هر محاسبه را نگه می‌دارد.

{
  "python": "3.14.5",
  "synthetic_numerical_stress_test": true,
  "reference_decimal_precision": [
    80,
    100
  ],
  "ratios_dimensionless": true,
  "rows": [
    {
      "dt_over_tau": 1e-17,
      "naive": 0.0,
      "stable": 1e-17,
      "naive_relative_error": 1.0,
      "stable_relative_error": 5e-18
    },
    {
      "dt_over_tau": 1e-12,
      "naive": 9.999778782798785e-13,
      "stable": 9.999999999995e-13,
      "naive_relative_error": 2.212171962149499e-05,
      "stable_relative_error": 2.421793993635782e-17
    },
    {
      "dt_over_tau": 1e-06,
      "naive": 9.999994999843054e-07,
      "stable": 9.999995000001665e-07,
      "naive_relative_error": 1.5861255958076973e-11,
      "stable_relative_error": 1.325737075241991e-16
    },
    {
      "dt_over_tau": 0.1,
      "naive": 0.09516258196404048,
      "stable": 0.09516258196404043,
      "naive_relative_error": 5.312996520674206e-16,
      "stable_relative_error": 5.202995170724482e-17
    },
    {
      "dt_over_tau": 1.0,
      "naive": 0.6321205588285577,
      "stable": 0.6321205588285577,
      "naive_relative_error": 1.966199880576778e-17,
      "stable_relative_error": 1.966199880576778e-17
    },
    {
      "dt_over_tau": 20.0,
      "naive": 0.9999999979388464,
      "stable": 0.9999999979388464,
      "naive_relative_error": 4.129311057982137e-17,
      "stable_relative_error": 4.129311057982137e-17
    }
  ],
  "tiny_roundtrip_ratio": 1e-17,
  "checks": {
    "tiny_naive_value_collapses_to_zero_here": true,
    "tiny_stable_value_stays_positive": true,
    "stable_relative_errors_below_2e_15_for_fixture": true,
    "reference_precision_crosscheck": true,
    "zero_ratio_gives_zero": true,
    "coefficient_in_unit_interval": true,
    "coefficient_increases_for_fixture": true,
    "tiny_inverse_roundtrip": true,
    "overlarge_absolute_tolerance_hides_bug": true,
    "relative_only_check_detects_bug": true,
    "state_addition_can_still_stagnate": true,
    "invalid_ratio_rejected": true,
    "saturated_inverse_rejected": true
  }
}

در آزمون عمدی، تلورانس مطلق 1e-8 باعث می‌شود صفر در مقایسه با مقدار حدود 1e-17 پذیرفته شود. این رفتار با قرارداد isclose سازگار است، اما نیاز آزمون ما را برآورده نمی‌کند. PEP 485 توضیح می‌دهد که تلورانس مطلق نزدیک صفر به مقیاس مسئله وابسته است. برای کنترل خطای نسبی این ضریبِ غیرصفر، در مثال از تلورانس مطلق صفر استفاده کرده‌ایم؛ این انتخاب نسخهٔ عمومی برای تمام آزمون‌ها نیست.

در نسبت بزرگ هزار، ضریب float به یک اشباع می‌شود و تابع معکوس آن را رد می‌کند؛ اطلاعات نسبت اولیه دیگر از این ضریب قابل بازیابی نیست. نکتهٔ دیگر اینکه ضریب درست لزوماً وضعیت درست تولید نمی‌کند: افزودن افزایشی در حد 1e-17 به وضعیت یک، در اجرای ما وضعیت را تغییر نداده است. رفع حذف کاهشی ضریب، همهٔ محدودیت‌های جمع ممیز شناور را رفع نمی‌کند.

۵. تمرین‌های پاسخ‌دار و کاربرد در زنجیرهٔ هوش مصنوعی

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

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

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

در پیش‌پردازش حسگر برای یادگیری ماشین، چنین آزمونی کمک می‌کند تغییر ویژگی را به اشتباه به مدل نسبت ندهیم. این مثال مدل آموزش نمی‌دهد و بهبود دقت پیش‌بینی را نشان نمی‌دهد. برای کنترل مستقل بخش دیگری از زنجیره، آزمون گرادیان مدل خطی را مطالعه کنید. ورودی‌ها، نسخهٔ اجرا، تلورانس و محدودیت‌ها باید کنار نتیجه باقی بمانند؛ این مطلب آموزش پایه بر اساس منابع خوانده‌شده در ۲۳ مهر ۱۴۰۵ است و خبر پژوهشی تازه محسوب نمی‌شود.

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

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