One of those decisions that seems simple until you actually have to measure it. A customer’s introductory rate expires, the invoice goes up, and you want to know whether the price increase hurt retention. Straightforward enough in theory.
The problem is that something else is almost always happening at the same time. The initiative that drove the original purchase — whether it was a system migration, compliance push, sales transformation, or product launch — has already wrapped up. The team that championed the tool has moved on to the next priority. And the product that once felt essential is quietly becoming a line item someone is going to question.
So when the customer churns, the account team blames the price. The retention strategy team says the use case ran its course. Product says the platform never got past the original buyer. Everyone has a theory and a spreadsheet to back it up.
Which attribution you land on matters — not in the abstract, but in terms of what you do next.
| If the primary cause is… | The business response is… |
|---|---|
| Promo expiry (price shock) | Extend discounting, redesign renewal packaging, adjust price ladder |
| Initiative completion (value exhaustion) | Invest in expansion use cases, trigger lifecycle retention plays, improve onboarding to recurring workflows |
| Both forces interact | Time renewal offers around new business moments; discount alone will not solve a value problem |
Each method below constructs a different counterfactual for the same event. Choosing the right one is not the hard part. Knowing which question you are trying to answer before you open a notebook — that is where most of these analyses go off track.
Define the question before the method
Before you touch the data, you need to decide what you are actually trying to estimate. The same churn event at renewal can produce three meaningfully different numbers depending on what you are asking:
- The promo-cohort effect. What was the average churn impact on customers whose introductory discount expired? The finance team usually wants this number because it aligns with how renewal revenue gets reported.
- The initiative-completion effect. What was the churn impact on customers whose original adoption use case had concluded by renewal? The retention strategy team wants this one because it speaks to whether the product achieved lasting value or just served a project.
- The joint effect and its interaction. What happened to customers who faced both at the same time — price increase and value exhaustion arriving together? This number is almost always larger than either force alone would predict, and it is usually the one that actually explains the churn spike.
These are not the same number, and they do not answer the same question. Treating them as interchangeable is the most common mistake I see in renewal churn analyses, and it is usually what keeps the account team versus retention strategy debate going in circles.
The Setup
The synthetic dataset contains 10,000 B2B customers observed around their renewal dates. Each has two flags: promo_expired (did their introductory rate end at renewal?) and initiative_complete (had the original use case concluded before renewal?). One thing worth flagging upfront: initiative_complete needs to be defined using pre-renewal signals — things like customer relationship management (CRM) milestones, implementation completion, or customer success health scores. If you infer it from declining usage after the fact, you will end up calling early churn behavior a cause of churn rather than a symptom of it. The true effects baked into the simulation:
- Baseline 6-month churn (neither force): 8%
- Promo expiry alone: +5 pp (13% churn)
- Initiative completion alone: +4 pp (12% churn)
- Both forces together: +14 pp (22% churn), a +5 pp interaction surplus above the additive expectation of 17%
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
RNG = np.random.default_rng(158) # seeded RNG for reproducibility
N = 10_000 # number of customers
# True effects baked into the data — what each method should recover.
TRUE_BASELINE = 0.08 # 8% baseline 6-month churn (neither force)
TRUE_PROMO = 0.05 # +5 pp from promo expiry alone
TRUE_INITIATIVE = 0.04 # +4 pp from initiative completion alone
TRUE_INTERACTION = 0.05 # +5 pp additional lift when BOTH forces hit
customers = pd.DataFrame({
'customer_id': np.arange(N),
'promo_expired': RNG.choice([0,1], N, p=[0.45, 0.55]),
'initiative_complete': RNG.choice([0,1], N, p=[0.50, 0.50]),
'arr_usd': RNG.lognormal(10.5, 0.8, N), # annual rev
'tenure_months': RNG.uniform(10, 14, N),
'n_seats': RNG.integers(5, 200, N), # seats sold
})
# Each customer's churn probability = baseline + promo + init + interaction.
# The interaction term only fires when BOTH forces are active.
churn_prob = (
TRUE_BASELINE
+ TRUE_PROMO * customers['promo_expired']
+ TRUE_INITIATIVE * customers['initiative_complete']
+ TRUE_INTERACTION * customers['promo_expired']
* customers['initiative_complete']
)
customers['churned'] = (RNG.uniform(size=N) < churn_prob).astype(int)Image by Author
Method 1: Difference-in-Differences
Business question: What was the average churn impact of promo expiry on customers who actually faced a price increase at renewal, and does that impact differ depending on whether the use case had already concluded?
Method-specific estimand: Average treatment effect of promo expiry on the promo-expired cohort, with a triple interaction term to detect whether the price shock is amplified when initiative completion co-occurs.
Identifying assumption: Parallel trends. Absent promo expiry, the churn trajectory of expired and non-expired customers would have tracked each other within comparable initiative-completion groups around the renewal date.
To run this, you would aggregate customers into
Here is the paraphrased version of the article, keeping the HTML structure intact while making the text clearer and easier to read:
—
The cohort-week grid is organized around the renewal date, with each row showing a cohort’s weekly churn rate and the number of customers still at risk during that week. The triple interaction term allows the model to detect whether the promotional price shock is amplified when the customer’s use case has also ended:
# A 'cohort' is the (promo_expired, initiative_complete) cell: 4 cohorts.
# Each row in the panel is one cohort in one week, with that cohort's
# weekly churn rate and the number of customers still at risk that week.
# week = 0 is the renewal date; negative weeks are pre-renewal.
panel = build_cohort_week_panel(customers) # long format: cohort x week
panel['post'] = (panel['week'] >= 0).astype(int) # 1 if post-renewal
panel['A'] = panel['promo_expired'] # rename for clarity
panel['B'] = panel['initiative_complete']
# 'post * A * B' expands to: post, A, B, post:A, post:B, A:B, post:A:B.
# Weighting by at_risk gives bigger cohort-weeks more influence.
did_model = smf.wls(
'churn_rate ~ post * A * B',
data = panel,
weights = panel['at_risk'],
).fit(cov_type='HC3') # heteroskedasticity-robust standard errors
# Coefficients to read:
# post:A = promo shock when the initiative is still ongoing
# post:B = initiative shock when the promo has not expired
# post:A:B = additional churn when both forces hit in the same week
Image by Author
A note on initiative_complete. This variable is not randomly assigned, and it correlates with factors that independently predict churn: customer size, how long the original buyer has been with the company, and product fit. Including covariates in the model helps, but what you must avoid is letting the model define this variable itself. Measure it before the renewal decision using CRM records or customer success milestones, not from usage patterns you observe after the customer has already started disengaging.
Failure mode: anticipation. Renewal quotes are sent out early. If customers start exploring alternatives the moment they see the new rate, the pre-period is already contaminated. Always inspect the event-study plot before trusting the coefficient.
Reading the result. The triple interaction term, post:A:B, is the key quantity this setup is designed to estimate. A positive coefficient means the price shock hits harder when the use case has already wound down. If you observe that pattern, a discounted renewal invoice alone will not solve the problem.
Method 2: Regression with interaction terms
Business question: What are the separate effects of price increase and project completion, and do they interact?
Method-specific estimand: Main effects and interaction coefficient from a regression that explicitly models both forces and their joint term.
Identifying assumption: No unmeasured confounders, adequate overlap across all four conditions, and a correctly specified functional form.
# Customer-level regression. Outcome: 1 if customer churned within 6 months.
# np.log1p(x) = log(1 + x); used to control for skewed dollar/count covariates
# (annual revenue, seat counts) so a few large customers do not dominate.
# The * operator below expands to: main effects of A and B AND their interaction.
interaction_model = smf.ols(
'churned ~ promo_expired * initiative_complete'
' + np.log1p(arr_usd) + np.log1p(n_seats)',
data=customers,
).fit(cov_type='HC3') # HC3 = heteroskedasticity-robust standard errors
# Coefficients (illustrative, matching simulation truth):
# promo_expired: +0.049 (b1, main effect of A)
# initiative_complete: +0.041 (b2, main effect of B)
# promo_expired:initiative_complete: +0.051 (b3, interaction A x B)One thing that often causes confusion: b1 is not “the overall effect of promo expiry.” It is the effect of promo expiry specifically when initiative_complete equals zero. Once the initiative has also concluded, the marginal effect of promo expiry becomes b1 + b3, where b3 is the interaction coefficient. The full picture:
Effect of promo expiry, initiative ongoing: b1 = +0.049
Effect of promo expiry, initiative complete: b1 + b3 = +0.100
Effect of initiative completion, promo ongoing: b2 = +0.041
Effect of initiative completion, promo expired: b2 + b3 = +0.092
Image by Author
Failure mode: collinearity. If many customers were sold into the same wave of transformation work, promo expiry and initiative completion will be correlated by construction. When that happens, b1, b2, and b3 become difficult to disentangle, and the standard errors will reflect that. In that case, report the joint prediction for each cohort rather than trying to interpret the individual coefficients.
Reading the result. The interaction coefficient is as large as either main effect on its own. A customer facing both forces is not just somewhat more at-risk; they are in a fundamentally different situation. That insight should drive the commercial response.
Method 3: Shapley value attribution
Business question: Given that both forces together caused 14 pp of incremental churn, how much of that should each force be held accountable for, for the purposes of budget allocation and renewal strategy?
Method-specific estimand: Fair allocation of the joint churn impact across the two causal forces, using Shapley values from cooperative game theory.
Identifying assumption: The coalition value estimates v(S), where S is a subset of the drivers and v(S) is the incremental churn caused by that subset, are credible. They should come from the regression or experiment above, not from a confounded model.
With just two drivers, Shapley values are actually quite intuitive. Each driver keeps its standalone contribution, and then the two split the interaction surplus evenly. Promo expiry gets its 5 pp plus half of the 5 pp interaction surplus. Initiative completion gets its 4 pp plus the other half. The code makes this
concrete:
from itertools import permutations
import math
# A 'coalition' represents any group of drivers that are active at the same time.
# v(S) = the additional churn (in percentage points) caused by coalition S.
# These coalition values are derived from the interaction regression model:
v = {
frozenset(): 0, # no drivers active
frozenset(['promo']): 5, # only promo expiry is active
frozenset(['init']): 4, # only initiative completion is active
frozenset(['promo', 'init']): 14, # both active, including a +5 pp interaction effect
}
# 'players' = the drivers across which we are distributing credit.
# For every possible ordering of players, each player's 'marginal contribution'
# is the amount by which the coalition's value increases when that player joins.
# The Shapley value is the average marginal contribution across all orderings.
def shapley_values(v, players):
n = len(players)
phi = {p: 0.0 for p in players} # running total for each player
for perm in permutations(players): # iterate through every possible ordering
coalition = frozenset() # begin with no drivers active
for player in perm:
# by how much does the coalition's value grow when this player joins?
marginal = v[coalition | {player}] - v[coalition]
phi[player] += marginal
coalition = coalition | {player}
# take the average across all n! orderings
return {p: round(phi[p] / math.factorial(n), 2) for p in players}
print(shapley_values(v, ['promo', 'init']))
# {'promo': 7.5, 'init': 6.5} # adds up to 14 pp, the total joint effect
Image by Author
The key point to remember here. Shapley is a credit-splitting rule. It divides impact fairly based on the coalition values you provide, but it cannot correct for flawed inputs. If your v(S) estimates come from a regression that suffers from confounding, your Shapley shares will carry that same bias. The math itself is sound; the causal groundwork still needs to be done beforehand.
Making sense of the result. A 7.5-to-6.5 split does not mean you should allocate 54% of your retention budget to pricing and 46% to customer success. It means both factors are essential, and the timing of the renewal offer is just as critical as the content of the offer itself.
Picking the right method
There is no single method that is always correct. The best choice depends on the specific question you are tackling and the data you have available. In practice, I typically run several approaches:
| Method | What it estimates | Key assumption | Tradeoffs |
|---|---|---|---|
| DiD | Average effect on the promo-expired cohort | Parallel trends around the renewal period | Requires clean cohorts and a pre-period; fails under anticipation or correlation |
| Regression + Interaction | Individual effects + interaction term | No unmeasured confounders; sufficient overlap across groups | Captures the interaction; breaks down when variables are highly collinear |
| Shapley attribution | Fair split of the combined impact | Reliable v(S) values from earlier steps | Helpful for budget discussions; becomes unstable when v(S) is noisy |



