{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "3636da25",
   "metadata": {},
   "source": [
    "# Class 3｜CNN、RNN、LSTM 實作：台積電大波動時，LSTM 能贏 SVR 嗎？\n",
    "\n",
    "**開啟後先按「執行階段 → 全部執行」（Ctrl＋F9）**，再從上往下看。只改標著 🎛️ 的地方。\n",
    "\n",
    "| 投影片 | Colab | 做什麼 | 時間 |\n",
    "|---|---|---|---|\n",
    "| CNN：Filter 掃過圖片（P20） | A0 | 換 filter、stride、padding，看 feature map | 3 分 |\n",
    "| RNN 手算（P42） | A1 | 改輸入順序看輸出為什麼不同；再看前幾步、猜下一步的輸出 | 5 分 |\n",
    "| LSTM 手算（P62） | A2 | 改輸入看三道門與 memory；再看前幾步、猜下一步的輸出 | 6 分 |\n",
    "| 波動區間、Class 2 的 SVR（P72、P73） | B1 | 看要打敗的對象，按 ▶ 就好 | 2 分 |\n",
    "| 實作 B（P89） | B2 主線三關 | 早停 → 預測修正量 → 記憶檢查，每關只改 1～2 個旋鈕 | 12 分 |\n",
    "| | B3 🎛️ 控制台 | 自由調整，衝排行榜 | 8 分 |\n",
    "| | B4 進階 | 特徵、資料年數、泛化、預測股價／漲跌 | 選做 |\n",
    "| | B5 | 疊圖比較、最後測試 | 3 分 |\n",
    "| 期中坦克（P101） | C | 疊幀 vs LSTM：敵人躲起來時記不記得 | 5 分 |\n",
    "\n",
    "**題目和 Class 2 一樣**：今天收盤後，預測「今天收盤價」相對「7 天後的 15 日均線」的乖離率（%）。\n",
    "**要贏的對手**：基準②價格不動（未來 7 天收盤價都當成今天）。**誤差比＝模型 MAE ÷ 基準 MAE**，小於 1 才算贏。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ad10e304",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 準備工具與內附資料（執行一次，不用改；想看怎麼寫可以展開）\n",
    "\"\"\"Class 3 實作工具。台積電 TWSE 原始日資料 2019–2025（2023–2025 和 Class 2 同一份快照，再往前補 2019–2022），內附在這一格，不用上網抓。\n",
    "神經網路用 PyTorch（Colab 已內建）。\"\"\"\n",
    "import io\n",
    "\n",
    "import re, copy, time, warnings, gzip, base64\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import torch\n",
    "from torch import nn\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.linear_model import Ridge\n",
    "from sklearn.svm import SVR, SVC\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "\n",
    "warnings.filterwarnings('ignore')\n",
    "torch.set_num_threads(1)\n",
    "\n",
    "# ---------- 特徵：和 Class 2 終版同一套算法 ----------\n",
    "def stock_features(raw, ma=15, horizon=7):\n",
    "    d = raw.copy(); d['Date'] = pd.to_datetime(d.Date, errors='coerce')\n",
    "    for c in ['Open', 'High', 'Low', 'Close', 'Volume']:\n",
    "        d[c] = pd.to_numeric(d[c].astype(str).str.replace(',', '', regex=False), errors='coerce')\n",
    "    if d.Date.isna().any() or d.Date.duplicated().any(): raise ValueError('日期缺漏或重複，先查來源。')\n",
    "    d = d.sort_values('Date').reset_index(drop=True)\n",
    "    for w in [5, 8, 10, 20, 60]: d[f'MA{w}'] = d.Close.rolling(w).mean()\n",
    "    d['MA_T'] = d.Close.rolling(ma).mean()\n",
    "    d['BIAS15'] = (d.Close / d.MA_T - 1) * 100\n",
    "    d['BIAS20'] = (d.Close / d.MA20 - 1) * 100\n",
    "    d['BIAS8'] = (d.Close / d.MA8 - 1) * 100\n",
    "    d['return1'] = d.Close.pct_change() * 100\n",
    "    d['return5'] = d.Close.pct_change(5) * 100\n",
    "    d['vol5'] = d.return1.rolling(5).std()\n",
    "    d['range_pct'] = (d.High - d.Low) / d.Close * 100\n",
    "    d['gap_pct'] = (d.Open / d.Close.shift(1) - 1) * 100\n",
    "    d['oc_pct'] = (d.Close / d.Open - 1) * 100\n",
    "    d['volume_ratio'] = d.Volume / d.Volume.rolling(20).mean()\n",
    "    d['ma5_20'] = d.MA5 / d.MA20\n",
    "    # Class 2 的技術指標：KD（9 日，台灣慣用的 1/3 平滑，起始 50）、RSI（14 日簡單平均）、MACD 柱（12, 26, 9，除以收盤價成 %）\n",
    "    lo, hi = d.Low.rolling(9).min(), d.High.rolling(9).max()\n",
    "    rsv = 100 * (d.Close - lo) / (hi - lo).replace(0, np.nan)\n",
    "    k = dd = 50.; ks, ds = [], []\n",
    "    for i, v in enumerate(rsv):\n",
    "        if i < 8: ks.append(np.nan); ds.append(np.nan); continue\n",
    "        v = 50. if pd.isna(v) else v; k = 2 / 3 * k + v / 3; dd = 2 / 3 * dd + k / 3; ks.append(k); ds.append(dd)\n",
    "    d['K9'], d['D9'] = ks, ds\n",
    "    diff = d.Close.diff(); up = diff.clip(lower=0).rolling(14).mean(); down = (-diff.clip(upper=0)).rolling(14).mean()\n",
    "    d['RSI14'] = (100 * up / (up + down)).fillna(50)\n",
    "    dif = d.Close.ewm(span=12, adjust=False).mean() - d.Close.ewm(span=26, adjust=False).mean()\n",
    "    d['MACD'] = (dif - dif.ewm(span=9, adjust=False).mean()) / d.Close * 100\n",
    "    d.loc[d.index < 33, 'MACD'] = np.nan                  # EMA 暖身：前 34 天不用\n",
    "    d['close_px'] = d.Close                            # 收盤價本身（元）：給「預測股價」用\n",
    "    d['vol20'] = d.return1.rolling(20).std()          # 波動：過去 20 天日報酬的標準差（%）\n",
    "    d['target'] = (d.Close / d.MA_T.shift(-horizon) - 1) * 100\n",
    "    d['target_date'] = d.Date.shift(-horizon)\n",
    "    k = ma - horizon\n",
    "    d['baseline'] = (d.Close / ((d.Close.rolling(k).sum() + horizon * d.Close) / ma) - 1) * 100\n",
    "    return d\n",
    "\n",
    "FEATURES = ['BIAS15', 'BIAS20', 'BIAS8', 'return1', 'return5', 'vol5', 'range_pct', 'gap_pct', 'oc_pct', 'volume_ratio', 'ma5_20', 'K9', 'D9', 'RSI14', 'MACD', 'close_px']\n",
    "CLASS2_BEST = ['BIAS15', 'BIAS20', 'BIAS8', 'return1', 'return5']   # Class 2 排行榜前段常用的 5 個特徵\n",
    "LABELS = {'BIAS15': '15 日乖離率', 'BIAS20': '20 日乖離率', 'BIAS8': '8 日乖離率', 'return1': '1 日報酬', 'return5': '5 日報酬',\n",
    "          'vol5': '5 日波動', 'range_pct': '當日震幅', 'gap_pct': '跳空缺口', 'oc_pct': '日內漲跌', 'volume_ratio': '成交量比', 'ma5_20': 'MA5／MA20', 'K9': 'KD 的 K', 'D9': 'KD 的 D', 'RSI14': 'RSI 14 日', 'MACD': 'MACD 柱', 'close_px': '收盤價（元）'}\n",
    "MAX_WINDOW = 40\n",
    "TRAIN_START = '2021-01-01'   # 訓練 2021–2023 三年（Class 2 只用 2023 一年）\n",
    "\n",
    "TASKS = {'bias': '乖離率：今天收盤 vs N 天後的 15 日均線（正式題目）', 'price': '股價：明天的收盤價（元）', 'updown': '漲跌：明天收盤比今天高（漲）還是低（跌）'}\n",
    "\n",
    "def setup(raw, train_start=TRAIN_START, horizon=7, task='bias'):\n",
    "    \"\"\"建立資料、切分、兩個波動區間。回傳 dict，後面所有函式都用它。\n",
    "    task＝bias：乖離率（基準：價格不動）｜price：明天收盤價（基準：明天＝今天）｜updown：明天漲或跌（基準：永遠猜訓練期較多的那一邊）\"\"\"\n",
    "    if task != 'bias': horizon = 1\n",
    "    d = stock_features(raw, horizon=horizon).reset_index(drop=True)\n",
    "    if task == 'price':\n",
    "        d['target'] = d.Close.shift(-1); d['baseline'] = d.Close\n",
    "    elif task == 'updown':\n",
    "        nxt = d.Close.shift(-1); d['target'] = (nxt > d.Close).astype(float).where(nxt.notna())\n",
    "    # 每一列至少要能往回看 MAX_WINDOW 天、所有特徵都算得出來（MA60 暖身），大家用同一批日期比較\n",
    "    first = int(d[FEATURES].notna().all(axis=1).idxmax())     # 所有特徵第一天算得出來的日子\n",
    "    ok = d[FEATURES].notna().all(axis=1) & d.target.notna() & (d.index >= first + MAX_WINDOW - 1)\n",
    "    r = np.flatnonzero(ok)\n",
    "    date, tdate = d.Date.values[r], d.target_date.values[r]\n",
    "    split = np.where((date >= np.datetime64(train_start)) & (date < np.datetime64('2024-01-01')) & (tdate < np.datetime64('2024-01-01')), 'train',\n",
    "            np.where((date >= np.datetime64('2024-01-01')) & (date < np.datetime64('2025-01-01')) & (tdate < np.datetime64('2025-01-01')), 'valid',\n",
    "            np.where(date >= np.datetime64('2025-01-01'), 'test', 'gap')))\n",
    "    # 不刪列：不同的訓練起始年，列數都一樣，預測可以互相比較；沒用到的列標成 gap\n",
    "    if task == 'updown':                                  # 基準：永遠猜訓練期比較常見的那一邊\n",
    "        tr = split == 'train'; d['baseline'] = float(d.target.values[r][tr].mean() >= .5)\n",
    "    S = dict(d=d, task=task, train_start=train_start, horizon=horizon, rows=r, split=split, y=d.target.values[r], base=d.baseline.values[r],\n",
    "             date=d.Date.values[r], tdate=d.target_date.values[r], vol=d.vol20.values[r])\n",
    "    S['mask'] = {k: split == k for k in ['train', 'valid', 'test']}\n",
    "    S['mask']['valid_storm'] = storm(S, 'valid'); S['mask']['test_storm'] = storm(S, 'test')\n",
    "    return S\n",
    "\n",
    "def storm(S, period, length=30):\n",
    "    \"\"\"波動區間：該年連續 30 個交易日、平均 vol20 最高的一段。只看「輸入當天已知」的波動，不看答案或模型誤差。\"\"\"\n",
    "    ids = np.flatnonzero(S['split'] == period); v = S['vol'][ids]\n",
    "    s = int(np.argmax(np.convolve(v, np.ones(length) / length, mode='valid')))\n",
    "    m = np.zeros(len(S['rows']), bool); m[ids[s:s + length]] = True\n",
    "    return m\n",
    "\n",
    "def window_text(S, key):\n",
    "    m = S['mask'][key]; a, b = pd.Timestamp(S['date'][m].min()), pd.Timestamp(S['date'][m].max())\n",
    "    return f'{a:%Y-%m-%d}～{b:%Y-%m-%d}'\n",
    "\n",
    "# ---------- 評分 ----------\n",
    "def mae(a, b): return float(np.mean(np.abs(np.asarray(a) - np.asarray(b))))\n",
    "\n",
    "def score(S, pred, key):\n",
    "    m = S['mask'][key]; y, b, p = S['y'][m], S['base'][m], pred[m]\n",
    "    if S.get('task') == 'updown':                         # 分類：錯誤率比＝模型錯誤率 ÷ 基準錯誤率\n",
    "        acc, bacc = float(np.mean((p >= .5) == (y == 1))), float(np.mean((b >= .5) == (y == 1)))\n",
    "        return dict(mae=1 - acc, base_mae=1 - bacc, ratio=(1 - acc) / max(1 - bacc, 1e-9), dir=acc, base_acc=bacc, n=int(m.sum()))\n",
    "    out = dict(mae=mae(y, p), base_mae=mae(y, b), ratio=mae(y, p) / mae(y, b), n=int(m.sum()))\n",
    "    if S.get('task') == 'price':                          # 方向：預測漲跌（相對今天）有沒有猜對\n",
    "        out['dir'] = float(np.mean((p > b) == (y > b))); out['r2'] = float(1 - ((p - y) ** 2).sum() / ((y - y.mean()) ** 2).sum())\n",
    "    else: out['dir'] = float(np.mean((y < 0) == (p < 0)))\n",
    "    return out\n",
    "\n",
    "# ---------- 把歷史排成 (樣本, 天數 T, 特徵) ----------\n",
    "def make_windows(S, features, T):\n",
    "    d = S['d']; X = d[features].to_numpy(float)\n",
    "    seen = sorted({j for r in S['rows'][S['mask']['train']] for j in range(r - T + 1, r + 1)})\n",
    "    sc = StandardScaler().fit(X[seen])                       # 量尺只從訓練期學\n",
    "    Xs = sc.transform(X).astype('float32')\n",
    "    return np.stack([Xs[r - T + 1:r + 1] for r in S['rows']])  # shape = (樣本數, T, 特徵數)\n",
    "\n",
    "# ---------- 模型 ----------\n",
    "class SeqNet(nn.Module):\n",
    "    def __init__(self, kind, n_feat, T, hidden=16, layers=1, dropout=0.):\n",
    "        super().__init__(); self.kind = kind\n",
    "        if kind == 'MLP':\n",
    "            self.net = nn.Sequential(nn.Flatten(), nn.Linear(n_feat * T, hidden), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden, 1))\n",
    "        else:\n",
    "            cell = nn.LSTM if kind == 'LSTM' else nn.RNN\n",
    "            self.rnn = cell(n_feat, hidden, num_layers=layers, batch_first=True, dropout=dropout if layers > 1 else 0.)\n",
    "            self.drop = nn.Dropout(dropout); self.head = nn.Linear(hidden, 1)\n",
    "    def forward(self, x):\n",
    "        if self.kind == 'MLP': return self.net(x).squeeze(-1)\n",
    "        out, _ = self.rnn(x)                                   # 每個視窗都從空白記憶開始\n",
    "        return self.head(self.drop(out[:, -1])).squeeze(-1)    # 只用最後一天的 hidden state 預測\n",
    "\n",
    "def _train_one(S, W, yt, kind, seed, hidden, layers, lr, epochs, batch, dropout, l2, optimizer, patience, loss, cls=False):\n",
    "    torch.manual_seed(seed); np.random.seed(seed)\n",
    "    X = torch.tensor(W); Y = torch.tensor(yt.astype('float32'))\n",
    "    net = SeqNet(kind, W.shape[2], W.shape[1], hidden, layers, dropout)\n",
    "    opt = (torch.optim.Adam(net.parameters(), lr=lr, weight_decay=l2) if optimizer == 'Adam' else\n",
    "           torch.optim.SGD(net.parameters(), lr=lr, momentum=.9 if optimizer == 'Momentum' else 0., weight_decay=l2))\n",
    "    lf = nn.BCEWithLogitsLoss() if cls else nn.MSELoss() if loss == 'MSE' else nn.L1Loss()\n",
    "    tr = torch.tensor(np.flatnonzero(S['mask']['train'])); va = np.flatnonzero(S['mask']['valid'])\n",
    "    best, best_state, best_ep, stale, hist = np.inf, None, 0, 0, []\n",
    "    for ep in range(1, epochs + 1):\n",
    "        net.train()\n",
    "        for b in tr[torch.randperm(len(tr))].split(batch):    # 打亂的是「整個視窗」，不是視窗裡的日子\n",
    "            opt.zero_grad(); l = lf(net(X[b]), Y[b]); l.backward()\n",
    "            nn.utils.clip_grad_norm_(net.parameters(), 1.); opt.step()\n",
    "        net.eval()\n",
    "        with torch.no_grad(): out = net(X).numpy()\n",
    "        if cls:\n",
    "            with torch.no_grad(): tl, vl = float(lf(torch.tensor(out[tr.numpy()]), Y[tr])), float(lf(torch.tensor(out[va]), Y[va]))\n",
    "        else: tl, vl = float(np.mean(np.abs(out[tr.numpy()] - yt[tr.numpy()]))), float(np.mean(np.abs(out[va] - yt[va])))\n",
    "        hist.append((ep, tl, vl))\n",
    "        if not np.isfinite(vl): break\n",
    "        if vl < best - 1e-5: best, best_state, best_ep, stale = vl, copy.deepcopy(net.state_dict()), ep, 0\n",
    "        else:\n",
    "            stale += 1\n",
    "            if patience and stale >= patience: break\n",
    "    if best_state is not None and patience: net.load_state_dict(best_state)\n",
    "    net.eval()\n",
    "    with torch.no_grad(): out = net(X).numpy()\n",
    "    return out, hist, best_ep, sum(p.numel() for p in net.parameters())\n",
    "\n",
    "DIRECT, RESID = '直接預測（從零算）', '預測修正量（基準＋修正）'\n",
    "\n",
    "def run(S, model='LSTM', features=None, window=20, target=DIRECT, hidden=16, layers=1, lr=3e-3, epochs=60, batch=32,\n",
    "        dropout=0., l2=0., optimizer='Adam', patience=0, loss='MSE', seeds=(0, 1, 2), svr_C=1., svr_gamma='scale', svr_epsilon=.1, ridge_alpha=1.):\n",
    "    \"\"\"訓練一個設定，回傳預測與分數。target＝預測修正量 時，模型只學「答案 − 基準②」，最後再加回基準。\"\"\"\n",
    "    features = list(features or CLASS2_BEST)\n",
    "    cls = S.get('task') == 'updown'\n",
    "    resid = (not cls) and target not in (DIRECT, '直接預測', '預測乖離率', '預測股價')\n",
    "    goal = S['y'] - S['base'] if resid else S['y']\n",
    "    ys = StandardScaler().fit(goal[S['mask']['train'], None])\n",
    "    yt = goal.astype('float32') if cls else ys.transform(goal[:, None]).ravel()\n",
    "    t0 = time.time(); info = dict(hist=[], best_ep=None, params=None)\n",
    "    if cls and model in ('SVR', 'Ridge'):                                # 分類版：SVR → SVC、Ridge → Logistic（Class 2）\n",
    "        W = make_windows(S, features, window); X = W.reshape(len(W), -1); tr = S['mask']['train']\n",
    "        m = SVC(C=svr_C, gamma=svr_gamma) if model == 'SVR' else LogisticRegression(C=1 / ridge_alpha, max_iter=2000)\n",
    "        m.fit(X[tr], goal[tr].astype(int)); z = m.decision_function(X); outs = [z]\n",
    "        info['params'] = X.shape[1] + 1 if model == 'Ridge' else int(len(m.support_))\n",
    "    elif model in ('SVR', 'Ridge'):\n",
    "        W = make_windows(S, features, window); X = W.reshape(len(W), -1)    # 經典模型：把 T 天展平成一排\n",
    "        m = SVR(C=svr_C, gamma=svr_gamma, epsilon=svr_epsilon) if model == 'SVR' else Ridge(alpha=ridge_alpha)\n",
    "        m.fit(X[S['mask']['train']], yt[S['mask']['train']]); outs = [m.predict(X)]\n",
    "        info['params'] = X.shape[1] + 1 if model == 'Ridge' else int(len(m.support_))\n",
    "    else:\n",
    "        W = make_windows(S, features, window); outs, hists = [], []\n",
    "        for sd in seeds:\n",
    "            out, hist, bep, npar = _train_one(S, W, yt, model, sd, hidden, layers, lr, epochs, batch, dropout, l2, optimizer, patience, loss, cls)\n",
    "            outs.append(out); hists.append(hist)\n",
    "        info.update(hist=hists, best_ep=bep, params=npar)\n",
    "    per_seed = [1 / (1 + np.exp(-o)) for o in outs] if cls else [ys.inverse_transform(o[:, None]).ravel() + (S['base'] if resid else 0) for o in outs]\n",
    "    pred = np.mean(per_seed, axis=0)\n",
    "    res = dict(model=model, features=features, window=window, target=target, hidden=hidden, layers=layers, lr=lr, epochs=epochs,\n",
    "               batch=batch, dropout=dropout, l2=l2, optimizer=optimizer, patience=patience, loss=loss, seeds=list(seeds),\n",
    "               svr_C=svr_C, svr_gamma=svr_gamma, svr_epsilon=svr_epsilon, ridge_alpha=ridge_alpha,\n",
    "               pred=pred, per_seed=per_seed, seconds=time.time() - t0, **info)\n",
    "    for k in ['train', 'valid', 'valid_storm', 'test', 'test_storm']: res[k] = score(S, pred, k)\n",
    "    res['seed_ratio'] = [score(S, p, 'valid')['ratio'] for p in per_seed]\n",
    "    # 有沒有真的學到：模型給的修正量（預測 − 基準）和真正的修正量（答案 − 基準）在驗證期的相關係數\n",
    "    v = S['mask']['valid']; c, t = (pred[v], S['y'][v]) if cls else (pred[v] - S['base'][v], S['y'][v] - S['base'][v])\n",
    "    res['learn_corr'] = float(np.corrcoef(c, t)[0, 1]) if c.std() > 1e-9 else 0.\n",
    "    res['shift_std'] = float(c.std()); res['shift_mean'] = float(c.mean())\n",
    "    return res\n",
    "\n",
    "# ---------- 投影片與 Colab 共用的手算小例子 ----------\n",
    "def rnn_toy(seq, w=1., memory0=(0., 0.)):\n",
    "    \"\"\"李宏毅老師的 RNN 例子：所有權重 = 1、沒有 bias、線性激活。回傳每一步的 memory 與輸出。\"\"\"\n",
    "    a = np.array(memory0, float); rows = []\n",
    "    for x in seq:\n",
    "        x = np.asarray(x, float)\n",
    "        h = np.full(2, w * x.sum() + w * a.sum())           # 每個綠色神經元：兩個輸入 + 兩個 memory\n",
    "        y = np.full(2, w * h.sum())                          # 每個紅色神經元：兩個 hidden 相加\n",
    "        rows.append(dict(x=x.tolist(), memory_before=a.tolist(), hidden=h.tolist(), output=y.tolist())); a = h\n",
    "    return rows\n",
    "\n",
    "def sigmoid(z): return 1 / (1 + np.exp(-z))\n",
    "\n",
    "def lstm_toy(seq, c0=0.):\n",
    "    \"\"\"一個 LSTM cell：x2=1 寫入、x2=−1 清除、x3=1 才輸出。權重照影片例子設定。\"\"\"\n",
    "    c, rows = c0, []\n",
    "    for x in seq:\n",
    "        x1, x2, x3 = x\n",
    "        z = x1; zi = 100 * x2 - 10; zf = 100 * x2 + 10; zo = 100 * x3 - 10\n",
    "        gi, gf, go = sigmoid(zi), sigmoid(zf), sigmoid(zo)\n",
    "        c_new = gf * c + gi * z; out = go * c_new\n",
    "        rows.append(dict(x=list(x), z=z, input_gate=gi, forget_gate=gf, output_gate=go, c_before=c, c_after=c_new, output=out)); c = c_new\n",
    "    return rows\n",
    "\n",
    "def conv2d(img, k, stride=1, pad=0):\n",
    "    img = np.pad(np.asarray(img, float), pad); k = np.asarray(k, float); n = (img.shape[0] - k.shape[0]) // stride + 1\n",
    "    return np.array([[float((img[i*stride:i*stride+k.shape[0], j*stride:j*stride+k.shape[1]] * k).sum()) for j in range(n)] for i in range(n)])\n",
    "\n",
    "def maxpool(fm, size=2):\n",
    "    fm = np.asarray(fm); n = fm.shape[0] // size\n",
    "    return np.array([[fm[i*size:(i+1)*size, j*size:(j+1)*size].max() for j in range(n)] for i in range(n)])\n",
    "\n",
    "IMAGE_6x6 = [[1,0,0,0,0,1],[0,1,0,0,1,0],[0,0,1,1,0,0],[1,0,0,0,1,0],[0,1,0,0,1,0],[0,0,1,0,1,0]]\n",
    "FILTER_1 = [[1,-1,-1],[-1,1,-1],[-1,-1,1]]     # 找「右下斜線」\n",
    "FILTER_2 = [[-1,1,-1],[-1,1,-1],[-1,1,-1]]     # 找「直線」\n",
    "\n",
    "# ---------- 坦克小實驗：敵人躲到牆後，記憶有沒有用（合成資料，對應期中範例 A／B） ----------\n",
    "def tank_episodes(n=600, steps=40, max_hide=8, seed=0):\n",
    "    \"\"\"每局 40 步。敵人方向 θ 慢慢改變；有時躲到牆後（觀測裡消失）。\n",
    "    觀測（學生看得到）：[看得到?, sinθ·看得到, cosθ·看得到]；答案（老師看全局）：敵人在左邊(1)還是右邊(0)。\"\"\"\n",
    "    rng = np.random.default_rng(seed); X = np.zeros((n, steps, 3), np.float32); Y = np.zeros((n, steps), np.int64); V = np.zeros((n, steps), bool)\n",
    "    for e in range(n):\n",
    "        th, w = rng.uniform(-np.pi, np.pi), rng.normal(0, .08); vis = np.ones(steps, bool); t = 3\n",
    "        while t < steps:\n",
    "            t += rng.integers(3, 10)\n",
    "            if t < steps: L = rng.integers(1, max_hide + 1); vis[t:t + L] = False; t += L\n",
    "        for t in range(steps):\n",
    "            w = .9 * w + rng.normal(0, .05); th = (th + w + np.pi) % (2 * np.pi) - np.pi\n",
    "            X[e, t] = [vis[t], np.sin(th) * vis[t], np.cos(th) * vis[t]]; Y[e, t] = int(np.sin(th) > 0); V[e, t] = vis[t]\n",
    "    return X, Y, V\n",
    "\n",
    "class TankNet(nn.Module):\n",
    "    def __init__(self, kind, stack=1, hidden=16):\n",
    "        super().__init__(); self.kind, self.stack = kind, stack\n",
    "        if kind == 'LSTM': self.rnn = nn.LSTM(3, hidden, batch_first=True); self.head = nn.Linear(hidden, 2)\n",
    "        else: self.net = nn.Sequential(nn.Linear(3 * stack, hidden), nn.ReLU(), nn.Linear(hidden, 2))\n",
    "    def forward(self, x):                                # x: (局數, 步數, 3)，每一局從空白記憶開始＝reset()\n",
    "        if self.kind == 'LSTM': return self.head(self.rnn(x)[0])\n",
    "        pad = torch.cat([torch.zeros_like(x[:, :1]).repeat(1, self.stack - 1, 1), x], 1)   # 開局前補 0\n",
    "        frames = torch.cat([pad[:, k:k + x.shape[1]] for k in range(self.stack)], -1)      # 疊最近 stack 步\n",
    "        return self.net(frames)\n",
    "\n",
    "def tank_run(kind='LSTM', stack=1, hidden=16, max_hide=8, epochs=40, seed=0):\n",
    "    X, Y, V = tank_episodes(max_hide=max_hide, seed=seed); tr, te = slice(0, 480), slice(480, 600)\n",
    "    torch.manual_seed(seed); net = TankNet(kind, stack, hidden); opt = torch.optim.Adam(net.parameters(), lr=.01)\n",
    "    Xt, Yt = torch.tensor(X), torch.tensor(Y)\n",
    "    for ep in range(epochs):\n",
    "        for b in torch.randperm(480).split(32):\n",
    "            opt.zero_grad(); l = nn.functional.cross_entropy(net(Xt[b]).reshape(-1, 2), Yt[b].reshape(-1)); l.backward(); opt.step()\n",
    "    with torch.no_grad(): p = net(Xt[te]).argmax(-1).numpy()\n",
    "    ok = p == Y[te]; vis = V[te]\n",
    "    hid_len = np.zeros_like(vis, dtype=int)                   # 已經消失幾步\n",
    "    for e in range(vis.shape[0]):\n",
    "        c = 0\n",
    "        for t in range(vis.shape[1]): c = 0 if vis[e, t] else c + 1; hid_len[e, t] = c\n",
    "    by_len = {k: float(ok[hid_len == k].mean()) for k in range(1, max_hide + 1) if (hid_len == k).any()}\n",
    "    return dict(kind=kind, stack=stack, hidden=hidden, visible=float(ok[vis].mean()), hidden_acc=float(ok[~vis].mean()), by_len=by_len,\n",
    "                params=sum(q.numel() for q in net.parameters()), example=dict(x=X[480].tolist(), y=Y[480].tolist(), v=V[480].tolist(), p=p[0].tolist()))\n",
    "\n",
    "# ---------- Colab 介面：手算小例子、控制台、排行榜、最後測試（圖上用英文避免 Colab 中文缺字） ----------\n",
    "import json as _json\n",
    "import matplotlib\n",
    "import matplotlib.pyplot as plt\n",
    "from IPython.display import display, HTML\n",
    "NAVY, ORANGE, GRAY, GREEN, PURPLE = '#073F81', '#ED802C', '#8996A4', '#238678', '#835C98'\n",
    "plt.rcParams.update({'font.size': 12, 'axes.spines.top': False, 'axes.spines.right': False})\n",
    "\n",
    "class Stop(Exception):\n",
    "    \"\"\"停下這一格，只顯示一句話。\"\"\"\n",
    "    def _render_traceback_(self): return [str(self)]\n",
    "\n",
    "def parse_seq(text, width):\n",
    "    try: seq = _json.loads(text)\n",
    "    except Exception: raise Stop(f'⛔ 格式不對：請寫成像 {\"[[1,1],[1,1],[2,2]]\" if width == 2 else \"[[3,1,0],[4,1,0]]\"} 的樣子。')\n",
    "    if not seq or any(len(x) != width for x in seq): raise Stop(f'⛔ 每一步要剛好 {width} 個數字。')\n",
    "    return seq\n",
    "\n",
    "# ---------- A0：CNN ----------\n",
    "def show_conv(image, filt, stride=1, pad=0, pool=True):\n",
    "    fm = conv2d(image, filt, stride, pad)\n",
    "    print(f'圖片 {np.shape(image)[0]}×{np.shape(image)[1]}、filter 3×3、stride={stride}、padding={pad} → feature map {fm.shape[0]}×{fm.shape[1]}')\n",
    "    print(f'   公式：⌊(N + 2P − K) / S⌋ + 1 = ⌊({np.shape(image)[0]} + 2×{pad} − 3) / {stride}⌋ + 1 = {fm.shape[0]}')\n",
    "    panels = [('Image', np.pad(np.asarray(image, float), pad), 'Blues'), ('Filter', np.asarray(filt, float), 'Oranges'), ('Feature map', fm, 'RdBu_r')]\n",
    "    if pool and fm.shape[0] >= 2: panels.append(('Max pooling 2x2', maxpool(fm), 'RdBu_r'))\n",
    "    fig, axes = plt.subplots(1, len(panels), figsize=(3.4 * len(panels), 3.4), layout='constrained')\n",
    "    for ax, (t, m, cmap) in zip(axes, panels):\n",
    "        v = np.abs(m).max() or 1\n",
    "        ax.imshow(m, cmap=cmap, vmin=-v if cmap == 'RdBu_r' else 0, vmax=v)\n",
    "        for (i, j), val in np.ndenumerate(m): ax.text(j, i, f'{val:g}', ha='center', va='center', fontsize=11)\n",
    "        ax.set_title(t); ax.set_xticks([]); ax.set_yticks([])\n",
    "    plt.show()\n",
    "    i, j = np.unravel_index(np.argmax(fm), fm.shape)\n",
    "    print(f'最大值 {fm.max():g} 在第 {i+1} 列第 {j+1} 欄：那裡最像這個 filter 要找的 pattern。')\n",
    "    return fm\n",
    "\n",
    "# ---------- A1：RNN ----------\n",
    "def show_rnn(seq, w=1.):\n",
    "    rows = rnn_toy(seq, w)\n",
    "    for k, r in enumerate(rows, 1):\n",
    "        print(f\"第 {k} 步：輸入 {r['x']}，memory {r['memory_before']} → hidden {r['hidden']} → 輸出 {r['output']}（memory 改存 {r['hidden'][0]:g}）\")\n",
    "    return rows\n",
    "\n",
    "def check_filter(image, filt, target=(0, 3)):\n",
    "    \"\"\"A0 自動檢查：自己設計的 filter，最大值要「只」出現在右上角那條左下斜線（第 1 列第 4 欄）\"\"\"\n",
    "    if not np.any(np.asarray(filt, float)): raise Stop('⛔ MY_FILTER 還是全 0：請自己設計 9 個數字。')\n",
    "    fm = conv2d(image, filt, 1, 0)\n",
    "    top = fm[target]; rest = np.delete(fm.flatten(), target[0] * fm.shape[1] + target[1]).max()\n",
    "    print(f'🔎 自動檢查（stride=1、padding=0）：右上角那格 = {top:g}，其他位置最大 = {rest:g}')\n",
    "    if top > rest:\n",
    "        print(f'✅ 通過：只有右上角的左下斜線得到最高分（比其他位置高 {top - rest:g}）。')\n",
    "    else:\n",
    "        i, j = np.unravel_index(np.argmax(fm), fm.shape)\n",
    "        print(f'❌ 還沒通過：最大值 {fm.max():g} 出現在第 {i+1} 列第 {j+1} 欄。'\n",
    "              '提示：左下斜線是「右上 → 左下」那三格是 1，filter 對應的三格給正數、其他格給負數。')\n",
    "    return top > rest\n",
    "\n",
    "RNN_CHALLENGES = {   # 前面幾步的輸出都給，只猜最後一步\n",
    "    '挑戰 1（WEIGHT 1）': dict(seq=[[1, 0], [0, 1], [1, 1]], w=1.0),\n",
    "    '挑戰 2（WEIGHT 0.5）': dict(seq=[[2, 2], [2, 0], [0, 0]], w=0.5),\n",
    "}\n",
    "\n",
    "def check_rnn(name, guess):\n",
    "    \"\"\"A1 挑戰：看前面幾步的輸入與輸出，猜最後一步的輸出（兩個輸出一樣，填一個數字）\"\"\"\n",
    "    c = RNN_CHALLENGES[name]; rows = rnn_toy(c['seq'], c['w']); ans = rows[-1]['output'][0]\n",
    "    print(f'{name}：WEIGHT = {c[\"w\"]:g}')\n",
    "    for k, r in enumerate(rows, 1):\n",
    "        last = k == len(rows)\n",
    "        g = lambda v: [f'{float(u):g}' for u in v]\n",
    "        print(f'  第 {k} 步：輸入 [{\", \".join(g(r[\"x\"]))}] → 輸出 {\"？\" if last else \"[\" + \", \".join(g(r[\"output\"])) + \"]\"}')\n",
    "    if guess is None: print('\\n👉 先用紙筆算，把答案填在 GUESS（例如 GUESS = 5）再按 ▶ 對答案。'); return None\n",
    "    ok = abs(guess - ans) < 1e-6\n",
    "    print(f'\\n你猜 {guess:g}，答案 {ans:g}　' + ('✅ 答對！' if ok else '❌ 再想想。'))\n",
    "    if not ok: print('提示：hidden ＝ WEIGHT ×（兩個輸入相加 ＋ 上一步的兩個 memory 相加），輸出 ＝ WEIGHT ×（兩個 hidden 相加），memory 存的是上一步的 hidden。')\n",
    "    print('\\n逐步計算：'); show_rnn(c['seq'], c['w'])\n",
    "    return ok\n",
    "\n",
    "# ---------- A2：LSTM ----------\n",
    "LSTM_CHALLENGES = {   # 前面幾步的輸出都給，只猜最後一步\n",
    "    '挑戰 1': [[5, 1, 0], [2, 1, 0], [4, 0, 1]],\n",
    "    '挑戰 2': [[3, 1, 0], [4, 1, 1], [6, -1, 0], [2, 1, 1]],\n",
    "    '挑戰 3': [[3, 1, 1], [0, -1, 0], [5, 0, 0], [1, 0, 1]],\n",
    "}\n",
    "\n",
    "def check_lstm(name, guess):\n",
    "    \"\"\"A2 挑戰：看前面幾步的輸入與輸出，猜最後一步的輸出\"\"\"\n",
    "    seq = LSTM_CHALLENGES[name]; rows = lstm_toy(seq); ans = round(rows[-1]['output'])\n",
    "    print(f'{name}（規則：x₂ = 1 把 x₁ 加進 memory、x₂ = −1 清空、x₃ = 1 才輸出 memory）')\n",
    "    for k, r in enumerate(rows, 1):\n",
    "        print(f'  第 {k} 步：輸入 {r[\"x\"]} → 輸出 {\"？\" if k == len(rows) else round(r[\"output\"])}')\n",
    "    if guess is None: print('\\n👉 先用紙筆算，把答案填在 GUESS（例如 GUESS = 5）再按 ▶ 對答案。'); return None\n",
    "    ok = abs(guess - ans) < .05\n",
    "    print(f'\\n你猜 {guess:g}，答案 {ans:g}　' + ('✅ 答對！' if ok else '❌ 再想想。'))\n",
    "    if not ok: print('提示：一步一步記下 memory 是多少。x₂ = 1 是「加進」不是取代；x₂ = 0 不寫入、但舊的留著；x₃ = 0 時輸出 0，但 memory 還在。')\n",
    "    print('\\n逐步計算（看 memory 那一欄）：'); show_lstm(seq)\n",
    "    return ok\n",
    "\n",
    "def show_lstm(seq):\n",
    "    rows = lstm_toy(seq)\n",
    "    df = pd.DataFrame([{'步': k, '輸入 x': r['x'], 'input 門': round(r['input_gate'], 3), 'forget 門': round(r['forget_gate'], 3),\n",
    "                        'output 門': round(r['output_gate'], 3), 'memory 前': round(r['c_before'], 2), 'memory 後': round(r['c_after'], 2), '輸出 y': round(r['output'], 2)}\n",
    "                       for k, r in enumerate(rows, 1)])\n",
    "    display(df.style.hide(axis='index').format(precision=3))\n",
    "    fig, ax = plt.subplots(figsize=(9, 3), layout='constrained')\n",
    "    t = np.arange(1, len(rows) + 1)\n",
    "    ax.step(t, [r['c_after'] for r in rows], where='mid', color=NAVY, lw=2.5, label='memory c')\n",
    "    ax.bar(t, [r['output'] for r in rows], color=ORANGE, alpha=.6, width=.4, label='output y')\n",
    "    ax.set_xticks(t); ax.set_xlabel('step'); ax.legend(); ax.grid(alpha=.2); plt.show()\n",
    "    return rows\n",
    "\n",
    "# ---------- B：台積電 ----------\n",
    "runs, state = [], {'locked': False, 'storm_start': None}\n",
    "GOALS = [('🥇', .985), ('🥈', .99), ('🥉', 1.0)]\n",
    "\n",
    "CACHE = {}\n",
    "TASK_CODE = {'乖離率（正式題目）': 'bias', '股價：明天收盤價': 'price', '漲跌：明天漲或跌': 'updown'}\n",
    "def use_task(train_from, horizon=7, task='bias'):\n",
    "    \"\"\"題目、訓練從哪一年開始、預測幾天後。驗證 2024、測試 2025 不變。換題目時其他回合保留，各自用自己的資料計分。\"\"\"\n",
    "    global S\n",
    "    task = TASK_CODE.get(task, task)\n",
    "    key = (f'{int(train_from)}-01-01', int(horizon) if task == 'bias' else 1, task)\n",
    "    if key not in CACHE:\n",
    "        CACHE[key] = setup(raw, key[0], key[1], key[2]); _apply_storm(CACHE[key])\n",
    "        for fn in MY_FNS: _attach(CACHE[key], fn)\n",
    "    if _key(S) != key:\n",
    "        S = CACHE[key]\n",
    "        print(f\"ℹ️ 題目：{TASKS[task]}\" + (f\"，預測 {key[1]} 天後\" if task == 'bias' else '') + f\"｜訓練 {key[0][:4]}–2023 共 {int(S['mask']['train'].sum())} 筆\" + ('' if (task, key[1]) == ('bias', 7) else '｜不是正式題目，不計牌'))\n",
    "\n",
    "def _key(X): return (X['train_start'], X['horizon'], X.get('task', 'bias'))\n",
    "\n",
    "def _apply_storm(X, length=30):\n",
    "    start = state['storm_start']\n",
    "    if start:\n",
    "        ids = np.flatnonzero((X['split'] == 'valid') & (X['date'] >= np.datetime64(start)))\n",
    "        if len(ids) < length: raise Stop('⛔ 開始日期太晚：2024 驗證期剩下不到 30 個交易日。')\n",
    "        m = np.zeros(len(X['rows']), bool); m[ids[:length]] = True; X['mask']['valid_storm'] = m\n",
    "    else: X['mask']['valid_storm'] = storm(X, 'valid', length)\n",
    "\n",
    "def set_storm(start=None, length=30):\n",
    "    \"\"\"波動區間：預設＝2024 平均波動最高的 30 天；也可以自己給開始日期（只能在 2024 驗證期內）。\"\"\"\n",
    "    state['storm_start'] = start or None\n",
    "    CACHE.setdefault(_key(S), S)\n",
    "    for X in CACHE.values(): _apply_storm(X, length)\n",
    "    for r in runs: r['valid_storm'] = score(r['S'], r['pred'], 'valid_storm')\n",
    "\n",
    "# ---------- 自己造特徵 ----------\n",
    "PAST_COLS = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'MA5', 'MA8', 'MA10', 'MA20', 'MA60'] + FEATURES   # 全部是「今天收盤就知道」的欄位\n",
    "MY_FEATS, MY_FNS = [], []\n",
    "\n",
    "def _frame(fn, d):\n",
    "    out = fn(d[PAST_COLS].copy())\n",
    "    if isinstance(out, pd.Series): out = out.to_frame(out.name or 'my_feature')\n",
    "    if not isinstance(out, pd.DataFrame): raise Stop('⛔ my_features 要回傳 DataFrame（或一欄 Series）。')\n",
    "    if len(out) != len(d): raise Stop(f'⛔ 回傳 {len(out)} 列，原本有 {len(d)} 列：不要刪列或排序，每天一列。')\n",
    "    return out.reset_index(drop=True)\n",
    "\n",
    "def _attach(X, fn):\n",
    "    out = _frame(fn, X['d'])\n",
    "    for c in out.columns: X['d'][c] = out[c].astype(float).to_numpy()\n",
    "\n",
    "def add_my_features(fn):\n",
    "    out = _frame(fn, S['d'])\n",
    "    if out.shape[1] == 0: raise Stop('⛔ 一個特徵都沒有：至少回傳一欄。')\n",
    "    clash = [c for c in out.columns if c in S['d'].columns and c not in MY_FEATS]\n",
    "    if clash: raise Stop(f'⛔ {clash} 和現有欄位同名，換個名字（例如加 my_ 開頭）。')\n",
    "    bad = [c for c in out.columns if any(w in str(c).lower() for w in ('target', 'future', 'next'))]\n",
    "    if bad: raise Stop(f'⛔ {bad} 看起來是未來的資訊：預測時還不知道，這叫資料洩漏。')\n",
    "    text = [c for c in out.columns if not pd.api.types.is_numeric_dtype(out[c])]\n",
    "    if text: raise Stop(f'⛔ 這幾欄不是數字：{text}。')\n",
    "    start = int(np.searchsorted(S['d'].Date.values, np.datetime64('2020-01-01'))) - MAX_WINDOW + 1   # 最早的訓練起點（2020）往回 40 天\n",
    "    use = out.iloc[start:].astype(float)\n",
    "    nan = [c for c in use.columns if use[c].isna().any()]\n",
    "    if nan: raise Stop(f\"⛔ 這幾欄在 {pd.Timestamp(S['d'].Date[start]):%Y-%m-%d} 之後還有缺值：{nan}。暖身期太長（rolling 超過 60 天）或除以 0？\")\n",
    "    if not np.isfinite(use.to_numpy()).all(): raise Stop('⛔ 出現無限大：是不是除以 0 了？')\n",
    "    for cut in [len(S['d']) * 2 // 3, int(np.searchsorted(S['d'].Date.values, np.datetime64('2024-01-01')))]:\n",
    "        part = _frame(fn, S['d'].iloc[:cut].reset_index(drop=True))\n",
    "        peek = [c for c in out.columns if not np.allclose(out[c].iloc[:cut].to_numpy(float), part[c].to_numpy(float), equal_nan=True)]\n",
    "        if peek: raise Stop(f'⛔ {peek} 偷看了未來：把後面的資料拿掉，過去的值竟然會變。檢查有沒有 shift(-k)、rolling(center=True)、或用了整段期間的平均。')\n",
    "    for c in out.columns:\n",
    "        if c not in MY_FEATS: MY_FEATS.append(c)\n",
    "        LABELS.setdefault(c, c)\n",
    "    MY_FNS.append(fn)\n",
    "    CACHE.setdefault(_key(S), S)\n",
    "    for X in CACHE.values(): _attach(X, fn)\n",
    "    res = S['y'] - S['base']; tr, va = S['mask']['train'], S['mask']['valid']\n",
    "    print(f'✅ 加入 {len(out.columns)} 個自己的特徵：{list(out.columns)}｜沒有偷看未來、沒有缺值')\n",
    "    for c in out.columns:\n",
    "        x = S['d'][c].to_numpy(float)[S['rows']]\n",
    "        print(f\"   {c}：和「真正修正量」的相關　訓練期 {np.corrcoef(x[tr], res[tr])[0, 1]:+.2f}　2024 {np.corrcoef(x[va], res[va])[0, 1]:+.2f}（兩期同號、而且不接近 0，才可能真的有用）\")\n",
    "    print('到控制台勾 USE_MY_FEATURES，再按 ▶。')\n",
    "\n",
    "def is_direct(r): return r['target'] in (DIRECT, '直接預測', '預測乖離率', '預測股價')\n",
    "def name_of(r):\n",
    "    t = r['S'].get('task', 'bias'); m = {'SVR': 'SVC', 'Ridge': 'Logistic'}.get(r['model'], r['model']) if t == 'updown' else r['model']\n",
    "    out = {'bias': ('預測乖離率', '預測修正量'), 'price': ('預測股價', '預測漲跌幾元'), 'updown': ('預測漲跌', '預測漲跌')}[t][0 if is_direct(r) else 1]\n",
    "    return f\"{m}｜看 {r['window']} 天｜{out}\"\n",
    "\n",
    "def show_storm():\n",
    "    d = S['d']; m = d.Date >= '2023-06-01'\n",
    "    fig, ax = plt.subplots(figsize=(12, 3.6), layout='constrained')\n",
    "    ax.plot(d.Date[m], d.vol20[m], color=NAVY, lw=1.8, label='vol20 (std of daily returns, 20 days, %)')\n",
    "    for key, col in [('valid_storm', ORANGE)]:\n",
    "        mm = S['mask'][key]; a, b = pd.Timestamp(S['date'][mm].min()), pd.Timestamp(S['date'][mm].max()); ax.axvspan(a, b, color=col, alpha=.25, label='volatile window (validation)')\n",
    "    for x in ['2024-01-01', '2025-01-01']: ax.axvline(pd.Timestamp(x), color=GRAY, ls='--')\n",
    "    ax.set_ylabel('vol20 (%)'); ax.legend(loc='upper left'); ax.grid(alpha=.2); plt.show()\n",
    "    v = S['vol'][S['mask']['valid_storm']].mean(); allv = S['vol'][S['mask']['valid']].mean()\n",
    "    print(f'波動區間（輸入日）：{window_text(S, \"valid_storm\")}｜平均 vol20 = {v:.2f}%，2024 全年平均 {allv:.2f}%（{v/allv:.1f} 倍）')\n",
    "    print(f'基準②價格不動在波動區的 MAE = {score(S, S[\"base\"], \"valid_storm\")[\"mae\"]:.2f} 個百分點（全年 {score(S, S[\"base\"], \"valid\")[\"mae\"]:.2f}）')\n",
    "\n",
    "def medal(r):\n",
    "    if r['S']['horizon'] != 7 or r['S'].get('task', 'bias') != 'bias': return ''\n",
    "    for m, g in GOALS:\n",
    "        if r['valid']['ratio'] <= g: return m\n",
    "    return ''\n",
    "\n",
    "def _cfg_text(r):\n",
    "    if r['model'] in ('SVR', 'Ridge'):\n",
    "        k = f\"C={r['svr_C']:g}, γ={r['svr_gamma']}, ε={r['svr_epsilon']:g}\" if r['model'] == 'SVR' else f\"α={r['ridge_alpha']:g}\"\n",
    "        return f\"{r['model']} {k}｜T={r['window']}\"\n",
    "    return (f\"{r['model']} h={r['hidden']}×{r['layers']}｜T={r['window']}｜{r['optimizer']} lr={r['lr']:g}｜ep={r['epochs']}\"\n",
    "            + (f\"｜早停{r['patience']}\" if r['patience'] else '') + (f\"｜L2={r['l2']:g}\" if r['l2'] else '') + (f\"｜drop={r['dropout']:g}\" if r['dropout'] else ''))\n",
    "\n",
    "def board(mark=None, top=8):\n",
    "    rows = [dict(回合=i, 說明=r['note'], 題目={'bias': '乖離率', 'price': '股價', 'updown': '漲跌'}[r['S'].get('task', 'bias')], 名稱=name_of(r), 預測幾天後=r['S']['horizon'], 訓練起=r.get('train_from', ''), 設定=_cfg_text(r), 特徵數=len(r['features']),\n",
    "                 驗證誤差比=r['valid']['ratio'], 波動區誤差比=r['valid_storm']['ratio'], 方向正確率=r['valid']['dir'], 學到嗎=round(r['learn_corr'], 2) if (not is_direct(r) or r['S'].get('task') == 'updown') else '', 牌=medal(r) + ('⛈️' if r['valid_storm']['ratio'] < 1 else ''))\n",
    "            for i, r in enumerate(runs)]\n",
    "    df = pd.DataFrame(rows).sort_values('驗證誤差比')\n",
    "    show = df.head(top)\n",
    "    if mark is not None and mark not in show.回合.values: show = pd.concat([show, df[df.回合 == mark]])\n",
    "    show = show.copy(); show.insert(0, '', np.where(show.回合 == mark, '👉', ''))\n",
    "    display(HTML(show.to_html(index=False, float_format=lambda v: f'{v:.3f}')))\n",
    "\n",
    "def _plot_run(r, ref=None):\n",
    "    S = r['S']; m = S['mask']['valid_storm']; t = pd.to_datetime(S['tdate'][m])\n",
    "    has_hist = bool(r['hist'])\n",
    "    fig, axes = plt.subplots(1, 2 if has_hist else 1, figsize=(14 if has_hist else 9, 4), layout='constrained', squeeze=False)\n",
    "    ax = axes[0, 0]\n",
    "    task = S.get('task', 'bias')\n",
    "    if task == 'price':                                    # 股價：畫整個 2024，才看得到天花板\n",
    "        m = S['mask']['valid']; t = pd.to_datetime(S['tdate'][m])\n",
    "    ax.plot(t, S['y'][m], color='#222222', lw=2.8 if task != 'updown' else 0, marker='o' if task == 'updown' else None, ms=4, label='Actual (1 = up, 0 = down)' if task == 'updown' else 'Actual')\n",
    "    if task != 'updown': ax.plot(t, S['base'][m], color=GREEN, ls='--', lw=1.8, label='Baseline: ' + ('tomorrow = today' if task == 'price' else 'price unchanged'))\n",
    "    else: ax.axhline(.5, color=GREEN, ls='--', lw=1.5, label='0.5')\n",
    "    if ref is not None and ref['S'] is S: ax.plot(t, ref['pred'][m], color=PURPLE, lw=1.8, alpha=.8, label=f\"Class 2 SVR (ratio {ref['valid_storm']['ratio']:.2f})\")\n",
    "    ax.plot(t, r['pred'][m], color=ORANGE, lw=2.4, label=f\"This run (ratio {r['valid' if task == 'price' else 'valid_storm']['ratio']:.2f})\")\n",
    "    ax.axhline(0, color=GRAY, lw=.8); ax.set_title('Validation 2024' if task == 'price' else 'Volatile window (validation 2024)'); ax.set_ylabel({'price': 'Close tomorrow (TWD)', 'updown': 'P(up tomorrow)'}.get(task, f\"Close vs MA15 in {S['horizon']} days (%)\"))\n",
    "    ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter('%m/%d')); ax.legend(fontsize=9); ax.grid(alpha=.2)\n",
    "    if has_hist:\n",
    "        ax = axes[0, 1]; h = np.array(r['hist'][0])\n",
    "        ax.plot(h[:, 0], h[:, 1], color=NAVY, label='train'); ax.plot(h[:, 0], h[:, 2], color=ORANGE, label='validation')\n",
    "        if r['patience']: ax.axvline(r['best_ep'], color=GRAY, ls=':', label=f\"best epoch {r['best_ep']}\")\n",
    "        ax.set(xlabel='epoch', ylabel='BCE loss' if task == 'updown' else 'MAE (scaled target)', title='Learning curve (seed 1)'); ax.legend(); ax.grid(alpha=.2)\n",
    "    plt.show()\n",
    "\n",
    "def diagnose(r):\n",
    "    msgs = []\n",
    "    if r['S'].get('task', 'bias') == 'bias' and r['S']['horizon'] != 7: msgs.append(f\"ℹ️ 預測 {r['S']['horizon']} 天後：基準 MAE {r['valid']['base_mae']:.2f}（7 天後是 1.28）。題目越遠越難，但比的仍是「贏基準多少」。\")\n",
    "    if not np.isfinite(r['pred']).all() or (r['S'].get('task', 'bias') == 'bias' and r['valid']['ratio'] > 3): msgs.append('🔴 預測爆掉：學習率太大（Class 1 學習率）。把 LR 調小。')\n",
    "    if r['hist']:\n",
    "        h = np.array(r['hist'][0]); tl, vl = h[-1, 1], h[-1, 2]\n",
    "        if len(h) >= 5 and h[-1, 2] < h[-5:, 2].max() - 1e-3 and not r['patience'] and h[-1, 2] <= h[:, 2].min() + 1e-6:\n",
    "            msgs.append('🟡 最後幾個 epoch 驗證誤差還在下降：還沒學完。試：增加 EPOCHS 或調大 LR。')\n",
    "        k = int(h[:, 2].argmin())\n",
    "        if r['patience'] == 0 and h[-1, 2] > 1.08 * h[k, 2] and h[-1, 1] < h[k, 1]:\n",
    "            msgs.append(f'🟠 過擬合（Class 2 泛化）：第 {int(h[k, 0])} 輪之後訓練誤差還在降、驗證誤差卻回升 {h[-1, 2] / h[k, 2] - 1:.0%}。試：EARLY_STOP、L2、DROPOUT，或把 HIDDEN 調小。')\n",
    "    direct = is_direct(r); task = r['S'].get('task', 'bias')\n",
    "    if task == 'price':\n",
    "        S_ = r['S']; v = S_['mask']['valid']; top, ptop = S_['y'][v].max(), r['pred'][v].max()\n",
    "        if direct and ptop < .9 * top: msgs.append(f'🔴 撞到天花板：2024 股價最高 {top:.0f} 元，模型最多只猜到 {ptop:.0f} 元。訓練期（{r[\"train_from\"]}–2023）沒看過這麼高的價格，LSTM 的 tanh 輸出和訓練期的量尺把它鎖住了。改 TARGET＝預測修正量（預測漲跌幾元）。')\n",
    "        if r['valid'].get('r2', 0) > .9 and r['valid']['ratio'] >= 1: msgs.append(f\"⚠️ R² = {r['valid']['r2']:.2f} 看起來很高，但沒贏「明天＝今天」：明天的價格本來就很接近今天，曲線疊在一起不代表學到東西。\")\n",
    "    if task == 'updown':\n",
    "        acc = r['valid']['dir']\n",
    "        if abs(acc - .5) < .07: msgs.append(f\"🪙 2024 正確率 {acc:.1%}：和擲硬幣（50%）差不多。基準（永遠猜訓練期比較多的「{'漲' if r['S']['base'][0] >= .5 else '跌'}」）是 {r['valid']['base_acc']:.1%}。\")\n",
    "    if task == 'bias' and direct and r['valid']['ratio'] > 1: msgs.append('💡 還沒贏過基準：試試 TARGET＝預測修正量（投影片「改法 1」）。')\n",
    "    if task != 'updown' and not direct:\n",
    "        if r['shift_std'] < .2: msgs.append(f\"🔍 模型幾乎沒有修正基準（修正量平均 {r['shift_mean']:+.2f}、標準差只有 {r['shift_std']:.2f}）：分數接近 1 是因為「幾乎不動」，不一定是學到東西。改 WINDOW 1 vs 40 看看結果會不會變。\")\n",
    "        if r['learn_corr'] > .1: msgs.append(f\"🔍 修正量和真正該修的方向有一點關係（相關 {r['learn_corr']:+.2f}）：可能真的學到一點，換種子、換期間確認。\")\n",
    "        elif r['shift_std'] >= .2: msgs.append(f\"🔍 修正量和真正該修的方向無關（相關 {r['learn_corr']:+.2f}）：模型在亂修，所以分數變差。\")\n",
    "    if r['best_ep'] is not None and r['patience'] and r['best_ep'] <= 2: msgs.append(f\"🔍 早停選在第 {r['best_ep']} 輪：模型發現「幾乎不學」驗證分數最好，代表資料裡可學的線索很少。\")\n",
    "    spread = np.ptp(r['seed_ratio']) if len(r['seed_ratio']) > 1 else 0\n",
    "    if spread > .05: msgs.append(f'🟡 種子之間差很多（{min(r[\"seed_ratio\"]):.3f}～{max(r[\"seed_ratio\"]):.3f}）：小差距可能只是運氣，多跑幾個 SEEDS。')\n",
    "    if r['valid_storm']['ratio'] > 1 and r['valid']['ratio'] < 1: msgs.append('⛈️ 全年贏基準，但波動區還沒贏：大跌常是外部消息，歷史價格裡線索很少。可以試 WINDOW、vol5、range_pct 這類波動特徵。')\n",
    "    if not msgs: msgs.append('🟢 曲線正常。下一步：換一個旋鈕，挑戰排行榜第一名，或拿波動區徽章 ⛈️。')\n",
    "    for m in msgs: print(m)\n",
    "\n",
    "def play(note='', **cfg):\n",
    "    if state['locked']: print('ℹ️ 最後測試已鎖定：可以繼續練習，但不會改變已交出的測試結果。')\n",
    "    if cfg.get('model') != 'SVR' and cfg.get('model') != 'Ridge' and not cfg.get('features'): raise Stop('⛔ 至少勾一個特徵。')\n",
    "    if not cfg.get('features'): raise Stop('⛔ 至少勾一個特徵。')\n",
    "    use_task(cfg.pop('train_from', S['train_start'][:4]), cfg.pop('horizon', S['horizon']), cfg.pop('task', S.get('task', 'bias')))\n",
    "    r = run(S, **cfg); r['S'] = S; r['note'] = note or '—'; r['train_from'] = S['train_start'][:4]; runs.append(r); rid = len(runs) - 1\n",
    "    ref = runs[0] if runs and runs[0]['note'].startswith('Class 2') and rid != 0 else None\n",
    "    print(f\"回合 {rid}｜{name_of(r)}｜\" + (f\"預測 {S['horizon']} 天後\" if S.get('task', 'bias') == 'bias' else TASKS[S['task']]) + f\"｜{_cfg_text(r)}｜{len(r['features'])} 個特徵：{', '.join(LABELS[f] for f in r['features'])}｜{r['seconds']:.1f} 秒\")\n",
    "    if r['params']: print(f\"   參數量：{r['params']:,}\" + ('（SVR：支援向量數）' if r['model'] == 'SVR' else '') + f\"｜訓練資料 {r['train_from']}–2023 共 {int(S['mask']['train'].sum())} 筆\")\n",
    "    _plot_run(r, ref)\n",
    "    v, s_ = r['valid'], r['valid_storm']; task = S.get('task', 'bias')\n",
    "    if task == 'updown':\n",
    "        print(f\"🎯 2024 全年驗證：正確率 {v['dir']:.1%}（基準 {v['base_acc']:.1%}、擲硬幣 50%）→ 錯誤率比 {v['ratio']:.3f}\")\n",
    "        print(f\"⛈️ 波動區 {window_text(S, 'valid_storm')}：正確率 {s_['dir']:.1%}（基準 {s_['base_acc']:.1%}）\")\n",
    "    elif task == 'price':\n",
    "        print(f\"🎯 2024 全年驗證：MAE {v['mae']:.1f} 元（明天＝今天 {v['base_mae']:.1f} 元）→ 誤差比 {v['ratio']:.3f}｜R² {v['r2']:.2f}｜漲跌方向 {v['dir']:.1%}\")\n",
    "        print(f\"⛈️ 波動區 {window_text(S, 'valid_storm')}：MAE {s_['mae']:.1f} 元（基準 {s_['base_mae']:.1f} 元）→ 誤差比 {s_['ratio']:.3f}\")\n",
    "    else:\n",
    "        print(f\"🎯 2024 全年驗證：MAE {v['mae']:.3f}（基準 {v['base_mae']:.3f}）→ 誤差比 {v['ratio']:.3f}｜方向正確率 {v['dir']:.1%}｜{medal(r) or '還沒拿牌'}\")\n",
    "        print(f\"⛈️ 波動區 {window_text(S, 'valid_storm')}：MAE {s_['mae']:.3f}（基準 {s_['base_mae']:.3f}）→ 誤差比 {s_['ratio']:.3f}\" + ('　拿到 ⛈️ 波動徽章！' if s_['ratio'] < 1 else ''))\n",
    "    if len(r['seed_ratio']) > 1: print(f\"   {len(r['seed_ratio'])} 個種子的平均預測；各種子驗證誤差比：{', '.join(f'{x:.3f}' for x in r['seed_ratio'])}\")\n",
    "    nxt = [(m, g) for m, g in GOALS[::-1] if v['ratio'] > g] if task == 'bias' and S['horizon'] == 7 else []\n",
    "    if nxt: print(f\"   下一面牌 {nxt[0][0]}：驗證誤差比 ≤ {nxt[0][1]}（還差 {max(v['ratio'] - nxt[0][1], .001):.3f}）\")\n",
    "    diagnose(r)\n",
    "    if rid > 0:\n",
    "        p = runs[rid - 1]; p.setdefault('horizon', p['S']['horizon']); r['horizon'] = S['horizon']; ch = [k for k in ['train_from', 'horizon', 'model', 'target', 'window', 'hidden', 'layers', 'optimizer', 'lr', 'epochs', 'batch', 'patience', 'l2', 'dropout', 'loss', 'svr_C', 'svr_gamma', 'svr_epsilon', 'ridge_alpha'] if p[k] != r[k]]\n",
    "        add, rem = set(r['features']) - set(p['features']), set(p['features']) - set(r['features'])\n",
    "        txt = '、'.join(f'{k} {p[k]} → {r[k]}' for k in ch) + ('、加入 ' + '、'.join(LABELS[f] for f in add) if add else '') + ('、移除 ' + '、'.join(LABELS[f] for f in rem) if rem else '')\n",
    "        d_ = r['valid']['ratio'] - p['valid']['ratio']\n",
    "        print(f\"📝 和上一回合比：{txt or '設定沒變（換了說明或種子）'} → 驗證誤差比 {'下降' if d_ < 0 else '上升'} {abs(d_):.3f} {'👍' if d_ < 0 else ''}\")\n",
    "    board(mark=rid)\n",
    "    return rid\n",
    "\n",
    "def compare(ids):\n",
    "    ids = [len(runs) - 1 if i == -1 else i for i in ids]\n",
    "    S0 = runs[ids[0]]['S']\n",
    "    if any(_key(runs[i]['S'])[1:] != _key(S0)[1:] for i in ids): raise Stop('⛔ 這幾個回合預測的天數不同（題目不同），不能畫在同一張圖。')\n",
    "    m = S0['mask']['valid_storm']; t = pd.to_datetime(S0['tdate'][m])\n",
    "    fig, (a1, a2) = plt.subplots(1, 2, figsize=(14, 4), layout='constrained', gridspec_kw={'width_ratios': [2.2, 1]})\n",
    "    a1.plot(t, S0['y'][m], color='#222222', lw=2.8, label='Actual'); a1.plot(t, S0['base'][m], color=GREEN, ls='--', label='Baseline')\n",
    "    cols = [ORANGE, NAVY, PURPLE, '#D4A017', '#C0392B']\n",
    "    for c, i in zip(cols, ids): a1.plot(t, runs[i]['pred'][m], color=c, lw=2, label=f\"run {i}: {runs[i]['model']}, T={runs[i]['window']}, {('direct' if runs[i]['target'] in (DIRECT, '直接預測') else 'residual')}\")\n",
    "    a1.set_title('Volatile window'); a1.legend(fontsize=9); a1.grid(alpha=.2); a1.xaxis.set_major_formatter(matplotlib.dates.DateFormatter('%m/%d'))\n",
    "    x = np.arange(len(ids)); a2.bar(x - .2, [runs[i]['valid']['ratio'] for i in ids], .4, color=NAVY, label='2024 all')\n",
    "    a2.bar(x + .2, [runs[i]['valid_storm']['ratio'] for i in ids], .4, color=ORANGE, label='volatile'); a2.axhline(1, color=GREEN, ls='--')\n",
    "    a2.set_xticks(x, [f'run {i}' for i in ids]); a2.set_ylabel('error ratio (1 = baseline)'); a2.legend(); a2.set_ylim(.8, max(1.5, max(runs[i]['valid_storm']['ratio'] for i in ids) + .05))\n",
    "    plt.show()\n",
    "\n",
    "def finish(run_id=-1, confirm=False):\n",
    "    if not runs: raise Stop('⛔ 先在控制台跑至少一個回合。')\n",
    "    rid = int(np.argmin([r['valid']['ratio'] if _key(r['S'])[1:] == (7, 'bias') else 9 for r in runs])) if run_id == -1 else run_id\n",
    "    r = runs[rid]; S = r['S']\n",
    "    if not confirm:\n",
    "        print(f'⏸️ 準備用回合 {rid}（{name_of(r)}，驗證誤差比 {r[\"valid\"][\"ratio\"]:.3f}）做最後測試。確定後把 CONFIRM 改成 True，再按 ▶。'); return\n",
    "    state.update(locked=True, selected=rid)\n",
    "    ref = runs[0] if runs[0]['note'].startswith('Class 2') and runs[0]['S'] is S else None\n",
    "    fig, axes = plt.subplots(1, 2, figsize=(14, 4), layout='constrained')\n",
    "    for ax, key, title in [(axes[0], 'test', '2025 (test, whole year)'), (axes[1], 'test_storm', f'2025 volatile window {window_text(S, \"test_storm\")}')]:\n",
    "        m = S['mask'][key]; t = pd.to_datetime(S['tdate'][m])\n",
    "        ax.plot(t, S['y'][m], color='#222222', lw=2 if key == 'test' else 2.8, label='Actual'); ax.plot(t, S['base'][m], color=GREEN, ls='--', lw=1.4, label='Baseline')\n",
    "        if ref is not None and rid != 0: ax.plot(t, ref['pred'][m], color=PURPLE, lw=1.4, alpha=.8, label=f\"Class 2 method ({ref[key]['ratio']:.2f})\")\n",
    "        ax.plot(t, r['pred'][m], color=ORANGE, lw=1.8, label=f\"run {rid} ({r[key]['ratio']:.2f})\"); ax.set_title(title); ax.legend(fontsize=9); ax.grid(alpha=.2)\n",
    "    plt.show()\n",
    "    for key, nm in [('test', '2025 全年'), ('test_storm', f'2025 波動區 {window_text(S, \"test_storm\")}')]:\n",
    "        x = r[key]; print(f\"{nm}：MAE {x['mae']:.3f}（基準 {x['base_mae']:.3f}）→ 誤差比 {x['ratio']:.3f}｜方向正確率 {x['dir']:.1%}\" + (f\"｜Class 2 做法 {ref[key]['ratio']:.3f}\" if ref is not None else ''))\n",
    "    print(f\"（驗證期：全年 {r['valid']['ratio']:.3f}、波動區 {r['valid_storm']['ratio']:.3f}）\")\n",
    "    print('2025 在 Class 2 已經看過一次：這裡是歷史回測，結果只看一次，不再回頭調參。')\n",
    "\n",
    "# ---------- C：坦克記憶小實驗 ----------\n",
    "tank_runs = []\n",
    "def show_tank(kind, stack, hidden, max_hide):\n",
    "    if kind == 'LSTM' and stack > 1: print('ℹ️ LSTM 自己有記憶，不用疊幀：STACK 這次不看。'); stack = 1\n",
    "    r = tank_run(kind, stack, hidden, max_hide); tank_runs.append(r)\n",
    "    name = f\"{kind}\" + (f\" stack {stack}\" if kind == 'MLP' else '')\n",
    "    print(f\"{name}｜參數 {r['params']:,}｜看得到時正確率 {r['visible']:.1%}｜敵人消失時正確率 {r['hidden_acc']:.1%}（亂猜＝50%）\")\n",
    "    fig, (a1, a2) = plt.subplots(1, 2, figsize=(14, 3.8), layout='constrained', gridspec_kw={'width_ratios': [1.1, 1.6]})\n",
    "    for q in tank_runs[-4:]:\n",
    "        nm = q['kind'] + (f\" stack {q['stack']}\" if q['kind'] == 'MLP' else ''); xs = sorted(q['by_len'])\n",
    "        a1.plot(xs, [100 * q['by_len'][k] for k in xs], marker='o', lw=2.5 if q is r else 1.4, label=nm)\n",
    "    a1.axhline(50, color=GRAY, ls=':'); a1.set(xlabel='steps since enemy disappeared', ylabel='turret direction correct (%)', ylim=(35, 102), title='Last 4 runs'); a1.legend(fontsize=9); a1.grid(alpha=.2)\n",
    "    ex = r['example']; t = np.arange(len(ex['y'])); v = np.array(ex['v'])\n",
    "    a2.fill_between(t, -.15, 1.15, where=~v, color=GRAY, alpha=.25, step='mid', label='enemy hidden')\n",
    "    a2.step(t, ex['y'], where='mid', color='#222222', lw=2.5, label='teacher: enemy on left (1) / right (0)')\n",
    "    a2.step(t, np.array(ex['p']) + .04, where='mid', color=ORANGE, lw=2, label=f'{name} prediction')\n",
    "    a2.set(xlabel='step in one episode', yticks=[0, 1], yticklabels=['right', 'left'], title='One test episode'); a2.legend(fontsize=9, loc='upper right'); plt.show()\n",
    "\n",
    "_RAW = \"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\"\n",
    "raw = pd.read_csv(io.BytesIO(gzip.decompress(base64.b64decode(_RAW))), dtype=str)\n",
    "S = setup(raw); CACHE[(S['train_start'], S['horizon'])] = S\n",
    "print(f\"工具與資料已載入：台積電 2330，{pd.Timestamp(S['d'].Date.min()):%Y-%m-%d}～{pd.Timestamp(S['d'].Date.max()):%Y-%m-%d}｜torch {torch.__version__}\")\n",
    "print(f\"訓練 {int(S['mask']['train'].sum())} 筆（2021–2023，控制台可改）、驗證 {int(S['mask']['valid'].sum())} 筆（2024）、測試 {int(S['mask']['test'].sum())} 筆（2025）\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cb178a21",
   "metadata": {},
   "source": [
    "# 實作 A｜把投影片的手算按一次（約 10 分鐘）"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "da1befe2",
   "metadata": {},
   "source": [
    "## A0｜CNN：Filter 掃過圖片（P20～P27）\n",
    "\n",
    "投影片上的 6×6 圖片。先按 ▶ 確認左上角是 3、右下角是 −1（想一想②），再改旋鈕：\n",
    "- `FILTER` 換成直線 filter：最大值跑到哪裡？\n",
    "- `STRIDE` 改 2、`PADDING` 改 1：feature map 變幾 × 幾？和投影片的公式對得上嗎？\n",
    "- 選「自己填」，在 `MY_FILTER` 設計一個找「左下斜線」的 filter：最大值要**只**出現在右上角那條左下斜線（第 1 列第 4 欄）。執行後會自動檢查。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15439d1a",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ CNN 小實驗\n",
    "FILTER = '斜線 Filter 1'  #@param ['斜線 Filter 1', '直線 Filter 2', '自己填']\n",
    "MY_FILTER = \"[[0, 0, 0], [0, 0, 0], [0, 0, 0]]\"  #@param {type:\"string\"}\n",
    "STRIDE = 1  #@param {type:\"slider\", min:1, max:3, step:1}\n",
    "PADDING = 0  #@param {type:\"slider\", min:0, max:2, step:1}\n",
    "filt = FILTER_1 if FILTER.startswith('斜線') else FILTER_2 if FILTER.startswith('直線') else _json.loads(MY_FILTER)\n",
    "fm = show_conv(IMAGE_6x6, filt, STRIDE, PADDING)\n",
    "if FILTER == '自己填': check_filter(IMAGE_6x6, filt)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4a97fd37",
   "metadata": {},
   "source": [
    "## A1｜RNN：所有權重都是 1（P42、想一想④）\n",
    "\n",
    "預設是投影片的 [1,1]、[1,1]、[2,2]，輸出應該是 [4,4]、[12,12]、[32,32]。\n",
    "把 `SEQUENCE` 換成 `[[2,2],[1,1],[1,1]]`：三個輸入一樣，只換順序，最後輸出變成多少？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0c32b53c",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ RNN 小實驗\n",
    "SEQUENCE = \"[[1,1],[1,1],[2,2]]\"  #@param {type:\"string\"}\n",
    "WEIGHT = 1.0  #@param {type:\"slider\", min:0.5, max:1.5, step:0.5}\n",
    "rows = show_rnn(parse_seq(SEQUENCE, 2), WEIGHT)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1a0d39d5",
   "metadata": {},
   "source": [
    "### A1 挑戰：猜下一步\n",
    "前面幾步的輸入和輸出都給你，**只猜最後一步的輸出**（兩個輸出一樣，填一個數字）。先用紙筆算，再按 ▶ 對答案，下面會列出每一步的計算。\n",
    "\n",
    "提示：hidden ＝ WEIGHT ×（兩個輸入相加 ＋ 上一步的兩個 memory 相加），輸出 ＝ WEIGHT ×（兩個 hidden 相加）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "afa072c4",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎯 RNN 挑戰：猜下一步\n",
    "CHALLENGE = '挑戰 1（WEIGHT 1）'  #@param ['挑戰 1（WEIGHT 1）', '挑戰 2（WEIGHT 0.5）']\n",
    "GUESS = None  #@param {type:\"raw\"}\n",
    "check_rnn(CHALLENGE, GUESS)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "768c8b1b",
   "metadata": {},
   "source": [
    "## A2｜LSTM：三道門（P60～P63）\n",
    "\n",
    "規則：**x₂ = 1 把 x₁ 寫入 memory；x₂ = −1 清空；x₃ = 1 才讀出**。表裡的門是 sigmoid 算出來的 0～1。\n",
    "- 在最後加上 `[5,1,1]`（想一想⑥），輸出是多少？\n",
    "- 試試 `[[3,1,0],[2,0,0],[1,0,1]]`：第 2 步的 2 為什麼沒有被存進去？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6ed58b9d",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ LSTM 小實驗\n",
    "SEQUENCE = \"[[3,1,0],[4,1,0],[2,0,0],[1,0,1],[3,-1,0]]\"  #@param {type:\"string\"}\n",
    "rows = show_lstm(parse_seq(SEQUENCE, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "be7ffa5a",
   "metadata": {},
   "source": [
    "### A2 挑戰：猜下一步\n",
    "前面幾步的輸入和輸出都給你，**只猜最後一步的輸出**。關鍵是自己記住 memory 現在是多少——輸出 0 不代表 memory 是 0。\n",
    "\n",
    "| 挑戰 | 要注意的地方 |\n",
    "|---|---|\n",
    "| 挑戰 1 | x₂ = 1 是「加進」memory |\n",
    "| 挑戰 2 | x₂ = −1 清空之後再寫入 |\n",
    "| 挑戰 3 | x₂ = 0 那一步沒有寫入 |"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a6fb8f91",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎯 LSTM 挑戰：猜下一步\n",
    "CHALLENGE = '挑戰 1'  #@param ['挑戰 1', '挑戰 2', '挑戰 3']\n",
    "GUESS = None  #@param {type:\"raw\"}\n",
    "check_lstm(CHALLENGE, GUESS)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c22cfe1f",
   "metadata": {},
   "source": [
    "# 實作 B｜台積電：LSTM 能贏 Class 2 的 SVR 嗎？（約 25 分鐘）"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ca174711",
   "metadata": {},
   "source": [
    "## B1｜準備：波動區間與要打敗的對象（P72、P73）\n",
    "\n",
    "按 ▶ 就好，不用改。這一格做兩件事：\n",
    "1. 找出 2024 驗證期的**波動區**：「過去 20 天波動」平均最高的連續 30 個交易日（只看當天就知道的波動，不看答案）。\n",
    "2. 跑 **Class 2 的做法**：SVR｜看 1 天｜預測乖離率（5 個特徵、C=1），一樣用 2021–2023 訓練。它是排行榜的**回合 0**，也是要打敗的對象。\n",
    "\n",
    "**誤差比＝模型 MAE ÷ 基準 MAE**，小於 1 才算贏過「價格不動」的基準。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5e653565",
   "metadata": {},
   "outputs": [],
   "source": [
    "set_storm(None); show_storm()\n",
    "BASE = dict(task='乖離率（正式題目）', train_from=2021, horizon=7, features=CLASS2_BEST)   # 主線共用：題目、資料、特徵固定\n",
    "rid = play('Class 2 做法', model='SVR', window=1, svr_C=1., **BASE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5a82b05c",
   "metadata": {},
   "source": [
    "## B2｜主線三關：一次只改一個地方\n",
    "\n",
    "起點是一個**還沒調好**的 LSTM（看 20 天、預測乖離率、沒有早停）。三關照投影片的順序把它一步步改好；每關只露出要改的旋鈕，其他設定沿用上一關。\n",
    "\n",
    "每跑一次會看到：①波動區的預測曲線（紫＝Class 2 SVR、橘＝這一回合）②學習曲線 ③🎯 全年與 ⛈️ 波動區的誤差比 ④🔍 診斷 ⑤排行榜。\n",
    "\n",
    "| 牌 | 2024 全年驗證誤差比 |\n",
    "|---|---|\n",
    "| 🥉 | ≤ 1.00（贏基準） |\n",
    "| 🥈 | ≤ 0.99 |\n",
    "| 🥇 | ≤ 0.985 |\n",
    "| ⛈️ 波動徽章 | 波動區誤差比 < 1 |\n",
    "\n",
    "### 起點：還沒調好的 LSTM\n",
    "按 ▶，記下驗證誤差比，看學習曲線：訓練誤差一直降，驗證誤差呢？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d1ccbf7d",
   "metadata": {},
   "outputs": [],
   "source": [
    "rid = play('起點：LSTM 還沒調好', model='LSTM', window=20, hidden=16, epochs=60, **BASE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1fa53750",
   "metadata": {},
   "source": [
    "### 第 1 關｜早停（Class 2 泛化）\n",
    "把 `EARLY_STOP` 打勾、`EPOCHS` 拉到 150：驗證誤差不再下降就停，留下驗證最好的那一輪。\n",
    "\n",
    "**想一想**：學習曲線上，驗證誤差最低在第幾輪？誤差比改善多少？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f8d6d3fc",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 第 1 關：早停\n",
    "EARLY_STOP = False  #@param {type:\"boolean\"}\n",
    "EPOCHS = 60  #@param {type:\"slider\", min:20, max:300, step:10}\n",
    "rid = play('第 1 關：早停' if EARLY_STOP else '第 1 關：沒有早停', model='LSTM', window=20, hidden=16, epochs=EPOCHS, patience=20 if EARLY_STOP else 0, **BASE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5d4efe38",
   "metadata": {},
   "source": [
    "### 第 2 關｜預測修正量（P74）\n",
    "早停已經打開。把 `TARGET` 換成「預測修正量」：模型不從零猜乖離率，只學「答案和基準差多少」，最後再加回基準。\n",
    "\n",
    "**想一想**：只改一個設定，為什麼差這麼多？有拿到牌嗎？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d2c0aed2",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 第 2 關：預測修正量\n",
    "TARGET = '直接預測（從零算）'  #@param ['直接預測（從零算）', '預測修正量（基準＋修正）']\n",
    "rid = play('第 2 關：' + TARGET[:5], model='LSTM', target=TARGET, window=20, hidden=16, epochs=150, patience=20, **BASE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4bcf85d5",
   "metadata": {},
   "source": [
    "### 第 3 關｜記憶檢查（P79）\n",
    "沿用第 2 關的設定。LSTM 的賣點是記憶：如果它真的用到前幾天的資訊，**只看 1 天**應該明顯變差；`MODEL` 換成沒有記憶的 MLP 也應該變差。\n",
    "先把 `WINDOW` 改 1 跑一次，再改 40 跑一次；有空再換 `MODEL`。看 🔍 診斷裡的「修正量相關」。\n",
    "\n",
    "**想一想**：看 1 天、20 天、40 天差多少？比換種子的差距大嗎？模型真的學到東西了嗎？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7089908c",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 第 3 關：記憶檢查\n",
    "WINDOW = 20  #@param {type:\"slider\", min:1, max:40, step:1}\n",
    "MODEL = 'LSTM'  #@param ['LSTM', 'RNN', 'MLP']\n",
    "rid = play(f'第 3 關：{MODEL} 看 {WINDOW} 天', model=MODEL, target='預測修正量（基準＋修正）', window=WINDOW, hidden=16, epochs=150, patience=20, **BASE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "354ab006",
   "metadata": {},
   "source": [
    "## B3｜🎛️ 自由控制台（P90）\n",
    "\n",
    "三關做完，用這幾個旋鈕自由調整、衝排行榜。**每按一次 ▶，排行榜多一個回合**；`NOTE` 寫下這回合改了什麼，之後才比得起來。\n",
    "\n",
    "股票很難預測，贏基準 1～2% 就不容易。**拿到牌也要看 🔍 診斷**：模型幾乎沒修正基準、或修正方向和答案無關，那 1% 只是運氣。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4e02edc4",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 台積電控制台：改這裡，再按 ▶\n",
    "NOTE = '我的設定'  #@param {type:\"string\"}\n",
    "MODEL = 'LSTM'  #@param ['LSTM', 'RNN', 'MLP']\n",
    "TARGET = '預測修正量（基準＋修正）'  #@param ['直接預測（從零算）', '預測修正量（基準＋修正）']\n",
    "WINDOW = 20  #@param {type:\"slider\", min:1, max:40, step:1}\n",
    "HIDDEN = 16  #@param [4, 8, 16, 32, 64] {type:\"raw\"}\n",
    "LAYERS = 1  #@param {type:\"slider\", min:1, max:3, step:1}\n",
    "EARLY_STOP = True  #@param {type:\"boolean\"}\n",
    "EPOCHS = 150  #@param {type:\"slider\", min:20, max:300, step:10}\n",
    "SEEDS = 3  #@param {type:\"slider\", min:1, max:5, step:1}\n",
    "rid = play(NOTE, model=MODEL, target=TARGET, window=WINDOW, hidden=HIDDEN, layers=LAYERS, epochs=EPOCHS, patience=20 if EARLY_STOP else 0, seeds=tuple(range(SEEDS)), **BASE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "291afc9a",
   "metadata": {},
   "source": [
    "## B4｜進階（選做）\n",
    "\n",
    "想多試的同學再做。下面的**進階控制台**有全部旋鈕；一次只改一個，`NOTE` 寫清楚。\n",
    "\n",
    "| 主題 | 改哪裡 | 投影片 | 想一想 |\n",
    "|---|---|---|---|\n",
    "| 加指標、自己造特徵 | 勾 `K9`、`D9`、`RSI14`、`MACD`；或在 B4-1 寫 `my_features`、勾 `USE_MY_FEATURES` | P80 | 印出的相關：訓練期和 2024 同號嗎？ |\n",
    "| 波動特徵 | 勾 `vol5`、`range_pct` | P72 | 能拿到 ⛈️ 嗎？ |\n",
    "| 訓練資料 | `TRAIN_FROM` 2023 → 2020 | P76 | 資料越多越好嗎？越舊的有用嗎？ |\n",
    "| 預測幾天後 | `HORIZON` 1、3、7、14 | P88 | 基準 MAE 怎麼變？差距呢？ |\n",
    "| 泛化 | `L2` 0.001、`DROPOUT` 0.2 | Class 2 | 波動區有改善嗎？ |\n",
    "| 學習率 | `LR` 0.0003、0.03、1.0 | Class 1 | 太大時曲線長什麼樣？ |\n",
    "| 預測股價 | `TASK`＝股價、勾 `close_px` | P86 | R² 很高為什麼還輸基準？ |\n",
    "| 預測漲跌 | `TASK`＝漲跌；`MODEL` LSTM vs SVR（＝SVC） | Class 2 分類 | 正確率比擲硬幣好多少？ |\n",
    "| 換波動區 | B4-1 的 `STORM_START` 填日期，例如 `2024-04-01` | P72 | 換一段，結論一樣嗎？ |\n",
    "\n",
    "### B4-1｜自己造特徵、換波動區\n",
    "`my_features` 只能用今天收盤就知道的欄位（Open、High、Low、Close、Volume、MA5～MA60 和上面的特徵）。工具會檢查有沒有偷看未來、有沒有缺值，並印出和「真正修正量」的相關：**訓練期和 2024 同號、而且不接近 0，才可能有用。** 外部資料（外資、美股、匯率）記得 `shift(1)`。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "bd5bca35",
   "metadata": {},
   "outputs": [],
   "source": [
    "def my_features(d):\n",
    "    out = pd.DataFrame(index=d.index)\n",
    "    out[\"my_vol10\"] = d[\"return1\"].rolling(10).std()                 # 👀 範例：10 日波動\n",
    "    out[\"my_bias60\"] = (d[\"Close\"] / d[\"MA60\"] - 1) * 100            # 👀 範例：60 日乖離率\n",
    "    # 🎛️ 在這裡加你的特徵，例如：out[\"my_up3\"] = (d[\"return1\"] > 0).rolling(3).sum()\n",
    "    return out\n",
    "\n",
    "add_my_features(my_features)\n",
    "STORM_START = ''   # 🎛️ 換波動區：填日期，例如 '2024-04-01'；留空＝不換（之前的回合不會重算）\n",
    "if STORM_START: set_storm(STORM_START); show_storm()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c2f29061",
   "metadata": {},
   "source": [
    "### B4-2｜🎛️ 進階控制台（全部旋鈕）"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d6cd1f39",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 台積電控制台：改這裡，再按 ▶（每按一次，排行榜多一個回合）\n",
    "NOTE = '進階：我的設定'  #@param {type:\"string\"}\n",
    "#@markdown ---\n",
    "#@markdown ### ⓪ 訓練資料從哪一年開始（到 2023 年底）：Class 2 只用 2023 一年；驗證 2024、測試 2025 不變\n",
    "TRAIN_FROM = 2021  #@param [2020, 2021, 2022, 2023] {type:\"raw\"}\n",
    "#@markdown ### ① 模型（投影片 RNN、LSTM；SVR 是 Class 2）：MLP＝把 T 天展平給一般神經網路\n",
    "MODEL = 'LSTM'  #@param ['LSTM', 'RNN', 'MLP', 'SVR', 'Ridge']\n",
    "#@markdown ### ⓪b 題目：乖離率（正式，計牌）｜股價：明天收盤價（基準：明天＝今天）｜漲跌：明天漲或跌（分類，基準：永遠猜訓練期較多的一邊）\n",
    "TASK = '乖離率（正式題目）'  #@param ['乖離率（正式題目）', '股價：明天收盤價', '漲跌：明天漲或跌']\n",
    "#@markdown ### ② 輸出：直接預測（乖離率／股價），或預測修正量（基準＋修正，投影片「改法 1」；股價題＝預測漲跌幾元）。漲跌題不用選\n",
    "TARGET = '預測修正量（基準＋修正）'  #@param ['直接預測（從零算）', '預測修正量（基準＋修正）']\n",
    "#@markdown ### ②b 乖離率題：預測幾天後（只有 7 天後計牌；股價、漲跌題固定明天）\n",
    "HORIZON = 7  #@param [1, 3, 5, 7, 10, 14] {type:\"raw\"}\n",
    "#@markdown ### ③ 特徵：每天給模型看哪些數字（Class 2 的 5 個預設已勾）\n",
    "#@markdown BIAS15／20／8：15／20／8 日乖離率｜return1、return5：1 日、5 日報酬｜vol5：5 日波動｜range_pct：當日震幅｜gap_pct：跳空｜oc_pct：日內漲跌｜volume_ratio：成交量比｜ma5_20：MA5／MA20｜K9、D9：KD 指標｜RSI14：RSI｜MACD：MACD 柱（%）｜close_px：收盤價本身（元，股價題用）\n",
    "BIAS15 = True  #@param {type:\"boolean\"}\n",
    "BIAS20 = True  #@param {type:\"boolean\"}\n",
    "BIAS8 = True  #@param {type:\"boolean\"}\n",
    "return1 = True  #@param {type:\"boolean\"}\n",
    "return5 = True  #@param {type:\"boolean\"}\n",
    "vol5 = False  #@param {type:\"boolean\"}\n",
    "range_pct = False  #@param {type:\"boolean\"}\n",
    "gap_pct = False  #@param {type:\"boolean\"}\n",
    "oc_pct = False  #@param {type:\"boolean\"}\n",
    "volume_ratio = False  #@param {type:\"boolean\"}\n",
    "ma5_20 = False  #@param {type:\"boolean\"}\n",
    "K9 = False  #@param {type:\"boolean\"}\n",
    "D9 = False  #@param {type:\"boolean\"}\n",
    "RSI14 = False  #@param {type:\"boolean\"}\n",
    "MACD = False  #@param {type:\"boolean\"}\n",
    "close_px = False  #@param {type:\"boolean\"}\n",
    "#@markdown USE_MY_FEATURES：加入 B4-1 自己造的特徵\n",
    "USE_MY_FEATURES = False  #@param {type:\"boolean\"}\n",
    "#@markdown ### ④ 輸入：看幾天的歷史（WINDOW）；SVR、Ridge 會把 T 天攤平。檢查記憶：1 天 vs 40 天結果有變嗎？\n",
    "WINDOW = 20  #@param {type:\"slider\", min:1, max:40, step:1}\n",
    "#@markdown ### ⑤ 網路大小（LSTM 參數約是一般網路的 4 倍；比一比參數量和訓練筆數）\n",
    "HIDDEN = 16  #@param [4, 8, 16, 32, 64] {type:\"raw\"}\n",
    "LAYERS = 1  #@param {type:\"slider\", min:1, max:3, step:1}\n",
    "#@markdown ### ⑥ 梯度下降（Class 1、2）\n",
    "OPTIMIZER = 'Adam'  #@param ['Adam', 'Momentum', 'SGD']\n",
    "LR = 0.003  #@param [0.0003, 0.001, 0.003, 0.01, 0.03, 0.1, 1.0] {type:\"raw\"}\n",
    "EPOCHS = 150  #@param {type:\"slider\", min:5, max:300, step:5}\n",
    "BATCH = 32  #@param [8, 32, 128, 512] {type:\"raw\"}\n",
    "LOSS = 'MSE'  #@param ['MSE', 'MAE']\n",
    "#@markdown ### ⑦ 泛化（Class 2）：早停、L2、Dropout\n",
    "EARLY_STOP = True  #@param {type:\"boolean\"}\n",
    "PATIENCE = 20  #@param {type:\"slider\", min:5, max:50, step:5}\n",
    "L2 = 0  #@param [0, 0.0001, 0.001, 0.01, 0.1] {type:\"raw\"}\n",
    "DROPOUT = 0.0  #@param {type:\"slider\", min:0, max:0.6, step:0.1}\n",
    "#@markdown ### ⑧ 種子：神經網路跑幾個種子取平均（越多越穩，也越慢）\n",
    "SEEDS = 3  #@param {type:\"slider\", min:1, max:5, step:1}\n",
    "#@markdown ### ⑨ 只有 MODEL＝SVR／Ridge 才用到（Class 2）\n",
    "SVR_C = 1.0  #@param [0.1, 1.0, 10.0, 100.0] {type:\"raw\"}\n",
    "SVR_GAMMA = 'scale'  #@param ['scale', '0.01', '0.1', '1']\n",
    "SVR_EPSILON = 0.1  #@param [0.01, 0.1, 0.5] {type:\"raw\"}\n",
    "RIDGE_ALPHA = 1.0  #@param [0.1, 1.0, 10.0, 100.0] {type:\"raw\"}\n",
    "\n",
    "FEATS = [f for f in FEATURES if globals()[f]] + (MY_FEATS if USE_MY_FEATURES else [])\n",
    "play(NOTE, task=TASK, train_from=TRAIN_FROM, horizon=HORIZON, model=MODEL, target=TARGET, features=FEATS, window=WINDOW, hidden=HIDDEN, layers=LAYERS, optimizer=OPTIMIZER, lr=LR,\n",
    "     epochs=EPOCHS, batch=BATCH, loss=LOSS, patience=PATIENCE if EARLY_STOP else 0, l2=L2, dropout=DROPOUT, seeds=tuple(range(SEEDS)),\n",
    "     svr_C=SVR_C, svr_gamma=SVR_GAMMA if SVR_GAMMA == 'scale' else float(SVR_GAMMA), svr_epsilon=SVR_EPSILON, ridge_alpha=RIDGE_ALPHA)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e432511b",
   "metadata": {},
   "source": [
    "## B5｜比較與最後測試\n",
    "\n",
    "### 把幾個回合疊在一起比較\n",
    "填回合編號（排行榜第一欄），-1＝最後一回合。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10507054",
   "metadata": {},
   "outputs": [],
   "source": [
    "COMPARE = [0, -1]   # 🎛️ 改這裡\n",
    "compare(COMPARE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "be0c3884",
   "metadata": {},
   "source": [
    "### 完整排行榜"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6cc474db",
   "metadata": {},
   "outputs": [],
   "source": [
    "board(top=100)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4dfa21e4",
   "metadata": {},
   "source": [
    "### 最後測試：2025 全年與 2025 波動區（只看一次）\n",
    "選你在驗證期最有把握的回合（-1＝驗證誤差比第一名），把 `CONFIRM` 改成 True。2025 的波動區是 4 月關稅衝擊那一段。\n",
    "2025 在 Class 2 已經看過一次，所以這是歷史回測；看完不再回頭調參。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23c3bbcc",
   "metadata": {},
   "outputs": [],
   "source": [
    "SELECTED_RUN = -1        # 🎛️ 回合編號\n",
    "CONFIRM = False\n",
    "finish(SELECTED_RUN, confirm=CONFIRM)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "620b8e94",
   "metadata": {},
   "source": [
    "# 實作 C｜期中暖身：敵人躲起來時，記憶有用嗎？（約 5 分鐘，P101）\n",
    "\n",
    "簡化版的坦克（合成資料，不是真的伺服器）：每局 40 步，敵人方向慢慢改變，有時躲到牆後、從觀測消失。\n",
    "坦克只看得到 `[看得到嗎, sin方向, cos方向]`；老師看全局，給答案「敵人在左邊還是右邊」（砲塔該往哪轉）。\n",
    "- 先跑 `MLP`、`STACK=1`（只看當下）→ 再 `STACK=4`（範例 A 鐵壁）→ 再 `LSTM`（範例 B 幽影）。\n",
    "- 把 `MAX_HIDE` 從 8 調到 12：疊 4 步的 MLP 會怎樣？LSTM 呢？"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "94d87f60",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 坦克記憶小實驗\n",
    "MODEL = 'MLP'  #@param ['MLP', 'LSTM']\n",
    "STACK = 1  #@param {type:\"slider\", min:1, max:8, step:1}\n",
    "HIDDEN = 16  #@param [4, 8, 16, 32] {type:\"raw\"}\n",
    "MAX_HIDE = 8  #@param {type:\"slider\", min:1, max:12, step:1}\n",
    "show_tank(MODEL, STACK, HIDDEN, MAX_HIDE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1fbee939",
   "metadata": {},
   "source": [
    "# D｜反思與繳交\n",
    "\n",
    "用自己的話回答，引用你在排行榜、疊圖、最後測試看到的數字。繳交可檢視的 Colab 連結。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6b6d2af6",
   "metadata": {},
   "outputs": [],
   "source": [
    "q1_lstm_better = \"請填寫\"   # ① LSTM 有比 Class 2 的 SVR 好嗎？好在哪裡？（引用排行榜數字）\n",
    "q2_storm = \"請填寫\"         # ② 大波動區間裡，模型表現怎樣？為什麼？\n",
    "q3_next = \"請填寫\"          # ③ 如果要再改進，你會怎麼做？\n",
    "q4_tank = \"請填寫\"          # ④ 期中的坦克：你的機體和模組會用疊幀還是 LSTM？為什麼？（引用 C 的結果）\n",
    "print('①', q1_lstm_better); print('②', q2_storm); print('③', q3_next); print('④', q4_tank)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bc46874a",
   "metadata": {},
   "source": [
    "## 資料與閱讀\n",
    "- 行情：TWSE STOCK_DAY 2330（2023–2025，和 Class 2 終版同一份快照）\n",
    "- 李宏毅，CNN：https://www.youtube.com/watch?v=OP5HcXJg2Aw\n",
    "- 李宏毅，RNN／LSTM：https://www.youtube.com/watch?v=xCGidAeyS4M\n",
    "- PyTorch LSTM：https://docs.pytorch.org/docs/stable/generated/torch.nn.LSTM.html"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
