🦁

pythonのruntimeで型のチェックをしたい

に公開

はじめに

筆者は業務で主に機械学習周りの開発を行っていて型周りの理論や実践については詳しい方ではないことを先に断っておきます。この記事は学習ノートの体をしたチラシの裏です。

自分はキャリアの序盤から割とpythonのtype hintをつけて開発してきたのですがあるかないかでかなりコードの読みやすさは変わってくると思ってます。
mypyやpyrightによって静的解析で型のチェックをできるのでこの記事で書くほどruntimeでチェックしたいことはあまりないのですがruntimeでもチェックしたいと言ってる人を見かけたので、自分が知ってる方法をメモしておこうと思います。
何でもかんでも型にしろや、Immutableにしろ、とは考えてなくバランスが大事だと考えています。
あくまでもかっちり作る部分において使えるかもなくらいです。安全性も高めつつスケールしやすい設計を目指して日々勉強しています。

前置きが長くなりましたが、この記事ではpythonでruntimeに型をチェックするツールとしてbeartypeとpydanticを紹介します。pythonの型周りについては説明しません。

pythonの型については以下の記事[1]がわかりやすいです。この記事を参考にtype hintをつけるようにしてみてください。

https://future-architect.github.io/articles/20201223/

使うライブラリ

  • beartype
  • pydantic

pythonのversionは手元に入っていた3.9.2を使っています

それぞれ以下のversionを使ってmacOS13.4(Intel Core i7)で動作確認しています。

  • beartype: 0.14.0
  • pydantic: 1.10.8
pip install beartype pydantic
poetry add beartype pydantic

beartype

beartype[2]はnear-real-timeのpythonのruntimeで関数の引数や返り値の型チェックライブラリです。
Readmeに書いてありますが、decoratorを使ってpythonの関数の引数や返り値の型をチェックすることができます。

-Oをつけてpythonを実行すると、__debug__にFalseが入って、beartypeのruntimeの型チェックを無効化することもできます。

以下のコードを見ると、TYPE_CHECKINGがTrueか、__debug__がFalseの時には、beartype decoratorは何もしないで引数にとったObjectをそのまま返すことでtype checkを無効化していることがわかります。

https://github.com/beartype/beartype/blob/main/beartype/_decor/decormain.py#L60-L100

基本的な使い方は以下のようになっています。

from beartype import beartype

# 引数の型がstrで返り値の型がstrであることをチェックする
@beartype
def hello(name: str) -> str:
    return f"Hello {name}"

このようにすることで、関数を呼び出す時にruntimeにおける引数の型が正しいかどうかをチェックすることができます。

hello(name="world")
# >>> Hello world

type hintと違う型を渡すとエラーになります。

hello(name=1)
# >>> Function __main__.hello() parameter name=1 violates type hint <class 'str'>, as int 1 not instance of str.

またlistなどもチェクすることができます。

from beartype import beartype

@beartype
def hello_names(names: list[str]) -> str:
    return f"Hello {', '.join(names)}!"

print(hello_names(names=["world", "python", "nnc_5522"]))
# >>> Hello world, python, nnc_5522!

Unionなどもチェックすることができます。

from typing import Union

@beartype
def hello_union(name: Union[str, int]) -> str:
    return f"Hello {name}!"

print(hello_union(name="world"))
print(hello_union(name=100))
# >>> Hello world!
# >>> Hello 100!

hello_union(name=1.0)
# >>> Function __main__.hello_union() parameter name=1.0 violates type hint typing.Union[str, int], as float 1.0 not str or int.

3.9.x以下のバージョンを使ってる時に、annotationsをimportすることでtype hintとしてtyping.Unionを使わずに直和型を書くことができますこれはtype hintの評価を遅らせてるだけでtype同士の演算として定義されている訳ではないので、beartypeではチェックできません。おとなしくtyping.Unionを使うべきです。

from __future__ import annotations

@beartype
def hello_union(name: str | int) -> str:
    return f"Hello {name}!"

print(hello_union(name="world"))

