LoginSignup
5
0

Pandasのinplaceオプション:なぜ避けるべきなのか

Last updated at Posted at 2023-12-09

目次

  1. イントロダクション
  2. inplaceオプションの動作原理
  3. inplace=Trueの使用に関する一般的な誤解
  4. パフォーマンスに関する検証
  5. inplace=Trueがもたらす問題点
  6. ケーススタディ:inplaceの影響の実例
  7. ベストプラクティスについて考える
  8. まとめ
  9. 余談

1. イントロダクション

Pythonの世界では、コードの品質と効率性を高めるためのツールが絶えず進化しています。その中で、私が最近出会ったのが「ruff」、Python用の新しいlinterツールです。このツールは、コードの潜在的な問題点を瞬時に指摘し、開発者の日々の作業を劇的に改善することができます。

特に興味深かったのは、ruffが提供するPandas関連の指摘です。Pandasはデータ分析で広く使われるライブラリですが、その使用には潜在的な落とし穴もあります。ruffを使っているとき、ある特定の機能―inplaceオプションに関する注意点に出くわしました(pandas-use-of-inplace-argument (PD002) - Ruff)。これは、データフレームを操作する際によく使われるオプションですが、実は多くの問題を秘めています。

この発見は、私にとってまさに目から鱗でした。そこで、この記事ではPandasinplaceオプションにスポットライトを当て、その使用を避けるべき理由と、より良い代替手段について掘り下げていきたいと思います。inplaceオプションの落とし穴を理解し、よりクリーンで効率的なPandasコードを書くための洞察を提供します。

2. inplaceオプションの動作原理

Pandasinplaceオプションは様々な関数で用意されています。
今回はclass DataFrameで定義されているdrop関数について注目します。

どの関数においても、デフォルトではinplace=Falseであることがわかります。

2.1. drop関数の動作について

drop関数は、特定のインデックスを削除する関数です。
公式ドキュメント: pandas.DataFrame.drop — pandas 2.1.3 documentation

2.1.1. inplace=False

inplace=Falseとした場合、drop関数を実行した後で元々の変数dfについては変更されていません。

>>> import pandas as pd
>>> df = pd.DataFrame({'A': [1, 2], 'B': [4, 5]})
>>> print(df)
   A  B
0  1  4
1  2  5
>>> df_dropped = df.drop('A', axis=1, inplace=False)
>>> print(df_dropped)
   B
0  4
1  5
>>> print(df)
   A  B
0  1  4
1  2  5

2.2. inplace=True

inplace=Trueとした場合、drop関数を実行した後で元々の変数dfについては変更されます。

>>> df = pd.DataFrame({'A': [1, 2], 'B': [4, 5]})
>>> df_dropped = df.drop('A', axis=1, inplace=True)
>>> print(df_dropped)
None
>>> print(df)
   B
0  4
1  5

2.2. dropの内部実装について

pd.DataFrameの中身については、つぎのように辿ることができます。

  1. Pythonのパッケージにおいて__init__.pyの中身は重要であるためその中身を確認します(6. モジュール — Python 3.12.0 ドキュメント)。
  2. pandas/pandas/__init__.py · pandas-dev/pandas をみると DataFramepandas.core.api から import されています。
  3. pandas/pandas/core/api.py · pandas-dev/pandas をみると DataFramepandas.core.frame から import されています。
  4. pandas/pandas/core/frame.py · pandas-dev/pandas をみると DataFrameの実態が定義されています。

一方でdrop関数の実態は親クラスで定義されていることがわかります。

  1. pandas/pandas/core/frame.py · pandas-dev/pandas をみると drop 関数は overload が沢山用意され、かつ 親クラスで定義されています。
  2. pandas/pandas/core/frame.py · pandas-dev/pandasをみると DataFrame クラスは 多重継承されたクラスです。
  3. Method Resolution Order(用語集 — Python 3.12.0 ドキュメント)をみると 次の順番で 親クラスの method を見ています。
    • >>> pd.DataFrame.__mro__
      (<class 'pandas.core.frame.DataFrame'>, <class 'pandas.core.generic.NDFrame'>, <class 'pandas.core.base.PandasObject'>, <class 'pandas.core.accessor.DirNamesMixin'>, <class 'pandas.core.indexing.IndexingMixin'>, <class 'pandas.core.arraylike.OpsMixin'>, <class 'object'>)
      
  4. pandas/pandas/core/generic.py · pandas-dev/pandas をみると NDFramedrop 関数が定義されています。

