Checking a sequential decision model

R
conservation
decision analysis
ecology tutorial
ggplot2
Four checks on a sequential conservation decision in R: the discount rate, the state grid, the assumed transition matrix and the end of the planning horizon.
Author

Tidy Ecology

Published

2026-07-21

A lowland reserve has an invasive shrub in it. The warden has an annual budget, two ways of spending part of it on the shrub (a volunteer work party with hand tools, or a contractor with machinery and herbicide), and the option of spending nothing. The shrub spreads on its own, so whatever is left standing this year is a larger problem next year and whatever is cleared is a smaller one. Sooner or later somebody runs a dynamic programme over that, and what comes back is not a decision. It is a rule: a table saying what to do at every level of cover the reserve could ever be in, for ever.

A rule looks more authoritative than the consequence table it grew out of. It is longer, it came out of an optimiser, and it arrives with a number attached for the expected cost of the entire future. This post measures how much of that survives four checks: moving the discount rate, changing the resolution of the state grid, replacing the assumed transition matrix with one fitted to a finite management record, and cutting the infinite horizon down to a planning period. All four run on one system, built here so that the right answer is known throughout, and each one ends in a cost.

Checking a decision analysis does this job for a consequence table, where the object under test is a single choice with fixed consequences and the failure modes are weights, scales and a missing alternative. The difference here is the one that defines this cluster of posts: the state moves whether or not anybody acts, and this year’s action changes next year’s state. That is what turns the output into a rule, and it is what makes a rule harder to check, because a rule is judged over a future that the rule itself is busy creating.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"))
}

A reserve with an invasive shrub

The state is the fraction of the reserve under the shrub, between nothing and everything. Three actions are available each year. Doing nothing costs nothing. A volunteer work party can clear up to five per cent of the reserve in a season, at ninety thousand pounds per unit of reserve area cleared. A contractor can clear up to twelve per cent, at two hundred thousand per unit area, because machinery, herbicide and follow-up visits cost more per hectare than volunteers do. Both crews are capped by how much ground they can cover, not by how much shrub there is, which is the feature that makes the problem interesting.

Within a year, three things happen in order. The crew clears what it can, so the cover left standing is what was there minus the smaller of the crew’s capacity and the cover itself. What is left spreads logistically, fastest when the reserve is half occupied and slowest when it is nearly empty or nearly full. A seed rain from the hedgerows outside adds a small amount in proportion to the space available, which means the shrub is never quite gone. The realised cover is that median value multiplied by a lognormal draw with a log standard deviation of a quarter, capped at one.

Costs are annual. Damage runs at twenty five thousand pounds a year when the reserve is fully occupied and in proportion below that. Treatment costs the crew’s rate times the area actually cleared. Future costs are discounted at a factor of 0.95 per year, which is a discount rate of 5.2632 per cent, and check 1 is about that number.

None of the transition model is estimated. The spread rate, the seed rain and the variability are stated, and the transition matrix is built from them. That is the normal situation and it is the subject of check 3.

spread_rate <- 0.7
seed_rain   <- 0.004
log_sd      <- 0.25
damage_full <- 25
dfac        <- 0.95

act_name <- c("No action", "Hand pulling", "Contract control")
act_cap  <- c(0.00, 0.05, 0.12)
act_rate <- c(0, 90, 200)

med_map <- function(x, a, rr = spread_rate, mm = seed_rain) {
  y <- x - pmin(act_cap[a], x)
  y + rr * y * (1 - y) + mm * (1 - y)
}
stage_cost <- function(x, a) damage_full * x + act_rate[a] * pmin(act_cap[a], x)
nodes_of <- function(n) seq(0, 1, length.out = n)

build_P <- function(n, rr = spread_rate, ss = log_sd, mm = seed_rain) {
  xs <- nodes_of(n); hh <- 1 / (n - 1)
  edg <- log(c(xs[-1] - hh / 2, Inf))
  lapply(seq_along(act_name), function(a) {
    lz <- log(med_map(xs, a, rr, mm))
    cp <- pnorm(t(outer(edg, lz, "-")) / ss)
    pm <- cbind(cp[, 1], cp[, -1] - cp[, -ncol(cp)])
    pm[pm < 0] <- 0
    pm / rowSums(pm)
  })
}
cost_of <- function(n) sapply(seq_along(act_name), function(a) stage_cost(nodes_of(n), a))

eval_pol <- function(pl, pol, cc, gg = dfac) {
  n <- nrow(cc); pp <- matrix(0, n, n)
  for (a in seq_along(act_name)) {
    ii <- which(pol == a)
    if (length(ii)) pp[ii, ] <- pl[[a]][ii, , drop = FALSE]
  }
  solve(diag(n) - gg * pp, cc[cbind(seq_len(n), pol)])
}
solve_mdp <- function(pl, cc, gg = dfac) {
  n <- nrow(cc); pol <- rep(1L, n)
  for (it in 1:300) {
    vv <- eval_pol(pl, pol, cc, gg)
    qq <- sapply(seq_along(act_name), function(a) cc[, a] + gg * (pl[[a]] %*% vv))
    np <- max.col(-qq, ties.method = "first")
    if (all(np == pol)) return(list(pol = pol, v = vv, iter = it))
    pol <- np
  }
  list(pol = pol, v = eval_pol(pl, pol, cc, gg), iter = NA_integer_)
}
bounds_of <- function(pol, xs) {
  k <- which(diff(pol) != 0)
  if (!length(k)) return(numeric(0))
  round((xs[k] + xs[k + 1]) / 2, 4)
}
hold_line <- function(u) {
  bb <- -(spread_rate - seed_rain)
  yy <- (-bb - sqrt(bb^2 - 4 * spread_rate * (u - seed_rain))) / (2 * spread_rate)
  yy + u
}

round(c(spread_rate = spread_rate, seed_rain = seed_rain, log_sd = log_sd,
        damage_at_full_cover = damage_full, discount_factor = dfac,
        discount_rate_per_cent = 100 * (1 / dfac - 1)), 4)
           spread_rate              seed_rain                 log_sd 
                0.7000                 0.0040                 0.2500 
  damage_at_full_cover        discount_factor discount_rate_per_cent 
               25.0000                 0.9500                 5.2632 
