iPAS AI 奈米級拆解程式題解說・中級科目三 115-01
Question 42 · PyTorch / torchvision

借一顆訓練好的 ResNet-50 來用:
鎖住權重、換掉 fc,就是「特徵萃取」

這段程式只有 8 行有字,卻把遷移學習最常見的做法完整寫了一遍:拿別人用上百萬張圖片訓練好的模型,把它學到的「看圖能力」整個鎖起來,只在最後面接一個新的小分類器來訓練。這頁會一行一行拆開:哪個是函式、哪個是類別、哪個是物件,requires_grad 這個屬性到底關掉了什麼,以及為什麼「先凍結、再換 fc」的順序不能反。

PyTorch 2.xtorchvision resnet50()parameters()requires_grad nn.Lineartorch.optim.Adam遷移學習零基礎可讀

讀完這頁,你會知道

  1. 三行 import 各拿到什麼;resnet50 為什麼是「函式」,而 nn.Linear、torch.optim.Adam 是「類別」
  2. model.parameters() 交出來的是什麼,for 迴圈怎麼把 161 個參數張量(共 25,557,032 個數字)一個個鎖住
  3. model.fc = nn.Linear(2048, 2) 是「換零件」:2048 從哪來、新零件為什麼可以訓練、順序寫反會怎樣
  4. 特徵萃取、部分微調、全面微調差在哪,以及零樣本學習、知識蒸餾為什麼不是答案
§0

題目

下圖為使用 ResNet 進行遷移學習(Transfer Learning)的 Python 程式片段。觀察程式中行(A)將所有既有權重的梯度計算關閉,這在遷移學習中屬於哪一種標準策略?
第 42、43 題共用的原始程式碼截圖:載入預訓練 ResNet-50,用 for 迴圈把所有參數的 requires_grad 設為 False(標示為行 A),再把 fc 換成 nn.Linear(2048, 2),最後用 Adam 只優化 fc 的參數,學習率 1e-4
原卷附圖(第 42、43 題共用這段程式;程式碼已在 §3 完整重打一份)

先憑直覺選一個,讀完再回來對照:

先抓題目的三個關鍵字

① 所有既有權重:指 resnet50(pretrained=True) 載進來、已經在 ImageNet 上訓練好的那 2,556 萬個數字。② 梯度計算關閉:就是 requires_grad = False。不算梯度,優化器就沒有依據可以改它,等於凍結。③ 標準策略:考的是遷移學習幾個名詞的定義。

再補一個題目沒明講、但程式寫了的線索:最後兩行換上新的 fc,而且只把 fc 交給優化器,所以整個模型只有最後那一層在學。

§1

先補 Python 基本功:讀懂這 8 行需要的 6 個觀念

寫過 Python 的可以跳到 §2。這節只講這段程式真的會用到的語法,例子全部取自題目本身。中間有兩支動畫:先用「借來的看圖工廠」看整段程式在做什麼,最後打開記憶體看物件真正的樣子;每支動畫前後都有文字說明。

① import觀念 1
拿工具箱
→
② 看圖工廠動畫 1
上鎖、換最後一站
→
③ 函式・物件・迴圈觀念 2~6
→
④ 記憶體實況動畫 2
8 行程式逐行

第 3 支動畫(真的訓練一步,誰的數字變了)放在 §4,和凍結控制台一起看。

觀念 1|三種 import 寫法:整包拿、拿子模組並取綽號、只拿一樣

Python 本身不會做深度學習,要把別人寫好的套件(package)載入才能用。套件裡再分成很多模組(module)。比喻:套件是一間五金行,模組是店裡的一排貨架,類別或函式是架上的工具。

寫法意思之後怎麼用
import torch把整個 PyTorch 套件載入,名字叫 torchtorch.optim.Adam(...)(第 10 行)
import torch.nn as nn走到 torch 裡的 nn 子模組,幫它取綽號 nnnn.Linear(...)(第 9 行),不用每次寫 torch.nn.Linear
import torchvision載入 torchvision 套件torchvision.models.resnet50(...)(第 5 行)
from torchvision.models import resnet50直接把 resnet50 這個工具拿到桌上直接寫 resnet50(...)(§6 的新式寫法)

中間的「.」讀成「的」:torchvision.models.resnet50 = 「torchvision 的 models 的 resnet50」,一層一層往裡面走。

看動畫 1 之前:這支要回答什麼

觀念 1 講了 import 拿進來的工具箱。動畫 1 先不看語法,把整段程式變成一個故事:借一座已經在 ImageNet 練好的看圖工廠(ResNet-50),把 161 組旋鈕(參數)全部上鎖,拆掉最後一站、換成只分 2 類的新站,再請調音師 Adam 只調新的那一站。

帶著三個問題看:① pretrained=True 借來的是什麼?② 迴圈鎖住的是什麼、一共鎖了多少?③ 為什麼新換上的 fc 沒有上鎖?

看的時候注意顏色:灰色工作站加 🔒 是凍結、綠色「可訓練」是會被訓練的那一站。

動畫 1借來的看圖工廠:上鎖、換最後一站、只調新的2 分 4 秒・有旁白,也有字幕

工作站、旋鈕、調音師是比喻;參數數量(161 組、25,557,032 個,新 fc 4,098 個)都是 torchvision 0.21 實測。各站「學會看什麼」是常見的直覺說法,幫助理解用,並不是每一層只做一件事。

看完動畫 1,你應該能回答:pretrained=True 借來的是什麼?
在 ImageNet(約 128 萬張照片、1000 類)上訓練好的 ResNet-50 權重:25,557,032 個已經調好的數字。
迴圈鎖住了什麼?
161 個參數物件的 requires_grad 開關,加起來 25,557,032 個數字。鎖上以後,訓練時不會算它們的梯度,也就不會被改。
為什麼新換上的 fc 沒有上鎖?
它是迴圈跑完之後才新做的零件,新參數的 requires_grad 預設是 True。如果順序寫反(先換 fc 再跑迴圈),新 fc 也會被鎖住,整個模型就沒有東西可以學。
這種「主幹全鎖、只訓練新的最後一層」叫什麼?
特徵萃取(Feature Extraction):凍結的主幹當成固定的特徵萃取器,只訓練新的分類頭。

觀念 2|類別 vs 函式:有些物件「自己照模具做」,有些物件「請工廠做好交給你」

類別是設計圖(模具),類別名稱加上小括號,就會照設計圖做出一個物件。第 40 題的 Sequential、Dense 都是這樣用。這題有兩個類別也是同一套:

nn.Linear(2048, 2)               # Linear 是類別 → 直接做出一個「全連接層物件」
torch.optim.Adam(..., lr=1e-4)   # Adam 是類別   → 直接做出一個「優化器物件」

但 resnet50 不一樣:它是小寫開頭的函式。函式也可以回傳物件——resnet50 在內部替你呼叫真正的類別 ResNet,把 50 層組好、把下載來的權重填進去,最後把做好的物件交給你。這種「專門幫你做物件的函式」常被叫做工廠函式(factory function)。

type(torchvision.models.resnet50)   # <class 'function'>                           ← 函式
model = torchvision.models.resnet50(pretrained=True)
type(model)                         # <class 'torchvision.models.resnet.ResNet'>  ← 物件屬於 ResNet 類別
自己照模具做(類別)

nn.Linear(2048, 2):規格(幾個輸入、幾個輸出)全由你決定,做出來的權重是亂數。
比喻:買模具回家,自己壓一片餅乾。

請工廠做(工廠函式)

resnet50(pretrained=True):你只說「要 50 層那款、要訓練好的」,內部的 ResNet(Bottleneck, [3, 4, 6, 3]) 和載入權重都幫你做完。
比喻:直接買一盒已經烤好、調好味的餅乾。

不論走哪條路,最後拿到的都是物件,用法完全一樣:model.parameters()、model.fc……。分辨方式靠 Python 社群的命名慣例(PEP 8):大寫駝峰是類別(ResNet、Linear、Adam),小寫是函式、方法、變數(resnet50、parameters、model)。

torchvision 其實兩條路都給你

torchvision.models.ResNet 這個類別也拿得到,但要自己傳 ResNet(Bottleneck, [3, 4, 6, 3]) 這種內部細節,而且不會自動載入預訓練權重。實務上一律用小寫的 resnet50()、resnet18() 這類工廠函式。

觀念 3|物件裡面還有物件:屬性可以裝「另一個物件」

model 是一個大物件,它身上的屬性 conv1、bn1、layer1~layer4、avgpool、fc 各自又是一個物件(一個層或一組層)。用點一路往裡面走:

model                  # ResNet 物件(整台機器)
model.fc               # 它的 fc 屬性:一個 Linear 物件(最後一個零件)
model.fc.weight        # Linear 物件的 weight 屬性:一個 Parameter(一大塊數字)
model.fc.weight.shape  # Parameter 的 shape 屬性:torch.Size([1000, 2048])
方法:有括號,叫物件「做事」

model.parameters():交出所有參數
model.train()、model.eval():切換模式
optimizer.step():更新一次權重

屬性:沒括號,讀或改物件「身上的資料」

model.fc:最後的分類層
param.requires_grad:要不要算梯度(True/False)
param.shape:張量的形狀

