Lesson 09 · 約 30 分鐘

習題與自我檢測

選擇題 + 判斷題 + 自動批改練習

🛠 跟著做

共 2 支 · 在本機 venv 跑 · 還沒裝環境?

09_eval_basics.py
python
📦 套件: numpy
🐙 在 GitHub 看完整原始碼
📄 原始碼
"""
09_eval_basics.py
================================
評估(evaluation / evals)入門——業界最看重、初學者最常忽略的一塊。
「能不能說清楚你的模型有多好、在哪裡爛」往往比「會不會訓練」更值錢。
對照講義 docs/00(第 1、4 站)、docs/06(名詞速查表)。

這支程式示範兩組最核心的指標,全部手算(不准用現成函式庫):

  A) 分類指標:accuracy / precision / recall / F1 + 混淆矩陣
     —— 回答「準不準?是寧可錯殺還是寧可放過?」
  B) 語言模型指標:交叉熵 loss 與困惑度(perplexity)
     —— 回答「模型對下一個字有多不確定?」(GPT 類模型的標準評估)

執行:python3 09_eval_basics.py   (只需要 numpy)
"""

import sys
import numpy as np

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")


# ──────────────────────────────────────────────────────────────
# A) 分類指標
# ──────────────────────────────────────────────────────────────
def classification_metrics(y_true, y_pred):
    """二元分類:1 = 陽性(例如「是垃圾郵件」),0 = 陰性。"""
    y_true = np.asarray(y_true)
    y_pred = np.asarray(y_pred)
    tp = int(np.sum((y_pred == 1) & (y_true == 1)))   # 真陽:說是、真的是
    fp = int(np.sum((y_pred == 1) & (y_true == 0)))   # 偽陽:說是、其實不是(誤報)
    fn = int(np.sum((y_pred == 0) & (y_true == 1)))   # 偽陰:說不是、其實是(漏抓)
    tn = int(np.sum((y_pred == 0) & (y_true == 0)))   # 真陰:說不是、真的不是

    accuracy = (tp + tn) / len(y_true)                # 整體答對比例
    precision = tp / (tp + fp) if (tp + fp) else 0.0  # 你說是的,有多少真的是(重視「別誤報」)
    recall = tp / (tp + fn) if (tp + fn) else 0.0     # 真的是的,你抓到多少(重視「別漏抓」)
    f1 = (2 * precision * recall / (precision + recall)
          if (precision + recall) else 0.0)           # precision 與 recall 的調和平均
    return dict(tp=tp, fp=fp, fn=fn, tn=tn, accuracy=accuracy,
                precision=precision, recall=recall, f1=f1)


def demo_classification():
    print("=" * 64)
    print("A) 分類指標:以『垃圾郵件偵測』為例(1=垃圾, 0=正常)")
    print("=" * 64)
    # 10 封信的真實標籤與模型預測
    y_true = [1, 0, 1, 1, 0, 0, 1, 0, 1, 0]
    y_pred = [1, 0, 1, 0, 0, 1, 1, 0, 1, 0]
    m = classification_metrics(y_true, y_pred)

    print("  混淆矩陣:")
    print(f"                 預測=垃圾   預測=正常")
    print(f"    實際=垃圾       {m['tp']:^6}     {m['fn']:^6}   ← 漏抓(FN)={m['fn']}")
    print(f"    實際=正常       {m['fp']:^6}     {m['tn']:^6}   ← 誤報(FP)={m['fp']}")
    print()
    print(f"  Accuracy  準確率 = {m['accuracy']:.2f}  (整體答對比例)")
    print(f"  Precision 精確率 = {m['precision']:.2f}  (標成垃圾的信裡,真的是垃圾的比例)")
    print(f"  Recall    召回率 = {m['recall']:.2f}  (所有垃圾信裡,被抓到的比例)")
    print(f"  F1 分數          = {m['f1']:.2f}  (precision 與 recall 的平衡)")
    print()
    print("  為什麼不能只看 accuracy?若 100 封信只有 1 封垃圾,")
    print("  模型「全部猜正常」accuracy 高達 99%,卻一封垃圾都沒抓到(recall=0)。")
    print("  這就是『不平衡資料』陷阱 → 要看 precision/recall/F1。\n")


