learn-product-analytics-with-phoebe / Analyst session 3 of 8
Learn Product Analytics with Phoebe · Analyst track · Session 3 of 8

Defining "active"

"Monthly active users" sounds like a measurement. It is a decision wearing a measurement's clothes. Tonight we take one set of real GitHub accounts, a deterministic 5 percent sample followed through 28 days of March 2024, and count them three times: active if they did anything, active if they pushed code, active if they opened an issue or a pull request. The count moves from 79,555 to 8,993. The stickiness ratio everyone quotes barely moves at all, which is exactly why it cannot be trusted to reveal the definition underneath it. Then we ask what "a day" even means when the data only sees two hours of it.

🟡 Core Product and data analysts Python, standard library only 45 min
0-3 · Welcome 3-18 · Three definitions, the ratios, the sampled day 18-40 · Build-along: DAU, WAU, MAU from real rows 40-45 · Q&A
Part 0

Where we start

Sessions 1 and 2 worked on one hour. Tonight needs time, because "active" is a word about repetition: a person seen on one day and again on another. So the dataset changes shape. Instead of every event in one hour, we follow a fixed set of accounts, every account whose GitHub id is divisible by 20, across two hours a day (03:00 and 15:00 UTC) for the 28 days from 2024-03-04 to 2024-03-31. That is 56 hourly files streamed and thrown away, keeping only who was seen and what they did. 79,555 accounts appear at least once; 98 of them carry the [bot] suffix and are dropped. Everything on this page is computed from that sample, and every count is "seen in the sampled hours", never "active on GitHub".

🎫 Clear all three quiz questions to stamp your passport Live = presented in the 45 minutes Self-study = the full depth, after class ★ Build-along = run the code yourself

★ What you walk out with tonight. A script that turns a month of sampled events into per-day "seen" sets under three definitions of active, prints MAU, average DAU and average WAU for each, and the two ratios (DAU/MAU, DAU/WAU) that boards ask for. Plus the argument you will need the first time someone changes a definition and the stickiness number stays flat: 79,555 / 48,946 / 8,993 monthly actives, 6.4 / 6.6 / 5.0 percent DAU/MAU, same accounts, same month.

Part 1 · covers three definitions on the same accounts

Three definitions, three monthly actives

5 min live

Hold the accounts fixed and change only the verb. "Active" as any event at all gives 79,555 monthly actives, which is every account in the sample by construction. "Active" as pushed code gives 48,946. "Active" as opened an issue or a pull request gives 8,993. Nothing about the users changed between those three sentences; a definition did. Whoever writes the definition is choosing the number, and the choice should be written down next to the number every time it is reported.

ONE SAMPLE, THREE VERBS 79,555 accounts with id divisible by 20 · two sampled hours a day · 28 days of March 2024 any event pushed code opened an issue or PR overlap with push not counted ANY EVENT 79,555 PUSHED CODE 48,946 OPENED ISSUE OR PR 8,993 roughly a ninth of the outer set Same accounts, same 28 days, same sampled hours. Only the verb in the definition changed, and the "MAU" moved ninefold.
🔍 Click to zoom - a monthly active count without its verb is a number without a meaning
LiveActive is a verb, not a status3 min▶

In the script, each definition is one test on the per-hour record of an account: any is "at least one event", push is "at least one PushEvent", open is "at least one issue or pull request opened". An account is active on a day under a definition if the test passes in either sampled hour of that UTC day. The three tests are one line each, and that line is the whole product decision. "Any event" includes starring someone else's repository and commenting on a thread; "pushed code" is closer to the work; "opened an issue or PR" is the narrowest, the moment an account asks the project for something or offers it something.

None of the three is the right one. The right one depends on the question. A capacity planner wants "any event" because servers do not care about intent. A team asking "is our product being used for its purpose" wants the narrow verb, and has to accept a number one ninth the size. Reporting the wide number to answer the narrow question is the most common quiet lie in product analytics, and it is rarely told on purpose.

Self-studyWhy 79,555 is not "GitHub's MAU"2 min▶

Two reasons, both worth saying out loud whenever the number is quoted. First, the sample: only accounts whose id is divisible by 20, so about one account in twenty, kept deterministically so the same accounts are followed through every hour. Second, the clock: two hours out of 24, every day. An account that works from 09:00 to 17:00 Central European time and never touches GitHub at 03:00 or 15:00 UTC does not exist in this dataset. 79,555 is "accounts in the sample seen in the sampled hours during March 2024", and the build-along prints it with that sentence attached. Part 3 is about what the two-hour clock does to every count on the page.

