AREKORE

daikikatsuragawaのアレコレ

「データに関する堅牢性と可読性を向上させるpydanticとpanderaの活用方法の提案」の質疑応答

先日、PyCon JP 2022にて「データに関する堅牢性と可読性を向上させるpydanticとpanderaの活用方法の提案」という題で発表をしました。

2022.pycon.jp

発表資料は以下です。

speakerdeck.com

とてもありがたいことに、想像以上に多くの方に発表を聞いていただくことができました。今回の発表について、確認しただけでも以下のような反響を得ることができました。ありがとうございます。

  • Twitterにおいて約50件のツイート
  • Slidoにおいて約20件の質問

ただし、質問については、発表後の時間が短かったこともあり、回答しきれないものも多く、整理した情報や意見を十分に伝えることができませんでした。そこで、本記事では、いただいた質問に基づき、質問を整理します。そして、それらの質問に対する回答という形式で、発表を補足していきます。※本記事は随時更新予定です。

質疑応答

ライブラリの仕様

Q:pydanticによってイミュータブルなクラスの定義は可能ですか?

pydanticによって、イミュータブル(つまり生成されたインスタンスの状態の変更が不可能)なクラスの定義は可能です。BaseModel(pydantic.BaseModel)内のConfigにより設定を変更することで実現します。以下2つパラメータによる設定例を紹介します。

  • allow_mutation
  • frozen

まずは、allow_mutationの設定をする場合です。allow_mutationについては以下のとおりです。

whether or not models are faux-immutable, i.e. whether setattr is allowed (default: True)

引用元:https://pydantic-docs.helpmanual.io/usage/model_config/

つまり、allow_mutation=Falseにするとイミュータブルなクラスが実現されます。以下のように設定が可能です。

from pydantic import BaseModel
 
class User(BaseModel):
    name: str
    age: int
  
    class Config:
        allow_mutation = False
 
user = User(name="パイソン 太郎", age=20)
user.name = "パイダンティック 次郎"

上記の実装は、イミュータブルなインスタンスに値の再代入を試みていますが、設定の意図通りにエラーが発生します。

(省略)

TypeError: "User" is immutable and does not support item assignment

続いてfrozenの設定をする場合です。frozenについては以下のとおりです。

setting frozen=True does everything that allow_mutation=False does, and also generates a hash() method for the model. This makes instances of the model potentially hashable if all the attributes are hashable. (default: False)

引用元:https://pydantic-docs.helpmanual.io/usage/model_config/

つまり、frozen=Trueにすると、allow_mutation=Falseになり、イミュータブルなクラスが実現されます。

from pydantic import BaseModel
 
class User(BaseModel):
    name: str
    age: int
  
    class Config:
        frozen = True
 
user = User(name="パイソン 太郎", age=20)
user.name = "パイダンティック 次郎"

上記の実装も、イミュータブルなインスタンスに値の再代入を試みていますが、設定の意図通りにエラーが発生します。

このように、pydanticで定義するクラスをイミュータブルにできます。

Q:pandasのデータフレームではstr型はobject型として扱われます。panderaでstr型のカラムの定義は可能ですか?

※当日、曖昧な回答をしてしまい申し訳ございません。以下が整理された回答です。

panderaでstr型のカラムの定義は可能です。str型を含むobject型は、str型として扱われます。それゆえ、panderaによる型のバリデーションによって弾かれることはありません。それに加え、Fieldクラスを組み合わせることで、文字列における「始まりの文字列」、「終わりの文字列」のバリデーションなども可能です。しかし、object型のうち本来str型ではないであろう要素の通過も許されてしまうという問題があります。つまり、他のint型、float型とは異なる挙動となっています。panderaのプロジェクトでは、本件に関連したIssueが公開されており議論がなされています。今後、str型に関するバリデーションについて対応されるかもしれません。

github.com

github.com

以下のような暫定対応により、他のint型、float型と同様の型のバリデーションの実現が可能です。

