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.3: Quality report and light cleaning

DSC ScaDS.AI, Leipzig University

Stage of the pipeline:

EXPLORE → VALIDATE → REPORT & CLEAN → PACKAGE → DMP & DEPOSIT

Notebook 2 gave us a precise list of what is wrong. This notebook does two things with that:

  1. produces a shareable data quality report we can hand to a colleague or attach to a DMP, and

  2. applies some light cleaning to fix the fixable problems and then re-runs the schema check to confirm the violations disappear.

After this notebook you will be able to: generate and read a profiling report, perform safe normalisation steps with the assistant’s help, and tell the difference between problems you should fix and problems you should only flag.

But first, 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'),
)

A one-line quality report

fg-data-profiling turns a DataFrame into a full interactive HTML report with types, distributions, missing values, duplicates, correlations and automatic data quality alerts...with essentially one line of code.

If fg-data-profiling is not already available in your venv, you can use uv to install (add) it:

#!uv add fg-data-profiling

Import the object ProfileReport:

from data_profiling import ProfileReport
import pandas as pd

# load the RAW data again (so this notebook stands on its own)
raw = pd.read_csv("../data/field_samples_raw.csv")

# one line generates a full data report
report = ProfileReport(raw, title="Field Samples Raw - Data Quality Report")

And save it as a standalone HTML file you can view, distribute, or attach to the documentation:

from pathlib import Path
Path("../outputs").mkdir(exist_ok=True)
report.to_file("../outputs/quality_report_raw.html")
print("Saved report to ../outputs/quality_report_raw.html")
Loading...

100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:00<00:00, 743.11it/s]
Loading...
Loading...
Loading...
Saved report to ../outputs/quality_report_raw.html

Read the alerts

Open the generated file quality_report_raw.html in a new browser tab. Scroll to the Alerts tab of the report. For our raw data it flags, among others:

  • region has a constant value - the same value in every row (often a candidate to drop).

  • island has 23.6% missing values - a column with substantial gaps.

  • missing values also in date_collected, body_mass_g, flipper_length_mm, sex, notes.

The Overview tab also reports duplicate rows. These alerts line up with the schema violations we found in Notebook 2 - two different tools, same underlying problems. The report is the human-readable artifact, the schema is the machine-enforceable one.

Light cleaning - fix the fixable

Now we normalise the problems that have an unambiguous correct answer (formatting, casing, units, inconsistent missing codes, duplicates). We do not invent values for genuinely missing data, and we do not silently overwrite impossible measurements - those get flagged.

Let’s ask the assistant to draft the cleaning, then read it carefully.

%%bob
I have a pandas DataFrame in the variable `raw` of biological field samples. 
Generate cleaning code that:
- reads missing values that are coded as "", "NA", "n/a", "NULL", "?" or "-999" all as NaN
- drops exact duplicate rows
- strips whitespace from sample_id; title-cases species and island; lower-cases sex
- maps category variants to a controlled vocabulary (e.g. "Gentooo"->"Gentoo", "Bisco"->"Biscoe",
  "Chinstrap Penguin"->"Chinstrap"; sex "m"->"male", "f"->"female", "unknown"/"." -> NaN)
- removes the unit " g" from body_mass_g and converts it to a number
- converts comma decimals in flipper_length_mm (e.g. "195,5") and makes it a number
- parses the mixed date formats in date_collected into ISO format YYYY-MM-DD
Store the result in a new DataFrame `clean`. 
Keep it readable with short comments.

The assistant should produce something close to the reference cleaning below. Note that we re-read the file with na_values so all the different “missing” spellings become real NaN from the start:

import numpy as np

# Re-read, declaring every "missing" spelling as NaN, and trimming stray spaces
MISSING = ["", "NA", "n/a", "NULL", "?", "-999"]
clean_reference = pd.read_csv(
    "../data/field_samples_raw.csv",
    na_values=MISSING, 
    keep_default_na=True, 
    skipinitialspace=True,
)

# 1. drop exact duplicate rows
clean_reference = clean_reference.drop_duplicates()

# 2. tidy text: strip whitespace and normalise casing
clean_reference["sample_id"] = clean_reference["sample_id"].str.strip()
clean_reference["species"]   = clean_reference["species"].str.strip().str.title()
clean_reference["island"]    = clean_reference["island"].str.strip().str.title()
clean_reference["sex"]       = clean_reference["sex"].str.strip().str.lower()

# 3. map category variants onto the controlled vocabularies
clean_reference["species"] = clean_reference["species"].map(
    {"Adelie": "Adelie", "Chinstrap": "Chinstrap", "Chinstrap Penguin": "Chinstrap",
     "Gentoo": "Gentoo", "Gentooo": "Gentoo"})
