The Roman Empire and contemporary house prices in the Netherlands

Creation of Title Page figure

# Load necessary packages
library(sf); library(ggplot2); library(dplyr)

# Load in the Netherlands Roman Empire data
netherlands_re <- st_read("Data/netherlands_roman_updated.geojson")
## Reading layer `netherlands_roman_updated' from data source 
##   `C:\Users\Milan\OneDrive - Universiteit Utrecht\FINAL thesis code\Data\netherlands_roman_updated.geojson' 
##   using driver `GeoJSON'
## Simple feature collection with 355 features and 5 fields
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: 3.360782 ymin: 50.72349 xmax: 7.227095 ymax: 53.55458
## Geodetic CRS:  WGS 84
# Convert the 'is_in_re' column to a factor
netherlands_re$is_in_re <- as.factor(netherlands_re$is_in_re)


# Filter out water observations from municipalities
excluded_areas <- c("IJsselmeer", "Zeeuwse meren", "Zuid Hollandse Meren")
netherlands_re <- netherlands_re %>%
  filter(!(NAME_1 %in% excluded_areas))

# Create the map
titlepage_plot <- ggplot(netherlands_re) +
  geom_sf(aes(fill = is_in_re), color = "black", size = 0.1) +
  scale_fill_manual(
    values = c("1" = "#800000", "0" = "white"), 
    labels = c("0" = "No", "1" = "Yes"),
    name = "Roman Empire"
  ) +
  labs(title = "Dutch Municipalities & the Roman Empire") +
  theme_minimal() +
  theme(legend.position = "bottom",
        panel.background = element_rect(fill = "lightblue"),
        plot.background = element_rect(fill = "lightblue"))

# Save the plot
ggsave("Figures/titlepage.png", plot = titlepage_plot, width = 6, height = 7, dpi = 300)

Creation of the Main Dataset

# Load additional packages
library(tidyverse); library(geodata)

# Roman Empire
re <- cawd::awmc.roman.empire.117.sp |>
  st_as_sf()

# Funda house prices with coordinates
funda_data <- read.csv("Data/houses_funda.csv", sep = ";")
funda_data$postcode_4 <- as.character(funda_data$postcode_4)
                                      
geopostcode <- st_read("Data/georef-netherlands-postcode-pc4.geojson")
## Reading layer `georef-netherlands-postcode-pc4' from data source 
##   `C:\Users\Milan\OneDrive - Universiteit Utrecht\FINAL thesis code\Data\georef-netherlands-postcode-pc4.geojson' 
##   using driver `GeoJSON'
## Simple feature collection with 4068 features and 7 fields
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: 3.358524 ymin: 50.75119 xmax: 7.228207 ymax: 53.5186
## Geodetic CRS:  WGS 84
## Merge the datasets based on postal codes
funda_geo <- funda_data |>
  left_join(geopostcode, by = c("postcode_4" = "pc4_code"))

# Convert funda_geo to an sf object
funda_geo <- funda_geo |> 
  st_as_sf()

# Is the house inside the roman empire?
is_in_re <- st_intersects(funda_geo, re)
is_in_re <- is_in_re |> as.numeric()

# Compute whether house is in empire
funda_geo <- funda_geo |>
  mutate(is_in_re = if_else(is.na(is_in_re), 0, is_in_re))

# Compute border
boundary <- re |> st_boundary() |> st_intersection(funda_geo)

# Define the coordinates of the bounding box
coords <- matrix(c(4.6, 52.5,
                   4.6, 52.6,
                   6.4, 52.3,
                   6.4, 51.7,
                   4.6, 52.5), ncol = 2, byrow = TRUE)

# Convert LINESTRING to POLYGON and create an sf object
bounding_box_polygon <- st_polygon(list(coords))
bounding_box_sf <- st_sf(geometry = st_sfc(bounding_box_polygon))

# Set the Coordinate Reference System (CRS)
st_crs(bounding_box_sf) <- 4326

