--- title: 'L1 正則化(Lasso Regression)' disqus: hackmd --- L1 正則化(Lasso Regression) === ## Table of Contents [TOC] Lasso Regression --- ### 從熟悉的 OLS 開始 從我們已經熟悉的 OLS 開始,在線性迴歸中,我們的模型是: $$ \hat{y} = XW $$ 在前面章節已經定義過 **MSE(Mean Squared Error)** 作為 Loss: $$ \mathrm{SSE} = \sum_{i=1}^{n} (y_i - \hat{y}_i)^2 $$ $$ \mathrm{MSE} = \frac{1}{n}\mathrm{SSE} $$ OLS 做的事情其實很單純: > **找一組權重 (W),讓 MSE 最小。** ### 「只看 MSE」會出問題? OLS 在**變數很多、而且高度相關**時,係數的變異會非常大。 :::info 更詳細請看後面章節的「迴歸假設與缺點」 ::: 當資料出現以下情況時: * 特徵很多 * 特徵之間高度相關 * 有些特徵其實沒什麼用 只最小化 MSE 會發生什麼? * 模型會「硬用」所有特徵 * 某些權重會變得很大(當特徵彼此高度相關,怎麼分都差不多,**模型一邊把某個權重拉大一邊用另一個權重抵銷**,稱為權重補償(weight compensation)) * 模型容易記住雜訊(overfitting) 所以我們開始意識到一件事: **MSE 小不代表模型好,權重如果太複雜,模型其實不穩定。** :::info OLS 的「不穩定」,不是指預測一定很差,而是指**模型對資料的微小變動過度敏感** 1. 不穩定的第一個來源:高度相關的特徵(共線性) 假設有兩個特徵: $$ x_2 \approx x_1 $$ 那模型其實在學的是: $$ y \approx w_1 x_1 + w_2 x_2 $$ 但因為 $(x_1, x_2)$ 幾乎一樣,以下兩組解: * $(w_1 = 10,; w_2 = -8)$ * $(w_1 = 2,; w_2 = 0)$ 對 MSE 來說可能幾乎一樣好,但只要資料稍微變一點點: * 某次抽樣 $(x_1)$ 稍強 * 下次抽樣 $(x_2)$ 稍強 OLS 就會把責任改丟給另一個特徵,結果造成: * 權重數值變很大、正負號亂跳 * 解釋性完全崩壞、泛化能力差 2. 不穩定的第二個來源:特徵很多,但資料有限 當特徵數接近樣本數,甚至超過時,每個權重都是一個「自由度」,OLS 幾乎可以「完美貼合訓練資料」,但貼合的其實是**雜訊 + 偶然性** 這時候會發生: * 訓練 MSE 很小 * 驗證 / 新資料誤差暴增(overfitting) ::: ### 限制「權重總量」 為了降低這個問題,Lasso 使用 $$ |W|_1 = \sum_j |W_j| $$ 這代表我們在意的是**所有權重「絕對值的總和」。** 於是,Lasso 的 **constrained 形式** 就出現了: $$ \min_W \mathrm{MSE}(W) \quad \text{s.t.} \quad |W|_1 \le C $$ 這一公式可以用人話翻譯成:「在 MSE 表現不錯的模型裡,我只允許那些『用少量、簡單權重』的模型。」 :::success **一般情況下,Lasso / Ridge (L1/L2)的「懲罰權重不包含截距(intercept)」** 也就是說正則化 **只作用在斜率係數 ($w_1, w_2, \dots$),不懲罰截距 ($b$)**,因為截距只是「整體平移」,不是模型複雜度 ::: ### 引入拉格朗日函數 上面的 constrained 問題在概念上很清楚,但在實作上不好直接解,但數學給了我們一個等價的做法: > **把「不能超過限制」改成「超過就付出代價」。** 這時就引入 **拉格朗日乘數 $(\lambda \ge 0)$**。 :::info #### 拉格朗日函數的正式定義 >對一個有 ${\displaystyle n}$ 個變數與 ${\displaystyle k}$ 個限制條件的最佳化問題,拉格朗日乘數法會將其轉換成一個 ${\displaystyle n+k}$ 個變數的方程式組,稱作拉格朗日方程式(英語:Lagrange function),這個方程組的解將包括所有最佳化問題的解。拉格朗日乘數法將會引入一個或一組新的未知數,即拉格朗日乘數(英語:Lagrange multiplier),又稱拉格朗日乘子,或拉氏乘子,它們是在轉換後的方程式,即限制方程式中作為梯度的線性組合中各個向量的係數。 >[維基百科](https://zh.wikipedia.org/zh-tw/%E6%8B%89%E6%A0%BC%E6%9C%97%E6%97%A5%E4%B9%98%E6%95%B0) ::: 原始約束條件可以寫成: $$ g(W) = |W|_1 - C \le 0 $$ 拉格朗日函數則定義為: $$ \mathcal{Loss}(W,\lambda)= \mathrm{MSE}(W) + \lambda \big(|W|_1 - C\big) $$ 也可以直接 $$ \mathcal{Loss}(W,\lambda)= \mathrm{MSE}(W) + \lambda \big|W|_1 $$ :::success 在最佳化時,我們關心的是哪一個 $W$ 讓目標最小 而 $(-\lambda C)$: * 不含 $W$ * 是一個常數 所以它**不影響最小化的位置**,可以直接忽略。 ::: 意思是MSE 本來就要小,如果 $|W|_1$太大,就用$\lambda$來懲罰這件事 ### Lasso 的 Loss 函數 這就是 Lasso 的 Loss 函數(承接MSE) $$ \mathcal{Loss}(W,\lambda)= \mathrm{MSE}(W) + \lambda \big|W|_1 $$ 這個 Loss 讓**Lasso 在最小化 MSE 的同時,額外懲罰「權重總量過大」的模型。** 而且: * $\lambda$ 越大 → 懲罰越重 → 更多 $W_j = 0$ * $\lambda \to 0$ → Lasso 退化成 OLS :::success #### 注意 Lasso 沒有讓問題變好,它只是讓模型「不要對問題那麼敏感」,它做的是「選擇」(特徵權重可變0),不是「修復」。 ::: numpy程式碼範例(constrained) --- :::success #### 注意:在colab cell執行下面程式碼,下載Latex所需資源,才能在matplotlib的圖裡使用Latex ```python= !apt update !apt install -y cm-super dvipng texlive-latex-extra texlive-fonts-recommended ``` 如果是在window本機 * 安裝 MiKTeX(建議完整安裝) * 安裝 Ghostscript(必要) * 安裝完成後,將 latex, dvipng 所在路徑加入環境變數 PATH ::: ```python= import numpy as np import matplotlib.pyplot as plt import matplotlib as mpl # 啟用 LaTeX 渲染 mpl.rcParams["text.usetex"] = True mpl.rcParams["text.latex.preamble"] = r"\usepackage{amsmath}" np.random.seed(42) n = 80 X = np.random.randn(n, 2) # Add multicollinearity X[:, 1] = 0.85 * X[:, 0] + 0.35 * X[:, 1] W_true = np.array([2.0, -1.0]) y = X @ W_true + 0.8 * np.random.randn(n) ``` 造一個回歸資料:$(y = XW_{true} + noise)$,刻意讓兩個特徵 **有相關性**(模擬共線性),這樣會更能理解「為什麼需要正則化」 ```python= w1 = np.linspace(-5, 5, 401) w2 = np.linspace(-5, 5, 401) W1, W2 = np.meshgrid(w1, w2) pred = X[:, 0][:, None, None] * W1[None, :, :] \ + X[:, 1][:, None, None] * W2[None, :, :] resid = y[:, None, None] - pred SSE_grid = np.sum(resid**2, axis=0) MSE_grid = SSE_grid / n # Constrained Lasso # min MSE(W) s.t. ||W||1 <= C C = 2.5 feasible = (np.abs(W1) + np.abs(W2)) <= C MSE_feasible = np.where(feasible, MSE_grid, np.inf) idx = np.unravel_index(np.argmin(MSE_feasible), MSE_feasible.shape) W_lasso = np.array([W1[idx], W2[idx]]) # OLS solution W_ols = np.linalg.lstsq(X, y, rcond=None)[0] def mse(X, y, W): r = y - X @ W return float((r @ r) / len(y)) mse_ols = mse(X, y, W_ols) mse_lasso = mse(X, y, W_lasso) ``` 定義模型與 Loss,這一步最重要的觀念是,目前不是用梯度下降去訓練(那會牽涉到 L1 不可微),而是在 $(w_1,w_2)$ 的平面上掃過所有可能(constrained),**找出在菱形約束內 MSE 最小的那個點**,並算出OLS答案做比較 ```python= fig, axs = plt.subplots(1, 2, figsize=(15, 7)) # ===== Left: geometry ===== ax = axs[0] # MSE contours (all equal status) levels = np.percentile(MSE_grid, [10, 20, 35, 50, 70]) ax.contour(W1, W2, MSE_grid, levels=levels, colors="gray", linewidths=1.2) # L1 feasible region diamond diamond = np.array([ [ C, 0], [ 0, C], [-C, 0], [ 0,-C], [ C, 0] ]) # IMPORTANT FIX: # - Do NOT use Unicode "≤" in labels if LaTeX is enabled. # - Use LaTeX command \le in math mode. ax.plot(diamond[:, 0], diamond[:, 1], color="tab:blue", linewidth=2.8, label=r"Feasible set: $|w_1|+|w_2|\le C$") ax.fill(diamond[:-1, 0], diamond[:-1, 1], color="tab:blue", alpha=0.18) # Solutions ax.scatter(W_ols[0], W_ols[1], s=90, color="tab:orange", zorder=5, label="OLS solution") ax.scatter(W_lasso[0], W_lasso[1], s=140, marker="X", color="tab:green", zorder=6, label="Lasso constrained solution") # Annotation style: white box to avoid overlap bbox_kw = dict(boxstyle="round,pad=0.25", fc="white", ec="none", alpha=0.9) arrow_kw = dict(arrowstyle="->", lw=1.5) ax.annotate(rf"OLS" "\n" rf"$W=({W_ols[0]:.2f},{W_ols[1]:.2f})$", xy=(W_ols[0], W_ols[1]), xytext=(W_ols[0]+0.6, W_ols[1]-1.0), arrowprops=arrow_kw, bbox=bbox_kw) ax.annotate(rf"Lasso" "\n" rf"$W=({W_lasso[0]:.2f},{W_lasso[1]:.2f})$", xy=(W_lasso[0], W_lasso[1]), xytext=(W_lasso[0]-3.0, W_lasso[1]+1.2), arrowprops=arrow_kw, bbox=bbox_kw) # Axes ax.set_title("Constrained Lasso (strict geometry)") ax.set_xlabel("w1") ax.set_ylabel("w2", rotation=0, labelpad=15) ax.axhline(0, lw=1) ax.axvline(0, lw=1) ax.set_aspect("equal", adjustable="box") ax.grid(True, alpha=0.25) ax.legend(loc="upper right") # ===== Right: mathematical explanation ===== axs[1].axis("off") text = ( r"$\mathbf{Constrained\ Lasso}$" "\n\n" r"$\min_{W}\ \mathrm{MSE}(W)$" "\n" r"$\mathrm{s.t.}\ \|W\|_1 \le C$" "\n\n" r"$\mathbf{Definitions}$" "\n" r"$W=(w_1,w_2)$" "\n" r"$\|W\|_1 = |w_1| + |w_2|$" "\n" r"$\mathrm{MSE}(W)=\frac{1}{n}\sum_{i=1}^{n}(y_i-\hat y_i)^2$" "\n" r"$\hat y = XW$" "\n\n" r"$\mathbf{Interpretation}$" "\n" r"Contours represent equal MSE levels." "\n" r"The solution is the feasible point" "\n" r"that minimizes MSE." "\n\n" r"$\mathbf{Results}$" "\n" rf"$C={C:.2f}$" "\n" rf"$W_{{OLS}}=({W_ols[0]:.2f},{W_ols[1]:.2f})$" "\n" rf"$W_{{Lasso}}=({W_lasso[0]:.2f},{W_lasso[1]:.2f})$" "\n" rf"$\mathrm{{MSE}}(W_{{OLS}})={mse_ols:.3f}$" "\n" rf"$\mathrm{{MSE}}(W_{{Lasso}})={mse_lasso:.3f}$" ) axs[1].text(0.04, 0.96, text, fontsize=16, va="top", linespacing=1.5) # tight_layout should now work plt.tight_layout() plt.show() ``` ![lasso](https://hackmd.io/_uploads/SyWES_crbx.png) 在圖上看到: 1. **MSE 等高線**(等誤差線,一圈圈灰色線):代表「同樣 MSE 的一圈」 2. **菱形(藍色,L1 約束區域)**:$(|w_1|+|w_2|\le C)$ 3. **OLS 點**:不管係數大小,只管 MSE 最小 4. **Lasso constrained 點**:在菱形內 MSE 最小 numpy程式碼範例(Lasso vs OLS) --- 如果針對問題去做詳細比較,我們創造一個共線性更高的特徵(用數學保證相關係數大約是rho,但再加一點獨立雜訊)並採取多次採樣,分別用OLS、Lasso去多次訓練 ```python= def make_collinear_data(n=80, rho=0.98, seed=None): """X has 2 features, highly correlated when rho is close to 1.""" rng = np.random.default_rng(seed) x1 = rng.normal(0, 1, size=n) eps = rng.normal(0, 1, size=n) x2 = rho * x1 + np.sqrt(max(1 - rho**2, 1e-12)) * eps X = np.column_stack([x1, x2]) return X ``` <details> <summary>產生共線性資料</summary> 為什麼要用 `sqrt(1 - rho^2)` 這個奇怪係數,因為它讓 (x2) 的變異數維持在 1(跟 x1 同尺度),而且讓**理論相關係數剛好是 rho**。 假設: * $x_1 \sim N(0,1)$ * $\varepsilon \sim N(0,1)$ * 而且 $x_1$ 跟 $\varepsilon$ 互相獨立 定義: $$ x_2 = \rho x_1 + \sqrt{1-\rho^2}\varepsilon $$ #### 先看變異數為什麼會是 1 因為獨立的話,變異數會相加: $$ Var(x_2) = Var(\rho x_1) + Var(\sqrt{1-\rho^2}\varepsilon) $$ $$ = \rho^2 Var(x_1) + (1-\rho^2)Var(\varepsilon) $$ 因為 $Var(x_1)=1$、$Var(\varepsilon)=1$: $$ Var(x_2) = \rho^2 + (1-\rho^2) = 1 $$ 所以不管 rho 是多少,x2 都會保持「標準差約 1」的尺度。 #### 再看相關係數為什麼是 rho 相關係數要看協方差: $$ Cov(x_1, x_2) = Cov(x_1, \rho x_1 + \sqrt{1-\rho^2}\varepsilon) $$ 拆開: $$ = \rho Cov(x_1,x_1) + \sqrt{1-\rho^2}Cov(x_1,\varepsilon) $$ 因為 $Cov(x_1,x_1)=Var(x_1)=1$,而 $x_1$ 跟 $\varepsilon$ 獨立所以 $Cov(x_1,\varepsilon)=0$: $$ Cov(x_1,x_2)=\rho $$ 相關係數: $$ Corr(x_1,x_2)=\frac{Cov(x_1,x_2)}{\sqrt{Var(x_1)Var(x_2)}}=\frac{\rho}{\sqrt{1\cdot 1}}=\rho $$ 所以理論上它就是 rho。 </details> ```python= import numpy as np import matplotlib.pyplot as plt def make_collinear_data(n=80, rho=0.98, seed=None): """X has 2 features, highly correlated when rho is close to 1.""" rng = np.random.default_rng(seed) x1 = rng.normal(0, 1, size=n) eps = rng.normal(0, 1, size=n) x2 = rho * x1 + np.sqrt(max(1 - rho**2, 1e-12)) * eps X = np.column_stack([x1, x2]) return X def make_y(X, W_true, noise_y=1.0, seed=None): rng = np.random.default_rng(seed) y = X @ W_true + noise_y * rng.normal(0, 1, size=len(X)) return y def mse(X, y, W): r = y - X @ W return float((r @ r) / len(y)) def fit_ols(X, y): return np.linalg.lstsq(X, y, rcond=None)[0] def soft_threshold(z, gamma): # S(z, gamma) = sign(z) * max(|z| - gamma, 0) if z > gamma: return z - gamma if z < -gamma: return z + gamma return 0.0 def fit_lasso_cd(X, y, lam=0.15, iters=200, tol=1e-7): """ Lasso (penalized) using coordinate descent (pure NumPy). Objective: J(W) = (1/n) * ||y - XW||^2 + lam * ||W||_1 No intercept here (keep it simple for 2D geometry & stability demo). """ n, p = X.shape W = np.zeros(p, dtype=float) # Precompute for speed X_sq = np.sum(X**2, axis=0) for _ in range(iters): W_old = W.copy() # update each coordinate for j in range(p): # partial residual: y - sum_{k != j} x_k w_k r = y - X @ W + X[:, j] * W[j] rho_j = (X[:, j] @ r) / n # coordinate descent closed-form with soft-threshold # NOTE: because we use (1/n)||...||^2, the threshold uses lam/2 # Derivation yields: # w_j = S(rho_j, lam/2) / ( (1/n) * sum x_ij^2 ) denom = (X_sq[j] / n) W[j] = soft_threshold(rho_j, lam / 2.0) / (denom + 1e-12) if np.max(np.abs(W - W_old)) < tol: break return W # Step 1) 固定「同一套生成規則」 W_true = np.array([2.0, -1.0]) rho = 0.98 # 高共線 n = 60 B = 200 noise_y = 1.0 # Lasso lambda(你可以調大→更稀疏、更穩;調小→更接近 OLS) lam = 0.20 # Step 2) 同一批重抽樣資料,收集 OLS / Lasso 的 W Ws_ols = [] Ws_lasso = [] mse_ols_list = [] mse_lasso_list = [] for b in range(B): # "同樣資料" 的意思:同一個 b 用同一個 X、同一個 y,同時丟給 OLS 與 Lasso X = make_collinear_data(n=n, rho=rho, seed=1000 + b) y = make_y(X, W_true, noise_y=noise_y, seed=2000 + b) W_ols = fit_ols(X, y) W_lasso = fit_lasso_cd(X, y, lam=lam, iters=400) Ws_ols.append(W_ols) Ws_lasso.append(W_lasso) mse_ols_list.append(mse(X, y, W_ols)) mse_lasso_list.append(mse(X, y, W_lasso)) Ws_ols = np.array(Ws_ols) Ws_lasso = np.array(Ws_lasso) mse_ols_list = np.array(mse_ols_list) mse_lasso_list = np.array(mse_lasso_list) # stats def stats(Ws, mses): mu = Ws.mean(axis=0) sd = Ws.std(axis=0) return mu, sd, mses.mean(), mses.std() mu_o, sd_o, mse_mu_o, mse_sd_o = stats(Ws_ols, mse_ols_list) mu_l, sd_l, mse_mu_l, mse_sd_l = stats(Ws_lasso, mse_lasso_list) # Step 3) 畫圖:左邊係數散佈、右邊文字總結(同你原本風格) fig, axs = plt.subplots(1, 2, figsize=(15, 7)) # --- Left: scatter in (w1, w2) space --- ax = axs[0] ax.set_title("Coefficient instability comparison (same resamples)") ax.set_xlabel("w1") ax.set_ylabel("w2", rotation=0, labelpad=15) ax.axhline(0, lw=1) ax.axvline(0, lw=1) ax.grid(True, alpha=0.25) ax.scatter(Ws_ols[:, 0], Ws_ols[:, 1], s=25, alpha=0.35, label="OLS (many resamples)") ax.scatter(Ws_lasso[:, 0], Ws_lasso[:, 1], s=25, alpha=0.35, label="Lasso (many resamples)") ax.scatter(W_true[0], W_true[1], s=180, marker="X", label="True W") # annotate typical std ax.text(0.07, 0.02, "Each dot = one resample\nSame X,y used for OLS and Lasso", transform=ax.transAxes, fontsize=10, bbox=dict(boxstyle="round", alpha=0.15)) ax.legend(loc="upper right") # --- Right: explanation --- axs[1].axis("off") text = ( r"$\mathbf{Same\ data,\ compare\ stability}$" "\n\n" rf"Data: n={n}, B={B}, rho={rho:.2f}, noise={noise_y:.1f}" "\n" rf"True: $W^{{true}}=({W_true[0]:.1f},{W_true[1]:.1f})$" "\n\n" r"$\mathbf{Models}$" "\n" r"OLS: minimize MSE only" "\n" r"Lasso (penalized): minimize MSE(W) + $\lambda \|W\|_1$" "\n" rf"Here: $\lambda={lam:.2f}$" "\n\n" r"$\mathbf{Why\ OLS\ unstable\ under\ collinearity?}$" "\n" r"When x1 and x2 are very similar, many (w1,w2) pairs" "\n" r"give almost the same predictions, so small resample/noise changes" "\n" r"can shift OLS coefficients a lot." "\n\n" r"$\mathbf{Stability\ summary\ (mean / std)}$" "\n" rf"OLS mean(W)=({mu_o[0]:.2f},{mu_o[1]:.2f}), std(W)=({sd_o[0]:.2f},{sd_o[1]:.2f})" "\n" rf"Lasso mean(W)=({mu_l[0]:.2f},{mu_l[1]:.2f}), std(W)=({sd_l[0]:.2f},{sd_l[1]:.2f})" "\n\n" r"$\mathbf{Training\ MSE\ (mean / std)}$" "\n" rf"OLS MSE={mse_mu_o:.3f} ± {mse_sd_o:.3f}" "\n" rf"Lasso MSE={mse_mu_l:.3f} ± {mse_sd_l:.3f}" "\n\n" r"$\mathbf{Key\ point}$" "\n" r"Lasso shrinks $|W|$ and tends to make coefficients smaller / sparser," "\n" r"so W varies less across resamples (more stable)." ) axs[1].text(0.04, 0.96, text, fontsize=14, va="top", linespacing=1.35) plt.tight_layout() plt.show() ``` ![LASSO_vs_OLS](https://hackmd.io/_uploads/ryl_xcRN-l.png) 從圖中可以看到相比OLS,Lasso在多次採樣下比較集中、穩定。 numpy程式碼範例(ISTA,Proximal Gradient Method) --- :::info ISTA = 用「兩個小步驟」來解 > 「一半可微、一半不可微」的最小化問題** 正式名稱: > **ISTA = Iterative Shrinkage-Thresholding Algorithm** ::: 現在做的是 **Penalized Lasso**,,實務上幾乎都直接用 penalized form因為 λ 比 C 好調。 $$ \min_W; Loss(W)=\mathrm{MSE}(W)+\lambda |W|_1 $$ 其中 $$ \mathrm{MSE}(W)=\frac{1}{n}|y-XW|^2 $$ 平滑、可微 $$ |W|_1=\sum_j |w_j| $$ 在 0 不可微(尖點) :::success #### 「不可微」到底卡在哪? 以一個係數 $w$ 來看: * $|w|$ 在 $w>0$ 梯度是 +1 * 在 $w<0$ 梯度是 -1 * 但在 $w=0$,左右斜率不一樣 → 沒有單一梯度 所以 **普通 GD/SGD** 沒辦法「正確且穩定」地在 0 附近處理它 (你會看到係數在 0 附近抖動、不容易精準變成 0)。 ::: **Lasso 的 Loss 有一半是 MSE(可微),一半是 L1(不可微),所以ISTA的做法是,先對 MSE 做一次普通梯度下降,在每一步,先沿著可微部分往下走一小步,再立刻用不可微部分『修正這一步』。** ISTA 每一步都做兩件事: #### (1) 先做一次「只管 MSE」的梯度下降 $$ W_{\text{tmp}} = W_k - \eta \nabla MSE(W_k) $$ 直覺: 先沿著「讓誤差變小」的方向走一步。 #### (2) 再做一次「L1 專用的收縮」 $$ W_{k+1} = \mathrm{prox}_{\eta g}(W_{\text{tmp}}) $$ 這句 prox 是啥?它的定義是: $$ \mathrm{prox}_{\eta g}(z)=\arg\min_w \left(\frac{1}{2}(w-z)^2+\eta g(w)\right) $$ :::success argmin 是什麼? $\min_x f(x)$意思是:「這個函數的最小值是多少?」」 $\arg\min_x f(x)$意思是:「**在哪個 x** 取得這個最小值?」 例子 $$ f(x) = (x-3)^2 $$ * $\min f(x) = 0$(最小值是 0) * $\arg\min f(x) = 3$(在 x=3 時達到) **min = 值** **argmin = 位置(參數)** ::: 把 $g(w)=\lambda|w|$ 代入: $$ \mathrm{prox}_{\eta\lambda|\cdot|_1}(z)=\arg\min_w \left(\frac{1}{2}(w-z)^2+\eta\lambda|w|_1\right) $$ proximal step 解的是: $$ \arg\min_W \left( \underbrace{\frac{1}{2}(W - W_{\text{tmp}})^2}_{\text{留在 MSE 的好方向附近}} + \underbrace{\eta\lambda |W|_1}_{\text{L1 拉回來}} \right) $$ 產生資料 ```python= import numpy as np np.random.seed(42) n = 80 X = np.random.randn(n, 2) # 做一點共線性:讓第二個特徵和第一個特徵相關 X[:, 1] = 0.85 * X[:, 0] + 0.35 * X[:, 1] W_true = np.array([2.0, -1.0]) y = X @ W_true + 0.8 * np.random.randn(n) print("X shape:", X.shape, "y shape:", y.shape) print("W_true:", W_true) ``` 定義模型與 MSE ```python= def y_hat(X, W): return X @ W def mse_loss(X, y, W): r = y - y_hat(X, W) return (r @ r) / len(y) def grad_mse(X, y, W): # MSE(W) = (1/n) ||y - XW||^2 # grad = (2/n) X^T (XW - y) n = len(y) return (2/n) * (X.T @ (X @ W - y)) ``` 實際訓練:ISTA(Proximal GD)解 Penalized Lasso soft-threshold(L1 的關鍵) ```python= def soft_threshold(z, t): return np.sign(z) * np.maximum(np.abs(z) - t, 0.0) ``` <details> <summary>soft-threshold推導</summary> 在 soft-threshold 那個問題裡,我們解的是: $$ \arg\min_w \left( \frac{1}{2}(w - z)^2 + t|w| \right) $$ * **變數是:** ( w ) * **不是:** (|w|) * $z$ = 梯度下降後的暫時位置 * $t = \eta\lambda$ = L1 在這一步的收縮強度 所以你不是在「解 $|w|$」,你是在找「哪個 **w(可以正、負、0)** 讓整個式子最小」,但是$|w|$ 在 **w = 0 不可微,所以在數學上你**不能一次微分處理,只能: > 「假設在右邊 → 解 → 檢查假設對不對」 > 「假設在左邊 → 解 → 檢查假設對不對」 #### Case by case 分析 #### 假設 (w > 0) 此時 ($|w| = w$) $$ f(w) = \frac{1}{2}(w - z)^2 + t w $$ 微分: $$ \frac{d}{dw} = (w - z) + t $$ 令為 0: $$ w = z - t $$ 但前提是: $(w > 0 \Rightarrow z - t > 0 \Rightarrow z > t)$ #### 假設 (w < 0) 此時 ($|w| = -w$) $$ f(w) = \frac{1}{2}(w - z)^2 - t w $$ 微分: $$ \frac{d}{dw} = (w - z) - t $$ 令為 0: $$ w = z + t $$ 但前提是: $(w < 0 \Rightarrow z + t < 0 \Rightarrow z < -t)$ #### 介於中間 如果: $$ -t \le z \le t $$ 那麼: * 上面兩個解都「不符合假設」 * 此時最小值會落在 **不可微點 (w=0)** 因為 L1 在 0 有尖角,會「卡住」 #### 把三種情況合起來 $$ w^* = \begin{cases} z - t, & z > t \\ 0, & |z| \le t \\ z + t, & z < -t \end{cases} $$ 這個東西就是**soft-threshold** #### 寫成 compact 形式(你看到的那行) $$ w^* = \mathrm{sign}(z)\max(|z|-t, 0) $$ </details> 訓練迴圈(每一步都在最小化 $MSE+\eta\lambda|W|$) ```python= def train_lasso_ista(X, y, lam=0.2, lr=0.1, steps=200): W = np.zeros(X.shape[1]) # 從 0 開始(常見做法) history = [] for k in range(steps): # (1) 先往讓 MSE 變小的方向走 W_tmp = W - lr * grad_mse(X, y, W) # (2) 再做 L1 的 proximal:soft-threshold W = soft_threshold(W_tmp, lr * lam) # 記錄 loss J = mse_loss(X, y, W) + lam * np.sum(np.abs(W)) history.append(J) return W, np.array(history) ``` 實際訓練 ```python= lam = 0.25 lr = 0.1 steps = 300 W_lasso, hist = train_lasso_ista(X, y, lam=lam, lr=lr, steps=steps) W_ols = np.linalg.lstsq(X, y, rcond=None)[0] print("lambda =", lam) print("W_lasso (ISTA) =", W_lasso, " | L1 =", np.abs(W_lasso).sum()) print("W_ols =", W_ols) print("Final J(W) =", hist[-1]) ``` 繪圖:把每個部分畫出來+直接寫在圖上解釋 ```python= import matplotlib.pyplot as plt # 在平面上算 MSE 等高線(用來視覺化地形) w1 = np.linspace(-5, 5, 401) w2 = np.linspace(-5, 5, 401) W1, W2 = np.meshgrid(w1, w2) pred = X[:, 0][:, None, None] * W1[None, :, :] + X[:, 1][:, None, None] * W2[None, :, :] resid = y[:, None, None] - pred SSE = np.sum(resid**2, axis=0) MSE = SSE / len(y) fig, ax = plt.subplots(figsize=(9, 7)) levels = np.percentile(MSE, [10, 20, 35, 50, 70]) ax.contour(W1, W2, MSE, levels=levels, colors="gray", linewidths=1.2) # 點:OLS vs Penalized Lasso(ISTA) ax.scatter([W_ols[0]], [W_ols[1]], s=70, marker="o", label="OLS (min MSE only)") ax.scatter([W_lasso[0]], [W_lasso[1]], s=120, marker="x", label="Lasso (penalized, ISTA)") # 在點旁邊寫解釋 bbox_kw = dict(boxstyle="round,pad=0.25", fc="white", ec="none", alpha=0.9) arrow_kw = dict(arrowstyle="->", lw=1.5) ax.annotate(f"OLS\nW=({W_ols[0]:.2f},{W_ols[1]:.2f})", xy=(W_ols[0], W_ols[1]), xytext=(W_ols[0]+0.6, W_ols[1]-1.0), arrowprops=arrow_kw, bbox=bbox_kw) ax.annotate(f"Lasso penalized\nW=({W_lasso[0]:.2f},{W_lasso[1]:.2f})\n|W|1={np.abs(W_lasso).sum():.2f}", xy=(W_lasso[0], W_lasso[1]), xytext=(W_lasso[0]-3.4, W_lasso[1]+1.2), arrowprops=arrow_kw, bbox=bbox_kw) ax.set_title("Penalized Lasso training (ISTA): shown on MSE contours") ax.set_xlabel("w1") ax.set_ylabel("w2") ax.axhline(0, linewidth=1) ax.axvline(0, linewidth=1) ax.set_aspect("equal", adjustable="box") ax.legend(loc="upper right") plt.tight_layout() plt.show() ``` ![lasso_ISTA](https://hackmd.io/_uploads/S1dS8uqrZe.png) 訓練曲線:看loss真的下降 ```python= plt.figure(figsize=(8, 5)) plt.plot(hist) plt.title("Training curve: Loss(W)=MSE(W)+lambda*|W|") plt.xlabel("step") plt.ylabel("Loss(W)") plt.tight_layout() plt.show() ``` ![lasso_loss](https://hackmd.io/_uploads/B1_zq41Bbx.png) scikit-learn程式碼範例 --- ```python= import numpy as np from sklearn.linear_model import Lasso np.random.seed(42) n = 80 X = np.random.randn(n, 2) # 做一點共線性:讓第二個特徵和第一個特徵相關 X[:, 1] = 0.85 * X[:, 0] + 0.35 * X[:, 1] W_true = np.array([2.0, -1.0]) y = X @ W_true + 0.8 * np.random.randn(n) print("X shape:", X.shape, "y shape:", y.shape) print("W_true:", W_true) # alpha 對應筆記裡的 lambda lasso = Lasso( alpha=0.25, # λ fit_intercept=False, max_iter=10_000 ) lasso.fit(X, y) W_lasso = lasso.coef_ print("Lasso coefficients:", W_lasso) print("||W|| =", np.abs(W_lasso).sum()) ```