Skip to content

Latest commit

 

History

History
250 lines (167 loc) · 13.7 KB

15.-floating-point-arithmetic-issues-and-limitations.md

File metadata and controls

250 lines (167 loc) · 13.7 KB

15. Floating Point Arithmetic: Issues and Limitations

Số hữu tỉ được máy tính hiểu dưới dạng phân số hệ nhị phân. Ví dụ như với phân số thập phân:

0.125

sẽ có giá trị là 1/10 + 2/100 + 5/1000, cũng theo cách đó là cách biểu diễn phân số nhị phân:

0.001

sẽ có giá trị là 0/2 + 0/4 + 1/8. Hai dạng phân số này đều có giá trị xác định, sự khác biệt duy nhất là một loại được viết dựa trên hệ thập phân, và loại kia là ở hệ nhị phân.

Vấn đề ở đây là, hầu hết phân số thập phân không thể biểu diễn chính xác ở dạng nhị phân. Để giải quyết vấn đề này, theo cách cơ bản nhất, các số hữu tỉ được nhập vào chỉ được máy tính hiểu ở dạng tổng các phân số nhị phân ở dạng gần đúng.

Giống như vấn đề với phân số thập phân, mọi thứ sẽ khó khăn hơn với những số dạng 1/3, ở đó ta chỉ có thể biểu diễn gần đúng ở dạng thập phân như:

0.3

hay chính xác hơn:

0.33

hay chính xác hơn:

0.333

Bất kể ta cần mẫn viết thêm bao nhiêu số, kết quả không bao giờ chính xác là 1/3, mà nó chỉ tăng tính gần đúng tới 1/3.

Tương tự như thế, không cần biết bạn muốn thêm bao nhiêu số với phân số nhị phân, số hữu tỉ thập phân 0.1 mãi mãi không thể có giá trị chính xác là 0.1 ở dạng nhị phân, 1/10 mãi mãi là sự lặp lại vô tận trong trường hợp này.

0.0001100110011001100110011001100110011001100110011...

Việc biểu diễn gần đúng này sẽ được dừng lại ở một số hữu hạn bit. Trong hầu hết các máy tính hiện tại, số hữu tỉ được biểu diễn gần đúng sử dụng 53 bit đầu tiên biểu diễn phần tỉ số và phần mẫu số là lũy thừa của 2. Trong trường hợp của 1/10, phân số nhị phân sẽ là (3602879701896397 / (2 ** 55)), nghĩa là gần đúng chứ không phải là 1/10.

Hầu hết người dùng đều không để ý đến giá trị gần đúng. Python chỉ in ra giá trị số hữu tỉ thập phân gần đúng của một số hữu tỉ nhị phân thật sự đang được lưu lại trong bộ nhớ máy tính (RAM). Với hầu hết các máy tính, nếu như Python thật sự in ra con số phân số nhị phân đang được lưu trữ trong bộ nhớ, giá trị 0.1 sẽ phải là:

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

Đây là một con số không phải ai cũng thấy hữu dụng nên Python chỉ thể hiện một con số được làm tròn thay vì giá trị thật

>>> 1 / 10
0.1

Luôn nhớ rằng, dù cho kết quả được in ra có vẻ giống với giá trị thực của 1/10, giá trị được lưu trữ chỉ là giá trị phân số nhị phân gần đúng mà thôi.

Thú vị hơn, ta sẽ thấy có rất nhiều số hữu tỉ thập phân có cùng một giá trị tương đương nhị phân. Ví dụ như 0.1 và 0.10000000000000001 hay 0.1000000000000000055511151231257827021181583404541015625 đều được biểu diễn chung bằng (3602879701896397 / (2 ** 55)). Chính vì nguyên nhân này, tất cả các giá trị hữu tỉ thập phân bên trên đều được logic eval(repr(x)) == 1/10 trả về giá trị True.

Trong quá khứ, hàm repr() sẽ hiển thị đến ký tự thứ 17 (0.10000000000000001). Bắt đầu từ Python 3.1, Python đã có thể lựa chọn giá trị tương đương gần nhất và hiển thị 0.1 (với hầu hết phần cứng).

