PDEP-7: pandasにおけるCopy-on-Writeによる一貫したコピー/ビューセマンティクス

概要

提案の概要

  1. インデックス操作(DataFrameまたはSeriesをあらゆる方法でサブセット化する操作、つまりDataFrameの列をSeriesとしてアクセスする操作を含む)または新しいDataFrameまたはSeriesを返すメソッドの結果は、ユーザーAPIの観点から、常にコピーであるかのように振る舞います。
  2. Copy-on-Write(実装の詳細として)を実装します。これにより、ユーザーAPIがコピーとして振る舞うことを保証しながら、内部的には可能な限りビューを使用できます。
  3. 結果として、オブジェクト(DataFrameまたはSeries)を変更したい場合、唯一の方法は、そのオブジェクト自体を直接変更することです。

これは複数の側面に対応します。1) 明確で一貫したユーザーAPI(明確なルール: *いかなる*サブセットまたは返されたSeries/DataFrameも、**常に**元のコピーとして振る舞い、したがって元のものを変更することはない)、2) 過剰なコピーを回避することによるパフォーマンスの向上(例えば、メソッドチェーンのワークフローでは、各ステップで実際のデータコピーを返さなくなります)。

すべてのインデックスステップがコピーとして振る舞うため、この提案では「連鎖代入」(複数のsetitemステップを含む)は*決して*機能せず、`SettingWithCopyWarning`は削除できます。

背景

pandasの現在の動作は、インデックスがビューを返すかコピーを返すかについて混乱を招きます。経験豊富なユーザーでさえ、ビューが返されるのかコピーが返されるのかを判断するのは困難です(概要は以下を参照)。ビューとコピーの返却に関して一貫性があり、合理的なAPIを提供したいと考えています。

私たちはパフォーマンスも重視しています。インデックス操作からビューを返すことは高速であり、メモリ使用量を削減します。これは、インデックスの設定/リセット、列名の変更など、データを変更しないいくつかのメソッドについても同様で、これらはメソッドチェーンのワークフローで使用でき、現在は各ステップで新しいコピーを返しています。

最後に、ビューに関するAPI/使いやすさの問題があります。DataFrameのサブセット(列や行の選択)を変更する操作において、ユーザーの意図を知ることは難しい場合があります。

>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1

ユーザーは`df2`を変更したときに`df`を変更するつもりだったのでしょうか(現在の実装における問題はさておき)?言い換えれば、列のインデックス作成が常にビューを返すか、常にコピーを返すかという完全に一貫した世界があった場合、上記のコードはユーザーが`df`を変更したいことを意味するのでしょうか?

ユーザーが意図する可能性のある動作は2つあります。

  1. ケース1: サブセットが元のビューである可能性があり、元のものも変更したい。
  2. ケース2: 元のものを変更せずに、サブセットだけを変更したい。

現在、pandasの不整合は、これらのワークフローの*どちらも*実際に可能ではないことを意味します。最初のものは困難です。なぜなら、インデックス操作はしばしば(常にではありませんが)コピーを返し、ビューが返される場合でも、ミューテーション時に`SettingWithCopyWarning`が発生することがあるからです。2番目のものはある程度可能ですが、多くの防御的コピー(`SettingWithCopyWarning`を回避するため、またはビューが返された場合にコピーがあることを保証するため)が必要です。

提案

これらの理由(一貫性、パフォーマンス、コードの明確さ)から、このPDEPは以下の変更を提案します。

  1. インデックス操作(DataFrameまたはSeriesをあらゆる方法でサブセット化する操作、つまりDataFrameの列をSeriesとしてアクセスする操作を含む)または新しいDataFrameまたはSeriesを返すメソッドの結果は、ユーザーAPIの観点から、常にコピーであるかのように振る舞います。
  2. Copy-on-Writeを実装します。これにより、ユーザーAPIがコピーとして動作することを保証しながら、内部的には可能な限りビューを使用できます。

