pandasのSettingWithCopyWarningを理解する (3/3)

この記事は、 SettingwithCopyWarning: How to Fix This Warning in Pandas – Dataquest の日本語訳です。3回にわたって掲載していて、この記事は3回目です。

1回目の記事はこちら: linus-mk.hatenablog.com

2回目の記事はこちら: linus-mk.hatenablog.com

f:id:soratokimitonoaidani:20190518160502p:plain

連鎖代入を詳しく調べる

以前の例を再利用しよう。data内で、bidderの値が'parakeet2004'である各行のbidderrate列を更新しようとしていたのであった。

data[data.bidder == 'parakeet2004']['bidderrate'] = 100
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/ipykernel/__main__.py:1: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame.Try using .loc[row_indexer,col_indexer] = value insteadSee the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy if __name__ == '__main__':

このSettingWithCopyWarningによってpandasが本当に伝えていることは、コードの動作があいまいだということである。しかし、なぜあいまいなのかを理解し、また警告の文言を理解するためには、いくつかの概念について説明したほうがよいだろう。

私たちは以前、ビューとコピーについて簡単に述べた(訳注:1回目の記事を参照)。DataFrameサブセットにアクセスするための可能な方法は2つある。メモリ内の元のデータへの参照を作成する(ビュー)か、サブセットを新しい小さなDataFrameにコピーする(コピー)ことである。 ビューは元のデータの特定の部分を見る方法だが、一方でコピーはそのデータの複製物であり、メモリ内の新しい場所にある。前の図で示したように、ビューを変更すると元の変数は変更されるが、コピーを変更しても元の変数は変更されない。

理由は後述するが、pandasでの 'get'操作の出力は保証されていない。pandasのデータ構造にインデックス操作を行うと、ビューまたはコピーのいずれも返される可能性がある。つまり、DataFrameに対するget操作は、新たなDataFrameを返すが、その中身は以下のどちらかだ。

  • 元のオブジェクトからのデータのコピー。
  • 元のオブジェクトのデータに対する参照(コピーを作成しない)。

どちらが起こるのかは分からないし、それぞれの場合の振る舞いは全く異なるので、警告を無視することは危険な行為である。

ビュー、コピー、そしてこのあいまいさをより明確に説明するために、単純なDataFrameを作成してインデックスを付けてみよう。

df1 = pd.DataFrame(np.arange(6).reshape((3,2)), columns=list('AB'))
df1
- a b
0 0 1
1 2 3
2 4 5

そして、 df1のサブセットをdf2に代入しよう。

df2 = df1.loc[:1]
df2
- a b
0 0 1
1 2 3

これまで学んできたことを考えると、df2はdf1のビューかもしれないし、またはdf1のサブセットのコピーかもしれない、ということが分かる。

問題を理解する前に、連鎖インデックスをもう一度見てみる必要がある。'parakeet2004'を使った例を詳しく説明すると、2つのインデックス操作を連鎖させたのであった。

data[data.bidder == 'parakeet2004']
__intermediate__['bidderrate'] = 100

ここで__intermediate__は最初の呼び出しの出力を表し(訳注:実際に__intermediate__というメソッドや変数があるわけではない)、私たちからは完全に隠れている。思い出してほしいのだが、属性アクセスを使用した場合も問題のある同じ結果になってしまった:

data[data.bidder == 'parakeet2004'].bidderrate = 100

同じことが他の形式の連鎖呼び出しにも当てはまる。それは、この中間オブジェクトを生成しているからである。

内部的には、連鎖インデックスとは、単一の操作を実行するために__getitem__または__setitem__を複数回呼び出すことを意味する。これらは特別なPythonメソッドであり、これらのメソッドを実装するクラスのインスタンスに対して角括弧を使用することで呼び出される。これは、シンタックスシュガー(糖衣構文)と呼ばれるものの例である。この例でPythonインタプリタが実行する内容を見てみよう。

# Our code
data[data.bidder == 'parakeet2004']['bidderrate'] = 100

# Code executed
data.__getitem__(data.__getitem__('bidder') == 'parakeet2004').__setitem__('bidderrate', 100)

