Why Is Determining Causality So Hard?

Bonus notes: confounders, the law of total probability, and counterfactuals

1 A simple example: ice cream and drownings

Let’s take a deliberately silly example to study causality: ice cream sales per day and the number of drownings per day.

Does anyone think that more people drowning causes ice cream sales to go up?

Of course not. But let’s use the statistical tools we have learned so far and see what they tell us — because every one of them is going to say these two variables are related.

1.1 Setting up a model of the world

I’m going to model this as if there are only two times of year: Winter and Summer.

Time of year Drownings Ice cream sales
Winter \(N(\mu = 5,\ \sigma^2 = 1)\) \(N(\mu = 5,\ \sigma^2 = 1)\)
Summer \(N(\mu = 20,\ \sigma^2 = 25)\) \(N(\mu = 20,\ \sigma^2 = 25)\)

The critical thing to notice: within a season, I am drawing the two variables completely independently. There is no causal link anywhere in this data generating process. Whatever relationship we find is something we manufactured by not looking at the season.

Note that rnorm() takes a standard deviation, not a variance, so \(\sigma^2 = 25\) becomes sd = 5.

Note, why did I pick these numbers? Recall a normal lives most of the time within \(2\sigma\) (2 standard deviations) of it’s mean. So I made these numbers so that winter ice cream is about \(5 \pm 2\) and summer is about \(20 \pm 2(5)=20 \pm 10\) to give reasonable scales.

Code
n_each <- 100

ice_cream <- tibble(
  time_of_year = rep(c("Winter", "Summer"), each = n_each),
  drownings = c(
    rnorm(n_each, mean = 5,  sd = 1),
    rnorm(n_each, mean = 20, sd = 5)
  ),
  sales = c(
    rnorm(n_each, mean = 5,  sd = 1),
    rnorm(n_each, mean = 20, sd = 5)
  )
) |>
  mutate(time_of_year = factor(time_of_year, levels = c("Winter", "Summer")))

glimpse(ice_cream)
#> Rows: 200
#> Columns: 3
#> $ time_of_year <fct> Winter, Winter, Winter, Winter, Winter, Winter, Winter, W…
#> $ drownings    <dbl> 5.981969, 5.468715, 4.892029, 4.787122, 6.158098, 6.29235…
#> $ sales        <dbl> 5.002189, 5.475364, 4.222258, 5.205378, 6.022545, 3.98992…

1.2 The plot that tells the truth

Code
ggplot(ice_cream, aes(x = drownings, y = sales, colour = time_of_year)) +
  geom_point(size = 2, alpha = 0.85) +
  scale_colour_manual(values = c(Winter = "#3d7ea6", Summer = "#e2a33c")) +
  labs(
    x = "Drownings per day",
    y = "Ice cream sales per day",
    colour = "Time of year",
    title = "With the season visible, the structure is obvious"
  )
Figure 1: Ice cream sales against drownings, coloured by time of year. The two clusters are the whole story.

Looking at Figure 1, we can clearly see that time of year is what is really doing the work here. There are two blobs. Inside each blob, the points are just noise — no tilt, no trend.

1.3 The same plot with the colour turned off

Code
ggplot(ice_cream, aes(x = drownings, y = sales)) +
  geom_point(size = 2, alpha = 0.85, colour = "grey25") +
  geom_smooth(method = "lm", se = FALSE, colour = "#c05e5e") +
  labs(
    x = "Drownings per day",
    y = "Ice cream sales per day",
    title = "With the season hidden, we 'find' a strong relationship"
  )
Figure 2: Exactly the same data, with the season hidden. Now it looks like a relationship.

From Figure 2 alone, it really does look like drownings and ice cream have a relationship. Nothing about the picture warns you that you are looking at two clusters rather than one trend.

2 What our tools say

2.1 Linear models

If I fit sales ~ drownings + time_of_year, the model will fit a slope near zero for drownings and give a different intercept to each season:

Code
lm(sales ~ drownings + time_of_year, data = ice_cream) |>
  tidy() |>
  mutate(across(where(is.numeric), \(x) round(x, 3)))
#> # A tibble: 3 × 5
#>   term               estimate std.error statistic p.value
#>   <chr>                 <dbl>     <dbl>     <dbl>   <dbl>
#> 1 (Intercept)           5.44      0.489     11.1    0    
#> 2 drownings            -0.073     0.068     -1.06   0.288
#> 3 time_of_yearSummer   16.3       1.19      13.7    0

If I model sales ~ drownings alone, it fits a solidly positive slope:

Code
lm(sales ~ drownings, data = ice_cream) |>
  tidy() |>
  mutate(across(where(is.numeric), \(x) round(x, 3)))
#> # A tibble: 2 × 5
#>   term        estimate std.error statistic p.value
#>   <chr>          <dbl>     <dbl>     <dbl>   <dbl>
#> 1 (Intercept)     2.70     0.623      4.33       0
#> 2 drownings       0.78     0.04      19.3        0

