{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 26: Data schema validation with Pandera\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/02-dev-tools/26-pandera-schema-validation.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/02-dev-tools/26-pandera-schema-validation.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "The grade-predictor pipeline finishes training. Accuracy: 87%. You deploy it.\n", "\n", "Three weeks later a data engineer flags something: 23 rows in `university_analytics.csv` have `final_score = 999`. The data entry system used 999 as a sentinel for \"not recorded\". Nobody documented it. The model trained on those 23 rows as if 999 were a valid exam score. There was no error, no warning. The model just learned the wrong thing for 23 students.\n", "\n", "The check you ran at load time called `.dtypes` and printed `.head()`. Neither caught a score of 999. Validation by inspection runs once, in a notebook cell, by whoever is at the keyboard. It is forgotten the next time the pipeline runs.\n", "\n", "What you needed was a **data contract**: a formal specification of what the DataFrame must look like: column types, allowed value ranges, uniqueness constraints, that runs automatically every time data enters the pipeline and fails loudly with a precise error when violated. Pydantic gave you this for individual records at the system boundary. Pandera gives you this for whole DataFrames inside the pipeline.\n", "\n", "**Next:** [Chapter 27: Logging](27-logging.ipynb) adds the audit trail that records which validation errors occurred and when, the permanent record that Pandera's exceptions do not keep by themselves.\n", "\n", "[](https://github.com/sambaiga/ds-mlops-path/blob/main/tutorials/02-dev-tools/26-pandera-schema-validation.ipynb)" ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning objectives\n", "\n", "By the end of this chapter you will be able to:\n", "\n", "| # | Skill | Covered in |\n", "| --- | --- | --- |\n", "| 1 | Explain the difference between row-level (Pydantic) and schema-level (Pandera) validation | Sec. 1 |\n", "| 2 | Define a Pandera `DataFrameSchema` with column types and constraints | Sec. 2 |\n", "| 3 | Use the class-based API with `pa.DataFrameModel` for typed schemas | Sec. 3 |\n", "| 4 | Write custom element-wise and series-level checks | Sec. 4 |\n", "| 5 | Validate DataFrames in a pipeline and collect errors without stopping | Sec. 5 |\n", "| 6 | Use Pandera schemas as pytest fixtures to document data contracts | Sec. 6 |\n", ":::" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "Pydantic validated the shape of a single record as it entered the system: one row, one pass or fail. A DataFrame is not a record. It is a table. The properties that matter at the table level are different: is `student_id` unique across all 2,400 rows? Is the distribution of `program` values consistent with what the source system should produce? Does `final_score` stay between 0 and 100 for every row, not just the one you checked manually?\n", "\n", "These are questions Pydantic cannot answer, because Pydantic operates on one object at a time.\n", "\n", "| | Pydantic `BaseModel` | Pandera `DataFrameSchema` |\n", "| --- | --- | --- |\n", "| Unit | One record (row) | Whole DataFrame |\n", "| Checks | Type coercion, field constraints, cross-field | Column dtype, nullability, uniqueness, value ranges, statistical bounds |\n", "| Returns | Validated model instance | Validated DataFrame |\n", "| Error info | Field path + message | Row index + column + failed check |\n", "| Best for | API inputs, config objects | CSVs, pipeline data, feature tables |\n", "\n", "
student_id. Run schema.validate(df) and confirm it raises a SchemaError with a message about uniqueness.\n",
"dup_df = sample_df.copy()\n",
"dup_df.loc[1, \"student_id\"] = \"S0001\" # duplicate\n",
"schema.validate(dup_df) # should raise\n",
"DataFrameModel for reuse, DataFrameSchema for quick scriptsDataFrameModel is easier to subclass, document, and test: it reads like a dataclass and fits naturally alongside Pydantic models. DataFrameSchema is useful when you want to build a schema programmatically at runtime, e.g., from a config file or database metadata.\n",
"StudentDataSchema and add a semester column constrained to [\"Fall\", \"Spring\", \"Summer\"]. Validate a DataFrame that includes the column with valid values, then one that has an invalid value. Confirm only the second raises a SchemaError.\n",
"class ExtendedSchema(StudentDataSchema):\n",
" semester: Series[str] = pa.Field(isin=[\"Fall\", \"Spring\", \"Summer\"])\n",
"@pa.dataframe_check to GradeSchema that verifies midterm_score and final_score are not both 0 for the same student (a student with both scores at 0 is almost certainly an error, not a result). Confirm it passes on valid data and fails when you introduce a row with both at 0.\n",
"@pa.dataframe_check\n",
"def not_both_zero(cls, df: pd.DataFrame) -> pd.Series:\n",
" return ~((df[\"midterm_score\"] == 0) & (df[\"final_score\"] == 0))\n",
"schema.validate(df, lazy=False) (the default) raises a SchemaError on the first failure: fast and clear for development. schema.validate(df, lazy=True) collects every failure and raises a SchemaErrors (note the plural) at the end, better for production, where you want a full error report rather than a partial run.\n",
"sample_df: one with midterm_score=150.0, one with an invalid program, and one with a duplicate student_id. Call StudentDataSchema.validate(bad_df, lazy=True). Catch the SchemaErrors exception and print the failure_cases DataFrame showing all three failures at once.\n",
"pa.DataFrameModel.example() to generate test data automaticallyStudentDataSchema.example(size=50) generates 50 valid rows matching all constraints. This removes the need to hand-craft test fixtures for every new schema.\n",
"StudentDataSchema.example(size=20) to generate 20 synthetic rows. Confirm that the generated DataFrame passes StudentDataSchema.validate() without errors. Then confirm that if you corrupt one cell (set a score to 200), validation fails.\n",
"synthetic = StudentDataSchema.example(size=20)\n",
"StudentDataSchema.validate(synthetic) # should pass\n",
"StudentDataSchema in grade_predictor/schemas.py covering all columns of university_analytics.csvload_and_validate (from Chapter 25) to run Pandera schema validation after row-level Pydantic validation@pa.dataframe_check that verifies the computed weighted average (using the weights from PipelineConfig) falls in [0, 100] for every rowSchemaError, and one that uses example() to generate synthetic data and confirms it passesuv run pytest -v and confirm all three pass