NEP 25 — 通過特殊資料型別支援 NA#

作者:

Nathaniel J. Smith <njs@pobox.com>

狀態:

延遲

類型:

標準追蹤

建立於:

2011-07-08

摘要#

背景:此 NEP 是作為 NEP 12(NEP 24 是另一個替代方案)的額外替代方案而撰寫的,在撰寫時,NEP 12 的實作已合併到 NumPy 主要分支中。

為了在整個遺失值/遮罩陣列/… 辯論中取得更多進展,對我們可以同意的部分進行更技術性的討論似乎很有用。這是第二個,旨在確定如何使用特殊 dtype 實作 NA 的細節。

理由#

普通值類似於整數或浮點數。遺失值是因某種原因無法使用的普通值的佔位符。例如,在處理統計數據時,我們經常建立表格,其中每列代表一個項目,每行代表該項目的屬性。例如,我們可能會選取一群人,並記錄每個人的身高、年齡、教育程度和收入,然後將這些值放入表格中。但是,我們發現我們的研究助理搞砸了,忘記記錄我們其中一個人的年齡。我們也可以丟棄他們剩餘的資料,但這將是浪費;即使是不完整的列,對於某些分析(例如,我們可以計算身高和收入的相關性)仍然完全可用。傳統的處理方式是在遺失資料中放入一些特定的無意義值,例如,將此人的年齡記錄為 0。但是這非常容易出錯;我們稍後可能會在執行其他分析時忘記這些特殊值,並驚訝地發現嬰兒的收入高於青少年。(在這種情況下,解決方案是直接省略所有未記錄年齡的項目,但這不是通用的解決方案;許多分析需要更聰明的東西來處理遺失值。)因此,我們沒有使用像 0 這樣的普通值,而是定義了一個特殊的「遺失」值,寫作「NA」,代表「不可用」。

有幾種可能的方式可以在記憶體中表示這樣的值。例如,我們可以保留一個特定的值(例如 0,或特定的 NaN,或最小的負整數),然後確保此值在我們陣列上的所有算術和其他運算中都得到特殊處理。另一種選擇是在我們的主陣列旁邊新增一個額外的遮罩陣列,使用它來指示哪些值應被視為 NA,然後擴展我們的陣列運算,以便在執行計算時檢查此遮罩陣列。每種實作方法都有不同的優點和缺點,但這裡我們專注於前者(基於值)的方法,並將後者的可能新增留待未來討論。這種方法的核心優點是 (1) 它不會增加額外的記憶體開銷,(2) 使用現有的檔案儲存格式可以輕鬆地將此類陣列儲存和檢索到磁碟,(3) 它與包含 NA 值的 R 陣列二進位相容,(4) 它與使用 NaN 指示浮點數遺失的常見做法相容,(5) dtype 已經是「奇怪的事情可能發生」的地方 – 有各種各樣的 dtype 不像普通數字(包括結構、Python 物件、固定長度字串等),因此接受任意 NumPy 陣列的程式碼已經必須準備好處理這些(即使只是通過檢查它們並引發錯誤)。因此,新增更多新的 dtype 對擴展作者的影響小於我們變更 ndarray 物件本身。

NA 值的基本語意如下。與任何其他值一樣,它們必須由您陣列的 dtype 支援 – 您不能在 dtype=int32 的陣列中儲存浮點數,也不能在其中儲存 NA。您需要一個 dtype=NAint32 或類似的東西的陣列(確切的語法有待確定)。否則,NA 值的行為與任何其他值完全相同。特別是,您可以對它們應用算術函數等等。預設情況下,任何以 NA 作為引數的函數總是返回 NA,而不管其他引數的值如何。這確保了如果我們嘗試計算收入與年齡的相關性,我們將得到「NA」,意思是「假設某些條目可能是任何值,則答案也可能是任何值」。這提醒我們花一點時間思考我們應該如何重新措辭我們的問題,使其更有意義。並且為了方便那些確實決定只想知道已知年齡和收入之間相關性的時候,您可以通過向函數呼叫新增單一引數來啟用此行為。

對於浮點數計算,NA 和 NaN 具有(幾乎?)相同的行為。但它們代表不同的事物 – NaN 代表無效的計算,例如 0/0,NA 代表不可用的值 – 區分這些事物很有用,因為在某些情況下應區別對待它們。(例如,插補程序應將 NA 替換為插補值,但可能應保持 NaN 不變。)而且無論如何,我們不能對整數、字串或布林值使用 NaN,因此我們無論如何都需要 NA,並且一旦我們對所有這些型別都有 NA 支援,我們不妨也為了保持一致性而支援浮點數。

