Chapter sebelumnya membangun API yang menjawab satu permintaan dalam hitungan milidetik — tetapi tidak semua masalah membutuhkan jawaban secepat itu. Skor kredit seluruh nasabah yang dihitung semalam, perkiraan permintaan gudang tiap Senin pagi, atau penilaian ulang jutaan item katalog seminggu sekali semuanya berbagi satu profil: volumenya besar, tetapi anggaran waktunya longgar. Untuk profil seperti ini, menyala terus sebuah server interaktif adalah pemborosan — pola yang tepat adalah batch inference: proses yang bangun sesuai jadwal, menelan data dalam potongan-potongan besar, menulis hasilnya berkeyingkan ID, lalu mati kembali. Chapter ini merakit pola tersebut lengkap dengan kebiasaan yang membedakan batch yang sehat dari sekadar loop raksasa: pemrosesan per chunk, penulisan hasil yang idempoten, dan penjadwalan yang bisa diulang tanpa rasa takut.

Kapan Pola Batch Unggul #

Tiga pertanyaan cepat menentukan apakah masalah kita masuk kategori batch. Pertama, berapa lama pengguna bersedia menunggu? Jika jawabannya “jam, bukan detik” — misalnya tim kredit baru membaca skor esok pagi — tidak ada alasan membayar latensi interaktif. Kedua, berapa volumenya? Batch memroses baris dalam jumlah sangat besar sekali jalan sehingga biaya per prediksinya nyaris nol: sebuah proses yang menyala 30 menit semalam menggantikan ribuan permintaan API. Ketiga, apakah hasil per baris cukup diperbarui sekali sehari? Jika keputusan bisnis mengikuti siklus harian atau mingguan, pembaruan seketika tidak menambah nilai.

Pola batch juga membawa satu keuntungan yang jarang disadari: kesederhanaan operasional. Tidak ada server yang harus dipantau kesehatannya sepanjang waktu, tidak ada lonjakan trafik yang harus diserap, dan kegagalan cukup ditangani dengan menjalankan ulang proses yang sama — asalkan desainnya idempoten, yang akan kita bahas di bawah.

Membaca Data dalam Potongan (Chunk) #

Data batch jarang muat sekaligus di memori — file skor harian bisa berukuran puluhan gigabyte. pandas menyediakan pola baca bertahap untuk kebutuhan ini: pd.read_csv() dengan parameter chunksize mengembalikan iterator berisi DataFrame kecil, satu potongan setiap iterasi:

python
for potongan in pd.read_csv("antrean.csv", chunksize=200):
    label = pipeline.predict(potongan[DAFTAR_FITUR])

Dua kebiasaan menentukan kualitas pola ini. Pertama, prediksi selalu dilakukan per potongan secara vektorial — pipeline.predict(potongan[DAFTAR_FITUR]) menerima seluruh DataFrame sekaligus. Memakai iterrows() lalu memprediksi baris per baris jauh lebih lambat karena pandas harus mengubah setiap baris menjadi objek Series; pola per-chunk mempertahankan kecepatan operasi vektor NumPy sambil menjaga memori tetap terbatas pada satu potongan. Kedua, urutan dan nama kolom fitur harus konsisten antara pelatihan dan inferensi — di sinilah konstanta FITUR pada skrip berikut memainkan perannya.

Skor Massal 1.000 Baris dalam Lima Chunk #

Skrip berikut memainkan seluruh siklus batch: melatih model dari 400 nasabah historis, menulis 1.000 nasabah produksi ke CSV sebagai simulasi sumber data, lalu membacanya kembali lima potongan × 200 baris, memprediksi tiap potongan, dan menyerukan hasil dalam bentuk dict berkeyingkan nasabah_id.

python
# Batch inference: skor massal 1.000 baris dengan pola chunk 200 baris.
# Sumber data disimulasikan sebagai berkas CSV yang dibaca bertahap dengan
# pd.read_csv(chunksize=...); setiap chunk diprediksi secara vektorial.

