「静的解析と実行時の検証のためのDataFrameの型ヒント」

「美容とファッションのエキスパートによる、生き生きとした記事を執筆します」

StaticFrameが包括的なDataFrameの型ヒントを可能にする方法

Authorによる写真

Python 3.5で型ヒントが導入されて以来、DataFrameの静的な型指定は通常、単に型の指定に限られてきました:

def process(f: DataFrame) -> Series: ...

これは不十分であり、コンテナ内に含まれる型を無視しています。DataFrameには文字列の列ラベルと整数、文字列、浮動小数点の値からなる3つの列があるかもしれません。これらの特性は型を定義します。そのような型ヒントを持つ関数引数は、インターフェースの期待値を理解するために、開発者、静的解析ツール、および実行時チェッカーに必要な情報をすべて提供します。私がリードディベロッパーであるオープンソースプロジェクトであるStaticFrame 2では、これが可能になりました:

from typing import Anyfrom static_frame import Frame, Index, TSeriesAnydef process(f: Frame[   # コンテナの型        Any,            # インデックスラベルの型        Index[np.str_], # 列ラベルの型        np.int_,        # 最初の列の型        np.str_,        # 2番目の列の型        np.float64,     # 3番目の列の型        ]) -> TSeriesAny: ...

すべての中核となるStaticFrameコンテナは、ジェネリックな仕様をサポートしています。静的にチェック可能な新しいデコレータ@CallGuard.checkは、関数インターフェースの型ヒントを実行時に検証することも可能です。さらに、Annotatedジェネリックを使用することで、新しいRequireクラスは、列ごとまたは行ごとのデータの検証を行う強力なランタイムバリデーターのファミリーを定義します。最後に、各コンテナは新しいvia_type_clinicインターフェースを公開して、型ヒントを派生し検証することができます。これらのツールを組み合わせることで、DataFrameの型ヒントと検証に統一的なアプローチが提供されます。

ジェネリックなDataFrameの要件

Pythonの組み込みジェネリック型(例:tupleまたはdict)は、コンポーネントの型(例:tuple[int, str, bool]またはdict[str, int])の指定を必要とします。コンポーネントの型を定義することで、より正確な静的解析が可能となります。DataFrameにも同様のことが言えますが、DataFrameの包括的な型ヒントを定義する試みはほとんどありませんでした。

Pandasは、pandas-stubsパッケージを使用しても、DataFrameのコンポーネントの型の指定を許可していません。Pandas DataFrameは、広範なインプレース変更を許可しているため、静的な型指定は適切ではないかもしれません。幸いにも、StaticFrameでは不変なDataFrameが利用可能です。

さらに、Pythonのジェネリックを定義するためのツールは、最近までDataFrameには適していませんでした。DataFrameには可変長の異種の列型があるため、ジェネリックの指定には課題があります。このような構造に対する型指定は、Python 3.11で導入された新しいTypeVarTupleを使用することで簡単になりました(また、typing_extensionsパッケージでバックポートが行われています)。

TypeVarTupleを使用すると、可変数の型を受け入れるジェネリックを定義することができます(詳細については、PEP 646を参照)。この新しい型変数を使用することで、StaticFrameはインデックスのためのTypeVar、列のためのTypeVar、ゼロ個以上の列のためのTypeVarTupleを持つジェネリックなFrameを定義することができます。

ジェネリックなSeriesは、インデックスのためのTypeVarと値のためのTypeVarで定義されます。StaticFrameのIndexIndexHierarchyもジェネリックであり、後者は再度TypeVarTupleを活用して、各階層レベルのコンポーネントIndexを定義しています。

StaticFrameは、Frameの列の型やSeriesIndexの値の型を定義するためにNumPyの型を使用しています。これにより、np.uint8np.complex128などのサイズ指定された数値型、またはnp.integernp.inexactなどの型のカテゴリを広く指定することができます。StaticFrameはNumPyのすべての型をサポートしているため、対応関係は直接的です。

ジェネリックデータフレームで定義されたインターフェース

上記の例を拡張して、以下の関数インターフェースは、3つの列を持つFrameSeriesの辞書に変換したものを示しています。コンポーネントの型ヒントによって提供される情報量が多いため、この関数の目的はほぼ明らかです。

from typing import Anyfrom static_frame import Frame, Series, Index, IndexYearMonthdef process(f: Frame[        Any,        Index[np.str_],        np.int_,        np.str_,        np.float64,        ]) -> dict[                int,                Series[                 # コンテナの型                        IndexYearMonth, # インデックスラベルの型                        np.float64,     # 値の型                        ],                ]: ...

この関数は、Open Source Asset Pricing(OSAP)データセット(企業レベルの特性/個人/予測子)からのシグナルテーブルを処理します。各テーブルには3つの列があります:セキュリティ識別子(”permno” とラベル付けされた列)、年と月(”yyyymm” とラベル付けされた列)、およびシグナル(シグナルに特有の名前を持つ)。

この関数は、提供されたFrameのインデックス(Anyとして型付けされている)を無視し、「permno」列のnp.int_値によって定義されるグループを作成します。各値が「permno」に対してnp.float64値のSeriesである辞書が返されます。インデックスは、np.str_「yyyymm」列から作成されたIndexYearMonthです。(StaticFrameは、単位型のインデックスを定義するためにNumPyのdatetime64値を使用します:IndexYearMonthdatetime64[M]のラベルを格納します。)

dictではなく、以下の関数は階層的なインデックスを持つSeriesを返します。 IndexHierarchyジェネリックは、各深さレベルごとにコンポーネントIndexを指定します。ここでは、最も外側の深さはIndex[np.int_](「permno」列から派生)であり、内側の深さはIndexYearMonth(「yyyymm」列から派生)です。

from typing import Anyfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchydef process(f: Frame[        Any,        Index[np.str_],        np.int_,        np.str_,        np.float64,        ]) -> Series[                    # コンテナの型                IndexHierarchy[          # インデックスラベルの型                        Index[np.int_],  # インデックスの深さ 0の型                        IndexYearMonth], # インデックスの深さ 1の型                np.float64,              # 値の型                ]: ...

豊富な型のヒントにより、機能が明示的になる自己文書化インターフェースが提供されます。さらに、これらの型のヒントは、Pyright(現在)およびMypy(TypeVarTupleのサポートが完全に行われるまで)との静的解析に使用することができます。たとえば、np.float64の2つの列を持つFrameでこの関数を呼び出すと、エディターで静的解析の型チェックが失敗するか、警告が表示されます。

ランタイムタイプの検証

静的な型チェックだけでは十分ではない場合があります。ランタイム評価によるさらなる制約が提供されます。特に、動的または不完全(または不正確に)型ヒントが付いた値に対して有効です。

TypeClinicという新しいランタイムタイプチェッカーをベースに、StaticFrame 2ではランタイムの型ヒントのバリデーション用のデコレータである@CallGuard.checkが導入されました。すべてのStaticFrameとNumPyのジェネリックをサポートし、ほとんどの組み込みのPythonの型もサポートされています(深くネストされている場合でも)。以下の関数は、@CallGuard.checkデコレータを追加しています。

from typing import Anyfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchy, [email protected] process(f: Frame[        Any,        Index[np.str_],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

いま、@CallGuard.checkで装飾されています。上記の関数が、ラベルのついていない2列のnp.float64Frameで呼び出された場合、ClinicError例外が発生し、3つの列が期待されたのに2つしか提供されず、文字列の列ラベルが期待されたのに整数のラベルが提供されたことが示されます(例外を発生させずに警告を発行するには、@CallGuard.warnのデコレータを使用してください)。

ClinicError:In args of (f: Frame[Any, Index[str_], int64, str_, float64]) -> Series[IndexHierarchy[Index[int64], IndexYearMonth], float64]└── Frame[Any, Index[str_], int64, str_, float64]    └── Expected Frame has 3 dtype, provided Frame has 2 dtypeIn args of (f: Frame[Any, Index[str_], int64, str_, float64]) -> Series[IndexHierarchy[Index[int64], IndexYearMonth], float64]└── Frame[Any, Index[str_], int64, str_, float64]    └── Index[str_]        └── Expected str_, provided int64 invalid

ランタイムデータの検証

その他の特性はランタイムで検証することもできます。例えば、shape属性やname属性、またはインデックスや列のラベルの順序などを検証できます。StaticFrameのRequireクラスは、設定可能なバリデータを提供します。

  • Require.Name:コンテナのname属性を検証します。
  • Require.Len:コンテナの長さを検証します。
  • Require.Shape:コンテナのshape属性を検証します。
  • Require.LabelsOrder:ラベルの順序を検証します。
  • Require.LabelsMatch:順序に関係なくラベルの一部であることを検証します。
  • Require.Apply:コンテナに対してブール値を返す関数を適用します。

成長するトレンドに合わせて、これらのオブジェクトは、一つ以上の追加引数としてAnnotatedジェネリックにタイプヒントとして提供されます(詳細については、PEP 593を参照してください)。

最初のAnnotated引数で参照されるタイプは、後続の引数のバリデータの対象です。たとえば、Index[np.str_]のタイプヒントをAnnotated[Index[np.str_], Require.Len(20)]のタイプヒントで置き換えると、実行時の長さの検証が最初の引数に関連するインデックスに適用されます。

OSAPシグナルテーブルの処理の例を拡張して、列ラベルの期待値を検証することもできます。Require.LabelsOrderバリデータは、ラベルのシーケンスを定義することができます。ゼロ個以上の未指定のラベルの範囲には...を使用することもできます。テーブルの最初の2列が「permno」と「yyyymm」とラベル付けされていることを指定し、3番目のラベルが可変(シグナルに依存する)であることを指定するために、次のようにRequire.LabelsOrderAnnotatedジェネリック内に定義できます:

from typing import Any, Annotatedfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchy, CallGuard, [email protected] process(f: Frame[        Any,        Annotated[                Index[np.str_],                Require.LabelsOrder('permno', 'yyyymm', ...),                ],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

インターフェースが小さなOSAPシグナルテーブルのコレクションを期待している場合、Require.LabelsMatchバリデータを使用して3番目の列を検証することができます。このバリデータでは、要求されるラベル、ラベルのセット(少なくとも1つは一致する必要がある)、正規表現パターンを指定することができます。3つのファイルのテーブルが期待されている場合(つまり、「Mom12m.csv」、「Mom6m.csv」、「LRreversal.csv」)、3番目の列のラベルをRequire.LabelsMatchで次のように定義できます:

@CallGuard.checkdef process(f: Frame[        Any,        Annotated[                Index[np.str_],                Require.LabelsOrder('permno', 'yyyymm', ...),                Require.LabelsMatch({'Mom12m', 'Mom6m', 'LRreversal'}),                ],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

「Require.LabelsOrder」と「Require.LabelsMatch」の両方は、ラベル指定子と関数を関連付けてデータ値をバリデートすることができます。バリデータが列ラベルに適用される場合、関数には列値のシリーズが提供されます。バリデータがインデックスラベルに適用される場合、関数には行値のシリーズが提供されます。

「Annotated」と同様に、ラベル指定子はリストで置き換えられ、最初のアイテムはラベル指定子であり、残りのアイテムは行または列の処理関数であり、ブール値を返します。

上記の例を拡張すると、すべての「permno」の値がゼロより大きく、すべてのシグナル値(「Mom12m」、「Mom6m」、「LRreversal」)が-1以上であることをバリデートすることができます。

from typing import Any, Annotatedfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchy, CallGuard, [email protected] process(f: Frame[        Any,        Annotated[                Index[np.str_],                Require.LabelsOrder(                        ['permno', lambda s: (s > 0).all()],                        'yyyymm',                        ...,                        ),                Require.LabelsMatch(                        [{'Mom12m', 'Mom6m', 'LRreversal'}, lambda s: (s >= -1).all()],                        ),                ],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

バリデーションに失敗する場合、@CallGuard.checkは例外を発生させます。たとえば、上記の関数が予期しない3番目の列ラベルを持つFrameで呼び出される場合、次の例外が発生します:

ClinicError:In args of (f: Frame[Any, Annotated[Index[str_], LabelsOrder(['permno', <lambda>], 'yyyymm', ...), LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])], int64, str_, float64]) -> Series[IndexHierarchy[Index[int64], IndexYearMonth], float64]└── Frame[Any, Annotated[Index[str_], LabelsOrder(['permno', <lambda>], 'yyyymm', ...), LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])], int64, str_, float64]    └── Annotated[Index[str_], LabelsOrder(['permno', <lambda>], 'yyyymm', ...), LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])]        └── LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])            └── Expected label to match frozenset({'Mom12m', 'LRreversal', 'Mom6m'}), no provided match

型の表現力への貢献 – TypeVarTuple

上記のように、TypeVarTupleはゼロ個以上の異なる列タイプを指定できます。たとえば、2つの浮動小数点数または6つの混在タイプのFrameの型ヒントを提供することができます:

>>> from typing import Any>>> from static_frame import Frame, Index>>> f1: sf.Frame[Any, Any, np.float64, np.float64]>>> f2: sf.Frame[Any, Any, np.bool_, np.float64, np.int8, np.int8, np.str_, np.datetime64]

これにより、さまざまなデータフレームを扱うことができますが、数百の列を持つような幅広いデータフレームの型ヒントは扱いづらいでしょう。Python 3.11では、TypeVarTupleのジェネリックに対して変数範囲の型を提供するための新しい構文が導入されています:tupleジェネリックのエイリアスのスターエクスプレッションです。たとえば、日付のインデックス、文字列の列ラベル、および列のタイプの任意の構成でFrameの型ヒントを指定するには、ゼロ個以上のAlltupleでスターアンパックすることができます:

>>> from typing import Any>>> from static_frame import Frame, Index>>> f: sf.Frame[Index[np.datetime64], Index[np.str_], *tuple[All, ...]]

タプルスターエクスプレッションは、型のリスト内のどこにでも配置することができますが、1つだけです。たとえば、以下の型ヒントでは、Boolean列と文字列列で始まり、後続のnp.float64列の任意の数の柔軟な仕様を持つFrameを定義しています。

>>> from typing import Any>>> from static_frame import Frame>>> f: sf.Frame[Any, Any, np.bool_, np.str_, *tuple[np.float64, ...]]

型ヒントのためのユーティリティ

このような詳細な型ヒントを扱うことは難しい場合があります。そのため、StaticFrameではランタイム型ヒントと検証のための便利なユーティリティを提供しています。StaticFrame 2のすべてのコンテナには、via_type_clinicインターフェースが備わっており、TypeClinicの機能にアクセスすることができます。

まず、Frameなどのコンテナを型ヒントに変換するためのユーティリティが提供されています。via_type_clinicインターフェースの文字列表現は、コンテナの型ヒントの文字列表現を提供します。また、to_hint()メソッドは完全なジェネリックエイリアスオブジェクトを返します。

>>> import static_frame as sf>>> f = sf.Frame.from_records(([3, '192004', 0.3], [3, '192005', -0.4]), columns=('permno', 'yyyymm', 'Mom3m'))>>> f.via_type_clinicFrame[Index[int64], Index[str_], int64, str_, float64]>>> f.via_type_clinic.to_hint()static_frame.core.frame.Frame[static_frame.core.index.Index[numpy.int64], static_frame.core.index.Index[numpy.str_], numpy.int64, numpy.str_, numpy.float64]

次に、ランタイム型ヒントのテストのためのユーティリティが提供されています。via_type_clinic.check()関数を使用して、コンテナを指定された型ヒントと照合することができます。

>>> f.via_type_clinic.check(sf.Frame[sf.Index[np.str_], sf.TIndexAny, *tuple[tp.Any, ...]])ClinicError:In Frame[Index[str_], Index[Any], Unpack[Tuple[Any, ...]]]└── Index[str_]    └── Expected str_, provided int64 invalid

逐次的な型付けをサポートするために、StaticFrameでは、各コンポーネントタイプに対してAnyで構成されたいくつかのジェネリックエイリアスを定義しています。たとえば、TFrameAnyはどのFrameにも使用でき、TSeriesAnyはどのSeriesにも使用できます。予想どおり、TFrameAnyは上記で作成したFrameを検証します。

>>> f.via_type_clinic.check(sf.TFrameAny)

結論

DataFramesのためのより良い型ヒントが必要とされています。モダンなPythonの型ヒントツールと、イミュータブルなデータモデルに基づくDataFrameを備えたStaticFrame 2は、メンテナンス性と検証性を重視するエンジニアにとって、強力なリソースを提供します。

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more