AI 互動教室 ‹ MLOps 自動化技術
下載 .py 開啟實戰 notebook ↗ 留言回報
FEATURE STORE · 補充 F · 11

Feast 特徵倉:
訓練與上線用同一份特徵

做訓練集時,你得把「事件」跟「特徵」接起來。最順手的寫法是 events.join(每位客戶最新的一筆)——一行搞定,跑得動,不報錯。 但事件發生在 8 天前,「最新的一筆」是今天早上才算出來的:你等於告訴模型「這位客戶 8 天後會怎樣」, 然後要它預測 8 天前的事。先玩玩看這件事有多容易發生——

事件發生在 第 2 天
POINT-IN-TIME:那一刻真的知道的
「拿最新一筆」:塞給模型的未來

時間軸上的每一個數字都是 notebook 實測資料裡「客戶 7」的真實快照(他的上游管線在第 4–6 天壞掉, 所以那三天沒有資料)。整份訓練資料有 246 / 360 筆會因為那行 join 拿到不一樣的特徵值。

01 · 兩個災難

特徵出事的兩種方式,都不會報錯

模型上線之後表現不如預期,第一個被懷疑的永遠是模型。但很多時候問題在更上游: 特徵是怎麼算出來的。有兩種經典災難,共通點是沒有任何錯誤訊息——

訓練/上線不一致(skew)資料洩漏(leakage)
發生在哪同一個特徵,兩個地方各算一次同一份訓練資料,join 少寫了時間條件
典型現場資料科學家用 pandas 算「近 30 天訂單數」,後端工程師照規格用 SQL 再寫一次events.join(latest_features)——每位客戶都拿最新一筆
症狀離線很準,上線就是差一截模型學到一個上線時不存在的世界
為什麼難抓兩段程式都「對」,只是對不上(時區、含不含當天、null 怎麼處理)錯誤散在資料裡,不會集中成一個看得出來的症狀

第二種特別陰險,因為它大部分時候是對的。實測 360 筆事件裡,那行 join 有 114 筆剛好給對了(客戶這幾天沒什麼變化),錯的 246 筆散在其中—— 你不會看到一個壞掉的欄位,只會看到一個「好像沒有想像中準」的模型。

差最誇張的一筆:客戶在事件當天的快照是 10 筆訂單、退貨率 0.108, 但那行 join 給模型的是 3 筆、退貨率 0.204——那是事件之後 7.9 天才算出來的數字, 正好記錄了「這位客戶後來不買了」。模型學得很開心,上線時卻永遠拿不到這種資訊。

# 正確的 point-in-time join,pandas 也寫得出來 # ——但這四個參數少一個就出事 asof = pd.merge_asof( events.sort_values("event_timestamp"), # ← 兩邊都要先排序 snapshots.sort_values("event_timestamp"), # 沒排序直接報錯 on="event_timestamp", by="customer_id", # ← 忘了它,拿到別人的特徵 direction="backward", # ← 忘了它,預設往後找(洩漏) tolerance=pd.Timedelta(days=3), # ← 忘了它,拿到三個月前的特徵 )

問題不在寫不寫得出來,在於這段程式住在哪裡。它住在某個人的 notebook 裡, 上線的工程師看不到;半年後加一個特徵,訓練管線、線上服務、批次評分腳本各有一份要改; 而且它沒有名字,沒有人能問「n_orders_30d 是誰定義的、算多久、什麼時候更新」。 特徵倉不是因為 join 難寫才存在,是因為 join 的定義需要一個唯一的家。

到 notebook 的 1️⃣ 節:兩種 join 並排、洩漏分佈圖
02 · 三個定義

Entity、FeatureView、Field,加上一個 yaml

