🐻‍❄️

Pydanticでバリデーションされない罠

2025/01/17に公開

はじめに

こんにちは。株式会社トリドリで機械学習エンジニアをしているqcubeです!
趣味は読書とボルダリングで、最近読んで(読んでいて)面白かった本は、『土と脂』、『WEIRD「現代人」の奇妙な心理』、『体内時計の科学』です。

さて私は、PythonでのデータのバリデーションにPydanticというライブラリを使っているのですが、予想外のエラーにハマってしまったので、原因と解決策を共有したいと思います。

PydanticとPolarsの簡単な復習

どっちのライブラリも知ってるよ!って方は、問題が起きたコードまで飛ばしてください。

使用したライブラリのバージョンは以下の通りです。

polars==1.6.0
pydantic==2.9.1

Pydantic

Pydantic はデータバリデーションに使えるライブラリです。
以下のようなクラスを作ると

from pydantic import BaseModel


class Person(BaseModel):
    name: str  # str型の必須のフィールド
    age: int | None = None  # int | None 型のフィールドで、デフォルト値は None

以下のようにバリデーション済みのインスタンスを作れます。

person = Person(name="qcube", age=30)
# Person(name='qcube', age=30)

person_with_default_age = Person(name="qcube")
# Person(name='qcube', age=None)

# フィールドの取得
name = person.name  # 'qcube'
age = person.age  # 30

# 辞書への変換
person_dict = person.model_dump()  # {'name': 'qcube', 'age': 30}

変な値を入れると、エラーが出ます。

person_with_invalid_age = Person(name="qcube", age=34.56)

エラーメッセージ

ValidationError: 1 validation error for Person
age
  Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=34.56, input_type=float]
    For further information visit https://errors.pydantic.dev/2.9/v/int_from_float

※ なお、strict mode ではない場合、person = Person(name="qcube", age="30") と書くこともできて、age は int の30になりますが、strict mode にすると ValidationError が出ます。

Polars

Polars は DataFrame を扱うためのライブラリです。基本 pandas の上位互換ですが、たまに pandas でしかできないこともあったりします。
Polars の良いところは、速い、読みやすい、型がちゃんとしている、です。
今回使うメソッドは3つだけです。

  • with_columns: 列を追加または上書きします。
  • map_elements: 列に対して演算します。
  • unnest: ネストされた列を別々の列に展開します。
import json

import polars as pl


df_raw = pl.DataFrame(
    {
        "col1": [1, 2, 3],
        "col2": [
            '{"col2_1": 1.0, "col2_2": "a"}',
            '{"col2_1": 2.0, "col2_2": "b"}',
            '{"col2_1": 3.0, "col2_2": "c"}',
        ],
    }
)
# shape: (3, 2)
# ┌──────┬────────────────────────────────┐
# │ col1 ┆ col2                           │
# │ ---  ┆ ---                            │
# │ i64  ┆ str                            │
# ╞══════╪════════════════════════════════╡
# │ 1    ┆ {"col2_1": 1.0, "col2_2": "a"} │
# │ 2    ┆ {"col2_1": 1.0, "col2_2": "b"} │
# │ 3    ┆ {"col2_1": 1.0, "col2_2": "c"} │
# └──────┴────────────────────────────────┘

df_2 = df_raw.with_columns(pl.col("col1") + 1)
# shape: (3, 2)
# ┌──────┬────────────────────────────────┐
# │ col1 ┆ col2                           │
# │ ---  ┆ ---                            │
# │ i64  ┆ str                            │
# ╞══════╪════════════════════════════════╡
# │ 2    ┆ {"col2_1": 1.0, "col2_2": "a"} │
# │ 3    ┆ {"col2_1": 1.0, "col2_2": "b"} │
# │ 4    ┆ {"col2_1": 1.0, "col2_2": "c"} │
# └──────┴────────────────────────────────┘

df_with_struct_col = df_2.with_columns(
    pl.col("col2").map_elements(lambda x: json.loads(x))
)
# shape: (3, 2)
# ┌──────┬───────────┐
# │ col1 ┆ col2      │
# │ ---  ┆ ---       │
# │ i64  ┆ struct[2] │
# ╞══════╪═══════════╡
# │ 2    ┆ {1.0,"a"} │
# │ 3    ┆ {1.0,"b"} │
# │ 4    ┆ {1.0,"c"} │
# └──────┴───────────┘

df_unnested = df_with_struct_col.unnest("col2")
# shape: (3, 3)
# ┌──────┬────────┬────────┐
# │ col1 ┆ col2_1 ┆ col2_2 │
# │ ---  ┆ ---    ┆ ---    │
# │ i64  ┆ f64    ┆ str    │
# ╞══════╪════════╪════════╡
# │ 2    ┆ 1.0    ┆ a      │
# │ 3    ┆ 1.0    ┆ b      │
# │ 4    ┆ 1.0    ┆ c      │
# └──────┴────────┴────────┘

少し補足すると、Struct はネストされたデータを扱うためのデータ型です。
map_elements を使うときは、return_dtype を指定した方がよいです。

df_with_struct_col = df_2.with_columns(
    pl.col("col2").map_elements(
        lambda x: json.loads(x),
        return_dtype=pl.Struct,
    )
)

また、そもそも map_elements よりも効率的に書ける方法があることが多いです。

