Audit Journal Entry Testing Pipeline
Build an audit analytics pipeline with SQL, Python, and Tableau.
Introduction
30 Second Summary
Every company records thousands of financial transactions each year. Auditors need to determine whether any of those entries have been fabricated or manipulated, but reviewing every line by hand is impossible.
In this project, you will build an audit data analytics pipeline that tests journal entries for fraud indicators using SQL, Python, and Benford's Law statistical analysis. You will visualize the results in a Tableau dashboard and embed it on a portfolio website.
What You'll Build
A complete journal entry testing workflow using Python, SQLite, and Tableau that mirrors the data extraction, statistical analysis, and visual reporting KPMG's Data & Analytics in Audit teams perform on real engagements.
By the end of this project, you'll have:
- A SQLite database with 5,000 synthetic journal entries containing injected audit anomalies.
- SQL queries and Python scripts that flag high-risk entries based on timing, amounts, and statistical patterns.
- An interactive Tableau Public dashboard visualizing flagged entries and Benford's Law analysis results, embedded on a portfolio website.
- Secret Mission: Extend the analysis with first-two-digit Benford's testing and automated risk scoring for more precise anomaly detection.
Do I need accounting or audit experience?
No prior audit or accounting experience is required. Basic Python and SQL knowledge is all you need. The project explains all audit concepts as you go.
Before We Start
Before you can analyze journal entries for fraud, you need a workspace with the right tools installed.
In this step, you will create a project folder, set up a Python virtual environment, and install the three packages your pipeline needs: pandas, matplotlib, and numpy.
In this step, get ready to:
- Create a project directory with a Python virtual environment.
- Install pandas, matplotlib, and numpy.
- Create placeholder files for your scripts and website.
Create your project directory and virtual environment
A virtual environment keeps your project's packages separate from your system-wide Python installation. This prevents version conflicts between projects.
- 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.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir audit-journal-testing
cd audit-journal-testing
- Create a Python virtual environment by running this command:
macOS
python3 -m venv venv
Windows
python -m venv venv
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt.
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Packages you install here only exist for this project.
The (venv) prefix in your terminal confirms the environment is active. All pip install commands will now install into this environment only.
Don't see (venv) in your prompt?
Make sure you ran the activation command from inside the audit-journal-testing folder. The venv folder must exist before you can activate it.
On Windows, if you see a permissions error, run this command first, then retry activation:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
Having trouble with something else?
Install Python packages
Your pipeline uses three packages: pandas for data manipulation, matplotlib for charting, and numpy for numerical calculations.
- Install all three packages by running this command:
pip install pandas matplotlib numpy
You should see output confirming that each package (and its dependencies) downloaded and installed successfully.
- Verify the installations by running:
pip list
You should see pandas, matplotlib, and numpy in the list of installed packages.
Seeing a permissions error or install failure?
Make sure (venv) still shows in your terminal prompt. If it disappeared, re-run the activation command from the previous substep.
If you see a network error, check your internet connection and try again.
Still stuck?
Create the project file structure
Your project needs five files: three Python scripts for data generation and analysis, plus two website files for the portfolio page.
- Create all five placeholder files by running:
macOS
touch generate_data.py audit_queries.py benford_analysis.py index.html style.css
Windows
New-Item generate_data.py, audit_queries.py, benford_analysis.py, index.html, style.css -ItemType File
- Confirm the files exist by running:
ls
You should see generate_data.py, audit_queries.py, benford_analysis.py, index.html, and style.css in the output (along with the venv folder).
What are these files for?
- generate_data.py will create a synthetic General Ledger with 5,000 journal entries.
- audit_queries.py will run SQL queries to flag high-risk entries.
- benford_analysis.py will test whether journal entry amounts follow Benford's Law.
- index.html and style.css will form your portfolio website with an embedded Tableau dashboard.
- Open this project 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.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the audit-journal-testing folder, then click Open.
You should see all five files listed in the Explorer sidebar on the left side of VS Code.
Don't see your files in the sidebar?
Make sure you opened the audit-journal-testing folder itself, not the Desktop or a parent folder. The folder name should appear at the top of the Explorer panel.
Need help?
Your workspace is ready. Next up, you will write the data generation script that creates a realistic General Ledger with 5,000 journal entries.
Generate Synthetic General Ledger Data
Your environment is ready. Now it is time to create the dataset your audit pipeline will analyze.
In a real audit engagement, you would extract journal entries from the client's ERP system. Since audit data is confidential, you will generate a synthetic General Ledger with 5,000 entries that mimic real-world patterns. You will also inject known anomalies so you can verify that your analysis scripts detect them later.
In this step, get ready to:
- Write a Python script that generates 5,000 realistic journal entries with proper accounting fields.
- Inject audit anomalies including weekend entries, round-number amounts, and near-threshold transactions.
- Load the generated data into a SQLite database and export it to CSV.
Set up the script configuration
The data generator needs a set of configuration constants that define what the synthetic ledger looks like. This includes the chart of accounts, user IDs, and description templates that mirror a real company's ERP system.
- Open generate_data.py in VS Code by clicking on it in the Explorer sidebar.
- Paste the following imports and configuration at the top of the file:
import pandas as pd
import numpy as np
import sqlite3
from datetime import datetime, timedelta
import random
import os
# Set random seeds for reproducible output
random.seed(42)
np.random.seed(42)
# Configuration constants
NUM_ENTRIES = 5000
START_DATE = datetime(2025, 1, 1)
END_DATE = datetime(2025, 12, 31)
MATERIALITY_THRESHOLD = 5000
What does this code do?
The random seeds ensure every learner gets the same dataset. This makes debugging easier because your output will match the expected results exactly.
The MATERIALITY_THRESHOLD of $5,000 represents the dollar amount above which entries require additional scrutiny from the audit team. You will inject entries just below this threshold later.
- Below the constants, paste the chart of accounts, user list, and description templates:
# Chart of accounts representing a typical company's GL structure
ACCOUNTS = [
("1000", "Cash and Cash Equivalents"),
("1100", "Accounts Receivable"),
("1200", "Inventory"),
("1300", "Prepaid Expenses"),
("2000", "Accounts Payable"),
("2100", "Accrued Liabilities"),
("2200", "Short-term Debt"),
("3000", "Common Stock"),
("3100", "Retained Earnings"),
("4000", "Revenue - Product Sales"),
("4100", "Revenue - Services"),
("5000", "Cost of Goods Sold"),
("5100", "Salaries and Wages"),
("5200", "Rent Expense"),
("5300", "Utilities Expense"),
("5400", "Depreciation Expense"),
("6000", "Interest Expense"),
("6100", "Miscellaneous Expense"),
]
# User IDs who post journal entries
USERS = ["JSMITH", "MBROWN", "KJONES", "LWILSON", "PTAYLOR", "ADMIN01", "SYSTEM"]
# Normal business descriptions for legitimate entries
DESCRIPTIONS_NORMAL = [
"Monthly recurring entry",
"Vendor payment processing",
"Revenue recognition",
"Payroll allocation",
"Depreciation - fixed assets",
"Inventory adjustment",
"Prepaid expense amortization",
"Accrual reversal",
"Intercompany transfer",
"Bank reconciliation adjustment",
]
# Vague descriptions that auditors consider red flags
DESCRIPTIONS_SUSPICIOUS = [
"Miscellaneous adjustment",
"Correction entry",
"Year-end adjustment",
"Manual override",
"Reclassification - misc",
]
Why two description lists?
Auditors look at journal entry descriptions as a risk indicator. Vague descriptions like "Miscellaneous adjustment" or "Manual override" suggest entries that bypassed normal controls. Your analysis scripts will flag these in a later step.
- Save generate_data.py.
You will not see any output yet. The next chunks add the functions that use these constants.
Write the normal entry generator functions
Real financial data follows Benford's Law. The first digit of naturally occurring numbers is not evenly distributed. The digit 1 appears about 30% of the time, while 9 appears only about 5%. Your generator needs to produce amounts that follow this pattern so the "normal" entries look realistic.
- Below the DESCRIPTIONS_SUSPICIOUS list, paste the amount generation function:
def generate_benford_amount(min_val=10, max_val=50000):
"""Generate amounts that follow Benford's Law distribution."""
mantissa = np.random.uniform(0, 1)
magnitude = np.random.uniform(np.log10(min_val), np.log10(max_val))
# Combine mantissa and magnitude to produce a Benford-conforming value
amount = 10 ** (mantissa + int(magnitude))
amount = min(max(amount, min_val), max_val)
return round(amount, 2)
How does this produce Benford-conforming amounts?
Benford's Law comes from logarithmic distribution. By generating a random mantissa and combining it with a random order of magnitude, the resulting numbers naturally cluster around lower first digits. This is the same distribution real transaction amounts follow.
- Below generate_benford_amount, paste the function that assembles a complete journal entry:
def generate_normal_entry(entry_id, date):
"""Generate a normal journal entry."""
# Weighted hours: most entries posted during peak business hours
hour = random.choices(
range(7, 19), weights=[1, 3, 5, 5, 5, 5, 5, 5, 5, 3, 2, 1]
)[0]
minute = random.randint(0, 59)
posting_time = f"{hour:02d}:{minute:02d}:00"
account = random.choice(ACCOUNTS)
amount = generate_benford_amount()
is_debit = random.choice([True, False])
user = random.choice(USERS[:5])
description = random.choice(DESCRIPTIONS_NORMAL)
# 70% automated, 30% manual mirrors typical ERP ratios
entry_type = random.choices(["Automated", "Manual"], weights=[70, 30])[0]
return {
"entry_id": entry_id,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": posting_time,
"account_code": account[0],
"account_name": account[1],
"debit_amount": amount if is_debit else 0,
"credit_amount": amount if not is_debit else 0,
"description": description,
"user_id": user,
"entry_type": entry_type,
}
What does this code do?
Each entry gets a realistic posting time weighted toward business hours (9am-4pm gets the heaviest weight). The function only uses the first five users from the USERS list for normal entries. ADMIN01 and SYSTEM are reserved for anomalous entries.
- Save generate_data.py.
You will not see a change yet. The next section adds the anomaly injection functions that your audit queries will detect.
Inject audit anomalies into the dataset
Auditors look for specific red-flag patterns in journal entries. You will inject three types of anomalies that align with ISA 240 and PCAOB AS 2401 testing standards: weekend entries, near-threshold amounts, and round numbers.
- Below generate_normal_entry, paste the weekend entry injection function:
def inject_weekend_entries(entries, start_id):
"""Inject entries posted on weekends."""
weekend_entries = []
for i in range(50):
# Find a weekend date
date = START_DATE + timedelta(days=random.randint(0, 364))
while date.weekday() < 5:
date += timedelta(days=1)
if date > END_DATE:
date = START_DATE + timedelta(days=random.randint(0, 364))
# Post during off-hours (8pm-11pm)
hour = random.randint(20, 23)
minute = random.randint(0, 59)
entry = {
"entry_id": start_id + i,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": f"{hour:02d}:{minute:02d}:00",
"account_code": random.choice(ACCOUNTS)[0],
"account_name": random.choice(ACCOUNTS)[1],
"debit_amount": generate_benford_amount(1000, 25000),
"credit_amount": 0,
"description": random.choice(DESCRIPTIONS_SUSPICIOUS),
"user_id": random.choice(["ADMIN01", "JSMITH"]),
"entry_type": "Manual",
}
weekend_entries.append(entry)
return weekend_entries
Why are weekend entries suspicious?
Legitimate accounting entries are almost always posted during business hours on weekdays. An entry posted at 10pm on a Saturday suggests someone bypassing normal review controls. These 50 entries use suspicious descriptions and the ADMIN01 user to make them even more detectable.
- Below inject_weekend_entries, paste the threshold entry injection function:
def inject_threshold_entries(entries, start_id):
"""Inject entries just below materiality threshold."""
threshold_entries = []
for i in range(30):
date = START_DATE + timedelta(days=random.randint(0, 364))
while date.weekday() >= 5:
date += timedelta(days=1)
# Amount between $4,500 and $4,999 (just below $5,000 threshold)
amount = round(random.uniform(4500, 4999), 2)
entry = {
"entry_id": start_id + i,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": f"{random.randint(8, 17):02d}:{random.randint(0, 59):02d}:00",
"account_code": "6100",
"account_name": "Miscellaneous Expense",
"debit_amount": amount,
"credit_amount": 0,
"description": random.choice(DESCRIPTIONS_SUSPICIOUS),
"user_id": random.choice(["ADMIN01", "KJONES"]),
"entry_type": "Manual",
}
threshold_entries.append(entry)
return threshold_entries
What is threshold splitting?
When someone knows that entries above $5,000 get extra scrutiny, they might split a $9,000 expense into two entries of $4,500. A cluster of entries just below the threshold is a classic fraud indicator. All 30 entries target the Miscellaneous Expense account, which adds to the suspicion.
- Below inject_threshold_entries, paste the round-number injection function:
def inject_round_number_entries(entries, start_id):
"""Inject round-number entries indicative of estimation or fabrication."""
round_entries = []
round_amounts = [1000, 2000, 3000, 5000, 10000, 15000, 20000, 25000]
for i in range(30):
date = START_DATE + timedelta(days=random.randint(0, 364))
while date.weekday() >= 5:
date += timedelta(days=1)
amount = random.choice(round_amounts)
entry = {
"entry_id": start_id + i,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": f"{random.randint(8, 17):02d}:{random.randint(0, 59):02d}:00",
"account_code": random.choice(["5100", "6000", "6100"])[0:4],
"account_name": "Manual Adjustment",
"debit_amount": amount,
"credit_amount": 0,
"description": random.choice(DESCRIPTIONS_SUSPICIOUS),
"user_id": random.choice(USERS[:5]),
"entry_type": "Manual",
}
round_entries.append(entry)
return round_entries
Why are round numbers a red flag?
Real transactions rarely land on exact thousands. A payment of $14,327.56 is normal. A payment of exactly $15,000 suggests estimation or fabrication. Auditors flag round amounts above $1,000 as entries that warrant additional testing.
- Save generate_data.py.
You now have all the injection functions in place. The next section ties everything together and writes the data to a database.
Load the generated data into a SQLite database
The main() function orchestrates the full pipeline: generate normal entries across the fiscal year, inject the anomalies, then write everything to a SQLite database and CSV file.
- Below inject_round_number_entries, paste the main() function and entry point:
def main():
print("Generating synthetic General Ledger data...")
# Generate normal weekday entries across the fiscal year
entries = []
days_in_year = (END_DATE - START_DATE).days
entries_per_day = NUM_ENTRIES // days_in_year
entry_id = 1
for day_offset in range(days_in_year):
date = START_DATE + timedelta(days=day_offset)
if date.weekday() >= 5:
continue
num_today = random.randint(entries_per_day - 5, entries_per_day + 5)
for _ in range(num_today):
entries.append(generate_normal_entry(entry_id, date))
entry_id += 1
if entry_id > NUM_ENTRIES - 110:
break
if entry_id > NUM_ENTRIES - 110:
break
# Inject anomalous entries
weekend_entries = inject_weekend_entries(entries, entry_id)
entry_id += len(weekend_entries)
entries.extend(weekend_entries)
threshold_entries = inject_threshold_entries(entries, entry_id)
entry_id += len(threshold_entries)
entries.extend(threshold_entries)
round_entries = inject_round_number_entries(entries, entry_id)
entries.extend(round_entries)
# Convert to DataFrame and export
df = pd.DataFrame(entries)
print(f"Generated {len(df)} journal entries")
# Save to SQLite database
os.makedirs("data", exist_ok=True)
conn = sqlite3.connect("data/general_ledger.db")
df.to_sql("journal_entries", conn, if_exists="replace", index=False)
conn.close()
print("Data loaded into SQLite database: data/general_ledger.db")
# Export CSV for Tableau
df.to_csv("data/journal_entries.csv", index=False)
print("Data exported to CSV: data/journal_entries.csv")
if __name__ == "__main__":
main()
What does this code do?
The loop stops normal entry generation at NUM_ENTRIES - 110 to leave room for the ~110 anomalous entries (50 weekend + 30 threshold + 30 round). This ensures the total stays close to 5,000.
The os.makedirs("data", exist_ok=True) line creates the data/ directory if it does not exist. The script then writes both a SQLite database (for SQL queries) and a CSV (for Tableau).
- Save generate_data.py.
- Run the script in your terminal by running this command:
python generate_data.py
You should see output like this:
Generating synthetic General Ledger data...
Generated 5000 journal entries
Data loaded into SQLite database: data/general_ledger.db
Data exported to CSV: data/journal_entries.csv
- Confirm the output files were created by running:
ls data/
You should see general_ledger.db and journal_entries.csv in the output.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is still active. You should see the environment name in parentheses at the start of your terminal prompt. If it is missing, reactivate it before running the script again.
If you see an error about pandas or numpy, re-run the install command from Step 1.
Help me fix a ModuleNotFoundError when running generate_data.py
- Verify the database is queryable by running a quick check in Python:
python -c "import sqlite3; conn = sqlite3.connect('data/general_ledger.db'); print(conn.execute('SELECT COUNT(*) FROM journal_entries').fetchone()[0], 'rows'); conn.close()"
You should see a number close to 5000 printed to the terminal. This confirms the SQLite database is populated and queryable.
Seeing 'no such table: journal_entries'?
This means the script did not write to the database successfully. Delete the data/ folder and run python generate_data.py again. Check for any error messages in the output.
Help me debug why my SQLite database has no tables
✔️ Awesome, I've got everything!
Great. Double check you have saved generate_data.py and that the data/ folder contains both general_ledger.db and journal_entries.csv.
ⓧ I'd like to double check the full code
Here is the complete generate_data.py file. Compare your code against this:
import pandas as pd
import numpy as np
import sqlite3
from datetime import datetime, timedelta
import random
import os
random.seed(42)
np.random.seed(42)
NUM_ENTRIES = 5000
START_DATE = datetime(2025, 1, 1)
END_DATE = datetime(2025, 12, 31)
MATERIALITY_THRESHOLD = 5000
ACCOUNTS = [
("1000", "Cash and Cash Equivalents"),
("1100", "Accounts Receivable"),
("1200", "Inventory"),
("1300", "Prepaid Expenses"),
("2000", "Accounts Payable"),
("2100", "Accrued Liabilities"),
("2200", "Short-term Debt"),
("3000", "Common Stock"),
("3100", "Retained Earnings"),
("4000", "Revenue - Product Sales"),
("4100", "Revenue - Services"),
("5000", "Cost of Goods Sold"),
("5100", "Salaries and Wages"),
("5200", "Rent Expense"),
("5300", "Utilities Expense"),
("5400", "Depreciation Expense"),
("6000", "Interest Expense"),
("6100", "Miscellaneous Expense"),
]
USERS = ["JSMITH", "MBROWN", "KJONES", "LWILSON", "PTAYLOR", "ADMIN01", "SYSTEM"]
DESCRIPTIONS_NORMAL = [
"Monthly recurring entry",
"Vendor payment processing",
"Revenue recognition",
"Payroll allocation",
"Depreciation - fixed assets",
"Inventory adjustment",
"Prepaid expense amortization",
"Accrual reversal",
"Intercompany transfer",
"Bank reconciliation adjustment",
]
DESCRIPTIONS_SUSPICIOUS = [
"Miscellaneous adjustment",
"Correction entry",
"Year-end adjustment",
"Manual override",
"Reclassification - misc",
]
def generate_benford_amount(min_val=10, max_val=50000):
"""Generate amounts that follow Benford's Law distribution."""
mantissa = np.random.uniform(0, 1)
magnitude = np.random.uniform(np.log10(min_val), np.log10(max_val))
amount = 10 ** (mantissa + int(magnitude))
amount = min(max(amount, min_val), max_val)
return round(amount, 2)
def generate_normal_entry(entry_id, date):
"""Generate a normal journal entry."""
hour = random.choices(
range(7, 19), weights=[1, 3, 5, 5, 5, 5, 5, 5, 5, 3, 2, 1]
)[0]
minute = random.randint(0, 59)
posting_time = f"{hour:02d}:{minute:02d}:00"
account = random.choice(ACCOUNTS)
amount = generate_benford_amount()
is_debit = random.choice([True, False])
user = random.choice(USERS[:5])
description = random.choice(DESCRIPTIONS_NORMAL)
entry_type = random.choices(["Automated", "Manual"], weights=[70, 30])[0]
return {
"entry_id": entry_id,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": posting_time,
"account_code": account[0],
"account_name": account[1],
"debit_amount": amount if is_debit else 0,
"credit_amount": amount if not is_debit else 0,
"description": description,
"user_id": user,
"entry_type": entry_type,
}
def inject_weekend_entries(entries, start_id):
"""Inject entries posted on weekends."""
weekend_entries = []
for i in range(50):
date = START_DATE + timedelta(days=random.randint(0, 364))
while date.weekday() < 5:
date += timedelta(days=1)
if date > END_DATE:
date = START_DATE + timedelta(days=random.randint(0, 364))
hour = random.randint(20, 23)
minute = random.randint(0, 59)
entry = {
"entry_id": start_id + i,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": f"{hour:02d}:{minute:02d}:00",
"account_code": random.choice(ACCOUNTS)[0],
"account_name": random.choice(ACCOUNTS)[1],
"debit_amount": generate_benford_amount(1000, 25000),
"credit_amount": 0,
"description": random.choice(DESCRIPTIONS_SUSPICIOUS),
"user_id": random.choice(["ADMIN01", "JSMITH"]),
"entry_type": "Manual",
}
weekend_entries.append(entry)
return weekend_entries
def inject_threshold_entries(entries, start_id):
"""Inject entries just below materiality threshold."""
threshold_entries = []
for i in range(30):
date = START_DATE + timedelta(days=random.randint(0, 364))
while date.weekday() >= 5:
date += timedelta(days=1)
amount = round(random.uniform(4500, 4999), 2)
entry = {
"entry_id": start_id + i,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": f"{random.randint(8, 17):02d}:{random.randint(0, 59):02d}:00",
"account_code": "6100",
"account_name": "Miscellaneous Expense",
"debit_amount": amount,
"credit_amount": 0,
"description": random.choice(DESCRIPTIONS_SUSPICIOUS),
"user_id": random.choice(["ADMIN01", "KJONES"]),
"entry_type": "Manual",
}
threshold_entries.append(entry)
return threshold_entries
def inject_round_number_entries(entries, start_id):
"""Inject round-number entries indicative of estimation or fabrication."""
round_entries = []
round_amounts = [1000, 2000, 3000, 5000, 10000, 15000, 20000, 25000]
for i in range(30):
date = START_DATE + timedelta(days=random.randint(0, 364))
while date.weekday() >= 5:
date += timedelta(days=1)
amount = random.choice(round_amounts)
entry = {
"entry_id": start_id + i,
"posting_date": date.strftime("%Y-%m-%d"),
"posting_time": f"{random.randint(8, 17):02d}:{random.randint(0, 59):02d}:00",
"account_code": random.choice(["5100", "6000", "6100"])[0:4],
"account_name": "Manual Adjustment",
"debit_amount": amount,
"credit_amount": 0,
"description": random.choice(DESCRIPTIONS_SUSPICIOUS),
"user_id": random.choice(USERS[:5]),
"entry_type": "Manual",
}
round_entries.append(entry)
return round_entries
def main():
print("Generating synthetic General Ledger data...")
entries = []
days_in_year = (END_DATE - START_DATE).days
entries_per_day = NUM_ENTRIES // days_in_year
entry_id = 1
for day_offset in range(days_in_year):
date = START_DATE + timedelta(days=day_offset)
if date.weekday() >= 5:
continue
num_today = random.randint(entries_per_day - 5, entries_per_day + 5)
for _ in range(num_today):
entries.append(generate_normal_entry(entry_id, date))
entry_id += 1
if entry_id > NUM_ENTRIES - 110:
break
if entry_id > NUM_ENTRIES - 110:
break
weekend_entries = inject_weekend_entries(entries, entry_id)
entry_id += len(weekend_entries)
entries.extend(weekend_entries)
threshold_entries = inject_threshold_entries(entries, entry_id)
entry_id += len(threshold_entries)
entries.extend(threshold_entries)
round_entries = inject_round_number_entries(entries, entry_id)
entries.extend(round_entries)
df = pd.DataFrame(entries)
print(f"Generated {len(df)} journal entries")
os.makedirs("data", exist_ok=True)
conn = sqlite3.connect("data/general_ledger.db")
df.to_sql("journal_entries", conn, if_exists="replace", index=False)
conn.close()
print("Data loaded into SQLite database: data/general_ledger.db")
df.to_csv("data/journal_entries.csv", index=False)
print("Data exported to CSV: data/journal_entries.csv")
if __name__ == "__main__":
main()
Your synthetic General Ledger is ready. Next up, you will write SQL queries to systematically detect each anomaly type you just injected.
Run SQL-Based Audit Analytics
Your General Ledger database is loaded with 5,000 journal entries, complete with injected anomalies. Now it is time to find them.
In a real audit engagement, KPMG's D&A teams run SQL queries against the entire population of journal entries to flag high-risk transactions for further investigation. You will write those same queries to surface suspicious patterns hiding in the data.
In this step, get ready to:
- Write SQL queries that identify weekend postings, off-hours entries, and round-number amounts.
- Add queries for near-threshold entries, suspicious descriptions, and duplicate entries.
- Export all flagged entries to CSV files for Tableau visualization.
Write the first three risk flag queries
Auditors look for patterns that suggest entries were posted outside normal business processes. Weekend postings, off-hours activity, and suspiciously round amounts are classic fraud indicators defined by PCAOB AS 2401 standards.
- In VS Code, open audit_queries.py (the empty placeholder file in your project folder).
- Copy the following code into the file:
import sqlite3
import pandas as pd
import os
def run_audit_queries():
"""Run SQL-based audit analytics on the General Ledger."""
conn = sqlite3.connect("data/general_ledger.db")
print("=" * 60)
print("AUDIT JOURNAL ENTRY TESTING - SQL ANALYTICS")
print("=" * 60)
# Flag 1: Weekend entries
weekend_query = """
SELECT *
FROM journal_entries
WHERE CAST(strftime('%w', posting_date) AS INTEGER) IN (0, 6)
"""
weekend_df = pd.read_sql_query(weekend_query, conn)
print(f"\n[FLAG 1] Weekend Entries: {len(weekend_df)} found")
# Flag 2: Off-hours entries (before 7am or after 8pm)
offhours_query = """
SELECT *
FROM journal_entries
WHERE CAST(substr(posting_time, 1, 2) AS INTEGER) < 7
OR CAST(substr(posting_time, 1, 2) AS INTEGER) >= 20
"""
offhours_df = pd.read_sql_query(offhours_query, conn)
print(f"[FLAG 2] Off-Hours Entries: {len(offhours_df)} found")
# Flag 3: Round amounts above $1,000
round_query = """
SELECT *
FROM journal_entries
WHERE (debit_amount > 1000 AND debit_amount = CAST(debit_amount AS INTEGER)
AND debit_amount % 1000 = 0)
OR (credit_amount > 1000 AND credit_amount = CAST(credit_amount AS INTEGER)
AND credit_amount % 1000 = 0)
"""
round_df = pd.read_sql_query(round_query, conn)
print(f"[FLAG 3] Round Amount Entries (>$1,000): {len(round_df)} found")
What do these queries do?
Each query targets a different fraud indicator:
- Flag 1 uses strftime('%w', posting_date) to extract the day of the week (0 = Sunday, 6 = Saturday). Legitimate entries rarely post on weekends.
- Flag 2 extracts the hour from posting_time and flags anything before 7am or after 8pm. These are outside normal business hours.
- Flag 3 catches perfectly round amounts above $1,000 (like $5,000 or $10,000). Round numbers often indicate estimated or fabricated entries.
Why test 100% of entries instead of sampling?
Traditional audit uses statistical sampling to test a subset of transactions. KPMG's D&A approach analyzes the complete population of journal entries, running every single one against audit rules.
This means nothing slips through the cracks. A fraudulent entry that might never land in a sample gets caught every time when you test the full dataset.
- You won't run the file yet. The next chunks complete the remaining queries and export logic.
Add threshold, description, and duplicate detection queries
The next three flags catch more subtle manipulation. Entries just below a materiality threshold suggest someone is deliberately avoiding review. Vague descriptions and duplicates point to entries that need closer inspection.
- In audit_queries.py, add the following code directly below the last line you just wrote:
# Flag 4: Entries near materiality threshold ($4,500 - $4,999)
threshold_query = """
SELECT *
FROM journal_entries
WHERE (debit_amount BETWEEN 4500 AND 4999)
OR (credit_amount BETWEEN 4500 AND 4999)
"""
threshold_df = pd.read_sql_query(threshold_query, conn)
print(f"[FLAG 4] Near-Threshold Entries ($4,500-$4,999): {len(threshold_df)} found")
# Flag 5: Manual entries with suspicious descriptions
suspicious_query = """
SELECT *
FROM journal_entries
WHERE entry_type = 'Manual'
AND (description LIKE '%misc%'
OR description LIKE '%override%'
OR description LIKE '%correction%'
OR description LIKE '%adjustment%')
"""
suspicious_df = pd.read_sql_query(suspicious_query, conn)
print(f"[FLAG 5] Suspicious Description Entries: {len(suspicious_df)} found")
# Flag 6: Duplicate entries (same date, amount, account)
duplicate_query = """
SELECT a.*
FROM journal_entries a
INNER JOIN (
SELECT posting_date, account_code, debit_amount, credit_amount
FROM journal_entries
WHERE debit_amount > 0 OR credit_amount > 0
GROUP BY posting_date, account_code, debit_amount, credit_amount
HAVING COUNT(*) > 1
) b ON a.posting_date = b.posting_date
AND a.account_code = b.account_code
AND a.debit_amount = b.debit_amount
AND a.credit_amount = b.credit_amount
"""
duplicate_df = pd.read_sql_query(duplicate_query, conn)
print(f"[FLAG 6] Potential Duplicate Entries: {len(duplicate_df)} found")
What do these queries detect?
- Flag 4 finds entries between $4,500 and $4,999. The materiality threshold is $5,000, so entries just below it suggest someone is deliberately staying under the review limit.
- Flag 5 filters for manual entries with vague descriptions like "miscellaneous" or "correction." These are commonly used to disguise improper transactions.
- Flag 6 uses a self-join with a subquery to find entries that share the same date, account, and amount. Duplicates can indicate copy-paste errors or intentional double-booking.
- You won't run the file yet. The next chunk adds the export logic that ties everything together.
Export flagged entries and run the analysis
With all six flags written, you need to combine the results, remove duplicates (an entry can trigger multiple flags), and export everything to CSV files that Tableau can read.
- In audit_queries.py, add the following code directly below the Flag 6 query:
# Combine all flags into a summary
all_flagged = pd.concat(
[
weekend_df.assign(risk_flag="Weekend Entry"),
offhours_df.assign(risk_flag="Off-Hours Entry"),
round_df.assign(risk_flag="Round Amount"),
threshold_df.assign(risk_flag="Near Threshold"),
suspicious_df.assign(risk_flag="Suspicious Description"),
duplicate_df.assign(risk_flag="Potential Duplicate"),
]
).drop_duplicates(subset=["entry_id"])
os.makedirs("output", exist_ok=True)
all_flagged.to_csv("output/flagged_entries.csv", index=False)
print(f"\nTotal unique flagged entries: {len(all_flagged)}")
print("Exported to: output/flagged_entries.csv")
# Summary by flag type
summary = (
all_flagged.groupby("risk_flag")
.agg(count=("entry_id", "count"), total_debit=("debit_amount", "sum"))
.reset_index()
)
summary.to_csv("output/flag_summary.csv", index=False)
print("\nFlag summary exported to: output/flag_summary.csv")
print("\n" + "=" * 60)
print("SUMMARY BY RISK FLAG")
print("=" * 60)
print(summary.to_string(index=False))
conn.close()
if __name__ == "__main__":
run_audit_queries()
What does the export section do?
- pd.concat stacks all six DataFrames together and adds a risk_flag column so you know which rule flagged each entry.
- drop_duplicates(subset=["entry_id"]) ensures an entry that triggers multiple flags only appears once in the final export.
- The summary groups flagged entries by category and calculates a count and total debit amount for each flag type. This becomes the data source for your Tableau dashboard.
- Save audit_queries.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before running it.
ⓧ I'd like to double check the full code
Here is the complete audit_queries.py file. Make sure yours matches:
import sqlite3
import pandas as pd
import os
def run_audit_queries():
"""Run SQL-based audit analytics on the General Ledger."""
conn = sqlite3.connect("data/general_ledger.db")
print("=" * 60)
print("AUDIT JOURNAL ENTRY TESTING - SQL ANALYTICS")
print("=" * 60)
# Flag 1: Weekend entries
weekend_query = """
SELECT *
FROM journal_entries
WHERE CAST(strftime('%w', posting_date) AS INTEGER) IN (0, 6)
"""
weekend_df = pd.read_sql_query(weekend_query, conn)
print(f"\n[FLAG 1] Weekend Entries: {len(weekend_df)} found")
# Flag 2: Off-hours entries (before 7am or after 8pm)
offhours_query = """
SELECT *
FROM journal_entries
WHERE CAST(substr(posting_time, 1, 2) AS INTEGER) < 7
OR CAST(substr(posting_time, 1, 2) AS INTEGER) >= 20
"""
offhours_df = pd.read_sql_query(offhours_query, conn)
print(f"[FLAG 2] Off-Hours Entries: {len(offhours_df)} found")
# Flag 3: Round amounts above $1,000
round_query = """
SELECT *
FROM journal_entries
WHERE (debit_amount > 1000 AND debit_amount = CAST(debit_amount AS INTEGER)
AND debit_amount % 1000 = 0)
OR (credit_amount > 1000 AND credit_amount = CAST(credit_amount AS INTEGER)
AND credit_amount % 1000 = 0)
"""
round_df = pd.read_sql_query(round_query, conn)
print(f"[FLAG 3] Round Amount Entries (>$1,000): {len(round_df)} found")
# Flag 4: Entries near materiality threshold ($4,500 - $4,999)
threshold_query = """
SELECT *
FROM journal_entries
WHERE (debit_amount BETWEEN 4500 AND 4999)
OR (credit_amount BETWEEN 4500 AND 4999)
"""
threshold_df = pd.read_sql_query(threshold_query, conn)
print(f"[FLAG 4] Near-Threshold Entries ($4,500-$4,999): {len(threshold_df)} found")
# Flag 5: Manual entries with suspicious descriptions
suspicious_query = """
SELECT *
FROM journal_entries
WHERE entry_type = 'Manual'
AND (description LIKE '%misc%'
OR description LIKE '%override%'
OR description LIKE '%correction%'
OR description LIKE '%adjustment%')
"""
suspicious_df = pd.read_sql_query(suspicious_query, conn)
print(f"[FLAG 5] Suspicious Description Entries: {len(suspicious_df)} found")
# Flag 6: Duplicate entries (same date, amount, account)
duplicate_query = """
SELECT a.*
FROM journal_entries a
INNER JOIN (
SELECT posting_date, account_code, debit_amount, credit_amount
FROM journal_entries
WHERE debit_amount > 0 OR credit_amount > 0
GROUP BY posting_date, account_code, debit_amount, credit_amount
HAVING COUNT(*) > 1
) b ON a.posting_date = b.posting_date
AND a.account_code = b.account_code
AND a.debit_amount = b.debit_amount
AND a.credit_amount = b.credit_amount
"""
duplicate_df = pd.read_sql_query(duplicate_query, conn)
print(f"[FLAG 6] Potential Duplicate Entries: {len(duplicate_df)} found")
# Combine all flags into a summary
all_flagged = pd.concat(
[
weekend_df.assign(risk_flag="Weekend Entry"),
offhours_df.assign(risk_flag="Off-Hours Entry"),
round_df.assign(risk_flag="Round Amount"),
threshold_df.assign(risk_flag="Near Threshold"),
suspicious_df.assign(risk_flag="Suspicious Description"),
duplicate_df.assign(risk_flag="Potential Duplicate"),
]
).drop_duplicates(subset=["entry_id"])
os.makedirs("output", exist_ok=True)
all_flagged.to_csv("output/flagged_entries.csv", index=False)
print(f"\nTotal unique flagged entries: {len(all_flagged)}")
print("Exported to: output/flagged_entries.csv")
# Summary by flag type
summary = (
all_flagged.groupby("risk_flag")
.agg(count=("entry_id", "count"), total_debit=("debit_amount", "sum"))
.reset_index()
)
summary.to_csv("output/flag_summary.csv", index=False)
print("\nFlag summary exported to: output/flag_summary.csv")
print("\n" + "=" * 60)
print("SUMMARY BY RISK FLAG")
print("=" * 60)
print(summary.to_string(index=False))
conn.close()
if __name__ == "__main__":
run_audit_queries()
- Run the audit analysis script by running this command in your terminal:
python audit_queries.py
You should see output listing each flag category with a count of entries found, followed by a summary table showing all six risk flag types with their counts and total debit amounts.
- Confirm that an output folder was created by running:
ls output
You should see flagged_entries.csv and flag_summary.csv in the output.
Seeing an error when running the script?
- If you see no such table: journal_entries, you need to run generate_data.py first. The database must exist before the audit queries can read it.
- If you see an IndentationError, make sure all code inside the run_audit_queries() function is indented with 4 spaces. The queries, DataFrames, and export logic all live inside that function.
- If you see NameError for one of the DataFrames (like weekend_df), check that all six query blocks are present and that none were accidentally deleted when pasting.
Why isn't my audit_queries.py script producing output?
Your SQL audit queries just scanned every single journal entry in the General Ledger and surfaced the suspicious ones. Next up, you will apply Benford's Law to test whether the amount distribution itself shows signs of manipulation.
Apply Benford's Law Analysis
Your SQL queries identified suspicious entries based on known risk patterns like timing and amounts. Now you'll apply a statistical technique that approaches the data from a completely different angle.
Benford's Law predicts that in naturally occurring numerical datasets, lower first digits appear more frequently than higher ones. If journal entry amounts deviate from this expected pattern, it could indicate fabricated or manipulated data. This is a core technique in KPMG's Journal Entry Testing workflow.
In this step, get ready to:
- Extract first digits from all debit amounts and compare against Benford's expected distribution.
- Calculate the Mean Absolute Deviation (MAD) and classify data conformity.
- Generate a bar chart and export results to CSV for Tableau.
Write the Benford's Law helper functions
The mathematical foundation of Benford's Law is a simple formula: P(d) = log10(1 + 1/d), where d is a digit from 1 to 9. You need two helper functions: one to calculate the expected probability for each digit, and one to extract the first significant digit from a dollar amount.
- In VS Code, open benford_analysis.py (currently an empty placeholder file).
- Replace the empty file with the following imports and helper functions:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
def benford_expected(digit):
return np.log10(1 + 1 / digit)
def extract_first_digit(value):
if value <= 0:
return None
s = str(abs(value))
for char in s:
if char.isdigit() and char != "0":
return int(char)
return None
What do these functions do?
- benford_expected(digit) calculates the probability a given digit (1-9) appears as the leading digit in naturally occurring data. Digit 1 has about a 30.1% chance, while digit 9 has only about 4.6%.
- extract_first_digit(value) walks through the string representation of a number, skipping zeros and decimal points, until it finds the first meaningful digit. This handles amounts like 0.45 (first digit is 4) correctly.
Build the frequency comparison logic
With the helpers in place, you need a main analysis function that loads the journal entry data, extracts first digits from every debit amount, and builds a comparison table of observed vs. expected frequencies.
- Below the extract_first_digit function, add the following run_benford_analysis function:
def run_benford_analysis():
print("=" * 60)
print("BENFORD'S LAW ANALYSIS - FIRST DIGIT TEST")
print("=" * 60)
df = pd.read_csv("data/journal_entries.csv")
amounts = df[df["debit_amount"] > 0]["debit_amount"]
print(f"\nAnalyzing {len(amounts)} debit entries")
first_digits = amounts.apply(extract_first_digit).dropna().astype(int)
observed_counts = first_digits.value_counts().sort_index()
total = len(first_digits)
observed_freq = observed_counts / total
digits = list(range(1, 10))
expected_freq = pd.Series(
[benford_expected(d) for d in digits], index=digits
)
results = pd.DataFrame(
{
"digit": digits,
"observed_count": [observed_counts.get(d, 0) for d in digits],
"observed_proportion": [observed_freq.get(d, 0) for d in digits],
"expected_proportion": [expected_freq[d] for d in digits],
"deviation": [
abs(observed_freq.get(d, 0) - expected_freq[d]) for d in digits
],
}
)
What does this code do?
- It reads the CSV export of your General Ledger and filters for rows where debit_amount is greater than zero.
- It applies extract_first_digit to every debit amount, then counts how often each digit 1-9 appears.
- It builds a DataFrame called results with columns for observed counts, observed proportions, expected Benford proportions, and the absolute deviation between observed and expected for each digit.
Why only debit amounts?
In journal entry testing, auditors typically focus on debits because fraudulent entries often inflate expenses or create fictitious assets. Credits are the balancing side. Analyzing debits gives a cleaner signal of potential manipulation.
Calculate MAD and classify conformity
The Mean Absolute Deviation (MAD) measures how far your observed digit distribution sits from what Benford's Law predicts. Nigrini's empirical thresholds tell you whether the deviation is normal or warrants investigation.
- Directly below the results DataFrame (still inside run_benford_analysis), add the MAD calculation and conformity classification:
mad = results["deviation"].mean()
if mad < 0.006:
conformity = "Close Conformity"
elif mad < 0.012:
conformity = "Acceptable Conformity"
elif mad < 0.015:
conformity = "Marginally Acceptable"
else:
conformity = "Nonconformity"
print(f"\nMean Absolute Deviation (MAD): {mad:.6f}")
print(f"Conformity Assessment: {conformity}")
print("\nDigit Distribution:")
print(results.to_string(index=False))
What are Nigrini's MAD thresholds?
Mark Nigrini, the leading researcher on Benford's Law in forensic accounting, established these empirical thresholds for first-digit tests:
- Close conformity: MAD < 0.006.
- Acceptable conformity: MAD < 0.012.
- Marginally acceptable: MAD < 0.015.
- Nonconformity: MAD >= 0.015 (warrants further investigation).
Your injected anomalies from Step 2 should push the MAD into the nonconformity or marginally acceptable range, demonstrating the technique's detection power.
Generate the visualization and export results
A side-by-side bar chart makes it immediately clear which digits deviate from expectations. This visual is what you would present to an audit engagement team to justify further investigation.
- Still inside run_benford_analysis, add the chart generation, CSV export, and the main block below the print statements:
fig, ax = plt.subplots(figsize=(10, 6))
x = np.arange(len(digits))
width = 0.35
bars1 = ax.bar(
x - width / 2,
results["observed_proportion"],
width,
label="Observed",
color="#2c5f8a",
)
bars2 = ax.bar(
x + width / 2,
results["expected_proportion"],
width,
label="Expected (Benford)",
color="#e8a83e",
)
ax.set_xlabel("First Digit")
ax.set_ylabel("Proportion")
ax.set_title(
f"Benford's Law Analysis - First Digit Test\nMAD = {mad:.6f} ({conformity})"
)
ax.set_xticks(x)
ax.set_xticklabels(digits)
ax.legend()
ax.grid(axis="y", alpha=0.3)
os.makedirs("output", exist_ok=True)
plt.tight_layout()
plt.savefig("output/benford_chart.png", dpi=150)
plt.close()
print("\nChart saved to: output/benford_chart.png")
results["mad"] = mad
results["conformity"] = conformity
results.to_csv("output/benford_results.csv", index=False)
print("Results exported to: output/benford_results.csv")
if __name__ == "__main__":
run_benford_analysis()
What does this code do?
- It creates a grouped bar chart with blue bars for observed proportions and gold bars for Benford's expected proportions. The title includes the MAD score and conformity classification.
- It saves the chart as output/benford_chart.png at 150 DPI for crisp rendering in your portfolio.
- It appends the MAD and conformity values to the results DataFrame and exports everything to output/benford_results.csv. This CSV feeds into your Tableau dashboard in the next step.
- The if __name__ == "__main__" block lets you run the script directly from the terminal.
- Save benford_analysis.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before running it.
ⓧ I'd like to double check the full code
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
def benford_expected(digit):
return np.log10(1 + 1 / digit)
def extract_first_digit(value):
if value <= 0:
return None
s = str(abs(value))
for char in s:
if char.isdigit() and char != "0":
return int(char)
return None
def run_benford_analysis():
print("=" * 60)
print("BENFORD'S LAW ANALYSIS - FIRST DIGIT TEST")
print("=" * 60)
df = pd.read_csv("data/journal_entries.csv")
amounts = df[df["debit_amount"] > 0]["debit_amount"]
print(f"\nAnalyzing {len(amounts)} debit entries")
first_digits = amounts.apply(extract_first_digit).dropna().astype(int)
observed_counts = first_digits.value_counts().sort_index()
total = len(first_digits)
observed_freq = observed_counts / total
digits = list(range(1, 10))
expected_freq = pd.Series(
[benford_expected(d) for d in digits], index=digits
)
results = pd.DataFrame(
{
"digit": digits,
"observed_count": [observed_counts.get(d, 0) for d in digits],
"observed_proportion": [observed_freq.get(d, 0) for d in digits],
"expected_proportion": [expected_freq[d] for d in digits],
"deviation": [
abs(observed_freq.get(d, 0) - expected_freq[d]) for d in digits
],
}
)
mad = results["deviation"].mean()
if mad < 0.006:
conformity = "Close Conformity"
elif mad < 0.012:
conformity = "Acceptable Conformity"
elif mad < 0.015:
conformity = "Marginally Acceptable"
else:
conformity = "Nonconformity"
print(f"\nMean Absolute Deviation (MAD): {mad:.6f}")
print(f"Conformity Assessment: {conformity}")
print("\nDigit Distribution:")
print(results.to_string(index=False))
fig, ax = plt.subplots(figsize=(10, 6))
x = np.arange(len(digits))
width = 0.35
bars1 = ax.bar(
x - width / 2,
results["observed_proportion"],
width,
label="Observed",
color="#2c5f8a",
)
bars2 = ax.bar(
x + width / 2,
results["expected_proportion"],
width,
label="Expected (Benford)",
color="#e8a83e",
)
ax.set_xlabel("First Digit")
ax.set_ylabel("Proportion")
ax.set_title(
f"Benford's Law Analysis - First Digit Test\nMAD = {mad:.6f} ({conformity})"
)
ax.set_xticks(x)
ax.set_xticklabels(digits)
ax.legend()
ax.grid(axis="y", alpha=0.3)
os.makedirs("output", exist_ok=True)
plt.tight_layout()
plt.savefig("output/benford_chart.png", dpi=150)
plt.close()
print("\nChart saved to: output/benford_chart.png")
results["mad"] = mad
results["conformity"] = conformity
results.to_csv("output/benford_results.csv", index=False)
print("Results exported to: output/benford_results.csv")
if __name__ == "__main__":
run_benford_analysis()
- Run the Benford's Law analysis by executing this command in your terminal:
python benford_analysis.py
- You should see output showing the number of debit entries analyzed, the MAD value, the conformity assessment, and a digit distribution table. The script also confirms that output/benford_chart.png and output/benford_results.csv were saved.
What should I see?
Your terminal output will show a table with digits 1-9, their observed counts, observed proportions, expected Benford proportions, and the deviation between them. The MAD value and conformity label appear above the table.
Because you injected anomalies in Step 2 (round numbers clustered around certain digits), the observed distribution should deviate noticeably from Benford's expected curve. This is exactly the kind of signal that triggers further investigation on a real engagement.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is still active. You should see the environment name in your terminal prompt.
- Confirm matplotlib is installed by running pip list and checking for matplotlib in the output.
- Check that data/journal_entries.csv exists from Step 2. If not, run python generate_data.py again first.
Help me debug my Benford's Law script error
- Confirm the chart was saved by running:
ls output/
- You should see benford_chart.png and benford_results.csv alongside the flagged entries files from Step 3.
Your Benford's analysis is complete. You now have two complementary layers of audit evidence: rule-based SQL flags and a statistical conformity test. Next up, you'll bring all these findings together in a Tableau dashboard and portfolio website.
Build Tableau Dashboard and Portfolio Website
Your analysis pipeline is complete. You have SQL-flagged entries, Benford's results, and exported CSVs sitting in your output/ folder.
Now you need to communicate these findings the way a real D&A team would. In this step, you will build an interactive Tableau dashboard that lets engagement teams explore flagged entries visually, then wrap everything in a portfolio website that a KPMG interviewer can click through.
In this step, get ready to:
- Build three Tableau visualizations from your exported CSV data and publish a dashboard to Tableau Public.
- Create a portfolio website with an embedded live Tableau dashboard.
- Test the website locally and verify the Tableau embed loads.
Connect data and build visualizations in Tableau
Tableau Desktop Public Edition connects directly to your CSV files and lets you build interactive charts without writing code. You will create three views that tell the audit story: which flags fired most often, when suspicious entries were posted, and whether amounts conform to Benford's Law.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Tableau Desktop Public Edition and press Enter to open it.
- On the Start page, in the Connect pane on the left, click Text File.
- Navigate to your project's output folder, select flagged_entries.csv, and click Open.
- Click the Sheet 1 tab at the bottom to start building your first view.
What is the Connect pane?
When you open Tableau Desktop, the Start page shows a Connect pane listing data source types you can connect to. Text File covers CSV, TSV, and TXT files. Once connected, Tableau automatically detects column names, data types, and loads the first 1,000 rows for preview.
Now build your first visualization: a bar chart showing the count of flagged entries by risk category.
- In the Data pane on the left, drag Risk Flag to the Columns shelf.
- Drag Entry Id (or Number of Records) to the Rows shelf. Tableau creates a bar chart showing counts per flag category.
- Click the Show Me panel on the top right and select the horizontal bar chart type if it is not already selected.
- Rename this sheet by double-clicking the Sheet 1 tab and typing Risk Flags by Category.
Next, create the highlight table showing flagged entries by date and amount.
- Click the New Worksheet icon (the small plus icon next to your sheet tabs at the bottom).
- Drag Posting Date to Columns.
- Drag Debit Amount to Rows.
- Drag Risk Flag to Color on the Marks card to color-code entries by flag type.
- Click Show Me and select the highlight table option to convert the view.
- Rename this sheet to Flagged Entries by Date.
Finally, build the Benford's Law comparison chart. You need to add the second data source first.
- Click the New Worksheet icon to create a third sheet.
- In the top menu, click Data, then New Data Source.
- Select Text File, navigate to output/benford_results.csv, and click Open.
- Click the sheet tab to return to the worksheet.
- Drag Digit to Columns.
- Drag both Observed Proportion and Expected Proportion to Rows. Tableau creates a side-by-side bar chart comparing the two distributions.
- Rename this sheet to Benford Analysis.
Why three separate visualizations?
Each chart answers a different question for the audit engagement team. The bar chart shows volume by flag type (where to focus). The highlight table shows timing patterns (when anomalies cluster). The Benford chart shows statistical conformity (whether amounts look natural or fabricated).
Together they tell a complete story that helps auditors prioritize which journal entries to investigate further.
Now assemble the three sheets into a dashboard and publish it.
- In the top menu, click Dashboard, then New Dashboard.
- Drag each of your three sheets from the Sheets pane on the left into the dashboard canvas. Arrange them so all three are visible.
- Double-click the dashboard title at the top and type Audit Journal Entry Testing - Findings Dashboard.
- Click Server in the top menu.
- Click Tableau Public, then click Save to Tableau Public.
- Sign in with your Tableau Public account credentials. If you do not have an account, click the link to create one for free.
- Enter a name for your workbook and click Save.
What happens when you publish?
Tableau Public creates an extract of your data and hosts the interactive dashboard online. Your browser opens automatically to show the published viz. Anyone with the link can view and interact with the charts.
After your browser opens, copy the embed URL for your portfolio website.
- On the published viz page in your browser, click Share at the bottom of the view.
- Copy the Link URL. It looks like https://public.tableau.com/views/YourWorkbook/YourView. Save this URL somewhere accessible.
Don't see the Share button?
Make sure your workbook has finished saving. The Share button appears in the viz footer only after the upload completes and your browser shows the live dashboard. If the page is still loading, wait a moment and refresh.
Help me publish my Tableau dashboard to Tableau Public.
Build the portfolio website
Your dashboard is live. Now you need a professional page that presents the methodology, findings, and embedded dashboard in one place. This is the artifact you would share with an interviewer to demonstrate your end-to-end D&A skills.
- Switch back to VS Code.
- Open index.html from the Explorer sidebar (it is currently empty).
- Replace the empty file with the following HTML, which builds the page structure, header, and methodology section:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Journal Entry Testing - Data Analytics Portfolio</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="container">
<h1>Audit Journal Entry Testing Pipeline</h1>
<p class="subtitle">Data Analytics for External Audit Engagements</p>
</div>
</header>
<main class="container">
<section class="methodology">
<h2>Methodology</h2>
<p>
This project demonstrates an end-to-end audit data analytics workflow
aligned with ISA 240 and PCAOB AS 2401 standards for journal entry testing.
Rather than relying on traditional sampling, this pipeline analyzes 100% of
the General Ledger population to identify high-risk entries that warrant
further audit investigation.
</p>
<div class="approach-grid">
<div class="approach-card">
<h3>Data Acquisition</h3>
<p>Extracted 5,000 journal entries from a General Ledger database using SQL queries against a SQLite database.</p>
</div>
<div class="approach-card">
<h3>Risk-Based Testing</h3>
<p>Applied 6 automated risk flags: weekend entries, off-hours postings, round amounts, near-threshold entries, suspicious descriptions, and duplicates.</p>
</div>
<div class="approach-card">
<h3>Statistical Analysis</h3>
<p>Performed Benford's Law first-digit analysis with MAD conformity assessment using Nigrini's empirical thresholds.</p>
</div>
<div class="approach-card">
<h3>Visual Communication</h3>
<p>Presented findings in an interactive Tableau dashboard designed for audit engagement teams.</p>
</div>
</div>
</section>
What does this code do?
- The <head> links to style.css for styling and sets the page title.
- The <header> creates a branded banner with the project title and subtitle.
- The methodology section uses a CSS grid (approach-grid) to display four cards explaining each stage of the pipeline: data acquisition, risk-based testing, statistical analysis, and visual communication.
- Directly below the methodology section (still inside <main>), add the findings summary, dashboard embed, tools section, and page footer:
<section class="findings">
<h2>Key Findings</h2>
<div class="findings-summary">
<div class="finding-stat">
<span class="stat-number">5,000</span>
<span class="stat-label">Journal Entries Analyzed</span>
</div>
<div class="finding-stat">
<span class="stat-number">6</span>
<span class="stat-label">Risk Flag Categories</span>
</div>
<div class="finding-stat">
<span class="stat-number">100%</span>
<span class="stat-label">Population Tested</span>
</div>
</div>
</section>
<section class="dashboard">
<h2>Interactive Dashboard</h2>
<p>Explore the audit findings below. Filter by risk category, date range, or amount to investigate flagged entries.</p>
<div class="tableau-container">
<script type="module" src="https://public.tableau.com/javascripts/api/tableau.embedding.3.latest.min.js"></script>
<tableau-viz id="tableauViz"
src="your-tableau-public-url-here"
toolbar="bottom" hide-tabs>
</tableau-viz>
</div>
</section>
<section class="tools">
<h2>Tools and Technologies</h2>
<ul class="tool-list">
<li>Python (pandas, numpy, matplotlib) - Data generation and statistical analysis</li>
<li>SQL (SQLite) - Data querying and risk flag identification</li>
<li>Tableau Public - Interactive dashboard visualization</li>
<li>HTML/CSS - Portfolio presentation</li>
</ul>
</section>
</main>
<footer>
<div class="container">
<p>Built as a portfolio project demonstrating Data & Analytics skills for external audit.</p>
</div>
</footer>
</body>
</html>
What does this code do?
- The findings section displays three stat cards (entries analyzed, flag categories, population coverage) in a flex row.
- The dashboard section loads the Tableau Embedding API v3 library and uses the <tableau-viz> web component to embed your published dashboard directly in the page.
- The src attribute currently says your-tableau-public-url-here. You will replace this with your actual Tableau Public URL shortly.
- The tools section and footer round out the page with context on technologies used.
What is the Tableau Embedding API v3?
The Tableau Embedding API v3 uses a web component (<tableau-viz>) instead of the older iframe approach. You include a single script tag and then use the custom HTML element to point at your published view. This produces cleaner HTML and gives you programmatic control over filters and interactions if needed.
- In index.html, find the line that says src="your-tableau-public-url-here".
- Replace your-tableau-public-url-here with the Tableau Public URL you copied earlier (the link from the Share dialog).
- Save index.html.
Now add the styling. Open style.css in the Explorer sidebar and replace the empty file with the following CSS.
- Paste the following CSS into style.css, which handles the reset, typography, container, and header:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif;
line-height: 1.6;
color: #1a1a2e;
background-color: #f8f9fa;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
}
header {
background-color: #00338d;
color: white;
padding: 48px 0;
text-align: center;
}
header h1 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 8px;
}
header .subtitle {
font-size: 1.1rem;
opacity: 0.9;
}
main {
padding: 48px 0;
}
section {
margin-bottom: 48px;
}
h2 {
font-size: 1.6rem;
color: #00338d;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e0e0e0;
}
h3 {
font-size: 1.1rem;
color: #1a1a2e;
margin-bottom: 8px;
}
What does this code do?
- The universal reset (*) removes default browser margins and sets box-sizing: border-box so padding does not expand elements beyond their set width.
- The header uses #00338d (KPMG's primary blue) as the background color for brand alignment.
- The .container class centers content at a max width of 1100px with consistent padding.
- Below the existing CSS, add the styles for the methodology grid, findings stats, dashboard container, tools list, and footer:
.approach-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-top: 20px;
}
.approach-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 24px;
border-left: 4px solid #00338d;
}
.approach-card p {
color: #4a4a4a;
font-size: 0.95rem;
}
.findings-summary {
display: flex;
gap: 32px;
justify-content: center;
flex-wrap: wrap;
margin-top: 24px;
}
.finding-stat {
text-align: center;
padding: 24px 32px;
background: white;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.stat-number {
display: block;
font-size: 2.4rem;
font-weight: 700;
color: #00338d;
}
.stat-label {
display: block;
font-size: 0.9rem;
color: #666;
margin-top: 4px;
}
.tableau-container {
margin-top: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
min-height: 600px;
background: white;
display: flex;
align-items: center;
justify-content: center;
}
tableau-viz {
width: 100%;
height: 600px;
}
.tool-list {
list-style: none;
margin-top: 16px;
}
.tool-list li {
padding: 12px 16px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.95rem;
}
footer {
background-color: #1a1a2e;
color: white;
text-align: center;
padding: 32px 0;
margin-top: 48px;
}
footer p {
opacity: 0.8;
font-size: 0.9rem;
}
What does this code do?
- The .approach-grid uses CSS Grid with auto-fit and minmax to create a responsive card layout that wraps naturally at smaller screen sizes.
- The .tableau-container sets a minimum height of 600px so the Tableau embed has enough space to render fully.
- The tableau-viz selector targets the custom web component element directly, giving it 100% width and a fixed height.
- Save style.css.
✔️ Awesome, I've got everything!
Great. Double check you have saved both index.html and style.css.
ⓧ I'd like to double check the full code
Here is the complete index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Journal Entry Testing - Data Analytics Portfolio</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="container">
<h1>Audit Journal Entry Testing Pipeline</h1>
<p class="subtitle">Data Analytics for External Audit Engagements</p>
</div>
</header>
<main class="container">
<section class="methodology">
<h2>Methodology</h2>
<p>
This project demonstrates an end-to-end audit data analytics workflow
aligned with ISA 240 and PCAOB AS 2401 standards for journal entry testing.
Rather than relying on traditional sampling, this pipeline analyzes 100% of
the General Ledger population to identify high-risk entries that warrant
further audit investigation.
</p>
<div class="approach-grid">
<div class="approach-card">
<h3>Data Acquisition</h3>
<p>Extracted 5,000 journal entries from a General Ledger database using SQL queries against a SQLite database.</p>
</div>
<div class="approach-card">
<h3>Risk-Based Testing</h3>
<p>Applied 6 automated risk flags: weekend entries, off-hours postings, round amounts, near-threshold entries, suspicious descriptions, and duplicates.</p>
</div>
<div class="approach-card">
<h3>Statistical Analysis</h3>
<p>Performed Benford's Law first-digit analysis with MAD conformity assessment using Nigrini's empirical thresholds.</p>
</div>
<div class="approach-card">
<h3>Visual Communication</h3>
<p>Presented findings in an interactive Tableau dashboard designed for audit engagement teams.</p>
</div>
</div>
</section>
<section class="findings">
<h2>Key Findings</h2>
<div class="findings-summary">
<div class="finding-stat">
<span class="stat-number">5,000</span>
<span class="stat-label">Journal Entries Analyzed</span>
</div>
<div class="finding-stat">
<span class="stat-number">6</span>
<span class="stat-label">Risk Flag Categories</span>
</div>
<div class="finding-stat">
<span class="stat-number">100%</span>
<span class="stat-label">Population Tested</span>
</div>
</div>
</section>
<section class="dashboard">
<h2>Interactive Dashboard</h2>
<p>Explore the audit findings below. Filter by risk category, date range, or amount to investigate flagged entries.</p>
<div class="tableau-container">
<script type="module" src="https://public.tableau.com/javascripts/api/tableau.embedding.3.latest.min.js"></script>
<tableau-viz id="tableauViz"
src="your-tableau-public-url-here"
toolbar="bottom" hide-tabs>
</tableau-viz>
</div>
</section>
<section class="tools">
<h2>Tools and Technologies</h2>
<ul class="tool-list">
<li>Python (pandas, numpy, matplotlib) - Data generation and statistical analysis</li>
<li>SQL (SQLite) - Data querying and risk flag identification</li>
<li>Tableau Public - Interactive dashboard visualization</li>
<li>HTML/CSS - Portfolio presentation</li>
</ul>
</section>
</main>
<footer>
<div class="container">
<p>Built as a portfolio project demonstrating Data & Analytics skills for external audit.</p>
</div>
</footer>
</body>
</html>
And here is the complete style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif;
line-height: 1.6;
color: #1a1a2e;
background-color: #f8f9fa;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
}
header {
background-color: #00338d;
color: white;
padding: 48px 0;
text-align: center;
}
header h1 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 8px;
}
header .subtitle {
font-size: 1.1rem;
opacity: 0.9;
}
main {
padding: 48px 0;
}
section {
margin-bottom: 48px;
}
h2 {
font-size: 1.6rem;
color: #00338d;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e0e0e0;
}
h3 {
font-size: 1.1rem;
color: #1a1a2e;
margin-bottom: 8px;
}
.approach-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-top: 20px;
}
.approach-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 24px;
border-left: 4px solid #00338d;
}
.approach-card p {
color: #4a4a4a;
font-size: 0.95rem;
}
.findings-summary {
display: flex;
gap: 32px;
justify-content: center;
flex-wrap: wrap;
margin-top: 24px;
}
.finding-stat {
text-align: center;
padding: 24px 32px;
background: white;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.stat-number {
display: block;
font-size: 2.4rem;
font-weight: 700;
color: #00338d;
}
.stat-label {
display: block;
font-size: 0.9rem;
color: #666;
margin-top: 4px;
}
.tableau-container {
margin-top: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
min-height: 600px;
background: white;
display: flex;
align-items: center;
justify-content: center;
}
tableau-viz {
width: 100%;
height: 600px;
}
.tool-list {
list-style: none;
margin-top: 16px;
}
.tool-list li {
padding: 12px 16px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.95rem;
}
footer {
background-color: #1a1a2e;
color: white;
text-align: center;
padding: 32px 0;
margin-top: 48px;
}
footer p {
opacity: 0.8;
font-size: 0.9rem;
}
Test the website locally
The page is built. Time to open it in a browser and confirm both the layout and the live Tableau embed render correctly.
- In your file manager (Finder on macOS or File Explorer on Windows), navigate to your project folder.
- Double-click index.html to open it in your default browser.
You should see a page with a blue header, four methodology cards in a grid, three stat numbers, and the Tableau dashboard loading in the embed area below.
What should I see?
The header banner is KPMG blue with white text reading "Audit Journal Entry Testing Pipeline". Below it, four cards explain the methodology. Three large numbers (5,000 / 6 / 100%) appear in the Key Findings section.
In the Interactive Dashboard section, the Tableau embed takes a few seconds to load. Once it appears, you can hover over bars and filter data directly inside the embedded dashboard.
Tableau embed not loading?
- Check that you replaced your-tableau-public-url-here with your actual Tableau Public URL in index.html.
- Make sure you are connected to the internet. The Tableau Embedding API loads from Tableau's servers.
- Verify your Tableau Public dashboard is set to public (it is by default when published from Tableau Desktop Public Edition).
Help me get my Tableau embed working on my portfolio page.
Your audit analytics pipeline now has a professional front end. Next up, the Secret Mission challenges you to extend the analysis with first-two-digit Benford's testing and automated risk scoring.
Secret mission
Advanced Risk Scoring with First-Two-Digit Analysis
Your first-digit Benford's test uses just 9 bins. KPMG's D&A teams use first-two-digit analysis (90 bins) for far greater precision. Can you extend the pipeline with this more powerful test and build an automated risk scoring system that assigns every journal entry a composite score?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free Tableau Public dashboard. 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:
- Local project folder with Python scripts, SQLite database, CSV files, and website files.
- Python virtual environment with pandas, matplotlib, and numpy installed.
- Tableau Public dashboard published to your profile.
Keep everything running
No action needed. Choose this if you plan to keep iterating on your dashboard, refining your analysis scripts, or sharing the portfolio website with interviewers.
- Your project folder stays on your Desktop with all scripts, data, and output files intact.
- Your Tableau Public dashboard remains live and accessible via your profile URL.
- Your virtual environment stays ready to activate whenever you want to rerun or modify your analysis.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal session. Your files and Tableau dashboard stay in place for when you return.
- Deactivate your virtual environment by running:
deactivate
- Close VS Code if you are done for the day.
- Your project folder and Tableau Public dashboard remain untouched until you are ready to resume.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your local files and your published Tableau dashboard.
- Deactivate your virtual environment (if still active) by running:
deactivate
- Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf audit-journal-testing
Windows users
Use this command instead to remove the project folder:
cd ~\Desktop
rmdir /s /q audit-journal-testing
- Delete your Tableau Public dashboard by going to your Tableau Public profile page.
- Click the More Actions icon on the viz you published for this project.
- Select Delete This Viz.
- Confirm the deletion when prompted. This action is permanent and cannot be undone.
Nice Work!
Nice Work!
You just built a complete audit data analytics pipeline from scratch. You generated a General Ledger, ran SQL-based risk testing, applied statistical analysis, and presented your findings in a professional portfolio.
You've learned how to:
- Write SQL audit queries that identify high-risk journal entries using criteria aligned with ISA 240 and PCAOB AS 2401 standards for fraud risk detection.
- Apply Benford's Law statistical analysis to detect potential data manipulation in journal entry amounts, using Mean Absolute Deviation for conformity assessment.
- Build an interactive Tableau dashboard and a portfolio website that communicates audit findings visually to engagement teams.
- Secret Mission: Extended the analysis with first-two-digit Benford's testing and automated risk scoring for more precise anomaly detection across 90 digit bins.
Ready to quiz yourself?