Contents

Chapter 1

Deduplication Strategies Guide

A comprehensive guide to identifying and removing duplicate records in your data.

Covers exact matches, fuzzy matches, and business-rule-based deduplication.

Why Duplicates Matter

Duplicates silently corrupt every analysis they touch:

Impact AreaWhat Goes Wrong
Revenue metricsOrders counted twice inflate revenue by 2-15%
Customer countsSame person as 3 records = 3x inflated user base
Marketing spendSending 3 emails to the same person = wasted budget + spam complaints
ML modelsDuplicates in training data create biased predictions
A/B testsSame user in both variants invalidates experiment

Deduplication Decision Tree

Is there a natural unique key (email, SSN, order_id)?
├── YES → Exact deduplication on that key
│         └── Which record to keep?
│             ├── Most recent (last updated wins)
│             ├── Most complete (fewest nulls)
│             └── Merge fields (combine non-null values)
│
└── NO → Fuzzy matching required
          ├── High-confidence fields available? (name + address + DOB)
          │   ├── YES → Composite key matching
          │   │         └── Threshold: match on 3+ of 5 fields
          │   └── NO → Probabilistic matching
          │             └── Use Jaro-Winkler or Levenshtein distance
          │                 └── Threshold: similarity > 0.85
          └── Manual review queue for borderline cases (0.70 - 0.85)

Strategy 1: Exact Key Deduplication

When to use: You have a reliable unique identifier.

python
import pandas as pd

df = pd.read_csv("customers.csv")

# Count duplicates before removal
dupes = df.duplicated(subset=["customer_id"], keep=False)
print(f"Duplicate groups: {dupes.sum()} rows in {df[dupes]['customer_id'].nunique()} groups")

# Strategy A: Keep first occurrence (oldest record)
df_deduped = df.drop_duplicates(subset=["customer_id"], keep="first")

# Strategy B: Keep last occurrence (most recently updated)
df_deduped = df.drop_duplicates(subset=["customer_id"], keep="last")

# Strategy C: Keep the most complete record (fewest nulls)
df["_null_count"] = df.isnull().sum(axis=1)
df_sorted = df.sort_values("_null_count")
df_deduped = df_sorted.drop_duplicates(subset=["customer_id"], keep="first")
df_deduped = df_deduped.drop(columns=["_null_count"])

Strategy 2: Composite Key Deduplication

When to use: No single unique key, but combination of fields is unique.

python
# Define composite key: name + email domain + signup month
# This catches cases where the same person signed up twice with
# slightly different info
df["_composite_key"] = (
    df["first_name"].str.lower().str.strip() + "|" +
    df["last_name"].str.lower().str.strip() + "|" +
    df["email"].str.split("@").str[1].str.lower() + "|" +
    df["signup_date"].str[:7]  # Year-month
)

df_deduped = df.drop_duplicates(subset=["_composite_key"], keep="last")
df_deduped = df_deduped.drop(columns=["_composite_key"])

Strategy 3: Fuzzy Matching

When to use: Data has typos, inconsistent formatting, or partial matches.

python
from difflib import SequenceMatcher

def similarity(a: str, b: str) -> float:
    """Calculate string similarity ratio (0.0 to 1.0)."""
    if not a or not b:
        return 0.0
    return SequenceMatcher(None, a.lower(), b.lower()).ratio()

def find_fuzzy_duplicates(df, name_col="full_name", threshold=0.85):
    """
    Find potential duplicate pairs using fuzzy name matching.

    WARNING: O(n²) complexity. For large datasets (>10K rows),
    use blocking (pre-filter by first letter, zip code, etc.)
    to reduce comparison space.
    """
    potential_dupes = []
    names = df[name_col].tolist()
    indices = df.index.tolist()

    for i in range(len(names)):
        for j in range(i + 1, len(names)):
            sim = similarity(str(names[i]), str(names[j]))
            if sim >= threshold:
                potential_dupes.append({
                    "index_a": indices[i],
                    "index_b": indices[j],
                    "name_a": names[i],
                    "name_b": names[j],
                    "similarity": round(sim, 3),
                })

    return pd.DataFrame(potential_dupes)

