An Analysis Workflow with netmatchRI

netmatchRI provides network-constrained matching and randomization-based inference for observational network data. Dual-penalty matching minimizes total covariate dissimilarity while enforcing penalties on network proximity between and within treatment arms in matched sets. Randomization-based inference combines evidence from correlated matched sets based on this design.

Data

The data frame should contain one row per unit, a binary treatment, baseline covariates, and an outcome. The network may be supplied as an adjacency or graph-distance matrix, with rows and columns in the same order as the data. The bundled example contains 300 units simulated under no treatment effect and network dependence. It was selected to illustrate how accounting for dependence can change the inferential conclusion.

library(netmatchRI)

data("netmatch_example", package = "netmatchRI")
sim <- netmatch_example
dat <- sim$data
net_dist <- sim$net_dist

dim(dat)
#> [1] 300   5
table(dat$Z)
#> 
#>   0   1 
#> 236  64

Match

Construct a dual-penalty design with kappa = 2, so that every pair of units within a matched set is separated by a network distance greater than two. HiGHS is the default open-source solver; Gurobi and GLPK can be selected with the solver argument.

m <- netmatch(
  data = dat,
  treat = "Z",
  covariates = c("X1", "X2", "X3"),
  network = net_dist,
  method = "dual",
  kappa = 2,
  estimand = "ATT"
)

m
#> <netmatch> dual-penalty matching; ATT matching weights
#>   300/300 units in 64 matched sets; highs backend

Summary and diagnostics

The summary reports matched sample sizes, effective sample sizes, matched-set sizes, balance, network distances, and solver status. diagnose_match() checks covariate balance and network distance within matched sets.

summary(m)
#> Matched design summary
#> 
#> Sample counts
#>    group original matched unmatched
#>  overall      300     300         0
#>  treated       64      64         0
#>  control      236     236         0
#> 
#> Effective sample size
#>    group      ess
#>  overall 218.3393
#>  treated  64.0000
#>  control 159.9529
#> 
#> Matched-set sizes
#>  subclass  n nt nc
#>         1  4  1  3
#>         2  4  1  3
#>         3  7  1  6
#>         4  3  1  2
#>         5  5  1  4
#>         6  8  1  7
#>         7  4  1  3
#>         8  9  1  8
#>         9  3  1  2
#>        10  7  1  6
#>        11  6  1  5
#>        12  4  1  3
#>        13  5  1  4
#>        14  7  1  6
#>        15  5  1  4
#>        16  5  1  4
#>        17  2  1  1
#>        18  3  1  2
#>        19  6  1  5
#>        20  3  1  2
#>        21  7  1  6
#>        22  6  1  5
#>        23  2  1  1
#>        24  8  1  7
#>        25  5  1  4
#>        26  6  1  5
#>        27  3  1  2
#>        28  4  1  3
#>        29  2  1  1
#>        30  5  1  4
#>        31 13  1 12
#>        32  5  1  4
#>        33  5  1  4
#>        34  2  1  1
#>        35  4  1  3
#>        36 10  1  9
#>        37  2  1  1
#>        38  3  1  2
#>        39  3  1  2
#>        40  3  1  2
#>        41  4  1  3
#>        42  2  1  1
#>        43  7  1  6
#>        44  4  1  3
#>        45  4  1  3
#>        46  3  1  2
#>        47  3  1  2
#>        48  5  1  4
#>        49  4  1  3
#>        50  5  1  4
#>        51  4  1  3
#>        52  2  1  1
#>        53  3  1  2
#>        54  8  1  7
#>        55  7  1  6
#>        56  8  1  7
#>        57  3  1  2
#>        58  4  1  3
#>        59  2  1  1
#>        60  2  1  1
#>        61  5  1  4
#>        62  7  1  6
#>        63  3  1  2
#>        64  3  1  2
#> 
#> Covariate balance (absolute SMD)
#>  covariate before_abs_smd after_abs_smd
#>         X1     0.19125705    0.08900321
#>         X2     0.07108809    0.05751692
#>         X3     0.23389449    0.06847942
#> 
#> Network summary
#>  n_pairs n_finite_pairs n_disconnected_pairs min_distance mean_distance
#>      711            675                   36            3       4.30963
#>  max_distance
#>             8
#> 
#> Solver: highs (OPTIMAL)