一般策略#

NumPy 已經有一種通用機制,用於定義新的 dtype 並將它們插入,以便 ndarray、轉換機制、ufunc 等支援它們。原則上,我們可以僅使用這些現有的介面實作 NA-dtype。但我們不想這樣做,因為從頭開始定義所有這些新的 ufunc 迴圈等將是一個巨大的麻煩,特別是因為所有情況下所需的基本功能是相同的。因此,我們需要一些用於 NA 的通用功能 – 但最好不要將其作為一組特殊的「NA 型別」內建,因為使用者很可能希望定義具有自己 NA 值的新自訂 dtype,並讓它們與其餘的 NA 機制良好整合。因此,我們的策略是避免 中間層錯誤,方法是公開一些用於不同情況下通用 NA 處理的程式碼,dtype 可以根據需要有選擇地使用或不使用。

一些範例用例
  1. 我們想要定義一個行為與 int32 完全相同的 dtype,除了將最負值視為 NA 之外。

  2. 我們想要定義一個參數化 dtype 來表示 類別資料,並且用於 NA 的位元模式取決於定義的類別數量,因此我們的程式碼需要積極參與處理它,而不是簡單地遵從標準機制。

  3. 我們想要定義一個行為類似於長度為 10 的字串並支援 NA 的 dtype。由於我們的字串可能包含任意二進位值,因此我們實際上想要為其分配 11 個位元組,第一個位元組是一個標誌,指示此字串是否為 NA,其餘位元組包含字串內容。

  4. 我們想要定義一個允許不同類型 NA 資料的 dtype,它們的列印方式不同,並且可以通過我們定義的新 ufunc is_na_of_type(...) 來區分,但在大多數操作中利用通用 NA 機制。

dtype C 語言層級 API 擴展#

PyArray_Descr 結構取得以下新欄位

void * NA_value;
PyArray_Descr * NA_extends;
int NA_extends_offset;

定義了以下新的標誌值

NPY_NA_AUTO_ARRFUNCS
NPY_NA_AUTO_CAST
NPY_NA_AUTO_UFUNC
NPY_NA_AUTO_UFUNC_CHECKED
NPY_NA_AUTO_ALL /* the above flags OR'ed together */

PyArray_ArrFuncs 結構取得以下新欄位

void (*isna)(void * src, void * dst, npy_intp n, void * arr);
void (*clearna)(void * data, npy_intp n, void * arr);

我們至少新增一個新的便利巨集

#define NPY_NA_SUPPORTED(dtype) ((dtype)->f->isna != NULL)

一般概念是,在我們過去呼叫特定於 dtype 的函數指標的任何地方,程式碼都將修改為改為

  1. 檢查是否啟用了相關的 NPY_NA_AUTO_... 位元,NA_extends 欄位是否為非 NULL,以及我們想要呼叫的函數指標是否為 NULL。

  2. 如果滿足這些條件,則使用 isna 來識別陣列中哪些條目是 NA,並適當地處理它們。然後在此 dtype 的 NA_extends dtype 上查找我們呼叫的任何函數,並使用它來處理非 NA 元素。

有關更多細節,請參閱以下章節。

請注意,如果 NA_extends 指向參數化 dtype,則它指向的 dtype 物件必須完全指定。例如,如果它是字串 dtype,則它必須具有非零的 elsize 欄位。

為了處理 NA 資訊儲存在 真實 資料旁邊的欄位中的情況,``NA_extends_offset`` 欄位設定為非零值;它必須指向此 dtype 的每個元素內的位置,在該位置可以找到 NA_extends dtype 的某些資料。例如,如果我們儲存了 10 位元組的字串,並且在開頭有一個 NA 指示器位元組,那麼我們有

elsize == 11
NA_extends_offset == 1
NA_extends->elsize == 10

當委派給 NA_extends dtype 時,我們將資料指標偏移 NA_extends_offset(同時保持我們的步幅不變),以便它看到預期型別的資料陣列(加上一些多餘的填充)。這基本上與記錄 dtype 使用的機制相同,IIUC,因此它應該經過了良好的測試。