比喻:model 是一台汽車,fc 是方向盤,weight 是方向盤上的皮革。「汽車.方向盤.皮革」一層層往裡指。

觀念 4|for 迴圈和產生器:一次處理一個

for param in model.parameters():    # ← 冒號結尾:下面縮排的是「每一輪要做的事」
    param.requires_grad = False     # ← 縮排 4 格:迴圈本體
                                    # ← 回到最左邊:迴圈結束
  • for 變數 in 一串東西::從「一串東西」裡每次拿一個出來,暫時取名叫 param,執行縮排裡的程式;拿完就結束。本題這串東西有 161 個,所以縮排那行會被執行 161 次。
  • model.parameters() 回傳的不是 list,而是產生器(generator):像一台出貨機,你要一個它才吐一個,不會一次把全部排好放進記憶體。代價是只能走一遍,吐完就空了(第 6、10 行的卡片會用到這件事)。
  • Python 用縮排決定哪些程式屬於迴圈,不是用大括號。縮排錯了會出現 IndentationError。

觀念 5|「物件.屬性 = 值」:撥開關 vs 換零件

這段程式有兩行長得很像,都是「物件.屬性 = 某個東西」,但規模差很多:

程式左邊右邊效果
param.requires_grad = False某個參數的 requires_grad 屬性布林值 False把開關從 True 撥到 False(修改屬性)
model.fc = nn.Linear(2048, 2)模型的 fc 屬性一個新做的 Linear 物件整個零件拆掉換新的(替換屬性)

注意 = 是「指派」,== 才是「比較」。寫成 param.requires_grad == False 只是問一句「它是 False 嗎?」,什麼都沒改,Python 也不會報錯——這是第 7 行最危險的坑。

觀念 6|本題出現的值:布林、整數、科學記號、註解

長相型別白話本題出現在
True、False布林 bool只有兩種值的開關。大寫開頭,寫成 false 會 NameErrorpretrained=True、requires_grad = False
2048、2整數 int沒有小數點的數字nn.Linear(2048, 2)
1e-4浮點數 float科學記號:1 × 10-4 = 0.0001。同理 1e-3 = 0.001、1e-5 = 0.00001lr=1e-4
# 行(A)註解# 後面到行尾都是給人看的,Python 直接略過第 7 行

把 6 個觀念串起來:本題的「類別 → 物件 → 方法」路線

① 類別/工廠函式(設計圖)類別:nn.Linear、torch.optim.Adam、ResNet
工廠函式:resnet50(內部替你呼叫 ResNet 類別)
→
② 建立物件(做出成品)model = resnet50(…):ResNet 物件
nn.Linear(2048, 2):Linear 物件
optimizer = Adam(…):Adam 物件
→
③ 呼叫方法/改屬性(叫它做事)model.parameters() 交出參數
param.requires_grad = False 撥開關
model.fc = … 換零件
之後:optimizer.zero_grad()、optimizer.step()

把觀念合起來:Python 執行這 8 行時,記憶體裡發生什麼

看動畫 2 之前:這支要回答什麼

動畫 1 是比喻;觀念 2~6 講了函式與類別、物件裡的物件、for 迴圈和產生器、改屬性與換零件。動畫 2 打開真正的記憶體,用一次實際執行的位址,一行一行畫出 8 行程式做出了哪些物件、哪些箭頭被改掉。

帶著三個問題看:① resnet50 是設計圖嗎?model 是照哪張設計圖做的?② param.requires_grad = False 和 model.fc = … 都是「物件.屬性 = 值」,差在哪?③ Adam 拿到的是 fc 參數的複本,還是同一個物件?

看的時候抓四種樣子:綠色格子是物件、黃色虛線小卡寫著它是照哪張設計圖做的、灰色虛線框是模組、紅色名牌是名字。

動畫 2打開記憶體:凍結是改屬性,換 fc 是換物件1 分 57 秒・有旁白,也有字幕

位址取自一次真實執行(Python 3.13.7、torch 2.6、torchvision 0.21),每次執行都不一樣。為了不下載 98 MB 的權重檔,實測用 weights=None 建模型;結構、參數數量和 requires_grad 的行為與 pretrained=True 完全相同。「舊 fc 被回收」和「Adam 清單裡是同一個物件」都用 weakref 和 is 實測過。

看完動畫 2,你應該能回答:resnet50 是設計圖嗎?
不是。type(torchvision.models.resnet50) 是 function;它在裡面呼叫 ResNet 這個類別做出物件,所以 type(model) 是 ResNet。
param.requires_grad = False 和 model.fc = nn.Linear(2048, 2) 差在哪?
前者是改參數身上的一個開關(布林值),物件還是同一個;後者是把 fc 屬性改指一個全新的 Linear 物件,舊的 Linear 沒有任何箭頭指著,被 Python 回收。
model.parameters() 交出的是 list 嗎?
不是,是 generator(出貨機):要一個才給一個,只能從頭走到尾一遍。迴圈靠它把 161 個參數一個一個交給名牌 param。
Adam 拿到的是複本嗎?
不是。optimizer.param_groups[0]['params'][0] is model.fc.weight 是 True:清單裡放的是箭頭,之後 optimizer.step() 改的就是 model.fc 身上那兩個參數。
§2

認識這次登場的框架:PyTorch、torchvision 與 ResNet-50

PyTorch(torch)
Meta(原 Facebook)AI 研究團隊開發,2017 年初公開;2022 年起由 Linux 基金會旗下的 PyTorch Foundation 管理

負責底層苦工:張量tensor:多維的數字陣列。一張彩色圖片是 3×224×224 的張量,一批 8 張就是 8×3×224×224。運算、GPU 加速、自動微分autograd:自動算出「每個參數往哪個方向調、調多少,loss 會變小」的梯度。requires_grad 就是它的開關。,以及各種層(torch.nn)和優化器(torch.optim)。

比喻:整間廚房:爐火、鍋具、刀具都在這裡。

torch.nn
PyTorch 內建的神經網路積木箱

放著所有層(Linear、Conv2d、BatchNorm2d…)、損失函數(CrossEntropyLoss…),以及所有層和模型的共同祖先類別 nn.Module。ResNet、Linear 都是 nn.Module 的後代,所以都有 .parameters() 這些方法。

比喻:樂高積木盒:每塊積木的卡榫規格都一樣,所以能任意組合。

torchvision
PyTorch 官方的電腦視覺套件,版本和 torch 綁定(torch 2.6 ↔ torchvision 0.21)

三大區:models(現成模型+預訓練權重,本題)、transforms(影像前處理與增強,第 41 題)、datasets(CIFAR-10、MNIST 等資料集,第 46 題)。

比喻:調理包專櫃:別人已經做好八成,你加熱、調味就能上桌。

ResNet-50
微軟研究院何愷明(Kaiming He)等人 2015 年提出,當年 ImageNet 影像分類競賽冠軍

關鍵發明是殘差連接每個區塊的輸出 = 這個區塊算出的「修正量」+ 原本的輸入(走捷徑直接跳過去)。梯度可以沿捷徑傳回前面,所以網路疊到 50、100 層以上仍然訓練得動。。「50」= 開頭 1 層卷積 + 16 個 Bottleneck 區塊 × 每個 3 層卷積 + 最後 1 層 fc。

比喻:一位看過上百萬張照片的資深鑑定師。

ImageNet 和「pretrained」是什麼?

ImageNet-1K 是影像分類的標準考卷:約 128 萬張訓練圖片、1,000 個類別(金魚、大白鯊、各種狗……)。預訓練(pretrained)就是指模型已經在這份資料上訓練好。torchvision 的 ResNet-50 權重有兩版:IMAGENET1K_V1(ImageNet top-1 準確率 76.13%)和改良訓練配方後重練的 IMAGENET1K_V2(80.858%)。兩版的架構、參數數量完全一樣,只有數值不同。

torch/torchvision 的「貨架配置圖」:本題用到哪幾格

torch ← L1:PyTorch 本體 ├─ Tensor 張量(多維陣列),資料和權重的基本型態 ├─ nn ← L2:取綽號 nn │ ├─ Module 所有層、所有模型的共同祖先類別 │ ├─ Parameter 「要被訓練的張量」,Tensor 的子類別 │ ├─ Linear ← L9:全連接層類別(新的 fc) │ ├─ Conv2d、BatchNorm2d、ReLU、MaxPool2d …(ResNet 內部全是這些) │ └─ CrossEntropyLoss 分類用損失函數(§6 會用到) ├─ optim │ ├─ Adam ← L10:優化器類別 │ └─ SGD、AdamW … └─ autograd 自動微分引擎(requires_grad 就是它的開關) torchvision ← L3 ├─ models │ ├─ resnet50() ← L5:工廠函式(小寫) │ ├─ ResNet resnet50() 回傳的物件屬於這個類別 │ ├─ ResNet50_Weights 新式寫法用的「權重選單」 │ └─ resnet18()、vgg16()、efficientnet_b0()、vit_b_16() … ├─ transforms 影像前處理與增強(第 41 題) └─ datasets CIFAR10、MNIST …(第 46 題)

遷移學習:把別人練好的「眼睛」借來用