# Create final boundary
final_boundary <- st_intersection(boundary |> st_union(), bounding_box_sf)

# Distance to border:
distances <- funda_geo |>
  st_centroid() |>
  st_distance(final_boundary)

# Add to df
funda_geo <- funda_geo |>
  mutate(distance_to_border = as.numeric(if_else(is_in_re == 1, distances, - distances)))

# Add geographical control variables

waterbodiesnl <- st_read("Data/KRW_waterbodies.geojson")
## Reading layer `KRW_waterbodies' from data source 
##   `C:\Users\Milan\OneDrive - Universiteit Utrecht\FINAL thesis code\Data\KRW_waterbodies.geojson' 
##   using driver `GeoJSON'
## Simple feature collection with 54 features and 40 fields
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: -4962.57 ymin: 307330.2 xmax: 276801.2 ymax: 642207
## Projected CRS: Amersfoort / RD New
# Ensure both datasets are in the same CRS
waterbodiesnl <- st_transform(waterbodiesnl, st_crs(funda_geo))

# Calculate the distance from each house to the nearest water body
dist_to_water <- st_distance(st_centroid(funda_geo), waterbodiesnl)

# Extract the minimum distance for each house
min_dist_to_water <- apply(dist_to_water, 1, min)

# Add the distance to the funda_geo dataset as a new column
funda_geo <- funda_geo |>
  mutate(distance_to_water = as.numeric(min_dist_to_water))

# Load ancient city data 
ancientcities <- read.csv("Data/Hanson2016_Cities_OxREP.csv")
ancientcities_sf <- st_as_sf(ancientcities, coords = c("Longitude..X.", "Latitude..Y."), crs = 4326)

# Ensure both datasets are in the same CRS
ancientcities_sf <- st_transform(ancientcities_sf, st_crs(funda_geo))

# Calculate the distance from each house to the nearest ancient city
dist_to_ancient_city <- st_distance(st_centroid(funda_geo), ancientcities_sf)

# Extract the minimum distance for each house
min_dist_to_ancient_city <- apply(dist_to_ancient_city, 1, min)

# Add the distance to the funda_geo dataset as a new column
funda_geo <- funda_geo |>
  mutate(distance_to_ancient_city = as.numeric(min_dist_to_ancient_city))

# Clean column names
names(funda_geo) <- make.names(names(funda_geo))

# Export to geojson
funda_geo |> write_sf("main_data.geojson")

Preparing the data

# Load in the main dataset
main_data <- st_read("main_data.geojson")
## Reading layer `main_data' from data source 
##   `C:\Users\Milan\OneDrive - Universiteit Utrecht\FINAL thesis code\main_data.geojson' 
##   using driver `GeoJSON'
## Simple feature collection with 40020 features and 21 fields (with 4 geometries empty)
## Geometry type: MULTIPOLYGON
## Dimension:     XY
## Bounding box:  xmin: 3.358524 ymin: 50.75559 xmax: 7.228207 ymax: 53.47275
## Geodetic CRS:  WGS 84
# Remove observations with NA values
main_data <- na.omit(main_data)

# Remove unnecessary variables
main_data <- main_data %>% select(-prices_per_sq_m, -geo_point_2d, -year, -gem_code, -prov_code)

# Remove the municipality of Amsterdam and province of Flevoland observations
main_data <- main_data %>% filter(gem_name != "Amsterdam", prov_name != "Flevoland")
# Convert energy labels to numeric for the analysis. Making the highest energy label
# also the highest number
energy_levels <- c("G", "F", "E", "D", "C", "B", "A", "A+", "A++", "A+++", "A++++")

main_data$energy_label <- factor(main_data$energy_label, levels = energy_levels, ordered = TRUE)

main_data$energy_label <- as.numeric(main_data$energy_label)
# Convert prices to numeric and correcting values of 1 million + that are incorrectly
# extracted from funda as 1,000 instead of 1,000,000

main_data$prices <- as.numeric(gsub(",", "", main_data$prices))

