Code
import pandas as pd
import matplotlib.pyplot as plt
%config InlineBackend.figure_formats = ['svg']This model presented in this notebook is a direct implementation of Daniel M. McCarthy, Peter S. Fader and Bruce G. S. Hardie’s 2016 paper Valuing Subscription-Based Businesses Using Publicly Disclosed Customer Data.
I will also implement the models proposed by Gupta, Sunil, Donald R. Lehmann, and Jennifer Ames Stuart in their 2004 paper Valuing Customers, and Schulze, Christian, Bernd Skiera, and Thorsten Wiesel in their 2012 paper Linking Customer and Financial Metrics to Shareholder Value: The Leverage Effect in Customer-Based Valuation as benchmarks.
import pandas as pd
import matplotlib.pyplot as plt
%config InlineBackend.figure_formats = ['svg']The subscription-based business model—where customers pay recurring fees for ongoing access to a product or service—has seen significant growth beyond its traditional domains of media and telecom. Today, it underpins a wide range of consumer and B2B offerings, including SaaS (e.g., Microsoft 365), meal kits (e.g., Blue Apron), and consumer goods (e.g., Dollar Shave Club). A key driver of this model’s appeal is the predictability of revenue streams.
As subscription businesses scale, they increasingly disclose customer-related metrics such as churn, acquisition cost (CAC), average revenue per user (ARPU), and customer lifetime value (CLV). These metrics are critical for understanding a firm’s future cash flows—especially since, in subscription settings, customer behavior largely drives economic value. Equity analysts and investors now routinely incorporate this data into valuation models, with subscriber base growth often serving as a key signal.
In this notebook, I implement the framework (and its models) from McCarthy, Fader, and Bruce (2016), which estimates firm value using only publicly disclosed customer data. Central to this framework are models of the firm’s acquisition and retention processes, which accommodate factors such as customer heterogeneity, duration dependence, seasonality, and changes in population size.
We explicitly account for the fact that publicly reported data are typically aggregated (temporally and across customers) and suffer from missingness (i.e., the reported data are not available for all periods).
I begin by reviewing the principles of customer-based corporate valuation and the nature of the data available from subscription firms. I then introduce models for acquisition, retention, and spending behavior, and estimate their parameters using historical data from two publicly traded firms: Dish Network (DISH) and Sirius XM (SIRI).
Once validated, the model is used to generate forward-looking estimates of customer behavior, firm-level revenue, and ultimately, equity value. I also highlight additional strategic insights that emerge from the estimated parameters and model outputs.
The framework summarized here is a discounted cash flow (DCF) model, which is the de-facto industry standard way in which operating assets are valued within the financial community. At the heart of any such valuation exercise is the estimation of period-by-period FCF, central to which are estimates of period-by-period revenue.
Denoting the value of the firm at time \(T\) by \(\text{SHV}_T\) (for shareholder value), we have
\[ \text{SHV}_T = \text{OA} + \text{NOA}_T - \text{ND}_T \]
The value of a firm’s operating assets (\(\text{OA}\)) is equal to the sum of all expected future free cash flows (FCFs) the firm will generate, discounted at the weighted average cost of capital (\(\text{WACC}\)):
\[ \text{OA}_T = \sum_{t=0}^{\infty} \frac{\text{FCF}_{T+t}}{(1+\text{WACC})^{t}} \]
FCF is equal to the net operating profit after taxes (\(\text{NOPAT}\)) minus the difference between capital expenditures (\(\text{CAPEX}\)) and depreciation and amortization (\(\text{D\&A}\)), minus the change in non-financial working capital (\(\Delta\text{NFWC}\)):
\[ \text{FCF}_t = \text{NOPAT}_t - (\text{CAPEX}_t - \text{D\&A}_t) - \Delta\text{NFWC}_t \]
The most important ingredient of FCF is \(\text{NOPAT}\), which is a measure of the underlying profitability of the operating assets of the firm. \(\text{NOPAT}\) is equal to revenues (\(\text{REV}\)) times the contribution margin ratio (\(1 - \text{VC}\)) minus fixed operating costs (\(\text{FC}\)), after taxes (where \(\text{TR}\) is the corporate tax rate for the firm):
\[ \text{NOPAT}_t = \left(\text{REV}_t \times (1 - \text{VC}) - \text{FC}_t \right) \times \left(1- \text{TR}_t\right) \]
Alternative Source: FRED (EOCCUSQ176N) Housing Inventory Estimate: Occupied Housing Units in the United States
file_path = "data/quarterly-estimates-housing-inventory-US.csv"
household_full = pd.read_csv(file_path)
household_full| Year | Q1 | Q2 | Q3 | Q4 | |
|---|---|---|---|---|---|
| 0 | 1965 | 56848 | 57459 | 57846 | 57850 |
| 1 | 1966 | 57676 | 58646 | 58925 | 58696 |
| 2 | 1967 | 58493 | 59143 | 60133 | 60133 |
| 3 | 1968 | 60451 | 60739 | 61196 | 61423 |
| 4 | 1969 | 61759 | 62141 | 62412 | 62730 |
| ... | ... | ... | ... | ... | ... |
| 59 | 2020 | 124391 | 126780 | 126703 | 125805 |
| 60 | 2021 | 125944 | 126155 | 126914 | 127430 |
| 61 | 2022 | 127574 | 127990 | 128307 | 129396 |
| 62 | 2023 | 129234 | 130100 | 130386 | 131206 |
| 63 | 2024 | 131092 | 131414 | 132114 | 132404 |
64 rows × 5 columns
file_path = "data/revised-quarterly-estimates-housing-inventory-US.csv"
household_revised = pd.read_csv(file_path)
household_revised| Year | Q1 | Q2 | Q3 | Q4 | |
|---|---|---|---|---|---|
| 0 | 2000 | NaN | 102274 | 102896 | 103646 |
| 1 | 2001 | 103456.0 | 103411 | 104228 | 104698 |
| 2 | 2002 | 104719.0 | 105276 | 105527 | 105759 |
| 3 | 2003 | 105884.0 | 105989 | 106068 | 106505 |
| 4 | 2004 | 106738.0 | 107020 | 107932 | 108735 |
| 5 | 2005 | 108863.0 | 109057 | 109736 | 110281 |
| 6 | 2006 | 110350.0 | 110560 | 110779 | 111096 |
| 7 | 2007 | 110715.0 | 111342 | 111251 | 111724 |
| 8 | 2008 | 111326.0 | 111601 | 111939 | 111823 |
| 9 | 2009 | 111862.0 | 112453 | 112381 | 112485 |
| 10 | 2010 | 112500.0 | 112726 | 112941 | 113445 |
| 11 | 2011 | 113119.0 | 113399 | 113559 | 114102 |
| 12 | 2012 | 114145.0 | 114228 | 114741 | 115125 |
| 13 | 2013 | 114778.0 | 115244 | 115415 | 115712 |
| 14 | 2014 | 115552.0 | 115992 | 116253 | 117686 |
| 15 | 2015 | 117360.0 | 117702 | 117799 | 118264 |
| 16 | 2016 | 118038.0 | 118772 | 119129 | 119226 |
| 17 | 2017 | 119435.0 | 119447 | 119669 | 120821 |
| 18 | 2018 | 120662.0 | 121143 | 121267 | 122396 |
| 19 | 2019 | 122255.0 | 122329 | 122617 | 123848 |
| 20 | 2020 | 124299.0 | 126726 | 126672 | 125814 |
| 21 | 2021 | 125996.0 | 126279 | 127090 | 127696 |
| 22 | 2022 | 127928.0 | 128206 | 128580 | 129718 |
| 23 | 2023 | 129600.0 | 130044 | 130312 | 131113 |
| 24 | 2024 | 130982.0 | 131414 | 132114 | 132404 |
household_full_lf = (
pd.wide_to_long(household_full, stubnames="Q", i="Year", j="Quarter")
.sort_index()
.rename(columns={"Q": "Total Household"})
)
household_rev_lf = (
pd.wide_to_long(household_revised, stubnames="Q", i="Year", j="Quarter")
.sort_index()
.rename(columns={"Q": "Total Household"})
)
household_rev_lf.plot(figsize=(9, 4), linewidth=0.75, color="k");dish_op_res = pd.read_csv(
'data/DISH - ADD-LOSS-END.csv',
index_col='Date',
parse_dates=True,
dayfirst=True
).sort_index()
dish_op_res.tail()| Year | Quarter | Service revenue | Total Revenue | Subscriber Acquisition Costs (SAC) | Equipment Capitalized | Pay-TV Subscribers | Pay-TV Subscriber additions, gross | Implied Subscriber additions, gross | Pay-TV Subscriber additions, net | Implied Pay-TV Subscriber additions, net | Pay-TV Churn Rate (average monthly) | Pay-TV SAC per Subscriber (average) | Pay-TV ARPU (average monthly) | Combined Additions | Combined Net Additions | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||
| 2014-03-31 | 2014 | 1 | 3556187.0 | 3594198.0 | NaN | NaN | 14.097 | 0.639 | NaN | 0.040 | 0.040 | 0.0142 | 862.0 | 82.36 | 0.639 | 0.040 |
| 2014-06-30 | 2014 | 2 | 3645101.0 | 3688119.0 | NaN | NaN | 14.053 | 0.656 | NaN | -0.044 | -0.044 | 0.0166 | 846.0 | 84.15 | 0.656 | -0.044 |
| 2014-09-30 | 2014 | 3 | 3647850.0 | 3679351.0 | NaN | NaN | 14.041 | 0.691 | NaN | -0.012 | -0.012 | 0.0167 | 861.0 | 84.39 | 0.691 | -0.012 |
| 2014-12-31 | 2014 | 4 | 3645953.0 | 3681719.0 | NaN | NaN | 13.978 | 0.615 | NaN | -0.063 | -0.063 | 0.0159 | 853.0 | 83.77 | 0.615 | -0.063 |
| 2015-03-31 | 2015 | 1 | 3693530.0 | 3724228.0 | NaN | NaN | 13.844 | 0.723 | NaN | -0.134 | -0.134 | 0.0164 | 667.0 | 85.73 | 0.554 | -0.134 |
# Gross Customer Acquisition Every Quarter
dish_ADD = dish_op_res['Combined Additions'] * 1000
# Surviving Customers at the End of Quarter
dish_END = dish_op_res['Pay-TV Subscribers'] * 1000
# Customer Churn Every Quarter
dish_LOSS = (
dish_op_res["Pay-TV Subscribers"].shift(periods=1)
+ dish_op_res["Combined Additions"]
- dish_op_res["Pay-TV Subscribers"]
) * 1000def dish_plot_format(ax, data):
# Recession bar
ax.axvspan(pd.to_datetime("12/1/2007"), pd.to_datetime("06/1/2009"), color='gray', alpha=0.3)
# Padding
pad = pd.DateOffset(months=12)
ax.set_xlim(data.index.min() - pad, data.index.max() + pad)
# x-ticks
ax.set_xticks(data.index[::8])
ax.set_xticklabels([f"Q{((d.month-1)//3)+1} {d.year}" for d in data.index[::8]], rotation=45)
plt.tight_layout()
plt.show()
return# Gross Customer Acquisition Every Quarter
_, ax = plt.subplots(figsize=(9, 4))
dish_ADD.plot(ax=ax, color='k', linewidth=0.75, title='ADD', ylim=(-70, 1200))
dish_plot_format(ax, dish_ADD)# Customer Churn Every Quarter
_, ax = plt.subplots(figsize=(9, 4))
dish_LOSS.plot(ax=ax, color="k", linewidth=0.75, title="LOSS", ylim=(-70, 1000))
dish_plot_format(ax, dish_LOSS)# Surviving Customers at the End of Quarter
_, ax = plt.subplots(figsize=(9, 4))
dish_END.plot(ax=ax, color='k', linewidth=0.75, title='END', ylim=(-800,16000))
dish_plot_format(ax, dish_END)Number of vehicles on the road data:
Data description: - Units: Number of Units - Frequency: Annually - Notes: Total number of registered private, commercial (incl. taxicabs), and public automobiles in the US every year (excluding buses, trucks, and motorcycles)
reg_cars = pd.read_csv('data/us_registered_vehicles.csv', index_col='Year')
reg_cars.tail()| Registered Automobiles | |
|---|---|
| Year | |
| 2019 | 108547710 |
| 2020 | 105135300 |
| 2021 | 102724349 |
| 2022 | 99666377 |
| 2023 | 96901563 |
reg_cars.plot(y='Registered Automobiles', figsize=(9, 4), color='k', linewidth=0.75);Vehicle sales data (for macroeconomic covariate):
Data description: - Autos–all passenger cars, including station wagons. - Light trucks–trucks up to 14,000 pounds gross vehicle weight, including minivans and - Sport utility vehicles. Prior to the 2003 Benchmark Revision light trucks were up to 10,000 pounds. - Heavy trucks–trucks more than 14,000 pounds gross vehicle weight. - Columns: - Autos – not seasonally adjusted (Thousands)
- Light Trucks – not seasonally adjusted (Thousands)
- Light Total –not seasonally adjusted (Thousands)
- Total – not seasonally adjusted (Thousands)
- Autos (SAAR) – seasonally adjusted at annual rates (Millions)
- Light Trucks (SAAR) – seasonally adjusted at annual rates (Millions)
- Light Total (SAAR) – seasonally adjusted at annual rates (Millions) - Total (SAAR) – seasonally adjusted at annual rates (Millions)
new_vehicle_sales = pd.read_csv(
'data/us_new_passenger_car_sales.csv',
index_col='Date',
parse_dates=True,
dayfirst=True)
new_vehicle_sales.tail()| Month | Year | Autos | Light Trucks | Light Total | Total | Autos (SAAR) | Light Trucks (SAAR) | Light Total (SAAR) | Total (SAAR) | |
|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||
| 2024-11-01 | November | 2024 | 250.101 | 1123.392 | 1373.493 | 1411.970 | 3.074369 | 13.585438 | 16.659807 | 17.150438 |
| 2024-12-01 | December | 2024 | 245.098 | 1249.122 | 1494.220 | 1539.189 | 3.039268 | 13.829528 | 16.868796 | 17.322678 |
| 2025-01-01 | January | 2025 | 197.367 | 905.660 | 1103.027 | 1138.497 | 2.840438 | 12.651473 | 15.491911 | 15.983576 |
| 2025-02-01 | February | 2025 | 219.794 | 1000.994 | 1220.788 | 1252.337 | 2.951624 | 13.059812 | 16.011436 | 16.447194 |
| 2025-03-01 | March | 2025 | 287.595 | 1297.795 | 1585.390 | 1620.008 | 3.114295 | 14.652515 | 17.766811 | 18.170024 |
new_vehicle_sales.plot(y='Light Total', figsize=(9, 4), color='k', linewidth=0.75);new_vehicle_sales.plot(y='Light Total (SAAR)', figsize=(9, 4), color='k', linewidth=0.75);