從零訓練一個 ResNet-50,需要上百萬張標註好的圖片和大量 GPU 時間。但你手上可能只有幾百張「良品/瑕疵品」照片。遷移學習(Transfer Learning)的想法是:在 ImageNet 上練過的模型,前面的層已經學會「看」的基本功——邊緣、顏色、紋理、形狀、物體的零件——這些能力換到別的影像任務一樣有用,只要重新教最後的「判斷」就好。

圖片3×224×224
→
conv1~layer4看圖的眼睛:邊緣→紋理→零件
→
avgpool2,048 個特徵
→
fc判斷:屬於哪一類

所以一個 ResNet 可以拆成兩半看:前面的主幹(backbone,conv1 到 avgpool,負責把圖片變成 2,048 個數字的「特徵」)和最後的分類頭(head,就是 fc,負責拿特徵做決定)。遷移學習的幾種策略,差別就在「主幹要鎖住多少」:

策略主幹新的分類頭本題?
特徵萃取 Feature Extraction全部凍結 🔒訓練 🔓✓ 本題
部分微調 Partial Fine-Tuning前段凍結,後段(例如 layer4)訓練訓練 🔓
全面微調 Full Fine-Tuning全部訓練(學習率通常調小)訓練 🔓

§4 會讓你親手切換,看可訓練參數差多少。

PyTorch 訓練一步的四個動作(requires_grad 管的是第 ③ 步)

① 前向model(x) 算出預測
→
② 損失loss_fn(預測, 答案)
→
③ 反向loss.backward() 算梯度
只算 requires_grad=True 的
→
④ 更新optimizer.step()
依梯度改權重

題目程式只寫到「準備好模型和優化器」,還沒開始訓練。§6 會把這四步真的跑一次,你會親眼看到被凍結的參數完全沒有梯度。

§3

逐行解剖

左邊是完整程式(點任一行可以跳到解說),右邊往下捲時,左邊會自動標出目前講到哪一行。手機上會改成上下排列。

q42.py10 行

把 PyTorch 整個套件載入記憶體,之後用 torch.xxx 取用裡面的工具。

逐字拆解

import
關鍵字「把後面的模組載入,並在程式裡建立一個同名的名字」。
torch
套件PyTorch 的套件名稱,用 pip install torch 安裝。名字來自它的前身:用 Lua 語言寫的 Torch 框架。

這一行執行時發生什麼事

Python 找到安裝好的 torch,執行它的初始化程式(載入 C++ 運算核心、偵測有沒有 GPU 等),第一次可能要一兩秒。之後 torch 這個名字就指向一個「模組物件」,用點可以拿到裡面的東西。

執行後多了: torch → 模組物件(PyTorch 本體) torch.__version__ → '2.6.0'(本頁驗證用的版本)

torch 裡最常用的幾格

寫法是什麼做什麼
torch.tensor()、torch.randn()函式建立張量。§6 用 randn 做假圖片
torch.nn子模組各種層與損失函數(第 2 行)
torch.optim子模組各種優化器,第 10 行的 Adam 就在這
torch.cuda.is_available()函式檢查有沒有可用的 NVIDIA GPU
torch.no_grad()情境管理器推論時暫停記錄計算圖,省記憶體(和「凍結」不同,第 7 行會比較)
torch.save()、torch.load()函式存、讀模型權重

這段程式哪裡用到 torch?

只有第 10 行的 torch.optim.Adam。你可能會想:第 2 行 import torch.nn as nn 不是也載入了 torch 嗎?載入了,但沒有建立 torch 這個名字——import A.B as C 只會建立 C。所以少了第 1 行,第 10 行就會出現 NameError: name 'torch' is not defined。

新手常踩的坑

  • 寫成 import pytorch:ModuleNotFoundError: No module named 'pytorch'。框架叫 PyTorch,套件名卻是 torch。
  • 寫成 import Torch:Python 的模組名稱分大小寫,同樣 ModuleNotFoundError(Windows 也一樣)。
  • 想用 GPU 卻裝到 CPU 版:pip install torch 不一定是你要的 CUDA 版本,要照 PyTorch 官網的安裝指令選。裝錯時 torch.cuda.is_available() 會是 False。

走進 torch 裡的 nn 子模組(放各種神經網路層的地方),並幫它取一個短綽號 nn。

逐字拆解

import
關鍵字載入模組。
torch.nn
子模組「torch 的 nn 區」,專門放神經網路積木。
as
關鍵字「取綽號」。
nn
別名之後程式裡寫 nn 就代表 torch.nn。社群慣例都叫 nn,就像 numpy 叫 np。

nn 這個積木箱裡有什麼

類別做什麼和本題的關係
nn.Module所有層、所有模型的共同祖先類別ResNet、Linear 都繼承它
nn.Parameter「要被訓練的張量」,是 Tensor 的子類別parameters() 吐出來的就是它
nn.Linear全連接層第 9 行新的 fc
nn.Conv2d、nn.BatchNorm2d、nn.ReLU、nn.MaxPool2d、nn.AdaptiveAvgPool2d卷積、批次正規化、激活、池化ResNet-50 內部全是這些
nn.CrossEntropyLoss多類別分類的損失函數§6 用到

物件導向重點:繼承讓所有層都有同一組方法

ResNet 和 Linear 長得完全不同,但在原始碼裡都寫成 class ResNet(nn.Module)、class Linear(Module):它們是 nn.Module 的子類別。子類別會繼承父類別的方法,所以下面這些方法,對整個模型、對單一一層都能用:

方法做什麼本題
.parameters()逐一交出所有參數(產生器)第 6、10 行
.named_parameters()同上,但連名字一起給,例如 ('fc.weight', …)§6
.children()、.named_children()列出直接的子模組看結構時
.train()、.eval()切換訓練/評估模式(影響 BatchNorm、Dropout)第 7 行的進階說明
.requires_grad_(False)一次設定底下所有參數的 requires_grad第 7 行的等價寫法
.to('cuda')把所有參數搬到 GPU實務訓練
.state_dict()、.load_state_dict()匯出/載入所有權重resnet50 內部載入權重就用它
物件(x)呼叫物件本身 = 執行 forward(前向計算)model(x)
執行後多了: nn → torch.nn 模組物件 (只建立了 nn 這個名字,不會建立 torch)

新手常踩的坑

  • 寫 import torch.nn 卻沒加 as nn,後面又用 nn.Linear:NameError: name 'nn' is not defined。沒取綽號時要寫全名 torch.nn.Linear。
  • 寫成 import torch.nn.Linear:ModuleNotFoundError: No module named 'torch.nn.Linear'。import 後面只能接模組,Linear 是類別,要用 from torch.nn import Linear。
  • 等價寫法 from torch import nn 結果完全一樣,看到別人這樣寫不用緊張。

載入 torchvision,等一下要從它的 models 區拿 ResNet-50。

逐字拆解

import
關鍵字載入模組。
torchvision
套件PyTorch 官方的影像套件,要另外安裝(pip install torchvision),版本要和 torch 配對。

torchvision 的分區

子模組放什麼出現在
torchvision.models現成模型架構+預訓練權重本題第 5 行
torchvision.transforms影像前處理、資料增強第 41 題
torchvision.datasets常用資料集下載器第 46 題
torchvision.io、ops、utils讀寫圖片影片、偵測用運算、畫圖小工具—

torchvision.models 裡的其他模型(換模型時要知道的事)

工廠函式參數量ImageNet top-1最後一層的屬性名
resnet18()11,689,51269.758%fc(輸入 512)
resnet50()25,557,03280.858%(V2)fc(輸入 2048)
vgg16()138,357,54471.592%classifier
efficientnet_b0()5,288,54877.692%classifier
mobilenet_v3_small()2,542,85667.668%classifier
vit_b_16()86,567,65681.072%heads

數字取自 torchvision 0.21 各模型 DEFAULT 權重的官方 meta 資料。重點是最後一欄:只有 ResNet 家族的分類頭叫 fc,換模型時第 9 行要跟著改。

執行後多了: torchvision → 模組物件 torchvision.models.resnet50 → 一個「函式」(還沒做出任何模型)

新手常踩的坑

  • torch 和 torchvision 版本不配:例如單獨升級 torch 卻沒升 torchvision,可能在 import 或執行時出現 operator torchvision::nms does not exist 之類的錯誤。兩個一起用 pip 安裝最保險。
  • 以為 import torchvision 就會下載模型:不會。下載發生在第 5 行呼叫 resnet50 的那一刻。

請 torchvision 的 resnet50 函式做一個 ResNet-50 模型,並把在 ImageNet 上訓練好的權重填進去,做好的模型物件取名叫 model。

逐字拆解