So if I never get to see the time-of-year data, my linear model tells me that ice cream and drownings do have a relationship — that they are not independent.

2.2 Correlation

The correlation is large and positive too:

Code
cor(ice_cream$drownings, ice_cream$sales)
#> [1] 0.8085498

But split it by season, and it evaporates:

Code
ice_cream |>
  group_by(time_of_year) |>
  summarise(correlation = cor(drownings, sales))
#> # A tibble: 2 × 2
#>   time_of_year correlation
#>   <fct>              <dbl>
#> 1 Winter           -0.0857
#> 2 Summer           -0.0753

This is the same fact Figure 1 showed us, written as a number.

2.3 A permutation test with infer

Think of each ice cream value and each drowning value as being paired up: high sales usually go with high drownings. Now imagine shuffling one column, so that each drowning value gets paired with a randomly chosen ice cream value. If the two variables were genuinely unrelated, our observed correlation should look unremarkable next to the correlations we get from those shuffles.

Let’s do that 500 times.

Code
observed_cor <- ice_cream |>
  specify(sales ~ drownings) |>
  calculate(stat = "correlation")

observed_cor
#> Response: sales (numeric)
#> Explanatory: drownings (numeric)
#> # A tibble: 1 × 1
#>    stat
#>   <dbl>
#> 1 0.809
Code
null_dist <- ice_cream |>
  specify(sales ~ drownings) |>
  hypothesize(null = "independence") |>
  generate(reps = 500, type = "permute") |>
  calculate(stat = "correlation")
Code
visualize(null_dist) +
  shade_p_value(obs_stat = observed_cor, direction = "two-sided") +
  labs(
    x = "Correlation under permutation",
    title = "Our observed correlation is nowhere near the null distribution"
  )
Figure 3: Null distribution of the correlation under 500 permutations, with the observed statistic marked.
Code
get_p_value(null_dist, obs_stat = observed_cor, direction = "two-sided")
#> # A tibble: 1 × 1
#>   p_value
#>     <dbl>
#> 1       0

Under a null of \(\rho = 0\), we would reject the null. So even our hypothesis test seems to point at a causal relationship between the two.

WarningThe test is not lying to you

The permutation test answered the question it was asked — are these two columns independent? — and answered it correctly. They are not independent. The mistake is ours, in thinking that “not independent” means “one causes the other.”

2.4 The test done within a season

Watch what happens when we run the identical test on summer days only, so that the season is held fixed:

Code
summer <- ice_cream |> filter(time_of_year == "Summer")

summer_obs <- summer |>
  specify(sales ~ drownings) |>
  calculate(stat = "correlation")

summer_null <- summer |>
  specify(sales ~ drownings) |>
  hypothesize(null = "independence") |>
  generate(reps = 500, type = "permute") |>
  calculate(stat = "correlation")

get_p_value(summer_null, obs_stat = summer_obs, direction = "two-sided")
#> # A tibble: 1 × 1
#>   p_value
#>     <dbl>
#> 1   0.488

Once we stop comparing winter days to summer days, the evidence disappears.

3 So what is really going on?

There is a hidden confounder — in this case time of year — that is really driving the relationship between the two variables.

We didn’t cover this in class, but there is a tool in probability called the law of total probability that explains exactly how this happens.

3.1 Building up to it

Recall that the probability of a union of two mutually exclusive events is the sum of their probabilities:

\[ P(A \cup B) = P(A) + P(B) \qquad \text{if } A \cap B = \emptyset . \]

Now consider an event \(A\) and an event \(B\), and write \(A\) as

\[ A = (A \cap B) \cup (A \cap B^{c}), \]

breaking \(A\) into two pieces. These two pieces are mutually exclusive and they cover all of \(A\), so the “total” probability of \(A\) decomposes into parts:

\[ P(A) = P(A \cap B) + P(A \cap B^{c}). \]

Figure 4: Event \(A\) split into the half-moon outside \(B\) and the lens inside \(B\).

3.2 More than two pieces

Consider two discrete random variables: \(X\), which takes values \(0, 1\), and \(Y\), which takes values \(0, 1, 2, 3, 4\). The probability \(P(X = 1)\) can be decomposed into “\(X\) is 1 and \(Y\) is whatever it likes”:

\[ P(X = 1) = \sum_{y = 0}^{4} P(X = 1,\, Y = y). \]

The event \(\{X = 1\}\) is a single blob, and the values of \(Y\) carve it into five chunks that don’t overlap and don’t miss anything.

Figure 5: The event \(\{X=1\}\) partitioned by the value of \(Y\).

3.3 The law of total probability

Now recall conditional probability:

\[ P(X = 1,\, Y = 3) = P(X = 1 \mid Y = 3)\, P(Y = 3). \]

Substituting that into each chunk gives the law of total probability: for any random variables \(X\) and \(Y\), where \(Y\) has state space \(\Omega\),

