Build a Shipping Fraud Data Pipeline
Detect shipping claim fraud patterns using a Python monorepo with uv.
Introduction
30 Second Summary
Every month, shipping companies process hundreds of compensation claims from customers who say their packages were lost, damaged, or never arrived. Most are legitimate. But hidden among them are repeat fraudsters filing fake claims, hoping nobody connects the dots.
In this project, you will build a Python-based fraud detection pipeline that analyzes shipping compensation claims. Your pipeline aggregates claims across claimants, surfaces hidden fraud patterns, and routes suspicious ones to review queues with clear reasons.
What You'll Build
Picture running a single command and watching your pipeline load 500 shipping claims, flag a handful of repeat offenders that manual review missed, and produce a four-panel dashboard ready to present to a security team.
By the end of this project, you'll have:
- A pipeline that generates 500 realistic shipping claims with embedded fraud patterns and surfaces repeat claimants who would be invisible in one-at-a-time manual review.
- A routing engine that flags suspicious claimants and sends them to named review queues with human-readable reasons explaining why each was flagged.
- A saved four-panel dashboard showing claim distributions, routing queue flags, top flagged claimants, and claim type breakdowns, ready to present to a security team.
- Secret Mission: Add carrier-specific cross-referencing to identify which carriers are disproportionately associated with fraud patterns.
Are there any prerequisites?
This project assumes you have Python 3.11+, VS Code, and basic Git installed. Step 1 walks you through installing everything else you need.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Monorepo with uv
Professional Python projects don't live as loose scripts scattered across folders. They use structured package management so that every component has its own dependencies, its own entry point, and a clear boundary.
In this step, you will install uv, the modern Python project manager, and use it to create a monorepo workspace. Your workspace will hold two packages: one for generating data and one for analyzing it. Both packages share a single lockfile and a single virtual environment, exactly how production teams organize multi-component projects.
In this step, get ready to:
- Install uv and create the workspace root directory.
- Create the fraud-data and fraud-analysis packages with their dependencies.
- Configure the workspace dependency and initialize Git.
Install uv and create the workspace
uv is a fast Python project manager that replaces pip, venv, and pip-tools with a single tool. It handles dependencies, virtual environments, and lockfiles in one command.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check if uv is already installed by running:
uv --version
✔️ I see a version number
uv is ready to go. Skip ahead to creating the workspace below.
ⓧ Command not found
uv is not installed yet. Install it by running the command for your operating system:
macOS
- Install uv by running this command:
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows
- Install uv by running this command in PowerShell:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
- Close your terminal and reopen it so the new uv command is available on your PATH.
- Verify the installation by running:
uv --version
You should see a version number printed, confirming uv is installed.
Still seeing command not found?
Make sure you fully closed and reopened your terminal after installing. The installer adds uv to your PATH, but an existing terminal session does not pick up the change.
help me troubleshoot why uv is not found after installing it
What is uv?
uv is a Python project manager built in Rust by Astral (the team behind Ruff). It handles creating virtual environments, resolving dependencies, and locking versions. Its workspace feature lets multiple packages share one lockfile and one virtual environment, which is exactly what a multi-package project needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the workspace root project by running this command:
uv init vinted-fraud-detection
uv creates a new folder called vinted-fraud-detection with a pyproject.toml, a README.md, and a src/ directory inside it.
- Move into the new workspace folder by running:
cd vinted-fraud-detection
- Confirm you are inside the workspace by running:
ls
You should see pyproject.toml, README.md, and src listed in the output.
Don't see these files?
Make sure you ran uv init vinted-fraud-detection from your Desktop, not from inside an existing project folder. Check your current directory with pwd (macOS) or cd (Windows).
help me figure out why my vinted-fraud-detection folder is missing files
Create the workspace packages and add dependencies
Your workspace needs two packages. The first, fraud-data, generates the claims dataset. The second, fraud-analysis, detects fraud patterns and creates dashboards.
- Create the fraud-data package by running:
uv init packages/fraud-data
- Create the fraud-analysis package by running:
uv init packages/fraud-analysis
What just happened?
Running uv init inside an existing project automatically registers the new package as a workspace member. uv updates the root pyproject.toml to include a [tool.uv.workspace] table with members = ["packages/*"]. Both packages now share one lockfile and one virtual environment.
- Verify both packages were created by running:
ls packages/
You should see fraud-data and fraud-analysis listed.
- Add pandas as a dependency of the fraud-data package by running:
uv add --package fraud-data pandas
- Add pandas and matplotlib as dependencies of the fraud-analysis package by running:
uv add --package fraud-analysis pandas matplotlib
uv downloads the packages, adds them to each package's pyproject.toml, and updates the shared lockfile. You should see resolution and installation output in your terminal.
Why add pandas to both packages?
fraud-data uses pandas to build DataFrames from the generated claims. fraud-analysis uses pandas to load, filter, and aggregate that data. Each package declares its own dependencies so it can be installed independently.
Seeing a resolution error?
Make sure you are running these commands from the vinted-fraud-detection root folder, not from inside a package subfolder. uv searches upward from the current directory for the workspace root.
help me fix a uv add resolution error in my workspace
Configure the workspace dependency and initialize Git
The analysis package needs to import code from the data package. You will tell uv that fraud-analysis depends on fraud-data by editing the analysis package's pyproject.toml.
- Open the vinted-fraud-detection folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the vinted-fraud-detection folder on your Desktop, then click Open.
- In the Explorer sidebar, navigate to packages/fraud-analysis/pyproject.toml and click to open it.
The file currently looks something like this, with auto-generated content from uv init:
[project]
name = "fraud-analysis"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.11"
dependencies = [
"pandas",
"matplotlib",
]
[build-system]
requires = ["uv_build>=0.12.5,<0.13"]
build-backend = "uv_build"
- Replace the entire contents of packages/fraud-analysis/pyproject.toml with the following:
[project]
name = "fraud-analysis"
version = "0.1.0"
description = "Fraud pattern analysis and routing"
requires-python = ">=3.11"
dependencies = [
"fraud-data",
"pandas",
"matplotlib",
]
[tool.uv.sources]
fraud-data = { workspace = true }
[build-system]
requires = ["uv_build>=0.12.5,<0.13"]
build-backend = "uv_build"
What does this change?
You added "fraud-data" to the dependencies list, telling uv that fraud-analysis needs the fraud-data package. The [tool.uv.sources] section with workspace = true tells uv to resolve fraud-data from within the workspace instead of looking for it on PyPI.
This means fraud-analysis can import functions from fraud-data directly. Workspace dependencies are editable, so any change you make to fraud-data is immediately available to fraud-analysis without reinstalling.
- Save packages/fraud-analysis/pyproject.toml.
- Sync the workspace to install all dependencies by running this in your terminal:
uv sync
You should see uv resolve and install all packages. It creates a .venv virtual environment at the workspace root.
Seeing a build error?
Double check that the dependencies list in packages/fraud-analysis/pyproject.toml includes "fraud-data" and that the [tool.uv.sources] section spells fraud-data = { workspace = true } exactly. A typo in either place causes a resolution failure.
help me fix a uv sync error related to workspace dependencies
- Create the data and outputs directories at the workspace root by running:
mkdir data outputs
- Initialize a Git repository by running:
git init
You should see a message confirming the repository was initialized.
- Create a .gitignore file to keep generated files out of version control. In the VS Code Explorer sidebar, right-click in the file tree and select New File.
- Name the file .gitignore and press Enter.
- Paste the following into the new file:
data/
outputs/
.venv/
__pycache__/
*.pyc
uv.lock
What does this .gitignore do?
This file tells Git to ignore generated data (data/), analysis outputs (outputs/), the virtual environment (.venv/), Python bytecode caches (__pycache__/), and the lockfile (uv.lock). These files are generated locally and don't belong in version control.
- Save .gitignore.
Before you run the final verification, what do you expect to see if your workspace is set up correctly?
- Test that the workspace can import both libraries by running:
uv run python -c "import pandas; import matplotlib; print('Workspace ready!')"
You should see Workspace ready! printed. This confirms that uv resolved all dependencies and both pandas and matplotlib are importable from the shared virtual environment.
Seeing an import error?
Run uv sync again from the vinted-fraud-detection root. If packages are missing, uv will install them. Make sure you are running the command from the workspace root, not from inside a package subfolder.
help me fix import errors in my uv workspace
✔️ Awesome, I've got everything!
Great. Double check that you have saved all your files.
ⓧ I'd like to double check the full code
Here is the full content of each file you should have at the end of this step.
Root pyproject.toml:
[project]
name = "vinted-fraud-detection"
version = "0.1.0"
description = "Shipping fraud detection monorepo"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[tool.uv.workspace]
members = ["packages/*"]
[build-system]
requires = ["uv_build>=0.12.5,<0.13"]
build-backend = "uv_build"
packages/fraud-data/pyproject.toml:
[project]
name = "fraud-data"
version = "0.1.0"
description = "Shipping claims data generation"
requires-python = ">=3.11"
dependencies = ["pandas"]
[build-system]
requires = ["uv_build>=0.12.5,<0.13"]
build-backend = "uv_build"
packages/fraud-analysis/pyproject.toml:
[project]
name = "fraud-analysis"
version = "0.1.0"
description = "Fraud pattern analysis and routing"
requires-python = ">=3.11"
dependencies = [
"fraud-data",
"pandas",
"matplotlib",
]
[tool.uv.sources]
fraud-data = { workspace = true }
[build-system]
requires = ["uv_build>=0.12.5,<0.13"]
build-backend = "uv_build"
.gitignore:
data/
outputs/
.venv/
__pycache__/
*.pyc
uv.lock
Your workspace is ready. You have two packages wired together, all dependencies installed, and a clean Git setup. Next up, you will build the data generator that creates 500 realistic shipping claims with hidden fraud patterns baked in.
Build the Data Generator
Your workspace is ready with two packages and all dependencies installed. Now it's time to fill the fraud-data package with the code that generates realistic shipping claims.
Before you can detect fraud, you need data to investigate. Building the generator as its own package means future analysis projects in this series can share the same data source without duplicating code.
In this step, get ready to:
- Define shared constants and exports for the fraud-data package.
- Build a generator that creates 500 shipping claims with embedded fraud patterns.
- Run the generator and verify the output CSV file.
Define the package exports
The __init__.py file controls what gets exported when other packages import from fraud-data. You need to define the shared constants and import the functions you'll build next.
- In VS Code, open packages/fraud-data/src/fraud_data/__init__.py.
- Select all the existing auto-generated content and replace it with the following code:
from fraud_data.generate import generate_claims, save_claims
CLAIM_TYPES = ["item_not_received", "damaged_in_transit", "lost_by_carrier"]
CARRIERS = ["PostNL", "DPD", "DHL", "Mondial Relay", "InPost", "Colissimo"]
COUNTRIES = ["FR", "DE", "NL", "PL", "LT", "ES", "IT", "BE"]
__all__ = ["generate_claims", "save_claims", "CLAIM_TYPES", "CARRIERS", "COUNTRIES"]
What does this code do?
The import line brings in generate_claims and save_claims from a file called generate.py that you'll create next.
CLAIM_TYPES, CARRIERS, and COUNTRIES define the valid values for the dataset. Any package that imports from fraud-data gets these constants.
- Save __init__.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved __init__.py.
ⓧ I'd like to double check the full code
from fraud_data.generate import generate_claims, save_claims
CLAIM_TYPES = ["item_not_received", "damaged_in_transit", "lost_by_carrier"]
CARRIERS = ["PostNL", "DPD", "DHL", "Mondial Relay", "InPost", "Colissimo"]
COUNTRIES = ["FR", "DE", "NL", "PL", "LT", "ES", "IT", "BE"]
__all__ = ["generate_claims", "save_claims", "CLAIM_TYPES", "CARRIERS", "COUNTRIES"]
Create the claims generator
The generator needs to create three categories of claims: legitimate claims from normal users, deliberate false positives from users who happen to file multiple claims, and fraudulent claims from repeat offenders. Each category teaches the analysis engine something different.
- Create a new file at packages/fraud-data/src/fraud_data/generate.py.
- Add the imports and function signature by pasting the following code:
import os
import random
from datetime import datetime, timedelta
import pandas as pd
def generate_claims(
num_claims: int = 500,
num_legitimate_users: int = 200,
num_fraud_users: int = 8,
seed: int = 42,
) -> pd.DataFrame:
"""Generate simulated shipping compensation claims with embedded fraud patterns."""
random.seed(seed)
claim_types = ["item_not_received", "damaged_in_transit", "lost_by_carrier"]
carriers = ["PostNL", "DPD", "DHL", "Mondial Relay", "InPost", "Colissimo"]
countries = ["FR", "DE", "NL", "PL", "LT", "ES", "IT", "BE"]
claims: list[dict] = []
What does this set up?
The function takes parameters for total claims, legitimate users, fraud users, and a random seed. random.seed(seed) ensures every run produces identical data, making your analysis results reproducible.
The local constants mirror the ones in __init__.py. These are the raw values the generator picks from when creating each claim. claims is the list that will hold every generated claim as a dictionary.
Verify that the package structure is intact by running this quick import test:
- Test the import by running this command:
uv run python -c "import fraud_data; print(fraud_data.CLAIM_TYPES)"
You should see ['item_not_received', 'damaged_in_transit', 'lost_by_carrier'] printed in your terminal.
Seeing a ModuleNotFoundError?
- Make sure you saved both __init__.py and generate.py before running the command.
- Check that generate.py is in packages/fraud-data/src/fraud_data/ and not in a different directory.
- Make sure you are running the command from the vinted-fraud-detection workspace root.
Help me fix my ModuleNotFoundError when importing fraud_data.
Now you'll build the three categories of claims inside the generate_claims function.
- In generate.py, add the legitimate claims loop below the claims: list[dict] = [] line:
# Legitimate claims: spread across 200 users, mostly 1 claim each
target_legitimate = num_claims - (num_fraud_users * 7) - 9
for _ in range(target_legitimate):
claimant_id = f"USER_{random.randint(1, num_legitimate_users):04d}"
claim_date = datetime(2026, 1, 1) + timedelta(days=random.randint(0, 210))
item_value = round(random.uniform(5, 300), 2)
claim_amount = round(item_value * random.uniform(0.8, 1.0), 2)
claims.append({
"claim_id": f"CLM_{len(claims) + 1:05d}",
"claimant_id": claimant_id,
"claim_date": claim_date.strftime("%Y-%m-%d"),
"item_value": item_value,
"claim_amount": claim_amount,
"claim_type": random.choice(claim_types),
"carrier": random.choice(carriers),
"country": random.choice(countries),
})
What does this loop do?
The formula num_claims - (num_fraud_users * 7) - 9 calculates how many legitimate claims to generate so the total reaches 500.
Each claim gets a random user ID, date, value, and type. Item values range from 5 to 300 EUR, and claim amounts are 80-100% of the item value. These claims look exactly like what a real shipping company processes every day.
- Still in generate.py, add the false positive claims below the legitimate claims loop:
# False positives: 3 legitimate users who coincidentally file 3 claims each
false_positive_users = [f"USER_{i:04d}" for i in range(195, 198)]
for user in false_positive_users:
for _ in range(3):
claim_date = datetime(2026, 1, 1) + timedelta(
days=random.randint(0, 210)
)
item_value = round(random.uniform(10, 80), 2)
claim_amount = round(item_value * random.uniform(0.85, 1.0), 2)
claims.append({
"claim_id": f"CLM_{len(claims) + 1:05d}",
"claimant_id": user,
"claim_date": claim_date.strftime("%Y-%m-%d"),
"item_value": item_value,
"claim_amount": claim_amount,
"claim_type": random.choice(claim_types),
"carrier": random.choice(carriers),
"country": random.choice(countries),
})
Why include false positives?
Three users (USER_0195 through USER_0197) each file exactly 3 claims with low values (10-80 EUR) and mixed claim types. On the surface, they look suspicious because they file multiple claims.
But their low values and varied claim types make them legitimate. Including these tests whether your analysis engine can tell the difference between coincidence and actual fraud.
- Verify the partial build still runs by running this command:
uv run python -c "from fraud_data.generate import generate_claims; generate_claims(); print('Partial build OK')"
You should see Partial build OK printed with no errors.
- Back in generate.py, add the fraud claims below the false positives loop:
# Fraud claims: 8 users, each filing 5-10 high-value INR-heavy claims
fraud_user_ids = [f"USER_{i:04d}" for i in range(201, 201 + num_fraud_users)]
for user in fraud_user_ids:
num_user_claims = random.randint(5, 10)
base_date = datetime(2026, 2, 1) + timedelta(days=random.randint(0, 120))
for _ in range(num_user_claims):
claim_date = base_date + timedelta(days=random.randint(0, 25))
item_value = round(random.uniform(150, 500), 2)
claim_amount = round(item_value * random.uniform(0.95, 1.0), 2)
claim_type = (
"item_not_received"
if random.random() < 0.8
else random.choice(claim_types)
)
claims.append({
"claim_id": f"CLM_{len(claims) + 1:05d}",
"claimant_id": user,
"claim_date": claim_date.strftime("%Y-%m-%d"),
"item_value": item_value,
"claim_amount": claim_amount,
"claim_type": claim_type,
"carrier": random.choice(carriers[:3]),
"country": random.choice(countries[:3]),
})
What makes these claims fraudulent?
Each fraud user files 5-10 claims, far more than a normal user's 1-2. Values are high (150-500 EUR) and claim amounts are 95-100% of item value.
80% of their claims are item_not_received, a known fraud vector where 55% of merchants rank INR abuse as their most serious concern.
Fraud users are also concentrated on the first three carriers (carriers[:3]). This creates a carrier-specific pattern you'll analyze later.
- Add the shuffle, return statement, save_claims function, and the main entry point below the fraud claims loop:
random.shuffle(claims)
return pd.DataFrame(claims)
def save_claims(df: pd.DataFrame, path: str = "data/shipping_claims.csv") -> None:
"""Save claims DataFrame to CSV."""
os.makedirs(os.path.dirname(path), exist_ok=True)
df.to_csv(path, index=False)
print(f"Saved {len(df)} claims to {path}")
if __name__ == "__main__":
df = generate_claims()
save_claims(df)
print(f"\nPreview:\n{df.head(10).to_string(index=False)}")
What does this code do?
random.shuffle(claims) randomizes the order so fraud claims are not clustered at the end of the CSV file. The function returns everything as a pandas DataFrame.
save_claims writes the DataFrame to a CSV file, creating the data/ directory if it does not exist. The if __name__ == "__main__" block lets you run generate.py directly as a script.
- Save generate.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved generate.py before running it.
ⓧ I'd like to double check the full code
Scroll up and confirm your generate.py contains all five sections in this order:
- Imports and function signature (starting with import os, ending with claims: list[dict] = []).
- Legitimate claims loop (starting with the # Legitimate claims comment).
- False positives loop (starting with the # False positives comment).
- Fraud claims loop (starting with the # Fraud claims comment).
- random.shuffle(claims), return, the save_claims function, and the if __name__ == "__main__" block.
Run the generator and verify the output
Before you run the generator, how many total claims do you expect it to create? Think about the three categories you just coded: legitimate, false positives, and fraud.
- Run the generator from the workspace root by running this command:
uv run python -m fraud_data.generate
You should see Saved 500 claims to data/shipping_claims.csv printed, followed by a preview of the first 10 rows. The preview shows columns claim_id, claimant_id, claim_date, item_value, claim_amount, claim_type, carrier, and country.
- Confirm the CSV file was created by running:
ls data/
You should see shipping_claims.csv listed.
Seeing an error when running the generator?
- If you see IndentationError, check that each code section in generate.py is indented consistently. The function body should use 4 spaces, and nested loops should use 8 spaces.
- If you see NameError, make sure you pasted the code sections in the correct order. Each section references variables defined in the section above it.
- If you see ImportError, run uv sync from the workspace root to make sure pandas is installed.
Help me debug my generate.py script error.
Your dataset is ready with 500 claims waiting to be analyzed. Next up, you'll load this data and try to find fraud the way a real analyst would start: by reviewing individual claims one at a time.
Explore Claims and Hit the Review Wall
You now have 500 shipping claims sitting in data/shipping_claims.csv. As a fraud analyst at Vinted Go, your first instinct is to open the data, filter for high-value claims, and inspect them one by one.
This step deliberately leads you into the same wall real fraud teams hit. You will load the dataset, print summary statistics, filter for suspicious claims, and discover that each one looks perfectly legitimate on its own.
In this step, get ready to:
- Explore the claims dataset with summary statistics and breakdowns.
- Attempt to identify fraud by reviewing individual high-value claims.
Set up the analysis module and explore the data
The fraud-analysis package needs an entry point that connects to the analysis module you are about to create.
- In VS Code, open packages/fraud-analysis/src/fraud_analysis/__init__.py.
- Select all the auto-generated content and replace it with the following:
from fraud_analysis.analyze import run_analysis
__all__ = ["run_analysis"]
What does this code do?
This tells Python that when someone imports from fraud_analysis, they get the run_analysis function. The __all__ list controls what gets exported.
- Save __init__.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your __init__.py should look exactly like this:
from fraud_analysis.analyze import run_analysis
__all__ = ["run_analysis"]
Now you need the actual analysis file that __init__.py points to. This file will load the claims CSV and print an overview of the data.
- Create a new file called analyze.py in the same folder (packages/fraud-analysis/src/fraud_analysis/).
- Paste in the imports and data loader by copying the following code:
import os
import matplotlib.pyplot as plt
import pandas as pd
from fraud_data import generate_claims
from fraud_data.generate import save_claims
def load_or_generate(path: str = "data/shipping_claims.csv") -> pd.DataFrame:
"""Load claims from CSV, or generate fresh data if the file is missing."""
if os.path.exists(path):
return pd.read_csv(path)
print(f"{path} not found. Generating fresh data...")
df = generate_claims()
save_claims(df, path)
return df
What does this code do?
The imports bring in pandas for data analysis, matplotlib for charts (used in a later step), and the generate_claims function from your fraud-data package.
The load_or_generate function checks whether data/shipping_claims.csv already exists. If it does, it reads the file with pd.read_csv(). If not, it generates fresh data using the fraud-data package you built earlier.
- Save analyze.py.
- Confirm both files exist by running:
ls packages/fraud-analysis/src/fraud_analysis/
- You should see __init__.py and analyze.py listed in the output.
Don't see analyze.py?
Make sure you created the file inside packages/fraud-analysis/src/fraud_analysis/, not in the workspace root or another package.
help me find where I accidentally created analyze.py
The data loader is ready. Now add the explore function to print a full overview of the 500 claims, along with the entry point to make the file runnable.
- In analyze.py, add the following code below the load_or_generate function:
def explore(df: pd.DataFrame) -> None:
"""Print dataset overview and summary statistics."""
print("=" * 60)
print("DATASET OVERVIEW")
print("=" * 60)
print(f"Total claims: {len(df)}")
print(f"\nColumn types:\n{df.dtypes}")
print(f"\nFirst 5 claims:\n{df.head().to_string(index=False)}")
print(f"\nBasic statistics:\n{df.describe().to_string()}")
print(f"\nClaims by type:\n{df['claim_type'].value_counts().to_string()}")
print(f"\nClaims by carrier:\n{df['carrier'].value_counts().to_string()}")
def run_analysis() -> None:
"""Run the complete fraud analysis pipeline."""
df = load_or_generate()
explore(df)
if __name__ == "__main__":
run_analysis()
What does this code do?
- explore prints six views of the data: total count, column types with df.dtypes, the first 5 rows with df.head(), statistical summary with df.describe(), and distributions by claim type and carrier using value_counts().
- run_analysis is the main entry point. It loads the data and calls explore.
- The if __name__ == "__main__" block lets you run the file directly from the terminal.
- Save analyze.py.
- Run the analysis from the workspace root by running:
uv run python -m fraud_analysis.analyze
- You should see the DATASET OVERVIEW header followed by statistics about the 500 claims, breakdowns by claim type, and claim counts by carrier.
Seeing a ModuleNotFoundError?
- Make sure you are running the command from the workspace root (vinted-fraud-detection), not from inside a package folder.
- Check that __init__.py imports run_analysis from fraud_analysis.analyze, not from a different module path.
- Try running uv sync to make sure all workspace dependencies are installed.
help me fix my ModuleNotFoundError when running fraud_analysis
Attempt individual claim review
The dataset overview shows 500 claims across multiple carriers and claim types. A fraud analyst reviewing compensation requests for high-value items would naturally start by filtering for the expensive ones and inspecting each claim individually.
- In analyze.py, add the following function above the run_analysis function:
def individual_review(df: pd.DataFrame) -> None:
"""Attempt to find fraud by reviewing individual claims."""
print("\n" + "=" * 60)
print("INDIVIDUAL CLAIM REVIEW")
print("=" * 60)
high_value = df[df["claim_amount"] > 200]
print(f"\nHigh-value claims (over EUR 200): {len(high_value)} of {len(df)}")
print(high_value.head(10).to_string(index=False))
print("\nRandom sample of 5 claims:")
print(df.sample(5, random_state=1).to_string(index=False))
print("\n>> Every claim looks legitimate on its own.")
print(">> Individual review cannot detect fraud. We need pattern analysis.")
What does this code do?
- df[df["claim_amount"] > 200] uses boolean indexing to filter for claims over 200 EUR. This is how pandas selects rows where a condition is true.
- df.sample(5, random_state=1) pulls 5 random claims so you can inspect them for anything suspicious.
- The final print statements are the verdict. Spoiler: you will not be able to spot fraud by looking at individual claims.
Now wire this new function into the analysis pipeline.
- In analyze.py, scroll down to the run_analysis function.
- Find this line:
df = load_or_generate()
explore(df)
- Add individual_review(df) on a new line below explore(df), so the function now looks like this:
def run_analysis() -> None:
"""Run the complete fraud analysis pipeline."""
df = load_or_generate()
explore(df)
individual_review(df)
- Save analyze.py.
Before you run this, take a moment to think. You are about to see the high-value claims and a random sample. Do you think you will be able to spot which claimants are fraudsters just by looking at individual rows?
- Run the analysis again:
uv run python -m fraud_analysis.analyze
Scroll through the output. You see the dataset overview from before, followed by the high-value claims and a random sample. Every single claim has a reasonable amount, a plausible claim type, and a real carrier. Nothing stands out as obviously fraudulent.
Why can't you spot the fraud?
This is the exact wall real fraud teams hit. Each claim, viewed alone, is plausible. A customer says their package was lost and wants 280 EUR back. Sounds reasonable.
The fraud only becomes visible at the level of patterns. Is this the same person filing their seventh claim this month? Is 80% of their history concentrated on Item Not Received? You cannot answer those questions by reviewing one claim at a time.
Seeing a NameError for individual_review?
- Make sure the individual_review function is defined above run_analysis in the file. Python reads top to bottom, so the function must be defined before it is called.
- Check for typos in the function name. It must be individual_review in both the definition and the call inside run_analysis.
help me fix my NameError in analyze.py
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
The full analyze.py file is built from three code blocks you added above. Scroll up and confirm each one is present in this order:
- Imports and the load_or_generate function at the top of the file.
- The explore function.
- The individual_review function.
- The run_analysis function calling load_or_generate(), explore(df), and individual_review(df).
- The if __name__ == "__main__" block at the bottom.
You have just proven that looking at claims one at a time is a dead end. In the next step, you will shift from individual review to pattern analysis, using aggregation to surface the repeat claimants hiding in plain sight.
Surface Fraud Patterns with Aggregation
In the last step, you reviewed claims one by one and hit a wall. Every single claim looked perfectly legitimate on its own.
Fraud hides at the pattern level, not the claim level. By aggregating claims per claimant using pandas groupby, you shift from "is this one claim suspicious?" to "is this claimant's behavior suspicious?" This is exactly the shift real fraud teams make when connecting signals across claims to surface repeat offenders.
In this step, get ready to:
- Use groupby aggregation to compute per-claimant statistics like total claims, average value, and Item Not Received ratio.
- Identify repeat claimants and measure what share of all claims they account for.
Aggregate claims per claimant
Instead of looking at individual claims, you need to group all claims by the same claimant and compute summary statistics. This collapses 500 rows into one row per user, making patterns immediately visible.
- In VS Code, open packages/fraud-analysis/src/fraud_analysis/analyze.py.
- Find the individual_review function. Below it, add the aggregate_patterns function by copying this code:
def aggregate_patterns(df: pd.DataFrame) -> pd.DataFrame:
"""Aggregate claims per claimant to reveal hidden fraud patterns."""
print("\n" + "=" * 60)
print("PATTERN ANALYSIS (AGGREGATION)")
print("=" * 60)
# Calculate core statistics per claimant
claimant_stats = df.groupby("claimant_id").agg(
total_claims=("claim_id", "count"),
total_amount=("claim_amount", "sum"),
avg_claim=("claim_amount", "mean"),
).reset_index()
# Calculate Item Not Received ratio per claimant
inr_ratio = df.groupby("claimant_id")["claim_type"].apply(
lambda x: (x == "item_not_received").sum() / len(x)
)
inr_ratio.name = "inr_ratio"
claimant_stats = claimant_stats.merge(
inr_ratio.reset_index(), on="claimant_id"
)
# Sort so heaviest claimants appear first
claimant_stats = claimant_stats.sort_values("total_claims", ascending=False)
print("\nTop 15 claimants by claim count:")
print(claimant_stats.head(15).to_string(index=False))
return claimant_stats
What does this code do?
This function transforms 500 individual claim rows into one summary row per claimant.
- df.groupby("claimant_id").agg() groups all claims by the same user, then calculates total_claims, total_amount, and avg_claim in one pass.
- The second groupby calculates each claimant's inr_ratio: the fraction of their claims that are Item Not Received. A high ratio is a red flag.
- Sorting by total_claims descending puts the most active claimants at the top, so patterns jump out immediately.
The function is ready, but run_analysis does not call it yet. You need to wire it in.
- Scroll down to the run_analysis function at the bottom of the file.
- Find this line:
individual_review(df)
- Add a new line directly below it so the function looks like this:
individual_review(df)
claimant_stats = aggregate_patterns(df)
- Save analyze.py.
- Run the analysis by running this command in your terminal:
uv run python -m fraud_analysis.analyze
After the familiar dataset overview and individual review sections, you should see a new PATTERN ANALYSIS section with the top 15 claimants table. Some users have 5 to 10 claims while most have just 1.
Not seeing the PATTERN ANALYSIS section?
- Make sure you saved analyze.py after both edits.
- Check that the claimant_stats = aggregate_patterns(df) line is inside the run_analysis function, indented at the same level as the lines above it.
- Verify that aggregate_patterns is defined at the module level (no extra indentation), not nested inside another function.
Why isn't my aggregate_patterns output appearing in the terminal?
Surface repeat claimants
The top 15 table already shows a gap between normal users and heavy claimants. Now you need to quantify that gap so you can report exactly how many repeat claimants exist and how much of the total claims volume they represent.
- In analyze.py, find the return claimant_stats line at the end of your aggregate_patterns function.
- Add the following code directly above that return line:
# Filter for repeat claimants and measure their impact
repeat = claimant_stats[claimant_stats["total_claims"] >= 3]
repeat_claim_count = int(repeat["total_claims"].sum())
print(f"\nRepeat claimants (3+ claims): {len(repeat)}")
print(
f"Their share of all claims: {repeat_claim_count}/{len(df)} "
f"({repeat_claim_count / len(df) * 100:.1f}%)"
)
print("\n>> A small group of users accounts for a disproportionate share.")
print(">> This is the pattern fraud hides in.")
What does this code do?
- claimant_stats[claimant_stats["total_claims"] >= 3] uses boolean indexing to filter for anyone who filed 3 or more claims. This is the same filtering pattern you used in individual_review, but now applied to the aggregated stats instead of raw claims.
- The percentage calculation shows what fraction of all 500 claims come from this small group, proving that a handful of users generate disproportionate claim volume.
- Save analyze.py.
Before you run this, think about what the data will show. Will every user with 3+ claims look like a fraudster, or might some be legitimate?
- Run the analysis again by running this command:
uv run python -m fraud_analysis.analyze
Scroll to the PATTERN ANALYSIS section. You now see the repeat claimant count and their share of all claims printed below the top 15 table.
What should I see?
A clear gap appears in the top 15 table. Most users have 1 or 2 claims. A small cluster of users has 5 to 10 claims each with high average values and high INR ratios. These are the embedded fraud patterns.
You will also notice a few users with exactly 3 claims. These are deliberate false positives: legitimate users who happened to file multiple low-value claims with mixed types. Not every repeat claimant is a fraudster. The routing engine in the next step will need rules precise enough to tell them apart.
Seeing a KeyError or empty table?
- A KeyError on claimant_id means the CSV column name does not match. Open data/shipping_claims.csv and check that the header row contains claimant_id exactly.
- If the repeat claimant code runs but shows 0 repeat claimants, make sure you inserted it above the return claimant_stats line, not below it. Code after a return never executes.
- If you see an IndentationError, make sure every line in the new block uses exactly 4 spaces of indentation, matching the lines above it.
Help me debug my aggregate_patterns function.
✔️ Awesome, I've got everything!
Great. Double check you have saved analyze.py.
ⓧ I'd like to double check the full code
The full analyze.py file is longer than a single code block, so scroll up and confirm each section is present in this order:
- Imports at the top: os, matplotlib.pyplot, pandas, and the fraud_data imports.
- load_or_generate function (from Step 3).
- explore function (from Step 3).
- individual_review function (from Step 3).
- aggregate_patterns function with the groupby aggregation, INR ratio calculation, top 15 print, repeat claimant filter, and the return claimant_stats at the end.
- run_analysis function calling load_or_generate, explore, individual_review, and aggregate_patterns.
- The if __name__ == "__main__": block at the very end.
You have uncovered the fraud patterns hiding in the data. A small group of repeat claimants accounts for a disproportionate share of all claims. Next up, you will build a routing engine that turns these patterns into actionable decisions, assigning each flagged claimant to a named review queue with a clear reason.
Build the Routing Engine and Dashboard
In the last step, your groupby aggregation surfaced a small group of repeat claimants responsible for a disproportionate share of all claims. You can see WHO the suspects are. But a fraud analyst handing this table to a security team would immediately hear: "Why are these people flagged? What do we do with them?"
A numeric risk score (0-100) might seem like the answer, but rule-based detection cannot support that kind of confidence level. Routing is more honest and more actionable. Instead of a number, each flagged claimant gets assigned to a named review queue with a human-readable reason explaining exactly why they were flagged.
In this step, get ready to:
- Build a routing engine that applies three detection rules and assigns flagged claimants to named review queues with reasons.
- Create and save a four-panel fraud analysis dashboard ready to present to a security team.
Add the routing engine
The routing engine applies three detection rules to the claimant_stats DataFrame your aggregation step built. Each rule checks a different fraud signal and routes matching claimants to a named queue.
Why routing instead of scoring?
A risk score (e.g. 78/100) implies the system has calibrated confidence in fraud likelihood. Rule-based detection cannot support that. Routing says "this claim matched pattern X, send it to queue X" with a plain-English reason. The analyst knows WHY a claim was flagged, not just how suspicious a number says it is.
- In analyze.py, add the start of a route_claims function directly above run_analysis. Copy the function definition and the first detection rule:
def route_claims(
df: pd.DataFrame, claimant_stats: pd.DataFrame
) -> pd.DataFrame:
"""Apply detection rules and route flagged claimants to review queues."""
print("\n" + "=" * 60)
print("ROUTING ENGINE")
print("=" * 60)
routes: list[dict] = []
# Rule 1: Repeat Claimant
repeat_users = claimant_stats[claimant_stats["total_claims"] >= 5]
for _, row in repeat_users.iterrows():
routes.append({
"claimant_id": row["claimant_id"],
"queue": "REPEAT_CLAIMANT",
"reason": f"{int(row['total_claims'])} claims filed (threshold: 5)",
})
What does this code do?
The function takes both the raw claims DataFrame and the aggregated claimant_stats from the previous step. It builds a list of routing entries, where each entry contains a claimant_id, a queue name, and a reason string.
Rule 1 (REPEAT_CLAIMANT) flags anyone who filed 5 or more claims. The reason string says exactly how many claims they filed and what the threshold was.
- Still inside route_claims, add Rules 2 and 3 directly below the Rule 1 block you just pasted:
# Rule 2: INR Concentration
inr_suspects = claimant_stats[
(claimant_stats["inr_ratio"] > 0.6)
& (claimant_stats["total_claims"] >= 3)
]
for _, row in inr_suspects.iterrows():
routes.append({
"claimant_id": row["claimant_id"],
"queue": "INR_CONCENTRATION",
"reason": f"{row['inr_ratio']:.0%} Item Not Received claims (threshold: 60%)",
})
# Rule 3: High Value Anomaly
overall_avg = df["claim_amount"].mean()
high_avg_users = claimant_stats[
(claimant_stats["avg_claim"] > overall_avg * 2)
& (claimant_stats["total_claims"] >= 2)
]
for _, row in high_avg_users.iterrows():
ratio = row["avg_claim"] / overall_avg
routes.append({
"claimant_id": row["claimant_id"],
"queue": "HIGH_VALUE",
"reason": (
f"Avg claim EUR {row['avg_claim']:.0f} "
f"({ratio:.1f}x above average)"
),
})
What do Rules 2 and 3 detect?
Rule 2 (INR_CONCENTRATION) targets claimants whose Item Not Received claims make up over 60% of their total, with at least 3 claims. INR abuse is the most common form of claims fraud, where claimants say packages never arrived when they actually did.
Rule 3 (HIGH_VALUE) catches claimants whose average claim amount is more than double the overall average, with at least 2 claims. This surfaces users who consistently target high-value items.
- Complete route_claims by adding the result printing and return statement below Rule 3:
routed = pd.DataFrame(routes)
if routed.empty:
print("No claims routed.")
return routed
print(f"\nTotal routing decisions: {len(routed)}")
print(f"Unique users flagged: {routed['claimant_id'].nunique()}")
print(f"\nFlags by queue:\n{routed['queue'].value_counts().to_string()}")
for queue_name in routed["queue"].unique():
queue_items = routed[routed["queue"] == queue_name]
print(f"\n--- {queue_name} queue ({len(queue_items)} flags) ---")
for _, item in queue_items.iterrows():
print(f" {item['claimant_id']}: {item['reason']}")
return routed
What does the result output show?
The routing results are converted into a DataFrame and printed grouped by queue. Each flagged claimant appears with their ID and a human-readable reason. This is what you would hand to a security team reviewer.
Now connect the routing engine to the pipeline so you can see it in action.
- In run_analysis, find this line:
claimant_stats = aggregate_patterns(df)
- Add this line directly below it:
routed = route_claims(df, claimant_stats)
- Save analyze.py.
Before you run this, think about what you expect to see. Will all three rules fire? Will any of the false-positive users (those 3-claim users from the data generator) get flagged?
- Run the pipeline by running this command:
uv run python -m fraud_analysis.analyze
Scroll to the ROUTING ENGINE section. You should see routing decisions printed, with each flagged user listed under their assigned queue along with a reason explaining why they were flagged. The false-positive users (3 claims each, low values, mixed types) should NOT appear because they fall below the 5-claim threshold for REPEAT_CLAIMANT and their mixed claim types keep them below the 60% INR threshold.
Seeing a NameError or no routing output?
- Make sure route_claims is defined above run_analysis in the file. Python reads top to bottom, so the function must exist before it is called.
- Check that all three rule blocks are correctly indented inside the route_claims function body. Every line should be indented by 4 spaces relative to the def keyword.
- Verify the line routed = route_claims(df, claimant_stats) is inside run_analysis, not outside it.
Why isn't my routing engine printing any output?
Create the fraud analysis dashboard
Terminal output is great for analysis, but a security team needs something they can review in a meeting. A four-panel dashboard visualizes the patterns your routing engine detected and makes the case at a glance.
- In analyze.py, add the start of a create_dashboard function between route_claims and run_analysis. Begin with the figure setup and the first panel:
def create_dashboard(
df: pd.DataFrame,
claimant_stats: pd.DataFrame,
routed: pd.DataFrame,
) -> None:
"""Create and save a four-panel fraud analysis dashboard."""
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Shipping Claims Fraud Analysis Dashboard", fontsize=14)
max_claims = int(claimant_stats["total_claims"].max())
axes[0, 0].hist(
claimant_stats["total_claims"],
bins=range(1, max_claims + 2),
edgecolor="black",
color="steelblue",
)
axes[0, 0].set_xlabel("Number of Claims")
axes[0, 0].set_ylabel("Number of Claimants")
axes[0, 0].set_title("Claims per Claimant")
What does this code do?
plt.subplots(2, 2) creates a 2x2 grid of panels in a single figure. Panel 1 is a histogram showing how many claims each claimant filed. Most claimants cluster at 1-2 claims, but a visible tail of repeat claimants stretches to the right.
- Add Panels 2 and 3 directly below the Panel 1 code inside create_dashboard:
if not routed.empty:
queue_counts = routed["queue"].value_counts()
colors = ["tomato", "orange", "gold"]
axes[0, 1].bar(
queue_counts.index,
queue_counts.values,
color=colors[: len(queue_counts)],
edgecolor="black",
)
axes[0, 1].set_ylabel("Number of Flags")
axes[0, 1].set_title("Flags by Review Queue")
axes[0, 1].tick_params(axis="x", rotation=15)
if not routed.empty:
user_flags = (
routed.groupby("claimant_id")
.size()
.sort_values(ascending=True)
.tail(10)
)
axes[1, 0].barh(
user_flags.index,
user_flags.values,
color="tomato",
edgecolor="black",
)
axes[1, 0].set_xlabel("Number of Rules Triggered")
axes[1, 0].set_title("Top Flagged Claimants")
What do Panels 2 and 3 show?
Panel 2 is a bar chart showing how many flags each review queue received. This tells the security team where the workload is concentrated.
Panel 3 is a horizontal bar chart of the top 10 flagged claimants ranked by how many rules they triggered. A claimant who hits all three rules is more suspicious than one who hits only one.
- Complete create_dashboard by adding Panel 4 and the save/show logic below Panel 3:
type_counts = df["claim_type"].value_counts()
axes[1, 1].pie(
type_counts,
labels=type_counts.index,
autopct="%1.1f%%",
startangle=90,
)
axes[1, 1].set_title("Claims by Type")
plt.tight_layout()
os.makedirs("outputs", exist_ok=True)
plt.savefig("outputs/fraud_dashboard.png", dpi=150, bbox_inches="tight")
plt.show()
print("\nDashboard saved to outputs/fraud_dashboard.png")
What does the save logic do?
Panel 4 is a pie chart breaking down claim types across the entire dataset. plt.tight_layout() prevents panels from overlapping. The figure is saved as a PNG to outputs/fraud_dashboard.png at 150 DPI, then displayed in a window with plt.show().
Now connect the dashboard to the pipeline.
- In run_analysis, find this line:
routed = route_claims(df, claimant_stats)
- Add this line directly below it:
create_dashboard(df, claimant_stats, routed)
- Save analyze.py.
Before you run, think about what the histogram should look like. Most users filed just 1 claim. A handful filed 5-10. What shape does that create?
- Run the full pipeline by running this command:
uv run python -m fraud_analysis.analyze
A matplotlib window opens with the four-panel dashboard. You should see the histogram heavily weighted toward 1-2 claims with a long tail, colored bars for each review queue, a horizontal bar chart of your most-flagged claimants, and a pie chart of claim types.
- Close the matplotlib window to let the script finish.
- Confirm the dashboard was saved by running:
ls outputs/
You should see fraud_dashboard.png listed in the output.
Dashboard window not appearing?
- On some systems, matplotlib needs a GUI backend to display. If the script hangs or no window appears, check that outputs/fraud_dashboard.png was still saved. You can open the PNG file directly to view your dashboard.
- If you see an IndentationError, make sure all the code inside create_dashboard is indented consistently at 4 spaces per level.
- Verify that create_dashboard is defined between route_claims and run_analysis in the file.
Why isn't my matplotlib dashboard showing up?
✔️ Awesome, I've got everything!
Great. Double check you have saved analyze.py and that outputs/fraud_dashboard.png exists.
ⓧ I'd like to double check the full code
Your analyze.py should contain these functions in this exact order. Scroll through your file and confirm each one is present:
- Imports at the top: os, matplotlib.pyplot, pandas, generate_claims, and save_claims.
- load_or_generate() from Step 3.
- explore() from Step 3.
- individual_review() from Step 3.
- aggregate_patterns() from Step 4.
- route_claims() with all three rules (REPEAT_CLAIMANT, INR_CONCENTRATION, HIGH_VALUE), result printing, and return.
- create_dashboard() with four panels (histogram, bar chart, horizontal bar chart, pie chart) and save/show logic.
- run_analysis() calling all six functions: load_or_generate(), explore(), individual_review(), aggregate_patterns(), route_claims(), and create_dashboard().
- The if __name__ == "__main__": block at the bottom calling run_analysis().
Your fraud pipeline is complete. You have a routing engine that explains exactly why each claimant was flagged, and a dashboard that makes the case visually. This is the deliverable a fraud analyst would hand to a security team. Next up, an optional Secret Mission to take your analysis even further.
Secret mission
Add Carrier-Specific Cross-Referencing
Your routing engine flags suspicious claimants, but the Vinted Go role requires collaboration with carrier security teams. Which carriers are disproportionately associated with fraud patterns? Add carrier-specific cross-referencing to find out and produce the exact deliverable you would share with a carrier partner.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources as they are, pause them to come back later, or delete them entirely.
Resources you used:
- The vinted-fraud-detection project folder on your Desktop (including all Python packages, generated data, and output images).
- uv package manager installed on your system.
- Generated files: data/shipping_claims.csv, outputs/fraud_dashboard.png, and outputs/carrier_analysis.png (all inside the project folder).
Keep everything running
No action needed. Choose this if you plan to continue building on this fraud detection pipeline or want to revisit the data and dashboard later.
- Your vinted-fraud-detection folder stays on your Desktop with all code, data, and outputs intact.
- uv remains installed on your system. You can reuse it for other Python projects.
Pause - I'll come back to this later
Free up a small amount of disk space by removing generated data and outputs, while keeping your code intact.
- Open a terminal and navigate to your project folder by running:
- Delete the generated data and output files by running:
cd ~/Desktop/vinted-fraud-detection
rm -rf data/ outputs/
- Your source code and uv installation stay in place. When you return, regenerate the data by running uv run python -m fraud_data.generate from the workspace root.
Delete - I don't want to use this again
Remove all project resources and optionally uninstall uv from your system.
- Delete the entire project folder. Open a terminal and run the command for your operating system.
macOS
rm -rf ~/Desktop/vinted-fraud-detection
Windows
Remove-Item -Recurse -Force "$HOME\Desktop\vinted-fraud-detection"
This removes all project code, the virtual environment, generated data, and output images in one step.
- To uninstall uv from your system, follow the steps for your operating system.
macOS
Clean up cached data, then remove the binaries.
uv cache clean
rm -r "$(uv python dir)"
rm -r "$(uv tool dir)"
rm ~/.local/bin/uv ~/.local/bin/uvx
Windows
Clean up cached data, then remove the binaries.
uv cache clean
Remove-Item -Recurse -Force "$HOME\.local\bin\uv.exe"
Remove-Item -Recurse -Force "$HOME\.local\bin\uvx.exe"
Remove-Item -Recurse -Force "$HOME\.local\bin\uvw.exe"
What about Python, VS Code, and Git?
These are general-purpose tools you will use across many projects. Keep them installed unless you have a specific reason to remove them.
Nice Work!
Nice Work!
Nice work! You just built a shipping fraud detection pipeline structured as a professional Python monorepo. You went from raw compensation claims to a routing engine and dashboard ready to present to a security team.
You've learned how to:
- Structure a professional monorepo using uv workspaces with two interconnected packages (fraud-data and fraud-analysis) sharing a single lockfile and virtual environment.
- Use pandas groupby aggregation to surface fraud patterns that are completely invisible at the individual claim level, revealing that a small group of repeat claimants accounts for a disproportionate share of claims.
- Build a routing engine that flags suspicious claimants to named review queues with human-readable reasons, and generate a four-panel dashboard with matplotlib saved as a PNG for a security team to act on.
- Secret Mission: Completed an optional challenge to extend your fraud detection pipeline even further.
Ready to quiz yourself?