使用NumPy處理資料時,我們可以將資料看成一個整體進行運算,例如,若nums1是個NumPy陣列,不管維度多少,我們都可以直接進行nums1+1的運算,而其結果,就是元素的值全部加1。

然而,不單只是純量數字可以這麼做,不同維度的陣列彼此之間也可以這麼做。例如,nums1+[1]也是可以的,如果nums2是個2x3的二維陣列,也可以進行nums2+[1,2,3]之類的運算。

基本上,NumPy的入門文件都會提到上述概念,而且,這種便利性要歸功於NumPy廣播(broadcasting)機制。

簡單來說,NumPy提供的內建的許多函式或方法,會試著將陣列調整為相同的形狀,像是方才的nums+1、nums+[1],我們可以直覺地想成,將1逐個加到元素就可以了,而nums2+[1,2,3]可以看成[1,2,3]與nums2的各列(row)配對相加。

不過,你可別因為nums2+[1,2,3]的關係,就誤以為廣播機制是逐列複製。

由於nums2+也能運算,其結果與nums2+相同,但是,並非各種維度都能混在一起計算,例如,nums2+[1,2]就會發生無法廣播(could not be broadcast)的錯誤。

身為開發者,當然就會想一探廣播機制,NumPy官方文件提供了相關說明,在〈Broadcasting〉就可以看到廣播的四個規則,以及輸入陣列能進行廣播的(broadcastable)三個條件。

不過,當中單純而冗長的文字描述,實在令人如墮五里霧中,而在閱讀網路上搜尋的文件之後,仍不明就裡,就算當中加上了圖解,也還是有點霧裡看花。

在官方網站上,我試著理解廣播的規則與輸入陣列條件時,當我往上捲動了文件,然後,狐疑了一下:「嗯?為什麼廣播的說明是放在〈Universal functions〉這個標題之中?」

Universal函式的觀點

試著先拋棄理解規則與條件,從Universal函式的觀點來看廣播吧!

事實上,Universal函式文件在內容的一開頭就寫道:Universal函式會逐一處理陣列上的元素(operates on ndarrays in an element-by-element fashion)。

理解廣播的出發點,其實就在「逐一處理」這個動作,無論是幾維,形狀為何,Universal函式底層最後關心的,都只是單一元素如何處理。

如果你定義了普通的函式,想透過numpy的frompyfunc向量化,以建立Universal函式,之後,你的普通函式要進行實作時,只要處理傳入的單一元素就可以了。

例如,想實作陣列相加的Universal函式:

def add(elem1, elem2):
       return elem1 + elem2 # 只要關心單一元素的處理
add = np.frompyfunc(add, 2, 1)

接下來,要進行add(a,b)時,最簡單的情況是a與b維度相同,這時,Universal函式(向量化後的add)只要一對一地將元素傳入你指定的普通函式;如果a是np.array([1, 2])而b是1呢?當兩者維度不同,Universal函式找出維度較大的陣列a,令b的維度與之相同,這時,我們可以想像b被視為np.array([1])。

現在維度相同而形狀不同,a的形狀是(2,),擴充後的b形狀是(1,),Universal函式為了能一對一處理,會在每取出a的一個元素時,b的1就用一次,可以想像b被視為[1, 1]了,這就是〈Broadcasting〉中談到的第四個規則。

之所以會說「可以想像b被視為……」,是因為實際上,原始的b不會真的被擴充為陣列,只是就使用者的角度來看像是被擴充罷了。

就方才的說明,a的維度是1,形狀為(2,),b原本維度是0,形狀為()(純量n可視為np.array(n)),計算過程就像是在低維度的陣列增加維度,也就是b形狀上從()變為(1,),這就是〈Broadcasting〉中談到的第一個規則「在低維度陣列的形狀前附加1(have 1’s prepended to their shapes)」,接下來改變形狀為(2,)的動作,就是規則二下的結果。

廣播後的形狀

簡單來說,想要理解廣播機制,我們可以假設自己是Universal函式的實作者,思考一下輸入陣列該如何處理,後續才能一對一、逐一處理元素,例如,來看看二維陣列的情況。

如果a是np.array(),而b是,Universal函式處理時,確認兩者維度是相同的,這時,不用套用規則一了,然而,形狀上a是(2,3),b是(1,3),在形狀不同時,我們可以從低維度的軸開始調整形狀,也就是依軸0、軸1的順序來處理,在處理軸0時,為了令b成為(2,3),[7,8,9]在軸0方向擴充為,接著,就可以逐一運算元素。

如果是b是np.array()呢?因為a、b維度相同,不用套用規則一,然而形狀a的形狀是(2,3),b是(2,1),軸0大小相同,因此處理軸1,為了令b成為(2,3),[7]在軸1方向擴充,[8]在軸1方向擴充,結果就是,兩個陣列維度相同,形狀相同了,接著逐一運算元素。

以上兩個例子,從低維度的軸開始調整形狀時,若不是1,或相同大小,廣播機制就無法運作,這也是規則四談到的情況。

例如,若b是,與a維度相同,然而形狀一個是(2,3),一個是(3,1),軸0的大小既不相同、也不是1,NumPy不知道怎麼調整為相同形狀,這時,就會發生無法廣播的錯誤;類似地,b若是,形狀是(1,2),軸0的大小既不相同、也不是1,NumPy此時也會因不知道如何調成相同形狀,而發生錯誤。

以簡馭繁的方式

簡單來說,我們如果從Universal函式的角度來看,〈Broadcasting〉當中所列出的四個規則,是試著讓使用者不用理解Universal函式運作原理的情況下,綜合出來的結論,而三個條件,是Universal函式接受的引數(陣列或純量)該有的限制。

當然,運用廣播機制時,最重要的是易於撰寫與閱讀,若是遇到一些需要自行撰寫複雜廣播的場合,可以試著先搜尋看看有無封裝好的函式。

例如,numpy的ix_函式可用來協助建立取陣列叉積(cross)的索引陣列,其底層就封裝了複雜的廣播機制,透過這類封裝了細節的函式,對於程式碼的撰寫與閱讀會有很大的幫助。

當然,還是會有需要理解廣播以撰寫或閱讀相關程式碼的場合(例如理解ix_函式原理),這時的出發點就是「Universal函式會逐元素處理」。

而為了達到這個目的,我們思考的順序會是「Universal函式會檢查維度」,必要時「將低維度的陣列維度增加(形狀前加1,直到維度相同)」,然後「從低維度的軸開始調整形狀(必須是1或相同大小)」。

至於〈Broadcasting〉中的規則與條件,可以輔助思考當然很好,如果沒什麼輔助作用,也不必太在意了。

專欄作者

熱門新聞

Advertisement