The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.
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.These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.