Strategy 4: Blocking + Fuzzy (Scalable)

When to use: Large datasets where full pairwise comparison is too slow.

python
def deduplicate_with_blocking(df, block_col="zip_code", match_cols=None, threshold=0.80):
    """
    Scalable fuzzy deduplication using blocking.

    Blocking dramatically reduces comparisons:
    - 100K records with no blocking: 5 billion comparisons
    - 100K records with 1000 zip codes: ~5 million comparisons (1000x faster)

    Args:
        df: Input DataFrame
        block_col: Column to group records by (records only compared within same block)
        match_cols: Columns to use for similarity scoring
        threshold: Minimum average similarity to consider a match
    """
    if match_cols is None:
        match_cols = ["first_name", "last_name", "email"]

    duplicates_to_remove = set()

    for block_value, group in df.groupby(block_col):
        if len(group) < 2:
            continue

        indices = group.index.tolist()
        for i in range(len(indices)):
            if indices[i] in duplicates_to_remove:
                continue
            for j in range(i + 1, len(indices)):
                if indices[j] in duplicates_to_remove:
                    continue

                # Score similarity across multiple columns
                scores = []
                for col in match_cols:
                    val_a = str(group.loc[indices[i], col])
                    val_b = str(group.loc[indices[j], col])
                    scores.append(similarity(val_a, val_b))

                avg_score = sum(scores) / len(scores)
                if avg_score >= threshold:
                    # Keep the record with fewer nulls
                    nulls_i = group.loc[indices[i]].isnull().sum()
                    nulls_j = group.loc[indices[j]].isnull().sum()
                    remove_idx = indices[j] if nulls_i <= nulls_j else indices[i]
                    duplicates_to_remove.add(remove_idx)

    return df.drop(index=duplicates_to_remove)

Strategy 5: Record Linkage (Multi-Source)

When to use: Merging data from multiple systems (CRM + billing + support).

python
def link_records(df_a, df_b, match_fields, threshold=0.85):
    """
    Link records across two DataFrames from different source systems.

    Returns a mapping of df_a index -> df_b index for matched pairs.
    Unmatched records from either side are preserved (outer join behavior).

    Example:
        CRM has "John Smith, john@acme-corp.example.com"
        Billing has "J. Smith, jsmith@acme-corp.example.com"
        → These should link to the same entity
    """
    links = {}

    for idx_a, row_a in df_a.iterrows():
        best_match = None
        best_score = 0.0

        for idx_b, row_b in df_b.iterrows():
            scores = []
            for field_a, field_b, weight in match_fields:
                val_a = str(row_a.get(field_a, ""))
                val_b = str(row_b.get(field_b, ""))
                sim = similarity(val_a, val_b)
                scores.append(sim * weight)

            total_weight = sum(w for _, _, w in match_fields)
            weighted_score = sum(scores) / total_weight

            if weighted_score > best_score:
                best_score = weighted_score
                best_match = idx_b

        if best_score >= threshold:
            links[idx_a] = {"matched_index": best_match, "confidence": best_score}

    return links

# Usage:
# match_fields = [
#     ("email", "billing_email", 2.0),      # High weight: email is reliable
#     ("last_name", "surname", 1.5),         # Medium weight
#     ("first_name", "given_name", 1.0),     # Lower weight (more variable)
#     ("phone", "contact_number", 1.5),      # Medium weight
# ]
# links = link_records(crm_df, billing_df, match_fields)

Merge Strategies (After Matching)

Once you've identified duplicates, you need to decide what the "golden record" looks like:

Last-Write-Wins

python
# Simple: keep the most recently updated record
df_golden = df.sort_values("updated_at").drop_duplicates("customer_id", keep="last")

Most-Complete-Wins

python
# Keep the record with the most non-null values
df["_completeness"] = df.notna().sum(axis=1)
df_golden = df.sort_values("_completeness", ascending=False).drop_duplicates("customer_id", keep="first")

Field-Level Merge (Best of Each)

