{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Predicty — พยากรณ์ยอดขาย ENT Group\n", "## ส่วนที่ 3 · สร้างโมเดล วัดผลจริง และแปลงเป็นการตัดสินใจ\n", "\n", "ส่วนที่ 2 บอกเราไว้แล้วว่า:\n", "ฤดูกาลอ่อน (0.17) · แนวโน้มอ่อน (0.26) · **63% ของความผันผวนอธิบายไม่ได้**\n", "\n", "ส่วนที่ 3 นี้จะพิสูจน์ว่าคำทำนายนั้นถูกหรือไม่ ด้วยการ **วัดผลจริง** ไม่ใช่การเดา\n", "\n", "| หัวข้อ | คำถามที่ตอบ |\n", "|---|---|\n", "| 1–2 | โมเดลมีอะไรบ้าง และ **baseline** คืออะไร ทำไมขาดไม่ได้ |\n", "| 3 | ทำไม `train_test_split` แบบสุ่ม **ผิด** สำหรับอนุกรมเวลา |\n", "| 4–5 | rolling-origin backtest + ตารางอันดับ + **skill score** |\n", "| 6 | ยิ่งพยากรณ์ไกล ยิ่งแย่จริงไหม |\n", "| 7 | ทำไม **ยอดรวมไตรมาส** ถึงแม่นกว่า **รายเดือน** |\n", "| 8 | ช่วงความเชื่อมั่น 80% ครอบคลุมความจริง 80% จริงหรือเปล่า |\n", "| 9–10 | พยากรณ์ของจริง + แปลงเป็นตัวเลขที่ผู้บริหารใช้ได้ |\n", "| 11 | ยิงเข้า API บน Hugging Face Space |\n", "\n", "> 🎯 **เกณฑ์ตัดสินของส่วนนี้:** โมเดลจะถือว่า \"ใช้ได้\" ก็ต่อเมื่อ **ชนะ baseline**\n", "> ไม่ใช่เพราะ MAPE ดูต่ำ หรือเพราะกราฟดูสวย" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 0) ตั้งค่า + โหลดข้อมูล" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import subprocess, warnings\n", "warnings.filterwarnings(\"ignore\")\n", "\n", "def setup_thai_font():\n", " import matplotlib, matplotlib.font_manager as fm\n", " for path in [\"/usr/share/fonts/truetype/tlwg/Sarabun.ttf\",\n", " \"/usr/share/fonts/truetype/tlwg/Loma.ttf\"]:\n", " try:\n", " fm.fontManager.addfont(path)\n", " matplotlib.rcParams[\"font.family\"] = fm.FontProperties(fname=path).get_name()\n", " return True\n", " except Exception:\n", " continue\n", " return False\n", "\n", "if not setup_thai_font():\n", " try:\n", " subprocess.run(\"apt-get -qq install -y fonts-thai-tlwg\".split(), capture_output=True, timeout=180)\n", " import matplotlib.font_manager as fm\n", " fm._load_fontmanager(try_read_cache=False)\n", " except Exception:\n", " pass\n", " setup_thai_font()\n", "\n", "import matplotlib\n", "matplotlib.rcParams[\"axes.unicode_minus\"] = False\n", "matplotlib.rcParams[\"figure.dpi\"] = 110\n", "\n", "import pandas as pd, numpy as np\n", "import matplotlib.pyplot as plt\n", "\n", "try:\n", " from google.colab import files\n", " up = files.upload()\n", " CSV = [k for k in up if \"monthly\" in k.lower()][0]\n", "except Exception:\n", " CSV = \"ent_monthly_series.csv\"\n", "\n", "M = pd.read_csv(CSV)\n", "M[\"ym\"] = pd.PeriodIndex(M[\"ym\"], freq=\"M\")\n", "M = M.set_index(\"ym\").sort_index()\n", "\n", "s = M[\"revenue\"].astype(float).iloc[:-1] # ตัดเดือนล่าสุดที่ยังเก็บไม่ครบ\n", "s.index = s.index.to_timestamp()\n", "s = s.asfreq(\"MS\")\n", "\n", "print(f\"ชุดฝึกโมเดล: {s.index.min():%Y-%m} → {s.index.max():%Y-%m} (n = {len(s)})\")\n", "print(f\"เฉลี่ย {s.mean():,.0f} บาท/เดือน · SD {s.std():,.0f} · CV {s.std()/s.mean():.2f}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 1) โมเดลทั้งหมด — เขียนให้หน้าตาเหมือนกันเพื่อเทียบกันได้\n", "\n", "ทุกโมเดลรับ `(train, h)` แล้วคืน array ยาว `h` — เท่านี้ก็เทียบกันได้ทุกตัวด้วยโค้ดเดียว\n", "\n", "**สังเกตว่า baseline อยู่ก่อน** — นี่ไม่ใช่เรื่องบังเอิญ ในงานพยากรณ์จริง\n", "baseline คือ *สิ่งที่ต้องเขียนก่อนเสมอ* เพราะมันคือ \"ด่าน\" ที่โมเดลของเราต้องผ่าน" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "from statsmodels.tsa.holtwinters import ExponentialSmoothing\n", "from statsmodels.tsa.statespace.sarimax import SARIMAX\n", "\n", "SEASON = 12\n", "SEASONAL_MIN = 36 # ต้องมี ≥ 3 รอบปี ส่วนฤดูกาลถึงจะเทรนได้จริง\n", "\n", "# ── baseline: สิ่งที่โมเดลต้องเอาชนะให้ได้ ─────────────────────────\n", "def naive(train, h):\n", " # ทายว่าทุกเดือนข้างหน้า = เดือนล่าสุด\n", " return np.full(h, float(train.values[-1]))\n", "\n", "def mean3(train, h):\n", " # ทายด้วยค่าเฉลี่ย 3 เดือนล่าสุด — วิธีที่ฝ่ายขายใช้จริงบ่อยที่สุด\n", " return np.full(h, float(train.values[-3:].mean()))\n", "\n", "def seasonal_naive(train, h):\n", " # ทายว่าเดือนนี้ปีนี้ = เดือนเดียวกันปีที่แล้ว\n", " if len(train) < SEASON:\n", " return mean3(train, h)\n", " return np.tile(train.values[-SEASON:], int(np.ceil(h / SEASON)))[:h].astype(float)\n", "\n", "# ── โมเดลจริง ────────────────────────────────────────────────────\n", "def linear(train, h):\n", " # ลากเส้นตรงที่พอดีกับข้อมูลที่สุด แล้วต่อไปข้างหน้า\n", " x = np.arange(len(train))\n", " return np.polyval(np.polyfit(x, train.values, 1), np.arange(len(train), len(train) + h))\n", "\n", "def holtwinters(train, h):\n", " # ระดับ + แนวโน้ม + ฤดูกาล โดยให้น้ำหนักข้อมูลใหม่มากกว่าเก่า\n", " seas = len(train) >= SEASONAL_MIN\n", " m = ExponentialSmoothing(train, trend=\"add\",\n", " seasonal=\"add\" if seas else None,\n", " seasonal_periods=SEASON if seas else None,\n", " initialization_method=\"estimated\").fit(optimized=True)\n", " return np.asarray(m.forecast(h), dtype=float)\n", "\n", "def sarima(train, h):\n", " # SARIMA(1,1,1)(1,1,1)[12]\n", " so = (1, 1, 1, SEASON) if len(train) >= SEASONAL_MIN else (0, 0, 0, 0)\n", " m = SARIMAX(train, order=(1, 1, 1), seasonal_order=so,\n", " enforce_stationarity=False, enforce_invertibility=False).fit(disp=False)\n", " return np.asarray(m.get_forecast(h).predicted_mean, dtype=float)\n", "\n", "MODELS = {\"naive\": naive, \"mean3\": mean3, \"seasonal_naive\": seasonal_naive,\n", " \"linear\": linear, \"holtwinters\": holtwinters, \"sarima\": sarima}\n", "KIND = {\"naive\": \"baseline\", \"mean3\": \"baseline\", \"seasonal_naive\": \"baseline\",\n", " \"linear\": \"โมเดล\", \"holtwinters\": \"โมเดล\", \"sarima\": \"โมเดล\"}\n", "BENCHMARK = \"seasonal_naive\" # baseline ที่ใช้เป็นด่านวัด skill\n", "\n", "print(f\"เตรียมโมเดลไว้ {len(MODELS)} ตัว — baseline {sum(v=='baseline' for v in KIND.values())} ตัว, โมเดลจริง {sum(v=='โมเดล' for v in KIND.values())} ตัว\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 2) มาตรวัด — และข้อจำกัดของแต่ละตัว\n", "\n", "| มาตรวัด | ความหมาย | ข้อดี | ข้อควรระวัง |\n", "|---|---|---|---|\n", "| **MAPE** | ผิดเฉลี่ยกี่ % | ผู้บริหารเข้าใจทันที | เดือนที่ยอดต่ำทำให้ % พองผิดปกติ |\n", "| **MdAPE** | ค่ากลางของ % ที่ผิด | ทนต่อเดือนสุดโต่ง | ไม่สะท้อนหางของความผิดพลาด |\n", "| **MAE** | ผิดเฉลี่ยกี่บาท | ใช้ตัดสินใจธุรกิจได้ตรง | เทียบข้ามธุรกิจไม่ได้ |\n", "| **RMSE** | ลงโทษความผิดพลาดก้อนใหญ่หนักกว่า | เหมาะเมื่อพลาดหนักครั้งเดียวแล้วเจ็บ | ถูกค่าสุดโต่งดึงง่าย |\n", "\n", "> **ห้ามดูมาตรวัดเดียว** — เดี๋ยวเราจะเห็นกรณีที่ MAPE กับ MAE จัดอันดับไม่ตรงกัน" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "def metrics(actual, pred):\n", " a, p = np.asarray(actual, float), np.asarray(pred, float)\n", " ape = np.abs((a - p) / np.where(np.abs(a) < 1e-9, np.nan, a)) * 100\n", " return {\"MAPE\": np.nanmean(ape),\n", " \"MdAPE\": np.nanmedian(ape),\n", " \"MAE\": np.mean(np.abs(a - p)),\n", " \"RMSE\": np.sqrt(np.mean((a - p) ** 2))}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 3) ⚠️ กับดัก 3 ชั้น ที่ทำให้ตัวเลขความแม่นยำ \"ดูดีเกินจริง\"\n", "\n", "ก่อนจะวัดผลอย่างถูกต้อง ต้องรู้ก่อนว่าอะไรทำให้วัดผิด — เราจะใช้ **โมเดลเดียวกัน ข้อมูลเดียวกัน**\n", "แล้วเปลี่ยนแค่ *วิธีวัด* เพื่อดูว่าตัวเลขเพี้ยนได้แค่ไหน\n", "\n", "| วิธีวัด | ปัญหา |\n", "|---|---|\n", "| ① วัดกับข้อมูลที่โมเดลเคยเห็น (in-sample) | โมเดลแค่ \"ท่องจำ\" — ไม่ได้ทำนายอะไรเลย |\n", "| ② สุ่มแบ่ง train/test | เดือนในชุดทดสอบถูกขนาบด้วยเดือนที่ใช้เทรน = เห็นอนาคต (leakage) |\n", "| ③ แบ่งตามเวลา | ✅ ถูกต้อง — เทรนด้วยอดีต ทดสอบกับอนาคต เหมือนตอนใช้จริง |" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "from sklearn.ensemble import RandomForestRegressor\n", "from sklearn.model_selection import train_test_split\n", "\n", "# สร้าง feature แบบที่คนทำ ML มักทำกับอนุกรมเวลา: ใช้ค่าย้อนหลังเป็น input\n", "d = pd.DataFrame({\"y\": s.values})\n", "for L in [1, 2, 3, 12]:\n", " d[f\"lag{L}\"] = d[\"y\"].shift(L)\n", "d[\"month\"] = s.index.month\n", "d = d.dropna().reset_index(drop=True)\n", "X, y = d.drop(columns=\"y\").values, d[\"y\"].values\n", "\n", "cut = int(len(d) * 0.8)\n", "rf_time = RandomForestRegressor(n_estimators=300, random_state=7).fit(X[:cut], y[:cut])\n", "\n", "m1 = metrics(y[:cut], rf_time.predict(X[:cut]))[\"MAPE\"] # ① in-sample\n", "m3 = metrics(y[cut:], rf_time.predict(X[cut:]))[\"MAPE\"] # ③ แบ่งตามเวลา\n", "\n", "seeds = []\n", "for rs in range(5): # ② สุ่มแบ่ง (5 ครั้ง)\n", " Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.2, random_state=rs, shuffle=True)\n", " rf = RandomForestRegressor(n_estimators=300, random_state=7).fit(Xtr, ytr)\n", " seeds.append(metrics(yte, rf.predict(Xte))[\"MAPE\"])\n", "\n", "print(\"Random Forest ตัวเดียวกัน · ข้อมูลชุดเดียวกัน · ต่างกันแค่วิธีวัด\\n\")\n", "print(f\"① in-sample (วัดกับข้อมูลที่เทรน) MAPE {m1:>6.1f}% ← ตัวเลขที่เอาไปอวดไม่ได้\")\n", "print(f\"② สุ่มแบ่ง train/test 5 ครั้ง MAPE {np.mean(seeds):>6.1f}% \"\n", " f\"(แกว่ง {min(seeds):.1f}–{max(seeds):.1f}%)\")\n", "print(f\"③ แบ่งตามเวลา (ถูกต้อง) MAPE {m3:>6.1f}% ← ความจริง\\n\")\n", "print(f\"ช่องว่างระหว่าง ① กับ ③ = {m3 - m1:.0f} จุดเปอร์เซ็นต์\")\n", "print(\"นี่คือระยะห่างระหว่าง 'โมเดลที่ท่องจำเก่ง' กับ 'โมเดลที่ทำนายเป็น'\")\n", "print()\n", "print(f\"และสังเกตว่า ② ให้ผลต่างกันถึง {max(seeds)-min(seeds):.0f} จุด เมื่อเปลี่ยนเมล็ดสุ่ม\")\n", "print(\"→ ตัวเลขที่เปลี่ยนไปมาตามเมล็ดสุ่ม ไม่ใช่ตัวเลขที่เอาไปตัดสินใจได้\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 4) rolling-origin backtest — วิธีวัดผลที่ถูกต้อง\n", "\n", "**หลักการ:** ย้อนเวลากลับไปยืนที่จุดหนึ่งในอดีต เห็นเฉพาะข้อมูลก่อนจุดนั้น\n", "พยากรณ์ไปข้างหน้า 6 เดือน แล้วเทียบกับสิ่งที่เกิดขึ้นจริง — **ทำซ้ำหลายจุด**\n", "\n", "```\n", "จุดที่ 1: [━━━━ train ━━━━]→ ทำนาย 6 เดือน เทียบของจริง\n", "จุดที่ 2: [━━━━━ train ━━━━━]→ ทำนาย 6 เดือน เทียบของจริง\n", "จุดที่ 3: [━━━━━━ train ━━━━━━]→ ทำนาย 6 เดือน เทียบของจริง\n", "...\n", "```\n", "\n", "**ทำไมต้องหลายจุด:** วัดครั้งเดียวคือการวัด *ดวง* ไม่ใช่ *ฝีมือ*\n", "โมเดลอาจบังเอิญเก่งในช่วง 6 เดือนที่เราเลือก แล้วพังในช่วงอื่น" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "HORIZON = 6\n", "MIN_TRAIN = 36 # อย่างน้อย 3 รอบปี\n", "\n", "def rolling_origin(s, models=MODELS, horizon=HORIZON, min_train=MIN_TRAIN, verbose=True):\n", " origins = list(range(min_train, len(s) - horizon + 1))\n", " res = {m: {\"actual\": [], \"pred\": [], \"err_h\": {h: [] for h in range(1, horizon + 1)},\n", " \"agg_a\": [], \"agg_p\": [], \"fail\": 0} for m in models}\n", "\n", " for o in origins:\n", " train, test = s.iloc[:o], s.iloc[o:o + horizon]\n", " for name, fn in models.items():\n", " try:\n", " fc = fn(train, horizon)\n", " if not np.all(np.isfinite(fc)):\n", " raise ValueError\n", " except Exception:\n", " res[name][\"fail\"] += 1\n", " continue\n", " res[name][\"actual\"] += test.values.tolist()\n", " res[name][\"pred\"] += fc.tolist()\n", " res[name][\"agg_a\"].append(test.values.sum()) # ยอดรวมทั้งช่วง\n", " res[name][\"agg_p\"].append(fc.sum())\n", " for h in range(1, horizon + 1):\n", " res[name][\"err_h\"][h].append(test.values[h-1] - fc[h-1])\n", "\n", " if verbose:\n", " print(f\"จุดเริ่มทั้งหมด {len(origins)} จุด \"\n", " f\"({s.index[min_train-1]:%Y-%m} → {s.index[origins[-1]-1]:%Y-%m})\")\n", " print(f\"แต่ละจุดพยากรณ์ล่วงหน้า {horizon} เดือน \"\n", " f\"→ วัดผลรวม {len(origins)*horizon} ค่าต่อโมเดล\")\n", " return res, origins\n", "\n", "bt, origins = rolling_origin(s)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 5) ตารางอันดับ + **skill score** — โมเดลชนะ baseline ไหม\n", "\n", "$$\\text{skill} = 1 - \\frac{\\text{MAPE}_{\\text{โมเดล}}}{\\text{MAPE}_{\\text{baseline}}}$$\n", "\n", "- `skill > 0` → **ดีกว่า** baseline (คุ้มที่จะใช้)\n", "- `skill ≤ 0` → **ไม่ดีกว่า** baseline (โมเดลไม่มีประโยชน์ — ต้องกล้ารายงานตามจริง)" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "rows = []\n", "for name, d in bt.items():\n", " m = metrics(d[\"actual\"], d[\"pred\"])\n", " ma = metrics(d[\"agg_a\"], d[\"agg_p\"])\n", " rows.append({\"โมเดล\": name, \"ประเภท\": KIND[name],\n", " \"MAPE\": m[\"MAPE\"], \"MdAPE\": m[\"MdAPE\"], \"MAE\": m[\"MAE\"], \"RMSE\": m[\"RMSE\"],\n", " \"MAPE_ยอดรวม6ด\": ma[\"MAPE\"]})\n", "\n", "lb = pd.DataFrame(rows).sort_values(\"MAPE\").reset_index(drop=True)\n", "bench = lb.loc[lb[\"โมเดล\"] == BENCHMARK, \"MAPE\"].iloc[0]\n", "lb[\"skill\"] = (1 - lb[\"MAPE\"] / bench).round(3)\n", "lb[\"ชนะ baseline?\"] = np.where(lb[\"skill\"] > 0, \"✅ ใช่\", \"❌ ไม่\")\n", "\n", "print(f\"เกณฑ์เทียบ (benchmark) = {BENCHMARK} · MAPE {bench:.1f}%\\n\")\n", "print(lb.to_string(index=False, formatters={\n", " \"MAPE\": \"{:.1f}%\".format, \"MdAPE\": \"{:.1f}%\".format,\n", " \"MAE\": \"{:,.0f}\".format, \"RMSE\": \"{:,.0f}\".format,\n", " \"MAPE_ยอดรวม6ด\": \"{:.1f}%\".format}))\n", "\n", "best = lb.iloc[0]\n", "print()\n", "print(\"─\" * 70)\n", "print(f\"อันดับ 1 : {best['โมเดล']} ({best['ประเภท']}) MAPE {best['MAPE']:.1f}%\")\n", "if best[\"ประเภท\"] == \"baseline\":\n", " print(\"⚠️ โมเดลที่ชนะคือ baseline — ข้อมูลชุดนี้ยังไม่มีสัญญาณพอให้โมเดลซับซ้อนได้เปรียบ\")\n", "winners = lb[(lb[\"ประเภท\"] == \"โมเดล\") & (lb[\"skill\"] > 0)]\n", "if len(winners) == 0:\n", " print(\"⚠️ ไม่มีโมเดลใดเอาชนะ baseline ได้เลย\")\n", "else:\n", " print(f\"โมเดลที่ชนะ baseline : {', '.join(winners['โมเดล'])}\")\n", "print(\"─\" * 70)\n", "\n", "# จัดอันดับด้วย MAPE กับ MAE ตรงกันไหม\n", "if list(lb.sort_values(\"MAPE\")[\"โมเดล\"]) != list(lb.sort_values(\"MAE\")[\"โมเดล\"]):\n", " print(\"\\n📌 สังเกต: จัดอันดับด้วย MAPE กับ MAE ได้ลำดับ 'ไม่ตรงกัน'\")\n", " print(\" MAPE:\", \" > \".join(lb.sort_values(\"MAPE\")[\"โมเดล\"]))\n", " print(\" MAE :\", \" > \".join(lb.sort_values(\"MAE\")[\"โมเดล\"]))\n", " print(\" → ต้องเลือกมาตรวัดจาก 'สิ่งที่ธุรกิจแคร์' ไม่ใช่จากตัวที่ให้ผลสวยที่สุด\")" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "fig, ax = plt.subplots(1, 2, figsize=(13, 4))\n", "o = lb.sort_values(\"MAPE\", ascending=False)\n", "colors = [\"#94a3b8\" if k == \"baseline\" else \"#3b82f6\" for k in o[\"ประเภท\"]]\n", "ax[0].barh(o[\"โมเดล\"], o[\"MAPE\"], color=colors)\n", "ax[0].axvline(bench, ls=\"--\", color=\"#ef4444\", lw=1.5, label=f\"baseline {bench:.1f}%\")\n", "ax[0].set_title(\"MAPE รายเดือน (ต่ำ = ดี) — เทา = baseline\", fontsize=10)\n", "ax[0].set_xlabel(\"%\"); ax[0].legend(fontsize=8); ax[0].grid(alpha=.25, axis=\"x\")\n", "\n", "o2 = lb.sort_values(\"skill\")\n", "ax[1].barh(o2[\"โมเดล\"], o2[\"skill\"],\n", " color=[\"#10b981\" if v > 0 else \"#ef4444\" for v in o2[\"skill\"]])\n", "ax[1].axvline(0, color=\"black\", lw=1)\n", "ax[1].set_title(\"skill score เทียบ baseline (บวก = คุ้มที่จะใช้)\", fontsize=10)\n", "ax[1].grid(alpha=.25, axis=\"x\")\n", "plt.tight_layout(); plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 6) ยิ่งพยากรณ์ไกล ยิ่งแย่จริงไหม" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "mae_h = pd.DataFrame({\n", " name: {h: np.mean(np.abs(e)) for h, e in d[\"err_h\"].items()}\n", " for name, d in bt.items()}).T\n", "mae_h.columns = [f\"h={h}\" for h in mae_h.columns]\n", "\n", "print(\"MAE แยกตามระยะที่พยากรณ์ (บาท)\")\n", "print(mae_h.loc[lb[\"โมเดล\"]].to_string(float_format=lambda x: f\"{x:,.0f}\"))\n", "\n", "fig, ax = plt.subplots(figsize=(9, 3.6))\n", "for name in lb[\"โมเดล\"]:\n", " st = \"--\" if KIND[name] == \"baseline\" else \"-\"\n", " ax.plot(range(1, HORIZON+1), mae_h.loc[name].values, st, marker=\"o\", ms=4, label=name)\n", "ax.set_xlabel(\"พยากรณ์ล่วงหน้ากี่เดือน (h)\"); ax.set_ylabel(\"MAE (บาท)\")\n", "ax.set_title(\"ความผิดพลาดตามระยะ — เส้นประ = baseline\", fontsize=10)\n", "ax.legend(fontsize=8, ncol=3); ax.grid(alpha=.25)\n", "plt.tight_layout(); plt.show()\n", "\n", "trend_up = mae_h.loc[lb.iloc[0][\"โมเดล\"]].values\n", "print()\n", "if trend_up[-1] > trend_up[0] * 1.15:\n", " print(\"→ ความผิดพลาดเพิ่มตามระยะชัดเจน (ตามที่ทฤษฎีคาด)\")\n", "else:\n", " print(\"→ ความผิดพลาด 'ไม่ได้' เพิ่มตามระยะอย่างชัดเจน\")\n", " print(\" เพราะข้อมูลชุดนี้ถูกครอบงำด้วย noise ไม่ใช่แนวโน้ม —\")\n", " print(\" ทำนายเดือนหน้ากับทำนายอีก 6 เดือน ยากพอ ๆ กัน\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 7) รายเดือน vs ยอดรวม — ทำไมยอดรวมถึงแม่นกว่า\n", "\n", "เพราะเดือนที่ทายสูงเกิน กับเดือนที่ทายต่ำเกิน **หักล้างกัน** เมื่อรวมยอด\n", "นี่คือเหตุผลที่ควรตั้งเป้าเป็น **ไตรมาส/ครึ่งปี** ไม่ใช่รายเดือน" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "cmp = lb[[\"โมเดล\", \"MAPE\", \"MAPE_ยอดรวม6ด\"]].copy()\n", "cmp[\"ดีขึ้น (pp)\"] = (cmp[\"MAPE\"] - cmp[\"MAPE_ยอดรวม6ด\"]).round(1)\n", "print(cmp.to_string(index=False, formatters={\n", " \"MAPE\": \"{:.1f}%\".format, \"MAPE_ยอดรวม6ด\": \"{:.1f}%\".format}))\n", "\n", "x = np.arange(len(cmp)); w = .38\n", "fig, ax = plt.subplots(figsize=(9, 3.6))\n", "ax.bar(x - w/2, cmp[\"MAPE\"], w, label=\"พยากรณ์รายเดือน\", color=\"#ef4444\")\n", "ax.bar(x + w/2, cmp[\"MAPE_ยอดรวม6ด\"], w, label=\"พยากรณ์ยอดรวม 6 เดือน\", color=\"#10b981\")\n", "ax.set_xticks(x, cmp[\"โมเดล\"], rotation=15, fontsize=9)\n", "ax.set_ylabel(\"MAPE (%)\"); ax.legend(fontsize=9); ax.grid(alpha=.25, axis=\"y\")\n", "ax.set_title(\"ความผิดพลาดลดลงเมื่อรวมยอด\", fontsize=10)\n", "plt.tight_layout(); plt.show()\n", "\n", "print(f\"\\n💡 โดยเฉลี่ยความผิดพลาดลดลง {cmp['ดีขึ้น (pp)'].mean():.1f} จุดเปอร์เซ็นต์\")\n", "print(\" → ถ้าจะเอาไปตั้ง KPI ให้ตั้งเป็นยอดรวมไตรมาส ไม่ใช่รายเดือน\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 8) ช่วงความเชื่อมั่น — สร้างจากความผิดพลาดจริง แล้ว**ตรวจสอบ**\n", "\n", "คนส่วนใหญ่หยุดที่การ *วาด* ช่วงความเชื่อมั่น แต่ไม่เคยตรวจว่ามัน **ถูกต้อง** ไหม\n", "\n", "วิธีสร้าง: จาก backtest เรามีกองความผิดพลาดที่ระยะ h = 1, 2, ... → คำนวณ σ(h) ของแต่ละกอง\n", "ช่วง 80% คือ `พยากรณ์ ± 1.28 × σ(h)`\n", "\n", "วิธีตรวจ (**coverage test**): ย้อนกลับไปดูว่า ในการทดสอบทั้งหมด\n", "ค่าจริงตกอยู่ในช่วง 80% กี่เปอร์เซ็นต์ — ถ้าได้ ~80% แปลว่าช่วงนี้เชื่อได้" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "Z = {80: 1.2816, 90: 1.6449, 95: 1.9600}\n", "BEST = lb.iloc[0][\"โมเดล\"]\n", "\n", "# σ ต่อระยะ จากความผิดพลาดจริง แล้วบังคับไม่ให้ลดลงตามระยะ\n", "raw = {h: np.std(bt[BEST][\"err_h\"][h], ddof=1) for h in range(1, HORIZON+1)}\n", "sigma, run = {}, 0.0\n", "for h in range(1, HORIZON+1):\n", " run = max(run, raw[h]); sigma[h] = run\n", "\n", "print(f\"โมเดล: {BEST}\")\n", "print(f\"{'h':<4}{'σ ดิบ':>14}{'σ ที่ใช้':>14} หมายเหตุ\")\n", "for h in range(1, HORIZON+1):\n", " note = \"\" if raw[h] >= sigma[h] - 1 else \"← σ ดิบเล็กลง แต่ไม่ยอมให้ช่วงแคบลง\"\n", " print(f\"{h:<4}{raw[h]:>14,.0f}{sigma[h]:>14,.0f} {note}\")\n", "print(\"\\nเหตุผล: จุดทดสอบมีจำกัด σ ดิบจึงแกว่ง — ถ้าปล่อยให้ช่วงเดือนที่ 5 แคบกว่าเดือนที่ 2\")\n", "print(\" เท่ากับบอกว่า 'ทำนายไกลแล้วแม่นขึ้น' ซึ่งไม่จริง\")\n", "\n", "print(\"\\n\" + \"═\"*58)\n", "print(\"ตรวจสอบความครอบคลุม (coverage test)\")\n", "print(\"═\"*58)\n", "print(f\"{'ระดับที่ตั้งใจ':<16}{'ครอบคลุมจริง':>16}{'ผลตรวจ':>18}\")\n", "for lvl, z in Z.items():\n", " hit = tot = 0\n", " for h in range(1, HORIZON+1):\n", " e = np.array(bt[BEST][\"err_h\"][h])\n", " hit += np.sum(np.abs(e) <= z * sigma[h]); tot += len(e)\n", " got = 100 * hit / tot\n", " ok = \"✅ ใช้ได้\" if abs(got - lvl) <= 10 else (\"⚠️ กว้างเกินไป\" if got > lvl else \"⚠️ แคบเกินไป\")\n", " print(f\"{lvl:>3}%{'':<12}{got:>15.1f}%{ok:>18}\")\n", "print(\"\\nถ้าครอบคลุม 'ต่ำกว่า' ที่ตั้งใจ = ช่วงแคบเกินจริง = เรามั่นใจเกินเหตุ (อันตรายที่สุด)\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 9) พยากรณ์ 6 เดือนข้างหน้าของจริง" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "PERIODS, LEVEL = 6, 80\n", "z = Z[LEVEL]\n", "\n", "mean = MODELS[BEST](s, PERIODS)\n", "sd = np.array([sigma[min(i, HORIZON)] * (np.sqrt(i/HORIZON) if i > HORIZON else 1)\n", " for i in range(1, PERIODS+1)])\n", "lower = np.maximum(0, mean - z*sd)\n", "upper = mean + z*sd\n", "idx = pd.date_range(s.index[-1] + pd.DateOffset(months=1), periods=PERIODS, freq=\"MS\")\n", "\n", "fc = pd.DataFrame({\"พยากรณ์\": mean, \"ต่ำสุด\": lower, \"สูงสุด\": upper}, index=idx)\n", "print(f\"โมเดล {BEST} · ช่วงความเชื่อมั่น {LEVEL}%\\n\")\n", "print(fc.to_string(float_format=lambda x: f\"{x:,.0f}\"))\n", "\n", "fig, ax = plt.subplots(figsize=(12, 4.2))\n", "hist = s.iloc[-30:]\n", "ax.plot(hist.index, hist.values, marker=\"o\", ms=3, lw=1.2, color=\"#3b82f6\", label=\"ข้อมูลจริง\")\n", "ax.plot(idx, mean, marker=\"o\", ms=4, lw=2, ls=\"--\", color=\"#10b981\", label=f\"พยากรณ์ ({BEST})\")\n", "ax.fill_between(idx, lower, upper, color=\"#10b981\", alpha=.18, label=f\"ช่วง {LEVEL}%\")\n", "ax.plot([hist.index[-1], idx[0]], [hist.values[-1], mean[0]], lw=1.2, ls=\":\", color=\"gray\")\n", "ax.set_ylabel(\"บาท\"); ax.grid(alpha=.25); ax.legend(fontsize=9)\n", "ax.set_title(f\"พยากรณ์รายได้ {PERIODS} เดือนข้างหน้า — MAPE ที่วัดได้จริง {lb.iloc[0]['MAPE']:.0f}%\",\n", " fontsize=11)\n", "plt.tight_layout(); plt.show()\n", "\n", "print(f\"\\n⚠️ ช่วงกว้าง {(upper-lower).mean():,.0f} บาท \"\n", " f\"(≈ {100*(upper-lower).mean()/mean.mean():.0f}% ของค่าพยากรณ์)\")\n", "print(\" ความกว้างนี้คือความจริง ไม่ใช่ข้อบกพร่อง — ข้อมูลผันผวนขนาดนี้ ช่วงต้องกว้างขนาดนี้\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 10) แปลงเป็นตัวเลขที่ผู้บริหารใช้ตัดสินใจได้\n", "\n", "ตัวเลขพยากรณ์เปล่า ๆ ไม่ช่วยใครตัดสินใจ สิ่งที่ช่วยคือ **ช่วง + สิ่งที่ต้องทำในแต่ละกรณี**" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "total_mean = mean.sum()\n", "total_sd = sigma[HORIZON] * np.sqrt(PERIODS)\n", "agg_mape = lb.iloc[0][\"MAPE_ยอดรวม6ด\"]\n", "\n", "print(\"═\"*66)\n", "print(f\"สรุปสำหรับผู้บริหาร · {idx[0]:%b %Y} – {idx[-1]:%b %Y}\")\n", "print(\"═\"*66)\n", "print(f\"ยอดรวมที่คาดการณ์ : {total_mean:>15,.0f} บาท\")\n", "print(f\"กรณีแย่ (ล่าง 80%) : {max(0,total_mean-1.2816*total_sd):>15,.0f} บาท\")\n", "print(f\"กรณีดี (บน 80%) : {total_mean+1.2816*total_sd:>15,.0f} บาท\")\n", "print(f\"ความผิดพลาดที่วัดได้จริง: {agg_mape:>14.1f}% (ของยอดรวม)\")\n", "print(\"─\"*66)\n", "print(\"ข้อเสนอแนะในการนำไปใช้:\")\n", "print(f\" • ตั้งเป้าที่ยอดรวม {PERIODS} เดือน ไม่ใช่รายเดือน\")\n", "print(f\" (พยากรณ์รายเดือนผิด {lb.iloc[0]['MAPE']:.0f}% แต่ยอดรวมผิดแค่ {agg_mape:.0f}%)\")\n", "print(f\" • วางแผนกระแสเงินสดด้วย 'กรณีแย่' ไม่ใช่ค่ากลาง\")\n", "print(f\" • ทบทวนตัวเลขทุกเดือน — พยากรณ์ไม่ใช่คำสัญญา\")\n", "print(\"─\"*66)\n", "print(\"สิ่งที่โมเดลนี้ทำ 'ไม่ได้' และต้องบอกผู้บริหารตรง ๆ:\")\n", "print(\" ✗ บอกไม่ได้ว่าเดือนไหนจะสูง เดือนไหนจะต่ำ (ฤดูกาลอ่อนเกินไป)\")\n", "print(\" ✗ ทำนายดีลก้อนใหญ่ที่ทำให้ยอดพุ่งไม่ได้ — เพราะดีลเหล่านั้นไม่ได้อยู่ในอดีตเป็นรูปแบบ\")\n", "print(\" → อยากให้แม่นขึ้น ต้องเพิ่ม 'ข้อมูลใหม่' เช่น pipeline รายดีลพร้อมวันคาดปิด\")\n", "print(\" งบลูกค้า และคู่แข่ง — ไม่ใช่เปลี่ยนโมเดล\")\n", "print(\"═\"*66)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 11) เชื่อมกับ API บน Hugging Face Space\n", "\n", "โมเดลที่รันได้แค่ใน Colab ไม่มีใครใช้ — ต้องมี API ให้หน้าเว็บเรียกได้\n", "\n", "API v2 คืนสิ่งเดียวกับที่เราคำนวณในโน้ตบุ๊กนี้: ตารางอันดับ · MAPE จริง · ช่วงจากความผิดพลาดจริง" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import json, urllib.request\n", "\n", "API = \"https://Therdpoom-forcast.hf.space\"\n", "\n", "def call(endpoint, payload, timeout=180):\n", " req = urllib.request.Request(API + endpoint, data=json.dumps(payload).encode(),\n", " headers={\"Content-Type\": \"application/json\"})\n", " with urllib.request.urlopen(req, timeout=timeout) as r:\n", " return json.load(r)\n", "\n", "payload = {\n", " \"data\": [{\"date\": d.strftime(\"%Y-%m\"), \"sales\": float(v)} for d, v in s.items()],\n", " \"periods\": 6, \"model\": \"auto\", \"interval\": 80,\n", "}\n", "\n", "try:\n", " out = call(\"/api/forecast\", payload)\n", " print(\"โมเดลที่ API เลือก :\", out[\"meta\"][\"used_model\"], \"|\", out[\"meta\"][\"selection_reason\"])\n", " print(\"MAPE ที่ API วัดได้ :\", out[\"accuracy\"][\"mape\"], \"%\")\n", " print(\"ความน่าเชื่อถือ :\", out[\"accuracy\"][\"reliability\"][\"th\"])\n", " for n in out[\"accuracy\"][\"reliability\"][\"honest_notes\"]:\n", " print(\" •\", n)\n", " print(\"\\nเทียบกับที่เราคำนวณเองในโน้ตบุ๊ก:\")\n", " print(f\" โมเดลที่เลือก {BEST} · MAPE {lb.iloc[0]['MAPE']:.2f}%\")\n", " print(\" (ต่างกันเล็กน้อยได้ เพราะ API จำกัดจำนวนจุดเริ่มไว้เพื่อความเร็ว)\")\n", " print(\"\\nพยากรณ์จาก API:\")\n", " for f in out[\"forecast\"]:\n", " print(f\" {f['period']} {f['predicted']:>13,.0f} [{f['lower']:>13,.0f} , {f['upper']:>13,.0f}]\")\n", "except Exception as e:\n", " print(\"เรียก API ไม่สำเร็จ:\", type(e).__name__, e)\n", " print(\"(Space อาจกำลัง sleep — เปิด\", API, \"ในเบราว์เซอร์เพื่อปลุก แล้วรันใหม่)\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "\n", "## 📌 บทเรียนของส่วนที่ 3\n", "\n", "1. **ไม่มี baseline = ไม่มีการวัดผล** — MAPE 28% จะดีหรือแย่ ตอบไม่ได้จนกว่าจะรู้ว่า baseline ได้เท่าไหร่\n", "2. **วัดผลกับข้อมูลที่โมเดลเคยเห็น = โกงตัวเอง** — Random Forest ตัวเดียวกันได้ MAPE 9% แบบ in-sample\n", " แต่ได้ 40% เมื่อวัดอย่างถูกต้อง · การสุ่มแบ่ง train/test ก็มี leakage และให้ผลไม่เสถียร\n", "3. **วัดครั้งเดียวคือวัดดวง** — rolling-origin หลายจุดเริ่มเท่านั้นที่บอกฝีมือจริง\n", "4. **ช่วงความเชื่อมั่นต้องถูกตรวจสอบ** — ช่วง 80% ที่ครอบคลุมจริงแค่ 50% อันตรายกว่าไม่มีช่วงเลย\n", "5. **รวมยอดแล้วแม่นขึ้น** — เลือกหน่วยเวลาให้ตรงกับความแม่นที่ทำได้จริง\n", "6. **เพดานอยู่ที่ข้อมูล ไม่ใช่โมเดล** — ผลนี้ตรงกับ Win/Loss Classifier (AUC ~0.65) ในกรณีศึกษาเดียวกัน:\n", " ทั้งสองงานไม่ได้แพ้เพราะโมเดลไม่เก่ง แต่แพ้เพราะปัจจัยที่ตัดสินผลจริงไม่ได้ถูกเก็บไว้ในข้อมูล\n", "\n", "## ✍️ แบบฝึกหัด\n", "\n", "1. เปลี่ยน `HORIZON` จาก 6 เป็น 3 แล้วรันใหม่ — อันดับโมเดลเปลี่ยนไหม\n", " ถ้าเปลี่ยน แปลว่าอะไรเกี่ยวกับการเลือกโมเดล\n", "2. เพิ่มโมเดลใหม่ชื่อ `drift` (ลากเส้นจากจุดแรกถึงจุดสุดท้ายแล้วต่อไป) เข้า `MODELS`\n", " แล้วดูว่ามันชนะ baseline ตัวไหนได้บ้าง\n", "3. ลองพยากรณ์บน `np.log(s)` แล้วแปลงกลับด้วย `np.exp()` — MAPE ดีขึ้นไหม\n", " และทำไมค่าเฉลี่ยหลังแปลงกลับถึงไม่ใช่ค่าเฉลี่ยที่แท้จริง (Jensen's inequality)\n", "4. ใช้ `SARIMAX(..., exog=qt_value)` โดยใส่มูลค่าใบเสนอราคาเป็นตัวแปรภายนอก\n", " — ผลตรงกับที่ส่วนที่ 2 ทำนายไว้ไหม (สหสัมพันธ์ ~0)\n", "5. รวมข้อมูลเป็นรายไตรมาสตั้งแต่ต้น (`s.resample(\"QS\").sum()`) แล้ววัดผลใหม่ทั้งชุด\n", " — ได้ MAPE เท่าไหร่ และเทียบกับการรวมยอดหลังพยากรณ์รายเดือน วิธีไหนดีกว่า\n", "6. **โจทย์เชิงจริยธรรม:** ถ้าผู้บริหารขอ \"ตัวเลขเดียว ไม่เอาช่วง\" คุณจะตอบอย่างไร\n", " ให้เขียนคำตอบ 5 บรรทัดที่ทั้งซื่อสัตย์และใช้งานได้จริง\n", "\n", "---\n", "**เสร็จส่วนที่ 3** — โมเดล วัดผล และ API พร้อมใช้กับ Dashboard ผู้บริหารแล้ว" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11" }, "colab": { "provenance": [], "toc_visible": true } }, "nbformat": 4, "nbformat_minor": 5 }