すでにお気づきかもしれないが、SettingWithCopyWarningは、この連鎖された__setitem__呼び出しの結果として生成される。あなたは自分でこれを試すことも可能だ――上記の2行は全く同じように機能する。理解する上で注意してほしいのだが、2番目の__getitem__呼び出し(bidderの列を呼ぶ箇所)は入れ子になっており、ここでの連鎖の問題の一部ではない。

一般に、既に述べたように、pandasはget操作がデータのビューとコピーのどちらを返すのかを保証していない。この例でビューが返された場合、連鎖代入の2番目の式では、元のオブジェクトに対して__setitem__を呼び出すだろう。しかし、コピーが返された場合、代わりに変更されるのはコピーである。元のオブジェクトは変更されない。

A value is trying to be set on a copy of a slice from a DataFrame. (DataFrameからのスライスのコピーに値が設定されようとしている)」という文言によって警告が言わんとしているのは、まさにこのことである。このコピーはどこからも参照されていないので、それは最終的にガベージコレクションの対象となる。SettingWithCopyWarningは以下のことを伝えている:最初の__getitem__呼び出しでビューとコピーのどちらが返されたのかを、pandasは判断できない、したがって、代入によって元のオブジェクトが変更されたかどうかは不明である。 pandasがこの警告を発生させる理由について考えるもう一つの方法は、「私たちは元のオブジェクトを変更しているのか?」という質問の答えが不明だからである。

そして私たちは元のオブジェクトを変更したい。この警告が提案する解決策は、これら2つの別々の連鎖操作を、locを使用して単一の代入操作に変換することである。これにより、コードから連鎖インデックスが削除され、警告が表示されなくなる。修正後のコードとそれを展開したものは、このようになる。

# Our code
data.loc[data.bidder == 'parakeet2004', 'bidderrate'] = 100
# Code executed
data.loc.__setitem__((data.__getitem__('bidder') == 'parakeet2004', 'bidderrate'), 100)

DataFrameのlocプロパティは、元のDataFrameであることが保証されているが、より多くのインデックス機能を備えている。

偽陰性

(訳注:ここでの「偽陰性」は「実際には問題が起きているのに、警告が発生しないこと」を指す)

locを使用しても問題が解決するわけではない。locを用いたget操作の場合、依然としてビューとコピーのどちらが返る可能性もあるからだ。やや複雑な例を見てみよう。

data.loc[data.bidder == 'parakeet2004', ('bidderrate', 'bid')]
- bidderrate bid
6 100 3.00
7 100 10.00
8 100 24.99

今回は、1列ではなく2列を取り出した。 全てのbidの値を設定してみよう。

data.loc[data.bidder == 'parakeet2004', ('bidderrate', 'bid')]['bid'] = 5.0
data.loc[data.bidder == 'parakeet2004', ('bidderrate', 'bid')]
- bidderrate bid
6 100 3.00
7 100 10.00
8 100 24.99

変更もされていないし、警告も発生していない!スライスのコピーに値を設定したが、pandasによって検出されなかった。これは偽陰性である(訳注:実際には問題が発生しているのに警告が発生しない状況)。locを使ったからといって、連鎖代入を使ってよいわけではない。この特定のバグに関する古い未解決のissueGitHubに存在する。

これを行う正しい方法は次のようになる。

data.loc[data.bidder == 'parakeet2004', 'bid'] = 5.0
data.loc[data.bidder == 'parakeet2004', ('bidderrate', 'bid')]
- bidderrate bid
6 100 5.0
7 100 5.0
8 100 5.0

実際の場面でこんな問題にはまってしまうことなんてあり得るのだろうか、と思うかもしれない。しかし、思っているよりも問題に陥りやすいのが、DataFrameのクエリの結果を変数に代入するときである。次の節で説明する。

隠れた連鎖

先ほどの隠れた連鎖の例をもう一度見てみよう。そこでは、変数winnersを作り、その中の304というラベルの行に対して、bidderの値を設定しようとしていた。

winners = data.loc[data.bid == data.price]
winners.loc[304, 'bidder'] = 'therealname'
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/pandas/core/indexing.py:517: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame.Try using .loc[row_indexer,col_indexer] = value insteadSee the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy self.obj[item] = s

