--- title: "Advanced diagnostics" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Advanced diagnostics} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ```{r setup} library(RASbound) ``` This vignette is for when you already know which function to call (see `vignette("RASbound-intro")`) and want to understand its result. It shows how to: * read `fixed_cells`; * diagnose infeasibility, and tell it apart from running out of iterations; * handle cells that must be exactly zero; * compare variants on the same problem; * recognize a stall and a few cases where a variant fails. ## A running example ```{r} x0 <- matrix(c(2, 4, 9, 6, 8, 1, 3, 5, 7), nrow = 3, byrow = TRUE) row_targets <- c(25, 20, 25) col_targets <- c(20, 25, 25) upper <- matrix(c(Inf, Inf, 6, Inf, Inf, Inf, Inf, Inf, Inf), nrow = 3, byrow = TRUE) # cap cell [1, 3] at 6 fit <- bras_freeze_hybrid(x0, row_targets, col_targets, upper = upper) fit ``` ## Reading `fixed_cells` ```{r} fit$fixed_cells ``` Each row is a cell that was pinned to a bound during the fit. The column `outer_pass` says when. Cells fixed in pass 1 were the clearest violations of the unconstrained solution. Cells fixed in later passes became binding only after earlier fixations changed the problem that was left. This column is your audit trail. It shows that a cell reached its bound because of the equilibrium of the free problem at that point, not because of an arbitrary early choice. If a cell binds when you did not expect it to, look at the data first. Usually the seed value, or the target of its row or column, is not consistent with the bounds you set. ## Diagnosing infeasibility Sometimes no matrix satisfies both the targets and the bounds. By default (`on_infeasible = "error"`) the function stops with a message. The same information is also available in the result: ```{r} too_tight <- matrix(1, nrow = 3, ncol = 3) fit_bad <- suppressWarnings( # x0 is also above `upper` here; that is not the point bras_freeze_sim(x0, row_targets, col_targets, upper = too_tight, on_infeasible = "warning") ) fit_bad$feasible fit_bad$diagnostics$infeasible_at ``` `infeasible_at$rows` and `infeasible_at$cols` list the margins that could not be met. Start there instead of scanning the whole matrix. There are two common causes. 1. **The bounds are too tight for a row or column.** The sum of `upper` over its cells is smaller than its target, or the sum of `lower` is larger. Check it directly: ```{r} rowSums(too_tight) # every row can reach at most 3, but the targets are 25, 20, 25 ``` 2. **A cell that has to contribute is seeded at zero.** The next section explains this case. The fix is different: change `x0`, not the bounds. ### `feasible = TRUE` but `converged = FALSE` This is not the same as proven infeasibility. It means the outer loop reached `max_outer` before it found a solution or ruled one out: ```{r} fit_slow <- bras_freeze_sim(x0, row_targets, col_targets, upper = upper, max_outer = 1, on_infeasible = "warning") fit_slow$feasible # TRUE: not proven infeasible fit_slow$converged # FALSE: it just did not finish ``` Here a larger `max_outer` may solve it. See `?bras_fit` for the full distinction. ## Cells that must be exactly zero You do not need `lower = upper = 0` for a cell that must be zero. Every variant scales cells multiplicatively, so a cell with `x0[i, j] = 0` stays exactly 0: ```{r} x0_with_zero <- x0 x0_with_zero[1, 1] <- 0 fit_zero <- bras_freeze_sim(x0_with_zero, row_targets, col_targets) fit_zero$matrix[1, 1] # exactly 0 ``` Two points to keep in mind. * **A seed outside its own `[lower, upper]` range is moved to the nearest bound, with a warning.** For most cells this only saves some work. For a cell seeded at 0 with a positive `lower`, it is necessary, because no iteration could move the cell away from zero: ```{r} bad_lower <- matrix(0, 3, 3); bad_lower[1, 1] <- 5 fit_imputed <- bras_freeze_sim(x0_with_zero, row_targets, col_targets, lower = bad_lower) fit_imputed$matrix[1, 1] # at least 5, not 0 ``` Read the warning as a prompt to check your inputs. It usually means that `x0` and the bounds do not agree. * **A row or column that depends on zero-seeded cells can be infeasible even with the loosest bounds** (`lower = 0`, `upper = Inf`), because those cells cannot contribute anything. The feasibility check of the `bras_freeze_*()` functions knows this: it treats the achievable range of a zero-seeded cell as `{0}`. Such a case is reported like any other infeasibility. If a cell is zero in your seed only because of a data problem (rounding, suppression, a missing value), remember that RAS can never make it positive. This holds for every form of RAS, bounded or not. If the cell should be able to become positive, seed it with a small positive value instead of 0. ## Comparing variants on the same problem All functions return the same `bras_fit` structure, so comparing them is direct. This is useful as a sanity check, and it is what a simulation study would automate: ```{r} fits <- list( freeze_sim = bras_freeze_sim(x0, row_targets, col_targets, upper = upper), freeze_seq = bras_freeze_seq(x0, row_targets, col_targets, upper = upper), naive = bras_naive(x0, row_targets, col_targets, upper = upper), soft = bras_soft(x0, row_targets, col_targets, upper = upper), cap_iter = bras_cap_iter(x0, row_targets, col_targets, upper = upper) ) ``` ```{r, eval = requireNamespace("survey", quietly = TRUE) && requireNamespace("MASS", quietly = TRUE)} fits$calibrate_raking <- bras_calibrate_raking(x0, row_targets, col_targets, upper = upper) fits$calibrate_linear <- bras_calibrate_linear(x0, row_targets, col_targets, upper = upper) # bras_calibrate_logit() is left out on purpose. The section # "When bras_calibrate_logit() fails" below uses this same problem. ``` ```{r} data.frame( method = names(fits), converged = vapply(fits, function(f) f$converged, logical(1)), max_row_err = vapply(fits, function(f) max(f$diagnostics$row_margin_error), numeric(1)), max_col_err = vapply(fits, function(f) max(f$diagnostics$col_margin_error), numeric(1)), dist_from_seed = vapply(fits, function(f) sum(abs(f$matrix - x0)), numeric(1)) ) ``` `dist_from_seed` is the L1 distance to the seed. It is a quick measure of how much each method changed the data. It is not a substitute for the divergence measures of a proper simulation study. Do not read this column as a ranking of all methods. The freeze family, `bras_naive()`, `bras_soft()`, `bras_cap_iter()` and `bras_calibrate_raking()` all change the data by multiplicative scaling, like classic RAS. `bras_calibrate_linear()` minimizes a least-squares distance. A smaller `dist_from_seed` for it does not mean it is closer to the right answer. It means that distance function chose a different matrix. Which distance to minimize is a modelling choice. Use `dist_from_seed` only to compare methods of the same family. ## `bras_calibrate_linear()` and `bras_calibrate_raking()` give different matrices Both wrappers meet the same margins, but they do not return the same matrix. This is a reproducible case, with a fixed seed. It is the case used in the package's own cross-validation test (`tests/testthat/test-crossvalidation-and-properties.R`): ```{r, eval = requireNamespace("survey", quietly = TRUE) && requireNamespace("MASS", quietly = TRUE)} set.seed(20260919) x0_cmp <- matrix(round(runif(16, 1, 20)), nrow = 4) row_targets_cmp <- round(runif(4, 80, 150)) props <- runif(4); props <- props / sum(props) col_targets_cmp <- round(sum(row_targets_cmp) * props) col_targets_cmp[4] <- sum(row_targets_cmp) - sum(col_targets_cmp[1:3]) fit_ras_cmp <- ras(x0_cmp, row_targets_cmp, col_targets_cmp, tol = 1e-10, max_iter = 500) fit_raking_cmp <- bras_calibrate_raking(x0_cmp, row_targets_cmp, col_targets_cmp) fit_linear_cmp <- bras_calibrate_linear(x0_cmp, row_targets_cmp, col_targets_cmp) # raking is very close to our own engine (same distance function): max(abs(fit_ras_cmp$matrix - fit_raking_cmp$matrix)) # linear is not, although it meets the same margins to the same tolerance: max(abs(fit_ras_cmp$matrix - fit_linear_cmp$matrix)) rbind(row_ras = rowSums(fit_ras_cmp$matrix), row_linear = rowSums(fit_linear_cmp$matrix)) ``` `bras_calibrate_raking()` agrees with `ras()` up to numerical tolerance. This is expected, because both minimize the same multiplicative distance. `bras_calibrate_linear()` meets the same margins, but some of its cells differ a lot from the `ras()` result. This is not numerical noise: its least-squares distance prefers another matrix. Both matrices are correct solutions of the constraints. They differ in which notion of "closest to the seed" selects among all valid matrices. Which one you need depends on what the adjustment is supposed to represent. `RASbound` cannot decide that for you. ## When `bras_calibrate_logit()` fails `bras_calibrate_logit()` was left out of the comparison above on purpose. On this problem it fails: ```{r, error = TRUE, eval = requireNamespace("survey", quietly = TRUE) && requireNamespace("MASS", quietly = TRUE)} # Self-contained: the running example again, so this chunk gives the same # result in whatever order the document is run. x0 <- matrix(c(2, 4, 9, 6, 8, 1, 3, 5, 7), nrow = 3, byrow = TRUE) row_targets <- c(25, 20, 25) col_targets <- c(20, 25, 25) upper <- matrix(c(Inf, Inf, 6, Inf, Inf, Inf, Inf, Inf, Inf), nrow = 3, byrow = TRUE) bras_calibrate_logit(x0, row_targets, col_targets, upper = upper) ``` The reason is the following. The bound `upper[1, 3] = 6` is tight, and the seed `x0[1, 3] = 9` is above it. The seed is therefore moved to exactly 6 before the calibration starts. The logit calibration needs finite bounds, so every `Inf` is replaced by `unbounded_mult * max(row_targets, col_targets)`. By default that is `20 * 25 = 500`. One cell sits on a real bound of 6 next to cells with a bound of 500, about 80 times larger. With such different scales, the Newton-Raphson solve of the logit distance can diverge, which gives the error above. In practice, the automatic replacement of `Inf` works well when the bounds are on a similar scale, or when only a few cells really matter. It does not replace real finite bounds when you know that some cells need a tight limit. If you meet this failure on your own data, use `bras_calibrate_raking()` or `bras_calibrate_linear()`. Both accept `Inf` and need no replacement. Or use one of the `bras_freeze_*()` functions. ## When `bras_freeze_hybrid()`'s per-pass fallback is not enough `bras_freeze_hybrid()` fixes all violating cells at once. If that would make a single pass infeasible, it falls back to sequential fixation for that pass only. This per-pass fallback does not make it as robust as `bras_freeze_seq()` in every case, as this real input shows: ```{r} x0 <- matrix(c(29, 16, 22, 14, 4, 20, 27, 20, 23, 29, 23, 13, 26, 20, 12), nrow = 5, byrow = TRUE) row_targets <- c(91, 45, 67, 71, 70) col_targets <- c(133, 89, 122) upper <- matrix(c(40, 47, 27, 17, 19, 13, 44, 16, 17, 29, 19, 41, 25, 16, 33), nrow = 5, byrow = TRUE) fit_sim <- suppressWarnings( bras_freeze_sim(x0, row_targets, col_targets, upper = upper, on_infeasible = "warning") ) fit_seq <- suppressWarnings( bras_freeze_seq(x0, row_targets, col_targets, upper = upper, on_infeasible = "warning") ) fit_hybrid <- bras_freeze_hybrid(x0, row_targets, col_targets, upper = upper) data.frame( method = c("sim", "seq", "hybrid"), feasible = c(fit_sim$feasible, fit_seq$feasible, fit_hybrid$feasible), converged = c(fit_sim$converged, fit_seq$converged, fit_hybrid$converged), n_fixed = c(nrow(fit_sim$fixed_cells), nrow(fit_seq$fixed_cells), nrow(fit_hybrid$fixed_cells)) ) ``` `sim` ends infeasible after fixing 10 cells, while `seq` solves the problem with only 5. `hybrid` also solves it, with 5 fixed cells. The per-pass fallback alone is not enough here. It looks at one pass at a time, and fixing a few more cells than necessary in several different passes can add up to an infeasible run, even if each pass looked fine on its own. For that reason `bras_freeze_hybrid()` has a second, global safety net. If the per-pass approach ends without a solution, it retries the whole problem from the start with sequential fixation, as `bras_freeze_seq()` does, before giving up. That is how `hybrid` succeeds here, and `fit$method` shows that this retry was used: ```{r} fit_hybrid$method ``` ## A stall: no violated bound, but the margins do not match A stall is a third outcome, different from infeasibility and from running out of `max_outer`. It is a known limitation of alternating-projection methods such as RAS. After enough cells are fixed, the free cells that remain in a row, and the columns they belong to, can reach a true fixed point that does not satisfy the margins. The margin error stops changing at all across iterations. It is not slow convergence. ```{r} x0_stall <- matrix(c(4, 20, 5, 49, 10, 41, 44, 35, 29, 9, 24, 18, 12, 30, 16, 46, 46, 14, 6, 10), nrow = 5, byrow = TRUE) row_targets_stall <- c(95, 120, 63, 67, 134) col_targets_stall <- c(146, 215, 82, 36) upper_stall <- matrix(c(33, 31, 13, 39, 35, 41, 13, 46, 17, 34, 48, 6, 23, 53, 51, 35, 53, 57, 50, 52), nrow = 5, byrow = TRUE) fit_stall <- suppressWarnings( bras_freeze_sim(x0_stall, row_targets_stall, col_targets_stall, upper = upper_stall, on_infeasible = "warning") ) fit_stall$feasible # TRUE: not proven infeasible fit_stall$converged # FALSE: the margins are not matched fit_stall$diagnostics$stalled # TRUE: a fixed point, not slow progress ``` The feasibility check done before solving cannot detect this. For each row and column separately, it checks whether the bounds of the remaining free cells can reach the residual target. That condition is necessary but not sufficient. A sufficient condition has to hold jointly for every subset of rows and columns (it is related to network-flow feasibility). That check is harder and is not implemented. **What to do.** A larger `max_iter` or `max_outer` will not help, and the warning says so. A stall often means that no matrix satisfies the targets and the bounds together. The last section of this vignette shows such a case on real data, with a simple test that proves it. You can loosen the bounds of the rows and columns involved. The largest values of `fit_stall$diagnostics$row_margin_error` and `col_margin_error` point to them. Or you can accept some residual mismatch, if that is tolerable for your use. ## A real infeasible case: a stall with no solution behind it A real 55 x 55 input-output-style table (rounded to integers, and shipped in `inst/extdata` as the `infeasible_*.csv` files) shows a stall on real data, and how to confirm that the problem has no solution. ```{r inf-data} read_mat <- function(f) { as.matrix(utils::read.csv(system.file("extdata", f, package = "RASbound"), header = FALSE)) } x0i <- unname(read_mat("infeasible_x0.csv")) upperi <- unname(read_mat("infeasible_upper.csv")) rti <- read_mat("infeasible_row_targets.csv")[, 1] cti <- read_mat("infeasible_col_targets.csv")[, 1] fit_i <- suppressWarnings( bras_freeze_seq(x0i, rti, cti, upper = upperi, tol = 1e-6, max_iter = 1000, max_outer = 500, on_infeasible = "warning") ) fit_i$converged fit_i$diagnostics$stalled ``` The simple feasibility check compares the target of each row and column with the total capacity of its cells. It passes here: ```{r inf-simple} cap <- ifelse(x0i > 0, upperi, 0) # effective capacity under RAS any(rti > rowSums(cap)) any(cti > colSums(cap)) ``` That check is necessary but not sufficient, because the cells of a row also have to respect the column targets. A set of cells can put at most `min(col_target, capacity)` into each column. For row 13: ```{r inf-cert} i <- 13 rti[i] # what the row must receive sum(cap[i, ]) # its raw capacity: it looks sufficient sum(pmin(cti, cap[i, ])) # what it can really absorb ``` Row 13 must receive more than it can absorb once the column totals are taken into account. So no matrix satisfies this problem, and the stall is the correct diagnosis. (Solving the transportation problem as a linear program or a maximum flow gives the same answer.) If every upper limit is relaxed by 25%, the problem has a solution: ```{r inf-relaxed} fit_r <- suppressWarnings( bras_freeze_seq(x0i, rti, cti, upper = round(upperi * 1.25), tol = 1e-6, max_iter = 1000, max_outer = 500, on_infeasible = "warning") ) fit_r$converged ```