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.057961city$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)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.4A 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.00network_coordinates() snaps each observation to the
street network on its own side and computes, as rd2d’s
(X1 - b1, X2 - b2):
s: signed network distance to the boundary (positive on
the treated side);b: d(b), the
network distance used for kernel weights, and o(b), the
signed offset along the boundary.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 FALSErdbwnet() 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 okres <- 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
#> okplot(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 okAs in rd2d’s summary(WBATE = ..., LBATE = TRUE):
w' tau: weights are normalised
to sum to one, and the standard error sqrt(w' Sigma w) uses
the joint covariance res$cov of the estimates across
evaluation points. Points that were not estimated (masked) must get
weight 0.max_j tau(b_j): its interval
uses the uniform critical value, so it inherits the band’s joint
coverage (conservative for the maximum).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.0001247184Averaging correlated neighbours helps less than independence would suggest:
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.430Fuzzy 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.536Derivatives: 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.00290vce 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.370The 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]These need no special API; they combine the pieces above.
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.320Pass a pre-determined characteristic as the outcome; there should be no jump. In the mock city, floor-area ratio is unrelated to treatment.
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.6590273With 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.243coords$n_locations and coords$n_obs.