Approximate Equality for Floating Point
01 何が問題だったのか
浮動小数点数の比較については、インターネット上にさまざまな「やってはいけない」アドバイスがあふれています。
- 浮動小数点数の等価比較をしてはいけない
- 常に epsilon(許容誤差)を使え
- 浮動小数点数はつねに不正確である
これらの大半は誤っているか、厳密には正しくても誤解を招く表現です。そして、実際に浮動小数点数を比較したいときに何をすべきか について具体的で正しい指針を与えてくれるものはほとんどありません。
浮動小数点演算ではほとんどの計算で丸め誤差が発生しますし、物理問題の数値解のように、元の方程式を近似的に解くというよりも関連する別の方程式を厳密に解いていると見なした方がよい場合もあります。そのため、2つの結果が許容誤差の範囲で一致していれば等しいと扱いたい、という要求は頻繁に生じます。
多くの実装が誤っている
問題は、多くの人がこの「許容誤差つき比較」を、浮動小数点数の仕組みをきちんと理解しないまま書いてしまうことです。C 系言語では次のようなコードをよく見かけます。
if (fabs(x - y) < DBL_EPSILON) {
// equal enough!
}
このコードはほぼ確実に間違っています。浮動小数点演算の重要な性質のひとつに スケール不変性 があります。つまり、同じ比を保ったまま全体を大きくしたり小さくしたりしても意味のある結果が得られるように設計されています。ところが、上のように 絶対的な 許容誤差を使う比較はこのスケール不変性を壊してしまうため、ほぼ常に不適切です。しかも、x と y がだいたい 1 のオーダーにある「もっともありがちなスケール」においてさえ、DBL_EPSILON は自明でない計算に対する許容誤差としては小さすぎます。
対称性も失われがち
自作の近似比較がもう一つよく壊す性質が 対称性(approxEqual(x, y) == approxEqual(y, x))です。「x を基準に y との差の割合を見る」ような素朴な相対比較はこの対称性を持たず、引数の順番次第で結果が変わるため、再現性の低いテスト失敗や見つけにくいバグの温床になります。
ゼロとの比較も別問題
さらにやっかいなのは ゼロとの比較 です。相対的な許容誤差を使う限り、どんな有限の値もゼロとは「近似的に等しくない」と判定されてしまいます(0 からの相対差はつねに無限大として扱わざるを得ないため)。そのため、ゼロとの近似比較には、相対比較とは別の仕組みが必要です。
標準ライブラリがこの領域で適切に考え抜かれた小さな API を提供すれば、ユーザーが自分で書くよりはるかにましな結果になるはずだ、というのがこの提案の動機です。
02 どのように解決されるのか
この提案は Returned for revision(差し戻し) となり、そのままの形では Swift に取り込まれていません。ここで説明する API は、提案されていた内容です。
FloatingPoint プロトコルに対して、近似比較のための2つのメソッドを追加するというのが提案の骨子でした。
isAlmostEqual(to:tolerance:)
通常の近似比較には isAlmostEqual(to:) を使います。
if x.isAlmostEqual(to: y) {
// equal enough!
}
必要であれば、許容誤差(tolerance)を明示的に指定することもできます。
if x.isAlmostEqual(to: y, tolerance: 0.001) {
// equal enough!
}
この述語は次の性質を持ちます。
- 反射的(NaN を除く。NaN は他のすべての浮動小数点比較と同様、自分自身とも等しくならない)
- 対称的(
x.isAlmostEqual(to: y)とy.isAlmostEqual(to: x)は常に一致する) - 推移的ではない(そのため、キーとしての比較関数などには不向き)
許容誤差は 相対的 に解釈されます。ライブラリとしては利用者の入力のスケールを事前に知ることができないため、絶対許容誤差を使う設計は誤りが多くなる、というのが相対許容誤差を採用する理由です。
tolerance のデフォルト値は Self.ulpOfOne.squareRoot() です。「計算について何も情報がないなら、およそ半分のビットが丸めで失われていると仮定すべき」という数値解析の経験則に由来する値で、大抵の用途で安全な既定値として機能します。もし「半分のビットまで一致しているのに意味的に近似等価ではない」ような状況に出会ったら、それは計算の ill-conditioned を示すシグナルで、計算の組み立て方自体を見直した方がよい、という考え方です。
tolerance には [.ulpOfOne, 1) の範囲の値を渡すことが想定されています。範囲外でも結果は well-defined ですが、実用的な意味はありません。
実装上は、両方のオペランドの絶対値と leastNormalMagnitude のうち最大のものを基準スケールとし、差の絶対値がそのスケールに tolerance を掛けた値より小さいか、という形で比較が行われます。leastNormalMagnitude が現れるのは、subnormal 領域の値でも破綻しないようにするためです。片方または両方が無限大や NaN の場合には、内部でスケールし直した上で比較する補助経路(rescaledAlmostEqual(to:tolerance:))に処理が切り替わります。
let scale = max(abs(self), abs(other), .leastNormalMagnitude)
return abs(self - other) < scale * tolerance
isAlmostZero(absoluteTolerance:)
ゼロとの近似比較は、相対許容誤差ではうまくいきません。そこで専用に isAlmostZero() が提供されます。
if x.isAlmostZero() {
// zero enough!
}
この述語は名前のとおり 絶対 許容誤差を使い、abs(self) < absoluteTolerance をそのまま判定します。デフォルトの absoluteTolerance も Self.ulpOfOne.squareRoot() です。
absoluteTolerance を自分で選びたいときのおおよその指針として、提案では次のようなルールを挙げています。
- 値が浮動小数点加減算の結果なら、
ulpOfOne * n * scale程度(nは足した項数、scaleは最大項の大きさ) - 値が浮動小数点乗算の結果なら、各項について「ゼロと区別したい最小値」を掛け合わせたもの
- より一般には、「意味的にゼロと区別したい最小値」の半分程度
使い分けの指針
提案は、isAlmostEqual を 非ゼロとの比較 に、isAlmostZero を ゼロとの比較 に使う、という使い分けを前提にしています。isAlmostEqual(to: 0) は、どんな妥当な tolerance を与えても(well-scaled な値同士なら)常に false を返すため、ゼロの近傍判定には使えません。
なぜ Returned for revision になったか
この提案はレビューの結果、差し戻しとなりました。方向性自体には支持があったものの、API の形(名前やデフォルト tolerance の選択、ゼロ比較を別メソッドに分ける設計など)について再検討が必要という判断でした。そのため、現時点の Swift 標準ライブラリには isAlmostEqual(to:tolerance:) や isAlmostZero(absoluteTolerance:) は含まれていません。浮動小数点の近似比較が必要な場合は、当面は用途に応じた比較関数を自前で用意するか、swift-numerics などの外部ライブラリに頼ることになります。