Claude Media
Claude CodeでFeastの特徴量定義とpoint-in-time joinの抜けを点検する

Claude CodeでFeastの特徴量定義とpoint-in-time joinの抜けを点検する

Feastの特徴量定義を読み取り専用のサブエージェントに点検させ、get_historical_featuresで未来の値が混ざらないかを小さな検証データで確かめる手順です。ttlとtimestamp_fieldの見方も示します。

特徴量ストアのFeastでは、学習データに未来の値が混ざっても、エラーは出ません。オフラインの精度だけが高く出て、本番に出してから落ちます。この種の抜けは、特徴量の定義とデータ取得の呼び出しを突き合わせないと見つかりません。ここでは、定義ファイルの点検を読み取り専用のサブエージェントに任せ、取得結果の検証は小さな検証データで行う手順を示します。Claude Codeの役割は、読む側と動かす側を分けるところにあります。

点検の対象は3か所に分かれる

Feastの特徴量は、時刻つきの記録として扱われます。点検で見る場所は、次の3つです。

点検箇所

未来の値が混ざる経路

  • FeatureViewの定義

    ttlとデータソースのtimestamp_field、created_timestamp_columnの設定です。ここが取得の窓と、使う時刻の列を決めます。

  • 学習データの取得呼び出し

    get_historical_featuresに渡すエンティティ用データフレームの時刻です。各行の時刻が、その行で許される最新の時点を決めます。

  • オンラインとの定義のずれ

    feature_store.yamlの設定と、オンラインストアへ書き込むマテリアライズの運用です。学習時に見た値と、配信時に返る値が同じ意味かを見ます。

get_historical_featuresは各行のevent_timestampを上限(その時刻を含む)として、その時刻以前の最新値を結合します。この制約がないと、予測時点より後に作られた特徴量が学習データに入り、オフライン指標が実力以上に出ます。つまり、取得側の時刻が正しければ、結合そのものはFeastが守ってくれます。抜けるのは、その前提を崩す定義と呼び出しのほうです。

読み取り専用のサブエージェントに定義を読ませる

定義ファイルの点検は、書き換える必要のない作業です。サブエージェントのtoolsにRead, Grep, Globだけを渡せば、編集も実行もできない点検役になります。サブエージェントのファイルは、プロジェクトなら.claude/agents/に置きます。

---
name: feast-auditor
description: Feastのリポジトリを読み、FeatureViewの時刻設定と取得呼び出しの抜けを報告する。特徴量定義の点検で使う。
tools: Read, Grep, Glob
model: sonnet
---
 
あなたはFeastリポジトリの点検役です。ファイルは変更しません。
 
次を順に調べ、ファイルパスと行番号つきの表で報告します。
1. FeatureViewごとに、ttl、source、timestamp_field、
   created_timestamp_columnが指定されているか
2. get_historical_featuresの呼び出しごとに、entity_dfの時刻列が
   何から作られているか(now()の固定値か、イベント時刻か)
3. 同じ特徴量名を学習用とオンライン用で別々に定義していないか
4. 判断できないものは「不明」と書き、推測で埋めない

この書き方は、サブエージェントの例にあるcode-reviewerと同じ型です。toolsを省くと、サブエージェントは使える道具をすべて引き継ぎます。点検役に実行まで許したくない場合は、省かずに列挙してください。名前はfeast-auditorのように、役割が分かるものにします。

呼び出しは、次のように頼みます。

feast-auditorでfeature_repo/配下を点検して。
FeatureViewの一覧と、ttl・timestamp_fieldの有無を表にして。

返ってくる表のうち、特に見るのは「指定なし」の行です。ttlは省略できる設定で、取得の窓を絞る役割を持ちます。点検役には指定の有無だけを報告させ、勝手に補わせないでください。

取得呼び出しの時刻を疑う

定義が正しくても、呼び出し側で崩れることがあります。典型は、エンティティ用データフレームの時刻に、学習対象のイベント時刻でなくnow()を一律で入れてしまう書き方です。バッチ推論では現在時刻を渡すのが正しい使い方ですが、学習データでそれをやると、全行が最新の値を引きます。

Before/Afterで見ると、直す箇所は1行です。

前と後

学習データのエンティティ時刻

BEFORE

現在時刻を一律で入れる

全行のevent_timestampが実行時刻になり、ラベルが確定した後の特徴量まで結合されます。

AFTER

イベント時刻を入れる

行ごとに、予測を出す時点の時刻を入れます。その時刻以前の値だけが結合されます。

