本文與 Claude AI 共同編輯
Receptive field (感受野, 以下簡稱 RF) 大概是 CNN 教科書裡最早出現、也最容易被當成「知道就好」的概念之一: 某一個輸出元素, 是由多少個輸入元素決定的?
但如果你試過把一個離線訓練好的模型搬到邊緣裝置上做串流推論, 就會發現 RF 從「知道就好」的常識, 變成整個轉換流程的地基, 它直接決定每個 node 要留多大的 buffer、以及整條 pipeline 會有多少 latency.
這篇筆記想把 RF 的計算講清楚, 並且對照兩種看起來方向完全相反、實際上等價的算法: 一種是正向地把三元組 $[S, a, b]$ 一層一層往下帶, 另一種是 Distill 那篇 Computing Receptive Fields of Convolutional Neural Networks 的反向遞迴.
為什麼串流需要 RF
PyTorch 訓練時的 graph 是 offline 的: 整段輸入一次餵進去. 以音訊為例, 一次吃 6 秒的音檔, 卷積在整條時間軸上滑過, 該補的 padding 補好, 輸出一次算完. 這在 GPU 上很自然, 但搬到邊緣裝置就有兩個麻煩:
- 記憶體: 6 秒 16 kHz 的音訊是 96000 個取樣點, 中間每一層的 feature map 都得整條留著
- 延遲: 要等 6 秒的資料到齊才算得出第一個輸出, 這不是 real time
實務上的做法是把模型設計成 causal 的, 並且把 offline graph 轉成 streaming graph: 每次只餵一小段 (一個 hop), 每個 node 內部保留一小塊 keeping buffer, 把上次算過、這次還用得到的邊界資料留下來. 轉換之後, 記憶體用量從「整段序列」降到「每個 node 只留它真正需要的那幾幀」, 而且每收到一個 hop 就能吐出對應的輸出, 達到 real time 的效果.
要做這個轉換, 不管是編譯器自動做還是人工做, 都必須對每一個 node 回答兩個問題:
- 這個 node 的輸出幀 $j$, 最早用到哪一幀輸入? → 決定 keeping buffer 要留多少過去的幀
- 最晚用到哪一幀輸入? → 決定要等多少未來的幀才算得出它, 也就是 lookahead latency
這兩個端點圍出來的區間, 就是這個 node 的 receptive field.
這裡要強調一件事: 串流需要的是理論 RF, 不是 effective RF. Luo et al. 指出輸入對輸出的影響力其實會往視窗邊緣大致呈高斯衰減, 只佔滿理論 RF 的一小部分, 所以理論值高估了「實際上重要」的範圍. 但對 buffer 來說沒得商量, 必須保留每一個可能有影響的幀, 少留一幀, 串流的結果就跟 offline 對不起來了.
先建立直覺
最簡單的例子是一個 $k = 3$、stride 1、padding 1 的 conv: 輸出幀 $j$ 讀輸入幀 $j-1, j, j+1$, 要保留 1 幀過去、等 1 幀未來. 疊兩層, 輸出幀 $j$ 讀第一層的 $j-1, \dots, j+1$, 而它們各自又讀輸入的前後一幀, 合起來就是 $j-2, \dots, j+2$:
|
|
5 幀的視窗、2 幀的未來. 整篇文章要做的, 就是把這個推理寫成一般的公式.
三個數字: [S, a, b]
先定義記號. 把每個 node 配上一個累積 stride $S$ 與兩個偏移 $a \le b$, 意思是: 該 node 的輸出幀 $j$ 讀取網路輸入的第
$$[\, j \cdot S + a,\ \ j \cdot S + b \,]$$ 幀. 就這樣, 三個數字把一個 node 對輸入的依賴講完了.
錨點 $j \cdot S$. 輸出每走 1 幀, 輸入走 $S$ 幀, 所以輸出幀 $j$ 在時間上對應到輸入幀 $j \cdot S$, 稱為它的錨點. $a$ 與 $b$ 是相對錨點的偏移: 視窗從錨點往前 $-a$ 幀開始、到錨點往後 $b$ 幀結束. 前一節的兩層例子就是 $S = 1,\ a = -2,\ b = 2$.
由此得到我們真正想要的兩個量:
- $\mathrm{rf} = b - a + 1$: 視窗的幀數, 也就是 keeping buffer 的大小
- $\mathrm{lookahead} = \max(b, 0)$: 要算出輸出 $j$, 輸入必須到達 $j \cdot S + b$, 也就是比錨點多等 $b$ 幀
$\mathrm{lookahead}$ 取 $\max$ 是因為 $b$ 可以是負的, 輸出只依賴錨點之前的輸入, 那就完全不必等, latency 是 0 而不是負數.
每個 op 都有自己的 [S, a, b]
一個 op 只要能寫成「輸出幀 $j$ 讀輸入幀 $j \cdot s - p,\ \dots,\ j \cdot s - p + k - 1$」, 它就有自己的 $(k, s, p)$, 也就有自己的 $[S, a, b]$ (這裡 $p$ 指的是左 padding; 右 padding 只決定序列結尾多吐幾幀, 不改變某個輸出讀的是哪些輸入, 所以完全不會出現在公式裡). 把 op 單獨接在網路輸入上, 從 $[1, 0, 0]$ 出發套一次下一節的規則, 就得到:
| op | $k,\ s,\ p$ | $[S,\ a,\ b]$ | $\mathrm{rf}$ / $\mathrm{lookahead}$ |
|---|---|---|---|
ReLU / Sigmoid 等 pointwise |
$1,\ 1,\ 0$ | $[1,\ 0,\ 0]$ | 1 / 0 |
Conv1d(k=3, s=1, p=1) ("same") |
$3,\ 1,\ 1$ | $[1,\ -1,\ 1]$ | 3 / 1 |
Conv1d(k=3, s=2, p=1) |
$3,\ 2,\ 1$ | $[2,\ -1,\ 1]$ | 3 / 1 |
causal conv: F.pad(x,(2,0)) + Conv1d(k=3, p=0) |
$3,\ 1,\ 2$ | $[1,\ -2,\ 0]$ | 3 / 0 |
Conv1d(k=3, d=2, p=2) (dilated) |
$5,\ 1,\ 2$ | $[1,\ -2,\ 2]$ | 5 / 2 |
MaxPool1d(k=2, s=2) |
$2,\ 2,\ 0$ | $[2,\ 0,\ 1]$ | 2 / 1 |
純延遲 F.pad(x,(2,0)) |
$1,\ 1,\ 2$ | $[1,\ -2,\ -2]$ | 1 / 0 |
GRU / LSTM |
— | $[1,\ -\infty,\ 0]$ | $\infty$ / 0 |
幾個值得一提的:
- Dilation 走 $k_{\text{eff}}$. 膨脹率 $d$ 的 kernel 實際涵蓋 $k_{\text{eff}} = d(k-1) + 1$ 個元素, 所以公式裡的 $k$ 一律換成 $k_{\text{eff}}$ 就好, 不需要另外一套規則.
- Causal conv 的 lookahead 是 0. 把 padding 全部補在左邊 ($p = k - 1$) 會讓 $b = k - 1 - p = 0$. 這正是「causal」的定義, 而它在 $[S, a, b]$ 的語言裡就只是 $b \le 0$.
- $b$ 可以是負的. 純延遲
F.pad(x, (2,0))的輸出幀 $j$ 就是輸入幀 $j - 2$ (前兩幀是補出來的 0), 所以 $a = b = -2$: 輸出比錨點還早確定, 一幀都不用等. - RNN 讓 $a = -\infty$. GRU / LSTM 的輸出依賴整段過去, 視窗左端延伸到序列開頭, $\mathrm{rf} = \infty$. 但 $b$ 不受影響, RNN 不會讓你多等未來的幀.
- Pointwise 的 op 直接沿用輸入的 $[S, a, b]$, 因為它不讀相鄰幀. 這裡也順帶回答一個常見的問題: layer normalization 算不算? 如果它沿 channel 維做, 那對時間軸而言就是 pointwise ($k = 1$); 如果是沿整條時間軸做 global normalization, 那等於 $k = \infty$, 跟 RNN 一樣讓 $a = -\infty$. 重點是只要能定義出 $(k, s, p)$, 就能算 RF, 跟這個 op 在做什麼運算無關.
正向規則
現在把規則推出來. 假設某個 node 已經有了 $[S, a, b]$, 也就是它的輸出幀 $i$ 讀網路輸入幀 $[\, i \cdot S + a,\ i \cdot S + b \,]$. 後面接一個 window 層, 參數是 $(k, s, p)$. 由定義, 新的輸出幀 $j$ 讀這個 node 的第
$$i = j \cdot s - p,\ \ \dots,\ \ j \cdot s - p + k - 1$$ 幀. 把最左的 $i$ 代進 $i \cdot S + a$、最右的 $i$ 代進 $i \cdot S + b$, 就得到它讀的網路輸入幀的兩個端點; 再整理成「$j \cdot$ 新 stride $+$ 偏移」的形式:
$$\begin{aligned} (j s - p) \cdot S + a &= j \cdot (S s) + (a - p S) \\ (j s - p + k - 1) \cdot S + b &= j \cdot (S s) + \big(b + (k - 1 - p) S\big) \end{aligned}$$ 對照定義 $[\, j \cdot S' + a',\ j \cdot S' + b' \,]$, 就直接讀出了正向規則 (右邊的 $S$ 都是更新前的值):
$$S' = S \cdot s, \qquad a' = a - p \cdot S, \qquad b' = b + (k - 1 - p) \cdot S$$ 三條式子在做的是同一件事: 把這一層的量 (以這一層的輸入幀為單位) 乘上 $S$, 換算成網路輸入幀.
- $S' = S \cdot s$: 這一層每走一步跳 $s$ 幀, 而這一層的每一幀又相當於 $S$ 個網路輸入幀
- $a' = a - p \cdot S$: 左 padding 讓視窗往錨點左邊多伸 $p$ 幀, 換算成 $p \cdot S$ 個網路輸入幀
- $b' = b + (k - 1 - p) \cdot S$: 視窗從左端往右寬 $k - 1$ 幀, 扣掉 padding 往左推的 $p$ 幀, 右端比錨點多出 $k - 1 - p$ 幀
兩式相減, 得到只關心大小時的版本:
$$\mathrm{rf}' = \mathrm{rf} + (k - 1) \cdot S$$ padding 消失了, 它改變的是讀哪些輸入幀, 不是讀幾幀. 這跟直覺一致.
起點是網路輸入自己: 它的輸出幀 $j$ 就是輸入幀 $j$, 所以
為什麼要正向走
Distill 文章的所有遞迴式都是從輸出往輸入走 ($l \to l-1$), 而這裡是反過來的: 依拓撲順序從網路輸入出發, 把 $[S, a, b]$ 一層一層往下帶.
選正向的理由很實際: 一個 graph 可能有很多個輸出, 但通常只有一個帶時間軸的輸入. 正向走一次, 就同時得到了每一個 node 的答案; 反向的話, 每個輸出都得各走一次. 而串流轉換需要的恰恰是「每個 node 的 buffer 要多大」, 不是只有最後那個輸出的數字.
另一個好處是, $[S, a, b]$ 把大小與位置一起帶著走. 反向那套要算位置得另外再跑一組遞迴 (下一節的 $u, v$ 或 $S_l, P_l$).
走一個例子
拿一條 5 層的鏈: 全部 $k = 3$、"same" padding ($p = 1$), strides 依序是 $1, 1, 2, 1, 1$.
|
|
|
|
逐層手算對一下:
$$\begin{array}{llll} \texttt{L1}: & S = 1 \cdot 1 = 1, & a = 0 - 1 \cdot 1 = -1, & b = 0 + (3 - 1 - 1) \cdot 1 = 1 \\ \texttt{L2}: & S = 1 \cdot 1 = 1, & a = -1 - 1 \cdot 1 = -2, & b = 1 + 1 \cdot 1 = 2 \\ \texttt{L3}: & S = 1 \cdot 2 = 2, & a = -2 - 1 \cdot 1 = -3, & b = 2 + 1 \cdot 1 = 3 \\ \texttt{L4}: & S = 2 \cdot 1 = 2, & a = -3 - 1 \cdot 2 = -5, & b = 3 + 1 \cdot 2 = 5 \\ \texttt{L5}: & S = 2 \cdot 1 = 2, & a = -5 - 1 \cdot 2 = -7, & b = 5 + 1 \cdot 2 = 7 \end{array}$$ 注意 L4 的 $k = 3$ 對 $b$ 的貢獻是 $1 \cdot 2 = 2$ 幀而不是 1 幀: L3 已經把 stride 墊到 2, L4 的一幀等於輸入的兩幀. RF 的成長速度取決於它前面累積了多少 stride, 這是整套公式最關鍵的直覺.
因為是對稱 padding, 這裡剛好 $a = -b$, 所以 RF 序列是 $3, 5, 7, 11, 15$: 每層往兩邊各長一樣多.
反向的視角: Distill 文章的公式
記號與遞迴
Distill 那篇把網路看成 $L$ 層的鏈, 第 $l$ 層有 kernel $k_l$、stride $s_l$、左 padding $p_l$, 輸入 feature map 為 $f_{l-1}$、輸出為 $f_l$ ($f_0$ 是網路輸入、$f_L$ 是輸出).
定義 $r_l$ 為「$f_L$ 的一個輸出元素, 需要 $f_l$ 的多少個元素」. 若我們關心 $f_l$ 上的 $r_l$ 個連續元素 $u, \dots, u + r_l - 1$, 它們在 $f_{l-1}$ 裡從 $u s_l - p_l$ 讀到 $(u + r_l - 1)s_l - p_l + k_l - 1$, 共 $s_l(r_l - 1) + k_l$ 個. 於是從 $r_L = 1$ 出發反向走:
$$r_{l-1} = s_l \cdot r_l + (k_l - s_l) \tag{1}$$ 展開得到封閉式:
$$r_0 = \sum_{l=1}^{L} (k_l - 1) \prod_{i=1}^{l-1} s_i + 1 \tag{2}$$ 位置也有一組類似的遞迴. 設輸出上關心的元素索引為 $u_L, \dots, v_L$, 則
$$v_0 = v_L \prod_{i=1}^{L} s_i - \sum_{l=1}^{L} (1 + p_l - k_l) \prod_{i=1}^{l-1} s_i \tag{6}$$ 文章還給 Eq.(5) 裡的兩個量取了名字, effective stride $S_l$ 與 effective padding $P_l$:
$$S_l = \prod_{i=l+1}^{L} s_i, \qquad P_l = \sum_{m=l+1}^{L} p_m \prod_{i=l+1}^{m-1} s_i$$ 它們同樣滿足反向遞迴 (起點 $S_L = 1$、$P_L = 0$):
$$P_{l-1} = s_l \cdot P_l + p_l \tag{8}$$ 於是單一輸出元素的 Eq.(5) 簡化成 $u_0 = -P_0 + u_L \cdot S_0$, 而 RF 的中心是
$$c_0 = -P_0 + u_L \cdot S_0 + \frac{r_0 - 1}{2} \tag{9}$$注意: 兩邊的「RF」定義不一樣
這是最容易搞混的地方, 值得特別標出來.
正向那套問的是:
要跑出 $f_l$ 的一幀, 需要多少 $f_{\mathbf{0}}$ 的幀? 輸入端固定在 $f_0$, 變動的是輸出端
Distill 文章的 $r_l$ 問的則是:
要跑出 $f_{\mathbf{L}}$ 的一幀, 需要多少 $f_l$ 的幀? 輸出端固定在 $f_L$, 變動的是輸入端
所以同樣寫成「第 $l$ 層的 RF」, 兩者是不同的東西. $r_3$ 跟 $\mathrm{rf}_3$ 沒有任何理由要相等, 實際上也不相等. 兩者只在端點重合:
$$r_0 \;=\; \mathrm{rf}_L$$ 都是「整個網路的輸出一幀, 要看幾幀輸入」. 接下來要證的等價性, 指的就是這件事.
還有一個記號上的小坑: Distill 的 $S_l$ 是從第 $l$ 層算到輸出的 stride 乘積, 而正向規則裡的 $S$ 是從網路輸入算到目前這層的乘積. 兩者在端點也對得上 ($S_0 = \prod_{i=1}^{L} s_i$ 就是走完全部層之後的 $S$), 但中途的值同樣不能直接對照.
同一條鏈的反向遞迴
把前面〈走一個例子〉那條 5 層鏈拿來反向跑一次:
|
|
|
|
$r_0 = 15$、$S_0 = 2$、$P_0 = 7$. 對照正向的最後一列: $\mathrm{rf} = 15$、$S = 2$、$-a = 7$, 完全相同.
同時也看到了中途的值確實對不上: 正向的 $\mathrm{rf}_3 = 7$, 反向的 $r_3 = 5$. 兩個都對, 只是在回答不同的問題.
等價性
大小
先解反向遞迴 Eq.(1). 宣稱
$$r_l = \sum_{m=l+1}^{L}(k_m - 1) \prod_{i=l+1}^{m-1} s_i \;+\; 1$$
$l = L$ 時右邊是空和加 1, 等於 $r_L = 1$, 成立. 假設對 $l$ 成立, 代進 Eq.(1):
$$\begin{aligned} r_{l-1} &= s_l \left[ \sum_{m=l+1}^{L}(k_m - 1)\prod_{i=l+1}^{m-1} s_i + 1 \right] + k_l - s_l \\ &= \sum_{m=l+1}^{L}(k_m - 1)\prod_{i=l}^{m-1} s_i \;+\; (k_l - 1) + 1 \\ &= \sum_{m=l}^{L}(k_m - 1)\prod_{i=l}^{m-1} s_i \;+\; 1 \end{aligned}$$ 最後一步是把 $k_l - 1$ 寫成 $(k_l - 1)\prod_{i=l}^{l-1} s_i$ (空乘積為 1) 併回和式. 這正是宣稱把 $l$ 換成 $l-1$ 的樣子, 所以歸納成立. 取 $l = 0$ 就得到 Eq.(2).
接著是關鍵的一步, 把封閉式的最後一項拆出來:
$$r_0 \;=\; \underbrace{\left[ \sum_{l=1}^{L-1}(k_l - 1)\prod_{i=1}^{l-1} s_i + 1 \right]}_{\text{把第 } L \text{ 層拿掉之後的封閉式}} \;+\; (k_L - 1)\prod_{i=1}^{L-1} s_i$$ 中括號裡那一坨, 恰好就是「把最後一層拿掉的那個網路」套用 Eq.(2) 的結果, 也就是正向的 $\mathrm{rf}_{L-1}$. 所以
$$\mathrm{rf}_L = \mathrm{rf}_{L-1} + (k_L - 1)\prod_{i=1}^{L-1} s_i$$ 而正向規則給的是 $\mathrm{rf}' = \mathrm{rf} + (k-1) \cdot S$, 其中正向走到第 $L$ 層時, 累積的 $S$ 正好是 $\prod_{i=1}^{L-1} s_i$. 兩式一模一樣.
換句話說: 正向遞迴就是反向遞迴的封閉解按層拆開來看. 反向那條 Eq.(1) 每一步都在做「乘 $s_l$ 再加 $k_l - s_l$」, 看不出每一層各自貢獻多少; 解開之後才看得出來, 第 $l$ 層的貢獻是乾淨的 $(k_l - 1)\prod_{i<l} s_i$, 而且跟後面的層無關, 這就是正向能夠一層一層往下累加的原因.
位置
大小對上了, 但正向的 $[S, a, b]$ 帶的資訊更多, 它還給了位置. 這部分同樣對得上.
正向走到第 $l$ 層時, 手上的 $S$ 是 $\prod_{i=1}^{l-1} s_i$, 所以走完整條鏈之後:
- $S$ 每層乘上 $s_l$, 最後是 $\prod_{i=1}^{L} s_i = S_0$
- $-a$ 每層加上 $p_l \prod_{i<l} s_i$, 最後是 $\sum_{l=1}^{L} p_l \prod_{i=1}^{l-1} s_i = P_0$
- $b$ 每層加上 $(k_l - 1 - p_l)\prod_{i<l} s_i$, 最後是 $-\sum_{l=1}^{L}(1 + p_l - k_l)\prod_{i=1}^{l-1} s_i$, 也就是 $v_L = 0$ 時的 $v_0$
第二條特別值得看一眼. Eq.(8) 的 $P_{l-1} = s_l P_l + p_l$ 從輸出端往前收, 展開是
$$P_0 = p_1 + s_1\big(p_2 + s_2(p_3 + \cdots)\big) = \sum_{l=1}^{L} p_l \prod_{i=1}^{l-1} s_i$$ 跟正向由左往右直接累加的是同一個和, 只是結合順序相反, 就像用 Horner 法算多項式跟直接展開算, 結果一樣.
對照表
| 正向 $[S, a, b]$ | Distill |
|---|---|
| $S$ | effective stride $S_0$ (Eq. 7) |
| $-a$ | effective padding $P_0$ (Eq. 8) |
| $\mathrm{rf} = b - a + 1$ | $r_0$ (Eq. 2) |
| $j \cdot S + a$、$j \cdot S + b$ | $u_L = v_L = j$ 時的 $u_0$、$v_0$ (Eq. 5–6) |
| $(a + b)/2$ | $j = 0$ 時的中心 $c_0$ (Eq. 9) |
| $\mathrm{lookahead} = \max(b, 0)$ | 沒有對應 |
最後一列是唯一一個 Distill 文章裡沒有的量, 而原因也不難理解: 影像的 RF 分析在意的是大小與對齊, 沒有時間軸, 自然沒有「要等多久」這回事. 串流才需要把視窗的右端單獨拿出來看.
用 autograd 驗證
公式對不對, 其實可以直接問 autograd: 對每個輸出幀求它對輸入的梯度, 梯度非零的輸入幀, 就是它真正依賴的幀.
|
|
|
|
在序列內部 ($j = 8, 15$) 兩者完全相同, 公式是 tight 的, 不多不少. 在邊界上公式會伸出去 ($j = 0$ 的左端 $-7$、$j = 1$ 的左端 $-5$), 那些位置在 offline 推論時是補出來的 0, 不是真的輸入幀. 這正是 effective padding 這個名字的意思.
小結, 以及還沒講的部分
有了每個 node 的 $[S, a, b]$, 我們能馬上感受到兩件事:
- $\mathrm{rf} = b - a + 1$ → 這個 node 被多少輸入幀所影響
- $\mathrm{lookahead} = \max(b, 0)$ → 這個 node 貢獻多少 latency
而且任何 op 只要能定義出 $(k, s, p)$ 的概念, 就能算它的 RF, 跟它在做什麼運算無關. 這也是為什麼這套東西可以寫成一個對 graph 做分析的 pass, 而不是針對每種 layer 各寫一套.
這篇只處理了 single path. 真實的模型有 residual add、有 U-Net 的 skip connection, 兩條 $[S, a, b]$ 不同的路徑在 add 相遇時會發生什麼事? Distill 文章給的對齊條件是, 對所有的路徑配對 $(i, j)$ 與所有的 $l$:
$$S_l^{(i)} = S_l^{(j)}, \qquad -P_l^{(i)} + \frac{r_l^{(i)} - 1}{2} \;=\; -P_l^{(j)} + \frac{r_l^{(j)} - 1}{2}$$ 翻成 $[S, a, b]$ 的語言會漂亮很多. 第二條的左右兩邊分別就是 $a + \frac{b-a}{2} = \frac{a+b}{2}$, 所以整個條件是:
$$S^{(i)} = S^{(j)}, \qquad \frac{a^{(i)} + b^{(i)}}{2} = \frac{a^{(j)} + b^{(j)}}{2}$$ 相同的 $S$、相同的中心. 講白話就是: 兩條路徑的取樣率要一樣, 而且每個輸出位置看的視窗要對準同一個中心點. $S$ 不同表示兩邊的時間軸根本沒對齊; 中心不同則表示視窗一邊偏過去了, 相加之後每個輸出幀的依賴範圍會變得不一致, 這時候就得靠插入 delay 之類的手段把它們橋回來. 這部分留待之後再寫.
參考資料
- Araujo, Norris & Sim, Computing Receptive Fields of Convolutional Neural Networks, Distill, 2019
- Luo et al., Understanding the Effective Receptive Field in Deep Convolutional Neural Networks, NIPS 2016
- 作者釋出的參考實作: google-research/receptive_field