15. Số học dấu phẩy động: Các vấn đề và hạn chế

Các số có dấu phẩy động được biểu diễn trong phần cứng máy tính dưới dạng phân số cơ số 2 (nhị phân). Ví dụ: phân số decimal 0.625 có giá trị 6/10 + 2/100 + 5/1000 và theo cách tương tự, phân số binary 0.101 có giá trị 1/2 + 0/4 + 1/8. Hai phân số này có giá trị giống nhau, điểm khác biệt thực sự duy nhất là phân số đầu tiên được viết bằng ký hiệu phân số cơ số 10 và phân số thứ hai được viết theo cơ số 2.

Thật không may, hầu hết các phân số thập phân không thể được biểu diễn chính xác dưới dạng phân số nhị phân. Hậu quả là, nói chung, các số dấu phẩy động thập phân bạn nhập chỉ gần đúng bằng các số dấu phẩy động nhị phân thực sự được lưu trữ trong máy.

Bài toán ban đầu dễ hiểu hơn ở cơ số 10. Xét phân số 1/3. Bạn có thể ước chừng số đó dưới dạng phân số cơ số 10:

0,3

hoặc, tốt hơn,

0,33

hoặc, tốt hơn,

0,333

và vân vân. Cho dù bạn sẵn sàng viết ra bao nhiêu chữ số thì kết quả sẽ không bao giờ chính xác là 1/3 mà sẽ là xấp xỉ 1/3 ngày càng tốt hơn.

Theo cách tương tự, cho dù bạn sẵn sàng sử dụng bao nhiêu chữ số cơ số 2 thì giá trị thập phân 0,1 cũng không thể được biểu diễn chính xác dưới dạng phân số cơ số 2. Trong cơ số 2, 1/10 là phân số lặp vô hạn

0,0001100110011001100110011001100110011001100110011...

Dừng ở bất kỳ số bit hữu hạn nào và bạn sẽ có được giá trị gần đúng. Trên hầu hết các máy hiện nay, số float được tính gần đúng bằng cách sử dụng phân số nhị phân với tử số sử dụng 53 bit đầu tiên bắt đầu bằng bit có trọng số cao nhất và với mẫu số là lũy thừa của hai. Trong trường hợp 1/10, phân số nhị phân là 3602879701896397 / 2 ** 55, gần bằng nhưng không chính xác bằng giá trị thực của 1/10.

Nhiều người dùng không biết về giá trị gần đúng vì cách hiển thị các giá trị. Python chỉ in giá trị gần đúng thập phân với giá trị thập phân thực của giá trị gần đúng nhị phân được máy lưu trữ. Trên hầu hết các máy, nếu Python in giá trị thập phân thực của phép tính gần đúng nhị phân được lưu trữ cho 0,1, thì nó sẽ phải hiển thị:

>>> 0,1
0.1000000000000000055511151231257827021181583404541015625

Đó là nhiều chữ số hơn hầu hết mọi người thấy hữu ích, vì vậy Python giữ số chữ số có thể quản lý được bằng cách hiển thị giá trị làm tròn thay thế:

>>> 1/10
0,1

Chỉ cần nhớ, mặc dù kết quả được in trông giống như giá trị chính xác là 1/10, nhưng giá trị được lưu trữ thực tế là phân số nhị phân có thể biểu thị gần nhất.

Điều thú vị là có nhiều số thập phân khác nhau có chung phân số nhị phân gần đúng nhất. Ví dụ: các số 0.1, 0.100000000000000010.1000000000000000055511151231257827021181583404541015625 đều gần đúng bằng 3602879701896397 / 2 ** 55. Vì tất cả các giá trị thập phân này đều có cùng giá trị gần đúng nên bất kỳ giá trị nào trong số chúng đều có thể được hiển thị trong khi vẫn giữ nguyên eval(repr(x)) == x bất biến.

