Notebook 4 — Data Cleaning for CA Professionals
Goal: Take messy real-world financial data and make it analysis-ready.
You will learn:
- Why "real" data is always dirty
- Spotting missing values
- Filling or dropping missing values
- Removing duplicate rows
- Cleaning text columns (extra spaces, wrong case)
- Fixing data types (numbers stored as text, dates as strings)
- Detecting outliers
- Renaming columns
- A short mini-project on a dirty invoice file
We will mostly use
data/invoices_dirty.csv— a deliberately messy version of the invoice file.
1. Why is real data dirty?
Open any client's accounting export and you'll typically find:
| Problem | Example |
|---|---|
| Missing values | blank Amount cells |
| Duplicate rows | the same invoice entered twice |
| Inconsistent text | "himalayan traders " vs "EVEREST SUPPLIERS" |
| Wrong data types | amount stored as text, date as string |
| Outliers / typos | a NPR 90 lakh invoice that should be 90,000 |
If we model or chart this data without cleaning, the results will be wrong — a serious risk in audit & assurance work.
import pandas as pd
import numpy as np
inv = pd.read_csv("data/invoices_dirty.csv")
print("Shape:", inv.shape)
inv.head()
Shape: (206, 11)
| Invoice_No | Date | Customer | City | Product | Quantity | Unit_Price | Amount_NPR | VAT_NPR | Total_NPR | Payment_Status | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | INV-20810001 | 2024-07-16 | Janakpur Wholesale | Birgunj | Food | 39 | 1219.0 | NaN | 6180.33 | 53721.33 | Paid |
| 1 | INV-20810002 | 2024-07-18 | Kathmandu Distributors | Kathmandu | Food | 27 | 1418.0 | 38286.0 | 4977.18 | 43263.18 | Overdue |
| 2 | INV-20810003 | 2024-07-20 | Biratnagar Enterprise | Butwal | Stationery | 10 | 1309.0 | 13090.0 | 1701.70 | 14791.70 | Paid |
| 3 | INV-20810004 | 2024-07-22 | Pokhara Mart | Birgunj | Food | 26 | 5501.0 | 143026.0 | 18593.38 | 161619.38 | Paid |
| 4 | INV-20810005 | 2024-07-24 | Janakpur Wholesale | NaN | Food | 19 | 1669.0 | 31711.0 | 4122.43 | 35833.43 | Paid |
2. Spotting missing values
# True wherever a cell is missing
inv.isna().head()
| Invoice_No | Date | Customer | City | Product | Quantity | Unit_Price | Amount_NPR | VAT_NPR | Total_NPR | Payment_Status | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | False | False | False | False | False | False | False | True | False | False | False |
| 1 | False | False | False | False | False | False | False | False | False | False | False |
| 2 | False | False | False | False | False | False | False | False | False | False | False |
| 3 | False | False | False | False | False | False | False | False | False | False | False |
| 4 | False | False | False | True | False | False | False | False | False | False | False |
# How many missing per column?
inv.isna().sum()
Invoice_No 0
Date 0
Customer 0
City 8
Product 0
Quantity 0
Unit_Price 0
Amount_NPR 13
VAT_NPR 0
Total_NPR 0
Payment_Status 0
dtype: int64
Practice 1
Find how many missing values each column of inv has, and what % of rows are missing in Amount_NPR.
# Your turn:
Show solution
print(inv.isna().sum())
pct = inv["Amount_NPR"].isna().mean() * 100
print(f"\nMissing in Amount_NPR: {pct:.2f}%")
3. Handling missing values
You have three common choices:
| Approach | When to use |
|---|---|
| Drop rows | A handful of rows missing in a critical column |
| Fill with 0 | Missing means "no value" (e.g. no discount applied) |
| Fill with mean / median | Missing because of bad entry, but the row is otherwise valid |
# Method 1 — drop any row that has a missing value
clean1 = inv.dropna()
print("Rows before:", len(inv), "→ after drop:", len(clean1))
Rows before: 206 → after drop: 185
# Method 2 — fill missing Amount_NPR with the median (more robust than mean)
clean2 = inv.copy()
median_amt = clean2["Amount_NPR"].median()
clean2["Amount_NPR"] = clean2["Amount_NPR"].fillna(median_amt)
# Fill missing City with the string "Unknown"
clean2["City"] = clean2["City"].fillna("Unknown")
print("Missing after fill:")
print(clean2.isna().sum())
Missing after fill:
Invoice_No 0
Date 0
Customer 0
City 0
Product 0
Quantity 0
Unit_Price 0
Amount_NPR 0
VAT_NPR 0
Total_NPR 0
Payment_Status 0
dtype: int64
Practice 2
Make a copy of inv called inv2. In inv2:
- Fill missing
Amount_NPRwith the column's mean. - Fill missing
Citywith the value"Not Provided". - Confirm there are no missing values left.
# Your turn:
Show solution
inv2 = inv.copy()
inv2["Amount_NPR"] = inv2["Amount_NPR"].fillna(inv2["Amount_NPR"].mean())
inv2["City"] = inv2["City"].fillna("Not Provided")
print(inv2.isna().sum())
4. Removing duplicate rows
Duplicates can sneak in when invoices are re-uploaded or merged from two files.
# Total duplicate rows
print("Duplicate rows:", inv.duplicated().sum())
# Drop them, keep the first occurrence
inv_dedup = inv.drop_duplicates()
print("After dedup :", len(inv_dedup))
Duplicate rows: 6
After dedup : 200
# Often we only care about duplicates in a key column like Invoice_No
print("Duplicate Invoice_No:", inv.duplicated(subset=["Invoice_No"]).sum())
inv_unique_inv = inv.drop_duplicates(subset=["Invoice_No"])
print("After dedup by Invoice_No:", len(inv_unique_inv))
Duplicate Invoice_No: 6
After dedup by Invoice_No: 200
Practice 3
- How many rows in
invhave a duplicateInvoice_No? - Keep only the first occurrence and store the result in
inv_clean.
# Your turn:
Show solution
print("Duplicate Invoice_No:", inv.duplicated(subset=["Invoice_No"]).sum())
inv_clean = inv.drop_duplicates(subset=["Invoice_No"], keep="first")
print("Final rows:", len(inv_clean))
5. Cleaning text columns
Text is the most common source of subtle bugs — " Himalayan Traders " and "HIMALAYAN TRADERS" look the same to us, but Pandas treats them as different customers.
# Look — same customer, three different versions
print(inv["Customer"].value_counts().head(8))
Customer
Janakpur Wholesale 27
Biratnagar Enterprise 22
Himalayan Traders 22
Pokhara Mart 20
Annapurna Stores 20
Nepalgunj Goods 20
Birgunj Cargo 18
Everest Suppliers 18
Name: count, dtype: int64
# Standard recipe: strip spaces + put in title-case
inv["Customer"] = inv["Customer"].str.strip().str.title()
print(inv["Customer"].value_counts().head(8))
Customer
Janakpur Wholesale 27
Himalayan Traders 27
Biratnagar Enterprise 22
Everest Suppliers 21
Pokhara Mart 20
Annapurna Stores 20
Nepalgunj Goods 20
Birgunj Cargo 18
Name: count, dtype: int64
Practice 4
Notice that some Customer values are still capitalised differently (e.g. "Everest Suppliers" vs "EVEREST SUPPLIERS"). Confirm they are all consistent now using .unique().
# Your turn:
Show solution
print(inv["Customer"].unique())
print("Distinct customers:", inv["Customer"].nunique())
6. Fixing data types
A common headache: numbers that came in as text, or dates that are just strings.
print(inv.dtypes)
Invoice_No str
Date str
Customer str
City str
Product str
Quantity int64
Unit_Price float64
Amount_NPR float64
VAT_NPR float64
Total_NPR float64
Payment_Status str
dtype: object
# Convert Date column from string → real datetime
inv["Date"] = pd.to_datetime(inv["Date"])
# Now we can extract year, month, weekday, etc.
inv["Year"] = inv["Date"].dt.year
inv["Month"] = inv["Date"].dt.month
inv["Weekday"] = inv["Date"].dt.day_name()
inv[["Date", "Year", "Month", "Weekday"]].head()
| Date | Year | Month | Weekday | |
|---|---|---|---|---|
| 0 | 2024-07-16 | 2024 | 7 | Tuesday |
| 1 | 2024-07-18 | 2024 | 7 | Thursday |
| 2 | 2024-07-20 | 2024 | 7 | Saturday |
| 3 | 2024-07-22 | 2024 | 7 | Monday |
| 4 | 2024-07-24 | 2024 | 7 | Wednesday |
Practice 5
Suppose someone gives you this Series of amounts where the values are stored as strings with commas:
["1,250", "3,400", "12,800", "5,000"]
Convert it into a real numeric Series and find the total.
# Your turn:
Show solution
s = pd.Series(["1,250", "3,400", "12,800", "5,000"])
nums = s.str.replace(",", "").astype(int)
print(nums)
print("Total:", nums.sum())
7. Detecting outliers — the IQR method
The IQR (Inter-Quartile Range) method is a standard, safe way to flag suspicious values:
Q1= 25th percentile.Q3= 75th percentile.IQR = Q3 − Q1.- Anything below
Q1 − 1.5·IQRor aboveQ3 + 1.5·IQRis considered an outlier.
amt = inv["Amount_NPR"].dropna()
q1, q3 = amt.quantile([0.25, 0.75])
iqr = q3 - q1
low, high = q1 - 1.5 * iqr, q3 + 1.5 * iqr
print(f"Q1 = {q1:,.0f} Q3 = {q3:,.0f} IQR = {iqr:,.0f}")
print(f"Acceptable range: {low:,.0f} → {high:,.0f}")
outliers = inv[(inv["Amount_NPR"] < low) | (inv["Amount_NPR"] > high)]
print(f"\nOutlier rows: {len(outliers)}")
outliers[["Invoice_No", "Customer", "Amount_NPR"]].head()
Q1 = 33,024 Q3 = 138,120 IQR = 105,096
Acceptable range: -124,620 → 295,764
Outlier rows: 7
| Invoice_No | Customer | Amount_NPR | |
|---|---|---|---|
| 27 | INV-20810028 | Annapurna Stores | 352755.0 |
| 39 | INV-20810040 | Everest Suppliers | 300377.0 |
| 96 | INV-20810097 | Nepalgunj Goods | 387590.0 |
| 132 | INV-20810133 | Annapurna Stores | 359325.0 |
| 149 | INV-20810150 | Nepalgunj Goods | 306636.0 |
Practice 6
Use the IQR method on Quantity to find unusually high quantity invoices. Print their count and the rows.
# Your turn:
Show solution
q = inv["Quantity"]
q1, q3 = q.quantile([0.25, 0.75])
iqr = q3 - q1
high = q3 + 1.5 * iqr
out = inv[inv["Quantity"] > high]
print("Outlier rows:", len(out))
out[["Invoice_No","Product","Quantity"]].head()
| Invoice_No | Product | Quantity |
|---|
8. Renaming columns
Good column names = self-documenting analysis.
inv = inv.rename(columns={
"Amount_NPR": "Amount",
"VAT_NPR": "VAT",
"Total_NPR": "Total",
})
inv.columns
Index(['Invoice_No', 'Date', 'Customer', 'City', 'Product', 'Quantity',
'Unit_Price', 'Amount', 'VAT', 'Total', 'Payment_Status', 'Year',
'Month', 'Weekday'],
dtype='str')
9. Mini-Project — Clean the messy invoice file end-to-end
Re-open data/invoices_dirty.csv and apply the full cleaning pipeline:
- Load it into
df. - Drop duplicate rows.
- Fill missing
Amount_NPRwith the median. - Fill missing
Citywith"Unknown". - Clean
Customernames → strip whitespace, Title case. - Convert
Datecolumn to real datetime. - Add a
YearandMonthcolumn. - Flag any
Amount_NPRoutlier in a new columnIs_Outlier(True/False) using the IQR method. - Save the cleaned file as
invoices_clean.csv.
Print the before/after row counts and the missing-value counts after each major step.
# Your code here
df = pd.read_csv("data/invoices_dirty.csv")
print("Before:", df.shape)
Before: (206, 11)
Show solution
df = pd.read_csv("data/invoices_dirty.csv")
print("Loaded:", df.shape)
# 1. drop duplicates
df = df.drop_duplicates()
print("After dedup:", df.shape)
# 2. missing values
df["Amount_NPR"] = df["Amount_NPR"].fillna(df["Amount_NPR"].median())
df["City"] = df["City"].fillna("Unknown")
print("Missing after fill:\n", df.isna().sum())
# 3. clean customer text
df["Customer"] = df["Customer"].str.strip().str.title()
# 4. dates
df["Date"] = pd.to_datetime(df["Date"])
df["Year"] = df["Date"].dt.year
df["Month"] = df["Date"].dt.month
# 5. outliers
q1, q3 = df["Amount_NPR"].quantile([0.25, 0.75])
iqr = q3 - q1
low, high = q1 - 1.5*iqr, q3 + 1.5*iqr
df["Is_Outlier"] = (df["Amount_NPR"] < low) | (df["Amount_NPR"] > high)
# 6. save
df.to_csv("invoices_clean.csv", index=False)
print("\nSaved invoices_clean.csv with shape:", df.shape)
print("Outliers flagged:", df["Is_Outlier"].sum())
What's next?
With clean data in hand we can build new, useful columns from existing ones —
in 05_Feature_Engineering we'll create ratios, date features, customer-level aggregates
and ready our data for machine-learning models.