PythonのNaN周りの挙動とIEEE754についてのメモ
こういうツイートがあった
Python君さぁ… pic.twitter.com/HDdQrSO8yp
— not (@not_522) May 25, 2022
「まあ大体IEEE 754のせい」でほぼ終わる話ではあるのだけど、少し深掘りもしたのでちょびっと備忘録メモ。
問題を(少し順番を変えた上で)再掲すると:
x = float('nan') x == x # False [x] == [x] # True y = float('nan') x == y # False [x] == [y] # False [x] == [copy.deepcopy(x)] # True
NaN != NaN
まず
x = float('nan') x == x # False
に関して。
NaN != NaN
は浮動小数点数の国際規格であるIEEE 754で決まっている挙動。これがNaN周りの怪しげな挙動の諸悪の根源である。そもそも自己同一性が保たれていないので、直観的な挙動にならなくても仕方ない。
なんでそんな定義になっているのか、という話がStack Overflowに載っていた:
回答者はIEEE 754の委員会メンバーだったとのこと。この挙動を決めた委員会メンバーであるKahanに直接聞いたところ、最大の理由は
there was no isnan( ) predicate at the time that NaN was formalized in the 8087 arithmetic; it was necessary to provide programmers with a convenient and efficient means of detecting NaN values that didn’t depend on programming languages providing something like isnan( ) which could take many years.
というわけで、この仕様が現実的にすぐに使われるためにはどうにかしてNaNチェックができるようにする必要があり、x != x
はそのチェックのためのハックだったようだ。isnan
がサポートされてしまった今となっては単なる歴史的経緯で、その結果として数十年後もこの微妙な挙動がサポートされ続けているので複雑な気持ちになる。
NaNの入ったリスト
ではなぜ
x == x # False [x] == [x] # True
のような挙動になるのかというと、Pythonではリスト(というかcontainer)の比較は、要素の値を比較する前にポインタを比較するから。
この挙動に関しては2006年にバグトラッカーで少し議論があった模様:
All your example shows is that the oddball NaN is in-fact odd.
I do not want the rest of Python mucked-up just because NaNs are designed to not follow the most basic definitions of equality
とのこと。この判断はまあ妥当に思える。実行速度面から言っても、もし[NaN] != [NaN]
を認めるならx = [NaN]; x != x
も認める必要が生じ、その結果すべての等価性の判定でデータ構造のすべてを触ってNaNが含まれていないかを調べる必要が生じる。「もしかしたらNaNが含まれているかもしれない」という可能性のために、すべてのデータ構造の等価性チェックにおいて「ノードが同じオブジェクトである」とわかってもチェックを打ち切れなくなる、というのはあまりにも大きなコストだろう。
ちなみにRubyでもNaN入りのリストの挙動は同じようだ:
既知の仕様で、 "Rubyの言語仕様的にはNaNとNaNの比較は未定義と言うことにします" ということらしい(同僚が教えてくれた) https://t.co/b9UgRJVcwh https://t.co/ieflOD9gc6
— osyoyu (@osyoyu) May 26, 2022
Rubyのバグトラッカーのチケット:
こちらの方が様々なオプションを比較して議論していて面白い。結論は同様。
NaNをdeepcopy
だったら
y = float('nan') x == y # False [x] == [y] # False [x] == [copy.deepcopy(x)] # True
という挙動はどういう理屈なのか。
x == y
がFalseなのはIEEE 754から。
[x] == [y]
がFalseなのはまずポインタが比較され、NaNはインターンされないのでメモリロケーションが違う、そして値の比較もIEEE 754準拠で当然False。
[x] == [copy.deepcopy(x)]
で新しくコピーしているのだからポインタ比較はFalseであるべきでは?と思うかもしれないが、copy.deepcopy
がコピーするのはミュータブルな構造のみでイミュータブルだと判定できるものに関してはコピーしない。id(x) == id(copy.deepcopy(x))
なのでリスト比較はTrueを返す。これも当然イミュータブルな値であれば問題ない挙動なはずなのだが、NaN != NaNのせいで歪みが出る。
終わりに
Pythonバグトラッカーで"nan"と検索すると「minやmaxやsortの挙動がおかしい」などいろいろ面白い話題がある。
von Neumannを剽窃して
Any one who considers comparing IEEE 754 floating point numbers in the possible presence of NaNs is, of course, in a state of sin.
と言いたくなる。