當委派給無法處理「行為不端」來源資料的函數時(請參閱 PyArray_ArrFuncs 文件以取得詳細資訊),那麼我們需要在委派之前檢查對齊問題(尤其是對於非零 NA_extends_offset)。如果存在問題,當我們需要先「清理」來源資料時,使用用於處理未對齊資料的常用機制。(當然,我們通常應該設定我們的 dtype,以便不存在任何對齊問題,但如果有人搞砸了,或者決定減少記憶體使用量對他們來說比快速內部迴圈更重要,那麼我們仍然應該優雅地處理它,就像我們現在所做的那樣。)

NA_valueclearna 欄位用於各種型別轉換。NA_value 是在例如從 np.NA 指派時使用的位元模式。clearna 如果 elsizeNA_extends->elsize 相同,則可以是無操作,但如果它們不相同,則它應清除此 dtype 使用的任何輔助 NA 儲存空間,以便指定的陣列元素都不是 NA。

核心 dtype 函數#

以下函數在 PyArray_ArrFuncs 中定義。此處描述的特殊行為由 dtype 標誌中的 NPY_NA_AUTO_ARRFUNCS 位元啟用,並且僅在給定的函數欄位填寫時啟用。

getitem:呼叫 isna。如果 isna 傳回 true,則傳回 np.NA。否則,委派給 NA_extends dtype。

setitem:如果輸入物件是 np.NA,則執行 memcpy(self->NA_value, data, arr->dtype->elsize);。否則,呼叫 clearna,然後委派給 NA_extends dtype。

copyswapncopyswap:FIXME:不確定是否需要對這些使用任何特殊處理?

compare:FIXME:這應該如何處理 NA?R 的排序函數丟棄 NA,這似乎不是一個好的選擇。

argmax:FIXME:這是用來做什麼的?如果它是 np.max 的底層實作,那麼它確實需要某種方法來取得 skipna 引數。如果不是,那麼適當的語意取決於它應該完成什麼…

dotfunc:QUESTION:是否實際上保證一切都具有相同的 dtype?FIXME:與 argmax 相同的問題。

scanfunc:這是一個難題。我們可能必須在我們所有的特殊 dtype 中明確地覆寫它,因為假設我們想要選擇,例如,讓符號「NA」表示文字檔案中的 NA 值,我們需要某種方法來檢查它是否存在,然後再委派。但是 ungetc 僅保證讓我們放回 1 個字元,而我們需要 2 個(或者如果我們實際檢查「NA 」則可能需要 3 個)。另一種選擇是讀取到下一個分隔符,檢查我們是否有 NA,如果沒有,則委派給 fromstr 而不是 scanfunc,但根據目前的 API,每個 dtype 原則上可能使用完全不同的規則來定義「下一個分隔符」。所以… 有什麼想法嗎? (FIXME)

fromstr:簡單 – 檢查「NA 」,如果存在則指派 NA_value,否則呼叫 clearna 並委派。

nonzero:FIXME:再次,這是用來做什麼的?(它似乎與使用轉換機制轉換為布林值是多餘的。)可能需要修改它,以便它可以傳回 NA…

fill:使用 isna 來檢查前兩個值中的任何一個是否為 NA。如果是,則用 NA_value 填滿陣列的其餘部分。否則,呼叫 clearna 然後委派。

fillwithvalue:猜測這可以直接委派?

sortargsort:這些可能應該安排將 NA 排序到陣列中的特定位置(前面或後面 – 有任何意見嗎?)

scalarkind:FIXME:我不知道這是做什麼的。

castdictcancastscalarkindtocancastto:請參閱下方的型別轉換章節。

型別轉換#

FIXME:這確實需要 NumPy 型別轉換規則專家的關注。但我似乎找不到解釋如何查找和決定型別轉換迴圈的文件(例如,如果您要從 dtype A 轉換為 dtype B,則使用哪個 dtype 的迴圈?),所以我無法深入細節。但這些細節很棘手,而且很重要…

但一般概念是,如果您有一個設定了 NPY_NA_AUTO_CAST 的 dtype,則自動允許以下轉換

  • 從底層型別轉換為 NA 型別:這由

  • 常用的 clearna + 可能步幅的複製舞步執行。此外,isna

  • 呼叫以檢查是否沒有常規值被意外

  • 轉換為 NA;如果是,則會引發錯誤。

  • 從 NA 型別轉換為底層型別:原則上允許,但如果 isna 對要轉換的任何值傳回 true,則再次引發錯誤。(如果您想繞過此限制,請使用 np.view(array_with_NAs, dtype=float)。)

  • 在 NA 型別和不支援 NA 的其他型別之間轉換:如果允許底層型別轉換為其他型別,則允許此轉換,並且通過結合與底層型別之間的轉換(使用上述規則)以及與其他型別之間的轉換(使用底層型別的規則)來執行。

  • 在 NA 型別和支援 NA 的其他型別之間轉換:如果其他型別設定了 NPY_NA_AUTO_CAST,則我們使用上述規則以及在一個陣列上使用 isna 轉換為另一個陣列中的 NA_value 元素的常用舞步。如果只有一個陣列設定了 NPY_NA_AUTO_CAST,則假定該 dtype 知道它在做什麼,並且我們不執行任何魔法。(但這是我不確定是否有意義的事情之一,如我的警告所述。)