model
變數自己取的名字,像貼在物件上的名牌。
=
指派把右邊算出來的東西貼上左邊的名牌。
torchvision.models
子模組torchvision 的模型區。
.resnet50
函式小寫開頭的工廠函式,不是類別。它會替你呼叫 ResNet 類別並載入權重。
(
呼叫函式加括號 = 現在執行它。
pretrained=True
關鍵字參數舊寫法:「要預訓練好的權重」。新版改用 weights=(下面會講)。
True
布林值大寫 T 的 True。

resnet50 函式裡面做了什麼(torchvision 0.21 原始碼節錄)

def resnet50(*, weights=None, progress=True, **kwargs):
    weights = ResNet50_Weights.verify(weights)
    return _resnet(Bottleneck, [3, 4, 6, 3], weights, progress, **kwargs)

def _resnet(block, layers, weights, progress, **kwargs):
    if weights is not None:
        _ovewrite_named_param(kwargs, "num_classes", len(weights.meta["categories"]))
    model = ResNet(block, layers, **kwargs)          # ← 這裡才真的用「類別」建立物件
    if weights is not None:
        model.load_state_dict(weights.get_state_dict(progress=progress, check_hash=True))
    return model
  1. 處理舊參數:函式外面包了一層處理舊寫法的程式(裝飾器),先把 pretrained=True 翻譯成 weights=ResNet50_Weights.IMAGENET1K_V1,再檢查 weights 合不合法。
  2. 鎖定 1000 類:有權重時,強制 num_classes = 1000,因為權重檔裡的 fc 就是 1000 類。
  3. 用 ResNet 類別建立物件:Bottleneck 是區塊的種類,[3, 4, 6, 3] 是 layer1~layer4 各疊幾個區塊。這組數字決定了它是「50」層。
  4. 填入權重:load_state_dict(...) 下載(或從快取讀)權重檔,把 25,557,032 個數字填進對應位置。
  5. 回傳做好的物件,交給 model 這個名字。
舊寫法 pretrained=True 會跳兩個警告(不影響執行)

在 torchvision 0.21 執行第 5 行,會看到(實測):

UserWarning: The parameter 'pretrained' is deprecated since 0.13 and may be removed
in the future, please use 'weights' instead.
UserWarning: Arguments other than a weight enum or `None` for 'weights' are deprecated
since 0.13 and may be removed in the future. The current behavior is equivalent to passing
`weights=ResNet50_Weights.IMAGENET1K_V1`. You can also use `weights=ResNet50_Weights.DEFAULT`
to get the most up-to-date weights.

意思是:pretrained 從 0.13 版(2022 年)起不建議使用,目前效果等於 weights=ResNet50_Weights.IMAGENET1K_V1。警告(warning)不是錯誤(error),模型照樣建得起來,考題寫法完全可以執行。

新式寫法(建議)

from torchvision.models import resnet50, ResNet50_Weights

weights = ResNet50_Weights.DEFAULT       # 目前 DEFAULT = IMAGENET1K_V2
model = resnet50(weights=weights)
preprocess = weights.transforms()        # 這組權重配套的前處理
weights 給什麼意思ImageNet top-1
None(預設)不載入,全部隨機初始化(從零訓練)—
ResNet50_Weights.IMAGENET1K_V12015 年原始配方訓練,= pretrained=True76.13%
ResNet50_Weights.IMAGENET1K_V2新配方重新訓練80.858%
ResNet50_Weights.DEFAULT「目前最好的那版」,torchvision 0.21 指向 V280.858%
預訓練模型要吃「它習慣的」圖片

weights.transforms() 會回傳一個前處理物件。V2 的內容是:短邊縮放到 232 → 中央裁切 224×224 → 轉成 0~1 的浮點數張量 → 用 ImageNet 的平均 [0.485, 0.456, 0.406]、標準差 [0.229, 0.224, 0.225] 做標準化;V1 則是縮放到 256 再裁 224。前處理和當初訓練時不一樣,凍結的特徵就會「看走眼」,程式不會報錯,但準確率會掉。

第一次執行會下載權重

權重檔不包含在套件裡。第一次呼叫時,會從 download.pytorch.org 下載(pretrained=True 對應的是 resnet50-0676ba61.pth,約 97.8 MB;V2 是 resnet50-11ad3fa6.pth,大小幾乎一樣),存到快取資料夾:

Windows :C:\Users\你的帳號\.cache\torch\hub\checkpoints\ macOS/Linux :~/.cache/torch/hub/checkpoints/

之後再執行就直接讀快取,不會重新下載。progress=True(預設)會顯示下載進度條;載入時還會用檔名裡的那段雜湊值檢查檔案(check_hash=True),檔案損壞就會報錯。

resnet50 完整參數表

參數本題預設值白話
weights(沒寫,用舊參數代替)None要載入哪一組預訓練權重。None = 不載入、隨機初始化
progress—True下載權重時顯示進度條
pretrainedTrue(舊參數,已不在函式簽名裡)0.13 起棄用;True 會被轉成 weights=IMAGENET1K_V1 並跳警告
以下是 **kwargs:resnet50 原封不動轉交給 ResNet 類別的參數
num_classes—1000最後 fc 的輸出數。有指定 weights 時只能是 1000
zero_init_residual—False把每個殘差區塊最後一個 BatchNorm 的權重初始化成 0(從零訓練時的小技巧)
groups—1分組卷積的組數(ResNeXt 才會改)
width_per_group—64每組的寬度(Wide ResNet 才會改)
replace_stride_with_dilation—None用空洞卷積取代縮小尺寸(語意分割常用)
norm_layer—None(= nn.BatchNorm2d)正規化層要用哪個類別

簽名是 resnet50(*, weights=None, progress=True, **kwargs)。那顆 * 表示後面的參數都要用「名字=值」傳。寫 resnet50(True) 在 0.21 還能跑,但會多跳一個「Using 'weights' as positional parameter(s) is deprecated」警告。

執行完之後,model 身上有什麼

model → ResNet 物件(type(model) 是 torchvision.models.resnet.ResNet) model.fc → Linear(in_features=2048, out_features=1000, bias=True) ← ImageNet 的 1000 類 參數 → 161 個參數張量,共 25,557,032 個數字,全部 requires_grad=True model.training → True(新建立的模型預設是訓練模式)

ResNet 物件的內部零件(輸入 224×224 時,實測形狀)

屬性是什麼輸出形狀參數數
conv17×7 卷積,64 個濾鏡,步幅 2(N, 64, 112, 112)9,408
bn1批次正規化(N, 64, 112, 112)128
reluReLU 激活(N, 64, 112, 112)0
maxpool3×3 最大池化,步幅 2(N, 64, 56, 56)0
layer13 個 Bottleneck 區塊(N, 256, 56, 56)215,808
layer24 個 Bottleneck 區塊(N, 512, 28, 28)1,219,584
layer36 個 Bottleneck 區塊(N, 1024, 14, 14)7,098,368
layer43 個 Bottleneck 區塊(N, 2048, 7, 7)14,964,736
avgpool全域平均池化 AdaptiveAvgPool2d((1, 1))(N, 2048, 1, 1)0
(flatten)forward 裡的 torch.flatten(x, 1)(N, 2048)0
fc全連接層 2048 → 1000(N, 1000)2,049,000
合計25,557,032

N 是一次餵幾張圖。fc 的 2,049,000 = 2048 × 1000 + 1000。整個模型將近六成的參數集中在 layer4。

新手常踩的坑

  • 想一步到位做 2 類:resnet50(weights=ResNet50_Weights.DEFAULT, num_classes=2) 會報 ValueError: The parameter 'num_classes' expected value 1000 but got 2 instead.(權重檔的 fc 是 1000 類,對不上)。正確做法就是題目這樣:先載入,再換 fc。
  • 寫成 torchvision.models.ResNet50(...):AttributeError: module 'torchvision.models' has no attribute 'ResNet50'。工廠函式是小寫 resnet50。
  • 電腦沒網路或被防火牆擋住時,第一次執行會在下載那步失敗。可以先在能上網的地方下載,把 .pth 檔放進上面的快取資料夾。
  • 忘了做 ImageNet 的前處理(或用了別的平均、標準差):程式不會報錯,效果卻會差很多。

呼叫 model 的 parameters 方法,拿到一台「一次吐一個參數」的出貨機;for 迴圈每次從裡面拿一個,暫時叫它 param,交給下一行處理,直到全部拿完。

逐字拆解

for
關鍵字迴圈的開頭:「對每一個……」。
param
迴圈變數自己取的名字,每一輪指向不同的參數。叫 p 也可以。
in
關鍵字「從……裡面拿」。
model
物件第 5 行做出來的 ResNet 物件。
.parameters
方法從 nn.Module 繼承來的方法:「把你所有的參數交出來」。
()
呼叫一定要有括號,方法才會真的執行。
:
冒號「以下縮排的是迴圈本體」。

parameters() 交出來的是什麼

回傳值是一個產生器(generator)。每次 for 向它要下一個,它就沿著模型的子模組往下找,吐出下一個 nn.Parameter。實際的順序:

第 1 輪 param = conv1.weight 形狀 (64, 3, 7, 7) 第 2 輪 param = bn1.weight 形狀 (64,) 第 3 輪 param = bn1.bias 形狀 (64,) … (layer1~layer4 裡的卷積與 BatchNorm 參數) 第 159 輪 param = layer4.2.bn3.bias 形狀 (2048,) 第 160 輪 param = fc.weight 形狀 (1000, 2048) 第 161 輪 param = fc.bias 形狀 (1000,) → 迴圈跑 161 輪;161 個張量裡的數字加起來 = 25,557,032

nn.Parameter:會被訓練的張量

每個 param 都是 nn.Parameter 物件。Parameter 是 Tensor(張量)的子類別(實測 issubclass(nn.Parameter, torch.Tensor) 是 True):本質上就是一塊多維數字陣列,只是多了一個身分——「我是模型要學的東西」。它身上常用的東西:

屬性/方法例:conv1.weight白話
.shapetorch.Size([64, 3, 7, 7])形狀:64 個濾鏡 × 3 個顏色通道 × 7 × 7
.requires_gradTrue(凍結前)要不要幫它算梯度——第 7 行就是改這個
.gradNone反向傳播後梯度存在這裡;還沒算過就是 None
.dtype、.devicetorch.float32、cpu數字型別、放在 CPU 還是 GPU
.numel()9408方法:裡面總共有幾個數字(§6 用它算參數量)

parameters() 的完整參數表

參數本題預設值白話
recurse(沒寫)TrueTrue:連子模組、子模組的子模組全部往下找。False:只給「這個物件自己直接擁有」的參數。ResNet 本身不直接擁有參數(都在 conv1、layer1…裡),所以 model.parameters(recurse=False) 實測是空的

兄弟方法:想看名字就用 named_parameters()

for name, param in model.named_parameters():
    print(name, param.shape, param.requires_grad)
# conv1.weight torch.Size([64, 3, 7, 7]) True
# bn1.weight torch.Size([64]) True
# ...

每一輪拿到一個 tuple (名字, 參數),for name, param in … 會把它拆成兩個變數。完整簽名是 named_parameters(prefix='', recurse=True, remove_duplicate=True)。

產生器只能走一遍(實測)
g = model.parameters()
len(list(g))   # 161
len(list(g))   # 0   ← 第二次就空了

要重新走一遍,就再呼叫一次 model.parameters() 拿一台新的出貨機。第 10 行有一個相關的坑。

新手常踩的坑

  • 漏了括號 for param in model.parameters::TypeError: 'method' object is not iterable。沒有呼叫方法,拿到的是「方法本身」。
  • 漏了冒號會 SyntaxError;下一行沒縮排會 IndentationError。
  • 以為 161 就是參數量:161 是「參數張量」的個數,每個張量裡有很多數字。總數要用 sum(p.numel() for p in model.parameters()) 算。

把這一輪拿到的參數的 requires_grad 屬性設成 False:之後反向傳播不會幫它算梯度,它的值在訓練中就不會改變。迴圈跑完 161 輪,整個 ResNet-50 就被鎖住了。

逐字拆解

␣␣␣␣
縮排行首的 4 個空白(這裡用 ␣ 標出來),表示這行屬於上面的 for 迴圈,每一輪都會執行。
param
物件這一輪拿到的 nn.Parameter。
.requires_grad
屬性布林屬性:True = 要算梯度、False = 不用。沒有括號,所以是屬性不是方法。
=
指派把右邊的值寫進這個屬性,直接修改物件本身。
False
布林值大寫 F。
# 行(A)
註解出題者加的標記,Python 會略過。

先弄懂「梯度」:訓練是怎麼改權重的

訓練神經網路像在山谷裡蒙著眼找最低點(loss 最小的地方)。梯度告訴你:「每一個參數往哪個方向調、loss 會下降得最快,影響有多大」。PyTorch 的流程是:

  1. 前向:model(x) 算出預測。只要算式裡有 requires_grad=True 的張量,PyTorch 就會邊算邊記下計算圖記錄「誰乘了誰、誰加了誰」的流程圖,反向傳播時沿著它倒著走。。
  2. 反向:loss.backward() 沿著計算圖倒著走,用微積分的連鎖律算出每個 requires_grad=True 參數的梯度,存進它的 .grad 屬性。
  3. 更新:optimizer.step() 依照 .grad 修改參數的值。

requires_grad=False 的參數:第 2 步不會算它的梯度(.grad 一直是 None),第 3 步優化器看到沒有梯度就跳過它,值永遠不變 = 凍結。但它仍然參與第 1 步:凍結的層照樣在「看圖」、產生特徵,只是不再學習。

比喻:161 組旋鈕的混音台

ResNet-50 像一台有 161 組旋鈕(加起來 2,556 萬個刻度)的混音台,已經由專業錄音師(ImageNet 訓練)調到很好的位置。行(A) 用膠帶把每一組旋鈕貼死:聲音照樣從混音台流過(前向計算照常),但再也沒有人能轉動它們(不會被更新)。

整個迴圈跑完後的狀態

161 個參數張量的 requires_grad:全部 False 可訓練參數:0 / 25,557,032 model.fc.weight.requires_grad → False ← 舊的 1000 類 fc 也一起被鎖住(反正下一行就換掉)

三種寫法,效果一樣

# 寫法 1:題目的寫法,逐一修改屬性
for param in model.parameters():
    param.requires_grad = False

# 寫法 2:呼叫 Module 的方法,一次鎖住底下所有參數(回傳 model 本身)
model.requires_grad_(False)

# 寫法 3:呼叫 Tensor 的方法,逐一設定
for param in model.parameters():
    param.requires_grad_(False)

名字結尾的底線 _ 是 PyTorch 的慣例,表示「就地修改(in-place)」,直接改物件本身。Module.requires_grad_ 的簽名是 requires_grad_(requires_grad=True),所以要解凍某一段就是 model.layer4.requires_grad_(True)(§4 的控制台會產生這種程式碼)。

為什麼要凍結?三個好處

  • 保住學過的東西:ImageNet 練出來的邊緣、紋理、形狀偵測器,不會被少量新資料亂改。
  • 資料少也不容易過擬合模型把訓練資料「背」起來,訓練集表現很好、新資料卻很差。能調的參數越多、資料越少,越容易發生。:換上新 fc 後,可訓練的只剩 4,098 個參數。
  • 省時間、省記憶體:反向傳播算到 fc 就停,Adam 也只要替 4,098 個參數記狀態。本頁驗證環境的 CPU 實測(batch 8):特徵萃取一步約 0.34 秒,全面微調約 1.09 秒。
進階:凍結參數 ≠ 凍結 BatchNorm 的統計值(實測)

ResNet-50 裡有 53 個 BatchNorm2d 層。BatchNorm 身上有兩種東西:可學的 weight、bias(是 Parameter,會被行(A) 鎖住),以及 running_mean、running_var、num_batches_tracked 這些累計統計值。後者是 buffer(緩衝區),不是 Parameter,不歸 requires_grad 管,而是在 model.train() 模式下每做一次前向計算就自動更新。

for p in model.parameters():
    p.requires_grad = False
model.train()
model(torch.randn(4, 3, 224, 224))
# bn1.running_mean 改變了嗎? True    ← 統計值還是被新資料改了
# bn1.num_batches_tracked:0 → 1
# bn1.weight 改變了嗎?      False   ← 參數確實鎖住了

model.eval()
model(torch.randn(4, 3, 224, 224))
# bn1.running_mean 改變了嗎? False   ← eval 模式才不會動

所以做純粹的特徵萃取時,有些人會在訓練時把主幹設成 eval()(例如直接 model.eval(),只讓 fc 學;fc 是 Linear,不受 train/eval 影響,梯度照樣算得出來),讓主幹完全維持 ImageNet 的樣子。兩種做法都有人用:新資料和 ImageNet 很不一樣時,讓統計值跟著新資料調整有時反而比較好。要記住的是:requires_grad 管的是 Parameter,管不到 BatchNorm 的 running 統計值。

新手常踩的坑

  • param.requires_grad == False:兩個等號是「比較」,算出 True/False 就丟掉,什麼都沒改,也不會報錯。實測迴圈跑完,可訓練參數還是 25,557,032 個。
  • param.requires_grad = false(小寫 f):NameError: name 'false' is not defined. Did you mean: 'False'?
  • param.require_grad = False(少一個 s):最陰險。Python 允許在物件上新增任意屬性,所以不會報錯,只是多了一個沒人理的 require_grad,真正的 requires_grad 還是 True(實測)。
  • 把凍結和 torch.no_grad() 搞混:with torch.no_grad(): 是「這一段計算不記錄計算圖」,用在推論;凍結是「這些參數永遠不學」,是參數身上的設定。
  • 以為凍結了就萬無一失:BatchNorm 的 running 統計值仍會在 train 模式下更新(見上面的進階說明)。

用 nn.Linear 類別做一個新的全連接層(吃 2,048 個特徵、輸出 2 個分數),把 model 原本的 fc 換掉。新做的層預設是可訓練的,所以整個模型現在「只有這一層」能學。

逐字拆解

model
物件剛剛被凍結的 ResNet 物件。
.fc
屬性ResNet 物件身上叫 fc 的屬性,裝著最後一層(一個子物件)。
=
指派把右邊的新物件放進這個屬性,舊的就被換掉。
nn.Linear
類別全連接層的設計圖(大寫 L)。
(
建立物件類別加括號 = 照設計圖做一個新的層。
2048
位置參數第 1 個位置是 in_features:每筆輸入有幾個數字。
2
位置參數第 2 個位置是 out_features:要輸出幾個數字 = 類別數(例如貓/狗、良品/瑕疵品)。

2048 從哪來:一定要和前一站的輸出對上

fc 吃的是 avgpool 攤平後的向量。沿著 ResNet 的 forward 走一遍(輸入 224×224,實測):

layer4 輸出 (N, 2048, 7, 7) ← 2048 個通道,每個是 7×7 的小圖 avgpool 輸出 (N, 2048, 1, 1) ← 每張 7×7 取平均,變成 1 個數字 flatten 輸出 (N, 2048) ← 拉平成 2048 維向量:這就是「萃取出來的特徵」 fc 輸出 (N, 2) ← 新的分類頭

2048 = layer4 的基本寬度 512 × Bottleneck 的擴張倍數 4(torchvision 原始碼就是 nn.Linear(512 * block.expansion, num_classes))。avgpool 是「自適應」池化 AdaptiveAvgPool2d((1, 1)),不管輸入圖片是 224 還是 300,都會壓成 1×1,所以 fc 永遠吃 2048 個數字。

更穩的寫法是讓程式自己讀:model.fc = nn.Linear(model.fc.in_features, 2)。舊 fc 的 in_features 屬性就是 2048;換成 resnet18 時它會自動變成 512,不用改數字(§6 就用這個寫法)。

nn.Linear 完整參數表

參數本題預設值白話
in_features2048(必填)輸入向量的長度
out_features2(必填)輸出幾個數字;分類時 = 類別數
bias—True要不要加偏差 b
device—None(= CPU)參數要建在哪個裝置上
dtype—None(= torch.float32)參數的數字型別

新的 fc 長什麼樣(實測)

model.fc → Linear(in_features=2048, out_features=2, bias=True) model.fc.weight → 形狀 (2, 2048),requires_grad=True ← 注意是 (輸出, 輸入) model.fc.bias → 形狀 (2,),requires_grad=True 參數數:2048 × 2 + 2 = 4,098 初始值:均勻分布的亂數,範圍約 ±1/√2048 ≈ ±0.0221

Linear 算的是「輸出 = 輸入 · WT + b」。PyTorch 把 weight 存成 (輸出數, 輸入數),和第 40 題 Keras 的 kernel(輸入數, 輸出數)剛好轉置——算出來一樣,只是存法不同。

為什麼新的 fc 可以訓練?

因為行(A) 的迴圈已經跑完了。迴圈只處理「當時」存在的 161 個參數;這一行才新建的 Linear,它的 weight 和 bias 是全新的 nn.Parameter,預設 requires_grad=True。

執行後: 可訓練:fc.weight (2, 2048) + fc.bias (2,) = 4,098 凍結: 其餘 159 個參數張量 = 23,508,032 總數: 23,512,130 (舊 fc 的 2,049,000 個參數已經被拆掉,不再算在模型裡)

物件導向重點:「指派一個層」為什麼就能換掉零件?

nn.Module 改寫了「設定屬性」這個動作(特殊方法 __setattr__)。當你把一個 nn.Module 物件指派給模型的屬性,它不只是存起來,還會自動登記成子模組,放進模型內部的 _modules 清單、取代同名的舊零件(實測順序仍是 conv1、bn1、…、avgpool、fc)。所以:

  • model.parameters() 之後吐出來的是新 fc 的參數,不是舊的。
  • ResNet 的 forward 寫的是 x = self.fc(x),它會去拿「現在」叫 fc 的那個物件,所以預測自動變成 2 類。
  • 舊的 fc 沒有任何名字指著它,Python 會自動把它回收。
順序很重要:先換 fc 再凍結,會發生什麼?(實測)
model.fc = nn.Linear(2048, 2)          # ✗ 先換
for param in model.parameters():       #   再凍結:新的 fc 也被鎖住
    param.requires_grad = False

optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-4)   # 不會報錯!
loss = loss_fn(model(x), y)            # 也不會報錯,但 loss 沒有 grad_fn
loss.backward()
# RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn

可訓練參數變成 0。建立 Adam 時不會報錯(它只是把 fc 的 2 個參數記下來);前向計算也正常;一直到 loss.backward() 才出錯,因為整張計算圖裡沒有任何需要梯度的東西。如果你改用「篩選 requires_grad 的參數」來建立優化器,則會更早在建立時就報 ValueError: optimizer got an empty parameter list。正確順序永遠是:先凍結舊的 → 再裝上新的 → 最後建立優化器。

新手常踩的坑

  • nn.Linear(1024, 2)(數字和前一站對不上):建立時不會錯,一跑前向就 RuntimeError: mat1 and mat2 shapes cannot be multiplied (4x2048 and 1024x2)。
  • model.FC = nn.Linear(2048, 2)(大寫):不會報錯,但這是「多掛一個叫 FC 的新零件」,forward 用的還是原本的 fc,輸出仍是 1000 類(實測 torch.Size([2, 1000]))。
  • model.fc.out_features = 2:只改了一個「標籤」屬性,印出來雖然變成 out_features=2,權重矩陣還是 1000×2048,輸出仍是 1000 類(實測)。要換就換整個物件。
  • 換別的模型時屬性名不同:VGG、EfficientNet、MobileNet 的分類頭叫 classifier,ViT 叫 heads,只有 ResNet 家族叫 fc。
  • 二分類卻寫 nn.Linear(2048, 1) 配 CrossEntropyLoss:CrossEntropyLoss 要每個類別各一個分數(2 類就輸出 2 個);只輸出 1 個分數的寫法要改配 BCEWithLogitsLoss。

用 Adam 類別建立一個優化器物件,交代它:「你只負責 fc 的 2 個參數張量(共 4,098 個數字),基本步伐(學習率)是 0.0001」,取名 optimizer。之後訓練時每呼叫一次 optimizer.step(),它就照梯度把這 4,098 個數字調一次。

逐字拆解

optimizer
變數自己取的名字,慣例叫 optimizer 或 opt。
torch.optim
子模組放各種優化器的地方(因為第 1 行有 import torch 才能這樣寫)。
.Adam
類別大寫開頭,Adam 優化演算法的設計圖。
(
建立物件類別加括號,做出一個優化器物件。
model.fc
物件第 9 行剛裝上的 Linear 物件。
.parameters()
方法Linear 也繼承了 nn.Module 的這個方法,只吐出 fc.weight、fc.bias 兩個。
lr=1e-4
關鍵字參數learning rate,學習率 = 0.0001。

為什麼只交 model.fc.parameters()?兩道鎖

  1. 行(A):主幹的 requires_grad=False → 反向傳播根本不算它們的梯度(省時間、省記憶體)。
  2. 第 10 行:只把 fc 交給 Adam → 優化器只會更新「交給它的」參數,就算主幹有梯度,Adam 也不會去改。

做個思考實驗:如果把行(A) 整個刪掉、第 10 行不變,主幹的權重仍然不會被改(實測 conv1.weight 沒變),只是反向傳播會白白替全部 161 個參數張量算梯度,又慢又佔記憶體。所以行(A) 的主要功能是「讓 autograd 不用算」,第 10 行則「確保只改 fc」。

Adam 完整參數表

參數本題預設值白話
paramsmodel.fc.parameters()(必填)要它負責更新哪些參數。可以給產生器、list,或分組的 dict
lr1e-40.001學習率:每一步的基本步伐。本題比預設小 10 倍
betas—(0.9, 0.999)兩個「記憶衰減率」:梯度平均、梯度平方平均各保留多少過去的資訊
eps—1e-08分母加的極小值,避免除以 0
weight_decay—0權重衰減(L2 懲罰),抑制權重變太大
amsgrad—False是否用 AMSGrad 變形版
foreach—None是否把多個參數合在一起算來加速(None = 自動決定)
maximize—FalseTrue 會改成「讓目標變大」
capturable—False配合 CUDA Graph 使用的進階選項
differentiable—False讓更新步驟本身也能被微分(研究用)
fused—None使用融合版實作加速(GPU 上較常用)

簽名:Adam(params, lr=0.001, betas=(0.9, 0.999), eps=1e-08, weight_decay=0, amsgrad=False, *, foreach=None, maximize=False, capturable=False, differentiable=False, fused=None)。* 後面的參數只能用「名字=值」傳。

1e-4 是多少?

科學記號:1e-4 = 1 × 10-4 = 0.0001(Python 裡 1e-4 == 0.0001 是 True,型別是 float)。為什麼遷移學習常把學習率設得比預設小,是第 43 題的主題;這裡先記住它是 0.0001 就好。

和第 40 題的 Keras Adam 比一比

兩邊預設學習率都是 0.001,但 epsilon 預設不同:Keras 是 1e-07,PyTorch 是 1e-08。另外 Keras 可以在 compile(optimizer='adam') 用字串指定;PyTorch 一定要自己用類別建立物件,而且建立時就要告訴它管哪些參數。

optimizer 物件身上有什麼(實際 print 結果)

print(optimizer)
# Adam (
# Parameter Group 0
#     amsgrad: False
#     betas: (0.9, 0.999)
#     capturable: False
#     differentiable: False
#     eps: 1e-08
#     foreach: None
#     fused: None
#     lr: 0.0001
#     maximize: False
#     weight_decay: 0
# )
屬性/方法做什麼
.param_groups(屬性)一個 list,每組是一個 dict:{'params': [fc.weight, fc.bias], 'lr': 0.0001, …}。想中途改學習率就改這裡
.state(屬性)Adam 替每個參數記的「動量」資料。剛建立時是空的;第一次 step() 之後,每個參數會多出 step、exp_avg、exp_avg_sq
.zero_grad()清掉參數的 .grad(預設 set_to_none=True,設回 None)。每一步開始前都要呼叫,否則梯度會一直累加
.step()照目前的 .grad 更新一次參數
.state_dict()、.load_state_dict()存、讀優化器狀態(中斷後接著訓練用)
.add_param_group({...})中途再加一組參數,例如訓練到一半才解凍 layer4

之後的訓練迴圈長這樣(第 50 題會詳講)

loss_fn = nn.CrossEntropyLoss()
for x, y in train_loader:            # 一批一批拿資料
    optimizer.zero_grad()            # ① 清掉上一步的梯度
    loss = loss_fn(model(x), y)      # ② 前向 + 算 loss
    loss.backward()                  # ③ 反向:只有 fc 會拿到梯度
    optimizer.step()                 # ④ Adam 更新 fc 的 4,098 個數字

Adam 要多少額外記憶體

Adam 替每個參數記兩個數字(exp_avg 一階動量、exp_avg_sq 二階動量)。本題只管 4,098 個參數,額外只要記 8,196 個數字;如果全面微調 23,512,130 個參數,就要多記 47,024,260 個(實測 Adam state 的數量),以 float32 每個 4 bytes 計約 188 MB。

新手常踩的坑

  • torch.optim.adam(...)(小寫):AttributeError: module 'torch.optim' has no attribute 'adam'。類別是大寫 Adam。
  • Adam(model.fc.parameters, lr=1e-4)(漏括號):TypeError: 'method' object is not iterable。
  • 先把產生器存起來又用掉:params = model.fc.parameters(); print(list(params)); Adam(params, lr=1e-4) → ValueError: optimizer got an empty parameter list(產生器第二次已經空了)。
  • lr=1e4(少了負號):那是 10,000,不是 0.0001,訓練會立刻爆掉。
  • 先建立優化器、之後才換 fc:優化器記住的是「當時」的參數物件,換掉 fc 之後,它會一直更新那個已經被拆下來的舊 fc,新的 fc 永遠學不到東西。所以一定是先換 fc,再建立優化器(題目的順序正確)。

§4

互動實驗:ResNet-50 凍結控制台

下面是換上新 fc 之後的 ResNet-50。點方塊切換 🔒凍結/🔓可訓練,看可訓練參數、策略名稱和對應的程式碼怎麼變。所有數字都是在 torchvision 0.21 實際數出來的。

ResNet-50 凍結控制台
先按一個預設,再自己點方塊試試。長條長度 = 該段的參數量(以最大的 layer4 為滿格);紅色「↑ 梯度」表示反向傳播會經過那裡。
輸入圖片 (N, 3, 224, 224)
→ CrossEntropyLoss(和標籤比對)
可訓練 凍結 整條 = 23,512,130 個參數(新 fc 細到幾乎看不見)
可訓練參數—
總參數23,512,130
可訓練比例—
Adam 要多記的數字—
對應的程式碼:

  

動畫 3:真的訓練一步,看誰的數字變了

看動畫 3 之前:這支要回答什麼

上面的控制台算的是「有多少參數可以訓練」。動畫 3 把題目沒寫完的訓練一步真的跑一次:前向、損失、反向、更新,看梯度走到哪裡停下、哪些數字真的被改了;最後把四個選項的策略放在一起比。

帶著三個問題看:① loss.backward() 之後,conv1.weight.grad 是什麼?② optimizer.step() 改了哪些數字、改了多少?③ 全面微調、零樣本學習、知識蒸餾和本題差在哪?

動畫 3訓練一步:只有新的 fc 在學1 分 47 秒・有旁白,也有字幕

4 張「照片」是用亂數產生的張量(seed 0),分數和 loss 0.6963 取自那一次執行。被凍結的參數一個都沒變;但 BatchNorm 的統計值(running_mean、running_var)不是參數,在 model.train() 模式下仍會更新,要連它們都固定,可以在訓練時對主幹呼叫 eval()(實測 eval 模式下不變)。

看完動畫 3,你應該能回答:loss.backward() 之後,conv1.weight.grad 是什麼?
None。requires_grad=False 的參數不算梯度;只有 fc.weight.grad 有值,形狀是 [2, 2048]。
optimizer.step() 改了哪些數字、改了多少?
只有新的 fc:4,096 個權重每個都動了約 0.0001(實測 0.00009998~0.0001000008,Adam 第一步的移動量大約就是 lr),兩個 bias 各動 ±0.0001。conv1、layer4 的權重用 torch.equal 比對訓練前後,完全相同。
四個選項的策略差在哪?
全面微調:鎖全部打開,整個模型一起訓練;零樣本學習:不針對新任務訓練就直接拿來判斷;知識蒸餾:大模型當老師,教一個小模型;特徵萃取(本題,答案 C):主幹全部凍結,只訓練新的分類頭。

三種策略比一比

特徵萃取(本題)部分微調(例:解凍 layer4)全面微調
凍結什麼整個主幹conv1~layer3不凍結
可訓練參數4,098(0.017%)14,968,834(63.66%)23,512,130(100%)
Adam 要多記的數字8,19629,937,66847,024,260
一步耗時(本機 CPU、batch 8,僅供比較)約 0.34 秒約 0.45 秒約 1.09 秒
資料量需求(經驗法則)少:每類幾十到幾百張就能起步中等多:資料少時很容易過擬合
適合新任務和 ImageNet 相近、資料少、要快資料中等,或新任務和 ImageNet 有一段差距資料多,且新任務和 ImageNet 差很多(如醫療影像、衛星圖)
風險特徵是固定的;新任務差太多時,準確率的天花板較低要決定解凍幾層;學習率要小學習率太大會把預訓練學到的東西沖掉(災難性遺忘catastrophic forgetting:模型學新任務時,把原本學會的能力大幅破壞掉。);最耗運算與記憶體

耗時是本頁驗證環境(20 執行緒 CPU)量到的一次結果,換機器、換 GPU 比例都會變。注意特徵萃取並沒有讓前向變快——圖片還是要走完整個 ResNet;省下的是反向傳播和優化器的工作。

特徵萃取的加速技巧:特徵算一次就好

既然主幹不學,在 eval() 模式、又沒有隨機資料增強的前提下,同一張圖每次算出來的 2048 維特徵都一樣。所以可以先把整份資料集的特徵算一次存起來,之後每個 epoch 只訓練 fc(甚至改用 scikit-learn 的邏輯迴歸),完全不用再跑主幹。這種「凍結的特徵+線性分類器」的做法也叫 linear probing(線性探測)。

另外兩個選項的名詞:零樣本學習、知識蒸餾

零樣本學習(Zero-shot Learning)

白話:完全不用新任務的標註資料訓練,直接拿模型去推論它沒專門學過的類別。常見做法是用「文字描述」當橋樑,例如 CLIP 模型把圖片和「a photo of a cat」「a photo of a dog」這些句子比對相似度,就能分類從沒訓練過的類別。

程式長相:只有推論,沒有 optimizer、沒有 backward()。

知識蒸餾(Knowledge Distillation)

白話:大模型(老師)教小模型(學生)。讓小模型去模仿訓練好的大模型輸出的機率分布(軟標籤),目的是把模型變小、變快,又盡量不掉準確率(Hinton 等人 2015 年提出)。

程式長相:同時有兩個模型;loss 裡會比對老師和學生的輸出(常用 KL 散度和溫度參數)。

本題程式的完整資料流

(N, 3, 224, 224)圖片
→
主幹 🔒23,508,032 個參數凍結
→
(N, 2048)萃取出的特徵
→
新 fc 🔓4,098 個參數
→
(N, 2)2 類分數
→
Adam(lr=1e-4)只更新 fc
§5

回到題目:逐項對答案

(A) 全面微調(Full Fine-Tuning) 錯

全面微調會讓整個模型(或大部分層)都跟著新資料更新,程式上通常不寫凍結迴圈,並把 model.parameters() 全部交給優化器。本題把所有既有權重的 requires_grad 都關掉、只把 fc 交給 Adam,可訓練參數只有 4,098 個(0.017%),正好相反。

(B) 零樣本學習(Zero-shot Learning) 錯

零樣本是不針對新任務訓練、直接推論。本題程式建立了優化器,準備訓練新的 fc;而且新 fc 是隨機初始化的,不訓練根本不能用——這段程式明顯是要「訓練」的。

(C) 特徵萃取(Feature Extraction) 正解

預訓練的 ResNet 被整個凍結,只當作「把圖片轉成 2048 維特徵」的萃取器,不在反向傳播時更新;再換上新的 fc 並只訓練它。這就是遷移學習典型的 feature extraction 策略。

(D) 知識蒸餾(Knowledge Distillation) 錯

知識蒸餾需要「老師」和「學生」兩個模型,讓小模型模仿大模型的輸出。本題只有一個 ResNet,也沒有任何模仿老師輸出的 loss,和凍結 ResNet 當特徵萃取器是兩回事。

記一張表就夠:五個名詞怎麼從程式分辨

策略新任務要訓練嗎誰會被更新程式的特徵
特徵萃取要只有新分類頭全部 requires_grad = False → 換 fc → 優化器只拿 fc
部分微調要後面幾層+新分類頭凍結後再用 requires_grad_(True) 解凍 layer4 等
全面微調要全部不凍結,優化器拿 model.parameters(),學習率通常調小
零樣本學習不用沒有只有推論,沒有 optimizer、沒有 backward
知識蒸餾要(訓練學生)學生模型兩個模型;loss 比對老師與學生的輸出
§6

動手跑跑看:用新式寫法把考題補成一次完整的訓練步

下面的程式改用 weights= 新式寫法,凍結、換 fc、數參數,再用隨機產生的「假圖片」做一次前向+反向+更新,印出哪些參數真的拿到了梯度。複製到 Google Colab 或裝好 PyTorch 的電腦就能執行(第一次會下載約 98 MB 的權重)。

import torch
import torch.nn as nn
from torchvision.models import resnet50, ResNet50_Weights

torch.manual_seed(0)                      # 固定亂數,讓結果可以重現

# 1. 建立模型:新式寫法,載入 ImageNet 預訓練權重
weights = ResNet50_Weights.DEFAULT        # 目前 = IMAGENET1K_V2
model = resnet50(weights=weights)
preprocess = weights.transforms()         # 真實圖片要先經過它(本例用隨機資料,沒用到)

# 2. 行(A):凍結所有既有權重
for param in model.parameters():
    param.requires_grad = False

# 3. 換上新的分類頭:2048 → 2 類(新層預設 requires_grad=True)
model.fc = nn.Linear(model.fc.in_features, 2)

# 4. 只把 fc 的參數交給優化器
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-4)
loss_fn = nn.CrossEntropyLoss()

# 5. 數一數參數
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"總參數:{total:,}")
print(f"可訓練:{trainable:,}({trainable / total:.3%})")

