Data Cleaning & Transformation with Pandas
Introduction
Real business data is almost always messy. Data cleaning is consistently reported as consuming 60–80% of a data analyst's time, so Pandas provides a complete toolkit for identifying and resolving data quality issues before any analysis can be trusted. This chapter covers cleaning (missing values, duplicates, wrong data types, outliers) and transformation (derived columns, custom row-wise logic, sorting/ranking) — turning the raw RetailEdge_Sales.csv from the previous chapter into something safe to analyze.
Concepts
Missing values
df.isnull().sum()counts missing values per column; dividing bylen(df)gives a percentage.df.dropna(subset=[...])removes rows missing a critical column (one you can't sensibly fill in).df['col'].fillna(...)fills gaps:.median()for numeric columns (robust to outliers),.mode()[0]for categorical columns (most frequent value),method='ffill'to carry the last known value forward (useful for ordered/time-series data), or a fixed business-logic value (e.g. missing discount really means 0).
Duplicates
df.duplicated()flags fully duplicate rows;df.duplicated(subset=['OrderID'])flags duplicates on a column that should be unique.df.drop_duplicates(subset=[...], keep='first')removes them, keeping the first occurrence.
Data type corrections
pd.to_datetime(df['Date'])converts a string column to a real datetime — unlocking.dt.year,.dt.month,.dt.quarter,.dt.day_name()..astype('category')for columns with a small number of repeated string values (e.g.Store) — saves memory and speeds up grouping..str.strip()removes stray leading/trailing whitespace, a common artifact of hand-edited CSV files.
Outliers
- The IQR (interquartile range) method:
IQR = Q3 - Q1; anything belowQ1 - 1.5*IQRor aboveQ3 + 1.5*IQRis a statistical outlier. - Three common strategies once found: remove them, cap/winsorize them (
.clip(lower=, upper=)), or flag them in a new column for a human to investigate — which one is right depends on whether the outlier is a data error or a genuine (if unusual) business event.
Transformation
df['new_col'] = ...creates a derived column from existing ones (a profit margin, a revenue band)..apply(func)on a single column appliesfuncto each value;.apply(func, axis=1)appliesfuncto each row, letting it read multiple columns at once..map(dict)replaces values using a lookup dictionary — simpler and faster than.apply()when you just need value substitution.pd.cut(series, bins=[...], labels=[...])bins a continuous column into named categories..rank()ranks values;.groupby('col')['other_col'].rank()ranks within each group rather than across the whole dataset.
Examples
Missing values:
missing = df.isnull().sum()
missing_pct = (df.isnull().sum() / len(df) * 100).round(2)
print(pd.DataFrame({'Missing Count': missing, 'Missing %': missing_pct}))
df_clean = df.dropna(subset=['Revenue']) # critical column
df_clean['Profit'] = df_clean['Profit'].fillna(df_clean['Profit'].median())
df_clean['SalesRep'] = df_clean['SalesRep'].fillna(df_clean['SalesRep'].mode()[0])
df_clean['Discount'] = df_clean['Discount'].fillna(0.0) # business rule
Duplicates:
print('Duplicate OrderIDs:', df.duplicated(subset=['OrderID']).sum())
df_clean = df.drop_duplicates(subset=['OrderID'], keep='first')
Type corrections:
df['Date'] = pd.to_datetime(df['Date'], format='%Y-%m-%d')
df['Year'], df['Month'], df['Quarter'] = df['Date'].dt.year, df['Date'].dt.month, df['Date'].dt.quarter
if df['Discount'].max() > 1: # e.g. stored as 10 instead of 0.10
df['Discount'] = df['Discount'] / 100
for col in ['Store', 'Region', 'Category']:
df[col] = df[col].astype('category')
Outliers:
Q1, Q3 = df['Revenue'].quantile(0.25), df['Revenue'].quantile(0.75)
IQR = Q3 - Q1
lower_bound, upper_bound = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
outliers = df[(df['Revenue'] < lower_bound) | (df['Revenue'] > upper_bound)]
df['Revenue_Capped'] = df['Revenue'].clip(lower=lower_bound, upper=upper_bound)
df['is_outlier'] = ((df['Revenue'] < lower_bound) | (df['Revenue'] > upper_bound)).astype(int)
Derived columns and row-wise logic:
df['Profit_Margin_Pct'] = (df['Profit'] / df['Revenue'] * 100).round(2)
df['Revenue_Tier'] = pd.cut(
df['Revenue'],
bins=[0, 1000, 10000, 50000, float('inf')],
labels=['Small', 'Medium', 'Large', 'Enterprise'],
right=False,
)
def effective_price(row):
return row['Unit_Price'] * (1 - row['Discount'])
df['Effective_Price'] = df.apply(effective_price, axis=1)
region_map = {'Mumbai': 'West', 'Pune': 'West', 'Delhi': 'North', 'Jaipur': 'North'}
df['Region_Derived'] = df['Store'].map(region_map)
Sorting and ranking:
top_orders = df.sort_values('Revenue', ascending=False).head(10)
df['Revenue_Rank'] = df['Revenue'].rank(ascending=False, method='dense').astype(int)
df['Store_Revenue_Rank'] = df.groupby('Store')['Revenue'].rank(ascending=False, method='dense').astype(int)
Challenges
1. A cleaning pipeline
Build a complete cleaning function for RetailEdge_Sales.csv:
- Print a missing value report showing count and % for every column.
- Drop rows where both Revenue AND Profit are missing.
- Fill missing Discount with 0.0, missing SalesRep with
'Unknown'. - Remove exact duplicate orders (same OrderID).
- Parse the Date column and extract Month, Quarter, and day of week.
- Flag orders where Profit is negative as
'Loss Orders'in a new column. - Report final shape and missing value count after all cleaning steps.
💡 Show Answer
import pandas as pd
def clean_data(df):
missing = df.isnull().sum()
missing_pct = (missing / len(df) * 100).round(2)
print(pd.DataFrame({'Missing Count': missing, 'Missing %': missing_pct}))
df = df.dropna(subset=['Revenue', 'Profit'], how='all')
df['Discount'] = df['Discount'].fillna(0.0)
df['SalesRep'] = df['SalesRep'].fillna('Unknown')
df = df.drop_duplicates(subset=['OrderID'], keep='first')
df['Date'] = pd.to_datetime(df['Date'])
df['Month'] = df['Date'].dt.month
df['Quarter'] = df['Date'].dt.quarter
df['DayOfWeek'] = df['Date'].dt.day_name()
df['Order_Type'] = df['Profit'].apply(lambda p: 'Loss Orders' if p < 0 else 'Profitable')
print('Final shape:', df.shape)
print('Remaining missing values:', df.isnull().sum().sum())
return df
df = pd.read_csv('RetailEdge_Sales.csv', parse_dates=['Date'])
df_clean = clean_data(df)
2. Data transformation
- Create a
Profit_Categorycolumn:'Loss'(Profit < 0),'Low'(< 5000),'Medium'(< 20000),'High'(>= 20000). - Compute
Effective_Revenue = Revenue * (1 - Discount)as a new column. - Using
.apply()withaxis=1, createOrder_Score = (Revenue*0.6 + Profit*0.4) / 1000. - Rank all orders by Profit within each
Profit_Categorygroup (groupby+rank). - Create a
Seasoncolumn: Dec–Feb=Winter, Mar–May=Spring, Jun–Aug=Monsoon, Sep–Nov=Autumn.
💡 Show Answer
def profit_category(p):
if p < 0:
return 'Loss'
elif p < 5000:
return 'Low'
elif p < 20000:
return 'Medium'
return 'High'
df['Profit_Category'] = df['Profit'].apply(profit_category)
df['Effective_Revenue'] = df['Revenue'] * (1 - df['Discount'])
df['Order_Score'] = df.apply(lambda row: (row['Revenue'] * 0.6 + row['Profit'] * 0.4) / 1000, axis=1)
df['Profit_Rank_In_Category'] = df.groupby('Profit_Category')['Profit'].rank(ascending=False, method='dense')
season_map = {12: 'Winter', 1: 'Winter', 2: 'Winter',
3: 'Spring', 4: 'Spring', 5: 'Spring',
6: 'Monsoon', 7: 'Monsoon', 8: 'Monsoon',
9: 'Autumn', 10: 'Autumn', 11: 'Autumn'}
df['Season'] = df['Date'].dt.month.map(season_map)