منابع ترجمه · The Python Tutorial · فصل ۱۵

محاسبات ممیز شناور: مسائل و محدودیت‌ها (آموزش رسمی پایتون، فصل ۱۵)

۹ دقیقه مطالعه · به‌روزرسانی ۳ آبان ۱۴۰۵

منبع اصلی: The Python Tutorial, Chapter 15: Floating-Point Arithmetic: Issues and Limitations — Python Software Foundation

💡 این صفحه مثل یک نوت‌بوک Jupyter است: متغیر و تابعی که در یک سلول تعریف شود، در سلول‌های بعدی همین صفحه در دسترس است. سلول‌ها را به ترتیب اجرا کنید، یا با «⏮ تا اینجا» همهٔ سلول‌های قبلیِ اجرانشده را یک‌جا اجرا کنید.

اعداد ممیز شناور در سخت‌افزار کامپیوتر به‌صورت کسرهای پایهٔ ۲ (دودویی) نمایش داده می‌شوند. برای مثال، کسر اعشاری 0.625 مقدار ۶/۱۰ + ۲/۱۰۰ + ۵/۱۰۰۰ را دارد، و به همین ترتیب کسر دودویی 0.101 مقدار ۱/۲ + ۰/۴ + ۱/۸ را دارد. این دو کسر مقدار یکسانی دارند و تنها تفاوت واقعی این است که اولی در نمادگذاری کسری پایهٔ ۱۰ و دومی در پایهٔ ۲ نوشته شده است.

متأسفانه بیشتر کسرهای اعشاری را نمی‌توان دقیقاً به‌صورت کسر دودویی نمایش داد. نتیجه این است که معمولاً اعداد ممیز شناور اعشاری که وارد می‌کنید فقط با اعداد ممیز شناور دودویی‌ای که واقعاً در ماشین ذخیره می‌شوند تقریب زده می‌شوند.

درک این مشکل ابتدا در پایهٔ ۱۰ آسان‌تر است. کسر ۱/۳ را در نظر بگیرید. می‌توانید آن را با یک کسر پایهٔ ۱۰ تقریب بزنید:

0.3

یا بهتر،

0.33

یا بهتر،

0.333

و همین‌طور ادامه. مهم نیست چند رقم بنویسید، نتیجه هرگز دقیقاً ۱/۳ نخواهد بود، اما تقریب بهتر و بهتری از ۱/۳ خواهد بود.

به همین ترتیب، مهم نیست چند رقم پایهٔ ۲ به کار ببرید، مقدار اعشاری ۰٫۱ را نمی‌توان دقیقاً به‌صورت کسر پایهٔ ۲ نمایش داد. در پایهٔ ۲، ۱/۱۰ کسر بی‌نهایت تکرارشونده زیر است:

0.0001100110011001100110011001100110011001100110011...

در هر تعداد متناهی از بیت‌ها متوقف شوید، یک تقریب به دست می‌آورید. در بیشتر ماشین‌های امروزی، floatها با کسری دودویی تقریب زده می‌شوند که صورت آن از ۵۳ بیت اول (از پرارزش‌ترین بیت) و مخرجش از توانی از دو تشکیل می‌شود. در مورد ۱/۱۰، کسر دودویی برابر 3602879701896397 / 2 ** 55 است که نزدیک به مقدار واقعی ۱/۱۰ است ولی دقیقاً برابرش نیست.

بسیاری از کاربران به دلیل نحوهٔ نمایش مقادیر از این تقریب بی‌خبرند. پایتون فقط یک تقریب اعشاری از مقدار اعشاری واقعیِ تقریب دودویی ذخیره‌شده توسط ماشین را چاپ می‌کند. در بیشتر ماشین‌ها، اگر پایتون مقدار اعشاری واقعی تقریب دودویی ذخیره‌شده برای ۰٫۱ را چاپ می‌کرد، باید این را نمایش می‌داد:

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

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

>>> 1 / 10
0.1

فقط به یاد داشته باشید، با اینکه نتیجهٔ چاپ‌شده شبیه مقدار دقیق ۱/۱۰ به نظر می‌رسد، مقدار ذخیره‌شدهٔ واقعی نزدیک‌ترین کسر دودویی قابل‌نمایش است.