python
def merge_duplicate_group(group):
    """
    For each field, pick the best non-null value from the group.

    Priority rules:
    - Timestamps: most recent non-null
    - Numeric: maximum (or average, depending on business rule)
    - Text: longest non-null (more complete)
    - Email: first valid non-null
    """
    merged = {}
    for col in group.columns:
        non_null = group[col].dropna()
        if non_null.empty:
            merged[col] = None
        elif col.endswith("_date") or col.endswith("_at"):
            merged[col] = non_null.max()  # Most recent
        elif group[col].dtype in ("float64", "int64"):
            merged[col] = non_null.max()  # Highest value
        else:
            # Longest string (usually most complete)
            merged[col] = max(non_null.astype(str), key=len)
    return pd.Series(merged)

df_golden = df.groupby("customer_id").apply(merge_duplicate_group).reset_index(drop=True)

Performance Considerations

Dataset SizeStrategyExpected Time
< 10K rowsFull pairwise fuzzySeconds
10K - 100KBlocking + fuzzyMinutes
100K - 1MBlocking + sampling + fuzzy10-30 min
> 1MDedicated tool (dedupe library, Spark)Hours

Common Pitfalls

1. Case sensitivity: Always normalize case before comparing. "John" ≠ "john" in raw comparison.

2. Whitespace: Trim and collapse whitespace. " John Smith " should match "John Smith".

3. Null handling: Two NULL values should NOT be considered a match (NULL ≠ NULL in dedup logic).

4. Transitive duplicates: If A≈B and B≈C, then A≈C. Build connected components, not just pairs.

5. Temporal duplicates: Same person, different time periods, might be legitimate (re-subscriber).

Verification Checklist

After deduplication, verify:

  • [ ] Row count decreased by expected percentage (typically 1-10% for clean sources, up to 30% for merged sources)
  • [ ] No legitimate distinct records were merged (spot-check 20 random removals)
  • [ ] Key metrics (revenue, user count) changed by explainable amounts
  • [ ] Downstream joins still work (no orphaned foreign keys)
  • [ ] Audit log captures what was removed and why

Part of Data Analyst Toolkit

Chapter 2

Missing Data Playbook

A decision framework for handling missing values in analytical datasets.

Covers detection, diagnosis, mechanism classification, and imputation strategies.

Why Missing Data Strategy Matters

The wrong approach to missing data introduces systematic bias:

Naive ApproachWhat Goes Wrong
Drop all rows with any NaNLose 30-60% of data; biased toward "complete" records
Fill everything with meanReduces variance; hides patterns; creates false precision
Fill with 0Corrupts numeric analysis (0 ≠ missing)
Ignore itModels crash, aggregations are wrong, joins silently drop rows

Step 1: Assess the Damage

Before choosing a strategy, understand the scale and pattern of missingness.

python
import pandas as pd
import numpy as np


def missing_data_report(df: pd.DataFrame) -> pd.DataFrame:
    """
    Generate a comprehensive missing data report.

    Returns a DataFrame with one row per column showing:
    - Count and percentage of missing values
    - Data type (to inform imputation strategy)
    - Number of unique values (helps distinguish categorical vs continuous)
    """
    report = pd.DataFrame({
        "column": df.columns,
        "dtype": df.dtypes.values,
        "missing_count": df.isnull().sum().values,
        "missing_pct": (df.isnull().sum() / len(df) * 100).values,
        "unique_values": df.nunique().values,
        "sample_values": [df[col].dropna().head(3).tolist() for col in df.columns],
    })

    report = report.sort_values("missing_pct", ascending=False)
    report["missing_pct"] = report["missing_pct"].round(1)

    return report


def missing_pattern_matrix(df: pd.DataFrame) -> pd.DataFrame:
    """
    Show co-occurrence patterns of missing values.

    If columns A and B are always missing together, they likely have
    the same root cause (e.g., a form section that users skip).
    """
    missing_mask = df.isnull().astype(int)
    # Each unique pattern of missingness
    patterns = missing_mask.drop_duplicates()
    pattern_counts = missing_mask.groupby(list(df.columns)).size().reset_index(name="count")
    pattern_counts = pattern_counts.sort_values("count", ascending=False)
    return pattern_counts

Step 2: Classify the Mechanism

Missing data falls into three categories. The category determines valid strategies:

MCAR: Missing Completely At Random

Definition: Missingness has NO relationship to any variable (observed or unobserved).

