AI School Pakistan
← Applied AI Builder0 of 18 complete
LessonCourse overview

Module 3 · Data and Multimodal Work · 40 min

Clean Spreadsheet and Tabular Data Before AI Analysis

Lesson 7 of 18

Is lesson ke baad tum

Make the table defensible before making it intelligent: preserve raw data, define row grain and types, quarantine uncertainty, reconcile every transformation, minimize personal fields, and give AI only the analysis-ready evidence.

In this lesson

Clean the six-row sales fixture below and explain where each row went. You will preserve leading-zero IDs, remove one exact duplicate, and keep unresolved values out of analysis. Then one changed source value will test whether your totals still reconcile.

Use Python 3 and pandas (python -m pip install pandas). Package installation may take longer than the core exercise. The script works locally after installation; no AI account or real customer data is needed.

Define One Row Before Cleaning Columns

In this fixture, one row means one order, and order_id is the key. A repeated ID with different source values is a conflict; an exact repeated row is an accidental export duplicate for this lab. In a real order-line table, repeated order IDs may be legitimate, so document the grain before removing anything.

Read identifiers as strings so 0001 remains 0001. The source uses ISO dates (YYYY-MM-DD) and PKR amounts with optional correctly grouped commas and up to two decimals. The script stores amounts in integer paisa: PKR 2,500 becomes 250000. Blank, invalid, and negative values remain review issues rather than guessed sales.

Worked Example: Audit and Clean a Synthetic Sales File

Create sales_raw.csv with synthetic data:

csv7 lines
order_id,order_date,city,revenue_pkr,status,customer_phone
0001,2026-07-01,Lahore,"2,500",delivered,0300-0000001
0002,2026-07-02,lahore,1800,Delivered,0300-0000002
0002,2026-07-02,lahore,1800,Delivered,0300-0000002
0003,2026-07-03,ISB,,pending,0300-0000003
0004,not-a-date,Karachi,3200,returned,0300-0000004
0005,2026-07-05,KHI,-500,delivered,0300-0000005

The phone values are fictional and unnecessary for sales analysis. The cleaned analytic table should remove them, not merely hide the column on screen.

Install pandas and create clean_sales.py:

python102 lines
from pathlib import Path
import json
import re
from decimal import Decimal
import pandas as pd

SOURCE = Path("sales_raw.csv")
OUTPUT = Path("sales_clean.csv")
REPORT = Path("quality_report.json")

# Read as text first so identifiers and original spellings survive inspection.
raw = pd.read_csv(SOURCE, dtype=str, keep_default_na=False)
df = raw.copy()
df.columns = [c.strip().lower() for c in df.columns]
if df.columns.duplicated().any():
    raise ValueError("Column names collide after normalization")

required = {"order_id", "order_date", "city", "revenue_pkr", "status"}
missing_columns = sorted(required - set(df.columns))
if missing_columns:
    raise ValueError(f"Missing required columns: {missing_columns}")

df["order_id"] = df["order_id"].str.strip()
df["city_original"] = df["city"]

city_map = {
    "lahore": "Lahore",
    "isb": "Islamabad",
    "islamabad": "Islamabad",
    "khi": "Karachi",
    "karachi": "Karachi",
}
df["city"] = df["city"].str.strip().str.casefold().map(city_map)
df["status"] = df["status"].str.strip().str.casefold()
allowed_statuses = {"delivered", "pending", "returned"}

df["order_date_parsed"] = pd.to_datetime(
    df["order_date"], format="%Y-%m-%d", errors="coerce"
)
def parse_paisa(value):
    value = value.strip()
    if not re.fullmatch(r"-?(?:[0-9]{1,9}|[1-9][0-9]{0,2}(?:,[0-9]{3}){1,2})(?:\.[0-9]{1,2})?", value):
        return pd.NA
    return int(Decimal(value.replace(",", "")) * 100)

df["revenue_paisa"] = pd.array(df["revenue_pkr"].map(parse_paisa), dtype="Int64")

# Remove only later copies of fully identical rows. If the same order ID has
# conflicting content, keep every version and quarantine the whole ID group.
distinct_rows = df.loc[~raw.duplicated(keep="first")]
conflicting_ids = distinct_rows.loc[
    distinct_rows.duplicated(subset=["order_id"], keep=False), "order_id"
].unique()
conflicting_id_mask = df["order_id"].isin(conflicting_ids)
exact_duplicate_drop_mask = raw.duplicated(keep="first") & ~conflicting_id_mask
missing_order_id_mask = df["order_id"].eq("")
invalid_date_mask = df["order_date_parsed"].isna()
invalid_city_mask = df["city"].isna()
invalid_status_mask = ~df["status"].isin(allowed_statuses)
missing_revenue_mask = df["revenue_paisa"].isna()
negative_revenue_mask = df["revenue_paisa"].lt(0).fillna(False)

report = {
    "source_file": SOURCE.name,
    "rows_in": int(len(raw)),
    "exact_duplicate_rows_removed": int(exact_duplicate_drop_mask.sum()),
    "conflicting_duplicate_id_rows": int(conflicting_id_mask.sum()),
    "missing_order_ids": int(missing_order_id_mask.sum()),
    "invalid_dates": int(invalid_date_mask.sum()),
    "unmapped_cities": int(invalid_city_mask.sum()),
    "unmapped_statuses": int(invalid_status_mask.sum()),
    "missing_or_invalid_revenue": int(missing_revenue_mask.sum()),
    "negative_revenue_needing_review": int(negative_revenue_mask.sum()),
    "raw_parseable_revenue_total_paisa": int(df["revenue_paisa"].sum()),
}

