pandasのSettingWithCopyWarningの対処法

Modified: | Tags: Python, pandas, エラー処理

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が発生しなかったからといって問題がないとは限らない。

ロバストなコードのためには、DataFrameSeriesに値を代入する場合、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:stepstopを指定する場合は注意が必要。行番号・列番号の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()を使わないという選択肢もあるかもしれない。

関連カテゴリー

関連記事