Feature pipeline pada chapter sebelumnya menghasilkan snapshot RFM untuk satu model. Masalah muncul ketika jumlah model bertumbuh: tim fraud, tim rekomendasi, dan tim marketing sama-sama menghitung “frekuensi belanja 30 hari” masing-masing dengan sedikit perbedaan, dan setiap service scoring menulis ulang logika yang sama di bahasa yang berbeda. Feature store adalah komponen yang menengahi: satu tempat resmi untuk mendefinisikan, menyimpan, dan menyajikan fitur bagi semua model — untuk pelatihan (offline) maupun untuk serving (online). Chapter ini membedah anatomi feature store, lalu membuktikan pelajaran terpentingnya: point-in-time correctness.
Masalah yang Dipecahkan Feature Store #
| Aspek | Offline store | Online store |
|---|---|---|
| Konsumen | pipeline training model | service scoring real-time |
| Pola akses | jutaan baris historis sekaligus | satu entity per request |
| Latensi yang diterima | detik sampai menit | milidetik |
| Isi | snapshot fitur lengkap ber-timestamp | nilai fitur terbaru per entity |
Dua kolom kanan pada tabel itu menjelaskan mengapa fitur untuk training dan serving sering berbeda nilai di banyak perusahaan: keduanya disimpan di sistem berbeda, dihitung jalur berbeda, dan diperbarui pada waktu berbeda. Feature store menyatukan keduanya dengan satu definisi fitur, sehingga model dilatih dan dilayani dari sumber yang sama — janji yang akan kita uji dari sisi konsistensi nilai pada chapter Feature Consistency.
Anatomi Feature Store ala Feast #
Feast adalah feature store open source yang paling sering dipakai sebagai rujukan desain. Blok berikut adalah konsep definisi fitur mengikuti API Feast, disusun sebagai berkas feature_definitions.py di dalam direktori feature repo; karena pustaka feast tidak terpasang di lingkungan penulisan, kode ini diverifikasi sintaksnya saja dan tidak dieksekusi, sehingga tidak ada blok output untuknya:
from datetime import timedelta
from feast import Entity, FeatureService, FeatureView, Field, FileSource
from feast.types import Float32, Int64
# Entity: satuan yang fiturnya dihitung -- di TokoKita, satu pelanggan.
customer = Entity(name="customer", join_keys=["customer_id"])
# Source: tabel snapshot RFM hasil feature pipeline (offline store berupa file).
rfm_source = FileSource(
name="rfm_snapshot",
path="feature_repo/data/rfm_snapshot.parquet",
timestamp_field="snapshot_ts",
)
# Feature view: definisi fitur + sumber + entity + ttl.
rfm_fv = FeatureView(
name="rfm_features",
entities=[customer],
schema=[
Field(name="recency_days", dtype=Int64),
Field(name="frequency", dtype=Int64),
Field(name="monetary", dtype=Float32),
],
source=rfm_source,
ttl=timedelta(days=1),
)
# Feature service: paket fitur yang dibaca satu use case (model deteksi fraud).
fraud_service = FeatureService(
name="fraud_detection_v1",
features=[rfm_fv[["recency_days", "frequency", "monetary"]]],
)Mari kita telaah baris-baris kode di atas:
- Baris 7-8 (
customer = Entity ...): entity adalah satuan yang fiturnya dihitung — di TokoKita satu pelanggan — danjoin_keysadalah kolom penghubung ke tabel fitur. - Baris 11-16 (
rfm_source = FileSource ...): sumber data adalah output feature pipeline dari chapter Feature Pipeline;timestamp_fieldmenandai kolom yang mencatat kapan setiap nilai fitur berlaku — kunci untuk pelajaran berikutnya. - Baris 19-30 (
rfm_fv = FeatureView ...): feature view mengikat nama fitur, tipe data, entity, sumber, danttl— masa berlaku nilai fitur sebelum harus dihitung ulang. - Baris 33-36 (
fraud_service = ...): feature service memaketkan subset fitur untuk satu use case, sehingga service fraud tidak membaca fitur yang hanya relevan bagi model rekomendasi.
Struktur ini persis yang dipublikasikan dokumentasi resmi Feast : definisi fitur hidup sebagai kode di repositori, bukan sebagai pengaturan di antarmuka.
Pelajaran Kunci: Point-in-Time Correctness #
Saat menyusun dataset training, kita menggabungkan tabel fitur dengan tabel event berlabel. Kesalahan paling halus di sini adalah mengambil snapshot fitur terbaru untuk semua event — padahal event bulan lalu wajib dipasangkan dengan fitur versi bulan lalu juga. Skrip berikut mengeksekusi perbandingannya di DuckDB; blok memotong bagian pembuatan tabel (14 snapshot harian fitur Budi dan Siti, plus 3 event berlabel) dan menampilkan dua query yang menjadi inti pelajaran:
# --- Join naif: pakai snapshot TERBARU untuk semua event ------------------------
naif = duckdb.sql(
"""
SELECT e.event_ts, e.customer_id, e.label,
s.snapshot_ts AS snapshot_dipakai, s.frequency_7d, s.monetary_7d
FROM events e
JOIN snapshots s
ON e.customer_id = s.customer_id
AND s.snapshot_ts = (
SELECT max(s2.snapshot_ts) FROM snapshots s2
WHERE s2.customer_id = e.customer_id
)
ORDER BY e.event_ts
"""
).df()
# --- Point-in-time: snapshot terakhir SEBELUM event (ASOF JOIN) -----------------
benar = duckdb.sql(
"""
SELECT e.event_ts, e.customer_id, e.label,
s.snapshot_ts AS snapshot_dipakai, s.frequency_7d, s.monetary_7d
FROM events e
ASOF JOIN snapshots s
ON e.customer_id = s.customer_id
AND e.event_ts >= s.snapshot_ts
ORDER BY e.event_ts
"""
).df()
fmt = "%Y-%m-%d %H:%M"
print("event label | naif: snapshot -> freq | pit: snapshot -> freq")
for (_, n), (_, p) in zip(naif.iterrows(), benar.iterrows()):
bocor = n.snapshot_dipakai > n.event_ts
status = f"MEMAKAI MASA DEPAN ({(n.snapshot_dipakai - n.event_ts).days} hari)" if bocor else "aman"
print(f"{n.event_ts:{fmt}} {n.label} | {n.snapshot_dipakai:{fmt}} -> {n.frequency_7d:>2}"
f" | {p.snapshot_dipakai:{fmt}} -> {p.frequency_7d:>2} [{status}]")Mari kita telaah bagian penting kode di atas:
- Blok query pertama (
naif): subquerymax(s2.snapshot_ts)memaksa setiap event memakai snapshot terbaru pelanggannya, apa pun tanggal eventnya. - Blok query kedua (
benar):ASOF JOINbawaan DuckDB memilih baris snapshot terakhir yangsnapshot_ts-nya tidak melewatievent_ts— definisi point-in-time yang benar. - Baris loop terakhir: setiap pasangan hasil dibandingkan, dan status
MEMAKAI MASA DEPANdicetak bila snapshot yang dipakai join naif terjadi setelah event.
Output yang Dihasilkan:
event label | naif: snapshot -> freq | pit: snapshot -> freq
2026-10-26 21:30 1 | 2026-10-31 06:00 -> 22 | 2026-10-26 06:00 -> 15 [MEMAKAI MASA DEPAN (4 hari)]
2026-10-28 09:15 0 | 2026-10-31 06:00 -> 11 | 2026-10-28 06:00 -> 9 [MEMAKAI MASA DEPAN (2 hari)]
2026-10-31 20:00 0 | 2026-10-31 06:00 -> 22 | 2026-10-31 06:00 -> 22 [aman]
join naif memakai snapshot masa depan pada 2 dari 3 event; 2 nilai fitur berbeda dari versi point-in-timeDua dari tiga event dilatih dengan fitur masa depan: event 26 Oktober dipasangkan dengan snapshot 31 Oktober yang belum ada saat event terjadi, sehingga frequency_7d yang seharusnya 15 menjadi 22. Ketidakcocokan seperti ini sulit dideteksi karena hasil join tetap rapi — hanya nilainya yang bohong. Inilah alasan timestamp_field pada definisi Feast tadi wajib ada: fitur tanpa timestamp tidak bisa diverifikasi ketersediaannya terhadap event.
Definisi fitur kini terpusat dan join historisnya aman. Persoalan tersisa berpindah ke presisi: apakah jalur offline dan jalur online menghasilkan angka yang persis sama? Chapter Feature Consistency mengujinya dengan eksperimen yang menunjukkan betapa mudahnya dua implementasi berbeda nilai.