Document raw source file reference
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## run_model.jl
|
||||
## Main runner for latent trait model
|
||||
## Executes the complete pipeline: data loading → preparation → model fitting
|
||||
##
|
||||
## Supports two model versions:
|
||||
## - "2dim": 2D bipolar model (V1) - estimates economic_lr and galtan directly
|
||||
## - "4dim": 4D unipolar model (V10) - estimates 4 traits, derives 2 scales
|
||||
##
|
||||
## Default is 2D model (better identification, faster convergence)
|
||||
#############################################################################
|
||||
|
||||
using Dates
|
||||
|
||||
#############################################################################
|
||||
## EXECUTION CONFIGURATION (Change these values as needed)
|
||||
#############################################################################
|
||||
const MODEL_VERSION = "2dim" # "2dim" (recommended) or "4dim"
|
||||
const STAN_MODEL_FILE = MODEL_VERSION == "2dim" ? "models/stan_model_2dim_v6.stan" : "models/stan_model_4dim_v10.stan"
|
||||
const NUM_CHAINS = 4 # Number of MCMC chains (run in parallel)
|
||||
const NUM_WARMUP = 1000 # Number of warmup iterations
|
||||
const NUM_SAMPLES = 2000 # Number of sampling iterations
|
||||
const ADAPT_DELTA = 0.95 # Target acceptance probability
|
||||
const MAX_DEPTH = 15 # Maximum tree depth
|
||||
const START_YEAR = 1944 # First year to include (avoid sparse early data)
|
||||
|
||||
println("=" ^ 70)
|
||||
println("Latent Trait Model - Estimation Pipeline")
|
||||
println("=" ^ 70)
|
||||
println("Started at: ", Dates.now())
|
||||
println("Configuration:")
|
||||
println(" Model version: $(MODEL_VERSION)")
|
||||
println(" Stan model: $(STAN_MODEL_FILE)")
|
||||
println(" Chains: $(NUM_CHAINS)")
|
||||
println(" Warmup iterations: $(NUM_WARMUP)")
|
||||
println(" Sampling iterations: $(NUM_SAMPLES)")
|
||||
println(" Total iterations per chain: $(NUM_WARMUP + NUM_SAMPLES)")
|
||||
println(" Adapt delta: $(ADAPT_DELTA)")
|
||||
println(" Max depth: $(MAX_DEPTH)")
|
||||
println(" Start year: $(START_YEAR)")
|
||||
if MODEL_VERSION == "2dim"
|
||||
println("\n 2D MODEL: Estimates economic_lr and galtan directly")
|
||||
println(" (Half the parameters, better convergence)")
|
||||
else
|
||||
println("\n 4D MODEL: Estimates 4 traits, derives 2 scales")
|
||||
println(" (Known identification issues - see VERSION_HISTORY.md)")
|
||||
end
|
||||
println("=" ^ 70)
|
||||
|
||||
# Include pipeline modules
|
||||
include("pipeline/00_validation.jl") # Validation checks
|
||||
include("pipeline/02_data_loading.jl")
|
||||
include("pipeline/03_data_preparation.jl")
|
||||
include("pipeline/04_model_execution.jl")
|
||||
include("pipeline/05_results_processing.jl")
|
||||
|
||||
# Load robust save module
|
||||
include("pipeline/06_save_model.jl")
|
||||
import .RobustSave: robust_save_model
|
||||
|
||||
function run_model(;
|
||||
num_chains=NUM_CHAINS,
|
||||
num_warmup=NUM_WARMUP,
|
||||
num_samples=NUM_SAMPLES,
|
||||
adapt_delta=ADAPT_DELTA,
|
||||
max_depth=MAX_DEPTH,
|
||||
model_file=STAN_MODEL_FILE,
|
||||
start_year=START_YEAR,
|
||||
data_dir="data"
|
||||
)
|
||||
"""Run the complete latent trait model pipeline (2D or 4D based on MODEL_VERSION)"""
|
||||
|
||||
try
|
||||
# Step 1: Load and preprocess data
|
||||
println("\n" * "="^50)
|
||||
println("STEP 1: DATA LOADING")
|
||||
println("="^50)
|
||||
|
||||
manifesto, expert_dim, expert_lr, year0, union_to_constituents, constituent_to_union = load_and_preprocess_4dim_data(start_year; data_dir=data_dir)
|
||||
|
||||
# Step 2: Prepare Stan data structure
|
||||
println("\n" * "="^50)
|
||||
println("STEP 2: DATA PREPARATION")
|
||||
println("="^50)
|
||||
|
||||
# Prepare indices and mappings (V4: union-aware)
|
||||
data_prep = prepare_4dim_stan_data(manifesto, expert_dim, expert_lr, year0;
|
||||
union_to_constituents=union_to_constituents,
|
||||
constituent_to_union=constituent_to_union)
|
||||
|
||||
# Finalize Stan data dictionary (V4: includes constituent arrays)
|
||||
final_data = finalize_4dim_stan_data(
|
||||
data_prep.manifesto, data_prep.expert_dim, data_prep.expert_lr,
|
||||
data_prep.segment_year, data_prep.segment_info,
|
||||
data_prep.all_parties, data_prep.all_groups,
|
||||
data_prep.group_to_index, year0, data_prep.S, data_prep.J, data_prep.P, data_prep.R,
|
||||
data_prep.N_ciy, data_prep.len_theta_ts, data_prep.segment_country_idx,
|
||||
data_prep.F, data_prep.segment_family_idx, data_prep.anchor_segment_idx;
|
||||
N_const_man_total=data_prep.N_const_man_total,
|
||||
n_const_man=data_prep.n_const_man,
|
||||
const_offset_man=data_prep.const_offset_man,
|
||||
const_rr_man=data_prep.const_rr_man,
|
||||
N_const_exp_dim_total=data_prep.N_const_exp_dim_total,
|
||||
n_const_exp_dim=data_prep.n_const_exp_dim,
|
||||
const_offset_exp_dim=data_prep.const_offset_exp_dim,
|
||||
const_rr_exp_dim=data_prep.const_rr_exp_dim,
|
||||
N_const_exp_lr_total=data_prep.N_const_exp_lr_total,
|
||||
n_const_exp_lr=data_prep.n_const_exp_lr,
|
||||
const_offset_exp_lr=data_prep.const_offset_exp_lr,
|
||||
const_rr_exp_lr=data_prep.const_rr_exp_lr
|
||||
)
|
||||
|
||||
dat_4dim = final_data.dat_4dim
|
||||
|
||||
# Step 3: Validate data BEFORE running Stan
|
||||
println("\n" * "="^50)
|
||||
println("STEP 3: DATA VALIDATION")
|
||||
println("="^50)
|
||||
|
||||
if !validate_stan_data(dat_4dim; verbose=true)
|
||||
error("Data validation failed - see errors above")
|
||||
end
|
||||
|
||||
estimate_memory_requirements(dat_4dim; verbose=true)
|
||||
|
||||
# Step 4: Create initialization function
|
||||
println("\n" * "="^50)
|
||||
println("STEP 4: MODEL INITIALIZATION")
|
||||
println("="^50)
|
||||
|
||||
# Determine model version for initialization
|
||||
model_init_version = MODEL_VERSION == "2dim" ? "v1_2dim" : "v10"
|
||||
|
||||
# Use S (segments) for initialization, not J (parties)
|
||||
init_fn = create_init_function(dat_4dim, data_prep.S, data_prep.P,
|
||||
data_prep.R, final_data.T_year, data_prep.N_ciy;
|
||||
model_version=model_init_version)
|
||||
|
||||
# Validate initialization values
|
||||
println("\nValidating initialization for chain 1...")
|
||||
test_init = init_fn()
|
||||
if !validate_init_values(test_init; verbose=true)
|
||||
error("Initialization validation failed - see errors above")
|
||||
end
|
||||
|
||||
# Step 5: Run Stan model
|
||||
println("\n" * "="^50)
|
||||
println("STEP 5: MODEL EXECUTION")
|
||||
println("="^50)
|
||||
|
||||
# Create temp folder for output
|
||||
temp_folder = mktempdir()
|
||||
println("Temporary folder for Stan output: $temp_folder")
|
||||
|
||||
# Run Stan model
|
||||
stanmodel = run_4dim_stan_model(
|
||||
dat_4dim, init_fn, temp_folder;
|
||||
num_chains=num_chains,
|
||||
num_warmup=num_warmup,
|
||||
num_samples=num_samples,
|
||||
adapt_delta=adapt_delta,
|
||||
max_depth=max_depth,
|
||||
model_file=model_file
|
||||
)
|
||||
|
||||
# Step 6: Results Processing & Diagnostics
|
||||
println("\n" * "="^50)
|
||||
println("STEP 6: RESULTS PROCESSING & DIAGNOSTICS")
|
||||
println("="^50)
|
||||
|
||||
results = extract_model_results_4dim(stanmodel)
|
||||
diagnostics = compute_model_diagnostics(stanmodel)
|
||||
|
||||
println("\nCONVERGENCE DIAGNOSTICS:")
|
||||
println(" Max R-hat: $(round(diagnostics.max_rhat, digits=4))")
|
||||
println(" Mean R-hat: $(round(diagnostics.mean_rhat, digits=4))")
|
||||
println(" High R-hat count: $(diagnostics.high_rhat_count)")
|
||||
println(" Min ESS: $(round(diagnostics.min_ess, digits=0))")
|
||||
println(" Mean ESS: $(round(diagnostics.mean_ess, digits=0))")
|
||||
println(" Convergence Status: $(diagnostics.convergence_status)")
|
||||
|
||||
# Step 7: Save results
|
||||
println("\n" * "="^50)
|
||||
println("STEP 7: SAVING RESULTS")
|
||||
println("="^50)
|
||||
println(" Using save-local-then-move strategy...")
|
||||
|
||||
# Prepare data for saving (SINGLE copy of stanmodel, not multiple!)
|
||||
model_data_to_save = Dict{String, Any}(
|
||||
# StanModel object (contains all MCMC samples)
|
||||
"stanmodel_object" => stanmodel,
|
||||
|
||||
# Processed diagnostics
|
||||
"diagnostics_summary" => diagnostics.diagnostics_summary,
|
||||
"data_dict" => dat_4dim,
|
||||
|
||||
# Original data for reference
|
||||
"manifesto" => final_data.manifesto,
|
||||
"expert_dim" => final_data.expert_dim,
|
||||
"expert_lr" => final_data.expert_lr,
|
||||
"segment_year" => final_data.segment_year, # V10: segment-year mapping
|
||||
"segment_info" => final_data.segment_info, # V10: segment metadata (party_id, segment_num, year range)
|
||||
|
||||
# Metadata with convergence info
|
||||
"model_info" => Dict(
|
||||
"timestamp" => Dates.format(Dates.now(), "yyyy-mm-dd_HH-MM-SS"),
|
||||
"max_rhat" => diagnostics.max_rhat,
|
||||
"mean_rhat" => diagnostics.mean_rhat,
|
||||
"min_ess" => diagnostics.min_ess,
|
||||
"mean_ess" => diagnostics.mean_ess,
|
||||
"convergence_status" => diagnostics.convergence_status,
|
||||
"model_file" => model_file,
|
||||
"model_version" => MODEL_VERSION,
|
||||
"num_chains" => num_chains,
|
||||
"num_warmup" => num_warmup,
|
||||
"num_samples" => num_samples,
|
||||
"adapt_delta" => adapt_delta,
|
||||
"max_depth" => max_depth,
|
||||
"year0" => year0,
|
||||
"dimensions" => MODEL_VERSION == "2dim" ?
|
||||
["economic_lr", "galtan"] :
|
||||
["pro_market", "pro_welfare", "cosmopolitan", "traditional"]
|
||||
)
|
||||
)
|
||||
|
||||
# Save using CSV-first robust system. Chains have already been secured
|
||||
# by model execution; robust_save_model adds metadata/data and verifies.
|
||||
save_dir = data_dir != "data" ? joinpath(data_dir, "model_run") : "outputs/model_outputs"
|
||||
output_file = robust_save_model(
|
||||
stanmodel,
|
||||
model_data_to_save,
|
||||
save_dir;
|
||||
compress=true, # Ignored by CSV-first save implementation
|
||||
keep_local_backups=2 # Ignored; chains are already saved before this step
|
||||
)
|
||||
|
||||
# Robust save module already verified everything!
|
||||
|
||||
println("\n" * "="^70)
|
||||
println("MODEL EXECUTION COMPLETED SUCCESSFULLY!")
|
||||
println("="^70)
|
||||
println(" Max R-hat: $(round(diagnostics.max_rhat, digits=4))")
|
||||
println(" Mean R-hat: $(round(diagnostics.mean_rhat, digits=4))")
|
||||
println(" Convergence: $(diagnostics.convergence_status)")
|
||||
println(" Output file: $output_file")
|
||||
|
||||
# Print summary statistics
|
||||
println("\nModel Summary ($(MODEL_VERSION == "2dim" ? "2D Direct Bipolar" : "4D Unipolar")):")
|
||||
println(" Segments: $(data_prep.S)")
|
||||
println(" Parties with valid segments: $(data_prep.J)")
|
||||
println(" Countries: $(data_prep.P)")
|
||||
println(" Segment-year combinations: $(data_prep.R)")
|
||||
println(" Years: $(final_data.T_year)")
|
||||
println(" Manifesto observations: $(dat_4dim["N_man"])")
|
||||
println(" Expert dimension-specific observations: $(dat_4dim["N_exp_dim"])")
|
||||
println(" Expert general L-R observations: $(dat_4dim["N_exp_lr"])")
|
||||
if MODEL_VERSION == "2dim"
|
||||
println(" Dimensions estimated: 2 (economic_lr, galtan)")
|
||||
println(" Theta parameters: $(2 * data_prep.R) (2 × R)")
|
||||
else
|
||||
println(" Dimensions estimated: 4 (pro_market, pro_welfare, cosmopolitan, traditional)")
|
||||
println(" Theta parameters: $(4 * data_prep.R) (4 × R)")
|
||||
end
|
||||
println("=" ^ 70)
|
||||
|
||||
# Cleanup temp folder after successful save
|
||||
println("\nCLEANING UP TEMPORARY FILES...")
|
||||
try
|
||||
if isdir(temp_folder)
|
||||
rm(temp_folder, recursive=true, force=true)
|
||||
println(" Removed temporary folder: $temp_folder")
|
||||
end
|
||||
catch cleanup_error
|
||||
println(" Warning: Could not remove temp folder: $cleanup_error")
|
||||
println(" (This won't affect your saved results)")
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
catch e
|
||||
println("\nERROR in model pipeline: $e")
|
||||
println("Stack trace:")
|
||||
showerror(stdout, e, catch_backtrace())
|
||||
rethrow(e)
|
||||
end
|
||||
end
|
||||
|
||||
function main(args=ARGS)
|
||||
# Parse --data-dir argument
|
||||
data_dir = "data"
|
||||
for (i, arg) in enumerate(args)
|
||||
if arg == "--data-dir" && i < length(args)
|
||||
data_dir = args[i + 1]
|
||||
elseif startswith(arg, "--data-dir=")
|
||||
data_dir = split(arg, "=", limit=2)[2]
|
||||
end
|
||||
end
|
||||
|
||||
if data_dir != "."
|
||||
println("Using data directory: $data_dir")
|
||||
end
|
||||
println("Executing $(MODEL_VERSION) latent trait model pipeline...")
|
||||
|
||||
# Check that required data files exist (in data_dir)
|
||||
required_data = [joinpath(data_dir, f) for f in ["text_data.csv", "expert.csv", "lr_data.csv"]]
|
||||
required_files = vcat(required_data, [STAN_MODEL_FILE])
|
||||
missing_files = []
|
||||
|
||||
for file in required_files
|
||||
if !isfile(file)
|
||||
push!(missing_files, file)
|
||||
end
|
||||
end
|
||||
|
||||
if !isempty(missing_files)
|
||||
println("ERROR: Missing required files:")
|
||||
for file in missing_files
|
||||
println(" - $file")
|
||||
end
|
||||
|
||||
if any(f -> endswith(f, "text_data.csv") || endswith(f, "expert.csv") || endswith(f, "lr_data.csv"), missing_files)
|
||||
println("\nTo generate data files, run:")
|
||||
println(" bash scripts/01_prepare_data.sh")
|
||||
end
|
||||
|
||||
error("Cannot proceed without required files")
|
||||
end
|
||||
|
||||
# Run the complete pipeline
|
||||
results = run_model(data_dir=data_dir)
|
||||
|
||||
println("\n$(MODEL_VERSION) latent trait model pipeline completed successfully!")
|
||||
println("Check outputs/model_outputs/latest/ for chain CSVs and metadata.")
|
||||
end
|
||||
|
||||
# Main execution
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
main()
|
||||
end
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env julia
|
||||
#=
|
||||
02_enrich_output.jl - Enrich party positions CSV with metadata
|
||||
|
||||
Fast post-processing script that adds party names, union membership status,
|
||||
and election results to the model output CSV. Operates purely on CSV files
|
||||
(no chain loading). Takes seconds, not minutes.
|
||||
|
||||
Usage:
|
||||
julia 02_enrich_output.jl # enriches latest party_positions_*.csv
|
||||
julia 02_enrich_output.jl somefile.csv # enriches a specific file
|
||||
|
||||
Adds columns:
|
||||
party_name_english - Full English party name (from data/party_names.csv)
|
||||
party_name_short - Standard abbreviation
|
||||
in_union - 1 if party's union had a joint manifesto that year, 0 otherwise
|
||||
pervote - Vote share (%) at election years; missing for non-election years
|
||||
=#
|
||||
|
||||
using CSV
|
||||
using DataFrames
|
||||
using Dates
|
||||
|
||||
function find_latest_output()
|
||||
outdir = "outputs/estimations/latest"
|
||||
files = filter(f -> startswith(f, "party_positions_") && endswith(f, ".csv") &&
|
||||
!contains(f, "metadata") && !contains(f, "tables"),
|
||||
readdir(outdir))
|
||||
isempty(files) && error("No party_positions_*.csv found in $outdir/")
|
||||
sort!(files, rev=true)
|
||||
return joinpath(outdir, files[1])
|
||||
end
|
||||
|
||||
function enrich(input_file::String)
|
||||
println("="^60)
|
||||
println("ENRICH OUTPUT")
|
||||
println("="^60)
|
||||
println("Input: $input_file")
|
||||
|
||||
output = CSV.read(input_file, DataFrame)
|
||||
println(" Rows: $(nrow(output)), Columns: $(ncol(output))")
|
||||
|
||||
# --- Party names ---
|
||||
party_names_file = joinpath("data", "party_names.csv")
|
||||
if isfile(party_names_file)
|
||||
names_df = CSV.read(party_names_file, DataFrame)
|
||||
name_lookup = Dict{Int, Tuple{String, String}}()
|
||||
for row in eachrow(names_df)
|
||||
short = ismissing(row.party_name_short) ? "" : string(row.party_name_short)
|
||||
name_lookup[row.partyfacts_id] = (string(row.party_name_english), short)
|
||||
end
|
||||
output.party_name_english = [
|
||||
haskey(name_lookup, pid) ? name_lookup[pid][1] : ""
|
||||
for pid in output.party_id
|
||||
]
|
||||
output.party_name_short = [
|
||||
haskey(name_lookup, pid) ? name_lookup[pid][2] : ""
|
||||
for pid in output.party_id
|
||||
]
|
||||
n_named = count(x -> x != "", output.party_name_english)
|
||||
println(" Party names: $n_named / $(nrow(output)) rows matched")
|
||||
else
|
||||
output.party_name_english = fill("", nrow(output))
|
||||
output.party_name_short = fill("", nrow(output))
|
||||
println(" WARNING: data/party_names.csv not found")
|
||||
end
|
||||
|
||||
# --- Union mapping (for in_union + pervote fallback) ---
|
||||
union_mapping_file = joinpath("data", "union_mapping.csv")
|
||||
constituent_to_union = Dict{Int, Int}()
|
||||
if isfile(union_mapping_file)
|
||||
union_df = CSV.read(union_mapping_file, DataFrame)
|
||||
for row in eachrow(union_df)
|
||||
constituent_to_union[row.expert_pf_id] = row.manifesto_pf_id
|
||||
end
|
||||
end
|
||||
|
||||
# --- in_union dummy (year-varying) ---
|
||||
text_data_file = "data/text_data.csv"
|
||||
output.in_union = zeros(Int, nrow(output))
|
||||
|
||||
if isfile(text_data_file) && !isempty(constituent_to_union)
|
||||
text_df = CSV.read(text_data_file, DataFrame)
|
||||
|
||||
# Build set of (party_pf_id, year) pairs for manifesto data
|
||||
manifesto_text = filter(r -> r.project == "Manifesto Project", text_df)
|
||||
manifesto_party_years = Set{Tuple{Int, Int}}()
|
||||
for row in eachrow(manifesto_text)
|
||||
push!(manifesto_party_years, (row.party, row.year))
|
||||
end
|
||||
|
||||
# Process each (party, segment) group
|
||||
gdf = groupby(output, [:party_id, :segment_num])
|
||||
for subdf in gdf
|
||||
pid = subdf.party_id[1]
|
||||
!haskey(constituent_to_union, pid) && continue
|
||||
union_id = constituent_to_union[pid]
|
||||
|
||||
# Get row indices in the full output for this group
|
||||
idxs = parentindices(subdf)[1]
|
||||
|
||||
# Determine in_union at election years
|
||||
election_year_vals = Dict{Int, Int}()
|
||||
for (j, row) in enumerate(eachrow(subdf))
|
||||
if (union_id, row.year) in manifesto_party_years
|
||||
election_year_vals[row.year] = 1
|
||||
elseif (pid, row.year) in manifesto_party_years
|
||||
election_year_vals[row.year] = 0
|
||||
end
|
||||
end
|
||||
|
||||
# Forward-fill within segment
|
||||
sorted_pairs = sort(collect(zip(subdf.year, idxs)))
|
||||
last_val = 0
|
||||
for (yr, idx) in sorted_pairs
|
||||
if haskey(election_year_vals, yr)
|
||||
last_val = election_year_vals[yr]
|
||||
end
|
||||
output.in_union[idx] = last_val
|
||||
end
|
||||
end
|
||||
|
||||
n_in_union = count(x -> x == 1, output.in_union)
|
||||
println(" in_union: $n_in_union rows flagged as union members")
|
||||
else
|
||||
println(" WARNING: Could not compute in_union (missing files)")
|
||||
end
|
||||
|
||||
# --- Election results (pervote) ---
|
||||
election_file = joinpath("data", "election_data.csv")
|
||||
output.pervote = Vector{Union{Float64, Missing}}(missing, nrow(output))
|
||||
|
||||
if isfile(election_file)
|
||||
election_df = CSV.read(election_file, DataFrame)
|
||||
|
||||
# Build lookup: (party_id, year) -> pervote
|
||||
election_lookup = Dict{Tuple{Int, Int}, Float64}()
|
||||
for row in eachrow(election_df)
|
||||
election_lookup[(row.party, row.year)] = row.pervote
|
||||
end
|
||||
|
||||
n_filled = 0
|
||||
for i in 1:nrow(output)
|
||||
pid = output.party_id[i]
|
||||
yr = output.year[i]
|
||||
|
||||
# Direct match (standalone party)
|
||||
if haskey(election_lookup, (pid, yr))
|
||||
output.pervote[i] = election_lookup[(pid, yr)]
|
||||
n_filled += 1
|
||||
elseif hasproperty(output, :union_party_id) && !ismissing(output.union_party_id[i])
|
||||
# Union constituent: look up by union PF ID
|
||||
uid = output.union_party_id[i]
|
||||
if haskey(election_lookup, (uid, yr))
|
||||
output.pervote[i] = election_lookup[(uid, yr)]
|
||||
n_filled += 1
|
||||
end
|
||||
elseif haskey(constituent_to_union, pid)
|
||||
# Fallback: use union mapping even if union_party_id column not populated
|
||||
uid = constituent_to_union[pid]
|
||||
if haskey(election_lookup, (uid, yr))
|
||||
output.pervote[i] = election_lookup[(uid, yr)]
|
||||
n_filled += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
println(" pervote: $n_filled / $(nrow(output)) rows filled")
|
||||
else
|
||||
println(" WARNING: election_data.csv not found")
|
||||
end
|
||||
|
||||
# --- Reorder columns ---
|
||||
estimate_cols = Symbol[]
|
||||
for base in ["economic_lr", "galtan", "pro_market", "pro_welfare", "cosmopolitan", "traditional"]
|
||||
sym = Symbol(base)
|
||||
if hasproperty(output, sym)
|
||||
push!(estimate_cols, sym)
|
||||
push!(estimate_cols, Symbol("$(base)_se"))
|
||||
push!(estimate_cols, Symbol("$(base)_q025"))
|
||||
push!(estimate_cols, Symbol("$(base)_q975"))
|
||||
end
|
||||
end
|
||||
|
||||
# Fix country code: MO (Macau) → MK (North Macedonia) — GPS uses wrong ISO2
|
||||
output.country = replace(output.country, "MO" => "MK")
|
||||
|
||||
col_order = vcat(
|
||||
[:party_id, :party_name_english, :party_name_short, :country, :year, :segment_num,
|
||||
:union_party_id, :in_union, :pervote],
|
||||
estimate_cols
|
||||
)
|
||||
col_order = filter(c -> hasproperty(output, c), col_order)
|
||||
select!(output, col_order)
|
||||
|
||||
# --- Write back ---
|
||||
CSV.write(input_file, output)
|
||||
println("\n Wrote: $input_file")
|
||||
println(" Columns ($(ncol(output))): $(join(string.(names(output)), ", "))")
|
||||
|
||||
return output
|
||||
end
|
||||
|
||||
function main(args=ARGS)
|
||||
input = length(args) >= 1 ? args[1] : find_latest_output()
|
||||
enrich(input)
|
||||
println("\nDone.")
|
||||
end
|
||||
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
main()
|
||||
end
|
||||
@@ -0,0 +1,985 @@
|
||||
#!/usr/bin/env julia
|
||||
#=
|
||||
02_post_estimation.jl - Extract party position estimates from Stan model output
|
||||
|
||||
Supports both model versions:
|
||||
- 2D model (V1): Extracts economic_lr and galtan directly
|
||||
- 4D model (V10): Extracts 4 traits + 2 derived scales
|
||||
|
||||
V10/V1 UPDATE: Handles segment-based indexing
|
||||
- Maps segment results back to original party IDs
|
||||
- Adds segment_num column to indicate which segment of the party
|
||||
- Flags discontinuities for parties with multiple segments
|
||||
|
||||
This script:
|
||||
1. Auto-detects the latest model run in model_outputs/
|
||||
2. Loads the chain CSV files and data mappings
|
||||
3. Detects model version from metadata or column names
|
||||
4. Extracts posterior summaries for all segment-year positions
|
||||
5. Maps Stan parameter indices back to real party IDs, segment numbers, and years
|
||||
6. Saves output as wide-format CSV with uncertainty estimates
|
||||
|
||||
Usage:
|
||||
julia 02_post_estimation.jl
|
||||
|
||||
Output:
|
||||
party_positions_YYYY-MM-DD_HH-MM-SS.csv
|
||||
=#
|
||||
|
||||
using CSV
|
||||
using DataFrames
|
||||
using Statistics
|
||||
using JSON
|
||||
using Dates
|
||||
using Printf
|
||||
|
||||
# =============================================================================
|
||||
# STEP 0: Auto-detect latest run
|
||||
# =============================================================================
|
||||
|
||||
function find_latest_run(base_dir::String="outputs/model_outputs/latest")
|
||||
if !isdir(base_dir)
|
||||
error("Model outputs directory not found: $base_dir")
|
||||
end
|
||||
|
||||
runs = filter(d -> startswith(d, "run_") && isdir(joinpath(base_dir, d)), readdir(base_dir))
|
||||
|
||||
if isempty(runs)
|
||||
error("No runs found in $base_dir")
|
||||
end
|
||||
|
||||
# Sort by timestamp in directory name (format: run_YYYY-MM-DD_HH-MM-SS)
|
||||
sort!(runs, rev=true)
|
||||
|
||||
latest = joinpath(base_dir, runs[1])
|
||||
println("Found $(length(runs)) run(s). Using latest: $latest")
|
||||
return latest
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 1: Load data and build segment-year lookup
|
||||
# =============================================================================
|
||||
|
||||
function load_run_data(run_dir::String)
|
||||
println("\n" * "="^60)
|
||||
println("LOADING RUN DATA")
|
||||
println("="^60)
|
||||
|
||||
data_dir = joinpath(run_dir, "data")
|
||||
chains_dir = joinpath(run_dir, "chains")
|
||||
|
||||
# Check required files exist
|
||||
required_files = [
|
||||
joinpath(data_dir, "text_data.csv"),
|
||||
joinpath(data_dir, "expert_dim.csv"),
|
||||
joinpath(data_dir, "expert_lr.csv"),
|
||||
joinpath(run_dir, "metadata.json")
|
||||
]
|
||||
|
||||
for f in required_files
|
||||
if !isfile(f)
|
||||
error("Required file not found: $f")
|
||||
end
|
||||
end
|
||||
|
||||
# Load data files
|
||||
println("Loading text_data.csv...")
|
||||
text_data = CSV.read(joinpath(data_dir, "text_data.csv"), DataFrame)
|
||||
println(" Rows: $(nrow(text_data))")
|
||||
|
||||
println("Loading expert_dim.csv...")
|
||||
expert_dim = CSV.read(joinpath(data_dir, "expert_dim.csv"), DataFrame)
|
||||
println(" Rows: $(nrow(expert_dim))")
|
||||
|
||||
println("Loading expert_lr.csv...")
|
||||
expert_lr = CSV.read(joinpath(data_dir, "expert_lr.csv"), DataFrame)
|
||||
println(" Rows: $(nrow(expert_lr))")
|
||||
|
||||
println("Loading metadata.json...")
|
||||
metadata = JSON.parsefile(joinpath(run_dir, "metadata.json"))
|
||||
println(" year0: $(metadata["year0"])")
|
||||
println(" Model: $(metadata["model_file"])")
|
||||
|
||||
# V10: Load segment_info if available
|
||||
segment_info_file = joinpath(data_dir, "segment_info.csv")
|
||||
segment_info = nothing
|
||||
if isfile(segment_info_file)
|
||||
println("Loading segment_info.csv (V10)...")
|
||||
segment_info = CSV.read(segment_info_file, DataFrame)
|
||||
println(" Segments: $(nrow(segment_info))")
|
||||
end
|
||||
|
||||
# V10: Load segment_year_map if available
|
||||
segment_year_file = joinpath(data_dir, "segment_year_map.csv")
|
||||
segment_year_map = nothing
|
||||
if isfile(segment_year_file)
|
||||
println("Loading segment_year_map.csv (V10)...")
|
||||
segment_year_map = CSV.read(segment_year_file, DataFrame)
|
||||
println(" Segment-years: $(nrow(segment_year_map))")
|
||||
end
|
||||
|
||||
# Find chain files
|
||||
chain_files = filter(f -> endswith(f, ".csv") && startswith(f, "chain_"), readdir(chains_dir))
|
||||
println("\nFound $(length(chain_files)) chain file(s)")
|
||||
|
||||
return (
|
||||
text_data = text_data,
|
||||
expert_dim = expert_dim,
|
||||
expert_lr = expert_lr,
|
||||
metadata = metadata,
|
||||
segment_info = segment_info,
|
||||
segment_year_map = segment_year_map,
|
||||
chain_files = [joinpath(chains_dir, f) for f in sort(chain_files)],
|
||||
run_dir = run_dir
|
||||
)
|
||||
end
|
||||
|
||||
function normalize_country_value(value)
|
||||
if ismissing(value)
|
||||
return missing
|
||||
end
|
||||
txt = strip(string(value))
|
||||
return isempty(txt) ? missing : txt
|
||||
end
|
||||
|
||||
function build_party_country_map(text_data::DataFrame, expert_dim::DataFrame, expert_lr::DataFrame)
|
||||
merged = unique(vcat(
|
||||
select(text_data, :party, :country),
|
||||
select(expert_dim, :party, :country),
|
||||
select(expert_lr, :party, :country)
|
||||
))
|
||||
|
||||
party_to_country = Dict{Int, String}()
|
||||
for row in eachrow(merged)
|
||||
pid = tryparse(Int, string(row.party))
|
||||
if pid === nothing
|
||||
continue
|
||||
end
|
||||
c = normalize_country_value(row.country)
|
||||
if !ismissing(c)
|
||||
party_to_country[pid] = c
|
||||
end
|
||||
end
|
||||
return party_to_country
|
||||
end
|
||||
|
||||
function load_constituent_to_union_map()::Dict{Int, Int}
|
||||
mapping_file = joinpath("data", "union_mapping.csv")
|
||||
constituent_to_union = Dict{Int, Int}()
|
||||
if isfile(mapping_file)
|
||||
union_df = CSV.read(mapping_file, DataFrame)
|
||||
for row in eachrow(union_df)
|
||||
constituent_to_union[row.expert_pf_id] = row.manifesto_pf_id
|
||||
end
|
||||
end
|
||||
return constituent_to_union
|
||||
end
|
||||
|
||||
function resolve_party_country(pid_value,
|
||||
party_to_country::Dict{Int, String},
|
||||
constituent_to_union::Dict{Int, Int})
|
||||
pid = tryparse(Int, string(pid_value))
|
||||
if pid === nothing
|
||||
return missing, "unresolved"
|
||||
end
|
||||
|
||||
if haskey(party_to_country, pid)
|
||||
return party_to_country[pid], "direct"
|
||||
end
|
||||
|
||||
if haskey(constituent_to_union, pid)
|
||||
uid = constituent_to_union[pid]
|
||||
if haskey(party_to_country, uid)
|
||||
return party_to_country[uid], "union_fallback"
|
||||
end
|
||||
end
|
||||
|
||||
return missing, "unresolved"
|
||||
end
|
||||
|
||||
function apply_country_resolution!(df::DataFrame,
|
||||
party_col::Symbol,
|
||||
country_col::Symbol,
|
||||
party_to_country::Dict{Int, String},
|
||||
constituent_to_union::Dict{Int, Int})
|
||||
resolved_country = Union{Missing, String}[]
|
||||
source_counts = Dict("direct" => 0, "union_fallback" => 0, "unresolved" => 0)
|
||||
unresolved_parties = Set{Int}()
|
||||
|
||||
for pid in df[!, party_col]
|
||||
country, source = resolve_party_country(pid, party_to_country, constituent_to_union)
|
||||
push!(resolved_country, country)
|
||||
source_counts[source] += 1
|
||||
if source == "unresolved"
|
||||
pid_int = tryparse(Int, string(pid))
|
||||
if pid_int !== nothing
|
||||
push!(unresolved_parties, pid_int)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
df[!, country_col] = resolved_country
|
||||
return source_counts, sort!(collect(unresolved_parties))
|
||||
end
|
||||
|
||||
function fill_missing_countries!(df::DataFrame,
|
||||
segment_info::Union{DataFrame, Nothing},
|
||||
party_to_country::Dict{Int, String},
|
||||
constituent_to_union::Dict{Int, Int})
|
||||
if !hasproperty(df, :country)
|
||||
source_counts, unresolved = apply_country_resolution!(
|
||||
df, :party_id, :country, party_to_country, constituent_to_union
|
||||
)
|
||||
return source_counts, unresolved
|
||||
end
|
||||
|
||||
normalized = Union{Missing, String}[]
|
||||
for val in df.country
|
||||
push!(normalized, normalize_country_value(val))
|
||||
end
|
||||
df.country = normalized
|
||||
|
||||
source_counts = Dict("direct" => 0, "union_fallback" => 0, "segment_info" => 0, "unresolved" => 0)
|
||||
|
||||
segment_country_by_id = Dict{Int, String}()
|
||||
if segment_info !== nothing && hasproperty(segment_info, :country)
|
||||
for row in eachrow(segment_info)
|
||||
c = normalize_country_value(row.country)
|
||||
if !ismissing(c)
|
||||
segment_country_by_id[Int(row.segment_id)] = c
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
unresolved_parties = Set{Int}()
|
||||
for i in 1:nrow(df)
|
||||
if !ismissing(df.country[i])
|
||||
continue
|
||||
end
|
||||
|
||||
if hasproperty(df, :segment_id) && haskey(segment_country_by_id, Int(df.segment_id[i]))
|
||||
df.country[i] = segment_country_by_id[Int(df.segment_id[i])]
|
||||
source_counts["segment_info"] += 1
|
||||
continue
|
||||
end
|
||||
|
||||
country, source = resolve_party_country(df.party_id[i], party_to_country, constituent_to_union)
|
||||
if !ismissing(country)
|
||||
df.country[i] = country
|
||||
source_counts[source] += 1
|
||||
else
|
||||
source_counts["unresolved"] += 1
|
||||
pid_int = tryparse(Int, string(df.party_id[i]))
|
||||
pid_int !== nothing && push!(unresolved_parties, pid_int)
|
||||
end
|
||||
end
|
||||
|
||||
return source_counts, sort!(collect(unresolved_parties))
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 2: Build complete segment-year mapping (V10) or party-year mapping (V9)
|
||||
# =============================================================================
|
||||
|
||||
function build_segment_year_map(text_data::DataFrame, expert_dim::DataFrame, expert_lr::DataFrame,
|
||||
segment_info::Union{DataFrame, Nothing},
|
||||
segment_year_map::Union{DataFrame, Nothing},
|
||||
run_dir::String,
|
||||
year0::Int)
|
||||
println("\n" * "="^60)
|
||||
println("BUILDING SEGMENT-YEAR MAPPING")
|
||||
println("="^60)
|
||||
|
||||
party_to_country = build_party_country_map(text_data, expert_dim, expert_lr)
|
||||
constituent_to_union = load_constituent_to_union_map()
|
||||
|
||||
# V10: Use segment_year_map if available
|
||||
if segment_year_map !== nothing && segment_info !== nothing
|
||||
println("Using segment_year_map.csv (V10 mode)")
|
||||
|
||||
# Convert relative Year to absolute year
|
||||
if hasproperty(segment_year_map, :Year)
|
||||
segment_year_map.year = segment_year_map.Year .+ year0
|
||||
elseif !hasproperty(segment_year_map, :year)
|
||||
error("segment_year_map has no Year or year column")
|
||||
end
|
||||
|
||||
# Add party_id from segment_info if not already present
|
||||
if !hasproperty(segment_year_map, :party_id)
|
||||
segment_id_to_party = Dict(row.segment_id => row.party_id for row in eachrow(segment_info))
|
||||
segment_year_map.party_id = [segment_id_to_party[sid] for sid in segment_year_map.segment_id]
|
||||
end
|
||||
|
||||
# Add segment_num from segment_info if not already present
|
||||
if !hasproperty(segment_year_map, :segment_num)
|
||||
segment_id_to_segnum = Dict(row.segment_id => row.segment_num for row in eachrow(segment_info))
|
||||
segment_year_map.segment_num = [segment_id_to_segnum[sid] for sid in segment_year_map.segment_id]
|
||||
end
|
||||
|
||||
# Resolve/fill country column using segment metadata first, then direct and union-fallback lookup.
|
||||
source_counts, unresolved = fill_missing_countries!(
|
||||
segment_year_map, segment_info, party_to_country, constituent_to_union
|
||||
)
|
||||
direct_count = get(source_counts, "direct", 0)
|
||||
union_count = get(source_counts, "union_fallback", 0)
|
||||
segment_info_count = get(source_counts, "segment_info", 0)
|
||||
unresolved_count = count(ismissing, segment_year_map.country)
|
||||
println(" Country resolution fill counts: direct=$direct_count, union_fallback=$union_count, segment_info=$segment_info_count, unresolved_rows=$unresolved_count")
|
||||
if !isempty(unresolved)
|
||||
println(" Warning: unresolved country party IDs (first 20): $(unresolved[1:min(20, length(unresolved))])")
|
||||
end
|
||||
|
||||
R = maximum(segment_year_map.rr)
|
||||
n_segments = length(unique(segment_year_map.segment_id))
|
||||
n_parties = length(unique(segment_year_map.party_id))
|
||||
|
||||
println("Loaded segment_year_map: $(nrow(segment_year_map)) segment-years (R=$R)")
|
||||
println(" Unique segments: $n_segments")
|
||||
println(" Unique parties: $n_parties")
|
||||
|
||||
# Count observed vs interpolated
|
||||
observed_rrs = Set{Int}()
|
||||
if hasproperty(text_data, :rr_man)
|
||||
union!(observed_rrs, Set(text_data.rr_man))
|
||||
end
|
||||
if hasproperty(expert_dim, :rr_exp_dim)
|
||||
union!(observed_rrs, Set(expert_dim.rr_exp_dim))
|
||||
end
|
||||
if hasproperty(expert_lr, :rr_exp_lr)
|
||||
union!(observed_rrs, Set(expert_lr.rr_exp_lr))
|
||||
end
|
||||
|
||||
n_observed = length(intersect(Set(segment_year_map.rr), observed_rrs))
|
||||
n_interpolated = nrow(segment_year_map) - n_observed
|
||||
println(" Observed segment-years: $n_observed")
|
||||
println(" Interpolated segment-years: $n_interpolated")
|
||||
|
||||
return segment_year_map, R, segment_info
|
||||
end
|
||||
|
||||
# V9 fallback: Use party_year_map
|
||||
party_year_file = joinpath(run_dir, "data", "party_year_map.csv")
|
||||
if isfile(party_year_file)
|
||||
println("Loading party_year_map.csv (V9 fallback mode)")
|
||||
party_year_map = CSV.read(party_year_file, DataFrame)
|
||||
|
||||
# Add party_id column (same as party for V9)
|
||||
if !hasproperty(party_year_map, :party_id)
|
||||
party_year_map.party_id = party_year_map.party
|
||||
end
|
||||
|
||||
# Add segment_num column (always 1 for V9)
|
||||
if !hasproperty(party_year_map, :segment_num)
|
||||
party_year_map.segment_num = ones(Int, nrow(party_year_map))
|
||||
end
|
||||
|
||||
# Add segment_id column (same as party index for V9)
|
||||
if !hasproperty(party_year_map, :segment_id)
|
||||
party_year_map.segment_id = party_year_map.party
|
||||
end
|
||||
|
||||
# Convert relative Year to absolute year
|
||||
if hasproperty(party_year_map, :Year)
|
||||
party_year_map.year = party_year_map.Year .+ year0
|
||||
elseif !hasproperty(party_year_map, :year)
|
||||
error("party_year_map has no Year or year column")
|
||||
end
|
||||
|
||||
# Resolve/fill country column
|
||||
if !hasproperty(party_year_map, :country)
|
||||
source_counts, unresolved = apply_country_resolution!(
|
||||
party_year_map, :party_id, :country, party_to_country, constituent_to_union
|
||||
)
|
||||
direct_count = source_counts["direct"]
|
||||
union_count = source_counts["union_fallback"]
|
||||
unresolved_count = source_counts["unresolved"]
|
||||
println(" Country resolution sources: direct=$direct_count, union_fallback=$union_count, unresolved=$unresolved_count")
|
||||
if !isempty(unresolved)
|
||||
println(" Warning: unresolved country party IDs (first 20): $(unresolved[1:min(20, length(unresolved))])")
|
||||
end
|
||||
else
|
||||
normalized = Union{Missing, String}[]
|
||||
for val in party_year_map.country
|
||||
push!(normalized, normalize_country_value(val))
|
||||
end
|
||||
party_year_map.country = normalized
|
||||
end
|
||||
|
||||
R = maximum(party_year_map.rr)
|
||||
println("Loaded party_year_map: $(nrow(party_year_map)) party-years (R=$R)")
|
||||
|
||||
return party_year_map, R, nothing
|
||||
end
|
||||
|
||||
# Fallback: Reconstruct from data files
|
||||
@warn "No mapping file found, reconstructing from data (observed years only)"
|
||||
|
||||
# Extract unique party-year-rr combinations from text_data
|
||||
text_map = unique(select(text_data, :party, :country, :year, :rr_man))
|
||||
rename!(text_map, :rr_man => :rr)
|
||||
text_map.party_id = text_map.party
|
||||
text_map.segment_num = ones(Int, nrow(text_map))
|
||||
|
||||
expert_dim_map = unique(select(expert_dim, :party, :country, :year, :rr_exp_dim))
|
||||
rename!(expert_dim_map, :rr_exp_dim => :rr)
|
||||
expert_dim_map.party_id = expert_dim_map.party
|
||||
expert_dim_map.segment_num = ones(Int, nrow(expert_dim_map))
|
||||
|
||||
expert_lr_map = unique(select(expert_lr, :party, :country, :year, :rr_exp_lr))
|
||||
rename!(expert_lr_map, :rr_exp_lr => :rr)
|
||||
expert_lr_map.party_id = expert_lr_map.party
|
||||
expert_lr_map.segment_num = ones(Int, nrow(expert_lr_map))
|
||||
|
||||
combined = vcat(text_map, expert_dim_map, expert_lr_map)
|
||||
segment_year_map = unique(combined)
|
||||
sort!(segment_year_map, :rr)
|
||||
|
||||
R = maximum(segment_year_map.rr)
|
||||
println("Reconstructed mapping: $(nrow(segment_year_map)) segment-years (R=$R)")
|
||||
|
||||
return segment_year_map, R, nothing
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3: Load and combine chains
|
||||
# =============================================================================
|
||||
|
||||
function load_chains(chain_files::Vector{String})
|
||||
println("\n" * "="^60)
|
||||
println("LOADING STAN CHAINS")
|
||||
println("="^60)
|
||||
flush(stdout)
|
||||
|
||||
chains = DataFrame[]
|
||||
|
||||
# The full Stan CSVs are very wide (hundreds of thousands of columns). For
|
||||
# post-estimation we only need party-position generated quantities. Reading
|
||||
# all columns can take hours and allocate many GB of irrelevant parameters.
|
||||
post_estimation_prefixes = (
|
||||
"economic_lr.",
|
||||
"galtan.",
|
||||
"pro_market.",
|
||||
"pro_welfare.",
|
||||
"cosmopolitan.",
|
||||
"traditional.",
|
||||
)
|
||||
keep_post_estimation_col(_i, name) = any(startswith(String(name), p) for p in post_estimation_prefixes)
|
||||
|
||||
for (i, f) in enumerate(chain_files)
|
||||
println("Loading chain $i: $(basename(f))...")
|
||||
flush(stdout)
|
||||
# Skip comment lines (Stan header) and parse only needed quantities.
|
||||
chain = CSV.read(f, DataFrame; comment="#", select=keep_post_estimation_col)
|
||||
println(" Samples: $(nrow(chain)), Parameters: $(ncol(chain))")
|
||||
flush(stdout)
|
||||
push!(chains, chain)
|
||||
end
|
||||
|
||||
# Combine chains
|
||||
println("Combining selected chain columns...")
|
||||
flush(stdout)
|
||||
combined = vcat(chains...)
|
||||
println("\nCombined: $(nrow(combined)) total samples")
|
||||
println("Selected parameters: $(ncol(combined))")
|
||||
flush(stdout)
|
||||
|
||||
return combined
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 4: Extract generated quantities
|
||||
# =============================================================================
|
||||
|
||||
"""
|
||||
Detect model version from chain column names.
|
||||
Returns "2dim" or "4dim".
|
||||
"""
|
||||
function detect_model_version(chains::DataFrame)
|
||||
cols = names(chains)
|
||||
# 2D model has economic_lr but NOT pro_market
|
||||
has_economic_lr = any(c -> startswith(string(c), "economic_lr."), cols)
|
||||
has_pro_market = any(c -> startswith(string(c), "pro_market."), cols)
|
||||
|
||||
if has_economic_lr && !has_pro_market
|
||||
return "2dim"
|
||||
elseif has_pro_market
|
||||
return "4dim"
|
||||
else
|
||||
error("Could not detect model version from chain columns")
|
||||
end
|
||||
end
|
||||
|
||||
function extract_estimates(chains::DataFrame, segment_year_map::DataFrame, R::Int)
|
||||
println("\n" * "="^60)
|
||||
println("EXTRACTING POSTERIOR ESTIMATES")
|
||||
println("="^60)
|
||||
|
||||
# Auto-detect model version from columns
|
||||
model_version = detect_model_version(chains)
|
||||
println("Detected model version: $model_version")
|
||||
|
||||
# Select quantities based on model version
|
||||
if model_version == "2dim"
|
||||
# 2D model: economic_lr and galtan are directly estimated
|
||||
# (general_lr is computed in Stan for anchoring but not extracted as output)
|
||||
quantities = ["economic_lr", "galtan"]
|
||||
test_col = "economic_lr.1"
|
||||
else
|
||||
# 4D model: 4 traits + 2 derived scales
|
||||
quantities = ["pro_market", "pro_welfare", "cosmopolitan", "traditional", "economic_lr", "galtan"]
|
||||
test_col = "pro_market.1"
|
||||
end
|
||||
|
||||
# Check that columns exist
|
||||
if !hasproperty(chains, Symbol(test_col))
|
||||
error("Column $test_col not found in chains. Available columns: $(first(names(chains), 10))...")
|
||||
end
|
||||
|
||||
n_samples = nrow(chains)
|
||||
println("Samples per parameter: $n_samples")
|
||||
|
||||
# Load union mapping for adding union_party_id column
|
||||
union_mapping_file = joinpath("data", "union_mapping.csv")
|
||||
constituent_to_union_pf = Dict{Int, Int}()
|
||||
if isfile(union_mapping_file)
|
||||
union_df = CSV.read(union_mapping_file, DataFrame)
|
||||
for row in eachrow(union_df)
|
||||
constituent_to_union_pf[row.expert_pf_id] = row.manifesto_pf_id
|
||||
end
|
||||
end
|
||||
|
||||
# Pre-allocate output DataFrame
|
||||
n_rows = nrow(segment_year_map)
|
||||
|
||||
# Add union_party_id column: NA for standalone parties, union PF ID for constituents
|
||||
union_ids = Union{Int, Missing}[]
|
||||
for pid in segment_year_map.party_id
|
||||
pid_int = isa(pid, Integer) ? pid : tryparse(Int, string(pid))
|
||||
if pid_int !== nothing && haskey(constituent_to_union_pf, pid_int)
|
||||
push!(union_ids, constituent_to_union_pf[pid_int])
|
||||
else
|
||||
push!(union_ids, missing)
|
||||
end
|
||||
end
|
||||
|
||||
output = DataFrame(
|
||||
party_id = segment_year_map.party_id,
|
||||
union_party_id = union_ids,
|
||||
segment_num = segment_year_map.segment_num,
|
||||
country = segment_year_map.country,
|
||||
year = segment_year_map.year,
|
||||
rr = segment_year_map.rr
|
||||
)
|
||||
|
||||
# Add columns for each quantity
|
||||
for q in quantities
|
||||
output[!, Symbol(q)] = zeros(Float64, n_rows)
|
||||
output[!, Symbol("$(q)_se")] = zeros(Float64, n_rows)
|
||||
output[!, Symbol("$(q)_q025")] = zeros(Float64, n_rows)
|
||||
output[!, Symbol("$(q)_q975")] = zeros(Float64, n_rows)
|
||||
end
|
||||
|
||||
println("Extracting estimates for $(n_rows) segment-year positions...")
|
||||
|
||||
# Progress tracking
|
||||
prog_interval = max(1, n_rows ÷ 20)
|
||||
|
||||
for (i, row) in enumerate(eachrow(segment_year_map))
|
||||
r = row.rr
|
||||
|
||||
# Progress
|
||||
if i % prog_interval == 0 || i == n_rows
|
||||
pct = round(100 * i / n_rows, digits=1)
|
||||
print("\r Progress: $pct% ($i / $n_rows)")
|
||||
end
|
||||
|
||||
for q in quantities
|
||||
col_name = Symbol("$q.$r")
|
||||
|
||||
if !hasproperty(chains, col_name)
|
||||
@warn "Column $col_name not found (rr=$r)" maxlog=5
|
||||
continue
|
||||
end
|
||||
|
||||
samples = chains[!, col_name]
|
||||
|
||||
# Compute summary statistics
|
||||
output[i, Symbol(q)] = mean(samples)
|
||||
output[i, Symbol("$(q)_se")] = std(samples)
|
||||
output[i, Symbol("$(q)_q025")] = quantile(samples, 0.025)
|
||||
output[i, Symbol("$(q)_q975")] = quantile(samples, 0.975)
|
||||
end
|
||||
end
|
||||
println() # Newline after progress
|
||||
|
||||
# Remove the rr column from final output (internal only)
|
||||
select!(output, Not(:rr))
|
||||
|
||||
return output
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 5: Validation
|
||||
# =============================================================================
|
||||
|
||||
function validate_output(output::DataFrame, segment_info::Union{DataFrame, Nothing})
|
||||
println("\n" * "="^60)
|
||||
println("VALIDATION CHECKS")
|
||||
println("="^60)
|
||||
|
||||
all_passed = true
|
||||
|
||||
# Detect which columns are present (2D vs 4D model)
|
||||
has_4d = hasproperty(output, :pro_market)
|
||||
|
||||
# Check 1: Range check - all estimates should be in [0, 1]
|
||||
println("\n1. Range check (all values in [0, 1]):")
|
||||
|
||||
if has_4d
|
||||
check_cols = [:pro_market, :pro_welfare, :cosmopolitan, :traditional, :economic_lr, :galtan]
|
||||
else
|
||||
check_cols = [:economic_lr, :galtan]
|
||||
end
|
||||
|
||||
for col in check_cols
|
||||
if !hasproperty(output, col)
|
||||
continue
|
||||
end
|
||||
vals = output[!, col]
|
||||
min_val, max_val = extrema(vals)
|
||||
in_range = min_val >= 0 && max_val <= 1
|
||||
status = in_range ? "PASS" : "FAIL"
|
||||
println(" $col: [$(@sprintf("%.4f", min_val)), $(@sprintf("%.4f", max_val))] - $status")
|
||||
all_passed = all_passed && in_range
|
||||
end
|
||||
|
||||
# Check 2: Anchor party checks
|
||||
println("\n2. Anchor party checks:")
|
||||
|
||||
# Define anchor parties with expected ranges (for 2D model)
|
||||
# Includes both union IDs (V3) and individual constituent IDs (V4)
|
||||
anchor_parties = [
|
||||
(id=211, name="CDU/CSU", country="DE", econ=(0.50, 0.70), galtan=(0.45, 0.70)),
|
||||
(id=1375, name="CDU", country="DE", econ=(0.50, 0.70), galtan=(0.45, 0.65)),
|
||||
(id=1731, name="CSU", country="DE", econ=(0.50, 0.70), galtan=(0.55, 0.75)),
|
||||
(id=383, name="SPD", country="DE", econ=(0.30, 0.50), galtan=(0.30, 0.55)),
|
||||
(id=1516, name="Labour", country="GB", econ=(0.30, 0.55), galtan=(0.30, 0.55)),
|
||||
(id=1567, name="Conservatives", country="GB", econ=(0.55, 0.80), galtan=(0.50, 0.75)),
|
||||
(id=487, name="SAP", country="SE", econ=(0.30, 0.50), galtan=(0.35, 0.55)),
|
||||
]
|
||||
|
||||
n_checked = 0
|
||||
n_passed = 0
|
||||
|
||||
for anchor in anchor_parties
|
||||
party_rows = filter(r -> r.party_id == anchor.id, output)
|
||||
|
||||
if nrow(party_rows) == 0
|
||||
println(" $(anchor.name) ($(anchor.id)): NOT FOUND")
|
||||
continue
|
||||
end
|
||||
|
||||
# Use most recent 20 years of data as reference period
|
||||
max_year = maximum(party_rows.year)
|
||||
ref_rows = filter(r -> r.year >= max_year - 20, party_rows)
|
||||
if nrow(ref_rows) == 0
|
||||
ref_rows = party_rows
|
||||
end
|
||||
|
||||
n_checked += 1
|
||||
|
||||
mean_econ = mean(ref_rows.economic_lr)
|
||||
mean_galtan = mean(ref_rows.galtan)
|
||||
|
||||
econ_ok = anchor.econ[1] <= mean_econ <= anchor.econ[2]
|
||||
galtan_ok = anchor.galtan[1] <= mean_galtan <= anchor.galtan[2]
|
||||
all_ok = econ_ok && galtan_ok
|
||||
|
||||
if all_ok
|
||||
n_passed += 1
|
||||
end
|
||||
|
||||
status = all_ok ? "PASS" : "WARN"
|
||||
econ_marker = econ_ok ? "" : "*"
|
||||
galtan_marker = galtan_ok ? "" : "*"
|
||||
|
||||
@printf(" %-15s econ=%.2f%s [%.2f-%.2f] galtan=%.2f%s [%.2f-%.2f] %s\n",
|
||||
anchor.name, mean_econ, econ_marker, anchor.econ[1], anchor.econ[2],
|
||||
mean_galtan, galtan_marker, anchor.galtan[1], anchor.galtan[2], status)
|
||||
end
|
||||
|
||||
if n_checked > 0
|
||||
println(" Anchor check: $n_passed/$n_checked within expected ranges")
|
||||
println(" Note: Model integrates text + expert data; deviations from expert-only expectations are normal")
|
||||
end
|
||||
|
||||
# Check 3: Coverage check
|
||||
println("\n3. Coverage check:")
|
||||
println(" Total segment-year positions: $(nrow(output))")
|
||||
println(" Unique parties: $(length(unique(output.party_id)))")
|
||||
blank_country_rows = count(ismissing, output.country)
|
||||
println(" Unique countries: $(length(unique(skipmissing(output.country))))")
|
||||
if blank_country_rows == 0
|
||||
println(" Blank country rows: 0 - PASS")
|
||||
else
|
||||
println(" Blank country rows: $blank_country_rows - FAIL")
|
||||
all_passed = false
|
||||
end
|
||||
println(" Year range: $(minimum(output.year)) - $(maximum(output.year))")
|
||||
|
||||
# V10: Check segment distribution
|
||||
segment_counts = combine(groupby(output, :party_id), nrow => :n_years)
|
||||
parties_multi_segment = filter(r -> r.party_id in
|
||||
[p for p in unique(output.party_id) if length(unique(filter(x -> x.party_id == p, output).segment_num)) > 1],
|
||||
segment_counts)
|
||||
if nrow(parties_multi_segment) > 0
|
||||
println("\n Parties with multiple segments: $(length(unique(parties_multi_segment.party_id)))")
|
||||
end
|
||||
|
||||
# Check 4: No duplicates (party_id, segment_num, year should be unique)
|
||||
println("\n4. Duplicate check:")
|
||||
dup_count = nrow(output) - nrow(unique(select(output, :party_id, :segment_num, :year)))
|
||||
if dup_count == 0
|
||||
println(" No duplicate (party_id, segment_num, year) combinations - PASS")
|
||||
else
|
||||
println(" WARNING: Found $dup_count duplicate combinations!")
|
||||
all_passed = false
|
||||
end
|
||||
|
||||
# Check 5: SE reasonableness
|
||||
println("\n5. Standard error check:")
|
||||
se_cols = has_4d ?
|
||||
[:pro_market_se, :pro_welfare_se, :cosmopolitan_se, :traditional_se] :
|
||||
[:economic_lr_se, :galtan_se]
|
||||
|
||||
for col in se_cols
|
||||
if !hasproperty(output, col)
|
||||
continue
|
||||
end
|
||||
vals = output[!, col]
|
||||
mean_se = mean(vals)
|
||||
max_se = maximum(vals)
|
||||
println(" $col: mean=$(@sprintf("%.4f", mean_se)), max=$(@sprintf("%.4f", max_se))")
|
||||
end
|
||||
|
||||
println("\n" * "-"^60)
|
||||
if all_passed
|
||||
println("All validation checks PASSED")
|
||||
else
|
||||
println("Some validation checks FAILED - please review output carefully")
|
||||
end
|
||||
|
||||
return all_passed
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 6: Save output
|
||||
# =============================================================================
|
||||
|
||||
function save_output(output::DataFrame, metadata::Dict, segment_info::Union{DataFrame, Nothing}, run_dir::String; outdir::String="outputs/estimations/latest")
|
||||
println("\n" * "="^60)
|
||||
println("SAVING OUTPUT")
|
||||
println("="^60)
|
||||
|
||||
timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
|
||||
mkpath(outdir)
|
||||
|
||||
# Delete previous output files
|
||||
for f in readdir(outdir)
|
||||
if startswith(f, "party_positions_") && (endswith(f, ".csv") || endswith(f, ".txt") || endswith(f, ".tex"))
|
||||
rm(joinpath(outdir, f))
|
||||
println(" Deleted old: $f")
|
||||
end
|
||||
end
|
||||
|
||||
# Save main CSV
|
||||
csv_file = joinpath(outdir, "party_positions_$timestamp.csv")
|
||||
CSV.write(csv_file, output)
|
||||
println("Saved: $csv_file")
|
||||
println(" Rows: $(nrow(output))")
|
||||
println(" Columns: $(ncol(output))")
|
||||
|
||||
# Count parties with multiple segments
|
||||
n_multi_segment = 0
|
||||
if segment_info !== nothing
|
||||
party_segment_counts = combine(groupby(segment_info, :party_id), nrow => :n_segments)
|
||||
n_multi_segment = count(r -> r.n_segments > 1, eachrow(party_segment_counts))
|
||||
end
|
||||
|
||||
# Save metadata
|
||||
meta_file = joinpath(outdir, "party_positions_$(timestamp)_metadata.txt")
|
||||
open(meta_file, "w") do f
|
||||
println(f, "Party Positions Dataset - Metadata")
|
||||
println(f, "="^50)
|
||||
println(f, "")
|
||||
println(f, "Generated: $(Dates.format(now(), "yyyy-mm-dd HH:MM:SS"))")
|
||||
println(f, "Source run: $(basename(run_dir))")
|
||||
println(f, "Model file: $(get(metadata, "model_file", "unknown"))")
|
||||
println(f, "")
|
||||
println(f, "Dataset size:")
|
||||
println(f, " Segment-year observations: $(nrow(output))")
|
||||
println(f, " Unique parties: $(length(unique(output.party_id)))")
|
||||
if n_multi_segment > 0
|
||||
println(f, " Parties with multiple segments: $n_multi_segment")
|
||||
end
|
||||
println(f, " Unique countries: $(length(unique(output.country)))")
|
||||
println(f, " Year range: $(minimum(output.year)) - $(maximum(output.year))")
|
||||
println(f, "")
|
||||
println(f, "Columns:")
|
||||
println(f, " party_id: PartyFacts ID (integer) - individual party (e.g., CDU=1375, CSU=1731)")
|
||||
println(f, " union_party_id: PartyFacts ID of parent union (NA for standalone parties)")
|
||||
println(f, " segment_num: Segment number within party (1, 2, 3...)")
|
||||
println(f, " country: ISO2 country code")
|
||||
println(f, " year: Calendar year")
|
||||
println(f, "")
|
||||
println(f, "Segment-Based Indexing:")
|
||||
println(f, " - Parties are split into segments at gaps > 7 years")
|
||||
println(f, " - Each segment is estimated independently (no continuity across gaps)")
|
||||
println(f, " - Segments with < 3 observations are dropped")
|
||||
println(f, " - segment_num=1 is the main segment; higher numbers indicate gaps in data")
|
||||
println(f, "")
|
||||
|
||||
# Check if this is 2D or 4D output
|
||||
is_2d = !hasproperty(output, :pro_market)
|
||||
|
||||
if is_2d
|
||||
println(f, "Model: 2D Direct Bipolar")
|
||||
println(f, "")
|
||||
println(f, "Bipolar scales (0 = left/GAL, 1 = right/TAN):")
|
||||
println(f, " economic_lr: Economic left-right position (directly estimated)")
|
||||
println(f, " galtan: GAL-TAN cultural position (directly estimated)")
|
||||
# Note: general_lr is computed internally for cross-dimensional anchoring
|
||||
# but not reported as output (the two dimension-specific estimates are preferred)
|
||||
else
|
||||
println(f, "Model: 4D Unipolar")
|
||||
println(f, "")
|
||||
println(f, "Dimensions (0 = low, 1 = high):")
|
||||
println(f, " pro_market: Pro-market economic position")
|
||||
println(f, " pro_welfare: Pro-welfare state position")
|
||||
println(f, " cosmopolitan: Cosmopolitan/GAL cultural position")
|
||||
println(f, " traditional: Traditional/TAN cultural position")
|
||||
println(f, "")
|
||||
println(f, "Derived bipolar scales (0 = left/GAL, 1 = right/TAN):")
|
||||
println(f, " economic_lr: Economic left-right (derived from pro_market - pro_welfare)")
|
||||
println(f, " galtan: GAL-TAN (derived from traditional - cosmopolitan)")
|
||||
end
|
||||
println(f, "")
|
||||
println(f, "Uncertainty columns:")
|
||||
println(f, " *_se: Standard error (posterior SD)")
|
||||
println(f, " *_q025: 2.5th percentile (lower 95% CI)")
|
||||
println(f, " *_q975: 97.5th percentile (upper 95% CI)")
|
||||
println(f, "")
|
||||
println(f, "Model convergence:")
|
||||
println(f, " Mean R-hat: $(get(metadata, "mean_rhat", "N/A"))")
|
||||
println(f, " Max R-hat: $(get(metadata, "max_rhat", "N/A"))")
|
||||
println(f, " Mean ESS: $(get(metadata, "mean_ess", "N/A"))")
|
||||
println(f, " Min ESS: $(get(metadata, "min_ess", "N/A"))")
|
||||
end
|
||||
println("Saved: $meta_file")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STEP 5b: Verify no union/alliance IDs in output
|
||||
# =============================================================================
|
||||
|
||||
function verify_no_unions_in_output(output::DataFrame)
|
||||
println("\n" * "="^60)
|
||||
println("UNION ID VERIFICATION")
|
||||
println("="^60)
|
||||
|
||||
union_mapping_file = joinpath("data", "union_mapping.csv")
|
||||
if !isfile(union_mapping_file)
|
||||
println(" No union_mapping.csv found — skipping verification")
|
||||
return
|
||||
end
|
||||
|
||||
union_df = CSV.read(union_mapping_file, DataFrame)
|
||||
union_pf_ids = Set(union_df.manifesto_pf_id)
|
||||
output_pf_ids = Set(output.party_id)
|
||||
|
||||
violations = intersect(union_pf_ids, output_pf_ids)
|
||||
|
||||
if isempty(violations)
|
||||
println(" PASS: No union/alliance PF IDs found in output")
|
||||
println(" Checked $(length(union_pf_ids)) union IDs against $(length(output_pf_ids)) output parties")
|
||||
else
|
||||
println(" WARNING: $(length(violations)) union PF IDs found in output")
|
||||
println(" (This is expected if union_mapping.csv was updated after the model run)")
|
||||
for v in sort(collect(violations))
|
||||
n_rows = count(r -> r.party_id == v, eachrow(output))
|
||||
println(" PF $v: $n_rows rows")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
function main()
|
||||
println("="^60)
|
||||
println("POST-ESTIMATION: 4D Latent Trait Model (V10)")
|
||||
println("="^60)
|
||||
println("Started: $(Dates.format(now(), "yyyy-mm-dd HH:MM:SS"))")
|
||||
|
||||
# Step 0: Find run directory (CLI --run-dir or auto-detect latest)
|
||||
run_dir = nothing
|
||||
output_dir = nothing
|
||||
for (i, arg) in enumerate(ARGS)
|
||||
if arg == "--run-dir" && i < length(ARGS)
|
||||
run_dir = ARGS[i + 1]
|
||||
elseif startswith(arg, "--run-dir=")
|
||||
run_dir = split(arg, "=", limit=2)[2]
|
||||
elseif arg == "--output-dir" && i < length(ARGS)
|
||||
output_dir = ARGS[i + 1]
|
||||
elseif startswith(arg, "--output-dir=")
|
||||
output_dir = split(arg, "=", limit=2)[2]
|
||||
end
|
||||
end
|
||||
if run_dir === nothing
|
||||
run_dir = find_latest_run()
|
||||
else
|
||||
println("Using specified run directory: $run_dir")
|
||||
end
|
||||
|
||||
# Step 1: Load run data
|
||||
data = load_run_data(run_dir)
|
||||
|
||||
# Step 2: Build segment-year mapping
|
||||
year0 = data.metadata["year0"]
|
||||
segment_year_map, R, segment_info = build_segment_year_map(
|
||||
data.text_data, data.expert_dim, data.expert_lr,
|
||||
data.segment_info, data.segment_year_map, data.run_dir, year0
|
||||
)
|
||||
|
||||
# Step 3: Load chains
|
||||
chains = load_chains(data.chain_files)
|
||||
|
||||
# Step 4: Extract estimates
|
||||
output = extract_estimates(chains, segment_year_map, R)
|
||||
|
||||
# Step 5: Validate
|
||||
validate_output(output, segment_info)
|
||||
|
||||
# Step 5b: Verify no union/alliance IDs in output
|
||||
verify_no_unions_in_output(output)
|
||||
|
||||
# Step 6: Save output
|
||||
effective_output_dir = output_dir !== nothing ? output_dir : "outputs/estimations/latest"
|
||||
csv_file, meta_file = save_output(output, data.metadata, segment_info, run_dir; outdir=effective_output_dir)
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("COMPLETE")
|
||||
println("="^60)
|
||||
println("Output files:")
|
||||
println(" $csv_file")
|
||||
println(" $meta_file")
|
||||
println("\nFinished: $(Dates.format(now(), "yyyy-mm-dd HH:MM:SS"))")
|
||||
|
||||
return output
|
||||
end
|
||||
|
||||
# Run if executed directly
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
main()
|
||||
end
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## 00_validation.jl
|
||||
## Pre-flight validation checks for Stan data and initialization
|
||||
## Prevents cryptic Stan errors by catching issues early
|
||||
#############################################################################
|
||||
|
||||
using Statistics
|
||||
|
||||
"""
|
||||
Validate Stan data dictionary before passing to Stan.
|
||||
Catches common issues that cause Stan to crash with cryptic errors.
|
||||
"""
|
||||
function validate_stan_data(dat::Dict; verbose=true)
|
||||
verbose && println("\n" * "="^70)
|
||||
verbose && println("VALIDATING STAN DATA")
|
||||
verbose && println("="^70)
|
||||
|
||||
errors = String[]
|
||||
warnings = String[]
|
||||
|
||||
# Check for NaN/Inf in all numeric data
|
||||
for (key, value) in dat
|
||||
if isa(value, AbstractArray) && eltype(value) <: Number
|
||||
if any(isnan, value)
|
||||
push!(errors, "Data '$key' contains NaN values")
|
||||
end
|
||||
if any(isinf, value)
|
||||
push!(errors, "Data '$key' contains Inf values")
|
||||
end
|
||||
elseif isa(value, Number)
|
||||
if isnan(value)
|
||||
push!(errors, "Data '$key' is NaN")
|
||||
end
|
||||
if isinf(value)
|
||||
push!(errors, "Data '$key' is Inf")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Validate expert data is in open interval (0, 1)
|
||||
if haskey(dat, "val_dim")
|
||||
val_dim = dat["val_dim"]
|
||||
if any(x -> x <= 0 || x >= 1, val_dim)
|
||||
n_boundary = count(x -> x <= 0 || x >= 1, val_dim)
|
||||
push!(errors, "Expert dimension data has $n_boundary values at boundaries (must be in (0,1))")
|
||||
if verbose
|
||||
println(" Dimension-specific expert data range: [$(minimum(val_dim)), $(maximum(val_dim))]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if haskey(dat, "val_lr")
|
||||
val_lr = dat["val_lr"]
|
||||
if any(x -> x <= 0 || x >= 1, val_lr)
|
||||
n_boundary = count(x -> x <= 0 || x >= 1, val_lr)
|
||||
push!(errors, "Expert L-R data has $n_boundary values at boundaries (must be in (0,1))")
|
||||
if verbose
|
||||
println(" L-R expert data range: [$(minimum(val_lr)), $(maximum(val_lr))]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Validate manifesto data
|
||||
if haskey(dat, "positive") && haskey(dat, "sample")
|
||||
positive = dat["positive"]
|
||||
sample = dat["sample"]
|
||||
|
||||
if any(positive .> sample)
|
||||
push!(errors, "Manifesto: positive counts exceed sample sizes")
|
||||
end
|
||||
|
||||
if any(positive .< 0)
|
||||
push!(errors, "Manifesto: negative positive counts found")
|
||||
end
|
||||
|
||||
if any(sample .< 0)
|
||||
push!(errors, "Manifesto: negative sample sizes found")
|
||||
end
|
||||
end
|
||||
|
||||
# Validate indices are within bounds
|
||||
# V10: Check segment indices (ss_man) if present, otherwise party indices (jj_man)
|
||||
if haskey(dat, "S") && haskey(dat, "ss_man")
|
||||
S = dat["S"]
|
||||
ss_man = dat["ss_man"]
|
||||
if any(ss_man .< 1) || any(ss_man .> S)
|
||||
push!(errors, "Manifesto segment indices out of bounds [1, $S]")
|
||||
end
|
||||
elseif haskey(dat, "J") && haskey(dat, "jj_man")
|
||||
J = dat["J"]
|
||||
jj_man = dat["jj_man"]
|
||||
if any(jj_man .< 1) || any(jj_man .> J)
|
||||
push!(errors, "Manifesto party indices out of bounds [1, $J]")
|
||||
end
|
||||
end
|
||||
|
||||
if haskey(dat, "R") && haskey(dat, "rr_man")
|
||||
R = dat["R"]
|
||||
rr_man = dat["rr_man"]
|
||||
if any(rr_man .< 1) || any(rr_man .> R)
|
||||
push!(errors, "Manifesto party-year indices out of bounds [1, $R]")
|
||||
end
|
||||
end
|
||||
|
||||
# V4: Validate constituent arrays
|
||||
if haskey(dat, "const_rr_man") && haskey(dat, "R")
|
||||
R = dat["R"]
|
||||
const_rr = dat["const_rr_man"]
|
||||
if any(const_rr .< 1) || any(const_rr .> R)
|
||||
push!(errors, "const_rr_man out of bounds [1, $R]")
|
||||
end
|
||||
# Verify offsets are valid
|
||||
if haskey(dat, "const_offset_man") && haskey(dat, "n_const_man")
|
||||
offsets = dat["const_offset_man"]
|
||||
n_consts = dat["n_const_man"]
|
||||
total = dat["N_const_man_total"]
|
||||
for i in eachindex(offsets)
|
||||
if offsets[i] + n_consts[i] - 1 > total
|
||||
push!(errors, "const_offset_man[$i] + n_const_man[$i] exceeds N_const_man_total")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Print summary
|
||||
if verbose
|
||||
println("\nDATA SUMMARY:")
|
||||
# V10: Show segments if present
|
||||
if haskey(dat, "S")
|
||||
println(" Segments (S): $(dat["S"])")
|
||||
println(" Parties with segments (J): $(get(dat, "J", "N/A"))")
|
||||
else
|
||||
println(" Parties (J): $(get(dat, "J", "N/A"))")
|
||||
end
|
||||
println(" Countries (P): $(get(dat, "P", "N/A"))")
|
||||
println(" Segment-years (R): $(get(dat, "R", "N/A"))")
|
||||
println(" Years (T_year): $(get(dat, "T_year", "N/A"))")
|
||||
println(" Manifesto obs: $(get(dat, "N_man", "N/A"))")
|
||||
println(" Expert dim obs: $(get(dat, "N_exp_dim", "N/A"))")
|
||||
println(" Expert L-R obs: $(get(dat, "N_exp_lr", "N/A"))")
|
||||
|
||||
if haskey(dat, "mn_resp_log_man")
|
||||
println("\nPRIOR MEANS:")
|
||||
println(" Manifesto: $(round(dat["mn_resp_log_man"], digits=3))")
|
||||
println(" Expert dim: $(round(dat["mn_resp_log_exp_dim"], digits=3))")
|
||||
println(" Expert L-R: $(round(dat["mn_resp_log_exp_lr"], digits=3))")
|
||||
end
|
||||
end
|
||||
|
||||
# Report results
|
||||
if !isempty(errors)
|
||||
println("\n❌ VALIDATION FAILED - $(length(errors)) ERROR(S):")
|
||||
for (i, err) in enumerate(errors)
|
||||
println(" $i. $err")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if !isempty(warnings)
|
||||
println("\n⚠️ $(length(warnings)) WARNING(S):")
|
||||
for (i, warn) in enumerate(warnings)
|
||||
println(" $i. $warn")
|
||||
end
|
||||
end
|
||||
|
||||
if verbose
|
||||
println("\n✓ DATA VALIDATION PASSED")
|
||||
println("="^70)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
"""
|
||||
Validate initialization values before passing to Stan.
|
||||
Checks for common issues that cause immediate Stan crashes.
|
||||
"""
|
||||
function validate_init_values(init_dict::Dict; verbose=true)
|
||||
verbose && println("\n" * "="^70)
|
||||
verbose && println("VALIDATING INITIALIZATION VALUES")
|
||||
verbose && println("="^70)
|
||||
|
||||
errors = String[]
|
||||
warnings = String[]
|
||||
|
||||
for (key, value) in init_dict
|
||||
# Check for NaN/Inf
|
||||
if isa(value, AbstractArray) && eltype(value) <: Number
|
||||
if any(isnan, value)
|
||||
push!(errors, "Init '$key' contains NaN")
|
||||
end
|
||||
if any(isinf, value)
|
||||
push!(errors, "Init '$key' contains Inf")
|
||||
end
|
||||
|
||||
if verbose && length(value) > 0
|
||||
val_array = vec(value)
|
||||
println(" $key: range [$(round(minimum(val_array), digits=3)), $(round(maximum(val_array), digits=3))]")
|
||||
end
|
||||
elseif isa(value, Number)
|
||||
if isnan(value)
|
||||
push!(errors, "Init '$key' is NaN")
|
||||
end
|
||||
if isinf(value)
|
||||
push!(errors, "Init '$key' is Inf")
|
||||
end
|
||||
|
||||
if verbose
|
||||
println(" $key: $(round(value, digits=3))")
|
||||
end
|
||||
end
|
||||
|
||||
# Check positive constraints (common Stan constraints)
|
||||
# Exception: *_raw parameters are non-centered and can be any real
|
||||
if (contains(string(key), "sigma") || contains(string(key), "tau") || contains(string(key), "phi")) &&
|
||||
!endswith(string(key), "_raw")
|
||||
if isa(value, Number) && value <= 0
|
||||
push!(errors, "Init '$key' = $value violates constraint > 0")
|
||||
elseif isa(value, AbstractArray) && any(value .<= 0)
|
||||
push!(errors, "Init '$key' has values ≤ 0 (violates constraint > 0)")
|
||||
end
|
||||
end
|
||||
|
||||
# Check Cholesky factors are valid
|
||||
if contains(string(key), "L_Omega")
|
||||
if isa(value, AbstractMatrix)
|
||||
# Check it's lower triangular with positive diagonal
|
||||
n = size(value, 1)
|
||||
if size(value, 2) != n
|
||||
push!(errors, "Init '$key' is not square")
|
||||
end
|
||||
for i in 1:n
|
||||
if value[i, i] <= 0
|
||||
push!(errors, "Init '$key' has non-positive diagonal at position $i")
|
||||
end
|
||||
for j in (i+1):n
|
||||
if abs(value[i, j]) > 1e-10
|
||||
push!(warnings, "Init '$key' is not lower triangular")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check slope parameters for positive constraint (V2/V3 feature)
|
||||
if key == "Gamma_man_slope_raw"
|
||||
if isa(value, AbstractArray) && any(value .< 0)
|
||||
push!(errors, "Init 'Gamma_man_slope_raw' has negative values (must be ≥ 0)")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Report results
|
||||
if !isempty(errors)
|
||||
println("\n❌ INIT VALIDATION FAILED - $(length(errors)) ERROR(S):")
|
||||
for (i, err) in enumerate(errors)
|
||||
println(" $i. $err")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if !isempty(warnings)
|
||||
println("\n⚠️ $(length(warnings)) WARNING(S):")
|
||||
for (i, warn) in enumerate(warnings)
|
||||
println(" $i. $warn")
|
||||
end
|
||||
end
|
||||
|
||||
if verbose
|
||||
println("\n✓ INIT VALIDATION PASSED")
|
||||
println("="^70)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
"""
|
||||
Estimate memory requirements for model
|
||||
"""
|
||||
function estimate_memory_requirements(dat::Dict; verbose=true, num_chains::Int=4, num_samples::Int=1000, num_threads_per_chain::Int=1)
|
||||
if !verbose
|
||||
return
|
||||
end
|
||||
|
||||
println("\n" * "="^70)
|
||||
println("MEMORY ESTIMATE")
|
||||
println("="^70)
|
||||
|
||||
R = get(dat, "R", 0)
|
||||
J = get(dat, "J", 0)
|
||||
K_man = get(dat, "K_man", 0)
|
||||
K_exp_dim = get(dat, "K_exp_dim", 0)
|
||||
K_exp_lr = get(dat, "K_exp_lr", 0)
|
||||
N_man = get(dat, "N_man", 0)
|
||||
N_ciy = get(dat, "N_ciy", 0)
|
||||
T_year = get(dat, "T_year", 0)
|
||||
|
||||
# Rough parameter count
|
||||
theta_params = 4 * R
|
||||
item_params = 4 * K_man * 2 + K_exp_dim * 3 + K_exp_lr * 2
|
||||
strategic_params = get(dat, "P", 0) * get(dat, "K_man", 0) # Country-item intercepts
|
||||
other_params = 4 * J + T_year + J + N_ciy + 50
|
||||
|
||||
total_params = theta_params + item_params + strategic_params + other_params
|
||||
|
||||
# Memory estimate (very rough)
|
||||
# Each parameter: ~8 bytes (float64) × samples × chains
|
||||
total_draws_per_param = num_samples * num_chains
|
||||
bytes_per_param = 8 * total_draws_per_param
|
||||
total_mb = (total_params * bytes_per_param) / (1024 * 1024)
|
||||
|
||||
println(" Configuration:")
|
||||
println(" Chains: $num_chains")
|
||||
println(" Samples per chain: $num_samples")
|
||||
println(" Threads per chain: $num_threads_per_chain")
|
||||
println(" Total parallel workers: $(num_chains * num_threads_per_chain)")
|
||||
|
||||
println(" Total parameters: ~$(total_params)")
|
||||
println(" Estimated memory (samples only): ~$(round(total_mb, digits=0)) MB")
|
||||
thread_scaling = max(1, num_threads_per_chain)
|
||||
println(" With thread overhead (×$(thread_scaling)): ~$(round(total_mb * thread_scaling, digits=0)) MB")
|
||||
println(" With safety margin (×3): ~$(round(3 * total_mb * thread_scaling, digits=0)) MB")
|
||||
|
||||
if total_mb * thread_scaling * 3 > 8000
|
||||
println("\n⚠️ WARNING: Model may require > 8GB RAM")
|
||||
end
|
||||
|
||||
println("="^70)
|
||||
end
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## 02_data_loading.jl
|
||||
## Load and preprocess data for latent trait model
|
||||
## Loads three datasets: text_data (manifesto + PolDem), expert dimension-specific, expert general L-R
|
||||
##
|
||||
## Supports both:
|
||||
## - 4D model (V10): type_high/type_low columns for bipolar bridges
|
||||
## - 2D model (V1): dim_idx + direction columns for direct estimation
|
||||
#############################################################################
|
||||
|
||||
using DataFrames, CSV, CategoricalArrays, Statistics
|
||||
|
||||
#############################################################################
|
||||
## UNION MAPPING: Individual party estimates via mean-constituent model
|
||||
## Loads data/union_mapping.csv and builds lookup structures
|
||||
#############################################################################
|
||||
|
||||
"""
|
||||
load_union_mapping(project_root::String)
|
||||
|
||||
Load union_mapping.csv and build lookup dictionaries.
|
||||
Returns (union_to_constituents, constituent_to_union) dicts.
|
||||
If file is missing or empty, returns empty dicts (backwards compatible).
|
||||
"""
|
||||
function load_union_mapping(project_root::String=".")
|
||||
mapping_file = joinpath(project_root, "data", "union_mapping.csv")
|
||||
|
||||
union_to_constituents = Dict{Int, Vector{Int}}()
|
||||
constituent_to_union = Dict{Int, Int}()
|
||||
|
||||
if !isfile(mapping_file)
|
||||
println(" No union_mapping.csv found - running without union decomposition")
|
||||
return union_to_constituents, constituent_to_union
|
||||
end
|
||||
|
||||
df = CSV.read(mapping_file, DataFrame)
|
||||
if nrow(df) == 0
|
||||
println(" union_mapping.csv is empty - running without union decomposition")
|
||||
return union_to_constituents, constituent_to_union
|
||||
end
|
||||
|
||||
for row in eachrow(df)
|
||||
union_id = row.manifesto_pf_id
|
||||
expert_id = row.expert_pf_id
|
||||
|
||||
if !haskey(union_to_constituents, union_id)
|
||||
union_to_constituents[union_id] = Int[]
|
||||
end
|
||||
if !(expert_id in union_to_constituents[union_id])
|
||||
push!(union_to_constituents[union_id], expert_id)
|
||||
end
|
||||
constituent_to_union[expert_id] = union_id
|
||||
end
|
||||
|
||||
println(" Union mapping loaded: $(length(union_to_constituents)) unions, $(length(constituent_to_union)) constituents")
|
||||
return union_to_constituents, constituent_to_union
|
||||
end
|
||||
|
||||
#############################################################################
|
||||
## SEGMENT-BASED INDEXING CONFIGURATION
|
||||
## Split parties at gaps > MAX_GAP years to avoid flat posteriors
|
||||
#############################################################################
|
||||
const MAX_GAP = 7 # Maximum years between observations within a segment
|
||||
const MIN_OBS = 2 # Minimum observations per segment (drop segments with fewer)
|
||||
|
||||
#############################################################################
|
||||
## 2D MODEL MAPPING CONFIGURATION
|
||||
## Maps type_high/type_low pairs to dim_idx + direction
|
||||
#############################################################################
|
||||
const TYPE_TO_DIM_DIRECTION = Dict(
|
||||
# Economic dimension: pro_market = right (+1), pro_welfare = left (-1)
|
||||
("pro_market", "pro_welfare") => (dim_idx=1, direction=1), # Right
|
||||
("pro_welfare", "pro_market") => (dim_idx=1, direction=-1), # Left
|
||||
|
||||
# Cultural dimension: traditional = TAN (+1), cosmopolitan = GAL (-1)
|
||||
("traditional", "cosmopolitan") => (dim_idx=2, direction=1), # TAN
|
||||
("cosmopolitan", "traditional") => (dim_idx=2, direction=-1) # GAL
|
||||
)
|
||||
|
||||
# Expert dimension mapping (lrecon -> economic, galtan/cultural -> galtan)
|
||||
const EXPERT_VAR_TO_DIM = Dict(
|
||||
"lrecon_ches" => 1,
|
||||
"lrecon_vparty" => 1,
|
||||
"welf_vparty" => 1,
|
||||
"lrecon_gps" => 1,
|
||||
"lrecon_poppa" => 1,
|
||||
"galtan_ches" => 2,
|
||||
"libcon_gps" => 2,
|
||||
"immig_vparty" => 2,
|
||||
"lgbt_vparty" => 2,
|
||||
"culsup_vparty" => 2,
|
||||
"relig_vparty" => 2,
|
||||
"gender_vparty" => 2
|
||||
)
|
||||
|
||||
function load_and_preprocess_4dim_data(start_year=1950; data_dir::String=".")
|
||||
println("Loading 4D latent trait data files...")
|
||||
println("Start year filter: $start_year")
|
||||
data_dir != "." && println("Data directory: $data_dir")
|
||||
|
||||
# Load union mapping (check data_dir first, fall back to project root)
|
||||
println("\nLoading union mapping...")
|
||||
union_mapping_dir = isfile(joinpath(data_dir, "data", "union_mapping.csv")) ? data_dir : "."
|
||||
union_to_constituents, constituent_to_union = load_union_mapping(union_mapping_dir)
|
||||
|
||||
# Load the three datasets
|
||||
text_data_raw = CSV.read(joinpath(data_dir, "text_data.csv"), DataFrame)
|
||||
expert_raw = CSV.read(joinpath(data_dir, "expert.csv"), DataFrame)
|
||||
lr_data_raw = CSV.read(joinpath(data_dir, "lr_data.csv"), DataFrame)
|
||||
|
||||
# Filter to start year BEFORE calculating year0
|
||||
text_data_raw = text_data_raw[text_data_raw.year .>= start_year, :]
|
||||
expert_raw = expert_raw[expert_raw.year .>= start_year, :]
|
||||
lr_data_raw = lr_data_raw[lr_data_raw.year .>= start_year, :]
|
||||
|
||||
println("Data filtered to $start_year onwards:")
|
||||
println(" Text data (manifesto + PolDem): $(nrow(text_data_raw)) observations")
|
||||
println(" Expert: $(nrow(expert_raw)) observations")
|
||||
println(" L-R data: $(nrow(lr_data_raw)) observations")
|
||||
|
||||
# Define base year for relative time indexing
|
||||
year0 = Int(minimum([minimum(text_data_raw.year), minimum(expert_raw.year), minimum(lr_data_raw.year)])) - 1
|
||||
println("Base year set to: $year0")
|
||||
|
||||
# Create type mapping for the four dimensions
|
||||
type_map = Dict(
|
||||
"pro_market" => 1,
|
||||
"pro_welfare" => 2,
|
||||
"cosmopolitan" => 3,
|
||||
"traditional" => 4
|
||||
)
|
||||
|
||||
println("Type mapping: pro_market=1, pro_welfare=2, cosmopolitan=3, traditional=4")
|
||||
|
||||
# Process text data (manifesto + PolDem media)
|
||||
text_data = copy(text_data_raw)
|
||||
text_data = text_data[text_data.year .> year0, :]
|
||||
|
||||
# Add type indices for text items (V4/V10: bipolar bridge structure)
|
||||
if !("type_high" in names(text_data)) || !("type_low" in names(text_data))
|
||||
error("Text data must contain 'type_high' and 'type_low' columns with values: pro_market, pro_welfare, cosmopolitan, traditional")
|
||||
end
|
||||
|
||||
text_data.type_high_idx = [type_map[t] for t in text_data.type_high]
|
||||
text_data.type_low_idx = [type_map[t] for t in text_data.type_low]
|
||||
|
||||
# V1 (2D model): Add dim_idx and direction columns
|
||||
# Maps type_high/type_low to single dimension + direction
|
||||
dim_idx_man = Int[]
|
||||
direction_man = Int[]
|
||||
for row in eachrow(text_data)
|
||||
key = (row.type_high, row.type_low)
|
||||
if haskey(TYPE_TO_DIM_DIRECTION, key)
|
||||
mapping = TYPE_TO_DIM_DIRECTION[key]
|
||||
push!(dim_idx_man, mapping.dim_idx)
|
||||
push!(direction_man, mapping.direction)
|
||||
else
|
||||
# Unknown mapping - this should not happen with valid data
|
||||
error("Unknown type_high/type_low pair: $(row.type_high) / $(row.type_low)")
|
||||
end
|
||||
end
|
||||
text_data.dim_idx_man = dim_idx_man
|
||||
text_data.direction_man = direction_man
|
||||
|
||||
# Standard processing
|
||||
text_data.country = categorical(text_data.country)
|
||||
text_data.party = categorical(text_data.party)
|
||||
text_data.var = categorical(text_data.var)
|
||||
text_data.Year = Int.(text_data.year) .- year0
|
||||
sort!(text_data, [:country, :party, :year, :var])
|
||||
println("Text data processed: $(nrow(text_data)) observations with bipolar bridge structure")
|
||||
|
||||
# Process expert dimension-specific data (bipolar items like lrecon_ches, galtan_ches)
|
||||
expert_dim_vars = ["lrecon_ches", "galtan_ches", "lrecon_vparty", "welf_vparty",
|
||||
"lrecon_gps", "libcon_gps", "lrecon_poppa",
|
||||
"immig_vparty", "lgbt_vparty", "culsup_vparty", "relig_vparty", "gender_vparty"]
|
||||
expert_dim = expert_raw[in.(expert_raw.var, Ref(expert_dim_vars)), :]
|
||||
expert_dim = expert_dim[(expert_dim.year .> year0) .& (expert_dim.val .>= 0) .& (expert_dim.val .<= 1), :]
|
||||
|
||||
# V5: Load integer observations, scale sizes, and expert counts for beta-binomial likelihood
|
||||
expert_dim.val_int = Int.(expert_dim.val_int)
|
||||
expert_dim.n_scale = Int.(expert_dim.n_scale)
|
||||
expert_dim.n_experts = Int.(expert_dim.n_experts)
|
||||
|
||||
# Add type mappings for dimension-specific expert data
|
||||
if !("type_low" in names(expert_dim)) || !("type_high" in names(expert_dim))
|
||||
error("Expert data must contain 'type_low' and 'type_high' columns")
|
||||
end
|
||||
|
||||
expert_dim.type_high_idx = [type_map[t] for t in expert_dim.type_high]
|
||||
expert_dim.type_low_idx = [type_map[t] for t in expert_dim.type_low]
|
||||
|
||||
# V1 (2D model): Add dim_idx for expert dimension data
|
||||
dim_idx_exp = Int[]
|
||||
for row in eachrow(expert_dim)
|
||||
var_name = string(row.var)
|
||||
if haskey(EXPERT_VAR_TO_DIM, var_name)
|
||||
push!(dim_idx_exp, EXPERT_VAR_TO_DIM[var_name])
|
||||
else
|
||||
# Fallback: infer from type_high/type_low
|
||||
key = (row.type_high, row.type_low)
|
||||
if haskey(TYPE_TO_DIM_DIRECTION, key)
|
||||
push!(dim_idx_exp, TYPE_TO_DIM_DIRECTION[key].dim_idx)
|
||||
else
|
||||
error("Unknown expert variable: $var_name with type pair $(row.type_high) / $(row.type_low)")
|
||||
end
|
||||
end
|
||||
end
|
||||
expert_dim.dim_idx_exp = dim_idx_exp
|
||||
|
||||
expert_dim.country = categorical(expert_dim.country)
|
||||
expert_dim.party = categorical(expert_dim.party)
|
||||
expert_dim.var = categorical(expert_dim.var)
|
||||
expert_dim.Year = Int.(expert_dim.year) .- year0
|
||||
sort!(expert_dim, [:country, :party, :year, :var])
|
||||
println("Expert dimension-specific data processed: $(nrow(expert_dim)) observations")
|
||||
|
||||
# Process expert general L-R data (cross-dimensional anchoring)
|
||||
lr_vars = ["lr_ches", "lr_poppa", "lr_morgan"] # General left-right items
|
||||
expert_lr = lr_data_raw[in.(lr_data_raw.var, Ref(lr_vars)), :]
|
||||
expert_lr = expert_lr[(expert_lr.year .> year0) .& (expert_lr.val .>= 0) .& (expert_lr.val .<= 1), :]
|
||||
|
||||
# V5: Load integer observations, scale sizes, and expert counts for beta-binomial likelihood
|
||||
expert_lr.val_int = Int.(expert_lr.val_int)
|
||||
expert_lr.n_scale = Int.(expert_lr.n_scale)
|
||||
expert_lr.n_experts = Int.(expert_lr.n_experts)
|
||||
|
||||
expert_lr.country = categorical(expert_lr.country)
|
||||
expert_lr.party = categorical(expert_lr.party)
|
||||
expert_lr.var = categorical(expert_lr.var)
|
||||
expert_lr.Year = Int.(expert_lr.year) .- year0
|
||||
sort!(expert_lr, [:country, :party, :year, :var])
|
||||
println("Expert general L-R data processed: $(nrow(expert_lr)) observations")
|
||||
|
||||
# Validate data integrity
|
||||
println("\nData validation:")
|
||||
|
||||
# Check text data dimension pair distribution (V4: bipolar bridges)
|
||||
type_pair_counts = combine(groupby(text_data, [:type_high, :type_low]), nrow => :count)
|
||||
for row in eachrow(type_pair_counts)
|
||||
println(" $(row.type_high) ↔ $(row.type_low): $(row.count) text data observations")
|
||||
end
|
||||
|
||||
# Check expert dimension-specific type pairs
|
||||
type_pair_counts = combine(groupby(expert_dim, [:type_high, :type_low]), nrow => :count)
|
||||
for row in eachrow(type_pair_counts)
|
||||
println(" $(row.type_high) - $(row.type_low): $(row.count) expert dimension-specific observations")
|
||||
end
|
||||
|
||||
# Check general L-R items
|
||||
lr_var_counts = combine(groupby(expert_lr, :var), nrow => :count)
|
||||
for row in eachrow(lr_var_counts)
|
||||
println(" $(row.var): $(row.count) general L-R observations")
|
||||
end
|
||||
|
||||
# Check overlapping parties across datasets
|
||||
text_data_parties = Set(text_data.party)
|
||||
expert_dim_parties = Set(expert_dim.party)
|
||||
expert_lr_parties = Set(expert_lr.party)
|
||||
|
||||
all_parties = union(text_data_parties, expert_dim_parties, expert_lr_parties)
|
||||
println("\nParty coverage:")
|
||||
println(" Total unique parties: $(length(all_parties))")
|
||||
println(" In text data: $(length(text_data_parties))")
|
||||
println(" In expert dimension-specific: $(length(expert_dim_parties))")
|
||||
println(" In expert general L-R: $(length(expert_lr_parties))")
|
||||
println(" In all three datasets: $(length(intersect(text_data_parties, expert_dim_parties, expert_lr_parties)))")
|
||||
|
||||
return text_data, expert_dim, expert_lr, year0, union_to_constituents, constituent_to_union
|
||||
end
|
||||
|
||||
# Execute if run directly
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
text_data, expert_dim, expert_lr, year0, u2c, c2u = load_and_preprocess_4dim_data()
|
||||
println("4D data loading test completed successfully")
|
||||
end
|
||||
@@ -0,0 +1,947 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## 03_data_preparation_4dim.jl
|
||||
## V10: Segment-based indexing to fix long gap interpolation issues
|
||||
##
|
||||
## Key change: Split parties at gaps > MAX_GAP years into independent segments.
|
||||
## Each segment has its own random walk (restarts at segment start).
|
||||
## Segments with < MIN_OBS observations are dropped.
|
||||
#############################################################################
|
||||
|
||||
using DataFrames, CSV, CategoricalArrays, Statistics, StatsFuns
|
||||
|
||||
# Import configuration from data loading module
|
||||
include("02_data_loading.jl")
|
||||
|
||||
"""
|
||||
split_party_years_into_segments(years::Vector{Int}, max_gap::Int)
|
||||
|
||||
Split a party's observation years into segments based on gaps.
|
||||
Returns a vector of vectors, where each inner vector contains consecutive years
|
||||
with max `max_gap` years between observations.
|
||||
"""
|
||||
function split_party_years_into_segments(years::Vector{Int}, max_gap::Int)
|
||||
if isempty(years)
|
||||
return Vector{Vector{Int}}()
|
||||
end
|
||||
|
||||
sorted_years = sort(unique(years))
|
||||
segments = [Int[sorted_years[1]]]
|
||||
|
||||
for y in sorted_years[2:end]
|
||||
if y - segments[end][end] <= max_gap
|
||||
push!(segments[end], y)
|
||||
else
|
||||
# Gap too large - start new segment
|
||||
push!(segments, [y])
|
||||
end
|
||||
end
|
||||
|
||||
return segments
|
||||
end
|
||||
|
||||
function prepare_4dim_stan_data(manifesto, expert_dim, expert_lr, year0;
|
||||
union_to_constituents=Dict{Int,Vector{Int}}(),
|
||||
constituent_to_union=Dict{Int,Int}())
|
||||
println("Preparing data for Stan model (Segment-based indexing)...")
|
||||
println(" MAX_GAP = $MAX_GAP years, MIN_OBS = $MIN_OBS observations")
|
||||
|
||||
has_unions = !isempty(union_to_constituents)
|
||||
if has_unions
|
||||
println(" Union mapping: $(length(union_to_constituents)) unions, $(length(constituent_to_union)) constituents")
|
||||
else
|
||||
println(" No union mapping - standard party indexing")
|
||||
end
|
||||
|
||||
# =========================================================================
|
||||
# STEP 1: Collect observation years per party (union-aware)
|
||||
# For union parties: create segments for each CONSTITUENT, not the union.
|
||||
# Union manifesto years are assigned to all constituents.
|
||||
# =========================================================================
|
||||
|
||||
# Identify which party IDs in data are unions vs standalone
|
||||
# NOTE: levels() returns raw types (Int64 for integer party IDs).
|
||||
# We consistently use String keys for all party lookups to avoid type mismatches.
|
||||
manifesto_party_strs = Set(string.(levels(manifesto.party)))
|
||||
union_ids_in_data = Set{String}()
|
||||
if has_unions
|
||||
for uid in keys(union_to_constituents)
|
||||
uid_str = string(uid)
|
||||
if uid_str in manifesto_party_strs
|
||||
push!(union_ids_in_data, uid_str)
|
||||
end
|
||||
end
|
||||
println(" Union party IDs found in manifesto data: $(length(union_ids_in_data))")
|
||||
end
|
||||
|
||||
# Collect all party IDs that need segments
|
||||
# For unions: constituents get segments; union itself does NOT
|
||||
# For standalone: party gets segment as before
|
||||
party_obs_years = Dict{String, Set{Int}}()
|
||||
|
||||
# First pass: collect non-union parties from all data sources (as strings)
|
||||
all_data_parties = Set{String}()
|
||||
for p in levels(manifesto.party)
|
||||
push!(all_data_parties, string(p))
|
||||
end
|
||||
for p in levels(expert_dim.party)
|
||||
push!(all_data_parties, string(p))
|
||||
end
|
||||
for p in levels(expert_lr.party)
|
||||
push!(all_data_parties, string(p))
|
||||
end
|
||||
|
||||
# Initialize observation years for standalone parties and constituents
|
||||
for p in all_data_parties
|
||||
p_int = tryparse(Int, string(p))
|
||||
if p_int !== nothing && has_unions && haskey(union_to_constituents, p_int) && string(p) in union_ids_in_data
|
||||
# This is a union ID in manifesto - skip it, create entries for constituents instead
|
||||
continue
|
||||
end
|
||||
party_obs_years[p] = Set{Int}()
|
||||
end
|
||||
|
||||
# For unions: ensure all constituents have entries
|
||||
if has_unions
|
||||
for (uid, constituents) in union_to_constituents
|
||||
uid_str = string(uid)
|
||||
if uid_str in union_ids_in_data
|
||||
for cid in constituents
|
||||
cid_str = string(cid)
|
||||
if !haskey(party_obs_years, cid_str)
|
||||
party_obs_years[cid_str] = Set{Int}()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add years from manifesto
|
||||
for row in eachrow(manifesto)
|
||||
p_str = string(row.party)
|
||||
p_int = tryparse(Int, p_str)
|
||||
if p_int !== nothing && has_unions && haskey(union_to_constituents, p_int) && p_str in union_ids_in_data
|
||||
# Union manifesto: add year to ALL constituents
|
||||
for cid in union_to_constituents[p_int]
|
||||
cid_str = string(cid)
|
||||
if haskey(party_obs_years, cid_str)
|
||||
push!(party_obs_years[cid_str], row.Year)
|
||||
end
|
||||
end
|
||||
else
|
||||
if haskey(party_obs_years, p_str)
|
||||
push!(party_obs_years[p_str], row.Year)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add years from expert_dim (individual party data - direct)
|
||||
for row in eachrow(expert_dim)
|
||||
p_str = string(row.party)
|
||||
if haskey(party_obs_years, p_str)
|
||||
push!(party_obs_years[p_str], row.Year)
|
||||
end
|
||||
end
|
||||
|
||||
# Add years from expert_lr (individual party data - direct)
|
||||
for row in eachrow(expert_lr)
|
||||
p_str = string(row.party)
|
||||
if haskey(party_obs_years, p_str)
|
||||
push!(party_obs_years[p_str], row.Year)
|
||||
end
|
||||
end
|
||||
|
||||
J_original = length(party_obs_years)
|
||||
println("Total number of parties to create segments for: $J_original")
|
||||
|
||||
# =========================================================================
|
||||
# STEP 2: Split parties into segments and filter by MIN_OBS
|
||||
# =========================================================================
|
||||
println("\nCreating segments (splitting at gaps > $MAX_GAP years)...")
|
||||
|
||||
segment_data = []
|
||||
segment_id = 0
|
||||
parties_split = 0
|
||||
segments_dropped = 0
|
||||
observations_dropped = 0
|
||||
|
||||
for (p, years_set) in party_obs_years
|
||||
years = collect(years_set)
|
||||
if isempty(years)
|
||||
continue
|
||||
end
|
||||
|
||||
segments = split_party_years_into_segments(years, MAX_GAP)
|
||||
|
||||
if length(segments) > 1
|
||||
parties_split += 1
|
||||
end
|
||||
|
||||
for (seg_num, seg_years) in enumerate(segments)
|
||||
n_obs = length(seg_years)
|
||||
if n_obs >= MIN_OBS
|
||||
segment_id += 1
|
||||
push!(segment_data, (
|
||||
segment_id = segment_id,
|
||||
party_id = p,
|
||||
segment_num = seg_num,
|
||||
year_start = minimum(seg_years),
|
||||
year_end = maximum(seg_years),
|
||||
n_obs = n_obs
|
||||
))
|
||||
else
|
||||
segments_dropped += 1
|
||||
observations_dropped += n_obs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
segment_info = DataFrame(segment_data)
|
||||
S = nrow(segment_info) # Number of valid segments
|
||||
|
||||
println(" Segments created: $S (from $J_original parties)")
|
||||
println(" Parties split into multiple segments: $parties_split")
|
||||
println(" Segments dropped (< $MIN_OBS obs): $segments_dropped")
|
||||
println(" Observations dropped: $observations_dropped")
|
||||
|
||||
# Get unique parties that have at least one valid segment
|
||||
all_parties = unique(segment_info.party_id)
|
||||
J = length(all_parties)
|
||||
println(" Parties with valid segments: $J")
|
||||
|
||||
# Create party-to-index mapping for the valid parties
|
||||
party_to_index = Dict(all_parties .=> 1:J)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 3: Create segment-year index space (R) - consecutive within segment
|
||||
# =========================================================================
|
||||
println("\nCreating segment-year index space...")
|
||||
|
||||
segment_year_data = []
|
||||
for row in eachrow(segment_info)
|
||||
for y in row.year_start:row.year_end
|
||||
push!(segment_year_data, (
|
||||
segment_id = row.segment_id,
|
||||
party_id = row.party_id,
|
||||
Year = y
|
||||
))
|
||||
end
|
||||
end
|
||||
|
||||
segment_year = DataFrame(segment_year_data)
|
||||
segment_year.rr = 1:nrow(segment_year)
|
||||
R = nrow(segment_year)
|
||||
println("Total segment-year positions (R): $R")
|
||||
|
||||
# Compute len_theta_ts for segments (years per segment)
|
||||
len_theta_ts = [row.year_end - row.year_start + 1 for row in eachrow(segment_info)]
|
||||
@assert sum(len_theta_ts) == R "sum(len_theta_ts)=$(sum(len_theta_ts)) must equal R=$R"
|
||||
|
||||
# =========================================================================
|
||||
# STEP 4: Map observations to segment-year indices (union-aware)
|
||||
# =========================================================================
|
||||
println("\nMapping observations to segment-year indices...")
|
||||
|
||||
# Create lookup: (party_str, year) -> segment_id (for valid segments only)
|
||||
party_year_to_segment = Dict{Tuple{String, Int}, Int}()
|
||||
for row in eachrow(segment_info)
|
||||
for y in row.year_start:row.year_end
|
||||
party_year_to_segment[(string(row.party_id), y)] = row.segment_id
|
||||
end
|
||||
end
|
||||
|
||||
# --- MANIFESTO: union-aware mapping ---
|
||||
# For union manifesto obs: map to first constituent's segment (for ss_man).
|
||||
# The actual theta averaging is handled via constituent arrays.
|
||||
manifesto_segment_ids = Union{Int, Missing}[]
|
||||
for row in eachrow(manifesto)
|
||||
p_str = string(row.party)
|
||||
p_int = tryparse(Int, p_str)
|
||||
key = (p_str, row.Year)
|
||||
|
||||
if haskey(party_year_to_segment, key)
|
||||
# Direct mapping (non-union or constituent with own segment)
|
||||
push!(manifesto_segment_ids, party_year_to_segment[key])
|
||||
elseif p_int !== nothing && has_unions && haskey(union_to_constituents, p_int)
|
||||
# Union party: use first constituent's segment
|
||||
found = false
|
||||
for cid in union_to_constituents[p_int]
|
||||
ckey = (string(cid), row.Year)
|
||||
if haskey(party_year_to_segment, ckey)
|
||||
push!(manifesto_segment_ids, party_year_to_segment[ckey])
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if !found
|
||||
push!(manifesto_segment_ids, missing)
|
||||
end
|
||||
else
|
||||
push!(manifesto_segment_ids, missing)
|
||||
end
|
||||
end
|
||||
manifesto.segment_id = manifesto_segment_ids
|
||||
n_manifesto_before = nrow(manifesto)
|
||||
manifesto = manifesto[.!ismissing.(manifesto.segment_id), :]
|
||||
manifesto.segment_id = Int.(manifesto.segment_id)
|
||||
println(" Manifesto: $(nrow(manifesto))/$n_manifesto_before observations (dropped $(n_manifesto_before - nrow(manifesto)) in invalid segments)")
|
||||
|
||||
# --- EXPERT DIM: direct mapping (individual party data) ---
|
||||
expert_dim_segment_ids = Union{Int, Missing}[]
|
||||
for row in eachrow(expert_dim)
|
||||
key = (string(row.party), row.Year)
|
||||
if haskey(party_year_to_segment, key)
|
||||
push!(expert_dim_segment_ids, party_year_to_segment[key])
|
||||
else
|
||||
push!(expert_dim_segment_ids, missing)
|
||||
end
|
||||
end
|
||||
expert_dim.segment_id = expert_dim_segment_ids
|
||||
n_expert_dim_before = nrow(expert_dim)
|
||||
expert_dim = expert_dim[.!ismissing.(expert_dim.segment_id), :]
|
||||
expert_dim.segment_id = Int.(expert_dim.segment_id)
|
||||
println(" Expert dim: $(nrow(expert_dim))/$n_expert_dim_before observations (dropped $(n_expert_dim_before - nrow(expert_dim)) in invalid segments)")
|
||||
|
||||
# --- EXPERT LR: direct mapping (individual party data) ---
|
||||
expert_lr_segment_ids = Union{Int, Missing}[]
|
||||
for row in eachrow(expert_lr)
|
||||
key = (string(row.party), row.Year)
|
||||
if haskey(party_year_to_segment, key)
|
||||
push!(expert_lr_segment_ids, party_year_to_segment[key])
|
||||
else
|
||||
push!(expert_lr_segment_ids, missing)
|
||||
end
|
||||
end
|
||||
expert_lr.segment_id = expert_lr_segment_ids
|
||||
n_expert_lr_before = nrow(expert_lr)
|
||||
expert_lr = expert_lr[.!ismissing.(expert_lr.segment_id), :]
|
||||
expert_lr.segment_id = Int.(expert_lr.segment_id)
|
||||
println(" Expert LR: $(nrow(expert_lr))/$n_expert_lr_before observations (dropped $(n_expert_lr_before - nrow(expert_lr)) in invalid segments)")
|
||||
|
||||
# =========================================================================
|
||||
# STEP 5: Create segment indices (ss) for each observation
|
||||
# ss indexes into 1:S (segment space), used for party-level parameters
|
||||
# =========================================================================
|
||||
|
||||
# Create segment_id to ss mapping
|
||||
segment_to_ss = Dict(row.segment_id => i for (i, row) in enumerate(eachrow(segment_info)))
|
||||
|
||||
manifesto.ss_man = [segment_to_ss[sid] for sid in manifesto.segment_id]
|
||||
expert_dim.ss_exp_dim = [segment_to_ss[sid] for sid in expert_dim.segment_id]
|
||||
expert_lr.ss_exp_lr = [segment_to_ss[sid] for sid in expert_lr.segment_id]
|
||||
|
||||
# Validate segment indices
|
||||
@assert all(1 .<= manifesto.ss_man .<= S)
|
||||
@assert all(1 .<= expert_dim.ss_exp_dim .<= S)
|
||||
@assert all(1 .<= expert_lr.ss_exp_lr .<= S)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 6: Map rr indices (segment-year) to datasets via leftjoin
|
||||
# =========================================================================
|
||||
|
||||
# Create (segment_id, Year) -> rr lookup
|
||||
seg_year_to_rr = Dict{Tuple{Int, Int}, Int}()
|
||||
for row in eachrow(segment_year)
|
||||
seg_year_to_rr[(row.segment_id, row.Year)] = row.rr
|
||||
end
|
||||
|
||||
# Join manifesto with segment_year to get rr indices
|
||||
manifesto = leftjoin(manifesto, segment_year, on=[:segment_id, :Year])
|
||||
rename!(manifesto, :rr => :rr_man)
|
||||
|
||||
expert_dim = leftjoin(expert_dim, segment_year, on=[:segment_id, :Year])
|
||||
rename!(expert_dim, :rr => :rr_exp_dim)
|
||||
|
||||
expert_lr = leftjoin(expert_lr, segment_year, on=[:segment_id, :Year])
|
||||
rename!(expert_lr, :rr => :rr_exp_lr)
|
||||
|
||||
# Validate rr mappings
|
||||
@assert all(!ismissing, manifesto.rr_man) "Some manifesto observations have no rr_man mapping"
|
||||
@assert all(!ismissing, expert_dim.rr_exp_dim) "Some expert_dim observations have no rr_exp_dim mapping"
|
||||
@assert all(!ismissing, expert_lr.rr_exp_lr) "Some expert_lr observations have no rr_exp_lr mapping"
|
||||
|
||||
# Convert to Int
|
||||
manifesto.rr_man = Int.(manifesto.rr_man)
|
||||
expert_dim.rr_exp_dim = Int.(expert_dim.rr_exp_dim)
|
||||
expert_lr.rr_exp_lr = Int.(expert_lr.rr_exp_lr)
|
||||
|
||||
# Validate bounds
|
||||
@assert all(1 .<= manifesto.rr_man .<= R) "rr_man out of bounds"
|
||||
@assert all(1 .<= expert_dim.rr_exp_dim .<= R) "rr_exp_dim out of bounds"
|
||||
@assert all(1 .<= expert_lr.rr_exp_lr .<= R) "rr_exp_lr out of bounds"
|
||||
|
||||
# Print diagnostics
|
||||
n_observed = length(unique(vcat(manifesto.rr_man, expert_dim.rr_exp_dim, expert_lr.rr_exp_lr)))
|
||||
println("\nSegment-years with data: $n_observed / $R ($(round(100*n_observed/R, digits=1))%)")
|
||||
println("Segment-years for interpolation: $(R - n_observed)")
|
||||
|
||||
# =========================================================================
|
||||
# STEP 6b: Build constituent arrays for union manifesto/expert observations
|
||||
# For each manifesto obs: store list of constituent rr indices for averaging
|
||||
# Non-union obs: single rr (n_const=1)
|
||||
# Union obs: multiple rr values (n_const=len(constituents))
|
||||
# =========================================================================
|
||||
println("\nBuilding constituent arrays for mean-constituent model...")
|
||||
|
||||
# --- Manifesto constituent arrays ---
|
||||
n_const_man_vec = Int[] # n_const for each manifesto obs
|
||||
const_rr_man_vec = Int[] # flat array of constituent rr values
|
||||
const_offset_man_vec = Int[] # offset into const_rr for each obs
|
||||
|
||||
offset = 1
|
||||
for row in eachrow(manifesto)
|
||||
p_str = string(row.party)
|
||||
p_int = tryparse(Int, p_str)
|
||||
|
||||
if p_int !== nothing && has_unions && haskey(union_to_constituents, p_int) && p_str in union_ids_in_data
|
||||
# Union manifesto obs: find rr for each constituent in this year
|
||||
constituent_rrs = Int[]
|
||||
for cid in union_to_constituents[p_int]
|
||||
ckey = (string(cid), row.Year)
|
||||
if haskey(party_year_to_segment, ckey)
|
||||
sid = party_year_to_segment[ckey]
|
||||
rr_key = (sid, row.Year)
|
||||
if haskey(seg_year_to_rr, rr_key)
|
||||
push!(constituent_rrs, seg_year_to_rr[rr_key])
|
||||
end
|
||||
end
|
||||
end
|
||||
if isempty(constituent_rrs)
|
||||
# Fallback: use the rr_man already assigned
|
||||
push!(constituent_rrs, row.rr_man)
|
||||
end
|
||||
push!(n_const_man_vec, length(constituent_rrs))
|
||||
push!(const_offset_man_vec, offset)
|
||||
append!(const_rr_man_vec, constituent_rrs)
|
||||
offset += length(constituent_rrs)
|
||||
else
|
||||
# Non-union: single constituent (itself)
|
||||
push!(n_const_man_vec, 1)
|
||||
push!(const_offset_man_vec, offset)
|
||||
push!(const_rr_man_vec, row.rr_man)
|
||||
offset += 1
|
||||
end
|
||||
end
|
||||
|
||||
N_const_man_total = length(const_rr_man_vec)
|
||||
n_union_man = count(x -> x > 1, n_const_man_vec)
|
||||
println(" Manifesto: $(nrow(manifesto)) obs, $n_union_man union obs, $N_const_man_total total constituent entries")
|
||||
|
||||
# --- Expert dim constituent arrays ---
|
||||
# Individual expert obs always have n_const=1 (direct mapping)
|
||||
# Union-level expert obs (if any) would average — but typically expert data is at individual party level
|
||||
n_const_exp_dim_vec = Int[]
|
||||
const_rr_exp_dim_vec = Int[]
|
||||
const_offset_exp_dim_vec = Int[]
|
||||
|
||||
offset = 1
|
||||
for row in eachrow(expert_dim)
|
||||
p_str = string(row.party)
|
||||
p_int = tryparse(Int, p_str)
|
||||
|
||||
if p_int !== nothing && has_unions && haskey(union_to_constituents, p_int) && p_str in union_ids_in_data
|
||||
# Union-level expert obs: average over constituents
|
||||
constituent_rrs = Int[]
|
||||
for cid in union_to_constituents[p_int]
|
||||
ckey = (string(cid), row.Year)
|
||||
if haskey(party_year_to_segment, ckey)
|
||||
sid = party_year_to_segment[ckey]
|
||||
rr_key = (sid, row.Year)
|
||||
if haskey(seg_year_to_rr, rr_key)
|
||||
push!(constituent_rrs, seg_year_to_rr[rr_key])
|
||||
end
|
||||
end
|
||||
end
|
||||
if isempty(constituent_rrs)
|
||||
push!(constituent_rrs, row.rr_exp_dim)
|
||||
end
|
||||
push!(n_const_exp_dim_vec, length(constituent_rrs))
|
||||
push!(const_offset_exp_dim_vec, offset)
|
||||
append!(const_rr_exp_dim_vec, constituent_rrs)
|
||||
offset += length(constituent_rrs)
|
||||
else
|
||||
# Individual party obs
|
||||
push!(n_const_exp_dim_vec, 1)
|
||||
push!(const_offset_exp_dim_vec, offset)
|
||||
push!(const_rr_exp_dim_vec, row.rr_exp_dim)
|
||||
offset += 1
|
||||
end
|
||||
end
|
||||
|
||||
N_const_exp_dim_total = length(const_rr_exp_dim_vec)
|
||||
n_union_exp_dim = count(x -> x > 1, n_const_exp_dim_vec)
|
||||
println(" Expert dim: $(nrow(expert_dim)) obs, $n_union_exp_dim union obs, $N_const_exp_dim_total total constituent entries")
|
||||
|
||||
# --- Expert LR constituent arrays ---
|
||||
n_const_exp_lr_vec = Int[]
|
||||
const_rr_exp_lr_vec = Int[]
|
||||
const_offset_exp_lr_vec = Int[]
|
||||
|
||||
offset = 1
|
||||
for row in eachrow(expert_lr)
|
||||
p_str = string(row.party)
|
||||
p_int = tryparse(Int, p_str)
|
||||
|
||||
if p_int !== nothing && has_unions && haskey(union_to_constituents, p_int) && p_str in union_ids_in_data
|
||||
constituent_rrs = Int[]
|
||||
for cid in union_to_constituents[p_int]
|
||||
ckey = (string(cid), row.Year)
|
||||
if haskey(party_year_to_segment, ckey)
|
||||
sid = party_year_to_segment[ckey]
|
||||
rr_key = (sid, row.Year)
|
||||
if haskey(seg_year_to_rr, rr_key)
|
||||
push!(constituent_rrs, seg_year_to_rr[rr_key])
|
||||
end
|
||||
end
|
||||
end
|
||||
if isempty(constituent_rrs)
|
||||
push!(constituent_rrs, row.rr_exp_lr)
|
||||
end
|
||||
push!(n_const_exp_lr_vec, length(constituent_rrs))
|
||||
push!(const_offset_exp_lr_vec, offset)
|
||||
append!(const_rr_exp_lr_vec, constituent_rrs)
|
||||
offset += length(constituent_rrs)
|
||||
else
|
||||
push!(n_const_exp_lr_vec, 1)
|
||||
push!(const_offset_exp_lr_vec, offset)
|
||||
push!(const_rr_exp_lr_vec, row.rr_exp_lr)
|
||||
offset += 1
|
||||
end
|
||||
end
|
||||
|
||||
N_const_exp_lr_total = length(const_rr_exp_lr_vec)
|
||||
n_union_exp_lr = count(x -> x > 1, n_const_exp_lr_vec)
|
||||
println(" Expert LR: $(nrow(expert_lr)) obs, $n_union_exp_lr union obs, $N_const_exp_lr_total total constituent entries")
|
||||
|
||||
# =========================================================================
|
||||
# STEP 7: Filter manifesto items with sufficient observations
|
||||
# =========================================================================
|
||||
var_man_counts_df = combine(groupby(manifesto, :var), :var => length => :n_obs)
|
||||
var_man_counts_df = var_man_counts_df[var_man_counts_df.n_obs .>= 2, :]
|
||||
var_man_counts = var_man_counts_df.var
|
||||
|
||||
var_exp_dim_counts_df = combine(groupby(expert_dim, :var), :var => length => :n_obs)
|
||||
var_exp_dim_counts_df = var_exp_dim_counts_df[var_exp_dim_counts_df.n_obs .>= 2, :]
|
||||
var_exp_dim_counts = var_exp_dim_counts_df.var
|
||||
|
||||
var_exp_lr_counts_df = combine(groupby(expert_lr, :var), :var => length => :n_obs)
|
||||
var_exp_lr_counts_df = var_exp_lr_counts_df[var_exp_lr_counts_df.n_obs .>= 2, :]
|
||||
var_exp_lr_counts = var_exp_lr_counts_df.var
|
||||
|
||||
manifesto = manifesto[in.(manifesto.var, Ref(var_man_counts)), :]
|
||||
manifesto.var_man = levelcode.(categorical(manifesto.var, levels=unique(var_man_counts)))
|
||||
|
||||
expert_dim = expert_dim[in.(expert_dim.var, Ref(var_exp_dim_counts)), :]
|
||||
expert_dim.var_exp_dim = levelcode.(categorical(expert_dim.var, levels=unique(var_exp_dim_counts)))
|
||||
|
||||
expert_lr = expert_lr[in.(expert_lr.var, Ref(var_exp_lr_counts)), :]
|
||||
expert_lr.var_exp_lr = levelcode.(categorical(expert_lr.var, levels=unique(var_exp_lr_counts)))
|
||||
|
||||
# =========================================================================
|
||||
# STEP 8: Country (group) indexing
|
||||
# =========================================================================
|
||||
all_groups = unique(vcat(levels(manifesto.country), levels(expert_dim.country), levels(expert_lr.country)))
|
||||
P = length(all_groups)
|
||||
println("Total number of unique countries (P): $P")
|
||||
|
||||
group_to_index = Dict(all_groups .=> 1:P)
|
||||
manifesto.pp_man = [group_to_index[c] for c in manifesto.country]
|
||||
expert_dim.pp_exp_dim = [group_to_index[c] for c in expert_dim.country]
|
||||
expert_lr.pp_exp_lr = [group_to_index[c] for c in expert_lr.country]
|
||||
|
||||
@assert all(1 .<= manifesto.pp_man .<= P)
|
||||
@assert all(1 .<= expert_dim.pp_exp_dim .<= P)
|
||||
@assert all(1 .<= expert_lr.pp_exp_lr .<= P)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 9: Country-item-year combinations for zero-inflation model
|
||||
# =========================================================================
|
||||
manifesto.ciy_key = string.(manifesto.country, "_", manifesto.var_man, "_", manifesto.Year)
|
||||
ciy_keys = unique(manifesto.ciy_key)
|
||||
N_ciy = length(ciy_keys)
|
||||
println("Total unique country-item-year combinations (N_ciy): $N_ciy")
|
||||
|
||||
ciy_key_to_index = Dict(ciy_keys .=> 1:N_ciy)
|
||||
manifesto.ciy_idx = [ciy_key_to_index[k] for k in manifesto.ciy_key]
|
||||
@assert all(1 .<= manifesto.ciy_idx .<= N_ciy)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 10: Segment-country mapping (each segment inherits from party)
|
||||
# For constituents: inherit country from their union's manifesto data
|
||||
# =========================================================================
|
||||
party_country_dict = Dict{String, Int}()
|
||||
|
||||
# From data directly
|
||||
for row in eachrow(manifesto)
|
||||
p_str = string(row.party)
|
||||
p_int = tryparse(Int, p_str)
|
||||
c_idx = group_to_index[string(row.country)]
|
||||
party_country_dict[p_str] = c_idx
|
||||
|
||||
# If union, also assign country to all constituents
|
||||
if p_int !== nothing && has_unions && haskey(union_to_constituents, p_int)
|
||||
for cid in union_to_constituents[p_int]
|
||||
party_country_dict[string(cid)] = c_idx
|
||||
end
|
||||
end
|
||||
end
|
||||
for row in eachrow(expert_dim)
|
||||
party_country_dict[string(row.party)] = group_to_index[string(row.country)]
|
||||
end
|
||||
for row in eachrow(expert_lr)
|
||||
party_country_dict[string(row.party)] = group_to_index[string(row.country)]
|
||||
end
|
||||
|
||||
# Map each segment to its party's country
|
||||
segment_country_idx = Int[]
|
||||
for row in eachrow(segment_info)
|
||||
pid = string(row.party_id)
|
||||
if haskey(party_country_dict, pid)
|
||||
push!(segment_country_idx, party_country_dict[pid])
|
||||
else
|
||||
# Fallback: try to find via union mapping
|
||||
pf_int = tryparse(Int, pid)
|
||||
if pf_int !== nothing && haskey(constituent_to_union, pf_int)
|
||||
uid = constituent_to_union[pf_int]
|
||||
if haskey(party_country_dict, string(uid))
|
||||
push!(segment_country_idx, party_country_dict[string(uid)])
|
||||
else
|
||||
error("Cannot find country for constituent $pid (union $uid)")
|
||||
end
|
||||
else
|
||||
error("Cannot find country for party $pid")
|
||||
end
|
||||
end
|
||||
end
|
||||
@assert all(1 .<= segment_country_idx .<= P)
|
||||
@assert length(segment_country_idx) == S
|
||||
|
||||
# Persist canonical country on segment mapping tables
|
||||
segment_country = [all_groups[idx] for idx in segment_country_idx]
|
||||
segment_info.country = segment_country
|
||||
|
||||
segment_country_by_id = Dict(row.segment_id => segment_country[i] for (i, row) in enumerate(eachrow(segment_info)))
|
||||
segment_year.country = [segment_country_by_id[sid] for sid in segment_year.segment_id]
|
||||
|
||||
# =========================================================================
|
||||
# STEP 11: Load party family data and map to segments
|
||||
# =========================================================================
|
||||
println("\nLoading party family data...")
|
||||
party_families_file = joinpath("data", "party_families.csv")
|
||||
if !isfile(party_families_file)
|
||||
error("party_families.csv not found at $party_families_file")
|
||||
end
|
||||
party_families_df = CSV.read(party_families_file, DataFrame)
|
||||
|
||||
pf_to_family = Dict(row.partyfacts_id => row.family for row in eachrow(party_families_df))
|
||||
|
||||
all_family_names = unique(party_families_df.family)
|
||||
family_to_idx = Dict(f => i for (i, f) in enumerate(all_family_names))
|
||||
F = length(all_family_names)
|
||||
println("Total number of party families (F): $F")
|
||||
println("Family categories: ", join(all_family_names, ", "))
|
||||
|
||||
# Map each segment to its party's family
|
||||
# For constituents: try own family first, fall back to union's family
|
||||
segment_family_idx = Int[]
|
||||
unmatched_segments = String[]
|
||||
default_family_idx = haskey(family_to_idx, "other") ? family_to_idx["other"] : 1
|
||||
|
||||
for row in eachrow(segment_info)
|
||||
pf_id = tryparse(Int, string(row.party_id))
|
||||
if !isnothing(pf_id) && haskey(pf_to_family, pf_id)
|
||||
family_name = pf_to_family[pf_id]
|
||||
push!(segment_family_idx, family_to_idx[family_name])
|
||||
elseif !isnothing(pf_id) && haskey(constituent_to_union, pf_id) && haskey(pf_to_family, constituent_to_union[pf_id])
|
||||
# Fall back to union's family
|
||||
family_name = pf_to_family[constituent_to_union[pf_id]]
|
||||
push!(segment_family_idx, family_to_idx[family_name])
|
||||
else
|
||||
push!(segment_family_idx, default_family_idx)
|
||||
push!(unmatched_segments, "$(row.party_id)_seg$(row.segment_num)")
|
||||
end
|
||||
end
|
||||
|
||||
if !isempty(unmatched_segments)
|
||||
println(" Warning: $(length(unmatched_segments)) segments not matched to families (assigned to 'other')")
|
||||
if length(unmatched_segments) <= 10
|
||||
println(" Unmatched: ", join(unmatched_segments, ", "))
|
||||
end
|
||||
end
|
||||
|
||||
@assert all(1 .<= segment_family_idx .<= F)
|
||||
@assert length(segment_family_idx) == S
|
||||
|
||||
# =========================================================================
|
||||
# STEP 12: Find anchor segment
|
||||
# With unions: use CDU (1375) as anchor (individual constituent)
|
||||
# Without unions: use CDU/CSU (211) as anchor (union ID)
|
||||
# =========================================================================
|
||||
anchor_party_id = has_unions ? 1375 : 211
|
||||
anchor_label = has_unions ? "CDU" : "CDU/CSU"
|
||||
anchor_segments = filter(row -> tryparse(Int, string(row.party_id)) == anchor_party_id, segment_info)
|
||||
|
||||
if nrow(anchor_segments) > 0
|
||||
# Pick segment with most observations
|
||||
anchor_segment_idx = anchor_segments[argmax(anchor_segments.n_obs), :segment_id]
|
||||
# Convert to ss index (1:S)
|
||||
anchor_segment_ss = segment_to_ss[anchor_segment_idx]
|
||||
println(" Anchor segment ($anchor_label, ID $anchor_party_id): segment $anchor_segment_ss ($(anchor_segments[argmax(anchor_segments.n_obs), :n_obs]) obs)")
|
||||
else
|
||||
println(" Warning: Anchor party $anchor_label (ID $anchor_party_id) not found, using segment 1")
|
||||
anchor_segment_ss = 1
|
||||
end
|
||||
|
||||
# =========================================================================
|
||||
# STEP 13: Validation - check no long gaps remain within segments
|
||||
# =========================================================================
|
||||
println("\nValidating segment structure...")
|
||||
rr_to_segment_year = Dict(row.rr => (segment_id=row.segment_id, year=row.Year) for row in eachrow(segment_year))
|
||||
seg_obs_years = Dict(row.segment_id => Set{Int}() for row in eachrow(segment_info))
|
||||
|
||||
for row in eachrow(manifesto)
|
||||
push!(seg_obs_years[row.segment_id], row.Year)
|
||||
end
|
||||
for row in eachrow(expert_dim)
|
||||
push!(seg_obs_years[row.segment_id], row.Year)
|
||||
end
|
||||
for row in eachrow(expert_lr)
|
||||
push!(seg_obs_years[row.segment_id], row.Year)
|
||||
end
|
||||
|
||||
# Union manifesto rows contribute to every constituent through const_rr_man_vec,
|
||||
# even though row.segment_id stores only a representative segment for indexing.
|
||||
# Include these constituent rr values so validation matches the actual Stan data.
|
||||
for rr in const_rr_man_vec
|
||||
if haskey(rr_to_segment_year, rr)
|
||||
sy = rr_to_segment_year[rr]
|
||||
push!(seg_obs_years[sy.segment_id], sy.year)
|
||||
end
|
||||
end
|
||||
|
||||
max_internal_gap = 0
|
||||
for (i, row) in enumerate(eachrow(segment_info))
|
||||
seg_obs = collect(seg_obs_years[row.segment_id])
|
||||
if length(seg_obs) > 1
|
||||
gaps = diff(sort(unique(seg_obs)))
|
||||
if !isempty(gaps)
|
||||
max_gap_in_seg = maximum(gaps)
|
||||
max_internal_gap = max(max_internal_gap, max_gap_in_seg)
|
||||
if max_gap_in_seg > MAX_GAP
|
||||
@warn "Segment $i (party $(row.party_id)) has internal gap of $max_gap_in_seg years!"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
println(" Maximum internal gap within segments: $max_internal_gap years (limit: $MAX_GAP)")
|
||||
|
||||
# Validate all segments have >= MIN_OBS
|
||||
for row in eachrow(segment_info)
|
||||
@assert row.n_obs >= MIN_OBS "Segment $(row.segment_id) has only $(row.n_obs) obs (min: $MIN_OBS)"
|
||||
end
|
||||
println(" All segments have >= $MIN_OBS observations: PASS")
|
||||
|
||||
println("\nSegment-based indexing created successfully")
|
||||
println(" S (segments): $S")
|
||||
println(" R (segment-years): $R")
|
||||
println(" J (parties with valid segments): $J")
|
||||
|
||||
return (manifesto=manifesto, expert_dim=expert_dim, expert_lr=expert_lr,
|
||||
segment_year=segment_year, segment_info=segment_info,
|
||||
all_parties=all_parties, all_groups=all_groups,
|
||||
S=S, J=J, P=P, R=R, N_ciy=N_ciy, len_theta_ts=len_theta_ts,
|
||||
segment_country_idx=segment_country_idx, group_to_index=group_to_index,
|
||||
F=F, segment_family_idx=segment_family_idx, anchor_segment_idx=anchor_segment_ss,
|
||||
# Constituent arrays for mean-constituent model
|
||||
N_const_man_total=N_const_man_total,
|
||||
n_const_man=n_const_man_vec,
|
||||
const_offset_man=const_offset_man_vec,
|
||||
const_rr_man=const_rr_man_vec,
|
||||
N_const_exp_dim_total=N_const_exp_dim_total,
|
||||
n_const_exp_dim=n_const_exp_dim_vec,
|
||||
const_offset_exp_dim=const_offset_exp_dim_vec,
|
||||
const_rr_exp_dim=const_rr_exp_dim_vec,
|
||||
N_const_exp_lr_total=N_const_exp_lr_total,
|
||||
n_const_exp_lr=n_const_exp_lr_vec,
|
||||
const_offset_exp_lr=const_offset_exp_lr_vec,
|
||||
const_rr_exp_lr=const_rr_exp_lr_vec,
|
||||
union_to_constituents=union_to_constituents,
|
||||
constituent_to_union=constituent_to_union)
|
||||
end
|
||||
|
||||
function finalize_4dim_stan_data(manifesto, expert_dim, expert_lr, segment_year, segment_info,
|
||||
all_parties, all_groups, group_to_index, year0, S, J, P, R, N_ciy,
|
||||
len_theta_ts, segment_country_idx, F, segment_family_idx, anchor_segment_idx;
|
||||
N_const_man_total=0, n_const_man=Int[], const_offset_man=Int[], const_rr_man=Int[],
|
||||
N_const_exp_dim_total=0, n_const_exp_dim=Int[], const_offset_exp_dim=Int[], const_rr_exp_dim=Int[],
|
||||
N_const_exp_lr_total=0, n_const_exp_lr=Int[], const_offset_exp_lr=Int[], const_rr_exp_lr=Int[])
|
||||
println("Finalizing 4D Stan data structure (V10: segment-based)...")
|
||||
|
||||
# Map years for temporal indexing
|
||||
years_all = sort(unique(vcat(manifesto.Year, expert_dim.Year, expert_lr.Year)))
|
||||
T_year = length(years_all)
|
||||
year_map = DataFrame(year_rel=years_all, year_ix=1:T_year)
|
||||
|
||||
# Apply year mapping to all datasets
|
||||
manifesto = leftjoin(manifesto, year_map, on=[:Year => :year_rel])
|
||||
rename!(manifesto, :year_ix => :year_for_man)
|
||||
|
||||
expert_dim = leftjoin(expert_dim, year_map, on=[:Year => :year_rel])
|
||||
rename!(expert_dim, :year_ix => :year_for_exp_dim)
|
||||
|
||||
expert_lr = leftjoin(expert_lr, year_map, on=[:Year => :year_rel])
|
||||
rename!(expert_lr, :year_ix => :year_for_exp_lr)
|
||||
|
||||
# Validate year assignments
|
||||
@assert all(.!ismissing.(manifesto.year_for_man))
|
||||
@assert all(.!ismissing.(expert_dim.year_for_exp_dim))
|
||||
@assert all(.!ismissing.(expert_lr.year_for_exp_lr))
|
||||
@assert all(1 .<= manifesto.year_for_man .<= T_year)
|
||||
@assert all(1 .<= expert_dim.year_for_exp_dim .<= T_year)
|
||||
@assert all(1 .<= expert_lr.year_for_exp_lr .<= T_year)
|
||||
|
||||
# Add small epsilon to prevent exact zeros and ones
|
||||
epsilon = 1e-6
|
||||
expert_dim.val = clamp.(expert_dim.val, epsilon, 1.0 - epsilon)
|
||||
expert_lr.val = clamp.(expert_lr.val, epsilon, 1.0 - epsilon)
|
||||
|
||||
# Calculate prior means
|
||||
man_positive_sample = manifesto.positive[manifesto.sample .> 0] ./ manifesto.sample[manifesto.sample .> 0]
|
||||
mn_resp_log_man = StatsFuns.logit(mean(man_positive_sample))
|
||||
mn_resp_log_exp_dim = StatsFuns.logit(mean(expert_dim.val))
|
||||
mn_resp_log_exp_lr = StatsFuns.logit(mean(expert_lr.val))
|
||||
|
||||
println("Prior means calculated:")
|
||||
println(" Manifesto: $(round(mn_resp_log_man, digits=3))")
|
||||
println(" Expert dimension-specific: $(round(mn_resp_log_exp_dim, digits=3))")
|
||||
println(" Expert general L-R: $(round(mn_resp_log_exp_lr, digits=3))")
|
||||
|
||||
# V6: Decade indexing for hierarchical L-R weights
|
||||
expert_lr_actual_years = expert_lr.Year .+ year0
|
||||
expert_lr_decade_raw = div.(expert_lr_actual_years, 10)
|
||||
all_lr_decades = sort(unique(expert_lr_decade_raw))
|
||||
lr_decade_to_index = Dict(all_lr_decades .=> 1:length(all_lr_decades))
|
||||
dd_exp_lr = [lr_decade_to_index[d] for d in expert_lr_decade_raw]
|
||||
D_lr = length(all_lr_decades)
|
||||
println(" Decade indexing (V6): $D_lr decades, range $(minimum(all_lr_decades)*10)s-$(maximum(all_lr_decades)*10)s")
|
||||
|
||||
# Create Stan data dictionary - V10 uses S (segments) instead of J (parties)
|
||||
dat_4dim = Dict(
|
||||
# Common data - V10: S = number of segments
|
||||
"S" => S, # NEW: Number of segments (was J)
|
||||
"J" => J, # Keep J for reference (parties with valid segments)
|
||||
"P" => P,
|
||||
"R" => R,
|
||||
"T_year" => T_year,
|
||||
"len_theta_ts" => Int.(len_theta_ts),
|
||||
|
||||
# Segment-country mapping (V10: segments inherit country from party)
|
||||
"segment_country" => segment_country_idx,
|
||||
|
||||
# Segment family data (V10: segments inherit family from party)
|
||||
"F" => F,
|
||||
"segment_family" => segment_family_idx,
|
||||
|
||||
# Anchor segment for identification (CDU/CSU segment)
|
||||
"anchor_segment" => anchor_segment_idx,
|
||||
|
||||
# Manifesto data - use ss_man (segment index) instead of jj_man (party index)
|
||||
"N_man" => nrow(manifesto),
|
||||
"K_man" => length(unique(manifesto.var_man)),
|
||||
"kk_man" => manifesto.var_man,
|
||||
"ss_man" => manifesto.ss_man, # V10: segment index (was jj_man)
|
||||
"rr_man" => manifesto.rr_man,
|
||||
"pp_man" => manifesto.pp_man,
|
||||
"positive" => manifesto.positive,
|
||||
"sample" => manifesto.sample,
|
||||
"year_for_man" => manifesto.year_for_man,
|
||||
"type_high_idx_man" => manifesto.type_high_idx,
|
||||
"type_low_idx_man" => manifesto.type_low_idx,
|
||||
|
||||
# V1 (2D model): dimension index and direction for text data
|
||||
"dim_idx_man" => manifesto.dim_idx_man,
|
||||
"direction_man" => manifesto.direction_man,
|
||||
|
||||
# Country-item-year data for zero-inflation
|
||||
"N_ciy" => N_ciy,
|
||||
"ciy_idx" => manifesto.ciy_idx,
|
||||
|
||||
# Expert dimension-specific data
|
||||
"N_exp_dim" => nrow(expert_dim),
|
||||
"K_exp_dim" => length(unique(expert_dim.var_exp_dim)),
|
||||
"kk_exp_dim" => expert_dim.var_exp_dim,
|
||||
"ss_exp_dim" => expert_dim.ss_exp_dim, # V10: segment index
|
||||
"rr_exp_dim" => expert_dim.rr_exp_dim,
|
||||
"pp_exp_dim" => expert_dim.pp_exp_dim,
|
||||
# V5 K-scaling: use rounded sum (mean × K × n_scale) and total trials (K × n_scale)
|
||||
"val_dim_int" => Int.(clamp.(round.(expert_dim.val .* expert_dim.n_scale .* expert_dim.n_experts), 0, expert_dim.n_scale .* expert_dim.n_experts)),
|
||||
"n_total_exp_dim" => expert_dim.n_scale .* expert_dim.n_experts,
|
||||
"n_experts_exp_dim" => expert_dim.n_experts,
|
||||
"type_high_idx" => expert_dim.type_high_idx,
|
||||
"type_low_idx" => expert_dim.type_low_idx,
|
||||
|
||||
# V1 (2D model): dimension index for expert dimension data
|
||||
"dim_idx_exp" => expert_dim.dim_idx_exp,
|
||||
|
||||
# Expert general L-R data
|
||||
"N_exp_lr" => nrow(expert_lr),
|
||||
"K_exp_lr" => length(unique(expert_lr.var_exp_lr)),
|
||||
"kk_exp_lr" => expert_lr.var_exp_lr,
|
||||
"ss_exp_lr" => expert_lr.ss_exp_lr, # V10: segment index
|
||||
"rr_exp_lr" => expert_lr.rr_exp_lr,
|
||||
"pp_exp_lr" => expert_lr.pp_exp_lr,
|
||||
# V5 K-scaling: use rounded sum (mean × K × n_scale) and total trials (K × n_scale)
|
||||
"val_lr_int" => Int.(clamp.(round.(expert_lr.val .* expert_lr.n_scale .* expert_lr.n_experts), 0, expert_lr.n_scale .* expert_lr.n_experts)),
|
||||
"n_total_exp_lr" => expert_lr.n_scale .* expert_lr.n_experts,
|
||||
"n_experts_exp_lr" => expert_lr.n_experts,
|
||||
|
||||
# V6: Decade indexing for hierarchical L-R weights
|
||||
"D_lr" => D_lr,
|
||||
"dd_exp_lr" => dd_exp_lr,
|
||||
|
||||
# Prior information
|
||||
"mn_resp_log_man" => mn_resp_log_man,
|
||||
"mn_resp_log_exp_dim" => mn_resp_log_exp_dim,
|
||||
"mn_resp_log_exp_lr" => mn_resp_log_exp_lr,
|
||||
|
||||
# Constituent arrays for mean-constituent model (V4)
|
||||
"N_const_man_total" => max(1, N_const_man_total),
|
||||
"n_const_man" => isempty(n_const_man) ? ones(Int, nrow(manifesto)) : n_const_man,
|
||||
"const_offset_man" => isempty(const_offset_man) ? collect(1:nrow(manifesto)) : const_offset_man,
|
||||
"const_rr_man" => isempty(const_rr_man) ? manifesto.rr_man : const_rr_man,
|
||||
|
||||
"N_const_exp_dim_total" => max(1, N_const_exp_dim_total),
|
||||
"n_const_exp_dim" => isempty(n_const_exp_dim) ? ones(Int, nrow(expert_dim)) : n_const_exp_dim,
|
||||
"const_offset_exp_dim" => isempty(const_offset_exp_dim) ? collect(1:nrow(expert_dim)) : const_offset_exp_dim,
|
||||
"const_rr_exp_dim" => isempty(const_rr_exp_dim) ? expert_dim.rr_exp_dim : const_rr_exp_dim,
|
||||
|
||||
"N_const_exp_lr_total" => max(1, N_const_exp_lr_total),
|
||||
"n_const_exp_lr" => isempty(n_const_exp_lr) ? ones(Int, nrow(expert_lr)) : n_const_exp_lr,
|
||||
"const_offset_exp_lr" => isempty(const_offset_exp_lr) ? collect(1:nrow(expert_lr)) : const_offset_exp_lr,
|
||||
"const_rr_exp_lr" => isempty(const_rr_exp_lr) ? expert_lr.rr_exp_lr : const_rr_exp_lr
|
||||
)
|
||||
|
||||
println("4D Stan data dictionary created with $(length(dat_4dim)) elements")
|
||||
|
||||
# Print summary statistics
|
||||
println("\nData summary (V10: Segment-based):")
|
||||
println(" Segments: $(dat_4dim["S"])")
|
||||
println(" Parties with valid segments: $(dat_4dim["J"])")
|
||||
println(" Segment-year combinations: $(dat_4dim["R"])")
|
||||
println(" Manifesto observations: $(dat_4dim["N_man"])")
|
||||
println(" Expert dimension-specific observations: $(dat_4dim["N_exp_dim"])")
|
||||
println(" Expert general L-R observations: $(dat_4dim["N_exp_lr"])")
|
||||
println(" Unique manifesto items: $(dat_4dim["K_man"])")
|
||||
println(" Unique expert dimension-specific items: $(dat_4dim["K_exp_dim"])")
|
||||
println(" Unique expert general L-R items: $(dat_4dim["K_exp_lr"])")
|
||||
println(" Years: $(dat_4dim["T_year"])")
|
||||
|
||||
return (dat_4dim=dat_4dim, manifesto=manifesto, expert_dim=expert_dim,
|
||||
expert_lr=expert_lr, T_year=T_year, segment_year=segment_year,
|
||||
segment_info=segment_info)
|
||||
end
|
||||
|
||||
# Execute if run directly
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
println("Run from main script to execute the full 4D pipeline")
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## 05_results_processing.jl
|
||||
## Extract and process 4D model results with diagnostics
|
||||
## Adapted from old_project for latent traits only (no election effects)
|
||||
#############################################################################
|
||||
|
||||
using StanSample, DataFrames, Statistics
|
||||
|
||||
function extract_model_results_4dim(stanmodel)
|
||||
"""
|
||||
Extract model results for 4D latent trait model
|
||||
Simplified version - no election effects (pure latent traits)
|
||||
"""
|
||||
println("Extracting 4D model results...")
|
||||
|
||||
try
|
||||
println("Model completed successfully - extracting results")
|
||||
|
||||
# For 4D latent trait model, we save the full stanmodel object
|
||||
# Post-estimation will extract specific parameters later
|
||||
|
||||
return (
|
||||
samples = stanmodel, # Save the full stanmodel with all MCMC samples
|
||||
extraction_status = "success"
|
||||
)
|
||||
|
||||
catch e
|
||||
println("Error in result extraction: $e")
|
||||
return (
|
||||
samples = "error",
|
||||
extraction_status = "error",
|
||||
error_message = string(e)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
function compute_model_diagnostics(stanmodel_result)
|
||||
"""
|
||||
Compute convergence diagnostics from Stan model
|
||||
Returns R-hat, ESS statistics, and overall convergence assessment
|
||||
|
||||
stanmodel_result can be either:
|
||||
- A SampleModel object directly
|
||||
- A named tuple from run_4dim_stan_model containing .stanmodel
|
||||
"""
|
||||
println("Computing model diagnostics...")
|
||||
|
||||
try
|
||||
# Handle both direct SampleModel and named tuple from run_4dim_stan_model
|
||||
stanmodel = if hasproperty(stanmodel_result, :stanmodel)
|
||||
stanmodel_result.stanmodel
|
||||
else
|
||||
stanmodel_result
|
||||
end
|
||||
|
||||
# Get REAL diagnostics using StanSample.jl
|
||||
diagnostics_summary = read_summary(stanmodel)
|
||||
|
||||
# Extract real Rhat and ESS values. Stan summary column names differ
|
||||
# across CmdStan/StanSample versions, so resolve aliases explicitly.
|
||||
summary_names = names(diagnostics_summary)
|
||||
rhat_col = if "r_hat" in summary_names
|
||||
"r_hat"
|
||||
elseif "R_hat" in summary_names
|
||||
"R_hat"
|
||||
elseif "RHat" in summary_names
|
||||
"RHat"
|
||||
else
|
||||
error("No R-hat column found in summary. Columns: $(join(summary_names, ", "))")
|
||||
end
|
||||
ess_col = if "ess_bulk" in summary_names
|
||||
"ess_bulk"
|
||||
elseif "ess" in summary_names
|
||||
"ess"
|
||||
elseif "ESS_bulk" in summary_names
|
||||
"ESS_bulk"
|
||||
elseif "n_eff" in summary_names
|
||||
"n_eff"
|
||||
else
|
||||
error("No ESS column found in summary. Columns: $(join(summary_names, ", "))")
|
||||
end
|
||||
|
||||
rhat_vals = diagnostics_summary[!, rhat_col]
|
||||
ess_bulk_vals = diagnostics_summary[!, ess_col]
|
||||
|
||||
# Compute real statistics (handle NaN values properly)
|
||||
# Use isfinite to exclude both missing and NaN values
|
||||
valid_rhat = filter(isfinite, rhat_vals)
|
||||
valid_ess = filter(isfinite, ess_bulk_vals)
|
||||
|
||||
mean_rhat = length(valid_rhat) > 0 ? mean(valid_rhat) : NaN
|
||||
max_rhat = length(valid_rhat) > 0 ? maximum(valid_rhat) : NaN
|
||||
mean_ess = length(valid_ess) > 0 ? mean(valid_ess) : NaN
|
||||
min_ess = length(valid_ess) > 0 ? minimum(valid_ess) : NaN
|
||||
|
||||
# Count problematic parameters (use isfinite for consistent counting)
|
||||
high_rhat_count = count(x -> isfinite(x) && x > 1.1, rhat_vals)
|
||||
moderate_rhat_count = count(x -> isfinite(x) && x > 1.05, rhat_vals)
|
||||
low_ess_count = count(x -> isfinite(x) && x < 400, ess_bulk_vals)
|
||||
very_low_ess_count = count(x -> isfinite(x) && x < 100, ess_bulk_vals)
|
||||
|
||||
# Total parameter count
|
||||
total_params = length(valid_rhat)
|
||||
|
||||
# Overall assessment (handle NaN values)
|
||||
if isnan(max_rhat) || total_params == 0
|
||||
convergence_status = "insufficient_data"
|
||||
else
|
||||
excellent_convergence = max_rhat < 1.05 && high_rhat_count == 0 && very_low_ess_count == 0
|
||||
good_convergence = max_rhat < 1.1 && high_rhat_count < 5 && very_low_ess_count < total_params * 0.1
|
||||
acceptable_convergence = max_rhat < 1.2 && high_rhat_count < total_params * 0.1
|
||||
|
||||
if excellent_convergence
|
||||
convergence_status = "excellent"
|
||||
elseif good_convergence
|
||||
convergence_status = "good"
|
||||
elseif acceptable_convergence
|
||||
convergence_status = "acceptable"
|
||||
else
|
||||
convergence_status = "poor"
|
||||
end
|
||||
end
|
||||
|
||||
println("\nDiagnostics computed:")
|
||||
println(" Total parameters: $total_params")
|
||||
println(" Mean R-hat: $(round(mean_rhat, digits=4))")
|
||||
println(" Max R-hat: $(round(max_rhat, digits=4))")
|
||||
println(" High R-hat count (>1.1): $high_rhat_count")
|
||||
println(" Mean ESS: $(round(mean_ess, digits=0))")
|
||||
println(" Min ESS: $(round(min_ess, digits=0))")
|
||||
println(" Very low ESS count (<100): $very_low_ess_count")
|
||||
println(" Convergence status: $convergence_status")
|
||||
|
||||
return (
|
||||
diagnostics_summary = diagnostics_summary,
|
||||
convergence_status = convergence_status,
|
||||
mean_rhat = mean_rhat,
|
||||
max_rhat = max_rhat,
|
||||
mean_ess = mean_ess,
|
||||
min_ess = min_ess,
|
||||
high_rhat_count = high_rhat_count,
|
||||
moderate_rhat_count = moderate_rhat_count,
|
||||
low_ess_count = low_ess_count,
|
||||
very_low_ess_count = very_low_ess_count,
|
||||
total_params = total_params
|
||||
)
|
||||
|
||||
catch e
|
||||
println("Error in diagnostics computation: $e")
|
||||
println("Stack trace:")
|
||||
showerror(stdout, e, catch_backtrace())
|
||||
|
||||
return (
|
||||
diagnostics_summary = "error",
|
||||
convergence_status = "error",
|
||||
mean_rhat = 999.0,
|
||||
max_rhat = 999.0,
|
||||
mean_ess = 0.0,
|
||||
min_ess = 0.0,
|
||||
high_rhat_count = 999,
|
||||
moderate_rhat_count = 999,
|
||||
low_ess_count = 999,
|
||||
very_low_ess_count = 999,
|
||||
total_params = 0,
|
||||
error_message = string(e)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Execute if run directly
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
println("Run from main run_model.jl to execute the full pipeline")
|
||||
end
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## 06_save_model.jl
|
||||
## CSV-First Save Architecture
|
||||
## Robust, portable, no serialization issues
|
||||
#############################################################################
|
||||
|
||||
module RobustSave
|
||||
|
||||
using Dates, Printf, CSV, DataFrames, JSON
|
||||
|
||||
export robust_save_model_csv, robust_save_model
|
||||
|
||||
"""
|
||||
CSV-First Save Architecture
|
||||
|
||||
When chains_already_saved=true (BULLETPROOF MODE):
|
||||
- Chains already saved to run_dir/chains/ by model execution
|
||||
- Just verify chains and add metadata/data files
|
||||
- Even if this function crashes, chains are SAFE
|
||||
|
||||
When chains_already_saved=false (legacy mode):
|
||||
- Copy chains from temp directory to new run directory
|
||||
- Add metadata/data files
|
||||
|
||||
Saves model results as:
|
||||
1. CSV chain files (source of truth - never fail)
|
||||
2. Data CSVs (original inputs for reproducibility)
|
||||
3. Simple metadata.json (no complex types)
|
||||
4. Human-readable README.txt
|
||||
|
||||
No JLD2, no serialization issues, fully portable and reproducible.
|
||||
"""
|
||||
function robust_save_model_csv(
|
||||
run_dir_or_temp::String,
|
||||
data_dict::Dict,
|
||||
original_data::Dict,
|
||||
metadata::Dict;
|
||||
chains_already_saved::Bool=false
|
||||
)
|
||||
println("\n" * "=" ^ 70)
|
||||
if chains_already_saved
|
||||
println("ADDING METADATA TO EXISTING RUN (chains already secured)")
|
||||
else
|
||||
println("CSV-FIRST MODEL SAVE")
|
||||
end
|
||||
println("=" ^ 70)
|
||||
|
||||
# Determine directories based on mode
|
||||
if chains_already_saved
|
||||
# Chains already saved - run_dir_or_temp IS the run directory
|
||||
run_dir = run_dir_or_temp
|
||||
chains_dir = joinpath(run_dir, "chains")
|
||||
data_dir = joinpath(run_dir, "data")
|
||||
run_id = basename(run_dir)
|
||||
timestamp = replace(run_id, "run_" => "")
|
||||
|
||||
println("Run directory: $run_dir")
|
||||
println("Mode: Chains already secured, adding metadata")
|
||||
|
||||
# Verify chains directory exists
|
||||
if !isdir(chains_dir)
|
||||
error("CRITICAL: Chains directory not found: $chains_dir")
|
||||
end
|
||||
|
||||
# Count existing chain files
|
||||
chain_files = filter(f -> endswith(f, ".csv") && contains(f, "chain"), readdir(chains_dir))
|
||||
if isempty(chain_files)
|
||||
error("CRITICAL: No chain CSV files found in $chains_dir")
|
||||
end
|
||||
|
||||
println("Found $(length(chain_files)) chain files already saved")
|
||||
|
||||
# Calculate total size
|
||||
total_size_gb = 0.0
|
||||
for chain_file in chain_files
|
||||
chain_path = joinpath(chains_dir, chain_file)
|
||||
total_size_gb += filesize(chain_path) / (1024^3)
|
||||
end
|
||||
|
||||
println("✓ Chains verified ($(round(total_size_gb, digits=2)) GB total)")
|
||||
|
||||
else
|
||||
# Legacy mode - create new run directory and copy chains
|
||||
temp_csv_dir = run_dir_or_temp
|
||||
|
||||
timestamp = Dates.format(Dates.now(), "yyyy-mm-dd_HH-MM-SS")
|
||||
run_id = "run_$(timestamp)"
|
||||
run_dir = joinpath("outputs", "model_outputs", "latest", run_id)
|
||||
chains_dir = joinpath(run_dir, "chains")
|
||||
data_dir = joinpath(run_dir, "data")
|
||||
|
||||
println("Run ID: $run_id")
|
||||
println("Output directory: $run_dir")
|
||||
|
||||
# Create directory structure
|
||||
mkpath(chains_dir)
|
||||
|
||||
# STEP 1: Copy CSV chain files
|
||||
println("\n" * "=" ^ 70)
|
||||
println("STEP 1: Copying MCMC chain CSV files")
|
||||
println("=" ^ 70)
|
||||
println("Source: $temp_csv_dir")
|
||||
println("Destination: $chains_dir")
|
||||
|
||||
csv_files = filter(f -> endswith(f, ".csv"), readdir(temp_csv_dir))
|
||||
chain_files = filter(f -> contains(f, "chain"), csv_files)
|
||||
|
||||
if isempty(chain_files)
|
||||
error("No chain CSV files found in $temp_csv_dir")
|
||||
end
|
||||
|
||||
println("Found $(length(chain_files)) chain files")
|
||||
|
||||
total_size_gb = 0.0
|
||||
for (i, csv_file) in enumerate(sort(chain_files))
|
||||
src_path = joinpath(temp_csv_dir, csv_file)
|
||||
|
||||
# Rename to standard format: chain_1.csv, chain_2.csv, etc.
|
||||
dest_filename = "chain_$i.csv"
|
||||
dest_path = joinpath(chains_dir, dest_filename)
|
||||
|
||||
src_size = filesize(src_path)
|
||||
size_gb = src_size / (1024^3)
|
||||
total_size_gb += size_gb
|
||||
|
||||
println(" Copying $csv_file → $dest_filename ($(round(size_gb, digits=2)) GB)")
|
||||
cp(src_path, dest_path, force=true)
|
||||
|
||||
# Verify copy with size check
|
||||
dest_size = filesize(dest_path)
|
||||
if dest_size != src_size
|
||||
error("CRITICAL: Size mismatch for $dest_filename! Source: $src_size, Dest: $dest_size")
|
||||
end
|
||||
end
|
||||
|
||||
println("✓ All chains copied and verified ($(round(total_size_gb, digits=2)) GB total)")
|
||||
end
|
||||
|
||||
# Create data directory
|
||||
mkpath(data_dir)
|
||||
|
||||
# STEP 2: Save data CSVs
|
||||
println("\n" * "=" ^ 70)
|
||||
println("STEP 2: Saving original data CSVs")
|
||||
println("=" ^ 70)
|
||||
|
||||
for (name, df) in original_data
|
||||
if isa(df, DataFrame)
|
||||
csv_path = joinpath(data_dir, "$(name).csv")
|
||||
println(" Saving $(name).csv ($(nrow(df)) rows)")
|
||||
CSV.write(csv_path, df)
|
||||
end
|
||||
end
|
||||
|
||||
println("✓ Data CSVs saved")
|
||||
|
||||
# STEP 3: Save Stan data dictionary as JSON
|
||||
println("\n" * "=" ^ 70)
|
||||
println("STEP 3: Saving Stan data dictionary")
|
||||
println("=" ^ 70)
|
||||
|
||||
# Convert data_dict to JSON-serializable format
|
||||
stan_data_json = Dict{String, Any}()
|
||||
for (k, v) in data_dict
|
||||
try
|
||||
# Only save simple types (numbers, arrays of numbers)
|
||||
if isa(v, Number) || isa(v, AbstractArray{<:Number})
|
||||
stan_data_json[k] = v
|
||||
elseif isa(v, AbstractArray)
|
||||
# Try to convert, skip if fails
|
||||
try
|
||||
stan_data_json[k] = collect(v)
|
||||
catch
|
||||
println(" Skipping $k (complex type)")
|
||||
end
|
||||
end
|
||||
catch e
|
||||
println(" Warning: Could not serialize $k: $e")
|
||||
end
|
||||
end
|
||||
|
||||
stan_data_path = joinpath(data_dir, "stan_data.json")
|
||||
open(stan_data_path, "w") do f
|
||||
JSON.print(f, stan_data_json, 2)
|
||||
end
|
||||
println("✓ Stan data saved to stan_data.json")
|
||||
|
||||
# Count chain files for metadata
|
||||
chain_files_final = filter(f -> endswith(f, ".csv") && contains(f, "chain"), readdir(chains_dir))
|
||||
num_chains = length(chain_files_final)
|
||||
|
||||
# Recalculate total_size_gb if in chains_already_saved mode
|
||||
if chains_already_saved
|
||||
total_size_gb = 0.0
|
||||
for chain_file in chain_files_final
|
||||
chain_path = joinpath(chains_dir, chain_file)
|
||||
total_size_gb += filesize(chain_path) / (1024^3)
|
||||
end
|
||||
end
|
||||
|
||||
# STEP 4: Save metadata
|
||||
println("\n" * "=" ^ 70)
|
||||
println("STEP 4: Saving metadata")
|
||||
println("=" ^ 70)
|
||||
|
||||
# Add run info to metadata
|
||||
metadata["run_id"] = run_id
|
||||
metadata["timestamp"] = timestamp
|
||||
metadata["files"] = Dict(
|
||||
"chains" => ["chains/chain_$i.csv" for i in 1:num_chains],
|
||||
"data" => readdir(data_dir),
|
||||
"chain_size_gb" => num_chains > 0 ? round(total_size_gb / num_chains, digits=2) : 0.0,
|
||||
"total_size_gb" => round(total_size_gb, digits=2)
|
||||
)
|
||||
|
||||
metadata_path = joinpath(run_dir, "metadata.json")
|
||||
open(metadata_path, "w") do f
|
||||
JSON.print(f, metadata, 2)
|
||||
end
|
||||
println("✓ Metadata saved to metadata.json")
|
||||
|
||||
# STEP 5: Generate README
|
||||
println("\n" * "=" ^ 70)
|
||||
println("STEP 5: Generating README")
|
||||
println("=" ^ 70)
|
||||
|
||||
readme_path = joinpath(run_dir, "README.txt")
|
||||
generate_readme(readme_path, run_id, metadata, num_chains, total_size_gb)
|
||||
println("✓ README generated")
|
||||
|
||||
# STEP 6: Final verification
|
||||
println("\n" * "=" ^ 70)
|
||||
println("STEP 6: Verification")
|
||||
println("=" ^ 70)
|
||||
|
||||
# Verify all chain files exist and are readable
|
||||
all_good = true
|
||||
verified_files = Dict{String, Dict{String, Any}}()
|
||||
|
||||
for i in 1:num_chains
|
||||
chain_path = joinpath(chains_dir, "chain_$i.csv")
|
||||
if !isfile(chain_path)
|
||||
println(" ✗ Missing: chain_$i.csv")
|
||||
all_good = false
|
||||
else
|
||||
# Quick read test and size check
|
||||
try
|
||||
CSV.File(chain_path; limit=1)
|
||||
file_size_gb = filesize(chain_path) / (1024^3)
|
||||
verified_files["chain_$i.csv"] = Dict(
|
||||
"path" => chain_path,
|
||||
"size_gb" => file_size_gb,
|
||||
"verified" => true
|
||||
)
|
||||
println(" ✓ chain_$i.csv verified ($(round(file_size_gb, digits=2)) GB)")
|
||||
catch e
|
||||
println(" ✗ Cannot read chain_$i.csv: $e")
|
||||
all_good = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if !all_good
|
||||
error("Verification failed - some files are missing or corrupted")
|
||||
end
|
||||
|
||||
println("\n" * "=" ^ 70)
|
||||
println("✓ MODEL SAVED SUCCESSFULLY")
|
||||
println("=" ^ 70)
|
||||
println("Run directory: $run_dir")
|
||||
println("Total size: $(round(total_size_gb, digits=2)) GB")
|
||||
println("Status: All files verified and ready")
|
||||
println("=" ^ 70)
|
||||
|
||||
# Return verification details for cleanup
|
||||
return (
|
||||
run_dir = run_dir,
|
||||
verified_files = verified_files,
|
||||
total_size_gb = total_size_gb,
|
||||
verification_passed = all_good,
|
||||
num_chains = num_chains
|
||||
)
|
||||
end
|
||||
|
||||
function generate_readme(
|
||||
filepath::String,
|
||||
run_id::String,
|
||||
metadata::Dict,
|
||||
num_chains::Int,
|
||||
total_size_gb::Float64
|
||||
)
|
||||
"""Generate human-readable README file"""
|
||||
|
||||
open(filepath, "w") do f
|
||||
write(f, "=" ^ 78 * "\n")
|
||||
write(f, "4D LATENT TRAIT MODEL - MODEL RUN RESULTS\n")
|
||||
write(f, "=" ^ 78 * "\n\n")
|
||||
|
||||
write(f, "Run ID: $run_id\n")
|
||||
write(f, "Model: $(get(metadata, "model_file", "unknown"))\n")
|
||||
write(f, "Date: $(Dates.format(Dates.now(), "yyyy-mm-dd HH:MM:SS"))\n")
|
||||
write(f, "Status: $(get(metadata, "convergence_status", "unknown"))\n\n")
|
||||
|
||||
write(f, "=" ^ 78 * "\n")
|
||||
write(f, "DIRECTORY CONTENTS\n")
|
||||
write(f, "=" ^ 78 * "\n\n")
|
||||
|
||||
write(f, "chains/\n")
|
||||
for i in 1:num_chains
|
||||
write(f, " ├── chain_$i.csv\n")
|
||||
end
|
||||
write(f, " Total: $(get(metadata, "num_chains", num_chains)) chains × " *
|
||||
"$(get(metadata, "num_samples", "?")) samples\n")
|
||||
write(f, " Size: $(round(total_size_gb, digits=2)) GB\n\n")
|
||||
|
||||
write(f, "data/\n")
|
||||
write(f, " ├── text_data.csv\n")
|
||||
write(f, " ├── expert_dim.csv\n")
|
||||
write(f, " ├── expert_lr.csv\n")
|
||||
write(f, " ├── segment_year_map.csv (V10)\n")
|
||||
write(f, " ├── segment_info.csv (V10)\n")
|
||||
write(f, " └── stan_data.json\n\n")
|
||||
|
||||
write(f, "=" ^ 78 * "\n")
|
||||
write(f, "MODEL CONFIGURATION\n")
|
||||
write(f, "=" ^ 78 * "\n\n")
|
||||
|
||||
write(f, "Chains: $(get(metadata, "num_chains", "?"))\n")
|
||||
write(f, "Warmup: $(get(metadata, "num_warmup", "?"))\n")
|
||||
write(f, "Samples: $(get(metadata, "num_samples", "?"))\n")
|
||||
write(f, "Adapt delta: $(get(metadata, "adapt_delta", "?"))\n")
|
||||
write(f, "Max depth: $(get(metadata, "max_depth", "?"))\n\n")
|
||||
|
||||
write(f, "Dimensions: $(join(get(metadata, "dimensions", ["?"]), ", "))\n\n")
|
||||
|
||||
write(f, "=" ^ 78 * "\n")
|
||||
write(f, "HOW TO USE THESE RESULTS\n")
|
||||
write(f, "=" ^ 78 * "\n\n")
|
||||
|
||||
write(f, "To extract party positions:\n\n")
|
||||
write(f, " julia 02_post_estimation.jl\n\n")
|
||||
|
||||
write(f, "This will read the CSV files and generate party_positions_[timestamp].csv\n")
|
||||
write(f, "with uncertainty estimates (SE, credible intervals).\n\n")
|
||||
|
||||
write(f, "=" ^ 78 * "\n")
|
||||
write(f, "Generated: $(Dates.format(Dates.now(), "yyyy-mm-dd HH:MM:SS"))\n")
|
||||
write(f, "=" ^ 78 * "\n")
|
||||
end
|
||||
end
|
||||
|
||||
"""
|
||||
Wrapper for robust_save_model_csv that handles the stanmodel tuple from run_4dim_stan_model.
|
||||
|
||||
The model execution now saves chains BEFORE returning, so this function:
|
||||
1. Verifies chains are already saved in final_run_dir
|
||||
2. Adds metadata and data files
|
||||
3. Returns the output path
|
||||
|
||||
Arguments:
|
||||
- stanmodel_tuple: Named tuple from run_4dim_stan_model (contains .stanmodel, .final_run_dir, etc.)
|
||||
- model_data: Dict with data_dict, original data, and metadata
|
||||
- output_dir: Base output directory (ignored - uses stanmodel_tuple.final_run_dir)
|
||||
- compress: Ignored (CSV-first architecture)
|
||||
- keep_local_backups: Ignored (chains already saved)
|
||||
"""
|
||||
function robust_save_model(
|
||||
stanmodel_tuple,
|
||||
model_data::Dict,
|
||||
output_dir::String;
|
||||
compress::Bool=true,
|
||||
keep_local_backups::Int=2
|
||||
)
|
||||
# Extract the final run directory from the stanmodel tuple
|
||||
if !hasproperty(stanmodel_tuple, :final_run_dir)
|
||||
error("stanmodel_tuple missing :final_run_dir - chains may not be saved!")
|
||||
end
|
||||
|
||||
final_run_dir = stanmodel_tuple.final_run_dir
|
||||
|
||||
# Prepare original data for saving
|
||||
original_data = Dict{String, Any}()
|
||||
if haskey(model_data, "manifesto")
|
||||
original_data["text_data"] = model_data["manifesto"]
|
||||
end
|
||||
if haskey(model_data, "expert_dim")
|
||||
original_data["expert_dim"] = model_data["expert_dim"]
|
||||
end
|
||||
if haskey(model_data, "expert_lr")
|
||||
original_data["expert_lr"] = model_data["expert_lr"]
|
||||
end
|
||||
# V10: Save segment_year_map and segment_info for post-estimation
|
||||
if haskey(model_data, "segment_year")
|
||||
original_data["segment_year_map"] = model_data["segment_year"]
|
||||
end
|
||||
if haskey(model_data, "segment_info")
|
||||
original_data["segment_info"] = model_data["segment_info"]
|
||||
end
|
||||
# V9 fallback: Save party_year_map for post-estimation (includes interpolated years)
|
||||
if haskey(model_data, "party_year")
|
||||
original_data["party_year_map"] = model_data["party_year"]
|
||||
end
|
||||
|
||||
# Prepare metadata
|
||||
metadata = Dict{String, Any}()
|
||||
if haskey(model_data, "model_info")
|
||||
for (k, v) in model_data["model_info"]
|
||||
metadata[string(k)] = v
|
||||
end
|
||||
end
|
||||
|
||||
# Get data_dict
|
||||
data_dict = get(model_data, "data_dict", Dict{String, Any}())
|
||||
|
||||
# Call the CSV-first save with chains_already_saved=true
|
||||
result = robust_save_model_csv(
|
||||
final_run_dir,
|
||||
data_dict,
|
||||
original_data,
|
||||
metadata;
|
||||
chains_already_saved=true
|
||||
)
|
||||
|
||||
return result.run_dir
|
||||
end
|
||||
|
||||
end # module
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## performance_monitoring.jl
|
||||
## Post-run performance diagnostics for Stan sampling jobs
|
||||
#############################################################################
|
||||
|
||||
using StanSample
|
||||
using Statistics: mean, median
|
||||
using Dates
|
||||
|
||||
function _safe_column(df, candidates::Vector{String})
|
||||
for candidate in candidates
|
||||
sym = Symbol(candidate)
|
||||
if sym in names(df)
|
||||
return df[!, sym]
|
||||
elseif candidate in names(df)
|
||||
return df[!, candidate]
|
||||
end
|
||||
end
|
||||
return nothing
|
||||
end
|
||||
|
||||
function _clean_values(vec)
|
||||
cleaned = Float64[]
|
||||
for v in vec
|
||||
if v isa Missing || v === nothing
|
||||
continue
|
||||
end
|
||||
try
|
||||
value = Float64(v)
|
||||
isfinite(value) && push!(cleaned, value)
|
||||
catch
|
||||
continue
|
||||
end
|
||||
end
|
||||
return cleaned
|
||||
end
|
||||
|
||||
function _summarize_vector(vec)
|
||||
if vec === nothing
|
||||
return Dict{String,Any}("available" => false)
|
||||
end
|
||||
cleaned = _clean_values(vec)
|
||||
if isempty(cleaned)
|
||||
return Dict{String,Any}("available" => false)
|
||||
end
|
||||
return Dict{String,Any}(
|
||||
"available" => true,
|
||||
"count" => length(cleaned),
|
||||
"mean" => mean(cleaned),
|
||||
"median" => median(cleaned),
|
||||
"min" => minimum(cleaned),
|
||||
"max" => maximum(cleaned)
|
||||
)
|
||||
end
|
||||
|
||||
function monitor_sampling_performance!(stanmodel;
|
||||
run_metrics::Union{Nothing,Dict{String,Any}}=nothing,
|
||||
metrics_path::Union{Nothing,String}=nothing,
|
||||
csv_paths::Union{Nothing,Vector{String}}=nothing,
|
||||
aggregate_metrics::Union{Nothing,Dict{String,Any}}=nothing,
|
||||
max_depth::Union{Nothing,Int}=nothing)
|
||||
|
||||
csv_paths === nothing && (csv_paths = discover_stan_csvs([stanmodel.tmpdir]))
|
||||
aggregate_metrics === nothing && begin
|
||||
_, aggregate_metrics = collect_run_metrics(csv_paths; max_depth=max_depth)
|
||||
end
|
||||
|
||||
summary_df = nothing
|
||||
try
|
||||
summary_df = read_summary(stanmodel)
|
||||
catch e
|
||||
println("Warning: could not read Stan summary: $e")
|
||||
end
|
||||
|
||||
performance = Dict{String,Any}(
|
||||
"generated_at" => Dates.format(Dates.now(), "yyyy-mm-ddTHH:MM:SS"),
|
||||
"csv_paths" => csv_paths
|
||||
)
|
||||
|
||||
if summary_df !== nothing
|
||||
performance["ess_bulk"] = _summarize_vector(_safe_column(summary_df, ["ess_bulk", "ess"]))
|
||||
performance["ess_tail"] = _summarize_vector(_safe_column(summary_df, ["ess_tail"]))
|
||||
performance["ess_per_sec"] = _summarize_vector(_safe_column(summary_df, ["ess_per_sec", "n_eff/s"]))
|
||||
performance["r_hat"] = _summarize_vector(_safe_column(summary_df, ["r_hat"]))
|
||||
|
||||
if haskey(performance["r_hat"], "available") && performance["r_hat"]["available"]
|
||||
performance["r_hat"]["max"] = maximum(_clean_values(_safe_column(summary_df, ["r_hat"])))
|
||||
end
|
||||
|
||||
performance["parameters_considered"] = size(summary_df, 1)
|
||||
else
|
||||
performance["ess_bulk"] = Dict{String,Any}("available" => false)
|
||||
performance["ess_tail"] = Dict{String,Any}("available" => false)
|
||||
performance["ess_per_sec"] = Dict{String,Any}("available" => false)
|
||||
performance["r_hat"] = Dict{String,Any}("available" => false)
|
||||
performance["parameters_considered"] = 0
|
||||
end
|
||||
|
||||
divergences = get(aggregate_metrics, "divergences", 0)
|
||||
total_samples = stanmodel.num_samples * stanmodel.num_chains
|
||||
divergence_rate = total_samples > 0 ? divergences / total_samples : nothing
|
||||
|
||||
performance["divergences"] = Dict{String,Any}(
|
||||
"count" => divergences,
|
||||
"rate" => divergence_rate,
|
||||
"total_draws" => total_samples
|
||||
)
|
||||
|
||||
performance["leapfrog"] = Dict{String,Any}(
|
||||
"mean" => get(aggregate_metrics, "mean_leapfrog", nothing)
|
||||
)
|
||||
|
||||
performance["step_size"] = Dict{String,Any}(
|
||||
"mean" => get(aggregate_metrics, "mean_step_size", nothing)
|
||||
)
|
||||
|
||||
sampling_seconds = get(aggregate_metrics, "sampling_seconds", nothing)
|
||||
if sampling_seconds !== nothing && sampling_seconds > 0
|
||||
performance["throughput"] = Dict{String,Any}(
|
||||
"samples_per_second" => (total_samples / sampling_seconds),
|
||||
"seconds_sampling" => sampling_seconds
|
||||
)
|
||||
else
|
||||
performance["throughput"] = Dict{String,Any}(
|
||||
"samples_per_second" => nothing,
|
||||
"seconds_sampling" => sampling_seconds
|
||||
)
|
||||
end
|
||||
|
||||
if run_metrics !== nothing
|
||||
run_metrics["performance"] = performance
|
||||
if metrics_path !== nothing
|
||||
safe_write_json(metrics_path, run_metrics)
|
||||
end
|
||||
elseif metrics_path !== nothing
|
||||
temp_metrics = Dict{String,Any}("performance" => performance)
|
||||
safe_write_json(metrics_path, temp_metrics)
|
||||
end
|
||||
|
||||
println("\nPERFORMANCE SUMMARY")
|
||||
println(" ESS bulk (mean): $(get(performance["ess_bulk"], "mean", "n/a"))")
|
||||
println(" ESS/sec (mean): $(get(performance["ess_per_sec"], "mean", "n/a"))")
|
||||
println(" Divergences: $(divergences)")
|
||||
println(" Divergence rate: $(divergence_rate === nothing ? "n/a" : round(divergence_rate, digits=6))")
|
||||
println(" Mean leapfrog steps: $(get(performance["leapfrog"], "mean", "n/a"))")
|
||||
|
||||
return performance
|
||||
end
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## validate_construct.jl
|
||||
## Construct validity: Party family ordering and temporal stability
|
||||
##
|
||||
## Following Claassen (2019), this script validates:
|
||||
## 1. Party family ordering: Do family means follow theoretically expected orderings?
|
||||
## 2. Temporal stability: Flag parties with implausible position changes
|
||||
##
|
||||
## Uses ParlGov party family classifications (Döring & Manow 2024) via PartyFacts IDs.
|
||||
#############################################################################
|
||||
|
||||
using CSV, DataFrames, Statistics, StatsBase, Dates, Printf
|
||||
|
||||
# Family code → display name mapping
|
||||
const FAMILY_DISPLAY_NAMES = Dict(
|
||||
"com" => "Communist/Far Left",
|
||||
"eco" => "Green/Ecological",
|
||||
"soc" => "Social Democratic",
|
||||
"lib" => "Liberal",
|
||||
"chr" => "Christian Democratic",
|
||||
"con" => "Conservative",
|
||||
"right" => "Radical Right"
|
||||
)
|
||||
|
||||
# Substantive families (drop Specialist, Other, Agrarian — heterogeneous or ambiguous)
|
||||
const SUBSTANTIVE_FAMILIES = Set(["com", "eco", "soc", "lib", "chr", "con", "right"])
|
||||
|
||||
# Expected orderings (theoretically motivated)
|
||||
# Economic: Communist < Social Democratic < Green < Christian Democratic < Conservative
|
||||
# (5-family core — Liberal position is ambiguous cross-nationally)
|
||||
const EXPECTED_ECONOMIC_ORDER = ["com", "soc", "eco", "chr", "con"]
|
||||
|
||||
# Cultural: Green < Liberal < Social Democratic < Christian Democratic < Conservative < Radical Right
|
||||
const EXPECTED_GALTAN_ORDER = ["eco", "lib", "soc", "chr", "con", "right"]
|
||||
|
||||
function load_model_output(base_dir::String=".")
|
||||
"""Load the most recent 2D model party positions output"""
|
||||
|
||||
position_files = filter(f -> startswith(f, "party_positions_") && endswith(f, ".csv") &&
|
||||
!endswith(f, "_metadata.txt") && !endswith(f, "_tables.tex"), readdir(base_dir))
|
||||
legacy_files = filter(f -> startswith(f, "party_positions_v1_") && endswith(f, ".csv"), readdir(base_dir))
|
||||
append!(position_files, legacy_files)
|
||||
|
||||
if !isempty(position_files)
|
||||
latest = sort(position_files)[end]
|
||||
println("Loading model output: $latest")
|
||||
return CSV.read(joinpath(base_dir, latest), DataFrame), latest
|
||||
end
|
||||
|
||||
# Check output estimations directory
|
||||
est_dir = joinpath(base_dir, "outputs", "estimations", "latest")
|
||||
if isdir(est_dir)
|
||||
est_files = filter(f -> startswith(f, "party_positions_") && endswith(f, ".csv") &&
|
||||
!endswith(f, "_metadata.txt") && !endswith(f, "_tables.tex"), readdir(est_dir))
|
||||
if !isempty(est_files)
|
||||
latest = sort(est_files)[end]
|
||||
println("Loading model output: outputs/estimations/latest/$latest")
|
||||
return CSV.read(joinpath(est_dir, latest), DataFrame), latest
|
||||
end
|
||||
end
|
||||
|
||||
error("No party_positions_*.csv found. Run 02_post_estimation.jl first.")
|
||||
end
|
||||
|
||||
function validate_party_families(model::DataFrame)
|
||||
"""Check whether party family means follow theoretically expected orderings"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("PARTY FAMILY ORDERING VALIDATION")
|
||||
println("="^60)
|
||||
println("\nUsing ParlGov family classifications (Döring & Manow 2024)")
|
||||
println()
|
||||
|
||||
party_col = hasproperty(model, :party_id) ? :party_id : :party
|
||||
|
||||
# Load party families
|
||||
families_df = CSV.read("data/party_families.csv", DataFrame)
|
||||
|
||||
# Join to model output
|
||||
model_with_families = innerjoin(model, families_df, on=party_col => :partyfacts_id)
|
||||
|
||||
# Filter to substantive families
|
||||
filter!(r -> r.family in SUBSTANTIVE_FAMILIES, model_with_families)
|
||||
|
||||
n_parties = length(unique(model_with_families[!, party_col]))
|
||||
n_obs = nrow(model_with_families)
|
||||
println(" Matched $n_parties parties ($n_obs party-years) across $(length(SUBSTANTIVE_FAMILIES)) families")
|
||||
println()
|
||||
|
||||
# Compute family means
|
||||
family_stats = combine(groupby(model_with_families, :family)) do df
|
||||
DataFrame(
|
||||
n_parties = length(unique(df[!, party_col])),
|
||||
n_obs = nrow(df),
|
||||
mean_economic = mean(df.economic_lr),
|
||||
sd_economic = std(df.economic_lr),
|
||||
mean_galtan = mean(df.galtan),
|
||||
sd_galtan = std(df.galtan)
|
||||
)
|
||||
end
|
||||
|
||||
# Add display names
|
||||
family_stats.family_name = [get(FAMILY_DISPLAY_NAMES, f, f) for f in family_stats.family]
|
||||
|
||||
# Sort by economic mean for display
|
||||
sort!(family_stats, :mean_economic)
|
||||
|
||||
# Print table
|
||||
@printf(" %-22s %7s %7s %10s %10s %10s %10s\n",
|
||||
"Family", "Parties", "Obs", "Econ Mean", "Econ SD", "Cult Mean", "Cult SD")
|
||||
println(" " * "-"^76)
|
||||
|
||||
for row in eachrow(family_stats)
|
||||
@printf(" %-22s %7d %7d %10.3f %10.3f %10.3f %10.3f\n",
|
||||
row.family_name, row.n_parties, row.n_obs,
|
||||
row.mean_economic, row.sd_economic, row.mean_galtan, row.sd_galtan)
|
||||
end
|
||||
|
||||
# Compute Spearman rank correlations for expected orderings
|
||||
println()
|
||||
|
||||
# Economic ordering
|
||||
econ_lookup = Dict(row.family => row.mean_economic for row in eachrow(family_stats))
|
||||
econ_observed = [econ_lookup[f] for f in EXPECTED_ECONOMIC_ORDER if haskey(econ_lookup, f)]
|
||||
econ_expected_ranks = collect(1:length(econ_observed))
|
||||
econ_observed_ranks = ordinalrank(econ_observed)
|
||||
rho_econ = corspearman(Float64.(econ_expected_ranks), Float64.(econ_observed_ranks))
|
||||
|
||||
println(@sprintf(" Economic ordering (5-family core): Spearman ρ = %.3f", rho_econ))
|
||||
econ_families_used = [f for f in EXPECTED_ECONOMIC_ORDER if haskey(econ_lookup, f)]
|
||||
econ_names = [get(FAMILY_DISPLAY_NAMES, f, f) for f in econ_families_used]
|
||||
println(" Expected: ", join(econ_names, " < "))
|
||||
observed_econ_order = econ_families_used[sortperm(econ_observed)]
|
||||
observed_econ_names = [get(FAMILY_DISPLAY_NAMES, f, f) for f in observed_econ_order]
|
||||
println(" Observed: ", join(observed_econ_names, " < "))
|
||||
|
||||
# Cultural ordering
|
||||
galtan_lookup = Dict(row.family => row.mean_galtan for row in eachrow(family_stats))
|
||||
galtan_observed = [galtan_lookup[f] for f in EXPECTED_GALTAN_ORDER if haskey(galtan_lookup, f)]
|
||||
galtan_expected_ranks = collect(1:length(galtan_observed))
|
||||
galtan_observed_ranks = ordinalrank(galtan_observed)
|
||||
rho_galtan = corspearman(Float64.(galtan_expected_ranks), Float64.(galtan_observed_ranks))
|
||||
|
||||
println(@sprintf(" Cultural ordering (6-family): Spearman ρ = %.3f", rho_galtan))
|
||||
galtan_families_used = [f for f in EXPECTED_GALTAN_ORDER if haskey(galtan_lookup, f)]
|
||||
galtan_names = [get(FAMILY_DISPLAY_NAMES, f, f) for f in galtan_families_used]
|
||||
println(" Expected: ", join(galtan_names, " < "))
|
||||
observed_galtan_order = galtan_families_used[sortperm(galtan_observed)]
|
||||
observed_galtan_names = [get(FAMILY_DISPLAY_NAMES, f, f) for f in observed_galtan_order]
|
||||
println(" Observed: ", join(observed_galtan_names, " < "))
|
||||
|
||||
println()
|
||||
println("-"^60)
|
||||
if rho_econ >= 0.9 && rho_galtan >= 0.8
|
||||
println("EXCELLENT: Family means follow expected orderings on both dimensions")
|
||||
elseif rho_econ >= 0.7 && rho_galtan >= 0.7
|
||||
println("GOOD: Family means broadly follow expected orderings")
|
||||
else
|
||||
println("CONCERN: Review family ordering results")
|
||||
end
|
||||
|
||||
return family_stats, rho_econ, rho_galtan
|
||||
end
|
||||
|
||||
function validate_temporal_stability(model::DataFrame)
|
||||
"""Check for implausible year-to-year position changes"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("TEMPORAL STABILITY VALIDATION")
|
||||
println("="^60)
|
||||
println("\nFlagging parties with >0.10 change per year")
|
||||
println()
|
||||
|
||||
party_col = hasproperty(model, :party_id) ? :party_id : :party
|
||||
|
||||
# Compute year-to-year changes within each party
|
||||
sort!(model, [party_col, :year])
|
||||
|
||||
unstable_parties = []
|
||||
|
||||
for party_df in groupby(model, party_col)
|
||||
if nrow(party_df) < 2
|
||||
continue
|
||||
end
|
||||
|
||||
party_id = party_df[1, party_col]
|
||||
country = party_df[1, :country]
|
||||
|
||||
# Compute differences
|
||||
for dim in [:economic_lr, :galtan]
|
||||
vals = party_df[!, dim]
|
||||
years = party_df.year
|
||||
|
||||
for i in 2:length(vals)
|
||||
diff = abs(vals[i] - vals[i-1])
|
||||
year_gap = years[i] - years[i-1]
|
||||
|
||||
# Normalize by year gap (handle multi-year gaps)
|
||||
annual_change = diff / max(year_gap, 1)
|
||||
|
||||
if annual_change > 0.10
|
||||
push!(unstable_parties, (
|
||||
party_id = party_id,
|
||||
country = country,
|
||||
dimension = string(dim),
|
||||
year_from = years[i-1],
|
||||
year_to = years[i],
|
||||
val_from = vals[i-1],
|
||||
val_to = vals[i],
|
||||
change = diff,
|
||||
annual_change = annual_change
|
||||
))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if isempty(unstable_parties)
|
||||
println(" No parties with >0.10 annual change found")
|
||||
println(" EXCELLENT: Positions are temporally stable")
|
||||
return DataFrame()
|
||||
end
|
||||
|
||||
unstable_df = DataFrame(unstable_parties)
|
||||
sort!(unstable_df, :annual_change, rev=true)
|
||||
|
||||
println(" Found $(nrow(unstable_df)) instances of rapid change:")
|
||||
println()
|
||||
@printf(" %-8s %-8s %-12s %-10s %-10s %8s\n",
|
||||
"Party", "Country", "Dimension", "Years", "Change", "Annual")
|
||||
println(" " * "-"^60)
|
||||
|
||||
for row in eachrow(unstable_df[1:min(20, nrow(unstable_df)), :])
|
||||
@printf(" %-8d %-8s %-12s %d->%d %8.3f %8.3f\n",
|
||||
row.party_id, row.country, row.dimension,
|
||||
row.year_from, row.year_to, row.change, row.annual_change)
|
||||
end
|
||||
|
||||
if nrow(unstable_df) > 20
|
||||
println(" ... and $(nrow(unstable_df) - 20) more")
|
||||
end
|
||||
|
||||
println()
|
||||
println("-"^60)
|
||||
n_parties = length(unique(unstable_df.party_id))
|
||||
n_total = length(unique(model[!, party_col]))
|
||||
println(@sprintf("Unstable parties: %d/%d (%.1f%%)", n_parties, n_total, 100*n_parties/n_total))
|
||||
|
||||
return unstable_df
|
||||
end
|
||||
|
||||
function validate_position_distributions(model::DataFrame)
|
||||
"""Check overall distribution of positions makes sense"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("POSITION DISTRIBUTION VALIDATION")
|
||||
println("="^60)
|
||||
println("\nSummary statistics for model estimates")
|
||||
println()
|
||||
|
||||
for dim in [:economic_lr, :galtan]
|
||||
if !hasproperty(model, dim)
|
||||
continue
|
||||
end
|
||||
|
||||
vals = model[!, dim]
|
||||
println("$dim:")
|
||||
println(@sprintf(" Mean: %.3f (should be ~0.50)", mean(vals)))
|
||||
println(@sprintf(" Median: %.3f (should be ~0.50)", median(vals)))
|
||||
println(@sprintf(" SD: %.3f (should be ~0.15)", std(vals)))
|
||||
println(@sprintf(" Min: %.3f", minimum(vals)))
|
||||
println(@sprintf(" Max: %.3f", maximum(vals)))
|
||||
println(@sprintf(" Q25: %.3f", quantile(vals, 0.25)))
|
||||
println(@sprintf(" Q75: %.3f", quantile(vals, 0.75)))
|
||||
println()
|
||||
end
|
||||
|
||||
# Check for extreme values
|
||||
println("Extreme positions (< 0.10 or > 0.90):")
|
||||
|
||||
party_col = hasproperty(model, :party_id) ? :party_id : :party
|
||||
|
||||
for dim in [:economic_lr, :galtan]
|
||||
if !hasproperty(model, dim)
|
||||
continue
|
||||
end
|
||||
|
||||
extreme = filter(row -> row[dim] < 0.10 || row[dim] > 0.90, model)
|
||||
n_extreme = nrow(extreme)
|
||||
pct_extreme = 100 * n_extreme / nrow(model)
|
||||
|
||||
println(@sprintf(" %s: %d (%.1f%%)", dim, n_extreme, pct_extreme))
|
||||
|
||||
if n_extreme > 0 && n_extreme <= 10
|
||||
for row in eachrow(extreme[1:min(5, nrow(extreme)), :])
|
||||
println(@sprintf(" Party %d (%s) %d: %.3f",
|
||||
row[party_col], row.country, row.year, row[dim]))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function validate_country_patterns(model::DataFrame)
|
||||
"""Check country-level patterns make sense"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("COUNTRY-LEVEL VALIDATION")
|
||||
println("="^60)
|
||||
println("\nMean positions by country (should vary but not wildly)")
|
||||
println()
|
||||
|
||||
country_stats = combine(groupby(model, :country)) do df
|
||||
DataFrame(
|
||||
n_parties = length(unique(hasproperty(df, :party_id) ? df.party_id : df.party)),
|
||||
n_obs = nrow(df),
|
||||
mean_econ = mean(df.economic_lr),
|
||||
mean_galtan = mean(df.galtan),
|
||||
sd_econ = std(df.economic_lr),
|
||||
sd_galtan = std(df.galtan)
|
||||
)
|
||||
end
|
||||
|
||||
sort!(country_stats, :n_obs, rev=true)
|
||||
|
||||
@printf("%-4s %6s %6s %8s %8s %8s %8s\n",
|
||||
"CC", "Parties", "N", "Econ", "SD", "Galtan", "SD")
|
||||
println("-"^60)
|
||||
|
||||
for row in eachrow(country_stats[1:min(20, nrow(country_stats)), :])
|
||||
@printf("%-4s %6d %6d %8.3f %8.3f %8.3f %8.3f\n",
|
||||
row.country, row.n_parties, row.n_obs,
|
||||
row.mean_econ, row.sd_econ, row.mean_galtan, row.sd_galtan)
|
||||
end
|
||||
|
||||
# Flag countries with unusual patterns
|
||||
println("\nCountries with unusual patterns:")
|
||||
unusual = filter(row -> row.mean_econ < 0.35 || row.mean_econ > 0.65 ||
|
||||
row.mean_galtan < 0.35 || row.mean_galtan > 0.65, country_stats)
|
||||
|
||||
if nrow(unusual) == 0
|
||||
println(" None - all countries have balanced party systems")
|
||||
else
|
||||
for row in eachrow(unusual)
|
||||
issues = String[]
|
||||
if row.mean_econ < 0.35
|
||||
push!(issues, "left-skewed economy")
|
||||
elseif row.mean_econ > 0.65
|
||||
push!(issues, "right-skewed economy")
|
||||
end
|
||||
if row.mean_galtan < 0.35
|
||||
push!(issues, "cultural-skewed")
|
||||
elseif row.mean_galtan > 0.65
|
||||
push!(issues, "TAN-skewed")
|
||||
end
|
||||
println(" $(row.country): $(join(issues, ", "))")
|
||||
end
|
||||
end
|
||||
|
||||
return country_stats
|
||||
end
|
||||
|
||||
function save_construct_results(families::DataFrame, unstable::DataFrame,
|
||||
countries::DataFrame, output_dir::String="outputs/checks")
|
||||
"""Save construct validation results"""
|
||||
|
||||
if !isdir(output_dir)
|
||||
mkpath(output_dir)
|
||||
end
|
||||
|
||||
timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
|
||||
|
||||
if nrow(families) > 0
|
||||
families_file = joinpath(output_dir, "construct_families_$timestamp.csv")
|
||||
CSV.write(families_file, families)
|
||||
println("\nSaved: $families_file")
|
||||
end
|
||||
|
||||
if nrow(unstable) > 0
|
||||
unstable_file = joinpath(output_dir, "construct_unstable_$timestamp.csv")
|
||||
CSV.write(unstable_file, unstable)
|
||||
println("Saved: $unstable_file")
|
||||
end
|
||||
|
||||
if nrow(countries) > 0
|
||||
country_file = joinpath(output_dir, "construct_countries_$timestamp.csv")
|
||||
CSV.write(country_file, countries)
|
||||
println("Saved: $country_file")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# Main execution
|
||||
function main()
|
||||
println("="^60)
|
||||
println("CONSTRUCT VALIDITY: Face Validity Checks")
|
||||
println("="^60)
|
||||
println("Checking if model estimates match expectations")
|
||||
println()
|
||||
|
||||
# Load data
|
||||
model, model_file = load_model_output()
|
||||
|
||||
println("\nModel output:")
|
||||
println(" Rows: $(nrow(model))")
|
||||
println(" Columns: $(names(model))")
|
||||
|
||||
# Run validations
|
||||
families, rho_econ, rho_galtan = validate_party_families(model)
|
||||
unstable = validate_temporal_stability(model)
|
||||
validate_position_distributions(model)
|
||||
countries = validate_country_patterns(model)
|
||||
|
||||
# Save results
|
||||
save_construct_results(families, unstable, countries)
|
||||
|
||||
# Summary
|
||||
println("\n" * "="^60)
|
||||
println("CONSTRUCT VALIDITY SUMMARY")
|
||||
println("="^60)
|
||||
|
||||
println(@sprintf(" Party family ordering:"))
|
||||
println(@sprintf(" Economic (5-family): Spearman ρ = %.3f", rho_econ))
|
||||
println(@sprintf(" Cultural (6-family): Spearman ρ = %.3f", rho_galtan))
|
||||
|
||||
n_unstable = nrow(unstable) > 0 ? length(unique(unstable.party_id)) : 0
|
||||
party_col = hasproperty(model, :party_id) ? :party_id : :party
|
||||
n_total = length(unique(model[!, party_col]))
|
||||
println(@sprintf(" Temporal stability: %d/%d parties stable (>0.10/yr threshold)",
|
||||
n_total - n_unstable, n_total))
|
||||
|
||||
if rho_econ >= 0.9 && rho_galtan >= 0.8 && n_unstable < 0.1 * n_total
|
||||
println("\n EXCELLENT: Model has good construct validity")
|
||||
elseif rho_econ >= 0.7 && rho_galtan >= 0.7
|
||||
println("\n GOOD: Model has reasonable construct validity")
|
||||
else
|
||||
println("\n CONCERN: Review family ordering results")
|
||||
end
|
||||
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("VALIDATION COMPLETE")
|
||||
println("="^60)
|
||||
|
||||
return (families=families, unstable=unstable, countries=countries)
|
||||
end
|
||||
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
main()
|
||||
end
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## validate_convergent.jl
|
||||
## Convergent validity: Compare model estimates to external expert surveys
|
||||
##
|
||||
## Following Claassen (2019), this script computes:
|
||||
## - Pearson/Spearman correlations between model and expert estimates
|
||||
## - Fisher z-transformation for correlation confidence intervals
|
||||
## - Mean Absolute Error (MAE) and Root Mean Square Error (RMSE)
|
||||
## - Breakdown by survey project and decade
|
||||
##
|
||||
## Target: r > 0.8 with CHES (Claassen achieved 0.50-0.57)
|
||||
#############################################################################
|
||||
|
||||
using CSV, DataFrames, Statistics, StatsBase, Dates, Printf, JSON
|
||||
|
||||
# Fisher z-transformation for correlation confidence intervals
|
||||
fisher_z(r) = 0.5 * log((1 + r) / (1 - r))
|
||||
fisher_z_inv(z) = (exp(2z) - 1) / (exp(2z) + 1)
|
||||
|
||||
function correlation_ci(r, n; alpha=0.05)
|
||||
"""Calculate confidence interval for correlation using Fisher z-transformation"""
|
||||
if n < 4
|
||||
return (lower=NaN, upper=NaN)
|
||||
end
|
||||
z = fisher_z(r)
|
||||
se = 1 / sqrt(n - 3)
|
||||
z_crit = 1.96 # For 95% CI
|
||||
z_lower = z - z_crit * se
|
||||
z_upper = z + z_crit * se
|
||||
return (lower=fisher_z_inv(z_lower), upper=fisher_z_inv(z_upper))
|
||||
end
|
||||
|
||||
function load_model_output(base_dir::String=".")
|
||||
"""Load the most recent 2D model party positions output"""
|
||||
|
||||
# First check for post_estimation output in root (current name, with legacy fallback)
|
||||
position_files = filter(f -> startswith(f, "party_positions_") && endswith(f, ".csv") &&
|
||||
!endswith(f, "_metadata.txt") && !endswith(f, "_tables.tex"), readdir(base_dir))
|
||||
legacy_files = filter(f -> startswith(f, "party_positions_v1_") && endswith(f, ".csv"), readdir(base_dir))
|
||||
append!(position_files, legacy_files)
|
||||
|
||||
if !isempty(position_files)
|
||||
latest = sort(position_files)[end]
|
||||
println("Loading model output: $latest")
|
||||
return CSV.read(joinpath(base_dir, latest), DataFrame), latest
|
||||
end
|
||||
|
||||
# Check current pipeline output directory, with legacy estimations/ fallback
|
||||
for (label, est_dir) in [
|
||||
("outputs/estimations/latest", joinpath(base_dir, "outputs", "estimations", "latest")),
|
||||
("estimations", joinpath(base_dir, "estimations")),
|
||||
]
|
||||
if isdir(est_dir)
|
||||
est_files = filter(f -> startswith(f, "party_positions_") && endswith(f, ".csv") &&
|
||||
!endswith(f, "_metadata.txt") && !endswith(f, "_tables.tex"), readdir(est_dir))
|
||||
if !isempty(est_files)
|
||||
latest = sort(est_files)[end]
|
||||
println("Loading model output: $label/$latest")
|
||||
return CSV.read(joinpath(est_dir, latest), DataFrame), latest
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
error("No party_positions_*.csv found. Run 02_post_estimation.jl first.")
|
||||
end
|
||||
|
||||
function load_expert_data(base_dir::String=".")
|
||||
"""Load expert survey data files"""
|
||||
|
||||
data_dir = isfile(joinpath(base_dir, "expert.csv")) ? base_dir : joinpath(base_dir, "data")
|
||||
|
||||
# Load dimension-specific expert data
|
||||
expert_file = joinpath(data_dir, "expert.csv")
|
||||
if !isfile(expert_file)
|
||||
error("expert.csv not found in $base_dir or $(joinpath(base_dir, "data"))")
|
||||
end
|
||||
|
||||
println("Loading expert.csv...")
|
||||
expert = CSV.read(expert_file, DataFrame)
|
||||
println(" Rows: $(nrow(expert))")
|
||||
println(" Variables: $(unique(expert.var))")
|
||||
|
||||
# Load L-R data
|
||||
lr_file = joinpath(data_dir, "lr_data.csv")
|
||||
if !isfile(lr_file)
|
||||
error("lr_data.csv not found in $base_dir or $(joinpath(base_dir, "data"))")
|
||||
end
|
||||
|
||||
println("Loading lr_data.csv...")
|
||||
lr_data = CSV.read(lr_file, DataFrame)
|
||||
println(" Rows: $(nrow(lr_data))")
|
||||
println(" Variables: $(unique(lr_data.var))")
|
||||
|
||||
return expert, lr_data
|
||||
end
|
||||
|
||||
function validate_economic_lr(model::DataFrame, expert::DataFrame)
|
||||
"""Validate economic_lr against CHES/V-Party/POPPA/GPS lrecon"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("CONVERGENT VALIDITY: economic_lr")
|
||||
println("="^60)
|
||||
|
||||
# Filter expert data for economic dimension
|
||||
econ_vars = filter(v -> startswith(v, "lrecon_"), unique(expert.var))
|
||||
econ_expert = filter(row -> row.var in econ_vars, expert)
|
||||
|
||||
println("\nExpert data variables: $(econ_vars)")
|
||||
println("Expert observations: $(nrow(econ_expert))")
|
||||
|
||||
# Merge with model output
|
||||
# Model has party_id column (from segment-based), expert has party column
|
||||
if hasproperty(model, :party_id)
|
||||
model_merge = select(model, :party_id => :party, :year, :economic_lr, :economic_lr_se)
|
||||
else
|
||||
model_merge = select(model, :party, :year, :economic_lr, :economic_lr_se)
|
||||
end
|
||||
|
||||
merged = innerjoin(econ_expert, model_merge, on=[:party, :year])
|
||||
|
||||
println("Merged observations: $(nrow(merged))")
|
||||
|
||||
if nrow(merged) < 10
|
||||
println("WARNING: Too few observations for meaningful validation")
|
||||
return nothing
|
||||
end
|
||||
|
||||
# Compute overall correlation
|
||||
r_pearson = cor(merged.val, merged.economic_lr)
|
||||
r_spearman = corspearman(merged.val, merged.economic_lr)
|
||||
mae = mean(abs.(merged.val .- merged.economic_lr))
|
||||
rmse = sqrt(mean((merged.val .- merged.economic_lr).^2))
|
||||
|
||||
ci = correlation_ci(r_pearson, nrow(merged))
|
||||
|
||||
println("\n--- Overall Statistics ---")
|
||||
println(@sprintf(" Pearson r: %.4f [%.4f, %.4f]", r_pearson, ci.lower, ci.upper))
|
||||
println(@sprintf(" Spearman r: %.4f", r_spearman))
|
||||
println(@sprintf(" MAE: %.4f", mae))
|
||||
println(@sprintf(" RMSE: %.4f", rmse))
|
||||
println(@sprintf(" N: %d", nrow(merged)))
|
||||
|
||||
# Breakdown by project
|
||||
println("\n--- By Project ---")
|
||||
by_project = combine(groupby(merged, :project)) do df
|
||||
n = nrow(df)
|
||||
if n < 3
|
||||
return DataFrame(n=n, r_pearson=NaN, mae=NaN)
|
||||
end
|
||||
DataFrame(
|
||||
n = n,
|
||||
r_pearson = cor(df.val, df.economic_lr),
|
||||
r_spearman = corspearman(df.val, df.economic_lr),
|
||||
mae = mean(abs.(df.val .- df.economic_lr)),
|
||||
rmse = sqrt(mean((df.val .- df.economic_lr).^2))
|
||||
)
|
||||
end
|
||||
|
||||
for row in eachrow(sort(by_project, :n, rev=true))
|
||||
if !isnan(row.r_pearson)
|
||||
println(@sprintf(" %-10s: r=%.3f, MAE=%.3f, n=%d",
|
||||
row.project, row.r_pearson, row.mae, row.n))
|
||||
end
|
||||
end
|
||||
|
||||
# Breakdown by decade
|
||||
println("\n--- By Decade ---")
|
||||
merged.decade = div.(merged.year, 10) .* 10
|
||||
by_decade = combine(groupby(merged, :decade)) do df
|
||||
n = nrow(df)
|
||||
if n < 3
|
||||
return DataFrame(n=n, r_pearson=NaN, mae=NaN)
|
||||
end
|
||||
DataFrame(
|
||||
n = n,
|
||||
r_pearson = cor(df.val, df.economic_lr),
|
||||
mae = mean(abs.(df.val .- df.economic_lr))
|
||||
)
|
||||
end
|
||||
|
||||
for row in eachrow(sort(by_decade, :decade))
|
||||
if !isnan(row.r_pearson)
|
||||
println(@sprintf(" %ds: r=%.3f, MAE=%.3f, n=%d",
|
||||
row.decade, row.r_pearson, row.mae, row.n))
|
||||
end
|
||||
end
|
||||
|
||||
return (
|
||||
dimension = "economic_lr",
|
||||
r_pearson = r_pearson,
|
||||
r_spearman = r_spearman,
|
||||
ci_lower = ci.lower,
|
||||
ci_upper = ci.upper,
|
||||
mae = mae,
|
||||
rmse = rmse,
|
||||
n = nrow(merged),
|
||||
by_project = by_project,
|
||||
by_decade = by_decade
|
||||
)
|
||||
end
|
||||
|
||||
function validate_galtan(model::DataFrame, expert::DataFrame)
|
||||
"""Validate galtan against CHES galtan and V-Party/GPS libcons"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("CONVERGENT VALIDITY: galtan")
|
||||
println("="^60)
|
||||
|
||||
# Filter expert data for GAL-TAN dimension
|
||||
galtan_vars = filter(v -> occursin("galtan", v) || occursin("libcon", v), unique(expert.var))
|
||||
galtan_expert = filter(row -> row.var in galtan_vars, expert)
|
||||
|
||||
println("\nExpert data variables: $(galtan_vars)")
|
||||
println("Expert observations: $(nrow(galtan_expert))")
|
||||
|
||||
# Merge with model output
|
||||
if hasproperty(model, :party_id)
|
||||
model_merge = select(model, :party_id => :party, :year, :galtan, :galtan_se)
|
||||
else
|
||||
model_merge = select(model, :party, :year, :galtan, :galtan_se)
|
||||
end
|
||||
|
||||
merged = innerjoin(galtan_expert, model_merge, on=[:party, :year])
|
||||
|
||||
println("Merged observations: $(nrow(merged))")
|
||||
|
||||
if nrow(merged) < 10
|
||||
println("WARNING: Too few observations for meaningful validation")
|
||||
return nothing
|
||||
end
|
||||
|
||||
# Compute overall correlation
|
||||
r_pearson = cor(merged.val, merged.galtan)
|
||||
r_spearman = corspearman(merged.val, merged.galtan)
|
||||
mae = mean(abs.(merged.val .- merged.galtan))
|
||||
rmse = sqrt(mean((merged.val .- merged.galtan).^2))
|
||||
|
||||
ci = correlation_ci(r_pearson, nrow(merged))
|
||||
|
||||
println("\n--- Overall Statistics ---")
|
||||
println(@sprintf(" Pearson r: %.4f [%.4f, %.4f]", r_pearson, ci.lower, ci.upper))
|
||||
println(@sprintf(" Spearman r: %.4f", r_spearman))
|
||||
println(@sprintf(" MAE: %.4f", mae))
|
||||
println(@sprintf(" RMSE: %.4f", rmse))
|
||||
println(@sprintf(" N: %d", nrow(merged)))
|
||||
|
||||
# Breakdown by project
|
||||
println("\n--- By Project ---")
|
||||
by_project = combine(groupby(merged, :project)) do df
|
||||
n = nrow(df)
|
||||
if n < 3
|
||||
return DataFrame(n=n, r_pearson=NaN, mae=NaN)
|
||||
end
|
||||
DataFrame(
|
||||
n = n,
|
||||
r_pearson = cor(df.val, df.galtan),
|
||||
r_spearman = corspearman(df.val, df.galtan),
|
||||
mae = mean(abs.(df.val .- df.galtan)),
|
||||
rmse = sqrt(mean((df.val .- df.galtan).^2))
|
||||
)
|
||||
end
|
||||
|
||||
for row in eachrow(sort(by_project, :n, rev=true))
|
||||
if !isnan(row.r_pearson)
|
||||
println(@sprintf(" %-10s: r=%.3f, MAE=%.3f, n=%d",
|
||||
row.project, row.r_pearson, row.mae, row.n))
|
||||
end
|
||||
end
|
||||
|
||||
# Breakdown by decade
|
||||
println("\n--- By Decade ---")
|
||||
merged.decade = div.(merged.year, 10) .* 10
|
||||
by_decade = combine(groupby(merged, :decade)) do df
|
||||
n = nrow(df)
|
||||
if n < 3
|
||||
return DataFrame(n=n, r_pearson=NaN, mae=NaN)
|
||||
end
|
||||
DataFrame(
|
||||
n = n,
|
||||
r_pearson = cor(df.val, df.galtan),
|
||||
mae = mean(abs.(df.val .- df.galtan))
|
||||
)
|
||||
end
|
||||
|
||||
for row in eachrow(sort(by_decade, :decade))
|
||||
if !isnan(row.r_pearson)
|
||||
println(@sprintf(" %ds: r=%.3f, MAE=%.3f, n=%d",
|
||||
row.decade, row.r_pearson, row.mae, row.n))
|
||||
end
|
||||
end
|
||||
|
||||
return (
|
||||
dimension = "galtan",
|
||||
r_pearson = r_pearson,
|
||||
r_spearman = r_spearman,
|
||||
ci_lower = ci.lower,
|
||||
ci_upper = ci.upper,
|
||||
mae = mae,
|
||||
rmse = rmse,
|
||||
n = nrow(merged),
|
||||
by_project = by_project,
|
||||
by_decade = by_decade
|
||||
)
|
||||
end
|
||||
|
||||
function validate_discriminant(model::DataFrame, expert::DataFrame)
|
||||
"""Compute cross-dimension correlations for discriminant validity (Campbell & Fiske 1959 MTMM)"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("DISCRIMINANT VALIDITY: Cross-dimension correlations")
|
||||
println("="^60)
|
||||
println("\nCampbell & Fiske (1959) MTMM framework:")
|
||||
println(" Convergent: same dimension, different method → HIGH")
|
||||
println(" Discriminant: different dimension, different method → LOW")
|
||||
println()
|
||||
|
||||
# Get party column
|
||||
if hasproperty(model, :party_id)
|
||||
model_econ = select(model, :party_id => :party, :year, :economic_lr)
|
||||
model_gal = select(model, :party_id => :party, :year, :galtan)
|
||||
else
|
||||
model_econ = select(model, :party, :year, :economic_lr)
|
||||
model_gal = select(model, :party, :year, :galtan)
|
||||
end
|
||||
|
||||
results = []
|
||||
|
||||
# 1. Expert economic vs Model economic (convergent - already computed, include for matrix)
|
||||
econ_vars = filter(v -> startswith(v, "lrecon_"), unique(expert.var))
|
||||
econ_expert = filter(row -> row.var in econ_vars, expert)
|
||||
merged_ee = innerjoin(econ_expert, model_econ, on=[:party, :year])
|
||||
if nrow(merged_ee) >= 10
|
||||
r = cor(merged_ee.val, merged_ee.economic_lr)
|
||||
push!(results, (model_dim="economic_lr", expert_dim="economic",
|
||||
r_pearson=r, r_spearman=corspearman(merged_ee.val, merged_ee.economic_lr),
|
||||
n=nrow(merged_ee), type="convergent"))
|
||||
@printf(" Model Economic × Expert Economic: r = %.3f (convergent, n=%d)\n", r, nrow(merged_ee))
|
||||
end
|
||||
|
||||
# 2. Expert economic vs Model galtan (discriminant)
|
||||
merged_eg = innerjoin(econ_expert, model_gal, on=[:party, :year])
|
||||
if nrow(merged_eg) >= 10
|
||||
r = cor(merged_eg.val, merged_eg.galtan)
|
||||
push!(results, (model_dim="galtan", expert_dim="economic",
|
||||
r_pearson=r, r_spearman=corspearman(merged_eg.val, merged_eg.galtan),
|
||||
n=nrow(merged_eg), type="discriminant"))
|
||||
@printf(" Model GAL-TAN × Expert Economic: r = %.3f (discriminant, n=%d)\n", r, nrow(merged_eg))
|
||||
end
|
||||
|
||||
# 3. Expert galtan vs Model galtan (convergent - already computed, include for matrix)
|
||||
galtan_vars = filter(v -> occursin("galtan", v) || occursin("libcon", v), unique(expert.var))
|
||||
galtan_expert = filter(row -> row.var in galtan_vars, expert)
|
||||
merged_gg = innerjoin(galtan_expert, model_gal, on=[:party, :year])
|
||||
if nrow(merged_gg) >= 10
|
||||
r = cor(merged_gg.val, merged_gg.galtan)
|
||||
push!(results, (model_dim="galtan", expert_dim="galtan",
|
||||
r_pearson=r, r_spearman=corspearman(merged_gg.val, merged_gg.galtan),
|
||||
n=nrow(merged_gg), type="convergent"))
|
||||
@printf(" Model GAL-TAN × Expert GAL-TAN: r = %.3f (convergent, n=%d)\n", r, nrow(merged_gg))
|
||||
end
|
||||
|
||||
# 4. Expert galtan vs Model economic (discriminant)
|
||||
merged_ge = innerjoin(galtan_expert, model_econ, on=[:party, :year])
|
||||
if nrow(merged_ge) >= 10
|
||||
r = cor(merged_ge.val, merged_ge.economic_lr)
|
||||
push!(results, (model_dim="economic_lr", expert_dim="galtan",
|
||||
r_pearson=r, r_spearman=corspearman(merged_ge.val, merged_ge.economic_lr),
|
||||
n=nrow(merged_ge), type="discriminant"))
|
||||
@printf(" Model Economic × Expert GAL-TAN: r = %.3f (discriminant, n=%d)\n", r, nrow(merged_ge))
|
||||
end
|
||||
|
||||
println()
|
||||
println("MTMM Matrix:")
|
||||
println(" Expert Economic Expert GAL-TAN")
|
||||
for r in results
|
||||
if r.model_dim == "economic_lr" && r.expert_dim == "economic"
|
||||
@printf(" Model Economic: %.3f ", r.r_pearson)
|
||||
end
|
||||
end
|
||||
for r in results
|
||||
if r.model_dim == "economic_lr" && r.expert_dim == "galtan"
|
||||
@printf("%.3f\n", r.r_pearson)
|
||||
end
|
||||
end
|
||||
for r in results
|
||||
if r.model_dim == "galtan" && r.expert_dim == "economic"
|
||||
@printf(" Model GAL-TAN: %.3f ", r.r_pearson)
|
||||
end
|
||||
end
|
||||
for r in results
|
||||
if r.model_dim == "galtan" && r.expert_dim == "galtan"
|
||||
@printf("%.3f\n", r.r_pearson)
|
||||
end
|
||||
end
|
||||
|
||||
return DataFrame(results)
|
||||
end
|
||||
|
||||
function save_validation_results(results::Vector, output_dir::String="validation";
|
||||
discriminant::Union{DataFrame, Nothing}=nothing)
|
||||
"""Save validation results to CSV files"""
|
||||
|
||||
if !isdir(output_dir)
|
||||
mkpath(output_dir)
|
||||
end
|
||||
|
||||
timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
|
||||
|
||||
# Summary table
|
||||
summary_rows = []
|
||||
for r in results
|
||||
if r !== nothing
|
||||
push!(summary_rows, (
|
||||
dimension = r.dimension,
|
||||
r_pearson = r.r_pearson,
|
||||
r_spearman = r.r_spearman,
|
||||
ci_lower = r.ci_lower,
|
||||
ci_upper = r.ci_upper,
|
||||
mae = r.mae,
|
||||
rmse = r.rmse,
|
||||
n = r.n
|
||||
))
|
||||
end
|
||||
end
|
||||
|
||||
if !isempty(summary_rows)
|
||||
summary_df = DataFrame(summary_rows)
|
||||
summary_file = joinpath(output_dir, "convergent_summary_$timestamp.csv")
|
||||
CSV.write(summary_file, summary_df)
|
||||
println("\nSaved: $summary_file")
|
||||
end
|
||||
|
||||
# By-project tables
|
||||
for r in results
|
||||
if r !== nothing && hasproperty(r, :by_project) && r.by_project !== nothing
|
||||
project_file = joinpath(output_dir, "convergent_$(r.dimension)_by_project_$timestamp.csv")
|
||||
CSV.write(project_file, r.by_project)
|
||||
println("Saved: $project_file")
|
||||
end
|
||||
end
|
||||
|
||||
# By-decade tables
|
||||
for r in results
|
||||
if r !== nothing && hasproperty(r, :by_decade) && r.by_decade !== nothing
|
||||
decade_file = joinpath(output_dir, "convergent_$(r.dimension)_by_decade_$timestamp.csv")
|
||||
CSV.write(decade_file, r.by_decade)
|
||||
println("Saved: $decade_file")
|
||||
end
|
||||
end
|
||||
|
||||
# Discriminant validity table
|
||||
if discriminant !== nothing && nrow(discriminant) > 0
|
||||
disc_file = joinpath(output_dir, "discriminant_summary_$timestamp.csv")
|
||||
CSV.write(disc_file, discriminant)
|
||||
println("Saved: $disc_file")
|
||||
end
|
||||
|
||||
return summary_rows
|
||||
end
|
||||
|
||||
function print_claassen_comparison(results::Vector)
|
||||
"""Print comparison with Claassen (2019) benchmarks"""
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("COMPARISON WITH CLAASSEN (2019) BENCHMARKS")
|
||||
println("="^60)
|
||||
|
||||
println("\nClaassen's results (mood estimates vs survey data):")
|
||||
println(" Pearson r: 0.50-0.57")
|
||||
println(" MAE: ~0.06 (6 pp on 0-1 scale)")
|
||||
println()
|
||||
|
||||
println("Our target (party positions, should be HIGHER than mood):")
|
||||
println(" Pearson r > 0.80 with expert surveys")
|
||||
println(" MAE < 0.15 (reasonable measurement error)")
|
||||
println()
|
||||
|
||||
println("-"^60)
|
||||
@printf("%-15s %8s %8s %8s %8s\n", "Dimension", "r", "Target", "MAE", "Status")
|
||||
println("-"^60)
|
||||
|
||||
for r in results
|
||||
if r !== nothing
|
||||
status = r.r_pearson > 0.80 ? "PASS" : (r.r_pearson > 0.70 ? "OK" : "LOW")
|
||||
@printf("%-15s %8.3f %8s %8.3f %8s\n",
|
||||
r.dimension, r.r_pearson, "> 0.80", r.mae, status)
|
||||
end
|
||||
end
|
||||
println("-"^60)
|
||||
end
|
||||
|
||||
# Main execution
|
||||
function main()
|
||||
println("="^60)
|
||||
println("CONVERGENT VALIDITY: Model vs Expert Surveys")
|
||||
println("="^60)
|
||||
println("Following Claassen (2019) validation framework")
|
||||
println()
|
||||
|
||||
# Load data
|
||||
model, model_file = load_model_output()
|
||||
expert, _ = load_expert_data()
|
||||
|
||||
println("\nModel output:")
|
||||
println(" Rows: $(nrow(model))")
|
||||
println(" Columns: $(names(model))")
|
||||
|
||||
# Run validations
|
||||
results = []
|
||||
|
||||
push!(results, validate_economic_lr(model, expert))
|
||||
push!(results, validate_galtan(model, expert))
|
||||
|
||||
# Run discriminant validity
|
||||
discriminant = validate_discriminant(model, expert)
|
||||
|
||||
# Save results
|
||||
save_validation_results(results; discriminant=discriminant)
|
||||
|
||||
# Print Claassen comparison
|
||||
print_claassen_comparison(results)
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("VALIDATION COMPLETE")
|
||||
println("="^60)
|
||||
|
||||
return results
|
||||
end
|
||||
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
main()
|
||||
end
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env julia
|
||||
#############################################################################
|
||||
## validate_uncertainty.jl
|
||||
## Uncertainty validation: Posterior Predictive Coverage (PPC)
|
||||
##
|
||||
## Following Claassen (2019), this script computes:
|
||||
## - PPC: What % of expert values fall within 95% posterior predictive interval?
|
||||
## - Also computes 80% PPC for direct Claassen comparison (he reports 60.3%)
|
||||
## - Wilson score CI for coverage proportion
|
||||
## - Breakdown by survey source and decade
|
||||
##
|
||||
## Unlike credible interval coverage (which checks θ-CIs), posterior predictive
|
||||
## coverage simulates what a new expert observation would look like given the
|
||||
## model's beta likelihood, accounting for both position uncertainty AND
|
||||
## measurement noise. Well-calibrated models should yield ~95% PPC at 95%.
|
||||
##
|
||||
## No model re-run needed: reads θ, γ, and φ from existing chain CSV files.
|
||||
#############################################################################
|
||||
|
||||
using CSV, DataFrames, Statistics, Dates, Printf, Random, JSON
|
||||
|
||||
# =============================================================================
|
||||
# Utility: Wilson score CI for a proportion
|
||||
# =============================================================================
|
||||
|
||||
function wilson_ci(p, n; alpha=0.05)
|
||||
if n == 0
|
||||
return (lower=NaN, upper=NaN, se=NaN)
|
||||
end
|
||||
z = 1.96 # For 95% CI
|
||||
denominator = 1 + z^2/n
|
||||
center = (p + z^2/(2n)) / denominator
|
||||
margin = z * sqrt((p*(1-p) + z^2/(4n))/n) / denominator
|
||||
se = sqrt(p * (1-p) / n)
|
||||
return (lower=center - margin, upper=center + margin, se=se)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 0: Find latest model run
|
||||
# =============================================================================
|
||||
|
||||
function find_latest_run(base_dir::String="model_outputs")
|
||||
if !isdir(base_dir)
|
||||
error("Model outputs directory not found: $base_dir")
|
||||
end
|
||||
runs = filter(d -> startswith(d, "run_") && isdir(joinpath(base_dir, d)), readdir(base_dir))
|
||||
if isempty(runs)
|
||||
error("No runs found in $base_dir")
|
||||
end
|
||||
sort!(runs, rev=true)
|
||||
latest = joinpath(base_dir, runs[1])
|
||||
println("Using latest run: $latest")
|
||||
return latest
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 1: Load expert_dim.csv from model run data
|
||||
# =============================================================================
|
||||
|
||||
function load_expert_dim(run_dir::String)
|
||||
expert_dim_file = joinpath(run_dir, "data", "expert_dim.csv")
|
||||
if !isfile(expert_dim_file)
|
||||
error("expert_dim.csv not found in $run_dir/data/")
|
||||
end
|
||||
expert_dim = CSV.read(expert_dim_file, DataFrame)
|
||||
println("Loaded expert_dim.csv: $(nrow(expert_dim)) observations")
|
||||
println(" Unique rr values: $(length(unique(expert_dim.rr_exp_dim)))")
|
||||
println(" Item indices (var_exp_dim): $(sort(unique(expert_dim.var_exp_dim)))")
|
||||
println(" Dimensions (dim_idx_exp): $(sort(unique(expert_dim.dim_idx_exp)))")
|
||||
return expert_dim
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 2: Selectively load chain columns
|
||||
# =============================================================================
|
||||
|
||||
function load_chains_selective(run_dir::String, needed_rr::Set{Int}, K::Int)
|
||||
"""Load only the chain columns we need for posterior predictive checks."""
|
||||
chains_dir = joinpath(run_dir, "chains")
|
||||
chain_files = sort(filter(f -> endswith(f, ".csv") && startswith(f, "chain_"), readdir(chains_dir)))
|
||||
|
||||
if isempty(chain_files)
|
||||
error("No chain files found in $chains_dir")
|
||||
end
|
||||
println("\nLoading $(length(chain_files)) chain files (selective columns)...")
|
||||
|
||||
# Build the set of column names we need
|
||||
needed_cols = Set{String}()
|
||||
|
||||
# theta columns: economic_lr.{rr} and galtan.{rr} for each unique rr
|
||||
for rr in needed_rr
|
||||
push!(needed_cols, "economic_lr.$rr")
|
||||
push!(needed_cols, "galtan.$rr")
|
||||
end
|
||||
|
||||
# Item parameters: gamma_exp_intercept.1-K, gamma_exp_slope.1-K
|
||||
for k in 1:K
|
||||
push!(needed_cols, "gamma_exp_intercept.$k")
|
||||
push!(needed_cols, "gamma_exp_slope.$k")
|
||||
end
|
||||
|
||||
# Precision parameter
|
||||
push!(needed_cols, "phi_exp_dim")
|
||||
|
||||
println(" Need $(length(needed_cols)) columns ($(length(needed_rr)) rr × 2 dims + $(2*K) item params + 1 phi)")
|
||||
|
||||
# Read the header from first chain to identify column indices
|
||||
first_chain_path = joinpath(chains_dir, chain_files[1])
|
||||
header_line = ""
|
||||
open(first_chain_path) do f
|
||||
for line in eachline(f)
|
||||
if !startswith(line, "#")
|
||||
header_line = line
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
all_cols = split(header_line, ",")
|
||||
col_indices = Int[]
|
||||
col_names = String[]
|
||||
|
||||
for (i, col) in enumerate(all_cols)
|
||||
if col in needed_cols
|
||||
push!(col_indices, i)
|
||||
push!(col_names, col)
|
||||
end
|
||||
end
|
||||
|
||||
println(" Found $(length(col_indices))/$(length(needed_cols)) columns in chains")
|
||||
|
||||
if length(col_indices) < length(needed_cols)
|
||||
missing_cols = setdiff(needed_cols, Set(col_names))
|
||||
n_missing = length(missing_cols)
|
||||
sample = collect(missing_cols)[1:min(5, n_missing)]
|
||||
println(" WARNING: Missing columns (showing $( min(5, n_missing))/$n_missing): $sample")
|
||||
end
|
||||
|
||||
# Build a type specification for selective reading
|
||||
# We'll use CSV.read with select parameter
|
||||
select_symbols = Symbol.(col_names)
|
||||
|
||||
all_chains = DataFrame[]
|
||||
for (i, cf) in enumerate(chain_files)
|
||||
path = joinpath(chains_dir, cf)
|
||||
print(" Loading chain $i: $(cf)... ")
|
||||
t = @elapsed begin
|
||||
chain = CSV.read(path, DataFrame; comment="#", select=select_symbols)
|
||||
end
|
||||
println("$(nrow(chain)) samples, $(round(t, digits=1))s")
|
||||
push!(all_chains, chain)
|
||||
end
|
||||
|
||||
combined = vcat(all_chains...)
|
||||
println("Combined: $(nrow(combined)) total posterior draws")
|
||||
return combined
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3: Compute posterior predictive coverage
|
||||
# =============================================================================
|
||||
|
||||
function compute_posterior_predictive_cic(chains::DataFrame, expert_dim::DataFrame;
|
||||
ci_level::Float64=0.95, seed::Int=42)
|
||||
"""
|
||||
Compute posterior predictive coverage for expert dimension observations.
|
||||
|
||||
For each expert observation n with observed value y_n:
|
||||
1. For each posterior draw s:
|
||||
- Get theta_s = theta[dim, rr] (on logit scale, but chains store inv_logit)
|
||||
- Compute mu_s = invlogit(gamma_intercept[k] + gamma_slope[k] * logit(theta_s))
|
||||
- V4 (Beta): Draw y_pred_s ~ Beta(phi * mu_s, phi * (1 - mu_s))
|
||||
- V5 (Beta-Binomial): Draw y_pred_s ~ Beta(phi * K * mu_s, phi * K * (1 - mu_s))
|
||||
where K = n_experts for that observation
|
||||
2. Compute quantile interval of y_pred draws
|
||||
3. Check if y_n falls within interval
|
||||
|
||||
Returns DataFrame with one row per observation plus coverage indicator.
|
||||
"""
|
||||
rng = MersenneTwister(seed)
|
||||
|
||||
alpha_lower = (1 - ci_level) / 2
|
||||
alpha_upper = 1 - alpha_lower
|
||||
|
||||
N = nrow(expert_dim)
|
||||
S = nrow(chains) # total posterior draws
|
||||
|
||||
# Detect V5 (Beta-Binomial with K-scaling) by presence of n_experts column
|
||||
has_k_scaling = hasproperty(expert_dim, :n_experts)
|
||||
if has_k_scaling
|
||||
k_vec = expert_dim.n_experts
|
||||
println("\nV5 detected: using Beta(phi*K*mu, phi*K*(1-mu)) with per-observation K")
|
||||
else
|
||||
println("\nV4 detected: using Beta(phi*mu, phi*(1-mu))")
|
||||
end
|
||||
|
||||
println("Computing posterior predictive coverage ($(round(Int, 100*ci_level))% level)")
|
||||
println(" Expert observations: $N")
|
||||
println(" Posterior draws: $S")
|
||||
|
||||
# Pre-extract phi vector
|
||||
phi_vec = chains[!, :phi_exp_dim]
|
||||
|
||||
# Pre-extract gamma vectors for each item k
|
||||
K = maximum(expert_dim.var_exp_dim)
|
||||
gamma_int = Dict{Int, Vector{Float64}}()
|
||||
gamma_slope = Dict{Int, Vector{Float64}}()
|
||||
for k in 1:K
|
||||
col_int = Symbol("gamma_exp_intercept.$k")
|
||||
col_slope = Symbol("gamma_exp_slope.$k")
|
||||
if hasproperty(chains, col_int) && hasproperty(chains, col_slope)
|
||||
gamma_int[k] = chains[!, col_int]
|
||||
gamma_slope[k] = chains[!, col_slope]
|
||||
end
|
||||
end
|
||||
|
||||
# Allocate result columns
|
||||
covered = BitVector(undef, N)
|
||||
pred_lower = Vector{Float64}(undef, N)
|
||||
pred_upper = Vector{Float64}(undef, N)
|
||||
pred_median = Vector{Float64}(undef, N)
|
||||
|
||||
# Pre-allocate per-observation draw buffer
|
||||
y_pred = Vector{Float64}(undef, S)
|
||||
|
||||
prog_interval = max(1, N ÷ 20)
|
||||
|
||||
for n in 1:N
|
||||
if n % prog_interval == 0 || n == N
|
||||
pct = round(100 * n / N, digits=1)
|
||||
print("\r Progress: $pct% ($n / $N)")
|
||||
end
|
||||
|
||||
rr = expert_dim.rr_exp_dim[n]
|
||||
dim = expert_dim.dim_idx_exp[n]
|
||||
k = expert_dim.var_exp_dim[n]
|
||||
y_obs = expert_dim.val[n]
|
||||
|
||||
# Get theta column (chains store inv_logit(theta), i.e. on [0,1] scale)
|
||||
theta_col = dim == 1 ? Symbol("economic_lr.$rr") : Symbol("galtan.$rr")
|
||||
|
||||
if !hasproperty(chains, theta_col) || !haskey(gamma_int, k)
|
||||
# Missing chain data — mark as not covered
|
||||
covered[n] = false
|
||||
pred_lower[n] = NaN
|
||||
pred_upper[n] = NaN
|
||||
pred_median[n] = NaN
|
||||
continue
|
||||
end
|
||||
|
||||
theta_star_vec = chains[!, theta_col] # inv_logit(theta), i.e. on [0,1]
|
||||
g_int = gamma_int[k]
|
||||
g_slope = gamma_slope[k]
|
||||
|
||||
# Effective concentration: phi for V4, phi * n_experts for V5
|
||||
k_mult = has_k_scaling ? Float64(k_vec[n]) : 1.0
|
||||
|
||||
# For each posterior draw, simulate a predictive observation
|
||||
for s in 1:S
|
||||
theta_star = theta_star_vec[s]
|
||||
|
||||
# Convert back to latent scale for linear predictor
|
||||
# theta_star is inv_logit(theta), so theta = logit(theta_star)
|
||||
# Clamp to avoid Inf
|
||||
theta_star_clamped = clamp(theta_star, 1e-10, 1 - 1e-10)
|
||||
theta_latent = log(theta_star_clamped / (1 - theta_star_clamped))
|
||||
|
||||
# Linear predictor
|
||||
lin = g_int[s] + g_slope[s] * theta_latent
|
||||
|
||||
# Mean of beta
|
||||
mu = 1 / (1 + exp(-lin))
|
||||
mu = clamp(mu, 1e-6, 1 - 1e-6)
|
||||
|
||||
# Beta parameters: phi * K * mu for V5, phi * mu for V4
|
||||
phi = phi_vec[s] * k_mult
|
||||
a = phi * mu
|
||||
b = phi * (1 - mu)
|
||||
|
||||
# Draw from Beta(a, b) via gamma method (no Distributions.jl needed)
|
||||
y_pred[s] = _rand_beta(rng, a, b)
|
||||
end
|
||||
|
||||
# Compute predictive interval
|
||||
sort!(y_pred)
|
||||
idx_lo = max(1, round(Int, alpha_lower * S))
|
||||
idx_hi = min(S, round(Int, alpha_upper * S))
|
||||
idx_med = round(Int, 0.5 * S)
|
||||
|
||||
pred_lower[n] = y_pred[idx_lo]
|
||||
pred_upper[n] = y_pred[idx_hi]
|
||||
pred_median[n] = y_pred[idx_med]
|
||||
covered[n] = (y_obs >= pred_lower[n]) && (y_obs <= pred_upper[n])
|
||||
end
|
||||
println() # newline after progress
|
||||
|
||||
# Add results to a copy of expert_dim
|
||||
result = DataFrame(
|
||||
rr = expert_dim.rr_exp_dim,
|
||||
dim_idx = expert_dim.dim_idx_exp,
|
||||
var_idx = expert_dim.var_exp_dim,
|
||||
val = expert_dim.val,
|
||||
party = expert_dim.party,
|
||||
country = expert_dim.country,
|
||||
year = expert_dim.year,
|
||||
project = expert_dim.project,
|
||||
var = expert_dim.var,
|
||||
pred_lower = pred_lower,
|
||||
pred_upper = pred_upper,
|
||||
pred_median = pred_median,
|
||||
covered = covered
|
||||
)
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Beta random variate without Distributions.jl
|
||||
# =============================================================================
|
||||
|
||||
"""
|
||||
_rand_beta(rng, a, b)
|
||||
|
||||
Generate a Beta(a, b) random variate using the Gamma method:
|
||||
Beta(a,b) = X/(X+Y) where X ~ Gamma(a), Y ~ Gamma(b).
|
||||
Uses Marsaglia & Tsang (2000) for Gamma generation.
|
||||
"""
|
||||
function _rand_beta(rng::AbstractRNG, a::Float64, b::Float64)
|
||||
x = _rand_gamma(rng, a)
|
||||
y = _rand_gamma(rng, b)
|
||||
return x / (x + y)
|
||||
end
|
||||
|
||||
"""
|
||||
_rand_gamma(rng, shape)
|
||||
|
||||
Generate Gamma(shape, 1) random variate using Marsaglia & Tsang (2000).
|
||||
For shape < 1, uses the rejection method with shape+1 then scales.
|
||||
"""
|
||||
function _rand_gamma(rng::AbstractRNG, shape::Float64)
|
||||
if shape < 1.0
|
||||
# Gamma(a) = Gamma(a+1) * U^(1/a) where U ~ Uniform(0,1)
|
||||
return _rand_gamma(rng, shape + 1.0) * rand(rng)^(1.0 / shape)
|
||||
end
|
||||
|
||||
# Marsaglia & Tsang (2000) for shape >= 1
|
||||
d = shape - 1.0/3.0
|
||||
c = 1.0 / sqrt(9.0 * d)
|
||||
|
||||
while true
|
||||
local x::Float64
|
||||
local v::Float64
|
||||
|
||||
while true
|
||||
x = randn(rng)
|
||||
v = 1.0 + c * x
|
||||
if v > 0.0
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
v = v * v * v
|
||||
u = rand(rng)
|
||||
|
||||
if u < 1.0 - 0.0331 * x^2 * x^2
|
||||
return d * v
|
||||
end
|
||||
|
||||
if log(u) < 0.5 * x^2 + d * (1.0 - v + log(v))
|
||||
return d * v
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# STEP 4: Summarize and save results
|
||||
# =============================================================================
|
||||
|
||||
function summarize_coverage(result::DataFrame, ci_level::Float64)
|
||||
level_pct = round(Int, 100 * ci_level)
|
||||
println("\n" * "="^60)
|
||||
println("POSTERIOR PREDICTIVE COVERAGE ($level_pct%)")
|
||||
println("="^60)
|
||||
|
||||
# Overall by dimension
|
||||
dim_names = Dict(1 => "economic_lr", 2 => "galtan")
|
||||
|
||||
summary_rows = []
|
||||
for dim in sort(unique(result.dim_idx))
|
||||
subset = filter(r -> r.dim_idx == dim, result)
|
||||
n = nrow(subset)
|
||||
n_covered = sum(subset.covered)
|
||||
ppc = n_covered / n
|
||||
ci = wilson_ci(ppc, n)
|
||||
dim_name = dim_names[dim]
|
||||
|
||||
println(@sprintf("\n %-15s: %.1f%% [%.1f%%, %.1f%%] (%d/%d)",
|
||||
dim_name, 100*ppc, 100*ci.lower, 100*ci.upper, n_covered, n))
|
||||
|
||||
push!(summary_rows, (
|
||||
dimension = dim_name,
|
||||
cic = ppc,
|
||||
cic_pct = round(100 * ppc, digits=1),
|
||||
ci_lower = ci.lower,
|
||||
ci_upper = ci.upper,
|
||||
n = n,
|
||||
covered = n_covered
|
||||
))
|
||||
|
||||
# By project
|
||||
println("\n By survey source:")
|
||||
by_project = combine(groupby(subset, :project)) do df
|
||||
nc = sum(df.covered)
|
||||
DataFrame(n = nrow(df), covered = nc, cic = nc / nrow(df))
|
||||
end
|
||||
sort!(by_project, :n, rev=true)
|
||||
|
||||
@printf(" %-12s %6s %8s\n", "Project", "N", "PPC")
|
||||
for row in eachrow(by_project)
|
||||
@printf(" %-12s %6d %7.1f%%\n", row.project, row.n, 100*row.cic)
|
||||
end
|
||||
|
||||
# By decade
|
||||
subset_with_decade = copy(subset)
|
||||
subset_with_decade.decade = div.(subset_with_decade.year, 10) .* 10
|
||||
println("\n By decade:")
|
||||
by_decade = combine(groupby(subset_with_decade, :decade)) do df
|
||||
nc = sum(df.covered)
|
||||
DataFrame(n = nrow(df), covered = nc, cic = nc / nrow(df))
|
||||
end
|
||||
sort!(by_decade, :decade)
|
||||
|
||||
@printf(" %-8s %6s %8s\n", "Decade", "N", "PPC")
|
||||
for row in eachrow(by_decade)
|
||||
@printf(" %-8d %6d %7.1f%%\n", row.decade, row.n, 100*row.cic)
|
||||
end
|
||||
end
|
||||
|
||||
return summary_rows
|
||||
end
|
||||
|
||||
function save_results(result_95::DataFrame, summary_95, summary_80,
|
||||
by_project_95::Dict, output_dir::String="validation")
|
||||
if !isdir(output_dir)
|
||||
mkpath(output_dir)
|
||||
end
|
||||
|
||||
timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
|
||||
|
||||
# Summary table (95%)
|
||||
if !isempty(summary_95)
|
||||
summary_df = DataFrame(summary_95)
|
||||
summary_file = joinpath(output_dir, "uncertainty_cic_summary_$timestamp.csv")
|
||||
CSV.write(summary_file, summary_df)
|
||||
println("\nSaved: $summary_file")
|
||||
end
|
||||
|
||||
# Also save 80% summary
|
||||
if !isempty(summary_80)
|
||||
summary80_df = DataFrame(summary_80)
|
||||
summary80_file = joinpath(output_dir, "uncertainty_cic_80pct_summary_$timestamp.csv")
|
||||
CSV.write(summary80_file, summary80_df)
|
||||
println("Saved: $summary80_file")
|
||||
end
|
||||
|
||||
# By-project tables (95%)
|
||||
dim_names = Dict(1 => "economic_lr", 2 => "galtan")
|
||||
for (dim, bp) in by_project_95
|
||||
project_file = joinpath(output_dir, "uncertainty_$(dim_names[dim])_by_project_$timestamp.csv")
|
||||
CSV.write(project_file, bp)
|
||||
println("Saved: $project_file")
|
||||
end
|
||||
|
||||
return summary_95
|
||||
end
|
||||
|
||||
function print_claassen_comparison(summary_95, summary_80)
|
||||
println("\n" * "="^60)
|
||||
println("COMPARISON WITH CLAASSEN (2019) BENCHMARKS")
|
||||
println("="^60)
|
||||
|
||||
println("\nClaassen's result:")
|
||||
println(" CIC (80% CI): 60.3%")
|
||||
println(" (Using credible intervals for θ, not posterior predictive)")
|
||||
println()
|
||||
println("Our results (posterior predictive):")
|
||||
println()
|
||||
|
||||
println("-"^60)
|
||||
@printf("%-15s %10s %10s %8s\n", "Dimension", "PPC 95%", "PPC 80%", "Status")
|
||||
println("-"^60)
|
||||
|
||||
dim_map_80 = Dict(r.dimension => r for r in summary_80)
|
||||
|
||||
for r in summary_95
|
||||
ppc80 = haskey(dim_map_80, r.dimension) ? dim_map_80[r.dimension].cic : NaN
|
||||
# Well-calibrated: 95% PPC should be near 95%
|
||||
status = r.cic >= 0.90 ? "GOOD" : (r.cic >= 0.80 ? "OK" : "LOW")
|
||||
@printf("%-15s %9.1f%% %9.1f%% %8s\n",
|
||||
r.dimension, 100*r.cic, 100*ppc80, status)
|
||||
end
|
||||
println("-"^60)
|
||||
println()
|
||||
println("Interpretation:")
|
||||
println(" 95% PPC ~95% = well-calibrated uncertainty")
|
||||
println(" 80% PPC > 60% = exceeds Claassen (2019) benchmark")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
function main()
|
||||
println("="^60)
|
||||
println("UNCERTAINTY VALIDATION: Posterior Predictive Coverage")
|
||||
println("="^60)
|
||||
println("Following Claassen (2019) validation framework")
|
||||
println("Posterior predictive intervals account for both position")
|
||||
println("uncertainty AND observation-level measurement noise.")
|
||||
println()
|
||||
|
||||
# Step 0: Parse options and find run directory
|
||||
run_dir = nothing
|
||||
quick_mode = get(ENV, "QUICK_VALIDATION", "0") == "1"
|
||||
for (i, arg) in enumerate(ARGS)
|
||||
if arg == "--run-dir" && i < length(ARGS)
|
||||
run_dir = ARGS[i + 1]
|
||||
elseif startswith(arg, "--run-dir=")
|
||||
run_dir = split(arg, "=", limit=2)[2]
|
||||
elseif arg == "--quick"
|
||||
quick_mode = true
|
||||
end
|
||||
end
|
||||
if run_dir === nothing
|
||||
run_dir = find_latest_run()
|
||||
else
|
||||
println("Using specified run directory: $run_dir")
|
||||
end
|
||||
|
||||
# Step 1: Load expert_dim.csv
|
||||
expert_dim = load_expert_dim(run_dir)
|
||||
|
||||
# Step 2: Selectively load chains
|
||||
needed_rr = Set(expert_dim.rr_exp_dim)
|
||||
K = maximum(expert_dim.var_exp_dim)
|
||||
chains = load_chains_selective(run_dir, needed_rr, K)
|
||||
|
||||
# Step 3a: Compute 95% posterior predictive coverage
|
||||
result_95 = compute_posterior_predictive_cic(chains, expert_dim; ci_level=0.95)
|
||||
summary_95 = summarize_coverage(result_95, 0.95)
|
||||
|
||||
# Step 3b: Compute 80% posterior predictive coverage (Claassen benchmark)
|
||||
# Recompute coverage from the same predictive draws but with 80% quantiles
|
||||
if quick_mode
|
||||
println("\nQUICK MODE: skipping 80% PPC recomputation")
|
||||
summary_80 = [(dimension=r.dimension, n=r.n, covered=r.covered, cic=NaN, ci_level=0.80) for r in summary_95]
|
||||
else
|
||||
println("\n" * "="^60)
|
||||
println("RECOMPUTING WITH 80% LEVEL (Claassen comparison)")
|
||||
println("="^60)
|
||||
result_80 = compute_posterior_predictive_cic(chains, expert_dim; ci_level=0.80, seed=42)
|
||||
summary_80 = summarize_coverage(result_80, 0.80)
|
||||
end
|
||||
|
||||
# Build by-project tables for 95%
|
||||
dim_names = Dict(1 => "economic_lr", 2 => "galtan")
|
||||
by_project_95 = Dict{Int, DataFrame}()
|
||||
for dim in sort(unique(result_95.dim_idx))
|
||||
subset = filter(r -> r.dim_idx == dim, result_95)
|
||||
bp = combine(groupby(subset, :project)) do df
|
||||
nc = sum(df.covered)
|
||||
DataFrame(n = nrow(df), covered = nc, cic = nc / nrow(df))
|
||||
end
|
||||
sort!(bp, :n, rev=true)
|
||||
by_project_95[dim] = bp
|
||||
end
|
||||
|
||||
# Step 4: Save results
|
||||
save_results(result_95, summary_95, summary_80, by_project_95)
|
||||
|
||||
# Step 5: Print Claassen comparison
|
||||
print_claassen_comparison(summary_95, summary_80)
|
||||
|
||||
println("\n" * "="^60)
|
||||
println("VALIDATION COMPLETE")
|
||||
println("="^60)
|
||||
|
||||
return (summary_95=summary_95, summary_80=summary_80)
|
||||
end
|
||||
|
||||
if abspath(PROGRAM_FILE) == @__FILE__
|
||||
main()
|
||||
end
|
||||
Reference in New Issue
Block a user