[{"data":1,"prerenderedAt":1965},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data":1935,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data":1944,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data":1949,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data":1950},{"id":4,"title":5,"body":6,"dateModified":1901,"datePublished":1901,"description":1902,"extension":1903,"faq":1904,"keyword":1913,"kind":1914,"meta":1915,"navigation":95,"path":1924,"seo":1925,"slug":1928,"stem":1929,"topics":1930,"type":1933,"__hash__":1934},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data\u002Findex.md","Build a Cohort Retention Table from Excel Data",{"type":7,"value":8,"toc":1889},"minimark",[9,24,29,61,67,177,375,379,390,570,581,585,596,646,649,653,656,782,796,799,1106,1110,1117,1234,1248,1255,1405,1409,1412,1753,1765,1769,1814,1818,1824,1828,1834,1840,1846,1852,1856,1885],[10,11,12,13,17,18,23],"p",{},"\"Are customers sticking around?\" is one of the questions a monthly report is most often asked, and one of the worst answered by a single number. A retention rate averaged over everyone mixes customers who joined last month with customers who joined a year ago, and hides whether things are getting better. A ",[14,15,16],"em",{},"cohort table"," separates them: customers are grouped by the month they first bought, and each row follows one group over the months that follow. Read across a row to see how one cohort decays; read down a column to see whether newer cohorts hold on better than older ones. All it needs is an order export with a customer ID and a date, and a few pandas group-bys. This page is part of ",[19,20,22],"a",{"href":21},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002F","Creating Pivot Tables from Excel Data",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl xlsxwriter\n","bash","",[37,38,39],"code",{"__ignoreMap":35},[40,41,44,48,52,55,58],"span",{"class":42,"line":43},"line",1,[40,45,47],{"class":46},"sMTad","pip",[40,49,51],{"class":50},"srMev"," install",[40,53,54],{"class":50}," pandas",[40,56,57],{"class":50}," openpyxl",[40,59,60],{"class":50}," xlsxwriter\n",[10,62,63,66],{},[37,64,65],{},"orders_2026.xlsx"," holds 3,205 orders from 723 customers between January and August 2026: an order ID, a customer ID, the order date and an amount. It is the shape of export every shop, subscription or B2B billing system can produce.",[30,68,72],{"className":69,"code":70,"language":71,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\norders = pd.read_excel(\"orders_2026.xlsx\", parse_dates=[\"OrderDate\"])\nprint(orders.shape, orders[\"CustomerID\"].nunique(), \"customers\")\nprint(orders.head(3).to_string(index=False))\n","python",[37,73,74,90,97,130,152],{"__ignoreMap":35},[40,75,76,80,84,87],{"class":42,"line":43},[40,77,79],{"class":78},"s-kum","import",[40,81,83],{"class":82},"skGVy"," pandas ",[40,85,86],{"class":78},"as",[40,88,89],{"class":82}," pd\n",[40,91,93],{"class":42,"line":92},2,[40,94,96],{"emptyLinePlaceholder":95},true,"\n",[40,98,100,103,106,109,112,115,119,121,124,127],{"class":42,"line":99},3,[40,101,102],{"class":82},"orders ",[40,104,105],{"class":78},"=",[40,107,108],{"class":82}," pd.read_excel(",[40,110,111],{"class":50},"\"orders_2026.xlsx\"",[40,113,114],{"class":82},", ",[40,116,118],{"class":117},"sa561","parse_dates",[40,120,105],{"class":78},[40,122,123],{"class":82},"[",[40,125,126],{"class":50},"\"OrderDate\"",[40,128,129],{"class":82},"])\n",[40,131,133,137,140,143,146,149],{"class":42,"line":132},4,[40,134,136],{"class":135},"sP0c6","print",[40,138,139],{"class":82},"(orders.shape, orders[",[40,141,142],{"class":50},"\"CustomerID\"",[40,144,145],{"class":82},"].nunique(), ",[40,147,148],{"class":50},"\"customers\"",[40,150,151],{"class":82},")\n",[40,153,155,157,160,163,166,169,171,174],{"class":42,"line":154},5,[40,156,136],{"class":135},[40,158,159],{"class":82},"(orders.head(",[40,161,162],{"class":135},"3",[40,164,165],{"class":82},").to_string(",[40,167,168],{"class":117},"index",[40,170,105],{"class":78},[40,172,173],{"class":135},"False",[40,175,176],{"class":82},"))\n",[178,179,187,188,187,192,187,196,187,204,187,214,187,225,187,232,187,237,187,241,187,247,187,252,187,256,187,263,187,269,187,273,187,277,187,281,187,285,187,288,187,291,187,297,187,301,187,305,187,308,187,311,187,315,187,318,187,323,187,330,187,335,187,339,187,342,187,346,187,349,187,354,187,361,187,366,187,369,187,372],"svg",{"viewBox":180,"role":181,"ariaLabelledBy":182,"xmlns":185,"style":186},"4 0 752 160","img",[183,184],"co-flow-t","co-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[189,190,191],"title",{"id":183},"From orders to a retention table",[193,194,195],"desc",{"id":184},"Each order gets its month, each customer gets a cohort from their first order month, the months since the first order are counted, distinct customers are counted per cohort and month, and the counts are divided by the cohort size.",[197,198],"rect",{"x":199,"y":200,"width":201,"height":202,"fill":203},"4","0","752","160","#ffffff",[197,205],{"x":206,"y":207,"width":208,"height":209,"rx":210,"fill":211,"stroke":212,"style":213},"20.0","16.0","115.2","128.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[215,216,218],"g",{"transform":217},"translate(65.6,37.8) scale(1.000)",[219,220],"path",{"d":221,"fill":222,"stroke":223,"style":224},"M4 5h16v15H4z M4 10h16 M8 3v4 M16 3v4","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[226,227,231],"text",{"x":228,"y":229,"style":230},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Order month",[226,233,236],{"x":228,"y":234,"style":235},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","date →",[226,238,240],{"x":228,"y":239,"style":235},"121.2","2026-03",[42,242],{"x1":243,"y1":244,"x2":245,"y2":244,"stroke":246,"style":213},"140.2","80.0","162.2","var(--brand,#5b5cf0)",[248,249],"polygon",{"points":250,"fill":251},"166.2,80.0 157.2,85.0 157.2,75.0","#5b5cf0",[197,253],{"x":254,"y":207,"width":208,"height":209,"rx":210,"fill":255,"stroke":246,"style":213},"171.2","#f0f4ff",[215,257,259],{"transform":258},"translate(216.8,29.0) scale(1.000)",[219,260],{"d":261,"fill":222,"stroke":262,"style":224},"M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6 M16 4.5a3.3 3.3 0 0 1 0 6.3 M18 14.3c2.2.7 3.5 2.8 3.5 5.7","var(--brand-strong,#4338ca)",[226,264,268],{"x":265,"y":266,"style":267},"228.8","75.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Cohort",[226,270,272],{"x":265,"y":271,"style":235},"95.0","first order",[226,274,276],{"x":265,"y":275,"style":235},"112.5","month per",[226,278,280],{"x":265,"y":279,"style":235},"130.0","customer",[42,282],{"x1":283,"y1":244,"x2":284,"y2":244,"stroke":246,"style":213},"291.4","313.4",[248,286],{"points":287,"fill":251},"317.4,80.0 308.4,85.0 308.4,75.0",[197,289],{"x":290,"y":207,"width":208,"height":209,"rx":210,"fill":255,"stroke":246,"style":213},"322.4",[215,292,294],{"transform":293},"translate(368.0,29.0) scale(1.000)",[219,295],{"d":296,"fill":222,"stroke":262,"style":224},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2",[226,298,300],{"x":299,"y":266,"style":267},"380.0","Months",[226,302,304],{"x":299,"y":303,"style":267},"93.0","since",[226,306,307],{"x":299,"y":275,"style":235},"order month −",[226,309,310],{"x":299,"y":279,"style":235},"cohort",[42,312],{"x1":313,"y1":244,"x2":314,"y2":244,"stroke":246,"style":213},"442.6","464.6",[248,316],{"points":317,"fill":251},"468.6,80.0 459.6,85.0 459.6,75.0",[197,319],{"x":320,"y":207,"width":208,"height":209,"rx":210,"fill":321,"stroke":322,"style":213},"473.6","#d9f4f1","var(--teal,#0f9488)",[215,324,326],{"transform":325},"translate(519.2,37.8) scale(1.000)",[219,327],{"d":328,"fill":222,"stroke":329,"style":224},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","var(--teal-ink,#0b6157)",[226,331,334],{"x":332,"y":229,"style":333},"531.2","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Count",[226,336,338],{"x":332,"y":337,"style":333},"101.8","customers",[226,340,341],{"x":332,"y":239,"style":235},"distinct per cell",[42,343],{"x1":344,"y1":244,"x2":345,"y2":244,"stroke":246,"style":213},"593.8","615.8",[248,347],{"points":348,"fill":251},"619.8,80.0 610.8,85.0 610.8,75.0",[197,350],{"x":351,"y":207,"width":208,"height":209,"rx":210,"fill":352,"stroke":353,"style":213},"624.8","#fdefd8","var(--gold,#b4740a)",[215,355,357],{"transform":356},"translate(670.4,29.0) scale(1.000)",[219,358],{"d":359,"fill":222,"stroke":360,"style":224},"M4 20h16 M7 17v-5 M12 17V7 M17 17v-8","var(--gold-ink,#7a4e06)",[226,362,365],{"x":363,"y":266,"style":364},"682.4","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Divide by",[226,367,368],{"x":363,"y":303,"style":364},"size",[226,370,371],{"x":363,"y":275,"style":235},"month 0 =",[226,373,374],{"x":363,"y":279,"style":235},"100%",[25,376,378],{"id":377},"step-1-give-every-order-a-cohort-and-an-age","Step 1 — Give every order a cohort and an age",[10,380,381,382,385,386,389],{},"A customer's cohort is the month of their first order. Compute it with ",[37,383,384],{},"groupby().transform(\"min\")",", which returns the per-customer minimum on every one of that customer's rows. The ",[14,387,388],{},"age"," of an order is how many whole months after the cohort month it happened:",[30,391,393],{"className":69,"code":392,"language":71,"meta":35,"style":35},"orders[\"OrderMonth\"] = orders[\"OrderDate\"].dt.to_period(\"M\")\norders[\"Cohort\"] = orders.groupby(\"CustomerID\")[\"OrderMonth\"].transform(\"min\")\n\ndef month_index(p):\n    return p.dt.year * 12 + p.dt.month\n\norders[\"Age\"] = month_index(orders[\"OrderMonth\"]) - month_index(orders[\"Cohort\"])\nprint(orders[[\"CustomerID\", \"OrderDate\", \"Cohort\", \"Age\"]].head(4).to_string(index=False))\nprint(orders[\"Age\"].value_counts().sort_index().to_dict())\n",[37,394,395,421,450,454,466,486,491,520,557],{"__ignoreMap":35},[40,396,397,400,403,406,408,411,413,416,419],{"class":42,"line":43},[40,398,399],{"class":82},"orders[",[40,401,402],{"class":50},"\"OrderMonth\"",[40,404,405],{"class":82},"] ",[40,407,105],{"class":78},[40,409,410],{"class":82}," orders[",[40,412,126],{"class":50},[40,414,415],{"class":82},"].dt.to_period(",[40,417,418],{"class":50},"\"M\"",[40,420,151],{"class":82},[40,422,423,425,428,430,432,435,437,440,442,445,448],{"class":42,"line":92},[40,424,399],{"class":82},[40,426,427],{"class":50},"\"Cohort\"",[40,429,405],{"class":82},[40,431,105],{"class":78},[40,433,434],{"class":82}," orders.groupby(",[40,436,142],{"class":50},[40,438,439],{"class":82},")[",[40,441,402],{"class":50},[40,443,444],{"class":82},"].transform(",[40,446,447],{"class":50},"\"min\"",[40,449,151],{"class":82},[40,451,452],{"class":42,"line":99},[40,453,96],{"emptyLinePlaceholder":95},[40,455,456,459,463],{"class":42,"line":132},[40,457,458],{"class":78},"def",[40,460,462],{"class":461},"s_Opv"," month_index",[40,464,465],{"class":82},"(p):\n",[40,467,468,471,474,477,480,483],{"class":42,"line":154},[40,469,470],{"class":78},"    return",[40,472,473],{"class":82}," p.dt.year ",[40,475,476],{"class":78},"*",[40,478,479],{"class":135}," 12",[40,481,482],{"class":78}," +",[40,484,485],{"class":82}," p.dt.month\n",[40,487,489],{"class":42,"line":488},6,[40,490,96],{"emptyLinePlaceholder":95},[40,492,494,496,499,501,503,506,508,511,514,516,518],{"class":42,"line":493},7,[40,495,399],{"class":82},[40,497,498],{"class":50},"\"Age\"",[40,500,405],{"class":82},[40,502,105],{"class":78},[40,504,505],{"class":82}," month_index(orders[",[40,507,402],{"class":50},[40,509,510],{"class":82},"]) ",[40,512,513],{"class":78},"-",[40,515,505],{"class":82},[40,517,427],{"class":50},[40,519,129],{"class":82},[40,521,523,525,528,530,532,534,536,538,540,542,545,547,549,551,553,555],{"class":42,"line":522},8,[40,524,136],{"class":135},[40,526,527],{"class":82},"(orders[[",[40,529,142],{"class":50},[40,531,114],{"class":82},[40,533,126],{"class":50},[40,535,114],{"class":82},[40,537,427],{"class":50},[40,539,114],{"class":82},[40,541,498],{"class":50},[40,543,544],{"class":82},"]].head(",[40,546,199],{"class":135},[40,548,165],{"class":82},[40,550,168],{"class":117},[40,552,105],{"class":78},[40,554,173],{"class":135},[40,556,176],{"class":82},[40,558,560,562,565,567],{"class":42,"line":559},9,[40,561,136],{"class":135},[40,563,564],{"class":82},"(orders[",[40,566,498],{"class":50},[40,568,569],{"class":82},"].value_counts().sort_index().to_dict())\n",[10,571,572,573,576,577,580],{},"Converting both periods to a running month number — year × 12 + month — and subtracting gives the age as an integer column in one vectorised step. Subtracting the periods directly also works, but returns offset objects that need a slow ",[37,574,575],{},".apply"," to turn into numbers. Calendar months are the usual unit; for a product with a weekly rhythm, use ",[37,578,579],{},"to_period(\"W\")"," and weeks instead.",[25,582,584],{"id":583},"step-2-count-active-customers-per-cohort-and-age","Step 2 — Count active customers per cohort and age",[10,586,587,588,591,592,595],{},"The core of the table is one group-by: for each cohort and age, how many ",[14,589,590],{},"distinct"," customers ordered? ",[37,593,594],{},"nunique"," matters — a customer who ordered twice in a month counts once:",[30,597,599],{"className":69,"code":598,"language":71,"meta":35,"style":35},"counts = (orders.groupby([\"Cohort\", \"Age\"])[\"CustomerID\"].nunique()\n                .unstack(fill_value=0))\nprint(counts)\n",[37,600,601,625,639],{"__ignoreMap":35},[40,602,603,606,608,611,613,615,617,620,622],{"class":42,"line":43},[40,604,605],{"class":82},"counts ",[40,607,105],{"class":78},[40,609,610],{"class":82}," (orders.groupby([",[40,612,427],{"class":50},[40,614,114],{"class":82},[40,616,498],{"class":50},[40,618,619],{"class":82},"])[",[40,621,142],{"class":50},[40,623,624],{"class":82},"].nunique()\n",[40,626,627,630,633,635,637],{"class":42,"line":92},[40,628,629],{"class":82},"                .unstack(",[40,631,632],{"class":117},"fill_value",[40,634,105],{"class":78},[40,636,200],{"class":135},[40,638,176],{"class":82},[40,640,641,643],{"class":42,"line":99},[40,642,136],{"class":135},[40,644,645],{"class":82},"(counts)\n",[10,647,648],{},"Each row is a cohort, each column a month of age, and column 0 is the cohort's size, since everyone orders in their first month by definition. The table is a triangle: the January cohort has eight months of history, the June cohort only three, because the data stops at the end of August. Those missing cells are not zeros — they are months that have not happened yet — so the next step keeps them empty rather than filling them.",[25,650,652],{"id":651},"step-3-turn-counts-into-retention-rates","Step 3 — Turn counts into retention rates",[10,654,655],{},"Divide every row by its cohort size, and blank out the future cells:",[30,657,659],{"className":69,"code":658,"language":71,"meta":35,"style":35},"last = orders[\"OrderMonth\"].max()\nsizes = counts[0]\nretention = counts.div(sizes, axis=0)\nfor cohort in retention.index:\n    observed = (last - cohort).n\n    retention.loc[cohort, retention.columns > observed] = float(\"nan\")\nprint((retention * 100).round(1))\n",[37,660,661,675,690,709,723,738,762],{"__ignoreMap":35},[40,662,663,666,668,670,672],{"class":42,"line":43},[40,664,665],{"class":82},"last ",[40,667,105],{"class":78},[40,669,410],{"class":82},[40,671,402],{"class":50},[40,673,674],{"class":82},"].max()\n",[40,676,677,680,682,685,687],{"class":42,"line":92},[40,678,679],{"class":82},"sizes ",[40,681,105],{"class":78},[40,683,684],{"class":82}," counts[",[40,686,200],{"class":135},[40,688,689],{"class":82},"]\n",[40,691,692,695,697,700,703,705,707],{"class":42,"line":99},[40,693,694],{"class":82},"retention ",[40,696,105],{"class":78},[40,698,699],{"class":82}," counts.div(sizes, ",[40,701,702],{"class":117},"axis",[40,704,105],{"class":78},[40,706,200],{"class":135},[40,708,151],{"class":82},[40,710,711,714,717,720],{"class":42,"line":132},[40,712,713],{"class":78},"for",[40,715,716],{"class":82}," cohort ",[40,718,719],{"class":78},"in",[40,721,722],{"class":82}," retention.index:\n",[40,724,725,728,730,733,735],{"class":42,"line":154},[40,726,727],{"class":82},"    observed ",[40,729,105],{"class":78},[40,731,732],{"class":82}," (last ",[40,734,513],{"class":78},[40,736,737],{"class":82}," cohort).n\n",[40,739,740,743,746,749,751,754,757,760],{"class":42,"line":488},[40,741,742],{"class":82},"    retention.loc[cohort, retention.columns ",[40,744,745],{"class":78},">",[40,747,748],{"class":82}," observed] ",[40,750,105],{"class":78},[40,752,753],{"class":135}," float",[40,755,756],{"class":82},"(",[40,758,759],{"class":50},"\"nan\"",[40,761,151],{"class":82},[40,763,764,766,769,771,774,777,780],{"class":42,"line":493},[40,765,136],{"class":135},[40,767,768],{"class":82},"((retention ",[40,770,476],{"class":78},[40,772,773],{"class":135}," 100",[40,775,776],{"class":82},").round(",[40,778,779],{"class":135},"1",[40,781,176],{"class":82},[10,783,784,787,788,791,792,795],{},[37,785,786],{},"div(..., axis=0)"," divides each row by the matching cohort size. ",[37,789,790],{},"unstack(fill_value=0)"," in the previous step wrote zeros into the future cells; resetting everything beyond each cohort's last observed month to ",[37,793,794],{},"NaN"," prevents a later average from treating \"has not happened yet\" as \"nobody came back\".",[10,797,798],{},"Reading the result: the January cohort keeps 56% of its customers into the second month and 23% by month seven. The April, May and June cohorts keep 65–67% into month one, against 50–61% for the first three cohorts — the kind of change a single blended retention number would hide, and exactly what the table is for.",[178,800,187,806,187,809,187,812,187,817,187,823,187,828,187,833,187,837,187,841,187,845,187,849,187,853,187,857,187,861,187,865,187,869,187,872,187,876,187,880,187,884,187,887,187,891,187,893,187,897,187,899,187,901,187,903,187,906,187,908,187,911,187,913,187,916,187,918,187,921,187,923,187,926,187,929,187,932,187,934,187,937,187,939,187,941,187,943,187,946,187,948,187,951,187,953,187,955,187,957,187,960,187,962,187,965,187,968,187,971,187,973,187,975,187,977,187,979,187,981,187,984,187,986,187,989,187,991,187,994,187,996,187,999,187,1001,187,1004,187,1006,187,1010,187,1012,187,1015,187,1017,187,1019,187,1021,187,1025,187,1027,187,1030,187,1032,187,1034,187,1036,187,1038,187,1040,187,1043,187,1047,187,1049,187,1052,187,1054,187,1056,187,1058,187,1061,187,1063,187,1066,187,1068,187,1071,187,1073,187,1075,187,1078,187,1082,187,1084,187,1087,187,1089,187,1091,187,1093,187,1095,187,1097,187,1100,187,1102,187,1104],{"viewBox":801,"role":181,"ariaLabelledBy":802,"xmlns":185,"style":805},"155 0 450 228",[803,804],"co-triangle-t","co-triangle-d","width:100%;max-width:450px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[189,807,808],{"id":803},"Retention by cohort — the triangle",[193,810,811],{"id":804},"A cohort table with cohorts January to June down the side and months since first order across the top. Values fall from 100 percent along each row; the lower-right part is empty because those months have not happened yet. Month-one retention is higher for the April to June cohorts.",[197,813],{"x":814,"y":200,"width":815,"height":816,"fill":203},"155","450","228",[197,818],{"x":819,"y":207,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},"171.1","32.0","28.0","stroke-width:1px",[226,824,779],{"x":825,"y":826,"style":827},"187.1","35.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle",[197,829],{"x":830,"y":207,"width":831,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"203.1","74.8","stroke-width:1.5px",[226,834,268],{"x":835,"y":826,"style":836},"240.6","font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[197,838],{"x":839,"y":207,"width":840,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"278.0","56.3",[226,842,844],{"x":843,"y":826,"style":836},"306.1","M0",[197,846],{"x":847,"y":207,"width":848,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"334.3","50.7",[226,850,852],{"x":851,"y":826,"style":836},"359.6","M1",[197,854],{"x":855,"y":207,"width":856,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"385.0","51.0",[226,858,860],{"x":859,"y":826,"style":836},"410.5","M2",[197,862],{"x":863,"y":207,"width":864,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"436.0","51.4",[226,866,868],{"x":867,"y":826,"style":836},"461.7","M3",[197,870],{"x":871,"y":207,"width":856,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"487.3",[226,873,875],{"x":874,"y":826,"style":836},"512.8","M4",[197,877],{"x":878,"y":207,"width":879,"height":821,"rx":200,"fill":255,"stroke":246,"style":832},"538.4","50.5",[226,881,883],{"x":882,"y":826,"style":836},"563.6","M5",[197,885],{"x":819,"y":886,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},"44.0",[226,888,890],{"x":825,"y":889,"style":827},"63.0","2",[197,892],{"x":830,"y":886,"width":831,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,894,896],{"x":835,"y":889,"style":895},"font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","2026-01",[197,898],{"x":839,"y":886,"width":840,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,900,374],{"x":843,"y":889,"style":895},[197,902],{"x":847,"y":886,"width":848,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,904,905],{"x":851,"y":889,"style":895},"56%",[197,907],{"x":855,"y":886,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,909,910],{"x":859,"y":889,"style":895},"46%",[197,912],{"x":863,"y":886,"width":864,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,914,915],{"x":867,"y":889,"style":895},"44%",[197,917],{"x":871,"y":886,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,919,920],{"x":874,"y":889,"style":895},"37%",[197,922],{"x":878,"y":886,"width":879,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,924,925],{"x":882,"y":889,"style":895},"29%",[197,927],{"x":819,"y":928,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},"72.0",[226,930,162],{"x":825,"y":931,"style":827},"91.0",[197,933],{"x":830,"y":928,"width":831,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,935,936],{"x":835,"y":931,"style":895},"2026-02",[197,938],{"x":839,"y":928,"width":840,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,940,374],{"x":843,"y":931,"style":895},[197,942],{"x":847,"y":928,"width":848,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,944,945],{"x":851,"y":931,"style":895},"50%",[197,947],{"x":855,"y":928,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,949,950],{"x":859,"y":931,"style":895},"43%",[197,952],{"x":863,"y":928,"width":864,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,954,920],{"x":867,"y":931,"style":895},[197,956],{"x":871,"y":928,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,958,959],{"x":874,"y":931,"style":895},"32%",[197,961],{"x":878,"y":928,"width":879,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,963,964],{"x":882,"y":931,"style":895},"26%",[197,966],{"x":819,"y":967,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},"100.0",[226,969,199],{"x":825,"y":970,"style":827},"119.0",[197,972],{"x":830,"y":967,"width":831,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,974,240],{"x":835,"y":970,"style":895},[197,976],{"x":839,"y":967,"width":840,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,978,374],{"x":843,"y":970,"style":895},[197,980],{"x":847,"y":967,"width":848,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,982,983],{"x":851,"y":970,"style":895},"61%",[197,985],{"x":855,"y":967,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,987,988],{"x":859,"y":970,"style":895},"53%",[197,990],{"x":863,"y":967,"width":864,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,992,993],{"x":867,"y":970,"style":895},"42%",[197,995],{"x":871,"y":967,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,997,998],{"x":874,"y":970,"style":895},"33%",[197,1000],{"x":878,"y":967,"width":879,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1002,1003],{"x":882,"y":970,"style":895},"31%",[197,1005],{"x":819,"y":209,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},[226,1007,1009],{"x":825,"y":1008,"style":827},"147.0","5",[197,1011],{"x":830,"y":209,"width":831,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1013,1014],{"x":835,"y":1008,"style":895},"2026-04",[197,1016],{"x":839,"y":209,"width":840,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1018,374],{"x":843,"y":1008,"style":895},[197,1020],{"x":847,"y":209,"width":848,"height":821,"rx":200,"fill":321,"stroke":322,"style":832},[226,1022,1024],{"x":851,"y":1008,"style":1023},"font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","67%",[197,1026],{"x":855,"y":209,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1028,1029],{"x":859,"y":1008,"style":895},"59%",[197,1031],{"x":863,"y":209,"width":864,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1033,910],{"x":867,"y":1008,"style":895},[197,1035],{"x":871,"y":209,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1037,950],{"x":874,"y":1008,"style":895},[197,1039],{"x":878,"y":209,"width":879,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[197,1041],{"x":819,"y":1042,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},"156.0",[226,1044,1046],{"x":825,"y":1045,"style":827},"175.0","6",[197,1048],{"x":830,"y":1042,"width":831,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1050,1051],{"x":835,"y":1045,"style":895},"2026-05",[197,1053],{"x":839,"y":1042,"width":840,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1055,374],{"x":843,"y":1045,"style":895},[197,1057],{"x":847,"y":1042,"width":848,"height":821,"rx":200,"fill":321,"stroke":322,"style":832},[226,1059,1060],{"x":851,"y":1045,"style":1023},"65%",[197,1062],{"x":855,"y":1042,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1064,1065],{"x":859,"y":1045,"style":895},"51%",[197,1067],{"x":863,"y":1042,"width":864,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1069,1070],{"x":867,"y":1045,"style":895},"41%",[197,1072],{"x":871,"y":1042,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[197,1074],{"x":878,"y":1042,"width":879,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[197,1076],{"x":819,"y":1077,"width":820,"height":821,"rx":200,"fill":211,"stroke":212,"style":822},"184.0",[226,1079,1081],{"x":825,"y":1080,"style":827},"203.0","7",[197,1083],{"x":830,"y":1077,"width":831,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1085,1086],{"x":835,"y":1080,"style":895},"2026-06",[197,1088],{"x":839,"y":1077,"width":840,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1090,374],{"x":843,"y":1080,"style":895},[197,1092],{"x":847,"y":1077,"width":848,"height":821,"rx":200,"fill":321,"stroke":322,"style":832},[226,1094,1024],{"x":851,"y":1080,"style":1023},[197,1096],{"x":855,"y":1077,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[226,1098,1099],{"x":859,"y":1080,"style":895},"57%",[197,1101],{"x":863,"y":1077,"width":864,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[197,1103],{"x":871,"y":1077,"width":856,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[197,1105],{"x":878,"y":1077,"width":879,"height":821,"rx":200,"fill":203,"stroke":212,"style":822},[25,1107,1109],{"id":1108},"step-4-an-average-retention-curve-weighted-properly","Step 4 — An average retention curve, weighted properly",[10,1111,1112,1113,1116],{},"Management usually wants one curve: \"what share of a typical cohort is still buying after ",[14,1114,1115],{},"n"," months?\" Averaging the percentages in each column treats a cohort of 97 customers the same as one of 139. Weight by size instead — sum the active customers and divide by the sum of the sizes of the cohorts that have reached that age:",[30,1118,1120],{"className":69,"code":1119,"language":71,"meta":35,"style":35},"observed = retention.notna()\nactive = counts.where(observed).sum()               # customers active at each age\neligible = observed.mul(sizes, axis=0).sum()        # customers in cohorts old enough\ncurve = pd.DataFrame({\"weighted %\": (active \u002F eligible * 100).round(1),\n                      \"simple mean %\": (retention.mean() * 100).round(1),\n                      \"cohorts\": observed.sum()})\nprint(curve)\n",[37,1121,1122,1132,1146,1168,1201,1219,1227],{"__ignoreMap":35},[40,1123,1124,1127,1129],{"class":42,"line":43},[40,1125,1126],{"class":82},"observed ",[40,1128,105],{"class":78},[40,1130,1131],{"class":82}," retention.notna()\n",[40,1133,1134,1137,1139,1142],{"class":42,"line":92},[40,1135,1136],{"class":82},"active ",[40,1138,105],{"class":78},[40,1140,1141],{"class":82}," counts.where(observed).sum()               ",[40,1143,1145],{"class":1144},"s-wDw","# customers active at each age\n",[40,1147,1148,1151,1153,1156,1158,1160,1162,1165],{"class":42,"line":99},[40,1149,1150],{"class":82},"eligible ",[40,1152,105],{"class":78},[40,1154,1155],{"class":82}," observed.mul(sizes, ",[40,1157,702],{"class":117},[40,1159,105],{"class":78},[40,1161,200],{"class":135},[40,1163,1164],{"class":82},").sum()        ",[40,1166,1167],{"class":1144},"# customers in cohorts old enough\n",[40,1169,1170,1173,1175,1178,1181,1184,1187,1190,1192,1194,1196,1198],{"class":42,"line":132},[40,1171,1172],{"class":82},"curve ",[40,1174,105],{"class":78},[40,1176,1177],{"class":82}," pd.DataFrame({",[40,1179,1180],{"class":50},"\"weighted %\"",[40,1182,1183],{"class":82},": (active ",[40,1185,1186],{"class":78},"\u002F",[40,1188,1189],{"class":82}," eligible ",[40,1191,476],{"class":78},[40,1193,773],{"class":135},[40,1195,776],{"class":82},[40,1197,779],{"class":135},[40,1199,1200],{"class":82},"),\n",[40,1202,1203,1206,1209,1211,1213,1215,1217],{"class":42,"line":154},[40,1204,1205],{"class":50},"                      \"simple mean %\"",[40,1207,1208],{"class":82},": (retention.mean() ",[40,1210,476],{"class":78},[40,1212,773],{"class":135},[40,1214,776],{"class":82},[40,1216,779],{"class":135},[40,1218,1200],{"class":82},[40,1220,1221,1224],{"class":42,"line":488},[40,1222,1223],{"class":50},"                      \"cohorts\"",[40,1225,1226],{"class":82},": observed.sum()})\n",[40,1228,1229,1231],{"class":42,"line":493},[40,1230,136],{"class":135},[40,1232,1233],{"class":82},"(curve)\n",[10,1235,1236,1239,1240,1243,1244,1247],{},[37,1237,1238],{},"observed"," marks the cells that exist. ",[37,1241,1242],{},"active"," adds up the customers still ordering at each age across the cohorts that have reached it, and ",[37,1245,1246],{},"eligible"," adds up the sizes of those same cohorts; dividing one by the other gives the share of all eligible customers who were active. The simple mean of the percentages is shown alongside for comparison: where large and small cohorts retain differently, the two disagree, and the weighted figure is the one that answers \"what share of customers\". In this data they are within half a point of each other, because the cohorts are of similar size — 97 to 139 customers. The gap opens up when a campaign month brings in a cohort several times the usual size: its behaviour should dominate the average in proportion to its size, and only the weighted version does that. Calculating both and comparing them is a cheap sanity check.",[10,1249,1250,1251,1254],{},"The later months rest on fewer cohorts — month seven is the January cohort alone — so the tail of the curve is less certain than its start. Showing the number of cohorts behind each point, as the ",[37,1252,1253],{},"cohorts"," column does, keeps readers from over-reading it.",[178,1256,187,1262,187,1265,187,1268,187,1272,187,1277,187,1282,187,1286,187,1290,187,1293,187,1297,187,1300,187,1304,187,1307,187,1310,187,1313,187,1317,187,1320,187,1323,187,1326,187,1329,187,1332,187,1336,187,1340,187,1344,187,1348,187,1351,187,1354,187,1357,187,1360,187,1363,187,1366,187,1369,187,1373,187,1377,187,1380,187,1384,187,1388,187,1392,187,1396,187,1400],{"viewBox":1257,"role":181,"ariaLabelledBy":1258,"xmlns":185,"style":1261},"4 0 748 258",[1259,1260],"co-curve-t","co-curve-d","width:100%;max-width:748px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[189,1263,1264],{"id":1259},"Weighted average retention curve",[193,1266,1267],{"id":1260},"Line chart of the weighted share of customers still ordering by months since first order. 100 percent in month 0, about 61 percent in month 1, about 42 percent in month 3, falling to about 23 percent by month 7.",[197,1269],{"x":199,"y":200,"width":1270,"height":1271,"fill":203},"748","258",[42,1273],{"x1":1274,"y1":1275,"x2":1276,"y2":1275,"stroke":212,"style":822},"51.3","194.0","736.0",[226,1278,200],{"x":1279,"y":1280,"style":1281},"29.6","198.0","font-size:11px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle",[42,1283],{"x1":1274,"y1":1284,"x2":1276,"y2":1284,"stroke":212,"style":1285},"151.5","stroke-width:1px;stroke-dasharray:5 4",[226,1287,1289],{"x":1279,"y":1288,"style":1281},"155.5","25",[42,1291],{"x1":1274,"y1":1292,"x2":1276,"y2":1292,"stroke":212,"style":1285},"109.0",[226,1294,1296],{"x":1279,"y":1295,"style":1281},"113.0","50",[42,1298],{"x1":1274,"y1":1299,"x2":1276,"y2":1299,"stroke":212,"style":1285},"66.5",[226,1301,1303],{"x":1279,"y":1302,"style":1281},"70.5","75",[42,1305],{"x1":1274,"y1":1306,"x2":1276,"y2":1306,"stroke":212,"style":1285},"24.0",[226,1308,1309],{"x":1279,"y":821,"style":1281},"100",[42,1311],{"x1":1274,"y1":1275,"x2":1276,"y2":1275,"stroke":1312,"style":832},"var(--muted,#5b6780)",[226,1314,844],{"x":1315,"y":1316,"style":235},"94.1","212.0",[226,1318,852],{"x":1319,"y":1316,"style":235},"179.7",[226,1321,860],{"x":1322,"y":1316,"style":235},"265.3",[226,1324,868],{"x":1325,"y":1316,"style":235},"350.8",[226,1327,875],{"x":1328,"y":1316,"style":235},"436.4",[226,1330,883],{"x":1331,"y":1316,"style":235},"522.0",[226,1333,1335],{"x":1334,"y":1316,"style":235},"607.6","M6",[226,1337,1339],{"x":1338,"y":1316,"style":235},"693.2","M7",[219,1341],{"d":1342,"fill":222,"stroke":246,"style":1343},"M94.1,24.0 L179.7,90.1 L265.3,106.8 L350.8,123.1 L436.4,132.6 L522.0,146.1 L607.6,154.9 L693.2,155.1","stroke-width:2.5px;stroke-linejoin:round;stroke-linecap:round",[1345,1346],"circle",{"cx":1315,"cy":1306,"r":1347,"fill":203,"stroke":246,"style":213},"4.0",[1345,1349],{"cx":1319,"cy":1350,"r":1347,"fill":203,"stroke":246,"style":213},"90.1",[1345,1352],{"cx":1322,"cy":1353,"r":1347,"fill":203,"stroke":246,"style":213},"106.8",[1345,1355],{"cx":1325,"cy":1356,"r":1347,"fill":203,"stroke":246,"style":213},"123.1",[1345,1358],{"cx":1328,"cy":1359,"r":1347,"fill":203,"stroke":246,"style":213},"132.6",[1345,1361],{"cx":1331,"cy":1362,"r":1347,"fill":203,"stroke":246,"style":213},"146.1",[1345,1364],{"cx":1334,"cy":1365,"r":1347,"fill":203,"stroke":246,"style":213},"154.9",[1345,1367],{"cx":1338,"cy":1368,"r":1347,"fill":203,"stroke":246,"style":213},"155.1",[226,1370,1309],{"x":1315,"y":1371,"style":1372},"14.0","font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[226,1374,1376],{"x":1319,"y":1375,"style":1372},"80.1","61.1",[226,1378,1274],{"x":1322,"y":1379,"style":1372},"96.8",[226,1381,1383],{"x":1325,"y":1382,"style":1372},"113.1","41.7",[226,1385,1387],{"x":1328,"y":1386,"style":1372},"122.6","36.1",[226,1389,1391],{"x":1331,"y":1390,"style":1372},"136.1","28.2",[226,1393,1395],{"x":1334,"y":1394,"style":1372},"144.9","23",[226,1397,1399],{"x":1338,"y":1398,"style":1372},"145.1","22.9",[226,1401,1404],{"x":299,"y":1402,"style":1403},"238.0","font-size:12.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","Months 6 and 7 rest on only one or two cohorts.",[25,1406,1408],{"id":1407},"step-5-write-the-table-to-excel-as-a-heatmap","Step 5 — Write the table to Excel as a heatmap",[10,1410,1411],{},"Cohort tables are read by colour first. Write the percentages with a percent format and a colour scale, so the decay along each row and the improvement down each column are visible at a glance:",[30,1413,1415],{"className":69,"code":1414,"language":71,"meta":35,"style":35},"with pd.ExcelWriter(\"cohort_retention.xlsx\", engine=\"xlsxwriter\") as xw:\n    out = retention.copy()\n    out.index = out.index.astype(str)\n    out.insert(0, \"Customers\", sizes.values)\n    out.columns = [\"Customers\"] + [f\"M{c}\" for c in retention.columns]\n    out.to_excel(xw, sheet_name=\"Retention\", index_label=\"Cohort\")\n    wb, ws = xw.book, xw.sheets[\"Retention\"]\n    pct = wb.add_format({\"num_format\": \"0%\"})\n    ws.set_column(2, len(out.columns), 7, pct)\n    ws.conditional_format(1, 3, len(out), len(out.columns),\n                          {\"type\": \"2_color_scale\", \"min_color\": \"#FFFFFF\", \"max_color\": \"#4338CA\"})\n    counts.where(observed).rename(columns=lambda c: f\"M{c}\").rename(index=str).to_excel(xw, sheet_name=\"Counts\")\nprint(pd.read_excel(\"cohort_retention.xlsx\", sheet_name=\"Retention\").iloc[:2, :5])\n",[37,1416,1417,1446,1456,1471,1486,1535,1559,1573,1595,1615,1639,1675,1723],{"__ignoreMap":35},[40,1418,1419,1422,1425,1428,1430,1433,1435,1438,1441,1443],{"class":42,"line":43},[40,1420,1421],{"class":78},"with",[40,1423,1424],{"class":82}," pd.ExcelWriter(",[40,1426,1427],{"class":50},"\"cohort_retention.xlsx\"",[40,1429,114],{"class":82},[40,1431,1432],{"class":117},"engine",[40,1434,105],{"class":78},[40,1436,1437],{"class":50},"\"xlsxwriter\"",[40,1439,1440],{"class":82},") ",[40,1442,86],{"class":78},[40,1444,1445],{"class":82}," xw:\n",[40,1447,1448,1451,1453],{"class":42,"line":92},[40,1449,1450],{"class":82},"    out ",[40,1452,105],{"class":78},[40,1454,1455],{"class":82}," retention.copy()\n",[40,1457,1458,1461,1463,1466,1469],{"class":42,"line":99},[40,1459,1460],{"class":82},"    out.index ",[40,1462,105],{"class":78},[40,1464,1465],{"class":82}," out.index.astype(",[40,1467,1468],{"class":135},"str",[40,1470,151],{"class":82},[40,1472,1473,1476,1478,1480,1483],{"class":42,"line":132},[40,1474,1475],{"class":82},"    out.insert(",[40,1477,200],{"class":135},[40,1479,114],{"class":82},[40,1481,1482],{"class":50},"\"Customers\"",[40,1484,1485],{"class":82},", sizes.values)\n",[40,1487,1488,1491,1493,1496,1498,1500,1503,1505,1508,1511,1515,1518,1521,1524,1527,1530,1532],{"class":42,"line":154},[40,1489,1490],{"class":82},"    out.columns ",[40,1492,105],{"class":78},[40,1494,1495],{"class":82}," [",[40,1497,1482],{"class":50},[40,1499,405],{"class":82},[40,1501,1502],{"class":78},"+",[40,1504,1495],{"class":82},[40,1506,1507],{"class":78},"f",[40,1509,1510],{"class":50},"\"M",[40,1512,1514],{"class":1513},"sSjpA","{",[40,1516,1517],{"class":82},"c",[40,1519,1520],{"class":1513},"}",[40,1522,1523],{"class":50},"\"",[40,1525,1526],{"class":78}," for",[40,1528,1529],{"class":82}," c ",[40,1531,719],{"class":78},[40,1533,1534],{"class":82}," retention.columns]\n",[40,1536,1537,1540,1543,1545,1548,1550,1553,1555,1557],{"class":42,"line":488},[40,1538,1539],{"class":82},"    out.to_excel(xw, ",[40,1541,1542],{"class":117},"sheet_name",[40,1544,105],{"class":78},[40,1546,1547],{"class":50},"\"Retention\"",[40,1549,114],{"class":82},[40,1551,1552],{"class":117},"index_label",[40,1554,105],{"class":78},[40,1556,427],{"class":50},[40,1558,151],{"class":82},[40,1560,1561,1564,1566,1569,1571],{"class":42,"line":493},[40,1562,1563],{"class":82},"    wb, ws ",[40,1565,105],{"class":78},[40,1567,1568],{"class":82}," xw.book, xw.sheets[",[40,1570,1547],{"class":50},[40,1572,689],{"class":82},[40,1574,1575,1578,1580,1583,1586,1589,1592],{"class":42,"line":522},[40,1576,1577],{"class":82},"    pct ",[40,1579,105],{"class":78},[40,1581,1582],{"class":82}," wb.add_format({",[40,1584,1585],{"class":50},"\"num_format\"",[40,1587,1588],{"class":82},": ",[40,1590,1591],{"class":50},"\"0%\"",[40,1593,1594],{"class":82},"})\n",[40,1596,1597,1600,1602,1604,1607,1610,1612],{"class":42,"line":559},[40,1598,1599],{"class":82},"    ws.set_column(",[40,1601,890],{"class":135},[40,1603,114],{"class":82},[40,1605,1606],{"class":135},"len",[40,1608,1609],{"class":82},"(out.columns), ",[40,1611,1081],{"class":135},[40,1613,1614],{"class":82},", pct)\n",[40,1616,1618,1621,1623,1625,1627,1629,1631,1634,1636],{"class":42,"line":1617},10,[40,1619,1620],{"class":82},"    ws.conditional_format(",[40,1622,779],{"class":135},[40,1624,114],{"class":82},[40,1626,162],{"class":135},[40,1628,114],{"class":82},[40,1630,1606],{"class":135},[40,1632,1633],{"class":82},"(out), ",[40,1635,1606],{"class":135},[40,1637,1638],{"class":82},"(out.columns),\n",[40,1640,1642,1645,1648,1650,1653,1655,1658,1660,1663,1665,1668,1670,1673],{"class":42,"line":1641},11,[40,1643,1644],{"class":82},"                          {",[40,1646,1647],{"class":50},"\"type\"",[40,1649,1588],{"class":82},[40,1651,1652],{"class":50},"\"2_color_scale\"",[40,1654,114],{"class":82},[40,1656,1657],{"class":50},"\"min_color\"",[40,1659,1588],{"class":82},[40,1661,1662],{"class":50},"\"#FFFFFF\"",[40,1664,114],{"class":82},[40,1666,1667],{"class":50},"\"max_color\"",[40,1669,1588],{"class":82},[40,1671,1672],{"class":50},"\"#4338CA\"",[40,1674,1594],{"class":82},[40,1676,1678,1681,1684,1687,1690,1692,1694,1696,1698,1700,1702,1705,1707,1709,1711,1714,1716,1718,1721],{"class":42,"line":1677},12,[40,1679,1680],{"class":82},"    counts.where(observed).rename(",[40,1682,1683],{"class":117},"columns",[40,1685,1686],{"class":78},"=lambda",[40,1688,1689],{"class":82}," c: ",[40,1691,1507],{"class":78},[40,1693,1510],{"class":50},[40,1695,1514],{"class":1513},[40,1697,1517],{"class":82},[40,1699,1520],{"class":1513},[40,1701,1523],{"class":50},[40,1703,1704],{"class":82},").rename(",[40,1706,168],{"class":117},[40,1708,105],{"class":78},[40,1710,1468],{"class":135},[40,1712,1713],{"class":82},").to_excel(xw, ",[40,1715,1542],{"class":117},[40,1717,105],{"class":78},[40,1719,1720],{"class":50},"\"Counts\"",[40,1722,151],{"class":82},[40,1724,1726,1728,1731,1733,1735,1737,1739,1741,1744,1746,1749,1751],{"class":42,"line":1725},13,[40,1727,136],{"class":135},[40,1729,1730],{"class":82},"(pd.read_excel(",[40,1732,1427],{"class":50},[40,1734,114],{"class":82},[40,1736,1542],{"class":117},[40,1738,105],{"class":78},[40,1740,1547],{"class":50},[40,1742,1743],{"class":82},").iloc[:",[40,1745,890],{"class":135},[40,1747,1748],{"class":82},", :",[40,1750,1009],{"class":135},[40,1752,129],{"class":82},[10,1754,1755,1756,1759,1760,1764],{},"The colour scale starts at column D, month one, so the 100% of month zero does not stretch the scale and wash out the differences that matter. Empty future cells stay uncoloured because a colour scale ignores blanks. The ",[37,1757,1758],{},"Counts"," sheet keeps the absolute numbers next to the percentages — a retention of 60% means something different for a cohort of 10 than for one of 1,000. ",[19,1761,1763],{"href":1762},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fturn-a-pivot-table-into-a-heatmap-in-excel\u002F","Turn a Pivot Table into a Heatmap in Excel"," covers colour scales in more detail.",[25,1766,1768],{"id":1767},"common-pitfalls","Common pitfalls",[1770,1771,1772,1783,1796,1802,1808],"ul",{},[1773,1774,1775,1779,1780,1782],"li",{},[1776,1777,1778],"strong",{},"Counting orders instead of customers."," Use ",[37,1781,594],{}," on the customer ID; two orders in a month is still one retained customer.",[1773,1784,1785,1788,1789,1791,1792,1795],{},[1776,1786,1787],{},"Zeros in future cells."," ",[37,1790,790],{}," and ",[37,1793,1794],{},"fillna(0)"," turn \"not yet observed\" into \"nobody returned\"; blank them out.",[1773,1797,1798,1801],{},[1776,1799,1800],{},"Averaging percentages."," Weight by cohort size when combining cohorts.",[1773,1803,1804,1807],{},[1776,1805,1806],{},"Partial current month."," If the export was taken mid-month, the latest column understates retention. Cut the data at the end of the last complete month.",[1773,1809,1810,1813],{},[1776,1811,1812],{},"Customers who existed before the data starts."," Anyone who first appears in the first month of the export may be an old customer; exclude that cohort or load enough history to know.",[25,1815,1817],{"id":1816},"conclusion","Conclusion",[10,1819,1820,1821,1823],{},"Assign every customer to the month of their first order with ",[37,1822,384],{},", compute each order's age in months, count distinct customers per cohort and age, and divide each row by the cohort size — leaving months that have not happened yet empty. Combine cohorts into one curve by weighting with cohort sizes, and write the table to Excel with a colour scale from month one onwards, next to the raw counts.",[25,1825,1827],{"id":1826},"frequently-asked-questions","Frequently asked questions",[10,1829,1830,1833],{},[1776,1831,1832],{},"What is a cohort retention table?","\nA table that groups customers by when they first bought and shows, for each group, the share still buying in each following month. Rows follow one cohort over time; columns compare cohorts at the same age.",[10,1835,1836,1839],{},[1776,1837,1838],{},"How do I calculate months since first purchase in pandas?","\nConvert the order date and the cohort month to periods, turn each into a running month number with year × 12 + month, and subtract. That gives an integer age in months for every order.",[10,1841,1842,1845],{},[1776,1843,1844],{},"Why are the bottom-right cells of my cohort table empty?","\nThose cohorts have not been observed that long yet — the data ends before they reach that age. The cells should stay empty rather than zero, so averages only use cohorts old enough.",[10,1847,1848,1851],{},[1776,1849,1850],{},"How do I average retention across cohorts?","\nSum the retained customers at each age across the cohorts that have reached it and divide by the sum of those cohorts' sizes. A simple mean of percentages gives small cohorts too much weight.",[25,1853,1855],{"id":1854},"related","Related",[1770,1857,1858,1864,1871,1878],{},[1773,1859,1860,1861,1863],{},"Up one level: ",[19,1862,22],{"href":21}," — group-bys, crosstabs and summaries.",[1773,1865,1866,1870],{},[19,1867,1869],{"href":1868},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas\u002F","Count Distinct Values per Group in pandas"," — the nunique step in depth.",[1773,1872,1873,1877],{},[19,1874,1876],{"href":1875},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-an-rfm-customer-segmentation-from-excel-data\u002F","Build an RFM Customer Segmentation from Excel Data"," — another way to look at the same customers.",[1773,1879,1880,1884],{},[19,1881,1883],{"href":1882},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fgroup-excel-rows-by-month-and-quarter-with-pandas\u002F","Group Excel Rows by Month and Quarter with pandas"," — periods and month grouping.",[1886,1887,1888],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":35,"searchDepth":92,"depth":92,"links":1890},[1891,1892,1893,1894,1895,1896,1897,1898,1899,1900],{"id":27,"depth":92,"text":28},{"id":377,"depth":92,"text":378},{"id":583,"depth":92,"text":584},{"id":651,"depth":92,"text":652},{"id":1108,"depth":92,"text":1109},{"id":1407,"depth":92,"text":1408},{"id":1767,"depth":92,"text":1768},{"id":1816,"depth":92,"text":1817},{"id":1826,"depth":92,"text":1827},{"id":1854,"depth":92,"text":1855},"2026-09-29","Turn an order export into the classic retention triangle: assign each customer to the month of their first order, count how many come back in each following month, convert counts to percentages, compare cohorts, weight an average retention curve correctly, and write a colour-scaled table to Excel.","md",[1905,1907,1909,1911],{"q":1832,"a":1906},"A table that groups customers by when they first bought and shows, for each group, the share still buying in each following month. Rows follow one cohort over time; columns compare cohorts at the same age.",{"q":1838,"a":1908},"Convert the order date and the cohort month to periods, turn each into a running month number with year × 12 + month, and subtract. That gives an integer age in months for every order.",{"q":1844,"a":1910},"Those cohorts have not been observed that long yet — the data ends before they reach that age. The cells should stay empty rather than zero, so averages only use cohorts old enough.",{"q":1850,"a":1912},"Sum the retained customers at each age across the cohorts that have reached it and divide by the sum of those cohorts' sizes. A simple mean of percentages gives small cohorts too much weight.","build a cohort retention table from excel data",null,{"breadcrumb":1916},[1917,1919,1922],{"name":1918,"item":1186},"Home",{"name":1920,"item":1921},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":1923,"item":21},"Creating Pivot Tables from Excel Data with Pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data",{"title":1926,"description":1927},"Cohort Retention Analysis in Excel with pandas","Build a cohort retention table with pandas from an Excel order export: first-order month cohorts, months since first order, active customers per cell, retention percentages, a weighted average curve and a heatmap-formatted workbook.","build-a-cohort-retention-table-from-excel-data","advanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data\u002Findex",[1931,1932],"pivots-and-summaries","pandas","how-to","4eHvCdp1j_are5N004cTC5UI0R-_Npck5l2PJmmbwbU",[1936,1940],{"title":1937,"path":1938,"stem":1939,"children":-1},"Bin Values into Ranges Like Excel FREQUENCY with pd.cut","\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbin-values-into-ranges-with-pandas-cut","advanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbin-values-into-ranges-with-pandas-cut\u002Findex",{"title":1941,"path":1942,"stem":1943,"children":-1},"Build a Crosstab from Excel Data with pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-crosstab-from-excel-data-with-pandas","advanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-crosstab-from-excel-data-with-pandas\u002Findex",[1945,1947],{"path":1946,"title":1923},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data",{"path":1948,"title":1920},"\u002Fadvanced-data-transformation-and-cleaning",[],[1951,1954,1957,1960,1963,1964],{"path":1952,"title":1953,"kind":1933},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-customer-churn-report-from-subscription-data","Build a Customer Churn Report from Subscription Data",{"path":1955,"title":1956,"kind":1933},"\u002Fgetting-started-with-python-excel-automation\u002Fautomating-excel-with-xlwings-basics\u002Frefresh-pivot-tables-and-queries-in-excel-with-python","Refresh Excel Pivot Tables and Queries with Python",{"path":1958,"title":1959,"kind":1933},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fadd-a-native-excel-pivot-table-with-python","Add a Native Excel Pivot Table with Python",{"path":1961,"title":1962,"kind":1933},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fadd-subtotals-and-grand-totals-to-a-pandas-pivot-table","Add Subtotals and Grand Totals to a pandas Pivot Table",{"path":1938,"title":1937,"kind":1933},{"path":1942,"title":1941,"kind":1933},1790972086195]