349 lines
14 KiB
R
349 lines
14 KiB
R
# ============================================================
|
|
# 02_build_model_inputs.R - Master Data Pipeline Orchestrator
|
|
# ============================================================
|
|
# Coordinates all data processing sub-scripts and produces
|
|
# final output files for the two-dimensional party-position model. By default this writes only
|
|
# to local-only directories under _local/ and never overwrites committed data/.
|
|
#
|
|
# Sub-scripts (run conditionally based on intermediate file existence):
|
|
# process_manifesto.R -> manifesto_data.csv
|
|
# process_poldem.R -> poldem_data.csv
|
|
# process_expert.R -> expert_raw.csv, lr_data_raw.csv
|
|
# process_morgan.R -> morgan_data.csv, morgan_lr.csv
|
|
#
|
|
# Final generated model inputs:
|
|
# text_data.csv - Combined manifesto + PolDem
|
|
# expert.csv - Expert survey data (CHES, V-Party, POPPA, GPS)
|
|
# lr_data.csv - General left-right anchoring data
|
|
# ============================================================
|
|
|
|
library(tidyverse)
|
|
library(countrycode)
|
|
|
|
cmd_args <- commandArgs(trailingOnly = FALSE)
|
|
file_arg <- grep("^--file=", cmd_args, value = TRUE)
|
|
if (length(file_arg) > 0) {
|
|
this_file <- normalizePath(sub("^--file=", "", file_arg[[1]]), mustWork = TRUE)
|
|
repo_root <- normalizePath(file.path(dirname(this_file), "..", ".."), mustWork = TRUE)
|
|
} else {
|
|
repo_root <- normalizePath(getwd(), mustWork = TRUE)
|
|
}
|
|
script_dir <- file.path(repo_root, "data-setup", "R")
|
|
build_dir <- normalizePath(
|
|
Sys.getenv("PARTY2D_BUILD_DIR", file.path(repo_root, "_local", "build")),
|
|
mustWork = FALSE
|
|
)
|
|
generated_input_dir <- normalizePath(
|
|
Sys.getenv("PARTY2D_GENERATED_INPUT_DIR", file.path(repo_root, "_local", "generated-inputs")),
|
|
mustWork = FALSE
|
|
)
|
|
dir.create(build_dir, recursive = TRUE, showWarnings = FALSE)
|
|
dir.create(generated_input_dir, recursive = TRUE, showWarnings = FALSE)
|
|
|
|
# The source-processing scripts use relative paths for intermediate files.
|
|
# Keep those intermediates in the ignored build directory, never committed data/.
|
|
setwd(build_dir)
|
|
|
|
# Static model support inputs are versioned in data/ and copied into the local
|
|
# generated-input set for comparison. They are not regenerated by raw-source setup.
|
|
for (support_file in c("union_mapping.csv", "party_families.csv")) {
|
|
src <- file.path(repo_root, "data", support_file)
|
|
if (!file.exists(src)) {
|
|
stop("Required committed support input not found: ", src)
|
|
}
|
|
file.copy(src, file.path(build_dir, support_file), overwrite = TRUE)
|
|
}
|
|
|
|
cat("============================================================\n")
|
|
cat("Data Management Pipeline\n")
|
|
cat("============================================================\n\n")
|
|
cat("Build directory: ", build_dir, "\n", sep = "")
|
|
cat("Generated input directory: ", generated_input_dir, "\n\n", sep = "")
|
|
|
|
# ============================================================
|
|
# Configuration: Set to TRUE to force re-run of sub-scripts
|
|
# ============================================================
|
|
|
|
FORCE_RERUN_MANIFESTO <- FALSE
|
|
FORCE_RERUN_POLDEM <- FALSE
|
|
FORCE_RERUN_EXPERT <- FALSE
|
|
FORCE_RERUN_MORGAN <- FALSE
|
|
|
|
# ============================================================
|
|
# Step 1: Manifesto Data
|
|
# ============================================================
|
|
|
|
cat("Step 1: Manifesto data\n")
|
|
if (!file.exists("manifesto_data.csv") || !file.exists("election_data.csv") || FORCE_RERUN_MANIFESTO) {
|
|
cat(" Running process_manifesto.R...\n")
|
|
source(file.path(script_dir, "process_manifesto.R"))
|
|
} else {
|
|
cat(" Loading cached manifesto_data.csv and election_data.csv...\n")
|
|
}
|
|
manifesto <- read_csv("manifesto_data.csv", show_col_types = FALSE)
|
|
election_data <- read_csv("election_data.csv", show_col_types = FALSE)
|
|
cat(sprintf(" Loaded manifesto: %d rows, %d parties\n", nrow(manifesto), n_distinct(manifesto$party)))
|
|
cat(sprintf(" Loaded election: %d rows, %d parties\n\n", nrow(election_data), n_distinct(election_data$party)))
|
|
|
|
# ============================================================
|
|
# Step 2: PolDem Media Data
|
|
# ============================================================
|
|
|
|
cat("Step 2: PolDem media data\n")
|
|
if (!file.exists("poldem_data.csv") || FORCE_RERUN_POLDEM) {
|
|
cat(" Running process_poldem.R...\n")
|
|
source(file.path(script_dir, "process_poldem.R"))
|
|
} else {
|
|
cat(" Loading cached poldem_data.csv...\n")
|
|
}
|
|
poldem_data <- read_csv("poldem_data.csv", show_col_types = FALSE)
|
|
cat(sprintf(" Loaded: %d rows, %d parties\n\n", nrow(poldem_data), n_distinct(poldem_data$party)))
|
|
|
|
# ============================================================
|
|
# Step 4: Expert Survey Data
|
|
# ============================================================
|
|
|
|
cat("Step 3: Expert survey data\n")
|
|
if (!file.exists("expert_raw.csv") || !file.exists("lr_data_raw.csv") || FORCE_RERUN_EXPERT) {
|
|
cat(" Running process_expert.R...\n")
|
|
source(file.path(script_dir, "process_expert.R"))
|
|
} else {
|
|
cat(" Loading cached expert_raw.csv and lr_data_raw.csv...\n")
|
|
}
|
|
expert_raw <- read_csv("expert_raw.csv", show_col_types = FALSE)
|
|
lr_data_raw <- read_csv("lr_data_raw.csv", show_col_types = FALSE)
|
|
cat(sprintf(" Expert: %d rows, LR: %d rows\n\n", nrow(expert_raw), nrow(lr_data_raw)))
|
|
|
|
# ============================================================
|
|
# Step 3b: Morgan (1976) Historical Expert Data
|
|
# ============================================================
|
|
|
|
cat("Step 3b: Morgan (1976) historical L-R data\n")
|
|
|
|
# First run to generate morgan_data.csv if needed
|
|
if (!file.exists("morgan_data.csv") || FORCE_RERUN_MORGAN) {
|
|
cat(" Running process_morgan.R (initial processing)...\n")
|
|
source(file.path(script_dir, "process_morgan.R"))
|
|
}
|
|
|
|
# morgan_lr.csv depends on text_data.csv, so we need to check if it needs regeneration
|
|
# It will be generated/regenerated below after text_data is created
|
|
|
|
# ============================================================
|
|
# Step 4: Combine Text Data Sources
|
|
# ============================================================
|
|
|
|
cat("Step 4: Combining text data sources\n")
|
|
text_data <- bind_rows(manifesto, poldem_data)
|
|
cat(sprintf(" Combined text_data: %d rows\n", nrow(text_data)))
|
|
|
|
# Save unfiltered text_data for reproducible mismatch diagnosis
|
|
write_csv(text_data, "text_data_unfiltered.csv")
|
|
cat(sprintf(" Saved unfiltered text_data: %d rows, %d parties\n", nrow(text_data), n_distinct(text_data$party)))
|
|
|
|
# ============================================================
|
|
# Step 4b: Party Renames (applied before filtering)
|
|
# ============================================================
|
|
# Renames must happen BEFORE the relevance filter so that party IDs
|
|
# match across text_data and expert_raw when computing expert coverage.
|
|
|
|
# Simple renames only (organizational continuity: same leadership/members)
|
|
simple_renames <- c(
|
|
`10` = 1816L, # DE: Greens -> Bündnis90/Grüne
|
|
`276` = 120L, # RO: FDSN/PDSR -> PSD (renamed 2001)
|
|
`8054` = 878L, # IT: PDS -> DS (renamed 1998)
|
|
`1696` = 813L, # IT: MSI -> AN (refounded 1995)
|
|
`553` = 1968L, # BE: Vlaams Blok -> Vlaams Belang (refounded 2004)
|
|
`8058` = 1626L # IT: Forza Italia (refounded 2013) -> Forza Italia (same party, Berlusconi)
|
|
)
|
|
|
|
apply_simple_renames <- function(df) {
|
|
for (old_id in names(simple_renames)) {
|
|
df <- df %>%
|
|
mutate(party = ifelse(party == as.integer(old_id), simple_renames[[old_id]], party))
|
|
}
|
|
df
|
|
}
|
|
|
|
cat("\nStep 4b: Party renames\n")
|
|
text_data <- apply_simple_renames(text_data)
|
|
cat(sprintf(" Applied %d renames to text_data\n", length(simple_renames)))
|
|
|
|
# ============================================================
|
|
# Step 4c: Relevance Filter
|
|
# ============================================================
|
|
# Design: R pipeline filters for RELEVANCE (is this party worth modeling?).
|
|
# Julia pipeline handles INTERPOLATION QUALITY (MAX_GAP=7 segment splitting, MIN_OBS=2).
|
|
# Expert survey coverage is a relevance signal: CHES only covers parties with >1% vote share.
|
|
|
|
cat("\nStep 4c: Relevance filter\n")
|
|
parties_before <- n_distinct(text_data$party)
|
|
|
|
# Compute expert coverage per party (with renames applied for consistent matching)
|
|
expert_year_counts <- bind_rows(
|
|
expert_raw %>% select(party, year),
|
|
lr_data_raw %>% select(party, year)
|
|
) %>% distinct() %>%
|
|
apply_simple_renames() %>%
|
|
distinct() %>%
|
|
count(party, name = "expert_years")
|
|
|
|
expert_party_ids <- unique(expert_year_counts$party)
|
|
cat(sprintf(" Parties with expert data: %d\n", length(expert_party_ids)))
|
|
|
|
# Three-tier relevance filter:
|
|
# Tier 1: 3+ text data years (always include, regardless of expert data)
|
|
# Tier 2: 2 text years + any expert data (major newer parties like M5S, ANO, LREM)
|
|
# Tier 3: 1 text year + 3+ expert survey years (parties with rich expert coverage)
|
|
text_data <- text_data %>%
|
|
group_by(country, party) %>%
|
|
mutate(n_years = n_distinct(year)) %>%
|
|
ungroup() %>%
|
|
left_join(expert_year_counts, by = "party") %>%
|
|
mutate(expert_years = replace_na(expert_years, 0L)) %>%
|
|
mutate(
|
|
tier = case_when(
|
|
n_years >= 3 ~ 1L,
|
|
n_years >= 2 & party %in% expert_party_ids ~ 2L,
|
|
n_years >= 1 & expert_years >= 3 ~ 3L,
|
|
TRUE ~ 0L
|
|
)
|
|
) %>%
|
|
filter(tier > 0) %>%
|
|
select(-n_years, -expert_years, -tier)
|
|
|
|
parties_after <- n_distinct(text_data$party)
|
|
cat(sprintf(" Parties before filter: %d\n", parties_before))
|
|
cat(sprintf(" Parties after filter: %d\n", parties_after))
|
|
cat(sprintf(" Parties removed: %d\n\n", parties_before - parties_after))
|
|
|
|
# ============================================================
|
|
# Step 5: Party Harmonization
|
|
# ============================================================
|
|
|
|
cat("Step 5: Party harmonization (union-aware)\n")
|
|
|
|
# Load union mapping to identify constituent parties
|
|
union_map <- read_csv("union_mapping.csv", show_col_types = FALSE)
|
|
|
|
# Build set of constituent parties whose union is in text_data
|
|
constituent_parties <- union_map %>%
|
|
filter(manifesto_pf_id %in% unique(text_data$party)) %>%
|
|
pull(expert_pf_id)
|
|
|
|
cat(sprintf(" Union mappings loaded: %d rows covering %d unions\n",
|
|
nrow(union_map), n_distinct(union_map$manifesto_pf_id)))
|
|
cat(sprintf(" Constituent parties with unions in text_data: %d\n",
|
|
length(unique(constituent_parties))))
|
|
|
|
# Deduplicate union manifesto rows: where multiple CMP codes map to the same
|
|
# union PF ID with identical content, keep only one set per (party, year, var)
|
|
text_data_before_dedup <- nrow(text_data)
|
|
text_data <- text_data %>%
|
|
distinct(country, party, year, var, .keep_all = TRUE)
|
|
cat(sprintf(" Text data: %d unique parties after harmonization\n", n_distinct(text_data$party)))
|
|
cat(sprintf(" Text data: deduplicated %d -> %d rows\n", text_data_before_dedup, nrow(text_data)))
|
|
|
|
# Filter expert data: keep parties in text_data OR constituent parties of unions in text_data
|
|
expert <- expert_raw %>%
|
|
apply_simple_renames() %>%
|
|
group_by(country, party, var, year) %>%
|
|
summarise(
|
|
val = mean(val, na.rm = TRUE),
|
|
val_int = first(val_int),
|
|
n_scale = first(n_scale),
|
|
n_experts = first(n_experts),
|
|
project = first(project),
|
|
type_low = first(type_low),
|
|
type_high = first(type_high),
|
|
.groups = "drop"
|
|
) %>%
|
|
filter(party %in% unique(text_data$party) | party %in% constituent_parties)
|
|
|
|
lr_data <- lr_data_raw %>%
|
|
apply_simple_renames() %>%
|
|
group_by(country, party, var, year) %>%
|
|
summarise(
|
|
val = mean(val, na.rm = TRUE),
|
|
val_int = first(val_int),
|
|
n_scale = first(n_scale),
|
|
n_experts = first(n_experts),
|
|
project = first(project),
|
|
.groups = "drop"
|
|
) %>%
|
|
filter(party %in% unique(text_data$party) | party %in% constituent_parties)
|
|
|
|
cat(sprintf(" Expert data: %d rows (filtered to text_data parties)\n", nrow(expert)))
|
|
cat(sprintf(" LR data (CHES/POPPA): %d rows (filtered to text_data parties)\n", nrow(lr_data)))
|
|
|
|
# ============================================================
|
|
# Step 5b: Integrate Morgan L-R Data
|
|
# ============================================================
|
|
|
|
cat("\nStep 5b: Morgan L-R data integration\n")
|
|
|
|
# Generate morgan_lr.csv (requires text_data.csv to exist)
|
|
# We need to regenerate it if text_data changed or if forced
|
|
if (!file.exists("morgan_lr.csv") || FORCE_RERUN_MORGAN) {
|
|
cat(" Generating morgan_lr.csv...\n")
|
|
# Write text_data first so morgan script can use it
|
|
write_csv(text_data, "text_data.csv")
|
|
source(file.path(script_dir, "process_morgan.R"))
|
|
}
|
|
|
|
# Load and integrate Morgan L-R data
|
|
if (file.exists("morgan_lr.csv")) {
|
|
morgan_lr <- read_csv("morgan_lr.csv", show_col_types = FALSE) %>%
|
|
apply_simple_renames() %>%
|
|
filter(party %in% unique(text_data$party) | party %in% constituent_parties)
|
|
|
|
cat(sprintf(" Morgan L-R: %d rows (filtered to text_data parties)\n", nrow(morgan_lr)))
|
|
cat(sprintf(" Morgan parties: %d\n", n_distinct(morgan_lr$party)))
|
|
cat(sprintf(" Morgan year range: %d-%d\n", min(morgan_lr$year), max(morgan_lr$year)))
|
|
|
|
# Combine with existing lr_data
|
|
lr_data_before <- nrow(lr_data)
|
|
lr_data <- bind_rows(lr_data, morgan_lr) %>%
|
|
arrange(country, party, year, var)
|
|
|
|
cat(sprintf(" Combined LR data: %d rows (+%d from Morgan)\n",
|
|
nrow(lr_data), nrow(lr_data) - lr_data_before))
|
|
} else {
|
|
cat(" Warning: morgan_lr.csv not found, skipping Morgan integration\n")
|
|
}
|
|
|
|
cat("\n")
|
|
|
|
# ============================================================
|
|
# Step 6: Write Final Outputs
|
|
# ============================================================
|
|
|
|
cat("Step 6: Writing final outputs\n")
|
|
|
|
write_csv(text_data, "text_data.csv")
|
|
write_csv(expert, "expert.csv")
|
|
write_csv(lr_data, "lr_data.csv")
|
|
|
|
for (final_file in c("text_data.csv", "expert.csv", "lr_data.csv", "union_mapping.csv", "party_families.csv")) {
|
|
file.copy(file.path(build_dir, final_file), file.path(generated_input_dir, final_file), overwrite = TRUE)
|
|
}
|
|
|
|
cat("\n============================================================\n")
|
|
cat("Pipeline Complete!\n")
|
|
cat("============================================================\n\n")
|
|
|
|
cat("Output files written:\n")
|
|
cat(sprintf(" local generated input dir: %s\n", generated_input_dir))
|
|
cat(sprintf(" text_data.csv: %d rows\n", nrow(text_data)))
|
|
cat(sprintf(" - Manifesto: %d rows\n", sum(grepl("_manifesto", text_data$var))))
|
|
cat(sprintf(" - PolDem: %d rows\n", sum(grepl("_poldem", text_data$var))))
|
|
cat(sprintf(" expert.csv: %d rows\n", nrow(expert)))
|
|
cat(sprintf(" lr_data.csv: %d rows\n", nrow(lr_data)))
|
|
cat(sprintf(" - CHES: %d rows\n", sum(lr_data$var == "lr_ches")))
|
|
cat(sprintf(" - POPPA: %d rows\n", sum(lr_data$var == "lr_poppa")))
|
|
cat(sprintf(" - Morgan: %d rows\n", sum(lr_data$var == "lr_morgan")))
|
|
|
|
cat("\nUnique parties in text_data:", n_distinct(text_data$party), "\n")
|
|
cat("Countries:", paste(sort(unique(text_data$country)), collapse = ", "), "\n")
|
|
cat("Year range:", min(text_data$year, na.rm = TRUE), "-", max(text_data$year, na.rm = TRUE), "\n")
|