Boundary discontinuity designs on networks with rd2dnet

rd2dnet estimates boundary discontinuity designs on networks. It carries the location-based framework of rd2d (Cattaneo, Titiunik and Yu) over from Euclidean coordinates to network coordinates and user-defined network metrics: side-specific local polynomials, robust bias-corrected inference, uniform bands, data-driven bandwidths and aggregate effects. Please cite the rd2d papers (see citation("rd2dnet")) when you use it.

This vignette uses the bundled mock city, a small synthetic street grid with a boundary along “60th Street” (treated side to the south), a highway with ramps, and a park on the boundary. It is only an illustration; nothing here depends on that application. Everything runs offline.

library(rd2dnet)
city <- load_mock_midtown()
obs <- city$obs
y <- obs$outcome
head(obs)
#>          x        y treated    far block   outcome
#> 1 1592.404  863.317    TRUE 11.762  6010  0.020990
#> 2  102.434   52.888    TRUE  1.990     0 -0.404383
#> 3 2033.176 2920.818   FALSE  4.258  8036  1.577470
#> 4 1359.062 2992.232   FALSE  4.078  5037  2.234262
#> 5 2039.634    8.763    TRUE  2.826  8000 -0.290319
#> 6 2143.511  107.474    TRUE  2.429  8001 -1.057961

1. Data and network

city$nodes holds the street intersections with projected x/y coordinates (metres); city$edges the street segments with length, highway class, speed_kph and travel_time. city$boundary is a boundary(): a line, the treated side, and a mask for the park frontage, where one side has no parcels and no effect can be estimated.

nd <- city$nodes
e <- city$edges
plot(nd$x, nd$y, type = "n", asp = 1, xlab = "x (m)", ylab = "y (m)",
     main = "mock city: treated (red) / control (blue)")
segments(nd$x[match(e$from, nd$name)], nd$y[match(e$from, nd$name)],
         nd$x[match(e$to, nd$name)], nd$y[match(e$to, nd$name)], col = "grey75", lwd = 0.6)
polygon(city$park, col = adjustcolor("forestgreen", 0.3), border = NA)
points(obs$x, obs$y, pch = 16, cex = 0.25, col = ifelse(obs$treated, "firebrick", "steelblue"))
lines(city$boundary$line, lwd = 2)

city$boundary
#> <rd2dnet boundary> length=3000.0, treated=region, masks=1

2. Distance is a metric you choose

A network is a graph plus a metric. Distance means dissimilarity and does not have to be metres. Coordinates, kernel distances and bandwidths are all in the metric’s units, while the treatment side stays geographic.

metrics <- list(
  "length (m)" = "length",
  "travel time (s)" = travel_time(),
  # 50 extra units per unit change in floor-area ratio between adjacent nodes
  "length + land-use contrast" = node_contrast("far", 50),
  # switching road class costs 500 m; ramps are transparent connectors, so
  # highway -> ramp -> highway is cheap but highway -> ramp -> street is not
  "length + class switches" = transition_cost("length", class_attr = "highway",
                                              default_penalty = 500, connectors = "motorway_link")
)
pts <- obs[1:5, c("x", "y")]
round(sapply(metrics, function(m) {
  network_coordinates(city$network(m), city$boundary, pts, n_eval = 5)$s
}), 1)
#>      length (m) travel time (s) length + land-use contrast
#> [1,]      829.1            78.1                     1487.1
#> [2,]     1649.5           107.9                     2190.1
#> [3,]    -1354.0          -118.9                    -2327.9
#> [4,]    -1783.2          -178.7                    -2926.7
#> [5,]     1648.4           149.7                     3173.8
#>      length + class switches
#> [1,]                   829.1
#> [2,]                  1649.5
#> [3,]                 -1354.0
#> [4,]                 -2283.2
#> [5,]                  1648.4

A metric can also be any vectorised function of the edge table, for example function(edges) edges$length * ifelse(edges$highway == "motorway", 3, 1). Any node-by-node dissimilarity matrix works too (precomputed()). Observations then snap to the nearest node, because per-edge costs are unknown.

base <- city$network("length")
D <- distances_from(base, seq_len(base$n_nodes)) # or your own matrix, in node order
pre <- city$network(precomputed(sqrt(D))) # e.g. a concave transform
round(network_coordinates(pre, city$boundary, pts, n_eval = 5)$s, 2)
#> Warning: precomputed(450 x 450) cannot split edges; make sure boundary nodes
#> exist in the graph
#> [1]  26.83  38.99 -36.88 -43.13  40.00