df_with_struct_col = df_2.with_columns(
    pl.col("col2").str.json_decode()
)

問題が起きたコード

本題に入ります。
私が書いていたのは、以下のようなコードです。
dfはデータベースから取得したDataFrameで、dataカラムにはJSON文字列が入っています。
そのJSON文字列をカラムに展開するためのコードです。

import json

import polars as pl
from pydantic import BaseModel


class MyModel(BaseModel):
    float_value: float = 0


def unnest(df: pl.DataFrame) -> pl.DataFrame:
    df_unnest = df.with_columns(
        pl.col("data").map_elements(
            lambda x: MyModel(**json.loads(x)).model_dump(),
            return_dtype=pl.Struct,
        )
    ).unnest("data")
    return df_unnest

ここで何が問題か気付いた方は、Pydanticを使いこなしている方だと思います!

いつエラーが起きるのか

実はこのコード、エラーが起きたり起きなかったりします。そのため、開発中には問題に気が付きませんでした。
以下にいくつか例を載せます。

# エラーは出ない
df1 = pl.DataFrame(
    {
        "id": [1],
        "data": ["{}"]
    }
)
df1_unnested = unnest(df1)

# エラーは出ない
df2 = pl.DataFrame(
    {
        "id": [1],
        "data": ['{"float_value": 0.0}']
    }
)
df2_unnested = unnest(df2)

# エラーは出ない
df3 = pl.DataFrame(
    {
        "id": [1, 2],
        "data": ["{}", "{}"]
    }
)
df3_unnested = unnest(df3)

# エラーは出ない
df4 = pl.DataFrame(
    {
        "id": [1, 2],
        "data": ['{"float_value": 0.0}', '{"float_value": 0.0}']
    }
)
df4_unnested = unnest(df4)

# エラーが出る!!
df5 = pl.DataFrame(
    {
        "id": [1, 2],
        "data": ['{"float_value": 0.0}', "{}"]
    }
)
df5_unnested = unnest(df5)

エラーメッセージは以下の通りです。

thread 'polars-0' panicked at /home/runner/work/polars/polars/crates/polars-core/src/series/any_value.rs:23:53:
data types of values should match: SchemaMismatch(ErrString("unexpected value while building Series of type Float64; found value of type Int64: 0"))
--- PyO3 is resuming a panic after fetching a PanicException from Python. ---

...

PanicException: data types of values should match: SchemaMismatch(ErrString("unexpected value while building Series of type Float64; found value of type Int64: 0"))

Float64の型があるべきところにInt64があると言われていますね。なぜでしょうか。

型の問題のようなので、型を確認してみましょう。

type(MyModel().float_value)

なんと、intになっています。
そうです、以下のように書くと、デフォルト値はfloatの0.0ではなくintの0になってしまうのです。
(コード再掲)

class MyModel(BaseModel):
    float_value: float = 0

一方、以下のように引数を渡してインスタンスを作成した場合は、ちゃんとfloatになります。

type(MyModel(float_value=0).float_value)

ちなみに、もとのdfのdataが ["{}", "{}"] のような場合(df1やdf3)は、intで一貫しているためエラーは出ず、unnestしたDataFrameのカラムはInt64になります。
逆に言うと、intとfloatが混在するとPolarsが型を判定できないため、(map_elementsで)エラーが出ます。

解決策

上記のように、デフォルト値の型はそのままとなるので、以下のように気を付けて書く、というのも解決策の1つです。

class MyModel2(BaseModel):
    float_value: float = 0.0

しかし、デフォルト値はデフォルトではバリデートされないので、以下のようなコードでさえエラーは出ません(mypyはエラーを出してくれますが)。

class MyModel3(BaseModel):
    float_value: float = "string"


m = MyModel3()
# MyModel3(float_value='string')

なので、デフォルト値もバリデートされるように、以下のように書いた方が安全でしょう。
(なお、バリデートされるのはインスタンスを作成するときで、クラスを定義したときではありません。)

from pydantic import ConfigDict


class MyModel4(BaseModel):
    model_config = ConfigDict(validate_default=True)

    float_value: float = 0.0

この場合、float_value: float = 0 と書いても、デフォルト値は float に変換されます。
また、float_value: float = "string" のように書いた場合は、以下のようにエラーが出ます。

class MyModel5(BaseModel):
    model_config = ConfigDict(validate_default=True)

    float_value: float = "string"


m = MyModel5()

エラーメッセージ

ValidationError: 1 validation error for MyModel5
float_value
  Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='string', input_type=str]
    For further information visit https://errors.pydantic.dev/2.9/v/float_parsing

また、上で少し触れた strict mode を使えば、さらに安全になります。

class MyModel6(BaseModel):
    model_config = ConfigDict(strict=True, validate_default=True)

    float_value: float = 0.0

こうすると、MyModel6(float_value="0") のような入力に対しても ValidationError が出るようになります。

まとめ

Pydantic のフィールドのデフォルト値は、バリデーションも型変換もされません。
floatとアノテーションしていても、intの値を書くとintになるし、strの値を書くとstrになります。
model_config = ConfigDict(...) を書くことで、より安全に型を使うことができるでしょう。
ここでは紹介していない設定が他にもたくさんあるので、興味がある方は調べてみてください!

toridori tech blog

Discussion