Metric trees

Revenue fell this week, so which part of the business made it fall?

Lesson 15 min readFree, runs offline
On this page
  1. The problem
  2. What you will build from scratch
  3. The idea
  4. Walk through
  5. Your turn
  6. Check yourself
  7. In real job postings
  8. Next

Get the lesson files

The dataset, exercise.py, the solution and the test live in the open-source repo. You need only Python 3.9 or later.

git clone https://github.com/Ani19arya/analytics-from-scratch
cd analytics-from-scratch/phases/03-metrics-and-product-analytics/01-metric-trees
python3 test_lesson.py
Open this lesson on GitHub

The problem

Brightloom, a fictional home goods shop, made less money in the week of 12 October than in the week before. Priya, the head of growth, has a Monday standup and wants one sentence: did we lose traffic, did people stop buying, or did they buy cheaper things? Looking at revenue alone cannot answer that, because revenue is the product of several smaller numbers. You will break revenue into a tree, measure how much of the drop each branch explains, and then drill one level down into traffic channels to find where it started.

The file data/daily_channels.csv is synthetic, generated by make_data.py with a fixed seed. It has one row per day and channel (search, email, paid_social) from 5 to 18 October 2026, with visitors, orders and revenue.

What you will build from scratch

  • build_tree(visitors, orders, revenue): the three branches of the revenue tree.
  • load_weeks(path, split_date, channel=None): prior week and current week trees from the raw daily rows, optionally for one channel.
  • decompose(prior, current): how many dollars of the change each branch explains, with effects that add up exactly to the total.
  • main_driver(effects): the branch that explains the most.

The idea

A metric tree writes a top-line number as a product (or sum) of smaller numbers that different teams own. For an online shop:

revenue    = visitors x conversion x aov
conversion = orders / visitors
aov        = revenue / orders        (average order value)

Multiply the right side out and the visitors and orders cancel, so the identity is exact for any period. Marketing owns visitors, product owns conversion, merchandising and pricing own AOV.

When revenue changes from R0 to R1, each factor changes too. The question is how to split the dollar change R1 - R0 between the factors. Because the tree is a product, take logs and it becomes a sum:

ln(R1 / R0) = ln(V1 / V0) + ln(C1 / C0) + ln(A1 / A0)

Each factor's share of the log change is its share of the dollar change:

effect_f = (R1 - R0) * ln(f1 / f0) / ln(R1 / R0)

The three effects always add up to R1 - R0, and the answer does not depend on the order you list the factors in. (The common alternative, swapping in one new factor at a time, gives different splits depending on which factor you swap first.) If revenue did not change at all, the formula divides by zero, so every effect is 0.

A tree tells you which branch moved, not why. Once you know the branch, cut it by a dimension (channel, device, country) and run the same decomposition on each slice.

Walk through

A small worked example. Last week 1,000 visitors placed 40 orders worth $2,400. This week 1,100 visitors placed 33 orders worth $1,980.

import math
prior = {"visitors": 1000, "conversion": 40 / 1000, "aov": 2400 / 40, "revenue": 2400.0}
current = {"visitors": 1100, "conversion": 33 / 1100, "aov": 1980 / 33, "revenue": 1980.0}
total = current["revenue"] - prior["revenue"]
log_total = math.log(current["revenue"] / prior["revenue"])
effects = {f: total * math.log(current[f] / prior[f]) / log_total for f in ("visitors", "conversion", "aov")}
print(round(total, 2), {f: round(v, 2) for f, v in effects.items()})
# -420.0 {'visitors': 208.09, 'conversion': -628.09, 'aov': 0.0}
print(round(sum(effects.values()), 2))   # -420.0

Traffic grew and added about $208, AOV stayed at $60, and the conversion drop from 4.0% to 3.0% cost about $630. The net is the $420 drop.

Now the lesson data. Sum the daily rows into two weeks, split on 12 October:

import csv
sums = {"prior": [0, 0, 0.0], "current": [0, 0, 0.0]}
with open("data/daily_channels.csv", newline="") as f:
    for row in csv.DictReader(f):
        k = "prior" if row["date"] < "2026-10-12" else "current"
        sums[k][0] += int(row["visitors"])
        sums[k][1] += int(row["orders"])
        sums[k][2] += float(row["revenue"])
print({k: [v[0], v[1], round(v[2], 2)] for k, v in sums.items()})
# {'prior': [18051, 611, 34796.15], 'current': [18261, 530, 30971.09]}

Running python3 solution/solution.py gives the full tree:

week visitors orders revenue conversion AOV
prior 18,051 611 $34,796.15 3.38% $56.95
current 18,261 530 $30,971.09 2.90% $58.44

Revenue fell by $3,825.06. Visitors explain +$379.92, conversion explains -$5,051.35 and AOV explains +$846.37. Conversion is the driver: without the conversion drop, revenue would have gone up.

Drill down by channel with the same functions. Search revenue rose $972.83 and email rose $1,263.29, while paid_social fell $6,061.18. Paid social conversion went from 3.11% to 1.27% while its traffic grew. The AOV rise is a side effect: fewer orders came from paid social, which has the cheapest baskets, so the average order across all channels went up even though no single channel changed its prices. That is a mix effect, and it is why you always drill down before you report a branch as good news.

What to tell Priya: "Revenue fell $3.8k week over week. Traffic was flat and order values were fine; the drop is conversion, and it is all in paid social, where conversion fell from 3.1% to 1.3% while traffic grew. Check the paid social landing page and ad targeting first."

Your turn

Open exercise.py and fill in the four functions using only csv and math. Then run, from this folder:

python3 test_lesson.py

To see the reference solution pass: CHECK_SOLUTION=1 python3 test_lesson.py. To print the full tree and channel drill down: python3 solution/solution.py.

Check week over week changes by hand with the percentage change calculator.

Check yourself

  1. Revenue is down 11%. Why is "revenue is down" not a useful answer for a standup?
  2. Why use the log method instead of changing one factor at a time?
  3. AOV went up this week. Is that good news?
  4. Name another metric tree you could build for a subscription app.
Answers
  1. Nobody can act on it. Each branch has an owner and a different fix, so the useful answer names the branch (conversion) and the slice (paid social) that moved.
  2. One at a time gives a different split depending on the order you swap the factors in, and the leftover interaction term has to be assigned somewhere. The log split is symmetric and the parts always add up to the total.
  3. Not on its own. Here it rose because the channel mix shifted away from low-value paid social orders, so it is a side effect of the problem, not a win. Check the mix before you credit a branch.
  4. For example: revenue = paying users x revenue per paying user, and paying users = active users x paid conversion rate. Or monthly recurring revenue = new + expansion - contraction - churned revenue, which is a sum tree instead of a product.

In real job postings

In the Real Analyst Jobs live feed on 25 October 2026, 72% of the 2,102 US analyst postings that list tools ask for SQL and 36% ask for Tableau, the tools usually used to build and show a revenue tree like this one. See SQL for analyst jobs and Tableau for analyst jobs.

Next

02: Funnel conversion

This is the first lesson of the phase. The previous phase is 02 Statistics and experiments.

Put the skill to work

Real Analyst Jobs lists analyst roles read daily from company career pages, with the skills each posting asks for.

Browse analyst jobs

All lessons in Analytics from Scratch

Raj