Example: A lab machine randomly fails 2% of the time.

Test: Little's MCAR test, or compare distributions of observed vs. complete cases.

Good news: Any imputation method works. No bias introduced.

MAR: Missing At Random

Definition: Missingness is explained by OTHER observed variables.

Example: Younger users skip the "income" field more often. Age is observed, so missingness is predictable.

Test: Check if missingness of column X correlates with values in columns Y, Z.

Strategy: Use the correlated variables to inform imputation.

MNAR: Missing Not At Random

Definition: Missingness depends on the MISSING VALUE ITSELF.

Example: High-income people refuse to report income. People with bad health skip health surveys.

Test: Cannot be statistically confirmed (the missing values are unknown).

Strategy: Domain knowledge, sensitivity analysis, or model-based approaches.

python
def classify_missingness(df: pd.DataFrame, target_col: str) -> dict:
    """
    Heuristic classification of missingness mechanism.

    Tests whether missingness in target_col correlates with other columns.
    If correlations are found → likely MAR.
    If no correlations found → possibly MCAR (or MNAR, which can't be tested).

    Returns dict with correlations and suggested mechanism.
    """
    is_missing = df[target_col].isnull().astype(int)

    correlations = {}
    for col in df.columns:
        if col == target_col:
            continue
        if df[col].dtype in ("float64", "int64"):
            # Point-biserial correlation between missingness indicator and numeric column
            valid_mask = df[col].notna()
            if valid_mask.sum() > 10:
                corr = is_missing[valid_mask].corr(df[col][valid_mask])
                if abs(corr) > 0.1:
                    correlations[col] = round(corr, 3)

    if not correlations:
        mechanism = "MCAR (no correlations found) or MNAR (untestable)"
    elif max(abs(v) for v in correlations.values()) > 0.3:
        mechanism = "MAR (strong correlations with other variables)"
    else:
        mechanism = "Possibly MAR (weak correlations detected)"

    return {
        "target_column": target_col,
        "missing_pct": round(is_missing.mean() * 100, 1),
        "correlated_columns": correlations,
        "suggested_mechanism": mechanism,
    }

Step 3: Choose a Strategy

Strategy Decision Matrix

Missing %MechanismColumn ImportanceRecommended Strategy
< 5%MCARAnyListwise deletion (drop rows)
< 5%MARHighSimple imputation (median/mode)
5-20%MCARMediumMean/median imputation
5-20%MARHighConditional imputation or regression
20-50%MARCriticalMultiple imputation or model-based
20-50%MARLowDrop the column entirely
> 50%AnyLowDrop the column
> 50%AnyHighFlag as separate category + keep

Strategy 1: Listwise Deletion (Drop Rows)

python
def drop_missing_rows(df: pd.DataFrame, columns: list[str], threshold: float = 0.5) -> pd.DataFrame:
    """
    Drop rows where critical columns are missing.

    Only use when:
    - Missing % is low (< 5%)
    - Missingness is MCAR
    - You have plenty of data remaining

    Args:
        df: Input DataFrame
        columns: Columns to check for missingness
        threshold: Drop row if more than this fraction of specified columns are null

    Returns:
        DataFrame with rows removed
    """
    null_fraction = df[columns].isnull().sum(axis=1) / len(columns)
    mask = null_fraction <= threshold
    dropped = (~mask).sum()
    print(f"Dropping {dropped} rows ({dropped/len(df):.1%}) with >{threshold:.0%} missing in {columns}")
    return df[mask].copy()

Strategy 2: Simple Imputation

