Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Session 2.2: Validating Data

DSC ScaDS.AI, Leipzig University

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.dtypes
sample_id object species object island object date_collected object body_mass_g object flipper_length_mm object sex object region object notes object dtype: object

Notice 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 pandera

Import pandera:

import pandera.pandas as pa
from pandera.pandas import Column, DataFrameSchema, Check

Let 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):

PieceMeaning
Column(int, ...)this column should hold integers
coerce=Truefirst try to convert the text into that type; report what cannot be converted
nullable=Falseempty / missing values are not allowed
unique=Trueevery 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=Falsetolerate 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 differences

Run 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_g is greater than 0 (so impossible negatives are reported separately from the range check), or

  • tighten the flipper_length_mm range 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.