---
title: "Diagnostics workflow"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Diagnostics workflow}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE, purl = FALSE)
```

```{r setup}
library(CausalState)
library(SuperLearner)
```

## Overview

`CausalState` produces a lot of diagnostic output. This vignette walks
through a *stable, objective workflow* for using it. The core idea is that
several checks and tuning decisions should happen **before** the final
estimation, and a smaller set of post-estimation checks confirm the fit
behaved sensibly.

The recommended flow:

1. **Density ratios** — fit the weights first; use `weight_diagnostics()`
   and `policy_change_summary` to judge whether the intervention is
   supported and to pick `trim`.
2. **Q calibration** — fit `sdr()`, `itmle()`, or `qreg()` under the
   **natural-course** policy; use `branch_cal_summary()` to tune the
   SuperLearner libraries for the g- and Q-branches. For `itmle()` this
   is also where you tune the targeting library `sl_tmle`.
3. **Final estimation** — once weights and Q-branches look reasonable,
   run `sdr()` / `itmle()` under the actual policy of interest.
4. **Post-estimation sanity checks** — `recursion_diag`, `target_cal`
   (iTMLE only), and `ic_df`. These describe what the estimator *did*
   with a final fit; they are informative but not part of the tuning
   loop.

We use `sim_bin()` throughout for illustration. `sim_cont()` (continuous
treatment) and `sim_multi()` (any mix of binary + continuous treatments)
follow the same shape.

```{r sim}
df <- sim_bin(n = 2000L, tmax = 5L, seed = 1L)
head(df)
```

## 1. Density ratios: is the intervention supportable?

Fit the density ratios before touching any outcome model. This step
answers two independent questions:

- **Are the weights well-behaved?** Very large cumulative-product weights
  (a handful of subjects with `Rt_cum >> N`) will destabilise both the
  SDR and iTMLE corrections regardless of how well the Q-models fit.
- **Is the intervention shift plausible?** If the policy is far from
  the observed treatment distribution, `policy_change_summary` will
  show it as extreme shift magnitudes.

```{r dr}
sl_lib <- c("SL.mean", "SL.glm")

policy_bin <- function(D_block, t, a_names) {
  out <- D_block[, ..a_names, drop = FALSE]
  out[[a_names[1]]] <- pmax(
    D_block[[a_names[1]]], as.integer(D_block[["L1"]] > 1.0)
  )
  out
}

wr <- density_ratio(
  df              = df,
  a_names         = "A",
  tmax            = 5L,
  baseline        = c("age", "sex"),
  tv_names        = c("L1", "L2"),
  sl_g            = sl_lib,
  k               = 1L,
  inner_v         = 3L,
  v               = 3L,
  seed            = 1L,
  id              = "id",
  time            = "time",
  policy_spec_fun = policy_bin
)
```

### `weight_diagnostics()`

```{r weightdiag}
weight_diagnostics(wr)
```

The most important columns:

- **`cum_mean`** — mean of the cumulative density-ratio product at each
  time step. This is what actually drives the size of the EIF
  correction in `sdr()` / `itmle()`. If `cum_mean` blows up (say
  above 5) or the ratio between successive time steps grows,
  cumulative weights are accumulating fast — the correction will be
  variance-dominated and the estimator will be noisy.
- **`Rt_mean`** — mean of the *instantaneous* density ratio at each
  step (restricted to at-risk subjects). Tells you where in time the
  shift is biting hardest. A single spike says the intervention is
  extreme at that time; a gentle slope across time is easier to
  handle.

Secondary columns, useful for tuning and plausibility checks:

- **`Rt_ess`**, **`cum_ess`** — Kish effective sample size. Low values
  flag that most of the mass is concentrated in a few subjects. Not a
  hard rule but useful when comparing candidate `sl_g` libraries or
  candidate policies.
- **`Rt_max`**, **`cum_max`** — worst-case weight. Useful for spotting
  positivity violations. If `cum_max` is orders of magnitude larger
  than `cum_mean`, the intervention is unrealistic somewhere in the
  covariate space.

### `policy_change_summary`

```{r change}
wr$policy_change_summary
```

Shows the fraction of subjects whose treatment actually changed under
the policy at each time-step, plus the mean/quantiles of the
observed-vs-shifted difference *among those who changed*. A policy
that changes 100% of subjects by large amounts will have extreme
weights by construction — the diagnostics above are then just
confirming a design choice rather than a modelling problem.

### Picking a trim

`weight_diagnostics()` accepts `trim` (default `1`, i.e. no trimming) so
you can see how a candidate trim reshapes the weight distribution
before committing:

```{r trim}
weight_diagnostics(wr, trim = 0.99)   # cap Rt_t at the 99th percentile
weight_diagnostics(wr, trim = 0.95)
```

`sdr()` and `itmle()` take the same `trim` argument. Trimming reduces
variance at the cost of a small (usually negligible) bias in the
weighted correction — the tradeoff is worth it when a small number of
extreme weights are dominating the influence curve.

## 2. Q calibration: are the outcome models trustworthy?

Once weights look reasonable, fit the estimator **under the natural
course** — i.e. with a policy that returns the observed treatment
unchanged. This diagnostic run has two properties that make it
uniquely useful for tuning:

- The mean of the estimated Q at time 1 should approximately equal
  the observed outcome mean. If it doesn't, the Q-branches are
  miscalibrated.
- The `branch_cal` table is populated with per-fold, per-time
  calibration slopes and target-vs-pred means for each of the four
  branches (`g_remain`, `g_death`, `q_rem`, `q_exit`).

Under the *actual* intervention policy, calibration is *unmeasurable*
in the usual sense — the counterfactual outcome is never observed.
That is why you should tune here, under the natural course, and then
lock the SL library choice before the intervention run.

```{r natrun}
policy_nat <- function(D_block, t, a_names) {
  D_block[, ..a_names, drop = FALSE]
}