customer = Entity(name="customer", join_keys=["customer_id"], value_type=ValueType.INT64) daily_source = FileSource( # timestamp_field 說的是 path=PARQUET, # 「這一列是何時算出來的」 timestamp_field="event_timestamp") customer_daily = FeatureView( name="customer_daily", entities=[customer], ttl=timedelta(days=3), # 超過 3 天沒更新就當它不存在 schema=[Field(name="n_orders_30d", dtype=Int64), Field(name="total_30d", dtype=Float32), Field(name="return_rate", dtype=Float32)], source=daily_source, online=True) store = FeatureStore(repo_path=".") store.apply([customer, customer_daily]) # ← 等同 CLI 的 feast apply

三個定義各回答一個問題:Entity=特徵是「誰」的、FeatureView=一組特徵從哪張表來、多久算過期、 Field=每個特徵叫什麼、什麼型別。apply() 把它們寫進 registry—— 從那一刻起這些定義就不屬於某個人的 notebook 了。實測不到 50 毫秒,registry 檔案只有 1.3 KB: 它只存定義,不存資料。

另一個檔案 feature_store.yaml 講的是東西存在哪裡。 看懂這三個角色就看懂特徵倉了:同一份定義,兩個資料倉庫

角色存什麼誰在讀本課用什麼正式環境常見
registry定義本身所有人本機 registry.dbS3/GCS 上的一個檔,或 SQL registry
offline store完整歷史(每天每位客戶)訓練、批次評分一張 parquetBigQuery/Snowflake/Redshift
online store只有每位客戶最新的一筆線上服務一個 SQLite 檔Redis/DynamoDB/Bigtable

離線那邊要「查得到過去任何一刻」,所以慢而全;線上那邊只要「現在最新值、毫秒內回答」,所以快而薄。 兩邊存的是同一份定義下的同一種特徵——這就是特徵倉的整個設計。 (順帶一提:很多文件叫你在終端機下 feast apply, 但 python -m feast 是跑不起來的——它沒有 __main__。)

到 notebook 的 2️⃣ 節:寫定義、apply、看 registry 裡有什麼
03 · POINT-IN-TIME

一行查詢,換掉整段手寫 join

training_df = store.get_historical_features( entity_df=events, # 客戶 id + event_timestamp + 標籤 features=["customer_daily:n_orders_30d", "customer_daily:total_30d", "customer_daily:return_rate"], ).to_df() # 標籤等欄位會原封不動帶回來

Feast 對每一列分別去找「那一刻最新的快照」。實測 360 筆事件約 0.1 秒跑完, 跟第 1 節手寫的 point-in-time 版本一致率 100%——它做的就是你手寫的那件事, 差別在於這次它是一個有名字的定義,訓練跟上線都會走同一份。

有兩件事第一次用就要知道,兩件都是靜默的:

行為後果怎麼防
進去 360 列,出來 356 列isna() 全是 0——不是 NaN,是整列消失查完永遠比對筆數(下一節詳談)
回傳的列順序跟 entity_df 不同.values 對齊兩張表會靜默錯位一律用 join key 做 mergejoin

還有一個會讓整批資料默默錯掉的坑:時區。 來源時間戳如果沒有帶時區,Feast 與 pandas 都會把它當成 UTC——不報錯、不警告。 實測把同一批快照的時間往後推 8 小時(就是「台北時間被當成 UTC」的效果), 360 筆事件裡有 59 筆拿到不一樣的特徵值。模型照樣訓練得出來、AUC 照樣有數字, 只是全部建立在錯的特徵上。時區不是格式問題,是資料正確性問題。

到 notebook 的 3️⃣ 節:跟手寫版對答案、時區實測
04 · TTL

太舊的特徵不給——而且它不是給你 NaN

ttl=timedelta(days=3) 的意思是:一筆快照從被算出來那一刻起只「有效」3 天。 這個設定在回答一個很重要的問題:上游壞掉沒更新時,模型該用舊值硬撐,還是該知道自己不知道?

ttl會發生什麼
太長上游停更一週,模型還在用一週前的行為預測今天,而且完全不知道自己在用舊資料
太短正常的更新延遲(批次晚跑一小時)就讓大量樣本沒有特徵
合理的起點更新週期 × 2~3。每天更新 → 給 3 天;實測改成 1 天,訓練集從 356 筆掉到 341 筆

然後是那個你一定要親眼看過一次的行為。ttl 到期時,Feast 的檔案 offline store 不是把特徵填成 NaN,而是把整列從結果裡拿掉

>>> len(events), len(training_df) (360, 356) >>> training_df[FEATURE_COLS].isna().sum().sum() 0

訓練集靜靜地少了 4 筆,isna() 檢查不出來,shape 你也不會每次都看。 所以請養成習慣:get_historical_features 之後永遠比對筆數,不一樣就丟例外。 而且少掉的那些列本身就是訊號——它們是「上游資料在那段時間壞掉」的客戶, 通常也正是最該被關注的那群人。

到 notebook 的 4️⃣ 節:3 列進 2 列出的實測+時間軸圖
05 · 訓練

同一批事件,兩種特徵,四次訓練

有了 point-in-time 正確的訓練集,剩下就是老套路。切法用時間切不用隨機切—— 隨機切在時間序列資料上本身就是一種洩漏(用未來的事件訓練、預測過去的事件)。 實測訓練集 207 筆、測試集 149 筆,兩個演算法各訓練一次「正確特徵」與一次「洩漏特徵」, 四個模型都在同一批測試事件、同一份 point-in-time 特徵上評估——因為那才是上線時的樣子。

演算法point-in-time 特徵 AUC洩漏特徵 AUC
LogisticRegression0.87570.8629+0.0128
RandomForest(100 棵、depth 4)0.86850.8411+0.0274

差距沒有你想像的大——這正是重點。洩漏不會讓模型當場崩潰, 它讓模型學到一個上線時不存在的世界,然後你會發現:離線評估的每一個數字看起來都很正常, 沒有任何指標會跳出來說「你的特徵是未來來的」。 你只會在上線後看到模型比預期差一點,然後開始調參數、換演算法、加特徵—— 而真正的問題在 join 的那一行。

(本課的洩漏是「拿到同一位客戶幾天後的行為」,屬於比較溫和的一種。 真正致命的版本是特徵欄位本身就是答案的結果——例如拿「客訴結案原因」去預測客訴會不會發生; 那種洩漏會讓離線分數高到不真實,反而比較容易被發現。越像這一課這種、越難發現。

到 notebook 的 5️⃣ 節:四次訓練全部記進 MLflow
06 · 上線

materialize 到 online store,毫秒級取特徵

# 排程每天跑這一行(Feast 自己記水位) store.materialize_incremental(end_date=now) feats = store.get_online_features( features=FEATURE_REFS, entity_rows=[{"customer_id": 7}], ).to_dict() # 跟訓練時讀的是同一份定義 model.predict_proba(pd.DataFrame(feats)[FEATURE_COLS])

客服系統打電話來:「客戶 7 正在線上,他會不會流失?」你有 50 毫秒可以回答。 那張 parquet 查不動,所以 materialize 把資料推進 online store。 實測 online store 只有 24 KB、60 列=20 位客戶 × 3 個特徵(offline 那張表是 192 列的完整歷史)。

量測毫秒
第一次呼叫(含開連線)4.5
之後單筆(20 次)最快/中位/最慢0.13 / 0.16 / 0.25
一次拿 20 位客戶0.87
SQLite 跑在本機的數字,換成 Redis 之後多的是網路來回的時間——看數量級:offline 幾十毫秒起跳,online 次毫秒

這件事必須排程。特徵倉不會自己更新:接上第 4 課的 schedule, 每天批次算完快照之後跟著跑一次 materialize_incremental。 沒排程的下場是 online store 停在某一天,線上服務拿著三個月前的特徵繼續回答,而且不會報錯。

還有一個上線必知的行為:查一個 online store 裡沒有的客戶不會報錯,每個特徵回 None。 新註冊的客戶還沒被 materialize 進來、或某位客戶的上游斷線超過 ttl, 你的服務就會拿到一排 None 然後在下一行炸掉—— 或更糟,被某個 fillna(0) 默默補成 0,模型照樣給出一個看起來很正常的分數。

最後是這一課真正的驗收:同一批客戶、同一個時刻,分別走 get_historical_features(訓練那條路)與 get_online_features(上線那條路), 三個特徵、20 位客戶,每一個值都完全相同。這不是碰巧—— 你沒有寫第二份特徵計算程式,所以根本沒有機會寫出跟訓練不一致的版本。

到 notebook 的 6️⃣ 節:延遲量測、離線 vs 線上對答案
07 · 版本與演進

特徵服務、加欄位、現算特徵

churn_v1 = FeatureService( name="churn_v1", features=[customer_daily[["n_orders_30d", "return_rate"]]]) store.get_online_features( features=store.get_feature_service("churn_v1"), entity_rows=[{"customer_id": 7}]) # → {'customer_id': [7], 'return_rate': [0.2041], # 'n_orders_30d': [3]}

v1 模型吃 3 個特徵、v2 加了兩個,線上服務怎麼知道現在該拿哪幾個? FeatureService 就是把「一組特徵」取個名字,讓模型跟特徵清單一起版本化。 線上服務的程式碼裡從此不需要出現任何特徵名稱——只需要知道它服務的是哪個模型版本。

加一個特徵欄位是四個步驟,而第四步是最多人漏掉、也最痛的一步: 來源多一欄 → schema 加一個 Fieldapply全量 materialize

做完 apply 之後離線線上
什麼都不做馬上有新欄位(每次都重讀來源)沒有
materialize_incrementalNone——水位已經到「現在」,它認為沒有新資料要推
跑全量 materialize(start, end)

這個坑之所以難,是因為它完全沉默:離線測試都對、apply 沒報錯、 materialize_incremental 也「成功」了,只有線上服務拿到一排 None

最後一個工具:on-demand feature view——有些特徵不該存,該現算。 「平均客單價」是 total_30d / n_orders_30d 算出來的, 存一份等於多一個可能跟另外兩欄不同步的地方。定義成一個函式,離線與線上查詢時當場算, 兩邊自動套用同一份轉換邏輯。判準是算它要多久:四則運算、取 log、算比值 → on-demand; 要掃三個月的訂單表 → 乖乖批次算好存起來。

到 notebook 的 7️⃣–9️⃣ 節:FeatureService、加欄位、on-demand、錯誤原文速查
08 · 實戰

換你動手

LEVEL 1

再加一個特徵欄位 orders_per_day,走完完整的四步(改來源 → 加 Fieldapply全量 materialize)。驗收:線上拿得到值而不是 None

LEVEL 2

ttl 改成 1 天再 apply,重跑查詢,數數看訓練集少了幾筆,並找出是哪些客戶的事件被吃掉。順便想想:如果你的批次工作偶爾會晚兩小時跑完,ttl 該給多少?

LEVEL 3

做一個「請求當下才知道的特徵」:用 RequestSource + on-demand feature view 算出「這次購物車金額 ÷ 這位客戶的平均客單價」,線上查詢時把 cart_amount 一起放進 entity_rows。這是特徵倉最實用的模式之一:存起來的歷史特徵 × 這一秒才發生的事

卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。

09 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

你要做一份流失預測的訓練集:一張「每日客戶快照」表,一張「推播事件+30 天後有沒有流失」表。同事說「直接 join 每位客戶最新的一筆特徵就好,反正特徵變化不大」。最佳做法是?

「特徵變化不大」是個假設,而且實測會打臉:360 筆事件裡有 246 筆拿到不一樣的值,最極端的一筆是「10 筆訂單、退貨率 0.108」被換成「3 筆、退貨率 0.204」——那是事件之後 8 天才算出來的數字。模型上線時拿不到未來,所以它學到的規則在上線時根本不存在。A 的交叉驗證抓不到這件事:洩漏在訓練集與驗證集裡是同一種,兩邊都被污染。B 縮短時間窗只是把洩漏變小,沒有消除,而且丟掉大部分訓練資料。D 最危險:洩漏的模型離線分數不一定比較低(實測差距只有 0.01–0.03),拿 AUC 當裁判會選錯,而且你永遠不會知道為什麼上線後不對。

Q2 錯誤診斷

你把 360 筆事件丟給 get_historical_features,程式沒有報錯,但下游的訓練筆數對不上。檢查了一下:

>>> len(events), len(training_df) (360, 356) >>> training_df[FEATURE_COLS].isna().sum().sum() 0

isna() 是 0 卻少了 4 列,這個組合只有一個解釋:那些列根本沒有進到結果裡。Feast 的檔案 offline store 對「事件時間距離最近快照超過 ttl」的列,做的是整列丟棄而不是填 NaN——實測就是上游管線斷線那幾天的客戶。這是本課最該記住的靜默行為,防法只有一個:查完永遠比對筆數len(got) != len(entity_df) 就丟例外)。而且少掉的那些列本身就是訊號,它們指向資料管線壞掉的時段。A 說得通但這裡不成立(事件時間戳是不重複的,而且去重不會挑出這 4 筆);C 的話 Entity 沒定義會在 apply 或查詢時直接報錯,不會安靜地少 4 列;D 沒有根據,parquet 讀取失敗會拋例外而不是少列。

Q3 錯誤診斷

上游多算了一個 avg_amount,你把 parquet 加了欄、schema 加了 Field、也 apply 過了。離線查得到值,排程也照常跑完,但線上服務拿到的是:

>>> store.materialize_incremental(end_date=now) Materializing 1 feature views to ... into the sqlite online store. >>> store.get_online_features( ... features=["customer_daily:avg_amount"], ... entity_rows=[{"customer_id": 7}]).to_dict() {'customer_id': [7], 'avg_amount': [None]}

materialize_incremental 只處理「上次水位到現在」這段新資料。加欄位並不會產生新的時間區間,所以它認為沒事可做、乾乾淨淨地「成功」了,而舊的那些列從來沒被重寫過——線上就永遠是 None。修法是跑一次涵蓋完整歷史的 materialize(start_date, end_date)凡是改了 schema 或改了特徵計算邏輯,都要全量重做一次。A 的 dtype 不符會在 apply 當場報 SpecifiedFeaturesNotPresentError,不會安靜回 None;B 若真的沒開,materialize 根本不會處理這個 view(而且離線也不受影響,症狀不同);C 刪檔案是把問題連同資料一起刪掉的做法,重建之後仍然要 materialize 才有值,而且線上服務會在這段期間全部拿到 None

Q4 情境題

線上推論服務用 get_online_features 取特徵。上線第三天,監控顯示有一小群客戶的流失機率全部落在同一個很低的數值上,但服務沒有任何錯誤紀錄。你查到那群人都是昨天剛註冊的新客戶。最該做的是?

症狀完全對得上「一排 None 被補成同一個預設值」:新客戶還沒被 materialize 進 online store,get_online_features 不報錯、回 None,某段程式把它補成 0,模型就對每個人輸出同一個分數。這種故障最貴的地方是它看起來完全正常。正確做法是把「拿不到特徵」當成一個明確的狀態:不送模型、走降級(給預設策略或轉人工)、並記錄下來當監控指標。A 拉長 ttl 對「從來沒有任何一筆快照」的新客戶沒用,而且會讓所有人都能用到很舊的特徵,是拿一個更大的問題換小問題。C 正是造成這次事故的那一行——0 不是「不知道」,模型會把它當成一個真實的、極端的特徵值。D 方向錯了:離線查詢是為了完整歷史而設計的,延遲差好幾個數量級,而且新客戶在那裡一樣沒有資料。

Q5 情境題

模型要上線了,後端工程師拿到一份規格文件,準備用 SQL 在 API 裡重新實作「近 30 天訂單數/金額/退貨率」三個特徵。你會怎麼建議?

這題就是 training-serving skew 的源頭:只要同一個特徵存在兩份實作,它們遲早會不一致——不是第一天,是半年後某一次只改了其中一邊的時候。特徵倉的第一個承諾就是消除這件事:線上服務呼叫 get_online_features,跟訓練用的 get_historical_features 讀同一份 registry 定義;再用 FeatureService 把「這個模型版本要哪幾個特徵」也綁進去,換模型只要換服務名稱,服務程式一行不用改。實測同一批客戶、同一個時刻,兩條路取出的三個特徵完全相同。B 是把驗證推遲到出事之後,而且「差太多」沒有客觀界線;C 把賭注押在文件與紀律上,這正是實務上最常輸的一種賭;D 方向對(收斂成一份)但選錯了那一份:SQL 寫在 API 裡,批次評分、重新訓練、實驗都用不到它,而且離線要做 point-in-time join 遠比 SQL 直覺得多。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;特徵倉整組跑在 notebook 自己的機器上,不連任何外部服務

不想用 molab?下載 feature-store_ext.py 後在自己電腦 uvx marimo edit --sandbox feature-store_ext.py,依賴會自動安裝。

molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。