医療従事者出身、未経験でデータ職を目指して転職活動中のshimodasと申します。
初学者なりにMLOpsについて学び、試行錯誤の末に出来上がったものたちを発信中です。
シリーズ「ノートブックを卒業した日 ── 指南書で学んだMLOps技術をKaggleで全部試してみた」
- uv(パッケージ管理))
- Pandera(データバリデーション)← 今ここ
- Hydra(設定管理)
- MLflow(実験管理)
- Streamlit(アプリ化・デプロイ)
- Dev Container(開発環境コンテナ)
- Cookiecutter × リポジトリ設計
はじめに
機械学習を始めたばかりの自分は、データの品質チェックをほとんどしていませんでした。
前処理スクリプトを書いて、pd.read_csv() でデータを読み込んで、そのまま学習に突っ込む。エラーが出なければOKだと思っていました。でも実際には、エラーが出ないまま間違った結果が出続けることがあります。型が違っていても、想定外の値が混入していても、Pythonは黙って処理を続けます。「なんか異様にスコアが低いな」と思って掘り返したら、前処理の段階で誤ったデータを投入していた、という経験を何度かしました。
『先輩データサイエンティストからの指南書』(浅野純季ら著、技術評論社、2025年)に、その解決策として Pandera が紹介されていました。「スキーマを定義してデータフレームを自動検証する」という話で、読んだ瞬間に「すごい」と思いました。目視での確認に頼るのではなく、仕組みとしてデータの質を担保する。それがプロとしてのデータへの向き合い方なのかもしれない、と感じました。
この記事では、Playground Series S6E5(F1ピットストップ予測)のデータパイプラインにPanderaを適用した話を書きます。シリーズ第2回は Pandera です。第1回(uv)はこちら。
対象読者: Pythonでモデルを書いていて、「前処理のバグがなぜかエラーにならない」「データの型や範囲が意図通りか自信がない」という方。
そもそもPanderaとは
Pandera(パンデラ)は、Pandasのデータフレームに対するバリデーションライブラリです。データ加工・前処理の際に「入出力が期待通りのスキーマに沿っているか」を自動チェックし、サイレント障害(エラーなく誤った結果が出続ける状態)を防ぎます。
一言でまとめると、こういうことができます。
import pandera as pa
from pandera.typing import Series
class FeatureSchema(pa.DataFrameModel):
pit_duration: Series[float] = pa.Field(ge=0)
lap_number: Series[int] = pa.Field(ge=1)
compound: Series[str] = pa.Field(isin=["SOFT", "MEDIUM", "HARD"])
このクラスを定義するだけで、「pit_duration は0以上のfloat」「compound は3種類のいずれか」というような仕様がコードに書けます。これが意図通りでない場合、SchemaError が即座に出ます。
Panderaを使う3大メリットはこんな感じです。
- バグの早期発見:型のズレ・外れ値・NULL混入をデータパイプラインの発生源近くで検知できる
- コードが仕様書になる:スキーマを見るだけで入出力の仕様が分かる
- リファクタリングの安全網:特徴量を追加・変更した際、スキーマ違反があれば即エラーになる
インストール
uv add pandera
Panderaが解決する問題
例えば、こんなコードがあります。
# preprocess.py
def preprocess(df: pd.DataFrame) -> pd.DataFrame:
df["pit_duration"] = df["pit_duration"].fillna(0)
df["lap_number"] = df["lap_number"].astype(int)
return df
何が問題なのか、という話なんですが。
fillna(0) で欠損を埋めるのは良いとして、pit_duration に負の値が混じっていても気づきません。astype(int) は、"abc" のような変換不能な文字列や NaN が含まれる場合はエラーを出してくれます。しかし、1.5 のような小数点付きの float を int に変換する場合は、エラーも警告も出ずに小数点以下が切り捨てられてしまいます。
さらに前処理スクリプトが増えてきたとき、train.py、predict.py、evaluate.py それぞれで微妙に違う前処理が走っていて、「推論時だけデータの形が違う」という事態が起きそうです(そもそも管理が大変)。Kaggleでは学習時と推論時でデータを別々に作ることが多いので、この「学習・推論のスキーマの乖離」問題は特にハマりやすいと思います。
つまり、「データが正しい形かどうか」を確認する仕組みがないことが根本の問題です。
実装:スキーマでデータを守る
ディレクトリ構成
(プロジェクトルート)
├── src/f1_pit_stops/
│ └── schema.py ← スキーマ定義をここに集約
├── scripts/
│ └── train.py ← 学習スクリプト
└── ...
スキーマ定義は schema.py として独立したファイルにまとめます。これで複数のスクリプトから使い回せます。
schema.pyの定義 (簡略化)
import pandera.pandas as pa
from pandera.typing import Series
class FeatureSchema(pa.DataFrameModel):
"""7特徴量の共通スキーマ(Train / Inference の基底)"""
Stint: Series[int] = pa.Field(ge=1, le=8)
Year: Series[int] = pa.Field(isin=[2022, 2023, 2024, 2025])
Driver: Series[str]
Race: Series[str]
TyreLife: Series[float] = pa.Field(ge=1.0, le=77.0)
RaceProgress: Series[float] = pa.Field(ge=0.01, le=1.0)
Compound: Series[str] = pa.Field(
isin=["HARD", "MEDIUM", "SOFT", "INTERMEDIATE", "WET"]
)
class Config:
strict = True
coerce = True
class TrainSchema(FeatureSchema):
"""学習用(id + 目的変数 PitNextLap)"""
id: Series[int] = pa.Field(ge=0)
PitNextLap: Series[int] = pa.Field(isin=[0, 1])
class Config(FeatureSchema.Config):
strict = True
coerce = True
class InferenceSchema(FeatureSchema):
"""推論用(7特徴量のみ)"""
class Config(FeatureSchema.Config):
strict = True
coerce = True
FeatureSchema に共通の特徴量を定義して、TrainSchema と InferenceSchema がそれぞれ継承します。学習データにしかない target カラムは TrainSchema だけに定義する、という設計です。
strict=True は定義していないカラムが混入していたときにエラーを出す設定です。「なんか余計なカラムが入ってた」という問題を防げます。
coerce=True は型の自動変換を許可する設定です。"1" という文字列を int として定義していれば自動で変換してくれます。ただし変換できない値があればエラーになります。
スクリプトへの組み込み (該当部分のみ)
このプロジェクトでは、デコレータではなく .validate() を明示的に呼び出す方式 も採用しています。
from f1_pit_stops.schema import TrainSchema, InferenceSchema
FEATURE_COLS = ["Stint", "Year", "Driver", "Race", "TyreLife", "RaceProgress", "Compound"]
TRAIN_COLS = ["id"] + FEATURE_COLS + ["PitNextLap"]
TEST_COLS = ["id"] + FEATURE_COLS
def preprocess_train(raw_path):
df = pd.read_csv(raw_path)[TRAIN_COLS]
df["PitNextLap"] = df["PitNextLap"].astype(int)
# ここに前処理を追記する
TrainSchema.validate(df) # 前処理が終わった後に検証
return df
def preprocess_test(raw_path):
df = pd.read_csv(raw_path)[TEST_COLS]
# ここに前処理を追記する
InferenceSchema.validate(df[FEATURE_COLS]) # id を除いた7特徴量だけ検証
return df
ポイントは2つあります。
① 前処理の「後」に検証する:加工が終わった DataFrame が正しい状態かを確認したいので、validate() を処理の最後に置いています。デコレータ方式だと「入力 or 出力を自動検証」になりますが、「どのタイミングで検証するか」を自分でコントロールしたい場合は .validate() の方が自然です。
② test データは特徴量だけ検証する:preprocess_test が返す DataFrame には id が含まれますが、スキーマ検証は df[FEATURE_COLS](7特徴量のみ)に対して行っています。InferenceSchema は id を持たない設計なので、id 込みの DataFrame 全体に対してバリデーションするとエラーになります。
**別の書き方:@pa.check_types デコレータ(簡単な例) **
入出力のスキーマが固定されている関数なら、デコレータを使う書き方もあります。
import pandera.pandas as pa
from pandera.typing import DataFrame
from f1_pit_stops.schema import FeatureSchema, TrainSchema
@pa.check_types
def preprocess_train(df: DataFrame[TrainSchema]) -> DataFrame[TrainSchema]:
df = df.copy()
df["PitNextLap"] = df["PitNextLap"].astype(int)
return df
デコレータを1行付けるだけで、関数の入力・出力の両方が自動でバリデーションされます。validate() の呼び出しを書き忘れる心配がなく、関数シグネチャを見るだけで「何を受け取り、何を返すか」がスキーマとして明示されます。
ただし、このプロジェクトでは .validate() を選んでいます。preprocess_test が id 付きの DataFrame を返しつつ7特徴量だけを検証する設計になっており、デコレータで返り値全体に InferenceSchema を当てようとするとスキーマが合わないためです。どちらも Pandera として正しい使い方で、制御のしやすさの差です。
| 方式 | 向いている場面 |
|---|---|
.validate() |
パイプラインの任意タイミングで検証、部分検証(カラムを絞って検証)、スクリプト |
@pa.check_types |
入出力が固定の関数・ライブラリAPI |
SchemaErrorが出たら何が分かるか
インデックス42行目の compound カラムに "INTER" という値が入っていたことが分かります。「どこで壊れたか」を特定するのに、print() でデバッグする必要がありません。
これがPanderaを使う前との一番大きな差だと思います。エラーが出た時点で「どのカラムの、何行目の、どんな値が問題か」が一発で分かります。
pa.Fieldのオプション一覧
よく使うオプションをまとめます。
| 引数 | 例 | 説明 |
|---|---|---|
ge |
pa.Field(ge=0) |
指定値以上 |
le |
pa.Field(le=100) |
指定値以下 |
in_range |
pa.Field(in_range={"min_value": 0, "max_value": 6}) |
指定範囲内 |
isin |
pa.Field(isin=["A", "B"]) |
指定リストのいずれか |
nullable |
pa.Field(nullable=True) |
NULLを許可するか |
unique |
pa.Field(unique=True) |
値がユニーク |
in_range は ge + le の組み合わせをまとめて書ける感じです。F1ピットストップ予測では、lap_number の上限(レースの最大周回数)をここで指定しました。
カスタムチェック:カラム間の整合性
組み込みオプションでは表現できない「カラム間の関係」は @pa.dataframe_check で書けます。
class TrainSchema(FeatureSchema):
target: Series[int] = pa.Field(isin=[0, 1])
@pa.dataframe_check
def target_consistent_with_pit_duration(cls, df: pd.DataFrame) -> Series[bool]:
"""pit_durationが異常に長い場合はtarget=1になっているべき"""
long_pit = df["pit_duration"] > 60
return ~long_pit | (df["target"] == 1)
カラム間の整合性チェックがスキーマの中に書けるので、「このチェックどこに書いたっけ」問題がなくなります。
注意点:coerce=Trueの落とし穴
coerce=True は便利ですが、2つの罠があります。
罠1:小数点以下がサイレントに切り捨てられる
class SampleSchema(pa.DataFrameModel):
val: Series[int]
class Config:
coerce = True
# 1.5 → int に coerce すると、エラーなく 1 になる
df = pd.DataFrame({"val": [1.5, 2.5, 3.5]})
SampleSchema.validate(df) # 成功。val は [1, 2, 3] になっている
float を int スキーマに coerce=True で流すと、小数点以下が切り捨てられてサイレントに変換されます。自分はこれで一度ハマりました(pit_duration を int と誤記したまま気づかなかった)。
罠2:NaNがあるとエラーになる
# NaN を含む float を int に coerce するとエラー
df = pd.DataFrame({"val": [1.0, float("nan"), 3.0]})
SampleSchema.validate(df) # SchemaErrors: DATATYPE_COERCION
Python の int 型は NaN を表現できないため、欠損値が混入していると変換に失敗してエラーになります。欠損値を含む可能性があるカラムを int で定義したい場合は、pa.Field(nullable=True) を指定するか、Pandas の Int64(nullable integer型)を使います。
import pandas as pd
from pandera.typing import Series
class TrainSchema(pa.DataFrameModel):
PitNextLap: Series[pd.Int64Dtype()] = pa.Field(isin=[0, 1], nullable=True)
# NaN を許容する int + 値の範囲の指定
coerce=True を使うときは、変換後の値をあわせてチェックする pa.Field のオプションも指定しておくと安心です。
まとめ
- Panderaは
pa.DataFrameModelを継承したスキーマクラスで、データフレームの型・範囲・値を宣言的に定義できます -
.validate()で任意のタイミングに検証を挟め、@pa.check_typesで入出力の自動検証も書けます -
strict=Trueで余計なカラムの混入を防ぎ、coerce=Trueで型の自動変換を許可します - スキーマ継承(
TrainSchema、InferenceSchema)で学習・推論の乖離を防げます - エラーが出たとき「どのカラムの何行目が問題か」が即座に分かるのが最大の価値です
「データが正しい形かどうか確認する仕組みがない」という状態は、静かにスコアを下げ続けます。Panderaを入れてから、「なんかおかしい」の調査時間が明らかに減りました。
実装を通じて気づいたのは、スキーマを定義する段階で「どの特徴量を使うか」「値の範囲はどこまでか」「欠損を許容するか」を明確に決める必要があるということです。目視確認に頼らず仕組みでデータの質を担保することが、データに責任を持つということだと実感しました。
次回(第3回)は Hydra の設定管理を詳しく書く予定です。
参考文献・リンク
- 浅野純季ほか『先輩データサイエンティストからの指南書 ―実務で生き抜くためのエンジニアリングスキル』技術評論社、2025年(ISBN: 978-4-297-15100-3)- https://gihyo.jp/book/2025/978-4-297-15100-3
- Kaggle Playground Series S6E5 – Predicting F1 Pit Stops
- Pandera 公式ドキュメント
- unionai-oss/pandera(GitHub)
- Panderaでデータバリデーションをやってみた(Qiita)
- Panderaを使ったデータバリデーション(Zenn)
- Panderaでデータの品質を守る(note)