print(data.frame(action = act_name, area_cap = act_cap,
                 area_cap_per_cent = 100 * act_cap, cost_per_unit_area = act_rate,
                 cost_at_cap = act_rate * act_cap), row.names = FALSE)
           action area_cap area_cap_per_cent cost_per_unit_area cost_at_cap
        No action     0.00                 0                  0         0.0
     Hand pulling     0.05                 5                 90         4.5
 Contract control     0.12                12                200        24.0
round(c(hand_pulling_holds_below = hold_line(act_cap[2]),
        contract_holds_below = hold_line(act_cap[3])), 4)
hand_pulling_holds_below     contract_holds_below 
                  0.1212                   0.3318 

The last two numbers come from the deterministic part of the model alone and they set the scene for everything that follows. Setting next year’s median cover equal to this year’s gives a quadratic whose lower root is the highest cover at which a crew can hold the line. Hand pulling holds below a cover of 0.1212 and loses above it. Contract control holds below 0.3318. Above a third of the reserve, no action in the set can stop the advance, and the only question left is whether it is worth paying to slow it down.

The rule the optimiser returns

The continuous problem is solved on a grid of 801 evenly spaced covers by policy iteration: evaluate the current rule exactly by solving the linear system for its value, take the greedy improvement, repeat. Value iteration on the same problem is run afterwards as a check, since the two are different algorithms with different failure modes and they should land on the same value function.

n_ref <- 801
p_ref <- build_P(n_ref); c_ref <- cost_of(n_ref); x_ref <- nodes_of(n_ref)
ref <- solve_mdp(p_ref, c_ref)
kk <- which(diff(ref$pol) != 0)
print(data.frame(from = act_name[ref$pol[kk]], to = act_name[ref$pol[kk + 1]],
                 at_cover = bounds_of(ref$pol, x_ref)), row.names = FALSE)
             from               to at_cover
        No action     Hand pulling   0.0006
     Hand pulling Contract control   0.0981
 Contract control     Hand pulling   0.4469
     Hand pulling        No action   0.4894
round(c(cost_at_0 = ref$v[1], cost_at_0.10 = approx(x_ref, ref$v, 0.10)$y,
        cost_at_0.30 = approx(x_ref, ref$v, 0.30)$y,
        cost_at_0.50 = approx(x_ref, ref$v, 0.50)$y,
        cost_at_1.00 = ref$v[n_ref]), 3)
   cost_at_0 cost_at_0.10 cost_at_0.30 cost_at_0.50 cost_at_1.00 
       9.016       31.516      240.710      420.014      443.014 
lo_ref <- x_ref <= 0.30
round(c(discovery_states = sum(lo_ref), mean_cost_over_discovery = mean(ref$v[lo_ref])), 3)
        discovery_states mean_cost_over_discovery 
                 241.000                   69.023 
vv <- rep(0, n_ref)
for (it in 1:4000) {
  qq <- sapply(seq_along(act_name), function(a) c_ref[, a] + dfac * (p_ref[[a]] %*% vv))
  nv <- do.call(pmin, split(qq, col(qq)))
  if (max(abs(nv - vv)) < 1e-12) break
  vv <- nv
}
c(value_iterations = it, policy_iterations = ref$iter)
 value_iterations policy_iterations 
              602                 6 
signif(c(gap_value_iteration_vs_direct_solve = max(abs(vv - ref$v))), 3)
gap_value_iteration_vs_direct_solve 
                           1.67e-11 

The rule has four boundaries. Below a cover of 0.0006 there is nothing worth sending anybody to, which on this grid means the empty state and nothing else. From there up to 0.0981 the volunteers go out. From 0.0981 to 0.4469 the contractor is called, because the volunteers’ five per cent ceiling is no longer enough to push the cover down. From 0.4469 to 0.4894 the contractor is stood down and the volunteers go back out, and above 0.4894 nothing is done at all.

That third band is easy to misread as a numerical wobble, and it is not. Above 0.3318 the contractor cannot hold the line either, so the expensive option stops buying containment and buys only a slower advance. The rule keeps paying for the slower advance up to 0.4469 and then decides the contractor is not worth it, while the cheap crew still is, until the cover reaches 0.4894 and the reserve is written off. The economic abandonment point therefore sits well above the physical one, which is the sort of thing a dynamic programme is genuinely good for and a spreadsheet never says.

The expected discounted costs run from 9.016 thousand pounds at zero cover, which is not zero because the seed rain never stops, through 31.516 at a tenth of the reserve, 240.710 at three tenths, 420.014 at a half and 443.014 at full cover. Policy iteration got there in 6 sweeps and value iteration in 602, and the largest disagreement between the two value functions across the 801 states is 1.68e-11 thousand pounds. The arithmetic is fine. Everything that follows is about the model, not the arithmetic.

One convention for the rest of the post. A rule is scored by the expected discounted cost of following it from a cover drawn uniformly from the 241 reference states at or below 0.30, which is the range within which the contractor can still hold the line and therefore the range in which the decision is live. Under the reference rule that average is 69.023 thousand pounds, and every loss below is the extra cost against that number.

Check 1: the discount rate

The discount factor is the only quantity in the model that is not about the shrub. It says how much a pound of damage in twenty years’ time is worth today, and no survey of the reserve will ever pin it down. The check sweeps it from 0.80 to 0.995 on a working grid of 161 states, records where the rule changes, and then asks the more useful question: what does it cost to act on the wrong one?

n_w <- 161
p_w <- build_P(n_w); c_w <- cost_of(n_w); x_w <- nodes_of(n_w)
base <- solve_mdp(p_w, c_w, dfac)
lo_w <- x_w <= 0.30
loss_w <- function(pol, ref_v = base$v, gg = dfac) {
  vu <- eval_pol(p_w, pol, c_w, gg)
  c(extra_cost = mean(vu[lo_w]) - mean(ref_v[lo_w]),
    extra_pct = 100 * (mean(vu[lo_w]) - mean(ref_v[lo_w])) / mean(ref_v[lo_w]))
}
g_seq <- seq(0.80, 0.995, length.out = 40)
pol_g <- lapply(g_seq, function(g) solve_mdp(p_w, c_w, g)$pol)
give_up <- sapply(pol_g, function(p) {
  k <- which(p != 1); if (!length(k)) 0 else (x_w[max(k)] + x_w[max(k) + 1]) / 2 })
enter_hc <- sapply(pol_g, function(p) {
  k <- which(p == 3); if (!length(k)) NA_real_ else (x_w[min(k)] + x_w[min(k) - 1]) / 2 })
