Lesson 09 · 約 30 分鐘
習題與自我檢測
選擇題 + 判斷題 + 自動批改練習
09 ・ 習題與自我檢測:把「看完」變成「做完」
整套教材一直強調「看完不等於會,做完才算」,這份就是補上「做」的著力點。 每一關有三種題:🔧 動手改(改程式看結果)、🧠 想清楚(用自己的話回答)、🏗️ 進階做(小專案)。
關卡 0 ・ 數學基礎(配 code/10_math_foundations.py、docs/08 第 1~2 節)
🔧 動手改
- 把
code/10線性回歸的lr從0.05改成0.5、再改成1.5,記錄三次的 loss 變化。哪個發散了? - 把資料的雜訊
np.random.randn(40) * 0.5的0.5改成2.0,學到的w, b會更準還是更不準?為什麼?
🧠 想清楚 3. 用一句話解釋「梯度」是什麼,以及為什麼「往梯度反方向走」能讓 loss 變小。4. 什麼是 gradient check?它為什麼能幫你抓到「反向傳播寫錯」的 bug?
✅ 參考解答
lr=1.5通常發散(loss 越來越大或變nan)。學習率太大,每步跨過谷底還越跨越遠。- 更不準。雜訊變大,資料偏離真線更多,估計的
w,b變異變大(但平均仍接近真值)。 - 梯度是「loss 對每個參數的偏微分」,指向loss 上升最快的方向;走反方向就是下降最快的方向,所以 loss 會變小。
- gradient check = 拿「數值梯度(動一點點 ±ε 看 loss 差多少)」當標準答案,比對你手寫的「解析梯度」。 若兩者差很多,代表你的反向傳播公式寫錯了——數值梯度幾乎不會錯,所以是可靠的對照。
關卡 1 ・ 注意力(配 code/01_attention_from_scratch.py、docs/08 第 3 節)
🔧 動手改
- 把
multi_head_attention(..., causal=True)改成causal=False,注意力權重矩陣會怎麼變?(提示:右上角) - 把
n_heads從 2 改成 4,輸出形狀會變嗎?每個 head 的維度d_head變多少? - 在
scaled_dot_product_attention裡,把/ np.sqrt(d_k)拿掉,權重分佈會變更尖還是更平?
🧠 想清楚 4. 用自己的話解釋 Q、K、V 各代表什麼。為什麼「Q·Kᵀ」算的是「相關度」?5. 因果遮罩為什麼是「GPT 一個字一個字往下生成」的關鍵?少了它會發生什麼事?
🏗️ 進階做 6. 不看 02_tiny_gpt.py,自己用 numpy 把「單頭因果注意力」從頭寫一次,和 code/01 的輸出比對。
✅ 參考解答
causal=False時右上角不再是 0,每個字都能看到所有字(雙向,像 BERT)。- 輸出形狀不變(還是
(T, d_model));d_head = d_model / n_heads,從 4 變成 2。 - 拿掉
/√d後分數數值變大,softmax 後更尖(更接近 one-hot),訓練也更不穩——這正是要除 √d 的原因。 - Q=「我在找什麼」、K=「我有什麼」、V=「我能提供的內容」。Q·Kᵀ 是點積,兩向量方向越接近、值越大 → 代表越「對盤」、越相關。
- 因果遮罩擋住未來,模型預測第 i 個字時只能用第 1..i 個字,這才符合「生成時還不知道未來」。少了它,模型訓練時能「偷看答案」(看到要預測的字),生成時就完全失準。
- 對照
code/01的scaled_dot_product_attention+ 下三角 mask 即可。
關卡 2 ・ 反向傳播(配 code/04_autograd_from_scratch.py、docs/08 第 2 節)
🔧 動手改
- 把
04的 MLP 結構從[4, 4, 1]改成[8, 1](只一層隱藏層),還學得會 XOR 嗎? - 把學習率
lr從 0.1 調到 1.0,loss 下降會更快還是震盪?
🧠 想清楚 3. Value.__mul__ 的 _backward 為什麼是「self.grad += other.data * out.grad」?(用乘法微分解釋)4. 為什麼 backward() 要先做「拓撲排序」再反向跑?如果順序錯了會怎樣?
🏗️ 進階做 5. 幫 Value 加一個 relu() 方法(forward:max(0, x);backward:x>0 時梯度為 1,否則 0),用它取代 tanh 再訓練一次。
✅ 參考解答
- 通常仍學得會(XOR 需要至少一層隱藏層做非線性切分);層數/寬度夠就行,可能要多幾個 epoch。
lr=1.0多半下降更快但開始震盪,太大會發散。這就是學習率的取捨。- 因為
out = self * other,∂out/∂self = other.data;鏈鎖律下,self 收到的梯度 =∂out/∂self × out.grad = other.data × out.grad。用+=是因為同一個節點可能被多條路徑用到,梯度要累加。 - 反向傳播要「先算完所有依賴我的節點的梯度,才能算我的」。拓撲排序保證「輸出在前、輸入在後」,反過來跑才正確。順序錯會用到還沒算好的
out.grad,梯度全錯。 - relu 的 backward:
self.grad += (out.data > 0) * out.grad。
關卡 3 ・ 迷你 GPT(配 code/02_tiny_gpt.py、填空版 code/02b_tiny_gpt_exercise.py)
🔧 動手改
- 先做
02b_tiny_gpt_exercise.py:補完三個 TODO(注意力、殘差、交叉熵),跑到 loss 會下降。 - 把
n_layer從 4 改成 1,再改成 8,比較最終 val loss 與訓練時間。 - 把
data/input.txt換成更大的文字(如公開領域莎士比亞),看生成品質變化。 - 生成時把
temperature從 0.8 改成 0.2 和 1.5,輸出會變保守還是發散?
🧠 想清楚 5. train loss 一直降但 val loss 開始上升,代表什麼?該怎麼辦?6. block_size 是什麼?把它調大會增加什麼成本?
🏗️ 進階做 7. 把字元級詞表換成 code/05_bpe_tokenizer.py 的 BPE token,比較困惑度與生成品質。
✅ 參考解答
- 解答見
code/02_tiny_gpt.py(完整版)。 n_layer=1通常 loss 較高(容量不足);n_layer=8loss 更低但更慢、且小資料容易過擬合。- 資料越大、越乾淨,生成越通順——這就是「縮放定律」的直覺。
temperature低 → 保守、重複;高 → 多樣但容易胡言亂語。它在 softmax 前縮放 logits。- 過擬合:模型開始死背訓練資料。對策:更多資料、加 dropout/正則化、early stopping、縮小模型。
block_size= context window(一次能看幾個字)。調大讓模型看更長上下文,但注意力是 O(T²),記憶體與計算成本平方成長。- BPE 讓每個 token 含更多資訊,序列變短、困惑度通常更低;對中文尤其有感。
關卡 4 ・ RAG 與評估(配 code/08_mini_rag.py、code/09_eval_basics.py、docs/08 第 4 節)
🔧 動手改
- 把你自己的筆記貼進
08_mini_rag.py的KNOWLEDGE_BASE,問幾個問題,看檢索準不準。 - 把
retrieve(..., k=2)改成k=1和k=4,回答的涵蓋度與雜訊怎麼變? - 在
09_eval_basics.py把y_pred全改成 0(全猜「正常」),看 accuracy 仍高、但 recall 變多少。
🧠 想清楚 4. RAG 的四步是哪四步?真實 RAG 會把其中哪兩步換成神經網路/LLM?5. 100 封信只有 1 封垃圾,「全猜正常」accuracy 99%,為什麼這是爛模型?該看哪個指標?6. 困惑度(perplexity)和交叉熵 loss 是什麼關係?困惑度 = 1 代表什麼?
🏗️ 進階做 7. 把 08_mini_rag.py 的 TF-IDF 換成「字 bigram 向量」或接一個真的句向量模型,比較檢索品質。
✅ 參考解答
1./2. k 越大涵蓋越廣但越多不相關雜訊;k 小則精準但可能漏。3. 全猜 0:accuracy 仍可能高(資料不平衡時),但 recall = 0(一個垃圾都沒抓到)→ 模型其實沒用。4. 切塊 → 向量化(embed) → 檢索(retrieve) → 生成(generate)。真實 RAG 把「向量化」換成神經嵌入模型、把「生成」換成 LLM。5. 因為它對「真正重要的少數類別(垃圾)」完全無能(recall=0)。要看 precision / recall / F1,而非只看 accuracy。6. 困惑度 = exp(交叉熵)。困惑度 = 1 代表模型對下一個字完全確定(機率 1,loss 0)。
關卡 5 ・ 生成模型與世界模型(配 code/11_mini_diffusion.py、code/03、code/06、docs/07)
🔧 動手改
- 把
11_mini_diffusion.py的資料sample_data改成「兩個點群」或「正方形」,看模型能不能學會新形狀。 - 把
T(擴散步數)從 40 改成 10 和 100,生成品質(平均半徑、標準差)怎麼變?
🧠 想清楚 3. Diffusion 的「前向過程」和「反向過程」各在做什麼?哪一個需要「學」?4. 為什麼說 LLM、Diffusion、世界模型是「同一套引擎」?它們各自「預測什麼」?
🏗️ 進階做 5. 把 code/03_world_model_planning.py 的線性世界模型換成一個小神經網路(可借 code/04 的引擎),看規劃是否更準。
✅ 參考解答
- 改
sample_data回傳新形狀即可;只要資料分佈合理,同一套去噪網路就能學會(可能要多訓練幾步)。 T太小(如 10)生成較粗糙、半徑標準差較大;T大(100)通常更貼合但更慢。- 前向:對乾淨資料一步步加雜訊(固定、不用學)。反向:學一個網路把雜訊一點點去掉(要學)。
- 都是「神經網路 + 大量資料 + 一個預測目標,用梯度下降訓練」。LLM 預測下一個字、Diffusion 預測「該去掉的雜訊」、世界模型預測下一個世界狀態。引擎相同,差在資料與預測目標。
- 把線性
next_state = A·state + B·action換成小 MLP,用收集到的轉移資料訓練即可。
關卡 6 ・ 電腦視覺、時間序列、表格 ML(配 code/12、code/13、code/14_tabular_ml.py、docs/07)
🔧 動手改
- 在
12_cnn_vision.py把demo_convolution的數字換成別張(digits.images[7]),看邊緣偵測結果。 - 把 CNN 的
nn.Conv2d(1, 8, 3)的通道數 8 改成 4 和 16,測試準確率與參數量怎麼變? - 在
13_timeseries_forecast.py把 AR 的階數p從 10 改成 1 和 30,有沒有打贏基準線? - 把時間序列的季節週期
/ 30改成/ 7,AR 模型還抓得到規律嗎? 4b. 在14_tabular_ml.py把資料換成load_wine(多分類)或load_diabetes,哪個模型贏?基準線打得贏嗎?
🧠 想清楚 5. 卷積核(kernel)和注意力的權重,有什麼共通點、有什麼不同?(提示:都是加權求和,但一個固定一個依輸入。)6. 為什麼時間序列「絕對不能」隨機打亂後切 train/test?7. 為什麼做時間序列一定要先比「naive 基準線」?打不贏基準線代表什麼?
🏗️ 進階做 8. 把 13 的 AR 線性模型換成一個小神經網路(可借 code/04 引擎或用 PyTorch),比較 MAE。9. 用 12 的 CNN 架構,找一個你有興趣的小影像資料集(或自己畫幾類圖),訓練分類器。
✅ 參考解答
- 通道越多,能學的特徵越多、參數量越大、準確率通常略升,但小資料容易過擬合。
p=1太短抓不到週期(30),常打不贏;p夠長(≥週期)才抓得到季節規律。- 只要
p涵蓋得到新週期就抓得到;若p比週期短就抓不到。 - 共通:都是「一塊區域 × 一組權重」的加權求和。不同:卷積核是固定的、共享的(平移不變);注意力權重是依輸入內容當場算的(資料相依)。
- 因為會「用未來預測過去」——測試集若混進訓練,等於偷看答案,評估會嚴重高估,上線就破功。
- 因為很多序列「明天≈今天」已經很準;花俏模型若連這都打不贏,就是沒學到任何有用的東西、白做。
怎麼用這份
- 跟著
docs/00的站別走,每讀完一段、跑完對應程式,就回來做該關卡的題。 - 先自己做,再看答案。做不出來不是壞事——那正是你「其實還沒懂」的地方,補起來才值錢。
- 「🏗️ 進階做」做完的成品,丟上你的 GitHub——那就是路線圖說的「作品集」。
🛠 跟著做
共 2 支 · 在本機 venv 跑 · 還沒裝環境?
numpy📄 原始碼
"""
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("一句話:會訓練不稀奇,能說清楚『好在哪、爛在哪、用哪個指標衡量』才是專業。")
numpy📄 原始碼
"""
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()