ফ্লোটিং পয়েন্ট নাম্বার ও কিছু সমস্যা

Aug 1, 2014

সত্যি কথা বলতে ফ্লোটিং পয়েন্ট নাম্বার একটা পেইন। ফ্লোটিং পয়েন্ট নাম্বার হল বাংলা কথায় ভগ্নাংশ সংখ্যা। সি তে এর জন্য দুটি ডাটা টাইপ আছে, float এবং double । যেকোন নাম্বার যদি ভগ্নাংশ হয় তাহলে তাকে কম্পিউটার বিশেষ প্রক্রিয়ায় মেমরিতে রাখে। আমরা জানি কম্পিউটার বাইনারি ছাড়া কিছু বোঝে না। তাই সে যাই তার মেমরিতে রাখুক না কেন, সবকিছুকে বাইনারিতে রূপান্তর করে নেয়। যেকোন পূর্ণ সংখ্যাকে অতি সহজেই যদিও বা বাইনারিতে রূপান্তর করে মেমরিতে রাখা যায়, ফ্লোটিং পয়েন্ট নাম্বারের ক্ষেত্রে তা নয়।

মেমরিতে নাম্বার রাখার প্রক্রিয়া

ধরা যাক আমরা -1.5 কে মেমরিতে রাখবো। একে বাইনারিতে রূপান্তর করলে হয় -1.1। -1.5 কে যেমন লেখা যায় -1.5 x 10^0 তেমনি -1.1 কে লেখা যায় -1.1 x 2^0। এখানে নাম্বারটিকে নরমালাইজ করে নিতে হবে। অর্থাৎ -0.00015 থাকলে সেটা -1.5 x 10^-4 এরকম ভাবে লিখতে হবে। বাইনারির ক্ষেত্রেও তাই।

নরমালাইজ করে লেখা এই বাইনারি নাম্বারের তিনটা অংশ আছে। প্রথমে সাইন (+/-), এরপর 1.1 মূল নাম্বারটা এবং তারপর 2^0। প্রত্যেকটি নাম্বারকে এরকমভাবে প্রকাশ করা যায়। একটা জিনিস খেয়াল করা দরকার, 1.1 যে অংশটা আছে, যেহেতু নাম্বারটি নরমালাইজ করা হয়েছে সেহেতু সবসময় . এর বামে একটা 1 থাকবে। এরজন্য নাম্বারটির যে পরিবর্তন হয় সেই অনুযায়ী 2 এর পাওয়ার পরিবর্তন করে নেওয়া হয় যেন সমগ্র নাম্বারের মানের কোন পরিবর্তন না ঘটে। আরেকটা জিনিস হল যে নাম্বারটি যেহেতু বাইনারি, অতএব সবসময় আমরা 2 এর কোন একটা পাওয়ার দিয়েই গুণ করব।

এখন তাহলে নাম্বাররের মধ্যে যে পরিবর্তন যোগ্য অংশগুলো থাকে তা হল সাইন, . এর ডান পাশের অংশটুকু, এবং 2 এর পাওয়ার কত সেটি। মেমরিতে মূলত এই তিনটি তথ্যই রাখা থাকে। সাইন বিট (S), 2 এর পাওয়ার কত হবে (exponent, E) এবং . এর ডানে কি থাকবে (mantissa, M)। S এর মান 0/1 হয় নাম্বারটি পজিটিভ নাকি নেগেটিভ তার উপর ভিত্তি করে। E এর মান হল নাম্বারটিকে 2 এর যে পাওয়ার দিয়ে গুণ করা হচ্ছে সেটি। তবে মেমরিতে রাখার সময় সেটির সাথে b (bias) যোগ করে তারপর রাখা হয়। b এর মান 32 bit এ 127 এবং 64 bit এ 1023। এরপর M এ যা থাকে তা হল নাম্বারটির 1. এর পর যেই বিটগুলো আছে সেগুলো।

সমস্যা

