NEP 42 — 全新且可擴充的 DType#

標題:

全新且可擴充的 DType

作者:

Sebastian Berg

作者:

Ben Nathanson

作者:

Marten van Kerkwijk

狀態:

已接受

類型:

標準

建立時間:

2019-07-17

決議:

https://mail.python.org/pipermail/numpy-discussion/2020-October/081038.html

注意

本 NEP 是系列文章的第三篇

  • NEP 40 說明 NumPy 的 dtype 實作的缺點。

  • NEP 41 概述我們建議的替代方案。

  • NEP 42 (本文件) 描述新設計中與資料型別相關的 API。

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

摘要#

NumPy 的 dtype 架構是單體的 — 每個 dtype 都是單一類別的實例。沒有原則性的方法可以針對新的 dtype 擴充它,而且程式碼難以閱讀和維護。

NEP 41 所述,我們正在提出一個新的架構,該架構是模組化且對使用者新增開放的。dtype 將衍生自新的 DType 類別,該類別作為新類型的擴充點。np.dtype("float64") 將傳回 Float64 類別的實例,它是根類別 np.dtype 的子類別。

本 NEP 是闡述此新架構的設計和 API 的兩個 NEP 之一。本 NEP 討論 dtype 實作;NEP 43 討論通用函式。

注意

私有和外部 API 的詳細資訊可能會變更,以反映使用者意見和實作限制。基本原則和選擇不應有顯著變更。

動機與範疇#

我們的目標是允許使用者程式碼為廣泛的用途建立功能完整的 dtype,從物理單位(例如公尺)到幾何物件的特定領域表示法。NEP 41 描述了許多這些新的 dtype 及其優點。

任何支援 dtype 的設計都必須考慮

  • 建立陣列時如何判斷形狀和 dtype

  • 如何儲存和存取陣列元素

  • 將 dtype 轉換為其他 dtype 的規則

此外

  • 我們希望 dtype 組成一個類別階層,對新類型和子階層開放,如 NEP 41 中所述。

為了提供此功能,

  • 我們需要定義使用者 API。

所有這些都是本 NEP 的主題。

  • 類別階層、其與 Python 純量值型別的關係,及其重要屬性在 nep42_DType 類別中描述。

  • 將支援 dtype 轉換的功能在 轉換中描述。

  • 項目存取和儲存的實作,以及在建立陣列時如何判斷形狀和 dtype,在 與 Python 物件之間的強制型轉中描述。

  • 使用者定義自己的 DType 的功能在 公開 C-API中描述。

此處和 NEP 43 中的 API 完全位於 C 語言端。Python 端版本將在未來的 NEP 中提出。未來的 Python API 預計會類似,但會提供更方便的 API 來重複使用現有 DType 的功能。它也可能提供速記來建立類似於 Python 資料類別的結構化 DType。

向後相容性#

預計中斷程度不會大於典型的 NumPy 版本。

  • 主要問題在 NEP 41 中指出,並且主要會影響 NumPy C-API 的重度使用者。

  • 最終,我們希望棄用目前用於建立使用者定義 dtype 的 API。

  • 小的、很少注意到的不一致性可能會變更。範例

    • np.array(np.nan, dtype=np.int64) 的行為與 np.array([np.nan], dtype=np.int64) 不同,後者會引發錯誤。這可能需要相同的結果(兩者都錯誤或兩者都成功)。

    • np.array([array_like]) 有時的行為與 np.array([np.array(array_like)]) 不同

    • 陣列運算可能會或可能不會保留 dtype 元資料

  • 描述 dtype 內部結構的文件需要更新。

新程式碼必須通過 NumPy 的常規測試套件,以確保變更與現有程式碼相容。

使用方式與影響#

我們相信本節中的少量結構足以鞏固 NumPy 目前的功能,並支援複雜的使用者定義 DType。

NEP 的其餘部分填補詳細資訊,並為此主張提供支援。

同樣,雖然 Python 用於說明,但實作僅為 C API;未來的 NEP 將處理 Python API。

在實作本 NEP 之後,將可以透過實作以下概述的 DType 基礎類別來建立 DType,這在 nep42_DType 類別中進一步描述

class DType(np.dtype):
    type : type        # Python scalar type
    parametric : bool  # (may be indicated by superclass)

    @property
    def canonical(self) -> bool:
        raise NotImplementedError

    def ensure_canonical(self : DType) -> DType:
        raise NotImplementedError

對於轉換,大部分功能由儲存在 _castingimpl 中的「方法」提供

    @classmethod
    def common_dtype(cls : DTypeMeta, other : DTypeMeta) -> DTypeMeta:
        raise NotImplementedError

    def common_instance(self : DType, other : DType) -> DType:
        raise NotImplementedError

    # A mapping of "methods" each detailing how to cast to another DType
    # (further specified at the end of the section)
    _castingimpl = {}

對於陣列強制型轉,也是轉換的一部分

    def __dtype_setitem__(self, item_pointer, value):
        raise NotImplementedError

    def __dtype_getitem__(self, item_pointer, base_obj) -> object:
        raise NotImplementedError

    @classmethod
    def __discover_descr_from_pyobject__(cls, obj : object) -> DType:
        raise NotImplementedError

    # initially private:
    @classmethod
    def _known_scalar_type(cls, obj : object) -> bool:
        raise NotImplementedError

轉換實作的其他元素是 CastingImpl

casting = Union["safe", "same_kind", "unsafe"]

class CastingImpl:
    # Object describing and performing the cast
    casting : casting

    def resolve_descriptors(self, Tuple[DTypeMeta], Tuple[DType|None] : input) -> (casting, Tuple[DType]):
        raise NotImplementedError

    # initially private:
    def _get_loop(...) -> lowlevel_C_loop:
        raise NotImplementedError

它描述從一個 DType 到另一個 DType 的轉換。在 NEP 43 中,此 CastingImpl 物件保持不變地用於支援通用函式。請注意,此處的名稱 CastingImpl 將通用地稱為 ArrayMethod,以同時容納轉換和通用函式。

定義#

dtype#

dtype *實例*;這是附加到 numpy 陣列的物件。

DType#

基礎型別 np.dtype 的任何子類別。