wr_nat <- density_ratio(
  df = df, a_names = "A", tmax = 5L,
  baseline = c("age", "sex"), tv_names = c("L1", "L2"),
  sl_g = sl_lib, k = 1L, inner_v = 3L, v = 3L, seed = 1L,
  id = "id", time = "time",
  policy_spec_fun = policy_nat
)

res_nat <- sdr(
  df = df, weight_object = wr_nat, tmax = 5L,
  id = "id", time = "time", alive = "alive", in_state = "in_state", y = "Y",
  baseline = c("age", "sex"), tv_names = c("L1", "L2"), a_names = "A",
  sl_remain = sl_lib, sl_death = sl_lib,
  sl_recursive = sl_lib, sl_y = sl_lib,
  k = 1L, inner_v = 3L, parallel = FALSE, seed = 1L,
  policy_spec_fun = policy_nat
)
```

### `branch_cal_summary()`

```{r branchcal}
bc <- branch_cal_summary(res_nat)
print(bc)
```

Focus on:

- **`tgt_vl`** — empirical mean of the target on the validation folds.
- **`pred_vl`** — mean of the model prediction on the same folds. Under
  the natural course, these should track closely per branch.
- **`cal_slope_vl`** — slope from regressing the target on the model
  prediction (on the logit scale for binomial branches). A slope of
  `1.0` is perfect calibration; `< 1` indicates over-fitting (the
  model spread its predictions too widely); `> 1` indicates
  under-fitting (predictions too flat).

A slope of `0.7-1.3` and `tgt_vl ≈ pred_vl` per branch is a reasonable
target. When a branch is systematically off, revise the corresponding
`sl_*` library — usually by adding smoother learners (`SL.glm`,
`SL.glmnet`) if calibration slope is `< 1`, or richer learners
(`SL.xgboost`, spline learners) if slope is `> 1` and the target is
poorly matched.

### Alternative: pure Q-view with `qreg()`

`sdr()` computes both g- and Q-branches, and both are reflected in
`branch_cal`. If you want to isolate the Q-side without the g-branch
noise (or without needing weights), `qreg()` runs the pure
Q-recursion under the natural course:

```{r qreg}
res_qreg <- qreg(
  df = df, tmax = 5L,
  id = "id", time = "time", alive = "alive", in_state = "in_state", y = "Y",
  baseline = c("age", "sex"), tv_names = c("L1", "L2"), a_names = "A",
  sl_remain = sl_lib, sl_death = sl_lib,
  sl_recursive = sl_lib, sl_y = sl_lib,
  k = 1L, inner_v = 3L, parallel = FALSE, seed = 1L,
  policy_spec_fun = policy_nat
)
branch_cal_summary(res_qreg)
```

### Tuning the targeting library (`itmle()` only)

`itmle()` adds a fluctuation/targeting step on top of the Q-mixture.
The targeting model has its own SuperLearner library
(`sl_tmle` / `tgt_lib`). A natural-course `itmle()` run lets you tune
this library too:

```{r itmlenat}
tgt_lib <- c("SL.tmle_empty", "SL.tmle_intercept", "SL.tmle_glm")