diag <- diagnose_match(m)
diag$covariate_balance
#>   covariate before_abs_smd after_abs_smd
#> 1        X1     0.19125705    0.08900321
#> 2        X2     0.07108809    0.05751692
#> 3        X3     0.23389449    0.06847942
diag$network_summary
#>   n_pairs n_finite_pairs n_disconnected_pairs min_distance mean_distance
#> 1     711            675                   36            3       4.30963
#>   max_distance
#> 1            8

Compare matching designs

Pass all three matching designs as a named list. The two plots compare covariate balance and covariate dissimilarity across these designs. Set include_before = FALSE to omit the original unmatched sample.

m_covariate <- netmatch(
  dat, "Z", c("X1", "X2", "X3"), net_dist,
  method = "covariate"
)
m_single <- netmatch(
  dat, "Z", c("X1", "X2", "X3"), net_dist,
  method = "single", kappa = 2
)
designs <- list(
  "Covariate-only" = m_covariate,
  "Single-penalty" = m_single,
  "Dual-penalty" = m
)
plot_covariate_balance(designs, include_before = TRUE)

plot_covariate_similarity(designs, include_before = TRUE)

Randomization-based inference

We use the dual-penalty design m to test the sharp null that no unit’s outcome is affected by any unit’s treatment. This includes both direct effects and treatment interference. The tests combine matched-set Mann–Whitney U statistics and calculate two-sided p-values using a normal approximation.

RI_unadjusted() ignores covariance across matched sets. RI_design() uses the design-based covariance bounds, while RI_adjusted() scales these bounds using the specified eta and rho. These parameters describe assumed bounds on covariance, rather than estimates of covariance from the data.

ri_unadjusted <- RI_unadjusted(m, "Y")
ri_design <- RI_design(m, "Y")
ri_adjusted <- RI_adjusted(m, "Y", eta = 0.05, rho = 0.20)

p_values <- data.frame(
  inference = c("Unadjusted", "Design-based", "Adjusted (eta = 0.05, rho = 0.20)"),
  p_value = c(ri_unadjusted$result$p_value,
              ri_design$result$p_value,
              ri_adjusted$result$p_value)
)
p_values
#>                           inference    p_value
#> 1                        Unadjusted 0.00517865
#> 2                      Design-based 0.67130599
#> 3 Adjusted (eta = 0.05, rho = 0.20) 0.05072345

Sensitivity

Evaluate the p-value over a grid of eta and rho values, and compute the critical curve for a chosen test level. Both plots below use the dual-penalty design m; the dashed black line in the p-value plot is its unadjusted p-value, and the dotted red line marks the 0.05 test level.

sens <- sensitivity_grid(
  m,
  "Y",
  eta = c(0, 0.01, 0.05, 0.10, 0.15, 0.20),
  rho = seq(0, 0.5, by = 0.05)
)
plot_sensitivity(sens)


crit <- critical_sensitivity(m, "Y", rho = seq(0, 0.5, by = 0.01))
crit
#> Critical eta (rho = 1): 0.0244
#> Critical ratio: 2.37%
#> Settings: alpha = 0.05; kappa = 2
crit$summary
#>   rho eta_critical critical_ratio
#> 1   1   0.02439996     0.02369962
plot_sensitivity(crit)

Interpretation

The sensitivity grid shows how the p-value changes as eta scales the design-based covariance bounds and rho controls their decay with the minimum network distance between matched sets. Smaller rho means faster decay; rho = 1 means no decay. Setting eta = 0 gives the unadjusted analysis, while eta = rho = 1 gives the design-based analysis.

The critical curve gives the value of eta at each rho for which the p-value equals alpha = 0.05. Within [0, 1], values below the curve retain rejection and values above it do not.

The summary reports the critical eta under no decay (rho = 1) and the critical ratio. The latter compares the average weighted covariance bound across relevant pairs of matched sets with the average weighted within-set variance at the critical boundary. If the unadjusted p-value is greater than alpha, there is no critical boundary.

The inferential assumptions are described in Dong and Lee (2026), cited by citation("netmatchRI"). In particular, the adjusted analysis requires the specified sensitivity bounds to cover the cross-matched-set covariances.