เจาะลึกการพยากรณ์อนุกรมเวลาด้วย TimesFM 2.5: จาก Backtesting ถึงการตรวจจับความผิดปกติ

ในบทช่วยสอนนี้ เราจะสร้างเวิร์กโฟลว์การพยากรณ์อนุกรมเวลาขั้นสูงแบบ end-to-end ด้วย TimesFM 2.5 โดยเริ่มตั้งแต่การกำหนดค่ารันไทม์ การติดตั้ง dependencies ที่จำเป็น และการตรวจหาฮาร์ดแวร์ที่พร้อมใช้งาน
เราได้สร้างชุดข้อมูลการค้าปลีกแบบหลายร้านค้าที่จำลองสถานการณ์สมจริง ซึ่งประกอบด้วยแนวโน้ม (Trend), ความเป็นฤดูกาล (Seasonality), ราคา, โปรโมชัน, วันหยุด, ผลกระทบจากอุณหภูมิ และความผันแปรแบบสุ่ม เพื่อใช้ในการทดสอบประสิทธิภาพของโมเดล
จากนั้นเราจะทำการโหลดและคอมไพล์โมเดล TimesFM 2.5 เพื่อตรวจสอบการกำหนดค่าการพยากรณ์ และใช้งานสำหรับการพยากรณ์ทั้งแบบจุด (Point Forecast) และแบบความน่าจะเป็น (Probabilistic Forecast) ในรูปแบบ zero-shot
นอกจากนี้ เรายังจะประเมินคุณภาพการพยากรณ์ด้วยตัวชี้วัดที่หลากหลาย เช่น MAE, RMSE, sMAPE, MASE, pinball loss และความครอบคลุมของช่วงการพยากรณ์ (Prediction-interval coverage) เพื่อให้มั่นใจในความแม่นยำของโมเดล
FAST_MODE = False
SEED = 7
import subprocess, sys, os, time, json, math, warnings
warnings.filterwarnings("ignore")
def _pip(*args):
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", *args])
try:
import timesfm
except ImportError:
print("Installing timesfm[torch] ... (~1-2 min)")
_pip("timesfm[torch]")
import timesfm
import numpy as np
import pandas as pd
import torch
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.set_float32_matmul_precision("high")
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print("=" * 78)
print(f"timesfm : {getattr(timesfm, '__version__', 'n/a')}")
print(f"torch : {torch.__version__}")
print(f"device : {DEVICE}")
if DEVICE == "cuda":
print(f"gpu : {torch.cuda.get_device_name(0)} "
f"({torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB)")
print("=" * 78)
try:
import jax
from sklearn import preprocessing
HAS_XREG_DEPS = True
except Exception as e:
HAS_XREG_DEPS = False
print(f"[warn] XReg deps missing ({e}); section 10 will be skipped.")
N_DAYS = 1200
N_STORES = 6
REGIONS = ["north", "north", "south", "south", "coast", "coast"]
dates = pd.date_range("2021-01-01", periods=N_DAYS, freq="D")
t = np.arange(N_DAYS)
dow = dates.dayofweek.values
doy = dates.dayofyear.values
temp_base = 18 + 12 * np.sin(2 * np.pi * (doy - 105) / 365.25)
temp = temp_base + np.cumsum(np.random.normal(0, 0.6, N_DAYS)) * 0.15
temp = temp - np.linspace(0, temp[-1] - temp_base[-1], N_DAYS)
holiday_doy = {1, 2, 45, 100, 120, 185, 240, 300, 358, 359, 360, 361, 362, 363, 364, 365}
is_holiday = np.isin(doy, list(holiday_doy)).astype(int)
rows = []
for s in range(N_STORES):
level = 180 + 60 * s
slope = np.random.uniform(0.02, 0.09)
week_amp = np.random.uniform(15, 35)
year_amp = np.random.uniform(20, 45)
elasticity = np.random.uniform(18, 32)
promo_lift = np.random.uniform(35, 70)
temp_beta = np.random.uniform(0.8, 2.2)
phase = np.random.uniform(0, 2 * np.pi)
base_price = np.random.uniform(9.0, 13.0)
price = base_price + np.random.normal(0, 0.25, N_DAYS)
promo = (np.random.rand(N_DAYS) < 0.09).astype(int)
price = price - promo * np.random.uniform(1.2, 2.2)
weekly = week_amp * np.array([0.9, 0.7, 0.7, 0.85, 1.25, 1.8, 1.5])[dow]
yearly = year_amp * np.sin(2 * np.pi * doy / 365.25 + phase)
sales = (level
+ slope * t
+ weekly
+ yearly
- elasticity * (price - base_price)
+ promo_lift * promo
+ 55 * is_holiday
+ temp_beta * (temp - 18)
+ np.random.normal(0, 14, N_DAYS))
sales = np.clip(sales, 5, None)
rows.append(pd.DataFrame({
"date": dates,
"store": f"store_{s}",
"region": REGIONS[s],
"sales": sales.astype(np.float32),
"price": price.astype(np.float32),
"promo": promo.astype(np.int32),
"holiday": is_holiday.astype(np.int32),
"dow": dow.astype(np.int32),
"temp": temp.astype(np.float32),
}))
df = pd.concat(rows, ignore_index=True)
STORES = sorted(df["store"].unique())
print(f"\nDataset: {df.shape[0]:,} rows | {len(STORES)} stores | "
f"{dates[0].date()} → {dates[-1].date()}")
print(df.head(3).to_string(index=False))
wide = df.pivot(index="date", columns="store", values="sales")
SEASON = 7
HORIZON = 56
print("\nLoading google/timesfm-2.5-200m-pytorch ...")
t0 = time.time()
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
"google/timesfm-2.5-200m-pytorch"
)
print(f"loaded in {time.time() - t0:.1f}s")
BASE_CFG = dict(
max_context=1024,
max_horizon=256,
normalize_inputs=True,
per_core_batch_size=16,
use_continuous_quantile_head=True,
force_flip_invariance=True,
infer_is_positive=True,
fix_quantile_crossing=True,
return_backcast=False,
)
model.compile(timesfm.ForecastConfig(**BASE_CFG))
print("compiled:", {k: v for k, v in BASE_CFG.items() if k in
("max_context", "max_horizon", "per_core_batch_size")})
def recompile(**overrides):
cfg = {**BASE_CFG, **overrides}
model.compile(timesfm.ForecastConfig(**cfg))
return cfgเรากำหนดค่าสภาพแวดล้อม Google Colab และติดตั้ง TimesFM รวมถึงไลบรารีที่เกี่ยวข้อง พร้อมตั้งค่า random seeds เพื่อให้ผลลัพธ์มีความคงตัวในการทดสอบทุกครั้ง
กระบวนการประกอบด้วยการโหลดโมเดล TimesFM 2.5 การกำหนดค่าการพยากรณ์พื้นฐาน และการคอมไพล์โมเดล เพื่อเตรียมพร้อมสำหรับการสร้างฟังก์ชันที่ใช้ซ้ำได้ในการทดลองขั้นถัดไป
IDX_MEAN, IDX_Q10, IDX_Q50, IDX_Q90 = 0, 1, 5, 9
QUANTILES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
target_store = STORES[0]
series = wide[target_store].values.astype(np.float32)
train, actual = series[:-HORIZON], series[-HORIZON:]
point, quant = model.forecast(horizon=HORIZON, inputs=[train.copy()])
print(f"\npoint {point.shape} # (n_series, horizon)")
print(f"quantile {quant.shape} # (n_series, horizon, 10)")
fig, ax = plt.subplots(figsize=(14, 5))
hist_n = 180
ax.plot(dates[-HORIZON - hist_n:-HORIZON], train[-hist_n:], color="#334155",
lw=1.2, label="history")
ax.plot(dates[-HORIZON:], actual, color="#0f172a", lw=1.6, label="actual")
ax.plot(dates[-HORIZON:], point[0], color="#ea580c", lw=2, label="TimesFM median")
for lo, hi, a in [(1, 9, .12), (2, 8, .16), (3, 7, .20), (4, 6, .24)]:
ax.fill_between(dates[-HORIZON:], quant[0, :, lo], quant[0, :, hi],
color="#ea580c", alpha=a, lw=0)
ax.axvline(dates[-HORIZON], color="#94a3b8", ls="--", lw=1)
ax.set_title(f"TimesFM 2.5 zero-shot — {target_store}, {HORIZON}-day horizon "
f"(fan = q10…q90)")
ax.legend(loc="upper left")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
plt.tight_layout()
plt.show()
print("\n--- output anatomy ---")
print("index 0 = MEAN (not q0!). indices 1..9 = q10..q90. index 5 = median.")
print("point_forecast is literally quantile[..., 5]:",
np.allclose(point, quant[..., IDX_Q50]))
print("monotone quantiles (fix_quantile_crossing):",
bool(np.all(np.diff(quant[0, :, 1:], axis=-1) >= -1e-4)))
row = pd.DataFrame({
"index": range(10),
"meaning": ["mean"] + [f"q{int(q*100)}" for q in QUANTILES],
"day+1": quant[0, 0].round(1),
f"day+{HORIZON}": quant[0, -1].round(1),
})
print(row.to_string(index=False))
print("Interval width grows with horizon — day+1 q10..q90 span "
f"{quant[0,0,9]-quant[0,0,1]:.1f}, day+{HORIZON} span "
f"{quant[0,-1,9]-quant[0,-1,1]:.1f}")
def seasonal_naive(history, horizon, season=SEASON):
"""Repeat the last full season forward — the baseline you must beat."""
reps = int(np.ceil(horizon / season))
return np.tile(history[-season:], reps)[:horizon]
def pinball(actual, q, quantiles=QUANTILES):
"""Mean pinball (quantile) loss over q10..q90 — the probabilistic metric."""
out = []
for i, tau in enumerate(quantiles, start=1):
e = actual - q[:, i]
out.append(np.mean(np.maximum(tau * e, (tau - 1) * e)))
return float(np.mean(out))
def evaluate(actual, pred, history, q=None, season=SEASON):
actual, pred = np.asarray(actual, float), np.asarray(pred, float)
err = actual - pred
scale = np.mean(np.abs(history[season:] - history[:-season])) + 1e-9
m = {
"MAE": float(np.mean(np.abs(err))),
"RMSE": float(np.sqrt(np.mean(err ** 2))),
"MAPE%": float(
np.mean(np.abs(err / np.maximum(np.abs(actual), 1e-9))) * 100
),
"sMAPE%": float(
np.mean(
2 * np.abs(err) /
(np.abs(actual) + np.abs(pred) + 1e-9)
) * 100
),
"MASE": float(np.mean(np.abs(err)) / scale),
}
if q is not None:
m["pinball"] = pinball(actual, q)
m["cov80%"] = float(
np.mean(
(actual >= q[:, IDX_Q10]) &
(actual <= q[:, IDX_Q90])
) * 100
)
return m
base_pred = seasonal_naive(train, HORIZON)
cmp = pd.DataFrame({
"TimesFM 2.5": evaluate(actual, point[0], train, quant[0]),
"seasonal naive": evaluate(actual, base_pred, train),
"last value": evaluate(
actual,
np.repeat(train[-1], HORIZON),
train
),
}).T
print("\n--- single-series accuracy ---")
print(cmp.round(3).to_string())
print("MASE < 1 means you beat seasonal naive. cov80% should sit near 80.")
inputs = [
wide[s].values[:-HORIZON].astype(np.float32).copy()
for s in STORES
]
t0 = time.time()
b_point, b_quant = model.forecast(
horizon=HORIZON,
inputs=list(inputs)
)
elapsed = time.time() - t0
print(f"\n{len(STORES)} series forecast in {elapsed:.2f}s "
f"({elapsed/len(STORES)*1000:.0f} ms/series)")
per_series = {}
for i, s in enumerate(STORES):
hist = wide[s].values[:-HORIZON]
act = wide[s].values[-HORIZON:]
per_series[s] = evaluate(
act,
b_point[i],
hist,
b_quant[i]
)
tbl = pd.DataFrame(per_series).T
print(tbl.round(3).to_string())
print("mean MASE:", round(tbl["MASE"].mean(), 3),
"| mean 80% coverage:", round(tbl["cov80%"].mean(), 1))
fig, axes = plt.subplots(2, 3, figsize=(16, 7), sharex=True)
for ax, s, i in zip(
axes.ravel(),
STORES,
range(len(STORES))
):
hist = wide[s].values[:-HORIZON]
ax.plot(
dates[-HORIZON-90:-HORIZON],
hist[-90:],
color="#475569",
lw=1
)
ax.plot(
dates[-HORIZON:],
wide[s].values[-HORIZON:],
color="#0f172a",
lw=1.4
)
ax.plot(
dates[-HORIZON:],
b_point[i],
color="#ea580c",
lw=1.8
)
ax.fill_between(
dates[-HORIZON:],
b_quant[i, :, IDX_Q10],
b_quant[i, :, IDX_Q90],
color="#ea580c",
alpha=.18,
lw=0
)
ax.set_title(
f"{s} MASE={per_series[s]['MASE']:.2f}",
fontsize=10
)
ax.tick_params(labelsize=8)
fig.suptitle(
"Batched zero-shot forecasts "
"(orange = median, band = 80% PI)"
)
plt.tight_layout()
plt.show()เราสร้างการพยากรณ์แบบ zero-shot ครั้งแรก พร้อมแสดงผลข้อมูลย้อนหลัง ข้อมูลจริง ค่ามัธยฐาน และแถบควอนไทล์เชิงความน่าจะเป็นในรูปแบบ Fan Chart
กระบวนการประเมินประกอบด้วยการคำนวณ MAE, RMSE, MAPE, sMAPE, MASE และ pinball loss เพื่อเปรียบเทียบ TimesFM กับโมเดลพื้นฐาน พร้อมพล็อตกราฟการพยากรณ์และช่วงความไม่แน่นอนของร้านค้าทั้งหมดในรูปแบบกลุ่ม (Batch)
N_FOLDS = 3 if FAST_MODE else 6
STEP = HORIZON
print(f"\n--- rolling-origin backtest: {N_FOLDS} folds x {HORIZON}d x "
f"{len(STORES)} series ---")
fold_rows = []
for k in range(N_FOLDS):
end = N_DAYS - k * STEP
cut = end - HORIZON
fold_inputs, fold_actuals, fold_hists = [], [], []
for s in STORES:
v = wide[s].values.astype(np.float32)
fold_inputs.append(v[:cut].copy())
fold_actuals.append(v[cut:end])
fold_hists.append(v[:cut])
fp, fq = model.forecast(
horizon=HORIZON,
inputs=list(fold_inputs)
)
for i, s in enumerate(STORES):
m_tfm = evaluate(
fold_actuals[i],
fp[i],
fold_hists[i],
fq[i]
)
m_snv = evaluate(
fold_actuals[i],
seasonal_naive(fold_hists[i], HORIZON),
fold_hists[i]
)
fold_rows.append({
"fold": k,
"store": s,
"model": "TimesFM",
**m_tfm
})
fold_rows.append({
"fold": k,
"store": s,
"model": "snaive",
**m_snv
})
bt = pd.DataFrame(fold_rows)
summary = bt.groupby("model")[
["MAE", "RMSE", "sMAPE%", "MASE"]
].mean()
print(summary.round(3).to_string())
lift = (
1 -
summary.loc["TimesFM", "MASE" ] /
summary.loc["snaive", "MASE"]
)
print(
f"TimesFM reduces MASE by "
f"{lift*100:.1f}% vs seasonal naive, zero-shot."
)
fig, ax = plt.subplots(figsize=(10, 4))
piv = bt.pivot_table(
index="fold",
columns="model",
values="MASE"
)
piv.plot(
kind="bar",
ax=ax,
color=["#ea580c", "#94a3b8"],
width=.8
)
ax.set_ylabel("MASE (lower is better)")
ax.axhline(1.0, color="k", ls=":", lw=1)
ax.set_title("Rolling-origin backtest — MASE per fold")
plt.tight_layout()
plt.show()
ctx_grid = (
[128, 512]
if FAST_MODE
else [64, 128, 256, 512, 1024]
)
print(f"\n--- context ablation {ctx_grid} ---")
abl = []
for ctx in ctx_grid:
recompile(max_context=ctx)
ins = [
wide[s].values[:-HORIZON][-ctx:]
.astype(np.float32)
.copy()
for s in STORES
]
t0 = time.time()
p, q = model.forecast(
horizon=HORIZON,
inputs=list(ins)
)
dt = time.time() - t0
ms = [
evaluate(
wide[s].values[-HORIZON:],
p[i],
wide[s].values[:-HORIZON],
q[i]
)
for i, s in enumerate(STORES)
]
abl.append({
"context": ctx,
"MASE": np.mean([m["MASE" ] for m in ms]),
"sMAPE%": np.mean([m["sMAPE%"] for m in ms]),
"cov80%": np.mean([m["cov80%"] for m in ms]),
"sec": round(dt, 2)
})
abl = pd.DataFrame(abl)
print(abl.round(3).to_string(index=False))
fig, ax1 = plt.subplots(figsize=(9, 4))
ax1.plot(
abl["context"],
abl["MASE"],
"o-",
color="#ea580c"
)
ax1.set_xscale("log", base=2)
ax1.set_xlabel("context length (days)")
ax1.set_ylabel("MASE", color="#ea580c")
ax2 = ax1.twinx()
ax2.bar(
abl["context"],
abl["sec"],
width=abl["context"] * 0.35,
alpha=.25,
color="#0369a1"
)
ax2.set_ylabel("wall-clock (s)", color="#0369a1")
ax1.set_title("Accuracy vs. context length vs. cost")
plt.tight_layout()
plt.show()
recompile()เราทำ Backtesting แบบ Rolling-origin ผ่านจุดตัดหลายจุดในประวัติ เพื่อประเมิน TimesFM ภายใต้ช่วงเวลาการพยากรณ์ที่สมจริง แทนที่จะพึ่งพาเพียงหน้าต่างเดียว
จากนั้นทำการศึกษา Context-length Ablation เพื่อวิเคราะห์ว่าปริมาณข้อมูลย้อนหลังที่แตกต่างกัน ส่งผลต่อความแม่นยำ เวลาในการประมวลผล และความครอบคลุมของช่วงการพยากรณ์อย่างไร
if HAS_XREG_DEPS:
CTX_X, H_X = 512, 56
recompile(
max_context=CTX_X,
max_horizon=128,
return_backcast=True
)
x_inputs = []
dyn_num = {"price": [], "temp": []}
dyn_cat = {"promo": [], "holiday": [], "dow": []}
stat_cat = {"region": [], "store": []}
for s in STORES:
g = df[df["store"] == s].reset_index(drop=True)
n = len(g)
ctx_lo, ctx_hi = n - H_X - CTX_X, n - H_X
x_inputs.append(
g["sales"]
.values[ctx_lo:ctx_hi]
.astype(np.float32)
)
for name, col in [
("price", "price"),
("temp", "temp")
]:
dyn_num[name].append(
g[col]
.values[ctx_lo:n]
.astype(np.float32)
)
for name, col in [
("promo", "promo"),
("holiday", "holiday"),
("dow", "dow")
]:
dyn_cat[name].append(
g[col]
.values[ctx_lo:n]
.astype(np.int32)
.tolist()
)
stat_cat["region"].append(g["region"].iloc[0])
stat_cat["store"].append(s)
x_actuals = [
wide[s].values[-H_X:]
for s in STORES
]
x_hists = [
wide[s].values[:-H_X]
for s in STORES
]
print(
f"\n--- XReg: inputs len={len(x_inputs[0])}, "
f"covariate len={len(dyn_num['price'][0])} "
f"=> inferred horizon "
f"{len(dyn_num['price'][0]) - len(x_inputs[0])} ---"
)
xreg_results = {}
for mode in ["xreg + timesfm", "timesfm + xreg"]:
t0 = time.time()
p_list, q_list = model.forecast_with_covariates(
inputs=[a.copy() for a in x_inputs],
dynamic_numerical_covariates=dyn_num,
dynamic_categorical_covariates=dyn_cat,
static_categorical_covariates=stat_cat,
xreg_mode=mode,
normalize_xreg_target_per_input=True,
ridge=1.0,
force_on_cpu=False,
)
dt = time.time() - t0
ms = [
evaluate(
x_actuals[i],
np.asarray(p_list[i]),
x_hists[i],
np.asarray(q_list[i])
)
for i in range(len(STORES))
]
xreg_results[mode] = (
np.asarray(p_list),
np.asarray(q_list),
ms,
dt
)
print(
f"{mode:>16}: "
f"MASE={np.mean([m['MASE'] for m in ms]):.3f} "
f"sMAPE={np.mean([m['sMAPE%'] for m in ms]):.2f}% "
f"({dt:.1f}s)"
)
recompile(
max_context=CTX_X,
max_horizon=128,
return_backcast=False
)
p_uni, q_uni = model.forecast(
horizon=H_X,
inputs=[a.copy() for a in x_inputs]
)
ms_uni = [
evaluate(
x_actuals[i],
p_uni[i],
x_hists[i],
q_uni[i]
)
for i in range(len(STORES))
]
print(
f"{'univariate':>16}: "
f"MASE={np.mean([m['MASE'] for m in ms_uni]):.3f} "
f"sMAPE={np.mean([m['sMAPE%'] for m in ms_uni]):.2f}%"
)
comp = pd.DataFrame({
"univariate": pd.DataFrame(ms_uni).mean(),
"xreg + timesfm": pd.DataFrame(
xreg_results["xreg + timesfm"][2]
).mean(),
"timesfm + xreg": pd.DataFrame(
xreg_results["timesfm + xreg"][2]
).mean(),
}).T
print("\n", comp.round(3).to_string())
best_mode = min(
xreg_results,
key=lambda m: np.mean(
[x["MASE"] for x in xreg_results[m][2]]
)
)
p_best = xreg_results[best_mode][0]
fig, axes = plt.subplots(
2,
2,
figsize=(15, 7),
sharex=True
)
for ax, i in zip(axes.ravel(), range(4)):
ax.plot(
dates[-H_X - 60:-H_X],
x_hists[i][-60:],
color="#475569",
lw=1
)
ax.plot(
dates[-H_X:],
x_actuals[i],
color="#0f172a",
lw=1.5,
label="actual"
)
ax.plot(
dates[-H_X:],
p_uni[i],
color="#94a3b8",
lw=1.6,
ls="--",
label="univariate"
)
ax.plot(
dates[-H_X:],
p_best[i],
color="#ea580c",
lw=1.8,
label=f"{best_mode}"
)
pr = df[
df["store"] == STORES[i]
]["promo"].values[-H_X:]
for j in np.where(pr == 1)[0]:
d = dates[-H_X + j]
ax.axvspan(
d - pd.Timedelta(hours=12),
d + pd.Timedelta(hours=12),
color="#16a34a",
alpha=.25,
lw=0
)
ax.set_title(STORES[i], fontsize=10)
ax.tick_params(labelsize=8)
axes[0, 0].legend(fontsize=8)
fig.suptitle(
"Covariate forecasting "
"(green = promotion days known in advance)"
)
plt.tight_layout()
plt.show()
else:
print(
"\n[skip] section 10 — "
"install scikit-learn + jax to run XReg."
)
recompile()เรานำตัวแปรต้น (Covariates) ทั้งแบบตัวเลข หมวดหมู่ และค่าคงที่ มาปรับปรุงการพยากรณ์ผ่านฟังก์ชัน XReg ของ TimesFM โดยเปรียบเทียบโหมดการทำงานต่างๆ กับการพยากรณ์แบบตัวแปรเดียว (Univariate)
การวิเคราะห์นี้ช่วยให้เห็นว่าการใส่ข้อมูลราคา อุณหภูมิ โปรโมชัน และวันหยุด ส่งผลต่อความแม่นยำอย่างไร โดยเฉพาะการตอบสนองของโมเดลต่อช่วงโปรโมชันในอนาคตที่ทราบล่วงหน้า
sig = wide[STORES[1]].values.astype(np.float32).copy()
truth_idx = [
N_DAYS - 210,
N_DAYS - 128,
N_DAYS - 61,
N_DAYS - 20
]
sig[truth_idx[0]] *= 1.9
sig[truth_idx[1]] *= 0.35
sig[truth_idx[2]:truth_idx[2] + 5] *= 1.5
sig[truth_idx[3]] *= 0.45
DET_WIN, DET_START = 14, N_DAYS - 280
flags = []
for start in range(
DET_START,
N_DAYS - DET_WIN + 1,
DET_WIN
):
ctx = sig[max(0, start - 512):start]
p, q = model.forecast(
horizon=DET_WIN,
inputs=[ctx.copy()]
)
act = sig[start:start + DET_WIN]
lo90 = q[0, :, IDX_Q10]
hi90 = q[0, :, IDX_Q90]
lo_ext = (
q[0, :, IDX_Q10] -
1.5 * (
q[0, :, IDX_Q50] -
q[0, :, IDX_Q10]
)
)
hi_ext = (
q[0, :, IDX_Q90] +
1.5 * (
q[0, :, IDX_Q90] -
q[0, :, IDX_Q50]
)
)
for j in range(DET_WIN):
sev = (
"CRITICAL"
if (
act[j] < lo_ext[j] or
act[j] > hi_ext[j]
)
else "WARNING"
if (
act[j] < lo90[j] or
act[j] > hi90[j]
)
else "ok"
)
flags.append({
"i": start + j,
"date": dates[start + j],
"actual": act[j],
"lo": lo90[j],
"hi": hi90[j],
"sev": sev,
"z": (
act[j] -
q[0, j, IDX_Q50]
) / max(
(hi90[j] - lo90[j]) / 2.563,
1e-6
)
})
fl = pd.DataFrame(flags)
alerts = fl[fl.sev != "ok"]
print(
f"\n--- anomaly detection: "
f"{len(alerts)} flags in {len(fl)} points "
f"({len(alerts)/len(fl)*100:.1f}% — "
f"expect ~20% by construction) ---"
)
print(
alerts[
alerts.sev == "CRITICAL"
][
["date", "actual", "lo", "hi", "z", "sev"]
]
.round(2)
.to_string(index=False)
)
print(
"injected anomalies at:",
[str(dates[i].date()) for i in truth_idx]
)
fig, ax = plt.subplots(figsize=(14, 4.5))
ax.plot(
fl["date"],
fl["actual"],
color="#0f172a",
lw=1.1,
label="observed"
)
ax.fill_between(
fl["date"],
fl["lo"],
fl["hi"],
color="#0369a1",
alpha=.15,
lw=0,
label="80% predictive interval"
)
w = fl[fl.sev == "WARNING"]
c = fl[fl.sev == "CRITICAL"]
ax.scatter(
w["date"],
w["actual"],
s=28,
color="#f59e0b",
zorder=3,
label="warning"
)
ax.scatter(
c["date"],
c["actual"],
s=60,
color="#dc2626",
zorder=4,
marker="X",
label="critical"
)
ax.set_title(
"Anomaly detection from TimesFM predictive intervals"
)
ax.legend(loc="upper left", fontsize=8)
plt.tight_layout()
plt.show()
LONG_H = 256
long_train = (
wide[STORES[2]]
.values[:-LONG_H]
.astype(np.float32)
)
long_act = wide[STORES[2]].values[-LONG_H:]
recompile(max_horizon=256)
p_direct, q_direct = model.forecast(
horizon=LONG_H,
inputs=[long_train.copy()]
)
recompile(max_horizon=128)
CHUNK = 64
ctx_roll = long_train.copy()
rec = []
for _ in range(LONG_H // CHUNK):
p_c, _ = model.forecast(
horizon=CHUNK,
inputs=[ctx_roll[-1024:].copy()]
)
rec.append(p_c[0])
ctx_roll = np.concatenate([
ctx_roll,
p_c[0]
])
p_rec = np.concatenate(rec)
print(f"\n--- long horizon ({LONG_H} days) ---")
print(
pd.DataFrame({
"direct": evaluate(
long_act,
p_direct[0],
long_train,
q_direct[0]
),
f"recursive x{CHUNK}": evaluate(
long_act,
p_rec,
long_train
),
})
.T
.round(3)
.to_string()
)
fig, ax = plt.subplots(figsize=(14, 4.5))
ax.plot(
dates[-LONG_H-120:-LONG_H],
long_train[-120:],
color="#475569",
lw=1
)
ax.plot(
dates[-LONG_H:],
long_act,
color="#0f172a",
lw=1.4,
label="actual"
)
ax.plot(
dates[-LONG_H:],
p_direct[0],
color="#ea580c",
lw=1.8,
label="direct"
)
ax.plot(
dates[-LONG_H:],
p_rec,
color="#7c3aed",
lw=1.5,
ls="--",
label=f"recursive ({CHUNK}-day chunks)"
)
ax.fill_between(
dates[-LONG_H:],
q_direct[0, :, IDX_Q10],
q_direct[0, :, IDX_Q90],
color="#ea580c",
alpha=.15,
lw=0
)
ax.legend()
ax.set_title(
"Direct vs recursive long-horizon forecasting"
)
plt.tight_layout()
plt.show()
recompile()เราสร้างเวิร์กโฟลว์การตรวจจับความผิดปกติ (Anomaly Detection) โดยเปรียบเทียบค่าที่สังเกตได้กับช่วงควอนไทล์ของ TimesFM พร้อมจำลองสถานการณ์ความผิดปกติแบบต่างๆ เช่น การพุ่งสูงของข้อมูล หรือข้อมูลขาดหาย
นอกจากนี้ ยังเปรียบเทียบการพยากรณ์ระยะไกลแบบทางตรง (Direct) และแบบเรียกซ้ำ (Recursive) เพื่อดูผลกระทบต่อความแม่นยำและการสะสมของความคลาดเคลื่อนเมื่อต้องพยากรณ์ในช่วงเวลาที่ยาวนานขึ้น
many = [
wide[STORES[i % len(STORES)]]
.values[:-HORIZON]
.astype(np.float32)
.copy()
for i in range(48)
]
print("\n--- throughput (48 series, 1024 context) ---")
bench = []
for bs in (
[8, 32]
if FAST_MODE
else [1, 4, 16, 32, 48]
):
recompile(per_core_batch_size=bs)
model.forecast(
horizon=32,
inputs=[many[0].copy()]
)
t0 = time.time()
model.forecast(
horizon=HORIZON,
inputs=list(many)
)
dt = time.time() - t0
bench.append({
"per_core_batch_size": bs,
"sec": round(dt, 2),
"series/sec": round(len(many) / dt, 1)
})
bench = pd.DataFrame(bench)
print(bench.to_string(index=False))
print(
"Tip: also try force_flip_invariance=False "
"for a ~2x speedup when your series are "
"strictly positive — it removes the second "
"forward pass."
)
recompile()
print("\n--- robustness checks ---")
dirty = (
wide[STORES[0]]
.values[:-HORIZON]
.astype(np.float32)
.copy()
)
dirty[:20] = np.nan
dirty[400:415] = np.nan
p_nan, _ = model.forecast(
horizon=HORIZON,
inputs=[dirty.copy()]
)
print(
f"(a) NaN input -> finite output: "
f"{np.isfinite(p_nan).all()}, "
f"MASE "
f"{evaluate(actual, p_nan[0], train)['MASE']:.3f} "
f"(clean: "
f"{evaluate(actual, point[0], train)['MASE']:.3f})"
)
def trim_trailing_nans(a):
a = np.asarray(a, dtype=np.float32)
return (
a[:len(a) - np.argmax(~np.isnan(a[::-1]))]
if np.isnan(a[-1])
else a
)
for L in [8, 32, 128]:
ps, _ = model.forecast(
horizon=14,
inputs=[train[-L:].copy()]
)
print(
f"(b) context={L:>4} -> 14d MASE "
f"{evaluate("
f"series[-HORIZON:-HORIZON+14], "
f"ps[0], "
f"train"
f")['MASE']:.3f}"
)
decay = np.clip(
np.linspace(400, 8, 400) +
np.random.normal(0, 6, 400),
0,
None
).astype(np.float32)
recompile(infer_is_positive=True)
p_clip, _ = model.forecast(
horizon=64,
inputs=[decay.copy()]
)
recompile(infer_is_positive=False)
p_free, _ = model.forecast(
horizon=64,
inputs=[decay.copy()]
)
print(
f"(c) decaying series: "
f"infer_is_positive=True "
f"min={p_clip.min():+.1f} "
f"(floored at 0) | "
f"False min={p_free.min():+.1f}"
)
recompile()
probe = [
train.copy(),
train.copy(),
train.copy()
]
before = len(probe)
model.forecast(
horizon=16,
inputs=probe
)
print(
f"(d) list length before={before} "
f"after={len(probe)} "
f"<-- pass list(inputs)!"
)
r1, _ = model.forecast(
horizon=32,
inputs=[train.copy()]
)
r2, _ = model.forecast(
horizon=32,
inputs=[train.copy()]
)
print(
f"(e) deterministic: "
f"{np.allclose(r1, r2)}"
)
fc_dates = pd.date_range(
dates[-1] + pd.Timedelta(days=1),
periods=HORIZON,
freq="D"
)
final_in = [
wide[s]
.values
.astype(np.float32)
.copy()
for s in STORES
]
fp, fq = model.forecast(
horizon=HORIZON,
inputs=list(final_in)
)
out = []
for i, s in enumerate(STORES):
out.append(pd.DataFrame({
"date": fc_dates,
"store": s,
"forecast": fp[i],
"q10": fq[i, :, IDX_Q10],
"q25": fq[i, :, 3],
"q50": fq[i, :, IDX_Q50],
"q75": fq[i, :, 7],
"q90": fq[i, :, IDX_Q90],
"mean": fq[i, :, IDX_MEAN],
}))
out = pd.concat(out, ignore_index=True)
out.to_csv(
"timesfm_forecasts.csv",
index=False
)
with open("timesfm_backtest.json", "w") as f:
json.dump({
"backtest": summary.round(4).to_dict(),
"context_ablation": abl.round(4).to_dict(
orient="records"
),
"throughput": bench.to_dict(
orient="records"
)
}, f, indent=2)
print(
"\nWrote timesfm_forecasts.csv "
"and timesfm_backtest.json"
)
print(
out.head(4)
.round(1)
.to_string(index=False)
)เราวัดประสิทธิภาพ (Throughput) ของโมเดลด้วยขนาดกลุ่ม (Batch sizes) ที่แตกต่างกัน เพื่อวิเคราะห์ความเร็วในการประมวลผลต่อวินาที พร้อมทั้งทดสอบความทนทานต่อข้อมูลที่ผิดพลาด เช่น ค่าว่าง (NaN) หรือข้อมูลประวัติที่สั้นมาก
ในขั้นตอนสุดท้าย เราได้ส่งออกผลลัพธ์การพยากรณ์ในอนาคตและสรุปการทดลองทั้งหมดเป็นไฟล์ CSV และ JSON พร้อมจัดเตรียมเทมเพลตสำหรับการประยุกต์ใช้ TimesFM 2.5 กับชุดข้อมูลส่วนตัวของผู้ใช้เอง โดยสรุป เราได้สร้างเวิร์กโฟลว์พยากรณ์ที่ครอบคลุม ทั้งการใช้ควอนไทล์วัดความไม่แน่นอน การทำ Backtesting ที่น่าเชื่อถือ และการศึกษาปัจจัยที่มีผลต่อความแม่นยำและต้นทุนการคำนวณ เพื่อเป็นรากฐานสำหรับการนำไปใช้งานจริงในระดับ Production ต่อไป
ตรวจสอบ Full Codes ที่นี่ นอกจากนี้ อย่าลังเลที่จะติดตามเราบน
ความคิดเห็น (0)
เข้าสู่ระบบเพื่อร่วมแสดงความเห็น
สมัครสมาชิกมาเป็นคนแรกที่แสดงความเห็นกันเลยโบร
