「手続き」から「宣言」へ。Polars Expression で 4,000 銘柄を 0.03 秒で「捌く」設計の妙
本記事は、欠損の多いyfinanceの RSI をPandas MultiIndex で 4,000 銘柄をだいたい2分で高速復旧する の続編です。
【完結編】10分の壁を突破せよ!全4,400銘柄の財務データ・価格・順位付けを 9分台で完遂する Turbo パイプラインの全貌
をアップしました。合わせて、閲覧いただけると幸いです。
半年運用後の見直し記事(前編・後編)も公開しました。
Pandas MultiIndex における管理コストを削減し、Polars Expression を用いた宣言的な計算モデルを採用することで、分析の柔軟性とパフォーマンスを両立させた事例を紹介する。
TL;DR
- Pandas を卒業: 巨大な MultiIndex 管理から解放され、Polars Expression による「宣言的計算」へシフト
- 計算モデルの転換: 「表操作(How)」から「意図の定義(What)」へのパラダイムシフトが、分析の自由度を劇的に変える
- ストリーミング設計: Producer-Consumer パイプラインにより、メモリ消費を一定に保ちながら 4,000 銘柄を安定スキャン
- 58倍の高速化: 単なる性能向上ではなく、これまで「重すぎて断念していた分析」が可能になる設計耐性の確保
1. 背景と課題:データ復旧の先にある「計算モデルの限界」
前回記事では、yfinance のデータ欠損を Pandas MultiIndex による行列演算で「力技」で克服する手法を紹介しました。しかし、その後に発生した問題は「データの欠落」ではなく、**「計算モデルそのもの」**にありました。
分析ロジックが高度化し、ボリンジャーバンド(移動標準偏差)のような統計指標や、財務データとの複雑な結合(Join)を分析軸に加えた際、従来の Pandas ベースの設計ではパフォーマンスや保守性の面で限界が顕在化しました。
- RAM 圧迫: 4,000 銘柄 × 数年分の時系列を一つの「巨大な表」として維持するメモリコスト。
- GIL の壁: Python オブジェクトを操作する限り、マルチコア CPU の真の性能を活かせない直列的な限界。
- 記述の命令化: 「何を計算したいか」よりも「どうやって DataFrame を操作するか」という手続きが優越するコードの複雑化。
問題の本質は、「表をどう操作するか」という Pandas の思想そのものが、多銘柄・高密度な分析という要求に対してオーバースペックな「構造維持コスト」を強いていたことにありました。
2. 構成:Producer-Consumer パイプラインによる効率化
この設計により、ネットワーク待ち時間(I/O)を Producer が引き受け、メインスレッド(Consumer)は Polars による超高速演算に専念できます。注目すべきは、「メモリに広げるデータを最小限に抑えつつ、計算だけを全力で回し続ける」 という物理的なレイアウトの変更です。
💡 なぜスレッド 1 でも速いのか?
本システムでは環境制約上、Polars のマルチスレッドを 1 に制限していますが、それでも Pandas より圧倒的に高速です。
これは Pandas が Python 側のループで NumPy 的な演算を繰り返すのに対し、Polars は単一スレッド内でも Rust 駆動の SIMD 命令(単一命令マルチデータ)や、CPU キャッシュ効率に最適化された計算パイプラインを構築するためです。物理的に「計算の密度」が異なります。
2.5. なぜ Polars への移行が『パラダイムシフト』なのか
Polars への移行は、単なる「高速なライブラリへの置換」ではありません。それは、データ処理の物理レイアウトと計算モデルそのものの再定義です。
- Index の廃止: Pandas の MultiIndex は強力ですが、構造維持のオーバーヘッドが大きく、並列化の足枷となります。Polars は Index を廃止し、データを純粋なフラットテーブル(Series の集合)として扱います。これにより、データのアライメントコストが消失し、SIMD による一括処理が容易になります。
- Lazy Evaluation(遅延評価): 命令を逐次実行するのではなく、クエリ全体を「論理実行計画」として構築します。Query Optimizer が不要な列の読み出しを自動で排除(Projection Pushdown)し、計算リソースを極限まで絞り込みます。
- Apache Arrow: メモリ上の物理的なレイアウトに Apache Arrow を採用しています。列指向(Columnar)で CPU キャッシュ効率に最適化されたこの構造は、Rust の持つハードウェア制御能力を最大限に引き出し、現代的な CPU のベクトル演算能力をフルに活用します。
3. 戦略:Polars Expression による「計算の宣言化」へのシフト
Polars 移行の真の価値は、速度以上に「思想の転換」にあります。
- Pandas = 手続き的 (Imperative): 「この列からこう取り出して、ここに代入せよ」という How の記述。
- Polars = 宣言的 (Declarative): 「この列とあの列をこのように加工した結果が欲しい」という What の記述。
Polars の Expression(式) は、実際の計算そのものではなく、「計算プランの定義」 です。これを Rust 駆動のクエリエンジンへ投げることで、エンジン側が「どの列が不要か」「どこを並列化できるか」を自律的に最適化します。エンジニアは「操作の順序」という拘束から解放され、「分析の意図」そのものをコードに落とし込めるようになります。
4. 実装:パラダイムシフトの具体
実際に PolarsEngine で使用している、高密度な Expression の実装例を紹介します。これらはすべて「計画」として連結され、最後に一撃で実行されます。
4.1. 決定論的マイクロノイズによる「物理的な順位の分離」
全く同じ指標値を持つ銘柄が並んだ時、従来のランキングでは同順位が頻発します。これを防ぐため、銘柄コードのハッシュ値から計算される微小なノイズを Expression レベルで注入します。
これは単なる乱数による分散ではなく、seed を固定したハッシュ値を用いることで、再計算時にも同じ銘柄には同じノイズが乗り、**計算の再現性(決定論的挙動)**を担保するという設計意図があります。
import polars as pl
# 銘柄コードに基づいた決定論的な微小ノイズ(0.0001〜0.0009)
# seed=42 により、何度実行しても特定の銘柄には常に同じノイズが割り当てられる
noise = (pl.col("code").hash(seed=42) % 10).cast(pl.Float64) * 0.0001
# スコアリング定義の Map (計算意図の宣言)
category_map = {
"value": [pl.lit(0.0) + noise],
"growth": [pl.lit(0.0) + noise],
"trend": [pl.lit(0.0) + noise],
}
4.2. 正規射影(Normal-Mapping)によるスコアの直感化
生の加点方式では、相場環境によって「みんな100点」「みんな0点」といった偏りが発生します。これを誤差関数を用いて正規分布(平均 50, 標準偏差 15)に射影することで、常に「全銘柄中の相対的な位置」を偏差値感覚で把握可能にします。
逆誤差関数 $\text{erf}^{-1}(x)$ は厳密解を初等関数で表せませんが、本実装では Winitzki の近似式を採用しています。
$$
\text{erf}^{-1}(x) \approx \text{sgn}(x) \sqrt{\sqrt{\left(\frac{2}{\pi a} + \frac{\ln(1-x^2)}{2}\right)^2 - \frac{\ln(1-x^2)}{a}} - \left(\frac{2}{\pi a} + \frac{\ln(1-x^2)}{2}\right)}
$$
ここで $a \approx 0.147$ です。この近似を採用した理由は、金融分析において実用上十分な精度(誤差 $10^{-3}$ オーダー)を維持しつつ、超越関数の計算コストを抑え、Polars の Expression パイプライン内で高速に評価させるというトレードオフの最適解だからです。
def normal_map(p_col):
"""パーセンタイル順位を正規分布へ射影する Expression"""
p = p_col.clip(0.0001, 0.9999)
x = 2.0 * p - 1.0 # -1.0 to 1.0
# 逆誤差関数 (erfinv) の Winitzki 近似
sign = x.sign()
ln_term = (1.0 - x * x).log()
a = 0.147
term1 = 2.0 / (3.14159 * a) + ln_term / 2.0
erf_inv = sign * ((term1**2 - ln_term / a).sqrt() - term1).sqrt()
return 50.0 + 15.0 * (1.41421356 * erf_inv)
# 最終スコアリングパスの宣言(pl.len() による最新の記述)
df = df.with_columns(
(pl.col("raw_score").rank() / pl.len()).alias("score_pct")
).with_columns(
normal_map(pl.col("score_pct")).round(2).alias("quant_score")
)
5. 最適化:リソース制限下での「耐性」の確保
マルチコアを全力で使い切る思想の一方で、実稼働環境(Tier2)のような厳しいリソース制限下では、過度な並列化はリソース競合や計算効率の低下を招くリスクがあります。
スレッドの明示的制約
# run_tests_safe.py より抜粋
os.environ["POLARS_MAX_THREADS"] = "1"
os.environ["POLARS_NUM_THREADS"] = "1"
一つの Expression 内での並列度をあえて抑える。その代わりに Producer-Consumer パイプラインによって「取得」と「演算」を高度にオーバーラップさせる。この「引き算」の設計が可能になったのも、計算モデルが Polars によって透過的(Transparent)になったからです。
6. 結果と帰結:速度の向上がもたらした「設計自由度」
ベンチマーク結果は劇的でした。4,000 銘柄・100 日分のデータでボリンジャーバンドを含む指標計算を行った結果、Polars は Pandas より約 58 倍高速という結果を得ました。
実行結果の実測
- Pandas (MultiIndex): 1.74 sec
- Polars (Expression): 0.03 sec
- Result: 58.0x faster (約50倍)
しかし、重要なのは「数秒縮まった」ことではありません。「1.9秒かかる計算」は、毎日 4,000 銘柄を全件スキャンするパイプラインにおいて、他の重い I/O や AI 分析と組み合わせる際の大きな壁(ボトルネック)になります。
0.03秒という「無視できるレベルの計算コスト」に落ち着いたことで、私たちは「もっと複雑な統計指標を足そう」「AI 分析の前にさらに多角的なフィルタリングをかけよう」といった、これまでは 「重すぎて切り捨てていた設計」 を自由に選択できるようになりました。
まとめ:速くなる話ではない、分析の考え方が変わる話である
Pandas から Polars への移行は、単なるライブラリの入れ替えではありません。それは「データの表操作」という手続きから卒業し、「計算意図の定義」へと自らの設計思想を昇格させるプロセスです。
Pandas の MultiIndex や複雑な apply における実装上の課題は、Polars Expression による宣言的記述で解決できる可能性があります。計算の最適化をエンジン側に任せることで、分析ロジックという本来の目的(WHAT)に注力できる設計が可能になります。
日本株データ基盤シリーズ
- 第1弾: Python + yfinance で日本株分析:データ欠損・429エラーを「自己修復」して止まらないスクリーナーを作る
- 第2弾: 欠損の多いyfinanceの RSI をPandas MultiIndex で 4,000 銘柄をだいたい2分で高速復旧する
- 第3弾(本記事)
- 完結編: 10分の壁を突破せよ!全4,400銘柄の財務データ・価格・順位付けを 9分台で完遂する Turbo パイプラインの全貌
- 前編: EDINETの訂正有報を半年取りこぼし、yfinanceは429制限… 30日スキャン+差分キャッシュで作り直した話
- 後編: 債務超過の銘柄が割安ランキング1位に来たので、Polarsと3層フィルタで除外ファイルを分けた
この記事が参考になったら、ストックしておくとシリーズの続きを追いやすくなります。
質問や、別の環境で試した結果はコメントで教えてください。
付録:実測に使用したベンチマークプログラム
本稿のベンチマークは以下の環境で測定されました。
- Python: 3.12.3
- Pandas: 2.3.3
- Polars: 1.39.0
import time
import numpy as np
import pandas as pd
import polars as pl
def benchmark():
# 4,000銘柄 x 100日
num_stocks, num_days = 4000, 100
total_rows = num_stocks * num_days
print(f"📊 Generating data: {num_stocks} stocks x {num_days} days...")
codes = [f"S{i:04d}" for i in range(num_stocks)]
data = {
"code": np.repeat(codes, num_days),
"date": np.tile(pd.date_range("2024-01-01", periods=num_days), num_stocks),
"close": np.random.uniform(100, 10000, total_rows),
"funda_score": np.random.uniform(0, 100, total_rows)
}
pdf = pd.DataFrame(data).set_index(["code", "date"]).sort_index()
ldf = pl.from_pandas(pd.DataFrame(data)).lazy()
# --- Pandas (MultiIndex + Vectorized) ---
print("\n--- Pandas (MultiIndex) ---")
start = time.time()
p_roll = pdf.groupby(level=0)["close"]
p_ma, p_std = p_roll.transform(lambda x: x.rolling(20).mean()), p_roll.transform(lambda x: x.rolling(20).std())
p_final = (pdf["funda_score"] + (pdf["close"] > (p_ma + 2 * p_std)).astype(int) * 10).clip(0, 100)
print(f"Time: {time.time() - start:.4f} sec")
# --- Polars (Expression) ---
print("\n--- Polars (Expression) ---")
start = time.time()
expr_upper = pl.col("close").rolling_mean(20).over("code") + 2 * pl.col("close").rolling_std(20).over("code")
res = ldf.with_columns([
(pl.col("funda_score") + pl.when(pl.col("close") > expr_upper).then(10.0).otherwise(0.0)).clip(0, 100).alias("score")
]).collect()
print(f"Time: {time.time() - start:.4f} sec")
if __name__ == "__main__":
benchmark()
