Therdpoom commited on
Commit
5b96880
·
verified ·
1 Parent(s): c55a9c0

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +212 -217
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)))