ทำไม 0.3 + 0.6 ถึงได้ 0.8999999 กับปัญหา Floating Point Approximation

อ่านชื่อหัวเรื่องมา เราว่านักคณิตศาสตร์กำหมัดแล้วนะ แต่ลองดูกันได้ เอา 0.3 + 0.6 ด้วยเครื่องคอมพิวเตอร์ ลองทำผ่าน Python ขำ ๆ ดูก็ได้ เราจะเห็นว่า มันจะได้ออกมาเป็น 0.89999 ไปเรื่อย ๆ เลย ทั้ง ๆ ที่จริง ๆ แล้ว เราบวกเอง มันก็ควรจะได้ 0.9 เป๊ะ ๆ ซึ่งเรื่องนี้ มันเกิดจากสิ่งที่เรียกว่า Floating Point Approximantion บนเครื่องคอมพิวเตอร์นั่นเอง

Floating Point Representation in Computer

เนื่องจากเครื่องคอมพิวเตอร์ มันเป็นเครื่องที่ทำงานบนระบบเลขฐานสอง ไม่ได้ใช้เลขฐานสิบเหมือนที่เราใช้งานกัน ฟิล ๆ เหมือนกับ เราคุยคนละภาษากัน ดังนั้น เลขที่เราเห็นเป็นฐานสิบ จริง ๆ แล้ว เวลาเครื่องคอมพิวเตอร์มันเก็บ มันก็จะเก็บเป็นฐานสองอยู่ดี

ซึ่งการแปลงเลขฐานเป็นเรื่องไม่ยากเท่าไหร่เนอะ เรียนกันตั้งแต่ประถมแล้ว เช่นเราบอกว่า เราจะแปลง 5 ฐาน 10 ให้กลายเป็นฐาน 2 ก็จะเป็น 11 ใช่มะ เพราะเรา เอา 5 หาร 2 ได้ 1 แล้วเหลือเศษ 1 ก็เลยกลายเป็น 11

วิธีนี้ใช้เยอะมาก ๆ เวลาสอบ ต้องแปลงหลาย ๆ ตัว เราก็จะเขียนตารางแบบนี้แหละ แล้วเอาตัวเลขเรียง ๆ ไปเรื่อย ๆ เผื่อเจอเลขซ้ำ เราก็จะไม่ต้องแปลงอีก

หรืออีกวิธีที่เราใช้เยอะมาก ๆ เวลาเรานับเร็ว ๆ คือ เราจำสองกำลังได้ คือ 1,2,4,8,16,32 ไปเรื่อย ๆ ถ้าเราบอกว่า จะเอา 30 มันมากกว่า 16 แต่น้อยกว่า 32 ทำให้ 16 ต้องเป็น 1 จากนั้นเอา 16 ไปลบ มันจะเหลือ 14 ก็มากกว่า 8 ทำให้ตำแหน่งของ 8 ต้องเป็น 1 จากนั้นจะเหลือ 6 ก็ไปลง 2 กับ 4 ได้เลย เท่านี้เองก็จะได้เลขออกมาแล้ว จะเป็น 11110

ถามว่า แล้วถ้าเป็นจุดทศนิยมละ เราจะทำยังไง ก็หลักการเดียวกัน เราก็คือ ยกกำลังติดลบ มันก็คือ 1 ส่วน 2 กำลัง อะไรก็ว่าไป มันก็ไล่ลงไปตั้งแต่ 0.5, 0.25, 0.125, 0.0625 ไปเรื่อย ๆ เลย เช่น 0.4 ก็จะแทนได้ด้วย 0.0110 0110 0110 0110 0110 0110 0 ฐานสอง ก็เรียบร้อย

ทีนี้ เวลาเราเก็บจริง ๆ เราจะต้องเก็บทั้งจำนวนเต็มด้านหน้า และจุดทศนิยมด้วย พร้อมกับเครื่องหมาย บวก หรือ ลบอีก ซึ่งมันเก็บได้หลายวิธีมาก ๆ เป็นเรื่องที่ไม่ดี เพราะไม่งั้น แต่ละคนเก็บกันคนละแบบ เวลาอ่านก็แย่เลย ต้องมานั่ง Convert อะไรอีกเสียเวลา

