Session 10 lecture: Finance: budget accountability¶
Every month a finance partner compares what each department spent with what it was allowed to spend, and picks a handful of lines to question. This lecture shows how to build that comparison so the totals are right, the signs are read correctly, and the lines you flag are the ones that really deserve attention.
Open this notebook in Google Colab with the button above, then run each cell from top to bottom with Shift + Enter. Save your own copy with File → Save a copy in Drive.
Recap quiz¶
Before we start, answer these questions on the Session 9 lecture. Write short answers; we discuss them together.
- From a random sample of 200 orders, the average order value is 450 with a 95% confidence interval from 400 to 500. Write one sentence for a manager that reports this correctly.
- You make your sample four times bigger. What happens to the standard error?
- An A/B test shows a lift of +0.4 percentage points with a 95% interval from −0.1 to +0.9. Can you say the new version is better? Can you say it makes no difference?
- A test gives p = 0.03. Say in plain words what that means, and give one common misreading.
- Why does checking a test every day and stopping on the first day p drops below 0.05 produce too many false winners?
What you will be able to explain¶
- The grain of expense postings and of budget cells, and why they cannot be joined directly.
- How signs work in an expense ledger: expenses are positive, credits and reversals are negative.
- Why you aggregate first, then combine, and how an outer join with an indicator keeps every cell visible.
- The difference between an unknown budget, a budget with no spending and an approved zero budget.
- Variance = actual − budget, and why, for expenses, a positive variance is unfavourable.
- When a variance % exists, and why you never average percentages to get a total.
- How to judge materiality using both the amount and the percentage.
- Why a favourable month may only be a credit, reversal or timing effect.
- Which reconciliation checks prove that your comparison table adds up.
This lecture builds on the grain ideas from Session 2 and the rate rules from Session 5. In the Lab you build the full comparison yourself, debug a colleague's broken version and choose what the finance partner should review.
How to use the questions in this notebook
Predict, then reveal boxes ask you to guess before running the next cell. Write your guess down first. Check your understanding boxes are short questions to answer in your own words.
Run the two cells below first: the finance data setup, then the cell that loads the two tables.
# Finance data setup (synthetic). Run this cell first.
# Downloads the course's synthetic finance data from GitHub and checks it is the exact version this course uses.
import hashlib
import io
import os
import urllib.request
import pandas as pd
FINANCE_URL = "https://raw.githubusercontent.com/Aayush181509/AnalyticsLecture/main/data/synthetic/finance-v1/"
FINANCE_SHA256 = {
"finance_actuals.csv": "3aeb2773990f86c21a1536b8854d7290794315e8bc872420c2e5df77151ac7fd",
"finance_budget.csv": "238dbec8e7e80a61068c12c48280ff4f9de91198879ee295366774f530efad60",
}
FINANCE_DIR = os.environ.get("COURSE_FINANCE_DIR", "")
def load_finance(name):
"""Load one finance-v1 file: 'finance_actuals.csv' or 'finance_budget.csv'."""
if FINANCE_DIR:
with open(os.path.join(FINANCE_DIR, name), "rb") as f:
data = f.read()
else:
data = urllib.request.urlopen(FINANCE_URL + name).read()
if hashlib.sha256(data).hexdigest() != FINANCE_SHA256[name]:
print("WARNING: this file differs from the course version. Ask your facilitator.")
return pd.read_csv(io.BytesIO(data), parse_dates=["month"], keep_default_na=False,
na_values={"amount": [""], "budget": [""]})
print("Finance setup complete.")
Finance setup complete.
import matplotlib.pyplot as plt
import numpy as np
pd.set_option("display.float_format", "{:,.2f}".format)
plt.rcParams.update({
"figure.figsize": (8, 4),
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"axes.axisbelow": True,
"grid.color": "#e6e5e1",
"axes.prop_cycle": plt.cycler(color=["#2a78d6", "#eb6834", "#1baf7a"]),
"axes.titleweight": "bold",
"axes.titlelocation": "left",
})
# Colour-blind-safe pair used for every variance chart in this session.
# Blue = favourable (under budget), orange = unfavourable (over budget). Always add a legend too.
FAVOURABLE, UNFAVOURABLE = "#2a78d6", "#eb6834"
actuals = load_finance("finance_actuals.csv")
budget = load_finance("finance_budget.csv")
print("Postings:", len(actuals), "| budget cells:", len(budget))
Postings: 1378 | budget cells: 432
Part 1: The business decision¶
Finance partner: "Every month I can review about a dozen budget lines with department heads. Which department and account lines should I put on the list, and why? I don't want to waste anyone's time on noise, and I don't want to miss a real problem."
The decision is a short, defensible review list. To get there we need a comparison of actual spending with budget for every month, department and account, that we can trust to add up.
About the data
finance_actuals.csvandfinance_budget.csvare synthetic teaching data generated by the course: a fictional company's monthly management expense view for calendar 2025. No real company, person or supplier is described. Amounts are in neutral currency units. This is not a financial statement: there is no revenue, no balance sheet and no audit claim.
| Table | One row is | Key |
|---|---|---|
finance_actuals.csv |
One expense posting (an invoice, salary run, credit or reversal) | posting_id |
finance_budget.csv |
One approved monthly budget cell | (month, department_id, account_id) |
Part 2: Grain, keys and signs¶
Before combining two tables, check what one row means in each and whether the keys really are unique. We also keep the department and account names next to their codes; the check below confirms each code has exactly one name, so the names can travel with the keys.
KEY = ["month", "department_id", "department", "account_id", "account"]
print("posting_id unique:", actuals["posting_id"].is_unique)
print("Most budget rows for one (month, department, account):", budget.groupby(KEY).size().max())
print("Names per department code:", actuals.groupby("department_id")["department"].nunique().max(),
"| names per account code:", actuals.groupby("account_id")["account"].nunique().max())
print("Months:", actuals["month"].min().date(), "to", actuals["month"].max().date())
actuals.head()
posting_id unique: True
Most budget rows for one (month, department, account): 1
Names per department code: 1 | names per account code: 1
Months: 2025-01-01 to 2025-12-01
| posting_id | posting_date | month | department_id | department | account_id | account | amount | posting_type | reference_posting_id | description | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | P25-00001 | 2025-01-01 | 2025-01-01 | D02 | Marketing | A200 | Travel | 534.24 | expense | Travel - Marketing | |
| 1 | P25-00002 | 2025-01-01 | 2025-01-01 | D06 | Finance and Admin | A300 | Software subscriptions | 254.84 | expense | Software subscriptions - Finance and Admin | |
| 2 | P25-00003 | 2025-01-02 | 2025-01-01 | D03 | Operations | A400 | Facilities | 9,148.20 | expense | Facilities - Operations | |
| 3 | P25-00004 | 2025-01-02 | 2025-01-01 | D05 | People | A300 | Software subscriptions | 1,350.20 | expense | Software subscriptions - People | |
| 4 | P25-00005 | 2025-01-02 | 2025-01-01 | D05 | People | A600 | Training | 2,036.12 | expense | Training - People |
Many postings fall into the same month, department and account. The budget has one row for each of those cells. So the two tables have different grains: postings are fine-grained, budgets are coarse.
Signs¶
In this ledger a positive amount is an expense. A negative amount is a credit (for example a supplier refunds an overcharge) or a reversal (an earlier posting is cancelled). Let us see how many of each there are.
actuals.groupby("posting_type")["amount"].agg(postings="count", total="sum", smallest="min", largest="max")
| postings | total | smallest | largest | |
|---|---|---|---|---|
| posting_type | ||||
| credit | 1 | -2,000.00 | -2,000.00 | -2,000.00 |
| expense | 1376 | 6,250,301.60 | 34.48 | 110,814.44 |
| reversal | 1 | -6,500.00 | -6,500.00 | -6,500.00 |
actuals.loc[actuals["amount"] < 0,
["posting_id", "posting_date", "department", "account", "amount", "posting_type",
"reference_posting_id", "description"]]
| posting_id | posting_date | department | account | amount | posting_type | reference_posting_id | description | |
|---|---|---|---|---|---|---|---|---|
| 610 | P25-00611 | 2025-06-09 | Sales | Travel | -6,500.00 | reversal | P25-00553 | Reversal of duplicated May conference invoice |
| 1118 | P25-01119 | 2025-10-21 | People | Professional services | -2,000.00 | credit | Supplier credit note for overcharge |
Only a couple of rows are negative, but they matter: they lower the month's actual spending. A reversal also names the posting it cancels in reference_posting_id. Keep both rows in mind; we return to them in Part 8.
Check your understanding 1
Why is "actual = sum of the
amountcolumn" correct here, while "actual = sum of the positive amounts" would be wrong? What would the second version do to a month with a large supplier credit?
Part 3: The join trap¶
The quickest way to put actuals next to budgets is to join the posting table straight onto the budget table. Let us try it on one budget cell: Sales travel in May.
Predict, then reveal
There is one budget cell for Sales travel in May, and several postings. After joining postings to budget, then adding up the
budgetcolumn, will you get the May budget, or something else?
is_cell = lambda df: (df["month"] == "2025-05-01") & (df["department"] == "Sales") & (df["account"] == "Travel")
one_budget = budget[is_cell(budget)]
its_postings = actuals[is_cell(actuals)]
joined = its_postings.merge(one_budget, on=KEY)
print("Budget cells:", len(one_budget), "| postings:", len(its_postings), "| rows after the join:", len(joined))
print(f"True May budget: {one_budget['budget'].sum():,.2f}")
print(f"'Budget' after the join: {joined['budget'].sum():,.2f}")
joined[["posting_id", "amount", "budget"]]
Budget cells: 1 | postings: 6 | rows after the join: 6
True May budget: 12,000.00
'Budget' after the join: 72,000.00
| posting_id | amount | budget | |
|---|---|---|---|
| 0 | P25-00484 | 2,123.44 | 12,000.00 |
| 1 | P25-00487 | 1,492.03 | 12,000.00 |
| 2 | P25-00501 | 6,818.31 | 12,000.00 |
| 3 | P25-00538 | 952.07 | 12,000.00 |
| 4 | P25-00552 | 6,500.00 | 12,000.00 |
| 5 | P25-00553 | 6,500.00 | 12,000.00 |
The join copies the budget onto every posting, so adding up the budget column counts it once per posting. Nothing errors and nothing warns you. Now the whole year:
naive = actuals.merge(budget, on=KEY) # postings joined straight to budgets
matched_cells = budget.merge(actuals[KEY].drop_duplicates(), on=KEY) # the same cells, each counted once
print(f"Budget after the naive join: {naive['budget'].sum():,.2f}")
print(f"Budget of the same cells, counted once: {matched_cells['budget'].sum():,.2f}")
print(f"Inflation factor: {naive['budget'].sum() / matched_cells['budget'].sum():.2f}x")
Budget after the naive join: 14,771,900.00
Budget of the same cells, counted once: 6,126,500.00
Inflation factor: 2.41x
Read the output above. On this version of the data the naive join reports a budget several times too large, so every department looks massively under budget. Notice also that the naive join silently drops anything that does not match on both sides.
The rule: aggregate first, then combine
Bring both tables to the same grain before joining. Here that means adding up postings to one row per (month, department, account), then joining one-to-one to the budget cells.
Part 4: Aggregate first, then an outer join with an indicator¶
Step 1: add up the postings to the budget's grain. We also count the postings in each cell.
actual_cells = actuals.groupby(KEY, as_index=False).agg(actual=("amount", "sum"),
postings=("posting_id", "count"))
print("Posting rows:", len(actuals), "-> actual cells:", len(actual_cells))
actual_cells.head()
Posting rows: 1378 -> actual cells: 431
| month | department_id | department | account_id | account | actual | postings | |
|---|---|---|---|---|---|---|---|
| 0 | 2025-01-01 | D01 | Sales | A100 | Salaries and wages | 85,470.10 | 2 |
| 1 | 2025-01-01 | D01 | Sales | A200 | Travel | 6,843.41 | 6 |
| 2 | 2025-01-01 | D01 | Sales | A300 | Software subscriptions | 3,767.03 | 2 |
| 3 | 2025-01-01 | D01 | Sales | A400 | Facilities | 5,512.62 | 1 |
| 4 | 2025-01-01 | D01 | Sales | A500 | Professional services | 2,071.17 | 3 |
Step 2: join to the budget with three safety features:
| Setting | What it does |
|---|---|
how="outer" |
Keeps cells that appear on either side, not only on both |
indicator=True |
Adds a column _merge saying where each row came from |
validate="one_to_one" |
Stops with an error if either side has duplicate keys |
var = actual_cells.merge(budget, on=KEY, how="outer", indicator=True, validate="one_to_one")
var["match"] = var["_merge"].astype(str).map({"both": "actual and budget",
"left_only": "actual only",
"right_only": "budget only"})
var = var.drop(columns="_merge")
var["match"].value_counts()
match
actual and budget 425
budget only 7
actual only 6
Name: count, dtype: int64
Most cells have both an actual and a budget. The two small groups are the interesting ones, and an inner join would have hidden both of them.
Part 5: Unknown, missing and zero are different¶
| Situation | What it means | Actual | Budget | Variance |
|---|---|---|---|---|
| Actual only | Money was spent, but no budget cell exists | the sum of postings | unknown (not zero) | unknown |
| Budget only | A budget was approved, nobody posted anything | 0, once we accept the postings are complete | the budget | 0 − budget |
| Approved zero budget | The budget is explicitly 0 | the sum of postings | 0 | the actual; no % |
An unknown budget must never be filled with 0: that would turn "we don't know" into "this was not allowed", and the cell would look like a large overspend. A budget-only cell, on the other hand, really does have actual spending of 0, as long as finance confirms all postings for the month are in.
var["postings"] = var["postings"].fillna(0).astype(int)
var["actual"] = var["actual"].fillna(0) # budget-only cells: no postings, actual = 0
var["budget_status"] = np.select(
[var["budget"].isna(), var["budget"] == 0],
["unknown budget", "approved zero budget"],
default="positive budget",
)
var.loc[(var["match"] != "actual and budget") | (var["budget_status"] != "positive budget"),
KEY[0:1] + ["department", "account", "postings", "actual", "budget", "match", "budget_status"]]
| month | department | account | postings | actual | budget | match | budget_status | |
|---|---|---|---|---|---|---|---|---|
| 35 | 2025-01-01 | Finance and Admin | Training | 0 | 0.00 | 0.00 | budget only | approved zero budget |
| 71 | 2025-02-01 | Finance and Admin | Training | 5 | 1,850.00 | 0.00 | actual and budget | approved zero budget |
| 107 | 2025-03-01 | Finance and Admin | Training | 0 | 0.00 | 0.00 | budget only | approved zero budget |
| 240 | 2025-07-01 | Technology | Cloud hosting | 2 | 2,383.57 | NaN | actual only | unknown budget |
| 264 | 2025-08-01 | Marketing | Training | 0 | 0.00 | 1,300.00 | budget only | positive budget |
| 277 | 2025-08-01 | Technology | Cloud hosting | 2 | 4,911.66 | NaN | actual only | unknown budget |
| 301 | 2025-09-01 | Marketing | Training | 0 | 0.00 | 1,300.00 | budget only | positive budget |
| 314 | 2025-09-01 | Technology | Cloud hosting | 2 | 3,531.16 | NaN | actual only | unknown budget |
| 338 | 2025-10-01 | Marketing | Training | 0 | 0.00 | 1,300.00 | budget only | positive budget |
| 351 | 2025-10-01 | Technology | Cloud hosting | 2 | 8,911.91 | NaN | actual only | unknown budget |
| 375 | 2025-11-01 | Marketing | Training | 0 | 0.00 | 1,300.00 | budget only | positive budget |
| 388 | 2025-11-01 | Technology | Cloud hosting | 2 | 4,266.88 | NaN | actual only | unknown budget |
| 412 | 2025-12-01 | Marketing | Training | 0 | 0.00 | 1,300.00 | budget only | positive budget |
| 425 | 2025-12-01 | Technology | Cloud hosting | 1 | 9,372.99 | NaN | actual only | unknown budget |
Read the table above. You should find three kinds of special cell: one account that has spending but no budget at all for part of the year, a line with budgets but no postings for several months, and a line with approved zero budgets, one of which has spending against it.
Check your understanding 2
For each kind of special cell, write one question you would ask the department head. (Example for the unknown budget: was this cost budgeted under a different account?)
Part 6: Variance and favourability¶
variance = actual − budget
For an expense, spending more than budget is bad news for the company, so:
| Variance | Meaning for an expense | Word to use |
|---|---|---|
| positive | spent more than budget | unfavourable (overspend) |
| negative | spent less than budget | favourable (underspend) |
| unknown | budget unknown | not assessed |
(For revenue the rule flips: earning more than budget is favourable. Never reuse an expense colour rule on a revenue chart.)
variance % = variance ÷ budget, only when the budget is above zero. With a zero budget the division is undefined; we label the cell "unbudgeted spend against zero" instead of inventing a percentage.
var["variance"] = var["actual"] - var["budget"] # NaN when the budget is unknown
var["variance_pct"] = np.where(var["budget"] > 0, var["variance"] / var["budget"], np.nan)
var["direction"] = np.select(
[var["budget"].isna(), var["variance"] > 0, var["variance"] < 0],
["not assessed (budget unknown)", "unfavourable", "favourable"],
default="on budget",
)
var.loc[(var["budget"] == 0) & (var["actual"] > 0), "direction"] = "unbudgeted spend against zero"
var["direction"].value_counts()
direction
unfavourable 232
favourable 197
not assessed (budget unknown) 6
on budget 2
unbudgeted spend against zero 1
Name: count, dtype: int64
Predict, then reveal
Look again at the joined May Sales travel row. Is its variance favourable or unfavourable? Is its variance % small, medium or large?
may = var.loc[is_cell(var), ["month", "department", "account", "actual", "budget", "variance", "variance_pct", "direction"]]
may.assign(variance_pct=may["variance_pct"].map("{:+.0%}".format))
| month | department | account | actual | budget | variance | variance_pct | direction | |
|---|---|---|---|---|---|---|---|---|
| 145 | 2025-05-01 | Sales | Travel | 24,385.85 | 12,000.00 | 12,385.85 | +103% | unfavourable |
Check your understanding 3
A colleague colours every negative variance red "because negative numbers are bad". On an expense report, what will that chart tell the reader, and why is it wrong?
Part 7: Reconciliation checks¶
A comparison table is only useful if it still adds up to its sources. Run these checks every time you build one, and keep the output as evidence.
def reconcile(table, label):
"""Compare a variance table with the two source tables and print PASS or FAIL for each check."""
all_keys = pd.concat([actuals[KEY], budget[KEY]]).drop_duplicates()
checks = [
("One row per (month, department, account)", not table.duplicated(KEY).any()),
("Every key from either source is present", len(table) == len(all_keys)),
("Actual total = signed posting total", abs(table["actual"].sum() - actuals["amount"].sum()) < 0.005),
("Budget total = total of unique budget cells", abs(table["budget"].sum() - budget["budget"].sum()) < 0.005),
]
print(f"Reconciliation: {label}")
print(f" actual {table['actual'].sum():,.2f} vs postings {actuals['amount'].sum():,.2f}")
print(f" budget {table['budget'].sum():,.2f} vs budget cells {budget['budget'].sum():,.2f}")
for name, ok in checks:
print(f" {'PASS' if ok else 'FAIL'} {name}")
reconcile(var, "aggregate first, then outer join")
Reconciliation: aggregate first, then outer join
actual 6,241,801.60 vs postings 6,241,801.60
budget 6,133,000.00 vs budget cells 6,133,000.00
PASS One row per (month, department, account)
PASS Every key from either source is present
PASS Actual total = signed posting total
PASS Budget total = total of unique budget cells
All four checks pass. Notice that .sum() skips the unknown budgets, so the budget total is the total of the cells that exist. Unknown is not added as zero; it is simply not there.
Check your understanding 4
Which of the four checks would have caught the naive join from Part 3? Which would catch an inner join?
Part 8: Materiality, using amount and percentage¶
Neither number alone tells you what matters:
- A large amount can be a small % of a very large budget (salaries).
- A large % can be a tiny amount on a small budget.
A common review rule uses both. The thresholds are business choices agreed with finance, not statistics. Here: flag a cell if the variance is at least 5,000 currency units either way, or at least 25% of budget and at least 1,000 currency units.
AMOUNT_LIMIT = 5000
PCT_LIMIT = 0.25
MIN_AMOUNT_FOR_PCT = 1000
big_amount = var["variance"].abs() >= AMOUNT_LIMIT
big_pct = (var["variance_pct"].abs() >= PCT_LIMIT) & (var["variance"].abs() >= MIN_AMOUNT_FOR_PCT)
var["review_flag"] = np.select([big_amount & big_pct, big_amount, big_pct],
["amount and %", "amount", "%"], default="")
flagged = var[var["review_flag"] != ""]
print("Cells flagged:", len(flagged), "of", len(var))
known = var.dropna(subset=["variance_pct"])
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(known["variance_pct"] * 100, known["variance"], s=14, color="#b8b6b0", label="Not flagged")
# Purple, green and black here mark review flags, not favourability (blue and orange are kept for that).
for flag, colour, marker in [("amount", "#7a5195", "o"), ("%", "#1baf7a", "s"), ("amount and %", "black", "D")]:
part = known[known["review_flag"] == flag]
ax.scatter(part["variance_pct"] * 100, part["variance"], s=36, color=colour, marker=marker,
label=f"Flagged: {flag}")
ax.axhline(0, color="black", linewidth=0.8)
ax.axvline(0, color="black", linewidth=0.8)
ax.set_title("Monthly cells: variance in currency units against variance %")
ax.set_xlabel("Variance % of budget (right = over budget)")
ax.set_ylabel("Variance (currency units)")
ax.legend(fontsize=8)
plt.show()
Cells flagged: 17 of 438

