Module 2 · Session 05 · 90 min · Python Lab

Session 05: Insurance Data Sources & Cleaning

CILO-2 · Analytics Tools · Lab-based, Hands-on (Python) · Jupyter Notebook required

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

CategoryDescriptionExamplesTypical 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.

🌎
Real World: A leading Indian health insurer discovered during a data quality audit that 8% of its claims records had mismatched customer IDs between the claims system and the policy system. This meant that when the claims analytics team tried to join claims to policies to calculate loss ratios by product, 8% of claims dropped out of the join — producing subtly wrong loss ratios for every product. The error was invisible to management because the numbers "looked reasonable" — they were just wrong by 8%. Fixing the data quality issue required tracing and reconciling 40,000+ records manually over 3 months. The lesson: data quality problems compound silently.

🔍 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 ItemCategory
1Policyholder's age, income, and credit score.
2Policy start/end dates and the premium amount.
3The claim settlement amount and days-to-settle.
4A surveyor's photo of a damaged car.
5The latest IMD rainfall data for a coastal district.
6The customer's occupation and KYC status.
7The claim description text written by the policyholder.
8The distribution channel and product name.

Reflection: Which category is hardest to clean, and why? Write your answer.

Check Your Classification
  1. Customer — demographics and financial profile of the policyholder.
  2. Policy — contract details of the insurance cover.
  3. Claims — records of losses and their settlement.
  4. Unstructured — photos require computer vision, not tabular cleaning.
  5. External — weather data comes from outside the insurer (IMD).
  6. Customer — occupation and KYC are part of the customer profile.
  7. Unstructured — free text requires NLP to extract structured meaning.
  8. 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.

📋 Stable content — Reviewed: July 2026

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:

TableRecordsWhat It ContainsPrimary KeyForeign 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)
📝
Where do these files come from? The six CSVs are generated by a single Python script — 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)
The rest of the course uses the exact same files.

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

Important: This generator is the single source of truth for the dataset. Run it once before the lab — do not regenerate mid-course or the keys will not match earlier exercises. Because the seed is fixed at 42, every student who runs it produces byte-identical files, so results can be compared across the class.

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.

  1. Can one customer hold more than one policy? How do you know from the diagram?
  2. Can one policy generate more than one claim? How do you know?
  3. Which column would you use to join Claims to Policy?
  4. Which table has NO direct relationship to the others? What is it used for?
  5. If a claim's policy_id does not exist in the Policy table, what does that indicate about data quality?
Check Your Answers
  1. 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.
  2. 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).
  3. 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.)
  4. 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.
  5. 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()}")
📝
Note: If you encounter a FileNotFoundError when loading the CSVs, check that the files are in the correct folder and that your Jupyter Notebook's working directory is set to the project root. Use `import os; print(os.getcwd())` to verify your current directory.

👀 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.

CommandWhat do you expect it to return?Purpose
customers.shape
customers.head(3)
customers.info()
customers.isnull().sum()
customers['gender'].value_counts()
Check Your Predictions
CommandReturnsPurpose
customers.shapeA tuple like (5000, 12) — (rows, columns)Count of rows & columns
customers.head(3)The first 3 rows of the DataFrame as a tableFirst few rows
customers.info()A summary listing each column, its data type, and how many non-null (non-missing) values it hasData types + non-null counts
customers.isnull().sum()A Series with one row per column showing the total count of missing (NaN) valuesMissing values per column
customers['gender'].value_counts()A Series showing each unique gender value and how many customers have itUnique 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)
Warning: Never drop rows with missing 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).

