寫入時複製 (CoW)#

注意

寫入時複製將成為 pandas 3.0 的預設值。我們建議 現在就啟用,以受益於所有改進。

寫入時複製最初於 1.5.0 版中引入。從 2.0 版開始,透過 CoW 而實現的大部分最佳化都已實作並獲得支援。從 pandas 2.1 開始,支援所有可能的最佳化。

CoW 將在 3.0 版中預設啟用。

CoW 將帶來更可預測的行為,因為不可能使用一個陳述式更新多個物件,例如索引運算或方法不會有副作用。此外,透過盡可能延後複製,平均效能和記憶體使用量將會提升。

先前的行為#

pandas 索引行為難以理解。有些運算會傳回檢視,而其他會傳回複製。根據運算結果,變異一個物件可能會意外變異另一個物件

In [1]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [2]: subset = df["foo"]

In [3]: subset.iloc[0] = 100

In [4]: df
Out[4]: 
   foo  bar
0  100    4
1    2    5
2    3    6

變異 子集,例如更新其值,也會更新 df。確切的行為難以預測。寫入時複製解決了意外修改多個物件的問題,它明確禁止這種情況。啟用 CoW 後,df 保持不變

In [5]: pd.options.mode.copy_on_write = True

In [6]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [7]: subset = df["foo"]

In [8]: subset.iloc[0] = 100

In [9]: df
Out[9]: 
   foo  bar
0    1    4
1    2    5
2    3    6

以下各節將說明這表示什麼,以及它如何影響現有應用程式。

移轉至寫入時複製#

寫入時複製將是 pandas 3.0 中的預設值和唯一模式。這表示使用者需要將其程式碼移轉為符合 CoW 規則。

pandas 中的預設模式會針對某些案例提出警告,這些案例將主動變更行為,進而變更使用者的預期行為。

我們新增了另一個模式,例如

pd.options.mode.copy_on_write = "warn"

這會對任何會因為 CoW 而改變行為的運算發出警告。我們預期這個模式會非常吵雜,因為許多我們不預期會影響使用者的案例也會發出警告。我們建議檢查這個模式並分析警告,但不需要處理所有這些警告。下列清單的前兩項是讓現有程式碼與 CoW 搭配運作時唯一需要處理的案例。

以下幾項說明使用者可見的變更

鍊式指定永遠不會運作

loc 應作為替代方案使用。查看鍊式指定區段以取得更多詳細資訊。

存取 pandas 物件的底層陣列會傳回唯讀檢視

In [10]: ser = pd.Series([1, 2, 3])

In [11]: ser.to_numpy()
Out[11]: array([1, 2, 3])

這個範例傳回一個 NumPy 陣列,該陣列是 Series 物件的檢視。這個檢視可以修改,因此也可以修改 pandas 物件。這不符合 CoW 規則。傳回的陣列設定為不可寫入,以防止這種行為。建立這個陣列的副本允許修改。如果您不再關心 pandas 物件,也可以再次讓陣列可寫入。

查看有關唯讀 NumPy 陣列的區段以取得更多詳細資訊。

一次只更新一個 pandas 物件

下列程式碼片段在沒有 CoW 的情況下更新 dfsubset

In [12]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [13]: subset = df["foo"]

In [14]: subset.iloc[0] = 100

In [15]: df
Out[15]: 
   foo  bar
0    1    4
1    2    5
2    3    6

這在 CoW 中不再可行,因為 CoW 規則明確禁止這一點。這包括將單一欄位更新為 Series,並依賴變更傳播回父 DataFrame。如果需要這種行為,可以用 lociloc 將此陳述重寫成單一陳述。 DataFrame.where() 是此案例的另一種合適替代方案。

使用內部方法更新從 DataFrame 選取的欄位也不再可行。

In [16]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [17]: df["foo"].replace(1, 5, inplace=True)

In [18]: df
Out[18]: 
   foo  bar
0    1    4
1    2    5
2    3    6

這是鏈結指定另一種形式。這通常可以改寫成 2 種不同形式

In [19]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [20]: df.replace({"foo": {1: 5}}, inplace=True)

In [21]: df
Out[21]: 
   foo  bar
0    5    4
1    2    5
2    3    6

另一種替代方案是不使用 inplace

In [22]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [23]: df["foo"] = df["foo"].replace(1, 5)

In [24]: df
Out[24]: 
   foo  bar
0    5    4
1    2    5
2    3    6

建構函式現在預設複製 NumPy 陣列

除非另有指定,否則 Series 和 DataFrame 建構函式現在會預設複製 NumPy 陣列。這項變更的目的是避免在 NumPy 陣列在 pandas 外部內部變更時,變更 pandas 物件。您可以設定 copy=False 來避免這種複製。

說明#

CoW 表示任何以任何方式從其他 DataFrame 或 Series 衍生的 DataFrame 或 Series 始終表現為一份拷貝。因此,我們只能透過修改物件本身來變更物件的值。CoW 不允許更新與其他 DataFrame 或 Series 物件共用資料的 DataFrame 或 Series。

這在修改值時避免了副作用,因此,大多數方法可以避免實際拷貝資料,並僅在必要時觸發拷貝。

下列範例將透過 CoW 就地運作

In [25]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [26]: df.iloc[0, 0] = 100

In [27]: df
Out[27]: 
   foo  bar
0  100    4
1    2    5
2    3    6

物件 df 與任何其他物件不共用任何資料,因此在更新值時不會觸發拷貝。相反地,下列運算會觸發 CoW 下的資料拷貝

In [28]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [29]: df2 = df.reset_index(drop=True)

