Session 05: Insurance Data Sources & Cleaning
Learning Objectives
- Identify the types and sources of data in the insurance industry — customer, policy, claims, external, and unstructured data
- Navigate the 6-table integrated synthetic insurance dataset and understand its schema, relationships, and business context
- Load CSV files into Pandas DataFrames and perform initial exploration using .head(), .info(), .describe(), and .shape
- Apply appropriate strategies for handling missing values — deletion, mean/median imputation, mode imputation, and forward-fill
- Merge Customer, Policy, and Claims tables into a single analysis-ready DataFrame and generate a comprehensive data quality report
1. Insurance Data Landscape
Insurance is an information business. An insurer's ability to accurately price risk, efficiently process claims, detect fraud, and retain customers depends entirely on the quality and breadth of its data. Understanding the types of data available — and their limitations — is the foundation of insurance analytics.
1.1 The Five Categories of Insurance Data
| Category | Description | Examples | Typical Sources |
|---|---|---|---|
| 1. Customer Data | Demographic and contact information about policyholders | Name, age, gender, location, occupation, income, credit score, KYC status, marital status | Application forms, KYC documents, third-party data providers (credit bureaus), Account Aggregator |
| 2. Policy Data | Details about the insurance contracts issued | Policy type, premium amount, sum assured, tenure, start/end dates, product name, distribution channel, add-on covers, status (active/lapsed/cancelled) | Policy administration system (core insurance platform) |
| 3. Claims Data | Records of all claims filed by policyholders | Claim ID, policy ID, claim date, claim type, claim amount, settlement amount, status, settlement date, days-to-settle, fraud flag, adjuster ID, cause of loss | Claims management system |
| 4. External Data | Data from outside the insurer used to augment internal data for risk assessment | Credit scores, vehicle registration data (VAHAN), weather data, economic indicators, crime statistics, hospital pricing data | Credit bureaus (CIBIL, Experian), government databases, third-party APIs, public datasets |
| 5. Unstructured Data | Non-tabular data that requires text analysis, computer vision, or natural language processing to extract value | Claim description text, policy documents (PDF), medical reports, surveyor photos, customer call transcripts, email correspondence | FNOL forms, medical records, surveyor reports, call centre recordings, scanned documents |
1.2 The Data Challenge in Insurance
Insurance data is notoriously messy for several structural reasons. First, data comes from multiple sources that were never designed to talk to each other — an insurer might have separate systems for policy administration, claims, billing, CRM, and document management, each with its own data formats, identifiers, and quality standards. Second, insurance data is long-tailed — a life insurance policy can span 30+ years, and the data quality from 1995 is unlikely to match the data quality from 2025. Third, claims data suffers from "reporting delay" — a claim may occur in one accounting period but not be reported until a later period (IBNR), and the final settlement may take years (long-tail liability claims).
These structural challenges are why data cleaning is not an optional preparatory step in insurance analytics — it is often 60–80% of the work. An analyst who cannot clean data effectively cannot produce reliable insurance insights, regardless of how sophisticated their modeling skills are.
🔍 Exercise 1.1 — Classify the Data Source
For each data item below, choose the correct category from the five in Section 1.1: Customer / Policy / Claims / External / Unstructured.
| # | Data Item | Category |
|---|---|---|
| 1 | Policyholder's age, income, and credit score. | |
| 2 | Policy start/end dates and the premium amount. | |
| 3 | The claim settlement amount and days-to-settle. | |
| 4 | A surveyor's photo of a damaged car. | |
| 5 | The latest IMD rainfall data for a coastal district. | |
| 6 | The customer's occupation and KYC status. | |
| 7 | The claim description text written by the policyholder. | |
| 8 | The distribution channel and product name. |
Reflection: Which category is hardest to clean, and why? Write your answer.
Check Your Classification
- Customer — demographics and financial profile of the policyholder.
- Policy — contract details of the insurance cover.
- Claims — records of losses and their settlement.
- Unstructured — photos require computer vision, not tabular cleaning.
- External — weather data comes from outside the insurer (IMD).
- Customer — occupation and KYC are part of the customer profile.
- Unstructured — free text requires NLP to extract structured meaning.
- Policy — channel and product describe how/where the policy was sold.
Reflection note: Unstructured data (photos, free text, documents) is the hardest to clean because it cannot be fixed with `fillna` or type conversion — it needs NLP/computer vision to extract value, and the extraction itself can introduce errors. This is why insurance analysts spend the most time on it.
2. The Integrated Synthetic Dataset
Throughout this course, we will use a single integrated synthetic insurance dataset. All 6 tables were designed together with consistent keys, realistic distributions, and embedded patterns for fraud detection, claims analysis, and risk modeling. Every session from Session 05 onward references this dataset — so understanding its structure now will pay dividends for the rest of the course.
2.1 The Six Tables
The integrated dataset comprises six related tables — three core tables linked by primary/foreign keys, plus three supporting tables used for specific exercises:
| Table | Records | What It Contains | Primary Key | Foreign Key |
|---|---|---|---|---|
| Customer | 5,000 | Demographics, location, occupation, income, credit score, KYC status | customer_id | — |
| Policy | 10,000 | Policy type, premium, sum assured, tenure, start/end dates, channel, product name | policy_id | customer_id |
| Claims | 20,000 | Claim amount, type, status, settlement date, days-to-settle, fraud flag | claim_id | policy_id, customer_id |
| Fraud Indicators | 5,000 | Suspicious patterns, historical flags, audit trail, risk score | indicator_id | claim_id |
| Cyber Incident | 2,000 | Breach type, severity, records exposed, loss amount (₹ Cr), ransomware flag, industry | incident_id | — |
| Weather / Climate | 3,000 | Daily rainfall, max/min temperature, cyclone events, wind speed, flood index by region | (date, region) | — |
generate_dataset.py (shown in Section 2.2 below) — that creates realistic synthetic data with consistent keys, distributions, and embedded fraud patterns. All files are published in the course's public GitHub repository:
To get started:
- Download the repository (green "Code" button → "Download ZIP") or clone it:
git clone https://github.com/afzalur7/insurtech-course-dataset.git - Put the six CSV files in a folder named
data/next to your Jupyter notebook - Or download individual files directly: customers.csv · policies.csv · claims.csv · fraud_indicators.csv · cyber_incidents.csv · weather_data.csv
- To regenerate the CSVs yourself:
python generate_dataset.py(seed = 42, so output is identical)
2.2 Generating the Six Tables (Python Code)
This is the exact script saved as generate_dataset.py in the course GitHub repository. It creates all six CSVs with reproducible results (np.random.seed(42)) so every student generates the identical dataset. Two ways to get the data: (1) Download the six ready-made CSVs from the repository (fastest — no code needed), or (2) run this script yourself to generate them from scratch. Both produce exactly the same files.
What the generator does (you don't need to write this — it's on GitHub)
- 1. Customer table — 5,000 customers: demographics, income (lognormal, ~₹1.3L median), credit score (~N(710, 85)), KYC status. 5% income missing by design (the MAR pattern used in the cleaning exercises).
- 2. Policy table — 10,000 policies linked to customers by
customer_id: type (Motor 40%, Health 30%, Property 12%, Crop 10%, Travel 8%), premium, sum assured, tenure, dates, channel. - 3. Claims table — 20,000 claims linked to policies by
policy_id: type, amount (right-skewed), settlement, status, fraud flag (~5%), days-to-settle. - 4. Fraud indicators — 5,000 records linked to claims by
claim_id, with a risk score and review status. - 5. Cyber incidents — 2,000 records: industry, breach type, records exposed, loss (₹ Cr), ransomware flag.
- 6. Weather data — 3,000 daily records by region: rainfall, temperature, cyclone events (~2%), wind speed, flood index.
Run it: python generate_dataset.py ·
View the full script on GitHub ·
Browse the repository
2.3 Entity-Relationship Diagram
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Customer │ │ Policy │ │ Claims │
│ │ │ │ │ │
│ customer_id (PK)│──────→│ policy_id (PK) │──────→│ claim_id (PK) │
│ age │ │ customer_id (FK) │ │ policy_id (FK) │
│ gender │ │ policy_type │ │ customer_id (FK) │
│ location │ │ premium │ │ claim_date │
│ occupation │ │ sum_assured │ │ claim_type │
│ income │ │ tenure_months │ │ claim_amount │
│ credit_score │ │ start_date │ │ settlement_amount│
│ kyc_status │ │ end_date │ │ status │
└─────────────────┘ │ channel │ │ fraud_flag │
│ product_name │ │ days_to_settle │
└──────────────────┘ └──────────────────┘
│ │
│ │
┌────────▼────────┐ ┌────────▼────────┐
│ Fraud Indicators│ │ Cyber Incident │
│ │ │ │
│ indicator_id │ │ incident_id │
│ claim_id (FK) │ │ industry │
│ indicator_type │ │ breach_type │
│ indicator_value │ │ records_exposed │
│ score │ │ loss_amount_cr │
│ review_status │ │ ransomware_flag │
└─────────────────┘ └─────────────────┘
┌──────────────────┐
│ Weather/Climate │
│ │
│ date │
│ region │
│ rainfall_mm │
│ max_temp │
│ cyclone_event │
│ wind_speed_kmph │
│ flood_index │
└───────────────────┘
The key relationships to note: Customer has a one-to-many relationship with Policy (a customer can hold multiple policies). Policy has a one-to-many relationship with Claims (a single policy can generate multiple claims over its lifetime). Fraud Indicators and the Weather table are standalone tables used for specific exercises in later sessions.
📑 Exercise 2.1 — Read the Relationships
Using the entity-relationship diagram above, answer the questions below. Write your answers in the boxes.
- Can one customer hold more than one policy? How do you know from the diagram?
- Can one policy generate more than one claim? How do you know?
- Which column would you use to join Claims to Policy?
- Which table has NO direct relationship to the others? What is it used for?
- If a claim's
policy_iddoes not exist in the Policy table, what does that indicate about data quality?
Check Your Answers
- Yes. The Customer→Policy relationship is one-to-many (a customer can hold multiple policies) — shown by the foreign key
customer_id (FK)in the Policy table and the "1 → ∞" arrow direction. - Yes. Policy→Claims is also one-to-many — a single policy can produce multiple claims over its lifetime (e.g., two accidents on the same motor policy).
policy_id— it appears as a primary key in Policy and as a foreign key in Claims. Join Claims.policy_id = Policy.policy_id. (You would also join on customer_id when you want customer details.)- The Weather/Climate table — it has no foreign key linking it to Customer, Policy, or Claims. It is used for climate-risk exercises (correlating weather events with claims) and parametric insurance design, where it is joined by date and region rather than by key.
- It is a data quality problem — an orphaned claim. The claim references a policy that does not exist, which will cause it to drop out of a merge. This must be investigated (wrong policy_id? policy deleted?) before analysis — never silently ignore orphans.
3. Setting Up Python for Data Work
Before we can clean insurance data, we need to set up our Python environment. This session assumes you are using Jupyter Notebook (or JupyterLab) with the Pandas and NumPy libraries installed.
3.1 Required Imports
Every data analysis session in this course starts with these imports:
# Core data manipulation libraries
import pandas as pd
import numpy as np
# Optional: display all columns and rows in Jupyter
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', 200)
# For reproducibility
np.random.seed(42)
print("Pandas version:", pd.__version__)
print("NumPy version:", np.__version__)
3.2 Loading the Data
The six CSV files should be placed in a `data/` folder relative to your notebook. Load them as follows:
# Specify the data path
DATA_PATH = 'data/'
# Load each table
customers = pd.read_csv(DATA_PATH + 'customers.csv')
policies = pd.read_csv(DATA_PATH + 'policies.csv')
claims = pd.read_csv(DATA_PATH + 'claims.csv')
fraud_indicators = pd.read_csv(DATA_PATH + 'fraud_indicators.csv')
cyber = pd.read_csv(DATA_PATH + 'cyber_incidents.csv')
weather = pd.read_csv(DATA_PATH + 'weather_data.csv')
# Confirm all tables loaded
print("Tables loaded successfully:")
print(f" Customers: {customers.shape[0]:,} rows")
print(f" Policies: {policies.shape[0]:,} rows")
print(f" Claims: {claims.shape[0]:,} rows")
print(f" Fraud Indicators:{fraud_indicators.shape[0]:,} rows")
print(f" Cyber Incidents: {cyber.shape[0]:,} rows")
print(f" Weather: {weather.shape[0]:,} rows")
3.3 Initial Exploration
Before any cleaning, explore each table to understand its structure:
# Examine the customers table
print("=== CUSTOMERS TABLE ===")
print(f"Shape: {customers.shape}")
print(f"\nColumn names and types:\n{customers.dtypes}")
print(f"\nFirst 5 rows:\n{customers.head()}")
print(f"\nSummary statistics (numeric):\n{customers.describe()}")
print(f"\nSummary statistics (categorical):\n{customers.describe(include='object')}")
# Check for missing values in each column
print("=== MISSING VALUES ===")
print(customers.isnull().sum())
print(f"\nPercentage missing:\n{customers.isnull().mean() * 100}")
# Unique values in categorical columns
print(f"\nUnique values in 'occupation': {customers['occupation'].nunique()}")
print(f"Unique values in 'location': {customers['location'].nunique()}")
print(f"Value counts for 'gender':\n{customers['gender'].value_counts()}")
👀 Exercise 3.1 — Predict the Output
Before you run any code, predict what each Pandas command returns. This "predict → run → check" habit is the fastest way to learn Pandas — it forces your brain to build a mental model of the API.
| Command | What do you expect it to return? | Purpose |
|---|---|---|
customers.shape |
||
customers.head(3) |
||
customers.info() |
||
customers.isnull().sum() |
||
customers['gender'].value_counts() |
Check Your Predictions
| Command | Returns | Purpose |
|---|---|---|
customers.shape | A tuple like (5000, 12) — (rows, columns) | Count of rows & columns |
customers.head(3) | The first 3 rows of the DataFrame as a table | First few rows |
customers.info() | A summary listing each column, its data type, and how many non-null (non-missing) values it has | Data types + non-null counts |
customers.isnull().sum() | A Series with one row per column showing the total count of missing (NaN) values | Missing values per column |
customers['gender'].value_counts() | A Series showing each unique gender value and how many customers have it | Unique values per column |
Key takeaway: .shape, .head(), and .info() are your "first look" trio — run them on every table before touching anything else. Two minutes of exploration saves two hours of debugging.
4. Handling Missing Values
Missing data is the most common data quality problem in insurance datasets. The question is never "will we have missing data?" but "how should we handle it?" The right strategy depends on the type of missingness, the column's role in analysis, and the proportion of missing values.
4.1 Types of Missingness
Statisticians distinguish three types of missing data, and the appropriate handling strategy depends on which type you have:
- MCAR — Missing Completely at Random: The missingness has no relationship to any data — it is purely random. A data entry error that randomly skips a field. This is rare in practice. When it occurs, you can safely delete missing rows or impute without introducing bias.
- MAR — Missing at Random: The missingness is related to observed data but not to the missing value itself. For example, older policyholders may be less likely to report their income. If age is observed, we can model income missingness using age. This is the most common type in insurance data.
- MNAR — Missing Not at Random: The missingness is related to the missing value itself. For example, policyholders with very low credit scores may refuse to share their credit score. If the missingness is caused by the low score itself, techniques like deletion or imputation will introduce bias. This requires more advanced handling (Heckman correction, sensitivity analysis).
4.2 Practical Strategies in Pandas
Strategy 1 — Deletion (Drop Missing Rows): Use only when a small proportion of rows is affected and missingness is MCAR.
# Drop rows where ANY value is missing (use with extreme caution)
df_clean = customers.dropna()
# Drop rows where a SPECIFIC column is missing (safer)
df_clean = customers.dropna(subset=['customer_id', 'age'])
# Drop columns with more than 50% missing values
threshold = len(customers) * 0.5
customers = customers.dropna(axis=1, thresh=threshold)
Strategy 2 — Mean/Median Imputation: Replace missing numeric values with the column mean or median. Median is preferred when the distribution is skewed (as claim amounts and incomes typically are).
# Mean imputation for normally distributed data
customers['age'].fillna(customers['age'].mean(), inplace=True)
# Median imputation for skewed data (preferred for income, claim amounts)
customers['income'].fillna(customers['income'].median(), inplace=True)
# Grouped median: impute based on similar customers
customers['income'] = customers.groupby('occupation')['income']\
.transform(lambda x: x.fillna(x.median()))
Strategy 3 — Mode Imputation: Replace missing categorical values with the most frequent category. Use for categorical variables where the missingness is small (<5%).
# Mode imputation for categorical columns
customers['gender'].fillna(customers['gender'].mode()[0], inplace=True)
customers['kyc_status'].fillna(customers['kyc_status'].mode()[0], inplace=True)
Strategy 4 — Forward Fill: Use for time-series data where a missing value likely equals the previous value (e.g., monthly claims where one month is missing).
# Forward fill for time-series data
monthly_claims['claim_count'].fillna(method='ffill', inplace=True)
# Backward fill as alternative
monthly_claims['claim_count'].fillna(method='bfill', inplace=True)
claim_amount or fraud_flag without deliberate consideration. These are the target variables for many models — dropping them means you are removing the very data you want to predict. In claims analysis, missing claim_amount should be flagged, investigated, and only then either imputed or excluded with clear documentation. Dropping rows with missing target data is one of the most common and most dangerous data cleaning mistakes in insurance analytics.
🤔 Exercise 4.1 — Choose the Missing-Value Strategy
For each scenario, choose the type of missingness (MCAR / MAR / MNAR) and the best handling strategy (drop / mean / median / mode / forward-fill / flag & investigate).
| # | Scenario | Missingness Type | Best Strategy |
|---|---|---|---|
| 1 | Random data-entry gaps in the age column — no pattern at all. |
||
| 2 | Older policyholders are less likely to report income; missingness depends on age. | ||
| 3 | Policyholders with very low credit scores refuse to share them — missingness is caused by the score itself. | ||
| 4 | claim_amount is missing on claims still awaiting settlement. |
||
| 5 | One month of a monthly claims time series is missing. | ||
| 6 | gender is missing on only 2% of records. |
Check Your Answers
| # | Missingness | Strategy | Why |
|---|---|---|---|
| 1 | MCAR | Median imputation (or drop rows if very few) | Truly random — imputation introduces no bias. Median is safer than mean for skewed data. |
| 2 | MAR | Median imputation grouped by age band | Missingness is explained by age, so impute using the median of the same age group, not the whole dataset. |
| 3 | MNAR | Flag & investigate | The missingness is caused by the value itself — imputing would systematically understate the true risk. Flag these records and investigate separately. |
| 4 | MAR / structural | Flag & investigate (do NOT drop) | claim_amount is a target variable — dropping removes the data you want to predict. These claims will settle later; handle them as a separate group. |
| 5 | Structural gap | Forward-fill | Time-series gap — the missing month most likely resembles the previous month. Use ffill. |
| 6 | MCAR | Mode imputation | Only 2% missing on a categorical — fill with the most common value. |
5. Data Type Conversion & Standardization
Insurance data regularly arrives with incorrect data types. Dates arrive as strings, numeric columns contain stray text characters, and categorical variables have inconsistent capitalization or naming conventions. Fixing these issues is essential before any analysis.
5.1 Date Conversion
Dates are the most commonly mis-typed column in insurance data. Always convert them explicitly:
# Convert date columns from string to datetime
policies['start_date'] = pd.to_datetime(policies['start_date'])
policies['end_date'] = pd.to_datetime(policies['end_date'])
claims['claim_date'] = pd.to_datetime(claims['claim_date'])
# NOTE: The dataset stores settlement delay as the numeric column 'days_to_settle',
# not as a 'settlement_date' column — so there is no settlement date to convert.
# Verify the conversion
print(policies[['start_date', 'end_date']].dtypes)
print(f"Date range: {policies['start_date'].min()} to {policies['start_date'].max()}")
# Create derived date features
claims['claim_year'] = claims['claim_date'].dt.year
claims['claim_month'] = claims['claim_date'].dt.month
claims['claim_quarter'] = claims['claim_date'].dt.quarter
claims['day_of_week'] = claims['claim_date'].dt.dayofweek # 0=Monday, 6=Sunday
5.2 Numeric Cleaning
Numeric columns often contain non-numeric characters — currency symbols, commas, spaces, or the word "NA":
# Check if premium column is actually numeric
print(f"Premium dtype: {policies['premium'].dtype}")
print(f"Unique non-numeric values:\n{policies[pd.to_numeric(policies['premium'], errors='coerce').isna()]['premium'].unique()}")
# Clean: remove currency symbols, commas, and convert to float
# NOTE: Only needed if the column was imported as TEXT (object dtype).
# In our generated dataset premium is already float, so we guard the
# .str accessor with a dtype check to avoid an AttributeError.
if policies['premium'].dtype == 'object':
policies['premium'] = policies['premium']\
.str.replace('₹', '')\
.str.replace(',', '')\
.str.strip()\
.astype(float)
# For claim amounts — check for outliers first using .describe()
print(claims['claim_amount'].describe())
# Cap extreme outliers at the 99th percentile (optional, based on business context)
cap = claims['claim_amount'].quantile(0.99)
claims['claim_amount_capped'] = claims['claim_amount'].clip(upper=cap)
5.3 Categorical Standardization
Inconsistent categorical values are a persistent problem — "Motor", "motor", "MOTOR", and "Motor Insurance" should all map to the same category:
# Check unique values before cleaning
print("Before cleaning:", policies['policy_type'].unique())
# Standardize to title case
policies['policy_type'] = policies['policy_type'].str.strip().str.title()
# Map variations to standard categories
type_mapping = {
'Motor Own Damage': 'Motor',
'Motor Od': 'Motor',
'Motor': 'Motor',
'Health': 'Health',
'Health Insurance': 'Health',
'Property': 'Property',
'Property Insurance': 'Property',
'Fire': 'Property',
'Crop': 'Crop',
'Crop Insurance': 'Crop',
'Travel': 'Travel'
}
policies['policy_type'] = policies['policy_type'].map(type_mapping)
print("After cleaning:", policies['policy_type'].unique())
print(f"Value counts:\n{policies['policy_type'].value_counts()}")
🔍 Exercise 5.1 — Spot the Messy Data
Each column below contains messy data. State (a) what is wrong, and (b) the exact Pandas step to fix it. Then answer the "predict" question at the end.
| # | Column with sample values | What is wrong? | Fix (Pandas) |
|---|---|---|---|
| 1 | premium = "₹12,500", "₹8,750", "10,000" |
||
| 2 | claim_date = "2026-07-15" stored as an object (string) |
||
| 3 | policy_type = "Motor", "motor", "MOTOR Insurance" |
||
| 4 | claim_amount = 9999999 (one value, clearly an entry error, not a real claim) |
Predict: What does policies['premium'].str.replace('₹','').str.replace(',','').astype(float) produce for the value "₹12,500"?
Check Your Answers
| # | Problem | Fix |
|---|---|---|
| 1 | Premium is stored as text with a currency symbol (₹), commas, and inconsistent formatting — so it cannot be used in calculations. | if policies['premium'].dtype == 'object': policies['premium'] = policies['premium'].str.replace('₹','').str.replace(',','').str.strip().astype(float) — the dtype guard prevents an AttributeError if premium is already numeric. |
| 2 | Dates stored as strings — you cannot sort by date or compute time differences. | policies['claim_date'] = pd.to_datetime(policies['claim_date']) |
| 3 | Categorical values are inconsistent in case and wording — "Motor", "motor", "MOTOR Insurance" would be treated as three different categories. | Standardise: policies['policy_type'] = policies['policy_type'].str.strip().str.title() then map variations to a single standard (e.g., "MOTOR Insurance" → "Motor"). |
| 4 | A data-entry outlier (9999999) that is not a real claim — capping it with .clip() would keep a wrong value; it must be investigated first. | Flag it, verify against the source, and only then correct or exclude it — never auto-cap a suspected data error. |
Predict answer: policies['premium'].str.replace('₹','').str.replace(',','').astype(float) turns "₹12,500" into the float 12500.0 — the ₹ and the comma are stripped, then the string "12500" is converted to a number. (Remember: this only runs if premium is actually text — in our generated dataset it is already float, so the dtype guard skips it.)
6. Duplicate Detection & Removal
Duplicates in insurance data can arise from system errors (the same claim entered twice), data integration (overlapping tables from merged systems), or data entry mistakes. Removing them requires careful judgment — what looks like a duplicate may actually be legitimate distinct records.
6.1 Identifying Duplicates
# Check for exact duplicates across all columns
exact_dups = claims.duplicated().sum()
print(f"Exact duplicate rows: {exact_dups}")
# Check for duplicates based on key columns (more common in insurance)
# A claim_id should be unique — any duplicate is a data error
dup_claim_ids = claims['claim_id'].duplicated().sum()
print(f"Duplicate claim IDs: {dup_claim_ids}")
# Show the actual duplicates (if any)
if dup_claim_ids > 0:
dupe_mask = claims['claim_id'].duplicated(keep=False)
print(claims[dupe_mask].sort_values('claim_id').head(10))
6.2 Smart Deduplication
Not all duplicates are identical — a customer may have two policies with the same policy type (legitimate — they bought a second vehicle) or the same claim entered twice (a duplicate that must be removed). The deduplication logic must reflect the business context.
# Remove exact duplicates (rare)
claims_deduped = claims.drop_duplicates()
print(f"Rows after exact dedup: {len(claims_deduped)}")
# For claim_id duplicates: keep the first occurrence, drop the rest
claims_deduped = claims.drop_duplicates(subset=['claim_id'], keep='first')
# For policy_id duplicates: check if same customer has multiple policies of same type
# These may be legitimate (two motor policies for two different cars)
# Flag them for review rather than automatically removing
policy_dup_check = policies.duplicated(subset=['customer_id', 'policy_type', 'start_date'], keep=False)
policy_suspicious = policies[policy_dup_check]
print(f"Policies with identical customer+type+start_date (possible duplicates): {len(policy_suspicious)}")
# For these suspicious cases, keep the one with the higher 'premium' (more complete data?)
policies = policies.sort_values('premium', ascending=False)\
.drop_duplicates(subset=['customer_id', 'policy_type', 'start_date'], keep='first')
🧮 Exercise 6.1 — Duplicate or Legitimate?
For each scenario, decide whether the record is a True duplicate (remove) or a Legitimate distinct record (keep) — and state the reason in one line.
| # | Scenario | Verdict | Reason |
|---|---|---|---|
| 1 | The same claim_id appears twice with identical data. |
||
| 2 | A customer has two motor policies with the same policy_type but different start_date. |
||
| 3 | The same policy_id appears twice with slightly different premiums. |
||
| 4 | The same customer_id appears twice with different income values. |
||
| 5 | Two different customers share the same location and occupation but different IDs. |
Check Your Answers
- True duplicate — remove. A
claim_idshould be unique. Identical data = system error (entered twice). Remove duplicates onclaim_id. - Legitimate — keep. Same customer + same policy type but different start date = they bought a second car on a later date. These are two real policies.
- True duplicate — remove. A
policy_idshould be unique; two rows with the same ID and different premiums = one is wrong. Keep the first (or reconcile with the source). - Data-quality issue — reconcile, don't blindly delete. Same person with conflicting income. Investigate which value is correct (newer? verified?) and update, rather than dropping a row that may be the only record.
- Legitimate — keep. Same location and occupation is a coincidence. Different IDs = different people. Removing them would be wrong.
Key rule: The decision depends on the business key — which columns should uniquely identify a record. If the business key is duplicated, it is a true duplicate. If the business key is different, the record is legitimate — even if other columns look similar.
7. Merging Insurance Tables
The cleaned tables must be joined to create the analysis-ready dataset. In insurance analytics, most analysis requires data from multiple tables — calculating loss ratios by customer segment (needs Customer + Policy + Claims) or building an underwriting model (needs features from all three).
7.1 The Merge Strategy
We join the tables using Pandas' `merge()` function, which works like SQL JOINs. The standard workflow is:
# Step 1: Merge Customer → Policy (left join: keep all policies, add customer info)
policy_customer = policies.merge(
customers,
on='customer_id',
how='left' # Keep all policies even if customer data is missing
)
# Step 2: Merge Policy+Customer → Claims
full_data = claims.merge(
policy_customer,
on=['policy_id', 'customer_id'],
how='left' # Keep all claims
)
# Verify the merged result
print(f"Merged dataset shape: {full_data.shape}")
print(f"Columns: {full_data.columns.tolist()}")
print(f"Missing values in merged dataset:\n{full_data.isnull().sum()}")
# Optional: merge fraud indicators
full_data = full_data.merge(
fraud_indicators,
on='claim_id',
how='left'
)
print(f"Final dataset shape: {full_data.shape}")
7.2 Verifying the Merge
Always verify that the merge worked as expected:
# Check: did every claim get matched to a policy?
claims_unmatched = full_data[full_data['premium'].isna()].shape[0]
print(f"Claims without matching policy: {claims_unmatched}")
# Check: did every policy get matched to a customer?
policies_unmatched = policy_customer[policy_customer['age'].isna()]['policy_id'].nunique()
print(f"Policies without matching customer: {policies_unmatched}")
# Row count sanity check
print(f"Original claims: {len(claims)}")
print(f"Merged dataset: {len(full_data)}")
if len(full_data) > len(claims):
print("CAUTION: Merge created extra rows — check for duplicate keys!")
elif len(full_data) < len(claims):
print("CAUTION: Merge dropped rows — check that all claim IDs exist in policies table!")
left join when merging claims → policies (keep all claims, add policy data where available). Claims without matching policies are data quality issues that must be investigated, not silently dropped. An inner join would hide them, robbing you of the opportunity to fix the underlying data quality problem. Merge verification is not optional — always check row counts before and after.
🔗 Exercise 7.1 — Choose the Right Join and Predict the Row Count
The Claims table has 20,000 rows. For each scenario, choose the join type and predict the resulting row count.
| # | Goal | Join Type | Predicted Row Count |
|---|---|---|---|
| 1 | Keep ALL claims, add policy details where available. | ||
| 2 | Keep only claims that have a matching policy. | ||
| 3 | A claim joins to a policy that appears TWICE in the Policy table (duplicate policy_id). |
Verification question: After a merge you get 20,004 rows from 20,000 claims. What is the most likely cause, and what check confirms it?
Check Your Answers
| # | Join | Row count | Why |
|---|---|---|---|
| 1 | left | Exactly 20,000 | Left join keeps every row from the left table (claims); unmatched claims simply get NaN for policy columns. Row count never changes for the left table. |
| 2 | inner | ≤ 20,000 | Inner join keeps only matches — any claim without a policy drops out. |
| 3 | left (or inner) | > 20,000 (row explosion) | A duplicate key in the Policy table causes a one-to-many match — each claim duplicates against both copies of the policy. This is why row counts must be verified: a "clean" left join should never add rows. |
Verification answer: 20,004 > 20,000 means a row explosion — one or more keys in the Policy (right) table are duplicated, so some claims matched to multiple policies. The check: count duplicate keys in the right table before merging — e.g., policies['policy_id'].duplicated().sum() — and dedupe first. Never merge before checking for duplicate keys in the join column.
8. Data Quality Report
The final step is to generate a data quality report — a structured summary of data quality issues in the cleaned dataset. This becomes documentation for anyone using the data for analysis or modeling.
8.1 Building the Report
def generate_data_quality_report(df, dataset_name):
"""
Generate a comprehensive data quality report for a DataFrame.
Parameters:
-----------
df : pd.DataFrame — The dataset to report on
dataset_name : str — Name of the dataset for the report header
Returns:
--------
pd.DataFrame — A quality report with one row per column
"""
report = pd.DataFrame({
'Column': df.columns,
'Data Type': df.dtypes.values,
'Non-Null Count': df.notna().sum().values,
'Null Count': df.isna().sum().values,
'Null %': (df.isna().mean() * 100).values,
'Unique Values': [df[col].nunique() for col in df.columns],
'Has Duplicates': [df[col].duplicated().any() for col in df.columns],
})
# Detect numeric columns for outlier check
numeric_cols = df.select_dtypes(include=[np.number]).columns
report['Has Outliers (IQR)'] = '—'
for col in df.columns:
if col in numeric_cols:
Q1 = df[col].quantile(0.25)
Q3 = df[col].quantile(0.75)
IQR = Q3 - Q1
outlier_count = ((df[col] < (Q1 - 1.5 * IQR)) | (df[col] > (Q3 + 1.5 * IQR))).sum()
report.loc[report['Column'] == col, 'Has Outliers (IQR)'] = \
'Yes (' + str(outlier_count) + ')' if outlier_count > 0 else 'No'
# Sort by null percentage descending
report = report.sort_values('Null %', ascending=False).reset_index(drop=True)
print(f"\n{'='*60}")
print(f"DATA QUALITY REPORT: {dataset_name}")
print(f"{'='*60}")
print(f"Shape: {df.shape[0]:,} rows × {df.shape[1]:,} columns")
print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
print(f"Columns with nulls: {(df.isna().sum() > 0).sum()} of {df.shape[1]}")
print(f"Duplicate rows: {df.duplicated().sum()}")
print(f"{'='*60}\n")
return report
# Generate reports for each table
for name, df in [
('Customers', customers),
('Policies', policies),
('Claims', claims),
('Merged Dataset', full_data)
]:
report = generate_data_quality_report(df, name)
try:
display(report) # Jupyter displays a formatted table
except NameError:
print(report) # Fallback for running as a plain .py script
print("\n")
8.2 Saving the Cleaned Data
Once the data is cleaned and merged, save it for use in subsequent sessions:
# Export the cleaned, merged dataset
full_data.to_csv('data/insurance_cleaned.csv', index=False)
print(f"Cleaned dataset saved: data/insurance_cleaned.csv ({len(full_data):,} rows)")
# Also save individual cleaned tables
customers.to_csv('data/customers_cleaned.csv', index=False)
policies.to_csv('data/policies_cleaned.csv', index=False)
claims.to_csv('data/claims_cleaned.csv', index=False)
# Save the quality report for reference
quality_report = generate_data_quality_report(full_data, 'Merged Dataset')
quality_report.to_csv('data/data_quality_report.csv', index=False)
print("Data quality report saved: data/data_quality_report.csv")
📊 Exercise 8.1 — Interpret and Prioritise the Report
This is a sample data quality report for a merged claims dataset. Your job: identify the issues, rank them by priority, and decide what to fix first.
| Column | Null % | Duplicates | Outliers | Your Priority |
|---|---|---|---|---|
claim_amount | 12% | 0 | Yes (entry errors) | |
income | 22% | 0 | No | |
policy_id | 0% | 2% | No | |
policy_type | 0% | 0 | 8% inconsistent categories | |
credit_score | 0.5% | 0 | No |
Question: Which issue do you fix FIRST, and why? Write your answer in one or two lines.
Check Your Priorities
| Column | Priority | Why |
|---|---|---|
claim_amount | CRITICAL — fix first | 12% null on a target variable. Never drop — investigate why it's missing (unsettled claims?) and handle as a separate group. This is the biggest risk to any downstream model. |
policy_id | HIGH | 2% duplicates. Fix before merging — duplicate keys cause row explosion and wrong aggregates. Dedupe now to save hours later. |
income | HIGH | 22% null is above the 10% rule-of-thumb. Impute with grouped median (by occupation/age) — but it is not a target variable, so it is slightly lower priority than claim_amount. |
policy_type | MEDIUM | 8% inconsistent categories distort any grouping. Standardise with the cleaning dictionary — quick and safe. |
credit_score | LOW | 0.5% null — impute with median/mode and move on. |
Rule of thumb: Fix (1) target-variable issues first, (2) duplicate keys before merging, (3) anything above 10% null, then (4) cosmetic standardisation. The order matters — a wrong fix early can corrupt everything downstream.
Hands-On Project: Clean and Prepare the Insurance Dataset
You have received 3 CSV files from an insurer's data warehouse — Customer, Policy, and Claims tables. Your task is to load, explore, clean, merge, and generate a quality report. This is the most practically useful session in the course — insurance data scientists spend 60–80% of their time on exactly this work.
Steps
- Load the three CSV files (Customer, Policy, Claims) into Pandas DataFrames. Use `pd.read_csv()`. Print the shape and first 3 rows of each table.
- Explore and identify issues: For each table, run:
- `.info()` — check data types and non-null counts
- `.describe()` — check numeric distributions
- `.isnull().sum()` — identify missing values
- `.duplicated().sum()` — identify duplicates
- `.value_counts()` on each categorical column — check for inconsistent entries
- Clean the Customers table:
- Impute missing income values using the median grouped by occupation
- Impute missing gender using mode
- Drop any rows where customer_id is missing (unfixable)
- Standardize location names to title case
- Clean the Policies table:
- Convert start_date and end_date to datetime
- Remove any ₹ or , characters from premium and convert to float
- Remove duplicate policy IDs keeping the first occurrence
- Clean the Claims table:
- Convert date columns to datetime
- Check claim_amount for outliers (values above the 99th percentile) and cap them
- Handle any missing fraud_flag values (fill with 0 = not fraud)
- Merge all tables into a single DataFrame and verify the merge (check row counts).
- Generate and print a data quality report for the final merged dataset.
- Save the cleaned merged dataset as `insurance_cleaned.csv`.
View Solution / Walkthrough
Complete Solution Code
This solution brings together all the code from sections 3–8 into a single pipeline. Run each cell in order in your Jupyter Notebook. The complete runnable script is also on GitHub — download it if you prefer to run it as one file.
# ============================================
# STEP 1: Load Data
# ============================================
import pandas as pd
import numpy as np
pd.set_option('display.max_columns', None)
DATA_PATH = 'data/'
customers = pd.read_csv(DATA_PATH + 'customers.csv')
policies = pd.read_csv(DATA_PATH + 'policies.csv')
claims = pd.read_csv(DATA_PATH + 'claims.csv')
print("Files loaded:")
print(f" Customers: {customers.shape}")
print(f" Policies: {policies.shape}")
print(f" Claims: {claims.shape}")
# ============================================
# STEP 2: Explore & Identify Issues
# ============================================
for name, df in [('Customers', customers), ('Policies', policies), ('Claims', claims)]:
print(f"\n{'='*40}")
print(f" {name} Table — Info")
print(f"{'='*40}")
print(df.info())
print(f"\nMissing values:\n{df.isnull().sum()}")
print(f"\nDuplicate rows: {df.duplicated().sum()}")
# ============================================
# STEP 3: Clean Customers
# ============================================
print("\n\n=== CLEANING CUSTOMERS ===")
# Drop rows with missing customer_id
before = len(customers)
customers = customers.dropna(subset=['customer_id'])
print(f"Dropped {before - len(customers)} rows with missing customer_id")
# Impute income: group median by occupation
customers['income'] = customers.groupby('occupation')['income']\
.transform(lambda x: x.fillna(x.median()))
# If any income still missing (occupations where ALL incomes are NaN), use global median
customers['income'].fillna(customers['income'].median(), inplace=True)
# Impute gender: mode
customers['gender'].fillna(customers['gender'].mode()[0], inplace=True)
# Standardize location
customers['location'] = customers['location'].str.strip().str.title()
print(f"Customers cleaned: {len(customers)} rows, {customers.isnull().sum().sum()} remaining nulls")
# ============================================
# STEP 4: Clean Policies
# ============================================
print("\n\n=== CLEANING POLICIES ===")
# Convert dates
policies['start_date'] = pd.to_datetime(policies['start_date'])
policies['end_date'] = pd.to_datetime(policies['end_date'])
# Clean premium (only if imported as text — our generated premium is already float)
if policies['premium'].dtype == 'object':
policies['premium'] = policies['premium']\
.str.replace('[₹,]', '', regex=True)\
.str.strip()\
.astype(float)
# Remove duplicate policy IDs
before = len(policies)
policies = policies.drop_duplicates(subset=['policy_id'], keep='first')
print(f"Removed {before - len(policies)} duplicate policy IDs")
print(f"Policies cleaned: {len(policies)} rows, {policies.isnull().sum().sum()} remaining nulls")
# ============================================
# STEP 5: Clean Claims
# ============================================
print("\n\n=== CLEANING CLAIMS ===")
# Convert dates (settlement delay is stored as numeric 'days_to_settle',
# not as a 'settlement_date' column)
claims['claim_date'] = pd.to_datetime(claims['claim_date'])
# Check claim_amount outliers
print(f"Claim amount before capping:")
print(claims['claim_amount'].describe())
cap = claims['claim_amount'].quantile(0.99)
claims['claim_amount'] = claims['claim_amount'].clip(upper=cap)
print(f"\nCapped outliers at 99th percentile: {cap:.2f}")
# Handle missing fraud_flag
claims['fraud_flag'] = claims['fraud_flag'].fillna(0).astype(int)
print(f"Claims cleaned: {len(claims)} rows, {claims.isnull().sum().sum()} remaining nulls")
# ============================================
# STEP 6: Merge Tables
# ============================================
print("\n\n=== MERGING TABLES ===")
# Merge customers → policies
policy_customer = policies.merge(customers, on='customer_id', how='left')
print(f"Policies + Customers merged: {len(policy_customer)} rows")
# Merge policy_customer → claims
full_data = claims.merge(policy_customer, on=['policy_id', 'customer_id'], how='left')
print(f"Full dataset merged: {len(full_data)} rows")
# Verify merge
claims_no_premium = full_data[full_data['premium'].isna()]
if len(claims_no_premium) > 0:
print(f"WARNING: {len(claims_no_premium)} claims have no matching policy")
print(claims_no_premium[['claim_id', 'policy_id']].head())
# Check for row explosion
if len(full_data) > len(claims):
print("WARNING: Merge created extra rows — check for duplicate keys in policies!")
elif len(full_data) == len(claims):
print("Row count verified: merge is clean.")
# ============================================
# STEP 7: Data Quality Report
# ============================================
print("\n\n=== DATA QUALITY REPORT ===")
report = pd.DataFrame({
'Column': full_data.columns,
'Dtype': full_data.dtypes.values,
'Nulls': full_data.isnull().sum().values,
'Null %': (full_data.isnull().mean() * 100).round(2).values,
'Unique': [full_data[c].nunique() for c in full_data.columns]
})
print(report.sort_values('Null %', ascending=False).to_string(index=False))
# ============================================
# STEP 8: Save Cleaned Data
# ============================================
full_data.to_csv(DATA_PATH + 'insurance_cleaned.csv', index=False)
print(f"\n\nSaved: data/insurance_cleaned.csv ({len(full_data):,} rows)")
Expected Output Summary
After running the solution, you should see:
- Customers: ~5,000 rows, 0 remaining nulls after imputation
- Policies: ~10,000 rows, dates converted, premium as float, no duplicate policy IDs
- Claims: ~20,000 rows, dates converted, outliers capped at 99th percentile, no missing fraud flags
- Merged: ~20,000 rows (one row per claim, same as original claims table — confirming a clean merge)
- Nulls in merged: Any remaining nulls are likely in columns from the policies table that don't exist for certain claims — investigate if this exceeds 1% of rows
3-2-1 Reflection — Before You Move On
Data cleaning is 60–80% of insurance analytics. Consolidate what you've learned — write it from memory, don't scroll back.
3 Things I Learned Today
2 Pandas Commands I Can Now Use From Memory
1 Question I Still Have About Data Cleaning
Key Takeaways
Insurance data comes from five categories — customer, policy, claims, external, and unstructured — each with different quality characteristics and cleaning requirements. The data is structurally messy because it originates from disconnected legacy systems.
Missing value handling is not a mechanical step — it requires judgment about the type of missingness (MCAR, MAR, MNAR) and the role of the column in downstream analysis. Never drop missing target variables (claim_amount, fraud_flag) without deliberate consideration.
Data cleaning in insurance requires converting dates from strings, stripping currency symbols from numbers, standardizing categorical values, and removing duplicates — but always with business context informing what is a true duplicate vs. a legitimate distinct record.
A left join (keep all claims, add policy data) is the correct merge strategy for insurance analytics. Always verify merge row counts — extra rows mean duplicate keys, fewer rows mean unmatched records were silently dropped.
A data quality report should be generated before any analysis — documenting null percentages, duplicates, outliers, and data types. This single discipline prevents more errors than any amount of careful modeling later.
Test Your Understanding
1. An insurance dataset has missing values in the 'income' column. Analysis shows that older policyholders are less likely to report their income, but the missingness is not related to income itself after controlling for age. This type of missingness is classified as:
2. After merging the Claims table (20,000 rows) with the Policies table using a left join on policy_id, the merged dataset has 20,004 rows. What is the most likely explanation?
3. A claims dataset has 1,500 rows with a missing fraud_flag. What is the recommended approach?
4. Which of the following is a correct Pandas idiom for imputing missing income values using the median income of customers in the same occupation group?
5. Why is inspecting .dtypes immediately after loading insurance data considered a critical first step in data cleaning?