python
def impute_simple(df: pd.DataFrame, strategies: dict[str, str]) -> pd.DataFrame:
    """
    Apply simple imputation strategies per column.

    Strategies:
        - "mean": Replace with column mean (numeric, symmetric distributions)
        - "median": Replace with column median (numeric, skewed distributions)
        - "mode": Replace with most frequent value (categorical)
        - "zero": Replace with 0 (when absence = zero, e.g., count of purchases)
        - "unknown": Replace with "Unknown" string (categorical, explicit missing category)
        - "ffill": Forward fill (time series, carry last known value)
        - "bfill": Backward fill (time series, use next known value)

    Args:
        df: Input DataFrame
        strategies: Dict mapping column name -> strategy name

    Returns:
        DataFrame with nulls filled
    """
    df = df.copy()

    for col, strategy in strategies.items():
        if col not in df.columns:
            continue

        null_count = df[col].isnull().sum()
        if null_count == 0:
            continue

        if strategy == "mean":
            df[col] = df[col].fillna(df[col].mean())
        elif strategy == "median":
            df[col] = df[col].fillna(df[col].median())
        elif strategy == "mode":
            mode_val = df[col].mode().iloc[0] if not df[col].mode().empty else "Unknown"
            df[col] = df[col].fillna(mode_val)
        elif strategy == "zero":
            df[col] = df[col].fillna(0)
        elif strategy == "unknown":
            df[col] = df[col].fillna("Unknown")
        elif strategy == "ffill":
            df[col] = df[col].ffill()
        elif strategy == "bfill":
            df[col] = df[col].bfill()

        print(f"  {col}: filled {null_count} nulls with {strategy}")

    return df


# Example usage:
# strategies = {
#     "age": "median",           # Skewed, use median not mean
#     "income": "median",        # Skewed right
#     "gender": "mode",          # Categorical
#     "signup_source": "unknown", # Explicit missing category
#     "purchase_count": "zero",  # No purchases = 0 purchases
#     "temperature": "ffill",    # Time series: last known value
# }
# df_clean = impute_simple(df, strategies)

Strategy 3: Conditional Imputation (Group-Based)

python
def impute_conditional(
    df: pd.DataFrame,
    target_col: str,
    group_cols: list[str],
    method: str = "median",
) -> pd.DataFrame:
    """
    Impute missing values using group-specific statistics.

    Instead of using the global median, uses the median within each group.
    This handles MAR data where missingness correlates with group membership.

    Example:
        Missing income → impute with median income for same age_group + education_level
        (Much better than global median because a 25-year-old with a PhD has different
        expected income than a 55-year-old with a high school diploma)

    Args:
        df: Input DataFrame
        target_col: Column to impute
        group_cols: Columns to group by for conditional statistics
        method: "median" or "mean"
    """
    df = df.copy()

    if method == "median":
        fill_values = df.groupby(group_cols)[target_col].transform("median")
    else:
        fill_values = df.groupby(group_cols)[target_col].transform("mean")

    # Fill with group statistic where available
    null_mask = df[target_col].isnull()
    df.loc[null_mask, target_col] = fill_values[null_mask]

    # Fall back to global statistic for groups that are entirely null
    still_null = df[target_col].isnull().sum()
    if still_null > 0:
        global_fill = df[target_col].median() if method == "median" else df[target_col].mean()
        df[target_col] = df[target_col].fillna(global_fill)
        print(f"  {still_null} values fell back to global {method}")

    return df

Strategy 4: Missing Indicator Pattern