目的は、可能な限りビューのパフォーマンス上の利点を取り込みながら、ユーザーに一貫性のある明確な動作を提供することです。これにより、ビューの返却は本質的に内部的な最適化となり、ユーザーは特定のインデックス操作がビューを返すのかコピーを返すのかを知る必要がなくなります。新しいルールはシンプルです。インデックス操作またはメソッドを介して、別のSeries/DataFrameから派生したSeries/DataFrameは、常に元のSeries/DataFrameのコピーとして動作します。

この一貫した動作を保証するメカニズムであるCopy-on-Writeには、以下のことが含まれます。setitem操作(つまり、`df[..] = ..`または`df.loc[..] = ..`または`df.iloc[..] = ..`、またはSeriesの同等物)は、変更されているデータが別のDataFrameのビューであるか(または別のDataFrameから表示されているか)をチェックします。もしそうであれば、ミューテーションの前にデータをコピーします。

上記の例を挙げると、ユーザーが親を変更したくない場合、`SettingWithCopyWarning`を避けるためだけの防御的なコピーはもはや必要ありません。

# Case 2: The user does not want mutating df2 to mutate the parent df, via CoW
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1
>>> df.iloc[1, 0]  # df was not mutated
2

一方、ユーザーが実際に元のdfを変更したい場合、`df2`がビューである可能性に頼ることはできません。なぜなら、サブセットの変更は親を変更しなくなるからです。元のdfを変更する唯一の方法は、元のdfに対してすべてのインデックスステップを1つのインデックス操作に結合することです(「連鎖的な」setitemはなし)。

# Case 1: user wants mutations of df2 to be reflected in df -> no longer possible
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1  # mutating df2 will not mutate df
>>> df.loc[df["A"] > 1, "A"] = 1  # need to directly mutate df instead

この提案はメソッドにも適用されます。

原則として、防御的なコピーに関しては、インデックス作成に特別なことは何もありません。既存のデータを変更せずに新しいシリーズ/データフレームを返す*あらゆる*メソッド(rename、set_index、assign、列の削除など)は、現在デフォルトでコピーを返し、ビューを返す候補となります。

>>> df2 = df.rename(columns=str.lower)
>>> df3 = df2.set_index("a")

現在、一般的にpandasユーザーは、`df2`や`df3`が`df`を変更するようなビューであるとは期待していません。Copy-on-Writeにより、上記のようなメソッド(または`df.rename(columns=str.lower).set_index("a")`のようなメソッドチェーンを使用するバリアント)でも不要なコピーを回避できます。

ミューテーションの前方伝播

これまで、サブセットを取り、サブセットを変更し、それが親にどのように影響するかという(より一般的な)ケースを検討してきました。親が変更された場合はどうでしょうか?

>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df[["A"]]
>>> df.iloc[0, 0] = 10
>>> df2.iloc[0, 0]  # what is this value?

この提案では`df2`がdfのコピーと*みなされる*(つまり、コピーとして振る舞う)ため、親`df`を変更してもサブセット`df2`は変更されません。

ミューテーションはいつ他のオブジェクトに伝播し、いつ伝播しないのか?

この提案は基本的に、ミューテーションが*決して*他のオブジェクトに伝播しない(ビューの場合に起こるような)ことを意味します。DataFrameまたはSeriesを変更する唯一の方法は、オブジェクト自体を直接変更することです。

しかし、これをPythonの言葉で説明しましょう。DataFrame `df1`があり、それを別の名前`df2`に代入するとします。

>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df1

これで2つの変数(`df1`と`df2`)がありますが、この代入は標準的なPythonのセマンティクスに従い、両方の名前は同じオブジェクトを指しています(「df1とdf2は*同一*である」)。

>>> id(df1) == id(df2)  # or: df1 is df2
True

したがって、DataFrame `df2`を変更すると、それは別の変数`df1`にも反映され、その逆も同様です(同じオブジェクトであるため)。

>>> df1.iloc[0, 0]
1
>>> df2.iloc[0, 0] = 10
>>> df1.iloc[0, 0]
10