جالب اینکه اعداد اعشاری مختلف زیادی هستند که نزدیک‌ترین کسر دودویی تقریبی یکسانی دارند. برای مثال، اعداد 0.1 و 0.10000000000000001 و 0.1000000000000000055511151231257827021181583404541015625 همگی با 3602879701896397 / 2 ** 55 تقریب زده می‌شوند. چون همهٔ این مقادیر اعشاری تقریب یکسانی دارند، هر کدام را می‌توان نمایش داد و در عین حال ناوردای eval(repr(x)) == x حفظ می‌شود.

از نظر تاریخی، اعلان پایتون و تابع درون‌ساخت repr() همان مقداری را که ۱۷ رقم معنادار دارد، یعنی 0.10000000000000001، انتخاب می‌کردند. از پایتون ۳٫۱ به بعد، پایتون (در بیشتر سیستم‌ها) می‌تواند کوتاه‌ترین آن‌ها را انتخاب کند و فقط 0.1 را نمایش دهد.

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

برای خروجی دلپذیرتر، ممکن است بخواهید از قالب‌بندی رشته برای تولید تعداد محدودی ارقام معنادار استفاده کنید:

>>> format(math.pi, '.12g')  # give 12 significant digits
'3.14159265359'

>>> format(math.pi, '.2f')   # give 2 digits after the point
'3.14'

>>> repr(math.pi)
'3.141592653589793'

مهم است بدانید که این، به معنای واقعی، یک توهم است: شما فقط نمایش مقدار واقعی ماشین را گرد می‌کنید.

یک توهم ممکن است توهم دیگری بزاید. برای مثال، چون ۰٫۱ دقیقاً ۱/۱۰ نیست، جمع سه مقدار ۰٫۱ هم ممکن است دقیقاً ۰٫۳ نشود:

>>> 0.1 + 0.1 + 0.1 == 0.3
False

همچنین، چون ۰٫۱ نمی‌تواند به مقدار دقیق ۱/۱۰ نزدیک‌تر شود و ۰٫۳ هم نمی‌تواند به مقدار دقیق ۳/۱۰ نزدیک‌تر شود، پیش‌گرد کردن با تابع round() کمکی نمی‌کند:

>>> round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1)
False

هرچند اعداد را نمی‌توان به مقادیر دقیق مورد نظرشان نزدیک‌تر کرد، تابع math.isclose() می‌تواند برای مقایسهٔ مقادیر نادقیق مفید باشد:

>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True

به‌طور جایگزین، می‌توان از تابع round() برای مقایسهٔ تقریب‌های خشن استفاده کرد:

>>> round(math.pi, ndigits=2) == round(22 / 7, ndigits=2)
True

محاسبات ممیز شناور دودویی شگفتی‌های زیادی از این دست دارد. مشکل «۰٫۱» در بخش «خطای نمایش» پایین با جزئیات دقیق توضیح داده شده است. برای خلاصه‌ای دلپذیر از نحوهٔ کار ممیز شناور دودویی و انواع مشکلاتی که معمولاً در عمل پیش می‌آید، Examples of Floating Point Problems را ببینید. همچنین برای شرح کامل‌تری از شگفتی‌های رایج دیگر، The Perils of Floating Point را ببینید.

همان‌طور که نزدیک پایان آن گفته شده، «هیچ پاسخ آسانی وجود ندارد.» با این حال، بیش از حد از ممیز شناور نترسید! خطاهای عملیات float در پایتون از سخت‌افزار ممیز شناور به ارث رسیده‌اند و در بیشتر ماشین‌ها از مرتبهٔ حداکثر ۱ جزء در 2**53 به ازای هر عملیات هستند. این برای بیشتر کارها بیش از کافی است، اما باید به یاد داشته باشید که این محاسبات اعشاری نیست و هر عملیات float می‌تواند دچار خطای گرد کردن تازه‌ای شود.

هرچند موارد بحرانی وجود دارند، برای بیشتر استفاده‌های معمولی از محاسبات ممیز شناور، اگر فقط نمایش نتایج نهایی را به تعداد ارقام اعشاری مورد انتظارتان گرد کنید، در نهایت همان نتیجهٔ مورد انتظار را می‌بینید. str() معمولاً کافی است، و برای کنترل دقیق‌تر مشخص‌کننده‌های قالب متد str.format() را در قالب‌بندی رشته ببینید.