# 6. 用隨機資料做一次「前向 → 反向 → 更新」
x = torch.randn(8, 3, 224, 224)           # 8 張假圖片(3 色、224×224)
y = torch.randint(0, 2, (8,))             # 8 個假標籤:0 或 1

model.train()
fc_before = model.fc.weight.clone()       # 先複製一份,等一下比對有沒有變
conv_before = model.conv1.weight.clone()

optimizer.zero_grad()                     # ① 清梯度
logits = model(x)                         # ② 前向
loss = loss_fn(logits, y)                 #    算 loss
loss.backward()                           # ③ 反向:算梯度
optimizer.step()                          # ④ 更新

print("輸出形狀:", logits.shape)
print("loss =", round(loss.item(), 4))
has_grad = [name for name, p in model.named_parameters() if p.grad is not None]
print("有 grad 的參數:", has_grad)
no_grad = sum(1 for p in model.parameters() if p.grad is None)
print("沒有 grad 的參數張量:", no_grad, "/", len(list(model.parameters())))
print("fc 權重更新了嗎?", not torch.equal(fc_before, model.fc.weight))
print("conv1 權重更新了嗎?", not torch.equal(conv_before, model.conv1.weight))
實際跑出來的結果

在 torch 2.6.0/torchvision 0.21.0(CPU)實際執行。為了不下載權重,驗證時把第 1 步改成 weights=None、省略 weights.transforms() 那行,其他程式一字不改:

