Pythonの四捨五入についてライブラリを比較

# はじめに

結論: **Decimalの ROUND_HALF_UP 一強です．**

比較対象: **Python標準，NumPy，Pandas，Decimal，SciPy，SymPy**


悪名高いPythonの四捨五入ですが，様々なライブラリで丸めのためのメソッドが実装されています．

Python標準の`round()`で2.5を四捨五入すれば分かりますが，Pythonのroundは四捨五入ではなく**銀行家の丸め**です．

## 銀行家の丸め

偶数丸めとも．

普通の四捨五入と違うところは，丸める対象が**5**の場合，**偶数に丸められる**という特徴があります．

標準関数でそんなことしちゃダメでしょ．

# ライブラリごとの比較

## 検証用コード

Python 3.10で検証．

評価対象
`0.4, 0.5, 0.6, 1.4, 1.5, 1.6, 2.4, 2.5, 2.6, 3.4, 3.5, 3.6, -0.4, -0.5, -0.6, -1.4, -1.5, -1.6`

0, 1, 2, 3, -1, -2 に0.5 ± 0.1 を足したものをそれぞれのライブラリで四捨五入します．

各ライブラリによる四捨五入の実装
``` py
py_result = round(value)
np_result = np.round(value)
pd_result = pd.Series([value]).round().iloc[0]
decimal_result = Decimal(value).quantize(Decimal('1')) # rounding=ROUND_HALF_EVEN
decimal_result_u = Decimal(value).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
scipy_result = scipy.special.round(value)
sympy_result = float(sympy.N(value).round())
```

<details>
<summary>評価用コード全文</summary>

``` py
import numpy as np
from decimal import Decimal, ROUND_HALF_UP
import math
import pandas as pd
import scipy
import scipy.special
import sympy

values_list = [[0.4, 0.5, 0.6],
          [1.4, 1.5, 1.6],
          [2.4, 2.5, 2.6],
          [3.4, 3.5, 3.6],
          [-0.4, -0.5, -0.6],
          [-1.4, -1.5, -1.6]]

methods = ['Python', 'NumPy', 'Pandas', 'Decimal', 'Decimal-UP', 'SciPy', 'SymPy']
print(f"{'Value':<10}" + ' '.join([f'{methods[i]:<10}' for i in range(len(methods))]))
print("="*40)

isOK_result = np.array([True]*len(methods))
for i, values in enumerate(values_list):
    isOK = np.array([True]*len(methods))
    for j, value in enumerate(values):
    
        py_result = round(value)
        np_result = np.round(value)
        pd_result = pd.Series([value]).round().iloc[0]
        decimal_result = Decimal(value).quantize(Decimal('1')) # rounding=ROUND_HALF_EVEN
        decimal_result_u = Decimal(value).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
        scipy_result = scipy.special.round(value)
        sympy_result = float(sympy.N(value).round())

        isOK_tmp = [result == ((i if j==0 else i+1) if i<=3 else (-abs(i-4) if j==0 else -abs(i-4+1))) for result in [py_result, np_result, pd_result, decimal_result, decimal_result_u, scipy_result, sympy_result]]
        isOK = np.logical_and(isOK, isOK_tmp)
        print(f"{value:<10} {py_result:<10} {np_result:<10} {pd_result:<10} {decimal_result:<10} {decimal_result_u:<10} {scipy_result:<10} {sympy_result:<10}")
    print(f"OK: {' '.join(methods[k] for k in range(len(methods)) if isOK[k])}")
    isOK_result = np.logical_and(isOK_result, isOK)
    print("="*40)
print('Result')
print(f"OK: {' '.join(methods[k] for k in range(len(methods)) if isOK_result[k])}")

```
</details>

## 結果

Valueの値をそれぞれのライブラリで丸めています．

`Decimal` はDecimal標準の丸め (初期設定 `rounding=ROUND_HALF_EVEN`)
`Decimal-UP` は丸め対象が5のとき大きい方に丸める丸め `rounding=ROUND_HALF_UP` です．

OK: には，ちゃんとした四捨五入 (銀行家の丸めでない，0.5を大きい整数に丸めるもの) が実装されているライブラリを表示しています．

``` py
Value     Python     NumPy      Pandas     Decimal    Decimal-UP SciPy      SymPy
========================================
0.4        0          0.0        0.0        0          0          0.0        0.0
0.5        0          0.0        0.0        0          1          0.0        0.0
0.6        1          1.0        1.0        1          1          1.0        1.0
OK: Decimal-UP
========================================
1.4        1          1.0        1.0        1          1          1.0        1.0
1.5        2          2.0        2.0        2          2          2.0        2.0
1.6        2          2.0        2.0        2          2          2.0        2.0
OK: Python NumPy Pandas Decimal Decimal-UP SciPy SymPy
========================================
2.4        2          2.0        2.0        2          2          2.0        2.0
2.5        2          2.0        2.0        2          3          2.0        2.0
2.6        3          3.0        3.0        3          3          3.0        3.0
OK: Decimal-UP
========================================
3.4        3          3.0        3.0        3          3          3.0        3.0
3.5        4          4.0        4.0        4          4          4.0        4.0
3.6        4          4.0        4.0        4          4          4.0        4.0
OK: Python NumPy Pandas Decimal Decimal-UP SciPy SymPy
========================================
-0.4       0          -0.0       -0.0       -0         -0         0.0        0.0
-0.5       0          -0.0       -0.0       -0         -1         0.0        0.0
-0.6       -1         -1.0       -1.0       -1         -1         -1.0       -1.0
OK: Decimal-UP
========================================
-1.4       -1         -1.0       -1.0       -1         -1         -1.0       -1.0
-1.5       -2         -2.0       -2.0       -2         -2         -2.0       -2.0
-1.6       -2         -2.0       -2.0       -2         -2         -2.0       -2.0
OK: Python NumPy Pandas Decimal Decimal-UP SciPy SymPy
========================================
Result
OK: Decimal-UP
```

結果: **Decimal で ROUND_HALF_UP を用いた場合のみ正しい**

Decimalを初期設定で扱うなら，なんと全滅．すべてのライブラリで銀行家の丸めが採用されています．

結果，DecimalでROUND_HALF_UPを使用する場合のみ，正しい四捨五入ができることが分かりました．

``` py
Decimal(value).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
```

Decimalの初期設定も銀行家の丸めなのは，他の関数と併用するときにバグらないようにするための配慮なのかもしれませんね．