Về mặt lịch sử, dấu nhắc Python và hàm repr() tích hợp sẽ chọn một chữ số có 17 chữ số có nghĩa, 0.10000000000000001. Bắt đầu với Python 3.1, Python (trên hầu hết các hệ thống) giờ đây có thể chọn cái ngắn nhất trong số này và chỉ cần hiển thị 0.1.

Lưu ý rằng đây là bản chất của dấu phẩy động nhị phân: đây không phải là lỗi trong Python và cũng không phải là lỗi trong mã của bạn. Bạn sẽ thấy điều tương tự trong tất cả các ngôn ngữ hỗ trợ số học dấu phẩy động của phần cứng của bạn (mặc dù một số ngôn ngữ có thể không tạo ra sự khác biệt theo mặc định hoặc trong tất cả các chế độ đầu ra).

Để có kết quả dễ chịu hơn, bạn có thể muốn sử dụng định dạng chuỗi để tạo ra một số chữ số có nghĩa giới hạn:

>>> format(math.pi, '.12g') # give 12 chữ số có nghĩa
'3.14159265359'

>>> format(math.pi, '.2f') # give 2 chữ số sau dấu phẩy
'3.14'

>>> đại diện(math.pi)
'3.141592653589793'

Điều quan trọng là phải nhận ra rằng, theo nghĩa thực tế, đây là một ảo ảnh: bạn chỉ đơn giản làm tròn display của giá trị thực của máy.

Một ảo ảnh có thể sinh ra một ảo ảnh khác. Ví dụ: vì 0,1 không chính xác là 1/10, nên tổng ba giá trị 0,1 cũng không thể mang lại chính xác 0,3:

>>> 0,1 + 0,1 + 0,1 == 0,3
sai

Ngoài ra, vì 0,1 không thể tiến gần hơn đến giá trị chính xác là 1/10 và 0,3 không thể tiến gần hơn đến giá trị chính xác là 3/10, nên việc làm tròn trước bằng hàm round() không thể giúp ích:

>>> vòng(0.1, 1) + vòng(0.1, 1) + vòng(0.1, 1) == vòng(0.3, 1)
sai

Mặc dù các số không thể gần với giá trị chính xác dự định của chúng nhưng hàm math.isclose() có thể hữu ích để so sánh các giá trị không chính xác:

>>> math.isclose(0,1 + 0,1 + 0,1, 0,3)
đúng

Ngoài ra, hàm round() có thể được sử dụng để so sánh các giá trị gần đúng:

>>> vòng(math.pi, ndigits=2) == vòng(22 / 7, ndigits=2)
đúng

Số học dấu phẩy động nhị phân chứa đựng nhiều điều bất ngờ như thế này. Sự cố với "0,1" được giải thích chi tiết chính xác bên dưới, trong phần "Lỗi biểu diễn". Xem Examples of Floating Point Problems để có bản tóm tắt thú vị về cách hoạt động của dấu phẩy động nhị phân và các loại vấn đề thường gặp trong thực tế. Ngoài ra, hãy xem The Perils of Floating Point để biết thông tin đầy đủ hơn về những điều bất ngờ phổ biến khác.

Như đã nói ở gần cuối, "không có câu trả lời dễ dàng." Tuy nhiên, đừng cảnh giác quá mức với dấu phẩy động! Các lỗi trong các phép toán float của Python được kế thừa từ phần cứng dấu phẩy động và trên hầu hết các máy đều có không quá 1 phần trong 2**53 cho mỗi thao tác. Như vậy là quá đủ cho hầu hết các tác vụ, nhưng bạn cần lưu ý rằng đó không phải là số học thập phân và mọi phép toán float đều có thể gặp phải lỗi làm tròn mới.

Mặc dù các trường hợp bệnh lý vẫn tồn tại, nhưng đối với hầu hết việc sử dụng số học dấu phẩy động thông thường, cuối cùng bạn sẽ thấy kết quả mà bạn mong đợi nếu bạn chỉ làm tròn việc hiển thị kết quả cuối cùng của mình thành số chữ số thập phân mà bạn mong đợi. str() thường là đủ và để kiểm soát tốt hơn, hãy xem công cụ xác định định dạng của phương thức str.format() trong Cú pháp định dạng chuỗi.

