العمليات الحسابية على الأعداد العشرية: مشاكل ومعوقات

من موسوعة حسوب
مراجعة 06:05، 25 مارس 2018 بواسطة عبد اللطيف ايمش (نقاش | مساهمات) (نقل عبد اللطيف ايمش صفحة Python/float arithmatics إلى Python/float-arithmatics دون ترك تحويلة)

تمثّل الأرقام العشرية ذات الفاصلة العائمة float في الحاسوب باستخدام كسور النظام الثنائي binary (الأساس 2)، فعلى سبيل المثال، الكسر العشري 0.125 يمتلك القيمة 1/10 + 2/100 + 5/1000، وبنفس الطريقة يمتلك الكسر الثنائي 0.001 القيمة 0/2 + 0/4 + 1/8.

يمتلك هذا الكسران القيم ذاتها، ولكن الفرق الوحيد بينهما هو أنّ الأول مكتوب بواسطة التمثيل الكسري ذي الأساس 10، أما الثاني فممثل بالأساس 2.

ولكن لا يمكن تمثيل معظم الكسور العشرية ككسور ثنائية مضبوطة، ونتيجة لذلك، فإنّ الأعداد العشرية ذات الفاصلة العائمة تقرّب بواسطة الأعداد الثنائية ذات الفاصلة العائمة المخزّنة في جهاز الحاسوب.

من السهل في البداية أن تفهم المشكلة في النظام العشري، فالكسر 1/3 على سبيل المثال يمكن تقريبه ككسر عشري إلى 0.3 أو أفضل من ذلك إلى 0.33 أو أفضل من ذلك إلى 0.333 وهكذا.

مهما كان عدد الأرقام التي ستضيفها فإنّ النتيجة لن تكون مساوية بالضبط للكسر 1/3، ولكن التقريب سيكون أفضل في كلّ مرة.

وبنفس الطريقة، مهما كان عدد الأرقام الثنائية التي سترغب في استخدامها، لا يمكن تمثيل القيمة 0.1 بدقة ككسر في النظام الثنائي، فقيمة الكسر 1/10 هذا النظام هي:

0.0001100110011001100110011001100110011001100110011...

يمكنك التوقف عند أيّ عدد من البتات، وستحصل على قيمة مقرّبة للعدد. تقرّب معظم أجهزة الحاسوب الحالية الأعداد العشرية ذات الفاصلة العائمة باستخدام كسر ثنائي يكون فيه البسط أول 53 بتاً تبدأ من البت الأكثر أهمية، والمقام كأُسّ للعد 2. وفي حالة العدد 1/10 يكون الكسر الثنائي هو:  ‎3602879701896397 / 2 ** 55‎ وهي نتيجة مقاربة جدًّا للقيمة الحقيقية للكسر 1/10.

لا يشعر معظم المستخدمين بعملية التقريب هذه بسبب طريقة عرض القيم، إذ تطبع بايثون فقط قيمة عشرية تقريبية للقيمة العشرية الحقيقية الخاصة بعملية التقريب الثنائية والمخزّنة في الحاسوب. وفي معظم الأجهزة، لو طبعت بايثون القيمة العشرية الحقيقية للتقريب الثنائي المخزّن للرقم 0.1، فإن النتيجة ستكون:

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

هذا الكم الكبير من الأعداد ليس مفيدًا في نظر الكثير من الناس؛ لذا تتحكم بايثون في عدد الأرقام وذلك بعرض القيمة المقرّبة فقط:

>>> 1 / 10
0.1

تذكّر دائمًا أنّه وعلى الرغم من أن القيم المطبوعة تبدو مثل القيمة الحقيقية للكسر 1/10، إلا أنّ القيمة الحقيقية المخزّنة في الحاسوب هي أقرب تمثيل للكسر الثنائي.

إلى جانب ما سبق، هناك العديد من الأرقام العشرية المختلفة التي تتشارك مع بعضها نفس الكسر الثنائي المقرّب، فعلى سبيل المثال تقرّب الأعداد 0.1 و 0.10000000000000001 و .1000000000000000055511151231257827021181583404541015625 عن طريق الكسر ‎3602879701896397 / 2 ** 55‎. ولما كانت الأرقام السابقة جميعها تتشارك في نفس مقدار التقريب، يمكن لأيّ منها أن يعطي النتيجة True عند اختبارها باستخدام التعبير:eval(repr(x)) == x.

في الإصدارات السابقة من بايثون يختار المحثّ والدالة الداخلية repr()‎ العدد الذي يمتلك 17 رقمًا معنويًّا، وهو 0.10000000000000001 .

أما في الإصدار 3.1 وما بعده فقد أصبحت اللغة (وفي معظم أنظمة التشغيل) قادرة على اختيار أقصر النتائج وستعرض العدد 0.1 فقط.

