Getting started with CCI

library(CCI)

This vignette shows how to test conditional independence with the CCI package: the idea behind the test, how to run it, how to read the output, and how it adapts to different kinds of data. The other vignettes cover:

The idea behind the test

Two variables \(Y\) and \(X\) are conditionally independent given \(Z\), written \(Y \perp\!\!\!\perp X \mid Z\), if \(X\) carries no information about \(Y\) once \(Z\) is known. The CCI test (Thorjussen et al. 2024, 2026) turns this into a question about prediction: does \(X\) improve the out-of-sample prediction of \(Y\) when \(Z\) is already used?

If \(Y \perp\!\!\!\perp X \mid Z\), then \(E(Y \mid X, Z) = E(Y \mid Z)\). The same holds if \(X\) is replaced by a random permutation \(X^*\), so a model for \(Y\) should predict equally well with \(X\) as with \(X^*\). With \(M\) a performance metric (e.g. RMSE) of a fitted model \(f_Y\) on new data, the hypotheses are

\[ H_0: Y \perp\!\!\!\perp X \mid Z \;\Rightarrow\; M(f_Y(X^*, Z)) = M(f_Y(X, Z)), \qquad H_1: Y \not\perp\!\!\!\perp X \mid Z \;\Rightarrow\; M(f_Y(X^*, Z)) \neq M(f_Y(X, Z)). \]

The test is computed with Monte Carlo cross-validation:

  1. Null distribution. Repeat nperm times: permute \(X\) into \(X^*\), split the data randomly into a training part (share p) and a test part, train a model for \(Y\) from \(X^*\) and \(Z\) on the training part, and record its performance on the test part.
  2. Test statistic. Train the same model with the real \(X\) on a random training split, and record its performance on the test part.
  3. P-value. Compare the test statistic with the null distribution. If the model with the real \(X\) predicts clearly better than the models with \(X^*\), \(X\) carries information about \(Y\) beyond \(Z\), and \(H_0\) is rejected.

Permuting \(X\) also breaks its relationship with \(Z\). Exact validity of a permutation test would require that the permutation keeps the joint distribution of \((X, Z)\), which is only possible when \(Z\) is categorical (then CCI permutes \(X\) within the groups of \(Z\)). CCI does not try to enforce this. Instead, it relies on the fact that under \(H_0\) both models converge to the same prediction \(E(Y \mid Z)\), and on repeated out-of-sample evaluation. Simulations show good type I error control, and the test is on the conservative side (Thorjussen et al. 2026). The price is computation time, and some loss of power in small samples.

A first test

We simulate data where \(Y\) and \(X\) both depend on \(Z_1\) and \(Z_2\), but not on each other. Then \(Y \perp\!\!\!\perp X \mid Z_1, Z_2\) is true, while \(Y \perp\!\!\!\perp X \mid Z_1\) is false, since \(Z_2\) creates a dependence between \(Y\) and \(X\) when it is left out.

normal_data <- function(n) {
  Z1 <- rnorm(n)
  Z2 <- rnorm(n)
  X <- Z1 + Z2 + rnorm(n)
  Y <- Z1 + Z2 + rnorm(n)
  data.frame(Z1, Z2, X, Y)
}
set.seed(123)
dat <- normal_data(500)

The hypothesis \(Y \perp\!\!\!\perp X \mid Z_1, Z_2\) is written Y ~ X | Z1 + Z2. The only required arguments of CCI.test() are formula and data. Setting seed makes the result reproducible.

res_true <- CCI.test(Y ~ X | Z1 + Z2, data = dat, seed = 1, progress = FALSE)
summary(res_true)
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using rf 
#> Formula:   Y ~ X | Z1 + Z2 
#> Permutations:  160 
#> Metric:    RMSE 
#> Tail:      left 
#> Statistic: 1.143 
#> P-value:   0.7019 
#> 
#> MC sample:   1

The p-value is 0.702, so we do not reject conditional independence. Leaving out \(Z_2\) should give a rejection:

