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

概要

提案の要約

  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の非一貫性のため、これらのワークフローはどちらも実際には不可能です。1つ目は、インデックス操作が(常にではありませんが)コピーを返すことが多く、ビューが返された場合でも、変更時に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を変更する唯一の方法は、元のデータに対する単一のインデックス操作ですべてのインデックスステップを組み合わせることです(「連鎖」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

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

原則として、防御的なコピーに関しては、インデックス作成に特別なことは何もありません。既存のデータを変更せずに新しいSeries/DataFrameを返すあらゆるメソッド(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つの変数(df1df2)がありますが、この代入は標準の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を参照)。行のファンシーインデックス作成(例:(位置)ラベルのリストを使用)はコピーを提供しますが、列のファンシーインデックス作成はビューを提供する可能性があります(現在、これもコピーを提供しますが、「代替案」(1b)の1つは、これが常にビューを返すようにすることです)。

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

代替案

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

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

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

  3. Error-on-Write:setitem操作は、それが別のDataFrameのサブセットであるかどうかを確認します(ビューとコピーの両方)。ビューの場合にコピーするのではなく、.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を変更し(列の選択がビューの場合)、2番目の例は変更しません)。一方、Copy-on-Write を使用した「常にコピー」ルールでは、これらの例のいずれも `df` を更新できません。

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

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

デメリット

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

後方互換性

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

複数のマイナー機能リリースに存在する従来の非推奨サイクルを実行すると、ノイズが多すぎます。インデックス作成は、警告を含めるにはあまりにも一般的な操作です(以前にビューを返した操作のみに制限した場合でも)。ただし、この提案はすでに実装されており、利用可能です。ユーザーはオプトインしてコードをテストできます(これは、 `pd.options.mode.copy_on_write = True` を使用したバージョン1.5以降で可能です)。

さらに、Copy-on-Writeの提案で動作が変更されるすべてのケースについて警告を出す、pandas 2.2の警告モードを追加します。最初に警告を有効にし、すべての警告を修正し、Copy-on-Writeモードを有効にしてコードがまだ機能していることを確認し、最後に新しいメジャーリリースにアップグレードするための、明確に文書化されたアップグレードパスを提供できます。

実装

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

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

>>> 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が煩わしくなる典型的な例は、データフレームをフィルタリングする場合です(これは常にコピーを返します)。

>>> 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()` はもう必要ありません)。

*この提案では*、フィルターされたデータフレームはビューではなくなり、上記のワークフローは警告なしで(したがって、追加のコピーを必要とせずに)期待どおりに機能します。

シリーズ(データフレーム列から)の変更

*現在*、データフレームの列にシリーズとしてアクセスすることは、実際に常にビューであることが保証されている数少ないケースの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)

*この提案では*、インデックス操作はすべてコピーになります。そのため、列にシリーズとしてアクセスする場合も同様です(実際には、もちろんビューのままですが、Copy-on-Writeを介してコピーとして動作します)。上記の例では、 `s` を変更しても、親の `df` は変更されなくなります。

この状況は、明示的な中間変数を使用することを除いて、上記の「連鎖代入」ケースと似ています。元のデータフレームを実際に変更するには、解決策は同じです。データフレームを1つのステップで直接変更します。例えば

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

「浅い」コピー

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

>>> 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)を参照してください。

同じデータを持つ新しいデータフレームを返すメソッド

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

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

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

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

シリーズとデータフレームのコンストラクタ

*現在*、シリーズとデータフレームのコンストラクタは、必ずしも入力をコピーするとは限りません(入力のタイプによって異なります)。例えば

>>> 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アプローチで浅いコピーを使用することもできます。これは、デフォルトでは、新しいシリーズまたはデータフレーム(上記の例では `s2` など)が、構築されているデータ(それ自体が変更されている場合)を変更せず、提案されたルールを尊重することを意味します。

詳細:ビューとコピーの現在の動作

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

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

詳細:以前の試み

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

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

R

ユーザーにとって、Rはいくぶん似たような動作をします。ほとんどのRオブジェクトは、「copy-on-modify」(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 は変更されません(「コピーオンライト」でコピーされる前)。

Polars

Polars(https://github.com/pola-rs/polars)は、Pythonインターフェースを持つDataFrameライブラリであり、主にArrow上にRustで記述されています。これは、その機能の一つとして「コピーオンライト」セマンティクスを明示的に言及しています。

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

PDEP-7 履歴

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

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