In [30]: df2.iloc[0, 0] = 100

In [31]: df
Out[31]: 
   foo  bar
0    1    4
1    2    5
2    3    6

In [32]: df2
Out[32]: 
   foo  bar
0  100    4
1    2    5
2    3    6

reset_index 傳回一份 CoW 的延遲拷貝,而它會在沒有 CoW 的情況下拷貝資料。由於兩個物件 dfdf2 共用相同的資料,因此在修改 df2 時會觸發拷貝。物件 df 仍具有與最初相同的數值,而 df2 已被修改。

如果在執行 reset_index 運算後不再需要物件 df,您可以透過將 reset_index 的輸出指定給同一個變數,來模擬就地運算。

In [33]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [34]: df = df.reset_index(drop=True)

In [35]: df.iloc[0, 0] = 100

In [36]: df
Out[36]: 
   foo  bar
0  100    4
1    2    5
2    3    6

初始物件在重新指派 reset_index 的結果後會超出範圍,因此 df 沒有與任何其他物件共用資料。修改物件時不需要複製。這通常適用於 寫入時複製最佳化 中列出的所有方法。

先前在操作檢視時,檢視和父物件會被修改

In [37]: with pd.option_context("mode.copy_on_write", False):
   ....:     df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
   ....:     view = df[:]
   ....:     df.iloc[0, 0] = 100
   ....: 

In [38]: df
Out[38]: 
   foo  bar
0  100    4
1    2    5
2    3    6

In [39]: view
Out[39]: 
   foo  bar
0  100    4
1    2    5
2    3    6

寫入時複製會在 df 變更時觸發複製,以避免同時變異 view

In [40]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [41]: view = df[:]

In [42]: df.iloc[0, 0] = 100

In [43]: df
Out[43]: 
   foo  bar
0  100    4
1    2    5
2    3    6

In [44]: view
Out[44]: 
   foo  bar
0    1    4
1    2    5
2    3    6

鏈式指派#

鏈式指派是指透過兩個後續索引操作來更新物件的技術,例如

In [45]: with pd.option_context("mode.copy_on_write", False):
   ....:     df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
   ....:     df["foo"][df["bar"] > 5] = 100
   ....:     df
   ....: 

當欄位 bar 大於 5 時,欄位 foo 會被更新。這違反了寫入時複製原則,因為它必須在一個步驟中修改檢視 df["foo"]df。因此,在啟用寫入時複製時,鏈式指派將永遠無法運作,並會引發 ChainedAssignmentError 警告

In [46]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [47]: df["foo"][df["bar"] > 5] = 100

使用寫入時複製,這可以使用 loc 來完成。

In [48]: df.loc[df["bar"] > 5, "foo"] = 100

唯讀 NumPy 陣列#

存取 DataFrame 的底層 NumPy 陣列,如果陣列與初始的 DataFrame 共享資料,將會傳回唯讀陣列

如果初始的 DataFrame 包含多個陣列,則陣列為拷貝

In [49]: df = pd.DataFrame({"a": [1, 2], "b": [1.5, 2.5]})

In [50]: df.to_numpy()
Out[50]: 
array([[1. , 1.5],
       [2. , 2.5]])

如果 DataFrame 僅包含一個 NumPy 陣列,則陣列與 DataFrame 共享資料

In [51]: df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})

In [52]: df.to_numpy()
Out[52]: 
array([[1, 3],
       [2, 4]])

此陣列為唯讀,表示無法就地修改

In [53]: arr = df.to_numpy()

In [54]: arr[0, 0] = 100
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[54], line 1
----> 1 arr[0, 0] = 100

ValueError: assignment destination is read-only

Series 也是如此,因為 Series 總是包含單一陣列。

有兩種可能的解決方案

  • 如果您想避免更新與陣列共享記憶體的 DataFrame,請手動觸發拷貝。

  • 讓陣列可寫入。這是一個效能較佳的解決方案,但會規避寫入時拷貝規則,因此應謹慎使用。

In [55]: arr = df.to_numpy()

In [56]: arr.flags.writeable = True

In [57]: arr[0, 0] = 100

In [58]: arr
Out[58]: 
array([[100,   3],
       [  2,   4]])

應避免的模式#

如果您在就地修改一個物件時,兩個物件共享相同的資料,則不會執行防禦性拷貝。

In [59]: df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

In [60]: df2 = df.reset_index(drop=True)

In [61]: df2.iloc[0, 0] = 100

這會建立兩個共享資料的物件,因此 setitem 作業會觸發拷貝。如果初始物件 df 不再需要,則不需要這樣做。只要重新指派給同一個變數,就會使物件持有的參考失效。

In [62]: df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

In [63]: df = df.reset_index(drop=True)

In [64]: df.iloc[0, 0] = 100

此範例不需要拷貝。建立多個參考會讓不必要的參考保持作用,因此會損害寫入時拷貝的效能。

寫入時拷貝最佳化#

一種新的延遲複製機制,直到有問題的物件被修改,而且僅當此物件與其他物件共用資料時,才會延遲複製。此機制已新增至不需要複製基礎資料的方法。熱門範例包括 DataFrame.drop()axis=1DataFrame.rename()

當啟用寫入時複製時,這些方法會傳回檢視,與一般執行相比,這提供了顯著的效能改善。

如何啟用 CoW#

寫入時複製可透過設定選項 copy_on_write 來啟用。此選項可透過以下任一方式在 __全域__ 中開啟

In [65]: pd.set_option("mode.copy_on_write", True)

In [66]: pd.options.mode.copy_on_write = True