main_data$prices <- sapply(main_data$prices, function(x) {
  if (x < 20000) {
    return(x * 1000)
  } else {
    return(x)
  }
})
# Converting the footage_interior and footage_exterior variables to numeric and square meters
# Also correcting values of 1000 m^2 + that are incorrectly extracted from funda

main_data$footage_interior <- as.numeric(gsub(",", "", main_data$footage_interior))
main_data$footage_exterior <- as.numeric(gsub(",", "", main_data$footage_exterior))

# Removing 1 observation that would incorrectly be corrected along with
# observations with 1000 m^2+ values
min_footage_interior <- min(main_data$footage_interior, na.rm = TRUE)
main_data <- main_data %>% filter(footage_interior != min_footage_interior)

# Correcting values
main_data$footage_exterior <- sapply(main_data$footage_exterior, function(x) {
  if (x < 25000) {
    return(x * 1000)
  } else {
    return(x)
  }
})

# Convert to square meters and rename variables
main_data <- main_data %>%
  mutate(
    footage_interior = footage_interior / 1000,
    footage_exterior = footage_exterior / 1000
  ) %>%
  rename(
    sqm_interior = footage_interior,
    sqm_exterior = footage_exterior
  )
# Convert bedrooms variable to numeric for the analysis
main_data$bedrooms <- as.numeric(main_data$bedrooms)

Analysis

# Load additional packages
library(stargazer);library(knitr);library(kableExtra);library(summarytools)
library(data.table); library(webshot); library(scales); library(gt)

Descriptive Statistics

# Convert data frame to data.table
main_data_dt <- as.data.table(main_data)

# Compute descriptive statistics
descriptive_stats <- main_data_dt[, .(
  N = .N,
  Mean = mean(prices, na.rm = TRUE),
  SD = sd(prices, na.rm = TRUE),
  Min = min(prices, na.rm = TRUE),
  Max = max(prices, na.rm = TRUE)
), by = is_in_re]

# Format the numeric values with commas for thousands
descriptive_stats[, Mean := comma(Mean)]
descriptive_stats[, SD := comma(SD)]
descriptive_stats[, Min := comma(Min)]
descriptive_stats[, Max := comma(Max)]
descriptive_stats[, N := comma(N)]

# Create the table
descriptive_table_gt <- gt(descriptive_stats) %>%
  tab_header(
    title = "Table 1 - Descriptive Statistics"
  ) %>%
  cols_label(
    is_in_re = "Is in Roman Empire",
    N = "N",
    Mean = "Mean",
    SD = "SD",
    Min = "Min",
    Max = "Max"
  ) %>%
  fmt_number(
    columns = vars(N, Mean, SD, Min, Max),
    decimals = 2,
    use_seps = TRUE
  ) %>%
  tab_source_note(
    source_note = "Prices are in euros"
  ) %>%
  cols_align(
    align = "center",
    columns = everything()
  ) %>%
  tab_options(
    table.font.size = "small",
    table.font.names = "Arial"
  )

# Save the table as an HTML file
gtsave(descriptive_table_gt, "Tables/descstats/desc_stats_table_gt.html")

Linear Regression Models

# Linear regression model 1 with only house characteristics as control variables
lrm1 <- lm(prices ~ is_in_re + sqm_interior + sqm_exterior + bedrooms + energy_label, data = main_data)

# Adding geographical control variables to linear regression model 1 
lrm2 <- lm(prices ~ is_in_re + sqm_interior + sqm_exterior + bedrooms + energy_label
           + distance_to_border + distance_to_water + distance_to_ancient_city, data = main_data)

# Combine both models into one table and save as HTML
stargazer(lrm1, lrm2, type = "html", title = "Table 2 - Linear Regression",
          column.labels = c("LR Model 1", "LR Model 2"),
          covariate.labels = c("In Roman Empire", "Interior Size (sqm)", "Exterior Size (sqm)",
                               "Number of Bedrooms", "Energy Label", "Distance to Border", 
                               "Distance to Water", "Distance to Ancient City"),
          dep.var.caption = "Dependent Variable: Housing Prices",
          out = "Tables/LRM/lrm1.2_table.html")
