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:
produces a shareable data quality report we can hand to a colleague or attach to a DMP, and
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-profilingImport the object ProfileReport:
from data_profiling import ProfileReportimport 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")
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:00<00:00, 743.11it/s]
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:
regionhas a constant value - the same value in every row (often a candidate to drop).islandhas 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)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
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")
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:00<00:00, 898.65it/s]
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.