Feast 特徵倉:
訓練與上線用同一份特徵
做訓練集時,你得把「事件」跟「特徵」接起來。最順手的寫法是 events.join(每位客戶最新的一筆)——一行搞定,跑得動,不報錯。 但事件發生在 8 天前,「最新的一筆」是今天早上才算出來的:你等於告訴模型「這位客戶 8 天後會怎樣」, 然後要它預測 8 天前的事。先玩玩看這件事有多容易發生——
時間軸上的每一個數字都是 notebook 實測資料裡「客戶 7」的真實快照(他的上游管線在第 4–6 天壞掉, 所以那三天沒有資料)。整份訓練資料有 246 / 360 筆會因為那行 join 拿到不一樣的特徵值。
特徵出事的兩種方式,都不會報錯
模型上線之後表現不如預期,第一個被懷疑的永遠是模型。但很多時候問題在更上游: 特徵是怎麼算出來的。有兩種經典災難,共通點是沒有任何錯誤訊息——
| 訓練/上線不一致(skew) | 資料洩漏(leakage) | |
|---|---|---|
| 發生在哪 | 同一個特徵,兩個地方各算一次 | 同一份訓練資料,join 少寫了時間條件 |
| 典型現場 | 資料科學家用 pandas 算「近 30 天訂單數」,後端工程師照規格用 SQL 再寫一次 | events.join(latest_features)——每位客戶都拿最新一筆 |
| 症狀 | 離線很準,上線就是差一截 | 模型學到一個上線時不存在的世界 |
| 為什麼難抓 | 兩段程式都「對」,只是對不上(時區、含不含當天、null 怎麼處理) | 錯誤散在資料裡,不會集中成一個看得出來的症狀 |
第二種特別陰險,因為它大部分時候是對的。實測 360 筆事件裡,那行 join 有 114 筆剛好給對了(客戶這幾天沒什麼變化),錯的 246 筆散在其中—— 你不會看到一個壞掉的欄位,只會看到一個「好像沒有想像中準」的模型。
差最誇張的一筆:客戶在事件當天的快照是 10 筆訂單、退貨率 0.108, 但那行 join 給模型的是 3 筆、退貨率 0.204——那是事件之後 7.9 天才算出來的數字, 正好記錄了「這位客戶後來不買了」。模型學得很開心,上線時卻永遠拿不到這種資訊。
問題不在寫不寫得出來,在於這段程式住在哪裡。它住在某個人的 notebook 裡, 上線的工程師看不到;半年後加一個特徵,訓練管線、線上服務、批次評分腳本各有一份要改; 而且它沒有名字,沒有人能問「n_orders_30d 是誰定義的、算多久、什麼時候更新」。 特徵倉不是因為 join 難寫才存在,是因為 join 的定義需要一個唯一的家。
到 notebook 的 1️⃣ 節:兩種 join 並排、洩漏分佈圖Entity、FeatureView、Field,加上一個 yaml
三個定義各回答一個問題:Entity=特徵是「誰」的、FeatureView=一組特徵從哪張表來、多久算過期、 Field=每個特徵叫什麼、什麼型別。apply() 把它們寫進 registry—— 從那一刻起這些定義就不屬於某個人的 notebook 了。實測不到 50 毫秒,registry 檔案只有 1.3 KB: 它只存定義,不存資料。
另一個檔案 feature_store.yaml 講的是東西存在哪裡。 看懂這三個角色就看懂特徵倉了:同一份定義,兩個資料倉庫。
| 角色 | 存什麼 | 誰在讀 | 本課用什麼 | 正式環境常見 |
|---|---|---|---|---|
| registry | 定義本身 | 所有人 | 本機 registry.db | S3/GCS 上的一個檔,或 SQL registry |
| offline store | 完整歷史(每天每位客戶) | 訓練、批次評分 | 一張 parquet | BigQuery/Snowflake/Redshift |
| online store | 只有每位客戶最新的一筆 | 線上服務 | 一個 SQLite 檔 | Redis/DynamoDB/Bigtable |
離線那邊要「查得到過去任何一刻」,所以慢而全;線上那邊只要「現在最新值、毫秒內回答」,所以快而薄。 兩邊存的是同一份定義下的同一種特徵——這就是特徵倉的整個設計。 (順帶一提:很多文件叫你在終端機下 feast apply, 但 python -m feast 是跑不起來的——它沒有 __main__。)
到 notebook 的 2️⃣ 節:寫定義、apply、看 registry 裡有什麼一行查詢,換掉整段手寫 join
Feast 對每一列分別去找「那一刻最新的快照」。實測 360 筆事件約 0.1 秒跑完, 跟第 1 節手寫的 point-in-time 版本一致率 100%——它做的就是你手寫的那件事, 差別在於這次它是一個有名字的定義,訓練跟上線都會走同一份。
有兩件事第一次用就要知道,兩件都是靜默的:
| 行為 | 後果 | 怎麼防 |
|---|---|---|
| 進去 360 列,出來 356 列 | isna() 全是 0——不是 NaN,是整列消失 | 查完永遠比對筆數(下一節詳談) |
| 回傳的列順序跟 entity_df 不同 | 用 .values 對齊兩張表會靜默錯位 | 一律用 join key 做 merge/join |
還有一個會讓整批資料默默錯掉的坑:時區。 來源時間戳如果沒有帶時區,Feast 與 pandas 都會把它當成 UTC——不報錯、不警告。 實測把同一批快照的時間往後推 8 小時(就是「台北時間被當成 UTC」的效果), 360 筆事件裡有 59 筆拿到不一樣的特徵值。模型照樣訓練得出來、AUC 照樣有數字, 只是全部建立在錯的特徵上。時區不是格式問題,是資料正確性問題。
到 notebook 的 3️⃣ 節:跟手寫版對答案、時區實測太舊的特徵不給——而且它不是給你 NaN
ttl=timedelta(days=3) 的意思是:一筆快照從被算出來那一刻起只「有效」3 天。 這個設定在回答一個很重要的問題:上游壞掉沒更新時,模型該用舊值硬撐,還是該知道自己不知道?
| ttl | 會發生什麼 |
|---|---|
| 太長 | 上游停更一週,模型還在用一週前的行為預測今天,而且完全不知道自己在用舊資料 |
| 太短 | 正常的更新延遲(批次晚跑一小時)就讓大量樣本沒有特徵 |
| 合理的起點 | 更新週期 × 2~3。每天更新 → 給 3 天;實測改成 1 天,訓練集從 356 筆掉到 341 筆 |
然後是那個你一定要親眼看過一次的行為。ttl 到期時,Feast 的檔案 offline store 不是把特徵填成 NaN,而是把整列從結果裡拿掉:
訓練集靜靜地少了 4 筆,isna() 檢查不出來,shape 你也不會每次都看。 所以請養成習慣:get_historical_features 之後永遠比對筆數,不一樣就丟例外。 而且少掉的那些列本身就是訊號——它們是「上游資料在那段時間壞掉」的客戶, 通常也正是最該被關注的那群人。
到 notebook 的 4️⃣ 節:3 列進 2 列出的實測+時間軸圖同一批事件,兩種特徵,四次訓練
有了 point-in-time 正確的訓練集,剩下就是老套路。切法用時間切不用隨機切—— 隨機切在時間序列資料上本身就是一種洩漏(用未來的事件訓練、預測過去的事件)。 實測訓練集 207 筆、測試集 149 筆,兩個演算法各訓練一次「正確特徵」與一次「洩漏特徵」, 四個模型都在同一批測試事件、同一份 point-in-time 特徵上評估——因為那才是上線時的樣子。
| 演算法 | point-in-time 特徵 AUC | 洩漏特徵 AUC | 差 |
|---|---|---|---|
| LogisticRegression | 0.8757 | 0.8629 | +0.0128 |
| RandomForest(100 棵、depth 4) | 0.8685 | 0.8411 | +0.0274 |
差距沒有你想像的大——這正是重點。洩漏不會讓模型當場崩潰, 它讓模型學到一個上線時不存在的世界,然後你會發現:離線評估的每一個數字看起來都很正常, 沒有任何指標會跳出來說「你的特徵是未來來的」。 你只會在上線後看到模型比預期差一點,然後開始調參數、換演算法、加特徵—— 而真正的問題在 join 的那一行。
(本課的洩漏是「拿到同一位客戶幾天後的行為」,屬於比較溫和的一種。 真正致命的版本是特徵欄位本身就是答案的結果——例如拿「客訴結案原因」去預測客訴會不會發生; 那種洩漏會讓離線分數高到不真實,反而比較容易被發現。越像這一課這種、越難發現。)
到 notebook 的 5️⃣ 節:四次訓練全部記進 MLflowmaterialize 到 online store,毫秒級取特徵
客服系統打電話來:「客戶 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 線上對答案特徵服務、加欄位、現算特徵
v1 模型吃 3 個特徵、v2 加了兩個,線上服務怎麼知道現在該拿哪幾個? FeatureService 就是把「一組特徵」取個名字,讓模型跟特徵清單一起版本化。 線上服務的程式碼裡從此不需要出現任何特徵名稱——只需要知道它服務的是哪個模型版本。
加一個特徵欄位是四個步驟,而第四步是最多人漏掉、也最痛的一步: 來源多一欄 → schema 加一個 Field → apply → 全量 materialize。
| 做完 apply 之後 | 離線 | 線上 |
|---|---|---|
| 什麼都不做 | 馬上有新欄位(每次都重讀來源) | 沒有 |
| 跑 materialize_incremental | 有 | None——水位已經到「現在」,它認為沒有新資料要推 |
| 跑全量 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、錯誤原文速查換你動手
再加一個特徵欄位 orders_per_day,走完完整的四步(改來源 → 加 Field → apply → 全量 materialize)。驗收:線上拿得到值而不是 None。
把 ttl 改成 1 天再 apply,重跑查詢,數數看訓練集少了幾筆,並找出是哪些客戶的事件被吃掉。順便想想:如果你的批次工作偶爾會晚兩小時跑完,ttl 該給多少?
做一個「請求當下才知道的特徵」:用 RequestSource + on-demand feature view 算出「這次購物車金額 ÷ 這位客戶的平均客單價」,線上查詢時把 cart_amount 一起放進 entity_rows。這是特徵倉最實用的模式之一:存起來的歷史特徵 × 這一秒才發生的事。
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
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,程式沒有報錯,但下游的訓練筆數對不上。檢查了一下:
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 過了。離線查得到值,排程也照常跑完,但線上服務拿到的是:
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 直覺得多。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;特徵倉整組跑在 notebook 自己的機器上,不連任何外部服務
不想用 molab?下載 feature-store_ext.py 後在自己電腦
uvx marimo edit --sandbox feature-store_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。