برای موارد استفاده‌ای که به نمایش اعشاری دقیق نیاز دارند، ماژول decimal را امتحان کنید که محاسبات اعشاری مناسب برای کاربردهای حسابداری و دقت بالا را پیاده می‌کند.

شکل دیگری از محاسبات دقیق توسط ماژول fractions پشتیبانی می‌شود که محاسبات را بر پایهٔ اعداد گویا پیاده می‌کند (بنابراین اعدادی مانند ۱/۳ را می‌توان دقیقاً نمایش داد).

اگر کاربر سنگین عملیات ممیز شناور هستید، باید به بستهٔ NumPy و بسیاری دیگر از بسته‌های ریاضی و آماری ارائه‌شده توسط پروژهٔ SciPy نگاهی بیندازید. https://scipy.org را ببینید.

پایتون ابزارهایی دارد که ممکن است در آن مواقع نادری که واقعاً می‌خواهید مقدار دقیق یک float را بدانید کمک کند. متد float.as_integer_ratio() مقدار یک float را به‌صورت کسر بیان می‌کند:

>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)

چون این نسبت دقیق است، می‌توان از آن برای بازسازی بدون اتلاف مقدار اصلی استفاده کرد:

>>> x == 3537115888337719 / 1125899906842624
True

متد float.hex() یک float را در مبنای ۱۶ (هگزادسیمال) بیان می‌کند، که باز هم مقدار دقیق ذخیره‌شده توسط کامپیوتر شما را می‌دهد:

>>> x.hex()
'0x1.921f9f01b866ep+1'

این نمایش هگزادسیمال دقیق را می‌توان برای بازسازی دقیق مقدار float استفاده کرد:

>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True

چون این نمایش دقیق است، برای انتقال مطمئن مقادیر بین نسخه‌های مختلف پایتون (استقلال از پلتفرم) و تبادل داده با زبان‌های دیگری که همان قالب را پشتیبانی می‌کنند (مانند Java و C99) مفید است.

ابزار مفید دیگر تابع sum() است که به کاهش اتلاف دقت هنگام جمع‌زدن کمک می‌کند. این تابع در مراحل گرد کردن میانی، هنگام اضافه شدن مقادیر به مجموع جاری، از دقت گسترش‌یافته استفاده می‌کند. این می‌تواند در دقت کلی تفاوت ایجاد کند، به‌طوری که خطاها تا حدی که بر مجموع نهایی اثر بگذارند انباشته نشوند:

>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 == 1.0
False
>>> sum([0.1] * 10) == 1.0
True

تابع math.fsum() پا را فراتر می‌گذارد و همهٔ «ارقام از دست‌رفته» را هنگام اضافه شدن مقادیر به مجموع جاری ردیابی می‌کند، به‌طوری که نتیجه فقط یک گرد کردن دارد. این از sum() کندتر است، اما در موارد نادری که ورودی‌هایی با بزرگی زیاد عمدتاً یکدیگر را خنثی می‌کنند و مجموع نهایی نزدیک صفر می‌ماند دقیق‌تر خواهد بود:

>>> arr = [-0.10430216751806065, -266310978.67179024, 143401161448607.16,
...        -143401161400469.7, 266262841.31058735, -0.003244936839808227]
>>> float(sum(map(Fraction, arr)))   # Exact summation with single rounding
8.042173697819788e-13
>>> math.fsum(arr)                   # Single rounding
8.042173697819788e-13
>>> sum(arr)                         # Multiple roundings in extended precision
8.042178034628478e-13
>>> total = 0.0
>>> for x in arr:
...     total += x                   # Multiple roundings in standard precision
...
>>> total                            # Straight addition has no correct digits!
-0.0051575902860057365

۱۵٫۱. خطای نمایش (Representation Error)

این بخش مثال «۰٫۱» را با جزئیات توضیح می‌دهد و نشان می‌دهد چگونه می‌توانید خودتان تحلیلی دقیق از چنین مواردی انجام دهید. فرض بر آشنایی پایه با نمایش ممیز شناور دودویی است.