res_itmle_nat <- itmle(
  df = df, weight_object = wr_nat, tmax = 5L,
  id = "id", time = "time", alive = "alive", in_state = "in_state", y = "Y",
  baseline = c("age", "sex"), tv_names = c("L1", "L2"), a_names = "A",
  sl_remain = sl_lib, sl_death = sl_lib,
  sl_recursive = sl_lib, sl_y = sl_lib, sl_tmle = tgt_lib,
  k = 1L, inner_v = 3L, v_target_itmle = 3L, v_sl_inner_itmle = 3L,
  parallel = FALSE, seed = 1L, policy_spec_fun = policy_nat
)
res_itmle_nat$diagnostics$target_sl
```

`target_sl` shows which targeting wrappers were selected per (fold, t).
If a wrapper never gets picked, drop it from `tgt_lib`. If the fit
looks unstable, prefer covariate-adaptive wrappers (`SL.tmle_glm`,
`SL.tmle_glmnet_*`) over intercept-only ones.

## 3. Final estimation

Only after the weight and calibration diagnostics are satisfactory
should you run the estimator under the actual policy of interest.
Keep the SL library choice locked from the tuning step.

```{r final}
res <- sdr(
  df = df, weight_object = wr, tmax = 5L,
  id = "id", time = "time", alive = "alive", in_state = "in_state", y = "Y",
  baseline = c("age", "sex"), tv_names = c("L1", "L2"), a_names = "A",
  sl_remain = sl_lib, sl_death = sl_lib,
  sl_recursive = sl_lib, sl_y = sl_lib,
  k = 1L, inner_v = 3L, parallel = FALSE, seed = 1L,
  policy_spec_fun = policy_bin, trim = 0.99
)
res
```

## 4. Post-estimation sanity checks

The remaining diagnostics describe what the estimator did with the
final fit. They cannot easily be used to tune before running the
estimator because they depend on the joint behaviour of the weights,
the Q-models, *and* the specific intervention. Treat them as
sanity checks rather than a feedback loop.

### `diagnostics$recursion_diag`

One row per fold × time-step, ordered from `t = tmax` down to `1`.
The columns are grouped by concept.

**Sample counts and fit-status flags**

- `n_train_at_risk`, `n_valid_at_risk` — subjects available for the
  recursion at each `t`.
- `n_train_death`, `n_train_dc`, `n_train_remain` — outcome counts
  used to fit that time-step's models.
- `used_const_rem` / `_dex` / `_qexit` / `_qrem` — `TRUE` when the
  corresponding SL fit had too few observations and a constant was
  substituted. A cluster of `TRUE` values at later `t` typically
  indicates late-follow-up subject scarcity.

**Predicted branch probabilities and means (natural vs shifted, training side)**

- `p_rem_{nat,shf}_{mean,sd}` — `g_remain` predictions (probability
  of staying in-state).
- `p_dex_{nat,shf}_{mean,sd}` — `g_death_exit` predictions (probability
  of death given exit).
- `q_rem_{nat,shf}_{mean,sd}` — `Q_rem` predictions (expected outcome
  for remainers).
- `q_exit_{nat,shf}_{mean,sd}` — mean of the exit-branch mixture
  `p_dex · q_death + (1 - p_dex) · q_dc`.

Differences between `_nat` and `_shf` columns reflect how much the
intervention shifts the branch predictions. Very small differences on
a policy that meaningfully changes treatment suggest the models are
insensitive to the treatment covariates — worth investigating.

**Full mixture Q on training data**

- `Q_{nat,shf}_pre_{mean,sd,min,max}` — mixture Q at time `t`, before
  the EIF update (SDR) or targeting (iTMLE).
- `Y_target_{mean,sd,min,max}` — the current regression target passed
  into the `Q_rem` model at this step. For SDR this is the running
  pseudo-outcome pre-update; for iTMLE it is the targeted Q from the
  outer step `s+1`.
- `pseudo_post_{mean,sd,min,max}` — pseudo-outcome after the EIF
  update (SDR only). Should stay close to `Y_target` in-mean; large
  divergence in the tails signals weight extremes.

**EIF update / targeting magnitudes**

- `delta_{sd, q95_abs, max_abs}` (SDR) — spread and tail of the EIF
  correction (`pseudo_post - Y_target`). Big `delta_max_abs` relative
  to `delta_sd` indicates a small number of subjects are driving the
  correction — usually because of extreme weights.
- `delta_{nat,shf}_target_{sd, q95_abs, max_abs}` (iTMLE) — same idea
  but for the targeting update rather than the EIF update, computed
  separately under natural and shifted branches.
- `n_post_below_0`, `n_post_above_1` — how many EIF-corrected
  pseudo-outcomes fell outside `[0, 1]`. Meaningful only for
  binomial or bounded-Gaussian outcomes; a couple of violations per
  fold is normal, a large number signals that the correction is
  frequently pushing predictions out of the plausible range.
- `n_{nat,shf}_post_{below_0,above_1}` (iTMLE) — same, but for the
  post-targeting Q rather than the EIF-corrected pseudo-outcome.

**Model residuals**

- `resid_{sd, q95_abs, max_abs}` — `Y_target - Q_{shf}`. This is
  how far off the fitted Q was from the target it was regressing
  against, evaluated on the shifted branch. Large residuals suggest
  the recursive Q model is misspecified for the current
  pseudo-outcome — a signal to revisit `sl_recursive` (or
  `sl_rec_early` if the miss is concentrated at early time-points).

**Validation-side sanity**

- `Q_{nat,shf}_vl_mean` (SDR) or `Q_{nat,shf}_vl_{pre,post}_mean`
  (iTMLE) — validation-fold Q means. Comparing to their training
  counterparts is a quick check for cross-fitting stability. Large
  training-vs-validation gaps signal over-fitting somewhere in the
  Q-recursion.
- `delta_{nat,shf}_vl_target_q95_abs` (iTMLE) — validation-side
  targeting update magnitude, useful for the same
  overfit/underfit check.

### `diagnostics$target_cal` (iTMLE only)

One row per (fold, outer iteration, inner iteration) of the Luedtke
targeting loop. Tracks:

- The EIF magnitude at each iteration — should decrease toward
  convergence.
- The fluctuation coefficient at each step.

Useful for spotting a targeting loop that is not converging (EIF
magnitude not shrinking, or coefficients oscillating). If this
happens under the actual policy, it usually points to weight-driven
instability in the fluctuation step.

### `diagnostics$sl_summary`

Per-fold, per-time, per-component SuperLearner weight table. Consistently
zero-weight learners can be pruned from the library on the next run —
this is a legitimate feedback loop back into the tuning step.

### `ic_df`

Per-subject influence curve values, used by `contrast()` to build
risk-difference / risk-ratio / odds-ratio contrasts with valid
standard errors. Not typically inspected directly; consumed by:

```{r contrast}
# res_nat and res are the natural-course and shifted fits from above.
contrast(res, res_nat)
```

## Workflow summary

1. `density_ratio()` → `weight_diagnostics()` + `policy_change_summary`
   → pick `trim`, revise policy or `sl_g` if weights or shift look
   unreasonable.
2. NAT-run `sdr()` (or `itmle()`, or `qreg()`) →
   `branch_cal_summary()` → revise `sl_remain` / `sl_death` /
   `sl_recursive` / `sl_y` (and for iTMLE, `sl_tmle`) until each
   branch calibrates.
3. Final `sdr()` / `itmle()` under the intervention policy.
4. Inspect `recursion_diag` (and `target_cal` for iTMLE) for
   sanity; use `sl_summary` to prune dead learners for the next run;
   use `ic_df` via `contrast()` for downstream comparisons.
