pandasのSettingWithCopyWarningの対処法
pandasで頻出の警告にSettingWithCopyWarning
がある。エラーではなく警告なので処理が止まることはないが、放置しておくと予期せぬ結果になってしまう場合がある。
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
loc[]
やiloc[]
、コピーやビューなどについての詳細は以下の記事を参照。
なお、あまりおすすめしないが、警告はPythonの標準ライブラリwarningsモジュールで非表示にすることもできる。
本記事のサンプルコードのpandasのバージョンは以下の通り。バージョンによって仕様が異なる可能性があるので注意。
import pandas as pd
print(pd.__version__)
# 2.0.3
chained indexing / assignment(連鎖インデクシング・代入)
問題の内容
以下の公式ドキュメントにあるように、SettingWithCopyWarning
の要因となるのはchained indexingおよびchained assignment。
[]
やloc[]
, iloc[]
などの[]
を含む処理を続けて行うことをchained indexingと呼ぶ。
df = pd.DataFrame({'a': [0, 1, 2], 'b': [3, 4, 5]}, index=['x', 'y', 'z'])
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
print(df.loc['x':'y']['a'])
# x 0
# y 1
# Name: a, dtype: int64
これに対して代入を行うことをchained assignmentと呼び、この際にSettingWithCopyWarning
が発生する場合がある。
df.loc['x':'y']['a'] = 100
# /var/folders/rf/b7l8_vgj5mdgvghn_326rn_c0000gn/T/ipykernel_40458/3771299631.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 instead
#
# See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
# df.loc['x':'y']['a'] = 100
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
A value is trying to be set on a copy of a slice from a DataFrame.
という文言の通り、DataFrame
のスライスのコピーに対して値を代入しようとしていますよ、という警告。
最初のインデクシング[]
がコピーを返す場合、コピーに対して二つ目のインデクシング[]
が適用されて値が代入されるので、元のDataFrame
の値は変わらない(代入されない)ということが起こってしまう。
なお、処理結果はpandasのバージョンによって異なる可能性があるので注意。バージョン0.25.1
では上記と同じコードでDataFrame
の値が更新されていた。
次の例のように、SettingWithCopyWarning
が発生しないのにDataFrame
の値が変わらない場合もある。
print(df.loc[['x', 'y']]['a'])
# x 0
# y 1
# Name: a, dtype: int64
df.loc[['x', 'y']]['a'] = 100
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
[]
やloc[]
, iloc[]
がコピーを返すかビューを返すかは判定できないので、SettingWithCopyWarning
が発生したからといって問題があるとは限らないし、SettingWithCopyWarning
が発生しなかったからといって問題がないとは限らない。
ロバストなコードのためには、DataFrame
やSeries
に値を代入する場合、chained indexingを避ける必要がある。
対処法: 連鎖させない
chained indexingを避けるには、警告メッセージにあるようにインデクシングを連鎖させずに1つにまとめればよい。
Try using .loc[row_indexer,col_indexer] = value instead
前項の2つの例は、以下のようにloc
を使って書ける。
df = pd.DataFrame({'a': [0, 1, 2], 'b': [3, 4, 5]}, index=['x', 'y', 'z'])
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
df.loc['x':'y', 'a'] = 100
print(df)
# a b
# x 100 3
# y 100 4
# z 2 5
df.loc[['x', 'y'], 'a'] = 0
print(df)
# a b
# x 0 3
# y 0 4
# z 2 5
単独のインデクシングであれば元のDataFrame
に値が代入されることが保証されている。処理速度もこちらのほうが速い。
行名・列名(行ラベル・列ラベル)と行番号・列番号を組み合わせて範囲を指定したい場合はインデクシングを繰り返したくなる。loc
は行名・列名、iloc
は行番号・列番号で指定する必要があるので、行番号と列ラベルのように混在して指定することができない。
df = pd.DataFrame({'a': [0, 1, 2], 'b': [3, 4, 5]}, index=['x', 'y', 'z'])
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
print(df.iloc[[0, 1]]['a'])
# x 0
# y 1
# Name: a, dtype: int64
# df.loc[[0, 1], 'a']
# KeyError: "None of [Index([0, 1], dtype='int64')] are in the [index]"
# df.iloc[[0, 1], 'a']
# ValueError: Location based indexing can only have [integer, integer slice (START point is INCLUDED, END point is EXCLUDED), listlike of integers, boolean array] types
このような場合はindex
属性およびcolumns
属性を使って、行番号・列番号から行名・列名を取得する。
print(df.index[0])
# x
print(df.index[1])
# y
print(df.columns[0])
# a
print(df.columns[1])
# b
コードは長くなるが、行名・列名に統一して指定できる。
print(df.loc[[df.index[0], df.index[1]], 'a'])
# x 0
# y 1
# Name: a, dtype: int64
スライスstart:stop:step
のstop
を指定する場合は注意が必要。行番号・列番号のiloc
ではstop
が結果に含まれないが、行名・列名のloc
ではstop
が結果に含まれる。
stop
の値に対してはindex
, columns
属性で行番号・列番号から行名・列名を取得する際に-1
する必要がある。
print(df.iloc[:2]['a'])
# x 0
# y 1
# Name: a, dtype: int64
print(df.loc[: df.index[2], 'a'])
# x 0
# y 1
# z 2
# Name: a, dtype: int64
print(df.loc[: df.index[2 - 1], 'a'])
# x 0
# y 1
# Name: a, dtype: int64
変数を介したchained indexing / assignment
問題の内容
1つ目のインデクシングを変数に代入した場合も同様。
一見連鎖していないように見えるが、以下の例はdf.loc['x':'y']['a']
と同じ。値を代入するとSettingWithCopyWarning
が発生する。
df = pd.DataFrame({'a': [0, 1, 2], 'b': [3, 4, 5]}, index=['x', 'y', 'z'])
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
df_slice = df.loc['x':'y']
print(df_slice)
# a b
# x 0 3
# y 1 4
print(df_slice['a'])
# x 0
# y 1
# Name: a, dtype: int64
df_slice['a'] = 100
# /var/folders/rf/b7l8_vgj5mdgvghn_326rn_c0000gn/T/ipykernel_40458/3718525832.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 instead
#
# See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
# df_slice['a'] = 100
print(df_slice)
# a b
# x 100 3
# y 100 4
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
上述のように、処理結果はpandasのバージョンによって異なる可能性があるので注意。バージョン0.25.1
では上記と同じコードで元のDataFrame
の値も更新されていた。
対処法: copy()でコピーを生成
[]
やloc[]
, iloc[]
といったインデクシングでビューが生成されるかコピーが生成されるかは判定できず、必ずビューを生成することもできない。
書き捨てのコードであったり、インデクシングで選択したあとで元のDataFrame
を使うことがない(値が変わっても変わらなくても問題ない)のであれば神経質にならなくてもいいが、様々な処理を行う可能性があるコードの中でビューが返されることを前提とするのは危険。
対処法はcopy()
で明示的にコピーを生成すること。インデクシングの結果を変数に代入する場合は常にコピーとして扱えばSettingWithCopyWarning
は発生しない。
df = pd.DataFrame({'a': [0, 1, 2], 'b': [3, 4, 5]}, index=['x', 'y', 'z'])
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
df_slice_copy = df.loc['x':'y'].copy()
print(df_slice_copy)
# a b
# x 0 3
# y 1 4
df_slice_copy['a'] = 100
print(df_slice_copy)
# a b
# x 100 3
# y 100 4
print(df)
# a b
# x 0 3
# y 1 4
# z 2 5
なお、インデクシングでコピーが返される場合にさらにcopy()
を実行すると一時的にメモリが余分に確保されてしまう可能性がある。巨大なデータを扱う場合は要注意。データの内容や処理が限定されている状況でいくつかのパターンであらかじめ問題がないことを確認した上であれば、copy()
を使わないという選択肢もあるかもしれない。