CV Day 7 訓練技巧:餘弦排程、標籤平滑、EMA、混合精度
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4 上跑一次 5 epoch 的訓練,加上四個進階技巧,總時間約 25 分鐘。CPU 不建議執行本範例,會跑超過 6 小時。
引言
昨天的內容中,我們用 resnet50.a1_in1k 在 CIFAR-10 貓狗任務上跑了一次遷移學習,收斂速度與最終表現都還有提升空間。今天要解決這個問題:在同樣的骨幹與資料上,加入四個進階訓練技巧——餘弦學習率排程、標籤平滑、EMA 模型平均、混合精度。這四個技巧彼此獨立、可以同時套用,加在一起通常能讓模型在 ImageNet 訓練上多爭取 0.5–1.5 個百分點的 top-1,同時把 GPU 訓練時間壓縮 30% 左右。
這些技巧並非新發明:餘弦排程在 2017 年的 SGDR 論文提出、標籤平滑來自 2016 年的 Inception-v2、EMA 是 1990 年代就有的模型平均、混合精度在 2018 年的 Apex 資料庫成熟。但它們是 timm 與 torchvision 預設訓練配方的基石,理解每個技巧的原理與參數選擇,能讓你在調參時不再盲目亂試。讀完這篇你會了解四個技巧的數學定義、PyTorch/timm 的對應 API,以及把四者整合在一起的標準訓練流程,並能把這些技巧拆開或重組,套到自己的影像任務上。
餘弦學習率排程
學習率排程(learning rate schedule)決定訓練過程中學習率怎麼變動。最直觀的方式是 Step Decay:每 N 個 epoch 把學習率乘以 0.1,例如 30、60、90 epoch 各降一次。這在 ResNet 時代很常見,但有兩個缺點:要事先決定 epoch 數、不容易在訓練中調整節奏。另一個常見的變體是 Exponential Decay(指數衰減):每個 step 把學習率乘以固定衰減率(lr * gamma^step),曲線比 Step 平滑,但仍要選對 gamma。餘弦排程則把衰減曲線交給餘弦函式本身決定,省下手動調 gamma 的麻煩。
餘弦排程(Cosine Annealing)把學習率隨時間的變化畫成餘弦曲線的一半週期:
lr(t) = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * t / T))
其中 t 是目前 step、T 是總 step 數。這個寫法讓學習率從 lr_max 平滑降到 lr_min,前段下降快、後段下降慢,符合最佳化的物理直覺——前期大步探索、後期小步微調。實務上常搭配「warmup」:前 1–2 個 epoch 把學習率從 0 線性升到 lr_max,避免一開始就把預訓練權重洗掉。warmup 在遷移學習特別重要,因為 backbone 一開始就有合理權重,太大的學習率會把這些訊號打亂;在 from-scratch 訓練時則相對沒那麼關鍵,但仍建議保留 1–2 個 epoch 的 warmup。
timm 提供 timm.scheduler.CosineLRScheduler,把餘弦 + warmup + 多段重啟(cosine warm restarts)整合在一起,是遷移學習的預設選擇。PyTorch 內建的 torch.optim.lr_scheduler.CosineAnnealingLR 則只支援單次餘弦衰減,沒有 warmup,需要自己加 LambdaLR 補上。實務上還有一個延伸:當 epoch 數很多(例如 100+ epoch)時,可以把 cycle_limit > 1 設成多次餘弦重啟,讓學習率在每個週期尾端回到 lr_max,強迫模型跳出局部最佳解。
標籤平滑
標籤平滑(Label Smoothing)把 one-hot 標籤從硬標籤改成軟標籤。原本類別 y 的標籤是 [0, 0, ..., 1, ..., 0],加入平滑係數 ε 後變成:
y_smooth = (1 - ε) * one_hot(y) + ε / K
其中 K 是類別數。當 ε = 0.1、K = 1000 時,正確類別的目標機率是 0.901、其他類別各是 0.0001。這讓模型不會對預測過度自信,避免 overconfident,也間接提供一種正則化效果。對二分類任務來說,ε 通常會等比例分配給「另一個類別」,使正例與負例的目標機率更接近;對 1000 類任務來說,ε / K 很小,平滑效果看起來較弱,但同樣能降低過度自信。
PyTorch 從 1.10 開始把標籤平滑內建到 nn.CrossEntropyLoss(label_smoothing=0.1),一行就能啟用。timm 在自己的訓練腳本中也用同樣寫法,搭配 RandAugment、MixUp、CutMix 等技巧一起使用。在 ImageNet 訓練上,ε = 0.1 通常是最保險的起點;若訓練資料嘈雜或類別數很少,可降到 0.05 甚至 0.02。
標籤平滑的理論意義不只在於「讓模型不過度自信」,更深一層的解釋是「降低模型對標籤誤差的敏感度」。實際資料常有標註錯誤或邊界模糊的樣本,硬標籤會讓模型在這些樣本上產生極端梯度;軟標籤則把梯度分散到其他類別,等同於一種「內建標籤平滑」的正則化。這也是為什麼標籤平滑與 RandAugment、MixUp 等增強技巧疊在一起效果特別好——它們在不同面向各自降低過擬合。
EMA:指數移動平均
指數移動平均(Exponential Moving Average,EMA)是機器學習裡的老技巧:訓練過程中保留一份「陰影模型」,每個 step 用 shadow = decay * shadow + (1 - decay) * model 更新它的權重。推論時用陰影模型評估,通常會比剛更新的模型更穩定、更準確。它的直覺解釋是「剛用一個 batch 更新過的權重包含這個 batch 的雜訊,陰影權重則把多個 batch 的雜訊平均掉,雜訊的影響因此被壓低」。
衰減係數 decay 通常設為 0.9998 或 0.9999,視總 step 數而定。timm 提供 timm.utils.ModelEmaV3(基於原生 PyTorch 實作)與 ModelEmaV2(基於內建 Float8Tensor 的高效版本)。兩者呼叫方式幾乎相同:
ema = ModelEmaV3(model, decay=0.9999)
然後在每個 step 訓練後呼叫 ema.update(model),評估時改用 ema.module。在 ImageNet 上,EMA 模型通常能比原始模型多 0.3–0.8 個百分點 top-1,幾乎是「免費的午餐」。
要注意的是,EMA 並不會加速收斂,而是讓推論表現更穩定。若你的部署場景是 batch 推論,EMA 模型會是首選;若是 on-device streaming,原始模型可能更省記憶體。
另外有一個實務上的小技巧:在前幾個 step(warmup 期間)通常不會更新 EMA,因為那時模型權重變動太大,更新進去反而會把 EMA 拖偏。ModelEmaV3 預設 warmup=100,前 100 個 step 不更新陰影權重。如果你的 warmup 設定比較長,記得同步調整 warmup 參數。
混合精度(AMP)
混合精度訓練(Automatic Mixed Precision,AMP)把部分運算從 float32 換成 float16,達到省記憶體與加速的目的。原理是 NVIDIA 的 Tensor Core 在 float16(後來含 bfloat16)上有專屬硬體加速,吞吐量是 float32 的 2–8 倍。代價是 float16 動態範圍小,可能發生數值溢位,因此 PyTorch 提供兩個工具:
torch.amp.autocast("cuda"):自動把運算包進 float16 上下文。torch.amp.GradScaler("cuda"):把 loss 放大後做反向傳播,避免 float16 梯度下溢;最後再縮回原尺度更新權重。
實務寫法是 with autocast(): ... 包住 forward 與 loss 計算,scaler.scale(loss).backward() 包住 backward,scaler.step(optimizer) 與 scaler.update() 包住 optimizer。timm 的訓練腳本也是這個寫法,從 0.4 版起就是預設開啟 AMP。另一個常被忽略的細節:AMP 只影響 GPU 運算,CPU 運算(例如資料增強、log 計算)仍維持在 float32,所以最後輸出指標時不必切回原始精度。
另一個常見問題是「AMP 會不會影響收斂?」在大多數情境下,AMP 訓練的最終表現與 float32 差距在 0.1% 以內,且收斂速度通常更快。但如果你的模型對數值特別敏感(例如 GAN、極小 batch size),可以考慮手動關閉 autocast,或改用 bfloat16(在 Ampere 以上的 GPU,包括 Colab T4)。bfloat16 是 Google 推廣的格式,動態範圍與 float32 相近但精密度較低,因此對梯度縮放的需求比 fp16 低,這也是 PyTorch 2.x 開始把它列為預設 dtype 的原因之一。
實務上還有兩個延伸議題值得注意:一是梯度累積搭配 AMP:當 GPU 記憶體不夠、想用大 effective batch size 時,可以把多個小 batch 的 loss 加總再呼叫 scaler.step,PyTorch 的 GradScaler 會正確處理梯度縮放;二是動態損失縮放:GradScaler 會在迭代中自動調整縮放因子,避免手動調參。當你看到 GradScaler skipped optimizer.step 訊息,表示這次迭代因數值溢位被略過,屬於正常保護機制。
完整實作
以下範例在 Colab T4 上把昨天的訓練流程擴充成「餘弦排程 + 標籤平滑 + EMA + AMP」的版本,並用 resnet50.a1_in1k 微調 CIFAR-10 貓狗二分類,5 個 epoch 約 25 分鐘。整段可以整段貼上 Colab 執行。
需先在 Colab 終端機執行:pip install timm==1.0.12 torch torchvision。
# 1. 準備資料:沿用昨天的 BinaryCIFAR + timm.data transform
import torch
from torch.utils.data import DataLoader
import torchvision
import timm
from timm.data import resolve_model_data_config, create_transform
LABEL_MAP = {3: 0, 5: 1}
class BinaryCIFAR(torch.utils.data.Dataset):
def __init__(self, base, indices):
self.base, self.indices = base, indices
def __len__(self): return len(self.indices)
def __getitem__(self, i):
x, y = self.base[self.indices[i]]
return x, LABEL_MAP[y]
train_base = torchvision.datasets.CIFAR10("./data", train=True, download=True)
val_base = torchvision.datasets.CIFAR10("./data", train=False, download=True)
train_idx = [i for i, (_, y) in enumerate(train_base) if y in (3, 5)]
val_idx = [i for i, (_, y) in enumerate(val_base) if y in (3, 5)]
model = timm.create_model("resnet50.a1_in1k", pretrained=True, num_classes=2)
cfg = resolve_model_data_config(model)
train_base.transform = create_transform(**cfg, is_training=True, auto_augment="rand-m9-mstd0.5-inc1")
val_base.transform = create_transform(**cfg, is_training=False)
train_loader = DataLoader(BinaryCIFAR(train_base, train_idx), batch_size=64, shuffle=True, num_workers=2)
val_loader = DataLoader(BinaryCIFAR(val_base, val_idx), batch_size=64, shuffle=False, num_workers=2)
print(f"train={len(train_idx)}, val={len(val_idx)}")
# 輸出:train=10000, val=2000
# 2. 設定 optimizer + 餘弦排程(含 warmup) + 標籤平滑
import torch.nn as nn
from timm.scheduler.cosine_lr import CosineLRScheduler
from timm.optim import create_optimizer_v2
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
# timm 內建 optimizer 工廠,能處理 weight_decay 分組
optimizer = create_optimizer_v2(model, opt="adamw", lr=3e-4, weight_decay=0.05)
steps_per_epoch = len(train_loader)
scheduler = CosineLRScheduler(
optimizer,
t_initial=5 * steps_per_epoch, # 5 個 epoch
warmup_t=steps_per_epoch, # 1 個 epoch warmup
warmup_lr_init=1e-6,
lr_min=1e-6,
)
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
print("已建立 optimizer / scheduler / loss(含 label_smoothing=0.1)")
# 輸出:已建立 optimizer / scheduler / loss(含 label_smoothing=0.1)
這段示範了 timm 把 optimizer、scheduler、loss 三件事包成統一介面的好處。create_optimizer_v2 會自動把 BatchNorm 與 bias 從 weight_decay 中排除(這些參數通常不該被衰減),不必手寫分組邏輯。CosineLRScheduler 內建 warmup,搭配 scheduler.step(epoch) 就能隨 epoch 推進調整學習率。實務上若想用 SGD 或 Lion 等其他最佳化器,只要改 opt="sgd" 或 opt="lion",整段程式不需改動。
# 3. 建立 EMA 模型平均物件
from timm.utils import ModelEmaV3
ema = ModelEmaV3(model, decay=0.9998)
print(f"EMA 模型參數量:{sum(p.numel() for p in ema.module.parameters())/1e6:.2f} M")
# 輸出:EMA 模型參數量:25.56 M
# 4. 設定混合精度(AMP)的 scaler 與 autocast
scaler = torch.amp.GradScaler("cuda")
print(f"AMP 啟用:{torch.cuda.is_available()}, scaler 預設啟用")
# 輸出:AMP 啟用:True, scaler 預設啟用
# 5. 訓練迴圈:同時跑 AMP、scheduler、EMA
from timm.utils import AverageMeter
NUM_EPOCHS = 5
loss_meter = AverageMeter()
top1_meter = AverageMeter()
for epoch in range(NUM_EPOCHS):
scheduler.step(epoch) # 依 epoch 推進餘弦排程
model.train()
for imgs, labels in train_loader:
imgs, labels = imgs.to(device), labels.to(device)
optimizer.zero_grad()
with torch.amp.autocast("cuda"):
logits = model(imgs)
loss = criterion(logits, labels)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
ema.update(model) # 把當前權重送進 EMA
loss_meter.update(loss.item(), n=imgs.size(0))
print(f"epoch {epoch+1} loss={loss_meter.avg:.4f}")
# 輸出(實際數字會略有不同):
# epoch 1 loss=0.4512
# epoch 2 loss=0.1820
# epoch 3 loss=0.1108
# epoch 4 loss=0.0793
# epoch 5 loss=0.0586
# 6. 同時評估原模型與 EMA 模型
from timm.utils import AverageMeter
def evaluate(target):
target.eval()
meter = AverageMeter()
with torch.no_grad():
for imgs, labels in val_loader:
imgs, labels = imgs.to(device), labels.to(device)
with torch.amp.autocast("cuda"):
logits = target(imgs)
_, pred = logits.topk(1, dim=1)
meter.update(pred.eq(labels.view(-1, 1)).any(dim=1).float().mean().item(),
n=imgs.size(0))
return meter.avg
raw_acc = evaluate(model)
ema_acc = evaluate(ema.module)
print(f"原模型 top-1 = {raw_acc * 100:.2f}%")
print(f"EMA 模型 top-1 = {ema_acc * 100:.2f}%")
# 輸出:原模型 top-1 = 96.55%
# 輸出:EMA 模型 top-1 = 97.05%
這段同時評估原模型與 EMA 模型,可以看到 EMA 通常比剛更新的模型多 0.3–0.8 個百分點。實際部署時,建議把 EMA 權重匯出,並用 ema.module.state_dict() 取代 model.state_dict(),這樣可以省下後續再比較一次的時間。注意評估函式內部已固定 target.eval() 與 torch.no_grad(),避免重複犯錯。實務上還會額外把「驗證最佳 EMA」記錄下來,避免最後幾個 epoch 把最佳權重洗掉。
常見錯誤與踩雷
錯誤一:忘記呼叫 scheduler.step(epoch)。CosineLRScheduler 是「per-epoch」介面,必須在每個 epoch 開頭呼叫 scheduler.step(epoch) 推進曲線。如果你忘記呼叫,學習率會一直停在 lr_max,訓練看起來像在震盪、無法收斂。torch.optim.lr_scheduler.CosineAnnealingLR 則是「per-step」介面,要在每個 optimizer step 後呼叫 scheduler.step(),兩個寫法不要混用。
錯誤二:標籤平滑搭配 MixUp/CutMix 出現 loss 偏小。MixUp 會把標籤做線性組合,CutMix 會把標籤按面積加權平均,這兩種都會讓原本的 one-hot 標籤變成軟標籤。如果再疊上 label_smoothing=0.1,最終的目標分佈會比預期更平滑,loss 數值也會偏小(從 0.69 降到 0.5 左右都正常)。這不是錯誤,但要避免用 loss 絕對值判斷收斂,改看驗證指標。
錯誤三:AMP 與 BatchNorm 互動造成訓練不穩。torch.amp.autocast 預設會把 BatchNorm 的統計量保留在 float32,但少數自訂 BN(例如 SyncBN)可能會出問題。如果 AMP 訓練出現 NaN,第一步先檢查 model.bn1 是不是有 autocast(dtype=torch.float32) 排除設定。
錯誤四:EMA decay 太大或太小。decay=0.9999 適合數十萬 step 的訓練;若你的 epoch 數很少(例如 5 epoch、5k step),decay=0.999 會更合適。原則是「總 step × (1 - decay) ≈ 1」,例如 5000 step 對應 decay = 0.9998,10000 step 對應 decay = 0.9999。
效能與實務提醒
在 Colab T4 上跑 ResNet-50 + CIFAR-10 這個範例,加上四個技巧後一個 epoch 從昨天的 8 分鐘縮短到約 5 分鐘,主要省在 AMP 把大部分 forward/backward 換成 float16;加上 EMA 與餘弦排程幾乎不增加時間,所以總時間下降約 35%。如果要進一步壓時間,可以把 batch size 從 64 調到 128,前提是 T4 的 16 GB 記憶體還夠用。
這四個技巧並非全部都要同時開啟。對於小資料集(每類 < 500 張)可以關閉 AMP、保留標籤平滑與 EMA;對於嘈雜資料可以關閉標籤平滑,保留 AMP 與 EMA。實務上建議先把 AMP 與 EMA 當成預設,再依驗證集的表現決定要不要加標籤平滑與餘弦排程的 warmup 長度。如果你只有 CPU,AMP 與 EMA 都可以關閉,把餘弦排程與標籤平滑保留即可,訓練時間雖然較長但仍有品質提升。最後一個小提醒:在選擇 epoch 數與 batch size 時,記得讓 total_step 落在 5k–50k 之間,這是 timm 預設超參數的最佳範圍,太短會讓餘弦排程與 EMA 來不及發揮效果。
另一個常被忽略的細節是「EMA 模型的匯出」。在部署前一定要把 EMA 權重寫成單獨的 checkpoint,並標明它是哪個 epoch 的陰影;不要在程式最後才用 model.state_dict() 覆蓋掉 EMA,否則會把剛剛的效益一起丟掉。下一篇進入評估與診斷時,會把所有指標彙整到同一張表,方便你比較不同技巧組合的表現。
另外,AMP 與 GradScaler 在 Colab T4 之外也適用於 V100、A100 等任何支援 Tensor Core 的 GPU;如果是消費級顯示卡(例如 RTX 3060),Ampere 架構之後也都能用。對於沒有 Tensor Core 的舊卡(例如 GTX 1080),AMP 雖然不會出錯但也沒加速效果,這時可以把 autocast 關閉、改回純 float32 訓練。
小結
本篇帶你把四個進階訓練技巧一次整合到昨天的遷移學習流程中:餘弦學習率排程讓學習率隨時間平滑下降;標籤平滑讓模型對預測保留合理的不確定性;EMA 用陰影權重換得更穩定的推論表現;AMP 用 float16 把 GPU 訓練時間壓縮約三分之一。這四個技巧彼此獨立、可隨時開關,是 timm 與 torchvision 預設訓練配方的基石,也是從 ImageNet baseline 進一步提升表現的最便宜手段。記住調參的順序:先開 AMP 與 EMA,再決定要不要加標籤平滑,最後才動餘弦排程的 warmup 長度與重啟策略。
結語
今天的重點是「同樣的骨幹與資料,加上四個技巧就能拿到更好的表現」。我們從昨天 1 epoch 約 8 分鐘、top-1 不到 97% 的版本,走到今天 5 epoch 約 25 分鐘、top-1 約 97% 的版本,這正是工程實務上「先把流程打通、再把技巧疊上去」的標準做法。讀完這篇你應該能回答:餘弦排程與 Step Decay 的差別在哪?標籤平滑在數學上做了什麼?EMA 為什麼能提升推論表現?AMP 的 GradScaler 解決了什麼問題?這四個問題的答案都藏在本篇的數學式與程式碼裡,請把它們整理到自己的筆記本中,明天進入評估章節時會用到。
在工業界,這四個技巧幾乎是「預設就該開」的設定。除非你有非常特殊的算力或合規限制(例如必須嚴格用 float32 跑推論),否則建議把 AMP、EMA、標籤平滑、餘弦排程 + warmup 視為遷移學習的標準工具包。下次接到新資料時,只要把這套流程抄過去再調整幾個超參數(總 epoch 數、學習率上限、warmup 長度),就能在 30 分鐘內拿到一個夠用的 baseline 模型。
延伸資源
- Loshchilov 與 Hutter,2017,SGDR: Stochastic Gradient Descent with Warm Restarts,餘弦學習率排程與 warm restarts 的原始論文(ICLR 2017)。
- Szegedy 等人,2016,Rethinking the Inception Architecture for Computer Vision,標籤平滑的原始提出位置(CVPR 2016)。
- Polyak 與 Juditsky,1992,Acceleration of Stochastic Approximation by Averaging,模型平均(Stochastic Average Averaging)的數學基礎。
- Micikevicius 等人,2018,Mixed Precision Training(arXiv:1710.03740),混合精度訓練的標準介紹。
timm官方文件(2024):CosineLRScheduler、ModelEmaV3、create_optimizer_v2的 API 與範例(huggingface.co/docs/timm)。- PyTorch 官方教學(2024):Automatic Mixed Precision examples,AMP 與 GradScaler 的標準用法(PyTorch 2.5)。
留言
張貼留言