NumPy 擴張機制(一)


使用 NumPy 在處理資料時,可以將資料看成一個整體進行運算,例如,可以對一組數字全部加 1:

import numpy as np

nums1 = np.array([1, 2, 3])
nums2 = nums1 + 10

從程式設計者的角度來看,nums 是一組數字,怎麼可以直接加上一個純量呢?然而,換個角度來想,
你是要對這組數字進行相同的操作,直接加 10,在表達上不是就夠了?

好吧!以上的說明文字,是從〈陣列程式設計〉中複製過來的,當時純綷是從程式碼的表述來談,那麼你有沒有想過,為什麼 NumPy 可以這麼做?

你可能會從純量計算的角度來看這件事,例如:

nums1 = [1, 2, 3]
nums2 = [n + 10 for n in nums1]

然而,NumPy 也可以將不同維度的陣列進行運算:

import numpy as np

a = np.arange(4).reshape(2, 2)
print(a + 10)
print(a + np.array([1, 3]))
print(a + np.array([[2, 4], [6, 8]]))
print(a + np.array([[10, 20]]))
print(a + np.array([[100]]))
print(a + np.array([200]))

想想看以上的計算各會什麼結果呢?而且並非各種維度都能混在一起計算,底下就會出錯:

import numpy as np

a = np.arange(4).reshape(2, 2)
print(a + np.array([1, 2, 3]))

錯誤訊息會是:

ValueError: operands could not be broadcast together with shapes (2,2) (3,)

從訊息中可以看到 broadcast 這個字眼,對於不同維度的資料,NumPy 有一套複雜的運算機制,稱為 Broadcasting,中文常翻為擴張,在〈Broadcasting〉可以看到擴張的四個規則,以及輸入陣列可擴張的(broadcastable)三個條件…呃…我相信第一次看的人,都會冒出一個念頭「這啥鬼?」

你有沒有注意到,〈Broadcasting〉的說明文件,是〈Universal functions〉的一部份呢?Universal functions 文件的一開頭就寫到:

A universal function (or ufunc for short) is a function that operates on ndarrays in an element-by-element fashion, supporting array broadcasting, type casting, and several other standard features.

理解擴張的出發點,其實就在 operates on ndarrays in an element-by-element fashion 這段,無論是幾維,形狀為何,Universal 函式底層關心的,都是單一元素如何處理,在〈NumPy 的 Universal 函式〉中,透過 frompyfunc 包裹普通的 Python 函式時,你指定的普通 Python 函式就是這麼做的。

先從簡單的開始好了,對於 a = np.array([1, 2, 3])b = np.array([4, 5, 6]),若透過 np.add 相加(也就是相當於 a + b),np.add 這個 Universal 函式,會一對一地處理元素相加,這沒有問題!

如果是 a = np.array([1, 2, 3])b = 1 呢?兩者維度不同,np.add 找出維度較大的陣列 a,讓 b 的維度與之相同,你可以想像 b 被視為 np.array([1]) 了。

現在維度相同了,而形狀不同,一個是 (3, ),一個是 (1, ),還無法讓 np.add 可以一對一處理,為了能一對一處理,只好在每取出 a 的一個元素時,b1 就用一次,你可以想像 b 被視為 np.array([1, 1, 1]) 了。

就方才的說明,a 的維度是 1,形狀為 (3, )b 原本維度是 0,形狀為 ()(純量 n 可視為 np.array(n)),計算過程就像是在低維度的陣列增加維度,形狀上從 () 變為 (1, ),然後改變形狀為 (3, ),就使用者的角度來看,就像是 b 被擴充了:

NumPy 擴張機制(一)

不過這邊只說了「你可以想像 b 被視為…」,是因為實際上不會真的擴充為陣列,只是就使用者的角度來看會如上圖罷了。

如果是 a = np.array([1, 2, 3])b = np.array([1]) 呢?兩者維度相同,np.add 想要一對一處理時發現形狀不同,只好在每取出 a 的一個元素時,b 中唯一的元素 1 就用一次,你可以想像 b 被視為 np.array([1, 1, 1]) 了:

NumPy 擴張機制(一)

就方才的說明,a 的維度是 1,形狀為 (3, )b 原本維度是 1,形狀為 (1, ),然後改變形狀為 (3, )

接著使用二維陣列 a = np.array([[1, 2, 3], [4, 5, 6]]),而 b = np.array([1]),為了便於說明,就都用「想像」後的結果來討論了。首先,兩者維度不同,先增加 b 的維度,也就是視 b 相當於 np.array([[1]]),就形狀而言,b 相當於從 (1, ),變成了 (1, 1),也就是新維度的軸(axis 0)先設 1(下圖加上陰影表示二維陣列):

NumPy 擴張機制(一)

維度相同了,np.add 接著想逐一處理元素,然而形狀一個是 (2, 3),一個是 (1, 1),首先處理 axis 0,令其成為 (2, 1)

NumPy 擴張機制(一)

接著處理 axis 1,形狀成為 (2, 3)

NumPy 擴張機制(一)

如果是 a = np.array([[1, 2, 3], [4, 5, 6]]),而 b = np.array([[7, 8, 9]]) 呢?首先,兩者維度相同,然而形狀一個是 (2, 3),一個是 (1, 3),首先處理 axis 0,令其成為 (2, 3)

NumPy 擴張機制(一)

接著就可以逐一運算元素了,可以發現,在形狀不同時,都是依 axis 0、axis 1 這樣的順序來處理。

如果是 a = np.array([[1, 2, 3], [4, 5, 6]]),而 b = np.array([[7], [8]]) 呢?首先,兩者維度相同,然而形狀一個是 (2, 3),一個是 (2, 1),axis 0 相同,因此處理 axis 1:

NumPy 擴張機制(一)

如果是 a = np.array([[1, 2, 3], [4, 5, 6]]),而 b = np.array([[7], [8], [9]]) 呢?兩者維度相同,然而形狀一個是 (2, 3),一個是 (3, 1),從 axis 0 開始,NumPy 就不知道怎麼調整為相同的形狀,這時你就會看到 could not be broadcast 的錯誤了:

NumPy 擴張機制(一)

簡單來說,從低維度的軸開始調整形狀時,若不是 1,或相同大小,擴張機制就無法運作,這樣的概念可以擴充至更多維度,記得,理解擴張的出發點,其實就在〈Universal functions〉中的 operates on ndarrays in an element-by-element fashion 這段,而〈Broadcasting〉中談到的規則,可以說是歸納後的結論。

來歸納一下我自己覺得容易判斷的方式:

  • 記得 Universal 函式是逐元素處理
  • 將低維度的陣列維度增加(形狀前加 1,直到維度相同)
  • 從低維度的軸開始調整形狀,必須是 1 或相同大小