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

訓練時一切正常,上線卻分不清 b 和 d:
RandomHorizontalFlip 偷偷把字母換掉了

這段程式只有 7 行有字,卻把 Python 物件導向最重要的幾件事全用上了:類別建立物件、物件裝進 list、一個「可以像函式一樣呼叫的物件」用 for 迴圈依序套用每個轉換。這頁會一個字一個字講清楚,再用互動實驗讓你親眼看到:同一張 b,每兩次就有一次被翻成 d,標籤卻還寫著 b。

PyTorchtorchvision 0.21Pillow (PIL) ComposeRandomHorizontalFlipRandomRotation ColorJitterToTensor__call__零基礎可讀

讀完這頁,你會知道

  1. import torchvision.transforms as transforms 的 as 在做什麼,以及 transforms 和 transform 只差一個 s 卻是完全不同的東西
  2. 「類別 → 建立物件 → 呼叫」這條路線在 torchvision 長什麼樣子:__init__、self、屬性、__call__ 各自負責什麼
  3. 四個轉換的每個參數(包括沒寫出來的預設值):翻轉機率、旋轉角度範圍、亮度倍率範圍、ToTensor 的形狀與數值變化
  4. 資料增強是「每次取資料時即時隨機」做的;為什麼水平翻轉對文字是毒藥,而且驗證損失還會一路下降、把問題藏起來
§0

題目

工程師使用 PyTorch 建立 OCR 訓練 pipeline,並對手寫字母資料集套用下圖程式進行資料增強(Transform)。模型在訓練過程中顯示驗證損失(Validation Loss)持續下降,但實際部署後,系統在辨識「b/d/p/q」類字母錯誤率異常偏高。下列何者為最可能的根本原因?
第 41 題原始程式碼截圖:import torchvision.transforms as transforms,再用 transforms.Compose 串起 RandomHorizontalFlip(p=0.5)、RandomRotation(15)、ColorJitter(brightness=0.3)、ToTensor() 四個轉換
原卷附圖(程式碼已在 §3 完整重打一份)

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

先抓題目的四個關鍵字

① 手寫字母、OCR:任務是「認字」,字的方向本身就是答案的一部分。② b/d/p/q:這四個字母剛好兩兩互為鏡像,錯誤只集中在它們身上,代表問題跟「左右、上下顛倒」有關。③ 驗證損失持續下降:整體數字看起來很好,問題被平均值藏起來了。④ 資料增強(Transform):嫌疑犯就在那四個轉換裡,要找出哪一個會改變字母方向。

§1

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

這題的程式很短,但它是「物件導向」的教科書範例。完全沒寫過 Python 也沒關係,下面每個觀念都直接用本題的程式舉例,還有兩支動畫把「程式執行時發生什麼事」畫給你看,每支動畫前後都有文字說明。

① import觀念 1
把貨架搬進來
→
② 影像加工廠動畫 1
設計圖、機器、生產線
→
③ 類別・物件・呼叫觀念 2~7
→
④ 記憶體實況動畫 2
7 行程式逐行

第 3 支動畫(每個 epoch 重新抽籤,模型學到什麼)放在 §4,和抽籤機一起看。

觀念 1|import … as …:把模組拿進來,順便取個綽號

Python 本身不會處理影像。要做資料增強,得借用別人寫好的套件(package)。套件裡分成很多模組(module),模組裡面放著類別和函式。

比喻:torchvision 是一間影像器材行,transforms 是店裡「影像加工機」那一排貨架,RandomHorizontalFlip、ToTensor 是架上的各台機器的設計圖。

寫法意思之後怎麼用
import torchvision.transforms把 transforms 貨架整排搬來,但沒取綽號每次都寫全名:torchvision.transforms.ToTensor()
import torchvision.transforms as transforms搬來之後,貼一張叫 transforms 的名牌(本題寫法)transforms.ToTensor()
import torchvision.transforms as T一樣,只是綽號取得更短(網路上很常見)T.ToTensor()
from torchvision import transforms從 torchvision 拿出 transforms 這個模組transforms.ToTensor(),和本題完全等價

as 後面的名字可以隨便取,它只是「之後要用什麼名字叫這個模組」。本題剛好取了和原名一樣的 transforms,所以看起來像沒取綽號,其實有。

本題最容易看錯的地方:transforms 和 transform 只差一個 s

transforms(有 s)是模組,是那排貨架;transform(沒有 s)是第 3 行自己取的變數,是組好的那條流水線。兩個名字長得幾乎一樣,但一個是「工具的來源」,一個是「做出來的成品」。

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

觀念 1 把 transforms 比喻成一排貨架。動畫 1 把整題 7 行變成一間影像加工廠:貨架上放的是設計圖(類別);設計圖加上括號,做出一台帶著設定的機器(物件);Compose 把四台機器排成一條生產線,貼上名牌 transform。最後把一張 b 放上生產線,看它怎麼變成 d。

看的時候盯著兩樣東西:每台機器抽到什麼,以及圖片下方那張「標籤」牌有沒有跟著變。

動畫 1一間影像加工廠:設計圖、機器、生產線2 分 17 秒・有旁白,也有字幕

畫面上的抽籤數字(0.496、+8°、×1.08 和 0.758、-6.6°、×1.18)取自一次真實執行(Python 3.13、torch 2.6、torchvision 0.21,亂數種子分別固定為 0 和 1)。加工廠、機器是比喻,真正能執行的是第 41 題那 7 行。

看完動畫 1,你應該能回答:transforms.RandomHorizontalFlip 本身能翻轉圖片嗎?
不行。它是設計圖(類別),本身不能加工。要先加括號做出一台機器 RandomHorizontalFlip(p=0.5),再把圖片交給這台機器(或交給裝著它的生產線 transform)。
transforms 和 transform 各指什麼?
transforms(有 s)是 L1 拿進來的模組,也就是放設計圖的貨架;transform(沒有 s)是 L3 用 Compose 做出來的生產線物件。
同一張 b 丟進 transform 兩次,結果會一樣嗎?
通常不一樣。每呼叫一次,翻轉機、旋轉機、調光機都會重新抽籤。動畫裡第 1 次抽到 0.496(翻),第 2 次抽到 0.758(不翻),角度和亮度也都不同。
圖片被翻成 d 之後,標籤會自動改成 d 嗎?
不會。生產線只加工圖片,標籤完全沒有經過它。圖是 d、標籤還是 b,這就是本題的禍根。

觀念 2|類別(class)與物件(object):設計圖和照著做出來的機器

類別是一張設計圖,描述某種東西有哪些資料、會做哪些動作。物件是照設計圖實際做出來的那一台,也叫「實例(instance)」。在 Python 裡,類別名稱後面加上小括號,就是「照設計圖做一台出來」:

transforms.RandomHorizontalFlip            # 設計圖(類別本身),還不能用
transforms.RandomHorizontalFlip(p=0.5)     # 加括號 = 照設計圖做出一台「翻轉機」物件
flip = transforms.RandomHorizontalFlip(p=0.5)   # 做出來之後貼一張名牌叫 flip

本題一共用了 5 張設計圖,做出 5 個物件:

類別(設計圖)做出來的物件是一台…在第幾行
RandomHorizontalFlip擲銅板決定要不要左右翻轉的機器L4
RandomRotation隨機轉一個小角度的機器L5
ColorJitter隨機調亮度的機器L6
ToTensor把圖片換成數字張量的機器L7
Compose把上面 4 台機器串成一條生產線的機器L3、L8
怎麼一眼看出誰是類別?

Python 社群的命名慣例(PEP 8):類別用大寫開頭的駝峰式(Compose、ToTensor、RandomHorizontalFlip),變數、函式、模組用小寫(transform、transforms)。所以 transforms.ToTensor() 一看就知道是「從 transforms 模組拿 ToTensor 類別,做出一個物件」。

觀念 3|屬性(attribute)與方法(method):物件身上的資料、物件會做的事

物件做出來以後,可以用「物件.名稱」取用它身上的東西。中間的點讀成「的」。

屬性:沒括號,讀物件身上的資料

flip.p → 0.5(翻轉機率)
rot.degrees → [-15.0, 15.0]
cj.brightness → (0.7, 1.3)
transform.transforms → 裝 4 個物件的 list

