Joint effects and interactions between management actions"

knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(multiscape)

Supply the total effect of executing a combination in the same call as its individual effects. The package preserves those totals and calculates signed interaction corrections, rather than attributing portions to individual actions.

base <- create_problem(
  data.frame(id = 10L, cost = 0), data.frame(id = 1L, name = "habitat"),
  data.frame(pu = 10L, feature = 1L, amount = 100)
) |>
  add_actions(data.frame(id = c("restore", "control")), cost = 1) |>
  add_action_sets(data.frame(set = c("restore_control", "restore_control"),
                             action = c("restore", "control"))) |>
  add_constraint_action_cardinality(2, "max")
effects <- data.frame(action = c("restore", "control", "restore_control"),
                      feature = "habitat", effect = c(30, 20, 70))
problem <- add_effects(base, effects)
problem$data$effect_terms[, c("action", "total_effect", "effect")]

The pair correction is 70 - 30 - 20 = 20. Selecting both yields 70, while restoration alone yields 30. Registration and effects do not force selection; cardinality controls capacity and logical relations control permitted groups.

The modern action field accepts atomic IDs or registered set IDs. effect, outcome, and relative_change keep their meanings, sharing the same reference:

outcomes <- data.frame(action = effects$action, feature = "habitat", outcome = c(130, 120, 170))
changes <- data.frame(action = effects$action, feature = "habitat", relative_change = c(.3, .2, .7))
outcome_problem <- add_effects(base, outcomes)
relative_problem <- add_effects(base, changes)
stopifnot(isTRUE(all.equal(problem$data$effect_terms, outcome_problem$data$effect_terms)),
          isTRUE(all.equal(problem$data$effect_terms, relative_problem$data$effect_terms)))

Modern raster lists can likewise include names of registered sets, with the same layer order, aggregation, reference, and interpretation rules as individual rasters. Legacy component filtering is rejected for sets. With no pu, a joint row expands only over units where every member has an available pair. Explicit joint rows with unavailable members are rejected. Later locks or incompatible constraints can make a supplied combination unreachable without removing its original observation.

Preserve inputs and record assumptions

problem$data$effects_input
problem$data$joint_effects[, c("action", "effect", "action_outcome")]
problem$data$effects_meta[c("missing_interactions", "missing_individual_effects")]

effects_input preserves the original table; effects_original preserves all canonical totals; joint_effects contains supplied set totals; effect_terms contains signed corrections. dist_effects retains individual rows for compatibility. Different set names cannot provide duplicate membership totals for one PU/feature. Zero corrections remain preserved as input observations.

Unspecified individual effects and interactions are assumed zero. This is an explicit model assumption, not evidence that an unobserved interaction is absent. For three actions, subtract individual and supplied pair corrections:

$$\delta_{ABC}=E(ABC)-E(A)-E(B)-E(C)-\delta_{AB}-\delta_{AC}-\delta_{BC}.$$

The rule extends to larger sets. No unprovided subsets are generated automatically.

Exact activation and model size

For set $S$, compilation adds a continuous auxiliary satisfying:

$$0\le y_{i,S}\le1,\quad y_{i,S}\le x_{i,a}\ (a\in S),\quad y_{i,S}\ge\sum_{a\in S}x_{i,a}-|S|+1.$$

Binary member decisions determine it exactly, even with negative coefficients or economic objectives. One PU/membership variable is shared across features. Only reachable, non-zero corrections need auxiliaries. Availability, known cardinality limits, and exclusions can eliminate combinations. Inferred negative final feature amounts are excluded through linear outcome constraints.

Ten actions have 1,013 subsets of size two or greater, but only non-zero, reachable corrections contribute variables. Sparse interactions limit growth; solution time still depends on the full model and the number of MO runs.

Solve with all three MO methods

The benefit objective maximizes signed change, including all individual contributions and the pair correction. Each method selects both actions, with benefit 70 and action cost 2.

mo_base <- problem |>
  add_constraint_action_cardinality(1, "min") |>
  add_objective_min_cost(alias = "cost", include_pu_cost = FALSE) |>
  add_objective_max_effect(alias = "benefit") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE)