ここまでたどり着くと、inplaceの箇所の実装を確認することができます。

3. inplace=True の使用に関する一般的な誤解

inplace=True とすることに対して、次の誤解が存在するかと思います。

  • メモリ使用量の削減という誤った信念
  • パフォーマンスへの誤解

これらは、inplace=Trueを行うことで、drop後の変数を宣言する必要がないために発生するかと思います。

しかし、コードを見ると inplaceTrue である場合は 関数が追加で実装される上に、inplaceTrueでもFalseでも 内部的に変数が設定されるため、メモリ使用量はあまり変わらずパフォーマンスについては低下するように思えます。

4. パフォーマンスに関する検証

inplace=Trueを用いた際に、本当にパフォーマンスは良くならないか検証してみました。

検証の内容としては簡単なデータフレームを作成しdrop動作をさせ、その際のメモリ使用量と実行時間について確認しました。

4.1. 使用コードについて

Python環境と使用ライブラリ,環境変数について
cpython@3.10.12
pandas==2.0.3
delogger==0.3.0
"""inplaceについて検証するためのコード."""
import gc

import pandas as pd
from delogger.presets.profiler import (
    logger,
)

NUM = 10


4.1.1. inplace=Trueの場合

@logger.line_memory_profile
def use_inplace() -> None:
    """use_inplace."""
    for _ in range(NUM):
        df: pd.DataFrame = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
        df.drop("A", axis=1, inplace=True)
        del df
        gc.collect()

if __name__ == "__main__":
    use_inplace()

ログの内容
DEBUG line_memory_profile.py:83 wrapper line, memory profiler result
Timer unit: 1e-09 s

Total time: 0.357716 s
File: ./include_inplace.py
Function: use_inplace at line 12

Line #      Hits         Time  Per Hit   % Time    Mem usage    Increment   Line Contents
=========================================================================================
    12                                             70.94 MiB    70.94 MiB           1   @logger.line_memory_profile
    13                                                                                  def use_inplace() -> None:
    14                                                                                      """use_inplace."""
    15        11      44000.0   4000.0      0.0    71.34 MiB     0.00 MiB          11       for _ in range(NUM):
    16        10    8568000.0 856800.0      2.4    71.34 MiB     0.13 MiB          10           df: pd.DataFrame = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
    17        10   14424000.0    1e+06      4.0    71.34 MiB     0.27 MiB          10           df.drop("A", axis=1, inplace=True)
    18        10      67000.0   6700.0      0.0    71.34 MiB     0.00 MiB          10           del df
    19        10  334613000.0    3e+07     93.5    71.34 MiB     0.00 MiB          10           gc.collect()

4.1.2. inplace=Falseの場合

@logger.line_memory_profile
def not_use_inplace() -> None:
    """not_use_inplace."""
    for _ in range(NUM):
        df: pd.DataFrame = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
        df_dropped: pd.DataFrame = df.drop("A", axis=1)
        del df, df_dropped
        gc.collect()

if __name__ == "__main__":
    not_use_inplace()

ログの内容
DEBUG line_memory_profile.py:83 wrapper line, memory profiler result
Timer unit: 1e-09 s

Total time: 0.349473 s
File: ./exclude_inplace.py
Function: not_use_inplace at line 12