Lưu ý đây là tính tự nhiên của số hữu tỉ nhị phân, đây không phải là lỗi của Python, cũng càng không phải là lỗi của chương trình bạn viết. Vấn đề này cũng được xử lý tương tự ở rất nhiều ngôn ngữ lập trình khác.

Để output dễ nhìn hơn, ta có thể dùng string format để đưa ra độ chính xác cho kết quả ta cần.

>>> format(math.pi, '.12g')  # trả về một số có 12 chữ số
'3.14159265359'

>>> format(math.pi, '.2f')   # trả về một số với 2 số đằng sau dấu '.'
'3.14'

>>> repr(math.pi)
'3.141592653589793'

Ta rút ra một vấn đề quan trọng từ việc này: ta chỉ nhận được con số làm tròn từ giá trị thật mà máy tính đang lưu trữ.

Mỗi sai số tạo nên các sai số khác. Ví dụ như, vì 0.1 không phải chính xác là 1/10, tổng của ba giá trị 0.1 có thể không phải là 0.3

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

Tất nhiên, do 0.1 không thể đúng là 1/10 và 0.3 không thể đúng là 3/10, việc làm tròn trước với hàm round() không thể giúp được gì:

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

Cho dù các con số này không thể tiến tới chính xác giá trị của nó, hàm round() có thể hữu dụng để làm tròn sau khi tính toán, khiến những số gần đúng có thể so sánh như hai giá trị tương đương.

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

Thuật toán hữu tỉ nhị phân tạo ra rất nhiều bất ngờ tương tự thế này. Vấn đề với "0.1" sẽ được trình bày cụ thể phía bên dưới, ở phần "Lỗi sai số". Xem The Perils of Floating Point để hiểu rõ hơn về vấn đề này.

Như được nói ở gần cuối, "vấn đề này không hề có câu trả lời dễ dàng". Tuy nhiên, ta đừng quá lo lắng về số hữu tỉ! Sai số của Python trong tính toán số hữu tỉ là sản phẩm kế thừa từ sai số hệ thống của số hữu tỉ, và hầu hết hệ thống có sai số không quá (1/(2 ** 53)). Việc này là quá chính xác với hầu hết các chương trình, chỉ cần lưu ý là nó không phải là thuật toán thập phân, và tất cả các phép tính hữu tỉ đều có sai số làm tròn.

Do các sai số hệ thống này, hầu hết các trường hợp thông thường có sử dụng thuật toán hữu tỉ ta có thể có các kết quả như mong muốn bằng việc làm tròn kết quả cuối cùng sau khi tính toán. str() thường được sử dụng, tìm hiểu thêm str.format() & cú pháp string format để sử dụng tốt hơn hàm này.

Với các trường hợp đòi hỏi giá trị thập phân chính xác, ta có thể sử dụng module decimal để tăng độ chính xác cho các thuật toán thập phân đòi hỏi trong các phần mềm kế toán hoặc các phần mềm kỹ thuật khác.

Một lựa chọn khác cho thuật toán chính xác là module fractions fractions với các thuật toán tăng cường độ chính xác dựa trên các số tỉ lệ (1/3 thể được biểu diễn chính xác nhờ số tỉ lệ).

Nếu bạn cần phải sử dụng số hữu tỉ thường xuyên, bạn nên tìm hiểu về các gói dữ liệu số của Python mà điển hình là SciPy.

Python có cung cấp một số công cụ có thể giúp bạn tìm hiểu giá trị thật sự của một số hữu tỉ. float.as_integer_ratio() là một giải pháp để thể hiện giá trị một số hữu tỉ ở dạng phân số.

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

Vì số tỉ lệ là chính xác, số này có thể được dùng để biểu diễn giá trị chính xác của giá trị số hữu tỉ ban đầu:

>>> x == 3537115888337719 / 1125899906842624
True

float.hex() là giải pháp thể hiện một số hữu tỉ ở dạng hexadecimal (phần 16), đây là giá trị thực thế được lưu trữ trong máy tính:

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

Một giá trị dưới dạng phần 16 được dùng để xây dựng lại giá trị chính xác của số hữu tỉ:

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