強制型轉#

將 Python 型別轉換為 NumPy 陣列和儲存在 NumPy 陣列中的值。

轉換 (cast)#

將陣列轉換為不同的 dtype。

參數型別#

dtype 的表示法可以根據參數值變更,例如具有長度參數的字串 dtype。目前 flexible dtype 類別的所有成員都是參數化的。請參閱 NEP 40

提升#

尋找一種 dtype,它可以對 dtype 的混合執行運算而不會遺失資訊。

安全轉換 (safe cast)#

如果變更型別時未遺失任何資訊,則轉換是安全的。

在 C 語言層級,我們使用 descriptordescr 來表示 *dtype 實例*。在建議的 C-API 中,這些術語將 dtype 實例與 DType 類別區分開來。

注意

NumPy 具有現有的純量值型別類別階層,如 在 NEP 40 中所示,新的 DType 階層將與其相似。這些型別在目前的 NumPy 中用作單一 dtype 類別的屬性;它們不是 dtype 類別。它們既無害也無助於這項工作。

DType 類別#

本節回顧建議的 DType 類別的基礎結構,包括型別階層和抽象 DType 的使用。

類別 getter#

為了從純量值型別建立 DType 實例,使用者現在呼叫 np.dtype (例如,np.dtype(np.int64))。有時也需要存取基礎 DType 類別;這在型別提示中尤其會出現,因為 DType 實例的「型別」是 DType 類別。從型別提示中汲取靈感,我們提出以下 getter 語法

np.dtype[np.int64]

以取得對應於純量值型別的 DType 類別。此表示法同樣適用於內建和使用者定義的 DType。

此 getter 消除了為每個 DType 建立明確名稱的需求,從而避免 np 命名空間過於擁擠;getter 本身表示型別。它也開啟了使 np.ndarray 通用於 DType 類別的可能性,使用類似以下的註釋

np.ndarray[np.dtype[np.float64]]

以上語法相當冗長,因此我們可能會包含類似以下的別名

Float64 = np.dtype[np.float64]

numpy.typing 中,從而保持註釋簡潔,但仍避免如上所述使 np 命名空間過於擁擠。對於使用者定義的 DType

class UserDtype(dtype): ...

可以執行 np.ndarray[UserDtype],在這種情況下保持註釋簡潔,而無需在 NumPy 本身中引入樣板程式碼。對於使用者定義的純量值型別

class UserScalar(generic): ...

我們需要將型別覆載新增至 dtype

@overload
__new__(cls, dtype: Type[UserScalar], ...) -> UserDtype

以允許 np.dtype[UserScalar]

初始實作可能只會傳回具體 (而非抽象) DType。

此項目仍在審查中。

階層和抽象類別#

我們將使用抽象類別作為我們可擴充 DType 類別階層的建置區塊。

  1. 抽象類別可以乾淨地繼承,原則上允許類似 isinstance(np.dtype("float64"), np.inexact) 的檢查。

  2. 抽象類別允許單一程式碼片段處理多種輸入型別。編寫為接受 Complex 物件的程式碼可以與任何精度的數字一起使用;結果的精度由引數的精度決定。

  3. 使用者建立的 DType 家族有空間。我們可以設想一個用於物理單位的抽象 Unit 類別,以及一個具體的子類別,例如 Float64Unit。呼叫 Unit(np.float64, "m") (m 表示公尺) 將等效於 Float64Unit("m")

  4. NEP 43 中通用函式的實作可能需要類別階層。

範例: NumPy Categorical 類別將與 pandas Categorical 物件相符,後者可以包含整數或一般 Python 物件。NumPy 需要一個 DType,它可以將 Categorical 指派給它,但它也需要類似 CategoricalInt64CategoricalObject 的 DType,以便 common_dtype(CategoricalInt64, String) 引發錯誤,但 common_dtype(CategoricalObject, String) 傳回 object DType。在我們的方案中,Categorical 是一個抽象型別,具有 CategoricalInt64CategoricalObject 子類別。

類別結構的規則,如下方 所示

  1. 無法實例化抽象 DType。實例化抽象 DType 會引發錯誤,或者可能傳回具體子類別的實例。引發錯誤將是預設行為,並且最初可能是必需的。

  2. 雖然抽象 DType 可能是超類別,但它們也可能像 Python 的抽象基礎類別 (ABC) 一樣運作,允許註冊而不是子類別化。可能可以簡單地使用或繼承 Python ABC。

  3. 具體 DType 不得子類別化。未來,這可能會放寬以允許專門的實作,例如子類別化 NumPy float64 的 GPU float64。

Julia 語言對子類別化具體型別也有類似的禁止。例如,除非經過非常仔細的設計,否則後來的 __common_instance____common_dtype__ 等方法無法適用於子類別。它有助於避免因子類別化未編寫為子類別化的型別而導致的實作變更的意外漏洞。我們認為,DType API 應該擴充以簡化現有功能的包裝。

DType 類別需要 C 語言端儲存方法和其他資訊,這將由 DTypeMeta 類別實作。每個 DType 類別都是 DTypeMeta 的實例,具有明確定義且可擴充的介面;終端使用者會忽略它。

_images/dtype_hierarchy.svg

其他方法和屬性#