方法:有括號,叫物件做事

flip.forward(img):叫翻轉機處理一張圖
rot.get_params([-15., 15.]):抽一個角度
flip.eval():切到評估模式(但本頁會證明它不會關掉翻轉)

上面右邊的數值都是實際在 torchvision 0.21 印出來的。你會發現:建立物件時傳進去的參數,會被存成物件的屬性,之後每次處理圖片時都拿出來用。這就是 __init__ 的工作,見觀念 4。

觀念 4|__init__ 與 self:建立物件的那一瞬間發生什麼事

每個類別裡都可以寫一個叫 __init__(前後各兩個底線)的特殊方法。只要用「類別名稱()」建立物件,Python 就會自動執行它,負責把物件初始化。下面是 Compose 的精簡版原始碼(真正的原始碼只多了一行記錄 API 使用次數的程式,邏輯完全一樣):

class Compose:
    def __init__(self, transforms):
        self.transforms = transforms
    def __call__(self, img):
        for t in self.transforms:
            img = t(img)
        return img
這一行白話
class Compose:開始畫一張叫 Compose 的設計圖。
def __init__(self, transforms):定義「建立物件時自動執行」的方法。它收兩個東西:self 和你傳進來的那個 list。
self.transforms = transforms把那個 list 存到「這個物件自己」身上,取名叫 transforms。從此之後 物件.transforms 就讀得到它。
def __call__(self, img):定義「物件被當成函式呼叫時」要做的事(觀念 5)。
for t in self.transforms:從自己身上的 list,一個一個拿出轉換物件,暫時叫它 t。
img = t(img)把圖丟給 t 處理,拿回處理好的圖,覆蓋掉舊的 img。
return img全部做完,把最後的結果交出去。

self 是什麼?就是「正在被建立、或正在做事的那個物件自己」。當你寫 transform = transforms.Compose([...]),Python 先生出一個空的 Compose 物件,然後自動呼叫 __init__(那個新物件, [...]),所以 self 指的就是之後被取名為 transform 的那個物件。你自己呼叫時不用傳 self,Python 會幫你塞。

小提醒:類別裡的 transforms 和 L1 的 transforms 不是同一個

__init__(self, transforms) 裡的 transforms 只是參數名稱,只在這個方法裡面有效,指的是你傳進來的 list。它和 L1 取綽號的模組 transforms 剛好同名,但互不相干。

觀念 5|__call__:可以「像函式一樣呼叫」的物件

一般物件只能用「物件.方法()」叫它做事。但如果類別裡寫了 __call__,物件本身就可以直接加括號呼叫,這種物件叫 callable可呼叫的:後面可以加 ( ) 執行的東西。函式、類別、定義了 __call__ 的物件都是 callable。:

transform(img)            # 看起來像呼叫函式
# Python 實際上做的是:
Compose.__call__(transform, img)   # self = transform,img = 你的圖片

本題 5 個類別的「做事邏輯」放的位置不一樣,這是用 inspect.getmro 實際查出來的:

類別繼承關係(MRO)做事的程式寫在呼叫 obj(img) 時的路線
ComposeCompose → object__call__直接執行 __call__
ToTensorToTensor → object__call__直接執行 __call__
RandomHorizontalFlipRandomHorizontalFlip → torch.nn.Module → objectforwardnn.Module 的 __call__ → 轉交給 forward
RandomRotationRandomRotation → torch.nn.Module → objectforward同上
ColorJitterColorJitter → torch.nn.Module → objectforward同上

繼承(inheritance)的意思是:子類別自動擁有父類別寫好的東西。torch.nn.Module 是 PyTorch 所有神經網路層的共同祖先,它已經寫好一個 __call__,內容是「去執行你的 forward」。所以 RandomHorizontalFlip 只要寫 forward 就好,flip(img) 會自動走到 forward(img)。這和你之後寫神經網路時 model(x) 會跑 forward(x) 是同一套機制(第 50 題會再看到)。