sel <- c(1, 21, 40)
print(data.frame(discount_factor = round(g_seq[sel], 3),
                 discount_rate_per_cent = round(100 * (1 / g_seq[sel] - 1), 2),
                 hand_to_contract = round(enter_hc[sel], 4),
                 abandon_above = round(give_up[sel], 4)), row.names = FALSE)
 discount_factor discount_rate_per_cent hand_to_contract abandon_above
           0.800                  25.00           0.1156        0.2656
           0.900                  11.11           0.1031        0.3844
           0.995                   0.50           0.0969        0.8156
round(c(abandon_lowest = min(give_up), abandon_highest = max(give_up),
        abandon_range = diff(range(give_up)),
        entry_range = diff(range(enter_hc, na.rm = TRUE))), 4)
 abandon_lowest abandon_highest   abandon_range     entry_range 
         0.2656          0.8156          0.5500          0.0188 
n_diff_90 <- sum(solve_mdp(p_w, c_w, 0.90)$pol != base$pol)
n_diff_99 <- sum(solve_mdp(p_w, c_w, 0.99)$pol != base$pol)
c(grid_states = n_w, discount_factors_swept = length(g_seq),
  differ_from_0.95_at_0.90 = n_diff_90, differ_from_0.95_at_0.99 = n_diff_99)
             grid_states   discount_factors_swept differ_from_0.95_at_0.90 
                     161                       40                       23 
differ_from_0.95_at_0.99 
                      46 
round(c(per_cent_of_states_differing_at_0.90 = 100 * n_diff_90 / n_w,
        per_cent_of_states_differing_at_0.99 = 100 * n_diff_99 / n_w), 1)
per_cent_of_states_differing_at_0.90 per_cent_of_states_differing_at_0.99 
                                14.3                                 28.6 
cross <- function(g_used, g_true) {
  pu <- solve_mdp(p_w, c_w, g_used)$pol
  loss_w(pu, solve_mdp(p_w, c_w, g_true)$v, g_true)
}
print(round(rbind("0.90 policy judged at 0.99" = cross(0.90, 0.99),
                  "0.99 policy judged at 0.90" = cross(0.99, 0.90),
                  "0.90 policy judged at 0.95" = cross(0.90, 0.95),
                  "0.99 policy judged at 0.95" = cross(0.99, 0.95)), 3))
                           extra_cost extra_pct
0.90 policy judged at 0.99     29.879    18.742
0.99 policy judged at 0.90      1.675     3.175
0.90 policy judged at 0.95      1.069     1.558
0.99 policy judged at 0.95      1.473     2.149
act_col <- c("No action" = te_pal$sage, "Hand pulling" = te_pal$gold,
             "Contract control" = te_pal$forest)
gmap <- data.frame(
  dfactor = rep(g_seq, each = n_w),
  cover = rep(x_w, length(g_seq)),
  action = factor(act_name[unlist(pol_g)], levels = act_name))
ggplot(gmap, aes(dfactor, cover, fill = action)) +
  geom_raster() +
  scale_fill_manual(values = act_col, name = NULL) +
  scale_x_continuous(expand = c(0, 0)) +
  scale_y_continuous(expand = c(0, 0)) +
  labs(x = "Discount factor per year", y = "Cover of the invasive shrub",
       title = "The discount rate decides when to give up, not when to start") +
  theme_te() +
  theme(legend.position = "top")
A filled map with the discount factor from 0.80 to 0.995 along the horizontal axis and invasive cover from zero to one on the vertical axis. Three bands of colour run left to right. A thin band of hand pulling hugs the bottom, a thick band of contract control sits above it with an almost flat lower edge near a tenth cover, a second thin band of hand pulling runs along the top of that region, and a large no action region occupies the rest. The boundary between contract control and no action rises steeply from about a quarter of the reserve on the left to about four fifths on the right.
Figure 1: The optimal action at every combination of discount factor and invasive cover, on a grid of 161 covers and 40 discount factors. The lower boundary between the two treatments barely moves; the upper boundary, where treatment stops altogether, climbs from a quarter of the reserve to four fifths of it.

The lower boundary is almost immovable. Across the whole sweep the cover at which the contractor replaces the volunteers travels 0.0188, from 0.1156 at a discount factor of 0.80 to 0.0969 at 0.995. Whether you are patient or impatient, an infestation on a tenth of the reserve gets the contractor. The upper boundary travels 0.5500 over the same sweep, from 0.2656 to 0.8156. At a discount factor of 0.80, which is a discount rate of 25 per cent a year, the reserve is written off once the shrub passes a quarter of it. At 0.995 the rule is still sending crews out when four fifths of the reserve is gone.

Of the 161 states, 23 get a different action at a discount factor of 0.90 than at 0.95, and 46 do at 0.99. That is 14.3 and 28.6 per cent of the state space, which sounds like a mild sensitivity until the rules are priced against each other. Handing an impatient rule to a patient owner is the expensive direction: the 0.90 rule, judged by an objective with a discount factor of 0.99, costs 29.879 thousand pounds more than that objective’s own rule, which is 18.742 per cent. The reverse mistake, a patient rule judged by an impatient objective, costs 1.675 thousand or 3.175 per cent. Against the working objective of 0.95, the two wrong rules cost 1.558 and 2.149 per cent.

The asymmetry is worth carrying. Over-treating in the early years is a bounded error, because you are spending real money on a real problem slightly too early. Under-treating is not bounded, because the shrub goes on compounding while you fail to act, and by the time the mistake shows up the cover is past the point where any crew can hold the line. If the discount rate is uncertain, the cheaper direction to be wrong in is the patient one.

None of this is a calibration problem. Nobody can go out and measure the discount rate on a nature reserve. It encodes how a public body weighs a habitat now against the same habitat in thirty years, which is a question for the people who own the decision, and the honest way to present a rule like this one is with the discount factor written on the front of it rather than buried in the code.

Check 2: the state grid

The shrub’s cover is a continuous quantity and the model is a finite matrix, so somebody chose a grid. That choice is usually made on grounds of run time and reported nowhere. The check solves the same continuous problem on grids of 11, 41, 161 and 801 states, treats the 801 state answer as the truth, and evaluates each coarse rule against it by reading the coarse rule as a step function of cover and pricing it on the fine model.

