MAE dan RMSE merangkum pertandingan menjadi dua angka, tetapi peringkasan itu juga meratakan detail yang paling berharga: kapan model meleset, ke arah mana melesetnya, dan mengapa. Bagian ini membedah residu — selisih aktual dikurangi prakiraan — minggu demi minggu, mencari pola pada tanda dan waktunya, lalu menelusuri galat terbesar sepanjang data. Hasilnya bukan alasan membuang model, melainkan peta kapan model boleh dipercaya dan kapan prakiraannya patut dicurigai.

Residu: Galat yang Bertanda #

Evaluasi pada Bagian 9 memakai galat mutlak, sehingga meleset 1 ke atas dan 1 ke bawah terbaca sama. Residu mengembalikan tandanya, dan tanda itu bermakna: residu positif berarti harga aktual berada di atas prakiraan — model meremehkan kenaikan; residu negatif berarti model melebih-lebihkan harga. Tabel berikut menghitungnya untuk enam minggu uji:

python
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

# Resep pembersihan hasil Bagian 4: duplikat dibuang, '-' diinterpolasi.
df = pd.read_csv("../data/harga_mingguan.csv")
bersih = df.drop_duplicates(subset=["minggu"], keep="first").copy()
bersih["harga_ribu"] = pd.to_numeric(bersih["harga_ribu"], errors="coerce")
bersih = bersih.sort_values("tanggal_mulai").reset_index(drop=True)
bersih["harga_ribu"] = bersih["harga_ribu"].interpolate().round(1)

# Tabel fitur lag hasil Bagian 6, lalu pembagian kronologis 28/6.
fitur = pd.DataFrame(
    {
        "lag_1": bersih["harga_ribu"].shift(1),
        "lag_2": bersih["harga_ribu"].shift(2),
        "minggu_ke": bersih["harga_ribu"].index + 1,
        "target": bersih["harga_ribu"],
        "minggu": bersih["minggu"],
    }
).dropna().reset_index(drop=True)
fitur["minggu_ke"] = fitur["minggu_ke"].astype(int)
latih = fitur.iloc[:-6].copy()
uji = fitur.iloc[-6:].copy()

# Model Bagian 8: LinearRegression pada fitur lag (deterministik).
FITUR_KOLOM = ["lag_1", "lag_2", "minggu_ke"]
model = LinearRegression()
model.fit(latih[FITUR_KOLOM], latih["target"])

prakiraan = model.predict(uji[FITUR_KOLOM])
residu = uji["target"].to_numpy() - prakiraan

tabel = pd.DataFrame(
    {
        "minggu": uji["minggu"],
        "aktual": uji["target"],
        "prakiraan": np.round(prakiraan, 2),
        "residu": np.round(residu, 2),
    }
)
tabel["pembacaan"] = np.where(residu < 0, "terlalu tinggi", "terlalu rendah")
print("Residu = aktual - prakiraan (ribu rupiah per kg):")
print(tabel.to_string(index=False))

Output yang Dihasilkan:

text
Residu = aktual - prakiraan (ribu rupiah per kg):
minggu  aktual  prakiraan  residu      pembacaan
   W31    39.1      40.25   -1.15 terlalu tinggi
   W32    40.2      41.54   -1.34 terlalu tinggi
   W33    42.5      42.23    0.27 terlalu rendah
   W34    43.0      44.24   -1.24 terlalu tinggi
   W35    41.9      43.40   -1.50 terlalu tinggi
   W36    40.9      41.88   -0.98 terlalu tinggi

Lima dari enam residu bernilai negatif; hanya W33 yang positif, dan nilainya paling kecil (0,27). Artinya, sepanjang jendela uji model hampir selalu memprakirakan harga lebih tinggi daripada kenyataan. Bandingkan dengan naive pada Bagian 7 yang galatnya berpindah tanda mengikuti arah tanjakan dan lereng — model kita justru konsisten ke satu arah. Pola searah seperti ini disebut bias, dan bias adalah informasi, bukan sekadar derau: prakiraan yang diketahui cenderung terlalu tinggi tetap berguna asal kecenderungannya diakui.

Lima dari Enam Minggu Prakiraan Terlalu Tinggi #

Seberapa besar kecenderungan itu? Blok berikut merata-ratakan residu jendela uji, lalu mengurutkan residu terbesar pada data latih untuk melihat apakah pola serupa pernah muncul dalam bentuk yang lebih ekstrem:

python
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

