タグ: expm1・log1p

  • expm1とlog1p、小さな変化が計算途中でゼロになる理由

    expm1とlog1p、小さな変化が計算途中でゼロになる理由

    小さな変化を計算したはずなのに、答えがゼロになることがあります。例えば、x=10のマイナス16乗を入れて「eのx乗から1を引く」と、数学では正の値になるのに、通常の浮動小数点計算では0と表示される場合があります。

    このような式に用意されているのがexpm1とlog1pです。前者はexp(x)−1、後者はlog(1+x)を求める専用関数です。式をそのまま二段階で計算する場合と何が違うのか、Pythonの標準ライブラリと独自の実行例で確かめます。

    簡単にまとめると?

    • 浮動小数点では、1の近くに表せる値の間隔があります。微小な変化が途中で丸められると、最後に引き算をしても取り戻せません。
    • expm1(x)とlog1p(x)は、ゼロに近いxから小さな結果を求めるための関数です。単にexpやlogを呼んだ後で表示桁数を増やす方法とは異なります。
    • 専用関数へ渡す前に入力の差が消えていれば、回復はできません。元のxを保持し、入力範囲と使っている数値型も確認します。

    1の隣にある数まで、間隔がある

    ここで使うbinary64は、2進数で有限の桁数を保存する浮動小数点形式です。有効桁は53ビットで、あらゆる実数をそのまま保存できるわけではありません。計算結果が表せる値の間に来たときは、近くの値へ丸められます。

    1.0より大きい側で、次に表せる値までの差は2のマイナス52乗、約2.22×10のマイナス16乗です。今回のx=1e-16はその半分より小さいため、通常の最近接丸めでは、1.0+xは再び1.0になります。これは「小さすぎる数は何でも0になる」という話ではありません。1e-16自体は保存できても、1.0に加えた結果としては、その微小な違いを残せないのです。

    この違いは、表示の問題と区別します。値が内部で1.0へ丸められた後に、小数点以下を長く表示しても、失われた差は戻りません。

    expの結果を丸めてから引くと、差だけが消える

    数学では、ゼロに近いxについてexp(x)は1に近く、exp(x)−1はおよそxになります。ところが、先にexp(x)の結果をbinary64へ丸めると、xが十分小さい場合、その結果が1.0になってしまいます。続けて1.0を引けば、結果は0です。

    注意したいのは、最後の引き算だけが誤っているわけではないことです。すでに丸められた1.0から1.0を引く処理は、その二つの数については正確です。求めたかった微小な差は、その前の段階で失われています。近い値の差を取ると、元の値にとっては小さな丸め誤差が、差にとっては大きな相対誤差になることが問題です。

    math.expm1(x)は、この差を一つの関数として求めます。小さなxに適した計算を使えるため、いったん丸めたexp(x)から単純に1を引く方法で失われる情報を残せます。内部の計算法はライブラリや環境によるので、特定の級数を必ず使うとは限りません。

    同じ入力で計算すると、何が変わるか

    以下はQSでPython 3.12.14・macOSの環境で実行したbinary64の例です。表では読みやすい桁数へ丸めています。1e-16は10のマイナス16乗を表す表記です。

    入力 x exp(x)−1 expm1(x) log(1+x) log1p(x)
    1e-8 約9.99999994e-9 約1.000000005e-8 約9.99999989e-9 約9.99999995e-9
    1e-12 約1.00008890e-12 約1.00000000e-12 約1.00008890e-12 約1.00000000e-12
    1e-16 0 約1e-16 0 約1e-16

    入力が1e-12の場合、二段階のexp(x)−1では、同じ入力を80桁精度で計算した参照値に対する相対誤差が約0.00889%でした。絶対的には小さな差でも、求めたい結果自体が小さいと、無視できるかどうかの判断は変わります。

    xが10のマイナス16乗の例。expを計算して1を引くと0になり、expm1では約10のマイナス16乗が残る。先に1とxを足してlogを取ると0になり、log1pでは約10のマイナス16乗が残る。
    QS独自の実行結果を整理した計算経路。x=1e-16、binary64の例。数学的に等しい式でも、途中でどの値を丸めるかによって、残る差が変わります。

    参照計算には、入力のfloatをDecimal.from_floatでそのまま80桁精度の計算へ移しています。十進表記の1e-12を理想的な実数として扱った値との比較ではなく、同じ表現済み入力に対する演算の違いを比べるためです。最後の数ビットは実行環境によって変わり得るため、この表をすべての数値ライブラリの結果保証としては使えません。

    log1pには、1を足す前のxを渡す

    log1pのlogは自然対数です。ゼロに近いxではlog(1+x)もおよそxになります。しかし先に1.0+xが1.0へ丸められると、logへ渡るのは1.0であり、log(1.0)は0になります。

    math.log1p(x)には、元のxをそのまま渡します。例えば、いったんy = 1.0 + xを作り、その後でmath.log1p(y - 1.0)と書き換えても、yがすでに1.0なら入力は0です。関数名を置き換えるだけでは、前段で消えた情報を取り戻せません。

    実装を点検するときは、最終行だけでなく、小さな変化をどこで作り、どこで大きな基準値へ足したかを追います。変化量xを独立して保持できるなら、その形のまま専用関数へ渡すことが選択肢になります。

    小さな減少量にも、式を組み替えて使える

    例えば、非負の小さなxに対して、残る割合をexp(−x)と置いた数式モデルを考えます。そこから減った割合は1−exp(−x)です。この値を計算したいなら、-math.expm1(-x)と書くことで、1に近い二つの値を最後に引く形を避けられます。

    x=1e-16のとき、この専用関数を使った結果は約1e-16です。この例は式の評価方法を示すもので、実物の減衰や装置性能を測定したものではありません。数式が対象に合っているかというモデルの問題と、その数式を精度よく計算できるかという数値計算の問題は、それぞれ確認が必要です。

    専用関数でも解決しない境界

    expm1やlog1pも有限精度で動きます。入力そのものの丸め、さらに小さな値での表現限界、大きな正のxでの指数関数のオーバーフローまで、すべてをなくすわけではありません。実数としてlog1pを使える範囲はx>−1であり、Pythonのmathでは範囲外や境界の扱いも確認する必要があります。

    判断の順番は、まず必要な量がexp(x)−1やlog(1+x)の形になっているか、次にxがゼロに近い領域を通るか、そして元のxを保持して渡せるか、です。必要な誤差水準に照らして、代表値だけでなくゼロ付近や境界でも比較します。数学的な式が正しくても、そのままの計算順序が欲しい小さな差を残せるとは限りません。

    出典