Ufuncs#

所有 ufunc 都取得一個額外的可選關鍵字引數 skipNA=,預設值為 False。

如果 skipNA == True,則 ufunc 機制無條件地為任何 NPY_NA_SUPPORTED(dtype) 為 true 的 dtype 呼叫 isna,然後表現得好像 where= 引數中遮罩了 isna 傳回 True 的任何值(請參閱 miniNEP 1 以取得 where= 的行為)。如果也給定了 where= 引數,則它的行為就好像 isna 值已從 where= 遮罩中 ANDed 掉,儘管它實際上並未修改遮罩。與以下其他變更不同,這對於任何已定義 isna 函數的 dtype 無條件執行;檢查 NPY_NA_AUTO_UFUNC 標誌。

如果設定了 NPY_NA_AUTO_UFUNC,則修改 ufunc 迴圈查找,以便每當它檢查目前 dtype 上是否存在迴圈,並且未找到迴圈時,它也會檢查 NA_extends dtype 上是否存在迴圈。如果找到該迴圈,則以正常方式使用它,但有以下例外:(1) 它僅針對根據 isna 不是 NA 的值呼叫,(2) 如果輸出陣列設定了 NPY_NA_AUTO_UFUNC,則在呼叫 ufunc 迴圈之前,會在其上呼叫 clearna,(3) 在呼叫 ufunc 迴圈之前,指標偏移會按 NA_extends_offset 調整。此外,如果設定了 NPY_NA_AUTO_UFUNC_CHECK,則在評估 ufunc 迴圈後,我們會在輸出陣列上呼叫 isna,並且如果輸出中存在任何輸入中沒有的 NA,則我們會引發錯誤。(這樣做的目的是捕獲以下情況:例如,我們使用最負整數表示 NA,然後某人的算術溢位意外地建立這樣的值。)

FIXME:我們應該更詳細地介紹當有多個輸入陣列時,NPY_NA_AUTO_UFUNC 如何工作,其中可能有些陣列設定了標誌,而有些陣列沒有設定。

列印#

FIXME:應該有一種機制,通過該機制,NA 值會自動 repr 為 NA,但我不太了解 NumPy 列印是如何工作的,因此我將讓其他人填寫此章節。

索引#

a[12] 這樣的純量索引通過 getitem 函數進行,因此根據上述提案,如果 dtype 委派 getitem,則對 NA 進行純量索引將傳回物件 np.NA。(如果它不委派 getitem,當然,它可以傳回它想要的任何東西。)

這似乎是最簡單的方法,但另一種選擇是在純量索引中新增一個特殊情況,如果設定了 NPY_NA_AUTO_INDEX 標誌,則它會在指定的元素上呼叫 isna。如果這傳回 false,它將像往常一樣呼叫 getitem;否則,它將傳回一個包含指定元素的 0-d 陣列。這樣做的問題是它會破壞像 if a[i] is np.NA: ... 這樣的表達式。(當然,對於 NaN 值,現在沒有像這樣方便的東西,但是,NaN 值沒有它們自己的全域單例。)因此,目前我們堅持純量索引僅傳回 np.NA,但如果有人反對,可以重新審視這一點。

用於通用 NA 支援的 Python API#

NumPy 將獲得一個名為 numpy.NA 的全域單例,類似於 None,但其語意反映了其作為遺失值的狀態。特別是,嘗試將其視為布林值將引發例外,並且與它的比較將產生 numpy.NA 而不是 True 或 False。這些基本知識是從 R 專案中 NA 值的行為中採用的。要深入研究這些想法,http://en.wikipedia.org/wiki/Ternary_logic#Kleene_logic 提供了一個起點。

大多數對 np.NA 的運算(例如,__add____mul__)都被覆寫為無條件地傳回 np.NA