grid_set <- c(11, 41, 161, 801)
to_ref <- function(pol, xs) pol[apply(abs(outer(x_ref, xs, "-")), 1, which.min)]
gres <- lapply(grid_set, function(n) {
  pl <- build_P(n); cc <- cost_of(n); ss <- solve_mdp(pl, cc); xs <- nodes_of(n)
  list(n = n, xs = xs, pol = ss$pol, own = ss$v, stay0 = pl[[1]][1, 1],
       true = eval_pol(p_ref, to_ref(ss$pol, xs), c_ref))
})
print(do.call(rbind, lapply(gres, function(z) data.frame(
  states = z$n,
  bottom_bin_edge = round(1 / (2 * (z$n - 1)), 5),
  prob_stay_at_zero = round(z$stay0, 4),
  claimed_cost_at_0.10 = round(approx(z$xs, z$own, 0.10)$y, 3),
  true_cost_at_0.10 = round(approx(x_ref, z$true, 0.10)$y, 3),
  extra_pct = round(100 * (mean(z$true[lo_ref]) - mean(ref$v[lo_ref])) /
                      mean(ref$v[lo_ref]), 3),
  extra_cost = round(mean(z$true[lo_ref]) - mean(ref$v[lo_ref]), 3)))),
  row.names = FALSE)
 states bottom_bin_edge prob_stay_at_zero claimed_cost_at_0.10
     11         0.05000            1.0000               22.500
     41         0.01250            1.0000               22.500
    161         0.00313            0.1617               33.952
    801         0.00062            0.0000               31.516
 true_cost_at_0.10 extra_pct extra_cost
            87.036    79.340     54.763
            39.794    10.937      7.549
            32.186     0.860      0.593
            31.516     0.000      0.000
for (z in gres) cat("states", z$n, "boundaries at cover:",
                    paste(format(bounds_of(z$pol, z$xs), scientific = FALSE),
                          collapse = " "), "\n")
states 11 boundaries at cover: 0.05 0.45 0.55 
states 41 boundaries at cover: 0.0125 0.0875 0.4375 0.4875 
states 161 boundaries at cover: 0.0031 0.0969 0.4469 0.4906 
states 801 boundaries at cover: 0.0006 0.0981 0.4469 0.4894 
library(grid)
band <- do.call(rbind, lapply(gres, function(z) {
  hh <- 1 / (2 * (z$n - 1))
  data.frame(states = z$n,
             xmin = pmax(z$xs - hh, 0), xmax = pmin(z$xs + hh, 1),
             action = factor(act_name[z$pol], levels = act_name))
}))
band$row <- match(band$states, grid_set)
lab_grid <- paste(grid_set, "states")

p_left <- ggplot(band) +
  geom_rect(aes(xmin = xmin, xmax = xmax, ymin = row - 0.42, ymax = row + 0.42,
                fill = action)) +
  scale_fill_manual(values = act_col, name = NULL) +
  scale_y_continuous(breaks = seq_along(grid_set), labels = lab_grid,
                     trans = "reverse") +
  scale_x_continuous(expand = c(0, 0)) +
  labs(x = "Cover of the invasive shrub", y = NULL,
       title = "A coarse grid invents a permanent eradication") +
  theme_te() +
  theme(legend.position = "top", panel.grid.major.y = element_blank(),
        plot.margin = margin(4, 14, 6, 6))

curve_df <- do.call(rbind, lapply(gres, function(z)
  data.frame(cover = x_ref[lo_ref], cost = z$true[lo_ref],
             grid = paste(z$n, "states"))))
curve_df$grid <- factor(curve_df$grid, levels = lab_grid)

p_right <- ggplot(curve_df, aes(cover, cost, colour = grid)) +
  geom_line(linewidth = 0.9) +
  scale_colour_manual(values = c(te_pal$clay, te_pal$gold, te_pal$green,
                                 te_pal$forest), name = NULL) +
  guides(colour = guide_legend(nrow = 1)) +
  labs(x = "Cover at which the shrub is found",
       y = "True cost, thousand pounds",
       title = "The cost of a coarse grid lands at low cover") +
  theme_te() +
  theme(legend.position = "top", plot.margin = margin(4, 14, 6, 6))

grid.newpage()
pushViewport(viewport(layout = grid.layout(2, 1)))
print(p_left, vp = viewport(layout.pos.row = 1, layout.pos.col = 1))
print(p_right, vp = viewport(layout.pos.row = 2, layout.pos.col = 1))
Two stacked panels. The upper panel has four horizontal strips, one per grid resolution, coloured by the recommended action across cover from zero to one. The 11 state and 41 state strips open with a visible no action block at the left hand end which is too thin to see on the two finer strips. The lower panel plots expected cost against cover from zero to 0.3 for the four rules; the 11 state curve sits far above the others below a cover of about 0.15, the 41 state curve sits a little above, and the 161 and 801 state curves lie almost on top of each other.
Figure 2: Top: the rule returned by each grid, plotted against cover so that the four are comparable. Bottom: the true expected discounted cost of following each of those rules, computed on the 801 state model, over the covers at which an infestation is likely to be found.

The 11 state rule costs 79.340 per cent more than the reference rule, which is 54.763 thousand pounds on an expected cost of 69.023. The 41 state rule costs 10.937 per cent, or 7.549 thousand. The 161 state rule costs 0.860 per cent. None of these grids is absurd. Eleven states is a sensible first pass, forty one is what most people would settle on, and a hundred and sixty one feels generous for a one dimensional problem.

The mechanism is in the third column of the table. On a grid of 11 states the lowest bin runs from zero to a cover of 0.05, and anything that lands in it is recorded as zero cover. From zero cover the seed rain puts next year’s median at 0.004, which is inside the same bin, so the probability of staying at zero once you arrive there is 1.0000. The model has an absorbing state. It believes that clearing the last few per cent of the shrub eradicates it permanently and for free, and it plans accordingly: clear hard, then stop. On the 41 state grid the bin edge is at 0.0125 and the same thing happens, again with probability 1.0000. Only at 161 states does the bottom bin start to leak, at 0.1617, and only at 801 does the model agree with the continuous process that the shrub always comes back.

That is why the coarse rules leave a visible band of untreated cover at the bottom of the upper panel: on the 11 state grid it runs up to a cover of 0.05, while on the 801 state grid it stops at 0.0006 and cannot be seen. The coarse model is not doing nothing because the shrub is gone. It is doing nothing because it thinks the shrub is gone. Followed on the real system, the rule clears the reserve, walks away, and comes back to the same problem a few years later, which is the cycle priced in the lower panel.

