BTYD Walkthrough
BTYD Walkthrough
2 Introduction
The BTYD package contains models to capture non-contractual purchasing
behavior of customers—or, more simply, models that tell the story of people
1
Frequency of Repeat Transactions
1500
Actual
Model
1000
Customers
500
0
0 1 2 3 4 5 6 7+
buying until they die (become inactive as customers). The main models presented
in the package are the Pareto/NBD, BG/NBD and BG/BB models, which
describe scenario of the firm not being able to observe the exact time at which a
customer drops out. We will cover each in turn. If you are unfamiliar with these
models, Fader et al. (2004) provides a description of the BG/NBD model, Fader
et al. (2005) provides a description of the Pareto/NBD model and Fader et al.
(2010) provides a description of the BG/BB model.
3 Pareto/NBD
The Pareto/NBD model is used for non-contractual situations in which customers
can make purchases at any time. Using four parameters, it describes the rate
at which customers make purchases and the rate at which they drop out—
allowing for heterogeneity in both regards, of course. We will walk through the
Pareto/NBD functionality provided by the BTYD package using the CDNOW1
dataset. As shown by figure 1, the Pareto/NBD model describes this dataset
quite well.
1 Provided with the BTYD package and available at brucehardie.com. For more details, see
2
3.1 Data Preparation
The data required to estimate Pareto/NBD model parameters is surprisingly
little. The customer-by-customer approach of the model is retained, but we need
only three pieces of information for every person: how many transactions they
made in the calibration period (frequency), the time of their last transaction
(recency), and the total time for which they were observed. A customer-by-
sufficient-statistic matrix, as used by the BTYD package, is simply a matrix
with a row for every customer and a column for each of the above-mentioned
statistics.
You may find yourself with the data available as an event log. This is a data
structure which contains a row for every transaction, with a customer identifier,
date of purchase, and (optionally) the amount of the transaction. dc.ReadLines
is a function to convert an event log in a comma-delimited file to an data frame in
R—you could use another function such as read.csv or read.table if desired,
but dc.ReadLines simplifies things by only reading the data you require and
giving the output appropriate column names. In the example below, we create
an event log from the file “cdnowElog.csv”, which has customer IDs in the second
column, dates in the third column and sales numbers in the fifth column.
3
with interpurchase time. Since our timing information is only accurate to the
day, we should merge all transactions that occurred on the same day. For this,
we use dc.MergeTransactionsOnSameDate. This function returns an event log
with only one transaction per customer per day, with the total sum of their
spending for that day as the sales number.
To validate that the model works, we need to divide the data up into a
calibration period and a holdout period. This is relatively simple with either an
event log or a customer-by-time matrix, which we are going to create soon. I
am going to use 30 September 1997 as the cutoff date, as this point (39 weeks)
divides the dataset in half. The reason for doing this split now will become
evident when we are building a customer-by-sufficient-statistic matrix from the
customer-by-time matrix—it requires a last transaction date, and we want to
make sure that last transaction date is the last date in the calibration period
and not in the total period.
The final cleanup step is a very important one. In the calibration period,
the Pareto/NBD model is generally concerned with repeat transactions—that
is, the first transaction is ignored. This is convenient for firms using the model
in practice, since it is easier to keep track of all customers who have made
at least one transaction (as opposed to trying to account for those who have
not made any transactions at all). The one problem with simply getting rid
of customers’ first transactions is the following: We have to keep track of a
“time zero” as a point of reference for recency and total time observed. For this
reason, we use dc.SplitUpElogForRepeatTrans, which returns a filtered event
log ($repeat.trans.elog) as well as saving important information about each
customer ($cust.data).
4
- Spend—each matrix entry will contain the amount spent by that customer
on that day. Use dc.CreateSpendCBT. You can set whether to use to-
tal spend for each day or average spend for each day by changing the
is.avg.spend parameter. In most cases, leaving is.avg.spend as FALSE
is appropriate.
# date
# cust 1997-01-08 1997-01-09 1997-01-10 1997-01-11 1997-01-12
# 1 0 0 0 0 0
# 2 0 0 0 0 0
# 6 0 0 0 1 0
5
function is used for the holdout period—it requires different input dates (simply
the start and end of the holdout period) and does not return a recency (which
has little value in the holdout period).
You’ll be glad to hear that, for the process described above, the package
contains a single function to do everything for you: dc.ElogToCbsCbt. However,
it is good to be aware of the process above, as you might want to make small
changes for different situations—for example, you may not want to remove
customers’ initial transactions, or your data may be available as a customer-by-
time matrix and not as an event log. For most standard situations, however,
dc.ElogToCbsCbt will do. Reading about its parameters and output in the
package documentation will help you to understand the function well enough to
use it for most purposes.
# [1] -9594.976
As with any optimization, we should not be satisfied with the first output we
get. Let’s run it a couple more times, with its own output as a starting point, to
see if it converges:
6
p.matrix <- c(params, LL)
for (i in 1:2){
params <- pnbd.EstimateParameters(cal.cbs = cal.cbs,
par.start = params,
hardie = allHardie)
LL <- pnbd.cbs.LL(params = params,
cal.cbs = cal.cbs,
hardie = allHardie)
p.matrix.row <- c(params, LL)
p.matrix <- rbind(p.matrix, p.matrix.row)
}
colnames(p.matrix) <- c("r", "alpha", "s", "beta", "LL")
rownames(p.matrix) <- 1:3
round(p.matrix, digits = 3)
# r alpha s beta LL
# 1 0.553 10.58 0.606 11.656 -9594.976
# 2 0.553 10.58 0.606 11.657 -9594.976
# 3 0.553 10.58 0.606 11.658 -9594.976
7
Heterogeneity in Transaction Rate
Mean: 0.0523 Var: 0.0049
25
20
Density
15
10
5
0
Transaction Rate
15
10
5
0
Dropout Rate
8
pnbd.Expectation(params = params, t = 52)
# [1] 1.473434
cal.cbs["1516",]
# x t.x T.cal
# 26.00000 30.85714 31.00000
# [1] 25.45647
pnbd.PAlive(params,
x,
t.x,
T.cal,
hardie = allHardie)
# [1] 0.997874
9
T.cal = 39,
hardie = allHardie)
cat ("x:",i,"\t Expectation:",cond.expectation, fill = TRUE)
}
# x: 10 Expectation: 0.7062289
# x: 15 Expectation: 0.1442396
# x: 20 Expectation: 0.02250658
# x: 25 Expectation: 0.00309267
pnbd.PlotFrequencyInCalibration(params = params,
cal.cbs = cal.cbs,
censor = 7,
hardie = allHardie)
10
elog <- dc.SplitUpElogForRepeatTrans(elog)$repeat.trans.elog;
x.star <- rep(0, nrow(cal.cbs));
cal.cbs <- cbind(cal.cbs, x.star);
elog.custs <- elog$cust;
for (i in 1:nrow(cal.cbs)){
current.cust <- rownames(cal.cbs)[i]
tot.cust.trans <- length(which(elog.custs == current.cust))
cal.trans <- cal.cbs[i, "x"]
cal.cbs[i, "x.star"] <- tot.cust.trans - cal.trans
}
round(cal.cbs[1:3,], digits = 3)
Now we can see how well our model does in the holdout period. Figure 4
shows the output produced by the code below. It divides the customers up into
bins according to calibration period frequencies and plots actual and conditional
expected holdout period frequencies for these bins.
# pdf
# 2
As you can see above, the graph also produces a matrix output. Most
plotting functions in the BTYD package produce output like this. They are often
worth looking at because they contain additional information not presented in
11
Conditional Expectation
Actual
8
Model
6
Holdout period transactions
4
2
0
0 1 2 3 4 5 6 7+
the graph—the size of each bin in the graph. In this graph, for example, this
information is important because the bin sizes show that the gap at zero means
a lot more than the precision at 6 or 7 transactions. Despite this, this graph
shows that the model fits the data very well in the holdout period.
Aggregation by calibration period frequency is just one way to do it. BTYD
also provides plotting functions which aggregate by several other measures. The
other one I will demonstrate here is aggregation by time—how well does our
model predict how many transactions will occur in each week?
The first step, once again, is going to be to collect the data we need to
compare the model to. The customer-by-time matrix has already collected the
data for us by time period; so we’ll use that to gather the total transactions per
day. Then we convert the daily tracking data to weekly data.
12
w.track.data[j] <- sum(d.track.data[(j*7-6):(j*7)])
}
pdf(file = 'pnbdTrackingInc.pdf')
inc.tracking <- pnbd.PlotTrackingInc(params = params,
T.cal = T.cal,
T.tot = T.tot,
actual.inc.tracking.data = w.track.data,
n.periods.final = n.periods.final)
dev.off()
# pdf
# 2
round(inc.tracking[,20:25], digits = 3)
Although figure 5 shows that the model is definitely capturing the trend of
customer purchases over time, it is very messy and may not convince skeptics.
Furthermore, the matrix, of which a sample is shown, does not really convey
much information since purchases can vary so much from one week to the next.
For these reasons, we may need to smooth the data out by cummulating it over
time, as shown in figure 6.
13
Tracking Weekly Transactions
Actual
Model
120
100
80
Transactions
60
40
20
0
1 5 9 14 19 24 29 34 39 44 49 54 59 64 69 74
Week
# pdf
# 2
round(cum.tracking[,20:25], digits = 3)
14
Tracking Cumulative Transactions
4000
3000
Cumulative Transactions
2000
1000
Actual
Model
0
1 5 9 14 19 24 29 34 39 44 49 54 59 64 69 74
Week
4 BG/NBD
The BG/NBD model, like the Pareto/NBD model, is used for non-contractual
situations in which customers can make purchases at any time. It describes the
rate at which customers make purchases and the rate at which they drop out
with four parameters—allowing for heterogeneity in both. We will walk through
the BG/NBD functions provided by the BTYD package using the CDNOW2
dataset. As shown by figure 7, the BG/NBD model describes this dataset quite
well.
15
Frequency of Repeat Transactions
Actual
1400
Model
1200
1000
Customers
800
600
400
200
0
0 1 2 3 4 5 6 7+
above-mentioned statistics.
You may find yourself with the data available as an event log. This is a data
structure which contains a row for every transaction, with a customer identifier,
date of purchase, and (optionally) the amount of the transaction. dc.ReadLines
is a function to convert an event log in a comma-delimited file to an data frame in
R—you could use another function such as read.csv or read.table if desired,
but dc.ReadLines simplifies things by only reading the data you require and
giving the output appropriate column names. In the example below, we create
an event log from the file “cdnowElog.csv”, which has customer IDs in the second
column, dates in the third column and sales numbers in the fifth column.
Note the formatting of the dates in the output above. dc.Readlines saves
dates as characters, exactly as they appeared in the original comma-delimited
file. For many of the data-conversion functions in BTYD, however, dates need
to be compared to each other—and unless your years/months/days happen to
16
be in the right order, you probably want them to be sorted chronologically and
not alphabetically. Therefore, we convert the dates in the event log to R Date
objects:
Our event log now has dates in the right format, but a bit more cleaning
needs to be done. Transaction-flow models, such as the BG/NBD, is concerned
with inter-purchase time. Since our timing information is only accurate to the
day, we should merge all transactions that occurred on the same day. For this,
we use dc.MergeTransactionsOnSameDate. This function returns an event log
with only one transaction per customer per day, with the total sum of their
spending for that day as the sales number.
To validate that the model works, we need to divide the data up into a
calibration period and a holdout period. This is relatively simple with either an
event log or a customer-by-time matrix, which we are going to create soon. I
am going to use 30 September 1997 as the cutoff date, as this point (39 weeks)
divides the dataset in half. The reason for doing this split now will become
evident when we are building a customer-by-sufficient-statistic matrix from the
customer-by-time matrix—it requires a last transaction date, and we want to
make sure that last transaction date is the last date in the calibration period
and not in the total period.
The final cleanup step is a very important one. In the calibration period,
the BG/NBD model is generally concerned with repeat transactions—that is,
the first transaction is ignored. This is convenient for firms using the model
in practice, since it is easier to keep track of all customers who have made
at least one transaction (as opposed to trying to account for those who have
not made any transactions at all). The one problem with simply getting rid
of customers’ first transactions is the following: We have to keep track of a
“time zero” as a point of reference for recency and total time observed. For this
reason, we use dc.SplitUpElogForRepeatTrans, which returns a filtered event
log ($repeat.trans.elog) as well as saving important information about each
customer ($cust.data).
17
split.data <- dc.SplitUpElogForRepeatTrans(elog.cal);
clean.elog <- split.data$repeat.trans.elog;
# date
# cust 1997-01-08 1997-01-09 1997-01-10 1997-01-11 1997-01-12
# 1 0 0 0 0 0
# 2 0 0 0 0 0
# 6 0 0 0 1 0
18
tot.cbt <- dc.CreateFreqCBT(elog)
cal.cbt <- dc.MergeCustomers(tot.cbt, freq.cbt)
You’ll be glad to hear that, for the process described above, the package
contains a single function to do everything for you: dc.ElogToCbsCbt. However,
it is good to be aware of the process above, as you might want to make small
changes for different situations—for example, you may not want to remove
customers’ initial transactions, or your data may be available as a customer-by-
time matrix and not as an event log. For most standard situations, however,
dc.ElogToCbsCbt will do. Reading about its parameters and output in the
package documentation will help you to understand the function well enough to
use it for most purposes.
19
params <- bgnbd.EstimateParameters(cal.cbs);
params
# p1 p2 p3 p4
# 0.2425982 4.4136842 0.7929899 2.4261667
# [1] -9582.429
As with any optimization, we should not be satisfied with the first output we
get. Let’s run it a couple more times, with its own output as a starting point, to
see if it converges:
# r alpha a b LL
# 1 0.2425982 4.413684 0.7929899 2.426167 -9582.429
# 2 0.2425965 4.413685 0.7929888 2.426166 -9582.429
# 3 0.2425967 4.413659 0.7929869 2.426164 -9582.429
20
Heterogeneity in Transaction Rate
Mean: 0.055 Var: 0.0125
20
15
Density
10
5
0
Transaction Rate
2
1
0
Dropout Probability p
21
4.3 Individual Level Estimations
Now that we have parameters for the population, we can make estimations for
customers on the individual level.
First, we can estimate the number of transactions we expect a newly acquired
customer to make in a given time period. Let’s say, for example, that we are
interested in the number of repeat transactions a newly acquired customer will
make in a time period of one year. Note that we use 52 weeks to represent one
year, not 12 months, 365 days, or 1 year. This is because our parameters were
estimated using weekly data.
bgnbd.Expectation(params, t=52);
# p3
# 1.444004
cal.cbs["1516",]
# x t.x T.cal
# 26.00000 30.85714 31.00000
# p3
# 25.75659
# p3
# 0.9688523
22
for (i in seq(10, 25, 5)){
cond.expectation <- bgnbd.ConditionalExpectedTransactions(
params, T.star = 52, x = i,
t.x = 20, T.cal = 39)
cat ("x:",i,"\t Expectation:",cond.expectation, fill = TRUE)
}
# x: 10 Expectation: 0.3474606
# x: 15 Expectation: 0.04283391
# x: 20 Expectation: 0.004158973
# x: 25 Expectation: 0.0003583685
bgnbd.PlotFrequencyInCalibration(params, cal.cbs, 7)
23
cal.cbs <- cbind(cal.cbs, x.star);
elog.custs <- elog$cust;
for (i in 1:nrow(cal.cbs)){
current.cust <- rownames(cal.cbs)[i]
tot.cust.trans <- length(which(elog.custs == current.cust))
cal.trans <- cal.cbs[i, "x"]
cal.cbs[i, "x.star"] <- tot.cust.trans - cal.trans
}
cal.cbs[1:3,]
Now we can see how well our model does in the holdout period. Figure 10
shows the output produced by the code below. It divides the customers up into
bins according to calibration period frequencies and plots actual and conditional
expected holdout period frequencies for these bins.
pdf(file = 'bgnbdCondExpComp.pdf')
comp <- bgnbd.PlotFreqVsConditionalExpectedFrequency(params, T.star,
cal.cbs, x.star, censor)
dev.off()
# pdf
# 2
As you can see above, the graph also produces a matrix output. Most
plotting functions in the BTYD package produce output like this. They are often
worth looking at because they contain additional information not presented in
the graph—the size of each bin in the graph. In this graph, for example, this
24
Conditional Expectation
Actual
7
Model
6
5
Holdout period transactions
4
3
2
1
0
0 1 2 3 4 5 6 7+
Figure 10: Actual vs. conditional expected transactions in the holdout period.
information is important because the bin sizes show that the gap at zero means
a lot more than the precision at 6 or 7 transactions. Despite this, this graph
shows that the model fits the data very well in the holdout period.
Aggregation by calibration period frequency is just one way to do it. BTYD
also provides plotting functions which aggregate by several other measures. The
other one I will demonstrate here is aggregation by time—how well does our
model predict how many transactions will occur in each week?
The first step, once again, is going to be to collect the data we need to
compare the model to. The customer-by-time matrix has already collected the
data for us by time period; so we’ll use that to gather the total transactions per
day. Then we convert the daily tracking data to weekly data.
25
}
# pdf
# 2
inc.tracking[,20:25]
Although figure 11 shows that the model is definitely capturing the trend of
customer purchases over time, it is very messy and may not convince skeptics.
Furthermore, the matrix, of which a sample is shown, does not really convey
much information since purchases can vary so much from one week to the next.
For these reasons, we may need to smooth the data out by cumulating it over
time, as shown in Figure 12.
26
Tracking Weekly Transactions
Actual
Model
120
100
80
Transactions
60
40
20
0
1 5 9 14 19 24 29 34 39 44 49 54 59 64 69 74
Week
cum.tracking.data,
n.periods.final,
allHardie)
dev.off()
# pdf
# 2
cum.tracking[,20:25]
5 BG/BB
The BG/BB model is also used for non-contractual settings. In many regards,
it is very similar to the Pareto/NBD model—it also uses four parameters to
describe a purchasing process and a dropout process. The difference between the
models is that the BG/BB is used to describe situations in which customers have
discrete transaction opportunities, rather than being able to make transactions
at any time. For this section, we will be using donation data presented in Fader
27
Tracking Cumulative Transactions
4000
3000
Cumulative Transactions
2000
1000
Actual
Model
0
1 5 9 14 19 24 29 34 39 44 49 54 59 64 69 74
Week
et. al. (2010). Figure 13 shows that this model also fits the right type of data
well.
28
Frequency of Repeat Transactions
Actual
3500
Model
3000
2500
Customers
2000
1500
1000
500
0
0 1 2 3 4 5 6
Figure 13: Calibration period fit of BG/BB model to the donations dataset.
elog[1:3,]
# cust date
# 1 1 1970-01-01
# 2 1 1975-01-01
# 3 1 1977-01-01
max(elog$date);
# [1] "1983-01-01"
min(elog$date);
# [1] "1970-01-01"
freq<- cal.cbs[,"x"]
rec <- cal.cbs[,"t.x"]
29
trans.opp <- 7 # transaction opportunities
cal.rf.matrix <- dc.MakeRFmatrixCal(freq, rec, trans.opp)
cal.rf.matrix[1:5,]
data(donationsSummary);
rf.matrix <- donationsSummary$rf.matrix
params <- bgbb.EstimateParameters(rf.matrix);
LL <- bgbb.rf.matrix.LL(params, rf.matrix);
p.matrix <- c(params, LL);
for (i in 1:2){
params <- bgbb.EstimateParameters(rf.matrix, params);
LL <- bgbb.rf.matrix.LL(params, rf.matrix);
p.matrix.row <- c(params, LL);
p.matrix <- rbind(p.matrix, p.matrix.row);
}
colnames(p.matrix) <- c("alpha", "beta", "gamma", "delta", "LL");
rownames(p.matrix) <- 1:3;
p.matrix;
The parameter estimation converges very quickly. It is much easier, and faster,
to estimate BG/BB parameters than it is to estimate Pareto/NBD parameters,
because there are fewer calculations involved.
We can interpret these parameters by plotting the mixing distributions. Alpha
and beta describe the beta mixing distribution of the beta-Bernoulli transaction
30
Heterogeneity in Transaction Rate
Mean: 0.6162 Var: 0.0801
2.5
2.0
1.5
Density
1.0
0.5
0.0
Transaction Rate
process. We can see the beta distribution with parameters alpha and beta in
figure 14, plotted using bgbb.PlotTransactionRateHeterogeneity(params).
Gamma and Delta describe the beta mixing distribution of the beta-geometric
dropout process. We can see the beta distribution with parameters gamma and
delta in figure 15, plotted using bgbb.PlotDropoutHeterogeneity(params).
The story told by these plots describes the type of customers most firms would
want—their transaction parameters are more likely to be high, and their dropout
parameters are more likely to be low.
bgbb.Expectation(params, n=10);
# [1] 3.179805
But we want to be able to say something about our existing customers, not
just about a hypothetical customer to be acquired in the future. Once again, we
31
Heterogeneity in Dropout Rate
Mean: 0.1909 Var: 0.0348
6
5
4
Density
3
2
1
0
Dropout rate
# customer A
n.cal = 6
n.star = 10
x = 0
t.x = 0
bgbb.ConditionalExpectedTransactions(params, n.cal,
n.star, x, t.x)
# [1] 0.1302169
# customer B
x = 4
t.x = 5
bgbb.ConditionalExpectedTransactions(params, n.cal,
n.star, x, t.x)
# [1] 3.627858
As expected, B’s conditional expectation is much higher than A’s. The point
I am trying to make, however, is that there are 3464 A’s in this dataset and only
32
284 B’s—you should never ignore the zeroes in these models.
bgbb.PlotFrequencyInCalibration(params, rf.matrix)
As with the equivalent Pareto/NBD plot, keep in mind that this plot is only
useful for an initial verification that the fit of the BG/BB model is not terrible.
The next step is to see how well the model performs in the holdout period.
When we used dc.ElogToCbsCbt earlier, we ignored a lot of the data it generated.
It is easy to get the holdout period frequencies from that data:
At this point, we can switch from simulated data to the real discrete data
(donation data) provided with the package. The holdout period frequencies
for the donations data is included in the package. Using this information,
we can generate figure 16, which compares actual and conditional expected
transactions in the holdout period. It bins customers according to calibration
period frequencies.
33
Conditional Expectation
Actual
5
Model
4
Holdout period transactions
3
2
1
0
0 1 2 3 4 5 6
Figure 16: Actual vs. conditional expected transactions in the holdout period,
binned by calibration period frequency.
# pdf
# 2
Since the BG/BB model uses discrete data, we can also bin customers by
recency. Figure 17 shows the fit of holdout period frequencies, with customers
binned in this manner.
34
Conditional Expected Transactions by Recency
Actual
4
Model
3
Holdout period transactions
2
1
0
0 1 2 3 4 5 6
Figure 17: Actual vs. conditional expected transactions in the holdout period,
binned by calibration period recency.
pdf(file = 'bgbbCondExpCompRec.pdf')
comp <- bgbb.PlotRecVsConditionalExpectedFrequency(params, n.star,
rf.matrix, x.star)
dev.off()
# pdf
# 2
35
Tracking Incremental Transactions
6000
Actual
Model
5000
4000
Transactions
3000
2000
1000
0
1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006
Time
the package.
# pdf
# 2
Figure 18 shows remarkable fit, but we can smooth it out for a cleaner graph
36
Tracking Cumulative Transactions
40000
30000
Cumulative Transactions
20000
10000
Actual
0
Model
1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006
Time
# pdf
# 2
6 Further analysis
The package functionality I highlighted above is just a starting point for working
with these models. There are additional tools, such as functions for discounted
37
expected residual transactions (the present value of the remaining transactions
we expect a customer to make) and an implementation of the gamma-gamma
spend model, which may come in useful for customer analysis. Hopefully you
now have an idea of how to start working with the BTYD package - from here,
you should be able to use the package’s additional functions, and may even want
to implement some of your own. Enjoy!
38
References
Fader, Peter S., and Bruce G.S. Hardie. “A Note on Deriving the Pareto/NBD
Model and Related Expressions.” November. 2005. Web.
<http://www.brucehardie.com/notes/008/>
Fader, Peter S., Bruce G.S. Hardie, and Jen Shang. “Customer-Base Analysis
in a Discrete-Time Noncontractual Setting.” Marketing Science, 29(6), pp.
1086-1108. 2010. INFORMS.
<http://www.brucehardie.com/papers/020/>
Fader, Peter S., Hardie, Bruce G.S., and Lee, Ka Lok. ““Counting Your Cus-
tomers” the Easy Way: An Alternative to the Pareto/NBD Model.” Marketing
Science, 24(2), pp. 275-284. 2005. INFORMS.
<http://brucehardie.com/papers/018/>
39