NEP 40 — NumPy 中的舊版資料類型實作#

標題:

NumPy 中的舊版資料類型實作

作者:

Sebastian Berg

狀態:

最終

類型:

資訊性

建立日期:

2019-07-17

注意

此 NEP 是系列的第一篇

  • NEP 40 (本文檔) 說明 NumPy dtype 實作的缺點。

  • NEP 41 概述我們提出的替代方案。

  • NEP 42 描述新設計與資料類型相關的 API。

  • NEP 43 描述新設計用於通用函數的 API。

摘要#

作為進一步 NumPy 增強提案 41、42 和 43 的準備。本 NEP 詳細說明截至 NumPy 1.18 的 NumPy 資料類型現狀。它描述了一些促成其他提案的技術層面和概念。對於更一般的資訊,大多數讀者應該從閱讀 NEP 41 開始,並僅將本文檔用作參考或額外詳細資訊。

詳細描述#

本節描述了一些核心概念,並簡要概述了 dtype 的當前實作以及討論。在許多情況下,小節將大致分為兩部分:首先描述當前實作,然後是「問題與討論」部分。

參數化資料類型#

某些資料類型本質上是參數化的。所有 np.flexible 純量類型都附加到參數化資料類型 (string、bytes 和 void)。純量值的類別 np.flexible 是變長資料類型(string、bytes 和 void)的超類別。C-巨集 PyDataType_ISFLEXIBLEPyTypeNum_ISFLEXIBLE 也類似地揭示了這種區別。這種彈性推廣到可以在陣列內部表示的值集。例如,"S8" 可以表示比 "S4" 更長的字串。因此,參數化字串資料類型也將陣列內的值限制為字串純量值可以表示的所有值的子集(或子類型)。

基本數值資料類型不是彈性的(不繼承自 np.flexible)。float64float32 等確實具有位元組順序,但描述的值不受其影響,並且始終可以將它們轉換為原生、標準的表示形式,而不會遺失任何資訊。

彈性的概念可以推廣到參數化資料類型。例如,私有 PyArray_AdaptFlexibleDType 函數也接受樸素的 datetime dtype 作為輸入,以找到正確的時間單位。因此,datetime dtype 的參數化不是在其儲存大小中,而是在儲存的值所代表的內容中。目前,np.can_cast("datetime64[s]", "datetime64[ms]", casting="safe") 傳回 true,儘管尚不清楚這是否是期望的結果,或者是否可以推廣到未來可能的資料類型,例如物理單位。

因此,我們擁有一些資料類型(主要是字串),它們具有以下屬性:

  1. 轉換不總是安全的 (np.can_cast("S8", "S4"))

  2. 陣列強制轉換應該能夠發現確切的 dtype,例如對於 np.array(["str1", 12.34], dtype="S"),其中 NumPy 發現結果 dtype 為 "S5"。(如果省略 dtype 參數,則目前行為不明確 [gh-15327]。)類似於 dtype="S" 的形式是 dtype="datetime64",它可以發現單位:np.array(["2017-02"], dtype="datetime64")

這個概念突顯出某些資料類型比基本數值類型更複雜,這在通用函數的複雜輸出類型發現中很明顯。

基於值的轉換#

轉換通常在兩種類型之間定義:當第二種類型可以表示第一種類型的所有值而不會遺失資訊時,認為第一種類型可以安全地轉換為第二種類型。NumPy 可以檢查實際值,以判斷轉換是否安全。

這對於諸如以下的表達式非常有用:

arr = np.array([1, 2, 3], dtype="int8")
result = arr + 5
assert result.dtype == np.dtype("int8")
# If the value is larger, the result will change however:
result = arr + 500
assert result.dtype == np.dtype("int16")

在此表達式中,python 值(最初沒有資料類型)表示為 int8int16(最小可能的資料類型)。

NumPy 目前甚至對 NumPy 純量值和零維陣列執行此操作,因此在上述表達式中將 5 替換為 np.int64(5)np.array(5, dtype="int64") 將導致相同的結果,因此忽略現有的資料類型。相同的邏輯也適用於浮點純量值,它們允許遺失精度。當兩個輸入都是純量值時,不會使用此行為,因此 5 + np.int8(5) 傳回預設整數大小(32 位元或 64 位元),而不是 np.int8