The 98 dropped accounts are the ones whose login ends in [bot]. Automation that does not label itself stays in the sample, exactly as in session 2's hour, where the busiest account had no suffix at all. Session 5 goes looking for it in the right tail of the days-active histogram.

Real world

Two decks, two MAUs, one product. A common pattern: the growth team's deck counts a user active if they opened the app, because the login event is the one that is never missing. The product team's deck counts a user active if they completed the core action, because that is what the roadmap is trying to move. Both numbers are correct. Then a board member reads both decks in the same afternoon and asks why the company cannot count its own users. The fix is not to pick a winner; it is a one-line definition printed under every chart, with the verb in it.

Part 2 · covers DAU, WAU, MAU and the ratios

The ratio that hides the choice

6 min live

Stickiness, DAU divided by MAU, is the number that survives every meeting. Here it is for the three definitions: 6.4 percent, 6.6 percent, 5.0 percent. The monthly count moved ninefold between the first and third definition; the ratio moved by less than two points. A reader given only the ratios would conclude the three definitions describe nearly the same engagement. They do not describe the same anything. The ratio normalises away the size of the population, and with it the entire definition question.

SAME SHAPE, DIFFERENT WORLD each panel scales its bars to its own MAU · averages over 28 days and 4 calendar weeks ANY EVENT MAU79,555 avg WAU27,490.0 avg DAU5,110.6 DAU/MAU 6.4%DAU/WAU 18.6% PUSHED CODE MAU48,946 avg WAU17,094.2 avg DAU3,217.9 DAU/MAU 6.6%DAU/WAU 18.8% OPENED ISSUE OR PR MAU8,993 avg WAU2,761.2 avg DAU452.4 DAU/MAU 5.0%DAU/WAU 16.4% The bars are the ratios. They look alike because a ratio throws the population size away, and the definition with it. Sampled hours only: a person active at other hours is missing from every bar on this figure. Part 3 says how much that matters.
🔍 Click to zoom - three almost identical shapes, and the left one counts nine times as many people as the right one
LiveHow the three counts are built, and what each averages3 min▶

MAU here is the number of accounts active on at least one of the 28 days, so it is one count for the whole window. DAU is a count per day, 28 of them, and the page reports the average. WAU is a count per calendar week (the window starts on a Monday, so the four weeks are Monday to Sunday), and the page reports the average of the four. A single account can therefore be in MAU once, in WAU up to four times and in DAU up to 28 times. DAU/MAU asks: of the people who showed up at all this month, what share shows up on a typical day. DAU/WAU asks the same over a week, and comes out around three times larger because a week is a smaller net.

Li Jin and Andrew Chen put the objection precisely in their power-user curve essay: DAU/MAU "is a single number and so blurs this variance". They meant the variance between users who come once and users who come daily. Tonight's data adds a second blur: the ratio also hides which verb was used to count anyone at all. Session 5 unpacks the first blur with the L28 histogram, on these same accounts.

Self-studyWhy the ratio barely moved2 min▶

It is tempting to read 6.4 versus 6.6 versus 5.0 as "engagement is about the same however you define it", and that reading is exactly backwards. When the definition narrows from any event to pushing code, both numerator and denominator shrink, and they shrank in almost the same proportion, so the ratio held. The narrowest verb has the lowest ratio, 5.0 percent, which is at least in the expected direction: opening issues and pull requests is a rarer daily habit than pushing. But a ratio that sits between 5 and 7 percent for three populations that differ ninefold is telling you it is a poor instrument for this question, not that the question has one answer.

A working rule: never report a ratio without the two counts that made it, on the same line. "DAU/MAU 6.6 percent (3,218 of 48,946, pushed code, sampled hours)" cannot be misread. "Stickiness 6.6 percent" can be misread in a dozen ways, and will be.

Real world

The redefinition nobody noticed. A common pattern: a team tightens its definition of active mid-quarter, from "opened the app" to "performed the core action", for good reasons. MAU falls by a large fraction on the chart and there is a difficult meeting. DAU/MAU, on the slide beside it, hardly moves, and someone says "at least engagement is stable". Both slides are computed correctly. The second one is answering a question nobody asked, and its stability is a property of the arithmetic rather than of the users.

Part 3 · covers what "a day" means under sampling

A day the data only half sees

4 min live

Every count tonight says "active on a day", and the data behind it saw two hours of that day. An account that pushes at 10:00 UTC every single morning is, in this sample, never active. An account that pushes once at 15:20 is active for the day. "Active on a day" here means "seen in the 03:00 or the 15:00 hour of that UTC day", and the honest sentence is longer than the convenient one every time. The same problem hides inside your own product's numbers, in gentler forms: which timezone's midnight ends the day, which events the client failed to send, and what a verb like "pushed" is allowed to count.