本節收集 DType 類別中的定義,這些定義未用於轉換和陣列強制型轉,這些內容在下面詳細描述。

  • 現有的 dtype 方法 (numpy.dtype) 和 C 語言端欄位將保留。

  • DType.type 取代 dtype.type。除非出現用例,否則 dtype.type 將被棄用。這表示一個 Python 純量值型別,它表示與 DType 相同的值。這與建議的 類別 getter陣列強制型轉期間的 DType 探索中使用的型別相同。(這也可以為抽象 DType 設定,這對於陣列強制型轉是必要的。)

  • 新的 self.canonical 屬性概括了位元組順序的概念,以指示資料是否以預設/標準方式儲存。對於現有程式碼,「標準」將僅表示原生位元組順序,但它可以在新的 DType 中採用新的含義 — 例如,區分 Complex 的複共軛實例,該實例儲存 real - imag 而不是 real + imag。ISNBO («是原生位元組順序») 旗標可能會重新用於標準旗標。

  • 包含對參數化 DType 的支援。如果 DType 從 ParametricDType 繼承,則將其視為參數化 DType。

  • DType 方法可能類似於甚至重複使用現有的 Python 插槽。因此,Python 特殊插槽對於使用者定義的 DType 是禁止的 (例如,定義 Unit("m") > Unit("cm")),因為我們可能希望開發適用於所有 DType 的這些運算子的含義。

  • 排序函式已移至 DType 類別。它們可以透過定義方法 dtype_get_sort_function(self, sortkind="stable") -> sortfunction 來實作,如果給定的 sortkind 未知,則必須傳回 NotImplemented

  • 無法移除的函式會實作為特殊方法。其中許多先前已定義為 dtype 實例 (PyArray_Descr *) 的 PyArray_ArrFuncs 插槽的一部分,包括諸如 nonzerofill (用於 np.arange) 和 fromstr (用於剖析文字檔案) 等函式。這些舊方法將被棄用,並新增遵循新設計原則的替代方法。API 未在此處定義。由於這些方法可以被棄用,並且可以新增重新命名的替代方法,因此如果這些新方法必須修改,那是可以接受的。

  • 不鼓勵對非內建型別使用 kind,而建議使用 isinstance 檢查。kind 將傳回物件的 __qualname__,以確保所有 DType 的唯一性。在 C 語言端,kindchar 設定為 \0 (NULL 字元)。雖然不鼓勵使用 kind,但目前的 np.issubdtype 可能仍然是此型別檢查的首選方法。

  • 方法 ensure_canonical(self) -> dtype 傳回新的 dtype (或 self),並設定 canonical 旗標。

  • 由於 NumPy 的方法是透過 unfuncs 提供功能,因此最終可能會將排序等將在 DType 中實作的函式重新實作為通用 ufuncs。

轉換#

我們在此回顧與轉換陣列相關的運算

我們展示如何實作使用 astype(new_dtype) 轉換陣列。

*通用 DType* 運算#

當輸入型別混合時,第一步是尋找可以容納結果而不會遺失資訊的 DType — 「通用 DType」。

陣列強制型轉和串連都會傳回通用 dtype 實例。大多數通用函式使用通用 DType 進行分派,儘管它們可能不會將其用於結果 (例如,比較的結果始終為布林值)。

我們提出以下實作

  • 對於兩個 DType 類別

    __common_dtype__(cls, other : DTypeMeta) -> DTypeMeta
    

    傳回新的 DType,通常是輸入之一,它可以表示兩個輸入 DType 的值。這通常應該是最小的:Int16Uint16 的通用 DType 是 Int32 而不是 Int64__common_dtype__ 可能會傳回 NotImplemented 以延遲到其他項目,並且與 Python 運算子一樣,子類別優先 (首先嘗試其 __common_dtype__ 方法)。

  • 對於相同 DType 的兩個實例

    __common_instance__(self: SelfT, other : SelfT) -> SelfT
    

    對於非參數化內建 dtype,這會傳回 self 的標準化副本,並保留元資料。對於非參數化使用者型別,這會提供預設實作。

  • 對於不同 DType 的實例,例如 >float64S8,運算分三個步驟完成

    1. Float64.__common_dtype__(type(>float64), type(S8)) 傳回 String (或延遲到 String.__common_dtype__)。

    2. 轉換機制 (在下面詳細說明) 提供 ">float64" 轉換為 "S32" 的資訊

    3. String.__common_instance__("S8", "S32") 傳回最終的 "S32"

這種交接的好處是減少重複的程式碼並保持關注點分離。DType 實作不需要知道如何轉換,並且轉換結果可以擴展到新類型,例如新的字串編碼。

這表示實作將像這樣運作

def common_dtype(DType1, DType2):
    common_dtype = type(dtype1).__common_dtype__(type(dtype2))
    if common_dtype is NotImplemented:
        common_dtype = type(dtype2).__common_dtype__(type(dtype1))
        if common_dtype is NotImplemented:
            raise TypeError("no common dtype")
    return common_dtype

def promote_types(dtype1, dtype2):
    common = common_dtype(type(dtype1), type(dtype2))

    if type(dtype1) is not common:
        # Find what dtype1 is cast to when cast to the common DType
        # by using the CastingImpl as described below:
        castingimpl = get_castingimpl(type(dtype1), common)
        safety, (_, dtype1) = castingimpl.resolve_descriptors(
                (common, common), (dtype1, None))
        assert safety == "safe"  # promotion should normally be a safe cast

    if type(dtype2) is not common:
        # Same as above branch for dtype1.

    if dtype1 is not dtype2:
        return common.__common_instance__(dtype1, dtype2)

其中一些步驟可能會針對非參數化 DType 進行最佳化。

由於 __common_dtype__ 傳回的型別不一定是兩個引數之一,因此它不等效於 NumPy 的「安全」轉換。安全轉換適用於 np.promote_types(int16, int64),它傳回 int64,但對於以下情況失敗

np.promote_types("int64", "float32") -> np.dtype("float64")

DType 作者有責任確保輸入可以安全地轉換為 __common_dtype__

例外情況可能適用。例如,至少目前認為將 int32 轉換為 (足夠長的) 字串是「安全的」。但是 np.promote_types(int32, String) 將 *不* 定義。

範例

object 始終選擇 object 作為通用 DType。對於 datetime64 型別,提升定義為沒有其他資料型別,但如果有人要實作新的更高精度的 datetime,則

HighPrecisionDatetime.__common_dtype__(np.dtype[np.datetime64])

將傳回 HighPrecisionDatetime,並且如下所述的轉換機制可能需要決定如何處理 datetime 單位。

