Skip to content
academia.sh

Lesson 12 / 13

Cohort and Funnel Analysis

Counting how many separate retention rates the cohort definition produces from the same dataset: the rule that counts the unobservable cohort as zero gives 0.619, the rule that excludes it gives 0.867, and outside the three-period window 843 of the 3,199 readings remain. Taking the entry window as a period gives two observable cohorts and a rate range of 0.009; taking it as five weeks gives six cohorts and a range of 0.040; since the narrowest margin of variation is 0.087, the whole range fits inside the margin of a single cohort. The funnel enters only as far as the aggregation reversal: while the aggregated difference is -0.237, the difference in both of the two observable cohorts is +0.083 and +0.028.

Published Updated

In this lesson

  • Computes the retention rates that the two rules, counting the unobservable cohort as zero and excluding it, produce from the same dataset, and gives the number of readings that remain outside the window
  • Compares how the number of cohorts, the rate range and the margin of variation of each cohort change when the entry window is thinned
  • Shows with numbers that the aggregated rate and the cohort rates point in opposite directions: aggregated difference -0.237, cohort differences +0.083 and +0.028
Contents

The previous lesson showed that a coefficient changes direction by breakdown, but the breakdown was always the region and time never entered the work. Subscribers do not join the network at the same time and are not observed for the same length of time. Still being able to read a subscriber one period after the registration opened is a success, and the rate of this success is called retention.

The number of this lesson is the retention rate that the cohort definition moves. A cohort is the set built by entry period, and its definition contains two decisions: how wide the entry window is and where the observation window closes. Both decisions are defensible, and both produce a different rate from the same dataset. The step computation of the conversion funnel is not repeated here; step conversion, drop-off rate and cumulative conversion were measured in the Conversion Funnel lesson of the User Experience course. The funnel enters this lesson only as far as the aggregation reversal.

  • AS36. The dataset is inherited from K03 and is fictional: 3,199 readings, 1,329 subscribers, three periods, five regions.
  • AS37. The entry week field is fictional too: the measurement network did not reach the regions at the same time, so the distribution of the entry period depends on the region.
  • AS38. In this lesson the reading index gives the period after entry; the calendar period is the sum of the entry period and the index minus one, and the calendar closes at three periods.
  • AS39. Retention is the share of the subscribers of a cohort who still produce a reading KK periods after entry. If the period KK later falls outside the calendar, the rate cannot be observed.
  • AS40. Two rules can be applied to an unobservable subscriber: the zero-counting rule counts the subscriber as not retained, the excluding rule removes the subscriber from the denominator.
  • AS41. The margin of variation is produced by drawing with replacement: the cohort list is redrawn 1,000 times.
  • AS42. The reading method field is fictional too and carries two relationships together: remote reading has become widespread in the late periods and has fewer interruptions.

The Observation Window Cuts the Cohort

The calendar closes at three periods. For a subscriber who joined the network in the third period, the answer to the question “can this subscriber still be read one period later” is not in the dataset. This subscriber has not failed to be retained; the subscriber has not been measured yet.

# cohort.py — MODEL. The fictional dataset of K03 is produced with the same seed. The entry
# week field is FICTIONAL too: the measurement network did not reach the regions at the same
# time, so the distribution of the entry period depends on the region. The reading index gives
# the period after entry; calendar period = entry period + index - 1 and the calendar closes
# at three periods.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]
ENTRY = {"east": [0.60, 0.28, 0.12], "central": [0.52, 0.30, 0.18],
         "north": [0.40, 0.34, 0.26], "south": [0.28, 0.36, 0.36],
         "west": [0.16, 0.32, 0.52]}


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


SUBSCRIBERS, DROPPED = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05])
    pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17])
    (DROPPED if r() < 0.046 else SUBSCRIBERS).append(
        {"subscriber_id": 10001 + i, "region": b[0], "base": b[2]})