サブエージェントには、呼び出し箇所の洗い出しだけを頼みます。entity_dfの作り方がpd.Timestamp.now()やCURRENT_TIMESTAMP()なら、報告に「現在時刻の固定」と書かせます。これは文字列検索で拾えるので、読み取り専用でも十分に働きます。

ttlは「実行時点」でなく各行の時刻から測る

ttlの読み違いも多い箇所です。TTLは、エンティティ用データフレームの各行の時刻から過去へさかのぼる上限です。クエリを実行した時刻からの長さではありません。ttl=timedelta(hours=2)なら、各行の時刻の2時間前までの記録だけが結合の候補になります。

ポイント・イン・タイム結合のページにあるデータフレームの例では、3行が結合され、2行が結合されませんでした。1行は特徴量の最も古い記録より前の時刻で、もう1行は記録から11時間後で窓の外だったためです。結合されなかった行の特徴量は、欠損になります。

ここから、2つの点検項目が出ます。

  • ttlが短すぎると、更新が遅れた特徴量が欠損になり、学習データに穴が空く
  • ttlが長すぎると、古い値を「最新」として使い続け、オンラインの鮮度と食い違う

どちらが正しいかは、特徴量の更新頻度で決まります。サブエージェントには、ttlと更新頻度の対応表を作らせ、更新頻度が書かれていないものを「不明」にして返させてください。

後から入った値が過去に混ざる経路を塞ぐ

もう1つ、見落としやすい経路があります。データソースにcreated_timestamp_columnがあると、同じイベント時刻の行が複数あるとき、作成時刻が新しい行が採用されます。ただし既定では、作成時刻による絞り込みはされません。そのため、イベント時刻より後に補正・追記された値が、過去の行にも返る場合があります。

Feastには、この絞り込みを有効にするオプションがあります。

training_df = store.get_historical_features(
    entity_df=entity_df,
    features=[
        "driver_hourly_stats:trips_today",
        "driver_hourly_stats:earnings_today",
    ],
    filter_by_created_timestamp=True,
)

filter_by_created_timestamp=Trueを付けると、作成時刻がエンティティ行の時刻以前の値だけが結合されます。このオプションは、補充された値が学習データに漏れるのを防ぎ、各時点でオンラインストアが返したはずの値を再現します。

使うときの条件が2つあります。作成時刻がNULLの行は、有効にすると除外されます。また、オフラインストアによってはこのフラグに未対応で、その場合は黙って無視されず、エラーになります。サブエージェントの点検項目には「created_timestamp_columnはあるのに、filter_by_created_timestampを使っていない呼び出し」を加えておくと、バックフィルのある特徴量の漏れを拾えます。

読むだけでは分からない挙動を、小さなデータで確かめる

定義を読んで「正しそうだ」と判断しても、使っているバージョンで想定どおりに動くかは別の話です。そこで、未来の値をわざと混ぜた小さな検証データを作り、結合結果を表明(assert)で確かめます。この作業はファイルを作ってPythonを実行するので、点検役ではなくメインの会話で行います。

検証用のリポジトリは、次の2ファイルから始めます。feature_store.yamlは、Feastのリファレンスにある例と同じ、ローカルで完結する設定です。

project: canary_pit
registry: data/registry.db
provider: local
online_store:
    type: sqlite
    path: data/online_store.db

検証は3つの行で組みます。ポイント・イン・タイム結合の説明にあるデータフレームの例(記録より前、窓の外、通常)に合わせた形です。canary_pit.pyには、データ作成から表明までを通しで書きます。

canary_pit.py の全体
from datetime import datetime, timedelta, timezone
 
import pandas as pd
from feast import Entity, FeatureStore, FeatureView, Field, FileSource
from feast.types import Int64
 
t0 = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
 
# 検証用の特徴量データ。t0+1時間の値(99)は「未来」として入れる
pd.DataFrame({
    "driver_id": [1, 1],
    "event_timestamp": [t0, t0 + timedelta(hours=1)],
    "trips_today": [10, 99],
}).to_parquet("driver_hourly_stats.parquet")
 
driver = Entity(name="driver", join_keys=["driver_id"])
fv = FeatureView(
    name="driver_hourly_stats",
    entities=[driver],
    schema=[Field(name="trips_today", dtype=Int64)],
    ttl=timedelta(hours=2),
    source=FileSource(
        path="driver_hourly_stats.parquet",
        timestamp_field="event_timestamp",
    ),
)
 
store = FeatureStore(repo_path=".")
store.apply([driver, fv])
 
