15. حساب ممیز شناور: مشکلات و محدودیت‌ها

اعداد ممیز شناور در سخت‌افزار رایانه به‌صورت کسرهای مبنای ۲ (دودویی) نمایش داده می‌شوند. برای مثال، کسر ده‌دهی 0.625 دارای مقدار 6/10 + 2/100 + 5/1000 است و به همین شکل، کسر دودویی 0.101 دارای مقدار 1/2 + 0/4 + 1/8 است. این دو کسر مقدار یکسانی دارند و تنها تفاوت واقعی آن‌ها این است که اولی با نمایش کسری در مبنای ۱۰ و دومی با نمایش کسری در مبنای ۲ نوشته شده است.

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

درک این مشکل ابتدا در مبنای ۱۰ آسان‌تر است. کسر 1/3 را در نظر بگیرید. می‌توانید آن را به‌صورت یک کسر ده‌دهی تقریبی نمایش دهید:

۰.۳

یا حتی بهتر،:

۰.۳۳

یا حتی بهتر،:

۰.۳۳۳

و به همین ترتیب ادامه دهید. مهم نیست چند رقم را بنویسید، نتیجه هرگز دقیقاً برابر با 1/3 نخواهد شد، اما هرچه ارقام بیشتری بنویسید، تقریب بهتری از 1/3 به دست می‌آید.

به همین شکل، مهم نیست چند رقم مبنای ۲ استفاده کنید، مقدار ده‌دهی 0.1 نمی‌تواند به‌طور دقیق به‌صورت یک کسر مبنای ۲ نمایش داده شود. در مبنای ۲، 1/10 کسری بی‌نهایت تکرارشونده است:

۰.۰۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱...

اگر در هر تعداد محدود بیت متوقف شوید، یک مقدار تقریبی به دست می‌آورید. در بیشتر ماشین‌های امروزی، اعداد ممیز شناور با استفاده از یک کسر دودویی تقریبی نمایش داده می‌شوند که صورت آن از ۵۳ بیت اول، از مهم‌ترین بیت شروع‌شده، تشکیل شده و مخرج آن توانی از ۲ است. در مورد 1/10، کسر دودویی برابر با 3602879701896397 / 2 ** 55 است که به مقدار واقعی 1/10 نزدیک است، اما دقیقاً برابر آن نیست.

بسیاری از کاربران به دلیل نحوهٔ نمایش مقادیر، از این تقریب آگاه نیستند. پایتون تنها یک تقریب ده‌دهی از مقدار ده‌دهی واقعیِ مقدار دودویی ذخیره‌شده در ماشین را چاپ می‌کند. در بیشتر ماشین‌ها، اگر پایتون مقدار ده‌دهی واقعیِ تقریب دودویی ذخیره‌شده برای 0.1 را چاپ می‌کرد، مجبور بود مقدار زیر را نمایش دهد:

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

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

>>> 1 / 10
0.1

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

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

در گذشته، اعلان پایتون و تابع داخلی repr() مقداری را با 17 رقم معنادار انتخاب می‌کردند: 0.10000000000000001. از پایتون 3.1 به بعد، پایتون (در بیشتر سیستم‌ها) قادر است کوتاه‌ترین مقدار از میان این نمایش‌ها را انتخاب کند و به‌سادگی 0.1 را نمایش دهد.

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

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

>>> format(math.pi, '.12g')  # نمایش ۱۲ رقم معنادار
'3.14159265359'

>>> format(math.pi, '.2f')   # نمایش ۲ رقم پس از ممیز
'3.14'

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

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

یک توهم می‌تواند باعث ایجاد توهم دیگری شود. برای مثال، از آنجا که 0.1 دقیقاً برابر با 1/10 نیست، جمع سه مقدار 0.1 نیز ممکن است دقیقاً برابر با 0.3 نشود:

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

همچنین، چون 0.1 نمی‌تواند به مقدار دقیق 1/10 نزدیک‌تر شود و 0.3 نیز نمی‌تواند به مقدار دقیق 3/10 نزدیک‌تر شود، گرد کردن اولیه با تابع 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

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

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

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

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

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

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

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

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

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

>>> x == 3537115888337719 / 1125899906842624
True

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

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

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

>>> 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)))   # جمع دقیق با یک گرد کردن نهایی
8.042173697819788e-13
>>> math.fsum(arr)                   # یک گرد کردن نهایی
8.042173697819788e-13
>>> sum(arr)                         # چندین گرد کردن با دقت گسترده
8.042178034628478e-13
>>> total = 0.0
>>> for x in arr:
...     total += x                   # چندین گرد کردن با دقت استاندارد
...
>>> total                            # جمع مستقیم هیچ رقم صحیحی ندارد!
-0.0051575902860057365

15.1. خطای نمایش

این بخش مثال "0.1" را با جزئیات توضیح می‌دهد و نشان می‌دهد که چگونه می‌توانید خودتان تحلیل دقیقی از چنین مواردی انجام دهید. فرض بر این است که با نحوه نمایش اعداد ممیز شناور دودویی آشنایی ابتدایی دارید.

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

چرا چنین است؟ 1/10 نمی‌تواند به‌طور دقیق به شکل یک کسر دودویی نمایش داده شود. از سال ۲۰۰۰ میلادی تاکنون، تقریباً همه ماشین‌ها از محاسبات ممیز شناور دودویی IEEE 754 استفاده می‌کنند و تقریباً همه پلتفرم‌ها اعداد float پایتون را به مقادیر «دقت مضاعف» "IEEE 754 binary64" نگاشت می‌کنند. مقادیر IEEE 754 binary64 دارای ۵۳ بیت دقت هستند، بنابراین هنگام ورود مقدار، کامپیوتر تلاش می‌کند "0.1" را به نزدیک‌ترین کسری که به شکل 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

بنابراین بهترین تقریب ممکن برای "1/10" در دقت مضاعف IEEE 754 برابر است با:

7205759403792794 / 2 ** 56

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

3602879701896397 / 2 ** 55

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

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

>>> 0.1 * 2 ** 55
3602879701896397.0

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

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

یعنی مقدار دقیق ذخیره‌شده در کامپیوتر برابر با مقدار اعشاری زیر است: 0.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'