import os

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

FOLDER = os.path.dirname(os.path.abspath(__file__))
NAMA_CSV = "antrean-nasabah.csv"
JALUR_CSV = os.path.join(FOLDER, NAMA_CSV)
UKURAN_CHUNK = 200
FITUR = ["usia", "pendapatan_juta", "tenor_bulan", "rasio_cicilan"]

rng = np.random.default_rng(42)

# 1) Data pelatihan berlabel: 400 nasabah historis.
n_latih = 400
usia_latih = rng.uniform(21.0, 65.0, n_latih)
pendapatan_latih = rng.uniform(3.0, 30.0, n_latih)
tenor_latih = rng.integers(3, 25, n_latih)
rasio_latih = rng.uniform(0.05, 0.90, n_latih)
latih = pd.DataFrame({
    "usia": usia_latih,
    "pendapatan_juta": pendapatan_latih,
    "tenor_bulan": tenor_latih,
    "rasio_cicilan": rasio_latih,
})
logit = (-7.5 + 9.0 * rasio_latih + 0.10 * pendapatan_latih
         - 0.02 * usia_latih + 0.08 * tenor_latih)
peluang_positif = 1.0 / (1.0 + np.exp(-logit))
label_latih = (rng.random(n_latih) < peluang_positif).astype(int)
print(f"data latih: {n_latih} baris, "
      f"perlu tinjauan={int(label_latih.sum())} "
      f"({label_latih.mean():.1%} dari label)")

model = Pipeline([
    ("skala", StandardScaler()),
    ("regresi", LogisticRegression(random_state=42)),
])
model.fit(latih[FITUR], label_latih)

# 2) Data produksi tanpa label: 1.000 nasabah, ditulis sebagai CSV
#    untuk mensimulasikan sumber data yang berada di luar memori.
produksi = pd.DataFrame({
    "nasabah_id": [f"NSB-{i:04d}" for i in range(1, 1001)],
    "usia": rng.uniform(21.0, 65.0, 1000),
    "pendapatan_juta": rng.uniform(3.0, 30.0, 1000),
    "tenor_bulan": rng.integers(3, 25, 1000),
    "rasio_cicilan": rng.uniform(0.05, 0.90, 1000),
})
produksi.to_csv(JALUR_CSV, index=False)


def skor_csv(jalur):
    """Baca CSV per chunk, prediksi tiap chunk secara vektorial, kembalikan
    dict {nasabah_id: (label, peluang)}."""
    hasil = {}
    for nomor, potongan in enumerate(pd.read_csv(jalur, chunksize=UKURAN_CHUNK), start=1):
        label = model.predict(potongan[FITUR])
        peluang = model.predict_proba(potongan[FITUR])[:, 1]
        awal = (nomor - 1) * UKURAN_CHUNK
        for id_nasabah, lbl, p in zip(potongan["nasabah_id"], label, peluang):
            hasil[id_nasabah] = ("perlu tinjauan" if lbl == 1 else "aman", round(float(p), 6))
        n_perlu = int((label == 1).sum())
        print(f"chunk {nomor} (baris {awal + 1}-{awal + len(potongan)}): "
              f"{len(potongan)} baris, aman={len(potongan) - n_perlu}, "
              f"perlu tinjauan={n_perlu}")
    return hasil


print("\nskor massal atas antrean-nasabah.csv (chunk 200 baris):")
hasil_skor = skor_csv(JALUR_CSV)
print(f"total: {len(hasil_skor)} nasabah diproses")

# 3) Bentuk DataFrame hasil untuk ditulis kembali ke sistem hilir.
df_hasil = pd.DataFrame(
    [(k, v[0], v[1]) for k, v in sorted(hasil_skor.items())],
    columns=["nasabah_id", "prediksi", "peluang_perlu_tinjauan"],
)
print("\ncuplikan hasil (5 baris pertama):")
print(df_hasil.head(5).to_string(index=False))