替代方案

  • 我們正將常見 DType 的決策推向 DType 類別。假設我們可以轉向基於安全轉換的通用演算法,對 DType 強加全序,並返回兩種引數都可以安全轉換成的第一種類型。

    設計合理的全序會很困難,而且它必須接受新條目。除此之外,這種方法是有缺陷的,因為匯入類型可能會改變程式的行為。例如,一個需要 int16uint16 的常見 DType 的程式通常會取得內建類型 int32 作為第一個匹配項;如果程式加入 import int24,則第一個匹配項會變成 int24,而較小的類型可能會首次導致程式溢位。 [1]

  • 未來可以實作更彈性的常見 DType,其中 __common_dtype__ 依賴來自轉換邏輯的資訊。由於 __commond_dtype__ 是一個方法,因此可以在稍後新增這樣的預設實作。

  • 當然,可以合併處理不同 dtype 的三步驟流程。這樣做會失去拆分的價值,以換取可能更快的執行速度。但是,受益的情況很少。大多數情況,例如陣列強制轉換,都只涉及單一 Python 類型(以及因此的 dtype)。

轉換操作#

轉換可能是最複雜且有趣的 DType 操作。它很像陣列上的典型通用函數,將一個輸入轉換為一個新的輸出,但有兩個區別

  • 轉換總是需要明確的輸出資料類型。

  • NumPy 迭代器 API 需要存取比通用函數目前需要的更低階的函數。

轉換可能很複雜,並且可能無法實作每個輸入資料類型的所有細節(例如非原生位元組順序或未對齊的存取)。因此,複雜的類型轉換可能需要 3 個步驟

  1. 輸入資料類型會被正規化並為轉換做好準備。

  2. 執行轉換。

  3. 結果(以正規化形式)會被轉換為請求的形式(非原生位元組順序)。

此外,NumPy 提供了不同的轉換種類或安全規範符

  • equivalent,僅允許位元組順序變更

  • safe,要求類型夠大以保留值

  • same_kind,要求安全轉換或種類內的轉換,例如 float64 到 float32

  • unsafe,允許任何資料轉換

在某些情況下,轉換可能只是一個視圖。

我們需要支援 arr.astype 的兩個目前簽名

  • 對於 DType:arr.astype(np.String)

    • 目前的拼寫方式 arr.astype("S")

    • np.String 可以是一個抽象 DType

  • 對於 dtypes:arr.astype(np.dtype("S8"))

我們還有 np.can_cast 的兩個簽名

  • 實例到類別:np.can_cast(dtype, DType, "safe")

  • 實例到實例:np.can_cast(dtype, other_dtype, "safe")

在 Python 層級,dtype 被多載以表示類別或實例。

第三個 can_cast 簽名,np.can_cast(DType, OtherDType, "safe"),可能會在內部使用,但不需要向 Python 公開。

在 DType 建立期間,DType 將能夠傳遞 CastingImpl 物件的列表,這些物件可以定義與 DType 之間的轉換。

其中一個應該定義該 DType 的實例之間的轉換。如果 DType 只有單一實作且是非參數化的,則可以省略它。

每個 CastingImpl 都有一個不同的 DType 簽名

CastingImpl[InputDtype, RequestedDtype]

並實作以下方法和屬性

  • 為了報告安全性,

    resolve_descriptors(self, Tuple[DTypeMeta], Tuple[DType|None] : input) -> casting, Tuple[DType].

    casting 輸出報告安全性(安全、不安全或同種類),並且該元組用於更多多步驟轉換,如下例所示。

  • 為了取得轉換函數,

    get_loop(...) -> function_to_handle_cast (signature to be decided)

    返回一個步幅轉換函數(「傳輸函數」)的低階實作,能夠執行轉換。

    最初,實作將是*私有的*,使用者將只能提供具有簽名的步幅迴圈。

  • 為了效能,casting 屬性會採用 equivalentsafeunsafesame-kind 的值。

執行轉換

_images/casting_flow.svg

上圖說明了將值為 42int24 多步驟轉換為長度為 20 的字串("S20")。

我們選擇了一個範例,其中實作者僅提供了有限的功能:一個將 int24 轉換為 S8 字串(可以容納所有 24 位元整數)的函數。這表示需要多次轉換。

完整流程是

  1. 呼叫

    CastingImpl[Int24, String].resolve_descriptors((Int24, String), (int24, "S20")).

    這提供了 CastingImpl[Int24, String] 僅實作 int24"S8" 的轉換的資訊。

  2. 由於 "S8""S20" 不匹配,請使用

    CastingImpl[String, String].get_loop()

    以尋找將 "S8" 轉換為 "S20" 的傳輸(轉換)函數

  3. 使用

    CastingImpl[Int24, String].get_loop()

  4. 使用兩個傳輸函數執行實際轉換

    int24(42) -> S8("42") -> S20("42").

    resolve_descriptors 允許以下實作

    np.array(42, dtype=int24).astype(String)

    呼叫

    CastingImpl[Int24, String].resolve_descriptors((Int24, String), (int24, None)).

    在這種情況下,(int24, "S8") 的結果定義了正確的轉換

    np.array(42, dtype=int24).astype(String) == np.array("42", dtype="S8").

轉換安全性

為了計算 np.can_cast(int24, "S20", casting="safe"),只需要 resolve_descriptors 函數,並且以與 描述轉換的圖 中相同的方式呼叫。

在這種情況下,對 resolve_descriptors 的呼叫也將提供 int24 -> "S8" 以及 "S8" -> "S20" 是安全轉換的資訊,因此 int24 -> "S20" 也是安全轉換。

在某些情況下,不需要轉換。例如,在大多數 Linux 系統上,np.dtype("long")np.dtype("longlong") 是不同的 dtype,但都是 64 位元整數。在這種情況下,可以使用 long_arr.view("longlong") 執行轉換。轉換是視圖的資訊將由額外的旗標處理。因此,casting 總共可以有 8 個值:原始的 4 個 equivalentsafeunsafesame-kind,加上 equivalent+viewsafe+viewunsafe+viewsame-kind+view。NumPy 目前定義 dtype1 == dtype2 僅在位元組順序匹配時為 True。此功能可以用「equivalent」轉換和「視圖」旗標的組合來取代。

(有關 resolve_descriptors 簽名的更多資訊,請參閱下方的 公開 C-API 章節和 NEP 43。)

相同 DType 實例之間的轉換

為了減少轉換步驟的數量,CastingImpl 必須能夠執行此 DType 所有實例之間的任何轉換。

一般來說,DType 實作者必須包含 CastingImpl[DType, DType],除非只有單例實例。

通用多步驟轉換