要約すると、変更は*同一の*オブジェクト間でのみ「伝播」します(Pythonの用語では、等しい(`==`)だけでなく、同一である(`is`)オブジェクト間、ドキュメントを参照)。変更されたオブジェクトは1つしかないため、「伝播」は適切な用語ではありません。

しかし、何らかの方法で新しいオブジェクトを作成する場合(たとえ同じデータを持つDataFrameであり、「等しい」DataFrameであるとしても)。

>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df1[:]  # or df1.loc[...] with some indexer

これらのオブジェクトはもはや同一ではありません。

>>> id(df1) == id(df2)  # or df1 is df2
False

したがって、一方への変更が他方に伝播することはありません。

>>> df1.iloc[0, 0]
1
>>> df2.iloc[0, 0] = 10
>>> df1.iloc[0, 0]  # not changed
1

現在、すべてのgetitemインデックス操作は*新しい*オブジェクトを返し、またほとんどすべてのDataFrame/Seriesメソッドは*新しい*オブジェクトを返します(一部のケースでは`inplace=True`の場合を除く)。したがって、親/子のDataFrameまたはSeriesを一切変更しないという上記のロジックに従います(可能な場合は遅延Copy-on-Writeメカニズムを使用します)。

NumPyとpandasのコピー/ビューの動作

NumPyには「ビュー」の概念があります(別の配列とデータを共有し、同じメモリを見る配列。詳細については、例えばこの説明を参照)。通常、ビューは別の配列のスライスとして作成します。しかし、リストのインデックスやブールマスクを使用するような、しばしば「ファンシーインデックス」と呼ばれる他のインデックスメソッドは、ビューではなくコピーを返します。

NumPy上に構築されているPandasは、これらの概念を使用し、その動作結果をユーザーに公開しています。これは基本的に、Pandasユーザーがインデックスの動作の詳細を理解するために、NumPyのビュー/ファンシーインデックスの概念も理解する必要があることを意味します。

ただし、DataFrameは配列ではないため、コピー/ビューのルールは現在のpandasのNumPyのルールとは依然として異なります。行のスライスは通常ビューを提供しますが(NumPyに準拠)、列のスライスは常にビューを提供するわけではありません(これはNumPyに合わせるように変更できる可能性がありますが、「代替案」1bを参照してください)。ファンシーインデックスの行(例:(位置)ラベルのリストを使用)はコピーを提供しますが、ファンシーインデックスの列はビューを提供する*可能性*があります(現在はこれもコピーを提供しますが、「代替案」の1つ(1b)では常にビューを返すようにします)。

このドキュメントでの提案は、pandasのユーザー向け動作をNumPyの概念から切り離すことです。スライスまたはマスクを使用してDataFrameのサブセットを作成する場合、ユーザーにとっては同様の方法で動作します(どちらも新しいオブジェクトを返し、元のコピーとして動作します)。pandasの内部では、実装を最適化するためにビューの概念を引き続き使用しますが、これはユーザーからは隠されます。

代替案

