9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

たくさんの浮動小数点数を足す場合の誤差

Last updated at Posted at 2021-08-15

これは何?

こんな記事

を先日書いた。

Kahan のアルゴリズムがあるのは知っていたけど、自分で書いたことは一度もなく。

それとは別に部分和を使うと誤差が減らせるという

ノウハウもある。

で。
どれがいいのか比べてみた。

実験内容

100個〜1万個の数値を足す。
数値は全部単精度。
単精度の値を有理数にしてから加算することで誤差のない値を求めておく。
で。
各アルゴリズムで計算する。
そして。

誤差 = ([誤差のない値] - [各アルゴリズムで計算した値]) ÷ [誤差のない値]

という形で誤差を計算して、誤差の二乗の平均値の平方根を評価する。

加算アルゴリズム

頭から順に加算

普通に足す。

c++
float sum(float const *begin, float const *end)
{
    float s = 0;
    for (auto it = begin; it != end; ++it)
    {
        s += *it;
    }
    return s;
}

素朴だけど、誤差が多めになる。

倍精度を使う

c++
float with_double(float const *begin, float const *end)
{
    double s = 0;
    for (auto it = begin; it != end; ++it)
    {
        s += *it;
    }
    return static_cast<float>(s);
}

倍精度が使えるのなら、使っちゃうのが簡単。
データが倍精度だったら 4倍精度を使いたいところだけど、現実的かどうかはケースバイケース。

部分和の和

前述の記事の方法。

書き方が違うけど、方針はだいたい同じ。

c++
float sum_of_parts(float const *begin, float const *end)
{
    auto len = end - begin;
    switch (len)
    {
    case 0:
        return 0;
    case 1:
        return *begin;
    default:
        break;
    }
    auto mid = begin + len / 2;
    return sum_of_parts(begin, mid) + sum_of_parts(mid, end);
}

値のばらつきが少ない場合、前半と後半は同じぐらいの値になるので加算による情報欠落が少ないだろうという読み。
並列実行と相性が良いはず。

Kahan

詳細は Wikipedia

を参照のこと。

c++
float kahan(float const *begin, float const *end)
{
    float sum = 0;
    float c = 0;
    for (auto it = begin; it != end; ++it)
    {
        auto y = *it - c;
        auto t = sum + y;
        c = (t - sum) - y;
        sum = t;
    }
    return sum;
}

足してから、足した結果増えた分と増やしたかった値を比べて次に活かす、みたいな話。

整列してから Kahan

絶対値が大きい順に整列してから Kahan で加算する。

c++
float sort_kahan(float const *begin, float const *end)
{
    std::vector<float> v(begin, end);
    float *b = v.data();
    float *e = b + v.size();
    std::sort(b, e, //
              [](float a, float b) -> bool
              {
                  return !(std::abs(a) <= std::abs(b));
              });
    return kahan(b, e);
}

小さい順より大きい順の方が誤差が少なかった。何故なのかはよくわからない。

Kahan-Babuska

Kahan と ruby の sum という記事を書いて、ruby の sum と Wikipedia の カハンのアルゴリズムはちょっと違うことを知ったのでアルゴリズムを追加。

ruby の sum と同様の計算

c++
float kb1(float const *begin, float const *end)
{
    float sum = 0;
    float c = 0;
    for (auto it = begin; it != end; ++it)
    {
        auto t = sum + *it;
        c += std::abs(*it) <= std::abs(sum)
                 ? sum - t + *it
                 : *it - t + sum;
        sum = t;
    }
    return sum + c;
}

kb は、Kahan-Babuska の頭文字。計算内容は ruby の Enumerable#sum と同様。

Kahan-Babuska 2段