\[ \boxed{\;P(X = x) = \sum_{y \in \Omega} P(X = x \mid Y = y)\, P(Y = y)\;} \]

4 Applying it to our example

Let’s apply this to ice cream, drownings, and time of year. Consider

\[ P(\text{ice cream is high} \mid \text{drownings are high}). \]

Is this more than 50%, do we think?

Let’s break the condition down into parts using \(Y\), the time of year. (Because everything here is already conditional on drownings being high, we use the conditional form of the law, in which the weights are also conditioned on \(D\) being high.)

\[ \begin{aligned} P(\text{IC high} \mid D \text{ high}) =\; & P(\text{IC high} \mid D \text{ high},\, Y = \text{winter})\, P(Y = \text{winter} \mid D \text{ high}) \\[4pt] +\; & P(\text{IC high} \mid D \text{ high},\, Y = \text{summer})\, P(Y = \text{summer} \mid D \text{ high}) \end{aligned} \]

Now, by construction, ice cream and drownings have no relationship once you also know the time of year. That is exactly how we simulated the data. So the conditioning on \(D\) drops out of the first factor in each term:

\[ P(\text{IC high} \mid D \text{ high},\, Y = \text{winter}) = P(\text{IC high} \mid Y = \text{winter}) \]

\[ P(\text{IC high} \mid D \text{ high},\, Y = \text{summer}) = P(\text{IC high} \mid Y = \text{summer}) \]

which leaves

\[ P(\text{IC high} \mid D \text{ high}) = \underbrace{P(\text{IC high} \mid Y = \text{winter})}_{\text{small}} \underbrace{P(\text{winter} \mid D \text{ high})}_{\text{small}} + \underbrace{P(\text{IC high} \mid Y = \text{summer})}_{\text{large}} \underbrace{P(\text{summer} \mid D \text{ high})}_{\text{large}}. \]

And now the mechanism is completely visible. If drownings are high, it is most likely summer. If it is summer, then ice cream sales are high. The association travels from \(D\) up to \(Y\) and back down to ice cream — never directly.

We can check that chain against the simulated data:

Code
ice_cream |>
  mutate(
    d_high  = drownings > median(drownings),
    ic_high = sales > median(sales)
  ) |>
  filter(d_high) |>
  summarise(
    `P(summer | D high)`  = mean(time_of_year == "Summer"),
    `P(IC high | D high)` = mean(ic_high)
  )
#> # A tibble: 1 × 2
#>   `P(summer | D high)` `P(IC high | D high)`
#>                  <dbl>                 <dbl>
#> 1                 0.99                  0.99

4.1 Confounders

Time of year here is a confounder: a hidden variable I cannot see that is affecting the relationship between my response variable and my explanatory variable.

Figure 6: The confounding structure. Time of year causes both, so the two effects show up as an association between them.

To tease out a causal relationship, you must remove the effect of all confounders, so that the only relationship being studied is the one between the response and the explanatory variable.

NoteComing up on Monday

How this can actually be done in practice — randomized control trials.

5 Does causation even matter, or is it all about prediction?

It depends on what you want to do with your answer.

5.1 When prediction is all you need

Imagine I am a computer that manages an ice cream store. My job is to stock ice cream each day to meet demand. For some reason, nobody tells me what time of year it is — I only get told how many people drowned yesterday, and I have to predict tomorrow’s sales.

In that case, even though there is no causal relationship, I should use the increase in drownings to buy more ice cream tomorrow:

Code
naive_model <- lm(sales ~ drownings, data = ice_cream)

new_days <- tibble(drownings = c(4, 18))

new_days |>
  mutate(predicted_sales = predict(naive_model, newdata = new_days))
#> # A tibble: 2 × 2
#>   drownings predicted_sales
#>       <dbl>           <dbl>
#> 1         4            5.82
#> 2        18           16.7

Roughly 5 boxes (the winter average) on a low-drowning day, roughly 20 (the summer average) on a high-drowning day. So my “bad” linear model from before is useful for prediction, even though there is no causal connection. The drowning count is serving as a proxy for the season I was never shown.

5.2 When prediction is not enough

Now suppose that, as the US Congress, I am alarmed about the increase in drownings in the country. So we pass a law banning all ice cream sales, in the hope of saving lives.

Since there is no causal link, this will not change drownings at all. People will still go swimming in the summer even if they cannot buy ice cream.

Here we are talking about a counterfactual. Say we observe 10 people who drowned, 8 of whom ate ice cream, and 10 people who did not drown, only 2 of whom ate ice cream. Little known to me, the first group was in summer and the second was in winter.

I never get to see:

  • Would the first 10 have drowned if they had not eaten ice cream?
  • Would the second 10 have drowned if they had eaten ice cream?

If you want to study these counterfactual claims — things that did not occur — you have to establish causality. No amount of predictive accuracy will get you there.

TipThe one-line summary

A model that predicts well tells you what the world looks like. Only a causal model tells you what the world would do if you reached in and changed something.