ONE UTC DAY, TWO WATCHED HOURS 00:00 UTC 06:00 12:00 18:00 03:00 hour 15:00 hour 22 unwatched WHAT THE SAMPLE CAN SEE 2 of 24 hours the same two, every day, for 28 days gold mark: pushed at 03:30 or 15:20 counted as active on this day dashed mark: pushed at 10:00 only not seen, so not active, every day a daily user on the wrong schedule has zero active days in this dataset "never active" here means "never at 03:00 or 15:00" Every DAU, WAU and MAU on this page is "seen in the sampled hours". The ring is why the page never says "GitHub's stickiness".
🔍 Click to zoom - the two blue wedges are the whole dataset; the rest of the day is a guess the numbers are not allowed to make
LiveThree ways "a day" goes wrong2 min▶

The clock. Two hours of 24 is the extreme case, chosen because 56 files can be streamed on a laptop while 672 cannot. But even complete data has a clock problem: a "day" is a UTC day here, and an account in Sydney whose working afternoon is 03:00 to 07:00 UTC has its Tuesday counted as Wednesday. Choose the day boundary and write it down; the same person can be a one-day user or a two-day user depending on it.

The verb. Session 1 found 234 PushEvents in a single hour that carried zero commits: a branch moved, a force push, a ref updated with nothing new in it. The push test tonight is "at least one PushEvent", so every one of those 234 makes its account "pushed code" active for the day. A stricter verb, "pushed at least one commit", needs the payload's commit count, which the sampler kept as a per-event count but not per commit. The definition you can compute is bounded by what you chose to keep.

The gap. An event that never arrived looks identical to an action that never happened. In this sample the gap is 22 hours a day and it is known. In a product it is a client that dropped events on a bad connection, and it is not known unless someone counts arrivals against expectations, which is what the session 1 validator's "not in the plan" tray was for.

Self-studyWhich direction the sampling bends each number2 min▶

The two-hour clock undercounts everything, but not evenly. MAU is hurt least: an account needs to be seen in just one of 56 sampled hours across the month to make it in. DAU is hurt most: an account has to land in one of two specific hours on that specific day. So the DAU/MAU ratio is biased low by construction, and by an unknown amount, because the true distribution of activity across hours for these accounts is not in the data. That is why the page reports 6.4 percent without ever calling it GitHub's stickiness, and why session 5's days-active histogram will lean left partly for a reason that has nothing to do with the users.

A fair question is whether any of this is worth computing. Yes, for the comparison: all three definitions were sampled by the same clock, so the ninefold gap between them is real even though each absolute number is low. Relative claims survive a shared bias; absolute claims do not.

Real world

The dashboard that sampled without saying so. A common pattern: a high-volume product turns on event sampling in its analytics tool to control cost, and the tool scales the counts back up so the charts look complete. Months later an analyst compares the dashboard's daily actives against the billing database and the two disagree, most for the smallest segments, where a scaled-up handful of sampled users stands in for thousands. Nothing was wrong with the tool. The word "sampled" had simply fallen off the chart title, and every reader assumed a census. The fix is a label, not a recompute: "sampled hours" or "sampled users", in the same font as the number.

Build-along

DAU, WAU and MAU from real rows, three ways

★ 22 min · everyone builds

Standard-library Python and 56 GH Archive files, streamed and discarded. Step 1 is the slow part, about 37 files' worth of gzip per hour of download depending on your connection, and it produces a small JSON cache; everything after it runs in seconds. Every number printed below is what this code prints on the cache for 2024-03-04 to 2024-03-31, hours 03:00 and 15:00 UTC, accounts with id divisible by 20.

FIVE STEPS, FIFTY-SIX FILES 1 · stream 56 hours keep id % 20 == 0 2 · one JSON each then actor-days.json 3 · drop [bot] 98 out, 79,555 stay 4 · days seen a set per verb 5 · MAU, DAU, WAU and the two ratios about 37 GB streamed, nothing kept but who and what a few MB of cache three sets per account three lines of output On your own product, step 1 is a query on the events table. Steps 3 to 5 are unchanged, and step 4 is where the definition lives.
🔍 Click to zoom - step 4 is one lambda per definition, and it is the only step a product decision touches

Stream the sample. Each hourly file is gunzipped on the fly and every line is tested with a regex before any JSON is parsed, because parsing 95 percent of the lines just to throw them away is the slow way. An actor id divisible by 20 is kept; the rest are skipped. Bot logins stay in the file so the drop is visible later.