What the model says about itself is further out than what it recommends. At a cover of a tenth, the 11 state model reports an expected cost of 22.500 thousand pounds for its own rule. The true cost of that rule is 87.036. The 41 state model reports the same 22.500 against a true 39.794. Both are wrong in the reassuring direction, and neither model contains anything that would flag it. The 161 state model reports 33.952 against a true 32.186 and errs the other way, which is the first sign of a grid fine enough to be pessimistic rather than hopeful.

The boundaries tell the same story in cover units rather than grid indices, which is the only way to compare them. The 11 state rule switches at 0.05, 0.45 and 0.55. The 41 state rule switches at 0.0125, 0.0875, 0.4375 and 0.4875. The 161 state rule gives 0.0031, 0.0969, 0.4469 and 0.4906 against the reference 0.0006, 0.0981, 0.4469 and 0.4894. Reported as grid indices these look like different problems; reported as covers they are visibly converging, and the abandonment threshold is stable from 41 states upward. It is the low cover end that needs the resolution, because that is where the interesting question lives.

The general point is not that grids should be fine. It is that the grid is a modelling assumption about what outcomes exist, not a numerical setting. The bin width at the bottom of the state space decides whether eradication is a thing that can happen. Nothing in the output of a coarse solve says so, and refining until the value function stops moving is the cheapest check in this post.

Check 3: the transition matrix nobody estimated

The matrix was written from three stated numbers. Suppose instead there is a management record: a run of annual cover estimates with the action taken in each year, from a warden who used the contractor above a cover of 0.2, the volunteers between 0.05 and 0.2 and nothing below, with a one in four chance in any year of doing something else because of budget, weather or a contractor who did not turn up. Records of 20, 50 and 200 years are simulated from the true process, and three estimators are run on each of them.

The first fits the structural model with the seed rain assumed known, so two free parameters, by maximum likelihood on the log scale with the cover cap treated as a right censored observation. The second fits the same model with the seed rain free as well, so three parameters. The third refuses to assume a functional form and estimates the transition matrix by counting transitions on the working grid, with a weak uniform prior spread over every row, which is all that a row with no data has to go on. Each fitted matrix is solved and the resulting rule is priced on the true matrix.

hist_act <- function(x, u1, u2) {
  a <- if (x > 0.2) 3L else if (x > 0.05) 2L else 1L
  if (u1 < 0.25) a <- 1L + as.integer(u2 * 3)
  a
}
sim_series <- function(n_yr, x0 = 0.12) {
  x <- numeric(n_yr + 1); a <- integer(n_yr); x[1] <- x0
  for (k in 1:n_yr) {
    a[k] <- hist_act(x[k], runif(1), runif(1))
    x[k + 1] <- min(med_map(x[k], a[k]) * exp(log_sd * rnorm(1)), 1)
  }
  list(x = x, a = a)
}
neg_ll <- function(rr, sg, mm, yy, lxn, cen) {
  lz <- log(yy + rr * yy * (1 - yy) + mm * (1 - yy))
  ll <- numeric(length(lz))
  ll[!cen] <- dnorm(lxn[!cen], lz[!cen], sg, log = TRUE)
  ll[cen] <- pnorm(lz[cen] / sg, log.p = TRUE)
  -sum(ll)
}
prep <- function(sr) {
  x <- head(sr$x, -1); xn <- sr$x[-1]
  list(y = x - pmin(act_cap[sr$a], x), lxn = log(pmax(xn, 1e-12)),
       cen = xn >= 1 - 1e-12)
}
fit_two <- function(sr) {
  dd <- prep(sr)
  o <- optim(c(log(0.7), log(0.25)), function(p)
    neg_ll(exp(p[1]), exp(p[2]), seed_rain, dd$y, dd$lxn, dd$cen))
  c(r = exp(o$par[1]), s = exp(o$par[2]), m = seed_rain)
}
fit_three <- function(sr) {
  dd <- prep(sr)
  o <- optim(c(log(0.7), log(0.25), log(0.004)), function(p)
    neg_ll(exp(p[1]), exp(p[2]), exp(p[3]), dd$y, dd$lxn, dd$cen))
  c(r = exp(o$par[1]), s = exp(o$par[2]), m = exp(o$par[3]))
}
bin_of <- function(v, n) {
  xs <- nodes_of(n)
  as.integer(cut(v, c(-Inf, xs[-1] - 1 / (2 * (n - 1)), Inf), labels = FALSE))
}
emp_P <- function(sr, n) {
  ii <- bin_of(head(sr$x, -1), n); jj <- bin_of(sr$x[-1], n)
  lapply(seq_along(act_name), function(a) {
    mm <- matrix(1 / n, n, n)
    for (k in which(sr$a == a)) mm[ii[k], jj[k]] <- mm[ii[k], jj[k]] + 1
    mm / rowSums(mm)
  })
}
set.seed(20260724)
n_rep <- 120; len_set <- c(20, 50, 200); rows <- list()
for (n_yr in len_set) for (rp in 1:n_rep) {
  sr <- sim_series(n_yr)
  f2 <- fit_two(sr); f3 <- fit_three(sr)
  l2 <- loss_w(solve_mdp(build_P(n_w, f2["r"], f2["s"], f2["m"]), c_w)$pol)
  l3 <- loss_w(solve_mdp(build_P(n_w, f3["r"], f3["s"], f3["m"]), c_w)$pol)
  lc <- loss_w(solve_mdp(emp_P(sr, n_w), c_w)$pol)
  rows[[length(rows) + 1]] <- data.frame(years = n_yr,
    r2 = f2["r"], r3 = f3["r"], m3 = f3["m"],
    two = l2["extra_pct"], three = l3["extra_pct"], counts = lc["extra_pct"],
    counts_cost = lc["extra_cost"],
    cells = length(unique(paste(bin_of(head(sr$x, -1), n_w), sr$a))))
}
est <- do.call(rbind, rows)
c(replicates = n_rep, rows_in_the_matrix = n_w * length(act_name))
        replicates rows_in_the_matrix 
               120                483 
print(round(aggregate(cbind(r2, r3, m3, cells) ~ years, est, median), 4), row.names = FALSE)
 years     r2     r3     m3 cells
    20 0.7070 0.6313 0.0048    16
    50 0.7173 0.6987 0.0041    29
   200 0.7034 0.6943 0.0041    98
