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 #

AspekOffline storeOnline store
Konsumenpipeline training modelservice scoring real-time
Pola aksesjutaan baris historis sekaligussatu entity per request
Latensi yang diterimadetik sampai menitmilidetik
Isisnapshot fitur lengkap ber-timestampnilai 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:

python
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:

  1. Baris 7-8 (customer = Entity ...): entity adalah satuan yang fiturnya dihitung — di TokoKita satu pelanggan — dan join_keys adalah kolom penghubung ke tabel fitur.
  2. Baris 11-16 (rfm_source = FileSource ...): sumber data adalah output feature pipeline dari chapter Feature Pipeline; timestamp_field menandai kolom yang mencatat kapan setiap nilai fitur berlaku — kunci untuk pelajaran berikutnya.
  3. Baris 19-30 (rfm_fv = FeatureView ...): feature view mengikat nama fitur, tipe data, entity, sumber, dan ttl — masa berlaku nilai fitur sebelum harus dihitung ulang.
  4. 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:

python
# --- 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:

  1. Blok query pertama (naif): subquery max(s2.snapshot_ts) memaksa setiap event memakai snapshot terbaru pelanggannya, apa pun tanggal eventnya.
  2. Blok query kedua (benar): ASOF JOIN bawaan DuckDB memilih baris snapshot terakhir yang snapshot_ts-nya tidak melewati event_ts — definisi point-in-time yang benar.
  3. Baris loop terakhir: setiap pasangan hasil dibandingkan, dan status MEMAKAI MASA DEPAN dicetak bila snapshot yang dipakai join naif terjadi setelah event.

Output yang Dihasilkan:

text
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-time

Dua 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.