3. Network coordinates

network_coordinates() snaps each observation to the street network on its own side and computes, as rd2d’s (X1 - b1, X2 - b2):

Evaluation points default to an even grid along the boundary; masked ones are skipped.

net <- city$network("length")
coords <- network_coordinates(net, city$boundary, obs, n_eval = 21)
coords
#> <rd2dnet coordinates> n=3000, treated=1764, eval_points=21, masked=7, locations=3000
head(as.data.frame(coords, j = 11))
#>          x        y treated         s crossing_arc  depth        d     o
#> 1 1592.404  863.317    TRUE   829.087         1900 16.683 1229.087   400
#> 2  102.434   52.888    TRUE  1649.546          400 27.112 2544.678 -1100
#> 3 2033.176 2920.818   FALSE -1353.994         2400 33.176 2253.994   900
#> 4 1359.062 2992.232   FALSE -1783.170         2150 32.232 2351.294   650
#> 5 2039.634    8.763    TRUE  1648.397         2400  8.763 2548.397   900
#> 6 2143.511  107.474    TRUE  1653.963         2650 27.474 2590.985  1150
head(eval_table(coords), 8)
#>    arc node node_arc    x    y masked
#> 1  100 9060      100 -300 1600  FALSE
#> 2  240 9060      100 -300 1600  FALSE
#> 3  380   60      400    0 1600  FALSE
#> 4  520   60      520  120 1600  FALSE
#> 5  660  160      660  260 1600  FALSE
#> 6  800  260      800  400 1600  FALSE
#> 7  940  260      940  540 1600  FALSE
#> 8 1080  360     1080  680 1600  FALSE

4. Estimation

rdbwnet() ports rd2d’s direct plug-in bandwidth selector (mserd by default; also msetwo, imserd, imsetwo and the coverage-error-optimal cer* variants). rdnet() selects bandwidths itself when h is not given, fits order p (default 1) for the point estimate and order q = p + 1 for robust bias-corrected inference, and adds a uniform band.

bw <- rdbwnet(y, coords)
head(bw[, c("arc", "h0", "h1", "n0", "n1", "status")])
#>   arc        h0        h1   n0   n1   status
#> 1 100        NA        NA   NA   NA singular
#> 2 240        NA        NA   NA   NA singular
#> 3 380  880.3830  880.3830 1236 1764       ok
#> 4 520  931.7889  931.7889 1236 1764       ok
#> 5 660  725.6914  725.6914 1236 1764       ok
#> 6 800 1033.4306 1033.4306 1236 1764       ok
res <- rdnet(y, coords, seed = 0)
res
#> rd2dnet: network boundary discontinuity estimates (sharp)
#>   metric=edge_cost("length")  kernel=triangular  p=1  q=2  vce=hc1
#>   evaluation points: 21  estimated: 12
#>   uniform band critical value: 2.699
#>       arc      h0      h1  n0  n1   tau    se tau_bc  ci_lo ci_hi   pvalue
#>   100.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>   240.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>   380.000 645.519 645.519  82 100 2.221 0.597 2.5427  0.711 4.374 6.50e-03
#>   520.000 730.659 730.659 125 146 2.216 0.422 2.4247  1.138 3.712 2.22e-04
#>   660.000 560.790 560.790 108 117 2.224 0.413 2.4153  1.050 3.781 5.27e-04
#>   800.000 821.495 821.495 194 266 2.157 0.290 2.2103  1.097 3.324 9.99e-05
#>   940.000 743.512 743.512 163 233 2.142 0.314 2.0946  1.027 3.163 1.21e-04
#>  1080.000 678.826 678.826  95 191 2.066 0.360 1.7011  0.869 2.533 6.13e-05
#>  1220.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  1360.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  1500.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  1640.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  1780.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  1920.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  2060.000      NA      NA   0   0    NA    NA     NA     NA    NA       NA
#>  2200.000 743.565 743.565 121 213 0.671 0.330 0.1552 -0.982 1.292 7.89e-01
#>  2340.000 552.100 552.100  91 111 0.555 0.353 0.0236 -1.099 1.147 9.67e-01
#>  2480.000 637.297 637.297 120 147 0.591 0.328 0.0819 -0.929 1.093 8.74e-01
#>  2620.000 729.850 729.850 154 179 0.758 0.304 0.3458 -0.621 1.313 4.83e-01
#>  2760.000 866.820 866.820 179 191 0.793 0.322 0.5559 -0.497 1.609 3.01e-01
#>  2900.000 727.187 727.187 102 107 0.651 0.483 0.1603 -1.505 1.825 8.50e-01
#>        status
#>  no bandwidth
#>  no bandwidth
#>            ok
#>            ok
#>            ok
#>            ok
#>            ok
#>            ok
#>        masked
#>        masked
#>        masked
#>        masked
#>        masked
#>        masked
#>        masked
#>            ok
#>            ok
#>            ok
#>            ok
#>            ok
#>            ok
plot(res, true_tau = city$true_tau(res$table$arc),
     main = "BATEC along the boundary: estimate, robust CIs, uniform band, truth")