# Checking for multicolinearity because of negative "bedrooms" coefficient
library(car)

vif2_values <- vif(lrm2)
print(vif2_values)
##                 is_in_re             sqm_interior             sqm_exterior 
##                 3.073957                 1.881289                 1.207393 
##                 bedrooms             energy_label       distance_to_border 
##                 1.636848                 1.025462                 3.167364 
##        distance_to_water distance_to_ancient_city 
##                 1.609852                 1.958498

Linear regression models robustness checks

# Adding interaction terms to lrm 2
lrm3 <- lm(prices ~ is_in_re * sqm_interior + sqm_exterior + bedrooms + energy_label
           + distance_to_border + distance_to_water + distance_to_ancient_city, data = main_data)

# Adding a log transformation for the dependent variable "prices" 
lrm4 <- lm(log(prices) ~ is_in_re * sqm_interior + sqm_exterior + bedrooms + energy_label
           + distance_to_border + distance_to_water + distance_to_ancient_city, data = main_data)

# Correct labels based on model summaries
lrmrbc_covariate_labels <- c("In Roman Empire", "Interior Size (sqm)",
                          "Exterior Size (sqm)", "Number of Bedrooms", "Energy Label", 
                          "Distance to Border", "Distance to Water", "Distance to Ancient City",
                          "In Roman Empire * Interior Size")

# Create a table for both models and save as HTML
stargazer(lrm3, lrm4, type = "html", title = "Table 3 - Linear Regression Robustness Checks",
          column.labels = c("LR Model 3", "LR Model 4"),
          covariate.labels = lrmrbc_covariate_labels,
          dep.var.labels.include = FALSE,
          model.names = FALSE,
          header = FALSE,
          add.lines = list(c("Dependent Variable", "Housing Prices", "log(Housing Prices)")),
          out = "Tables/LRM/lrm3_4_table.html")

Spatial Regression Discontinuity Design (RDD)

# Load additional packages
library(rdrobust)

Covariate Balance Checks

# Run the balance checks

# Size Interior
rdd_balance1 <- rdrobust(y = main_data$sqm_interior, x = main_data$distance_to_border, c = 0)

# Size Exterior
rdd_balance2 <- rdrobust(y = main_data$sqm_exterior, x = main_data$distance_to_border, c = 0)

# Bedrooms
rdd_balance3 <- rdrobust(y = main_data$bedrooms, x = main_data$distance_to_border, c = 0)

# Energy Label
rdd_balance4 <- rdrobust(y = main_data$energy_label, x = main_data$distance_to_border, c = 0)

# Function to extract p-value and coefficients
balance_coefs <- function(rdd_result) {
  coef <- rdd_result$coef[1, 1]
  se <- rdd_result$se[1, 1]
  p_value <- rdd_result$pv[1, 1]
  stars <- if (p_value < 0.01) {
    "***"
  } else if (p_value < 0.05) {
    "**"
  } else if (p_value < 0.1) {
    "*"
  } else {
    ""
  }
  return(list(coef = paste0(round(coef, 3), stars), se = round(se, 3)))
}

# Extracting the necessary statistics
balance_results <- data.frame(
  Variable = c("Size Interior", "Size Exterior", "Bedrooms", "Energy Label"),
  Coefficient = c(balance_coefs(rdd_balance1)$coef, 
                  balance_coefs(rdd_balance2)$coef, 
                  balance_coefs(rdd_balance3)$coef, 
                  balance_coefs(rdd_balance4)$coef),
  SE = c(balance_coefs(rdd_balance1)$se, 
         balance_coefs(rdd_balance2)$se, 
         balance_coefs(rdd_balance3)$se, 
         balance_coefs(rdd_balance4)$se),
  Control = c(rdd_balance1$N_h[1], rdd_balance2$N_h[1], rdd_balance3$N_h[1], rdd_balance4$N_h[1]),
  Treated = c(rdd_balance1$N_h[2], rdd_balance2$N_h[2], rdd_balance3$N_h[2], rdd_balance4$N_h[2]),
  Bandwidth = c(rdd_balance1$bws[1, 1], rdd_balance2$bws[1, 1], rdd_balance3$bws[1, 1], rdd_balance4$bws[1, 1])
)