jumlah_perlu = int((df_hasil["prediksi"] == "perlu tinjauan").sum())
print(f"\ndistribusi prediksi: aman={len(df_hasil) - jumlah_perlu}, "
      f"perlu tinjauan={jumlah_perlu}")

# 4) Uji idempotensi: jalankan ulang skor atas CSV yang sama dan bandingkan.
hasil_ulang = skor_csv(JALUR_CSV)
print(f"\nuji idempotensi: total={len(hasil_ulang)}, "
      f"identik dengan skor pertama={hasil_ulang == hasil_skor}")

# 5) Bersihkan berkas sementara.
os.remove(JALUR_CSV)
print(f"berkas {NAMA_CSV} dihapus; proses selesai")

Output yang Dihasilkan:

text
data latih: 400 baris, perlu tinjauan=134 (33.5% dari label)

skor massal atas antrean-nasabah.csv (chunk 200 baris):
chunk 1 (baris 1-200): 200 baris, aman=142, perlu tinjauan=58
chunk 2 (baris 201-400): 200 baris, aman=141, perlu tinjauan=59
chunk 3 (baris 401-600): 200 baris, aman=156, perlu tinjauan=44
chunk 4 (baris 601-800): 200 baris, aman=141, perlu tinjauan=59
chunk 5 (baris 801-1000): 200 baris, aman=146, perlu tinjauan=54
total: 1000 nasabah diproses

cuplikan hasil (5 baris pertama):
nasabah_id       prediksi  peluang_perlu_tinjauan
  NSB-0001           aman                0.257614
  NSB-0002           aman                0.371832
  NSB-0003 perlu tinjauan                0.751360
  NSB-0004           aman                0.058569
  NSB-0005           aman                0.001197

distribusi prediksi: aman=726, perlu tinjauan=274
chunk 1 (baris 1-200): 200 baris, aman=142, perlu tinjauan=58
chunk 2 (baris 201-400): 200 baris, aman=141, perlu tinjauan=59
chunk 3 (baris 401-600): 200 baris, aman=156, perlu tinjauan=44
chunk 4 (baris 601-800): 200 baris, aman=141, perlu tinjauan=59
chunk 5 (baris 801-1000): 200 baris, aman=146, perlu tinjauan=54

uji idempotensi: total=1000, identik dengan skor pertama=True
berkas antrean-nasabah.csv dihapus; proses selesai

Mari kita telaah bagian-bagian yang memikul beban utama:

  1. Baris 13–17 (konstanta): JALUR_CSV dibangun dari __file__ agar berkas sementara tercipta di folder chapter ini di mana pun skrip dijalankan. FITUR adalah satu-satunya sumber kebenaran urutan kolom — dipakai baik saat fit (baris 45) maupun saat predict di dalam skor_csv, sehingga pipeline tidak pernah menerima kolom dalam urutan yang berbeda.
  2. Baris 21–39 (data latih): label dibangkitkan dari kombinasi linier fitur melalui fungsi logistik, sehingga model benar-benar memiliki pola untuk dipelajari; cetakan baris 37–39 memperlihatkan proporsi kelas agar pembaca tahu distribusi awalnya.
  3. Baris 49–56 (data produksi sebagai CSV): 1.000 nasabah ditulis ke berkas sebelum dibaca lagi. Langkah ini disengaja — di produksi, sumber data memang berupa berkas atau tabel yang berada di luar memori proses, jadi pola bacanya kita latih apa adanya.
  4. Baris 59–73 (def skor_csv): inti pola chunk. pd.read_csv(jalur, chunksize=UKURAN_CHUNK) menghasilkan iterator; enumerate(..., start=1) memberi nomor potongan untuk pencatatan. Prediksi dan predict_proba dipanggil satu kali per potongan atas potongan[FITUR] — vektorial, tanpa iterrows(). Hasil disimpan ke dict hasil dengan nasabah_id sebagai kunci, sehingga potongan yang sama dibaca ulang hanya menimpa nilai lama, bukan menambah entri baru.
  5. Baris 81–90 (DataFrame hasil): sorted(hasil_skor.items()) menjamin baris hasil terurut berdasarkan ID secara deterministik sebelum head(5).to_string(index=False) mencetaknya rapi tanpa indeks.
  6. Baris 92–95 (uji idempotensi): skor_csv dipanggil kedua kalinya atas CSV yang sama — perhatikan keluaran chunk yang tercetak ulang — lalu kedua dict dibandingkan dengan ==. Kesamaan True membuktikan proses ini aman dijalankan berulang.
  7. Baris 97–99 (pembersihan): berkas sementara dihapus agar folder chapter kembali bersih.