Đối với các trường hợp sử dụng yêu cầu biểu diễn số thập phân chính xác, hãy thử sử dụng mô-đun decimal triển khai số học thập phân phù hợp cho các ứng dụng kế toán và ứng dụng có độ chính xác cao.

Một dạng số học chính xác khác được hỗ trợ bởi mô-đun fractions thực hiện số học dựa trên các số hữu tỷ (vì vậy các số như 1/3 có thể được biểu diễn chính xác).

Nếu bạn là người sử dụng nhiều các phép toán dấu phẩy động, bạn nên xem qua gói NumPy và nhiều gói khác dành cho các phép toán và thống kê do dự án SciPy cung cấp. Xem <https://scipy.org>.

Python cung cấp các công cụ có thể hữu ích trong những trường hợp hiếm hoi khi bạn thực sự muốn biết giá trị chính xác của số float. Phương thức float.as_integer_ratio() biểu thị giá trị của float dưới dạng phân số:

>>> x = 3,14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)

Vì tỷ lệ này là chính xác nên nó có thể được sử dụng để tạo lại giá trị ban đầu một cách dễ dàng:

>>> x == 3537115888337719 / 1125899906842624
đúng

Phương thức float.hex() biểu thị số float ở dạng thập lục phân (cơ số 16), một lần nữa đưa ra giá trị chính xác được máy tính của bạn lưu trữ:

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

Biểu diễn thập lục phân chính xác này có thể được sử dụng để xây dựng lại giá trị float một cách chính xác:

>>> x == float.fromhex('0x1.921f9f01b866ep+1')
đúng

Vì cách biểu diễn là chính xác nên nó rất hữu ích cho việc chuyển các giá trị một cách đáng tin cậy qua các phiên bản Python khác nhau (độc lập với nền tảng) và trao đổi dữ liệu với các ngôn ngữ khác hỗ trợ cùng định dạng (chẳng hạn như Java và C99).

Một công cụ hữu ích khác là chức năng sum() giúp giảm thiểu tình trạng mất độ chính xác trong quá trình tính tổng. Nó sử dụng độ chính xác mở rộng cho các bước làm tròn trung gian khi các giá trị được thêm vào tổng số đang chạy. Điều đó có thể tạo ra sự khác biệt về độ chính xác tổng thể để các lỗi không tích lũy đến mức ảnh hưởng đến tổng số cuối cùng:

>>> 0,1 + 0,1 + 0,1 + 0,1 + 0,1 + 0,1 + 0,1 + 0,1 + 0,1 + 0,1 == 1,0
sai
>>> tổng([0,1] * 10) == 1,0
đúng

Zz000zz còn đi xa hơn và theo dõi tất cả "các chữ số bị mất" khi các giá trị được thêm vào tổng số đang chạy để kết quả chỉ có một lần làm tròn duy nhất. Tốc độ này chậm hơn sum() nhưng sẽ chính xác hơn trong những trường hợp hiếm gặp khi đầu vào có cường độ lớn hầu như triệt tiêu lẫn nhau để lại tổng cuối cùng gần bằng 0:

>>> mảng = [-0.10430216751806065, -266310978.67179024, 143401161448607.16,
... -143401161400469.7, 266262841.31058735, -0,003244936839808227]
>>> float(sum(map(Fraction, arr))) tổng # Exact với làm tròn đơn
8.042173697819788e-13
>>> làm tròn math.fsum(arr) # Single
8.042173697819788e-13
>>> làm tròn tổng (arr) # Multiple với độ chính xác mở rộng
8.042178034628478e-13
>>> tổng = 0,0
>>> cho x trong mảng:
... tổng số += x # Multiple làm tròn ở độ chính xác tiêu chuẩn
...
>>> tổng cộng # Straight không có chữ số chính xác!
-0,0051575902860057365