res_false <- CCI.test(Y ~ X | Z1, data = dat, seed = 1, progress = FALSE)
summary(res_false)
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using rf 
#> Formula:   Y ~ X | Z1 
#> Permutations:  160 
#> Metric:    RMSE 
#> Tail:      left 
#> Statistic: 1.492 
#> P-value:   0.006211 
#> 
#> MC sample:   1

The summary shows:

Printing the result gives a shorter overview:

res_false
#> 
#> Computational Conditional Independence Test
#> 
#> Formula:   Y ~ X | Z1 
#> Method:    rf with metric RMSE and 160 permutations
#> Statistic: 1.492   P-value: 0.006211

The null distribution and the test statistic (dashed line) can be plotted:

plot(res_false)

The test statistic lies far to the left of the null distribution: the model predicts \(Y\) much better with the real \(X\) than with a permuted one.

The formula

The formula Y ~ X | Z1 + Z2 means “test \(Y \perp\!\!\!\perp X \mid Z_1, Z_2\)”. The first variable on the right-hand side is \(X\), and everything after | is the conditioning set \(Z\). The following formulas are equivalent ways of writing the same hypothesis:

Y ~ X | Z1 + Z2
Y ~ X + Z1 + Z2   # the first variable after ~ is X, the rest is Z
X ~ Y | Z1 + Z2   # conditional independence is symmetric in X and Y

The test itself is not symmetric: the variable on the left is the one that is predicted. Which direction gives the most power is discussed in vignette("diagnostics-and-tuning", package = "CCI").

To test unconditional independence \(Y \perp\!\!\!\perp X\), write the conditioning set as 1:

set.seed(2)
uncond <- data.frame(X = rnorm(300))
uncond$Y <- sin(2 * uncond$X) + rnorm(300, sd = 0.5)   # dependent, but almost uncorrelated
cor(uncond$X, uncond$Y)
#> [1] 0.2046094
summary(CCI.test(Y ~ X | 1, data = uncond, seed = 1, progress = FALSE))
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using rf 
#> Formula:   Y ~ X | 1 
#> Permutations:  160 
#> Metric:    RMSE 
#> Tail:      left 
#> Statistic: 0.5464 
#> P-value:   0.006211 
#> 
#> MC sample:   1

Y ~ X + 1 gives the same test, while Y ~ X gives an error, to avoid testing unconditional independence by mistake. The example also shows that CCI finds non-linear dependence that a correlation misses.

Choosing the learner

The method argument selects the machine learning model:

method Model Comment
"rf" (default) Random forest (ranger) Robust, a good first choice
"xgboost" Gradient boosting (xgboost) Often more power, slower, more tuning parameters
"svm" Support vector machine (e1071) Smooth relationships
"KNN" k-nearest neighbours (kknn) Very fast, useful for large data

Tree-based models (rf, xgboost) are recommended in most cases, as they predict robustly out of sample even when fitted closely to the training data. Model parameters are passed directly to CCI.test():

summary(CCI.test(Y ~ X | Z1, data = dat, method = "xgboost",
                 nrounds = 200, eta = 0.1, max_depth = 3,
                 nperm = 100, seed = 1, progress = FALSE))
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using xgboost 
#> Formula:   Y ~ X | Z1 
#> Permutations:  100 
#> Metric:    RMSE 
#> Tail:      left 
#> Statistic: 1.494 
#> P-value:   0.009901 
#> 
#> MC sample:   1

nrounds is the number of trees for both rf and xgboost (default 600). For rf, mtry sets the number of variables tried in each split, for KNN, k sets the number of neighbours. Other arguments are passed on to the underlying model function (ranger::ranger(), xgboost::xgb.train(), e1071::svm() or kknn::kknn()).

Different types of data

CCI chooses the performance metric from the type of \(Y\) (metric = "Auto"):

For classification, metric = "LogLoss" evaluates the predicted class probabilities instead of the predicted classes, and metric can also be set to "Kappa" for a numeric \(Y\) with a few values.