locを使用しても、SettingWithCopyWarningが出力される。この問題は非常に分かりづらいかもしれない。なぜなら、警告メッセージが提案していることを、私たちがすでに実行したようにみえるからだ。

しかし、変数winnersについて考えてみよう。本当のところ、これは何なのだろうか? 変数winnersをdata.loc[data.bid == data.price]でインスタンス化したしたことを考慮すると、変数winnersが元のDataFrameのビューとコピーのどちらかなのかは分からないのだ(get操作はビューかコピーのどちらかを返すので)。インスタンス化の行と、警告が出てきた行とを組み合わせると、私たちの間違いがはっきりする。

data.loc[data.bid == data.price].loc[304, 'bidder'] = 'therealname'
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/pandas/core/indexing.py:517: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame.Try using .loc[row_indexer,col_indexer] = value insteadSee the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy self.obj[item] = s

この場合も連鎖代入を使っていたのだが、今回は2行に分かれていたというわけだ。これについて考える別の方法は、「このコードは1つのものを修正するか、それとも2つのものか?」という質問をすることである。今回の場合、答えは不明である。もしwinnersがコピーであればwinnersだけが影響を受けるが、winnersがビューであればwinnersとdataの両方が更新された値を表示することになる。この状況は、スクリプトソースコード内で非常に離れている行の間で発生する可能性があり、問題の原因を突き止めるのが非常に困難になるおそれがある。

ここでの警告が意図していることは、私たちが思い違いをするのを防ぐことである すなわち、元のDataFrameをコードは実際には変更しないのに変更すると思うことや、元のDataFrameではなくてコピーを変更しているのだと思ってしまうことである。pandasのGitHubリポジトリ上の古いissueをよく調べると、開発者自身がこの問題を説明しているのを読むことができる。

問題をどのように解決するかは、私たち自身がどうしたいかによって異なる。元のデータのコピーを使って作業したい場合は、pandasにコピーを作成するように強制すれば解決である。

winners = data.loc[data.bid == data.price].copy()
winners.loc[304, 'bidder'] = 'therealname'
print(data.loc[304, 'bidder']) # Original
print(winners.loc[304, 'bidder']) # Copy
nan
therealname

一方で、元のDataFrameを更新する必要がある場合は、元のDataFrameを使用せねばならない。不確定な動作によって他の変数をインスタンス化するのは避けるべきだ。以前のコードは次のようにすればよいだろう。

# Finding the winners
winner_mask = data.bid == data.price
# Taking a peek
data.loc[winner_mask].head()
# Doing analysis
mean_win_time = data.loc[winner_mask, 'bidtime'].mean()
... # 20 lines of code
mode_open_bid = data.loc[winner_mask, 'openbid'].mode()
# Updating the username
data.loc[304, 'bidder'] = 'therealname'

DataFrameのサブセットのサブセットを変更するような、より複雑な状況においては、連鎖インデックスを使用する代わりに、元のDataFrame上でlocを使って作成しているスライスを変更できる。たとえば、上で書いた新しい変数winner_maskを変更したり、winnersのサブセットを選択した新しい変数を作成したりできる。以下のように:

- auctionid bid bidtime bidder bidderrate openbid price bidtime_hours
225 8213387444 152.0 2.919757 uconnbabydoll1975 15 0.99 152.0 70.074168
328 8213935134 207.5 2.983542 toby2492 0 0.10 207.5 71.605008
416 8214430396 199.0 2.990463 volpendesta 4 9.99 199.0 71.771112
531 8215582227 152.5 2.999664 ultimatum_man 2 60.00 152.5 71.991936

この手法は、将来の開発コードのメンテナンスとスケーリングに対してより堅牢である。

歴史

読者の皆さんは疑問に思っているかもしれない。 このSettingWithCopy問題の全部が、簡単にすっかり回避できないのはどうしてだろうか? ここまで長々見てきたような分かりにくい状況を作り出すのをやめて、ビューならビューだけを、コピーならコピーだけを常に返すインデックスメソッドを明示的に規定してやればいいのではないか? この点を理解するためには、pandasの過去を調べる必要がある。