★ Step 1 · one small JSON per sampled hour (shell loop + keep_sample.py)# shell: 28 days x 2 hours = 56 files, streamed, never stored mkdir -p actors for d in $(seq 0 27); do day=$(python3 -c "import datetime as dt,sys; print(dt.date(2024,3,4)+dt.timedelta(int(sys.argv[1])))" $d) for h in 3 15; do curl -sfL --retry 3 https://data.gharchive.org/$day-$h.json.gz | gunzip | python3 keep_sample.py > actors/$day-$h.json done done # keep_sample.py: stdin is one hour of events, one JSON object per line import json, re, sys ACTOR_ID = re.compile(r'"actor":\{"id":(\d+),') acc, n = {}, 0 for line in sys.stdin: n += 1 m = ACTOR_ID.search(line) if m is None or int(m.group(1)) % 20: continue # not in the 5 percent: skip before json.loads e = json.loads(line) a = acc.setdefault(e["actor"]["id"], [e["actor"]["login"], 0, 0, 0]) a[1] += 1 # any event if e["type"] == "PushEvent": a[2] += 1 # pushed (a PushEvent, commits not counted) elif e["type"] in ("PullRequestEvent", "IssuesEvent") and (e.get("payload") or {}).get("action") == "opened": a[3] += 1 # opened an issue or a pull request print(json.dumps({"events": n, "actors": acc}))

Combine and load. One dictionary keyed by hour, each value the per-actor record [login, n_events, n_push, n_opened]. Then drop bots and print the two sizes. The print statement is the first line of every report built from this cache.

★ Step 2 · actor-days.json, then drop the [bot] suffiximport json, glob, os from collections import defaultdict hours = {os.path.basename(p)[:-5]: json.load(open(p)) for p in glob.glob("actors/*.json")} json.dump(hours, open("actor-days.json", "w")) # 56 keys like "2024-03-04-15" days = sorted({h.rsplit("-", 1)[0] for h in hours}) assert len(days) == 28, len(days) bots, seen = set(), defaultdict(lambda: {d: set() for d in DEFS}) # DEFS is step 3 events = defaultdict(int) for h, blob in hours.items(): day = h.rsplit("-", 1)[0] for aid, v in blob["actors"].items(): if v[0].endswith("[bot]"): bots.add(aid) continue events[aid] += v[1] for d, test in DEFS.items(): if test(v): seen[aid][d].add(day) print("sampled accounts seen at least once:", len(seen), " bot accounts dropped:", len(bots)) # sampled accounts seen at least once: 79555 bot accounts dropped: 98

Write the definitions. Three lambdas on the per-hour record. This is the step a product manager should be able to read, and the one that changes when the definition of active changes. Run it before step 2 so the loop can use it.