觀念 6|list、for 迴圈、重新指派:生產線的三個零件

  • list(串列):方括號 [a, b, c],有順序的清單,裡面可以裝任何東西,包括物件。本題的 list 裝了 4 個轉換物件,順序就是加工順序。
  • for 迴圈:for t in 清單: 會把清單裡的東西一個一個拿出來,每拿一個就執行一次縮排裡的程式。
  • 重新指派:img = t(img) 不是數學等式。意思是「先算右邊(把 img 交給 t 處理),再把結果貼上 img 這張名牌」。舊的 img 被換掉,下一圈拿到的就是加工過的版本。
  • 結尾多一個逗號沒關係:第 7 行 transforms.ToTensor(), 最後的逗號是 Python 允許的「尾逗號」,之後要再加一個轉換時比較不會漏掉逗號。
  • 括號沒關就能換行:第 3~8 行其實是同一句話 transform = transforms.Compose([…]),只是 [ 和 ( 還沒關起來,Python 會把換行當成延續。

觀念 7|亂數:每呼叫一次就重新抽一次

名字有 Random 的轉換,和 ColorJitter,每次被呼叫都會重新擲骰子。同一張圖丟進去兩次,出來的結果通常不一樣。它們用的是 PyTorch 的亂數產生器(例如 torch.rand(1)),所以只要先寫 torch.manual_seed(0) 固定亂數種子,結果就能重現。本頁實測:設同一個 seed 之後連抽 3 個角度,兩次得到完全相同的 3 個數字。

把 7 個觀念串起來:本題的「類別 → 物件 → 呼叫」路線

① 類別(設計圖)transforms.Compose、RandomHorizontalFlip、RandomRotation、ColorJitter、ToTensor
都放在 L1 拿進來的 transforms 模組裡
→
② 建立物件(做出機器)L4~L7 各做出一台轉換物件,裝進 list
transform = transforms.Compose([…]) 做出生產線物件,__init__ 把 list 存成 self.transforms
→
③ 呼叫(叫它做事)transform(img) 觸發 Compose.__call__,for 迴圈依序 img = t(img)
每個 t(img) 再走到各自的 forward 或 __call__

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

動畫 1 是比喻;觀念 4~7 講了 __init__、self、__call__ 和 for 迴圈。動畫 2 打開真正的記憶體,用一次真實執行的位址,一行一行畫出記憶體裡多了什麼,最後再把一張 b 丟進生產線,看 transform(img) 一步一步怎麼跑。

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

帶著三個問題看:① RandomRotation(15) 的 15,存進物件之後變成什麼?② transform 這張名牌是什麼時候才綁上去的?③ 執行 transform(img) 之後,原本那張 img 會被改掉嗎?

看的時候抓四種樣子:黃色格子是類別(設計圖)、綠色格子是物件、紅色名牌是名字,右上角的粉紅虛線框是 __call__ 執行時才存在、只在裡面有效的名牌。

動畫 2打開記憶體:Python 執行第 41 題時發生了什麼3 分 26 秒・有旁白,也有字幕

這支動畫照 torchvision 0.21 實際的行為畫,位址取自同一次真實執行(import 實測 7.8 秒);每台電腦、每次執行的位址都不一樣,這是正常的。為了畫面清楚,transforms 子工具箱裡的 41 張設計圖只畫了本題用到的 5 張。

看完動畫 2:RandomRotation(15) 的 15,存進物件之後變成什麼?
degrees = [-15.0, 15.0]。__init__ 把一個數字換成「−15 到 +15」的範圍。ColorJitter(brightness=0.3) 也一樣,被換算成 (0.7, 1.3)。
第 3~8 行是一整句,Python 先做哪一部分?transform 名牌什麼時候綁上?
先做方括號裡的四個轉換物件,再建立清單,再呼叫 Compose 建立生產線物件。整句右邊都算完,最後才執行等號,把 transform 名牌綁上去。
翻轉機的類別裡沒有寫 __call__,t(img) 怎麼會執行到 forward?
Python 沿著類別往上找,在上層類別 torch.nn.Module 找到 __call__,它會轉交給 forward。這叫繼承(觀念 5 的表格)。
transform(img) 執行完,原本的 img 還是那張 b 嗎?
是。每台機器都是做出一張新圖片交出去,__call__ 裡的 img = t(img) 只是把「迴圈裡的名牌」換到新圖片上(實測四圈各產生一個新物件,原圖的像素沒有變)。這也是資料集能在每個 epoch 從原圖重新抽籤的原因。
整個過程中,label 有被改嗎?
沒有。transform 只收圖片,label 從頭到尾都是 2(b)。
§2

認識這次登場的框架:PyTorch、torchvision、Pillow

PyTorch
深度學習框架,Meta(原 Facebook)AI 研究團隊主導開發,2017 年公開

負責底層:張量tensor:多維的數字陣列。一張 28×28 的灰階圖,在 PyTorch 裡就是形狀 [1, 28, 28] 的張量。運算、自動微分自動算出每個權重要往哪個方向調、調多少,不用自己推公式。、GPU 加速、神經網路的積木(torch.nn)。2022 年起移交 Linux 基金會旗下的 PyTorch 基金會管理。

比喻:工廠的動力系統和機台。

torchvision
PyTorch 官方的電腦視覺套件

把影像相關的常用工具打包好:datasets(MNIST、EMNIST、CIFAR-10 等資料集)、models(ResNet 等現成模型)、transforms(影像前處理與資料增強)。本題只用到 transforms。

比喻:專做影像的周邊設備店,跟 PyTorch 本體版本要配對(本頁用 torch 2.6 + torchvision 0.21 驗證)。

Pillow(PIL)
Python 最常用的影像處理函式庫

由早期的 PIL(Python Imaging Library)延續而來,安裝名稱是 pillow,程式裡仍寫 import PIL / from PIL import Image。torchvision 的資料集讀出來的圖片就是 PIL Image 物件,L4~L6 的翻轉、旋轉、調亮度,其實是在呼叫 Pillow 的功能。

比喻:修圖軟體,本題的前三個轉換都在「修圖」,最後 ToTensor 才把圖變成數字。

什麼是資料增強(Data Augmentation)?

訓練資料不夠多、不夠多樣時,把現有圖片做一些「不改變答案」的小變化(轉一點角度、移一點位置、調亮一點),讓模型看過更多種長相,比較不會只背死某幾張圖。關鍵是括號裡那四個字:不改變答案。本題的錯誤,就是用了一個會改變答案的變化,卻沒有改標籤。

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

torchvision ├─ datasets MNIST、EMNIST、CIFAR10 …(資料集類別,§3 最後一張卡會用到) ├─ models resnet18、resnet50 …(第 42 題會用到) ├─ transforms ← L1 拿進來、取綽號 transforms 的模組 │ ├─ Compose ← L3:把多個轉換串成一條流水線 │ ├─ RandomHorizontalFlip ← L4:50% 機率左右翻轉(本題禍首) │ ├─ RandomVerticalFlip 上下翻轉 │ ├─ RandomRotation ← L5:隨機旋轉一個角度 │ ├─ RandomAffine 旋轉+平移+縮放+傾斜(修正版會用) │ ├─ ColorJitter ← L6:隨機調亮度、對比、飽和度、色相 │ ├─ ToTensor ← L7:PIL 圖片 → 0~1 的浮點張量 │ ├─ PILToTensor PIL 圖片 → 0~255 的整數張量(不縮放) │ ├─ Normalize 標準化:(x − 平均) ÷ 標準差 │ ├─ Resize、CenterCrop、RandomCrop … │ ├─ functional hflip、rotate、adjust_brightness … 真正動手的函式 │ └─ v2 新版 transforms(§3 L7 的版本小知識) ├─ io 讀寫圖片、影片檔 └─ utils make_grid、save_image 等小工具

一張圖從硬碟走到模型的路線

Dataset[i]讀出一張 PIL 圖+標籤
→
transform(img)本題這 7 行
→
[1, 28, 28]一張圖的張量
→
DataLoader疊成一批 [64, 1, 28, 28]
→
model(x)CNN 預測 26 個字母

本題程式只負責第二格。但要理解「為什麼每個 epoch 看到的圖都不一樣」,必須知道 transform 是在第一格「讀一張圖」時被呼叫的,§3 最後一張卡會專門講。

§3

逐行解剖

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

q41.py8 行

從 torchvision 套件裡,把 transforms(影像轉換工具)這個模組整個拿進來,之後用 transforms 這個名字叫它。

逐字拆解

import
關鍵字「把後面那個模組拿進來」。
torchvision
套件PyTorch 官方的影像套件,要先安裝(pip install torchvision)才有,版本要和 torch 配對。
.transforms
模組點 = 「的」。torchvision 的 transforms 區,放著所有影像轉換類別。
as
關鍵字「取綽號」。後面接你想用的名字。
transforms
綽號之後程式裡的 transforms 就是指這個模組。剛好和原名一樣,所以看起來像多此一舉,但少了 as transforms,你就得每次寫 torchvision.transforms.X。

這一行執行時發生什麼事

Python 會找到已安裝的 torchvision,把 transforms 模組載入記憶體(第一次 import torch 相關套件可能要一兩秒),然後在目前的程式裡建立一個名字 transforms,指向這個模組物件。

執行後多了: transforms → <module 'torchvision.transforms' from '…/torchvision/transforms/__init__.py'> (一個「模組」物件,裡面有 Compose、RandomHorizontalFlip、ToTensor … 等類別)

模組裡拿東西:transforms.X

之後的 transforms.Compose、transforms.ToTensor,意思都是「transforms 模組的 Compose 類別」。這行只是把貨架搬過來,還沒有做出任何物件。

新手常踩的坑

  • 寫成 import torchvision.transform(少了 s):模組名稱是複數 transforms,會出現 ModuleNotFoundError。
  • 寫成 import torchvision.transforms.ToTensor:import 後面要接模組,ToTensor 是類別,要改用 from torchvision.transforms import ToTensor。
  • 之後把變數也取名 transforms:例如 transforms = transforms.Compose([...]),模組的綽號就被流水線物件蓋掉了,下一次寫 transforms.ToTensor() 會出現 AttributeError: 'Compose' object has no attribute 'ToTensor'(本頁實測的錯誤訊息)。所以題目才用沒有 s 的 transform 當變數名。

用 Compose 做出一條影像加工生產線,線上依序放 4 台機器(第 4~7 行做出來的 4 個轉換物件)。做好後取名叫 transform。之後只要寫 transform(img),圖片就會依序經過這 4 台機器。

逐字拆解

transform
變數自己取的名字(沒有 s),像貼在生產線上的名牌。取 train_tf、aug 都可以。
=
指派不是數學的等於。「把右邊做出來的東西,貼上左邊的名牌」。
transforms.
模組L1 取綽號的那個模組,點 = 「的」。
Compose(
類別+括號類別加括號 = 建立物件。Python 會自動執行 Compose 的 __init__。Compose 是英文「組合」的意思。
[
list 開頭Compose 唯一的參數是一個 list,裡面裝要串起來的轉換物件。
])
收尾(第 8 行)先關 list 的 ],再關 Compose 呼叫的 )。括號一關,第 3~8 行這一整句才結束。

Compose 完整參數表

參數本題的值預設值白話
transforms4 個轉換物件組成的 list(必填,沒有預設值)要依序套用的轉換。list 的順序 = 處理的順序。Compose 就只有這一個參數(inspect.signature 實測:(transforms))

建立物件那一刻:__init__ 做了什麼

回想 §1 觀念 4 的精簡原始碼。transforms.Compose([a, b, c, d]) 執行時:

  1. Python 先做出一個空的 Compose 物件。
  2. 自動呼叫 __init__(self=那個新物件, transforms=[a, b, c, d])。
  3. self.transforms = transforms:把 list 存到物件身上,變成它的屬性。
  4. 建立完成,這個物件被貼上 transform 名牌。
執行後(實際用 print 印出): >>> print(transform) Compose( RandomHorizontalFlip(p=0.5) RandomRotation(degrees=[-15.0, 15.0], interpolation=nearest, expand=False, fill=0) ColorJitter(brightness=(0.7, 1.3), contrast=None, saturation=None, hue=None) ToTensor() ) type(transform) → torchvision.transforms.transforms.Compose callable(transform) → True ← 可以像函式一樣呼叫 transform.transforms → 裝 4 個物件的 list(屬性,沒有括號) len(transform.transforms) → 4

