Pydanticでバリデーションされない罠
はじめに
こんにちは。株式会社トリドリで機械学習エンジニアをしている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(...)
を書くことで、より安全に型を使うことができるでしょう。
ここでは紹介していない設定が他にもたくさんあるので、興味がある方は調べてみてください!
Discussion