print(round(aggregate(cbind(two, three, counts) ~ years, est, median), 3), row.names = FALSE)
 years   two three  counts
    20 0.221 0.728 433.657
    50 0.054 0.105 405.096
   200 0.031 0.032 358.930
print(round(aggregate(cbind(two, three, counts) ~ years, est,
                      function(z) quantile(z, 0.9)), 3), row.names = FALSE)
 years   two   three  counts
    20 2.104 229.797 445.421
    50 0.607   1.454 446.383
   200 0.249   0.830 463.412
print(round(aggregate(counts_cost ~ years, est, median), 3), row.names = FALSE)
 years counts_cost
    20     297.327
    50     277.745
   200     246.092
e20 <- est[est$years == 20, ]
round(c(share_three_above_5pct_at_20y = mean(e20$three > 5),
        share_two_above_5pct_at_20y = mean(e20$two > 5),
        spearman_seed_rain_vs_loss = cor(e20$m3, e20$three, method = "spearman"),
        seed_rain_q90_at_20y = quantile(e20$m3, 0.9)), 4)
share_three_above_5pct_at_20y   share_two_above_5pct_at_20y 
                       0.1833                        0.0333 
   spearman_seed_rain_vs_loss      seed_rain_q90_at_20y.90% 
                       0.5169                        0.0737 
round(loss_w(solve_mdp(build_P(n_w, mm = 0.07), c_w)$pol), 3)
extra_cost  extra_pct 
    52.623     76.751 
est_lab <- c("Two parameters", "Three parameters", "Transition counts")
long <- rbind(
  data.frame(years = est$years, loss = est$two, estimator = est_lab[1]),
  data.frame(years = est$years, loss = est$three, estimator = est_lab[2]),
  data.frame(years = est$years, loss = est$counts, estimator = est_lab[3]))
long$estimator <- factor(long$estimator, levels = est_lab)
long$years <- factor(long$years, levels = len_set, labels = paste(len_set, "years"))
long$loss <- pmax(long$loss, 0.01)

ggplot(long, aes(years, loss, fill = estimator)) +
  geom_boxplot(colour = te_pal$ink, linewidth = 0.35, outlier.size = 0.7,
               alpha = 0.75) +
  scale_fill_manual(values = c(te_pal$forest, te_pal$gold, te_pal$clay), name = NULL) +
  scale_y_log10(breaks = c(0.01, 0.1, 1, 10, 100, 1000),
                labels = c("0.01", "0.1", "1", "10", "100", "1000")) +
  labs(x = "Length of the management record", y = "Extra cost, per cent",
       title = "One extra parameter is what makes short records dangerous") +
  theme_te() +
  theme(legend.position = "top")
Grouped box plots with record length of 20, 50 and 200 years on the horizontal axis and extra cost on a logarithmic vertical axis running from a hundredth of a per cent to a thousand per cent. The two parameter boxes sit low and shrink with length. The three parameter boxes sit slightly higher at 20 years with a long upper tail reaching past one hundred per cent, and come close to the two parameter boxes by 50 years. The transition count boxes sit near four hundred per cent at every length and do not move.
Figure 3: Extra discounted cost of the rule derived from each fitted transition model, over 120 simulated management records at each length. Losses below one hundredth of a per cent are drawn at one hundredth so that the logarithmic axis can show them.

The two parameter fit is almost harmless. Its median extra cost is 0.221 per cent from a 20 year record, 0.054 per cent from 50 years and 0.031 per cent from 200, and its median spread rate estimates are 0.7070, 0.7173 and 0.7034 against a true 0.7. Twenty annual cover estimates are enough to recover a rule that is within a quarter of a per cent of the right one. That is not what the framing of this check expected, and it is the result.

Adding one parameter changes the picture. With the seed rain free as well, the median loss from a 20 year record is 0.728 per cent, which still looks fine, but the ninetieth percentile is 229.797 per cent against 2.104 for the two parameter fit, and 0.1833 of the 20 year records produce a rule that costs more than five per cent extra. The tail is the whole story: most records are fine and about one in five is not. By 50 years the ninetieth percentile has fallen to 1.454 per cent and by 200 years to 0.830.

The reason is exactly the mechanism of check 2, arriving this time through the data rather than through the grid. The seed rain is a small number that only matters at low cover, and the record was made at covers where it is a rounding error, so it is barely identified. Its ninetieth percentile estimate from 20 years is 0.0737, against a true 0.004, and its rank correlation with the loss is 0.5169. A model that believes in a seed rain of 0.07 believes the reserve is under constant re-invasion from outside, gives up on the low cover states, and costs 76.751 per cent extra when its rule is priced on the truth. Check 2 found a model that was too optimistic about eradication because of a bin edge. Check 3 finds one that is too pessimistic about it because of a parameter the data never saw.

The transition count estimator is in a different category. Its median loss is 433.657 per cent from 20 years, 405.096 from 50 and 358.930 from 200, which in money is between 246.092 and 297.327 thousand pounds of extra expected cost. The trend with data is real and useless. The reason is in the count above the table: the matrix has 483 rows, being 161 states by three actions, and the median number of rows with any data at all is 16 from a 20 year record, 29 from 50 years and 98 from 200. Two centuries of annual monitoring fills a fifth of the object. Coarsening the grid so that the counting becomes feasible is not an escape either, since check 2 has already priced an 11 state grid at 79.340 per cent.

So the transition matrix in a sequential decision model is not data. It is a model with two or three parameters in it, wearing a matrix as a costume, and the honest way to report one is to give the parameters and their sources rather than the matrix. The check that matters is not whether the matrix was estimated well. It is which parameters the record could actually see, and whether the ones it could not see are the ones the rule turns on.

Check 4: the end of the horizon

Management plans have end dates. A five year plan, a ten year scheme, a twenty five year vision: each of those is a finite horizon problem, and the finite horizon problem is solved by backward induction from a terminal condition rather than by iterating to a fixed point. The usual terminal condition is nothing, meaning the world is assumed to end with the plan. The check runs backward induction over 40 years and compares each step with the stationary rule.

n_hz <- 40
v_t <- rep(0, n_w); pol_t <- matrix(0L, n_w, n_hz); v_hz <- matrix(0, n_w, n_hz)
for (k in 1:n_hz) {
  qq <- sapply(seq_along(act_name), function(a) c_w[, a] + dfac * (p_w[[a]] %*% v_t))
  pol_t[, k] <- max.col(-qq, ties.method = "first")
  v_t <- qq[cbind(seq_len(n_w), pol_t[, k])]
  v_hz[, k] <- v_t
}
diff_k <- apply(pol_t, 2, function(p) sum(p != base$pol))
gu_t <- apply(pol_t, 2, function(p) {
  k <- which(p != 1); if (!length(k)) 0 else (x_w[max(k)] + x_w[max(k) + 1]) / 2 })