raiseされる例外
Traceback (most recent call last):
  File "/Users/chibadaimare/.pyenv/versions/3.9.2/lib/python3.9/site-packages/beartype/peps/_pep563.py", line 584, in resolve_pep563
    func_hints_resolved[pith_name] = eval(
  File "<string>", line 1, in <module>
TypeError: unsupported operand type(s) for |: 'type' and 'type'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/chibadaimare/work/example_beartype_pydantic/main.py", line 16, in <module>
    def hello_union(name: str | int) -> str:
  File "/Users/chibadaimare/.pyenv/versions/3.9.2/lib/python3.9/site-packages/beartype/_decor/_cache/cachedecor.py", line 77, in beartype
    return beartype_object(obj, conf)
  File "/Users/chibadaimare/.pyenv/versions/3.9.2/lib/python3.9/site-packages/beartype/_decor/decorcore.py", line 195, in beartype_object
    return _beartype_func(  # type: ignore[return-value]
  File "/Users/chibadaimare/.pyenv/versions/3.9.2/lib/python3.9/site-packages/beartype/_decor/decorcore.py", line 637, in _beartype_func
    bear_call.reinit(func, conf, **kwargs)
  File "/Users/chibadaimare/.pyenv/versions/3.9.2/lib/python3.9/site-packages/beartype/_check/checkcall.py", line 341, in reinit
    resolve_pep563(
  File "/Users/chibadaimare/.pyenv/versions/3.9.2/lib/python3.9/site-packages/beartype/peps/_pep563.py", line 621, in resolve_pep563
    raise BeartypePep563Exception(
beartype.roar.BeartypePep563Exception: function __main__.hello_union() parameter "name" PEP 563-postponed type hint 'str | int' syntactically invalid (i.e., "unsupported operand type(s) for |: 'type' and 'type'") under:
~~~~[ GLOBAL SCOPE ]~~~~
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x1065c81c0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/Users/chibadaimare/work/example_beartype_pydantic/main.py', '__cached__': None, 'annotations': _Feature((3, 7, 0, 'beta', 1), (3, 10, 0, 'alpha', 0), 16777216), 'beartype': <function beartype at 0x106925550>, 'hello': <function hello at 0x106668d30>, 'hello_names': <function hello_names at 0x106a488b0>}
~~~~[ LOCAL SCOPE  ]~~~~
{}

numpy.ndarrayなどのthird partyのライブラリの型でもチェック可能です

@beartype
def np_add(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    return a + b

print(np_add(a=np.array([1, 2, 3]), b=np.array([4, 5, 6])))
# >>> [5 7 9]

# Error Case
print(np_add(a=[1, 2, 3], b=np.array([4, 5, 6])))
# >>> Function __main__.np_add() parameter a=[1, 2, 3] violates type hint <class 'numpy.ndarray'>, as list [1, 2, 3] not instance of <protocol "numpy.ndarray">.

さらにdtypeなどもチェックすることができます。

@beartype
def np_diff(a: NDArray[np.float16], b: NDArray[np.float16]) -> np.ndarray:
    return b - a

print(
    np_diff(
        a=np.array([1, 2, 3], dtype=np.float16),
        b=np.array([4, 5, 6], dtype=np.float16),
    )
)
# >>> [3. 3. 3.]

np_diff(
    a=np.array([1, 2, 3], dtype=np.float64),
    b=np.array([4, 5, 6], dtype=np.float16),
)
# >>> Function __main__.np_diff() parameter a="array([1., 2., 3.])" violates type hint numpy.
# ... ndarray[typing.Any, numpy.dtype[numpy.float16]],
# ... as <protocol "numpy.ndarray"> "array([1., 2., 3.])" violates validator numpy.ndarray[typing.Any, numpy.dtype[numpy.float16]]:
# ...   False == numpy.ndarray[typing.Any, numpy.dtype[numpy.float16]].

もちろん、pytorchのtensorなどもチェック可能です。

@beartype
def torch_add(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
    return a + b

print(torch_add(a=torch.tensor([1, 2, 3]), b=torch.tensor([4, 5, 6])))
# >>> tensor([5, 7, 9])

類似ライブラリとしてtypeguard[3]というものがありますが、beartypeが出してるベンチマークを見ると、呼び出しまで含めるとbeartypeの方が早い印象です。


図1: 画像はhttps://beartype.readthedocs.io/en/latest/math/より引用

pydantic

pydanticは、pythonの型ヒントを使って、データのバリデーションを行うライブラリです。
基本的な使い方はpydantic.BaseModelを継承して型ヒントを書くとruntimeでデータのバリデーションを行ってくれます。
機械学習を普段主にやる人であってもFastAPIを使ってAPI提供などをやっていれば触ったことや聞いたことがあると思います。

pydanticの日本語の記事では以下の記事[4]など参照してください。

https://zenn.dev/hayata_yamamoto/articles/python-pydantic

基本的には上で紹介されてる通りなのですが、基本的な使い方は以下のようになります。

from pydantic import BaseModel, Field

class User(BaseModel):
    name: str = Field(..., description="名前")
    age: int = Field(..., description="年齢")

dictやlistはdefault_factoryを使って与えます

from typing import Optional
from pathlib import Path

class Request(BaseModel):
    id: int = Field(..., description="ID")
    image_name: str = Field(..., description="画像名")
    model_path: Path = Field(..., description="モデルパス")
    additional_config: Optional[dict[str, Union[str, float, bool]]] = Field(
        default_factory=lambda: {"tta": False}, description="追加設定"
    )

pydantic.BaseModelを継承したクラスはdict()を呼ぶこことで辞書に変換でき、またjson()を呼ぶことでjson(文字列)に変換できます。これにより簡単にserializeすることが可能です。

また入れ子になったクラスであってもdict()で辞書に変換できます。


class EstimatedResult(BaseModel):
    value: list[int] = Field(..., description="推論結果")
    shape: tuple[int, int] = Field(..., description="推論結果のshape")


class Response(BaseModel):
    ids: list[int] = Field(..., description="ID")
    estimated_result: EstimatedResult = Field(..., description="推論結果")
    model_path: Path = Field(..., description="モデルパス")


estimated = EstimatedResult(value=[1, 2, 3], shape=(3, 1))
response = Response(
    ids=[1, 2, 3],
    estimated_result=estimated,
    model_path=Path("weighs/model1.pth"),
)
print(response.dict())
# >>> {'ids': [1, 2, 3], 'estimated_result': {'value': [1, 2, 3], 'shape': (3, 1)}, 'model_path': PosixPath('weighs/model1.pth')}

また逆に、jsonやdictからも初期化することができます。またschema()を呼ぶことでJson Schemaなどを返すこともできます。
詳細は[5]を参照してください。

また、Configを設定することでimmutableにすることもできます。

from pydantic import BaseModel, Field

class ImmutableResponse(BaseModel):
    ids: list[int] = Field(..., description="ID")
    estimated_result: EstimatedResult = Field(..., description="推論結果")
    model_path: Path = Field(..., description="モデルパス")

    class Config:
        frozen = True  # 全Fieldをimmutableにする


immutable_response = ImmutableResponse(
    ids=[1, 2, 3],
    estimated_result=estimated,
    model_path=Path("weighs/model1.pth"),
)

try:
    immutable_response.estimated_result = [1000000, 100000, 1000000]
except Exception as e:
    print(e)
# >>> "ImmutableResponse" is immutable and does not support item assignment

特定のFieldだけimmutableにすることもできます。

class ResponseConainsImmutableField(BaseModel):
    """
    Ref:
    [1] https://docs.pydantic.dev/latest/usage/schema/#field-customization
    """

    # allow_mutation=FalseでこのFieldのみimmutable
    ids: list[int] = Field(..., allow_mutation=False, description="ID")
    estimated_result: EstimatedResult = Field(..., description="推論結果")
    model_path: Path = Field(..., description="モデルパス")

    class Config:
        # to check to be performed
        validate_assignment = True

# ids Fieldのみimmutable
response_contains_immutable_field = ResponseConainsImmutableField(
    ids=[1, 2, 3],
    estimated_result=estimated,
    model_path=Path("weighs/model1.pth"),
)
# OK
response_contains_immutable_field.model_path = Path("weights/model2.pth")
# Fail
response_contains_immutable_field.ids = [1111, 1111, 1111]
# >>> "ids" has allow_mutation set to False and cannot be assigned

その他にもFieldをカスタマイズすることができ正規表現でvalidationしたり、長さを制限できたりさまざまなvalidationができます。
詳細は[6]を参照してください。

dataclasses.dataclassとはこのあたりのruntimeにおけるデータ・型のvalidationの有無がおおきな差分になると思います。

データのvalidationは以下のようにvalidator decoratorを使って定義することが可能です。

class ExampleWithValidation(BaseModel):
    ids: list[int] = Field(..., description="ID")

    @validator("ids")
    def is_less_than_10(cls, value: list[int]) -> list[int]:
        if not all([v < 10 for v in value]):
            raise ValueError(f"Expected id is less than 10, but got {value}")
        return value


# Example with validation
# OK
_ = ExampleWithValidation(ids=[1, 2, 3])
# Fail
try:
    _ = ExampleWithValidation(ids=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
except ValidationError as e:
    print(e)
# >>> 1 validation error for ExampleWithValidation
# ... ids
# ...  Expected id is less than 10, but got [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (type=value_error)

pydanticにも関数の引数の型をチェックするdecoratorが存在します。[7]
v1.5で追加され、まだbeta版ですが、以下のように使うことができます。

beartypeのように型が違った場合に例外を投げることを期待してるのですが、このサンプルコードでfloatを期待してる引数にintを渡しても例外が投げられませんでした。

ここはなぜなのかドキュメントを読む限りでは分かりませんでした。

ただドキュメント通りに、数値(float)を期待している引数に文字列を渡すと例外を投げてくれました。

@validate_arguments
def sample_fn(a: int, b: float) -> float:
    return a * b

# Example with validate_arguments
# OK
print(sample_fn(1, 2.0))
# >>> 2.0

# Fail?
try:
    print(sample_fn(1, 2))
except ValidationError as e:
    print(e)
# >>> 2.0

# Fail
try:
    print(sample_fn(1, "test"))
except ValidationError as e:
    print(e)
# 想定外エラー
except Exception as e:
    print(e)
# >>> 1 validation error for SampleFn
# ... b
# ...   value is not a valid float (type=type_error.float)

数値に可換な文字列を渡した場合には数値に変換して計算してくれるようです(???)

try:
    print(sample_fn(1, "2"))
except ValidationError as e:
    print(e)
# 想定外エラー
except Exception as e:
    print(e)
# >>> 2.0

beta版ということもあり、関数の型のチェックなどはbeartypeを利用する方が良いと思います。

また現在pydanticはv2の開発が進んでいます。[8] READMEによるとまだproduction readyではないようなのですがpyo3を用いてRustで書かれているようなので、かなり高速になるのではないかと思います。
最近ではpolarsなど機械学習周りでもRust製のライブラリが増えてきているので楽しみです。

まとめ

pythonのruntimeで型のvalidationを行う方法としてbeartypeとpydanticを紹介しました。
beartypeは関数の入出力の型のチェックを行うことができ、pydanticは型やデータのvalidationを行うことができます。
ベータ版ではあるもののpydanticでも関数の入出力の型のチェックはできなくはなけいどbeartypeを使うほうが現状は良さそうでした。(自分の調査不足かも)

手軽にかけるpythonですが、ちゃんとしたものを作ろうとすると難しい部分もあります。
少なくてもtype hintとして型を書いて読む人が関数の意図を把握しやすくする、静的解析でバグの混入をできるだけ防ぐは後々の自分のためにも必須ではないでしょうか。

その上でbeartypeやpydanticを使って型やデータのvalidationを行うことで安全性を高めることができると思います。もちろん闇雲に使えばいい訳ではなく、柔軟性とのトレードオフを意識して使っていく必要があると思いますが。。

どこまで厳しくチェックしてやるかは開発者の属してるチームや部署の文化次第な面もあると思いますが、少なくても自分が経験してきた範囲ではtype hintは少なくても書いていた方が開発体験は上がるのでtype hintは書きましょう。

記事中に掲載したサンプルコードは以下のリンク先で確認できます。

https://github.com/daikichiba9511/example_beartype_pydantic

参考

[1] https://future-architect.github.io/articles/20201223/
[2] https://github.com/beartype/beartype
[3] https://github.com/agronholm/typeguard
[4] https://zenn.dev/hayata_yamamoto/articles/python-pydantic
[5] https://pydantic-docs.helpmanual.io/usage/models/#json-serialisation-dict-conversion
[6] https://pydantic-docs.helpmanual.io/usage/schema/#field-customization
[7] https://docs.pydantic.dev/latest/usage/validation_decorator/
[8] https://docs.pydantic.dev/latest/blog/pydantic-v2/

GitHubで編集を提案

Discussion