clean_reference["island"] = clean_reference["island"].map(
    {"Biscoe": "Biscoe", "Dream": "Dream", "Torgersen": "Torgersen", "Bisco": "Biscoe"})
clean_reference["sex"] = clean_reference["sex"].map(
    {"m": "male", "male": "male", "f": "female", "female": "female",
     "unknown": np.nan, ".": np.nan})

# 4. numbers: strip the " g" unit, fix comma-decimals, convert to numeric
clean_reference["body_mass_g"] = (clean_reference["body_mass_g"].astype("string")
                        .str.replace(" g", "", regex=False).str.strip())
clean_reference["body_mass_g"] = pd.to_numeric(clean_reference["body_mass_g"], errors="coerce")

clean_reference["flipper_length_mm"] = (clean_reference["flipper_length_mm"].astype("string")
                              .str.replace(",", ".", regex=False).str.strip())
clean_reference["flipper_length_mm"] = pd.to_numeric(clean_reference["flipper_length_mm"], errors="coerce")

# 5. parse the four date formats into one ISO format
clean_reference["date_collected"] = pd.to_datetime(
    clean_reference["date_collected"], errors="coerce", dayfirst=True, format="mixed"
).dt.strftime("%Y-%m-%d")

clean_reference.head(10)
Loading...

Re-validate - did it work?

This is the satisfying part. We load the same schema we saved in Notebook 2 and run it against the cleaned data.

import warnings
import pandera.pandas as pa

# reuse the contract from Notebook 2
schema = pa.DataFrameSchema.from_yaml("../outputs/data_schema.yml")

with warnings.catch_warnings():
    # pandera concatenates failure tables internally and triggers a pandas FutureWarning;
    # it does not affect the result, so we silence just this one message.
    warnings.filterwarnings("ignore", category=FutureWarning,
                            message="The behavior of DataFrame concatenation")
    try:
        schema.validate(clean_reference, lazy=True)
        print("✅ All records valid.")
    except pa.errors.SchemaErrors as exc:
        remaining = exc.failure_cases
        print(f"Remaining failing cases: {len(remaining)} (down from ~600 on the raw data)\n")
        print(remaining.groupby(["column", "check"]).size().to_string())
Remaining failing cases: 110 (down from ~600 on the raw data)

column             check                
body_mass_g        coerce_dtype('int64')    15
                   in_range(2500, 6500)     13
                   not_nullable             15
date_collected     not_nullable              9
flipper_length_mm  in_range(150, 250)        6
                   not_nullable             11
island             not_nullable             41

Fix vs. flag

Look at what remains. The format problems are gone - species, sex,sample_id and the date format now pass. What’s left is genuine data quality issues:

  • Missing required values (e.g. blank island, body_mass_g, date_collected).

  • Impossible / out-of-range measurements (negative masses, a flipper length ~10× too large from a cm/mm mix-up).

A steward fixes formatting; a steward does not invent data.
We cannot know the true island of a sample that arrived blank, so we do not guess. These records are flagged and sent back to the data provider. Let’s list them so we have something concrete to report:

# Records with an out-of-range body mass (kept as-is, flagged for review)
mass_issues = clean_reference[(clean_reference["body_mass_g"] < 2500) | 
    (clean_reference["body_mass_g"] > 6500)]
print("Out-of-range body_mass_g:", len(mass_issues))
display(mass_issues[["sample_id", "species", "body_mass_g", "flipper_length_mm"]].head())

# Records missing a required field
required = ["sample_id", "species", "island", "date_collected",
            "body_mass_g", "flipper_length_mm"]
missing_required = clean_reference[clean_reference[required].isna().any(axis=1)]
print("\nRecords missing at least one required field:", len(missing_required))
Out-of-range body_mass_g: 13
Loading...

Records missing at least one required field: 68

Save the cleaned data

We save the cleaned table. It is not yet a finished package - that comes in Notebook 4 - but it is the validated core that everything else will wrap around.

clean_reference.to_csv("../outputs/field_samples_clean.csv", index=False)
print("Saved cleaned data to ../outputs/field_samples_clean.csv")
Saved cleaned data to ../outputs/field_samples_clean.csv

(Optional) Generate a fresh report on the cleaned data to see the before/after difference in alerts and distributions:

clean_report = ProfileReport(clean_reference, title="Field Samples Cleaned - Data Quality Report")
clean_report.to_file("../outputs/quality_report_clean.html")
print("Saved cleaned-data report to ../outputs/quality_report_clean.html")
Loading...

100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:00<00:00, 898.65it/s]
Loading...
Loading...
Loading...
Saved cleaned-data report to ../outputs/quality_report_clean.html

What’s next

We now have validated, cleaned data plus a quality report and a record of issues to flag. We will continue with 4_metadata-packaging.ipynb to describe the data with metadata and bundle everything into a documented, FAIR data package.