初心者がPandasのSettingWithCopyWarningで躓いた話


コードは動くのにエラー?

Yahoo!ファイナンスからダウンロードしたデータを利用して前日の終値と当日の終値から対数を計算した値を列に追加する際にPandasでSettingWithCopyWarningというエラーが出た。コードはちゃんと動くのにエラー?と思いつつエラーに四苦八苦していたがプロパティをよく見たらPandas.DataFrameのプロパティにあったのである。
ただの見落としで恥ずかしいが、備忘もかねて記録しておく。

エラー表示内容

SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

書いたコード

# -*- coding: utf-8 -*-

import argparse
import glob
import math
import os

import pandas as pd


# log(当日の終値,前日の終値(底))として対数表示したもの
# 一番最初のセルは計算できないので0を入力しておく
def add_log(dataframe):
    if 'Close' in dataframe.columns:
        dataframe['LogClose'] = 0
        for row_index in range(1, len(dataframe)):
            dataframe['LogClose'][row_index] = math.log(float(dataframe['Close'][row_index]), float(dataframe['Close'][row_index - 1]))
        return dataframe
    return None


if __name__ == '__main__':
    # コマンドラインで使うための設定
    parser = argparse.ArgumentParser(description = 'YahooファイナンスからDLしたCSVに前日終値と当日終値から対数を計算した列を追加')
    parser.add_argument('-d', '--dir', required = True, help = 'ダウンロードしたcsvファイルが含まれるディレクトリ')
    parser.add_argument('-ed', '--export_dir', default = '対数追加済み', help = '編集後のCSVを入れるディレクトリ')
    args = parser.parse_args()

    # 保存先のパス
    export_directory = os.path.join(args.dir, args.export_dir)

    # 選択したディレクトリからCSVファイルだけを検索
    for file_path in glob.glob(os.path.join(args.dir, '*.csv')):
        # 対数計算をする
        stock_csv = add_log(pd.read_csv(file_path))
        if stock_csv is None:
            continue
        if not os.path.exists(export_directory):
            os.makedirs(export_directory)
        # CSVの書き出し
        stock_csv.to_csv(os.path.join(export_directory, os.path.basename(file_path)))

問題の箇所は関数add_logの下記の部分らしい。

dataframe['LogClose'][row_index] = math.log(float(dataframe['Close'][row_index]), float(dataframe['Close'][row_index - 1]))

手順は

  1. LogCloseという列を準備し0を代入しておく
  2. LogCloseの列に上から順にClose列(終値)を参照して計算した値を入れていく

というつもりだった。

https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
へ警告の詳細を見に行くと、ビュー(同じメモリ)かコピー(違うメモリだが同じ内容)かどっちを返すかPandasでは判断できないため予期せぬバグを生むからきちんとしなさいということらしい。
2種類の解決策を記載しておく。

解決方法1(こちらを優先で使うこととする)

Pandas.DataFrameのプロパティを使用する

Pandasで任意の位置の要素の値を取得、変更するプロパティ

  • at 行名、列名で一つの要素を選択
  • iat 行番号、列番号で一つの要素を選択
  • loc 行名、列名で複数の要素を選択
  • iloc 行番号、列番号で複数の要素を選択

locilocは範囲で指定可能で一つのセルを選択することも可能だがatiatよりは処理が遅くなる。

解決した関数部分のコード
最初に0を全部に入れ、LogClose列を最初に作成する手間も無くなった。

def add_log(dataframe):
    if 'Close' in dataframe.columns:
        for row_index in range(1, len(dataframe)):
            dataframe.at[row_index, 'LogClose'] = math.log(float(dataframe['Close'][row_index]), float(dataframe['Close'][row_index - 1]))
        dataframe.at[0, 'LogClose'] = 0
        return dataframe
    return None

解決方法2

リスト内包表記で計算しながらリストを作成していく

プロパティを探す前に無理やりやった方法。こっちでもいいとは思うがちゃんとしたプロパティを使いたいので解決方法1を優先とする。リスト内包表記、とても便利だけど綺麗に分けて書かないと式がわかりにくくなる。
解決した関数部分のコード

def add_log(dataframe):
    if 'Close' in dataframe.columns:
        for row_index in range(1, len(dataframe)):
            dataframe['LogClose'] = [0] + [math.log(float(dataframe['Close'][row_index]), float(dataframe['Close'][row_index - 1])) for row_index in range(1, len(stock_csv))]
        return dataframe
    return None

急に2回floatへキャストしているところが気になってきた。

参考