""" LOO cross-validation on PDE residuals for lengthscale selection. Idea: condition on BCs + observations only (partial posterior), then compute leave-one-out pseudo-likelihood of PDE constraints under the partial posterior. Optimize lengthscale to maximize this LOO score. Analogy with ODE filters: the Kalman filter innovation calibrates the output scale by asking "how surprising is this residual given everything seen so far?" LOO-CV emulates this in a spatial/batch setting: "how surprising is this cell's PDE residual given all other cells?" Usage: julia --project=../.. loo_lengthscale.jl julia --project=../.. loo_lengthscale.jl -N 31 --n-lengthscales 20 """ using LinearAlgebra, SparseArrays using Random using ArgParse using NPZ using Statistics using Printf using CairoMakie push!(LOAD_PATH, joinpath(@__DIR__, "..", "..")) using GPFiniteVolume using FunctionalGPs, GaussianMarkovRandomFields import GaussianMarkovRandomFields: mean, std import FunctionalGPs: ⊗ include("run_gpfvm.jl") # Reuse ground truth generation from scalability study function setup_2d_grid(Nx, Ny, domain) x_min, x_max, y_min, y_max = domain xs = range(x_min, x_max, length=Nx) ys = range(y_min, y_max, length=Ny) return collect(xs), collect(ys) end function solve_forward_problem(xs, ys, prob::SourceIdentificationProblem) Nx, Ny = length(xs), length(ys) Δx = xs[2] - xs[1] Δy = ys[2] - ys[1] n_cells_x, n_cells_y = Nx - 1, Ny - 1 s_int_true = zeros(n_cells_x, n_cells_y) for cj in 1:n_cells_y cy = 0.5 * (ys[cj] + ys[cj+1]) for ci in 1:n_cells_x cx = 0.5 * (xs[ci] + xs[ci+1]) s_int_true[ci, cj] = evaluate_source(prob, cx, cy) * Δx * Δy end end n_nodes = Nx * Ny node_idx(i, j) = (j - 1) * Nx + i rows = Int[]; cols = Int[]; vals = Float64[] b = zeros(n_nodes) for j in 1:Ny, i in 1:Nx idx = node_idx(i, j) if i == 1 push!(rows, idx); push!(cols, idx); push!(vals, 1.0) b[idx] = prob.c_inflow elseif i == Nx push!(rows, idx); push!(cols, idx); push!(vals, 1.0) push!(rows, idx); push!(cols, node_idx(i-1, j)); push!(vals, -1.0) elseif j == 1 push!(rows, idx); push!(cols, idx); push!(vals, 1.0) push!(rows, idx); push!(cols, node_idx(i, j+1)); push!(vals, -1.0) elseif j == Ny push!(rows, idx); push!(cols, idx); push!(vals, 1.0) push!(rows, idx); push!(cols, node_idx(i, j-1)); push!(vals, -1.0) else push!(rows, idx); push!(cols, idx); push!(vals, prob.vx * Δy) push!(rows, idx); push!(cols, node_idx(i-1, j)); push!(vals, -prob.vx * Δy) diff_coef = prob.D / Δx * Δy diff_coef_y = prob.D / Δy * Δx push!(rows, idx); push!(cols, idx); push!(vals, 2diff_coef + 2diff_coef_y) push!(rows, idx); push!(cols, node_idx(i+1, j)); push!(vals, -diff_coef) push!(rows, idx); push!(cols, node_idx(i-1, j)); push!(vals, -diff_coef) push!(rows, idx); push!(cols, node_idx(i, j+1)); push!(vals, -diff_coef_y) push!(rows, idx); push!(cols, node_idx(i, j-1)); push!(vals, -diff_coef_y) source = 0.0 for (ci, cj) in [(i-1, j-1), (i, j-1), (i-1, j), (i, j)] if 1 <= ci <= n_cells_x && 1 <= cj <= n_cells_y source += 0.25 * s_int_true[ci, cj] end end b[idx] = source end end A = sparse(rows, cols, vals, n_nodes, n_nodes) c_true = reshape(A \ b, Nx, Ny) return c_true, s_int_true end function generate_observations(xs, ys, c_true, prob::SourceIdentificationProblem) Random.seed!(prob.noise_seed) obs_xs, obs_ys = observation_coords(prob) obs_ix = [argmin(abs.(xs .- ox)) for ox in obs_xs] obs_iy = [argmin(abs.(ys .- oy)) for oy in obs_ys] true_c = [c_true[ix, iy] for (ix, iy) in zip(obs_ix, obs_iy)] return obs_xs, obs_ys, true_c, true_c .+ prob.noise_std * randn(length(obs_xs)) end # ============================================================================== # LOO score computation # ============================================================================== """ Compute LOO pseudo-log-likelihood of PDE constraints under the partial posterior (conditioned on BCs + observations only). Returns (loo_score, n_fvm_constraints, pde_residual_norm). """ function compute_loo_score(prob::SourceIdentificationProblem, data::Dict; ρ::Real, lengthscale_c::Real, lengthscale_s::Real, output_scale::Real=1.0, smoothness::Int=2, source_amplitude::Real=1.0, constraint_noise::Real=1e-5) xs = data["xs"]; ys = data["ys"] Nx, Ny = length(xs), length(ys) n_cells_x, n_cells_y = Nx - 1, Ny - 1 x_intervals = intervals_from_endpoints(collect(xs)) y_intervals = intervals_from_endpoints(collect(ys)) grid = FactorizedGrid(xs, ys) cells_2d = x_intervals ⊗ y_intervals # Kernels k_c = HalfIntegerMaternKernel(smoothness, [lengthscale_c]) ⊗ HalfIntegerMaternKernel(smoothness, [lengthscale_c]) k_s = HalfIntegerMaternKernel(smoothness, [lengthscale_s]) ⊗ HalfIntegerMaternKernel(smoothness, [lengthscale_s]) # Functionals L_c_eval = EvaluationFunctional(grid) L_c_vert = EvaluationFunctional(xs) ⊗ VectorizedLebesgueIntegral(y_intervals) L_c_horiz = VectorizedLebesgueIntegral(x_intervals) ⊗ EvaluationFunctional(ys) L_c_dx_vert = L_c_vert ∘ PartialDerivative((1, 0)) L_c_dy_horiz = L_c_horiz ∘ PartialDerivative((0, 1)) L_s_eval = EvaluationFunctional(grid) L_s_int = VectorizedLebesgueIntegral(cells_2d) # Sparse precisions approx_c = sparse_precision([ :c => L_c_eval, :c_vert => L_c_vert, :c_horiz => L_c_horiz, :c_dx_vert => L_c_dx_vert, :c_dy_horiz => L_c_dy_horiz, ], k_c; ρ=ρ, ordering=:integrals_coarsest) approx_s = sparse_precision([ :s => L_s_eval, :s_int => L_s_int, ], k_s; ρ=4.0, ordering=:integrals_coarsest) n_c = approx_c.info.n n_s = approx_s.info.n n_total = n_c + n_s # Scale prior precision by 1/output_scale (covariance scales by output_scale) Q_joint = blockdiag(sparse(approx_c.Q), sparse(approx_s.Q) / source_amplitude^2) / output_scale layout_c = approx_c.layout layout_s = approx_s.layout # FVM and BC constraint matrices (separate!) A_fvm, b_fvm = build_fvm_constraint(xs, ys, prob, layout_c, layout_s, n_c) A_bc, b_bc = build_boundary_constraints(xs, ys, prob, layout_c, n_c, n_total) n_fvm = size(A_fvm, 1) n_bc = size(A_bc, 1) # Observation matrix obs_x = data["obs_x"]; obs_y = data["obs_y"] obs_c = data["obs_c"]; noise_std = Float64(data["noise_std"]) n_obs = length(obs_c) obs_ix = [argmin(abs.(xs .- ox)) for ox in obs_x] obs_iy = [argmin(abs.(ys .- oy)) for oy in obs_y] obs_indices = [indices(layout_c, :c)[(iy-1)*Nx + ix] for (ix, iy) in zip(obs_ix, obs_iy)] A_obs = spzeros(n_obs, n_total) for (i, idx) in enumerate(obs_indices) A_obs[i, idx] = 1.0 end # Condition on PDE + BCs (model structure), LOO over real observations only Q_bc = (1.0 / constraint_noise^2) * sparse(I, n_bc, n_bc) Q_fvm = (1.0 / constraint_noise^2) * sparse(I, n_fvm, n_fvm) Q_model = Q_joint + A_bc' * Q_bc * A_bc + A_fvm' * Q_fvm * A_fvm rhs_model = A_bc' * Q_bc * b_bc # b_fvm = 0, so no PDE rhs contribution # Factorize PDE+BC-conditioned prior F = cholesky(Symmetric(Q_model)) μ_model = F \ rhs_model # LOO over the 10 real observations # Marginal distribution of obs under the PDE-conditioned prior: # y_obs ~ N(A_obs μ_model, A_obs Σ_model A_obs^T + σ²_obs I) z_obs = A_obs * μ_model # predicted observations X = F \ Matrix(A_obs') # Σ_model A_obs^T, via n_obs sparse solves S = A_obs * X + noise_std^2 * I(n_obs) # 10×10 marginal covariance S = Symmetric(S) # LOO-CV (Rasmussen & Williams, Eq. 5.12) r = obs_c - z_obs # observation residuals F_S = cholesky(S) α = F_S \ r S_inv_diag = diag(inv(F_S)) # LOO pseudo-log-likelihood loo_obs = -0.5 * sum(i -> -log(S_inv_diag[i]) + α[i]^2 / S_inv_diag[i], 1:n_obs) # Also compute PDE residual norm for diagnostics pde_residual_norm = norm(A_fvm * μ_model) return (; loo_total=loo_obs, loo_pde=0.0, loo_obs, n_fvm, n_obs, pde_residual_norm) end # ============================================================================== # Main experiment # ============================================================================== function run_loo_experiment(; problem_path::String, N::Int, ρ::Float64, lengthscales_c::Vector{Float64}, lengthscales_s::Vector{Float64}, output_dir::String) println("=" ^ 60) println("LOO Lengthscale Selection Experiment") println("=" ^ 60) prob = load_problem(problem_path) println(prob) # Generate data at this N xs, ys = setup_2d_grid(N, N, prob.domain) c_true, _ = solve_forward_problem(xs, ys, prob) obs_xs, obs_ys, _, obs_c = generate_observations(xs, ys, c_true, prob) data = Dict{String, Any}( "xs" => xs, "ys" => ys, "obs_x" => obs_xs, "obs_y" => obs_ys, "obs_c" => obs_c, "noise_std" => prob.noise_std, "source_x" => [s.x for s in prob.sources], "source_y" => [s.y for s in prob.sources], ) Δ = min(xs[2]-xs[1], ys[2]-ys[1]) n_c, n_s = length(lengthscales_c), length(lengthscales_s) println("\nGrid: $(N)×$(N), Δ = $(round(Δ, digits=4))") println("Default lengthscale (5Δ): $(round(5Δ, digits=4))") println("Sweeping: $(n_c) ℓ_c × $(n_s) ℓ_s = $(n_c * n_s) evaluations") println() # Warmup print("Warmup... ") compute_loo_score(prob, data; ρ=ρ, lengthscale_c=lengthscales_c[1], lengthscale_s=lengthscales_s[1]) println("done.\n") # 2D sweep loo_total = zeros(n_c, n_s) loo_pde = zeros(n_c, n_s) loo_obs = zeros(n_c, n_s) count = 0 for (j, ℓ_s) in enumerate(lengthscales_s) for (i, ℓ_c) in enumerate(lengthscales_c) count += 1 t = @elapsed r = compute_loo_score(prob, data; ρ=ρ, lengthscale_c=ℓ_c, lengthscale_s=ℓ_s) loo_total[i, j] = r.loo_total loo_pde[i, j] = r.loo_pde loo_obs[i, j] = r.loo_obs @printf("[%3d/%d] ℓ_c=%.3f ℓ_s=%.3f | total=%8.1f pde=%8.1f obs=%7.1f | %.2fs\n", count, n_c*n_s, ℓ_c, ℓ_s, r.loo_total, r.loo_pde, r.loo_obs, t) end end # Find optimum best = argmax(loo_total) best_ℓc = lengthscales_c[best[1]] best_ℓs = lengthscales_s[best[2]] println("\n" * "=" ^ 50) @printf("Best: ℓ_c = %.4f, ℓ_s = %.4f (LOO = %.1f)\n", best_ℓc, best_ℓs, loo_total[best]) @printf("Default (5Δ): %.4f\n", 5Δ) println("=" ^ 50) # Plots mkpath(output_dir) # 1D slices through the optimum fig = Figure(size=(900, 350), fontsize=12) # Slice: vary ℓ_c at best ℓ_s ax1 = Axis(fig[1, 1]; xlabel="ℓ_c (concentration)", ylabel="LOO score", title="Vary ℓ_c (ℓ_s = $(round(best_ℓs, digits=3)))") scatterlines!(ax1, lengthscales_c, loo_total[:, best[2]]; color=:black, label="Total", linewidth=2, markersize=5) scatterlines!(ax1, lengthscales_c, loo_pde[:, best[2]]; color=:royalblue, label="PDE", linewidth=1.5, markersize=4) scatterlines!(ax1, lengthscales_c, loo_obs[:, best[2]]; color=:crimson, label="Obs", linewidth=1.5, markersize=4) vlines!(ax1, [5Δ]; color=:gray50, linestyle=:dash, linewidth=1) axislegend(ax1; position=:rb) # Slice: vary ℓ_s at best ℓ_c ax2 = Axis(fig[1, 2]; xlabel="ℓ_s (source)", ylabel="LOO score", title="Vary ℓ_s (ℓ_c = $(round(best_ℓc, digits=3)))") scatterlines!(ax2, lengthscales_s, loo_total[best[1], :]; color=:black, label="Total", linewidth=2, markersize=5) scatterlines!(ax2, lengthscales_s, loo_pde[best[1], :]; color=:royalblue, label="PDE", linewidth=1.5, markersize=4) scatterlines!(ax2, lengthscales_s, loo_obs[best[1], :]; color=:crimson, label="Obs", linewidth=1.5, markersize=4) vlines!(ax2, [5Δ]; color=:gray50, linestyle=:dash, linewidth=1) axislegend(ax2; position=:rb) filename = joinpath(output_dir, "loo_slices_N$(N).pdf") save(filename, fig, px_per_unit=3) println("\nSaved: $filename") # 2D heatmap of total LOO if n_c > 1 && n_s > 1 fig2 = Figure(size=(500, 400), fontsize=12) ax = Axis(fig2[1, 1]; xlabel="ℓ_c (concentration)", ylabel="ℓ_s (source)", title="LOO total score (N=$N)") hm = heatmap!(ax, lengthscales_c, lengthscales_s, loo_total; colormap=:viridis) scatter!(ax, [best_ℓc], [best_ℓs]; color=:red, markersize=10, marker=:star5) scatter!(ax, [5Δ], [5Δ]; color=:white, markersize=8, marker=:xcross, strokewidth=1.5) Colorbar(fig2[1, 2], hm; label="LOO score") filename2 = joinpath(output_dir, "loo_heatmap_N$(N).pdf") save(filename2, fig2, px_per_unit=3) println("Saved: $filename2") end return (; loo_total, loo_pde, loo_obs, lengthscales_c, lengthscales_s, best_ℓc, best_ℓs) end # ============================================================================== # Output scale calibration # ============================================================================== function run_output_scale_experiment(; problem_path::String, N::Int, ρ::Float64, lengthscale_c::Float64, lengthscale_s::Float64, output_scales::Vector{Float64}, output_dir::String) println("=" ^ 60) println("Output Scale Calibration via LOO on PDE Residuals") println("=" ^ 60) prob = load_problem(problem_path) println(prob) xs, ys = setup_2d_grid(N, N, prob.domain) c_true, _ = solve_forward_problem(xs, ys, prob) obs_xs, obs_ys, _, obs_c = generate_observations(xs, ys, c_true, prob) data = Dict{String, Any}( "xs" => xs, "ys" => ys, "obs_x" => obs_xs, "obs_y" => obs_ys, "obs_c" => obs_c, "noise_std" => prob.noise_std, "source_x" => [s.x for s in prob.sources], "source_y" => [s.y for s in prob.sources], ) println("\nGrid: $(N)×$(N)") println("Fixed lengthscales: ℓ_c = $lengthscale_c, ℓ_s = $lengthscale_s") println("Sweeping $(length(output_scales)) output scale values") println() # Warmup print("Warmup... ") compute_loo_score(prob, data; ρ=ρ, lengthscale_c=lengthscale_c, lengthscale_s=lengthscale_s, output_scale=output_scales[1]) println("done.\n") scores_total = Float64[] scores_pde = Float64[] scores_obs = Float64[] for (i, σ²) in enumerate(output_scales) t = @elapsed r = compute_loo_score(prob, data; ρ=ρ, lengthscale_c=lengthscale_c, lengthscale_s=lengthscale_s, output_scale=σ²) push!(scores_total, r.loo_total) push!(scores_pde, r.loo_pde) push!(scores_obs, r.loo_obs) @printf("[%2d/%d] σ² = %8.4f | total=%9.1f pde=%9.1f obs=%7.1f | %.2fs\n", i, length(output_scales), σ², r.loo_total, r.loo_pde, r.loo_obs, t) end best_idx = argmax(scores_total) best_σ² = output_scales[best_idx] println("\n" * "=" ^ 50) @printf("Best output scale: σ² = %.4f (LOO = %.1f)\n", best_σ², scores_total[best_idx]) println("=" ^ 50) # Plot mkpath(output_dir) fig = Figure(size=(550, 400), fontsize=12) ax = Axis(fig[1, 1]; xlabel="Output scale σ²", ylabel="LOO pseudo-log-likelihood", xscale=log10, title="Output scale calibration (N=$N, ℓ_c=$lengthscale_c, ℓ_s=$lengthscale_s)", ) scatterlines!(ax, output_scales, scores_total; color=:black, label="Total", linewidth=2, markersize=6) scatterlines!(ax, output_scales, scores_pde; color=:royalblue, label="PDE", linewidth=1.5, markersize=5) scatterlines!(ax, output_scales, scores_obs; color=:crimson, label="Obs", linewidth=1.5, markersize=5) vlines!(ax, [best_σ²]; color=:crimson, linestyle=:dash, linewidth=1, label="LOO optimum") vlines!(ax, [1.0]; color=:gray50, linestyle=:dash, linewidth=1, label="Default (σ²=1)") axislegend(ax; position=:rb) filename = joinpath(output_dir, "output_scale_N$(N).pdf") save(filename, fig, px_per_unit=3) println("\nSaved: $filename") return (; output_scales, scores_total, scores_pde, scores_obs, best_σ²) end # ============================================================================== # CLI # ============================================================================== function parse_loo_args() s = ArgParseSettings(description = "LOO lengthscale selection on PDE residuals") @add_arg_table! s begin "--problem", "-p" help = "Path to problem TOML" default = joinpath(@__DIR__, "problems", "default.toml") "-N" help = "Grid size" arg_type = Int default = 31 "--rho" help = "Sparsity parameter" arg_type = Float64 default = 2.0 "--n-ls" help = "Number of lengthscale values per axis" arg_type = Int default = 10 "--ls-c-min" help = "Min lengthscale for concentration" arg_type = Float64 default = 0.05 "--ls-c-max" help = "Max lengthscale for concentration" arg_type = Float64 default = 0.5 "--ls-s-min" help = "Min lengthscale for source" arg_type = Float64 default = 0.05 "--ls-s-max" help = "Max lengthscale for source" arg_type = Float64 default = 0.5 "--output-dir", "-o" help = "Output directory" default = joinpath(@__DIR__, "results", "loo") "--mode" help = "Mode: 'lengthscale' or 'output-scale'" default = "output-scale" "--lengthscale-c" help = "Fixed ℓ_c for output-scale mode" arg_type = Float64 default = 0.2 "--lengthscale-s" help = "Fixed ℓ_s for output-scale mode" arg_type = Float64 default = 0.12 "--n-sigma" help = "Number of output scale values to test" arg_type = Int default = 15 "--sigma-min" help = "Min output scale (log10)" arg_type = Float64 default = -3.0 "--sigma-max" help = "Max output scale (log10)" arg_type = Float64 default = 2.0 end return parse_args(s) end function loo_main() args = parse_loo_args() if args["mode"] == "output-scale" σ²s = 10 .^ range(args["sigma-min"], args["sigma-max"], length=args["n-sigma"]) run_output_scale_experiment( problem_path = args["problem"], N = args["N"], ρ = args["rho"], lengthscale_c = args["lengthscale-c"], lengthscale_s = args["lengthscale-s"], output_scales = collect(σ²s), output_dir = args["output-dir"], ) else n = args["n-ls"] ℓs_c = collect(range(args["ls-c-min"], args["ls-c-max"], length=n)) ℓs_s = collect(range(args["ls-s-min"], args["ls-s-max"], length=n)) run_loo_experiment( problem_path = args["problem"], N = args["N"], ρ = args["rho"], lengthscales_c = ℓs_c, lengthscales_s = ℓs_s, output_dir = args["output-dir"], ) end end if abspath(PROGRAM_FILE) == @__FILE__ loo_main() end