PandasからPolarsへ|バックテスト高速化の実測と書き換え

Python実装・コード

先週、日本株製造メーカー10銘柄×5年分の日足データでパラメータ最適化をしようとしたら、僕のノートPCが「ウィィィン」と唸り出して、そのまま30分近く固まりました。for文でパラメータを100通り回そうとしただけなのに。

調べてPolarsに乗り換え、7倍速くなりました。ただ、この記事で本当に伝えたいのはそこではありません。ベンチマークを取り直したら、7倍のうち3倍ぶんくらいは自分の書き方が悪かっただけでした。

最初に測ったとき、Pandas版だけ毎回df.copy()していたんです。Polars版はコピーなし。これでは条件が違います。この記事では、条件を揃えたベンチマークを載せた上で、それでもPolarsが速い場面と、Pandasのままで十分な場面を分けます。

📘 外部参考Polars公式ドキュメントpandas User Guide

そもそもなぜPandasは遅いのか

Pandasは内部的にNumPyの配列を使っていますが、1行1行の処理やfor文的な操作になると、Pythonオブジェクトを介するオーバーヘッドが積み重なります。さらにシングルスレッドが基本なので、CPUのコアが余っていても使い切れません。

一方Polarsは、Rust実装とApache Arrowのメモリレイアウトをベースに、マルチスレッドと遅延評価(Lazy Evaluation)を前提に設計されています。「今すぐ実行する」のではなく「計画を立ててからまとめて実行する」ので、無駄な中間データが減ります。

ただし、この説明から期待されるほどの差が常に出るわけではありません。1万行程度のデータでrolling_meanを1回だけ計算するなら、両者はほぼ同じです。差が出るのは、行数が多いとき、列が多いとき、そしてグループごとの処理をするときです。

条件を揃えたベンチマーク

まず再現できるデータを作ります。手元のデータに依存しないので、そのまま試せます。

import time
import numpy as np
import pandas as pd
import polars as pl

def make_data(n_codes: int = 10, n_days: int = 1250, seed: int = 0) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    frames = []
    dates = pd.bdate_range("2020-01-01", periods=n_days)
    for i in range(n_codes):
        ret = rng.normal(0.0003, 0.015, n_days)
        close = 1000 * np.cumprod(1 + ret)
        frames.append(pd.DataFrame({
            "date": dates,
            "code": f"C{i:03d}",
            "close": close,
        }))
    return pd.concat(frames, ignore_index=True)

pdf = make_data()
print(pdf.shape)   # (12500, 3)

def bench(fn, n: int = 100, warmup: int = 3) -> float:
    for _ in range(warmup):
        fn()
    t = time.perf_counter()
    for _ in range(n):
        fn()
    return (time.perf_counter() - t) / n * 1000   # 1回あたりミリ秒

ウォームアップを入れているのがポイントです。初回はキャッシュが冷えていたりインポート直後だったりで、極端に遅く出ます。1回だけtime.perf_counter()で挟んだ計測は、たいてい信用できません。

3パターンで比べる

# (1) 最初に僕が書いた、コピーありのPandas版
def pandas_naive():
    df = pdf.copy()
    df["sma20"] = df.groupby("code")["close"].transform(lambda s: s.rolling(20).mean())
    df["std20"] = df.groupby("code")["close"].transform(lambda s: s.rolling(20).std())
    df["upper"] = df["sma20"] + 2 * df["std20"]
    df["lower"] = df["sma20"] - 2 * df["std20"]
    df["signal"] = (df["close"] < df["lower"]).astype("int8")
    return df

# (2) 同じPandasでも、無駄を削った版
grouped = pdf.groupby("code")["close"]
def pandas_tuned():
    sma = grouped.rolling(20).mean().reset_index(level=0, drop=True)
    std = grouped.rolling(20).std().reset_index(level=0, drop=True)
    lower = sma - 2 * std
    return (pdf["close"].values < lower.values).astype("int8")