# Creating the table
balance_table <- gt(balance_results) %>%
  tab_header(title = "Table 4 - Balance Check Results") %>%
  tab_source_note(
    source_note = "Statistical Significance: *** p<0.01, ** p<0.05, * p<0.1"
  )

# Saving the table as an HTML file
gtsave(balance_table, "Tables/RDD/balance_check_results.html")

RDD Analysis - Models 1, 2 & 3

# RDD Model 1 
rdd1 <- rdrobust(y = main_data$prices, x = main_data$distance_to_border, c = 0)

# RDD Model 2
rdd2 <- rdrobust(y = main_data$prices, x = main_data$distance_to_border, c = 0,
                           covs=cbind(main_data$sqm_interior, main_data$sqm_exterior, 
                                      main_data$bedrooms, main_data$energy_label))

# RDD Model 3
rdd3 <- rdrobust(y = main_data$prices, x = main_data$distance_to_border, c = 0,
                           covs=cbind(main_data$sqm_interior, main_data$sqm_exterior, 
                                      main_data$bedrooms, main_data$energy_label,
                                      main_data$distance_to_water, main_data$distance_to_ancient_city))

# Function to extract p-value and coefficients
rdd_coefs <- function(rdd_result) {
  coef <- rdd_result$coef[1, 1]
  se <- rdd_result$se[1, 1]
  p_value <- rdd_result$pv[1, 1]
  stars <- if (p_value < 0.01) {
    "***"
  } else if (p_value < 0.05) {
    "**"
  } else if (p_value < 0.1) {
    "*"
  } else {
    ""
  }
  return(list(coef = paste0(round(coef, 3), stars), se = round(se, 3)))
}

# Extracting the necessary statistics
rdd_results <- data.frame(
  Model = c("RDD Model 1", "RDD Model 2", "RDD Model 3"),
  Coefficient = c(rdd_coefs(rdd1)$coef, 
                  rdd_coefs(rdd2)$coef, 
                  rdd_coefs(rdd3)$coef),
  SE = c(rdd_coefs(rdd1)$se, 
         rdd_coefs(rdd2)$se, 
         rdd_coefs(rdd3)$se),
  Control = c(rdd1$N_h[1], rdd2$N_h[1], rdd3$N_h[1]),
  Treated = c(rdd1$N_h[2], rdd2$N_h[2], rdd3$N_h[2]),
  Bandwidth = c(rdd1$bws[1, 1], rdd2$bws[1, 1], rdd3$bws[1, 1])
)

# Creating the table
rdd_table <- gt(rdd_results) %>%
  tab_header(title = "Table 5 - RDD Results") %>%
  tab_source_note(
    source_note = "Statistical Significance: *** p<0.01, ** p<0.05, * p<0.1"
  )

# Saving the table as an HTML file
gtsave(rdd_table, "Tables/RDD/rdd_123.html")

RDD analysis - validity checks

# RDD Model 4 
rdd4 <- rdrobust(y = log(main_data$prices), x = main_data$distance_to_border, c = 0)

# RDD Model 5
rdd5 <- rdrobust(y = log(main_data$prices), x = main_data$distance_to_border, c = 0,
                           covs=cbind(main_data$sqm_interior, main_data$sqm_exterior, 
                                      main_data$bedrooms, main_data$energy_label))

