Public libraries are a basic element of urban social infrastructure. Their locations should be sufficiently dispersed to ensure access across the city, but they may also cluster in dense, central and historically developed areas. This project asks:
The units of analysis are service locations, not organisations. If several named departments share a building, they are treated as one site. This avoids coincident points, which are inappropriate for a simple point process.
Locations were extracted on 31 August 2026 from OpenStreetMap through
the Overpass API. Records tagged amenity=library within the
administrative area of Warsaw were retained when the Polish name or
operator contained a phrase characteristic of a public branch:
biblioteka publiczna, wypożyczalnia,
czytelnia, or dla dzieci. This operational rule
excludes most university, school and specialist libraries, but it may
still omit a public facility with incomplete OSM tags. The official city
portal reports 202 public library facilities; it is used as a
plausibility benchmark rather than as a point-level data source.
The city boundary was obtained through Nominatim/OpenStreetMap. All computations use Poland CS92 (EPSG:2180), so distances are in metres and areas in square metres. The raw extraction and boundary are bundled with this report, making the analysis reproducible without an internet connection.
raw <- read.csv("warsaw_public_libraries.csv", encoding = "UTF-8",
check.names = FALSE)
boundary <- st_read("warsaw_boundary.geojson", quiet = TRUE) |>
st_make_valid() |>
st_transform(2180) |>
summarise()
pts <- st_as_sf(raw, coords = c("longitude", "latitude"), crs = 4326,
remove = FALSE) |>
st_transform(2180)
pts <- pts[lengths(st_within(pts, boundary)) > 0, ]
# One point per physical site (one-metre rounding handles exact/coincident records).
xy0 <- st_coordinates(pts)
pts$site_key <- paste(round(xy0[,1]), round(xy0[,2]), sep = "_")
sites <- pts |>
group_by(site_key) |>
summarise(n_units = n(), names = paste(unique(name), collapse = " | "),
do_union = FALSE) |>
ungroup()
data.frame(
stage = c("OSM records matching the rule", "Inside Warsaw boundary",
"Unique service locations"),
n = c(nrow(raw), nrow(pts), nrow(sites))
) |> knitr::kable(caption = "Cleaning and aggregation of the point data")
| stage | n |
|---|---|
| OSM records matching the rule | 206 |
| Inside Warsaw boundary | 206 |
| Unique service locations | 206 |
ggplot() +
geom_sf(data = boundary, fill = "grey97", colour = "grey45", linewidth = 0.35) +
geom_sf(data = sites, aes(size = n_units), colour = "#087E8B", alpha = 0.78) +
scale_size_continuous(range = c(1.5, 4.5), breaks = c(1, 2, 3),
name = "Units at site") +
coord_sf(datum = NA) +
labs(title = "Public-library service locations in Warsaw",
subtitle = "Several units at one address are represented by one point",
x = NULL, y = NULL,
caption = "Data: OpenStreetMap contributors (ODbL), extraction 2026-08-31")
The map should be read together with the irregular observation window. Sparse areas near the city edge include forests, the airport and low-density neighbourhoods; a homogeneous model is therefore deliberately only a baseline.
W <- as.owin(st_geometry(boundary))
xy <- st_coordinates(sites)
X <- ppp(x = xy[,1], y = xy[,2], window = W, checkdup = TRUE)
unitname(X) <- c("metre", "metres")
area_km2 <- area.owin(W) / 1e6
intensity_km2 <- npoints(X) / area_km2
nnd_km <- nndist(X) / 1000
summary_table <- data.frame(
Measure = c("Number of unique sites", "Window area (km²)",
"Mean sites per km²", "Mean nearest-neighbour distance (km)",
"Median nearest-neighbour distance (km)"),
Value = c(npoints(X), round(area_km2, 1), round(intensity_km2, 3),
round(mean(nnd_km), 3), round(median(nnd_km), 3))
)
knitr::kable(summary_table, caption = "Basic characteristics of the pattern")
| Measure | Value |
|---|---|
| Number of unique sites | 206.000 |
| Window area (km²) | 516.700 |
| Mean sites per km² | 0.399 |
| Mean nearest-neighbour distance (km) | 0.524 |
| Median nearest-neighbour distance (km) | 0.391 |
ggplot(data.frame(distance = nnd_km), aes(distance)) +
geom_histogram(binwidth = 0.25, boundary = 0, fill = "#087E8B", colour = "white") +
labs(title = "Nearest-neighbour distances", x = "Distance (km)", y = "Number of sites")
A kernel estimate provides a descriptive picture of the spatially
varying intensity. The bandwidth is selected by likelihood
cross-validation. Edge correction is applied by
density.ppp.
bw <- bw.ppl(X)
lambda_hat <- density(X, sigma = bw, edge = TRUE, at = "pixels")
plot(lambda_hat / 1e6, main = paste0("Kernel intensity (sites per km²); bandwidth = ",
round(bw/1000, 2), " km"),
ribargs = list(las = 1))
plot(X, add = TRUE, pch = 16, cex = 0.35)
To represent the centre–periphery gradient parsimoniously, distance from the centroid of Warsaw is used as a spatial covariate. It is not interpreted causally: it is a large-scale trend control.
cent <- st_coordinates(st_centroid(boundary))[1,]
dist_centre <- as.im(function(x, y) sqrt((x-cent[1])^2 + (y-cent[2])^2)/1000, W = W)
fit0 <- ppm(X ~ 1)
fit_xy <- ppm(X ~ polynom(x, y, 2))
fit_d <- ppm(X ~ d + I(d^2), covariates = list(d = dist_centre))
models <- data.frame(
Model = c("Homogeneous Poisson", "Quadratic spatial trend",
"Quadratic distance-to-centre trend"),
Parameters = c(length(coef(fit0)), length(coef(fit_xy)), length(coef(fit_d))),
AIC = c(AIC(fit0), AIC(fit_xy), AIC(fit_d))
) |>
mutate(Delta_AIC = round(AIC - min(AIC), 2), AIC = round(AIC, 2)) |>
arrange(AIC)
knitr::kable(models, caption = "Comparison of Poisson point-process models")
| Model | Parameters | AIC | Delta_AIC |
|---|---|---|---|
| Quadratic spatial trend | 6 | 6327.40 | 0.00 |
| Quadratic distance-to-centre trend | 3 | 6362.91 | 35.52 |
| Homogeneous Poisson | 1 | 6484.87 | 157.48 |
dgrid <- seq(0, max(dist_centre$v, na.rm = TRUE), length.out = 200)
b <- coef(fit_d)
eta <- b[1] + b[2] * dgrid + b[3] * dgrid^2
effect <- data.frame(distance = dgrid, relative_intensity = exp(eta - eta[1]))
ggplot(effect, aes(distance, relative_intensity)) +
geom_line(linewidth = 1, colour = "#C81D25") +
geom_hline(yintercept = 1, linetype = 3) +
labs(title = "Fitted centre--periphery trend",
subtitle = "Intensity relative to the fitted value at the city centroid",
x = "Distance from Warsaw centroid (km)", y = "Relative intensity")
Ripley’s \(K\) function compares the observed number of neighbours within distance \(r\) with the CSR expectation \(K(r)=\pi r^2\). The transformed \(L(r)-r\) is easier to read: positive values indicate more close pairs than expected under CSR. A global simulation envelope (39 simulations) is shown for the homogeneous baseline.
E <- envelope(X, fun = Kest, nsim = 39, correction = "border",
transform = expression(sqrt(./pi) - r), global = TRUE,
savefuns = TRUE, verbose = FALSE)
plot(E, . - r ~ r, main = "CSR envelope for Ripley's L(r) - r",
xlab = "r (metres)", ylab = "L(r) - r (metres)")
abline(h = 0, lty = 3)
Because CSR confounds interaction with a varying intensity, the inhomogeneous \(K\)-function is the more relevant diagnostic. The fitted quadratic spatial trend is used to estimate intensity at each observed site.
lambda_i <- predict(fit_xy, locations = X, type = "trend")
Ki <- Kinhom(X, lambda = lambda_i, correction = "border")
plot(Ki, sqrt(border/pi) - r ~ r, main = "Inhomogeneous L(r) - r",
xlab = "r (metres)", ylab = "Linhom(r) - r (metres)")
abline(h = 0, lty = 3)
diag_xy <- diagnose.ppm(fit_xy, type = "pearson")
plot(diag_xy, which = "smooth", main = "Smoothed Pearson residuals")
Q <- quadrat.test(fit_xy, nx = 4, ny = 4)
data.frame(statistic = unname(Q$statistic), df = unname(Q$parameter),
p_value = Q$p.value) |>
knitr::kable(digits = 3, caption = "Pearson quadrat goodness-of-fit test")
| statistic | df | p_value |
|---|---|---|
| 22.783 | 9 | 0.013 |
The project separates two questions that are easily confused. The CSR analysis asks whether libraries look like points from a constant-rate Poisson process; the model comparison and inhomogeneous \(K\) analysis ask whether departures remain after a large-scale intensity trend is allowed.
Interpret the generated output as follows:
These results are descriptive, not an evaluation of accessibility. A stronger policy analysis would add population density, age structure, public-transport travel time, opening hours and capacity. The OSM selection rule and completeness are further limitations. Consequently, the report supports claims about the spatial pattern in the supplied data, not about causal determinants or equal access.
Baddeley, A., Rubak, E., & Turner, R. (2015). Spatial Point Patterns: Methodology and Applications with R. CRC Press.
Kopczewska, K. (2020). Applied Spatial Statistics and Econometrics: Data Analysis in R. Routledge.
Moraga, P. (2023). Spatial Statistics for Data Science: Theory and Practice with R. Chapman & Hall/CRC.
OpenStreetMap contributors (2026). OpenStreetMap data, ODbL 1.0. Data accessed via the Overpass API and Nominatim on 31 August 2026.
Warszawa 19115 (2026). Biblioteki Publiczne m.st. Warszawy. City information portal; used to benchmark the approximate number of facilities.
sessionInfo()
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=Polish_Poland.utf8 LC_CTYPE=Polish_Poland.utf8
## [3] LC_MONETARY=Polish_Poland.utf8 LC_NUMERIC=C
## [5] LC_TIME=Polish_Poland.utf8
##
## time zone: Europe/Warsaw
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] spatstat.model_3.7-2 rpart_4.1.24 spatstat.explore_3.8-2
## [4] nlme_3.1-168 spatstat.random_3.5-1 spatstat.geom_3.8-2
## [7] spatstat.univar_3.2-0 spatstat.data_3.1-9 ggplot2_4.0.3
## [10] dplyr_1.2.1 sf_1.1-2
##
## loaded via a namespace (and not attached):
## [1] s2_1.1.9 sass_0.4.10 generics_0.1.4
## [4] class_7.3-23 tensor_1.5.1 KernSmooth_2.23-26
## [7] lattice_0.22-7 digest_0.6.39 magrittr_2.0.4
## [10] spatstat.utils_3.2-4 evaluate_1.0.5 grid_4.5.2
## [13] RColorBrewer_1.1-3 fastmap_1.2.0 jsonlite_2.0.0
## [16] Matrix_1.7-4 spatstat.sparse_3.2-0 e1071_1.7-17
## [19] DBI_1.2.3 mgcv_1.9-3 scales_1.4.0
## [22] jquerylib_0.1.4 abind_1.4-8 cli_3.6.6
## [25] rlang_1.2.0 units_1.0-0 polyclip_1.10-7
## [28] splines_4.5.2 withr_3.0.2 cachem_1.1.0
## [31] yaml_2.3.12 otel_0.2.0 tools_4.5.2
## [34] deldir_2.0-4 vctrs_0.7.3 R6_2.6.1
## [37] proxy_0.4-29 lifecycle_1.0.5 classInt_0.4-11
## [40] pkgconfig_2.0.3 pillar_1.11.1 bslib_0.9.0
## [43] gtable_0.3.6 glue_1.8.0 Rcpp_1.1.1
## [46] xfun_0.55 tibble_3.3.1 tidyselect_1.2.1
## [49] rstudioapi_0.17.1 knitr_1.51 goftest_1.2-3
## [52] farver_2.1.2 htmltools_0.5.9 labeling_0.4.3
## [55] rmarkdown_2.31 wk_0.9.5 compiler_4.5.2
## [58] S7_0.2.1