Read the chart above. Salary cells sit high or low on the vertical axis but close to zero %; small training or travel cells can sit far to the right with modest amounts. Each flagged group needs a different follow-up question.
Check your understanding 5
Name one cell that the amount rule flags but the % rule does not, and one the other way round (use the chart, then the table in the next cell). Why would a finance partner want to see both?
ranked = flagged.sort_values("variance", key=abs, ascending=False)[
["month", "department", "account", "actual", "budget", "variance", "variance_pct", "review_flag"]]
ranked.assign(variance_pct=ranked["variance_pct"].map("{:+.0%}".format))
| month | department | account | actual | budget | variance | variance_pct | review_flag | |
|---|---|---|---|---|---|---|---|---|
| 145 | 2025-05-01 | Sales | Travel | 24,385.85 | 12,000.00 | 12,385.85 | +103% | amount and % |
| 120 | 2025-04-01 | Operations | Salaries and wages | 133,955.61 | 124,000.00 | 9,955.61 | +8% | amount |
| 320 | 2025-09-01 | People | Training | 12,300.00 | 4,100.00 | 8,200.00 | +200% | amount and % |
| 181 | 2025-06-01 | Sales | Travel | 2,274.94 | 9,200.00 | -6,925.06 | -75% | amount and % |
| 84 | 2025-03-01 | Operations | Salaries and wages | 130,080.34 | 124,000.00 | 6,080.34 | +5% | amount |
| 12 | 2025-01-01 | Operations | Salaries and wages | 129,805.69 | 124,000.00 | 5,805.69 | +5% | amount |
| 271 | 2025-08-01 | Technology | Salaries and wages | 65,634.86 | 71,000.00 | -5,365.14 | -8% | amount |
| 200 | 2025-06-01 | Technology | Software subscriptions | 19,752.36 | 15,400.00 | 4,352.36 | +28% | % |
| 421 | 2025-12-01 | Technology | Software subscriptions | 19,367.46 | 15,400.00 | 3,967.46 | +26% | % |
| 164 | 2025-05-01 | Technology | Software subscriptions | 19,326.58 | 15,400.00 | 3,926.58 | +25% | % |
| 128 | 2025-04-01 | Technology | Software subscriptions | 19,284.96 | 15,400.00 | 3,884.96 | +25% | % |
| 356 | 2025-10-01 | People | Professional services | 1,221.66 | 3,100.00 | -1,878.34 | -61% | % |
| 301 | 2025-09-01 | Marketing | Training | 0.00 | 1,300.00 | -1,300.00 | -100% | % |
| 338 | 2025-10-01 | Marketing | Training | 0.00 | 1,300.00 | -1,300.00 | -100% | % |
| 375 | 2025-11-01 | Marketing | Training | 0.00 | 1,300.00 | -1,300.00 | -100% | % |
| 412 | 2025-12-01 | Marketing | Training | 0.00 | 1,300.00 | -1,300.00 | -100% | % |
| 264 | 2025-08-01 | Marketing | Training | 0.00 | 1,300.00 | -1,300.00 | -100% | % |
Part 9: Never average percentages¶
What is the company's overall variance %? Three colleagues give three answers.
Predict, then reveal
Will "the average of all the cells' variance %" be close to the variance % computed from total actual and total budget?
comparable = var[var["budget_status"] != "unknown budget"] # cells with a known budget (including zero)
unknown = var[var["budget_status"] == "unknown budget"]
total_actual = comparable["actual"].sum()
total_budget = comparable["budget"].sum()
monthly = comparable.groupby("month")[["actual", "budget"]].sum()
print(f"Comparable scope: actual {total_actual:,.2f} | budget {total_budget:,.2f} | "
f"variance {total_actual - total_budget:+,.2f}")
print(f" Variance % from totals (correct): {(total_actual - total_budget) / total_budget:+.1%}")
print(f" Average of the 12 monthly %: {((monthly['actual'] - monthly['budget']) / monthly['budget']).mean():+.1%}")
print(f" Average of every cell's %: {comparable['variance_pct'].mean():+.1%}")
print(f"Reported separately, actuals with unknown budget: {unknown['actual'].sum():,.2f} "
f"({', '.join(sorted(unknown['account'].unique()))}, {len(unknown)} cells)")
Comparable scope: actual 6,208,423.43 | budget 6,133,000.00 | variance +75,423.43
Variance % from totals (correct): +1.2%
Average of the 12 monthly %: +1.2%
Average of every cell's %: -0.2%
Reported separately, actuals with unknown budget: 33,378.17 (Cloud hosting, 6 cells)
Averaging cell percentages gives a tiny training budget the same weight as a large salary budget, so a few small cells drag the "average" far from the truth. Averaging monthly percentages is closer only because months have similar budgets; it is still the wrong method.
Rules for a total variance %
- Add up actual and budget over the comparable scope (cells with a known budget).
- Compute the % from those totals.
- Report the unknown-budget actuals separately, with their amount. Never treat their budget as zero, and never leave them out silently.
Part 10: Credits, reversals and timing¶
Look at Sales travel month by month.
Predict, then reveal
May was flagged as a large overspend. Would you call Sales into a meeting about travel discipline? What might June show?
sales_travel = var[(var["department"] == "Sales") & (var["account"] == "Travel")].set_index("month")
display(sales_travel[["postings", "actual", "budget", "variance", "direction"]])
may_june = sales_travel.loc["2025-05-01":"2025-06-01", ["actual", "budget", "variance"]].sum()
print(f"May + June together: actual {may_june['actual']:,.2f} | budget {may_june['budget']:,.2f} | "
f"variance {may_june['variance']:+,.2f}")
| postings | actual | budget | variance | direction | |
|---|---|---|---|---|---|
| month | |||||
| 2025-01-01 | 6 | 6,843.41 | 6,400.00 | 443.41 | unfavourable |
| 2025-02-01 | 3 | 6,959.24 | 7,400.00 | -440.76 | favourable |
| 2025-03-01 | 2 | 10,684.76 | 10,100.00 | 584.76 | unfavourable |
| 2025-04-01 | 1 | 10,213.47 | 11,000.00 | -786.53 | favourable |
| 2025-05-01 | 6 | 24,385.85 | 12,000.00 | 12,385.85 | unfavourable |
| 2025-06-01 | 4 | 2,274.94 | 9,200.00 | -6,925.06 | favourable |
| 2025-07-01 | 5 | 5,557.23 | 5,500.00 | 57.23 | unfavourable |
| 2025-08-01 | 3 | 5,668.55 | 5,500.00 | 168.55 | unfavourable |
| 2025-09-01 | 5 | 11,940.45 | 11,000.00 | 940.45 | unfavourable |
| 2025-10-01 | 6 | 11,117.40 | 12,000.00 | -882.60 | favourable |
| 2025-11-01 | 3 | 10,931.57 | 10,100.00 | 831.57 | unfavourable |
| 2025-12-01 | 1 | 9,909.63 | 10,100.00 | -190.37 | favourable |
May + June together: actual 26,660.79 | budget 21,200.00 | variance +5,460.79
May looks badly over budget and June looks well under. Taken together, most of the swing disappears. The postings explain why:
same_content = [c for c in actuals.columns if c != "posting_id"]
duplicates = actuals[actuals.duplicated(same_content, keep=False)]
reversal = actuals[actuals["posting_type"] == "reversal"]
pd.concat([duplicates, reversal])[["posting_id", "posting_date", "month", "department", "account",
"amount", "posting_type", "reference_posting_id", "description"]]
| posting_id | posting_date | month | department | account | amount | posting_type | reference_posting_id | description | |
|---|---|---|---|---|---|---|---|---|---|
| 551 | P25-00552 | 2025-05-28 | 2025-05-01 | Sales | Travel | 6,500.00 | expense | Conference travel invoice | |
| 552 | P25-00553 | 2025-05-28 | 2025-05-01 | Sales | Travel | 6,500.00 | expense | Conference travel invoice | |
| 610 | P25-00611 | 2025-06-09 | 2025-06-01 | Sales | Travel | -6,500.00 | reversal | P25-00553 | Reversal of duplicated May conference invoice |
One conference invoice was posted twice in May (two rows identical apart from their posting_id), and the second copy was reversed in June. So May's "overspend" and June's "saving" are the same booking error, not two months of behaviour.
The supplier credit we saw in Part 2 works the same way: it lowers that month's actual and makes the line look favourable, but it corrects an earlier overcharge. It is not evidence that the department operated more cheaply.
Check your understanding 6
A department head says: "We were under budget in June, so we managed travel well." Using the output above, write a two-sentence reply.
Part 11: Worked case — the annual department × account view¶
We now build the view the finance partner asked for: for each department and account, the year's actual, budget, variance and variance %, using comparable cells only, sorted by the size of the variance. Then a chart with colours that are safe for colour-blind readers and labelled, so nobody has to guess what blue or orange mean.
view = comparable.groupby(["department", "account"])[["actual", "budget"]].sum()
view["variance"] = view["actual"] - view["budget"]
view["variance_pct"] = np.where(view["budget"] > 0, view["variance"] / view["budget"], np.nan)
view = view.sort_values("variance", key=abs, ascending=False)
print(f"Department x account lines: {len(view)} | actual {view['actual'].sum():,.2f} "
f"| budget {view['budget'].sum():,.2f} (should equal the comparable totals in Part 9)")
view.head(10).assign(variance_pct=view["variance_pct"].head(10).map("{:+.1%}".format))
Department x account lines: 36 | actual 6,208,423.43 | budget 6,133,000.00 (should equal the comparable totals in Part 9)
| actual | budget | variance | variance_pct | ||
|---|---|---|---|---|---|
| department | account | ||||
| Operations | Salaries and wages | 1,528,096.20 | 1,488,000.00 | 40,096.20 | +2.7% |
| Technology | Software subscriptions | 215,898.99 | 184,800.00 | 31,098.99 | +16.8% |
| Sales | Salaries and wages | 1,006,341.26 | 984,000.00 | 22,341.26 | +2.3% |
| Marketing | Salaries and wages | 480,164.76 | 492,000.00 | -11,835.24 | -2.4% |
| Technology | Salaries and wages | 842,062.76 | 852,000.00 | -9,937.24 | -1.2% |
| People | Training | 57,876.64 | 49,200.00 | 8,676.64 | +17.6% |
| Finance and Admin | Salaries and wages | 363,578.20 | 372,000.00 | -8,421.80 | -2.3% |
| Marketing | Training | 9,044.44 | 15,600.00 | -6,555.56 | -42.0% |
| Sales | Travel | 116,486.50 | 110,300.00 | 6,186.50 | +5.6% |
| Operations | Facilities | 251,183.64 | 246,000.00 | 5,183.64 | +2.1% |
top = view.head(12).iloc[::-1] # largest at the top of the chart
labels = [f"{d} | {a}" for d, a in top.index]
colours = [UNFAVOURABLE if v > 0 else FAVOURABLE for v in top["variance"]]
fig, ax = plt.subplots(figsize=(9, 5.5))
ax.barh(labels, top["variance"], color=colours)
ax.axvline(0, color="black", linewidth=0.8)
for y, (v, p) in enumerate(zip(top["variance"], top["variance_pct"])):
ax.text(v, y, f" {v:+,.0f} ({p:+.0%}) " , va="center", ha="left" if v > 0 else "right", fontsize=8)
ax.set_title("Largest annual expense variances, 2025 (comparable budget cells)")
ax.set_xlabel("Variance = actual − budget (currency units)")
ax.legend(handles=[plt.Rectangle((0, 0), 1, 1, color=UNFAVOURABLE),
plt.Rectangle((0, 0), 1, 1, color=FAVOURABLE)],
labels=["Over budget (unfavourable)", "Under budget (favourable)"], loc="lower right", fontsize=8)
ax.margins(x=0.25)
plt.show()

