Build a Fraud Risk Detection System
Detect payment fraud using Benford's Law, Z-scores, and ML.
Introduction
30 Second Summary
Every year, organizations lose billions to fraudulent payments that slip through manual review. The patterns are there in the data, but spotting them by hand across thousands of transactions is nearly impossible.
In this project, you will build a Python fraud detection system that automatically scans operational payment data, flags suspicious transactions using three different techniques, and produces a professional risk report ready for a management team.
What You'll Build
A complete fraud risk detection pipeline that generates synthetic payment data, applies statistical and machine learning detection methods, scores transactions by risk level, and exports formatted reports and interactive visualizations.
By the end of this project, you'll have:
- A synthetic data generator that produces realistic vendor payment records with injected fraud patterns.
- A fraud analysis script that applies Benford's Law, Z-score anomaly detection, and Isolation Forest machine learning to flag suspicious transactions.
- A formatted Excel risk report with conditional formatting, dynamic formulas, and actionable recommendations alongside interactive Plotly visualizations.
- Secret Mission: Build an interactive Tableau dashboard from your scored data with filters, risk breakdowns, and storytelling features.
Are there any prerequisites?
You should have Python 3.11+ installed, VS Code ready, and Tableau Public (or Desktop) available. Basic ability to read Python code is helpful, but you do not need prior data science experience.
Before We Start
Before We Start...
Set Up Your Project
Before building the fraud detection system, you need the right tools in place. Python has powerful libraries for statistical analysis, machine learning, and data visualization that make automated fraud detection possible.
In this step, you'll install seven Python libraries, create your project folder with starter files, and verify everything is working together.
In this step, get ready to:
- Install Python libraries for statistics, machine learning, and visualization.
- Create the project folder with two starter Python files.
- Verify all libraries are accessible from Python.
Install the required Python libraries
Your fraud detection system depends on seven libraries. Each handles a different part of the pipeline, from data manipulation to machine learning to charting.
- Open a terminal in VS Code by pressing Ctrl+` (the backtick key, above Tab on your keyboard).
- Install all required libraries by running this command:
pip install pandas==3.0.3 numpy==2.4.6 scipy==1.17.1 scikit-learn==1.9.0 matplotlib==3.11.0 plotly==6.8.0 openpyxl==3.1.5
What are these libraries?
- pandas handles tabular data (like spreadsheets) in Python.
- numpy provides fast math operations on arrays of numbers.
- scipy adds statistical functions like Z-score calculations.
- scikit-learn is a machine learning library with anomaly detection algorithms.
- matplotlib creates static charts and graphs.
- plotly creates interactive, browser-based visualizations.
- openpyxl reads and writes formatted Excel files.
- Wait for the installation to complete. You should see Successfully installed messages for each package in your terminal output.
Seeing errors during installation?
- If you see command not found for pip, try pip3 instead. Some systems use pip3 for Python 3.
- If packages fail to install with version errors, your Python version may be below 3.11. Run python --version to check. These libraries require Python 3.11 or higher.
- If you see permission errors, try adding --user to the end of the pip install command.
Still stuck? Help me troubleshoot my pip install errors for pandas, numpy, scipy, scikit-learn, matplotlib, plotly, and openpyxl.
Create the project folder and files
Now you need a dedicated folder to keep all your fraud detection scripts organized. You'll create the folder on your Desktop so it's easy to find.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir fraud-detection
cd fraud-detection
- Create the two starter Python files by running this command:
touch generate_data.py fraud_analysis.py
On Windows?
If touch doesn't work in your terminal, run this instead:
New-Item generate_data.py, fraud_analysis.py
PowerShell uses New-Item to create empty files.
- Confirm the files exist by running:
ls
- You should see generate_data.py and fraud_analysis.py in the output.
- Now open this folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the fraud-detection folder, and click Open.
VS Code reloads with your project folder open. You should see both files in the Explorer sidebar on the left.
Don't see the files in Explorer?
- Make sure you selected the fraud-detection folder itself, not the Desktop. The Explorer should show FRAUD-DETECTION at the top.
- If the Explorer panel is hidden, press Ctrl+Shift+E (Windows) or Cmd+Shift+E (macOS) to toggle it open.
Still need help? Help me open my fraud-detection folder in VS Code and find my Python files.
Verify the installation
One quick command confirms that Python can find and load all seven libraries. This catches any installation issues before you start writing code.
- Open a new terminal in VS Code by pressing Ctrl+` (the backtick key).
- Verify all libraries are installed by running this command:
python -c "import pandas, numpy, scipy, sklearn, matplotlib, plotly, openpyxl; print('All libraries installed successfully')"
- You should see All libraries installed successfully printed in your terminal with no errors.
Seeing ModuleNotFoundError?
- If python is not recognized, try python3 instead. macOS often uses python3 as the command name.
- If a specific module is not found, re-run the pip install command from substep 1 to install the missing package.
- Make sure you used the same Python version for pip install and python -c. If you installed with pip3, verify with python3 -c.
Still stuck? Help me fix my Python library import errors for the fraud detection project.
Your environment is ready. Next up, you'll build a synthetic data generator that creates realistic vendor payments with hidden fraud patterns baked in.
Generate Synthetic Operational Data
Your development environment is ready. Now it's time to tackle the first real challenge: where does fraud detection data come from?
In the real world, fraud analysts work with messy payment records from multiple systems. For this project, you'll build a synthetic data generator that produces realistic vendor payments and expense claims with known fraud patterns injected so you can validate your detection algorithms later.
In this step, get ready to:
- Build a vendor payment generator with four distinct fraud patterns.
- Build an expense claim generator with round-number fraud injection.
- Run the script and export two CSV files for analysis.
Write the vendor payment generator
Real financial transactions follow a lognormal distribution, where most amounts are small but a few are very large. This distribution also naturally follows Benford's Law, which your detection algorithm will test for in the next step.
- In VS Code, click on generate_data.py in the Explorer sidebar to open it.
- Copy the following code into the file to create the imports and the generate_vendor_payments() function:
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
def generate_vendor_payments(n_records=1500, fraud_rate=0.08):
"""Generate synthetic vendor payment data with injected fraud patterns."""
rng = np.random.default_rng(42)
vendors = [f"VND-{i:04d}" for i in range(1, 51)]
programmes = ["Education", "Health", "WASH", "Protection", "Livelihoods"]
payment_methods = ["Bank Transfer", "Mobile Money", "Cash", "Cheque"]
start_date = datetime(2024, 1, 1)
dates = [start_date + timedelta(days=int(d)) for d in rng.integers(0, 540, n_records)]
legitimate_amounts = rng.lognormal(mean=7.5, sigma=1.2, size=n_records)
legitimate_amounts = np.round(legitimate_amounts, 2)
n_fraud = int(n_records * fraud_rate)
fraud_indices = rng.choice(n_records, n_fraud, replace=False)
amounts = legitimate_amounts.copy()
fraud_labels = np.zeros(n_records, dtype=int)
for i, idx in enumerate(fraud_indices):
pattern = i % 4
if pattern == 0:
amounts[idx] = rng.choice([5000, 10000, 15000, 20000, 25000, 50000])
elif pattern == 1:
amounts[idx] = rng.uniform(9500, 9999)
elif pattern == 2:
source_idx = rng.integers(0, n_records)
amounts[idx] = amounts[source_idx]
else:
amounts[idx] = rng.uniform(80000, 150000)
fraud_labels[idx] = 1
df = pd.DataFrame({
"transaction_id": [f"TXN-{i:05d}" for i in range(1, n_records + 1)],
"date": dates,
"vendor_id": rng.choice(vendors, n_records),
"programme": rng.choice(programmes, n_records),
"amount": np.round(amounts, 2),
"payment_method": rng.choice(payment_methods, n_records),
"is_fraud": fraud_labels
})
return df
What does this code do?
The function creates 1500 synthetic vendor payment records that mimic real operational data.
- numpy.random.default_rng(42) creates a seeded random number generator so everyone gets the same "random" data. The seed 42 makes results reproducible.
- rng.lognormal(mean=7.5, sigma=1.2) generates amounts that follow a lognormal distribution. Most payments cluster around smaller values, with a long tail of large payments.
- The fraud injection loop cycles through four patterns: round-number manipulation (exactly $5,000 or $10,000), just-below-threshold amounts ($9,500-$9,999), duplicate amounts copied from other records, and unusually large payments ($80,000-$150,000).
- The is_fraud column marks which records were tampered with, so you can validate detection accuracy later.
Why use synthetic data instead of a public dataset?
No public dataset matches the specific context of NGO vendor payments and programme expenditure. Synthetic data lets you inject known fraud patterns and verify that your detection algorithms actually catch them.
The lognormal distribution is important because real financial data naturally follows it. This means the legitimate records will also satisfy Benford's Law, making the injected fraud patterns stand out when tested.
- Save generate_data.py.
You won't see any output yet. The next substep adds the second data generator, and then you'll make the script runnable.
Write the expense claims generator
Expense claims are a separate data source with their own fraud signature. Staff expense fraud typically shows up as suspiciously round numbers rather than the varied patterns seen in vendor payments.
- In generate_data.py, add the following function below the generate_vendor_payments() function:
def generate_expense_claims(n_records=800, fraud_rate=0.10):
"""Generate synthetic expense claim data."""
rng = np.random.default_rng(123)
staff = [f"EMP-{i:03d}" for i in range(1, 31)]
categories = ["Travel", "Accommodation", "Meals", "Supplies", "Communication", "Transport"]
offices = ["Nairobi", "Kampala", "Mogadishu", "Juba", "Addis Ababa"]
start_date = datetime(2024, 1, 1)
dates = [start_date + timedelta(days=int(d)) for d in rng.integers(0, 540, n_records)]
amounts = rng.lognormal(mean=4.5, sigma=0.9, size=n_records)
amounts = np.round(amounts, 2)
n_fraud = int(n_records * fraud_rate)
fraud_indices = rng.choice(n_records, n_fraud, replace=False)
fraud_labels = np.zeros(n_records, dtype=int)
for idx in fraud_indices:
amounts[idx] = rng.choice([100, 200, 250, 500, 750, 1000])
fraud_labels[idx] = 1
df = pd.DataFrame({
"claim_id": [f"EXP-{i:04d}" for i in range(1, n_records + 1)],
"date": dates,
"staff_id": rng.choice(staff, n_records),
"office": rng.choice(offices, n_records),
"category": rng.choice(categories, n_records),
"amount": amounts,
"is_fraud": fraud_labels
})
return df
What does this code do?
This function creates 800 expense claim records with a 10% fraud rate.
- The lognormal parameters (mean=4.5, sigma=0.9) produce smaller amounts than vendor payments, matching typical expense claim ranges.
- The fraud pattern here is simpler: amounts are replaced with exact round numbers like $100, $250, or $1,000. In real expense fraud, employees often fabricate claims in clean round figures rather than natural-looking amounts like $47.83.
- A separate seed (123) ensures this dataset is independent from the vendor payments.
- Save generate_data.py.
You won't see output yet. The next chunk adds the runner that exports both datasets to CSV.
Add the main block and run the script
The __main__ block ties everything together. It calls both generator functions and saves the results as CSV files that your fraud analysis script will consume in the next step.
- At the bottom of generate_data.py, add the following code below the generate_expense_claims() function:
if __name__ == "__main__":
print("Generating synthetic operational data...")
vendor_payments = generate_vendor_payments()
expense_claims = generate_expense_claims()
vendor_payments.to_csv("vendor_payments.csv", index=False)
expense_claims.to_csv("expense_claims.csv", index=False)
print(f"Generated {len(vendor_payments)} vendor payment records")
print(f"Generated {len(expense_claims)} expense claim records")
print("Files saved: vendor_payments.csv, expense_claims.csv")
What does this code do?
- if __name__ == "__main__": ensures this code only runs when you execute the file directly (not when it's imported by another script).
- to_csv("vendor_payments.csv", index=False) saves the DataFrame to a CSV file without adding a numeric row index column.
- The print statements give you a progress report confirming how many records were generated.
- Save generate_data.py.
- Run the script in your terminal by running this command:
python generate_data.py
- You should see this output in your terminal:
Generating synthetic operational data...
Generated 1500 vendor payment records
Generated 800 expense claim records
Files saved: vendor_payments.csv, expense_claims.csv
- Confirm the CSV files were created by running:
ls *.csv
You should see vendor_payments.csv and expense_claims.csv in the output.
Not seeing the expected output?
- If you see ModuleNotFoundError, one of the libraries did not install correctly. Re-run the pip install command from Step 1.
- If you see IndentationError, check that all indentation uses 4 spaces consistently. Python is strict about whitespace.
- If you see SyntaxError, look for missing colons at the end of def, for, or if lines.
Still stuck? Help me debug my generate_data.py script
✔️ Awesome, I've got everything!
Great. Double check you've saved generate_data.py and that both CSV files appear in your fraud-detection folder.
ⓧ I'd like to double check the full code
Here is the complete generate_data.py file. Compare yours against it:
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
def generate_vendor_payments(n_records=1500, fraud_rate=0.08):
"""Generate synthetic vendor payment data with injected fraud patterns."""
rng = np.random.default_rng(42)
vendors = [f"VND-{i:04d}" for i in range(1, 51)]
programmes = ["Education", "Health", "WASH", "Protection", "Livelihoods"]
payment_methods = ["Bank Transfer", "Mobile Money", "Cash", "Cheque"]
start_date = datetime(2024, 1, 1)
dates = [start_date + timedelta(days=int(d)) for d in rng.integers(0, 540, n_records)]
legitimate_amounts = rng.lognormal(mean=7.5, sigma=1.2, size=n_records)
legitimate_amounts = np.round(legitimate_amounts, 2)
n_fraud = int(n_records * fraud_rate)
fraud_indices = rng.choice(n_records, n_fraud, replace=False)
amounts = legitimate_amounts.copy()
fraud_labels = np.zeros(n_records, dtype=int)
for i, idx in enumerate(fraud_indices):
pattern = i % 4
if pattern == 0:
amounts[idx] = rng.choice([5000, 10000, 15000, 20000, 25000, 50000])
elif pattern == 1:
amounts[idx] = rng.uniform(9500, 9999)
elif pattern == 2:
source_idx = rng.integers(0, n_records)
amounts[idx] = amounts[source_idx]
else:
amounts[idx] = rng.uniform(80000, 150000)
fraud_labels[idx] = 1
df = pd.DataFrame({
"transaction_id": [f"TXN-{i:05d}" for i in range(1, n_records + 1)],
"date": dates,
"vendor_id": rng.choice(vendors, n_records),
"programme": rng.choice(programmes, n_records),
"amount": np.round(amounts, 2),
"payment_method": rng.choice(payment_methods, n_records),
"is_fraud": fraud_labels
})
return df
def generate_expense_claims(n_records=800, fraud_rate=0.10):
"""Generate synthetic expense claim data."""
rng = np.random.default_rng(123)
staff = [f"EMP-{i:03d}" for i in range(1, 31)]
categories = ["Travel", "Accommodation", "Meals", "Supplies", "Communication", "Transport"]
offices = ["Nairobi", "Kampala", "Mogadishu", "Juba", "Addis Ababa"]
start_date = datetime(2024, 1, 1)
dates = [start_date + timedelta(days=int(d)) for d in rng.integers(0, 540, n_records)]
amounts = rng.lognormal(mean=4.5, sigma=0.9, size=n_records)
amounts = np.round(amounts, 2)
n_fraud = int(n_records * fraud_rate)
fraud_indices = rng.choice(n_records, n_fraud, replace=False)
fraud_labels = np.zeros(n_records, dtype=int)
for idx in fraud_indices:
amounts[idx] = rng.choice([100, 200, 250, 500, 750, 1000])
fraud_labels[idx] = 1
df = pd.DataFrame({
"claim_id": [f"EXP-{i:04d}" for i in range(1, n_records + 1)],
"date": dates,
"staff_id": rng.choice(staff, n_records),
"office": rng.choice(offices, n_records),
"category": rng.choice(categories, n_records),
"amount": amounts,
"is_fraud": fraud_labels
})
return df
if __name__ == "__main__":
print("Generating synthetic operational data...")
vendor_payments = generate_vendor_payments()
expense_claims = generate_expense_claims()
vendor_payments.to_csv("vendor_payments.csv", index=False)
expense_claims.to_csv("expense_claims.csv", index=False)
print(f"Generated {len(vendor_payments)} vendor payment records")
print(f"Generated {len(expense_claims)} expense claim records")
print("Files saved: vendor_payments.csv, expense_claims.csv")
Your synthetic datasets are ready. Next up, you'll build the fraud detection engine that applies Benford's Law, Z-score analysis, and Isolation Forest machine learning to flag suspicious transactions.
Detect Fraud Patterns
Your synthetic data is ready with known fraud patterns hiding inside 1500 vendor payments. Now comes the core analytical work: building detection functions that can surface those anomalies automatically.
In this step, you'll implement three professional-grade detection techniques that real auditors and risk analysts use. Each technique catches a different type of fraud, and combining them into a composite risk score gives you far more confidence than any single method alone.
In this step, get ready to:
- Implement a Benford's Law first-digit test to detect manipulated payment amounts.
- Build Z-score and Isolation Forest anomaly detectors.
- Create a composite risk scoring system that classifies transactions into four risk levels.
Implement the Benford's Law test
Benford's Law states that in naturally occurring datasets, the first digit of numbers follows a predictable logarithmic distribution. The digit 1 appears about 30% of the time, while 9 appears less than 5% of the time. When someone fabricates or manipulates financial amounts, this natural pattern breaks down.
- Switch back to fraud_analysis.py in VS Code.
- Add the following imports at the top of the file:
import pandas as pd
import numpy as np
from scipy import stats
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import LabelEncoder
import matplotlib.pyplot as plt
import plotly.express as px
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font
from openpyxl.utils.dataframe import dataframe_to_rows
What are all these imports for?
You're importing libraries for each layer of the analysis. scipy provides statistical functions like Z-scores. sklearn (scikit-learn) provides the Isolation Forest machine learning model.
The matplotlib, plotly, and openpyxl imports are for the visualization and reporting functions you'll build in the next step. Adding them now keeps all imports together at the top of the file.
- You won't see a change yet. The next chunk builds the first detection function using these imports.
- Below the imports, add the benfords_law_test() function by pasting this code:
def benfords_law_test(amounts):
"""Apply Benford's Law first-digit test to detect manipulated amounts."""
# Extract the first digit from each amount
first_digits = []
for amount in amounts:
amount_str = str(abs(amount)).lstrip("0").replace(".", "")
if amount_str:
first_digits.append(int(amount_str[0]))
first_digits = pd.Series(first_digits)
# Calculate observed first-digit frequencies
observed = first_digits.value_counts().sort_index()
observed_freq = observed / observed.sum()
# Calculate expected Benford frequencies using the log10 formula
digits = range(1, 10)
expected_freq = pd.Series(
[np.log10(1 + 1 / d) for d in digits],
index=digits
)
observed_freq = observed_freq.reindex(digits, fill_value=0)
# Use Z-scores to identify statistically significant deviations
n = len(first_digits)
z_scores = {}
for d in digits:
p_obs = observed_freq.get(d, 0)
p_exp = expected_freq[d]
z = abs(p_obs - p_exp) / np.sqrt(p_exp * (1 - p_exp) / n)
z_scores[d] = z
# Build results DataFrame with significance flags
results = pd.DataFrame({
"digit": list(digits),
"observed_freq": [observed_freq.get(d, 0) for d in digits],
"expected_freq": [expected_freq[d] for d in digits],
"z_score": [z_scores[d] for d in digits],
"significant": [z_scores[d] > 1.96 for d in digits]
})
return results
What does this code do?
This function applies the mathematical foundation of Benford's Law to your payment data. It extracts the first digit of every amount, then compares how often each digit (1 through 9) actually appears versus how often Benford's Law predicts it should.
The formula np.log10(1 + 1/d) calculates the expected probability for each digit. A Z-score above 1.96 means a digit's frequency deviates from the expected value with 95% statistical confidence. That deviation signals potential manipulation.
- Save fraud_analysis.py.
- You won't see output yet. The next substep adds the statistical and machine learning detectors that work alongside this test.
Seeing a syntax error on save?
Make sure the function is pasted below the imports with one blank line separating them. Check that the indentation is consistent (4 spaces per level) and that none of the string quotes were changed by auto-formatting.
help me fix a syntax error in my benfords_law_test function
Build the Z-score and Isolation Forest detectors
Benford's Law catches digit-level manipulation. Now you need techniques that catch amounts that are statistically unusual in other ways. A Z-score measures how far a value sits from the average. Isolation Forest is a machine learning algorithm that identifies points that are easy to isolate from the rest of the data.
- Below the benfords_law_test() function, add the Z-score anomaly detection function:
def zscore_anomaly_detection(df, amount_column="amount", threshold=3.0):
"""Flag records with amounts that deviate significantly from the mean."""
# Calculate absolute Z-scores for the amount column
z_scores = np.abs(stats.zscore(df[amount_column]))
df["zscore"] = z_scores
# Flag any transaction more than 3 standard deviations from the mean
df["zscore_flag"] = z_scores > threshold
return df
What does this code do?
A Z-score tells you how many standard deviations a value is from the mean. An amount with a Z-score above 3.0 is extremely unusual compared to the rest of the dataset. This is the simplest anomaly detection approach, but it catches the most obviously outlying values like the fraudulent payments in the $80,000-$150,000 range.
- You won't see a change yet. The Isolation Forest function below completes this substep.
- Below zscore_anomaly_detection(), add the Isolation Forest detection function:
def isolation_forest_detection(df, features, contamination=0.08):
"""Use Isolation Forest to detect multivariate anomalies."""
# Create the model expecting ~8% of data to be anomalous
model = IsolationForest(
contamination=contamination,
random_state=42,
n_estimators=100
)
# Fit and predict: -1 means anomaly, 1 means normal
predictions = model.fit_predict(features)
scores = model.decision_function(features)
# Add flag and score columns to the DataFrame
df["isolation_flag"] = predictions == -1
df["isolation_score"] = scores
return df
What does this code do?
Isolation Forest works by randomly splitting data and measuring how many splits it takes to isolate each point. Fraudulent transactions tend to be isolated quickly because they sit far from normal clusters.
The contamination=0.08 parameter tells the model to expect about 8% of transactions to be anomalous. The n_estimators=100 parameter sets how many random trees to build. Unlike the Z-score (which only looks at amounts), Isolation Forest considers multiple features at once, catching fraud patterns that only appear when you look at combinations of variables.
- Save fraud_analysis.py.
- You won't see output yet. The next substep adds the final two functions that tie all the detection flags together.
Red underlines on the sklearn import?
VS Code's linter sometimes flags sklearn imports before the library is fully indexed. If you installed all libraries in Step 1 without errors, the underline is a false positive. It will resolve when you run the script later.
help me fix sklearn import issues in VS Code
Why three techniques instead of just one?
Each method catches different fraud types. Benford's Law spots fabricated numbers. Z-scores catch extreme outliers. Isolation Forest finds subtle multi-dimensional anomalies. Professional auditors layer multiple techniques because no single method catches everything.
Combining them into a composite score (which you'll build next) gives you higher confidence when multiple methods agree that a transaction is suspicious.
Create the duplicate detector and risk scoring system
The final piece is catching duplicates and combining all your flags into one actionable risk score. Duplicate payments are one of the most common fraud patterns in operational finance.
- Below isolation_forest_detection(), add the duplicate detection function:
def detect_duplicates(df, amount_column="amount", date_column="date", window_days=7):
"""Detect suspicious duplicate amounts within a time window."""
df["date"] = pd.to_datetime(df["date"])
df["duplicate_flag"] = False
# Find amounts that appear 3 or more times
amount_counts = df[amount_column].value_counts()
suspicious_amounts = amount_counts[amount_counts >= 3].index
# Check if those duplicates cluster within a 7-day window
for amount in suspicious_amounts:
mask = df[amount_column] == amount
subset = df[mask].sort_values(date_column)
if len(subset) >= 3:
date_diffs = subset[date_column].diff().dt.days
close_dates = date_diffs <= window_days
df.loc[subset.index[close_dates], "duplicate_flag"] = True
return df
What does this code do?
This function looks for the same payment amount appearing 3 or more times within a 7-day window. A single duplicate could be coincidence, but clusters of identical amounts in a short timeframe often indicate copy-paste fraud or fictitious invoices.
The function first identifies which amounts appear frequently, then checks whether those repeated amounts cluster together in time. Only time-clustered duplicates get flagged.
- You won't see a change yet. The risk scoring function below is the final piece that ties all flags together.
- Below detect_duplicates(), add the composite risk scoring function:
def calculate_risk_score(df):
"""Calculate a composite risk score (0-100) based on all detection flags."""
# Weight each detection method's contribution
weights = {
"zscore_flag": 30,
"isolation_flag": 40,
"duplicate_flag": 30
}
# Sum weighted flags into a single score
df["risk_score"] = 0
for flag, weight in weights.items():
if flag in df.columns:
df["risk_score"] += df[flag].astype(int) * weight
# Classify into risk levels based on score ranges
df["risk_level"] = pd.cut(
df["risk_score"],
bins=[-1, 0, 30, 60, 100],
labels=["Low", "Medium", "High", "Critical"]
)
return df
What does this code do?
This function converts your three boolean flags into a single 0-100 risk score. Isolation Forest gets the highest weight (40) because it considers multiple features simultaneously. Z-score and duplicate detection each contribute 30 points.
A transaction flagged by all three methods scores 100 (Critical). One flagged only by Z-score scores 30 (Medium). The pd.cut() function maps these numeric scores into four human-readable risk levels: Low, Medium, High, and Critical.
- Save fraud_analysis.py.
- Verify the file has no syntax errors by running this in your terminal:
python -c "import fraud_analysis; print('All detection functions loaded successfully')"
You should see the message All detection functions loaded successfully with no errors.
Seeing an import error?
Make sure you're running the command from inside the fraud-detection folder where fraud_analysis.py lives. Also check that all five functions are separated by single blank lines and that no indentation uses tabs mixed with spaces.
help me debug my fraud_analysis.py import error
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete fraud_analysis.py file so far:
import pandas as pd
import numpy as np
from scipy import stats
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import LabelEncoder
import matplotlib.pyplot as plt
import plotly.express as px
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font
from openpyxl.utils.dataframe import dataframe_to_rows
def benfords_law_test(amounts):
"""Apply Benford's Law first-digit test to detect manipulated amounts."""
first_digits = []
for amount in amounts:
amount_str = str(abs(amount)).lstrip("0").replace(".", "")
if amount_str:
first_digits.append(int(amount_str[0]))
first_digits = pd.Series(first_digits)
observed = first_digits.value_counts().sort_index()
observed_freq = observed / observed.sum()
digits = range(1, 10)
expected_freq = pd.Series(
[np.log10(1 + 1 / d) for d in digits],
index=digits
)
observed_freq = observed_freq.reindex(digits, fill_value=0)
n = len(first_digits)
z_scores = {}
for d in digits:
p_obs = observed_freq.get(d, 0)
p_exp = expected_freq[d]
z = abs(p_obs - p_exp) / np.sqrt(p_exp * (1 - p_exp) / n)
z_scores[d] = z
results = pd.DataFrame({
"digit": list(digits),
"observed_freq": [observed_freq.get(d, 0) for d in digits],
"expected_freq": [expected_freq[d] for d in digits],
"z_score": [z_scores[d] for d in digits],
"significant": [z_scores[d] > 1.96 for d in digits]
})
return results
def zscore_anomaly_detection(df, amount_column="amount", threshold=3.0):
"""Flag records with amounts that deviate significantly from the mean."""
z_scores = np.abs(stats.zscore(df[amount_column]))
df["zscore"] = z_scores
df["zscore_flag"] = z_scores > threshold
return df
def isolation_forest_detection(df, features, contamination=0.08):
"""Use Isolation Forest to detect multivariate anomalies."""
model = IsolationForest(
contamination=contamination,
random_state=42,
n_estimators=100
)
predictions = model.fit_predict(features)
scores = model.decision_function(features)
df["isolation_flag"] = predictions == -1
df["isolation_score"] = scores
return df
def detect_duplicates(df, amount_column="amount", date_column="date", window_days=7):
"""Detect suspicious duplicate amounts within a time window."""
df["date"] = pd.to_datetime(df["date"])
df["duplicate_flag"] = False
amount_counts = df[amount_column].value_counts()
suspicious_amounts = amount_counts[amount_counts >= 3].index
for amount in suspicious_amounts:
mask = df[amount_column] == amount
subset = df[mask].sort_values(date_column)
if len(subset) >= 3:
date_diffs = subset[date_column].diff().dt.days
close_dates = date_diffs <= window_days
df.loc[subset.index[close_dates], "duplicate_flag"] = True
return df
def calculate_risk_score(df):
"""Calculate a composite risk score (0-100) based on all detection flags."""
weights = {
"zscore_flag": 30,
"isolation_flag": 40,
"duplicate_flag": 30
}
df["risk_score"] = 0
for flag, weight in weights.items():
if flag in df.columns:
df["risk_score"] += df[flag].astype(int) * weight
df["risk_level"] = pd.cut(
df["risk_score"],
bins=[-1, 0, 30, 60, 100],
labels=["Low", "Medium", "High", "Critical"]
)
return df
Your detection engine is built. You have five functions that can analyse payment data from multiple angles and classify every transaction by risk. Next up, you'll wire these functions together, generate visualizations, and export a professional Excel risk report.
Visualize and Export Results
Your detection system now flags suspicious transactions, but raw data tables don't communicate risk to decision-makers. A Risk Management team needs charts they can interpret at a glance and a formatted report they can act on within 48 hours.
In this step, you'll turn your analysis into publication-quality deliverables. You'll create a static Benford's Law chart, interactive Plotly dashboards, and a formatted Excel report with dynamic formulas and conditional formatting.
In this step, get ready to:
- Create a matplotlib bar chart comparing observed vs expected Benford frequencies.
- Build interactive Plotly charts showing risk distribution and programme breakdowns.
- Export a formatted Excel risk report with conditional formatting and dynamic formulas.
Create the Benford's Law comparison chart
A grouped bar chart is the standard way auditors present Benford's Law findings. It shows observed digit frequencies side-by-side with the expected mathematical distribution, making deviations immediately visible.
- In fraud_analysis.py, scroll down below the calculate_risk_score() function.
- Add the following plot_benfords_comparison() function below it:
def plot_benfords_comparison(benford_results, save_path="benford_analysis.png"):
"""Create a bar chart comparing observed vs expected Benford frequencies."""
fig, ax = plt.subplots(figsize=(10, 6))
x = benford_results["digit"]
width = 0.35
# Plot observed frequencies in red and expected in blue
ax.bar(x - width / 2, benford_results["observed_freq"], width,
label="Observed", color="#e74c3c", alpha=0.8)
ax.bar(x + width / 2, benford_results["expected_freq"], width,
label="Expected (Benford)", color="#3498db", alpha=0.8)
# Mark statistically significant deviations with an asterisk
for i, sig in enumerate(benford_results["significant"]):
if sig:
ax.annotate("*", (x.iloc[i] - width / 2, benford_results["observed_freq"].iloc[i]),
ha="center", va="bottom", fontsize=16, color="red", fontweight="bold")
ax.set_xlabel("First Digit", fontsize=12)
ax.set_ylabel("Frequency", fontsize=12)
ax.set_title("Benford's Law Analysis: Observed vs Expected First-Digit Distribution", fontsize=13)
ax.set_xticks(x)
ax.legend()
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=150)
plt.close()
print(f"Saved: {save_path}")
What does this code do?
This function creates a side-by-side bar chart for digits 1 through 9. The red bars show how often each digit actually appears as the first digit in your payment amounts. The blue bars show how often Benford's Law predicts they should appear.
Any digit where the observed frequency deviates significantly (Z-score above 1.96) gets a red asterisk. This is the visual signal that tells an auditor "investigate this digit further."
- Save fraud_analysis.py.
You won't see a change yet. This function will be called by the main pipeline you'll add at the end of this step.
Seeing a syntax error on save?
Check that the function is indented at the module level (no extra indentation). Make sure the closing parentheses on the ax.annotate call are balanced. There are two closing parentheses on the line after fontweight="bold".
Help me debug a syntax error in my plot_benfords_comparison function
Build interactive Plotly risk charts
Static charts work for reports, but interactive dashboards let stakeholders filter and explore the data themselves. Plotly generates HTML files that open in any browser with hover tooltips and zoom.
- Below your plot_benfords_comparison() function, add the plot_risk_distribution() function:
def plot_risk_distribution(df, save_path="risk_distribution.html"):
"""Create an interactive Plotly chart showing risk score distribution."""
fig = px.histogram(
df,
x="risk_score",
color="risk_level",
color_discrete_map={
"Low": "#27ae60",
"Medium": "#f39c12",
"High": "#e74c3c",
"Critical": "#8e44ad"
},
title="Transaction Risk Score Distribution",
labels={"risk_score": "Risk Score", "count": "Number of Transactions"},
nbins=20
)
fig.update_layout(
xaxis_title="Risk Score",
yaxis_title="Number of Transactions",
template="plotly_white"
)
fig.write_html(save_path)
print(f"Saved: {save_path}")
What does this code do?
This creates a histogram of all 1500 transactions bucketed by their composite risk score. Each bar is color-coded by risk level: green for Low, orange for Medium, red for High, and purple for Critical.
The fig.write_html() call saves the chart as a self-contained HTML file. Open it in any browser to hover over bars and see exact counts.
- Save fraud_analysis.py.
You won't see a change yet. The next function adds the programme breakdown chart.
- Below plot_risk_distribution(), add the plot_risk_by_programme() function:
def plot_risk_by_programme(df, save_path="risk_by_programme.html"):
"""Create an interactive chart showing risk breakdown by programme."""
risk_summary = df.groupby(["programme", "risk_level"]).size().reset_index(name="count")
fig = px.bar(
risk_summary,
x="programme",
y="count",
color="risk_level",
color_discrete_map={
"Low": "#27ae60",
"Medium": "#f39c12",
"High": "#e74c3c",
"Critical": "#8e44ad"
},
title="Risk Level Breakdown by Programme",
barmode="stack"
)
fig.update_layout(template="plotly_white")
fig.write_html(save_path)
print(f"Saved: {save_path}")
What does this code do?
This chart groups transactions by programme (Education, Health, WASH, Protection, Livelihoods) and stacks them by risk level. It answers the question: "Which programme has the most high-risk transactions?"
The groupby() call aggregates transaction counts before plotting. The barmode="stack" parameter layers risk levels on top of each other so you can compare both total volume and risk composition.
- Save fraud_analysis.py.
You won't see output yet. Both Plotly functions will be called by the main pipeline at the end of this step.
Seeing an indentation error?
Make sure both Plotly functions are at the same indentation level as your other functions (no nesting inside plot_benfords_comparison). Each def keyword should start at column 0.
Help me fix an indentation error in my Plotly functions
Export the formatted Excel risk report and run the pipeline
A professional risk report needs more than raw data. It needs an executive summary with live formulas, color-coded severity rows, and actionable recommendations. This is what you'd hand to a Risk Manager who needs to act on your findings.
- Below plot_risk_by_programme(), add the export_risk_report() function:
def export_risk_report(df, benford_results, output_path="fraud_risk_report.xlsx"):
"""Export a formatted Excel report with risk scores and analysis summary."""
wb = Workbook()
# Sheet 1: Executive Summary with key metrics
ws_summary = wb.active
ws_summary.title = "Executive Summary"
ws_summary["A1"] = "FRAUD RISK ANALYSIS REPORT"
ws_summary["A1"].font = Font(bold=True, size=16)
ws_summary["A3"] = "Report Generated:"
ws_summary["B3"] = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")
ws_summary["A4"] = "Total Transactions Analyzed:"
ws_summary["B4"] = len(df)
ws_summary["A5"] = "High/Critical Risk Flagged:"
ws_summary["B5"] = len(df[df["risk_score"] >= 60])
ws_summary["A6"] = "Percentage Flagged:"
ws_summary["B6"] = "=B5/B4*100"
ws_summary["A8"] = "RISK LEVEL BREAKDOWN"
ws_summary["A8"].font = Font(bold=True, size=12)
ws_summary["A9"] = "Risk Level"
ws_summary["B9"] = "Count"
ws_summary["C9"] = "Percentage"
for i, (level, count) in enumerate(df["risk_level"].value_counts().items(), start=10):
ws_summary[f"A{i}"] = str(level)
ws_summary[f"B{i}"] = int(count)
ws_summary[f"C{i}"] = f"=B{i}/B4*100"
# Sheet 2: Flagged Transactions with conditional formatting
ws_flagged = wb.create_sheet("Flagged Transactions")
flagged_df = df[df["risk_score"] >= 30].sort_values("risk_score", ascending=False)
flagged_cols = ["transaction_id", "date", "vendor_id", "programme", "amount",
"risk_score", "risk_level", "zscore_flag", "isolation_flag", "duplicate_flag"]
flagged_export = flagged_df[[c for c in flagged_cols if c in flagged_df.columns]].copy()
flagged_export["date"] = flagged_export["date"].astype(str)
flagged_export["risk_level"] = flagged_export["risk_level"].astype(str)
for row in dataframe_to_rows(flagged_export, index=False, header=True):
ws_flagged.append(row)
# Apply red fill for Critical (score >= 60) and yellow for High (score >= 30)
red_fill = PatternFill(start_color="FFCCCC", end_color="FFCCCC", fill_type="solid")
yellow_fill = PatternFill(start_color="FFFFCC", end_color="FFFFCC", fill_type="solid")
for row_idx in range(2, ws_flagged.max_row + 1):
risk_cell = ws_flagged.cell(row=row_idx, column=6)
if risk_cell.value and risk_cell.value >= 60:
for col_idx in range(1, ws_flagged.max_column + 1):
ws_flagged.cell(row=row_idx, column=col_idx).fill = red_fill
elif risk_cell.value and risk_cell.value >= 30:
for col_idx in range(1, ws_flagged.max_column + 1):
ws_flagged.cell(row=row_idx, column=col_idx).fill = yellow_fill
for cell in ws_flagged[1]:
cell.font = Font(bold=True)
# Sheet 3: Benford's Law digit analysis results
ws_benford = wb.create_sheet("Benford Analysis")
ws_benford["A1"] = "BENFORD'S LAW FIRST-DIGIT ANALYSIS"
ws_benford["A1"].font = Font(bold=True, size=12)
for row in dataframe_to_rows(benford_results, index=False, header=True):
ws_benford.append(row)
for cell in ws_benford[2]:
cell.font = Font(bold=True)
# Sheet 4: Actionable recommendations for the Risk Management team
ws_rec = wb.create_sheet("Recommendations")
ws_rec["A1"] = "RISK MITIGATION RECOMMENDATIONS"
ws_rec["A1"].font = Font(bold=True, size=14)
recommendations = [
"1. Investigate all Critical-risk transactions (score >= 60) within 48 hours.",
"2. Review vendor payment patterns for VND IDs appearing in multiple flags.",
"3. Implement approval thresholds at $9,500 to catch just-below-limit fraud.",
"4. Cross-reference duplicate payments with delivery confirmations.",
"5. Conduct quarterly Benford's Law analysis on all programme expenditure.",
"6. Establish automated alerts for round-number payments above $5,000."
]
for i, rec in enumerate(recommendations, start=3):
ws_rec[f"A{i}"] = rec
wb.save(output_path)
print(f"Saved: {output_path}")
What does this code do?
- Sheet 1 (Executive Summary) writes key metrics and uses a live Excel formula =B5/B4*100 to calculate the flagged percentage dynamically. If the data changes, the percentage updates automatically.
- Sheet 2 (Flagged Transactions) filters to transactions scoring 30 or above, then applies red background for Critical-risk rows and yellow for High-risk rows. This is visual triage for the reviewer.
- Sheet 3 (Benford Analysis) presents the first-digit test results so auditors can see which digits deviated.
- Sheet 4 (Recommendations) provides six concrete mitigation actions the team can implement immediately.
- Save fraud_analysis.py.
You won't see a change yet. The next chunk adds the main pipeline that calls all your functions and generates every output file.
Seeing a NameError for Workbook or PatternFill?
Scroll to the top of your file and confirm these imports are present: from openpyxl import Workbook and from openpyxl.styles import PatternFill, Font. These were added in the previous step but may have been accidentally deleted.
Help me fix a NameError in my export_risk_report function
Now you need a main block that orchestrates the entire pipeline. It loads the CSV data, runs every detection function, generates all visualizations, and exports the final report.
- At the very bottom of fraud_analysis.py, below all your function definitions, add the following __main__ block:
if __name__ == "__main__":
print("=" * 60)
print("FRAUD RISK DETECTION SYSTEM")
print("=" * 60)
# Step 1: Load the synthetic vendor payment data
print("\n[1/5] Loading operational data...")
payments = pd.read_csv("vendor_payments.csv")
print(f" Loaded {len(payments)} vendor payments")
df = payments.copy()
# Step 2: Run Benford's Law analysis on payment amounts
print("\n[2/5] Running Benford's Law analysis...")
benford_results = benfords_law_test(df["amount"])
significant_digits = benford_results[benford_results["significant"]]["digit"].tolist()
if significant_digits:
print(f" WARNING: Significant deviations for digits: {significant_digits}")
else:
print(" No significant deviations detected.")
# Step 3: Run all three anomaly detection methods
print("\n[3/5] Running anomaly detection...")
df = zscore_anomaly_detection(df, threshold=3.0)
print(f" Z-score flags: {df['zscore_flag'].sum()} transactions")
le = LabelEncoder()
df["vendor_encoded"] = le.fit_transform(df["vendor_id"])
df["programme_encoded"] = le.fit_transform(df["programme"])
features = df[["amount", "vendor_encoded", "programme_encoded"]]
df = isolation_forest_detection(df, features, contamination=0.08)
print(f" Isolation Forest flags: {df['isolation_flag'].sum()} transactions")
df = detect_duplicates(df)
print(f" Duplicate flags: {df['duplicate_flag'].sum()} transactions")
df = calculate_risk_score(df)
print(f"\n Risk Score Summary:")
print(f" {df['risk_level'].value_counts().to_string()}")
# Step 4: Generate all visualizations
print("\n[4/5] Generating visualizations...")
plot_benfords_comparison(benford_results)
plot_risk_distribution(df)
plot_risk_by_programme(df)
# Step 5: Export formatted Excel report and scored CSV
print("\n[5/5] Exporting Excel risk report...")
export_risk_report(df, benford_results)
df.to_csv("scored_transactions.csv", index=False)
print("Saved: scored_transactions.csv (for Tableau import)")
print("\n" + "=" * 60)
print("ANALYSIS COMPLETE")
print(f"High/Critical risk transactions: {len(df[df['risk_score'] >= 60])}")
print("Review fraud_risk_report.xlsx for detailed findings.")
print("=" * 60)
What does this pipeline do?
This block runs only when you execute the script directly. It follows a 5-stage pipeline: load data, run Benford's analysis, run all anomaly detectors (Z-score, Isolation Forest, duplicates), generate charts, and export the Excel report.
The LabelEncoder converts text columns (vendor_id, programme) into numeric values so Isolation Forest can process them. The final scored_transactions.csv output includes all flags and scores for every transaction. You'll use this file in the Secret Mission.
- Save fraud_analysis.py.
- Run the full pipeline by entering this command in the terminal:
python fraud_analysis.py
You should see output showing each pipeline stage completing, ending with "ANALYSIS COMPLETE" and the count of High/Critical risk transactions.
- Verify your output files exist by running:
ls
You should see benford_analysis.png, risk_distribution.html, risk_by_programme.html, fraud_risk_report.xlsx, and scored_transactions.csv in the output.
- Open risk_distribution.html in your browser to see the interactive risk score histogram.
- Open fraud_risk_report.xlsx in Excel or Google Sheets. You should see four sheets with colored rows on the Flagged Transactions sheet and a working formula in cell B6 of Executive Summary.
Seeing a FileNotFoundError for vendor_payments.csv?
Make sure your terminal is inside the fraud-detection folder where your CSV files live. Run ls to confirm you can see vendor_payments.csv in the current directory.
If the file is missing, re-run python generate_data.py to regenerate the synthetic data.
Help me fix a FileNotFoundError when running fraud_analysis.py
✔️ Awesome, I've got everything!
Great. Double check you've saved fraud_analysis.py and that all five output files are present in your folder.
ⓧ I'd like to double check the full code
Here is the complete fraud_analysis.py file. Compare your code against it:
import pandas as pd
import numpy as np
from scipy import stats
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import LabelEncoder
import matplotlib.pyplot as plt
import plotly.express as px
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font
from openpyxl.utils.dataframe import dataframe_to_rows
def benfords_law_test(amounts):
"""Apply Benford's Law first-digit test to detect manipulated amounts."""
first_digits = []
for amount in amounts:
amount_str = str(abs(amount)).lstrip("0").replace(".", "")
if amount_str:
first_digits.append(int(amount_str[0]))
first_digits = pd.Series(first_digits)
observed = first_digits.value_counts().sort_index()
observed_freq = observed / observed.sum()
digits = range(1, 10)
expected_freq = pd.Series(
[np.log10(1 + 1 / d) for d in digits],
index=digits
)
observed_freq = observed_freq.reindex(digits, fill_value=0)
n = len(first_digits)
z_scores = {}
for d in digits:
p_obs = observed_freq.get(d, 0)
p_exp = expected_freq[d]
z = abs(p_obs - p_exp) / np.sqrt(p_exp * (1 - p_exp) / n)
z_scores[d] = z
results = pd.DataFrame({
"digit": list(digits),
"observed_freq": [observed_freq.get(d, 0) for d in digits],
"expected_freq": [expected_freq[d] for d in digits],
"z_score": [z_scores[d] for d in digits],
"significant": [z_scores[d] > 1.96 for d in digits]
})
return results
def zscore_anomaly_detection(df, amount_column="amount", threshold=3.0):
"""Flag records with amounts that deviate significantly from the mean."""
z_scores = np.abs(stats.zscore(df[amount_column]))
df["zscore"] = z_scores
df["zscore_flag"] = z_scores > threshold
return df
def isolation_forest_detection(df, features, contamination=0.08):
"""Use Isolation Forest to detect multivariate anomalies."""
model = IsolationForest(
contamination=contamination,
random_state=42,
n_estimators=100
)
predictions = model.fit_predict(features)
scores = model.decision_function(features)
df["isolation_flag"] = predictions == -1
df["isolation_score"] = scores
return df
def detect_duplicates(df, amount_column="amount", date_column="date", window_days=7):
"""Detect suspicious duplicate amounts within a time window."""
df["date"] = pd.to_datetime(df["date"])
df["duplicate_flag"] = False
amount_counts = df[amount_column].value_counts()
suspicious_amounts = amount_counts[amount_counts >= 3].index
for amount in suspicious_amounts:
mask = df[amount_column] == amount
subset = df[mask].sort_values(date_column)
if len(subset) >= 3:
date_diffs = subset[date_column].diff().dt.days
close_dates = date_diffs <= window_days
df.loc[subset.index[close_dates], "duplicate_flag"] = True
return df
def calculate_risk_score(df):
"""Calculate a composite risk score (0-100) based on all detection flags."""
weights = {
"zscore_flag": 30,
"isolation_flag": 40,
"duplicate_flag": 30
}
df["risk_score"] = 0
for flag, weight in weights.items():
if flag in df.columns:
df["risk_score"] += df[flag].astype(int) * weight
df["risk_level"] = pd.cut(
df["risk_score"],
bins=[-1, 0, 30, 60, 100],
labels=["Low", "Medium", "High", "Critical"]
)
return df
def plot_benfords_comparison(benford_results, save_path="benford_analysis.png"):
"""Create a bar chart comparing observed vs expected Benford frequencies."""
fig, ax = plt.subplots(figsize=(10, 6))
x = benford_results["digit"]
width = 0.35
ax.bar(x - width / 2, benford_results["observed_freq"], width,
label="Observed", color="#e74c3c", alpha=0.8)
ax.bar(x + width / 2, benford_results["expected_freq"], width,
label="Expected (Benford)", color="#3498db", alpha=0.8)
for i, sig in enumerate(benford_results["significant"]):
if sig:
ax.annotate("*", (x.iloc[i] - width / 2, benford_results["observed_freq"].iloc[i]),
ha="center", va="bottom", fontsize=16, color="red", fontweight="bold")
ax.set_xlabel("First Digit", fontsize=12)
ax.set_ylabel("Frequency", fontsize=12)
ax.set_title("Benford's Law Analysis: Observed vs Expected First-Digit Distribution", fontsize=13)
ax.set_xticks(x)
ax.legend()
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.savefig(save_path, dpi=150)
plt.close()
print(f"Saved: {save_path}")
def plot_risk_distribution(df, save_path="risk_distribution.html"):
"""Create an interactive Plotly chart showing risk score distribution."""
fig = px.histogram(
df,
x="risk_score",
color="risk_level",
color_discrete_map={
"Low": "#27ae60",
"Medium": "#f39c12",
"High": "#e74c3c",
"Critical": "#8e44ad"
},
title="Transaction Risk Score Distribution",
labels={"risk_score": "Risk Score", "count": "Number of Transactions"},
nbins=20
)
fig.update_layout(
xaxis_title="Risk Score",
yaxis_title="Number of Transactions",
template="plotly_white"
)
fig.write_html(save_path)
print(f"Saved: {save_path}")
def plot_risk_by_programme(df, save_path="risk_by_programme.html"):
"""Create an interactive chart showing risk breakdown by programme."""
risk_summary = df.groupby(["programme", "risk_level"]).size().reset_index(name="count")
fig = px.bar(
risk_summary,
x="programme",
y="count",
color="risk_level",
color_discrete_map={
"Low": "#27ae60",
"Medium": "#f39c12",
"High": "#e74c3c",
"Critical": "#8e44ad"
},
title="Risk Level Breakdown by Programme",
barmode="stack"
)
fig.update_layout(template="plotly_white")
fig.write_html(save_path)
print(f"Saved: {save_path}")
def export_risk_report(df, benford_results, output_path="fraud_risk_report.xlsx"):
"""Export a formatted Excel report with risk scores and analysis summary."""
wb = Workbook()
ws_summary = wb.active
ws_summary.title = "Executive Summary"
ws_summary["A1"] = "FRAUD RISK ANALYSIS REPORT"
ws_summary["A1"].font = Font(bold=True, size=16)
ws_summary["A3"] = "Report Generated:"
ws_summary["B3"] = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")
ws_summary["A4"] = "Total Transactions Analyzed:"
ws_summary["B4"] = len(df)
ws_summary["A5"] = "High/Critical Risk Flagged:"
ws_summary["B5"] = len(df[df["risk_score"] >= 60])
ws_summary["A6"] = "Percentage Flagged:"
ws_summary["B6"] = "=B5/B4*100"
ws_summary["A8"] = "RISK LEVEL BREAKDOWN"
ws_summary["A8"].font = Font(bold=True, size=12)
ws_summary["A9"] = "Risk Level"
ws_summary["B9"] = "Count"
ws_summary["C9"] = "Percentage"
for i, (level, count) in enumerate(df["risk_level"].value_counts().items(), start=10):
ws_summary[f"A{i}"] = str(level)
ws_summary[f"B{i}"] = int(count)
ws_summary[f"C{i}"] = f"=B{i}/B4*100"
ws_flagged = wb.create_sheet("Flagged Transactions")
flagged_df = df[df["risk_score"] >= 30].sort_values("risk_score", ascending=False)
flagged_cols = ["transaction_id", "date", "vendor_id", "programme", "amount",
"risk_score", "risk_level", "zscore_flag", "isolation_flag", "duplicate_flag"]
flagged_export = flagged_df[[c for c in flagged_cols if c in flagged_df.columns]].copy()
flagged_export["date"] = flagged_export["date"].astype(str)
flagged_export["risk_level"] = flagged_export["risk_level"].astype(str)
for row in dataframe_to_rows(flagged_export, index=False, header=True):
ws_flagged.append(row)
red_fill = PatternFill(start_color="FFCCCC", end_color="FFCCCC", fill_type="solid")
yellow_fill = PatternFill(start_color="FFFFCC", end_color="FFFFCC", fill_type="solid")
for row_idx in range(2, ws_flagged.max_row + 1):
risk_cell = ws_flagged.cell(row=row_idx, column=6)
if risk_cell.value and risk_cell.value >= 60:
for col_idx in range(1, ws_flagged.max_column + 1):
ws_flagged.cell(row=row_idx, column=col_idx).fill = red_fill
elif risk_cell.value and risk_cell.value >= 30:
for col_idx in range(1, ws_flagged.max_column + 1):
ws_flagged.cell(row=row_idx, column=col_idx).fill = yellow_fill
for cell in ws_flagged[1]:
cell.font = Font(bold=True)
ws_benford = wb.create_sheet("Benford Analysis")
ws_benford["A1"] = "BENFORD'S LAW FIRST-DIGIT ANALYSIS"
ws_benford["A1"].font = Font(bold=True, size=12)
for row in dataframe_to_rows(benford_results, index=False, header=True):
ws_benford.append(row)
for cell in ws_benford[2]:
cell.font = Font(bold=True)
ws_rec = wb.create_sheet("Recommendations")
ws_rec["A1"] = "RISK MITIGATION RECOMMENDATIONS"
ws_rec["A1"].font = Font(bold=True, size=14)
recommendations = [
"1. Investigate all Critical-risk transactions (score >= 60) within 48 hours.",
"2. Review vendor payment patterns for VND IDs appearing in multiple flags.",
"3. Implement approval thresholds at $9,500 to catch just-below-limit fraud.",
"4. Cross-reference duplicate payments with delivery confirmations.",
"5. Conduct quarterly Benford's Law analysis on all programme expenditure.",
"6. Establish automated alerts for round-number payments above $5,000."
]
for i, rec in enumerate(recommendations, start=3):
ws_rec[f"A{i}"] = rec
wb.save(output_path)
print(f"Saved: {output_path}")
if __name__ == "__main__":
print("=" * 60)
print("FRAUD RISK DETECTION SYSTEM")
print("=" * 60)
print("\n[1/5] Loading operational data...")
payments = pd.read_csv("vendor_payments.csv")
print(f" Loaded {len(payments)} vendor payments")
df = payments.copy()
print("\n[2/5] Running Benford's Law analysis...")
benford_results = benfords_law_test(df["amount"])
significant_digits = benford_results[benford_results["significant"]]["digit"].tolist()
if significant_digits:
print(f" WARNING: Significant deviations for digits: {significant_digits}")
else:
print(" No significant deviations detected.")
print("\n[3/5] Running anomaly detection...")
df = zscore_anomaly_detection(df, threshold=3.0)
print(f" Z-score flags: {df['zscore_flag'].sum()} transactions")
le = LabelEncoder()
df["vendor_encoded"] = le.fit_transform(df["vendor_id"])
df["programme_encoded"] = le.fit_transform(df["programme"])
features = df[["amount", "vendor_encoded", "programme_encoded"]]
df = isolation_forest_detection(df, features, contamination=0.08)
print(f" Isolation Forest flags: {df['isolation_flag'].sum()} transactions")
df = detect_duplicates(df)
print(f" Duplicate flags: {df['duplicate_flag'].sum()} transactions")
df = calculate_risk_score(df)
print(f"\n Risk Score Summary:")
print(f" {df['risk_level'].value_counts().to_string()}")
print("\n[4/5] Generating visualizations...")
plot_benfords_comparison(benford_results)
plot_risk_distribution(df)
plot_risk_by_programme(df)
print("\n[5/5] Exporting Excel risk report...")
export_risk_report(df, benford_results)
df.to_csv("scored_transactions.csv", index=False)
print("Saved: scored_transactions.csv (for Tableau import)")
print("\n" + "=" * 60)
print("ANALYSIS COMPLETE")
print(f"High/Critical risk transactions: {len(df[df['risk_score'] >= 60])}")
print("Review fraud_risk_report.xlsx for detailed findings.")
print("=" * 60)
Secret mission
Build a Tableau Risk Dashboard
Your Python analysis produced powerful results, but stakeholders want to explore the data themselves. In this mission, you'll take your scored transaction data into Tableau and build an interactive dashboard with filters, calculated fields, and storytelling features. This is the exact workflow described in operational risk analyst job descriptions.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The fraud-detection project folder with all Python scripts, CSV files, HTML charts, the PNG chart, and the Excel report.
- Python libraries installed via pip (pandas, numpy, scipy, scikit-learn, matplotlib, plotly, openpyxl).
- Tableau workbook connected to scored_transactions.csv.
Keep everything running
No action needed. Choose this if you are still actively building on this project or want to reference the code later.
- Your fraud-detection folder stays on your Desktop with all scripts, data, and output files intact.
- The Python libraries remain installed and available for other projects.
- Your Tableau workbook remains saved wherever you stored it.
Pause - I'll come back to this later
Close running applications to free up memory. All files and libraries stay on disk so you can pick up where you left off.
- Close the Tableau workbook and quit Tableau.
- Close VS Code.
- Your project folder and installed libraries remain untouched.
Delete - I don't want to use this again
Remove all project files and libraries to start fresh. This cannot be undone.
- Close Tableau and delete your Tableau workbook file from wherever you saved it.
- Close VS Code.
- Delete the project folder by running the command for your operating system below.
On macOS or Linux:
cd ~/Desktop
rm -rf fraud-detection
On Windows:
cd ~\Desktop
rmdir /s /q fraud-detection
- Uninstall the Python libraries you installed for this project by running:
pip uninstall pandas numpy scipy scikit-learn matplotlib plotly openpyxl -y
Nice Work!
Nice Work!
You just built a complete fraud risk detection system from scratch. Your pipeline ingests operational payment data, applies three professional-grade detection techniques, scores every transaction, and delivers the findings in both interactive visualizations and a formatted Excel report.
You've learned how to:
- Build a fraud detection pipeline that applies Benford's Law, Z-score anomaly detection, and Isolation Forest to flag suspicious transactions and assign composite risk scores.
- Create both static (matplotlib) and interactive (Plotly) visualizations that communicate risk findings to stakeholders.
- Export a professional Excel risk report with dynamic formulas, conditional formatting, and actionable recommendations using openpyxl.
- Secret Mission: Built an interactive Tableau dashboard with filters, calculated fields, and storytelling features.
Ready to quiz yourself?