c++
float kb2(float const *begin, float const *end)
{
    float sum = 0;
    float c0 = 0;
    float c1 = 0;
    for (auto it = begin; it != end; ++it)
    {
        auto t = sum + *it;
        auto v0 = std::abs(*it) <= std::abs(sum)
                      ? sum - t + *it
                      : *it - t + sum;
        auto t0 = c0 + v0;
        c1 += std::abs(v0) <= std::abs(c0)
                  ? c0 - t0 + v0
                  : v0 - t0 + c0;
        c0 = t0;
        sum = t;
    }
    return sum + c0 + c1;
}

ruby の Enumerable#sum の計算を見て、もっと精度上げられるなと思ったので、それを実装した。

調査結果

以下、 sum は単純加算。
with_double は倍精度で単純加算。
sum_of_parts は、前半と後半の合計を再帰。
kahan は Kahan のアルゴリズム。
sort_kahan は、大きい順に整列してから Kahan のアルゴリズム。
kb1 は、ruby の sum と同等。
kb2 は、ruby の sum で誤差を積んでいるんだけど、その加算に ruby の sum と同様の計算をする。

そして、データのばらつき方をいろいろ変えて試してみた。

横軸は対数目盛にした。
棒がないのは誤差ゼロということ。

2の±32乗

2のn乗をデータとするんだけど、 n は、-32〜32 までの一様乱数。
exp_flat_rand_32.png

±2の±32乗

±2のn乗をデータとするんだけど、 n は、-32〜32 までの一様乱数。符号も乱数で決める。
exp_flat_rand_32_pm.png

0〜1

0〜1 の一様乱数。
flat_rand_0_1.png

-1〜1

-1〜1 の一様乱数
flat_rand_m1_1.png

最初が巨大

先頭に $2^{22}$ があり、残りは 0.9〜1.1 の一様乱数。
huge_first.png

最初が負の巨大

先頭に $-2^{22}$ があり、残りは 0.9〜1.1 の一様乱数。
neghuge_first.png

平均0、標準偏差1 の正規分布

normal_0_1.png

平均50、標準偏差10 の正規分布

normal_50_10.png

二乗

1〜1万 をシャッフルしてからそれぞれを二乗した。
pow_2.png

悪意のあるデータ

最初に合計がゼロにならないようにちょっとデータがあって、残りは$$10^{18}, 10^{12}, 10^{6}, rand, -10^{18}, -10^{12}, -10^{6}$$の繰り返し。 $rand$ は、 ±1 の範囲の一様乱数。

malicious.png

まとめ

誤差の量は下表のようになった

アルゴリズム 悪意のあるデータ以外 悪意のあるデータ
sum 多い 多い
with_double 全部ゼロ 普通
sum_of_parts 少ない 多い
kahan とても少ない 多い
kb1 全部ゼロ 少ない
kb2 全部ゼロ とても少ない
sort_kahan 全部ゼロ ゼロ

となっていた。
kahan も相当強いと思ったけど、sort_kahan は全データで誤差ゼロという圧倒的な強さだった。
びっくりした。

というわけで、誤差少なく総和を求めたい場合

  • より高い精度での加算が現実的ならそうする。
  • 並列実行で速度を稼ぎたいとかそういう事情があるなら sum_of_parts のような感じのことをする。精度はちょっとしかよくならない。
  • より高い精度での加算が現実的ではないけど精度を上げたい場合。
  • ruby の sum と同様の kb1 がおすすめ。加算の経過をモニタしながら中断とかもできる。
  • データの性質によっては、kb2 を選ぶことで何桁か精度を上げることができるけど、稀だと思う。
  • 時間に余裕があり、加算の経過のモニタが不要なら sort_kahan のような計算が最強みたいだけど、これが必要なケースは極めて稀だと思う。

こんな感じで選べば良いと思う。

kahan は kb1 と比べるとメリットがないので、 kb1 を使うのが良い。
sort_kahan で kahan 使っちゃってるけど、これも kb1 に書き換えたほうが良かったか。ぐぬぬ。

なお。
実験のソースコードは https://github.com/nabetani/kahanchallenge においてある。

9
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?