注意 print 出來的內容:你只寫了 RandomRotation(15),它卻印出 degrees=[-15.0, 15.0] 和三個你沒寫的參數。這就是「沒寫不代表沒有,沒寫的用預設值」,下面幾張卡會一個一個講。

呼叫 transform(img) 時的實況轉播

transform(img) → Python:transform 是 Compose 物件,去 Compose 類別找 __call__ → Compose.__call__(self=transform, img=img) 第 1 圈:t = RandomHorizontalFlip 物件 → img = t(img) 可能被左右翻轉 第 2 圈:t = RandomRotation 物件 → img = t(img) 轉 −15°~+15° 第 3 圈:t = ColorJitter 物件 → img = t(img) 亮度 ×0.7~1.3 第 4 圈:t = ToTensor 物件 → img = t(img) PIL 圖 → 張量 → return img ← 現在 img 是 torch.Size([1, 28, 28]) 的 float32 張量

前三圈 img 一直是 PIL 圖片,只有第 4 圈之後才變成張量。這也是為什麼 ToTensor 通常放最後:前面的修圖都在 PIL 圖片上做完,最後再一次轉成數字。

Compose 物件身上有什麼方法

方法什麼時候被執行做什麼
__init__(self, transforms)transforms.Compose([...]) 建立物件時把 list 存成 self.transforms
__call__(self, img)transform(img) 時for 迴圈依序套用每個轉換,回傳最後結果
__repr__(self)print(transform) 時產生上面那段好讀的文字

Compose 類別自己定義的就只有這三個(實測 Compose.__dict__)。它很單純:就是一個裝著 list、會依序跑 for 迴圈的物件。

進階:Compose 不是 nn.Module

Compose 的繼承關係只有 Compose → object,不是 PyTorch 的 nn.Module,官方說明文件寫明它不支援 TorchScript(把模型編譯成可部署格式的工具)。如果要把前處理一起編譯,官方建議改用 torch.nn.Sequential(...) 串,而且裡面只能放能處理張量的轉換。考試用不到,知道就好。

新手常踩的坑

  • 忘了方括號:transforms.Compose(a, b)。Compose 只收一個 list,實測會出現 TypeError: Compose.__init__() takes 2 positional arguments but 3 were given(2 個是 self 和 transforms,你卻給了 self+2 個)。
  • 以為第 3~8 行執行完圖片就處理好了:這幾行只是「把生產線組好」,一張圖都還沒碰到。要等 transform(img) 被呼叫才會真的動手。
  • 順序放錯:例如把 Normalize 放在 ToTensor 前面,Normalize 收到 PIL 圖片,實測會出現 TypeError: img should be Tensor Image. Got <class 'PIL.Image.Image'>。

做一台「每次擲一次銅板」的翻轉機:每張圖進來,有 50% 機率被左右鏡像翻轉,50% 原樣放行。對貓狗照片沒問題,但對字母來說,b 翻過來就是 d。

逐字拆解

transforms.
模組從 transforms 貨架拿。
RandomHorizontalFlip
類別Random(隨機)+ Horizontal(水平)+ Flip(翻轉)。「水平翻轉」是左右對調,像照鏡子。
(
建立物件類別加括號,做出一台翻轉機。這個物件沒有自己的名字,直接當成 list 的第 1 個元素交給 Compose 保管。
p=0.5
關鍵字參數p 是 probability(機率):每張圖被翻轉的機率是 0.5。
),
收尾關掉括號;逗號分隔 list 裡的元素。

RandomHorizontalFlip 完整參數表

參數本題的值預設值白話
p0.50.5翻轉的機率,0~1 之間。0 = 永遠不翻,1 = 每張都翻。本題寫的值剛好就是預設值,寫 RandomHorizontalFlip() 結果完全相同

它就只有這一個參數(實測 signature:(p=0.5))。

翻轉機的內部:forward 方法(torchvision 0.21 原始碼)

def forward(self, img):
    if torch.rand(1) < self.p:
        return F.hflip(img)
    return img
  • torch.rand(1):從 0~1 之間均勻抽一個亂數。抽到 0.31 → 小於 0.5 → 翻;抽到 0.73 → 不翻。所以長期來看剛好一半被翻。
  • self.p:建立物件時存下的 0.5(屬性)。
  • F.hflip(img):真正動手翻的函式(F 是 torchvision.transforms.functional)。PIL 圖片會呼叫 Pillow 的 img.transpose(Image.FLIP_LEFT_RIGHT);如果傳進來的是張量,則把最後一個維度(寬)倒過來。
  • 沒翻的時候直接 return img,連複製都沒有,原封不動交出去。

因為它繼承 torch.nn.Module,所以 Compose 呼叫 t(img) 時會走「nn.Module 的 __call__ → forward」這條路(§1 觀念 5)。

這個物件身上: flip.p → 0.5 flip.training → True (nn.Module 都有的屬性,下面會說它對翻轉沒影響) flip(img) → 一張 PIL 圖片(大小不變,可能被翻過,也可能沒有)

為什麼它是禍首:字母方向就是答案

資料增強的前提是「變化後答案不變」。但 b 左右翻轉後就長成 d,標籤卻還寫著 b,因為 transform 只改圖片,完全不碰標籤。

翻轉方式會互換的字母對應的轉換
左右翻轉(水平)b ↔ d、p ↔ qRandomHorizontalFlip(本題)
上下翻轉(垂直)b ↔ p、d ↔ q(n 和 u 也很像)RandomVerticalFlip
轉 180°b ↔ q、d ↔ p(數字 6 ↔ 9)大角度的 RandomRotation

那模型學到什麼?假設 b 和 d 一樣多。訓練時,模型看到的「b 形狀」圖片有兩種來源:沒被翻的真 b(標籤 b),以及被翻過來的 d(標籤 d)。兩種一樣多、長得一樣,模型能做到最好的就是回答「b 50%、d 50%」,跟擲銅板一樣。部署後遇到真實的 b,當然常常認錯。這就是選項 B 說的「以 50% 機率學到鏡像錯誤標籤」。

其他字母呢?像 a、e、k 翻過來變成現實中不存在的鏡像字,不會和別的字母搶標籤,但模型白白花力氣學了沒人會寫的字形。o、x 這類左右對稱的字母翻了也沒差。§4 的抽籤機可以三種都試試看。

model.eval() 不會關掉翻轉

RandomHorizontalFlip 雖然繼承 nn.Module、也有 .training 屬性,但它的 forward 裡完全沒有檢查這個屬性。實測:對它呼叫 .eval() 之後抽 1000 次,仍然翻了 487 次。所以「訓練用增強、驗證不用」只能靠建立兩個不同的 transform 來做到,不能靠切換模式。

新手常踩的坑

  • 看到網路範例(多半是貓狗、CIFAR-10 這種一般物體)都加了水平翻轉,就照抄到文字、數字辨識上。翻轉適不適合,要看「翻完之後答案變不變」。
  • 以為 p 是翻轉的「幅度」:p 是機率,翻就是整張完整鏡像,沒有翻一半這種事。
  • 以為只影響 4 個字母、整體準確率還不錯就沒關係:這 4 個字母的準確率會掉到接近擲銅板,§4 會用數字算給你看。

做一台隨機旋轉機:每張圖進來,從 −15° 到 +15° 之間隨機抽一個角度,把圖轉過去。模擬「每個人寫字時紙張歪的角度不一樣」。

逐字拆解

RandomRotation
類別Random(隨機)+ Rotation(旋轉)。
(15)
位置參數沒寫名字,照位置對應第 1 個參數 degrees。寫成 RandomRotation(degrees=15) 完全一樣。
),
收尾這是 list 的第 2 個元素。

degrees = 15 不是「轉 15 度」

給一個數字 15,torchvision 會在 __init__ 裡把它換成範圍 [-15.0, 15.0] 存起來。每次處理圖片時,再從這個範圍均勻地抽一個角度(原始碼:torch.empty(1).uniform_(degrees[0], degrees[1]))。所以可能轉 +3.2°、−11.7°、+14.9°,每次都不一樣。

  • 正數 = 逆時針,負數 = 順時針(官方文件寫 counter-clockwise,本頁也實際轉了一張圖確認)。
  • 也可以直接給範圍:RandomRotation((-5, 10)) 表示從 −5° 到 +10° 之間抽。