ফ্লোটিং পয়েন্ট নাম্বারের যে সব সমস্যা আছে তার মধ্যে একটা বড় সমস্যা হল অসীম ধারা। এক-তৃতীয়াংশ, একে আমরা সহজেই বাংলায় ১/৩ লিখে গণিতে সুন্দর করে ব্যবহার করতে পারি। কিন্তু যখন এর দশমিক রূপ দেখা হবে, তখনই দেখা যায় এটা একটা অসীম ধারা, ০.৩৩৩৩৩… (অসীম পর্যন্ত)। এই ধরনের নাম্বার দেখেই বোঝা যাচ্ছে যে একে বাইনারিতে রূপান্তর করে নিখুঁতভাবে মেমরিতে রাখা সম্ভব না। কারণ মেমরির সীমা আছে, কিন্তু এই নাম্বারগুলোর সীমা নেই।

আরেক ধরনের সমস্যা দেখা যাক। সহজ সুন্দর একটা নাম্বার ০.১। ছোট এবং সহজ একটা নাম্বার, দেখে কোনভাবেই মনে হয় না যে একে মেমরিতে রাখতে কোন সমস্যা হতে পারে। কিন্তু হ্যা, একে মেমরিতে রাখা বিশাল সমস্যা! ০.১ ডেসিমেলে যতটা সুন্দর বাইনারিতে ততটা না! একে বাইনারিতে রূপান্তর করলে যা পাওয়া যায় তা হল ০.০০০১১০০১১০০১১০০১১… (অসীম পর্যন্ত)। আবারও সেই অসীম ধারা! এবার আবার সেটি হল বাইনারিতে। এখান থেকেই কিছুটা হলেও আন্দাজ করা যায় যে এইসব নাম্বার মেমরিতে রাখতে গেলে কিছু ডাটা হারিয়ে যায়। এই কারণে আমরা যে নাম্বারটি চাই ঠিক সেটি অনেক সময় পাওয়া যায় না।

একটা কোড থেকে এর বাস্তব সমস্যা দেখা যাক।

double value = 0.1;
printf( "%.17lf", value );
Output: 0.10000000000000001

অতএব দেখা যাচ্ছে আমরা রাখলাম ০.১, আউটপুট ঠিক ০.১ না এসে একটু বেশি আসলো। আরেকটি কোড দেখা যাক।

double value = 0.1 + 0.1 + 0.1 + 0.1 + 0.1 +
               0.1 + 0.1 + 0.1 + 0.1 + 0.1;
printf( "%.16lf", value );
Output: 0.9999999999999999

এখানে যেটা করা হয়েছে তা হল ১০ টা ০.১ কে যোগ করে একটা ডাবল ভেরিয়েবলে রাখা হয়েছে। ১০ টা ০.১ এর যোগফল ১.০ হওয়ার কথা, সাধারণ যোগ। কিন্তু যখন আউটপুটে দেখা যাচ্ছে ঠিক ০.১ না এসে একটু কম আসলো।

এই দুইটা উদাহরণ দেখানোর মূল উদ্দেশ্য এটা বোঝানো যে ফ্লোটিং পয়েন্ট নাম্বার নিয়ে কাজ করতে গেলে প্রিসিশনে সমস্যা থাকে। অর্থাৎ ঠিক যে নাম্বার পাওয়া যাওয়ার কথা, বা যে নাম্বার মেমরিতে থাকার কথা ঠিক সেটি থাকে না। একটু এদিক সেদিক হয়। যদিও বা এটা দশমিকের বেশ কয়েক ঘর পরে, অনেক সময় কনটেস্টে বিভিন্ন গাণিতিক সমস্যা সমাধান করতে যেয়ে এটার প্রভাব দেখা যায়। এটার কারণে অনেক সময়ই Wrong Answer আসে। একারণে ফ্লোটিং পয়েন্ট নাম্বার যত কম ব্যবহার করা যায় তত বেশি নিরাপদ থাকা যায়।

ফ্লোটিং পয়েন্ট নাম্বারের তুলনা করা