لاحظ أنّ سبب المشكلة عائد إلى طبيعة الأعداد الثنائية ذات الفاصلة العائمة، وهي ليست عيبًا في بايثون ولا في شيفرتك البرمجية، وستلاحظ وجود هذه المشكلة في جميع اللغات البرمجية التي تدعم العمليات الحسابية على الأعداد ذات الفاصلة العائمة في جهازك، على أنّ بعض اللغات لا تعرض بصورة افتراضية الفروقات بين الأعداد، أو لا تعرضها في كل أشكال المخرجات).

يمكن الحصول على مخرجات أوضح عن طريق تنسيق السلسلة النصية لإظهار عدد محدّد من الأعداد المعنوية:

>>> format(math.pi, '.12g')  # يعطي 12 عددًا معنويًّا
'3.14159265359'
>>> format(math.pi, '.2f')   # يعطي عددين بعد الفاصلة العشرية
'3.14'
>>> repr(math.pi)
'3.141592653589793'

من الضروري الانتباه إلى أنّ ما يحدث في الواقع هو وهم نظري، فما يجري هو عملية تقريب للعدد المعروض من قبل الحاسوب فقط.  وقد يؤدي ذلك إلى الوقوع في وهم آخر، فعلى سبيل المثال، لما كان العدد 0.1 لا يساوي بالضبط 1/10 فإن جمع 0.1 مع نفسها مرتين قد لا يعطي الناتج 0.3 بالضبط:

>>> .1 + .1 + .1 == .3
False

كذلك لا يمكن للعدد 0.1 أن يصل إلى القيمة المضبوطة للكسر 1/10 كما لا يمكن للعدد 0.3 أن يصل إلى القيمة المضبوطة للكسر 3/10، وهكذا فإنّ التقريب المسبق باستخدام الدالة round()‎ لن يجدي نفعًا:

>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

صحيح أنّ الأعداد لا يمكن تقريبها إلى القيمة المضبوطة، ولكن تكون الدالة round()‎ مفيدة لإجراء التقريب اللاحق، وبهذا يصبح بالإمكان مقارنة الأعداد ذات القيم غير الدقيقة بعضها ببعض:

>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

هناك الكثير من المفاجئات المماثلة التي تظهر عند التعامل مع الأعداد الثنائية ذات الفاصلة العائمة. يناقش القسم "أخطاء التمثيل" أدناه مشكلة العدد 0.1 بالتفصيل.

ومع ذلك يجب عدم الإفراط في الخوف من التعامل مع الأعداد الثنائية ذات الفاصلة العائمة، فالأخطاء التي تظهر في العمليات التي تجري على هذه الأعداد ناتجة من طريقة تعامل الحاسوب مع هذه الأرقام، وفي معظم الأجهزة تكون نسبة الأخطاء جزءًا من ‎2**53‎ لكل عملية، وهذا المقدار كافٍ في معظم الحالات، ولكن يجب الانتباه إلى أنّ العمليات التي تجري على الأعداد ليست عشرية وأنّ كلّ عملية حسابية ستعاني من خطأ جديد في التقريب.

قد تظهر الحاجة إلى تجنّب هذه الأخطاء في التطبيقات التي تعالج حالات مرضية، ولكن في معظم الحالات الأخرى يكفي ببساطة تقريب الأعداد المعروضة في النتيجة النهائية إلى عدد الأرقام المطلوبة أو المتوقعة. وعادة ما يكفي استخدام الدالة str()‎، وللتحكم في هذه الدالة بصورة أكبر يمكنك مراجعة محدّدات التنسيق في تابع str.format()‎ في فصل تنسيق السلاسل النصية.

إن كانت هناك حاجة إلى تمثيل الأرقام العشرية بدقّة فيمكن استخدام وحدة decimal والتي تتضمن عمليات حسابية مناسبة للتطبيقات المحاسبية والتطبيقات التي تتطلب دقّة عالية جدًّا.

تتضمّن الوحدة fractions كذلك عمليات حسابية دقيقة بالاعتماد على الأعداد الكسرية (rational numbers) وبذلك يمكن تمثيل العدد 1/3 مثلًا بدقّة.

إن كنت ممّن يستخدمون الأرقام ذات الفاصلة العائمة بكثرة فيجدر بك إلقاء نظرة على حزمة Numerical Python أو الحزم الأخرى الخاصّة بالعمليات الحسابية والإحصائية والتي يوفّرها مشروع SciPy.

تقدّم بايثون بعض الأدوات التي تساعد في الحالات النادرة التي ترغب فيها بمعرفة القيمة المضبوطة للعدد العشري. يمكن استخدام التابع as_integer_ratio()‎ لعرض قيمة العدد العشري على هيئة كسر عشري:

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

ولمّا كانت النسبة دقيقة، فبالإمكان استخدامها لإعادة إنشاء القيمة الأصلية:

>>> x == 3537115888337719 / 1125899906842624
True