Line #      Hits         Time  Per Hit   % Time    Mem usage    Increment   Line Contents
=========================================================================================
    12                                             70.96 MiB    70.96 MiB           1   @logger.line_memory_profile
    13                                                                                  def not_use_inplace() -> None:
    14                                                                                      """not_use_inplace."""
    15        11      41000.0   3727.3      0.0    71.37 MiB     0.00 MiB          11       for _ in range(NUM):
    16        10    7865000.0 786500.0      2.3    71.37 MiB     0.13 MiB          10           df: pd.DataFrame = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
    17        10   13549000.0    1e+06      3.9    71.37 MiB     0.27 MiB          10           df_dropped: pd.DataFrame = df.drop("A", axis=1)
    18        10     196000.0  19600.0      0.1    71.37 MiB     0.00 MiB          10           del df, df_dropped
    19        10  327822000.0    3e+07     93.8    71.37 MiB     0.00 MiB          10           gc.collect()

4.2. ログ結果の比較

項目 use_inplace() (inplace=True) not_use_inplace() (inplace=False)
dropの実行 0.27 MiB 0.27 MiB
dropの実行時間 1.4424000 x 1e-2 秒 1.3549000 x 1e-2 秒

4.3. ログ結果の解釈

  • 両関数のdropの実行によるメモリ使用量の増加がほぼ同じです(0.27 MiB)。これは、inplace=Trueを使用しても、内部で新しいデータフレームが一時的に作成されているためと考えられます。
  • 実行時間もほぼ同じで、inplace=Trueを使用した場合が若干長いですが、この差はごくわずかです。

4.4. 検証の結論

今回の検証では、inplace=Trueinplace=Falseの間でメモリ使用量に大きな違いは見られませんでした。また、実行時間もほぼ同じでした。これは、inplaceパラメーターの使用が必ずしもパフォーマンスやメモリ効率に大きな影響を与えるわけではないことを示しています。

5. inplace=Trueがもたらす問題点

さて、先程の項でinplaceはパフォーマンスの影響にあまり関与しないことがわかりました。それでは、inplace=Trueを使用することに対して、一体何が問題なのでしょうか。

問題を考える前に利点について考え、その後に欠点について考えてみます。

5.1. inplace=Trueの利点について考える

5.1.1. 中間生成物の命名の不要について

データ分析のプロセスはしばしば、複雑で多段階のデータ変換を伴います。このような環境で、Pandasinplace=Trueオプションは、分析の効率を高める重要なツールとなる可能性があります。このオプションの最大の利点は、中間生成物を作成せずにデータを直接変更することができる点にあります。

inplace=Trueを使用すると、このプロセスが大幅に簡略化されます。新しい変数を作成し、それをトラッキングする必要がなくなるため、コードの可読性が向上するように思えます。

しかしながら、実際の分析においては本当に可読性・運用性を向上させているか注意深く考える必要があります。

5.2. inplace=Trueの欠点について考える

5.2.1. データの不意な変更とその影響

inplace=Trueを使用すると、データフレームの変更が不可逆的になります。これは、データ分析の過程で特に問題となる場合があります。例えば、データのクリーニングや前処理を行っている際に、誤って重要なデータを削除してしまう可能性があります。一旦削除してしまうと、その操作を元に戻すことはできません。このように、inplace=Trueの使用は、データの誤操作によるリスクを増大させます。

5.2.2. デバッグと保守の困難さ

inplace=Trueによる直接的な変更は、コードのデバッグと保守を困難にします。特に、複雑なデータ分析プロセスでは、一つのデータフレームに対して多数の操作が行われることがあります。inplace=Trueを使用していると、どの操作がデータにどのような影響を与えたかを追跡することが難しくなります。また、将来のデータセットに同じ処理を適用する際に、予期しない結果やエラーが発生する可能性があり、コードの再利用性が低下します。

6. ケーススタディ: inplace=Trueの影響の実例

前述した内容を踏まえて次のようなケースを考えてみました。こちらを考えるとより、inplace=Trueを使うべきかinplace=Falseを使うべきかの助けになるかと存じます。

6.1. データの分析における効率の悪化

6.1.1. シナリオ

槇くんは、Kaggleのコンペティションや業務で大規模なデータセットの前処理をJupyter Notebookで行っています。データクリーニングの一環として、欠損値がある行や特定の条件に該当する行を削除する必要が生じました。槇くんはpd.DataFrame.dropna()DataFrame.drop()メソッドを使用し、変数の命名が面倒という観点からinplace=Trueオプションでこれらの行をデータフレームから直接削除しました。

