Pythonのround()は四捨五入じゃない!? 業務システムで発覚した「銀行家の丸め」の罠
はじめに
こんにちは。中村です。
業務システムの金額計算で「計算結果が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.5 が 3 にならず 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.5 | 700 | 2 | 6,300.5 | 6,300 | 6,301 | +1 |
| 2.5 | 500 | 1 | 1,250.5 | 1,250 | 1,251 | +1 |
| 6.5 | 300 | 1 | 1,950.5 | 1,950 | 1,951 | +1 |
| 8.5 | 200 | 3 | 5,100.5 | 5,100 | 5,101 | +1 |
| 3.5 | 500 | 1 | 1,750.5 | 1,750 | 1,751 | +1 |
| 1.5 | 700 | 1 | 1,050.5 | 1,050 | 1,051 | +1 |
| 3.5 | 400 | 2 | 2,800.0 | 2,800 | 2,800 | 0 |
| 5.0 | 600 | 1 | 3,000.0 | 3,000 | 3,000 | 0 |
計算結果が .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 の結果 | 2 | 3 |
3.5 の結果 | 4 | 4 |
教訓
- Pythonの
round()は四捨五入ではない。この事実を知らないと、金額計算で1円のズレが発生する。 - 金額計算では
decimalモジュールのROUND_HALF_UPを使う - 他言語から移植する際は、丸め方式の違いに要注意(VB/VBA/Excelなど、多くの言語・ツールはデフォルトが四捨五入)
- テストでは 端数がちょうど
.5になるケース を必ず含める