يعرض التابع hex()‎ العدد العشري بصيغة ست عشرية (الأساس 16) ويعطي القيمة المضبوطة والمخزّنة بواسطة الحاسب:

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

يمكن استخدام التمثيل الست عشري هذا لإعادة بناء القيمة العشرية ذاتها:

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

طرق التمثيل هذه دقيقة؛ لذا يمكن الاعتماد عليها في نقل القيم إصدارات مختلفة من بايثون (بغض النظر عن منصّة التشغيل) أو تبادل البيانات مع لغات البرمجة الأخرى التي تدعم التنسيق ذاته (مثل جافا و C++‎). الدالة math.fsum()‎ مفيدة أيضًا؛ إذ أنّها تخفّف مقدار الدقة المفقودة أثناء إجراء عمليات الجمع. تتابع هذه الدالة الأعداد المفقودة عند إضافة قيم جديد إلى المجموع الكلي. يساعد هذا الأمر على تقليل الأخطاء الإجمالية إذ لا تتجمع الأخطاء إلى درجة تؤدي إلى التأثير على الناتج النهائي:

>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

أخطاء التمثيل

يوضح هذا القسم مشكلة العدد 0.1 بالتفصيل، ويعرض الطريقة التي يمكنك اتباعها لإجراء تحليل دقيق للحالات المشابهة، ويفترض هذا القسم أن يكون لديك إلمام بسيط بطريق تمثيل الأعداد الثنائية ذات الفاصلة العائمة.

أخطاء التمثيل ناتجة من عدم إمكانية تمثيل بعض (في الواقع معظم) الكسور العشرية ككسور ثنائية (الأساس 2). وهذا هو السبب الرئيسي الذي يجعل بايثون (أو Perl، و C، و C++‎، وجافا، و Fortran وغيرها الكثير) لا تعرض القيمة الدقيقة للأعداد العشرية كما هو متوقع.

السبب في ذلك أنّ الكسر 1/10 غير قابل للتمثيل الدقيق باستخدام الكسور الثنائية، وتستخدم معظم الأجهزة المعيار IEEE-754 لإجراء العمليات الحسابية على الأعداد ذات الفاصلة العائمة، وتحوّل معظم المنصّات الأعداد العشرية في بايثون حسب المعيار IEEE-754 "double precision"‎. يتضمّن هذا المعيار 53 بتًّا من الدقة، وعند إدخال العدد 0.1 إلى الحاسوب فإنّه يحاول تحويل هذا العدد إلى أقرب كسر ممكن يحمل الصيغة J/2**N، و J هو عدد صحيح يتضمن 53 بتًّا بالضبط. 

تعاد كتابة:

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

إلى:

J ~= 2**N / 10

وإن أخذنا بنظر الاعتبار أنّ J يمتلك 53 بتًّا بالضبط (أكبر من أو يساوي ‎2**52 ولكن أصغر من ‎2**53‎) فإنّ أفضل قيمة تأخذها N هي 56:

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

بمعنى أن 56 هي القيمة الوحيدة التي يمكن أن تأخذها N بحيث تمتلك J بالضبط 53 بتًّا. وهكذا تصبح أفضل قيمة يمكن أن تأخذها J هي القيمة المقرّبة للناتج:

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

ولمّا كان الباقي أكبر من 5، فإنّ أفضل تقريب يكون للقيمة الأعلى:

>>> q+1
7205759403792794

وهكذا يصبح أفضل تقريب للكسر 1/10 في المعيار ‎754 double precision هو:

7205759403792794 / 2 ** 56

وبتقسيم البسط والمقام على 2 يختصر الكسر إلى:

3602879701896397 / 2 ** 55

لاحظ أنّه التقريب كان للقيمة الأعلى، وهذا يعني أنّ الرقم الذي حصلنا عليه أكبر بقليل من القيمة الحقيقية للكسر 1/10، وإن لم نجرِ عملية التقريب فإنّ الحاصل سيكون أصغر قليلًا من القيمة الحقيقية للكسر 1/10. ولا يمكن بأيّ حالٍ من الأحوال أن نحصل بالضبط على القيمة الحقيقية. وهكذا لا يمكن للحاسوب أن يرى الكسر 1/10، وما يراه هو الكسر الذي حصلنا عليها في أعلاه، وأفضل تقريب مبني على المعيار السابق هو:

>>> 0.1 * 2 ** 55
3602879701896397.0

وإن ضربنا ذلك الكسر بالقيمة ‎10**55‎ فيمكننا حينئذٍ مشاهدة القيمة مع 55 مرتبة عشرية:

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

وهذا يعني أنّ الرقم الحقيقي المخزّن في الحاسوب مساوٍ للقيمة العشرية:

0.1000000000000000055511151231257827021181583404541015625

لا تعرض معظم اللغات البرمجية (وبضمنها الإصدارات القديمة من بايثون) القيمة العشرية كاملة، بل تقرّبها إلى 17 عدد معنوي:

>>> 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'

مصادر