three_classes <- function(n) {
  Z1 <- rnorm(n)
  Z2 <- rnorm(n)
  X <- exp(Z1) + Z2 + rnorm(n, sd = 0.2)
  score <- log(abs(Z1) + 1) + Z2
  Y <- ifelse(score > 0.5, "high", ifelse(score > 0, "medium", "low"))
  data.frame(Z1, Z2, X, Y)
}
set.seed(3)
cat_data <- three_classes(500)
summary(CCI.test(Y ~ X | Z1 + Z2, data = cat_data, seed = 1, progress = FALSE))
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using rf 
#> Formula:   Y ~ X | Z1 + Z2 
#> Permutations:  160 
#> Metric:    Kappa 
#> Tail:      right 
#> Statistic: 0.8978 
#> P-value:   0.4658 
#> 
#> MC sample:   1
summary(CCI.test(Y ~ X | Z1, data = cat_data, metric = "LogLoss", seed = 1, progress = FALSE))
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using rf 
#> Formula:   Y ~ X | Z1 
#> Permutations:  160 
#> Metric:    LogLoss 
#> Tail:      left 
#> Statistic: 0.475 
#> P-value:   0.006211 
#> 
#> MC sample:   1

\(Y\) is here a character vector, which is treated as categorical. For Kappa the tail is right (higher Kappa means better prediction), for RMSE and LogLoss it is left.

Categorical conditioning variables. When \(Z\) contains factor, character or logical variables, and robust = TRUE (default), \(X\) is permuted within the groups defined by the categorical variables in \(Z\). This keeps the relationship between \(X\) and the categorical part of \(Z\), which improves type I error control.

Polynomial and interaction terms. By default, CCI.test() adds polynomial terms up to degree 3 (poly = TRUE, degree = 3) and pairwise interactions (interaction = TRUE) of the numeric conditioning variables to the model. This helps the learner capture smooth relationships between \(Z\) and \(Y\). The terms are named Z1_d_2, Z1_int_Z2 and so on, and are only added to \(Z\), never to \(X\). Set poly = FALSE and interaction = FALSE to use the variables as they are.

Large data sets

The runtime grows with the sample size and the number of Monte Carlo samples. Three arguments help:

hard_case <- function(n) {
  Z1 <- runif(n, -2, 2)
  Z2 <- runif(n, -2, 2)
  hZ <- sin(Z1) * cos(Z2)
  X <- hZ + 0.2 * rnorm(n)
  Y <- hZ^2 + 0.2 * rnorm(n)
  data.frame(X, Y, Z1, Z2)
}
set.seed(4)
big <- hard_case(5000)
start <- Sys.time()
res_big <- CCI.test(Y ~ X | Z1 + Z2, data = big, method = "KNN", seed = 1, progress = FALSE)
Sys.time() - start
#> Time difference of 2.521336 secs
summary(res_big)
#> 
#> Computational Conditional Independence Test
#> --------------------------------------------
#> Method:    CCI test using KNN 
#> Formula:   Y ~ X | Z1 + Z2 
#> Permutations:  160 
#> Metric:    RMSE 
#> Tail:      left 
#> Statistic: 0.2176 
#> P-value:   0.2671 
#> 
#> MC sample:   0.28

The “MC sample” line of the summary shows that a share of 0.28 of the data was used in each Monte Carlo sample, i.e. \((900/5000)^{0.75}\). For guidance on runtimes with the other learners, see Thorjussen et al. (2026).

References

Thorjussen, Christian B. H., Kristian H. Liland, Ingrid Måge, and Lars Erik Solberg. 2024. “Computational Test for Conditional Independence.” Algorithms 17 (8): 323. https://doi.org/10.3390/a17080323.
Thorjussen, Christian B. H., Kristian H. Liland, Lars Erik Solberg, and Ingrid Måge. 2026. CCI: An R Package for Computational Conditional Independence Testing.” SoftwareX 34: 102726. https://doi.org/10.1016/j.softx.2026.102726.