خطای نمایش به این واقعیت اشاره دارد که برخی (در واقع بیشتر) کسرهای اعشاری را نمی‌توان دقیقاً به‌صورت کسر دودویی (پایهٔ ۲) نمایش داد. این دلیل اصلی آن است که پایتون (یا Perl، C، C++، Java، Fortran و بسیاری دیگر) اغلب عدد اعشاری دقیقی را که انتظار دارید نمایش نمی‌دهند.

چرا چنین است؟ ۱/۱۰ دقیقاً به‌صورت کسر دودویی قابل‌نمایش نیست. دست‌کم از سال ۲۰۰۰، تقریباً همهٔ ماشین‌ها از محاسبات ممیز شناور دودویی IEEE 754 استفاده می‌کنند و تقریباً همهٔ پلتفرم‌ها floatهای پایتون را به مقادیر «دقت مضاعف» IEEE 754 binary64 نگاشت می‌کنند. مقادیر IEEE 754 binary64 شامل ۵۳ بیت دقت هستند، بنابراین هنگام ورودی، کامپیوتر تلاش می‌کند ۰٫۱ را به نزدیک‌ترین کسری از شکل J/2**\ N تبدیل کند که J یک عدد صحیح با دقیقاً ۵۳ بیت است. بازنویسی

1 / 10 ~= J / (2**N)

به‌صورت

J ~= 2**N / 10

و با یادآوری اینکه J دقیقاً ۵۳ بیت دارد (>= 2**52 ولی < 2**53)، بهترین مقدار برای N برابر ۵۶ است:

>>> 2**52 <=  2**56 // 10  < 2**53
True

یعنی ۵۶ تنها مقدار N است که J را با دقیقاً ۵۳ بیت باقی می‌گذارد. بهترین مقدار ممکن برای J در این صورت خارج‌قسمت گردشدهٔ آن است:

>>> q, r = divmod(2**56, 10)
>>> r
6

چون باقی‌مانده بیشتر از نصف ۱۰ است، بهترین تقریب با گرد کردن به بالا به دست می‌آید:

>>> q+1
7205759403792794

بنابراین بهترین تقریب ممکن برای ۱/۱۰ در دقت مضاعف IEEE 754 این است:

7205759403792794 / 2 ** 56

تقسیم صورت و مخرج بر دو، کسر را به این تبدیل می‌کند:

3602879701896397 / 2 ** 55

توجه کنید که چون به بالا گرد کردیم، این مقدار در واقع کمی بزرگ‌تر از ۱/۱۰ است؛ اگر به بالا گرد نکرده بودیم، خارج‌قسمت کمی کوچک‌تر از ۱/۱۰ می‌شد. اما در هیچ حالتی نمی‌تواند دقیقاً ۱/۱۰ باشد!

بنابراین کامپیوتر هرگز ۱/۱۰ را «نمی‌بیند»: آنچه می‌بیند همان کسر دقیق بالاست، بهترین تقریب دقت مضاعف IEEE 754 که می‌تواند به دست آورد:

>>> 0.1 * 2 ** 55
3602879701896397.0

اگر آن کسر را در 10**55 ضرب کنیم، می‌توانیم مقدار را تا ۵۵ رقم اعشار ببینیم:

>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625

یعنی عدد دقیق ذخیره‌شده در کامپیوتر برابر مقدار اعشاری ۰٫۱۰۰۰۰۰۰۰۰۰۰۰۰۰۰۰۰۰۵۵۵۱۱۱۵۱۲۳۱۲۵۷۸۲۷۰۲۱۱۸۱۵۸۳۴۰۴۵۴۱۰۱۵۶۲۵ است. بسیاری از زبان‌ها (از جمله نسخه‌های قدیمی‌تر پایتون) به‌جای نمایش مقدار اعشاری کامل، نتیجه را تا ۱۷ رقم معنادار گرد می‌کنند:

>>> format(0.1, '.17f')
'0.10000000000000001'

ماژول‌های fractions و decimal این محاسبات را آسان می‌کنند:

>>> from decimal import Decimal
>>> from fractions import Fraction

>>> Fraction.from_float(0.1)
Fraction(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

>>> Decimal.from_float(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

>>> format(Decimal.from_float(0.1), '.17')
'0.10000000000000001'