總參數:23,512,130
可訓練:4,098(0.017%)
輸出形狀: torch.Size([8, 2])
loss = 0.7539
有 grad 的參數: ['fc.weight', 'fc.bias']
沒有 grad 的參數張量: 159 / 161
fc 權重更新了嗎? True
conv1 權重更新了嗎? False

參數數量、形狀、哪些參數有梯度、哪些權重被更新,都和載入預訓練權重時一模一樣(凍結與否跟權重的數值無關)。只有 loss 的數值取決於權重和亂數,換成預訓練權重或不同版本會不同。順帶一提:2 類亂猜的 loss 大約是 ln 2 ≈ 0.693,0.7539 很接近——新的 fc 還沒學,等於在猜。

換成真的圖片時

from PIL import Image

img = Image.open("cat.jpg").convert("RGB")
x = preprocess(img).unsqueeze(0)      # (3, 224, 224) → (1, 3, 224, 224):加上「第幾張」那一維
model.eval()
with torch.no_grad():                 # 推論不需要記錄計算圖
    probs = model(x).softmax(dim=1)   # 2 個類別的機率,加起來 = 1
print(probs)

「cat.jpg」換成你自己的圖片路徑。前處理後的形狀已用一張 640×480 的隨機圖片實測:preprocess(img) 是 torch.Size([3, 224, 224]),unsqueeze(0) 後是 torch.Size([1, 3, 224, 224])。