6.1.2. 問題の発生

しかし、モデルを構築する段階で、削除された行が実は重要な情報を含んでいたことがわかりました。さらに、inplace=Trueを使用しているため、これらの行は元に戻すことができませんでした。

6.1.3. 影響

データを復元するためには、データソースからデータを再度読み込む必要がありますが、これにはかなりの時間がかかります。結果として、槇くんは作業を一時中断し、分析プロジェクトのスケジュールに遅延が生じました。

7. ベストプラクティスについて考える

今までの話から、Pandasの前処理に関してベストプラクティスはinplace=Falseを使用することであることが伺えました。この章では、データの保全、デバッグのしやすさ、コードの柔軟性、そして可読性に注目して改めて詳細をまとめます。

7.1. データの保全

  • inplace=Falseを使用すると、元のデータフレームは変更されず、新しいデータフレームが作成されます。これにより、元のデータを保持し続けることができ、データの誤操作によるリスクを低減できます。
df_original = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
df_dropped = df_original.drop("A", axis=1)  # 元のデータフレームは変更されない

7.2. デバッグのしやすさ

  • 新しいデータフレームを生成することで、各変更ステップを個別に確認しやすくなり、デバッグが容易になります。
df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
df_renamed = df.rename(columns={"A": "a"})  # 変更点を確認しやすい
df_dropped = df_renamed.drop("B", axis=1)

7.3. コードの柔軟性

  • 複数のデータフレームを操作することで、異なるデータ処理のパスを容易に試すことができます。これにより、データ分析の柔軟性が高まります。
df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
df_with_a = df.drop("B", axis=1)
df_with_b = df.drop("A", axis=1)  # 異なる処理を並行して実行できる

7.4. 可読性の向上

  • 全ての前処理に対して、変数を持たせておくのは確かに面倒ですが、メソッドチェーンを使用するとこの問題は解消され、分析の中で試行錯誤しながらも、コードの可読性が向上させることができます。
df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
# inplace=Falseでの作業(開始)
# ...
# inplace=Falseでの作業(終了)
df_processed = (df
                .rename(columns={"A": "a"})
                .drop("B", axis=1))

8. まとめ

inplace=Falseの使用は、データ分析のプロセスをより透明で安全かつ柔軟にし、結果としてプロジェクトの全体的な成功に貢献します。データの不可逆的な変更を避け、デバッグと保守のしやすさを保ちながら、より高い可読性を実現することができます。

9. 余談

9.1. Polarsについて

Polarsでは polars.DataFrame.drop_in_place — Polars documentation, polars.DataFrame.drop — Polars documentationのように そもそも inplace を使用する際と使用しない場合でメソッド名が異なるようです。

9.2. ヒューマンエラーについて

仮にinplace=Falseとしても次のようなことをすると元のデータは失われます。

df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
df = df.drop("B", axis=1)

このコードでは、dropメソッドを使用して"B"列を削除していますが、その結果は元の変数dfに再代入されています。これにより、元の"B"列を含むデータフレームは失われます。

そのため、Pythonコードを扱うときは 変数への再代入は避ける といった基本的なルールが大切なのかもしれません。

9.3. 分析にNotebookを使用した際の共有について

分析作業の一環としてJupyter Notebookを使用する際には、他人にコードを読んでもらうことも重要で、ruffなどのlinterツールを通せるようにできる形に出力しておくことは重要です。この点において、Jupyter NotebookからPythonコードへの変換を効率的に行うフォーマットは非常に便利で、分析プロセスをより構造化し、コードの可読性と共有性を高めることができます。

過去に私の自前ですがPythonライブラリを公開したためそちらについての紹介リンクを添付させていただきます。

詳しくは、こちらの記事をご覧ください:Kaggleのコードを英語で読めないから,NotebookをPythonコードに変更しよう #Python - Qiita

参考資料

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0