雖然此行為是根據轉換定義的,並由 np.result_type 揭露,但它主要對於通用函數(例如上述範例中的 np.add)很重要。通用函數目前依賴安全的轉換語意來判斷應使用哪個迴圈,以及輸出資料類型將是什麼。

問題與討論#

對於具有資料類型的值,目前的方法似乎並不受歡迎,但對於第一個範例中的純 python 整數或浮點數可能很有用,這似乎已達成共識。但是,資料類型系統和通用函數調度的任何變更都必須首先完全支援目前的行為。主要困難在於,例如,值 156 可以由 np.uint8np.int16 表示。結果取決於轉換上下文中的「最小」表示形式(對於 ufunc,上下文可能取決於迴圈順序)。

object 資料類型#

object 資料類型目前充當任何其他方式無法表示的值的通用後備方案。但是,由於沒有明確定義的類型,因此它存在一些問題,例如,當陣列填充了 Python 序列時

>>> l = [1, [2]]
>>> np.array(l, dtype=np.object_)
array([1, list([2])], dtype=object)  # a 1d array

>>> a = np.empty((), dtype=np.object_)
>>> a[...] = l
ValueError: assignment to 0-d array  # ???
>>> a[()] = l
>>> a
array(list([1, [2]]), dtype=object)

在沒有明確定義的類型的情況下,諸如 isnan()conjugate() 之類的函數不一定能運作,但可以針對 decimal.Decimal 運作。為了改善這種情況,似乎需要更容易地建立 object dtype,它們表示特定的 Python 資料類型,並以指向 python PyObject 的指標形式將其物件儲存在陣列中。與大多數資料類型不同,Python 物件需要垃圾回收。這表示必須定義處理參考和訪問所有物件的其他方法。實際上,對於大多數用例,限制此類資料類型的建立就足夠了,以便與 Python C 級參考相關的所有功能對於 NumPy 而言都是私有的。

建立與內建 Python 物件匹配的 NumPy 資料類型也會產生一些需要更多思考和討論的問題。這些問題不需要立即解決

  • NumPy 目前在某些情況下甚至對於陣列輸入也會傳回純量值,在大多數情況下,這可以無縫運作。但是,這僅在 NumPy 純量值的行為很像 NumPy 陣列時才成立,而通用 Python 物件不具有此功能。

  • 無縫整合可能需要 np.array(scalar) 自動找到正確的 DType,因為某些操作(例如索引)會傳回純量值而不是 0D 陣列。如果多個使用者獨立決定實作例如 decimal.Decimal 的 DType,則會出現問題。

目前的 dtype 實作#

目前,np.dtype 是一個 Python 類別,其執行個體是 np.dtype(">float64") 等執行個體。為了設定這些執行個體的實際行為,原型執行個體會全域儲存,並根據 dtype.typenum 進行查閱。單例模式盡可能使用。在需要時,會複製和修改它,例如變更位元組順序。

參數化資料類型(字串、void、datetime 和 timedelta)必須儲存額外的資訊,例如字串長度、欄位或 datetime 單位 – 會建立這些類型的新執行個體,而不是依賴單例模式。NumPy 中的所有目前資料類型都進一步支援在建立期間設定中繼資料欄位,該欄位可以設定為任意字典值,但在實務中似乎很少使用(最近和著名的使用者之一是 h5py)。

許多特定於資料類型的函數在名為 PyArray_ArrFuncs 的 C 結構中定義,該結構是每個 dtype 執行個體的一部分,並且與 Python 的 PyNumberMethods 具有相似之處。對於使用者定義的資料類型,此結構向使用者公開,使得 ABI 相容的變更不可能實現。此結構包含重要的資訊,例如如何複製或轉換,並為函數指標提供空間,例如比較元素、轉換為布林值或排序。由於其中一些函數是向量化操作,可以對多個元素進行操作,因此它們符合 ufunc 的模型,並且未來不需要在資料類型上定義。例如,np.clip 函數先前是使用 PyArray_ArrFuncs 實作的,現在作為 ufunc 實作。

討論與問題#

dtype 上函數的目前實作的另一個問題是,與方法不同,它們在呼叫時不會傳遞 dtype 的執行個體。相反,在許多情況下,傳入正在操作的陣列,並且通常僅用於再次擷取資料類型。未來的 API 可能應停止傳入完整的陣列物件。由於有必要回退到舊的定義以實現向後相容性,因此陣列物件可能不可用。但是,傳入主要定義了資料類型的「虛擬」陣列可能是一種足夠的因應措施(請參閱向後相容性;有時也可能需要對齊資訊)。

