学習する(改良5:高速化 groupby.apply,rolling,astype(“category”))

バグ修正

データの前処理の高速化を行いました。

いまのボトルネックは主に「groupby.apply(lambda … rolling …)」と「同じ列の何度もソート/astype/fillna」の往復です。副作用なく速くできる“差し替えポイント”を順番に挙げます。


1) groupby.apply(lambda s: s.shift(1).rolling(...)) をやめる

apply+lambda は各グループで Python ループが回るので遅いです。
シフト → もう一度 groupby で束ねて rolling の二段構えにすると C 実装に近くなり速くなります。

def add_rolling_features_fast(df, key, col, windows=(3,5,7)):
    # 事前にグループオブジェクトを用意
    g = df.groupby(key, sort=False)[col]
    prev = g.shift(1)  # 1走前

    # prev を再び key で束ねて rolling(lambda不要)
    gg = prev.groupby(df[key])

    for w in windows:
        ma = gg.rolling(w, min_periods=1).mean().reset_index(level=0, drop=True)
        sd = gg.rolling(w, min_periods=2).std().reset_index(level=0, drop=True)

        df[f"{col}_ma{w}_馬"] = ma.astype("float32")
        df[f"{col}_sd{w}_馬"] = sd.fillna(0).astype("float32")

        diff = (df[col] - df[f"{col}_ma{w}_馬"]).astype("float32")
        df[f"{col}_diff_vs_馬ma{w}"] = diff
        df[f"{col}_z_vs_馬ma{w}"] = (diff / df[f"{col}_sd{w}_馬"].replace(0, np.nan)) \
                                     .astype("float32").fillna(0.0)
    return df

# 置き換え
data = data.sort_values(["馬ID", "日付"], kind="mergesort").reset_index(drop=True)
for col in ["距離", "斤量"]:
    data = add_rolling_features_fast(data, "馬ID", col, windows=(3,5,7))

groupby(...).rolling(...) に渡すシリーズは 一度 shift したもの と覚えると安定します。


2) クラス基準の rolling も同様に最適化

ここも apply+lambda を排除できます。日付順だけ担保すれば 再ソート回数を減らしつつ rolling が可能です。

data["クラスキー"] = data["距離カテゴリ"].astype("category").astype(str) + "_" + data["芝ダ"].astype("category").astype(str)

# 日付だけで全体を安定ソート(mergesort)→ その順序を使ってグループ rolling
data = data.sort_values(["日付"], kind="mergesort").reset_index(drop=True)
gk = data.groupby("クラスキー", sort=False)

for col in ["距離", "斤量"]:
    prev = gk[col].shift(1)
    gg = prev.groupby(data["クラスキー"])
    for w in (3,5,7):
        ma = gg.rolling(w, min_periods=1).mean().reset_index(level=0, drop=True).astype("float32")
        data[f"{col}_mean_クラス過去_ma{w}"] = ma
        data[f"{col}_diff_vs_クラス_ma{w}"] = (data[col] - ma).astype("float32")

# もうクラスキーは不要
data.drop(columns=["クラスキー"], inplace=True)
  • 既存コードのように クラスキー→日付 で再ソートし直す必要はありません。
    日付 で一度だけ安定ソートすれば、同一クラス内の並びも日付昇順になり、groupby の順序が保たれます。

3) 体重系 rolling も同じ最適化

g = data.groupby("馬ID", sort=False)
bw = g["馬体重"]
prev_bw = bw.shift(1)

data["馬体重_diff_prev"] = (data["馬体重"] - prev_bw).astype("float32")
data["馬体重_pct_prev"]  = (data["馬体重_diff_prev"] / prev_bw.replace(0, np.nan)).astype("float32")

prev = bw.shift(1)
gg = prev.groupby(data["馬ID"])
ma5 = gg.rolling(5, min_periods=1).mean().reset_index(level=0, drop=True)
sd5 = gg.rolling(5, min_periods=2).std().reset_index(level=0, drop=True)

data["馬体重_ma5_馬"] = ma5.astype("float32")
data["馬体重_sd5_馬"] = sd5.fillna(0).astype("float32")
data["馬体重_z_vs_馬ma5"] = (
    (data["馬体重"] - data["馬体重_ma5_馬"]) / data["馬体重_sd5_馬"].replace(0, np.nan)
).astype("float32").fillna(0.0)