import pandera as pa
from pandera.typing import Series

class UserSchema(pa.SchemaModel):
    name: Series[str]
    age: Series[int]

    @pa.check("name")
    def check_value_is_string_type(cls, series: Series) -> Series:
        return series.map(lambda v: isinstance(v, str))

Q:pydanticによるバリデーション時のエラーのカスタマイズ(エラーメッセージの変更など)は可能ですか?

可能です。特にエラーメッセージについて説明します。設定方法は大きく2つあります。

まず、Configのerror_msg_templatesに設定を入れることで、エラーメッセージの上書きが可能です。

a dict used to override the default error message templates. Pass in a dictionary with keys matching the error messages you want to override (default: {})

引用元: https://pydantic-docs.helpmanual.io/usage/model_config/

例えば、整数型であるという条件を満たさない場合のエラー(type_error.integer)が発生したときのメッセージとして「整数型ではありません!」を設定しています。

from pydantic import BaseModel
 
class User(BaseModel):
    name: str
    age: int
  
    class Config:
        error_msg_templates = {
            "type_error.integer": "整数型ではありません!",
        }
 
user = User(name="パイソン 太郎", age="二十歳")

整数型ではない値を入れたため、エラーが発生されます。設定した「整数型ではありません!」というメッセージが出力されます。

(省略)

ValidationError: 1 validation error for User
age
  整数型ではありません! (type=type_error.integer)

error_msg_templatesに要素を追加することで、各種エラーにおけるメッセージの設定が可能です。 Fieldに設定した最小値の条件を満たさないエラー(value_error.number.not_ge)に対して「自然数ではありません!」というメッセージを設定します。

from pydantic import BaseModel, Field
 
class User(BaseModel):
    name: str
    age: int = Field(ge=0)
  
    class Config:
        error_msg_templates = {
            "type_error.integer": "整数型ではありません!",
            "value_error.number.not_ge": "自然数ではありません!",
        }
 
user = User(name="パイソン 太郎", age=-1)

自然数ではない値を入れたため、エラーが発生されます。設定した「自然数ではありません!」というメッセージが出力されます。

(省略)

ValidationError: 1 validation error for User
age
  自然数ではありません! (type=value_error.number.not_ge; limit_value=0)

続いて、@validatorpydantic.validator)というデコレーターを付与しているメソッドにおけるエラーに対してメッセージを設定することでもメッセージの指定が可能です。以下は、上記のコードに加え、文字列に空白を含むことを期待するモデルです。

from pydantic import BaseModel, Field, validator
 
class User(BaseModel):
    name: str
    age: int = Field(ge=0)
  
    class Config:
        error_msg_templates = {
            "type_error.integer": "整数型ではありません!",
            "value_error.number.not_ge": "自然数ではありません!",
        }
  
    @validator("name")
    def check_contain_space(cls, v):
        if " " not in v.strip(): # 前後の半角空白を無視
            raise ValueError("半角空白が含まれていません!")
        return v.strip() # 前後の半角空白を削除
 
user = User(name="パイソン太郎", age=20)

半角空白が含まれていない値を入れたため、エラーが発生されます。設定した「半角空白が含まれていません!」というメッセージが出力されます。

ValidationError: 1 validation error for User
name
  半角空白が含まれていません! (type=value_error)

以上の方法でpydanticに関するエラーメッセージの指定(上書き)が可能です。

類似するライブラリとの比較

Q:TypedDictとpydanticの違いは何ですか?

TypedDictとpydanticについて以下のように特徴を比較します。