# (3) Polars(Lazy + over)
lf = pl.from_pandas(pdf).lazy()
def polars_lazy():
    return (
        lf.with_columns([
            pl.col("close").rolling_mean(20).over("code").alias("sma20"),
            pl.col("close").rolling_std(20).over("code").alias("std20"),
        ])
        .with_columns([
            (pl.col("sma20") - 2 * pl.col("std20")).alias("lower"),
        ])
        .with_columns([
            (pl.col("close") < pl.col("lower")).cast(pl.Int8).alias("signal"),
        ])
        .collect()
    )

for name, fn in [("pandas_naive", pandas_naive),
                 ("pandas_tuned", pandas_tuned),
                 ("polars_lazy", polars_lazy)]:
    print(f"{name:14s} {bench(fn):7.2f} ms/回")

僕の4コアのノートPCでの結果はこうなりました。

実装1回あたりnaive比何が効いたか
pandas_naive約98 ms1.0倍copyとlambda transformが重い
pandas_tuned約34 ms2.9倍copy削除、lambdaをやめた
polars_lazy約14 ms7.0倍マルチスレッド + Arrow

最初に見た「7倍」のうち、およそ3倍はPandasの書き方の問題でした。純粋にライブラリの差と言えるのは、tuned比の2.4倍ぶんです。それでも十分速いのですが、「Pandasは遅いからPolars」という言い方は正確ではありませんでした。

groupby().transform(lambda s: ...)は特に遅いです。銘柄ごとにPython関数が呼ばれるので、10銘柄なら10回のPython往復が発生します。groupby().rolling()に置き換えるだけで、ここは大きく縮みます。

Polarsで書くバックテスト

指標だけでなく、シグナルからリターンまで一気に書けます。over("code")があるので、銘柄ごとのループが不要です。

FEE = 0.0005

def backtest_polars(lf: pl.LazyFrame, fast: int = 5, slow: int = 25) -> pl.DataFrame:
    return (
        lf.sort(["code", "date"])
        .with_columns([
            pl.col("close").rolling_mean(fast).over("code").alias("ma_fast"),
            pl.col("close").rolling_mean(slow).over("code").alias("ma_slow"),
            pl.col("close").pct_change().over("code").alias("ret"),
        ])
        .with_columns(
            (pl.col("ma_fast") > pl.col("ma_slow")).cast(pl.Float64).alias("raw_signal")
        )
        .with_columns(
            pl.col("raw_signal").shift(1).over("code").alias("position")
        )
        .with_columns([
            (pl.col("ret") * pl.col("position")).alias("gross"),
            (pl.col("position").diff().over("code").abs() * FEE).fill_null(0).alias("cost"),
        ])
        .with_columns((pl.col("gross") - pl.col("cost")).alias("net"))
        .collect()
    )

def summarize(df: pl.DataFrame) -> pl.DataFrame:
    return (
        df.group_by("code")
        .agg([
            (pl.col("net").fill_null(0) + 1).product().alias("equity"),
            pl.col("net").mean().alias("mean_ret"),
            pl.col("net").std().alias("vol"),
            pl.col("position").diff().abs().sum().alias("trades"),
        ])
        .with_columns([
            (pl.col("equity") - 1).round(4).alias("total_return"),
            (pl.col("mean_ret") / pl.col("vol") * (245 ** 0.5)).round(3).alias("sharpe"),
        ])
        .sort("sharpe", descending=True)
    )

res = backtest_polars(lf)
print(summarize(res))

.over("code")shiftdiffにも付けているのが重要です。付け忘れると、ある銘柄の最終日の値が次の銘柄の初日に漏れます。銘柄をまたいだデータ漏洩で、Pandasのgroupby忘れと同じ事故です。エラーにならないので気づきにくい。

パラメータ最適化で効いてくる

本題だった最適化ループです。ここでLazyの真価が出ます。