kshow <- c(1, 2, 5, 10, 20, 30, 40)
print(data.frame(years_left = kshow, states_differing = diff_k[kshow],
                 abandon_above = round(gu_t[kshow], 4)), row.names = FALSE)
 years_left states_differing abandon_above
          1               78        0.0000
          2               78        0.0000
          5               54        0.3094
         10               30        0.3469
         20               17        0.4156
         30               12        0.4531
         40                7        0.4656
round(c(stationary_abandon_above = (x_w[max(which(base$pol != 1))] +
          x_w[max(which(base$pol != 1)) + 1]) / 2,
        horizon_40_cost = mean(v_hz[lo_w, n_hz]),
        infinite_cost = mean(base$v[lo_w]),
        understated_pct = 100 * (mean(base$v[lo_w]) - mean(v_hz[lo_w, n_hz])) /
          mean(base$v[lo_w])), 3)
stationary_abandon_above          horizon_40_cost            infinite_cost 
                   0.491                   65.129                   68.563 
         understated_pct 
                   5.008 
fin_val <- function(pol_fixed, tp) {
  vv <- rep(0, n_w)
  for (k in 1:tp) {
    pp <- matrix(0, n_w, n_w)
    for (a in seq_along(act_name)) {
      ii <- which(pol_fixed == a)
      if (length(ii)) pp[ii, ] <- p_w[[a]][ii, , drop = FALSE]
    }
    vv <- c_w[cbind(seq_len(n_w), pol_fixed)] + dfac * (pp %*% vv)
  }
  as.vector(vv)
}
print(do.call(rbind, lapply(c(5, 10, 20, 40), function(tp) {
  vs <- fin_val(base$pol, tp)
  data.frame(plan_years = tp,
             plan_used_for_ever_pct = round(loss_w(pol_t[, tp])["extra_pct"], 3),
             stationary_used_for_plan_pct = round(100 * (mean(vs[lo_w]) -
               mean(v_hz[lo_w, tp])) / mean(v_hz[lo_w, tp]), 3))
})), row.names = FALSE)
 plan_years plan_used_for_ever_pct stationary_used_for_plan_pct
          5                148.896                       37.305
         10                  5.983                        4.809
         20                  0.517                        1.140
         40                  0.033                        0.232
v_t2 <- base$v; n_diff2 <- 0L
for (k in 1:n_hz) {
  qq <- sapply(seq_along(act_name), function(a) c_w[, a] + dfac * (p_w[[a]] %*% v_t2))
  pk <- max.col(-qq, ties.method = "first")
  n_diff2 <- n_diff2 + sum(pk != base$pol)
  v_t2 <- qq[cbind(seq_len(n_w), pk)]
}
c(states_differing_with_stationary_terminal_value = n_diff2,
  state_steps_checked = n_w * n_hz)
states_differing_with_stationary_terminal_value 
                                              0 
                            state_steps_checked 
                                           6440 
hz_map <- data.frame(
  years_left = rep(seq_len(n_hz), each = n_w),
  cover = rep(x_w, n_hz),
  action = factor(act_name[as.vector(pol_t)], levels = act_name))
st_bounds <- bounds_of(base$pol, x_w)
ggplot(hz_map, aes(years_left, cover, fill = action)) +
  geom_raster() +
  geom_hline(yintercept = st_bounds[2:4], colour = te_pal$ink,
             linetype = "22", linewidth = 0.5) +
  scale_fill_manual(values = act_col, name = NULL) +
  scale_x_continuous(expand = c(0, 0)) +
  scale_y_continuous(expand = c(0, 0)) +
  labs(x = "Years remaining in the plan", y = "Cover of the invasive shrub",
       title = "A finite horizon changes the rule decades before the end",
       subtitle = paste("Dotted lines: the three action boundaries of the same rule",
                        "solved with no end to the horizon")) +
  theme_te() +
  theme(legend.position = "top",
        plot.subtitle = element_text(size = 9.5, colour = "#2c3a31"))
A filled map with years remaining from one to forty along the horizontal axis and invasive cover from zero to one on the vertical axis. With one or two years left the whole map is the no action colour. As years remaining increase, a contract control region grows upward from the bottom and its upper edge climbs steadily towards, but never reaches, the two closely spaced dashed lines of the stationary rule just below a half. The lowest dashed line, at a cover of about a tenth, is met almost immediately.
Figure 4: The optimal action in a finite horizon problem, as a function of how many years are left and how much of the reserve the shrub holds. The dashed lines mark the boundaries of the stationary rule, which is what the same model returns when the horizon never ends.

With one or two years left, the rule is to do nothing anywhere. All 78 states that the stationary rule would treat are left alone, because a crew sent out in the final year buys a reduction in cover that nobody will be around to enjoy. With five years left the abandonment threshold is 0.3094 and 54 states still disagree with the stationary rule. With ten years left it is 0.3469 and 30 states. With twenty it is 0.4156 and 17 states, with thirty 0.4531 and 12, and with forty 0.4656 and 7. The stationary threshold is 0.491, and the finite horizon solution has not reached it after forty years.

That is the honest correction to the way this check is usually described. The end of the horizon does not distort the last few steps. At a discount factor of 0.95 it distorts everything, decaying geometrically, and forty years back the rule is still visibly different and the value function still understates the infinite horizon cost by 5.008 per cent, 65.129 thousand pounds against 68.563. A plan long enough that its ending does not matter would have to be several times longer than any management plan ever written.

The prices of the two possible mistakes are not equal. Taking the first year of a five year plan and using it as standing policy costs 148.896 per cent extra, which is the outcome of writing off a reserve that a longer view would still be defending. Ten years costs 5.983 per cent, twenty 0.517 and forty 0.033. In the other direction, running the stationary rule inside a genuine five year problem costs 37.305 per cent, ten years 4.809, twenty 1.140 and forty 0.232. Both mistakes are expensive at five years and both are cheap at twenty, but the short plan used for ever costs about four times what the long rule used briefly does, and it fails in the direction of doing too little.