RandomRotation 完整參數表

參數本題的值預設值白話
degrees15 → [-15.0, 15.0](必填)角度範圍。一個數字 x 代表 [−x, +x];也可以給 (最小, 最大)
interpolation—InterpolationMode.NEAREST轉完之後新像素怎麼算。NEAREST(最近鄰)直接抄最近的舊像素,快但邊緣鋸齒;BILINEAR(雙線性)取周圍平均,比較平滑
expand—FalseFalse:維持原本大小,轉出去的角落被裁掉;True:把畫布放大到裝得下整張轉過的圖(圖片尺寸會變)
center—None旋轉中心。None = 圖片正中央
fill—0轉完後空出來的角落要補什麼顏色。0 = 黑色
這個物件身上: rot.degrees → [-15.0, 15.0] ← 15 被轉成範圍了 rot.interpolation → InterpolationMode.NEAREST rot.expand → False rot.fill → 0 rot(img) → 一張 PIL 圖片,還是 28×28

fill=0 的小陷阱:白底的圖會長出黑角

EMNIST、MNIST 這類資料是黑底白字,背景本來就是 0,補黑色剛好看不出來。但如果你的資料是白底黑字,轉完四個角會出現黑色三角形。本頁實測:一張全白的 28×28 圖轉 15°,有 80 個像素變成 0。這時要改成 fill=255。§4 的抽籤機可以切到白底看看。

和選項 A 的關係

±15° 對手寫字其實是常見的溫和範圍:b 歪 15° 還是 b,不會變成 d。要讓 b 變成別的字母,得轉到接近 180°(b 變 q)。所以「角度太大」可能讓字有點變形,但解釋不了「錯誤剛好集中在 b/d/p/q」這個現象。

新手常踩的坑

  • 寫 RandomRotation(-15) 想表示「往順時針轉」:單一數字必須是正數,實測會出現 ValueError: If degrees is a single number, it must be positive.
  • 以為每張都轉 15 度:是從範圍裡隨機抽,抽到接近 0° 的機會和抽到 14° 一樣多。
  • 數字辨識用太大的角度(例如 180):6 和 9 會互換,和本題的 b/d 是同一類錯誤。

做一台隨機調光機:每張圖進來,從 0.7 倍到 1.3 倍之間抽一個亮度倍率,每個像素都乘上它。模擬「筆的深淺、掃描時光線不同」。

逐字拆解

ColorJitter
類別Color(顏色)+ Jitter(抖動):隨機抖動顏色相關的屬性。
brightness=0.3
關鍵字參數亮度抖動的幅度。0.3 會被換成倍率範圍 (0.7, 1.3)。
),
收尾list 的第 3 個元素。

ColorJitter 完整參數表

參數本題的值預設值白話
brightness0.3 → (0.7, 1.3)0(不調)亮度倍率範圍。給 x 代表 [max(0, 1−x), 1+x];也可以直接給 (0.8, 1.2)
contrast—0(不調)對比倍率範圍,規則同上
saturation—0(不調)飽和度(顏色鮮豔程度)。對灰階圖沒有效果(實測灰階圖調完完全不變)
hue—0(不調)色相(偏紅偏藍)。給 x 代表 [−x, +x],x 最大 0.5。灰階圖直接原樣回傳
這個物件身上(實際印出): cj.brightness → (0.7, 1.3) cj.contrast → None ← 預設 0 會被存成 None,代表「這項關掉」 cj.saturation → None cj.hue → None

亮度倍率實際上怎麼改像素

對 PIL 圖片,torchvision 呼叫 Pillow 的 ImageEnhance.Brightness:每個像素乘上倍率,超過 255 的截在 255。本頁實測一排像素 [0, 128, 255]:

倍率0(黑底)128(灰)255(白色筆畫)
× 0.7089178
× 1.00128255
× 1.30166255

黑底永遠是 0,變的只有筆畫的深淺。字的形狀、方向完全沒動,b 怎麼調都還是 b。這就是選項 C 不成立的原因。

補充:ColorJitter 每次會用 torch.randperm(4) 隨機決定亮度、對比、飽和度、色相的處理順序。本題只開了亮度,順序沒有影響。

新手常踩的坑

  • 以為 brightness=0.3 是「調暗 30%」:它是「在 0.7~1.3 倍之間隨機」,可能變暗也可能變亮。
  • 給太大:brightness=1.5 實測會變成範圍 (0.0, 2.5),倍率抽到接近 0 時整張圖幾乎全黑,等於餵模型一張空白圖。
  • 給負數或 hue 超過 0.5:實測分別出現 ValueError: If brightness is a single number, it must be non negative. 和 hue values should be between (-0.5, 0.5)。

生產線最後一站:把 PIL 圖片換成 PyTorch 能計算的張量,把 0~255 的整數縮成 0.0~1.0 的小數,形狀排成「通道 × 高 × 寬」。

逐字拆解

ToTensor
類別To(轉成)+ Tensor(張量)。
()
建立物件沒有任何參數,但括號一定要寫,因為要放進 list 的是「物件」,不是「類別」。
,
尾逗號list 最後一個元素後面多一個逗號是合法的,方便之後再加一行。

ToTensor 完整參數表

參數本題的值預設值白話
(沒有任何參數)——實測 signature:()。它只做一件固定的事,不需要設定

它做了三件事

  1. 換型別:PIL 圖片(或 NumPy 陣列)→ torch.Tensor,資料型別 float32。
  2. 縮放數值:0~255 的整數 ÷ 255 → 0.0~1.0。例如 255 → 1.0、178 → 0.698、0 → 0.0。
  3. 調整維度順序:圖片習慣的「高 × 寬 × 通道」(H×W×C) → PyTorch 習慣的「通道 × 高 × 寬」(C×H×W)。灰階圖只有 1 個通道channel:每個像素有幾個數字。灰階 1 個(亮度),彩色 RGB 有 3 個(紅、綠、藍)。,所以 28×28 的灰階圖變成 [1, 28, 28];彩色 28×28 會變成 [3, 28, 28]。
實測(28×28 灰階 PIL 圖): 輸入 PIL Image, mode='L', size=(28, 28),像素 0~255 的整數 輸出 torch.Size([1, 28, 28]),torch.float32,最小 0.0、最大 1.0

和其他轉換不一樣的地方:它不是 nn.Module

ToTensor 的繼承關係是 ToTensor → object,邏輯直接寫在 __call__,原始碼只有一行:return F.to_tensor(pic)。官方文件也註明它不支援 TorchScript。

選項 D:ToTensor 一定要放在幾何變換前面嗎?

不用。翻轉、旋轉、調亮度都能直接處理 PIL 圖片,所以傳統寫法是「先在 PIL 圖上修完,最後 ToTensor」。而且舊版 transforms 的這幾個轉換也能處理張量,本頁實測把 ToTensor 改放第一個,整條線照樣跑得動、輸出一樣是 [1, 28, 28]。順序的硬性規定只有一條:需要張量的轉換(例如 Normalize)要放在 ToTensor 後面。所以 D「必須放在所有幾何變換之前」是錯的,而且就算換順序,b 被翻成 d 的問題也不會消失。

版本小知識:transforms.v2 的新寫法

torchvision 後來推出 torchvision.transforms.v2,類別名稱大多相同(v2.RandomHorizontalFlip、v2.RandomRotation…)。其中 ToTensor 在 v2 被標為「即將淘汰」,實測呼叫 v2.ToTensor() 會跳警告,建議改成兩步:

from torchvision.transforms import v2
to_tensor = v2.Compose([
    v2.ToImage(),                           # PIL 圖片 → 影像張量(uint8,還是 0~255)
    v2.ToDtype(torch.float32, scale=True),  # 轉成 float32,scale=True 才會 ÷255 變 0~1
])

實測輸出同樣是 [1, 28, 28]、float32、0.0~1.0。另外 v2 的 Compose 和 ToImage 都繼承 nn.Module,和舊版不同。考題用的是舊版寫法,兩種都看得懂就好。

