Files
party2d/src/julia/validate_external.jl
T
2026-06-15 11:33:18 +02:00

376 lines
14 KiB
Julia

#!/usr/bin/env julia
#############################################################################
## validate_external.jl
## Out-of-sample validation via held-out expert observations
##
## Design:
## - Text data stays 100% intact (same parties, segments, indices)
## - 20% of expert/LR observations held out (stratified by source)
## - Expert-only parties (no text data) are never held out
## - Model trains on 80% expert + 100% text
## - Held-out expert ratings compared to model predictions
##
## Usage:
## julia scripts/validate_external.jl prepare
## julia 01_run_model.jl --data-dir validation/external_split/
## julia 02_post_estimation.jl # on training run
## julia scripts/validate_external.jl compute <model_positions.csv>
#############################################################################
using CSV, DataFrames, Statistics, Random, Dates, Printf
const HOLDOUT_FRAC = 0.20
const SEED = 42
# =========================================================================
# STEP 1: Prepare train/test split
# =========================================================================
function prepare_holdout_data(base_dir::String=".")
println("="^70)
println("PREPARING OUT-OF-SAMPLE VALIDATION SPLIT")
println("="^70)
println()
# Load data
text_data = CSV.read(joinpath(base_dir, "text_data.csv"), DataFrame)
expert = CSV.read(joinpath(base_dir, "expert.csv"), DataFrame)
lr_data = CSV.read(joinpath(base_dir, "lr_data.csv"), DataFrame)
println("Full dataset:")
println(" text_data: $(nrow(text_data)) rows, $(length(unique(text_data.party))) parties")
println(" expert: $(nrow(expert)) rows, $(length(unique(expert.party))) parties")
println(" lr_data: $(nrow(lr_data)) rows, $(length(unique(lr_data.party))) parties")
# Identify expert-only parties (no text data) — these are NEVER held out
text_parties = Set(unique(text_data.party))
expert_only_parties = Set(p for p in unique(vcat(expert.party, lr_data.party))
if !(p in text_parties))
println()
println("Expert-only parties (protected from holdout): $(length(expert_only_parties))")
# Split expert data: stratified by source variable
# Safeguard: ensure each party keeps at least one observation in training
Random.seed!(SEED)
expert.row_id = 1:nrow(expert)
expert.is_holdout = falses(nrow(expert))
for var_group in groupby(expert, :var)
var_name = first(var_group.var)
eligible = findall(row -> !(row.party in expert_only_parties), eachrow(var_group))
n_holdout = round(Int, length(eligible) * HOLDOUT_FRAC)
holdout_candidates = shuffle(eligible)
# Track per-party counts to ensure at least 1 stays in training
party_train_count = Dict{Int, Int}()
for idx in eligible
p = var_group.party[idx]
party_train_count[p] = get(party_train_count, p, 0) + 1
end
n_held = 0
for idx in holdout_candidates
n_held >= n_holdout && break
p = var_group.party[idx]
if party_train_count[p] > 1 # keep at least 1 in training
row_id = var_group.row_id[idx]
expert.is_holdout[row_id] = true
party_train_count[p] -= 1
n_held += 1
end
end
end
# Split LR data: stratified by source variable (same safeguard)
lr_data.row_id = 1:nrow(lr_data)
lr_data.is_holdout = falses(nrow(lr_data))
for var_group in groupby(lr_data, :var)
var_name = first(var_group.var)
eligible = findall(row -> !(row.party in expert_only_parties), eachrow(var_group))
n_holdout = round(Int, length(eligible) * HOLDOUT_FRAC)
holdout_candidates = shuffle(eligible)
party_train_count = Dict{Int, Int}()
for idx in eligible
p = var_group.party[idx]
party_train_count[p] = get(party_train_count, p, 0) + 1
end
n_held = 0
for idx in holdout_candidates
n_held >= n_holdout && break
p = var_group.party[idx]
if party_train_count[p] > 1
row_id = var_group.row_id[idx]
lr_data.is_holdout[row_id] = true
party_train_count[p] -= 1
n_held += 1
end
end
end
# Create train/test splits
expert_train = expert[.!expert.is_holdout, Not([:row_id, :is_holdout])]
expert_test = expert[expert.is_holdout, Not([:row_id, :is_holdout])]
lr_train = lr_data[.!lr_data.is_holdout, Not([:row_id, :is_holdout])]
lr_test = lr_data[lr_data.is_holdout, Not([:row_id, :is_holdout])]
# Report split
println()
println("Split summary ($(round(100*HOLDOUT_FRAC))% holdout):")
println(" expert train: $(nrow(expert_train)) rows ($(round(100*nrow(expert_train)/nrow(expert), digits=1))%)")
println(" expert test: $(nrow(expert_test)) rows ($(round(100*nrow(expert_test)/nrow(expert), digits=1))%)")
println(" lr train: $(nrow(lr_train)) rows ($(round(100*nrow(lr_train)/nrow(lr_data), digits=1))%)")
println(" lr test: $(nrow(lr_test)) rows ($(round(100*nrow(lr_test)/nrow(lr_data), digits=1))%)")
# Report per-source breakdown
println()
println("Per-source breakdown (expert):")
for var_name in sort(unique(expert.var))
n_full = count(expert.var .== var_name)
n_test = count(expert_test.var .== var_name)
println(" $var_name: $(n_full - n_test) train / $n_test test")
end
println()
println("Per-source breakdown (LR):")
for var_name in sort(unique(lr_data.var))
n_full = count(lr_data.var .== var_name)
n_test = count(lr_test.var .== var_name)
println(" $var_name: $(n_full - n_test) train / $n_test test")
end
# Verify: training set has same parties as full set
train_parties_expert = Set(unique(expert_train.party))
train_parties_lr = Set(unique(lr_train.party))
full_parties_expert = Set(unique(expert.party))
full_parties_lr = Set(unique(lr_data.party))
lost_expert = setdiff(full_parties_expert, train_parties_expert)
lost_lr = setdiff(full_parties_lr, train_parties_lr)
println()
if isempty(lost_expert) && isempty(lost_lr)
println("✓ No parties lost from training set")
else
println("⚠ Parties lost from expert training: $(length(lost_expert))")
println("⚠ Parties lost from LR training: $(length(lost_lr))")
end
# Save to output directory
# Files use standard names so 01_run_model.jl can load with --data-dir
output_dir = joinpath(base_dir, "validation", "external_split")
mkpath(output_dir)
# Training files (standard names for model loading)
CSV.write(joinpath(output_dir, "text_data.csv"), text_data) # UNCHANGED
CSV.write(joinpath(output_dir, "expert.csv"), expert_train)
CSV.write(joinpath(output_dir, "lr_data.csv"), lr_train)
# Test files (for compute step)
CSV.write(joinpath(output_dir, "expert_test.csv"), expert_test)
CSV.write(joinpath(output_dir, "lr_data_test.csv"), lr_test)
# Copy union mapping (needed by model)
if isdir(joinpath(base_dir, "data"))
mkpath(joinpath(output_dir, "data"))
cp(joinpath(base_dir, "data", "union_mapping.csv"),
joinpath(output_dir, "data", "union_mapping.csv"), force=true)
end
println()
println("Files saved to: $output_dir")
println(" text_data.csv — IDENTICAL to original ($(nrow(text_data)) rows)")
println(" expert.csv — training only ($(nrow(expert_train)) rows)")
println(" lr_data.csv — training only ($(nrow(lr_train)) rows)")
println(" expert_test.csv — held-out ($(nrow(expert_test)) rows)")
println(" lr_data_test.csv — held-out ($(nrow(lr_test)) rows)")
return output_dir
end
# =========================================================================
# STEP 2: Compute held-out validation metrics
# =========================================================================
function compute_holdout_metrics(model_file::String, test_dir::String)
println()
println("="^70)
println("COMPUTING HELD-OUT VALIDATION METRICS")
println("="^70)
# Load model output
model = CSV.read(model_file, DataFrame)
party_col = hasproperty(model, :party_id) ? :party_id : :party
println("Model output: $(nrow(model)) party-years")
# Load test data
expert_test = CSV.read(joinpath(test_dir, "expert_test.csv"), DataFrame)
lr_test = CSV.read(joinpath(test_dir, "lr_data_test.csv"), DataFrame)
println("Held-out expert: $(nrow(expert_test)) observations")
println("Held-out LR: $(nrow(lr_test)) observations")
# Build lookup: (party, year) → model estimates
model_lookup = Dict{Tuple{Int,Int}, NamedTuple}()
for row in eachrow(model)
key = (row[party_col], row.year)
model_lookup[key] = (
economic_lr = row.economic_lr,
galtan = row.galtan,
economic_lr_se = hasproperty(row, :economic_lr_se) ? row.economic_lr_se : missing,
galtan_se = hasproperty(row, :galtan_se) ? row.galtan_se : missing,
economic_lr_q025 = hasproperty(row, :economic_lr_q025) ? row.economic_lr_q025 : missing,
economic_lr_q975 = hasproperty(row, :economic_lr_q975) ? row.economic_lr_q975 : missing,
galtan_q025 = hasproperty(row, :galtan_q025) ? row.galtan_q025 : missing,
galtan_q975 = hasproperty(row, :galtan_q975) ? row.galtan_q975 : missing,
)
end
# Map expert variables to dimensions
econ_vars = Set(["lrecon_ches", "lrecon_poppa", "lrecon_gps", "lrecon_vparty", "welf_vparty"])
galtan_vars = Set(["galtan_ches", "libcon_gps", "immig_vparty", "lgbt_vparty",
"culsup_vparty", "relig_vparty", "gender_vparty"])
# Process expert test observations
results = NamedTuple[]
for row in eachrow(expert_test)
key = (row.party, row.year)
haskey(model_lookup, key) || continue
m = model_lookup[key]
if row.var in econ_vars
dim = "economic_lr"
model_val = m.economic_lr
model_q025 = m.economic_lr_q025
model_q975 = m.economic_lr_q975
elseif row.var in galtan_vars
dim = "galtan"
model_val = m.galtan
model_q025 = m.galtan_q025
model_q975 = m.galtan_q975
else
continue
end
covered = !ismissing(model_q025) && !ismissing(model_q975) &&
row.val >= model_q025 && row.val <= model_q975
push!(results, (
party = row.party,
country = row.country,
year = row.year,
var = row.var,
dimension = dim,
expert_val = row.val,
model_val = model_val,
error = row.val - model_val,
abs_error = abs(row.val - model_val),
covered_95 = ismissing(model_q025) ? missing : covered,
))
end
if isempty(results)
println("ERROR: No matching test observations found")
return nothing
end
results_df = DataFrame(results)
# Compute and report metrics
println()
println("-"^70)
println("HELD-OUT VALIDATION RESULTS")
println("-"^70)
# Overall
overall_r = cor(results_df.expert_val, results_df.model_val)
overall_mae = mean(results_df.abs_error)
overall_rmse = sqrt(mean(results_df.error .^ 2))
println()
println(@sprintf("Overall: r=%.4f, MAE=%.4f, RMSE=%.4f, n=%d",
overall_r, overall_mae, overall_rmse, nrow(results_df)))
# By dimension
println()
println("By dimension:")
for dim in sort(unique(results_df.dimension))
d = filter(r -> r.dimension == dim, results_df)
r_val = cor(d.expert_val, d.model_val)
mae = mean(d.abs_error)
rmse = sqrt(mean(d.error .^ 2))
cov = count(skipmissing(d.covered_95)) / count(!ismissing, d.covered_95)
println(@sprintf(" %-12s: r=%.4f, MAE=%.4f, RMSE=%.4f, CIC95=%.1f%%, n=%d",
dim, r_val, mae, rmse, 100*cov, nrow(d)))
end
# By source
println()
println("By source:")
for var in sort(unique(results_df.var))
v = filter(r -> r.var == var, results_df)
nrow(v) < 5 && continue
r_val = cor(v.expert_val, v.model_val)
mae = mean(v.abs_error)
println(@sprintf(" %-20s: r=%.4f, MAE=%.4f, n=%d", var, r_val, mae, nrow(v)))
end
return results_df
end
# =========================================================================
# Main
# =========================================================================
function main()
args = ARGS
if isempty(args) || args[1] == "prepare"
output_dir = prepare_holdout_data()
println()
println("="^70)
println("NEXT STEPS")
println("="^70)
println("""
1. Run model on training data:
julia 01_run_model.jl --data-dir $output_dir
2. Run post-estimation on training run output:
julia 02_post_estimation.jl
3. Compute held-out metrics:
julia scripts/validate_external.jl compute <party_positions_file.csv>
""")
elseif args[1] == "compute" && length(args) >= 2
model_file = args[2]
test_dir = joinpath("validation", "external_split")
isfile(model_file) || error("Model file not found: $model_file")
isdir(test_dir) || error("Test data not found. Run 'prepare' first.")
results_df = compute_holdout_metrics(model_file, test_dir)
if results_df !== nothing
timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
output_file = joinpath("validation", "external_validation_$(timestamp).csv")
CSV.write(output_file, results_df)
println()
println("Detailed results saved: $output_file")
end
else
println("Usage:")
println(" julia scripts/validate_external.jl prepare")
println(" julia scripts/validate_external.jl compute <party_positions.csv>")
end
end
if abspath(PROGRAM_FILE) == @__FILE__
main()
end