雖然在 NumPy 本身之外沒有廣泛使用,但目前的 PyArray_Descr 是一個公共結構。對於儲存在 f 欄位中的 PyArray_ArrFuncs 結構而言,情況尤其如此。由於相容性,它們可能需要在很長一段時間內保持支援,並有可能用調度到較新 API 的函數來取代它們。

但是,從長遠來看,可能必須棄用對這些結構的存取。

NumPy 純量值和類型階層#

作為上述資料類型實作的旁注:與資料類型不同,NumPy 純量值目前確實提供類型階層,該階層由諸如 np.inexact 之類的抽象類型組成(請參閱下圖)。實際上,NumPy 中的某些控制流程目前使用 issubclass(a.dtype.type, np.inexact)

_images/nep-0040_dtype-hierarchy.png

圖: 從參考文件中複製的 NumPy 純量值類型階層。排除了一些別名,例如 np.intp。未顯示 Datetime 和 timedelta。#

NumPy 純量值嘗試模擬具有固定資料類型的零維陣列。對於數值(和 unicode)資料類型,它們進一步限制為原生位元組順序。

目前的轉換實作#

資料類型需要支援的主要功能之一是使用 arr.astype(new_dtype, casting="unsafe") 在彼此之間進行轉換,或者在使用不同類型(例如新增整數和浮點數)的 ufunc 執行期間進行轉換。

轉換表判斷是否可以從一種特定類型轉換為另一種類型。但是,通用轉換規則無法處理參數化 dtype,例如字串。參數化資料類型的邏輯主要在 PyArray_CanCastTo 中定義,並且目前無法針對使用者定義的資料類型進行自訂。

實際的轉換有兩個不同的部分

  1. copyswap/copyswapn 是為每個 dtype 定義的,並且可以處理非原生位元組順序以及未對齊記憶體的位元組交換。

  2. 通用轉換程式碼由 C 函數提供,這些函數知道如何將對齊且連續的記憶體從一個 dtype 轉換為另一個 dtype(均為原生位元組順序)。這些 C 級函數可以註冊以將對齊且連續的記憶體從一個 dtype 轉換為另一個 dtype。可以為函數提供兩個陣列(儘管純量值的參數有時為 NULL)。NumPy 將確保這些函數接收原生位元組順序輸入。目前的實作將函數儲存在資料類型上的 C 陣列中,或在轉換為使用者定義的資料類型時儲存在字典中。

因此,一般而言,NumPy 將使用函數鏈 in_copyswapn -> castfunc -> out_copyswapn 執行轉換,並在這些步驟之間使用(小)緩衝區。

上述多個函數被包裝到一個單一函數(帶有中繼資料)中,該函數處理轉換,並用於例如 ufunc 使用的緩衝迭代期間。這是始終用於使用者定義資料類型的機制。對於 NumPy 本身中定義的大多數 dtype,使用了更專業的程式碼來尋找執行實際轉換的函數(由私有 PyArray_GetDTypeTransferFunction 定義)。此機制取代了上述大多數機制,並為例如輸入在記憶體中不連續時提供了更快的轉換。但是,它不能由使用者定義的資料類型擴展。

與轉換相關的是,我們目前有一個 PyArray_EquivTypes 函數,它指示視圖就足夠了(因此不需要轉換)。此函數在多個位置使用,並且可能應該是重新設計的轉換 API 的一部分。

通用函數中的 DType 處理#

通用函數作為 numpy.UFunc 類別的執行個體實作,其中包含一個資料類型特定的(基於 dtype 類型程式碼字元,而不是資料類型執行個體)實作的有序列表,每個實作都具有簽名和函數指標。可以使用 ufunc.types 查看此實作列表,其中列出了所有實作及其 C 樣式類型程式碼簽名。例如

>>> np.add.types
[...,
 'll->l',
 ...,
 'dd->d',
 ...]

每個簽名都與在 C 中定義的單個內部迴圈函數相關聯,該函數執行實際計算,並且可能會被多次呼叫。

尋找正確的內部迴圈函數的主要步驟是呼叫 PyUFunc_TypeResolutionFunc,它從提供的輸入陣列中擷取輸入 dtype,並將判斷要執行的完整類型簽名(包括輸出 dtype)。

