⚔️ C#クエスト ― 等しさの定義と、誤差からの挑戦(後編) 🐉
第5章:精度という壁
旅人は軋む階段を上り、二階へ向かった。
扉の向こうに、底の見えない器が待っていた。
護符はできた。
比較の罠には、もう落ちない。
……はずだった。
だが館の奥で、旅人は妙な計算に出会う。
5.1 粒にならない「1」
旅人は、ツボの前で立ち止まった。
「小さな数が飲み込まれる」
そんな話は、ただの比喩だと思っていた。
だが――この館では、比喩では終わらない。
double a = 1e16;
double b = a + 1.0d;
Console.WriteLine(a.ToString("G17"));
Console.WriteLine(b.ToString("G17"));
Console.WriteLine(a == b ? "equal" : "NOT equal");
Console.WriteLine((b - a).ToString("G17"));
旅人の目の前で、こうなった。
a (G17): 10000000000000000
b (G17): 10000000000000000
a==b : equal
delta : 0
旅人は息をのんだ。
「……1 を足したのに、増えていない?」
数学的には、確かに増えている。
だがこのツボでは、その「1」が目盛りに届かない。
増えたはずの一滴は、記録されない。
5.2 有効桁という天井
旅人は、ツボを覗き込んだ。
底は見えない。どこまでも深い。
「無限に入るなら、無限に刻めるはずだ」
そう思った瞬間、旅人は気づく。
溢れないことと、刻めることは別だ。
このツボの目盛りは、無限じゃない。
double は実数をそのまま保存しているわけではない。
限られたビット数で「近い値」を表している。
つまり――
表現できる細かさには限界がある
この限界が、いわゆる 有効桁 だ。
double の精度は、ざっくり言えば 15~16桁程度。
それ以上の細かさは、刻めない。
本当に大きい値が支配しているなら、
小さい数は刻みに届かず、結果に残らないことがある。
それが有効桁の考え方だ。
旅人は理解した。
この大陸の敵は、計算の正しさではない。
まず立ちはだかるのは、精度という器だった。
5.2.1 対策:刻めないなら、刻み方を変える
旅人は、対策を整理した。
-
足す順番を変える(小さいものから足す)
→ 大きくなった合計に、小さな値をぶつけない -
基準点を引く(差分を主役にする)
→ 絶対値ではなく「変化量」を積む -
整数で表せるなら整数にする(件数・回数・連番など)
→ “数の意味”に合った表現を選ぶ
この館では、数は無限に細かくはならない。
ならばこちらが、戦い方を変えるしかない。
※ただし、順番を変えると結果が変わることがある(→§5.3)。
5.2.2 失われた有効桁は戻らない
旅人は、もうひとつの罠に気づいた。
この館には、目盛りの粗い小さな器がある。
float だ。
double が 15~16桁の器なら、
float はせいぜい 7桁程度しか刻めない。
そして恐ろしいのは――
一度 float に落ちた精度は、戻らないことだ。
たとえ後から double に変換しても、安心はできない。
器を大きくしただけで、目盛りが細かくなるわけではない。
旅人は試した。
double da = 12345678.123456789;
float f = (float)da;
double db = f;
Console.WriteLine($"da (G17): {da.ToString("G17")}");
Console.WriteLine($"db (G17): {db.ToString("G17")}");
Console.WriteLine($"da==db : {(da == db ? "equal" : "NOT equal")}");
Console.WriteLine($"diff : {Math.Abs(da - db).ToString("G17")}");
Console.WriteLine($"f (G9) : {f.ToString("G9")}");
結果はこうだった。
da (G17): 12345678.123456789
db (G17): 12345678
da==db : NOT equal
diff : 0.12345678918063641
f (G9) : 12345678
表示は double らしく桁が増える。
だが――それは「刻めた」わけではない。
float が刻めるのは 7桁程度。
だから 8桁目以降は、あてにならない。
旅人は理解した。
これは計算の問題ではない。
器が変わったのだ。
5.2.3 有効桁の混在(低い方に揃う)
有効桁の考え方として大事なのは、
有効桁の少ない値が混ざると、結果の信頼できる桁も低い方に揃う
ということだ。
たとえば、次のような足し算を考える。
0.123456789012345
+)0.1111111
-------------------
0.234567889012345
ここで 0.1111111 は小数第7位までしか分からない。
つまり 小数第8位以降は不明だ。
この状態で加算すると、見た目の結果は細かい桁まで出る。
だが――小数第8位以降は保証できない。
したがって、この計算で「有効なデータ」と言えるのは、
0.2345678までになる。
第7位までが確かな値なので、結果も第7位までしか保証できない
精度の高い値があっても、
低い方が混ざれば、結果は低い方に揃ってしまう。
floatが混ざると、この現象が現実のコードでも起きる。
5.3 演算順序が変わると、結果も変わる(非互換)
旅人は考えた。
「なら、順番を工夫すればいい。
飲み込まれる前に、先に混ぜれば――」
だが、その発想はすぐに別の壁にぶつかった。
順番を変えると、結果も変わることがある。
旅人は試した。
double a = 1e16;
double b = -1e16;
double c = 1;
Console.WriteLine((a + b) + c);
Console.WriteLine(a + (b + c));
結果は、同じになるとは限らない。
-
(a + b) + cは 1 になることがある -
a + (b + c)は 0 になることがある
旅人は息をのんだ。
同じ3つの数を足しているのに、だ。
つまり――
式を“改善”したつもりでも、非互換になり得る。
5.3.1 「最適化」は数値結果を変えることがある
さらに厄介なのは、順番が意図せず変わることだ。
- リファクタで式の括り方を変えた
- 集計処理を高速化して足し込み順序が変わった
- 並列化・SIMD化で加算順序が保証されなくなった
これらはすべて、結果を変える可能性がある。
旅人は悟った。
「速くする」ことは、
「同じ結果を守る」ことと両立しない場面がある。
だから順番を変えるなら、先に決めるべきものがある。
どこまでを同じとみなすか。
コラム:順番を決めるという設計
本当に大きい値が支配しているなら、
小さい数は刻みに届かず、結果に残らないことがある。
それが有効桁の考え方だ。
だから「どの順番で計算するか」は、単なる好みではない。
設計になる。
値の範囲(スケール)が分かっているなら、
それらを考慮して計算の順番を決めなければいけない。
5.4 精度の壁を越えるための作法
精度の壁を壊す魔法はない。
だが、ぶつからない歩き方はある。
- 単位を変える(mm→μm、円→銭など)
- スケールを揃える(大きすぎる値と小さすぎる値を混ぜない)
- 足し込み順序を固定する(互換性が必要な処理)
- 整数で表せるなら整数にする(件数・回数・連番など)
旅人は覚えた。
この章で戦ったのは、
精度だ。
だが――
敵はここで終わらない。
第6章:誤差は増殖する
無限のツボを越えても、館は終わらなかった。
二階の奥には、もっと静かな部屋が残っていた。
音がしない。
風もない。
なのに――床だけが、わずかに軋む。
旅人は気づく。
ここには罠がない。
敵も見えない。
それでも、確実に削られていく。
6.1 丸め誤差は必ず出る
旅人は机の上に、同じ数を並べた。
double x = 0.1;
double y = 0.2;
double z = x + y;
Console.WriteLine(z.ToString("G17"));
Console.WriteLine(z == 0.3 ? "equal" : "NOT equal");
結果は、こうなることがある。
0.30000000000000004- そして
NOT equal
旅人はもう驚かなかった。
5章で学んだ通りだ。
double は実数そのものではない。
限られた目盛りで、近い値を刻んでいる。
だから――
丸めは必ず起きる
ここで大事なのは、
「丸めが起きるかどうか」ではない。
どれだけ起きるかだ。
6.2 累積誤差(足し込み・反復計算)
この部屋の恐ろしさは、静かに始まる。
一回のズレは、小さい。
目を凝らさなければ見えない。
だが――それが積み重なる。
旅人は試した。
double sum = 0.0;
for (int i = 0; i < 10; i++)
{
sum += 0.1;
}
Console.WriteLine(sum.ToString("G17"));
Console.WriteLine(sum == 1.0 ? "equal" : "NOT equal");
結果は、こうなることがある。
0.99999999999999989- そして
NOT equal
旅人は理解した。
誤差は「一回で倒す敵」ではない。
増殖する。
足し込み。
反復計算。
ループ。
集計。
処理が長くなればなるほど、
ズレは少しずつ形を変えながら増えていく。
6.3 誤差を抑え込む奥義
部屋の奥、壁に刻まれた文字があった。
誰かがここで戦い、そして残していったものだ。
誤差は消せない。
だが、増殖は止められる。
(1) 足し込み順序を固定する(互換性を守る)
集計や検証で「同じ結果」が必要なら、
順序を揃えるのが第一だ。
- 並列化で順序が変わる
- SIMD化で順序が変わる
- 実装変更で順序が変わる
この館では、順序が変わるだけで結果が変わる。
だから順序は、仕様になる。
(2) スケールを揃える(桁を揃えてから計算する)
大きい数と小さい数を混ぜれば、
小さい数は削られやすい。
単位を変える。
基準点を引く。
平均との差分にする。
「刻み」が暴れる前に、土俵を揃える。
(3) 補償和という考え方(Kahan)
旅人は机の引き出しから、奇妙なメモを見つけた。
失われた分を、別で持っておく
それだけで、増殖が抑えられることがある。
細かい実装は覚えなくていい。
名前だけでいい。
Kahan(カハン)補償和。
「足し算のたびに落ちる欠片を拾う」技だ。
(4) decimal を選ぶ(必要な場面だけ)
この世界には、別の器もある。
金額のように「10進で正確さが必要」なら、選ぶ意味がある。
ただし、万能ではない。
重い。遅い。扱いも違う。
だから――
必要な場面だけでいい。
旅人は奥義を胸に刻んだ。
6.4 最終決戦:刻印の意味
旅人は、もう一度、壁の文字を見上げた。
誤差を想定し、許容せよ。
許容できないなら、手間を払え。
最初はただの警句に見えた。
だが今なら分かる。
この部屋の敵は、見えない。
斬れない。
倒せない。
それでも――増える。
旅人は机に向かった。
同じ計算を、もう一度やり直す。
ただし今度は、
落ちた欠片を拾いながら。
6.4.1 許容できるなら、許容する
誤差は「バグ」ではない。
この世界の仕様だ。
だからまずは、こう考える。
- どこまでのズレを許すか(許容範囲)
- どこまで一致させる必要があるか(互換性)
許容できるなら、そこで終わりでいい。
それが一番強い。
6.4.2 許容できないなら、手間を払う(補償和)
だが、どうしても許容できない場面がある。
- 長い反復計算でズレが増える
- 足し込み回数が多すぎる
- 検証で “同じ” を求められる
そんなときは、手間を払う。
旅人は引き出しから、古いメモを見つけた。
失われた分を、別で持っておく
それが――補償和(Kahan)だった。
6.4.3 Kahan補償和(解説コメント付き)
// Kahan summation(補償和)
// 足し算で「丸めにより失われた端数」を別変数で拾い続け、累積誤差の増殖を抑える。
// ポイントは「合計 sum」だけを更新するのではなく、落ちた欠片 c を次の加算へ持ち越すこと。
static double SumKahan(double[] xs)
{
double sum = 0.0; // 合計
double c = 0.0; // 補償(丸めで失われた分の蓄積)
foreach (var x in xs)
{
// 前回までに失われた分(c)を差し引いて、
// 今回の加算に“乗せ直す”
double y = x - c;
// ここで加算する(この瞬間に丸めが起きる)
double t = sum + y;
// (t - sum) は「今回、sumに実際に加算された量」
// そこから y を引くと「今回、落ちた欠片」が出る
c = (t - sum) - y;
// 合計更新
sum = t;
}
return sum;
}
旅人は理解した。
誤差は消せない。
だが、増殖は抑えられる。
変えたのは式ではない。
積み方だけだ。
コラム:Kahan補償和は強力だが万能ではない
Kahan補償和は「足し算の累積誤差」を抑える強力な手段だが、万能ではない。
- 逐次依存があるため並列化しづらい(補償値を次へ持ち越すため)
- 乗算や複雑な式の誤差には直接効かない(効くのは足し算の丸め誤差)
- 大量データでは Neumaier の方が安定する場合もある
補償和は「誤差を消す魔法」ではなく、
誤差の増殖を抑えるための武器として使い分けるのが現実的だ。
6.4.4 結論:勝利条件は「ゼロ」ではない
旅人は最後に、同じ足し算を繰り返して確かめた。
== final battle: naive vs kahan (sum 0.1 many times) ==
expected: 1000000
naive : 999999.99983897537
kahan : 1000000
誤差は消せない。
だが、増殖は抑えられる。
そして最後に残るのは、壁の刻印どおりだ。
許容できるなら、許容せよ。
許容できないなら、手間を払え。
旅人は扉に手をかけた。
誤差は消えていない。だが――もう暴れない。
それで十分だった。
エピローグ:演算子は正しい
演算子は正しい。
揺れているのは、この世界の前提と器のほうだ。
整数の国では、余りは残せた。
この大陸では、余りすら分ける。
値はある。
だが、完全ではない。
だから必要なのは、
正しさを祈ることではない。
境界を決め、判断をまとめ、揺れる世界でも壊れない形を選ぶこと。
旅人はそれを知った。
扉の向こうには、まだ見ぬ土地が広がっている。
だが、もう立ち止まらない。
立ち止まるべきは――コードだ。
旅人は静かに歩き出した。
付録A:比較テスト用サンプルコード
作成
dotnet new console -n FloatQuest
cd FloatQuest
実行
dotnet run
サンプルコード
using System;
class Program
{
const double EPS = 1e-10;
static bool NearlyEqual(double x, double y)
=> Math.Abs(x - y) < EPS;
const double ABS_EPS = 1e-10; // ゼロ付近の守り(距離)
const double REL_EPS = 1e-10; // 大きな値の守り(比率)
static bool NearlyEqual2(double a, double b)
{
double diff = Math.Abs(a - b);
// まずは絶対誤差で守る(0近傍で相対誤差は弱い)
if (diff <= ABS_EPS) return true;
// 次に相対誤差で守る(値のスケールに合わせる)
double scale = Math.Max(Math.Abs(a), Math.Abs(b));
return diff <= REL_EPS * scale;
}
static void Main()
{
TestNearlyEqual_01();
TestPrecisionCeiling_1e16();
TestFloatContamination();
TestNearlyEqual_LargeScale();
TestFinalBattle();
}
static void TestNearlyEqual_01()
{
double a = 0.5 - 0.4;
double b = 0.1;
Console.WriteLine("== 0.5 - 0.4 vs 0.1 ==");
Console.WriteLine("== raw compare ==");
Console.WriteLine(a == b ? "equal" : "NOT equal");
Console.WriteLine();
Console.WriteLine("== NearlyEqual ==");
Console.WriteLine(NearlyEqual(a, b) ? "equal" : "NOT equal");
Console.WriteLine();
Console.WriteLine("== values ==");
Console.WriteLine($"a (G17): {a.ToString("G17")}");
Console.WriteLine($"b (G17): {b.ToString("G17")}");
Console.WriteLine($"diff : {Math.Abs(a - b).ToString("G17")}");
Console.WriteLine($"EPS : {EPS.ToString("G17")}");
Console.WriteLine();
}
static void TestNearlyEqual_LargeScale()
{
Console.WriteLine("== NearlyEqual large scale (1e8 vs 1e8 + 0.001) ==");
double a = 1e8;
double b = a + 0.001;
Console.WriteLine($"a (G17): {a.ToString("G17")}");
Console.WriteLine($"b (G17): {b.ToString("G17")}");
Console.WriteLine($"diff : {Math.Abs(a - b).ToString("G17")}");
Console.WriteLine(NearlyEqual(a, b) ? "equal" : "NOT equal");
Console.WriteLine();
double diff = Math.Abs(a - b);
double scale = Math.Max(Math.Abs(a), Math.Abs(b)) * REL_EPS ;
Console.WriteLine("== NearlyE2ual2 large scale (1e8 vs 1e8 + 0.001) ==");
Console.WriteLine(NearlyEqual2(a, b) ? "equal" : "NOT equal");
Console.WriteLine();
}
static void TestPrecisionCeiling_1e16()
{
double a = 1e16;
double b = a + 1.0d;
Console.WriteLine("== precision ceiling (1e16 + 1) ==");
Console.WriteLine($"a (G17): {a.ToString("G17")}");
Console.WriteLine($"b (G17): {b.ToString("G17")}");
Console.WriteLine($"a==b : {(a == b ? "equal" : "NOT equal")}");
Console.WriteLine($"delta : {(b - a).ToString("G17")}");
Console.WriteLine();
}
static void TestFloatContamination()
{
Console.WriteLine("== float contamination (double -> float -> double) ==");
double da = 12345678.123456789; // double ではそれなりに刻める
float f = (float)da; // ここで刻みが荒くなる(丸め)
double db = f; // double に戻しても、荒いまま
Console.WriteLine($"da (G17): {da.ToString("G17")}");
Console.WriteLine($"db (G17): {db.ToString("G17")}");
Console.WriteLine($"da==db : {(da == db ? "equal" : "NOT equal")}");
Console.WriteLine($"diff : {Math.Abs(da - db).ToString("G17")}");
Console.WriteLine($"f (G9) : {f.ToString("G9")}");
Console.WriteLine();
}
static double SumNaive(double[] xs)
{
double sum = 0.0;
foreach (var x in xs) sum += x;
return sum;
}
// Kahan compensation summation
static double SumKahan(double[] xs)
{
double sum = 0.0;
double c = 0.0; // 失われた分(補償)
foreach (var x in xs)
{
double y = x - c;
double t = sum + y;
c = (t - sum) - y;
sum = t;
}
return sum;
}
static void TestFinalBattle()
{
Console.WriteLine("== final battle: naive vs kahan (sum 0.1 many times) ==");
const int N = 10_000_000;
var xs = new double[N];
for (int i = 0; i < N; i++) xs[i] = 0.1;
double naive = SumNaive(xs);
double kahan = SumKahan(xs);
Console.WriteLine($"expected: {(N * 0.1).ToString("G17")}");
Console.WriteLine($"naive : {naive.ToString("G17")}");
Console.WriteLine($"kahan : {kahan.ToString("G17")}");
Console.WriteLine();
}
}
付録B:EPSの決め方と誤差判定(実務テンプレ)
B.1 まず結論:比較は「絶対+相対」で行う
浮動小数点の比較は == ではなく、次の判定を使う。
diff <= max(ABS_EPS, REL_EPS * scale)
diff = |a - b|-
scale = max(|a|, |b|)(値の大きさに合わせる) -
ABS_EPS:ゼロ付近の守り(距離) -
REL_EPS:大きな値の守り(比率)
B.2 EPSの決め方(設計ルール)
(1) ABS_EPS は「業務の最小単位」から決める
ABS_EPS は「この差は誤差扱いする」という線引き。
例:
- 長さが mm 単位で十分 →
ABS_EPS = 1e-3 - 重量が 0.1g 単位 →
ABS_EPS = 1e-4 - 温度が 0.01℃単位 →
ABS_EPS = 1e-2
0近傍では相対誤差が効きにくいため、ABS_EPS が最後の砦になる。
(2) REL_EPS は「有効桁」または「許容差の逆算」で決める
REL_EPS は「値の大きさに対して何桁ぶん一致させたいか」を表す。
有効桁が N 桁なら目安は
[
REL_EPS \approx 10^{-N}
]
または「許容したい差」から逆算する。
例:1e8 付近で 0.01 まで同一扱いしたい
[
REL_EPS \ge 0.01 / 1e8 = 1e-10
]
よって
const double REL_EPS = 1e-10;
(3) doubleの限界より厳しくしても勝てない
doubleは有限精度なので、REL_EPS を極端に小さくしても意味が薄い。
また演算回数が増えるほど誤差は増えるため、比較は余裕を持たせる。
B.3 推奨の誤差判定(NearlyEqual)
以下は実務でそのまま使える比較関数。
static bool NearlyEqual(double a, double b,
double absEps = 1e-10,
double relEps = 1e-10)
{
// NaNは常に一致しない
if (double.IsNaN(a) || double.IsNaN(b)) return false;
// ±Infinity は同符号同士のみ一致
if (double.IsInfinity(a) || double.IsInfinity(b)) return a == b;
double diff = Math.Abs(a - b);
double scale = Math.Max(Math.Abs(a), Math.Abs(b));
// abs と rel の強い方で判定
return diff <= Math.Max(absEps, relEps * scale);
}
B.4 使い分けの目安(現場で迷わないための3行)
-
ゼロ付近の誤差は
ABS_EPSが支配する -
大きな値の誤差は
REL_EPSが支配する - REL_EPS は「許したい差 ÷ 値の大きさ」で逆算できる
Discussion