ในการเก็บข้อมูล ที่เราเคยได้ยินเครื่องคอมพิวเตอร์แบบ 32 หรือ 64-Bits ก็คือตรงนี้แหละ ถ้าไปหาอ่านเพิ่มลองหา IEEE-754 มันเป็นมาตรฐานสำหรับการ Represent พวกตัวเลขบนเครื่องคอมพิวเตอร์ พูดง่าย ๆ มันจะใช้ Scientific Notation เช่น 134.21 มันจะแปลงเป็น 1.3421 x 10^ 2

จากนั้น ดูจากภาพด้านบนนี้ เราจะเห็นว่า วิธีการเก็บมันจะแบ่งส่วนการจัดเก็บออกเป็น 3 ส่วนด้วยกัน คือ Sign Bit, Exponents และ Mantissa โดยจะแบ่งเป็น 1,8 และ 23 ตามลำดับ วิธีการทำก็คือ เมื่อเราแปลงตัวเลขของเราให้อยู่ในรูปแบบของ Scientific Notation เริ่มจากเครื่องหมาย หรือ Sign Bit ถ้าเป็นลบก็แทน 1 แต่กรณีนี้ เป็นบวก เราก็แทน 0 เข้าไป

จากนั้นฝั่งของ Exponent เราได้เป็นกำลัง 2 มา แต่เราจะใส่ 2 ตรง ๆ ไม่ได้ เพราะเราต้องการที่จะทำให้มันสามารถเก็บค่าได้แค่กำลังที่เป็นบวกเท่านั้น ถ้าเราใช้ 8 Bits เราก็เอากึ่งกลาง 127 ไปบวกนั่นเอง ก็จะเป็น 129 ก็จะแทนด้วย 10000001

และท้ายสุดแปลงด้านหลังทศนิยมให้เป็นฐาน 2 อย่างที่เราเล่าไป มันก็จะได้ 0011 0101 1100 0010 1000 1111 นี่แหละคือการที่เครื่องมันเก็บทศนิยม แต่ถ้าเราเก็บแบบ Double Precison มันก็จะใช้ขนาด 64-Bits แบ่งเป็น 1,11 และ 52 สำหรับ Sign Bit, Exponent และ Mantissa ตามลำดับ

ทำไม 0.3 + 0.6 ถึงเป็นปัญหา

ทีนี้ ถามว่า แล้วทำไมเลข 0.3 + 0.6 มันเป็นปัญหา ถ้าเราลองเอาไปบวกกันจริง ๆ อาจจะผ่าน Python Shell ก็ได้ เราจะได้ 0.8999999999999999 แล้วไปเรื่อย ๆ เลย กลายเป็นว่า ชิบหาย แล้วค่ามันก็ไม่ตรงน่ะสิ ยิ่งถ้าเราเอาไปใช้ในการคำนวณต่อด้วย

value = 0.9
digit_count = 17
base_2 = ""

for i in range(digit_count) :
    num = 1 / (2 ** (i+1))
    
    if value > num :
        base_2 += '1'
        value -= num
    else :
        base_2 += '0'

print(base_2)

ถ้าเราลอง Represent 0.9 ในฐาน 2 กัน แต่ขี้เกียจทำมือแล้ว เราเขียน Python Script ขึ้นมาง่าย ๆ กัน คุณภาพไว้ก่อนนะ เอาให้ง่าย ๆ ก่อน เราจะกำหนดค่า value คือค่าที่เราอยากจะ Represent ในที่นี้คือ 0.9 แล้วกำหนด digit_count คือจำนวนตำแหน่งของผลลัพธ์บนเลขฐาน 2 เป็น 17 ทำไมเดี๋ยวเรามาเล่าอีกที และสุดท้าย ทำการ Initiate base_2 ซึ่งเป็นผลลัพธ์ของเรากัน

ใน Loop เราก็จะทำการคำนวณ 2 กำลังลบเท่าไหร่ก็ว่ากันไป แล้วเทียบเลย ถ้ามากกว่า Bit นั้นก็จะ On แล้วเราก็เอามาลบกับค่าจริงของเรา หรือถ้าไม่ ก็จะ Off ไปแล้วใส่ 0 ก็คือ การหารเลขฐานปกตินั่นแหละ แล้วสุดท้ายจบ Loop เราก็ Print ออกมา อีซี่..........

11100110011001100

เราได้ผลลัพธ์มาแล้ว เราอยากให้ลองสังเกตดี ๆ มันจะเริ่มจาก 11100 ก่อน อันนี้ไม่มีอะไร แล้วเราลองดูอีก 4 ตำแหน่งต่อไป มันจะเป็น 1100 แล้วอีก 4 ตำแหน่งต่อไปคือ 1100 เห้ย มันเหมือนกันเลยนี่หว่า มันก็จะวนซ้ำแบบนี้ไปเรื่อย ๆ