# Resep pembersihan hasil Bagian 4: duplikat dibuang, '-' diinterpolasi.
df = pd.read_csv("../data/harga_mingguan.csv")
bersih = df.drop_duplicates(subset=["minggu"], keep="first").copy()
bersih["harga_ribu"] = pd.to_numeric(bersih["harga_ribu"], errors="coerce")
bersih = bersih.sort_values("tanggal_mulai").reset_index(drop=True)
bersih["harga_ribu"] = bersih["harga_ribu"].interpolate().round(1)

# Tabel fitur lag hasil Bagian 6, lalu pembagian kronologis 28/6.
fitur = pd.DataFrame(
    {
        "lag_1": bersih["harga_ribu"].shift(1),
        "lag_2": bersih["harga_ribu"].shift(2),
        "minggu_ke": bersih["harga_ribu"].index + 1,
        "target": bersih["harga_ribu"],
        "minggu": bersih["minggu"],
    }
).dropna().reset_index(drop=True)
fitur["minggu_ke"] = fitur["minggu_ke"].astype(int)
latih = fitur.iloc[:-6].copy()
uji = fitur.iloc[-6:].copy()

# Model Bagian 8: LinearRegression pada fitur lag (deterministik).
FITUR_KOLOM = ["lag_1", "lag_2", "minggu_ke"]
model = LinearRegression()
model.fit(latih[FITUR_KOLOM], latih["target"])

residu_uji = uji["target"].to_numpy() - model.predict(uji[FITUR_KOLOM])
prakiraan_latih = model.predict(latih[FITUR_KOLOM])
residu_latih = latih["target"].to_numpy() - prakiraan_latih

print(f"Bias data uji (rata-rata residu)  : {residu_uji.mean():+.2f} ribu")
print(f"Prakiraan terlalu tinggi di       : {int((residu_uji < 0).sum())} dari 6 minggu uji")
print(f"Residu terbesar data uji          : {residu_uji[int(np.argmax(np.abs(residu_uji)))]:+.2f} pada {uji['minggu'].iloc[int(np.argmax(np.abs(residu_uji)))]}")
print()
print("Empat residu terbesar pada 28 minggu data latih:")
urut = np.argsort(np.abs(residu_latih))[::-1][:4]
for i in urut:
    print(f"  {latih['minggu'].iloc[i]} : aktual {latih['target'].iloc[i]:5.1f}, prakiraan {prakiraan_latih[i]:5.1f}, residu {residu_latih[i]:+5.2f}")

Output yang Dihasilkan:

text
Bias data uji (rata-rata residu)  : -0.99 ribu
Prakiraan terlalu tinggi di       : 5 dari 6 minggu uji
Residu terbesar data uji          : -1.50 pada W35

Empat residu terbesar pada 28 minggu data latih:
  W19 : aktual  51.0, prakiraan  41.4, residu +9.64
  W29 : aktual  38.2, prakiraan  40.8, residu -2.60
  W24 : aktual  40.1, prakiraan  38.1, residu +2.03
  W23 : aktual  37.6, prakiraan  35.9, residu +1.68

Bias jendela uji terbaca −0,99 ribu: rata-rata prakiraan berada hampir 1 ribu di atas harga aktual. Dua mekanisme dari Bagian 8 menjelaskannya. Pertama, bobot momentum 0,899 mendorong prakiraan terus menanjak setelah harga naik — pada W34 dan W35, di puncak tanjakan dan awal lereng, dorongan itu membuat prakiraan melampaui harga yang mulai melandai. Kedua, intersep dan fitur tren menarik prakiraan ke atas menuju level jangka panjangnya ketika harga berada di bawah rata-rata deret 40,6 — itulah yang terjadi pada W31 dan W32 ketika harga berayun di 39–40. Prakiraan yang cenderung sedikit di atas kenyataan justru bisa dibaca sebagai batas atas yang berhati-hati untuk belanja, selama bias sebesar ini tetap dilaporkan.

Pelajaran Terbesar Datang dari Lonjakan W19 #

Baris pertama keluaran kedua adalah temuan terpenting seluruh analisis kesalahan: pada data latih, galat terburuk model mencapai +9,64 ribu pada W19 — harga aktual 51,0 sementara prakiraan hanya 41,4. Minggu-minggu sebelumnya nyaris datar (W17 41,2, W18 41,5); tidak ada sinyal apa pun dalam fitur lag bahwa harga akan melompat 22,9% dalam seminggu. Lonjakan awal musim hujan itu tidak berasal dari pola deret, melainkan dari kejadian di luar data — dan model yang hanya membaca masa lalu tidak mungkin melihatnya datang. Baseline naive mencatat galat +9,50 pada minggu yang sama, sehingga ini bukan kelemahan model kita saja: semua metode berbasis riwayat tersandung di titik yang sama.

