Bagian 2 menetapkan pembanding yang harus dikalahkan: seasonal-naive mingguan dengan MAE 17,29 unit per hari pada 14 hari terakhir. Kini tiba giliran model machine learning, dan langkah pertamanya bukan memilih algoritma melainkan mengubah bentuk data. Deret waktu bukan tabel fitur-target siap latih; kita harus merakitnya sendiri lewat feature engineering: setiap hari dijadikan satu baris, nilai-nilai masa lalu dijadikan fitur, dan nilai hari tersebut dijadikan target regresi. Setelah tabel itu terbentuk, GradientBoostingRegressor mempelajarinya, lalu kita uji apakah model yang belajar sanggup mengalahkan aturan “ulang pekan lalu” — dengan prediksi 14 hari yang dijalankan rekursif agar tidak ada masa depan yang bocor.

Dari Deret ke Tabel: Rekayasa Fitur Lag #

Empat fitur numerik dibangun semata-mata dari masa lalu: lag_1 (nilai kemarin), lag_7 (nilai sepekan lalu), lag_14 (nilai dua pekan lalu), dan rolling_mean_7 (rata-rata tujuh nilai terakhir sebelum hari yang diramal). Karena pembangkit data di Bagian 1 menunjukkan musiman mingguan sebagai struktur dominan, lag kelipatan tujuh menjadi kandidat fitur utama; rolling_mean_7 membawa informasi level terkini sehingga tren naik ikut terbaca. Fitur kalender ditambahkan lewat one-hot encoding tujuh hari-dalam-minggu — Senin sampai Minggu masing-masing menjadi satu kolom bernilai 0 atau 1.

Proses pembentukan fitur memakai shift dan rolling dari pandas dengan satu disiplin ketat: jendela informasi harus berhenti tepat sebelum hari yang diramal. rolling_mean_7 dihitung sebagai shift(1).rolling(7).mean() — digeser satu langkah dulu, baru dirata-ratakan tujuh nilai — sehingga nilai hari yang diramal tidak pernah ikut menghitung fiturnya sendiri. Empat belas baris pertama deret dibuang karena lag_14 dan rolling_mean_7 belum lengkap; tersisa 702 baris latih pada history 716 hari.

Meramal 14 Hari secara Rekursif #

Ramalan 14 hari ke depan tidak bisa dilakukan sekali jalan: lag_1 untuk hari kedua masa depan membutuhkan nilai hari pertama masa depan yang belum terjadi. Solusinya adalah strategi rekursif — prediksi hari pertama dihitung dari history, hasilnya dimasukkan kembali sebagai lag_1 untuk prediksi hari kedua, dan seterusnya sampai hari keempat belas. Prediksi model dengan demikian menjadi masukan bagi model sendiri, meniru persis cara pemakaian di produksi ketika hari yang diramal tiba satu per satu. Pendekatan ini jujur: satu-satunya informasi yang masuk adalah history dan prediksi model sendiri, tidak ada nilai aktual masa depan yang diselundupkan.

python
# Model machine learning untuk forecasting penjualan: rekayasa fitur lag
# (lag 1, 7, 14, rata-rata bergerak 7) + hari-dalam-minggu one-hot, lalu
# GradientBoostingRegressor(random_state=42) dibandingkan dengan tiga
# baseline Bagian 2 pada split kronologis yang sama (14 hari terakhir).
# Prediksi 14 hari dilakukan rekursif: prediksi hari t menjadi masukan
# lag untuk hari t+1 - meniru pemakaian nyata, tanpa membocorkan masa
# depan. Jalankan dari root proyek: python3 src/04_model_ml.py

import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor

HORIZON = 14
HARI = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]

df = pd.read_csv("data/penjualan.csv", parse_dates=["tanggal"])
y = df["penjualan_unit"].to_numpy().astype(float)
dow = df["tanggal"].dt.dayofweek.to_numpy()
tanggal = df["tanggal"].dt.strftime("%Y-%m-%d").to_list()
n_latih = len(y) - HORIZON

# ---------- Rekayasa fitur: hanya dari masa lalu ----------
# Untuk meramal hari t, model hanya boleh melihat nilai sampai t-1:
# lag_1 = y[t-1], lag_7 = y[t-7], lag_14 = y[t-14],
# rolling_mean_7 = rata-rata y[t-7..t-1] (shift(1) memastikan
# jendela bergerak berhenti tepat sebelum hari yang diramal).
s = pd.Series(y)
fitur = pd.DataFrame(
    {
        "lag_1": s.shift(1),
        "lag_7": s.shift(7),
        "lag_14": s.shift(14),
        "rolling_mean_7": s.shift(1).rolling(7).mean(),
    }
)
for d, nama in enumerate(HARI):
    fitur[f"hari_{nama}"] = (dow == d).astype(float)
KOLOM = list(fitur.columns)
X = fitur.dropna().to_numpy()
target = s[14:].to_numpy()