Bandwidths can also be given directly: a scalar, c(h_control, h_treated), one per evaluation point, or a J x 2 matrix. weights multiplies the kernel weights (for example floor area), and pooled = TRUE fits a common-slope model.

res_fixed <- rdnet(y, coords, h = 600, weights = obs$far, seed = 0)
head(res_fixed$table[, c("arc", "tau_bc", "se_rb", "status")])
#>   arc   tau_bc     se_rb       status
#> 1 100       NA        NA insufficient
#> 2 240       NA        NA insufficient
#> 3 380 2.464088 0.8753670           ok
#> 4 520 2.935654 0.5621422           ok
#> 5 660 2.722338 0.5773783           ok
#> 6 800 2.586707 0.6496810           ok

5. Aggregate effects: WBATE and LBATE

As in rd2d’s summary(WBATE = ..., LBATE = TRUE):

ok <- res$table$status == "ok"
summary(res, wbate = as.numeric(ok), lbate = TRUE)$aggregate
#>            tau   tau_bc     se_rb     ci_lo    ci_hi       pvalue
#> WBATE 1.420357 1.225953 0.3286006 0.5819076 1.869998 0.0001908501
#> LBATE 2.224411 2.542737        NA 0.6768318 5.064925           NA
# Weight by frontage instead: here, by the number of parcels near each point.
wbate(res, ifelse(ok, res$table$n0 + res$table$n1, 0))
#>          tau       tau_bc        se_rb        ci_lo        ci_hi       pvalue 
#> 1.4461755880 1.2528537159 0.3265479181 0.6128315571 1.8928758746 0.0001247184

Averaging correlated neighbours helps less than independence would suggest:

se_naive <- sqrt(sum((ok / sum(ok))^2 * ifelse(ok, res$table$se_rb, 0)^2))
c(with_covariance = wbate(res, as.numeric(ok))[["se_rb"]], ignoring_it = se_naive)
#> with_covariance     ignoring_it 
#>       0.3286006       0.1820890

6. Covariates, fuzzy designs, derivatives

Covariates enter with coefficients common to both sides (rd2d’s covs_eff) and are estimated jointly with the local polynomials. In the mock city the outcome depends only weakly on log floor-area ratio, so adjusting for it leaves estimates and standard errors almost unchanged; a covariate that predicts the outcome well shrinks the standard errors.

res_cov <- rdnet(y, coords, h = 600, covs = log(obs$far), seed = 0)
res_raw <- rdnet(y, coords, h = 600, seed = 0)
cmp <- data.frame(tau_bc = res_raw$table$tau_bc, tau_bc_covs = res_cov$table$tau_bc,
                  se_rb = res_raw$table$se_rb, se_rb_covs = res_cov$table$se_rb)
head(round(na.omit(cmp), 3))
#>   tau_bc tau_bc_covs se_rb se_rb_covs
#> 3  2.563       2.579 0.954      0.973
#> 4  2.671       2.670 0.664      0.669
#> 5  2.339       2.338 0.660      0.662
#> 6  2.314       2.296 0.780      0.773
#> 7  1.849       1.836 0.650      0.648
#> 8  1.567       1.615 0.425      0.430

Fuzzy designs: pass take-up fuzzy = D. The estimate is the ratio of the outcome jump to the take-up jump at each point; the first stage is reported as fs/fs_se. Below, take-up is 75% on the treated side and 25% on the other (true first stage 0.5) and the effect of take-up is 2, in a larger simulated city (12,000 parcels).