Tiga residu besar berikutnya melengkapi polanya. W23 dan W24 (+1,68 dan +2,03) adalah masa pemulihan setelah harga jatuh dari puncak W20 — harga bergerak naik-turun cepat sementara model masih membawa momentum lama. W29 (−2,60) adalah kebalikannya: harga jatuh tajam dari 40,5 ke 38,2, sementara prakiraan masih berdiri di atasnya. Galat terbesar model berkumpul di satu jenis medan saja: titik balik arah yang cepat — masuk lonjakan, keluar lonjakan, dan lereng curam. Grafik berikut memperlihatkan bentuk pola ini pada jendela uji:

python
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

# Resep pembersihan hasil Bagian 4: duplikat dibuang, '-' diinterpolasi.
df = pd.read_csv("../data/harga_mingguan.csv")
bersih = df.drop_duplicates(subset=["minggu"], keep="first").copy()
bersih["harga_ribu"] = pd.to_numeric(bersih["harga_ribu"], errors="coerce")
bersih = bersih.sort_values("tanggal_mulai").reset_index(drop=True)
bersih["harga_ribu"] = bersih["harga_ribu"].interpolate().round(1)

# Tabel fitur lag hasil Bagian 6, lalu pembagian kronologis 28/6.
fitur = pd.DataFrame(
    {
        "lag_1": bersih["harga_ribu"].shift(1),
        "lag_2": bersih["harga_ribu"].shift(2),
        "minggu_ke": bersih["harga_ribu"].index + 1,
        "target": bersih["harga_ribu"],
        "minggu": bersih["minggu"],
    }
).dropna().reset_index(drop=True)
fitur["minggu_ke"] = fitur["minggu_ke"].astype(int)
latih = fitur.iloc[:-6].copy()
uji = fitur.iloc[-6:].copy()

# Model Bagian 8: LinearRegression pada fitur lag (deterministik).
FITUR_KOLOM = ["lag_1", "lag_2", "minggu_ke"]
model = LinearRegression()
model.fit(latih[FITUR_KOLOM], latih["target"])

residu = uji["target"].to_numpy() - model.predict(uji[FITUR_KOLOM])
warna = ["tab:red" if r < 0 else "tab:green" for r in residu]

fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(uji["minggu"], residu, color=warna, width=0.6)
ax.axhline(0, color="black", linewidth=0.8)
ax.axhline(residu.mean(), color="gray", linestyle="--", linewidth=1, label=f"bias {residu.mean():+.2f}")
ax.set_xlabel("minggu uji")
ax.set_ylabel("residu (ribu rupiah per kg)")
ax.set_title("Residu model pada 6 minggu uji: 5 dari 6 di bawah nol")
ax.legend(loc="lower left")
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
plt.show()

Bentuk grafiknya lugas: lima batang merah menjulur ke bawah nol dengan panjang yang mirip (0,98 sampai 1,50), satu batang hijau kecil menjulur ke atas pada W33, dan garis putus-putus abu-abu menandai bias −0,99 di bawah sumbu nol. Batang hijau tunggal itu jatuh tepat pada minggu kenaikan paling tajam — satu-satunya momen model meremehkan harga adalah saat harga bergerak lebih cepat daripada perkiraan mesin momentumnya.

Ringkasan Bagian 10 #

Analisis residu menghasilkan empat pelajaran. Pertama, model melebih-lebihkan harga pada 5 dari 6 minggu uji dengan bias −0,99 ribu — kecenderungan sistematis yang berasal dari dorongan momentum dan tarikan tren, bukan kebetulan acak. Kedua, satu-satunya minggu yang diremehkan (W33, +0,27) adalah minggu kenaikan paling tajam: mesin momentum mengejar tapi tetap tertinggal sedikit. Ketiga, galat terbesar sepanjang data terjadi di titik balik cepat — puncaknya W19 (+9,64) saat lonjakan musim hujan yang tak terlihat oleh fitur lag. Keempat, untuk praktiknya: baca prakiraan model sebagai angka yang cenderung sedikit terlalu tinggi, dan waspadai minggu-minggu saat arah harga berubah cepat.

Kita kini tahu seberapa besar model bisa meleset dan ke arah mana. Ukuran sebaran galat itu sendiri bisa dipakai untuk hal yang lebih berguna daripada penyesalan: membangun kisaran prakiraan yang jujur, pada Bagian 11: Membahas Ketidakpastian .