# Baris latih: hari ke-14 sampai akhir data latih (indeks 14..n_latih-1).
X_latih = X[: n_latih - 14]
y_latih = target[: n_latih - 14]
print("== Siapkan data latih model ==")
print(f"jumlah fitur   : {len(KOLOM)} -> {KOLOM}")
print(f"baris latih    : {len(X_latih)} (hari ke-14 s/d ke-{n_latih - 1})")
print(f"baris 0..13    : dibuang, lag_14 dan rolling_mean_7 belum lengkap")

# ---------- Latih model ----------
model = GradientBoostingRegressor(
    n_estimators=150,
    learning_rate=0.1,
    max_depth=3,
    random_state=42,
)
model.fit(X_latih, y_latih)

# ---------- Prediksi rekursif 14 hari ----------
# Riwayat berjalan dimulai dari seluruh data latih; setiap prediksi baru
# ditambahkan ke riwayat sehingga lag hari berikutnya memakai prediksi,
# bukan nilai aktual yang belum terjadi.
riwayat = list(y[:n_latih])
prediksi_model = []
for t in range(n_latih, len(y)):
    r = np.asarray(riwayat)
    baris = [r[-1], r[-7], r[-14], float(r[-7:].mean())]
    baris += [1.0 if dow[t] == d else 0.0 for d in range(7)]
    p = float(model.predict(np.array([baris]))[0])
    prediksi_model.append(p)
    riwayat.append(p)
prediksi_model = np.array(prediksi_model)

# ---------- Baseline pada split yang sama ----------
uji = y[n_latih:]
pred_naive = np.repeat(y[n_latih - 1], HORIZON)
pred_ma7 = np.repeat(y[n_latih - 7:n_latih].mean(), HORIZON)
pred_seas = np.tile(y[n_latih - 7:n_latih], 2)

def mae(aktual, prediksi):
    return float(np.mean(np.abs(aktual - prediksi)))

def rmse(aktual, prediksi):
    return float(np.sqrt(np.mean((aktual - prediksi) ** 2)))

kandidat = [
    ("naive (nilai terakhir)", pred_naive),
    ("rata-rata 7 hari", pred_ma7),
    ("seasonal-naive mingguan", pred_seas),
    ("GradientBoosting + fitur lag", prediksi_model),
]

# ---------- Prediksi harian ----------
print("\n== Prediksi harian: model vs seasonal-naive vs aktual ==")
print(f"{'tanggal':12s} {'aktual':>7s} {'seas':>7s} {'model':>7s}")
for i in range(HORIZON):
    print(
        f"{tanggal[n_latih + i]:12s} {uji[i]:7.0f} {pred_seas[i]:7.1f}"
        f" {prediksi_model[i]:7.1f}"
    )

# ---------- Tabel perbandingan ----------
print("\n== Perbandingan pada split kronologis yang sama ==")
print(f"{'model':32s} {'MAE':>7s} {'RMSE':>7s}")
for nama, pred in kandidat:
    print(f"{nama:32s} {mae(uji, pred):7.2f} {rmse(uji, pred):7.2f}")

mae_seas = mae(uji, pred_seas)
mae_model = mae(uji, prediksi_model)
print(f"\nMAE model vs baseline terbaik : {mae_model:.2f} vs {mae_seas:.2f}")
print(f"perbaikan                     : {(mae_seas - mae_model) / mae_seas:+.1%}")

# ---------- Kepentingan fitur ----------
print("\n== Tingkat kepentingan fitur (feature_importances_) ==")
urut = np.argsort(model.feature_importances_)[::-1]
for i in urut:
    print(f"{KOLOM[i]:18s} {model.feature_importances_[i]:6.3f}")

Output yang Dihasilkan:

text
== Siapkan data latih model ==
jumlah fitur   : 11 -> ['lag_1', 'lag_7', 'lag_14', 'rolling_mean_7', 'hari_Senin', 'hari_Selasa', 'hari_Rabu', 'hari_Kamis', 'hari_Jumat', 'hari_Sabtu', 'hari_Minggu']
baris latih    : 702 (hari ke-14 s/d ke-715)
baris 0..13    : dibuang, lag_14 dan rolling_mean_7 belum lengkap

== Prediksi harian: model vs seasonal-naive vs aktual ==
tanggal       aktual    seas   model
2024-12-17       139   158.0   150.5
2024-12-18       156   149.0   140.4
2024-12-19       152   161.0   155.1
2024-12-20       158   157.0   156.1
2024-12-21       188   189.0   182.1
2024-12-22       203   191.0   177.2
2024-12-23       136   146.0   135.4
2024-12-24       140   158.0   146.9
2024-12-25       100   149.0   139.5
2024-12-26       168   161.0   154.9
2024-12-27       250   157.0   158.6
2024-12-28       196   189.0   190.5
2024-12-29       183   191.0   184.6
2024-12-30       147   146.0   144.2

== Perbandingan pada split kronologis yang sama ==
model                                MAE    RMSE
naive (nilai terakhir)             29.29   40.32
rata-rata 7 hari                   27.78   35.35
seasonal-naive mingguan            17.29   29.61
GradientBoosting + fitur lag       16.09   28.38