# ──────────────────────────────────────────────────────────────
# B) 語言模型指標:交叉熵與困惑度
# ──────────────────────────────────────────────────────────────
def cross_entropy(probs_for_true):
    """
    給每一步「模型分配給『真正下一個字』的機率」,算平均交叉熵:
        CE = 平均( −log P(真正的字) )
    猜得越準(機率越接近 1)→ −log 越接近 0 → loss 越小。
    """
    probs_for_true = np.asarray(probs_for_true)
    return float(np.mean(-np.log(probs_for_true)))


def perplexity(ce_loss):
    """困惑度 = exp(交叉熵)。直覺:模型平均在『幾個字之間猶豫』。越小越好。"""
    return float(np.exp(ce_loss))


def demo_language_model():
    print("=" * 64)
    print("B) 語言模型指標:交叉熵 loss 與困惑度(perplexity)")
    print("=" * 64)
    # 假設詞表有 50 個字。比較三個模型對同一句(5 個字)的表現:
    vocab_size = 50
    print(f"  假設詞表 {vocab_size} 個字。完全亂猜的模型,每字機率 = 1/{vocab_size}。\n")

    scenarios = {
        "亂猜的模型      ": [1 / vocab_size] * 5,
        "學了一點的模型  ": [0.10, 0.05, 0.20, 0.08, 0.12],
        "學得不錯的模型  ": [0.6, 0.4, 0.8, 0.5, 0.7],
    }
    for name, probs in scenarios.items():
        ce = cross_entropy(probs)
        ppl = perplexity(ce)
        print(f"  {name} | 交叉熵 loss = {ce:.3f} | 困惑度 = {ppl:6.2f}")
    print()
    print(f"  亂猜模型的困惑度會接近詞表大小 {vocab_size}(在 {vocab_size} 個字之間瞎猜)。")
    print("  困惑度越往 1 靠 → 模型越確定下一個字。")
    print("  👉 這正是 code/02_tiny_gpt.py 訓練時印出的 loss(取 exp 就是困惑度)。\n")


if __name__ == "__main__":
    demo_classification()
    demo_language_model()
    print("一句話:會訓練不稀奇,能說清楚『好在哪、爛在哪、用哪個指標衡量』才是專業。")
13_timeseries_forecast.py
python
📦 套件: numpy
🐙 在 GitHub 看完整原始碼
📄 原始碼
"""
13_timeseries_forecast.py
================================
時間序列預測入門——股價、用電、需求預測都屬這塊(對你的股票興趣直接有用)。
對照講義 docs/07(全景地圖:時間序列)。只用 numpy,全部手刻。

這支程式的重點觀念(業界做時間序列最該先懂的事):
  1. 時間序列「不能隨機切」train/test:未來不能拿來訓練 → 一定照時間切(前面訓練、後面測試)。
  2. 一定要先比「笨基準線」:最笨的「明天=今天」往往比想像中難打贏。
       打不贏基準線的花俏模型,等於沒用。
  3. AR(p) 自迴歸:用「過去 p 天」線性預測「下一天」,用最小平方法求解。
  4. 用 MAE / RMSE 評估,並和基準線比較。

⚠️ 重要:這裡用「合成資料(趨勢+季節+雜訊)」示範方法。真實股價接近隨機漫步,
   極難預測;任何聲稱能輕鬆預測股價的,請高度懷疑。這支教的是「方法與紀律」,不是明牌。

執行:python3 13_timeseries_forecast.py   (只需要 numpy)
"""

import sys
import numpy as np

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")

rng = np.random.default_rng(0)