元のドキュメントとGitHubイシュー(インデックス操作における将来のコピー/ビューセマンティクスに関する提案 - #36195)では、コピー/ビューの状況をより一貫性があり明確にするためのいくつかのオプションが議論されました。

  1. 明確に定義されたコピー/ビューのルール: どの操作がコピーになり、どの操作がビューになるかについて、より一貫性のあるルールを確保し、ビューは親を変更し、コピーは変更しないようにします。a. 最小限の変更は、現在の動作を正式化することです。これは、いくつかのバグを修正し、どの操作がビューで、どの操作がコピーであるかを明確に文書化し、テストすることに帰着します。b. 代替案は、ルールセットを簡素化することです。例えば、列の選択は常にビューであり、行のサブセット化は常にコピーである、または、列の選択は常にビューであり、行のサブセット化はスライスであればビューであり、そうでなければ常にコピーである、などです。

  2. Copy-on-Write: setitem操作は、それが別のDataFrameのビューであるかどうかをチェックします。もしそうであれば、変更する前にデータをコピーします。(つまり、この提案)

  3. Error-on-Write: setitem操作は、それが別のデータフレームのサブセットであるかどうか(ビューとコピーの両方)をチェックします。ビューの場合にコピーするのではなく、例外を発生させ、ユーザーに`.copy_if_needed()`(名前は未定)でデータをコピーするか、`.as_mutable_view()`(名前は未定)でフレームを「変更可能なビュー」としてマークするように指示します。

このドキュメントは基本的にオプション2(Copy-on-Write)の拡張バージョンを提案しています。他のオプションと比較したCopy-on-Writeの利点に関するいくつかの議論は以下の通りです。

上記の他の「明確に定義されたルール」のアイデアは、常に特定のケース(およびNumPyルールからの逸脱)を含みます。そして、明確なルールがあったとしても、ユーザーは`df['a'][df['b'] < 0] = 0`と`df[df['b'] < 0]['a'] = 0`が異なることを理解するために、これらのルールの詳細を知る必要があります(列/行のインデックスの順序が入れ替わっています。前者はdfを変更し(列の選択がビューである場合)、後者は変更しません)。一方、Copy-on-Writeによる「常にコピー」のルールでは、これらの例のどちらも`df`を更新するために機能しません。

一方、このドキュメントの提案では、サブセットがビューであるべきかどうか(可能な場合)、そしてそれが変更されたときに親を変更するかどうかをユーザーが制御することはできません。親のDataFrameを変更する唯一の方法は、そのDataFrame自体に対して直接インデックス操作を行うことです。

より詳細な議論については、GitHubのコメントを参照してください: https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449

デメリット

この提案が後方互換性のない、既存の動作を変更する破壊的な変更となるという事実(次のセクションを参照)以外にも、いくつかの潜在的なデメリットがあります。

後方互換性

このドキュメントの提案は、既存の動作を壊す後方互換性のない変更であることは明らかです。ビューとコピー、およびミューテーションに関する現在の不整合と繊細さのため、破壊的な変更なしに何かを変更することは困難でしょう。ただし、現在の提案は最小限の変更を加える提案ではありません。このような変更は、いずれにせよメジャーバージョンアップ(例えばpandas 3.0)を伴う必要があります。

いくつかのマイナーフィーチャーリリースで伝統的な非推奨サイクルを行うと、あまりにもノイズが多くなります。インデックスは、警告を含めるにはあまりにも一般的な操作です(たとえ以前にビューを返していた操作に限定したとしても)。ただし、この提案はすでに実装されており、利用可能です。ユーザーは、`pd.options.mode.copy_on_write = True`を設定して(バージョン1.5から可能)、オプトインしてコードをテストできます。

さらに、pandas 2.2 では、Copy-on-Write 提案のもとで動作が変更されるすべてのケースで警告を発生させる警告モードを追加します。まず警告を有効にし、すべての警告を修正し、次に Copy-on-Write モードを有効にしてコードがまだ動作することを確認し、最終的に新しいメジャーリリースにアップグレードするという、明確に文書化されたアップグレードパスを提供できます。

実装

この実装はpandas 1.5以降で利用可能で(pandas 2.0以降で大幅に改善されています)、弱参照を使用して、DataFrame/Seriesのデータが別の(pandas)オブジェクトのデータを参照しているか、別のオブジェクトから参照されているかを追跡します。これにより、Series/DataFrameが変更されるたびに、データを変更する前にコピーする必要があるかどうかを確認できます(こちらを参照)。

実装をテストし、新しい動作を試すには、以下のオプションで有効にできます。

>>> pd.options.mode.copy_on_write = True

pandasをインポートした後(またはpandasをインポートする前に環境変数`PANDAS_COPY_ON_WRITE=1`を設定して)。

具体的な例

連鎖代入

連鎖インデックスの「古典的な」ケースを考えてみましょう。これは、SettingWithCopy警告のそもそもの動機でした。

>>> df[df['B'] > 3]['B'] = 10

それはおおよそ以下と等価です。

>>> df2 = df[df['B'] > 3]  # Copy under NumPy's rules
>>> df2['B'] = 10  # Update (the copy) df2, df not changed
>>> del df2  # All references to df2 are lost, goes out of scope

そのため、`df`は変更されません。この理由から、SettingWithCopyWarningが導入されました。

この提案では、インデックス操作の結果はすべてコピーとして振る舞う(Copy-on-Write)ため、連鎖代入は*決して*機能しません。曖昧さがなくなるため、警告は削除されるという考えです。

上記の例は、現在のpandasでは連鎖代入が機能しないケースです。しかし、もちろん、連鎖代入で現在*機能する*パターンもあり、使われています。この提案では、どのような連鎖代入も機能しなくなるため、これらのケースは機能しなくなります(例:上記のケースで順序を入れ替えるなど)。

>>> df['B'][df['B'] > 3] = 10
# or
>>> df['B'][0:5] = 10

これらのケースは、ユーザーの意図したことを決して達成できないため、`ChainedAssignmentError`という警告を発生させます。Cythonからこれらの操作がトリガーされた場合、Cythonは異なる参照カウントメカニズムを使用するため、誤検知が発生することがあります。Cythonからpandasコードを呼び出してもパフォーマンス上の利点はないため、これらのケースはまれであるはずです。

フィルタリングされたデータフレーム

現在のSettingWithCopyWarningが煩わしくなる典型的な例は、DataFrameをフィルタリングする場合です(これは常にすでにコピーを返します)。

>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df_filtered = df[df["A"] > 1]
>>> df_filtered["new_column"] = 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 instead

フィルタリングされたデータフレームを変更すると(例:列を追加する)、不要なSettingWithCopyWarningが表示されます(メッセージは紛らわしい)。この警告をなくす唯一の方法は、防御的なコピーを行うことです(`df_filtered = df[df["A"] > 1].copy()`。これにより、現在の実装ではデータが2回コピーされますが、Copy-on-Writeでは`.copy()`は不要になります)。

この提案では、フィルタリングされたDataFrameは決してビューではなく、上記のワークフローは警告なしに(したがって、余分なコピーなしに)期待通りに機能します。

Seriesの変更(DataFrameの列から)

現在、DataFrameの列にSeriesとしてアクセスすることは、実際に常にビューであることが保証されている数少ないケースの1つです。

>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> s = df["A"]
>>> s.loc[0] = 0   # will also modify df (but no longer with this proposal)

この提案では、すべてのインデックス操作はコピーを返します。したがって、列へのSeriesとしてのアクセスもコピーを返します(実際には、もちろんビューのままですが、Copy-on-Writeによってコピーとして振る舞います)。上記の例では、`s`を変更しても、親の`df`は変更されなくなります。

この状況は、上記の「連鎖代入」のケースと似ていますが、明示的な中間変数がある点が異なります。元のDataFrameを実際に変更するには、解決策は同じです。DataFrameを単一ステップで直接変更します。例えば、

>>> df.loc[0, "A"] = 0

「シャロー」コピー

現在、`copy(deep=False)`を使用してDataFrameの「シャロー」コピーを作成できます。これは新しいDataFrameオブジェクトを作成しますが、基になるインデックスとデータはコピーされません。元のデータの変更はシャローコピーに反映され(その逆も同様)、ドキュメントを参照してください。

>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df.copy(deep=False)
>>> df2.iloc[0, 0] = 0   # will also modify df (but no longer with this proposal)

この提案では、このようなシャローコピーはもはや不可能です。Copy-on-Writeをトリガーせずにデータを共有できるのは、「同一の」オブジェクト(Pythonの用語では:`df2 is df`)のみです。シャローコピーは、むしろCopy-on-Writeを介した「遅延」コピーになります。

これに関するより詳細なコメントは、#36195 (comment)を参照してください。

同じデータを持ち、新しいDataFrameを返すメソッド

この例はすでに上記でも示されていますが、現在、Series/DataFrame上のほとんどすべてのメソッドは、デフォルトで新しいオブジェクトを返し、それは元のデータのコピーです。

>>> df2 = df.rename(columns=str.lower)
>>> df3 = df2.set_index("a")

上記の例では、df2はdfのデータのコピーを保持し、df3はdf2のデータのコピーを保持しています。これらのDataFrameのいずれかを変更しても、親のDataFrameは変更されません。

この提案では、これらのメソッドは引き続き新しいオブジェクトを返しますが、Copy-on-Writeによるシャローコピーメカニズムを使用するため、実際には、これらのメソッドは各ステップでデータをコピーする必要がなく、現在の動作を維持できます。

SeriesとDataFrameのコンストラクタ

現在、SeriesとDataFrameのコンストラクタは、入力タイプによって常にコピーするとは限りません。例えば、

>>> s = pd.Series([1, 2, 3])
>>> s2 = pd.Series(s)
>>> s2.iloc[0] = 0   # will also modify the parent Series s
>>> s
0   0  # <-- modified
1   2
2   3
dtype: int64

この提案では、コンストラクタで*デフォルトで*Copy-on-Writeアプローチによるシャローコピーも使用できます。これは、デフォルトで新しいSeriesまたはDataFrame(上記の例の`s2`のようなもの)は、構築元のデータを(それ自体が変更されたときに)変更しないことを意味し、提案されたルールを尊重します。

より詳細な背景: 現在のビューとコピーの動作

私たちの知る限りでは、インデックス操作は現在、以下のケースでビューを返します。

残りの操作(リストインデクサまたはブールマスクで行をサブセット化)は実際にはコピーを返し、ユーザーがサブセットを変更しようとするとSettingWithCopyWarningが発生します。

より詳細な背景: 以前の試み

この一般的な問題については以前にも議論しました。https://github.com/pandas-dev/pandas/issues/10954およびいくつかのプルリクエスト(https://github.com/pandas-dev/pandas/pull/12036, https://github.com/pandas-dev/pandas/pull/11207, https://github.com/pandas-dev/pandas/pull/11500)。

他の言語/ライブラリとの比較

R

ユーザーにとって、Rは多少似たような動作をします。ほとんどのRオブジェクトは、「コピーオンモディファイ」を介してイミュータブルとみなすことができます(https://adv-r.hadley.nz/names-values.html#copy-on-modify)。しかしPythonとは対照的に、Rではこれは言語機能であり、任意の代入(変数を新しい名前にバインドする)や関数引数としての受け渡しは、本質的に「コピー」を作成します(そのようなオブジェクトを変更する場合、その時点で実際のデータがコピーされ、名前に再バインドされます)。

x <- c(1, 2, 3)
y <- x
y[[1]] <- 10  # does not modify x

しかし、上記の例をPythonでリストを使って行うと、xとyは「同一」であり、一方を変更すると他方も変更されます。

この言語の動作の結果として、`data.frame`を変更しても、メモリを共有する可能性のある他のdata.frameは変更されません(「copy-on-modify」でコピーされる前は)。

Polars

Polars (https://github.com/pola-rs/polars) は、主にRustでArrow上に書かれたPythonインターフェースを持つDataFrameライブラリです。明示的に「Copy-on-Write」セマンティクスをその機能の1つとして言及しています。

いくつかの実験に基づくと、Polarsのユーザー向け動作は、この提案で説明されている動作と類似しているようです(DataFrame/Seriesを変更しても、親/子のオブジェクトが変更されることはなく、したがって連鎖代入も機能しません)。

PDEP-7の履歴

注: この提案はPDEPになる前に議論されていました。主な議論はGH-36195で行われました。このドキュメントは、Tom Augspurgerによって開始された、明確なコピー/ビューセマンティクスに関する異なるオプションを議論した元のドキュメント(Googleドキュメント)を修正したものです。

関連するメーリングリストの議論: https://mail.python.org/pipermail/pandas-dev/2021-July/001358.html