# diff の移動平均も lambda 排除
diff = data["馬体重_diff_prev"]
ggd = diff.groupby(data["馬ID"])
for k in (2,3,5):
    data[f"馬体重_diff_prev_ma{k}"] = ggd.rolling(k, min_periods=1).mean() \
        .reset_index(level=0, drop=True).astype("float32").fillna(0.0)

5) 文字列→カテゴリ化でメモリ & 速度改善

頻出の離散列は category に。value_counts() や結合、groupby が軽くなります。

ただし、一度 pd.Series に変換してから astype("category") してください。
もしくは最初から pd.cut を使えば DataFrame の列として直接 category 型にできます。


年齢層修正

# 年齢層(np.select の場合)
data["年齢層"] = pd.Series(
    np.select(
        [data["年齢"] <= 3, data["年齢"] <= 5],
        ["若駒", "壮年"],
        default="ベテラン"
    ),
    index=data.index
).astype("category")

代替: pd.cut を使う場合(おすすめ)

data["年齢層"] = pd.cut(
    data["年齢"],
    bins=[-np.inf, 3, 5, np.inf],
    labels=["若駒", "壮年", "ベテラン"]
).astype("category")

こちらの方がシンプルで pandas ネイティブなので速いです。

距離カテゴリ修正

np.select(...).astype("category")
👉 pd.Series(..., index=data.index).astype("category") にすれば解決します。


修正版

# 距離カテゴリ
data["距離カテゴリ"] = pd.Series(
    np.select(
        [data["距離"] <= 1400, data["距離"] <= 1800, data["距離"] <= 2200],
        ["スプリント", "マイル", "中距離"],
        default="長距離"
    ),
    index=data.index
).astype("category")

さらにシンプルにしたいなら

pd.cut を使うと、最初からカテゴリで出てくるので .astype("category") も不要です:

data["距離カテゴリ"] = pd.cut(
    data["距離"],
    bins=[-np.inf, 1400, 1800, 2200, np.inf],
    labels=["スプリント", "マイル", "中距離", "長距離"]
)

Categorical修正

fill_cols の中に Categorical 型の列があり、そこへ未登録カテゴリ(”不明”)で fillna("不明") したためです。Categorical は 事前にカテゴリへ追加しないと新しい値を入れられません。

安全な修正コード(列ごとに分岐)

from pandas.api.types import is_categorical_dtype

fill_cols = ['血統得意距離', '血統得意脚質', '血統得意馬場', '血統得意芝ダ']

for col in fill_cols:
    if col not in data.columns:
        continue
    s = data[col]
    if is_categorical_dtype(s):
        # 既存がカテゴリなら "不明" をカテゴリに追加してから埋める
        data[col] = s.cat.add_categories(["不明"]).fillna("不明")
        # 余分なカテゴリを掃除したい場合は次を有効化
        # data[col] = data[col].cat.remove_unused_categories()
    else:
        # 非カテゴリなら普通に埋めて、必要ならカテゴリ化
        data[col] = s.fillna("不明").astype("category")

ポイント

  • Categorical 列に新しい値を入れるときは cat.add_categories([...]) が必須。
  • すでにカテゴリ化している列は 順序(ordered) も維持されます(add_categories は順序を壊しません)。
  • もし最初から「欠損は必ず”不明”にする」運用なら、先に fill → 後で一括で astype("category") にすると楽です。





  

バグ修正

「脚質」列に関して、カテゴリに「0」が含まれていないためエラーが発生しています。原因は 脚質Categorical 型のまま fillna("0") していて、カテゴリに "0" が未登録だからです。
→ 先に "0" をカテゴリへ追加してから埋めればOK。ついでに将来の同種エラーも避ける安全&高速パターンで書きます。

import pandas as pd

# 直前1走の脚質
s = data.groupby("馬ID", sort=False)["脚質"].shift(1)

if isinstance(s.dtype, pd.CategoricalDtype):
    # "0" が未登録なら追加してから埋める
    if "0" not in s.cat.categories:
        s = s.cat.add_categories(["0"])
    data["脚質過去"] = s.fillna("0")
else:
    # 非カテゴリなら普通に埋めてからカテゴリ化(必要なら)
    data["脚質過去"] = s.fillna("0").astype("category")

コメント

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