def make_series(n=300):
    """造一條有『上升趨勢 + 週期季節 + 雜訊』的時間序列(像某些銷售/用電資料)。"""
    t = np.arange(n)
    trend = 0.05 * t                       # 緩慢上升
    season = 3.0 * np.sin(2 * np.pi * t / 30)   # 每 30 步一個週期
    noise = rng.normal(0, 0.8, n)
    return 10 + trend + season + noise


def mae(y_true, y_pred):
    return float(np.mean(np.abs(y_true - y_pred)))


def rmse(y_true, y_pred):
    return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))


def make_lagged(series, p):
    """把序列攤成監督式學習資料:用前 p 個值當特徵 X,下一個值當目標 y。"""
    X, y = [], []
    for i in range(p, len(series)):
        X.append(series[i - p:i])
        y.append(series[i])
    return np.array(X), np.array(y)


def fit_ar(X, y):
    """AR(p) 線性模型:y ≈ X·w + b,用最小平方法(normal equation)一次解出。"""
    X1 = np.concatenate([X, np.ones((len(X), 1))], axis=1)   # 加一欄常數項當 b
    coef, *_ = np.linalg.lstsq(X1, y, rcond=None)            # 解 min ||X1·coef − y||²
    return coef                                              # 最後一個是 b


def predict_ar(X, coef):
    X1 = np.concatenate([X, np.ones((len(X), 1))], axis=1)
    return X1 @ coef


def main():
    series = make_series()
    n = len(series)

    # ── 照時間切:前 80% 訓練、後 20% 測試(絕不打亂!)──
    split = int(n * 0.8)
    train, test = series[:split], series[split:]
    print("=" * 64)
    print(f"序列長度 {n}(訓練 {len(train)} / 測試 {len(test)})。時間序列照時間切,不可隨機打亂。")
    print("=" * 64)

    # ── 基準線 1:naive(明天 = 今天)──
    naive_pred = series[split - 1:n - 1]      # 用前一天當預測
    print("\n[基準線] naive『明天=今天』:")
    print(f"   MAE={mae(test, naive_pred):.3f}  RMSE={rmse(test, naive_pred):.3f}")

    # ── 基準線 2:移動平均(過去 k 天的平均)──
    k = 5
    ma_pred = np.array([series[split + i - k:split + i].mean() for i in range(len(test))])
    print(f"\n[基準線] 移動平均(過去 {k} 天):")
    print(f"   MAE={mae(test, ma_pred):.3f}  RMSE={rmse(test, ma_pred):.3f}")

    # ── 模型:AR(p) 自迴歸 ──
    p = 10
    Xtr, ytr = make_lagged(train, p)
    coef = fit_ar(Xtr, ytr)
    # 在測試段逐步預測(每次用「真實的過去 p 天」預測下一天,walk-forward)
    ar_pred = []
    for i in range(len(test)):
        window = series[split + i - p:split + i]
        ar_pred.append(predict_ar(window[None, :], coef)[0])
    ar_pred = np.array(ar_pred)
    print(f"\n[模型] AR({p}) 自迴歸(最小平方法):")
    print(f"   MAE={mae(test, ar_pred):.3f}  RMSE={rmse(test, ar_pred):.3f}")

    # ── 結論:模型有沒有打贏基準線?──
    best_baseline = min(mae(test, naive_pred), mae(test, ma_pred))
    ar_mae = mae(test, ar_pred)
    print("\n" + "=" * 64)
    if ar_mae < best_baseline:
        print(f"✅ AR 模型 MAE={ar_mae:.3f} 打贏最好的基準線 {best_baseline:.3f}——這條序列有可學的規律。")
    else:
        print(f"⚠️ AR 模型沒打贏基準線——表示要嘛資料太隨機,要嘛模型/特徵要再想。")
    print("這套紀律(照時間切、先比基準線、用 MAE/RMSE)對任何時間序列都適用,股票也一樣。")
    print("想進階:把 AR 換成神經網路(LSTM / Transformer)、加更多特徵(成交量、技術指標)。")


if __name__ == "__main__":
    main()