methods <- list(
  weighted = set_method_weighted_sum(mo_base, aliases = c("cost", "benefit"),
    normalize_weights = FALSE, runs = set_runs_manual(data.frame(weight_cost = 1, weight_benefit = 1))),
  epsilon = set_method_epsilon_constraint(mo_base, primary = "cost", aliases = c("cost", "benefit"),
    runs = set_runs_manual(data.frame(eps_benefit = 69))),
  augmecon = set_method_augmecon(mo_base, primary = "cost", aliases = c("cost", "benefit"),
    runs = set_runs_manual(data.frame(eps_benefit = 69)))
)
solutions <- lapply(methods, solve)
lapply(solutions, get_objectives)
for (solution in solutions) {
  objective <- get_objectives(solution)
  feature <- get_features(solution)
  stopifnot(objective$cost == 2, objective$benefit == 70,
            feature$selected_baseline == 100, feature$selected_amount_after == 170)
}

Negative interactions, net benefit, and final loss

negative_input <- effects
negative_input$effect[negative_input$action == "restore_control"] <- 40
negative <- add_effects(base, negative_input)
negative$data$effect_terms[, c("action", "total_effect", "effect")]
economic <- negative |>
  add_profit(c(restore = 10, control = 9)) |>
  add_objective_max_profit(alias = "profit") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE)
solution <- solve(economic)
get_features(solution)
stopifnot(get_features(solution)$selected_net == 40,
          get_features(solution)$selected_benefit == 40,
          get_features(solution)$selected_loss == 0,
          get_features(solution)$selected_amount_after == 140)

The correction is -10, but the final change is +40. The summary reports benefit 40 and loss zero, counting the selected reference once. It does not classify the negative correction as an ecological loss by itself.

add_objective_max_effect() now optimizes that signed net change. Earlier versions counted positive effects only; models with negative effects can change their solutions. No separate net-benefit function is needed.

benefit_problem <- negative |> add_objective_max_effect(alias = "benefit")
loss_problem <- negative |>
  add_constraint_action_cardinality(2, "equal") |>
  add_objective_min_loss(alias = "loss")
benefit_solution <- solve(set_solver_cbc(benefit_problem, gap_limit = 0, verbose = FALSE))
loss_solution <- solve(set_solver_cbc(loss_problem, gap_limit = 0, verbose = FALSE))
stopifnot(get_objectives(benefit_solution)$benefit == 40,
          get_objectives(loss_solution)$loss == 0)

For signed change $\Delta_{if}$ after combining scoped actions, the two criteria are $\max \sum_{i,f}\Delta_{if}$ and $\min \sum_{i,f}\max(-\Delta_{if},0)$. Gains can offset deterioration in the benefit objective. In the loss objective, gains in one unit or feature cannot cancel losses in another. Both retain independent action and feature scopes. An interaction belongs to an action scope only if every member belongs to it.

Loss is linearized exactly using finite bounds derived from the signed coefficients. Mixed-sign PU/feature expressions require one continuous loss variable and one binary sign variable; sign-compatible expressions need no such auxiliaries. The constraints make the loss exact even if it is a secondary objective or has zero weight. All required scoped expressions are prepared in the common model used by weighted-sum, epsilon-constraint, and AUGMECON.

deteriorating_input <- effects
deteriorating_input$effect[deteriorating_input$action == "restore_control"] <- -10
deteriorating <- add_effects(base, deteriorating_input) |>
  add_constraint_action_cardinality(2, "equal")
net_solution <- deteriorating |> add_objective_max_effect(alias = "benefit") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE) |> solve()
loss_solution <- deteriorating |> add_objective_min_loss(alias = "loss") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE) |> solve()
stopifnot(get_objectives(net_solution)$benefit == -10,
          get_objectives(loss_solution)$loss == 10,
          get_features(loss_solution)$selected_amount_after == 90)

The pair correction is now -60; the objective counts the final change of -10, and the final loss is 10. It does not count the correction's magnitude as loss.

Targets on joint outcomes

Targets require the reference plus the joint change. With reference 100 and joint change +40, selecting both actions contributes 140, rather than adding the two individual post-action amounts (130 + 120). Action-scoped targets count the reference only when at least one scoped action is selected in the unit.

target_problem <- negative |>
  add_constraint_targets_absolute(140) |>
  add_objective_min_cost(alias = "cost")
target_solution <- solve(set_solver_cbc(target_problem, gap_limit = 0, verbose = FALSE))
get_targets(target_solution)
stopifnot(get_objectives(target_solution)$cost == 2,
          get_features(target_solution)$selected_amount_after == 140,
          get_targets(target_solution)$achieved == 140)


Try the multiscape package in your browser

Any scripts or data that you put into this service are public.

multiscape documentation built on Oct. 12, 2026, 5:08 p.m.