預設情況下,TypeResolver 是透過按順序搜尋 ufunc.types 中列出的所有實作來實作的,如果可以安全地轉換所有輸入以符合簽名,則停止搜尋。這表示如果新增了 long (l) 和 double (d) 陣列,numpy 將發現 'dd->d' 定義有效(long 可以安全地轉換為 double)並使用它。

在某些情況下,這並不受歡迎。例如,np.isnat 通用函數具有 TypeResolver,它拒絕整數輸入,而不是允許將它們轉換為浮點數。原則上,下游專案目前可以使用它們自己的非預設 TypeResolver,因為執行此操作所需的相應 C 結構是公開的。已知執行此操作的唯一專案是 Astropy,如果 NumPy 要移除取代 TypeResolver 的可能性,它願意切換到新的 API。

對於使用者定義的資料類型,調度邏輯類似,儘管單獨實作且受到限制(請參閱下面的討論)。

問題與討論#

目前僅當任何輸入(或輸出)具有使用者資料類型時,才能找到/解析使用者定義的函數,因為它使用 OO->O 簽名。例如,假設已實作 ufunc 迴圈以實作 fraction_divide(int, int) -> Fraction,則呼叫 fraction_divide(4, 5)(沒有特定的輸出 dtype)將會失敗,因為僅當任何輸入已經是 Fraction 時,才能找到包含使用者資料類型 Fraction(作為輸出)的迴圈。fraction_divide(4, 5, dtype=Fraction) 可以使其運作,但很不方便。

通常,調度是透過尋找第一個匹配的迴圈來完成的。匹配定義為:所有輸入(以及可能的輸出)都可以安全地轉換為簽名類型程式碼(另請參閱目前的實作部分)。但是,在某些情況下,安全轉換存在問題,因此明確禁止使用。例如,np.isnat 函數目前僅針對 datetime 和 timedelta 定義,即使整數定義為可以安全地轉換為 timedelta。如果情況並非如此,則呼叫 np.isnat(np.array("NaT", "timedelta64").astype("int64")) 目前會傳回 true,即使整數輸入陣列沒有「非時間」的概念。如果通用函數(例如 scipy.special 中的大多數函數)僅針對 float32float64 定義,則它目前會自動將 float16 靜默轉換為 float32(對於任何整數輸入也類似)。這確保了成功執行,但可能會導致在將新資料類型新增到 ufunc 時輸出 dtype 發生變更。當新增 float16 迴圈時,輸出資料類型目前將從 float32 變更為 float16,而不會發出警告。

一般而言,註冊迴圈的順序很重要。但是,僅當在首次定義 ufunc 時新增所有迴圈時,此方法才可靠。當匯入新的使用者資料類型時新增的其他迴圈不得對匯入發生的順序敏感。

有兩種主要方法可以更好地定義使用者定義類型的類型解析

  1. 允許使用者 dtype 直接影響迴圈選擇。例如,當沒有完全匹配的迴圈可用時,它們可以提供傳回/選擇迴圈的函數。

  2. 定義所有實作/迴圈的總排序,可能基於「安全轉換」語意,或類似於此的語意。

雖然選項 2 在推理方面可能不那麼複雜,但它是否足以滿足所有(或大多數)用例仍有待觀察。

調整 UFunc 中的參數化輸出 DType#

參數化 dtype 所需的第二個步驟目前在 TypeResolver 中執行:datetime 和 timedelta 資料類型必須決定操作和輸出陣列的正確參數。此步驟還需要仔細檢查所有轉換是否可以安全地執行,預設情況下,這表示它們是「相同種類」的轉換。

問題與討論#

修正正確的輸出 dtype 目前是類型解析的一部分。但是,這是一個不同的步驟,可能應該在實際的類型/迴圈解析發生後作為這樣處理。

因此,此步驟可能會從調度步驟(如上所述)移至下面描述的特定於實作的程式碼。

UFunc 的特定於 DType 的實作#

一旦找到正確的實作/迴圈,UFunc 目前會呼叫一個以 C 語言編寫的單一內部迴圈函數。可以多次呼叫它以執行完整計算,並且它幾乎或完全沒有關於目前上下文的資訊。它還具有 void 傳回值。

問題與討論#

