Model dilatih dengan fitur yang dihitung pipeline batch di Python, lalu disajikan fitur yang dihitung ulang oleh service scoring — sering kali ditulis orang berbeda, di bahasa berbeda, bulan berbeda. Bila kedua implementasi menghasilkan nilai yang berbeda sedikit saja, model menerima input yang tidak pernah dilihatnya saat latihan, dan kualitas prediksi menurun tanpa satu pun galat yang tampil di log. Selisih inilah yang disebut training-serving skew, dan chapter ini mendemonstrasikannya dengan dua implementasi yang tampak sama lalu membuktikan perbaikannya: satu implementasi bersama yang dijaga oleh uji paritas otomatis.

Sumber Skew yang Paling Umum #

Skew hampir tidak pernah lahir dari niat; skew lahir dari detail kecil yang diterjemahkan berbeda oleh dua implementasi:

Sumber skewBentuknya di TokoKita
Batas jendela>= versus > pada batas bawah jendela 7 hari
Pembulatanrata-rata float dibulatkan versus pembagian bulat //
Tipe datafloat64 di batch terpaksa jadi int di service
Definisi diam-diam berubahbatch memakai jendela 30 hari, service 31 hari
Zona waktubatch membaca UTC, service membaca waktu lokal

Tabel ini terasa sepele sampai kita ingat bahwa model tidak punya mekanisme untuk curiga: angka 60000 dan 65000 sama-sama sah di matanya, padahal keduanya berasal dari definisi fitur yang berbeda.

Studi Kasus: Dua Implementasi yang Hampir Sama #

Skrip pada berkas scratch menyiapkan 8 pesanan Budi, Siti, dan Dewi selama 7 hari sebelum cutoff 1 November 2026 — dua pesanan sengaja diletakkan tepat di batas bawah dan tepat di bawah batas atas jendela. Blok berikut menampilkan kedua implementasi fitur “rata-rata nilai pesanan 7 hari” beserta perbandingannya:

python
def fitur_batch(orders, cutoff):
    """Jalur training: rata-rata nilai pesanan 7 hari, dihitung pandas."""
    dalam = orders[
        (orders["order_ts"] >= cutoff - JENDELA) & (orders["order_ts"] < cutoff)
    ]
    return dalam.groupby("customer_id")["total"].mean().round(0)


def fitur_serving(orders, cutoff):
    """Jalur serving: implementasi ulang 'versi ringkas' di service scoring."""
    hasil = {}
    for cid, grp in orders.groupby("customer_id"):
        total = 0
        n = 0
        for _, o in grp.iterrows():
            # jebakan 1: batas bawah terbuka -- pesanan tepat di batas terlewat
            if cutoff - JENDELA < o["order_ts"] < cutoff:
                total += o["total"]
                n += 1
        # jebakan 2: pembagian bulat, bukan rata-rata float
        hasil[cid] = total // n if n else 0
    return pd.Series(hasil, name="total").sort_index().astype("int64")


batch = fitur_batch(orders, CUTOFF)
serving = fitur_serving(orders, CUTOFF)

perbandingan = pd.DataFrame({"batch": batch, "serving": serving})
perbandingan["selisih"] = perbandingan["batch"] - perbandingan["serving"]
print("== Sebelum perbaikan: dua implementasi terpisah ==")
print(perbandingan.to_string())
beda = (perbandingan["selisih"] != 0).sum()
print(f"baris dengan nilai berbeda: {beda} dari {len(perbandingan)}")