用於像 np.asarray([1, 2, 3])np.asarray([1.0, 2.0. 3.0]) 這樣的表達式的自動魔法 dtype 偵測將被擴展以識別 np.NA 值,並使用它來自動切換到內建的啟用 NA 的 dtype(哪一個由陣列中的其他元素決定)。一個簡單的 np.asarray([np.NA]) 將使用啟用 NA 的 float64 dtype(這類似於您從 np.asarray([]) 獲得的結果)。請注意,這意味著像 np.log(np.NA) 這樣的表達式將起作用:首先 np.NA 將被強制轉換為 0-d NA-float 陣列,然後將在該陣列上呼叫 np.log

Python 層級 dtype 物件取得以下新欄位

NA_supported
NA_value

NA_supported 是一個布林值,它只是公開 NPY_NA_SUPPORTED 標誌的值;如果此 dtype 允許 NA,則應為 true,否則為 false。[FIXME:最好只是根據 isna 函數的存在來鍵入此值嗎?即使 dtype 決定自行實作所有其他 NA 處理,它仍然必須定義 isna 才能使 skipNA= 正確工作。]

NA_value 是一個給定 dtype 的 0-d 陣列,其唯一元素包含與 dtype 的底層 NA_value 欄位相同的位元模式。這使得可以確定此型別的 NA 值的預設位元模式(例如,使用 np.view(mydtype.NA_value, dtype=int8))。

我們在 Python 層級公開 NA_extendsNA_extends_offset 值,至少目前是這樣;它們被認為是實作細節(並且如果需要它們,稍後公開它們比不需要它們時取消公開它們更容易)。

定義了兩個新的 ufunc:np.isNA 傳回一個邏輯陣列,其中 dtype 的 isna 函數傳回 true 的地方為 true 值。np.isnumber 僅針對數值 dtype 定義,對於所有不是 NA 的元素以及 np.isfinite 將傳回 True 的元素,傳回 True。

內建 NA dtype#

以上描述了 dtype 中 NA 支援的通用機制。它足夠靈活,可以處理各種情況,但我們也想定義一些通常有用的預設可用的 NA 支援 dtype。

對於每個內建 dtype,我們定義一個關聯的 NA 支援 dtype,如下所示

  • 浮點數:關聯的 dtype 使用特定的 NaN 位元模式來指示 NA(為 R 相容性而選擇)

  • 複數:我們做 R 所做的任何事情(FIXME:查找這個 – 可能是兩個 NA 浮點數?)

  • 帶符號整數:最負的帶符號值用作 NA(為 R 相容性而選擇)

  • 不帶符號整數:最正值用作 NA(不可能與 R 相容)。

  • 字串:第一個位元組(或者,在 unicode 字串的情況下,前 4 個位元組)用作指示 NA 的標誌,資料的其餘部分給出實際的字串。(不可能與 R 相容)

  • 物件:兩個選項 (FIXME):我們要麼不包含啟用 NA 的版本,要麼使用 np.NA 作為 NA 位元模式。

  • 布林值 (boolean): 我們會做任何 R 語言會做的事 (待辦事項: 查閱一下 – 0 == FALSE, 1 == TRUE, 2 == NA?)

這些 dtype 中的每一個都使用上述機制被簡單地定義,並且是自動型別推斷機制 (用於 np.asarray([True, np.NA, False]) 等) 自動使用的類型。

它們也可以透過一個新的函式 np.withNA 存取,該函式接受一個常規的 dtype (或可以強制轉換為 dtype 的物件,例如 'float') 並返回上述 dtype 之一。理想情況下,withNA 也應該接受一些可選參數,讓您描述您想將哪些值視為 NA 等,但我會將其留到以後的草稿中 (待辦事項)。

待辦事項: 如果 d 是上述 dtype 之一,那麼 d.type 應該返回什麼?

NEP 也包含一個關於用於描述 NA dtype 的稍微複雜的領域特定語言的提案。我不確定這是否是一個好主意。(我對使用字串作為資料結構有偏見,並且發現現有的字串已經夠令人困惑了 – 此外,顯然 NEP 版本的 NumPy 在列印 dtype 時使用像 'f8' 這樣的字串,而我的 NumPy 使用像 'float64' 這樣的物件名稱,所以我不太確定那裡發生了什麼。withNA(float64, arg1=value1) 看起來比 “NA[f8,value1]” 更令人愉悅的 dtype 列印方式,至少對我而言是如此。) 但是如果人們想要它,那就很酷。

型別層級結構#

待辦事項: 我們應該如何對 NA dtype 進行子類型檢查等等?issubdtype(withNA(float), float) 返回什麼?issubdtype(withNA(float), np.floating) 呢?

序列化#