新出現的語法是什麼白話
from torchvision.models import resnet50, ResNet50_Weightsimport一次拿兩樣:工廠函式和權重選單
ResNet50_Weights.DEFAULT列舉(Enum)的成員「目前最好的那組權重」,torchvision 0.21 指向 IMAGENET1K_V2
weights.transforms()方法回傳這組權重配套的前處理物件
torch.manual_seed(0)函式固定亂數種子,每次重跑的隨機數都一樣
model.fc.in_features屬性舊 fc 的輸入數 2048,避免把數字寫死
nn.CrossEntropyLoss()類別 → 物件多類別分類損失;吃 logits(不用先 softmax)和整數標籤
p.numel()Tensor 的方法這個張量裡有幾個數字
sum(p.numel() for p in …)內建函式+產生器運算式把每個張量的數字個數加總;後面加 if 可以篩選
f"{total:,}"、f"{x:.3%}"f-string 格式, 加千分位;.3% 乘 100、留 3 位小數再加 %
torch.randn(8, 3, 224, 224)函式標準常態分布的亂數張量,當假圖片
torch.randint(0, 2, (8,))函式0 以上、2 以下(不含 2)的整數亂數,當假標籤
model.train()、model.eval()方法切換訓練/評估模式(BatchNorm 的行為會不同)
.clone()Tensor 的方法複製一份獨立的張量,原本的之後變了它也不變
optimizer.zero_grad()、loss.backward()、optimizer.step()方法訓練一步的三個固定動作(第 50 題詳講)
loss.item()Tensor 的方法把只有一個數字的張量轉成 Python 的 float
p.grad is not None屬性+比較這個參數有沒有算過梯度
torch.equal(a, b)函式兩個張量形狀相同、每個數字也都相同才回傳 True
.unsqueeze(0)、.softmax(dim=1)Tensor 的方法在最前面加一維;沿著類別那一維做 softmax 轉成機率
§7

