note.nkmk.me

pandasのSettingWithCopyWarningの対処法

Date: 2019-09-15 / tags: Python, pandas, エラー

pandasで頻出の警告にSettingWithCopyWarningがある。エラーではなく警告なので処理が止まることはないが、放っておくと気づかないうちに予期せぬ結果になってしまう場合がある。

ここでは以下の内容について説明する。

  • chained indexing / assignment(連鎖インデクシング・代入)
    • 問題の内容
    • 対処法: 連鎖させない
  • オブジェクトを介したchained indexing / assignment
    • 問題の内容
    • 対処法: copy()でコピーを生成

loc[]iloc[]、コピーやビューなどについての詳細は以下の記事を参照。

以下の内容のpandasのバージョン0.25.1。バージョンが異なると挙動が異なる場合があるので注意。

なお、あまりおすすめしないが、警告はPythonの標準ライブラリwarningsモジュールで非表示にすることもできる。

スポンサーリンク

chained indexing / assignment(連鎖インデクシング・代入)

問題の内容

冒頭に記載した公式ドキュメントにもあるように、SettingWithCopyWarningの要因となるのはchained indexingおよびchained assignment。

loc[]iloc[]などの[]を含む処理を続けて行うことをchained indexingと呼ぶ。

import pandas as pd

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
# /usr/local/lib/python3.7/site-packages/ipykernel_launcher.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: http://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
#   """Entry point for launching an IPython kernel.

A value is trying to be set on a copy of a slice from a DataFrame.という文言の通り、DataFrameのスライスのコピーに対して値を代入しようとしていますよ、という警告。

最初のインデクシング[]がビューを返す場合は問題ないが、コピーを返す場合、コピーに対して二つ目のインデクシング[]が適用されて値が代入されるので、元のDataFrameの値は変わらない(代入されない)ということが起こってしまう。

なお、この例の場合、最初のloc['x':'y']が返すのはコピーではなくビューなので問題なく値が代入されている。

print(df)
#      a  b
# x  100  3
# y  100  4
# z    2  5

loc[]iloc[], []がコピーを返すかビューを返すかは判定できないので、SettingWithCopyWarningが発生したからといって必ずしもコピーが生成されているとは限らない。

次のような場合もある。

print(df.loc[['x', 'y']]['a'])
# x    100
# y    100
# Name: a, dtype: int64

df.loc[['x', 'y']]['a'] = 0
print(df)
#      a  b
# x  100  3
# y  100  4
# z    2  5

最初のloc[['x', 'y']]が返すのはコピーなので、さらにインデクシングを適用して値を代入しても元のDataFrameの値は変わらない。これは意図した結果ではない。

ややこしいことに、この場合はSettingWithCopyWarningが発生しない。

少なくともバージョン0.25.1の時点では、SettingWithCopyWarningが発生したからといって問題があるとは限らないし、SettingWithCopyWarningが発生しなかったからといって問題がないとは限らない。

いずれにせよ、ロバストなコードのためにはchained indexingを避ける必要がある。

対処法: 連鎖させない

chained indexingを防ぐには、警告メッセージにもあるようにインデクシングを連鎖させずに1つににまとめればよい。

Try using .loc[row_indexer,col_indexer] = value instead

上の2つの例は以下のように書ける。

df.loc['x':'y', 'a'] = 10
print(df)
#     a  b
# x  10  3
# y  10  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は行番号・列番号で指定する必要があるので、行番号と列ラベルのように混在して指定することができない。

print(df.iloc[[0, 1]]['a'])
# x    0
# y    0
# Name: a, dtype: int64

# df.loc[[0, 1], 'a']
# KeyError: "None of [Int64Index([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    0
# Name: a, dtype: int64

ここで、スライスstart:stop:stepstopを指定する場合は注意が必要。行番号・列番号のilocではstopが結果に含まれるが、行名・列名のlocではstopが結果に含まれない。

stopの値に対してはindex, columns属性で行番号・列番号から行名・列名を取得する際に-1する必要がある。

print(df.iloc[:2]['a'])
# x    0
# y    0
# Name: a, dtype: int64

print(df.loc[:df.index[2], 'a'])
# x    0
# y    0
# z    2
# Name: a, dtype: int64

print(df.loc[:df.index[2 - 1], 'a'])
# x    0
# y    0
# 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
# /usr/local/lib/python3.7/site-packages/ipykernel_launcher.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: http://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
#   """Entry point for launching an IPython kernel.

print(df_slice)
#      a  b
# x  100  3
# y  100  4

print(df)
#      a  b
# x  100  3
# y  100  4
# z    2  5

以下の例はdf.loc[['x', 'y']]['a']と同じ。SettingWithCopyWarningは発生しないが、df.loc[['x', 'y']]でコピーが生成されるので元のDataFrameの値は更新されない。

df_list = df.loc[['x', 'y']]
print(df_list)
#      a  b
# x  100  3
# y  100  4

print(df_list['a'])
# x    100
# y    100
# Name: a, dtype: int64

df_list['a'] = 0
print(df_list)
#    a  b
# x  0  3
# y  0  4

print(df)
#      a  b
# x  100  3
# y  100  4
# z    2  5

以下のようなことも起こる。スライスで取得したオブジェクトに列を追加する例。

df_slice['c'] = 0
# /usr/local/lib/python3.7/site-packages/ipykernel_launcher.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: http://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
#   """Entry point for launching an IPython kernel.

print(df_slice)
#      a  b  c
# x  100  3  0
# y  100  4  0

df.at['x', 'a'] = 0
print(df)
#      a  b
# x    0  3
# y  100  4
# z    2  5

print(df_slice)
#      a  b  c
# x    0  3  0
# y  100  4  0

元のDataFrameに存在しない列を追加したにもかかわらず、一部ではメモリを共有しているというよくわからない状態になっている。

対処法: copy()でコピーを生成

loc[]iloc[], []といったインデクシングでビューが生成されるかコピーが生成されるかは判定できず、必ずビューを生成することもできない。

書き捨てのコードであったり、インデクシングで選択したあとで元のDataFrameを使うことがない(値が変わっても変わらなくても問題ない)のであれば神経質にならなくてもいいが、様々なデータを対象とする可能性があるコードの中でビューが返されることを前提とするのは危険。

唯一できる対処法はcopy()でコピーを生成すること。インデクシングでオブジェクトを生成する場合は常にコピーとして扱う。

df_slice_copy = df.loc['x':'y'].copy()
print(df_slice_copy)
#      a  b
# x    0  3
# y  100  4

df.at['x', 'a'] = 100
print(df)
#      a  b
# x  100  3
# y  100  4
# z    2  5

print(df_slice_copy)
#      a  b
# x    0  3
# y  100  4

なお、インデクシングでコピーが返される場合にさらにcopy()を実行すると一時的にメモリが余分に確保されてしまう可能性がある。巨大なデータを扱う場合は要注意。データの内容や処理が限定されている状況でいくつかのパターンであらかじめコピーが生成されることを確認した上であれば、copy()を使わないという選択肢もあるかもしれない。

スポンサーリンク
シェア
このエントリーをはてなブックマークに追加

関連カテゴリー

関連記事