Mari kita telaah dua sumber perbedaannya:

  1. Baris 6 (>= pada fitur_batch) versus Baris 19 (< pada fitur_serving): pesanan Siti tepat pukul 00:00 di batas bawah jendela dihitung oleh batch tetapi terlewat oleh serving — satu pesanan hilang dari rata-rata.
  2. Baris 9 (.mean().round(0)) versus Baris 22 (total // n): batch membulatkan rata-rata float, serving memotong dengan pembagian bulat; hasil keduanya bisa berselisih satu.
  3. Baris 24-27: kedua hasil digabungkan menjadi satu tabel agar selisihnya terlihat baris demi baris, bukan disembunyikan di dua log berbeda.

Output yang Dihasilkan:

text
== Sebelum perbaikan: dua implementasi terpisah ==
         batch  serving  selisih
Budi  226667.0   226666      1.0
Dewi  165000.0   165000      0.0
Siti   65000.0    60000   5000.0
baris dengan nilai berbeda: 2 dari 3

Dua dari tiga pelanggan menerima nilai fitur berbeda. Selisih Budi hanya 1 (pembulatan), tetapi selisih Siti mencapai 5.000 karena pesanan tepat di batas jendela hilang — dan tidak ada yang salah secara sintaks di kedua kode tersebut.

Perbaikan: Satu Implementasi Bersama #

Obatnya bukan menulis lebih hati-hati, melainkan menghapus duplikasi: service tidak menulis ulang logika, service memanggil fungsi yang sama untuk satu pelanggan pada satu waktu:

python
# --- Perbaikan: satu implementasi bersama ---------------------------------------
# Service tidak menulis ulang logika; service memanggil fungsi yang SAMA,
# hanya untuk satu pelanggan pada satu waktu (permintaan real-time).
serving_fixed = pd.Series(
    {
        cid: fitur_batch(orders[orders["customer_id"] == cid], CUTOFF).iloc[0]
        for cid in orders["customer_id"].unique()
    },
    name="total",
).sort_index().astype("int64")

identik = (serving_fixed == batch.astype("int64")).all()
print()
print("== Setelah perbaikan: serving memanggil fungsi batch yang sama ==")
print(f"semua nilai identik: {identik}")

Output yang Dihasilkan:

text
== Setelah perbaikan: serving memanggil fungsi batch yang sama ==
semua nilai identik: True

Pola ini persis tujuan feature store pada chapter Feature Store: satu definisi, dua jalur akses. Bila organisasi belum memakai feature store, satu modul fitur bersama yang diimpor kedua pihak sudah menutup sebagian besar celah.

Uji Paritas sebagai Regression Test #

Satu implementasi bersama tetap bisa dirusak oleh refactor di masa depan, jadi paritas perlu dijaga test otomatis pada dataset emas — dataset kecil yang sengaja memuat kasus batas. Berkas test_parity.py berikut memotong definisi dataset emas (4 pesanan, termasuk baris tepat di batas bawah) dan menampilkan intinya:

python
def fitur_rata_batch(orders, cutoff):
    """Implementasi tunggal: dipakai training MAUPUN serving."""
    dalam = orders[
        (orders["order_ts"] >= cutoff - JENDELA) & (orders["order_ts"] < cutoff)
    ]
    return dalam.groupby("customer_id")["total"].mean().round(0)


def fitur_rata_serving(orders, cutoff, customer_id):
    """Jalur serving memanggil implementasi tunggal untuk satu pelanggan."""
    satu = orders[orders["customer_id"] == customer_id]
    return fitur_rata_batch(satu, cutoff).loc[customer_id]


def test_nilai_batch_sama_dengan_serving():
    batch = fitur_rata_batch(ORDERS_EMAS, CUTOFF)
    for cid in ORDERS_EMAS["customer_id"].unique():
        assert fitur_rata_serving(ORDERS_EMAS, CUTOFF, cid) == batch.loc[cid]


def test_dtype_konsisten():
    batch = fitur_rata_batch(ORDERS_EMAS, CUTOFF)
    serving = pd.Series(
        {cid: fitur_rata_serving(ORDERS_EMAS, CUTOFF, cid)
         for cid in ORDERS_EMAS["customer_id"].unique()}
    )
    assert batch.astype("int64").dtype == serving.astype("int64").dtype

Test pertama membandingkan nilai kedua jalur untuk setiap pelanggan; test kedua menjaga dtype tetap sama agar model tidak dikejutkan input float padahal dilatih dengan int. Dijalankan dengan pytest , keduanya lulus:

Output yang Dihasilkan:

text
..                                                                       [100%]
2 passed in 1.32s

Angka durasi di baris terakhir bergerak sedikit antar eksekusi (1,32 detik saat tersimpan, 1,33 detik saat diulang untuk verifikasi chapter ini) — hanya waktunya yang berubah, bukan hasilnya. Uji seperti ini sebaiknya berjalan di CI bersama test pipeline lain sebagaimana dibahas chapter Pipeline Testing, dengan konsep assertion yang setara dengan Testing Model Data pada section analytics.

Nilai fitur kini konsisten antar jalur. Pertanyaan berikutnya menyentuh waktu: versi data mana yang dipakai model kemarin, dan bisakah kita memulihkannya persis? Chapter Data Versioning menjawab dengan manifest ber-hash.