MAE model vs baseline terbaik : 16.09 vs 17.29
perbaikan                     : +6.9%

== Tingkat kepentingan fitur (feature_importances_) ==
lag_7               0.516
lag_14              0.283
rolling_mean_7      0.110
lag_1               0.049
hari_Sabtu          0.013
hari_Jumat          0.013
hari_Minggu         0.007
hari_Senin          0.005
hari_Rabu           0.003
hari_Selasa         0.001
hari_Kamis          0.000

Mari kita bedah skrip model machine learning baris-per-baris:

  1. Baris 16–20 (muat dan potong): deret dibaca, n_latih ditetapkan 716 hari — potongan yang sama persis dengan Bagian 2 agar perbandingan adil.
  2. Baris 27–35 (fitur lag): s.shift(k) menggeser deret k langkah sehingga baris hari t memuat nilai hari t−k; rolling_mean_7 memakai shift(1).rolling(7).mean() agar jendela tujuh nilai berhenti di hari t−1.
  3. Baris 36–37 (one-hot hari): loop tujuh hari membuat kolom hari_Senin sampai hari_Minggu bernilai 1,0 pada hari yang cocok dan 0,0 pada lainnya — musiman mingguan kini tersedia sebagai fitur eksplisit.
  4. Baris 39–40 (penyelarasan target): dropna() membuang 14 baris awal, dan s[14:] menjadi target — baris ke-i tabel fitur selaras dengan hari ke-14+i.
  5. Baris 43–44 (iris latih): hanya baris dengan indeks hingga 715 dipakai; 14 baris terakhir deret tidak pernah disentuh saat pelatihan.
  6. Baris 51–57 (latih model): GradientBoostingRegressor(n_estimators=150, learning_rate=0.1, max_depth=3, random_state=42) — pohon keputusan kecil dibangun berturut-turut, tiap pohon mengoreksi galat gabungan pohon sebelumnya; random_state=42 menjamin hasil identik antar eksekusi.
  7. Baris 63–72 (loop rekursif): riwayat berjalan dimulai dari 716 nilai history; tiap iterasi merangkai satu baris fitur dari 14 nilai terakhir riwayat, memprediksi, lalu menambahkan hasil prediksi ke riwayat — inilah mesin ramalan 14 hari yang jujur.
  8. Baris 75–78 (baseline split sama): ketiga baseline Bagian 2 dihitung ulang pada potongan identik sehingga tabel perbandingan membandingkan hal yang sama.
  9. Baris 103–111 (tabel skor): MAE dan RMSE empat kandidat dicetak berjajar, lalu perbaikan model atas baseline terbaik dinyatakan dalam persentase.
  10. Baris 114–117 (kepentingan fitur): feature_importances_ diurutkan menurun — peta mana sinyal yang paling dipakai pohon-pohon model.

Hasil: Model lawan Baseline #

ModelMAE (unit/hari)RMSE (unit/hari)
Naive (nilai terakhir)29,2940,32
Rata-rata 7 hari27,7835,35
Seasonal-naive mingguan17,2929,61
GradientBoosting + fitur lag16,0928,38

Model mengalahkan baseline terbaik dengan MAE 16,09 lawan 17,29 — perbaikan 6,9% — dan RMSE turun dari 29,61 ke 28,38. Prediksi harian memperlihatkan asal perbaikan tersebut: hari Sabtu dan Minggu diramal 182–190 unit (aktual 183–203), jauh lebih dekat daripada ramalan datar baseline naive; hari kerja diramal pada kisaran 135–156 unit yang mengikuti aktual 136–158. Model berhasil karena rolling_mean_7 menangkap level Desember yang tinggi dan fitur hari menandai akhir pekan.

Bagian kepentingan fitur membawa temuan yang tidak terduga sekaligus masuk akal: lag_7 menyedot 0,516 dari total kepentingan dan lag_14 0,283, sementara ketujuh kolom one-hot hari hanya menyumbang 0,000–0,013. Model praktis menemukan sendiri aturan seasonal-naive — nilai sepekan lalu memang berasal dari hari-dalam-minggu yang sama, sehingga informasi hari sudah tertanam di dalam lag_7. Batas model juga terbaca jujur: hari libur 2024-12-25 diramal 139,5 padahal aktual 100, dan promo 2024-12-27 diramal 158,6 padahal aktual 250 — dua galat terbesar yang hanya bisa diperbaiki dengan fitur kalender event, bukan dengan menambah pohon.

Satu split tunggal, secara statistik, belum cukup menjadi bukti. Bisa jadi model beruntung pada 14 hari yang kebetulan ramah, atau sebaliknya dihukum oleh satu event ekstrem. Untuk mengukur keandalan lintas waktu, evaluasi harus diulang pada beberapa periode uji berurutan dengan data latih yang terus bertambah — kerangka walk-forward yang dikerjakan pada Bagian 4: Validasi Walk-Forward.

Referensi Resmi #