--- title: "Custom models and performance metrics" output: rmarkdown::html_vignette bibliography: references.bib vignette: > %\VignetteIndexEntry{Custom models and performance metrics} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4, message = FALSE ) ``` ```{r setup} library(CCI) ``` The CCI test only needs two things: a way to train a model on part of the data, and a way to measure how well it predicts the rest. The package has four built-in learners (`rf`, `xgboost`, `svm`, `KNN`) and three built-in metrics (RMSE, Kappa, LogLoss), but both can be replaced: - `metricfunc`: keep a built-in learner, but measure performance with your own metric. - `mlfunc`: replace both the learner and the metric with your own function. In both cases you must tell CCI which direction is "better" with the `tail` argument: - `tail = "left"` if **lower** values mean better predictions (errors and losses, like RMSE); - `tail = "right"` if **higher** values mean better predictions (like $R^2$ or accuracy). We use the same kind of data as in `vignette("Testing-CI-with-CCI", package = "CCI")`, where $Y \perp\!\!\!\perp X \mid Z_1, Z_2$ is true and $Y \perp\!\!\!\perp X \mid Z_1$ is false. ```{r} 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(1) dat <- normal_data(500) ``` # Custom performance metrics with `metricfunc` A metric function takes the observed values of the test data and the model's predictions, and returns a single number: ```{r, eval = FALSE} my_metric <- function(actual, predictions) { # compute and return one number } ``` It may also have a `...` argument, in which case additional arguments given to `CCI.test()` are passed on to it. What `actual` and `predictions` contain depends on the learner and on the type of $Y$: | `method` | Numeric $Y$ (regression) | Categorical $Y$ (classification) | |---|---|---| | `"rf"`, `"svm"`, `"KNN"` | numeric, numeric predictions | factor, predicted classes (factor) | | `"xgboost"` | numeric, numeric predictions | factor, class probabilities: the probability of the second class for two classes, an $n \times K$ matrix with the classes as column names for more | ## Regression: $R^2$ $R^2$ is higher for better predictions, so `tail = "right"`: ```{r} r_squared <- function(actual, predictions) { 1 - sum((actual - predictions)^2) / sum((actual - mean(actual))^2) } res_r2 <- CCI.test(Y ~ X | Z1, data = dat, metricfunc = r_squared, tail = "right", seed = 1, progress = FALSE) summary(res_r2) ``` The summary shows the name of the metric function. The mean absolute error (MAE) is lower for better predictions, so `tail = "left"`. Here with the KNN learner: ```{r} mae <- function(actual, predictions) mean(abs(actual - predictions)) summary(CCI.test(Y ~ X | Z1 + Z2, data = dat, method = "KNN", metricfunc = mae, tail = "left", seed = 1, progress = FALSE)) ``` ## Classification: balanced accuracy and Brier score With a categorical $Y$, the built-in learners except xgboost give the predicted classes. Balanced accuracy, the average share of correct predictions within each class, is robust to unequal class sizes: ```{r} set.seed(2) cat_data <- normal_data(500) cat_data$Y <- factor(ifelse(cat_data$Y > 1, "high", "low")) # unequal class sizes table(cat_data$Y) balanced_accuracy <- function(actual, predictions) { mean(tapply(as.character(predictions) == as.character(actual), actual, mean)) } summary(CCI.test(Y ~ X | Z1, data = cat_data, metricfunc = balanced_accuracy, tail = "right", seed = 1, progress = FALSE)) ``` xgboost gives class probabilities instead. For two classes, `predictions` is the probability of the second class level (`"low"` here), which allows metrics like the Brier score (lower is better): ```{r} brier <- function(actual, predictions) { mean((as.numeric(actual == levels(actual)[2]) - predictions)^2) } summary(CCI.test(Y ~ X | Z1, data = cat_data, method = "xgboost", nrounds = 100, eta = 0.1, metricfunc = brier, tail = "left", seed = 1, progress = FALSE)) ``` # Custom learners with `mlfunc` An `mlfunc` function trains a model on the training rows, predicts the test rows, and returns the performance as a single number. It must have these arguments: - `formula`: a regression formula `Y ~ X + Z1 + ...` (the `|` is replaced by `+`); - `data`: the data, with $X$ permuted when the null distribution is built; - `train_indices` and `test_indices`: the rows to train on and to evaluate on; - `...`: additional arguments given to `CCI.test()`, e.g. tuning parameters for the model. The general structure is: ```{r, eval = FALSE} my_wrapper <- function(formula, data, train_indices, test_indices, ...) { model <- train_model(formula, data = data[train_indices, ], ...) predictions <- predict(model, data[test_indices, ]) actual <- data[test_indices, all.vars(formula)[1]] compute_metric(actual, predictions) } ``` The formula and data include the polynomial and interaction terms that `CCI.test()` adds to $Z$ (see `poly` and `interaction`). Set `poly = FALSE` and `interaction = FALSE` if your model should only see the original variables. ## A linear model With a linear regression as learner, the test compares how well a linear model predicts $Y$ with and without the real $X$. With the polynomial and interaction terms of $Z$, this is a flexible and very fast test: ```{r} lm_wrapper <- function(formula, data, train_indices, test_indices, ...) { model <- lm(formula, data = data[train_indices, ]) predictions <- predict(model, newdata = data[test_indices, ]) actual <- data[test_indices, all.vars(formula)[1]] sqrt(mean((actual - predictions)^2)) # RMSE: lower is better } summary(CCI.test(Y ~ X | Z1 + Z2, data = dat, mlfunc = lm_wrapper, tail = "left", seed = 1, progress = FALSE)) summary(CCI.test(Y ~ X | Z1, data = dat, mlfunc = lm_wrapper, tail = "left", seed = 1, progress = FALSE)) ``` ## Logistic regression with extra arguments Arguments that `CCI.test()` does not know are passed on to `mlfunc` through `...`. Here a logistic regression returns the log loss of the predicted probabilities, and the constant used to keep the probabilities away from 0 and 1 is given as an argument: ```{r} logistic_wrapper <- function(formula, data, train_indices, test_indices, clip = 1e-6, ...) { model <- glm(formula, data = data[train_indices, ], family = binomial) prob <- predict(model, newdata = data[test_indices, ], type = "response") prob <- pmin(pmax(prob, clip), 1 - clip) actual <- data[test_indices, all.vars(formula)[1]] is_second <- actual == levels(actual)[2] # glm models the probability of the second level -mean(ifelse(is_second, log(prob), log(1 - prob))) # log loss: lower is better } summary(CCI.test(Y ~ X | Z1, data = cat_data, mlfunc = logistic_wrapper, tail = "left", clip = 1e-4, poly = FALSE, interaction = FALSE, seed = 1, progress = FALSE)) ``` Probability-based metrics like the log loss usually give more power than the share of correct classifications, since they use how confident the predictions are. ## Any model from caret The caret package gives a common interface to more than 200 models, which makes a general wrapper easy. The caret method and model parameters are given as arguments: ```{r} caret_wrapper <- function(formula, data, train_indices, test_indices, caret_method, ...) { model <- caret::train(formula, data = data[train_indices, ], method = caret_method, trControl = caret::trainControl(method = "none"), ...) predictions <- predict(model, newdata = data[test_indices, ]) actual <- data[test_indices, all.vars(formula)[1]] sqrt(mean((actual - predictions)^2)) } summary(CCI.test(Y ~ X | Z1, data = dat, mlfunc = caret_wrapper, tail = "left", caret_method = "knn", tuneGrid = data.frame(k = 15), seed = 1, progress = FALSE)) ``` # Tips - Return a single number. If a model fails to fit in a Monte Carlo sample, that sample is left out with a warning, so errors in your function show up as warnings about missing values in the null distribution. - Always set `tail`. `CCI.test()` stops with an error if a `metricfunc` or `mlfunc` is given without it. - The functions are stored in the result, so `QQplot()` repeats the test with the same custom model and metric. - Use `seed` in `CCI.test()` for reproducible results. ```{r} QQplot(res_r2, nperm = 40, progress = FALSE) ```