The fix costs one line. Backward induction with the stationary value function as the terminal condition, rather than zero, reproduces the stationary rule exactly: across 6440 state and step combinations, 0 differ. The terminal condition is a statement about what the reserve is worth on the day the plan expires, and setting it to zero says the reserve is worth nothing then. Nobody believes that, and it is written into most finite horizon code by default.

What the four checks cannot see

All four checks assume the state is known when the action is chosen. The rule is a function of cover, and it has been fed the true cover every time. Real management reads the cover off a survey.

The last block leaves the grid behind entirely and simulates the continuous process forward from starting covers drawn from the same discovery set, applying the 801 state rule to whatever the survey reported. Three versions are run on identical random draws: a perfect survey, a survey with a lognormal error a little larger than one year of the process noise, and the same noisy survey with a three in ten chance of missing an infestation below five per cent cover altogether.

set.seed(20260724)
n_sim <- 4000; n_yr_sim <- 120
start_pool <- x_ref[lo_ref]
pol_fun <- function(xo) ref$pol[pmax(1L, pmin(n_ref, round(xo * (n_ref - 1)) + 1L))]
run_sim <- function(obs_sd, miss_p, x0) {
  x <- x0; tot <- rep(0, n_sim)
  for (k in 1:n_yr_sim) {
    xo <- if (obs_sd > 0) pmin(x * exp(obs_sd * rnorm(n_sim)), 1) else x
    if (miss_p > 0) xo[x < 0.05 & runif(n_sim) < miss_p] <- 0
    a <- pol_fun(xo)
    tot <- tot + dfac^(k - 1) * stage_cost(x, a)
    x <- pmin(med_map(x, a) * exp(log_sd * rnorm(n_sim)), 1)
  }
  mean(tot)
}
x0_pool <- sample(start_pool, n_sim, replace = TRUE)
set.seed(20260724); s_perfect <- run_sim(0, 0, x0_pool)
set.seed(20260724); s_noisy   <- run_sim(0.30, 0, x0_pool)
set.seed(20260724); s_missed  <- run_sim(0.30, 0.30, x0_pool)
c(replicates = n_sim, years_simulated = n_yr_sim)
     replicates years_simulated 
           4000             120 
round(c(observation_log_sd = 0.30, non_detection_below_0.05 = 0.30,
        analytic_cost = mean(ref$v[lo_ref]), simulated_cost = s_perfect,
        calibration_gap_pct = 100 * (s_perfect - mean(ref$v[lo_ref])) /
          mean(ref$v[lo_ref])), 3)
      observation_log_sd non_detection_below_0.05            analytic_cost 
                   0.300                    0.300                   69.023 
          simulated_cost      calibration_gap_pct 
                  68.896                   -0.184 
round(c(perfect = s_perfect, noisy = s_noisy, noisy_and_missed = s_missed,
        noisy_pct = 100 * (s_noisy - s_perfect) / s_perfect,
        missed_pct = 100 * (s_missed - s_perfect) / s_perfect), 3)
         perfect            noisy noisy_and_missed        noisy_pct 
          68.896           76.604           81.208           11.187 
      missed_pct 
          17.870 

The perfect survey run doubles as the last calibration in the post, and it is the one that justifies everything above it. Simulating the continuous process directly, with no grid anywhere in the loop except in the lookup of the action, gives an expected discounted cost of 68.896 thousand pounds against the 69.023 that the 801 state model computed by linear algebra. The simulation comes in 0.184 per cent low, which is the truncation at 120 years plus Monte Carlo error over 4000 replicates. The discretised model is a fair account of the continuous one.

A survey with a lognormal error of 0.30 on the log scale costs 11.187 per cent, taking the expected cost to 76.604 thousand. Adding a three in ten chance of missing a low cover infestation takes it to 81.208, or 17.870 per cent above perfect information. That is about as much as handing a patient owner an impatient rule, which cost 18.742 per cent in check 1, and rather more than a 41 state grid costs at 10.937 per cent. It is invisible to all four checks, because all four ask what the rule should be given the state and none asks what happens when the state is read wrong.

The proper treatment is a partially observed model, where the state is a probability distribution over covers rather than a cover, and the rule is a function of that distribution. Those are expensive to solve and rare in practice. The cheap alternative is the one run here: keep the rule you have, simulate it forward through the observation process you actually have, and price the difference. It takes a few lines, it needs no new theory, and it is the only one of the five numbers in this post that a monitoring budget can act on.

There is a second thing none of the checks can see, and it cannot be measured from inside the model at all. Every check above assumes the shrub spreads logistically with lognormal noise, and the truth against which everything was scored was built from that same assumption. A dispersal kernel with a long tail, a seed bank that carries the infestation through a year of apparently clean ground, or a neighbour who changes their management, would each break the state description rather than the numbers in it. Checks 1 to 4 measure how much of the rule depends on choices made after the state variable was chosen. The choice of the state variable itself is upstream of all of them.

Where to go next

The three posts this one is checking build the machinery. Markov decision processes for management sets out the state, the action set, the transition matrix and the Bellman equation, and shows why the output of value iteration is a stationary rule. Stochastic dynamic programming for harvest derives an optimal harvest rule and measures how far it sits from constant escapement once uncertainty and a concave utility are switched on. When to stop monitoring is the optimal stopping version of the same idea, and it is the natural next step after the observation error result above, since it prices the information a survey buys against what the survey costs and what the delay costs.

If you take one habit from this post, make it the grid refinement in check 2. It costs one extra solve, and it is the only one of the four failures that the model reports as good news: a coarse solve hands back a lower expected cost and no warning at all.

References

Puterman ML 1994 Markov Decision Processes: Discrete Stochastic Dynamic Programming. Wiley, ISBN 978-0-471-61977-2

Marescot L, Chapron G, Chades I, Fackler PL, Duchamp C, Marboutin E, Gimenez O 2013 Methods in Ecology and Evolution 4(9):872-884 (10.1111/2041-210X.12082)

Chades I, Chapron G, Cros MJ, Garcia F, Sabbadin R 2014 Ecography 37(9):916-920 (10.1111/ecog.00888)

Clark CW, Mangel M 2000 Dynamic State Variable Models in Ecology: Methods and Applications. Oxford University Press, ISBN 978-0-19-512267-1

Sutton RS, Barto AG 2018 Reinforcement Learning: An Introduction. Second edition. MIT Press, ISBN 978-0-262-03924-6

Weitzman ML 2001 American Economic Review 91(1):260-271 (10.1257/aer.91.1.260)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.