entity_df = pd.DataFrame({
    "driver_id": [1, 1, 1],
    "event_timestamp": [
        t0 - timedelta(hours=1),      # 記録より前: 欠損の想定
        t0 + timedelta(minutes=30),   # 通常: 10 の想定
        t0 + timedelta(hours=11),     # ttl(2h)の外: 欠損の想定
    ],
})
 
df = store.get_historical_features(
    entity_df=entity_df,
    features=["driver_hourly_stats:trips_today"],
).to_df().sort_values("event_timestamp").reset_index(drop=True)
print(df)
 
assert pd.isna(df.loc[0, "trips_today"]), "記録より前の行に値が入っている"
assert df.loc[1, "trips_today"] == 10, "未来の値(99)が混ざっているか、窓の効き方が違う"
assert pd.isna(df.loc[2, "trips_today"]), "ttlの外の行に値が入っている"
print("OK")

実行は、feature_store.yamlと同じディレクトリで行います。

python canary_pit.py

期待する結果は、1行目が欠損、2行目が10、3行目が欠損です。2行目が99になれば、未来の値が混ざっています。欠損が出ない行があれば、窓の効き方が想定と違います。表明が通れば最後にOKが出ます。Claude Codeに書かせるときは「実行して、期待値と実測の差を表にして」と頼むと、差分の読み取りまで済みます。

検証データはpandasで作った仮のものです。実データを使わないので、顧客情報を扱うリポジトリでも安全に回せます。

オンラインとオフラインの定義のずれを探す

学習時と配信時の食い違いは、時刻設定以外にも出ます。マテリアライズのmaterialize_incrementalは、バッチソースにあるエンティティごとの最新値をオンラインストアへ入れます。つまりオンラインは「最新の1点」、オフラインは「各時点の値」を返します。両者は、同じ特徴量でも別のしくみで値を出しています。

点検役に頼むと効くのは、次の観点です。

  • 学習用と配信用で、別々のFeatureViewや別のデータソースを定義していないか
  • オンデマンド特徴量ビューの変換が、取得の両経路で同じコードを通っているか
  • enable_validation=Trueが、必要なFeatureViewに付いているか

3つ目は、有効にするとマテリアライズと履歴取得のときに、宣言した特徴量の列が入力にあるかと型の一致を確かめる設定です。型の不一致は警告として記録されます。つまり、止める仕組みではなく、気づくための仕組みです。

点検の流れをCLAUDE.mdに残す

毎回同じ依頼を書くのは手間なので、点検の約束事をCLAUDE.mdに1節書いておきます。

## Feastの点検
- FeatureViewを変えたら、feast-auditorで時刻設定を点検する
- 学習データの取得は、entity_dfにイベント時刻を使う(now()は禁止)
- created_timestamp_columnがあるソースは filter_by_created_timestamp を検討する
- 検証用データでの結合結果は canary_pit.py で確認する

この約束は、手順を書くだけでは守られません。人間のレビューで見逃した場合に備え、canary_pit.pyをCIに入れて、変更のたびに回す運用もあります。同じ型の点検は、IFCの属性の点検やpg_restoreのリストア検証でも使えます。サブエージェントを読み取り専用にする設計は、読み取り専用のDB分析役の記事に詳しく書いています。

この点検で分からないこと

今回の方法では、拾えないものがあります。

  • 特徴量の元データ自体に混ざった未来の情報。たとえば、ラベル確定後に集計した列をそのまま特徴量にした場合は、時刻設定が正しくても漏れます
  • 結合後の変換での漏れ。全期間の平均で正規化するような処理は、Feastの外側にあります
  • オンラインストアの実際の値。点検役は読むだけなので、本番の値は確かめません

最初の2つは、特徴量を作るSQLや前処理のコードをサブエージェントに読ませる範囲に広げると、見つけやすくなります。ただし、列の意味はコードだけでは決まらないので、疑いのある列の一覧を作らせ、人が確認する形にします。

まとめ

漏れの原因は、ttlの読み違い、now()の固定、作成時刻の未絞り込みの3つに寄ります。定義と呼び出しの洗い出しは読み取り専用のサブエージェントに、結合結果の確認は検証データの実行に分けると、実行権限を広げずに済みます。使っているバージョンで期待どおりに動くことを、先に小さなデータで見ておくと、本番の定義を読むときの判断が速くなります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. docs.feast.dev/getting-started/concepts/point-in-time-joins.md
  3. docs.feast.dev/getting-started/concepts/feature-view.md
  4. docs.feast.dev/getting-started/concepts/feature-retrieval.md
  5. docs.feast.dev/getting-started/concepts/data-ingestion.md
この記事を共有:XはてブLinkedIn