Vì giá trị này là giá trị chính xác, nó có thể sử dụng để truyền các giá trị qua lại giữa các phiên bản khác nhau của Python (từ những hệ điều hành độc lập) cũng như trao đổi dữ liệu với các ngôn ngữ lập trình khác có sử dụng định dạng tương tự (Java hay C++).

Còn một công cụ khác nữa là hàm math.fsum() giúp giảm thiểu sai số trong phép tính tổng. Hàm này lưu lại các phần bị mất trong giá trị thực và thêm nó vào tổng thực tế. Việc này tạo ra một kết quả chính xác hơn khi ta cần sử dụng để so sánh:

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

15.1. Lỗi sai số

Đây là phần diễn giải về “0.1” và chỉ ra cho bạn cách làm những trường hợp tương tự. Phần này đòi hỏi những kiến thức cơ bản về số hữu tỉ được biểu diễn dưới dạng phân số nhị phân.

Lỗi sai số dùng để chỉ về vấn đề rằng có một số (hoặc thực tế là gần hết) các số hữu tỉ thập phân không thể thể hiện chính xác ở dạng số hữu tỉ nhị phân. Đây là nguyên nhân chính vì sao Python (hay Perl, C, C++, Java, Fortran, cùng rất nhiều ngôn ngữ lập trình khác) thường không thể hiện chính xác được con số hữu tỉ mà bạn đang mong muốn.

Vì sao lại thế? 1/10 không được thể hiện chính xác dưới dạng số hữu tỉ nhị phân. Hầu hết tất cả các máy tính ngày nay (tính đến tháng 11 năm 2000) sử dụng thuật toán số hữu tỉ IEEE-754, mà hầu hết hệ thống có sử dụng Python đều sử dụng chuẩn IEEE-754 “double precision” cho số hữu tỉ. 754 double precision bao gồm 53 bit thể hiện độ chính xác, và hướng máy tính đạt được con số 0.1 bằng một phân số nhị phân gần nhất dạng J/2**N trong đó J là một số tự nhiên được thể hiện chính xác bằng 53 bit. Ta có thể viết:

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

lại là:

J ~= 2**N / 10

đừng quên là ta sử dụng chính xác 53 bit để biểu diễn J (>= 2**52 nhưng < 2**53), nên giá trị tốt nhất cho N là 56:>>>

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

Thế nên, 56 là giá trị tốt nhất với N để có thể biểu diễn J với chính xác 53 bit. Giá trị tốt nhất cho J được làm tròn:>>>

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

Vì phần dư lớn hơn 5, cách làm tròn tốt nhất là làm tròn lên:>>>

>>> q+1
7205759403792794

Vì thế, giá trị gần đúng nhất với 1/10 theo chuẩn 754 double precision là:

7205759403792794 / 2 ** 56

Chia cả tử số và mẫu số cho 2 ta có được một phân số:

3602879701896397 / 2 ** 55

Lưu ý là vì chúng ta làm tròn nó, con số này có thể hơi lớn hơn 1/10; nếu ta không làm tròn, con số đó có thể hơi nhỏ hơn 1/10. Nhưng không có cách nào để nó chính xác là 1/10 cả!

Thế nên máy tính không bao giờ hiểu 1/10: những gì máy tính biết là phân số nhị phân được viết bên trên, giá trị gần đúng nhất mà nó có thể đạt được:>>>

>>> 0.1 * 2 ** 55
3602879701896397.0

Nếu ta nhân con số trên với 10**55, ta có thể thấy kết quả bao gồm 55 chữ số:>>>

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

điều này có nghĩa là con số thực tế được máy tính lưu trữ là 0.1000000000000000055511151231257827021181583404541015625. Thay vì việc hiển thị toàn bộ giá trị này, rất nhiều ngôn ngữ lập trình (bao gồm cả các phiên bản cũ của Python), làm tròn con số này về một số có 17 chữ số:>>>

>>> format(0.1, '.17f')
'0.10000000000000001'

Module fractionsdecimal khiến việc tính toán này trở nên dễ dàng hơn:>>>

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