15.1. Lỗi biểu diễn

Phần này giải thích chi tiết về ví dụ "0,1" và cho biết cách bạn có thể tự mình thực hiện phân tích chính xác các trường hợp như thế này. Giả sử có sự quen thuộc cơ bản với biểu diễn dấu phẩy động nhị phân.

Representation error đề cập đến thực tế là một số phân số thập phân (thực tế là hầu hết) không thể được biểu diễn chính xác dưới dạng phân số nhị phân (cơ số 2). Đây là lý do chính tại sao Python (hoặc Perl, C, C++, Java, Fortran và nhiều thứ khác) thường không hiển thị số thập phân chính xác mà bạn mong đợi.

Tại sao vậy? 1/10 không thể biểu diễn chính xác dưới dạng phân số nhị phân. Kể từ ít nhất là năm 2000, hầu hết tất cả các máy đều sử dụng số học dấu phẩy động nhị phân IEEE 754 và hầu hết tất cả các nền tảng đều ánh xạ Python tới các giá trị "độ chính xác kép" IEEE 754 nhị phân64. IEEE 754 giá trị nhị phân64 chứa 53 bit chính xác, do đó, khi nhập vào, máy tính sẽ cố gắng chuyển đổi 0,1 thành phân số gần nhất có thể ở dạng J/2**N trong đó J là số nguyên chứa chính xác 53 bit. Viết lại

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

BẰNG

J ~= 2**N / 10

và nhớ lại rằng J có chính xác 53 bit (là >= 2**52 nhưng < 2**53), giá trị tốt nhất cho N là 56:

>>> 2**52 <=  2**56 // 10 < 2**53
đúng

Nghĩa là, 56 là giá trị duy nhất cho N để lại J chính xác 53 bit. Giá trị tốt nhất có thể có cho J là thương số được làm tròn:

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

Vì số dư lớn hơn một nửa của 10 nên giá trị gần đúng tốt nhất thu được bằng cách làm tròn:

>>> q+1
7205759403792794

Do đó, giá trị gần đúng tốt nhất có thể đạt tới 1/10 trong độ chính xác kép IEEE 754 là:

7205759403792794 / 2 ** 56

Chia cả tử số và mẫu số cho hai sẽ rút gọn phân số thành:

3602879701896397 / 2 ** 55

Lưu ý rằng vì chúng tôi đã làm tròn nên số này thực sự lớn hơn 1/10 một chút; nếu chúng ta không làm tròn thì thương số sẽ nhỏ hơn 1/10 một chút. Nhưng không thể nào là exactly 1/10 được!

Vì vậy, máy tính không bao giờ "nhìn thấy" 1/10: những gì nó nhìn thấy là phân số chính xác được đưa ra ở trên, phép tính gần đúng kép IEEE 754 tốt nhất mà nó có thể nhận được:

>>> 0,1 * 2 ** 55
3602879701896397.0

Nếu chúng ta nhân phân số đó với 10**55, chúng ta có thể thấy giá trị có tới 55 chữ số thập phân:

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

nghĩa là số chính xác được lưu trong máy tính bằng giá trị thập phân 0,10000000000000000055511151231257827021181583404541015625. Thay vì hiển thị giá trị thập phân đầy đủ, nhiều ngôn ngữ (bao gồm cả các phiên bản Python cũ hơn), làm tròn kết quả thành 17 chữ số có nghĩa:

>>> định dạng(0.1, '.17f')
'0,10000000000000001'

Các mô-đun fractionsdecimal giúp việc tính toán này trở nên dễ dàng:

>>> từ nhập thập phân Thập phân
>>> từ phân số nhập Phân số

>>> Phân số.from_float(0.1)
Phân số(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

>>> Thập phân.from_float(0.1)
Thập phân('0.10000000000000000055511151231257827021181583404541015625')

>>> định dạng(Decimal.from_float(0.1), '.17')
'0,10000000000000001'