即使使用者僅提供 int16 -> int24 轉換,我們也可以實作某些轉換,例如 int8int24。此提案不提供該功能,但未來的工作可能會動態地找到此類轉換,或至少允許 resolve_descriptors 返回任意 dtypes

如果 CastingImpl[Int8, Int24].resolve_descriptors((Int8, Int24), (int8, int24)) 返回 (int16, int24),則可以擴展實際的轉換過程以包含 int8 -> int16 轉換。這會增加一個步驟。

範例

將整數轉換為 datetime 的實作通常會說這種轉換是不安全的(因為它始終是不安全的轉換)。它的 resolve_descriptors 函數可能看起來像

def resolve_descriptors(self, DTypes, given_dtypes):
   from_dtype, to_dtype = given_dtypes
   from_dtype = from_dtype.ensure_canonical()  # ensure not byte-swapped
   if to_dtype is None:
       raise TypeError("Cannot convert to a NumPy datetime without a unit")
   to_dtype = to_dtype.ensure_canonical()  # ensure not byte-swapped

   # This is always an "unsafe" cast, but for int64, we can represent
   # it by a simple view (if the dtypes are both canonical).
   # (represented as C-side flags here).
   safety_and_view = NPY_UNSAFE_CASTING | _NPY_CAST_IS_VIEW
   return safety_and_view, (from_dtype, to_dtype)

注意

雖然 NumPy 目前定義了整數到 datetime 的轉換,但除了可能無單位的 timedelta64 之外,最好完全不要定義這些轉換。一般來說,我們預期使用者定義的 DType 將使用自訂方法,例如 unit.drop_unit(arr)arr * unit.seconds

替代方案

  • 我們的設計目標是:- 盡可能減少 DType 方法的數量並避免程式碼重複。- 鏡像通用函數的實作。

  • 除了定義 CastingImpl.casting 之外,在尋找正確的 CastingImpl 的第一步中僅使用 DType 類別的決定,允許保留現有使用者定義 dtype 的 __common_dtype__ 的目前預設實作,這在未來可以擴展。

  • 拆分為多個步驟似乎增加了複雜性而不是減少它,但它整合了 np.can_cast(dtype, DTypeClass)np.can_cast(dtype, other_dtype) 的簽名。

    此外,API 保證了使用者 DType 的關注點分離。使用者 Int24 dtype 如果不希望處理所有字串長度,則不必處理。此外,新增到 String DType 的編碼不會影響整體轉換。resolve_descriptors 函數可以繼續返回預設編碼,而 CastingImpl[String, String] 可以處理任何必要的編碼變更。

  • 主要的替代方案是將此處推送到 CastingImpl 中的大部分資訊直接移動到 DType 上的方法中。但這會模糊轉換和通用函數之間的相似性。如下所述,它確實減少了間接性。

  • 先前的提案定義了兩個方法 __can_cast_to__(self, other) 以動態返回 CastingImpl。這消除了在 DType 建立時(在其中一個涉及的 DType 中)定義所有可能轉換的要求。

    這樣的 API 可以在稍後新增。它類似於 Python 的 __getattr__,在提供對屬性查找的額外控制方面。

註解

CastingImpl 在此 NEP 中用作名稱,以闡明它實作了與轉換相關的所有功能。它旨在與 NEP 43 中提出的 ArrayMethod 相同,作為重組 ufunc 以處理新 DType 的一部分。所有類型定義預期都將命名為 ArrayMethod

CastingImpl 的分派工作方式計劃最初是有限且完全不透明的。在未來,它可能會或可能不會被移動到特殊的 UFunc 中,或者更像通用函數一樣運作。

與 Python 物件之間的強制轉換#

當在陣列中儲存單個值或將其取出時,有必要強制轉換它——也就是說,轉換它——到陣列內部的低階表示形式和從低階表示形式轉換。

強制轉換比典型的轉換稍微複雜一些。其中一個原因是 Python 物件本身可能是具有關聯 DType 的 0 維陣列或純量。

與 Python 純量之間的強制轉換需要兩到三個方法,這些方法在很大程度上對應於目前的定義

  1. __dtype_setitem__(self, item_pointer, value)

  2. __dtype_getitem__(self, item_pointer, base_obj) -> objectbase_obj 用於記憶體管理,通常被忽略;它指向擁有資料的物件。它的唯一作用是支援 NumPy 中具有子陣列的結構化資料類型,這些資料類型目前返回陣列的視圖。該函數返回等效的 Python 純量(即通常是 NumPy 純量)。

  3. __dtype_get_pyitem__(self, item_pointer, base_obj) -> object(最初對於新式使用者定義的資料類型是隱藏的,可能會在使用者要求時公開)。這對應於 arr.item() 方法,該方法也由 arr.tolist() 使用,並返回 Python 浮點數,例如,而不是 NumPy 浮點數。

(以上適用於 C-API。Python 端 API 將必須使用位元組緩衝區或類似物來實作此功能,這對於原型設計可能很有用。)

當某個純量具有已知的(不同的)dtype 時,NumPy 未來可能會使用轉換而不是 __dtype_setitem__

(最初)預期使用者資料類型會為其自己的 DType.type 和它希望支援的所有基本 Python 純量(例如 intfloat)實作 __dtype_setitem__。未來,known_scalar_type 函數可能會公開,以允許使用者 dtype 發出訊號,表明它可以直接儲存哪些 Python 純量。

實作: 從任意 Python 物件 value 設定陣列中單個項目的虛擬碼實作如下(此處的一些函數稍後定義)

def PyArray_Pack(dtype, item_pointer, value):
    DType = type(dtype)
    if DType.type is type(value) or DType.known_scalartype(type(value)):
        return dtype.__dtype_setitem__(item_pointer, value)

    # The dtype cannot handle the value, so try casting:
    arr = np.array(value)
    if arr.dtype is object or arr.ndim != 0:
        # not a numpy or user scalar; try using the dtype after all:
        return dtype.__dtype_setitem__(item_pointer, value)

     arr.astype(dtype)
     item_pointer.write(arr[()])

其中對 np.array() 的呼叫表示 dtype 探索,實際上並未執行。

