NEP 12 — NumPy 中的遺失資料功能#
- 作者:
Mark Wiebe <mwwiebe@gmail.com>
- 版權:
Copyright 2011 by Enthought, Inc
- 授權條款:
CC By-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/)
- 日期:
2011-06-23
- 狀態:
延遲
目錄#
摘要#
對在 NumPy 中處理遺失資料感興趣的使用者通常會被指向 ndarray 的遮罩陣列子類別,稱為「numpy.ma」。這個類別有一些強烈依賴其功能的使用者,但習慣於 R 專案中遺失資料佔位符「NA」的深度整合,以及發現程式設計介面具有挑戰性或不一致的人,往往不會使用它。
本 NEP 提議將基於遮罩的遺失資料解決方案整合到 NumPy 中,並提供額外的基於位元模式的遺失資料解決方案,該解決方案可以同時實作或稍後實作,並與基於遮罩的解決方案無縫整合。
本提案中的基於遮罩的解決方案和基於位元模式的解決方案提供完全相同的遺失值抽象,但在效能、記憶體開銷和彈性方面存在一些差異。
基於遮罩的解決方案更具彈性,支援基於位元模式的解決方案的所有行為,但在元素被遮罩時,會保持隱藏值不變。
基於位元模式的解決方案需要更少的記憶體,與 R 中使用的 64 位元浮點表示法具有位元級相容性,但不保留隱藏值,實際上需要從底層 dtype 中竊取至少一個位元模式來表示遺失值 NA。
這兩種解決方案都是通用的,因為它們可以非常容易地與自訂資料型別一起使用,對於遮罩解決方案來說無需任何努力,而對於位元模式解決方案來說,則需要選擇要犧牲的位元模式。
遺失資料的定義#
為了能夠發展對各種 NumPy 函數將執行哪些計算的直覺,必須應用遺失元素的統一概念模型。找出人們在使用「遺失資料」時需要或想要的行為似乎很棘手,但我認為它可以歸結為兩個不同的想法,每個想法在內部都是自洽的。
其中一個是「未知但存在的資料」解釋,可以嚴格地應用於所有計算,而另一個解釋對於某些統計運算(如標準差)有意義,但對於線性代數運算(如矩陣乘積)則沒有意義。因此,使「未知但存在的資料」成為預設解釋更優越,它為所有計算提供了一致的模型,並且對於其他解釋有意義的那些運算,可以新增可選參數「skipna=」。
對於那些希望將其他解釋作為預設值的人,在其他地方提出的用於自訂子類別 ufunc 行為的 _numpy_ufunc_ 成員函數的機制將允許建立具有不同預設值的子類別。
未知但存在的資料 (NA)#
這是 R 專案中採用的方法,將遺失元素定義為具有有效值但未知或為 NA(不可用)的東西。本提案採用此行為作為涉及遺失值的所有運算的預設行為。
在這種解釋中,幾乎任何具有遺失輸入的計算都會產生遺失輸出。例如,如果「a」僅包含一個遺失元素,「sum(a)」將產生遺失值。當輸出值不依賴於其中一個輸入時,輸出非 NA 值是合理的,例如 logical_and(NA, False) == False。
一些更複雜的算術運算(如矩陣乘積)在此解釋中定義明確,結果應與遺失值為 NaN 時相同。實際上將這些東西實作到理論極限可能不值得,並且在許多情況下,引發例外或傳回所有遺失值可能比進行精確計算更可取。
不存在或被跳過的資料 (IGNORE)#
另一種有用的解釋是,遺失元素應被視為在陣列中不存在,並且運算應盡力根據剩餘資料來解釋這意味著什麼。在這種情況下,「mean(a)」將僅計算可用值的平均值,並根據哪些值遺失來調整其使用的總和和計數。為了保持一致性,所有遺失值陣列的平均值必須產生與不支援遺失值的零大小陣列的平均值相同的結果。
當將稀疏採樣資料符合到規則採樣模式時,可能會出現這種資料,並且在嘗試獲得許多統計查詢的最佳猜測答案時,這是一種有用的解釋。
在 R 中,許多函數採用參數「na.rm=T」,這表示將資料視為 NA 值不是資料集的一部分。本提案定義了一個標準參數「skipna=True」用於相同的目的。
遺失值的實作技術#
除了遺失值的兩種不同解釋之外,還有兩種常用的遺失值實作技術。雖然這些技術的現有實作之間存在一些不同的預設行為,但我認為,新的實作中的設計選擇必須基於其優點而不是死記硬背地複製以前的設計來做出。
遮罩和位元模式都有不同的優點和缺點,具體取決於應用程式上下文。因此,本 NEP 提議同時實作兩者。為了能夠編寫通用的「遺失值」程式碼,而無需擔心其使用的陣列是採用了其中一種方法還是另一種方法,兩種實作的遺失值語意將是相同的。
指示遺失值的位元模式 (bitpattern)#
選擇一個或多個位元模式(例如具有特定酬載的 NaN)來表示遺失值佔位符 NA。
這種方法的結果是,指定 NA 會變更保存值的位元,因此該值會消失。
此外,對於某些型別(如整數),必須犧牲一個良好且適當的值才能啟用此功能。
指示遺失值的布林遮罩 (mask)#
遮罩是與現有陣列資料一起配置的平行布林陣列,每個元素一個位元組或每個元素一個位元。在本 NEP 中,選擇的慣例是 True 表示元素有效(未遮罩),False 表示元素為 NA。
透過在編寫任何同時使用值和遮罩的 C 演算法時小心謹慎,可以使遮罩值的記憶體永遠不會被寫入。此功能允許同一個資料的多個同時檢視,並具有不同的遺失選擇,這是郵件列表上的許多人要求的功能。
這種方法對底層資料型別的值沒有任何限制,它可以採用任何二進制模式,而不會影響 NA 行為。
詞彙表#
由於以上關於不同概念及其關係的討論很難理解,因此以下是本 NEP 中使用的術語的更簡潔定義。
- NA (不可用/傳播)
用於表示計算未知的值的佔位符。該值可能暫時被遮罩隱藏,可能因硬碟損壞而遺失,或因任何原因而消失。對於總和和乘積,這表示如果任何輸入為 NA,則產生 NA。這與 R 專案中的 NA 相同。
- IGNORE (忽略/跳過)
應被計算視為不存在或不可能存在值的佔位符。對於總和,這表示如同值為零一樣運作,對於乘積,這表示如同值為一一樣運作。這就像陣列以某種方式壓縮以不包含該元素。
- bitpattern (位元模式)
用於實作 NA 或 IGNORE 的技術,其中從值資料型別的所有可能位元模式中選擇一組特定的位元模式,以指示元素為 NA 或 IGNORE。
- mask (遮罩)
用於實作 NA 或 IGNORE 的技術,其中使用平行於資料陣列的布林或列舉陣列來指示哪些元素為 NA 或 IGNORE。
- numpy.ma
遮罩陣列特定形式的現有實作,它是 NumPy 程式碼庫的一部分。
- Python API
所有暴露給 Python 程式碼的介面機制,用於在 NumPy 中使用遺失值。此 API 旨在具有 Python 風格,並盡可能符合 NumPy 的運作方式。
- C API
所有暴露的實作機制,適用於想要支援 NumPy 遺失值支援的 C 中編寫的 CPython 擴充功能。此 API 旨在在 C 中盡可能自然,並且通常優先考慮彈性和高效能。
在 Python 中看到的遺失值#
使用遺失值#
NumPy 將獲得一個稱為 numpy.NA 的全域單例,類似於 None,但其語意反映了其作為遺失值的狀態。特別是,嘗試將其視為布林值將引發例外,並且與它的比較將產生 numpy.NA 而不是 True 或 False。這些基礎知識是從 R 專案中 NA 值的行為中採用的。要深入探討這些想法,https://en.wikipedia.org/wiki/Ternary_logic#Kleene_logic 提供了一個起點。
例如,
>>> np.array([1.0, 2.0, np.NA, 7.0], maskna=True)
array([1., 2., NA, 7.], maskna=True)
>>> np.array([1.0, 2.0, np.NA, 7.0], dtype='NA')
array([1., 2., NA, 7.], dtype='NA[<f8]')
>>> np.array([1.0, 2.0, np.NA, 7.0], dtype='NA[f4]')
array([1., 2., NA, 7.], dtype='NA[<f4]')
對於遮罩和 NA dtype 版本,分別產生具有值 [1.0, 2.0, <inaccessible>, 7.0] / 遮罩 [Exposed, Exposed, Hidden, Exposed] 和值 [1.0, 2.0, <NA bitpattern>, 7.0] 的陣列。
np.NA 單例可以接受 dtype= 關鍵字參數,指示應將其視為特定資料型別的 NA。這也是以類似 NumPy 純量的方式保留 dtype 的機制。以下是它的外觀
>>> np.sum(np.array([1.0, 2.0, np.NA, 7.0], maskna=True))
NA(dtype='<f8')
>>> np.sum(np.array([1.0, 2.0, np.NA, 7.0], dtype='NA[f8]'))
NA(dtype='NA[<f8]')
將值指定給陣列始終會導致該元素不為 NA,並在必要時透明地取消遮罩。將 numpy.NA 指定給陣列會遮罩該元素或為特定 dtype 指定 NA 位元模式。在基於遮罩的實作中,遺失值背後的儲存永遠不會以任何方式存取,除非透過指定其值來取消遮罩。
為了測試值是否遺失,將提供函數「np.isna(arr[0])」。NumPy 純量的主要原因之一是允許它們的值進入字典。
除非寫入遮罩陣列的所有運算也取消遮罩該值,否則不會影響該值。如果仍然可以從另一個沒有遮罩它們的檢視存取遮罩元素背後的儲存,則允許仍然依賴它。例如,以下是在 missingdata work-in-progress 分支上執行的
>>> a = np.array([1,2])
>>> b = a.view(maskna=True)
>>> b
array([1, 2], maskna=True)
>>> b[0] = np.NA
>>> b
array([NA, 2], maskna=True)
>>> a
array([1, 2])
>>> # The underlying number 1 value in 'a[0]' was untouched
在基於遮罩的實作和位元模式實作之間複製值將透明地執行正確的操作,將位元模式轉換為遮罩值,或在適當時將遮罩值轉換為位元模式。唯一的例外是,如果遮罩陣列中的有效值恰好具有 NA 位元模式,則將此值複製到 dtype 的 NA 形式也會使其變為 NA。
當在具有 NA dtype 和遮罩陣列的陣列之間完成運算時,結果將是遮罩陣列。這是因為在某些情況下,NA dtype 無法表示遮罩陣列中的所有值,因此轉到遮罩陣列是保留資料所有方面的唯一方法。
如果將 np.NA 或遮罩值複製到未啟用遺失值支援的陣列,則會引發例外。向目標陣列新增遮罩會很麻煩,因為擁有遮罩將是一個「病毒式」屬性,會消耗額外的記憶體並以意想不到的方式降低效能。
預設情況下,字串「NA」將用於表示 str 和 repr 輸出中的遺失值。全域配置將允許變更此設定,完全擴展 nan 和 inf 的處理方式。以下是在目前草稿實作中可行的
>>> a = np.arange(6, maskna=True)
>>> a[3] = np.NA
>>> a
array([0, 1, 2, NA, 4, 5], maskna=True)
>>> np.set_printoptions(nastr='blah')
>>> a
array([0, 1, 2, blah, 4, 5], maskna=True)
對於浮點數,Inf 和 NaN 是與遺失值不同的概念。如果在具有預設遺失值支援的陣列中發生除以零,則將產生未遮罩的 Inf 或 NaN。為了遮罩這些值,可以進一步使用 'a[np.logical_not(a.isfinite(a))] = np.NA' 來實現這一點。對於位元模式方法,可以使用稍後章節中描述的參數化 dtype('NA[f8,InfNan]') 來獲得這些語意,而無需額外操作。
即使使用遮罩值,像這樣的遮罩陣列的手動迴圈
>>> a = np.arange(5., maskna=True)
>>> a[3] = np.NA
>>> a
array([ 0., 1., 2., NA, 4.], maskna=True)
>>> for i in range(len(a)):
... a[i] = np.log(a[i])
...
__main__:2: RuntimeWarning: divide by zero encountered in log
>>> a
array([ -inf, 0. , 0.69314718, NA, 1.38629436], maskna=True)
也可以運作,因為 'a[i]' 傳回與資料型別關聯的 NA 物件,ufuncs 可以正確處理它。
存取布林遮罩#
用於在遮罩方法中實作遺失資料的遮罩無法直接從 Python 存取。這部分是因為對於遮罩中的 True 應該表示「遺失」還是「未遺失」存在不同意見。此外,直接暴露遮罩將排除潛在的空間優化,其中使用位元級而不是位元組級遮罩來獲得八倍的記憶體使用率改進。
為了直接存取遮罩,提供了兩個函數。它們對於具有遮罩和 NA 位元模式的陣列都等效地工作,因此它們是根據 NA 和可用值而不是遮罩和未遮罩值來指定的。這些函數是 'np.isna' 和 'np.isavail',它們分別測試 NA 或可用值。
建立 NA 遮罩陣列#
建立具有 NA 遮罩的陣列的常用方法是將關鍵字參數 maskna=True 傳遞給其中一個建構函式。大多數建立新陣列的函數都採用此參數,並且當參數設定為 True 時,會產生所有元素都暴露的 NA 遮罩陣列。
還有兩個標誌指示和控制遮罩陣列中使用的遮罩性質。這些標誌可用於新增遮罩,或確保遮罩不是另一個陣列遮罩的檢視。
第一個是 'arr.flags.maskna',對於所有遮罩陣列都為 True,並且可以設定為 True 以將遮罩新增到沒有遮罩的陣列。
第二個是 'arr.flags.ownmaskna',如果陣列擁有遮罩的記憶體,則為 True,如果陣列沒有遮罩,或者具有另一個陣列遮罩的檢視,則為 False。如果在遮罩陣列中將其設定為 True,則陣列將建立遮罩的副本,以便對遮罩的進一步修改不會影響從中取得檢視的原始遮罩。
從列表建構時的 Na 遮罩#
NA 遮罩建構的初始設計是使所有建構完全明確。當以互動方式使用 NA 遮罩陣列時,這被證明是不方便的,並且建立物件陣列而不是 NA 遮罩陣列可能會非常令人驚訝。
因此,設計已變更為在從列表中建立陣列時,只要列表中有 NA 物件,就啟用 NA 遮罩。關於預設情況下應該建立 NA 遮罩還是 NA 位元模式可能存在一些爭議,但由於時間限制,僅解決 NA 遮罩是可行的,並且在整個 NumPy 中更全面地擴展 NA 遮罩支援似乎比啟動另一個系統並最終得到兩個不完整的系統更合理。
遮罩實作細節#
遮罩的記憶體排序將始終與其關聯的陣列的排序相符。Fortran 風格的陣列將具有 Fortran 風格的遮罩,依此類推。
當取得具有遮罩的陣列的檢視時,該檢視將具有也是原始陣列中遮罩的檢視的遮罩。這表示取消遮罩檢視中的值也會在原始陣列中取消遮罩它們,並且如果將遮罩新增到陣列,則除了建立複製資料但不複製遮罩的新陣列之外,將永遠無法移除該遮罩。
仍然可以暫時處理具有遮罩的陣列而不給它遮罩,方法是首先建立陣列的檢視,然後向該檢視新增遮罩。可以同時使用多個不同的遮罩檢視資料集,方法是建立多個檢視,並為每個檢視提供一個遮罩。
新的 ndarray 方法#
新增到 numpy 命名空間的新函數是
np.isna(arr) [IMPLEMENTED]
Returns a boolean array with True wherever the array is masked
or matches the NA bitpattern, and False elsewhere
np.isavail(arr)
Returns a boolean array with False wherever the array is masked
or matches the NA bitpattern, and True elsewhere
新增到 ndarray 的新函數是
arr.copy(..., replacena=np.NA)
Modification to the copy function which replaces NA values,
either masked or with the NA bitpattern, with the 'replacena='
parameter supplied. When 'replacena' isn't NA, the copied
array is unmasked and has the 'NA' part stripped from the
parameterized dtype ('NA[f8]' becomes just 'f8').
The default for replacena is chosen to be np.NA instead of None,
because it may be desirable to replace NA with None in an
NA-masked object array.
For future multi-NA support, 'replacena' could accept a dictionary
mapping the NA payload to the value to substitute for that
particular NA. NAs with payloads not appearing in the dictionary
would remain as NA unless a 'default' key was also supplied.
Both the parameter to replacena and the values in the dictionaries
can be either scalars or arrays which get broadcast onto 'arr'.
arr.view(maskna=True) [IMPLEMENTED]
This is a shortcut for
>>> a = arr.view()
>>> a.flags.maskna = True
arr.view(ownmaskna=True) [IMPLEMENTED]
This is a shortcut for
>>> a = arr.view()
>>> a.flags.maskna = True
>>> a.flags.ownmaskna = True
具有遺失值的元素級 ufunc#
作為實作的一部分,將必須擴展 ufunc 和其他運算以支援遮罩計算。由於這通常是一個有用的功能,即使在遮罩陣列的上下文之外也是如此,除了使用遮罩陣列外,ufunc 還將採用可選的 'where=' 參數,該參數允許使用布林陣列來選擇應該在何處進行計算。
>>> np.add(a, b, out=b, where=(a > threshold))
擁有此 'where=' 參數的好處是,它提供了一種暫時處理具有遮罩的物件的方法,而無需建立遮罩陣列物件。在上面的範例中,這只會對 'where' 子句中具有 True 的陣列元素執行加法,並且 'a' 和 'b' 都不需要是遮罩陣列。
如果未指定 'out' 參數,則使用 'where=' 參數將產生一個以遮罩作為結果的陣列,對於 'where' 子句具有值 False 的任何位置,都會有遺失值。
對於布林運算,R 專案特殊情況處理 logical_and 和 logical_or,以便 logical_and(NA, False) 為 False,logical_or(NA, True) 為 True。另一方面,0 * NA 不是 0,但此處的 NA 可能表示 Inf 或 NaN,在這種情況下,0 * 後備值無論如何都不會是 0。
對於 NumPy 元素級 ufunc,該設計將不支援輸出遮罩同時依賴於輸入的遮罩和值的能力。然而,NumPy 1.6 nditer 使編寫看起來和感覺就像 ufunc,但偏離其行為的獨立函數變得相當容易。函數 logical_and 和 logical_or 可以移動到獨立函數物件中,這些物件與目前的 ufunc 向後相容。
具有遺失值的歸約 ufunc#
像 'sum'、'prod'、'min' 和 'max' 這樣的歸約運算將與遮罩值存在但其值未知的概念一致地運作。
將為那些可以適當解釋它的函數新增一個可選參數 'skipna=',以便執行運算,就好像只有未遮罩的值存在一樣。
使用 'skipna=True',當所有輸入值都被遮罩時,'sum' 和 'prod' 將分別產生加法和乘法恆等式,而 'min' 和 'max' 將產生遮罩值。如果 'skipna=True',則需要計數的統計運算(如 'mean' 和 'std')也將使用未遮罩的值計數進行計算,並且當所有輸入都被遮罩時,會產生遮罩值。
一些範例
>>> a = np.array([1., 3., np.NA, 7.], maskna=True)
>>> np.sum(a)
array(NA, dtype='<f8', maskna=True)
>>> np.sum(a, skipna=True)
11.0
>>> np.mean(a)
NA(dtype='<f8')
>>> np.mean(a, skipna=True)
3.6666666666666665
>>> a = np.array([np.NA, np.NA], dtype='f8', maskna=True)
>>> np.sum(a, skipna=True)
0.0
>>> np.max(a, skipna=True)
array(NA, dtype='<f8', maskna=True)
>>> np.mean(a)
NA(dtype='<f8')
>>> np.mean(a, skipna=True)
/home/mwiebe/virtualenvs/dev/lib/python2.7/site-packages/numpy/core/fromnumeric.py:2374: RuntimeWarning: invalid value encountered in double_scalars
return mean(axis, dtype, out)
nan
函數 'np.any' 和 'np.all' 需要一些特殊考慮,就像 logical_and 和 logical_or 一樣。也許描述它們行為的最佳方式是透過一系列範例
>>> np.any(np.array([False, False, False], maskna=True))
False
>>> np.any(np.array([False, np.NA, False], maskna=True))
NA
>>> np.any(np.array([False, np.NA, True], maskna=True))
True
>>> np.all(np.array([True, True, True], maskna=True))
True
>>> np.all(np.array([True, np.NA, True], maskna=True))
NA
>>> np.all(np.array([False, np.NA, True], maskna=True))
False
由於 'np.any' 是 'np.logical_or' 的歸約,而 'np.all' 是 'np.logical_and' 的歸約,因此它們像其他類似的歸約函數一樣具有 'skipna=' 參數是有道理的。
參數化 NA 資料型別#
遮罩陣列不是處理遺失資料的唯一方法,有些系統透過定義特殊的「NA」值來處理這個問題,用於表示遺失的資料。這與 NaN 浮點值不同,後者是不良浮點計算值的結果,但許多人將 NaN 用於此目的。
在 IEEE 浮點值的情況下,可以使用特定的 NaN 值(其中有很多),用於「NA」,與 NaN 不同。對於有號整數,合理的方法是使用最小的可儲存值,該值沒有對應的正值。對於無號整數,最大儲存值似乎最合理。
為了提供通用機制的目標,此參數化型別機制比建立單獨的 nafloat32、nafloat64、naint64、nauint64 等 dtype 更具吸引力。如果將其視為處理遮罩的替代方法,但沒有值保留,則此參數化型別可以與遮罩以特殊方式協同工作,以動態產生值 + 遮罩組合,並使用與遮罩陣列系統完全相同的計算基礎架構。這允許人們避免為每個 ufunc 和每個 na* dtype 編寫特殊情況程式碼的需求,這在為每個 na* dtype 建置單獨的獨立 dtype 實作時很難避免。
跨基本型別保留 NA 位元模式的可靠轉換也需要考慮。即使在 double -> float 的簡單情況下(硬體支援),NA 值也會遺失,因為 NaN 酬載通常不會保留。為相同的底層型別指定不同位元遮罩的能力也需要正確轉換。透過定義完善的介面轉換為/從 (value,flag) 對,這變得可以直接通用地支援。
這種方法也為 IEEE 浮點數提供了一些細微變化的機會。預設情況下,將使用一個精確的位元模式,一個靜音 NaN,其酬載不會由硬體浮點運算產生。R 所做的選擇可能是這個預設值。
此外,有時將所有 NaN 視為遺失值可能也不錯。這需要稍微複雜的映射來將浮點值轉換為遮罩/值組合,並且轉換回始終會產生 NumPy 使用的預設 NaN。最後,將 NaN 和 Inf 都視為遺失值只是 NaN 版本的一個微小變化。
字串需要稍微不同的處理,因為它們的大小可能不同。一種方法是使用由前 32 個 ASCII/unicode 值之一組成的單字元訊號。這裡有很多可能的值可以使用,例如 0x15「Negative Acknowledgement」或 0x10「Data Link Escape」。
Object dtype 有一個明顯的訊號,即 np.NA 單例本身。任何具有物件語意的 dtype 都無法自訂此設定,因為指定位元模式僅適用於純二進制資料,而不適用於具有建構和解構物件語意的資料。
Struct dtype 更像是一個核心基本 dtype,與此參數化可支援 NA 的 dtype 的方式相同。無法將它們作為參數化 NA dtype 的參數。
dtype 名稱將被參數化,類似於 datetime64 如何透過元資料單位進行參數化。使用什麼名稱可能需要一些爭論,但「NA」似乎是一個合理的選擇。使用預設遺失值位元模式,這些 dtype 看起來像 np.dtype('NA[float32]')、np.dtype('NA[f8]') 或 np.dtype('NA[i64]')。
為了覆寫指示遺失值的位元模式,可以給出十六進制無號整數格式的原始值,並且在上述浮點數的特殊情況下,可以提供特殊字串。某些情況下的預設值,以這種形式明確寫出,則為
np.dtype('NA[?,0x02]')
np.dtype('NA[i4,0x80000000]')
np.dtype('NA[u4,0xffffffff]')
np.dtype('NA[f4,0x7f8007a2')
np.dtype('NA[f8,0x7ff00000000007a2') (R-compatible bitpattern)
np.dtype('NA[S16,0x15]') (using the NAK character as the signal).
np.dtype('NA[f8,NaN]') (for any NaN)
np.dtype('NA[f8,InfNaN]') (for any NaN or Inf)
當未指定任何參數時,會建立彈性的 NA dtype,它本身無法保存值,但會符合 'np.astype' 等函數中的輸入型別。dtype 'f8' 映射到 'NA[f8]',而 [('a', 'f4'), ('b', 'i4')] 映射到 [('a', 'NA[f4]'), ('b', 'NA[i4]')]。因此,要使用 'NA[f8]' 檢視 'f8' 陣列 'arr' 的記憶體,您可以說 arr.view(dtype='NA')。
未來擴展到多重 NA 酬載#
SAS 和 Stata 兩種套件都支援多種不同的 “NA” 值。這允許使用者指定數值為 NA 的不同原因,例如,作業未完成是因為狗吃了作業,或是學生生病了。在這些套件中,不同的 NA 值具有線性順序,指定了不同 NA 值如何組合在一起。
在關於 C 實作細節的章節中,遮罩的設計使其具有酬載 (payload) 的遮罩成為 NumPy 布林型別的嚴格超集合,而布林型別的酬載僅為零。不同的酬載以 'min' 運算結合。
設計中最重要的前瞻性部分是確保 C ABI 層級的選擇和 Python API 層級的選擇能夠自然地過渡到多重 NA 支援。以下是多重 NA 支援可能呈現的一種方式
>>> a = np.array([np.NA(1), 3, np.NA(2)], maskna='multi')
>>> np.sum(a)
NA(1, dtype='<i4')
>>> np.sum(a[1:])
NA(2, dtype='<i4')
>>> b = np.array([np.NA, 2, 5], maskna=True)
>>> a + b
array([NA(0), 5, NA(2)], maskna='multi')
此 NEP 的設計並未區分 NA 遮罩產生的 NA 或 NA dtype 產生的 NA。這兩者在計算中都被同等對待,其中遮罩優先於 NA dtype。
>>> a = np.array([np.NA, 2, 5], maskna=True)
>>> b = np.array([1, np.NA, 7], dtype='NA')
>>> a + b
array([NA, NA, 12], maskna=True)
多重 NA 方法允許使用者區分這些 NA,方法是為不同類型分配不同的酬載。如果我們擴展 'skipna=' 參數以接受酬載列表以及 True/False,則可以這樣做
>>> a = np.array([np.NA(1), 2, 5], maskna='multi')
>>> b = np.array([1, np.NA(0), 7], dtype='NA[f4,multi]')
>>> a + b
array([NA(1), NA(0), 12], maskna='multi')
>>> np.sum(a, skipna=0)
NA(1, dtype='<i4')
>>> np.sum(a, skipna=1)
7
>>> np.sum(b, skipna=0)
8
>>> np.sum(b, skipna=1)
NA(0, dtype='<f4')
>>> np.sum(a+b, skipna=(0,1))
12
與 numpy.ma 的差異#
numpy.ma 使用的計算模型並未嚴格遵守 NA 或 IGNORE 模型。本節展示了一些範例,說明這些差異如何影響簡單的計算。此資訊對於幫助使用者在系統之間導航非常重要,因此摘要可能應該放在文件中的表格中。
>>> a = np.random.random((3, 2))
>>> mask = [[False, True], [True, True], [False, False]]
>>> b1 = np.ma.masked_array(a, mask=mask)
>>> b2 = a.view(maskna=True)
>>> b2[mask] = np.NA
>>> b1
masked_array(data =
[[0.110804969841 --]
[-- --]
[0.955128477746 0.440430735546]],
mask =
[[False True]
[ True True]
[False False]],
fill_value = 1e+20)
>>> b2
array([[0.110804969841, NA],
[NA, NA],
[0.955128477746, 0.440430735546]],
maskna=True)
>>> b1.mean(axis=0)
masked_array(data = [0.532966723794 0.440430735546],
mask = [False False],
fill_value = 1e+20)
>>> b2.mean(axis=0)
array([NA, NA], dtype='<f8', maskna=True)
>>> b2.mean(axis=0, skipna=True)
array([0.532966723794 0.440430735546], maskna=True)
對於像 np.mean 這樣的函數,當 'skipna=True' 時,所有 NA 的行為都與空陣列一致
>>> b1.mean(axis=1)
masked_array(data = [0.110804969841 -- 0.697779606646],
mask = [False True False],
fill_value = 1e+20)
>>> b2.mean(axis=1)
array([NA, NA, 0.697779606646], maskna=True)
>>> b2.mean(axis=1, skipna=True)
RuntimeWarning: invalid value encountered in double_scalars
array([0.110804969841, nan, 0.697779606646], maskna=True)
>>> np.mean([])
RuntimeWarning: invalid value encountered in double_scalars
nan
特別注意,numpy.ma 通常會跳過遮罩值,但當所有值都被遮罩時會傳回遮罩,而 'skipna=' 參數在所有值都是 NA 時傳回零,以與 np.sum([]) 的結果一致
>>> b1[1]
masked_array(data = [-- --],
mask = [ True True],
fill_value = 1e+20)
>>> b2[1]
array([NA, NA], dtype='<f8', maskna=True)
>>> b1[1].sum()
masked
>>> b2[1].sum()
NA(dtype='<f8')
>>> b2[1].sum(skipna=True)
0.0
>>> np.sum([])
0.0
布林索引#
使用包含 NA 的布林陣列進行索引,根據 NA 抽象概念,沒有一致的解釋。例如
>>> a = np.array([1, 2])
>>> mask = np.array([np.NA, True], maskna=True)
>>> a[mask]
What should happen here?
由於 NA 代表有效但未知的值,並且它是布林值,因此它有兩個可能的基礎值
>>> a[np.array([True, True])]
array([1, 2])
>>> a[np.array([False, True])]
array([2])
改變的是輸出陣列的長度,沒有任何東西本身可以替代 NA。因此,至少在最初,NumPy 會針對這種情況引發例外。
另一種可能性是增加不一致性,並遵循 R 使用的方法。也就是說,產生以下結果
>>> a[mask]
array([NA, 2], maskna=True)
如果在使用者測試中發現基於務實的原因這是必要的,則即使它不一致也應添加此功能。
PEP 3118#
PEP 3118 沒有任何遮罩機制,因此帶有遮罩的陣列將無法透過此介面存取。同樣地,它也不支援使用 NA 或 IGNORE 位元模式指定 dtype,因此參數化的 NA dtype 也將無法透過此介面存取。
如果 NumPy 確實允許透過 PEP 3118 存取,這將以非常有害的方式規避遺失值抽象概念。其他函式庫會嘗試使用遮罩陣列,並在沒有同時存取遮罩或意識到遮罩和資料一起遵循的遺失值抽象概念的情況下,靜默地存取資料。
Cython#
Cython 使用 PEP 3118 來處理 NumPy 陣列,因此目前它會簡單地拒絕處理它們,如 “PEP 3118” 章節中所述。
為了正確支援 NumPy 遺失值,Cython 需要以某種方式進行修改以添加此支援。最有可能的最佳方法是將其與支援 np.nditer 一起包含,np.nditer 最有可能進行增強,以使編寫遺失值演算法更容易。
硬遮罩#
numpy.ma 實作具有 “硬遮罩” 功能,可防止透過賦值來取消遮罩值。這將是一個內部陣列標誌,名稱類似於 'arr.flags.hardmask'。
如果實作了硬遮罩功能,則布林索引可以傳回硬遮罩陣列,而不是目前那樣傳回具有 C 順序任意選擇的扁平陣列。雖然這顯著改進了陣列的抽象概念,但這不是相容的變更。
與預先存在的 C API 使用方式的互動#
確保現有使用 C API 的程式碼(無論是用 C、C++ 還是 Cython 編寫的)做出合理的行為是此實作的重要目標。一般策略是讓現有程式碼在沒有明確告知 numpy 它支援 NA 遮罩的情況下,因例外而失敗,並說明原因。人們使用幾種不同的存取模式來取得 numpy 陣列資料,在這裡我們檢視其中的一些模式,以了解 numpy 可以做什麼。這些範例來自對 numpy C API 陣列存取進行 Google 搜尋。
NumPy 文件 - 如何擴展 NumPy#
https://scipy-docs.dev.org.tw/doc/numpy/user/c-info.how-to-extend.html#dealing-with-array-objects
此頁面有一個標題為 “Dealing with array objects” 的章節,其中提供了一些關於如何從 C 存取 numpy 陣列的建議。當接受陣列時,它建議的第一步是使用 PyArray_FromAny 或基於該函數構建的巨集,因此遵循此建議的程式碼在給定它不知道如何處理的 NA 遮罩陣列時會正確地失敗。
處理此問題的方式是 PyArray_FromAny 需要一個特殊標誌 NPY_ARRAY_ALLOWNA,然後才允許 NA 遮罩陣列流經。
https://scipy-docs.dev.org.tw/doc/numpy/reference/c-api.array.html#NPY_ARRAY_ALLOWNA
不遵循此建議,而是僅呼叫 PyArray_Check() 來驗證它是否為 ndarray 並檢查某些標誌的程式碼,將會靜默地產生不正確的結果。這種程式碼風格沒有為 numpy 提供任何機會來說 “嘿,這個陣列很特別”,因此也不與惰性求值、衍生 dtype 等未來想法相容。
來自 Cython 網站的教學課程#
http://docs.cython.org/src/tutorial/numpy.html
本教學課程提供了一個卷積範例,並且當給定包含 NA 值的輸入時,所有範例都會因 Python 例外而失敗。
在引入任何 Cython 類型註解之前,程式碼的功能與直譯器中等效的 Python 完全相同。
當引入類型資訊時,它是透過 numpy.pxd 完成的,numpy.pxd 定義了 ndarray 宣告和 PyArrayObject * 之間的對應。在底層,這會對應到 __Pyx_ArgTypeTest,它會將 Py_TYPE(obj) 與 ndarray 的 PyTypeObject 進行直接比較。
然後,程式碼會進行一些 dtype 比較,並使用常規 Python 索引來存取陣列元素。此 Python 索引仍然會通過 Python API,因此 numpy 中的 NA 處理和錯誤檢查仍然可以像往常一樣工作,並且如果輸入具有無法放入輸出陣列的 NA,則會失敗。在這種情況下,當嘗試將 NA 轉換為整數以設定在輸出中時,它會失敗。
程式碼的下一個版本引入了更有效率的索引。這基於 Python 的緩衝區協議運作。這會導致 Cython 呼叫 __Pyx_GetBufferAndValidate,它會呼叫 __Pyx_GetBuffer,它會呼叫 PyObject_GetBuffer。如果輸入是具有 NA 遮罩的陣列,則此呼叫使 numpy 有機會引發例外,而 Python 緩衝區協議不支援這種情況。
Numerical Python - JPL 網站#
http://dsnra.jpl.nasa.gov/software/Python/numpydoc/numpy-13.html
本文檔來自 2001 年,因此不反映最新的 numpy,但它是 Google 上搜尋 “numpy c api example” 的第二個熱門結果。
第一個範例,標題為 “A simple example”,實際上即使沒有 NA 支援,對於最新的 numpy 來說也已經無效。特別是,如果資料未對齊或位元組順序不同,則可能會崩潰或產生不正確的結果。
本文檔接下來的操作是引入 PyArray_ContiguousFromObject,這讓 numpy 有機會在使用 NA 遮罩陣列時引發例外,因此後續程式碼將會按預期引發例外。
C 實作細節#
要實作的第一個版本是陣列遮罩,因為它是更通用的方法。遮罩本身是一個陣列,但由於它旨在永遠無法從 Python 直接存取,因此它本身不會是一個完整的 ndarray。遮罩始終具有與其附加的陣列相同的形狀,因此它不需要自己的形狀。但是,對於具有 struct dtype 的陣列,遮罩將具有與直接布林值不同的 dtype,因此它確實需要自己的 dtype。這為我們提供了以下對 PyArrayObject 的新增內容
/*
* Descriptor for the mask dtype.
* If no mask: NULL
* If mask : bool/uint8/structured dtype of mask dtypes
*/
PyArray_Descr *maskna_dtype;
/*
* Raw data buffer for mask. If the array has the flag
* NPY_ARRAY_OWNMASKNA enabled, it owns this memory and
* must call PyArray_free on it when destroyed.
*/
npy_mask *maskna_data;
/*
* Just like dimensions and strides point into the same memory
* buffer, we now just make the buffer 3x the nd instead of 2x
* and use the same buffer.
*/
npy_intp *maskna_strides;
這些欄位可以透過內聯函數存取
PyArray_Descr *
PyArray_MASKNA_DTYPE(PyArrayObject *arr);
npy_mask *
PyArray_MASKNA_DATA(PyArrayObject *arr);
npy_intp *
PyArray_MASKNA_STRIDES(PyArrayObject *arr);
npy_bool
PyArray_HASMASKNA(PyArrayObject *arr);
必須將 2 或 3 個標誌添加到陣列標誌中,既用於請求 NA 遮罩,也用於測試它們
NPY_ARRAY_MASKNA
NPY_ARRAY_OWNMASKNA
/* To possibly add in a later revision */
NPY_ARRAY_HARDMASKNA
為了允許輕鬆偵測 NA 支援以及陣列是否具有任何遺失值,我們添加了以下函數
- PyDataType_HasNASupport(PyArray_Descr* dtype)
如果這是 NA dtype,或每個欄位都具有 NA 支援的 struct dtype,則傳回 true。
- PyArray_HasNASupport(PyArrayObject* obj)
如果陣列 dtype 具有 NA 支援,或陣列具有 NA 遮罩,則傳回 true。
- PyArray_ContainsNA(PyArrayObject* obj)
如果陣列沒有 NA 支援,則傳回 false。如果陣列具有 NA 支援,且陣列中任何位置都有 NA,則傳回 true。
- int PyArray_AllocateMaskNA(PyArrayObject* arr, npy_bool ownmaskna, npy_bool multina)
為陣列分配 NA 遮罩,確保在請求時的所有權,並在 multina 為 True 時使用 NPY_MASK 而不是 NPY_BOOL 作為 dtype。
遮罩二進位格式#
遮罩本身的格式旨在指示元素是否被遮罩,以及包含酬載,以便將來可以使用具有不同酬載的多個不同 NA。最初,我們將僅使用酬載 0。
遮罩的類型為 npy_uint8,位元 0 用於指示值是否被遮罩。如果 ((m&0x01) == 0),則元素被遮罩,否則它未被遮罩。其餘位元是酬載,即 (m>>1)。組合具有酬載的遮罩的慣例是較小的酬載會傳播。此設計為遮罩元素提供 128 個酬載值,為未遮罩元素提供 128 個酬載值。
這種方法的最大優點是 npy_bool 也可用作遮罩,因為它採用 False 的值 0 和 True 的值 1。此外,npy_bool 的酬載(始終為零)優先於所有其他可能的酬載。
由於該設計涉及為遮罩提供自己的 dtype,因此我們可以區分使用單個 NA 值(npy_bool 遮罩)進行遮罩和使用多重 NA(npy_uint8 遮罩)進行遮罩。初始實作將僅支援 npy_bool 遮罩。
一個被捨棄的想法是允許將遮罩 + 酬載的組合作為簡單的 'min' 運算。這可以透過將酬載放在位元 0 到 6 中來完成,以便酬載為 (m&0x7f),並使用位元 7 作為遮罩標誌,因此 ((m&0x80) == 0) 表示元素被遮罩。事實上,這使得遮罩與布林值完全不同,而不是嚴格的超集合,這是捨棄此選擇的主要原因。
C 迭代器 API 變更:使用遮罩進行迭代#
對於使用遮罩進行迭代和計算,無論是在遺失值的上下文中,還是在像 ufuncs 中的 'where=' 參數一樣使用遮罩時,擴展 nditer 是公開此功能最自然的方式。
遮罩運算需要與轉換、對齊以及任何其他導致值被複製到臨時緩衝區的事情一起工作,nditer 可以很好地處理這些事情,但在該上下文之外很難做到。
首先,我們描述為在遺失值上下文之外使用遮罩而設計的迭代,然後是包含遺失值支援的功能。
迭代器遮罩功能#
我們添加了幾個新的每個運算元標誌
- NPY_ITER_WRITEMASKED
指示從緩衝區到陣列完成的任何複製都被遮罩。這是必要的,因為如果將浮點陣列視為整數陣列,則 READWRITE 模式可能會破壞資料,因此複製到緩衝區再返回會截斷為整數。沒有為讀取提供類似的標誌,因為可能無法提前知道遮罩,並且將所有內容複製到緩衝區永遠不會破壞資料。
使用迭代器的程式碼應僅寫入未被指定遮罩遮罩的值,否則結果會因是否啟用緩衝而異。
- NPY_ITER_ARRAYMASK
指示此陣列是一個布林遮罩,用於在將任何 WRITEMASKED 參數從緩衝區複製回陣列時使用。只能有一個這樣的遮罩,並且也不能有虛擬遮罩。
作為一個特例,如果在同一時間指定了標誌 NPY_ITER_USE_MASKNA,則使用運算元的遮罩而不是運算元本身。如果運算元沒有遮罩,但基於 NA dtype,則迭代器公開的該遮罩在從緩衝區複製到陣列時會轉換為 NA 位元模式。
- NPY_ITER_VIRTUAL
指示此運算元不是陣列,而是在內部迭代程式碼中動態建立的。這為程式碼分配了足夠的緩衝區空間來讀取/寫入資料,但沒有實際的陣列支援資料。當與 NPY_ITER_ARRAYMASK 結合使用時,允許建立 “虛擬遮罩”,指定哪些值未被遮罩,而無需建立完整的遮罩陣列。
迭代器 NA 陣列功能#
我們添加了幾個新的每個運算元標誌
- NPY_ITER_USE_MASKNA
如果運算元具有 NA dtype、NA 遮罩或兩者兼有,則這會在運算元列表的末尾添加一個新的虛擬運算元,該運算元會迭代特定運算元的遮罩。
- NPY_ITER_IGNORE_MASKNA
如果運算元具有 NA 遮罩,則預設情況下,迭代器會引發例外,除非指定了 NPY_ITER_USE_MASKNA。此標誌停用該檢查,並且適用於首先使用 PyArray_ContainsNA 函數檢查陣列中的所有元素都不是 NA 的情況。
如果 dtype 是 NA dtype,這也會從 dtype 中剝離 NA 性質,顯示一個不支援 NA 的 dtype。
已拒絕的替代方案#
參數化資料類型,為 NA 標誌添加額外記憶體#
除了在陣列中添加單獨的遮罩之外,另一種替代方案是引入參數化類型,該類型將原始 dtype 作為參數。dtype “i8” 將變為 “maybe[i8]”,並且將一個位元組標誌附加到 dtype,以指示該值是否為 NA。
這種方法添加的記憶體開銷大於或等於保持單獨的遮罩,但具有更好的局部性。為了保持 dtype 對齊,'i8' 需要有 16 個位元組才能保持正確的對齊方式,與單獨保持遮罩的 12.5% 開銷相比,開銷為 100%。
致謝#
除了來自 Travis Oliphant 和 Enthought 其他人的回饋之外,此 NEP 還根據 NumPy-Discussion 郵件列表的大量回饋進行了修訂。參與討論的人員有
Nathaniel Smith
Robert Kern
Charles Harris
Gael Varoquaux
Eric Firing
Keith Goodman
Pierre GM
Christopher Barker
Josef Perktold
Ben Root
Laurent Gautier
Neal Becker
Bruce Southey
Matthew Brett
Wes McKinney
Lluís
Olivier Delalleau
Alan G Isaac
Antero Tammi
Jason Grout
Dag Sverre Seljebotn
Joe Harrington
Gary Strangman
Chris Jordan-Squire
Peter
如果我遺漏了任何人,我深感抱歉。