★ Step 3 · three definitions of "active on a day"DEFS = { "any": lambda v: v[1] > 0, # did anything at all in a sampled hour "push": lambda v: v[2] > 0, # at least one PushEvent (a zero-commit push counts) "open": lambda v: v[3] > 0, # opened at least one issue or pull request } # an account is active on a UTC day, under a definition, if the test passes # in either of that day's two sampled hours; seen[aid][d] is the set of such days

Count. MAU is the number of accounts with a non-empty set. DAU is the count per day, averaged over 28. WAU is the count per calendar week (the window opens on a Monday), averaged over four. The ratios use the averages. One extra line reports how concentrated the sampled events are, for session 5.

★ Step 4 · MAU, average DAU, average WAU, two ratiosev = sorted(events.values(), reverse=True) print("top 1 percent of accounts by events hold %.1f%% of all sampled events" % (100 * sum(ev[: len(ev) // 100]) / sum(ev))) for d in DEFS: ldays = [len(s[d]) for s in seen.values() if s[d]] mau = len(ldays) dau = [sum(1 for s in seen.values() if day in s[d]) for day in days] avg_dau = sum(dau) / 28 wk = [sum(1 for s in seen.values() if any(day in s[d] for day in days[i:i + 7])) for i in (0, 7, 14, 21)] print("\n[%s] MAU (active on >= 1 of 28 days): %d" % (d, mau)) print(" average DAU %.1f stickiness DAU/MAU %.1f%% average WAU %.1f DAU/WAU %.1f%%" % ( avg_dau, 100 * avg_dau / mau, sum(wk) / 4, 100 * avg_dau / (sum(wk) / 4)))

Read the output. These are the lines the cache gives. Say the long sentence when you quote them: accounts in the 5 percent sample, seen in the two sampled hours, March 2024.

★ Step 5 · what it prints# top 1 percent of accounts by events hold 30.1% of all sampled events # # [any] MAU (active on >= 1 of 28 days): 79555 # average DAU 5110.6 stickiness DAU/MAU 6.4% average WAU 27490.0 DAU/WAU 18.6% # # [push] MAU (active on >= 1 of 28 days): 48946 # average DAU 3217.9 stickiness DAU/MAU 6.6% average WAU 17094.2 DAU/WAU 18.8% # # [open] MAU (active on >= 1 of 28 days): 8993 # average DAU 452.4 stickiness DAU/MAU 5.0% average WAU 2761.2 DAU/WAU 16.4%

Keep the cache and the sets. seen[aid][d] is a set of days per account per definition. Session 5 turns the size of each set into the L28 histogram, the power-user curve, without touching GH Archive again. If you change a definition, change the lambda in step 3 and re-run steps 2 and 4; the fetch never needs to repeat.

Homework

Before session 4

30 min
  1. Add a fourth definition to step 3, "busy": at least two events in a sampled hour (v[1] > 1). Re-run steps 2 and 4 and write down where its MAU and DAU/MAU land relative to the three on this page. Your numbers will differ from nothing here; this is the same cache, so they are new canon for you.
  2. Compute DAU/MAU per calendar week instead of for the whole month, for the "push" definition, and note whether it moves more between weeks than between definitions. One sentence on what that says about the ratio as an instrument.
  3. Write your own product's three definitions of active, one sentence each with the verb in it, and predict their ordering before you count. Then count, if you have the events. Keep the prediction; being wrong about the ordering is the useful part.
  4. Keep actor-days.json and the seen sets. Session 5 builds the days-active histogram from them, and session 4 switches to a different sample: 8,348 new repositories followed for six weeks.
Check yourself

Three questions

Clear all three and analyst session 3 gets stamped in your passport.

1 · The same 79,555 sampled accounts give MAU 79,555 under "any event", 48,946 under "pushed code" and 8,993 under "opened an issue or PR". What changed between the three counts?

Hold everything fixed and change the verb, and "MAU" moves ninefold. That is why a monthly active number reported without its definition cannot be compared with anything, including last quarter's.

2 · DAU/MAU reads 6.4, 6.6 and 5.0 percent for the three definitions. A colleague says "so engagement is about the same however you define active". What is wrong with that reading?

Numerator and denominator shrank in almost the same proportion, so the ratio held while the counts collapsed. Report a ratio with the two counts that made it on the same line, and the verb beside them.

3 · The sample watches the 03:00 and 15:00 UTC hours only. An account that pushes code at 10:00 UTC every weekday in March 2024 appears in the cache as:

Two hours of 24 cannot see a user who works at other hours. MAU is hurt least and DAU most, so the ratio is biased low by an unknown amount. Relative claims between the three definitions survive the shared bias; absolute claims about GitHub do not.

Sources covered

What this session teaches, and where it came from

✓Every MAU, DAU, WAU and ratio on the pageMeasured on GH Archive, accounts with id divisible by 20, hours 03:00 and 15:00 UTC, 2024-03-04 to 2024-03-31, by the code on this page
✓79,555 accounts seen, 98 [bot] dropped, top 1 percent hold 30.1 percent of sampled eventsSame run, printed by steps 2 and 4
✓DAU/MAU "is a single number and so blurs this variance"Jin, L. and Chen, A. (2018). "The Power User Curve: The best way to understand your most engaged users." a16z, 6 August 2018, read at source
✓234 zero-commit pushes in one hourMeasured on GH Archive 2024-03-04 15:00 UTC in analyst session 1
✓Hourly files, UTC, no loginGH Archive. https://www.gharchive.org/ (hourly archives, data.gharchive.org)
◐GitHub's true daily active count and stickinessNot measurable from two sampled hours a day; taught as an uncertainty with its direction, never as a number

Every fact on this page, and its verification tier, is recorded in the course's source map.

Analyst session 3 cheat sheet · pin this

Active is a verbAny event 79,555 · pushed code 48,946 · opened issue or PR 8,993. Same accounts.
The ratio hides the choiceDAU/MAU 6.4 / 6.6 / 5.0 percent while the count moves ninefold.
Print the counts with the ratio"6.6 percent (3,218 of 48,946, pushed code, sampled hours)" cannot be misread.
Say the long sentenceSeen in the sampled hours, 5 percent of accounts, March 2024. Never "GitHub's MAU".
A day has a clock and a verbUTC midnight decides the day; a zero-commit push still counts as "pushed".
Relative survives, absolute does notAll three definitions share the two-hour bias, so the gap between them is real.