プログラミング
PHPとLuaにおける対数関数の非単調性
Log is non-monotonic in PHP and Lua (purplesyringa.moe)
要約
数学的に対数関数は単調であるべきですが、PHPとLuaでは特定の浮動小数点数の場合にこの性質が破られることがあります。これは一般的な浮動小数点演算の誤差ではなく、対数計算の実装方法の違いに起因します。特に、基数が10や2の場合に、言語が内部で異なる計算方法(自然対数を使用するか、専用の関数を使用するか)を切り替えることが原因で、予期しない結果が生じることがあります。
全文翻訳
a > b > 1 かつ x > 1 の場合、log_a(x) < log_b(x) であると証明できます。(念のため、log_a(x) は x = a^t となる値 t を表します。)これは直感的に理解できます。通常の数値では、a が大きいほど、同じ x を得るために必要な t は小さくなります。
しかし、PHP に尋ねると、いくつかの稀なケースであなたが間違っていると教えてくれます。
<?php $x = 2.93; $a = 10 + 2 ** -49; $b = 10; assert($a > $b); var_dump(log($x, $a) < log($x, $b)); var_dump(log($x, $a) == log($x, $b));
はっきりさせておきますが、これは通常の浮動小数点数の不正確さではありません。誰もが浮動小数点演算は不正確であることをすでに知っており、それをブログで話題にしても面白くありません。この例は、意図的に別の何かを引き起こすように設計されました。
結果が < ではなく = を示すとしても、それは完全に自然です。すべての実数を浮動小数点数として正確に表現できるわけではないため、丸めによって近い結果が等しいと表示されることがあります。実際、同じ数値で Python の math.log は「等しい」結果を生成します。しかし、PHP では、結果が奇妙に反転します。Lua でも同様です!しかし、Rust や C# ではそうなりません。すべて同じマシンと OS 上でです!どうしてでしょうか?
また、私が変更しているのは基数だけであることに注意してください。引数も変更した場合、あらゆる言語で有効な多くの反例が見つかるでしょう。例えば:
log(243 ** 3, 3 ** 3) != log(243, 3)
…これは対数計算の公式が不完全だからです。私たちが議論しているのはそれではありません。
対数の基数
これがなぜ起こるのかを理解するために、言語が通常 math.log をどのように実装しているかを見てみましょう。超越関数を提供するライブラリである libm は、対数を計算するために複数の関数を公開しています:log、log10、log2 など。各関数は単一の基数を扱います:log は基数 e、log10 は基数 10 などを使用します。それらの精度に保証はありませんが、通常はかなり良好で、少なくとも単調です(総当たり)。
しかし、任意の基数を扱う関数はありません。そのため、2つの引数を持つ log を提供する言語は、ずるをする必要があります。数学的には、log_a(x) = ln(x) / ln(a) なので、2つの基数 e の対数から任意の対数を計算できます。
これは少し不正確です。そのため、math.log(243^3, 3^3) == math.log(243, 3) が失敗します。ln と除算の二重丸めにより、計算された値がわずかにずれます。
しかし、これは投稿の冒頭にある、x が変更されていないケースを説明しません。その例では、分子は同じ(ln(x))で、分母は(記号的には)減少しますが、結果が減少するのはなぜでしょうか?浮動小数点数でさえ奇妙です!
さらに奇妙なのは、PHP や Lua で実際に ln(a) と ln(b) を計算すると、それらが同じ値に丸められることです!つまり、分子も分母も変化していないのに、結果が変化したのですか??
解決策
おそらく、あなたはおそらく理由に気づいているでしょう。b=10 は非常に特殊な反例であり、二重対数精度誤差には疑わしい log10 のような穴があります。
PHP と Lua は常に ln(x) / ln(a) の公式を使用しているわけではありません。libm が直接実装している基数、つまり基数 10 と基数 2 の場合、それらは自然対数を通さずに対応する関数(log10 と log2)を呼び出します。したがって、問題のコードは ln(x) / ln(a) の2つの計算を比較しているのではなく、log(x) / log(10 + eps) と log10(x) を比較しています。これらは完全に異なる方法であるため、異なる方向にエラーが発生する可能性があることに驚くべきではありません。
意図は良いものです。適用可能な場合、log10 はより正確で高速な結果を提供します。しかし、2つの評価方法を組み合わせると、境界で不連続性が生じ、各方法が個別に満たす合理的な仮定を破ってしまいます。
雑談
これをバグと呼ぶべきかどうかはわかりませんが、確かに見過ごされた問題です。残念ながら、これは浮動小数点数の世界では非常に一般的です。IEEE-754 自体は信じられないほど堅牢ですが、あちこちの考えなしの実装上の決定がそれを汚染し、人々がほぼすべての FP バグを固有の不正確さに帰するほどです。
Lua の場合、math.log しか提供しておらず、math.log10 は提供していないため、正しいことは math.log10 を追加し、math.log からの特別なケース処理を削除することです。これにより、固定基数 10 を使用する人々は、より高速でシンプルな math.log10 メソッドを使用でき、より良い精度境界を提供する可能性があります。一方、可変基数を使用する人々は、エッジケースを気にする必要がないという利点があります。ああ、Lua 5.2 では、彼らはほぼ正反対のことをしました!私は人々が正確さを重視するのを好みます。
一方、PHP は、log10 がすでに存在しているにもかかわらず、基数 10 と 2 を特別扱いしています。これは、RTFM を読まない人々のためのものだと思われますが、彼らのターゲットオーディエンスを疑うことはありません。もし PHP の関数名を推測するほど狂っている人がいればですが。私は混在したシグナルを受け取っているので、頭痛がする前にこの投稿を終えた方が良いでしょう。
興味深いことに、PHP と C# は、x に関係なく NaN を返すように log(x, 1) を特別扱いしていますが、Lua と Python は通常 ±∞ を返します。まさに多様なエコシステムです。
ああ、そしてさらに:PHP の log のシグネチャは、デフォルトの基数が M_E(e の近似値)であると述べていますが、ドキュメントは基数なしの log が自然対数を返すと述べています。では、log(x) は log_M_E(x) を計算するのか、それとも log_e(x) を計算するのか?(この質問は、それに見えるよりも重要です。例えば、sin(M_PI) は非ゼロ値を正しく返しますが、M_PI は π と正確ではありません。)Lua は少なくともそれを曖昧にするという配慮をしています。ネタバレ:後者ですが、実際には log(x, M_E) も同じ結果を生成します。なぜなら、ln(x) / ln(a) の丸めがひどすぎて、両者が一致するからです。