新手常踩的坑

  • 忘了括號寫成 transforms.ToTensor:list 裡放的變成「類別」,Compose 執行 t(img) 時等於在建立物件,實測錯誤是 TypeError: ToTensor.__init__() takes 1 positional argument but 2 were given。
  • 對已經是張量的東西再 ToTensor 一次:實測 TypeError: pic should be PIL Image or ndarray. Got <class 'torch.Tensor'>。
  • 以為所有輸入都會 ÷255:只有 uint8(0~255 整數)的圖會縮放;如果餵 float 型別的 NumPy 陣列,實測 255.0 進去還是 255.0 出來。
  • 想保留 0~255 整數,要用另一個類別 PILToTensor()(實測輸出 uint8、最大 255)。

第 3~8 行只是「組好一條生產線」。真正開工是在訓練時:資料集每取出一張圖,就呼叫一次 transform(img),而且每次都重新擲骰子。資料不會因此變多,但同一張圖每個 epoch 看起來都不一樣。

transform 交給 Dataset 保管(示意,不必執行)

import torchvision
from torch.utils.data import DataLoader

train_set = torchvision.datasets.EMNIST(
    root='data', split='letters', train=True,
    download=True, transform=transform)          # ← 把整條生產線交給資料集
loader = DataLoader(train_set, batch_size=64, shuffle=True)

for x, y in loader:      # 每一圈取 64 張,每張都在這時才跑 transform
    ...                  # x: [64, 1, 28, 28],y: 64 個標籤
EMNIST 參數這裡的值預設值白話
root'data'(必填)資料存放的資料夾
split'letters'(必填)要哪一份。可選 byclass、bymerge、balanced、letters、digits、mnist
trainTrueTrueTrue 拿訓練集,False 拿測試集
downloadTrueFalse本機沒有就自動下載
transformtransformNone對圖片做的處理,就是本題那條生產線
target_transform—None對標籤做的處理。本題沒有,所以圖被翻了標籤也不會跟著改

EMNIST 的 signature 是 (root, split, **kwargs),train、download、transform、target_transform 這幾個是透過 **kwargs 轉交給它的父類別 MNIST(實測 MNIST 的 signature 確認了上面的預設值)。letters 分割把大小寫合併成 26 類,類別清單實測是 ['N/A', 'a', 'b', …, 'z'],所以標籤是 1~26(b = 2、d = 4)。

Dataset 取一張圖時,transform 在這裡被呼叫

EMNIST 繼承自 MNIST,取第 i 張圖時跑的是 MNIST 的 __getitem__(torchvision 0.21 原始碼節錄):

def __getitem__(self, index):
    img, target = self.data[index], int(self.targets[index])
    img = Image.fromarray(img.numpy(), mode="L")     # 先變成 PIL 灰階圖
    if self.transform is not None:
        img = self.transform(img)                    # ← 呼叫 Compose.__call__
    if self.target_transform is not None:
        target = self.target_transform(target)
    return img, target

看到了嗎?self.transform 就是你交給它的 Compose 物件,self.transform(img) 就是觀念 5 講的「像函式一樣呼叫物件」。而標籤 target 走的是另一條路,和圖片怎麼被翻轉完全無關。

即時(on-the-fly)隨機:資料不會變多,但每次都不一樣

本頁用 200 張假圖做的 Dataset 實測: len(ds) → 200 ← 加了增強,筆數還是 200 x1, y1 = ds[0]; x2, y2 = ds[0] torch.equal(x1, x2) → False ← 同一張圖取兩次,結果不同 y1, y2 → 1, 1 ← 標籤永遠一樣 DataLoader(batch_size=64) 取一批 → x: torch.Size([64, 1, 28, 28]) float32 y: torch.Size([64]) int64
  • 資料增強不會先把資料集複製成好幾倍存起來。10 萬張的資料集,每個 epoch 還是看 10 萬次。
  • 但每次取圖都重新抽亂數,所以第 1 個 epoch 的第 0 張可能是「翻轉+轉 7°」,第 2 個 epoch 變成「沒翻+轉 −12°」。等於每個 epoch 都看到一份新的版本。
  • 這也代表:翻轉造成的「圖文不符」不是只發生一次,而是每個 epoch 都有約一半的 b/d/p/q 被教錯,模型永遠擺脫不了。§4 的動畫 3 用 4 個 epoch 演給你看。

驗證集要用另一條「不隨機」的生產線

val_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),   # 選用:0~1 → -1~1
])
val_set = torchvision.datasets.EMNIST(root='data', split='letters', train=False,
                                      download=True, transform=val_transform)

驗證和測試是在模擬「真實上線的情況」,不應該加隨機變化,同一張圖每次評估都該得到一樣的分數(實測 val_transform 對同一張圖跑兩次,結果完全相同)。完整修正版在 §5。

新手常踩的坑

  • 訓練集和驗證集共用同一個 transform:驗證集也被隨機翻轉,分數會忽高忽低,而且和上線時的真實分布不同。
  • 以為「加了增強資料就變多」,於是把 epoch 數調少:每個 epoch 的筆數沒變,只是內容每次不同。
  • 以為圖翻了標籤會自動跟著改:transform 只碰圖片。要改標籤得自己寫 target_transform 或自訂 Dataset,而且得知道「翻轉後該變成哪個字母」,非常麻煩,直接不要翻最單純。
§4

互動實驗:資料增強抽籤機

下面這台機器用 JavaScript 照 Compose 的流程真的跑一遍:50% 機率水平翻轉 → 從 [−15, 15] 抽角度旋轉(最近鄰取樣、角落補 0)→ 從 [0.7, 1.3] 抽亮度倍率 → ToTensor。先選一個字母,按「抽一次」看單張,再按「連抽 100 次」看統計。

資料增強抽籤機
28×28 的手寫風字母,放大顯示每一個像素。標籤一律照 EMNIST letters 的編號(a = 1、b = 2 …)。
transform: 底色:
原圖
① 翻轉後
② 旋轉後
③ 調亮度後
標籤 label:'b'(2)不管圖片被怎麼處理,這個數字都不會變
可以試試看

① 選 b 連抽幾次 100:圖文不符大約落在 50 次上下,這就是選項 B 說的「50% 機率」。② 換 a 或 k:沒有圖文不符,但會出現一堆鏡像怪字。③ 換 o:翻了跟沒翻一樣。④ 切到 修正版:不管哪個字母,圖文不符都是 0。⑤ 切到 白底黑字:注意旋轉後四個角的黑色三角形,那是 fill=0 補的。

動畫 3:每個 epoch 重新抽籤,模型到底學到什麼?

抽籤機一次只看一張圖。動畫 3 把鏡頭拉遠到整個訓練過程:資料集只存原圖,每個 epoch 取圖時才呼叫 transform;同一張 b 每一輪的樣子都不一樣,標籤卻永遠是 b。

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

帶著兩個問題看:① 為什麼模型看到「長得像 b」的圖,最好只能回答「b 50%、d 50%」?② 既然 b/d/p/q 學不好,驗證損失為什麼還會一路下降?

為了看清楚,動畫假設資料集只有一張 b 和一張 d,連跑 4 個 epoch。

動畫 3每個 epoch 重新抽籤:模型到底學到什麼2 分 1 秒・有旁白,也有字幕

每一格的圖都是用題目一字不改的 transform 真實抽籤做出來的(亂數種子 127)。驗證損失曲線是示意,算法和下方的示意圖相同。

看完動畫 3:資料集有 2 張圖、跑 4 個 epoch,模型一共看了幾張?資料集變成幾張?
看了 8 張(每輪 2 張),資料集還是 2 張。資料增強是取圖時即時做的,不會先複製存起來。
動畫裡第 1 個 epoch,兩張圖都長得像 d,標籤分別是什麼?
一張是 b(被翻過的 b),一張是 d(沒翻的 d)。同樣的長相、不同的答案,模型只能五五波,這就是選項 B 說的「以 50% 機率學到鏡像錯誤標籤」。
怎麼修?
訓練用的 transform 拿掉 RandomHorizontalFlip;驗證另外用一條不隨機的 transform(只有 ToTensor,或再加 Normalize)。完整修正版在 §5。

為什麼驗證損失還是一路下降?用算術看