一頁總結+自我檢測

這題要帶走的 6 件事
  1. resnet50 是工廠函式(小寫),回傳 ResNet 物件;nn.Linear、torch.optim.Adam 是類別,加括號直接建立物件。
  2. model.parameters() 是方法,回傳產生器,一次吐一個 nn.Parameter;ResNet-50 有 161 個參數張量、25,557,032 個數字。
  3. 行(A) param.requires_grad = False 是修改物件屬性:不算梯度 → 不會被更新 = 凍結。
  4. model.fc = nn.Linear(2048, 2) 是換零件;2048 是 layer4 的 2048 個通道經 avgpool 而來;新層預設可訓練,所以順序是先凍結 → 再換 fc → 最後建優化器。
  5. 凍結整個主幹、只訓練新分類頭 = 特徵萃取(可訓練 4,098 個,0.017%);解凍後段 = 部分微調;全部訓練 = 全面微調。
  6. requires_grad 管不到 BatchNorm 的 running 統計值;要它們也不動,得把主幹設成 eval()。
如果想改成「只解凍 layer4 和 fc」,程式怎麼改?可訓練參數有多少?
凍結迴圈和換 fc 都照舊,接著加一行 model.layer4.requires_grad_(True),優化器改成 torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=1e-4)。可訓練參數 = 14,964,736 + 4,098 = 14,968,834(約 63.66%),這叫部分微調。
把第 9 行搬到第 6 行之前(先換 fc 再凍結),什麼時候才會發現出錯?
建立 Adam 時不會報錯,前向計算也正常;要到 loss.backward() 才會出現 RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn,因為新的 fc 也被鎖住,整個模型沒有任何可訓練參數。
換成 resnet18,第 9 行的 2048 要改成多少?最保險的寫法是什麼?
resnet18 的 fc 輸入是 512(實測 Linear(in_features=512, out_features=1000))。最保險的寫法是 nn.Linear(model.fc.in_features, 2),讓程式自己讀。
如果把行(A) 刪掉、其他都不變,ResNet 主幹的權重會被改嗎?
不會被 Adam 改,因為優化器只拿到 fc 的參數(實測 conv1.weight 不變)。但反向傳播會替全部 161 個參數張量算梯度,白白浪費時間和記憶體;而且在 train 模式下,BatchNorm 的統計值本來就會變。
resnet50 和 nn.Linear,哪個是類別?怎麼確認?
nn.Linear 是類別;resnet50 是函式,type(torchvision.models.resnet50) 會印出 <class 'function'>。它回傳的物件屬於 ResNet 類別,所以 type(model) 是 torchvision.models.resnet.ResNet。
寫成 param.requires_grad == False 會怎樣?
兩個等號只是比較,什麼都沒改,也不會報錯。所有參數仍是可訓練的;換完 fc 後,反向傳播會替整個模型算梯度,雖然優化器只更新 fc,但又慢又佔記憶體,也就不是本題要的特徵萃取寫法了。