แต่ปัญหาคือ จำที่เราคุยกันเรื่องวิธีการ Represent Floating Point บนคอมพิวเตอร์ได้ใช่มะ เรามี Bit ที่เก็บ Mantissa ได้จำกัด เราไม่สามารถเก็บเลขซ้ำแบบนี้ไปเรื่อย ๆ ไม่มีที่สิ้นสุดได้ ดังนั้น ตอนขาที่เราแปลงกลับมา มันก็จะไม่ได้เลขเป๊ะ ๆ ใช่ม้าา

base_2 = '11100110011001100'
base_10 = 0

for position, digit in enumerate(base_2) :
    num = 1 / (2 ** (position+1))
    base_10 += int(digit) * num

print(base_10)

เพื่อให้เห็นภาพมากขึ้น เราจะเขียน Script เพื่อแปลงมันกลับไปเป็นเลขฐาน 10 กัน เราก็แค่เอามาทีละตำแหน่ง แล้วบวกไปเรื่อย ๆ เท่านั้นเลย ในที่นี้ base_2 เราก็จะเอาผลลัพธ์จากรอบที่เราแปลงรอบก่อนมาใส่ตรง ๆ เลย

0.899993896484375

เมื่อรันออกมา เราจะเห็นว่า อ้าว มันไม่ได้กลับไปเป็น 0.9 แต่ได้ออกมาเป็นเกือบ ๆ 0.9 นั่นก็เพราะอย่างที่เราบอกว่า มันวนไปเรื่อย ๆ แต่ตำแหน่งที่เราเก็บมันสั้นกว่านั้น มันเก็บได้จำกัด ทำให้เราไม่สามารถ Represent ค่าจริง ๆ ลงไปบนระบบฐาน 2 ได้ เลยต้องใช้เป็นแค่การประมาณเท่านั้น เราเลยเรียกว่า Floating Point Approximation

เราจะแก้ปัญหาอย่างไร

from decimal import Decimal

print(Decimal('0.3') + Decimal('0.6'))

สำหรับบน Python เขาเตรียม Package สำหรับการจัดการเรื่องแบบนี้โดยเฉพาะเลย แค่เราเรียกเป็น Decimal แต่นั้นเลย เราก็จะได้คำตอบที่ถูกต้อง

round(0.3+0.6,1)

อีกวิธีคือการ Round หรือการปัดนั่นเอา อย่างที่เราบอกว่า เราไม่สามารถเก็บค่าจริง ๆ ได้ งั้นเราก็เอาค่าเท่าที่เราเก็บได้แหละ มาปัด ก็เป็นอีกวิธีเหมือนกัน ซึ่งในหลาย ๆ ภาษาก็จะมีพวก Round Function มาด้วย ลองไปหาใช้ดูได้

สรุป

จำนวนทศนิยมเรียกว่าเป็นปัญหาสำหรับการคำนวณบนเครื่องคอมพิวเตอร์อยู่พอสมควรด้วยวิธีการที่เราใช้ในการ Represent ทศนิยมบนระบบคอมพิวเตอร์ ที่เรายังใช้เป็นเลขฐาน 2 แปลงมาเป็นฐาน 10 อีกที ซึ่งถามว่า คนทั่ว ๆ ไปมีปัญหาอะไรมั้ย เราก็ต้องบอกเลยว่า ไม่ เพราะเราไม่ได้ใช้ความละเอียดของทศนิยมมากขนาดนั้น เราแก้ปัญหาด้วยการปัดเลขได้ แต่สำหรับการคำนวณเฉพาะทางมาก ๆ อย่างในฝังของงานวิจัยทางวิทยาศาสตร์ต่าง ๆ เรื่องพวกนี้อาจจะเป็นปัญหาก็ได้ ซึ่งพวกนั้นเขาก็จะมีวิธีการดีลที่แตกต่างกัน เช่น อาจจะปัดเหมือนเราแหละ แต่จำนวนทศนิยมที่เขาปัด อาจจะมากกว่าเราเยอะมาก ๆ เพื่อให้การคำนวณมันละเอียดมากขึ้น เป็นเรื่องแปลก ๆ ที่เราว่า หลาย ๆ คนจนเจอเยอะมาก ๆ จนเป็นมีมเลย ก็หวังว่าจะเป็นประโยชน์กันนะครับ