假設 26 個字母的資料一樣多,而且模型完全分不出「真的 b」和「翻過來的 d」。那麼:

  • 其他 22 個字母不受影響,loss 會一路降到接近 0。
  • b/d/p/q 這 4 個:前面說過,模型最多只能回答「50% / 50%」,每張的 loss 最低只能到 −log(0.5) = 0.693,準確率卡在 50%。
  • 整體平均 = (22 × 很小的數 + 4 × 0.693) ÷ 26。因為大部分字母還在進步,平均值就一直往下走,看起來訓練很成功。
驗證損失示意:平均值把問題藏起來了
這不是真實的訓練紀錄,是用上面的簡化假設算出來的示意曲線:每個字母一開始都是瞎猜(loss = ln 26 ≈ 3.26),之後以相同速度往各自的下限靠近。把滑鼠移到圖上(手機用點的)可以看每一輪的數字。
epoch整體平均 loss其他 22 個字母b/d/p/q
0(訓練前)3.2583.2583.258
12.4482.4212.593
50.8300.7501.265
100.2880.1910.821
200.1400.0380.700

到第 20 輪,整體 loss 從 3.26 降到 0.14,任何人看到都會覺得訓練很順利。若其他 22 個字母準確率約 99%,整體準確率 = (22 × 0.99 + 4 × 0.5) ÷ 26 ≈ 91.5%,數字也不難看。但 b/d/p/q 只有 50%,上線後使用者寫了 b,系統有一半機會讀成 d。實際資料裡手寫的 d 不會剛好是 b 的完美鏡像,模型也許能學到一點差異,不一定剛好 50%,但「整體變好、這 4 個卡住」的現象是一樣的。

§5

回到題目:逐項對答案

(A) RandomRotation(15) 角度過大導致特徵變形,應降至 5 度以內 錯

15 度旋轉可能讓字有點變形,有一點影響,但 b 轉 15° 還是 b,不會變成另一個字母。最能解釋「錯誤剛好集中在 b/d/p/q 這種鏡像字母」的,是水平翻轉,不是旋轉角度。

(B) RandomHorizontalFlip 破壞字母方向語義,使模型以 50% 機率學到鏡像錯誤標籤 正解

b/d/p/q 這類字母方向一變就可能變成另一個字。RandomHorizontalFlip 以 50% 機率水平翻轉影像,會把某些字母變成鏡像,但標籤仍保持原字母,造成標籤和影像語意不一致。模型在驗證集可能表現好(整體 loss 被其他 22 個字母拉低),但部署遇到真實字母方向時容易混淆。

(C) ColorJitter 改變筆畫灰階,導致邊緣偵測特徵失真 錯

ColorJitter(brightness=0.3) 只是把每個像素乘上 0.7~1.3 倍,改變的是亮度或灰階深淺。字的形狀、方向沒有動,通常不會把 b 變成 d 這種方向語意錯誤。

(D) ToTensor( ) 應放在所有幾何變換之前才能保持座標對應正確 錯

torchvision transforms 中,幾何變換通常就在 ToTensor 之前處理 PIL 圖片,這正是題目的寫法;ToTensor 不必放在所有幾何變換前。只有 Normalize 這類「需要張量」的轉換必須放在 ToTensor 之後。而且順序怎麼排,翻轉造成的鏡像問題都還在。

記一張表就夠:四個轉換會不會改變「答案」?

轉換對圖片做什麼會把 b 變成別的字母嗎?對手寫字母
RandomHorizontalFlip(p=0.5)50% 左右鏡像會:b→d、p→q拿掉
RandomRotation(15)轉 −15°~+15°不會(要接近 180° 才會變 q)可保留,或降到 ±5°~10° 更保守
ColorJitter(brightness=0.3)亮度 ×0.7~1.3不會可保留
ToTensor()PIL → [1, 28, 28] 浮點張量不會(只是換格式)一定要有

怎麼修:拿掉翻轉,訓練和驗證分開兩條生產線

import torchvision.transforms as transforms

# 訓練用:只做「不會改變字母身分」的增強
train_transform = transforms.Compose([
    transforms.RandomRotation(5),                    # 小角度:-5° ~ +5°
    transforms.RandomAffine(degrees=0,               # 這一步不再旋轉(上一步做過了)
                            translate=(0.1, 0.1),    # 左右、上下各最多平移 10%(28 像素 → 最多 3 像素)
                            scale=(0.9, 1.1)),       # 放大縮小 0.9 ~ 1.1 倍
    transforms.ColorJitter(brightness=0.3),          # 筆畫深淺,保留
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),            # 0~1 → -1~1
])

# 驗證/測試用:不做任何隨機增強,每次結果都一樣
val_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])
RandomAffine 參數修正版的值預設值白話
degrees0(必填)旋轉角度範圍,規則和 RandomRotation 一樣。0 = 不轉
translate(0.1, 0.1)None(不平移)水平、垂直最多平移圖寬、圖高的幾成。28 × 0.1 = 2.8,四捨五入後實測平移量是 −3~+3 的整數像素
scale(0.9, 1.1)None(不縮放)縮放倍率從這個範圍均勻抽
shear—None傾斜(把正方形推成平行四邊形)的角度範圍
interpolation—InterpolationMode.NEAREST同 RandomRotation
fill—0空出來的地方補什麼值
center—None變換中心,None = 圖片中央
Normalize 參數修正版的值預設值白話
mean(0.5,)(必填)每個通道要減掉的平均值。灰階只有 1 個通道,所以 tuple 只有一個數(注意逗號)
std(0.5,)(必填)每個通道要除的標準差。(x − 0.5) ÷ 0.5 會把 0~1 變成 −1~1(實測 0 → −1.0、1 → 1.0)
inplace—FalseTrue 會直接改原本的張量,省記憶體
實際印出修正版: Compose( RandomRotation(degrees=[-5.0, 5.0], interpolation=nearest, expand=False, fill=0) RandomAffine(degrees=[0.0, 0.0], translate=(0.1, 0.1), scale=(0.9, 1.1)) ColorJitter(brightness=(0.7, 1.3), contrast=None, saturation=None, hue=None) ToTensor() Normalize(mean=(0.5,), std=(0.5,)) )

也可以把前兩步合成一步:transforms.RandomAffine(degrees=5, translate=(0.1, 0.1), scale=(0.9, 1.1)),實測 degrees 會變成 [-5.0, 5.0],效果相同。

補充:哪些任務可以水平翻轉?

判斷標準只有一個:翻完之後,正確答案有沒有變?

任務能不能水平翻轉原因
貓狗、車輛、一般物體分類(如 CIFAR-10)可以貓往左看、往右看都還是貓
風景、場景分類可以鏡像後還是同一種場景
物件偵測、影像分割可以,但標註要一起翻只翻圖不翻框,框會對不上物體
文字、字母、數字辨識(OCR)不行b↔d、p↔q 直接換字;其他字變成不存在的鏡像字
左右有意義的醫學影像(例如胸部 X 光,心臟通常在左側)不行,或要非常小心翻轉後變成不符合正常解剖位置的影像,標籤卻沒變
交通號誌:左轉、右轉、方向箭頭不行翻完意思剛好相反(除非同時把標籤「左轉」改成「右轉」)
§6

動手跑跑看:不用下載資料集,自己畫一個 b 來增強

下面用 Pillow 畫一個 28×28 的字母 b(黑底白字,和 EMNIST 一樣),套上題目一字不改的 transform,印出張量資訊,再畫出 8 張增強結果。複製貼到 Google Colab 就能執行(Colab 已裝好 PyTorch 和 torchvision)。

import numpy as np
import torch
import torchvision.transforms as transforms
from PIL import Image, ImageDraw, ImageFont
import matplotlib.pyplot as plt

# 1. 自己畫一個字母 b:先在 112x112 的大畫布畫,再縮成 28x28(像 EMNIST:黑底白字)
big = Image.new('L', (112, 112), color=0)            # 'L' = 灰階,0 = 全黑
draw = ImageDraw.Draw(big)                           # 拿到這張圖的「畫筆」物件
font = ImageFont.load_default(size=90)               # PIL 內建字型(較新版 Pillow 才能指定大小)
draw.text((56, 56), 'b', fill=255, font=font, anchor='mm')   # 在正中央寫白色的 b
img = big.resize((28, 28), Image.BILINEAR)           # 縮成 28x28
print('原圖:', type(img).__name__, img.mode, img.size)