Keluaran tersebut juga menampilkan satu fakta yang layak dicermati: jumlah perlu tinjauan berbeda antar chunk (58, 59, 44, 59, 54). Perbedaan wajar ini berasal dari komposisi acak tiap potongan — dan tepat karena itulah pencatatan per chunk berguna: jika satu potongan tiba-tiba menunjukkan angka yang menyimpang jauh, kita tahu bagian data mana yang perlu diperiksa.

Idempotensi berarti menjalankan proses yang sama berkali-kali meninggalkan efek yang sama dengan menjalankannya sekali. Properti ini bukan kemewahan: penjadwal produksi akan menjalankan ulang batch yang gagal di tengah jalan, dan kesalahan operator memang terjadi. Tiga strategi penulisan hasil memenuhi properti tersebut dengan biaya berbeda:

StrategiMekanismeKelebihanKekurangan
Timpa berdasarkan kunciTulis hasil dengan UPDATE/upsert per nasabah_idHasil selalu terbaru per barisButuh indeks kunci di tabel tujuan
Partisi per tanggalTulis ke partisi tanggal=..., hapus dulu partisi ituRiwayat harian utuh, audit mudahMenyimpan semua versi = biaya penyimpanan
Sisipan dengan kunci unikINSERT dengan primary key (tanggal, nasabah_id)Sederhana, aman konflikKunci duplikat harus ditolak eksplisit

Ketiganya menolak pola berbahaya yang sama: INSERT tanpa kunci ke tabel bersama. Skrip kita memakai varian pertama — dict Python yang ditimpa per kunci — dan uji pada baris 92–95 membuktikan pemilihannya.

Menjadwalkan Eksekusi Batch #

Batch baru bernilai penuh ketika berjalan tanpa campur tangan manusia. Penjadwal paling sederhana di sistem Unix adalah cron — satu baris pada berkas crontab:

bash
# Menit Jam Tanggal Bulan Hari-perintah
30 2 * * * /usr/bin/python3 /opt/ml/skor_nasabah.py

Baris tersebut menjalankan skrip setiap pukul 02.30 — jam sepi ketika sumber data harian biasanya sudah lengkap. Untuk kebutuhan yang lebih besar, orkestrator alur kerja seperti Apache Airflow menjadi standar industri: batch dideklarasikan sebagai DAG (grafik terarah tanpa siklus) berisi tugas bergantungan, lengkap dengan percobaan ulang otomatis, penanda keberhasilan per tanggal, dan tampilan riwayat eksekusi. Airflow tidak dipasang di lingkungan Course, jadi cukup dipahami sebagai perluasan konsep dari cron: satu baris jadwal menjadi grafik tugas yang bisa dipantau. Perhatikan bahwa fitur retry pada kedua dunia tersebut hanya aman karena desain batch kita sudah idempoten — percobaan ulang menimpa, bukan menggandakan.

Setiap malam, proses ini menimbun hasil baru: berjuta skor, probabilitas, dan distribusi kelas per chunk. Pertanyaan yang belum terjawab adalah apakah model yang menghasilkan skor tersebut masih cocok dengan data yang terus berubah — jawabannya menuntut pemantauan distribusi, dan itu materi Chapter Monitoring dan Deteksi Drift.

Referensi Resmi #