Model tidak bisa membaca tabel transaksi mentah. Model membaca fitur: kolom angka ringkas per entitas — misalnya “berapa kali pelanggan ini berbelanja 30 hari terakhir” — yang harus dihitung ulang dari event mentah setiap saat. Pekerjaan menghitung, menjadwalkan, dan mendokumentasikan transformasi tersebut adalah tugas feature pipeline, layer pertama yang kita bangun di antara data warehouse dan model. Chapter ini membangun satu feature pipeline utuh untuk fitur klasik RFM, lalu menunjukkan mengapa identitas fitur harus menyimpan parameter dan versinya, bukan sekadar namanya.
Dari Event Menjadi Fitur #
RFM (recency, frequency, monetary) adalah contoh fitur paling tua sekaligus paling sering dipakai: seberapa baru pelanggan berbelanja, seberapa sering, dan seberapa besar nilainya. Tiga angka tersebut tidak ada di tabel orders; ketiganya adalah hasil agregasi event pesanan per pelanggan dalam jendela waktu tertentu. Feature pipeline adalah job terjadwal yang membaca sumber event, memotong waktunya, lalu menulis hasil agregasi sebagai tabel fitur snapshot — konsep pipeline batch yang sudah kita kenal, tetapi dengan output yang dirancang untuk model, bukan untuk dashboard.
Snapshot dengan Cutoff Waktu #
Skrip lengkap pada berkas scratch membuat 97 pesanan dari 6 pelanggan TokoKita selama 30 hari dengan generator ber-seed, lalu menghitung snapshot RFM per 1 November 2026. Blok berikut memotong bagian generator dan menampilkan inti perhitungannya:
CUTOFF = datetime(2026, 11, 1)
JENDELA = 30 # hari
def hitung_rfm(orders, cutoff, jendela):
"""Recency/frequency/monetary per pelanggan hanya dari data sebelum cutoff."""
mulai = cutoff - timedelta(days=jendela)
dalam = orders[(orders["order_ts"] >= mulai) & (orders["order_ts"] < cutoff)]
agg = dalam.groupby("customer_id").agg(
frequency=("order_id", "count"),
monetary=("total", "sum"),
terakhir=("order_ts", "max"),
)
agg["recency_days"] = (cutoff - agg["terakhir"]).dt.days
return agg[["recency_days", "frequency", "monetary"]].sort_index()
rfm = hitung_rfm(orders, CUTOFF, JENDELA)
print(f"orders: {len(orders)} baris, snapshot per {CUTOFF:%Y-%m-%d}")
print(rfm.to_string())Mari kita telaah baris-baris kode di atas:
- Baris 1-2 (
CUTOFF,JENDELA): cutoff adalah batas atas snapshot, dan jendela 30 hari adalah satu-satunya parameter yang membedakan fitur ini dari fitur RFM berminggu-minggu. - Baris 7 (
dalam = orders[...]): filter memakai interval setengah terbuka — batas bawah ikut (>=), batas atas tidak (<) — sehingga pesanan tepat pukul 00:00 tanggal cutoff masuk ke snapshot berikutnya, bukan snapshot ini. - Baris 8-12 (
agg = dalam.groupby ...): satugroupbymenghasilkan frequency (jumlah pesanan), monetary (total nilai), dan transaksi terakhir tiap pelanggan. - Baris 13 (
recency_days): recency dihitung dari jarak cutoff ke transaksi terakhir; pelanggan yang baru berbelanja kemarin mendapat angka kecil.
Output yang Dihasilkan:
orders: 97 baris, snapshot per 2026-11-01
recency_days frequency monetary
customer_id
Andi 2 11 1905000
Budi 1 16 3340000
Dewi 0 18 3260000
Fajar 3 13 2315000
Rina 0 21 3220000
Siti 0 18 2905000Fitur adalah Kode Ber-Parameter #
Nama rfm_frequency saja tidak cukup mengidentifikasi fitur: frekuensi 30 hari dan frekuensi 7 hari adalah dua fitur berbeda yang kebetulan bersaudara. Karena itu identitas fitur harus menyimpan parameter dan versinya:
# Metadata fitur: nama + parameter + versi. Fitur adalah kode ber-parameter,
# jadi identitasnya harus menyimpan parameternya, bukan hanya namanya.
FITUR = {
"rfm_recency_days": {"sumber": "orders", "jendela_hari": 30, "versi": 1},
"rfm_frequency": {"sumber": "orders", "jendela_hari": 30, "versi": 1},
"rfm_monetary": {"sumber": "orders", "jendela_hari": 30, "versi": 1},
}
print()
print("contoh metadata:", FITUR["rfm_frequency"])Output yang Dihasilkan:
contoh metadata: {'sumber': 'orders', 'jendela_hari': 30, 'versi': 1}Metadata sederhana ini menjadi fondasi bagi chapter-chapter selanjutnya: versi 1 memudahkan kita menaikkan versi saat logika berubah, dan parameter jendela memastikan tidak ada tim yang diam-diam memakai rfm_frequency versi 7 hari untuk model yang dilatih dengan versi 30 hari.
Satu Logika, Dua Mesin #
Perhitungan di atas berjalan di pandas, tetapi bentuk produksinya biasanya SQL pada engine analitik. Kedua bentuk tersebut wajib menghasilkan nilai yang persis sama, jadi skrip menutup dengan mengulang perhitungan yang sama sebagai query DuckDB :
# Jalur SQL yang sama di DuckDB -- bentuk produksi dari perhitungan ini.
rfm_sql = duckdb.sql(
"""
SELECT customer_id,
date_diff('second', max(order_ts), TIMESTAMP '2026-11-01 00:00') // 86400
AS recency_days,
count(*) AS frequency,
sum(total) AS monetary
FROM orders
WHERE order_ts >= TIMESTAMP '2026-11-01 00:00' - INTERVAL 30 DAY
AND order_ts < TIMESTAMP '2026-11-01 00:00'
GROUP BY customer_id
ORDER BY customer_id
"""
).df().set_index("customer_id")
sama = (rfm.to_numpy() == rfm_sql[rfm.columns].to_numpy()).all()
print(f"pandas dan DuckDB menghasilkan nilai yang sama: {sama}")Output yang Dihasilkan:
pandas dan DuckDB menghasilkan nilai yang sama: TrueKlausa WHERE pada query menyalin interval setengah terbuka versi pandas, dan date_diff ... // 86400 membagi selisih detik menjadi hari agar recency berdefinisi sama. Perbandingan sama = ... memverifikasi kedua jalur nilai demi nilai dengan API to_numpy() dari pandas
— pola uji paritas yang akan kita angkat menjadi kewajiban di chapter Feature Consistency.
Fitur kini terhitung, tetapi snapshot RFM ini baru milik satu model dan masih disimpan sebagai file biasa. Ketika puluhan model dan beberapa tim membutuhkan fitur yang sama, kita butuh satu tempat resmi untuk menyimpan, menemukan, dan menyajikannya baik untuk training maupun serving — tugas feature store pada chapter berikutnya.