#!/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 left-right and cultural cosmopolitan--traditionalist positions 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 left-right and cultural cosmopolitan--traditionalist positions 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 economic=%.2f%s [%.2f-%.2f] cultural=%.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, :segment_num => (x -> length(unique(x))) => :n_segments) parties_multi_segment = filter(:n_segments => >(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 inspect 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/cosmopolitan, 1 = right/traditionalist):") println(f, " economic_lr: Economic left-right position (directly estimated)") println(f, " galtan: Cultural cosmopolitan--traditionalist 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 cultural position") println(f, " traditional: Traditionalist cultural position") println(f, "") println(f, "Derived bipolar scales (0 = left/cosmopolitan, 1 = right/traditionalist):") println(f, " economic_lr: Economic left-right (derived from pro_market - pro_welfare)") println(f, " galtan: Cultural cosmopolitan--traditionalist (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") return csv_file, meta_file end # ============================================================================= # 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: Party-position model") 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