比較軸 TypedDict pydantic
インストールの要否 標準ライブラリである。PyPIからのインストールは不要。 外部ライブラリである。PyPIからのインストールが必要。
型チェックの精度 構造の表現が可能なのでより詳細な辞書型の方チェックが可能になる。 構造の表現が可能なのでより詳細な辞書型の方チェックが可能になる。
バリデーションの実現方法 あくまで型なので、単体では特にバリデーションは実施されない。 クラスおよびフィールドの定義のみで型についてのバリデーションが実施される。Fieldモジュールにより簡単なバリデーションの実現が可能である。メソッドを定義することにより詳細なバリデーションの実現が可能である。
シリアライズ・デシリアライズの仕様(辞書型) あくまで型なので、特にシリアライズ・デシリアライズなどの振る舞いはない。 辞書型とのシリアライズ・デシリアライズが可能。辞書がネストされている場合も意図どおりインスタンスをの生成が可能である。
シリアライズ・デシリアライズの仕様 (JSON あくまで型なので、特にシリアライズ・デシリアライズなどの振る舞いはない。 JSONとのシリアライズ・デシリアライズが可能。JSONがネストされている場合も意図どおりインスタンスをの生成が可能である。

上記のように、それぞれに異なるメリット・デメリットが存在します。使用状況によって導入を検討してください。

Q:dataclassとpydanticの違いは何ですか?

dataclassとpydanticについて以下のように特徴を比較します。

比較軸 dataclass pydantic
インストールの要否 標準ライブラリである。PyPIからのインストールは不要。 外部ライブラリである。PyPIからのインストールが必要。
型チェックの精度 構造の表現が可能なのでより詳細な型チェックが可能になる。 構造の表現が可能なのでより詳細な型チェックが可能になる。
バリデーションの実現方法 クラスおよびフィールドの定義のみではバリデーションが実施されない。メソッドを定義することにより詳細なバリデーションの実現が可能である。 クラスおよびフィールドの定義のみで型についてのバリデーションが実施される。Fieldモジュールにより簡単なバリデーションの実現が可能である。メソッドを定義することにより詳細なバリデーションの実現が可能である。
シリアライズ・デシリアライズの仕様(辞書型) 辞書型へのシリアライズ・デシリアライズが可能。ただし、ネストされている要素に関してはインスタンスには変換されず、辞書型として格納される。 辞書型とのシリアライズ・デシリアライズが可能。辞書がネストされている場合も意図どおりインスタンスをの生成が可能である。
シリアライズ・デシリアライズの仕様 (JSON dataclasses-jsonという外部ライブラリを使用することでJSONとのシリアライズ・デシリアライズが可能。ネストされている場合も意図どおりインスタンスをの生成が可能である。 JSONとのシリアライズ・デシリアライズが可能。JSONがネストされている場合も意図どおりインスタンスをの生成が可能である。

上記のように、それぞれに異なるメリット・デメリットが存在します。使用状況によって導入を検討してください。

Q:dataclassからpydanticへの移行について有用な情報はありますか?

既存のdataclass(dataclasses.dataclass)を使用したコードに対して、比較的少ない変更でpydanticへ移行する時に有用なモジュールとしてdataclass(pydantic.dataclasses.dataclass)があります。

Dataclasses

If you don't want to use pydantic's BaseModel you can instead get the same data validation on standard dataclasses (introduced in Python 3.7).

引用元:https://pydantic-docs.helpmanual.io/usage/dataclasses/

@dataclassというデコレーターを付与することでバリデーションをしてくれるクラスにしてくれます。つまり、使用している@dataclassdataclasses.dataclass)というデコレーターを付与しているクラスの内容を変更する必要がなく、インポートをするモジュールの差し替えのみでpydanticの使用が可能です。

例えば、従来のdataclass(dataclasses.dataclass)を使用した以下のコードがあったとします。これは期待しない値の格納を許してしまいます。

from dataclasses import dataclass

@dataclass
class User:
     name: str
     age: int

User(name="パイソン 太郎", age="二十歳")

dataclassモジュールをdataclasses.dataclassからpydantic.dataclasses.dataclassに差し替えます。差分は以下です。

- from dataclasses import dataclass
+ from pydantic.dataclasses import dataclass

モジュールを差し替えるのみで、バリデーションが実現され、期待しない値の格納を防いでいます。

from pydantic.dataclasses import dataclass

@dataclass
class User:
     name: str
     age: int

User(name="パイソン 太郎", age="二十歳")
(省略)

ValidationError: 1 validation error for User
age
  value is not a valid integer (type=type_error.integer)

以上より、既存のdataclass(dataclasses.dataclass)を使用しているコードにおいて、モジュールをdataclass(pydantic.dataclasses.dataclass)に差し替えることで、バリデーションを実施してくれるクラスに変えることができます。

ただし、dataclass(pydantic.dataclasses.dataclass)とBaseModel(pydantic.BaseModel)の挙動が異なるようなので注意してください。

Note

Keep in mind that pydantic.dataclasses.dataclass is a drop-in replacement for dataclasses.dataclass with validation, not a replacement for pydantic.BaseModel (with a small difference in how initialization hooks work). There are cases where subclassing pydantic.BaseModel is the better choice.

For more information and discussion see pydantic/pydantic#710.

引用元:https://pydantic-docs.helpmanual.io/usage/dataclasses/

使用場面についての見解

特定の場面に対して「導入すべきか?」という質問が多く挙げられました。断定できないことが恐縮ですが、導入するか否かの意思決定については、各々個人やプロジェクトのさまざまな事情なども組み合わせて検討することをお勧めします。私自身も限られた経験の中で、導入する(したい)場面や、導入しない(したくない、できない)場面が、条件付きで導入できる場面があり、ケースバイケースだと思っています。それゆえ、以降の内容を意思決定の材料とすることをお勧めします。

Q:pydanticとpanderaによるデータのバリデーションにかかる時間を見過ごせない状況では、ウェブアプリケーション全体ではなく、バッチ処理のみに導入するという選択肢はどうですか?

おっしゃるとおり、良い選択肢だと思います。定刻に起動するバッチ処理は比較的、処理時間を気にしなくても良いと思われるので、処理時間に関する懸念は解消されやすいと思われます。場合によっては、期待しないデータを一時的に管理してしまう可能性もありますが、少なくともバッチ処理の頻度では解消できるかと思われます。理想は常にバリデーションしているべきだが、このように、例えば入り口と出口のみだったり、バッチ処理のみなど、部分的にバリデーション を用意するという工夫が考えられます。

Q:機械学習や分析など、ラビットプロトタイピングが有用な場面があります。このような場合にライブラリ(pydanticやpandera)を使用する際、慣れていない場合は開発速度が遅くなります。何か対策はありますか?

私の一意見を回答とさせていただきます。対策としては、状況によって使用するか否かの判断をしています。例に挙げていただいた機械学習や分析、特にGoogle ColaboratoryやJupyter Notebookなど、インタラクティブに実装するものに関して、その判断について考えます。まず判断材料となる要素として「pydanticとpanderaがもたらす」という価値と「慣れていない場合は開発スピードが遅くなる」というリスクを挙げます。この比重は状況によって変わってきます。まず、書き捨てのコードの場合、「使用しない」に比重を置きます。なぜなら、後から再実行しない、つまり堅牢性が重要ではないためです。それに加え、後から見返す必要がない、つまり可読性が重要ではないためです。逆に後から見返し、管理したい、再実行することを考えると、長い目では「使用する」に比重をおいた方が良いと思います。そのとき、ある程度のめどが立つまでは使用はせず、リファクタリングの段階で使用するのが良いのではないかと考えています。また、計算ミスなどが深刻な影響を与える場合、「pydanticとpanderaがもたらす価値」が上がる可能性もあるかもしれません。

Q:定義しているスキーマの対象のAPIのレスポンスや元のデータにおける仕様に変更があった場合、どのような対応をすると良いですか?後方互換性を持たせるなどですか?

通常の開発における修正と同様に、スキーマを定義し直します。ただし、データのスキーマが変化する過渡期については注意が必要です。リリースにおける切り替えの工夫だったり、おっしゃるとおり後方互換性を持たせるなどの対応が必要かと思われます。試したことはないが、Union型やOption型、可能であれば使用を避けたいが、Any型、スキーマ自体の設定の変更などにより許容するデータを調整できるため、過渡期に用意しておく後方互換性に有用な要素になるかとも思われます。

Q:現実のデータセットは必ずしも整理されているとは限らないということは承知していますが、そのような場合でもスキーマを定義、バリデーションを実施することが望ましいですか?

おっしゃるとおり、元のデータセットは必ずしも整理されているとは限りません。そこで取りうる選択肢もいくつか存在すると思われます。例えば、そのようなデータセットを整形などすると思われますが、まず一次受けのタイミングではそのまま受け取り、その後、整形に伴ってpanderaによるスキーマ定義およびバリデーションを実現するという選択肢も考えられます。もしくは、一次受けのタイミングでわかっている範囲での必要最小限のバリデーションのみ実装するという方法も考えられます。これにより、少なくともどのようなデータを扱っているのかを他者が理解することができると思われます。

Q:pydanticを使用せず、panderaのみの使用は可能ですか?

発表では類似の目的と類似の記述方法を持つ2つのライブラリとして併用することを勧めたが、それぞれ独立したライブラリなので問題ない。ただし、panderaの依存関係にpydanticが含まれているため、インストールは必要となる。どうせインストールされるのであれば、併用してみてはどうかというのが私の意見です。

Q:(本発表において)pydanticとpanderaを併用する意図はどのようなものですか?一方のみの使用でも良いのでは?

前提として、状況に応じて、一方のみの使用でも良いと思います。例えば、データフレームを扱わないのにpanderaを使用する必要はありません。逆にデータフレームを扱うような処理においては併用が有効になる可能性があります。改めて、併用を提案したい理由を列挙します。

  • スキーマ定義およびバリデーションが可能な型の範囲が増えること
  • panderaのSchemaModelを使用する場合に併用時の学習コストが少ないこと
  • 依存関係もあり他方を使用している場合リソースに関する懸念が少ないこと(使用しない理由がないこと)

経験に基づく見解

※今回は個人として発表しました。それゆえ、以降は個人の活動や経験に基づき回答します。

Q:発表内容を実際の業務や研究に活用したことがありますか?

私が個人としてPythonによるコーディングを実施する場面は以下2つです。

  • Google Colaboratoryでの分析
  • OSS(主にライブラリ)開発

Google Colaboratoryでの分析では「何度か実行する」かつ「見返す」ようなコードではがあります。そのようなコードの場合、pydanticやpanderaを使用しています。ある意味ドキュメントの代わりもかねて使用しています。逆に、書き捨てのコードでは恩恵が少ないので使用しません。

OSS開発については使用しません。依存関係を増やしてしまうことが一番の理由です。対象のOSSを使用しているプロジェクトの都合なども考えると、依存関係を増やすのはハードルが高いです。ただし、堅牢性、可読性に関する課題感を抱くことはしばしばあり、開発、テスト時にできることとして、型ヒントおよびmypyの導入をしていたりはします。

Q:処理速度が遅くて困った経験はありますか?

自分には「処理速度が遅くて困った経験」がありませんでした。理由は以下で困りようもないのだと考えられます。

  • リアルタイム性が重要な実装ではない(計算時間を待てる)
  • そもそも扱うデータが大規模ではない(かもしれない)

逆にリアルタイム性が重要であったり、大規模なデータを扱う場合は、処理速度が遅く困ることになるかもしれません。

本記事のまとめ

今回の発表および皆様からのコメントを踏まえ、意思決定の事例やユースケースを、導入したか否かにかかわらず知りたいと思いました。可能であれば、類型化できるのではないかと考えています。引き続き、情報収集に努めます。情報提供や質問などあれば、以下までご連絡をください。よろしくお願いいたします。

forms.gle