Document raw source file reference

This commit is contained in:
aseimel
2026-06-15 11:33:18 +02:00
commit b5ca9370f1
61 changed files with 293618 additions and 0 deletions
+593
View File
@@ -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