範例: 目前的 datetime64 返回 np.datetime64 純量,並且可以從 np.datetime64 指派。但是,datetime __dtype_setitem__ 也允許從日期字串(“2016-05-01”)或 Python 整數指派。此外,datetime __dtype_get_pyitem__ 函數實際上返回 Python datetime.datetime 物件(大多數時候)。

替代方案: 此功能也可以實作為與 object dtype 之間的轉換。但是,強制轉換比典型的轉換稍微複雜一些。其中一個原因是,一般來說,Python 物件本身可能是具有關聯 DType 的零維陣列或純量。這樣的物件具有 DType,並且已經定義了到另一個 DType 的正確轉換

np.array(np.float32(4), dtype=object).astype(np.float64)

與...相同

np.array(4, dtype=np.float32).astype(np.float64)

明確地實作第一個從 objectnp.float64 的轉換,將要求使用者重複或退回到現有的轉換功能。

當然可以使用通用轉換機制來描述與 Python 物件之間的強制轉換,但是 object dtype 非常特殊且重要,足以由 NumPy 使用所提出的方法來處理。

其他問題和討論

  • __dtype_setitem__ 函數重複了一些程式碼,例如從字串強制轉換。

    datetime64 允許從字串指派,但是從字串 dtype 轉換為 datetime64 也會發生相同的轉換。

    我們未來可能會公開 known_scalartype 函數,以允許使用者實作這種重複。

    例如,NumPy 通常會使用

    np.array(np.string_("2019")).astype(datetime64)

    但是,datetime64 可以選擇出於效能原因而改用其 __dtype_setitem__

  • 關於應如何處理純量的子類別存在一個問題。我們預期停止自動偵測 np.array(float64_subclass) 的 dtype 為 float64。使用者仍然可以提供 dtype=np.float64。但是,使用 np.array(scalar_subclass).astype(requested_dtype) 的上述自動轉換將會失敗。在許多情況下,這不是問題,因為可以改用 Python __float__ 協定。但在某些情況下,這將表示 Python 純量的子類別的行為將有所不同。

注意

範例: np.complex256 未來不應在其 __dtype_setitem__ 方法中使用 __float__,除非它是已知的浮點類型。如果純量是不同高精度浮點類型(例如 np.float128)的子類別,則目前這會在不通知使用者的情況下損失精度。在這種情況下,np.array(float128_subclass(3), dtype=np.complex256) 可能會失敗,除非首先將 float128_subclass 轉換為 np.float128 基底類別。

陣列強制轉換期間的 DType 探索#

使用 NumPy 陣列的一個重要步驟是從通用 Python 物件的集合建立陣列。

動機: 雖然目前區別不明顯,但主要有兩個需求

np.array([1, 2, 3, 4.])

需要根據內部的 Python 物件猜測正確的 dtype。這樣的陣列可能包含資料類型的混合,只要它們可以被提升。第二個用例是當使用者提供輸出 DType 類別,而不是特定的 DType 實例時

np.array([object(), None], dtype=np.dtype[np.string_])  # (or `dtype="S"`)

在這種情況下,使用者表示 object()None 應被解釋為字串。對於未來的 Categorical,也出現了考慮使用者提供的 DType 的需求

np.array([1, 2, 1, 1, 2], dtype=Categorical)

它必須將數字解釋為唯一的類別值,而不是整數。

還有三個進一步的問題需要考慮

  1. 可能需要建立與沒有 dtype 屬性的普通 Python 純量(例如 datetime.datetime)關聯的資料類型。

  2. 一般來說,資料類型可以表示序列,但是,NumPy 目前假設序列始終是元素的集合(序列本身不能是元素)。一個範例是 vector DType。

  3. 陣列本身可能包含具有特定 dtype 的陣列(甚至是通用 Python 物件)。例如:np.array([np.array(None, dtype=object)], dtype=np.String) 提出了如何處理包含的陣列的問題。

其中一些困難之所以產生,是因為找到輸出陣列的正確形狀和找到正確的資料類型密切相關。

實作: 上面有兩種不同的情況

  1. 使用者沒有提供 dtype 資訊。

  2. 使用者提供了 DType 類別 – 例如,以 "S" 表示,代表任何長度的字串。

在第一種情況下,有必要建立從組成元素的 Python 類型到 DType 類別的映射。一旦 DType 類別已知,就需要找到正確的 dtype 實例。在字串的情況下,這需要找到字串長度。

這兩種情況應透過利用兩條資訊來實作

  1. DType.type:目前的類型屬性,用於指示哪個 Python 純量類型與 DType 類別關聯(這是一個*類別*屬性,它始終存在於任何資料類型,並且不限於陣列強制轉換)。

  2. __discover_descr_from_pyobject__(cls, obj) -> dtype:一個類別方法,它返回給定輸入物件的正確描述符。請注意,只有參數化 DType 必須實作此方法。對於非參數化 DType,使用預設實例始終是可以接受的。

已經透過 DType.type 屬性與 DType 關聯的 Python 純量類型從 DType 映射到 Python 純量類型。在註冊時,DType 可以選擇允許自動探索此 Python 純量類型。這需要在相反方向進行查找,這將使用全域映射(類似字典)來實作

known_python_types[type] = DType

正確的垃圾回收需要額外的注意。如果 Python 純量類型(pytype)和 DType 都是動態建立的,則它們可能會再次被刪除。為了允許這樣做,必須可以使上面的映射變弱。這要求 pytype 明確持有 DType 的引用。因此,除了建立全域映射之外,NumPy 還會將 DType 作為 pytype.__associated_array_dtype__ 儲存在 Python 類型中。這 *不* 定義映射,並且 *不應* 直接存取。特別是,屬性的潛在繼承並不表示 NumPy 會自動使用超類別 DType。必須為子類別建立新的 DType

注意

目前 Python 整數尚無明確/具體的 NumPy 型別與之關聯。這是因為在陣列強制轉換期間,NumPy 目前會在 longunsigned longint64unsigned int64object (在許多機器上 long 為 64 位元) 清單中,尋找第一個能夠表示其值的型別。

