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:
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:
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 tinggiLima 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:
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:
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.68Bias 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:
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 .