ビューを返すのかコピーを返すのかを決定するためにpandasが用いているロジックは、pandasの操作の根底にあるNumPyライブラリの使用に由来している。実のところ、ビューはNumPyを介してpandasの言語仕様に入った。実際、NumPyではビューが期待通りに返ってくるので便利である。NumPy配列はsingle-dtypedなので、pandasは最も適切なdtypeを使って、メモリ空間と処理に要する時間を最小にしようとする。その結果、単一のdtypeを含むDataFrameのスライスを、単一のNumPy配列のビューとして返すことができる。この方式のおかげで、操作を処理するのは非常に効率的になった。しかしながら、multi-dtypeスライスをNumPyに同じように効率よく格納することはできない。pandasは、汎用的に使えるインデックス機能を組み合わせて、NumPyコアを最も効果的に使用できるようにした。

結局のところ、pandasのインデックスの設計は便利で汎用的に使えるが、設計方法は根底にあるNumPy配列の機能と厳密には一致してるわけではない。 これらの設計要素と機能要素が長い時間をかけて相互に作用した。その結果出来上がったのが、ビューとコピーのどちらを返せばよいかを決定する一連の複雑な規則である。熟練したpandasエンジニアは、pandasの動作には通常満足している。インデックス操作の挙動を自分の思い通りにするのに苦労しないからである。

get操作はインデックス可能なpandasオブジェクトを返すが、だからといって連鎖インデックスは意図したとおりの方法ではない。しかし、ライブラリを初めて使用する人にとっては残念なことに、連鎖インデックスを使ってしまうのはほとんど避けられない。さらに、ここ数年のpandasのコアデベロッパーの一人であるJeff Rebackが言うには、「言語の観点から連鎖インデックスを直接検出することは不可能である。それを推測せねばならない」。

その結果、この警告は2013年の終わりごろにバージョン0.13.0で導入された。多くの開発者が遭遇した、「連鎖代入が警告やエラーもないのに失敗する事象」に対する解決策として導入された。

バージョン0.12より前では、 ixインデクサが最も普及していた(pandasの命名法では、ix, loc, ilocのような「インデクサ」は、その後に角括弧をつけるとオブジェクトをインデックスできる構造にすぎない。 これは配列と同様であるが、しかし動作は特別なものである)。 しかし、2013年半ばのこの頃には、pandasプロジェクトは勢力を伸ばしはじめて、初心者ユーザー向けにすることがますます重要になっていた。このリリース以降、locおよびilocインデクサは、その性質が明示的であり使用方法が解釈しやすいことから、結果的に好まれている。(訳注:ixインデクサは非推奨(deprecated)となった

SettingWithCopyWarningは、導入後も進化を続け、数年間にわたりGitHubの多くのissueで盛んに議論され、さらに更新されている。しかしこの警告はpandasに定着しているため、pandasの専門家になるためには理解することが依然として必要不可欠である。

まとめ

SettingWithCopyWarningの根底にある複雑さは、pandasライブラリの数少ない小さな欠点の1つである。その起源はライブラリに非常に深く組み込まれており、無視してはならない。 Jeff Reback自身の言葉では、「私の知る限り、実際にはこの警告を無視すべきであるという場合はありません。……特定の種類のインデックス操作をして上手くいかないこともあるし、他の種類のインデックス操作で上手くいくこともあるでしょう。危険極まりない行為です。」

幸運なことに、警告に対処するには、連鎖代入を特定して修正するだけで済む。この記事全体の中で、このことだけは覚えていってほしい。


これにて翻訳完了である。

2回目の記事を書き上げた後、3回目の翻訳を仕上げることを半ば忘れていた。ところが、2週間ほど前にこのシリーズに対して言及があったのである。

DataFrameの値を書き換えるときにSettingWithCopyWarningが出る - 日々精進

驚いた。なにせpandasの特定の警告に関する記事なので、いささかマニアックというか、それほど需要は多くないはずだ。この記事に対するリアクションを見たのは初めてであった。
感謝の言葉に気を良くした俺は翻訳を再開したというわけだ(チョロい)。

英語の記事(ドキュメント)の翻訳は楽しいし勉強になるが、なかなか大変なのも事実だ。今後はまたいい記事があったら翻訳していこう。
それでは。