এইখান থেকে যে সমস্যার উদ্ভব হল তার একটি হল দুইটা ফ্লোটিং পয়েন্ট নাম্বার তুলনা করা। অর্থাৎ relational operator সমূহ ব্যবহার করে দুইটা নাম্বার কি সমান, নাকি একটা আরেকটার চেয়ে বড় বা ছোট এটা নির্ণয় করা। আরেকটা কোড দেখা যাক।

float value1 = 1.345;
float value2 = 1.123;
float total = value1 + value2; // মান হওয়ার কথা 2.468
if( total == 2.468 )
    printf( "They are equal! :)" );
else
    printf( "They are not equal! :(" );
Output: They are not equal! :(

এই আউটপুট কেন? ঐ যে একই কথা, প্রিসিশনে সমস্যা। দশমিকের পর মাত্র তিন ঘর হলেও মেমরিতে তো আর তিন ঘর থাকে না। পুরোটাই থাকে এবং শেষের দিকের ডিজিট যে উলটাপালটা হয় তা তো একটু আগেই দেখা গেল। এই কারণে দুইটা সমান আসছে না। অতএব তুলনা করার প্রক্রিয়া পাল্টাতে হবে।

“যথেষ্ট” কাছাকাছি কিনা

নতুন যে প্রক্রিয়ায় তুলনা করা হবে সেখানে সরাসরি দুইটা নাম্বার সমান কিনা তুলনা করা হয় না। যেটি করা হয় তা হল দুইটা নাম্বার কি একটা আরেকটার “যথেষ্ট” কাছাকাছি কিনা তা দেখা হয়। আর এই যে যথেষ্ট কাছাকাছি কিনা তা দেখার জন্য একটা অতি ক্ষুদ্র নাম্বারের সাহায্য নেওয়া হয়। যদি নাম্বার দুইটার ব্যবধান ঐ ক্ষুদ্র নাম্বারটির চেয়ে ছোট হয় তাহলে ধরে নেওয়া হয় তারা যথেষ্ট কাছাকাছি, এবং তাদেরকে তখন সমান বলে গণ্য করা হয়। ব্যাপারটা সহজ করে বোঝানোর জন্য ধরা যাক দুটি নাম্বার আছে আমাদের কাছে 100 এবং 101 । এখন এদের ব্যবধান যদি একটি অতি ক্ষুদ্র নাম্বার (এক্ষেত্রে ধরা যাক 2) এর চেয়ে ছোট হয় তাহলে আমরা ধরে নিব নাম্বার দুটি সমান কারণ নাম্বার দুটির তুলনায় 2 অনেক ছোট। বাস্তবে কিন্তু নাম্বার দুটি সমান না, দেখাই যাচ্ছে, একটা 100, আরেকটা 101 । আমরা ধরে নিব এরা যথেষ্ট কাছাকাছি, তাই এরা সমান।

ছোট নাম্বারের সাপেক্ষে তুলনা

এই যে আমরা ছোট নাম্বার নিব সেটি হতে পারে 1e-8 বা 1e-9 বা 1e-10 যাদের মানে যথাক্রমে 1 * 10^(-8), 1 * 10^(-9) এবং 1 * 10^(-10) । নাম্বারটি কত ছোট হবে তা আমাদের প্রয়োজনের উপর নির্ভর করে। কত কাছাকাছি হলে আমরা ধরে নিব যে নাম্বার দুটি সমান তার উপর নির্ভর করে নাম্বারটি কত হবে। এই নাম্বারকে আমরা বলি epsilon (এপসাইলন) বা সংক্ষেপে EPS । উপরে যে কোডটি দেখানো হয়েছে সেটিই এবার EPS এর সাহায্যে তুলনা করে দেখা যাক।

float EPS = 1e-5;
float value1 = 1.345;
float value2 = 1.123;
float total = value1 + value2; // মান হওয়ার কথা 2.468
if( fabs( total - 2.468 ) <= EPS )
    printf( "They are equal! :)" );
else
    printf( "They are not equal! :(" );

 

Output: They are equal! :)

