This vignette illustrates how to use the causact
package
to reduce the friction experienced while going from business issues to
computational Bayesian models, inference, and insight. The package is
easy-to-use, intuitive, and provides a visual interface to computational
Bayesian inference, and allows R
to access a probabilistic
programming language (PPL). To get started load causact
package:
We will use the below example fictional narrative along with some
simulated data to motivate how to use use causact
for
iterating from narrative to computational insight. A more detailed
version of this vignette can be found in Chapters 14-17 of “A Business
Analyst’s Introduction to Business Analytics” available at https://www.causact.com.
BankPass is an auto financing company that is launching a new credit card, the Travel Rewards Card (TRC). The card is designed for people who love to take adventure vacations like ziplining, kayaking, scuba diving, and the like. To date, BankPass has been randomly selecting a small number of customers to target with a private offer to sign up for the card. They would like to assess the probability that an individual will get a TRC card if exposed to a private marketing offer. Since they are an auto-loan company, they are curious about whether the model of car (e.g. Honda Accord, Toyota Corrola, Jeep Wrangler, etc.) being financed influences a customer’s willingness to sign up for the credit card. The logic is that people who like adventure travel might also like specific kinds of cars. If it is true that cars are a reflection of their owners, then the company might expand its credit card offerings to take advantage of its car ownership data.
Let’s get started capturing the BankPass problem as a graphical model. The first thought should be to make a simple model of how the real-world ends up generating your variable(s) of interest; and don’t worry about how good the model is - just get something written.
library(causact)
dag_create() %>%
dag_node("Get Card", "Y") %>%
dag_node("Adventurous Personality", "X",
child = "Y") %>%
dag_render(shortLabel = TRUE)
The above output is a visual depiction of what mathematicians and computer scientists call a graph. To them, a graph is a set of related objects where the objects are called nodes and any pair of related nodes are known as an edge. For our purposes, a node is a visual depiction of a random variable (i.e. an oval) and the presence of a visible edge indicates a relationship between the connected random variables.
Vetting the above model reveals some shortcomings and our first vetting criteria is to ensure the two nodes can be modelled as random variables with each node representing a mapping of real-world outcomes to a numerical values.
For the “Get Card” node, let’s label it \(X\), the mapping of outcomes to values is the conventional mapping for a Bernoulli-distributed random variable:
\[ X \equiv \begin{cases} 0, & \textrm{if customer does not get the card }\\ 1, & \textrm{if customer gets the TRC card } \end{cases} \]
where successes are mapped to 1 and failures mapped to 0.
However, how does one map “Adventurous Personality” to values? In fact, what measurement or specific criteria am I using for “Adventurous Personality”? This is an ill-defined variable. Since the story actually suggests that Car Model is the data we have, let’s create a graphical model where Car Model helps determine the likelihood of Get Card. Using \(Y\) and \(X\) as respective labels for Car Model and Get Card, we show a revised candidate graphical model below.
library(causact)
dag_create() %>%
dag_node("Get Card", "X") %>%
dag_node("Car Model", "Y",
child = "X") %>%
dag_render(shortLabel = FALSE)
As shown above, the difficult to measure personality node gets replaced by the car model node. To verify we can measure Car Model as a random variable, let’s be explicit about the mapping of “Car Model” outcomes to real numbers. For example:
\[ Y \equiv \begin{cases} 1, & \textrm{if customer owns a Toyota Corolla}\\ 2, & \textrm{if customer owns a Jeep Wrangler}\\ \textrm{ } \vdots & \hspace{3cm} \vdots\\ K, & \textrm{if customer owns the } K^{th} \textrm{car model.} \end{cases} \]
A dataset to accompany this story is built-in to the
causact
package:
# ------------------------------------
# Revisiting the Get Credit Card Model
# ------------------------------------
# data included in the causact package
# 1,000 customers and the car they drive
carModelDF #show data
#> # A tibble: 1,000 x 3
#> # Rowwise:
#> customerID carModel getCard
#> <int> <chr> <int>
#> 1 1 Toyota Corolla 0
#> 2 2 Subaru Outback 1
#> 3 3 Toyota Corolla 0
#> 4 4 Subaru Outback 0
#> 5 5 Kia Forte 1
#> 6 6 Toyota Corolla 0
#> 7 7 Toyota Corolla 0
#> 8 8 Subaru Outback 1
#> 9 9 Toyota Corolla 0
#> 10 10 Toyota Corolla 0
#> # ... with 990 more rows
We take a bottom-up approach starting with our target measurement
Get Card to add in mathematical details about how the data is
generated. Specifically, we declare any observation x
to be
a Bernoulli-distributed random variable with yet-to-be-defined parameter
theta
. (For a list of supported distributions type
?causact::distributions
)
To model additional nodes, consider any parameters on the right-hand
side of the statistical model that remain undefined; in our case
theta
is undefined. Hence, if \(x
\sim \textrm{Bernoulli}(\theta)\), then \(\theta\) should be a parent node of \(x\) in the graphical model. And at this
point, we say ay caramba!. There is a mismatch between Car
Model, the parent of Get Card in the previous graphical
model, and \(\theta\), the implied
parent of Get Card using the statistical model.
Realizing that Signup Probability, \(\theta\), is truly the variable of interest
as we are interested in modelling the probability of someone signing up
for the card. The below figure graphically models the probability of
sign-up (\(\theta\)) as the
parent. Notice that nodes with data, i.e. Get Card, have darker
fill; observed nodes get a darker fill and latent or
unobserved nodes remain lighter. The right-hand side
(rhs
) of darker nodes represent the likelihood
part of Bayes rule and the right-hand side of lighter nodes reprent the
prior part of Bayes rule.
graph = dag_create() %>%
dag_node("Get Card","x",
rhs = bernoulli(theta),
data = carModelDF$getCard) %>%
dag_node("Signup Probability","theta",
rhs = uniform(0,1),
child = "x")
graph %>% dag_render()
The above generative recipe implies there is only one true
\(\theta\) value - which is unknown so
we place a flat uniform prior over all possible values for \(\theta\). Given \(\theta\), 1,000 observations are outcomes
of Bernoulli trials using that one \(\theta\) value. The posterior distribution
reallocates plausibility among \(\theta\) values to those most consistent
with our prior and data. A representative sample from the posterior
distribution is easily obtained using dag_numpyro()
:
From this, a reasonable conclusion might be that plausible values for Signup Probability lie (roughly) between 36% and 42%.
Our business narrative calls for the probability to change based on
car model. The dag_plate()
function helps create a
generative DAG where theta
will vary based on car model. In
comparison to mode probabilistic programming paradigms, the
causact
package shifts the burden of index creation to the
computer instead of the analyst. A plate signifies that the
random variables inside of it should be repeated; thus, any oval inside
a plate is indexed behind the scenes and effectively, a repeat instance
is created for each member of the plate. The system automatically adds
the index to the model using the plate label
for indexing
notation. When using dag_plate()
the following arguments
prove most useful:
descr
- a character string representing a longer more
descriptive label for the cluster/plate.label
- a short character string to use as an index
label when generating code.data
- a vector representing the categorical data whose
unique values become the plate index. When using with
addDataNode = TRUE
, ensure this vector represents
observations of a variable whose unique values can be coerced to a
factor. Omit this argument and causact
assumes that node
replication is based on observations of any observed nodes within the
plate.nodeLabels
- a character vector of node labels or
descriptions to include in the list of nodes that get repeated one time
per index value. Labels must be defined already within the specification
of the causact_graph_
.addDataNode
- a logical value. When
addDataNode = TRUE
, the code adds a node of observed data,
in addition to the plate, based on the supplied data
argument value. This node is used to extract the proper index when any
of the nodes in the plate are fed to children nodes. Verify the
graphical model using dag_render()
to ensure correct
behavior.Below, we can change the generative DAG to accommodate the story of Signup Probability being different for drivers of different cars.
### Now vary your modelling of theta by car model
### we use the plate notation to say
### "create a copy of theta for each unique car model
graph = dag_create() %>%
dag_node("Get Card","x",
rhs = bernoulli(theta),
data = carModelDF$getCard) %>%
dag_node(descr = "Signup Probability",
label = "theta",
rhs = uniform(0,1),
child = "x") %>%
dag_plate(descr = "Car Model",
label = "y",
data = carModelDF$carModel, #plate labels
nodeLabels = "theta", # node on plate
addDataNode = TRUE) # create obs. node
graph %>% dag_render()
This generative DAG used one additional function call,
dag_plate()
, that resulted in both the creation of the
plate, and also the additional node representing Car Model
observations. Each of the 1,000 observed customers of the Car
Model node were driving one of 4 unique car models
of the Car Model plate. The indexing of
theta
by y
, i.e. the theta[y]
on
the rhs
of the Get Card node extracts one of the 4
\(\theta\)-values to govern each
observation of \(x\).
Expert tip: Observed-discrete random variables, like car model, are best modelled via plates. Do not rush to add nodes or indexes for these random variables. Let dag_plate() do the work for you.
Now, when we get the posterior distribution from our code, we get a representative sample of four \(\theta\) values instead of one.
Visualizing the posterior yields:
Notice, the index names for the four theta
values are
automatically generated as abbreviated versions of the unique labels
found in carModelDF$carModel
.
Digesting the posterior plot, notice that credible interval widths
are different for the different theta
values. For example,
the probability interval for the Toyota Corolla is more narrow than the
interval for the other cars. This is simply because there is more data
regarding Toyota Corolla drivers than the other vehicles. With more
data, we get more confident in our estimate. Here is a quick way to see
the distribution of car models across our observations:
In the previous posterior plot, notice the overlapping beliefs
regarding the true sign-up probability for drivers of the
Toyota Corolla and Kia Forte. Based on this, a reasonable query might be
what is the probability that theta_Kia Forte
>
theta_ToyotaCorolla
? As a typical workflow, use an
indicator function on the posterior draws to answer the question. So, to
answer “what is the probability that theta_Kia.Forte
>
theta_Toyota.Corolla
?”, we compute:
drawsDF %>%
mutate(indicatorFunction =
theta_Kia.Forte > theta_Toyota.Corolla) %>%
summarize(pctOfDraws = mean(indicatorFunction))
#> # A tibble: 1 x 1
#> pctOfDraws
#> <dbl>
#> 1 0.769
And using this ability to make probabalistic statements, one might say “there is an approximately 77% chance that Kia Forte drivers are more likely than Toyota Corolla drivers to sign up for the credit card.
In the previous genrative DAG, the bracketed [1000]
next
to the node descriptions tells us that the observed data has 1,000
elements. Often, for visual clarity, I like to make a plate of
observations and have the observed nodes revert back to representing
single realizations. This is done by creating a second plate, but now
excluding the data
argument in the additional call to
dag_plate
:
graph = dag_create() %>%
dag_node("Get Card","x",
rhs = bernoulli(theta),
data = carModelDF$getCard) %>%
dag_node("Signup Probability","theta",
rhs = uniform(0,1),
child = "x") %>%
dag_plate("Car Model", "y",
data = carModelDF$carModel,
nodeLabels = "theta",
addDataNode = TRUE) %>%
dag_plate("Observations", "i",
nodeLabels = c("x","y"))
dag_plate()
now infers the number of repetitions from
the observed values within the plate. The generative DAG shown below
reflects this change, yet remains computationally equivalent to the
previous DAG.
This code change is completely optional, but using the extra plate adds clarity for both you and your collaborators in a data analysis.
The causact
package is actively being developed. Please
post any questions or bugs as a github issue at: https://github.com/flyaflya/causact/issues.