🐼

pandasのquery()で指定できる@ローカル変数名の謎を追う

2024/07/23に公開

(この記事は下記の再掲です)
https://peperon-brain.hatenablog.com/entry/2023/11/05/234708

仕事でpandasを使っていて、こんな書き方ができることを知った。

bar = 123
df_filtered = df.query('foo == @bar')

ローカル変数を参照させている。上の例ではfoo列の値が123であるデータをフィルタできる。
公式ドキュメントにもはっきり書かれている。
https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.query.html

式を渡す時点では単なる文字列なのに、pandasはどうやってbar = 123を参照しているのだろうか?
なんかスタックを辿る仕組みがあるんだろうと見当はつくが、具体的なHowの部分を知るため取材班はアマゾンの奥地へと飛んだ。

結論: inspect.stack()を使えばできる

※この検証はPython 3.9.10で行いました

inspect.stack()を使うことで、コールスタックを辿って各フレームの情報を得ることができる。
ここにローカル変数や関数の引数も含まれている。フレームの f_locals で取得できる。

import inspect

def callee():
    stack = inspect.stack()
    for level, frame in enumerate(stack):
        print(f'\n----- frame level={level} -----')
        print(frame.function)
        print(frame.frame.f_locals)

def caller_lv3():
    callee()

def caller_lv2(arg):
    var2 = arg
    caller_lv3()

def caller_lv1():
    var1 = 1
    caller_lv2(var1)

def main():
    caller_lv1()

main()

実行結果:

----- frame level=0 -----
callee
{'stack': [FrameInfo(frame=<frame at 0x104ded900, ...(省略)...}

----- frame level=1 -----
caller_lv3
{}

----- frame level=2 -----
caller_lv2
{'arg': 1, 'var2': 1}

----- frame level=3 -----
caller_lv1
{'var1': 1}

----- frame level=4 -----
main
{}

----- frame level=5 -----
<module>
{'__name__': '__main__', ...(省略)...}

pandasで上記を実行している箇所

このあたりでやっている。

https://github.com/pandas-dev/pandas/blob/32b8cd2c81108dc51d59d2d09287df29d40d255b/pandas/core/computation/scope.py#L271-L313
ずっと上の方からスタックレベルを記録してきて、ターゲットの階層を認識した上でそのフレーム情報を取得しているようだ。
これを元に@部分を値へ置換しているということですね。
APIだけ見るとさらっとしてるけど、内部ではちょっとトリッキーなことをしている。

Discussion