unknown_total = unknown["actual"].sum()
top_line = view.index[0]
print(
f"In 2025, comparable expenses were {total_actual:,.0f} against a budget of {total_budget:,.0f}, "
f"{(total_actual - total_budget) / total_budget:+.1%} overall. "
f"The largest line variance is {top_line[0]} {top_line[1].lower()} at {view['variance'].iloc[0]:+,.0f} "
f"({view['variance_pct'].iloc[0]:+.1%}). "
f"A further {unknown_total:,.0f} of {', '.join(unknown['account'].unique()).lower()} spending had no budget "
f"and is reported separately."
)
In 2025, comparable expenses were 6,208,423 against a budget of 6,133,000, +1.2% overall. The largest line variance is Operations salaries and wages at +40,096 (+2.7%). A further 33,378 of cloud hosting spending had no budget and is reported separately.
The printed summary follows the rules of this lecture: totals recomputed from comparable cells, signed variances with the word "over" or "under", the unknown-budget spending named and sized, not hidden. The annual view still hides timing: a line can be close to budget for the year while one month was badly wrong, or the reverse. That is why the lab works with both the monthly and the annual views.
Check your understanding 7
Pick one line from the chart. Is it a candidate for the review list? What extra information (monthly pattern, number of postings, credits) would you look at before deciding?
Summary¶
| Question | Method | Trap to avoid |
|---|---|---|
| What is one row? | Check keys: posting_id; (month, department, account) |
Assuming two tables share a grain |
| How do I combine actuals and budgets? | Aggregate postings first, then outer join with indicator and validate |
Joining postings to budgets (budget inflation); inner joins (lost cells) |
| What if a budget is missing? | Label it unknown; report its actuals separately | Filling unknown budgets with 0 |
| Is a variance good or bad? | Expense: positive = unfavourable | Colouring every negative number red |
| When does a % exist? | Only when budget > 0 | Dividing by zero budgets; 100% for budget-only lines without comment |
| What is the total %? | Recompute from totals of the comparable scope | Averaging cell or monthly percentages |
| What is material? | Amount and %, with agreed thresholds | Looking at only one of them |
| Is a saving real? | Inspect credits, reversals and duplicates; combine adjacent months | Praising a reversal as good management |
| Does the table add up? | Reconcile actual and budget totals, keys and uniqueness | Trusting a table that never errored |
Quiz¶
Answer these at the start of the next lecture, which opens the operations case: are orders delivered on time, and which carrier should we worry about? You do not need any operations knowledge; use common sense. Write short answers.
- A warehouse promised delivery within 3 days. 470 of 500 delivered orders arrived within 3 days. What is the on-time rate?
- Another 40 orders were shipped but have not arrived yet, and 10 of them are already past their promised date. Should they count in the on-time rate? What happens to the rate if you simply leave them out?
- Carrier A was on time for 49 of 50 shipments; carrier B for 4,700 of 5,000. Which result do you trust more, and why?
- Average delay is 1 day, but some customers wait two weeks. Which other number would you report to show the bad experiences?
- To combine two warehouses' on-time rates into one company rate, should you average the two rates or do something else?
- A dashboard shows on-time delivery improving after the team stopped recording orders that were cancelled for being late. What went wrong?
Lab briefing¶
In the Lab you:
- Load the postings and budgets, and check keys and signs.
- Aggregate postings, outer-join to budgets with an indicator, and reconcile.
- Build a monthly variance table and find the top exceptions by amount and by %.
- Find the actual-only, budget-only and zero-budget cells.
- Solve the Sales travel May/June story from the postings.
- Draw a labelled, colour-blind-safe variance chart.
- Challenge: debug a colleague's three mistakes with before/after reconciliation, then choose three review priorities.
Open the Session 10 lab.