এখানে কি হচ্ছে দেখা যাক। আমরা শুরুতেই EPS ডিক্লেয়ার করলাম যার মান দিলাম 1e-5 । অর্থাৎ যদি তুলনা করার নাম্বার দুটির ব্যবধান এর সমান বা ছোট হয় আমরা ধরে নিব তারা সমান বলার মত যথেষ্ট কাছাকাছি। এরপর ৫ম লাইনে আমরা সেই তুলনা করলাম। fabs( ) ফাংশনটি কোন ফ্লোটিং পয়েন্ট নাম্বারের পরম মান রিটার্ন করে। আমরা এখানে শুধু নাম্বার দুটির ব্যবধান হিসাব করব বিধায় তাদের বিয়োগফলের পরম মান নিয়েছি। এরপর দেখলাম সেটি EPS এর সমান বা ছোট কিনা। যদি তা হয় তাহলে নাম্বার দুটিকে আমরা সমান বলব। না হলে এরা সমান না। অতএব প্রিসিশন ক্ষুদ্র হেরফেরটুকু হিসাবে আনার জন্য অতি ক্ষুদ্র একটা মান EPS নেওয়া হয়। একই যুক্তি ব্যবহার করে আমরা আরও বেশ কিছু তুলনা করতে পারি, নিচে দেখানো হল।

double a, b;
double EPS = 1e-9;
// a < b তুলনার জন্য
if( a + EPS < b )
// a > b তুলনার জন্য
if( a > b + EPS )
// a > 0 তুলনার জন্য
if( a > EPS )
// a < 0 তুলনার জন্য
if( a < -EPS )

-0.0 এবং nan

সাইন-ম্যান্টিসা-এক্সপোনেন্ট এই পদ্ধতিতে নাম্বার রাখার ফলে একটা উদ্ভট ঘটনা ঘটতে পারে। সেটি হল ঋণাত্মক শূন্য! শূন্য তো শূন্যই - এটি আবার ধনাত্মক ঋণাত্মক হয় কিভাবে? আসলে সমস্যাটা হয় নাম্বার সংরক্ষণের পদ্ধতির কারণে। মেমরিতে শূন্যকে রাখার জন্য ম্যান্টিসা এবং এক্সপোনেন্টের সবগুলো বিট ০ করে দেওয়া হয়। এরপর অবশিষ্ট যে সাইন বিটটি থাকে সেটি নিয়ে অসুবিধার উদ্ভব হয়। সেটি 0 হলে নাম্বারটি +0.0 আর সেটি 1 হলে নাম্বারটি হয় -0.0! ছোট একটি কোড রান করলেই ব্যাপারটা দেখা যাবে।

double x = -0.0;
cout << x << endl;

আরেক ধরনের নাম্বার হল Not a Number (nan)! nan দিয়ে মূলত অবাস্তব সংখ্যাকে বোঝানো হয়। যেমন sqrt(-1.0) একটি nan। কখনও যদি আউটপুটে nan আসে তাহলে বুঝতে হবে ভিতরের হিসাবে কোথাও এরকম কিছু হয়েছে যেখানে কোন ঋণাত্মক নাম্বারের বর্গমূল বের করার চেষ্টা করা হয়েছে। নিচের কোডটি রান করলেই দেখা যাবে কিভাবে nan আউটপুট আসে।

cout << sqrt(-1.0) << endl;

long double

যেহেতু মেমরিতে বিট সংখ্যা সীমাবদ্ধ থাকে তাই হিসাব সবসময় একদম নিখুঁত হয় না। স্বাভাবিকভাবেই বিট সংখ্যা বাড়লে প্রিসিশন ভালো হবে, ফলে হিসাব আরো বেশি নিখুঁত হবে। একারণে long double ব্যবহার করে প্রিসিশন বাড়ানো যেতে পারে। তবে এটা প্ল্যাটফর্ম এর উপর নির্ভর করে। যেমন g++ এ long double ডাটা টাইপটির জন্য মেমরিতে 80 বিট মেমরি এলোকেট হয়। অন্যদিকে MSVC++ এ long double ডাটা টাইপ থাকলেও সেটি double এ ম্যাপ করা। অর্থাৎ long double ব্যবহার করলে আসলে 64 বিটের double ডাটা টাইপ হিসাবে কাজ করে।