# RDD Model 6
rdd6 <- rdrobust(y = log(main_data$prices), x = main_data$distance_to_border, c = 0,
                           covs=cbind(main_data$sqm_interior, main_data$sqm_exterior, 
                                      main_data$bedrooms, main_data$energy_label,
                                      main_data$distance_to_water, main_data$distance_to_ancient_city))

# Extracting the necessary statistics for RDD results
rdd_log <- data.frame(
  Model = c("RDD Model 4", "RDD Model 5", "RDD Model 6"),
  Coefficient = c(rdd_coefs(rdd4)$coef, 
                  rdd_coefs(rdd5)$coef, 
                  rdd_coefs(rdd6)$coef),
  SE = c(rdd_coefs(rdd4)$se, 
         rdd_coefs(rdd5)$se, 
         rdd_coefs(rdd6)$se),
  Control = c(rdd4$N_h[1], rdd5$N_h[1], rdd6$N_h[1]),
  Treated = c(rdd4$N_h[2], rdd5$N_h[2], rdd6$N_h[2]),
  Bandwidth = c(rdd4$bws[1, 1], rdd5$bws[1, 1], rdd6$bws[1, 1])
)

# Creating the table for RDD results
rdd_table2 <- gt(rdd_log) %>%
  tab_header(title = "Table 6 - RDD Results (Log-Transformed Prices)") %>%
  tab_source_note(
    source_note = "Statistical Significance: *** p<0.01, ** p<0.05, * p<0.1"
  )

# Saving the table as an HTML file
gtsave(rdd_table2, "Tables/RDD/rdd_456.html")

RDD Analysis - Validty checks 2

# RDD models 4, 5 & 6 with cerrd bandwidth selection

rdd4_cerrd <- rdrobust(y = log(main_data$prices), x = main_data$distance_to_border, c = 0, bwselect = "cerrd")

rdd5_cerrd <- rdrobust(y = log(main_data$prices), x = main_data$distance_to_border, c = 0,
                 covs = cbind(main_data$sqm_interior, main_data$sqm_exterior, 
                              main_data$bedrooms, main_data$energy_label), bwselect = "cerrd")

rdd6_cerrd <- rdrobust(y = log(main_data$prices), x = main_data$distance_to_border, c = 0,
                 covs = cbind(main_data$sqm_interior, main_data$sqm_exterior, 
                              main_data$bedrooms, main_data$energy_label,
                              main_data$distance_to_water, main_data$distance_to_ancient_city), bwselect = "cerrd")

# Extracting the necessary statistics for RDD results with cerrd bandwidth selection
rdd_results_cerrd <- data.frame(
  Model = c("RDD Model 4 (cerrd)", "RDD Model 5 (cerrd)", "RDD Model 6 (cerrd)"),
  Coefficient = c(rdd_coefs(rdd4_cerrd)$coef, 
                  rdd_coefs(rdd5_cerrd)$coef, 
                  rdd_coefs(rdd6_cerrd)$coef),
  SE = c(rdd_coefs(rdd4_cerrd)$se, 
         rdd_coefs(rdd5_cerrd)$se, 
         rdd_coefs(rdd6_cerrd)$se),
  Control = c(rdd4_cerrd$N_h[1], rdd5_cerrd$N_h[1], rdd6_cerrd$N_h[1]),
  Treated = c(rdd4_cerrd$N_h[2], rdd5_cerrd$N_h[2], rdd6_cerrd$N_h[2]),
  Bandwidth = c(rdd4_cerrd$bws[1, 1], rdd5_cerrd$bws[1, 1], rdd6_cerrd$bws[1, 1])
)

# Creating the table for RDD results with cerrd bandwidth selection
rdd_table_cerrd <- gt(rdd_results_cerrd) %>%
  tab_header(title = "Table 7 - RDD Results (With cerrd Bandwidth Selection)") %>%
  tab_source_note(
    source_note = "Statistical Significance: *** p<0.01, ** p<0.05, * p<0.1"
  )

# Saving the table as an HTML file
gtsave(rdd_table_cerrd, "Tables/RDD/rdd_cerrd_456.html")