library(xgboost)
library(data.table)

set.seed(1024)

# Function to obtain a list of interactions fitted in trees, requires input of maximum depth
treeInteractions <- function(input_tree, input_max_depth) {
  ID_merge <- i.id <- i.feature <- NULL  # Suppress warning "no visible binding for global variable"

  trees <- data.table::copy(input_tree)  # copy tree input to prevent overwriting
  if (input_max_depth < 2) return(list())  # no interactions if max depth < 2
  if (nrow(input_tree) == 1) return(list())

  # Attach parent nodes
  for (i in 2:input_max_depth) {
    if (i == 2) trees[, ID_merge := ID] else trees[, ID_merge := get(paste0('parent_', i - 2))]
    parents_left <- trees[!is.na(Split), list(i.id = ID, i.feature = Feature, ID_merge = Yes)]
    parents_right <- trees[!is.na(Split), list(i.id = ID, i.feature = Feature, ID_merge = No)]

    data.table::setorderv(trees, 'ID_merge')
    data.table::setorderv(parents_left, 'ID_merge')
    data.table::setorderv(parents_right, 'ID_merge')

    trees <- merge(trees, parents_left, by = 'ID_merge', all.x = TRUE)
    trees[!is.na(i.id), c(paste0('parent_', i - 1), paste0('parent_feat_', i - 1))
          := list(i.id, i.feature)]
    trees[, c('i.id', 'i.feature') := NULL]

    trees <- merge(trees, parents_right, by = 'ID_merge', all.x = TRUE)
    trees[!is.na(i.id), c(paste0('parent_', i - 1), paste0('parent_feat_', i - 1))
          := list(i.id, i.feature)]
    trees[, c('i.id', 'i.feature') := NULL]
  }

  # Extract nodes with interactions
  interaction_trees <- trees[!is.na(Split) & !is.na(parent_1),
                             c('Feature', paste0('parent_feat_', 1:(input_max_depth - 1))),
                             with = FALSE]
  interaction_trees_split <- split(interaction_trees, seq_len(nrow(interaction_trees)))
  interaction_list <- lapply(interaction_trees_split, as.character)

  # Remove NAs (no parent interaction)
  interaction_list <- lapply(interaction_list, function(x) x[!is.na(x)])

  # Remove non-interactions (same variable)
  interaction_list <- lapply(interaction_list, unique)  # remove same variables
  interaction_length <- sapply(interaction_list, length)
  interaction_list <- interaction_list[interaction_length > 1]
  interaction_list <- unique(lapply(interaction_list, sort))
  return(interaction_list)
}

# Generate sample data
x <- list()
for (i in 1:10) {
  x[[i]] <- i * rnorm(1000, 10)
}
x <- as.data.table(x)

y <- -1 * x[, rowSums(.SD)] + x[['V1']] * x[['V2']] + x[['V3']] * x[['V4']] * x[['V5']]
     + rnorm(1000, 0.001) + 3 * sin(x[['V7']])

train <- as.matrix(x)

# Interaction constraint list (column names form)
interaction_list <- list(c('V1', 'V2'), c('V3', 'V4', 'V5'))

# Convert interaction constraint list into feature index form
cols2ids <- function(object, col_names) {
  LUT <- seq_along(col_names) - 1
  names(LUT) <- col_names
  rapply(object, function(x) LUT[x], classes = "character", how = "replace")
}
interaction_list_fid <- cols2ids(interaction_list, colnames(train))

# Fit model with interaction constraints
bst <- xgboost(data = train, label = y, max_depth = 4,
               eta = 0.1, nthread = 2, nrounds = 1000,
               interaction_constraints = interaction_list_fid)

bst_tree <- xgb.model.dt.tree(colnames(train), bst)
bst_interactions <- treeInteractions(bst_tree, 4)
  # interactions constrained to combinations of V1*V2 and V3*V4*V5

# Fit model without interaction constraints
bst2 <- xgboost(data = train, label = y, max_depth = 4,
                eta = 0.1, nthread = 2, nrounds = 1000)

bst2_tree <- xgb.model.dt.tree(colnames(train), bst2)
bst2_interactions <- treeInteractions(bst2_tree, 4)  # much more interactions

# Fit model with both interaction and monotonicity constraints
bst3 <- xgboost(data = train, label = y, max_depth = 4,
                eta = 0.1, nthread = 2, nrounds = 1000,
                interaction_constraints = interaction_list_fid,
                monotone_constraints = c(-1, 0, 0, 0, 0, 0, 0, 0, 0, 0))

bst3_tree <- xgb.model.dt.tree(colnames(train), bst3)
bst3_interactions <- treeInteractions(bst3_tree, 4)
  # interactions still constrained to combinations of V1*V2 and V3*V4*V5

# Show monotonic constraints still apply by checking scores after incrementing V1
x1 <- sort(unique(x[['V1']]))
for (i in seq_along(x1)){
  testdata <- copy(x[, - ('V1')])
  testdata[['V1']] <- x1[i]
  testdata <- testdata[, paste0('V', 1:10), with = FALSE]
  pred <- predict(bst3, as.matrix(testdata))

  # Should not print out anything due to monotonic constraints
  if (i > 1) if (any(pred > prev_pred)) print(i)
  prev_pred <- pred
}