#ScenarioMissingness TypeBest Strategy
1Random data-entry gaps in the age column — no pattern at all.
2Older policyholders are less likely to report income; missingness depends on age.
3Policyholders with very low credit scores refuse to share them — missingness is caused by the score itself.
4claim_amount is missing on claims still awaiting settlement.
5One month of a monthly claims time series is missing.
6gender is missing on only 2% of records.
Check Your Answers
#MissingnessStrategyWhy
1MCARMedian imputation (or drop rows if very few)Truly random — imputation introduces no bias. Median is safer than mean for skewed data.
2MARMedian imputation grouped by age bandMissingness is explained by age, so impute using the median of the same age group, not the whole dataset.
3MNARFlag & investigateThe missingness is caused by the value itself — imputing would systematically understate the true risk. Flag these records and investigate separately.
4MAR / structuralFlag & 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.
5Structural gapForward-fillTime-series gap — the missing month most likely resembles the previous month. Use ffill.
6MCARMode imputationOnly 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()}")
💡
Pro Tip: Create a reusable "data cleaning dictionary" — a Python dictionary that maps raw values to standard values — and save it as a JSON file. When new data arrives, apply the same mapping. This ensures consistency over time and across analysts. A good data cleaning dictionary is reusable project documentation, not just a script that runs once.

🔍 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 valuesWhat is wrong?Fix (Pandas)
1premium = "₹12,500", "₹8,750", "10,000"
2claim_date = "2026-07-15" stored as an object (string)
3policy_type = "Motor", "motor", "MOTOR Insurance"
4claim_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
#ProblemFix
1Premium 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.
2Dates stored as strings — you cannot sort by date or compute time differences.policies['claim_date'] = pd.to_datetime(policies['claim_date'])
3Categorical 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").
4A 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')
📝
Note: In insurance, a "duplicate" policy where the same customer has the same policy type and same start date may indicate a data entry error — or it may mean the customer bought a second vehicle on the same day. Only domain knowledge (or a phone call to the customer) can definitively resolve this. When in doubt, flag rather than delete. Lost data cannot be recovered.

🧮 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.

#ScenarioVerdictReason
1The same claim_id appears twice with identical data.
2A customer has two motor policies with the same policy_type but different start_date.
3The same policy_id appears twice with slightly different premiums.
4The same customer_id appears twice with different income values.
5Two different customers share the same location and occupation but different IDs.
Check Your Answers
  1. True duplicate — remove. A claim_id should be unique. Identical data = system error (entered twice). Remove duplicates on claim_id.
  2. 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.
  3. True duplicate — remove. A policy_id should be unique; two rows with the same ID and different premiums = one is wrong. Keep the first (or reconcile with the source).
  4. 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.
  5. 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!")
💡
Pro Tip: Always use a 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.

#GoalJoin TypePredicted Row Count
1Keep ALL claims, add policy details where available.
2Keep only claims that have a matching policy.
3A 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
#JoinRow countWhy
1leftExactly 20,000Left 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.
2inner≤ 20,000Inner join keeps only matches — any claim without a policy drops out.
3left (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")
💡
Pro Tip: A data quality report should be generated and reviewed before any analysis begins. A rule of thumb used by experienced insurance data scientists: for every dataset, run the quality report, fix any issues where null % exceeds 10% or where outliers are suspicious, document what was done, and only then proceed to analysis. This single discipline prevents more errors than any amount of careful modeling later.

📊 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.

ColumnNull %DuplicatesOutliersYour Priority
claim_amount12%0Yes (entry errors)
income22%0No
policy_id0%2%No
policy_type0%08% inconsistent categories
credit_score0.5%0No

Question: Which issue do you fix FIRST, and why? Write your answer in one or two lines.

Check Your Priorities
ColumnPriorityWhy
claim_amountCRITICAL — fix first12% 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_idHIGH2% duplicates. Fix before merging — duplicate keys cause row explosion and wrong aggregates. Dedupe now to save hours later.
incomeHIGH22% 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_typeMEDIUM8% inconsistent categories distort any grouping. Standardise with the cleaning dictionary — quick and safe.
credit_scoreLOW0.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

  1. 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.
  2. 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
  3. 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
  4. 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
  5. 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)
  6. Merge all tables into a single DataFrame and verify the merge (check row counts).
  7. Generate and print a data quality report for the final merged dataset.
  8. 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

1

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.

2

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.

3

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.

4

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.

5

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?