# Deduplication is justified here only for fully identical copies. Conflicting
# versions remain visible and are marked for source-owner review.
clean = df.loc[~exact_duplicate_drop_mask].copy()

# Do not impute questionable business facts. Exclude unresolved rows from analysis.
review_mask = (
    clean["order_id"].eq("")
    | clean["order_date_parsed"].isna()
    | clean["city"].isna()
    | ~clean["status"].isin(allowed_statuses)
    | clean["revenue_paisa"].isna()
    | clean["revenue_paisa"].lt(0)
    | clean["order_id"].isin(conflicting_ids)
)
clean["review_status"] = review_mask.map({True: "needs_review", False: "analysis_ready"})

# Remove the unnecessary phone field from the analytic export.
clean = clean.drop(columns=["customer_phone"], errors="ignore")
clean.to_csv(OUTPUT, index=False)

report["rows_out"] = int(len(clean))
report["analysis_ready_rows"] = int((clean["review_status"] == "analysis_ready").sum())
report["analysis_ready_total_paisa"] = int(clean.loc[clean["review_status"] == "analysis_ready", "revenue_paisa"].sum())
report["output_parseable_total_paisa"] = int(clean["revenue_paisa"].sum())
REPORT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))

Run python clean_sales.py. Inspect both outputs. The script preserves original city text, adds parsed columns, checks the lab's explicit status set, marks blank keys and unresolved records, removes an unnecessary personal field, and removes only the later copy of a fully identical row. If one order ID appears with conflicting content, every version stays visible and is quarantined for source-owner review.

It does not turn the blank revenue into zero, convert a negative value into a positive sale, guess the bad date, or assume an unknown city or status without an explicit mapping.

The negative amount might be an error, refund, adjustment, or legitimate convention. Only the source owner can resolve it. Marking it for review is better than a confident “cleaning” rule that changes the business meaning.

Reconcile Before and After

For the original fixture, expect six input rows, one exact duplicate removed, five output rows, and two analysis-ready rows. Orders 0003, 0004, and 0005 need review for a missing amount, invalid date, and negative amount respectively.

Parseable source amounts total 880000 paisa, including the duplicate. Removing its 180000 paisa leaves 700000. The two analysis-ready rows total 430000 paisa; the unresolved parseable rows contribute 270000, and the blank contributes no known amount. These are data-quality reconciliation totals, not a claim about recognized revenue. Filter status and apply the business's accounting definition before reporting sales.

The cleaned file retains review rows with labels. Filter review_status == "analysis_ready" before analysis. No phone column should remain. A join to a many-row interaction table can still multiply totals; verify grain and counts again after every join.

Failure Cases to Catch

  • Merged headers or totals inside data

    export a rectangular table with one header row and keep summary formulas separate.

  • Leading zeros lost

    ingest identifiers as strings and compare source values.

  • Dates parsed by locale guess

    specify the format and quarantine failures.

  • Blank converted to zero

    missing, zero, not applicable, and not yet reported have different meanings.

  • Silent deduplication

    define the key and preserve a duplicate report; identical rows may still represent valid repeated events.

  • Category over-normalization

    “Islamabad” and “Rawalpindi” are not interchangeable because a model says both are nearby.

  • Outlier deletion

    investigate and document; an extreme value may be the most important transaction or a unit error.

  • PII in analytic extracts

    minimize at ingestion. Hashing a phone number may still permit linking and may not be needed at all.

  • Spreadsheet formula injection

    cells beginning with formula characters can become active when opened in spreadsheet software. Treat external CSV cells as untrusted and follow the target tool's safe-import guidance.

  • AI-written cleaning code not tested

    run on fixtures with known expected failures and review diffs before touching real data.

Hands-On Exercise: Produce a Reproducible Data Quality Pack

Run the supplied script and compare its report with the expected counts and totals above. Preserve the six-row fixture as your original; use a copy for the change.

In the copy, set only order 0003 revenue to 1000, representing a corrected value supplied by the fictional source owner. Run the cleaner on that copy by changing SOURCE. Predict the new report first: still six input/five output rows, but three analysis-ready rows totaling 530000 paisa. The invalid date and negative amount must remain unresolved.

Add assertions for the changed fixture's output count, analysis-ready count, and total. Fail the command if any differs. Confirm the original file and leading-zero IDs remain intact and the export has no customer_phone column. Restore the original source path when finished.

Optional extra diagnosis: add another 0002 row with revenue 1900. The entire conflicting ID group must be held for review; choosing the last row silently would invent which version is authoritative.

Completion Rubric

  • Original fixture gives 6 → 5 rows, 2 analysis-ready rows, and 430000 paisa in the ready subset.
  • The one-value correction gives 3 ready rows and 530000 paisa while unrelated issues remain unresolved.
  • Source files and string IDs survive unchanged; output excludes the unnecessary phone field.
  • Your assertions fail if a count or total differs, and your explanation separates reconciliation from recognized revenue.

Sources

Practice quiz

Check yourself

4 questions · practise what you just learned

0/4
  1. 1 / 4 · diagnose

    A CSV mixes a title row, merged headings, and a totals row with order records. What should be corrected before running the cleaner?

Apna kaam check karo.

Practice complete ki? Result ko example aur lesson ke checks se compare karo.

This is your own completion record, not a graded assessment.

Kuch samajh nahi aaya? Ask a question →