def grid_polars(lf: pl.LazyFrame, fasts=range(3, 21, 2), slows=range(20, 101, 10)):
    plans = []
    for f in fasts:
        for s in slows:
            if f >= s:
                continue
            plan = (
                lf.sort(["code", "date"])
                .with_columns([
                    pl.col("close").rolling_mean(f).over("code").alias("mf"),
                    pl.col("close").rolling_mean(s).over("code").alias("ms"),
                    pl.col("close").pct_change().over("code").alias("ret"),
                ])
                .with_columns(
                    (pl.col("mf") > pl.col("ms")).cast(pl.Float64)
                    .shift(1).over("code").alias("pos")
                )
                .select([
                    pl.lit(f).alias("fast"),
                    pl.lit(s).alias("slow"),
                    (pl.col("ret") * pl.col("pos")).mean().alias("mean_ret"),
                    (pl.col("ret") * pl.col("pos")).std().alias("vol"),
                ])
            )
            plans.append(plan)
    # 全プランをまとめて並列実行させる
    results = pl.collect_all(plans)
    return pl.concat(results).with_columns(
        (pl.col("mean_ret") / pl.col("vol") * (245 ** 0.5)).round(3).alias("sharpe")
    ).sort("sharpe", descending=True)

print(grid_polars(lf).head(10))

pl.collect_all()が効きます。個別に.collect()を呼ぶと1つずつ実行されますが、リストで渡すとPolarsが並列に処理します。90通りのグリッドで、逐次実行より体感で2倍以上速くなりました。30分固まっていた処理が数十秒です。

メモリも見ておく

速度だけでなく、使用メモリも測りました。ノートPCだと、こちらが先に限界に来ます。

def memory_mb(obj) -> float:
    if isinstance(obj, pd.DataFrame):
        return obj.memory_usage(deep=True).sum() / 1024 ** 2
    return obj.estimated_size("mb")

big = make_data(n_codes=200, n_days=2500)
print(f"pandas: {memory_mb(big):.1f} MB")
print(f"polars: {memory_mb(pl.from_pandas(big)):.1f} MB")

文字列カラム(銘柄コード)が多いデータでは差が開きます。PandasはPythonのstrオブジェクトを持つのに対し、PolarsはArrowの文字列表現なので、コンパクトです。category型に変換すればPandasでも縮みますが、そこまでやるならPolarsでいい、というのが正直な感想でした。

乗り換えで詰まったポイント

いいことばかり書きましたが、Pandasの感覚のままでは書けない部分がかなりありました。

  • インデックスがない。日付も普通のカラムなので、df.loc["2026-07-01"]ではなくfilter(pl.col("date") == ...)で絞ります。時系列のリサンプルはgroup_by_dynamicを使います。
  • applyは使わない。Python関数を渡した瞬間に並列化が止まり、Pandasより遅くなることもあります。式(Expression)で書けないか先に考えます。
  • nullとNaNが別物。Pandasは両方NaNですが、Polarsでは欠損はnull、演算結果の非数はNaNです。fill_nullfill_nanを使い分ける必要があります。rolling_meanのウォームアップ部分はnullです。
  • ライブラリの多くがPandasを返す。yfinanceもJ-Quantsのクライアントも戻り値はPandasなので、pl.from_pandas()での変換が必ず挟まります。

4つ目は意外とコストがかかります。1万行程度なら変換は1ミリ秒未満ですが、ループの中で毎回変換していると台無しです。変換は最初に1回だけ、というのが鉄則でした。

まとめ:どう使い分けたか

データ取得はPandasのまま、最適化のように同じ処理を大量に繰り返す部分だけPolars、というハイブリッドに落ち着きました。全部を書き換える体力は正直なかったです。

今回いちばんの収穫は、7倍のうち3倍が自分の書き方の問題だったと分かったことです。遅いと感じたら、ライブラリを替える前にcopy()applyを疑う。それでも足りないときにPolarsを検討する。この順番なら、書き換えコストを払う価値があるかを先に判断できます。

バックテストのロジック自体はPandas版と共通です。shiftの位置やコストの適用方法はバックテスト実装の記事に書いたとおりで、速くなっても間違いは速く出るだけです。次は分足データでウォークフォワード検証を試すつもりです。

タイトルとURLをコピーしました