big <- make_mock_midtown(n_obs = 12000, seed = 11)
big_coords <- network_coordinates(big$network(), big$boundary, big$obs, n_eval = 21)
set.seed(2)
T <- big_coords$treated
D_takeup <- as.numeric(ifelse(T, runif(length(T)) < 0.75, runif(length(T)) < 0.25))
y_fuzzy <- 0.0008 * (big$obs$y - 1600) + 2 * D_takeup + rnorm(length(T), 0, 0.5)
res_fz <- rdnet(y_fuzzy, big_coords, fuzzy = D_takeup, seed = 0)
head(round(na.omit(res_fz$table[, c("arc", "fs", "fs_se", "tau", "ci_lo", "ci_hi")]), 3))
#>    arc    fs fs_se   tau ci_lo ci_hi
#> 3  380 0.467 0.120 2.096 1.477 3.067
#> 4  520 0.461 0.068 1.998 1.316 2.361
#> 5  660 0.447 0.073 1.713 1.118 2.157
#> 6  800 0.427 0.077 1.730 0.699 2.133
#> 7  940 0.371 0.086 1.730 0.054 2.392
#> 8 1080 0.404 0.076 1.869 0.353 2.536

Derivatives: deriv = c(1, 0) is the jump in the slope across the boundary (a kink), deriv = c(0, 1) the jump in the slope along it, and tangvec = c(t_s, t_o) any directional derivative. The mock outcome has the same slope on both sides, so the true kink is zero.

kink <- rdnet(y, coords, h = 800, deriv = c(1, 0), seed = 0)
head(signif(na.omit(kink$table[, c("arc", "tau_bc", "ci_lo", "ci_hi")]), 3))
#>    arc    tau_bc    ci_lo   ci_hi
#> 3  380 -4.29e-04 -0.00843 0.00757
#> 4  520  9.98e-04 -0.00621 0.00821
#> 5  660 -4.64e-05 -0.00592 0.00582
#> 6  800  4.18e-04 -0.00622 0.00706
#> 7  940 -9.11e-04 -0.00641 0.00459
#> 8 1080 -2.57e-03 -0.00804 0.00290

7. Standard errors

vce takes "hc0"-"hc3"; cluster gives cluster-robust errors; conley_cutoff gives a Bartlett spatial HAC over network distance (or conley_distance = "euclidean"). The same choice drives the uniform band and WBATE/LBATE through res$cov.

A caution on cluster: as in rd2d, cluster-robust errors use the residuals of the local fit. When clusters are small relative to the bandwidth (here, city blocks inside a 600 m window), the local polynomial absorbs much of the within-cluster variation and the cluster standard errors come out too small. For spatially correlated shocks, the Conley option, whose residuals come from a wider auxiliary fit, is the validated choice.

fits <- list(
  HC1 = rdnet(y, coords, h = 600, seed = 0),
  cluster_block = rdnet(y, coords, h = 600, cluster = obs$block, seed = 0),
  conley_network = rdnet(y, coords, h = 600, conley_cutoff = 150, seed = 0),
  conley_euclidean = rdnet(y, coords, h = 600, conley_cutoff = 150, conley_distance = "euclidean", seed = 0)
)
head(round(na.omit(sapply(fits, function(r) r$table$se_rb)), 3))
#>        HC1 cluster_block conley_network conley_euclidean
#> [1,] 0.954         0.322          0.898            0.749
#> [2,] 0.664         0.431          0.660            0.602
#> [3,] 0.660         0.455          0.528            0.449
#> [4,] 0.780         0.454          0.621            0.491
#> [5,] 0.650         0.446          0.512            0.404
#> [6,] 0.425         0.257          0.393            0.370

8. Density (sorting) test

The local polynomial density test of Cattaneo, Jansson and Ma (rddensity), as a density() method for network coordinates. By default position is measured in cumulative street length from the boundary (normalize = "network"), so the test asks whether there are more parcels per metre of street on one side, instead of reacting to differences in street supply.

density(coords)
#> rd2dnet density test (network): jump=-1.98e-07 (bc 3.37e-06) se=3.2e-06 z=1.053 p=0.2923 log-ratio=-0.020
#>   [n=903/896, in window 241/272, h=12249.6/12249.6]
density(coords, normalize = "none")
#> rd2dnet density test (none): jump=2.53e-05 (bc -5.15e-05) se=9.4e-05 z=-0.548 p=0.5834 log-ratio=0.643
#>   [n=903/896, in window 136/144, h=326.8/326.8]

9. Recipes

These need no special API; they combine the pieces above.

Donut

