Stage of the pipeline:
EXPLORE → VALIDATE → REPORT & CLEAN → PACKAGE → DMP & DEPOSIT
In Notebook 1 we eyeballed the raw data and the assistant listed some problems. Eyeballing does not scale and is easy to get wrong. In this notebook we make the rules explicit and machine-checkable by writing a data schema - a data contract that says exactly what valid data must look like - and let the computer find every record that breaks it.
We use pandera, a popular Python package for validating pandas DataFrames.
After this notebook you will be able to: read a column’s data type, express the rules from a data dictionary as a pandera schema, run it to get a complete list of violations, and save the schema as a reusable artifact.
But first, let’s initialize our AI assistant:
# Import os to read env vars
import os
# Import bob and helper functions
from bia_bob import bob, available_models, fix, doc
# Initialize assistant, reads API key from env var automatically
bob.initialize(
# read endpoint URL from env var
endpoint=os.getenv('ENDPOINT_URL'),
# select an available model for your purpose
model="coder",
# read the Data Steward system prompt from env var
system_prompt=os.getenv('SYSTEM_PROMPT_DATA_STEWARD'),
)The silent problem¶
Let’s load the raw data and look at the data types pandas inferred.
import pandas as pd
df = pd.read_csv("../data/field_samples_raw.csv")
df.dtypessample_id object
species object
island object
date_collected object
body_mass_g object
flipper_length_mm object
sex object
region object
notes object
dtype: objectNotice that columns which should be numbers - body_mass_g, flipper_length_mm -
were read as text (object / string / str). And date_collected is text too.
This is the danger: pandas did not complain. It silently accepted "4168 g",
"195,5", and "-999" as ordinary text. Nothing told us the data was wrong. A schema is how we make the computer complain on purpose.
The data contract¶
Open the file ../data/data_dictionary.md side by side. It already states, in plain language, what each column should contain - types, allowed categories, ranges, and which fields are required or unique. A pandera schema is simply that same dictionary written as code the computer can enforce.
If pandera is not already available in your venv, you can use uv to install (add) it:
#!uv add panderaImport pandera:
import pandera.pandas as pa
from pandera.pandas import Column, DataFrameSchema, CheckLet the assistant draft the schema¶
Writing schemas by hand is fiddly, so we ask our assistant. We give it the rules from the data dictionary and ask for a pandera schema.
AI-first, but we will read every line before we trust it.
%%bob
Write a pandera DataFrameSchema (using `import pandera.pandas as pa`) for a pandas DataFrame
with these columns and rules:
- sample_id: string, unique, not null, must match the pattern S followed by 4 digits
- species: string, must be one of: Adelie, Chinstrap, Gentoo
- island: string, must be one of: Biscoe, Dream, Torgersen
- date_collected: string, not null, must match an ISO date pattern YYYY-MM-DD
- body_mass_g: integer (coerce), not null, between 2500 and 6500
- flipper_length_mm: float (coerce), not null, between 150 and 250
- sex: string, nullable, must be one of: male, female
- region: string, must equal "Antarctica"
- notes: string, nullable, not required
Store it in a variable called `my_pandera_schema`. Just the code.Read the pandera building blocks (this is the whole vocabulary you need):
| Piece | Meaning |
|---|---|
Column(int, ...) | this column should hold integers |
coerce=True | first try to convert the text into that type; report what cannot be converted |
nullable=False | empty / missing values are not allowed |
unique=True | every value must be different |
Check.isin([...]) | value must be one of this controlled vocabulary |
Check.in_range(lo, hi) | numeric value must fall in this range |
Check.str_matches(r"...") | text must match this pattern (a regular expression) |
strict=False | tolerate extra columns not listed in the schema |
The assistant should produce something close to the reference schema below. Compare the two, then run this reference version so everyone is working with the same contract:
schema_reference = DataFrameSchema(
columns={
# unique ID, no whitespace, exact pattern "S" + 4 digits
"sample_id": Column(
str, unique=True, nullable=False,
checks=Check.str_matches(r"^S\d{4}$"),
description="Unique sample identifier, pattern S + 4 digits (e.g. S0001).",
),
# controlled vocabulary -> only these three values are valid
"species": Column(
str, checks=Check.isin(["Adelie", "Chinstrap", "Gentoo"]),
description="Penguin species (controlled vocabulary).",
),
"island": Column(
str, checks=Check.isin(["Biscoe", "Dream", "Torgersen"]),
description="Island in the Palmer Archipelago (controlled vocabulary).",
),
# date must be written as ISO 8601 (YYYY-MM-DD)
"date_collected": Column(
str, nullable=False,
checks=Check.str_matches(r"^\d{4}-\d{2}-\d{2}$"),
description="Date the sample was collected, ISO 8601 (YYYY-MM-DD).",
),
# numbers: coerce text -> number, then check a plausible range
"body_mass_g": Column(
int, coerce=True, nullable=False,
checks=Check.in_range(2500, 6500),
description="Body mass in grams.",
),
"flipper_length_mm": Column(
float, coerce=True, nullable=False,
checks=Check.in_range(150, 250),
description="Flipper length in millimetres.",
),
# sex may be empty (unknown), but if present must be male/female
"sex": Column(
str, nullable=True, checks=Check.isin(["male", "female"]),
description="Recorded sex; empty means unknown/not recorded.",
),
# constant column
"region": Column(
str, checks=Check.eq("Antarctica"),
description="Broad geographic region (constant across the dataset).",
),
# free-text, optional column
"notes": Column(
str, nullable=True, required=False,
description="Optional free-text field-collection remarks.",
),
},
strict=False, # allow extra columns we did not list
)
schema_reference<Schema DataFrameSchema(columns={'sample_id': <Schema Column(name=sample_id, type=DataType(str))>, 'species': <Schema Column(name=species, type=DataType(str))>, 'island': <Schema Column(name=island, type=DataType(str))>, 'date_collected': <Schema Column(name=date_collected, type=DataType(str))>, 'body_mass_g': <Schema Column(name=body_mass_g, type=DataType(int64))>, 'flipper_length_mm': <Schema Column(name=flipper_length_mm, type=DataType(float64))>, 'sex': <Schema Column(name=sex, type=DataType(str))>, 'region': <Schema Column(name=region, type=DataType(str))>, 'notes': <Schema Column(name=notes, type=DataType(str))>}, checks=[], parsers=[], index=None, dtype=None, coerce=False, strict=False, name=None, ordered=False, unique=None, report_duplicates=all, unique_column_names=False, add_missing_columns=False, title=None, description=None, metadata=None, drop_invalid_rows=False)>Comparing the generated schema and the reference schema can be hard just by looking at it. So let’s ask our assistant again, whether there’s a way to compare the content of the 2 variables:
%%bob
Tell me wether the content of the two variables {my_pandera_schema} and {schema_reference}
is equal. No code, just a short answer. Point out any differencesRun the validation¶
We validate with the parameter lazy=True so pandera collects all violations in one pass instead of stopping at the first one. When the data fails, it raises a SchemaErrors exception that carries a table of every failing case.
try:
schema_reference.validate(df, lazy=True)
print("✅ All records are valid.")
except pa.errors.SchemaErrors as exc:
failures = exc.failure_cases
print(f"❌ Found {len(failures)} failing cases.\n")
# The most useful steward's view: how many failures per column and per rule
summary = failures.groupby(["column", "check"]).size()
print(summary.to_string())❌ Found 600 failing cases.
column check
body_mass_g coerce_dtype('int64') 23
dtype('int64') 23
in_range(2500, 6500) 1
not_nullable 8
date_collected not_nullable 9
str_matches('^\d{4}-\d{2}-\d{2}$') 132
flipper_length_mm coerce_dtype('float64') 12
dtype('float64') 12
in_range(150, 250) 1
not_nullable 8
island isin(['Biscoe', 'Dream', 'Torgersen']) 41
not_nullable 43
sample_id field_uniqueness 4
str_matches('^S\d{4}$') 13
sex isin(['male', 'female']) 136
species isin(['Adelie', 'Chinstrap', 'Gentoo']) 134
This grouped table is your data quality finding: for each column, which rule was broken and how often.
Read it against the data dictionary - every line here is a real defect we described earlier (mixed casing in species, the Bisco typo and blanks in island, non-ISO dates, text-with-units in body_mass_g, comma-decimals in flipper_length_mm, inconsistent sex codes, duplicate / whitespace sample_id).
Look at concrete failing values¶
The summary tells us which rules, sometimes we want to see the actual bad values so we know how to fix them. Let’s inspect two columns:
try:
schema_reference.validate(df, lazy=True)
except pa.errors.SchemaErrors as exc:
failures = exc.failure_cases
for col in ["species", "body_mass_g"]:
bad_values = failures.loc[failures["column"] == col, "failure_case"].dropna().unique()
print(f"{col} - distinct invalid values:")
print(" ", list(bad_values)[:12])
print()species - distinct invalid values:
['ADELIE', 'chinstrap', 'Adelie ', 'Chinstrap Penguin', ' Adelie', 'gentoo', 'Gentooo', 'adelie']
body_mass_g - distinct invalid values:
['4168 g', '4847 g', '3764 g', '3975 g', '5882 g', '2425 g', '4783 g', '3797 g', '2750 g', '4720 g', '3645 g', '3854 g']
Two ideas worth remembering¶
Validation detects; it does not fix.
This notebook only finds problems. Cleaning them is the job of the next notebook. Keeping detection and cleaning separate makes your work transparent and reproducible.
A schema is documentation.
The schema is a precise, shareable description of what your clean data should look like. It is useful to a future colleague - and we will reuse it when we package the data later.
Let’s save it as a portable file:
from pathlib import Path
# Make sure an outputs folder exists, then save the schema as a shareable YAML file
out_dir = Path("../outputs")
out_dir.mkdir(exist_ok=True)
schema_reference.to_yaml(out_dir / "data_schema.yml")
print(f"Saved schema to {out_dir / 'data_schema.yml'}")Saved schema to ../outputs/data_schema.yml
Your turn¶
Use the assistant to extend the contract, then re-run the validation cell above to see the effect. For example, ask it to:
add a check that
body_mass_gis greater than 0 (so impossible negatives are reported separately from the range check), ortighten the
flipper_length_mmrange to 170–230.
Always read the generated line before adding it to the schema.
%%bob
Show me one extra pandera Check I can add to the body_mass_g column
in {schema_reference} to reject values that are zero or negative.
Explain in one sentence what it does.What’s next¶
We now have a precise list of what is wrong with the data. We continue with 3_quality-report.ipynb to generate a shareable quality report and apply some light cleaning. We will then re-run this schema to watch whether the violations disappear.