একই হিসাব বার বার করলে ভিন্ন ফল আসতে পারে!

ঘটনাটি সবক্ষেত্রে সত্যি না। তবে অনেকসময় এরকম হতেই পারে যে একই হিসাব একবার একরকম ফল দিল, আরেকবার আরেকরকম দিচ্ছে। এর কারণ হল কম্পাইলার অনেকসময় প্রিসিশন ঠিক রাখার চেষ্টা করতে গিয়ে ইন্টারনালি double এর জায়গায় long double নিয়ে কাজ করতে পারে। সবশেষে যে আউটপুট দেওয়ার কথা সেটিকে double এ টাইপকাস্ট করে নিবে। কম্পাইলার যদি একসময় ঠিক করে যে সে long double নিয়ে কাজ করবে, আবার অন্য কোনসময় ঠিক করে যে double নিয়ে কাজ করবে তাহলে একই হিসাবের আউটপুট ভিন্ন হতেই পারে!

দুঃখজনক হলেও সত্য যে এটি এমন একটি বাগ যেটি ধরতে পারা প্রায় অসম্ভব ব্যাপার। ধরা যাক আমরা ডিবাগ করার জন্য প্রতিটি স্টেটমেন্টের মাঝে আউটপুট চেক করছি। সেখানে ভেলুগুলো double এ টাইপকাস্ট হয়ে যাবে। ফলে আমরা প্রতিটি স্টেপে যেয়ে দেখবো যে সবকিছু ঠিকমত কাজ করছে। কিন্তু যখন সবগুলো ডিবাগ স্টেটমেন্ট উঠিয়ে দেওয়া হবে তখন আবার সে long double নিয়ে কাজ করে অন্যরকম ভেলু দিতে পারে! এর জন্য একটা সমাধান হতে পারে সবক্ষেত্রে long double নিয়ে কাজ করা।

তবে আরেকটি সমস্যা হতে পারে। সেটি হচ্ছে অনেকক্ষেত্রে আমাদের কোড এর এক্সিকিউশন অপ্টিমাইজ করতে যেয়ে কম্পাইলার হিসাবের সিকোয়েন্স পাল্টাতে পারে। ভিন্ন ভিন্ন ক্ষেত্রে কম্পাইলার একই কোড ভিন্ন ভিন্ন ভাবে এক্সিকিউট করতে পারে। এতে করে ভিন্ন আউটপুট দেখা যেতে পারে।

যেমন একটি স্টেটমেন্ট যদি এমন হয় x+y-z সেটিকে কম্পাইলার (x+y)-z অথবা x+(y-z) এর যেকোনভাবে এক্সিকিউট করতে পারে। প্রথমে মনে হতে পারে এ আর এমন কি! রেজাল্ট তো একই আসবে। কিন্তু না, সবসময় একই আসবে না। নিচের কোডটি রান করলেই তা দেখা যাবে।

double x = 1.0;
double y = 1e30;
double z = 1e30;
double out1 = (x+y)-z;
double out2 = x+(y-z);
cout << out1 << ' ' << out2 << endl;

অতএব একই কোড একাধিক জায়গায় থাকলেও আমরা বলতে পারবো না যে সেটি একই আউটপুট দিবে। এই সমস্যা দূর করার জন্য একটা উপায় হল যেই কোডটি একাধিক জায়গায় ব্যবহার করা হচ্ছে সেটিকে একটি ফাংশন হিসেবে লিখে সেই ফাংশনটি দুই জায়গায় কল করা। তাহলে একই আউটপুট আসবে।

রেফারেন্সঃ টপকোডার টিউটোরিয়াল