推論が数時間→数秒になった話: iterrows をやめる
推論が数時間→数秒になった話: iterrows をやめる
過去走の系列データを整形してニューラルネットに流す推論処理が、全レース分で数時間かかっていました。最終的にこれは数秒になりました。アルゴリズムを変えたわけでも、マシンを増強したわけでもありません。DataFrameを1行ずつ処理するのをやめただけです。この記事では、犯人の特定から、ベクトル化、GPUテンソルの常駐、そして「速くしたら結果が変わっていた」を防ぐ回帰テストまで、実際にやった改善手順を紹介します。
犯人: iterrows + 行単位の特徴整形
遅かったコードは、要約するとこういう形をしていました。
import numpy as np
def build_features_slow(df):
"""1頭ずつ過去走を整形する旧実装"""
rows = []
for _, row in df.iterrows(): # ← 犯人その1
feat = []
for i in range(1, 6): # 過去5走分
time_val = row[f"zenso{i}_time"]
feat.append(float(time_val) if time_val > 0 else 0.0) # ← 犯人その2
feat.append(np.log1p(row[f"zenso{i}_kyori"]))
rows.append(np.array(feat, dtype=np.float32))
return np.stack(rows)
一見素直なコードですが、iterrows は1行ごとにSeriesオブジェクトを生成し、各列アクセスで型変換が走ります。さらに行ごとに float() や np.log1p のような関数呼び出しが積み重なります。1行あたりのオーバーヘッドはマイクロ秒単位でも、数十万行 × 数十特徴では数時間になります。numpyやpandasが速いのはC実装の列演算に処理を任せたときであって、Pythonのforループで回した時点でその恩恵は全部捨てているのです。
まずは思い込みではなく計測で確認します。
import time
t0 = time.perf_counter()
result_slow = build_features_slow(df)
print(f"旧実装: {time.perf_counter() - t0:.2f} 秒")
この計測コードはこの後ずっと使い回すので、最初に書いておくのが結局近道です。
改善1: 列演算で (N, FEATURE_DIM) を一括生成する
行ごとに「1頭分の特徴ベクトル」を作るのではなく、全馬分の行列を列演算で一度に作ります。同じ変換を全行に適用するなら、numpyは1回の呼び出しで済みます。
def build_features_fast(df):
"""列演算で全行を一括整形する新実装"""
n = len(df)
cols = []
for i in range(1, 6):
time_arr = df[f"zenso{i}_time"].to_numpy(dtype=np.float32)
time_arr = np.where(time_arr > 0, time_arr, 0.0) # 行ループの if を where に
kyori_arr = np.log1p(df[f"zenso{i}_kyori"].to_numpy(dtype=np.float32))
cols.extend([time_arr, kyori_arr])
return np.stack(cols, axis=1) # 形状 (N, FEATURE_DIM)
書き換えのコツは機械的です。
- 行ごとの
if→np.whereや boolean mask - 行ごとの関数呼び出し → 配列全体への1回の呼び出し(
np.log1p(arr)) row[col]の繰り返し → 先に.to_numpy()で列を配列として取り出す
ループは「過去5走」という特徴の種類に対する5回だけ残ります。Nに対するループが消えたので、行数が10倍になっても処理時間はほぼ列演算のコストだけです。この時点で数時間が数十秒になりました。
過去走のような可変長系列(3走しかない馬もいる)は、ベクトル化しにくい代表格ですが、定石があります。固定長(例: 5走)に padding して埋め、どこが実データでどこが埋め草かを mask 行列として別に持つ方法です。NN側はmaskを使ってpadding部分を無視します。「可変長だからループで」ではなく「固定長に揃えてmaskで管理」が列演算と相性の良い形です。
改善2: GPU テンソルを常駐させて転送をなくす
NNに流す部分にも同種の無駄がありました。バッチごとにnumpy配列をGPUへ転送していたのです。CPU→GPUの転送は1回ごとに固定コストがかかるため、小さいバッチを何千回も送ると転送時間が支配的になります。
対策は、特徴行列全体を最初に1回だけGPUに載せて、以降はGPU上のindexスライスで切り出すことです。
import torch
# 起動時に1回だけ: 全データを GPU に常駐させる
features_gpu = torch.from_numpy(feature_matrix).to("cuda") # (N, FEATURE_DIM)
def predict_race(model, horse_indices):
"""レースごとの推論は GPU 上のスライスだけで完結する"""
batch = features_gpu[horse_indices] # GPU 内の操作、転送なし
with torch.no_grad():
return model(batch).cpu().numpy()
数十万行 × 数百次元のfloat32でも数百MB程度で、最近のGPUメモリには余裕で載ります。「データをデバイスに置いたまま、計算の指示だけを送る」のはGPU活用の基本形ですが、バッチごとに torch.tensor(batch_df.values).to("cuda") と書いてしまっているコードは意外と多いはずです。
改善3: 正しさはビット一致の回帰テストで担保する
高速化で一番怖いのは、速くなったが出力が静かに変わっていることです。np.where への書き換えで欠損の扱いが変わった、dtypeがfloat64からfloat32になって丸めが変わった、などは普通に起きます。予測モデルの場合、出力がわずかに変わっても例外は出ません。気づかないまま予測の質だけが変わります。
なので、旧実装を消す前に同じ入力で新旧の出力を突き合わせる回帰テストを書きます。原則は許容誤差つき比較ではなくbitwise(完全一致)比較です。
def test_vectorized_matches_legacy():
df = load_fixture_races() # 実データから固定したテスト入力
expected = build_features_slow(df)
actual = build_features_fast(df)
# 原則: ビット一致を要求する
np.testing.assert_array_equal(actual, expected)
「浮動小数なんだから多少ズレてもいい」と最初から allclose に逃げると、本物のバグ(欠損処理の違いなど)を許容誤差が隠してしまいます。まずbitwiseで通す努力をして、演算順序の変更でどうしても一致しない箇所(総和の順序が変わる集約など)だけ、理由をコメントに書いた上で np.testing.assert_allclose(actual, expected, rtol=1e-6) に緩めます。乱数が絡む処理はseedを固定してから比較します。「どこを緩めたか」が明示されているテストと、全体が最初から緩いテストでは、守れるものがまったく違います。
このテストがGREENのまま旧実装を削除できたとき、初めて高速化は完了です。
結果と、速さがもたらすもの
最終的な内訳はこうなりました。
| 段階 | 処理時間 |
|---|---|
| 旧実装(iterrows + 行単位整形) | 数時間 |
| 列演算でベクトル化 | 数十秒 |
| GPU テンソル常駐 + スライス | 数秒 |
実務上の効能は「待ち時間が減った」だけではありません。推論が数時間かかると、特徴量を1つ変えて全期間で検証する実験が1日1回しか回せません。数秒なら何十回でも回せます。実験の回転数が変わると、試せる仮説の数が変わります。高速化は快適さの問題ではなく、モデル改善の生産性そのものに効く投資です。
まとめ
| 手順 | 内容 |
|---|---|
| 計測 | time.perf_counter で犯人を特定してから直す |
| ベクトル化 | iterrows をやめ、列演算で (N, FEATURE_DIM) を一括生成 |
| 可変長対応 | padding + mask で固定長に揃える |
| GPU 常駐 | テンソルを置いたまま index スライス、毎バッチの転送をなくす |
| 回帰テスト | 旧実装と bitwise 比較、緩めるなら理由を明示して allclose |
「Pythonは遅い」のではなく「Pythonのループに仕事をさせるのが遅い」のです。手元の推論や特徴量生成に iterrows が残っていたら、まず計測から始めてみてください。
学習・推論の元になるデータベースは JvLink To Importer で構築できます。
この記事をシェア