# 2. 題目的 transform(一字不改)
transform = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(15),
    transforms.ColorJitter(brightness=0.3),
    transforms.ToTensor(),
])
print(transform)

# 3. 套用一次:PIL 圖片進去,張量出來
torch.manual_seed(0)                                 # 固定亂數,讓結果可重現
x = transform(img)
print('輸出:', type(x).__name__, x.shape, x.dtype)
print('最小值:', x.min().item(), ' 最大值:', round(x.max().item(), 4))

# 4. 同一張 b 抽 8 次增強,自己跑 Compose 的迴圈,順便記下有沒有翻轉
orig = np.array(img)
fig, axes = plt.subplots(2, 4, figsize=(8, 4.6))
n_flip = 0
for ax in axes.flat:
    step1 = transform.transforms[0](img)             # 只做第 1 步:水平翻轉(50%)
    flipped = not np.array_equal(np.array(step1), orig)
    out = step1
    for t in transform.transforms[1:]:               # 剩下 3 步照順序做完
        out = t(out)
    n_flip += flipped
    ax.imshow(out.squeeze(0), cmap='gray', vmin=0, vmax=1)
    ax.set_title(f"label=b, looks like {'d' if flipped else 'b'}",
                 color='red' if flipped else 'black', fontsize=9)
    ax.axis('off')
print('8 張裡被水平翻轉的張數:', n_flip)
plt.tight_layout()
plt.show()

# 5. 只看水平翻轉:抽 1000 次,數翻了幾次
flip = transforms.RandomHorizontalFlip(p=0.5)
count = sum(not np.array_equal(np.array(flip(img)), orig) for _ in range(1000))
print('1000 次裡翻轉了', count, '次')
實際跑出來的結果

這份程式在本機實際執行過(Python 3.13、torch 2.6.0、torchvision 0.21.0、Pillow 11.3,CPU)。以下是真實輸出:

原圖: Image L (28, 28)
Compose(
    RandomHorizontalFlip(p=0.5)
    RandomRotation(degrees=[-15.0, 15.0], interpolation=nearest, expand=False, fill=0)
    ColorJitter(brightness=(0.7, 1.3), contrast=None, saturation=None, hue=None)
    ToTensor()
)
輸出: Tensor torch.Size([1, 28, 28]) torch.float32
最小值: 0.0  最大值: 1.0
8 張裡被水平翻轉的張數: 7
1000 次裡翻轉了 493 次
  • 形狀 [1, 28, 28]、float32、0.0~1.0:ToTensor 的三件事(換型別、÷255、排成 C×H×W)全部驗證。
  • 8 張裡翻了 7 張:不是 4 張,因為 8 次太少,擲 8 次銅板出現 7 次正面也不奇怪。抽到 1000 次就回到 493 次,非常接近一半。
  • 因為有 torch.manual_seed(0),同樣的版本重跑會得到同樣的數字;拿掉這行,每次結果都會略有不同。
  • Pillow 太舊時 load_default(size=90) 會報錯,可以改用 ImageFont.truetype('任一字型檔.ttf', 90),字型不同不影響結論。
matplotlib 畫出的 2 列 4 欄增強結果:8 張中有 7 張的 b 被水平翻轉成 d 的樣子,標題以紅字寫 label=b, looks like d;只有一張仍是 b
上面程式實際畫出的 8 張圖(存檔後縮小顏色數嵌入本頁):標籤全是 b,紅字的 7 張看起來都是 d。
新出現的語法是什麼白話
Image.new('L', (112, 112), color=0)Pillow 的函式做一張新圖:模式 'L'(灰階)、寬高 112、填滿 0(黑)。回傳一個 Image 物件
ImageDraw.Draw(big)類別 → 物件做出一支「只畫在 big 上」的畫筆物件
ImageFont.load_default(size=90)Pillow 的函式拿 Pillow 內建的字型,大小 90
draw.text(xy, 'b', fill=255, font=font, anchor='mm')畫筆物件的方法在 xy 寫字;fill=255 是白色;anchor='mm' 讓 xy 對準文字的正中央
big.resize((28, 28), Image.BILINEAR)Image 物件的方法縮放,回傳一張新圖,原本的 big 不變
torch.manual_seed(0)函式固定 PyTorch 的亂數種子,讓隨機結果可重現
transform.transforms[0]、[1:]屬性+索引/切片讀 Compose 物件身上的 list,取第 0 個(翻轉機)、取第 1 個到最後
np.array_equal(a, b)NumPy 函式兩個陣列是否每個數字都相同。不同 → 代表被翻轉了
out.squeeze(0)張量的方法拿掉第 0 維那個大小為 1 的通道:[1, 28, 28] → [28, 28],imshow 才畫得出來
fig, axes = plt.subplots(2, 4)matplotlib 函式開一張圖,切成 2 列 4 欄共 8 個小格子(Axes 物件)
axes.flat屬性把 2×4 的格子攤平成一排,for 迴圈可以一格一格拿
ax.imshow(..., cmap='gray', vmin=0, vmax=1)Axes 物件的方法用灰階畫圖,0 畫成黑、1 畫成白
f"…{'d' if flipped else 'b'}…"f-string+條件運算式大括號裡的運算結果會塞進字串;A if 條件 else B 是一行版的 if
sum(… for _ in range(1000))產生器運算式跑 1000 次,把每次的 True(=1)/False(=0)加總,就是翻轉次數
§7

一頁總結+自我檢測

這題要帶走的 5 件事
  1. transforms(模組)≠ transform(變數)。transforms.Xxx(...) = 從模組拿類別、加括號建立物件。
  2. Compose 物件用 __init__ 把 list 存成 self.transforms,被呼叫時用 __call__ 的 for 迴圈依序 img = t(img)。
  3. 參數換算:RandomRotation(15) → 角度 [−15, 15];ColorJitter(brightness=0.3) → 倍率 (0.7, 1.3);ToTensor() → [1, 28, 28]、float32、0~1。
  4. 資料增強是每次取資料時即時隨機做的,筆數不變;驗證集要用另一個不隨機的 transform,.eval() 關不掉它。
  5. 增強不能改變答案。水平翻轉讓 b↔d、p↔q,標籤卻沒變,模型對這 4 個字母只能擲銅板;整體驗證損失被其他字母拉低,問題就被藏起來了。
如果有人寫成 transforms = transforms.Compose([...]),下一行再寫 transforms.ToTensor() 會怎樣?
模組的綽號 transforms 被 Compose 物件蓋掉了。Compose 物件身上沒有 ToTensor,實測會出現 AttributeError: 'Compose' object has no attribute 'ToTensor'。所以變數要取別的名字,例如題目的 transform。
為什麼 transform(img) 可以像函式一樣呼叫?它實際上執行了什麼?
因為 Compose 類別定義了 __call__。transform(img) 等於 Compose.__call__(transform, img),裡面用 for 迴圈把 self.transforms 的 4 個轉換依序套在 img 上,最後回傳張量。
把 RandomHorizontalFlip 換成 RandomVerticalFlip,哪幾對字母會出事?
上下翻轉會讓 b↔p、d↔q 互換(n 和 u 也很像)。一樣是「改變答案卻不改標籤」,對文字辨識一樣不能用。
資料集有 10 萬張圖,加了這 4 個增強,每個 epoch 模型會看到幾張?
還是 10 萬張。增強是 Dataset 每次取圖時即時做的,不會把資料複製成好幾份;差別在於每個 epoch 看到的是重新抽亂數後的新版本。
訓練完呼叫 model.eval(),驗證時就不會被隨機翻轉了嗎?
不會。RandomHorizontalFlip 的 forward 沒有檢查 training 屬性,本頁實測對它呼叫 .eval() 後抽 1000 次仍翻了 487 次。驗證集要另外建立只有 ToTensor(和 Normalize)的 val_transform。
一張 28×28 的灰階 PIL 圖經過題目的 transform,輸出的形狀、型別、數值範圍是?如果要加 Normalize,放哪裡?
torch.Size([1, 28, 28])、torch.float32、0.0~1.0。Normalize 需要張量,必須放在 ToTensor 之後。