Drop observations within delta (in metric units) of the boundary. Missing outcomes are skipped.

y_donut <- ifelse(abs(coords$s) < 40, NA, y)
donut <- rdnet(y_donut, coords, h = 700, seed = 0)
head(round(na.omit(data.frame(full = res_fixed$table$tau_bc, donut_40m = donut$table$tau_bc)), 3))
#>    full donut_40m
#> 3 2.464     1.340
#> 4 2.936     1.085
#> 5 2.722     1.435
#> 6 2.587     1.169
#> 7 2.050     1.539
#> 8 1.796     1.320

Covariate balance

Pass a pre-determined characteristic as the outcome; there should be no jump. In the mock city, floor-area ratio is unrelated to treatment.

bal <- rdnet(log(obs$far), coords, seed = 0)
summary(bal, wbate = as.numeric(bal$table$status == "ok"), lbate = TRUE)$aggregate
#>               tau     tau_bc     se_rb      ci_lo     ci_hi    pvalue
#> WBATE 0.005795486 0.06236909 0.1496254 -0.2308913 0.3556295 0.6767992
#> LBATE 0.105378732 0.24280099        NA -0.4586853 1.3672864        NA

Placebo boundary

Build a boundary() along a fake line inside one side and use only that side’s observations. Here: a street 8 blocks north, on the control side. It runs through the park, which is masked.

fake_y <- city$boundary_y + 8 * 80
north <- obs$y > city$boundary_y
fake <- boundary(rbind(c(-400, fake_y), c(2600, fake_y)),
                 treated_region = rect_polygon(-1e5, -1e5, 1e5, fake_y), mask = list(city$park))
pc <- network_coordinates(city$network(), fake, obs[north, ], n_eval = 15)
placebo <- rdnet(y[north], pc, seed = 0)
summary(placebo, wbate = as.numeric(placebo$table$status == "ok"))$aggregate
#>                tau    tau_bc     se_rb     ci_lo     ci_hi    pvalue
#> WBATE -0.004436142 0.1262539 0.2861236 -0.434538 0.6870458 0.6590273

Difference-in-discontinuities

With a panel (the same units before and after), run on y_post - y_pre. With independent samples, estimate each period with the same evaluation points and bandwidths and difference the estimates, with se = sqrt(se_pre^2 + se_post^2).

set.seed(5)
f <- y - city$true_tau(coords$crossing_arc) * coords$treated # outcome without the effect
y_pre <- f + rnorm(length(y), 0, 0.5) # same parcels, earlier period
y_post <- y + rnorm(length(y), 0, 0.5)
did_panel <- rdnet(y_post - y_pre, coords, h = 700, seed = 0)

c_pre <- make_mock_midtown(n_obs = 3000, seed = 21)
c_post <- make_mock_midtown(n_obs = 3000, seed = 22)
co_pre <- network_coordinates(c_pre$network(), c_pre$boundary, c_pre$obs, n_eval = 21)
co_post <- network_coordinates(c_post$network(), c_post$boundary, c_post$obs, n_eval = 21)
y0 <- c_pre$obs$outcome - c_pre$true_tau(co_pre$crossing_arc) * co_pre$treated
r_pre <- rdnet(y0, co_pre, h = 700, seed = 0)$table
r_post <- rdnet(c_post$obs$outcome, co_post, h = 700, seed = 0)$table
did <- data.frame(arc = r_post$arc, panel = did_panel$table$tau_bc,
                  independent = r_post$tau_bc - r_pre$tau_bc,
                  se_independent = sqrt(r_pre$se_rb^2 + r_post$se_rb^2),
                  truth = city$true_tau(r_post$arc))
head(round(na.omit(did), 3))
#>    arc panel independent se_independent truth
#> 3  380 1.876       1.037          0.910 1.462
#> 4  520 1.577       0.621          0.739 1.723
#> 5  660 1.805       0.963          0.683 1.956
#> 6  800 2.156       1.538          0.651 2.133
#> 7  940 2.445       2.261          0.623 2.233
#> 8 1080 2.869       2.277          0.825 2.243

10. Large data

panel_xy <- as.matrix(obs[rep(1:500, each = 20), c("x", "y")]) # 500 sensors x 20 readings
pcoords <- network_coordinates(net, city$boundary, panel_xy, n_eval = 11)
c(n_obs = pcoords$n_obs, n_locations = pcoords$n_locations)
#>       n_obs n_locations 
#>       10000         500