相反地,它們需要使用 AbstractPyInt 來實作。這個 DType 類別接著可以提供 __discover_descr_from_pyobject__ 並回傳實際的 dtype,例如 np.dtype("int64")。為了在 ufuncs 中進行分派/提升,也需要動態建立 AbstractPyInt[value] 類別 (建立可以快取),以便它們可以提供由 np.result_type(python_integer, array) [2] 提供的當前值基礎的提升功能。

為了讓 DType 接受輸入作為非基本 Python 型別或 DType.type 實例的純量,我們使用 known_scalar_type 方法。這可以允許將 vector 發現為純量 (元素) 而不是序列 (對於指令 np.array(vector, dtype=VectorDType)),即使 vector 本身是一個序列,甚至是陣列子類別。這最初不會是公開 API,但可能會在稍後公開。

範例: 目前的 datetime DType 需要一個 __discover_descr_from_pyobject__,它可以為字串輸入回傳正確的單位。這使其能夠支援

np.array(["2020-01-02", "2020-01-02 11:24"], dtype="M8")

透過檢查日期字串。結合常見的 dtype 運算,這使其能夠自動找到 datetime64 單位應為「分鐘」。

NumPy 內部實作: 尋找正確 dtype 的實作方式將類似於以下虛擬碼

def find_dtype(array_like):
    common_dtype = None
    for element in array_like:
        # default to object dtype, if unknown
        DType = known_python_types.get(type(element), np.dtype[object])
        dtype = DType.__discover_descr_from_pyobject__(element)

        if common_dtype is None:
            common_dtype = dtype
        else:
            common_dtype = np.promote_types(common_dtype, dtype)

實際上,np.array() 的輸入是序列和類陣列物件的混合,因此決定什麼是元素需要檢查它是否為序列。因此,完整的演算法 (沒有使用者提供的 dtype) 看起來更像

def find_dtype_recursive(array_like, dtype=None):
    """
    Recursively find the dtype for a nested sequences (arrays are not
    supported here).
    """
    DType = known_python_types.get(type(element), None)

    if DType is None and is_array_like(array_like):
        # Code for a sequence, an array_like may have a DType we
        # can use directly:
        for element in array_like:
            dtype = find_dtype_recursive(element, dtype=dtype)
        return dtype

    elif DType is None:
        DType = np.dtype[object]

    # dtype discovery and promotion as in `find_dtype` above

如果使用者提供 DType,則將首先嘗試此 DType,並且在執行提升之前可能需要轉換 dtype

限制: 巢狀陣列 np.array([np.array(None, dtype=object)], dtype=np.String) 的動機點 3. 目前 (有時) 透過檢查巢狀陣列的所有元素來支援。如果巢狀陣列的 dtype 為 object,則使用者 DType 將隱含地正確處理這些。在某些其他情況下,NumPy 將僅保留現有功能的向後相容性。NumPy 使用此類功能來允許諸如以下的程式碼

>>> np.array([np.array(["2020-05-05"], dtype="S")], dtype=np.datetime64)
array([['2020-05-05']], dtype='datetime64[D]')

這會發現 datetime 單位 D (天)。如果沒有中間轉換為 object 或自訂函式,則使用者 DType 將無法存取此可能性。

全域型別映射的使用意味著,如果兩個 DType 希望映射到相同的 Python 型別,則必須給出錯誤或警告。在大多數情況下,使用者 DType 應僅針對在同一程式庫中定義的型別實作,以避免潛在的衝突。DType 實作人員有責任謹慎對待這一點,並在有疑問時避免註冊。

替代方案

  • 除了全域映射之外,我們可以依賴純量屬性 scalar.__associated_array_dtype__。這僅在子類別的行為上產生差異,並且確切的實作最初可以是未定義的。純量預計將衍生自 NumPy 純量。原則上,NumPy 在一段時間內仍然可以選擇依賴該屬性。

  • 先前用於 dtype 發現演算法的提案使用了兩階段方法,首先找到正確的 DType 類別,然後才發現參數化的 dtype 實例。它被拒絕了,因為過於複雜。但它本可以啟用通用函式中的值基礎的提升,允許

    np.add(np.array([8], dtype="uint8"), [4])
    

    回傳 uint8 結果 (而不是 int16),這目前發生在

    np.add(np.array([8], dtype="uint8"), 4)
    

    (請注意清單 [4] 而不是純量 4)。這不是 NumPy 目前擁有或希望支援的功能。

進一步的問題和討論: 可以建立一個 DType,例如 Categorical、array 或 vector,只有在提供 dtype=DType 時才能使用。此類 DType 無法正確地往返。例如

np.array(np.array(1, dtype=Categorical)[()])

將產生一個整數陣列。為了獲得原始的 Categorical 陣列,需要明確傳遞 dtype=Categorical。這是一個普遍的限制,但如果傳遞 dtype=original_arr.dtype,則始終可以往返。

公開 C-API#

DType 建立#

為了建立新的 DType,使用者將需要定義 使用和影響 章節中概述並在本提案中詳細說明的 方法和屬性。

此外,在 PyArray_ArrFuncs 中類似的一些方法將是以下 slots struct 所需要的。

NEP 41 中所述,在 C 中定義此 DType 類別的介面是仿照 PEP 384 建模的:Slots 和一些額外資訊將在 slots struct 中傳遞,並由 ssize_t 整數識別

static struct PyArrayMethodDef slots[] = {
    {NPY_dt_method, method_implementation},
    ...,
    {0, NULL}
}

typedef struct{
  PyTypeObject *typeobj;    /* type of python scalar or NULL */
  int flags                 /* flags, including parametric and abstract */
  /* NULL terminated CastingImpl; is copied and references are stolen */
  CastingImpl *castingimpls[];
  PyType_Slot *slots;
  PyTypeObject *baseclass;  /* Baseclass or NULL */
} PyArrayDTypeMeta_Spec;

PyObject* PyArray_InitDTypeMetaFromSpec(PyArrayDTypeMeta_Spec *dtype_spec);

所有這些都是透過複製傳遞的。

待辦事項: DType 作者應該能夠為 DType 定義新方法,最多定義一個完整的物件,並且在未來,甚至可能擴展 PyArrayDTypeMeta_Type struct。我們必須決定最初提供什麼。一個解決方案可能是僅允許從現有類別繼承:class MyDType(np.dtype, MyBaseclass)。如果 np.dtype 在方法解析順序中排在第一位,這也可以防止像 == 這樣的 slots 不必要的覆寫。

