Pythonのround()は四捨五入じゃない!? 業務システムで発覚した「銀行家の丸め」の罠

blog, technologies

はじめに

こんにちは。中村です。

業務システムの金額計算で「計算結果が1円ずれる」という不具合が単体テストにて発覚しました。
たった1円。されど1円。お金の計算で「なんか合わない」は冷や汗モノです。

調べてみると、原因はまさかの round() 関数。
「え、round() って四捨五入でしょ?」――私もそう思っていました。
ところがPythonの round()銀行家の丸め(Banker’s Rounding) という、ちょっと変わった丸め方式を採用していたのです。

本記事では、この問題の原因と対処法を、実際の業務ロジックに近い計算例を交えて紹介します。


銀行家の丸めとは?

一般的な四捨五入では、端数がちょうど 0.5 のとき 常に切り上げ ます。

一方、銀行家の丸め(偶数丸め / Round Half to Even)では、端数がちょうど 0.5 のとき 最も近い偶数の方に丸め ます。

# Pythonのround()は銀行家の丸め
round(0.5)   # → 0  (偶数側 = 0)
round(1.5)   # → 2  (偶数側 = 2)
round(2.5)   # → 2  (偶数側 = 2)
round(3.5)   # → 4  (偶数側 = 4)
round(4.5)   # → 4  (偶数側 = 4)

2.53 にならず 2 になる。え、マジで? これが銀行家の丸めです。

統計的な偏りを減らす目的で設計された方式で、IEEE 754(浮動小数点数の国際規格)のデフォルトでもあります。しかし、日本の商慣習や多くの業務システムで期待される「普通の四捨五入」とは異なります。


実際の不具合と修正

業務システムでは、重量 × 単価 × 日数 で金額を計算し、結果を整数に丸めて画面や帳票に表示していました。

修正前と修正後

from decimal import Decimal, ROUND_HALF_UP

amount = 6300.5  # 重量 × 単価 × 日数 の計算結果

# 修正前: 銀行家の丸め
round(amount)                        # → 6300  ...え?

# 修正後: 純粋な四捨五入
Decimal(str(amount))  # str()で文字列にしてからDecimalに変換(float直だと誤差が残る)
    .quantize(Decimal('1'), rounding=ROUND_HALF_UP)  # 整数に丸める(0.5は常に切り上げ)
                                     # → 6301  よし!

計算ロジック自体は同じ。計算結果を整数に丸める方法を変えただけです。

結果の比較

重量単価日数計算結果round()ROUND_HALF_UP差額
4.570026,300.56,3006,301+1
2.550011,250.51,2501,251+1
6.530011,950.51,9501,951+1
8.520035,100.55,1005,101+1
3.550011,750.51,7501,751+1
1.570011,050.51,0501,051+1
3.540022,800.02,8002,8000
5.060013,000.03,0003,0000

計算結果が .5 で終わり、かつ整数部分が偶数のとき、round() は切り捨ててしまいます。
結果として 本来より1円少ない金額 が表示されていました。これはこわい。


なぜPythonは銀行家の丸めを採用しているのか?

銀行家の丸めは、大量のデータを丸める際に統計的な偏りが小さくなるという利点があります。

通常の四捨五入では、.5 を常に切り上げるため、全体的にわずかに大きい方へ偏ります。銀行家の丸めでは、.5 のときに切り上げと切り捨てが交互に起きるため、合計値の偏りが小さくなります。

import statistics

values = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5]

# 元の値の合計
original_sum = sum(values)  # → 49.5

# 銀行家の丸め後の合計
bankers_sum = sum(round(v) for v in values)  # → 50

# 四捨五入後の合計
from decimal import Decimal, ROUND_HALF_UP
normal_sum = sum(
    int(Decimal(str(v)).quantize(Decimal('1'), rounding=ROUND_HALF_UP))
    for v in values
)  # → 55

print(f"元の合計:         {original_sum}")   # 49.5
print(f"銀行家の丸め合計: {bankers_sum}")     # 50(誤差 +0.5)
print(f"四捨五入合計:     {normal_sum}")      # 55(誤差 +5.5)

統計処理や科学計算では銀行家の丸めが合理的ですが、
業務システムでは「0.5は切り上げ」という一般的な期待に合わせる方が適切です。


まとめ

項目round()Decimal + ROUND_HALF_UP
方式銀行家の丸め(偶数丸め)純粋な四捨五入
.5の扱い最も近い偶数に丸める常に切り上げ
用途統計・科学計算業務・金額計算
2.5 の結果23
3.5 の結果44

教訓

  • Pythonの round() は四捨五入ではない。この事実を知らないと、金額計算で1円のズレが発生する。
  • 金額計算では decimal モジュールの ROUND_HALF_UP を使う
  • 他言語から移植する際は、丸め方式の違いに要注意(VB/VBA/Excelなど、多くの言語・ツールはデフォルトが四捨五入)
  • テストでは 端数がちょうど .5 になるケース を必ず含める

参考

Related posts