Upload app.py
Browse files
app.py
CHANGED
|
@@ -1,29 +1,27 @@
|
|
| 1 |
-
|
| 2 |
-
App · PY
|
| 3 |
"""
|
| 4 |
Predicty · Sales Forecast API (v2 — teaching edition)
|
| 5 |
=======================================================
|
| 6 |
Backend สำหรับหลักสูตร Non-Degree "นักวิเคราะห์ข้อมูลและแบบจำลองเชิงพยากรณ์"
|
| 7 |
กรณีศึกษา: ENT Group
|
| 8 |
-
|
| 9 |
ปรัชญาของเวอร์ชันนี้ — "อ่านผลอย่างซื่อสัตย์"
|
| 10 |
------------------------------------------------
|
| 11 |
v1 มีปัญหาเชิงการสอน 4 ข้อ ที่ v2 แก้ทั้งหมด:
|
| 12 |
-
|
| 13 |
1. confidence เดิมคือสูตรกุขึ้นเอง 1 - resid/mean → หน้าเว็บโชว์ 94%
|
| 14 |
ทั้งที่ข้อมูลจริง ENT พยากรณ์ผิดเฉลี่ย ~30%
|
| 15 |
v2: ความแม่นยำมาจาก "การทดสอบย้อนหลังจริง" (rolling-origin backtest) เท่านั้น
|
| 16 |
-
|
| 17 |
2. ช่วงความเชื่อมั่นเดิมใช้ค่าเบี่ยงเบนตัวเดียวคงที่ทุกเดือน
|
| 18 |
v2: ช่วงมาจาก "การกระจายของความผิดพลาดจริงที่ระยะนั้น ๆ" → กว้างขึ้นตามระยะ
|
| 19 |
-
|
| 20 |
3. guardrails เดิมบีบค่าพยากรณ์เงียบ ๆ ไว้ใน [avg/3, avg*3]
|
| 21 |
v2: ปิดโดยปริยาย และถ้าเปิดจะรายงานว่าเดือนไหนถูกตัดบ้าง
|
| 22 |
-
|
| 23 |
4. เลือกโมเดลจาก holdout ครั้งเดียว ≤3 เดือน แล้วไม่บอกผล
|
| 24 |
v2: rolling-origin หลายจุดเริ่ม + ส่ง "ตารางอันดับ" กลับไปให้ผู้เรียนเห็นทุกโมเดล
|
| 25 |
พร้อม skill score เทียบ baseline — ถ้าชนะ baseline ไม่ได้ ต้องกล้าบอก
|
| 26 |
-
|
| 27 |
Endpoints
|
| 28 |
---------
|
| 29 |
GET / ข้อมูลบริการ + รายการ endpoint
|
|
@@ -37,28 +35,28 @@ Endpoints
|
|
| 37 |
POST /api/charts ชุดข้อมูลกราฟหลายแบบ (YoY / YTD / heatmap / growth / …)
|
| 38 |
POST /api/hindcast ทำนายย้อนกลับทับเส้นจริง + ตรวจความสอดคล้องของ YoY
|
| 39 |
"""
|
| 40 |
-
|
| 41 |
import hashlib
|
| 42 |
import json
|
| 43 |
import os
|
| 44 |
import time
|
| 45 |
import warnings
|
| 46 |
-
|
| 47 |
import numpy as np
|
| 48 |
import pandas as pd
|
| 49 |
from flask import Flask, jsonify, request
|
| 50 |
from flask_cors import CORS
|
| 51 |
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
| 52 |
from statsmodels.tsa.statespace.sarimax import SARIMAX
|
| 53 |
-
|
| 54 |
warnings.filterwarnings("ignore")
|
| 55 |
-
|
| 56 |
app = Flask(__name__)
|
| 57 |
CORS(app, resources={r"/*": {"origins": "*"}})
|
| 58 |
-
|
| 59 |
# ค่า z สำหรับช่วงความเชื่อมั่นแบบ two-sided
|
| 60 |
Z = {80: 1.2816, 90: 1.6449, 95: 1.9600}
|
| 61 |
-
|
| 62 |
SEASONAL_PERIOD = 12 # ข้อมูลรายเดือน
|
| 63 |
MIN_HISTORY = 6 # น้อยกว่านี้พยากรณ์ไม่ได้เลย
|
| 64 |
MIN_TRAIN = 36 # ต้องมีอย่างน้อย 3 รอบปี ฤดูกาลถึงจะเทรนได้จริง
|
|
@@ -66,42 +64,42 @@ SEASONAL_MIN = 36 # < 36 เดือน ปิดส่วนฤ
|
|
| 66 |
MAX_ORIGINS = 12 # จำกัดจำนวนจุดเริ่มใน backtest (คุมเวลาบน CPU ฟรีของ HF)
|
| 67 |
MIN_ORIGINS = 3 # น้อยกว่านี้ถือว่าวัดความแม่นยำไม่ได้ — จะไม่รายงาน MAPE เลย
|
| 68 |
BACKTEST_HORIZON = 6 # ทดสอบย้อนหลังที่ระยะ 1–6 เดือน
|
| 69 |
-
|
| 70 |
_CACHE = {} # cache ผล backtest ตาม hash ของข้อมูล
|
| 71 |
_CACHE_MAX = 32
|
| 72 |
-
|
| 73 |
-
|
| 74 |
# ══════════════════════════════════════════════════════════════════
|
| 75 |
# ส่วนที่ 1 · เตรียมข้อมูล + ตรวจคุณภาพ
|
| 76 |
# ══════════════════════════════════════════════════════════════════
|
| 77 |
-
|
| 78 |
def to_series(rows):
|
| 79 |
"""แปลง [{date:'2026-01', sales:123}, ...] → pandas Series รายเดือน
|
| 80 |
-
|
| 81 |
คืน (series, warnings) — warnings คือสิ่งที่ผู้เรีย���ควรรู้ก่อนเชื่อผลพยากรณ์
|
| 82 |
"""
|
| 83 |
warns = []
|
| 84 |
df = pd.DataFrame(rows)
|
| 85 |
-
|
| 86 |
if "date" not in df.columns or "sales" not in df.columns:
|
| 87 |
raise ValueError("ต้องมีคอลัมน์ 'date' (YYYY-MM) และ 'sales'")
|
| 88 |
-
|
| 89 |
df["date"] = pd.to_datetime(df["date"].astype(str).str.strip() + "-01", errors="coerce")
|
| 90 |
df["sales"] = pd.to_numeric(df["sales"], errors="coerce")
|
| 91 |
-
|
| 92 |
bad = int(df["date"].isna().sum())
|
| 93 |
if bad:
|
| 94 |
warns.append(f"มี {bad} แถวที่วันที่อ่านไม่ออก — ถูกตัดทิ้ง")
|
| 95 |
df = df.dropna(subset=["date"])
|
| 96 |
-
|
| 97 |
dup = int(df["date"].duplicated().sum())
|
| 98 |
if dup:
|
| 99 |
warns.append(f"มีเดือนซ้ำ {dup} รายการ — รวมกันด้วยผลบวก")
|
| 100 |
df = df.groupby("date", as_index=False)["sales"].sum()
|
| 101 |
-
|
| 102 |
df = df.sort_values("date").set_index("date")
|
| 103 |
s = df["sales"].astype(float).asfreq("MS")
|
| 104 |
-
|
| 105 |
gaps = int(s.isna().sum())
|
| 106 |
if gaps:
|
| 107 |
warns.append(
|
|
@@ -109,17 +107,17 @@ def to_series(rows):
|
|
| 109 |
f"(ระวัง: ค่าที่เติมไม่ใช่ข้อมูลจริง)"
|
| 110 |
)
|
| 111 |
s = s.interpolate().ffill().bfill()
|
| 112 |
-
|
| 113 |
return s, warns
|
| 114 |
-
|
| 115 |
-
|
| 116 |
def data_quality(s):
|
| 117 |
"""ตรวจคุณภาพข้อมูล — สิ่งที่ต้องดูก่อนพยากรณ์เสมอ"""
|
| 118 |
v = s.values.astype(float)
|
| 119 |
n = len(v)
|
| 120 |
mean, std = float(np.mean(v)), float(np.std(v, ddof=1)) if n > 1 else 0.0
|
| 121 |
cv = std / mean if mean else 0.0
|
| 122 |
-
|
| 123 |
# outlier แบบ MAD (ทนต่อค่าสุดโต่งกว่า z-score ธรรมดา)
|
| 124 |
med = float(np.median(v))
|
| 125 |
mad = float(np.median(np.abs(v - med))) or 1e-9
|
|
@@ -128,7 +126,7 @@ def data_quality(s):
|
|
| 128 |
{"period": s.index[i].strftime("%Y-%m"), "value": float(v[i]), "z_mad": round(float(zmod[i]), 2)}
|
| 129 |
for i in np.where(np.abs(zmod) > 3.5)[0]
|
| 130 |
]
|
| 131 |
-
|
| 132 |
notes = []
|
| 133 |
if n < MIN_TRAIN:
|
| 134 |
notes.append(f"ข้อมูลมี {n} เดือน — น้อยกว่า {MIN_TRAIN} เดือน (3 รอบปี) จับรูปแบบฤดูกาลได้ไม่น่าเชื่อถือ")
|
|
@@ -145,7 +143,7 @@ def data_quality(s):
|
|
| 145 |
f"เดือนล่าสุด ({s.index[-1].strftime('%Y-%m')}) ต่ำกว่าค่าเฉลี่ย 3 เดือนก่อนหน้ามาก "
|
| 146 |
f"— มักแปลว่าเดือนนั้นยังเก็บข้อมูลไม่ครบ ควรตัดออกก่อนฝึกโมเดล"
|
| 147 |
)
|
| 148 |
-
|
| 149 |
return {
|
| 150 |
"n_months": n,
|
| 151 |
"start": s.index[0].strftime("%Y-%m"),
|
|
@@ -160,37 +158,37 @@ def data_quality(s):
|
|
| 160 |
"outliers": outliers,
|
| 161 |
"notes": notes,
|
| 162 |
}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
# ══════════════════════════════════════════════════════════════════
|
| 166 |
# ส่วนที่ 2 · โมเดล (baseline อยู่ก่อน — เพราะต้องเอาไว้เทียบเสมอ)
|
| 167 |
# ══════════════════════════════════════════════════════════════════
|
| 168 |
-
|
| 169 |
def fit_naive(train, h):
|
| 170 |
"""ทายว่าเดือนหน้าเท่ากับเดือนล่าสุด — baseline ที่ง่ายที่สุด"""
|
| 171 |
return np.full(h, float(train.values[-1]))
|
| 172 |
-
|
| 173 |
-
|
| 174 |
def fit_mean3(train, h):
|
| 175 |
"""ทายด้วยค่าเฉลี่ย 3 เดือนล่าสุด — baseline ที่ธุรกิจใช้จริงบ่อยที่สุด"""
|
| 176 |
return np.full(h, float(np.mean(train.values[-3:])))
|
| 177 |
-
|
| 178 |
-
|
| 179 |
def fit_seasonal_naive(train, h):
|
| 180 |
"""ทายว่าเดือนนี้ปีนี้ = เดือนเดียวกันปีที่แล้ว — baseline มาตรฐานของข้อมูลมีฤดูกาล"""
|
| 181 |
if len(train) < SEASONAL_PERIOD:
|
| 182 |
return fit_mean3(train, h)
|
| 183 |
last_year = train.values[-SEASONAL_PERIOD:]
|
| 184 |
return np.tile(last_year, int(np.ceil(h / SEASONAL_PERIOD)))[:h].astype(float)
|
| 185 |
-
|
| 186 |
-
|
| 187 |
def fit_linear(train, h):
|
| 188 |
"""ลากเส้นตรงผ่านข้อมูลทั้งหมดแล้วต่อไปข้างหน้า"""
|
| 189 |
x = np.arange(len(train))
|
| 190 |
coef = np.polyfit(x, train.values, 1)
|
| 191 |
return np.polyval(coef, np.arange(len(train), len(train) + h))
|
| 192 |
-
|
| 193 |
-
|
| 194 |
def fit_holtwinters(train, h):
|
| 195 |
"""Exponential Smoothing: ระดับ + แนวโน้ม + ฤดูกาล (ถ้าข้อ���ูลยาวพอ)"""
|
| 196 |
use_seasonal = len(train) >= SEASONAL_MIN
|
|
@@ -202,8 +200,8 @@ def fit_holtwinters(train, h):
|
|
| 202 |
initialization_method="estimated",
|
| 203 |
).fit(optimized=True)
|
| 204 |
return np.asarray(m.forecast(h), dtype=float)
|
| 205 |
-
|
| 206 |
-
|
| 207 |
def fit_sarima(train, h):
|
| 208 |
"""SARIMA(1,1,1)(1,1,1)[12] — โมเดลอนุกรมเวลามาตรฐาน"""
|
| 209 |
seasonal_order = (1, 1, 1, SEASONAL_PERIOD) if len(train) >= SEASONAL_MIN else (0, 0, 0, 0)
|
|
@@ -215,11 +213,11 @@ def fit_sarima(train, h):
|
|
| 215 |
enforce_invertibility=False,
|
| 216 |
).fit(disp=False)
|
| 217 |
return np.asarray(m.get_forecast(h).predicted_mean, dtype=float)
|
| 218 |
-
|
| 219 |
-
|
| 220 |
def _lag_frame(values, lags=(1, 2, 3, 12), months=None):
|
| 221 |
"""แปลงอนุกรมเวลาเป็นตารางแบบ supervised learning
|
| 222 |
-
|
| 223 |
บทเรียน: ML ทั่วไปไม่รู้จัก "เวลา" เราต้องป้อนอดีตให้มันเองผ่าน lag features
|
| 224 |
"""
|
| 225 |
import numpy as _np
|
|
@@ -233,26 +231,26 @@ def _lag_frame(values, lags=(1, 2, 3, 12), months=None):
|
|
| 233 |
rows.append(row)
|
| 234 |
ys.append(v[t])
|
| 235 |
return _np.array(rows, dtype=float), _np.array(ys, dtype=float)
|
| 236 |
-
|
| 237 |
-
|
| 238 |
def fit_ml_rf(train, h):
|
| 239 |
"""Random Forest บน lag features — ตัวแทนฝั่ง Machine Learning
|
| 240 |
-
|
| 241 |
พยากรณ์หลายงวดแบบ recursive: ทำนายเดือนถัดไป → เอาค่าที่ทำนายได้ไปเป็น input
|
| 242 |
ของเดือนถัดไปอีกที ความผิดพลาดจึงสะสมตามระยะ ซึ่งเป็นข้อจำกัดจริงของวิธีนี้
|
| 243 |
"""
|
| 244 |
from sklearn.ensemble import RandomForestRegressor
|
| 245 |
-
|
| 246 |
LAGS = (1, 2, 3, 12)
|
| 247 |
v = np.asarray(train.values, dtype=float)
|
| 248 |
if len(v) < max(LAGS) + 8:
|
| 249 |
return fit_mean3(train, h)
|
| 250 |
-
|
| 251 |
months = list(pd.DatetimeIndex(train.index).month)
|
| 252 |
X, y = _lag_frame(v, LAGS, months)
|
| 253 |
model = RandomForestRegressor(n_estimators=200, max_depth=8, min_samples_leaf=3,
|
| 254 |
random_state=7, n_jobs=1).fit(X, y)
|
| 255 |
-
|
| 256 |
hist = list(v)
|
| 257 |
last_month = months[-1]
|
| 258 |
out = []
|
|
@@ -263,8 +261,8 @@ def fit_ml_rf(train, h):
|
|
| 263 |
out.append(pred)
|
| 264 |
hist.append(pred)
|
| 265 |
return np.asarray(out, dtype=float)
|
| 266 |
-
|
| 267 |
-
|
| 268 |
MODELS = {
|
| 269 |
"naive": fit_naive,
|
| 270 |
"mean3": fit_mean3,
|
|
@@ -274,11 +272,11 @@ MODELS = {
|
|
| 274 |
"sarima": fit_sarima,
|
| 275 |
"ml_rf": fit_ml_rf,
|
| 276 |
}
|
| 277 |
-
|
| 278 |
# baseline = โมเดลง่าย ๆ ที่โมเดลจริงต้องเอาชนะให้ได้ ไม่งั้นถือว่าไม่มีประโยชน์
|
| 279 |
BASELINES = ["naive", "mean3", "seasonal_naive"]
|
| 280 |
BENCHMARK = "seasonal_naive"
|
| 281 |
-
|
| 282 |
MODEL_INFO = {
|
| 283 |
"naive": {"th": "ทายเท่าเดือนล่าสุด", "kind": "baseline",
|
| 284 |
"how": "ค่าพยากรณ์ทุกเดือน = ค่าของเดือนสุดท้ายในข้อมูล"},
|
|
@@ -295,25 +293,25 @@ MODEL_INFO = {
|
|
| 295 |
"ml_rf": {"th": "Random Forest (Machine Learning)", "kind": "model",
|
| 296 |
"how": "ป้อนค่าย้อนหลัง 1/2/3/12 เดือน + เดือนของปี ให้ต้นไม้ตัดสินใจหลายร้อยต้นโหวตกัน"},
|
| 297 |
}
|
| 298 |
-
|
| 299 |
# ชื่อเดิมจาก v1 — รับไว้เพื่อไม่ให้ของเก่าพัง
|
| 300 |
ALIASES = {"arima": "sarima", "hw": "holtwinters", "ets": "holtwinters"}
|
| 301 |
-
|
| 302 |
-
|
| 303 |
# ══════════════════════════════════════════════════════════════════
|
| 304 |
# ส่วนที่ 3 · การวัดผล
|
| 305 |
# ══════════════════════════════════════════════════════════════════
|
| 306 |
-
|
| 307 |
def _safe_ape(actual, pred):
|
| 308 |
a = np.asarray(actual, dtype=float)
|
| 309 |
p = np.asarray(pred, dtype=float)
|
| 310 |
denom = np.where(np.abs(a) < 1e-9, np.nan, a)
|
| 311 |
return np.abs((a - p) / denom) * 100
|
| 312 |
-
|
| 313 |
-
|
| 314 |
def score(actual, pred):
|
| 315 |
"""MAPE / MdAPE / MAE / RMSE
|
| 316 |
-
|
| 317 |
MAPE = ผิดเฉลี่ยกี่ % — สื่อสารกับผู้บริหารง่ายที่สุด แต่ถูกเดือนที่ยอดต่ำดึงให้พองได้
|
| 318 |
MdAPE = ค่ากลางของ % ที่ผิด — ทนต่อเดือนสุดโต่งกว่า
|
| 319 |
MAE = ผิดเฉลี่ยกี่บาท — ใช้ตัดสินใจธุรกิจได้ตรงกว่า %
|
|
@@ -328,23 +326,23 @@ def score(actual, pred):
|
|
| 328 |
"mae": float(np.mean(np.abs(a - p))),
|
| 329 |
"rmse": float(np.sqrt(np.mean((a - p) ** 2))),
|
| 330 |
}
|
| 331 |
-
|
| 332 |
-
|
| 333 |
def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS, models=None):
|
| 334 |
"""ทดสอบย้อนหลังแบบเลื่อนจุดเริ่ม — วิธีวัดความแม่นยำที่ถูกต้องของอนุกรมเวลา
|
| 335 |
-
|
| 336 |
หลักการ: ทำเสมือนย้อนเวลากลับไปยืนที่จุดหนึ่งในอดีต แล้วพยากรณ์ไปข้างหน้า
|
| 337 |
โดยเห็นเฉพาะข้อมูลก่อนจุดนั้น จากนั้นเทียบกับสิ่งที่เกิดขึ้นจริง — ทำซ้ำหลายจุด
|
| 338 |
-
|
| 339 |
ทำไมต้องเลื่อนหลายจุด: วัดครั้งเดียวคือการวัด "ดวง" ไม่ใช่ "ฝีมือ"
|
| 340 |
-
|
| 341 |
คืน: {model: {"metrics": {...}, "errors_by_h": {h: [error, ...]}}}
|
| 342 |
"""
|
| 343 |
models = models or list(MODELS.keys())
|
| 344 |
n = len(s)
|
| 345 |
FLOOR = max(MIN_HISTORY, SEASONAL_PERIOD + 3) # เล็กกว่านี้เทรนอะไรไม่ได้เลย
|
| 346 |
min_train = max(MIN_TRAIN, SEASONAL_PERIOD + 3) # อยากได้ 3 รอบปี ถ้าข้อมูลมีพอ
|
| 347 |
-
|
| 348 |
# ต้องได้จุดทดสอบอย่างน้อย MIN_ORIGINS จุด — ถ้าไม่พอ ยอมย่อหน้าต่างเทรนลง
|
| 349 |
want = n - horizon + 1 - MIN_ORIGINS
|
| 350 |
shortened = False
|
|
@@ -352,10 +350,10 @@ def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS
|
|
| 352 |
new_min = max(FLOOR, want)
|
| 353 |
shortened = new_min < min_train
|
| 354 |
min_train = new_min
|
| 355 |
-
|
| 356 |
if n < min_train + horizon:
|
| 357 |
return None, "ข้อมูลสั้นเกินไปสำหรับการทดสอบย้อนหลัง"
|
| 358 |
-
|
| 359 |
all_origins = list(range(min_train, n - horizon + 1))
|
| 360 |
if len(all_origins) < MIN_ORIGINS:
|
| 361 |
# จุดทดสอบน้อยกว่านี้ = วัด "ดวง" ไม่ใช่ "ฝีมือ" — ยอมบอกว่าวัดไม่ได้ ดีกว่ารายงานตัวเลขที่เชื่อไม่ได้
|
|
@@ -366,12 +364,12 @@ def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS
|
|
| 366 |
origins = [all_origins[i] for i in sorted(set(idx))]
|
| 367 |
else:
|
| 368 |
origins = all_origins
|
| 369 |
-
|
| 370 |
out = {m: {"errors_by_h": {h: [] for h in range(1, horizon + 1)},
|
| 371 |
"actual": [], "pred": [],
|
| 372 |
"agg_actual": [], "agg_pred": [], # ยอดรวมทั้งช่วง (เช่น รายไตรมาส)
|
| 373 |
"failed": 0} for m in models}
|
| 374 |
-
|
| 375 |
for o in origins:
|
| 376 |
train, test = s.iloc[:o], s.iloc[o:o + horizon]
|
| 377 |
for name in models:
|
|
@@ -389,7 +387,7 @@ def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS
|
|
| 389 |
out[name]["agg_pred"].append(float(np.sum(fc)))
|
| 390 |
for h in range(1, horizon + 1):
|
| 391 |
out[name]["errors_by_h"][h].append(float(test.values[h - 1] - fc[h - 1]))
|
| 392 |
-
|
| 393 |
result = {}
|
| 394 |
for name, d in out.items():
|
| 395 |
if len(d["actual"]) < horizon:
|
|
@@ -401,7 +399,7 @@ def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS
|
|
| 401 |
"n_origins": len(origins),
|
| 402 |
"failed_fits": d["failed"],
|
| 403 |
}
|
| 404 |
-
|
| 405 |
meta = {
|
| 406 |
"n_origins": len(origins),
|
| 407 |
"horizon": horizon,
|
|
@@ -415,11 +413,11 @@ def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS
|
|
| 415 |
"เพื่อให้มีจุดทดสอบพอ — ตัวเลขความแม่นยำจึงมีความไม่แน่นอนสูง"
|
| 416 |
)
|
| 417 |
return result, meta
|
| 418 |
-
|
| 419 |
-
|
| 420 |
def build_leaderboard(bt):
|
| 421 |
"""เรียงอันดับโมเดลตาม MAPE + คำนวณ skill score เทียบ baseline
|
| 422 |
-
|
| 423 |
skill = 1 - MAPE(model) / MAPE(seasonal_naive)
|
| 424 |
> 0 ดีกว่า baseline
|
| 425 |
= 0 เท่า baseline
|
|
@@ -427,10 +425,10 @@ def build_leaderboard(bt):
|
|
| 427 |
"""
|
| 428 |
if not bt:
|
| 429 |
return [], None
|
| 430 |
-
|
| 431 |
bench = bt.get(BENCHMARK) or bt.get("mean3")
|
| 432 |
bench_mape = bench["metrics"]["mape"] if bench else None
|
| 433 |
-
|
| 434 |
rows = []
|
| 435 |
for name, d in bt.items():
|
| 436 |
m = d["metrics"]
|
|
@@ -452,13 +450,13 @@ def build_leaderboard(bt):
|
|
| 452 |
})
|
| 453 |
rows.sort(key=lambda r: r["mape"])
|
| 454 |
return rows, bench_mape
|
| 455 |
-
|
| 456 |
-
|
| 457 |
def reliability_label(mape, best_model, leaderboard):
|
| 458 |
"""แปลตัวเลข MAPE เป็นภาษาที่ผู้บริหารและ นศ. ใช้ตัดสินใจ��ด้จริง"""
|
| 459 |
beat = any(r["kind"] == "model" and r["beats_baseline"] for r in leaderboard)
|
| 460 |
winner_is_baseline = MODEL_INFO.get(best_model, {}).get("kind") == "baseline"
|
| 461 |
-
|
| 462 |
if mape < 10:
|
| 463 |
level, th = "high", "แม่นสูง — ใช้วางแผนรายเดือนได้"
|
| 464 |
elif mape < 20:
|
|
@@ -467,7 +465,7 @@ def reliability_label(mape, best_model, leaderboard):
|
|
| 467 |
level, th = "low", "ต่ำ — ใช้ได้แค่ดูแนวโน้มกว้าง ๆ ห้ามใช้ตั้งเป้ารายเดือน"
|
| 468 |
else:
|
| 469 |
level, th = "very_low", "ต่ำมาก — ข้อมูลผันผวนเกินกว่าจะพยากรณ์รายเดือนได้"
|
| 470 |
-
|
| 471 |
honest = []
|
| 472 |
if winner_is_baseline:
|
| 473 |
honest.append(
|
|
@@ -485,32 +483,32 @@ def reliability_label(mape, best_model, leaderboard):
|
|
| 485 |
"และควรรวมยอดเป็นรายไตรมาสซึ่งแม่นกว่ารายเดือน"
|
| 486 |
)
|
| 487 |
return {"level": level, "th": th, "honest_notes": honest, "any_model_beats_baseline": beat}
|
| 488 |
-
|
| 489 |
-
|
| 490 |
# ══════════════════════════════════════════════════════════════════
|
| 491 |
# ส่วนที่ 4 · ช่วงความเชื่อมั่นจากความผิดพลาดจริง
|
| 492 |
# ══════════════════════════════════════════════════════════════════
|
| 493 |
-
|
| 494 |
def empirical_intervals(mean, errors_by_h, level, fallback_std):
|
| 495 |
"""สร้างช่วงความเชื่อมั่นจาก "ความผิดพลาดที่เคยเกิดขึ้นจริง" ที่ระยะนั้น ๆ
|
| 496 |
-
|
| 497 |
นี่คือหัวใจเชิงการสอน: ช่วงกว้างขึ้นตามระยะ "เพราะวัดได้จริงว่ามันกว้างขึ้น"
|
| 498 |
ไม่ใช่เพราะสูตรบอกให้กว้าง
|
| 499 |
-
|
| 500 |
วิธีทำ
|
| 501 |
1. จาก backtest เก็บความผิดพลาดที่ระยะ h = 1, 2, 3, ... แยกกอง
|
| 502 |
2. σ(h) = ส่วนเบี่ยงเบนมาตรฐานของกองนั้น
|
| 503 |
3. บังคับให้ σ ไม่ลดลงตามระยะ (running max) — เพราะจุดทดสอบมีจำกัด
|
| 504 |
σ ดิบจึงแกว่ง ถ้าไม่บังคับ จะได้ช่วงเดือนที่ 5 แคบกว่าเดือนที่ 2 ซึ่งไม่สมเหตุผล
|
| 505 |
4. เลยระยะที่ทดสอบไว้ → ขยายต่อด้วย √(h/h_max) ตามทฤษฎี random walk
|
| 506 |
-
|
| 507 |
หมายเหตุที่ควรบอกผู้เรียน: ใช้ σ (สมมาตร) แทน quantile ดิบ เพราะจุดทดสอบระดับ
|
| 508 |
~10 จุดยังน้อยเกินกว่าจะประมาณ quantile หางได้เสถียร — บอกความไม่แน่นอน
|
| 509 |
ของ "การประมาณความไม่แน่นอน" ตรง ๆ ดีกว่าแกล้งทำเป็นแม่น
|
| 510 |
"""
|
| 511 |
z = Z.get(int(level), Z[80])
|
| 512 |
max_h = max(errors_by_h.keys()) if errors_by_h else 0
|
| 513 |
-
|
| 514 |
# σ ต่อระยะ จากความผิดพลาดจริง
|
| 515 |
sigma, sigma_raw, n_used, method = {}, {}, {}, "normal_approx"
|
| 516 |
running = 0.0
|
|
@@ -524,7 +522,7 @@ def empirical_intervals(mean, errors_by_h, level, fallback_std):
|
|
| 524 |
running = max(running, raw)
|
| 525 |
sigma[h] = running
|
| 526 |
method = "empirical_sigma_by_horizon"
|
| 527 |
-
|
| 528 |
lower, upper, sigma_out = [], [], []
|
| 529 |
for i, m in enumerate(mean, start=1):
|
| 530 |
if sigma:
|
|
@@ -538,7 +536,7 @@ def empirical_intervals(mean, errors_by_h, level, fallback_std):
|
|
| 538 |
lower.append(max(0.0, float(m - spread)))
|
| 539 |
upper.append(float(m + spread))
|
| 540 |
sigma_out.append(round(float(sd), 2))
|
| 541 |
-
|
| 542 |
detail = {
|
| 543 |
"method": method,
|
| 544 |
"z": z,
|
|
@@ -551,11 +549,11 @@ def empirical_intervals(mean, errors_by_h, level, fallback_std):
|
|
| 551 |
),
|
| 552 |
}
|
| 553 |
return np.array(lower), np.array(upper), detail
|
| 554 |
-
|
| 555 |
-
|
| 556 |
def apply_guardrails(fc, history):
|
| 557 |
"""บีบค่าพยากรณ์ให้อยู่ในกรอบที่สมเหตุสมผล — ปิดโดยปริยาย
|
| 558 |
-
|
| 559 |
ข้อควรระวังเชิงการสอน: การบีบค่าแบบนี้ทำ��ห้กราฟ "ดูดี" ขึ้น
|
| 560 |
แต่ซ่อนความจริงว่าโมเดลกำลังให้คำตอบที่เพี้ยน — ถ้าเปิดใช้ต้องรายงานเสมอ
|
| 561 |
"""
|
|
@@ -565,16 +563,16 @@ def apply_guardrails(fc, history):
|
|
| 565 |
lo, hi = avg / 3, avg * 3
|
| 566 |
clipped = [i for i, v in enumerate(fc) if v < lo or v > hi]
|
| 567 |
return np.clip(fc, lo, hi), clipped
|
| 568 |
-
|
| 569 |
-
|
| 570 |
def next_periods(last_date, n):
|
| 571 |
return [(last_date + pd.DateOffset(months=i + 1)).strftime("%Y-%m") for i in range(n)]
|
| 572 |
-
|
| 573 |
-
|
| 574 |
def _hash(rows, extra=""):
|
| 575 |
return hashlib.md5((json.dumps(rows, sort_keys=True, default=str) + extra).encode()).hexdigest()
|
| 576 |
-
|
| 577 |
-
|
| 578 |
def _cached_backtest(rows, s, horizon):
|
| 579 |
key = _hash(rows, f"|bt{horizon}")
|
| 580 |
if key in _CACHE:
|
|
@@ -587,17 +585,17 @@ def _cached_backtest(rows, s, horizon):
|
|
| 587 |
_CACHE.pop(next(iter(_CACHE)))
|
| 588 |
_CACHE[key] = (bt, meta)
|
| 589 |
return bt, meta
|
| 590 |
-
|
| 591 |
-
|
| 592 |
def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality, level):
|
| 593 |
"""อธิบายว่าทำไมผลพยากรณ์ถึงออกมาแบบนี้ — เป็นภาษาที่คนไม่ได้เรียนสถิติอ่านรู้เรื่อง
|
| 594 |
-
|
| 595 |
ตัวเลขพยากรณ์เปล่า ๆ ไม่ช่วยให้ใครตัดสินใจ สิ่งที่ช่วยคือการรู้ว่า
|
| 596 |
ค่านี้มาจากไหน · เชื่อได้แค่ไหน · และมันทำอะไรไม่ได้บ้าง
|
| 597 |
"""
|
| 598 |
v = np.asarray(s.values, dtype=float)
|
| 599 |
reasons = []
|
| 600 |
-
|
| 601 |
# ① ทำไมถึงเลือกโมเดลนี้ ────────────────────────────────────────
|
| 602 |
if leaderboard:
|
| 603 |
me = next((r for r in leaderboard if r["model"] == used), None)
|
|
@@ -613,7 +611,7 @@ def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality
|
|
| 613 |
t += (f" แต่ยัง \"ไม่ดีกว่า\" การเดาแบบง่าย ({bench['label']} {bench['mape']}%) "
|
| 614 |
"— แปลว่าข้อมูลชุดนี้ยังไม่มีสัญญาณพอให้โมเดลได้เปรียบ")
|
| 615 |
reasons.append({"title": "ทำไมถึงเลือกโมเดลนี้", "text": t})
|
| 616 |
-
|
| 617 |
# ② ตัวเลขที่ได้มาจากอะไร ───────────────────────────────────────
|
| 618 |
if used == "naive":
|
| 619 |
why = f"ทุกเดือนถูกตั้งให้เท่ากับเดือนล่าสุด ({v[-1]:,.0f} บาท) เส้นพยากรณ์จึงแบนราบ"
|
|
@@ -650,7 +648,7 @@ def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality
|
|
| 650 |
else:
|
| 651 |
why = "ค่าพยากรณ์คำนวณจากรูปแบบที่โมเดลจับได้จากข้อมูลในอดีต"
|
| 652 |
reasons.append({"title": "ตัวเลขที่ได้มาจากอะไร", "text": why})
|
| 653 |
-
|
| 654 |
# ③ ทิศทาง — เทียบกับอดีตที่คนดูจำได้ ──────────────────────────
|
| 655 |
last3, fc3 = float(np.mean(v[-3:])), float(np.mean(mean[:min(3, len(mean))]))
|
| 656 |
ch3 = (fc3 - last3) / last3 * 100 if last3 else 0
|
|
@@ -662,7 +660,7 @@ def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality
|
|
| 662 |
if ly:
|
| 663 |
t += f" · เทียบช่วงเดียวกันของปีก่อน ({ly:,.0f} บาท) ต่างกัน {(fc3 - ly) / ly * 100:+.1f}%"
|
| 664 |
reasons.append({"title": "ทิศทางเทียบกับของจริง", "text": t})
|
| 665 |
-
|
| 666 |
# ④ ทำไมช่วงถึงกว้างขนาดนี้ ─────────────────────────────────────
|
| 667 |
width = float(np.mean(np.asarray(ci_detail["sigma_by_horizon"], dtype=float))) * 2 * ci_detail["z"]
|
| 668 |
pct = width / float(np.mean(mean)) * 100 if np.mean(mean) else 0
|
|
@@ -672,7 +670,7 @@ def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality
|
|
| 672 |
if ci_detail.get("method") == "empirical_sigma_by_horizon":
|
| 673 |
t += " ความกว้างนี้วัดจากความผิดพลาดที่เกิดขึ้นจริงในการทดสอบย้อนหลัง ไม่ได้มาจากสูตร"
|
| 674 |
reasons.append({"title": "ทำไมช่วงถึงกว้างขนาดนี้", "text": t})
|
| 675 |
-
|
| 676 |
# ⑤ สิ่งที่ผลนี้ทำไม่ได้ ────────────────────────────────────────
|
| 677 |
cant = []
|
| 678 |
if acc and acc["mape"] >= 20:
|
|
@@ -683,7 +681,7 @@ def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality
|
|
| 683 |
cant.append("ไม่รู้เรื่องราคาคู่แข่ง งบลูกค้า หรือ pipeline ที่กำลังจะปิด "
|
| 684 |
"— ปัจจัยเหล่านี้ไม่ได้อยู่ในข้อมูลที่ป้อนเข้ามา")
|
| 685 |
reasons.append({"title": "สิ่งที่ผลนี้ทำไม่ได้", "text": " · ".join(cant)})
|
| 686 |
-
|
| 687 |
# ⑥ พาดหัวหนึ่งประโยค ──────────────────────────────────────────
|
| 688 |
lvl = acc["reliability"]["level"] if acc and acc.get("reliability") else "unknown"
|
| 689 |
headline = {
|
|
@@ -692,14 +690,14 @@ def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality
|
|
| 692 |
"low": "ผลนี้ใช้ได้แค่ดูแนวโน้มกว้าง ๆ ให้ตั้งเป้าเป็นยอดรวมไตรมาสแทนรายเดือน",
|
| 693 |
"very_low": "ข้อมูลผันผวนเกินกว่าจะพยากรณ์รายเดือนได้ ให้ใช้เป็นช่วงเท่านั้น",
|
| 694 |
}.get(lvl, "ยังประเมินความน่าเชื่อถือของผลนี้ไม่ได้ เพราะข้อมูลสั้นเกินไป")
|
| 695 |
-
|
| 696 |
return {"headline": headline, "reasons": reasons}
|
| 697 |
-
|
| 698 |
-
|
| 699 |
# ══════════════════════════════════════════════════════════════════
|
| 700 |
# ส่วนที่ 5 · Endpoints
|
| 701 |
# ══════════════════════════════════════════════════════════════════
|
| 702 |
-
|
| 703 |
@app.route("/")
|
| 704 |
def root():
|
| 705 |
return jsonify({
|
|
@@ -720,13 +718,13 @@ def root():
|
|
| 720 |
},
|
| 721 |
"models": MODEL_INFO,
|
| 722 |
})
|
| 723 |
-
|
| 724 |
-
|
| 725 |
@app.route("/health")
|
| 726 |
def health():
|
| 727 |
return jsonify({"status": "ok", "version": "2.1-teaching"})
|
| 728 |
-
|
| 729 |
-
|
| 730 |
@app.route("/api/sample")
|
| 731 |
def sample():
|
| 732 |
"""ข้อมูลจริง ENT — ให้ UI กดโหลดได้ในคลิกเดียว"""
|
|
@@ -736,8 +734,8 @@ def sample():
|
|
| 736 |
return jsonify(json.load(f))
|
| 737 |
except FileNotFoundError:
|
| 738 |
return jsonify({"error": "ไม่พบไฟล์ sample_ent.json ใน Space"}), 404
|
| 739 |
-
|
| 740 |
-
|
| 741 |
@app.route("/api/forecast", methods=["POST", "OPTIONS"])
|
| 742 |
def forecast():
|
| 743 |
if request.method == "OPTIONS":
|
|
@@ -752,19 +750,19 @@ def forecast():
|
|
| 752 |
level = level if level in Z else 80
|
| 753 |
use_guardrails = bool(body.get("guardrails", False))
|
| 754 |
drop_last = bool(body.get("drop_last_incomplete", False))
|
| 755 |
-
|
| 756 |
if len(rows) < MIN_HISTORY:
|
| 757 |
return jsonify({"error": f"ต้องการข้อมูลย้อนหลังอย่างน้อย {MIN_HISTORY} เดือน"}), 400
|
| 758 |
-
|
| 759 |
s, warns = to_series(rows)
|
| 760 |
if drop_last and len(s) > MIN_HISTORY:
|
| 761 |
warns.append(f"ตัดเดือนสุดท้าย ({s.index[-1].strftime('%Y-%m')}) ออกตามที่ร้องขอ")
|
| 762 |
s = s.iloc[:-1]
|
| 763 |
if len(s) < MIN_HISTORY:
|
| 764 |
return jsonify({"error": f"หลังทำความสะอาดแล้วเหลือ {len(s)} เดือน — ไม่พอ"}), 400
|
| 765 |
-
|
| 766 |
quality = data_quality(s)
|
| 767 |
-
|
| 768 |
# ── ทดสอบย้อนหลัง (ใช้ทั้งเลือกโมเดล วัดความแม่น และสร้างช่วงความเชื่อมั่น) ──
|
| 769 |
bt, bt_meta = _cached_backtest(rows if not drop_last else rows[:-1],
|
| 770 |
s, min(BACKTEST_HORIZON, max(1, periods)))
|
|
@@ -777,7 +775,7 @@ def forecast():
|
|
| 777 |
f"{why} — ระบบจึงบอก 'ความแม่นยำ' ไม่ได้เลย "
|
| 778 |
"ให้ถือว่าผลพยากรณ์นี้ยังพิสูจน์ไม่ได้ และห้ามนำไปตั้งเป้า"
|
| 779 |
)
|
| 780 |
-
|
| 781 |
# ── เลือกโมเดล ──
|
| 782 |
if want in MODELS:
|
| 783 |
used, selection = want, "ผู้ใช้เลือกเอง"
|
|
@@ -786,7 +784,7 @@ def forecast():
|
|
| 786 |
else:
|
| 787 |
used = "holtwinters" if len(s) >= SEASONAL_MIN else "linear"
|
| 788 |
selection = "ข้อมูลสั้นเกินกว่าจะทดสอบย้อนหลัง — เลือกตามความยาวข้อมูล"
|
| 789 |
-
|
| 790 |
# ── พยากรณ์อนาคตจริง (เทรนด้วยข้อมูลทั้งหมด) ──
|
| 791 |
fallback_used = None
|
| 792 |
try:
|
|
@@ -797,27 +795,27 @@ def forecast():
|
|
| 797 |
fallback_used = f"{used} ล้มเหลว ({type(e).__name__}) — ถอยไปใช้ seasonal_naive"
|
| 798 |
used = "seasonal_naive"
|
| 799 |
mean = MODELS[used](s, periods)
|
| 800 |
-
|
| 801 |
# ── ช่วงความเชื่อมั่นจากความผิดพลาดจริง ──
|
| 802 |
errors_by_h = bt.get(used, {}).get("errors_by_h", {}) if bt else {}
|
| 803 |
resid_std = float(np.std(np.diff(s.values))) if len(s) > 1 else float(np.std(s.values))
|
| 804 |
lower, upper, ci_detail = empirical_intervals(mean, errors_by_h, level, resid_std)
|
| 805 |
-
|
| 806 |
# ── guardrails (ปิดโดยปริยาย และรายงานเสมอ) ──
|
| 807 |
clipped = []
|
| 808 |
if use_guardrails:
|
| 809 |
mean, clipped = apply_guardrails(mean, s.values)
|
| 810 |
lower = np.minimum(lower, mean)
|
| 811 |
upper = np.maximum(upper, mean)
|
| 812 |
-
|
| 813 |
labels = next_periods(s.index[-1], periods)
|
| 814 |
-
|
| 815 |
# ── สรุปแนวโน้ม ──
|
| 816 |
last3 = float(np.mean(s.values[-3:]))
|
| 817 |
fc3 = float(np.mean(mean[:min(3, periods)]))
|
| 818 |
change = (fc3 - last3) / last3 if last3 else 0.0
|
| 819 |
trend = "up" if change > 0.05 else "down" if change < -0.05 else "stable"
|
| 820 |
-
|
| 821 |
# ── ความแม่นยำของโมเดลที่ใช้จริง ──
|
| 822 |
acc = None
|
| 823 |
if bt and used in bt:
|
|
@@ -833,15 +831,15 @@ def forecast():
|
|
| 833 |
"baseline_mape": None if bench_mape is None else round(bench_mape, 2),
|
| 834 |
}
|
| 835 |
acc["reliability"] = reliability_label(m["mape"], used, leaderboard)
|
| 836 |
-
|
| 837 |
explanation = explain_forecast(s, used, mean, labels, leaderboard, acc,
|
| 838 |
ci_detail, quality, level)
|
| 839 |
-
|
| 840 |
# confidence เก่า — เก็บไว้เพื่อความเข้ากันได้ แต่คำนวณจาก MAPE จริงแล้ว
|
| 841 |
confidence = None
|
| 842 |
if acc:
|
| 843 |
confidence = round(max(0.0, 1 - min(acc["mape"], 100) / 100), 3)
|
| 844 |
-
|
| 845 |
# ยอดรวมทั้งช่วง — ตัวเลขที่ผู้บริหารใช้ตัดสินใจได้จริงกว่ารายเดือน
|
| 846 |
agg_mape = None
|
| 847 |
if bt and used in bt:
|
|
@@ -857,7 +855,7 @@ def forecast():
|
|
| 857 |
"— ถ้าจะเอาไปตั้งเป้า ให้ตั้งเป็นยอดรวมไตรมาส/ครึ่งปี ไม่ใช่รายเดือน"
|
| 858 |
),
|
| 859 |
}
|
| 860 |
-
|
| 861 |
return jsonify({
|
| 862 |
"forecast": [
|
| 863 |
{"period": p, "predicted": round(float(mv), 2),
|
|
@@ -895,8 +893,8 @@ def forecast():
|
|
| 895 |
})
|
| 896 |
except Exception as e:
|
| 897 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 898 |
-
|
| 899 |
-
|
| 900 |
@app.route("/api/backtest", methods=["POST", "OPTIONS"])
|
| 901 |
def backtest_endpoint():
|
| 902 |
"""ทดสอบย้อนหลังอย่างเดียว — สำหรับหน้าจอ 'เทียบโมเดล' ในห้องเรียน"""
|
|
@@ -908,14 +906,14 @@ def backtest_endpoint():
|
|
| 908 |
horizon = max(1, min(12, int(body.get("horizon", BACKTEST_HORIZON))))
|
| 909 |
if len(rows) < MIN_HISTORY:
|
| 910 |
return jsonify({"error": f"ต้องการข้อมูลอย่างน้อย {MIN_HISTORY} เดือน"}), 400
|
| 911 |
-
|
| 912 |
s, warns = to_series(rows)
|
| 913 |
bt, meta = _cached_backtest(rows, s, horizon)
|
| 914 |
if not bt:
|
| 915 |
return jsonify({"error": meta if isinstance(meta, str) else "ทดสอบย้อนหลังไม่ได้"}), 400
|
| 916 |
-
|
| 917 |
leaderboard, bench = build_leaderboard(bt)
|
| 918 |
-
|
| 919 |
# ความผิดพลาดแยกตามระยะ — สอนว่า "ยิ่งไกลยิ่งแย่" เห็นเป็นตัวเลข
|
| 920 |
by_h = {}
|
| 921 |
for name, d in bt.items():
|
|
@@ -923,14 +921,14 @@ def backtest_endpoint():
|
|
| 923 |
str(h): round(float(np.mean(np.abs(e))), 2)
|
| 924 |
for h, e in d["errors_by_h"].items() if e
|
| 925 |
}
|
| 926 |
-
|
| 927 |
monthly_vs_total = [
|
| 928 |
{"model": r["model"], "label": r["label"],
|
| 929 |
"mape_monthly": r["mape"], "mape_total": r["mape_total"],
|
| 930 |
"improvement_pct_points": round(r["mape"] - r["mape_total"], 2)}
|
| 931 |
for r in leaderboard
|
| 932 |
]
|
| 933 |
-
|
| 934 |
return jsonify({
|
| 935 |
"leaderboard": leaderboard,
|
| 936 |
"monthly_vs_total": monthly_vs_total,
|
|
@@ -942,12 +940,12 @@ def backtest_endpoint():
|
|
| 942 |
})
|
| 943 |
except Exception as e:
|
| 944 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 945 |
-
|
| 946 |
-
|
| 947 |
@app.route("/api/decompose", methods=["POST", "OPTIONS"])
|
| 948 |
def decompose():
|
| 949 |
"""แยกอนุกรมเวลาเป็น แนวโน้ม + ฤดูกาล + ส่วนที่เหลือ (STL)
|
| 950 |
-
|
| 951 |
ค่าความแรง (strength) ตามนิยามของ Hyndman:
|
| 952 |
strength = max(0, 1 - Var(residual) / Var(residual + component))
|
| 953 |
> 0.6 ถือว่าองค์ประกอบนั้นเด่นชัด
|
|
@@ -956,27 +954,27 @@ def decompose():
|
|
| 956 |
return ("", 204)
|
| 957 |
try:
|
| 958 |
from statsmodels.tsa.seasonal import STL
|
| 959 |
-
|
| 960 |
body = request.get_json(force=True) or {}
|
| 961 |
rows = body.get("data", [])
|
| 962 |
if len(rows) < 2 * SEASONAL_PERIOD:
|
| 963 |
return jsonify({"error": f"ต้องการอย่างน้อย {2 * SEASONAL_PERIOD} เดือนจึงจะแยกฤดูกาลได้"}), 400
|
| 964 |
-
|
| 965 |
s, warns = to_series(rows)
|
| 966 |
res = STL(s, period=SEASONAL_PERIOD, robust=True).fit()
|
| 967 |
tr, se, rs = res.trend, res.seasonal, res.resid
|
| 968 |
-
|
| 969 |
def strength(comp):
|
| 970 |
v_r, v_rc = np.var(rs), np.var(rs + comp)
|
| 971 |
return round(float(max(0.0, 1 - v_r / v_rc)) if v_rc > 0 else 0.0, 3)
|
| 972 |
-
|
| 973 |
st_trend, st_seas = strength(tr), strength(se)
|
| 974 |
-
|
| 975 |
# ดัชนีฤดูกาลเฉลี่ยรายเดือน
|
| 976 |
seas_idx = pd.Series(se.values, index=s.index.month).groupby(level=0).mean()
|
| 977 |
month_th = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.",
|
| 978 |
"ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."]
|
| 979 |
-
|
| 980 |
reading = []
|
| 981 |
reading.append(
|
| 982 |
f"ความแรงของแนวโน้ม {st_trend} — " +
|
|
@@ -993,7 +991,7 @@ def decompose():
|
|
| 993 |
f"ส่วนที่อธิบายไม่ได้ (residual) กินความผันผวนไป {var_share*100:.0f}% "
|
| 994 |
+ ("— นี่คือเพดานของการพยากรณ์ด้วยข้อมูลชุดนี้" if var_share > 0.5 else "")
|
| 995 |
)
|
| 996 |
-
|
| 997 |
return jsonify({
|
| 998 |
"periods": [d.strftime("%Y-%m") for d in s.index],
|
| 999 |
"observed": [round(float(x), 2) for x in s.values],
|
|
@@ -1011,8 +1009,8 @@ def decompose():
|
|
| 1011 |
})
|
| 1012 |
except Exception as e:
|
| 1013 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
@app.route("/api/diagnostics", methods=["POST", "OPTIONS"])
|
| 1017 |
def diagnostics():
|
| 1018 |
"""ADF stationarity + ACF/PACF + คุณภาพข้อมูล — ใช้ก่อนเลือกพารามิเตอร์ SARIMA"""
|
|
@@ -1020,17 +1018,17 @@ def diagnostics():
|
|
| 1020 |
return ("", 204)
|
| 1021 |
try:
|
| 1022 |
from statsmodels.tsa.stattools import acf, adfuller, pacf
|
| 1023 |
-
|
| 1024 |
body = request.get_json(force=True) or {}
|
| 1025 |
rows = body.get("data", [])
|
| 1026 |
nlags = max(6, min(24, int(body.get("nlags", 24))))
|
| 1027 |
if len(rows) < MIN_HISTORY:
|
| 1028 |
return jsonify({"error": f"ต้องการข้อมูลอย่างน้อย {MIN_HISTORY} เดือน"}), 400
|
| 1029 |
-
|
| 1030 |
s, warns = to_series(rows)
|
| 1031 |
v = s.values.astype(float)
|
| 1032 |
nlags = min(nlags, len(v) // 2 - 1)
|
| 1033 |
-
|
| 1034 |
def adf(x, label):
|
| 1035 |
stat, p, lags, nobs, crit, _ = adfuller(x, autolag="AIC")
|
| 1036 |
return {
|
|
@@ -1044,18 +1042,18 @@ def diagnostics():
|
|
| 1044 |
if p < 0.05 else
|
| 1045 |
"ยังไม่นิ่ง (p ≥ 0.05) — ควร differencing"),
|
| 1046 |
}
|
| 1047 |
-
|
| 1048 |
adf_raw = adf(v, "ข้อมูลดิบ")
|
| 1049 |
adf_d1 = adf(np.diff(v), "หลัง differencing 1 ครั้ง") if len(v) > 3 else None
|
| 1050 |
-
|
| 1051 |
a = acf(v, nlags=nlags, fft=False)
|
| 1052 |
p_ = pacf(v, nlags=min(nlags, len(v) // 2 - 1))
|
| 1053 |
bound = 1.96 / np.sqrt(len(v))
|
| 1054 |
-
|
| 1055 |
sig_seasonal = [
|
| 1056 |
int(k) for k in [12, 24] if k <= nlags and abs(float(a[k])) > bound
|
| 1057 |
]
|
| 1058 |
-
|
| 1059 |
hints = []
|
| 1060 |
d_sug = 0 if adf_raw["is_stationary"] else 1
|
| 1061 |
hints.append(f"แนะนำ d = {d_sug} (จากผล ADF)")
|
|
@@ -1068,7 +1066,7 @@ def diagnostics():
|
|
| 1068 |
f"PACF ตัดหลัง lag {max(sig_early)} — ลอง p = {max(sig_early)}"
|
| 1069 |
if sig_early else "PACF ไม่มี lag ต้น ๆ ที่เด่น — ลอง p = 0 หรือ 1"
|
| 1070 |
)
|
| 1071 |
-
|
| 1072 |
return jsonify({
|
| 1073 |
"adf": [x for x in [adf_raw, adf_d1] if x],
|
| 1074 |
"acf": [{"lag": i, "value": round(float(a[i]), 4), "significant": bool(abs(float(a[i])) > bound)}
|
|
@@ -1082,29 +1080,29 @@ def diagnostics():
|
|
| 1082 |
})
|
| 1083 |
except Exception as e:
|
| 1084 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
# ══════════════════════════════════════════════════════════════════
|
| 1088 |
# ส่วนที่ 6 · ชุดข้อมูลกราฟ และการทำนายย้อนกลับ
|
| 1089 |
# ══════════════════════════════════════════════════════════════════
|
| 1090 |
-
|
| 1091 |
def _year_table(s):
|
| 1092 |
"""ตารางปี × เดือน พร้อมธงบอกว่าปีไหน/เดือนไหนข้อมูลยังไม่ครบ"""
|
| 1093 |
df = pd.DataFrame({"v": s.values, "y": s.index.year, "m": s.index.month})
|
| 1094 |
piv = df.pivot_table(index="y", columns="m", values="v", aggfunc="sum")
|
| 1095 |
piv = piv.reindex(columns=range(1, 13))
|
| 1096 |
return piv
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
def _nn(x):
|
| 1100 |
"""แปลง NaN เป็น None เพื่อให้ JSON อ่านได้"""
|
| 1101 |
return None if x is None or (isinstance(x, float) and not np.isfinite(x)) else round(float(x), 2)
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
@app.route("/api/charts", methods=["POST", "OPTIONS"])
|
| 1105 |
def charts():
|
| 1106 |
"""ชุดข้อมูลพร้อมวาดกราฟหลายแบบจากอนุกรมเดียวกัน
|
| 1107 |
-
|
| 1108 |
คืนเป็น "ข้อมูลที่พร้อมวาด" ทุกบล็อก — หน้าเว็บเลือกหยิบไปวาดได้เลย
|
| 1109 |
ทุกบล็อกที่อิงรายปีจะมีธง complete บอกว่าปีนั้นข้อมูลครบ 12 เดือนหรือยัง
|
| 1110 |
เพื่อไม่ให้กราฟหลอกตาว่า "ปีนี้ยอดตก" ทั้งที่ยังไม่จบปี
|
|
@@ -1117,17 +1115,17 @@ def charts():
|
|
| 1117 |
drop_last = bool(body.get("drop_last_incomplete", False))
|
| 1118 |
if len(rows) < 3:
|
| 1119 |
return jsonify({"error": "ต้องการข้อมูลอย่างน้อย 3 เดือน"}), 400
|
| 1120 |
-
|
| 1121 |
s, warns = to_series(rows)
|
| 1122 |
if drop_last and len(s) > 3:
|
| 1123 |
warns.append(f"ตัดเดือนสุดท้าย ({s.index[-1].strftime('%Y-%m')}) ออกตามที่ร้องขอ")
|
| 1124 |
s = s.iloc[:-1]
|
| 1125 |
-
|
| 1126 |
piv = _year_table(s)
|
| 1127 |
years = [int(y) for y in piv.index]
|
| 1128 |
month_th = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.",
|
| 1129 |
"ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."]
|
| 1130 |
-
|
| 1131 |
# ── 1. ยอดรวมรายปี + อัตราเติบโต YoY ──────────────────────────
|
| 1132 |
totals, counts = piv.sum(axis=1), piv.notna().sum(axis=1)
|
| 1133 |
yoy_annual = []
|
|
@@ -1141,7 +1139,7 @@ def charts():
|
|
| 1141 |
"avg_per_month": round(tot / cnt, 2) if cnt else None,
|
| 1142 |
})
|
| 1143 |
prev = tot
|
| 1144 |
-
|
| 1145 |
# ── 2. เทียบรายเดือนแยกปี (multi-line / grouped bar) ──────────
|
| 1146 |
yoy_monthly = {
|
| 1147 |
"months": month_th,
|
|
@@ -1149,7 +1147,7 @@ def charts():
|
|
| 1149 |
"values": [_nn(piv.loc[y, m]) for m in range(1, 13)]}
|
| 1150 |
for y in years],
|
| 1151 |
}
|
| 1152 |
-
|
| 1153 |
# ── 3. YTD pace — สะสมตั้งแต่ต้นปี เทียบจังหวะข้ามปี ──────────
|
| 1154 |
ytd = {"months": month_th, "series": []}
|
| 1155 |
for y in years:
|
|
@@ -1162,7 +1160,7 @@ def charts():
|
|
| 1162 |
acc += float(v)
|
| 1163 |
run.append(round(acc, 2))
|
| 1164 |
ytd["series"].append({"year": int(y), "values": run})
|
| 1165 |
-
|
| 1166 |
# ── 4. heatmap ปี × เดือน (ดัชนีเทียบค่าเฉลี่ยของปีนั้น) ──────
|
| 1167 |
heat = []
|
| 1168 |
for y in years:
|
|
@@ -1171,7 +1169,7 @@ def charts():
|
|
| 1171 |
heat.append({"year": int(y),
|
| 1172 |
"values": [None if pd.isna(row[m]) or not mean else round(float(row[m]) / mean, 3)
|
| 1173 |
for m in range(1, 13)]})
|
| 1174 |
-
|
| 1175 |
# ── 5. เส้นจริง + ค่าเฉลี่ยเคลื่อนที่ ────────────────────────
|
| 1176 |
ma = {
|
| 1177 |
"periods": [d.strftime("%Y-%m") for d in s.index],
|
|
@@ -1179,7 +1177,7 @@ def charts():
|
|
| 1179 |
"ma3": [_nn(x) for x in s.rolling(3).mean().values],
|
| 1180 |
"ma12": [_nn(x) for x in s.rolling(12).mean().values],
|
| 1181 |
}
|
| 1182 |
-
|
| 1183 |
# ── 6. อัตราเติบโต MoM และ YoY รายเดือน ──────────────────────
|
| 1184 |
mom = s.pct_change() * 100
|
| 1185 |
yoy_m = s.pct_change(12) * 100
|
|
@@ -1188,7 +1186,7 @@ def charts():
|
|
| 1188 |
"mom_pct": [_nn(x) for x in mom.values],
|
| 1189 |
"yoy_pct": [_nn(x) for x in yoy_m.values],
|
| 1190 |
}
|
| 1191 |
-
|
| 1192 |
# ── 7. การกระจายของยอดรายเดือน (histogram) ───────────────────
|
| 1193 |
cnts, edges = np.histogram(s.values, bins=min(12, max(5, len(s) // 7)))
|
| 1194 |
dist = {
|
|
@@ -1196,7 +1194,7 @@ def charts():
|
|
| 1196 |
"count": int(cnts[i])} for i in range(len(cnts))],
|
| 1197 |
"mean": round(float(s.mean()), 2), "median": round(float(s.median()), 2),
|
| 1198 |
}
|
| 1199 |
-
|
| 1200 |
# ── 8. โปรไฟล์รายเดือน (box plot) — ฤดูกาลซ้ำจริงไหม ─────────
|
| 1201 |
prof = []
|
| 1202 |
norm = pd.DataFrame({"v": s.values, "y": s.index.year, "m": s.index.month})
|
|
@@ -1211,7 +1209,7 @@ def charts():
|
|
| 1211 |
"median": round(float(g.median()), 3), "q3": round(q3, 3),
|
| 1212 |
"max": round(float(g.max()), 3), "iqr": round(q3 - q1, 3),
|
| 1213 |
"repeatable": bool(q3 - q1 < 0.35)})
|
| 1214 |
-
|
| 1215 |
# ── 9. แยกตามหมวด (เฉพาะเมื่อข้อมูลขาเข้ามีคอลัมน์ category) ──
|
| 1216 |
by_category = None
|
| 1217 |
cats = [r for r in rows if isinstance(r, dict) and r.get("category")]
|
|
@@ -1232,14 +1230,14 @@ def charts():
|
|
| 1232 |
"note": ("เกิน 3 หมวดให้ใช้ bar เรียงลำดับ ไม่ใช่ pie "
|
| 1233 |
"— ตาคนเทียบความยาวแท่งได้แม่นกว่ามุมของชิ้นพาย"),
|
| 1234 |
}
|
| 1235 |
-
|
| 1236 |
partial = [r["year"] for r in yoy_annual if not r["complete"]]
|
| 1237 |
if partial:
|
| 1238 |
warns.append(
|
| 1239 |
f"ปี {partial} ยังมีข้อมูลไม่ครบ 12 เดือน — ห้ามเทียบยอดรวมรายปีตรง ๆ "
|
| 1240 |
"ให้ใช้ค่าเฉลี่ยต่อเดือนหรือ YTD แทน"
|
| 1241 |
)
|
| 1242 |
-
|
| 1243 |
return jsonify({
|
| 1244 |
"yoy_annual": yoy_annual,
|
| 1245 |
"yoy_monthly": yoy_monthly,
|
|
@@ -1265,15 +1263,15 @@ def charts():
|
|
| 1265 |
})
|
| 1266 |
except Exception as e:
|
| 1267 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
@app.route("/api/hindcast", methods=["POST", "OPTIONS"])
|
| 1271 |
def hindcast():
|
| 1272 |
"""ทำนายย้อนกลับ — ให้โมเดลทายอดีตทีละเดือน โดยเห็นเฉพาะข้อมูลก่อนหน้า
|
| 1273 |
-
|
| 1274 |
ต่างจาก /api/backtest ตรงที่คืน "เส้นกราฟ" ให้วาดทับกับของจริงได้
|
| 1275 |
ทุกค่าที่คืนเป็น out-of-sample ล้วน ไม่มีการมองอนาคตแม้แต่จุดเดียว
|
| 1276 |
-
|
| 1277 |
และตอบคำถามสำคัญ: อัตราเติบโต YoY ที่โมเดลทำนาย
|
| 1278 |
สอดคล้องกับ YoY ที่เกิดขึ้นจริงหรือไม่
|
| 1279 |
"""
|
|
@@ -1289,27 +1287,27 @@ def hindcast():
|
|
| 1289 |
want = [m for m in want if m in MODELS]
|
| 1290 |
if not want:
|
| 1291 |
return jsonify({"error": "ไม่มีชื่อโมเดลที่ใช้ได้"}), 400
|
| 1292 |
-
|
| 1293 |
s, warns = to_series(rows)
|
| 1294 |
if drop_last and len(s) > MIN_HISTORY:
|
| 1295 |
warns.append(f"ตัดเดือนสุดท้าย ({s.index[-1].strftime('%Y-%m')}) ออกตามที่ร้องขอ")
|
| 1296 |
s = s.iloc[:-1]
|
| 1297 |
-
|
| 1298 |
n = len(s)
|
| 1299 |
min_train = max(MIN_TRAIN, SEASONAL_PERIOD + 3)
|
| 1300 |
if n < min_train + horizon + 2:
|
| 1301 |
min_train = max(SEASONAL_PERIOD + 3, n - horizon - 6)
|
| 1302 |
if n < min_train + horizon:
|
| 1303 |
return jsonify({"error": f"ต้องการข้อมูลอย่างน้อย {min_train + horizon} เดือน"}), 400
|
| 1304 |
-
|
| 1305 |
periods = [d.strftime("%Y-%m") for d in s.index]
|
| 1306 |
-
|
| 1307 |
ck = _hash(rows, f"|hc{horizon}|{drop_last}|{','.join(sorted(want))}")
|
| 1308 |
if ck in _CACHE:
|
| 1309 |
return jsonify(_CACHE[ck])
|
| 1310 |
-
|
| 1311 |
preds = {m: [None] * n for m in want}
|
| 1312 |
-
|
| 1313 |
t0 = time.time()
|
| 1314 |
for o in range(min_train, n - horizon + 1):
|
| 1315 |
train = s.iloc[:o]
|
|
@@ -1321,9 +1319,9 @@ def hindcast():
|
|
| 1321 |
except Exception:
|
| 1322 |
pass
|
| 1323 |
elapsed = round(time.time() - t0, 2)
|
| 1324 |
-
|
| 1325 |
actual = [round(float(x), 2) for x in s.values]
|
| 1326 |
-
|
| 1327 |
# ── ความผิดพลาดของแต่ละโมเดล เฉพาะจุดที่ทำนายได้ ─────────────
|
| 1328 |
summary = {}
|
| 1329 |
for m in want:
|
|
@@ -1341,13 +1339,13 @@ def hindcast():
|
|
| 1341 |
"rmse": round(sc["rmse"], 2),
|
| 1342 |
"bias": round(float(np.mean(p - a)), 2), # บวก = ทายสูงเกินจริงเป็นระบบ
|
| 1343 |
}
|
| 1344 |
-
|
| 1345 |
# ── ตรวจความสอดคล้องของ YoY จริง เทียบ YoY ที่โมเดลทำนาย ─────
|
| 1346 |
ydf = pd.DataFrame({"period": periods, "actual": actual})
|
| 1347 |
ydf["year"] = [int(str(p)[:4]) for p in periods]
|
| 1348 |
for m in want:
|
| 1349 |
ydf[m] = preds[m]
|
| 1350 |
-
|
| 1351 |
yoy_check = []
|
| 1352 |
yrs = sorted(ydf["year"].unique())
|
| 1353 |
for i in range(1, len(yrs)):
|
|
@@ -1379,7 +1377,7 @@ def hindcast():
|
|
| 1379 |
}
|
| 1380 |
if entry["models"]:
|
| 1381 |
yoy_check.append(entry)
|
| 1382 |
-
|
| 1383 |
# ── สรุปเป็นภาษาคน ────────────────────────────────────────────
|
| 1384 |
reading = []
|
| 1385 |
if summary:
|
|
@@ -1407,7 +1405,7 @@ def hindcast():
|
|
| 1407 |
reading.append(
|
| 1408 |
"ทุกค่าในกราฟนี้เป็น out-of-sample ล้วน — ณ แต่ละจุด โมเดลเห็นเฉพาะข้อมูลก่อนหน้านั้น"
|
| 1409 |
)
|
| 1410 |
-
|
| 1411 |
payload = {
|
| 1412 |
"periods": periods,
|
| 1413 |
"actual": actual,
|
|
@@ -1432,13 +1430,13 @@ def hindcast():
|
|
| 1432 |
return jsonify(payload)
|
| 1433 |
except Exception as e:
|
| 1434 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
@app.route("/api/describe", methods=["POST", "OPTIONS"])
|
| 1439 |
def describe():
|
| 1440 |
"""อธิบายข้อมูลชุดนี้ให้คนที่ไม่เคยวิเคราะห์ข้อมูลมาก่อนเข้าใจ
|
| 1441 |
-
|
| 1442 |
ตอบ 4 คำถามที่ผู้เริ่มต้นถามเสมอ:
|
| 1443 |
1. ข้อมูลนี้คืออะไร จากไหน ถึงไหน
|
| 1444 |
2. แต่ละคอลัมน์หมายถึงอะไร
|
|
@@ -1454,12 +1452,12 @@ def describe():
|
|
| 1454 |
subject = str(body.get("subject", "ยอดขาย"))
|
| 1455 |
if len(rows) < 2:
|
| 1456 |
return jsonify({"error": "ต้องการข้อมูลอย่างน้อย 2 เดือน"}), 400
|
| 1457 |
-
|
| 1458 |
s, warns = to_series(rows)
|
| 1459 |
q = data_quality(s)
|
| 1460 |
v = np.asarray(s.values, dtype=float)
|
| 1461 |
years = sorted({d.year for d in s.index})
|
| 1462 |
-
|
| 1463 |
# ── 1. เล่าให้ฟังเป็นภาษาคน ────────────────────────────────
|
| 1464 |
overview = [
|
| 1465 |
f"ข้อมูลชุดนี้คือ{subject} สรุปเป็น รายเดือน เดือนละ 1 ค่า",
|
|
@@ -1468,7 +1466,7 @@ def describe():
|
|
| 1468 |
f"ค่าเฉลี่ยเดือนละ {q['mean']:,.0f} {unit} · ต่ำสุด {q['min']:,.0f} · สูงสุด {q['max']:,.0f}",
|
| 1469 |
f"หนึ่งแถว = หนึ่งเดือน ไม่ใช่หนึ่งรายการขาย — รายการขายทั้งหมดในเดือนนั้นถูกบวกรวมกันแล้ว",
|
| 1470 |
]
|
| 1471 |
-
|
| 1472 |
# ── 2. พจนานุกรมคอลัมน์ ────────────────────────────────────
|
| 1473 |
dictionary = [
|
| 1474 |
{"column": "date", "th": "เดือน", "type": "ข้อความ รูปแบบ YYYY-MM",
|
|
@@ -1480,7 +1478,7 @@ def describe():
|
|
| 1480 |
dictionary.append({"column": "category", "th": "หมวดสินค้า", "type": "ข้อความ",
|
| 1481 |
"example": "Computer/PC",
|
| 1482 |
"meaning": "กลุ่มสินค้า ใช้แยกดูว่าหมวดไหนโตหรือหด (ไม่บังคับ)"})
|
| 1483 |
-
|
| 1484 |
# ── 3. ตัวอย่างข้อมูลจริง หัวและท้าย ───────────────────────
|
| 1485 |
def peek(idx):
|
| 1486 |
return [{"date": s.index[i].strftime("%Y-%m"), "sales": round(float(v[i]), 2)} for i in idx]
|
|
@@ -1489,7 +1487,7 @@ def describe():
|
|
| 1489 |
"last": peek(range(max(0, len(v) - 5), len(v))),
|
| 1490 |
"note": "แสดง 5 แถวแรกและ 5 แถวสุดท้าย จากทั้งหมด %d แถว" % len(v),
|
| 1491 |
}
|
| 1492 |
-
|
| 1493 |
# ── 4. เช็คลิสต์ความพร้อมของข้อมูล ─────────────────────────
|
| 1494 |
def item(label, ok, detail):
|
| 1495 |
return {"check": label, "status": "ok" if ok else "warn", "detail": detail}
|
|
@@ -1512,7 +1510,7 @@ def describe():
|
|
| 1512 |
"ดูปกติ" if not any("เดือนล่าสุด" in n for n in q["notes"])
|
| 1513 |
else f"{q['end']} ต่ำผิดปกติ — น่าจะยังเก็บไม่ครบเดือน ควรตัดออกก่อนฝึกโมเดล"),
|
| 1514 |
]
|
| 1515 |
-
|
| 1516 |
# ── 5. แผนการวิเคราะห์ ทีละขั้น ────────────────────────────
|
| 1517 |
plan = [
|
| 1518 |
{"step": 1, "title": "ทำความรู้จักข้อมูล",
|
|
@@ -1546,7 +1544,7 @@ def describe():
|
|
| 1546 |
"look_at": "explanation (ทำไมได้ผลแบบนี้) · ช่วงความเชื่อมั่น · total (ยอดรวมทั้งช่วง)",
|
| 1547 |
"why": "ตัวเลขเดียวไม่ช่วยตัดสินใจ ช่วง + เหตุผล + ข้อจำกัด คือสิ่งที่ช่วย"},
|
| 1548 |
]
|
| 1549 |
-
|
| 1550 |
return jsonify({
|
| 1551 |
"overview": overview,
|
| 1552 |
"dictionary": dictionary,
|
|
@@ -1570,10 +1568,7 @@ def describe():
|
|
| 1570 |
})
|
| 1571 |
except Exception as e:
|
| 1572 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
if __name__ == "__main__":
|
| 1576 |
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
Downloaded คู่มือติดตั้งและใช้สอน.md Show in Finder
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Predicty · Sales Forecast API (v2 — teaching edition)
|
| 3 |
=======================================================
|
| 4 |
Backend สำหรับหลักสูตร Non-Degree "นักวิเคราะห์ข้อมูลและแบบจำลองเชิงพยากรณ์"
|
| 5 |
กรณีศึกษา: ENT Group
|
| 6 |
+
|
| 7 |
ปรัชญาของเวอร์ชันนี้ — "อ่านผลอย่างซื่อสัตย์"
|
| 8 |
------------------------------------------------
|
| 9 |
v1 มีปัญหาเชิงการสอน 4 ข้อ ที่ v2 แก้ทั้งหมด:
|
| 10 |
+
|
| 11 |
1. confidence เดิมคือสูตรกุขึ้นเอง 1 - resid/mean → หน้าเว็บโชว์ 94%
|
| 12 |
ทั้งที่ข้อมูลจริง ENT พยากรณ์ผิดเฉลี่ย ~30%
|
| 13 |
v2: ความแม่นยำมาจาก "การทดสอบย้อนหลังจริง" (rolling-origin backtest) เท่านั้น
|
| 14 |
+
|
| 15 |
2. ช่วงความเชื่อมั่นเดิมใช้ค่าเบี่ยงเบนตัวเดียวคงที่ทุกเดือน
|
| 16 |
v2: ช่วงมาจาก "การกระจายของความผิดพลาดจริงที่ระยะนั้น ๆ" → กว้างขึ้นตามระยะ
|
| 17 |
+
|
| 18 |
3. guardrails เดิมบีบค่าพยากรณ์เงียบ ๆ ไว้ใน [avg/3, avg*3]
|
| 19 |
v2: ปิดโดยปริยาย และถ้าเปิดจะรายงานว่าเดือนไหนถูกตัดบ้าง
|
| 20 |
+
|
| 21 |
4. เลือกโมเดลจาก holdout ครั้งเดียว ≤3 เดือน แล้วไม่บอกผล
|
| 22 |
v2: rolling-origin หลายจุดเริ่ม + ส่ง "ตารางอันดับ" กลับไปให้ผู้เรียนเห็นทุกโมเดล
|
| 23 |
พร้อม skill score เทียบ baseline — ถ้าชนะ baseline ไม่ได้ ต้องกล้าบอก
|
| 24 |
+
|
| 25 |
Endpoints
|
| 26 |
---------
|
| 27 |
GET / ข้อมูลบริการ + รายการ endpoint
|
|
|
|
| 35 |
POST /api/charts ชุดข้อมูลกราฟหลายแบบ (YoY / YTD / heatmap / growth / …)
|
| 36 |
POST /api/hindcast ทำนายย้อนกลับทับเส้นจริง + ตรวจความสอดคล้องของ YoY
|
| 37 |
"""
|
| 38 |
+
|
| 39 |
import hashlib
|
| 40 |
import json
|
| 41 |
import os
|
| 42 |
import time
|
| 43 |
import warnings
|
| 44 |
+
|
| 45 |
import numpy as np
|
| 46 |
import pandas as pd
|
| 47 |
from flask import Flask, jsonify, request
|
| 48 |
from flask_cors import CORS
|
| 49 |
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
| 50 |
from statsmodels.tsa.statespace.sarimax import SARIMAX
|
| 51 |
+
|
| 52 |
warnings.filterwarnings("ignore")
|
| 53 |
+
|
| 54 |
app = Flask(__name__)
|
| 55 |
CORS(app, resources={r"/*": {"origins": "*"}})
|
| 56 |
+
|
| 57 |
# ค่า z สำหรับช่วงความเชื่อมั่นแบบ two-sided
|
| 58 |
Z = {80: 1.2816, 90: 1.6449, 95: 1.9600}
|
| 59 |
+
|
| 60 |
SEASONAL_PERIOD = 12 # ข้อมูลรายเดือน
|
| 61 |
MIN_HISTORY = 6 # น้อยกว่านี้พยากรณ์ไม่ได้เลย
|
| 62 |
MIN_TRAIN = 36 # ต้องมีอย่างน้อย 3 รอบปี ฤดูกาลถึงจะเทรนได้จริง
|
|
|
|
| 64 |
MAX_ORIGINS = 12 # จำกัดจำนวนจุดเริ่มใน backtest (คุมเวลาบน CPU ฟรีของ HF)
|
| 65 |
MIN_ORIGINS = 3 # น้อยกว่านี้ถือว่าวัดความแม่นยำไม่ได้ — จะไม่รายงาน MAPE เลย
|
| 66 |
BACKTEST_HORIZON = 6 # ทดสอบย้อนหลังที่ระยะ 1–6 เดือน
|
| 67 |
+
|
| 68 |
_CACHE = {} # cache ผล backtest ตาม hash ของข้อมูล
|
| 69 |
_CACHE_MAX = 32
|
| 70 |
+
|
| 71 |
+
|
| 72 |
# ══════════════════════════════════════════════════════════════════
|
| 73 |
# ส่วนที่ 1 · เตรียมข้อมูล + ตรวจคุณภาพ
|
| 74 |
# ══════════════════════════════════════════════════════════════════
|
| 75 |
+
|
| 76 |
def to_series(rows):
|
| 77 |
"""แปลง [{date:'2026-01', sales:123}, ...] → pandas Series รายเดือน
|
| 78 |
+
|
| 79 |
คืน (series, warnings) — warnings คือสิ่งที่ผู้เรีย���ควรรู้ก่อนเชื่อผลพยากรณ์
|
| 80 |
"""
|
| 81 |
warns = []
|
| 82 |
df = pd.DataFrame(rows)
|
| 83 |
+
|
| 84 |
if "date" not in df.columns or "sales" not in df.columns:
|
| 85 |
raise ValueError("ต้องมีคอลัมน์ 'date' (YYYY-MM) และ 'sales'")
|
| 86 |
+
|
| 87 |
df["date"] = pd.to_datetime(df["date"].astype(str).str.strip() + "-01", errors="coerce")
|
| 88 |
df["sales"] = pd.to_numeric(df["sales"], errors="coerce")
|
| 89 |
+
|
| 90 |
bad = int(df["date"].isna().sum())
|
| 91 |
if bad:
|
| 92 |
warns.append(f"มี {bad} แถวที่วันที่อ่านไม่ออก — ถูกตัดทิ้ง")
|
| 93 |
df = df.dropna(subset=["date"])
|
| 94 |
+
|
| 95 |
dup = int(df["date"].duplicated().sum())
|
| 96 |
if dup:
|
| 97 |
warns.append(f"มีเดือนซ้ำ {dup} รายการ — รวมกันด้วยผลบวก")
|
| 98 |
df = df.groupby("date", as_index=False)["sales"].sum()
|
| 99 |
+
|
| 100 |
df = df.sort_values("date").set_index("date")
|
| 101 |
s = df["sales"].astype(float).asfreq("MS")
|
| 102 |
+
|
| 103 |
gaps = int(s.isna().sum())
|
| 104 |
if gaps:
|
| 105 |
warns.append(
|
|
|
|
| 107 |
f"(ระวัง: ค่าที่เติมไม่ใช่ข้อมูลจริง)"
|
| 108 |
)
|
| 109 |
s = s.interpolate().ffill().bfill()
|
| 110 |
+
|
| 111 |
return s, warns
|
| 112 |
+
|
| 113 |
+
|
| 114 |
def data_quality(s):
|
| 115 |
"""ตรวจคุณภาพข้อมูล — สิ่งที่ต้องดูก่อนพยากรณ์เสมอ"""
|
| 116 |
v = s.values.astype(float)
|
| 117 |
n = len(v)
|
| 118 |
mean, std = float(np.mean(v)), float(np.std(v, ddof=1)) if n > 1 else 0.0
|
| 119 |
cv = std / mean if mean else 0.0
|
| 120 |
+
|
| 121 |
# outlier แบบ MAD (ทนต่อค่าสุดโต่งกว่า z-score ธรรมดา)
|
| 122 |
med = float(np.median(v))
|
| 123 |
mad = float(np.median(np.abs(v - med))) or 1e-9
|
|
|
|
| 126 |
{"period": s.index[i].strftime("%Y-%m"), "value": float(v[i]), "z_mad": round(float(zmod[i]), 2)}
|
| 127 |
for i in np.where(np.abs(zmod) > 3.5)[0]
|
| 128 |
]
|
| 129 |
+
|
| 130 |
notes = []
|
| 131 |
if n < MIN_TRAIN:
|
| 132 |
notes.append(f"ข้อมูลมี {n} เดือน — น้อยกว่า {MIN_TRAIN} เดือน (3 รอบปี) จับรูปแบบฤดูกาลได้ไม่น่าเชื่อถือ")
|
|
|
|
| 143 |
f"เดือนล่าสุด ({s.index[-1].strftime('%Y-%m')}) ต่ำกว่าค่าเฉลี่ย 3 เดือนก่อนหน้ามาก "
|
| 144 |
f"— มักแปลว่าเดือนนั้นยังเก็บข้อมูลไม่ครบ ควรตัดออกก่อนฝึกโมเดล"
|
| 145 |
)
|
| 146 |
+
|
| 147 |
return {
|
| 148 |
"n_months": n,
|
| 149 |
"start": s.index[0].strftime("%Y-%m"),
|
|
|
|
| 158 |
"outliers": outliers,
|
| 159 |
"notes": notes,
|
| 160 |
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
# ══════════════════════════════════════════════════════════════════
|
| 164 |
# ส่วนที่ 2 · โมเดล (baseline อยู่ก่อน — เพราะต้องเอาไว้เทียบเสมอ)
|
| 165 |
# ══════════════════════════════════════════════════════════════════
|
| 166 |
+
|
| 167 |
def fit_naive(train, h):
|
| 168 |
"""ทายว่าเดือนหน้าเท่ากับเดือนล่าสุด — baseline ที่ง่ายที่สุด"""
|
| 169 |
return np.full(h, float(train.values[-1]))
|
| 170 |
+
|
| 171 |
+
|
| 172 |
def fit_mean3(train, h):
|
| 173 |
"""ทายด้วยค่าเฉลี่ย 3 เดือนล่าสุด — baseline ที่ธุรกิจใช้จริงบ่อยที่สุด"""
|
| 174 |
return np.full(h, float(np.mean(train.values[-3:])))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
def fit_seasonal_naive(train, h):
|
| 178 |
"""ทายว่าเดือนนี้ปีนี้ = เดือนเดียวกันปีที่แล้ว — baseline มาตรฐานของข้อมูลมีฤดูกาล"""
|
| 179 |
if len(train) < SEASONAL_PERIOD:
|
| 180 |
return fit_mean3(train, h)
|
| 181 |
last_year = train.values[-SEASONAL_PERIOD:]
|
| 182 |
return np.tile(last_year, int(np.ceil(h / SEASONAL_PERIOD)))[:h].astype(float)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
def fit_linear(train, h):
|
| 186 |
"""ลากเส้นตรงผ่านข้อมูลทั้งหมดแล้วต่อไปข้างหน้า"""
|
| 187 |
x = np.arange(len(train))
|
| 188 |
coef = np.polyfit(x, train.values, 1)
|
| 189 |
return np.polyval(coef, np.arange(len(train), len(train) + h))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
def fit_holtwinters(train, h):
|
| 193 |
"""Exponential Smoothing: ระดับ + แนวโน้ม + ฤดูกาล (ถ้าข้อ���ูลยาวพอ)"""
|
| 194 |
use_seasonal = len(train) >= SEASONAL_MIN
|
|
|
|
| 200 |
initialization_method="estimated",
|
| 201 |
).fit(optimized=True)
|
| 202 |
return np.asarray(m.forecast(h), dtype=float)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
def fit_sarima(train, h):
|
| 206 |
"""SARIMA(1,1,1)(1,1,1)[12] — โมเดลอนุกรมเวลามาตรฐาน"""
|
| 207 |
seasonal_order = (1, 1, 1, SEASONAL_PERIOD) if len(train) >= SEASONAL_MIN else (0, 0, 0, 0)
|
|
|
|
| 213 |
enforce_invertibility=False,
|
| 214 |
).fit(disp=False)
|
| 215 |
return np.asarray(m.get_forecast(h).predicted_mean, dtype=float)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
def _lag_frame(values, lags=(1, 2, 3, 12), months=None):
|
| 219 |
"""แปลงอนุกรมเวลาเป็นตารางแบบ supervised learning
|
| 220 |
+
|
| 221 |
บทเรียน: ML ทั่วไปไม่รู้จัก "เวลา" เราต้องป้อนอดีตให้มันเองผ่าน lag features
|
| 222 |
"""
|
| 223 |
import numpy as _np
|
|
|
|
| 231 |
rows.append(row)
|
| 232 |
ys.append(v[t])
|
| 233 |
return _np.array(rows, dtype=float), _np.array(ys, dtype=float)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
def fit_ml_rf(train, h):
|
| 237 |
"""Random Forest บน lag features — ตัวแทนฝั่ง Machine Learning
|
| 238 |
+
|
| 239 |
พยากรณ์หลายงวดแบบ recursive: ทำนายเดือนถัดไป → เอาค่าที่ทำนายได้ไปเป็น input
|
| 240 |
ของเดือนถัดไปอีกที ความผิดพลาดจึงสะสมตามระยะ ซึ่งเป็นข้อจำกัดจริงของวิธีนี้
|
| 241 |
"""
|
| 242 |
from sklearn.ensemble import RandomForestRegressor
|
| 243 |
+
|
| 244 |
LAGS = (1, 2, 3, 12)
|
| 245 |
v = np.asarray(train.values, dtype=float)
|
| 246 |
if len(v) < max(LAGS) + 8:
|
| 247 |
return fit_mean3(train, h)
|
| 248 |
+
|
| 249 |
months = list(pd.DatetimeIndex(train.index).month)
|
| 250 |
X, y = _lag_frame(v, LAGS, months)
|
| 251 |
model = RandomForestRegressor(n_estimators=200, max_depth=8, min_samples_leaf=3,
|
| 252 |
random_state=7, n_jobs=1).fit(X, y)
|
| 253 |
+
|
| 254 |
hist = list(v)
|
| 255 |
last_month = months[-1]
|
| 256 |
out = []
|
|
|
|
| 261 |
out.append(pred)
|
| 262 |
hist.append(pred)
|
| 263 |
return np.asarray(out, dtype=float)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
MODELS = {
|
| 267 |
"naive": fit_naive,
|
| 268 |
"mean3": fit_mean3,
|
|
|
|
| 272 |
"sarima": fit_sarima,
|
| 273 |
"ml_rf": fit_ml_rf,
|
| 274 |
}
|
| 275 |
+
|
| 276 |
# baseline = โมเดลง่าย ๆ ที่โมเดลจริงต้องเอาชนะให้ได้ ไม่งั้นถือว่าไม่มีประโยชน์
|
| 277 |
BASELINES = ["naive", "mean3", "seasonal_naive"]
|
| 278 |
BENCHMARK = "seasonal_naive"
|
| 279 |
+
|
| 280 |
MODEL_INFO = {
|
| 281 |
"naive": {"th": "ทายเท่าเดือนล่าสุด", "kind": "baseline",
|
| 282 |
"how": "ค่าพยากรณ์ทุกเดือน = ค่าของเดือนสุดท้ายในข้อมูล"},
|
|
|
|
| 293 |
"ml_rf": {"th": "Random Forest (Machine Learning)", "kind": "model",
|
| 294 |
"how": "ป้อนค่าย้อนหลัง 1/2/3/12 เดือน + เดือนของปี ให้ต้นไม้ตัดสินใจหลายร้อยต้นโหวตกัน"},
|
| 295 |
}
|
| 296 |
+
|
| 297 |
# ชื่อเดิมจาก v1 — รับไว้เพื่อไม่ให้ของเก่าพัง
|
| 298 |
ALIASES = {"arima": "sarima", "hw": "holtwinters", "ets": "holtwinters"}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
# ══════════════════════════════════════════════════════════════════
|
| 302 |
# ส่วนที่ 3 · การวัดผล
|
| 303 |
# ══════════════════════════════════════════════════════════════════
|
| 304 |
+
|
| 305 |
def _safe_ape(actual, pred):
|
| 306 |
a = np.asarray(actual, dtype=float)
|
| 307 |
p = np.asarray(pred, dtype=float)
|
| 308 |
denom = np.where(np.abs(a) < 1e-9, np.nan, a)
|
| 309 |
return np.abs((a - p) / denom) * 100
|
| 310 |
+
|
| 311 |
+
|
| 312 |
def score(actual, pred):
|
| 313 |
"""MAPE / MdAPE / MAE / RMSE
|
| 314 |
+
|
| 315 |
MAPE = ผิดเฉลี่ยกี่ % — สื่อสารกับผู้บริหารง่ายที่สุด แต่ถูกเดือนที่ยอดต่ำดึงให้พองได้
|
| 316 |
MdAPE = ค่ากลางของ % ที่ผิด — ทนต่อเดือนสุดโต่งกว่า
|
| 317 |
MAE = ผิดเฉลี่ยกี่บาท — ใช้ตัดสินใจธุรกิจได้ตรงกว่า %
|
|
|
|
| 326 |
"mae": float(np.mean(np.abs(a - p))),
|
| 327 |
"rmse": float(np.sqrt(np.mean((a - p) ** 2))),
|
| 328 |
}
|
| 329 |
+
|
| 330 |
+
|
| 331 |
def rolling_origin_backtest(s, horizon=BACKTEST_HORIZON, max_origins=MAX_ORIGINS, models=None):
|
| 332 |
"""ทดสอบย้อนหลังแบบเลื่อนจุดเริ่ม — วิธีวัดความแม่นยำที่ถูกต้องของอนุกรมเวลา
|
| 333 |
+
|
| 334 |
หลักการ: ทำเสมือนย้อนเวลากลับไปยืนที่จุดหนึ่งในอดีต แล้วพยากรณ์ไปข้างหน้า
|
| 335 |
โดยเห็นเฉพาะข้อมูลก่อนจุดนั้น จากนั้นเทียบกับสิ่งที่เกิดขึ้นจริง — ทำซ้ำหลายจุด
|
| 336 |
+
|
| 337 |
ทำไมต้องเลื่อนหลายจุด: วัดครั้งเดียวคือการวัด "ดวง" ไม่ใช่ "ฝีมือ"
|
| 338 |
+
|
| 339 |
คืน: {model: {"metrics": {...}, "errors_by_h": {h: [error, ...]}}}
|
| 340 |
"""
|
| 341 |
models = models or list(MODELS.keys())
|
| 342 |
n = len(s)
|
| 343 |
FLOOR = max(MIN_HISTORY, SEASONAL_PERIOD + 3) # เล็กกว่านี้เทรนอะไรไม่ได้เลย
|
| 344 |
min_train = max(MIN_TRAIN, SEASONAL_PERIOD + 3) # อยากได้ 3 รอบปี ถ้าข้อมูลมีพอ
|
| 345 |
+
|
| 346 |
# ต้องได้จุดทดสอบอย่างน้อย MIN_ORIGINS จุด — ถ้าไม่พอ ยอมย่อหน้าต่างเทรนลง
|
| 347 |
want = n - horizon + 1 - MIN_ORIGINS
|
| 348 |
shortened = False
|
|
|
|
| 350 |
new_min = max(FLOOR, want)
|
| 351 |
shortened = new_min < min_train
|
| 352 |
min_train = new_min
|
| 353 |
+
|
| 354 |
if n < min_train + horizon:
|
| 355 |
return None, "ข้อมูลสั้นเกินไปสำหรับการทดสอบย้อนหลัง"
|
| 356 |
+
|
| 357 |
all_origins = list(range(min_train, n - horizon + 1))
|
| 358 |
if len(all_origins) < MIN_ORIGINS:
|
| 359 |
# จุดทดสอบน้อยกว่านี้ = วัด "ดวง" ไม่ใช่ "ฝีมือ" — ยอมบอกว่าวัดไม่ได้ ดีกว่ารายงานตัวเลขที่เชื่อไม่ได้
|
|
|
|
| 364 |
origins = [all_origins[i] for i in sorted(set(idx))]
|
| 365 |
else:
|
| 366 |
origins = all_origins
|
| 367 |
+
|
| 368 |
out = {m: {"errors_by_h": {h: [] for h in range(1, horizon + 1)},
|
| 369 |
"actual": [], "pred": [],
|
| 370 |
"agg_actual": [], "agg_pred": [], # ยอดรวมทั้งช่วง (เช่น รายไตรมาส)
|
| 371 |
"failed": 0} for m in models}
|
| 372 |
+
|
| 373 |
for o in origins:
|
| 374 |
train, test = s.iloc[:o], s.iloc[o:o + horizon]
|
| 375 |
for name in models:
|
|
|
|
| 387 |
out[name]["agg_pred"].append(float(np.sum(fc)))
|
| 388 |
for h in range(1, horizon + 1):
|
| 389 |
out[name]["errors_by_h"][h].append(float(test.values[h - 1] - fc[h - 1]))
|
| 390 |
+
|
| 391 |
result = {}
|
| 392 |
for name, d in out.items():
|
| 393 |
if len(d["actual"]) < horizon:
|
|
|
|
| 399 |
"n_origins": len(origins),
|
| 400 |
"failed_fits": d["failed"],
|
| 401 |
}
|
| 402 |
+
|
| 403 |
meta = {
|
| 404 |
"n_origins": len(origins),
|
| 405 |
"horizon": horizon,
|
|
|
|
| 413 |
"เพื่อให้มีจุดทดสอบพอ — ตัวเลขความแม่นยำจึงมีความไม่แน่นอนสูง"
|
| 414 |
)
|
| 415 |
return result, meta
|
| 416 |
+
|
| 417 |
+
|
| 418 |
def build_leaderboard(bt):
|
| 419 |
"""เรียงอันดับโมเดลตาม MAPE + คำนวณ skill score เทียบ baseline
|
| 420 |
+
|
| 421 |
skill = 1 - MAPE(model) / MAPE(seasonal_naive)
|
| 422 |
> 0 ดีกว่า baseline
|
| 423 |
= 0 เท่า baseline
|
|
|
|
| 425 |
"""
|
| 426 |
if not bt:
|
| 427 |
return [], None
|
| 428 |
+
|
| 429 |
bench = bt.get(BENCHMARK) or bt.get("mean3")
|
| 430 |
bench_mape = bench["metrics"]["mape"] if bench else None
|
| 431 |
+
|
| 432 |
rows = []
|
| 433 |
for name, d in bt.items():
|
| 434 |
m = d["metrics"]
|
|
|
|
| 450 |
})
|
| 451 |
rows.sort(key=lambda r: r["mape"])
|
| 452 |
return rows, bench_mape
|
| 453 |
+
|
| 454 |
+
|
| 455 |
def reliability_label(mape, best_model, leaderboard):
|
| 456 |
"""แปลตัวเลข MAPE เป็นภาษาที่ผู้บริหารและ นศ. ใช้ตัดสินใจ��ด้จริง"""
|
| 457 |
beat = any(r["kind"] == "model" and r["beats_baseline"] for r in leaderboard)
|
| 458 |
winner_is_baseline = MODEL_INFO.get(best_model, {}).get("kind") == "baseline"
|
| 459 |
+
|
| 460 |
if mape < 10:
|
| 461 |
level, th = "high", "แม่นสูง — ใช้วางแผนรายเดือนได้"
|
| 462 |
elif mape < 20:
|
|
|
|
| 465 |
level, th = "low", "ต่ำ — ใช้ได้แค่ดูแนวโน้มกว้าง ๆ ห้ามใช้ตั้งเป้ารายเดือน"
|
| 466 |
else:
|
| 467 |
level, th = "very_low", "ต่ำมาก — ข้อมูลผันผวนเกินกว่าจะพยากรณ์รายเดือนได้"
|
| 468 |
+
|
| 469 |
honest = []
|
| 470 |
if winner_is_baseline:
|
| 471 |
honest.append(
|
|
|
|
| 483 |
"และควรรวมยอดเป็นรายไตรมาสซึ่งแม่นกว่ารายเดือน"
|
| 484 |
)
|
| 485 |
return {"level": level, "th": th, "honest_notes": honest, "any_model_beats_baseline": beat}
|
| 486 |
+
|
| 487 |
+
|
| 488 |
# ══════════════════════════════════════════════════════════════════
|
| 489 |
# ส่วนที่ 4 · ช่วงความเชื่อมั่นจากความผิดพลาดจริง
|
| 490 |
# ══════════════════════════════════════════════════════════════════
|
| 491 |
+
|
| 492 |
def empirical_intervals(mean, errors_by_h, level, fallback_std):
|
| 493 |
"""สร้างช่วงความเชื่อมั่นจาก "ความผิดพลาดที่เคยเกิดขึ้นจริง" ที่ระยะนั้น ๆ
|
| 494 |
+
|
| 495 |
นี่คือหัวใจเชิงการสอน: ช่วงกว้างขึ้นตามระยะ "เพราะวัดได้จริงว่ามันกว้างขึ้น"
|
| 496 |
ไม่ใช่เพราะสูตรบอกให้กว้าง
|
| 497 |
+
|
| 498 |
วิธีทำ
|
| 499 |
1. จาก backtest เก็บความผิดพลาดที่ระยะ h = 1, 2, 3, ... แยกกอง
|
| 500 |
2. σ(h) = ส่วนเบี่ยงเบนมาตรฐานของกองนั้น
|
| 501 |
3. บังคับให้ σ ไม่ลดลงตามระยะ (running max) — เพราะจุดทดสอบมีจำกัด
|
| 502 |
σ ดิบจึงแกว่ง ถ้าไม่บังคับ จะได้ช่วงเดือนที่ 5 แคบกว่าเดือนที่ 2 ซึ่งไม่สมเหตุผล
|
| 503 |
4. เลยระยะที่ทดสอบไว้ → ขยายต่อด้วย √(h/h_max) ตามทฤษฎี random walk
|
| 504 |
+
|
| 505 |
หมายเหตุที่ควรบอกผู้เรียน: ใช้ σ (สมมาตร) แทน quantile ดิบ เพราะจุดทดสอบระดับ
|
| 506 |
~10 จุดยังน้อยเกินกว่าจะประมาณ quantile หางได้เสถียร — บอกความไม่แน่นอน
|
| 507 |
ของ "การประมาณความไม่แน่นอน" ตรง ๆ ดีกว่าแกล้งทำเป็นแม่น
|
| 508 |
"""
|
| 509 |
z = Z.get(int(level), Z[80])
|
| 510 |
max_h = max(errors_by_h.keys()) if errors_by_h else 0
|
| 511 |
+
|
| 512 |
# σ ต่อระยะ จากความผิดพลาดจริง
|
| 513 |
sigma, sigma_raw, n_used, method = {}, {}, {}, "normal_approx"
|
| 514 |
running = 0.0
|
|
|
|
| 522 |
running = max(running, raw)
|
| 523 |
sigma[h] = running
|
| 524 |
method = "empirical_sigma_by_horizon"
|
| 525 |
+
|
| 526 |
lower, upper, sigma_out = [], [], []
|
| 527 |
for i, m in enumerate(mean, start=1):
|
| 528 |
if sigma:
|
|
|
|
| 536 |
lower.append(max(0.0, float(m - spread)))
|
| 537 |
upper.append(float(m + spread))
|
| 538 |
sigma_out.append(round(float(sd), 2))
|
| 539 |
+
|
| 540 |
detail = {
|
| 541 |
"method": method,
|
| 542 |
"z": z,
|
|
|
|
| 549 |
),
|
| 550 |
}
|
| 551 |
return np.array(lower), np.array(upper), detail
|
| 552 |
+
|
| 553 |
+
|
| 554 |
def apply_guardrails(fc, history):
|
| 555 |
"""บีบค่าพยากรณ์ให้อยู่ในกรอบที่สมเหตุสมผล — ปิดโดยปริยาย
|
| 556 |
+
|
| 557 |
ข้อควรระวังเชิงการสอน: การบีบค่าแบบนี้ทำ��ห้กราฟ "ดูดี" ขึ้น
|
| 558 |
แต่ซ่อนความจริงว่าโมเดลกำลังให้คำตอบที่เพี้ยน — ถ้าเปิดใช้ต้องรายงานเสมอ
|
| 559 |
"""
|
|
|
|
| 563 |
lo, hi = avg / 3, avg * 3
|
| 564 |
clipped = [i for i, v in enumerate(fc) if v < lo or v > hi]
|
| 565 |
return np.clip(fc, lo, hi), clipped
|
| 566 |
+
|
| 567 |
+
|
| 568 |
def next_periods(last_date, n):
|
| 569 |
return [(last_date + pd.DateOffset(months=i + 1)).strftime("%Y-%m") for i in range(n)]
|
| 570 |
+
|
| 571 |
+
|
| 572 |
def _hash(rows, extra=""):
|
| 573 |
return hashlib.md5((json.dumps(rows, sort_keys=True, default=str) + extra).encode()).hexdigest()
|
| 574 |
+
|
| 575 |
+
|
| 576 |
def _cached_backtest(rows, s, horizon):
|
| 577 |
key = _hash(rows, f"|bt{horizon}")
|
| 578 |
if key in _CACHE:
|
|
|
|
| 585 |
_CACHE.pop(next(iter(_CACHE)))
|
| 586 |
_CACHE[key] = (bt, meta)
|
| 587 |
return bt, meta
|
| 588 |
+
|
| 589 |
+
|
| 590 |
def explain_forecast(s, used, mean, labels, leaderboard, acc, ci_detail, quality, level):
|
| 591 |
"""อธิบายว่าทำไมผลพยากรณ์ถึงออกมาแบบนี้ — เป็นภาษาที่คนไม่ได้เรียนสถิติอ่านรู้เรื่อง
|
| 592 |
+
|
| 593 |
ตัวเลขพยากรณ์เปล่า ๆ ไม่ช่วยให้ใครตัดสินใจ สิ่งที่ช่วยคือการรู้ว่า
|
| 594 |
ค่านี้มาจากไหน · เชื่อได้แค่ไหน · และมันทำอะไรไม่ได้บ้าง
|
| 595 |
"""
|
| 596 |
v = np.asarray(s.values, dtype=float)
|
| 597 |
reasons = []
|
| 598 |
+
|
| 599 |
# ① ทำไมถึงเลือกโมเดลนี้ ────────────────────────────────────────
|
| 600 |
if leaderboard:
|
| 601 |
me = next((r for r in leaderboard if r["model"] == used), None)
|
|
|
|
| 611 |
t += (f" แต่ยัง \"ไม่ดีกว่า\" การเดาแบบง่าย ({bench['label']} {bench['mape']}%) "
|
| 612 |
"— แปลว่าข้อมูลชุดนี้ยังไม่มีสัญญาณพอให้โมเดลได้เปรียบ")
|
| 613 |
reasons.append({"title": "ทำไมถึงเลือกโมเดลนี้", "text": t})
|
| 614 |
+
|
| 615 |
# ② ตัวเลขที่ได้มาจากอะไร ───────────────────────────────────────
|
| 616 |
if used == "naive":
|
| 617 |
why = f"ทุกเดือนถูกตั้งให้เท่ากับเดือนล่าสุด ({v[-1]:,.0f} บาท) เส้นพยากรณ์จึงแบนราบ"
|
|
|
|
| 648 |
else:
|
| 649 |
why = "ค่าพยากรณ์คำนวณจากรูปแบบที่โมเดลจับได้จากข้อมูลในอดีต"
|
| 650 |
reasons.append({"title": "ตัวเลขที่ได้มาจากอะไร", "text": why})
|
| 651 |
+
|
| 652 |
# ③ ทิศทาง — เทียบกับอดีตที่คนดูจำได้ ──────────────────────────
|
| 653 |
last3, fc3 = float(np.mean(v[-3:])), float(np.mean(mean[:min(3, len(mean))]))
|
| 654 |
ch3 = (fc3 - last3) / last3 * 100 if last3 else 0
|
|
|
|
| 660 |
if ly:
|
| 661 |
t += f" · เทียบช่วงเดียวกันของปีก่อน ({ly:,.0f} บาท) ต่างกัน {(fc3 - ly) / ly * 100:+.1f}%"
|
| 662 |
reasons.append({"title": "ทิศทางเทียบกับของจริง", "text": t})
|
| 663 |
+
|
| 664 |
# ④ ทำไมช่วงถึงกว้างขนาดนี้ ─────────────────────────────────────
|
| 665 |
width = float(np.mean(np.asarray(ci_detail["sigma_by_horizon"], dtype=float))) * 2 * ci_detail["z"]
|
| 666 |
pct = width / float(np.mean(mean)) * 100 if np.mean(mean) else 0
|
|
|
|
| 670 |
if ci_detail.get("method") == "empirical_sigma_by_horizon":
|
| 671 |
t += " ความกว้างนี้วัดจากความผิดพลาดที่เกิดขึ้นจริงในการทดสอบย้อนหลัง ไม่ได้มาจากสูตร"
|
| 672 |
reasons.append({"title": "ทำไมช่วงถึงกว้างขนาดนี้", "text": t})
|
| 673 |
+
|
| 674 |
# ⑤ สิ่งที่ผลนี้ทำไม่ได้ ────────────────────────────────────────
|
| 675 |
cant = []
|
| 676 |
if acc and acc["mape"] >= 20:
|
|
|
|
| 681 |
cant.append("ไม่รู้เรื่องราคาคู่แข่ง งบลูกค้า หรือ pipeline ที่กำลังจะปิด "
|
| 682 |
"— ปัจจัยเหล่านี้ไม่ได้อยู่ในข้อมูลที่ป้อนเข้ามา")
|
| 683 |
reasons.append({"title": "สิ่งที่ผลนี้ทำไม่ได้", "text": " · ".join(cant)})
|
| 684 |
+
|
| 685 |
# ⑥ พาดหัวหนึ่งประโยค ──────────────────────────────────────────
|
| 686 |
lvl = acc["reliability"]["level"] if acc and acc.get("reliability") else "unknown"
|
| 687 |
headline = {
|
|
|
|
| 690 |
"low": "ผลนี้ใช้ได้แค่ดูแนวโน้มกว้าง ๆ ให้ตั้งเป้าเป็นยอดรวมไตรมาสแทนรายเดือน",
|
| 691 |
"very_low": "ข้อมูลผันผวนเกินกว่าจะพยากรณ์รายเดือนได้ ให้ใช้เป็นช่วงเท่านั้น",
|
| 692 |
}.get(lvl, "ยังประเมินความน่าเชื่อถือของผลนี้ไม่ได้ เพราะข้อมูลสั้นเกินไป")
|
| 693 |
+
|
| 694 |
return {"headline": headline, "reasons": reasons}
|
| 695 |
+
|
| 696 |
+
|
| 697 |
# ══════════════════════════════════════════════════════════════════
|
| 698 |
# ส่วนที่ 5 · Endpoints
|
| 699 |
# ══════════════════════════════════════════════════════════════════
|
| 700 |
+
|
| 701 |
@app.route("/")
|
| 702 |
def root():
|
| 703 |
return jsonify({
|
|
|
|
| 718 |
},
|
| 719 |
"models": MODEL_INFO,
|
| 720 |
})
|
| 721 |
+
|
| 722 |
+
|
| 723 |
@app.route("/health")
|
| 724 |
def health():
|
| 725 |
return jsonify({"status": "ok", "version": "2.1-teaching"})
|
| 726 |
+
|
| 727 |
+
|
| 728 |
@app.route("/api/sample")
|
| 729 |
def sample():
|
| 730 |
"""ข้อมูลจริง ENT — ให้ UI กดโหลดได้ในคลิกเดียว"""
|
|
|
|
| 734 |
return jsonify(json.load(f))
|
| 735 |
except FileNotFoundError:
|
| 736 |
return jsonify({"error": "ไม่พบไฟล์ sample_ent.json ใน Space"}), 404
|
| 737 |
+
|
| 738 |
+
|
| 739 |
@app.route("/api/forecast", methods=["POST", "OPTIONS"])
|
| 740 |
def forecast():
|
| 741 |
if request.method == "OPTIONS":
|
|
|
|
| 750 |
level = level if level in Z else 80
|
| 751 |
use_guardrails = bool(body.get("guardrails", False))
|
| 752 |
drop_last = bool(body.get("drop_last_incomplete", False))
|
| 753 |
+
|
| 754 |
if len(rows) < MIN_HISTORY:
|
| 755 |
return jsonify({"error": f"ต้องการข้อมูลย้อนหลังอย่างน้อย {MIN_HISTORY} เดือน"}), 400
|
| 756 |
+
|
| 757 |
s, warns = to_series(rows)
|
| 758 |
if drop_last and len(s) > MIN_HISTORY:
|
| 759 |
warns.append(f"ตัดเดือนสุดท้าย ({s.index[-1].strftime('%Y-%m')}) ออกตามที่ร้องขอ")
|
| 760 |
s = s.iloc[:-1]
|
| 761 |
if len(s) < MIN_HISTORY:
|
| 762 |
return jsonify({"error": f"หลังทำความสะอาดแล้วเหลือ {len(s)} เดือน — ไม่พอ"}), 400
|
| 763 |
+
|
| 764 |
quality = data_quality(s)
|
| 765 |
+
|
| 766 |
# ── ทดสอบย้อนหลัง (ใช้ทั้งเลือกโมเดล วัดความแม่น และสร้างช่วงความเชื่อมั่น) ──
|
| 767 |
bt, bt_meta = _cached_backtest(rows if not drop_last else rows[:-1],
|
| 768 |
s, min(BACKTEST_HORIZON, max(1, periods)))
|
|
|
|
| 775 |
f"{why} — ระบบจึงบอก 'ความแม่นยำ' ไม่ได้เลย "
|
| 776 |
"ให้ถือว่าผลพยากรณ์นี้ยังพิสูจน์ไม่ได้ และห้ามนำไปตั้งเป้า"
|
| 777 |
)
|
| 778 |
+
|
| 779 |
# ── เลือกโมเดล ──
|
| 780 |
if want in MODELS:
|
| 781 |
used, selection = want, "ผู้ใช้เลือกเอง"
|
|
|
|
| 784 |
else:
|
| 785 |
used = "holtwinters" if len(s) >= SEASONAL_MIN else "linear"
|
| 786 |
selection = "ข้อมูลสั้นเกินกว่าจะทดสอบย้อนหลัง — เลือกตามความยาวข้อมูล"
|
| 787 |
+
|
| 788 |
# ── พยากรณ์อนาคตจริง (เทรนด้วยข้อมูลทั้งหมด) ──
|
| 789 |
fallback_used = None
|
| 790 |
try:
|
|
|
|
| 795 |
fallback_used = f"{used} ล้มเหลว ({type(e).__name__}) — ถอยไปใช้ seasonal_naive"
|
| 796 |
used = "seasonal_naive"
|
| 797 |
mean = MODELS[used](s, periods)
|
| 798 |
+
|
| 799 |
# ── ช่วงความเชื่อมั่นจากความผิดพลาดจริง ──
|
| 800 |
errors_by_h = bt.get(used, {}).get("errors_by_h", {}) if bt else {}
|
| 801 |
resid_std = float(np.std(np.diff(s.values))) if len(s) > 1 else float(np.std(s.values))
|
| 802 |
lower, upper, ci_detail = empirical_intervals(mean, errors_by_h, level, resid_std)
|
| 803 |
+
|
| 804 |
# ── guardrails (ปิดโดยปริยาย และรายงานเสมอ) ──
|
| 805 |
clipped = []
|
| 806 |
if use_guardrails:
|
| 807 |
mean, clipped = apply_guardrails(mean, s.values)
|
| 808 |
lower = np.minimum(lower, mean)
|
| 809 |
upper = np.maximum(upper, mean)
|
| 810 |
+
|
| 811 |
labels = next_periods(s.index[-1], periods)
|
| 812 |
+
|
| 813 |
# ── สรุปแนวโน้ม ──
|
| 814 |
last3 = float(np.mean(s.values[-3:]))
|
| 815 |
fc3 = float(np.mean(mean[:min(3, periods)]))
|
| 816 |
change = (fc3 - last3) / last3 if last3 else 0.0
|
| 817 |
trend = "up" if change > 0.05 else "down" if change < -0.05 else "stable"
|
| 818 |
+
|
| 819 |
# ── ความแม่นยำของโมเดลที่ใช้จริง ──
|
| 820 |
acc = None
|
| 821 |
if bt and used in bt:
|
|
|
|
| 831 |
"baseline_mape": None if bench_mape is None else round(bench_mape, 2),
|
| 832 |
}
|
| 833 |
acc["reliability"] = reliability_label(m["mape"], used, leaderboard)
|
| 834 |
+
|
| 835 |
explanation = explain_forecast(s, used, mean, labels, leaderboard, acc,
|
| 836 |
ci_detail, quality, level)
|
| 837 |
+
|
| 838 |
# confidence เก่า — เก็บไว้เพื่อความเข้ากันได้ แต่คำนวณจาก MAPE จริงแล้ว
|
| 839 |
confidence = None
|
| 840 |
if acc:
|
| 841 |
confidence = round(max(0.0, 1 - min(acc["mape"], 100) / 100), 3)
|
| 842 |
+
|
| 843 |
# ยอดรวมทั้งช่วง — ตัวเลขที่ผู้บริหารใช้ตัดสินใจได้จริงกว่ารายเดือน
|
| 844 |
agg_mape = None
|
| 845 |
if bt and used in bt:
|
|
|
|
| 855 |
"— ถ้าจะเอาไปตั้งเป้า ให้ตั้งเป็นยอดรวมไตรมาส/ครึ่งปี ไม่ใช่รายเดือน"
|
| 856 |
),
|
| 857 |
}
|
| 858 |
+
|
| 859 |
return jsonify({
|
| 860 |
"forecast": [
|
| 861 |
{"period": p, "predicted": round(float(mv), 2),
|
|
|
|
| 893 |
})
|
| 894 |
except Exception as e:
|
| 895 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 896 |
+
|
| 897 |
+
|
| 898 |
@app.route("/api/backtest", methods=["POST", "OPTIONS"])
|
| 899 |
def backtest_endpoint():
|
| 900 |
"""ทดสอบย้อนหลังอย่างเดียว — สำหรับหน้าจอ 'เทียบโมเดล' ในห้องเรียน"""
|
|
|
|
| 906 |
horizon = max(1, min(12, int(body.get("horizon", BACKTEST_HORIZON))))
|
| 907 |
if len(rows) < MIN_HISTORY:
|
| 908 |
return jsonify({"error": f"ต้องการข้อมูลอย่างน้อย {MIN_HISTORY} เดือน"}), 400
|
| 909 |
+
|
| 910 |
s, warns = to_series(rows)
|
| 911 |
bt, meta = _cached_backtest(rows, s, horizon)
|
| 912 |
if not bt:
|
| 913 |
return jsonify({"error": meta if isinstance(meta, str) else "ทดสอบย้อนหลังไม่ได้"}), 400
|
| 914 |
+
|
| 915 |
leaderboard, bench = build_leaderboard(bt)
|
| 916 |
+
|
| 917 |
# ความผิดพลาดแยกตามระยะ — สอนว่า "ยิ่งไกลยิ่งแย่" เห็นเป็นตัวเลข
|
| 918 |
by_h = {}
|
| 919 |
for name, d in bt.items():
|
|
|
|
| 921 |
str(h): round(float(np.mean(np.abs(e))), 2)
|
| 922 |
for h, e in d["errors_by_h"].items() if e
|
| 923 |
}
|
| 924 |
+
|
| 925 |
monthly_vs_total = [
|
| 926 |
{"model": r["model"], "label": r["label"],
|
| 927 |
"mape_monthly": r["mape"], "mape_total": r["mape_total"],
|
| 928 |
"improvement_pct_points": round(r["mape"] - r["mape_total"], 2)}
|
| 929 |
for r in leaderboard
|
| 930 |
]
|
| 931 |
+
|
| 932 |
return jsonify({
|
| 933 |
"leaderboard": leaderboard,
|
| 934 |
"monthly_vs_total": monthly_vs_total,
|
|
|
|
| 940 |
})
|
| 941 |
except Exception as e:
|
| 942 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 943 |
+
|
| 944 |
+
|
| 945 |
@app.route("/api/decompose", methods=["POST", "OPTIONS"])
|
| 946 |
def decompose():
|
| 947 |
"""แยกอนุกรมเวลาเป็น แนวโน้ม + ฤดูกาล + ส่วนที่เหลือ (STL)
|
| 948 |
+
|
| 949 |
ค่าความแรง (strength) ตามนิยามของ Hyndman:
|
| 950 |
strength = max(0, 1 - Var(residual) / Var(residual + component))
|
| 951 |
> 0.6 ถือว่าองค์ประกอบนั้นเด่นชัด
|
|
|
|
| 954 |
return ("", 204)
|
| 955 |
try:
|
| 956 |
from statsmodels.tsa.seasonal import STL
|
| 957 |
+
|
| 958 |
body = request.get_json(force=True) or {}
|
| 959 |
rows = body.get("data", [])
|
| 960 |
if len(rows) < 2 * SEASONAL_PERIOD:
|
| 961 |
return jsonify({"error": f"ต้องการอย่างน้อย {2 * SEASONAL_PERIOD} เดือนจึงจะแยกฤดูกาลได้"}), 400
|
| 962 |
+
|
| 963 |
s, warns = to_series(rows)
|
| 964 |
res = STL(s, period=SEASONAL_PERIOD, robust=True).fit()
|
| 965 |
tr, se, rs = res.trend, res.seasonal, res.resid
|
| 966 |
+
|
| 967 |
def strength(comp):
|
| 968 |
v_r, v_rc = np.var(rs), np.var(rs + comp)
|
| 969 |
return round(float(max(0.0, 1 - v_r / v_rc)) if v_rc > 0 else 0.0, 3)
|
| 970 |
+
|
| 971 |
st_trend, st_seas = strength(tr), strength(se)
|
| 972 |
+
|
| 973 |
# ดัชนีฤดูกาลเฉลี่ยรายเดือน
|
| 974 |
seas_idx = pd.Series(se.values, index=s.index.month).groupby(level=0).mean()
|
| 975 |
month_th = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.",
|
| 976 |
"ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."]
|
| 977 |
+
|
| 978 |
reading = []
|
| 979 |
reading.append(
|
| 980 |
f"ความแรงของแนวโน้ม {st_trend} — " +
|
|
|
|
| 991 |
f"ส่วนที่อธิบายไม่ได้ (residual) กินความผันผวนไป {var_share*100:.0f}% "
|
| 992 |
+ ("— นี่คือเพดานของการพยากรณ์ด้วยข้อมูลชุดนี้" if var_share > 0.5 else "")
|
| 993 |
)
|
| 994 |
+
|
| 995 |
return jsonify({
|
| 996 |
"periods": [d.strftime("%Y-%m") for d in s.index],
|
| 997 |
"observed": [round(float(x), 2) for x in s.values],
|
|
|
|
| 1009 |
})
|
| 1010 |
except Exception as e:
|
| 1011 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
@app.route("/api/diagnostics", methods=["POST", "OPTIONS"])
|
| 1015 |
def diagnostics():
|
| 1016 |
"""ADF stationarity + ACF/PACF + คุณภาพข้อมูล — ใช้ก่อนเลือกพารามิเตอร์ SARIMA"""
|
|
|
|
| 1018 |
return ("", 204)
|
| 1019 |
try:
|
| 1020 |
from statsmodels.tsa.stattools import acf, adfuller, pacf
|
| 1021 |
+
|
| 1022 |
body = request.get_json(force=True) or {}
|
| 1023 |
rows = body.get("data", [])
|
| 1024 |
nlags = max(6, min(24, int(body.get("nlags", 24))))
|
| 1025 |
if len(rows) < MIN_HISTORY:
|
| 1026 |
return jsonify({"error": f"ต้องการข้อมูลอย่างน้อย {MIN_HISTORY} เดือน"}), 400
|
| 1027 |
+
|
| 1028 |
s, warns = to_series(rows)
|
| 1029 |
v = s.values.astype(float)
|
| 1030 |
nlags = min(nlags, len(v) // 2 - 1)
|
| 1031 |
+
|
| 1032 |
def adf(x, label):
|
| 1033 |
stat, p, lags, nobs, crit, _ = adfuller(x, autolag="AIC")
|
| 1034 |
return {
|
|
|
|
| 1042 |
if p < 0.05 else
|
| 1043 |
"ยังไม่นิ่ง (p ≥ 0.05) — ควร differencing"),
|
| 1044 |
}
|
| 1045 |
+
|
| 1046 |
adf_raw = adf(v, "ข้อมูลดิบ")
|
| 1047 |
adf_d1 = adf(np.diff(v), "หลัง differencing 1 ครั้ง") if len(v) > 3 else None
|
| 1048 |
+
|
| 1049 |
a = acf(v, nlags=nlags, fft=False)
|
| 1050 |
p_ = pacf(v, nlags=min(nlags, len(v) // 2 - 1))
|
| 1051 |
bound = 1.96 / np.sqrt(len(v))
|
| 1052 |
+
|
| 1053 |
sig_seasonal = [
|
| 1054 |
int(k) for k in [12, 24] if k <= nlags and abs(float(a[k])) > bound
|
| 1055 |
]
|
| 1056 |
+
|
| 1057 |
hints = []
|
| 1058 |
d_sug = 0 if adf_raw["is_stationary"] else 1
|
| 1059 |
hints.append(f"แนะนำ d = {d_sug} (จากผล ADF)")
|
|
|
|
| 1066 |
f"PACF ตัดหลัง lag {max(sig_early)} — ลอง p = {max(sig_early)}"
|
| 1067 |
if sig_early else "PACF ไม่มี lag ต้น ๆ ที่เด่น — ลอง p = 0 หรือ 1"
|
| 1068 |
)
|
| 1069 |
+
|
| 1070 |
return jsonify({
|
| 1071 |
"adf": [x for x in [adf_raw, adf_d1] if x],
|
| 1072 |
"acf": [{"lag": i, "value": round(float(a[i]), 4), "significant": bool(abs(float(a[i])) > bound)}
|
|
|
|
| 1080 |
})
|
| 1081 |
except Exception as e:
|
| 1082 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
# ══════════════════════════════════════════════════════════════════
|
| 1086 |
# ส่วนที่ 6 · ชุดข้อมูลกราฟ และการทำนายย้อนกลับ
|
| 1087 |
# ══════════════════════════════════════════════════════════════════
|
| 1088 |
+
|
| 1089 |
def _year_table(s):
|
| 1090 |
"""ตารางปี × เดือน พร้อมธงบอกว่าปีไหน/เดือนไหนข้อมูลยังไม่ครบ"""
|
| 1091 |
df = pd.DataFrame({"v": s.values, "y": s.index.year, "m": s.index.month})
|
| 1092 |
piv = df.pivot_table(index="y", columns="m", values="v", aggfunc="sum")
|
| 1093 |
piv = piv.reindex(columns=range(1, 13))
|
| 1094 |
return piv
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
def _nn(x):
|
| 1098 |
"""แปลง NaN เป็น None เพื่อให้ JSON อ่านได้"""
|
| 1099 |
return None if x is None or (isinstance(x, float) and not np.isfinite(x)) else round(float(x), 2)
|
| 1100 |
+
|
| 1101 |
+
|
| 1102 |
@app.route("/api/charts", methods=["POST", "OPTIONS"])
|
| 1103 |
def charts():
|
| 1104 |
"""ชุดข้อมูลพร้อมวาดกราฟหลายแบบจากอนุกรมเดียวกัน
|
| 1105 |
+
|
| 1106 |
คืนเป็น "ข้อมูลที่พร้อมวาด" ทุกบล็อก — หน้าเว็บเลือกหยิบไปวาดได้เลย
|
| 1107 |
ทุกบล็อกที่อิงรายปีจะมีธง complete บอกว่าปีนั้นข้อมูลครบ 12 เดือนหรือยัง
|
| 1108 |
เพื่อไม่ให้กราฟหลอกตาว่า "ปีนี้ยอดตก" ทั้งที่ยังไม่จบปี
|
|
|
|
| 1115 |
drop_last = bool(body.get("drop_last_incomplete", False))
|
| 1116 |
if len(rows) < 3:
|
| 1117 |
return jsonify({"error": "ต้องการข้อมูลอย่างน้อย 3 เดือน"}), 400
|
| 1118 |
+
|
| 1119 |
s, warns = to_series(rows)
|
| 1120 |
if drop_last and len(s) > 3:
|
| 1121 |
warns.append(f"ตัดเดือนสุดท้าย ({s.index[-1].strftime('%Y-%m')}) ออกตามที่ร้องขอ")
|
| 1122 |
s = s.iloc[:-1]
|
| 1123 |
+
|
| 1124 |
piv = _year_table(s)
|
| 1125 |
years = [int(y) for y in piv.index]
|
| 1126 |
month_th = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.",
|
| 1127 |
"ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."]
|
| 1128 |
+
|
| 1129 |
# ── 1. ยอดรวมรายปี + อัตราเติบโต YoY ──────────────────────────
|
| 1130 |
totals, counts = piv.sum(axis=1), piv.notna().sum(axis=1)
|
| 1131 |
yoy_annual = []
|
|
|
|
| 1139 |
"avg_per_month": round(tot / cnt, 2) if cnt else None,
|
| 1140 |
})
|
| 1141 |
prev = tot
|
| 1142 |
+
|
| 1143 |
# ── 2. เทียบรายเดือนแยกปี (multi-line / grouped bar) ──────────
|
| 1144 |
yoy_monthly = {
|
| 1145 |
"months": month_th,
|
|
|
|
| 1147 |
"values": [_nn(piv.loc[y, m]) for m in range(1, 13)]}
|
| 1148 |
for y in years],
|
| 1149 |
}
|
| 1150 |
+
|
| 1151 |
# ── 3. YTD pace — สะสมตั้งแต่ต้นปี เทียบจังหวะข้ามปี ──────────
|
| 1152 |
ytd = {"months": month_th, "series": []}
|
| 1153 |
for y in years:
|
|
|
|
| 1160 |
acc += float(v)
|
| 1161 |
run.append(round(acc, 2))
|
| 1162 |
ytd["series"].append({"year": int(y), "values": run})
|
| 1163 |
+
|
| 1164 |
# ── 4. heatmap ปี × เดือน (ดัชนีเทียบค่าเฉลี่ยของปีนั้น) ──────
|
| 1165 |
heat = []
|
| 1166 |
for y in years:
|
|
|
|
| 1169 |
heat.append({"year": int(y),
|
| 1170 |
"values": [None if pd.isna(row[m]) or not mean else round(float(row[m]) / mean, 3)
|
| 1171 |
for m in range(1, 13)]})
|
| 1172 |
+
|
| 1173 |
# ── 5. เส้นจริง + ค่าเฉลี่ยเคลื่อนที่ ────────────────────────
|
| 1174 |
ma = {
|
| 1175 |
"periods": [d.strftime("%Y-%m") for d in s.index],
|
|
|
|
| 1177 |
"ma3": [_nn(x) for x in s.rolling(3).mean().values],
|
| 1178 |
"ma12": [_nn(x) for x in s.rolling(12).mean().values],
|
| 1179 |
}
|
| 1180 |
+
|
| 1181 |
# ── 6. อัตราเติบโต MoM และ YoY รายเดือน ──────────────────────
|
| 1182 |
mom = s.pct_change() * 100
|
| 1183 |
yoy_m = s.pct_change(12) * 100
|
|
|
|
| 1186 |
"mom_pct": [_nn(x) for x in mom.values],
|
| 1187 |
"yoy_pct": [_nn(x) for x in yoy_m.values],
|
| 1188 |
}
|
| 1189 |
+
|
| 1190 |
# ── 7. การกระจายของยอดรายเดือน (histogram) ───────────────────
|
| 1191 |
cnts, edges = np.histogram(s.values, bins=min(12, max(5, len(s) // 7)))
|
| 1192 |
dist = {
|
|
|
|
| 1194 |
"count": int(cnts[i])} for i in range(len(cnts))],
|
| 1195 |
"mean": round(float(s.mean()), 2), "median": round(float(s.median()), 2),
|
| 1196 |
}
|
| 1197 |
+
|
| 1198 |
# ── 8. โปรไฟล์รายเดือน (box plot) — ฤดูกาลซ้ำจริงไหม ─────────
|
| 1199 |
prof = []
|
| 1200 |
norm = pd.DataFrame({"v": s.values, "y": s.index.year, "m": s.index.month})
|
|
|
|
| 1209 |
"median": round(float(g.median()), 3), "q3": round(q3, 3),
|
| 1210 |
"max": round(float(g.max()), 3), "iqr": round(q3 - q1, 3),
|
| 1211 |
"repeatable": bool(q3 - q1 < 0.35)})
|
| 1212 |
+
|
| 1213 |
# ── 9. แยกตามหมวด (เฉพาะเมื่อข้อมูลขาเข้ามีคอลัมน์ category) ──
|
| 1214 |
by_category = None
|
| 1215 |
cats = [r for r in rows if isinstance(r, dict) and r.get("category")]
|
|
|
|
| 1230 |
"note": ("เกิน 3 หมวดให้ใช้ bar เรียงลำดับ ไม่ใช่ pie "
|
| 1231 |
"— ตาคนเทียบความยาวแท่งได้แม่นกว่ามุมของชิ้นพาย"),
|
| 1232 |
}
|
| 1233 |
+
|
| 1234 |
partial = [r["year"] for r in yoy_annual if not r["complete"]]
|
| 1235 |
if partial:
|
| 1236 |
warns.append(
|
| 1237 |
f"ปี {partial} ยังมีข้อมูลไม่ครบ 12 เดือน — ห้ามเทียบยอดรวมรายปีตรง ๆ "
|
| 1238 |
"ให้ใช้ค่าเฉลี่ยต่อเดือนหรือ YTD แทน"
|
| 1239 |
)
|
| 1240 |
+
|
| 1241 |
return jsonify({
|
| 1242 |
"yoy_annual": yoy_annual,
|
| 1243 |
"yoy_monthly": yoy_monthly,
|
|
|
|
| 1263 |
})
|
| 1264 |
except Exception as e:
|
| 1265 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1266 |
+
|
| 1267 |
+
|
| 1268 |
@app.route("/api/hindcast", methods=["POST", "OPTIONS"])
|
| 1269 |
def hindcast():
|
| 1270 |
"""ทำนายย้อนกลับ — ให้โมเดลทายอดีตทีละเดือน โดยเห็นเฉพาะข้อมูลก่อนหน้า
|
| 1271 |
+
|
| 1272 |
ต่างจาก /api/backtest ตรงที่คืน "เส้นกราฟ" ให้วาดทับกับของจริงได้
|
| 1273 |
ทุกค่าที่คืนเป็น out-of-sample ล้วน ไม่มีการมองอนาคตแม้แต่จุดเดียว
|
| 1274 |
+
|
| 1275 |
และตอบคำถามสำคัญ: อัตราเติบโต YoY ที่โมเดลทำนาย
|
| 1276 |
สอดคล้องกับ YoY ที่เกิดขึ้นจริงหรือไม่
|
| 1277 |
"""
|
|
|
|
| 1287 |
want = [m for m in want if m in MODELS]
|
| 1288 |
if not want:
|
| 1289 |
return jsonify({"error": "ไม่มีชื่อโมเดลที่ใช้ได้"}), 400
|
| 1290 |
+
|
| 1291 |
s, warns = to_series(rows)
|
| 1292 |
if drop_last and len(s) > MIN_HISTORY:
|
| 1293 |
warns.append(f"ตัดเดือนสุดท้าย ({s.index[-1].strftime('%Y-%m')}) ออกตามที่ร้องขอ")
|
| 1294 |
s = s.iloc[:-1]
|
| 1295 |
+
|
| 1296 |
n = len(s)
|
| 1297 |
min_train = max(MIN_TRAIN, SEASONAL_PERIOD + 3)
|
| 1298 |
if n < min_train + horizon + 2:
|
| 1299 |
min_train = max(SEASONAL_PERIOD + 3, n - horizon - 6)
|
| 1300 |
if n < min_train + horizon:
|
| 1301 |
return jsonify({"error": f"ต้องการข้อมูลอย่างน้อย {min_train + horizon} เดือน"}), 400
|
| 1302 |
+
|
| 1303 |
periods = [d.strftime("%Y-%m") for d in s.index]
|
| 1304 |
+
|
| 1305 |
ck = _hash(rows, f"|hc{horizon}|{drop_last}|{','.join(sorted(want))}")
|
| 1306 |
if ck in _CACHE:
|
| 1307 |
return jsonify(_CACHE[ck])
|
| 1308 |
+
|
| 1309 |
preds = {m: [None] * n for m in want}
|
| 1310 |
+
|
| 1311 |
t0 = time.time()
|
| 1312 |
for o in range(min_train, n - horizon + 1):
|
| 1313 |
train = s.iloc[:o]
|
|
|
|
| 1319 |
except Exception:
|
| 1320 |
pass
|
| 1321 |
elapsed = round(time.time() - t0, 2)
|
| 1322 |
+
|
| 1323 |
actual = [round(float(x), 2) for x in s.values]
|
| 1324 |
+
|
| 1325 |
# ── ความผิดพลาดของแต่ละโมเดล เฉพาะจุดที่ทำนายได้ ─────────────
|
| 1326 |
summary = {}
|
| 1327 |
for m in want:
|
|
|
|
| 1339 |
"rmse": round(sc["rmse"], 2),
|
| 1340 |
"bias": round(float(np.mean(p - a)), 2), # บวก = ทายสูงเกินจริงเป็นระบบ
|
| 1341 |
}
|
| 1342 |
+
|
| 1343 |
# ── ตรวจความสอดคล้องของ YoY จริง เทียบ YoY ที่โมเดลทำนาย ─────
|
| 1344 |
ydf = pd.DataFrame({"period": periods, "actual": actual})
|
| 1345 |
ydf["year"] = [int(str(p)[:4]) for p in periods]
|
| 1346 |
for m in want:
|
| 1347 |
ydf[m] = preds[m]
|
| 1348 |
+
|
| 1349 |
yoy_check = []
|
| 1350 |
yrs = sorted(ydf["year"].unique())
|
| 1351 |
for i in range(1, len(yrs)):
|
|
|
|
| 1377 |
}
|
| 1378 |
if entry["models"]:
|
| 1379 |
yoy_check.append(entry)
|
| 1380 |
+
|
| 1381 |
# ── สรุปเป็นภาษาคน ────────────────────────────────────────────
|
| 1382 |
reading = []
|
| 1383 |
if summary:
|
|
|
|
| 1405 |
reading.append(
|
| 1406 |
"ทุกค่าในกราฟนี้เป็น out-of-sample ล้วน — ณ แต่ละจุด โมเดลเห็นเฉพาะข้อมูลก่อนหน้านั้น"
|
| 1407 |
)
|
| 1408 |
+
|
| 1409 |
payload = {
|
| 1410 |
"periods": periods,
|
| 1411 |
"actual": actual,
|
|
|
|
| 1430 |
return jsonify(payload)
|
| 1431 |
except Exception as e:
|
| 1432 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1433 |
+
|
| 1434 |
+
|
| 1435 |
+
|
| 1436 |
@app.route("/api/describe", methods=["POST", "OPTIONS"])
|
| 1437 |
def describe():
|
| 1438 |
"""อธิบายข้อมูลชุดนี้ให้คนที่ไม่เคยวิเคราะห์ข้อมูลมาก่อนเข้าใจ
|
| 1439 |
+
|
| 1440 |
ตอบ 4 คำถามที่ผู้เริ่มต้นถามเสมอ:
|
| 1441 |
1. ข้อมูลนี้คืออะไร จากไหน ถึงไหน
|
| 1442 |
2. แต่ละคอลัมน์หมายถึงอะไร
|
|
|
|
| 1452 |
subject = str(body.get("subject", "ยอดขาย"))
|
| 1453 |
if len(rows) < 2:
|
| 1454 |
return jsonify({"error": "ต้องการข้อมูลอย่างน้อย 2 เดือน"}), 400
|
| 1455 |
+
|
| 1456 |
s, warns = to_series(rows)
|
| 1457 |
q = data_quality(s)
|
| 1458 |
v = np.asarray(s.values, dtype=float)
|
| 1459 |
years = sorted({d.year for d in s.index})
|
| 1460 |
+
|
| 1461 |
# ── 1. เล่าให้ฟังเป็นภาษาคน ────────────────────────────────
|
| 1462 |
overview = [
|
| 1463 |
f"ข้อมูลชุดนี้คือ{subject} สรุปเป็น รายเดือน เดือนละ 1 ค่า",
|
|
|
|
| 1466 |
f"ค่าเฉลี่ยเดือนละ {q['mean']:,.0f} {unit} · ต่ำสุด {q['min']:,.0f} · สูงสุด {q['max']:,.0f}",
|
| 1467 |
f"หนึ่งแถว = หนึ่งเดือน ไม่ใช่หนึ่งรายการขาย — รายการขายทั้งหมดในเดือนนั้นถูกบวกรวมกันแล้ว",
|
| 1468 |
]
|
| 1469 |
+
|
| 1470 |
# ── 2. พจนานุกรมคอลัมน์ ────────────────────────────────────
|
| 1471 |
dictionary = [
|
| 1472 |
{"column": "date", "th": "เดือน", "type": "ข้อความ รูปแบบ YYYY-MM",
|
|
|
|
| 1478 |
dictionary.append({"column": "category", "th": "หมวดสินค้า", "type": "ข้อความ",
|
| 1479 |
"example": "Computer/PC",
|
| 1480 |
"meaning": "กลุ่มสินค้า ใช้แยกดูว่าหมวดไหนโตหรือหด (ไม่บังคับ)"})
|
| 1481 |
+
|
| 1482 |
# ── 3. ตัวอย่างข้อมูลจริง หัวและท้าย ───────────────────────
|
| 1483 |
def peek(idx):
|
| 1484 |
return [{"date": s.index[i].strftime("%Y-%m"), "sales": round(float(v[i]), 2)} for i in idx]
|
|
|
|
| 1487 |
"last": peek(range(max(0, len(v) - 5), len(v))),
|
| 1488 |
"note": "แสดง 5 แถวแรกและ 5 แถวสุดท้าย จากทั้งหมด %d แถว" % len(v),
|
| 1489 |
}
|
| 1490 |
+
|
| 1491 |
# ── 4. เช็คลิสต์ความพร้อมของข้อมูล ─────────────────────────
|
| 1492 |
def item(label, ok, detail):
|
| 1493 |
return {"check": label, "status": "ok" if ok else "warn", "detail": detail}
|
|
|
|
| 1510 |
"ดูปกติ" if not any("เดือนล่าสุด" in n for n in q["notes"])
|
| 1511 |
else f"{q['end']} ต่ำผิดปกติ — น่าจะยังเก็บไม่ครบเดือน ควรตัดออกก่อนฝึกโมเดล"),
|
| 1512 |
]
|
| 1513 |
+
|
| 1514 |
# ── 5. แผนการวิเคราะห์ ทีละขั้น ────────────────────────────
|
| 1515 |
plan = [
|
| 1516 |
{"step": 1, "title": "ทำความรู้จักข้อมูล",
|
|
|
|
| 1544 |
"look_at": "explanation (ทำไมได้ผลแบบนี้) · ช่วงความเชื่อมั่น · total (ยอดรวมทั้งช่วง)",
|
| 1545 |
"why": "ตัวเลขเดียวไม่ช่วยตัดสินใจ ช่วง + เหตุผล + ข้อจำกัด คือสิ่งที่ช่วย"},
|
| 1546 |
]
|
| 1547 |
+
|
| 1548 |
return jsonify({
|
| 1549 |
"overview": overview,
|
| 1550 |
"dictionary": dictionary,
|
|
|
|
| 1568 |
})
|
| 1569 |
except Exception as e:
|
| 1570 |
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
| 1571 |
+
|
| 1572 |
+
|
| 1573 |
if __name__ == "__main__":
|
| 1574 |
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
|
|
|
|
|
|
|
|
|