slots 將由以 NPY_dt_ 為前綴的名稱識別,並且是

  • is_canonical(self) -> {0, 1}

  • ensure_canonical(self) -> dtype

  • default_descr(self) -> dtype (回傳必須是原生的,並且通常應該是單例)

  • setitem(self, char *item_ptr, PyObject *value) -> {-1, 0}

  • getitem(self, char *item_ptr, PyObject (base_obj) -> object or NULL

  • discover_descr_from_pyobject(cls, PyObject) -> dtype or NULL

  • common_dtype(cls, other) -> DType, NotImplemented, or NULL

  • common_instance(self, other) -> dtype or NULL

如果省略或設定為 NULL,則在可能的情況下將提供預設實作。非參數 dtype 不必實作

  • discover_descr_from_pyobject (改用 default_descr)

  • common_instance (改用 default_descr)

  • ensure_canonical (改用 default_descr)。

排序預計使用以下方式實作

  • get_sort_function(self, NPY_SORTKIND sort_kind) -> {out_sortfunction, NotImplemented, NULL}.

為了方便起見,如果使用者僅實作

  • compare(self, char *item_ptr1, char *item_ptr2, int *res) -> {-1, 0, 1}

就足夠了。限制: PyArrayDTypeMeta_Spec struct 擴展起來很笨拙 (例如,透過在 slots 中新增版本標籤來指示新的、更長的版本)。我們可以使用函式來提供 struct;這將需要記憶體管理,但將允許 ABI 相容的擴展 (在建立 DType 時,struct 會再次釋放)。

CastingImpl#

CastingImpl 的外部 API 最初將限制為定義

  • casting 屬性,它可以是支援的轉換種類之一。這是最安全的可能轉換。例如,在兩個 NumPy 字串之間進行轉換通常當然是「安全」的,但在特定情況下,如果第二個字串較短,則可能是「相同種類」。如果兩種型別都不是參數化的,則 resolve_descriptors 必須使用它。

  • resolve_descriptors(PyArrayMethodObject *self, PyArray_DTypeMeta *DTypes[2], PyArray_Descr *dtypes_in[2], PyArray_Descr *dtypes_out[2], NPY_CASTING *casting_out) -> int {0, -1} 輸出 dtype 必須正確設定為 strided loop (轉換函式) 可以處理的 dtype。最初,結果必須具有與 CastingImpl 定義的 DType 類別相同的實例。casting 將設定為 NPY_EQUIV_CASTINGNPY_SAFE_CASTINGNPY_UNSAFE_CASTINGNPY_SAME_KIND_CASTING。可以設定一個新的、額外的標誌 _NPY_CAST_IS_VIEW,以指示不需要轉換,並且視圖足以執行轉換。當發生錯誤時,轉換應回傳 -1。如果轉換不可能 (但未發生錯誤),則應回傳 -1 結果,而沒有設定錯誤。這一點正在考慮中,我們可以使用 ``-1`` 來指示一般錯誤,並使用不同的回傳值來表示不可能的轉換。 這表示無法告知使用者轉換為何不可能。

  • strided_loop(char **args, npy_intp *dimensions, npy_intp *strides, ...) -> int {0, -1} (簽名將在 NEP 43 中完全定義)

這與針對 ufuncs 提出的 API 相同。簽名的額外 ... 部分將包含諸如兩個 dtype 之類的資訊。內部使用了更最佳化的迴圈,並且將在未來提供給使用者 (請參閱註解)。

儘管冗長,但 API 將模仿用於建立新 DType 的 API

typedef struct{
  int flags;                  /* e.g. whether the cast requires the API */
  int nin, nout;              /* Number of Input and outputs (always 1) */
  NPY_CASTING casting;        /* The "minimal casting level" */
  PyArray_DTypeMeta *dtypes;  /* input and output DType class */
  /* NULL terminated slots defining the methods */
  PyType_Slot *slots;
} PyArrayMethod_Spec;

轉換和一般 ufuncs 之間的重點不同。例如,對於轉換,nin == nout == 1 始終正確,而對於 ufuncs,casting 預計通常為 “no”

註解: 我們最初可能僅允許使用者定義單個迴圈。NumPy 內部最佳化得多,這應該以兩種方式之一逐步公開

  • 允許多個版本,例如

    • 連續的內部迴圈

    • 跨步的內部迴圈

    • 純量內部迴圈

  • 或者,更可能的是,公開 get_loop 函式,該函式傳遞了額外資訊,例如固定的 strides (類似於我們的內部 API)。

  • 轉換層級表示最小保證的轉換層級,如果轉換可能不可能,則可以為 -1。對於大多數非參數轉換,此值將為轉換層級。當基於此層級 np.can_cast() 的結果為 True 時,NumPy 可以跳過 resolve_descriptors 呼叫。

範例尚不包含設定和錯誤處理。由於這些與 UFunc 機制相似,因此它們將在 NEP 43 中定義,然後完全相同地合併到轉換中。

使用的 slots/方法將以 NPY_meth_ 為前綴。

替代方案

  • 除了名稱變更和簽名調整之外,上述結構似乎沒有太多替代方案。使用 *_FromSpec 函式提出的 API 是實現穩定且可擴展 API 的好方法。slots 設計是可擴展的,並且可以在不破壞二進位相容性的情況下進行變更。仍然可以提供便利函式,以允許使用更少的程式碼進行建立。

  • 一個缺點是編譯器無法警告函式指標不相容性。

實作#

實作步驟在 NEP 41 的實作章節中概述。簡而言之,我們首先將重寫轉換和陣列強制轉換的內部結構。之後,新的公開 API 將逐步新增。我們計劃最初以初步狀態公開它,以獲得經驗。隨著新增新功能,目前在 dtype 上實作的所有功能將被系統地替換。

替代方案#

可能的實作空間很大,因此已經進行了許多討論、概念和設計文件。這些列在 NEP 40 中。替代方案也在上面相關章節中討論過。

參考文獻#