VALID = {k["subscriber_id"] for k in SUBSCRIBERS}
READINGS = {}
for k in SUBSCRIBERS + DROPPED:
    r = rng(SEED + 7000 + k["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(
            k["base"] * math.exp((r() + r() + r() - 1.5) * 0.62) * (1 - d * 0.05) * 100
            + 0.5) / 100
        if k["subscriber_id"] in VALID:
            READINGS.setdefault(k["subscriber_id"], []).append(t)
        r()

JOINED = []
for k in SUBSCRIBERS:
    if k["subscriber_id"] not in READINGS:
        continue
    r = rng(SEED + 61000 + k["subscriber_id"])
    e = pick(r(), ENTRY[k["region"]])
    week = e * 13 + 1 + int(r() * 13)
    JOINED.append({"id": k["subscriber_id"], "region": k["region"], "week": week,
                   "cohort": e + 1, "block": (week - 1) // 5 + 1,
                   "n": len(READINGS[k["subscriber_id"]])})

WINDOW = 3


def observable(x, K):
    """Does the cohort stay inside the three-period calendar K periods later."""
    return x["cohort"] + K <= WINDOW


def retained(x, K):
    return x["n"] >= K + 1 and observable(x, K)


def rate(group, K, rule):
    denominator = group if rule == "zero-counting" else [x for x in group if observable(x, K)]
    if not denominator:
        return None, 0
    return sum(1 for x in denominator if retained(x, K)) / len(denominator), len(denominator)


print(f"fictional dataset (seed {SEED}): {len(SUBSCRIBERS)} subscribers, "
      f"{sum(len(v) for v in READINGS.values())} readings")
print("entry cohort (period): " + ", ".join(
    f"{k}: {sum(1 for x in JOINED if x['cohort'] == k)}" for k in (1, 2, 3))
    + f"   ({len(JOINED)} subscribers with at least one reading)")
print(f"readings outside the three-period window: "
      f"{sum(max(0, x['cohort'] + x['n'] - 1 - WINDOW) for x in JOINED)}"
      f" / {sum(x['n'] for x in JOINED)}")

for K in (1, 2):
    print(f"\nretention ({K} period after entry)")
    print("cohort      n     observable   zero-counting   excluding")
    for coh in (1, 2, 3):
        group = [x for x in JOINED if x["cohort"] == coh]
        o1, n1 = rate(group, K, "zero-counting")
        o2, n2 = rate(group, K, "excluding")
        print(f"{coh:>6} {len(group):>6} {n2:>14}   {o1:>13.3f}   "
              + (f"{o2:>9.3f}" if o2 is not None else f"{'--':>9}"))
    o1, _ = rate(JOINED, K, "zero-counting")
    o2, n2 = rate(JOINED, K, "excluding")
    print(f"{'total':>6} {len(JOINED):>6} {n2:>14}   {o1:>13.3f}   {o2:>9.3f}")
fictional dataset (seed 20260218): 1329 subscribers, 3199 readings
entry cohort (period): 1: 488, 2: 412, 3: 360   (1260 subscribers with at least one reading)
readings outside the three-period window: 843 / 3199

retention (1 period after entry)
cohort      n     observable   zero-counting   excluding
     1    488            488           0.871       0.871
     2    412            412           0.862       0.862
     3    360              0           0.000          --
 total   1260            900           0.619       0.867

retention (2 period after entry)
cohort      n     observable   zero-counting   excluding
     1    488            488           0.648       0.648
     2    412              0           0.000          --
     3    360              0           0.000          --
 total   1260            488           0.251       0.648

The number to enter the report is 0.619 or 0.867 for one-period retention. Both come from the same dataset and the same definition; the only place where they part is what the 360 unobservable subscribers are counted as. The zero-counting rule takes them as not retained and pulls the rate down by 0.248. For two-period retention the difference is even larger: 0.251 against 0.648.

Counting an unobservable subscriber as zero is not a measurement but an assumption, and this assumption is not met in the dataset. For the 360 subscribers of cohort 3, the reading of the next period has never been collected; had it been collected, it would be expected to give a number close to the rates 0.871 and 0.862 of the first two cohorts.

The third row gives the size of this cutting: 843 of the 3,199 readings fall outside the three-period calendar. The cohort definition cuts the dataset, and the part that is cut off is not small.

The Entry Window Changes the Number of Cohorts

The second decision of the cohort is the width of the entry window. If blocks of five weeks are taken instead of periods, the same subscribers distributed over eight cohorts. Next to the rate of every cohort, its margin is printed too.

# When the entry window changes, how many separate rates come out of the same dataset.
RESAMPLES = 1000
rs = rng(SEED + 44000)


def rate_interval(group, K):
    """Drawing with replacement: the cohort list is redrawn 1,000 times."""
    n, drawn = len(group), []
    for _ in range(RESAMPLES):
        drawn.append(sum(1 for _ in range(n) if retained(group[int(rs() * n)], K)) / n)
    drawn.sort()
    return drawn[24], drawn[974]


def scan(field, label):
    keys = sorted({x[field] for x in JOINED})
    print(f"\nentry window: {label} -> {len(keys)} cohorts   "
          "(excluding rule, 1 period)")
    print("cohort      n    retention   margin of variation   width")
    widths, rates = [], []
    for a in keys:
        group = [x for x in JOINED if x[field] == a and observable(x, 1)]
        if len(group) < 10:
            print(f"{a:>6} {len(group):>6}   {'unobservable':>12}")
            continue
        o = sum(1 for x in group if retained(x, 1)) / len(group)
        d, u = rate_interval(group, 1)
        widths.append(u - d)
        rates.append(o)
        print(f"{a:>6} {len(group):>6}   {o:>10.3f}   [{d:.3f}, {u:.3f}]   {u - d:>10.3f}")
    print(f"  rate range {max(rates) - min(rates):.3f}, "
          f"narrowest margin {min(widths):.3f}, widest margin {max(widths):.3f}")
    return rates


d3 = scan("cohort", "period (13 weeks)")
d8 = scan("block", "five weeks")
print(f"\nnumber of retention rates from the same dataset: {len(d3)} and {len(d8)}")
print(f"range with the period definition {min(d3):.3f} - {max(d3):.3f}, "
      f"with the five-week definition {min(d8):.3f} - {max(d8):.3f}")
entry window: period (13 weeks) -> 3 cohorts   (excluding rule, 1 period)
cohort      n    retention   margin of variation   width
     1    488        0.871   [0.840, 0.898]        0.057
     2    412        0.862   [0.828, 0.896]        0.068
     3      0   unobservable
  rate range 0.009, narrowest margin 0.057, widest margin 0.068

entry window: five weeks -> 8 cohorts   (excluding rule, 1 period)
cohort      n    retention   margin of variation   width
     1    181        0.878   [0.829, 0.923]        0.094
     2    185        0.854   [0.805, 0.903]        0.097
     3    184        0.891   [0.848, 0.935]        0.087
     4    174        0.856   [0.805, 0.908]        0.103
     5    155        0.852   [0.800, 0.903]        0.103
     6     21        0.857   [0.667, 1.000]        0.333
     7      0   unobservable
     8      0   unobservable
  rate range 0.040, narrowest margin 0.087, widest margin 0.333

number of retention rates from the same dataset: 2 and 6
range with the period definition 0.862 - 0.871, with the five-week definition 0.852 - 0.891

When the entry window narrows, the number of reportable rates rises from 2 to 6 and the range between the rates widens from 0.009 to 0.040. A person looking at a cohort matrix may read this widening as a pattern: the third block is 0.891, the fifth block 0.852, and the 4-point drop between them looks like a trend.

The column of margins closes this reading. The narrowest margin of variation is 0.087 wide; the whole of the 0.040 range between the six blocks fits inside the margin of a single cohort. The pairwise differences between the blocks also stay inside the margins. The thin cohort definition did not produce new information; it divided the same information into more numbers and enlarged the margin of each number.

The sixth block is the extreme example of this. The rate resting on twenty-one observable subscribers is 0.857 and its margin is [0.667, 1.000]; its width is 0.333, that is, more than three times the margin of the five cohorts. The seventh and eighth blocks stay entirely outside the observation window and produce no rate. As the cohort definition thins, the number of unobservable cohorts grows too.

The Aggregated Rate and the Rate of the Cohorts

What a cohort is good for shows when it is placed next to an aggregated rate. There are two reading methods in the dataset and the two steps of the funnel are these: entry to the cohort, reading in the next period.

# The reading method field is FICTIONAL too and carries two relationships together: remote
# reading has become widespread in the late periods and has fewer interruptions.
METHOD_BASE = {1: 0.28, 2: 0.55, 3: 0.80}
for x in JOINED:
    r = rng(SEED + 71000 + x["id"])
    p = METHOD_BASE[x["cohort"]] + (0.10 if x["n"] >= 2 else -0.10)
    x["method"] = "remote" if r() < p else "on-site"

METHODS = ("on-site", "remote")


def difference_interval(a, b, K):
    na, nb, drawn = len(a), len(b), []
    for _ in range(RESAMPLES):
        oa = sum(1 for _ in range(na) if retained(a[int(rs() * na)], K)) / na
        ob = sum(1 for _ in range(nb) if retained(b[int(rs() * nb)], K)) / nb
        drawn.append(ob - oa)
    drawn.sort()
    return drawn[24], drawn[974]


print("funnel: entry to the cohort -> reading in the next period")
print("\ncohort   on-site reading   remote reading   difference   margin of variation")
for coh in (1, 2, 3):
    group = [x for x in JOINED if x["cohort"] == coh]
    a = [x for x in group if x["method"] == "on-site"]
    b = [x for x in group if x["method"] == "remote"]
    if not observable(group[0], 1):
        print(f"{coh:>6}   {'--':>5} ({len(a):>3})       {'--':>5} ({len(b):>3})"
              f"              --   unobservable")
        continue
    oa = sum(1 for x in a if retained(x, 1)) / len(a)
    ob = sum(1 for x in b if retained(x, 1)) / len(b)
    d, u = difference_interval(a, b, 1)
    print(f"{coh:>6}   {oa:.3f} ({len(a):>3})       {ob:.3f} ({len(b):>3})"
          f"          {ob - oa:>+.3f}   [{d:+.3f}, {u:+.3f}]")
a = [x for x in JOINED if x["method"] == "on-site"]
b = [x for x in JOINED if x["method"] == "remote"]
oa = sum(1 for x in a if retained(x, 1)) / len(a)
ob = sum(1 for x in b if retained(x, 1)) / len(b)
d, u = difference_interval(a, b, 1)
print(f"{'total':>6}   {oa:.3f} ({len(a):>3})       {ob:.3f} ({len(b):>3})"
      f"          {ob - oa:>+.3f}   [{d:+.3f}, {u:+.3f}]")

print("\ncohort composition of the method (row share)")
for y in METHODS:
    group = [x for x in JOINED if x["method"] == y]
    print(f"  {y:<8} " + "  ".join(
        f"cohort {k}: {sum(1 for x in group if x['cohort'] == k) / len(group):.3f}"
        for k in (1, 2, 3)))
funnel: entry to the cohort -> reading in the next period

cohort   on-site reading   remote reading   difference   margin of variation
     1   0.843 (325)       0.926 (163)          +0.083   [+0.028, +0.139]
     2   0.844 (154)       0.872 (258)          +0.028   [-0.041, +0.099]
     3      -- ( 56)          -- (304)              --   unobservable
 total   0.755 (535)       0.519 (725)          -0.237   [-0.284, -0.184]

cohort composition of the method (row share)
  on-site  cohort 1: 0.607  cohort 2: 0.288  cohort 3: 0.105
  remote   cohort 1: 0.225  cohort 2: 0.356  cohort 3: 0.419

The aggregated row says a single sentence: retention is 0.237 points lower for remotely read meters and its margin is [-0.284, -0.184], away from zero. This number and this margin have been written in the form the rule of the course asks for, and they are wrong.

The cohort rows show the opposite. In the first cohort remote reading is 0.083 points ahead and its margin is [+0.028, +0.139], not containing zero. In the second cohort it is 0.028 points ahead and its margin contains zero. The third cohort cannot be observed. No cohort shows the direction the aggregated rate shows: two say the opposite, one says nothing.

This phenomenon is called the aggregation reversal, and its mechanism is in the last table. 60.7% of the on-site read meters are in the first cohort; 41.9% of the remotely read ones are in the third cohort. The third cohort is counted as zero because of the observation window, so the aggregated rate of remote reading comes mostly from subscribers who could not be measured. What has been measured is not the difference between the two methods but the cohort composition of the two methods.

An aggregated rate describes no cohort if there is both a rate difference and a composition difference between the cohorts. An aggregated rate reported without its composition written is not a measurement but a decision to take an average.

Summary

  • The rule applied to the unobservable cohort produces two rates from the same dataset: one-period retention is 0.619 with the zero-counting rule and 0.867 with the excluding rule; for two periods it is 0.251 and 0.648.
  • Outside the three-period observation window 843 of the 3,199 readings remain; the cohort definition cuts the dataset.
  • When the entry window goes down from a period to five weeks, the number of reportable rates rises from 2 to 6 and the rate range from 0.009 to 0.040; since the narrowest margin of variation is 0.087, the whole range fits inside the margin of a single cohort.
  • In the sixth block, which rests on twenty-one subscribers, the rate is 0.857, the margin [0.667, 1.000] and the width 0.333; the seventh and eighth blocks are entirely outside the observation window.
  • Aggregation reversal: while the aggregated difference is -0.237 and its margin is away from zero, the difference in the two observable cohorts is +0.083 and +0.028. 60.7% of those read on-site are in the first cohort, 41.9% of those read remotely are in the third cohort.

Next Step

In three lessons the margin was written next to the number and the three sources that enlarge the margin were counted: the number of combinations, the variable that is not in the model, the cohort definition. In all three the dataset was taken as given. The last lesson of the course removes this assumption: the dataset is made up of meters that could already be read and of subscribers who produced at least one reading, that is, what is measured is not the network itself but the visible part of the network. In the same lesson it is counted how many findings can be produced from noise, and this number is compared with the expected value.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close