參數化資料類型可能需要將額外資訊傳遞到內部迴圈函數,以決定如何解譯資料。這就是目前不存在 string dtype 的通用函數的原因(儘管在 NumPy 本身中技術上是可行的)。請注意,目前可以傳入輸入陣列物件(在不需要轉換時,這些物件又會保留資料類型)。但是,不應需要完整的陣列資訊,並且目前在發生任何轉換之前傳入陣列。此功能在 NumPy 中未使用,並且沒有已知的使用者。

另一個問題是內部迴圈函數中的錯誤報告。目前有兩種方法可以執行此操作

  1. 透過設定 Python 例外狀況

  2. 使用 CPU 浮點錯誤標誌。

在傳回給使用者之前,會檢查這兩者。但是,許多整數函數目前都無法設定這些錯誤,因此檢查浮點錯誤標誌是不必要的開銷。另一方面,沒有辦法停止迭代或傳遞不使用浮點標誌或需要保留 Python 全域解譯器鎖定 (GIL) 的錯誤資訊。

似乎有必要為內部迴圈函數的作者提供更多控制權。這表示允許使用者更輕鬆地從內部迴圈函數傳入和傳出資訊,同時提供輸入陣列物件。最有可能的是,這將涉及

  • 允許在第一次和最後一次內部迴圈呼叫之前和之後執行額外程式碼。

  • 從內部迴圈傳回整數值,以允許提早停止迭代並可能傳播錯誤資訊。

  • 可能允許專門的內部迴圈選擇。例如,目前 matmul 和許多歸約將針對某些輸入執行最佳化程式碼。允許預先選擇此類最佳化迴圈可能是有意義的。允許這樣做也可能有助於使轉換(大量使用此功能)和 ufunc 實作更接近。

github 問題 gh-12518 中詳細討論了圍繞內部迴圈函數的問題。

歸約使用「身分」值。目前,每個 ufunc 定義一次,而與 ufunc dtype 簽名無關。例如,0 用於 sum,或 math.inf 用於 min。這對於數值資料類型效果很好,但對於其他 dtype 並非總是合適的。一般而言,應該可以為 ufunc 歸約提供特定於 dtype 的身分。

陣列強制轉換期間的資料類型發現#

當呼叫 np.array(...) 以將通用 Python 物件強制轉換為 NumPy 陣列時,需要檢查所有物件以找到正確的 dtype。np.array() 的輸入可能是巢狀 Python 序列,這些序列將最終元素保留為通用 Python 物件。NumPy 必須解壓縮所有巢狀序列,然後檢查元素。最終資料類型是透過迭代將最終進入陣列的所有元素並執行以下操作來找到的:

  1. 發現單個元素的 dtype

    • 從陣列(或類似陣列)或 NumPy 純量值使用 element.dtype

    • 針對已知的 Python 類型使用 isinstance(..., float)(請注意,這些規則表示子類別目前有效)。

    • 用於強制轉換元組的 void 資料類型的特殊規則。

  2. 使用 np.promote_types 升級目前的 dtype 和下一個元素的 dtype。

  3. 如果找到字串,整個流程會重新啟動 (另請參閱[gh-15327]),方式類似於給定 dtype="S" (請參閱下方) 的情況。

如果給定 dtype=...,則會直接使用此 dtype,除非它是不特定的參數化 dtype 實例,意即 “S0”、“V0”、“U0”、“datetime64” 和 “timdelta64”。因此,這些是沒有長度 0 的彈性資料類型 – 被視為無大小 – 以及沒有附加單位的日期時間或時間差 (“通用單位”)。

在未來的 DType 類別階層中,這些可能會由類別而不是特殊的實例來表示,因為這些特殊的實例通常不應附加到陣列。

如果提供了這樣的參數化 dtype 實例,例如使用 dtype="S",則會呼叫 PyArray_AdaptFlexibleDType 並有效地使用 DType 特定邏輯檢查所有值。也就是說

  • 字串將使用 str(element) 來尋找大多數元素的長度

  • Datetime64 能夠從字串強制轉換並猜測正確的單位。

討論與議題#

在正常的探索過程中,isinstance 應該更嚴格地檢查 type(element) is desired_type。此外,目前的 AdaptFlexibleDType 邏輯應提供給使用者 DType 使用,而不應作為次要步驟,而是取代或成為正常探索的一部分。

討論#

關於目前狀態以及未來資料類型系統可能的外觀,已經有很多討論。這些討論的完整列表很長,有些已經隨著時間流逝而遺失,以下提供了一些最近的子集

參考文獻#