นากการ์ตูนสีมิ้นต์บวกเหรียญบนตาชั่งที่แม่นยำ ข้างเครื่องคิดเลขและแว่นขยายที่ส่องเศษเล็ก ๆ บนโต๊ะ กับลิ้นชักเหรียญที่จัดเรียงไว้

Python math.fsum: บวกทศนิยมทั้งลิสต์ให้แม่น และรู้ว่าเมื่อไหร่ควรเลิกใช้ float กับเงิน

น้องกายEngineering

สคริปต์รวมยอดบิลที่เขียนว่า total += price วนไปเรื่อยๆ อาจได้ 0.9999999999999999 แทน 1.0 ทั้งที่บวก 0.1 สิบครั้ง ผมรันบน Python 3.14.7 เครื่องนี้แล้วเจอจริงครับ เรื่องนี้ไม่ใช่บั๊กของ Python แต่เป็นธรรมชาติของเลขทศนิยมแบบ float ที่ บทเรียนทางการของ Python อธิบายไว้

บทความนี้เทียบ 3 ทาง: บวกเองใน loop, ใช้ sum() และใช้ math.fsum แล้วปิดด้วยเรื่องสำคัญที่สุดคือ ถ้าเป็นเงินบาทจริง ควรใช้อะไร

WHY: ทำไมยอดรวมเพี้ยนได้

float เก็บเลขเป็นเลขฐานสอง เลขอย่าง 0.1 เขียนเป็นฐานสองแบบพอดีไม่ได้ เหมือนเขียน 1/3 เป็นทศนิยมฐานสิบไม่จบ เครื่องจึงเก็บค่าที่ “ใกล้มาก” แทน ทุกครั้งที่บวก ผลจะถูกปัดให้เข้ากับช่องเก็บอีกรอบ บวกหลายครั้ง เศษที่ปัดทิ้งก็สะสม

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

HOW: สามวิธีบวก

  1. total += x ใน loop ปัดทุกครั้งที่บวก ความคลาดเคลื่อนสะสมตามจำนวนรายการ
  2. sum(values) เอกสาร built-in ระบุว่าตั้งแต่ Python 3.12 การรวม float เปลี่ยนไปใช้อัลกอริทึมที่แม่นขึ้นบนเครื่องส่วนใหญ่ เคส 0.1 สิบตัวจึงได้ 1.0 แล้ว
  3. math.fsum(values) เอกสาร math บอกว่าคืนผลรวมที่แม่นยำ โดยติดตามผลรวมย่อยหลายตัวไว้ระหว่างทาง เพื่อไม่ให้เศษหาย

ตัวอย่างที่ผมหาเจอบนเครื่องนี้ว่า sum() กับ fsum() ให้ผลต่างกัน คือเมื่อมีเลขใหญ่มากบวกลบหักล้างกันปนกับเลขเล็ก: [-1e20, 3.3, 0.001, 7.7, 1e20, 3.3] ได้ sum() = 14.300999999999998 แต่ fsum() = 14.301

จุดหักมุม: แม่นที่สุด ไม่ได้แปลว่าตรงใจ

ลองรวมราคา 19.99 + 5.01 + 0.1 + 0.2 บนเครื่องนี้:

  • loop += ได้ 25.3
  • sum() ได้ 25.299999999999997
  • fsum() ได้ 25.299999999999997

ดูเหมือน loop ชนะ แต่จริงๆ แล้ว fsum() คืนค่าที่ใกล้ “ผลรวมจริงของเลขฐานสองที่เก็บไว้” มากที่สุด ปัญหาอยู่ที่ตัว 19.99 กับ 0.1 ไม่เคยเป็น 19.99 กับ 0.1 พอดีตั้งแต่แรก ส่วน loop บังเอิญปัดไปเจอเลขที่ดูสวย นี่คือเหตุผลที่ fsum เหมาะกับงานวิทยาศาสตร์ สถิติ เซนเซอร์ แต่ ไม่ใช่คำตอบของเงิน

สำหรับเงิน ใช้ decimal.Decimal ที่สร้างจากข้อความ เช่น Decimal("19.99") หรือเก็บเป็นจำนวนเต็มหน่วยสตางค์ (1999) ไปเลย

โค้ดพร้อมเทสต์

import math
import unittest
from decimal import Decimal

def loop_total(values):
    total = 0.0
    for v in values:
        total += v
    return total

class FloatTotalTest(unittest.TestCase):
    def test_loop_drifts_but_sum_and_fsum_fix_tenths(self):
        tenths = [0.1] * 10
        self.assertEqual(loop_total(tenths), 0.9999999999999999)
        self.assertEqual(sum(tenths), 1.0)        # Python 3.12+
        self.assertEqual(math.fsum(tenths), 1.0)

    def test_fsum_beats_sum_on_cancellation(self):
        data = [-1e20, 3.3, 0.001, 7.7, 1e20, 3.3]
        self.assertEqual(math.fsum(data), 14.301)
        self.assertNotEqual(sum(data), math.fsum(data))

    def test_money_needs_decimal(self):
        prices = [19.99, 5.01, 0.1, 0.2]
        self.assertNotEqual(math.fsum(prices), 25.3)  # float ยังไม่ใช่เงิน
        exact = sum(Decimal(p) for p in ["19.99", "5.01", "0.1", "0.2"])
        self.assertEqual(exact, Decimal("25.30"))
        satang = sum([1999, 501, 10, 20])
        self.assertEqual(satang, 2530)

if __name__ == "__main__":
    unittest.main()

เลือกใช้อะไรเมื่อไหร่

งานใช้เหตุผล
รวมค่าที่วัดได้ (อุณหภูมิ, ระยะทาง, ค่าเซนเซอร์) จำนวนมากmath.fsumแม่นที่สุดสำหรับ float
รวม float ทั่วไปใน Python 3.12+sum()ดีขึ้นมากแล้ว และอ่านง่าย
loop ที่ต้องทำอย่างอื่นไปด้วยเก็บค่าใส่ลิสต์ แล้วค่อย fsum ตอนจบเลี่ยงการปัดทุกครั้ง
เงิน ภาษี ใบเสร็จDecimal("...") หรือจำนวนเต็มสตางค์ทศนิยมฐานสิบตรงกับที่คนอ่าน
เทียบ float ว่า “เท่ากันไหม”math.iscloseอย่าใช้ == กับผลคำนวณ float

ข้อควรรู้จากเอกสาร math: ความแม่นของ fsum ขึ้นกับมาตรฐาน IEEE-754 และโหมดปัดเศษปกติ บางระบบที่ไม่ใช่ Windows อาจคลาดได้ในหลักสุดท้ายเป็นครั้งคราว

Recap

  • เจอยอดรวม float แปลกๆ ให้เช็กก่อนว่าบวกใน loop ด้วย += หรือเปล่า
  • Python 3.12 ขึ้นไป sum() แม่นขึ้นแล้ว แต่ math.fsum ยังแม่นกว่าในเคสเลขใหญ่หักล้างกัน
  • งานเงินห้ามใช้ float: ใช้ Decimal("19.99") หรือเก็บเป็นสตางค์
  • สร้าง Decimal จากข้อความ ไม่ใช่จาก float
  • เทียบ float ด้วย math.isclose ไม่ใช่ ==
  • ก๊อปเทสต์ด้านบนไปรันบน Python ของคุณ ดูว่าผลตรงกันไหม