python
def add_missing_indicators(df: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
    """
    Add binary indicator columns for missingness BEFORE imputing.

    This preserves the information that a value was missing, which
    may be predictive in itself (e.g., people who skip income question
    tend to have different behavior patterns).

    Always add indicators BEFORE imputation so the model can learn
    from the missingness pattern.

    Args:
        df: Input DataFrame
        columns: Columns to create indicators for

    Returns:
        DataFrame with added {col}_missing columns
    """
    df = df.copy()
    for col in columns:
        if col in df.columns:
            indicator_col = f"{col}_was_missing"
            df[indicator_col] = df[col].isnull().astype(int)
    return df

Strategy 5: Categorical "Unknown" Bucket

python
def handle_categorical_missing(df: pd.DataFrame, cat_columns: list[str]) -> pd.DataFrame:
    """
    For categorical columns, treat missing as its own category.

    This is often the BEST strategy for categorical data because:
    1. "Unknown" is meaningful (customer chose not to answer)
    2. Imputing mode creates false certainty
    3. Models can learn that "Unknown" has its own behavior pattern

    For boolean-like columns, you might want "Unknown" as a third state
    rather than forcing True/False.
    """
    df = df.copy()
    for col in cat_columns:
        if col in df.columns:
            null_count = df[col].isnull().sum()
            if null_count > 0:
                df[col] = df[col].fillna("Unknown")
                print(f"  {col}: {null_count} nulls → 'Unknown' category")
    return df

Anti-Patterns to Avoid

1. Imputing After Splitting (Data Leakage)

python
# WRONG: Statistics computed on full dataset leak test info into training
df_imputed = df.fillna(df.mean())  # Uses ALL data for mean
train, test = train_test_split(df_imputed)

# CORRECT: Compute statistics on training set only, apply to both
train, test = train_test_split(df)
train_means = train.mean()
train_filled = train.fillna(train_means)
test_filled = test.fillna(train_means)  # Use TRAINING means on test

2. Ignoring Missing Pattern Before Joins

python
# WRONG: Inner join silently drops rows where join key is null
merged = orders.merge(customers, on="customer_id")  # Loses null customer_id orders

# CORRECT: Explicitly handle null keys before joining
orphan_orders = orders[orders["customer_id"].isnull()]
print(f"WARNING: {len(orphan_orders)} orders have no customer_id — excluded from join")
merged = orders.dropna(subset=["customer_id"]).merge(customers, on="customer_id")

3. Mean Imputation on Skewed Data

python
# For income data: mean = $85,000, median = $52,000
# Mean is pulled up by high earners — bad imputation value for typical person

# WRONG for skewed data:
df["income"] = df["income"].fillna(df["income"].mean())  # $85K — too high for most

# CORRECT for skewed data:
df["income"] = df["income"].fillna(df["income"].median())  # $52K — better central tendency

Validation After Imputation

Always verify that imputation didn't corrupt your data:

python
def validate_imputation(df_before: pd.DataFrame, df_after: pd.DataFrame, columns: list[str]):
    """
    Compare distributions before and after imputation to catch problems.

    Red flags:
    - Mean shifted by more than 10%
    - Variance decreased by more than 20% (over-smoothing)
    - New values outside original range (impossible values created)
    """
    print("=" * 60)
    print("IMPUTATION VALIDATION REPORT")
    print("=" * 60)

    for col in columns:
        if col not in df_before.columns:
            continue

        before = df_before[col].dropna()
        after = df_after[col].dropna()

        if before.dtype in ("float64", "int64"):
            mean_shift = abs(after.mean() - before.mean()) / before.mean() * 100
            var_change = (after.var() - before.var()) / before.var() * 100

            status = "OK"
            if mean_shift > 10:
                status = "WARNING: Mean shifted >10%"
            if var_change < -20:
                status = "WARNING: Variance decreased >20%"

            print(f"\n  {col}:")
            print(f"    Mean:  {before.mean():.2f} → {after.mean():.2f} ({mean_shift:+.1f}%)")
            print(f"    Std:   {before.std():.2f} → {after.std():.2f}")
            print(f"    Range: [{before.min():.2f}, {before.max():.2f}] → [{after.min():.2f}, {after.max():.2f}]")
            print(f"    Status: {status}")

Quick Reference: Strategy by Data Type

Data TypeBest StrategyFallback
Continuous numeric (normal)Mean or conditional meanMedian
Continuous numeric (skewed)Median or conditional medianMode of binned values
Categorical (few levels)Mode or "Unknown" bucketMost frequent
Categorical (many levels)"Unknown" bucketGroup by related column
BooleanMode (if >90% one value) or "Unknown"Three-state: True/False/Unknown
Date/timeForward fill or interpolationDrop row
Free text"Not provided" stringDrop column if >50% missing
ID/key columnsNEVER impute — drop the rowFlag for investigation

Part of Data Analyst Toolkit

Chapter 3
🔒 Available in full product

Outlier Detection Guide

You’ve reached the end of the free preview

Get the full Data Cleaning Playbook and unlock everything.

All Chapters

Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.

Full Tool Suite

Access all interactive tools with complete data, all workload profiles, and the full scenario library.

Source Files

Downloadable source code, configuration files, and working examples from every chapter.

Lifetime Updates

Free updates for life. Every new chapter, tool, and improvement included.

Buy Now — $19 →
📦 Free sample included — download another copy for the full product.
Data Cleaning Playbook v1.0.0 — Free Preview